📦

UTF-8 バイト解剖: 文字がバイトになるまで

UTF-8 エンコーディングのバイトレベル解説。コードポイントが1〜4バイトにマップされる仕組み。

UTF-8 の 4 つのバイト範囲

UTF-8 は可変長エンコーディングで、すべての Unicode コードポイントを 1〜4 バイトで表現します。バイト数はコードポイントの範囲で決まります:

バイト数コードポイント範囲先頭バイトパターンCP用ビット数
1U+0000 .. U+007F0xxxxxxx7
2U+0080 .. U+07FF110xxxxx 10xxxxxx11
3U+0800 .. U+FFFF1110xxxx 10xxxxxx 10xxxxxx16
4U+10000 .. U+10FFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxx21

最初の 128 コードポイント(ASCII)は ASCII と同一の 1 バイトで表現されます。この後方互換性は Ken Thompson と Rob Pike による意図的な設計判断でした。有効な ASCII ファイルはそのまま有効な UTF-8 ファイルです。

2 バイト範囲はラテン拡張文字、ギリシャ文字、キリル文字、アラビア文字、ヘブライ文字をカバーします。ほとんどのヨーロッパ言語は 1〜2 バイト/文字に収まります。

ビットレベルの UTF-8 解剖

コードポイントがどのようにバイト列になるか、正確にたどってみましょう。(U+00E9、アキュートアクセント付き e)は 2 バイト範囲(U+0080..U+07FF)に該当します:

U+00E9 のバイナリ: 000 1110 1001  (11ビット)

グループに分割:      [00011] [101001]
テンプレートに挿入:  110xxxxx 10xxxxxx
結果:                11000011 10101001
16進数:              0xC3     0xA9

次に (U+6F22、CJK 統合漢字)を見ましょう。3 バイト範囲に該当します:

U+6F22 のバイナリ: 0110 1111 0010 0010  (16ビット)

グループに分割:      [0110] [111100] [100010]
テンプレートに挿入:  1110xxxx 10xxxxxx 10xxxxxx
結果:                11100110 10111100 10100010
16進数:              0xE6     0xBC     0xA2

重要なポイント: 先頭バイトの上位ビットが、後続バイト数を正確に示します。0 で始まれば単独の ASCII バイト。110 で始まれば「あと 1 バイト続く」。1110 は「あと 2 バイト」。11110 は「あと 3 バイト」。継続バイトは必ず 10 で始まります。

なぜ CJK は 3 バイト、絵文字は 4 バイトなのか

よくある疑問: なぜ日中韓の漢字は UTF-8 で 3 バイト必要なのか? 答えはコードポイントの配置にあります:

文字種範囲UTF-8 バイト数
ASCII / 基本ラテンU+0000..U+007F1 バイトA = 0x41
ラテン拡張 / キリルU+0080..U+07FF2 バイトé = 0xC3 0xA9
CJK 統合漢字U+4E00..U+9FFF3 バイト漢 = 0xE6 0xBC 0xA2
絵文字 / SMPU+10000..U+10FFFF4 バイト🌍 = 0xF0 0x9F 0x8C 0x8D

CJK 統合漢字ブロックは U+4E00 から U+9FFF まで(2 万文字以上)あり、完全に 3 バイトゾーンに収まります。つまり、中国語や日本語のテキストは UTF-8 では、GB2312 や Shift_JIS(1 文字 2 バイト)と比べて約 50% サイズが大きくなります。

絵文字は追加多言語面(U+1F000 以降)にあるため、4 バイト必要です。🌍(U+1F30D)1 つで UTF-8 は 4 バイト、UTF-32 も 4 バイト、UTF-16 ではサロゲートペアとして 4 バイト(2 つの 16 ビットユニット)です。

自己同期: UTF-8 最大の強み

UTF-8 には注目すべき特性があります: バイト列の任意の位置にジャンプしても、そこが文字の先頭か途中かを即座に判定できます。これを自己同期(self-synchronization)と呼びます。

ルールは単純です: バイトが 0 で始まれば 1 バイト文字。10 で始まれば継続バイトなので、先頭バイトを見つけるまで後退。11 で始まればマルチバイト列の先頭バイトです。

バイトパターン    意味
0xxxxxxx        1バイト文字(ASCII)
10xxxxxx        継続バイト(開始位置にはならない)
110xxxxx        2バイト列の先頭
1110xxxx        3バイト列の先頭
11110xxx        4バイト列の先頭

この設計により、1 バイトが破損・欠落しても壊れるのは最大 1 文字だけです。デコーダは次の先頭バイトで再同期できます。Shift_JIS と比較すると、Shift_JIS では 1 バイトの欠落が後続の全文字を誤解釈させる可能性があります(「文字化けの連鎖」問題)。

もう一つの利点: ファイルパスの / のような ASCII 部分文字列を単純なバイト比較で検索でき、マルチバイト文字の内部で誤マッチする危険がありません。Shift_JIS ではこれが不可能です — 後続バイトが偶然 ASCII バイト値と一致することがあるためです(有名な 0x5C バックスラッシュ問題)。

UTF-8 と UTF-16 のサイズ比較

どちらのエンコーディングが効率的かは、コンテンツの内容次第です:

コンテンツ種別UTF-8 バイト数UTF-16 バイト数有利な方
ASCII テキスト(英語コード)1/文字2/文字UTF-8(50% 小さい)
ヨーロッパ語テキスト1-2/文字2/文字UTF-8(やや小さい)
CJK テキスト(中国語/日本語)3/文字2/文字UTF-16(33% 小さい)
絵文字多めテキスト4/文字4/文字(サロゲート)同等
混在(HTML + CJK)~2.2 平均2/文字(+ BOM)ほぼ同等

Web コンテンツでは UTF-8 がほぼ常に有利です。HTML マークアップ、CSS、JavaScript、URL は ASCII が大部分を占めるためです。日本語の Web ページでも、マークアップの ASCII 部分が多いため、UTF-8 のサイズは UTF-16 と同等かそれ以下になります。WHATWG HTML 仕様が UTF-8 をデフォルトエンコーディングに定めているのもこの理由です。

UTF-16 は CJK 中心のテキストのメモリ内文字列処理では優位性を保ちます(Java、JavaScript、Windows が 1990 年代に内部文字列形式として採用した理由)。しかし保存やネットワーク転送では UTF-8 が事実上の標準となり、2024 年時点で Web ページの 98% 以上が UTF-8 を使用しています。

// Node.js でのサイズ比較:
const text = "漢字とASCII mixed テキスト";
console.log(Buffer.byteLength(text, "utf8"));    // 39 バイト
console.log(Buffer.byteLength(text, "utf16le")); // 36 バイト
// CJK 混在テキストでは UTF-16 がやや有利

const code = "function hello() { return 42; }";
console.log(Buffer.byteLength(code, "utf8"));    // 31 バイト
console.log(Buffer.byteLength(code, "utf16le")); // 62 バイト
// ASCII では UTF-8 が圧勝

関連記事