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 Zeile result[property] = value nicht funktioniert – der Typ {} hat schließlich keine Properties!
  • Hätte result den Typ T, dürfte es nicht mit {} initialisiert werden
  • Hätte result einen anderen Typ als T wie z. B. Partial<T>, würde es nicht zum Rückgabetyp T 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“.