Interoperabilidad de Python

La interoperabilidad de la API de Python es un requisito importante para este proyecto. Si bien Swift está diseñado para integrarse con otros lenguajes de programación (y sus tiempos de ejecución), la naturaleza de los lenguajes dinámicos no requiere la integración profunda necesaria para admitir lenguajes estáticos. Python en particular está diseñado para integrarse en otras aplicaciones y tiene una interfaz API C simple . Para los propósitos de nuestro trabajo, podemos proporcionar una metaincrustación, que permite a los programas Swift usar las API de Python como si estuvieran incrustando directamente el propio Python.

Para lograr esto, el script/programa Swift simplemente vincula el intérprete de Python a su código. Nuestro objetivo cambia de "cómo trabajamos con las API de Python" a una pregunta de "¿cómo hacemos que las API de Python se sientan naturales, accesibles y fáciles de alcanzar desde el código Swift?" Este no es un problema trivial: existen diferencias de diseño significativas entre Swift y Python, incluidos sus enfoques para el manejo de errores, la naturaleza superdinámica de Python, las diferencias en la sintaxis a nivel superficial entre los dos lenguajes y el deseo de no "comprometer" las cosas que los programadores de Swift esperan. También nos preocupamos por la comodidad y la ergonomía y creemos que es inaceptable exigir un generador de envoltorios como SWIG.

Enfoque global

Nuestro enfoque general se basa en la observación de que Python está fuertemente tipado pero, como la mayoría de los lenguajes de tipado dinámico, su sistema de tipos se aplica en tiempo de ejecución. Si bien ha habido muchos intentos de actualizar un sistema de tipo estático encima (por ejemplo, mypy , pytype y otros ), se basan en sistemas de tipo poco sólidos, por lo que no son una solución completa en la que podamos confiar y, además, van contra muchos de las premisas de diseño que hacen que Python y sus bibliotecas sean realmente geniales.

Mucha gente ve a Swift como un lenguaje tipado estáticamente y, por lo tanto, llega a la conclusión de que la solución correcta es calzar la forma fluida de Python en un agujero definido estáticamente. Sin embargo, otros se dan cuenta de que Swift combina los beneficios de un potente sistema de tipo estático con un sistema de tipo dinámico (¡a menudo subestimado!). En lugar de intentar forzar el sistema de tipos dinámicos de Python para que sea algo que no es, elegimos encontrarnos con Python donde está y adoptar plenamente su enfoque de tipos dinámicos.

El resultado final de esto es que podemos lograr una experiencia Python muy natural, directamente en código Swift. A continuación se muestra un ejemplo de cómo se ve esto; el código comentado muestra la sintaxis pura de Python para comparar:

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 puede ver, la sintaxis aquí es inmediatamente comprensible para un programador de Python: las principales diferencias son que Swift requiere que se declaren valores antes de su uso (con let o var ) y que elegimos incluir funciones integradas de Python como import , type , slice . etc bajo un Python. espacio de nombres (simplemente para evitar saturar el alcance global). Esto es el resultado de un equilibrio consciente entre intentar hacer que Python se sienta natural y familiar, sin comprometer el diseño global del lenguaje Swift.

Esta línea se establece a través de un requisito simple: no debemos depender de ningún compilador o característica del lenguaje específico de Python para lograr la interoperabilidad de Python; debe implementarse completamente como una biblioteca Swift. Después de todo, si bien Python es increíblemente importante para la comunidad de aprendizaje automático, existen otros lenguajes dinámicos (Javascript, Ruby, etc.) que tienen fuertes puntos de apoyo en otros dominios, y no queremos que cada uno de estos dominios imponga un aumento de complejidad sin fin. al lenguaje Swift.

Puede ver la implementación actual de nuestra capa puente en Python.swift . Este es código Swift puro que funciona con Swift no modificado.

Limitaciones de este enfoque

Debido a que elegimos adoptar la naturaleza dinámica de Python en Swift, obtenemos tanto las ventajas como las desventajas que traen consigo los lenguajes dinámicos. Específicamente, muchos programadores de Swift esperan y dependen de una finalización de código sorprendente y aprecian la comodidad de que el compilador detecte errores tipográficos y otros errores triviales en el momento de la compilación. Por el contrario, los programadores de Python no tienen estas posibilidades (en cambio, los errores generalmente se detectan en tiempo de ejecución) y, debido a que adoptamos la naturaleza dinámica de Python, las API de Python en Swift funcionan de la misma manera.

Después de una cuidadosa consideración con la comunidad de Swift, quedó claro que se trata de un equilibrio: qué parte de la filosofía y el sistema de valores de Swift se puede proyectar en el ecosistema de la biblioteca de Python... sin romper aquellas cosas que son verdaderas y hermosas sobre Python. y sus bibliotecas? Al final, llegamos a la conclusión de que un modelo centrado en Python es el mejor compromiso: debemos aceptar el hecho de que Python es un lenguaje dinámico, que nunca tendrá ni podrá tener una finalización de código perfecta y una detección de errores en tiempo de compilación estática.

Cómo funciona

Mapeamos el sistema de tipos dinámicos de Python en un único tipo Swift estático llamado PythonObject y permitimos que PythonObject adopte cualquier valor dinámico de Python en tiempo de ejecución (similar al enfoque de Abadi et al. ). PythonObject corresponde directamente a PyObject* utilizado en los enlaces de Python C y puede hacer cualquier cosa que haga un valor de Python en Python. Por ejemplo, esto funciona tal como se esperaría en 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 no queremos comprometer el diseño global de Swift, restringimos todo el comportamiento de Python a expresiones que involucran este tipo PythonObject . Esto garantiza que la semántica del código Swift normal permanezca sin cambios, incluso si se mezcla, coincide, interactúa y se entremezcla con valores de Python.

Interoperabilidad básica

A partir de Swift 4.0, ya se podía lograr directamente un nivel razonable de interoperabilidad básica a través de las características del lenguaje existente: simplemente definimos PythonObject como una estructura Swift que envuelve una clase privada Swift PyReference , lo que permite a Swift asumir la responsabilidad del recuento de referencias de 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
  ...
}

De manera similar, podemos implementar func + (y el resto de los operadores de Python compatibles) en PythonObject en términos de la interfaz de ejecución de Python existente. Nuestra implementación se ve así:

// 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...

También hacemos que PythonObject se ajuste a Sequence y otros protocolos, permitiendo que funcione código como este:

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

Además, debido a que PythonObject se ajusta a MutableCollection , obtienes acceso completo a las API de Swift para Colecciones , incluidas funciones como map , filter , sort , etc.

Conversiones hacia y desde valores Swift

Ahora que Swift puede representar y operar con valores de Python, se vuelve importante poder convertir entre tipos nativos de Swift como Int y Array<Float> y los equivalentes de Python. Esto lo maneja el protocolo PythonConvertible , al que se ajustan los tipos Swift básicos como Int , y los tipos de colección Swift como Array y Dictionary se ajustan condicionalmente (cuando sus elementos se ajustan). Esto hace que las conversiones encajen naturalmente en el modelo Swift.

Por ejemplo, si sabe que necesita un entero Swift o desea convertir un entero Swift a Python, puede usar:

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

De manera similar, los tipos agregados como las matrices funcionan exactamente de la misma manera:

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

Esto encaja exactamente en el modelo que un programador de Swift esperaría: las conversiones fallidas se proyectan en resultados opcionales (al igual que las conversiones de "cadena a int"), proporcionando la seguridad y previsibilidad que esperan los programadores de Swift.

Finalmente, debido a que tiene acceso a todo el poder de Python, todas las capacidades reflectantes normales de Python también están disponibles directamente, incluidas Python.type , Python.id , Python.dir y el módulo inspect de Python.

Desafíos de interoperabilidad

El soporte anterior es posible porque el diseño de Swift apunta y aprecia el objetivo de la extensibilidad sintáctica de tipos a nivel de biblioteca. También tenemos la suerte de que Python y Swift comparten una sintaxis a nivel de superficie muy similar para expresiones (operadores y llamadas a funciones/métodos). Dicho esto, encontramos un par de desafíos debido a las limitaciones de la extensibilidad de sintaxis de Swift 4.0 y las diferencias intencionales de diseño que debemos superar.

Búsqueda dinámica de miembros

Aunque Swift es un lenguaje generalmente extensible, la búsqueda de miembros primitivos no era una característica extensible de la biblioteca. Específicamente, dada una expresión de forma xy , el tipo de x no podía controlar lo que sucedía cuando se accedía a un miembro y en él. Si el tipo de x hubiera declarado estáticamente un miembro llamado y , entonces esta expresión se resolvería; de lo contrario, el compilador la rechazaría.

Dentro de las limitaciones de Swift, creamos un enlace que solucionó este problema. Por ejemplo, fue sencillo implementar accesos de miembros en términos de PyObject_GetAttrString y PyObject_SetAttrString de Python. Este código permitido como:

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

Sin embargo, probablemente todos estemos de acuerdo en que esto no logra nuestro objetivo de proporcionar una interfaz natural y ergonómica para trabajar con valores de Python. Más allá de eso, no ofrece ninguna posibilidad de trabajar con Swift L-Values: no hay forma de deletrear el equivalente de ax += 1 . Juntos, estos dos problemas supusieron una importante brecha de expresividad.

Después de discutirlo con la comunidad Swift , la solución a este problema es permitir que el código de la biblioteca implemente un gancho alternativo para manejar las búsquedas fallidas de miembros. Esta característica existe en muchos lenguajes dinámicos, incluido Objective-C , y como tal, propusimos e implementamos SE-0195: Introducir tipos de "búsqueda dinámica de miembros" definidos por el usuario que permiten que un tipo estático proporcione un controlador alternativo para búsquedas no resueltas. Esta propuesta fue discutida extensamente por la comunidad Swift a través del proceso Swift Evolution y finalmente fue aceptada. Se envía desde Swift 4.1.

Como resultado de esto, nuestra biblioteca de interoperabilidad puede implementar el siguiente enlace:

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

Lo que permite que el código anterior se exprese simplemente como:

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

... y la sintaxis natural ax += 1 funciona tal como esperábamos. Esto muestra el enorme beneficio de poder hacer evolucionar la pila completa de un lenguaje, sus bibliotecas y aplicaciones juntas para lograr un objetivo.

Tipos dinámicamente invocables

Además de la búsqueda de miembros, tenemos un desafío similar cuando se trata de llamar a valores. Los lenguajes dinámicos a menudo tienen la noción de valores "invocables" , que pueden tomar una firma arbitraria, pero Swift 4.1 no admite tal cosa. Por ejemplo, a partir de Swift 4.1, nuestra biblioteca de interoperabilidad puede trabajar con las API de Python a través de una interfaz 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")])

Si bien es posible hacer cosas con esto, claramente no logramos nuestro objetivo de conveniencia y ergonomía.

Al evaluar este problema con la comunidad Swift y #2 , observamos que Python y Swift admiten argumentos con y sin nombre: los argumentos con nombre se pasan como un diccionario. Al mismo tiempo, los lenguajes derivados de Smalltalk añaden un detalle adicional: las referencias a los métodos son la unidad atómica, que incluye el nombre base del método junto con cualquier argumento de palabra clave. Si bien la interoperabilidad con este estilo de lenguaje no es importante para Python, queremos asegurarnos de que Swift no quede en un rincón que impida una gran interoperabilidad con Ruby, Squeak y otros lenguajes derivados de SmallTalk.

Nuestra solución, que se implementó en Swift 5 , es introducir un nuevo atributo @dynamicCallable para indicar que un tipo (como PythonObject ) puede manejar la resolución dinámica de llamadas. La función @dynamicCallable se implementó y estuvo disponible en el módulo de interoperabilidad de 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")

Creemos que esto es bastante convincente y cierra la brecha restante de expresividad y ergonomía que existe para estos casos. Creemos que esta característica será una buena solución para Ruby, Squeak y otros lenguajes dinámicos, además de ser una característica generalmente útil del lenguaje Swift que podría aplicarse a otras bibliotecas Swift.

Manejo de excepciones versus manejo de errores

El enfoque de Python para el manejo de excepciones es similar al de C++ y muchos otros lenguajes, donde cualquier expresión puede generar una excepción en cualquier momento y las personas que llaman pueden elegir manejarlas (o no) de forma independiente. Por el contrario, el enfoque de manejo de errores de Swift hace que la "capacidad de lanzamiento" sea una parte explícita del contrato API de un método y obliga a las personas que llaman a manejar (o al menos reconocer) que se puede generar un error.

Esta es una brecha inherente entre los dos idiomas y no queremos ocultar esta diferencia con una extensión de idioma. Nuestra solución actual a esto se basa en la observación de que, aunque cualquier llamada a función podría generarse, la mayoría de las llamadas no lo hacen. Además, dado que Swift hace explícito el manejo de errores en el lenguaje, es razonable que un programador de Python en Swift también piense en dónde espera que se puedan generar y detectar errores. Hacemos esto con una proyección .throwing explícita en PythonObject . He aquí un ejemplo:

  // 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)
  }

Y, por supuesto, esto se integra con todas las mecánicas normales proporcionadas por el manejo de errores de Swift, incluida la capacidad de usar try? si desea manejar el error pero no le importan los detalles incluidos en la excepción.

Implementación actual y estado

Como se mencionó anteriormente, nuestra implementación actual de la biblioteca de interoperabilidad de Python está disponible en GitHub en el archivo Python.swift . En la práctica, hemos descubierto que funciona bien en muchos casos de uso. Sin embargo, faltan algunas cosas que debemos seguir desarrollando y descubriendo:

El corte de Python es más general que la sintaxis de corte de Swift. Ahora puede obtener acceso completo a él a través de la función Python.slice(a, b, c) . Sin embargo, deberíamos incorporar la sintaxis de rango normal a...b de Swift, y podría ser interesante considerar implementar operadores de zancada como una extensión de esa sintaxis de rango básica. Necesitamos investigar y decidir cuál es el modelo correcto para usar en la subclasificación de clases de Python. Actualmente no hay forma de hacer que una estructura como PythonObject funcione con coincidencia de patrones de tuplas, por lo que usamos propiedades de proyección como .tuple2 . Si esto se convierte en un problema en la práctica, podemos investigar agregarlo a Swift, pero actualmente no creemos que sea un problema suficiente como para que valga la pena resolverlo en el corto plazo.

Resumen y conclusión

Nos sentimos bien con esta dirección y creemos que hay varios aspectos interesantes de este trabajo: es genial que no haya cambios específicos de Python en el compilador o lenguaje Swift. Podemos lograr una buena interoperabilidad de Python a través de una biblioteca escrita en Swift al componer características de lenguaje independientes de Python. Creemos que otras comunidades podrán componer el mismo conjunto de características para integrarse directamente con los lenguajes dinámicos (y sus tiempos de ejecución) que son importantes para otras comunidades (por ejemplo, JavaScript, Ruby, etc.).

Otro aspecto interesante de este trabajo es que la compatibilidad con Python es completamente independiente de otras lógicas de diferenciación automática y de TensorFlow que estamos creando como parte de Swift para TensorFlow. Esta es una extensión generalmente útil del ecosistema Swift que puede ser independiente, útil para el desarrollo del lado del servidor o cualquier otra cosa que quiera interoperar con las API de Python existentes.