見た目は 1 文字なのに、なぜ JS は 8、Go は 18、Ruby は 5 と数えるのか?
家族の絵文字 👨👩👧 の長さを測ると、JavaScript の length は 8、Go の len() は 18、Ruby の length は 5 を返す。画面には 1 文字しか見えていないのに、3 つの言語の答えがどれも 1 にならない。どの言語も間違ってはいない。では、それぞれ何を数えているのだろうか。
- 「文字」には、Unicode が割り振った番号(符号位置)、その番号を保存するためのバイト列(UTF-8 / UTF-16)、人間が 1 文字と感じる単位(書記素クラスタ)の 3 層があり、言語ごとに数えている層が違う。
- UTF-8 は 1〜4 バイトの可変長で、ASCII とバイト単位で互換になるよう設計されている。JS の文字列は UTF-16 で、U+FFFF を超える絵文字は 2 単位(サロゲートペア)を使う。
- MySQL の
utf8は最大 3 バイトのutf8mb3の別名で絵文字を保存できないため、utf8mb4を指定する。
「文字」は 3 つの層でできている
Unicode は、世界中の文字一つひとつに番号を割り振った表である。この番号を符号位置(コードポイント)と呼び、U+3042(あ)や U+1F604(😄)のように 16 進数で書く。範囲は U+0000 から U+10FFFF までで、ここまでは単なる「番号の表」にすぎない。
番号をメモリやファイルに置くには、それをバイト列に変換する規則が要る。これが符号化方式(エンコーディング)で、UTF-8 と UTF-16 が代表例だ。同じ U+1F604 でも、UTF-8 なら 4 バイト、UTF-16 なら 16 ビットの単位 2 つになる。
さらにその上に、人間が「1 文字」と感じる単位がある。Unicode の規格 UAX #29 はこれを書記素クラスタ(grapheme cluster)と定義している。冒頭の家族絵文字は、男性・女性・女の子の 3 つの絵文字を、ZWJ(Zero Width Joiner, U+200D。前後の絵文字を 1 つに合成させる目に見えない文字)でつないだ 5 つの符号位置からできている。
UTF-8 は「先頭バイトを見れば長さが分かる」
UTF-8 は、符号位置の大きさに応じて 1〜4 バイトを使い分ける。RFC 3629 の規定では、U+007F までは 1 バイト、U+07FF までは 2 バイト、U+FFFF までは 3 バイト、それより上は 4 バイトだ。ひらがなや常用漢字の多くは 3 バイト、絵文字の多くは 4 バイトになる。
😄 = U+1F604(21 ビットの番号) 0 0001 1111 0110 0000 0100 │ ▼ 4 バイト用の型に上から詰める ┌────────┬────────┬────────┬────────┐ │11110xxx│10xxxxxx│10xxxxxx│10xxxxxx│ └────────┴────────┴────────┴────────┘ │ ▼ ┌────────┬────────┬────────┬────────┐ │11110000│10011111│10011000│10000100│ │ F0 │ 9F │ 98 │ 84 │ └────────┴────────┴────────┴────────┘ 先頭の 1 の個数 = 全体のバイト数 10 で始まる = 続きのバイト
この型には 2 つの狙いがある。1 つ目は、先頭バイトの上位ビットに並ぶ 1 の個数が全体のバイト数を表し、続きのバイトは必ず 10 で始まることだ。バイト列の途中から読み始めても、10 で始まらないバイトまで戻れば文字の切れ目が見つかる。
2 つ目は ASCII との互換である。U+007F までは ASCII と同じ 1 バイトになり、複数バイトの文字を構成するバイトはすべて最上位ビットが 1 なので、/ や " のような ASCII のバイトが日本語の途中に紛れ込むことがない。ASCII しか知らない古いプログラムに UTF-8 を流しても、区切り文字の検索が壊れないのはこのためだ。
JS の length が数えているのは UTF-16 の「単位」
JavaScript の文字列は UTF-16 で表現され、length は文字数ではなく UTF-16 の符号単位(16 ビットのかたまり)の数を返す、と MDN は明記している。U+FFFF 以下の文字は 1 単位で収まるので、日本語だけを扱っている間は length が文字数と一致して見える。
しかし 16 ビットでは U+10000 以上を表せない。そこで UTF-16 は、文字を割り当てずに予約してある領域 U+D800〜U+DFFF から 2 つの値を組み合わせて 1 文字を表す。これをサロゲートペアと呼ぶ。😄 は D83D DE04 の 2 単位なので、"😄".length は 2 になり、"😄".slice(0, 1) は前半だけの壊れた値(表示すると �)を返す。
一方、Go の len() は UTF-8 のバイト数を返し、Ruby の length は符号位置の数を返す。これで冒頭の 3 つの数字が説明できる。
👨👩👧 を分解すると(見た目は 1 文字) 男 ZWJ 女 ZWJ 子 ┌────┬────┬────┬────┬────┐ │ 1 │ 1 │ 1 │ 1 │ 1 │ 符号位置 5 │ 2 │ 1 │ 2 │ 1 │ 2 │ UTF-16 8 │ 4 │ 3 │ 4 │ 3 │ 4 │ UTF-8 18 └────┴────┴────┴────┴────┘ └──────── 書記素 1 個 ───────┘ Ruby s.length ─▶ 5 JS s.length ─▶ 8 Go len(s) ─▶ 18
こう並べると、符号位置を数える Ruby が一番「正しい」ように見える。しかし実際は Ruby の 5 も人間の答えの 1 とは違う。正解に近いのは、どの層を数えるべきかを用途ごとに選ぶことである。
弁当の数え方に近い。客が見ているのは「幕の内 1 個」(書記素)、厨房の献立表では「ご飯・焼き魚・卵焼き・漬物・仕切り」の 5 品目(符号位置)、配送業者にとっては「容器いくつ分の重さか」(バイトや UTF-16 単位)だ。誰の数え方も間違っていないが、レジで品目数を請求すると客は怒る。
どの層で数えるべきか
画面上の文字数制限やカーソル移動、「先頭 20 文字で省略」のような処理は、書記素クラスタで数えないと絵文字や国旗の途中で切れる。国旗 🇯🇵 も 2 つの符号位置(地域指示子)の組なので、符号位置で切ると「🇯」だけが残る。JavaScript では Intl.Segmenter、Ruby では String#grapheme_clusters が書記素単位の分割を提供する。
// JavaScript
const s = "👨👩👧";
s.length; // 8(UTF-16 単位)
[...s].length; // 5(符号位置)
[...new Intl.Segmenter("ja", { granularity: "grapheme" })
.segment(s)].length; // 1(書記素)
逆に、DB のカラム容量や HTTP ボディのサイズ上限、ネットワーク越しのバッファ確保はバイト数で考える。Go が len() でバイト数を返し、for range で回したときだけ UTF-8 を 1 符号位置(Go では rune と呼ぶ)ずつ解読するのは、文字列を「UTF-8 のバイト列」として扱う設計を貫いているからだ。
MySQL の utf8 は 3 バイトまでしか入らない
MySQL の文字セット utf8 は utf8mb3 の別名(非推奨)で、1 文字最大 3 バイト、つまり U+FFFF 以下しか保存できない。4 バイトになる絵文字を挿入すると、strict モードでは Incorrect string value: '\xF0\x9F...' のエラーで INSERT が失敗する。先ほどの UTF-8 の表で、4 バイト文字の先頭が F0 だったことを思い出すと、このエラーメッセージが何を拒否しているかが読める。
MySQL 8.0 ではサーバーのデフォルトが utf8mb4(照合順序 utf8mb4_0900_ai_ci)になった。ただし、5.7 以前に作られたテーブルはアップグレード後も作成時の文字セットを持ち続けるので、デフォルトが変わっても古いテーブルには絵文字が入らないままになる。
-- テーブルの文字セットを確認 SHOW CREATE TABLE posts; -- utf8mb3 なら変換する(テーブルを書き換えるので本番は要計画) ALTER TABLE posts CONVERT TO CHARACTER SET utf8mb4;
接続側の文字セットも揃える必要がある。Rails なら config/database.yml に encoding: utf8mb4 を書く。テーブルだけ utf8mb4 にしても、接続が 3 バイトまでの文字セットで送れば、4 バイト文字は DB に届く前に変換に失敗する。
一番多いのは、フロントとバックエンドで文字数制限が食い違うケースだ。HTML の maxlength や JS の length は UTF-16 単位で数え、Rails の validates :name, length: { maximum: 20 } は Ruby の String#length(符号位置)で数える。絵文字を混ぜると、フロントは「20 文字を超えた」と言い、サーバーは通す(あるいはその逆)という不一致が起きる。仕様書の「20 文字」がどの層の 20 なのかを決めておくと、この手の問い合わせは減る。もう一つは、ユーザー名や投稿本文に絵文字が入った瞬間にだけ 500 エラーが出る障害で、多くは前述の utf8mb3 が原因である。
Mac のターミナルで、同じ文字が UTF-8 で何バイトになるかを xxd で見る。
printf 'A' | xxd printf 'あ' | xxd printf '😄' | xxd printf '👨👩👧' | wc -c node -e 'const s="👨👩👧"; console.log(s.length, [...s].length)' ruby -e 's="👨👩👧"; p [s.length, s.bytesize, s.grapheme_clusters.size]'
A は 41 の 1 バイト、あ は e3 81 82 の 3 バイト、😄 は f0 9f 98 84 の 4 バイトで、先頭バイトが e(1110…)や f(1111 0…)で始まり、続きのバイトが 8〜b(10…)で始まっていることが確認できる。家族絵文字は wc -c で 18、node で 8 5、ruby で [5, 18, 1] が表示されるはずだ(node / ruby が無い環境ではその行を飛ばしてよい)。
- UTF-8 では日本語は 2 バイト — ひらがな・カタカナ・常用漢字の多くは
U+0800〜U+FFFFにあるので 3 バイトになる。2 バイトなのは Shift_JIS などの旧来の符号化方式の話である。 - Unicode と UTF-8 は同じもの — Unicode は文字と番号の対応表で、UTF-8 はその番号をバイト列にする規則の一つ。同じ Unicode 文字列を UTF-16 で保存すればバイト列はまったく別物になる。
- 符号位置で数えれば「人間の文字数」になる — ZWJ でつないだ絵文字、国旗、濁点などの結合文字は複数の符号位置で 1 文字に見える。人間の 1 文字に合わせたいなら書記素クラスタで数える。
- 符号位置(コードポイント)
- Unicode が各文字に割り振った番号。
U+0000〜U+10FFFF。 - UTF-8
- 符号位置を 1〜4 バイトの可変長で表す符号化方式。ASCII とバイト単位で互換。
- サロゲートペア
- UTF-16 で
U+10000以上の文字を 16 ビット単位 2 つで表す仕組み。 - 書記素クラスタ
- 人間が 1 文字と感じる単位。複数の符号位置から成ることがある。
- ZWJ
- Zero Width Joiner(
U+200D)。前後の絵文字を 1 つの絵文字に合成させる不可視文字。 - utf8mb4
- MySQL で 1 文字最大 4 バイトを扱える文字セット。絵文字を保存するにはこれが要る。
- RFC 3629: UTF-8, a transformation format of ISO 10646 — 1〜4 バイトのビットパターン表と、ASCII 互換の設計意図が短くまとまっている。
- Strings, bytes, runes and characters in Go — Go の文字列がバイト列であること、
runeとfor rangeの関係を例で示す公式ブログ。 - MDN: String: length — JS の
lengthが UTF-16 単位を数えることと、絵文字での例。 - MySQL 8.0 Reference Manual: The utf8mb3 Character Set —
utf8mb3が BMP までしか扱えず非推奨である旨の一次情報。 - UAX #29: Unicode Text Segmentation — 書記素クラスタの境界規則(ZWJ・国旗・結合文字)の定義元。