【问题标题】:Required positional arguments with dataclass properties具有数据类属性的必需位置参数
【发布时间】:2021-12-19 01:35:07
【问题描述】:

似乎已经对此进行了相当多的讨论。我发现 this post 特别有用,而且它似乎提供了最好的解决方案之一。

但是推荐的解决方案有问题。

嗯,一开始似乎效果很好。考虑一个没有属性的简单测试用例:

@dataclass
class Foo:
    x: int
>>> # Instantiate the class
>>> f = Foo(2)
>>> # Nice, it works!
>>> f.x
2

现在尝试使用推荐的解决方案将x 实现为属性:

@dataclass
class Foo:
    x: int
    _x: int = field(init=False, repr=False)
    
    @property
    def x(self):
        return self._x
    
    @x.setter
    def x(self, value):
        self._x = value
>>> # Instantiate while explicitly passing `x`
>>> f = Foo(2)
>>> # Still appears to work
>>> f.x
2

但是等等……

>>> # Instantiate without any arguments
>>> f = Foo()
>>> # Oops...! Property `x` has never been initialized. Now we have a bug :(
>>> f.x
<property object at 0x10d2a8130>

真正预期的行为是:

>>> # Instantiate without any arguments
>>> f = Foo()
TypeError: __init__() missing 1 required positional argument: 'x'

似乎 dataclass 字段已被该属性覆盖...关于如何解决此问题的任何想法?

相关:

【问题讨论】:

    标签: python properties python-dataclasses


    【解决方案1】:

    在数据类中使用与__init__ 方法的参数名称相同的属性有一个有趣的副作用。当类在没有参数的情况下被实例化时,property 对象作为默认值传递。

    作为一种变通方法,您可以使用检查x 中的__post_init__ 的类型。

    @dataclass
    class Foo:
        x: int
        _x: int = field(init=False, repr=False)
    
        def __post_init__(self):
            if isinstance(self.x, property):
                raise TypeError("__init__() missing 1 required positional argument: 'x'")
    
        @property
        def x(self):
            return self._x
    
        @x.setter
        def x(self, value):
            self._x = value
    

    现在在实例化 Foo 时,不传递任何参数会引发预期的异常。

    f = Foo()
    # raises TypeError
    
    f = Foo(1)
    f
    # returns
    Foo(x=1)
    

    当使用多个属性时,这是一个更通用的解决方案。这使用InitVar 将参数传递给__post_init__ 方法。它确实要求首先列出属性,并且它们各自的存储属性必须具有相同的名称并带有前导下划线。

    这很 hacky,属性不再显示在 repr 中。

    @dataclass
    class Foo:
        x: InitVar[int]
        y: InitVar[int]
        _x: int = field(init=False, repr=False, default=None)
        _y: int = field(init=False, repr=False, default=None)
    
        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[1:]
            for arg_name, val in zip(arg_names, args):
                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
    

    【讨论】:

    • 我喜欢它很简单,但感觉很hacky。如果有多个没有默认值的属性字段,您能否展示一个更通用的解决方案?我们是否需要将它们硬编码到初始化后检查中?
    • @James 后一种方法是个好主意,但它有一些缺陷,至少在测试方面是这样。例如,如果您在顶部定义了一个数据类字段,如 a: str 不与属性关联,则 inspect.getfullargspec 调用返回 [a, x, y] 的 3 元素元组,而不是预期的 [x, y]。
    • 我注意到的另一件重要的事情是__post_init__ 设置内部属性并且不通过属性设置器。至少,我觉得在这里检查属性是个好主意,因为它通常可能在 setter 方法中有一些验证逻辑。
    【解决方案2】:

    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 对象,它会将赋值= 运算符之后的值视为默认值,因此我们观察到&lt;property object at 0x00000263C50ECC78&gt; 作为默认值传入当我们没有为字段属性 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 未显式传递给构造函数时引发错误;也许您只想设置一个默认值,例如 Noneint 值,例如 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
    

    【讨论】:

    • +1 用于澄清变量名称只是被属性定义覆盖。我理解这一点,但是当您考虑在同一范围内将相同的名称分配给两次时,这当然看起来很明显。我想唯一的方法是将属性创建移动到不同的范围,例如在课堂之外(正如 morlind 在链接文章的 cmets 中所建议的那样),或者进入 __post_init__。我真的想知道最后一个选项是否最有意义。
    • @corvus 实际上我不喜欢将属性创建移到类之外,原因与我在链接文章中概述的原因相同。对我来说,主要问题是,例如,如果您将另一个字段属性添加到类中,很容易忘记添加它。我更喜欢提到的__post_init__(也是@James 在这里建议的方法),因为它包含在类中,并且由于它靠近类定义的顶部,因此可以根据需要轻松更改或修改它。缺点是我猜您需要为您计划添加的每个新属性手动执行此操作。
    • @corvus 我添加了一种更通用的元类方法,可用于自动检查是否为字段属性传递了值。到目前为止它看起来很有效,但可能值得做一些改进。我暂时保持原样。
    猜你喜欢
    • 2022-12-12
    • 2020-06-11
    • 1970-01-01
    • 1970-01-01
    • 2013-05-17
    • 2021-06-19
    • 1970-01-01
    • 1970-01-01
    • 2020-07-31
    相关资源
    最近更新 更多