Minimierung der Shared State mit Swift Value Types

iOS Developer @ Chrono24
Minimierung der Shared State mit Swift Value Types

Die Chrono24 iOS-App gibt es bereits seit den Objective-C Tagen und hat seitdem einen langen Weg zurückgelegt. Damals waren wir noch gezwungen, von NSObject zu erben und deshalb für fast alles Reference Types zu verwenden. Aber mit der Einführung von Swift im Jahr 2014 und dessen Value Types eröffnete sich eine ganz neue Welt der Möglichkeiten.

In diesem Artikel sprechen wir darüber, wie Value Types in Swift es uns ermöglichen, sichereren Code zu schreiben, indem sie so genannten Shared State minimieren und den Code nachvollziehbarer machen als zuvor.

Das Problem mit Reference Types


Instanzen von Klassen haben eine inhärente Identität, die Objektidentität: eine Adresse auf dem Heap, wo ihre Daten gespeichert sind. Wenn wir ein Objekt als Argument an eine Funktion übergeben oder es einer Variablen zuweisen, kopieren wir eigentlich nur einen Zeiger an die Stelle, an der das Objekt auf dem Heap lebt. Das Objekt wird by reference übergeben. Es gibt noch immer nur das eine Objekt, das auf dem Heap lebt, aber mehrere Zeiger, die darauf verweisen.

In vielen Situationen ist dies wünschenswert: Wir speichern die potentiell teuren Objektdaten nur einmal und können beliebig viele billige Zeiger darauf haben. Wenn das Objekt unveränderlich ist, d.h. sich seine Daten im Laufe der Zeit nicht ändern, ist das super.

Aber nicht alle unsere Daten können unveränderlich sein. Vielleicht brauchen wir Mutabilität, weil wir nicht alles direkt im Initializer konfigurieren können. Oder vielleicht rechtfertigt unser Use Case veränderliche Klassen, z.B. eine Klasse, die eine Konversation modelliert, die als vom Benutzer gelesen markiert werden kann. Wenn wir Instanzen von veränderlichen Klassen aus verschiedenen Teilen unserer Anwendung referenzieren, haben wir sogenannten Shared Mutable State. Das ist potenziell gefährlich, denn plötzlich kann ein Teil der Anwendung das Objekt verändern, ohne dass die anderen Teile über diese Änderung informiert werden, sodass diese beim nächsten Zugriff auf das Objekt einfach die aktualisierten Daten lesen, ob sie es wollen oder nicht. Darüber hinaus hat jeder Teil, der sich auf einen gemeinsamen veränderbaren Zustand bezieht, nun die Möglichkeit, ihn zu mutieren, obwohl es im Allgemeinen eine Art Eigentümer gibt, der die Daten verwaltet, während die anderen Teile die Daten lediglich konsumieren.

Wenn wir keinen Shared Mutable State haben wollen, können wir defensive Kopien unserer Modellobjekte erstellen. Wir übergeben sie dann an einen anderen Teil der Anwendung, sodass Änderungen, die in einem Teil vorgenommen wurden, im anderen nicht sichtbar sind. Beim Erstellen von defensiven Kopien reicht eine flache Kopie im Allgemeinen nicht aus, da das Objekt selbst ein veränderliches Objekt enthalten kann. Stattdessen müssen wir eigentlich eine tiefe Kopie machen, indem wir den Inhalt des primären Objekts sowie rekursiv enthaltene veränderbare Objekte kopieren. Aber auch das wäre dann wieder nur eine veränderliche Kopie und andere Teile der Anwendung könnten sie verändern, ohne dass wir das mitbekommen, falls sie eine Referenz darauf bekommen. Um wirklich sicher zu sein, bräuchten wir zwei Versionen eines Datentyps — eine veränderliche und eine unveränderliche Version — sodass wir eine unveränderliche Kopie an einen anderen Teil der Anwendung übergeben können.

Wenn verschiedene Teile der Anwendung absichtlich Shared Mutable State haben, müssen wir sicherstellen, dass, wenn ein Teil den gemeinsamen Zustand ändert, die anderen Teile die Möglichkeit haben, entsprechend zu reagieren. Wir können dies tun, indem wir das Observer Pattern für unsere veränderlichen Klassen implementieren. Um das Observer Pattern korrekt zu implementieren, muss ein veränderliches Objekt all seine referenzierten Objekte beobachten, um seine eigenen Beobachter zu benachrichtigen, wenn sich eines der referenzierten Objekte ändert. Und wenn eine ihrer Referenzen auf eine neue Instanz umgelegt wird, darf sie nicht vergessen, sich von der alten Instanz abzumelden und die neue Instanz zu abonnieren.

Leider können unveränderliche Typen aufgrund neuer Anforderungen sehr schnell zu veränderlichen Typen werden, was Probleme mit sich bringt: Bisher haben wir den Typ korrekterweise als unveränderlich im Code angenommen, keine defensiven Kopien gemacht oder uns als Beobachter registriert, wo wir das sonst für einen veränderlichen Datentyp hätten. Alle diese Stellen müssen aktualisiert werden, wenn die Klasse nicht mehr unveränderlich ist. Es ist möglicherweise nicht einfach, alle betroffenen Teile des Codes zu finden. Wir müssen auch Objekte berücksichtigen, die auf den ersten Blick unveränderlich erscheinen, aber unveränderliche Verweise auf veränderliche Objekte enthalten: Sobald ein tief verschachtelter Typ veränderbar wird, gilt das auch für alle Typen die rekursiv darauf verweisen. Es gibt auch die Möglichkeit, dass uns jemand eine veränderliche Unterklasse einer unveränderlichen Oberklasse untermogelt, ohne dass wir es merken.

Wie Value Types den Tag retten


Value Types haben keine inhärente Identität wie Referenztypen. Alles, was sie haben ist deren Inhalt. Wenn dieser Inhalt gleich dem Inhalt eines anderen Wertes ist, dann werden sie als äquivalent und substituierbar angesehen. Value Types werden by value übergeben: Bei der Übergabe an einen anderen Teil des Programms wird nicht die Adresse des Quellwerts kopiert, sondern dessen Inhalt. Infolgedessen werden Instanzen von Werttypen nicht geteilt — jeder erhält automatisch seine eigene defensive Kopie und kann sie frei verändern, ohne den Rest der Anwendung zu beeinträchtigen. Dies ermöglicht es uns, vorhersehbaren Code zu schreiben, der einfacher zu verstehen ist. Natürlich leben Werttypen auch irgendwo im Speicher und haben daher eine Adresse, aber wir sehen oder behandeln diese Adresse im Allgemeinen nicht.

Die Veränderlichkeit eines Value Types ist nicht Teil des Typs selbst. Stattdessen kann die Deklarationsstelle wählen, ob die Instanz unveränderlich (mit let) oder veränderbar (mit var) sein soll. Wird ein Wert als unveränderlich deklariert, so können weder der Wert selbst noch verschachtelte Werte neu zugeordnet werden oder mutating Methoden darauf aufgerufen werden, auch wenn die verschachtelten Werte in ihrem Container als veränderbar definiert sind — schließlich wird das Ganze als konstant deklariert. Wenn ein Wert hingegen als veränderlich deklariert ist, sind alle oben genannten Operationen zulässig. Beachten Sie, dass es keinen Unterschied gibt, ob Sie einen bestehenden Wert mutieren oder einen äquivalenten neuen Wert zuweisen, da Werttypen keine inhärente Identität wie Referenztypen haben. Mit nur einer Implementierung des Typs erhalten wir sowohl eine unveränderliche als auch eine veränderliche Variante, wobei die veränderbaren Teile einfach nicht verfügbar sind, wenn sie als unveränderlicher Typ verwendet werden.

Wichtig ist an dieser Stelle, dass dies keineswegs nur für Swift gilt. Es kann auch in einfachem C mit structs und const nachgebaut werden. Swift fügt jedoch ein paar Feinheiten hinzu, wie z.B. die Unterstützung von Methoden und Automatic Reference Counting für enthaltene Referenzen auf Reference Types.

Value Types und Identitäten


Identitäten sind entscheidend, um Dinge voneinander zu unterscheiden, und Klassen geben uns die Objektidentität umsonst. Stellen wir uns vor, wir haben eine Liste von Uhren und jede Uhr wird durch eine Zelle in einem UITableView dargestellt. Wir können die Liste der Uhren verwenden, um das UITableView zu befüllen. Wenn eine neue Liste der Uhren erstellt wird, können wir anhand der Uhrenidentitäten feststellen, wo Zellen eingefügt, gelöscht oder verschoben werden müssen.

Allerdings ist die Objektidentität nicht das, was wir hier wollen: Wenn wir einen aktualisierten Satz von Uhren von einem API-Endpunkt erhalten und diese dem Benutzer präsentieren wollen, haben wir nach der Deserialisierung Objekte mit allen neuen Identitäten. Unsere Update-Logik auf Basis von Objektidentitäten würde dann einfach alle alten Zellen entfernen und neue Zellen für alle Uhren erstellen.

Welche Art von Identität wollen wir hier? Im obigen Beispiel repräsentieren die Objekte nach der Aktualisierung immer noch dasselbe: die gleichen zugrundeliegenden Datensätze in unserer Datenbank. In einer Datenbank haben wir im Allgemeinen einen Primärschlüssel, der die Identität der Datensätze definiert, mit denen wir es zu tun haben; so etwas wie eine watchID für das obige Beispiel. Dieser Schlüssel allein definiert die Record-Identität einer Uhr. Und wir können mehrere Momentaufnahmen desselben Datensatzes zu verschiedenen Zeitpunkten (z.B. vor und nach einem Update) erstellen und sie anhand ihrer Record-Identität zuordnen.

Dies ist natürlich auch mit Referenzarten möglich. Aber Werttypen machen es uns extrem einfach, weil wir sicherstellen können, dass die Snapshots unveränderlich sind und sie diese andere Art von Identität nicht mitbringen, die in unserem Anwendungsfall überhaupt keine Bedeutung hat.

SwiftUI führte das Identifiable-Protokoll ein, damit Typen ihre Record-Identität angeben können und diverse UI-Komponenten, die auf dem Konzept der Record-Identität aufbauen. Nach seiner Einführung wurde das Protokoll in die Standardbibliothek verschoben, sodass es jeder nutzen konnte.

protocol Identifiable {
    associatedtype ID: Hashable
    var id: ID { get } 
}

Beobachten von Veränderungen

Die Implementierung des Observer-Patterns für (verschachtelte) veränderliche Klassen ist sehr fehleranfällig, und selbst bei korrekter Implementierung gibt es einige Nachteile: Erstens werden Mutationen sehr feingranular kommuniziert. Mehrere aufeinanderfolgende Mutationen benachrichtigen den Beobachter für jede Mutation einzeln, auch wenn wir viel mehr an der Veränderung als Ganzes interessiert sind. Darüber hinaus müssen wir vom Schlimmsten ausgehen und alles aktualisieren, was von irgendeinem Teil des Objekts abhängt, da wir im Allgemeinen nicht wissen, welcher Teil des Objekts geändert wurde.

Bei Value Types hingegen brauchen wir gar kein Observer Pattern zu implementieren. Es sei daran erinnert, dass bei Werttypen die Deklarationsstelle die Veränderlichkeit eines Wertes definiert. Unveränderliche Werte dürfen sich nicht ändern und veränderbare Werte können sich nur als Ganzes ändern, d.h. Mutationen des Wertes selbst oder eines seiner verschachtelten Werte führen zu einer Mutation des gesamten Wertes, wodurch ein potenzieller didSet Property Observer aufgerufen wird. Auf diese Weise können wir Mutationen beliebiger Größe durchführen oder sogar einen ganz neuen Wert zuweisen und den Property Observer nur einmal aufrufen, und wir haben zusätzlich Zugriff auf den oldValue, um herauszufinden, welcher Teil der Daten sich tatsächlich geändert hat, und unsere daraus resultierenden Aktualisierungen effizienter durchzuführen.

Value Semantics


Nicht alles kann ein Value Type sein. Manchmal brauchen wir die eindeutige Identität, welche uns die Adresse eines Wertes gibt. Manchmal ändert sich die Größe eines Typs dynamisch und erfordert eine Reallokation, wie beispielsweise bei Arrays, bei denen Elemente entfernt oder eingefügt wurden. Und Value Types können sich sogar wie Referenztypen verhalten, z.B. wenn sie auf gemeinsam genutzten veränderbaren Speicher wie globale Variablen verweisen oder wenn sie veränderbare Objekte enthalten.

Entscheidend ist, welche Art von Semantik ein Typ hat, was er garantiert, wie er sich verhält. In der Standardbibliothek haben sogar Collections Wertsemantik: Ein Array, ein Dictionary oder ein Set ist nur ein Zeiger auf (potenziell gemeinsam genutzten) veränderbaren Speicher auf dem Heap, aber diese Typen implementieren einen effizienten Copy-On-Write-Mechanismus, der den zugrundeliegenden Speicher vor der Mutation kopiert, sofern er mehrfach referenziert wird.

Beobachten von Veränderungen in SwiftUI


In SwiftUI wird der Body eines Views invalidiert, wenn sich der Wert eines Bindings oder ObservableObjects, das im Body gelesen wird, ändert. Im nächsten Run-Loop werden dann die View Bodies neu gerendert und die resultierende virtuelle View-Hierarchie verglichen, um herauszufinden, welche Änderungen an der tatsächlichen View-Hierarchie vorgenommen werden müssen. Dies ist als Invalidierung anstelle eines direkten Reloads implementiert, sodass mehrere aufeinanderfolgende Updates gruppiert werden können, was das Beobachtermuster, wie es von ObservableObject implementiert wurde, nicht zulässt.

Der Inhalt dynamischer Listen wird entsprechend der Record-Identität der Identifiable Modellwerte unterschieden, um herauszufinden, wo Zellen eingefügt, gelöscht oder verschoben werden müssen.

Value Types bei Chrono24


Obwohl wir SwiftUI derzeit nicht verwenden, gibt es einige Lektionen zu lernen, wie die Dinge dort funktionieren, insbesondere Datenfluss und wie Updates durchgeführt werden.

In der Chrono24 iOS-Anwendung versuchen wir im Allgemeinen Shared Mutable State zu vermeiden. Daher verwenden wir für die meisten unserer Daten Value Types. Unsere Daten haben in der Regel einen Eigentümer, entweder einen Service oder einen Controller, der sie verwaltet und Momentaufnahmen der Daten an andere Komponenten weiterleitet, die sie benötigen — in erster Linie Views. Die Views können daher die Daten nicht direkt verändern und müssen stattdessen Aktionen an den Controller zurücksenden, um Änderungen anzufordern. Der Controller kann dann die Aktion ausführen, infolgedessen möglicherweise die Daten ändern und eine Momentaufnahme der aktualisierten Daten an die Views und alle anderen Teile, die die aktualisierten Daten benötigen, zurückschreiben.

Darüber hinaus implementieren wir die Protokolle Identifiable und Equatable in unseren Modellwerten in der gesamten App, um eine effiziente Aktualisierung der View-Hierarchie bei Änderungen der zugrundeliegenden Daten zu ermöglichen. Indem wir die Primärschlüssel unserer Modelle extrahieren und mit denen vor dem Update vergleichen, können wir feststellen, wo Elemente eingefügt, gelöscht oder verschoben werden müssen. Bei Momentaufnahmen von Datensätzen, deren Identität sowohl in den alten als auch in den neuen Daten vorhanden ist, können wir dann anhand der Equatable-Implementierung überprüfen, ob sich Änderungen am Rest des Datensatzes ergeben haben. Wenn ja, übergeben wir den neuen Snapshot an das jeweilige View, damit es neu gezeichnet werden kann; wenn nicht, dann sind die beiden Snapshots tatsächlich substituierbar und wir müssen keine zusätzliche Arbeit leisten.

Ein Beispielprojekt, das die Probleme mit Reference Types aufzeigt und wie wir bei Chrono24 Value Types zur Bewältigung alltäglicher Aufgaben bei der Entwicklung einer iOS-App einsetzen, findet sich auf GitHub.

Zusammenfassend sind dies unsere größten Takeaways:

  • Durch die Verwendung von Value Types für unsere Modelltypen können wir Shared Mutable State und Beobachtungslogik reduzieren.
  • Wenn Sie Value Type für Ihre Modelle verwenden, betrachten Sie diese als Momentaufnahmen eines Datensatzes zu verschiedenen Zeitpunkten und definieren Sie deren Record-Identität in Bezug auf den Primärschlüssel in der zugrundeliegenden Datenbank.
  • Value Types ermöglichen es uns, UI-Updates effizienter durchzuführen: Wir können trivial mehrere Updates zusammen mit nur einem einzigen effizienten Neuzeichnen durchführen, weil wir sowohl den alten als auch den neuen Wert kennen und herausfinden können, welche Teile neu gezeichnet werden müssen (oder auch nicht).

Bildquellen