0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaScriptのnew演算子:生成プロトコルと戻り値を理解する

0
Posted at

JavaScriptの new は、関数を呼ぶための装飾ではありません。「コンストラクターとして呼び出せる値」に対して、オブジェクト生成の内部処理 [[Construct]] を実行する演算子です。

実務でまず押さえる結論は次のとおりです。

  • new F() が成功するのは、F[[Construct]] を持つときだけ
  • 通常の関数コンストラクターでは、新しいオブジェクトが用意され、その this として本体が実行される
  • F.prototype は、新しいインスタンスの [[Prototype]] を決める候補になる
  • constructorがオブジェクトを明示的に返すと、その値が new 式全体の結果になる
  • new.target を使うと、どのコンストラクターを起点に生成が始まったか観測できる
  • 仕様を正確に表すなら「4ステップの書き換え」ではなく、[[Construct]]NewTarget で考える

この記事では new 式そのものを扱います。constructorへ何を書くべきかは初期化設計、プロパティ探索はprototype、メモリ配置はエンジン実装の話であり、分けて考えると混乱しません。

最小の関数コンストラクター

class が導入される前から、JavaScriptでは通常の関数をコンストラクターとして利用できます。

function User(name) {
  this.name = name;
}

User.prototype.greet = function () {
  return `Hello, ${this.name}`;
};

const user = new User("Taro");

console.log(user.name);    // Taro
console.log(user.greet()); // Hello, Taro

このとき new User("Taro") は、おおまかには次のように理解できます。

  1. 新しいオブジェクトを用意する
  2. そのオブジェクトの [[Prototype]] を決める
  3. 新しいオブジェクトを this として User を実行する
  4. constructorの戻り値規則に従って結果を返す

この4段階は学習用モデルとして便利です。ただし、完全な仕様アルゴリズムではありません。派生クラス、組み込みオブジェクト、Proxy、Reflect.construct では単純な書き換えだけでは説明しきれません。言語仕様では、new はコンストラクターオブジェクトの [[Construct]] 内部メソッドを呼び、引数リストと NewTarget を渡す、と定義されています。

呼び出せることと生成できることは別

JavaScriptの関数らしい値には、通常呼び出しの能力 [[Call]] と、コンストラクター呼び出しの能力 [[Construct]] があります。両方を持つ値もあれば、片方だけを持つ値もあります。

通常の function 宣言は、一般に呼び出しもconstructもできます。

function Example() {
  console.log(new.target ? "construct" : "call");
}

Example();     // call
new Example(); // construct

一方、classはconstructできますが、通常の関数としては呼べません。

class User {}

new User(); // 成功
User();     // TypeError: Class constructor User cannot be invoked without 'new'

アロー関数や多くのメソッド定義は [[Construct]] を持たないため、new と組み合わせると TypeError になります。

const arrow = () => ({});
const object = {
  method() {},
};

// new arrow();         // TypeError
// new object.method(); // TypeError

「関数ならすべて new できる」「.prototype が見えれば必ずconstructできる」と判定してはいけません。Proxyやbound functionもあり、外側からconstruct可能性を完全に判定するための単純な標準関数はありません。通常は、APIの契約としてclassやfactoryを明示し、試しに new して判定する設計を避けます。

prototypeは生成されるオブジェクトの親候補になる

通常の関数コンストラクターでは、F.prototype がオブジェクトなら、新しいインスタンスの [[Prototype]] に使われます。

function User(name) {
  this.name = name;
}

const user = new User("Taro");

console.log(Object.getPrototypeOf(user) === User.prototype); // true

ここで区別したいのは、User.prototype という通常のプロパティと、user が内部に持つ [[Prototype]] です。new は前者を参照し、後者を設定します。同じものではなく、生成時に関連付けられる二つの概念です。

もし F.prototype がオブジェクトでなければ、通常のオブジェクト生成では既定の Object.prototype が使われます。

function Item() {}
Item.prototype = 123;

const item = new Item();

console.log(Object.getPrototypeOf(item) === Object.prototype); // true

このフォールバックを利用する設計に利点はありません。constructorとして公開する関数の prototype をプリミティブへ変更すると、メソッド共有や instanceof の期待も壊れます。動作を知るための境界例として理解し、実務では避けます。

なお、後から F.prototype を別オブジェクトへ置き換えても、すでに生成済みのインスタンスの [[Prototype]] は自動で付け替わりません。

function Box() {}

const before = new Box();
const oldPrototype = Box.prototype;

Box.prototype = { kind: "new prototype" };
const after = new Box();

console.log(Object.getPrototypeOf(before) === oldPrototype); // true
console.log(Object.getPrototypeOf(after) === Box.prototype); // true

new がprototypeを参照するのは各生成時です。この性質はprototype記事で扱うプロパティ探索にもつながります。

constructorの戻り値で結果が変わる

関数コンストラクターが何も返さない、またはプリミティブ値を返す場合、new が用意したオブジェクトが結果になります。

function User(name) {
  this.name = name;
  return 42;
}

const user = new User("Taro");
console.log(user.name); // Taro

一方、オブジェクトを明示的に返すと、そのオブジェクトが new 式全体の結果になります。配列や関数もオブジェクトとして扱われます。

function User(name) {
  this.name = name;
  return { name: "Replacement" };
}

const user = new User("Taro");

console.log(user.name);                  // Replacement
console.log(user instanceof User);       // false
console.log(Object.hasOwn(user, "name")); // true

最初に作られたオブジェクトには Taro が代入されますが、外へ返るのは別オブジェクトです。この機能を日常的なconstructorで使うと、class名、prototype、型から予想した結果とずれます。別形式を返したいなら、通常のfactory functionやstatic factory methodの方が意図を伝えやすくなります。

classの基底constructorにも、オブジェクトを返せば生成結果を置き換える規則があります。ただし、TypeScriptでは通常、クラスのインスタンス型と異なるオブジェクトを返すコードは型エラーになります。言語の実行時機能と、型システムが推奨する設計は分けて理解します。

派生constructorはさらに厳密です。オブジェクトを返すか、何も返さず super() で得た this を使う必要があり、任意のプリミティブを返すと TypeError です。こうした例外規則も、constructorから明示returnを避ける実務上の理由になります。

new.targetで生成の起点を知る

関数またはconstructorの本体では、new.target から今回のconstruct呼び出しの起点を取得できます。通常呼び出しなら undefined です。

function User(name) {
  if (!new.target) {
    throw new TypeError("User must be called with new");
  }
  this.name = name;
}

new User("Taro"); // 成功
// User("Taro");  // TypeError

classはもともと通常呼び出しを拒否するため、このガードは主にfunction constructorで意味を持ちます。

継承時には、基底constructor内でも new.target は実際に new の対象となった派生クラスを示します。

class Entity {
  constructor() {
    console.log(new.target.name);
  }
}

class User extends Entity {}

new Entity(); // Entity
new User();   // User

これを利用して抽象的な基底クラスの直接生成を拒否できます。

class Shape {
  constructor() {
    if (new.target === Shape) {
      throw new TypeError("Shape cannot be instantiated directly");
    }
  }
}

class Circle extends Shape {}

new Circle();
// new Shape(); // TypeError

ただし、JavaScriptの「抽象クラス」を完全に実現する機能ではありません。TypeScriptを使えるなら abstract class の方がコンパイル時に意図を示せます。また、継承を必要としない設計なら、factoryで公開する生成経路を絞る方が単純です。

Reflect.constructはTargetとNewTargetを分ける

Reflect.construct(Target, argumentsList, NewTarget) は、new Target(...argumentsList) に近いconstruct操作を関数として実行します。第三引数を使うと、「どの本体を実行するか」と「どのconstructorを生成起点として扱うか」を分けられます。

function Person(name) {
  this.name = name;
}

function Employee() {}
Employee.prototype.describe = function () {
  return `employee: ${this.name}`;
};

const employee = Reflect.construct(Person, ["Taro"], Employee);

console.log(employee.name);                  // Taro
console.log(employee.describe());            // employee: Taro
console.log(Object.getPrototypeOf(employee) === Employee.prototype); // true

この例では Person の初期化本体を実行しつつ、NewTargetEmployee です。そのため、新しいオブジェクトのprototype候補には Employee.prototype が使われます。

Object.create(Target.prototype)Target.apply(instance, args) を組み合わせた自作 new は、classを apply できない、明示returnを正しく処理しにくい、new.target を再現できない、Proxyや組み込みconstructorへ対応できない、という問題があります。メタプログラミングでconstruct操作が必要なら、標準の Reflect.construct を使います。

もっとも、通常のアプリケーションで第三引数まで必要になることは稀です。フレームワーク、Proxy、継承補助などの基盤コードで役立つ機能であり、通常の生成には new やfactoryの方が読みやすい選択です。

bound functionもconstructできることがある

construct可能な関数を bind すると、得られたbound functionもconstructできます。この場合、bind時に指定した thisArg は無視され、先頭引数だけが固定されます。

function User(role, name) {
  this.role = role;
  this.name = name;
}

const ignoredThis = { role: "ignored" };
const Member = User.bind(ignoredThis, "member");

const user = new Member("Taro");

console.log(user.role); // member
console.log(user.name); // Taro
console.log(user instanceof User); // true

bound function自身には通常の .prototype プロパティがありませんが、内部のconstruct処理は元のTargetへ委譲されます。そのため、単純に「.prototype の有無」で new 可能か判断できません。

一方、アロー関数をbindしてもconstruct可能にはなりません。元のTargetが [[Construct]] を持たないからです。また、bound functionを extends の直後へそのまま置くと、prototypeがないため失敗する場合があります。部分適用のためのbindと、継承階層を作るclassは別の仕組みとして使う方が安全です。

組み込みオブジェクトは個別の契約を持つ

すべての組み込み関数が、通常呼び出しと new 呼び出しで同じ振る舞いをするわけではありません。

console.log(typeof Date());      // string
console.log(new Date() instanceof Date); // true

console.log(Number("42"));      // 42
console.log(new Number("42"));  // Number object(通常は不要)

Date() は現在日時の文字列を返し、new Date() はDateオブジェクトを作ります。Number() はプリミティブへの変換に使えますが、new Number() はラッパーオブジェクトを作るため、真偽値判定や厳密等価比較で直感に反する結果を生みます。

const wrappedZero = new Number(0);

console.log(Boolean(wrappedZero)); // true
console.log(wrappedZero === 0);     // false

また、MapSetPromise などは new を必要とします。組み込みAPIについては「大文字名だから同じ」と推測せず、個別の公式仕様を確認します。

構文上の境界と読みやすさ

引数がない場合、new Usernew User() は同じconstruct呼び出しになります。

class User {}

const a = new User;
const b = new User();

console.log(a instanceof User); // true
console.log(b instanceof User); // true

ただし、括弧付きの方が通常の呼び出しに似ており、引数追加時の差分も小さいため、チームでは new User() に統一するのが一般的です。

optional chainingをconstructor位置へ直接置くことはできません。

const Registry = { User: class User {} };

// new Registry?.User(); // SyntaxError
const UserConstructor = Registry?.User;
const user = UserConstructor ? new UserConstructor() : undefined;

存在しないconstructorを黙って飛ばす設計が妥当かも含め、いったん値を取り出して条件分岐すると意図が明確です。

演算子の結合順序が分かりにくい式も避けます。new Factory.create() のようなコードは、何をconstructしているか読者が立ち止まります。constructor式を変数へ分け、括弧を明示する方が保守しやすくなります。

newとfactoryを選ぶ基準

new は、呼び出し側が具体的なクラスを知り、そのクラスのインスタンスを同期的に一つ作ることが明確な場合に適しています。

factoryは次の状況で有利です。

  • 非同期に値を取得してから生成する
  • キャッシュ済みインスタンスを返す可能性がある
  • 入力によって異なる実装を返す
  • Result のように失敗を値で表す
  • fromJsonfromSeconds のように生成意図を名前で示す
  • classを公開せず、戻り値のインターフェースだけを契約にする
type Clock = {
  now(): Date;
};

function createClock(): Clock {
  return {
    now: () => new Date(),
  };
}

この程度ならclassと new を使わない方が小さく書けます。逆に、識別可能なインスタンス、継承、private field、複数メソッドにまたがる不変条件が重要なら、classと new が自然です。どちらを選んでも、利用側へ「同期的に新しい個体を必ず返すのか」を伝えることが大切です。

振る舞いをテストで確かめる

new の境界規則は、Node.js標準のassertで再現できます。

import assert from "node:assert/strict";

function Product(name) {
  this.name = name;
}

const product = new Product("book");
assert.equal(product.name, "book");
assert.equal(Object.getPrototypeOf(product), Product.prototype);

function Replacement() {
  return { replaced: true };
}

assert.deepEqual(new Replacement(), { replaced: true });
assert.throws(() => new (() => {}), TypeError);

最後の式は、アロー関数がconstructできないことを確認しています。ブラウザーのコンソールでも試せますが、例外メッセージの文言はエンジンごとに異なります。テストでは全文一致より、例外の種類や自分で定義した安定したメッセージを検証します。

まとめ

new を正しく理解する鍵は、見た目の「関数呼び出し」から、constructという別のプロトコルへ視点を移すことです。

  • new[[Construct]] を持つ値だけに使える
  • F.prototype は生成時のprototype候補だが、プリミティブなら既定値へフォールバックする
  • constructorがプリミティブを返しても無視され、オブジェクトを返すと生成結果を置き換える
  • new.target は実際の生成起点を表し、継承先から基底constructorまで伝わる
  • Reflect.construct はTargetとNewTargetを分離でき、自作の new より仕様に忠実である
  • bound functionや組み込みconstructorには固有の境界がある
  • 非同期処理、複数実装、失敗の値表現が必要ならfactoryも比較する

日常のコードでは new User() と書くだけで十分です。しかし、戻り値の置き換え、継承、メタプログラミングで予想外の挙動に遭遇したとき、[[Construct]]NewTarget、prototype候補という三点に戻ると整理できます。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?