第二个用户的购物车里,莫名其妙混进了第一个用户的商品:我在 Python 里因为一个 def func(items=[]) 的默认参数,踩了个让我后背发凉的共享状态大坑
这个 bug 是测试同事报给我的,描述得活灵活现:"你那个新写的接口有鬼。我先用账号 A 调了一次,往购物车加了个商品,没问题;然后我换账号 B 调,购物车应该是空的吧?结果里面赫然躺着账号 A 刚才加的那个商品!" 我第一反应是:"不可能,你是不是没退干净登录、缓存串了?" 她把请求录屏甩给我:两个完全独立的请求、两个不同的用户 ID,而第二个的返回里,确确实实带着第一个的数据。我盯着屏幕,后背慢慢沁出一层凉意——这不是缓存问题,这是我的代码,在两个本该毫不相干的请求之间,泄露了数据。
在一个多用户的系统里,"A 的数据跑到了 B 那里"是仅次于"删库"的、最让人头皮发麻的那类 bug。它意味着数据隔离被打破了,意味着用户的隐私可能在用户之间串台。我立刻放下手头所有事,一头扎进了排查。而当我最终定位到根因时,我又好气又好笑——罪魁祸首,竟是一行我写过无数次、自以为无比熟悉、却从来没真正理解过的代码:一个函数的默认参数,我把它写成了一个空列表 []。
故障现场:一个"记仇"的购物车
我那个出问题的函数,简化之后,核心就是这么一段。它的本意特别简单:接收一个商品,把它加进一个购物车列表里,然后返回这个购物车。如果调用时没传购物车,就默认从一个空购物车开始:
# 我写的"加入购物车"函数(有致命缺陷的版本)
def add_to_cart(item, cart=[]): # ← 默认参数是一个空列表
cart.append(item)
return cart
# 看起来天经地义, 用起来也"似乎"没问题:
# 用户A的请求 (没传 cart, 用默认的空列表)
cart_a = add_to_cart("苹果")
print(cart_a) # 我以为是 ['苹果'] —— 没错, 是 ['苹果']
# 用户B的请求 (也没传 cart, '应该'也从空列表开始)
cart_b = add_to_cart("香蕉")
print(cart_b) # 我以为是 ['香蕉'] —— 错! 实际是 ['苹果', '香蕉'] !!!
看到那个 ['苹果', '香蕉'] 时,我整个人都不好了。用户 B 明明只加了"香蕉",可他的购物车里,却带着用户 A 之前加的"苹果"。这个函数,就像一个"记仇"的购物车,它居然"记得"上一个用户往里加过什么!我反复看那行 def add_to_cart(item, cart=[]),百思不得其解:我不是每次调用都没传 cart、让它用那个"空列表"默认值吗?为什么这个"空列表",会从一次调用,把数据带到下一次调用里去?它不应该每次都是一个全新的、空的 [] 吗?
我顺手加了几行打印,去看每次调用时,那个 cart 默认值的"身份证号"(id):
def add_to_cart(item, cart=[]):
print(f"这次用的 cart 的 id = {id(cart)}") # 打印这个列表的内存身份
cart.append(item)
return cart
add_to_cart("苹果") # 这次 cart 的 id = 140234...880
add_to_cart("香蕉") # 这次 cart 的 id = 140234...880 ← 居然是同一个 id!!
真相,就藏在这两个一模一样的 id 里。两次调用,我以为用的是两个不同的"空列表",可它们的 id 完全相同——这意味着,它们根本就是同一个列表对象!我所谓的"每次都从一个空购物车开始",是一个彻头彻尾的幻觉。从头到尾,所有没传 cart 的调用,共享的都是同一个列表。第一个用户往里加了苹果,这个列表就一直留着苹果;第二个用户来的时候,拿到的是同一个、已经装着苹果的列表。数据,就是这么跨用户泄露的。
第一件事:搞懂默认参数到底"什么时候、被创建几次"
定位到现象,我必须搞懂背后的原理,否则下次还会再栽。我查了 Python 的文档和原理,终于把那个困扰我的核心问题想透了:函数的默认参数值,是在"函数被定义(def 那一行被执行)"的那一刻,就被创建好的,而且只创建这一次;它绝不是在"每次函数被调用"时,才重新创建的。
# 关键认知: 默认值 [] 在 def 这行执行时, 就被创建了"一个"列表,
# 这个列表, 此后被所有"使用默认值"的调用, 反复共享。
def add_to_cart(item, cart=[]):
cart.append(item)
return cart
# ↑ 当 Python 读到上面这个 def 时, 它就当场创建了一个空列表 [],
# 并把它作为 cart 的默认值"绑定"在了这个函数上。
# 这个列表, 从此就是函数的一部分, 全程只有这一个。
# 所以:
add_to_cart("苹果") # 用那"唯一的"默认列表, 它变成 ['苹果']
add_to_cart("香蕉") # 还是用那"唯一的"默认列表(已是['苹果']), 变成 ['苹果','香蕉']
# 你甚至可以直接"摸"到这个藏起来的默认列表:
print(add_to_cart.__defaults__) # (['苹果', '香蕉'],) ← 它就活在函数对象里!
看到 add_to_cart.__defaults__ 打印出 (['苹果', '香蕉'],) 那一刻,我彻底通透了。那个默认列表,根本不是什么"每次调用时凭空出现的空列表",它是一个实实在在地、被存储在函数对象本身(__defaults__ 属性)里的、活生生的对象。它在 def 执行时诞生,然后就作为函数的"随身物品",一直跟着这个函数。每次调用如果不传 cart,用的就是这个"随身物品";而列表是可变的(mutable),你对它的 append 修改,会实实在在地改变这个"随身物品"本身,于是这个改动,就被"记"了下来,带到了下一次调用。我那个"记仇的购物车",记仇的根源,就在这里。
第二件事:正解是用 None 作默认值,在函数内部"按需创建"
搞懂了根因,正确的写法就呼之欲出了。问题的本质是"可变的默认值被共享",那解法就是:别用可变对象当默认值,改用一个不可变的"哨兵值"(通常是 None)当默认,然后在函数内部,每次需要时再创建一个全新的列表。这是 Python 里处理可变默认参数的、几乎是唯一正确的标准范式:
# 正解: 用 None 当默认值, 在函数体内"按需"创建新列表
def add_to_cart(item, cart=None):
if cart is None: # 没传 cart? 那就现场创建一个全新的空列表
cart = [] # ← 关键: 这个 [] 是在"每次调用"时才执行的!
cart.append(item)
return cart
# 现在, 每次不传 cart 的调用, 都会走到 cart = [] 这行,
# 当场创建一个独立的、全新的空列表, 互不干扰:
cart_a = add_to_cart("苹果")
print(cart_a) # ['苹果'] ✓
cart_b = add_to_cart("香蕉")
print(cart_b) # ['香蕉'] ✓ 干净! 没有'苹果'了!
print(cart_a) # ['苹果'] ✓ A 的购物车也没被 B 污染
这个修复的精髓,在于它把"创建空列表"这个动作,从"函数定义时,只做一次"(出问题的写法),挪到了"函数调用时,每次都做"(正确的写法)。None 是不可变的,把它当默认值,不存在"被修改并污染下次调用"的问题;而真正的列表创建 cart = [],被放进了函数体内部,于是它就成了"每次调用都重新执行"的代码——每次调用,都拿到一个崭新的、独立的空列表。一句话:默认参数那个"坑",不在于"用了默认值",而在于"用了一个'可变的'默认值";只要把可变的默认值,换成不可变的 None + 函数体内按需创建,坑就被填平了。这个 if x is None: x = [] 的范式,从此成了我写带可变默认参数的函数时,刻进肌肉记忆的标准动作。
下面这张图,把"出问题的共享"和"正确的按需创建"两条路径,画在了一起:
左边红色那条路,就是我踩的坑:可变默认值,全程复用同一个对象,上次的修改赖着不走。右边绿色那条路,是正解:None 哨兵 + 体内新建,每次都干干净净。两条路的分岔点,只在于"默认值,到底是个会被改的可变对象,还是个改不动的 None"。
第三件事:这个坑的"近亲"——字典、集合做默认值也一样
填平了列表这个坑,我立刻警觉:既然根源是"可变对象做默认值",那 Python 里其它的可变对象,岂不是都有这个问题?我一验证,果然——字典 {}、集合 set(),任何可变对象做默认参数,都会踩同一个坑:
# 坑的"近亲": 可变的字典做默认值, 同样会被共享
def add_config(key, value, config={}): # ✗ 同样的坑!
config[key] = value
return config
c1 = add_config("a", 1) # {'a': 1}
c2 = add_config("b", 2) # 期望 {'b': 2}, 实际 {'a': 1, 'b': 2} —— 又串了!
# 正解依然是 None 哨兵:
def add_config_ok(key, value, config=None):
if config is None:
config = {}
config[key] = value
return config
# 而不可变对象做默认值, 则完全没有这个问题(它们改不动, 自然不会被污染):
def greet(name, greeting="你好"): # ✓ 字符串不可变, 安全
return f"{greeting}, {name}"
def repeat(x, times=3): # ✓ 整数不可变, 安全
return [x] * times
这一验证,让我对这个坑的边界有了清晰的认识:它的判断标准只有一个——你的默认值,是"可变对象"还是"不可变对象"。列表 []、字典 {}、集合 set() 这些可变对象做默认值,都会因为"全程共享同一个对象"而踩坑;而字符串、数字、元组、None、True/False 这些不可变对象做默认值,则完全安全——因为它们根本"改不动",自然也就不存在"被上次调用改脏、污染下次"的可能。所以这个坑真正要记的,不是"列表不能当默认值"这么具体的一条,而是一条更普适的原则:任何'可变对象',都不要直接拿来当函数的默认参数值;要用,就用 None 当默认、在函数体内按需创建。
第四件事:既然是坑,Python 为什么不"修"了它?
把坑填平后,我心里冒出一个不服气的问题:这明明是个反直觉、坑了无数新手的设计,Python 为什么不干脆改成"每次调用都重新创建默认值"呢?我查了不少资料,才理解了这个设计背后其实是有道理的——"默认值在定义时只创建一次"这个规则,本身是简单、一致、且在某些场景下有用的;真正的"坑",是它和"可变对象"撞在一起时,才产生的。
# 理解1: "定义时求值一次"这个规则, 用在不可变默认值上, 既高效又符合直觉
def f(x=10): # 10 创建一次, 反正不可变, 共享它毫无问题
return x + 1
# 理解2: 这个规则甚至能被"故意利用"来做一些技巧, 比如缓存:
def fib(n, _cache={0: 0, 1: 1}): # 故意用可变默认值当"持久缓存"!
if n not in _cache:
_cache[n] = fib(n - 1) + _cache[n - 2] if False else fib(n-1)+fib(n-2)
return _cache[n]
# 这个 _cache 在多次调用间"持久存在"的特性, 这里正是我们想要的(记忆化)。
# —— 同一个特性, 在购物车场景是"坑", 在缓存场景却是"特性"!
# 理解3: 如果改成"每次调用重新求值默认值", 反而会带来性能开销和其它语义问题。
# 所以 Python 选择了"定义时求值一次"这个简单一致的规则,
# 把"别拿可变对象当默认值"的注意义务, 交给了程序员。
这个认知让我对这个"坑"释然了,也更深刻了:同一个语言特性("默认值定义时创建一次"),用在不可变对象上是高效无害的,被故意用来做持久缓存时是巧妙的"特性",而被无意地用在可变对象上、又期望它"每次都是新的"时,就成了咬人的"坑"。说到底,坑不在规则本身,而在"程序员对规则的预期"与"规则的实际行为"之间的错位——我以为默认值每次新建,而它其实只建一次。理解了这一点,我不再把它当成 Python 的一个"缺陷"去抱怨,而是把它当成一个"我必须了解并尊重的语言规则"去掌握。把"定义时创建"这个规则,作用在可变 vs 不可变默认值上的不同结果,整理成一张表:
| 默认值 | 是否可变 | 多次调用共享同一个吗 | 会踩坑吗 |
|---|---|---|---|
| [] 列表 | 可变 | 是(共享) | 会(改动被累积) |
| {} 字典 | 可变 | 是(共享) | 会(改动被累积) |
| set() 集合 | 可变 | 是(共享) | 会 |
| 0 / 10 数字 | 不可变 | 是(但改不动) | 不会 |
| "abc" 字符串 | 不可变 | 是(但改不动) | 不会 |
| (1,2) 元组 | 不可变 | 是(但改不动) | 不会 |
| None | 不可变 | 是(但改不动) | 不会(且是推荐哨兵) |
第五件事:举一反三——"共享可变状态"是一整类 bug 的根源
这次踩坑后劲很大,它让我意识到:"一个本以为是私有的、独立的可变状态,实际上却被多个使用方共享了"——这个模式,远不止默认参数这一个地方,它是 Python(乃至所有语言)里一大类隐蔽 bug 的共同根源。我顺藤摸瓜,梳理了它的几个常见"变体":
# 变体1: 类属性 vs 实例属性 —— 可变类属性被所有实例共享
class ShoppingCart:
items = [] # ✗ 这是"类属性", 被所有实例共享! (同款坑)
def add(self, x): self.items.append(x)
# 正解: 在 __init__ 里初始化为实例属性
class ShoppingCartOk:
def __init__(self): self.items = [] # ✓ 每个实例独立
# 变体2: 用 * 复制嵌套列表 —— 内层列表是同一个引用
grid = [[0] * 3] * 3 # ✗ 三行其实是同一个列表!
grid[0][0] = 1 # 改一行, 三行全变 [1,0,0]
grid_ok = [[0] * 3 for _ in range(3)] # ✓ 每行独立创建
# 变体3: 直接 return 内部可变状态 —— 外部拿到引用后能改你的"私货"
class Config:
def __init__(self): self._data = {"k": "v"}
def get_all(self): return self._data # ✗ 返回内部引用, 外部能篡改
def get_all_safe(self): return dict(self._data) # ✓ 返回副本
这三个变体,和最初的默认参数坑,内核完全一致——都是"一份可变状态,被本应彼此独立的多方,无意中共享了"。变体1(可变类属性):写成类属性的 items = [] 被所有实例共享,和默认参数坑一模一样,正解是在 __init__ 里初始化为实例属性。变体2(嵌套列表的 * 复制):[[0]*3]*3 里的三个"行",其实是同一个列表的三个引用,改一个全变,正解是用列表推导式为每行独立创建。变体3(返回内部引用):方法直接 return self._data,等于把内部可变状态的"钥匙"交给了外部,外部一改就污染了你的内部,正解是返回一份副本。把这些"共享可变状态"的坑和对策汇总:
| 场景 | 共享的根源 | 正解 |
|---|---|---|
| 可变默认参数 | 默认值定义时只建一次 | None 哨兵 + 体内新建 |
| 可变类属性 | 类属性被所有实例共享 | 在 __init__ 里设为实例属性 |
| [[0]*n]*m 建二维 | 外层复制的是同一个内层引用 | 用列表推导式逐行新建 |
| return 内部可变状态 | 外部拿到引用可篡改内部 | 返回副本(dict()/list()/copy) |
| 多处持有同一可变对象 | 一处修改, 处处可见 | 明确所有权, 或传不可变副本 |
这张表背后的统一心法是:每当你的代码里出现一个"可变对象",你都要清醒地问自己一句——"这个对象,到底有几个地方持有它的引用?它们之中任何一个对它的修改,我都希望被其他持有者看到吗?"如果答案是"我希望它们各自独立、互不影响",那你就必须警惕"共享"——确保每一方都拿到一个独立的副本,而不是同一个对象的多个引用。我那个购物车 bug,根子上就是没问这句话:我以为每个用户的购物车是独立的,却没意识到它们共享着同一个默认列表。对'可变状态的共享'保持一份清醒的警觉,是写出没有这类隐蔽 bug 的代码的关键。
一张"该不该用它当默认值/会不会共享"的决策图
把这次踩坑沉淀成一张图。每当你要写默认参数、或处理一个可变对象时,照着它判断:
这张图的主干判断是:默认值是可变对象、且你想要"调用间独立"——那就铁定用 None 哨兵 + 体内新建。只有在你"刻意"想利用它的持久共享特性时(比如做缓存),才保留可变默认值,且必须写注释说明。再往大了说,处理任何可变对象,都先问一句"几方持有、要不要独立",要独立就发副本。把这套判断变成习惯,这一整类"共享可变状态"的坑就基本与你绝缘。
我立下的几条可变状态使用规矩
这次"购物车跨用户串数据"的事故后,我给自己立了几条规矩:
- 默认参数绝不用可变对象:列表/字典/集合绝不直接当默认值,一律用
None哨兵 + 函数体内if x is None: x = []按需创建。 - 可变成员在 __init__ 里建:类里的可变状态(列表/字典)写进
__init__作为实例属性,别写成类属性被所有实例共享。 - 建二维结构用推导式:
[[0]*n for _ in range(m)],绝不用[[0]*n]*m(内层是同一引用)。 - 对外返回可变状态发副本:方法要返回内部的列表/字典时,返回副本(
list()/dict()/copy),别把内部引用直接交出去。 - 警惕"共享引用":每出现一个可变对象,问清"几方持有、改动要不要互相可见",要独立就确保各拿副本。
- 多用户/多请求场景重点查隔离:涉及用户数据的代码,专门验证"两个用户的数据是不是真的独立",别让任何状态意外跨用户共享。
- 不可变优先:能用不可变对象(元组、frozenset、具名元组)表达的状态,优先用不可变的,从根上杜绝"被意外修改/共享"。
这几条里,第一条是用一个"后背发凉"的数据泄露 bug 换来的、最该刻进肌肉记忆的铁律。而贯穿所有规矩的那条主线,是对"可变性"与"共享"这两件事的高度敏感。我这次栽跟头,根子上是我对"我正在使用的这个对象,是不是可变的、又是不是被共享的"这件事,缺乏一种本能的警觉——我随手写下 cart=[],完全没意识到这个 [] 既是可变的、又会被所有调用共享,这两个属性叠加在一起,就酿成了跨用户的数据泄露。'可变' + '共享',是很多隐蔽 bug 的危险配方;而对这个配方保持警觉、在写下每一个可变对象时都多想一层'它会被谁共享',是避开这一整类坑的根本功夫。
写在最后:最深的坑,往往藏在最熟悉的语法里
这次被一个 def func(items=[]) 坑到的经历,给我一个不小的触动:我们编程时栽的最深、最隐蔽的跟头,常常不是来自那些我们明知"很难、很陌生"的高级特性,反而来自那些我们"天天在用、自以为闭着眼都会"的基础语法。默认参数,是我学 Python 第一周就会用的东西,我写过成百上千次,从没觉得它有什么值得深究的——可恰恰是这份"我太熟了"的自负,让我从来没有真正去搞清楚"它的默认值,到底是什么时候、被创建几次的"。我对它的"熟悉",是一种停留在"会用"层面的、肤浅的熟悉;而正是这种"肤浅的熟悉"和"自以为懂",成了那个跨用户数据泄露 bug 的温床。
想通这一点,我对"基础"二字,生出了一份新的敬畏。真正的熟练,不是'我用过很多次',而是'我真正理解了它每一个行为背后的原理';而这两者之间的鸿沟,往往就藏在那些我们因为'太熟'而懒得深究的细节里。越是基础、越是常用的语法,我们就越容易满足于"会用就行"的浅层理解,越容易跳过对它"为什么这样、什么时候这样"的追问——可也正因为它基础、常用,它一旦藏着我们没理解透的行为,这个行为就会在我们写的无数处代码里,反复地、悄无声息地埋雷。一个真正可靠的工程师,和一个只是"能写出能跑的代码"的人,差距常常就在这里:前者会对每一个常用的语法,都抱有一份'我真的彻底搞懂它了吗'的审慎,会去深究那些'看起来理所当然'的默认行为;而后者,则满足于'它一直没出过事',直到某天,它在一个最意想不到的地方,狠狠咬上一口。
所以,如果你也想写出更可靠的代码,我想把这次踩坑最想说的话送给你:请对你最熟悉的那些语法,也保持一份'重新审视'的好奇与谦逊。去问问那些你"天天在用、从没怀疑"的东西:这个默认参数,到底什么时候求值?这个 is 和 ==,到底差在哪?这个 for 循环里的闭包,捕获的到底是值还是变量?因为编程的可靠,不只建立在你掌握了多少高级特性之上,更建立在你把那些最基础、最常用的东西,理解得有多透彻、多扎实之上;那些最熟悉的语法,恰恰最值得你回过头去,重新、认真地搞懂一遍。那个"记仇的购物车",最终教给我的,正是这份对"熟悉"的警惕——它让我明白,在编程的世界里,"我以为我懂了",往往才是最危险的开始;而对自己的"懂",时时保持一分谦逊的怀疑,去把每一个习以为常的细节都真正吃透,才是通往可靠的、那条最朴素也最扎实的路。
—— 别看了 · 2026