我写了个函数给参数设了个空列表默认值,第二次调用时却发现里面莫名其妙带着上一次的数据,我对着 Python 可变默认参数在定义时只创建一次被所有调用共享这个坑排查了大半天的复盘
这是一个堪称 Python "头号经典陷阱"的坑,几乎每个 Python 程序员都会在某个时刻栽进去一次。它的诡异之处在于:出问题的函数每次调用的代码都一模一样,可它的行为却随着调用次数的增加而改变——仿佛这个函数"有记忆",记住了上一次发生的事。
事情起于一个收集数据的函数。我写了一个函数,给一个篮子(列表)里添加物品,为了方便"不传篮子时就新建一个空篮子",我很自然地给参数设了个 [] 作为默认值:
# 给篮子添加物品, 不传篮子就用一个"空篮子"(有问题的版本)
def add_item(item, basket=[]): # ★ 默认值是一个空列表 []
basket.append(item)
return basket
# 第一次调用(不传 basket, 期望得到只含 'apple' 的新列表)
print(add_item('apple')) # ['apple'] ✓ 看起来正常
# 第二次调用(不传 basket, 期望又得到一个只含 'banana' 的新列表)
print(add_item('banana')) # 💥 ['apple', 'banana'] ← 怎么把上次的 apple 也带上了?!
# 第三次
print(add_item('cherry')) # 💥 ['apple', 'banana', 'cherry'] ← 越攒越多!
我盯着这个越攒越多的列表,大脑短路。我每次调用 add_item 都没有传 basket,按我的理解,每次都应该用一个全新的空列表 [] 啊,怎么第二次、第三次调用,会把前几次的数据都累积进来?这个函数就像有了"记忆",每次都记得上次往篮子里放了什么。在真实业务里,这意味着不同次调用之间的数据互相污染了——一个本该"每次都从空开始"的函数,变成了"不断累积历史"的函数,埋下了极隐蔽的数据错乱。
第一件事:看清真相——默认参数值在函数"定义时"只创建一次,被所有调用共享
我去深究了 Python 函数默认参数的求值机制,才终于解开这个"函数有记忆"之谜——Python 的默认参数值,是在函数定义(def 执行)的那一刻就计算并创建好一次的,之后所有不传该参数的调用,共享的都是同一个默认值对象;而如果这个默认值是可变的(列表/字典),对它的修改就会在多次调用间累积。
可变默认参数的真相
# 1. 关键认知: 默认参数值是【在函数定义时】求值的, 不是每次调用时!
# - 当 Python 执行到 def add_item(item, basket=[]): 这一行(定义函数)时,
# 它会【立刻计算 []】, 创建一个空列表对象, 并把它【绑定】为这个参数的默认值。
# - 这个创建【只发生一次】(就在 def 那一刻), 之后再也不会重新创建!
# 2. 之后每次"不传 basket"地调用 add_item:
# - 它用的 basket, 都是【定义时创建的那同一个列表对象】!
# - 所以所有这些调用, 共享的是【同一个篮子】, 而不是各自新建的。
# 3. 于是:
# add_item('apple') → 往那个共享的列表加 apple → ['apple']
# add_item('banana') → 往【同一个】列表加 banana → ['apple', 'banana']
# add_item('cherry') → 还是【那一个】列表 → ['apple', 'banana', 'cherry']
# → 数据在多次调用间【累积】, 因为它们改的是同一个对象!
# 4. 为什么"感觉"它该每次新建:
# - 我们直觉以为 basket=[] 是"每次调用时, 若没传就执行 []新建一个";
# - 但真相是 basket=[] 是"定义时执行一次 [], 把结果当默认值, 以后都用它"。
# - 这个"定义时求值一次" vs "调用时每次求值"的差异, 是坑的根源。
# 5. 不可变默认值(int/str/None/元组)为什么没事:
# - def f(x, n=0): 默认值0也是共享的, 但int不可变, 你只能 n=n+1 重新绑定,
# 改不了"0"这个对象本身, 所以感觉不到共享。
# - 坑只在【可变】默认值(list/dict/set/自定义可变对象)上暴露。
# 核心: Python默认参数值在【函数定义时】只创建一次, 被之后所有不传该参数的调用共享;
# 若默认值是可变对象(list/dict), 对它的修改会在多次调用间累积——这就是"函数有记忆"的真相。
真相大白,我恍然大悟。原来 Python 的默认参数值,是在函数定义(执行到 def 那一行)的那一刻就计算并创建好一次的——执行到 def add_item(item, basket=[]): 时,Python 会立刻计算 []、创建一个空列表对象,并把它绑定为这个参数的默认值;这个创建只发生一次,之后再也不重新创建。于是,之后每次"不传 basket"地调用,用的都是定义时创建的那同一个列表对象——所有这些调用共享同一个篮子!所以 add_item('apple')、add_item('banana')、add_item('cherry') 改的是同一个列表,数据自然在多次调用间累积。坑的根源,正是我的直觉("basket=[] 是每次没传就新建一个")和真相("basket=[] 是定义时执行一次 [],以后都用这一个")之间的差异——"定义时求值一次" vs "调用时每次求值"。而为什么不可变默认值(n=0)没事?因为 int 不可变,你只能 n=n+1 重新绑定、改不了 0 这个对象本身,所以感觉不到共享;坑只在可变默认值(list/dict/set)上才暴露。
第二件事:正解——用 None 作哨兵,在函数内部新建可变对象
搞懂了原理,正解就清晰了:默认值用 None(不可变)作"哨兵",在函数体内判断 None 时才新建可变对象——这样每次调用都得到一个真正全新的对象。
# ====== 正解(标准做法): 用 None 哨兵 ======
def add_item(item, basket=None): # ★ 默认值用 None(不可变), 不会被共享
if basket is None: # ★ 没传时, 在【函数内部】每次都新建一个
basket = []
basket.append(item)
return basket
print(add_item('apple')) # ['apple'] ✓
print(add_item('banana')) # ['banana'] ✓ 不再带上 apple, 每次都是新篮子!
print(add_item('cherry')) # ['cherry'] ✓
# 为什么这样对:
# - 默认值 None 是不可变的, 共享它没有任何副作用
# - 真正的"新建[]", 被挪到了【函数体内】, 而函数体是【每次调用都执行】的
# → 所以每次没传 basket 的调用, 都会执行 basket = [] 新建一个独立的列表
# ====== 同样适用于字典、集合等所有可变默认值 ======
def f(data=None):
if data is None:
data = {} # 每次新建独立的 dict
...
def g(items=None):
items = items if items is not None else set() # 一行写法
...
# ====== 为什么用 None 而不是直接判断 if not basket ======
# ✗ if not basket: basket = []
# → 当调用者【故意传了一个空列表 []】时, not [] 也是 True, 会被误新建!
# → 用 is None 才能精确区分"没传(None)"和"传了空列表([])"。
# ====== 偶尔的"例外": 故意用可变默认值做缓存(要清楚自己在干嘛) ======
def fib(n, _cache={}): # 故意用共享的dict当缓存(memoization)
if n in _cache: return _cache[n]
_cache[n] = n if n < 2 else fib(n-1) + fib(n-2)
return _cache[n]
# → 这是【有意利用】可变默认值的共享性来做缓存, 但要明确知道并写注释,
# 且这种"_cache"前缀+不暴露给调用者, 是约定俗成的写法。普通参数千万别这么用。
# 核心: 可变对象别直接做默认值, 用 None 作哨兵、在函数体内 if x is None: x=[] 新建;
# 用 is None 精确区分"没传"和"传了空对象"; 除非有意做缓存(并写明), 否则严守这条。
修复的核心,是"默认值用 None 哨兵,在函数体内新建可变对象"。正解(标准做法):用 None 哨兵——默认值用 None(不可变、共享它没副作用),在函数体内 if basket is None: basket = [] 新建;关键在于真正的"新建 []"被挪到了函数体内,而函数体是每次调用都执行的,所以每次没传的调用都得到一个独立的新列表。这适用于字典、集合等所有可变默认值。还有一个要点:为什么用 is None 而不是 if not basket——因为调用者可能故意传了一个空列表 [],not [] 也是 True 会被误新建;用 is None 才能精确区分"没传(None)"和"传了空列表([])"。偶尔的例外:有意用可变默认值做缓存(def fib(n, _cache={}) 的 memoization),但要明确知道、写注释、用 _ 前缀,普通参数千万别这么用。归根结底:可变对象别直接做默认值,用 None 作哨兵、在函数体内新建;用 is None 精确区分"没传"和"传了空对象"。
第三件事:Python 里其他"定义时 vs 运行时"求值的坑
排查后我把 Python 里其他和"定义时求值 vs 运行时求值"、"共享可变状态"相关的坑也系统梳理了一遍。
"定义时vs运行时"求值 / 共享状态的其他坑
# 1. 可变默认参数(本文): 定义时建一次, 调用间共享。→ None哨兵。
# 2. 闭包延迟绑定(late binding): 循环里建一组lambda
# funcs = [lambda: i for i in range(3)]
# [f() for f in funcs] → [2,2,2] (而非0,1,2)!
# → 闭包捕获的是【变量i本身】, 不是当时的值; 循环结束i=2, 都返回2。
# → 修复: lambda i=i: i (用默认参数在定义时捕获当时的值)
# 3. 类变量 vs 实例变量: class里 items=[] 写成类变量, 所有实例共享!
# class Cart: items = [] # ✗ 所有Cart实例共享同一个items!
# → 可变的实例属性要在 __init__ 里 self.items = [] 初始化。
# 4. 装饰器在定义时执行: @decorator 在函数定义时就运行装饰逻辑。
# 5. 默认参数用了"定义时"的变量值: def f(x=some_global):
# → 绑定的是def那一刻 some_global 的值, 后来改了global也不影响。
# 6. 模块级可变全局: 模块import时执行一次, 全局可变状态被共享。
# 共同根源: Python里有些代码在【定义/导入时】执行(默认参数求值、类体、装饰器),
# 有些在【每次调用时】执行(函数体); 混淆这两个时机, 加上"共享可变对象", 就出各种坑。
# 核心: 分清"定义/导入时执行一次"和"每次调用时执行"两个时机; 可变状态别在"只执行一次"的
# 地方创建却期望"每次都新"(默认参数/类变量); 闭包注意捕获的是变量非值。
排查让我把这类坑都梳理清了。一、可变默认参数(本文)。二、闭包延迟绑定——[lambda: i for i in range(3)] 全返回 2,因为闭包捕获的是变量 i 本身而非当时的值,循环结束 i=2;修复用 lambda i=i: i。三、类变量 vs 实例变量——class 里 items=[] 是类变量、所有实例共享,可变实例属性要在 __init__ 里 self.items=[]。四、装饰器在定义时执行。五、默认参数绑定的是 def 那刻的变量值。六、模块级可变全局。它们的共同根源是:Python 里有些代码在定义/导入时执行(默认参数求值、类体、装饰器),有些在每次调用时执行(函数体);混淆这两个时机、加上共享可变对象,就出各种坑。核心是:分清"定义时执行一次"和"每次调用时执行"两个时机;可变状态别在"只执行一次"的地方创建却期望"每次都新"。下面这张图,是这次可变默认参数累积的成因与解法:
第四件事:默认值该用什么、会不会出坑速查表
这次踩坑后,我把"不同默认值会不会出坑、该怎么写"整理成一张表,定义函数时对照。
| 默认值写法 | 会出坑吗 | 说明 / 正确写法 |
|---|---|---|
| x=[] | ✗ 出坑 | 可变, 调用间共享。→ x=None |
| x={} | ✗ 出坑 | 同上。→ x=None |
| x=set() | ✗ 出坑 | 同上。→ x=None |
| x=SomeMutableObj() | ✗ 出坑 | 自定义可变对象同理。→ x=None |
| x=0 / x='' / x=None | ✓ 安全 | 不可变, 共享无副作用 |
| x=(1,2)(元组) | ✓ 安全 | 元组不可变 |
| x=time.time() | △ 注意 | 定义时求值一次! 不是每次调用的时间 |
这张表把"默认值安不安全"钉死了。核心规律是:不可变的默认值(数字、字符串、None、元组)安全(共享它没副作用);可变的默认值(list/dict/set/可变对象)会出坑,一律用 None 哨兵替代;还要警惕 x=time.time() 这种——它在定义时求值一次,绑定的是那一刻的时间,而非每次调用的时间。它给我的最大启发是:判断默认参数会不会出坑,关键看两点交叉——"这个默认值是可变的还是不可变的" × "它被在定义时求值一次、又被多次调用共享";只有"可变 + 共享"撞在一起才出事。这其实和我在很多语言里反复遇到的主题一脉相承:"可变性(mutability)"和"共享(被多处引用)"这对组合,是无数隐蔽 bug 的共同根源——Python 的可变默认参数、列表乘法共享、类变量共享,本质都是"一个可变对象被多个地方共享,一处修改影响了所有地方"。这让我形成一个跨语言的警觉:每当我看到"一个可变对象"将要"被多个地方引用/共享"时(无论是默认参数、全局变量、缓存、还是传参),我都会停下来想一想:"这里的共享是我想要的吗?会不会一处修改意外影响到别处?需不需要在这里切断共享(拷贝/新建)?"。对"可变 + 共享"这对危险组合保持本能的警觉——是写对 Python(及一切有引用语义的语言)的一项核心功底。
第五件事:为什么这个坑是 Python 的"经典面试题"
这个坑几乎是 Python 面试的"常客",我也反思了它经久不衰的原因。
| 原因 | 说明 |
|---|---|
| 极反直觉 | "默认值每次新建"是绝大多数人的第一直觉, 而真相相反 |
| 考察对求值时机的理解 | 定义时 vs 调用时求值, 是Python执行模型的关键点 |
| 牵出引用/可变性 | 引申到对象引用、可变性这些核心概念 |
| 隐蔽且真实 | 真实项目里常见, 不报错却埋雷 |
| 区分新手老手 | 踩过并理解过的人, 和没踩过的人, 认知层次不同 |
这张表道出了这个坑成为"经典"的原因。核心是:它极其反直觉(和绝大多数人的第一直觉相反),又恰好考察了 Python 执行模型里几个最关键的概念——"求值时机(定义时 vs 调用时)""对象引用""可变性";一个简单的现象,背后牵出的是对语言本质的理解。它给我的深刻启发是:很多被反复当成"面试题/经典坑"的问题,之所以"经典",不是因为它们刁钻冷僻,恰恰是因为它们简单的表象下,藏着对某个核心原理的深刻考察;能不能讲清楚这类问题"为什么会这样",往往真实地反映了一个人对这门语言/技术的理解深度——是停留在"背住了这个坑要用 None"的层面,还是真正理解了"默认参数在定义时求值、可变对象被共享"的原理。这让我对"学习"有了一点新的体会:遇到一个"坑"或一个"反直觉的现象",别满足于记住"该怎么绕过它"(知其然),而要追问"它为什么会这样"(知其所以然);因为"为什么"背后,往往连接着这门技术最核心、最本质的原理——把一个个"坑"的"为什么"都搞透,你对这门技术的理解,就会从零散的"招式"汇聚成贯通的"内功"。透过"坑"去理解背后的原理、用"为什么"把知识串成体系——是这个经典 Python 坑,在技术细节之上,教给我的关于"如何深入学习"的一课。
第六件事:写函数定义参数时,我现在的判断习惯
现在每当我给函数参数设默认值,我都会按这张图先想清楚:
这张图的精髓,是"可变对象绝不直接当默认值,用 None 哨兵在函数体内新建"。不可变默认值(数字/字符串/None/元组)直接写;可变对象(list/dict/set)改成默认 None、函数体内 if x is None 新建,用 is None 精确区分没传和传了空。还要警惕默认值是函数调用结果(time()/uuid())时它只在定义时求值一次,想每次新也要挪进函数体。这套习惯,让我定义函数时,从"图方便直接 =[] 默认值"变成了"先想默认值可不可变、会不会被共享累积"——核心始终是:默认值定义时只求值一次被共享,可变对象用 None 哨兵函数体内新建。
我立下的几条规矩
这场"函数有记忆、数据越攒越多"的事故,换来了我写 Python 时,刻进骨子里的几条铁律:
- 默认参数值在定义时只求值一次。不是每次调用新建,这是根本认知。
- 可变对象绝不直接当默认值。=[] / ={} 是经典错误。
- 用 None 作哨兵,函数体内新建。if x is None: x = []。
- 用 is None,别用 if not x。精确区分"没传"和"传了空对象"。
- 类的可变属性在 __init__ 里初始化。别写成类变量被实例共享。
- 闭包注意捕获的是变量不是值。循环里用 lambda i=i 当场捕获。
- 分清"定义时执行"和"调用时执行"。这是 Python 很多坑的总根源。
附:一段亲眼看清"默认值只建一次"的实验
口说无凭。下面这段代码,用 id() 和多次调用,把"默认值在定义时只创建一次、被共享"这件事彻底演示清楚:
print("=== 实验1: 可变默认值被共享(用id证明是同一个对象) ===")
def bad(item, basket=[]):
print(f" 进入函数时 basket={basket}, id={id(basket)}") # id 每次都一样!
basket.append(item)
return basket
bad('a') # 进入时 basket=[], id=140... (第一次)
bad('b') # 进入时 basket=['a'], id=140... (id和上次【相同】, 是同一个对象!)
bad('c') # 进入时 basket=['a','b'], id=140... (还是同一个)
# 还能从函数对象上直接看到那个"被共享的默认值":
print(" 函数记着的默认值:", bad.__defaults__) # (['a','b','c'],) ← 它一直存着!
print("\n=== 实验2: None哨兵, 每次都是新对象(id每次不同) ===")
def good(item, basket=None):
if basket is None:
basket = []
print(f" 进入处理时 basket={basket}, id={id(basket)}") # id 每次都不同!
basket.append(item)
return basket
good('a') # basket=[], id=140...A
good('b') # basket=[], id=140...B (id和上次【不同】, 是全新的对象!)
good('c') # basket=[], id=140...C (又是新的)
print("\n=== 实验3: 默认值是函数调用, 也是定义时求值一次 ===")
import time
def stamp(t=time.time()): # ★ time.time() 在【定义时】就执行了, 绑死了那一刻
return t
print(" 第一次:", stamp())
time.sleep(1)
print(" 一秒后:", stamp()) # 和第一次【完全一样】! 不是一秒后的时间(定义时求值一次)
# 核心: 跑一遍, 用id亲眼看到"bad的basket每次是同一个对象(id相同、数据累积)"、
# "good每次是新对象(id不同)"、"默认值time.time()定死在定义那一刻"——一次刻进认知。
这段实验代码,是我这次踩坑后写下的"认知校准器"。它最有力的地方,是用 id() 把抽象的"是不是同一个对象"变成了肉眼可见的数字:实验一里,bad 每次进入函数时打印的 basket 的 id 完全相同——这就是"它们是同一个对象"的铁证,也直接解释了数据为何累积;而且你还能从 bad.__defaults__ 里亲眼看到那个被函数"一直存着"的、装满了历史数据的默认列表。实验二里,good 每次的 id 都不同,证明 None 哨兵确实让每次都新建了独立对象。实验三则戳穿了 time.time() 当默认值的陷阱——一秒后调用,返回的还是定义那一刻的时间。这正是我想用这段代码,留给每个 Python 学习者的核心方法:当你对某个"反直觉"的语言行为(对象是否共享、何时创建)将信将疑时,id() 是你最好的"显微镜"——它能把"两个变量到底是不是指向同一个对象"这个看不见的事实,变成一个可以直接打印、直接比较的数字。因为Python 里大量的坑(本文的默认参数、列表乘法共享、浅拷贝、is vs ==)都和"对象身份(到底是不是同一个对象)"有关;而 id() 和 is,就是你洞察对象身份的两件利器;当你养成"拿不准就用 id() 看一眼"的习惯,这些围绕"对象共享"的坑,就再也藏不住了。用 id() 这台显微镜把"对象身份"看得清清楚楚——这份"用最简单的工具观测最本质的事实"的习惯,是我整个踩坑系列里理解一切"共享/引用类"问题最可靠的法门。
写在最后
回头看,这场由"一个 =[] 默认值"引发的、函数仿佛有了记忆的事故,真正教给我的,远不止"用 None 当默认值"这一个技巧。它让我对"代码的执行时机"——这个平时不太会去想、却深刻影响程序行为的维度,有了一次清醒的认识。我栽跟头,根源是我把代码当成了一段"静态的、按字面意思描述行为的文本"来读:我看到 basket=[],就理所当然地把它理解为"这个函数被调用、且没传 basket 时,就用一个空列表"——我以为这行代码描述的是"调用时的行为"。可我忽略了一个关键维度:这行代码本身,是在什么"时刻"被执行的。真相是,basket=[] 里的 [],是在"函数被定义的那一刻"就执行了的,而不是在"每次调用的时刻"。我混淆了"代码写在哪"和"代码何时执行"。这让我领悟到一个深刻的认知:理解程序,不能只看"代码写了什么"(静态的字面),还要理解"每一行代码究竟在什么时机被执行"(动态的时序);同一行代码,写在"定义时执行的位置"(默认参数、类体、模块顶层)和写在"每次调用执行的位置"(函数体内),其行为可能截然不同;很多诡异的 bug,都源于我们对"这段代码到底何时、执行几次"的错误假设。这其实是从"读代码"到"理解程序运行"的一道分水岭:初级的理解,是"看懂每行代码字面上想做什么";而更深的理解,是"在脑海里模拟出程序的执行过程——什么时候执行到这里、这里执行了几次、此刻各个对象的状态是什么";建立起这种"时序的、动态的"程序心智模型,你才能真正预见代码的行为,而不被"定义时 vs 运行时"这类时机问题所迷惑。从"看代码写了什么"到"想象代码何时如何运行"——这,是我用一次"函数有记忆"的事故,换来的、关于 Python、也关于如何真正理解程序执行的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写下一个可变默认值时,先愣一下、想起"这个 [] 只会在定义时建一次哦",那我对着那个越攒越多的篮子排查的这大半天,就值了。
—— 别看了 · 2026