[詳解] Pythonのdataclasses
dataclassesとは
pythonのdataclassesモジュールは、データを格納するためのクラスを定義し、データ格納クラスのための様々な機能を含んだモジュールです。
データ格納のための、と言うとふんわりした印象になりますが、クラス変数を初期化するための__init__()
関数を自動生成してくれるため、クラスの定義がシンプルになります。またデータ格納を目的とするクラスの場合__init__()
に大量の引数を記載する必要がありますが、自動生成されることによりその必要も無くなります。
データ格納といった目的以外にも様々な用途に用いることが考えられると思います。
dataclassesはPython3.7から追加になりました。本記事はPython3.9のドキュメント、ソースコードを参照して執筆しています。
基本的な使い方
dataclassesモジュールを用いたデータ格納クラスを作成するために必要なことは、基本的に以下の3つです。
-
dataclasses.dataclass
デコレータをクラス宣言に付与 - データフィールドをクラス変数として記述する
- データフィールドのクラス変数に型アノテーションを記述する
例えば以下のようにデータクラス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
を指定する必要があります。
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 = []
クラス変数に初期値を設定していない場合、引数無しでインスタンスを初期化することは出来ません。
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
型アノテーションを付与しない場合
型アノテーションを指定しない場合はどうなるか。例えば以下の場合。
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__()
は自動生成されません。
-
@dataclass(init=False)
と定義した場合 -
__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モジュールのソースコード にわかりやすい表があるので、そちらをみるとわかりやすいです。
-
repr
デフォルト: True。
Trueを指定した場合、かつ、__repr__()
を自分で定義していない場合は__repr__()
を自動生成する。 -
eq
デフォルト: True。
Trueを指定した場合、かつ、__eq__()
を自分で定義していない場合は__eq__()
を自動生成する。
上の方でも書きましたが、自動生成した__eq__()
は比較したインスタンスの比較を、データクラスのフィールド(クラス変数で指定したやつ)のタプルで比較するようにします。 -
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
-
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') # エラーにならない
-
unsafe_hash
デフォルト: False。
unsafe_hash
、eq
、frozen
の指定した値の組み合わせに応じて__hash__()
が自動生成されます。__hash__()
を自前で定義している場合はエラーがraiseされます。-
unsafe_hash=True
とした場合はeq
、frozen
の値に関わらず__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__()
が自動生成されますが、eq
とfrozen
の設定に注意が必要です。
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_factory
とdefault
はどちらか一方しか指定できません。
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
の引数は他にもinit
、repr
、compare
、hash
等があり、これらは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
repr
、compare
、hash
についてはドキュメントを見ていただいた方が早いです。
上記以外には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型等からデータクラスを生成するモジュールが標準搭載されています。
-
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)])
-
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
-
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)
-
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
-
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
にできないか?という実験です。
別記事で検証しましたので詳細は以下。
上記記事から抜粋すると、以下のように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
メソッドをラップして実装の簡略化・ルールの共有などを図ろう
Discussion