Конкатенация результата функции с изменяемым аргументом по умолчанию

Предположим, что функция с изменяемым аргументом по умолчанию:

def f(l=[]):
    l.append(len(l))
    return l

Если я запущу это:

def f(l=[]):
    l.append(len(l))
    return l
print(f()+["-"]+f()+["-"]+f()) # -> [0, '-', 0, 1, '-', 0, 1, 2]

Или это:

def f(l=[]):
    l.append(len(l))
    return l
print(f()+f()+f()) # -> [0, 1, 0, 1, 0, 1, 2]

Вместо следующего, который был бы более логичным:

print(f()+f()+f()) # -> [0, 0, 1, 0, 1, 2]

Почему?

Ответы

Ответ 1

Это на самом деле довольно интересно!

Как мы знаем, список l в определении функции инициализируется только один раз при определении этой функции, и для всех вызовов этой функции будет ровно одна копия этого списка. Теперь функция изменяет этот список, что означает, что несколько вызовов этой функции изменят один и тот же объект несколько раз. Это первая важная часть.

Теперь рассмотрим выражение, которое добавляет эти списки:

f()+f()+f()

В соответствии с законами операторского приоритета это эквивалентно следующему:

(f() + f()) + f()

... что точно так же, как это:

temp1 = f() + f() # (1)
temp2 = temp1 + f() # (2)

Это вторая важная часть.

Добавление списков создает новый объект без изменения каких-либо его аргументов. Это третья важная часть.

Теперь давайте объединим то, что мы знаем.

В строке 1 выше первый вызов возвращает [0], как и следовало ожидать. Второй вызов возвращает [0, 1], как и следовало ожидать. Ой, подожди! Функция будет возвращать один и тот же объект (не его копию!) Снова и снова, после его изменения! Это означает, что объект, возвращенный первым вызовом, теперь также изменился и стал [0, 1]! И вот почему temp1 == [0, 1] + [0, 1].

Результат сложения, однако, является совершенно новым объектом, поэтому [0, 1, 0, 1] + f() такой же, как [0, 1, 0, 1] + [0, 1, 2]. Обратите внимание, что второй список, опять же, именно то, что вы ожидаете от вашей функции. То же самое происходит, когда вы добавляете f() + ["-"]: это создает новый объект list, так что любые другие вызовы f не будут мешать ему.

Вы можете воспроизвести это, объединив результаты двух вызовов функций:

>>> f() + f()
[0, 1, 0, 1]
>>> f() + f()
[0, 1, 2, 3, 0, 1, 2, 3]
>>> f() + f()
[0, 1, 2, 3, 4, 5, 0, 1, 2, 3, 4, 5]

Опять же, вы можете делать все это, потому что объединяете ссылки на один и тот же объект.

Ответ 2

Вот способ подумать об этом, который может помочь в этом:

Функция - это структура данных. Вы создаете блок с блоком def почти так же, как вы создаете тип с блоком class или создаете список в квадратных скобках.

Наиболее интересной частью этой структуры данных является код, который запускается при вызове функции, но аргументы по умолчанию также являются его частью! Фактически, вы можете проверить как код, так и аргументы по умолчанию из Python через атрибуты функции:

>>> def foo(a=1): pass
... 
>>> dir(foo)
['__annotations__', '__call__', '__class__', '__closure__', '__code__', '__defaults__', ...]
>>> foo.__code__
<code object foo at 0x7f114752a660, file "<stdin>", line 1>
>>> foo.__defaults__
(1,)

(гораздо более приятный интерфейс для этого - inspect.signature, но все, что он делает, это проверяет эти атрибуты.)

Итак, причина, по которой это изменяет список:

def f(l=[]):
    l.append(len(l))
    return l

это та же самая причина, по которой это также изменяет список:

f = dict(l=[])
f['l'].append(len(f['l']))

В обоих случаях вы изменяете список, принадлежащий какой-либо родительской структуре, поэтому изменение, естественно, будет видно и в родительском.


Обратите внимание, что это дизайнерское решение, специально принятое Python, и оно не является обязательным для языка. JavaScript недавно узнал об аргументах по умолчанию, но он рассматривает их как выражения, которые должны быть переоценены заново при каждом вызове - по сути, каждый аргумент по умолчанию является своей крошечной функцией. Преимущество состоит в том, что у JS нет этой ошибки, но недостатком является то, что вы не можете осмысленно проверить значения по умолчанию, как в Python.