パーサコンビネータ を作っていたとき,
最近流行りの (?) 絵文字が変数名として使えるような言語を作る場合に, 正しくコード中の扱うにはどうしたら良いかなどを考えていたら,
Unicode と JavaScript の文字列に関する理解が得られたので, ここに共有します.
とはいえ, JavaScript の文字列がどう表現され, どう扱われるのかについてが主で,
それ以外, 例えば正規化については紹介すらできていませんのでご了承ください (気が向いたら追記するかも).
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)) で表す
- 上位サロゲート c1: 0xD800 〜 0xDBFF
- 下位サロゲート c2: 0xDC00 〜 0xDFFF
(c1 - 0xD800) * 0x400 + (c2 - 0xDC00) + 0x10000
- U+D800 〜 U+DFFF にはサロゲートペアとして使用するため文字が割り当てられておらず,
符号化後に 0xD800 〜 0xDFFF がサロゲートペアとして現れない場合は不正
より詳細に知りたい方は Unicode - Wikipedia などを読みましょう.
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);
assert(str.length === 4);
ECMAScript 2015 (ES6) 以降で, (ようやく) UTF-16 文字列を便利 / 適切に扱うための方法が標準で提供されました.
以下ではその一部を紹介します.
ES5 以前でも符号単位のエスケープは存在しましたが, ES6 以降では符号位置のエスケープが追加されました.
注意すべき点は, 符号位置でエスケープしたからといって文字列が符号位置の列になるわけではなく,
旧来通りの符号単位でエスケープした場合の文字列となんら変わりないということです.
const a = "\uD83C\uDF63\uD83c\uDF64";
const b = "\u{1F363}\u{1F364}"
assert(a === b);
assert(b[0] === "\uD83C");
assert(b[1] === "\uDF63");
正規表現の u
(unicode) フラグをつけることで, いくつかの Unicode 関連の機能が有効になります (Unicode-aware と言ったりする).
まず, 上記の文字列リテラル中と同様に, 符号位置のエスケープが行えるようになります.
const a = /\u{1F363}/;
assert(a.test("\u{1F363}\u{1F364}") === false);
const b = /\u{1F363}/u;
assert(b.test("\u{1F363}\u{1F364}") === true);
続いて, 正規表現中で 1 文字を表す表現が符号位置にマッチするようになります.
const a = /^./;
assert(a.exec("\u{1F363}\u{1F364}")[0] === "\uD83C");
const b = /^./u;
assert(b.exec("\u{1F363}\u{1F364}")[0] === "\u{1F363}");
最後に, i
フラグ (大文字小文字の区別をしない) を同時に使用したとき,
いくつかの言語で用いられる, 1 つの大文字が複数の小文字に対応する (あるいはその逆) ような文字に対するマッチが正しく行われるようになります (例は省略).
文字列のイテレータは符号位置を 1 つずつ取り出します.
for of
ループを使えば, (おそらく多くの人が期待した通りの) 文字ごとに対する処理が行えます.
const str = "\u{1F363}\u{1F364}";
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}";
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 で良い 🍣 ライフを!