nFast jede größere Ansammlung von Mainstreamsprachen-Programmcode zeigtnPhänomene, die ich als Löcher bezeichne. Löcher manifestieren sich im Zuge dernUmsetzung von Konzepten in konkreten Code, sind nicht notwendigerweise Bugs,nhaben oft mit Objekten zu tun und lassen sich nicht loswerden – sienerwachsen aus Tradeoffs beim Sprachdesign und bestehen aus (möglichem)nunerwünschtem Verhalten des aus dem Code resultierenden Programms. Ich halte esnfür wichtig, dass wir als Nutzer von Mainstream-Programiersprachen wienJavaScript und TypeScript solche Löcher erkennen und damit einen souveränennUmgang pflegen. Was nach meiner Auffassung bedeutet, sie einfach zu tolerieren.n
nn
nUm als Loch zu gelten, muss ein Stück Code ein subtileres (potenzielles) Problemnaufweisen als beispielsweise ein Fall-Through in einem Switch-Statement und esndarf sich auch nicht durch ein angeflanschtes Typsystem wie etwa TypeScriptnreparieren lassen. Vielmehr geht es um unerwünschtes Verhalten, das sich ausnbestimmten Patterns oder Sprachfeatures ganz automatisch ergibt. Meinnpersönliches Lieblingsloch sind Constructor-Funktionen in JavaScript bzw.nTypeScript. Nehmen wir doch zu Demonstrationszwecken ein allgemeinnverständliches, wenn auch an den Haaren herbeigezogenes, praxisfernesnSimpel-JavaScript-Beispiel her:n
nn
class Car {n #kilometers;n #gear;nn constructor () {n this.#kilometers = 0;n this.#gear = 0;n }nn drive (km) {n if (typeof km !== "number" || km < 0) {n throw new Error();n }n this.#kilometers += km;n }nn shift (gear) {n if (typeof gear !== "number" || gear < -1 || gear > 5) {n throw new Error();n }n this.#gear = gear;n }nn get kilometers () {n return this.#kilometers;n }nn get gear () {n return this.#gear;n }nn}
nn
nEine der zentralen Ideen hinter objektorientierter Programmierung ist, dassnObjekte ihren internen Zustand vor der Außenwelt verbergen und Modifikationenndes Zustands nur über Methoden erlauben. In der obigen Beispielklasse sind dienFelder für #gear
und #kilometers
privat und können nurnüber die Methoden shift()
und drive()
indirektnverändert werden. Die Methoden fangen ungültige Inputs ab und stellen damitnsicher, dass unser Kilometerzähler stets nur wächst und dass wir keinen Gangneinlegen, den wir nicht zur Verfügung haben. Es gibt also eine endliche Menge annZuständen, die ein Auto-Objekt einnehmen kann und daher nur eine endliche Mengenvon Fällen, über die wir uns für unser Objekt Gedanken machen müssen:n
nn
// Ein möglicher Zustandnlet a = { #kilometers: 0, #gear: 2 }nn// Ein weiterer möglicher Zustandnlet b = { #kilometers: 42, #gear: 0 }nn// Ein unmöglicher Zustand, den wir nicht beachten müssennlet c = { #kilometers: 42, #gear: -7 }nn// Ein weiterer unmöglicher Zustandnlet d = { #kilometers: 42, #gear: 0, #asdf: "Hi!" }
nn
nJede Instanz der Auto-Klasse ist also stets in einem wohldefinierten Zustand mitneinem validen Gang, positiver Kilometerzahl und nichts anderem, womit jede diesernInstanzen im Prinzip eine solide State Machine darstellt. Oder?n
nn
nEs gibt in der Klasse ein Loch, in dem das Auto tatsächlich nicht inneinem wohldefinierten Zustand ist – und zwar im Constructor!nDie Methoden shift()
und drive()
bewerkstelligen dienÜbergänge von einem gültigen Zustand unseres Auto-Objekts in den nächstenngültigen Zustand und prüfen dafür die Inputs, nehmen aber einfach an, dass dienAusgangszustände jeweils auch gültig sind. Diese Annahme müssen die Methodennauch treffen, des wäre unverhältnismäßiger Aufwand, den Vorher-Zustand desnObjekts in jedem Methodenaufruf zu validieren und solange jede Methode einennneuen gültigen Zustand produziert (und nur Methoden Zustände erzeugen können),nist das auch nicht nötig. Allerdings gilt die Annahme eines gültigennObjektzustandes nicht im Constructor! Bevor wirnthis.#kilometers
und this.#gear
erstmals definieren,nsind sie undefined
und damit ist unser Auto in einem eindeutignnicht-wohldefinierten Zustand:n
nn
class Car {n #kilometers;n #gear;nn constructor () {n // Bis zu dieser Stelle ist "#kilometers" undefinedn this.#kilometers = 0;n // Bis zu dieser Stelle ist "#gear" undefinedn this.#gear = 0;n }nn // drive, shift usw.n}
nn
nBesonders überraschend ist das nicht, denn der Constructor ist ja gerade dafürnda, unser Objekt erstmals zu konstruieren, und was nicht fertig konstruiert ist,nist noch nicht in einem nicht-wohldefinierten Zustand (es sei denn wir nehmenndie undefined
-Fälle in die Liste der von uns als gültig betrachtetennZustände auf, was diese Liste allerdings so umfangreich machen würde, dass ihrnpraktischer Nutzen dahin geht). Diese nicht-wohldefiniertennZustände mitten in einem Sprachkonstrukt, das genau diese Zustände verhindernnsoll (Klasse), ist was ich mit „Loch“ meine. Es ist eine Lücke in unserennAnnahmen (z. B. „sauber konstruiere Klasse === State Machine“) und jenennMaßnahmen, die eigentlich rund um die Vermeidung solcher Lücken in unserernAuto-State-Machine kreisen (z. B. sauber konstruierte Methoden). Einnsolches Loch muss keinen Bug auslösen, aber falls es der Constructor schafft,ndas Objekt in einen nicht-vorgesehenen Zustand zu versetzen, könnten dienMethoden (deren Kern-Annahme ist, gültige Ausgangszustände vorzufinden) innSchwierigkeiten kommen und Bugs zutage treten lassen.n
nn
nSolche Löcher lassen sich im Allgemeinen nicht stopfen. Natürlich könnten wir innunserem simplen Beispiel den nicht-wohldefinierten Zustand (bzw. die vielennverschiedenen undefinierten Zustände) entfernen, indem wir den Constructornlöschen und die Felder #gear
und #kilometers
nanderweitig initialisieren:n
nn
class Car {n #kilometers = 0; // Deklaration PLUS initialisierungn #gear = 0; // Deklaration PLUS initialisierungnn // KEIN Constructor mehrn // drive, shift usw.n}
nn
nDamit ist aber weniger das Loch an sich gestopft, als vielmehr ein löchrigesnBauteil entfernt worden. Der Constructor war in diesem Fall überflüssig undndaher können wir das Loch zusammen mit dem Constructor loswerden. Sobald dernConstructor aber nicht überflüssig ist, weil er z. B. Parameter empfängtnund validiert …n
nn
class Car {n #kilometers;n #gear;nn constructor (km = 0) {n if (typeof km !== "number" || km < 0) {n throw new Error();n }n this.#kilometers = km;n this.#gear = 0;n }nn // drive, shift usw.n}
nn
n… haben wir wieder einen Programmteil, in dem das Objekt nichtnwohldefiniert ist. Natürlich könnten wir auch hier wieder versuchen einennWorkaround zu schaffen, indem wir die privaten Felder bei ihrer Initialisierungnmit Default-Werten initialisieren, die später überschrieben werden:n
nn
class Car {n #kilometers = 0; // brauchbarer Defaultn #gear = 0; // brauchbarer Defaultnn constructor (km = 0) {n if (typeof km !== "number" || km < 0) {n throw new Error();n }n this.#kilometers = km;n }nn // drive, shift usw.n}
nn
nAber das lässt sich nicht ohne weiteres generalisieren! Für die meistennZahl-Felder mag 0
ein brauchbarer Standardwert sein, gerade wennner wie in unserem Beispiel innerhalb des gültigen Wertebereichs für Kilometernund Gänge liegt. Was ist aber mit Feldern, für die es keinen selbsterklärendennStandard gibt und die, weil von irgendwelchen Inputs und Validierungen abhängig,nerst im Constructor festgelegt werden?n
nn
class Car {n #kilometers = 0;n #gear = 0;n #seats; // was könnte hier der Standard sein?nn constructor (seats, km = 0) {n if (typeof km !== "number" || km < 0) {n throw new Error();n }n this.#kilometers = km;n if (typeof seats !== "number" || seats < 1) {n throw new Error();n }n this.#seats = seats;n }nn // drive, shift usw.n}
nn
nDas Feld #seats
ist auch eine Zahl und wenn wir nur auf dienDatentypen schauen, könnten wir vielleicht 0
für einen „validen“nWert halten, aber semantisch ist ein Auto ohne Sitzplätze fragwürdig. Es wärenkorrekter, die Sitzplätze als nicht definiert zu betrachten, solange wir keinennentsprechenden User-Input erhalten und validiert haben. Aber damit ginge wiedernein Constructor-Loch einher.n
nn
nStatt sich an weiteren klapprigen und/oder nicht-generalisierbaren Workaroundsnzu probieren, finde ich es sehr viel sinnvoller, das (mögliche) Loch, dass einnConstructor darstellt, als gegeben zu akzeptieren und damit zu leben. Wenn wirnhinnehmen, dass ein Objekt innerhalb des Constructors in einemnnicht-wohldefinierten Zustand sein kann (bzw. ist, denn sonst wäre dernConstructor ja überflüssig), folgt daraus eigentlich nur eine Regel:nkeine Methoden im Constructor verwenden! Methoden besorgenneinen Übergang von wohldefiniertem Zustand A zu wohldefiniertemnZustand B, aber wenn wir im Constructor sind, gibt es eben keinennwohldefinierten Zustand A. Halten wir uns an diese einfache Regel, ist dasnLoch im Constructor nichts, was uns stören kann, sondern es ist sogarnnützlich – wir können einfach einen Ausgangszustand für unser Objektnherstellen, indem wir ein paar Zeilen imperativen Code schreiben und diesenngründlich testen. Es ist im Prinzip ein praktischer Anwendungsfall fürnAmbiguitätstoleranz. Unsere Klasse kann sowohl eine saubere State Machine seinnals auch ungültige Zustände (in engen Grenzen) erlauben. Und solange wirnjeweils wissen, wann welchen Garantien gelten und wann nicht, ist das auch garnkein Problem.n
nn
nTypeScript hilft im Übrigen an dieser Stelle auch kein bisschen weiter,ndas Constructor-Loch besteht weiterhin und kann sich bemerkbar machen:n
nn
class Car {nn // Anname: jede Car-Instanz hat immer einen numerischen kilometers-Wertn private kilometers: number;nn constructor (km: number) {n // hier this.kilometers auszulesen lässt das Typesystem nicht zu,n // aber was sehr wohl geht ist...n this.accessKilometers();n this.kilometers = km;n }nn private accessKilometers () {n console.log(this.kilometers); // undefinedn }nn}
nn
nWie wir es auch drehen und wenden: ein Constructor ist nun mal dafür da, einenninitialen wohldefinierten Zustand eines Objekts herzustellen und das bringt mitnsich, dass vor und während dieses Prozesses kein wohldefinierter Zustandnvorhanden ist. Ein solches Loch ist also kein Programmierfehler unserseits,nim Wesen eines Constructors als solchem begründet! Der Constructor selbst, bzw.ndie Möglichkeit, ein Objekt auf imperative Weise zu konstruieren, ist das Loch,nnicht unsere Benutzung des Constructors.n
nn
nLöcher gibt es aber nicht nur in Klassen, sondern auch in normalen Funktionen.nDiese fallen gerade in TypeScript zwar gern auf, aber lassen sich nicht sinnvollnstopfen:n
nn
type Car = {n kilometers: number;n gear: number;n}nnfunction makeObject <T extends object> (...entries: [string, any][]): T {n let result = {};n for (const [property, value] of entries) {n result[property] = value;n }n return result;n}nnconst myCar: Car = makeObject<Car>(["kilometers", 42], [ "gear", 0 ]);
nn
nDieser Code ist kein valides TypeScriptnund ist auch nicht ohne weiteres zum Funktionieren zu bringen:n
nn
- n
- 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
n
n
n
nn
nAuch wenn wir für entries
etwas weniger laxes alsn[string, any][]
einsetzen ändert das nichts am Grundproblem: innder Funktion entsteht das Objekt vom Typ T
gerade erst, weswegen esnvor Durchlauf der letzten Schleifeniteration prinzipbedingt noch keinnT
sein kann. Es handelt sich im Wesentlichen um das gleiche Lochnwie im Constructor – um imperativen Objektzusammenbau.n
nn
nLöcher wie im JavaScript-Klassenconstructor oder dem iterativen Zusammenbau vonnTypeScript-Objekten kommen aus Eigenschaften der Programmiersprachen selbst. Esngibt andere Sprachen (z. B. Haskell und Rust), in denen solche Löchernwesentlich seltener auftreten und in denen durch ausgefuchste Typsysteme oderndas Fehlen bestimmter Sprachkonstrukte das Mantran„make illegal states unrepresentable“ tatsächlichnumsetzbar ist – um mit JavaScript auch dorthin zu kommen, müssten wirnSprachkonstrukten wie Constructor-Funktionen abschwören und uns aufnObjektliterale beschränken.n
n
nDiese tendenziell lochfreien Programmiersprachen sind aber noch eher jenseitsndes Mainstreams zu finden und auch weit weniger einfach zu lernen als etwanJavaScript und TypeScript&nsbp;– Gründlichkeit hat nun mal einen Preis.nEs ist extrem einfach, in einem Klassenconstructor aus dem Nichts ein Objekt zuninitialisieren oder in TypeScript mithilfe von any
einen Recordnzusammenzubasteln, und diese Einfachheit ist viel wert. Löcher sind lediglichndie Kehrseite dieser Einfachheit. Löcher gilt es zu erkennen, zu tolerieren undnmögliche Probleme sollten weitsichtig umschifft werden, z.B. durch die Anwendungnder Regel „keine Methodenaufrufe im Constructor“.n
Schreibe einen Kommentar