UTF-16 とサロゲートペア — 文字数の落とし穴
UTF-16 は 16 ビット単位の符号化。BMP 外の文字はサロゲートペアで 2 単位になり、C# や JavaScript の length は見た目の文字数と一致しなくなる。
「3 文字の名前が入らない」— 文字数の数え方の事件
「氏名は 4 文字まで」のはずのフォームに「𠮷野家」の 3 文字が入らない。絵文字 2 個の投稿が「4 文字」とカウントされる。C# で Length を見て切り出したら文字の残骸が混ざった。— この章は「バイト数」に続くもう 1 つの数のズレ、「文字数」の数え方の話です。
主役は UTF-16。Windows の内部、Java・C#・JavaScript の文字列型が採用している符号化で、16 ビット(2 バイト)のコード単位を基本とします。「あ」U+3042 なら、そのまま 16 ビットに収まって 1 単位 — ここまでは素直です。
しかし第 2 章で見たとおり、Unicode の番号は U+10FFFF まであります。16 ビットで表せる U+FFFF までの範囲を BMP(基本多言語面)と呼びますが、絵文字(U+1F600 台)や「𠮷」(U+20BB7)のような文字は BMP の外側。16 ビット 1 個には収まりません。
サロゲートペア — 2 単位で 1 文字を表す
UTF-16 の答えは、2 つのコード単位をペアにして 1 文字を表すことでした。手順は UTF-8 のテンプレートによく似ています。
D842 DFB7。D800〜DFFF は単独では文字にならない予約領域なので、読み手はペアだと即座に分かるこの 2 単位 1 組をサロゲートペアと呼びます。絵文字も同じ仕組みで、「😀」U+1F600 は D83D DE00 の 2 単位です。
BMP 内の「あ」は 1 単位、BMP 外の「𠮷」は 2 単位。length が数えているのはこの「箱の数」であって、見た目の文字数ではありません。
小問 5-1 — UTF-16 とサロゲートペア
16 ビット単位の世界で、大きな番号の文字がどう表されるかを確認します。
Q1. UTF-16 の説明として正しいものはどれですか。
UTF-16 の基本単位は 16 ビットです。Unicode 初期の「全文字 2 バイトで足りる」見込みが崩れ、U+FFFF を超える文字を 2 単位で表すサロゲートペアが後から導入されました。「固定長」は BMP 内限定の近い過去の姿であり、現在は可変長(1 または 2 単位)です。1 バイト単位で ASCII 互換なのは UTF-8 のほうです。
Q2. 「𠮷」(つちよし)のコードポイントは U+20BB7 で、U+FFFF を超えています。UTF-16 ではどう表されますか。
U+20BB7 から 0x10000 を引いた 0x10BB7 を 20 ビットで表し、上位 10 ビットを高位サロゲート(0xD800〜)、下位 10 ビットを低位サロゲート(0xDC00〜)に載せると D842 DFB7 になります。D800〜DFFF はこの用途のために予約された「単独では文字にならない」領域で、2 つ 1 組で初めて 1 文字を表します。絵文字(U+1F600 など)もすべて同じ仕組みです。
Q3. C# の string.Length や JavaScript の .length は UTF-16 のコード単位数を返します。「𠮷野家」(𠮷 = U+20BB7、野・家は BMP 内)の length はいくつですか。
「𠮷」はサロゲートペアで 2 単位、「野」「家」は 1 単位ずつなので 2 + 1 + 1 = 4 です。見た目は 3 文字なのに 4 が返る — 「length = 文字数」という思い込みが崩れる瞬間です。文字数制限のチェックや先頭 N 文字の切り出しをコード単位でやると、ペアの真ん中で切れて壊れた文字が生まれます。
length が返すのは「箱の数」
ここで冒頭の事件が解けます。C# の string.Length も JavaScript の .length も、返しているのはUTF-16 のコード単位数 — つまりサロゲートペアの「箱」を 2 と数えた値です。
| 文字列 | 見た目 | length(コード単位) | なぜ |
|---|---|---|---|
| あいう | 3 文字 | 3 | すべて BMP 内で 1 単位ずつ |
| 𠮷野家 | 3 文字 | 4 | 𠮷 が 2 単位 |
| 😀😀😀 | 3 文字 | 6 | 絵文字は各 2 単位 |
これは処理系のバグではなく仕様どおりです。危ないのは、length を「文字数」と読み替えて文字数制限のチェックや先頭 N 文字の切り出しに使うとき。切り出しがペアの真ん中に落ちると、高位サロゲートだけが残った「壊れた文字」が生まれ、後段の処理やデータベースでエラーの種になります。
さらにその先 — 結合文字と「見た目の 1 文字」
実は「コードポイント単位で数えれば文字数になる」も、まだ正解ではありません。家族の絵文字「👨👩👧」は、「男性」「女性」「女児」の 3 つの絵文字をゼロ幅接合子(ZWJ)という見えないコードポイントでつないだもので、コードポイントは 5 個、UTF-16 のコード単位では 8 個。それでも画面に見えるのは 1 文字です。「が」を「か + 濁点」の 2 コードポイントで表す結合文字も同じ仲間です。
人間の目に映る 1 文字に対応する単位は書記素クラスタ(grapheme cluster)と呼ばれ、数えるには専用の API(JavaScript の Intl.Segmenter など)を使います。まとめると、「文字数」には少なくとも 4 つの数え方 — バイト・コード単位・コードポイント・書記素 — があり、どれを数えているかを言えることがこの章のゴールです。第 3 章のシミュレータに「𠮷野家」や家族絵文字を入れて、4 つの数が全部食い違うのを確かめてみてください。
小問 5-2 — length の罠と結合文字
「文字数を数える」ことの意外な難しさを、絵文字で仕上げます。
Q4. 絵文字「😀」は U+1F600 で、UTF-16 では 2 コード単位です。JavaScript で「😀😀😀」(絵文字 3 個)の .length はいくつですか。
2 単位 × 3 個 = 6 です。「絵文字 3 文字までOK」のつもりで length <= 3 と書いた入力チェックは、絵文字 2 個で弾いてしまいます。SNS の文字数カウントや DB の文字数制限で頻出する、現代らしい落とし穴です。
Q5. length(Length)が「見た目の文字数」と食い違う場面での正しい理解はどれですか。
length は「UTF-16 のコード単位数」という明確な仕様どおりに動いています。問題は使う側が「文字数」と読み替えてしまうことです。なお絵文字を避けても「𠮷」などの漢字や、次問の結合文字で同じズレは起きます。数えたい単位(バイト・コード単位・コードポイント・書記素)を意識して選ぶ — これがこの章の結論です。
Q6. 家族の絵文字「👨👩👧」は、複数の絵文字をゼロ幅接合子(ZWJ)でつないだもので、JavaScript の .length は 8 になります。「見た目の 1 文字」を正しく数えたいときの適切な単位はどれですか。
👨👩👧 は「男性 + ZWJ + 女性 + ZWJ + 女児」の 5 コードポイント(うち絵文字 3 つは各 2 単位)で、コード単位では 8、コードポイントでも 5 です。人間の目に映る「1 文字」に対応する単位は書記素クラスタで、これを数えて初めて 1 になります。多くの言語に専用 API(JavaScript の Intl.Segmenter など)があり、第 3 章のシミュレータの「見た目の文字数」欄もこの単位で数えています。
この章で持ち帰ること
- UTF-16 は 16 ビットのコード単位が基本。BMP(U+FFFF まで)は 1 単位、BMP 外はサロゲートペアで 2 単位
- 「𠮷」U+20BB7 = D842 DFB7、「😀」U+1F600 = D83D DE00
- C#・JavaScript の length はコード単位数。「𠮷野家」は 4、「😀😀😀」は 6
- 見た目の 1 文字 = 書記素クラスタ。バイト・コード単位・コードポイント・書記素の 4 つの数え方を区別する
符号化の仕組みはここまでで出そろいました。次章からは実務編 — 「宣言と実体のズレ」が生む現場のトラブルを片付けていきます。