Python的默认参数把我坑惨了,原来写[]和写None的区别这么大

小张花了整整一个下午,就为了找一个bug。

他写了一个函数,用来给用户添加标签。逻辑很简单:传入用户ID和标签,把标签追加到用户的标签列表里。代码大概长这样:

复制代码
def add_tag(tag, tags=[]):
    tags.append(tag)
    return tags

print(add_tag("Python"))
print(add_tag("Java"))
print(add_tag("Go"))

他预期的输出是:

复制代码
['Python']
['Java']
['Go']

实际输出是:

复制代码
['Python']
['Python', 'Java']
['Python', 'Java', 'Go']

小张盯着屏幕,怀疑自己是不是没睡醒。他明明每次调用都没传第二个参数,为什么列表里的内容会累积?难道Python的默认参数不是每次调用都重新创建一个空列表吗?

答案是:不是。

这个坑,几乎每个Python开发者都踩过。它藏在Python最基础的语法里,却能让老手也翻车。

先把问题复现清楚

为了看清这个问题的本质,我们把代码稍微改一下:

复制代码
def add_tag(tag, tags=[]):
    print(f"调用前 tags 的 id: {id(tags)}")
    tags.append(tag)
    print(f"调用后 tags 的内容: {tags}")
    return tags

print("--- 第一次调用 ---")
add_tag("Python")
print("--- 第二次调用 ---")
add_tag("Java")
print("--- 第三次调用 ---")
add_tag("Go")

输出:

复制代码
--- 第一次调用 ---
调用前 tags 的 id: 140234567890
调用后 tags 的内容: ['Python']
--- 第二次调用 ---
调用前 tags 的 id: 140234567890
调用后 tags 的内容: ['Python', 'Java']
--- 第三次调用 ---
调用前 tags 的 id: 140234567890
调用后 tags 的内容: ['Python', 'Java', 'Go']

注意那个id,三次调用完全一样。也就是说,tags这个默认参数从头到尾都是同一个列表对象。第一次调用往里面加了"Python",第二次调用时它还在,所以又加了"Java",第三次继续累积。

问题的根源在于:Python的默认参数在函数定义时就被求值了,而且只求值一次。

默认参数到底在什么时候被创建?

很多人下意识地认为,默认参数是每次调用函数时"临时"创建的。比如调用add_tag("Python")时,Python发现第二个参数没传,于是创建一个空列表[],把它赋给tags。下次调用时再创建一个新的。

但真实情况不是这样。

Python在执行def语句时,会把这个函数的默认参数值计算出来,然后保存在函数对象的一个属性里。这个属性叫__defaults__。之后每次调用函数,如果某个参数没有传值,Python就直接从__defaults__里取出那个对象来用。

我们用代码验证一下:

复制代码
def add_tag(tag, tags=[]):
    tags.append(tag)
    return tags

print(add_tag.__defaults__)

输出:

复制代码
([],)

第一次调用之前,__defaults__里存的是一个空列表。调用add_tag("Python")之后,这个列表被修改了。再看一次:

复制代码
add_tag("Python")
print(add_tag.__defaults__)

输出:

复制代码
(['Python'],)

__defaults__里的列表已经被改变了。第二次调用时,Python拿到的就是这个已经被修改过的列表,而不是一个新的空列表。

整个过程可以这样理解:def语句执行的那一刻,Python看到了tags=[],于是创建了一个列表对象,把它存进函数的默认参数表里。之后这个列表对象就一直跟着这个函数,直到函数被销毁。每次调用不传参数,用的都是同一个列表。

这就好比你开了一家店,门口放了一个意见箱。你本来想的是"每个顾客来了都放一个新箱子",但实际上你只在开店那天放了一个箱子,之后就再也没换过。所有顾客的意见都塞进了同一个箱子里。

为什么写None就没问题?

知道了原因,解决方案就很直接了:不要用可变对象作为默认参数。

复制代码
def add_tag(tag, tags=None):
    if tags is None:
        tags = []
    tags.append(tag)
    return tags

print(add_tag("Python"))
print(add_tag("Java"))
print(add_tag("Go"))

输出:

复制代码
['Python']
['Java']
['Go']

这次对了。为什么?

因为None是一个不可变对象。函数定义时,__defaults__里存的是None。每次调用时,如果没传参数,Python取出None赋给tags。然后函数内部检查tags is None,如果是,就创建一个新的空列表。

关键的区别在于:新建列表这个动作发生在函数调用时,而不是函数定义时 。每次调用都会执行tags = [],所以每次都是一个全新的列表。

这里有一个常见的写法误区:

复制代码
def add_tag(tag, tags=None):
    tags = tags or []
    tags.append(tag)
    return tags

看起来没问题,但有个隐患:如果调用者传了一个空列表[]进来,tags or []会返回一个新的空列表,而不是使用调用者传进来的那个。调用者的列表不会被修改。这可能会导致意外的行为。所以推荐用is None判断,而不是用or。

不只列表,字典、集合都有这个问题

[]是最常见的坑,但绝不是唯一的。任何可变对象作为默认参数都有同样的问题。

字典:

复制代码
def add_config(key, value, config={}):
    config[key] = value
    return config

print(add_config("a", 1))
print(add_config("b", 2))

输出:

复制代码
{'a': 1}
{'a': 1, 'b': 2}

集合:

复制代码
def add_item(item, items=set()):
    items.add(item)
    return items

print(add_item("a"))
print(add_item("b"))

输出:

复制代码
{'a'}
{'a', 'b'}

自定义对象也一样:

复制代码
class Logger:
    def __init__(self):
        self.logs = []

def log(message, logger=Logger()):
    logger.logs.append(message)
    return logger.logs

print(log("第一条"))
print(log("第二条"))

输出:

复制代码
['第一条']
['第一条', '第二条']

规律是一样的:只要默认参数是可变对象,它就会被所有调用共享。

为什么Python要这样设计?

看到这里,你可能会问:Python为什么要这么设计?这不是故意坑人吗?

其实这个设计是有原因的。Python的默认参数值是在函数定义时求值的,这意味着一件很重要的事情:默认参数可以依赖函数定义时的上下文。

比如:

复制代码
import datetime

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

log("程序启动")

这里datetime.datetime.now()在函数定义时被调用了一次,之后每次调用log都用的是同一个时间戳。这可能不是你想要的效果,但它展示了默认参数的一个特性:它是在定义时确定的,不是调用时。

再比如,默认参数可以引用之前定义的变量:

复制代码
DEFAULT_TIMEOUT = 30

def connect(host, timeout=DEFAULT_TIMEOUT):
    pass

如果默认参数在每次调用时才求值,那么DEFAULT_TIMEOUT如果在之后被修改,connect的默认行为也会跟着变。但因为在定义时求值,函数的行为就固定下来了。

Python选择"定义时求值"而不是"调用时求值",是为了让函数的行为更可预测。但代价就是,可变默认参数会被共享。

这个设计本身没有错,错的是我们使用它的方式。可变对象作为默认参数,几乎从来都不是我们想要的行为。

这个坑为什么这么难发现?

这个问题的隐蔽性在于:它在简单场景下不会暴露。

如果你只调用一次函数,完全没问题。如果你每次调用都传了第二个参数,也没问题。只有当你不传参数、并且多次调用时,问题才会显现。

更麻烦的是,它可能不会立刻出错。数据会静默地累积,直到某个时刻才引发异常。比如你写了一个处理请求的函数,默认参数是个字典,用来存缓存。你以为每次请求都是独立的,结果上一个请求的数据泄漏到了下一个请求里。这种bug在测试环境可能永远发现不了,一到生产环境就出问题。

还有一个让人困惑的地方:默认参数在函数定义时只求值一次这条规则,很多人其实"知道",但没有真正理解。考试能答对,写代码时照样踩坑。因为直觉上,"默认值"听起来就像是每次调用时才会用到的东西。

怎么彻底避免这个问题?

最根本的原则只有一条:永远不要用可变对象作为默认参数。

具体来说,以下这些都不能作为默认参数:

  • []

  • {}

  • set()

  • bytearray()

  • 任何自定义的可变对象

  • 任何包含可变对象的容器

正确的做法是:默认值用None,在函数体内判断并创建。

复制代码
def process(items=None):
    if items is None:
        items = []
    # 继续处理

这个模式适用于所有情况。

如果你觉得每次都写if xxx is None太麻烦,可以用一个辅助函数:

复制代码
def ensure_list(value):
    return [] if value is None else value

def add_tag(tag, tags=None):
    tags = ensure_list(tags)
    tags.append(tag)
    return tags

但说实话,多写两行代码换来的是代码行为的确定性,这笔买卖很划算。

有没有故意用可变默认参数的情况?

有,但很少,而且通常不值得。

有些人利用可变默认参数来实现缓存:

复制代码
def fib(n, cache={0: 0, 1: 1}):
    if n not in cache:
        cache[n] = fib(n - 1) + fib(n - 2)
    return cache[n]

这段代码能跑,而且性能不错。但它有几个问题:缓存无法清空、无法限制大小、多线程不安全。真要缓存,用functools.lru_cache更清晰:

复制代码
from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

还有人用可变默认参数来记录函数被调用的历史:

复制代码
def track_call(arg, history=[]):
    history.append(arg)
    return history

但这种"隐藏状态"让函数的行为变得不可预测,调试起来很痛苦。不如显式传一个列表进来,或者用类来管理状态。

在绝大多数情况下,可变默认参数带来的便利远远小于它带来的风险。所以业界共识就是:别用。

一个容易忽略的变体:类属性

和默认参数类似,类属性也有共享的问题:

复制代码
class User:
    tags = []

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

u1 = User()
u2 = User()
u1.add_tag("Python")
print(u2.tags)

输出:

复制代码
['Python']

u2的tags里居然有u1添加的标签。因为tags = []是类属性,所有实例共享同一个列表。解决方案是在__init__里初始化:

复制代码
class User:
    def __init__(self):
        self.tags = []

这个坑和默认参数的坑本质上是同一个问题:可变对象被多个上下文共享。

再回到小张的故事

小张最后把代码改成了:

复制代码
def add_tag(tag, tags=None):
    if tags is None:
        tags = []
    tags.append(tag)
    return tags

问题解决了。他顺手查了一下项目里还有没有类似的写法,结果发现了七八处。有的是默认参数用了{},有的是类属性用了[],还有一处是默认参数用了datetime.now(),导致所有日志的时间戳都一样。

他花了一个小时全部改完,然后在团队群里发了一条消息:"以后写默认参数,别用[],用None。别问我怎么知道的。"

这个坑不会报错,不会崩溃,只会在某个不经意的时刻让你怀疑人生。而避免它的成本,只是多写一行if xxx is None。

Python的默认参数在定义时求值,而且只求值一次。可变对象一旦被共享,所有调用都会互相影响。写[],你得到的是一个共享的列表;写None,你得到的是每次调用都新建的列表。

区别就是这么大。

相关推荐
微小冷1 小时前
Python凸优化cvxpy初步
python·机器人·凸优化·数学规划·cvxpy
在世修行1 小时前
干货:显式映射 vs 自动判据
python·插件
xzal121 小时前
Python之简单理解栈和队列
python
DeepAgent2 小时前
AI Agent 工程实践(49):一次真实优化——从 Agent v1 到 v2
开发语言·人工智能·agent
知识分享小能手2 小时前
C++ 学习教程,从入门到精通,C++对象和类 — 详细知识点总结(10)
开发语言·c++·学习
lupai2 小时前
维修保养记录精准版 API 对接实战指南
数据库·python
罗西的思考2 小时前
[Agent Memory / 强化学习] MemPO源码学习笔记 ---(5)--- GRPO
人工智能·算法·机器学习
郑州光合科技余经理2 小时前
同城电商系统:库存变更怎么同步到订单
java·开发语言·前端·后端·uni-app·php·ai编程
水水不水啊2 小时前
告别杂乱的调试窗口:我用 Python + WebView 写了一个现代化串口助手
python·测试工具·嵌入式·嵌入式开发·串口调试·串口助手