UnicodeとUTF-8のしくみ:文字化けの歴史から絵文字の「文字数」まで

ASCIIとShift_JISの時代の文字化け、1991年のUnicode誕生、ダイナーの紙マットで設計されたUTF-8が標準になった理由、そして今も開発者を悩ませる正規化と文字数の問題を解説します。

everytooleverywhereallatonce · 公開

メールを開いたら「縺薙s縺ォ縺。縺ッ」のような漢字と半角カナの羅列が並んでいた、という経験があれば、文字コードの問題にはもう出会っています。これは「こんにちは」をUTF-8で保存したバイト列を、Shift_JISとして読んだ結果です。コンピュータは文字ではなく数値を保存していて、その数値をどの表で文字に戻すかは読む側が決めます。書く側と読む側の表が違えば文字は化けます。UnicodeとUTF-8は、この表を世界で一つにそろえようとした試みで、30年ほどかけてほぼ成功しました。

ASCII:128個の番号から始まった

1963年に米国で定められたASCIIは7ビット、つまり128個の番号に英大文字・小文字、数字、記号、改行などの制御文字を割り当てました。Aは65(16進41)、改行は10(0A)です。今でも主要な文字コードの最初の128個はASCIIと同じです。

問題は1バイトが8ビットだったことから始まりました。残りの128個(0x80〜0xFF)を国や会社がそれぞれ勝手に埋めたのです。西欧はé、ñ、üを入れたISO 8859-1(Latin-1)を、ロシアやギリシャは自国の文字を入れました。日本ではJIS X 0201がこの領域に半角カナを置きました。同じ0xB1というバイトが、日本では「ア」、西欧では「±」でした。

Shift_JISと5C問題

漢字は256個には収まらないので、2バイトで1文字を表す方式が使われました。日本では文字集合JIS X 0208(当初はJIS C 6226、1978年)を、Windowsやかつての Mac OS はShift_JIS、UNIXはEUC-JPという異なる方式で符号化しました。同じ文章でもOSによってバイト列が違ったわけです。

Shift_JISには有名な落とし穴がありました。2バイト目に0x5Cが来ることがあり、0x5CはASCIIではバックスラッシュ()です。「表」(95 5C)や「ソ」(83 5C)、「能」(94 5C)を含む文字列をCプログラムやシェルが扱うと、後ろのバイトをエスケープ文字と誤解して次の文字を食べてしまい、「表示」が化けるといった事故が頻発しました。

また、JIS X 0201は0x5Cの位置に円記号¥を置いたので、日本語フォントではバックスラッシュが¥の形で描かれます。WindowsのパスがC:¥Usersと見えるのはその名残で、中身は今もバックスラッシュです。

変換表の違いも問題を起こしました。Shift_JISの0x8160「〜」は、JISの対応表ではU+301C(WAVE DASH)、Microsoftの対応表(CP932)ではU+FF5E(全角チルダ)に変換されます。同じ「〜」がシステム間で別の文字になる、いわゆる波ダッシュ問題です。「―」(0x815C)もU+2014とU+2015に分かれます。

こうした文字化けは日本で日常的だったため、「mojibake」という言葉がそのまま英語に入りました。

Unicode:すべての文字に番号を一つずつ

1980年代後半、ゼロックスのジョー・ベッカー、アップルのリー・コリンズとマーク・デイビスらが、各国の文字コードを一つにまとめる作業を始めました。Unicodeコンソーシアムは1991年1月にカリフォルニアで法人として設立され、同年10月にUnicode 1.0の第1巻が出版されました。

初期のUnicodeは「16ビット、65,536文字あれば現用の文字はすべて入る」という前提でした。Windows NTやJavaはこれを信じて、文字列を16ビット単位で設計しました。しかし漢字だけでも、人名用や歴史的な文献用を含めると数万字が足りません。日本語でよく問題になる「𠮷」(つちよし)もこの外側にあります。1996年のUnicode 2.0は空間をU+10FFFFまで、約111万に広げ、16ビットで固まったシステムのためにサロゲートペアを用意しました。U+D800〜DFFFの領域を空けておき、2つを組にして16ビットの外の1文字を表します。JavaScriptで“𠮷”.lengthが2になるのはこのためです。

Unicodeが決めるのは「文字と番号の対応」までです。番号をどうバイトにするかは別の話で、それがUTF-8、UTF-16、UTF-32です。

UTF-8:ダイナーの紙マットの上で

1992年9月、ベル研究所のケン・トンプソンとロブ・パイクは、X/Openが採決しようとしていたFSS-UTFという方式について意見を求められました。パイクが問題にしたのは、バイト列を途中から読んだとき、1文字未満の損失ですぐに文字の境界を見つけ直す能力がないことでした。その夜、ニュージャージーのダイナーで、トンプソンが紙のランチョンマットの上にビット配置を書きました。パイクの記録によれば、トンプソンはその夜のうちに符号化・復号のコードを書き、9月8日に提案をX/Openに送り、その週の終わりには二人のOS「Plan 9」全体がこの方式で動いていました。

規則は表一つに収まります。

コードポイント範囲 バイトの形 例
U+0000〜007F 0xxxxxxx A → 41
U+0080〜07FF 110xxxxx 10xxxxxx é → C3 A9
U+0800〜FFFF 1110xxxx 10xxxxxx 10xxxxxx あ → E3 81 82
U+10000〜10FFFF 11110xxx + 10xxxxxx×3 😀 → F0 9F 98 80

最初の案は31ビットまで、最大6バイトを認めていました。2003年のRFC 3629はUnicodeの上限U+10FFFFに合わせて最大4バイトに絞り、サロゲート領域をUTF-8で書くことを禁じました。実際にどの文字がどのバイトになるかはUTF-8バイトビューアで1文字ずつ確かめられます。

なぜUTF-8が勝ったのか

UTF-16が先にOSや言語に入ったのに、Webやファイル、ネットワークはUTF-8が取りました。理由は設計そのものにあります。

  • ASCIIと完全に互換。 英数字だけのASCIIファイルはそのまま正しいUTF-8で、既存ファイルの変換が要りませんでした。
  • 0x80未満のバイトは必ずASCII。 かなや漢字のバイトはすべて0x80以上なので、/や改行、NULを探す古いプログラムが文字を途中で切ることがありません。5C問題は構造的に起こりえません。
  • 自己同期する。 先頭バイトは11、続くバイトは10で始まるので、途中から読んでも、1バイト壊れても、次の文字から正しく読めます。
  • バイト順がない。 UTF-16はビッグエンディアンかリトルエンディアンかを決める必要があり、BOMで示すことが多いのですが、UTF-8はバイト列なのでその問題がありません。
  • バイト順の並べ替えがコードポイント順と一致する。

欠点もあります。Shift_JISで2バイトだったかなや漢字がUTF-8では3バイトになり、日本語の文章は約1.5倍に膨らみます。それでも互換性の利点が勝ちました。ブラウザが従うWHATWGのEncoding Standardは、新しいプロトコルと形式は「UTF-8だけを使わなければならない(must)」と定めています。

同じ文字、違うバイト:正規化

Unicodeでは、同じ見た目を複数の方法で書ける場合があります。「が」はU+304Cの1文字でも、「か」(U+304B)+結合用濁点(U+3099)の2文字でも書けます。éも同様です。

Unicode標準附属書#15は、これをそろえる4つの正規化形式を定めています。

  • NFC:合成できるものは合成した形。キーボード入力やWebの大半はこの形です。
  • NFD:すべて分解した形。
  • NFKC/NFKD:さらに①→1、㍿→株式会社、全角A→A、半角カ→カのような互換文字まで展開する形。検索エンジンやユーザー名の比較に使われます。

日本のユーザーがこの違いに一番出会うのはファイル名です。macOSの旧ファイルシステムHFS+はファイル名を分解形で保存しており、今でもMacで作ったZIPやアップロードファイルの名前が分解形で届くことがよくあります。これをWindowsで見ると「が」の濁点が分かれて見えたり、見た目は同じ名前なのに検索で見つからなかったりします。比較や保存の前にどちらかに正規化するのが解決策です(JavaScriptならstr.normalize(“NFC”))。

「文字数」は一つではない

「この文字列は何文字か」の答えは、何を数えるかで変わります。家族の絵文字👨‍👩‍👧で見てみます。

数える単位 値 どこで使われるか
UTF-8バイト 18 ファイルサイズ、DBのバイト制限、通信
UTF-16コードユニット 8 JavaScript・Javaのlength
コードポイント 5 Pythonのlen、Array.from
書記素クラスタ 1 人が見る1文字、カーソル移動

家族の絵文字は、男性・女性・女の子の絵文字の間にゼロ幅接合子(ZWJ、U+200D)を挟んだものなので、コードポイントは5つです。人が1文字と感じる単位はUnicode標準附属書#29が「拡張書記素クラスタ」として定義していて、ブラウザではIntl.Segmenterで数えられます。文字数カウントツールはこの方式を使っています。フォームとSNSとメッセージアプリで文字数が食い違うのは、たいていこの表の別の行を基準にしているからです。

今も起きるバグ

  • 絵文字が保存できない:MySQLのutf8(utf8mb3)は1文字3バイトまでしか保存できず、4バイトの絵文字や𠮷でエラーになります。utf8mb4を使います。
  • バイト数で切る:日本語は3バイト単位なので、任意のバイトで切ると最後の文字が�になります。
  • 片割れのサロゲート:JavaScriptのsubstringで絵文字や𠮷の真ん中を切ると\uD842だけが残ります。Unicodeエスケープ変換で見るとどこで切れたかが分かります。
  • ExcelとUTF-8のCSV:UTF-8のCSVをダブルクリックで開くと、ExcelがShift_JISと推測して文字化けすることがあります。先頭にBOM(EF BB BF)を付けて保存すると多くの場合解決します。逆にJSONやシェルスクリプトの先頭のBOMはエラーの原因になります。
  • HTMLの二重エスケープ:画面に「&」が見えたら、保存時と出力時に二回変換しています。どの文字をどこで変換すべきかはHTMLエンティティ変換で説明しています。
  • そっくりな文字:長音符ー(U+30FC)とダッシュ—(U+2014)とマイナス−(U+2212)は、検索でも計算でも別の文字です。特殊文字ピッカーでコードポイントを確かめて選ぶと防げます。

文字化けを見つけたときの確認手順

  1. 化け方を見ます。「縺薙s」のように「縺」「繧」と半角カナが並ぶなら、UTF-8をShift_JISとして読んだものです。「é」のようなラテン文字ならUTF-8をLatin-1として読んだものです。どちらも元のバイトは残っているので、UTF-8として読み直せば戻ります。
  2. �が出ているなら、すでに置換文字に変わっていて元に戻せません。元のファイルから取り直します。
  3. 見た目は正しいのに一致しないなら、正規化(NFC/NFD)、そっくりな文字、見えない空白(U+00A0、U+200B、全角スペース)を疑います。バイトビューアに両方を入れると、行数とバイトの違いがすぐに分かります。

出典