LoginSignup
25
19

More than 3 years have passed since last update.

PEP557 dataclassの仕組みを解説する

Last updated at Posted at 2019-12-01

TL;DR

  • dataclassすごくいいよ :thumbsup:
  • 手書きで書いた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のソースのこの辺を読んでみよう。

  1. dataclassデコレータがクラスに対して呼ばれる
  2. 各フィールドの型情報(型名、型クラス、デフォルト値等)をtype annotationsから取得する
  3. 型情報を使って__init__ 関数定義の文字列を作る
  4. 文字列をexecに渡して動的に関数を生成する
  5. クラスに__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でもこの分野が盛り上がって良いライブラリがたくさん出てきてほしい。

25
19
1

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
  3. You can use dark theme
What you can do with signing up
25
19