首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Python 的默认参数把我坑惨了,原来可变对象作默认值这么要命

Python 的默认参数把我坑惨了,原来可变对象作默认值这么要命

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

一个让我背了黑锅的线上事故

先讲一件让我至今心有余悸的事。

前年我在维护一个订单处理系统,核心逻辑是给一批订单打标签。每个订单进来,系统会根据规则生成一个标签列表,然后存到数据库里。

代码大概长这样:

代码语言:javascript
复制
def apply_tags(order_id, tags=[]):
    # 根据订单信息生成标签
    if order_id.startswith("VIP"):
        tags.append("VIP用户")
    if order_id.startswith("大额"):
        tags.append("大额订单")
    # ... 更多规则
    return tags

这个函数的设计很直接——你传一个订单号进来,它返回对应的标签列表。tags 参数是可选的,方便外部传入已有的标签列表进行追加。

看起来没毛病对吧?

但是有一天,运营反馈说:"为什么有些普通订单莫名其妙被打上了'VIP用户'标签?"

我查了数据库,发现确实是出现了标签错乱。但我的逻辑很严谨啊,只有订单号以 "VIP" 开头的才会打 VIP 标签,怎么普通订单也中招了?

查了半天日志,我发现一个诡异的现象:同一个 Python 进程处理的不同订单,标签列表会互相累积。 一个 VIP 订单处理完后,它产生的标签会出现在下一个普通订单的结果里。

我盯着代码看了整整一个小时,最后终于发现了问题所在:

代码语言:javascript
复制
def apply_tags(order_id, tags=[]):   # 这个 [] 在函数定义时只创建一次!

Python 在定义函数的时候,tags=[] 这个空列表就已经被创建好了。之后每次调用这个函数,如果不传 tags 参数,用的都是 同一个列表对象

所以当第一个 VIP 订单调用了 apply_tags("VIP001")tags 列表里有了 ["VIP用户"]。第二个普通订单调用 apply_tags("普通001") 时,没有传 tags 参数,用的还是刚才那个列表,里面已经有一个元素了,于是变成了 ["VIP用户", "普通订单"]

普通订单就此背上了"VIP用户"的标签。

那天我删掉了线上几万条错误标签,然后记住了这个教训:Python 函数的默认参数,在定义时只计算一次,然后永远复用。

默认参数的计算时机:只发生在 def 那一刻

这个行为是 Python 和其他语言很不一样的地方。

在很多语言里,函数的默认参数是在每次调用时重新计算的。但 Python 不是这样——默认参数的值在函数定义时就确定了,之后再也不变了。

看一个更直观的例子:

代码语言:javascript
复制
import datetime

def log_message(msg, timestamp=datetime.datetime.now()):
    print(f"[{timestamp}] {msg}")

log_message("第一条日志")  # 输出 [2024-01-01 10:00:00] 第一条日志
time.sleep(5)
log_message("第二条日志")  # 输出 [2024-01-01 10:00:00] 第二条日志

两条日志的时间戳一模一样,因为 datetime.datetime.now() 只在函数定义时执行了一次。

如果你希望每次调用都用当前时间,必须这样写:

代码语言:javascript
复制
def log_message(msg, timestamp=None):
    if timestamp is None:
        timestamp = datetime.datetime.now()
    print(f"[{timestamp}] {msg}")

这个 None 加判断的写法,是 Python 社区处理"每次调用重新计算默认值"的标准模式。

哪些类型是"可变对象",为什么它们这么危险?

要理解这个坑的严重性,先搞清楚什么是"可变对象"。

Python 里的数据分为两大类:

不可变对象:数字、字符串、元组、布尔值、None

  • 修改它们会创建新对象,原对象不变
  • 作为默认参数,你改不了它,所以安全

可变对象:列表、字典、集合、自定义类的实例

  • 修改它们会改变原对象本身
  • 作为默认参数,你改了它,下次调用还是改过的状态
代码语言:javascript
复制
# 安全:数字是不可变的
def add_one(x, increment=1):
    return x + increment   # increment 永远不会变

# 危险:列表是可变的
def add_item(item, items=[]):
    items.append(item)     # 每次调用都在修改同一个列表
    return items

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['a', 'b']  ← 出问题了

不可变对象作为默认值,不管你调用多少次,它都是原来的值。可变对象就不一样了——每次调用都有可能修改它,而修改会累积。

还有哪些地方藏着同样的坑?

其实不止函数参数,Python 里还有好几个地方也有同样的问题。

类属性里的可变对象:

代码语言:javascript
复制
class Order:
    tags = []   # 这个列表属于类,不属于实例

    def add_tag(self, tag):
        self.tags.append(tag)

o1 = Order()
o2 = Order()
o1.add_tag("VIP")
print(o2.tags)  # ['VIP']  ← o2 无辜中枪

类属性是所有实例共享的。如果你在类属性里放列表、字典这些可变对象,所有实例都会共享同一份数据。

正确的做法是放在 __init__ 里,每个实例独立创建:

代码语言:javascript
复制
class Order:
    def __init__(self):
        self.tags = []   # 每个实例都有自己的列表

闭包里捕获的变量:

代码语言:javascript
复制
funcs = []
for i in range(3):
    funcs.append(lambda: i)   # 捕获的是变量 i,不是值 i

for f in funcs:
    print(f())  # 输出 2, 2, 2,不是 0, 1, 2

这个坑的机制不太一样,但本质类似——你以为是"当时的值",实际是"同一个变量"。

标准解决方案:None + 判断

Python 社区有一个约定俗成的写法,专门解决可变对象作默认值的问题:

代码语言:javascript
复制
# 错误写法
def add_item(item, items=[]):
    items.append(item)
    return items

# 正确写法
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

核心思路:默认参数永远用 None,在函数体里判断并创建可变对象。

这个模式适用于所有可变对象:

代码语言:javascript
复制
# 列表
def process(items=None):
    if items is None:
        items = []

# 字典
def process(data=None):
    if data is None:
        data = {}

# 集合
def process(ids=None):
    if ids is None:
        ids = set()

如果你觉得每次都写 if items is None 太啰嗦,可以这样写一行:

代码语言:javascript
复制
def add_item(item, items=None):
    items = items or []   # 但要注意:如果 items 是空列表或者空字典,也会被替换
    items.append(item)
    return items

不过 items = items or [] 有个小问题——如果传入的是空列表 [],它会被当作"假值",然后被替换成一个新的空列表。大多数情况下这不算问题,但如果你确实需要区分"传入了空列表"和"没传参数",用 if items is None 更准确。

有没有例外?什么时候可以用可变默认值?

有的。

缓存场景是一个经典的例外。有时候你故意用可变默认值来做缓存:

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

每次调用 get_user,如果同一个 user_id 再次出现,直接从缓存返回,不用重复查数据库。

但即使是这种场景,也要小心——如果缓存无限增长,会导致内存泄露。通常在这种场景下,用类或者专门的缓存库(比如 functools.lru_cache)更靠谱:

代码语言:javascript
复制
from functools import lru_cache

@lru_cache(maxsize=128)
def get_user(user_id):
    return query_database(user_id)

lru_cache 不仅帮你做了缓存,还限制了缓存大小,更安全。

我怎么养成的习惯

那次线上事故之后,我给自己定了一个规则:所有默认参数,只要值是可变的,一律用 None

这个规则写起来多几个字符,但能避免 100% 的"默认参数污染"问题。

而且我的 IDE(PyCharm)会直接给 def f(items=[]) 这种写法画黄线警告,提醒我"默认参数是可变的,有风险"。

现在我的手指已经形成肌肉记忆了——打完函数参数,如果是列表、字典、集合,自动写成 =None,然后在函数体里加一个判断。

回到那个订单标签的例子,正确的写法是:

代码语言:javascript
复制
def apply_tags(order_id, tags=None):
    if tags is None:
        tags = []
    if order_id.startswith("VIP"):
        tags.append("VIP用户")
    if order_id.startswith("大额"):
        tags.append("大额订单")
    return tags

这样每次调用都是全新的列表,互不干扰。

写在最后

那个删除线上错误标签的下午,我一共删了将近五万条数据。每一行 DELETE 语句执行的时候,我都在心里骂自己一遍——为什么当初写代码的时候不多想一步?

Python 的这个设计,严格来说不算 Bug——它是为了性能做的优化。函数定义时只计算一次默认值,能节省每次调用时重新创建对象的时间开销。在 CPython 的实现里,默认参数是存储在函数对象上的,每次调用直接从函数对象里取,效率很高。

但"高性能"带来的副作用就是"容易被误用"。尤其对于从其他语言转过来的开发者,默认参数每次调用重新计算才是习惯的认知,Python 这个"定义时计算一次"完全是意料之外。

我现在每次写函数默认参数,都会问自己三遍:

  • 这个默认值是可变的吗?
  • 我是不是需要每次调用都新建一个?
  • None 安全还是直接用值安全?

问完这三遍,再落笔。

希望你不用像我一样,靠删五万条数据来记住这个教训。

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

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

目录
  • 一个让我背了黑锅的线上事故
  • 默认参数的计算时机:只发生在 def 那一刻
  • 哪些类型是"可变对象",为什么它们这么危险?
  • 还有哪些地方藏着同样的坑?
  • 标准解决方案:None + 判断
  • 有没有例外?什么时候可以用可变默认值?
  • 我怎么养成的习惯
  • 写在最后
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档