2024年06月26日
不具合を調査しているとき、「あるオブジェクトのプロパティに、『ルール上入ってはいけない値』が入っている事はわかったが、それがどこで行われているのかがわからない」ということが時折あります。特に古いコードのメンテナンスにありがちで、かなりの足かせとなることが多いのではないでしょうか。
そんなときに、対象のオブジェクトの変化を追跡できるよう、JavaScriptの挙動そのものを作り変えることができたら、便利だと思いませんか? こうした、製造物のプログラミングではなく、開発言語そのものの動きを書き換えてしまうことを、「プログラム言語へのプログラミング」という意味を込めて「メタプログラミング」と呼びます。そしてもちろん、JavaScriptもメタプログラミングが可能です!
ほんらいメタプログラミングは、デバッグのためだけに使うのは「もったいない」ほど強力な仕組みなのですが、活用されている事例が少なく見受けられるため、まずは「デバッグのための道具」として使うことで、その仕組みを一緒に学んでいきましょう。
追いかけたいオブジェクトの Proxy (プロクシ:代理人)を作ろう
例として、草野球チームの経理アプリを想定してみましょう。
このアプリには、チームの予算を示すteam.estimate
という数値型のプロパティがあり、仕様(業務ルール)上、0以下になることは想定されていないとします。ところが、うっかりそれが発生してしまったことで、予算の計算処理に不具合が起こってしまいました。さらに team.estimate
に対する変更処理はあちこちで行われており、一つづつ追いかけていては収集がつきません。
そこで登場するのが、Proxy
です。
// 現状のコード:問題となっているオブジェクトの初回生成時
const team = new Team();
// 調査用のコード:↑の変数の代理人を作る
const target = new Team();
/** ここは次の項で説明します */
const handler = {};
// 代理人に `team` のすべてを「全権委任」します
const team = new Proxy(target, handler);
新しく作ったteam
は、現状のコードの team
と全く同じ働きをします。全権委任を受けた代理人なので、これは当然の働きです。ひとつだけ違うのは、handler
という連想配列ですね……。そう、ここにメタプログラミングを仕込むわけです。
ハンドラーからメタプログラミング!
さて、このhandler
という連想配列ですが、代理人に対し「この動作を監視して、このように扱いなさい」という命令状だと考えてください。これをハンドラー(handler)といい、この中にトラップ(trap: 罠)を定義してゆきます。
トラップについて
JavaScriptのすべてのオブジェクト(いわゆる連想配列)は、そのプロパティに対する操作のすべてを、次に挙げる「内部メソッド」の実行として解釈しています。
- [[Get]]
- プロパティへのアクセス
- [[Set]]
- プロパティへの代入(代入演算子
=
のこと)
- プロパティへの代入(代入演算子
- [[DefineOwnProperty]]
- プロパティを実行時に「生やす」(
const a={b:1};
のとき、a.c = 2;
など、別のプロパティを作ること)
- プロパティを実行時に「生やす」(
- [[Delete]]
- プロパティの実行時削除(削除演算子
delete
による、delete object.property
など)
- プロパティの実行時削除(削除演算子
- [[HasProperty]]
- 存在演算子
in
による、特定プロパティの存在チェック
- 存在演算子
- [[OwnPropertyKeys]]
- プロパティ列挙メソッド
Object.keys()
,Object.entries()
による、列挙
- プロパティ列挙メソッド
- [[GetPrototypeOf]]
- インスタンス型判定演算子
instanceof
による、継承チェーン存在判定
- インスタンス型判定演算子
- [[PreventExtensions]]
- オブジェクト固定化メソッド
Object.seal()
,Object.freeze()
による、封印/凍結
- オブジェクト固定化メソッド
- [[IsExtensible]]
- 上記、封印/凍結がなされているかのチェック
- [[Call]]
- (そのオブジェクトが関数である場合に限り)関数の実行
- [[Construct]]
- (そのオブジェクトが関数である場合に限り)
new
演算子によるインスタンス生成
- (そのオブジェクトが関数である場合に限り)
- [[SetPrototypeOf]]
- 説明は省略します。
- [[GetOwnProperty]]
- 説明は省略します。
言い換えるとJavaScriptでは、すべてのオブジェクトのプロパティに対するあらゆる操作は、上記13の内部メソッドの呼び出しとして、監視可能ということになります。
しかし、すべてを監視するのはマシンに多大な負荷をかける行為であり、実際に行うのはナンセンスです。なので、プログラマーが必要としたときに、必要なオブジェクトだけの、必要な内部メソッドだけを監視できるような仕組みが生まれました。これが、トラップです。
トラップの指定方法
代理人に渡すハンドラーは、上記の各内部メソッドに対応したトラップ名の関数の連想配列……という書式に従ったものでなければなりません。対応した関数がなければ、そのトラップは無いものとみなされ、通常通りの処理を行います。
実装については後で触れようと思いますので、ここでは一部の定義のみを TypeScript で記載することにします。
/** ハンドラー(代理人に渡す命令状)の一部 */
interface ProxyHandler<T extends object> {
/**
* get: [[Get]] に対するトラップ
*
* @param target 委任もと本人
* @param p target のプロパティ名
* @param receiver この関数における `this` となるオブジェクト(通常は代理人)
* @returns 返したい値
*/
get?(target: T, p: string | symbol, receiver?: any): any;
/**
* set: [[Set]] に対するトラップ
*
* @param target 委任もと本人
* @param p target のプロパティ名
* @param newValue target[property] に、まさに設定しようとしている値
* @param receiver この関数における `this` となるオブジェクト(通常は代理人)
* @returns true: 問題なし, false: 問題発見
*/
set?(target: T, p: string | symbol, newValue: any, receiver?: any): boolean;
/**
* deleteProperty: [[Delete]] に対するトラップ
*
* @param target 委任もと本人
* @param p 削除対象のプロパティ名
* @returns true: 問題なし, false: 問題発見
*/
deleteProperty?(target: T, p: string | symbol): boolean;
};
すべてのトラップの定義は、このページにある各リンクから参照してください。
さて、先ほど「記載のないトラップは無いものとなるので、通常通りの処理を行う」と書きましたが、記載のあるトラップは、そのままでは通常通りの処理を行わないということでもあります。本記事の題材の目的は、team.estimate
に仕様に反した値が入るタイミングを検知することであり、仕様通りの値が入ったときには、もともとの動作を保証してあげなければいけません。つぎは、「トラップ内でも通常通りの処理を行う方法」について解説します。
正しい動作の場合は、委任された権限を Reflect(リフレクト:返還)しよう。
トラップ内で、委任もとの本来の動作を行うには、全権委任を受けた代理人が、委任もとに対し「その権限のみを一時的に返還」することで実現します。これをリフレクト(reflect: 返還)と呼びます。
リフレクトも、そのものずばりReflect
というオブジェクトを使います。
// Proxyに委任された `get()` 権限を、委任もとに返還する。
Reflect.get(target, propertyKey, receiver);
// Proxyに委任された `set()` 権限を、委任もとに返還する。
Reflect.set(target, property, value, receiver);
// Proxyに委任された `deleteProperty()` 権限を、委任もとに返還する。
Reflect.deleteProperty(target, propertyKey);
// …などなど!
Reflect
の各メソッドは、ハンドラーのトラップ関数と同じ引数を持ちます。そしてそのメソッドの戻り値は、同名のトラップ関数が通常通りに動作したときの値となります。
つまり、例えばgetトラップを作ったうえで、元の動作を保証したければ、
/** ハンドラー(代理人に渡す命令状) */
const handler = {
/**
* get: [[Get]] に対するトラップ
*
* @template {T}
* @param {T} target 委任もと本人
* @param {string} p target のプロパティ名
* @param {any?} receiver この関数における `this` となるオブジェクト(通常は代理人)
* @returns 返したい値
*/
get(target, p, receiver) {
// `Reflect.get(すべての引数)` を return !
return Reflect.get(target, p, receiver);
},
};
と、return Reflect[トラップ関数名](すべての引数);
とすればいいですし、一部の引数(とくにreceiver
が重要となることは少ないでしょう)を省略したい場合は、
/** ハンドラー(代理人に渡す命令状) */
const handler = {
/**
* get: [[Get]] に対するトラップ
*
* @template {T}
* @param {T} target 委任もと本人
* @param {string} p target のプロパティ名
* @returns 返したい値
*/
get(target, p) {
// 記載されてなくても、その関数に渡されたすべての引数は `arguments` 配列にある!
return Reflect.get(...arguments);
},
};
と、return Reflect[トラップ関数名](...arguments);
と書けば、うっかり書き忘れることもありませんので、おすすめします。
追跡開始!
さあお待たせしました。いよいよ「プロパティ名 estimate
に0以下の値が入ったことを追跡」してみましょう。
// トラップ(引っ掛け)で、JavaScriptの動作に割り込みを入れる。
const handler = {
/**
* set: [[Set]] に対するトラップ
*
* @param {any} target 委任もと本人
* @param {string} property target のプロパティ名
* @param {any} value target[property] に、まさに設定しようとしている値
* @param {any?} この関数における `this` となるオブジェクト(通常は代理人)
* @returns {boolean} true: 問題なし, false: 問題発見
*/
set(target, property, value, receiver) {
// estimate に 0 以下が設定されたかどうかを判定
if (property === 'estimate' && value <= 0) {
console.error("ここで0以下が設定された!");
// 問題発覚!
return false;
}
// 監視対象でない場合は、本来の振る舞いをさせ(リフレクト)、trueを返させる
return Reflect.set(...arguments);
},
};
ブラウザーの開発ツールのコンソールを開いてから、下のサンプルの「Run Pen」をクリックしてみてください。問題のある箇所で、例外が発行されるはずです。
See the Pen Proxy の例 1 by isobeact (@rmzpacpm-the-builder) on CodePen.
コンソールには、このように表示されるはずです。コードの箇所まで教えてくれるので、デバッグがはかどりますね!
まとめ
こんかいは、見えないバグの追跡手段としてプロクシを紹介しましたが、ハンドラーとトラップ関数、リフレクトとを組み合わせることで、もともとのJavaScriptの挙動さえ再定義=メタプログラミングすることが可能だとおわかりいただけたと思います。さあ、みなさんはデバッグ以外で何を実現してみましょうか? 夢が広がりますね!
こんかいの記事は、以上です。それでは、よきコーディングライフを!