Проблема изменяемых аргументов по умолчанию
Разбор классической ошибки с изменяемыми типами (списками, словарями) в параметрах функции.
Введение в проблему изменяемых аргументов по умолчанию
Добро пожаловать в один из самых важных уроков курса Python для уровня Intermediate. Сегодня мы подробно разберем одну из самых коварных, классических и часто встречающихся ошибок, с которой сталкиваются не только новички, но и разработчики среднего уровня. Речь идет о проблеме изменяемых аргументов по умолчанию (Mutable Default Arguments). Если вы когда-либо замечали, что ваша функция внезапно начинает «запоминать» данные из предыдущих вызовов, хотя вы этого не задумывали, вы столкнулись именно с этим явлением. В Python функции являются объектами первого класса, и понимание того, как они создаются, инициализируются и хранятся в памяти, критически важно для написания чистого, предсказуемого и профессионального кода. Чтобы осознать масштаб этой проблемы, нам потребуется вернуться к базовым концепциям: что такое изменяемые (mutable) и неизменяемые (immutable) типы данных, как работают ссылки на объекты в Python, и в какой именно момент интерпретатор вычисляет значения по умолчанию для параметров функции. Как гласит Дзен Python (The Zen of Python): «Явное лучше, чем неявное» (Explicit is better than implicit). Использование пустых списков или словарей в качестве значений по умолчанию создает скрытую, неявную связь между всеми вызовами функции, что может привести к катастрофическим последствиям в реальных проектах, утечкам данных между сессиями пользователей или непредсказуемому поведению системы управления базами данных. На протяжении этого урока мы будем использовать методики микрообучения и активного припоминания (Active Recall), чтобы закрепить материал на уровне мышечной памяти. Мы препарируем функцию, посмотрим, что находится у нее «под капотом» с помощью встроенных инструментов интроспекции, таких как атрибут __defaults__, и напишем несколько проектов, чтобы увидеть, как эта проблема проявляется в реальной разработке.
def add_student(name, students=[]):
"""Добавляет студента в список группы."""
students.append(name)
return students
Анатомия ошибки: код Junior-разработчика
Давайте посмотрим на функцию, представленную в блоке кода выше. На первый взгляд, логика кажется абсолютно безупречной и интуитивно понятной. Разработчик создал функцию `add_student`, которая принимает два параметра: `name` (имя студента) и `students` (список студентов, куда нужно добавить имя). Для удобства разработчик задал пустому списку значение по умолчанию: `students=[]`. Его ожидания были простыми и логичными: если мы вызываем функцию и передаем ей уже существующий список, имя должно добавиться в этот список. А если мы вызываем функцию, передавая только имя (без второго аргумента), функция должна автоматически создать новый, пустой список, добавить в него переданное имя и вернуть этот новый список. Именно так работают значения по умолчанию во многих других языках программирования, таких как C++, Java или JavaScript. Однако Python устроен иначе. Философия и архитектура интерпретатора CPython предполагают, что значение по умолчанию вычисляется ровно один раз — в момент определения функции (когда интерпретатор читает ключевое слово `def`), а не при каждом ее вызове. Это означает, что пустой список `[]` создается в оперативной памяти лишь однажды, и ссылка на этот конкретный объект списка навсегда привязывается к параметру `students`. При первом вызове функции без второго аргумента в этот список будет добавлен первый студент. При втором вызове функции (снова без второго аргумента) Python не создаст новый пустой список! Он возьмет тот самый список, который был создан при определении функции, и который уже содержит имя первого студента. Таким образом, второй студент добавится к первому. Это классическая иллюстрация того, почему знание того, «как» работает язык, недостаточно — нужно понимать «почему» он так работает. В следующем блоке мы запустим этот код и проанализируем результаты.
# Демонстрация проблемы изменяемых аргументов по умолчанию
# Вызываем функцию для группы A
group_a = add_student("Алиса")
print(f"Группа A: {group_a}")
# Вызываем функцию для группы B (ожидаем новый список)
group_b = add_student("Боб")
print(f"Группа B: {group_b}")
# Вызываем функцию для группы C
group_c = add_student("Чарли")
print(f"Группа C: {group_c}")
Разбор результатов: неожиданный эффект накопления
Если мы выполним код из предыдущего блока, мы увидим результаты, которые приведут новичка в полное замешательство. Вывод консоли будет следующим: `Группа A: ['Алиса']`, затем `Группа B: ['Алиса', 'Боб']` и, наконец, `Группа C: ['Алиса', 'Боб', 'Чарли']`. Ожидалось, что каждая группа будет состоять только из одного студента, так как мы предполагали, что каждый раз создается новый список. Вместо этого мы получили эффект «накопления». Все три переменные (`group_a`, `group_b`, `group_c`) на самом деле указывают на один и тот же объект списка в памяти компьютера. Алиса, Боб и Чарли оказались в одном массиве. Эта ситуация имитирует диалог из наших архивных материалов для подготовки к Code Review, где Junior-разработчик не понимает, почему студенты из первой группы попадают во вторую. Senior-разработчик объясняет ему, что это классическая ошибка Mutable Default Argument. Проблема заключается не в самом списке или в методе `.append()`, а в правилах области видимости и жизненного цикла объектов в Python. Когда скрипт запускается, интерпретатор проходит по коду сверху вниз. Дойдя до инструкции `def add_student(name, students=[]):`, он создает объект функции. В этот же самый момент он вычисляет выражение `[]`, создает в куче (heap) объект списка и сохраняет ссылку на него внутри объекта функции. Каждый раз, когда мы вызываем `add_student` без второго аргумента, мы обращаемся к этому «встроенному» в функцию списку. Поскольку списки являются изменяемыми (mutable) объектами, операция `.append()` меняет состояние этого объекта на месте (in-place). Важно понимать, что если бы значением по умолчанию была строка (например, `students=""`) или число (`students=0`), мы бы не столкнулись с такой проблемой, потому что строки и числа в Python — неизменяемые (immutable) типы. При попытке их изменить Python создал бы новый объект, оставив оригинал нетронутым.
В какой момент интерпретатор Python вычисляет и создает в памяти значение по умолчанию для аргумента функции?
Флеш-карточки
Что такое Mutable Default Argument?
Нажмите, чтобы увидеть ответ
Использование изменяемого объекта (списка, словаря) в качестве значения аргумента по умолчанию, что ведет к сохранению состояния между вызовами.
Нажмите, чтобы вернуться
В чем разница между Mutable и Immutable при передаче по умолчанию?
Нажмите, чтобы увидеть ответ
Изменение Mutable объекта модифицирует оригинал в памяти функции. Изменение Immutable объекта создает новый объект, не затрагивая дефолтное значение.
Нажмите, чтобы вернуться
Когда вычисляются дефолтные аргументы?
Нажмите, чтобы увидеть ответ
Только один раз — во время выполнения инструкции def (определение функции).
Нажмите, чтобы вернуться
Погружение в память: доказательство с помощью функции id()
В программировании на Python нельзя просто полагаться на интуицию; необходимо уметь проверять свои гипотезы инструментально. Чтобы окончательно доказать себе и коллегам, что в случае с изменяемыми аргументами по умолчанию мы работаем с одним и тем же объектом в оперативной памяти, мы можем использовать встроенную функцию `id()`. Функция `id()` возвращает уникальный идентификатор объекта в течение его жизненного цикла. В реализации CPython этот идентификатор фактически является адресом объекта в оперативной памяти. Если `id()` двух переменных совпадает, это означает, что они обе ссылаются на один и тот же участок памяти, то есть являются одним и тем же объектом. Это концепция, тесно связанная с оператором `is`, который проверяет идентичность объектов (в отличие от оператора `==`, который проверяет равенство значений). Когда мы пишем надежный код (в соответствии с принципами из архивных упражнений по функциональному программированию), мы должны жестко контролировать состояние данных. В следующем блоке кода мы модифицируем нашу проблемную функцию, добавив в нее вывод `id()` списка `students`. Мы увидим, что несмотря на разные вызовы функции для создания разных групп (группы А и группы Б), идентификатор списка останется неизменным. Это наглядно демонстрирует, что интерпретатор не выделяет память под новый список при каждом вызове. Понимание этой механики отличает Junior-разработчика от Middle и Senior. Senior-разработчик не просто знает синтаксис, он видит программу как сеть объектов, взаимодействующих друг с другом через ссылки в памяти. И именно это видение позволяет избегать трудноуловимых багов, которые могут проявиться только в production-среде при высокой нагрузке или параллельном выполнении.
def add_student_with_id(name, students=[]):
students.append(name)
# Выводим адрес памяти (id) списка студентов
print(f"ID списка внутри функции: {id(students)}")
return students
list1 = add_student_with_id("Иван")
list2 = add_student_with_id("Мария")
list3 = add_student_with_id("Олег")
print(f"list1 is list2? {list1 is list2}")
Интроспекция: Магический атрибут __defaults__
Python гордится своей открытостью и возможностями интроспекции (introspection) — способности программы исследовать саму себя во время выполнения. Если значения по умолчанию сохраняются между вызовами, возникает логичный вопрос: где именно они хранятся? Ответ кроется во внутренней структуре объектов функций. Каждая функция в Python — это полноценный объект (экземпляр внутреннего класса `function`), у которого есть свои методы и атрибуты (dunder methods, от double underscore). Один из таких магических атрибутов называется `__defaults__`. Этот атрибут представляет собой кортеж (tuple), в котором интерпретатор бережно хранит значения по умолчанию для всех позиционных аргументов. Поскольку кортеж является неизменяемым типом данных (immutable), сама структура `__defaults__` изменить размер не может, однако объекты внутри этого кортежа (в нашем случае — список) могут быть изменяемыми! Это и есть корень проблемы. Когда мы выполняем `students.append(name)`, мы обращаемся к списку, который физически находится внутри кортежа `__defaults__` объекта функции `add_student`. В следующем кодовом примере мы напрямую обратимся к атрибуту `__defaults__` нашей функции ДО ее первого вызова, ПОСЛЕ первого вызова и ПОСЛЕ второго вызова. Вы воочию увидите, как список внутри этого системного кортежа растет по мере выполнения программы. Это потрясающий способ понять внутреннюю кухню языка. Знание таких механизмов (как и LEGB для областей видимости или MRO для наследования) открывает двери к метапрограммированию и созданию сложных декораторов. Вы перестаете смотреть на Python как на черный ящик и начинаете понимать его архитектуру на уровне исходного кода CPython.
def example_func(items=[]):
items.append(1)
return items
# Посмотрим, что лежит в __defaults__ ДО вызова
print("До вызовов:", example_func.__defaults__)
example_func()
# Смотрим ПОСЛЕ первого вызова
print("После 1 вызова:", example_func.__defaults__)
example_func()
# Смотрим ПОСЛЕ второго вызова
print("После 2 вызовов:", example_func.__defaults__)
Как называется магический атрибут функции (с двойными подчеркиваниями), в котором Python хранит значения по умолчанию для позиционных аргументов?
Единственно верное решение: Паттерн None
Итак, мы досконально разобрали проблему и поняли ее причины: значения по умолчанию вычисляются при определении функции, сохраняются в `__defaults__`, и если это изменяемый объект (mutable), то его модификации сохраняются между вызовами. Как же нам обойти эту архитектурную особенность языка? Сообщество Python выработало стандартный, элегантный и единственно правильный паттерн (idiom) для решения этой задачи. Вместо того чтобы указывать пустой список `[]` или пустой словарь `{}` в качестве значения по умолчанию, мы должны использовать специальный объект `None` (тип NoneType), который является неизменяемым синглтоном. Сигнатура функции должна выглядеть так: `def add_student(name, students=None):`. Внутри тела функции мы должны сделать проверку: `if students is None: students = []`. Почему это работает? Потому что `None` создается один раз, и при каждом вызове функции мы проверяем, передал ли пользователь свой собственный список. Если он ничего не передал, значением `students` будет `None`. Внутри функции сработает условный оператор, и мы выполним инструкцию `students = []`. И вот здесь кроется главная магия: инструкция `students = []` выполняется не во время определения функции, а во время ее ВЫЗОВА (execution time). Следовательно, при каждом новом вызове функции без аргумента, интерпретатор будет создавать абсолютно новый, свежий, пустой список в оперативной памяти. Таким образом, мы получаем поведение, которое изначально ожидали от нашей программы. Этот паттерн настолько распространен в мире Python, что его понимание является базовым требованием на любом техническом собеседовании. Кроме того, использование `None` делает сигнатуру функции более чистой и явной. Вы говорите пользователю: «Если вы ничего не передадите, я сам позабочусь о создании нужной структуры данных».
def add_student_fixed(name, students=None):
"""Правильный способ инициализации изменяемых аргументов."""
if students is None:
# Создаем новый список при каждом вызове, если аргумент не передан
students = []
students.append(name)
return students
# Теперь все работает правильно!
group_x = add_student_fixed("Анна")
group_y = add_student_fixed("Виктор")
print(f"Группа X: {group_x}") # Выведет: ['Анна']
print(f"Группа Y: {group_y}") # Выведет: ['Виктор']
Почему оператор 'is' предпочтительнее оператора '==' при проверке аргумента на None (например, if arg is None)?
Задание
Процесс рефакторинга: исправление унаследованного (legacy) кода с ошибкой Mutable Default Argument.
- Найдите сигнатуру функции и замените изменяемое значение по умолчанию (например, config={}) на config=None.
- Добавьте первой строкой в тело функции проверку: if config is None:
- Внутри блока if присвойте параметру новый пустой объект: config = {}.
- Запустите тесты (unit tests), чтобы убедиться, что утечка данных между вызовами функции устранена.
- Добавьте или обновите аннотации типов (Type Hints), указав Optional[dict].
Проблема со словарями (Dicts) и множествами (Sets)
До сих пор мы рассматривали проблему изменяемых аргументов по умолчанию на примере списков (lists). Однако важно понимать, что эта ловушка распространяется на абсолютно все изменяемые типы данных в Python. Два других самых популярных изменяемых типа — это словари (dictionaries, `dict`) и множества (sets, `set`). Давайте обратимся к архивным материалам нашего курса (Часть 1. Упражнения на закрепление, Работа со словарями и множествами). Предположим, вы пишете продвинутую функцию для агрегации метрик или подсчета символов, как в задании 1.2, и решаете передавать словарь для накопления результатов по умолчанию. Если вы напишете `def collect_metrics(data, metrics={}):`, вы попадете в ту же самую яму. Словарь будет создан один раз при компиляции функции. Если вы вызовете эту функцию для обработки данных разных пользователей, метрики первого пользователя бесшовно смешаются с метриками второго, третьего и так далее. Это может привести к серьезным проблемам с безопасностью и консистентностью данных (Data Leakage). Аналогичная ситуация возникает с множествами. Если вы создаете функцию `def add_unique_tag(tag, tags_set=set()):`, то `set()` будет вычислен при определении функции, и все теги будут складываться в одно глобальное множество. Разработчики уровня Intermediate обязаны выработать рефлекс: как только ваши пальцы набирают знак равенства `=` в сигнатуре функции, а затем скобки `[]` или `{}`, в голове должна срабатывать красная сигнальная лампа. Это почти всегда ошибка (за исключением очень специфических случаев использования кэширования, которые мы обсудим позже). Решение для словарей и множеств абсолютно идентично решению для списков: используем `None` в параметрах и инициализируем структуру внутри тела функции с помощью `if metrics is None: metrics = {}`.
# Ошибка со словарями (dict)
def log_user_action(action, user_session={}):
user_session[action] = True
return user_session
# Сессия пользователя 1
session1 = log_user_action("login")
# Сессия пользователя 2 (неожиданно содержит действия первого!)
session2 = log_user_action("view_profile")
print(session2) # {'login': True, 'view_profile': True}
Пользовательские классы и ООП (Custom Objects)
Проблема изменяемых аргументов по умолчанию простирается далеко за пределы встроенных коллекций (списков, словарей, множеств). Она в полной мере затрагивает объектно-ориентированное программирование (ООП) и любые экземпляры пользовательских классов. Вспомним наше задание 3.1 из архивных материалов: «Система управления библиотекой», где мы создавали класс `Book`. Представьте, что вы пишете функцию `def register_user(name, favorite_book=Book('Неизвестно', 'Аноним'))`. Что произойдет в этом случае? Как вы уже, наверное, догадались, интерпретатор Python выполнит выражение `Book('Неизвестно', 'Аноним')` прямо в момент чтения определения функции. В памяти будет создан ровно ОДИН экземпляр класса `Book`. И все пользователи, которые будут зарегистрированы без указания любимой книги, получат ссылку на этот единственный объект! Если позже один из пользователей решит изменить автора своей дефолтной книги (`user1.favorite_book.author = 'Оруэлл'`), то автор изменится У ВСЕХ остальных пользователей, у которых не была указана книга при регистрации. Это поведение нарушает фундаментальные принципы инкапсуляции и независимости объектов. Любой объект в Python, класс которого не запрещает изменение внутреннего состояния (в отличие от `tuple`, `int`, `str`), является изменяемым. Даже если вы передаете экземпляр класса `datetime.now()` как значение по умолчанию `def log_event(event_name, timestamp=datetime.now()):`, время будет вычислено один раз — в момент запуска скрипта! Все последующие вызовы этой функции без аргумента `timestamp` будут регистрировать события с одним и тем же временем запуска программы. Это еще одна классическая вариация той же самой архитектурной особенности Python.
from datetime import datetime
import time
# АНТИПАТТЕРН: datetime.now() вычисляется один раз!
def log_system_event(msg, timestamp=datetime.now()):
print(f"[{timestamp}] {msg}")
log_system_event("Запуск сервера")
time.sleep(2) # Имитация задержки
log_system_event("Подключение базы данных") # Время будет таким же!
Какое значение нужно установить по умолчанию для аргумента timestamp, чтобы время генерировалось динамически при каждом вызове функции?
Флеш-карточки
В чем ошибка кода: def create_profile(data, tags={}): ...?
Нажмите, чтобы увидеть ответ
Использование пустого словаря {} как дефолтного значения приведет к тому, что все профили, созданные без tags, будут использовать один и тот же словарь в памяти.
Нажмите, чтобы вернуться
В чем ошибка кода: def get_time(current_time=time.time()): ...?
Нажмите, чтобы увидеть ответ
Функция time.time() будет вызвана один раз при загрузке модуля, и дефолтное значение застынет. Оно не будет обновляться при каждом вызове функции.
Нажмите, чтобы вернуться
Как исправить проблему со временем?
Нажмите, чтобы увидеть ответ
def get_time(current_time=None): if current_time is None: current_time = time.time()
Нажмите, чтобы вернуться
Типизация (Type Hinting) правильного паттерна
В современном Python (версии 3.6 и выше) использование аннотаций типов (Type Hints) стало индустриальным стандартом (PEP 484). Они не влияют на выполнение кода, но позволяют инструментам статического анализа (например, mypy, Pyright) и вашему редактору кода (IDE, например, PyCharm или VS Code) находить ошибки до запуска программы. Когда мы заменяем `def add_student(name: str, students: list = [])` на паттерн с `None`, у нас возникает проблема типизации. Если мы напишем `def add_student(name: str, students: list = None)`, статический анализатор может выдать предупреждение. Почему? Потому что мы утверждаем, что параметр `students` должен иметь тип `list`, но по умолчанию передаем `None`, который имеет тип `NoneType`. Это несовпадение типов! Чтобы правильно описать такую ситуацию, нам нужен тип, который говорит: «Это может быть список, А МОЖЕТ БЫТЬ None». В модуле `typing` для этого существует специальный обобщенный тип `Optional`. Правильная аннотация выглядит так: `from typing import Optional, List` и сигнатура: `def add_student(name: str, students: Optional[List[str]] = None):`. `Optional[X]` — это синтаксический сахар для `Union[X, None]` (то есть тип X или тип None). Начиная с Python 3.10 (PEP 604), синтаксис стал еще лаконичнее и элегантнее: теперь вместо импорта `Optional` мы можем использовать оператор вертикальной черты (pipe) `|` для обозначения объединения типов. Вы можете написать `def add_student(name: str, students: list[str] | None = None):`. Это современный, чистый и предельно понятный способ (Pythonic way) описания функций с опциональными изменяемыми аргументами. Применение правильной типизации не только документирует ваш код, но и делает автодополнение в вашей IDE значительно умнее, предлагая методы списка только внутри блока, где `students` уже гарантированно является списком.
# Современный подход: Python 3.10+ с Type Hints (PEP 604)
def add_user(username: str, metadata: dict[str, str] | None = None) -> dict[str, str]:
"""
Добавляет пользователя, используя правильный паттерн с типизацией.
"""
if metadata is None:
metadata = {} # Здесь type checker понимает, что metadata теперь dict
metadata['username'] = username
metadata['registered'] = 'yes'
return metadata
user1 = add_user("neo")
user2 = add_user("trinity")
Какая аннотация типов (в Python 3.10+) является наиболее корректной и современной для аргумента списка, использующего паттерн None?
Исключение из правил: Преднамеренное использование (Memoization)
Существует ли ситуация, когда сохранение состояния в аргументах по умолчанию является полезным? Да, существует! Иногда опытные разработчики используют эту «ошибку» как фичу (feature) для создания кэширующих функций. Этот паттерн называется Мемоизация (Memoization). Мемоизация — это метод оптимизации, применяемый для ускорения выполнения программ путем сохранения результатов длительных вызовов функций и возвращения кэшированного результата при повторном вызове с теми же входными данными. Предположим, у вас есть ресурсоемкая математическая функция, например, вычисление чисел Фибоначчи или запрос к медленному внешнему API (как в нашем Модуле 4: Работа с внешним миром). Вы можете намеренно использовать словарь в качестве значения по умолчанию: `def factorial(n, _cache={}):`. Подчеркивание перед именем параметра `_cache` — это договоренность PEP 8, означающая, что этот аргумент является внутренним (private) и не должен передаваться пользователем при вызове функции. Внутри функции вы сначала проверяете, есть ли аргумент `n` в ключах словаря `_cache`. Если есть, вы мгновенно возвращаете `_cache[n]`. Если нет, вы производите сложные вычисления, записываете результат в `_cache[n]` и возвращаете его. Поскольку словарь вычисляется один раз при определении функции, он работает как глобальное хранилище данных, привязанное исключительно к этой функции. Это изящный трюк, который демонстрирует глубокое понимание механики языка. Однако в современном Python (начиная с версии 3.2) для мемоизации чаще используют встроенный декоратор `@functools.lru_cache` или `@functools.cache` (Python 3.9+). Декораторы делают код чище и скрывают логику кэширования от сигнатуры функции. Тем не менее, использование словаря по умолчанию для кэширования остается классическим паттерном, который вы можете встретить в исходных кодах популярных библиотек.
# Использование изменяемого аргумента по умолчанию как фичи (Мемоизация)
def expensive_computation(x, _cache={}):
if x in _cache:
print(f"[{x}] Взято из кэша!")
return _cache[x]
print(f"[{x}] Вычисляем (имитация долгой работы)...")
result = x * x * x # Какое-то сложное вычисление
_cache[x] = result
return result
expensive_computation(5) # Вычисляем
expensive_computation(5) # Берем из кэша
expensive_computation(10) # Вычисляем
Как называется паттерн оптимизации производительности, при котором результаты ресурсоемких вызовов функции сохраняются (например, в словаре-аргументе) для повторного использования?
Защита от ошибки: Статический анализ кода и Линтеры
Человеческий фактор неизбежен. Даже очень опытные Senior-разработчики могут в спешке написать `data=[]` в параметрах функции. Чтобы этого избежать, современные процессы разработки (CI/CD) включают в себя этап статического анализа кода. Статический анализ — это процесс автоматической проверки исходного кода без его фактического запуска. Инструменты, называемые линтерами (Linters), сканируют ваш код на предмет синтаксических ошибок, нарушения стиля оформления (PEP 8) и потенциальных багов. В экосистеме Python существуют популярные инструменты, такие как `pylint`, `flake8` и современный, невероятно быстрый `ruff` (написанный на Rust). Если вы прогоните код с изменяемым аргументом по умолчанию через `pylint`, он немедленно выдаст строгое предупреждение: `W0102: Dangerous default value [] as argument (dangerous-default-value)`. Линтер понимает, что вы используете список в качестве дефолтного значения, и предупреждает о высокой вероятности ошибки. Настройка линтера в вашем редакторе (VS Code или PyCharm) — это первый шаг к написанию профессионального кода. Ваша IDE будет подчеркивать такие участки желтым или красным цветом еще до того, как вы сохраните файл. В больших компаниях код просто не пройдет автоматическую проверку (Continuous Integration) и не будет допущен к слиянию (Merge Request), пока вы не исправите `[]` на `None`. Использование таких инструментов — это часть методологии Scaffolding (поэтапной поддержки), о которой мы упоминали в архитектурном обзоре курса. Линтер выступает в роли наставника (Senior), который бьет вас по рукам каждый раз, когда вы совершаете эту ошибку, пока у вас не выработается стойкий рефлекс использовать паттерн `None`.
# Представьте, что вы запускаете линтер (например, flake8) для этого кода:
def process_data(data={}):
pass
# Вывод терминала:
# main.py:3:22: B006 Do not use mutable data structures for argument defaults.
# Они вычисляются один раз во время определения функции.
Флеш-карточки
Что такое Linter (например, Pylint, Flake8)?
Нажмите, чтобы увидеть ответ
Программа для статического анализа кода, которая находит синтаксические ошибки, антипаттерны и нарушения стиля оформления (PEP 8) без запуска программы.
Нажмите, чтобы вернуться
Какое предупреждение (сообщение) обычно выдает линтер при виде def func(lst=[]):?
Нажмите, чтобы увидеть ответ
Dangerous default value (Опасное значение по умолчанию) или Do not use mutable data structures for argument defaults.
Нажмите, чтобы вернуться
Почему важно использовать линтеры?
Нажмите, чтобы увидеть ответ
Они автоматизируют процесс поиска багов, повышают качество кода и обучают разработчика лучшим практикам программирования (Best Practices).
Нажмите, чтобы вернуться
Project-Based Learning: Разработка логгера запросов (API Logger)
Чтобы закрепить наши знания через проектное обучение (PBL - Project-Based Learning), давайте создадим практический инструмент, который часто требуется в веб-разработке (как мы обсуждали в модуле Modern Web Development). Наша задача — написать функцию-логгер `log_api_request`, которая будет регистрировать обращения к нашему серверу. Функция должна принимать URL запроса и словарь `headers` (HTTP-заголовки). По умолчанию, если заголовки не переданы, система должна подставлять стандартный заголовок: `{'User-Agent': 'MyPythonApp/1.0'}`. Если мы напишем `def log_api_request(url, headers={'User-Agent': 'MyPythonApp/1.0'}):`, мы создадим бомбу замедленного действия. Если во время работы функции мы решим дополнить заголовки (например, добавить токен авторизации или временную метку `headers['X-Timestamp'] = time.time()`), эти новые данные навсегда останутся в дефолтном словаре! Следующий запрос, который придет без своих заголовков, получит заголовок `User-Agent`, но также и `X-Timestamp` от предыдущего запроса. Это может привести к тому, что один пользователь случайно воспользуется авторизационным токеном другого пользователя (Критическая уязвимость безопасности: Session Hijacking). Чтобы этого избежать, мы применим изученный паттерн. Мы установим `headers=None`. Внутри функции мы напишем: `if headers is None: headers = {'User-Agent': 'MyPythonApp/1.0'}`. При такой архитектуре каждый новый вызов без аргументов будет генерировать абсолютно новый словарь в памяти, изолируя сессии пользователей друг от друга. В следующем блоке кода мы реализуем этот проект.
import time
def log_api_request(url: str, headers: dict | None = None):
"""
Регистрирует API запрос.
Использует безопасный паттерн для инициализации изменяемых параметров.
"""
# Безопасная инициализация словаря
if headers is None:
headers = {'User-Agent': 'MyPythonApp/1.0'}
# Добавляем временную метку для ВСЕХ запросов
headers['X-Request-Time'] = str(time.time())
print(f"[LOG] Вызван URL: {url}")
print(f"[LOG] Заголовки: {headers}\n")
# Эмуляция независимых запросов
log_api_request("/api/v1/users")
time.sleep(0.1)
log_api_request("/api/v1/posts")
Задание
Практическое задание: Рефакторинг класса Employee
- Создайте класс Employee с методом __init__(self, name, skills=[]).
- Убедитесь, что при создании двух разных сотрудников (без передачи skills) добавление навыка одному отражается на обоих.
- Исправьте метод __init__, заменив skills=[] на skills=None.
- Добавьте проверку if skills is None внутри метода __init__.
- Проверьте, что теперь сотрудники имеют независимые списки навыков, и инкапсуляция не нарушена.
Глубокий уровень: Байт-код (Bytecode) и инструкция MAKE_FUNCTION
Для разработчиков, стремящихся к уровню Senior, простого понимания «оно так работает» недостаточно. Нужно заглянуть под капот интерпретатора CPython. Python — это компилируемый интерпретатор. Сначала ваш текстовый файл компилируется в промежуточный байт-код (bytecode), а затем виртуальная машина Python (PVM) выполняет этот байт-код. Мы можем использовать встроенный модуль `dis` (disassembler) для изучения инструкций виртуальной машины. Когда интерпретатор встречает ключевое слово `def`, он генерирует и выполняет инструкцию `MAKE_FUNCTION`. Именно в этот момент происходит магия! Перед вызовом `MAKE_FUNCTION` интерпретатор вычисляет все значения по умолчанию (например, создает список `[]`) и помещает их на стек (stack). Затем инструкция `MAKE_FUNCTION` забирает этот список со стека и упаковывает его в магический атрибут `__defaults__` нового объекта функции. Поскольку функция создается только один раз (когда модуль импортируется или скрипт запускается), инструкция `MAKE_FUNCTION` выполняется только один раз. А когда вы вызываете функцию (`func()`), виртуальная машина выполняет инструкцию `CALL_FUNCTION`. Эта инструкция смотрит: «Ага, пользователь не передал аргумент. Давай-ка возьмем его из `__defaults__`». Она берет ссылку на тот самый объект списка в памяти и передает ее в локальную область видимости (local scope) функции. Если внутри функции мы делаем `.append()`, мы модифицируем объект по этой ссылке. Понимание байт-кода рассеивает любую «магию» языка. Вы ясно видите, что поведение изменяемых аргументов по умолчанию — это не баг (bug), а естественное и неизбежное следствие дизайна виртуальной машины и оптимизации производительности. Создавать новые объекты каждый раз при вызове `MAKE_FUNCTION` было бы архитектурно неправильно для компилятора.
import dis
# Посмотрим на байт-код создания функции!
def example():
def inner(lst=[]):
pass
return inner
print("Разбор байт-кода функции, содержащей def:")
dis.dis(example)
Какая инструкция байт-кода виртуальной машины Python (PVM) отвечает за создание объекта функции и привязку вычисленных значений по умолчанию к атрибуту __defaults__?
Заключение и лучшие практики (Best Practices)
Подошел к концу наш глубокий разбор одной из самых обсуждаемых особенностей языка Python. Мы прошли путь от простого удивления при виде странного поведения функции до глубокого понимания внутренней работы CPython, атрибута `__defaults__` и инструкций виртуальной машины. Давайте подведем итоги в формате лучших практик (Best Practices), которые вы должны применять каждый день. Во-первых, выучите наизусть: НИКОГДА не используйте `[]`, `{}` или `set()` в качестве значений по умолчанию в сигнатурах функций, если только вы не делаете это намеренно для кэширования. Во-вторых, используйте паттерн `arg=None` в сигнатуре и `if arg is None: arg = []` внутри тела функции. В-третьих, не забывайте про пользовательские классы и вызовы функций (например, `time.time()`) — они тоже вычисляются один раз при определении. В-четвертых, применяйте аннотации типов (Type Hints) с использованием `Optional` или оператора `|`, чтобы сделать код понятным для статических анализаторов и ваших коллег. В-пятых, настройте линтер (pylint, ruff) в вашем редакторе, чтобы он автоматически ловил эту ошибку. Овладение этой темой — это важная веха на пути от новичка (Junior) к уверенному разработчику (Intermediate/Senior). Вы научились мыслить не категориями текста программы, а категориями объектов, ссылок и жизненного цикла данных в оперативной памяти. Это понимание поможет вам в дальнейшем при изучении сложных концепций ООП, метаклассов и многопоточности (threading/multiprocessing), где управление доступом к изменяемым объектам становится критически важной задачей. Поздравляю с успешным прохождением этого важного урока!