
那天早会上,产品经理拍着我的肩膀说:"用户反馈页面越来越慢了,你们后端能不能加个缓存?"
我拍着胸脯说:"小意思,现成的。" 然后花五分钟写了一个简易缓存函数:
def get_data_with_cache(key, cache={}):
if key in cache:
print(f"命中缓存,key={key}")
return cache[key]
print(f"缓存未命中,去查数据库了... key={key}")
# 模拟查数据库,耗时 2 秒
import time
time.sleep(2)
result = f"data_for_{key}"
cache[key] = result
return result逻辑完美:有缓存走缓存,没缓存查数据库并写入缓存。
上线之后,数据量小的时候跑得飞快。我心里美滋滋,觉得自己又要拿季度最佳员工了。
结果第三周,数据量上来了,服务器开始频繁 OOM(内存溢出),动不动就重启。我百思不得其解——缓存我设了上限吗?没有。但一个缓存函数,能把内存吃爆?
我把代码里里外外查了个遍,最后把目光锁定在这个 cache={} 上。一个空字典,按说啥也没有啊。
但实际运行时,我打印了一下 len(cache),吓出一身冷汗——这个字典里塞了上百万条数据,而且还在持续增长,永远没有清理的迹象。
更诡异的是,我明明每次调用都传了 key 参数,为什么 cache 这个字典像是"记住了"我上次调用塞进去的所有数据?它不应该每次都是一个新的空字典吗?
我盯着这行代码看了十分钟,突然明白了。
这个 cache 字典,它根本就不是"每次新建的空字典",它是在函数定义的那一刻就生成了一个字典对象,然后每次调用用的都是同一个对象。 我每一次调用,都是在往同一个字典里塞数据。塞进去的东西再也出不来了,内存自然就炸了。
这就好比你家门口放了个快递柜,你觉得每天都是新的,但其实快递员每天都在同一个柜子里塞包裹,从来没清空过。时间久了,柜子当然塞不下。
咱们把这个坑简化成最小 demo,你一看就懂:
def add_item(item, target=[]):
target.append(item)
return target来,按照你正常的编程直觉,猜一下下面这段代码的输出:
print(add_item(1))
print(add_item(2))
print(add_item(3))"第一行返回 [1],第二行返回 [2],第三行返回 [3]呗,每次都是从空列表开始。"
你要是这么想,那就跟我当初一样天真了。
真实运行结果:
[1]
[1, 2]
[1, 2, 3]看见了没?每次调用返回的列表都在"长大",之前添加的元素全都还在。函数像是有了记忆,把所有历史调用记录都攒在了一个地方。
这就是 Python 里臭名昭著的 "可变默认参数" 陷阱,属于 Python 新手进阶路上一定会被绊倒一次的那种坑。你越觉得它应该"每次重置",它就越要"一直叠加"。
我们来还原一下 Python 解释器看到这段代码时,脑子里到底在想什么。
当你写下 def add_item(item, target=[]) 的时候,Python 在内存里干了三件事:
第一步: 创建一个列表对象 [],放进内存某个地址,比如叫它 0x12345678。
第二步: 创建一个函数对象,把参数 item 和 target 的信息存进去,顺便在函数对象的某个隐藏属性里记一笔:"默认参数 target 指向 0x12345678 这个列表。"
第三步: 把这个函数对象赋值给变量名 add_item。
关键点来了:这个列表对象 [] 在函数定义的时候就已经被创建了,而且只创建了这么一次。
然后每次你调用 add_item(1),Python 发现你没给 target 传值,就自动去那个隐藏属性里查默认参数,拿到 0x12345678,然后把你传进来的 1 append 到这个列表里。
因为每次拿到的都是同一个地址,所以你每调用一次,就往这个列表里追加一个元素。它当然会长大。
打个比方你就懂了:
你开了一家奶茶店,做了个"标准杯"的模具(默认参数)。你每次做奶茶的时候如果没特殊要求,就拿这个模具做。但问题是,你每次做完奶茶,都没有把模具里的残渣清掉。第一次做的是珍珠奶茶,模具里留下了珍珠;第二次做波波奶茶,你又往里倒了波波。到第三次,你的"标准杯"已经成了一杯混杂了珍珠、波波、椰果的大杂烩。模具(默认参数)从一开始就是同一个,从来没人重置过。
你可能会有个疑问:"不对啊,我经常用默认参数啊,def foo(n, count=0) 这种,从来没出过问题。"
你说得对,因为 0 是不可变对象。
不可变对象和可变对象的区别,是理解这个坑的关键。我用两张图给你说清楚。
不可变对象(比如整数 0):
def foo(n, count=0):
count += n
return count每次调用 foo(5),发生了什么?
count 一开始指向 0 这个对象count += n 其实是 count = count + n,这会生成一个新的整数对象(比如 5)0 对象纹丝不动0 还是那个 0,永远不会变所以每次调用都像是"从头开始",因为默认参数本身没有被修改,只是你给 count 这个变量重新赋值了。
可变对象(比如列表 []):
def bar(item, target=[]):
target.append(item)
return target每次调用 bar(1),发生了什么?
target 指向 [] 这个列表对象target.append(item) 是在修改这个列表对象本身,而不是创建新对象区别一句话总结:
不可变参数你改不了它,只能"换一个新的"。可变参数你改了它,它就真的变了,而且默认参数里存的那个也跟着变了。
你以为只有列表会这样?天真。只要是可变对象,统统有这个问题。
场景一:字典做缓存
就是我开头翻车的那个案例。
def fetch_user(user_id, cache={}):
if user_id not in cache:
cache[user_id] = query_db(user_id)
return cache[user_id]每次调用都在往同一个字典里塞数据,内存越涨越高。
场景二:集合做去重收集器
def collect_unique(value, collector=set()):
collector.add(value)
return collector第一次调用 collect_unique("a") 返回 {"a"},第二次 collect_unique("b") 返回 {"a", "b"}。你本来以为每次都是新的集合,结果所有数据混在了一起。
场景三:自定义对象做默认参数
class Timer:
def __init__(self):
self.start = time.time()
def track_time(timer=Timer()):
return time.time() - timer.start第一次调用计时器从函数定义时开始算,这个可能还能接受。但如果你在函数里修改了这个 timer 对象的状态,那后续每次调用都会带着之前的修改,乱成一锅粥。
场景四:嵌套函数里的变量捕获
严格来说这不算默认参数,但现象很像:
def create_multipliers():
multipliers = []
for i in range(3):
def multiplier(x):
return x * i
multipliers.append(multiplier)
return multipliers
for m in create_multipliers():
print(m(2))猜猜输出什么?0, 2, 4?不对。输出是 4, 4, 4。因为三个函数捕获的是同一个变量 i,循环结束后 i=2,三个函数用的都是这个最终值。
虽然机制不一样,但"你以为每次都是新的,其实是同一个"这个核心感觉是一模一样的。
很多人第一次遇到这个问题都会骂:"Python 这是什么破设计?"
别急着骂,Python 之所以这么设计,其实是有合理原因的。
原因一:性能。 如果每次调用函数都要重新创建默认参数,那每次调用都得分配内存、初始化。对于高频调用的函数,这个开销不可忽视。Python 选择在定义时创建一次,后续复用,这是"以空间换时间"的经典思路。
原因二:一致性。 Python 的函数定义是可执行语句。当你执行 def 时,默认参数表达式会立即求值。这个规则简单明确,没有例外。如果单独给可变类型搞特殊处理,反而增加了语言的复杂度。
原因三:灵活性。 有些场景确实需要"记住上一次的状态"。比如缓存、累加器、计数器。Python 的这个特性给了你选择:想每次重置就用不可变默认参数,想记住状态就用可变默认参数(前提是你清楚自己在干什么)。
所以说到底,这不是 bug,这是一个有副作用的特性。用好了是工具,用砸了是灾难。
解决方案简单得让人想哭:
永远不要用可变对象做默认参数。
如果你需要一个默认的空列表、空字典或空集合,统一用 None 占位,然后在函数体里初始化。
标准范式长这样:
# 错误写法(千万别学)
def bad_func(data, cache={}):
pass
# 正确写法(刻进DNA)
def good_func(data, cache=None):
if cache is None:
cache = {}
# 然后用 cache 干活
pass对于列表也是一样:
def add_item(item, target=None):
if target is None:
target = []
target.append(item)
return target现在再跑之前那个例子:
print(add_item(1)) # [1]
print(add_item(2)) # [2]
print(add_item(3)) # [3]每次调用 target is None 为真,都会新建一个空列表,三个调用互不干扰。完美解决。
如果你想再"专业"一点,可以这样写,更简洁:
def add_item(item, target=None):
target = target or []
target.append(item)
return target但注意:target or [] 有个小陷阱,如果你传进来的 target 是个空列表 [],它也会被替换成新的空列表。大多数情况下这没问题,但如果你依赖"传入空列表也要用传入的那个",那还是用 if target is None 更保险。
理解了可变默认参数的本质之后,你会发现它不只是一个需要规避的陷阱,某些场景下它反而能帮你写出更简洁的代码。
利用"记住状态"实现函数级别的缓存(谨慎使用):
def fibonacci(n, memo={0: 0, 1: 1}):
if n not in memo:
memo[n] = fibonacci(n-1) + fibonacci(n-2)
return memo[n]每次调用 fibonacci,memo 字典都在积累之前的计算结果,递归效率奇高。但前提是你知道这个字典会一直存在,而且你要评估内存占用。
利用"记住状态"做计数器(适合调试场景):
def call_counter(func, counter={}):
counter[func.__name__] = counter.get(func.__name__, 0) + 1
print(f"{func.__name__} 被调用了 {counter[func.__name__]} 次")
return func
@call_counter
def do_work():
pass
do_work() # do_work 被调用了 1 次
do_work() # do_work 被调用了 2 次当然,正经项目里用类或者闭包更清晰,但偶尔写个调试脚本,这种"小聪明"能省不少事。
可变默认参数是 Python 里翻车率最高的几个特性之一,几乎每一个 Python 开发者都在这上面栽过跟头。区别在于,有人栽一次就记住了,有人栽了三次还在用 def foo(lst=[])。
我的建议是:
第一,写代码时养成肌肉记忆。 手指一碰到 def xxx(..., param={}) 这个模式,大脑立刻报警。你会条件反射地改成 param=None,然后补上 if param is None。
第二,代码审查时重点关注。 看到同事的 PR 里有可变默认参数,直接评论:"这里建议改成 None 初始化,防止状态污染。" 你帮他避坑,他请你喝奶茶,双赢。
第三,如果你确实想用可变默认参数来"记住状态",一定要写注释。 明确告诉读代码的人(包括未来的你自己):"这个参数是有意为之,用来跨调用共享状态,不是 bug。" 没有注释的话,三个月后的你看到这段代码,大概率会骂一句"哪个傻子写的",然后发现就是你自己。
最后送你一句口诀:
列表字典和集合,默认参数别用我。真想共享加注释,否则 None 来背锅。
下次写默认参数的时候,先问问自己:这个值,我到底是想每次新建,还是想共享状态?想清楚了再下笔,坑就追不上你。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。