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


Если функция в Python неожиданно «помнит» данные из прошлого вызова, причина часто одна: изменяемый аргумент по умолчанию. Это удобно воспроизводится на коротком примере и так же коротко проверяется тестом. Ниже разберём один конкретный дефект: накопление значений в списке между независимыми вызовами функции.
Все примеры ниже рассчитаны на обычный Python 3.x: их можно запускать в REPL, в файле .py или в учебной песочнице. Важная цель не просто увидеть ошибку, а получить проверку, которая потом защитит код от повторного появления этого дефекта.
Два последовательных вызова: как быстро воспроизвести баг
Самый короткий сценарий — функция, которая добавляет элемент в список и возвращает его. На вид код выглядит безобидно: список нужен только как удобное значение по умолчанию. Но именно здесь и появляется скрытое состояние.
def add_tag(tag, tags=[]):
tags.append(tag)
return tags
first = add_tag("draft")
second = add_tag("ready")
print(first)
print(second)
Интуитивное ожидание у новичка обычно такое: first должен быть равен ["draft"], а second — ["ready"]. На практике оба значения окажутся ["draft", "ready"]. Причём неприятнее всего то, что переменная first «задним числом» изменилась после второго вызова: она ссылается на тот же самый список.
Для воспроизведения достаточно двух последовательных вызовов без второго аргумента. Если же передать свой список явно, например add_tag("done", []), накопления между вызовами не будет: проблема связана именно со значением по умолчанию, а не с самой операцией append.
Почему список общий, а не новый на каждый вызов
Суть поведения в том, что значение аргумента по умолчанию создаётся один раз в момент определения функции, а не заново при каждом вызове. Для неизменяемых значений вроде числа или строки это обычно незаметно. Для списка, словаря или множества это уже общее хранилище, которое все вызовы без явного аргумента используют совместно.
Это можно увидеть по идентификатору объекта:
def add_tag(tag, tags=[]):
print(id(tags))
tags.append(tag)
return tags
add_tag("draft")
add_tag("ready")
Оба вызова выведут один и тот же id, то есть внутри функции используется один и тот же объект списка. Поэтому второй вызов не начинает работу с пустого состояния, а продолжает менять уже заполненный список.
Есть ещё один полезный способ посмотреть на проблему: объект по умолчанию хранится у функции в __defaults__. Если запустить дефектный пример несколько раз, в add_tag.__defaults__ будет лежать уже изменённый список. Это хороший приём для отладки: он помогает убедиться, что состояние живёт не в локальной переменной конкретного вызова, а в самой функции.
Как создать отдельный список для каждого независимого вызова
Исправление стандартное: вместо пустого списка в сигнатуре используют None, а новый список создают уже внутри функции. Тогда каждый вызов без второго аргумента получает свой отдельный объект.
def add_tag(tag, tags=None):
if tags is None:
tags = []
tags.append(tag)
return tags
first = add_tag("draft")
second = add_tag("ready")
print(first) # ['draft']
print(second) # ['ready']
Почему проверка должна быть именно tags is None, а не if not tags? Потому что пустой список, переданный пользователем явно, тоже считается ложным значением. Если написать if not tags, функция по ошибке заменит переданный пустой список на новый и нарушит ожидание вызывающего кода. Проверка на None отделяет случай «аргумент не передали» от случая «передали пустую коллекцию специально».
Практический результат здесь простой: после исправления функция перестаёт хранить скрытое состояние между независимыми вызовами. Это особенно важно для утилитных функций, обработчиков данных и маленьких помощников, которые вызываются много раз в разных местах программы.
Учебный пример: лог двух независимых запросов
Учебный пример
Представим условную задачу: нужно собирать шаги обработки запроса в список. Для каждого нового запроса ожидается свой отдельный лог. Исходные данные такие: первый запрос добавляет шаг "parse", второй — "save". Между запросами список шагов не должен сохраняться.
def collect_steps(step, steps=[]):
steps.append(step)
return steps
request_a = collect_steps("parse")
request_b = collect_steps("save")
print("A:", request_a)
print("B:", request_b)
print(request_a is request_b)
Проверяемый результат у дефектной версии будет таким: у обоих вызовов получится один и тот же список ["parse", "save"], а выражение request_a is request_b вернёт True. Это уже не просто теоретическая тонкость синтаксиса: лог второго запроса испортил лог первого.
Исправленная версия ведёт себя так, как ожидается от независимых запросов:
def collect_steps(step, steps=None):
if steps is None:
steps = []
steps.append(step)
return steps
request_a = collect_steps("parse")
request_b = collect_steps("save")
print("A:", request_a)
print("B:", request_b)
print(request_a is request_b)
Теперь проверяемый результат другой: request_a содержит только ["parse"], request_b содержит только ["save"], а сравнение по идентичности возвращает False. Значит, мы получили не просто «правильный вывод на экране», а действительно раздельные объекты без скрытого общего состояния.
Регрессионная проверка: как зафиксировать независимость вызовов
Чтобы дефект не вернулся позже, полезно оставить короткую проверку на уровне теста. Для такой задачи достаточно обычных assert. Этот вариант можно запускать как скрипт: если проверка не проходит, Python остановится с ошибкой.
def collect_steps(step, steps=None):
if steps is None:
steps = []
steps.append(step)
return steps
def test_independent_calls():
first = collect_steps("parse")
second = collect_steps("save")
assert first == ["parse"]
assert second == ["save"]
assert first is not second
def test_explicit_list_is_preserved():
external = []
result = collect_steps("parse", external)
assert result is external
assert external == ["parse"]
test_independent_calls()
test_explicit_list_is_preserved()
print("ok")
Первая проверка фиксирует главный смысл исправления: два вызова без второго аргумента должны быть независимыми и возвращать разные объекты. Вторая проверка защищает важный граничный случай: если список передал сам вызывающий код, функция должна работать именно с ним, а не незаметно создавать новый.
Эта пара тестов полезнее, чем одиночная проверка результата печати. Она сразу ловит два класса ошибок: возврат общего списка и неаккуратную замену переданного пустого списка. Если кто-то позже упростит код до steps = steps or [], второй тест покажет, что поведение изменилось не в ту сторону.
Если вы системно изучаете функции в Python, полезно отдельно тренировать такие темы, как аргументы, локальные переменные и поведение значений по умолчанию. На официальной странице курса «Основы Python» у Eduson Academy среди тем программы указаны функции, работа с аргументами, args и kwargs, а также практика в интерактивной песочнице. Для этой темы стоит заранее проверить для себя: есть ли в обучении задания, где нужно не только написать функцию, но и проверить её повторными вызовами и простыми тестами.




















