🐍

[詳解] Pythonのdataclasses

2021/01/04に公開

dataclassesとは

pythonのdataclassesモジュールは、データを格納するためのクラスを定義し、データ格納クラスのための様々な機能を含んだモジュールです。
データ格納のための、と言うとふんわりした印象になりますが、クラス変数を初期化するための__init__()関数を自動生成してくれるため、クラスの定義がシンプルになります。またデータ格納を目的とするクラスの場合__init__()に大量の引数を記載する必要がありますが、自動生成されることによりその必要も無くなります。
データ格納といった目的以外にも様々な用途に用いることが考えられると思います。

dataclassesはPython3.7から追加になりました。本記事はPython3.9のドキュメント、ソースコードを参照して執筆しています。

基本的な使い方

dataclassesモジュールを用いたデータ格納クラスを作成するために必要なことは、基本的に以下の3つです。

  1. dataclasses.dataclassデコレータをクラス宣言に付与
  2. データフィールドをクラス変数として記述する
  3. データフィールドのクラス変数に型アノテーションを記述する

例えば以下のようにデータクラスPersonを定義します。

@dataclass
class Person():
    first_name: str
    last_name: str
    age: int

上記のようにdataclassを定義すると、以下のような__init__()関数がデータクラスに対して自動で作成されます。
__init__()は暗黙的に作成されるもので、コード上に明記されるわけではないことに注意してください。
また__init__()のほかに__repr__()関数なども自動生成されます。後述しますが、とある条件下では自動生成されない場合があるので注意してください。

def __init__(self, first_name: str, last_name: str, age: int)
    self.first_name = first_name
    self.last_name = last_name
    self.age = age

dataclassを使用せずにPersonクラスを定義すると以下のようになると思います。

class NormalPerson():
    def __init__(self, first_name: str, last_name: str, age: int):
        self.first_name = first_name
        self.last_name = last_name
        self.age = age

それぞれインスタンスを生成されると以下のように出力されます。

>>> from person.person import Person, NormalPerson
>>> Person('taro', 'tanaka', 20)
Person(first_name='taro', last_name='tanaka', age=20)
>>> NormalPerson('taro', 'tanaka', 20)
<person.person.NormalPerson object at 0x7fd38d00f310>

dataclassを使用した場合、出力される文字列はデータクラス名と各フィールドのキーバリューが出力されることがわかります。
これはdataclassデコレータが対象のクラスの__repr__()などの特殊メソッドを自動生成しているためです。

クラス変数なので"."で接続してフィールドを参照できます。またデフォルトではmutableになっているのでフィールドの値を変更できます。

>>> taro = Person('taro', 'tanaka', 20)
>>> taro.first_name
'taro'
>>> taro.first_name = "johtaro"
>>> taro.first_name
'johtaro'

__init__()は自動生成されますが、自前で定義することも出来ます。
「型アノテーション付のクラス変数」がフィールドと認識される条件になるので、自前の__init__()内でdataclassのフォーマットに沿っていないクラス変数を定義してしまうと、そのクラス変数はデータクラスのフィールドとして扱われないので注意してください。

@dataclass
class Person():
    first_name: str
    last_name: str
    age: int

    def __init__(self, first_name: str, last_name: str, age: int)
        # 自分でinitを定義してもいいが、引数の定義漏れに注意
        self.first_name = first_name
        self.last_name = last_name
        self.age = age

        # 型アノテーション付のクラス変数として定義していないので
        # dataclassのフィールドとしては扱われない
        self.sex = None

フィールドの初期値

クラス変数定義の後ろに"= value"とすることで初期値を設定することが出来ます。
str, intなどの場合はそのまま"="の後に空文字など指定すれば良いですが、listやset、dictなどのデフォルト値を設定する場合はdataclasses.fieldを使用する必要があります。
例えばlist型のフィールドに初期値を設定する場合はdataclasses.field関数の引数にdefault_factoryを指定する必要があります。

person.py
from dataclasses import dataclass, field

@dataclass
class Person():
    first_name: str = ''
    last_name: str = ''
    age: int = 0
    family: list = field(default_factory=list)
>>> Person()
Person(first_name='', last_name='', age=0, family=[])

詳細は後述しますが、default_factoryを使用する必要がある型はミュータブルな型です。intやstr, tuple, frozensetのようなイミュータブルな型はdefault_factoryを指定する必要がありません。

from dataclasses import dataclass

@dataclass
class Foo():
    # これはOK
    int_field: int = 0
    str_field: str = ''
    tuple_field: tuple = ()
    frozenset_field: frozenset = frozenset()
    # これはNG 例外が出る
    dict_field: dict = {}
    set_field: set = set()
    list_field: list = []

クラス変数に初期値を設定していない場合、引数無しでインスタンスを初期化することは出来ません。

person.py
from dataclasses import dataclass

@dataclass
class Person():
    first_name: str
    last_name: str
    age: int
>>> Person()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: __init__() missing 3 required positional arguments: 'first_name', 'last_name', and 'age'

初期値の設定あり/なしのフィールドを組み合わせることもできます。その場合、初期値なしのフィールドは必ず先に宣言する必要があります。

正しい定義
from dataclasses import dataclass

@dataclass
class Foo():
    non_default: str
    int_field: int = 0

print(Foo(non_default="val"))
出力
Foo(non_default='val', int_field=0)

初期値ありのフィールドを宣言した後に初期値なしを定義するとエラーになります。

間違った定義
from dataclasses import dataclass

@dataclass
class Foo():
    int_field: int = 0
    non_default: str

出力
    raise TypeError(f'non-default argument {f.name!r} '
TypeError: non-default argument 'non_default' follows default argument

型アノテーションを付与しない場合

型アノテーションを指定しない場合はどうなるか。例えば以下の場合。

person.py
from dataclasses import dataclass

@dataclass
class Person():
    first_name: str = ''
    last_name: str = ''
    age = 0

この場合は型アノテーションがついているクラス変数のみdetaclassのフィールド、ということになります。
したがってこの場合に作成される__init__()は以下のようになります。

def __init__(self, first_name: str = '', last_name: str = '')
    self.first_name = first_name
    self.last_name = last_name

ですのでインスタンス生成時にageを引数で渡してしまうとエラーになります

>>> taro = Person('taro', 'tanaka')
>>> taro
Person(first_name='taro', last_name='tanaka')
# ageも普通のクラスフィールドとして使用できる
>>> taro.age
0
>>> taro.age = 20
>>> taro.age
20

# インスタンス生成時にageを指定するとエラー
>>> Person('jiro', 'satoh', 20)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: __init__() takes from 1 to 3 positional arguments but 4 were given

データクラスの比較

同一データクラスでクラス変数の値まで完全に一致している場合、異なるインスタンスでも比較するとTrueを返します。
dataclassを使用しないで定義したクラスの場合はFalseを返します。
これはdataclassデコレータが自動生成する__eq__()メソッドが同一クラスのインスタンスの比較を、フィールドのタプルで比較するようにするためです。

# Person is dataclass
>>> taro = Person('taro', 'tanaka', 20)
>>> taro == Person('taro', 'tanaka', 20)
True

# NormalPerson is NOT dataclass
>>> taro = NormalPerson('taro', 'tanaka', 20)
>>> taro == NormalPerson('taro', 'tanaka', 20)
False

__eq__()以外にも__lt__()__le__()__gt__()__ge__()といったインスタンス比較のための特殊メソッドを自動生成させることもできます。詳細は次の「自動生成される特殊メソッド」をご覧ください。

自動生成される特殊メソッド

前述したとおりdataclassデコレータをクラス定義に付与した場合、__init__()__repr__()といった特殊メソッドが自動生成されます。
しかしこれらの特殊メソッドが自動されるか否かには条件があります。例えば__init__()の場合、以下の条件のいずれかを満たすと__init__()は自動生成されません。

  1. @dataclass(init=False)と定義した場合
  2. __init__()を自分で定義した場合

①について、datraclassデコレータはいくつか引数を指定できます。例えばinitパラメータはデフォルトではTrueですが、Falseを指定した場合は__init__()を生成しません。

②について、例えば以下のようにデータクラスを定義します。

from dataclasses import dataclass

@dataclass
class Person():
    first_name: str = ''
    last_name: str = ''
    age: int = 0

    def __init__(self, first_name: str):
        self.first_name = first_name

自分で定義した__init__()にはfirst_nameしか引数を定義していないため、インスタンス初期化時に他のフィールドを指定しようとするとエラーになります。

>>> taro = Person('taro', 'tanaka', 20)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: __init__() takes 2 positional arguments but 4 were given

__init__で指定した引数のみ指定した場合は成功します。

>>> Person(first_name='taro')
Person(first_name='taro', last_name='', age=0)

ちなみに、__init__で引数に追加していないフィールドは初期値を設定しないとどうあがいてもエラーになります。

from dataclasses import dataclass

@dataclass
class Person():
    last_name: str  # 初期値なし
    age: int        # 初期値なし
    first_name: str

    def __init__(self, first_name: str):
        self.first_name = first_name

print(Person(last_name='tanaka', age=20, first_name='taro'))
出力
Traceback (most recent call last):
    print(Person(last_name='tanaka', age=20, first_name='taro'))
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: Person.__init__() got an unexpected keyword argument 'last_name'

__init__をどうしても自前で実装したい場合は各フィールドを__init__の引数に忘れず追加する必要がありますが、よっぽどの理由がない限り__init__を自分で書くよりも後述する__post_init__を使った方がよいと思います。


__init__()がクラスに定義してあるか否かはクラスの__dict__という特殊属性を参照しています。
実際に実装したデータクラスの__dict__属性から__init__の部分を出力してみるとその違いがわかります。

# __init__() を自分で定義しなかった場合
>>> Person.__dict__['__init__']
<function __create_fn__.<locals>.__init__ at 0x7feb68f3c9d0>

# __init__() を自分で定義した場合
>>> Person.__dict__['__init__']
<function Person.__init__ at 0x7fa7b6dc5940>

dataclassデコレータの引数にはinitの他に以下のような物が存在しています。
各引数と特殊メソッドが生成される条件はdataclassesモジュールのソースコード にわかりやすい表があるので、そちらをみるとわかりやすいです。

  1. repr
    デフォルト: True。
    Trueを指定した場合、かつ、__repr__()を自分で定義していない場合は__repr__()を自動生成する。

  2. eq
    デフォルト: True。
    Trueを指定した場合、かつ、__eq__()を自分で定義していない場合は__eq__()を自動生成する。
    上の方でも書きましたが、自動生成した__eq__()は比較したインスタンスの比較を、データクラスのフィールド(クラス変数で指定したやつ)のタプルで比較するようにします。

  3. order
    デフォルト: False。
    Trueを指定した場合、__lt__()__le__()__gt__()__ge__()といった特殊メソッドを自動生成します。
    こちらの場合も、インスタンス間の比較をフィールドのタプルで行っているようにします。
    order=Trueかつeq=FalseにするとValueErrorがraiseされます。
    またorder=Trueかつ__lt__()__le__()__gt__()__ge__()のいずれかを自分で定義してしまっている場合はTypeErrorがraiseされるため、これらの特殊メソッドを自作したい場合はorder=Trueに指定できません。

    >>> taro = Person('taro', 'tanaka', 20)
    >>> jiro = Person('jiro', 'tanaka', 20)
    # order=False (default) の場合
    >>> taro > jiro
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    TypeError: '>' not supported between instances of 'Person' and 'Person'
    # order=Trueの場合
    >>> taro > jiro
    True
    
  4. frozen
    デフォルト: False。
    Trueを指定した場合、 フィールドへの代入時にエラーをraiseするようになります。つまりイミュータブルなデータクラスを実現できます。frozen=Trueとした場合__delattr__()__setattr__()を自動生成することで実現しています。
    こちらも__delattr__()__setattr__()を自前で定義して、かつfrozen=Trueとした場合TypeErrorをraiseします。
    自動生成された__delattr__()/__setattr__()が呼び出された場合dataclasses.FrozenInstanceErrorがraiseされます。

    注意すべき挙動ですが、__post_init__でクラス変数を初期化しようとした場合、当然自動生成された__setattr__()が呼ばれるので、FrozenInstanceErrorになってしまいます。
    これはobjectクラスの__setattr__()を呼び出すことで回避できます。

    @dataclass(frozen=True)
    class Foo:
        foo: str = field(default='', init=False)
        bar: str = field(default='', init=False)
    
        def __post_init__(self):
            self.bar = 'bar_value'  # FrozenInstanceError
            object.__setattr__(self, 'foo', 'foo_value') # エラーにならない
    
  5. unsafe_hash
    デフォルト: False。
    unsafe_hasheqfrozenの指定した値の組み合わせに応じて__hash__()が自動生成されます。__hash__()を自前で定義している場合はエラーがraiseされます。

    • unsafe_hash=Trueとした場合はeqfrozenの値に関わらず__hash__()が自動生成されます。
    • unsafe_hash=Falseは基本的に__hash__()が自動生成されませんが、eq=Trueかつfrozen=Trueの場合は__hash__()が自動生成されます。これはデータクラスがイミュータブルになっているので、データクラスをハッシュ化可能とするためだと思います。

    dataclassデコレータの引数を全てデフォルト値にした場合、データクラスには__hash__ = Noneという特殊属性が設定されます。hash()のドキュメントにもあるように、__hash__ = Noneとなっている場合、当該クラスのインスタンスをハッシュ化しようとした場合(組み込み関数hash()を使用した場合など)、TypeErrorがraiseされます。ただし__hash__()を自前で定義している場合は__hash__ = Noneが設定されません。
    つまりは、データクラスがミュータブルであることを示しています。

    unsafe_hash=Trueにした場合__hash__()が自動生成されますが、eqfrozenの設定に注意が必要です。
    Python用語集hashableの説明によれば、

    「(ハッシュ可能) ハッシュ可能 なオブジェクトとは、生存期間中変わらないハッシュ値を持ち (__hash__() メソッドが必要)、他のオブジェクトと比較ができる (__eq__() メソッドが必要) オブジェクトです。同値なハッシュ可能オブジェクトは必ず同じハッシュ値を持つ必要があります。」

    とあります。
    unsafe_hash=True, eq=Falseとした場合、__eq__()が定義されていないのに__hash__()が定義され、hashableの概念とは異なります。
    また、unsafe_hash=True, frozen=Falseとした場合、ミュータブルなのに__hash__()が定義されるため、「生存期間中変わらない」という概念に反します。実際、unsafe_hash=True, frozen=Falseとしたデータクラスでクラス変数の値を変更するとインスタンスのハッシュ値は変化します。

    # Personクラスには `unsafe_hash=True, frozen=False` を設定
    >>> a = Person()
    Person(first_name='', last_name='', age=0)
    >>> hash(a)
    3010437511937009226
    >>> a.age = 20
    >>> hash(a)
    8565032653382883967
    

    "unsafe"なhash化であるというのは上記のような理由によるものだと思います。バグを防ぐためにもunsafe_hash=Trueとする場合は合わせてeq=True, frozen=Trueとするほうが良いかと思います。
    __hash__()の詳細な自動生成パターンはソースコードに記載してあるので一度見ておくと参考になります。

dataclasses.field

listやsetのようなミュータブルな型を持つフィールドにデフォルト値を指定する場合はdataclasses.fieldを使用する必要があったと思います。
list等では必要、と書きましたがstrやintのフィールドにも使用できます。

from dataclasses import dataclass, field
from typing import List

class Person():
    first_name: str = ''
    last_name: str = ''
    age: int = field(default=0)
    family: List[str] = field(default_factory=list)

引数defaultが指定された場合は、その値がデフォルト値になります。
引数default_factoryに引数無しのオブジェクトを指定すると、このフィールドの初期値が必要になったときに呼び出されます。
default_factorydefaultはどちらか一方しか指定できません。

listなどのミュータブルな型のフィールドの場合にdefault_factoryが必要なのは、Pythonのデフォルト引数に関する仕様に起因します。[1]
Pythonのデフォルト引数は関数が呼び出される時ではなく、関数が定義されるときに1回だけ行われます。従って可変なオブジェクトをデフォルト引数に使用すると、関数が呼び出されるたびにそのオブジェクトが変更されます。[2] 要するに異なる2つのインスタンス間でクラス変数を共有してしまい、意図しない動作を招いてしまいます。
dataclassは上記のような問題を回避するために、クラス変数がミュータブルな型の場合はエラーを出すようにしています。

従って以下のようなクラス変数定義はエラーになります

    # 空のリストを指定
    family: List[str] = []
    # defaultのほうを指定する
    family: List[str] = field(default=[])

default_factoryには引数無しのオブジェクトしか指定できないので、要素の入ったlistなどはそのまま渡せません。
空のlist以外に特定の要素のlistなどを初期値としたい場合はlambda関数を使います

from dataclasses import dataclass, field
from typing import List

class Person():
    first_name: str = ''
    last_name: str = ''
    age: int = field(default=0)
    family: List[str] = field(default_factory=lambda: ['papa', 'mama'])

dataclasses.fieldの引数は他にもinitreprcomparehash等があり、これらはdataclassデコレータによって自動される__repr__()__eq__()などの特殊メソッドに当該フィールドを含むか否かを指定できます。
たとえばfield(init=False)とすると、自動生成される__init__()のデフォルト引数に当該フィールドは追加されません。

>>> @dataclass
... class Foo:
...     foo: int = field(default=1, init=False)
...
>>> foo = Foo()
>>> foo.foo
1
# initに引数が設定されていないのでエラーになる
>>> foo = Foo(1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: __init__() takes 1 positional argument but 2 were given

>>> @dataclass
... class Bar:
...     bar: int = field(init=False)
...
>>> bar = Bar()
# `default`が指定されていないのでエラーになる
>>> bar.bar
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Bar' object has no attribute 'bar'
# もちろん引数も設定されていない
>>> bar = Bar(1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: __init__() takes 1 positional argument but 2 were given
# 後から値を入れるしかない
>>> bar.bar = 1
>>> bar.bar
1

reprcomparehashについてはドキュメントを見ていただいた方が早いです。

上記以外にはmetadataという引数がありますがこれだけは特殊です。metadataにはマッピング型のオブジェクト(dictや, collections.defaultdict, collections.OrderedDict, collections.Counter など)を指定できますが、metadataの値はdataclassesモジュールによって使用されません。この属性はサードパーティのライブラリ等のために設けられています。

例えばdataclass-csvというライブラリはdataclassを用いてCSVファイルのデータを簡単に格納・検証できるライブラリですが、metadata属性を用いて特定のフィールドのみ特殊なデータフォーマットであることを定義したりしています。

from dataclasses import dataclass, field
from datetime import datetime
from dataclass_csv import DataclassReader, dateformat

@dataclass
@dateformat('%Y/%m/%d')
class User:
    name: str
    email: str
    birthday: datetime
    create_date: datetime = field(metadata={'dateformat': '%Y/%m/%d %H:%M'})

このようにmetadata属性をうまく利用することで特定のフィールド対してのみ処理を行ったりすることが出来るようになります。dataclassを活用して様々な機能を実装するために覚えておいて損は無いと思います。
なおデータクラスインスタンスから各フィールドの情報を取得したい場合はdataclasses.fields関数を使います。これはdataclasses.Fieldオブジェクトのタプルを返します。dataclasses.Fieldにはデータクラスのフィールドに対する情報が含まれており、field()で指定した情報がそのまま入っています。

>>> from person.person import Person, NormalPerson
>>> taro = Person('taro', 'tanaka', 20)
>>> from dataclasses import fields
>>> fields(taro)
(Field(name='first_name',type=<class 'str'>,default='',default_factory=<dataclasses._MISSING_TYPE object at 0x7fce54001730>,init=True,repr=True,hash=None,compare=True,metadata=mappingproxy({}),_field_type=_FIELD), Field(name='last_name',type=<class 'str'>,default='',default_factory=<dataclasses._MISSING_TYPE object at 0x7fce54001730>,init=True,repr=True,hash=None,compare=True,metadata=mappingproxy({}),_field_type=_FIELD), Field(name='age',type=<class 'int'>,default=0,default_factory=<dataclasses._MISSING_TYPE object at 0x7fce54001730>,init=True,repr=True,hash=None,compare=True,metadata=mappingproxy({}),_field_type=_FIELD))
>>> type(fields(taro))
<class 'tuple'>
>>> type(fields(taro)[0])
<class 'dataclasses.Field'>

便利なユーティリティ関数

detaclassesモジュールにはデータクラスを他の型に変換したり、逆にdict型等からデータクラスを生成するモジュールが標準搭載されています。

  1. asdict()
    データクラスのインスタンスをdictに変換します。各フィールドに対して"フィールド名:値"となるようなdictになります。dict_factory引数にlist、tuple等を指定するとそれぞれ指定したファクトリを用いて出力されます。その場合は(フィールド名、値)のタプルが出力されます

    >>> asdict(taro)
    {'first_name': 'taro', 'last_name': 'tanaka', 'age': 20}
    >>> asdict(taro, dict_factory=tuple)
    (('first_name', 'taro'), ('last_name', 'tanaka'), ('age', 20))
    >>> asdict(taro, dict_factory=list)
    [('first_name', 'taro'), ('last_name', 'tanaka'), ('age', 20)]
    >>> asdict(taro, dict_factory=collections.OrderedDict)
    OrderedDict([('first_name', 'taro'), ('last_name', 'tanaka'), ('age', 20)])
    
  2. astuple()
    データクラスのインスタンスをtupleに変換します。こちらは各フィールドの値だけがタプルで出力されます。asdict()と同様tuple_factoryという引数が用意されています。

    >>> astuple(taro)
    ('taro', 'tanaka', 20)
    >>> astuple(taro, tuple_factory=list)
    ['taro', 'tanaka', 20]
    >>> astuple(taro, tuple_factory=set)
    {'tanaka', 20, 'taro'}
    # これはエラーになる
    >>> astuple(taro, tuple_factory=dict)
    ValueError: dictionary update sequence element #0 has length 4; 2 is required
    
  3. make_dataclass

    cls_name という名前、 fields で定義されるフィールド、 bases で与えられた基底クラス、 namespace で与えられた名前空間付きで初期化されたデータクラスを作成します。

    とのことです。この関数の使い方はドキュメントを見るのが早いですが、この関数を使用してdataclassデコレータを付与したようなデータクラスを動的に作成できます。
    ドキュメントにもありますが、dataclass()をデコレータではなく普通の関数のように使用することで既存のクラスをデータクラスに変換できます。

    # デコレータ付けてない
    class Person():
        first_name: str = ''
        last_name: str = ''
        age: int = 0
    
    >>> PersonDC = dataclasses.make_dataclass(Person)
    >>> PersonDC('taro', 'tanaka', 20)
    PersonDC(first_name='taro', last_name='tanaka', age=20)
    
  4. is_dataclass
    引数で渡したオブジェクトがデータクラスだった場合はTrue、そうでない場合はFalseを返します。
    引数で渡したオブジェクトがデータクラスそのものなのか、データクラスのインスタンスなのかまではわからないので、そちらの判定はisinstance()を用いる必要があります。

    >>> taro = Person('taro', 'tanaka', 20)
    >>> type(taro)
    <class 'person.person.Person'>
    >>> dataclasses.is_dataclass(taro)
    True
    >>> dataclasses.is_dataclass(Person)
    True
    >>> isinstance(taro, Person)
    True
    # dataclassそのものなのか、dataclassのインスタンスなのかは
    # isinstance()でtypeと比較する
    >>> isinstance(Person, type)
    True
    >>> isinstance(taro, type)
    False
    
  5. replace
    instance引数にデータクラスインスタンスを渡します。するとinstacneと同じ型のオブジェクトを新しく生成し、フィールドをchangesの値で置き換えます。changesに指定するフィールドは一部のフィールドだけでも大丈夫です。

    つまりもとのデータクラスを汚さずに新しいデータクラスオブジェクトを生成できますし、frozen=Trueでイミュータブルにしたデータクラスを元に新しくデータクラスを複製するのにも役立ちます。
    なおイミュータブルなデータクラスを元に複製した場合、複製されたインスタンスはchangesで指定したフィールドに変更されていますが、複製インスタンス自体もイミュータブルになっているので、直接フィールドを変更することは出来ません。

    >>> taro = Person('taro', 'tanaka', 20)
    >>> dataclasses.replace(taro, first_name='jiro')
    Person(first_name='jiro', last_name='tanaka', age=20)
    
    # instanceに渡すのはインスタンスである必要がある
    >>> dataclasses.replace(Person, first_name='jiro')
    TypeError: replace() should be called on dataclass instances
    
    # Personがimmutableな場合、複製されたインスタンスもimmutable
    >>> jiro = dataclasses.replace(taro, first_name='jiro')
    >>> jiro.first_name = 'tanjiro'
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
      File "<string>", line 4, in __setattr__
    dataclasses.FrozenInstanceError: cannot assign to field 'first_name'
    

    また、データクラスに初期化限定変数(InitVer)のフィールドが定義されている場合、replace()の呼び出し時に初期値を指定する必要があります。

    from dataclasses import dataclass, field, InitVar
    
    @dataclass()
    class Person():
        first_name: str = ''
        last_name: str = ''
        age: int = 0
        full_name: str = field(init=False)
        separater: InitVar[str] = None
    
        def __post_init__(self, separater):
            _separater = separater if separater is not None else ' '
            self.full_name = _separater.join(
                    [self.first_name, self.last_name])
    
    >>> taro = Person('taro', 'tanaka', 20, '.')
    >>> jiro = dataclasses.replace(taro, separater='_')
    >>> jiro
    Person(first_name='taro', last_name='tanaka', age=20, full_name='taro_tanaka')
    >>> jiro = dataclasses.replace(taro)
    ValueError: InitVar 'separater' must be specified with replace()
    

初期化後の処理(__post__init__())と初期化限定変数(dataclasses.InitVar)

__post_init__()

データクラスに__post_init__()を定義(自動生成ではない)した場合、dataclassによって自動生成された__init__()は、__init__()の処理後に__post_init__()メソッドを呼び出します。
例えば以下のようにfull_nameフィールドは__init__()で受け取らないようにして、__post_init__()の中で初期化することで、他フィールドの値を用いて動的に定義することが出来ます。

from dataclasses import dataclass, field

@dataclass()
class Person():
    first_name: str = ''
    last_name: str = ''
    age: int = 0
    full_name: str = field(init=False)

    def __post_init__(self):
        self.full_name = ' '.join([self.first_name, self.last_name])
>>> taro = Person('taro', 'tanaka', 20)
>>> taro.full_name
'taro tanaka'

ちなみに上記例で@dataclass(frozen=True)とすると、post_initでself.full_nameに値を入れるところでエラーがでます。前述のとおりobject.__setattr__()を用いて値を設定できます。

# __post_init__内でself.full_name に値を代入して、かつ、
# @dataclass(frozen=True)とした場合
>>> taro = Person('taro', 'tanaka', 20)
dataclasses.FrozenInstanceError: cannot assign to field 'full_name'

初期化限定変数

クラス変数の型アノテーションにdataclasses.InitVarを指定した場合、そのクラス変数はデータクラスの一般的なフィールドとしては扱われず、初期化限定フィールドとして扱われます。
初期化限定フィールドは自動生成される__init__()メソッドと、オプションの__post_init__()メソッドの引数に渡されますが、データクラスのフィールドとしては扱われないため、asdictなどでデータクラスフィールドを出力する場合に初期化限定フィールドは出力されません。
下の例ではseparaterというstring型のクラス変数をInitVerに指定しています。

from dataclasses import dataclass, field, InitVar

@dataclass()
class Person():
    first_name: str = ''
    last_name: str = ''
    age: int = 0
    full_name: str = field(init=False)
    separater: InitVar[str] = None

    def __post_init__(self, separater):
        _separater = separater if separater is not None else ' '
        self.full_name = _separater.join(
                [self.first_name, self.last_name])
>>> from person.person import Person
>>> taro = Person('taro', 'tanaka', 20, '.')
>>> taro.full_name
'taro.tanaka'
>>> taro.separater
# None
>>> asdict(taro)
# "separator"はdataclassのフィールドとして定義されていない
{'first_name': 'taro', 'last_name': 'tanaka', 'age': 20, 'full_name': 'taro.tanaka'}

separater__init__()メソッドの引数に渡されるため、インスタンス初期化時にPerson('taro', 'tanaka', 20, '.')のように引数に値を指定することが出来ます。
インスタンス初期化時に渡したseparaterの値は__post_init__()メソッドの引数にも渡せますが、__post_init__()定義時に引数にseparaterを明記する必要があります。
taro.separaterで値を出力しようとしてもエラーにならず、クラス変数の宣言で指定しているNoneが出力されます。つまりデータクラスのフィールドとしては扱われませんが、クラス変数として値は保持されてしまうので、注意してください。

ここで、

def __post_init__(self):
        self.separater = self.separater if self.separater is not None else ' '
        self.full_name = self.separater.join(
                [self.first_name, self.last_name])

のように記述してしまうと、クラス変数separaterに値が記憶されてしいます。

また、__post_init__()の引数として定義できるのはInintVer型を指定したクラス変数のみです。以下のようなメソッドを定義するとエラーになります。

@dataclass()
class Person():
    first_name: str = ''
    last_name: str = ''
    age: int = 0
    full_name: str = field(init=False)
    separater: InitVar[str] = None

    def __post_init__(self, first_name, last_name, separater):
        _separater = separater if separater is not None else ' '
        self.full_name = _separater.join(
                [first_name, last_name])
>>> taro = Person('taro', 'tanaka', 20, '.')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 7, in __init__
TypeError: __post_init__() missing 2 required positional arguments: 'last_name' and 'separater'

応用

オリジナルのdataclassデコレータを作る

例えば特定の用途で用いるデータクラス群を全てImmutableであることを強制したい場合、都度dataclass(frozen=True)を記述するのは面倒ですし、書き洩らしがあるかもしれません。
そういったときはオリジナルのデコレータを作成することでdataclassデコレータの引数を強制させることが出来ます。

from dataclasses import dataclass

def frozendataclass(clazz):
    def wrap(clazz):
        # オリジナルの属性やメソッドを追加してやったり
        setattr(clazz, 'music', 'let it go')
        return dataclass(clazz, frozen=True)

    return wrap(clazz)

@frozendataclass
class FrozenPerson():
    first_name: str = ''
    last_name: str = ''
    age: int = 0
>>> anna = FrozenPerson('Anna', 'Arendelle', 18)
>>> anna.music
'let it go'
>>> anna.first_name = 'Elsa'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 4, in __setattr__
dataclasses.FrozenInstanceError: cannot assign to field 'first_name'

オリジナルのfiledメソッドを作成する

同じような考え方で、オリジナルのfield()関数も自作できると思います。
こちらはfield()の引数でmetadataなどを強制したいときなんかに使えると思います。

from dataclasses import field

def personal_info(**kwargs):
    """ 個人情報を表すフィールドを定義したい
    """
    return field(**kwargs, metadata={'personal_info': True})

@dataclass
class Person():
    first_name: str = personal_info(default='')
    last_name: str = personal_info(default='')
    age: int = 0
>>> taro = Person('taro', 'tanaka', 20)
>>> fields(taro)[0].name
'first_name'
>>> fields(taro)[0].metadata
mappingproxy({'personal_info': True})

データクラスを継承する

@dataclassデコレータを付けるのではなく、class SubClass(SuperDataClass)のように他のクラスを継承する形でサブクラスをdataclassにできないか?という実験です。
別記事で検証しましたので詳細は以下。

https://zenn.dev/enven/articles/9902e768f34472bd9214

上記記事から抜粋すると、以下のようにdataclassとして定義した抽象クラスを継承することでデータクラスの実装が可能です。

@dataclass
class SuperDataclass(metaclass=ABCMeta):
    bar: str = ''

    def __new__(cls, *args, **kwargs):
        dataclass(cls)
        return super().__new__(cls)

class DataclassImpl(SuperDataclass):
    foo: str = ''
>>> dc_impl = DataclassImpl()
>>> print(dc_impl)
DataclassImpl(bar='', foo='')
>>> print(is_dataclass(dc_impl))
True
>>> print(DataclassImpl(bar='ba', foo='fu'))
DataclassImpl(bar='ba', foo='fu')

上記の例からもわかるように、基底クラスにdataclassになっているクラスがいる場合、そのサブクラスに基底クラスのデータクラスフィールドが受け継がれます
なお、一度dataclassにしたクラスに再度dataclassをするとエラーになったり、予期しない動作をする場合があるので注意しましょう。

まとめ

  • dataclassデコレータをクラスに付与、クラス変数に型アノテーションを付与する
  • dataclassデコレータの引数を理解しよう
  • dataclasses.fieldを駆使してフィールドを定義しよう
  • データクラスを操作する便利メソッドがあるのでガンガン使ってこう
  • 特殊メソッドは条件によっては自動生成されないので気を付けよう
  • dataclassデコレータとfieldメソッドをラップして実装の簡略化・ルールの共有などを図ろう
脚注
  1. https://docs.python.org/ja/3/library/dataclasses.html#mutable-default-values ↩︎

  2. https://docs.python-guide.org/writing/gotchas/#mutable-default-arguments ↩︎

Discussion