故障排查 · 2026 年 11 月 11 日

Python 里最隐蔽的坑:可变默认参数与闭包晚绑定

同一个默认参数被所有调用共享,循环里的 lambda 全部输出最后一个值——两个看起来无关的 bug,根因都是「什么时候求值」。本文给出复现、定位命令和四种修复写法。

有个接口上线后出现怪事:前一个请求带的分页参数,出现在了下一个请求的返回里。单独测每个请求都正常,并发一上来就串数据。另一处是批量注册的回调,全部用了循环里最后一个值。

这两个 bug 表面无关,根因是同一件事:Python 在什么时候对表达式求值,以及捕获的是值还是变量。两者的共同点是——代码看起来完全正常,review 时也很难靠肉眼发现。

现象:两个最小复现#

先看默认参数,再看闭包:

python
# 1) 默认参数:默认值在 def 执行时创建,只创建一次
def collect(item, bucket=[]):
    bucket.append(item)
    return bucket

print(collect("a"))   # ['a']
print(collect("b"))   # ['a', 'b']  ← 期望是 ['b']

# 2) 闭包:捕获变量,不是值
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])   # [2, 2, 2],不是 [0, 1, 2]

bucket 没有在每次调用时新建,而是复用了函数对象上挂着的那个列表。进程活得越久,这个列表越大,还会持有元素引用,形成慢性内存增长。

三个 lambda 捕获的是同一个变量 i,循环结束后它已经是 2。函数定义得早,取值取得晚。

在 Web 框架里,默认参数问题会变成跨请求污染:视图函数签名写成 def index(filters={}),第一个请求写入的过滤条件会一直留在进程里,下一个请求直接带着它跑。单进程单请求测试很难复现,多 worker 也只是降低撞上的概率。

这两类 bug 还有个共同特征:单元测试如果每次只调用一次、或者只验证「不抛异常」,就永远测不出来。测试通过,线上照样串数据。

定位:把「隐藏的共享」打印出来#

默认参数挂在函数对象的 __defaults__ 上,闭包的自由变量则存在 cell 里,两处都能直接打印:

python
def collect(item, bucket=[]):
    bucket.append(item)
    return bucket

collect("a"); collect("b")
print(collect.__defaults__)          # (['a', 'b'],) 已经脏了
print(id(collect.__defaults__[0]))   # 多次调用 id 不变

funcs = [lambda: i for i in range(3)]
print(funcs[0].__closure__[0] is funcs[1].__closure__[0])   # True
print(funcs[0].__closure__[0].cell_contents)                # 2(循环结束后)

默认值对象被复用,所以 id 不变、内容在长;两个闭包指向同一个 cell,就证明它们共享变量而不是各自持有快照。

线索 说明 结论
f.__defaults__[0] 每次调用内容在变 默认值是同一个可变对象 命中默认参数问题
两次调用的默认值 id() 相同 对象被复用 命中默认参数问题
f.__closure__[0] is g.__closure__[0] 共享 cell 命中闭包晚绑定
单独调用正常、并发或循环后异常 状态跨调用残留 优先查上面两条

线上不方便打断点时,在可疑函数入口加一行 print(id(bucket), len(bucket)),连续两次调用 id 相同、长度递增,基本可以定性。要追内存增长,用 tracemalloc 对默认值对象前后各拍一次快照,看增长落在哪个分配点。

如果只是想先缩小范围,静态搜索比重读代码快:grep -rn "def .*= *\[\]"、= *{}、= *set() 三条命令,能把可疑函数列个七七八八。

根因:求值时机与捕获对象#

默认参数在 def 执行时求值一次。 函数是对象,默认值是它的属性,所有调用共享。写 def f(x, acc=[]) 相当于在模块加载时执行了一次 acc = [],然后每次调用都传同一个引用。注意这和「调用时传参」完全不同:传参是每次调用重新求值,默认值不是。

闭包捕获变量,不捕获值。 Python 的闭包按名字查找自由变量,最终落到一个 cell 上。循环里的 i 在函数作用域只有一个 cell,每轮只是给它赋新值,所以所有闭包看到的是最后一次赋值。

边界要说清。 默认值是不可变对象(int、str、tuple、None)时没有这个问题。可变对象只要从不修改也安全,但这是隐式约定,下一个人加一行 append 就会炸。用默认参数做 memo 缓存是合法模式,代价是要自己考虑线程安全和容量上限。

同类问题还有三个。 类属性 class A: cache = {} 被所有实例共享;dataclass 字段写 items: list = [] 会直接报错;except E as e 里的 e 在 Python 3 中出块即删除,闭包引用它会得到 NameError。

理解这层机制之后,判断标准很简单:这个对象在多次调用之间应不应该保持状态。不应该,就别让它出现在默认值里。

修复:哨兵、绑定、工厂#

默认参数用 None 哨兵,在函数体内创建:

python
from typing import Optional, List

def collect(item: str, bucket: Optional[List[str]] = None) -> List[str]:
    if bucket is None:          # 必须用 is None
        bucket = []
    bucket.append(item)
    return bucket

判断条件不能写 if not bucket。调用方传一个空列表进来时,not [] 为真,函数会新建列表,调用方拿到的仍是空列表,这个 bug 比原来更隐蔽。

闭包用「定义时绑定」:

python
# 方式一:默认参数把当前值绑进去
funcs = [lambda i=i: i for i in range(3)]

# 方式二:functools.partial,意图更清楚
from functools import partial

def tag(i: int) -> int:
    return i

funcs = [partial(tag, i) for i in range(3)]
print([f() for f in funcs])   # [0, 1, 2]
场景 错误写法 正确写法
累积容器 def f(x, acc=[]) acc=None + 函数内初始化
循环建回调 lambda: i lambda i=i: i 或 partial
类级缓存 class A: cache = {} 实例属性,或按 key 分开
dataclass 可变字段 items: list = [] field(default_factory=list)
手写缓存 def f(x, cache={}) functools.lru_cache
注意

修复默认参数时,顺手检查所有调用点。如果历史代码依赖「共享」这个副作用做缓存,改成 None 哨兵会悄悄改变行为,先补测试再改。

如果是做缓存,functools.lru_cache 比自己拿默认参数当字典更稳:它带淘汰策略、有 cache_info() 可观测,线程安全也是明确的。自己用 {} 当缓存,既没有上限也没有统计,出问题时连命中率都拿不到。

防复发#

  • CI 里打开静态检查:flake8-bugbear 的 B006、pylint 的 W0102、ruff 的 B006,都能直接抓出可变默认值。
  • 单测里连续调用两次,断言第二次结果不包含第一次的数据。这一条能拦住绝大多数跨请求污染。
  • 类型标注统一写 Optional[X] = None,review 时一眼能看出是哨兵。
  • 循环里创建函数、线程、协程并延后执行时,先问一句「它捕获的是哪个变量」。
  • 需要绑定时优先用 functools.partial,比手写 i=i 更难被误读。
  • 存量代码全仓扫一遍:grep -rn "def .*= *\[\]\|def .*= *{}",把可疑签名列出来逐个确认。

结论#

默认参数的问题出在「定义时求值一次,所有调用共享」;闭包的问题出在「捕获变量而非值」。两者都不是语言缺陷,是求值模型的必然结果。

落地四条:可变默认值一律换成 None 哨兵并用 is None 判断;循环里创建闭包要么用默认参数绑定,要么用 partial;把 B006 和 W0102 加进 CI;单测至少调用两次。

再补一句判断标准:默认值适合放不可变常量,需要可变状态就交给显式参数或工厂函数。把这条写进团队规范,比事后排查便宜得多。

下次再遇到「数据串了」或者「回调全用了最后一个值」,先打印 __defaults__ 和 __closure__,比读十遍代码快。

完

#Python#排障#语言特性
回到顶部 ↑