1. Феномен: Списки с «памятью»

Представьте классическую задачу: вам нужна функция, которая добавляет элемент в список. Если список не передан, функция должна создать новый (пустой) и работать с ним. Начинающий разработчик пишет интуитивно понятный код:

def add_item(item, storage=[]):
    storage.append(item)
    return storage

На первый взгляд, всё логично: если storage не указан, он инициализируется как []. Однако при последовательных вызовах мы сталкиваемся с аномалией:

print(add_item("яблоко"))  # Вывод: ['яблоко']
print(add_item("банан"))   # Вывод: ['яблоко', 'банан']
print(add_item("груша"))   # Вывод: ['яблоко', 'банан', 'груша']

Вместо того чтобы каждый раз начинать с чистого листа, функция начинает «запоминать» результаты предыдущих операций. Создается иллюзия, что у функции появилась скрытая память или статическая переменная, которая хранит состояние между вызовами.

В чем ловушка?
Проблема в нарушении ожидаемого поведения. В разработке мы полагаемся на то, что функция с параметрами по умолчанию каждый раз стартует из одного и того же «нулевого» состояния. В данном же случае мы получаем неявный побочный эффект. В реальном проекте это приводит к самым трудноотлавливаемым багам: данные одного пользователя могут внезапно оказаться в корзине другого просто потому, что они оба использовали один и тот же «дефолтный» список в памяти сервера.

Этот феномен — не баг интерпретатора, а фундаментальная особенность реализации Python, которую мы разберем в следующем пункте.

2. Корень проблемы: Время определения vs Время выполнения

Чтобы понять, почему список «запоминает» значения, нужно разобраться в том, как Python обрабатывает инструкцию def.

В большинстве языков программирования аргументы по умолчанию вычисляются каждый раз в момент вызова функции. В Python всё иначе: значения по умолчанию вычисляются ровно один раз — в момент определения функции (Definition Time).

Как это работает «под капотом»:

  1. Когда интерпретатор читает файл и встречает def func(arg=[]), он выполняет это выражение.

  2. Он создает объект пустого списка в памяти.

  3. Он сохраняет ссылку на этот конкретный объект внутри самой функции — в скрытом атрибуте __defaults__.

Когда вы вызываете функцию без аргумента, Python не создает новый список. Он просто берет уже существующий объект из «кармана» __defaults__.

Доказательство:

Мы можем буквально заглянуть в «память» функции и увидеть, как она меняется:

def add_item(item, storage=[]):
    storage.append(item)

# Сразу после создания функции:
print(add_item.__defaults__)  # Вывод: ([],) — список уже здесь!

add_item("X")

# После одного вызова:
print(add_item.__defaults__)  # Вывод: (['X'],) — тот же самый объект изменился

Главный вывод: Значение по умолчанию в Python — это не шаблон для создания нового объекта, а статический объект, намертво привязанный к функции. Если этот объект можно изменить (как список), то все последующие вызовы будут работать с его обновленной версией. Именно здесь кроется фундаментальное различие между изменяемыми и неизменяемыми типами данных.

3. Изменяемость как главный враг

Естественный вопрос: почему мы не сталкиваемся с этой проблемой, когда используем в качестве аргументов числа, строки или None? Ведь мы постоянно пишем def func(count=0) или def func(name="Guest"), и это работает абсолютно предсказуемо.

Разгадка кроется в фундаментальном различии между изменяемыми (mutable) и неизменяемыми (immutable) типами данных.

Immutable-типы (Безопасные)

К ним относятся int, float, str, bool, tuple. Эти объекты невозможно изменить «на месте». Если вы попытаетесь «изменить» число или строку, Python на самом деле создаст в памяти новый объект и перенаправит локальную переменную на него.

Пример с числом:

def increment(count=0):
    count += 1  # Создается НОВОЕ число, локальная переменная count теперь ссылается на него
    return count

print(increment()) # 1
print(increment()) # 1 (в __defaults__ по-прежнему лежит старый 0)

Значение в __defaults__ остается неизменным, потому что число 0 физически нельзя превратить в 1.

Mutable-типы (Ловушки)

К ним относятся list, dict, set. Эти объекты поддерживают изменение «на месте» (in-place mutation). Когда вы вызываете метод .append(), .extend() или .update(), вы не создаете новый объект — вы модифицируете тот самый экземпляр, на который ссылается атрибут функции __defaults__.

Тип данных

В аргументах

Последствия модификации

int, str, None

Безопасно

Создается новая локальная копия

list, dict, set

Опасно

Изменяется общий объект для всех вызовов

Вердикт: Проблема «магических списков» — это прямое следствие мутабельности. Используя изменяемый объект как значение по умолчанию, вы создаете общее разделяемое состояние (shared state) там, где ожидали изоляцию. Любая модификация такого аргумента внутри функции «отравляет» её для всех последующих вызовов.

4. Каноничное решение: Идиома None

Чтобы избежать нежелательного сохранения состояния, в сообществе Python выработался стандарт (идиома), который считается единственно верным способом инициализации изменяемых аргументов.

Реализация паттерна

Вместо того чтобы создавать список в момент определения функции, мы используем None в качестве «заглушки». Создание же реального объекта переносится непосредственно в тело функции — туда, где код выполняется при каждом вызове.

def add_item(item, storage=None):
    if storage is None:
        storage = []
    storage.append(item)
    return storage

Почему это работает?

  1. Безопасность None: Объект None является неизменяемым (immutable). Даже если функция вызывается тысячи раз, ссылка в __defaults__ всегда указывает на один и тот же None, который невозможно «отравить» или изменить.

  2. Runtime-инициализация: Инструкция storage = [] теперь выполняется в Runtime (во время выполнения). Это гарантирует, что при каждом вызове, где не передан аргумент storage, будет создаваться новый, чистый экземпляр списка в памяти.

  3. Явное поведение: Теперь функция ведет себя детерминировано. Первый вызов вернет ['яблоко'], второй — ['банан'], так как их области видимости больше не связаны общим объектом из этапа компиляции.

Важный нюанс: is None против if not

Обратите внимание, что проверку стоит делать именно через оператор идентичности: if storage is None:.

Иногда разработчики пишут if not storage:, но это плохая практика. Если пользователь намеренно передаст в функцию пустой список (который является False в логическом контексте), проверка if not storage сработает, и код создаст еще один новый список внутри, проигнорировав объект пользователя. Проверка is None гарантирует, что мы создаем список только тогда, когда аргумент действительно был опущен.

Заключение

Понимание разницы между временем определения и временем выполнения — это важный этап перехода от новичка к крепкому Python-разработчику. Помните простое правило: никогда не используйте изменяемые объекты (списки, словари, множества) в качестве аргументов по умолчанию. Используйте None, и ваш код станет предсказуемым, чистым и профессиональным.

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