nikkie-ftnextの日記

イベントレポートや読書メモを発信

あ(U+3042)はどういう規則でb'\xe3\x81\x82'というバイト列に変換される? UTF-8の変換アルゴリズムを知りました

はじめに

うにおん!ならぬ、うにこーど! nikkieです。

先日ChatGPTがどのように日本語テキストをトークン化するのか覗きました。

トークンのIDから対応するテキストを見ようとPythonのbytesを扱ったわけですが、その中で感じた疑問についてアウトプットです。

目次

あ(U+3042)をencodeするとb'\xe3\x81\x82'

ひらがなの「あ」はUnicodeのコードポイント(符号位置)がU+3042です。

※この記事におけるPythonのバージョンは 3.10.9 です

>>> hex(ord("あ"))
'0x3042'

ひらがなの「あ」(str)をbytesに変換すると

>>> "あ".encode()
b'\xe3\x81\x82'

https://docs.python.org/ja/3/library/stdtypes.html#str.encode

  • 第1引数のデフォルト値はUTF-8です
>>> "あ".encode("utf8")
b'\xe3\x81\x82'

続く文字を変換していくと規則性がある

「あ」の後のひらがなについてもコードポイントとbytesを見ていきましょう

>>> hex(ord("い"))
'0x3044'
>>> "い".encode()
b'\xe3\x81\x84'
>>> hex(ord("う"))
'0x3046'
>>> "う".encode()
b'\xe3\x81\x86'
>>> hex(ord("え"))
'0x3048'
>>> "え".encode()
b'\xe3\x81\x88'
>>> hex(ord("お"))
'0x304a'
>>> "お".encode()
b'\xe3\x81\x8a'

ひらがなが1文字進むと、コードポイントもbytesも2ずつ増加していますよね!
1の位(一番右の桁)は同じ値です(2 -> 4 -> 6 -> 8 -> a)

これを見て、「コードポイントからbytesへの変換規則がなにかあるんじゃないか?」と、私、気になりました!

脱線:こんなところでも見かけます

Python標準ライブラリの中でもUnicodeのコードポイントやそれをバイトに変換した値が登場します

jsonUnicodeコードポイント

>>> json.dumps({"key": "あい"})
'{"key": "\\u3042\\u3044"}'
>>> json.dumps({"key": "あい"}, ensure_ascii=False)
'{"key": "あい"}'

ensure_ascii引数はデフォルト値がTrueです。
https://docs.python.org/ja/3/library/json.html#json.dump

ensure_ascii が (デフォルト値の) true の場合、出力では入力された全ての非 ASCII 文字はエスケープされていることが保証されています。ensure_ascii が false の場合、これらの文字はそのまま出力されます。

エスケープされる=Unicodeコードポイントでの出力ということですね。

urllib.parseでバイト列

URLに日本語を使ったときにはバイト列に変換されています。
「URLエンコーディング」や「Percent-Encoding(RFC 3986)」というそうです。

URLエンコーディングurllib.parse.urlencodeというまさにそれという関数がありますね!
https://docs.python.org/ja/3/library/urllib.parse.html#urllib.parse.urlencode

>>> import urllib.parse
>>> urllib.parse.urlencode({"key": "あい"})
'key=%E3%81%82%E3%81%84'

何らかの規則でURLエンコーディングされていると思っていましたが、文字列をbytesにencodeするのと同じ規則だったのですね!

変換規則:1110 xxxx 10xx xxxx 10xx xxxx

調べた末に以下に行き着きました。

UTF-8の符号化方法から引用します。

UTF-8は, Code pointを1~4bytesの可変長で変換します.

U+0800 ~ U+FFFFの範囲のコードポイントは、以下のように3バイトに変換されるそうです。

1110 xxxx 10xx xxxx 10xx xxxx

変換規則の適用例:「あ」

  • あ(U+3042)は、U+0800 ~ U+FFFFの範囲なので3バイトに変換される
  • U+3042のビット表記は 0011 0000 0100 0010
  • 1バイト目:1110 0011E3
    • U+3042のビット表記から先頭4ビットが取られる
  • 2バイト目:1000 000181
    • U+3042のビット表記から5ビット目〜10ビット目が取られる
  • 3バイト目:1000 001082
    • U+3042のビット表記から11ビット目〜最後が取られる
  • 3バイトは E38182
    • "あ".encode()で見たbytesだ!!!!

他の例:「お」

  • お:U+304A は、U+0800 ~ U+FFFFの範囲内 -> 3バイトに変換
  • U+304Aのビット表記 0011 0000 0100 1010
  • 1バイト目:1110 0011E3
  • 2バイト目:1000 000181
  • 3バイト目:1000 10108A
  • 👉 E3818A

もひとつ他の例:「誕」

>>> hex(ord("誕"))
'0x8a95'
>>> "誕".encode()
b'\xe8\xaa\x95'
  • 誕:U+8A95は、U+0800 ~ U+FFFFの範囲内 -> 3バイトに変換
  • U+8A95のビット表記 1000 1010 1001 0101
  • 1バイト目:1110 1000E8
  • 2バイト目:1010 1010AA
  • 3バイト目:1001 010195
  • 👉 E8AA05

ひらがな・カタカナ・漢字には、1110 xxxx 10xx xxxx 10xx xxxxの3バイト変換が適用される

U+0800 ~ U+FFFFの範囲には、ひらがな・カタカナ・漢字は含まれると理解しました。

  • Unicode基本多言語面BMPにあたる
  • 含まれる文字
    • ひらがな:U+3040~U+309F
    • カタカナ:U+30A0~U+30FF
    • 漢字
      • CJK統合漢字:U+4E00~U+9FFF
      • 他:U+3400~U+4DBF、U+F900~U+FAFF

終わりに

chr(0x3042).encode()b'\xe3\x81\x82'となる(UTF-8の)変換規則が気になり調べたところ、完全に理解できました!
Unicodeのコードポイントとしては16進4桁(2バイト)ですが、変換規則により8ビット(=4+2+2)追加されるのでbytesは3バイトになるわけですね。

ChatGPTのトークンから興味を持ったわけですが、UTF-8で変換したバイト列はURLエンコーディングなど他の箇所でも見かけることに気づきました。
文字コードは(戻ってこれないかもしれないほどの)深い世界に見えていましたが、やはり陰ながら支えてくれていたんだなあという実感です(しみじみ)

P.S. Unicodeまわりの参考文献

気になるところだけ読んで、URLエンコーディングBMPについて学びました。
分かりやすいと思います