数据类和属性装饰器

88

我一直在研究Python 3.7的dataclass,作为namedtuple的替代品(通常在需要对数据进行分组处理时使用)。我想知道dataclass是否与property装饰器兼容,以定义getter和setter函数来操作dataclass的数据元素。如果是这样的话,这个描述在哪里?或者有没有可用的示例?


1
https://florimond.dev/blog/articles/2018/10/reconciling-dataclasses-and-properties-in-python/ - demberto
1
这只是指出了问题,但并没有解决它。例如,在__init__中未传递默认值的属性是一个未处理的情况。 - rv.kvetch
18个回答

74

确实起作用:

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')

实际上,为什么不呢?最终,你得到的只是一个来自类型派生出来的好老的类:

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

也许这就是为什么属性没有被特别提到的原因。然而,PEP-557's Abstract提到了众所周知的Python类特性的通用可用性:
由于数据类使用普通的类定义语法,您可以自由地使用继承、元类、文档字符串、用户定义方法、类工厂和其他Python类特性。

38
我想,我有点希望dataclasses允许使用属性来覆盖获取或设置,而不必将字段命名为前导下划线。数据类的一部分是初始化,这意味着你的接口与创建不同,即你最终得到的是Test(_name='foo')。虽然这只是一个小问题,但是因为dataclasses和命名元组之间的区别很小,所以这会成为另一个有用的特性(使其更加独特,从而赋予它更多的目的)。 - Marc
1
@Marc 他们确实会!使用经典的getter和setter,在init中调用setter函数而不是直接赋值。 def set_booking_ref(self, value:str): self._booking_ref = value.strip() ... booking_ref = property(get_booking_ref, set_booking_ref) ... def __init__(self, booking_ref :str): self.set_booking_ref(self, booking_ref)。不确定如何使用@property装饰器来完成这个操作。 - Alan
23
@Marc我有同样的顾虑。这里有一个好的解释,说明如何解决这个问题。 - JorenV
1
@DanCoates,感谢您指出这一点。我刚刚创建了一个适当的答案。 - JorenV
2
将私有成员作为公共数据类字段提供是一种反模式。 - Rick
显示剩余5条评论

27
一个最小化额外代码和没有隐藏变量的解决方案是覆盖__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")

2
这是一个相当可靠的解决方案。你绕过了需要属性方法的需求,这可能是好事也可能是坏事。个人而言,我喜欢属性的概念,因为我觉得它真正符合 Python 的风格,但我仍然投了赞成票,因为这绝对是一种有效的方法。 - rv.kvetch
2
我的使用场景是基于数据类字段值覆盖一些模板化的“Path”实例,因此“property”太啰嗦了:对于每个变量,需要使用下划线前缀变量+属性定义+带有“Path”覆盖的setter。这个解决方案简洁明了!非常感谢! - никта

20

支持默认值的两个版本

大多数已发布的方法没有提供一种可读的方式来设置属性的默认值,这是dataclass相当重要的一部分。以下是两种可能的方法。

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

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机制。

个人认为这种方法比第一种更清晰易读,因为它遵循正常的dataclass习惯用法来定义默认值,并且在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()

1
正如另一个串中所指出的那样,如果你发现自己需要投入这么多的努力,那么最好还是使用普通类吧... - Martin CR
夸张了吧?像 Test.name = Test._name 这样的琐碎单行代码根本算不上“麻烦”。虽然这是令人讨厌的样板代码,但它仍然比等效的普通类(即非 @dataclass)要少得多。 - Cecil Curry
1
如果有人感兴趣,也包括 @MartinCR,我想出了一种元类方法,它在某种程度上受到了这篇文章的启发。我已经确定它非常高效,因为它生成一个 __post_init__,仅在初始设置属性时运行一次,因此它与数据类很好地配合使用。你可以在 gist 这里 找到它。 - rv.kvetch

15
@property通常用于通过getter和setter将看似公共参数(例如name)存储到私有属性(例如_name)中,而数据类为您生成__init __()方法。 问题在于,这个生成的__init __()方法应该通过公共参数name进行接口交互,同时在内部设置私有属性_name。 数据类不能自动完成此操作。
为了具有相同的接口(通过name)来设置值和创建对象,可以使用以下策略(基于这篇博客文章,提供了更多解释):
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

现在可以像使用带有数据成员name的数据类那样使用它:

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

上述实现做了以下几件事情:

  • name 类成员将作为公共接口使用,但实际上并不真正存储任何内容。
  • _name 类成员存储实际内容。使用 field(init=False, repr=False) 进行赋值,确保 @dataclass 装饰器在构建 __init__()__repr__() 方法时忽略它。
  • name 的 getter/setter 实际上返回/设置 _name 的内容。
  • @dataclass 生成的初始化程序将使用我们刚刚定义的 setter。它不会显式地初始化 _name,因为我们告诉它不要这样做。

这是我个人认为最好的答案,但缺少(重要的)能力来设置在实例化类时未指定属性的默认值。请参阅我的答案以进行微调以允许此功能。 - Martin CR
1
请注意,mypy会抱怨name的双重定义!但不会有运行时错误。 - gmagno
顺便说一句,我添加了一种使用元类的方法,以帮助支持具有默认值的属性。 - rv.kvetch

6

目前,我发现最好的方法是在一个独立的子类中通过属性覆盖数据类字段。

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

这个类的行为类似于普通的数据类。并且将正确定义__repr____init__字段(A(x=4)而不是A(_x=4))。缺点是属性不能是只读的。 这篇博客文章试图通过同名的property覆盖wheels数据类属性。 然而,@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
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'

通过创建自定义元类并设置一些 field(metadata={'setter': _x_setter, 'getter': _x_getter}),很可能可以自动覆盖它。


对于您的第一种方法,似乎也有可能从内到外进行。定义带有 getter 和 setter 的 _A,同时使用 @dataclass 外部 A(_A) - InQβ

5

一些包装可能是有益的:

#         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

2
虽然外观美观,但计算强度很高。property_dataclass() 的时间复杂度为 O(n)(其中 n 是装饰类的属性数量),具有大量不可忽略的常数。对于简单的数据类可能还可以接受,但对于非平凡的数据类,特别是涉及继承的情况下,很快就会变成 CPU 负载的混合物。核心 @dataclass 装饰器本身的计算强度只会加剧这种担忧。 - Cecil Curry

5

以下是我在__post_init__中定义字段的属性的方式。虽然这是一个有点投机取巧的方法,但它可以与基于字典的dataclasses初始化和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'}

实际上,我是从这篇在Stack Overflow中提到的博文开始的,但遇到了一个问题,即由于装饰器被应用于类,数据类字段被设置为property类型。

@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 对象作为参数,而不是字段默认值。


2

使用数据类中的属性方法也适用于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

IDE似乎会抱怨如果不带参数调用构造函数,所以我建议像这样定义它:uid: str = None。当然,另一个问题是如果没有通过构造函数提供值,则uid将被设置为属性对象,但可以轻松地通过使用装饰器来解决这个问题。 - rv.kvetch

2

这里介绍另一种方法,可以让您使用没有前导下划线的字段:

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')

这种方法唯一的问题(至少我所知道的)是当访问或读取属性时,PyCharm会发出警告。例如: print(p.name) assert p.name == 'test'. 我猜解决方法可能是像这样分配:name: str = None 并用 @property 装饰它本身;虽然 PyCharm 在实现层面上仍然会发出警告,但在客户端,警告似乎已经消失了。 - rv.kvetch

2

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

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

最终,我添加了一个元类来更新与dataclass字段相关联的属性,以检查传递给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映射到数据类字段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]

我个人更喜欢后一种方法,因为在我看来它看起来更加简洁,而且当调用dataclass帮助函数asdict时,字段_name不会显示出来。以下内容可用于测试上述任一方法。最好的部分是我的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()

最后,这是元类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

更新(2021年10月):

我已经将上述逻辑 - 包括对额外边缘情况的支持 - 封装到助手库 dataclass-wizard 中,以防这对任何人有兴趣。您可以在链接的文档中了解更多关于使用字段属性的内容。快乐编码!

更新(2021年11月):

一种更高效的方法是使用元类来生成一个仅运行一次的 __post_init__() ,以修复字段属性,使其与数据类配合使用。您可以在此处查看代码片段。我已经测试过了,当创建多个类实例时,这种方法被优化了,因为它设置了第一次运行 __post_init__() 时需要的所有东西。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接