数据类和 属性 装饰器

Dataclasses and property decorator

我一直在阅读 Python 3.7 的数据类作为命名元组的替代方法(我通常在必须将数据分组到结构中时使用)。我想知道 dataclass 是否与 属性 装饰器兼容,以便为数据类的数据元素定义 getter 和 setter 函数。如果是这样,这在某处有描述吗?或者有可用的例子吗?

确实有效:

from dataclasses import dataclass

@dataclass
class Test:
    _name: str="schbell"

    @property
    def name(self) -> str:
        return self._name

    @name.setter
    def name(self, v: str) -> None:
        self._name = v

t = Test()
print(t.name) # schbell
t.name = "flirp"
print(t.name) # flirp
print(t) # Test(_name='flirp')

事实上,为什么不呢?最后,你得到的只是一个很好的旧 class,派生自类型:

print(type(t)) # <class '__main__.Test'>
print(type(Test)) # <class 'type'>

也许这就是为什么没有特别提到属性的原因。但是,PEP-557's Abstract 提到了众所周知的 Python class 功能的一般可用性:

Because Data Classes use normal class definition syntax, you are free to use inheritance, metaclasses, docstrings, user-defined methods, class factories, and other Python class features.

目前,我发现的最好方法是在单独的子 class.

中用 属性 覆盖数据 class 字段
from dataclasses import dataclass, field

@dataclass
class _A:
    x: int = 0

class A(_A):
    @property
    def x(self) -> int:
        return self._x

    @x.setter
    def x(self, value: int):
        self._x = value

class 的行为类似于常规数据class。并且会正确定义__repr____init__字段(A(x=4)而不是A(_x=4)。缺点是属性不能只读。

This blog post,尝试用同名的 property 覆盖车轮数据 class 属性。 但是,@property 会覆盖默认值 field,这会导致意外行为。

from dataclasses import dataclass, field

@dataclass
class A:

    x: int

    # same as: `x = property(x)  # Overwrite any field() info`
    @property
    def x(self) -> int:
        return self._x

    @x.setter
    def x(self, value: int):
        self._x = value

A()  # `A(x=<property object at 0x7f0cf64e5fb0>)`   Oups

print(A.__dataclass_fields__)  # {'x': Field(name='x',type=<class 'int'>,default=<property object at 0x>,init=True,repr=True}

解决这个问题的一种方法是在调用 dataclass metaclass 之后覆盖 class 定义之外的字段,同时避免继承。

@dataclass
class A:
  x: int

def x_getter(self):
  return self._x

def x_setter(self, value):
  self._x = value

A.x = property(x_getter)
A.x = A.x.setter(x_setter)

print(A(x=1))
print(A())  # missing 1 required positional argument: 'x'

应该可以通过创建一些自定义元数据class 并设置一些 field(metadata={'setter': _x_setter, 'getter': _x_getter}).

来自动覆盖它

一些包装可能会很好:

#         DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 
#                     Version 2, December 2004 
# 
#  Copyright (C) 2020 Xu Siyuan <inqb@protonmail.com> 
# 
#  Everyone is permitted to copy and distribute verbatim or modified 
#  copies of this license document, and changing it is allowed as long 
#  as the name is changed. 
# 
#             DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 
#    TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 
# 
#   0. You just DO WHAT THE FUCK YOU WANT TO.

from dataclasses import dataclass, field

MISSING = object()
__all__ = ['property_field', 'property_dataclass']


class property_field:
    def __init__(self, fget=None, fset=None, fdel=None, doc=None, **kwargs):
        self.field = field(**kwargs)
        self.property = property(fget, fset, fdel, doc)

    def getter(self, fget):
        self.property = self.property.getter(fget)
        return self

    def setter(self, fset):
        self.property = self.property.setter(fset)
        return self

    def deleter(self, fdel):
        self.property = self.property.deleter(fdel)
        return self


def property_dataclass(cls=MISSING, / , **kwargs):
    if cls is MISSING:
        return lambda cls: property_dataclass(cls, **kwargs)
    remembers = {}
    for k in dir(cls):
        if isinstance(getattr(cls, k), property_field):
            remembers[k] = getattr(cls, k).property
            setattr(cls, k, getattr(cls, k).field)
    result = dataclass(**kwargs)(cls)
    for k, p in remembers.items():
        setattr(result, k, p)
    return result

你可以这样使用它:

@property_dataclass
class B:
    x: int = property_field(default_factory=int)

    @x.getter
    def x(self):
        return self._x

    @x.setter
    def x(self, value):
        self._x = value

根据上面的想法,我创建了一个 class 装饰器函数 resolve_abc_prop,它创建了一个包含 getter 和 setter 函数的新 class建议 通过@shmee.

def resolve_abc_prop(cls):
    def gen_abstract_properties():
        """ search for abstract properties in super classes """

        for class_obj in cls.__mro__:
            for key, value in class_obj.__dict__.items():
                if isinstance(value, property) and value.__isabstractmethod__:
                    yield key, value

    abstract_prop = dict(gen_abstract_properties())

    def gen_get_set_properties():
        """ for each matching data and abstract property pair, 
            create a getter and setter method """

        for class_obj in cls.__mro__:
            if '__dataclass_fields__' in class_obj.__dict__:
                for key, value in class_obj.__dict__['__dataclass_fields__'].items():
                    if key in abstract_prop:
                        def get_func(self, key=key):
                            return getattr(self, f'__{key}')

                        def set_func(self, val, key=key):
                            return setattr(self, f'__{key}', val)

                        yield key, property(get_func, set_func)

    get_set_properties = dict(gen_get_set_properties())

    new_cls = type(
        cls.__name__,
        cls.__mro__,
        {**cls.__dict__, **get_set_properties},
    )

    return new_cls

这里我们定义了一个数据classAData和一个mixinAOpMixin实现操作 关于数据。

from dataclasses import dataclass, field, replace
from abc import ABC, abstractmethod


class AOpMixin(ABC):
    @property
    @abstractmethod
    def x(self) -> int:
        ...

    def __add__(self, val):
        return replace(self, x=self.x + val)

最后,装饰器 resolve_abc_prop 用于创建一个新的 class 使用来自 AData 的数据和来自 AOpMixin.

的操作
@resolve_abc_prop
@dataclass
class A(AOpMixin):
    x: int

A(x=4) + 2   # A(x=6)

编辑 #1:我创建了一个 python 包,可以用数据 class 覆盖抽象属性:dataclass-abc

继 post 关于数据 类 和可以找到的属性 here 之后,TL;DR 版本解决了一些非常丑陋的情况,您必须调用 MyClass(_my_var=2) 和奇怪的 __repr__ 输出:

from dataclasses import field, dataclass

@dataclass
class Vehicle:

    wheels: int
    _wheels: int = field(init=False, repr=False)

    def __init__(self, wheels: int):
       self._wheels = wheels

    @property
    def wheels(self) -> int:
         return self._wheels

    @wheels.setter
    def wheels(self, wheels: int):
        self._wheels = wheels

@property 通常用于通过 getter 和 [=57 将看似 public 的参数(例如 name)存储到私有属性(例如 _name)中=]s,而 dataclasses 会为您生成 __init__() 方法。 问题是这个生成的 __init__() 方法应该通过 public 参数 name 接口,同时在内部设置私有属性 _name。 这不是由 dataclasses.

自动完成的

为了在设置值和创建对象时拥有相同的接口(通过name),可以使用以下策略(基于this blogpost,这也提供了更多解释) :

from dataclasses import dataclass, field

@dataclass
class Test:
    name: str
    _name: str = field(init=False, repr=False)

    @property
    def name(self) -> str:
        return self._name

    @name.setter
    def name(self, name: str) -> None:
        self._name = name

现在可以像人们期望的那样使用数据class 和数据成员 name:

my_test = Test(name='foo')
my_test.name = 'bar'
my_test.name('foobar')
print(my_test.name)

以上实现做了以下事情:

  • name class 成员将用作 public 接口,但它实际上并没有真正存储任何东西
  • _name class 成员存储实际内容。 field(init=False, repr=False) 的赋值确保 @dataclass 装饰器在构造 __init__()__repr__() 方法时忽略它。
  • getter/setter为name实际上returns/sets_name
  • 的内容
  • 通过@dataclass生成的初始化器将使用我们刚刚定义的setter。它不会显式初始化 _name,因为我们告诉它不要这样做。

两个支持默认值的版本

大多数已发布的方法都没有提供一种可读的方式来设置 属性 的默认值,这是 dataclass[=36= 的一个非常重要的部分].这里有两种可能的方法。

第一种方式基于@JorenV引用的方法。它在 _name = field() 中定义默认值,并利用观察结果,即如果未指定初始值,则 setter 将传递给 属性 对象本身:

from dataclasses import dataclass, field


@dataclass
class Test:
    name: str
    _name: str = field(init=False, repr=False, default='baz')

    @property
    def name(self) -> str:
        return self._name

    @name.setter
    def name(self, value: str) -> None:
        if type(value) is property:
            # initial value not specified, use default
            value = Test._name
        self._name = value


def main():
    obj = Test(name='foo')
    print(obj)                  # displays: Test(name='foo')

    obj = Test()
    obj.name = 'bar'
    print(obj)                  # displays: Test(name='bar')

    obj = Test()
    print(obj)                  # displays: Test(name='baz')


if __name__ == '__main__':
    main()

第二种方式基于与@Conchylicultor相同的方法:绕过dataclass 通过覆盖 class 定义之外的字段来实现机器。

我个人认为这种方式比第一种方式更清晰、更易读,因为它遵循正常的 dataclass 习惯用法来定义默认值并且不需要 'magic' 在 setter.

即便如此,我还是希望一切都是独立的...也许一些聪明的人可以找到一种方法将字段更新合并到 dataclass.__post_init__() 或类似的?

from dataclasses import dataclass


@dataclass
class Test:
    name: str = 'foo'

    @property
    def _name(self):
        return self._my_str_rev[::-1]

    @_name.setter
    def _name(self, value):
        self._my_str_rev = value[::-1]


# --- has to be called at module level ---
Test.name = Test._name


def main():

    obj = Test()
    print(obj)                      # displays: Test(name='foo')

    obj = Test()
    obj.name = 'baz'
    print(obj)                      # displays: Test(name='baz')

    obj = Test(name='bar')
    print(obj)                      # displays: Test(name='bar')


if __name__ == '__main__':
    main()

这是我在 __post_init__ 中将字段定义为 属性 的操作。这是一个完全 hack,但它适用于 dataclasses 基于 dict 的初始化,甚至适用于 marshmallow_dataclasses.

from dataclasses import dataclass, field, asdict


@dataclass
class Test:
    name: str = "schbell"
    _name: str = field(init=False, repr=False)

    def __post_init__(self):
        # Just so that we don't create the property a second time.
        if not isinstance(getattr(Test, "name", False), property):
            self._name = self.name
            Test.name = property(Test._get_name, Test._set_name)

    def _get_name(self):
        return self._name

    def _set_name(self, val):
        self._name = val


if __name__ == "__main__":
    t1 = Test()
    print(t1)
    print(t1.name)
    t1.name = "not-schbell"
    print(asdict(t1))

    t2 = Test("llebhcs")
    print(t2)
    print(t2.name)
    print(asdict(t2))

这将打印:

Test(name='schbell')
schbell
{'name': 'not-schbell', '_name': 'not-schbell'}
Test(name='llebhcs')
llebhcs
{'name': 'llebhcs', '_name': 'llebhcs'}

我实际上是从这个 SO 某处提到的 blog post 开始的,但是 运行 进入了数据 class 字段被设置为类型 property 的问题因为装饰器应用于 class。也就是说,

@dataclass
class Test:
    name: str = field(default='something')
    _name: str = field(init=False, repr=False)

    @property
    def name():
        return self._name

    @name.setter
    def name(self, val):
        self._name = val

会使 name 成为 property 而不是 str 类型。因此,setter 实际上将接收 property 对象作为参数而不是字段默认值。

在尝试了来自该线程的不同建议后,我对@Samsara Apathika 的回答进行了一些修改。简而言之:我从 __init__ 中删除了“下划线”字段变量(因此它可供内部使用,但 asdict()__dataclass_fields__ 看不到)。

from dataclasses import dataclass, InitVar, field, asdict

@dataclass
class D:
    a: float = 10.                # Normal attribut with a default value
    b: InitVar[float] = 20.       # init-only attribute with a default value 
    c: float = field(init=False)  # an attribute that will be defined in __post_init__
    
    def __post_init__(self, b):
        if not isinstance(getattr(D, "a", False), property):
            print('setting `a` to property')
            self._a = self.a
            D.a = property(D._get_a, D._set_a)
        
        print('setting `c`')
        self.c = self.a + b
        self.d = 50.
    
    def _get_a(self):
        print('in the getter')
        return self._a
    
    def _set_a(self, val):
        print('in the setter')
        self._a = val


if __name__ == "__main__":
    d1 = D()
    print(asdict(d1))
    print('\n')
    d2 = D()
    print(asdict(d2))

给出:

setting `a` to property
setting `c`
in the getter
in the getter
{'a': 10.0, 'c': 30.0}


in the setter
setting `c`
in the getter
in the getter
{'a': 10.0, 'c': 30.0}

这是另一种方式,它允许您拥有没有前导下划线的字段:

from dataclasses import dataclass


@dataclass
class Person:
    name: str = property

    @name
    def name(self) -> str:
        return self._name

    @name.setter
    def name(self, value) -> None:
        self._name = value

    def __post_init__(self) -> None:
        if isinstance(self.name, property):
            self.name = 'Default'

结果是:

print(Person().name)  # Prints: 'Default'
print(Person('Joel').name)  # Prints: 'Joel'
print(repr(Person('Jane')))  # Prints: Person(name='Jane')

这种在数据类中使用属​​性的方法也适用于 asdict,而且也更简单。为什么?使用 ClassVar 键入的字段会被数据类忽略,但我们仍然可以在我们的属性中使用它们。

@dataclass
def SomeData:
    uid: str
    _uid: ClassVar[str]

    @property
    def uid(self) -> str:
        return self._uid

    @uid.setter
    def uid(self, uid: str) -> None:
        self._uid = uid

具有最少附加代码且没有隐藏变量的解决方案是覆盖 __setattr__ 方法以对字段进行任何检查:

@dataclass
class Test:
    x: int = 1

    def __setattr__(self, prop, val):
        if prop == "x":
            self._check_x(val)
        super().__setattr__(prop, val)

    @staticmethod
    def _check_x(x):
        if x <= 0:
            raise ValueError("x must be greater than or equal to zero")

好的,这是我第一次尝试将所有内容都包含在 class.

我尝试了几种不同的方法,包括在 class 定义上方的 @dataclass 旁边有一个 class 装饰器。装饰器版本的问题是,如果我决定使用它,我的 IDE 会抱怨,然后我丢失了 dataclass 装饰器提供的大部分类型提示。例如,如果我试图将一个字段名称传递给构造函数方法,当我添加一个新的 class 装饰器时它不再自动完成。我认为这是有道理的,因为 IDE 假定装饰器以某种重要的方式覆盖了原始定义,但这成功地说服了我不要尝试使用装饰器方法。

我最后添加了一个元class来更新与数据class字段关联的属性,以检查传递给setter的值是否是一个属性对象正如其他一些解决方案所提到的,现在看来效果很好。以下两种方法中的任何一种都应该适用于测试(基于@Martin CR的解决方案)

from dataclasses import dataclass, field


@dataclass
class Test(metaclass=dataclass_property_support):
    name: str = property
    _name: str = field(default='baz', init=False, repr=False)

    @name
    def name(self) -> str:
        return self._name

    @name.setter
    def name(self, value: str) -> None:
        self._name = value

    # --- other properties like these should not be affected ---
    @property
    def other_prop(self) -> str:
        return self._other_prop

    @other_prop.setter
    def other_prop(self, value):
        self._other_prop = value

这里有一种方法(隐含地)将以下划线开头的 属性 _name 映射到数据 class 字段 name:

@dataclass
class Test(metaclass=dataclass_property_support):
    name: str = 'baz'

    @property
    def _name(self) -> str:
        return self._name[::-1]

    @_name.setter
    def _name(self, value: str):
        self._name = value[::-1]

我个人更喜欢后一种方法,因为在我看来它看起来更干净一些,而且在调用数据class 辅助函数时字段 _name 不会显示 asdict 例如。

以下内容适用于上述任一方法的测试目的。最好的部分是我的 IDE 也不会抱怨任何代码。

def main():
    obj = Test(name='foo')
    print(obj)                  # displays: Test(name='foo')

    obj = Test()
    obj.name = 'bar'
    print(obj)                  # displays: Test(name='bar')

    obj = Test()
    print(obj)                  # displays: Test(name='baz')


if __name__ == '__main__':
    main()

最后,这里是 metaclass dataclass_property_support 的定义,现在似乎可以正常工作:

from dataclasses import MISSING, Field
from functools import wraps
from typing import Dict, Any, get_type_hints


def dataclass_property_support(*args, **kwargs):
    """Adds support for using properties with default values in dataclasses."""
    cls = type(*args, **kwargs)

    # the args passed in to `type` will be a tuple of (name, bases, dict)
    cls_dict: Dict[str, Any] = args[2]

    # this accesses `__annotations__`, but should also work with sub-classes
    annotations = get_type_hints(cls)

    def get_default_from_annotation(field_: str):
        """Get the default value for the type annotated on a field"""
        default_type = annotations.get(field_)
        try:
            return default_type()
        except TypeError:
            return None

    for f, val in cls_dict.items():

        if isinstance(val, property):
            public_f = f.lstrip('_')

            if val.fset is None:
                # property is read-only, not settable
                continue

            if f not in annotations and public_f not in annotations:
                # adding this to check if it's a regular property (not
                # associated with a dataclass field)
                continue

            try:
                # Get the value of the field named without a leading underscore
                default = getattr(cls, public_f)
            except AttributeError:
                # The public field is probably type-annotated but not defined
                #   i.e. my_var: str
                default = get_default_from_annotation(public_f)
            else:
                if isinstance(default, property):
                    # The public field is a property
                    # Check if the value of underscored field is a dataclass
                    # Field. If so, we can use the `default` if one is set.
                    f_val = getattr(cls, '_' + f, None)
                    if isinstance(f_val, Field) \
                            and f_val.default is not MISSING:
                        default = f_val.default
                    else:
                        default = get_default_from_annotation(public_f)

            def wrapper(fset, initial_val):
                """
                Wraps the property `setter` method to check if we are passed
                in a property object itself, which will be true when no
                initial value is specified (thanks to @Martin CR).

                """
                @wraps(fset)
                def new_fset(self, value):
                    if isinstance(value, property):
                        value = initial_val
                    fset(self, value)
                return new_fset

            # Wraps the `setter` for the property
            val = val.setter(wrapper(val.fset, default))

            # Replace the value of the field without a leading underscore
            setattr(cls, public_f, val)

            # Delete the property if the field name starts with an underscore
            # This is technically not needed, but it supports cases where we
            # define an attribute with the same name as the property, i.e.
            #    @property
            #    def _wheels(self)
            #        return self._wheels
            if f.startswith('_'):
                delattr(cls, f)

    return cls

更新 (10/2021):

我已经设法将上述逻辑(包括对其他边缘情况的支持)封装到链接文档中的帮助程序库 dataclass-wizard, in case this is of interest to anyone. You can find out more about using field properties 中。编码愉快!

更新 (11/2021):

一种更高效的方法是使用 metaclass 在 class 上生成一个 __post_init__(),仅 运行s 一次来修复字段属性,因此它可以工作有数据classes。您可以在此处查看我添加的 the gist。我能够对其进行测试,并且在创建多个 class 实例时,这种方法得到了优化,因为它在第一次 __post_init__() 是 运行.

时正确设置了所有内容

只需将字段定义放在属性之后:

@dataclasses.dataclass
class Test:
    @property
    def driver(self):
        print("In driver getter")
        return self._driver

    @driver.setter
    def driver(self, value):
        print("In driver setter")
        self._driver = value

    _driver: typing.Optional[str] =\
        dataclasses.field(init=False, default=None, repr=False)
    driver: typing.Optional[str] =\
       dataclasses.field(init=False, default=driver)
>>> t = Test(1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: __init__() takes 1 positional argument but 2 were given
>>> t = Test()
>>> t._driver is None
True
>>> t.driver is None
In driver getter
True
>>> t.driver = "asdf"
In driver setter
>>> t._driver == "asdf"
True
>>> t
In driver getter
Test(driver='asdf')

我很惊讶这还不是一个答案,但我质疑它的智慧。这个答案的唯一原因是在表示中包含 属性 - 因为 属性 的后备存储 (_driver) 已经包含在比较测试和相等性测试等中。例如,这是一个常见的成语:

class Test:
    def __init__(self):
        self._driver = "default"

    @property
    def driver(self):
        if self._driver == "default":
            self._driver = "new"
        return self._driver
>>> t = Test()
>>> t
<__main__.Test object at 0x6fffffec11f0>
>>> t._driver
'default'
>>> t.driver
'new'

这是等效的数据class - 除了它在表示中添加了 属性。在标准class中,(t._driver,t.driver)的结果是("default","new")。请注意,数据 class 的结果是 ("new","new")。这是一个非常简单的示例,但您必须认识到,在特殊方法中包含可能具有副作用的属性可能不是最好的主意。

@dataclasses.dataclass
class Test:
    @property
    def driver(self):
        print("In driver getter")
        if self._driver == "default":
            self._driver = "new"
        return self._driver

    _driver: typing.Optional[str] =\
        dataclasses.field(init=False, default="default", repr=False)

    driver: typing.Optional[str] =\
       dataclasses.field(init=False, default=driver)
>>> t = Test()
>>> t
In driver getter
Test(driver='new')
>>> t._driver
'new'
>>> t.driver
In driver getter
'new'

所以我建议只使用:

@dataclasses.dataclass
class Test:
    _driver: typing.Optional[str] =\
        dataclasses.field(init=False, default="default", repr=False)

    @property
    def driver(self):
        print("In driver getter")
        if self._driver == "default":
            self._driver = "new"
        return self._driver
>>> t
Test()
>>> t._driver
'default'
>>> t.driver
In driver getter
'new'

您可以回避整个问题,避免使用 dataclasses 进行初始化,只需在 属性 getter.

中使用 hasattr
@dataclasses.dataclass
class Test:
    @property
    def driver(self):
        print("In driver getter")
        if not hasattr(self, "_driver"):
            self._driver = "new"
        return self._driver

或使用 __post_init__:

@dataclasses.dataclass
class Test:
    def __post_init__(self):
        self._driver = None

    @property
    def driver(self):
        print("In driver getter")
        if self._driver is None:
            self._driver = "new"
        return self._driver

为什么要这样做?因为 init=False 数据 class 默认值仅存储在 class 而不是实例。

对于将我带到此页面的用例,即拥有一个不可变的数据类,有一个简单的选项可以使用 @dataclass(frozen=True)。这删除了 ​​getter 和 setter 的所有相当冗长的显式定义。选项 eq=True 也很有用。

来源:joshorr 对 this post, linked in a comment to the accepted answer. Also a bit of a classical case of RTFM 的回复。