具有可变和不可变属性的Dataclass风格对象?

6

我一直在尝试使用从文件中动态加载的属性名创建dataclasses,但是无法找到同时创建“冻结”和“非冻结”属性的方法。我认为dataclasses只允许您将所有属性设置为冻结或非冻结。

目前,我创建了一个冻结的dataclass并将可变类作为其中一个属性添加进去,这样我就可以随时更改它,但我对这种方法的可读性不太满意。

有没有其他Pythonic的dataclass可以推荐,而无需实现具有设置可变/不可变属性功能的类?

import dataclasses

class ModifiableConfig:
    """There is stuff in here but you get the picture."""
    ...

config_dataclass = dataclasses.make_dataclass(
    'c',
    [(x, type(x), v) for x, v in config.items()] + [('var', object, ModifiableConfig())],
    frozen=True
)

然而,我更希望能够选择哪些属性被冻结,哪些不被冻结。这使得添加额外的类到数据类变得过时了。具体如下:

config_dataclass_modifiable = dataclasses.make_dataclass(
            'c', [(x, type(x), v, True if 'modifiable' in x else False) for x, v in config.items()])

请注意"True if 'modifiable' in x else False",我并不是说这就是我最终会采用的方式,但是希望这能更好地理解我的问题。

我认为如果您添加一个示例,类似于您已经有的内容(可能带有一些示例用法),这将非常有帮助,尤其是对于那些您认为不太易读的部分。这将使我们更好地了解您所讨论的内容。 - martineau
4个回答

6
调整属性处理的正常方法是编写自定义 __setattr__ 方法,它允许您覆盖属性分配的默认行为。不幸的是,这种方法也是 dataclasses 用于强制执行 frozen 逻辑的钩子,这有效地通过抛出 TypeError: Cannot overwrite attribute __setattr__ in class ModifiableConfig 来防止进一步修改该函数。

因此,我看不到解决您问题的直接简单方法。在我看来,将类的可变部分委托给内部对象或字典并不差或不符合 Python 的风格,但如果您可以放弃 frozen 并且只需要部分可变的数据类,则可以尝试使用这个半冰冻的配方,它会更新 dataclass 装饰器,并添加一个标志 semi,您可以打开该标志以获取所描述的行为:

from dataclasses import dataclass as dc
from traceback import format_stack

def dataclass(_cls=None, *, init=True, repr=True, eq=True, order=False,
              unsafe_hash=False, frozen=False, semi=False):

    def wrap(cls):
        # sanity checks for new kw
        if semi:
            if frozen:
                raise AttributeError("Either semi or frozen, not both.")
            if cls.__setattr__ != cls.mro()[1].__setattr__:
                raise AttributeError("No touching setattr when using semi!")

        # run original dataclass decorator
        dc(cls, init=init, repr=repr, eq=eq, order=order,
           unsafe_hash=unsafe_hash, frozen=frozen)

        # add semi-frozen logic
        if semi:
            def __setattr__(self, key, value):
                if key in self.__slots__:
                    caller = format_stack()[-2].rsplit('in ', 1)[1].strip()
                    if caller != '__init__':
                        raise TypeError(f"Attribute '{key}' is immutable!")
                object.__setattr__(self, key, value)
            cls.__setattr__ = __setattr__

        return cls

    # Handle being called with or without parens
    if _cls is None:
        return wrap
    return wrap(_cls)

我这里简要说明一下,并没有涉及一些潜在的边缘情况。有更好的方法来处理包装,以使内部更加一致,但这将使这个已经复杂的片段变得更加复杂。
鉴于这个新的“dataclass”装饰器,您可以像这样使用它来定义一个带有一些不可变属性和一些可变属性的数据类:
>>> @dataclass(semi=True)
... class Foo:
...     # put immutable attributes and __dict__ into slots 
...     __slots__ = ('__dict__', 'x', 'y')
...     x: int
...     y: int
...     z: int
...
>>> f = Foo(1, 2, 3)
>>> f        # prints Foo(x=1, y=2, z=3)
>>> f.z = 4  # will work
>>> f.x = 4  # raises TypeError: attribute 'x' is immutable!

你不必使用__slots__来区分可变部分和不可变部分,但出于一些方便的原因(例如作为元属性而不是默认数据类repr的一部分),这是很方便的,并且对我来说感觉很直观。

谢谢您的回复!我会尝试一下! - JMB
希望这对你有用 =) 如果有什么不如预期的地方,请随时回来。 - Arne
太棒了,我认为这应该被添加到Python中。通常我只想要一些字段被冻结,也许将这个功能添加到field方法中会很好,像这样:x: int = field(default=0, frozen=True) - abdelgha4
是的,这将是一种真正优雅的处理方式,而不是滥用__slots__——这也与更近期Python版本中的数据类插槽方式发生冲突。 - Arne

1
在上面的最佳答案中,如果Foo是另一个类的子类,则代码会出错。要修复此问题,请使用以下代码:
super(type(self), self).__setattr__(key, value)

应该这样写:

应该阅读:

super(type(cls), cls).__setattr__(key, value)

那么,超级变量确实向上遍历,而不是进入无限自引用。

这并不是那么简单,你的解决方案在基本情况下会出现“TypeError: can't apply this setattr to type object”的错误。而且,在没有检查的情况下更新类的属性而不是实例的属性也很奇怪,我认为作为后果,更改将在所有实例上可见。我会看看是否可以在该setattr中修复mro以使其与继承一起使用,但可能干净的解决方案根本不可能 - 将继承与类装饰器混合使用本来就很可疑。 - Arne
顺便感谢你找到了这个 bug,我现在已经更新了我的答案,使用 object.__setattr__,这样就可以始终正常工作,除非某个父类定义了一个应该被使用的自定义 __setattr__ - Arne
是的,你说得对。这种解决方案更加简洁! - Coert van Gemeren

1

我发现了一种相当简单的方法来做到这一点,并保持一些像样的代码:

@dataclass
class Person():
    name: str
    id: int
    _id: int = field(init=False, repr=False)

    @property
    def id(self):
        return self._id

    @id.setter
    def id(self, id: int) -> None:
        try:
            if self._id:
                raise Exception('This field is inmutable!')
        except AttributeError as error:
            self._id = id

基本上,id 变成了一个接口,我通过在 _id 已经存在时抛出异常来重写 setter。您可以为此目的创建一个专用的异常类,例如 InmutableException


0

由于dataclasses在较新的Python版本中向@dataclass(...)添加了新的参数,例如Python 3.10中的kw_only,因此使用装饰器来包装@dataclass装饰器可能不是一个理想的选择。

另一种选择是在Python 3中使用更新的描述符方法。虽然下面的解决方案在将slots=True传递给@dataclass装饰器时无法工作,但在一般情况下它似乎足够好用。

这里是一个简单的描述符类Frozen的实现,如果设置了属性超过一次(即在__init__()之外),则会引发错误:

class Frozen:
    __slots__ = ('private_name', )

    def __set_name__(self, owner, name):
        self.private_name = '_' + name

    def __get__(self, obj, objtype=None):
        value = getattr(obj, self.private_name)
        return value

    def __set__(self, obj, value):
        if hasattr(obj, self.private_name):
            msg = f'Attribute `{self.private_name[1:]}` is immutable!'
            raise TypeError(msg) from None

        setattr(obj, self.private_name, value)

使用方法:

from dataclasses import dataclass


@dataclass
class Foo:
    # optional: define __slots__ to reduce memory usage
    __slots__ = ('_x', '_y', 'z')

    x: int = Frozen()
    y: int = Frozen()
    z: int


f = Foo(1, 2, 3)
print(f)

f.z = 4  # will work
f.z = 5  # will work

f.x = 4  # raises an error -> TypeError: Attribute `x` is immutable!

关于 Frozen,它允许您为字段设置一个默认值,请参见我的帖子,其中说明了如何设置。

时间

如果您感到好奇,我还使用自定义__setattr__()方法对上述描述方法进行了计时,具体请参见最佳答案

以下是我使用timeit模块的示例代码:

from timeit import timeit


@dc
class Foo:
    # uncomment if you truly want to add __slots__:
    # __slots__ = ('_x', '_y', 'z')

    x: int = Frozen()
    y: int = Frozen()
    z: int


@dataclass(semi=True)
class Foo2:
    # put immutable attributes and __dict__ into slots
    __slots__ = ('__dict__', 'x', 'y')
    x: int
    y: int
    z: int


n = 100_000

print('Foo.__init__() -> descriptor: ', timeit('Foo(1, 2, 3)', number=n, globals=globals()))
print('Foo.__init__() -> setattr:    ', timeit('Foo2(1, 2, 3)', number=n, globals=globals()))

f1 = Foo(1, 2, 3)
f2 = Foo2(1, 2, 3)

print('foo.z -> descriptor: ', timeit('f1.z', number=n, globals=globals()))
print('foo.z -> setattr:    ', timeit('f2.z', number=n, globals=globals()))

在我的 Mac M1 上的结果:

Foo.__init__() -> descriptor:  0.0345854579936713
Foo.__init__() -> setattr:     3.2137108749884646
foo.z -> descriptor:  0.003795791999436915
foo.z -> setattr:     0.002478832990163937

这意味着使用描述符方法创建新的Foo实例速度更快(高达100倍),但使用自定义setattr方法调用__setattr__()略微更快,可能是因为实现__slots__属性减少了内存开销,并且还减少了实例属性的平均查找时间。


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