Eine JavaScript-Lifecycle-Falle — Wie Class-Field-Initialiser die Vererbung brechen

Wer gerne mal den Constructor einer Subklasse wegrationalisiert, sollte sich vor einer subtilen Falle in Acht nehmen. Die Initialisierung von Klassenfeldern (Class Fields) findet nämlich unter Umständen nicht dann statt, wenn man es erwartet.

Hintergrund

Ich migriere derzeit @pdf-lib/fontkit nach TypeScript. Dabei habe ich unbemerkt eine Änderung im Laufzeitverhalten eingebaut, die extrem schwer zu finden war.

Die Basisklasse für alle Schriftarten ist SFNTFont. Deren Constructor macht im Grunde fast nichts, außer einen Getter für jede bekannte Schrifttabelle einer SFNT-Schriftart zu definieren. Das eigentliche Decodieren der Tabellen passiert erst später bei Bedarf (Lazy Loading).

Eine WOFF2Font hat im Wesentlichen dieselbe Struktur wie TrueType-/OpenType-Schriftarten, allerdings sind die Schriftdaten mit dem Brotli-Algorithmus komprimiert.

Wir erstellen hier eine stark vereinfachte Version des Codes, die das Problem verdeutlicht. Wer den echten Code sehen möchte oder an einer TypeScript-Version von fontkit interessiert ist, findet das Projekt unter pdf-lab.

// sfnt-font.js
export class SFNTFont {
    constructor(data) {
        this.data = data;

        console.log(`decompressed-Flag im Constructor: ${this.decompressed}`);

        // Beispiel für einen Getter.
        Object.defineProperty(this, 'hmtx', {
            get: () => this.getTable('hmtx'),
        });

        // Genau diese Zeile Debug-Code hat das Problem verursacht:
        console.log(this.hmtx);
    }

    getTable(name) {
        // "Decodiere" this.data in die Schrifttabelle `name` ...
        return `table data: ${this.data}`;
    }
}

Das console.log() in Zeile 6 wird gleich noch wichtig. Es soll uns zeigen, dass die Eigenschaft decompressed zu diesem Zeitpunkt noch undefined ist.

Das andere console.log() gibt die "decodierte" Tabelle aus.

Und hier ist ein kleines Skript, das die Klasse verwendet:

// run.js
import { SFNTFont } from './sfnt-font.js';

console.log('Erstelle Schriftart...');
const font = new SFNTFont('abcxyz');
console.log('Schriftart instanziiert');
console.log(font.getTable('hmtx'));

Nach dem Ausführen mit node ./run.js sieht die Ausgabe wie folgt aus:

Erstelle Schriftart...
decompressed-Flag im Constructor: undefined
table data: abcxyz
Schriftart instanziiert
table data: abcxyz

Das entspricht den Erwartungen. Das decompressed-Flag ist nicht definiert. Der Constructor ruft this.getTable() auf und gibt das Ergebnis aus. Anschließend ruft das Skript run.js ebenfalls font.getTable() auf und erhält dasselbe Ergebnis.

Mit Vererbung

Jetzt kommt die Subklasse WOFF2Font ins Spiel:

// woff2-font.js
import { SFNTFont } from './sfnt-font.js';

export class WOFF2Font extends SFNTFont {
    decompressed = false;

    getTable(name) {
        console.log(`decompressed-Flag vorher: ${this.decompressed}`);
        if (!this.decompressed) {
            // Daten entpacken.
            this.data = this.decompress();
            this.decompressed = true;
        }
        console.log(`decompressed-Flag nachher: ${this.decompressed}`);

        return super.getTable(name);
    }

    decompress() {
        // "Entpacke" die Daten.
        return this.data + this.data;
    }
}

Es ist absolut kritisch, dass die Daten nur ein einziges Mal entpackt werden. Das Flag in Zeile 4 soll genau das sicherstellen. Es wird initial auf false gesetzt. Wenn die Daten zum ersten Mal benötigt werden, prüft die Logik das Flag, entpackt die Daten gegebenenfalls und setzt das Flag auf true, um ein erneutes Entpacken zu verhindern. Das Entpacken wird hier einfach durch das Verdoppeln der Eingabedaten simuliert.

Jetzt erstellen wir ein zweites Skript namens run-woff2.js:

// run-woff2.js
import { WOFF2Font } from './woff2-font.js';

console.log('Erstelle Schriftart...');
const font = new WOFF2Font('abcxyz');
console.log('Schriftart instanziiert');
console.log(font.getTable('hmtx'));

Ein Aufruf von node ./run-woff2.js liefert dieses Ergebnis:

Erstelle Schriftart...
decompressed-Flag im Constructor: undefined
decompressed-Flag vorher: undefined
decompressed-Flag nachher: true
table data: abcxyzabcxyz
Schriftart instanziiert
decompressed-Flag vorher: false
decompressed-Flag nachher: true
table data: abcxyzabcxyzabcxyzabcxyz

Beim ersten Zugriff auf die Tabelle – direkt aus dem Constructor heraus – sieht scheinbar noch alles richtig aus. Die WOFF2Font "entpackt" die Daten wie gewünscht durch Verdoppelung.

Der Schutzmechanismus gegen das doppelte Entpacken versagt jedoch. Sobald die Instanz später im Skript verwendet wird, werden die Tabellendaten ein zweites Mal "entpackt".

Die Erklärung

Wer sich die Ausgabe von run-woff2.js genau ansieht, bemerkt ein wichtiges Detail bei den Zeilen, die den Wert des Flags ausgeben:

...
decompressed-Flag im Constructor: undefined
...

Das Flag ist im Constructor der Basisklasse undefined, obwohl es eigentlich mit false initialisiert wurde. Man könnte nun argumentieren, dass das keine Rolle spielt, weil undefined ein „falsy“ Wert ist. Und tatsächlich funktioniert der erste Durchlauf genau deshalb wie gewünscht.

Doch beim zweiten Mal, wenn die Methode getTable() vom Hauptskript aufgerufen wird, zeigt sich das eigentliche Problem:

...
decompressed-Flag vorher: false
decompressed-Flag nachher: true
table data: abcxyzabcxyzabcxyzabcxyz

Plötzlich steht das Flag wieder auf false, obwohl es kurz zuvor auf true gesetzt wurde.

Das ist exakt das standardmäßige Verhalten von JavaScript. Die Ausführungsreihenfolge ohne expliziten Subklassen-Constructor lautet:

  1. Führe den Constructor der Basisklasse aus.
  2. Führe die Feld-Initialisierer (Instance Field Initialiser) der Subklasse aus.

Wenn der Constructor der Basisklasse läuft, existiert die Eigenschaft decompressed auf der Instanz noch gar nicht. Sie wird erst danach angelegt – also nachdem der Basis-Constructor bereits zurückgekehrt ist – und stur auf false gesetzt. Das passiert selbst dann, wenn die Eigenschaft in der Zwischenzeit (durch die Callback-Methoden im Basis-Constructor) dynamisch erzeugt und auf true gesetzt wurde. Der Feld-Initialisierer überschreibt den Wert einfach eiskalt.

Wie lässt sich das vermeiden?

In nativem JavaScript gibt es dafür keine elegante Patentlösung. Die sicherste Regel lautet: Niemals Class-Field-Initialiser in Subklassen verwenden. Das führt jedoch zu recht klobigem Boilerplate-Code wie diesem:

export class WOFF2Font extends SFNTFont {
    constructor(data) {
        super(data);

        this.decompressed = this.decompressed ?? false;
    }

    // ...
}

Alternativ kann man die Eigenschaft im Klassenrumpf deklarieren, ohne ihr einen Wert zuzuweisen:

export class WOFF2Font extends SFNTFont {
    decompressed;

    // ...
}

Das wirkt allerdings oft unklar und irritierend, da das Weglassen von Standardwerten bei Boolean-Flags im Allgemeinen als schlechter Stil gilt.

In der Regel ist dieses Verhalten ein klares Symptom für ein architektonisches Problem (Code-Smell). Es zeigt, dass das Design der Basisklasse fehlerhaft ist, weil es überschreibbare Subklassen-Methoden aus dem eigenen Constructor heraus aufruft, noch bevor die Subklasse überhaupt fertig instanziiert wurde.

Man könnte meinen, das Problem zu lösen, indem die Eingabedaten im Subklassen-Constructor einfach bedingungslos vor dem super-Aufruf entpackt werden:

export class WOFF2Font extends SFNTFont {
    constructor(data) {
        this.decompress(); // Funktioniert nicht!
        super(this.data);
    }

    // ...
}

Das schlägt allerdings fehl, da in JavaScript vor dem Aufruf von super() nicht auf this zugegriffen werden darf.

In TypeScript gibt es dank der Typ-Erosion eine saubere Lösung mittels einer Definite Assignment Assertion (!):

export class WOFF2Font extends SFNTFont {
    private decompressed!: boolean;

    // ...
}

Das funktioniert, weil die Assertion zur Laufzeit keinerlei JavaScript-Code erzeugt. Sie signalisiert dem TypeScript-Compiler lediglich: „Ich weiß, was ich tue“. Im kompilierten JavaScript-Output sieht man davon nichts mehr.

Mein bevorzugter Ansatz für dieses Projekt war es letztlich, das Entpacken der Daten direkt in den Constructor zu verlegen, wo es hingehört. Das bisherige Lazy-Loading bietet hier ohnehin keinen echten Vorteil, da die gesamte Klasse ohne die entpackten Daten unbrauchbar ist. Hier ist die finale Version:

// woff2-font.js
import { SFNTFont } from './sfnt-font.js';

export class WOFF2Font extends SFNTFont {
    decompressed;

    constructor(data) {
        super(data);
        this.data = this.decompress();
        this.decompressed = true;
    }

    getTable(name) {
        if (!this.decompressed) {
            throw new Error('Versuch, ein nicht initialisiertes Objekt zu verwenden!');
        }

        return super.getTable(name);
    }

    decompress() {
        return this.data + this.data;
    }
}

Wer bessere Ideen oder sauberere Ansätze für dieses Dilemma hat, kann gerne einen Kommentar hinterlassen.

Kommentar hinterlassen

Die Angabe der E-Mail-Adresse ist freiwillig. Bitte bedenke aber, dass ohne gültige E-Mail-Adresse keine Benachrichtigung über eine Antwort möglich ist. Die Adresse wird nicht zusammen mit dem Kommentar angezeigt!

Eine JavaScript-Lifecycle-Falle — Wie Class-Field-Initialiser die Vererbung brechen

Syzygy-Tabellen in eine Schachengine integrieren

Stolpersteine von Yargs 18

Die einfachste Methode den Zauberwürfel zu lösen

JSON.stringify() missbrauchen

Tücken von JavaScript `for...in`-Schleifen

Diese Website verwendet Cookies und ähnliche Technologien, um gewisse Funktionalität zu ermöglichen, die Benutzbarkeit zu erhöhen und Inhalt entsprechend ihren Interessen zu liefern. Über die technisch notwendigen Cookies hinaus können abhängig von ihrem Zweck Analyse- und Marketing-Cookies zum Einsatz kommen. Sie können ihre Zustimmung zu den vorher erwähnten Cookies erklären, indem sie auf "Zustimmen und weiter" klicken. Hier können sie Detaileinstellungen vornehmen oder ihre Zustimmung - auch teilweise - mit Wirkung für die Zukunft zurücknehmen. Für weitere Informationen lesen sie bitte unsere Datenschutzerklärung.