Изменяемый аргумент по умолчанию в Python: почему список помнит прошлый вызов

Изменяемый аргумент по умолчанию в Python: почему список помнит прошлый вызов
Содержание статьи

Если функция Python неожиданно помнит данные предыдущего вызова, проверьте изменяемое значение аргумента по умолчанию. Список в объявлении функции создаётся один раз, а не заново при каждом пропущенном аргументе. На Mentoring.digital разберём ошибку на небольшом примере и отделим её от намеренной работы с переданным списком.

Как увидеть общее состояние

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

first = collect("alpha")
second = collect("beta")
print(first)
print(second)
print(first is second)

Оба имени связаны с одним списком. Поэтому важно смотреть не только на вывод сразу после первого вызова, но и на ранее сохранённый результат после второго. Проблема не в том, что Python не умеет создавать списки, а в моменте создания конкретного объекта. Эта же логика относится к изменяемому словарю в аргументе по умолчанию.

Практический приём диагностики: найдите место создания состояния и все места изменения. Если состояние должно принадлежать одному вызову, его жизненный цикл не должен незаметно совпадать с жизненным циклом определения функции. Проверка идентичности здесь помогает увидеть связь, но не является универсальным способом сравнения содержимого.

Создайте новый список внутри функции

def collect(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

assert collect("alpha") == ["alpha"]
assert collect("beta") == ["beta"]

None обозначает отсутствие переданного списка. В этом варианте новый объект появляется внутри вызова. Проверяйте именно отсутствие, а не истинность значения: выражение items or [] заменило бы и явно переданный пустой список. Это может изменить договорённость с вызывающим кодом.

Выбор None подходит, если оно не является самостоятельным допустимым значением с другим смыслом. Если интерфейсу нужно различать «аргумент не передан» и «передан None», понадобится отдельный объект-маркер и явно описанный контракт. Не добавляйте такую сложность без необходимости, но учитывайте её при проектировании библиотеки.

Что делать с явно переданным объектом

shared = []
result = collect("alpha", shared)
assert result is shared
assert shared == ["alpha"]

Исправленная функция всё ещё изменяет явно переданный список. Это отдельный вопрос интерфейса. Если вызывающий код ожидает сохранения своего объекта без изменений, можно создать копию перед добавлением элемента. Но такая правка должна соответствовать назначению функции: она меняет поведение для всех переданных списков.

Не называйте копирование универсальной защитой вложенных данных. Поверхностная копия списка не создаёт независимые копии всех вложенных изменяемых объектов. Для текущей задачи сначала достаточно определить, кто владеет списком и допустимо ли его изменение. Затем уже выбирать подходящий уровень копирования.

Какие проверки сохранить

Проверьте два независимых вызова без второго аргумента, явный пустой список и список с существующими элементами. Для каждого случая сформулируйте ожидаемое содержимое и ожидаемую идентичность. Если контракт предусматривает изменение переданного объекта, проверка должна это подтверждать, а не запрещать автоматически.

Приведённый пример является учебным. Его задача показать границу состояния, а не воспроизвести клиентский инцидент. Для своего проекта добавьте проверку именно той функции, которая использует общий объект. Изолированный пример не доказывает отсутствие других источников состояния, например глобального кэша или атрибута экземпляра.

Если причина по-прежнему неясна, подготовьте минимальный пример и ожидаемое поведение для наставника по Python. Полезный итог разбора: объяснение жизненного цикла объекта и проверка контракта, а не только замена квадратных скобок на None.

Похожие статьи по данной теме