JavaScript のプロトタイプチェーンで無限ループ


前置き

JavaScript では、プロトタイプチェーンという仕組みによってプロパティの移譲が実現されています。

プロトタイプチェーンについて理解している方は、以下の説明は読み飛ばしてください。ちょっと不安な方は、ブラウザのコンソール等で確認しながら読み進めてください。

A
const objectA = {
   toString() { return "A" }
}
objectA.toString() // "A"
B
const objectB = {}
objectB.toString() // "[object Object]"

例えば、上のコード (A) の objectA.toString() の部分は、(当然ですが)objectA の直接所有する toString メソッドが呼ばれます。これは、以下のように確認できます。

objectA.hasOwnProperty("toString") // true

一方で、(B) では、objectBtoString メソッドを持っていません。では、"[object Object]" はどこから返されたのでしょうか。

ここで登場するのがプロトタイプです。

まず、objectB の値を確認しましょう。{} (オブジェクトリテラル)が代入されていますね。オブジェクトリテラルから生成されたオブジェクトは全て、Object.prototype 1 をプロトタイプとして持っています。これを確認するには、Object.getPrototypeOf メソッドを使います。

Object.getPrototypeOf(objectB) === Object.prototype            // true
// オブジェクトリテラル
Object.getPrototypeOf({}) === Object.prototype                 // true
Object.getPrototypeOf({foo: 1, bar: "a"}) === Object.prototype // true
// objectA も同様
Object.getPrototypeOf(objectA) === Object.prototype            // true

それでは、Object.prototype について確かめてみましょう。

Object.prototype.hasOwnProperty("toString")    // true
objectB.toString === Object.prototype.toString // true

Object.prototype は、toString メソッドを持っていることがわかりました。さらに、objectB.toStringObject.prototype.toString が同一であるということもわかりました。

つまり、objectBtoString メソッドを、自身のプロトタイプである Object.prototype に移譲しているということです。

object.toString は、大まかに以下のような手順で評価されています。2

  1. objecttoString プロパティを直接所有していれば、そのプロパティの値を返す(objectA の場合)。
  2. そうでなければ、《object のプロトタイプ》.toString を返す(objectB の場合)。

手順 2. は、プロパティが見つかるまで再帰的に(プロトタイプ → プロトタイプのプロトタイプ → …)行われます。見つからないまま null(終端)にたどり着いた場合は、undefined が返されます。

もう少し例を見てみましょう。

const array = [1, 2, 3]
// array → Array.prototype → Object.prototype → null
Object.getPrototypeOf(array) === Array.prototype            // true
Object.getPrototypeOf(Array.prototype) === Object.prototype // true
Object.getPrototypeOf(Object.prototype) === null            // true

// length は、array が所有している
array.hasOwnProperty("length") // true

// toString は、array → Array.prototype
// Object.prototype も toString を持っているが、先に見つかった方が優先される
array.hasOwnProperty("toString")            // false
Array.prototype.hasOwnProperty("toString")  // true
array.toString === Array.prototype.toString // true

// valueOf は、array → Array.prototype → Object.prototype
array.hasOwnProperty("valueOf")             // false
Array.prototype.hasOwnProperty("valueOf")   // false
Object.prototype.hasOwnProperty("valueOf")  // true
array.valueOf === Object.prototype.valueOf  // true

// nonExistingProperty は、array → Array.prototype → Object.prototype の
// どこにも存在しない
// Object.prototype のプロトタイプは null なので、それ以上は探索できない
array.nonExistingProperty === undefined     // true

ここで出てきた、arrayArray.prototypeObject.prototypenull が、プロトタイプチェーンです。

JavaScript では 3、プロトタイプチェーンによって、オブジェクトの機能を他のオブジェクトに移譲することができます。これは C# や Java などにおけるクラスの継承によく似ています。

プロトタイプチェーンを利用すると、クラス単位ではなくオブジェクト単位で「継承」できるため、より柔軟に 4 コードを書くことができます。

本題

さて、プロトタイプチェーンについて、もう一度考えてみましょう。全てのオブジェクトは普通、次のようなプロトタイプチェーンを持っているはずですね。

オブジェクト → プロトタイプ → プロトタイプのプロトタイプ → … → null

ここで、オブジェクトのプロトタイプを再設定することを考えます。Object.setPrototypeOf が使えますね。

// 配列風のオブジェクト: a → Object.prototype
const a = {0: "a", length: 1}
// a → Array.prototype → Object.prototype
Object.setPrototypeOf(a, Array.prototype)
// Array.prototype.slice が呼ばれる
a.slice() // Array [ "a" ]

では、オブジェクト a のプロトタイプを a 自身に設定するとどうなるでしょうか。プロトタイプチェーンが循環してしまうので、a._ は無限にプロパティを探し続けるのでしょうか。

ちょっと試してみましょう。

Object.setPrototypeOf(a, a)
// TypeError: can't set prototype: it would cause a prototype chain cycle

怒られてしまいました。どうやら Object.setPrototypeOf でプロトタイプチェーンが循環しないかチェックされているようです。

それでは、プロトタイプチェーンを循環させることは不可能なのでしょうか。どうにかしてチェックをすり抜けることはできないのでしょうか。

少し勿体ぶりましたが、結論から言うと、それは可能です。

Proxy オブジェクトを使えば、対象のオブジェクトに対する操作をトラップすることができます。これを用いて、プロトタイプを偽ればよいのです 5

前置き(2回目)

それでは、この記事の主役 6 である、Proxy オブジェクトについて軽く説明します。既にご存知の方は、(以下同文)

Proxy オブジェクトは、前述のとおり、他のオブジェクトへの操作をトラップするための機能です。具体的には、以下の操作に適用できます 7

  • プロパティの値の設定・所得(get, set
  • プロパティの定義・削除・存在チェック(defineProperty, deleteProperty, has
  • プロトタイプの設定・所得(getPrototypeOf, setPrototypeOf
  • 関数・コンストラクタ呼び出し 8apply, construct

簡単な例を紹介します。

const target = {
   realProperty: "own",
}
const handler = {
   // get(プロパティの値の所得)をトラップ
   get(target, property, receiver) {
      if (property === "pseudoProperty") {
         return "handled by proxy"
      }
      return Reflect.get(target, property, receiver)
   },
}
const proxy = new Proxy(target, handler)

target.realProperty        // "own"
proxy.realProperty         // "own"
target.pseudoProperty      // undefined
proxy.pseudoProperty       // "handled by proxy"
target.nonExistingProperty // undefined
proxy.nonExistingProperty  // undefined

Proxy オブジェクトは、new Proxy(target, handler) で生成します 9target には対象のオブジェクトを指定し、handler に操作へのトラップを記述します。

ここでは、プロパティの値の所得をトラップして、プロパティ名が pseudoProperty の時だけ "handled by proxy" を返しています。それ以外の場合は、デフォルトの動作をさせるため、Reflect.get を呼び出します。

Reflect オブジェクトが新しく登場しましたが、ここでは Proxy ハンドラの処理をデフォルトの処理に転送するために使うだけなので、特に深入りはしません。

ハンドラには、必要なメソッド(上の例では get)だけ記述します。その他は、対象のオブジェクトと同じ振る舞いをしてくれます。

ここで注意が必要なのは、元のオブジェクト(target)と Proxy オブジェクト(proxy)は別のオブジェクトだということです。また、proxy を使っている間も、target は普通に使用できます。

target === proxy // false

Proxy オブジェクトは、上手く使えば便利ですが、よく調べずに使うと思わぬバグに頭を悩ませたりすることになります。また、そもそも Proxy の存在を知らない人も少なくない(要出典)ので、実際に使用する時は慎重に検討しましょう。

本題(今度こそ)

ようやく本題に戻ってきました。

Proxy で getPrototypeOf をトラップして、Object.setPrototypeOf のチェックをすり抜ける、という話でしたね。

まず、Object.setPrototypeOf のチェックの詳細な処理を確認しましょう。仕様書の OrdinarySetPrototypeOf (https://www.ecma-international.org/ecma-262/10.0/#sec-ordinarysetprototypeof) から該当部分を引用します。

OrdinarySetPrototypeOf(O,V)
6. Let p be V.
7. Let done be false.
8. Repeat, while done is false,
    a. If p is null, set done to true.
    b. Else if SameValue(p, O) is true, return false.
    c. Else,
         i. If p.[[GetPrototypeOf]] is not the ordinary object internal method defined in 9.1.1, set done to true.
        ii. Else, set p to p.[[Prototype]].

この処理が false を返すとプロトタイプチェーンの循環が検出されたという旨のエラーが投げられることになります。

8. c. i. を見てください。これが何を意味しているかというと、p.[[GetPrototypeOf]] が普通のオブジェクトのデフォルトのメソッドではない場合、ループから抜けてチェックを終了するということです。Proxy オブジェクトは、(getPrototypeOf をトラップするかどうかに関わらず)このケースに該当します。

つまり、Proxy オブジェクトを使うだけで、チェックを逃れることができるということです。

さて、それではコードに落とし込みましょう。

const target = {}

このオブジェクト target をベースにします。Proxy オブジェクトは、

const proxy = new Proxy(target, {})

で作成します。今回は特にハンドラを定義する必要がありません。

そして最後に、プロトタイプを循環させます。

Object.setPrototypeOf(target, proxy)

まとめると、以下のようになります。

const target = {}
Object.getPrototypeOf(target, new Proxy(target, {}))
target._ // 無限ループ

以外とすっきりしていますね。私が実行してみたところ、きちんと(?)無限ループ 10 になってくれました 11

終わりに

プロトタイプチェーンをいじって無限ループを実現できました。無限ループしか実行できませんが。何が言いたいかというと、ループの中身の処理は一切記述出来ないのです。Fizz とか Buzz とか出力したり、alert を出し続けたりすることもできません。

全く役に立ちませんね。あれ、そう言えば、前置きの方が本題よりも長くなっているような……

参考


  1. 仕様書では %ObjectPrototype% ですが、度々注釈を入れるのは面倒なので、ビルトインオブジェクトは書き換えられていないものとします。 

  2. 非常にざっくりとした説明です。詳しくは https://www.ecma-international.org/ecma-262/10.0/#sec-ordinaryget 辺りを参照。 

  3. プロトタイプチェーンは JavaScript 特有のものではなく、Self や Io、 Lua から取り入れられた機能です。 

  4. 良い意味でも悪い意味でも。この記事では悪い意味の方を紹介します。 

  5. 本当はこんな事をしてはいけません。用量・用法を守って正しく使いましょう。 

  6. タイトルとの齟齬には触れないでください。 

  7. 全てを網羅しているわけではありません。一覧は https://www.ecma-international.org/ecma-262/10.0/#table-30 にあります。 

  8. 対象のオブジェクトについて、その操作が有効な場合のみ。 

  9. Proxy.revocable で取り消し可能な Proxy オブジェクトを作ることもできますが、ここでは割愛します。 

  10. 実際には再帰的に [[Get]] が呼び出されているので、すぐにコールスタックの限界に達してエラーになりますが。 

  11. 因みに、ある種の exotic object(Proxy オブジェクトを含む)がプロトタイプチェーンに含まれると、プロパティにアクセスする時に無限ループに陥ることがあるという旨の記述が、仕様書の注釈にあります:https://www.ecma-international.org/ecma-262/10.0/#sec-invariants-of-the-essential-internal-methods