CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: オブジェクトを漬物 (Pickle) にする

Python の標準ライブラリにある pickle モジュールは Python のオブジェクトを直列化・非直列化するための機能を提供している。 直列化 (Serialize) というのはプログラミング言語においてオブジェクトをバイト列などの表現に変換することを指す。 非直列化 (Deserialize) はその逆で、バイト列を元にオブジェクトを復元することだ。 バイト列などに変換されたデータはファイルなどの形で永続化できる。 最近の典型的な用途でいえば、機械学習で学習済みのモデルを保存して取り回すためなんかに使われる。

ところで、モジュール名でもある pickle という単語は、複数形にすると pickles つまりハンバーガーなどにはさまっているピクルスになる。 きっとメモリ上にある揮発性で賞味期限の短いオブジェクトを、バイト列などの形でファイルなどに長期保存できる状態にすることをピクルス、つまり漬物に見立てているのだろう。 お肉を保存食のベーコンに仕上げるときに漬けこむ液体もピックル液といったりするね。

もくじ

使ってみよう

まず、使用する Python のバージョンは次の通り。 なぜここでバージョンを示したのか不思議に思うかもしれないけど、これは後で意味を持ってくる。

$ python --version
Python 3.5.0

動作確認は Python の REPL を使って行う。 まずは pickle モジュールをインポートしよう。 標準ライブラリなので新たに何か入れるといったことは必要はない。

$ python
>>> import pickle

まずは Python のオブジェクトをバイト列に直列化して、それをファイルに書き込んでみる。

最初に 'sample.pickle' というファイル名を開いて、ファイルオブジェクトをバイナリの書き込みモード (wb) で取得する。 そして、そのファイルオブジェクトに 'Hello, World!' という文字列を pickle.dump() 関数を使って直列化した上で書き込む。

>>> with open('sample.pickle', mode='wb') as f:
...     pickle.dump('Hello, World!', f)
...

これでファイルに Python の文字列オブジェクト (のバイト列表現) が書き込まれた。

では、同じ REPL 上で改めて先ほど保存したファイルを今度はバイナリの読み込みモード (rb) で開き直そう。 取得したファイルオブジェクトを pickle.load() 関数に渡せば、自動的にオブジェクトが復元される。

>>> with open('sample.pickle', mode='rb') as f:
...     pickle.load(f)
...
'Hello, World!'

ファイルに書き込まれていたバイト列からオブジェクトが復元されたことがわかる。

ちなみに Python ではこのように pickle モジュールを使って Python オブジェクトを直列化・非直列化することを Pickle 化 (Pickling)・非 Pickle 化 (Unpickling) と呼ぶ。

ファイルを介すのが面倒くさい

先ほどの例では本物のファイルを介して動作を確認した。 しかし、使うたびに毎回ファイルを介さないといけないのは面倒に感じるはず。 そこで、ここではファイルオブジェクトを使用しない例をふたつ紹介する。

まずひとつ目はインメモリのファイルライクオブジェクトである io.BytesIO クラスを使うやり方。 これはファイルライクオブジェクトといってファイルオブジェクトと同じインターフェースをもったオブジェクトになっている。 ただし、読み書きされた内容はメモリ上に存在するため実際のファイルができたり OS のファイル記述子を消費したりといったことがない。

>>> import io
>>> file = io.BytesIO()
>>> pickle.dump('Hello, World!', file)
>>> file.seek(0)
0
>>> pickle.load(file)
'Hello, World!'

ふたつ目はバイト列を直接扱うやり方。 取り出したバイト列は、もちろんファイルに書き込むことやネットワークで送ることもできるので汎用性の高い方法といえる。

>>> binary = pickle.dumps('Hello, World!')
>>> binary
b'\x80\x03X\r\x00\x00\x00Hello, World!q\x00.'
>>> pickle.loads(binary)
'Hello, World!'

プロトコルバージョン

最初のほうで Python のバージョンを示していた。 先ほどの例では 3.5 を使っていたところを、次は 2.7 を使ってみよう。

$ python --version
Python 2.7.10

この環境で先ほど保存したファイルを読み込むとどうなるだろうか。 なんと同じ手順で読み込んだのに、今度は例外になってしまった。 エラーメッセージでは、どうやら pickle がサポートしていないプロトコルだといっている。

>>> import pickle
>>> with open('sample.pickle', mode='rb') as f:
...     pickle.load(f)
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/pickle.py", line 1378, in load
    return Unpickler(file).load()
  File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/pickle.py", line 858, in load
    dispatch[key](self)
  File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/pickle.py", line 886, in load_proto
    raise ValueError, "unsupported pickle protocol: %d" % proto
ValueError: unsupported pickle protocol: 3

そう、実は pickle がオブジェクトを Pickle 化・非 Pickle 化する際のやり方 (プロトコル) には複数のバージョンが存在している。 プロトコルのバージョンは 0 から始まって、より新しいものほど大きな数字が割り振られている。 新しい (数字が大きい) ものほど効率が良いなど優れたものになっているが、反面それは古いバージョンの Python で使うことができない。 例えば Python 2.7 で使用できるプロトコルのバージョンは最大で 2 までとなっている。 つまり、Python 3.5 と 2.7 の両方で扱うことのできるバイト列を pickle モジュールで作るにはプロトコルのバージョンを 2 以下に指定しなければいけないということになる。

改めて Python 3.5 でプロトコルのバージョンを 2 に指定してファイルに書き込んでみよう。

$ python --version
Python 3.5.0
$ python
>>> import pickle
>>> with open('sample.pickle', mode='wb') as f:
...     pickle.dump('Hello, World!', f, protocol=2)
...

それを Python 2.7 で読み込む。 今度はうまくいった。

$ python --version
Python 2.7.10
$ python
>>> import pickle
>>> with open('sample.pickle', mode='rb') as f:
...     pickle.load(f)
...
u'Hello, World!'

保存されるもの・されないもの

また、残念ながら pickle はオブジェクトであればすべての情報をバイト列にしてくれるわけではない。 例えば、関数やクラスを Pickle 化しようとしても、それにはモジュールとオブジェクトの名前しか記録されない。 つまり、コードはバイト列の中に含まれていないということ。 しかるに非 Pickle 化する環境では Pickle 化したときと同じ名前で同じモジュールとオブジェクトがインポートできる状態になければいけない。

例えば、同じ REPL の中であれば定義した関数は同じものなので問題なく Pickle 化・非 Pickle 化できる。

>>> def greet():
...     print('Hello, World!')
...
>>> binary = pickle.dumps(greet)
>>> restored_obj = pickle.loads(binary)
>>> type(restored_obj)
<class 'function'>
>>> restored_obj()
Hello, World!

では、この関数オブジェクトを 'function.pickle' という名前でファイルに保存しておこう。 そして、一旦その REPL は終了する。

>>> with open('function.pickle', mode='wb') as f:
...     pickle.dump(greet, f)
...
>>> exit()

再度 REPL を起動したら、保存したファイルを読み込んで非 Pickle 化してみよう。 この場合、開き直した REPL には関数 greet() の定義がないことから例外になる。 ファイルに保存されたバイト列の中には名前の情報しかなくコードの情報がないので復元できないということだ。

$ python
>>> import pickle
>>> with open('function.pickle', mode='rb') as f:
...     pickle.load(f)
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
AttributeError: Can't get attribute 'greet' on <module '__main__' (built-in)>

では、同じ名前を持った異なるコードだとどうなるだろうか? 今度は同じ名前をもった greet() 関数を定義しているが標準出力に書き出している内容が異なる。

>>> def greet():
...     print('Hello, Pickle!')
...
>>> with open('function.pickle', mode='rb') as f:
...     function = pickle.load(f)
...
>>> function()
Hello, Pickle!

実行結果をみると、pickle モジュールは名前は同じだが内容は異なる関数を素直に読み込んで実行してしまった。

つまり、pickle モジュールは読み込む内容を信用しているということだ。 システムに危険を及ぼすような内容がそれに含まれていたとしても pickle モジュールはそれを防いでくれない。 したがって、脆弱性になるので信用できない内容は非 Pickle 化してはいけない。

クラスインスタンス

次はクラスインスタンスを Pickle 化してみよう。 インスタンスのもっている属性の情報はちゃんとバイト列の中に含まれるので安心してほしい。 (ただし、もちろんクラス自体のもつコード、例えばメソッドがどういった処理をもっているかについてはバイト列に含まれない)

まずはユーザ定義クラス MyClass を定義する。 このクラスは属性としてメンバ変数 msg とメソッド greet() をもっている。 メソッド greet() ではメンバ変数 msg を表示する。 ユーザ定義クラス MyClass をインスタンス化したら、それを Pickle 化してファイルに保存しよう。 終わったら一旦 REPL を終了させておく。

>>> class MyClass(object):
...     def __init__(self, msg):
...         self.msg = msg
...     def greet(self):
...         print(self.msg)
...
>>> obj = MyClass('Hello, World!')
>>> with open('class.pickle', mode='wb') as f:
...     pickle.dump(obj, f)
...
>>> exit()

もう一度 REPL を立ち上げてファイルからクラスを非 Pickle 化する。 先ほど関数を例にして確認した通りだけど、クラスについてもコードはバイト列の中に保存されない。 つまり、同じ名前のモジュールとオブジェクトがなければ非 Pickle 化しようとしても例外になってしまう。

$ python
>>> import pickle
>>> with open('class.pickle', mode='rb') as f:
...     pickle.load(f)
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
AttributeError: Can't get attribute 'MyClass' on <module '__main__' (built-in)>

そのため再度ユーザ定義クラス MyClass を用意する必要がある。 今回は REPL を使ったのでこんな面倒なことになっているけど、通常であれば自分で書いた Pickle を扱うモジュールまたはパッケージの中にクラスは含まれているはず。

>>> class MyClass(object):
...     def __init__(self, msg):
...         self.msg = msg
...     def greet(self):
...         print(self.msg)
...

改めてファイルからインスタンスを読み込んでメソッドを実行しよう。 ここで適切にメンバ変数 msg の内容が出力されていることから、その情報が Pickle 化したバイト列の中に含まれていることが確認できた。

>>> with open('class.pickle', mode='rb') as f:
...     obj = pickle.load(f)
...
>>> obj.greet()
Hello, World!

Pickle 化できないもの

しかし、メンバ変数の内容が Pickle 化できるからといって、何も考えずにオブジェクトをそのままバイト列にできるかというとそうではない。 Python には Pickle 化できないオブジェクトが一部にあるからだ。 例えばファイルオブジェクトはその代表といえる。 ファイルオブジェクトを Pickle 化しようとすると、以下のように TypeError 例外になってしまう。

>>> file = open('/dev/null', mode='rb')
>>> pickle.dumps(file)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: cannot serialize '_io.BufferedReader' object

これはつまり、Pickle 化できないオブジェクトがメンバ変数に含まれるインスタンスは、それが丸ごと Pickle 化できないことを意味する。 次のサンプルコードでは、ユーザ定義クラス Sample がメンバ変数に Pickle 化できないファイルオブジェクトをもっている。 そして main() 関数ではそのインスタンスの Pickle 化・非 Pickle 化を試みている。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import pickle


class Sample(object):

    def __init__(self, filename):
        # 文字列は Pickle 化できる
        self.filename = filename

        # ファイルオブジェクトは Pickle 化できない
        self.file = open(filename, mode='rb')


def main():
    obj = Sample('/dev/null')
    binary = pickle.dumps(obj)
    restored_obj = pickle.loads(binary)
    print(restored_obj.filename)
    print(restored_obj.file)


if __name__ == '__main__':
    main()

上記のサンプルコードを実行してみよう。

$ python picklefile.py 
Traceback (most recent call last):
  File "picklefile.py", line 28, in <module>
    main()
  File "picklefile.py", line 21, in main
    binary = pickle.dumps(obj)
TypeError: cannot serialize '_io.BufferedReader' object

やはりインスタンスに Pickle 化できないオブジェクトが含まれるため TypeError 例外になってしまった。

Pickle 化できないオブジェクトを扱う方法

では、Pickle 化できないオブジェクトがメンバ変数に含まれる時点でお手上げかというとそうではない。 Python ではこの問題に対処するための方法が用意されている。 それが特殊メソッド __getstate__() と __setstate__() を使うやり方だ。 このふたつの特殊メソッドはオブジェクトが Pickle 化・非 Pickle 化される際の処理に対応している。 つまり、その挙動をオーバーライドできるということだ。

次のサンプルコードではユーザ定義クラス Sample に __getstate__() と __setstate__() を実装することで Pickle 化できるようにしている。 具体的な処理としては、Pickle 化されるときに呼ばれる __getstate__() で Pickle 化できないオブジェクトを Pickle 化の対象から除外している。 そして、非 Pickle 化されるときに呼ばれる __setstate__() では、ファイル名からファイルオブジェクトを改めて作りなおしている。 尚、非 Pickle 化される際には __init__() メソッドは呼ばれない点に注意が必要となる。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import pickle


class Sample(object):

    def __init__(self, filename):
        """非 Pickle 化されるときは呼ばれない"""

        # 文字列は Pickle 化できる
        self.filename = filename

        # ファイルオブジェクトは Pickle 化できない
        self.file = open(filename, mode='rb')

    def __getstate__(self):
        """Pickle 化されるとき呼ばれる"""

        # オブジェクトの持つ属性をコピーする
        state = self.__dict__.copy()

        # Pickle 化できない属性を除去する
        del state['file']

        # Pickle 化する属性を返す
        return state

    def __setstate__(self, state):
        """非 Pickle 化されるとき呼ばれる"""

        # オブジェクトの持つ属性を復元する
        self.__dict__.update(state)

        # Pickle 化できなかった属性を作りなおす
        self.file = open(self.filename, mode='rb')


def main():
    obj = Sample('/dev/null')
    binary = pickle.dumps(obj)
    restored_obj = pickle.loads(binary)
    print(restored_obj.filename)
    print(restored_obj.file)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python picklefile.py 
/dev/null
<_io.BufferedReader name='/dev/null'>

今度は例外にならず適切にオブジェクトを Pickle 化・非 Pickle 化できた。

まとめ

  • Python のオブジェクトをファイルなどに保存するには pickle モジュールを使う
  • オブジェクトをバイト列などの表現に直列化・非直列化することは Pickle 化・非 Pickle 化という
  • 脆弱性になるので信用できない内容を非 Pickle 化してはいけない
  • Pickle 化・非 Pickle 化する際のプロトコルにはバージョンがあって適切な番号を選ぶ必要がある
  • ソースコードの情報は Pickle 化されない
  • オブジェクトの中には Pickle 化できないものがある
  • Pickle 化できないオブジェクトを含むインスタンスを扱うには特殊メソッド __getstate__() と __setstate__() を使う

いじょう。