Interopérabilité Python

L'interopérabilité des API Python est une exigence importante pour ce projet. Bien que Swift soit conçu pour s'intégrer à d'autres langages de programmation (et à leurs environnements d'exécution), la nature des langages dynamiques ne nécessite pas l'intégration approfondie nécessaire pour prendre en charge les langages statiques. Python en particulier est conçu pour être intégré à d'autres applications et possède une API d'interface C simple . Pour les besoins de notre travail, nous pouvons fournir une méta-intégration, qui permet aux programmes Swift d'utiliser les API Python comme s'ils intégraient directement Python lui-même.

Pour ce faire, le script/programme Swift relie simplement l'interpréteur Python à son code. Notre objectif passe de « comment travaillons-nous avec les API Python » à une question : « comment pouvons-nous rendre les API Python naturelles, accessibles et faciles à atteindre à partir du code Swift ? » Ce n'est pas un problème trivial : il existe des différences de conception significatives entre Swift et Python, notamment leurs approches de la gestion des erreurs, la nature super-dynamique de Python, les différences de syntaxe au niveau de la surface entre les deux langages et le désir de ne pas le faire. "compromettre" les choses auxquelles les programmeurs Swift s'attendent. Nous nous soucions également de la commodité et de l'ergonomie et pensons qu'il est inacceptable d'exiger un générateur d'emballage comme SWIG.

Approche générale

Notre approche globale est basée sur l'observation que Python est fortement typé mais que - comme la plupart des langages typés dynamiquement - son système de types est appliqué au moment de l'exécution. Bien qu'il y ait eu de nombreuses tentatives pour moderniser un système de types statiques par-dessus (par exemple mypy , pytype et autres ), ils s'appuient sur des systèmes de types peu fiables, ils ne constituent donc pas une solution complète sur laquelle nous pouvons compter, et de plus, ils vont à l'encontre de nombreux systèmes de types statiques. des prémisses de conception qui rendent Python et ses bibliothèques vraiment formidables.

De nombreuses personnes voient Swift comme un langage typé statiquement et concluent donc que la bonne solution consiste à insérer la forme fluide de Python dans un trou défini statiquement. Cependant, d'autres se rendent compte que Swift combine les avantages d'un puissant système de types statiques avec un système de types dynamiques (souvent sous-estimé !). Au lieu d'essayer de forcer le système de types dynamiques de Python à être quelque chose qu'il n'est pas, nous choisissons de rencontrer Python là où il se trouve et d'adopter pleinement son approche typée dynamiquement.

Le résultat final est que nous pouvons obtenir une expérience Python très naturelle – directement dans le code Swift. Voici un exemple de ce à quoi cela ressemble ; le code commenté montre la syntaxe Python pure à des fins de comparaison :

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)

Comme vous pouvez le voir, la syntaxe ici est immédiatement compréhensible pour un programmeur Python : les différences majeures sont que Swift nécessite que les valeurs soient déclarées avant utilisation (avec let ou var ) et que nous avons choisi de mettre des fonctions intégrées à Python comme import , type , slice etc sous un Python. espace de noms (simplement pour éviter d’encombrer la portée globale). Ceci est le résultat d’un équilibre conscient entre essayer de rendre Python naturel et familier, sans compromettre la conception globale du langage Swift.

Cette ligne est établie selon une exigence simple : nous ne devons pas dépendre d' un compilateur ou de fonctionnalités de langage spécifiques à Python pour réaliser l'interopérabilité de Python - elle doit être complètement implémentée en tant que bibliothèque Swift. Après tout, même si Python est extrêmement important pour la communauté du machine learning, il existe d'autres langages dynamiques (Javascript, Ruby, etc.) qui sont solidement implantés dans d'autres domaines, et nous ne voulons pas que chacun de ces domaines impose une complexité sans fin. sur le langage Swift.

Vous pouvez voir l'implémentation actuelle de notre couche de pontage dans Python.swift . Il s'agit de pur code Swift qui fonctionne avec Swift non modifié.

Limites de cette approche

Parce que nous choisissons d'adopter la nature dynamique de Python dans Swift, nous obtenons à la fois les avantages et les inconvénients que les langages dynamiques apportent. Plus précisément, de nombreux programmeurs Swift s'attendent et dépendent d'une complétion de code incroyable et apprécient le confort de voir le compilateur détecter les fautes de frappe et autres bogues triviaux au moment de la compilation. En revanche, les programmeurs Python n'ont pas ces possibilités (au lieu de cela, les bogues sont généralement détectés au moment de l'exécution), et parce que nous adoptons la nature dynamique de Python, les API Python dans Swift fonctionnent de la même manière.

Après un examen attentif avec la communauté Swift, il est devenu clair qu'il s'agissait d'un équilibre : dans quelle mesure la philosophie et le système de valeurs de Swift peuvent être projetés sur l'écosystème de la bibliothèque Python... sans briser ce qui est vrai et beau à propos de Python. et ses bibliothèques ? En fin de compte, nous avons conclu qu'un modèle centré sur Python est le meilleur compromis : nous devons accepter le fait que Python est un langage dynamique, qu'il n'aura jamais et ne pourra jamais avoir une complétion de code parfaite et une détection des erreurs au moment de la compilation statique.

Comment ça fonctionne

Nous mappons le système de types dynamiques de Python en un seul type Swift statique nommé PythonObject et permettons à PythonObject de prendre n'importe quelle valeur Python dynamique au moment de l'exécution (similaire à l'approche d' Abadi et al. ). PythonObject correspond directement à PyObject* utilisé dans les liaisons Python C et peut faire tout ce qu'une valeur Python fait en Python. Par exemple, cela fonctionne exactement comme on peut s’y attendre 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".

Parce que nous ne voulons pas compromettre la conception globale de Swift, nous limitons tout le comportement de Python aux expressions impliquant ce type PythonObject . Cela garantit que la sémantique du code Swift normal reste inchangée, même s'il se mélange, s'apparie, s'interface et se mélange avec les valeurs Python.

Interopérabilité de base

Depuis Swift 4.0, un niveau raisonnable d'interopérabilité de base était déjà directement réalisable grâce aux fonctionnalités du langage existantes : nous définissons simplement PythonObject comme une structure Swift qui encapsule une classe Swift PyReference privée, permettant à Swift de prendre en charge la responsabilité du comptage des références 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 même, nous pouvons implémenter func + (et le reste des opérateurs Python pris en charge) sur PythonObject en termes d'interface d'exécution Python existante. Notre implémentation ressemble à ceci :

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

Nous rendons également PythonObject conforme à Sequence et à d'autres protocoles, permettant à un code comme celui-ci de fonctionner :

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

De plus, comme PythonObject est conforme à MutableCollection , vous bénéficiez d'un accès complet aux API Swift pour Collections , y compris des fonctions telles que map , filter , sort , etc.

Conversions vers et depuis les valeurs Swift

Maintenant que Swift peut représenter et opérer sur les valeurs Python, il devient important de pouvoir convertir entre les types natifs Swift comme Int et Array<Float> et les équivalents Python. Ceci est géré par le protocole PythonConvertible - auquel les types Swift de base comme Int se conforment, et aux types de collection Swift comme Array et Dictionary se conforment conditionnellement (lorsque leurs éléments sont conformes). Cela permet aux conversions de s'intégrer naturellement dans le modèle Swift.

Par exemple, si vous savez que vous avez besoin d'un entier Swift ou si vous souhaitez convertir un entier Swift en Python, vous pouvez utiliser :

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

De même, les types d'agrégats tels que les tableaux fonctionnent exactement de la même manière :

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

Cela correspond exactement au modèle auquel un programmeur Swift s'attend : les conversions défaillantes sont projetées dans des résultats facultatifs (tout comme le sont les conversions "string to int"), offrant la sécurité et la prévisibilité attendues par les programmeurs Swift.

Enfin, comme vous avez accès à toute la puissance de Python, toutes les fonctionnalités de réflexion normales de Python sont également directement disponibles, notamment Python.type , Python.id , Python.dir et le module inspect Python.

Défis d'interopérabilité

La prise en charge ci-dessus est possible car la conception de Swift vise et apprécie l'objectif d'extensibilité syntaxique des types au niveau de la bibliothèque. Nous avons également la chance que Python et Swift partagent une syntaxe de surface très similaire pour les expressions (opérateurs et appels de fonction/méthode). Cela dit, nous avons rencontré quelques défis en raison des limites de l'extensibilité de la syntaxe de Swift 4.0 et des différences de conception intentionnelles que nous devons surmonter.

Recherche dynamique de membres

Bien que Swift soit un langage généralement extensible, la recherche de membres primitifs n'était pas une fonctionnalité extensible par la bibliothèque. Plus précisément, étant donné une expression du formulaire xy , le type de x était incapable de contrôler ce qui se passait lors de l'accès à un membre y . Si le type de x avait déclaré statiquement un membre nommé y alors cette expression serait résolue, sinon elle serait rejetée par le compilateur.

Dans le cadre des contraintes de Swift, nous avons construit une liaison qui contournait ce problème. Par exemple, il était simple d'implémenter les accès aux membres en termes de PyObject_GetAttrString et PyObject_SetAttrString de Python. Cela permettait du code comme :

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

Cependant, nous pouvons probablement tous convenir que cela n’atteint pas notre objectif de fournir une interface naturelle et ergonomique pour travailler avec les valeurs Python ! Au-delà de cela, cela n'offre aucune possibilité de travailler avec Swift L-Values ​​: il n'y a aucun moyen d'épeler l'équivalent de ax += 1 . Ensemble, ces deux problèmes constituaient un écart d'expressivité important.

Après discussion avec la communauté Swift , la solution à ce problème consiste à permettre au code de la bibliothèque d'implémenter un hook de secours pour gérer les recherches de membres ayant échoué. Cette fonctionnalité existe dans de nombreux langages dynamiques, y compris Objective-C , et en tant que tel, nous avons proposé et implémenté SE-0195 : Introduire des types de « recherche de membre dynamique » définis par l'utilisateur qui permettent à un type statique de fournir un gestionnaire de secours pour les recherches non résolues. Cette proposition a été longuement discutée par la communauté Swift à travers le processus Swift Evolution, et a finalement été acceptée. Il est expédié depuis Swift 4.1.

De ce fait, notre bibliothèque d'interopérabilité est capable d'implémenter le hook suivant :

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

Ce qui permet d'exprimer simplement le code ci-dessus comme suit :

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

... et la syntaxe naturelle ax += 1 fonctionne exactement comme prévu. Cela montre l’énorme avantage de pouvoir faire évoluer ensemble la pile complète d’un langage, ses bibliothèques et ses applications afin d’atteindre un objectif.

Types appelables dynamiquement

En plus de la recherche de membres, nous sommes confrontés à un défi similaire lorsqu'il s'agit d'appeler des valeurs. Les langages dynamiques ont souvent la notion de valeurs « appelables » , qui peuvent prendre une signature arbitraire, mais Swift 4.1 ne prend pas en charge une telle chose. Par exemple, depuis Swift 4.1, notre bibliothèque d'interopérabilité est capable de fonctionner avec les API Python via une interface comme celle-ci :

// 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")])

Même s’il est possible de faire avancer les choses avec cela, cela n’atteint clairement pas notre objectif de commodité et d’ergonomie.

En évaluant ce problème avec la communauté Swift et #2 , nous observons que Python et Swift prennent en charge les arguments nommés et non nommés : les arguments nommés sont transmis sous forme de dictionnaire. Dans le même temps, les langages dérivés de Smalltalk ajoutent un problème supplémentaire : les références de méthode sont l'unité atomique, qui inclut le nom de base de la méthode ainsi que tous les arguments de mot-clé. Bien que l'interopérabilité avec ce style de langage ne soit pas importante pour Python, nous voulons nous assurer que Swift ne se retrouve pas dans une situation qui empêcherait une bonne interopérabilité avec Ruby, Squeak et d'autres langages dérivés de SmallTalk.

Notre solution, qui a été implémentée dans Swift 5 , consiste à introduire un nouvel attribut @dynamicCallable pour indiquer qu'un type (comme PythonObject ) peut gérer la résolution d'appel dynamique. La fonctionnalité @dynamicCallable a été implémentée et rendue disponible dans le module d'interopérabilité 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")

Nous pensons que cela est assez convaincant et comble le déficit d’expressivité et d’ergonomie qui existe dans ces cas. Nous pensons que cette fonctionnalité sera une bonne solution pour Ruby, Squeak et d'autres langages dynamiques, tout en étant une fonctionnalité du langage Swift généralement utile qui pourrait être applicable à d'autres bibliothèques Swift.

Gestion des exceptions vs gestion des erreurs

L'approche de Python en matière de gestion des exceptions est similaire à celle du C++ et de nombreux autres langages, dans lesquels n'importe quelle expression peut lever une exception à tout moment et les appelants peuvent choisir de les gérer (ou non) indépendamment. En revanche, l'approche de gestion des erreurs de Swift fait de la « throwability » une partie explicite du contrat API d'une méthode et oblige les appelants à gérer (ou au moins à reconnaître) qu'une erreur peut être générée.

Il s'agit d'un écart inhérent entre les deux langages, et nous ne voulons pas masquer cette différence avec une extension de langage. Notre solution actuelle à ce problème s'appuie sur l'observation selon laquelle même si n'importe quel appel de fonction peut lancer, la plupart des appels ne le font pas. De plus, étant donné que Swift rend la gestion des erreurs explicite dans le langage, il est raisonnable pour un programmeur Python-in-Swift de réfléchir également à l'endroit où il s'attend à ce que les erreurs soient générées et interceptables. Nous faisons cela avec une projection explicite .throwing sur PythonObject . Voici un exemple :

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

Et bien sûr, cela s'intègre à tous les mécanismes normaux fournis par la gestion des erreurs Swift, y compris la possibilité d'utiliser try? si vous souhaitez gérer l'erreur mais ne vous souciez pas des détails inclus dans l'exception.

Mise en œuvre et état actuel

Comme mentionné ci-dessus, notre implémentation actuelle de la bibliothèque d'interopérabilité Python est disponible sur GitHub dans le fichier Python.swift . En pratique, nous avons constaté que cela fonctionne bien dans de nombreux cas d’utilisation. Cependant, il manque quelques éléments que nous devons continuer à développer et à comprendre :

Le découpage Python est plus général que la syntaxe de découpage de Swift. À l’heure actuelle, vous pouvez y accéder pleinement via la fonction Python.slice(a, b, c) . Cependant, nous devrions câbler la syntaxe normale de plage a...b de Swift, et il pourrait être intéressant d'envisager d'implémenter des opérateurs striding comme une extension de cette syntaxe de plage de base. Nous devons étudier et choisir le bon modèle à utiliser pour le sous-classement des classes Python. Il n'existe actuellement aucun moyen de faire fonctionner une structure comme PythonObject avec la correspondance de modèles de tuple, nous utilisons donc des propriétés de projection telles que .tuple2 . Si cela devient un problème dans la pratique, nous pouvons envisager de l'ajouter à Swift, mais nous ne pensons pas actuellement que ce sera un problème suffisant pour mériter d'être résolu à court terme.

Sommaire et conclusion

Nous sommes satisfaits de cette direction et pensons qu'il y a plusieurs aspects intéressants dans ce travail : c'est formidable qu'il n'y ait pas de changements spécifiques à Python dans le compilateur ou le langage Swift. Nous sommes en mesure d'obtenir une bonne interopérabilité avec Python grâce à une bibliothèque écrite en Swift en composant des fonctionnalités de langage indépendantes de Python. Nous pensons que d'autres communautés seront capables de composer le même ensemble de fonctionnalités pour s'intégrer directement aux langages dynamiques (et à leurs environnements d'exécution) qui sont importants pour d'autres communautés (par exemple JavaScript, Ruby, etc.).

Un autre aspect intéressant de ce travail est que la prise en charge de Python est complètement indépendante des autres logiques TensorFlow et de différenciation automatique que nous construisons dans le cadre de Swift pour TensorFlow. Il s'agit d'une extension généralement utile de l'écosystème Swift qui peut être autonome, utile pour le développement côté serveur ou tout autre élément souhaitant interagir avec les API Python existantes.