Python3.8の新機能(3) - Pickleの改良

#はじめに
2019年10月にリリースを予定しているPython3.8で新たに加わる変更を[Python3.8の新機能 (まとめ)](https://qiita.com/ksato9700/items/3846e8db573a07c71c33)という記事でまとめ始めています。分量のある変更は別記事にしていて、これはその第3弾のPickleの改良についての解説です。

#Pickleとは
PickleはPythonのデータシリアライゼーション(データ直列化)のライブラリです。Pythonで扱っているデータをファイルなどに書き出しておきたい場合に、データ構造や型の情報を含めて記録し、読み込み時にはそのファイルにある情報のみで再構成をできるようにする仕組みです。JSONなどとも用途が似通っていますが、Pickleは以下のような特徴を持ちます。

* バイナリフォーマットなのでJSONやXMLのようなテキストフォーマットよりコンパクト
* 一般的な型だけでなくユーザ定義のクラスのインスタンスの直列化も可能
* 標準ライブラリに含まれているので追加のパッケージインストールが不要
* Python専用のフォーマットなので他の言語へのデータ受け渡しには不向き

Python 3.8ではこのPickleにいくつかの変更が加えられています

#3.8での変更点
大きく2つの変更点があります。

1. デフォルトのプロトコルバージョンが4になった
2. プロトコルバージョン5が追加され、out-of-bandデータのサポートが追加された

Pickleは1995年に最初に設計されて以来、これまでも何度か拡張されていてその度にプロトコルのバージョンが上がっています。これまでの最新プロトコルバージョンはPython3.4で導入された4でしたが、デフォルトのプロトコルバージョンはPython3.0で導入された3でした。これが、Python3.8からデフォルトのPickleプロトコルバージョンが4になり性能の改善が期待できます。バージョン4の詳細は[PEP 3154](https://www.python.org/dev/peps/pep-3154/)にあります。

2つ目のOut-of-bandデータのサポートですが、これは[PEP 574](https://www.python.org/dev/peps/pep-0574/)で提案された変更によるものです。ちょっと詳しく次の章で見てみたいと思います。

#なぜOut-of-bandデータが必要か？

PEP-574によると、Pickleは最近ではファイルへの書き出しというよりも(それらにはより汎用なJSONなどのフォーマットが使われる)、Pythonのプロセス間でのデータのやり取りに使われているようです。そして、データ解析などの応用ではとても大きなデータをPythonで扱う事が増えていて、それらをpickleでシリアライズする時に別手段で(out-of-band)で引きた渡したいという要求があります。

Out-of-bandデータは元々はネットワーク用語で、通常経路(in-band)とは別の経路(わき道)でデータ転送することです。データ経路が詰まっている時でも緊急度の高いコマンドなどを送れるようにする為に使われてました。経路は物理的に別れている場合と単に論理的な分離である場合とがありますが、アプリからは2つの経路があるように見える点では一緒です。そしてPickleの場合、Out-of-bandはシリアライズデータの中に入れずに別の手段での受け渡しになるわけですが、なぜそれが必要なのか？ それはできるだけコピーを避けるためです。

データを含めてin-bandでシリアライズ(dump)するとまずその全てを保持するためのバッファを用意してそこにデータをコピーすることになります。そしてそれを受け取った側でもそのシリアライズデータをデシリアライズ(load)する時にまたコピーが発生します。データが小さければそれほど大きな差にはなりませんが、数MBを超えるデータとなると話が変わってきます。それをOut-of-bandでシリアライズデータから切り離して引き渡すことでコピーの回数を減らせる可能性が出てきます。データを値ではなくポインター or 参照で引き渡すイメージですね。

#Out-of-bandデータの引き渡し方
まずは比較のためにProtocol Version 4の場合を見ます。`'abc'`を100回繰り返すバイト列をシリアライズしてみます。


```python:pickle_test_4.py
import pickle
import pickletools

data = bytearray('abc' * 100, 'ascii')
pickled = pickletools.optimize(pickle.dumps(data, protocol=4))
print("Serialized:", pickled)
print("Deserialized:", pickle.loads(pickled))
```

結果はこうなります。

```
Serialized: b'\x80\x04\x95J\x01\x00\x00\x00\x00\x00\x00\x8c\x08builtins\x8c\tbytearray\x93B,\x01\x00\x00abcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabc\x85R.'
Deserialized: bytearray(b'abcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabc')
```

`pickle.dumps`でバイト列にシリアライズされたデータが`pickle.loads`で元に戻されている事がわかります。シリアライズされたデータの中身が良くわからないので`pickletools.dis(pickled)`で逆アセンブル(？)してみます。

```
    0: \x80 PROTO      4
    2: \x95 FRAME      330
   11: \x8c SHORT_BINUNICODE 'builtins'
   21: \x8c SHORT_BINUNICODE 'bytearray'
   32: \x93 STACK_GLOBAL
   33: B    BINBYTES   b'abcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabc'
  338: \x85 TUPLE1
  339: R    REDUCE
  340: .    STOP
highest protocol among opcodes = 4
```

ほぼコード通りですが、組み込みのbytearrayを使って初期値`b'abcabc...'`からバイト列を生成するというという風になっている様です。Protocol Version 4までは、この初期値の部分がどんなに大きくてもここに格納することになり、シリアライズしたデータはその分大きくなってしまいます。そして生成の際にその分無駄なコピーが発生してしまいます。

これをProtocol Version 5の機能を使ってout-fo-band転送してみます。変更点は以下の3つ。

* out-of-band転送したいデータを含むクラスに`__reduce_ex__`メソッドを追加する。
* `pickle.dumps`で `buffer_callback`にコールバック関数を指定する。
* `pickle.loads`で `buffers`に引き渡しのためのバッファを指定する。

これだけだと良くわからないので、サンプルのコードで説明します。上記の例は Out-of-band転送する場合にはこの様になります。

```python:pickle_test_5.py
import pickle
import pickletools

class barray(bytearray):
    def __reduce_ex__(self, protocol):
        return type(self), (pickle.PickleBuffer(self),)

buffers = []

def buf_cb(pickle_buffer):
    data = pickle_buffer.raw().obj
    buffers.append(data)

data = barray('abc' * 100, 'ascii')
pickled = pickletools.optimize(pickle.dumps(
    data, protocol=5,  buffer_callback=buf_cb))
print("Serialized:", pickled)
print("Deserialized:", pickle.loads(pickled, buffers=buffers))

pickletools.dis(pickled)
```

まずひとつ目の`__reduce_ex__`メソッドですが、それぞれの型をどの様にシリアライズしたいかを定義できて、返り値として、2要素以上のタプルを返します。1要素目がそのデータを再構成する時に呼ばれるオブジェクト(通常はクラスオブジェクト)で、2要素目がその呼び出し引数(初期値)です。通常はデータそのものを2つ目の要素として指定しますが、Out-of-bandにしたい時はそれを`pickle.PickleBuffer`型にして返します。

なお、bytearrayは組み込みの型で直接メソッドを追加できないので、ここの例ではそれを基底クラスとする新しいクラス `barray`を作り、そこに`__reduce_ex__`メソッドを追加しています。

そして2つ目の変更点が`dumps()`の`buffer_callback`引数で、ここに`PickleBuffer`を引数として取るコールバック関数を指定します。このコールバック関数は`dumps()`がデータをシリアライズする過程で`PickleBuffer`に遭遇する度に呼ばれますが、ここの例ではOut-of-band転送したいデータを `buffers`という配列に追加していっています。

で、3つ目の変更点の`loads()`の`buffers`引数。ここに`dumps()`のコールバック関数で設定された`buffers`という配列を指定します。それを理解するには上記のコードでどのようなシリアライズデータが作成されるのかを見るのが早いと思います。

実行結果はこんな風になります。

```
Serialized: b'\x80\x05\x95\x17\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x8c\x06barray\x93\x97\x85R.'
Deserialized: barray(b'abcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabc')
    0: \x80 PROTO      5
    2: \x95 FRAME      23
   11: \x8c SHORT_BINUNICODE '__main__'
   21: \x8c SHORT_BINUNICODE 'barray'
   29: \x93 STACK_GLOBAL
   30: \x97 NEXT_BUFFER
   31: \x85 TUPLE1
   32: R    REDUCE
   33: .    STOP
highest protocol among opcodes = 5
```
まずシリアライズされたデータのサイズが全然違います(Version 4が341バイトだったのに対してVersion 5では34バイト)。上記の逆アセンブルされたシリアライズデータを見るとわかると思いますが、初期化データはここには入っていません。代わりに`NEXT_BUFFER`というopcodeが見えます。`loads()`はこのopcodeに遭遇する度に引数で与えられた配列からデータを一つずつ取ってきて使います。上手くやればコピーなしでデータの引き渡しができるでしょう。

#まとめ
これまであまりPickleって使ったことがなかったのですが、今回の変更をきっかけに少し理解が深まりました。ファイルに書き出しておくだけならJSONとかの方が汎用性もあって便利だろうなと思っていましたが、Python間でのデータの受け渡しに、ユーザ定義の型情報も含んだ形で行えるのはちょっと便利かも知れません。今後、なにか作るときの選択肢の一つとして考えたいと思います。