UTF-8は優れた設計
(iamvishnu.com)- UTF-8は数百万の文字を表現しながら、ASCIIとの下位互換性を維持する可変長エンコーディング方式
- ASCIIと同じ7ビット領域(
U+0000〜U+007F)は1バイトのまま使うため、ASCIIファイルはそのまま有効なUTF-8ファイルになる - それ以外の文字は2〜4バイト列で表現され、先頭バイトのビットパターンが長さを定義し、後続バイトは
10で始まることで継続バイトであることを区別する - この設計のおかげで、UTF-8は汎用的な文字集合を扱いながらも既存のASCIIシステムと完全に互換し、最も広く使われる文字エンコーディングになった
- UTF-16、UTF-32のような他のUnicodeエンコーディングは、このようなASCII互換性を提供しない
UTF-8設計の卓越性
- UTF-8エンコーディングに初めて触れたとき、異なる言語や文字の数百万種類の文字をひとつの体系で包含しながら、既存のASCIIと互換性のある構造を持っていることに強い印象を受けた
- 基本的にUTF-8は最大32ビットを活用するが、ASCIIは7ビットしか使わない
- UTF-8の設計原則は次のとおり
- すべてのASCIIエンコード済みファイルは有効なUTF-8ファイルである
- ASCII文字だけを含むすべてのUTF-8ファイルは有効なASCIIファイルである
- わずか128文字に限られた旧来のシステムと、数百万文字を包含する体系を結びつける発想は非常に革新的
UTF-8の基本概念
- UTF-8はUnicode文字集合のすべての文字を表現することを目的に設計された可変長文字エンコーディング(variable-width encoding)
- 各文字を1〜4バイトでエンコードする
- 最初の128文字(
U+0000〜U+007F)は単一バイトで保存されるため、ASCIIとの下位互換性を確保できる - それ以外の文字は2バイト、3バイト、4バイトでエンコードされる
- 最初のバイトの先行ビットが、エンコードに必要な全バイト数を決定する
| 1バイトパターン | バイト数 | 全バイト列パターン |
|---|---|---|
| 0xxxxxxx | 1 | 0xxxxxxx(通常のASCII) |
| 110xxxxx | 2 | 110xxxxx 10xxxxxx |
| 1110xxxx | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
| 11110xxx | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
- マルチバイト列の2、3、4バイト目は常に**
10で始まり、これは継続バイトであることを明確に示す** - 主バイトと継続バイトの残りのビットを組み合わせて、ひとつのコードポイントを作る
- コードポイントは固有のUnicode文字識別子で、"U+"接頭辞と16進数で表される
- 例: "A"のコードポイントは
U+0041
- UTF-8エンコーディングのバイト列から文字を解釈する流れは次のとおり
- 1. バイトを読み、先頭が0なら単一バイト文字(ASCII)と見なし、残りの7ビットで文字を表して次のバイトへ進む
- 2. 0でなければ
- 110なら2バイト文字として次の1バイトを追加で読む
- 1110なら3バイト文字として次の2バイトを読む
- 11110なら4バイト文字として追加の3バイトを読む
- 3. 決定したバイト列から先頭ビットを除いた残りのビットを結合し、コードポイントの2進値として使う
- 4. Unicode文字集合からコードポイントを探して画面に表示する
- 5. 次のバイトで繰り返す
例: ヒンディー語の文字 "अ"
- UTF-8表現:
11100000 10100100 10000101(3バイト) - 先頭バイト(
11100000)→ 3バイト文字であることを示す - 3バイトの有効ビットの組み合わせ →
00001001 00000101= 16進数0x0905 - コードポイント
U+0905はデーヴァナーガリー文字 "अ" を意味する
ファイル例
-
1.
Hey👋 Buddy- 合計13バイトで構成
- ASCII文字(H, e, y, B, u, d, d, y, 空白)→ 各1バイト
- 👋(U+1F44B)→ 4バイト
11110000 10011111 10010001 10001011
- このファイルは有効なUTF-8ファイルだが、非ASCII文字(絵文字)を含むため、ASCIIとの下位互換ではない
- 合計13バイトで構成
-
2.
Hey Buddy- 合計9バイトで、すべてASCII範囲
- したがってこのファイルは同時に有効なASCIIファイルであり、有効なUTF-8ファイルでもある
他のエンコーディングとの比較
- ASCIIとの互換性を提供するエンコーディングはいくつかあるが、UTF-8ほど広く使われてはいない
- GB18030(中国標準)などもASCII互換性を提供するが、広く使われてはいない
- ISO/IEC 8859系は単一バイト拡張(最大256文字)であるため限界がある
- UTF-16/UTF-32にはASCII互換性がない
- 'A'(U+0041): UTF-16は
00 41、UTF-32は00 00 00 41
- 'A'(U+0041): UTF-16は
おまけ: UTF-8 Playground
- UTF-8のエンコーディング過程を視覚的に探れるインタラクティブなツール
- https://utf8-playground.netlify.app/
1件のコメント
Hacker News の意見
UTF-8 では継続バイトが常に
10で始まるため、任意のバイト位置にジャンプしても、その位置が文字の開始なのか継続バイトなのかをすぐ判定できる。だから次または前の文字開始位置を簡単に見つけられる。EBML の可変長整数エンコーディング方式(単一バイトの ASCII 互換性を保つために 1/0 を反転する方式)のようにエンコードすると、任意位置から文字の開始を即座に知るのが難しい。詳しくは RFC8794 section 4.4 を参照その通りで、UTF-8 の大きな利点だ。UTF-8 文字列は先頭から読まなくても前後に自由に移動できる。Python の場合、文字列インデックスを文字単位で可能にするために、CPython では wide character を使っている。かつては 2 バイトまたは 4 バイト文字を選べて、その後は実行時に自動で切り替わるようになった。だが依然として wide character であって、UTF-8 ではない。たとえば絵文字 1 個でも文字列サイズが 4 倍に増える。私はむしろ内部表現を UTF-8 にして、インデックス型を不透明なオブジェクトにし、小さな整数を足し引きすると文字列内を前後に移動するような実装を考えていた。実際に整数へ変換したり直接添字アクセスしたりすると、そこで文字列のインデックスを計算する方式だ。このアプローチなら正規表現なども不透明インデックスオブジェクトを活用して UTF-8 表現上でうまく動く
LEB128/VLQ は EBML の可変長整数方式より優れていると思う。バイト内の MSB(最上位ビット)で区別する。
0ならシーケンス終端で次のバイトは新しいシーケンス、1なら MSB が0になるまで後ろへ巻き戻す。SIMD 最適化された効率的な実装もある。LEB128 と VLQ の違いはエンディアンだけだ。ASCII は0xxxxxxx、拡張文字は1xxxxxxx 0xxxxxxx、1xxxxxxx 1xxxxxxx 0xxxxxxxなどとなり、3 バイトで最大0x1FFFFFまでエンコードでき、Unicode に必要な量より多い。自己同期化(self-synchronizing)はしないが、より圧縮的だ。ASCII は依然 1 バイトで、数式記号や日本語のようなU+3FFF以下のコードポイントは 2 バイトで表現できるので、コードサイズ削減には有利だテキストが壊れておらず、悪意をもって改ざんされてもいないという前提でのみ可能だと思う。不正な UTF-8 シーケンスの解析やエスケープ処理では、これまで多数のセキュリティ脆弱性が発生してきた。関連例として CVE-2025-1094 PostgreSQL 問題、また UTF-8 関連 CVE 一覧 を見ればよい
必ずしも正しい話ではない。不正な UTF-8 の場合、継続バイト(continuation byte)が文字に変わることもある。たとえば
0b01100001 0b10000000 0b01100001のように入ってくると、a�aという 3 文字になる。出力文字の開始点かどうかは直前 1〜3 バイトを見なければならない最大 4 バイトのマルチバイト長なら、後ろ向きに最大 3 バイト確認するだけで現在位置が継続バイトかどうかを判断できる。開始バイトが出てこなければ、単一バイト文字だと分かる。ライブラリが UTF-8 を正しく認識できなくても、切り出したスライスの先頭と末尾の不正バイトを無視して、それなりに妥当な文字列を取り出す復旧目的でこのように設計されたのだと推測する
UTF-8 は本当に素晴らしいと思う。核心は ASCII が 7 ビットしか使わないと決めたことにある。1963 年時点でも 7 ビットという選択はやや特異だった。これが単なる歴史的偶然なのか気になる。ASCII を設計した人たちが、もう 1 ビット使って追加の記号を入れることを考えたのか、それともコードページや拡張性を念頭に置いていたのか知りたい
正確な理由は分からないが、昔は 8 ビットが常に使えるわけではなかった。7 ビット + 1 parity あるいはフラグビットが一般的だった(そのため e-mail は今でも quoted-printable で 7 ビットだけを使って 8 ビットをエンコードする)。8 ビットをそのまま完全に通せることを 8-bit clean と呼ぶ。そういう文脈で見ると、UTF-8 も結局は ASCII に残っていた 8 番目のビットをうまく活用した例だ。参考までに 8-bit clean の説明 もある
専門家ではないが、以前 ASCII の歴史を読んだことがある。ASCII はテレタイプコード(電信コードから発展したもの)にルーツがある。モールス信号は可変長なので機械実装が面倒だった。そこで 5 ビットの Baudot コードが生まれた。固定長コードで機械を単純化し、オペレーターの疲労を減らすのが目的だった。Baudot コードのおかげで、今でもシンボルレートを baud と呼ぶ。その後、タイプライターを使った紙テープ入力方式によって柔軟性が上がり、Carriage Return(復帰)や Line Feed(改行)といった特殊記号が追加された。初期のコンピュータ産業は入力にパンチカードを採用していたが、IBM はカードをより高速に処理する新しい 8 ビット体系を開発し、それが ASCII ベースになった。結局、技術の進歩に応じて 2 進コードは拡張されてきた。ASCII も 8 ビット byte の慣行より前に生まれた過渡期の産物だ
実際、余ったビットは parity(パリティ)用に再利用するためのものだった
ASCII の 8 ビット拡張(ISO 8859-x 系)は何十年にもわたって広く使われてきたし、Windows の標準コードページでも今なお使われている。もし ASCII が最初から 8 ビットだったとしても、主要な文字は最初の 128 個に集中していただろうから、UTF-8 にも適していたと思う。歴史的偶然だとするなら、ASCII が 7 ビットだったことではなく、当時のコンピュータの発展が主に英語圏で進み、英語が 7 ビットだけで十分表現できたことだ
7 ビット自体は特別おかしくない。Baudot は 5 ビットで、それでは足りず 6 ビットコードが生まれ、その後に 7 ビット ASCII が作られた。IBM は System/360 で 8 ビット byte(EBCDIC コード)を標準化したが、他のコンピュータベンダーでは byte 長は一定ではなかった。7 ビットは見た目には変でも、文字とシステムワードが必ずしもきれいに収まる状況ではなかった
UTF-8 は期待以上の設計だという点には同意する。ただ、Unicode には適用範囲(scope)が広がりすぎる問題がある。Unicode に何を含めるべきかという疑問が出てくる。直感的には「人類が意思疎通のために使う、区別可能で印字可能な文字すべて」だと思いたくなるが、実際にはそうではない。
区別が明確ではない。コードポイントが結合用(combining)として存在することもある
具体性に欠ける。1 文字を複数の方法で書ける。見た目には同じ文字でも、異なるコードポイントや意味を持つ
印字可能なものだけではない。制御文字(control char)が存在する。ASCII 互換性のために入ったものもあるが、独自の制御文字も増えている アニメーションする Unicode ポイントはまだないようだ。少なくとも印字可能なものは紙に出力できる。だが将来もこの不変性が保たれるかは分からない。ちなみに、著者が触れていない utf エンコーディングとして utf-7 もある。utf-8 に似ているが、80 年代のネットワーク環境では最後の 1 ビットを使うのが安全でないという前提で作られたものだ。偶然 utf-7 でエンコードされたメールを受け取ったことがある。どうやって送ったのか今でも分からない
UTF-7 は主に、電子メールのような 8-bit clean ではない伝送環境向けに作られた。今では時代遅れで、補助平面のエンコードもできない(UTF-16 surrogate pair 経由でしか表せない)。UTF-9 というものもあるが、これはエイプリルフール用 RFC で紹介されたパロディだ(PDP-10 のような 36 ビット環境向け)
以前からずっと不思議だったことがある。Unicode コードポイントは不要に長いバイト列でもエンコードできてしまうという点だ。UTF-8 はこれを禁止し、最短のシーケンスだけを許可している。たとえば
00000001でもよいし、11000000 10000001でも同じになる。ならば、そもそも別方式にして不正エンコーディングが存在しないようにはできなかったのだろうか。たとえば 2 バイトシーケンスの先頭を最後の有効値にして、11000000 10000001が128+1になり、0-127は 1 バイトにする。そうすれば不正コードもなくなり、エッジケースでは文字列が少し短くなるはずだ。おそらく当時のハードウェアコストの都合で検討されなかったのだろうか。(更新: 実際のビット列は10000001であるべきで、修正した)c2 80であるのはなぜで、c0 80(7fを超えた最初)ではないのか、ということだと思う。その理由は次の通りだと考える a) overlong encoding を許すと、一部が短いシーケンスしか確認しない場合にセキュリティホールになる b) 標準的な UTF-8 のエンコード/デコードはマスキング(bitmask)とシフト(bitshift)だけで処理できる。提案方式では追加で減算演算が必要になる 1992 年のメール議論でもこれについて話されており、FSS-UTF には additive constants が含まれていた(下記参照)バイトパターンの self-synchronicity(自己同期性)を保つことが重要だ。もし
11000000 10000001のような形で継続バイトの性質を崩すと、途中で切れた UTF-8 ストリームから常にコードポイント境界を見つけられる特性を失う。この方式に加減算まで入ると、デコーダの性能も落ちる。現在はビット演算だけで処理できるquectophoton のコメントにある通り、継続バイトが常に
10で始まるからこそ、パーサはどの位置からでもコードポイント境界を見つけられる。実際、90 年代初頭に UTF-8 を設計した当時は、信頼性の低い伝送環境が多いことが考慮されていた提案方式を使うと、エンコード/デコード計算はより複雑で遅くなる。今ならビットシフトを数回するだけで済むが、当時(90 年代)の遅いコンピュータ環境では重要な点だった
UTF-8 の設計についてもっと知りたければ、Russ Cox の one-pager と Rob Pike の 歴史まとめ を参照
UTF-8 は素晴らしく、あらゆる環境で使われてほしい(JavaScript を見ている)。ただし唯一の欠点は、不正なバイトシーケンスをどう解釈するかが標準で明確に定められていないことだ。「あらゆるバイトシーケンスに対して必ず解釈方法を定める」設計のほうがより完全だったと思う。HTML5 の仕様のようにすれば、うまく運用できたはずだ
後方互換性(backwards compatibility)にはいつも愛憎がある。混乱するのは嫌いだが、何かを壊してでも前進しようとする動きは好意的に見ている。だが同時に、UTF-8 や EAN のように互換性を保ちつつ賢く設計された例には気分がよくなる。正直、UTF-8 は互換性のためにほとんど何も犠牲にしていないように思える
もしあえて変えるなら、制御文字の一部をもっと一般的な文字に置き換えて、少しでも空間を減らしたかもしれない(Unicode 互換性まで壊すのだとすれば)。マルチバイト文字エンコーディング形式として独立に見ても、ほぼ最適だと思う
UTF-8 playground のリンク(utf8-playground.netlify.app)は本当に気に入った。UI から直接コードポイントを入力できるとさらによいと思う(今は URL 経由でしかできなかった)。(更新: すでに PR がマージされて可能になっていた)
この話題をもっと深く掘り下げたい、しかも Advent of Code のような形式が好きなら、i18n-puzzles にテキストエンコーディング関連のパズルがいくつもある。UTF-8 や UTF-16 などの仕組みを完全に腹落ちさせる助けになる
良い記事をありがとう。私も UTF-8 を勧めるが、BOM と一緒に使うときに限ってよいと思っている。そうでないと、アプリケーションはそれが UTF-8 だと分からず、保存も UTF-8 ですべきだという事実を見落とす。たとえば Windows で新しいテキスト文書を作ると、ファイルが空の時点で BOM だけ入っていれば、どのアプリでもその後の編集や保存時に UTF-8 で保存すべきだと自動認識できる。BOM がない状態では、アプリがエンコーディングを自動判別しようとしても完全には信頼できず、アクセント付き文字などの特殊文字を追加すると混乱が増す(エディタが言語を誤推定したり、Notepad がアップデート後に既定エンコーディングを変えたりする)。だから UTF-8 を使うことには同意するが、必ず BOM が OS/アプリのデフォルト設定であるべきだと思う