合同会社小村ソフト
第 5 章

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 のテンプレートによく似ています。

Step 1 — 0x10000 を引く
U+20BB7 − 0x10000 = 0x10BB7。BMP 外の番号は、こうすると必ず 20 ビットに収まる
Step 2 — 20 ビットを 10 + 10 に割る
上位 10 ビットを 0xD800 に足して高位サロゲート D842、下位 10 ビットを 0xDC00 に足して低位サロゲート DFB7
Step 3 — 2 単位並べる
「𠮷」 = D842 DFB7。D800〜DFFF は単独では文字にならない予約領域なので、読み手はペアだと即座に分かる

この 2 単位 1 組をサロゲートペアと呼びます。絵文字も同じ仕組みで、「😀」U+1F600 は D83D DE00 の 2 単位です。

「あ」は UTF-16 で 1 コード単位、「𠮷」U+20BB7 はサロゲートペア D842 DFB7 の 2 コード単位になることを示す図

BMP 内の「あ」は 1 単位、BMP 外の「𠮷」は 2 単位。length が数えているのはこの「箱の数」であって、見た目の文字数ではありません。

小問 5-1 — UTF-16 とサロゲートペア

16 ビット単位の世界で、大きな番号の文字がどう表されるかを確認します。

Q1. UTF-16 の説明として正しいものはどれですか。

Q2. 「𠮷」(つちよし)のコードポイントは U+20BB7 で、U+FFFF を超えています。UTF-16 ではどう表されますか。

Q3. C# の string.Length や JavaScript の .length は UTF-16 のコード単位数を返します。「𠮷野家」(𠮷 = U+20BB7、野・家は BMP 内)の length はいくつですか。

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 はいくつですか。

Q5. length(Length)が「見た目の文字数」と食い違う場面での正しい理解はどれですか。

Q6. 家族の絵文字「👨‍👩‍👧」は、複数の絵文字をゼロ幅接合子(ZWJ)でつないだもので、JavaScript の .length は 8 になります。「見た目の 1 文字」を正しく数えたいときの適切な単位はどれですか。

この章で持ち帰ること

  • UTF-16 は 16 ビットのコード単位が基本。BMP(U+FFFF まで)は 1 単位、BMP 外はサロゲートペアで 2 単位
  • 「𠮷」U+20BB7 = D842 DFB7、「😀」U+1F600 = D83D DE00
  • C#・JavaScript の length はコード単位数。「𠮷野家」は 4、「😀😀😀」は 6
  • 見た目の 1 文字 = 書記素クラスタ。バイト・コード単位・コードポイント・書記素の 4 つの数え方を区別する

符号化の仕組みはここまでで出そろいました。次章からは実務編 — 「宣言と実体のズレ」が生む現場のトラブルを片付けていきます。