Schnell unter der Haube: Generische Implementierung

Mit generischem Code können Sie flexible, wiederverwendbare Funktionen und Typen schreiben, die mit jedem Typ arbeiten können, abhängig von den von Ihnen definierten Anforderungen. Sie können Code schreiben, der Doppelarbeit vermeidet und seine Absicht klar und abstrahiert zum Ausdruck bringt. - Schnelle Dokumente

Jeder, der über Swift schrieb, verwendete Generika. Array , Dictionary , Set - die grundlegendsten Optionen für die Verwendung von Generika aus der Standardbibliothek. Wie sind sie im Inneren vertreten? Lassen Sie uns sehen, wie diese grundlegende Funktion der Sprache von Apple-Ingenieuren implementiert wird.


Generische Parameter können entweder durch Protokolle begrenzt oder nicht eingeschränkt werden, obwohl Generika grundsätzlich in Verbindung mit Protokollen verwendet werden, die beschreiben, was genau mit Methodenparametern oder Typfeldern getan werden kann.


Um Generika zu implementieren, verwendet Swift zwei Ansätze:


  1. Laufzeit - generischer Code ist ein Wrapper (Boxing).
  2. Compiletime-Way - Generischer Code wird zur Optimierung (Spezialisierung) in einen bestimmten Codetyp konvertiert.

Boxen


Stellen Sie sich eine einfache Methode mit einem unbegrenzten generischen Protokollparameter vor:


 func test<T>(value: T) -> T { let copy = value print(copy) return copy } 

Der schnelle Compiler erstellt einen einzelnen Codeblock, der aufgerufen wird, um mit einem beliebigen <T> . Das heißt, unabhängig davon, ob wir test(value: 1) oder test(value: "Hello") schreiben, wird derselbe Code aufgerufen und zusätzlich werden Informationen über den Typ <T> die alle erforderlichen Informationen enthalten, an die Methode übertragen .


Mit solchen unbegrenzten Protokollparametern kann wenig getan werden. Um diese Methode bereits zu implementieren, müssen Sie wissen, wie ein Parameter kopiert wird. Sie müssen seine Größe kennen, um Speicher zur Laufzeit zuzuweisen. Sie müssen wissen, wie Sie ihn zerstören, wenn der Parameter das Feld verlässt Sichtbarkeit. Die Value Witness Table ( VWT ) wird zum Speichern dieser Informationen verwendet. VWT wird in der Kompilierungsphase für alle Typen erstellt und der Compiler garantiert, dass es zur Laufzeit genau ein solches Objektlayout gibt. Ich möchte Sie daran erinnern, dass Strukturen in Swift als Wert und Klassen als Referenz übergeben werden, sodass für let copy = value mit T == MyClass und T == MyStruct .


Wertzeugen-Tabelle

Das heißt, das Aufrufen der test mit dem Übergeben der deklarierten Struktur dort sieht schließlich ungefähr so ​​aus:


 //  ,  metadata   let myStruct = MyStruct() test(value: myStruct, metadata: MyStruct.metadata) 

Etwas komplizierter wird es, wenn MyStruct selbst eine generische Struktur ist und die Form MyStruct<T> . Abhängig von <T> in MyStruct unterscheiden sich die Metadaten und MyStruct<Int> für die Typen MyStruct<Int> und MyStruct<Bool> . Dies sind zwei verschiedene Typen zur Laufzeit. Das Erstellen von Metadaten für jede mögliche Kombination von MyStruct und T äußerst ineffizient. Swift geht also in die andere Richtung und erstellt in solchen Fällen Metadaten zur Laufzeit unterwegs. Der Compiler erstellt ein Metadatenmuster für die generische Struktur, das mit einem bestimmten Typ kombiniert werden kann und als Ergebnis zur Laufzeit vollständige Typinformationen mit der richtigen VWT erhält.


 //   ,  metadata   func test<T>(value: MyStruct<T>, tMetadata: T.Type) { //       let myStructMetadata = get_generic_metadata(MyStruct.metadataPattern, tMetadata) ... } let myStruct = MyStruct<Int>() test(value: myStruct) //   test(value: myStruct, tMetadata: Int.metadata) //      

Wenn wir Informationen kombinieren, erhalten wir Metadaten, mit denen wir arbeiten können (kopieren, verschieben, zerstören).


Es ist immer noch etwas komplizierter, wenn Protokollbeschränkungen zu Generika hinzugefügt werden. Zum Beispiel beschränken wir <T> das Equatable Protokoll. Es sei eine sehr einfache Methode, die die beiden übergebenen Argumente vergleicht. Das Ergebnis ist nur ein Wrapper über die Vergleichsmethode.


 func isEquals<T: Equatable>(first: T, second: T) -> Bool { return first == second } 

Damit das Programm ordnungsgemäß funktioniert, müssen Sie einen Zeiger auf die Vergleichsmethode static func ==(lhs:T, rhs:T) . Wie bekomme ich es? Offensichtlich reicht die VWT Übertragung nicht aus, sie enthält diese Informationen nicht. Um dieses Problem zu lösen, gibt es eine Protocol Witness Table oder PWT . Dieses VWT ähnelt VWT und wird in der Kompilierungsphase für Protokolle erstellt und beschreibt diese Protokolle.


 isEquals(first: 1, second: 2) //   //     isEquals(first: 1, // 1 second: 2, metadata: Int.metadata, // 2 intIsEquatable: Equatable.witnessTable) // 3 

  1. Zwei Argumente bestanden
  2. Übergeben Sie Metadaten für Int damit Sie Objekte kopieren / verschieben / zerstören können
  3. Wir geben die Informationen weiter, die Int Equatable implementiert.

Wenn die Einschränkung die Implementierung eines anderen Protokolls erfordert, z. B. T: Equatable & MyProtocol , werden Informationen zu MyProtocol mit dem folgenden Parameter hinzugefügt:


 isEquals(..., intIsEquatable: Equatable.witnessTable, intIsMyProtocol: MyProtocol.witnessTable) 

Durch die Verwendung von Wrappern zur Implementierung von Generika können Sie alle erforderlichen Funktionen flexibel implementieren, der Overhead kann jedoch optimiert werden.


Generische Spezialisierung


Um die unnötige Notwendigkeit zu beseitigen, während der Programmausführung Informationen zu erhalten, wurde der sogenannte generische Spezialisierungsansatz verwendet. Sie können einen generischen Wrapper durch einen bestimmten Typ durch eine bestimmte Implementierung ersetzen. Zum Beispiel für zwei Aufrufe von isEquals(first: 1, second: 2) und isEquals(first: "Hello", second: "world") zusätzlich zur Hauptimplementierung "Wrapper" zwei zusätzliche völlig unterschiedliche Versionen der Methode für Int und für String .


Quellcode


Erstellen Sie zunächst eine generic.swift- Datei und schreiben Sie eine kleine generische Funktion, die wir berücksichtigen werden.


 func isEquals<T: Equatable>(first: T, second: T) -> Bool { return first == second } isEquals(first: 10, second: 11) 

Jetzt müssen Sie verstehen, was daraus schließlich ein Compiler wird.
Dies wird deutlich, wenn Sie unsere .swift- Datei in Swift Intermediate Language oder SIL kompilieren.


Ein bisschen über SIL und den Kompilierungsprozess


SIL ist das Ergebnis einer von mehreren Phasen der schnellen Kompilierung.


Compiler-Pipeline

Der Quellcode .swift wird an Lexer übergeben, der einen abstrakten Syntaxbaum ( AST ) der Sprache erstellt, auf dessen Grundlage die Typprüfung und die semantische Analyse des Codes durchgeführt werden. SilGen konvertiert AST in SIL , das als raw SIL , auf dessen Grundlage Code optimiert und ein optimiertes canonical SIL erhalten wird, das zur Konvertierung in IR an IRGen - ein spezielles Format, das LLVM versteht und das in .o- , . konvertiert wird, , . , . SIL sehen.


Und wieder zu den Generika


Erstellen Sie eine SIL Datei aus unserem Quellcode.


 swiftc generic.swift -O -emit-sil -o generic-sil.s 

Wir erhalten eine neue Datei mit der Erweiterung *.s . Wenn wir nach innen schauen, sehen wir viel weniger lesbaren Code als das Original, aber immer noch relativ klar.


Rohsil

Suchen Sie die Zeile mit dem Kommentar // isEquals<A>(first:second:) . Dies ist der Beginn der Beschreibung unserer Methode. Es endet mit einem Kommentar // end sil function '$s4main8isEquals5first6secondSbx_xtSQRzlF' . Ihr Name kann etwas anders sein. Lassen Sie uns die Methodenbeschreibung etwas analysieren.


  • %0 und %1 in Zeile 21 sind der first bzw. second Parameter
  • In Zeile 24 erhalten wir Typinformationen und geben sie an %4
  • In Zeile 25 erhalten wir einen Zeiger auf eine Vergleichsmethode aus Typinformationen
  • in Zeile 26 Wir rufen die Methode per Zeiger auf und übergeben ihr sowohl Parameter als auch Typinformationen
  • In Zeile 27 geben wir das Ergebnis an.

Als Ergebnis sehen wir: Um die notwendigen Aktionen bei der Implementierung der generischen Methode auszuführen, müssen wir während der Programmausführung Informationen aus der Beschreibung des Typs <T> .


Wir gehen direkt zur Spezialisierung über.


In der kompilierten SIL Datei folgt unmittelbar nach der Deklaration der allgemeinen isEquals Methode die Deklaration des Spezialisten für den Typ Int .



Spezialisierte SIL

In Zeile 39 wird anstelle des Abrufs der Methode zur Laufzeit aus den "cmp_eq_Int64" sofort die Methode zum Vergleichen von Ganzzahlen "cmp_eq_Int64" aufgerufen.


Damit sich die Methode „spezialisieren“ kann, muss die Optimierung aktiviert sein . Das müssen Sie auch wissen


Das Optimierungsprogramm kann nur dann eine Spezialisierung durchführen, wenn die Definition der generischen Deklaration im aktuellen Modul ( Quelle ) sichtbar ist.

Das heißt, die Methode kann nicht auf verschiedene Swift-Module spezialisiert werden (z. B. die generische Methode aus der Cocoapods-Bibliothek). Eine Ausnahme bildet die Standard-Swift-Bibliothek, in der grundlegende Typen wie Array , Set und Dictionary . Alle Generika der Basisbibliothek sind auf bestimmte Typen spezialisiert.


Hinweis: Die Attribute @inlinable und @usableFromInline wurden in Swift 4.2 implementiert, @usableFromInline der Optimierer die Methodenkörper anderer Module @inlinable und es die Möglichkeit gibt, sie zu spezialisieren. Dieses Verhalten wurde jedoch von mir nicht getestet ( Quelle ).


Referenzen


  1. Beschreibung der Generika
  2. Optimierung in Swift
  3. Detailliertere und ausführlichere Präsentation zum Thema.
  4. Englischer Artikel

Source: https://habr.com/ru/post/de451704/


All Articles