パーサコンビネータ を作っていたとき, 最近流行りの (?) 絵文字が変数名として使えるような言語を作る場合に, 正しくコード中の扱うにはどうしたら良いかなどを考えていたら, Unicode と JavaScript の文字列に関する理解が得られたので, ここに共有します.
とはいえ, JavaScript の文字列がどう表現され, どう扱われるのかについてが主で, それ以外, 例えば正規化については紹介すらできていませんのでご了承ください (気が向いたら追記するかも).
Unicode について
JavaScript の文字列について説明する前に, ざっくり Unicode の仕組みや用語を抑えておきます.
- Unicode は文字集合 (character set) や, 以下に挙げるような符号化方式などを定義した文字コードの規格
- 文字集合中の文字は符号位置 (code point) と呼ばれる 0(16) 〜 10FFFF(16) の数に割り当てられている (U+0000 〜 U+10FFFF と表す)
- 各符号位置は, 以下の符号化方式のいずれかを用いて符号単位 (code unit) の列に変換される
- UTF-16 の具体的な符号化方式は以下のようになっている
より詳細に知りたい方は 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 で良い 🍣 ライフを!