Das Initialisierungsmodell von Swift wurde entwickelt, um das undefinierte Verhalten zu beseitigen, das in Sprachen wie Objective-C häufig vorkommt, wo der Zugriff auf Instanzmethoden oder -eigenschaften, bevor der gesamte Speicher initialisiert wurde, zu Segmentierungsfehlern oder Sicherheitsanfälligkeiten führen kann. Das grundlegende Problem liegt in Klassenhierarchien: Ein Unterklasse-Objekt enthält Speicher für seine eigenen gespeicherten Eigenschaften plus alle geerbten Eigenschaften, und der Compiler muss sicherstellen, dass kein Code auf diesen Speicher zugreift, bevor jedes Byte gültig ist. Um dieses Problem zu lösen, erzwingt Swift ein definitives Initialisierungs (DI)-Invariant durch statische Analyse, das vorschreibt, dass ein Objekt in einem teilweise konstruierten, unsicheren Zustand bleibt, bis Phase 1 seiner zweiphasigen Initialisierung abgeschlossen ist. Während Phase 1 muss der Initialisierer Werte für alle Eigenschaften zuweisen, die von der aktuellen Klasse eingeführt werden, und nach oben an die Initialisierer der Oberklasse delegieren; erst nachdem diese Phase abgeschlossen ist, kann self sicher zugegriffen oder verwendet werden.
class Vehicle { let wheelCount: Int init(wheels: Int) { self.wheelCount = wheels // Phase 1 abgeschlossen für Vehicle } } class Bicycle: Vehicle { let hasBell: Bool init(bell: Bool) { // Phase 1: Zuerst eigene Eigenschaften initialisieren self.hasBell = bell // Dann an Oberklasse delegieren super.init(wheels: 2) // Phase 1 abgeschlossen: vollständige definitive Initialisierung erreicht // Phase 2: Sicher, self zu verwenden self.checkSafety() } func checkSafety() { print("Fahrrad mit \(wheelCount) Rädern \(hasBell ? "hat" : "hat keine") Glocke") } }
Bei der Entwicklung einer Anwendung für medizinische Aufzeichnungen standen wir vor einem komplexen Szenario mit einer PatientRecord-Oberklasse und einer ICUPatientRecord-Unterklasse, die während der Initialisierung eine Schweregradbewertung basierend auf dem Alter des Patienten (einer Oberklassen-Eigenschaft) erfordern musste. Die ursprüngliche Implementierung versuchte, eine Hilfsmethode calculateSeverity() aufzurufen—die auf self.age zugreift—bevor super.init(age:) aufgerufen wurde, was zu einem Compilerfehler führte, da der Unterklassen-Initialisierer noch nicht die Sicherheit des geerbten Speichers gewährleistet hatte. Wir prüften drei unterschiedliche architektonische Ansätze, um diese Einschränkung zu lösen.
Ein Ansatz bestand darin, den Schweregrad als implizit entpackte Optionale (var severity: Int!) zu deklarieren und die Zuweisung bis nach Abschluss der Initialisierung der Oberklasse zu verschieben. Obwohl dies den Compiler zufriedenstellte, brachte es erhebliche Laufzeitrisiken mit sich: Die Eigenschaft könnte vor der Zuweisung zugegriffen werden, was zu einem Absturz führen würde, und verhinderte den Einsatz einer unveränderlichen let-Deklaration, was die Integritätsgarantie des Datensatzes beeinträchtigte.
Eine zweite Strategie sah vor, eine statische Fabrikmethode zu verwenden, die ein temporäres Platzhalterobjekt instanziierte, um das Alter zu lesen, den Schweregrad offline zu berechnen und dann die tatsächliche Instanz mit vorab berechneten Werten zu erstellen. Dies bewahrte die Speichersicherheit, führte jedoch zu erheblichem Overhead und verwirrte den Initialisierungsfluss, wodurch die Codebasis erheblich schwieriger zu pflegen und zu debuggen wurde.
Die gewählte Lösung bestand darin, den Initialisierer so umzugestalten, dass das Alter als Parameter akzeptiert wurde, der Schweregrad mit einer reinen statischen Funktion berechnet wurde, die auf dem Eingangsparameter basierte und den vorab berechneten Wert an einen designated Initializer übergab. Dieser Ansatz bewahrte die Unveränderlichkeit, indem er severity als let-Konstante erlaubte, sich strikt an die zweiphasigen Initialisierungsregeln hielt und es dem Compiler ermöglichte, die Sicherheit zur Build-Zeit und nicht zur Laufzeit zu überprüfen. Das Ergebnis war eine Null-Absturz-Initialisierungssequenz, die klar die Datenabhängigkeit zwischen Alter und Schweregrad ausdrückte und die statische Analyse von Swift nutzte, um Regressionen zu verhindern.
Warum verhindert der Compiler den Aufruf von Instanzmethoden auf self, auch wenn diese Methoden in der Unterklasse definiert sind und scheinbar nichts mit den Eigenschaften der Oberklasse zu tun haben?
Der Compiler setzt diese Einschränkung durch, weil das Objekt als zugewiesener Speicher existiert, aber der Oberklassenanteil nicht initialisierten Rohspeicher bleibt. Jeder Methodenaufruf auf self—unabhängig davon, wo er definiert ist—erhält den vollständigen Objektzeiger und könnte potenziell auf die nicht initialisierten Felder der Oberklasse über indirekte Mittel zugreifen, was die Speichersicherheit verletzt. Swift behandelt sämtliche self-Verwendung vor Abschluss von Phase 1 konservativ als unsicher, wobei nur direkte Zuweisungen zu den gespeicherten Eigenschaften der aktuellen Klasse erlaubt sind.
Wie handhabt die Analyse der definitiven Initialisierung weak-Referenz-Eigenschaften im Vergleich zu unowned-Referenz-Eigenschaften?
Der Checker für definitive Initialisierung behandelt optionale Typen, einschließlich weak-Variablen, die implizit Optional sind, als ob ihnen automatisch ein gültiger Anfangswert von nil vom Compiler zugewiesen wird. Folglich erfordern weak-Eigenschaften keine explizite Initialisierung in Initialisierern. Im Gegensatz dazu sind unowned-Referenzen nicht optional und unterstellen sofortige Nicht-nil Semantik; daher müssen sie vor Abschluss des Initialisierers einen Wert zugewiesen bekommen, genau wie starke Referenzen, oder der Compiler gibt einen Fehler zur definitiven Initialisierung aus.
Was unterscheidet die Delegationsregeln für Convenience-Initialisierer von designated Initialisierern bezüglich der definitiven Initialisierung?
Convenience-Initialisierer fungieren als sekundäre Einstiegspunkte, die an einen designated Initializer (über self.init) delegieren müssen, bevor sie spezifische Instanzoperationen durchführen. Ihnen ist es strengstens untersagt, gespeicherte Eigenschaften direkt zu initialisieren, da der designated Initializer, den sie aufrufen, die Verantwortung dafür trägt, die Anforderungen an die definitive Initialisierung zu erfüllen. Dies steht im Gegensatz zu designated Initialisierern, die alle Eigenschaften, die von ihrer Klasse eingeführt werden, initialisieren müssen, bevor sie nach oben an einen Oberklassen-Initialisierer delegieren, um sicherzustellen, dass das Objekt auf jeder Ebene der Hierarchie gültig ist.