Python 可变默认参数陷阱,一个 dict 被整个函数共用
def record(item, cache={}):
cache[item] = True
return cache
print(record("apple"))
print(record("banana"))
猜猜输出是什么。如果猜的是 {'apple': True} 和 {'banana': True},那你已经站在这个坑的边上。
实际输出是这两行
{'apple': True}
{'apple': True, 'banana': True}
第二次调用时,cache 里还躺着第一次塞进去的 apple。这个函数没有重新定义,cache 也不是每次调用新建的空字典。它是同一个对象,被函数一直攥在手里。
写 Python 的人迟早会遇到这个现象。它不挑版本,Python 2 到 Python 3 都这样,也不是哪个库的 bug,纯粹是语言本身的默认行为。搞懂它只需要半小时,但能省掉不少排查线上事故的时间。下面把机制、修法和相关坑一次讲完。
最容易踩到它的几个地方
这个坑最常见的出场方式有三样,往列表里追加,往字典里计数,拿容器当缓存。
往列表里追加。你写了一个函数,想把每次调用传入的元素收进一个列表
def add_tag(tag, tags=[]):
tags.append(tag)
return tags
第一次调用 add_tag("python") 返回 ['python'],第二次调用 add_tag("django") 返回 ['python', 'django']。你以为自己在处理一个全新的列表,实际上一直在同一个列表上追加。数据越攒越多,你甚至说不清它是从哪一次调用开始变大的。
往字典里计数。你想统计一批词出现的次数
def count_word(word, counter={}):
counter[word] = counter.get(word, 0) + 1
return counter
连续调用几次以后,字典里攒下所有调用传过的词。单看一次调用,逻辑没有错。放到整体里,数据全串在一起。这种错误在单元测试里很难暴露,因为测试往往只调用一次。
拿容器当缓存。有人图省事,直接用默认参数做 memo
def fib(n, memo={}):
if n in memo:
return memo[n]
...
这三样玩法的共同点,都是把可变容器放在了默认参数的位置上。至于为什么放在那里会出问题,下面讲。
问题出在 def 是一句语句
Python 里的 def 是一句普通语句,它做的事是创建一个函数对象。代码执行到 def 那一行,函数对象生成,默认参数的值也在这同一刻求值一次。求值出来的 dict 被存进函数对象,以后每次调用这个函数,都直接拿这个 dict 用,不再重新求值。
对比一下函数体里的赋值。cache = {} 写在函数体里,每次调用都会执行,每次都新建一个字典。默认参数不是函数体,它只求值这一次,然后被函数长期持有。
可以自己验证。在函数里打印 id(cache),两次调用得到同一个数字。id 相同就是同一个对象。这个 dict 在函数定义时就已经造好,往后每次调用用的都是它。
函数对象上有个属性叫 __defaults__,默认参数就存在里面。它是个元组,装着你在 def 里写的那些默认值。位置参数和关键字参数的默认值分开放,关键字专属参数(keyword-only)的默认值存在 __kwdefaults__ 里。两个地方装的都是对象本身,不是对象的副本。
问题在于 dict 是可变对象,函数拿的是引用。你在函数体里往 cache 塞东西,塞的是这个共享对象本身。下一次调用,看到的就是改过的内容。
还可以用标准库确认。inspect.signature 打印出来的签名里,默认值就是定义时那个对象。你在交互环境里定义一个带 dict 默认参数的函数,再打印 func.__defaults__,会看到一个 dict 躺在里面。往里面放点东西,再打印,东西还在。函数对象活着,这个 dict 就一直被它抱着。
判断两个调用拿到的是不是同一个对象,除了比 id,还可以用 is。record("a") is record("b") 会返回 True,因为两次返回的都是同一个 dict。看到这种结果,基本可以断定踩坑了。
语言为什么这样设计
很多人第一次知道这个行为时,第一反应是问,Python 为什么不每次调用都重新求值默认参数。
因为别的语言就是这么做的。C++ 的默认参数在每次调用时求值,所以默认参数写 vector<int> v = {},每次调用都拿到一个全新的空 vector。Java 不允许默认参数,想实现类似效果得靠重载。Python 选了另一条路,定义时求值一次。
这样设计的好处是性能。默认参数只创建一次,调用时直接复用,省掉了每次创建新对象的开销。代价就是它保存的是对象本身,而对象可以被修改。可变对象一旦被改,下一次调用看到的就是改过的状态。
这个选择跟函数是一等对象的模型一致。def 执行一次就生成一个函数,默认值作为函数的一部分被固定下来。在 Python 诞生时,这算合理取舍。只是可变对象的存在,让这个设计露出了一个容易踩的边角。
哪些类型安全,哪些不安全
判断标准只有一条,看默认参数是可变的还是不可变的。
| 类型 | 是否安全 | 原因 |
|---|---|---|
| None | 安全 | 不可变,且没有状态可改 |
| int / float | 安全 | 不可变,任何操作都产生新对象 |
| str | 安全 | 不可变 |
| tuple | 安全 | 不可变 |
| frozenset | 安全 | 不可变 |
| list | 危险 | 可变,追加、删除都会改到共享对象 |
| dict | 危险 | 可变,增删键值都会改到共享对象 |
| set | 危险 | 可变,增删元素都会改到共享对象 |
None、整数、字符串、元组、frozenset 安全。它们是不可变对象,函数拿着引用也没用,因为没有任何操作能改到它内部。就算你把默认参数传进别的函数,最多产生一个新对象,原对象纹丝不动。
list、dict、set 不安全。它们是可变容器,放在默认参数的位置就是定时炸弹。就算你只是读它不写它,只要有一次调用写了,所有调用就都被污染。
默认参数是元组时,函数里把它转成可变对象再改,改的是新对象,不会污染元组本身,这一点没问题。判断只看默认参数这个容器本身是不是可变的。容器可变,就有共享风险。
标准修法,None 哨兵
最常用也最推荐的修法,是把默认参数改成 None,在函数体里判断一次
def record(item, cache=None):
if cache is None:
cache = {}
cache[item] = True
return cache
函数体里每次新建一个 dict,各次调用互不干扰。成本只是一个 None 判断,可以忽略。
有的代码会写成 cache = cache or {},效果类似,但有个边界问题。调用者显式传入一个空字典时,空字典的布尔值是 False,会被 or 替换成新字典。调用者传了东西,却被静默忽略,这种错误更难查。用 if cache is None 就没有这个毛病,它只认 None 这一个哨兵。建议一律用 is None 的判断。
这个写法还保留了一个能力。调用者可以显式传一个 dict 进来,想共享就共享,不传就每次新建。需要跨调用保留状态的场景,用这种方式写出来,意图也清楚。
配合类型标注,写出来是这样
def record(item: str, cache: dict | None = None) -> dict:
if cache is None:
cache = {}
cache[item] = True
return cache
标注里明确写了 None 可能是默认值,读代码的人一看就懂,不会误以为默认值是个 dict。
还有一种风格,干脆把默认参数设为不可变类型,函数开头再转
def record(item, cache=()):
cache = dict(cache)
cache[item] = True
return cache
元组是不可变的,定义时创建一次,没有任何调用能改到它,所以安全。函数内部再把它转成新字典。这种写法多一行转换,好处是默认值写出来就是不可变的,扫一眼就知道不会踩坑。缺点是不够直观,新手看到 cache=() 要反应一下。
那如果我本来就想共享呢
可变默认参数偶尔被当成免费缓存用,比如 fib 的 memo。技术上能用,代码也能跑。但它会让读代码的人停下来想,这个 dict 是谁的,什么时候清的,会不会被别的调用污染。省了一行初始化,赔进去的是可读性。
想让状态留在函数外面,更直白的做法是把 dict 放在模块层,或者定义一个类,用实例属性来存。意图摆在那里,别人扫一眼就懂,也不会在 code review 里被追问。
如果目标是缓存计算结果,标准库自带 functools.lru_cache,加一行装饰器就行
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
if n < 2:
return n
return fib(n-1) + fib(n-2)
缓存由装饰器管理,函数签名干干净净,别人读代码时不需要猜 memo 从哪里来。能用现成的就别自己造。
同一个机制,别的坑
这个坑不止默认参数一处,dataclass 里也常见
@dataclass
class Config:
tags: list = []
同样是定义时求值一次,所有实例共享同一个列表。给一个实例的 tags 追加元素,其他实例的 tags 也会变。正确写法是 field(default_factory=list),每个实例新建一个。
类属性放可变对象也一样。所有实例拿到同一个 dict,改一个全变。理解了”定义时求值一次,之后复用同一对象”,这些坑就都能认出来,它们是一件事。
闭包的表现类似,症状不同。闭包里修改外层变量,所有引用这个闭包的调用共享同一份状态。但闭包的机制是 cell 变量,跟默认参数不是一回事。认准一个判断口径,定义处只求值一次的东西,如果是可变对象,就要小心它被共享。
用工具提前拦住
这个坑老到几乎所有主流检查工具都能识别,不用靠人肉记。
flake8 的 B006 规则,pylint 的 W0102,ruff 的 B006,都会在默认参数是可变对象时报错。把这一类检查加进 CI,写错的代码根本合不进去。很多团队是在这个坑造成线上事故以后,才想起开这些规则的。
配置也不复杂。用 ruff 的话,在 pyproject.toml 里启用 B 规则,再指定忽略掉不关心的几条,其余交给工具。格式化工具和静态检查跑一遍,问题直接标红。成本极低,收益是长期的,值得在项目一开始就配上。
线上事故长什么样,怎么排查
这个坑造成的线上事故,症状非常有辨识度。接口第一次调用返回正常,多调用几次,返回的数据开始包含别人传过的内容,而且数据量只增不减。重启进程以后症状消失,跑一段时间又复发。因为默认参数是进程内的共享对象,进程一重启就重建了。
排查顺序可以这样走。先看报错的函数签名,默认参数是不是可变容器。再看类定义,类属性有没有直接放 list 或 dict。最后看模块层,有没有函数外的全局可变对象被函数修改。三个位置查完,多数串数据的问题都能定位。
这个坑的隐蔽性还在于,单元测试经常测不出来。测试往往只调用一次函数,断言返回结果,而串数据的现象要从第二次调用才开始显现。写测试时对同一个函数连续调用两次,对比两次结果,才能把这个坑逼出来。一次调用的测试通过,说明不了默认参数没有问题。
面试官问这个坑,他想听什么
这个坑是 Python 面试高频题。面试官通常想知道三件事。
第一,你知不知道默认参数在定义时求值一次。第二,你能不能说出可变和不可变的区别,以及哪些类型会踩坑。第三,你会怎么修。能把 None 哨兵和 is None 判断讲清楚,再补一句 dataclass 的 default_factory,这一题基本就稳了。
如果面试官追问为什么这样设计,能提到性能考虑和函数一等对象,就超出大多数人的回答水平了。
记住这一条
写函数的时候扫一眼默认参数。是可变类型,就改成 None 哨兵。就这一条,别的都不用记。
