- はじめに — 「一文字」という錯覚
- 三つのレイヤー — バイト、コードポイント、書記素
- なぜ "👨👩👧".length は嘘をつくのか
- UTF-8 vs UTF-16 vs UTF-32
- サロゲートペア — UTF-16の原罪
- 正規化 — 同じに見えるのに違う é
- macOSとLinuxの é 戦争 — 韓国語が特に危ない
- 絵文字とZWJ — 一文字を組み立てる方法
- 文字列反転 — 最も有名な落とし穴
- 実務チェックリスト
- おわりに
- 参考資料
はじめに — 「一文字」という錯覚
プログラミングを始めると、私たちは文字列を「文字の並び」だと習います。そしてたいていは、そのモデルでうまくやっていけます。英語圏でASCIIだけを扱っていた時代には、それは事実でした。一文字は1バイトで、文字列の長さはそのまま文字数で、文字列を反転するのは配列を反転するのと同じでした。
ところが、私たちが扱うテキストは英語だけではありません。韓国語、日本語、絵文字、結合アクセント、右から左へ書く文字が入ってきた瞬間、「一文字」という単純なモデルは崩れます。しかもこの崩壊は静かに起こります。コンパイルエラーも出ず、ほとんどのテストも通ります。そしてユーザーが絵文字混じりの名前を入力したり、韓国語ユーザーがmacOSで作ったファイル名をLinuxで検索したりした瞬間、突然すべてがずれます。
この記事は、その地雷原を地図に描きます。バイトとコードポイントと書記素クラスタがどう別々のレイヤーなのか、なぜ"👨👩👧".lengthが嘘をつくのか、UTF-8とUTF-16の違いがなぜ実務のバグになるのかを一つずつ押さえます。とりわけ韓国語と日本語を扱う私たちにとって、これは他人事ではありません。
三つのレイヤー — バイト、コードポイント、書記素
まず身につけるべきは、テキストが一つのレイヤーではなく三つのレイヤーだという事実です。ほとんどすべてのUnicodeバグは、この三つのレイヤーを混同するところから始まります。
- バイト(byte): 保存と転送の単位。ファイルやネットワークに実際に流れるもの。エンコーディング(UTF-8など)がコードポイントをバイトに変換します。
- コードポイント(code point): Unicodeが各文字に付けた番号。
U+AC00(가)、U+1F600(😀)のように表記します。Unicodeはこの番号の巨大な辞書です。 - 書記素クラスタ(grapheme cluster): 人が「一文字」と認識する単位。これが複数のコードポイントから成りうる点が落とし穴の核心です。
たとえば家族を表す絵文字を見てみましょう。人の目には一文字です。しかしその裏には複数のコードポイントがあり、そのコードポイントはさらに複数のバイトへエンコードされます。
人が見るもの: 👨👩👧 (一書記素 = 「一文字」)
│
コードポイント: U+1F468 U+200D U+1F469 U+200D U+1F467 (5個)
│
UTF-8バイト: 11バイト
三つのレイヤーの個数がすべて違います。書記素は1個、コードポイントは5個、バイトは11個です。ところがほとんどの言語で、.lengthはこの三つのどれも正確には数えません。
なぜ "👨👩👧".length は嘘をつくのか
JavaScriptで次を実行してみましょう。
"👨👩👧".length // 8
[..."👨👩👧"].length // 5
.lengthが8を返します。書記素は1個、コードポイントは5個なのに、なぜ8なのでしょうか。JavaScriptの文字列の.lengthはUTF-16コードユニットの数を数えます。書記素でもコードポイントでもありません。この絵文字の5個のコードポイントのうち3個(人物の絵文字)はUTF-16でそれぞれ2個のコードユニット(サロゲートペア)で表現され、ZWJ2個はそれぞれ1個なので、3×2 + 2×1 = 8になります。
つまり.lengthが数えているのは「人が見る文字数」でも「Unicode文字数」でもなく、内側のエンコーディングの実装詳細です。スプレッド演算子[...str]やfor...ofはコードポイント単位で走査するので5を返します。そして人が期待する「1」を得るには、書記素クラスタの分割器(Intl.Segmenterなど)が必要です。
const seg = new Intl.Segmenter("ja", { granularity: "grapheme" });
[...seg.segment("👨👩👧")].length // 1
ここから得られる教訓は明快です。「文字列の長さ」という問いには答えが一つではありません。保存に必要なバイト数なのか、コードポイント数なのか、ユーザーが数える文字数なのかを、まず決めなければなりません。ツイートの文字数制限、入力欄の最大長、カーソル移動といったUIロジックでこの区別を見落とすと、必ずバグが出ます。
UTF-8 vs UTF-16 vs UTF-32
Unicodeは「番号簿」でしかなく、その番号を実際のバイトへどう変換するかがエンコーディングです。代表的な三つを比較しましょう。
| エンコーディング | コードユニットのサイズ | 文字あたりのバイト | 特徴 |
|---|---|---|---|
| UTF-8 | 8ビット | 1〜4バイト(可変) | ASCII互換、Web標準、空間効率がよい |
| UTF-16 | 16ビット | 2または4バイト | BMPは2バイト、それ以外はサロゲートペア |
| UTF-32 | 32ビット | 常に4バイト | 固定幅、単純だが空間を浪費 |
UTF-8は今日の事実上の標準です。ASCII範囲(0〜127)はそのまま1バイトにエンコードされるので、英語のテキストはASCIIと完全に同一です。その上のラテン拡張、韓国語、絵文字へ進むにつれ2、3、4バイトへ増えます。韓国語の一文字はUTF-8で3バイト、日本語もほとんどが3バイトです。だから韓国語・日本語のテキストは英語よりファイルが大きくなります。
UTF-16はJava(JVM)、JavaScript、Windows、C#の内部文字列表現です。基本多言語面(BMP、U+0000〜U+FFFF)の文字は2バイトで表現しますが、その外(絵文字の多く)は2個の16ビットユニット、すなわちサロゲートペアで表現します。先ほど見た.lengthの嘘の根源が、まさにこれです。
UTF-32はすべてのコードポイントを常に4バイトで表現します。インデックス参照がO(1)で単純になる利点がありますが、たいていのテキストでは空間を大きく浪費するので、保存・転送用にはほとんど使われません。
サロゲートペア — UTF-16の原罪
サロゲートペアをもう少し深く見る必要があります。Unicodeは当初、16ビットあればすべての文字を収められると考えていました(65,536文字)。しかしすぐに足りなくなり、コードポイント空間はU+10FFFFまで拡張されました。すでに16ビットユニットに縛られていたUTF-16は、この拡張された文字を何とかして表現しなければなりませんでした。
その解法がサロゲートペアです。U+D800〜U+DFFFの区間を「単独では文字ではなく、ペアを成して初めて意味を持つ」特殊領域として予約し、BMPの外の文字をこの区間の2ユニットの組み合わせで表現します。
😀 = U+1F600
│
UTF-16: 0xD83D 0xDE00 (ハイサロゲート + ローサロゲート)
│
「この二つが合わさって初めて 😀 が一つ」
問題は、この二つのユニットを誤って分割できてしまうことです。JavaScriptでstr.charAt(0)やstr[0]で絵文字の最初の一文字を切り出すと、半分だけのサロゲートが出てきて壊れた文字(たいてい�)になります。
const s = "😀";
s[0]; // '\uD83D' — 半分だけのサロゲート、壊れた文字
s.substring(0, 1); // 同様に壊れる
s.codePointAt(0); // 128512 — 正しいコードポイント
文字列を切ったりインデックス参照したりするとき、コードユニット境界ではなくコードポイント境界を尊重しなければならない理由がこれです。ユーザー名を20「文字」に切り詰める素朴なコードが絵文字を半分に割ってしまう事故はよくあります。
正規化 — 同じに見えるのに違う é
ここで、韓国語・日本語ユーザーにとりわけ重要な地雷を踏みます。正規化(normalization)です。
問題の出発点はこうです。Unicodeには同じ文字を表現する方法が複数ある場合が多いのです。フランス語のéを見てみましょう。
- 合成済み(NFC):
U+00E9— 「é」そのものである単一のコードポイント。 - 分解済み(NFD):
U+0065 U+0301— 「e」(U+0065)のあとに結合アクセント(U+0301)を付けたもの。
二つとも画面には同じくéと見えます。しかしバイトのレベルではまったく別のデータです。だからこういうことが起こります。
const a = "é"; // NFC: 1コードポイント
const b = "é"; // NFD: 2コードポイント
a === b; // false !
a.length; // 1
b.length; // 2
a.normalize() === b.normalize(); // true (両方ともNFCに正規化)
目には同じ文字なのに===比較が失敗します。データベースでユーザー名を検索したのに「確かにあるのに出てこない」という幽霊バグの、よくある正体がこれです。片方がNFCで、もう片方がNFDで保存されていると、文字列一致が失敗します。
Unicodeは四つの正規化形式を定義しています。
| 形式 | 名称 | 方式 |
|---|---|---|
| NFC | 正規合成 | 分解してから最大限に再合成(最も一般的な保存形式) |
| NFD | 正規分解 | 最大限に分解 |
| NFKC | 互換合成 | 互換文字まで正規化してから合成 |
| NFKD | 互換分解 | 互換文字まで正規化してから分解 |
実務の原則は単純です。受け取った文字列は、保存する前に一つの形式(ふつうはNFC)に正規化せよ。 そうすれば比較、検索、重複チェックが安定します。
macOSとLinuxの é 戦争 — 韓国語が特に危ない
正規化が他人事でないのは、OSごとに好む形式が違うからです。とりわけAppleのファイルシステムは歴史的にファイル名をNFDに近い形式で保存してきましたし、LinuxとWindowsはたいていNFCを使います。
この違いは韓国語で劇的に現れます。ハングルの「각」は二通りに表現できます。
"각" (NFC): U+AC01 (1コードポイント、合成済み音節)
"각" (NFD): U+1100 U+1161 U+11A8 (ㄱ+ㅏ+ㄱ、字母3個に分解)
macOSで「報告書_最終.hwp」のようなファイルを作って圧縮したりgitにコミットしたりしたあと、Linuxサーバーでそのファイルを名前で探すと出てこないことがあります。バイトが違うからです。韓国の開発者なら「Macで作ったzipをサーバーで展開したら韓国語のファイル名が壊れた、あるいは検索できない」という経験が一度はあるはずです。犯人はエンコーディングではなく正規化形式の不一致であることが多いのです。
日本語も同じく、濁点・半濁点の付いた仮名(が、ぱ など)が分解形と合成形を持ちうるので、同じ落とし穴にはまります。だからファイル名、ユーザー入力、検索キーを扱うときは、どの正規化形式に統一するかをチームの規則として決めておくのがよいでしょう。
絵文字とZWJ — 一文字を組み立てる方法
先ほど家族の絵文字が5個のコードポイントだと述べました。この組み立ての秘密がZWJ(Zero Width Joiner、U+200D)です。ZWJは目に見えない「接着剤」文字で、前後の絵文字を一つに合わせてレンダリングせよという信号です。
👨 (男性) + ZWJ + 👩 (女性) + ZWJ + 👧 (女の子)
= 👨👩👧 (レンダリングされると家族一つ)
フォントやプラットフォームがこのZWJシーケンスを理解すれば合成された絵文字一つを描き、理解しなければ人が三人並んで表示されます。だから同じテキストが端末ごとに違って見えることがあります。
これに肌の色の修飾子(Fitzpatrick modifier)、国旗(二つの地域表示文字の組み合わせ)、性別・職業の組み合わせまで加わると、一つの書記素がコードポイント五つ六つに増えることはよくあります。これらすべてが、ユーザーには「絵文字一個」です。文字列を扱うときにこの書記素クラスタを尊重しないと、絵文字を間違って切ったり、長さを間違って数えたり、カーソルを半分だけ動かしたりするバグが出ます。
文字列反転 — 最も有名な落とし穴
「文字列を反転せよ」はコーディング面接の定番です。素朴な答えはこうです。
function reverse(s) {
return s.split("").reverse().join("");
}
reverse("hello"); // "olleh" — うまくいく
ASCIIでは完璧です。しかしUnicodeが入ると崩れます。
reverse("😀"); // "\uDE00\uD83D" — サロゲートペアが反転して壊れる → �
reverse("é"); // アクセントが前の文字から外れる → "́e"
split("")はUTF-16コードユニット単位で分割するので、サロゲートペアを半分に割ります。結合文字の場合はアクセントが別の文字に付いてしまいます。コードポイント単位で処理すればサロゲートの問題は解けますが、結合文字とZWJ絵文字は依然として壊れます。本当に正しく反転するには、書記素クラスタ単位で分割しなければなりません。
function reverseGraphemes(s, locale = "ja") {
const seg = new Intl.Segmenter(locale, { granularity: "grapheme" });
return [...seg.segment(s)].map(x => x.segment).reverse().join("");
}
この例が与える教訓は、文字列反転そのものが重要だからではありません。「文字単位で処理する」という素朴な仮定が、いかに頻繁に、いかに静かに崩れるかを示しているからです。切り出し、桁数の勘定、カーソル移動、正規表現マッチングまで、同じ落とし穴があちこちに潜んでいます。
実務チェックリスト
ここまでの地雷原を実務の規則に圧縮すると次のようになります。
- エンコーディングはUTF-8に統一せよ。 ファイル、DB、HTTPヘッダ、ソースコードまですべてUTF-8なら、エンコーディングの混乱の半分が消えます。
- 長さの定義を先に決めよ。 バイトか、コードポイントか、書記素か。UIの文字数制限は書記素基準にしてこそユーザーの期待と合います。
- 入力は保存前に正規化せよ。 ふつうはNFCに。検索キーと比較対象も同じ形式に揃えます。
- 文字列をコードユニット単位で切るな。 絵文字と結合文字を半分に割らないために、コードポイント、理想的には書記素の境界を尊重します。
- ファイル名の正規化形式を疑え。 とりわけmacOSとLinuxをまたぐパイプライン、韓国語・日本語のファイル名で。
- テストに絵文字と結合文字を入れよ。 ASCIIだけでテストすると、これらのバグは決して捕まりません。
おわりに
テキストは「文字の並び」だという私たちの最初の直観は、英語とASCIIという狭い世界でのみ真でした。実際のテキストにはバイトとコードポイントと書記素という三つのレイヤーがあり、絵文字と結合文字と正規化がそのレイヤーを互いにずらします。.lengthが嘘をつき、同じに見えるéが違い、文字列反転が絵文字を壊すのは、すべてこのずれから来ます。
韓国語と日本語を扱う私たちにとって、これはとりわけ切実です。合成形と分解形のあいだ、macOSとLinuxのあいだで、同じ文字が違うバイトになる世界に住んでいるのですから。幸い、原理は単純です。三つのレイヤーを区別し、UTF-8に統一し、入力を正規化し、コードユニットではなく書記素を尊重すること。この四つの習慣だけで、テキストの地雷原のほとんどを安全に渡れます。
参考資料
- The Unicode Standard: https://www.unicode.org/versions/latest/
- UTF-8 and Unicode FAQ: https://www.unicode.org/faq/utf_bom.html
- Unicode Normalization Forms (UAX #15): https://www.unicode.org/reports/tr15/
- Unicode Text Segmentation (UAX #29): https://www.unicode.org/reports/tr29/
- The Absolute Minimum Every Software Developer Must Know About Unicode (Joel Spolsky): https://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/
- MDN: String.prototype.normalize(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize
현재 단락 (1/104)
プログラミングを始めると、私たちは文字列を「文字の並び」だと習います。そしてたいていは、そのモデルでうまくやっていけます。英語圏でASCIIだけを扱っていた時代には、それは事実でした。一文字は1バイト...