Unicode

世界中の文字にコードポイントを割り当てる規格。コードポイントとコードユニットと書記素クラスターの 3 層、サロゲートペア、正規化と辞書順の落とし穴を解説

テキスト処理Web
「Unicode」の技術書を見る →

Unicode とは

Unicode は、世界中の文字 (ラテン文字、漢字、アラビア文字、絵文字など) に一意の番号を割り当てる規格である。この番号がコードポイントで、'A' は U+0041 に対応する。Unicode Consortium が管理し、ほぼ年 1 回の間隔で版が上がる。2025 年に公開された 17.0 では 4,803 文字が追加され、収録は合計 159,801 文字になった。

文字の扱いで事故が起きるのは、たいてい次の 3 つの層を混同したときである。コードポイントは文字に振られた番号、コードユニットはそれをバイト列に写したときの最小単位、書記素クラスターは人が 1 文字と見なす見た目のまとまりを指す。半角英数字ならこの 3 つはすべて 1 で一致するが、絵文字や結合文字が混ざると数が食い違い、文字数の数え方も切り詰め方も変わる。

エンコーディング

Unicode が定めるのはコードポイントの割り当てまでで、それを実際のバイト列へ写す規則がエンコーディングである。同じ文字でも規則が違えばバイト数も並び順も変わる。

エンコーディングバイト数特徴用途
UTF-81〜4 バイト (可変長)ASCII 互換、Web の標準HTML, JSON, ファイル
UTF-162 or 4 バイトJavaScript の内部表現JavaScript, Java, Windows
UTF-324 バイト (固定長)処理が単純だがメモリ消費大内部処理

Web では UTF-8 が事実上の標準だ。HTML の <meta charset="UTF-8"> で指定する。手元の Node.js で UTF-8 のバイト数を測ると、A が 1 バイト、あ が 3 バイト、😀 が 4 バイトだった。日本語だけのテキストなら UTF-16 の方が短くなる場合もあるが、それでも UTF-8 が選ばれるのは ASCII 互換で既存の処理系を壊さないためである。

UTF-16 で 2 バイトに収まらないのは U+FFFF を超える文字である。この範囲は上位 (U+D800〜U+DBFF) と下位 (U+DC00〜U+DFFF) の 2 つのコードユニットの組で表す。これがサロゲートペアで、😀 は U+1F600 という 1 つのコードポイントなのに 0xD83D と 0xDE00 の 2 単位になる。上位 / 下位に使う範囲には文字が割り当てられていないため、組を崩さない限り通常の文字と衝突しない。逆に組の片方だけを取り出すと、どの文字にも対応しない値が残る。

JavaScript での Unicode

JavaScript の文字列は UTF-16 のコードユニット列として扱われる。length が返すのはコードユニット数であって文字数ではないため、サロゲートペアや結合文字が混ざると見た目の数と合わなくなる。以下は Node.js v26 で実際に確かめた値である。

// 基本多言語面 (BMP) の文字: 1 つの UTF-16 コードユニット
'A'.length;    // 1
'あ'.length;   // 1

// 絵文字: サロゲートペア (2 つの UTF-16 コードユニット)
'😀'.length;   // 2
'👨‍👩‍👧‍👦'.length; // 11 (絵文字 4 つ + 連結用の ZWJ 3 つ)

// スプレッド構文はコードポイント単位まで戻す
[...'😀'].length;      // 1
[...'👨‍👩‍👧‍👦'].length; // 7 (見た目の 1 文字にはならない)

// 見た目の 1 文字で数えるなら書記素クラスタで区切る
[...new Intl.Segmenter().segment('👨‍👩‍👧‍👦')].length; // 1

家族の絵文字が示すのは、コードポイント単位に直しても人の感覚には届かないという点である。Intl.Segmenter は既定の粒度が書記素クラスターで、segment の戻り値は反復可能なオブジェクトなので、件数が欲しければ配列に展開する。MDN の互換性情報では 2024 年 4 月に主要ブラウザーで揃った機能として扱われている (2026 年 8 月時点)。入力欄の残り文字数や一覧の省略表示のように人が数える場面ではこれを使い、保存できる長さの見積もりのように機械の都合で数える場面はコードユニットやバイト数で数える。層を選び分けることが肝心である。

よくあるバグ

文字列の切り詰め

// サロゲートペアの途中で切れる例
'Hello 😀 World'.slice(0, 7); // "Hello \ud83d" (どの文字にも対応しない値が残る)

// コードポイント単位に分割してから切る
[...'Hello 😀 World'].slice(0, 7).join(''); // "Hello 😀"

切り詰めが残した上位サロゲートは、表示が崩れるだけでなく、そのまま保存や送信をすると受け取り側で不正なバイト列として拒否されることがある。文字数の上限を課す処理では、上限の単位をどの層で数えるか先に決める。データベースの桁数に合わせるならバイト数、利用者への見え方に合わせるなら書記素クラスターが近い。

正規表現

// u フラグ付き: . が 1 つのコードポイントに対応する
/^.$/u.test('😀');  // true

// u フラグなし: . はコードユニット 1 つ分しか見ない
/^.$/.test('😀');   // false

u (Unicode) フラグを付けないと、正規表現は UTF-16 コードユニット単位で動作する。ES2024 で加わった v フラグは u の上位互換で、文字集合の差集合や積集合が書けるほか、複数のコードポイントからなる絵文字も 1 つのまとまりとして扱える。手元では /^\p{RGI_Emoji}$/v が家族の絵文字にも単体の絵文字にも一致した。ただし . は v でもコードポイント単位のままなので、書記素クラスターを丸ごと拾いたい場合はこうした集合指定か Intl.Segmenter を使う。

正規化 (NFC / NFD)

同じ文字が異なるコードポイントの組み合わせで表現される場合がある。

const a = 'é';           // U+00E9 (合成済み)
const b = 'e\u0301';     // U+0065 + U+0301 (分解形)
a === b;                  // false (!)
a.normalize('NFC') === b.normalize('NFC'); // true

合成済みの 1 文字は length が 1、分解形は 2 になり、=== での比較は false になる。同じ形に正規化すれば一致する。NFC は合成する方向、NFD は分解する方向で、システム全体でどちらかに揃っていれば実務上は困らない。ファイル名は環境によって分解形で保存されることがあり、画面上は同じ名前なのに一致しないという不具合の典型的な原因になる。ファイル名や利用者の入力を比較 / 重複判定するときは、比較の直前に片方へ揃える。

互換分解を伴う NFKC / NFKD は畳む範囲がずっと広く、全角英数字を半角に、丸囲み数字を数字に、㍿ を「株式会社」にまで変換する。検索キーや突き合わせ用のキーを作るには有効だが、元の表記は復元できないため、表示用の値をこれで上書きしてはいけない。

辞書順ソートが期待と合わない理由

多くのデータストアは文字列を UTF-8 のバイト列として比較する。UTF-8 はバイト順とコードポイント順が一致するよう設計されているので、この比較結果はコードポイント順と同じになる。問題は、コードポイント順が人の期待する並びではないことである。ひらがなとカタカナは別の範囲に置かれているので同じ読みでも混ざらず、漢字の並びは読みと無関係になる。

UTF-16 のコードユニット順はコードポイント順とすら一致しない。サロゲートに使う U+D800 以降の範囲が U+E000 以降の文字より手前にあるため、U+FFFF を超える文字が U+FFFF に近い文字より小さいと判定される。手元でも '\u{10000}' < '\uffff' が true になった。JavaScript の Array.prototype.sort は既定でこの比較を行うので、絵文字や補助面の漢字を含む配列の並びは素朴な予想から外れる。

人が納得する並びが必要なら、照合順序を扱う仕組みに任せる。JavaScript では Intl.Collator('ja') が使える。['さくら', 'ざくろ', 'サクラ'] を並べると、既定の比較では「さくら」「ざくろ」「サクラ」の順、Intl.Collator('ja') では「さくら」「サクラ」「ざくろ」の順になった。後者は仮名の種類を無視して読みで並べている。データストア側で並べたいなら、読みを別項目として保存しそれをソートキーにするのが確実である。バイト順のまま日本語の索引を作ると、後から並び順の要件が出たときに作り直しになる。

共有:

この記事は役に立ちましたか?

関連用語

関連する記事