エフアンダーバー

個人でのゲーム開発

【JavaScript】`\0` ← ヌル文字 `\00` ← エラー

ヌル文字を表すと思っていた構文が実はヌル文字を表す構文じゃなかったという話。

コトのおこり

webpack を更新したところ次のエラーが発生したので原因を調査することに。

ERROR in index.js from Terser

Octal escape sequences are not allowed in template strings

和訳。

Terser より index.js にてエラー

8進エスケープシーケンスはテンプレート文字列内では利用できません

terser は webpack v4.26.0 より、メンテナンスされていない uglify-es に代わって採用されたJavaScriptの圧縮ツールだそうです。

(説明に"A JavaScript parser, mangler/compressor and beautifier toolkit for ES6+."とあるのでいろいろできるみたいですが)

どうやら原因は書いた記憶のない8進エスケープシーケンスというものが、 テンプレート文字列内に存在してしまっていることのようです。

8進エスケープシーケンスとは?

そもそも8進エスケープシーケンスとはなんぞや、ということで調べてみると、 Latin-1の文字を8進数の文字コードで記述するものとのこと。 16進エスケープシーケンス\xXXの8進数版ですね。 まあ想像通り。

そして、その記法はバックスラッシュ(\)に続けて8進数表記で0から377までのいづれかの数字を記述するそうです。

……ん、0から?

と、そこで気付いてテンプレート文字列中の\0をすべて\x00に置き換えたところ問題のエラーは解消しました。

今までヌル文字を表すエスケープ文字だと思っていたものは、 文字コード0の文字を表す8進エスケープシーケンスだったようです。

これはJavaScriptに限ったことではなく、多くの言語でそうなっているそうです。 言語屋さんからしたら常識なんでしょうが、今までまるで知りませんでした……。

\0 はダメなのか?

今回の件はそのまま受け取ると、\0と書いてはいけないと言っているように思えます。 これは正しいのでしょうか。

なぜ8進エスケープシーケンスはダメなのか?

まずはなぜテンプレート文字列内で8進エスケープシーケンスを使用してはいけないのかという話から。

昔、何かと容量が貴重だった時代には8進数で表現した方がわかりやすいデータというものがありました。 8進数は2進数や16進数と並んで、数の表現としてよく使われていたようです。 そのためJavaScriptには8進数リテラルや8進エスケープシーケンスが実装されました。

しかし、時代とともに8進数で表すデータが消えていき、16進数で代替するようになると、 8進数リテラルや8進エスケープシーケンスはその有用性よりも、記法のわかりづらさが問題視されるようになりました。

例えば、8進数リテラルは8進数の数字の先頭に0を付けることで表現しますが、 これが10進数リテラルと見分けづらくミスの原因となりがちです。 私も二次元構造の配列を初期化しようとして一度引っかかったことがあります。

// 0で位置合わせしようとした失敗例
var matrix = [
    012, 345, 678,    //  10, 345, 678,
    123, 456, 789,    // 123, 456, 789,
    234, 567, 890,    // 234, 567, 890,
];

現在では8進数リテラルや8進エスケープシーケンスはマイナーな構文となりましたが、 それにも関わらず存在を知らずに書いてしまうほどシンプルな記法をもっています。

そこで、Strictモードが導入された際、 その他のわかりづらい挙動とともにこれらにも修正が入ることになりました。 旧式の8進数リテラルや8進エスケープシーケンスはStrictモードではコンパイルエラーとなり、 8進数リテラルには新しい記法0oXXが導入されました。

この新たな仕様は、JavaScriptにおいて旧式の8進数リテラルや8進エスケープシーケンスが、 互換性のためだけに存在する古い構文となったことを示しています。

そのため、以降に追加された構文であるテンプレート文字列内では最初から8進エスケープシーケンスは使えないのです。

当時をあまり知らないので正確じゃないかもしれませんがだいたいこんな感じみたいです。

やはり \0 はダメなのか?

つまり、8進エスケープシーケンスは非推奨な機能だから、 8進エスケープシーケンスである\0は書いてはいけない……と思いきや、 実はそうでもないみたいです。

JavaScriptの仕様版たるECMAScriptの仕様書をみてみると、

EscapeSequence ::
    CharacterEscapeSequence
    0 [lookahead ∉ DecimalDigit]
    HexEscapeSequence
    UnicodeEscapeSequence

と文字列のエスケープシーケンスを定義しています (\の記述がありませんがこの定義を参照する場所で必ず前置されます)。

注目すべきは二つめの項目で、 これは後ろに数字が来ない"0"はエスケープシーケンスであることを示しています。

つまり、\0だけは特別に現在も正しいエスケープシーケンスです。 \0は「文字コード0を表す8進エスケープシーケンス」から、 「ヌル文字を表すエスケープシーケンス」へと扱いが改められたことになります。

というわけで、冒頭の問題は単に terser のバグということになりそうです。

ちなみに、Strictモードでもテンプレート文字列でもない場合には前述の定義を下記のように拡張できるよ、 とも書かれていて、 これにより互換性を保った記述ができるようになっています。

EscapeSequence ::
    CharacterEscapeSequence
    LegacyOctalEscapeSequence
    HexEscapeSequence
    UnicodeEscapeSequence

LegacyOctalEscapeSequence ::
    OctalDigit [lookahead ∉ OctalDigit]
    ZeroToThree OctalDigit [lookahead ∉ OctalDigit]
    FourToSeven OctalDigit
    ZeroToThree OctalDigit OctalDigit

8進エスケープシーケンスの遺産

さて、無事に\0が正しいとわかったわけですが、気になる点がひとつ。

エスケープシーケンスの定義にある[lookahead ∉ DecimalDigit]の部分は果たして必要でしょうか。 ……と、まあ投げかけるのも野暮ですね。 8進エスケープシーケンスが有効か否かで結果が大きく変わってしまうのを避けたんだと思います。 8進数字のOctalDigitではなく10進数字のDecimalDigitになっているのは記法を統一するためでしょう。 記述は制限されますが、実用上ほとんど問題ないとの判断かと。

さて、\0の後に数字を続けるとエスケープシーケンスにならないことはわかりました。 では、その時の評価値はいったいどうなるのでしょうか。

答えは実行してみればすぐに構文エラーだとわかります。

$ node -i
> `\00`
`\00`
  ^

SyntaxError: Octal escape sequences are not allowed in template strings.

これは\00がNotEscapeSequenceとして解釈され、

NotEscapeSequence ::
    0 DecimalDigit
    DecimalDigit but not 0
    ...(略)

NotEscapeSequenceを含むタグのないテンプレート文字列は構文エラーであると決められているためです。

It is a Syntax Error if the [Tagged] parameter was not set and NoSubstitutionTemplate Contains NotEscapeSequence

ちなみにタグを付ければ問題なく動作します。

$ node -i
> String.raw`\00`
'\\00'

さらには、構文エラーだったにも関わらずテンプレートとしての評価値があり、

$ node -i
> function tag(x){ return x; }
undefined
> tag`a0${0}b\0${0}c\00`
[ 'a0', 'b\u0000', undefined ]

NotEscapeSequenceを含む部分の評価値はundefinedになります。

やはりJavaScriptは闇が深かった。

おわりに

ヌル文字を表すと思っていたエスケープシーケンスは
ヌル文字を表すためのエスケープシーケンスではなかったけど
ヌル文字を表すためのエスケープシーケンスになっていた
……という話でした。

調べたことをてきとーに並びたてた結果、何が言いたいのかよくわからなくなってますね。

ブログを再開したいなと思いつつ、久しぶりの記事を何から始めようか悩んでいたのですが、 思わぬ事実に衝撃を受けた勢いで「今ならいける」と書きあげました。 次は日が空いて書きづらくなる前に書きたい……という淡い願望があります。