- UTF-16はASCII文字の表現にNULバイトと2バイト改行を含み、プログラムの基本的な前提を3つとも違反する
- UTF-8はASCII互換性によってこれらの前提を守り、既存のUnixツール群やC文字列処理とそのまま共存した
- 「UTF-8の親和性がよい」理由はUnicode実装の優劣ではなく、プログラムの暗黙の前提を崩さない設計にある

1. RubyでUTF-8が「なんとなく安定している」感覚の正体
RubyでCSVや設定ファイルを扱うとき、Shift_JISだとなんとなくバグが出やすい、UTF-16はなぜか硬い印象がある、一方でUTF-8は安定している――そういう感覚を持っていました。

長い間、「Rubyのエンコーディングサポートがよい」という漠然とした理解で片付けていましたが、これだと理由の説明になっていません。
この感覚の正体は、エンコーディング設計とプログラムが暗黙に持っている「前提」の一致・不一致でした。
2. プログラムはテキストについて「前提」を持っている

grep・sed・awk・head・sort といったUnixのテキスト処理ツールも、RubyやCで書かれたプログラムも、テキストを扱うとき次の前提に暗黙に依存しています1。
- ASCII範囲(
0x00〜0x7F)の文字は1バイトとしてそのまま現れる - NULバイト(
0x00)は文字列の終端か、通常のテキストには出てこない - 改行は
0x0Aの1バイトである
この前提が崩れると、ツールは黙って誤動作します。
エラーで止まってくれれば気づけますが、データが静かにずれるほうが実際の障害につながります。
Unixのpipeはただのバイトストリームなので、UTF-16のテキストも「流す」こと自体はできます。
問題は「流せるかどうか」ではなく、「途中のツールが正しく動くかどうか」です。
3. UTF-16がプログラムと相性が悪い理由

UTF-16LEでは、ASCII文字の A は 41 00、改行は 0A 00 として表現されます2。
これが先ほどの前提を3つとも違反します。
C文字列はNULバイク(0x00)で終端を検知する設計です。
UTF-16は A(U+0041)を 41 00 と表現するため、このNULが文字列の終端と誤解されます3。
先頭1文字で文字列が切れます。
head -n 1 や wc -l は改行文字 0A を目印に行を数えます。
しかし 0A 00 という並びでは 0A が改行として検出されて後続の 00 が余り、行数計算はすべてずれます4。grep でパターンを探すと、途中に現れるNULバイト 00 が割り込むため、ASCII文字のパターンも一致しません。
「pipeで渡せるが、パイプラインでは動かない」という状況がここから生まれます。
UTF-16がなんとなく「硬い」という印象の正体は、これかもしれません。
処理系の外でファイルを扱おうとするたびに、ひと手間かかる感じ。
理由がわかると、「ツール群と設計の前提が合っていない」というだけのことでした。
4. Shift_JISが抱える別の問題

Shift_JISにはNULバイトの問題はありません。
ただし、前提の違反は別の形で起きます。
Shift_JISの2バイト文字の後続バイトには、0x40〜0x7E と 0x80〜0xFC の値が使われます5。
この範囲には、バックスラッシュ \(0x5C)やスラッシュ /(0x2F)のバイト値が含まれます。
「ASCII範囲の文字は1バイトとしてそのまま現れる」という前提への違反です。
2バイト文字の内側にASCII文字と同じバイト値が現れるので、ツールが誤検出します。grep でパターンを検索するとき、2バイト文字の後続バイトがASCII文字として反応する。
エラーにならずデータが静かにずれる、というパターンです。
Rubyスクリプトで「なんとなくバグが出やすい」と感じていた原因のひとつはこれでした。
Rubyの問題ではなく、Shift_JISの設計とプログラムの前提がずれている問題です。
5. UTF-8の設計

UTF-8はプログラムの前提を維持するように設計されています6。
- ASCII文字(U+0000〜U+007F)は
0x00〜0x7Fの1バイトで表現される - マルチバイト文字の先頭バイトは
0xC0以上の値になる - マルチバイト文字の後続バイトは
0x80〜0xBFの範囲に収まる - NULバイト(
0x00)はU+0000のときだけ現れる
後続バイトの範囲が 0x80 以上に限定されているため、ASCII範囲(0x00〜0x7F)とは完全に分離されています。
Shift_JISが抱えていた「後続バイトにASCII文字が含まれる」問題が起きません。
NULバイトも通常のテキストに出てきません。
C文字列の終端誤認も起きません。
\n は 0A のまま、grep も awk も head も何も変えなくていい。
既存のASCIIテキストがそのままUTF-8として有効だということです7。
日本語を含むファイルでも、ASCII部分については既存のツールがそのまま動き続けます。
6. BOM付きUTF-8が同じ問題を再現する

UTF-8にはバイト順の問題がありません。
バイトは常に同じ順序で並ぶので、BOM(Byte Order Mark:バイトオーダーマーク)は本来不要です。
それでもWindowsのメモ帳などは、UTF-8ファイルの先頭に EF BB BF(BOM)を付けることがあります8。
これが前述と同じ構造の問題を再現します。
Unixのシェルスクリプトは1行目に #!/usr/bin/env ruby のようなshebangを書きます。
OSはこの行を読んでインタープリタを決めますが、先頭に EF BB BF があると # ではなく EF から始まるため、shebangと認識されません9。
スクリプトが黙って動かなくなります。
設定ファイルのキー名やCSVの1列目ヘッダーにBOMが混入すると、値が一致しなくなります。id というキーが \xEF\xBB\xBFid になっているので、プログラムからは別の文字列として見えます。
「プログラムの前提(先頭バイトは #)を崩す」という構造は、UTF-16の問題と同じです。
エラーで落ちてくれればまだよく、データが静かにずれたまま処理が続くのが実際の障害につながります。
7. プレーンテキストという思想

UTF-8がプログラムとの親和性がよい理由は、設計がUNIXの根底にある考え方と一致しているからです。
Unixは「小さな道具を組み合わせる」設計です10。ls・grep・sort・head はそれぞれ単機能で、pipeでつなぐことで複雑な処理を組み立てます。
各ツールはバイトストリームを読んで、バイトストリームを出します。
この組み合わせが成り立つのは、バイトストリームが「テキスト」として共通の前提を持つからです。
改行が 0A の1バイトであること、NULが終端であること、ASCIIが1バイトで現れること。
プレーンテキストは、その共通前提の集積です。
フォーマットに依存せず、特定のソフトウェアなしに読める。
バイト列として保存でき、どのツールも同じ入力として扱える。
UTF-8はこの思想と設計が一致しました。
「既存の世界を壊さない」という選択が、Unixのツール群とそのまま共存することを可能にした。
UTF-16とShift_JISは、それぞれ異なる形でその前提と衝突しました。
UTF-8がUTF-16よりプログラムとの親和性がよい理由は、「より優れたUnicode実装」だからではなく、プログラムが暗黙に依存している前提をそのまま守る設計だったからです。
Rubyで日本語テキストをUTF-8で扱うとき「なんとなく安定している」という感覚の正体は、ASCII互換性でした。
「Rubyのエンコーディングサポートがよい」という話ではなく、「設計上の前提がプログラムと揃っている」という話でした。
- POSIXの定義(IEEE Std 1003.1-2017 Section 3.206)では、1行を「ゼロ個以上の非改行文字と末尾の改行文字の列」と規定し、テキストファイルはこの行の集まりとして定義されている。Doug McIlroyは1978年のBell System Technical Journalにて「テキストストリームを汎用インターフェースとして扱うプログラムを書け」とUnix哲学の第3条として記した。 – Unix philosophy – Wikipedia
- UTF-16LE(リトルエンディアン)では16ビットの符号単位を下位バイトから先に格納する。ASCII文字 ‘A’(U+0041)の符号単位 0x0041 はバイト列 0x41 0x00 となり、改行 LF(U+000A)は 0x0A 0x00 となる。BMP(基本多言語面)内の文字はすべて2バイトで表現されるため、通常のASCIIテキストでも頻繁にNULバイトが現れる。 – UTF-16 – Wikipedia
- C言語の文字列はNULバイト(0x00)で終端されるというANSI C(ISO/IEC 9899)の仕様がある。IBMのドキュメントは「非Unicode対応アプリケーションは、UTF-16で表現された大文字 ‘A’(U+0041)のバイト列の先頭バイト 0x00 を、シングルバイトのASCII NUL文字と誤認することがある」と指摘している。 – Unicode character encoding – IBM Documentation
- POSIXの規定(Section 3.206)では、テキストの1行は「改行文字(0x0A)で終端される非改行文字の列」とされる。
wc -lはこの改行文字をカウントして行数を返し、head -n Nも改行を基準に行を切り出す。UTF-16では改行が0A 00の2バイトとなるため、0Aが単独で検出されてカウントがずれる。 – POSIX.1-2017 Definitions - Shift JIS Wikipediaによれば「2バイト文字の後続バイトに
0x40〜0x7Eの値が現れることで、Shift_JISの検出が困難になる。これらのコードはASCII文字と同じバイト値を使うためである」と説明されている。また、文字列検索は単純なバイト比較では実装できず、Shift_JIS専用のアルゴリズムが必要になる。 – Shift JIS – Wikipedia - RFC 3629(2003年11月策定)はUTF-8をインターネット標準(STD 63)として定義した文書で、「US-ASCIIのオクテット値はUTF-8でエンコードされた文字ストリームには他の箇所では現れない。これにより、US-ASCII値に基づいてパースするが他の値には透過的なファイルシステムやソフトウェア(例:Cライブラリのprintf()関数)との互換性が提供される」と記している。 – RFC 3629 – UTF-8, a transformation format of ISO 10646
- UTF-8はKen Thompson(UnixとC言語の共同開発者)がRob Pikeとともに1992年9月、ニュージャージー州のダイナーでプレースマットの上に設計した。当時Plan 9 OSで使っていたISO 10646のUTFエンコーディングがNULバイトを含む問題を持っていたため、既存のUnixツールを壊さないASCII互換性を根本的な設計目標として再設計された。翌週にはX/Openがこのエンコーディングを採択した。 – The History of UTF-8 as told by Rob Pike
- RFC 3629のSection 6では「UTF-8は1オクテットを符号単位とするため、バイト順の識別は不要であり、BOMは常にオクテット列 EF BB BF として現れる」と明記されている。Unicode標準はUTF-8でのBOM使用を許可しているが、必須でも推奨でもない。UTF-8 FAQは「UTF-8は常に同じバイト順を持つため、BOMはエンコーディングの識別にしか役立たない」と説明している。 – RFC 3629 – UTF-8, a transformation format of ISO 10646
- Shebang (Unix) – Wikipediaによれば「
exec関数がshebangの前に0xEF 0xBB 0xBF(UTF-8 BOM)バイトを検出した場合、スクリプトインタープリタの実行が妨げられる。POSIX(Unix系)スクリプトでのBOM使用は、この理由と相互運用性・設計上の観点から推奨されない」と説明されている。 – Shebang (Unix) – Wikipedia - Doug McIlroyは1978年のBell System Technical Journalにて、Unix哲学を「一つのことをうまくやるプログラムを書け。プログラムの出力が、まだ知らぬ別のプログラムの入力になることを前提にせよ。テキストストリームを扱うプログラムを書け、それが普遍的なインターフェースだから」と記した。McIlroyはBell Labsの計算科学研究センター長であり、Unixのpipeの発明者でもある。 – Unix philosophy – Wikipedia