まえがき
いろいろと開発や調査をするのに伴い、Effective Pythonを読み返す機会が増えてきました。初回は理解していると思ってスルーしたところでも、あとで読み返すと新たな発見(あるいは不十分な理解)が見つかることがよくあります。
で、本題なのですが、本書のItem3: Know the Differences Between bytes and strに"Unicode Sandwich"なる単語が登場します。当初は「ああ、文字列はきちんとStrクラスとして扱おうということなのね」程度に捉えていましたが、実際はもう少し込み入った話でした。あまり日本語の記事も見かけないので、色々とまとめておきます。
……と思ったのですが、結論としては割と徒労に終わった感じです。日本語でプログラムを扱う我々にはあまり関係のないお話でした……
あえて役に立つ教訓を取り出すのであれば、
- エンコーディング/デコーディングは入出力時に意識的に指定しようね
- テキストの処理はstr型で行おうね
といったところでしょうか。せっかく色々調べたので、備忘録代わりに残しておきます。
Unicode Sandwichとは
元々は著名なプログラマーであるNed Batchelderさんが、彼の講演で提唱した概念らしいです。
ざっくりと図にまとめると以下のような感じ。
Pythonはデフォルトのstr型がUnicodeを利用するようになっているため、Unicodeの前後が挟まれている→サンドイッチということらしいです。
Q1.) そもそもUnicodeとは
A.) たくさんある文字コードの業界規格の1つ
パソコンは根本的に数字しか扱えません。そのため文字を扱いたければ、それぞれの文字に数字を割り当てる必要があります。これを文字コードといいます。
文字コードの規格はたくさんたくさんあります。完璧に分離/管理されていれば統一する必要もないのでしょうが、実際は同じ文字に違う数字が割り当てられることがあるので大変です。人がそれぞれの目的のために作ったものなので、推測するのも難しいです。
Unicodeもそんな文字コードを扱うための業界規格の1つになります。世界中の文字を共通の集合で扱うことを目的にしており、そんなUnicode内で文字に割り当てられる数字はUnicodeポイントと呼ばれます。
Q2.)エンコーディングとは
A2.) 与えられたUnicodeポイントをどのようにバイト列に符号化するかという決まりごと
Unicodeでは1つの文字に1つのUnicodeポイントが与えられていますが、これがそのままファイルに書き込まれるわけではありません。同じUnicodeポイントでも、どのように実際のバイト列=数字にするのかには若干の違いがあります。この書き込み方を決めるのがエンコーディング、日本語だと符号化方式になります。
Pythonではutf-8というエンコーディングがデフォルトで採用されており、組み込み関数のordを利用することで、その文字のUnicodeポイントを確認することができます。
print(ord("a"))
print(ord("A"))
97
65
逆にUnicodeポイントから文字を調べたければchrを利用します。
print(chr(97))
print(chr(65))
a
A
少し横道に逸れますが、Unicodeは”世界で使われている全ての文字を利用する”という大きな目的を掲げているだけあって、顔文字にまでしっかりとUnicodeポイントが与えられています。
print(ord("😀"))
128512
ここまでの話をまとめると、
- コンピュータで文字を扱うためには数字を割り当ててあげる必要がある
- 数字の割り当て方法の1つがUnicode
- Unicodeで割り当てられた数字(Unicodeポイント)を実際のバイト列に変換するやり方の1つがutf-8
ということになります。
Q3.)文字コードやエンコーディングが違うと何が困るのか
A3.) エラーを吐いてしまったり、文字化けが起こったりする。
上述したように、同じ文字でも文字コードが変われば別の数字で表されます。
cp = "ぬ".encode("cp932")
print(cp)
print(type(cp))
ut = "ぬ".encode("utf-8")
print(ut)
print(type(ut))
b'\x82\xca'
<class 'bytes'>
b'\xe3\x81\xac'
<class 'bytes'>
ある文字コードとエンコーディングでエンコードされた文字列を、別の文字コードとエンコーディングでデコードするとエラーを吐くことがあります。
wrong_decode = cp.decode("utf-8")
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x82 in position 0: invalid start byte
このエラーは「utf-8では0x82が初めの位置にあるのはおかしいよ」というものです。
一方で、あらゆる場合でエラーを吐くかというとそうではなく、
cp = "A".encode("cp932")
print(cp)
print(type(cp))
wrong_decode = cp.decode("utf-8")
print(type(wrong_decode))
print(wrong_decode)
b'A'
<class 'bytes'>
<class 'str'>
A
というように問題なく元に戻せる場合もあります。
で、なんでエラーを吐いたり吐かなかったりするのかという話なのですが、utf-8ではUnicodeポイントを符号化する際にいくつかの操作を加えます。
上の例はその操作の結果、許容されないバイト列が出てきたのでUnicodeDecodeErrorを吐きましたが、下はそうではない、という話です。
Unicodeは互換性も重視しており、一分の文字(アルファベットとか)に関しては他と全く同じ文字コードが割り当てられていたりします。
変換方式については少々ややこしい話になりますし、結論には影響しないので折りたたんで置いておきますね。
utf-8の変換方式
こちらのサイトの解説がわかりやすかったので、そのまんま流れを追いかけていきます。
ひらがなの”ぬ”のUnicodeポイントは306C(16進数)です。Unicodeの流儀に則って書くと、U+306Cですね。
これはU+0800 ~ U+FFFFの範囲にある3バイト文字なので、16bitの2進数に変換した後、4,6,6bitに分割します。
ここまでの流れを図にすると、こんな感じ。
で、最後にそれぞれの16進数に指定の数字を足します。3バイト文字であれば、1バイト目にはE0、2バイト目と3バイト目には80を足します。この結果がutf-8でエンコーディングされたバイト列になります。
Pythonで確認すると、ちゃんと同じ値になっています。
ぬの文字 = "ぬ".encode("utf-8")
print(ぬの文字)
print(type(ぬの文字))
b'\xe3\x81\xac'
<class 'bytes'>
このちょっとややこしい操作にはもちろん利点もありまして、
- 1バイト文字なら、0x00~0x7F
- 2バイト文字なら、0xC2~0xDF
- 3バイト文字なら、0xE0~0xEF
- 4バイト文字なら、0xF0~0xF7
というように最初の1バイトに重複がないため、そこを見ると何バイトの文字なのかが判別できるようになっています。また、この1バイトは、どのバイト文字でも後ろに続くバイトともしっかり重複しないようになっていますので、「あぁ、途中の文字なんだな」というのがわかります。
ここでようやくUnicodeDecodeエラーを吐いた原因がわかります。先程cp932でエンコードした”ぬ”のバイト列は0x82から始まっていましたが、utf-8はどのバイト文字も0x82から始まることはありません。だから、「1バイト目に0x82がきているのはおかしいでしょ」と言われたわけです。
------折りたたみここまで------
また、最近はあまり見なくなりましたが、昔は文字化けと呼ばれる現象がありました。サイトを開くと謎の漢字や記号が羅列されているアレです。当時は何だかホラーの世界に迷い込んだようで、非常に怖かった記憶があります。
これもまた文字コードの違いによるものです。ブラウザ側とHTMLファイル側で文字コードの解釈が異なるとおきる現象です。
Q4.) 結局Unicode Sandwichとは何なのか
A4.) 予期せぬ動作やエラーを避けるために、エンコードとデコードは入出力の境界線で、中の処理はUnicodeを用いたstr型で行っていこうという考え方
御存知の通り、Pythonのファイル処理は基本的にwith openを利用します。組み込み関数のopenはキーワード引数としてencodingを取ることができます。そして当然のごとく、入出力のencodingが異なっているとエラーを吐きます。
sample_text = "坊主が屏風に上手に坊主の絵を書いた"
with open("cp932.txt", mode="w", encoding="cp932") as fp:
fp.write(sample_text)
with open("cp932.txt", mode="r", encoding="utf-8") as fp:
print(fp.read())
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x96 in position 0: invalid start byte
繰り返しになりますが、文字コードとエンコーディングを推測するのはとても難しいです。一応chardetなどを使えば推測することも可能ですが、元ファイル次第では判定を誤ることがあります。
そのため、入出力を行う際には、encodingを明示すべきです。自由に選択できるのであればutf-8で統一することが望ましいでしょう。
sample_text = "坊主が屏風に上手に坊主の絵を書いた"
with open("utf8.txt", mode="w", encoding="utf-8") as fp:
fp.write(sample_text)
with open("utf8.txt", mode="r", encoding="utf-8") as fp:
print(fp.read())
坊主が屏風に上手に坊主の絵を書いた
ただ、外部のシステム、たとえばAPIを利用したりするときは、自分で入出力の文字コードとエンコーディングを指定できないことも多いです。そのような時は、内部で処理を行う前にdecodeし、行った後にencodeするのが望ましいでしょう。
Q5.) なぜ日本語でユニコードサンドイッチの記事が見つからなかったのか?
A5.) そもそもbytes型で文字を扱おうという発想が存在しないため
書籍の方ではbytes型との兼ね合いについて色々解説がなされています。いまさらになりますが、エンコードとデコードを介さずとも、文字列の前にbを置くことで、直接バイト型を扱うことも可能です。
b_string = b"A"
print(type(b_string), b_string)
<class 'bytes'> b'A'
そしてstr型同士で出来る演算は、
plus_string = "A" + "B"
print(type(plus_string), plus_string)
<class 'str'> AB
bytes型でも基本できます。
plus_b_string = b"A" + b"B"
print(type(plus_b_string), plus_b_string)
<class 'bytes'> b'AB'
ただし、bytes型はASCIIコードで扱っている文字しか扱えません。
b_string = b"ぬ"
SyntaxError: bytes can only contain ASCII literal characters.
また、strとbytes、異なる型同士で演算を行うこともできません。
plus_string = b"A" + "B"
TypeError: can't concat str to bytes
これは<や>,==などを使った演算も同様です。
誤解を恐れずに言うのであれば、文字の取り扱いにおいて、bytes型はstr型の下位互換です。表せる文字は少なく、str型であれば大抵の操作ができる。
~2021/5/28追記~
記事を書いている最中に調べるだけ調べて書き忘れていたので、追記しておきます。
Unicodeにももちろん問題は存在しています。非常に幅広い文字を表示できることからセキュリティの問題に発展することがあります。
~追記ここまで~
あえてbytes型の方が優れている点を挙げるとすれば、使用するメモリが少ないことでしょうか。
import sys
bytes_text = b"A"
unicode_text = "A"
print(sys.getsizeof(bytes_text))
print(sys.getsizeof(unicode_text))
34
50
もしかすると組み込みシステムなど、メモリの使用量が厳しく制限される場所ではbytes型の方が好まれるのかも……?まぁそんな場所ではそもそもPythonを使うことはなさそうですが。
少し話がそれましたが、我々が日本語でテキストを扱う限り、bytes型で話を済ませることはできません。書籍やサイトなどでも当たり前のようにutf-8を扱っているため、ここで詰まる人が少なく、それゆえにやたらと情報が少なかったのでしょう。
あと、自分はPython3からのユーザーなので直接経験はしていないのですが、Python2では文字の取り扱いが大層ややこしかったそうです。
- デフォルトはstr型
- けど保存?(メモリへの保存なのかファイルへの保存なのかは不明)自体はbytes型
- Unicodeを使うためには、文字列の前にuを置く必要があった
という状況だったらしく、Python2の、それも英語圏のユーザーはこの仕様にかなり苦しんだのはなんとなく想像がつきます。だからこそPython3のリリースを機に、Unicode Sandwichというアイデアが生まれたのではないでしょうか。
あとがき
Unicode Sandwichについて書籍を読んだりサイトを漁っていたりする時に、何かこうしっくりとこない感覚がずっとつきまとっていました。その原因が多分使っている言語の違いによるものだと何となく理解できました。今まで疎かにしていた文字コードとエンコーディングの違いについても理解を深められたので、調べた甲斐はあったのだと思います。
集合知に感謝。
参考サイト様(アルファベット順)
- Ash.jp: Unicode対応 文字コード表
- Better Programming: Strings, Unicode, and Bytes in Python 3: Everything You Always Wanted to Know
- Build Insider: Unicodeとは? その歴史と進化、開発者向け基礎知識
- Compart
- PyPi: chardet
- Divide et impera: UnicodeからUTF-8への変換を手でやってみる
- Gihyo.jp: 本当は怖い文字コードの話 第4回 UTF-8の冗長なエンコード
- Ned Batchelder: Pragmatic Unicode
- Python.org: 組み込み型
- Qiita: unicodeとは?文字コードとは?UTF-8とは?
- Tech Racho: Unicodeで絶対知っておくべきセキュリティ5つの注意(翻訳)