Skip to content

メタプログラミングと Proxy/Reflect

JavaScript のオブジェクトは、プロパティの読み書きやプロトタイプの取得といった基本操作を、エンジン内部の「内部メソッド」を通じて行います。通常のオブジェクトではこれらは固定の振る舞いですが、Proxy を使うと、その一つひとつをユーザが書いた関数(トラップ)に差し替えられます。これはメタプログラミング、すなわち「言語の基本操作そのものをプログラムで書き換える」機能であり、エンジンにとっては「オブジェクト操作の唯一の窓口」を設計しておくことと、「ユーザが返した結果を信用せず検証すること」の二つが要になります。この章では、内部メソッドとトラップの対応、エンジンがどこで分岐するか、Reflect の役割、そして仕様が要求する不変条件の強制と、実装で陥りやすい罠を扱います。

メタオブジェクトプロトコル

仕様は、あらゆるオブジェクトが備える一連の内部メソッドを定めています。内部メソッドは二重角括弧で表記され、オブジェクトに対する基本操作の意味を規定します。主なものは次のとおりです。

  • [[Get]] / [[Set]]: プロパティの取得・設定。
  • [[Has]]: in 演算子が問う、プロパティの存在判定。
  • [[Delete]]: delete 演算子による削除。
  • [[OwnPropertyKeys]]: 自身のプロパティキーの列挙(Object.keysfor-in の基盤)。
  • [[GetOwnProperty]] / [[DefineOwnProperty]]: プロパティ記述子の取得・定義。
  • [[GetPrototypeOf]] / [[SetPrototypeOf]]: プロトタイプの取得・設定。
  • [[IsExtensible]] / [[PreventExtensions]]: 拡張可能性の問い合わせ・封鎖。
  • [[Call]] / [[Construct]]: 関数として呼ぶ・new で構築する(関数オブジェクトのみ)。

通常オブジェクト(ordinary object)は、これらを仕様が定めた既定の振る舞いで実装します。たとえば [[Get]] は自身のスロットを見て、なければプロトタイプチェーンをたどります(前掲「オブジェクトモデル」参照)。これに対し、既定と異なる振る舞いを持つオブジェクトをエキゾチックオブジェクト(exotic object)と呼びます。配列(length と添字の連動)、arguments、そして Proxy がその例です。

Proxy は、内部メソッドの呼び出しを、対応するトラップ(ハンドラオブジェクト上のメソッド)へ横流しするエキゾチックオブジェクトです。プロキシは二つの内部スロットを持ちます。

  • [[ProxyTarget]]: 既定の動作を委ねる対象オブジェクト(ターゲット)。
  • [[ProxyHandler]]: トラップ関数を並べたハンドラオブジェクト。

ハンドラに対応するトラップが定義されていなければ、その内部メソッドはターゲットに対してそのまま実行されます。つまりターゲットが「既定値」として働き、ハンドラは「上書きしたい操作だけ」を書けばよい設計になっています。

エンジンはどこで分岐するか

Proxy が成立する前提は、エンジンがオブジェクト操作を必ず内部メソッド経由で行うことです。バイトコードの get_property オペコード、in 演算子、for-in の列挙、Object.keys、プロトタイプ探索 — オブジェクトに触れるあらゆる箇所が、生のフィールドアクセスではなく「そのオブジェクトの内部メソッドを呼ぶ」という一段の間接を通っていなければなりません。この間接があるからこそ、対象がプロキシのときだけトラップへ分岐できます。

概念的には、各内部メソッドはオブジェクトの種類で分岐する一枚の窓口になります。プロパティ取得の流れを [[Get]] で追うと次のようになります。

get(obj, key, receiver):
    if obj が通常オブジェクト:
        return ordinary_get(obj, key, receiver)      // スロット参照 + プロトタイプ探索
    if obj がプロキシ:
        return proxy_get(obj, key, receiver)          // トラップへ
    ... その他のエキゾチック ...

proxy_get(p, key, receiver):
    handler = p.[[ProxyHandler]]
    target  = p.[[ProxyTarget]]
    trap = handler の "get"
    if trap が未定義:
        return get(target, key, receiver)             // ターゲットへ委譲(既定動作)
    result = trap を呼ぶ(handler, target, key, receiver) // ユーザコードが走る
    不変条件を検証する(target, key, result)             // 後述: 信用しない
    return result

重要なのは、ここでユーザ定義のトラップ関数が呼ばれる点です。トラップは任意の JS コードであり、確保・再入・例外送出のすべてが起こりえます。エンジンから見れば get 一つが「任意コードの実行点」になります。これはネイティブ境界での GC 安全性(前掲「ネイティブコードとの相互作用と GC 安全性」参照)や例外の伝播と直結する論点です。

Reflect — 内部メソッドの関数版

トラップの多くは「少し細工をして、あとはターゲットに既定どおり処理させたい」形をとります。このとき、ターゲットに対して内部メソッドを正しい意味で呼び直す手段が要ります。それが Reflect です。

Reflect は内部メソッドを通常の関数として写した名前空間で、Reflect.getReflect.setReflect.hasReflect.deletePropertyReflect.ownKeysReflect.getPrototypeOfReflect.definePropertyReflect.applyReflect.construct などが [[Get]][[Set]]・… に一対一で対応します。トラップの引数と Reflect の引数はそろえてあり、トラップ本体はそのまま Reflect へ受け流せます。

const handler = {
  get(target, key, receiver) {
    // 何らかの観測・細工をしたうえで…
    return Reflect.get(target, key, receiver); // 既定の [[Get]] を正しい receiver で実行
  }
};

トラップが Reflect に委譲するのが定石なのは、単なる短縮ではなく意味的な正しさのためです。たとえば [[Get]]receiver(元の取得先。プロキシがプロトタイプに置かれている場合、最初にアクセスされたオブジェクト)を引き回す必要があり、ターゲット上の getter はこの receiverthis として起動しなければなりません。return target[key] と素朴に書くと receiver が失われ、getter の this がずれます。Reflect.get(target, key, receiver) はこの受け渡しを仕様どおり行います。Reflect の戻り値も内部メソッドの結果に合わせてあり(たとえば Reflect.set / Reflect.deleteProperty は成否の真偽値を返す)、トラップが返すべき型とかみ合います。

エンジン側の実装では、Reflect.xxx は対応する内部メソッドを呼ぶ薄いラッパとして実装できます。トラップ内部で Reflect.get(target, ...) が呼ばれれば、それはまた get の窓口に戻り、ターゲットが通常オブジェクトなら ordinary_get に、さらに別のプロキシならまたトラップに至ります。この再入の可能性が後述の罠につながります。

不変条件の強制

Proxy の設計で最も繊細なのは、トラップの戻り値を信用してはいけない点です。トラップはユーザが書いた任意コードであり、言語の根幹をなす不変条件を破る値を返しうるからです。仕様は各内部メソッドに「本質的内部メソッドの不変条件」を課しており、エンジンはトラップの結果をこれらに照らして検証する義務を負います。検証に失敗すれば TypeError を投げ、破った結果を決して外へ出しません。

なぜ検証が要るのか。たとえば、書き換え不可(non-writable)かつ設定変更不可(non-configurable)なデータプロパティは、「一度観測したら常に同じ値に見える」という保証を言語全体に与えます。最適化やユーザコードはこの不変性に依存します。もしプロキシの get トラップがそのプロパティに対して毎回違う値を返せてしまうと、この保証が崩れ、言語の意味論が壊れます。したがってエンジンは、ターゲット上の当該プロパティが non-writable・non-configurable であれば、トラップの戻り値がターゲットの実値と一致することを確かめ、違えば TypeError にします。

const target = Object.freeze({ x: 1 }); // x は書き換え不可・設定変更不可
const p = new Proxy(target, { get: () => 2 });
p.x; // TypeError: ターゲットの実値 1 と異なる 2 は返せない

主な内部メソッドとトラップ、そして守らせるべき不変条件を対応させると次のようになります。

内部メソッド トラップ名 守らせる不変条件(要点)
[[Get]] get non-writable・non-configurable なデータプロパティでは、ターゲットの実値と同じ値を返すこと。non-configurable な getter が undefined の場合は undefined を返すこと
[[Set]] set non-writable・non-configurable なデータプロパティを、ターゲットと異なる値には設定できない
[[Has]] has non-configurable な自身のプロパティの存在を隠せない。非拡張ターゲットの自身のプロパティも隠せない
[[Delete]] deleteProperty non-configurable な自身のプロパティの削除成功を報告できない
[[GetOwnProperty]] getOwnPropertyDescriptor non-configurable なプロパティを存在しないと報告できない。非拡張ターゲットに無い自身のプロパティを在ると報告できない
[[DefineOwnProperty]] defineProperty non-configurable な既存プロパティと矛盾する定義や、非拡張ターゲットへの新規プロパティ追加を成功と報告できない
[[OwnPropertyKeys]] ownKeys ターゲットの non-configurable な自身のキーをすべて含むこと。非拡張ターゲットではキー集合が過不足なく一致すること。重複キーを含まないこと
[[GetPrototypeOf]] getPrototypeOf 非拡張ターゲットでは、ターゲットの実際のプロトタイプと同一のものを返すこと
[[SetPrototypeOf]] setPrototypeOf 非拡張ターゲットでは、実際のプロトタイプと異なる値への変更成功を報告できない
[[IsExtensible]] isExtensible ターゲットの実際の拡張可能性と一致する真偽値を返すこと
[[Call]] / [[Construct]] apply / construct construct はオブジェクトを返すこと(非オブジェクトは不可)

これらの検証は、トラップを呼んだ直後にエンジンが行います。多くはターゲットに対して [[GetOwnProperty]][[IsExtensible]] を追加で呼んで実状を確かめ、トラップの主張と突き合わせる形になります。この追加問い合わせ自体がまた再入・確保の起きうる操作である点にも注意が要ります。

取り消し可能プロキシ

Proxy.revocable は、プロキシと、それを無効化する revoke 関数の組を返します。revoke を呼ぶと、以後そのプロキシへのあらゆる内部メソッド呼び出しが TypeError を投げるようになります。ケイパビリティを一時的に渡し、あとで確実に断ち切る用途に使います。

実装は、プロキシの内部スロットで表現します。取り消しは [[ProxyTarget]][[ProxyHandler]]null に落とすことで表し、各内部メソッドはハンドラを参照する前にこのスロットが null かどうかを検査します。

proxy_get(p, key, receiver):
    handler = p.[[ProxyHandler]]
    if handler が null:                 // 取り消し済み
        throw TypeError
    target = p.[[ProxyTarget]]
    ...

検査を「ハンドラ参照より前」に置くことが肝心です。取り消し後にトラップを読みにいったりターゲットに触れたりしてはならず、まず無効状態を確定させてから弾きます。この検査は全トラップに共通して先頭に現れます。

実装上の罠

罠1: 再入と無限再帰

トラップは任意コードなので、その内部でまた別のプロキシ(あるいは自分自身)に対する操作を引き起こせます。典型的なのは、get トラップの中でターゲットのプロパティを読むつもりが、そのターゲット自体がプロキシで、また get トラップに入る、という連鎖です。ハンドラにターゲットとして自分自身を渡す、あるいはトラップ内でプロキシへ再帰的にアクセスするコードは、ネイティブスタック上で内部メソッド呼び出しを際限なく積み上げます。

これを放置すると、エンジン実装言語のスタックが溢れてプロセスごとクラッシュします。ユーザコードがエンジンを落とせてしまうのは受け入れられません。対策は、内部メソッド呼び出し(あるいはプロキシのトラップ起動)に深さの上限を設け、超えたら捕捉可能な RangeError(スタック超過)として送出することです。ネイティブスタックが実際に尽きる前に、言語レベルの例外として安全に巻き戻せる地点で止めるのが要点です。構文解析の再帰深度制限(前掲「構文解析」参照)と同じ発想を、実行時のオブジェクト操作にも適用します。

罠2: 共通プロローグの重複

すべてのトラップは、実質同じ前処理から始まります。(1) 取り消し状態の検査、(2) 深さガードの更新、(3) ハンドラから該当トラップ関数を取り出す、(4) 未定義ならターゲットへ委譲する、というプロローグです。これを内部メソッドの数だけ(getsethasdeletePropertyownKeys・…)書くと、同じコードが何度も並びます。

素直に共通関数へ括り出したくなりますが、ここに設計上の摩擦があります。「トラップが未定義でターゲットへ委譲する」ときの早期リターンの型が、内部メソッドごとに違うのです。[[Has]] / [[Set]] / [[Delete]] は真偽値を、[[Get]] は任意の値を、[[OwnPropertyKeys]] はキーの配列を、[[GetPrototypeOf]] はオブジェクトか null を返します。共通プロローグが「委譲した結果」を返そうとすると、戻り値の型が呼び出し側ごとに異なるため、単純な一つのヘルパにまとまりません。実装言語の型システムによっては、ジェネリックなヘルパ、あるいは「プロローグは検査とトラップ関数の取得までを担い、委譲と本処理は各内部メソッドに残す」といった分割が現実的な落としどころになります。重複を減らそうとして無理に一本化すると、かえって分岐が増えたり型の辻褄合わせが煩雑になったりしがちで、どこまで括り出すかは慎重に見極める必要があります。

罠3: 戻り値は攻撃者制御 — 再検証と再強制

トラップの戻り値も引数も、ユーザが自由に決められます。エンジンはこれらを信用せず、使う前に検証と型強制をやり直さなければなりません。

  • 不変条件の再検証: 前節のとおり、トラップ結果は必ず不変条件に照らす。ここを省くと言語の保証が破れる。
  • 型の再強制: たとえば ownKeys トラップが返した配列の各要素は、そのままキーとして使わず、文字列またはシンボルであることを確かめ、必要なら正規のプロパティキーへ強制する。数値インデックスの正規化なども同様に、トラップ結果側でやり直す。
  • ユーザコードは GC・例外点: トラップ本体も、検証のためにターゲットへ追加で行う [[GetOwnProperty]] などの問い合わせも、任意コードや確保を伴いうる。したがってプロキシ処理の最中は、組み立て中の値・ターゲット・ハンドラといった一時値がすべて GC から見える状態でなければならず(前掲「ネイティブコードとの相互作用と GC 安全性」参照)、トラップが投げた例外は保留中例外として正しく伝播させなければなりません(前掲「例外処理」参照)。トラップ実行と不変条件検証を挟んで生きているべきローカル値の保護は、プロキシ実装で特に漏れやすい箇所です。

つまりプロキシの各内部メソッドは、「取り消し検査 → 深さガード → トラップ取得 → (未定義なら委譲) → トラップ実行(ユーザコード) → 戻り値の再強制 → 不変条件の再検証 → 返す」という定型を、戻り値の型ごとに正しく組み立てる作業になります。定型のどの段を省いても、クラッシュ・意味崩壊・メモリ安全性のいずれかの穴になります。

flowchart TD
    A["内部メソッド呼び出し(例 get)"] --> B["取り消し検査:ハンドラが null なら TypeError"]
    B --> C["深さガード更新:上限超過なら RangeError"]
    C --> D["ハンドラからトラップを取得"]
    D -. "トラップ未定義" .-> F["ターゲットへ委譲(既定動作)"]
    D -. "トラップあり" .-> G["トラップ実行(ユーザコード)"]
    G --> H["戻り値の再強制:キー型などを正規化"]
    H --> I["不変条件の再検証:破れば TypeError"]
    I --> J["結果を返す"]
    F --> J

プロキシの各内部メソッドが共通して踏む定型で、どの段を省いてもクラッシュ・意味崩壊・メモリ安全性の穴になる。

まとめ

  • 仕様はあらゆるオブジェクトに内部メソッド([[Get]][[Set]][[OwnPropertyKeys]] など)を定め、通常オブジェクトはそれを既定動作で実装する。Proxy は各内部メソッドをハンドラ上のトラップへ横流しするエキゾチックオブジェクトで、ターゲットが既定値として働く。
  • プロキシが成立する前提として、エンジンはオブジェクト操作をすべて内部メソッド経由で行い、対象がプロキシのときだけトラップへ分岐する。トラップ呼び出しは任意コードの実行点になる。
  • Reflect は内部メソッドを関数化した名前空間で、トラップから receiver などを正しく引き回してターゲットへ委譲する定石を与える。委譲は短縮でなく意味的な正しさのために Reflect を使う。
  • トラップの戻り値は信用できない。エンジンは本質的内部メソッドの不変条件(non-writable・non-configurable プロパティの値の一貫性、ownKeys が non-configurable キーを漏らさないこと、非拡張ターゲットのプロトタイプ不変など)を検証し、破れば TypeError を投げる義務がある。
  • 取り消し可能プロキシは内部スロットを null に落として表し、各トラップはハンドラを参照する前に取り消し状態を検査する。
  • 実装の罠は、(1) 再入による無限再帰を捕捉可能なエラーで止める深さガード、(2) 内部メソッドごとに戻り値型が違うため一本化しにくい共通プロローグ、(3) 攻撃者制御の戻り値・引数の再検証と再強制、そしてトラップが GC・例外点であることの三つに集約される。

参考文献