是否可以防止从冻结的 python 数据类中读取数据?

Is it possible to prevent reading from a frozen python dataclass?

我有一种情况,我希望能够将冻结的 dataclass 实例视为始终具有最新数据。或者换句话说,我希望能够检测到 dataclass 实例是否已调用 replace 并抛出异常。它也应该仅适用于该特定实例,以便 creation/replacements 的其他数据class 相同类型的实例不会相互影响。

下面是一些示例代码:

from dataclasses import dataclass, replace

@dataclass(frozen=True)
class AlwaysFreshData:
    fresh_data: str


def attempt_to_read_stale_data():
    original = AlwaysFreshData(fresh_data="fresh")
    unaffected = AlwaysFreshData(fresh_data="not affected")

    print(original.fresh_data)

    new = replace(original, fresh_data="even fresher")

    print(original.fresh_data) # I want this to trigger an exception now

    print(new.fresh_data)

这里的想法是防止 意外突变和从我们的数据class 对象中读取过时数据以防止错误。

可以这样做吗?通过基础 class 或其他方法?

编辑:此处的目的是为数据classes 提供一种enforcing/verifying“所有权”语义,即使它仅在运行时。

这是一个有问题的常规数据class情况的具体示例。

@dataclass
class MutableData:
    my_string: str

def sneaky_modify_data(data: MutableData) -> None:
    some_side_effect(data)
    data.my_string = "something else" # Sneaky string modification

x = MutableData(my_string="hello")

sneaky_modify_data(x)

assert x.my_string == "hello" # as a caller of 'sneaky_modify_data', I don't expect that x.my_string would have changed!

这可以通过使用冻结数据来避免classes!但是仍然有一种情况会导致潜在的错误,如下所示。

@dataclass(frozen=True)
class FrozenData:
    my_string: str

def modify_frozen_data(data: FrozenData) -> FrozenData:
   some_side_effect(data)
   return replace(data, my_string="something else")

x = FrozenData(my_string="hello")

y = modify_frozen_data(x)

some_other_function(x) # AHH! I probably wanted to use y here instead, since it was modified!

总而言之,我希望能够防止 对数据进行偷偷摸摸或未知的修改,同时强制使已替换的数据失效。 这样可以防止意外使用过时的数据。

有些人可能很熟悉这种情况,因为它类似于 Rust 中的所有权语义。

至于我的具体情况,我已经有大量使用这些语义的代码,除了 NamedTuple 个实例。这是可行的,因为在任何实例上修改 _replace 函数都允许使实例无效。同样的策略对于数据classes 并不适用,因为dataclasses.replace 不是实例本身的函数。

我同意 Jon 的观点,保持适当的数据清单和更新共享实例将是解决问题的更好方法,但如果由于某种原因这不可能或不可行(你应该认真检查它是否 真的 足够重要),有一种方法可以实现你所描述的(顺便说一句,好的模型)。不过,它需要一些重要的代码,并且之后对您的数据类有一些限制:

from dataclasses import dataclass, replace, field
from typing import Any, ClassVar


@dataclass(frozen=True)
class AlwaysFreshData:
    #: sentinel that is used to mark stale instances
    STALE: ClassVar = object()

    fresh_data: str
    #: private staleness indicator for this instance
    _freshness: Any = field(default=None, repr=False)

    def __post_init__(self):
        """Updates a donor instance to be stale now."""

        if self._freshness is None:
            # is a fresh instance
            pass
        elif self._freshness is self.STALE:
            # this case probably leads to inconsistent data, maybe raise an error?
            print(f'Warning: Building new {type(self)} instance from stale data - '
                  f'is that really what you want?')
        elif isinstance(self._freshnes, type(self)):
            # is a fresh instance from an older, now stale instance
            object.__setattr__(self._freshness, '_instance_freshness', self.STALE)
        else:
            raise ValueError("Don't mess with private attributes!")
        object.__setattr__(self, '_instance_freshness', self)

    def __getattribute__(self, name):
        if object.__getattribute__(self, '_instance_freshness') is self.STALE:
            raise RuntimeError('Instance went stale!')
        return object.__getattribute__(self, name)

您的测试代码的行为如下:

# basic functionality
>>> original = AlwaysFreshData(fresh_data="fresh")
>>> original.fresh_data
fresh
>>> new = replace(original, fresh_data="even fresher")
>>> new.fresh_data
even_fresher

# if fresher data was used, the old instance is "disabled"
>>> original.fresh_data
Traceback (most recent call last):
  File [...] in __getattribute__
    raise RuntimeError('Instance went stale!')
RuntimeError: Instance went stale!

# defining a new, unrelated instance doesn't mess with existing ones
>>> runner_up = AlwaysFreshData(fresh_data="different freshness")
>>> runner_up.fresh_data
different freshness
>>> new.fresh_data  # still fresh
even_fresher
>>> original.fresh_data  # still stale
Traceback (most recent call last):
  File [...] in __getattribute__
    raise RuntimeError('Instance went stale!')
RuntimeError: Instance went stale!

需要注意的一件重要事情是,这种方法向数据类引入了一个新字段,即 _freshness,它可能会被手动设置并扰乱整个逻辑。您可以尝试在 __post_init__ 中捕获它,但像这样的东西是让旧实例保持新鲜的有效偷偷摸摸的方式:

>>> original = AlwaysFreshData(fresh_data="fresh")
# calling replace with _freshness=None is a no-no, but we can't prohibit it
>>> new = replace(original, fresh_data="even fresher", _freshness=None)
>>> original.fresh_data
fresh
>>> new.fresh_data
even_fresher

此外,我们需要它的默认值,这意味着在它下面声明的任何字段也需要一个默认值(这还不错 - 只需在它上面声明那些字段),包括未来子项的所有字段(这是一个更大的问题,关于如何处理这种情况有 )。

无论何时使用这种模式,您还需要一个可用的标记值。这并不是很糟糕,但对某些人来说可能是一个奇怪的概念。