Unicode と JavaScript の文字列について

パーサコンビネータ を作っていたとき, 最近流行りの (?) 絵文字が変数名として使えるような言語を作る場合に, 正しくコード中の扱うにはどうしたら良いかなどを考えていたら, UnicodeJavaScript の文字列に関する理解が得られたので, ここに共有します.

とはいえ, JavaScript の文字列がどう表現され, どう扱われるのかについてが主で, それ以外, 例えば正規化については紹介すらできていませんのでご了承ください (気が向いたら追記するかも).

Unicode について

JavaScript の文字列について説明する前に, ざっくり Unicode の仕組みや用語を抑えておきます.

  • Unicode文字集合 (character set) や, 以下に挙げるような符号化方式などを定義した文字コードの規格
  • 文字集合中の文字は符号位置 (code point) と呼ばれる 0(16) 〜 10FFFF(16) の数に割り当てられている (U+0000 〜 U+10FFFF と表す)
    • 文字列中のどこに文字があるかという意味の位置ではない *1
    • 必ず文字が割り当てられるわけではなく, 例えば U+D800 〜 U+DFFF には文字が割り当てられていない (代用符号位置, 下記の UTF-16 についても参照)
  • 各符号位置は, 以下の符号化方式のいずれかを用いて符号単位 (code unit) の列に変換される
    • UTF-8: 1 符号位置を 1 〜 4 個の 8 bit 符号なし整数で表す
    • UTF-16: 1 符号位置を 1 〜 2 個の 16 bit 符号なし整数で表す
    • UTF-32: 1 符号位置を 1 個の 32 bit 符号なし整数で表す
    • ここでの「16 bit 符号なし整数」などが符号単位です
  • UTF-16 の具体的な符号化方式は以下のようになっている
    • U+0000 〜 U+D7FF, U+E000 〜 U+FFFF の符号位置は, そのまま同じ値の符号単位を使って表す
    • U+10000 〜 U+10FFFF の符号位置は 16 bit では表せないため, 2 つの符号単位 (サロゲートペア (surrogate pair)) で表す
    • U+D800 〜 U+DFFF にはサロゲートペアとして使用するため文字が割り当てられておらず, 符号化後に 0xD800 〜 0xDFFF がサロゲートペアとして現れない場合は不正

より詳細に知りたい方は Unicode - Wikipedia などを読みましょう.

JavaScript の文字列

JavaScript (ECMAScript) の文字列は 0 個以上の 16 bit 符号なし整数の列として定義されていて *2, 列の各要素は UTF-16 の符号単位として扱われます. ただし, 必ずしも UTF-16 の文字列として正しくなくても良いです (例えばサロゲートペアに使う 0xD800 〜 0xDFFF の符号単位がペアで現れなくても良い).

参照: ECMAScript® 2016 Language Specification - 6.1.4 The String Type

文字列に対する []#charAt(), #charCodeAt(), #codePointAt() などのメソッドを用いたインデックスアクセスでは, 文字列中の符号単位の場所を指定したことになります. また #length は文字列中に含まれる符号単位の数を数えます. このため, サロゲートペアを使って表される領域 (例えば絵文字など) を扱う際には注意が必要です.

const str = "\uD83C\uDF63\uD83c\uDF64"; // "🍣🍤"

// インデックスは符号単位の場所を指す
assert(str[0] === "\uD83C");
assert(str.charCodeAt(1) === 0xDF63);

// length は含まれる符号単位の数を返す
assert(str.length === 4);

ECMAScript 2015 (ES6) 以降で, (ようやく) UTF-16 文字列を便利 / 適切に扱うための方法が標準で提供されました. 以下ではその一部を紹介します.

文字列リテラル中の符号位置エスケープ

ES5 以前でも符号単位のエスケープは存在しましたが, ES6 以降では符号位置のエスケープが追加されました. 注意すべき点は, 符号位置でエスケープしたからといって文字列が符号位置の列になるわけではなく, 旧来通りの符号単位でエスケープした場合の文字列となんら変わりないということです.

// 符号単位のエスケープ
const a = "\uD83C\uDF63\uD83c\uDF64"; // "🍣🍤"
// 符号位置のエスケープ
const b = "\u{1F363}\u{1F364}"

// a と b は同じ文字列を表す
assert(a === b);

// 符号位置で表現しても, 文字列が符号単位の列であることに変わりはない
assert(b[0] === "\uD83C");
assert(b[1] === "\uDF63");

正規表現u フラグ

正規表現u (unicode) フラグをつけることで, いくつかの Unicode 関連の機能が有効になります (Unicode-aware と言ったりする).

まず, 上記の文字列リテラル中と同様に, 符号位置のエスケープが行えるようになります.

// u フラグなし
const a = /\u{1F363}/;
assert(a.test("\u{1F363}\u{1F364}") === false);

// u フラグあり
const b = /\u{1F363}/u;
assert(b.test("\u{1F363}\u{1F364}") === true);

続いて, 正規表現中で 1 文字を表す表現が符号位置にマッチするようになります.

// u フラグなし
const a = /^./; // 任意の 1 文字
assert(a.exec("\u{1F363}\u{1F364}")[0] === "\uD83C");

// u フラグあり
const b = /^./u;
assert(b.exec("\u{1F363}\u{1F364}")[0] === "\u{1F363}");

最後に, i フラグ (大文字小文字の区別をしない) を同時に使用したとき, いくつかの言語で用いられる, 1 つの大文字が複数の小文字に対応する (あるいはその逆) ような文字に対するマッチが正しく行われるようになります (例は省略).

文字列のイテレータ

文字列のイテレータ符号位置を 1 つずつ取り出します.

for of ループを使えば, (おそらく多くの人が期待した通りの) 文字ごとに対する処理が行えます.

const str = "\u{1F363}\u{1F364}"; // "🍣🍤"

// "\uD83C", "\uDF63", "\uD83c", "\uDF64" が順に出力されてしまう
for (let i = 0; i < str.length; i++) {
    console.log(str[i]);
}

// "🍣", "🍤" が順に出力される
for (const c of str) {
    console.log(c);
}

また spread operator ... を使用した文字数を正しく数えるイディオム*3として次のようなものがあります (一度配列を生成しているため, あまり効率は良くないような気がするので注意).

const str = "\u{1F363}\u{1F364}"; // "🍣🍤"

// 通常の length は文字数を正しく数えられない
assert(str.length === 4);

// 文字数を正しく数えられる
assert([...str].length === 2);

String#codePointAt メソッド

インデックスを指定すると, 文字列中のその場所にある符号位置を整数として返します. 注意点として, インデックスは符号単位の場所を指すため, 単にインデックスを 1 ずつ増やしていくだけでは 1 文字 (符号位置) ずつ取り出していくことはできません. 1 文字ずつ処理したい場合は for of を使うか, 取り出された符号位置が U+10000 以降ならインデックスを 2 増やすなどの処理が必要です.

const str = "\u{1F363}\u{1F364}"; // "🍣🍤"

assert(str.codePointAt(0) === 0x1F363);
assert(str.codePointAt(1) === 0xDF63);  // 注意!
assert(str.codePointAt(2) === 0x1F364);
assert(str.codePointAt(3) === 0xDF64);  // 注意!

String.fromCodePoint メソッド

符号位置を整数として指定すると, その符号位置からなる文字列を返します. String#codePointAt に対する String.fromCodePoint の関係がちょうど String#charCodeAt に対する String.fromCharCode と同じような感じですね.

assert(String.fromCodePoint(0x1F363) === "\u{1F363}");

// 複数引数にも対応
assert(String.fromCodePoint(0x1F363, 0x1F364) === "\u{1F363}\u{1F364}");

これはそこまで気にするほどでもないかもしれない注意点ですが, まだ処理系に実装されてから日が浅いためかあまり最適化されておらず, これを使うよりも polyfill 的に実装した方が速いということもあります.

String#normalize メソッド

把握できていないのでまた今度で ꒰⁎×﹏×⁎꒱՞༘✡

まとまらないまとめ

Unicode-aware で良い 🍣 ライフを!

*1:紛らわしいので「符号点」と呼んだほうが良い気がする

*2:余談: 実際の処理系の内部では, 最適化のため必ずしも 16 bit で保存されているわけはないようです

*3:これでもまだ合成文字を使った場合は見た目と文字数が異なるということが起きる