文字・コードポイント・UTF-8のバイト列をxxdで見る
len('文字化け')は4なのにファイルは12バイト。文字・コードポイント・バイトの違いをxxdとPython・PHPで実行して確かめ、半角カナがUTF-8で3バイトになる理由、strlenとmb_strlenの使い分け、UTF-8が勝った理由まで実測します。
シリーズ:文字コード講座 1/6(初級)。 「文字化け」に何度も悩まされてきたけれど、そもそも文字がどうバイトになっているのかは曖昧なまま、という人向けです。読み終えると、16進ダンプで UTF-8 のバイト列を読み、文字列が何バイトになるかを予測し、どの関数がどの「長さ」を返すのかを区別できるようになります。
この記事は、基幹システムから届く Shift_JIS の CSV を扱う人、DB の VARCHAR に日本語が入り切らなかった人、「半角カナは1バイト」と教わった人のための講座です。len("文字化け") は4なのにファイルは12バイト、半角の カ が UTF-8 では3バイト、という食い違いはすべて、文字・コードポイント・バイトは別物で、ツールごとに数えているものが違うという1点から説明できます。以下の出力はすべて Apple Silicon Mac(M1 Pro)上の Python 3.9.6、PHP 8.5.6、macOS 標準の xxd で実際に実行した結果です。
文字・コードポイント・バイトは3つの別のもの
コードに触る前に、3つの層に名前を付けておきます。
- 文字:人間が見ているもの。
あ、文、カ、👍。(第5回で扱いますが、👨👩👧 は1文字に見えて5つのコードポイントです。) - コードポイント:Unicode が文字に割り当てた番号。
U+と16進数で書きます。あは U+3042、文は U+6587、〜は U+301C、👍は U+1F44D。範囲は U+0000〜U+10FFFF です。 - バイト:ファイル・通信・DB のカラムに実際に入っているもの。コードポイントをバイト列に変える規則を文字コード(エンコーディング)と呼びます。UTF-8 はその1つで、Shift_JIS、CP932、EUC-JP、UTF-16 も同じく規則の1つです。
Python の str はコードポイントの並びで、ディスクや通信路にあるのはバイト列です。str → bytes がエンコード、bytes → str がデコード。第2回で扱う文字化けは、ほぼすべて「間違った規則でデコードした」結果です。
最初の5分:xxd でバイト列を見る
xxd はパイプで渡したものの生のバイトを表示します。echo だと改行が付くので printf '%s' を使います。
for s in A あ 文 カ 〜 👍; do printf '%s' "$s" | xxd; done
00000000: 41 A
00000000: e381 82 ...
00000000: e696 87 ...
00000000: efbd b6 ...
00000000: e380 9c ...
00000000: f09f 918d ....
A は ASCII と同じ 0x41 の1バイト。ひらがな・漢字は3バイト、そして半角カナの カ も3バイトです。絵文字は4バイト。右側の . は「表示できる ASCII ではない」という意味です。
同じことを Python で、コードポイントとバイト列を並べて見てみます。
import unicodedata
for s in ["hello", "文字化け", "カナ", "ア", "10時〜12時", "①", "👍"]:
b = s.encode("utf-8")
print(f"{s!r:12} len={len(s):2} bytes={len(b):2} {b.hex(' ')}")
for ch in "Aあ文カ〜👍":
print(f"{ch} U+{ord(ch):04X} {unicodedata.name(ch)} -> {ch.encode().hex(' ')}")
'hello' len= 5 bytes= 5 68 65 6c 6c 6f
'文字化け' len= 4 bytes=12 e6 96 87 e5 ad 97 e5 8c 96 e3 81 91
'カナ' len= 2 bytes= 6 ef bd b6 ef be 85
'ア' len= 1 bytes= 3 e3 82 a2
'10時〜12時' len= 7 bytes=13 31 30 e6 99 82 e3 80 9c 31 32 e6 99 82
'①' len= 1 bytes= 3 e2 91 a0
'👍' len= 1 bytes= 4 f0 9f 91 8d
A U+0041 LATIN CAPITAL LETTER A -> 41
あ U+3042 HIRAGANA LETTER A -> e3 81 82
文 U+6587 CJK UNIFIED IDEOGRAPH-6587 -> e6 96 87
カ U+FF76 HALFWIDTH KATAKANA LETTER KA -> ef bd b6
〜 U+301C WAVE DASH -> e3 80 9c
👍 U+1F44D THUMBS UP SIGN -> f0 9f 91 8d
表示の桁がずれているのは、ターミナルが全角文字を2桁で描くのに :12 は文字数で埋めるからで、これも「長さ」の食い違いの一種です。〜 の名前が WAVE DASH(U+301C)になっている点は覚えておいてください。Windows で入力した「〜」は多くの場合 FULLWIDTH TILDE(U+FF5E)という別の文字で、これが第2回の「波ダッシュ問題」につながります。
UTF-8 のビット配置:1〜4バイト
UTF-8 は先頭バイトの上位ビットで長さを示し、続くバイトにはすべて 10 の印を付けます。
| コードポイントの範囲 | バイト数 | ビットパターン | 例 |
|---|---|---|---|
| U+0000〜U+007F | 1 | 0xxxxxxx |
A 41、1 31 |
| U+0080〜U+07FF | 2 | 110xxxxx 10xxxxxx |
é C3 A9、ñ C3 B1 |
| U+0800〜U+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
あ E3 81 82、文 E6 96 87、カ EF BD B6、① E2 91 A0 |
| U+10000〜U+10FFFF | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
👍 F0 9F 91 8D |
日本語でよく使う文字(ひらがな、カタカナ、常用漢字、全角記号、半角カナ)は、すべて3バイトの範囲に入ります。4バイトになるのは絵文字と、𠮷(つちよし)のような一部の漢字です。
仕組みはこれだけです。上の表だけを使った10行のデコーダで確かめます。
import sys
def decode_one(b):
x = b[0]
if x < 0x80: return x, 1
if x >> 5 == 0b110: return ((x & 0x1F) << 6) | (b[1] & 0x3F), 2
if x >> 4 == 0b1110: return ((x & 0x0F) << 12) | ((b[1] & 0x3F) << 6) | (b[2] & 0x3F), 3
if x >> 3 == 0b11110:
return ((x & 0x07) << 18) | ((b[1] & 0x3F) << 12) | ((b[2] & 0x3F) << 6) | (b[3] & 0x3F), 4
raise ValueError(f"not a lead byte: {x:#04x}")
data, i, out = sys.argv[1].encode(), 0, []
while i < len(data):
cp, n = decode_one(data[i:]); out.append(f"U+{cp:04X}"); i += n
print(out)
$ python3 dec.py "文字化け カナ"
['U+6587', 'U+5B57', 'U+5316', 'U+3051', 'U+0020', 'U+FF76', 'U+FF85']
10xxxxxx の印のおかげで、どのバイトからでも、継続バイトを後ろに戻れば文字の先頭が見つかります(自己同期性)。Shift_JIS では2バイト目が 0x40 以上の ASCII 領域と重なるため、途中から読むと区切りが分かりません。有名な「ソ」「表」の2バイト目が 0x5C(\)で、エスケープ処理を壊す「5C問題」もここから来ています。UTF-8 ではマルチバイト文字のどのバイトも 0x80 以上なので、この種の事故は起きません。
どの「長さ」? Python の len() と PHP の strlen・mb_strlen
Python 3 の len() は str ならコードポイント数、bytes ならバイト数です。PHP の文字列はバイト列なので、strlen はバイト数、mb_ 系の関数がコードポイント数を返します。
<?php
foreach (["文字化け", "カナ", "〜", "①"] as $s) {
printf("%s strlen=%d mb_strlen=%d\n", $s, strlen($s), mb_strlen($s));
}
文字化け strlen=12 mb_strlen=4
カナ strlen=6 mb_strlen=2
〜 strlen=3 mb_strlen=1
① strlen=3 mb_strlen=1
シェルでも答えが2つあります。wc -c はバイト数、wc -m はロケールに従った文字数です。
$ printf '文字化け\n' > ja.txt
$ wc -c ja.txt → 13 ja.txt
$ wc -m ja.txt → 5 ja.txt (LANG=C.UTF-8)
$ LANG=C wc -m ja.txt → 13 ja.txt
同じファイルなのに「文字数」が2通り。シリーズ全体で使う早見表です。
| 知りたいもの | Python | PHP | シェル |
|---|---|---|---|
| 保存・通信・カラム上限のバイト数 | len(s.encode("utf-8")) |
strlen($s) |
wc -c |
| コードポイント数 | len(s) |
mb_strlen($s) |
wc -m(UTF-8 ロケール) |
| 人が「文字」と呼ぶ数 | 第5回参照 | grapheme_strlen($s) |
— |
よくある間違い:文字の途中でバイトを切る
間違い。 ニックネームを「全角10文字まで」と考えて substr で切る、または通信バッファを固定サイズで読んで、塊ごとにデコードする。
実際に起きること。 切れ目がマルチバイト文字の途中に入ります。PHP の substr("文字化け", 0, 4) は e6 96 87 e5 を返し、最後の1バイトは「字」の破片です。Python は大きな声で失敗し、errors="replace" を付けると静かに壊れます。
for s, n in [("文字化け", 5), ("カナ", 4)]:
b = s.encode()[:n]
try:
b.decode()
except UnicodeDecodeError as e:
print(repr(b), e, "| replace:", repr(b.decode("utf-8", "replace")))
b'\xe6\x96\x87\xe5\xad' 'utf-8' codec can't decode bytes in position 3-4: unexpected end of data | replace: '文�'
b'\xef\xbd\xb6\xef' 'utf-8' codec can't decode byte 0xef in position 3: unexpected end of data | replace: 'カ�'
直し方。 文字単位の関数で切ります(Python は s[:10]、PHP は mb_substr($s, 0, 10)。mb_substr("文字化け", 0, 2) は 文字 を返しました)。バイトを分割して読む場合はインクリメンタルデコーダを使い、半端な文字を次の塊に持ち越します。実際の境界でのバグと修正は第6回で扱います。C で書く人は、NUL 終端側の話を C の文字列と構造体の講座で確認してください。
ASCII は UTF-8 の部分集合、そして UTF-8 が勝った理由
ASCII のバイトは UTF-8 でもまったく同じ意味で、マルチバイト文字のどのバイトも 0x80 未満になりません。そのため ASCII だけのファイルはそのまま正しい UTF-8 であり、多くのレガシー文字コードとも一致します。
t = "Hello, World 123"
print(t.encode("ascii") == t.encode("utf-8")) # True
print(t.encode("ascii") == t.encode("cp932") == t.encode("cp1252")) # True
print(t.encode("utf-16-le")[:8].hex(" ")) # 48 00 65 00 6c 00 6c 00
UTF-16 は ASCII 互換ではなく、英字の隣に 00 が入ります。C の文字列関数はこれを「文字列の終わり」と読みます。UTF-8 が Web・Linux・Git・JSON の標準になった理由は、ここから導けます。
- ASCII 前提の古い道具がそのまま動く。 マルチバイト文字の中に
00や/(2F)や\(5C)が紛れ込まない。"文字化け 👍"を UTF-8 にしても00は出ませんが、UTF-16 では出ます。 - バイト順がない。 UTF-16 は BOM かラベルで
FF FEとFE FFを区別する必要があります("Hi".encode("utf-16")はb'\xff\xfeH\x00i\x00')。 - 自己同期性。 1バイト壊れても被害は1文字で済みます。
- バイト順=コードポイント順。 ランダムな文字列1万個(seed 42)を UTF-8 のバイト列で並べた順序は、
strの並び順と完全に一致しました。
サイズはどうでしょうか。言語ごとの短い文と、このサイトの実際のページを測りました。
| テキスト | コードポイント | UTF-8 | UTF-16 | レガシー |
|---|---|---|---|---|
| 日本語の文 | 59 | 153 | 118 | 106(cp932) |
| 韓国語の文 | 64 | 146 | 128 | 105(cp949) |
英語の文(’ “ ” é ï 入り) |
95 | 103 | 190 | 95(cp1252) |
スペイン語の文(ñ ¿ ¡ 入り) |
91 | 104 | 182 | 91(cp1252) |
日本語の本文だけなら、UTF-8 は1文字3バイトで、Shift_JIS や UTF-16 の2バイトより大きくなります。ここで「やはり日本語は Shift_JIS が効率的」と結論するのは早計です。実際のページは本文だけではありません。
このサイトの講座ページ(HTML、CSS クラス名、URL、コード)を測ると、日本語版でも非 ASCII 文字は全体の21.5%しかありませんでした。日本語版は UTF-8 で39,229バイト、UTF-16 で54,842バイト。英語版は32,616対65,142バイトです。W3Techs の調査では Web サイトの98%以上が UTF-8 です(同社の数値で、ここでは測っていません)。
正直な限界とバージョンの注記
- Python 3.9.6 の
unicodedataは Unicode 13.0 準拠です。新しい絵文字ではunicodedata.nameがValueError: no such nameを出すことがあります。UTF-8 のバイト列自体は Unicode のバージョンに依存しません。 - PHP 8.5.6(mbstring 有効)で
mb_internal_encoding()はUTF-8を返しました。古いphp.iniで別の内部エンコーディングを指定していると結果が変わります。 wc -mはロケール(LANG)次第です。使ったのは macOS のwcで、GNU のwcは実行していません。- UTF-8 は RFC 3629(2003年)で4バイト・U+10FFFF までに制限されました。5・6バイト形式を説明している古い資料は現在では無効です。
- サイズ表は4つの文と4つのページだけで、コーパスではありません。傾向の例として読んでください。
練習問題
- 実行する前に、
"カナ漢字"のlen(s)とlen(s.encode("utf-8"))を予想してください。Shift_JIS(cp932)では何バイトでしょうか。 - ログに
E2 82 ACというバイトが出ています。ビット表を使って手でデコードしてください。F0 9F 8E 89もやってみましょう。 - DB のカラムが「100バイトまで」のとき、全角文字は UTF-8 で最大何文字入りますか。絵文字だけなら?
解答
lenは4、UTF-8 は12バイト(ef bd b6 ef be 85 e6 bc a2 e5 ad 97)。cp932 では半角カナ1バイト×2+漢字2バイト×2で6バイトです。E2は1110 0010なので3バイト:0010+000010+101100で U+20AC、€です。F0 9F 8E 89は4バイトで U+1F389、🎉。- ひらがな・漢字・全角記号は3バイトなので33文字(99バイト)。絵文字は4バイトなので25個。「全角50文字」のつもりで100バイトにすると入り切りません。
次回
バイト列は見えてしまえば簡単です。問題は、ある文字コードで書いたバイト列を、別の文字コードで読んだときに起きます。
よくある質問
Pythonで len('文字化け') が4なのに、保存したファイルが12バイトなのはなぜ?
len() は str のコードポイント数を数えます。ファイルには UTF-8 で保存されるため、漢字・ひらがなは1文字3バイトになり、合計12バイトです。バイト数は len(s.encode('utf-8')) で求めます。
半角カナは1バイトではないのですか?
Shift_JIS(CP932)では1バイトですが、UTF-8 では3バイトです。'カナ' は CP932 で b6 c5 の2バイト、UTF-8 では ef bd b6 ef be 85 の6バイトでした。「半角=1バイト」は Shift_JIS 時代の前提です。
日本語ならUTF-16の方がUTF-8より小さいのでは?
本文だけならそうです。59文字の日本語の文は UTF-8 で153バイト、UTF-16 で118バイトでした。ただし Web ページはタグが ASCII なので逆転し、このサイトの日本語ページは UTF-8 で39,229バイト、UTF-16 で54,842バイトでした。