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

Классическая ловушка: аргумент по умолчанию объявлен как список, функция вызывается трижды, и на третий раз в нём лежат данные первых двух вызовов. Причина не в магии, а в том, что [] создаётся один раз при определении функции и живёт в её __defaults__. Изменяемость по умолчанию в Python приводит к целому классу таких багов: разделяемое состояние между вызовами, объекты, которые нельзя положить в множество или использовать ключом словаря, и структуры, которые тихо меняются под ногами в многопоточном коде.
Разбираем, какими средствами добиться неизменяемости в Python 3.11+, где она настоящая, а где — лишь соглашение, и сколько это стоит по памяти и удобству. Angle: только про неизменяемость самих данных; вопросы конкурентного доступа и GIL — отдельная тема, здесь не разворачиваем.
Что в Python неизменяемо изначально
Часть встроенных типов неизменяема на уровне интерпретатора: у них нет методов, меняющих объект на месте. Это int, float, bool, str, bytes, tuple, frozenset и complex. Любая операция над ними возвращает новый объект, а старый остаётся прежним.
text = "hello"
upper = text.upper() # новый объект
print(text, upper) # hello HELLO — исходный не изменилсяЭто свойство даёт им важную способность: быть хешируемыми. Только неизменяемый объект можно положить ключом в словарь или элементом во множество, потому что его хеш не должен меняться за время жизни. Именно поэтому tuple кладётся ключом, а list — нет.
Но неизменяемость здесь поверхностная. Кортеж защищает только свою структуру, а не содержимое:
data = ([1, 2], 3)
data[0].append(99) # разрешено: сам список изменяемый
print(data) # ([1, 2, 99], 3)
hash(data) # TypeError: unhashable type: 'list'Кортеж нельзя расширить или переприсвоить элемент, но вложенный список в нём меняется свободно. Разница между поверхностной и глубокой неизменяемостью — то, о чём чаще всего забывают.
Неизменяемость в Python почти всегда поверхностная. Замороженный контейнер держит только свои ссылки, а не то, на что они указывают. Глубину нужно обеспечивать самому — на каждом уровне вложенности.
Способы сделать свои данные неизменяемыми
Для собственных классов есть несколько инструментов с разной строгостью и ценой. Пройдём по ним от самого декларативного к самому жёсткому.
NamedTuple
Наследник tuple с именованными полями. Неизменяемость настоящая на уровне полей, объект хешируемый, занимает столько же памяти, сколько обычный кортеж — атрибуты хранятся в структуре кортежа, а не в __dict__.
from typing import NamedTuple
class Point(NamedTuple):
x: int
y: int
p = Point(1, 2)
p.x = 5 # AttributeError: can't set attribute
print(hash(p)) # работает — можно ключом словаряМинус: под капотом это кортеж, поэтому p[0] тоже работает и наследование ведёт себя как у tuple. Подходит для простых записей-значений без методов бизнес-логики.
frozen dataclass
Датакласс с frozen=True запрещает присваивание атрибутов после создания: попытка приводит к FrozenInstanceError. Реализовано через переопределённые __setattr__ и __delattr__, которые бросают исключение.
from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
host: str
port: int
cfg = Config("localhost", 8080)
cfg.port = 9090 # dataclasses.FrozenInstanceErrorС frozen=True датакласс автоматически получает __hash__, если также включён eq=True (по умолчанию так и есть). В отличие от NamedTuple, здесь можно объявлять методы и не тащить за собой поведение кортежа. За модификацию отвечает __setattr__, поэтому создание объекта чуть медленнее обычного датакласса — присваивание в __init__ идёт через object.__setattr__.
__slots__ плюс защита от присваивания
Если нужно только запретить добавление новых атрибутов и сэкономить память, помогает __slots__. Он не делает объект неизменяемым — существующие поля всё ещё меняются, — но убирает __dict__ и не даёт присвоить незаявленный атрибут. С Python 3.10 у датаклассов есть параметр slots=True, комбинируемый с frozen=True.
MappingProxyType и tuple вместо изменяемых полей
Когда объект хранит словарь или список, сама заморозка контейнера-владельца не защитит вложенное. Для словарей есть types.MappingProxyType — read-only обёртка, через которую нельзя менять исходный словарь.
from types import MappingProxyType
_settings = {"debug": False, "retries": 3}
settings = MappingProxyType(_settings)
settings["debug"] = True # TypeError: 'mappingproxy' object does not support item assignment
_settings["debug"] = True # но исходный словарь менять можноВажный нюанс: MappingProxyType — это вид, а не копия. Если сохранить ссылку на исходный словарь, менять его можно, и изменения будут видны через прокси. Настоящая защита — не давать наружу исходную ссылку.
Сравнение подходов
| Подход | Неизменяемость | Хешируемость | Методы в классе | Память |
|---|---|---|---|---|
tuple | Поверхностная | Да (если элементы хешируемы) | Нет | Минимум |
NamedTuple | Поверхностная, поля защищены | Да | Ограниченно | Как у tuple |
frozen dataclass | Поверхностная, поля защищены | Да (при eq=True) | Да | Есть __dict__, если без slots |
frozen dataclass + slots | Поверхностная, поля защищены | Да | Да | Меньше, без __dict__ |
MappingProxyType | Только чтение, копии нет | Нет | — | Обёртка над словарём |
Когда что выбирать
- Простая запись из двух-трёх значений, нужна как ключ словаря —
NamedTuple. Меньше кода, кортежная семантика в плюс. - Объект с методами и валидацией, который нельзя менять после создания —
frozen dataclass. Добавьтеslots=True, если таких объектов создаётся много. - Публичный словарь конфигурации, который отдаёте наружу —
MappingProxyTypeповерх приватного словаря, ссылку на который никому не передаёте. - Глубокая неизменяемость вложенных структур — заменяйте списки на кортежи, словари на
MappingProxyTypeили замороженные датаклассы на каждом уровне. Автоматически это Python не сделает.
Где подход ломается
Обход через object.__setattr__. Неизменяемость frozen-датакласса — это соглашение уровня Python, а не защита памяти. Прямой вызов object.__setattr__(cfg, "port", 9090) обойдёт запрет. Frozen защищает от случайной ошибки, а не от злонамеренного кода.
Вложенные изменяемые поля. frozen dataclass с полем-списком остаётся уязвимым: сам список меняется свободно. Хешируемость такого объекта тоже сломается — hash() упадёт на изменяемом поле, если оно участвует в сравнении.
Deepcopy и pickle. Frozen-датаклассы корректно копируются через copy.deepcopy и сериализуются, но при кастомном __setattr__ в наследнике поведение стоит проверять — переопределённый сеттер может конфликтовать с восстановлением состояния.
Ложное чувство потокобезопасности. Неизменяемый объект безопасно читать из нескольких потоков, но само по себе это не делает потокобезопасным код, который эти объекты пересоздаёт и переприсваивает переменной. Атомарность присваивания и видимость — отдельный вопрос.
Что делать дальше
Если проект уже большой, начните с публичных границ: заморозьте объекты, которые уходят из вашего модуля наружу и меняются потребителями по ошибке. Внутри цикла обработки данных изменяемость часто оправдана скоростью. Для сложных персистентных структур с частичными обновлениями посмотрите на библиотеки вроде pyrsistent — они дают структурно разделяемые неизменяемые коллекции, где обновление возвращает новую версию без полного копирования. Померьте разницу в памяти между обычным и slots-датаклассом на своих объёмах через tracemalloc, прежде чем переписывать всё подряд ради экономии.
Поиск источников: Реальные статьи и книги со ссылками, библиография по ГОСТ
Спросить за 15 ₽Источники: dataclasses — Data Classes, Python documentation, types.MappingProxyType, Python documentation
Частые вопросы
Есть ли frozen=True у dataclass во всех версиях Python 3?
Почему hash() падает на frozen dataclass с полем-списком?
MappingProxyType защищает исходный словарь полностью?
А если у меня Python 3.9 и slots=True не работает?
Можно ли полностью запретить изменение объекта в Python?
Неизменяемые объекты медленнее обычных?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.