Python 为什么不建议使用可变对象作为函数默认参数?
简化版
因为 Python 的默认参数在函数定义时求值一次,不是每次调用都重新创建。把列表、字典、集合这类可变对象作为默认参数,会导致多次调用共享同一个对象,产生意外状态残留。
详细版
典型错误写法:
def add_item(item, items=[]):
items.append(item)
return items
print(add_item("a")) # ['a']
print(add_item("b")) # ['a', 'b']
很多人期待第二次调用返回 ['b'],但实际返回 ['a', 'b']。原因是 items=[] 只在函数定义时执行一次,之后每次没有传 items 时都会复用同一个列表。
推荐写法是使用 None 作为哨兵值:
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
这样每次没有传入列表时,函数内部都会创建一个新的列表。
需要注意:默认参数不是不能用,而是不要用“会被修改的可变对象”做默认值。不可变对象如 None、数字、字符串、元组通常没问题。
完整版教学
一、默认参数的求值时机
Python 函数定义语句执行时,会创建函数对象,同时计算默认参数表达式。
def f(x=[]):
return x
执行到这段代码时,[] 会被创建一次,并保存在函数对象的默认参数里。之后每次调用 f(),如果调用者没有传 x,Python 就取出这同一个列表对象作为参数。
可以用 __defaults__ 观察:
def f(x=[]):
x.append(1)
return x
print(f.__defaults__) # ([],)
f()
print(f.__defaults__) # ([1],)
默认列表本身被修改了,所以函数对象里的默认值也变了。
二、为什么这个设计存在
这不是 Python 的 bug,而是语言设计的一部分。默认值在定义时求值,有两个好处:
- 不需要每次调用都重新计算默认表达式;
- 默认值可以捕获定义时的对象或配置。
例如:
import time
def created_at(ts=time.time()):
return ts
这里 ts 是函数定义时的时间,不是每次调用的时间。它可能不是你想要的行为,但它是符合规则的。
同理:
def connect(timeout=3):
...
3 是不可变整数,不会被修改,所以没有风险。
三、可变默认参数的典型坑
最常见的是列表累积:
def collect(value, bucket=[]):
bucket.append(value)
return bucket
还有字典缓存误用:
def set_option(key, value, options={}):
options[key] = value
return options
第二个函数每次调用都会污染同一个 options 字典。
这类问题难排查,因为单次调用看起来正常,多次调用或单元测试顺序变化时才会暴露。
四、标准写法:None 哨兵
更稳的写法是:
def collect(value, bucket=None):
if bucket is None:
bucket = []
bucket.append(value)
return bucket
为什么用 is None,而不是 if not bucket?
因为空列表、空字典、空字符串都是假值。调用者可能明确传入一个空列表,函数应该尊重这个参数,而不是误以为没有传。
def f(items=None):
if items is None:
items = []
这个判断表达的是“参数缺省”,不是“参数内容为空”。
五、什么时候可变默认参数反而能用
少数情况下,开发者会有意利用这个特性做缓存:
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
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
显式工具比隐藏在默认参数里的状态更容易读懂,也更容易维护。
六、常见误区与追问
易错点:默认参数挂在函数对象上,而不是挂在“某一次调用”上。看到
def f(x=[]),要把它想成函数对象里长期保存着同一个列表。
- 误区:默认参数会在每次调用函数时重新创建。 Python 是在执行
def语句、创建函数对象时计算默认值;后续调用缺省参数时复用这个对象,所以列表、字典、集合会留下历史修改。 - 追问:为什么
None哨兵要用is None判断? 因为None是单例,is表达身份判断;同时空列表、空字典、0、空字符串都是假值,用if not items会把调用者明确传入的空容器误判成“没传”。 - 误区:所有默认参数都危险。 不可变对象如整数、字符串、
None通常安全;真正危险的是默认对象会被原地修改,例如append、update、add。 - 追问:默认参数里写
time.time()会发生什么? 它会在函数定义时执行一次,而不是每次调用都更新时间;如果需要调用时刻,应把默认值设为None,在函数体内调用time.time()。 - 误区:可变默认参数永远不能出现。 它可以被刻意用作隐藏缓存,但这会把状态藏在函数签名里,工程上通常不如
lru_cache、实例属性或显式缓存对象清楚。 - 追问:怎么在排查线上问题时确认是不是默认参数共享? 可以打印
func.__defaults__或连续调用两次最小复现;如果第二次结果带着第一次的数据,基本就是默认可变对象被复用了。
七、加强记忆
默认参数不是“调用时创建”,而是“定义时创建”。不可变默认值通常安全,可变默认值一旦被修改,就会在多次调用之间共享状态。写函数时先问自己:这个默认对象会不会被改?如果会,就用 None 做哨兵,在函数内部创建新对象。