Python默认参数是在定义时还是调用时求值
深入解析Python函数默认参数的求值时机,通过代码示例、底层__defaults__属性原理及内联SVG图示,讲解可变对象陷阱与None哨兵解决方案。
在 Python 中,函数的默认参数是在函数定义时(即 def 语句执行时)求值一次,而不是在每次调用函数时求值。这个求值结果会被永久保存在函数对象自身的属性中,并在后续每次未传对应实参的调用中被直接复用。
一、核心结论与现象演示
许多初学 Python 或从其他语言转过来的开发者,往往会直觉性地认为默认参数应该在每次函数调用时重新计算。然而 Python 的机制截然不同:当解释器运行到 def 语句块时,它会立刻对函数签名中的所有默认值表达式进行求值,并将生成的对象存入内存。
如果默认参数是一个可变对象(Mutable Object)(如列表 list、字典 dict、集合 set),多次调用函数且省略该参数时,所有调用都将共享这同一个在定义时创建的对象。这就会导致调用间的“状态残留”与意外累加。
请看下面这个最经典的例子:
def append_item(element, target_list=[]):
target_list.append(element)
return target_list
# 连续调用测试
print(append_item("A")) # 输出: ['A']
print(append_item("B")) # 输出: ['A', 'B'] —— 并非预期的 ['B']
print(append_item("C")) # 输出: ['A', 'B', 'C']
在上面的代码中,第二次调用 append_item("B") 并没有像预期的那样得到仅包含 'B' 的新列表,而是把 'B' 追加到了前一次调用保留的列表中。这是因为默认值 [] 仅在定义函数时被创建了一次,后续每次缺省调用都指向同一个列表实例。
二、底层原理:函数对象的 __defaults__ 属性
要彻底理解这一机制,需要了解 Python 的执行过程:
- def 是一条可执行语句:当 Python 解释器扫描到
def foo(x=1): ...时,它不仅声明了函数名,还会立刻在当前命名空间中执行默认参数表达式并创建一个函数对象。 - 默认参数被固化保存在属性中:求值产生的值会被打包成一个元组,存放在函数对象的
__defaults__属性中(针对仅限关键字参数,则存放在__kwdefaults__字典中)。 - 调用时按索引提取引用:当调用函数且省略了某个带默认值的形参时,Python 只是简单地从
__defaults__元组中按位置取出对象引用传递给函数内部局部变量,而绝不会重新执行当初定义该默认值的代码。
我们可以直接在交互式命令行中查看这个属性:
# 查看函数创建后的默认参数属性
print(append_item.__defaults__)
# 输出: ([],) —— 一个包含单个空列表的元组
append_item("第一项")
print(append_item.__defaults__)
# 输出: (['第一项'],) —— 元组内的列表已经被就地修改
append_item("第二项")
print(append_item.__defaults__)
# 输出: (['第一项', '第二项'],)
从以上结果可以清晰看到,append_item.__defaults__[0] 的内容随着函数运行被持续修改。这就证实了函数对象所持有的默认值确实是同一个对象实例。
三、另一个常见陷阱:动态时变表达式
除了列表和字典等可变对象外,“在定义时求值”的机制还会影响动态时变表达式。例如获取当前时间 time.time()、datetime.now(),或生成随机数的表达式。
考虑以下记录日志的函数:
import time
from datetime import datetime
def log_event(message, log_time=datetime.now()):
print(f"[{log_time.strftime('%H:%M:%S')}] {message}")
# 间隔测试
log_event("服务启动")
time.sleep(3)
log_event("接收到新请求")
time.sleep(3)
log_event("任务处理完毕")
在实际运行这段代码时,你会发现三次输出中的时间戳完全相同!这是因为 datetime.now() 仅在 def log_event 语句被加载执行的那一毫秒被计算过一次。随后的每一次调用,即使间隔数小时,默认取出来的都是当时定义时刻的旧时间。
四、标准解决方案:None 哨兵值与惰性初始化
既然默认参数在定义时只求值一次,那么如何在需要“每次调用创建全新独立对象”或“每次调用获取当前状态”时正确编写代码?
Python 官方推荐且最符合习惯的模式是:使用不可变对象 None 作为默认值(哨兵值),并在函数体内部进行动态判断与惰性初始化。
1. 针对可变对象(列表、字典、集合)的修复
def append_item(element, target_list=None):
# 每次调用时若未传参,在函数体内动态创建新列表
if target_list is None:
target_list = []
target_list.append(element)
return target_list
print(append_item("A")) # 输出: ['A']
print(append_item("B")) # 输出: ['B'] —— 正确隔离!
2. 针对动态时变表达式的修复
def log_event(message, log_time=None):
# 惰性求值:在真正调用发生的时刻获取最新时间
if log_time is None:
log_time = datetime.now()
print(f"[{log_time.strftime('%H:%M:%S')}] {message}")
if target_list is None:,而不要写成 if not target_list:。因为如果调用者显式传入了合法的空列表 [],使用 not 判断会错误地覆盖调用者的输入并重新赋值。
五、设计初衷:Python 为什么这样设计?
既然定义时求值容易引起误解,Python 官方设计团队为何坚持这种机制?主要原因包含以下两点:
- 性能与执行效率考量:如果每次调用函数都要重新解析、编译并计算默认参数的表达式,会带来显著的函数调用开销。定义时求值让默认参数在函数创建时一次性计算完毕,调用时只需通过指针访问,速度极快。
- 一致的执行哲学:在 Python 语言中,
def本身就是一个普通的动态执行语句。函数是一等公民,与变量赋值一样遵循“执行赋值语句时对右侧表达式求值”的普适规律。
巧妙利用:何时可以故意利用定义时求值?
定义时求值不仅不是缺陷,在特定场景下还可以作为强大的设计工具:
- 解决循环中闭包的晚期绑定问题:在循环中创建 lambda 或局部函数时,利用默认参数将当前循环变量固化下来:
funcs = [lambda x, i=i: x + i for i in range(3)] print([f(10) for f in funcs]) # 输出: [10, 11, 12](若不用 i=i 默认参数,将输出 [12, 12, 12]) - 局部缓存与查找优化:在频繁调用的递归或数学函数中,将耗时的模块级查找或局部缓存字典挂载到默认参数上,避免全局变量污染。
六、总结与选型决策指南
在日常 Python 编码中,何时直接书写默认值,何时应当使用 None 哨兵?下表和决策图给出了清晰的判断依据:
| 默认参数类型 | 典型示例 | 推荐处理模式 | 底层原因 |
|---|---|---|---|
| 不可变基本类型 | count=0, name="", flag=True, mode=(1, 2) |
直接在函数签名中指定 | 不可变对象无法就地修改,天然安全无副作用 |
| 可变容器类型 | items=[], options={}, tags=set() |
指定为 None,体内惰性初始化 |
防止多次调用共享同一个容器产生状态污染 |
| 动态时变表达式 | now=datetime.now(), seed=random.random() |
指定为 None,体内实时获取 |
防止定义时刻的结果被永久固化在函数对象中 |
| 闭包变量固化 | lambda x, i=i: x + i |
利用默认参数提前绑定当前值 | 借用定义时求值特性解决循环变量延迟绑定 |
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









