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

简介: 本文以一次线上事故为引,详解Python中可变对象作函数默认参数的致命陷阱:`def f(items=[])` 中的空列表在函数定义时仅创建一次,导致多次调用间状态污染。文章剖析原理、列举类属性/闭包等同类风险,并给出标准解法——统一用 `None` 作默认值并在函数体内初始化,助开发者避开“背黑锅”式Bug。(239字)

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

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

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

代码大概长这样:

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

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

看起来没毛病对吧?

代理 IP 使用小技巧 让你的数据抓取效率翻倍 (67).png

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

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

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

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

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 不是这样——默认参数的值在函数定义时就确定了,之后再也不变了。

看一个更直观的例子:

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() 只在函数定义时执行了一次。

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

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

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

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

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

Python 里的数据分为两大类:

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

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

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

  • 修改它们会改变原对象本身
  • 作为默认参数,你改了它,下次调用还是改过的状态

# 安全:数字是不可变的
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 里还有好几个地方也有同样的问题。

类属性里的可变对象:

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__ 里,每个实例独立创建:

class Order:
   def __init__(self):
       self.tags = []   # 每个实例都有自己的列表

闭包里捕获的变量:

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 社区有一个约定俗成的写法,专门解决可变对象作默认值的问题:

# 错误写法
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,在函数体里判断并创建可变对象。

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

# 列表
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 太啰嗦,可以这样写一行:

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

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

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

有的。

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

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)更靠谱:

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,然后在函数体里加一个判断。

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

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 安全还是直接用值安全?

问完这三遍,再落笔。

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

目录
相关文章
人工智能 缓存 前端开发
6274 19
人工智能 JavaScript 开发工具
3243 4
缓存 JavaScript Shell
1540 1
开发工具 Swift git
1008 1
Shell API 调度
845 2
|
13天前
|
存储 弹性计算 缓存
阿里云服务器租赁费用:新版租赁收费标准及活动报价参考
本文更新了2026年阿里云全系列云服务器租赁活动报价,所有特惠资源均可前往阿里云活动中心选购,整体覆盖从个人入门到企业级高性能场景的全梯度需求。其中轻量应用服务器主打极致性价比,2核2G峰值200M带宽配置每日10点、15点限时抢购价仅38元/年,2核4G配置379元/年起;高性价比的经济型e实例、通用算力型u2i实例覆盖2核4G至4核32G全档位,适配开发测试与中小型企业业务;搭载英特尔至强6处理器的第九代c9i企业级实例算力较上代提升20%,支撑高并发生产环境,不同实例规格价差清晰,用户可根据自身业务负载与预算灵活选型。
2114 121
阿里云服务器租赁费用:新版租赁收费标准及活动报价参考
|
14天前
|
人工智能 程序员 API
Codex 接入 DeepSeek-V4-Flash:还能补上识图,提供两套方案
Codex 接入 DeepSeek-V4-Flash 怎么配?本文覆盖 CLI 与桌面端,再用 qwen3-vl-flash 补识图,两套方案可直接照做
1755 13
安全 机器人 API
585 2
缓存 人工智能 算法
695 1

热门文章

最新文章