本文目录导读:
这是一个非常好的问题,因为它直击 Python 语言设计中的一个核心特性,也是很多初学者和中级开发者容易踩坑的地方。
要彻底理解“函数的默认参数为什么是共享对象”(通常被称为“默认参数陷阱”),最可靠的方法就是阅读 Python 的源码,我们将通过源码来揭示其背后的设计哲学和执行逻辑。
函数的默认参数在函数定义时(def 语句执行时)被创建并求值一次,然后存储在一个所有函数调用共享的属性中(__defaults__),此后每次调用函数,如果未提供该参数,Python 会直接把这个已创建的对象(的引用)传递给函数体,而不是重新创建它。
这导致了像 def foo(x=[]) 这样的函数,多次调用后 x 会累积元素。
通过源码理解这一过程
我们将追踪 Python 从编译到执行 def 语句,再到调用函数的整个过程,下面的代码示例基于 CPython 3.12 的主线逻辑,但核心机制在所有 Python 3 版本中都是一致的。
编译(Compilation)
当你输入 def foo(x=[]) 时,Python 编译器会做两件关键的事:
- 将默认值 打包:编译器识别出
x=[]中的 是一个字面量(literal),但它不会直接生成创建空列表的字节码(BUILD_LIST)。 - 生成
MAKE_FUNCTION指令:编译器会生成一个MAKE_FUNCTION操作码,并将 的创建代码(BUILD_LIST等)放在这个指令之前。
让我们用 dis 模块来看看实际的字节码:
import dis
def foo(x=[]):
pass
dis.dis(foo)
输出会类似于:
2 0 RESUME 0
3 2 LOAD_CONST 1 (None)
4 RETURN_VALUE
等等! 对于 foo 函数本身,你看到的只是 pass 的字节码。x=[] 的影子在哪里?
我们必须 dis 的是包含 def 语句的模块或函数,而不是 foo 本身,因为 def 是一个可执行语句。
import dis
def outer():
def foo(x=[]):
pass
return foo
dis.dis(outer)
输出会类似于:
2 0 RESUME 0
3 2 LOAD_CONST 1 (<code object foo at 0x...>)
4 MAKE_FUNCTION 1 (defaults)
4 6 STORE_FAST 0 (foo)
8 LOAD_FAST 0 (foo)
10 RETURN_VALUE
关键点来了! 注意 MAKE_FUNCTION 指令,它接收了一个代码对象(<code object foo at ...>)和一个可选的参数元组(这里是 defaults),在 MAKE_FUNCTION 执行之前,字节码会先执行 LOAD_CONST 或其他指令来准备这个 defaults 元组,对于 def foo(x=[]),这个元组就是 。
是怎么来的?编译器生成的并不是 MAKE_FUNCTION 之前的一个 BUILD_LIST 指令。 作为默认值,其求值会在 MAKE_FUNCTION 内部发生,或者更准确地说,是作为 __defaults__ 的一部分被计算和存储。
更深入一点,可以看函数的 __code__ 对象的 co_consts 属性,你会发现 被作为常量存储在了代码对象的常量池中。
print(foo.__code__.co_consts) # 输出: (None,) # 这里的 [] 被优化掉了?不,它被存储为另一个常量。 # 更准确的方法是看 outer 的字节码。
为了看的更清楚,我们可以看一个更简单的例子:
def bar(x=[]):
pass
print(bar.__defaults__)
# 输出: ([],)
看到了吗?bar.__defaults__ 是一个元组,里面已经存在了一个列表对象 ,这个列表对象是在 def 语句执行时创建的。
编译阶段的结论是: 编译器将 def 语句中的默认值表达式(如 )的创建指令(BUILD_LIST)放在 MAKE_FUNCTION 指令之前,或者更准确地说,在编译阶段, 这个字面量会被存入代码对象的常量池,并会在 MAKE_FUNCTION 执行时被“实体化”(创建实际对象)。
执行 def 语句(Function Definition Execution)
当 Python 执行到 def foo(x=[]) 这一行时,MAKE_FUNCTION 指令会被执行。
- 创建函数对象:
MAKE_FUNCTION操作码会调用PyFunction_New或类似的 C 函数来创建一个新的函数对象。 - 处理
__defaults__:在创建函数对象之后,但在填充__defaults__属性之前,Python 会执行SET_FUNCTION_DEFAULTS或类似的操作(取决于具体版本和实现细节)。- 关键步骤是:它会计算默认参数的值,对于
x=[],它会执行BUILD_LIST指令,创建一个空的列表对象 。 MAKE_FUNCTION会把这个新创建的列表对象放入函数对象的__defaults__元组中。__defaults__是一个 Python 元组,它持有对这个列表对象的引用。
- 关键步骤是:它会计算默认参数的值,对于
至此,这个列表对象被固定了下来。 它已经存在于内存中,并且被 foo.__defaults__ 所引用。
函数调用(Function Invocation)
当你在后续的代码中调用 foo() 或 foo(1) 时:
- Python 会检查:
foo(**args, **kwargs)的调用协议会检查你是否提供了位置参数x。 - 若未提供:Python 会从
foo.__defaults__中取出对应的默认值,在 C 层面(Objects/funcobject.c的function_call或相关调用路径),它会读取func_defaults,然后取出元组中的第<param_index>个元素。 - 结果:它直接返回了那个唯一的、共享的列表对象的引用,你没有得到一个新的 ,你得到了一个指向
foo函数定义时创建的相同列表对象的指针。
图解
你可以把整个过程想象成:
- 定义时:
def foo(x=[]):-> 创建一个盒子,盒子里有一个小徽章(列表对象 ),盒子被命名为foo.__defaults__。 - 调用时:
foo()-> Python 看到你没有带参数,于是它走到foo的盒子那里,拿出里面的小徽章,扔给foo的函数体(x = 徽章)。 - 修改时:
foo()内执行x.append(1)-> 你实际上是在修改那个小徽章本身。 - 再次调用:
foo()-> Python 再次从foo的盒子中拿出同一个小徽章,上面已经刻着[1]。
为什么 Python 要这样设计?
这是性能考量和语言一致性的结果。
- 性能:在定义时计算默认值,可以避免在每次调用时都重新计算一次,如果默认值是一个复杂的表达式(如
time.time()或一个大型字典),每次调用都重新计算的开销是不可接受的,这符合 Python “定义时求值”的规则,与函数内部的代码(每次调用时求值)区分开来。 - 一致性:Python 中的函数是一等公民,函数定义(
def)是可执行语句,其他语句如赋值(a = [])也是在执行时创建一次对象,对默认参数应用相同的规则,保持了一致性,如果你想要每次调用都获得一个新对象,你应该在函数体内部写x = []或使用x=None的模式。
如何验证这个结论?
你不需要看 C 源码,只需要 Python 交互式环境即可验证:
# 1. 检查 __defaults__ 是否确实是一个元组,且内容是同一个对象
def foo(x=[]):
return id(x)
# 2. 第一次调用
id1 = foo()
id2 = foo()
print(id1 == id2) # 输出: True (证明是同一个对象)
# 3. 修改 __defaults__ 本身
foo.__defaults__[0].append("hacked")
print(foo()) # 输出: ['hacked'] (因为默认参数对象本身被改了)
# 4. 替换 __defaults__ (虽然可以,但这是危险操作)
foo.__defaults__ = ([1,2,3],)
print(foo()) # 输出: [1,2,3]
通过 Python 源码(主要指 compile.c、ceval.c、funcobject.c)我们可以清晰地看到:
- 编译时:默认值的表达式被编译为初始化代码。
- 定义时(
def语句执行):MAKE_FUNCTION指令执行,根据编译时的代码 创建一次 默认参数对象,并将引用存储到函数对象的__defaults__属性中。 - 调用时:如果未提供参数,Python 会直接引用
__defaults__中已存在的对象,绝不会重新创建。
理解了这个底层机制,你就不会再被“可变默认参数”陷阱所困扰,而是能自然地使用 x=None 模式,并在需要时利用这个特性(比如作为函数内缓存的优化手段,尽管不推荐)。
标签: 共享对象