我该如何避免Python早期绑定默认参数引起的问题(例如,可变默认参数“记住”旧数据)?

222
有时候,将默认参数设置为空列表似乎是很自然的。然而,在这些情况下,Python会产生意外的行为
例如,考虑以下函数:
def my_func(working_list=[]):
    working_list.append("a")
    print(working_list)

第一次调用时,缺省值会起作用,但之后的调用将更新现有列表(每个调用均添加一个 "a"),并打印更新版本。
我该如何修复此函数,以便在没有显式参数的情况下重复调用时,每次都使用新的空列表?

2
相同的行为也会发生在集合中,尽管你需要一个稍微复杂一些的例子才能将其显示为错误。 - abeboparebop
5
随着链接失效,请让我明确指出这是期望的行为。默认变量在函数定义时被计算(即在第一次调用函数时发生),而不是在每次调用函数时计算。因此,如果你改变了一个可变的默认参数,任何后续的函数调用都只能使用被改变的对象。 - Moritz
10个回答

259
def my_func(working_list=None):
    if working_list is None: 
        working_list = []

    # alternative:
    # working_list = [] if working_list is None else working_list

    working_list.append("a")
    print(working_list)

文档中指出应该将None作为默认值,并在函数体中明确地测试它。


1
更好的说法是: 如果 working_list == None: 还是 如果 working_list: - John Mulder
3
这是Python中的首选方式,即使我不喜欢它因为它很丑。我会说最好的做法是“如果working_list是None”。 - Bite code
27
在这个例子中,首选的方式是说:if working_list is None。调用者可能已经使用了一个自定义append方法的空列表对象。 - tzot
6
注意,如果not working_list的长度为0,则其值为True。这会导致不一致的行为:如果该函数接收到一个带有元素的列表,则调用者的列表将被更新;如果列表为空,则不会被修改。 - vincent
1
@PatrickT 选择正确的工具取决于情况 - 可变参数函数与接受(可选)列表参数的函数非常不同。你需要在它们之间进行选择的情况比你想象的要少得多。当参数数量发生变化时,可变参数很棒,但是在编写代码时是固定的。就像你的例子一样。如果它是运行时变量,或者你想在列表上调用f(),那么你必须调用f(*l),这很糟糕。更糟糕的是,使用可变参数实现mate(['larch', 'finch', 'robin'], ['bumble', 'honey', 'queen'])会很糟糕。如果是def mate(birds=[], bees=[]):就好多了。 - FeRD
显示剩余3条评论

62

其他答案已经提供了直接的解决方案,但由于这是新 Python 程序员非常容易犯的错误,值得补充一下为什么 Python 会以这种方式行事的解释。在 The Hitchhikers Guide to Python 中,可变默认参数 很好地总结了这个问题:

Python 的默认参数在函数定义时只计算一次(不像 Ruby 等语言每次调用函数都会重新计算)。这意味着如果你使用一个可变的默认参数并且改变它,你已经改变了该对象的状态,并且所有未来对该函数的调用都将使用修改后的值。


18

虽然在这种情况下并不重要,但你可以使用对象身份来测试是否为 None:

if working_list is None: working_list = []

你也可以利用 Python 中布尔运算符 or 的定义:

working_list = working_list or []

如果调用者给你一个空列表(在Python中,空列表被视为false),并期望你的函数修改他所提供的列表,则此函数的行为将是意外的。


5
“or”的建议看起来不错,但当它接收到01,或TrueFalse时,行为却出人意料。 - Nico Schlömer

14
如果该函数的意图是要修改作为 working_list 参数传递的列表,则请参考HenryR的答案(=None,内部检查是否为None)。
但是,如果你不想改变参数的值,只是想将其作为列表的起点,请直接复制它:
def myFunc(starting_list = []):
    starting_list = list(starting_list)
    starting_list.append("a")
    print starting_list

(或者在这个简单的例子中只需使用print starting_list + ["a"],但我想那只是一个玩具示例)

通常情况下,在Python中修改参数不是好的编程习惯。唯一完全期望改变对象的函数是对象的方法。甚至更少见的是修改可选参数 - 仅在某些调用中发生的副作用是否真的是最好的接口?

  • 如果您出于C语言的“输出参数”的习惯而这样做,则完全没有必要 - 您始终可以将多个值作为元组返回。

  • 如果您这样做是为了有效地构建长列表而不建立中间列表,请考虑将其编写为生成器,并在调用时使用result_list.extend(myFunc())。这样您的调用约定仍然非常清晰。

一个经常使用可变可选参数的模式是递归函数中的隐藏“备忘录”参数:

def depth_first_walk_graph(graph, node, _visited=None):
    if _visited is None:
        _visited = set()  # create memo once in top-level call

    if node in _visited:
        return
    _visited.add(node)
    for neighbour in graph[node]:
        depth_first_walk_graph(graph, neighbour, _visited)

如果参数不会被改变,那么使用不可变对象作为默认值(())可能也是有意义的。根据情况,这可能需要对代码进行轻微更改(但在此处不需要;list(())可以很好地创建一个新的空列表)。 - Karl Knechtel

3

可能我有些跑题了,但是请记住如果你只想传递可变数量的参数,Pythonic的方式是传递一个元组*args或者字典**kargs。这些是可选的并且比语法myFunc([1, 2, 3])更好。

如果你想传递一个元组:

def myFunc(arg1, *args):
  print args
  w = []
  w += args
  print w
>>>myFunc(1, 2, 3, 4, 5, 6, 7)
(2, 3, 4, 5, 6, 7)
[2, 3, 4, 5, 6, 7]

如果您想传递一个字典:

def myFunc(arg1, **kargs):
   print kargs
>>>myFunc(1, option1=2, option2=3)
{'option2' : 2, 'option1' : 3}

2

总结

Python会提前计算参数的默认值,它们是“早绑定”的。这可能会导致一些问题,例如:

Python 在运行之前就会计算参数/参数的默认值,这可能会导致一些不同的问题。

>>> import datetime, time
>>> def what_time_is_it(dt=datetime.datetime.now()): # chosen ahead of time!
...     return f'It is now {dt.strftime("%H:%M:%S")}.'
... 
>>> 
>>> first = what_time_is_it()
>>> time.sleep(10) # Even if time elapses...
>>> what_time_is_it() == first # the reported time is the same!
True

问题最常见的表现方式是函数参数是可变的(例如,一个列表),并且在函数代码中被改变。当这种情况发生时,更改将被“记住”,因此会在后续调用中“看到”。
>>> def append_one_and_return(a_list=[]):
...     a_list.append(1)
...     return a_list
... 
>>> 
>>> append_one_and_return()
[1]
>>> append_one_and_return()
[1, 1]
>>> append_one_and_return()
[1, 1, 1]

因为 a_list 是事先创建的,所以使用默认值的每个函数调用都将使用 同一个列表对象,这个对象在每次调用时都会被修改,附加另一个 1 值。

这是一个有意识的设计决策,可以在某些情况下利用 - 尽管通常有更好的方法来解决那些其他问题。 (考虑使用functools.cachefunctools.lru_cache进行记忆化,并使用functools.partial绑定函数参数)。

这也意味着实例的方法不能使用实例属性作为默认值:在确定默认值的时间,self不在作用域内,而且实例也不存在

>>> class Example:
...     def function(self, arg=self):
...         pass
... 
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in Example
NameError: name 'self' is not defined

Example 类也尚不存在,名为 Example 的变量也不在作用域内;因此,类属性在这里同样无法使用,即使我们并不关心可变性的问题。)
解决方案
使用 None 作为哨兵值
通常被认为是标准且惯用的方法是将 None 作为默认值,并明确检查该值并在函数的程序逻辑中替换它。因此:
>>> def append_one_and_return_fixed(a_list=None):
...     if a_list is None:
...         a_list = []
...     a_list.append(1)
...     return a_list
... 
>>> append_one_and_return_fixed([2]) # it works consistently with an argument
[2, 1]
>>> append_one_and_return_fixed([2])
[2, 1]
>>> append_one_and_return_fixed() # and also without an argument
[1]
>>> append_one_and_return_fixed()
[1]

这个工作原理是代码 a_list = [] 在调用函数时运行(如果需要),而不是提前 - 因此,它每次都会创建一个新的空列表。因此,这种方法也可以解决 datetime.now()问题。这确实意味着函数不能为其他目的使用 None 值; 但是,在普通代码中,这不应该成为问题。

简单避免可变默认值

如果没有必要修改参数以实现函数逻辑,由于命令查询分离原则,最好就不要那样做

因此,append_one_and_return 的设计本来就很差:由于其目的是显示输入的某个修改版本,它不应该同时实际修改调用者的变量,而是应该为显示目的创建一个新对象。这允许使用不可变对象(例如元组)作为默认值。因此:

def with_appended_one(a_sequence=()):
    return [*a_sequence, 1]

这种方式可以避免修改输入,即使该输入是明确提供的:

>>> x = [1]
>>> with_appended_one(x)
[1, 1]
>>> x # not modified!
[1]

即使没有参数,它也可以正常工作,甚至可以重复执行:

>>> with_appended_one()
[1]
>>> with_appended_one()
[1]

现在它变得更加灵活了:

>>> with_appended_one('example') # a string is a sequence of its characters.
['e', 'x', 'a', 'm', 'p', 'l', 'e', 1]

可能在3.12版本中出现的新语法

计划在Python 3.12中引入新的语法,详见PEP 671,该语法将允许显式请求默认值的延迟绑定而不是早期绑定。该语法很可能如下所示:

def append_and_show_future(a_list=>None): # note => instead of =
    a_list.append(1)
    print(a_list)

然而,截至本文撰写时,该提案尚未正式接受并且不在即将到来的更改列表上。

0
也许最简单的方法就是在脚本内创建列表或元组的副本。这样可以避免检查的需要。例如,
    def my_funct(params, lst = []):
        liste = lst.copy()
         . . 

这实际上是在很少的细节上复制了Beni Cherniavsky-Paskin的答案 - Karl Knechtel

0

引用自https://docs.python.org/3/reference/compound_stmts.html#function-definitions

当函数定义被执行时,默认参数值从左到右进行计算。这意味着表达式在函数定义时只计算一次,并且相同的“预先计算”值用于每个调用。当默认参数是可变对象(例如列表或字典)时,理解这一点尤为重要:如果函数修改了对象(例如通过向列表添加项),则默认值实际上被修改了。这通常不是预期的结果。解决方法是将None用作默认值,并在函数体中显式测试它,例如:

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

-1

已经有很好且正确的答案了。我只是想提供另一种语法来编写您想要做的事情,当您例如想要创建一个带有默认空列表的类时,我发现这更加优美:

class Node(object):
    def __init__(self, _id, val, parents=None, children=None):
        self.id = _id
        self.val = val
        self.parents = parents if parents is not None else []
        self.children = children if children is not None else []

这段代码片段使用了if else运算符语法。我特别喜欢它,因为它是一个整洁的单行代码,没有冒号等复杂符号,几乎读起来像正常的英语句子。 :)
在你的情况下,你可以写成:
def myFunc(working_list=None):
    working_list = [] if working_list is None else working_list
    working_list.append("a")
    print working_list

-5

我参加了UCSC延伸课程的 Python for programmer

下列哪项陈述是正确的:def Fn(data = []):

a) 这样做是个好主意,因为每次调用时数据列表都会为空。

b) 这样做是个好主意,因为所有没有提供参数的函数调用都将使用空列表作为数据。

c) 只要你的数据是一个字符串列表,这么做就是合理的。

d) 这是个坏主意,因为默认值[]将累积数据并且默认值[]会随后续调用而改变。

答案:

d) 这是个坏主意,因为默认值[]将累积数据并且默认值[]会随后续调用而改变。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接