search
LoginSignup
28

More than 5 years have passed since last update.

posted at

updated at

ユーザ定義クラスのpickle, unpickle処理のまとめ

概要

pickleはPython独自のデータシリアライズフォーマットで非常に強力な仕組みだが、その背後の動作は高い柔軟性と過去の経緯より単純ではない。
ここでは、主要なbuiltinクラス以外(マイナーなbuiltinクラス、標準・非標準のライブラリ・ユーザ定義クラスなど)がpickle化, unpickle化される過程をまとめ、どのようにすれば効率的にpickle化できるユーザ定義クラスが作れるかまとめた。

なお、ここでは Python 3.3.5 及び Pickle Protocol Version 3 を前提にして話を進める。
Python 3.4 より Protocol Version 4 が導入されたが、内部処理はさらに複雑になったため、まずPython 3.3のコードで理解をするは効率的かと思う。

pickle化の処理の流れ

主に、下記のメソッドを追っていけば理解できる。

Lib/pickle.py
class _Pickler:
    def save(self, obj, save_persistent_id=True):
        ...
    def save_reduce(self, func, args, state=None,
                    listitems=None, dictitems=None, obj=None):
        ...
    def save_global(self, obj, name=None, pack=struct.pack):
        ...
Objects/typeobject.c
static PyObject *
reduce_2(PyObject *obj)
{
    ...
}

static PyObject *
object_reduce_ex(PyObject *self, PyObject *args)
{
    ...
}

pickle化の1stステップ

pickle.dump, pickle.dumpsなどが呼ばれると、全ては下記の処理によりpickle化される。

sample1.py
pickler = pickle.Pickler(fileobj, protocol)
pickler.dump(obj)

なお、Picklerクラスは、
1. C実装の_pickle.Pickler、または
2. Python実装のpickle._Pickler
で、それぞれ下記の場所に実体がある。
1. Modules/_pickler.c で定義されている static PyTypeObject Pickler_Type;
2. Lib/pickle.py で定義されている class _Pickler
通常はC実装が優先的に使用されるが、importに失敗した場合はPython実装が使用される。
ここでは仕組みの理解が主目的なので、Python実装を中心に話を進める。

個別のオブジェクトは、pickler.save(obj)により再帰的にpickle化されていく。
まず循環参照や複数個所など参照されているなどによる既出のオブジェクトは、この関数の前半で適切に前方への参照としてpickle化される。

主要なbuiltinクラスの場合

下記のbuiltinクラスおよび定数は多く使われるため、Pickleは専用の効率的な処理を実装している。
このため、本稿での説明には該当しないので割愛する。
int, float, str, bytes, list, tuple, dict, bool, None
これ以外のクラスの場合、以下に示す手順でpickle化される。

クラスオブジェクト又は関数の場合

pickle化対象がクラスオブジェクト(すなわちisinstance(obj, type) == True)の場合又は関数の場合、obj.__module__, obj.__name__が文字列として記録される。
unpickle化では、必要なモジュールをimportしたうえで、この変数名で参照できる値がunpickle化される。
すなわち、モジュールのグローバルな名前空間上で定義されたクラスや関数以外はpickle化できない。
もちろん関数やクラスのロジックも記憶されない、PythonはLISPではない。

copyregモジュールに登録されたクラスのオブジェクトの場合

次に、copyregモジュールにグローバル定義された辞書より、copyreg.dispatch_table[type(obj)]の存在がチェックされる。

sample02.py
import copyreg
if type(obj) in copyreg.dispatch_table:
    reduce = copyreg.dispatch_table[type(obj)]
    rv = reduce(obj)

戻り値rvの内容については後述。

このように、copyreg.dispatch_tableに登録された関数が最優先でpickle化に使用される。
このため、定義を変更できないクラスであってもpickle化/unpickle化の挙動を変更することができる。極端な話、時刻オブジェクトをpickle化/unpickle化すると正規表現オブジェクトになるように仕込むこともできる。

sample03.py
import pickle
import copyreg
import datetime
import re

def reduce_datetime_to_regexp(x):
    return re.compile, (r'[spam]+',)

copyreg.pickle(datetime.datetime, reduce_datetime_to_regexp)

a = datetime.datetime.now()
b = pickle.loads(pickle.dumps(a))
print(a, b) # 2014-10-05 10:24:12.177959 re.compile('[spam]+') のように出力

辞書dispatch_tableへの追加は、copyreg.pickle(type, func)を介して行う。

なお、辞書pickler.dispatch_tableがあった場合、copyreg.dispatch_tableの代わりにこちらが使用される。
特定用途のpickle化の時のみ挙動を変えたい場合は、こちらの方が安全である。

sample03a.py
import pickle
import copyreg
import datetime
import re
import io

def reduce_datetime_to_regexp(x):
    return re.compile, (r'[spam]+',)

a = datetime.datetime.now()

with io.BytesIO() as fp:
    pickler = pickle.Pickler(fp)
    pickler.dispatch_table = copyreg.dispatch_table.copy()
    pickler.dispatch_table[datetime.datetime] = reduce_datetime_to_regexp
    pickler.dump(a)
    b = pickle.loads(fp.getvalue())

print(a, b) # 2014-10-05 10:24:12.177959 re.compile('[spam]+') のように出力

obj.__reduce_ex__ が定義されている場合

メソッドobj.__reduce_ex__が定義されている場合、

sample03.py
rv = obj.__reduce_ex__(protocol_version)

が呼び出される。
戻り値rvの内容については後述。

obj.__reduce__ が定義されている場合

メソッドobj.__reduce__が定義されている場合、

sample03.py
rv = obj.__reduce__()

が呼び出される。
戻り値rvの内容については後述。

__reduce__の必要性

現状では無いと思われる。常に__reduce_ex__を使えば良い。
こちらの方が先に検索されるので、若干であるが早くもなる。
プロトコル変数も、使わなければ無視すれば良い。

何も特別な定義をしていない場合

pickle化/unpickle化のために、何も特別なメソッドなどを書いていない場合は、最終手段としてobject標準のreduce処理が行われる。
これは、言わば「大抵のオブジェクトでこのまま使えそうな、最もユニバーサルな、最大公約数的な__reduce_ex__の実装」であり、大変参考になるのだが、残念ながらC言語で実装されていて理解は難しい。
この当該箇所を、エラー処理など割愛し、大まかな流れをPythonで実装すると下記のようになる。

object_reduce_ex.py
class object:
    def __reduce_ex__(self, proto):
        from copyreg import __newobj__

        if hasattr(self, '__getnewargs__'):
            args = self.__getnewargs__()
        else:
            args = ()

        if hasattr(self, '__getstate__'):
            state = self.__getstate__()
        elif hasattr(type(self), '__slots__'):
            state = self.__dict__, {k: getattr(self, k) for k in type(self).__slots__}
        else:
            state = self.__dict__

        if isinstance(self, list):
            listitems = self
        else:
            listitems = None

        if isinstance(self, dict):
            dictitems = self.items()
        else:
            listitems = None

        return __newobj__, (type(self),)+args, state, listitems, dictitems

上を参照してわかる通り、object.__reduce_ex__に頼るとしても、__getnewargs__, __getstate__のメソッドを定義すれば、細かい挙動の変更が可能となる。
これらの関数は、自前で__reduce_ex__, __reduce__を定義した場合は、明示的に呼ばない限り利用されない。

__getnewargs__

pickle化可能なタプルを返すメソッド。
これが定義されていると、unpickle化での__new__への引数(__init__ではない)がカスタマイズできる。
先頭の引数(クラスオブジェクト)は含まない。

__getstate__

これが定義されていると、unpickle化での__setstate__の引数、又は__setstate__が存在しない場合の__dict__及びスロットの初期値がカスタマイズできる。

__reduce_ex__, __reduce__及びcopyreg登録関数が返すべき値

上記の処理で、各関数が返すべき値rvは、

  • 文字列
  • 要素数2以上5以下のタプル。要素数が5未満の場合は、不足分はNoneと解釈される。

である。

type(rv) is strであった場合

type(obj).__module__, rvが文字列としてpickle化では記録され、unpickle化には適切にモジュールがimportされた上でこの名前で参照されるオブジェクトが返される。
このメカニズムは、シングルトンオブジェクトなどをpickle化する際に有効に利用できる。

type(rv) is tupleであった場合

タプルの要素(2以上5以下)は下記の通り

  1. func - unpickle時にオブジェクトを生成するpickle可能かつ呼び出し可能オブジェクト(典型的にはクラスオブジェクト)。ただし、func.__name__ == "__newobj__"の場合は例外で後述。
  2. args - pickle可能な要素からなるタプル。funcの呼び出し時のパラメータとして使用される。
  3. state - オブジェクトの状態をunpickleするためのオブジェクト。省略可能。Noneでも良い。
  4. listitems - listライクなオブジェクトの要素を返すイテレーション可能オブジェクト。省略可能。Noneでも良い。
  5. dictitems - dictライクなオブジェクトのキーと要素を返すイテレーション可能オブジェクト。イテレータが返す値は、キーと要素のペアでなければならない。典型的にはdict_object.items()。省略可能。Noneでも良い。

func.__name__ == "__newobj__"の場合

この場合、args[0]がクラスオブジェクトとして解釈されargsを引数としてクラスオブジェクトが作成される。この時は__init__は呼ばれない。
このような条件のfuncオブジェクトが必要ならば、copyregモジュールに既に宣言されたものがある。

Lib/copyreg.py
def __newobj__(cls, *args):
    return cls.__new__(cls, *args)

このcopyreg.__newobj__は、通常の関数と解釈しても同様の動作をするように実装されて入るが、実際には実行されることはない。

stateの値の解釈

以下のように解釈される。
1. unpickle化対象のオブジェクトがobj.__setstate__を持っていれば、そのメソッドへの引数。
2. 要素2のタプルであれば、state[0]obj.__items__の内容を示す辞書、state[1]type(obj).__slots__の内容を示す辞書。いずれもNoneでも良い。
3. 単一の辞書であれば、obj.__items__の内容

unpickle化の処理の流れ

主に、下記のメソッドを追っていけば理解できる。

Lib/pickle.py
class _Unpickler:
    def load_newobj(self):
        ...
    def load_reduce(self):
        ...
    def load_build(self):
        ...
    def load_global(self):
        ...

unpickle化の1stステップ

pickle.load, pickle.loadsなどが呼ばれると、全ては下記の処理によりunpickle化される。

sample1.py
unpickler = pickle.Unpickler(fileobj)
unpickler.load()

なお、Unpicklerクラスは、
1. C実装の_pickle.Unpickler、または
2. Python実装のpickle._Unpickler
で、それぞれ下記の場所に実体がある。
1. Modules/_pickler.c で定義されている static PyTypeObject Unpickler_Type;
2. Lib/pickle.py で定義されている class _Unpickler

pickleデータ内の要素に応じて、opcodeと呼ばれるIDに応じ、unpickler.load_xxx()を順次呼び出しながら、オブジェクトを復元していく。

global opcodeデータのunpickle化

クラス、関数、__reduce_ex__が文字列を返したケースなどでは"modulename.varname"という文字列がそのまま記録されている。
この場合、必要ならばモジュールをimportし、対応する値を出力する。
unpicklerによる新たなオブジェクトの生成は行われない。

newobj, reduce, build opcodeデータのunpickle化

__reduce_ex__等により返される5要素のタプルを使用してpickle化された場合、これらの処理によりオブジェクトがunpickle化される。
この処理に対応するload_newobj, load_reduce, load_buildの各メソッドの概要を平易な流れで書き直せば、おおむね下記のようになる。

sample09.py
def unpickle_something():
    func, args, state, listitems, dictitems = load_from_pickle_stream()

    if getattr(func, '__name__', None) == '__newobj__':
        obj = args[0].__new__(*args)
    else:
        obj = func(*args)

    if lisitems is not None:
        for x in listitems:
            obj.append(x)

    if dictitems is not None:
        for k, v in dictitems:
            obj[k] = v

    if hasattr(obj, '__setstate__'):
        obj.__setstate__(state)
    elif type(state) is tuple and len(state) == 2:
        for k, v in state[0].items():
            obj.__dict__[k] = v
        for k, v in state[1].items():
            setattr(obj, k, v)
    else:
        for k, v in state.items():
            obj.__dict__[k] = v

    return obj

ケーススタディ

特になにもしなくて良いケース

次のような条件を満たすケースは、特にpickle化、unpickle化の処理を書かなくても、適切に処理ができる。
1. 全ての__dict__の中身はpickle化可能で、そのまま復元されても問題ない。
2. 全ての__slots__に対応する属性の値はpickle化可能で、そのまま復元されても問題ない。
3. C言語による実装により、Pythonからアクセス不可能な内部データを持っていない。
4. __new__に引数を解釈する処理を加えていない。
5. __init__が呼ばれなくても、属性が正しく復元されればオブジェクトとして矛盾はない。
6. listやdictのサブクラスの場合、要素は全てpickle化可能で、そのまま復元されて問題ない。

pickleに含めたくない属性(キャッシュなど)やpickle化できない属性を持つオブジェクト

sphere0.py
import pickle

class Sphere:
    def __init__(self, radius):
        self._radius = radius
    @property
    def volume(self):
        if not hasattr(self, '_volume'):
            from math import pi
            self._volume = 4/3 * pi * self._radius ** 3
        return self._volume

def _main():
    sp1 = Sphere(3)
    print(sp1.volume)
    print(sp1.__reduce_ex__(3))
    sp2 = pickle.loads(pickle.dumps(sp1))
    print(sp2.volume)

if __name__ == '__main__':
    _main()

球を表現するShhereオブジェクトは、体積を表すvolumeプロパティにアクセスすると、内部に計算結果をキャッシュする。
これをそのままpickle化すると、キャッシュされた体積まで一緒に保存されるため、データサーズが大きくなる。
これを削除したい。

sphere1.py
class Sphere:
    def __init__(self, radius):
        self._radius = radius
    @property
    def volume(self):
        if not hasattr(self, '_volume'):
            from math import pi
            self._volume = 4/3 * pi * self._radius ** 3
        return self._volume
    def __getstate__(self):
        return {'_radius': self._radius}

unpickle後の__dict__の値を返す__getstate__メソッドを定義することで、キャッシュがpickle化されることを防げる。

sphere2.py
class Sphere:
    __slots__ = ['_radius', '_volume']
    def __init__(self, radius):
        self._radius = radius
    @property
    def volume(self):
        if not hasattr(self, '_volume'):
            from math import pi
            self._volume = 4/3 * pi * self._radius ** 3
        return self._volume
    def __getstate__(self):
        return None, {'_radius': self._radius}

メモリ効率を向上させるため、__slots__を定義した場合、__dict__は存在しなくなるため__getstate__が返す値も替える必要がある。
この場合2要素のタプルとし、後ろの要素を__slots__の属性を初期化する辞書とする。
前の要素(__dict__の初期値)はNoneで構わない。

sphere3.py
class Sphere:
    __slots__ = ['_radius', '_volume']
    def __init__(self, radius):
        self._radius = radius
    @property
    def volume(self):
        if not hasattr(self, '_volume'):
            from math import pi
            self._volume = 4/3 * pi * self._radius ** 3
        return self._volume
    def __getstate__(self):
        return self._radius
    def __setstate__(self, state):
        self._radius = state

pickleすべき値が半径のみと決まっているなら、辞書ではなくself._radiusの値そのものを__getstate__で返しても良い。
その場合、対になる__setstate__も定義する。

__new__に適切な引数を与えないと生成できないオブジェクト

intliterals.py
import pickle

class IntLiterals(tuple):
    def __new__(cls, n):
        a = '0b{n:b} 0o{n:o} {n:d} 0x{n:X}'.format(n=n).split()
        return super(cls, IntLiterals).__new__(cls, a)
    def __getnewargs__(self):
        return int(self[0], 0),

def _main():
    a = IntLiterals(10)
    print(a) # ('0b1010', '0o12', '10', '0xA')
    print(a.__reduce_ex__(3))
    b = pickle.loads(pickle.dumps(a))
    print(b)

if __name__ == '__main__':
    _main()

__init__を呼ばないと生成できないオブジェクト

closureholder.py
import pickle

class ClosureHolder:
    def __init__(self, value):
        def _get():
            return value
        self._get = _get
    def get(self):
        return self._get()
    def __reduce_ex__(self, proto):
        return type(self), (self.get(),)

def _main():
    a = ClosureHolder('spam')
    print(a.get())
    print(a.__reduce_ex__(3))
    b = pickle.loads(pickle.dumps(a))
    print(b.get())

if __name__ == '__main__':
    _main()

getが返す値は、__init__内でクロージャにより記憶されているので、__init__を呼び出さないとオブジェクトを作成できない。
このようなケースでは、object.__reduce_ex__を使用することはできないので、自前で__reduce_ex__を実装する。

シングルトンオブジェクト

singleton.py
class MySingleton(object):
    def __new__(cls, *args, **kwds):
        assert mysingleton is None, \
            'A singleton of MySingleton has already been created.'
        return super(cls, MySingleton).__new__(cls, *args, **kwds)
    def __reduce_ex__(self, proto):
        return 'mysingleton'

mysingleton = None
mysingleton = MySingleton()

def _main():
    import pickle
    a = pickle.dumps(mysingleton)
    b = pickle.loads(a)
    print(b)

if __name__ == '__main__':
    _main()

MySingletonクラスは、mysingletonグローバル変数に唯一のインスタンスのみを持つとする。
これを正しくunpickleするには、__reduce_ex__が文字列を返す形式を使えば良い。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
28