首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >默认参数的陷阱,每个Python新手都踩过

默认参数的陷阱,每个Python新手都踩过

原创
作者头像
风一样的男子
发布2026-06-16 11:04:19
发布2026-06-16 11:04:19
1480
举报
文章被收录于专栏:编程教程编程教程

一个让老程序员都翻过车的Bug

三年前,我在写一个电商系统的订单处理模块。

有个函数需要给商品添加标签,如果调用时没给标签列表,就自动新建一个空列表来装:

代码语言:javascript
复制
def add_tag(item, tags=[]):
    tags.append(item)
    return tags

看着没毛病吧?测试一下:

代码语言:javascript
复制
print(add_tag("电子产品"))      # ['电子产品']
print(add_tag("包邮"))           # ['电子产品', '包邮']  等等,什么?
print(add_tag("今日特价"))       # ['电子产品', '包邮', '今日特价']

我当时盯着屏幕看了十秒钟,脑子里只有一个念头:为什么第二次调用的结果里,还带着第一次的数据?

我期望的是每次不传tags时,都得到一个全新的空列表。但实际结果是,所有调用都共用同一个列表。

这还不是最离谱的。如果我把代码改一下:

代码语言:javascript
复制
def add_tag(item, tags=[]):
    tags.append(item)
    return tags

def main():
    user_tags = add_tag("电子产品")
    user_tags.append("高性价比")
    admin_tags = add_tag("包邮")
    print(admin_tags)  # ['电子产品', '高性价比', '包邮']

main()

看到这个输出,我真的想砸键盘。

这个Bug花了两个小时才定位到。而原因说来很简单:Python的默认参数在函数定义时就被创建了,之后每次调用都用的同一个对象

今天我把这个坑彻底讲清楚,包括它为什么会这样、会带来哪些隐藏问题,以及怎么正确地绕开它。


第一步:复现这个“灵异”现象

先写一个最简单的例子:

代码语言:javascript
复制
def surprise(x, data=[]):
    data.append(x)
    return data

print(surprise(1))  # [1]
print(surprise(2))  # [1, 2]
print(surprise(3))  # [1, 2, 3]

每次调用,data都在累计之前传进去的值。

再看一个用字典的例子:

代码语言:javascript
复制
def collect(key, value, cache={}):
    cache[key] = value
    return cache

print(collect("a", 1))   # {'a': 1}
print(collect("b", 2))   # {'a': 1, 'b': 2}

同样的问题。

但如果你用不可变对象做默认参数,就没事:

代码语言:javascript
复制
def greet(name, suffix="先生"):
    return name + suffix

print(greet("张"))   # 张先生
print(greet("李"))   # 李先生 —— 正常,每次都是新的"先生"

为什么字符串没问题,列表就有问题?

因为默认参数的值只计算一次,在函数定义时。

  • suffix="先生"里的"先生"是字符串(不可变),你没法修改它,所以每次用都一样,也没机会出问题
  • data=[]里的[]是列表(可变),每次调用函数时,如果你修改了这个列表,修改会保留到下一次调用

问题的本质是:默认参数不是“每次调用时创建新对象”,而是“在函数定义时创建一次对象,然后每次调用都复用这个对象”


第二步:用id()看穿真相

id()函数可以返回对象的内存地址。我们来验证一下:

代码语言:javascript
复制
def show_id(x=[]):
    print(id(x))
    return x

print(id([]))  # 随便创建一个空列表,看一个地址

show_id()  # 地址A
show_id()  # 地址A(和上一次相同!)
show_id()  # 地址A(还是同一个)

三次调用,默认参数x指向的是同一个列表对象。

对比一下:

代码语言:javascript
复制
def good(x=None):
    if x is None:
        x = []   # 每次调用到这里才创建新列表
    print(id(x))

good()  # 地址B
good()  # 地址C(不同的地址)

看到了吗?x = []这行代码在函数体里,每次调用都会执行,所以每次都创建一个全新的列表。

而默认参数x=[]只执行一次——Python读代码时,遇到def这一行,就创建好了[]这个对象,存在某个地方,每次调用都拿过来用。

一句话:默认参数在def时创建,函数体里的代码在调用时执行。


第三步:为什么Python要这样设计?

你可能想问:这不是坑吗?Python为什么不设计成“每次调用重新创建默认参数”?

这个问题有技术上的原因,也有设计上的考量。

原因1:性能

函数定义只执行一次。如果每次调用都重新创建默认参数(即使是一个空列表),会带来不必要的开销。Python很注重简单场景下的性能。

原因2:一致性

Python的函数参数绑定机制是统一的。默认参数值在函数定义时被计算并保存,这个规则很简单一致。如果改成“每次调用重新计算”,会增加语言的复杂性。

原因3:这是一个特性,不是bug

Guido van Rossum(Python之父)认为这个行为是有用的。在某些场景下,你需要函数调用之间保持状态,这个特性可以方便地实现——比如一个带缓存的函数,可以把缓存字典作为默认参数。

代码语言:javascript
复制
def expensive_query(query, cache={}):
    if query in cache:
        return cache[query]
    result = do_real_query(query)   # 耗时操作
    cache[query] = result
    return result

这个代码利用了默认参数的持久性来缓存结果,下次用同样的query就能直接返回。

当然,这个技巧比较高级,普通开发者最好还是用更明确的方式(比如类或者闭包)。

所以准确地说:这不是设计缺陷,而是一个容易被误用的特性


第四步:真实的Bug案例(比你想象的更隐蔽)

案例1:API请求参数累积

代码语言:javascript
复制
def fetch_data(url, params={}):
    params['timestamp'] = time.time()
    response = requests.get(url, params=params)
    return response

第一次调用,params是个空字典,加上timestamp后发给API。 第二次调用,params还是那个字典,里面已经有timestamp了,你又加了一个timestamp,变成了两个timestamp参数。API可能就懵了。

案例2:数据验证函数

代码语言:javascript
复制
def validate(data, errors=[]):
    if 'name' not in data:
        errors.append('缺少name字段')
    if 'age' not in data:
        errors.append('缺少age字段')
    return errors

errors1 = validate({'name': '张三'})
errors2 = validate({'age': 18})

print(errors1)  # ['缺少age字段']
print(errors2)  # ['缺少age字段', '缺少name字段']  —— 混了

第二个调用的错误列表里,包含了第一次调用产生的错误。因为用的是同一个列表。

案例3:类实例化的坑

代码语言:javascript
复制
class Order:
    def __init__(self, items=[]):
        self.items = items

order1 = Order()
order1.items.append("手机")

order2 = Order()
print(order2.items)  # ['手机'] —— 不是空列表!

这个坑尤其隐蔽。你想让每个订单都有自己独立的商品列表,结果所有订单都共享了同一个列表。

修复:

代码语言:javascript
复制
class Order:
    def __init__(self, items=None):
        if items is None:
            items = []
        self.items = items

第五步:标准解法(None占位符)

正确的写法非常固定:

代码语言:javascript
复制
def good_function(param=None):
    if param is None:
        param = []
    # 现在可以放心修改param了
    param.append("something")
    return param

但要注意一个细节:如果调用方可能主动传入None作为合法值怎么办?

代码语言:javascript
复制
def process(data=None):
    if data is None:
        data = []   # 如果调用方传入None,也会被替换成空列表

如果你需要区分“没传参数”和“传了None”,可以这样做:

代码语言:javascript
复制
_sentinel = object()  # 创建一个唯一的标识

def process(data=_sentinel):
    if data is _sentinel:
        data = []   # 只有没传参数时才创建新列表
    # 如果调用方传了None,data就是None,不会被替换

这种写法很少用到,绝大多数场景用None占位符就够了。

为什么用None?

因为None是Python中表示“没有值”的标准方式。它不可变,安全,而且语义清晰。看到param=None,有经验的Python开发者立刻知道这是为了避免可变默认参数的陷阱。

什么类型需要这样做?

所有可变类型:

  • 列表 []
  • 字典 {}
  • 集合 set()
  • 自定义类的实例(如果会被修改)
  • 任何可能在函数内部被修改的对象

不可变类型(字符串、整数、浮点数、元组、布尔值)不需要这个技巧,因为函数内部修改不了它们。


第六步:类型注解怎么写才规范?

现代Python有类型注解,和None默认值配合时有个常见的困惑:

代码语言:javascript
复制
def process(items: list = None) -> list:  # 这样写对吗?
    if items is None:
        items = []
    items.append("x")
    return items

Pylance/Pyright可能会报警:None类型和list类型不匹配。

正确写法:

代码语言:javascript
复制
from typing import Optional, List

def process(items: Optional[List] = None) -> List:
    if items is None:
        items = []
    items.append("x")
    return items

Python 3.10+可以用更简洁的写法:

代码语言:javascript
复制
def process(items: list | None = None) -> list:
    if items is None:
        items = []
    items.append("x")
    return items

| None明确表示这个参数可以是None。


第七步:哪些函数内部可以安全地修改默认参数?

只有一种情况是安全的:你明确知道自己在做什么,并且是故意利用这个特性

比如前面提到的缓存函数:

代码语言:javascript
复制
def memoize(func):
    cache = {}
    def wrapper(n):
        if n not in cache:
            cache[n] = func(n)
        return cache[n]
    return wrapper

# 或者更直接的:
def fibonacci(n, memo={}):
    if n in memo:
        return memo[n]
    if n <= 1:
        return n
    memo[n] = fibonacci(n-1, memo) + fibonacci(n-2, memo)
    return memo[n]

这种情况下,你确实希望memo字典在调用之间保持状态。

但即使这样,更好的写法仍然是:

代码语言:javascript
复制
def fibonacci(n, memo=None):
    if memo is None:
        memo = {}
    if n in memo:
        return memo[n]
    # ... 其余代码相同

这样更清晰,也避免了副作用带来的困惑。

经验法则:除非有充分的理由(并且写清楚注释),否则永远用None占位符。


第八步:检查工具帮你自动发现问题

现代Python工具可以自动检测这个坑:

Pylint 会警告:W0102: dangerous-default-value

Flake8 通过插件可以检测

Pyright / mypy 静态类型检查也能发现不一致

如果你用VSCode + Pylance,默认就有相关提示。

很多公司代码规范里明确规定:禁止使用可变对象作为默认参数


一个完整的对比总结

代码语言:javascript
复制
# 错误写法 —— 会累积状态
def bad_append(item, target=[]):
    target.append(item)
    return target

# 正确写法 —— 每次都重新创建
def good_append(item, target=None):
    if target is None:
        target = []
    target.append(item)
    return target

# 测试
print(bad_append(1))   # [1]
print(bad_append(2))   # [1, 2]  ← 出问题了

print(good_append(1))  # [1]
print(good_append(2))  # [2]     ← 正常

场景

正确做法

错误做法

默认值用空列表

def f(x=None): if x is None: x = []

def f(x=[]):

默认值用空字典

def f(x=None): if x is None: x = {}

def f(x={}):

默认值用空集合

def f(x=None): if x is None: x = set()

def f(x=set()):

默认值用字符串

def f(x="")(安全,不需要特殊处理)

默认值用整数

def f(x=0)(安全)


最后总结

这个坑的核心就一句话:

默认参数的值在函数定义时创建一次,之后每次调用都复用。

  • 如果默认参数是可变对象(列表、字典、集合),你在函数内部修改它,修改会保留到下一次调用
  • 这会导致非常隐蔽的bug,尤其是当你以为“每次调用都是新的”的时候

黄金法则:

永远不要用可变对象作为函数的默认参数。

正确做法:

代码语言:javascript
复制
def func(param=None):
    if param is None:
        param = []  # 或者 {} / set()
    # 现在可以放心修改param了

现在我写任何函数,只要参数有默认值,都会下意识地问自己:这个默认值可变吗?如果是列表、字典、集合,立刻改成None模式。

这个习惯救了我无数次。希望也能救你。

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

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

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

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

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一个让老程序员都翻过车的Bug
  • 第一步:复现这个“灵异”现象
  • 第二步:用id()看穿真相
  • 第三步:为什么Python要这样设计?
  • 第四步:真实的Bug案例(比你想象的更隐蔽)
    • 案例1:API请求参数累积
    • 案例2:数据验证函数
    • 案例3:类实例化的坑
  • 第五步:标准解法(None占位符)
  • 第六步:类型注解怎么写才规范?
  • 第七步:哪些函数内部可以安全地修改默认参数?
  • 第八步:检查工具帮你自动发现问题
  • 一个完整的对比总结
  • 最后总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档