Interoperabilità con Python

L'interoperabilità dell'API Python è un requisito importante per questo progetto. Sebbene Swift sia progettato per integrarsi con altri linguaggi di programmazione (e i relativi runtime), la natura dei linguaggi dinamici non richiede la profonda integrazione necessaria per supportare i linguaggi statici. Python in particolare è progettato per essere incorporato in altre applicazioni e dispone di una semplice interfaccia API C. Per gli scopi del nostro lavoro, possiamo fornire un meta-embedding, che consente ai programmi Swift di utilizzare le API Python come se incorporassero direttamente Python stesso.

Per ottenere ciò, lo script/programma Swift collega semplicemente l'interprete Python al suo codice. Il nostro obiettivo cambia da "come lavoriamo con le API Python" alla domanda "come possiamo rendere le API Python naturali, accessibili e facili da raggiungere dal codice Swift?" Questo non è un problema banale: ci sono differenze progettuali significative tra Swift e Python, inclusi i loro approcci alla gestione degli errori, la natura superdinamica di Python, le differenze nella sintassi a livello superficiale tra i due linguaggi e il desiderio di non farlo. "compromettere" le cose che i programmatori Swift si aspettano. Ci preoccupiamo anche della comodità e dell'ergonomia e riteniamo che sia inaccettabile richiedere un generatore di avvolgimento come SWIG.

Approccio globale

Il nostro approccio generale si basa sull'osservazione che Python è fortemente tipizzato ma, come la maggior parte dei linguaggi tipizzati dinamicamente, il suo sistema di tipi viene applicato in fase di esecuzione. Sebbene ci siano stati molti tentativi di adattare un sistema di tipi statici sopra di esso (ad esempio mypy , pytype e altri ), si basano su sistemi di tipi non validi quindi non sono una soluzione completa su cui possiamo fare affidamento, e inoltre si scontrano con molti delle premesse di progettazione che rendono Python e le sue librerie davvero eccezionali.

Molte persone vedono Swift come un linguaggio tipizzato staticamente e quindi giungono alla conclusione che la soluzione giusta è calzare la forma fluida di Python in un buco definito staticamente. Tuttavia, altri si rendono conto che Swift combina i vantaggi di un potente sistema di tipi statici con un sistema di tipi dinamici (spesso sottovalutato!). Invece di tentare di forzare il sistema di tipi dinamici di Python a essere qualcosa che non è, scegliamo di incontrare Python dove si trova e di abbracciare pienamente il suo approccio tipizzato dinamicamente.

Il risultato finale è che possiamo ottenere un'esperienza Python molto naturale, direttamente nel codice Swift. Ecco un esempio di come appare; il codice commentato mostra la sintassi Python pura per il confronto:

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)

Come puoi vedere, la sintassi qui è immediatamente comprensibile per un programmatore Python: le differenze principali sono che Swift richiede che i valori siano dichiarati prima dell'uso (con let o var ) e che abbiamo scelto di inserire funzioni integrate Python come import , type , slice ecc sotto un Python. namespace (semplicemente per evitare di ingombrare l'ambito globale). Questo è il risultato di un consapevole equilibrio tra il tentativo di rendere Python naturale e familiare, senza compromettere la progettazione globale del linguaggio Swift.

Questa linea viene stabilita attraverso un semplice requisito: non dovremmo dipendere da alcun compilatore o funzionalità del linguaggio specifici di Python per ottenere l'interoperabilità di Python: dovrebbe essere completamente implementato come una libreria Swift. Dopotutto, sebbene Python sia incredibilmente importante per la comunità del machine learning, ci sono altri linguaggi dinamici (Javascript, Ruby, ecc.) che hanno forti punti d'appoggio in altri domini e non vogliamo che ciascuno di questi domini imponga uno scorrimento di complessità infinito. sul linguaggio Swift.

Puoi vedere l'attuale implementazione del nostro livello ponte in Python.swift . Questo è puro codice Swift che funziona con Swift non modificato.

Limitazioni di questo approccio

Poiché scegliamo di abbracciare la natura dinamica di Python in Swift, otteniamo sia i pro che i contro che i linguaggi dinamici portano con sé. Nello specifico, molti programmatori Swift si aspettano e dipendono da uno straordinario completamento del codice e apprezzano la comodità di avere il compilatore in grado di rilevare errori di battitura e altri bug banali in fase di compilazione. Al contrario, i programmatori Python non hanno queste possibilità (invece, i bug vengono solitamente rilevati in fase di esecuzione) e poiché stiamo abbracciando la natura dinamica di Python, le API Python in Swift funzionano allo stesso modo.

Dopo un'attenta considerazione con la comunità di Swift, è diventato chiaro che si tratta di un equilibrio: quanto della filosofia e del sistema di valori di Swift può essere proiettato sull'ecosistema della libreria Python... senza rompere quelle cose che sono vere e belle di Python e le sue biblioteche? Alla fine, abbiamo concluso che un modello incentrato su Python è il miglior compromesso: dovremmo accettare il fatto che Python è un linguaggio dinamico, che non avrà mai e non potrà mai avere un completamento del codice e un rilevamento degli errori perfetti in fase di compilazione statica.

Come funziona

Mappiamo il sistema di tipi dinamici di Python in un singolo tipo Swift statico denominato PythonObject e consentiamo a PythonObject di assumere qualsiasi valore Python dinamico in fase di esecuzione (simile all'approccio di Abadi et al. ). PythonObject corrisponde direttamente a PyObject* utilizzato nei collegamenti Python C e può fare qualsiasi cosa faccia un valore Python in Python. Ad esempio, funziona proprio come ti aspetteresti in 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".

Poiché non vogliamo compromettere la progettazione globale di Swift, limitiamo tutto il comportamento di Python alle espressioni che coinvolgono questo tipo PythonObject . Ciò garantisce che la semantica del normale codice Swift rimanga invariata, anche se si mescola, si abbina, si interfaccia e si mescola con i valori Python.

Interoperabilità di base

A partire da Swift 4.0, un livello ragionevole di interoperabilità di base era già direttamente ottenibile attraverso le funzionalità del linguaggio esistenti: definiamo semplicemente PythonObject come una struttura Swift che racchiude una classe Swift PyReference privata, consentendo a Swift di assumersi la responsabilità del conteggio dei riferimenti 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
  ...
}

Allo stesso modo, possiamo implementare func + (e il resto degli operatori Python supportati) su PythonObject in termini dell'interfaccia runtime Python esistente. La nostra implementazione è simile alla seguente:

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

Rendiamo anche PythonObject conforme a Sequence e ad altri protocolli, consentendo il funzionamento di codice come questo:

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

Inoltre, poiché PythonObject è conforme a MutableCollection , ottieni pieno accesso alle API Swift per Collections , incluse funzioni come map , filter , sort , ecc.

Conversioni da e verso valori Swift

Ora che Swift può rappresentare e operare su valori Python, diventa importante essere in grado di eseguire la conversione tra tipi nativi Swift come Int e Array<Float> e gli equivalenti Python. Questo è gestito dal protocollo PythonConvertible , al quale si conformano i tipi Swift di base come Int , e ai tipi di raccolta Swift come Array e Dictionary si conformano condizionatamente (quando i loro elementi si conformano). Ciò fa sì che le conversioni si adattino naturalmente al modello Swift.

Ad esempio, se sai di aver bisogno di un intero Swift o desideri convertire un intero Swift in Python, puoi utilizzare:

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

Allo stesso modo, i tipi aggregati come gli array funzionano esattamente allo stesso modo:

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

Ciò si adatta esattamente al modello che un programmatore Swift si aspetterebbe: le conversioni fallibili vengono proiettate in risultati opzionali (proprio come lo sono le conversioni da "stringa a int"), fornendo la sicurezza e la prevedibilità che i programmatori Swift si aspettano.

Infine, poiché hai accesso a tutta la potenza di Python, sono direttamente disponibili anche tutte le normali capacità riflessive di Python, inclusi Python.type , Python.id , Python.dir e il modulo Python inspect .

Sfide di interoperabilità

Il supporto di cui sopra è possibile perché il design di Swift mira e apprezza l'obiettivo dell'estensibilità sintattica dei tipi a livello di libreria. Siamo anche fortunati che Python e Swift condividano una sintassi a livello superficiale molto simile per le espressioni (operatori e chiamate di funzioni/metodi). Detto questo, ci sono un paio di sfide che abbiamo incontrato a causa dei limiti dell'estensibilità della sintassi di Swift 4.0 e delle differenze di progettazione intenzionali che dobbiamo superare.

Ricerca dinamica dei membri

Sebbene Swift sia un linguaggio generalmente estensibile, la ricerca dei membri primitivi non era una funzionalità estensibile alla libreria. Nello specifico, data un'espressione nella forma xy , il tipo di x non era in grado di controllare cosa accadeva quando si accedeva a un membro y su di esso. Se il tipo di x avesse dichiarato staticamente un membro denominato y allora questa espressione verrebbe risolta, altrimenti verrebbe rifiutata dal compilatore.

Entro i limiti di Swift, abbiamo creato un'associazione che funzionasse attorno a questo. Ad esempio, è stato semplice implementare gli accessi ai membri in termini di PyObject_GetAttrString e PyObject_SetAttrString di Python. Ciò ha consentito codice come:

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

Tuttavia, probabilmente siamo tutti d'accordo sul fatto che ciò non raggiunge il nostro obiettivo di fornire un'interfaccia naturale ed ergonomica per lavorare con i valori Python! Oltre a ciò, non fornisce alcuna opportunità per lavorare con Swift L-Values: non c'è modo di scrivere l'equivalente di ax += 1 . Insieme, questi due problemi costituivano un significativo divario di espressività.

Dopo aver discusso con la comunità Swift , la soluzione a questo problema è consentire al codice della libreria di implementare un hook di fallback per gestire le ricerche dei membri non riuscite. Questa funzionalità esiste in molti linguaggi dinamici incluso Objective-C e, come tale, abbiamo proposto e implementato SE-0195: introdurre tipi di "ricerca membro dinamica" definiti dall'utente che consentono a un tipo statico di fornire un gestore di fallback per le ricerche non risolte. Questa proposta è stata discussa a lungo dalla comunità Swift attraverso il processo Swift Evolution e alla fine è stata accettata. È stato spedito da Swift 4.1.

Di conseguenza, la nostra libreria di interoperabilità è in grado di implementare il seguente hook:

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

Ciò consente di esprimere semplicemente il codice sopra riportato come:

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

... e la sintassi naturale ax += 1 funziona proprio come ci aspettiamo. Ciò dimostra l’enorme vantaggio di poter evolvere insieme l’intero stack di un linguaggio, le sue librerie e le applicazioni per raggiungere un obiettivo.

Tipi richiamabili dinamicamente

Oltre alla ricerca dei membri, dobbiamo affrontare una sfida simile quando si tratta di chiamare i valori. I linguaggi dinamici hanno spesso la nozione di valori "richiamabili" , che possono assumere una firma arbitraria, ma Swift 4.1 non supporta una cosa del genere. Ad esempio, a partire da Swift 4.1, la nostra libreria di interoperabilità è in grado di funzionare con le API Python attraverso un'interfaccia come questa:

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

Sebbene sia possibile ottenere risultati con questo, chiaramente non stiamo raggiungendo il nostro obiettivo di comodità ed ergonomia.

Valutando questo problema con la comunità Swift e #2 , osserviamo che Python e Swift supportano sia argomenti con nome che senza nome: gli argomenti con nome vengono passati come dizionario. Allo stesso tempo, i linguaggi derivati ​​da Smalltalk aggiungono un ulteriore problema: i riferimenti ai metodi sono l'unità atomica, che include il nome base del metodo insieme a eventuali argomenti di parole chiave. Sebbene l'interoperabilità con questo stile di linguaggio non sia importante per Python, vogliamo assicurarci che Swift non venga messo in un angolo che precluda una grande interoperabilità con Ruby, Squeak e altri linguaggi derivati ​​​​da SmallTalk.

La nostra soluzione, implementata in Swift 5 , consiste nell'introdurre un nuovo attributo @dynamicCallable per indicare che un tipo (come PythonObject ) può gestire la risoluzione dinamica delle chiamate. La funzionalità @dynamicCallable è stata implementata e resa disponibile nel modulo di interoperabilità 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")

Pensiamo che questo sia piuttosto convincente e colmi l'espressività rimanente e il divario ergonomico che esiste per questi casi. Riteniamo che questa funzionalità sarà una buona soluzione per Ruby, Squeak e altri linguaggi dinamici, oltre ad essere una funzionalità del linguaggio Swift generalmente utile che potrebbe essere applicabile ad altre librerie Swift.

Gestione delle eccezioni e gestione degli errori

L'approccio di Python alla gestione delle eccezioni è simile a quello del C++ e di molti altri linguaggi, dove qualsiasi espressione può generare un'eccezione in qualsiasi momento e i chiamanti possono scegliere di gestirla (o meno) in modo indipendente. Al contrario, l'approccio di gestione degli errori di Swift rende la "lanciabilità" una parte esplicita del contratto API di un metodo e forza i chiamanti a gestire (o almeno riconoscere) che un errore può essere generato.

Si tratta di un divario intrinseco tra le due lingue e non vogliamo mascherare questa differenza con un'estensione linguistica. La nostra attuale soluzione a questo problema si basa sull'osservazione che, anche se qualsiasi chiamata di funzione potrebbe sollevare un'eccezione, la maggior parte delle chiamate non lo fa. Inoltre, dato che Swift rende esplicita la gestione degli errori nel linguaggio, è ragionevole che un programmatore Python-in-Swift pensi anche a dove si aspetta che gli errori siano lanciabili e rilevabili. Lo facciamo con una proiezione .throwing esplicita su PythonObject . Ecco un esempio:

  // 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, naturalmente, questo si integra con tutti i normali meccanismi forniti dalla gestione degli errori di Swift, inclusa la possibilità di utilizzare try? se vuoi gestire l'errore ma non ti interessano i dettagli inclusi nell'eccezione.

Implementazione e stato attuali

Come accennato in precedenza, la nostra attuale implementazione della libreria di interoperabilità Python è disponibile su GitHub nel file Python.swift . In pratica, abbiamo scoperto che funziona bene per molti casi d’uso. Tuttavia, mancano alcune cose che dobbiamo continuare a sviluppare e capire:

L'affettamento di Python è più generale della sintassi dell'affettamento di Swift. In questo momento puoi averne pieno accesso tramite la funzione Python.slice(a, b, c) . Tuttavia, dovremmo inserire la normale sintassi dell'intervallo a...b di Swift, e potrebbe essere interessante considerare l'implementazione degli operatori di striding come estensione di quella sintassi dell'intervallo di base. Dobbiamo indagare e stabilire il modello giusto da utilizzare per la sottoclasse delle classi Python. Al momento non esiste un modo per far funzionare una struttura come PythonObject con la corrispondenza dei modelli di tuple, quindi utilizziamo proprietà di proiezione come .tuple2 . Se questo diventa un problema nella pratica, possiamo valutare di aggiungerlo a Swift, ma al momento non pensiamo che sarà un problema sufficiente per valere la pena risolverlo a breve termine.

Sommario e conclusione

Ci sentiamo bene in questa direzione e pensiamo che ci siano molti aspetti interessanti di questo lavoro: è fantastico che non ci siano modifiche specifiche di Python nel compilatore o nel linguaggio Swift. Siamo in grado di ottenere una buona interoperabilità con Python attraverso una libreria scritta in Swift componendo funzionalità del linguaggio indipendenti da Python. Crediamo che altre comunità saranno in grado di comporre lo stesso set di funzionalità per integrarsi direttamente con i linguaggi dinamici (e i loro tempi di esecuzione) che sono importanti per altre comunità (ad esempio JavaScript, Ruby, ecc.).

Un altro aspetto interessante di questo lavoro è che il supporto Python è completamente indipendente dagli altri TensorFlow e dalla logica di differenziazione automatica che stiamo creando come parte di Swift per TensorFlow. Si tratta di un'estensione generalmente utile all'ecosistema Swift che può essere autonomo, utile per lo sviluppo lato server o qualsiasi altra cosa che voglia interagire con le API Python esistenti.