qtatsuの週報

初心者ですわぁ

【Python】bytesとstrを真面目に理解する

前書き

本記事は、Pythonのbytes型およびエンコーディングの仕組みを「触って動かそう」方式で理解しよう!という内容です。

(strやbytesの詳しいメソッドなどは参考リンクの公式ドキュメントを参照していただきたいです。)

参考リンク

Unicode HOWTO

なんと公式ドキュメントにHOWTOがあります。僕の記事は要らなかったですね....

組み込み型

bytesやbytearray型についてはこちらを参照してください。

文字コード考え方から理解するUnicodeとUTF-8の違い | ギークを目指して

文字集合文字コード、コードポイントについて、わかりやすく解説されています。

用語がいまいちわからない方はこちらから読まれることをお勧めします。

Unicodeとは? その歴史と進化、開発者向け基礎知識 - Build Insider

歴史的経緯から書かれており、分量は多いですがとても詳しい記事です。

Characters, Symbols and the Unicode Miracle - Computerphile - YouTube

エンコードの仕組みをASCII登場からUTF8まで、歴史的経緯とともに10分程度で説明されてます。公式のHOWTOの参考文献に載っていたのですが、お勧めなので紹介します。

環境

バージョン
MacOS Big Sur 11.6
Python3 3.9.1

bytesとは何なのか

結論: bytesはその名の通りバイト(10進数では0〜255)の集まり、整数の配列です。出力時には __repr__によりASCIIで表示されます。

1. 前置き: bytesは文字列の様に扱うことができる

組み込み型

bytes型(バイトオブジェクト)はbを文字の先頭につけて作成できます。(公式ドキュメントを見ると、この記法をバイトリテラルと呼んでいます)

>>> bytes1 = b'Hello, World!!'
>>> print(bytes1)
b'Hello, World!!'

bytesは普通の文字列(str型)と似ています。

例えば以下のように、strでお馴染みの結合と置換は、bytesでも可能です。

ただし、printした結果はバイトオブジェクトであることを表す"b"が付きます。

# 結合
>>> print(b'Hello, ' + b'World')
b'Hello, World'

# 置換
>>> b'Hello, World'.replace(b'Hello', b'Good night')
b'Good night, World'

https://docs.python.org/3.9/library/io.html#binary-i-o

In-memoryのストリームとして、str型ではStringIOを使います。bytesはBytesIOストリームに渡すことができます。

>>> from io import BytesIO
>>> buf = BytesIO()
>>> buf.write(b'Hello, World!!')
14
>>> buf.tell()
14
>>> buf.seek(0)
0
>>> buf.read()
b'Hello, World!!'

バイトリテラル記法では、非ASCII文字を渡すことはできません。

>>> b'ああ'
SyntaxError: bytes can only contain ASCII literal characters.

2. bytesはその名の通り、バイト(整数)の配列

自分は学び初めの頃、上記のような「文字列の様な挙動」のイメージによって混乱していました。

bytesはその名の通り、1バイト=8ビット、つまり0〜255の整数が並んだ配列です。(二進数では0000_0000〜1111_1111)

バイトリテラル(b'')を利用せず、直接数値のリストからbytesを定義してみます。

組み込みのbytesクラスを使います。

※ ASCIIでは97, 98, 99にはa, b, cが対応します(wikipedia参照) ASCII - Wikipedia

>>> bytes([97, 98, 99])
b'abc'

bytesは数値の配列なので、sumで足し算することもできます(実用性はありませんが...)。

>>> sum(b'abc')  # = 97 + 98 + 99
294

また、1バイトを超える数値を割り振ることはできません。

>>> bytes([255])  # OK
b'\xff'

>>> bytes([256])  # NG
ValueError: bytes must be in range(0, 256)

ところで、先述したASCIIのwikipediaには以下のような記述があります。

ASCIIは、7桁の2進数で表すことのできる整数の数値のそれぞれに...(略) 初めの32文字(10進数で0-31)はASCIIでは制御文字として予約されている。基本的にはこれらの制御文字は表示するための文字ではなく、モニタやプリンタなどの機器を制御するために用いられる。

制御文字がどのように出力されるか、実際に試してみます。

>>> bytes([0, 1, 2, 30, 31, 32, 33])
b'\x00\x01\x02\x1e\x1f !'

32〜126は文字に変換できています。それ以外の数字だと、たとえ0-255の範囲でも文字に変換できていません。

制御文字(0〜31)は16進数表記で表されるようです。これはbytesの__repr__がそのように表現している、というだけの話だと思います(後述)。

32はスペース、33は"!"に対応しています。

次は126以上の数字を見てみましょう。

>>> bytes([126, 127, 128, 129, 255])
b'~\x7f\x80\x81\xff'

7bitで最大(111_1111)は127ですが、こちらは削除の制御記号とのことです(Wikipedia参照)。

3. bytesが文字列のように出力されるのは__repr__のため

bytesに数値の配列を渡してインスタンス化すると、バイトリテラルの形(b'')で結果が表示されました。

bytesの実体は整数の配列ですので、この挙動は __repr__特殊メソッドでASCII変換 した結果です。

__repr__を直接呼んでみましょう。(実用面での意味は全くありませんが...)

>>> bytes([97]).__repr__()
"b'a'"

公式ドキュメントから一部を省略して引用します。

組み込み型 — Python 3.10.0b2 ドキュメント

bytesリテラルと repr 出力は ASCII テキストをベースにしたものですが、 bytes オブジェクトは、各値が 0 <= x < 256 の範囲に収まるような整数...(省略)... の不変なシーケンスとして振る舞います。...(省略)... 任意のバイナリデータが一般にテキストになっているわけではないことを強調するためにこのように設計されました

繰り返しますが、bytesの実体は整数の配列です。

list()を使って整数のリストに戻すこともできます.

>>> list(b'abc')
[97, 98, 99]

もっと言うと、indexを指定して要素を取り出せます。結果はただの整数です

>>> b'abc'[0]
97

# 余談ですが、slice記法で取り出すとbytes型のままです。
>>> b'abc'[0:1]
b'a'

bytes型のメソッドや演算をみると、ASCIIコードで変換した文字列のように扱えます。しかしbytesの実体は整数です。

b'あ'のように非ASCII文字を渡せないのも、ASCIIで対応する数値が無いものは受け付けられないためです。

strとは何なのか

Unicode HOWTO — Python 3.9.4 ドキュメント 公式に記事があります。

1. 前置き: strはUnicode

まず、「PythonはUTF8で文字列を扱う」などと書かれている記事を散見しますが、これは誤りだと思います。 (僕が間違っていたらご指摘をお願いします:pray:)

open関数やstr.encode, str.decodeメソッドのデフォルト符号化方式としてUTF8が設定されているだけであり、文字列それ自体はUnicodeです。

換言すると、文字列を扱っている時点では符号化方式(UTF8やSJISなど)は関係ないですし、未定です。

符号化方式と文字集合の違いは、以下の記事がわかりやすかったので紹介させていただきます。

文字コード考え方から理解するUnicodeとUTF-8の違い | ギークを目指して

2. chrとord

組み込み関数ordは、strを受け取り(1文字のみ可)、対応するUnicodeのコードポイントを返します。

コードポイントは、ある文字集合(ここではUnicode)で、「その文字は頭から何番目か?」のような意味です。

下の例ではのコードポイントを求めており、10進数では12354、16進数に変換すると0x3042であることがわかります(2バイト)。

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

組み込み関数chrはordと逆の働きをします。 コードポイント(数値)を渡すと、そのコードポイントに対応する文字を返します。

>>> chr(12354)   # 10進数で指定
'あ'
>>> chr(0x3042)  # 16進数で指定
'あ'

それでは、chrやord, その他エンコードのメソッドを使い、Pythonのstr型(Unicodeを表す)を色々触ってみます。

3. Unicodeの上限と\uエスケープ

Unicodeで一番大きなコードポイントの値は1_114_111(10進数)です。

1_114_112を渡すと範囲外となり、対応するものがないのでValueErrorになります。

>>> chr(1_114_111)
'\U0010ffff'
>>> chr(1114112)
ValueError: chr() arg not in range(0x110000)

これは先程、bytesに255以上の値を渡した時とにています。

str型が持つ「1文字」も実体はコードポイントを表す数値です。

bytesほど単純ではないですが、ord関数を使えば数字(コードポイント)の配列に戻すこともできます。

ところで、\uエスケープを使うことで文字列リテラルの中で直接コードポイントを記述することもできます。

先程、Unicodeにおけるコードポイントは3042でしたので、"\u3042""あ"と全く同じということになります。

>>> "あ" == "\u3042"
True

4. strとbytes: エンコードとデコード

これまでにも述べましたが、Pythonのstr(文字列)はUnicodeのコードポイントで表されています。

外部出力、たとえばファイルに書きこむ時には、UTF-8などの符号化方式でエンコードする必要があります。

逆に、エンコードされたファイルを読み込んで文字列として表示するには、デコードが必要です。

たとえばopen関数は、encoding引数で符号化方式を指定することができます。 UTF-8はデフォルトの符号化方式なので、特に指定しなければUTF-8となりますが、以下では明示的に指定しました。

# 書き込み
>>> with open('tmp.txt', 'w', encoding='utf-8') as f:
        f.write('UTF-8だよ!!')
    
# 読み込み
>>> with open('tmp.txt', 'r', encoding='utf-8') as f:
        print(f.read())                                                               
                                                                                      
# OUT: UTF-8だよ!!   

文字列はエンコードされると、バイト列になります。

以下のように、エンコード/デコードをすることができます。

(str, bytesそれぞれのメソッドを使って変換していますが、他にもstr, bytesコンストラクタに渡したりcodecsを使う方法もあります: 省略)

>>> 'ぴた'.encode(encoding='utf-8')
b'\xe3\x81\xb4\xe3\x81\x9f'

>>> b'\xe3\x81\xb4\xe3\x81\x9f'.decode(encoding='utf-8')
'ぴた'

5. UnicodeUTF-8 エンコードの仕組み

この章の本題です。

UTF-8の最初の方はASCIIと互換性がある

※ 理論の詳しい部分は、以下のブログまたは動画(10分くらい)がわかりやすいので参照していただきたいです。

(自分の記事では、Pythonを使って手を動かしながら挙動を確かめることに焦点を当てています.)

Unicodeとは? その歴史と進化、開発者向け基礎知識 - Build Insider

Characters, Symbols and the Unicode Miracle - Computerphile - YouTube

さて、日本語の文字列をutf-8エンコードすると、バイト列が16進数表示で出力されていました。

ではアルファベットはどうなるでしょうか。

# 「あ」は 3バイト
>>> 'あ'.encode('utf8')
b'\xe3\x81\x82'

# 「a」は[b'a']にエンコードされた。
>>> 'a'.encode('utf8')
b'a'

バイトリテラル表記のb'a'になりました。

UTF-8では、0始まりの1バイト(つまり7ビット分)は完全にASCIIと互換性を持っています。

0から始まる1バイト(0b_0000_00000b_0111_1111、10進数では0127)はASCIIと同じコードポイントとなるのです。

ord関数を使って"a"のコードポイントを求めてみましょう。

>>> ord('a')  # Unicodeのコードポイントを求める関数.
97

>>> bytes([97])  # ASCIIでも97番号は"a"
b'a'

マルチバイト文字がエンコーディングされる様子を見てみよう

一方、日本語をUTF-8エンコードすると、ひらがな一文字で3バイトの大きさのバイト列が出力されていました。

これは、UTF-8可変長の文字コードとなっているからです。1バイトで表現し切ることができない文字は、複数バイトをつかってエンコードします。

その仕組みを詳しくみていきます。

まず、ひらがなの「あ」をエンコードした結果を2進数で表すと以下のようになります。

>>> 'あ'.encode('utf-8')                    # エンコード
b'\xe3\x81\x82'

>>> list(b'\xe3\x81\x82')                   # 10進数に変換
[227, 129, 130]

>>> (bin(227), bin(129), bin(130))          # 2進数に変換
('0b11100011', '0b10000001', '0b10000010')

わかりやすく、以降はハイフンで区切ります。

1バイト目が0b_1110_0011 2バイト目が0b_1000_0001 3バイト目が0b_1000_0010 となっています。

UTF-8では、文字の最初数bitをみるとそのバイトがどのような役割なのか分かるようになっています。

先程紹介したASCII互換の部分(1バイト文字)では、必ず0から始まります。逆に言えば、0から始まる1バイトはASCII互換の部分だとして解釈します。

そして1から始まる場合、

  • 10xx xxxx -> マルチバイト文字の途中.
  • 110x xxxx -> 2バイトからなるマルチバイト文字の始まり
  • 1110 xxxx -> 3バイトからなるマルチバイト文字の始まり

このような意味になります。実際にやってみた方が早いので、「あ」のUnicodeコードポイントを手動で求めてみましょう。

  1. 1バイト目は0b_1110_0011でした。
    • 1110 xxxパターンなので、3バイトからなることがわかります。
    • ここで、「3バイト」を示す最初の1110をのぞいた4bitを取り出します。1110_00110011
  2. 2バイト目は0b_1000_0001でした。
    • 10xx xxxxパターンなのでマルチバイト文字の途中です。
    • ここで、「途中」を示す最初の10をのぞいた6bitを取り出します。1000_000100_0001
  3. 3バイト目は0b_1000_0010でした。
    • 10xx xxxxパターンなので、これも2バイト目と同じです。
    • 「途中」を示す最初の10をのぞいた6bitを取り出します。1000_001000_0010

次に、1〜3バイト目から取り出したバイト列を結合します。

0011, 00_0001, 00_0010 を結合→ 0011_00_0001_00_0010

では、この結果をchr関数によってUnicodeのコードポイントとして解釈し、対応する文字を取り出します。

(※ Pythonでは0bを銭湯につければ2進数として扱うことができます)

>>> chr(0b_0011_00_0001_00_0010)
'あ'

文字「あ」が出力されました。 これで、「あ」をUTF-8エンコードして得られたバイト列を手動で解釈し、Unicodeのコードポイントを取り出せたことがわかります。

まとめ

Pythonで「文字」を操作できるsrtやbytesだが、実体は数字の並びであることを意識すると仕組みがよく分かる。

  • Pythonのbytes型はASCIIコードで変換された文字として出力されるが、実体は数値が並んだものである。
  • Pythonのstr型は文字列をUnicode(文字集合)として扱っている。その実体はコードポイント(数字)
  • UTF-8で文字列を符号化するとバイト列になる。
    • 結果のバイト列は、コードポイント(数値)を特定のルールを元にバイト列で表現したものであった。
    • ルールがわかれば、手動でもエンコードすることができる。

あとがき

以上です。自分がPythonを描き始めた頃に混乱したbytesやstr型の挙動をまとめました。

また、文字コードの仕組みについても実際にコードを動かして理解する助けになればいいな、という観点で紹介しました。

間違っている部分や不正確な部分などあれば、ぜひご指摘いただけると、とっても嬉しいです!!