Python 3 での文字列とバイト列の相互変換と16進数表示


## 概要

REPL を通して文字列とバイト列の相互変換と16進数表記について調べたことをまとめました。16進数表記に関して従来の `%` の代わりに `format` や `hex` を使うことできます。レガシーエンコーディングを扱う場合、Unicode と1対1の関係にない文字が存在するので、不用意に変換すると元の文字に戻すことができないことがあります。

## 文字列とバイト列の相互変換

`encode`、`decode` メソッドを使います。

```python
>>> 'abcd'.encode()
b'abcd'
>>> b'abcd'.decode()
'abcd'
```

これらのメソッドのデフォルトのエンコーディングは `encoding='utf-8'` です。

```python
>>> 'abcd'.encode(encoding='utf-8')
b'abcd'
>>> b'abcd'.decode(encoding='utf-8')
'abcd'
```

不正なバイト列に遭遇した場合、デフォルトのふるまいはエラーになります。

```python
>>> b'\xff'.decode()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte
```

代替文字に置き換える場合、`errors='replace'` を指定します。

```python
>>> b'\xff'.decode('utf-8', 'replace')
'�'
```

キーワード引数を指定することができます。

```python
>>> b'\xff'.decode(encoding='utf-8', errors='replace')
'�'
>>> str(b'\xff', encoding='utf-8', errors='replace')
'�'
```

文字列とバイト列の変換には `bytes` および `str` 関数を使うこともできます。

```python
>>> bytes('abcd', encoding='utf-8', errors='replace')
b'abcd'
>>> str(b'abcd', encoding='utf-8', errors='replace')
'abcd'
```

## バイト列の16進数表記

想定しない変換で文字を壊したとき、生のデータを目で確認するために16進数表記が使われます。

print 関数や対話式プログラムではバイト列に含まれる ASCII は16進数で表示されません。

```python
>>> b'\x61\x62\x63\x64'
b'abcd'
>>> '\x61\x62\x63\x64'
'abcd'
```

Python 3.5 で導入された hex 関数を使えば、16進数文字列に変換されます。

```python
>>> b'\x61\x62\x63\x64'.hex()
'61626364'
>>> '\x61\x62\x63\x64'.encode('utf-8', 'replace').hex()
'61626364'
```

`\xXX` の形式で表示したいのであればリスト内包表記と `format` を使います。`hex` も代わりに使うことができます。

```python
>>> ''.join([r'\x{:02x}'.format(x) for x in b'\x61\x62\x63\x64'])
'\\x61\\x62\\x63\\x64'
>>> ''.join([hex(x)[2:] for x in b'\x61\x62\x63\x64'])
'61626364'
```

Python 2 系との互換性を保ちたいのであれば、`%` を使うことができます。

```python
>>> print(''.join(r'\x%02X' % x for x in b'\x61\x62\x63\x64'))
\x61\x62\x63\x64
```

## 16進数とバイト列の相互変換

[こちら](https://qiita.com/masakielastic/items/21ba9f68ef6c4fd7692d)の記事をご参照ください。

## Unicode リテラル

Unicode リテラルは外国人とのやりとりや表示端末に導入されているフォントの種類にかぎられているときに役立ちます。

`encode` の `unicode_escape` は文字列を Unicode リテラルに変換してくれます。ただし、ASCII は変換してくれません。

```python
>>> 'aあ\U0001f40d'.encode('unicode_escape')
b'a\\u3042\\U0001f40d'
```

ASCII の Unicode リテラルも必要であれば、内包表記を使ってエスケープできます。

```python
>>> [r'\u{:04x}'.format(cp) if cp < 0x10000 else r'\U{:08x}'.format(cp) for cp in [ord(v) for v in 'aあ\U0001f40d'] if cp]
['\\u0061', '\\u3042', '\\U0001f40d']
```

コードポイントから Unicode リテラルを求める方法は複数あります。

```python
>>> cp = 0x1f40d
>>> r'\U{:08x}'.format(cp)
>>> r'\U'+format(cp, 'x').zfill(8)
>>> r'\U'+hex(cp)[2:].zfill(8)
>>> r'\U%08x' % cp
'\\U0001f40d'
```

U+10000 以降の文字のサロゲートペアのコードポイントを求める必要があるなら、`json.dumps` を使います。

```
>>> import json
>>> [r'\u{:04x}'.format(cp) if cp < 0x10000 else json.dumps(chr(cp))[1:-1] for cp in [ord(v) for v in 'aあ\U0001f40d'] if cp]
```

UTF-32 と UTF-16 の相互変換の式は Unicode.org の [Q&A](http://unicode.org/faq/utf_bom.html#utf16-4) に記載されています。

## 文字とコードポイントの相互変換

`ord` および `chr` を使って文字とコードポイントを相互変換できます。

```python
>>> hex(ord('あ'))
'0x3042'
>>> hex(ord('\u3042'))
'0x3042'
>>> chr(0x3042)
'あ'
```

サロゲートの範囲は utf-8 で無効ですが、文字およびコードポイントに変換できます。

```python
>>> chr(0xd800)
'\ud800'
>>> ord('\ud800')
55296
>>> 0xd800
55296
```

ただし、サロゲートをバイト列に変換する場合、エラーになります。

```python
>>> chr(0xd800).encode('utf-8')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeEncodeError: 'utf-8' codec can't encode character '\ud800' in position 0: surrogates not allowed
```

エラーで停止させたくなければ、代替文字に置き換えるオプションを指定します。

```python
>>> chr(0xd800).encode('utf-8', 'replace')
b'?'
```

U+10000 以降のサロゲートペアを求めるには `json.dumps` を使います。

```python
>>> (lambda x: [json.dumps(x)[3:7], json.dumps(x)[9:-1]])('\U0001f40d')
['d83d', 'dc0d']
```

サロゲートペアのコードポイントをそれぞれ chr で変換して連結しても元の文字は生成されません。

```python
>>> chr(0xd83d) + chr(0xdc0d)
'\ud83d\udc0d'
```

`json.loads` を使います。

```python
>>> (lambda x: json.loads('"\\u' + format(x[0], 'x') + '\\u' + format(x[1], 'x') + '"'))([0xd83d, 0xdc0d])
'🐍'
```

## 文字列とレガシーエンコーディングとの相互変換

文字列からレガシーエンコーディングとの相互変換には 'encode'、'decode' を使います。レガシーエンコーディングはバイト列であらわされます。

```python
>>> 'あ'.encode('utf-8')
b'\xe3\x81\x82'
>>> 'あ'.encode('sjis')
b'\x82\xa0'
>>> b'\x82\xa0'.decode('sjis')
'あ'
```

レガシーエンコーディングのなかには Unicode との往復変換で別の文字に置き換わってしまうものがあります。次の cp932 の文字で確認してみましょう。

```python
>>> c = b'\x87\x97'.decode('cp932')
>>> '0x{:x}'.format(ord(c))
'0x2220'
>>> chr(0x2220).encode('cp932')
b'\x81\xda'
```

0x8797 (cp932) -> U+2220 (utf-8) -> 0x81da (cp932) に変換されていることがわかります。往復変換が保障されない文字の一覧はマイクロソフトの[サイト](https://support.microsoft.com/ja-jp/help/170559/prb-conversion-problem-between-shift-jis-and-unicode)で公開されています。文字のテストデータの作り方については[こちら](https://qiita.com/masakielastic/items/272fdc4df317c8ad0ff1)の記事をご参照ください。

### 文字、バイト単位の展開

文字列型、バイト列は特別な変換をしなくても for ループで1文字、バイト単位で展開できます。

```python
>>> [x for x in 'あいうえお']
['あ', 'い', 'う', 'え', 'お']
>>> ''.join([x for x in 'あいうえお'])
'あいうえお'
```

次はバイト型を試してみましょう。

```python
>>> 'あ'.encode('utf-8')
b'\xe3\x81\x82'
>>> [format(x, 'x') for x in b'\xe3\x81\x82']
['e3', '81', '82']
```

`codecs.iterencode` を使うとそれぞれの要素はバイトに変換されます。

```python
>>> import codecs
>>> [x for x in codecs.iterencode('abcd', 'utf-8')]
[b'a', b'b', b'c', b'd']
```

`codecs.iterdecode` はリストの要素をバイト型から文字列型に変換します。

```python
>>> [x for x in codecs.iterdecode([b'a', b'b', b'c', b'd'], 'utf-8')]
['a', 'b', 'c', 'd']
>>> ''.join([x for x in codecs.iterdecode([b'a', b'b', b'c', b'd'], 'utf-8')])
'abcd'
```

レガシーな文字エンコーディングを1文字ずつ展開するのであれば、一度文字列に変換にするか、文字列に変換したくないのであれば、バイト列を解析するデコーダーが必要になります。

```python
>>> 'あいう'.encode('sjis', 'replace')
b'\x82\xa0\x82\xa2\x82\xa4'
>>> [x.encode('cp932') for x in b'\x82\xa0\x82\xa2\x82\xa4'.decode('sjis')]
[b'\x82\xa0', b'\x82\xa2', b'\x82\xa4']
```

### スライス

文字列、バイト列のスライスを求めてみましょう。

```python
>>> 'あいう'[0]
'あ'
>>> 'あいう'[:2]
'あい'
>>> 'あいう'[2:]
'う'
```

今度はバイト列を試してみましょう。

```
>>> 'あいう'.encode('utf-8')
b'\xe3\x81\x82\xe3\x81\x84\xe3\x81\x86'
>>> 'あいう'.encode('utf-8')[:2]
b'\xe3\x81'
```

レガシーな文字エンコーディングの場合、文字単位でスライスするには文字列型に変換する方法があります。

```python
>>> 'あいう'.encode('sjis')
b'\x82\xa0\x82\xa2\x82\xa4'
>>> b'\x82\xa0\x82\xa2\x82\xa4'.decode('sjis')[0].encode('sjis')
b'\x82\xa0'
```

### 文字数、バイト数

`len` を使います。

```python
>>> len('あいう')
3
>>> 'あいう'.encode('utf-8')
b'\xe3\x81\x82\xe3\x81\x84\xe3\x81\x86'
>>> len('あいう'.encode('utf-8'))
9
```

レガシーな文字エンコーディングの場合、バイト数が得られます。

```python
>>> 'あいう'.encode('sjis')
b'\x82\xa0\x82\xa2\x82\xa4'
>>> len('あいう'.encode('sjis'))
6
```

文字数が必要な場合、文字列型に変換する方法があります。

```python
>>> len(b'\x82\xa0\x82\xa2\x82\xa4'.decode('sjis'))
3
```

len はリスト型にも使うことができます。

```python
>>> [x for x in 'あいう']
['あ', 'い', 'う']
>>> len([x for x in 'あいう'])
3
>>> [format(x, 'x') for x in 'あいう'.encode('utf-8')]
['e3', '81', '82', 'e3', '81', '84', 'e3', '81', '86']
>>> len([x for x in 'あいう'.encode('utf-8')])
9
```

### 不正なバイト列が含まれている場合のバリデーション

不正なバイト列が含まれている場合、decode、encode はエラーを引き起こします。

```python
>>> b'a\xffc'.decode('utf-8')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 1: invalid start byte
```

```python
>>> b'a\xffc'.decode('utf-8', 'replace')
'a�c'
>>> b'a\xffc'.decode('utf-8', 'replace').encode('utf-8')
b'a\xef\xbf\xbdc'
```

`errors` オプションに `replace` を指定すれば代替文字に置き換わります。
つまり、往復変換して代替文字に置き換わっているかどうかをチェックすることで、文字列が妥当であるかのバリデーションを実行することができます。

```python
>>> b'a\xffc'.decode('utf-8', 'replace')
'a�c'
>>> b'a\xffc'.decode('utf-8', 'replace').encode('unicode_escape')
b'a\\ufffdc'
>>> b'a\xffc'.decode('utf-8', 'replace').encode('utf-8')
b'a\xef\xbf\xbdc'
>>> b'a\xffc' == b'a\xef\xbf\xbdc'
False
>>> 
```

utf-8 の代替文字が U+FFFD であるのに対して、レガシーな文字エンコーディングの代替文字は `?` なので、`decode`、`encode` の両方で `replace` オプションを指定する必要があります。

```python
>>> b'ab\xffc'.decode('sjis', 'replace').encode('sjis', 'replace')
b'ab?c'
```

cp932 の場合、往復変換の安全性が保障されない文字を誤検出する可能性があります。

```python
>>> b'\x87\x97'.decode('cp932', 'replace').encode('cp932', 'replace')
b'\x81\xda'
```