首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Python 的可变默认参数把我坑惨了,原来每次调用函数都在"叠 buff"

Python 的可变默认参数把我坑惨了,原来每次调用函数都在"叠 buff"

原创
作者头像
风一样的男子
发布2026-08-05 15:09:21
发布2026-08-05 15:09:21
1320
举报
文章被收录于专栏:编程教程编程教程

1. 一个本该跑得飞快的缓存,把服务器干崩了

那天早会上,产品经理拍着我的肩膀说:"用户反馈页面越来越慢了,你们后端能不能加个缓存?"

我拍着胸脯说:"小意思,现成的。" 然后花五分钟写了一个简易缓存函数:

代码语言:javascript
复制
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 字典,它根本就不是"每次新建的空字典",它是在函数定义的那一刻就生成了一个字典对象,然后每次调用用的都是同一个对象。 我每一次调用,都是在往同一个字典里塞数据。塞进去的东西再也出不来了,内存自然就炸了。

这就好比你家门口放了个快递柜,你觉得每天都是新的,但其实快递员每天都在同一个柜子里塞包裹,从来没清空过。时间久了,柜子当然塞不下。


2. 一个"调皮"的函数,记住了不该记住的东西

咱们把这个坑简化成最小 demo,你一看就懂:

代码语言:javascript
复制
def add_item(item, target=[]):
    target.append(item)
    return target

来,按照你正常的编程直觉,猜一下下面这段代码的输出:

代码语言:javascript
复制
print(add_item(1))
print(add_item(2))
print(add_item(3))

"第一行返回 [1],第二行返回 [2],第三行返回 [3]呗,每次都是从空列表开始。"

你要是这么想,那就跟我当初一样天真了。

真实运行结果:

代码语言:javascript
复制
[1]
[1, 2]
[1, 2, 3]

看见了没?每次调用返回的列表都在"长大",之前添加的元素全都还在。函数像是有了记忆,把所有历史调用记录都攒在了一个地方。

这就是 Python 里臭名昭著的 "可变默认参数" 陷阱,属于 Python 新手进阶路上一定会被绊倒一次的那种坑。你越觉得它应该"每次重置",它就越要"一直叠加"。


3. 到底发生了什么?拆开 Python 的内存给你看

我们来还原一下 Python 解释器看到这段代码时,脑子里到底在想什么。

当你写下 def add_item(item, target=[]) 的时候,Python 在内存里干了三件事:

第一步: 创建一个列表对象 [],放进内存某个地址,比如叫它 0x12345678

第二步: 创建一个函数对象,把参数 itemtarget 的信息存进去,顺便在函数对象的某个隐藏属性里记一笔:"默认参数 target 指向 0x12345678 这个列表。"

第三步: 把这个函数对象赋值给变量名 add_item

关键点来了:这个列表对象 [] 在函数定义的时候就已经被创建了,而且只创建了这么一次。

然后每次你调用 add_item(1),Python 发现你没给 target 传值,就自动去那个隐藏属性里查默认参数,拿到 0x12345678,然后把你传进来的 1 append 到这个列表里。

因为每次拿到的都是同一个地址,所以你每调用一次,就往这个列表里追加一个元素。它当然会长大。

打个比方你就懂了:

你开了一家奶茶店,做了个"标准杯"的模具(默认参数)。你每次做奶茶的时候如果没特殊要求,就拿这个模具做。但问题是,你每次做完奶茶,都没有把模具里的残渣清掉。第一次做的是珍珠奶茶,模具里留下了珍珠;第二次做波波奶茶,你又往里倒了波波。到第三次,你的"标准杯"已经成了一杯混杂了珍珠、波波、椰果的大杂烩。模具(默认参数)从一开始就是同一个,从来没人重置过。


4. 不可变参数为什么没事?"换新" vs "改旧"

你可能会有个疑问:"不对啊,我经常用默认参数啊,def foo(n, count=0) 这种,从来没出过问题。"

你说得对,因为 0不可变对象

不可变对象和可变对象的区别,是理解这个坑的关键。我用两张图给你说清楚。

不可变对象(比如整数 0):

代码语言:javascript
复制
def foo(n, count=0):
    count += n
    return count

每次调用 foo(5),发生了什么?

  • count 一开始指向 0 这个对象
  • count += n 其实是 count = count + n,这会生成一个新的整数对象(比如 5
  • 原来的 0 对象纹丝不动
  • 默认参数里存的 0 还是那个 0,永远不会变

所以每次调用都像是"从头开始",因为默认参数本身没有被修改,只是你给 count 这个变量重新赋值了。

可变对象(比如列表 []):

代码语言:javascript
复制
def bar(item, target=[]):
    target.append(item)
    return target

每次调用 bar(1),发生了什么?

  • target 指向 [] 这个列表对象
  • target.append(item) 是在修改这个列表对象本身,而不是创建新对象
  • 默认参数里存的还是同一个列表地址
  • 但这个列表内部的内容已经变了

区别一句话总结:

不可变参数你改不了它,只能"换一个新的"。可变参数你改了它,它就真的变了,而且默认参数里存的那个也跟着变了。


5. 你踩过的那些"叠 buff"场景,远不止列表

你以为只有列表会这样?天真。只要是可变对象,统统有这个问题。

场景一:字典做缓存

就是我开头翻车的那个案例。

代码语言:javascript
复制
def fetch_user(user_id, cache={}):
    if user_id not in cache:
        cache[user_id] = query_db(user_id)
    return cache[user_id]

每次调用都在往同一个字典里塞数据,内存越涨越高。

场景二:集合做去重收集器

代码语言:javascript
复制
def collect_unique(value, collector=set()):
    collector.add(value)
    return collector

第一次调用 collect_unique("a") 返回 {"a"},第二次 collect_unique("b") 返回 {"a", "b"}。你本来以为每次都是新的集合,结果所有数据混在了一起。

场景三:自定义对象做默认参数

代码语言:javascript
复制
class Timer:
    def __init__(self):
        self.start = time.time()

def track_time(timer=Timer()):
    return time.time() - timer.start

第一次调用计时器从函数定义时开始算,这个可能还能接受。但如果你在函数里修改了这个 timer 对象的状态,那后续每次调用都会带着之前的修改,乱成一锅粥。

场景四:嵌套函数里的变量捕获

严格来说这不算默认参数,但现象很像:

代码语言:javascript
复制
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,三个函数用的都是这个最终值。

虽然机制不一样,但"你以为每次都是新的,其实是同一个"这个核心感觉是一模一样的。


6. 你以为的"bug",其实是 Python 故意的

很多人第一次遇到这个问题都会骂:"Python 这是什么破设计?"

别急着骂,Python 之所以这么设计,其实是有合理原因的。

原因一:性能。 如果每次调用函数都要重新创建默认参数,那每次调用都得分配内存、初始化。对于高频调用的函数,这个开销不可忽视。Python 选择在定义时创建一次,后续复用,这是"以空间换时间"的经典思路。

原因二:一致性。 Python 的函数定义是可执行语句。当你执行 def 时,默认参数表达式会立即求值。这个规则简单明确,没有例外。如果单独给可变类型搞特殊处理,反而增加了语言的复杂度。

原因三:灵活性。 有些场景确实需要"记住上一次的状态"。比如缓存、累加器、计数器。Python 的这个特性给了你选择:想每次重置就用不可变默认参数,想记住状态就用可变默认参数(前提是你清楚自己在干什么)。

所以说到底,这不是 bug,这是一个有副作用的特性。用好了是工具,用砸了是灾难。


7. 怎么根治?一招搞定,永绝后患

解决方案简单得让人想哭:

永远不要用可变对象做默认参数。

如果你需要一个默认的空列表、空字典或空集合,统一用 None 占位,然后在函数体里初始化。

标准范式长这样:

代码语言:javascript
复制
# 错误写法(千万别学)
def bad_func(data, cache={}):
    pass

# 正确写法(刻进DNA)
def good_func(data, cache=None):
    if cache is None:
        cache = {}
    # 然后用 cache 干活
    pass

对于列表也是一样:

代码语言:javascript
复制
def add_item(item, target=None):
    if target is None:
        target = []
    target.append(item)
    return target

现在再跑之前那个例子:

代码语言:javascript
复制
print(add_item(1))  # [1]
print(add_item(2))  # [2]
print(add_item(3))  # [3]

每次调用 target is None 为真,都会新建一个空列表,三个调用互不干扰。完美解决。

如果你想再"专业"一点,可以这样写,更简洁:

代码语言:javascript
复制
def add_item(item, target=None):
    target = target or []
    target.append(item)
    return target

但注意:target or [] 有个小陷阱,如果你传进来的 target 是个空列表 [],它也会被替换成新的空列表。大多数情况下这没问题,但如果你依赖"传入空列表也要用传入的那个",那还是用 if target is None 更保险。


8. 这一招不止能避坑,还能给你新的思路

理解了可变默认参数的本质之后,你会发现它不只是一个需要规避的陷阱,某些场景下它反而能帮你写出更简洁的代码。

利用"记住状态"实现函数级别的缓存(谨慎使用):

代码语言:javascript
复制
def fibonacci(n, memo={0: 0, 1: 1}):
    if n not in memo:
        memo[n] = fibonacci(n-1) + fibonacci(n-2)
    return memo[n]

每次调用 fibonaccimemo 字典都在积累之前的计算结果,递归效率奇高。但前提是你知道这个字典会一直存在,而且你要评估内存占用。

利用"记住状态"做计数器(适合调试场景):

代码语言:javascript
复制
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 次

当然,正经项目里用类或者闭包更清晰,但偶尔写个调试脚本,这种"小聪明"能省不少事。


9. 最后的忠告:把这个"坑"刻进脑子里

可变默认参数是 Python 里翻车率最高的几个特性之一,几乎每一个 Python 开发者都在这上面栽过跟头。区别在于,有人栽一次就记住了,有人栽了三次还在用 def foo(lst=[])

我的建议是:

第一,写代码时养成肌肉记忆。 手指一碰到 def xxx(..., param={}) 这个模式,大脑立刻报警。你会条件反射地改成 param=None,然后补上 if param is None

第二,代码审查时重点关注。 看到同事的 PR 里有可变默认参数,直接评论:"这里建议改成 None 初始化,防止状态污染。" 你帮他避坑,他请你喝奶茶,双赢。

第三,如果你确实想用可变默认参数来"记住状态",一定要写注释。 明确告诉读代码的人(包括未来的你自己):"这个参数是有意为之,用来跨调用共享状态,不是 bug。" 没有注释的话,三个月后的你看到这段代码,大概率会骂一句"哪个傻子写的",然后发现就是你自己。

最后送你一句口诀:

列表字典和集合,默认参数别用我。真想共享加注释,否则 None 来背锅。

下次写默认参数的时候,先问问自己:这个值,我到底是想每次新建,还是想共享状态?想清楚了再下笔,坑就追不上你。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 1. 一个本该跑得飞快的缓存,把服务器干崩了
  • 2. 一个"调皮"的函数,记住了不该记住的东西
  • 3. 到底发生了什么?拆开 Python 的内存给你看
  • 4. 不可变参数为什么没事?"换新" vs "改旧"
  • 5. 你踩过的那些"叠 buff"场景,远不止列表
  • 6. 你以为的"bug",其实是 Python 故意的
  • 7. 怎么根治?一招搞定,永绝后患
  • 8. 这一招不止能避坑,还能给你新的思路
  • 9. 最后的忠告:把这个"坑"刻进脑子里
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档