ECMAScript仕様を理解する、パート2
仕様を読むスキルをさらに練習しましょう。まだ前回のエピソードを見ていない場合は、今がそれを確認する良いタイミングです!
パート2の準備はいいですか?
仕様を知る楽しみな方法として、まずJavaScriptの機能を選び、それがどのように仕様化されているかを調べます。
警告!このエピソードには、2020年2月時点のECMAScript仕様からコピーされたアルゴリズムが含まれています。これらはいずれ古くなります。
プロパティがプロトタイプチェーンで検索されることは知っています: オブジェクトが読もうとしているプロパティを持っていない場合、プロトタイプチェーンを検索し続け、プロパティが見つかるか、それ以上プロトタイプを持たないオブジェクトに達するまで探索します。
例えば:
const o1 = { foo: 99 };
const o2 = {};
Object.setPrototypeOf(o2, o1);
o2.foo;
// → 99
プロトタイプの検索はどこで定義されていますか?
この動作がどこで定義されているかを見つけてみましょう。始めるのに良い場所はオブジェクト内部メソッドのリストです。
[[GetOwnProperty]]と[[Get]]の両方がありますが、自身のプロパティに制限されないバージョンを探しているので、[[Get]]を選びます。
残念ながら、プロパティ記述子仕様型にも[[Get]]というフィールドがあります。そのため、仕様を[[Get]]のために閲覧する際には、この二つの独立した使用法を慎重に区別する必要があります。
[[Get]]は基本的な内部メソッドです。通常のオブジェクトは基本的な内部メソッドのデフォルトの動作を実装します。特殊なオブジェクトはデフォルトの動作から逸脱する独自の内部メソッド[[Get]]を定義できます。この投稿では通常のオブジェクトに焦点を当てています。
[[Get]]のデフォルト実装はOrdinaryGetに委任します:
Oの[[Get]]内部メソッドがプロパティキーPとECMAScript言語値Receiverと共に呼び出されるとき、次の手順が実行されます:
? OrdinaryGet(O, P, Receiver)を返します。
Receiverはアクセサプロパティのゲッタ関数を呼び出す際に使用されるthis値であることがすぐに分かります。
OrdinaryGetは次のように定義されています:
OrdinaryGet ( O, P, Receiver )抽象操作
OrdinaryGetがオブジェクトO、プロパティキーP、ECMAScript言語値Receiverと共に呼び出されるとき、次の手順が実行されます:
IsPropertyKey(P)がtrueであることを断言します。descを? O.[[GetOwnProperty]](P)とします。descがundefinedの場合:
parentを? O.[[GetPrototypeOf]]()とします。parentがnullの場合、undefinedを返します。? parent.[[Get]](P, Receiver)を返します。IsDataDescriptor(desc)がtrueの場合、desc.[[Value]]を返します。IsAccessorDescriptor(desc)がtrueであることを断言します。getterをdesc.[[Get]]とします。getterがundefinedの場合、undefinedを返します。? Call(getter, Receiver)を返します。
プロトタイプチェーンの探索はステップ3の中にあります: プロパティが自身のプロパティとして見つからない場合、プロトタイプの[[Get]]メソッドを呼び出し、再度OrdinaryGetに委任します。まだプロパティが見つからない場合、さらにそのプロトタイプの[[Get]]メソッドを呼び出し、再度OrdinaryGetに委任します。これをプロパティが見つかるか、プロトタイプを持たないオブジェクトに到達するまで繰り返します。
o2.fooにアクセスする際、このアルゴリズムがどのように動作するか見てみましょう。まず、OrdinaryGetをOがo2、Pが"foo"の場合で呼び出します。O.[[GetOwnProperty]]("foo")はundefinedを返します。なぜならo2には"foo"という自身のプロパティがないためです。そのためステップ3の条件分岐に進みます。ステップ3.aで、parentをo2のプロトタイプ、つまりo1に設定します。parentはnullではないので、ステップ3.bで返されません。ステップ3.cで、プロパティキー"foo"を使用して親の[[Get]]メソッドを呼び出し、その結果を返します。
親(o1)は通常のオブジェクトなので、その[[Get]]メソッドは再度OrdinaryGetを呼び出します。この場合、Oがo1でPが"foo"です。o1には"foo"という自身のプロパティがあるため、ステップ2でO.[[GetOwnProperty]]("foo")は関連するプロパティ記述子を返し、それをdescに格納します。
プロパティ記述子は仕様タイプです。データプロパティ記述子はプロパティの値を直接 [[Value]] フィールドに保存します。アクセサプロパティ記述子は [[Get]] および/または [[Set]] フィールドにアクセサ関数を保存します。この場合、"foo" に関連付けられたプロパティ記述子はデータプロパティ記述子です。
ステップ2で desc に保存したデータプロパティ記述子は undefined ではないため、ステップ3で if 分岐を採用しません。次にステップ4を実行します。プロパティ記述子がデータプロパティ記述子であるため、ステップ4でその [[Value]] フィールド 99 を返し、終了します。
Receiverとは何か、それはどこから来るのか?
Receiver パラメータはアクセサプロパティの場合にのみステップ8で使用されます。これはアクセサプロパティのゲッタ関数を呼び出す際に this 値 として渡されます。
OrdinaryGet は再帰の間で元の Receiver を変更せずに通過させます(ステップ3.c)。次に、Receiver が最初にどこから来るのか確認してみましょう!
[[Get]] が呼び出される箇所を検索すると、参照に対して動作する抽象的な操作 GetValue が見つかります。参照は仕様タイプであり、基底値、参照名、および厳密参照フラグで構成されています。o2.foo の場合は、基底値がオブジェクト o2、参照名が文字列 "foo"、厳密参照フラグが false となります。この例のコードは厳密ではないためです。
脇道: なぜ参照はレコードではないのか?
脇道: 参照はレコードではありませんが、レコードのように見えるかもしれません。三つのコンポーネントを含んでおり、それらは三つの名前付きフィールドとしても同様に表現できます。参照がレコードではない理由は歴史的なものに過ぎません。
GetValue に戻る
GetValue がどのように定義されているか見てみましょう:
ReturnIfAbrupt(V)。Type(V)がReferenceでないなら、Vを返す。baseをGetBase(V)とする。IsUnresolvableReference(V)がtrueなら、ReferenceError例外を投げる。IsPropertyReference(V)がtrueなら、以下を実行:
HasPrimitiveBase(V)がtrueなら、以下を実行:
- この場合、
baseがundefinedまたはnullになることはないことを保証する。baseを! ToObject(base)に設定する。? base.[[Get]](GetReferencedName(V), GetThisValue(V))を返す。- それ以外の場合:
baseは環境レコードであることを保証する。? base.GetBindingValue(GetReferencedName(V), IsStrictReference(V))を返す。
例の参照は o2.foo であり、プロパティ参照です。そのため、分岐5を採用します。分岐5.aは採用しません。基底値(o2)が プリミティブ値(数値、文字列、シンボル、ビッグイント、真偽値、未定義、またはヌル)ではないためです。
次に、ステップ5.bで [[Get]] を呼び出します。渡す Receiver は GetThisValue(V) であり、この場合は参照の基底値そのものです:
IsPropertyReference(V)がtrueであることを保証する。IsSuperReference(V)がtrueなら、以下を実行:
Vの参照のthisValueコンポーネントの値を返す。GetBase(V)を返す。
o2.foo では、ステップ2の分岐は採用しません。それはスーパー参照(例えば super.foo のようなもの)ではないためですが、ステップ3を採用し、参照の基底値 o2 を返します。
すべてを組み合わせると、Receiver を元の参照の基底値に設定し、その後プロトタイプチェーンのウォーク中に変更しないことが分かります。最終的に、見つけるプロパティがアクセサプロパティの場合、呼び出し時に this 値 として Receiver を使用します。
特に、ゲッタ内の this 値 はプロトタイプチェーンウォーク中にプロパティが見つかったオブジェクトではなく、プロパティを取得しようとした元のオブジェクトを指します。
試してみましょう!
const o1 = { x: 10, get foo() { return this.x; } };
const o2 = { x: 50 };
Object.setPrototypeOf(o2, o1);
o2.foo;
// → 50
この例では、foo というアクセサプロパティがあり、それに対してゲッタを定義しています。ゲッタは this.x を返します。
次に o2.foo にアクセスします。ゲッタは何を返しますか?
ゲッタを呼び出す際の this 値 はプロパティを取得しようとしたオブジェクト自身であり、プロトタイプチェーンウォーク中にプロパティを見つけたオブジェクトではありません。この場合 this 値 は o2 であり、o1 ではありません。ゲッタが o2.x または o1.x を返すかどうかを確認することで検証できます。実際に o2.x を返します。
動きました!このコードスニペットの挙動を仕様を読んで予測することができました。
プロパティのアクセス — なぜ [[Get]] を呼び出すのか?
o2.foo などのプロパティにアクセスする際にオブジェクトの内部メソッド [[Get]] が呼び出されることはどこで仕様に記載されているのでしょうか?確かにどこかで定義されているはずです。私の言葉をそのまま鵜呑みにしないでください!
オブジェクトの内部メソッド [[Get]] が参照に対して動作する抽象的な操作 GetValue から呼び出されることを見つけました。しかし、GetValue はどこから呼び出されるのでしょうか?
MemberExpression のランタイムセマンティクス
仕様の文法規則は言語の構文を定義しています。ランタイムセマンティクスは、構文構造が「意味すること」(実行時にどのように評価されるか)を定義します。
文脈自由文法に慣れていない場合は、今すぐ確認することをお勧めします!
文法規則については次のエピソードで詳しく見ていきますので、今はシンプルにしておきましょう!特に、このエピソードでは生産式の下付き文字(Yield、Awaitなど)は無視してかまいません。
以下の生産式は、MemberExpressionがどのようなものかを説明しています:
MemberExpression :
PrimaryExpression
MemberExpression [ Expression ]
MemberExpression . IdentifierName
MemberExpression TemplateLiteral
SuperProperty
MetaProperty
new MemberExpression Arguments
ここでは、MemberExpressionのための7つの生産式があります。MemberExpressionは単にPrimaryExpressionであることができます。あるいは、MemberExpressionとExpressionを組み合わせてMemberExpression [ Expression ](例: o2['foo'])のように構築することもできます。または、MemberExpression . IdentifierName(例: o2.foo)のようにもなれます。これは私たちの例に関係する生産式です。
MemberExpression : MemberExpression . IdentifierNameの生産式のランタイムセマンティクスは、それを評価する際の一連の手順を定義しています:
ランタイムセマンティクス:
MemberExpression : MemberExpression . IdentifierNameの評価
baseReferenceをMemberExpressionを評価した結果とする。baseValueを? GetValue(baseReference)とする。- この
MemberExpressionに一致するコードが厳格モードコードである場合、strictをtrueに設定する。それ以外の場合はfalseに設定する。? EvaluatePropertyAccessWithIdentifierKey(baseValue, IdentifierName, strict)を返す。
このアルゴリズムは、抽象操作EvaluatePropertyAccessWithIdentifierKeyに委譲されるため、それも読み込む必要があります:
EvaluatePropertyAccessWithIdentifierKey( baseValue, identifierName, strict )抽象操作
EvaluatePropertyAccessWithIdentifierKeyは、値baseValue、構文木ノードidentifierName、およびブール引数strictを引数として取ります。以下の手順を実行します:
identifierNameがIdentifierNameであることをアサートする。bvを? RequireObjectCoercible(baseValue)とする。propertyNameStringをidentifierNameのStringValueとする。- 基本値コンポーネントが
bvであり、参照された名前コンポーネントがpropertyNameStringで、厳格参照フラグがstrictであるタイプReferenceの値を返す。
つまり:EvaluatePropertyAccessWithIdentifierKeyは、与えられたbaseValueをベースとして使用し、identifierNameの文字列値をプロパティ名として使用し、strictを厳格モードフラグとして使用するReferenceを構築します。
最終的に、このReferenceはGetValueに渡されます。これはそのReferenceの使用方法に応じて仕様書内のいくつかの場所で定義されています。
MemberExpressionをパラメータとして使用
私たちの例では、プロパティアクセスをパラメータとして使用します:
console.log(o2.foo);
この場合、動作はArgumentList生産式のランタイムセマンティクスによって定義され、引数に対してGetValueを呼び出します:
ランタイムセマンティクス:
ArgumentListEvaluation
ArgumentList : AssignmentExpression
refをAssignmentExpressionを評価した結果とする。argを? GetValue(ref)とする。- 唯一のアイテムが
argであるリストを返す。
o2.fooはAssignmentExpressionのように見えませんが、実際にはそうなので、この生産式が適用されます。なぜなのかを知りたい場合は、こちらの追加コンテンツを確認してください。ただし、この時点では必ずしも必要ではありません。
ステップ1におけるAssignmentExpressionはo2.fooです。ref、つまりo2.fooを評価した結果は、前述のReferenceです。ステップ2で、それに対してGetValueを呼び出します。このため、オブジェクト内部メソッド[[Get]]が呼び出され、プロトタイプチェーンの探索が行われることを知っています。
まとめ
今回のエピソードでは、仕様が言語機能(今回の場合はプロトタイプ検索)を、トリガーとなる構文構造とそれを定義するアルゴリズムという複数の異なるレイヤーでどのように定義するかを見ていきました。