有个接口上线后出现怪事:前一个请求带的分页参数,出现在了下一个请求的返回里。单独测每个请求都正常,并发一上来就串数据。另一处是批量注册的回调,全部用了循环里最后一个值。
这两个 bug 表面无关,根因是同一件事:Python 在什么时候对表达式求值,以及捕获的是值还是变量。两者的共同点是——代码看起来完全正常,review 时也很难靠肉眼发现。
现象:两个最小复现#
先看默认参数,再看闭包:
# 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 里,两处都能直接打印:
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 哨兵,在函数体内创建:
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 比原来更隐蔽。
闭包用「定义时绑定」:
# 方式一:默认参数把当前值绑进去
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__,比读十遍代码快。
完