深層JavaScript
本書をサポートしてください:購入する または 寄付する
(広告、ブロックしないでください。)

13 クラスのインスタンス化テクニック



この章では、クラスのインスタンスを作成するためのいくつかのアプローチ(コンストラクタ、ファクトリ関数など)について調べます。1つの具体的な問題を何度も解決することで、これを行います。この章の焦点はクラスにあるため、クラスの代替案は無視されます。

13.1 問題:プロパティの非同期初期化

次のコンテナクラスは、そのプロパティ`.data`のコンテンツを非同期で受信することになっています。これは最初の試みです。

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

このコードの主要な問題:プロパティ`.data`は最初は`undefined`です。

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

A行では、B行とC行で使用しているプライベートフィールド `.#data`を宣言します。

`DataContainer`のコンストラクタ内のPromiseは非同期で解決されるため、現在のタスクを完了して`setTimeout()`を介して新しいタスクを開始した場合にのみ、`.data`の最終値を確認できます。言い換えれば、`DataContainer`のインスタンスはまだ完全に初期化されていません。

13.2 解決策:Promiseベースのコンストラクタ

`DataContainer`のインスタンスが完全に初期化されるまで、インスタンスへのアクセスを遅らせるにはどうすればよいでしょうか?コンストラクタからPromiseを返すことで実現できます。デフォルトでは、コンストラクタはそれが属するクラスの新しいインスタンスを返します。オブジェクトを明示的に返す場合は、それをオーバーライドできます。

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'));

これで、インスタンスにアクセスできるようになるまで待つ必要があります(B行)。データが「ダウンロード」された後(A行)、インスタンスが渡されます。このコードには2つの潜在的なエラーの原因があります。

いずれの場合も、エラーはコンストラクタから返されるPromiseの拒否になります。

長所と短所

13.2.1 すぐに実行される非同期アロー関数の使用

コンストラクタから返されるPromiseを作成するためにPromise APIを直接使用する代わりに、すぐに実行する非同期アロー関数を使用することもできます。

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

13.3 解決策:静的ファクトリメソッド

クラス`C`の*静的ファクトリメソッド*は`C`のインスタンスを作成し、`new C()`を使用する代替手段です。JavaScriptでの静的ファクトリメソッドの一般的な名前

次の例では、`DataContainer.create()`は静的ファクトリメソッドです。`DataContainer`のインスタンスに対するPromiseを返します。

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'));

今回は、すべての非同期機能が`.create()`に含まれているため、クラスの残りの部分は完全に同期になり、シンプルになります。

長所と短所

13.3.1 改善:シークレットトークンによるプライベートコンストラクタ

インスタンスが常に正しく設定されるようにするには、`DataContainer.create()`のみが`DataContainer`のコンストラクタを呼び出せるようにする必要があります。シークレットトークンを使用することで実現できます。

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'));

`secretToken`と`DataContainer`が同じモジュールに存在し、後者のみがエクスポートされている場合、外部当事者は`secretToken`にアクセスできず、そのため`DataContainer`のインスタンスを作成できません。

長所と短所

13.3.2 改善:コンストラクタが例外をスローし、ファクトリメソッドがクラスプロトタイプを借用する

この解決策のバリアントでは、`DataContainer`のコンストラクタを無効にし、別の方法でインスタンスを作成するトリックを使用します(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');
  });

`DataContainer`のインスタンスは、内部的にはプロトタイプが`DataContainer.prototype`である任意のオブジェクトです。そのため、`Object.create()`(A行)を介してインスタンスを作成できるため、B行で`instanceof`が機能します。

長所と短所

13.3.3 改善:インスタンスはデフォルトで非アクティブ、ファクトリメソッドでアクティブ化

もう1つの、より冗長なバリアントは、デフォルトで`.#active`フラグを介してインスタンスがオフになることです。それらをオンにする初期化メソッド`.#init()`は外部からアクセスできませんが、`Data.container()`はそれを呼び出すことができます。

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'));

`.#active`フラグは、各メソッドの先頭で呼び出される必要があるプライベートメソッド`.#check()`によって強制されます。

この解決策の大きな欠点は、冗長性です。各メソッドで`.#check()`を呼び出すのを忘れるリスクもあります。

13.3.4 バリアント:独立したファクトリ関数

完全を期すために、別のバリアントを示します。静的メソッドをファクトリとして使用する代わりに、独立したスタンドアロン関数を使用することもできます。

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'));

ファクトリとしてのスタンドアロン関数は時々有用ですが、この場合は静的メソッドの方が好きです。

13.4 Promiseベースのコンストラクタのサブクラス化(オプション)

一般的に、サブクラス化は控えめに使用するものです。

独立したファクトリ関数を使用すると、`DataContainer`を拡張するのは比較的容易です。

しかし、Promiseベースのコンストラクタを持つクラスを拡張すると、深刻な制限が生じます。次の例では、`DataContainer`をサブクラス化します。サブクラス`SubDataContainer`には、独自のプライベートフィールド`.#moreData`があり、スーパークラスのコンストラクタから返されたPromiseにフックすることで非同期的に初期化します。

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;
  }
}

しかし、このクラスをインスタンス化できません。

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

なぜ失敗するのでしょうか?コンストラクタは常にそのプライベートフィールドを`this`に追加します。しかし、ここでは、サブコンストラクタの`this`はスーパークラスコンストラクタから返されたPromiseであり(Promiseを介して配信される`SubDataContainer`のインスタンスではありません)。

ただし、`SubDataContainer`にプライベートフィールドがない場合、このアプローチはまだ機能します。

13.5 結論

この章で調べたシナリオの場合、Promiseベースのコンストラクタ、または静的ファクトリメソッドとシークレットトークンによるプライベートコンストラクタのいずれかのほうが好きです。

ただし、ここで紹介した他のテクニックは、他のシナリオでも役立つ可能性があります。

13.6 参考資料