Interoperabilidade Python

A interoperabilidade da API Python é um requisito importante para este projeto. Embora o Swift seja projetado para integração com outras linguagens de programação (e seus tempos de execução), a natureza das linguagens dinâmicas não requer a integração profunda necessária para suportar linguagens estáticas. Python em particular foi projetado para ser incorporado em outras aplicações e possui uma API de interface C simples . Para os propósitos do nosso trabalho, podemos fornecer uma meta-incorporação, que permite que programas Swift usem APIs Python como se estivessem incorporando diretamente o próprio Python.

Para conseguir isso, o script/programa Swift simplesmente vincula o interpretador Python ao seu código. Nosso objetivo muda de "como trabalhamos com APIs Python" para uma questão de "como podemos fazer com que as APIs Python pareçam naturais, acessíveis e fáceis de alcançar a partir do código Swift?" Este não é um problema trivial - existem diferenças significativas de design entre Swift e Python, incluindo suas abordagens para tratamento de erros, a natureza superdinâmica do Python, as diferenças na sintaxe de nível superficial entre as duas linguagens e o desejo de não "comprometer" as coisas que os programadores Swift esperam. Também nos preocupamos com a conveniência e a ergonomia e achamos inaceitável exigir um gerador de embalagem como o SWIG.

Abordagem global

Nossa abordagem geral é baseada na observação de que Python é fortemente tipado, mas - como a maioria das linguagens de tipo dinâmico - seu sistema de tipos é aplicado em tempo de execução. Embora tenha havido muitas tentativas de modernizar um sistema de tipo estático em cima dele (por exemplo, mypy , pytype e outros ), eles dependem de sistemas de tipo doentios, portanto não são uma solução completa na qual podemos confiar e, além disso, contrariam muitos das premissas de design que tornam o Python e suas bibliotecas realmente excelentes.

Muitas pessoas veem o Swift como uma linguagem de tipo estaticamente e, portanto, chegam à conclusão de que a solução certa é encaixar a forma fluida do Python em um buraco definido estaticamente. No entanto, outros percebem que o Swift combina os benefícios de um poderoso sistema de tipos estáticos com um sistema de tipos dinâmicos (muitas vezes subestimado!). Em vez de tentar forçar o sistema de tipos dinâmicos do Python a ser algo que não é, optamos por encontrar o Python onde ele está e abraçar totalmente sua abordagem de tipos dinâmicos.

O resultado final disso é que podemos obter uma experiência Python muito natural – diretamente no código Swift. Aqui está um exemplo de como é; o código comentado mostra a sintaxe Python pura para comparação:

import PythonKit

// Python:
//    import numpy as np
//    a = np.arange(15).reshape(3, 5)
//    b = np.array([6, 7, 8])
let np = Python.import("numpy")
let a = np.arange(15).reshape(3, 5)
let b = np.array([6, 7, 8])

// Python:
//    import gzip as gzip
//    import pickle as pickle
let gzip = Python.import("gzip")
let pickle = Python.import("pickle")

// Python:
//    file = gzip.open("mnist.pkl.gz", "rb")
//    (images, labels) = pickle.load(file)
//    print(images.shape)  # (50000, 784)
let file = gzip.open("mnist.pkl.gz", "rb")
let (images, labels) = pickle.load(file).tuple2
print(images.shape) // (50000, 784)

Como você pode ver, a sintaxe aqui é imediatamente compreensível para um programador Python: as principais diferenças são que o Swift exige que os valores sejam declarados antes do uso (com let ou var ) e que optamos por colocar funções integradas do Python como import , type , slice etc sob um Python. namespace (simplesmente para evitar sobrecarregar o escopo global). Isso é resultado de um equilíbrio consciente entre tentar fazer o Python parecer natural e familiar, sem comprometer o design global da linguagem Swift.

Esta linha é estabelecida através de um requisito simples: não devemos depender de nenhum compilador específico do Python ou de recursos de linguagem para obter interoperabilidade com o Python - ela deve ser completamente implementada como uma biblioteca Swift. Afinal, embora Python seja extremamente importante para a comunidade de aprendizado de máquina, existem outras linguagens dinâmicas (Javascript, Ruby, etc.) que têm fortes bases em outros domínios, e não queremos que cada um desses domínios imponha uma complexidade infinita. para a linguagem Swift.

Você pode ver a implementação atual de nossa camada de ponte em Python.swift . Este é um código Swift puro que funciona com Swift não modificado.

Limitações desta abordagem

Como escolhemos abraçar a natureza dinâmica do Python em Swift, obtemos os prós e os contras que as linguagens dinâmicas trazem consigo. Especificamente, muitos programadores Swift esperam e dependem de uma incrível conclusão de código e apreciam o conforto de ter o compilador detectando erros de digitação e outros bugs triviais para eles em tempo de compilação. Em contraste, os programadores Python não têm essas possibilidades (em vez disso, os bugs geralmente são detectados em tempo de execução) e, como estamos adotando a natureza dinâmica do Python, as APIs Python no Swift funcionam da mesma maneira.

Após cuidadosa consideração com a comunidade Swift, ficou claro que este é um equilíbrio: quanto da filosofia e do sistema de valores do Swift pode ser projetado no ecossistema da biblioteca Python... sem quebrar as coisas que são verdadeiras e bonitas sobre Python e suas bibliotecas? No final, concluímos que um modelo centrado em Python é o melhor compromisso: devemos aceitar o fato de que Python é uma linguagem dinâmica, que nunca terá e nunca poderá ter conclusão de código perfeita e detecção de erros em tempo de compilação estática.

Como funciona

Mapeamos o sistema de tipos dinâmicos do Python em um único tipo Swift estático chamado PythonObject e permitimos que PythonObject assuma qualquer valor dinâmico do Python em tempo de execução (semelhante à abordagem de Abadi et al. ). PythonObject corresponde diretamente ao PyObject* usado nas ligações Python C e pode fazer qualquer coisa que um valor Python faz em Python. Por exemplo, isso funciona exatamente como você esperaria em Python:

var x: PythonObject = 42  // x is an integer represented as a Python value.
print(x + 4)         // Does a Python addition, then prints 46.

x = "stringy now"    // Python values can hold strings, and dynamically change Python type!
print("super " + x)  // Does a Python addition, then prints "super stringy now".

Como não queremos comprometer o design global do Swift, restringimos todo o comportamento do Python a expressões que envolvam este tipo PythonObject . Isso garante que a semântica do código Swift normal permaneça inalterada, mesmo que esteja misturando, combinando, fazendo interface e se misturando com valores Python.

Interoperabilidade básica

A partir do Swift 4.0, um nível razoável de interoperabilidade básica já era diretamente alcançável por meio dos recursos de linguagem existentes: simplesmente definimos PythonObject como uma estrutura Swift que envolve uma classe Swift PyReference privada, permitindo que Swift assuma a responsabilidade pela contagem de referências do Python:

/// Primitive reference to a Python value.  This is always non-null and always
/// owning of the underlying value.
private final class PyReference {
  var state: UnsafeMutablePointer<PyObject>

  init(owned: UnsafeMutablePointer<PyObject>) {
    state = owned
  }

  init(borrowed: UnsafeMutablePointer<PyObject>) {
    state = borrowed
    Py_IncRef(state)
  }

  deinit {
    Py_DecRef(state)
  }
}

// This is the main type users work with.
public struct PythonObject {
  /// This is a handle to the Python object the PythonObject represents.
  fileprivate var state: PyReference
  ...
}

Da mesma forma, podemos implementar func + (e o restante dos operadores Python suportados) em PythonObject em termos da interface de tempo de execução Python existente. Nossa implementação fica assim:

// Implement the + operator in terms of the standard Python __add__ method.
public static func + (lhs: PythonObject, rhs: PythonObject) -> PythonObject {
  return lhs.__add__.call(with: rhs)
}
// Implement the - operator in terms of the standard Python __sub__ method.
public static func - (lhs: PythonObject, rhs: PythonObject) -> PythonObject {
  return lhs.__sub__.call(with: rhs)
}
// Implement += and -= in terms of + and -, as usual.
public static func += (lhs: inout PythonObject, rhs: PythonObject) {
  lhs = lhs + rhs
}
public static func -= (lhs: inout PythonObject, rhs: PythonObject) {
  lhs = lhs - rhs
}
// etc...

Também tornamos PythonObject em conformidade com Sequence e outros protocolos, permitindo que códigos como este funcionem:

func printPythonCollection(_ collection: PythonObject) {
  for elt in collection {
    print(elt)
  }
}

Além disso, como PythonObject está em conformidade com MutableCollection , você obtém acesso total às APIs Swift para Collections , incluindo funções como map , filter , sort , etc.

Conversões de e para valores Swift

Agora que o Swift pode representar e operar em valores do Python, torna-se importante ser capaz de converter entre tipos nativos do Swift como Int e Array<Float> e os equivalentes do Python. Isso é tratado pelo protocolo PythonConvertible - ao qual os tipos básicos do Swift, como Int estão em conformidade, e os tipos de coleção do Swift, como Array e Dictionary , estão em conformidade condicionalmente (quando seus elementos estão em conformidade). Isso faz com que as conversões se encaixem naturalmente no modelo Swift.

Por exemplo, se você sabe que precisa de um número inteiro Swift ou deseja converter um número inteiro Swift em Python, você pode usar:

let pyInt = PythonObject(someSwiftInteger)     // Always succeeds.
if let swiftInt = Int(somePythonValue) {  // Succeeds if the Python value is convertible to Int.
  print(swiftInt)
}

Da mesma forma, tipos agregados como arrays funcionam exatamente da mesma maneira:

// This succeeds when somePythonValue is a collection of values that are convertible to Int.
if let swiftIntArray = Array<Int>(somePythonValue) {
  print(swiftIntArray)
}

Isso se encaixa exatamente no modelo que um programador Swift esperaria: conversões com falha são projetadas em resultados opcionais (assim como as conversões "string para int"), fornecendo a segurança e previsibilidade que os programadores Swift esperam.

Finalmente, como você tem acesso a todo o poder do Python, todos os recursos reflexivos normais do Python também estão disponíveis diretamente, incluindo Python.type , Python.id , Python.dir e o módulo inspect do Python.

Desafios de interoperabilidade

O suporte acima é possível porque o design do Swift visa e aprecia o objetivo da extensibilidade sintática de tipos em nível de biblioteca. Também temos sorte de que Python e Swift compartilhem uma sintaxe de nível superficial muito semelhante para expressões (operadores e chamadas de função/método). Dito isso, encontramos alguns desafios devido aos limites da extensibilidade da sintaxe do Swift 4.0 e às diferenças intencionais de design que precisamos superar.

Pesquisa dinâmica de membros

Embora Swift seja uma linguagem geralmente extensível, a pesquisa de membros primitivos não era um recurso extensível à biblioteca. Especificamente, dada uma expressão no formato xy , o tipo de x não foi capaz de controlar o que aconteceu quando um membro y foi acessado nela. Se o tipo de x tivesse declarado estaticamente um membro chamado y então esta expressão seria resolvida, caso contrário seria rejeitada pelo compilador.

Dentro das restrições do Swift, construímos uma ligação que funcionou em torno disso. Por exemplo, foi simples implementar acessos de membros em termos de PyObject_GetAttrString e PyObject_SetAttrString do Python. Este código permitido como:

// Python: a.x = a.x + 1
a.set(member: "x", to: a.get(member: "x") + 1)

No entanto, provavelmente todos podemos concordar que isso não atinge nosso objetivo de fornecer uma interface natural e ergonômica para trabalhar com valores Python! Além disso, não oferece nenhuma possibilidade de trabalhar com Swift L-Values: não há como soletrar o equivalente a ax += 1 . Juntos, esses dois problemas representavam uma lacuna significativa de expressividade.

Após discussão com a comunidade Swift , a solução para esse problema é permitir que o código da biblioteca implemente um gancho de fallback para lidar com pesquisas de membros com falha. Este recurso existe em muitas linguagens dinâmicas, incluindo Objective-C e, como tal, propusemos e implementamos SE-0195: Introduzir tipos de "Pesquisa de Membro Dinâmico" definidos pelo usuário que permitem que um tipo estático forneça um manipulador de fallback para pesquisas não resolvidas. Esta proposta foi amplamente discutida pela comunidade Swift através do processo Swift Evolution e foi finalmente aceita. Ele está disponível desde o Swift 4.1.

Como resultado disso, nossa biblioteca de interoperabilidade é capaz de implementar o seguinte gancho:

@dynamicMemberLookup
public struct PythonObject {
...
  subscript(dynamicMember member: String) -> PythonObject {
    get {
      return ... PyObject_GetAttrString(...) ...
    }
    set {
      ... PyObject_SetAttrString(...)
    }
  }
}

O que permite que o código acima seja simplesmente expresso como:

// Python: a.x = a.x + 1
a.x = a.x + 1

... e a sintaxe natural ax += 1 funciona exatamente como esperamos. Isso mostra o enorme benefício de poder evoluir toda a pilha de uma linguagem, suas bibliotecas e aplicativos juntos para atingir um objetivo.

Tipos que podem ser chamados dinamicamente

Além da pesquisa de membros, temos um desafio semelhante quando se trata de chamar valores. Linguagens dinâmicas geralmente têm a noção de valores "chamáveis" , que podem assumir uma assinatura arbitrária, mas o Swift 4.1 não tem suporte para tal coisa. Por exemplo, a partir do Swift 4.1, nossa biblioteca de interoperabilidade é capaz de trabalhar com APIs Python através de uma interface como esta:

// Python: a = np.arange(15).reshape(3, 5)
let a = np.arange.call(with: 15).reshape.call(with: 3, 5)

// Python: d = np.array([1, 2, 3], dtype="i2")
let d = np.array.call(with: [6, 7, 8], kwargs: [("dtype", "i2")])

Embora seja possível fazer as coisas com isso, claramente não estamos atingindo nosso objetivo de conveniência e ergonomia.

Avaliando este problema com a comunidade Swift e #2 , observamos que Python e Swift suportam argumentos nomeados e não nomeados: os argumentos nomeados são passados ​​como um dicionário. Ao mesmo tempo, as linguagens derivadas de Smalltalk acrescentam uma desvantagem adicional: as referências de método são a unidade atômica, que inclui o nome base do método junto com quaisquer argumentos de palavra-chave. Embora a interoperabilidade com esse estilo de linguagem não seja importante para Python, queremos ter certeza de que o Swift não seja encurralado que impeça uma grande interoperabilidade com Ruby, Squeak e outras linguagens derivadas de SmallTalk.

Nossa solução, que foi implementada no Swift 5 , é introduzir um novo atributo @dynamicCallable para indicar que um tipo (como PythonObject ) pode lidar com a resolução dinâmica de chamadas. O recurso @dynamicCallable foi implementado e disponibilizado no módulo de interoperabilidade PythonKit.

// Python: a = np.arange(15).reshape(3, 5)
let a = np.arange(15).reshape(3, 5)

// Python: d = np.array([1, 2, 3], dtype="i2")
let d = np.array([6, 7, 8], dtype: "i2")

Achamos que isso é bastante atraente e preenche a lacuna restante de expressividade e ergonomia que existe para esses casos. Acreditamos que este recurso será uma boa solução para Ruby, Squeak e outras linguagens dinâmicas, além de ser um recurso geralmente útil da linguagem Swift que pode ser aplicável a outras bibliotecas Swift.

Tratamento de exceções vs tratamento de erros

A abordagem do Python para tratamento de exceções é semelhante ao C++ e muitas outras linguagens, onde qualquer expressão pode lançar uma exceção a qualquer momento, e os chamadores podem optar por tratá-las (ou não) de forma independente. Em contraste, a abordagem de tratamento de erros do Swift torna a "lançabilidade" uma parte explícita do contrato de API de um método e força os chamadores a tratar (ou pelo menos reconhecer) que um erro pode ser lançado.

Esta é uma lacuna inerente entre as duas línguas, e não queremos encobrir esta diferença com uma extensão de linguagem. Nossa solução atual para isso baseia-se na observação de que, embora qualquer chamada de função possa ser lançada, a maioria das chamadas não o faz. Além disso, dado que o Swift torna o tratamento de erros explícito na linguagem, é razoável para um programador Python-in-Swift também pensar sobre onde eles esperam que os erros sejam lançáveis ​​e capturáveis. Fazemos isso com uma projeção .throwing explícita em PythonObject . Aqui está um exemplo:

  // Open a file.  If this fails, the program is terminated, just like an
  // unhandled exception in Python.

  // file = open("foo.txt")
  let file = Python.open("foo.txt")
  // blob = file.read()
  let blob = file.read()

  // Open a file, a thrown "file not found" exception is turned into a Swift error.
  do {
    let file = try Python.open.throwing.dynamicallyCall("foo.txt")
    let blob = file.read()
    ...
  } catch {
    print(error)
  }

E, claro, isso se integra a toda a mecânica normal fornecida pelo tratamento de erros do Swift, incluindo a capacidade de usar try? se você deseja tratar o erro, mas não se importa com os detalhes incluídos na exceção.

Implementação e status atuais

Conforme mencionado acima, nossa implementação atual da biblioteca de interoperabilidade Python está disponível no GitHub no arquivo Python.swift . Na prática, descobrimos que funciona bem para muitos casos de uso. No entanto, faltam algumas coisas que precisamos continuar desenvolvendo e descobrindo:

O fatiamento do Python é mais geral do que a sintaxe de fatiamento do Swift. Agora você pode obter acesso total a ele por meio da função Python.slice(a, b, c) . No entanto, devemos conectar a sintaxe normal de intervalo a...b do Swift, e pode ser interessante considerar a implementação de operadores striding como uma extensão dessa sintaxe básica de intervalo. Precisamos investigar e definir o modelo certo para usar na subclasse de classes Python. Atualmente não há como fazer uma estrutura como PythonObject funcionar com correspondência de padrão de tupla, então usamos propriedades de projeção como .tuple2 . Se isso se tornar um problema na prática, podemos investigar a adição disso ao Swift, mas atualmente não achamos que será um problema suficiente para valer a pena resolvê-lo no curto prazo.

Resumo e conclusão

Nos sentimos bem com essa direção e achamos que há vários aspectos interessantes neste trabalho: é ótimo que não haja mudanças específicas do Python no compilador ou na linguagem Swift. Conseguimos obter uma boa interoperabilidade do Python por meio de uma biblioteca escrita em Swift, compondo recursos de linguagem independentes do Python. Acreditamos que outras comunidades serão capazes de compor o mesmo conjunto de recursos para integração direta com as linguagens dinâmicas (e seus tempos de execução) que são importantes para outras comunidades (por exemplo, JavaScript, Ruby, etc).

Outro aspecto interessante deste trabalho é que o suporte ao Python é completamente independente do outro TensorFlow e da lógica de diferenciação automática que estamos construindo como parte do Swift para TensorFlow. Esta é uma extensão geralmente útil para o ecossistema Swift que pode ser independente, útil para desenvolvimento no lado do servidor ou qualquer outra coisa que queira interoperar com APIs Python existentes.