Interoperacyjność Pythona

Interoperacyjność API Pythona jest ważnym wymaganiem dla tego projektu. Chociaż Swift został zaprojektowany do integracji z innymi językami programowania (i ich środowiskami wykonawczymi), natura języków dynamicznych nie wymaga głębokiej integracji potrzebnej do obsługi języków statycznych. W szczególności język Python został zaprojektowany do osadzania w innych aplikacjach i posiada prosty interfejs API w języku C. Na potrzeby naszej pracy możemy zapewnić meta-embedding, który pozwala programom Swift korzystać z API Pythona tak, jakby bezpośrednio osadzały sam Python.

Aby to osiągnąć, skrypt/program Swift po prostu łączy interpreter Pythona ze swoim kodem. Nasz cel zmienia się z „jak pracujemy z interfejsami API Pythona” na pytanie „w jaki sposób sprawiamy, że interfejsy API Pythona wydają się naturalne, dostępne i łatwo dostępne z kodu Swift?” Nie jest to trywialny problem — istnieją znaczące różnice projektowe pomiędzy Swiftem i Pythonem, w tym podejście do obsługi błędów, superdynamiczny charakter Pythona, różnice w składni na poziomie powierzchni pomiędzy obydwoma językami oraz chęć uniknięcia „pójść na kompromis” z rzeczami, których programiści Swift przyzwyczaili się oczekiwać. Dbamy również o wygodę i ergonomię i uważamy, że niedopuszczalne jest wymaganie generatora owijarek takiego jak SWIG.

Ogólne podejście

Nasze ogólne podejście opiera się na obserwacji, że Python ma silną typizację, ale – jak w przypadku większości języków z dynamiczną typizacją – jego system typów jest wymuszany w czasie wykonywania. Chociaż podjęto wiele prób modernizacji systemu typu statycznego (np. mypy , pytype i inne ), opierają się one na systemach typu unsound, więc nie są pełnym rozwiązaniem, na którym możemy polegać, a ponadto działają przeciwko wielu założeń projektowych, które sprawiają, że Python i jego biblioteki są naprawdę świetne.

Wiele osób postrzega Swift jako język o typie statycznym i dlatego dochodzi do wniosku, że właściwym rozwiązaniem jest wepchnięcie płynnej formy Pythona do statycznie zdefiniowanej dziury. Jednak inni zdają sobie sprawę, że Swift łączy zalety potężnego systemu typów statycznych z (często niedocenianym!) systemem typów dynamicznych. Zamiast próbować narzucić systemowi typów dynamicznych Pythona coś, czym nie jest, postanawiamy spotkać się z Pythonem tam, gdzie jest i w pełni przyjąć jego podejście z typowaniem dynamicznym.

Efektem końcowym jest to, że możemy osiągnąć bardzo naturalne środowisko Pythona – bezpośrednio w kodzie Swift. Oto przykład tego, jak to wygląda; skomentowany kod pokazuje składnię czystego Pythona dla porównania:

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)

Jak widać, składnia tutaj jest natychmiast zrozumiała dla programisty Pythona: główne różnice polegają na tym, że Swift wymaga zadeklarowania wartości przed użyciem (za pomocą let lub var ) oraz że zdecydowaliśmy się umieścić wbudowane funkcje Pythona, takie jak import , type , slice itp. w Python. przestrzeni nazw (po prostu po to, aby uniknąć zaśmiecania zakresu globalnego). Jest to wynik świadomej równowagi pomiędzy próbą sprawienia, aby Python wydawał się naturalny i znajomy, przy jednoczesnym zachowaniu globalnego projektu języka Swift.

Ta linia została ustanowiona poprzez prosty wymóg: nie powinniśmy polegać na żadnym kompilatorze ani funkcjach języka specyficznych dla Pythona, aby osiągnąć interoperację z Pythonem - powinna ona zostać całkowicie zaimplementowana jako biblioteka Swift. W końcu chociaż Python jest niezwykle ważny dla społeczności uczących się maszyn, istnieją inne dynamiczne języki (Javascript, Ruby itp.), które mają silne oparcie w innych domenach i nie chcemy, aby każda z tych domen narzucała nieskończony wzrost złożoności na język Swift.

Aktualną implementację naszej warstwy pomostowej możesz zobaczyć w Python.swift . To jest czysty kod Swift, który działa z niezmodyfikowanym Swiftem.

Ograniczenia tego podejścia

Ponieważ zdecydowaliśmy się przyjąć dynamiczną naturę Pythona w Swift, otrzymujemy zarówno zalety, jak i wady, jakie niosą ze sobą dynamiczne języki. W szczególności wielu programistów języka Swift przyzwyczaiło się oczekiwać niesamowitego uzupełniania kodu i polegać na nim, a także docenia wygodę wynikającą z faktu, że kompilator wyłapuje literówki i inne trywialne błędy w czasie kompilacji. W przeciwieństwie do tego programiści Pythona nie mają takich możliwości (zamiast tego błędy są zwykle wychwytywane w czasie wykonywania), a ponieważ uwzględniamy dynamiczną naturę Pythona, interfejsy API Pythona w Swift działają w ten sam sposób.

Po dokładnym rozważeniu ze społecznością Swift stało się jasne, że istnieje równowaga: ile filozofii i systemu wartości Swift można przenieść na ekosystem biblioteki Pythona... bez niszczenia tego, co jest prawdziwe i piękne w Pythonie i jego biblioteki? Ostatecznie doszliśmy do wniosku, że najlepszym kompromisem jest model zorientowany na Pythona: powinniśmy zaakceptować fakt, że Python jest językiem dynamicznym i że nigdy nie będzie i nigdy nie będzie miał doskonałego uzupełniania kodu i wykrywania błędów w czasie kompilacji statycznej.

Jak to działa

Mapujemy system typów dynamicznych Pythona na pojedynczy statyczny typ Swift o nazwie PythonObject i pozwalamy PythonObject na przyjmowanie dowolnej dynamicznej wartości Pythona w czasie wykonywania (podobnie jak w podejściu Abadi i in. ). PythonObject odpowiada bezpośrednio PyObject* używanemu w powiązaniach Pythona C i może zrobić wszystko, co wartość Pythona robi w Pythonie. Na przykład działa to tak, jak można się spodziewać w Pythonie:

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

Ponieważ nie chcemy narażać globalnego projektu Swifta, ograniczamy całe zachowanie Pythona do wyrażeń obejmujących ten typ PythonObject . Gwarantuje to, że semantyka normalnego kodu Swift pozostanie niezmieniona, nawet jeśli miesza, dopasowuje, łączy i przenika z wartościami Pythona.

Podstawowa interoperacyjność

Od wersji Swift 4.0 rozsądny poziom podstawowej interoperacyjności był już możliwy do bezpośredniego osiągnięcia dzięki istniejącym funkcjom językowym: po prostu definiujemy PythonObject jako strukturę Swift, która otacza prywatną klasę Swift PyReference , umożliwiając Swiftowi przejęcie odpowiedzialności za zliczanie referencji w Pythonie:

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

Podobnie możemy zaimplementować func + (i resztę obsługiwanych operatorów Pythona) na PythonObject w zakresie istniejącego interfejsu wykonawczego Pythona. Nasza implementacja wygląda następująco:

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

Sprawiamy również, że PythonObject jest zgodny z Sequence i innymi protokołami, umożliwiając działanie takiego kodu:

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

Co więcej, ponieważ PythonObject jest zgodny z MutableCollection , zyskujesz pełny dostęp do interfejsów API Swift dla Collections , w tym do funkcji takich jak map , filter , sort itp.

Konwersje do i z wartości Swift

Teraz, gdy Swift może reprezentować wartości Pythona i działać na nich, ważna staje się możliwość konwersji między natywnymi typami Swift, takimi jak Int i Array<Float> , a odpowiednikami Pythona. Jest to obsługiwane przez protokół PythonConvertible , z którym zgodne są podstawowe typy Swift, takie jak Int , oraz typy kolekcji Swift, takie jak Array i Dictionary , z którymi warunkowo są zgodne (o ile ich elementy są zgodne). Dzięki temu konwersje w naturalny sposób wpasowują się w model Swift.

Na przykład, jeśli wiesz, że potrzebujesz liczby całkowitej Swift lub chcesz przekonwertować liczbę całkowitą Swift na Python, możesz użyć:

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

Podobnie typy agregujące, takie jak tablice, działają dokładnie w ten sam sposób:

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

Pasuje to dokładnie do modelu, jakiego oczekiwałby programista Swift: nieudane konwersje są rzutowane na opcjonalne wyniki (podobnie jak konwersje „string na int”), zapewniając bezpieczeństwo i przewidywalność, których oczekują programiści Swift.

Wreszcie, ponieważ masz dostęp do pełnych możliwości Pythona, wszystkie normalne funkcje refleksyjne Pythona są również bezpośrednio dostępne, w tym Python.type , Python.id , Python.dir i moduł Python inspect .

Wyzwania dotyczące interoperacyjności

Powyższe wsparcie jest możliwe, ponieważ projekt Swifta ma na celu i docenia cel, jakim jest syntaktyczna rozszerzalność typów na poziomie biblioteki. Mamy również szczęście, że Python i Swift mają bardzo podobną składnię na poziomie powierzchni wyrażeń (operatorów i wywołań funkcji/metod). To powiedziawszy, napotkaliśmy kilka wyzwań związanych z ograniczeniami rozszerzalności składni Swifta 4.0 i zamierzonymi różnicami projektowymi, które musimy pokonać.

Dynamiczne wyszukiwanie członków

Chociaż Swift jest językiem ogólnie rozszerzalnym, prymitywne wyszukiwanie elementów członkowskich nie było funkcją rozszerzalną za pomocą biblioteki. W szczególności, biorąc pod uwagę wyrażenie postaci xy , typ x nie był w stanie kontrolować tego, co się dzieje, gdy uzyskano na nim dostęp do elementu członkowskiego y . Jeśli typ x statycznie zadeklarował element członkowski o nazwie y , wówczas to wyrażenie zostałoby rozwiązane, w przeciwnym razie zostałoby odrzucone przez kompilator.

W ramach ograniczeń Swifta zbudowaliśmy powiązanie , które działało wokół tego. Na przykład proste było zaimplementowanie dostępu do elementów członkowskich w kategoriach PyObject_GetAttrString i PyObject_SetAttrString języka Python. Pozwoliło to na kod taki jak:

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

Jednak prawdopodobnie wszyscy zgodzimy się, że nie osiąga to naszego celu, jakim jest zapewnienie naturalnego i ergonomicznego interfejsu do pracy z wartościami Pythona! Poza tym nie zapewnia żadnych możliwości pracy z Swift L-Values: nie ma sposobu, aby przeliterować odpowiednik ax += 1 . Razem te dwa problemy stanowiły znaczną lukę w wyrazistości.

Po dyskusji ze społecznością Swift rozwiązaniem tego problemu jest umożliwienie kodowi biblioteki zaimplementowania haka awaryjnego w celu obsługi nieudanego wyszukiwania elementów członkowskich. Ta funkcja istnieje w wielu dynamicznych językach, w tym w Objective-C , i jako taka zaproponowaliśmy i wdrożyliśmy SE-0195: Przedstawiamy zdefiniowane przez użytkownika typy „Dynamicznego wyszukiwania elementów członkowskich” , które pozwalają typowi statycznemu zapewnić rezerwową procedurę obsługi nierozwiązanych wyszukiwań. Propozycja ta została szczegółowo omówiona przez społeczność Swift w procesie Swift Evolution i ostatecznie została zaakceptowana. Jest dostarczany od wersji Swift 4.1.

W rezultacie nasza biblioteka interoperacyjności może zaimplementować następujący hak:

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

Co pozwala po prostu wyrazić powyższy kod jako:

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

... a składnia natural ax += 1 działa tak, jak się spodziewamy. Pokazuje to ogromną korzyść płynącą z możliwości wspólnego rozwijania całego stosu języka, jego bibliotek i aplikacji, aby osiągnąć cel.

Typy wywoływalne dynamicznie

Oprócz wyszukiwania członków mamy podobne wyzwanie, jeśli chodzi o wywoływanie wartości. W językach dynamicznych często istnieje pojęcie wartości „wywoływalnych” , które mogą przyjmować dowolny podpis, ale Swift 4.1 nie obsługuje takich rzeczy. Na przykład od wersji Swift 4.1 nasza biblioteka interoperacyjności może współpracować z interfejsami API języka Python za pośrednictwem interfejsu takiego jak ten:

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

Chociaż można to osiągnąć, wyraźnie nie osiąga się naszego celu, jakim jest wygoda i ergonomia.

Oceniając ten problem ze społecznością Swift i #2 , zauważamy, że Python i Swift obsługują zarówno nazwane, jak i nienazwane argumenty: nazwane argumenty są przekazywane jako słownik. Jednocześnie języki wywodzące się ze Smalltalk dodają dodatkowy błąd: odniesienia do metod są jednostką atomową, która zawiera podstawową nazwę metody wraz ze wszystkimi argumentami kluczowymi. Chociaż interoperacyjność z tym stylem języka nie jest ważna dla Pythona, chcemy mieć pewność, że Swift nie zostanie wrzucony w kąt, który uniemożliwiałby dobrą współpracę z Ruby, Squeak i innymi językami wywodzącymi się ze SmallTalk.

Nasze rozwiązanie, które zostało zaimplementowane w Swift 5 , polega na wprowadzeniu nowego atrybutu @dynamicCallable wskazującego, że typ (np. PythonObject ) może obsługiwać dynamiczne rozpoznawanie wywołań. W module współdziałania PythonKit została zaimplementowana i udostępniona funkcja @dynamicCallable .

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

Uważamy, że jest to całkiem przekonujące i zamyka pozostałą lukę w zakresie ekspresji i ergonomii, która istnieje w tych przypadkach. Wierzymy, że ta funkcja będzie dobrym rozwiązaniem dla Ruby, Squeak i innych języków dynamicznych, a także będzie ogólnie użyteczną funkcją języka Swift, którą można zastosować w innych bibliotekach Swift.

Obsługa wyjątków a obsługa błędów

Podejście Pythona do obsługi wyjątków jest podobne do C++ i wielu innych języków, gdzie każde wyrażenie może zgłosić wyjątek w dowolnym momencie, a osoby wywołujące mogą zdecydować się na ich obsługę (lub nie) niezależnie. W przeciwieństwie do tego, podejście Swift do obsługi błędów sprawia, że ​​„możliwość rzucania” jest wyraźną częścią kontraktu API metody i zmusza osoby wywołujące do obsługi (lub przynajmniej potwierdzenia) , że błąd może zostać zgłoszony.

Jest to nieodłączna luka między tymi dwoma językami i nie chcemy zamazywać tej różnicy za pomocą rozszerzenia językowego. Nasze obecne rozwiązanie tego problemu opiera się na obserwacji, że chociaż dowolne wywołanie funkcji może wywołać efekt, większość wywołań tego nie robi. Co więcej, biorąc pod uwagę, że Swift wyraźnie określa obsługę błędów w języku, rozsądne jest, aby programista Python-in-Swift zastanowił się również, gdzie spodziewa się błędów, które można zgłosić i przechwycić. Robimy to za pomocą jawnej projekcji .throwing na PythonObject . Oto przykład:

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

I oczywiście integruje się to ze wszystkimi normalnymi mechanizmami zapewnianymi przez obsługę błędów Swift, w tym z możliwością użycia try? jeśli chcesz obsłużyć błąd, ale nie przejmują się szczegółami zawartymi w wyjątku.

Obecne wdrożenie i stan

Jak wspomniano powyżej, nasza bieżąca implementacja biblioteki interoperacyjności języka Python jest dostępna w serwisie GitHub w pliku Python.swift . W praktyce odkryliśmy, że sprawdza się to dobrze w wielu przypadkach użycia. Jednak brakuje kilku rzeczy, które musimy dalej rozwijać i wymyślać:

Krojenie w Pythonie jest bardziej ogólne niż składnia krojenia w Swift. W tej chwili możesz uzyskać do niego pełny dostęp poprzez funkcję Python.slice(a, b, c) . Powinniśmy jednak podłączyć normalną składnię zakresu a...b ze Swifta i może być interesujące rozważenie zaimplementowania operatorów kroczących jako rozszerzenia tej podstawowej składni zakresu. Musimy zbadać i wybrać właściwy model do wykorzystania w podklasach klas Pythona. Obecnie nie ma możliwości, aby struktura taka jak PythonObject działała z dopasowywaniem wzorców krotek, dlatego używamy właściwości projekcji, takich jak .tuple2 . Jeśli stanie się to problemem w praktyce, możemy rozważyć dodanie tego do Swifta, ale obecnie nie uważamy, że będzie to na tyle problem, aby warto go było rozwiązać w najbliższej przyszłości.

Podsumowanie i wnioski

Dobrze czujemy się w tym kierunku i uważamy, że jest kilka interesujących aspektów tej pracy: to wspaniale, że nie ma żadnych zmian specyficznych dla Pythona w kompilatorze lub języku Swift. Jesteśmy w stanie osiągnąć dobrą interoperacyjność Pythona poprzez bibliotekę napisaną w Swift, komponując funkcje językowe niezależne od Pythona. Wierzymy, że inne społeczności będą w stanie skomponować ten sam zestaw funkcji, aby bezpośrednio zintegrować się z dynamicznymi językami (i ich środowiskami wykonawczymi), które są ważne dla innych społeczności (np. JavaScript, Ruby itp.).

Innym interesującym aspektem tej pracy jest to, że obsługa Pythona jest całkowicie niezależna od innej logiki TensorFlow i automatycznego różnicowania, którą budujemy w ramach Swift dla TensorFlow. Jest to ogólnie przydatne rozszerzenie ekosystemu Swift, które może działać samodzielnie, przydatne do programowania po stronie serwera lub czegokolwiek innego, co chce współpracować z istniejącymi interfejsami API języka Python.