Swift-Protokolle mit assoziierten Typen (PATs) oder Self-Anforderungen können nicht als erstklassige existentiale Typen (z.B. [MyProtocol]) fungieren, da dem Compiler die konkreten Typ-Metadaten fehlen, die benötigt werden, um Zeugniskarten für assoziierte Typen zur Compile-Zeit zu erstellen. Diese Einschränkung verhindert, dass heterogene Sammlungen Instanzen direkt speichern, da das Speicherlayout für assoziierte Typen zwischen den konformen Typen variiert. Entwickler lösen diese Einschränkung durch Typauslagerungsmuster, indem sie Box-Wrapper implementieren, die Protokoll-Zeugniskarten oder auf Closure-basierte Dispatch-Methoden nutzen, um den Zugriff auf die Schnittstelle zu homogenisieren und gleichzeitig die zugrunde liegende Komplexität assoziierter Typen zu kapseln.
Bei der Architektur einer plattformübergreifenden Medien-Engine benötigte unser Team einen PlaylistController, der in der Lage ist, verschiedene Audiocodecs zu verwalten – darunter MP3, AAC und FLAC – die jeweils ein Playable-Protokoll mit einem assoziierten Buffer-Typ implementieren, der die decodierten Audiosamples repräsentiert. Der assoziierte Buffer unterschied sich erheblich zwischen den Formaten: unkomprimierte PCM-Daten für FLAC im Gegensatz zu komprimierten Paketen für MP3, was inkompatible Speicherlayouts erzeugte, die eine standardmäßige polymorphe Speicherung verhinderten.
Ein Ansatz verwendet generische Spezialisierung über Playlist<T: Playable>, wodurch die gesamte Sammlung auf einen einzigen konkreten Typ beschränkt wird. Dies eliminiert die Laufzeitübertragungskosten und ermöglicht aggressive Compiler-Optimierungen wie Inlining. Diese Vorgehensweise opfert jedoch die Polymorphie vollständig, was es den Nutzern unmöglich macht, MP3- und FLAC-Titel innerhalb derselben Playlist-Struktur miteinander zu kombinieren.
Alternativ könnten Entwickler die nativen existenziellen Container von Swift über die Syntax [any Playable] nutzen, die in modernem Swift verfügbar ist. Während dies heterogene Speicherung unterstützt, erfordert der Zugriff auf den assoziierten Buffer-Typ, dass Existentialen bei jedem Aufruf manuell geöffnet werden, wodurch ein umfangreicher Boilerplate-Code erzeugt wird und die Heap-Allokation für große Werttypen erforderlich wird. Zusätzlich verhindert der Verlust von konkreten Typinformationen, dass der Compiler die Methodenaufrufe devirtualisiert, was messbare Überheadkosten in engen Audioverarbeitungszyklen einführt.
Die optimale Lösung implementiert eine manuelle Typauslagerungsbox namens AnyPlayable, die auf Closure-basierten Zeugentabellen basiert, um die Methoden play() und stop() zu delegieren. Dieser Wrapper speichert die konkrete Instanz in einem klassenbasierten Container oder existenziellen Puffer und verbirgt die Komplexität des assoziierten Typs, während er eine einheitliche Schnittstelle bereitstellt. Obwohl dies ein Maß an Indirektion ähnlich wie bei virtuellem Dispatch einführt, abstrahiert es erfolgreich die Unterschiede in der Pufferimplementierung und unterstützt echte heterogene Sammlungen ohne Laufzeitumwandlungskomplexität.
Wir wählten den Ansatz mit dem Typauslagerungs-Wrapper, da Medienanwendungen grundsätzlich erfordern, verschiedene Codecs innerhalb einheitlicher Playlists zu mischen, und die Kosten für virtuellen Dispatch im Vergleich zu I/O-Latenz beim Audio-Streaming vernachlässigbar bleiben. Die Implementierung ermöglichte die nahtlose Integration proprietärer DRM-Formate mit Standardcodecs, ohne die Architektur des Controllers zu ändern. Letztendlich wurde dadurch die Typsicherheit zur Compile-Zeit während der Titelinitialisierung aufrechterhalten, während gleichzeitig die zur Laufzeit erforderliche Flexibilität für benutzergenerierte Inhaltsbibliotheken bereitgestellt wurde.
Frage 1: Warum können wir nicht einfach as! any Playable verwenden, um konkrete Typen in Existentialen zu konvertieren, wenn assoziierte Typen beteiligt sind?
Swift verbietet die Verwendung von Protokollen mit assoziierten Typen als nackte Existentialen, da der existenzielle Container festen Speicher mit fester Größe (typischerweise drei Worte) benötigt, während assoziierte Typen beliebig große Speicheranforderungen haben können. Wenn der assoziierte Typ Buffer ein 512-Byte decodiertes Frame für FLAC repräsentiert, jedoch einen 4-Byte-Paketindex für MP3, kann der Existential nicht beide inline unterbringen, ohne den konkreten Typ zur Compile-Zeit zu kennen. Daher erzwingt der Compiler die Typauslagerung oder generische Einschränkungen, um die Speichersicherheit zu gewährleisten und Laufzeitabstürze durch Stackbeschädigungen oder Pufferüberläufe zu verhindern.
Frage 2: Wie unterscheiden sich die opaken Rückgabetypen (some Collection) in Swift 5.1 von Typauslagerungsboxen hinsichtlich Leistung und API-Evolution?
Opake Rückgabetypen nutzen rückwärtige Generika und Compile-Zeit-Spezialisierung, die es dem Compiler ermöglichen, vollständige konkrete Typinformationen beizubehalten und gleichzeitig Implementierungsdetails vor den Aufrufern zu verbergen. Dies vermeidet die Übertragungskosten für virtuelle Dispatch- und Heap-Allokation, die inhärent zu manuellen Typauslagerungsboxen sind. Allerdings erfordern opake Typen, dass der zugrunde liegende Typ am Rückgabepunkt fest bleibt (vorbehaltlich der SE-0368-Mehrfach-opaque-Ergebnisse), während Typauslagerungsboxen eine dynamische Variation konkreter Typen innerhalb desselben Containers zur Laufzeit ermöglichen, wobei Leistung gegen polymorphe Flexibilität eingetauscht wird.
Frage 3: Welche Gefahren des Speichermanagements entstehen, wenn Typauslagerungsboxen selbstreferenzierende Protokolle (z.B. Protokolle mit Methoden, die Self zurückgeben) in mehrthreadigen Umgebungen erfassen?
Typauslagerungsboxen verwenden häufig klassenbasierte Wrapper oder Closure-Captures, um konkrete Instanzen zu speichern. Wenn das Protokoll erfordert, dass Self zurückgegeben wird oder assoziierte Typen, die auf Self verweisen, verwendet werden, muss die Box die Typidentität durch Referenzsemantik bewahren. Dies kann potenzielle zirkuläre Verweise erzeugen, wenn der konkrete Typ einen Rückverweis zur Box hält. In gleichzeitigen Kontexten können mehrere Threads, die den boxierten Zustand ändern, Rennbedingungen auf dem Referenzzählwerk oder internen Puffern auslösen. Entwickler müssen sicherstellen, dass der Wrapper ordnungsgemäß Sendable konform ist, typischerweise indem sie Actor-Isolation oder unveränderliche Wertsemantiken innerhalb der Box implementieren, um Datenrennen zu verhindern und gleichzeitig die abstrahierte Schnittstelle aufrechtzuerhalten.