第一次调用函数,结果只有本次加入的内容;第二次没有传容器,却把上次的数据也带回来了。遇到这种现象,先别急着怀疑调用顺序或数据库缓存。如果函数签名里出现列表、字典默认值,先检查那个对象是不是一直被重复使用。这里用一个可独立运行的实验,把问题定位、修复方法和容易漏掉的边界放在一起。
一、先看问题:参数省略了,状态却留下了
设想一个收集条目的函数,把收到的值追加进列表后返回。把列表写成参数默认值,看起来像是“没有传列表时,用一个空列表”。真正执行两次才发现,第二次得到的是两次追加的合并结果。更隐蔽的是,第一次拿到的返回值也会跟着变化,因为两次返回的并不是独立快照,而是同一个对象。
这类错误常在单次调用测试中漏掉:第一次结果完全合理,问题要到后续调用才显现。字典默认值也一样,前一次写入的键值会留给后一次调用。排查时应同时比较内容和对象身份;两个列表内容相等,不能证明它们共享,内容不同也不能直接说明它们独立。实验用身份比较确认关系,不依赖某个具体内存地址。
二、根因:默认值在执行 def 时求值
Python 官方教程指出,默认值在函数定义处、定义所在的作用域中求值。语言参考进一步说明:默认参数表达式在执行函数定义时从左到右求值,随后调用使用已经算好的值。因此,准确说法不是“每次调用都会创建默认列表”,而是“每次执行这条 def 时计算默认值”。同一个函数的默认列表被修改后,后续未传该参数的调用会继续使用它。[1][2]
这里的“每次执行”很重要。如果函数定义位于工厂函数内部,每次调用工厂都会再次执行内部的 def;默认表达式写成空列表字面量时,这几次定义分别创建自己的列表。但若默认表达式返回一个公共对象,就算重新定义函数也可能拿到同一个对象。共享与否最终取决于表达式算出了什么,不能只看函数名字是否相同。

三、用 None 修复,但先说清接口约定
最直接的改法,是把默认值换成 None,在函数体里用 is None 判断,再创建空列表。这样容器创建发生在实际调用时,且只在该分支执行。下面的 “追加” 把省略参数和显式传入 None 都解释成“创建新列表”,而显式传入列表则解释成“向调用方提供的列表原地追加”。这是接口选择,不是 Python 自动替我们决定的业务规则。
不要顺手改成 items = items or []。空列表是假值,这种写法会把调用方明确传入的空列表换成另一个列表;非空列表却又保留原对象,导致同一接口的行为随容器是否为空而改变。使用 is None 可以保留显式空列表的身份,也不会偷偷复制它。若接口只接受列表或 None,应像示例一样显式抛出类型异常,而不是用测试断言承担输入校验。
四、完整脚本:六组断言一次跑完
将下面完整代码保存为 example.py,用 python3 example.py 执行,无第三方依赖。断言用于实验,请勿添加 -O。实际验证解释器为 Python 3.14.3,这不是该修复方案的最低版本声明。六组实验覆盖共享复现、None分支、显式容器、假值反例、独立哨兵、浅拷贝和类型拒绝。
# Python 3;保存为 example.py 后运行 python3 example.py,不加 -O。
def 错误追加(值, 列表=[]):
列表.append(值)
return 列表
def 追加(值, 列表=None):
if 列表 is None:
列表 = []
if not isinstance(列表, list):
raise TypeError("参数必须是列表或 None")
列表.append(值)
return 列表
# 1:默认列表共享;先前返回值也被后续调用改变。
甲 = 错误追加("甲")
乙 = 错误追加("乙")
assert 甲 is 乙 and 甲 == ["甲", "乙"]
print("第1组:默认列表共享")
# 2:省略或 None 各自新建;显式空列表则保留身份。
甲, 乙 = 追加("甲"), 追加("乙", None)
assert 甲 == ["甲"] and 乙 == ["乙"] and 甲 is not 乙
容器 = []
assert 追加("甲", 容器) is 容器 and 容器 == ["甲"]
assert 追加("乙", 容器) is 容器 and 容器 == ["甲", "乙"]
print("第2组:None 分支及显式列表")
# 3:or 会替换显式空列表,不适合判断是否省略。
空列表 = []
assert (空列表 or []) is not 空列表
print("第3组:空列表假值反例")
# 4:None 有业务含义时,改用独立哨兵。
_缺省 = object()
def 选择(列表=_缺省):
if 列表 is _缺省:
return []
if 列表 is None: # 本接口约定 None 表示禁用。
return None
if not isinstance(列表, list):
raise TypeError("参数必须是列表或 None")
return 列表
甲, 乙 = 选择(), 选择()
assert 甲 == 乙 == [] and 甲 is not 乙
assert 选择(None) is None and 选择(空列表) is 空列表
print("第4组:省略、None、空列表三分支")
# 5:浅拷贝不拆散嵌套引用。
原件 = [{"次数": 0}]
副本 = 原件.copy()
副本[0]["次数"] += 1
assert 副本 is not 原件 and 副本[0] is 原件[0]
assert 原件[0]["次数"] == 1
print("第5组:浅拷贝只隔离外层")
# 6:业务输入校验使用显式异常;assert 只用于实验。
try:
追加("甲", ())
except TypeError:
print("第6组:非法类型已拒绝")
else:
raise RuntimeError("非法类型未拒绝")
print("全部6组通过;仅验证单进程顺序调用语义。")
正常执行会打印六组结果,最后显示“全部6组通过;仅验证单进程顺序调用语义。”断言失败时先核对完整代码,并在新进程重跑;交互环境中只重复执行追加调用,会继续改变旧默认对象。
五、当 None 本身有业务含义时
如果 None 表示“禁用收集”或“没有配置”,就不能再把它和省略参数合并处理。此时创建一个模块内私有的 object() 对象作为默认哨兵,用身份判断区分它。示例的 “选择” 中,省略参数得到新列表,传 None 则原样返回 None,传空列表仍返回那个空列表。关键不是哨兵的名字,而是它作为一个独立对象,能和这些合法输入区分开。
六、新外层列表,不等于整棵数据都独立
把容器创建移入函数,能解决特定默认外层容器被复用的问题,但不能推导出“所有对象都已独立”。例如每次新建列表时都往里面放一个全局公共字典,外层列表虽然不同,里面仍指向同一个字典。若只把赋值改成引用全局公共列表,甚至连外层也仍然共享。这些都是对象引用关系的问题,不会因为代码位置移动而消失。
显式把同一个列表传给“追加”两次,会按约定累积数据,这不是修复失效。若要求不改变调用方外层列表,可先调用 copy() 再追加;但嵌套字典仍可能共享,修改其字段会影响原列表引用的对象。更深的复制应按数据结构和共享约定决定,一次浅拷贝不能承诺完整隔离。
| 输入或写法 | 实际行为 | 该检查什么 |
|---|---|---|
| 默认列表或字典被原地修改 | 同函数的后续省略参数调用继续使用 | 默认对象身份及残留内容 |
| None 分支创建空列表 | 分支每次执行均新建外层列表 | 结果内容与身份均符合约定 |
| 用 or 替换空容器 | 显式空列表也被替换 | 调用方容器是否收到修改 |
| 显式反复传入同一列表 | 可以持续原地累积 | 是否符合接口所有权约定 |
| 浅拷贝或引用公共子对象 | 嵌套对象仍可能共享 | 外层与内层分别比较身份 |
七、失败边界:别把所有共享都判成错误
可变默认参数并非在所有场景都错误。如果设计就是有意保留缓存或累计状态,共享可能是需求本身。但必须明确状态归谁所有、何时清空、生命周期多长、调用方能否修改返回对象,以及并发访问采用什么策略。隐藏在默认值里的状态不容易被调用者发现,通常把状态放到明确的实例或缓存对象里,会更方便说明和维护。
本地断言只能证明列出的单进程顺序调用行为,不能据此声称线程安全、跨进程一致或真实 Web 请求已隔离。也不要把所有带空列表返回值的函数都当成问题:关键是对象何时创建、哪些引用指向它、后续是否原地修改。修复之后仍需检查函数内部是否把新建容器换成公共对象,以及返回值是否继续被其他调用者修改。
回归测试不要只保存两次调用的打印文本,还要保留两次返回对象,同时检查身份与内容。这样既能发现旧结果被后来调用改变,也能区分有意共用调用方容器和意外复用默认容器。
八、验收清单与官方依据
- 连续省略容器调用两次,确认没有混入旧内容,并且返回的外层对象不同。
- 分别覆盖显式 None、空列表和非空列表,确认调用方对象身份与原地修改规则。
- 业务需要区分省略与 None 时,检查独立哨兵的三个分支。
- 加入嵌套字典、公共对象和显式重复传入同一列表的测试,避免只验外层。
- 测试错误类型会抛出明确异常,并从完整最终代码重新执行全部断言。
本稿的扩展测试还检查了默认表达式计数和抛错时机,以及空列表被 or 表达式误替换的反例;它们是本地语义测试,不是生产运行证据。实际修改业务接口前,先和调用方确认“传入容器会不会被改动”,再选择原地追加、浅拷贝或独立状态方案。修复目标是把共享关系变成明确约定,而不只是把签名里的方括号换成 None。
- [1] Python 3.14 官方教程:Default Argument Values,说明可变默认值复用,并给出 None 分支修复。
- [2] Python 3.14 语言参考:Function definitions,说明函数定义是可执行语句,以及默认表达式在定义执行时求值。
🔕 评论已关闭