Как Python обрабатывает изменяемые аргументы по умолчанию и в чём типичная ошибка?
Изменяемые дефолты (список, словарь) создаются один раз при определении функции и разделяются между всеми вызовами. Решение — использовать None как sentinel и создавать объект внутри функции.
Изменяемые аргументы по умолчанию в Python
В Python значение аргумента по умолчанию вычисляется один раз — в момент определения функции, а не при каждом её вызове. Это означает, что если передать изменяемый объект (список, словарь, множество), все вызовы функции будут разделять один и тот же объект.
Демонстрация проблемы
def append_item(item, container=[]):
container.append(item)
return container
print(append_item(1)) # [1]
print(append_item(2)) # [1, 2] -- неожиданно!
print(append_item(3)) # [1, 2, 3]
Список container создаётся один раз при компиляции тела функции и сохраняется как атрибут объекта функции: append_item.__defaults__. Каждый вызов работает с одним и тем же объектом.
Проверка через __defaults__
def broken(x=[]):
x.append(1)
return x
print(broken.__defaults__) # ([],)
broken()
print(broken.__defaults__) # ([1],) -- объект изменился!
Правильный паттерн — sentinel None
def append_item(item, container=None):
if container is None:
container = []
container.append(item)
return container
print(append_item(1)) # [1]
print(append_item(2)) # [2] -- новый список каждый раз
Использование None в качестве sentinel — стандарт де-факто в Python. Аналогично для словарей и множеств:
from typing import Optional
def register(
name: str,
tags: Optional[list[str]] = None,
metadata: Optional[dict[str, str]] = None,
) -> dict:
if tags is None:
tags = []
if metadata is None:
metadata = {}
return {"name": name, "tags": tags, "meta": metadata}
Когда изменяемый default может быть намеренным
Иногда разработчики намеренно используют изменяемые defaults как простой кеш:
def fibonacci(n, _cache={}):
if n in _cache:
return _cache[n]
if n <= 1:
return n
result = fibonacci(n - 1) + fibonacci(n - 2)
_cache[n] = result
return result
Такой приём работает, но лучше использовать functools.lru_cache или явный атрибут класса — код становится прозрачнее.
Dataclasses и field(default_factory)
В dataclasses нельзя напрямую указать изменяемый default — Python выбросит ValueError. Нужно использовать field(default_factory=...):
from dataclasses import dataclass, field
@dataclass
class Config:
tags: list[str] = field(default_factory=list)
options: dict[str, str] = field(default_factory=dict)
Подводные камни
- Ошибка проявляется не сразу: первый вызов выглядит корректно, проблема видна только со второго.
- Дефолты хранятся в
__defaults__(позиционные) и__kwdefaults__(keyword-only). Их можно случайно сломать, присвоивfunc.__defaults__ = (...). - Методы класса не защищены от этой проблемы — дефолт тоже вычисляется один раз при определении класса.
- Лямбды с изменяемыми defaults:
f = lambda x=[]: x.append(1)— та же ловушка, менее заметная. - Тесты могут проходить в изоляции, но падать при запуске всего набора — порядок тестов влияет на состояние дефолта.
- При использовании
functools.partialс изменяемым аргументом проблема сохраняется, так как partial сохраняет ссылку на тот же объект. - Линтер
pylintвыдаётW0102: dangerous-default-value,flake8с плагиномflake8-bugbear— предупреждениеB006. Включайте эти проверки в CI.
Common mistakes
- Описывать mutable default arguments только как термин и не показывать механизм на минимальном примере.
- Игнорировать ошибки, пустые данные, конкурентный доступ или границы транзакции.
- Не связывать поведение с официальным контрактом Python и реальной эксплуатацией.
What the interviewer is testing
- Объясняет mutable default arguments через последовательность действий, а не через набор ключевых слов.
- Приводит короткий кодовый пример или production-сценарий с ожидаемым поведением.
- Называет хотя бы один риск: производительность, безопасность, транзакции, память или сопровождение.