Предполагается, что вы уже умеете писать простые программы на Python и знаете базовый синтаксис (if, for, функции, списки). Здесь мы разбираем не как пользоваться языком, а почему он работает именно так.

Попробуйте угадать, что выведет этот код:

def add(item, items=[]):
    items.append(item)
    return items

print(add(1))
print(add(2))
print(add(3))

Если вы ещё не сталкивались с этой особенностью Python, скорее всего, ожидание будет таким:

[1]
[2]
[3]

Ведь каждый вызов функции кажется совершенно независимым от предыдущего.

Однако Python выводит совсем другой результат:

[1]
[1, 2]
[1, 2, 3]

Получается, что список каким-то образом “запомнил” предыдущие вызовы функции.

Но почему?


Если посмотреть только на этот пример, может показаться, что функции в Python обладают какой-то скрытой памятью.

На самом деле никакой магии здесь нет.

Чтобы понять происходящее, достаточно вспомнить то, о чём мы говорили в предыдущих статьях.

Во-первых, переменные не хранят объекты - они лишь ссылаются на них.

Во-вторых, список является изменяемым объектом.

Именно эти два факта полностью объясняют поведение программы.


Когда Python встречает определение функции:

def add(item, items=[]):
    ...

список создаётся один раз.

Не при каждом вызове функции.

Не каждый раз, когда выполняется add().

А именно в тот момент, когда Python читает определение функции.

Получается примерно такая картина:

add

 └────► items ───► []

У функции появляется параметр items, который уже ссылается на существующий объект списка.

Теперь происходит первый вызов:

add(1)

Поскольку второй аргумент не передан, используется тот самый список.

После выполнения append() он становится таким:

items ───► [1]

Функция завершается.

Но список никуда не исчезает.

Он продолжает существовать.


Теперь происходит второй вызов:

add(2)

Многие ожидают, что Python создаст новый пустой список.

Но этого не происходит.

Функция снова использует тот же самый объект.

Теперь он выглядит так:

items ───► [1, 2]

Во время третьего вызова происходит абсолютно то же самое.

Список продолжает изменяться:

items ───► [1, 2, 3]

Именно поэтому каждый следующий вызов “помнит” результаты предыдущих.


Важно заметить, что проблема здесь вовсе не в функции.

Если написать похожий код без функций, результат никого не удивит:

items = []

items.append(1)
items.append(2)
items.append(3)

print(items)

Получится:

[1, 2, 3]

Мы уже знаем почему.

Список - изменяемый объект.

Каждый вызов append() меняет существующий объект, а не создаёт новый.

В функции происходит ровно то же самое.

Просто объект списка оказался создан раньше, чем многие ожидают.


Как же тогда правильно писать такой код?

В Python существует общепринятый способ.

Вместо пустого списка используют значение None.

def add(item, items=None):
    if items is None:
        items = []

    items.append(item)
    return items

Теперь каждый вызов без второго аргумента создаёт новый список.

Поэтому результат становится ожидаемым:

print(add(1))
print(add(2))
print(add(3))
[1]
[2]
[3]

Может возникнуть вопрос: почему Python вообще работает именно так?

Почему бы не создавать новый список автоматически при каждом вызове функции?

Ответ довольно простой.

Значения параметров по умолчанию - это такие же объекты, как и любые другие.

Python создаёт их один раз и затем использует повторно.

Это делает поведение языка последовательным.

Для чисел или строк такая особенность почти незаметна, потому что они являются неизменяемыми объектами.

Но как только значением по умолчанию становится список, словарь или множество, начинает проявляться эффект, который мы только что увидели.


Если посмотреть на ситуацию с точки зрения предыдущих статей, то ничего нового здесь не произошло.

Есть объект.

Есть ссылка на этот объект.

Объект является изменяемым.

Поэтому каждый вызов функции работает с одним и тем же списком.

Именно поэтому опытные Python-разработчики почти никогда не используют изменяемые объекты в качестве значений параметров по умолчанию.


На этом этапе мы уже знаем достаточно, чтобы перейти к следующему вопросу.

Мы несколько раз говорили, что список можно изменить “на месте”.

Но всегда ли Python изменяет существующий объект?

Например, что произойдёт здесь?

numbers = [1, 2]

numbers += [3]

Будет изменён существующий список или создастся новый?

А что произойдёт, если вместо списка использовать число?

На первый взгляд операции выглядят одинаково, но ведут себя совершенно по-разному.

Именно это мы и разберём в следующей статье.