🐍

[Python] デコレータを使わずにdataclassにする

2021/01/08に公開

背景

Pythonのdataclassについてです。
dataclassについての説明は以下。

https://zenn.dev/enven/articles/8b80ff38461b4ff329aa

タイトルだけだといまいちわかりにくいかもしれませんが、やりたいのは↓のような感じです。
抽象クラス(もしくは普通のクラス)を継承してサブクラスを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として扱えないか?という実験です。

ケース1: __new__でデコレートする

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の特殊属性、特殊メソッド、クラス変数などの一覧が取得できます。ここにはbarfoo両方出力されています。
次に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__を見てみるとbarfooの両方が含まれています。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)

fielddefaultが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)の処理で、clsa_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