Deep JavaScript
Bitte unterstützen Sie dieses Buch: kaufen Sie es oder spenden Sie
(Werbung, bitte nicht blockieren.)

13 Techniken zum Instanziieren von Klassen



In diesem Kapitel untersuchen wir mehrere Ansätze zur Erstellung von Klasseninstanzen: Konstruktoren, Factory-Funktionen usw. Wir tun dies, indem wir ein konkretes Problem mehrmals lösen. Der Fokus dieses Kapitels liegt auf Klassen, weshalb Alternativen zu Klassen ignoriert werden.

13.1 Das Problem: Initialisieren einer Property asynchron

Die folgende Container-Klasse soll den Inhalt ihrer Property .data asynchron empfangen. Dies ist unser erster Versuch

class DataContainer {
  #data; // (A)
  constructor() {
    Promise.resolve('downloaded')
      .then(data => this.#data = data); // (B)
  }
  getData() {
    return 'DATA: '+this.#data; // (C)
  }
}

Hauptproblem dieses Codes: Die Property .data ist anfänglich undefined.

const dc = new DataContainer();
assert.equal(dc.getData(), 'DATA: undefined');
setTimeout(() => assert.equal(
  dc.getData(), 'DATA: downloaded'), 0);

In Zeile A deklarieren wir das private Feld .#data, das wir in Zeile B und Zeile C verwenden.

Das Promise innerhalb des Konstruktors von DataContainer wird asynchron aufgelöst, weshalb wir den endgültigen Wert von .data erst sehen können, wenn wir die aktuelle Aufgabe beenden und eine neue starten, über setTimeout(). Anders ausgedrückt, die Instanz von DataContainer ist noch nicht vollständig initialisiert, wenn wir sie zum ersten Mal sehen.

13.2 Lösung: Promise-basierter Konstruktor

Was wäre, wenn wir den Zugriff auf die Instanz von DataContainer verzögern, bis sie vollständig initialisiert ist? Dies können wir erreichen, indem wir einen Promise vom Konstruktor zurückgeben. Standardmäßig gibt ein Konstruktor eine neue Instanz der Klasse zurück, zu der er gehört. Wir können dies überschreiben, indem wir explizit ein Objekt zurückgeben

class DataContainer {
  #data;
  constructor() {
    return Promise.resolve('downloaded')
      .then(data => {
        this.#data = data;
        return this; // (A)
      });
  }
  getData() {
    return 'DATA: '+this.#data;
  }
}
new DataContainer()
  .then(dc => assert.equal( // (B)
    dc.getData(), 'DATA: downloaded'));

Jetzt müssen wir warten, bis wir auf unsere Instanz zugreifen können (Zeile B). Sie wird uns übergeben, nachdem die Daten „heruntergeladen“ wurden (Zeile A). Es gibt zwei mögliche Fehlerquellen in diesem Code

In beiden Fällen werden die Fehler zu Ablehnungen des Promises, das vom Konstruktor zurückgegeben wird.

Vor- und Nachteile

13.2.1 Verwenden einer sofort aufgerufenen asynchronen Pfeilfunktion

Anstatt die Promise API direkt zu verwenden, um das vom Konstruktor zurückgegebene Promise zu erstellen, können wir auch eine asynchrone Pfeilfunktion verwenden, die wir sofort aufrufen

constructor() {
  return (async () => {
    this.#data = await Promise.resolve('downloaded');
    return this;
  })();
}

13.3 Lösung: Statische Factory-Methode

Eine statische Factory-Methode einer Klasse C erstellt Instanzen von C und ist eine Alternative zur Verwendung von new C(). Gängige Namen für statische Factory-Methoden in JavaScript

Im folgenden Beispiel ist DataContainer.create() eine statische Factory-Methode. Sie gibt Promises für Instanzen von DataContainer zurück

class DataContainer {
  #data;
  static async create() {
    const data = await Promise.resolve('downloaded');
    return new this(data);
  }
  constructor(data) {
    this.#data = data;
  }
  getData() {
    return 'DATA: '+this.#data;
  }
}
DataContainer.create()
  .then(dc => assert.equal(
    dc.getData(), 'DATA: downloaded'));

Diesmal ist die gesamte asynchrone Funktionalität in .create() enthalten, was den Rest der Klasse vollständig synchron und damit einfacher macht.

Vor- und Nachteile

13.3.1 Verbesserung: Privater Konstruktor über geheimes Token

Wenn wir sicherstellen wollen, dass Instanzen immer korrekt eingerichtet sind, müssen wir sicherstellen, dass nur DataContainer.create() den Konstruktor von DataContainer aufrufen kann. Dies können wir über ein geheimes Token erreichen

const secretToken = Symbol('secretToken');
class DataContainer {
  #data;
  static async create() {
    const data = await Promise.resolve('downloaded');
    return new this(secretToken, data);
  }
  constructor(token, data) {
    if (token !== secretToken) {
      throw new Error('Constructor is private');
    }
    this.#data = data;
  }
  getData() {
    return 'DATA: '+this.#data;
  }
}
DataContainer.create()
  .then(dc => assert.equal(
    dc.getData(), 'DATA: downloaded'));

Wenn secretToken und DataContainer im selben Modul liegen und nur letzteres exportiert wird, dann haben externe Parteien keinen Zugriff auf secretToken und können daher keine Instanzen von DataContainer erstellen.

Vor- und Nachteile

13.3.2 Verbesserung: Konstruktor wirft Fehler, Factory-Methode leiht sich Prototyp der Klasse

Die folgende Variante unserer Lösung deaktiviert den Konstruktor von DataContainer und verwendet einen Trick, um Instanzen davon auf andere Weise zu erstellen (Zeile A)

class DataContainer {
  static async create() {
    const data = await Promise.resolve('downloaded');
    return Object.create(this.prototype)._init(data); // (A)
  }
  constructor() {
    throw new Error('Constructor is private');
  }
  _init(data) {
    this._data = data;
    return this;
  }
  getData() {
    return 'DATA: '+this._data;
  }
}
DataContainer.create()
  .then(dc => {
    assert.equal(dc instanceof DataContainer, true); // (B)
    assert.equal(
      dc.getData(), 'DATA: downloaded');
  });

Intern ist eine Instanz von DataContainer jedes Objekt, dessen Prototyp DataContainer.prototype ist. Deshalb können wir Instanzen über Object.create() erstellen (Zeile A) und deshalb funktioniert instanceof in Zeile B.

Vor- und Nachteile

13.3.3 Verbesserung: Instanzen sind standardmäßig inaktiv, werden durch Factory-Methode aktiviert

Eine weitere, umständlichere Variante ist, dass Instanzen standardmäßig über das Flag .#active ausgeschaltet sind. Die Initialisierungsmethode .#init(), die sie einschaltet, kann nicht extern aufgerufen werden, aber Data.container() kann sie aufrufen

class DataContainer {
  #data;
  static async create() {
    const data = await Promise.resolve('downloaded');
    return new this().#init(data);
  }

  #active = false;
  constructor() {
  }
  #init(data) {
    this.#active = true;
    this.#data = data;
    return this;
  }
  getData() {
    this.#check();
    return 'DATA: '+this.#data;
  }
  #check() {
    if (!this.#active) {
      throw new Error('Not created by factory');
    }
  }
}
DataContainer.create()
  .then(dc => assert.equal(
    dc.getData(), 'DATA: downloaded'));

Das Flag .#active wird über die private Methode .#check() erzwungen, die am Anfang jeder Methode aufgerufen werden muss.

Der Hauptnachteil dieser Lösung ist ihre Ausführlichkeit. Es besteht auch die Gefahr, dass man vergisst, .#check() in jeder Methode aufzurufen.

13.3.4 Variante: Separate Factory-Funktion

Der Vollständigkeit halber zeige ich eine weitere Variante: Anstatt eine statische Methode als Factory zu verwenden, kann man auch eine separate, eigenständige Funktion verwenden.

const secretToken = Symbol('secretToken');
class DataContainer {
  #data;
  constructor(token, data) {
    if (token !== secretToken) {
      throw new Error('Constructor is private');
    }
    this.#data = data;
  }
  getData() {
    return 'DATA: '+this.#data;
  }
}

async function createDataContainer() {
  const data = await Promise.resolve('downloaded');
  return new DataContainer(secretToken, data);
}

createDataContainer()
  .then(dc => assert.equal(
    dc.getData(), 'DATA: downloaded'));

Eigenständige Funktionen als Factories sind gelegentlich nützlich, aber in diesem Fall bevorzuge ich eine statische Methode

13.4 Unterklasse eines Promise-basierten Konstruktors (optional)

Im Allgemeinen sollte die Unterklasse sparsam eingesetzt werden.

Mit einer separaten Factory-Funktion ist es relativ einfach, DataContainer zu erweitern.

Leider führt die Erweiterung der Klasse mit dem Promise-basierten Konstruktor zu erheblichen Einschränkungen. Im folgenden Beispiel unterklassifizieren wir DataContainer. Die Unterklasse SubDataContainer hat ihr eigenes privates Feld .#moreData, das sie asynchron initialisiert, indem sie sich in das von super-Konstruktor zurückgegebene Promise einklinkt.

class DataContainer {
  #data;
  constructor() {
    return Promise.resolve('downloaded')
      .then(data => {
        this.#data = data;
        return this; // (A)
      });
  }
  getData() {
    return 'DATA: '+this.#data;
  }
}

class SubDataContainer extends DataContainer {
  #moreData;
  constructor() {
    super();
    const promise = this;
    return promise
      .then(_this => {
        return Promise.resolve('more')
          .then(moreData => {
            _this.#moreData = moreData;
            return _this;
          });
      });
  }
  getData() {
    return super.getData() + ', ' + this.#moreData;
  }
}

Leider können wir diese Klasse nicht instanziieren

assert.rejects(
  () => new SubDataContainer(),
  {
    name: 'TypeError',
    message: 'Cannot write private member #moreData ' +
      'to an object whose class did not declare it',
  }
);

Warum das Scheitern? Ein Konstruktor fügt seine privaten Felder immer zu seinem this hinzu. Hier ist jedoch this im Unterklassen-Konstruktor das von super-Konstruktor zurückgegebene Promise (und nicht die von Promise gelieferte Instanz von SubDataContainer).

Dieser Ansatz funktioniert jedoch weiterhin, wenn SubDataContainer keine privaten Felder hat.

13.5 Fazit

Für das in diesem Kapitel untersuchte Szenario bevorzuge ich entweder einen Promise-basierten Konstruktor oder eine statische Factory-Methode plus einen privaten Konstruktor über ein geheimes Token.

Die anderen hier vorgestellten Techniken können jedoch in anderen Szenarien nützlich sein.

13.6 Weiterführende Lektüre