ECMAScript仕様を理解する, 第3部
このエピソードでは、ECMAScript言語の定義とその構文についてさらに深掘りします。文脈自由文法に馴染みのない方は、今が基礎を確認する良いタイミングです。仕様では言語を定義するために文脈自由文法を使用しています。親しみやすい紹介として"Crafting Interpreters"の文脈自由文法に関する章を参照するか、より数学的な定義についてはWikipediaのページをご覧ください。
ECMAScript文法
ECMAScript仕様では、次の4つの文法が定義されています:
字句文法は、ユニコードコードポイントがどのように入力要素(トークン、行終端子、コメント、空白)に変換されるかを記述しています。
構文文法は、構文的に正しいプログラムがどのようにトークンで構成されるかを定義します。
RegExp文法は、ユニコードコードポイントがどのように正規表現に変換されるかを記述します。
数値文字列文法は、文字列がどのように数値に変換されるかを記述します。
各文法は一連の生成規則からなる文脈自由文法として定義されています。
文法は若干異なる記法を使用します:構文文法はLeftHandSideSymbol :を使用し、字句文法とRegExp文法はLeftHandSideSymbol ::を使用し、数値文字列文法はLeftHandSideSymbol :::を使用します。
次に、字句文法と構文文法についてさらに詳しく見ていきます。
字句文法
仕様では、ECMAScriptのソーステキストをユニコードコードポイントのシーケンスと定義しています。たとえば、変数名はASCII文字に限定されず、他のユニコード文字も含むことができます。仕様は実際のエンコーディング(例: UTF-8やUTF-16)については言及せず、ソースコードがすでにそのエンコーディングに基づいてユニコードコードポイントのシーケンスに変換されていると仮定しています。
ECMAScriptソースコードを事前にトークン化することは不可能です。そのため、字句文法の定義は少し複雑になります。
たとえば、/が除算演算子なのかRegExpの開始なのかは、それが現れる文脈を見ないと判断できません:
const x = 10 / 5;
ここでは/はDivPunctuatorです。
const r = /foo/;
ここでは最初の/はRegularExpressionLiteralの開始です。
テンプレートでも同様の曖昧さが生じます — }`の解釈はその発生する文脈に依存します:
const what1 = 'temp';
const what2 = 'late';
const t = `I am a ${ what1 + what2 }`;
ここでは`I am a ${はTemplateHeadであり、}`はTemplateTailです。
if (0 == 1) {
}`not very useful`;
ここでは}はRightBracePunctuatorであり、`はNoSubstitutionTemplateの開始です。
/や}`の解釈がその“文脈” — コードの構文構造における位置 — に依存するにもかかわらず、次に説明する文法は依然として文脈自由です。
字句文法は、ある入力要素が許容される文脈とそうではない文脈を区別するためにいくつかの目標記号を使用します。たとえば、目標記号InputElementDivは/が除算であり、/=が除算代入である文脈で使用されます。InputElementDiv生成規則は、この文脈で生成可能なトークンを一覧にしています:
InputElementDiv ::
WhiteSpace
LineTerminator
Comment
CommonToken
DivPunctuator
RightBracePunctuator
この文脈では、/に遭遇するとDivPunctuator入力要素が生成されます。ここでRegularExpressionLiteralを生成することはできません。
一方で、InputElementRegExpは/がRegExpの始まりである文脈における目標記号です:
InputElementRegExp ::
WhiteSpace
LineTerminator
Comment
CommonToken
RightBracePunctuator
RegularExpressionLiteral
生成規則からわかるように、ここではRegularExpressionLiteral入力要素を生成する可能性はありますが、DivPunctuatorを生成することはできません。
同様に、RegularExpressionLiteralに加えてTemplateMiddleとTemplateTailが許可されるコンテキストの場合には、別の目標シンボルInputElementRegExpOrTemplateTailがあります。最後に、RegularExpressionLiteralは許可されず、TemplateMiddleとTemplateTailのみが許可されるコンテキストには、目標シンボルInputElementTemplateTailがあります。
実装では、構文解析器(“parser”)が辞書解析器(“tokenizer”または“lexer”)を呼び出して、目標シンボルをパラメータとして渡し、その目標シンボルに適した次の入力要素を要求することがあります。
構文文法
前述の辞書文法では、Unicodeコードポイントからトークンを構築する方法を規定しました。構文文法はそれを基にして構築され、構文的に正しいプログラムがトークンからどのように構成されるかを規定します。
例: レガシー識別子の許容
文法に新しいキーワードを導入することは、既存コードが識別子としてすでにそのキーワードを使用している場合に壊れる可能性がある変更となります。
例えば、awaitがキーワードになる前に、次のようなコードが書かれていたかもしれません:
function old() {
var await;
}
ECMAScript文法は、このコードが引き続き動作するよう、awaitキーワードを慎重に追加しました。非同期関数内ではawaitがキーワードなので、次のようなコードは動作しません:
async function modern() {
var await; // 構文エラー
}
yieldを非ジェネレーターで識別子として許可し、ジェネレーターでは許可しない仕組みも同様に機能します。
awaitを識別子として許可する方法を理解するには、ECMAScript特有の構文文法記法を理解する必要があります。では、実際に見てみましょう!
生成規則と略記法
VariableStatement の生成規則がどのように定義されているか見てみましょう。一見すると、文法は少し難解に見えるかもしれません:
VariableStatement[Yield, Await] :
var VariableDeclarationList[+In, ?Yield, ?Await] ;
添え字 ([Yield, Await]) と接頭辞 (+ や ? の記号) にはどのような意味があるのでしょうか?
この記法については、文法記法セクションで説明されています。
添え字は、左辺記号のセットに対して生成規則のセットを一度に表現する略記法です。左辺記号には2つのパラメーターがあり、これにより4つの「実際の」左辺記号に展開されます: VariableStatement, VariableStatement_Yield, VariableStatement_Await, および VariableStatement_Yield_Await。
ここで、単なるVariableStatementは「_Awaitも_YieldもないVariableStatement」意味します。それをVariableStatement[Yield, Await]と混同してはいけません。
生成規則の右辺では、略記 +In は「_In付きのバージョンを使用」、?Await は「左辺記号が_Awaitを持つ場合にのみ_Await付きのバージョンを使用」を意味します(?Yieldも同じ)。
略記のもう一つである~Fooは、「_Fooなしのバージョンを使用」という意味ですが、この規則では使用されていません。
これらの情報を基に、生成規則を次のように展開できます:
VariableStatement :
var VariableDeclarationList_In ;
VariableStatement_Yield :
var VariableDeclarationList_In_Yield ;
VariableStatement_Await :
var VariableDeclarationList_In_Await ;
VariableStatement_Yield_Await :
var VariableDeclarationList_In_Yield_Await ;
最終的に、次の2つの点を明らかにする必要があります:
_Await付きの場合か、_Awaitなしの場合の選択がどこで行われるのか?- どこで違いが生じるのか —
Something_AwaitとSomething(_Awaitなしのもの)の生成規則が分岐する箇所はどこか?
_Await有りか無しか?
まず最初に質問1に取り組みましょう。非同期関数と非非同期関数が、関数本体に対してパラメーター_Awaitを選択するかどうかで異なることは簡単に推測できます。非同期関数宣言の生成規則を読むと、次の規則が見つかります:
AsyncFunctionBody :
FunctionBody[~Yield, +Await]
AsyncFunctionBodyにはパラメーターがありませんが、右辺側のFunctionBodyに追加されます。
この生成規則を展開すると次のようになります:
AsyncFunctionBody :
FunctionBody_Await
つまり、非同期関数はFunctionBody_Awaitを持ち、関数本体ではawaitがキーワードとして扱われます。
一方で、非非同期関数内では、関連する生成規則は次の通りです:
FunctionDeclaration[Yield, Await, Default] :
function BindingIdentifier[?Yield, ?Await] ( FormalParameters[~Yield, ~Await] ) { FunctionBody[~Yield, ~Await] }
(FunctionDeclarationには別の生成規則がありますが、このコード例には関係ありません。)
組み合わせの展開を避けるため、この特定の生成規則で使用されていないDefaultパラメーターは無視します。
生成規則の展開された形は次の通りです:
FunctionDeclaration :
function BindingIdentifier ( FormalParameters ) { FunctionBody }
FunctionDeclaration_Yield :
function BindingIdentifier_Yield ( FormalParameters ) { FunctionBody }
FunctionDeclaration_Await :
function BindingIdentifier_Await ( FormalParameters ) { FunctionBody }
FunctionDeclaration_Yield_Await :
function BindingIdentifier_Yield_Await ( FormalParameters ) { FunctionBody }
この生成規則では、FunctionBody と FormalParameters(_Yield および _Await を含まない)を常に取得します。これは、展開されていない生成規則で [~Yield, ~Await] が付与されているためです。
関数名は異なる扱いを受けます。左辺記号に _Await および _Yield パラメーターがある場合、それらのパラメーターを取得します。
要約すると、非同期関数は FunctionBody_Await をもち、非非同期関数は _Await を含まない FunctionBody をもちます。非ジェネレーター関数について話しているので、非同期例関数も非非同期例関数も _Yield を付与されません。
FunctionBody と FunctionBody_Await のどちらがどれかを覚えるのは難しいかもしれません。FunctionBody_Await は await が識別子である関数のためなのか、それとも await がキーワードである関数のためなのか?
ここで _Await パラメーターは「await がキーワードである」ことを意味するように考えることができます。このアプローチは今後の拡張にも対応可能です。新しいキーワード blob が導入されたとしても、「blob的」関数の内部でのみ適用される場合、非blob的非非同期非ジェネレーターは現在と同じように FunctionBody(_Await、_Yield、または _Blob を含まない)を持ちます。blob的関数は FunctionBody_Blob をもち、非同期blob的関数は FunctionBody_Await_Blob を持つなどです。この場合でも Blob の添字を生成規則に追加する必要がありますが、既存の関数の展開された FunctionBody の形式には変更はありません。
await を識別子として禁止する
次に、FunctionBody_Await 内にいる場合に await が識別子として禁止される仕組みを確認する必要があります。
生成規則の進行を追うことで、_Await パラメーターが FunctionBody から私たちが前に見ていた VariableStatement 生成規則まで変更されることなく伝播される様子がわかります。
したがって、非同期関数内では VariableStatement_Await が存在し、非非同期関数内では VariableStatement が存在します。
さらに生成規則を追いかけながら、パラメーターの追跡を続けることができます。VariableStatement の生成規則は以下の通りです:
VariableStatement[Yield, Await] :
var VariableDeclarationList[+In, ?Yield, ?Await] ;
VariableDeclarationList のすべての生成規則はパラメーターをそのまま保持します:
VariableDeclarationList[In, Yield, Await] :
VariableDeclaration[?In, ?Yield, ?Await]
(ここでは例に関連する 生成規則 のみを示しています。)
VariableDeclaration[In, Yield, Await] :
BindingIdentifier[?Yield, ?Await] Initializer[?In, ?Yield, ?Await] opt
opt の略語は右辺記号が任意であることを意味します。実際にはオプション記号がある生成規則とない生成規則の2つがあります。
例に関連する単純なケースでは、VariableStatement はキーワード var、後に初期化子なしで単一の BindingIdentifier、およびセミコロンで終わります。
await を BindingIdentifier として禁止または許可するために、以下のようなものが得られることを期待しています:
BindingIdentifier_Await :
Identifier
yield
BindingIdentifier :
Identifier
yield
await
これにより、非同期関数内では await を識別子として禁止し、非非同期関数内では識別子として許可することができます。
しかし、仕様はこのように定義されておらず、代わりに以下の 生成規則 が見つかります:
BindingIdentifier[Yield, Await] :
Identifier
yield
await
展開すると、次の生成規則になります:
BindingIdentifier_Await :
Identifier
yield
await
BindingIdentifier :
Identifier
yield
await
(ここでは例に不要な BindingIdentifier_Yield および BindingIdentifier_Yield_Await の生成規則は省略しています。)
これは、await と yield が常に識別子として許可されるように見えます。それはどういうことでしょうか?この記事全体が無駄なのでしょうか?
静的意味が救う
実際には、非同期関数内で await を識別子として禁止するためには静的意味が必要になります。
静的意味は静的なルール、つまりプログラムが実行される前にチェックされるルールを記述します。
この場合、BindingIdentifier の静的意味 は次の構文指導ルールを定義しています:
BindingIdentifier[Yield, Await] : awaitこの生成規則が
[Await]パラメーターを持つ場合、文法エラーになります。
これは効果的に BindingIdentifier_Await : await の生成規則を禁止します。
仕様書では、この生成規則を持ちながら静的意味論において構文エラーとして定義する理由は、自動セミコロン挿入(ASI)との干渉によるものだと説明されています。
ASIは、文法生成規則に従ってコードの行を解析できない場合に発動します。ASIは、文や宣言がセミコロンで終わらなければならない要件を満たすためにセミコロンを追加しようとします。(ASIについては後のエピソードで詳しく説明します。)
次のコードを考えてください(仕様書の例より):
async function too_few_semicolons() {
let
await 0;
}
もし文法が await を識別子として許可しない場合、ASIが発動し、次のような文法的に正しいコードに変換します。このコードでは let も識別子として使用されています:
async function too_few_semicolons() {
let;
await 0;
}
ASIとのこのような干渉は非常に混乱を招くため、静的意味論が使用され、await を識別子として許可しない措置が取られました。
識別子のStringValuesの禁止
関連するもう一つのルールがあります:
BindingIdentifier : Identifierこの生成規則が
[Await]パラメーターを持ち、IdentifierのStringValueが"await"の場合、それは構文エラーとなります。
最初は少し混乱するかもしれませんが、Identifierは次のように定義されています:
Identifier :
IdentifierName but not ReservedWord
awaitはReservedWordなので、Identifierがawaitになることはありません。
実際には、Identifierがawaitになることはなく、しかしStringValueが"await"である別のものになる可能性があります — 文字列シーケンスawaitの異なる表現です。
識別子名の静的意味論は、識別子名のStringValueがどのように計算されるかを定義しています。例えば、Unicodeエスケープシーケンスでaを表すのは\u0061なので、\u0061waitはStringValueが"await"になります。\u0061waitは字句構文ではキーワードとして認識されず、代わりにIdentifierとなります。静的意味論によりこれは非同期関数内で変数名として使用することは禁止されています。
したがって、次は動作します:
function old() {
var \u0061wait;
}
これは動作しません:
async function modern() {
var \u0061wait; // 構文エラー
}
まとめ
このエピソードでは、字句構文、構文構文、そして構文構文を定義するために使用される省略表記に慣れました。例として、非同期関数内でawaitを識別子として使用することを禁止し、非非同期関数内では許可するという点を掘り下げました。
自動セミコロン挿入やカバー構文など、構文構文の他の興味深い部分については後のエピソードで取り上げますので、お楽しみに!