Pflichtlektüre: https://engineering.cloudflight.io/defining-boundaries

Ein großer Teil dessen, was Komplexität ausmacht, sind Seiteneffekte. Die Herausforderung dabei ist das (halb) nicht-deterministische Verhalten ihrer Ausführung. Ein Netzwerkaufruf kann mir bei Erfolg die gewünschten Daten liefern oder mit einem von mehreren möglichen Fehlercodes fehlschlagen. Mutationen können die Datenintegrität vollständig zerstören. Diesmal sprechen wir über den Umgang mit Mutationen.

Die Problemstellung

Stellen wir uns folgenden Code vor:

const data = {
    foo: 'hello',
    world: 'bar'
};
function doSomething(dataToRead) {
    const fooValueBeforeCall = dataToRead.foo;
    await apiCall();
    const fooValueAfterCall = dataToRead.foo;
}
function modifyData(dataToModify) {
    dataToModify.foo = 'some new value';
}
doSomething(data);
modifyData(data);

Was wir hier sehen: Das Lesen der Eigenschaft foo liefert nicht immer dasselbe Ergebnis. Das ist besonders schmerzhaft bei Programmen mit gleichzeitiger Ausführung, da die Geschäftslogik möglicherweise erfordert, dass bestimmte Bedingungen erfüllt sind.

Die Modifikation der Datenstruktur geschieht irgendwo anders in der Code-Basis. Im Beispiel befindet sie sich direkt unterhalb unseres Aufrufs von doSomething, aber in der Realität kann sie an einer anderen Stelle sein, die wir nicht kennen. Natürlich verletzt der obige Code die Grenzen.

Inspirationen

Wie können wir dann verhindern, dass Mutationen zur Fehlerquelle werden? Wir können uns ansehen, wie verschiedene Programmiersprachen damit umgehen.

Haskell

In Haskell sind Mutationen überhaupt nicht erlaubt. Jede Änderung, die wir vornehmen möchten, muss durch die Rückgabe einer neuen Datenstruktur mit den angewendeten Änderungen erfolgen. Mutationen können keine Fehlerquelle sein, wenn es sie nicht gibt, oder? Natürlich hat dieser Ansatz – wie alles im echten Leben – auch einige Nachteile. Die Mehrheit der heutigen Computer basiert auf der Turing-Maschine, die selbst auf der Mutation von Daten basiert. Auch die (Low-Level-)Abstraktionen, die wir heute darüber hinaus verwenden, weichen von dieser Idee nicht ab. Das ist eine Einschränkung für Haskell, da die Low-Level-Details auf eine Weise mit Seiteneffekten implementiert werden müssen. Andernfalls leidet die Performance aufgrund der Diskrepanz zwischen dem, wofür die Maschine gut ist, und dem, was Haskell tun möchte. Nicht alle diese Low-Level-Details können vollständig abstrahiert werden, was Haskell für leistungskritische Arbeiten auf dem Niveau von C und C++ ungeeignet macht.

Rust

Rust hingegen konkurriert auf diesem Niveau und hat daher auf die Idee der Mutationsfreiheit verzichtet. Die Sprache hat einen Sweet Spot zwischen den beiden Welten gefunden: Mutabilität als Schlüsselwort und das Ownership-Modell. Erinnert euch, was im Artikel über Grenzen geschrieben stand? Wir wollen so viel wie möglich mit dem Typsystem ausdrücken und auf andere Lösungen zurückgreifen, wenn es keinen anderen Weg gibt. Rust kodiert Mutabilität im Typsystem mit dem Schlüsselwort mut. Die meisten Code-Teile müssen Daten weder ändern noch tun sie es, daher ist Unveränderlichkeit in Rust der Standard. Falls Mutationen benötigt werden, kann das Schlüsselwort mut verwendet werden. Schwieriger zu verstehen wäre das Ownership-Modell von Rust. Ich werde hier nicht jedes Detail des Ownership-Modells erklären – dafür gibt es die offizielle Dokumentation. Stattdessen nehme ich die für uns interessanten Teile, nämlich Referenzen und Borrowing. Die Einschränkungen von Rust sind folgende: Wir können beliebig viele Leser dieser Daten haben, aber keine Schreiber. Oder wir können einen Schreiber für diese Daten haben, aber keine Leser. Kommt euch dieses Muster bekannt vor? Ja, es ist ein Read-Write-Lock – aber zur Kompilierzeit erzwungen, ermöglicht durch das Typsystem.

Die Architektur definieren

Wir können diese beiden Systeme als Inspiration dafür nutzen, wie wir Mutationen verwalten sollten. Den Haskell-Ansatz vollständig zu verfolgen wird nicht funktionieren, da wir im Frontend einen globalen Zustand haben – den Store. Rust hat großartige Ideen, aber wir können uns auch dazu nicht vollständig bekennen, da TypeScript keine Möglichkeit hat, das Ownership-Modell mit seinem Typsystem auszudrücken. Aus diesen Gründen müssen wir uns für das, was wir vorhaben, auf Konventionen anstatt auf das Typsystem verlassen.

Die einzige Quelle der Wahrheit

Führen wir das Konzept des Eigentümers ein. Unsere Idee des Eigentümers ist eine einfache: Wo ist die einzige Quelle der Wahrheit? Angenommen, wir haben eine Funktion, die einige Daten entgegennimmt. Wo ist die einzige Quelle der Wahrheit dieser Daten? Ja, beim Aufrufer, denn der Aufrufer hat die Daten an die Funktion übergeben. Der Aufrufer könnte die Daten von seinem eigenen Aufrufer erhalten haben, in welchem Fall die einzige Quelle der Wahrheit auch auf diesen Aufrufer verschoben wird. Alternativ könnte der Aufrufer die Daten aus einem Store erhalten haben – dann wird der Store zur einzigen Quelle der Wahrheit. Seht ihr, worauf ich hinauswill? Ich frage ständig, woher die Daten kommen, und letztlich landen wir an einem Ort, der das absolute Sagen über die Daten hat und von dem alle abhängen.

Kann TypeScript das Read-Write-Lock-Muster wie in Rust ausdrücken? Nein. TypeScript hat dafür keine Möglichkeit. Was wir tun können, ist anzunehmen, dass es immer N Leser gibt. Im Fall von Rust kann niemand die Daten schreiben. Aber das ist für uns nicht realistisch, da Mutationen notwendig sind, um den Zustand im Store zu aktualisieren und Benutzeränderungen in der UI anzuzeigen. Wenn wir es nicht eliminieren können, können wir die Verwendung von Mutationen einschränken. Wo sollten wir mutieren dürfen? Nicht dort, wo die Daten gelesen werden, da das den Leser zu einem Schreiber macht – und wir dürfen sie nicht vermischen. Wir haben ohnehin keine Möglichkeit sicherzustellen, dass keine Leser vorhanden sind. Es gibt also nur eine Antwort auf diese Frage: Dort, wo die einzige Quelle der Wahrheit ist. Da alles die Daten vom Eigentümer, also der einzigen Quelle der Wahrheit, oder einer Ableitung davon liest, können wir auch davon ausgehen, dass er Änderungen an den ihm gehörenden Daten korrekt handhaben kann.

Ein Leser muss mit dem Eigentümer kommunizieren, dass Änderungen an den Daten vorgenommen werden sollen. Es gibt zwei Möglichkeiten, das zu erreichen: Dem Eigentümer mitteilen, was geändert werden soll, oder dem Eigentümer eine bereits geänderte Kopie geben, die die Daten ersetzen soll. Ersteres erfordert, dass der Eigentümer versteht, was der Leser von ihm will. Dieses Konzept ist eine Schnittstelle zwischen den Daten und der Außenwelt, die bewacht, was geändert werden darf. Letzteres legt die Datenstruktur vollständig offen, was mehr als genug sein könnte, wenn die Anforderungen nicht sehr komplex sind.

Zu beachten ist hier, dass jede Komponente dieses Konzept für ihre eigene Quelle der Wahrheit anwendet, die ihr Aufrufer, Elternteil oder wie auch immer es genannt wird, ist. Warum können wir nicht direkt in die eigentliche einzige Quelle der Wahrheit schreiben? Weil der Aufgerufene nicht weiß und nicht wissen sollte, woher sein Aufrufer die Daten hat. Letztendlich ist das eine Code-Stück, das die Daten aus dem Store oder der Datenbank liest, auch dasjenige, das die Daten zurückschreibt.

Two diagrams: the left illustrates Parent, Child, and GrandChild components passing data and handling mutations vertically while interacting with a Store; the right depicts Function A, B, and C passing parameters and return values, with Function A linked to the Store for mutation management.

Ersetzen statt Mutieren

Wir sind jedoch noch nicht fertig. Die oben genannte Architektur erleichtert nur das Nachdenken, indem sie relevantes Verhalten zusammenfasst. Nehmen wir den folgenden Code:

let data = {
    foo: 'hello',
    world: 'bar'
};
export function modifyFoo(newFoo) {
    data.foo = newFoo;
}
export function readData() {
    return data;
}

Diese Implementierung des Stores sieht doch gut aus, oder? Zugriffsmethoden zum Schreiben und Lesen befinden sich beide in derselben Datei, und die Daten selbst sind nicht exponiert. Aber das kann zum selben Bug aus unserem ersten Code-Beispiel führen. Haskell hat bereits die halbe Antwort für uns: Bestehende Daten nicht modifizieren, sondern neue erstellen, auf die die Änderungen angewendet wurden. Mit anderen Worten: Wir müssen eine neue Datenstruktur erstellen und die alte damit ersetzen.

let data = {
    foo: 'hello',
    world: 'bar'
};
export function modifyFoo(newFoo) {
    const updatedData = {
        ...data,
        foo: newFoo,
    };
    data = updatedData;
}
export function readData() {
    return data;
}

Mit der neuen Implementierung ist auch der Bug aus unserem ersten Code-Beispiel behoben.

Eine lohnenswerte Frage wäre: Kann der mehrfache Aufruf von readData dazu führen, dass unterschiedliche Daten zurückgegeben werden? Haben wir den Bug wirklich behoben? Die Antwort lautet ja. Das Lesen aus dem Store selbst gilt als Seiteneffekt, da der Store nur eine globale Variable mit einigen Abstraktionen darum herum ist. Der Bug, den wir behoben haben, ist das Lesen von Daten aus dem Eingabeparameter, das stets dieselben Daten zurückliefert – erreicht durch das Nicht-Mutieren bestehender Daten.