文字コードとUnicodeの内部(符号化と正規化)
なぜ絵文字で文字数がずれ、見た目が同じ文字列が==で不一致になるのか。コードポイント・UTF-8/16・サロゲート・書記素・正規化の内部を押さえ、文字化けと比較バグを根本から防げる。
- 1.Unicodeは文字に番号(コードポイント U+0000〜U+10FFFF)を振る規格で、それをバイト列にする方式がUTF-8/16/32。UTF-8は1〜4バイトの可変長、UTF-16はBMP外をサロゲートペア(2個の16ビット)で表す。
- 2.「見た目1文字」はコードポイント1個とは限らない。é は U+00E9 か e+結合文字の2個で表せ、絵文字や国旗は複数コードポイントの書記素クラスタになる。だから length は人間の文字数と一致しない。
- 3.NFC/NFDは合成・分解で表現を一意化する正規化。比較・検索・キー化の前に正規化を揃えないと、同じに見える文字列が不一致になる。照合(collation)は言語依存の並び順を別レイヤで決める。
文字・コードポイント・バイト列の三層
文字コードの混乱は、「文字」を一枚岩で捉えることから生まれます。Unicodeは概念を3層に分けます。第一に抽象文字(人間が認識する「あ」「A」「é」)。第二にコードポイント——各文字に振られた一意の整数で、U+0041(A)のように書きます。範囲は U+0000 から U+10FFFF までで、約111万個分の空間があります。第三に符号化形式(encoding)——コードポイントを実際のバイト列に落とす規則で、ここがUTF-8/16/32の役割です。
つまり「文字 → 番号 → バイト」という写像が二段あり、文字コードの不具合はたいていこの境界で起きます。番号を割り当てるのがUnicode規格、番号をバイトにするのが符号化方式、と切り分けるのが理解の起点です。文字列が結局はバイト列であることは「変数とデータ型」の参照とコピーの話とも地続きです。
コードポイント空間は65536個ずつの17の「平面」に区切られます。最初の U+0000〜U+FFFF が**基本多言語面(BMP)**で、ラテン・キリル・漢字・かな等の主要文字が収まります。BMPの外(U+10000 以上)には絵文字や歴史的文字、追加漢字があり、ここが後述のサロゲートで効いてきます。
UTF-8/16/32の符号化原理
同じコードポイントでも、バイトへの詰め方が異なります。三方式の設計思想を区別するのが要点です。
| 方式 | 符号単位 | 1文字のバイト数 | 特徴 |
|---|---|---|---|
| UTF-8 | 8ビット | 1〜4バイト(可変長) | ASCIIと後方互換。Web標準。自己同期性あり |
| UTF-16 | 16ビット | 2または4バイト | BMPは2バイト、外はサロゲートペアで4バイト |
| UTF-32 | 32ビット | 常に4バイト(固定長) | 添字アクセスが O(1)。空間効率は最悪 |
UTF-8はコードポイントの大きさで1〜4バイトを使い分けます。先頭バイトのビットパターンが長さを示し、後続バイトはすべて 10xxxxxx で始まります。
U+0041 'A' → 0xxxxxxx : 41 (1バイト)
U+00E9 'é' → 110xxxxx 10xxxxxx : C3 A9 (2バイト)
U+3042 'あ' → 1110xxxx 10xxxxxx 10xxxxxx : E3 81 82 (3バイト)
U+1F600 '😀'→ 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx : F0 9F 98 80 (4バイト)
この設計には2つの利点があります。ひとつはASCII互換——U+0000〜U+007F は1バイトでそのままASCIIと一致するため、既存のASCIIテキストはすべて妥当なUTF-8です。もうひとつは自己同期性——後続バイトが必ず 10 で始まるので、バイト列の途中から読み始めても文字境界を即座に見つけられます。壊れたバイトが混ざっても、その1文字だけ捨てて復帰できます。
UTF-16はBMPを2バイトで表し、BMP外を後述のサロゲートペアで4バイトにします。JavaScriptの文字列、Java/C#のchar、Windows APIの内部表現はUTF-16です。UTF-32は全コードポイントを4バイト固定で表し、i 番目のコードポイントへ定数時間でアクセスできますが、英数字中心のテキストではUTF-8の4倍の容量を食います。
Web(HTML/JSON)・Linux・多くのDBがUTF-8を既定とするのは、ASCII互換で容量効率がよく、バイトオーダー(エンディアン)の曖昧さがないためです。UTF-16/32はバイト順がLE/BEで割れ、BOM(先頭の FE FF 等)で区別する必要があります。新規設計でUTF-8以外を選ぶ積極的理由はほぼありません。
サロゲートペア——UTF-16がBMP外を表す仕掛け
UTF-16の符号単位は16ビット=65536通りしかなく、BMP外(U+10000 以上)を直接は表せません。そこでUnicodeは U+D800〜U+DFFF の2048個をサロゲート専用領域として予約し、ここには文字を割り当てません。BMP外の1文字は、上位サロゲート(U+D800〜U+DBFF)と下位サロゲート(U+DC00〜U+DFFF)の2個1組で表します。
U+1F600 '😀' を UTF-16 にする手順
1. 0x10000 を引く : 1F600 - 10000 = 0F600
2. 20ビットを上位10/下位10に: 0000111101 1000000000
3. 上位 + 0xD800 : D83D
4. 下位 + 0xDC00 : DE00
結果(サロゲートペア) : D83D DE00
この仕組みのため、UTF-16ベースの言語では**「1文字」が符号単位1個とは限りません**。JavaScriptの "😀".length が 2 を返すのはこのためで、charAt や添字 [i] はサロゲートの片割れを返してしまいます。正しくはコードポイント単位の反復(for...of や Array.from)を使います。
"😀".length // 2(UTF-16符号単位の数)
[..."😀"].length // 1(コードポイント単位)
"😀".codePointAt(0) // 128512(= 0x1F600)
サロゲートは必ずペアで現れるべきですが、文字列スライスでペアを途中で切ると孤立サロゲートが生じます。これは妥当なテキストではなく、UTF-8へ変換できません。文字列を機械的に固定長で切る処理は、サロゲート境界・後述の書記素境界の両方を壊しがちです。
書記素クラスタ——人間が数える「1文字」
コードポイント単位でも、まだ人間の「1文字」とはずれます。é は2通りで表せます。単一の U+00E9(合成済み)か、U+0065(e)+ U+0301(結合アクサンテギュ)の2コードポイントです。後者のように、基底文字と結合文字をまとめて1つの見える単位にしたものを**書記素クラスタ(grapheme cluster)**と呼びます。
絵文字はさらに複雑です。国旗 🇯🇵 は2個の地域指示子の組、肌色付き絵文字は絵文字+肌色修飾子、家族絵文字は複数の人物絵文字を U+200D(ZWJ, ゼロ幅接合子)で連結したZWJシーケンスで、コードポイント数個が1書記素になります。
| 数え方 | 対象 | 「👨👩👧」の数 |
|---|---|---|
| バイト数 | 符号化後のバイト | UTF-8で18バイト |
| 符号単位数 | UTF-16の16ビット単位 | 8(length が返す値) |
| コードポイント数 | U+ 単位 | 5(人物3+ZWJ2) |
| 書記素クラスタ数 | 人が見る1文字 | 1 |
「文字数を数える」「N文字で切る」といった処理が国際化で壊れるのは、この4つの「数」を混同するからです。表示・カーソル移動・切り詰めは書記素クラスタ単位で行う必要があり、UAX #29(テキスト境界)が境界判定規則を定めます。実装にはICUや各言語のセグメンテーションAPI(JavaScriptの Intl.Segmenter 等)を使います。「正規表現」の .(任意の1文字)も多くのエンジンでは書記素ではなくコードポイント単位で動くため、絵文字1個に複数回マッチしうる点に注意が要ります。
正規化(NFC/NFD)——表現の一意化
é の2表現は見た目もコードポイント列も意味も等価ですが、バイト列としては別物です。そのまま == で比較すると不一致になります。これを揃えるのが**正規化(normalization)**で、Unicodeは4形式を定めます。
| 形式 | 向き | 内容 |
|---|---|---|
| NFD | 分解 | 正準分解。é → e + ́(結合文字へ展開) |
| NFC | 合成 | 正準分解した後に再合成。é → 単一の U+00E9 |
| NFKD | 互換分解 | 見た目の差を潰す分解。① → 1、fi → f+i など |
| NFKC | 互換合成 | 互換分解後に再合成。検索の正規化向き |
軸は2つです。正準(canonical)か互換(compatibility, K付き)か——正準は「真に同じ文字」だけを等価とし、互換は「全角①と半角1」「合字fiとfi」のような見た目違いも同一視します(情報が落ちるので不可逆)。もう一方は分解(D)か合成(C)か。一般的な保存・比較にはNFCが推奨され、Webやファイル名で広く使われます。検索インデックスのキー化など見た目差を無視したい場面でNFKCを使います。
正準分解では、結合文字の並びを正準結合クラスに基づいて一意な順序へ並べ替えます(例えば下に付く点と上に付くアクセントの順序を固定)。これにより、同じ見た目を生む結合文字の順列がすべて同一のバイト列へ収束します。
比較・等値判定・ハッシュキー・ユニーク制約・パスワード照合・ファイル名一致は、すべて正規化形を揃えてから行う必要があります。揃えないと、ユーザーには同一に見える文字列が「別物」となり、ログインできない・重複登録できてしまう等の不具合が出ます。境界(DBへ保存する直前など)で一律にNFC化するのが定石です。
正規化はハッシュキーの安定性にも直結します。文字列をキーにする際、同じ見た目で異なるバイト列があるとハッシュ値が割れる——この問題意識は「ハッシュ関数の設計と均一性」の入力前処理と同じ発想です。
照合(collation)——「等しいか」と「どの順か」は別問題
正規化が「等価判定」を扱うのに対し、**照合(collation)**は「並び順」を扱う別レイヤです。コードポイントの数値順(バイナリ順)に並べると、Z(U+005A)が a(U+0061)より前に来る、ひらがなより漢字が散らばる、といった人間の直感に反する順序になります。
Unicode照合アルゴリズム(UCA, UTS #10)は、各文字に多段の照合重みを与えます。第1レベルが文字の素性(a/b/c…)、第2レベルがアクセント差、第3レベルが大文字小文字差、という具合に段階比較し、まず第1レベルで比べ、同じなら次のレベルへ進みます。これにより「大文字小文字を無視」「アクセントを無視」といった強さ(strength)の調整が可能になります。
さらに照合はロケール依存です。ドイツ語では ä を a の近くに、スウェーデン語では ä をアルファベット末尾に置きます。同じUnicode文字列でも言語によって正しい順序が違うため、ソートには必ずロケールを指定します。文字列照合のアルゴリズム自体(部分一致探索)は「文字列照合アルゴリズム」で扱う別の問題で、ここでの照合は「順序づけ」を指します。
バイナリ順(コードポイント値): apple, Banana, cherry, Äpfel
ドイツ語ロケールのUCA順: Äpfel, apple, Banana, cherry
「コードポイントとエンコーディングは別層」「UTF-8は可変長1〜4バイトでASCII互換、UTF-16はサロゲートでBMP外を表す」「length≠コードポイント数≠書記素数」「比較前にNFC正規化、並び順はロケール依存のUCA」——この4点は国際化・文字処理の定番です。
まとめ
文字コードは「抽象文字 → コードポイント → バイト列」の三層で捉えるのが要です。コードポイント(U+0000〜U+10FFFF)を符号化するのがUTF-8/16/32で、UTF-8は1〜4バイトの可変長・ASCII互換・自己同期、UTF-16はサロゲートペアでBMP外を表します。「1文字」はバイト・符号単位・コードポイント・書記素クラスタで数が食い違い、表示や切り詰めは書記素単位で行います。等価判定の前にはNFC(見た目差まで無視するならNFKC)で正規化を揃え、並び順はロケール依存のUnicode照合アルゴリズムで決めます。文字化けと比較バグは気のせいではなく、どの層を扱っているかの取り違えなのです。
プログラミング Article
文字コードとUnicodeの内部(符号化と正規化)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
Unicode
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
「見た目1文字」はコードポイント1個とは限らない。é は U+00E9 か e+結合文字の2個で表せ、絵文字や国旗は複数コードポイントの書記素クラスタになる。だから length は人間の文字数と一致しない。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「Unicode / 文字コード」に近いか確認する。
- 強みである「Unicodeは文字に番号(コードポイント U+0000〜U+10FFFF)を振る規格で、それをバイト列にする方式がUTF-8/16/32。UTF-8は1〜4バイトの可変長、UTF-16はBMP外をサロゲートペア(2個の16ビット)で表す。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。