Skip to content

字句解析

字句解析(lexical analysis)は、パイプラインの入口です。ソースコードは、エンジンにとってはまず単なる文字(コードポイント)の並びにすぎません。この平坦な文字列を、意味のある最小単位である「トークン」— 識別子・数値リテラル・文字列リテラル・演算子・キーワードなど — の列に切り分けるのが字句解析の役割です。一見すると単純な文字列分割に見えますが、JavaScript の字句規則には / が割り算にも正規表現にもなるといった文脈依存や、テンプレートリテラルの入れ子、Unicode 識別子、そして自動セミコロン挿入(ASI)といった、素朴な実装では扱いきれない厄介な要素が潜んでいます。加えて字句解析器(lexer, tokenizer とも呼ぶ)はソースの全バイトを必ず一度は走査するため、1 文字あたりの処理費用がそのまま全体の速度に効きます。この章では、トークン化の基本と、JavaScript 特有の難所、そして実装上のトレードオフと罠を扱います。

トークン化とは何か

字句解析器は、文字ストリームを先頭から走査し、次のような種類のトークンに切り分けます。

  • 識別子 (identifier): 変数名・関数名・プロパティ名など(foo, _bar, π)。
  • キーワード (keyword): 言語が予約した語(if, function, return など)。多くの実装では識別子として読み取ってから予約語かどうかを判定します。
  • 数値リテラル (numeric literal): 42, 3.14, 0xff, 1e10, 10n(BigInt)など。
  • 文字列リテラル (string literal): "abc", 'x\n'
  • テンプレートリテラル (template literal): `hello ${name}` のように式を埋め込めるもの。
  • 区切り子・演算子 (punctuator): +, ===, =>, {, ;, ... など。
  • 正規表現リテラル (regular expression literal): /ab+c/g

トークンにならない要素 — 空白・改行・コメント — は「トリビア(trivia)」と呼ばれ、通常はトークン列からは除かれます。ただし改行の有無だけは後述の ASI で意味を持つため、字句解析器は改行を単純に捨てるのではなく「このトークンの直前に改行があったか」という情報を保持しておく必要があります。

基本的な走査は、先頭の 1 文字を見て種類を大きく振り分け、その種類ごとに末尾まで読み進める、という繰り返しです。

loop:
  空白・コメントを読み飛ばす(改行を見たら「改行フラグ」を立てる)
  c = 次の文字
  if c が識別子開始文字      -> 識別子/キーワードを読む
  else if c が数字            -> 数値リテラルを読む
  else if c が " または '     -> 文字列リテラルを読む
  else if c が `              -> テンプレートを読む
  else if c が /              -> 割り算か正規表現かを判定して読む(後述)
  else                       -> 最長一致する区切り子/演算子を読む

多くのトークンはこの素直な走査で読めます。難しさは、「先頭の 1 文字だけでは種類が決まらない」ケースにあります。

文脈依存性 — 字句解析器と構文解析器の協調

JavaScript の字句規則は、文字だけを見て機械的にトークンを決められない「文脈依存」を含みます。これは、コンパイラ理論の教科書が前提とする「字句解析は構文解析から独立して先に走る」という理想形が、そのままでは通用しないことを意味します。字句解析器と構文解析器は協調しなければなりません。

/ の曖昧性 — 割り算か、正規表現か

もっとも有名な難所が、スラッシュ / です。同じ 1 文字が、文脈によって割り算演算子にも正規表現リテラルの開始にもなります。

a = b / c / d;      // 二つの割り算
a = b + /c/d;       // + の後ろなので /c/ は割り算ではなく正規表現リテラル(d はフラグ)
re = /ab+c/g;       // これは正規表現リテラル

この二つは、/ の直後の文字を見ても区別できません。判別の鍵は 直前のトークン にあります。直前が「値を生成しうるもの」— 識別子・リテラル・)] など — であれば、次に来る / は割り算です。直前が「オペランドを待っている文脈」— (, ,, =, return などの演算子やキーワード — であれば、/ は正規表現リテラルの始まりです。

問題は、この「直前が何を意味するか」の判断が、本質的に文法(構文)の知識を要する点です。ECMAScript 仕様はこれを、字句文法に二つの「開始記号(goal symbol)」を設けることで形式化しています。割り算を許す文脈では InputElementDiv、正規表現を許す文脈では InputElementRegExp という異なる規則でトークンを読む、と定めているのです。どちらの開始記号で読むべきかは構文解析器が知っているため、実装では次のいずれかの形になります。

  • 構文解析器が字句解析器に文脈を伝える: 構文解析器が「今は正規表現を許す位置だ」と判断し、その情報を渡して 1 トークンを要求する。字句解析器はその指示に従って / を解釈する。
  • 字句解析器が直前トークンを覚えておく: 直前に返したトークンの種類から、次の / が割り算か正規表現かを字句解析器側で推定する。単純だが、return/typeof のようなキーワードの後や、{ がブロックかオブジェクトリテラルかの区別など、キーワード・記号ごとの例外表を正しく作り込む必要がある。

いずれにせよ、字句解析を構文解析から完全に切り離すことはできず、両者の間で文脈のやり取りが必要になります。

テンプレートリテラルの入れ子

テンプレートリテラル `...${ 式 }...` も、字句と構文の境界をまたぐ要素です。${} の間には任意の式が来られ、その式の中にさらにテンプレートリテラルを書けます。

`outer ${ `inner ${x}` } end`

このため、テンプレートは単一のトークンとして一度に読み切れません。仕様は、テンプレートを複数のトークン片に分解します。おおむね次のように読み進めます。

  • 開き ` から最初の ${ までを「テンプレート先頭(template head)」。
  • ${ の後ろは通常の式として構文解析器が読み進める。
  • 式が終わって現れる } から次の ${ までを「テンプレート中間(template middle)」。
  • 最後の } から閉じ ` までを「テンプレート末尾(template tail)」。
`outer ${ `inner ${x}` } end`

outer template
|-- TemplateHead       : "`outer ${"
|-- <embedded expr>
|     `-- inner template
|          |-- TemplateHead    : "`inner ${"
|          |-- <embedded expr> : x
|          `-- TemplateTail     : "}`"
`-- TemplateTail       : "} end`"

入れ子テンプレートが head/tail の片と埋め込み式に分解され、式の中にさらにテンプレートが入る様子。

ここで厄介なのは、式の中に現れる } が「オブジェクトリテラルやブロックを閉じる }」なのか「テンプレートに戻る }」なのかを見分ける必要がある点です。素朴には {} の対応(ネスト深さ)を数えて、深さ 0 の } をテンプレート復帰点と判断します。この判断も、} の文脈を理解している構文解析器と字句解析器の協調によって行われます。実装では、テンプレートの入れ子状態(いくつのテンプレートの中にいるか、各テンプレートでのブレース深さ)をスタックとして保持するのが定石です。

Unicode 識別子

識別子に使える文字は ASCII の英数字とアンダースコアだけではありません。ECMAScript は Unicode の文字プロパティに基づいて識別子を定義します。

  • 識別子の 先頭 に使える文字は、Unicode プロパティ ID_Start を持つ文字(加えて $_)。
  • 識別子の 2 文字目以降 に使える文字は、ID_Continue を持つ文字(加えて $、ゼロ幅接合子 ZWJ / 非接合子 ZWNJ)。

ID_Start はおおむね「文字(letter)」に相当し、ID_Continue はそれに数字や一部の結合文字を加えたものです。これにより π日本語café といった識別子が合法になります。

さらに、識別子の中には Unicode エスケープシーケンスを書けます。\u{XXXX} あるいは \uXXXX の形で、コードポイントを直接指定できるのです。

var \u0061 = 1;    // 変数 a と同じ
var caf\u00e9 = 2; // 変数 café と同じ

重要なのは、エスケープは「その位置に対応する 1 文字が書かれているのと同じ」でなければならない点です。つまり \u0061(= a)は識別子開始として有効ですが、ID_Start を持たない文字をエスケープで書いても識別子にはできません。字句解析器はエスケープを復号したうえで ID_Start / ID_Continue の判定にかける必要があります。またこのエスケープはキーワードの偽装にも使えるため、\u0069\u0066(= if)のように書かれたものをキーワードとして扱うべきか識別子として扱うべきか、という規則(仕様は「エスケープを含むとキーワードとしては無効」とする方向で線を引く)も実装しなければなりません。

完全な Unicode プロパティ判定の費用

ID_Start / ID_Continue は数万コードポイントに及ぶ巨大な集合です。すべての文字でこの判定を厳密に行うと、識別子を読むたびにプロパティ表を引くことになり、字句解析器のホットパスに無視できない費用が乗ります。実務的な最適化は、ASCII 高速路 です。

if c < 0x80:
    ASCII の小さなビット表/範囲比較だけで判定(分岐一つ)
else:
    Unicode プロパティ表を引く(範囲テーブルの二分探索など)

現実の JavaScript ソースは識別子の大半が ASCII で書かれているため、ASCII の範囲を数回の比較で捌き、非 ASCII のときだけ本格的な Unicode 判定に落とす構成にすると、正しさを保ったまま平均費用を大きく下げられます。Unicode 側のテーブルも、コードポイントを羅列するのではなく「連続する範囲(開始・終了)」の列として持ち、二分探索するのが定番です。プロパティの定義は Unicode のバージョンごとに更新されるため、どのバージョンに準拠するかを決め、テーブルをそのバージョンから生成する運用も必要になります。

数値リテラルと文字列リテラルの走査

数値リテラル

数値リテラルは種類が多く、走査規則が細かく分かれます。

  • 10 進数: 42, 3.14, .5, 5.
  • 指数表記: 1e10, 2.5E-3
  • 基数プレフィックス: 0x1F(16 進)、0o17(8 進)、0b1010(2 進)
  • BigInt: 末尾に n(10n)。ただし小数点・指数とは併用できない。
  • 数値区切り: 1_000_000(桁区切りのアンダースコア)。先頭・末尾や連続は不可。

走査自体は「数字が続く限り読む」ですが、指数部の符号、区切りアンダースコアの配置制約、BigInt サフィックスとの排他性など、細部の検査が必要です。また、数値リテラルの 直後に識別子開始文字が来てはならない という規則があります。3in x のような並びは弾く必要があり、これも字句解析器が数値を読み切った直後に次の文字を覗いて検査します。

leading zero(先頭ゼロ)は、後述するようにモード依存の罠を抱えます。

文字列リテラルとエスケープ

文字列リテラルは、開きクォート(" または ')から同じ種類の閉じクォートまでを読みます。途中のエスケープシーケンスを解釈するのが要点です。

  • 単純エスケープ: \n, \t, \\, \", \', \0 など。
  • 16 進エスケープ: \xHH
  • Unicode エスケープ: \uHHHH と、コードポイントを直接書ける \u{...}
  • 行継続: 行末のバックスラッシュ+改行は、文字列に何も加えずに次行へ続ける。

文字列リテラル内では、エスケープしていない生の改行は許されません(テンプレートリテラルは許す、という違いがあります)。字句解析器はクォート内を走査しながらエスケープを復号し、最終的な文字列値を組み立てます。ここでも、\u{...} が有効なコードポイントの範囲(≤ U+10FFFF。文字列リテラルではサロゲート領域も許される)に収まっているか、といった検査が要ります。

モードによって変わる規則 — 先頭ゼロと厳格モード

同じ文字列でも、それが厳格モード(strict mode)かどうかで合法性が変わるものがあります。字句解析における代表例が、先頭ゼロの 8 進数一部のエスケープ です。

  • 非厳格モードでは、0755 のような先頭ゼロで始まる整数を「レガシー 8 進数リテラル」として受理します。08, 09(8 進に収まらない桁を含む)も、非厳格モードでは 10 進として救済されます(NonOctalDecimalIntegerLiteral)。
  • 厳格モードでは、これらのレガシー 8 進表記も、文字列中の \0 以外のレガシー 8 進エスケープ(\07 など)も、いずれも構文エラーになります。

ここで問題になるのは、厳格モードかどうかが判明するのは字句解析より後 だという点です。関数やスクリプトが厳格モードかは、その本体先頭の "use strict" ディレクティブ(あるいは外側の文脈)によって決まりますが、それを知るには先にトークン化しなければなりません。つまり字句解析器は、その場では合否を確定できず、「これはレガシー 8 進だった」「このエスケープはレガシー 8 進だった」という事実を記録しておき、後で厳格モードと判明したときに構文解析器がエラーにする、という二段構えが必要になります。字句のレベルと構文・意味のレベルが素直に分離できない一例です。

キーワード・予約語・文脈依存キーワード

「予約された語」にもいくつかの層があります。字句解析器と構文解析器で扱いが分かれるため、区別しておく価値があります。

  • 予約語 (reserved word): if, for, function, return, class, this など。識別子として使えません。これらは字句としては識別子と同じ形で読み、読み終えてから予約語表と照合してキーワードトークンに昇格させる、という実装が一般的です。
  • 厳格モード予約語: implements, interface, let, package, private, protected, public, static, yield など。非厳格モードでは識別子として使えますが、厳格モードでは予約されます。モード依存であるため、これも「その場では識別子、モード確定後に判定」という扱いになります。
  • 文脈依存キーワード (contextual keyword): async, await, yield, of, get, set, as, from など。特定の構文位置でだけ特別な意味を持ち、それ以外では通常の識別子として使えます。たとえば offor (x of arr) では特別ですが、var of = 1; では普通の変数名です。

文脈依存キーワードは、その名の通り 字句レベルでは識別子として読み、意味を与えるのは構文解析器の仕事 です。字句解析器がこれらをキーワードトークンに昇格させてしまうと、識別子として使う正当なコードを壊します。したがって字句解析器が固定的にキーワード化してよいのは、いかなる文脈でも識別子になれない予約語だけで、モード依存語・文脈依存語は識別子として通し、判断を後段に委ねるのが正しい分担です。なお yieldawait は二面性を持ち、モードや文脈(厳格モード、ジェネレータ、async 関数・モジュール)によって予約語にも文脈依存キーワードにもなります。

分類 識別子として使えるか どこで判定するか
予約語 if, return, class 不可(常に) 字句解析器(語照合)
厳格モード予約語 let, yield, public 非厳格では可 モード確定後
文脈依存キーワード of, async, as, from 可(位置次第) 構文解析器

自動セミコロン挿入 (ASI)

JavaScript は文の終端にセミコロンを要求しますが、多くの場面で省略できます。これを裏で支えるのが 自動セミコロン挿入(Automatic Semicolon Insertion, ASI) です。ASI は、構文解析が行き詰まったときに、特定の条件下でセミコロンが「あったことにする」規則です。字句解析器が改行情報を提供し、構文解析器がそれを使って挿入判断を下す、両者の協調で成り立ちます。

仕様は ASI を三つの規則にまとめています。

  1. エラー時の挿入: トークンを読み進めて文法違反(構文的に受理できないトークン)に突き当たったとき、そのトークンの直前に 改行があった か、そのトークンが } であれば、そこにセミコロンを挿入して回復を試みる。
  2. 入力末尾での挿入: トークン列の末尾に達したが文がまだ完結していないとき、末尾にセミコロンを挿入する。
  3. 制限付き生成規則(restricted production)での挿入: 特定の構文(下記)で、本来つながるべきトークンとの間に改行があった場合、そこで強制的にセミコロンを挿入する。

規則 1・2 は「困ったら補う」ものですが、規則 3 は「改行があると 強制的に 切る」もので、意図に反する挙動を生みやすいのが特徴です。ここで「改行があったか」を判定する情報は、字句解析器が保持していた改行フラグから得られます。ASI の挿入判断そのものは構文解析器の責務ですが、その材料(トークン間に改行が存在したか)を提供するのは字句解析器であり、この点でも両者は不可分です。

制限付き生成規則と古典的な落とし穴

規則 3 が適用されるのは、returnthrowbreakcontinue・後置 ++ / --yield・アロー関数の => 前など、「この位置で改行を挟んではいけない」と仕様が定めた箇所です。もっとも有名な罠が return です。

function f() {
  return
    42;
}

書き手は 42 を返すつもりでも、return の直後に改行があるため、規則 3 によって return; が確定します。結果として関数は undefined を返し、42; は到達不能な式文になります。同種の罠は、行頭に ([` が来るコードでも逆向きに現れます。

a = b
(function(){ ... })()   // b(function(){...})() と解釈され、ASI は起きない

ここでは改行があっても、b に続けて (...) が文法的に受理できてしまうため、規則 1 の「文法違反に突き当たる」条件が成立せず、セミコロンは挿入されません。前行の末尾と次行の先頭が偶然つながってしまう、いわゆる「先頭の (/[ 問題」です。ASI は「改行があれば必ず文が切れる」規則ではなく、あくまで「文法違反・末尾・制限付き生成規則」という限定条件でのみ働く、という理解が要点です。

手書き字句解析器と生成字句解析器

字句解析器の作り方には、大きく二つの流儀があります。

  • 手書き(hand-written): 文字を見て分岐する走査ループを直接コードで書く。上で示した擬似コードのような形。
  • 生成(generated / table-driven): 字句規則を正規表現などで記述し、字句解析器生成器(古典的には lex/flex の系譜)が有限オートマトンの遷移表を生成する。実行時はその表を引いて状態遷移する。

両者の得失は次の通りです。

観点 手書き 生成(表駆動)
速度 ホットパスを細かく最適化でき、高速にしやすい 表引きが挟まり、細かな最適化はしにくい
文脈依存への対応 / 曖昧性・テンプレート・ASI 材料を柔軟に組み込める 純粋な正規言語からの逸脱は扱いにくく、特別扱いの追加が必要
エラー報告 位置・原因を細かく制御でき、良質なメッセージを出しやすい 生成器の枠に依存し、きめ細かな報告はしにくい
実装・保守コスト 規則を自分で書き切る必要があり分量は多い 規則の記述は簡潔だが、生成器という依存が増える
移植性 依存が少なく自己完結 生成器・ランタイムへの依存が生じる

実用的な JavaScript エンジンの多くが 手書き を選ぶ傾向にあるのは、ここまで見てきた文脈依存(/ の曖昧性、テンプレートの入れ子、モード依存規則、ASI のための改行追跡)が、純粋な正規言語の枠に収まらないためです。これらを表駆動の生成器で扱おうとすると、結局は生成された表の外に多くの特別扱いを継ぎ足すことになり、生成の利点が薄れます。加えて、字句解析器は全バイトを走査するホットパスであり、手書きなら ASCII 高速路のような最適化を細部まで作り込める点も、手書きが好まれる理由です。

実装上の罠

/ の判定ミスは静かに壊す

/ を割り算と正規表現のどちらに読むかを誤ると、多くの場合その場では構文エラーにならず、意味の異なるプログラムとして通ってしまう ことがあります。直前トークンごとの分類表(どのキーワード・記号の後なら正規表現を許すか)に一つでも漏れがあると、特定のコードだけが誤って解釈される、再現条件の見えにくい不具合になります。returntypeofdelete などのキーワード後、) の後(if (x) /re/ のように直前の ) が制御構文のものか式のものかで変わる場合)など、境界例を洗い出してテストで固めることが重要です。

ASI は「材料」と「判断」の分担を誤りやすい

ASI の不具合は、多くが「改行があったか」という材料の取り違えから生じます。字句解析器がコメントをまたぐ改行や、Unicode の改行文字(行区切り U+2028 など)を改行として数え損ねると、構文解析器の ASI 判断が狂います。逆に、改行の有無を構文解析器が独自に判断しようとすると、トリビアを読み飛ばした後では改行情報が失われていて正しく判定できません。「改行があったか」は字句解析器が記録し、「セミコロンを挿入するか」は構文解析器が決める という分担を崩さないことが、ASI を正しく実装する鍵です。

モード確定前の判断を確定させない

leading zero・厳格モード予約語・レガシー 8 進エスケープのように、合否がモードに依存する要素を、字句解析の段階で早まって確定させると誤判定になります。これらは「事実(レガシー 8 進だった等)」だけを記録し、モードが確定した時点で合否を下す、という遅延判断が必要です。同様に、文脈依存キーワードを字句解析器がキーワードへ昇格させてしまう実装も、ofasync を変数名に使う正当なコードを壊す典型的な罠です。

まとめ

  • 字句解析は文字ストリームをトークン列へ変換する段だが、JavaScript では純粋な正規言語の走査に収まらない文脈依存を多く含む。
  • / は直前トークンに応じて割り算か正規表現かが変わり、テンプレートリテラルは入れ子の式を含むため、字句解析器と構文解析器の協調が不可欠である。
  • 識別子は Unicode の ID_Start / ID_Continue で定義され、エスケープも解釈する必要がある。全文字での厳密判定は高価なため、ASCII 高速路と範囲テーブルで平均費用を下げる。
  • 数値・文字列リテラルは種類とエスケープが多く、leading zero やレガシー 8 進エスケープはモード依存のため、字句段では確定できず遅延判断が要る。
  • 予約語は字句で判定してよいが、モード依存語・文脈依存キーワードは識別子として通し、判断を後段へ委ねる。
  • ASI は「改行があったか」という材料を字句解析器が提供し、挿入判断を構文解析器が下す協調で成り立つ。return 直後の改行や行頭の (/[ が古典的な落とし穴になる。
  • 文脈依存とホットパス最適化の要求から、実用エンジンでは手書きの字句解析器が選ばれることが多い。

参考文献