雑記: 文字の組織とそのコードネームの話(符号化文字集合と文字符号化方式の話)

お気付きの点がありましたらご指摘いただけますと幸いです。

まとめ

組織名 メンバー コードネームの流儀
Unicode 世界中の文字たちが所属しており 0~0x10FFFF の整数の会員番号が割り振られている。 UTF-8 が最も一般的な流儀であり、数字やアルファベットなど任務が多い(会員番号 0~127 の)メンバーは会員番号の整数をそのまま1バイトで表現したもの、その他のメンバーは会員番号を所定のルールで 2〜4 バイトにしたものがコードネームになる。UTF-16UTF-32 という流儀も用いられる。文字列.encode('utf-n').hex() で確認できる。
ASCII Unicode の会員番号 0~127 のメンバーをとるとそのまま ASCII のメンバーで会員番号も同じである。というかこちらの組織の方が昔からある。 おそらく今日 ASCII の文字たちだけで任務にあたることはなく、他の文字も加えた組織で活動すると思われる。会員番号 128~255 にもメンバーを迎えた ISO-8859-n などである。もっとも、通常 ASCII エンコーディングといったとき会員番号をそのまま1バイトで表現したバイト列とすることを指すと思われる。文字列.encode('ascii').hex() でそうなる。
ISO 8859-1
(Latin-1)
Unicode の会員番号 0~255 がそのまま ISO 8859-1 で会員番号も同じである。なお ISO 8859-n という組織は n によって会員番号 128~255 の顔ぶれが異なる。ISO 8859-1 の会員番号 128~255 の文字は西ヨーロッパで用いられる文字をカバーする。 会員番号をそのまま1バイトで表現したものがコードネームとなる。つまり会員番号 0~127 の文字のコードネームは Unicode における UTF-8 式のコードネームと一致する(会員番号 128~255 のメンバーは UTF-8 では2バイトになるため一致しない)。文字列.encode('latin-1').hex() で確認できる。
JIS X 0201 アルファベット、数字、片仮名がメインに 192 名の文字が所属している(濁点付き片仮名や半濁点付き片仮名は所属しておらず濁点や半濁点が所属している)。日本産業規格(JIS)が組織した文字の組織で最古の組織である。 JIS X 2021 と JIS X 0208 は合同で任務にあたることが多い。
  • Shift_JIS は JIS X 2021 のメンバーに1バイトのコードネームを、JIS X 0208 のメンバーに2バイトのコードネームを割り当てる流儀である。このときどちらの組織にも所属するアルファベットや数字や片仮名は1バイトのコードネームと2バイトのコードネームを持つことになるが、1バイトのコードネームの文字は画面や紙面上で2バイトのコードネームの文字の半分の幅で表示されることが多く、これを俗に半角文字とよぶ。
  • EUC-JP は ASCII のメンバー、JIS X 2021 の片仮名メンバー、JIS X 0208 のメンバーにコードネームを割り当てるが、ASCII メンバーのコードネームのみ1バイト、他メンバーのコードネームは2バイトになる。JIS X 2021 の片仮名も2バイトになるが半角カタカナとよぶ。もっとも EUC-JP 流儀を用いる場合でも文字集合に JIS X 2021 片仮名を含めないこともあるし、逆に JIS X 0208 外の漢字も含めて 3 バイトのコードネームを割り当てることもある。
JIS X 0208 濁点/半濁点付きの平仮名/片仮名や 6355 文字の漢字を含めた日本語で用いられる文字が 6879 名所属している。メンバーは 94 名以下の「区」に区分けされ、会員番号は「 n 区 m 点」のように2つの十進整数で表記される。例えば「あ」は 4 区 2 点である。区は 84 区まである。
特定の流儀での文字列のコードネーム列は Python で以下のように確認できる(何バイトかわかりやすくするために 16 進数表示で 2 桁ごとに空白を挿入しているが、sep オプションが有効なのは Python 3.8 以降なのでこれより古いバージョンではこのオプションを取る必要がある)。 f:id:cookie-box:20211106181951p:plain:w120

>>> 'ありがとう'.encode('utf-8').hex(sep=' ')  # utf-8 では 15 バイト
'e3 81 82 e3 82 8a e3 81 8c e3 81 a8 e3 81 86'

>>> 'ありがとう'.encode('shift_jis').hex(sep=' ')  # shift_jis では 10 バイト
'82 a0 82 e8 82 aa 82 c6 82 a4'

>>> 'アリガトウ'.encode('shift_jis').hex(sep=' ')  # さらに半角なら 6 バイト
'b1 d8 b6 de c4 b3'

>>> 'ありがとう'.encode('latin-1').hex(sep=' ')  # 平仮名は latin-1 のメンバーではない
UnicodeEncodeError: 'latin-1' codec can't encode characters in position 0-4: 
ordinal not in range(256)

>>> 'Dankeschön'.encode('latin-1').hex(sep=' ')  # オー・ウムラウトは latin-1 のメンバーである
'44 61 6e 6b 65 73 63 68 f6 6e'

>>> 'Dankeschön'.encode('ascii').hex(sep=' ')  # オー・ウムラウトは ascii のメンバーではない
UnicodeEncodeError: 'ascii' codec can't encode character '\xf6' in position 8: 
ordinal not in range(128)

>>> 'Thanks'.encode('ascii').hex(sep=' ')  # アルファベットは ascii のメンバーである
'54 68 61 6e 6b 73'





f:id:cookie-box:20211026122630p:plain:w60

Unicode というのは世界中のすべての文字を会員登録させて会員番号(コードポイント)をふることを目指している会なのですね。会員番号は 0~0x10FFFF(0x は16進数のプレフィックスですね)の整数値であると。例えば平仮名の「あ」の会員番号は 0x3042(10進数だと 12354)ですね。特に Unicode のコードポイントであることを明示するために U+3042 と表記することもあるのですね。

>>> chr(12354)  # コードポイントを長さ1の文字列に変換
'あ'
>>> ord('あ')  # 長さ1の文字列からコードポイントに変換
12354
>>> hex(ord('あ'))
'0x3042'

f:id:cookie-box:20211104100722p:plain:w60

では Unicode さえあれば文字列を整数列にできるので計算機で文字列を扱うことができますね…って違うのですか? えっと、計算機さんはデータをバイト列―0~256 の整数の列と考えていいですよね―にしてあげなければ利用できないのですね? 会員番号は16の6乗までしかないですから、256の3乗までしかないわけで、3バイトずつ並べればいいのでは? 例えば「ありがとう」は以下の15バイトにします。

00 30 42   00 30 8a   00 30 4c   00 30 68   00 30 46

これで一体何の不満が*1…「無駄が多い」? た、確かに、日本語はともかく、数字やアルファベットやカンマやピリオドはみんな会員番号 122 までにいますから、英語の文章はほとんど1文字が1バイトで済むはずですね…すべての文字を3バイトで表現するのはあまりに非効率です…。それ以外に、「C言語の strlen() のような関数と互換性がない」? C言語の strlen() は、文字型ポインタを文字列の先頭からゼロバイト 00 (8 ビット文字符号化方式における「ヌル文字」)に到達するまでインクリメントすることで文字列の長さを計測するのですよね*2。あっ…上の「ありがとう」には途中に 00 が含まれていますね…ゼロバイトが文字列の終端を意味していません…。

f:id:cookie-box:20211026122630p:plain:w60

なので会員番号をバイト列にする効率的なルール(文字符号化方式)が整備されているのですね。それで Unicode に対する最も一般的な文字符号化方式UTF-8 なのですね。…UTF-8 形式のバイト列はいわば Unicode の全メンバーに割り当てられるコードネームですね。なるほどよく出動するメンバーには簡潔なコードネームも割り当てられようというものです。Unicode 会の実態はメンバーたちがコードネームで暗躍する秘密結社だったのですね。

f:id:cookie-box:20211026122630p:plain:w60

「あ」さんの会員番号は 0x3042 でしたが、UTF-8 式でのコードネームもこっそりみてみましょう。

>>> 'あ'.encode('utf-8')
b'\xe3\x81\x82'

3バイト列「e3 81 82」ですか…会員番号を素朴に3バイト列にした「00 30 42」とは結構違いますね…UTF-8 とは一体どんな変換規則になっているのでしょうか? …ウィキペディアをみて自分で再現すると以下ですね(簡単のため2進数表記を出力します)。会員番号が 0x7f を超える皆さんは会員番号を2進数にしてからいくつかの区間にばらしてパターンにあてはめるのがミソですね。


def unicode_to_utf8(i):
    if i <= 0x7f:  # 会員番号 ~0x7f は単に8桁の2進数に(1バイト)
        return f'{i:08b}'
    elif i <= 0x7ff:  # 会員番号 ~0x7ff は11桁の2進数にして以下にはめる(2バイト)
        b = f'{i:011b}'
        return '110' + b[:5] + '10' + b[5:]
    elif i <= 0xffff:  # 会員番号 ~0xffff は16桁の2進数にして以下にはめる(3バイト)
        b = f'{i:016b}'
        return '1110' + b[:4] + '10' + b[4:10] + '10' + b[10:]
    elif i <= 0x10ffff:  # 会員番号 ~0x10ffff は21桁の2進数にして以下にはめる(4バイト)
        b = f'{i:021b}'
        return '11110' + b[:3] + '10' + b[3:9] + '10' + b[9:15] + '10' + b[15:]
    raise ValueError('Unicode のコードポイントの最大値を超えています!')

# 検算
for c in ['a', 'あ', '🍪']:
    print(c)
    print(unicode_to_utf8(ord(c)))
    print(format(int(c.encode('utf-8').hex(), 16), 'b').zfill(8))

検算結果もばっちりです。

a
01100001
01100001
あ
111000111000000110000010
111000111000000110000010
🍪
11110000100111111000110110101010
11110000100111111000110110101010

f:id:cookie-box:20211026122630p:plain:w60

それに、ウィキペディアのビットパターン表をみて「あ」のコードネームがコードポイント「00 30 42」とは結構異なってみえる「e3 81 82」になる理由も腑に落ちました。つまり、UTF-8 式でのコードネームは必ず以下のいずれかになるので、「データ中のあるバイトをみただけで、それが『ここから何バイト続く文字の開始バイトである』or 『文字の開始バイトではない』とわかる」が実現されています。バイトの先頭が 0 であったら「この1バイトで1文字だな」と思えばいいし、110 であったら「このバイトと次のバイトの2バイトで1文字だな」と思えばいいし、10 であったら「このバイトは文字の開始バイトではない」と思えばいいわけです。

# Unicode のメンバーの文字たちのコードネームは以下のいずれか!
0xxxxxxx  <- 会員番号が2進数で7桁以下ならこう
110xxxxx 10xxxxxx  <- 会員番号が2進数で11桁以下ならこう
1110xxxx 10xxxxxx 10xxxxxx  <- 会員番号が2進数で16桁以下ならこう
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx  <- 会員番号が2進数で21桁以下ならこう

f:id:cookie-box:20211104100722p:plain:w60

しかし、よく考えると「開始バイトではない」の識別に 10 と2桁を割くのはもったいないのではないでしょうか。以下のエンコーディングであればコードネームが2バイト以下で済むメンバーの数が上のやり方の2倍、コードネームが3バイト以下で済むメンバーの人数が4倍です。

# メンバーの文字たちのコードネームを以下のいずれかにするのではだめなのか?
10xxxxxx  <- 会員番号が2進数で6桁以下ならこう
110xxxxx 0xxxxxxx  <- 会員番号が2進数で12桁以下ならこう
1110xxxx 0xxxxxxx 0xxxxxxx  <- 会員番号が2進数で18桁以下ならこう
11110xxx 0xxxxxxx 0xxxxxxx 0xxxxxxx  <- 会員番号が2進数で24桁以下ならこう

ですがこのエンコーディングでは以下の問題がありますね。だから採用されていないのでしょう。

  • コードネームが 2, 3 バイト以下のメンバーの数を増やせる一方で、コードネームが1バイトのメンバーの数は 128 から 64 に減ってしまう。会員番号 64~127 にいるアルファベットの大文字小文字が 1 バイトで表現できなくなってしまう弊害が大きいと思われる。
  • 会員番号 0 の「ヌル文字」がゼロバイトにならないので「ゼロバイトを発見することで文字列の終端位置を把握する」という手法が取れなくなる(ただし、これだけなら会員番号 0 のみゼロバイトを当てれば解決すると思われる)。

f:id:cookie-box:20211026122630p:plain:w60

それで「Python ソースコードのデフォルトエンコーディングUTF-8」とありますね。「Pythonソースコードにかかれた文字を UTF-8 でバイト列にして理解していきます」ということなのでしょうか。なので Unicode 文字をソースコードに記述することができるのですね。Unicode 文字であれば UTF-8 によってバイト列にできますから。他方、ソースコードを英数字のみ(ASCII のみ)にしつつ Unicode 文字を表現することもできるのですね。"\u3042" と記述しても "あ" と記述しても Python さんは e3 81 82 というバイト列として理解するのでしょう。以下のコードはコメントが日本語なので台無しですが。

print('\u3042')      # 「あ」(会員番号が16進数で4桁以下ならこうかける)
print('\U00003042')  # 「あ」(すべての文字はこうかける)
print('\U0001f36a')  # クッキーの絵文字(会員番号が4桁超えなのでこのかき方しかできない)
print('\N{HIRAGANA LETTER A}')  # 「あ」を名称で記述
print('\N{COOKIE}')             # クッキーの絵文字を名称で記述
あ
あ
🍪
あ
🍪

そして文字列をバイト列にする関数が str.encode(), バイト列を文字列にする関数が bytes.decode() とのことですが、これらは第1引数に文字符号化方式、第2引数に「変換できない文字/バイトがあった場合の対応方法」を指定できるのですね。

str.encode() bytes.decode()
strict 例外を送出する。 例外を送出する。
ignore エンコードできなかった文字を単に抜く。 デコードできなかったバイトを単に抜く。
replace エンコードできなかった文字を 3f(ASCII や UTF-8 等で疑問符)にする。 デコードできなかったバイトをREPLACEMENT CHARACTER にする。
backslashreplace エンコードできなかった文字を \uXXXX あるいは \UXXXXXXXX(XXXX, XXXXXXXX は Unicode コードポイントの16進数表記)としてからエンコードする(Unicode 文字でなければそもそも Pythonソースコードに記述できない)。 デコードできなかったバイトを \xXX(XX はそのバイトの16進数表記)という文字列にする。
namereplace エンコードできなかった文字を \N{hoge}(hogeUnicode におけるその文字の名称)としてからエンコードする(Unicode 文字でなければそもそも Pythonソースコードに記述できない)。 《 指定不可 》
xmlcharrefreplace エンコードできなかった文字を &#XXXXX;(XXXXX は Unicode コードポイントの10進数表記)としてからエンコードする(Unicode 文字でなければそもそも Pythonソースコードに記述できない)。 《 指定不可 》

つづかない

*1:原ドキュメント(参考文献1.)では1つ目の不満を「可搬性がない」としていますが、これはコードポイントをまず 32bit 整数列にしてみたせいなのでとばします。

*2:高速化のためにこれとは異なる実装の仕方がされている場合があります。