登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  python教程

Python dataclasses.field 怎样声明不可共享的默认值

来源:17golang原创

时间:2026-11-02 11:59:51 250浏览 收藏

Python dataclasses.field 要为每个实例创建独立的列表、字典或集合,应使用 field(default_factory=list)、field(default_factory=dict)等零参数工厂。这里传入的是可调用对象,默认值真正需要时才调用它。工厂必须返回新对象;这个参数不会禁止调用方显式共享对象,也不会自动深拷贝嵌套数据。

官方文档:https://docs.python.org/3/library/dataclasses.html。设计背景见 https://peps.python.org/pep-0557/。下面的示例使用 Python 3.9 起支持的内置容器泛型语法;代码用于解释规则,文中不提供本机运行记录。

我会先区分默认对象与默认工厂

给一个“导入任务”建模时,我更愿意先问:每次创建任务,错误列表归谁所有?如果答案是各个任务自己维护,那么默认配置就应该保存创建方法,而不是一份已经存在的列表。这是我选择字段写法时的判断方式,不依赖任务名称或者列表最初是否为空。

from dataclasses import dataclass, field

# 反例:类定义处理时会因可变列表默认值而抛出 ValueError
@dataclass
class BadImportJob:
    errors: list[str] = field(default=[])

这段反例应单独看待,不要与后面的正常示例拼成一个脚本继续运行。把 []包进 field(default=...)没有改变对象归属:它仍是一份现成的列表。dataclasses 对常见危险默认值会主动报错,而不是悄悄为每个实例复制它。

规则有一个兼容变化:Python 3.11 起,用“默认对象不可哈希”作为拒绝条件;更早的规则针对 list、dict、set。不可哈希只是可变性的近似判断,不能把“装饰器接受了这个对象”理解为它必然不可变,更不能当成每实例独立的保证。PEP 557 解释了设计背景,现行行为仍应以当前库文档为准。

用零参数工厂创建独立字段

from dataclasses import dataclass, field

@dataclass
class ImportJob:
    source: str
    errors: list[str] = field(default_factory=list)
    counters: dict[str, int] = field(default_factory=dict)

# 两次省略字段参数,分别需要自己的默认容器
first = ImportJob("north.csv")
second = ImportJob("south.csv")
first.errors.append("缺少编号")
first.counters["invalid"] = 1

# 这些断言表达预期行为,可由读者在自己的环境执行
assert first.errors is not second.errors
assert first.counters is not second.counters
assert second.errors == []
assert second.counters == {}

errors和 counters分别使用 list、dict 工厂,保存可追加错误和可更新计数。source没有默认值,放在有默认值的字段前面,避免生成初始化方法时出现参数顺序错误。

我通常优先选内置构造器,原因是它直接表达“每次创建一个空容器”,读代码的人不必进入另一个函数确认行为。需要非空默认内容时,再写一个命名函数或简短 lambda。default_factory=list()则把列表本身传了进去;列表不可调用,不能满足工厂契约。default与 default_factory也不能同时指定。

ImportJob 字段与两个实例各自容器的关系结构图
静态说明图:字段声明中的工厂关系与实例容器归属分别分组;连线表示结构归属,不是执行时间线。

嵌套容器和显式参数仍要单独处理

让我更在意的边界是:工厂被调用,不代表它真的创造了新对象。比如 lambda: shared_errors虽然可以零参数调用,却始终返回那份共享列表。修复要落在工厂函数的返回值上,而不是仅仅让字段声明出现 default_factory 这个单词。

from dataclasses import dataclass, field

def new_sections() -> dict[str, list[str]]:
    # 外层字典和内层列表都在调用时重新创建
    return {"accepted": [], "rejected": []}

@dataclass
class ImportReport:
    sections: dict[str, list[str]] = field(default_factory=new_sections)

left = ImportReport()
right = ImportReport()
left.sections["accepted"].append("item-7")

# 不只判断外层身份,也判断实际需要隔离的内层对象
assert left.sections is not right.sections
assert left.sections["accepted"] is not right.sections["accepted"]
assert right.sections["accepted"] == []

这个工厂为每次调用创建完整的嵌套结构。如果改成对共享模板执行 dict(template),只会新建外层字典,模板里面的列表仍可能被复用。对于业务形状明确的默认值,我倾向直接重新构建需要的层级;如果确实要从复杂模板复制,则应按对象语义选择复制方式。连接、锁、文件句柄等资源不能简单视作普通数据深拷贝。

# 以下代码承接前面的 ImportJob 定义
provided: list[str] = []
one = ImportJob("one.csv", errors=provided)
two = ImportJob("two.csv", errors=provided)

# 显式传入同一个对象时,默认工厂不会接管或复制它
assert one.errors is two.errors
one.errors.append("共同记录")
assert provided == ["共同记录"]

如果接口要求“即使显式传入列表,也必须由当前实例独占外层容器”,可以在 __post_init__中用 self.errors = list(self.errors)建立一份浅拷贝。这个选择改变了输入所有权契约,应该写清楚;元素如果本身可变,浅拷贝仍会共享元素。默认工厂解决省略参数时的创建问题,输入复制解决显式传参的所有权问题,两处职责不同。

ImportReport 外层字典、内层列表与显式输入的结构关系图
静态说明图:区分嵌套默认结构与显式输入共享。判断隔离时需要查看实际会被修改的对象层级。

哪些兼容细节会改变采用方式

字段不允许构造传入时怎么写?可以设置 field(default_factory=dict, init=False)。在 dataclasses 生成的初始化方法中,这类字段仍会通过工厂取得初值。如果自己定义了 __init__,就需要自己负责相应初始化;不要假定未生成的方法仍会替你调用工厂。

frozen=True 能代替工厂吗?不能。它约束实例字段赋值,不会把字段引用的列表自动转成不可变对象。若业务确实需要不可变内容,应选择满足要求的不可变数据结构;tuple 的内部元素也需要逐一考虑,不能只看最外层类型。

为什么两个空容器判断相等,却仍然是独立的?==比较内容,is判断对象身份。判断这类默认值隔离问题时,应同时关注身份和修改后的影响;不要只用两个空列表相等就断言它们被共享。

需求建议声明或处理需要保留的边界
每实例独立空列表field(default_factory=list)显式传参仍按引用接收
每实例独立空字典field(default_factory=dict)不等于线程安全字典
非空嵌套默认结构零参数函数逐层新建不复用模板中的可变子对象
标量或合适的不可变默认值直接赋默认值考虑内部引用是否仍可变
显式输入也要复制定义初始化所有权策略浅拷贝与深层隔离不同

我会把 default_factory 当成默认对象的创建约定:省略参数时,调用一个零参数函数拿到所需对象。对于每个实例独立维护的可变状态,工厂应真正新建相关容器;对于调用方有意共享的输入,则通过接口约定明确保留或复制。这种区分比单纯消除 ValueError 更有助于后续维护。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>