In Fortsetzung des Themas werden wir Protokolltypen und verallgemeinerten Code untersuchen.
Die folgenden Punkte werden dabei berücksichtigt:
- Implementierung von Polymorphismus ohne Vererbung und Referenztypen
- wie Objekte vom Typ Protokoll gespeichert und verwendet werden
- Wie funktioniert der Methodenversand mit ihnen?
Protokolltypen
Implementierung von Polymorphismus ohne Vererbung und Referenztypen:
protocol Drawable { func draw() } struct Point: Drawable { var x, y: Int func draw() { ... } } struct Line: Drawable { var x1, x2, y1, y2: Int func draw() { ... } } var drawbles = [Drawable]() for d in drawbles { d.draw() }
- Bezeichnen Sie das Drawable-Protokoll, das über eine Draw-Methode verfügt.
- Wir implementieren dieses Protokoll für Point und Line - jetzt können Sie damit wie mit Drawable umgehen (Aufruf der Draw-Methode)
Wir haben immer noch einen polymorphen Code. Das d-Element des drawables-Arrays verfügt über eine Schnittstelle, die im Drawable-Protokoll angegeben ist, jedoch über unterschiedliche Implementierungen seiner Methoden, die in Line und Point angegeben sind.
Das Hauptprinzip (Ad-hoc) des Polymorphismus: "Gemeinsame Schnittstelle - viele Implementierungen"
Dynamischer Versand ohne virtuelle Tabelle
Denken Sie daran, dass die Definition der korrekten Implementierung der Methode beim Arbeiten mit Klassen (Referenztypen) durch Dynamic Submission und eine virtuelle Tabelle erreicht wird. Jeder Klassentyp verfügt über eine virtuelle Tabelle, in der Implementierungen seiner Methoden gespeichert sind. Der dynamische Versand definiert die Methodenimplementierung für einen Typ, indem er in seine virtuelle Tabelle schaut. All dies ist aufgrund der Möglichkeit der Vererbung und des Überschreibens von Methoden erforderlich.
Bei Strukturen ist eine Vererbung sowie eine Neudefinition von Methoden nicht möglich. Auf den ersten Blick ist dann keine virtuelle Tabelle erforderlich, aber wie funktioniert dann der dynamische Versand? Wie kann ein Programm verstehen, welche Methode auf d.draw () aufgerufen wird?
Es ist anzumerken, dass die Anzahl der Implementierungen dieser Methode gleich der Anzahl der Typen ist, die dem Drawable-Protokoll entsprechen.
Protokoll Zeuge Tabelle
ist die Antwort auf diese Frage. Jeder Typ, der ein Protokoll implementiert, hat diese Tabelle. Wie eine virtuelle Tabelle für Klassen werden Implementierungen der für das Protokoll erforderlichen Methoden gespeichert.
Im Folgenden wird die Protokollzeugen-Tabelle als "Protokoll-Methodentabelle" bezeichnet.
Ok, jetzt wissen wir, wo wir nach Methodenimplementierungen suchen müssen. Es bleiben nur zwei Fragen:
- Wie finde ich die entsprechende Protokoll-Methodentabelle für ein Objekt, das dieses Protokoll implementiert hat? Wie finden Sie in unserem Fall diese Tabelle für das Element d des Zeichnungsarrays?
- Die Elemente des Arrays müssen dieselbe Größe haben (dies ist der Kern des Arrays). Wie kann dann ein zeichnbares Array diese Anforderung erfüllen, wenn es sowohl Linie als auch Punkt darin speichern kann und sie unterschiedliche Größen haben?
MemoryLayout.size(ofValue: Line(...))
Existenzieller Container
Um diese beiden Probleme zu beheben, verwendet Swift ein spezielles Speicherschema für Instanzen von Protokolltypen, die als existenzieller Container bezeichnet werden. Es sieht so aus:

Es werden 5 Maschinenwörter benötigt (im x64-Bit-System 5 * 8 = 40 Bit). Es ist in drei Teile gegliedert:
Wertepuffer - Speicherplatz für die Instanz selbst
vwt - Zeiger auf Value Witness Table
pwt - Zeiger auf die Protokollzeugen-Tabelle
Betrachten Sie alle drei Teile genauer:
Inhaltspuffer
Nur drei Maschinenwörter zum Speichern einer Instanz. Wenn die Instanz in den Inhaltspuffer passen kann, wird sie darin gespeichert. Wenn die Instanz mehr als 3 Maschinenwörter enthält, passt sie nicht in den Puffer und das Programm wird gezwungen, Speicher auf dem Heap zuzuweisen, die Instanz dort abzulegen und einen Zeiger auf diesen Speicher in den Inhaltspuffer zu setzen. Betrachten Sie ein Beispiel:
let point: Drawable = Point(...)
Point () belegt 2 Maschinenwörter und passt perfekt in den Wertepuffer - das Programm legt es dort ab:

let line: Drawable = Line(...)
Zeile () belegt 4 Maschinenwörter und kann nicht in einen Wertepuffer passen - das Programm weist ihm Speicher für den Heap zu und fügt einen Zeiger auf diesen Speicher im Wertepuffer hinzu:

ptr zeigt auf eine Instanz von Line () auf dem Heap:

Lebenszyklustabelle
Neben der Protokoll-Methodentabelle hat jede Tabelle, die das Protokoll enthält, diese Tabelle. Es enthält eine Implementierung von vier Methoden: Zuweisen, Kopieren, Zerstören, Freigeben. Diese Methoden steuern den gesamten Lebenszyklus eines Objekts. Betrachten Sie ein Beispiel:
- Beim Erstellen eines Objekts (Punkt (...) als Zeichenobjekt) wird die Zuweisungsmethode von T.Zh. dieses Objekt. Die Zuweisungsmethode entscheidet, wo der Inhalt des Objekts abgelegt werden soll (im Wertepuffer oder auf dem Heap). Wenn er auf dem Heap abgelegt werden soll, weist er die erforderliche Speichermenge zu
- Die Kopiermethode platziert den Inhalt des Objekts an der entsprechenden Stelle.
- Nach Abschluss der Arbeit mit dem Objekt wird die Destruct-Methode aufgerufen, die alle Verknüpfungszählungen, falls vorhanden, reduziert
- Nach der Zerstörung wird die Freigabemethode aufgerufen, die den auf dem Heap zugewiesenen Speicher freigibt, falls vorhanden
Protokoll Methodentabelle
Wie oben beschrieben, enthält es Implementierungen der Methoden, die das Protokoll für den Typ benötigt, an den diese Tabelle gebunden ist.
Existenzcontainer - Antworten
Wir haben also zwei Fragen beantwortet:
- Die Protokoll-Methodentabelle ist im Existential-Container dieses Objekts gespeichert und kann leicht von diesem abgerufen werden
- Wenn der Elementtyp des Arrays ein Protokoll ist, nimmt jedes Element dieses Arrays einen festen Wert von 5 Maschinenwörtern an - genau das ist für einen existenziellen Container erforderlich. Wenn der Inhalt des Elements nicht im Wertepuffer abgelegt werden kann, wird er auf dem Heap abgelegt. Wenn dies möglich ist, wird der gesamte Inhalt in den Wertepuffer gestellt. In jedem Fall erhalten wir, dass die Größe des Objekts mit dem Protokolltyp 5 Maschinenwörter (40 Bit) beträgt, und daraus folgt, dass alle Elemente des Arrays dieselbe Größe haben.
let line: Drawable = Line(...) MemoryLayout.size(ofValue: line)
Existenzcontainer - Beispiel
Betrachten Sie das Verhalten eines existenziellen Containers in diesem Code:
func drawACopy(local: Drawable) { local.draw() } let val: Drawable = Line(...) drawACopy(val)
Ein existenzieller Container kann folgendermaßen dargestellt werden:
struct ExistContDrawable { var valueBuffer: (Int, Int, Int) var vwt: ValueWitnessTable var pwt: ProtocolWitnessTable }
Pseudocode
Hinter den Kulissen nimmt die drawACopy-Funktion ExistContDrawable auf:
func drawACopy(val: ExistContDrawable) { ... }
Der Funktionsparameter wird manuell erstellt: Erstellen Sie einen Container, füllen Sie seine Felder aus dem empfangenen Argument:
func drawACopy(val: ExistContDrawable) { var local = ExistContDrawable() let vwt = val.vwt let pwt = val.pwt local.type = type local.pwt = pwt ... }
Wir entscheiden, wo der Inhalt gespeichert wird (im Puffer oder Heap). Wir rufen vwt.allocate und vwt.copy auf, um den lokalen Inhalt mit val zu füllen:
func drawACopy(val: ExistContDrawable) { ... vwt.allocateBufferAndCopy(&local, val) }
Wir rufen die Zeichenmethode auf und übergeben ihr einen Zeiger auf self (die projectBuffer-Methode entscheidet, wo sich self befindet - im Puffer oder auf dem Heap - und gibt den richtigen Zeiger zurück):
func drawACopy(val: ExistContDrawable) { ... pwt.draw(vwt.projectBuffer(&local)) }
Wir beenden die Arbeit mit lokalen. Wir säubern alle angesagten Links von lokalen. Die Funktion gibt einen Wert zurück - wir löschen den gesamten für drawACopy (Stapelrahmen) zugewiesenen Speicher:
func drawACopy(val: ExistContDrawable) { ... vwt.destructAndDeallocateBuffer(&local) }
Existenzieller Container - Zweck
Die Verwendung eines existenziellen Containers erfordert viel Arbeit - das obige Beispiel hat dies bestätigt - aber warum ist es überhaupt notwendig, was ist der Zweck? Ziel ist es, Polymorphismus mit Hilfe von Protokollen und den Typen, die diese implementieren, zu implementieren. In OOP verwenden wir abstrakte Klassen und erben von ihnen durch Überschreiben von Methoden. In EPP verwenden wir Protokolle und setzen deren Anforderungen um. Auch bei Protokollen ist die Implementierung von Polymorphismus eine große und energieaufwendige Aufgabe. Um "unnötige" Arbeit zu vermeiden, müssen Sie daher verstehen, wann Polymorphismus erforderlich ist und wann nicht.
Polymorphismus bei der Implementierung von EPP gewinnt durch die Tatsache, dass bei Verwendung von Strukturen keine konstante Referenzzählung erforderlich ist und keine Klassenvererbung erfolgt. Ja, alles ist sehr ähnlich. Klassen verwenden eine virtuelle Tabelle, um die Implementierung einer Methode zu bestimmen. Protokolle verwenden die Protokollmethode. Es werden Klassen auf den Haufen gelegt, manchmal können auch Strukturen dort platziert werden. Das Problem ist jedoch, dass so viele Zeiger wie möglich auf die auf dem Heap platzierte Klasse gerichtet werden können und eine Referenzzählung erforderlich ist und nur ein Zeiger auf die auf dem Heap platzierten Strukturen in einem existenziellen Container gespeichert ist.
In der Tat ist es wichtig zu beachten, dass eine Struktur, die in einem existenziellen Container gespeichert ist, die Semantik von Werttypen beibehält, unabhängig davon, ob sie auf dem Stapel oder dem Heap platziert wird. Die Lebenszyklustabelle ist für die Erhaltung der Semantik verantwortlich, da sie Methoden beschreibt, die die Semantik bestimmen.
Existenzieller Container - Gespeicherte Eigenschaften
Wir haben untersucht, wie eine protokollartige Variable von einer Funktion übergeben und verwendet wird. Betrachten wir, wie solche Variablen gespeichert werden:
struct Pair { init(_ f: Drawable, _ s: Drawable) { first = f second = s } var first: Drawable var second: Drawable } var pair = Pair(Line(), Point())
Wie werden diese beiden Drawable-Strukturen in der Pair-Struktur gespeichert? Was ist der Inhalt des Paares? Es besteht aus zwei existenziellen Containern - einem für den ersten und einem für den zweiten. Zeile passt nicht in den Puffer und wird auf den Heap gelegt. Punkt in den Puffer passen. Die Pair-Struktur kann auch Objekte unterschiedlicher Größe speichern:
pair.second = Line()
Jetzt wird auch der Inhalt von second auf den Heap gelegt, da er nicht in den Puffer passt. Überlegen Sie, wozu dies führen kann:
let aLine = Line(...) let pair = Pair(aLine, aLine) let copy = pair
Nach dem Ausführen dieses Codes erhält das Programm den folgenden Speicherstatus:

Wir haben 4 Speicherzuordnungen auf dem Heap, was nicht gut ist. Versuchen wir Folgendes zu beheben:
- Erstellen Sie eine analoge Klassenlinie
class LineStorage: Drawable { var x1, y1, x2, y2: Double func draw() {} }
- Wir verwenden es paarweise
let lineStorage = LineStorage(...) let pair = Pair(lineStorage, lineStorage) let copy = pair
Wir bekommen eine Platzierung auf dem Haufen und 4 Zeiger darauf:

Wir haben es aber mit referentiellem Verhalten zu tun. Das Ändern von copy.first wirkt sich auf pair.first aus (dasselbe gilt für .second), was nicht immer das ist, was wir wollen.
Indirektes Speichern und Kopieren bei Änderung (Copy-on-Write)
Davor wurde erwähnt, dass String eine Copy-on-Write-Struktur ist (speichert seinen Inhalt auf dem Heap und kopiert ihn, wenn er sich ändert). Überlegen Sie, wie Sie Ihre Struktur implementieren können, die beim Ändern kopiert wird:
struct BetterLine: Drawable { private var storage: LineStorage init() { storage = LineStorage((0, 0), (10, 10)) } func draw() -> Double { ... } mutating func move() { if !isKnownUniquelyReferenced(&storage) { storage = LineStorage(self.storage) }
- BetterLine speichert alle Eigenschaften im Speicher. Der Speicher ist eine Klasse und wird auf dem Heap gespeichert.
- Der Speicher kann nur mit der Verschiebungsmethode geändert werden. Darin überprüfen wir, dass nur ein Zeiger auf Speicher verweist. Wenn es mehr Zeiger gibt, teilt diese BetterLine den Speicher mit jemandem. Damit sich BetterLine vollständig als Struktur verhält, muss der Speicher individuell sein - wir erstellen eine Kopie und arbeiten in Zukunft damit.
Mal sehen, wie es im Speicher funktioniert:
let aLine = BetterLine() let pair = Pair(aLine, aLine) let copy = pair copy.second.x1 = 3.0
Als Ergebnis der Ausführung dieses Codes erhalten wir:

Mit anderen Worten, wir haben zwei Instanzen von Pair, die sich denselben Speicher teilen: LineStorage. Wenn Sie den Speicher in einem seiner Benutzer ändern (erster / zweiter), wird eine separate Kopie des Speichers für diesen Benutzer erstellt, damit sich die Änderung nicht auf andere auswirkt. Dies löst das Problem der Verletzung der Semantik von Werttypen aus dem vorherigen Beispiel.
Protokolltypen - Zusammenfassung
- Kleine Werte . Wenn wir mit Objekten arbeiten, die wenig Speicherplatz beanspruchen und im Puffer eines existenziellen Containers abgelegt werden können, dann:
- Es wird keine Platzierung auf dem Haufen geben
- keine Referenzzählung
- Polymorphismus (dynamisches Senden) unter Verwendung einer Protokolltabelle
- Tolles Preis-Leistungs-Verhältnis. Wenn wir mit Objekten arbeiten, die nicht in den Puffer passen, dann:
- Haufenplatzierung
- Referenzzählung, wenn Objekte Verknüpfungen enthalten.
Die Mechanismen der Verwendung des Umschreibens für Änderungen und indirekte Speicherung wurden demonstriert und können die Situation mit Referenzzählung bei einer großen Anzahl von ihnen erheblich verbessern.
Wir haben festgestellt, dass Protokolltypen wie Klassen in der Lage sind, Polymorphismus zu realisieren. Dies geschieht durch Speichern in einem existenziellen Container und Verwenden von Protokolltabellen - Lebenszyklustabellen und Protokollmethodentabellen.