🎛️

JavaScriptのレガシー挙動を定めたAnnex Bをひたすら読む記事

2022/01/16に公開

ECMAScript Annex Bおよび関連する仕様を読みます。

おことわり

  • 言うまでもありませんが、ここで説明されている機能は使わないようにしましょう。
  • 筆者がJavaScriptを書き始めたのは2005年頃で、その後2010年代は実質的な空白期間でした。そのため本記事に含まれる歴史的背景の説明は、2005年頃の筆者が学んだ内容に加えて、当時の資料を遡って調査した結果に基づいて記載されています。できる限り信頼性の高い情報を見つけた上で記述するよう心がけましたが、当時常識だった知識の欠落等により不正確な記述になっている部分があるかもしれません。もし誤り等があったら指摘いただけると嬉しいです。
  • 現在のzennでは <sub></sub><ins></ins> は描画されていませんが、心の目で下付き文字や下線装飾に読み替えてください。

ECMAScript Annex B とは

ECMAScript Annex B ではWebブラウザの互換性のための追加機能を定義しています。これが本文に対するパッチの形で書かれているのは、非推奨で筋の悪い機能でありながらも過去のWebページとの互換性のために残す必要があるからです。そのためAnnex Bはフルの標準ではなく、以下のようなものとして扱われるべきです。

  • WebブラウザはAnnex Bを実装する必要がある。
  • Webブラウザ以外のJavaScriptの処理系はAnnex Bを実装しないのが望ましい。
  • JavaScriptのプログラムを新規に記述したり出力したりする場合はAnnex Bの機能に依存しないのが望ましい。

詳しくは別記事「Annex Bを読む、の補足と附録」にまとめたので、そちらをご覧ください。

レガシー8進/10進リテラルとエスケープ

この規定はECMAScript最新版 (ES2022 Draft) ではAnnex Bではなく本文 (LEGACY) で規定されています。Normative Optionalではないため、処理系はこの機能を実装する必要があります。

背景と歴史

0 を前置することで8進数になるという慣習はC言語を代表としていくつかのプログラミング言語で実装されていますが、これは10進数と紛らわしいため最近の言語では 0o など別の接頭辞で代替される動きがあります。JavaScriptでも同様にES2015で 0o が使えるようになり、0 は非推奨化されています。

一方C言語では文字列中でも \012 のように8進数でエスケープを指定することが可能で、JavaScriptにも同じ機能が受け継がれています。これは区切り位置が明確でないなどの問題があり非推奨化されています。 ( \x0A など別のエスケープを使うのがいいでしょう)

いずれも 09\9 など8進数として正しくないケースのフォールバックが後から定められています。

  • 1999年: ES3でレガシー8進リテラルと8進エスケープがAnnex Bに移動[1]
  • 2009年: ES5でstrict modeが導入された。これに合わせて、レガシー8進リテラルと8進エスケープをstrict modeで禁止する規定が追加された。
  • 2015年: ES2015がリリース。
    • 接頭辞の新8進リテラル 0o0b とともに追加された。
    • 非8進形式の10進リテラルが追加された。[2]
  • 2021年: 非8進形式の10進エスケープがES2021で追加された
  • 2022年: ES2022で全ての規定が本文 (LEGACY) に移動する。

レガシー8進リテラル

NumericLiteral ::
     LegacyOctalIntegerLiteral

LegacyOctalIntegerLiteral ::
     0 OctalDigit
     LegacyOctalIntegerLiteral OctalDigit

0で始まる数はレガシー8進法の整数です。ただし、単なる 0 は10進法であり別の構文として扱っています。

017 // 15

NumericLiteral :: LegacyOctalIntegerLiteral
DecimalIntegerLiteral :: NonOctalDecimalIntegerLiteral

  • It is a Syntax Error if the source text matched by this production is strict mode code.

NOTE: In non-strict code, this syntax is Legacy.

strict modeではレガシー8進リテラルを禁止しています。また、strict modeではない場合でもレガシー8進リテラルはLEGACYであると規定しています。

なお、現在はレガシー8進リテラルとは別に 0o で始まる8進リテラルが使用可能です。

レガシー非8進形式10進リテラル

DecimalIntegerLiteral ::
     NonOctalDecimalIntegerLiteral

NonOctalDecimalIntegerLiteral ::
     0 NonOctalDigit
     LegacyOctalLikeDecimalIntegerLiteral NonOctalDigit
     NonOctalDecimalIntegerLiteral DecimalDigit

NonOctalDigit :: one of
     8 9

0で始まる数はレガシー8進法の整数ですが、もし途中で 89 が出てきたときは8進法としては解釈できないので10進法にフォールバックします。

017 // 15 (8進法として解釈されている)
018 // 18 (10進法として解釈されている)

NumericLiteral :: LegacyOctalIntegerLiteral
DecimalIntegerLiteral :: NonOctalDecimalIntegerLiteral

  • It is a Syntax Error if the source text matched by this production is strict mode code.

NOTE: In non-strict code, this syntax is Legacy.

strict modeではこのような8進法と紛らわしい10進リテラルも禁止しています。また、strict modeではない場合でもこれはLEGACYであると規定しています。

8進エスケープ

EscapeSequence ::
     0 [lookahead ∉ DecimalDigit]

NULエスケープ \0 を定義しています。これ自体は現在でも正当な機能ですが、数字が後続してはいけないという制約があります。

EscapeSequence ::
     LegacyOctalEscapeSequence

LegacyOctalEscapeSequence ::
     0 [lookahead ∈ { 8, 9 }]
     NonZeroOctalDigit [lookahead ∉ OctalDigit]
     ZeroToThree OctalDigit [lookahead ∉ OctalDigit]
     FourToSeven OctalDigit
     ZeroToThree OctalDigit OctalDigit

ZeroToThree :: one of
     0 1 2 3
FourToSeven :: one of
     4 5 6 7

8進エスケープを定義しています。8進エスケープは「3桁以内」かつ「256未満」という制約の中で貪欲にパースされます。これを表現するために OctalDigitZeroToThreeFourToSeven に分けています。

また、 \0 で始まるケースについても慎重に扱っています。 \07\011 ではNULエスケープよりも8進エスケープとしての解釈が優先されるようになっていますが、NULエスケープ側では \08\09も禁止するようにしていました。この場合は \0 までをNULエスケープではなく8進エスケープとしてパースすると規定しています。紛らわしいことに変わりはないので一緒に禁止してしてしまおうという魂胆でそう。

"\0a" // "\0" = NULエスケープ
"\08" // "\0" = 8進エスケープ
"\01" // "\01" = 8進エスケープ
"\011" // "\011" = 8進エスケープ
"\0111" // "\011" = 8進エスケープ (最大3桁のためパースが打ち切られる)
"\377" // "\377" = 8進エスケープ
"\400" // "\40" = 8進エスケープ (最大255のためパースが打ち切られる)

EscapeSequence ::
     LegacyOctalEscapeSequence
     NonOctalDecimalEscapeSequence

  • It is a Syntax Error if the source text matched by this production is strict mode code.

NOTE 1: In non-strict code, this syntax is Legacy.

strict modeでは8進エスケープを禁止しています。また、strict modeではない場合でも8進エスケープはLEGACYであると規定しています。前述の通り、 \08\09 に出てくる \0 もこのルールで禁止されることは注目に値します。

NOTE 2: It is possible for string literals to precede a Use Strict Directive that places the enclosing code in strict mode, and implementations must take care to enforce the above rules for such literals. For example, the following source text contains a Syntax Error:

function invalid() { "\7"; "use strict"; }

後続するUse Strict Directiveによって翻って直前の文字列リテラルの8進エスケープが禁止されるケースについての注意書きです。今後のディレクティブ追加に備えて「ディレクティブは先頭に何個書いてもよい」「未知の内容の文字列リテラルは未知のディレクティブとして無視する」というルールになっているために発生します。よくこんなコーナーケースを思いつきますね。

非8進形式のエスケープ

EscapeSequence ::
     NonOctalDecimalEscapeSequence

NonOctalDecimalEscapeSequence :: one of
     8 9

8進リテラルと違い、8進エスケープでは 8 9 が出てきてもそこでパースが打ち切られるだけです。しかし例外としていきなり 8 9 が出てくる場合 (\8, \9) があるため、この場合は 8, 9 とみなすと規定しています。

EscapeSequence ::
     LegacyOctalEscapeSequence
     NonOctalDecimalEscapeSequence

  • It is a Syntax Error if the source text matched by this production is strict mode code.

NOTE 1: In non-strict code, this syntax is Legacy.

この紛らわしいエスケープも当然strict modeでは禁止、非strict modeでもLEGACYです。

HTML風コメント

HTML風のコメントである <!----> がサポートされます。と言っても、 <!----> で囲まれた部分がコメントになるわけではありません。これらを1行コメントとして認識させるのが本機能の特徴です。

背景と歴史

JavaScript (Netscape Navigator 2.0) がリリースされた1995年時点では、それ以外のWebブラウザは当然 <script> タグを知りません。未知のタグは通常無視されて中身がそのまま表示されますが、 <script> の中身が表示されてしまうのは製作者にとって意図した挙動ではありません。

そこでNetscape Navigatorでは、 <script> タグ内でHTMLコメントを特別扱いし、複数行コメントではなく1行コメントとして無視するようになっていました。実際のNetscape Navigator 2.0のハンドブックの記述例がこちらです。

<head>
<script language="JavaScript">
<!--  to hide script contents from old browsers
  function square(i) {
    document.write("The call passed ", i ," to the function.","&lt;BR&gt;")
    return i * i
  }
  document.write("The function returned ",square(5),".")
// end hiding contents from old browsers  -->
</script>
</head>
<body>
<br>
All done.
</body>

これにより既存のWebブラウザは <!-- ... --> の間を全てコメントと認識する一方、Netscape Navigator 2.0 では1行コメントである <!--// --> だけを無視して中身を実行することができます。

  • 1995年: Netscape Navigator 2.0 (JavaScript 1.0) にHTMLの開きコメントの処理が実装された。
  • 2000年: MozillaにHTMLの閉じコメントの処理が実装された
  • 2012年: WHATWG JavaScriptが作られた。ここにHTMLコメントの記述が含まれる。
  • 2015年: WHATWG JavaScriptの内容を取り込む形で、HTMLコメントがES2015のAnnex Bに記載された。

Annex Bの挙動

本来であれば必要なのは <!-- のサポートだけですが、ES2015では --> のサポートも同時に足されています。ざっくり言うと以下のようなルールが適用されます。

  • <!--// と同様、行コメントマーカーとして利用できる。
  • --> は同じ行に先行するトークンがある場合を除き、行コメントマーカーとして利用できる。 (ただし、プログラムソースの先頭行では使えない)
--> ←これは行コメント
<!-- ←これも行コメント

let x = 10;
// コメントではない。-- と > として解釈される
while (x --> 0) console.log(x);

console.log(x); <!-- ←これも行コメント
/* foo */ /* bar */ --> ←これも行コメント
/* foo
 */ /* bar */ --> ←これも行コメント

調べてみると2000年7月にMozillaに行頭の --> を認識する変更が入っています。おそらく、 // --> を間違えて --> と書いていたHTMLをより適切にサポートするためにMozillaが当該の実装を入れ、それを反映する形でWHATWG JavaScriptの規格に含まれたものがES2015に取り込まれたという経緯でしょう。

モジュールの除外規定

The syntax and semantics of 12.4 is extended as follows except that this extension is not allowed when parsing source text using the goal symbol Module:

モジュール (ES Modules) は新しい機能なので、モジュール内ではHTMLコメントは使えません。

strict modeかどうかは字句解析と構文解析をしてみないとわからないため、字句解析の結果をグローバルに変える可能性がある本機能はstrict modeの有無に依存して切り替わらないようになっていると考えられます。モジュールかどうかは外部的な情報から決まる (少なくともWebブラウザではそのようになっている) ため、このような規定を入れても問題なく実装できます。

HTML開きコメント

Comment ::
     SingleLineHTMLOpenComment

SingleLineHTMLOpenComment ::
     <!-- SingleLineCommentChars<sub>opt</sub>

HTMLの開きコメント <!-- を行コメントマーカーとして定義しています。 SingleLineCommentChars// で使われているものと同じです。

字句構文では開始位置からの最長マッチを次のトークンとして採用するルールになっているため、このルールがあることで <!--< よりも優先して使われます。

複数行コメントの修正

Comment ::
     MultiLineComment
     SingleLineDelimitedComment

規格本文では単一のルールだった /* ... */ を2つのルールに分けています。

  • /* ... */ に改行が含まれない場合→ SingleLineDelimitedComment
  • /* ... */ に改行が含まれる場合→ MultiLineComment

MultiLineComment ::
     /* FirstCommentLine<sub>opt</sub> LineTerminator MultiLineCommentChars<sub>opt</sub> */ HTMLCloseComment<sub>opt</sub>

FirstCommentLine ::
     SingleLineDelimitedCommentChars

/* ... */ に改行が含まれる場合です。改行が含まれることを明示するために内部がやや複雑になっています。 MultiLineCommentChars は規格本文で使われているものと同じです。

SingleLineDelimitedComment ::
     /* SingleLineDelimitedCommentChars<sub>opt</sub> */

/* ... */ に改行が含まれない場合です。

SingleLineDelimitedCommentChars ::
     SingleLineNotAsteriskChar SingleLineDelimitedCommentChars<sub>opt</sub>
     * SingleLinePostAsteriskCommentChars<sub>opt</sub>

SingleLineNotAsteriskChar ::
     SourceCharacter but not one of * or LineTerminator

SingleLinePostAsteriskCommentChars ::
     SingleLineNotForwardSlashOrAsteriskChar SingleLineDelimitedCommentChars<sub>opt</sub>
     * SingleLinePostAsteriskCommentChars<sub>opt</sub>

SingleLineNotForwardSlashOrAsteriskChar ::
     SourceCharacter but not one of / or * or LineTerminator

/* ... */ 内で使われていた MultiLineCommentChars を、改行を許さない形で修正したものです。 */ や改行が含まれない文字列をあらわしています。

HTML閉じコメント

Comment ::
     MultiLineComment
     SingleLineHTMLCloseComment

MultiLineComment ::
     /* FirstCommentLine<sub>opt</sub> LineTerminator MultiLineCommentChars<sub>opt</sub> */ HTMLCloseComment<sub>opt</sub>

SingleLineHTMLCloseComment ::
     LineTerminatorSequence HTMLCloseComment

「行頭」という条件をそのまま書くことはできないので、以下のように場合分けをしています。

  • 改行トークンから後続する場合 (SingleLineHTMLCloseComment)
  • 改行を含む複数行コメントから後続する場合 (MultiLineComment)

SingleLineHTMLCloseComment はSingleLineという名前がついていますが、実際には直前の改行文字を含んでいます。 (元々コメントは改行文字を含むときに LineTerminator と同様の効果を持つと定義されているため、このことによる挙動への影響はありません)

HTMLCloseComment ::
     WhiteSpaceSequence<sub>opt</sub> SingleLineDelimitedCommentSequence<sub>opt</sub> --> SingleLineCommentChars<sub>opt</sub>

WhiteSpaceSequence ::
     WhiteSpace WhiteSpaceSequence<sub>opt</sub>

SingleLineDelimitedCommentSequence ::
     SingleLineDelimitedComment WhiteSpaceSequence<sub>opt</sub> SingleLineDelimitedCommentSequence<sub>opt</sub>

行頭の空白文字と /* ... */ を読み飛ばしたあと --> を発見したらHTMLの閉じコメントとみなしています。あとは行コメントと同じです。

正規表現のレガシー文法

背景と歴史

ECMAScriptの本文で規定されている正規表現の構文はコンフリクトのない綺麗な文法で書かれています。しかし、実際のWebブラウザは規格の拡張として規格上は文法違反になる正規表現をかなり寛容に解釈することが指摘されており、これらの挙動に依存したコードの互換性を保つためにES2015ではAnnex Bの一部として取り入れられました。 (正確にはES2015策定前の2013年9月ごろからドラフトに取り入れられています)

また、パーサーが寛容になってしまっていると新しい構文を追加するときに困難が発生する場合があります。ES2018で名前つきキャプチャグループが導入された際、 /\k/ に対する既存の寛容な挙動を維持するために、2段階で構文解析する仕組みが取り入れられています。このようなことがないようにUnicodeモードでは将来の構文の追加を妨げるような拡張を明示的に禁止するようになっています。

  • 2015年: ES2015で正規表現の構文に関する以下の内容がAnnex Bに追加された。
    • 先読みの量化
    • 括弧のフォールバック
    • 未知エスケープのフォールバック
    • キャプチャグループ番号のフォールバック (WHATWG JavaScriptからの移管)
    • 無効な範囲指定のフォールバック
      • 現在とは異なる形で定義されていた。
  • 2015年: ES2015では正規表現のUnicodeモードも導入された。Unicodeモードでは互換性仕様が無効化されたほか、将来の互換性のために特定の拡張 (エスケープのフォールバック) が明示的に禁止された。
  • 2016年: ES2016のAnnex Bに以下の変更が入った
    • 追加: 文字クラス内の制御文字エスケープの拡張
    • 変更: 無効な範囲指定のフォールバック (構文解析中ではなく意味論でフォールバックを定義するように変更)
    • その他細かい変更。
  • 2018年: ES2018で名前つきキャプチャグループの導入にともない、同名のエスケープのフォールバックを使っていたコードが壊れないようにキャプチャグループ名のフォールバックが定義された。

JavaScriptの字句解析器との関係について

JavaScriptの字句解析器は、正規表現のパーサーには依存しない形で定義されています。JavaScriptの字句解析器が正規表現リテラルに遭遇したときのルールはおよそ以下のようになっています。

  • // または /* で始まる場合はコメントとしての解釈を優先し、正規表現リテラルとしては解釈しない。
  • 次に / が出現したときに正規表現リテラルの終わりとする。ただし、以下の例外がある。
    • エスケープされた / つまり \/ は正規表現リテラルの一部とみなす。
    • 文字クラス [...] 中に出現した / は正規表現リテラルの一部とみなす。
  • ↑の対応のために [, ] \ も特別な文字として認識される。これらも \[, \], \\ とすることでエスケープできる。

それ以外の構文は、この時点 (字句解析中) では特別に認識する必要はありません。これらはJavaScriptの構文解析が終わった後の静的検査中にあらためてパースされます。

Annex B では、正規表現リテラルの字句文法には手を加えていません。あくまで、正規表現リテラルや new RegExp で正規表現がコンパイルされるときの挙動を拡張しているだけです。そのため、JavaScriptの字句解析の挙動がこれによって変わることはありません。

Unicodeモードについて

歴史的な経緯から、JavaScriptの正規表現Unicodeの基本多言語面のコードポイント (U+0000~U+FFFF) に対して動作するBMPモードと、Unicodeのスカラー値 (U+0000~U+D7FF, U+E000~U+10FFFF) に対して動作するUnicodeモードがあります。 u フラグをつけるとUnicodeモードになります。

/a/u // Unicodeモード

本節の規定は互換性のために残されているもののため、相対的に新しい機能であるUnicodeモードが有効のときは規格本文に書かれている(まともな)挙動が維持されるようになっています。

規格中の文法では [UnicodeMode] フラグによってパーサーの挙動が分けられています。

Nフラグについて

文法中に登場する [N] フラグは \k<groupName> の有効・無効を制御します。これも互換性のための仕組みです。詳しくは後述します。

先読みの量化

Term<sub>[UnicodeMode, N]</sub> ::
     [+UnicodeMode] Assertion<sub>[+UnicodeMode, ?N]</sub>
     [~UnicodeMode] QuantifiableAssertion<sub>[?N]</sub> Quantifier
     [~UnicodeMode] Assertion<sub>[~UnicodeMode, ?N]</sub>

QuantifiableAssertion<sub>[N]</sub> ::
     ( ? = Disjunction<sub>[~UnicodeMode, ?N]</sub> )
     ( ? ! Disjunction<sub>[~UnicodeMode, ?N]</sub> )

\b, ^, $ などは前後の文脈に対する判定を行うだけで、それ自体は幅を持ちません。このような式をアサーションといいます。JavaScriptの正規表現ではアサーションには量化を直接つけることはできないのですが、ここでは互換性のために先読みには量化をつけられるようにしています。

// 先読みを量化している (量化する意味はない)
/(?=foo)+/.test("foo");

後読みは比較的新しい機能のため、後読みの量化はAnnex Bにも含まれていません。

括弧のフォールバック

Term<sub>[UnicodeMode, N]</sub> ::
     [~UnicodeMode] ExtendedAtom<sub>[?N]</sub> Quantifier
     [~UnicodeMode] ExtendedAtom<sub>[?N]</sub>

拡張構文を記述するためにBMPモードでは Atom のかわりになる ExtendedAtom を定義して使っています。これは以降も出てきます。

(ExtendedAtom のキャプチャグループ用のルールから GroupSpecifier が抜けているのはミス?)

ExtendedAtom[N] ::
     ExtendedPatternCharacter

ExtendedPatternCharacter ::
     SourceCharacter but not one of ^ $ \ . * + ? ( ) [ |

PatternCharacter のかわりに ExtendedPatternCharacter を使っています。これには追加で ] { } の3文字が含まれています。

/]/ // リテラルな "]"
/}/ // リテラルな "}"

ただし、以下のルールに注意が必要です。

ExtendedAtom[N] ::
     InvalidBracedQuantifier

InvalidBracedQuantifier ::
     { DecimalDigits<sub>[~Sep]</sub> }
     { DecimalDigits<sub>[~Sep]</sub> , }
     { DecimalDigits<sub>[~Sep]</sub> , DecimalDigits<sub>[~Sep^]</sub> }

InvalidBracedQuantifierQuantifierPrefix のうち { で始まるものと同じルールです。

このルールは ExtendedPatternCharacter より手前に配置されています。Annex BのB.1.2内の文法では曖昧性の解消のために 手前のルールが適用できない場合だけ後続のルールが有効 という特別ルールが与えられているため、以下のような振舞いが規定されていることになります。

/{/ // リテラルな "{"
/{}/ // リテラルな "{}"
/{5}/ // エラー

またこれにより .{5} のような普通の正規表現も .{5} に分けて解釈する余地が発生しますが、これも Term 内のルールの出現順により曖昧性が解消されます。 .{5} までで Term :: ExtendedAtom Quantifier として解釈できるため、 . までを Term :: ExtendedAtom として展開することはできないという寸法です。

未知エスケープのフォールバック

IdentityEscape<sub>[UnicodeMode, N]</sub> ::
     [+UnicodeMode] SyntaxCharacter
     [+UnicodeMode] /
     [~UnicodeMode] SourceCharacterIdentityEscape<sub>[?N]</sub>

SourceCharacterIdentityEscape[N] ::
     [~N] SourceCharacter but not c
     [+N] SourceCharacter but not one of c or k

IdentityEscape は正規表現中で特別な意味をもつ文字から特別な意味を取り除いてリテラルに使うためのエスケープです。もともと規格本文で以下のような分岐が定義されています。

  • Unicodeモードでは 「SyntaxCharacter/」 つまり ^ $ \ . * + ? ( ) [ ] { } | / の15種類のエスケープのみが許可されている。
  • BMPモードでは UnicodeIDContinue 以外 (≒ 識別子に使えるもの以外) のエスケープが許可されている。
// 規格本文の規定
/\// // "/"
/\//u // "/"
/\&/ // "&"
/\&/u // error
/\♥/ // "♥"
/\♥/u // error
/\_/ // 規格本文の規定ではエラー
/\_/u // error
/\あ/ // 規格本文の規定ではエラー
/\あ/u // error

Annex BではBMPモードでのフォールバックがさらに緩和されます。

/\_/ // "_"
/\あ/ // "あ"

\8, \9, \k, \c については後述します。

キャプチャグループ番号のフォールバック

AtomEscape<sub>[UnicodeMode, N]</sub> ::
     [+UnicodeMode] DecimalEscape
     [~UnicodeMode] DecimalEscape but only if the CapturingGroupNumber of DecimalEscape is ≤ NcapturingParens

\1 などのキャプチャ参照をパースする条件を狭めています。一見するとこれは機能を減らしているように見えますが、もともと同じ条件がEarly Errorsでチェックされているので機能が減っているわけではありません。この変更を前提に以下のルールが追加されています。

CharacterEscape<sub>[UnicodeMode, N]</sub> ::
     [~UnicodeMode] LegacyOctalEscapeSequence

これは文字列リテラルで使える8進エスケープで、3桁以内かつ256未満という条件下で8進法の整数に最長マッチします。

/()\1/ // キャプチャ参照
/\1/ // "\x01"
/\1()/ // 無効なキャプチャ参照
/\17/ // "\x0F"
/\19/ // "\x01" + "9"
/\1111/ // "I1" (= "\1111")

なお DecimalEscape には元々 0 で始まるエスケープは含まれていません。

\8\9は8進エスケープにはならないので、前述のフォールバックが適用されて IdentityEscape として扱われます。

/\8/ // "8"
/\9/ // "9"

NcapturingParens は正規表現全体の性質なので、パースの途中に参照されるのは一見すると不思議です。しかし、実際にはこの形のエスケープは単独で再パースできる (隣接する構文と干渉しない) ため問題ないのだと思われます。

キャプチャグループ名のフォールバック

AtomEscape<sub>[UnicodeMode, N]</sub> ::
     [+N] k GroupName<sub>[?UnicodeMode]</sub>

SourceCharacterIdentityEscape[N] ::
     [~N] SourceCharacter but not c
     [+N] SourceCharacter but not one of c or k

この2箇所が [N] フラグの利用箇所です。 [N] がついているときは \k をキャプチャグループ名として解釈しますが、そうでないときは \k を認識しません (→ IdentityEscape にフォールバックし、 k と等価として扱われます。)

[N] フラグがどのように決定されるかは規格本文のParsePatternに定義されています。

  • Unicodeモードのときはキャプチャ名参照 \k は常に有効。
  • BMPモードのときは、
    • まずキャプチャ名参照が無効な状態でパースする。
    • その結果、名前つきキャプチャグループが存在していたら、キャプチャ名参照を有効にして再度パースする (以前の結果は破棄する)。

このリトライ挙動は、Annex Bがない状態では特別な意味を持ちませんが、Annex Bがある場合は \kIdentityEscape として解釈可能なため意味を持ちます。

/\k<foo>/ // "k<foo>"
/\k/ // "k"
/(?<foo>)\k<foo>/ // キャプチャ参照
/(?<foo>)\k/ // エラー

制御文字エスケープのフォールバック

ExtendedAtom<sub>[N]</sub> ::
     \ [lookahead = c]

SourceCharacterIdentityEscape[N] ::
     [~N] SourceCharacter but not c
     [+N] SourceCharacter but not one of c or k

JavaScriptの正規表現では \c に英字を続けると制御文字エスケープ (文字の下位5ビットを取り出した制御文字を返す) になります。たとえば /\cx//\cX/ はCtrl-X, つまりU+0018を表します。

Annex Bでは、 \c に英字が続かなかったときは特別に IdentityEscape ではなくリテラルなバックスラッシュにフォールバックすると規定されています。

/\c0/ // "\\c0"

また、同様のフォールバックが文字クラス内でも利用できるようになっています。

ClassAtomNoDash<sub>[UnicodeMode, N]</sub> ::
     \ [lookahead = c]

/[\c]/ // "\\" または "c"
/[\c%]/ // "\\", "c", または "%"

(フォールバック用のルールに [~UnicodeMode] がついていないのはミス?)

文字クラス内の制御文字エスケープの拡張

ClassEscape<sub>[UnicodeMode, N]</sub> ::
     [~UnicodeMode] c ClassControlLetter

ClassControlLetter ::
     DecimalDigit
     _

文字クラス内でも制御文字エスケープを使えますが、Annex Bでは文字クラス内の制御文字エスケープに限ってアンダースコア (\c_) と数字 (\c5) も許可しています。これらは文字クラス内ではフォールバックしません。

/\c_/ // "\\c_"
/[\c_]/ // "\x1F"
/\c5/ // "\\c5"
/[\c5]/ // "\x15"

無効な範囲指定のフォールバック

Additionally, the rules for the following productions are modified with the addition of the <ins>highlighted</ins> text:

NonemptyClassRanges :: ClassAtom - ClassAtom ClassRanges

  • It is a Syntax Error if IsCharacterClass of the first ClassAtom is true or IsCharacterClass of the second ClassAtom is true <ins>and this production has a <sub>[UnicodeMode]</sub> parameter</ins>.

パース後のチェックが緩和されています。このチェックは以下のような正規表現を禁止するものです。

// 文字クラスを含む範囲指定は無効
/[\s-\w]/u // error

規格本文ではこのような指定をBMPモードでも同様に禁止していますが、Annex BではBMPモードでこのような指定を可能にしています。このときの挙動はCompileToCharSetを置き換えることで指定されています。

The following two rules replace the corresponding rules of CompileToCharSet.

NonemptyClassRanges :: ClassAtom - ClassAtom ClassRanges

  1. Let A be CompileToCharSet of the first ClassAtom.
  2. Let B be CompileToCharSet of the second ClassAtom.
  3. Let C be CompileToCharSet of ClassRanges.
  4. Let D be ! CharacterRangeOrUnion(A, B).
  5. Return the union of D and C.

ポイントは CharacterRangeUnion が適用されているところです。これ自体がAnnex Bで追加される専用の操作で、以下のように定義されています。

  1. If Unicode is false, then
    • a. If A does not contain exactly one character or B does not contain exactly one character, then
      • i. Let C be the CharSet containing the single character - U+002D (HYPHEN-MINUS).
      • ii. Return the union of CharSets A, B and C.
  2. Return ! CharacterRange(A, B).

要するに、範囲指定が無効だった場合は - をリテラルとして再解釈しています。

/[\d-_]/ // 数字、ハイフン、またはアンダースコア

ただし、本規格の記述に従う場合、構文解析をやり直すわけではありません。以下のようなケースでは後続のハイフンが範囲指定に昇格することはありません。これは意図的というよりも、そこまで対応する必要はなかったということなのではないかと思います。

/[\d-a-z]/ // 数字、a, z, またはハイフン (bからyまでは含まれない)

__proto__, __defineGetter__, __defineSetter__, __lookupGetter__, __lookupSetter__

これらの規定はECMAScript最新版 (ES2022 Draft) ではAnnex Bではなく本文で規定されています。 __proto__ のプロパティ初期化子の特別ルールを除くと、これらは NORMATIVE OPTIONAL, LEGACY としてマークされています。

背景と歴史

これらの機能もNetscapeに由来しているようですが、完全に追うことはできませんでした。

一方MDNの情報が正確であれば、IEは11の時点で (おそらく標準に追従する形で) 実装しています。

__defineGetter__, __defineSetter__2000年のコミットで、 __lookupGetter__, __lookupSetter__2001年のコミットで、それぞれMozillaに実装されているのが確認できていますが、前者のコミットメッセージに含まれる "Added ECMA3 compliant getter/setter syntax." の意味するところは不明です。

なお、それぞれは以下の標準機能で代替できます。

  • __proto__: Object.getPrototypeOf, Object.setPrototypeOf
  • __defineGetter__, __defineSetter__: Object.defineProperty
  • __lookupGetter__, __lookupSetter__: Object.getOwnPropertyDescriptor

歴史:

  • 1995年~1998年: おそらくこのどこかのタイミングでNetscape Navigatorが隠しAPIとして __proto__, __parent__ を実装した。
    • その後 __parent__ は標準入りせず消滅した。
  • 1998年: __proto__, __parent__ がES2に入ることが決定されたが、その後再考されることに。
  • 2000年・2001年: Mozillaが __defineGetter__, __defineSetter__, __lookupGetter__, __lookupSetter__実装した
  • 2009年: ES5で Object.getPrototypeOf が標準化された。
  • 2011年~2012年: TC39の複数回のミーティングにわたって __proto__ のAnnex B入りが議論され、ES6 (現: ES2015) の3rd draft (2012年2月) で __proto__ が取り入れられた。
  • 2013年5月: __proto__ はaccessor propertyとして定義するのが望ましいとの結論になり、この月のdraft rev. 15 で __proto__ の仕様が書き直された。
    • __proto__ 初期化子に対する記述もこのタイミングで追加された。
    • 古い実装が謎のChapter Fとして残されていたが、これは2013年10月のrev. 10で削除された。
  • 2015年: ES2015で Object.setPrototypeOf が標準化された。また、 __proto__, __defineGetter__, __defineSetter__, __lookupGetter__, __lookupSetter__ がAnnex B入りを果たした。

__proto__ プロパティ

.__proto__ を使うとオブジェクトのプロトタイプを取得・設定することができます。 (紛らわしいですが、 .prototype はそのオブジェクト自体のプロトタイプではなく、そのコンストラクタやジェネレーターが返す値のプロトタイプに対応するので注意してください。)

規格の記述に特筆するべき点はないので詳しくは省略します。興味深い点としては、現在のJavaScriptでは __proto__ と同等の機能を自力で定義できる程度には強力だという点でしょうか。

// だいたいこんな感じ
Object.defineProperty(Object.prototype, "__proto__", {
  configurable: true,
  get() {
    return Object.getPrototypeOf(this);
  },
  set(proto) {
    if (typeof proto === "object") Object.setPrototypeOf(this, proto);
  },
});

もちろん、prototype pollutionの原因になるので、実際にこのようなプロパティを定義するべきではありません。

__proto__ プロパティ初期化子

オブジェクトリテラルはプロパティを [[DefineOwnProperty]] (Object.defineProperty 相当) で定義するのですが、例外として __proto__ という名前が使われているときは __proto__ を定義せずにすでに説明した __proto__ に代入したかのような振舞いをします。ちなみにこの挙動は元々はAnnex Bにありました (巨大HTML注意) が、現在はNORMATIVE OPTIONALともLEGACYとも書かれておらず、真正なECMAScriptの機能です。 (これ自体はprototype pollutionの危険性が低いからではないかと思います)

  1. If this PropertyDefinition is contained within a Script that is being evaluated for JSON.parse (see step 7 of JSON.parse), then
    • a. Let isProtoSetter be false.
  2. Else if propKey is the String value "__proto__" and if IsComputedPropertyKey of PropertyName is false, then
    • a. Let isProtoSetter be true.
  3. Else,
    • a. Let isProtoSetter be false.

オブジェクトリテラルの評価手順の一部です。まず、オブジェクトリテラルでも必ず __proto__ を特別扱いするわけではないので、特別扱いするべきかどうかを決定しています。条件は以下です。

  • そのプロパティはcomputed propertyではない。つまり、プロパティ名部分が [] で囲まれていない。
  • プロパティ名 (↑の条件があるので実際には静的に決定できる) が __proto__ である。
  • JSON.parse の一部ではない。 (※オブジェクトリテラルの評価手順を JSON.parse の動作を定義するためにも使っているため、この条件を入れて除外している)
({ __proto__: obj }) // 対象
({ "__proto__": obj }) // 対象
({ ["__proto__"]: obj }) // 対象外
JSON.parse('{"__proto__":{"foo":"bar"}}') // 対象外
  1. If isProtoSetter is true, then
    • a. If Type(propValue) is either Object or Null, then
      • i. Return ! object.[[SetPrototypeOf]](propValue).
    • b. Return NormalCompletion(empty).

__proto__ の特別扱いをすることに決めたら、 Object.prototype.__proto__ の代入の振舞いを模倣します。プロトタイプに設定できるのはオブジェクトまたはnullだけですが、 __proto__ にそれ以外の値を代入しようとした場合は単に何もせず無視されるため、その振舞いが再現されています。

({ __proto__: [1, 2, 3] }.length) // 3
({ __proto__: "123" }.length) // undefined (エラーにはならない)

最後に、重複プロパティ名に関する静的検査ルールが追加されています。

ObjectLiteral :
     { PropertyDefinitionList }
     { PropertyDefinitionList , }

It is a Syntax Error if _PropertyNameList of PropertyDefinitionList contains any duplicate entries for "__proto__" and at least two of those entries were obtained from productions of the form PropertyDefinition : PropertyName : AssignmentExpression . This rule is not applied if this ObjectLiteral is contained within a Script that is being parsed for JSON.parse (see step 4 of JSON.parse).

NOTE 2: The List returned by PropertyNameList does not include property names defined using a ComputedPropertyName.

__proto__ が2回出現するのを禁止しています。なお、 __proto__ に限らないオブジェクトリテラル内の重複プロパティ名はES5のstrict modeで一度禁止されましたが、ES2015で再び緩和されています。

({ __proto__: null, __proto__: null }) // error
({ a: 1, a: 2 }) // { a: 2 }

また、ここで __proto__ プロパティと判定される条件は先ほどの処理が発動する条件と対応するようになっています。

  • NOTE 2 に書かれているように、PropertyNameListにはComputedPropertyNameに由来する名前は含まれていない (ComputedPropertyNameは一般に静的に解析できないので当然といえば当然ですね) ので、 ["__proto__"]: ... が判定されないことと整合しています。
  • PropertyNameListに含まれる全てのプロパティ定義が対象ではなく、 PropertyDefinition : PropertyName : AssignmentExpression の形のプロパティ定義に限定しています。たとえば、メソッド定義の形で __proto__ が定義されても判定に含まれないことが考慮されています。
({ __proto__: () => {} }.bind) // Function.prototype.bind
({ __proto__() {} }.bind) // undefined

__defineGetter__, __defineSetter__, __lookupGetter__, __lookupSetter__

これも Object.prototype.__proto__ と同じく、特筆すべき点は特にありません。再現実装を置いておきます (もちろん、実際に使うのは止めておきましょう。)

// JavaScriptコードを使った再現実装
Object.prototype.__defineGetter__ = function(prop, get) {
  Object.defineProperty(this, prop, {
    enumerable: true,
    configurable: true,
    get,
  });
};

Object.prototype.__defineSetter__ = function(prop, set) {
  Object.defineProperty(this, prop, {
    enumerable: true,
    configurable: true,
    set,
  });
};

Object.prototype.__lookupGetter__ = function(prop) {
  // Run ToObject only once (use Object.assign to reproduce TypeError)
  let obj = this != null ? Object(this) : Object.assign(this, {});
  // Run ToPropertyKey only once
  prop = ((prop) => {
    const obj = { [prop]: null };
    return Object.getOwnPropertyNames(obj)[0] ?? Object.getOwnPropertySymbols(obj)[0];
  })(prop);
  while (obj) {
    const desc = Object.getOwnPropertyDescriptor(obj, prop);
    if (desc) return desc.get;
    else obj = Object.getPrototypeOf(obj);
  }
  return undefined;
};

Object.prototype.__lookupSetter__ = function(prop) {
  // Run ToObject only once (use Object.assign to reproduce TypeError)
  let obj = this != null ? Object(this) : Object.assign(this, {});
  // Run ToPropertyKey only once
  prop = ((prop) => {
    const obj = { [prop]: null };
    return Object.getOwnPropertyNames(obj)[0] ?? Object.getOwnPropertySymbols(obj)[0];
  })(prop);
  while (obj) {
    const desc = Object.getOwnPropertyDescriptor(obj, prop);
    if (desc) return desc.set;
    else obj = Object.getPrototypeOf(obj);
  }
  return undefined;
};

レガシーライブラリ関数

escape/unescape

文字列をそのままバイト列とみなしてURLエスケープをする関数です。UTF-8ではないので注意が必要です (文字列が U+0100 以降のときは "%u..." というエスケープになります)。

escape("%&") // => "%25%26"
unescape("%25%26") // => "%&"

UTF-8に対応したエスケープ関数 encodeURI(Component), decodeURI(Component) がECMAScriptの本文に定義されており、こちらの利用が推奨されています。これを逆手に取って、パーセントエンコーディングを経由してUnicode文字列とUTF-8の相互変換を実装する悪いテクニックもありますが、使わないほうがよいでしょう (WHATWG EncodingのAPIがオススメです)

  • 1995年: Netscape Navigator 2.0に escape/unescape が実装された。
  • 1997年: ES1の本文に登場。
  • 1999年: 複数回のミーティングにわたって encodeURI/decodeURI が議論されている。 (escape/unescape に関する直接的な議論は議事録には残されていないが、8月のFinal Draftの変更点には記載がある。)
  • 1999年: ES3で encodeURI/decodeURI が定義された。あわせて、新設されたAnnex Bに escape/unescape が移動された。

substr

substringと似ていますが、第二引数に部分文字列の長さを指定します。

MDNに古いIE向けのpolyfillと称した再現実装があります。

  • 1997年: Netscape Navigator 4.0 (JavaScript 1.2) で登場したと考えられる。 (JavaScript 1.0, 1.1のリファレンスには存在しない / とほほのJavaScript入門の昔のバージョンにもe4/N4との記載がある)
  • 1997年: 上記の「とほほのJavaScript入門」の記述が正しければ、同年にリリースされたInternet Explorer 4.0でも利用可能だったと考えられる。 (ただし上記のMDNにあるように、IEのsubstrの挙動は少し異なるらしい)
  • 1999年: ES3でAnnex Bの一部として記載された。
    • Annex Bに入れる判断がなされた理由は不明。

StackOverflowに関連する質問がありましたが、TC39がsubstrをAnnex Bに入れる (本文には取り込まない) 判断をした理由を説明している回答は2022年1月時点で存在しません。

HTML生成関数

ご覧の通りです。

"Table of contents".anchor("toc")
// => '<a name="toc">Table of contents</a>'
"Hello!".big()
// => '<big>Hello!</big>'
"Hide and seek".blink()
// => '<blink>Hide and seek</blink>'
"Important!".bold()
// => '<b>Important!</b>'
"int main()".fixed()
// => '<tt>int main()</tt>'
"Warning!!".fontcolor("red")
// => '<font color="red">Warning!!</font>'
"Hello again!".fontsize(7)
// => '<font size="7">Hello again!</font>'
"i.e.".italics()
// => '<i>i.e.</i>'
"Click here!".link("https://example.com/")
// => '<a href="https://example.com">Click here!</a>'
"secret page".small()
// => '<small>secret page</small>'
"my bad!".strike()
// => '<strike>my bad!</strike>'
"2".sup()
// => '<sup>2</sup>'
"i".sub()
// => '<sub>i</sub>'

これはJavaScriptが最初に実装されたNetscape Navigator 2.0のハンドブックにも登場します。 document.writeで使うことが想定されていたようです。 (DHTMLやDOMが登場するのはもう少し後です)

var myString="Table of Contents"

msgWindow=window.open("","displayWindow")
msgWindow.document.writeln(myString.anchor("contents_anchor"))
msgWindow.document.close()

出力されるタグの古さも見所です。CSS1はこの時点ではまだリリースされておらず、スタイル指定には専用のタグを使うのが主流だったようです。blinkタグという当時存在した奇抜なタグまでサポートされているのは見所です。[3]

trimLeft/trimRight

trimLeft/trimRightは文字列の先頭または末尾の連続した空白文字を取り除くメソッドですが、標準化の際にtrimStart/trimEndで置き換えられました。文字は必ず左から右に進むわけではないことを念頭に置くと、実際の振舞いをより正しく表現しているのは "start"/"end" だからです。

getYear/setYear/toGMTString

JavaScriptのDateのインターフェースはjava.util.Dateの影響を受けていると思われます。これらのメソッドもその一部です。Javaでは java.util.Date の西暦に依存した処理全体が非推奨になっていますが、JavaScriptでは一部だけがレガシーAPI扱いになっています。

まず、getYear/setYearは当時、西暦年を2桁で返す関数として実装されていました。これは一種の2000年問題を引き起こします。

  • 1900年からの年数として連続的に実装すると、102年などのあまり自然ではない整数になってしまう。
  • 2000年以降は西暦年をそのまま返すことにすると、非連続的な結果になってしまう。

事実、古いIEでは他と異なる実装 (後者の仕様) になっていたようです。Annex Bでは前者の仕様で定義されています。

toGMTStringtoUTCString の古い別名です。 (toUTCStringjava.util.Date にはありません) GMTは現在、UTCまたはUT1から±0時間を指すタイムゾーンを表す言葉であり、時間の基準点としてはUTCと呼んだほうが自然だからです。

  • 1995年: Netscape Navigator 2.0 (JavaScript 1.0) でgetYear, setYear, toGMTString が登場。
    • これに先行してJavaの java.util.Date に同機能があったと考えられるが、時系列的にはまだJDKの安定版である1.0.2はリリースされておらず、Alpha/Betaのドキュメントは見つけられなかったので詳細は不明。少なくとも安定版の1.0.2時点 (1996年) でこれらのメソッドが存在していたことが確認できる。
  • 1997年3月: 14日のTC39のミーティング (ECMA/TC39/97/24) で getFullYear / setFullYear を含む Date の諸問題が議論された。また、18日のミーティング (ECMA/TC39/97/26) でGMTをUTCと呼び替える提案がなされた。
  • 1997年: JDK 1.1がリリース。Dateのオリジナルになったと考えられる java.util.Date は大部分が非推奨化された。
  • 1997年: ES1で注釈つきで規格に含まれた。
    • より正確には、 getYear/setYear の説明はES1のドラフトバージョンのひとつであるECMAScript 0.16 (1997年4月) から存在し、その時点で互換性のために存在している旨の注釈がついている。また、 toGMTString はECMAScript 0.18 (1997年5月) から存在し、やはり注釈がはじめからついている。
  • 1998年: Netscape Navigator 4.0 (JavaScript 1.3) はECMAScript互換が表明されているため、このバージョンで getFullYear/setFullYear/toUTCString が実装されたと考えられる。 JavaScript 1.3のリファレンスでは説明に一貫性がないものの getFullYear/setFullYear の用例が含まれている。
    • JavaScript 1.2のリファレンスにはこれらのメソッドは含まれていない。
  • 1999年: ES3でAnnex Bの誕生にともない、これらのメソッドもAnnex Bに移動された。

RegExp.prototype.compile

通常、JavaScriptの正規表現オブジェクトはイミュータブルで、必要な状態管理といえばせいぜいsticky regexpのためのlastIndexプロパティくらいのものです。しかし、 RegExp.prototype.compile を使うと正規表現のパターンそのものを置き換えてしまうという大変なことができてしまいます。

cosnt re = /foo/;
re.compile("bar");
re.test("bar") // => true

RegExpの意味論を複雑にするだけでメリットがほぼないため、Annex Bに入る以外の道はなかったでしょう。

  • 1997年: とほほのJavaScriptリファレンスの記述が正確であれば、Internet Explorer 4.0で compile が実装されたと考えられる。
  • 1997年: とほほのJavaScriptリファレンスの記述が正確であれば、Netscape Navigator 4.xのどこかで compile が実装されたと考えられる。下記のTC39議事録におけるAllen Wirfs-Brock氏の発言が正しければJScriptの実装が先行していることになるので、4.0よりも後のどこかのバージョンかもしれない。
  • 2012年: WHATWG JavaScriptが作られた。ここに RegExp.prototype.compile の記述が含まれる。
  • 2013年3月: ES6 (現: ES2015) の draft rev. 14 で RegExp.prototype の説明が変更され、 source を含むいくつかのプロパティが RegExp インスタンスのデータプロパティから RegExp.prototype のアクセサプロパティに変更された。
    • compile によってパターンの中身が変わりうるという前提のもとでは、readonlyのデータプロパティとして実装するのは適切ではないという判断からだと思われる。
  • 2013年3月: TC39のミーティングで上記の変更が取り上げられた。
  • 2013年5月: ES6 (現: ES2015) の draft rev. 15 で RegExp.prototype.compile がAnnex Bに追加された。
  • 2015年: 上記変更がES2015としてリリースされた。

関数宣言の非標準的な使い方

背景と歴史

JavaScriptのfunction宣言は初期化処理自体が巻き上げられる (ブロック内で先行して実行される) という非常に特異な性質があります。

// 後で定義されているgreetをここで呼ぶことができる
greet();

function greet() { console.log("Hello!"); }

こうした理由からか、function宣言は規格上は他の「文 (statement)」とは異なり、書ける場所に強い制限がありました。一方で各ブラウザは規定外の位置でのfunction宣言を独自に実装しており、それぞれの挙動は少しずつ異なっていました。

ECMAScript 2015ではこれらの既存の実装とも少しずつ異なる形でfunction宣言の制限が緩和されましたが、互換性のために既存の実装の挙動がAnnex Bとして残されることになりました。

// ラベルつき関数宣言
foo: function f() {}

// ブロックレベル関数宣言の代替意味論
if (condition) {
  function g() { /* implementation1 */ }
} else {
  function g() { /* implementation2 */ }
}
g();

// if文直下の関数宣言
if (condition) function h() { /* implementation1 */ }
else function h() { /* implementation2 */ }
  • 1995年: Netscape Navigator 2.0 (JavaScript 1.0) ではfunction 宣言はスクリプトの最上位にのみ置くことができた。巻き上げがあったかどうかは不明。
  • 1997年: ES1。
    • function 宣言はスクリプトの最上位にのみ置くことができた。
    • function 宣言の巻き上げ挙動が規定されていた。
    • function 式はなかったが、 new Function は可能だった。
  • 1999年: ES3。
    • function 宣言をスクリプトの最上位だけでなく、関数本体の最上位にも置くことができるようになった。
    • function 式も追加された。
  • 2009年: ES5。
    • §12で function 宣言を規定以外の位置で使ったときの互換性問題についての注意が追加された。実際の規定には大きな変更はない。
  • 2013年3月: ブロックレベル関数宣言の互換性に関する議論
  • 2015年: ES2015。
    • function 宣言のブロック内での意味論が定義された。
    • スクリプト・モジュールトップレベル、関数トップレベル、ブロック以外での関数宣言の禁止規定が追加された。
      • 特に、strict modeではAnnex B込みでも禁止されるが、これはES5ではなくES2015での新規規定。
    • 既存の実装との互換性のための挙動がAnnex Bに定義された。

ラベルつき関数宣言

まず、ラベル付き文は以下のように定義されています。

LabelledStatement<sub>[Yield, Await, Return]</sub> :
     LabelIdentifier<sub>[?Yield, ?Await]</sub> : LabelledItem<sub>[?Yield, ?Await, ?Return]</sub>

LabelledItem<sub>[Yield, Await, Return]</sub> :
     Statement<sub>[?Yield, ?Await, ?Return]</sub>
     FunctionDeclaration<sub>[?Yield, ?Await, ~Default]</sub>

Statement には function, function*, async function, async function* が含まれていない点が注目に値します。つまり規格上、パーサーは以下のように定義されています。

// OK
foo: var x;
// パースは通る (構文解析後にエラーとして扱う)
foo: function f() {}
// パースは通るかもしれないし、通らないかもしれない。
// (明示的に規定はないが、通常は構文解析中または構文解析後にエラーになる)
foo: function* g() {}
foo: async function af() {}
foo: async function* ag() {}

function*, async function, async function* に関して規定がないのは、単にわざわざ明示的に禁止しなくても大丈夫だという判断でしょう。

そして、この foo: function f() {} は以下のルールにより常にエラーにすることになっています。 (つまり、ES2015以降の処理系はAnnex Bを採用しない限りこれを禁止する必要があり、勝手に拡張してはいけないことになります。)

LabelledItem : FunctionDeclaration

It is a Syntax Error if any source text is matched by this production.

そして、この本文規定をAnnex Bで緩和しています。

LabelledItem : FunctionDeclaration

It is a Syntax Error if any source text <ins>that is strict mode code</ins> is matched by this production.

Annex Bを実装した場合でも、strict modeではラベルつき関数宣言を禁止する必要があります。

ブロックレベル関数宣言の代替意味論

対応しようとしているユースケースの説明

Annex Bの当該部分の冒頭で歴史的経緯とユースケースの説明が書かれています。サポートする必要があるのは、既存実装間で挙動が共通していて用例のあるユースケースだとして、そのようなユースケースを3種類に分類して説明しています。

  1. A function is declared and only referenced within a single block.
  2. A function is declared and possibly used within a single Block but also referenced by an inner function definition that is not contained within that same Block.
  3. A function is declared and possibly used within a single block but also referenced within subsequent blocks.

第一の例はこのような例です。これはECMAScript 2015の本文規定でも動作します。

if (true) {
  // 宣言した関数は同じブロック内でのみ利用されている。
  f();
  function f() {
    console.log("Hello!");
  }
}

第二の例はこのような例です。Annex Bでの対応が必要です。

// ブロック内で宣言された関数を、ブロックの外の関数宣言から参照している。
// (本文規定では f はブロックスコープに閉じるため参照できない)
function h() {
  f();
}

if (true) {
  function f() {
    console.log("Hello!");
  }
} else {
  function f() {}
}

// 実際に呼ばれるのは function f が評価されるよりも後。
h();

第三の例はこのような例です。Annex Bでの対応が必要です。

if (true) {
  function f() {
    console.log("Hello!");
  }
} else {
  function f() {}
}

// ブロック内で宣言された関数を、ブロックの外 (宣言が評価されるより後) から参照している。
// (本文規定では f はブロックスコープに閉じるため参照できない)
f();

ここで起こっている齟齬をまとめると以下のようになります。

  • ES5.1までのfunction宣言は、「関数本体直下の文が最終的には全て実行されることが意図されていること」を利用して、束縛生成と初期化処理を関数本体の先頭まで巻き上げるものだった。
  • ES2015ではこの意図を自然に延長し、ブロック内のfunction宣言をブロックスコープ (let/constと同様) としてブロックスコープの先頭まで巻き上げるように規定された。
  • 一方、既存実装ではブロック内のfunction宣言を関数本体直下のfunction宣言とは区別して、var文のように扱うものがあった。この場合束縛は関数スコープとして巻き上げられるが、初期化処理は巻き上げられない。

Annex Bの規定では、「ブロック内で初期化ごと巻き上げる」挙動と「関数スコープとして束縛だけ巻き上げる」挙動の合併を取るような形で規定されます。

本文規定の確認 (1) function宣言の先行評価

まず、function宣言の意味論が本文でどう規定されているかを確認しておきます。ブロックの評価ルールが以下のように規定されています。

Block : { StatementList }

  1. Let oldEnv be the running execution context's LexicalEnvironment.
  2. Let blockEnv be NewDeclarativeEnvironment(oldEnv).
  3. Perform BlockDeclarationInstantiation(StatementList, blockEnv).
  4. Set the running execution context's LexicalEnvironment to blockEnv.
  5. Let blockValue be the result of evaluating StatementList.
  6. Set the running execution context's LexicalEnvironment to oldEnv.
  7. Return blockValue.

ステップ5がメインの評価手順ですが、その前にBlockDeclarationInstantiationを実行しています。この中で以下の作業が行われます。

  • var, let, const, class の束縛を作成する。
  • function, function*, async function, async function* の束縛を作成し、初期化する。

また、ブロック以外についても以下のように同様の規定があります。

  • switch文の { ... }CaseBlock という特殊なブロックですが、BlockDeclarationInstatiationが使われる点は同じです。
    • したがって、関数宣言はswitch内のどの分岐で書かれていても、switch内の全ての箇所から呼び出すことができます。
  • 関数本体の評価に使われるFunctionDeclarationInstantiationでも(他の手順が関与するので複雑ですが)同様に規定されています。
  • スクリプトの評価に使われるScriptEvaluationでも関数がglobalThisに束縛されることを除き、同様に規定されています。
  • モジュールでは相互参照などを適切に解決するために、モジュール読み込み処理全体がリンクステップと評価ステップの2段階に分かれています。リンクステップ中に実行されるInitializeEnvironmentに、ブロックの先行評価と同様の手順が含まれています。

そして、ブロックのメインの評価中に function, function*, async function, async function* に遭遇したときの挙動は以下のように定義されています

FunctionDeclaration : function BindingIdentifier ( FormalParameters ) { FunctionBody }

  1. Return NormalCompletion(empty).

つまり、メインの評価手順中は何もしません (ブロックの先行評価中に必要な処理は全て行われているので、何もする必要はない)。

function*, async function, async function* についても同様に規定されています (これらはAnnex Bで拡張する必要がないため別の場所でまとめて規定されている)

本文規定の確認 (2) 重複する宣言

var文による宣言は重複が許されているという特徴があります。これはvarによる束縛が関数スコープで巻き上げられる挙動と整合性を取るために必要なルールです。

function f() {
  // このiはfの直下で var i; をしたのと同等とみなされる。
  for (var i = 0; i < 10; i++) { /* ... */ }
  // このiもfの直下で var i; をしたのと同等とみなされる。
  // そのため、fでは2回 var i; を行ったことになるが、このような重複は許されている。
  for (var i = 0; i < 5; i++) { /* ... */ }
}

一方、 let/const はそうではありません。

function f() {
  let x; let x; // error
}

このことはBlockのEarly Error規定で以下のように定義されています。

Block : { StatementList }

  • It is a Syntax Error if the LexicallyDeclaredNames of StatementList contains any duplicate entries.
  • It is a Syntax Error if any element of the LexicallyDeclaredNames of StatementList also occurs in the VarDeclaredNames of StatementList.

ほぼ同様の規定がswitchのCaseBlock関数本体スクリプトモジュールにも存在します。いずれも以下のようなルールになっています。

  • let (と、その仲間) は、同じ場所 (ブロック等) で重複して出現したらエラー。
  • let (と、その仲間) は、同じ場所 (ブロック等) で var (と、その仲間) と重複してもエラー。
    • var は、それより内側のブロックで宣言されたものも含む。
  • var (と、その仲間) 同士は重複してもよい。
    • var は、それより内側のブロックで宣言されたものも含む。

では関数宣言はどう扱われているかというと、ブロック直下のものとそうでないもので異なります。 (正確にはモジュール直下もブロック直下と同様に扱われます)

スクリプト直下や関数本文直下のfunction宣言は、初期化が巻き上げられる点以外はvar文とよく似た扱いを受けます。function宣言同士やfunction宣言とvar文の間での重複が許され、最後に代入されたものが有効になります (VarDeclaredNames に含まれる) 。

function f() {
  // 2つ目のfooが有効
  function foo() {}
  function foo() {}
}
function f() {
  function foo() {}
  // var文以降は代入された新しいfunctionが有効になる
  var foo = function() {};
}

一方ブロック直下のfunction宣言は、ECMAScriptの本文規定ではlet/constと同様になっています。 (LexicallyDeclaredNames)

if (true) {
  function foo() {}
  function foo() {} // error
}

この区別を与えているのがLexicallyDeclaredNamesの以下の規定:

FunctionStatementList : StatementList

  1. Return TopLevelLexicallyDeclaredNames of StatementList.

ClassStaticBlockStatementList : StatementList

  1. Return the TopLevelLexicallyDeclaredNames of StatementList.

ScriptBody : StatementList

  1. Return TopLevelLexicallyDeclaredNames of StatementList.

と、VarDeclaredNamesの以下の規定です。

FunctionStatementList : StatementList

  1. Return TopLevelVarDeclaredNames of StatementList.

ClassStaticBlockStatementList : StatementList

  1. Return the TopLevelVarDeclaredNames of StatementList.

ScriptBody : StatementList

  1. Return TopLevelVarDeclaredNames of StatementList.

これにより実際の定義をTopLevelLexicallyDeclaredNamesTopLevelVarDeclaredNamesという別の定義にリダイレクトしています。

仮想的なvar束縛の作成

まず、ブロックレベル関数宣言の値をブロックの外から参照できるようにするために、仮想的なvar宣言があることにします。そのためにFunctionDeclarationInstantiation, GlobalDeclarationInstantiation, EvalDeclarationInstantiationのそれぞれ本文中で空けておいた空欄にパッチを当てています。 (strict modeではないときのdirect evalは呼び出し元のスコープに干渉できるため、同様の対応が必要になります)

  1. If strict is false, then
    • a. For each FunctionDeclaration f that is directly contained in the StatementList of a Block, CaseClause, or DefaultClause, do
      • i. Let F be StringValue of the BindingIdentifier of f.
      • ii. If replacing the FunctionDeclaration f with a VariableStatement that has F as a BindingIdentifier would not produce any Early Errors for func and F is not an element of parameterNames, then

まず、strict modeでは互換機能を有効化しないように条件を記載しています。次の行では関数スコープ内のブロックを再帰的に探索し、関数宣言を発見しています。このような書き方では正確性が保証されないので本来であればStatic Semanticsを別で定めるのが望ましいですが、Annex Bの一部分で意図もほぼわかっている部分なのでそこまで頑張らなくてもいいという判断かもしれません。

続く条件で、この規定のせいでエラーになるくらいなら規格本文の挙動を優先するということを表明しています。たとえば以下の例を考えます。

let f;
if (true) {
  function f() {}
  f();
}

本文規定では f はブロックスコープで let と同等になるため上の let f をシャドウするだけで衝突はしません。しかし、 function fvar f とみなしてしまうと let/var 間の衝突が発生してしまいます。 1.a.ii 行の規定でこれを回避しています。

重複宣言ルールの緩和

BlockのEarly ErrorルールCaseBlockのEarly Errorルールを緩和しています。

The rules for the following production in 14.2.1 are modified with the addition of the highlighted text:

Block : { StatementList }

  • It is a Syntax Error if the LexicallyDeclaredNames of StatementList contains any duplicate entries<ins>, unless the source text matched by this production is not strict mode code and the duplicate entries are only bound by FunctionDeclarations</ins>.
  • It is a Syntax Error if any element of the LexicallyDeclaredNames of StatementList also occurs in the VarDeclaredNames of StatementList.

ブロックレベル関数宣言をvarとみなす既存実装では同じ名前で2回宣言を繰り返してもよいはずなので、その挙動を再現するために例外規定が足されていると考えられます。

if (true) {
  // Annex Bを採用した場合はOKになる
  function foo() {}
  function foo() {}
} else {
  // これはAnnex Bを適用していても禁止
  function foo() {}
  let foo;
}

既存束縛の再利用

本文規定ではブロックの先行評価中に束縛を必ず作成しています。このままではブロックの外から観測できないので、既存束縛を再利用するようにBlockDeclarationInstantiationを変更しています。

また、既存束縛は初期化済みかもしれないので、二重初期化エラーを避けるために分岐を追加しています。

if文直下の関数宣言

if, for, while の中身にはブロックだけでなく、文を直接置くことができます。

if (true) console.log("foo"); // {} がない例

しかし、関数宣言は文ではないためこの位置には書けません。 (varは歴史的経緯からこの位置に書けますが、あとから追加された let/const/class などは書けません)

Annex Bの§B.3.3ではif文直下に関数宣言を置けるようにしています。おそらく条件に応じて別の関数を定義するというユースケースが実際にあったためでしょう。

if (cond)
  function f() { /* ... */ }
else
  function f() { /* ... */ }

この規定は構文を拡張するだけのもので、意味論的には単に {} があるときと同様です。

if (cond) {
  function f() { /* ... */ }
} else {
  function f() { /* ... */ }
}

そして、これは先ほどの規定により以下とおおよそ等価になります。

if (cond) {
  var f = function() { /* ... */ };
} else {
  var f = function() { /* ... */ };
}

catch変数の重複判定の緩和

背景と歴史

let/const はブロックスコープの束縛を導入します。

if (true) {
  let x = 42;
} else {
  let x = 42;
}
console.log(x); // 上記のxを参照するわけではない。

しかし、ブロックスコープ自体は let/const よりもずっと昔から存在していました。それは with 文と try-catchcatch 節です。ただし、 with 文のスコープで束縛が新規に作られることはありません。

let/const 導入時に、 catch によって生成される束縛も let/const と一貫したルールで検査されるようにしました。そのようなルールのひとつが以下のルールです。

// letとvarの間の衝突となるため、エラー。
let x;
if (true) var x;

catchの束縛には元々このルールはなかったため、これは非互換な変更です。

// catchとvarの間の衝突となるため、ECMAScript 2015以降でAnnex Bを実装していない場合はエラー。
try {} catch (x) { var x; }

このようなコードの互換性を保つために、Annex Bでルールが緩和されています。

  • 1997年: ES1。 with 文の意味論を定義するためにブロックスコープ (scope chain) が定義されていた。
  • 1999年: ES3。 try-catch が定義された。これによりブロックスコープ (scope chain) を作る構文が withcatch の2つに増えた。
    • その後、ES5では関数スコープ・ブロックスコープがVariableEnvironment/LexicalEnvironmentとして整理された。
  • 2011年9月: ES6 (現: ES2015) のsecond draftで let/const が登場した。これにあわせて、 catch 節の束縛にも let/const に準ずるルールが導入された。
    • なお、 let/const の提案自体はさらに昔に遡る。 (筆者は2008年頃の議論までは遡ることができたがどこまで遡れるかまではわからなかった。)
  • 2013年5月: ES6 (現: ES2015) のdraft rev. 15で catch 節部分にAllen Wirfs-Brock氏の指摘が入っていることが確認できる。
  • 2014年7月: ES6 (現: ES2015) のdraft rev. 26で上記の指摘が対応され、Annex Bの記述が足された。
  • 2015年: ES2015で上記変更がリリースされた。

変更点

元のAnnex Bでは差分形式で表示されていませんが、簡単な差分なので以下で <ins></ins> を用いて表示します。

Catch : catch ( CatchParameter ) Block

  • It is a Syntax Error if BoundNames of CatchParameter contains any duplicate elements.
  • It is a Syntax Error if any element of the BoundNames of CatchParameter also occurs in the LexicallyDeclaredNames of Block.
  • It is a Syntax Error if any element of the BoundNames of CatchParameter also occurs in the VarDeclaredNames of Block<ins> unless CatchParameter is CatchParameter : BindingIdentifier </ins>.

CatchParameterBindingIdentifier のほかに BindingPattern も取ることができます。 BindingPattern はES2015の機能なので互換性のための対応は必要ないという判断でしょう。また、 let/const 宣言等で導入された名前との衝突も禁止していますが、こちらも let/const 宣言自体がES2015の機能なので互換性のための対応は必要ないという判断でしょう。

// Annex B で許可される
try {} catch (x) { var x; }
// 禁止
try {} catch (x) { let x; }
// 禁止
try {} catch ([x]) { var x; }

Annex Bの当該セクションにはdirect evalに関しての対応も含まれていますが省略します。

for-inの初期化子

背景と歴史

for-inの左辺には左辺式または変数宣言を置くことができました。

// 左辺式
for (x in obj) {}
// 変数宣言
for (var x in obj) {}

この変数宣言は通常のvar式と同様に初期化子を書くことができました。

// ES5.1までvalid
for (var x = null in obj) {}

var は関数スコープのためfor終了後も永続します。そのため初期化子は全く意味がないわけではないですが、基本的にはこのようなパターンはあまり有用ではないと考えられます。このような背景からES2015で for-of の文法の追加にあわせてこの構文は削除されましたが、実際には無視できない数のページが壊れることが判明したためあとからAnnex Bで復活しています。non-strict modeでのみ有効です。

  • 1997年: ES1。少なくともこの時点でfor-inの初期化子が構文に含まれていた。
  • 2012年2月: ES6 (現: ES2015) の3rd draftで for-of が導入され、 for の構文が整理された。この際に for-in の初期化子が消滅。
  • 2013年7月: for-in の初期化子が消滅したことが指摘される
  • 2015年: ES2015がリリースされ、上記の for-in の初期化子の削除が一旦確定した。
  • 2017年: for-in の初期化子削除により無視できない非互換性が発生することがブラウザベンダから報告されていることを受け、ES2017でAnnex Bに for-in の初期化子のための修正が追加された

変更点

本文には以下の構文があります。

ForInOfStatement<sub>[Yield, Await, Return]</sub> :
     for ( var ForBinding<sub>[?Yield, ?Await]</sub> in Expression<sub>[+In, ?Yield, ?Await]</sub> ) Statement<sub>[?Yield, ?Await, ?Return]</sub>

ForBinding<sub>[Yield, Await]</sub> :
     BindingIdentifier<sub>[?Yield, ?Await]</sub>
     BindingPattern<sub>[?Yield, ?Await]</sub>

これに並べる形で以下の構文が足されます。

ForInOfStatement<sub>[Yield, Await, Return]</sub> :
     for ( var BindingIdentifier<sub>[?Yield, ?Await]</sub> Initializer[~In, ?Yield, ?Await]</sub> in Expression<sub>[+In, ?Yield, ?Await]</sub> ) Statement<sub>[?Yield, ?Await, ?Return]</sub>

  • 互換性のための機能であり、比較的新しい BindingPattern に対応する必要はないためか BindingIdentifier のみが定義されています。
  • 曖昧性解消のために初期化子には [~In] が指定されています。

以降は、足した構文に対する意味論を網羅的に定義しているだけなので省略します。

document.all

背景と歴史

JavaScriptは当初、 document.write による静的HTMLの生成や一部の要素 (フォームや画像) の操作のみが可能だったようです。その後あらゆる要素の操作ができるDynamic HTML / DOMなどの技術が登場します。

現在我々が使っているDOMの前身にあたるDOM Level 1が策定されたのは1998年ですが、これに先行してWebブラウザがDynamic HTML機能を実装していました。それらの実装はレトロニムでDOM Level 0と通称されることもあるようです。

document.allもそうしたDOM Level 0の一つで、Internet Explorerが実装していました。これは以下のようにドキュメント内の全ての要素を取得したり、特定のid/nameを持つ要素を取得したりできるものです。

document.all[10] // 全ての要素のうち10番目
document.all(10) // 全ての要素のうち10番目
document.all("email") // "#email, [name='email']" の最初の要素
document.all("email", 10) // "#email, [name='email']" の10番目の要素

実際にはこのAPIはDOM Level 1には入らず、 document.getElementById など同様の機能をもつ別のメソッド群が標準化されました。

これだけなら話は簡単なのですが、この document.all機能検出の対象としてものすごく頻繁に用いられていたようです。たとえば2007年のこの記事でも、 document.all に基づいた機能検出が紹介されています。

そいった積み重ねから、2004年の時点でIE以外のブラウザが document.all を普通に実装するのは互換性の観点から難しい状態だったのだと推測されます。 (その詳しい理由はわかりません。たとえば、 document.all の存在をもとに他のIE独自の機能の存在も仮定してしまうコードがあったのかもしれません。) 結果としてMozillaは2004年にdocument.all を特殊な振舞いをするオブジェクトとして実装しました。このときは、 if&& など論理値を要求するいくつかのVM命令の前で document.allundefined として解決するような仕組みとして実装されたようです。

結局後続の実装も同様に実装するようになり、やがてHTMLで標準化されました。しかし、この document.all の振舞いはどうやってもECMAScriptの本文規格と矛盾してしまうため、矛盾を解消して振舞いを明確にするためにECMAScript側にサポートが取り込まれたという経緯のようです。

変更点

ECMAScriptではオブジェクトが仕様定義上必要な状態を保持するためのデータ領域として内部スロット (internal slot) というものを定義しています。内部スロットは、JavaScriptコードから直接アクセスできないことを除くと、プロパティとよく似ています。内部スロット (および内部メソッド) は [[Prototype]] のように二重角括弧で表します。

§B.3.6では document.all を識別するために [[IsHTMLDDA]] という内部スロットをホスト規格が定義することを許可しています。その下のNOTEで、これが document.all のための特別な内部スロットであり他の目的で使ってはいけないことを念押ししています。 (名前の DDA も Document Dot All の意味です。)

内部スロットは値を持つこともできますが、 [[IsHTMLDDA]] は存在するかしないかだけが重要です。 [[IsHTMLDDA]] が存在するとき、そのオブジェクトは一部の操作に対して undefined のように振る舞うと説明されています。

具体的には以下の3つの操作が修正されます。1つ目は ToBoolean です。本文規格ではオブジェクトは常に true として扱われることになっていますが、Annex Bでは document.all だけ例外的に false として扱われることになります。

!document.all // => true
document.all || "foo" // => "foo"
document.all && "foo" // => document.all
document.all ? "foo" : "bar" // => "bar"
Boolean(document.all) // => false

if (document.all) console.log("foo");
else console.log("bar");
// => bar

2つ目は IsLooselyEqual です。これは ==!= の挙動を規定するものです。元々の IsLooselyEqualnullundefined は互いに等しく、他の値とは等しくありません。ここに document.all も仲間入りします。

document.all == document.all // => true (本文規定と同じ)
document.all == null // => true
document.all == undefined // => true
// 左右逆にしても同様

3つ目は typeof 演算子 です。本文規定ではオブジェクトに対する typeof"object""function" のいずれかを返しますが、 document.all に対しては特別に "undefined" を返します。

typeof document.all // => "undefined"

それ以外の操作はECMAScript側では変更されません。たとえば [[Get]] は維持されるため、プロパティアクセスは可能です。

document.all.length // => 134 (文書によって異なる)

HTML側ではさらなるカスタマイズとして [[Call]] を上書きしているため、メソッドとして呼ぶことも可能です (Functionのインスタンスではないにもかかわらず)。

document.all(0) // => <html>

// Functionのインスタンスではない
document instanceof Function // => false
document.all.call(document, 0) // => Uncaught TypeError: document.all.call is not a function

補足と附録

別記事「Annex Bを読む、の補足と附録」にまとめたので、そちらをご覧ください。 (あとでリンクを貼る)

参考資料・関連リンク

更新履歴

  • 2022-01-16 公開。
脚注
  1. 1999年8月のES3 Final Draft時点ではAnnex Bにはライブラリ関数しか存在しなかった。1999年9月のミーティングで8進リテラルと8進エスケープのAnnex Bへの移動が決定されている: "Mike proposes to move the Octal productions to the compatibility appendix (B)." ↩︎

  2. 2014 ECMAScript Archives 内、Minutes of the 42nd meeting of TC39, Boston, September 2014 の内容を参照。関連スレッド: Early error on '0' followed by '8' or '9' in numeric literals does not seem to be web-compatible ↩︎

  3. blinkタグの誕生にまつわるLou Montulliの手記は面白いのでおすすめです ↩︎

Discussion