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



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

下面是一些示例代码:

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)

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

可以这样做吗?是通过基类还是其他方法?

编辑:这里的目的是有一种强制/验证数据类的"所有权"语义的方法,即使它只是在运行时。

下面是常规数据类存在问题的情况的具体示例。

@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!

这可以通过使用冻结的数据类来防止!但是,仍然存在可能导致潜在错误的情况,如下所示。

@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函数都可以使实例失效。同样的策略对数据类不起作用,因为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!

需要注意的重要一点是,这种方法为 dataclass 引入了一个新字段,即_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

此外,我们需要一个默认值,这意味着在其下方声明的任何字段也需要一个默认值(这还不错 - 只需在其上方声明这些字段(,包括来自未来子级的所有字段(这更是一个问题,并且有一篇关于如何处理这种情况的大量帖子(。

每当使用这种模式时,您还需要一个可用的哨兵值。这还不错,但对某些人来说可能是一个奇怪的概念。

最新更新