Конкатенация результата функции с изменяемым аргументом по умолчанию
Предположим, что функция с изменяемым аргументом по умолчанию:
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.