Tech Learning Daily

2026-10-11 (Sun) — 第 86 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Lang 🌿 基礎 ⏱ 約 7 分

見た目は 1 文字なのに、なぜ JS は 8、Go は 18、Ruby は 5 と数えるのか?

家族の絵文字 👨‍👩‍👧 の長さを測ると、JavaScript の length は 8、Go の len() は 18、Ruby の length は 5 を返す。画面には 1 文字しか見えていないのに、3 つの言語の答えがどれも 1 にならない。どの言語も間違ってはいない。では、それぞれ何を数えているのだろうか。

🎯 3 行まとめ
  • 「文字」には、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 が原因である。

⌨️ 手を動かす(5 分)

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 バイトを扱える文字セット。絵文字を保存するにはこれが要る。
🔗 もっと深く