Schauen wir uns das Thema protokollorientierte Programmierung genauer an. Der Einfachheit halber wurde das Material in drei Teile geteilt.
Dieses Material ist eine Kommentarübersetzung der Präsentation der WWDC 2016 . Entgegen der allgemeinen Überzeugung, dass Dinge "unter der Haube" dort bleiben sollten, ist es manchmal äußerst nützlich, herauszufinden, was dort passiert. Dies hilft, den Artikel korrekt und für den vorgesehenen Zweck zu verwenden.
In diesem Teil werden wichtige Probleme der objektorientierten Programmierung und deren Lösung durch POP behandelt. Alles wird in den Realitäten der Swift-Sprache berücksichtigt, die Details werden als "Haube" der Protokolle betrachtet.
OOP Probleme und warum brauchen wir POP
Es ist bekannt, dass es in OOP eine Reihe von Schwachstellen gibt, die die Programmausführung „überlasten“ können. Betrachten Sie die explizitesten und häufigsten:
- Zuordnung: Stapel oder Haufen?
- Referenzzählung: mehr oder weniger?
- Methodenversand: statisch oder dynamisch?
1.1 Zuordnung - Stapel
Stack ist eine ziemlich einfache und primitive Datenstruktur. Wir können oben auf den Stapel legen (drücken), wir können von oben auf den Stapel nehmen (Pop). Die Einfachheit ist, dass dies alles ist, was wir damit machen können.
Nehmen wir der Einfachheit halber an, dass jeder Stapel eine Variable (Stapelzeiger) hat. Es wird verwendet, um den oberen Rand des Stapels zu verfolgen und eine Ganzzahl (Integer) zu speichern. Daraus folgt, dass die Geschwindigkeit der Operationen mit dem Stapel gleich der Geschwindigkeit ist, mit der Integer in diese Variable umgeschrieben wird.
Drücken Sie - setzen Sie auf die Oberseite des Stapels, erhöhen Sie den Stapelzeiger;
pop - Stapelzeiger reduzieren.
Werttypen
Betrachten wir die Prinzipien der Stapeloperation in Swift unter Verwendung von Strukturen (struct).
In Swift sind Werttypen Strukturen (struct) und Enumerationen (enum), und Referenztypen sind Klassen (class) und Funktionen / Verschlüsse (func). Werttypen werden auf dem Stapel gespeichert, Referenztypen werden auf dem Heap gespeichert.
struct Point { var x, y: Double func draw() {...} } let point1 = Point(...)

- Wir platzieren die erste Struktur auf Stack
- Kopieren Sie den Inhalt der ersten Struktur
- Ändern Sie den Speicher der zweiten Struktur (die erste bleibt erhalten)
- Ende der Nutzung. Freier Speicher
1.2 Zuordnung - Haufen
Heap ist eine baumartige Datenstruktur. Das Thema der Heap-Implementierung ist hier nicht betroffen, aber wir werden versuchen, es mit dem Stack zu vergleichen.
Warum lohnt es sich, wenn möglich, Stack anstelle von Heap zu verwenden? Deshalb:
- Referenzzählung
- freie Speicherverwaltung und Suche nach Zuordnung
- Speicher für Freigabe neu schreiben
All dies ist nur ein kleiner Teil dessen, was Heap zum Funktionieren bringt und es im Vergleich zu Stack deutlich belastet.
Wenn wir beispielsweise freien Speicher auf dem Stapel benötigen, nehmen wir einfach den Wert des Stapelzeigers und erhöhen ihn (weil alles über dem Stapelzeiger im Stapel freier Speicher ist) - O (1) ist eine Operation, die zeitlich konstant ist.
Wenn wir freien Speicher auf Heap benötigen, beginnen wir mit dem entsprechenden Suchalgorithmus in der Datenbaumstruktur danach zu suchen. Im besten Fall haben wir eine O (logn) -Operation, die zeitlich nicht konstant ist und von bestimmten Implementierungen abhängt.
Tatsächlich ist Heap viel komplizierter: Seine Arbeit wird von einer Vielzahl anderer Mechanismen bereitgestellt, die im Darm von Betriebssystemen leben.
Es ist auch erwähnenswert, dass die Verwendung von Heap im Multithreading-Modus die Situation erheblich verschärft, da die Synchronisation der gemeinsam genutzten Ressource (Speicher) für verschiedene Threads sichergestellt werden muss. Dies wird durch die Verwendung von Sperren (Semaphoren, Spinlocks usw.) erreicht.
Referenztypen
Schauen wir uns an, wie Heap in Swift mithilfe von Klassen funktioniert.
class Point { var x, y: Double func draw() {...} } let point1 = Point(...)

1. Legen Sie den Klassenkörper auf Heap. Platzieren Sie den Zeiger auf diesen Körper auf dem Stapel.
- Kopieren Sie den Zeiger, der sich auf den Hauptteil der Klasse bezieht
- Wir verändern einen Körper einer Klasse
- Ende der Nutzung. Freier Speicher
1.3 Zuordnung - Ein kleines und "echtes" Beispiel
In einigen Situationen vereinfacht die Auswahl von Stapel nicht nur die Speicherbehandlung, sondern verbessert auch die Codequalität. Betrachten Sie ein Beispiel:
enum Color { case red, green, blue } enum Orientation { case left, right } enum Tail { case none, tail, bubble } var cache: [String: UIImage] = [] func makeBalloon(_ color: Color, _ orientation: Orientation, _ tail: Tail) -> UIImage { let key = "\(color):\(orientation):\(tail)" if let image = cache[key] { return image } ... }
Wenn das Cache-Wörterbuch einen Wert mit dem Schlüssel hat, gibt die Funktion einfach das zwischengespeicherte UIImage zurück.
Die Probleme dieses Codes sind:
Es ist keine gute Praxis, String als Schlüssel im Cache zu verwenden, da String am Ende "sich als alles herausstellen kann".
String ist eine Copy-on-Write-Struktur. Um ihre Dynamik zu implementieren, werden alle Zeichen auf dem Heap gespeichert. Daher ist String eine Struktur und wird im Stapel gespeichert, speichert jedoch den gesamten Inhalt auf dem Heap.
Dies ist erforderlich, um die Zeile ändern zu können (einen Teil der Zeile entfernen, dieser Zeile eine neue Zeile hinzufügen). Wenn alle Zeichen der Zeichenfolge auf dem Stapel gespeichert wären, wären solche Manipulationen unmöglich. In C sind Zeichenfolgen beispielsweise statisch. Dies bedeutet, dass die Größe einer Zeichenfolge zur Laufzeit nicht erhöht werden kann, da der gesamte Inhalt auf dem Stapel gespeichert ist. Klicken Sie hier , um die Zeilen in Swift zu kopieren und detaillierter zu analysieren.
Lösung:
Verwenden Sie die hier ganz offensichtliche Struktur anstelle der Zeichenfolge:
struct Attributes: Hashable { var color: Color var orientation: Orientation var tail: Tail }
Ändern Sie das Wörterbuch in:
var cache: [Attributes: UIImage] = []
String loswerden
let key = Attributes(color: color, orientation: orientation, tail: tail)
In der Attributstruktur werden alle Eigenschaften im Stapel gespeichert, da die Aufzählung im Stapel gespeichert ist. Dies bedeutet, dass Heap hier nicht implizit verwendet wird und jetzt die Schlüssel für das Cache-Wörterbuch sehr genau definiert sind, was die Sicherheit und Klarheit dieses Codes erhöht. Wir haben auch die implizite Verwendung von Heap beseitigt.
Fazit: Stack ist viel einfacher und schneller als Heap - die Wahl für die meisten Situationen liegt auf der Hand.
2. Referenzzählung
Wofür?
Swift sollte wissen, wann es möglich ist, ein Stück Speicher auf dem Heap freizugeben, das beispielsweise von einer Instanz einer Klasse oder Funktion belegt wird. Dies wird durch einen Linkzählmechanismus implementiert. Jede auf Heap gehostete Instanz (Klasse oder Funktion) verfügt über eine Variable, in der die Anzahl der Links gespeichert ist. Wenn keine Links zu einer Instanz vorhanden sind, gibt Swift einen dafür zugewiesenen Speicher frei.
Es ist zu beachten, dass für eine „qualitativ hochwertige“ Implementierung dieses Mechanismus viel mehr Ressourcen benötigt werden als zum Erhöhen und Verringern des Stapelzeigers. Dies liegt an der Tatsache, dass der Wert der Anzahl der Links von verschiedenen Threads aus zunehmen kann (weil Sie auf eine Klasse oder Funktion von verschiedenen Threads verweisen können). Vergessen Sie auch nicht, die Synchronisierung einer gemeinsam genutzten Ressource (variable Anzahl von Links) für verschiedene Threads (Spinlocks, Semaphoren usw.) sicherzustellen.
Stapel: freien Speicher finden und verwendeten Speicher freigeben - Stapelzeigeroperation
Heap: Suche nach freiem Speicher und Freigabe des verwendeten Speichers - Baumsuchalgorithmus und Referenzzählung.
In der Attributstruktur werden alle Eigenschaften im Stapel gespeichert, da die Aufzählung im Stapel gespeichert ist. Dies bedeutet, dass Heap hier nicht implizit verwendet wird und jetzt die Schlüssel für das Cache-Wörterbuch sehr genau definiert sind, was die Sicherheit und Klarheit dieses Codes erhöht. Wir haben auch die implizite Verwendung von Heap beseitigt.
Pseudocode
Betrachten Sie einen kleinen Pseudocode, um zu demonstrieren, wie die Linkzählung funktioniert:
class Point { var refCount: Int var x, y: Double func draw() {...} init(...) { ... self.refCount = 1 } } let point1 = Point(x: 0, y: 0) let point2 = point1 retain(point2)
Struct
Bei der Arbeit mit Strukturen ist ein Mechanismus wie die Referenzzählung einfach nicht erforderlich:
- Struktur nicht auf Heap gespeichert
- Struktur - bei Zuordnung kopiert, daher keine Referenzen
Links kopieren
Auch hier werden struct und alle anderen Werttypen in Swift bei der Zuweisung kopiert. Wenn die Struktur Links in sich selbst speichert, werden sie auch kopiert:
struct Label { let text: String let font: UIFont ... init() { ... text.refCount = 1 font.refCount = 1 } } let label = Label(text: "Hi", font: font) let label2 = label retain(label2.text._storage)
label und label2 haben gemeinsame Instanzen, die auf Heap gehostet werden:
Wenn also die Struktur Links in sich selbst speichert, verdoppelt sich beim Kopieren dieser Struktur die Anzahl der Links, was sich, falls nicht erforderlich, negativ auf die "Leichtigkeit" des Programms auswirkt.
Und wieder das "echte" Beispiel:
struct Attachment { let fileUrl: URL
Die Probleme dieser Struktur sind, dass sie hat:
- 3 Heap-Zuordnung
- Da String eine beliebige Zeichenfolge sein kann, sind Sicherheit und Codeklarheit betroffen.
Gleichzeitig sind uuid und mimeType streng definierte Dinge:
uuid ist eine Zeichenfolge im Format xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx
mimeType ist eine Zeichenfolge im Typ- / Erweiterungsformat.
Lösung
let uuid: UUID
Im Fall von mimeType funktioniert enum einwandfrei:
enum MimeType { init?(rawValue: String) { switch rawValue { case "image/jpeg": self = .jpeg case "image/png": self = .png case "image/gif": self = .gif default: return nil } } case jpeg, png, gif }
Oder besser und einfacher:
enum MimeType: String { case jpeg = "image/jpeg" case png = "image/png" case gif = "image/gif" }
Und vergessen Sie nicht zu ändern:
let mimeType: MimeType
3.1 Methodenversand
- Dies ist ein Algorithmus, der nach dem aufgerufenen Methodencode sucht
Bevor auf die Implementierung dieses Mechanismus eingegangen wird, sollte festgelegt werden, was eine „Nachricht“ und eine „Methode“ in diesem Zusammenhang sind:
- Eine Nachricht ist der Name, den wir an das Objekt senden. Argumente können weiterhin zusammen mit dem Namen gesendet werden.
circle.draw(in: origin)
Die Nachricht lautet draw - der Name der Methode. Das Empfängerobjekt ist ein Kreis. Origin ist auch ein Argument übergeben.
- Methode ist der Code, der als Antwort auf die Nachricht zurückgegeben wird.
Dann ist Method Dispatch ein Algorithmus, der entscheidet, welche Methode einer bestimmten Nachricht gegeben werden soll.
Genauer gesagt zum Methodenversand in Swift
Da wir von der übergeordneten Klasse erben und ihre Methoden überschreiben können, muss Swift genau wissen, welche Implementierung dieser Methode in einer bestimmten Situation aufgerufen werden muss.
class Parent { func me() { print("parent") } } class Child: Parent { override func me() { print("child") } }
Erstellen Sie einige Instanzen und rufen Sie die me-Methode auf:
let parent = Parent() let child = Child() parent.me()
Ein ziemlich offensichtliches und einfaches Beispiel. Und was ist, wenn:
let array: [Parent] = [Child(), Child(), Parent(), Child()] array.forEach { $0.me()
Dies ist nicht so offensichtlich und erfordert Ressourcen und einen bestimmten Mechanismus, um die korrekte Implementierung der me-Methode zu bestimmen. Ressourcen sind der Prozessor und RAM. Ein Mechanismus ist ein Methodenversand.
Mit anderen Worten, beim Methodenversand bestimmt das Programm, welche Methodenimplementierung aufgerufen werden soll.
Wenn eine Methode im Code aufgerufen wird, muss ihre Implementierung bekannt sein. Wenn sie es weiß
Zum Zeitpunkt der Kompilierung ist dies dann Static Dispatch. Wenn die Implementierung unmittelbar vor dem Aufruf festgelegt wird (zur Laufzeit zum Zeitpunkt der Codeausführung), handelt es sich um Dynamic Dispatch.
3.2 Methodenversand - Statischer Versand
Das Optimalste, da:
- Der Compiler weiß, welcher Codeblock (Methodenimplementierung) aufgerufen wird. Dank dessen kann er diesen Code so weit wie möglich optimieren und auf einen Mechanismus wie Inlining zurückgreifen.
- Außerdem führt das Programm zum Zeitpunkt der Codeausführung einfach diesen dem Compiler bekannten Codeblock aus. Es werden keine Ressourcen und Zeit aufgewendet, um die korrekte Implementierung der Methode zu bestimmen, was die Ausführung des Programms beschleunigt.
3.3 Methodenversand - Dynamischer Versand
Nicht das Optimalste, da:
- Die korrekte Implementierung der Methode wird zum Zeitpunkt der Programmausführung festgelegt, was Ressourcen und Zeit erfordert
- Keine Compiler-Optimierungen kommen nicht in Frage
3.4 Methodenversand - Inlining
Ein Mechanismus wie Inlining wurde erwähnt, aber was ist das? Betrachten Sie ein Beispiel:
struct Point { var x, y: Double func draw() {
- Die point.draw () -Methode und die drawAPoint-Funktion werden über Static Dispatch verarbeitet, da es keine Schwierigkeiten gibt, die richtige Implementierung für den Compiler zu ermitteln (da keine Vererbung vorliegt und eine Neudefinition nicht möglich ist).
- Da der Compiler weiß, was zu tun ist, kann er dies optimieren. Optimiert zunächst drawAPoint und ersetzt einfach den Funktionsaufruf durch seinen Code:
let point = Point(x: 0, y: 0) point.draw()
- optimiert dann point.draw, da die Implementierung dieser Methode auch bekannt ist:
let point = Point(x: 0, y: 0)
Wir haben einen Punkt erstellt, den Code der Zeichenmethode ausgeführt - der Compiler hat diese Funktionen einfach durch den erforderlichen Code ersetzt, anstatt sie aufzurufen. In Dynamic Dispatch ist dies etwas komplizierter.
3.5 Methodenversand - Vererbungsbasierter Polymorphismus
Warum brauche ich Dynamic Dispatch? Ohne sie ist es unmöglich, Methoden zu definieren, die von untergeordneten Klassen überschrieben werden. Polymorphismus wäre nicht möglich. Betrachten Sie ein Beispiel:
class Drawable { func draw() {} } class Point: Drawable { var x, y: Double override func draw() { ... } } class Line: Drawable { var x1, y1, x2, y2: Double override func draw() { ... } } var drawables: [Drawable] for d in drawables { d.draw() }
- Drawables-Array kann Punkt und Linie enthalten
- Intuitiv ist hier kein statischer Versand möglich. d in der for-Schleife kann Line oder Point sein. Der Compiler kann dies nicht bestimmen, und jeder Typ hat seine eigene Implementierung von draw
Wie funktioniert dann Dynamic Dispatch? Jedes Objekt hat ein Typfeld. Punkt (...). Typ ist gleich Punkt und Linie (...). Typ ist gleich Linie. Ebenfalls irgendwo im (statischen) Speicher des Programms befindet sich eine Tabelle (virtuelle Tabelle), in der für jeden Typ eine Liste mit seinen Methodenimplementierungen vorhanden ist.
In Objective-C wird das Typfeld als isa-Feld bezeichnet. Es ist auf jedem Objective-C-Objekt (NSObject) vorhanden.
Die Klassenmethode wird in einer virtuellen Tabelle gespeichert und hat keine Ahnung von sich selbst. Um self innerhalb dieser Methode zu verwenden, muss es dort übergeben werden (self).
Daher ändert der Compiler diesen Code in:
class Point: Drawable { ... override func draw(_ self: Point) { ... } } class Line: Drawable { ... override func draw(_ self: Line) { ... } } var drawables: [Drawable] for d in drawables { vtable[d.type].draw(d) }
Zum Zeitpunkt der Codeausführung müssen Sie in die virtuelle Tabelle schauen, dort die Klasse d finden, die Zeichenmethode aus der resultierenden Liste nehmen und ihr ein Objekt vom Typ d als self übergeben. Dies ist eine anständige Arbeit für einen einfachen Methodenaufruf, aber es muss sichergestellt werden, dass der Polymorphismus funktioniert. Ähnliche Mechanismen werden in jeder OOP-Sprache verwendet.
Methodenversand - Zusammenfassung
- Klassenmethoden werden standardmäßig über Dynamic Dispatch verarbeitet. Es müssen jedoch nicht alle Klassenmethoden über Dynamic Dispatch verarbeitet werden. Wenn die Methode nicht überschrieben wird, können Sie sie mit dem letzten Schlüsselwort überschreiben. Der Compiler weiß dann, dass diese Methode nicht überschrieben werden kann, und verarbeitet sie über Static Dispatch
- Nicht-Klassen-Methoden können nicht überschrieben werden (da struct und enum die Vererbung nicht unterstützen) und werden über Static Dispatch verarbeitet
OOP-Probleme - Zusammenfassung
Es ist notwendig, auf Kleinigkeiten zu achten wie:
- Beim Erstellen einer Instanz: Wo befindet sie sich?
- Wenn Sie mit dieser Instanz arbeiten: Wie funktioniert die Linkzählung?
- Beim Aufruf einer Methode: Wie wird sie verarbeitet?
Wenn wir für Dynamik bezahlen, ohne sie zu realisieren und ohne sie zu benötigen, wirkt sich dies negativ auf das implementierte Programm aus.
Polymorphismus ist eine sehr wichtige und nützliche Sache. Derzeit ist nur bekannt, dass der Polymorphismus in Swift in direktem Zusammenhang mit Klassen und Referenztypen steht. Wir wiederum sagen, dass der Unterricht langsam und schwer ist und die Struktur einfach und leicht ist. Ist Polymorphismus durch Strukturen möglich? Eine protokollorientierte Programmierung kann eine Antwort auf diese Frage geben.