Python dataclass 默认值:default_factory 怎样避免多个实例共用列表

10-01 4阅读

两个任务,为什么出现了同一条标签

批处理程序里,每个任务都有一个标签列表。给任务甲追加“待重试”,任务乙却也出现相同标签,这类问题容易被误判为并发写入。实际上,即使程序只有一个线程,只要两个实例持有同一个列表,也会出现这种现象。排查起点应该是对象身份,而不只是打印后的内容是否相等。

当前 dataclass 会拒绝常见的可变默认值,例如直接写一个空列表。官方文档说明,从 Python 三点十一开始,它用不可哈希性近似判断风险。这个检查能挡住常见错误,但不是完整的对象隔离证明。默认工厂需要是一个零参数可调用对象,在字段需要默认值时才调用;不能同时再指定默认值。

Python dataclass 默认值:default_factory 怎样避免多个实例共用列表

AI生成概念示意图,非真实界面

把创建时机变成可验证的行为

下面代码使用 Python 三点十一或更新版本。正确写法把 list 本身交给工厂,而不是提前调用后交出一个列表。带初始结构的字典用函数现场创建,内部重试列表也在每次调用时新建。测试先修改甲的两层数据,再检查乙仍为空,比只看两个实例的初始打印结果更有说服力。

from dataclasses import dataclass, field

try:
    @dataclass
    class Rejected:
        labels: list = []
except ValueError:
    print("mutable default: rejected")
else:
    raise AssertionError("expected mutable default rejection")

@dataclass
class Job:
    name: str
    labels: list = field(default_factory=list)
    options: dict = field(default_factory=lambda: {"retry": []})

a, b = Job("A"), Job("B")
a.labels.append("ready")
a.options["retry"].append(1)
assert a.labels is not b.labels
assert b.labels == [] and b.options == {"retry": []}
print("independent defaults:", a.labels, b.labels)

shared = []
@dataclass
class StillShared:
    labels: list = field(default_factory=lambda: shared)

c, d = StillShared(), StillShared()
c.labels.append("leak")
assert c.labels is d.labels and d.labels == ["leak"]
print("factory returning same list:", d.labels)

provided = []
e, f = Job("E", provided), Job("F", provided)
e.labels.append("caller-owned")
assert e.labels is f.labels is provided
print("explicit shared input:", f.labels)

template = {"retry": []}
left, right = dict(template), dict(template)
left["retry"].append(2)
assert left is not right and right["retry"] == [2]
print("shallow copy keeps nested alias:", right)

前两组输出分别证明直接列表默认值被拒绝,以及正确工厂产生独立列表。注意,两个空列表的内容本来就相等,用相等运算无法证明隔离;这里同时检查对象身份和修改后的内容。调查线上串数据时,可以先在测试环境创建两个最小实例,逐字段执行这种“只改一个,再看另一个”的检查。

用了工厂,仍可能返回同一个对象

第三组故意让工厂返回外部 shared。函数确实执行了两次,却交回同一个列表,因此修改仍会泄漏。工厂这个名称只规定调用方式,不保证实现诚实地制造新对象。代码评审时应沿返回值追到真正的创建位置,尤其注意模块级缓存、闭包捕获和为了节省分配而复用的模板。

第四组则由调用者显式传入同一份列表。此时默认工厂没有机会介入,两个任务共享输入是当前接口行为。若业务要求任务拥有独立副本,应在构造边界明确复制策略,并写进接口约定;若允许共享,就应明确谁能够修改。不要把“默认情况下独立”误写成“任何输入都会被自动复制”。

复制深度也属于接口设计

最后一组创建了两个新的外层字典,内部重试列表却仍相同。官方复制文档区分浅拷贝与递归复制;不过,对任务配置而言,盲目深拷贝也未必合适,例如某些字段本来代表共享资源。更容易审核的做法是列出可变字段,决定哪些新建、哪些复制、哪些只读引用,再给每条决定配一个修改测试。

手动验收还应包含带初始标签的任务、空输入和嵌套列表。测试结束后创建第三个默认实例,确认前面操作没有污染后续任务。若标签从业务上根本不允许修改,可以考虑用不可变容器表达约束;但容器中再装可变对象时仍要检查内部引用。类型注解描述预期结构,不会替你落实所有权。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。