JavaLYG

Python函数第二次调用混入旧数据:用None哨兵修复可变默认参数

第一次调用函数,结果只有本次加入的内容;第二次没有传容器,却把上次的数据也带回来了。遇到这种现象,先别急着怀疑调用顺序或数据库缓存。如果函数签名里出现列表、字典默认值,先检查那个对象是不是一直被重复使用。这里用一个可独立运行的实验,把问题定位、修复方法和容易漏掉的边界放在一起。

一、先看问题:参数省略了,状态却留下了

设想一个收集条目的函数,把收到的值追加进列表后返回。把列表写成参数默认值,看起来像是“没有传列表时,用一个空列表”。真正执行两次才发现,第二次得到的是两次追加的合并结果。更隐蔽的是,第一次拿到的返回值也会跟着变化,因为两次返回的并不是独立快照,而是同一个对象。

这类错误常在单次调用测试中漏掉:第一次结果完全合理,问题要到后续调用才显现。字典默认值也一样,前一次写入的键值会留给后一次调用。排查时应同时比较内容和对象身份;两个列表内容相等,不能证明它们共享,内容不同也不能直接说明它们独立。实验用身份比较确认关系,不依赖某个具体内存地址。

二、根因:默认值在执行 def 时求值

Python 官方教程指出,默认值在函数定义处、定义所在的作用域中求值。语言参考进一步说明:默认参数表达式在执行函数定义时从左到右求值,随后调用使用已经算好的值。因此,准确说法不是“每次调用都会创建默认列表”,而是“每次执行这条 def 时计算默认值”。同一个函数的默认列表被修改后,后续未传该参数的调用会继续使用它。[1][2]

这里的“每次执行”很重要。如果函数定义位于工厂函数内部,每次调用工厂都会再次执行内部的 def;默认表达式写成空列表字面量时,这几次定义分别创建自己的列表。但若默认表达式返回一个公共对象,就算重新定义函数也可能拿到同一个对象。共享与否最终取决于表达式算出了什么,不能只看函数名字是否相同。

可变默认列表的共享路径与 None 分支中新建列表的对照示意图
图:左右为替代写法,def/if是省略冒号及函数体的机制标签,不可直接运行;完整代码见下文。同函数默认列表复用;None分支每次新建外层列表。显式容器不自动复制,不表示嵌套或并发隔离。

三、用 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. [1] Python 3.14 官方教程:Default Argument Values,说明可变默认值复用,并给出 None 分支修复。
  2. [2] Python 3.14 语言参考:Function definitions,说明函数定义是可执行语句,以及默认表达式在定义执行时求值。

🔕 评论已关闭