有个 Python 函数,我们公司写了大半年没出问题。直到一次大促,客服反馈用户 A 的订单里出现了用户 B 的优惠券,用户 B 的购物车里出现了用户 A 的商品。两个完全不相关的请求,数据互相串了。排查到天亮,根源就一行代码:
def process_order(uid, items=[]): # ← 这行是元凶
items.append({'flag': 'processed'})
return items
看出来了吗?这是 Python 最经典的坑:mutable default argument。函数定义时 items=[] 只创建一次列表,所有不传 items 参数的调用共享同一个列表。本文把这个坑掰开讲透,顺便讲它的两个表亲(默认参数是 dict / 是 datetime.now)。
30 秒最小复现
def add(item, bucket=[]):
bucket.append(item)
return bucket
print(add('a')) # ['a']
print(add('b')) # ['a', 'b'] ← 你以为是 ['b'],实际不是
print(add('c')) # ['a', 'b', 'c']
print(add('d', [])) # ['d'] ← 显式传 [] 就 OK
# 验证默认值确实是同一个对象
def f(x=[]):
return id(x)
print(f()) # 140391224589376
print(f()) # 140391224589376 ← 完全同一个 id
为什么会这样?Python 函数的默认参数是在函数定义时(也就是 def 那一行被执行时)求值,而不是每次调用时求值。def add(item, bucket=[]) 这一行执行后,add.__defaults__ 里存了一个 list 对象的引用。每次调用 add('a') 时,如果没传 bucket,就用这个共享引用。
def add(item, bucket=[]):
bucket.append(item)
return bucket
print(add.__defaults__) # ([],)
add('a')
print(add.__defaults__) # (['a'],) ← defaults 本身被改了
add('b')
print(add.__defaults__) # (['a', 'b'],)
正确写法
# 推荐:用 None 作哨兵,函数内部初始化
def add(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
# Python 3.10+ 也可以用 dataclass 的 field(default_factory=list)
from dataclasses import dataclass, field
@dataclass
class Cart:
items: list = field(default_factory=list) # ✓ 每次实例化新建 list
# items: list = [] # ✗ 所有实例共享!
# 函数签名用类型提示也明确表达意图
from typing import Optional, List
def add(item: str, bucket: Optional[List[str]] = None) -> List[str]:
if bucket is None:
bucket = []
bucket.append(item)
return bucket
真实事故复盘:订单串单
回到开头那段代码,我们的真实场景:
# 简化后的核心逻辑(真实代码隐去了一些字段)
class OrderProcessor:
def __init__(self):
self.coupon_service = CouponService()
def calculate_total(self, uid, items, applied_coupons=[]):
"""计算订单总价,可选应用的优惠券列表"""
total = sum(item['price'] * item['qty'] for item in items)
# 业务规则:VIP 用户自动应用一张满 100 减 10 的券
if self.is_vip(uid):
vip_coupon = self.coupon_service.get_vip_coupon(uid)
applied_coupons.append(vip_coupon) # 重点:这里 append!
for coupon in applied_coupons:
total -= coupon.discount
return total
processor = OrderProcessor()
# 用户 1 下单
total1 = processor.calculate_total(uid=1001, items=[...])
# applied_coupons = [coupon_for_1001] ← 这个 list 被改了
# 用户 2 下单(也没传 applied_coupons)
total2 = processor.calculate_total(uid=1002, items=[...])
# 注意!此时 applied_coupons 默认值已经是 [coupon_for_1001]
# 然后 append(coupon_for_1002) → [coupon_for_1001, coupon_for_1002]
# 用户 2 的订单被错误应用了用户 1 的优惠券!
修复就一行:
def calculate_total(self, uid, items, applied_coupons=None):
if applied_coupons is None:
applied_coupons = []
# ... 后面代码不变
修完之后,我们写了一个 lint 规则,强制检测所有 mutable default。
怎么防:lint + 团队规范
Python 有专门检测这个坑的 lint 规则:
# pylint 默认开启 W0102 dangerous-default-value
pylint --disable=all --enable=W0102 your_module.py
# ruff(更快的现代 linter)也有
# pyproject.toml
[tool.ruff.lint]
select = [
"B006", # mutable-argument-default
"B008", # function-call-in-default-argument
...
]
# pre-commit hook 里挂上,提交前自动检测
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.4
hooks:
- id: ruff
args: [--fix, --select=B006,B008]
所有团队都该在 pre-commit 加这条。代价 = 0,收益 = 永远不再因为这个坑半夜被叫起来。
表亲 1:默认参数是字典(同样坑)
# 错
def cache_user(uid, cache={}):
if uid not in cache:
cache[uid] = fetch_user(uid)
return cache[uid]
# 这看起来像一个"局部缓存",实际是<全局>缓存(永远不会清空)
# 第一次跑测试用户 1001,第二次跑测试用户 1002...过了几年 cache 里几十万 entry
# 内存爆炸是迟早的事
# 对:显式用模块级变量,或者 functools.lru_cache
from functools import lru_cache
@lru_cache(maxsize=10000) # 显式上限
def cache_user(uid):
return fetch_user(uid)
表亲 2:默认参数调用函数
from datetime import datetime
# 错:default 是函数调用,只在 def 执行时算一次
def log_event(msg, ts=datetime.now()):
print(f'[{ts}] {msg}')
log_event('start') # [2024-11-11 10:00:00] start
import time; time.sleep(2)
log_event('next') # [2024-11-11 10:00:00] next ← 时间没变!
# 对:用 None 哨兵
def log_event(msg, ts=None):
if ts is None:
ts = datetime.now()
print(f'[{ts}] {msg}')
这个坑更隐蔽,因为 datetime.now() 看起来像每次都求值。但 Python 是「def 这一行被执行时」求值,后面所有调用共享同一个时间戳。
另一个相关坑:循环里的闭包延迟求值
# 错:所有 lambda 都引用最后的 i
funcs = []
for i in range(3):
funcs.append(lambda: i)
print([f() for f in funcs]) # [2, 2, 2] ← 全是 2!
# 因为 lambda 里的 i 是闭包变量,引用的是循环结束后的 i
# 对 1:用默认参数把当前值"绑定"进去
funcs = []
for i in range(3):
funcs.append(lambda i=i: i) # i=i 把当前 i 值作为默认参数
print([f() for f in funcs]) # [0, 1, 2]
# 对 2:用 functools.partial
from functools import partial
funcs = [partial(lambda x: x, i) for i in range(3)]
# 对 3:用列表推导(每次迭代是独立作用域)
funcs = [(lambda x=i: x) for i in range(3)]
更高级的反模式:类属性是 mutable
# 错
class Cart:
items = [] # 类属性,所有实例共享!
def add(self, item):
self.items.append(item)
c1 = Cart()
c1.add('a')
c2 = Cart()
print(c2.items) # ['a'] ← c2 看到 c1 的 items!
# 对:在 __init__ 里初始化
class Cart:
def __init__(self):
self.items = [] # 实例属性,每个实例独立
def add(self, item):
self.items.append(item)
# 或者用 dataclass
from dataclasses import dataclass, field
@dataclass
class Cart:
items: list = field(default_factory=list)
# 不能写成 items: list = [] ← dataclass 会拒绝
注意:dataclass 看到 mutable default 会直接抛 ValueError: mutable default {} for field items is not allowed: use default_factory。这是它救命的特性。
排查命令:找出项目里所有 mutable default
# 用 ruff
ruff check --select B006 .
# example.py:5:23: B006 Do not use mutable data structures for argument defaults
# example.py:12:18: B006 Do not use mutable data structures for argument defaults
# 用 ast 自己写一个(不依赖第三方)
python3 -c "
import ast, sys
for path in sys.argv[1:]:
with open(path) as f:
tree = ast.parse(f.read())
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
for default in node.args.defaults:
if isinstance(default, (ast.List, ast.Dict, ast.Set, ast.Call)):
print(f'{path}:{default.lineno}: mutable default in {node.name}')
" your_project/**/*.py
为什么 Python 不"修复"这个设计
每次新人发现这个坑,都会问:为什么 Python 不直接每次调用时新建?答案是:
- 性能:默认值在 def 时算一次,调用时直接复用,比每次重新求值快很多
- 一致性:Python 哲学是"明确比隐式好"。如果 mutable default 自动新建,immutable default(int/str)也应该自动新建,代码行为就不一致了
- 历史包袱:改变这个行为会破坏大量现有代码
所以 Python 选择保留这个语义,但通过 lint + dataclass 异常 + 教育让你不踩坑。
本节带走 5 条
- 函数参数默认值永远用 None,在函数内部做
if x is None: x = []。绝不写def f(x=[]): - 类属性永远在 __init__ 初始化 mutable。
items = []这种写在类定义里全是 bug - dataclass 用 field(default_factory=list),不要
items: list = [] - pre-commit + ruff 强制 B006,从源头堵
- code review 时看到默认参数是 [] / {} / set() / datetime.now() 全部打回
这一个 bug 我们公司付出的代价是:6 小时排查 + 30 几个用户订单数据修正 + 一次 P1 工单复盘。学费够高,希望你看完不用付这个学费。
—— 别看了 · 2026