[Python] デコレータを使わずにdataclassにする
背景
Pythonのdataclassについてです。
dataclassについての説明は以下。
タイトルだけだといまいちわかりにくいかもしれませんが、やりたいのは↓のような感じです。
抽象クラス(もしくは普通のクラス)を継承してサブクラスをdataclass
にできないか?という感じです。
class SubClass(SuperClass):
foo: str = ''
>>> sub = SubClass()
>>> print(sub)
SubClass('foo'='')
>>> is_dataclass(sub)
True
本来ならSubClass
の上に@dataclass
デコレータを付与することでクラスをdataclassとして定義します。
こちら にも書いたようにオリジナルのデコレータを作成してデコレートしたclassをdataclassにするようなことをしていたんですが、__new__()
からクラスオブジェクトに対して属性をsetattr
したりするとクラスに定義してるわけではないのでVSCodeなどで上手くコード補完が利きませんでした。
ならばデコレータではなく抽象クラスを定義してそれを継承する形でサブクラスをdataclass
として扱えないか?という実験です。
__new__
でデコレートする
ケース1: from abc import ABCMeta
from dataclasses import dataclass, is_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(foo='')
>>> print(is_dataclass(dc_impl))
True
>>> print(DataclassImpl(foo='fu'))
DataclassImpl(foo='fu')
サブクラスがちゃんとdataclassになっています。
__new__()
にはargsとkwargsを指定していないと、DataclassImpl('')
やDataclassImpl(foo='')
のように引数にフィールドを指定してインスタンスを作成しようとした場合にエラーになるので注意しましょう。
当然ながらSuperClassはdataclassではないので、SuperClassに定義されているクラス変数bar
はSubClassデータクラスのフィールドとしては認識されていません。
ケース2: 抽象クラスをdataclassにする
from abc import ABCMeta
from dataclasses import dataclass
@dataclass
class SuperDataclass2(metaclass=ABCMeta):
bar: str = ''
class DataclassImpl2(SuperDataclass2):
foo: str = ''
>>> dc_impl = DataclassImpl2()
>>> print(dc_impl)
DataclassImpl2(bar='')
>>> print(is_dataclass(dc_impl))
True
>>> print(DataclassImpl2(bar='ba'))
DataclassImpl2(bar='ba')
抽象クラスの方だけがdataclass
として定義されているためデータクラスフィールドは抽象クラスで定義したbar
しか認識されていません。
それでもSubClassはちゃんとdataclass
として認識されてますし、__init__
でフィールドにも値が入っています。
ケース3: 合わせ技
from abc import ABCMeta
from dataclasses import dataclass, is_dataclass
@dataclass
class SuperDataclass3(metaclass=ABCMeta):
bar: str = ''
def __new__(cls, *args, **kwargs):
dataclass(cls)
return super().__new__(cls)
class DataclassImpl3(SuperDataclass3):
foo: str = ''
>>> dc_impl = DataclassImpl3()
>>> print(dc_impl)
DataclassImpl3(bar='', foo='')
>>> print(is_dataclass(dc_impl))
True
>>> print(DataclassImpl3(bar='ba', foo='fu'))
DataclassImpl3(bar='ba', foo='fu')
ケース1+2の両方の手法を取り入れてみました。
サブクラスであるDataclassImpl3
にfooとbar、両方のフィールドが定義されていることがわかります。
ただし出力されている順序は抽象クラスの定義⇒サブクラスの定義の順になります。
サブクラスの__init__()
でfooとbar両方のフィールドが引数に定義されているのも確認できます。
なんでこうなるのか
Pythonのオブジェクトは__dict__
というdict型の特殊属性を持っており、その中にはクラス変数や特殊メソッド含むメソッドの情報など、オブジェクトの書き込み可能な属性
dataclass
関数は第1引数で指定したクラスオブジェクトの__dict__
を参照して、型アノテーションのついているクラス変数をデータクラスのフィールドとして定義(__dataclass_fields__
という特殊属性に保存して追加)します。
ケース2の場合は抽象クラスしかdataclass
化していないため、サブクラスのクラス変数を認識できないのはわかります。
ケース1の場合を見てみましょう。サブクラスオブジェクトの情報を出力するのに少しprintを追加しています。
class SuperDataclass(metaclass=ABCMeta):
bar: str = ''
def __new__(cls, *args, **kwargs):
print('## print(cls)')
print(cls)
print('## print(dir(cls))')
print(dir(cls))
print(cls.__dict__)
print('print(cls.__dict__)')
dataclass(cls)
return super().__new__(cls)
class DataclassImpl(SuperDataclass):
foo: str = ''
>>> DataclassImpl()
## print(cls)
<class '__main__.DataclassImpl'>
## print(dir(cls))
['__abstractmethods__', '__annotations__', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_abc_impl', 'bar', 'foo']
## print(cls.__dict__)
{'__module__': '__main__', '__annotations__': {'foo': <class 'str'>}, 'foo': '', '__doc__': None, '__abstractmethods__': frozenset(), '_abc_impl': <_abc._abc_data object at 0x7f00d1ffcd40>}
DataclassImpl(foo='')
print(cls)
でクラスオブジェクトがサブクラスのものであることがわかります。
dir(cls)
でDataclassImpl
の特殊属性、特殊メソッド、クラス変数などの一覧が取得できます。ここにはbar
とfoo
両方出力されています。
次にcls.__dict__
の中身が出力されますが、ここにはfooしか含まれていません。
上述したように__dict__
には「書き換え可能」な属性情報しか含まれていないので、Pythonにおいてはスーパークラスから継承されたクラス変数は「書き換え不可な属性」ということのようです。
上述の通りdataclass
は__dict__
の中身を見てデータクラスフィールドを決定しているので、この場合はfoo
しか追加されていないというわけです。
ケース3について。以下のようなコードを実行してみます。
@dataclass
class SuperClass(metaclass=ABCMeta):
bar: str = ''
def __new__(cls, *args, **kwargs):
print('## before dataclass')
print(cls)
print(cls.__dict__)
print(cls.__dataclass_fields__)
dataclass(cls)
print('## after dataclass')
print(cls.__dict__)
print('\n')
print(cls.__dataclass_fields__)
return super().__new__(cls)
class SubClass(SuperClass):
foo: str = ''
if __name__ == '__main__':
sub = SubClass()
print(sub)
ちょっと長いですが出力が以下です。
## before dataclass
<class '__main__.SubClass'>
{'__module__': '__main__', '__annotations__': {'foo': <class 'str'>}, 'foo': '', '__doc__': None, '__abstractmethods__': frozenset(), '_abc_impl': <_abc._abc_data object at 0x7f5e4e1bcc40>}
{'bar': Field(name='bar',type=<class 'str'>,default='',default_factory=<dataclasses._MISSING_TYPE object at 0x7f5e4e347790>,init=True,repr=True,hash=None,compare=True,metadata=mappingproxy({}),_field_type=_FIELD)}
## after dataclass
{'__module__': '__main__', '__annotations__': {'foo': <class 'str'>}, 'foo': '', '__doc__': 'SubClass(*args, **kwargs)', '__abstractmethods__': frozenset(), '_abc_impl': <_abc._abc_data object at 0x7f5e4e1bcc40>, '__dataclass_params__': _DataclassParams(init=True,repr=True,eq=True,order=False,unsafe_hash=False,frozen=False), '__dataclass_fields__': {'bar': Field(name='bar',type=<class 'str'>,default='',default_factory=<dataclasses._MISSING_TYPE object at 0x7f5e4e347790>,init=True,repr=True,hash=None,compare=True,metadata=mappingproxy({}),_field_type=_FIELD), 'foo': Field(name='foo',type=<class 'str'>,default='',default_factory=<dataclasses._MISSING_TYPE object at 0x7f5e4e347790>,init=True,repr=True,hash=None,compare=True,metadata=mappingproxy({}),_field_type=_FIELD)}, '__init__': <function __create_fn__.<locals>.__init__ at 0x7f5e4e12f040>, '__repr__': <function __create_fn__.<locals>.__repr__ at 0x7f5e4e127ee0>, '__eq__': <function __create_fn__.<locals>.__eq__ at 0x7f5e4e12f1f0>, '__hash__': None}
{'bar': Field(name='bar',type=<class 'str'>,default='',default_factory=<dataclasses._MISSING_TYPE object at 0x7f5e4e347790>,init=True,repr=True,hash=None,compare=True,metadata=mappingproxy({}),_field_type=_FIELD), 'foo': Field(name='foo',type=<class 'str'>,default='',default_factory=<dataclasses._MISSING_TYPE object at 0x7f5e4e347790>,init=True,repr=True,hash=None,compare=True,metadata=mappingproxy({}),_field_type=_FIELD)}
SubClass(bar='', foo='')
__new__()
の中でクラスオブジェクトに対してdataclass
を実行する前にcls.__dataclass_fields__
を見てみると、抽象クラスのクラス変数bar
がデータクラスのフィールドとして登録されている事がわかります。SubClass
のインスタンス生成前にSuperClass
に指定したdataclass
アノテーションが既に実行されていることがわかります。
ただcls.__dict__
の中身を見ると、サブクラスに定義したクラス変数foo
しかありません。
その後dataclass(cls)
を実行した後にcls.__dataclass_fields__
を見てみるとbar
とfoo
の両方が含まれています。cls.__dict__
にbar
が含まれていないのにちゃんとデータクラスのフィールドとして認識されています。
dataclassesのソースコードを見てみると、dataclass
化されるクラスの基底クラスのリスト(cls.__mrro__
)を参照して、基底クラスが既に__dataclass_fields__
属性を持っていた場合に、基底クラスの__dataclass_fields__
をdataclass
化するクラスのフィールドに追加していることがわかります。
これにより抽象クラスのデータクラスフィールドがサブクラスにも追加されたんですね。
注意点
dataclass
が実行されると、その処理の中でターゲットとなるクラスに定義されているクラス変数の値をデフォルト値に置き換えます。
簡略化して説明すると、以下のコードが
@dataclass
class Foo:
foo: str = field(default='fu')
↓こう変換されます。
class Foo:
foo: str = 'fu'
このとき特定のクラス変数、例えばdefault_factoryが指定されているフィールドの場合、クラス変数がクラスから削除されます。
該当するソースコードは以下のようになっています。
# If the class attribute (which is the default value for this
# field) exists and is of type 'Field', replace it with the
# real default. This is so that normal class introspection
# sees a real default value, not a Field.
if isinstance(getattr(cls, f.name, None), Field):
if f.default is MISSING:
# If there's no default, delete the class attribute.
# This happens if we specify field(repr=False), for
# example (that is, we specified a field object, but
# no default value). Also if we're using a default
# factory. The class attribute should not be set at
# all in the post-processed class.
delattr(cls, f.name)
field
でdefault
がMISSINGの場合、というif文の分岐になっていますが、field
にはdefaultかdefault_factoryのいずれかを指定しなければいけないので、defaultがMISSINGということはdefault_factoryが指定されている場合になります。
従ってdelattr(cls, f.name)
でdefault_factoryが指定されたクラス変数が削除されます。
その後2回目の実行時に以下の処理の個所に来た際に、
def _get_field(cls, a_name, a_type):
# Return a Field object for this field name and type. ClassVars
# and InitVars are also returned, but marked as such (see
# f._field_type).
# If the default value isn't derived from Field, then it's only a
# normal default value. Convert it to a Field().
default = getattr(cls, a_name, MISSING)
if isinstance(default, Field):
f = default
else:
if isinstance(default, types.MemberDescriptorType):
# This is a field in __slots__, so it has no default value.
default = MISSING
f = field(default=default)
delattr(cls, f.name)
で当該クラス変数が削除されているので、default = getattr(cls, a_name, MISSING)
の処理で、cls
にa_name
というクラス変数が存在しないため、default=MISSINGという結果になります。
その後f = field(default=default)
というところに到達しますが、default=MISSINGなので、field(default=MISSING)
でクラス変数のFieldが設定されます。defaultもdefult_factoryも初期値がMISSINGなので、default、default_facotryともにMISSINGになります。
さらにその後、__init__
関数を設定する処理の中で、defaultとdefult_facotry両方がMISSINGの場合にTypeErrorを出す処理があります。
seen_default = False
for f in fields:
# Only consider fields in the __init__ call.
if f.init:
if not (f.default is MISSING and f.default_factory is MISSING):
seen_default = True
elif seen_default:
# ここでエラーになる。
raise TypeError(f'non-default argument {f.name!r} '
'follows default argument')
このように、クラス変数にdefault_facotryを指定したfieldが定義してあり、そのクラスに対して2回dataclass
を実行してしまうと、2回目の実行時にTypeErrorになってしまいます。
回避策
if not is_dataclass(cls)
のようにclsがすでにdataclassになっているかどうかをチェックすることで2回目以降の呼び出しでエラーにならないようにすることが出来ます。
from abc import ABCMeta
from dataclasses import dataclass, is_dataclass
class SuperDataclass(metaclass=ABCMeta):
bar: str = ''
def __new__(cls, *args, **kwargs):
if not is_dataclass(cls):
# サブクラスのオブジェクトがデータクラスでなければ
# dataclassを呼び出す
dataclass(cls)
return super().__new__(cls)
ただこの場合継承しているクラスがデータクラスだった場合、1回目の処理で既に__new__
にわたってくるクラスオブジェクトclsはデータクラスになっているので、サブクラスで定義されているクラス変数がデータクラスのフィールドに設定されません。
@dataclass
class SuperDataclass(metaclass=ABCMeta):
bar: str = ''
def __new__(cls, *args, **kwargs):
# この時点でclsはデータクラスになってい、
# フィールドには"bar"のみ定義されている
# if not is_dataclass(cls):としてしまうと、
# DataclassImplの"foo"がフィールドに設定されない
dataclass(cls)
return super().__new__(cls)
class DataclassImpl(SuperDataclass):
foo: str = ''
従って、別の回避策が必要になります。例えば、「クラスオブジェクトcls
の__dataclass_fields__
にDataclassImplで定義されているクラス変数が含まれているか」といった判定が考えられます。
@dataclass
class SuperDataclass(metaclass=ABCMeta):
bar: str = ''
def __new__(cls, *args, **kwargs):
if hasattr(cls, '__dataclass_fields__'):
fields_ = cls.__dataclass_fields__ # barの情報のみ
annotations_ = cls.__dict__.get('__annotations__', {}) # fooの型情報
if all(f not in fields_.keys() for f in annotations_.keys()):
dataclass(cls)
return super().__new__(cls)
class DataclassImpl(SuperDataclass):
foo: str = ''
めんどくさい!
まとめ
ちなみにですが抽象クラスからmetaclass=ABCMeta
の部分を外して普通のクラスにした場合でも各ケースの結果は一緒ですね。
抽象クラスのクラス変数をサブクラスのデータクラスフィールドとしたい場合は、抽象クラスとサブクラス両方をdataclass
として指定する必要があるのがわかりました。
各ケースそれぞれに一長一短あると思うので、ご自身のユースケースに合う方法で実装してみてはいかがでしょうか。
Discussion