Python 函数默认参数是 [] 让我们订单串单的 P1 事故复盘

一行 def f(items=

有个 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 不直接每次调用时新建?答案是:

  1. 性能:默认值在 def 时算一次,调用时直接复用,比每次重新求值快很多
  2. 一致性:Python 哲学是"明确比隐式好"。如果 mutable default 自动新建,immutable default(int/str)也应该自动新建,代码行为就不一致了
  3. 历史包袱:改变这个行为会破坏大量现有代码

所以 Python 选择保留这个语义,但通过 lint + dataclass 异常 + 教育让你不踩坑。

本节带走 5 条

  1. 函数参数默认值永远用 None,在函数内部做 if x is None: x = []。绝不写 def f(x=[]):
  2. 类属性永远在 __init__ 初始化 mutableitems = [] 这种写在类定义里全是 bug
  3. dataclass 用 field(default_factory=list),不要 items: list = []
  4. pre-commit + ruff 强制 B006,从源头堵
  5. code review 时看到默认参数是 [] / {} / set() / datetime.now() 全部打回

这一个 bug 我们公司付出的代价是:6 小时排查 + 30 几个用户订单数据修正 + 一次 P1 工单复盘。学费够高,希望你看完不用付这个学费。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

K8s Pod OOMKilled 排查指南:6 种真实原因 + 每种修法

2026-5-19 10:29:27

技术教程

React useState 连点 3 次只 +1 的真相:批量更新 + 函数式 setter 完全指南

2026-5-19 10:33:10

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索