在dataclasses 中使用属性实际上有一个奇怪的效果,正如@James 也指出的那样。实际上,这个问题不仅仅局限于数据类。而是由于您声明(或重新声明)变量的顺序而发生的。
详细来说,考虑一下当你做这样的事情时会发生什么,只使用一个简单的类:
class Foo:
x: int = 2
@property
def x(self):
return self._x
但是当你现在这样做时,看看会发生什么:
>>> Foo.x
<property object at 0x00000263C50ECC78>
那么发生了什么?显然,property 方法声明覆盖了我们声明为 x: int = 2 的属性。
其实在@dataclass装饰器运行的时候(也就是Foo的类定义完成了),其实它看到的就是x的定义:
x: int = <property object at 0x00000263C50ECC78>
令人困惑,对吧?它仍然可以看到Foo.__annotations__ 中存在的类注释,但它也可以看到带有我们在数据类字段之后声明的getter 的property 对象。需要注意的是,这个结果无论如何都不是错误。然而,由于dataclasses 没有显式检查property 对象,它会将赋值= 运算符之后的值视为默认值,因此我们观察到<property object at 0x00000263C50ECC78> 作为默认值传入当我们没有为字段属性 x 显式传递值时的构造函数。
这实际上是一个非常有趣的结果,需要牢记。事实上,我还提出了一个关于 Using Field Properties 的部分,它实际上涵盖了同样的行为以及它的一些意想不到的后果。
具有必需值的属性
这是一个通用的元类方法,它可能对自动化目的很有用,假设您想要做的是在构造函数中未传递任何字段属性的值时引发TypeError。我还在public gist 中创建了一个优化的修改方法。
这个元类的作用是为该类生成一个__post_init__(),并为每个声明的字段属性检查property 对象是否已在__init__() 装饰器生成的__init__() 方法中设置为默认值;这表明没有值被传递给字段属性的构造函数,因此一个格式正确的TypeError 然后被提升给调用者。我从上面的@James's answer 改编了这种元类方法。
注意:以下示例应在 Python 3.7+ 中运行
from __future__ import annotations
from collections import deque
# noinspection PyProtectedMember
from dataclasses import _create_fn
from logging import getLogger
log = getLogger(__name__)
def require_field_properties(name, bases=None, cls_dict=None) -> type:
"""
A metaclass which ensures that values for field properties are passed in
to the __init__() method.
Accepts the same arguments as the builtin `type` function::
type(name, bases, dict) -> a new type
"""
# annotations can also be forward-declared, i.e. as a string
cls_annotations: dict[str, type | str] = cls_dict['__annotations__']
# we're going to be doing a lot of `append`s, so might be better to use a
# deque here rather than a list.
body_lines: deque[str] = deque()
# Loop over and identify all dataclass fields with associated properties.
# Note that dataclasses._create_fn() uses 2 spaces for the initial indent.
for field, annotation in cls_annotations.items():
if field in cls_dict and isinstance(cls_dict[field], property):
body_lines.append(f'if isinstance(self.{field}, property):')
body_lines.append(f" missing_fields.append('{field}')")
# only add a __post_init__() if there are field properties in the class
if not body_lines:
cls = type(name, bases, cls_dict)
return cls
body_lines.appendleft('missing_fields = []')
# to check if there are any missing arguments for field properties
body_lines.append('if missing_fields:')
body_lines.append(" s = 's' if len(missing_fields) > 1 else ''")
body_lines.append(" args = (', and' if len(missing_fields) > 2 else ' and')"
".join(', '.join(map(repr, missing_fields)).rsplit(',', 1))")
body_lines.append(' raise TypeError('
"f'__init__() missing {len(missing_fields)} required "
"positional argument{s}: {args}')")
# does the class define a __post_init__() ?
if '__post_init__' in cls_dict:
fn_locals = {'_orig_post_init': cls_dict['__post_init__']}
body_lines.append('_orig_post_init(self, *args)')
else:
fn_locals = None
# generate a new __post_init__ method
_post_init_fn = _create_fn('__post_init__',
('self', '*args'),
body_lines,
globals=cls_dict,
locals=fn_locals,
return_type=None)
# Set the __post_init__() attribute on the class
cls_dict['__post_init__'] = _post_init_fn
# (Optional) Print the body of the generated method definition
log.debug('Generated a body definition for %s.__post_init__():',
name)
log.debug('%s\n %s', '-------', '\n '.join(body_lines))
log.debug('-------')
cls = type(name, bases, cls_dict)
return cls
以及元类的示例用法:
from dataclasses import dataclass, field
from logging import basicConfig
from metaclasses import require_field_properties
basicConfig(level='DEBUG')
@dataclass
class Foo(metaclass=require_field_properties):
a: str
x: int
y: bool
z: float
# the following definitions are not needed
_x: int = field(init=False, repr=False)
_y: bool = field(init=False, repr=False)
_z: float = field(init=False, repr=False)
@property
def x(self):
return self._x
@x.setter
def x(self, value):
print(f'Setting x: {value!r}')
self._x = value
@property
def y(self):
return self._y
@y.setter
def y(self, value):
print(f'Setting y: {value!r}')
self._y = value
@property
def z(self):
return self._z
@z.setter
def z(self, value):
print(f'Setting z: {value!r}')
self._z = value
if __name__ == '__main__':
foo1 = Foo(a='a value', x=1, y=True, z=2.3)
print('Foo1:', foo1)
print()
foo2 = Foo('hello', 123)
print('Foo2:', foo2)
现在的输出似乎符合预期:
DEBUG:metaclasses:Generated a body definition for Foo.__post_init__():
DEBUG:metaclasses:-------
missing_fields = []
if isinstance(self.x, property):
missing_fields.append('x')
if isinstance(self.y, property):
missing_fields.append('y')
if isinstance(self.z, property):
missing_fields.append('z')
if missing_fields:
s = 's' if len(missing_fields) > 1 else ''
args = (', and' if len(missing_fields) > 2 else ' and').join(', '.join(map(repr, missing_fields)).rsplit(',', 1))
raise TypeError(f'__init__() missing {len(missing_fields)} required positional argument{s}: {args}')
DEBUG:metaclasses:-------
Setting x: 1
Setting y: True
Setting z: 2.3
Foo1: Foo(a='a value', x=1, y=True, z=2.3)
Setting x: 123
Setting y: <property object at 0x10c2c2350>
Setting z: <property object at 0x10c2c23b0>
Traceback (most recent call last):
...
foo2 = Foo('hello', 123)
File "<string>", line 7, in __init__
File "<string>", line 13, in __post_init__
TypeError: __init__() missing 2 required positional arguments: 'y' and 'z'
所以上述解决方案确实按预期工作,但是它有很多代码,所以值得一问:为什么不让它少代码,而是设置@ 987654352@ 在类本身,而不是通过一个元类?这里的核心原因实际上是性能。例如,在上述情况下,您最好尽量减少创建新 Foo 对象的开销。
因此,为了进一步探索这一点,我整理了一个小测试用例来比较元类方法与 __post_init__ 方法的性能,使用 inspect 模块检索类的字段属性在运行时。下面是示例代码:
import inspect
from dataclasses import dataclass, InitVar
from metaclasses import require_field_properties
@dataclass
class Foo1(metaclass=require_field_properties):
a: str
x: int
y: bool
z: float
@property
def x(self):
return self._x
@x.setter
def x(self, value):
self._x = value
@property
def y(self):
return self._y
@y.setter
def y(self, value):
self._y = value
@property
def z(self):
return self._z
@z.setter
def z(self, value):
self._z = value
@dataclass
class Foo2:
a: str
x: InitVar[int]
y: InitVar[bool]
z: InitVar[float]
# noinspection PyDataclass
def __post_init__(self, *args):
if m := sum(isinstance(arg, property) for arg in args):
s = 's' if m > 1 else ''
raise TypeError(f'__init__() missing {m} required positional argument{s}.')
arg_names = inspect.getfullargspec(self.__class__).args[2:]
for arg_name, val in zip(arg_names, args):
# setattr calls the property defined for each field
self.__setattr__(arg_name, val)
@property
def x(self):
return self._x
@x.setter
def x(self, value):
self._x = value
@property
def y(self):
return self._y
@y.setter
def y(self, value):
self._y = value
@property
def z(self):
return self._z
@z.setter
def z(self, value):
self._z = value
if __name__ == '__main__':
from timeit import timeit
n = 1
iterations = 1000
print('Metaclass: ', timeit(f"""
for i in range({iterations}):
_ = Foo1(a='a value' * i, x=i, y=i % 2 == 0, z=i * 1.5)
""", globals=globals(), number=n))
print('InitVar: ', timeit(f"""
for i in range({iterations}):
_ = Foo2(a='a value' * i, x=i, y=i % 2 == 0, z=i * 1.5)
""", globals=globals(), number=n))
以下是我在 Python 3.9 环境中使用N=1000 迭代和 Mac OS X (Big Sur) 进行测试时的结果:
Metaclass: 0.0024892739999999997
InitVar: 0.034604513
毫不奇怪,在创建多个 Foo 对象时,元类方法总体上更高效 - 平均快 10 倍。这样做的原因是它只需要遍历并确定一个类中定义的字段属性一次,然后它实际上会为这些字段生成一个__post_init__。总体而言,结果是它的性能更好,尽管它在技术上需要更多的代码和设置才能达到目标。
具有默认值的属性
假设您不想在x 未显式传递给构造函数时引发错误;也许您只想设置一个默认值,例如 None 或 int 值,例如 3。
我创建了一种专门用于处理这种情况的元类方法。还有原始的gist,如果您想了解它是如何实现的,可以查看(或者如果您也好奇,也可以直接查看source code)。无论如何,这是我在下面提出的解决方案;请注意,它涉及第三方库,因为不幸的是,这种行为目前还没有融入 dataclasses 模块中。
from __future__ import annotations
from dataclasses import dataclass, field
from dataclass_wizard import property_wizard
@dataclass
class Foo(metaclass=property_wizard):
x: int | None
_x: int = field(init=False, repr=False) # technically, not needed
@property
def x(self):
return self._x
@x.setter
def x(self, value):
print(f'Setting x to: {value!r}')
self._x = value
if __name__ == '__main__':
f = Foo(2)
assert f.x == 2
f = Foo()
assert f.x is None
这是元类方法的输出:
Setting x to: 2
Setting x to: None
以及单独使用 @dataclass 装饰器的输出 - 也如上述问题中所观察到的:
Setting x to: 2
Setting x to: <property object at 0x000002D65A9950E8>
Traceback (most recent call last):
...
assert f.x is None
AssertionError
指定默认值
最后,这是一个为属性设置显式默认值的示例,使用带有前导下划线 _ 定义的属性将其与具有公共名称的数据类字段区分开来。
from dataclasses import dataclass
from dataclass_wizard import property_wizard
@dataclass
class Foo(metaclass=property_wizard):
x: int = 1
@property
def _x(self):
return self._x
@_x.setter
def _x(self, value):
print(f'Setting x to: {value!r}')
self._x = value
if __name__ == '__main__':
f = Foo(2)
assert f.x == 2
f = Foo()
assert f.x == 1
输出:
Setting x to: 2
Setting x to: 1