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]]
が呼び出され、プロトタイプチェーンの探索が行われることを知っています。
まとめ
今回のエピソードでは、仕様が言語機能(今回の場合はプロトタイプ検索)を、トリガーとなる構文構造とそれを定義するアルゴリズムという複数の異なるレイヤーでどのように定義するかを見ていきました。