TL;DR
- dataclassすごくいいよ
- 手書きで書いたclassと遜色ないよ
- これからdataclassをベースにしたライブラリが出てきそう
dataclass
とは?
dataclassはpython 3.7で追加された新しい標準ライブラリ。簡単に説明するとclassに宣言に@dataclass
デコレータを付けると、__init__
, __repr__
, __eq__
, __hash__
といった所謂dunder(double underscoreの略。日本語だとダンダーと読むのかな)メソッドを生成してくるライブラリ。これを使うと面倒なクラスの定義を大幅に短縮できたり、下手な実装より高速だったりする。ここで紹介した以外にもdataclassには色々な機能があるので、詳しくは公式ドキュメントやPython3.7からは「Data Classes」がクラス定義のスタンダードになるかもしれないを参照してほしい。
python3.7がまだ使えないよ、という人にもPyPIに3.6用のbackportが用意されています。
dataclass
の使い方
from dataclasses import dataclass, field
from typing import ClassVar, List, Dict, Tuple
import copy
@dataclass
class Foo:
i: int
s: str
f: float
t: Tuple[int, str, float, bool]
d: Dict[int, str]
b: bool = False # デフォルト値
l: List[str] = field(default_factory=list) # listのデフォルトを[]にする
c: ClassVar[int] = 10 # クラス変数
# 生成された`__init__`でインスタンス化
f = Foo(i=10, s='hoge', f=100.0, b=True,
l=['a', 'b', 'c'], d={'a': 10, 'b': 20},
t=(10, 'hoge', 100.0, False))
# 生成された`__repr__`でhの文字列表現をプリントアウトする
print(f)
# コピーを作って書き換えてみる
ff = copy.deepcopy(f)
ff.l.append('d')
# 生成された`__eq__`で比較する
assert f != ff
パフォーマンス
dataclassを使って作ったDataclassFooと手書きで書いたManualFooの__init__
, __repr__
, __eq__
の実行時間を計測してみた。
- macOS 10.14 Mojave
- Intel 2.3GHz 8-core Intel Core i9
- DDR4 32GB RAM
- Python 3.6.3
計測に使ったソースコード
import timeit
from dataclasses import dataclass
@dataclass
class DataclassFoo:
i: int
s: str
f: float
b: bool
class ManualFoo:
def __init__(self, i, s, f, b):
self.i = i
self.s = s
self.f = f
self.b = b
def __repr__(self):
return f'ManualFoo(i={self.i}, s={self.s}, f={self.f}, b={self.b})'
def __eq__(self, b):
a = self
return a.i == b.i and a.s == b.s and a.f == b.f and a.b == b.b
def bench(name, f):
times = timeit.repeat(f, number=100000, repeat=5)
print(name + ':\t' + f'{sum(t)/5:.5f}')
bench('dataclass __init__', lambda: DataclassFoo(10, 'foo', 100.0, True))
bench('manual class __init__', lambda: ManualFoo(10, 'foo', 100.0, True))
df = DataclassFoo(10, 'foo', 100.0, True)
mf = ManualFoo(10, 'foo', 100.0, True)
bench('dataclass __repr__', lambda: str(df))
bench('manual class __repr__', lambda: str(mf))
df2 = DataclassFoo(10, 'foo', 100.0, True)
mf2 = ManualFoo(10, 'foo', 100.0, True)
bench('dataclass __eq__', lambda: df == df2)
bench('manual class __eq__', lambda: mf == mf2)
各10万回を5セット実行した平均
計測結果(sec) | |
---|---|
dataclass __init__ | 0.04382 |
手書きclass __init__ | 0.04003 |
dataclass __repr__ | 0.07527 |
手書きclass __repr__ | 0.08414 |
dataclass __eq__ | 0.04755 |
手書きclass __eq__ | 0.04593 |
50万回実行してこれならほぼ差はないと言っていいでしょう。
また、バイトコードも一致した。
dataclassの\_\_init\_\_
>>> import dis
>>> dis.dis(DataclassFoo.__init__)
2 0 LOAD_FAST 1 (i)
2 LOAD_FAST 0 (self)
4 STORE_ATTR 0 (i)
3 6 LOAD_FAST 2 (s)
8 LOAD_FAST 0 (self)
10 STORE_ATTR 1 (s)
4 12 LOAD_FAST 3 (f)
14 LOAD_FAST 0 (self)
16 STORE_ATTR 2 (f)
5 18 LOAD_FAST 4 (b)
20 LOAD_FAST 0 (self)
22 STORE_ATTR 3 (b)
24 LOAD_CONST 0 (None)
26 RETURN_VALUE
手書きclassの\_\_init\_\_
>>> dis.dis(ManualFoo.__init__)
13 0 LOAD_FAST 1 (i)
2 LOAD_FAST 0 (self)
4 STORE_ATTR 0 (i)
14 6 LOAD_FAST 2 (s)
8 LOAD_FAST 0 (self)
10 STORE_ATTR 1 (s)
15 12 LOAD_FAST 3 (f)
14 LOAD_FAST 0 (self)
16 STORE_ATTR 2 (f)
16 18 LOAD_FAST 4 (b)
20 LOAD_FAST 0 (self)
22 STORE_ATTR 3 (b)
24 LOAD_CONST 0 (None)
26 RETURN_VALUE
dataclassの内部の解説に入る前に
dataclassを説明するにあたり重要なパーツを説明しておきたい。
PEP526: Syntax for Variable Annotations
PEP526は型宣言の方法を記述してあるんだけど、この仕様追加によってclassに宣言された変数の型情報をプログラム実行時に取得することが可能になった。
from typing import Dict
class Player:
players: Dict[str, Player]
__points: int
print(Player.__annotations__)
# {'players': typing.Dict[str, __main__.Player],
# '_Player__points': <class 'int'>}
組み込みexec
関数
evalは知ってる人が多いと思う。ざっくりevalとの違いをいうと、
eval
: 引数の文字列を式として評価する
exec
: 引数の文字列を文として評価する
これだけじゃ意味不明なので次の例を見てみよう。
これを実行すると"typing rocks!"を出力されるのは簡単に想像できる。
>>> exec('print("typing rocks!")')
"typing rocks!"
ではこれは?
exec('''
def func():
print("typing rocks!")
''')
次にこれを実行してみる
>>> func()
"typing rocks!"
そう。実はexecは文字列を式として評価するので、pythonの関数でさえも動的に定義することができる。すげぇ。
で、dataclassは内部で何を行っているのか?
dataclassデコレータをつけたclassがimportされると、上で説明したtype annotationsやexecを使ってコード生成を行っている。超ざっくりだが、以下のような流れになる。詳しく知りたい人はcpythonのソースのこの辺を読んでみよう。
- dataclassデコレータがクラスに対して呼ばれる
- 各フィールドの型情報(型名、型クラス、デフォルト値等)をtype annotationsから取得する
- 型情報を使って
__init__
関数定義の文字列を作る - 文字列を
exec
に渡して動的に関数を生成する - クラスに
__init__
関数をセットする
3, 4, 5を単純化したコードはこんな感じ。
nl = '\n' # f-string内でエスケープ使えないので外で定義する
# 関数定義の文字列作成
s = f"""
def func(self, {', '.join([f.name for f in fields(Hoge)])}):
{nl.join(' self.'+f.name+'='+f.name for f in fields(Hoge))}
"""
# 関数定義の文字列をコンソール出力してみる
print(s)
# def func(self, i, s, f, t, d, b, l):
# self.i=i
# self.s=s
# self.f=f
# self.t=t
# self.d=d
# self.b=b
# self.l=l
# execでコード生成。`func`関数がスコープ内に定義された
exec(s)
setattr(Foo, 'func', func) # クラスに生成した関数をクラスにセットする
以上は単純化された例だけど、実際には
- フィールドに設定されたデフォルト値
- List等に使うデフォルトファクトリ関数
- クラス変数(ClassVar)
- プログラマが定義済みだったら生成しない
- 他のdunder関数の生成
- dataclassのclassの継承
等を全て考慮して、どんな場合も正しく動作するように丁寧に丁寧に関数定義文字列作成、コード生成が行われているのです。
さらに、もう一つ押さえておきたいことが、このコード生成が行われるのはモジュールがロードされた瞬間のみということ。一度classがimportされたら、手書きで書いたclassと何の変わりもなく使えるということだ。
Rustの#[derive]
Rustにはstructを定義する時につけるDerive attribute(#[derive]
)というものがある。これ、dataclassとほぼ同等かそれ以上のことができる。例えば以下をみてもらえると、
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
struct Foo {
i: i32,
s: String,
b: bool,
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
をつけるだけで、これだけのメソッドを生成してくれる。
- Debug用文字列生成のメソッド生成 (Pythonでいう
__repr__
) - オブジェクトをクローンするメソッド生成
- 比較メソッド生成(Pythonでいう
__eq__
や__gt__
) - ハッシャーメソッド生成(Pythonでいう
__hash__
)
またRustはさらにすごくて、自分のCustom deriveを実装する機能が公式でサポートされていて、割とカジュアルに型ベースのメタプログラミングができる。
こういったプログラマを楽にする機能がRustには他にもたくさんあるので、型制約や所有権が難しくてもRustが生産性が高い理由だと筆者は思っている。Rustは本当に素晴らしい言語なのでPythonistaの方々もぜひぜひ触ってみてほしい。
メタプログラミングとしてのdataclassの可能性
dataclassは型ベースのメタプログラミングの有用性と可能性を示したいい例だと個人的には思っている。
筆者も二つほどdataclassをベースにしたライブラリを作ってみたので、興味がある人はみてみてほしい。
環境変数の値をdataclassのフィールドにマッピングするライブラリ。コンテナ使ってて、Pythonのコンフィグclassを環境変数でオーバーライドしたい時とかに便利
dataclassベースのシリアライズライブラリ。dataclassを使って、Rustの神ライブラリserdeと同等の機能を実装するべく開発中。
おわりに
RustでそうであるようにPythonでもこの分野が盛り上がって良いライブラリがたくさん出てきてほしい。