“最小惊讶原则”与可变默认参数

3419 投票
34 回答
267086 浏览
提问于 2025-04-15 12:55

任何玩过Python的人都可能遇到过这样一个问题,这个问题让人很困惑,甚至有点痛苦:

def foo(a=[]):
    a.append(5)
    return a

对于Python的新手来说,他们可能会期待这个没有参数的函数总是返回一个只有一个元素的列表:[5]。但实际上,结果却大相径庭,让新手感到非常惊讶:

>>> foo()
[5]
>>> foo()
[5, 5]
>>> foo()
[5, 5, 5]
>>> foo()
[5, 5, 5, 5]
>>> foo()

我有个经理第一次遇到这个情况时,称其为这个语言的“戏剧性设计缺陷”。我告诉他,这种行为是有原因的,如果你不了解内部机制,确实会让人感到困惑和意外。不过,我自己也无法回答一个问题:为什么默认参数是在函数定义时绑定,而不是在函数执行时绑定?我怀疑这种经验上的行为是否真的有实际用途(谁在C语言中真正使用过静态变量,而不引发错误呢?)

编辑

Baczek给出了一个有趣的例子。结合你们大多数的评论,特别是Utaal的评论,我进一步思考了一下:

def a():
    print("a executed")
    return []

           
def b(x=a()):
    x.append(5)
    print(x)

a executed
>>> b()
[5]
>>> b()
[5, 5]

在我看来,这个设计决定与参数的作用域放在哪里有关:是在函数内部,还是“和它一起”放在外面?

如果在函数内部进行绑定,那就意味着x在函数被调用时才会绑定到指定的默认值,而不是在定义时绑定,这样会出现一个深层次的问题:def这一行就会变得“混合”,因为一部分绑定(函数对象的绑定)是在定义时发生的,而另一部分(默认参数的赋值)则是在函数调用时发生的。

而实际的行为则更一致:这一行的所有内容在执行时被评估,也就是说是在函数定义时。

34 个回答

317

相关的部分来自于文档

默认参数值在函数定义时是从左到右进行计算的。 这意味着这个表达式只会在函数被定义的时候计算一次,然后每次调用这个函数时都会使用同一个“预先计算好的”值。这一点特别重要,尤其是当默认参数是可变对象,比如列表或字典时:如果函数修改了这个对象(例如,往列表里添加一个项目),那么默认值实际上也被修改了。这通常不是我们想要的结果。解决这个问题的一种方法是使用None作为默认值,并在函数体内明确进行测试,例如:

def whats_on_the_telly(penguin=None):
    if penguin is None:
        penguin = []
    penguin.append("property of the zoo")
    return penguin
336

假设你有以下代码

fruits = ("apples", "bananas", "loganberries")

def eat(food=fruits):
    ...

当我看到这个叫做 eat 的函数时,最让我惊讶的事情就是,如果第一个参数没有给定,它会默认变成一个元组 ("apples", "bananas", "loganberries")

但是,假设在代码的后面,我做了类似这样的事情

def some_random_function():
    global fruits
    fruits = ("blueberries", "mangos")

那么如果默认参数是在函数执行时绑定的,而不是在函数声明时绑定的,我会非常震惊(而且是那种很糟糕的震惊),发现 fruits 的值被改变了。在我看来,这比发现你上面的 foo 函数在修改列表要更让人惊讶。

真正的问题出在可变变量上,所有编程语言在某种程度上都有这个问题。这里有个问题:假设在 Java 中我有以下代码:

StringBuffer s = new StringBuffer("Hello World!");
Map<StringBuffer,Integer> counts = new HashMap<StringBuffer,Integer>();
counts.put(s, 5);
s.append("!!!!");
System.out.println( counts.get(s) );  // does this work?

那么,我的 map 是在放入时使用 StringBuffer 这个键的值,还是说它是通过引用来存储这个键?无论哪种情况,总会有人感到惊讶;要么是那个试图用和放入时相同的值从 Map 中取出对象的人,要么是那个即使使用的键和放入时完全相同的对象,却无法取回他们的对象的人(这其实就是为什么 Python 不允许它的可变内置数据类型作为字典的键)。

你的例子很好地展示了 Python 新手会感到惊讶并受到影响的情况。但我认为如果我们“修复”这个问题,那只会造成另一种情况,让他们受到影响,而这种情况会更加不直观。而且,当处理可变变量时,总会遇到一些情况,让人直观地期待某种行为或相反的行为,这取决于他们写的代码。

我个人喜欢 Python 现在的做法:默认的函数参数是在函数定义时被评估的,而这个对象始终是默认值。我想他们可以特别处理使用空列表的情况,但那样的特殊处理会引起更多的惊讶,更不用说会导致向后不兼容的问题。

1926

其实,这并不是设计上的缺陷,也不是因为内部结构或性能问题。这完全是因为在Python中,函数被视为一等公民,而不仅仅是一段代码。

一旦你这样理解,就会觉得一切都很合理:函数是一个根据其定义被评估的对象;默认参数就像是“成员数据”,因此它们的状态可能会在每次调用之间变化——就像其他任何对象一样。

无论如何,effbot(Fredrik Lundh)在Python中的默认参数值中对这种行为的原因有很好的解释。我觉得讲得非常清楚,真的建议大家去读一读,以更好地了解函数对象是如何工作的。

撰写回答