文字・コードポイント・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回で扱う文字化けは、ほぼすべて「間違った規則でデコードした」結果です。

図。上段:人が見る文字列「文字」、そのコードポイント U+6587 U+5B57、UTF-8 のバイト列 E6 96 87 E5 AD 97。コードポイントからバイト列へエンコードの矢印。下段:UTF-8 の4つのサイズ。U+007F までの A などは1バイト、U+07FF までの é などは2バイト、U+FFFF までの あ・文・半角カ などは3バイト、U+10FFFF までの絵文字 👍 などは4バイト。
同じ文字列の3つの層。エンコードはコードポイントをバイト列に、デコードはバイト列をコードポイントに戻す。何バイトになるかはコードポイントの範囲で決まる。

最初の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 の標準になった理由は、ここから導けます。

  1. ASCII 前提の古い道具がそのまま動く。 マルチバイト文字の中に 00 や /(2F)や \(5C)が紛れ込まない。"文字化け 👍" を UTF-8 にしても 00 は出ませんが、UTF-16 では出ます。
  2. バイト順がない。 UTF-16 は BOM かラベルで FF FE と FE FF を区別する必要があります("Hi".encode("utf-16") は b'\xff\xfeH\x00i\x00')。
  3. 自己同期性。 1バイト壊れても被害は1文字で済みます。
  4. バイト順=コードポイント順。 ランダムな文字列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 が効率的」と結論するのは早計です。実際のページは本文だけではありません。

1ページのサイズの棒グラフ。英語版は UTF-8 で 32.6 KB、UTF-16 で 65.1 KB。スペイン語版は 33.0 KB と 65.5 KB。日本語版は 39.2 KB と 54.8 KB。韓国語版は 36.7 KB と 54.3 KB。
このサイトの同じ講座ページを UTF-8 と UTF-16 で保存したサイズ。HTML のタグ部分は ASCII なので、日本語・韓国語でも UTF-8 の方が小さい。Python 3.9 で計測、単位は KB(1,000バイト)。

このサイトの講座ページ(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つのページだけで、コーパスではありません。傾向の例として読んでください。

練習問題

  1. 実行する前に、"カナ漢字" の len(s) と len(s.encode("utf-8")) を予想してください。Shift_JIS(cp932)では何バイトでしょうか。
  2. ログに E2 82 AC というバイトが出ています。ビット表を使って手でデコードしてください。F0 9F 8E 89 もやってみましょう。
  3. DB のカラムが「100バイトまで」のとき、全角文字は UTF-8 で最大何文字入りますか。絵文字だけなら?

解答

  1. len は4、UTF-8 は12バイト(ef bd b6 ef be 85 e6 bc a2 e5 ad 97)。cp932 では半角カナ1バイト×2+漢字2バイト×2で6バイトです。
  2. E2 は 1110 0010 なので3バイト:0010+000010+101100 で U+20AC、€ です。F0 9F 8E 89 は4バイトで U+1F389、🎉。
  3. ひらがな・漢字・全角記号は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バイトでした。

  • #文字コード
  • #初級
  • #UTF-8
  • #Unicode
  • #Python
  • #PHP

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑