Fast jede größere Ansammlung von Mainstreamsprachen-Programmcode zeigt Phänomene, die ich als Löcher bezeichne. Löcher manifestieren sich im Zuge der Umsetzung von Konzepten in konkreten Code, sind nicht notwendigerweise Bugs, haben oft mit Objekten zu tun und lassen sich nicht loswerden – sie erwachsen aus Tradeoffs beim Sprachdesign und bestehen aus (möglichem) unerwünschtem Verhalten des aus dem Code resultierenden Programms. Ich halte es für wichtig, dass wir als Nutzer von Mainstream-Programiersprachen wie JavaScript und TypeScript solche Löcher erkennen und damit einen souveränen Umgang pflegen. Was nach meiner Auffassung bedeutet, sie einfach zu tolerieren.
Um als Loch zu gelten, muss ein Stück Code ein subtileres (potenzielles) Problem aufweisen als beispielsweise ein Fall-Through in einem Switch-Statement und es darf sich auch nicht durch ein angeflanschtes Typsystem wie etwa TypeScript reparieren lassen. Vielmehr geht es um unerwünschtes Verhalten, das sich aus bestimmten Patterns oder Sprachfeatures ganz automatisch ergibt. Mein persönliches Lieblingsloch sind Constructor-Funktionen in JavaScript bzw. TypeScript. Nehmen wir doch zu Demonstrationszwecken ein allgemein verständliches, wenn auch an den Haaren herbeigezogenes, praxisfernes Simpel-JavaScript-Beispiel her:
class Car { #kilometers; #gear; constructor () { this.#kilometers = 0; this.#gear = 0; } drive (km) { if (typeof km !== "number" || km < 0) { throw new Error(); } this.#kilometers += km; } shift (gear) { if (typeof gear !== "number" || gear < -1 || gear > 5) { throw new Error(); } this.#gear = gear; } get kilometers () { return this.#kilometers; } get gear () { return this.#gear; } }
Eine der zentralen Ideen hinter objektorientierter Programmierung ist, dass
Objekte ihren internen Zustand vor der Außenwelt verbergen und Modifikationen
des Zustands nur über Methoden erlauben. In der obigen Beispielklasse sind die
Felder für #gear
und #kilometers
privat und können nur
über die Methoden shift()
und drive()
indirekt
verändert werden. Die Methoden fangen ungültige Inputs ab und stellen damit
sicher, dass unser Kilometerzähler stets nur wächst und dass wir keinen Gang
einlegen, den wir nicht zur Verfügung haben. Es gibt also eine endliche Menge an
Zuständen, die ein Auto-Objekt einnehmen kann und daher nur eine endliche Menge
von Fällen, über die wir uns für unser Objekt Gedanken machen müssen:
// Ein möglicher Zustand let a = { #kilometers: 0, #gear: 2 } // Ein weiterer möglicher Zustand let b = { #kilometers: 42, #gear: 0 } // Ein unmöglicher Zustand, den wir nicht beachten müssen let c = { #kilometers: 42, #gear: -7 } // Ein weiterer unmöglicher Zustand let d = { #kilometers: 42, #gear: 0, #asdf: "Hi!" }
Jede Instanz der Auto-Klasse ist also stets in einem wohldefinierten Zustand mit einem validen Gang, positiver Kilometerzahl und nichts anderem, womit jede dieser Instanzen im Prinzip eine solide State Machine darstellt. Oder?
Es gibt in der Klasse ein Loch, in dem das Auto tatsächlich nicht in
einem wohldefinierten Zustand ist – und zwar im Constructor!
Die Methoden shift()
und drive()
bewerkstelligen die
Übergänge von einem gültigen Zustand unseres Auto-Objekts in den nächsten
gültigen Zustand und prüfen dafür die Inputs, nehmen aber einfach an, dass die
Ausgangszustände jeweils auch gültig sind. Diese Annahme müssen die Methoden
auch treffen, des wäre unverhältnismäßiger Aufwand, den Vorher-Zustand des
Objekts in jedem Methodenaufruf zu validieren und solange jede Methode einen
neuen gültigen Zustand produziert (und nur Methoden Zustände erzeugen können),
ist das auch nicht nötig. Allerdings gilt die Annahme eines gültigen
Objektzustandes nicht im Constructor! Bevor wir
this.#kilometers
und this.#gear
erstmals definieren,
sind sie undefined
und damit ist unser Auto in einem eindeutig
nicht-wohldefinierten Zustand:
class Car { #kilometers; #gear; constructor () { // Bis zu dieser Stelle ist "#kilometers" undefined this.#kilometers = 0; // Bis zu dieser Stelle ist "#gear" undefined this.#gear = 0; } // drive, shift usw. }
Besonders überraschend ist das nicht, denn der Constructor ist ja gerade dafür
da, unser Objekt erstmals zu konstruieren, und was nicht fertig konstruiert ist,
ist noch nicht in einem nicht-wohldefinierten Zustand (es sei denn wir nehmen
die undefined
-Fälle in die Liste der von uns als gültig betrachteten
Zustände auf, was diese Liste allerdings so umfangreich machen würde, dass ihr
praktischer Nutzen dahin geht). Diese nicht-wohldefinierten
Zustände mitten in einem Sprachkonstrukt, das genau diese Zustände verhindern
soll (Klasse), ist was ich mit „Loch“ meine. Es ist eine Lücke in unseren
Annahmen (z. B. „sauber konstruiere Klasse === State Machine“) und jenen
Maßnahmen, die eigentlich rund um die Vermeidung solcher Lücken in unserer
Auto-State-Machine kreisen (z. B. sauber konstruierte Methoden). Ein
solches Loch muss keinen Bug auslösen, aber falls es der Constructor schafft,
das Objekt in einen nicht-vorgesehenen Zustand zu versetzen, könnten die
Methoden (deren Kern-Annahme ist, gültige Ausgangszustände vorzufinden) in
Schwierigkeiten kommen und Bugs zutage treten lassen.
Solche Löcher lassen sich im Allgemeinen nicht stopfen. Natürlich könnten wir in
unserem simplen Beispiel den nicht-wohldefinierten Zustand (bzw. die vielen
verschiedenen undefinierten Zustände) entfernen, indem wir den Constructor
löschen und die Felder #gear
und #kilometers
anderweitig initialisieren:
class Car { #kilometers = 0; // Deklaration PLUS initialisierung #gear = 0; // Deklaration PLUS initialisierung // KEIN Constructor mehr // drive, shift usw. }
Damit ist aber weniger das Loch an sich gestopft, als vielmehr ein löchriges Bauteil entfernt worden. Der Constructor war in diesem Fall überflüssig und daher können wir das Loch zusammen mit dem Constructor loswerden. Sobald der Constructor aber nicht überflüssig ist, weil er z. B. Parameter empfängt und validiert …
class Car { #kilometers; #gear; constructor (km = 0) { if (typeof km !== "number" || km < 0) { throw new Error(); } this.#kilometers = km; this.#gear = 0; } // drive, shift usw. }
… haben wir wieder einen Programmteil, in dem das Objekt nicht wohldefiniert ist. Natürlich könnten wir auch hier wieder versuchen einen Workaround zu schaffen, indem wir die privaten Felder bei ihrer Initialisierung mit Default-Werten initialisieren, die später überschrieben werden:
class Car { #kilometers = 0; // brauchbarer Default #gear = 0; // brauchbarer Default constructor (km = 0) { if (typeof km !== "number" || km < 0) { throw new Error(); } this.#kilometers = km; } // drive, shift usw. }
Aber das lässt sich nicht ohne weiteres generalisieren! Für die meisten
Zahl-Felder mag 0
ein brauchbarer Standardwert sein, gerade wenn
er wie in unserem Beispiel innerhalb des gültigen Wertebereichs für Kilometer
und Gänge liegt. Was ist aber mit Feldern, für die es keinen selbsterklärenden
Standard gibt und die, weil von irgendwelchen Inputs und Validierungen abhängig,
erst im Constructor festgelegt werden?
class Car { #kilometers = 0; #gear = 0; #seats; // was könnte hier der Standard sein? constructor (seats, km = 0) { if (typeof km !== "number" || km < 0) { throw new Error(); } this.#kilometers = km; if (typeof seats !== "number" || seats < 1) { throw new Error(); } this.#seats = seats; } // drive, shift usw. }
Das Feld #seats
ist auch eine Zahl und wenn wir nur auf die
Datentypen schauen, könnten wir vielleicht 0
für einen „validen“
Wert halten, aber semantisch ist ein Auto ohne Sitzplätze fragwürdig. Es wäre
korrekter, die Sitzplätze als nicht definiert zu betrachten, solange wir keinen
entsprechenden User-Input erhalten und validiert haben. Aber damit ginge wieder
ein Constructor-Loch einher.
Statt sich an weiteren klapprigen und/oder nicht-generalisierbaren Workarounds zu probieren, finde ich es sehr viel sinnvoller, das (mögliche) Loch, dass ein Constructor darstellt, als gegeben zu akzeptieren und damit zu leben. Wenn wir hinnehmen, dass ein Objekt innerhalb des Constructors in einem nicht-wohldefinierten Zustand sein kann (bzw. ist, denn sonst wäre der Constructor ja überflüssig), folgt daraus eigentlich nur eine Regel: keine Methoden im Constructor verwenden! Methoden besorgen einen Übergang von wohldefiniertem Zustand A zu wohldefiniertem Zustand B, aber wenn wir im Constructor sind, gibt es eben keinen wohldefinierten Zustand A. Halten wir uns an diese einfache Regel, ist das Loch im Constructor nichts, was uns stören kann, sondern es ist sogar nützlich – wir können einfach einen Ausgangszustand für unser Objekt herstellen, indem wir ein paar Zeilen imperativen Code schreiben und diesen gründlich testen. Es ist im Prinzip ein praktischer Anwendungsfall für Ambiguitätstoleranz. Unsere Klasse kann sowohl eine saubere State Machine sein als auch ungültige Zustände (in engen Grenzen) erlauben. Und solange wir jeweils wissen, wann welchen Garantien gelten und wann nicht, ist das auch gar kein Problem.
TypeScript hilft im Übrigen an dieser Stelle auch kein bisschen weiter, das Constructor-Loch besteht weiterhin und kann sich bemerkbar machen:
class Car { // Anname: jede Car-Instanz hat immer einen numerischen kilometers-Wert private kilometers: number; constructor (km: number) { // hier this.kilometers auszulesen lässt das Typesystem nicht zu, // aber was sehr wohl geht ist... this.accessKilometers(); this.kilometers = km; } private accessKilometers () { console.log(this.kilometers); // undefined } }
Wie wir es auch drehen und wenden: ein Constructor ist nun mal dafür da, einen initialen wohldefinierten Zustand eines Objekts herzustellen und das bringt mit sich, dass vor und während dieses Prozesses kein wohldefinierter Zustand vorhanden ist. Ein solches Loch ist also kein Programmierfehler unserseits, im Wesen eines Constructors als solchem begründet! Der Constructor selbst, bzw. die Möglichkeit, ein Objekt auf imperative Weise zu konstruieren, ist das Loch, nicht unsere Benutzung des Constructors.
Löcher gibt es aber nicht nur in Klassen, sondern auch in normalen Funktionen. Diese fallen gerade in TypeScript zwar gern auf, aber lassen sich nicht sinnvoll stopfen:
type Car = { kilometers: number; gear: number; } function makeObject <T extends object> (...entries: [string, any][]): T { let result = {}; for (const [property, value] of entries) { result[property] = value; } return result; } const myCar: Car = makeObject<Car>(["kilometers", 42], [ "gear", 0 ]);
Dieser Code ist kein valides TypeScript und ist auch nicht ohne weiteres zum Funktionieren zu bringen:
- Im Jetzt-Zustand hat
result
den Typ{}
, weswegen die Zeileresult[property] = value
nicht funktioniert – der Typ{}
hat schließlich keine Properties! - Hätte
result
den TypT
, dürfte es nicht mit{}
initialisiert werden - Hätte
result
einen anderen Typ alsT
wie z. B.Partial<T>
, würde es nicht zum RückgabetypT
der Funktion passen
Auch wenn wir für entries
etwas weniger laxes als
[string, any][]
einsetzen ändert das nichts am Grundproblem: in
der Funktion entsteht das Objekt vom Typ T
gerade erst, weswegen es
vor Durchlauf der letzten Schleifeniteration prinzipbedingt noch kein
T
sein kann. Es handelt sich im Wesentlichen um das gleiche Loch
wie im Constructor – um imperativen Objektzusammenbau.
Löcher wie im JavaScript-Klassenconstructor oder dem iterativen Zusammenbau von TypeScript-Objekten kommen aus Eigenschaften der Programmiersprachen selbst. Es gibt andere Sprachen (z. B. Haskell und Rust), in denen solche Löcher wesentlich seltener auftreten und in denen durch ausgefuchste Typsysteme oder das Fehlen bestimmter Sprachkonstrukte das Mantra „make illegal states unrepresentable“ tatsächlich umsetzbar ist – um mit JavaScript auch dorthin zu kommen, müssten wir Sprachkonstrukten wie Constructor-Funktionen abschwören und uns auf Objektliterale beschränken.
Diese tendenziell lochfreien Programmiersprachen sind aber noch eher jenseits
des Mainstreams zu finden und auch weit weniger einfach zu lernen als etwa
JavaScript und TypeScript&nsbp;– Gründlichkeit hat nun mal einen Preis.
Es ist extrem einfach, in einem Klassenconstructor aus dem Nichts ein Objekt zu
initialisieren oder in TypeScript mithilfe von any
einen Record
zusammenzubasteln, und diese Einfachheit ist viel wert. Löcher sind lediglich
die Kehrseite dieser Einfachheit. Löcher gilt es zu erkennen, zu tolerieren und
mögliche Probleme sollten weitsichtig umschifft werden, z.B. durch die Anwendung
der Regel „keine Methodenaufrufe im Constructor“.