Модель данных Python: объекты, ссылки, изменяемость, dunder-методы
Есть один документ, который отличает человека, «пишущего на Python», от человека,
«понимающего Python»: глава Data Model
в Language Reference. Она объясняет не синтаксис, а семантику: что такое объект,
что делает интерпретатор, когда вы пишете a + b, len(x), x[i], with f:,
if obj: — и почему одни и те же на вид строки кода ведут себя по-разному.
Азы («переменная», «список», «класс») здесь не повторяются: они разобраны в курсе Программирование с нуля, в частности в статьях Переменные и типы данных и Объекты и классы. Наша задача другая — построить точную ментальную модель исполнения, из которой потом выводятся ответы на реальные вопросы:
- почему функция «испортила» переданный ей список, хотя вы ничего не возвращали;
- почему
copy()не спас, аdeepcopy()съел 400 МБ и 12 секунд; - почему объект остался в памяти, хотя все ссылки на него исчезли;
- почему ваш класс сломал
set()иdict, хотя__eq__вы написали правильно; - почему
a is bвернулоTrueв REPL иFalseв продакшене.
Все эти вопросы — про одно и то же: про модель данных.
Объект: идентичность, тип, значение
В Python всё есть объект: числа, строки, функции, модули, классы, исключения, срезы, сами типы. У каждого объекта есть ровно три вещи, которые его определяют.
| Атрибут | Как получить | Меняется? | Что это в CPython |
|---|---|---|---|
| Идентичность | id(obj) |
Никогда | Адрес объекта в куче |
| Тип | type(obj) |
Практически никогда | Указатель ob_type на PyTypeObject |
| Значение | зависит от типа | У изменяемых — да | Данные в теле объекта |
Идентичность — это «кто ты», значение — «что в тебе». Оператор is сравнивает
идентичность, == — значение (через протокол, о котором ниже).
a = [1, 2, 3]
b = [1, 2, 3]
c = a
print(a == b) # True — значения равны
print(a is b) # False — это два разных объекта
print(a is c) # True — одно и то же
print(id(a) == id(c)) # True
Тип — не ярлык, приклеенный снаружи. Тип лежит внутри объекта и содержит таблицу указателей на функции: как объект складывать, как хешировать, как печатать, как итерировать. Именно эта таблица делает Python Python’ом.
Отсюда первый практический вывод: никакой операции «над переменной» не существует —
все операции идут через тип объекта. a + b — это не машинная инструкция сложения,
а поиск слота nb_add у type(a), вызов C-функции, аллокация нового объекта под
результат. Отсюда и цена: арифметика в чистом Python в 30–100 раз медленнее C
(подробнее в https://courses.digitable.life/post/python/13-performance/).
Сколько это стоит в памяти
Никаких «примитивных типов» в Python нет. Целое число — полноценный объект с заголовком:
import sys
# значения для CPython 3.12 на 64-битной платформе
print(sys.getsizeof(0)) # 28 — да, двадцать восемь байт на ноль
print(sys.getsizeof(2**64)) # 36 — по 4 байта на каждую 30-битную «цифру»
print(sys.getsizeof("")) # 41 — заголовок пустой строки
print(sys.getsizeof("a")) # 42 — ASCII: байт на символ поверх заголовка
print(sys.getsizeof([])) # 56 — пустой список
print(sys.getsizeof([0] * 1000)) # 8056 — 8 байт указателя на элемент
Важно: sys.getsizeof поверхностный — он считает сам объект, но не то, на что тот
ссылается. Размер [0] * 1000 — это 8 КБ указателей плюс один разделяемый объект 0;
а вот [i for i in range(1000)] — те же 8 КБ плюс тысяча объектов int по 28 байт.
Для честного измерения используйте
tracemalloc из стандартной
библиотеки.
Имя — это ярлык, а не ячейка
В C int x = 5 создаёт именованную ячейку памяти и кладёт туда пять. В Python
x = 5 создаёт (или находит) объект 5 и привязывает к нему имя x в
некотором пространстве имён. Имя — запись в словаре (или слот в массиве кадра, см.
https://courses.digitable.life/post/python/04-functions-and-scopes/), содержащая указатель.
Присваивание никогда не копирует объект и никогда не мутирует тот, что был привязан раньше. Оно только перевешивает ярлык.
a = [10, 20]
b = a # второй ярлык на ТОТ ЖЕ объект
b.append(30) # мутация объекта — видна через оба имени
print(a) # [10, 20, 30]
b = [1] # перепривязка имени — объект не тронут
print(a) # [10, 20, 30]
print(b) # [1]
Привязок имени в языке много, и все они работают одинаково:
x = obj # присваивание
def f(): ... # def привязывает имя функции
class C: ... # class привязывает имя класса
import json # import привязывает имя модуля
for item in items: ... # цикл привязывает item на каждой итерации
with open(p) as fh: ... # as привязывает
except ValueError as err: # тоже привязка (и авто-удаление после блока!)
if (n := len(data)) > 0: ... # морж-оператор привязывает прямо в выражении
del x # снимает привязку, объект НЕ обязательно умирает
del x не «удаляет объект» — он удаляет имя. Объект исчезнет, только если это была
последняя ссылка на него.
Аргументы функций: call by sharing
Классический спор «Python — call by value или call by reference?» бессмысленен, потому что там нет ни того, ни другого. Правильный термин — call by sharing (термин Барбары Лисков): в функцию передаётся ссылка на объект, и параметр становится ещё одним именем для него.
def spoil(items: list[int]) -> None:
items.append(999) # мутация — видна снаружи
def rebind(items: list[int]) -> None:
items = [0] # перепривязка локального имени — снаружи невидима
data = [1, 2]
spoil(data); print(data) # [1, 2, 999]
rebind(data); print(data) # [1, 2, 999] — ничего не изменилось
Практическое правило продакшн-кода: функция, которая мутирует аргумент, обязана делать
это явно и говорить об этом в имени и типе. def normalize(rows) -> list[Row] не
должна трогать вход; def sort_in_place(rows) -> None — должна и возвращает None
(так же ведут себя list.sort, list.append, random.shuffle в стандартной
библиотеке; это соглашение, а не случайность).
Изменяемость — свойство типа, а не переменной
Изменяемость решает почти всё: можно ли объект хешировать, можно ли им безопасно делиться между потоками, можно ли отдавать его наружу без копии.
| Неизменяемые | Изменяемые |
|---|---|
int, float, complex, bool |
list, dict, set, bytearray |
str, bytes |
экземпляры обычных классов |
tuple, frozenset, range |
collections.deque, defaultdict |
datetime, Decimal, Fraction |
numpy.ndarray, pandas.DataFrame |
Неизменяемость в Python поверхностная. Кортеж гарантирует, что его слоты будут указывать на те же объекты, но не гарантирует, что эти объекты не изменятся:
t = ([1, 2], "стабильно")
t[0].append(3) # разрешено: мутируем список, а не кортеж
print(t) # ([1, 2, 3], 'стабильно')
Отсюда знаменитая ловушка с составным присваиванием:
t = ([1, 2],)
t[0] += [3] # TypeError: 'tuple' object does not support item assignment
print(t) # ([1, 2, 3],) — но список УЖЕ изменён!
Почему так: t[0] += [3] разворачивается в tmp = t[0]; tmp = tmp.__iadd__([3]); t[0] = tmp. Первый шаг успешно мутирует список на месте, второй — падает при попытке
записать в кортеж. Операция «наполовину выполнилась». Это не баг, а прямое следствие
того, как определён += в Data Model.
Разница между += для изменяемого и неизменяемого — источник постоянной путаницы:
a = [1, 2]; b = a
a += [3] # list.__iadd__ — мутация на месте
print(b) # [1, 2, 3] — b видит изменение
x = (1, 2); y = x
x += (3,) # у tuple нет __iadd__ → x = x + (3,), новый объект
print(y) # (1, 2) — y не видит ничего
Копирование: три уровня, и все три вам понадобятся
import copy
original = [[1, 2], [3, 4]]
alias = original # 1. ярлык: один объект
shallow = copy.copy(original) # 2. поверхностная: новый внешний, общие вложенные
deep = copy.deepcopy(original) # 3. глубокая: дублируется всё дерево
shallow[0].append(99)
print(original) # [[1, 2, 99], [3, 4]] — сюрприз!
deep[1].append(77)
print(original) # [[1, 2, 99], [3, 4]] — а тут чисто
Поверхностную копию делают также list(xs), xs[:], dict(d), d.copy(),
set(s) — все они копируют контейнер, но не содержимое.
copy.deepcopy рекурсивно обходит граф объектов, ведёт словарь memo
(id(объекта) -> копия), поэтому корректно обрабатывает циклы и сохраняет
разделяемость внутри одного вызова. Цена — аллокация на каждый узел, десятки
микросекунд даже на маленьких структурах. В горячем пути deepcopy — типичный источник
неожиданных 30 % времени в профиле.
Свои классы участвуют в протоколе копирования через
__copy__, __deepcopy__, __reduce__:
class Connection:
"""Соединение копировать нельзя — сокет не дублируется осмысленно."""
def __deepcopy__(self, memo: dict) -> "Connection":
raise TypeError("Connection нельзя копировать; создайте новое соединение")
Практические альтернативы deepcopy, которые почти всегда лучше:
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Config:
host: str
port: int
retries: int = 3
base = Config("db.local", 5432)
staging = replace(base, host="db.staging") # новый объект, старый цел
# с Python 3.13 работает и универсальный copy.replace(base, host=...)
Неизменяемые объекты не нуждаются в копировании вообще — их можно шарить без страха.
Это главный архитектурный аргумент за frozen=True в моделях домена
(https://courses.digitable.life/post/python/16-architecture-production/).
Жизненный цикл объекта: подсчёт ссылок и сборщик циклов
CPython использует подсчёт ссылок как основной механизм: у объекта есть поле
ob_refcnt, оно увеличивается при каждой новой ссылке и уменьшается при исчезновении.
Дошло до нуля — объект немедленно освобождается.
Подсчёт ссылок даёт детерминированное освобождение: как только последнее имя
исчезло, память вернулась и файл закрылся. Это удобно — и это ловушка, потому что
свойство принадлежит CPython, а не языку. В PyPy или GraalPy сборка отложенная,
и код вроде open(path).read() оставит открытый дескриптор на неопределённое время.
Правильный ответ — контекстные менеджеры, а не надежда на рефкаунт
(https://courses.digitable.life/post/python/06-pythonic-idioms/).
import sys
data = [1, 2, 3]
print(sys.getrefcount(data)) # 2 — одна ссылка наша, одна временная (аргумент вызова)
alias = data
print(sys.getrefcount(data)) # 3
del alias
print(sys.getrefcount(data)) # 2
# Начиная с Python 3.12 (PEP 683) None, True, False, малые int «бессмертны»:
print(sys.getrefcount(None)) # огромное фиксированное число, счётчик не трогается
Рефкаунт не справляется с циклами: два объекта, ссылающиеся друг на друга, никогда
не обнулятся. Для них есть поколенческий сборщик gc,
который периодически обходит объекты-контейнеры (только их — числа и строки в циклы
попасть не могут) и находит недостижимые группы.
import gc
class Node:
def __init__(self) -> None:
self.peer: "Node | None" = None
a, b = Node(), Node()
a.peer, b.peer = b, a # цикл
del a, b # рефкаунты не обнулились
print(gc.collect()) # найдено и удалено объектов > 0
Что из этого важно в продакшене:
__del__— плохой деструктор. Он не гарантирует момент вызова, глушит исключения (печатает их в stderr), может воскресить объект и мешает отладке. Для ресурсов —withиcontextlib, для «финализации по требованию» —weakref.finalize.- Сборщик стоит времени. Долгоживущие процессы с миллионами объектов тратят на
обходы заметный процент CPU. Классический приём —
gc.freeze()после прогрева, чтобы «вечные» объекты не переползали между поколениями и не ломали copy-on-write в форк-воркерах: так делали в Instagram (Dismissing Python Garbage Collection at Instagram). - Утечка в Python — это почти всегда «забытая ссылка»: глобальный кеш, замыкание,
список подписчиков,
functools.lru_cacheна методе (держитselfвечно). Ищут черезgc.get_objects(),tracemallocиobjgraph. - Слабые ссылки (
weakref.ref,WeakValueDictionary) — штатный способ сделать кеш, который не мешает объектам умирать. Учтите:list,dict,tupleиintслабых ссылок не поддерживают, а классу со__slots__нужен явный__weakref__.
В сборках со свободными потоками (PEP 703, экспериментально с 3.13, официально
поддержанная сборка в 3.14) наивный Py_INCREF заменён на смесь biased reference
counting и отложенного подсчёта — ещё одна причина не завязывать логику на
детерминированность освобождения. Что это значит для конкурентности — в
https://courses.digitable.life/post/python/11-concurrency/.
Dunder-методы: синтаксис — это вызовы методов типа
Теперь главное. Практически весь синтаксис Python — синтаксический сахар над вызовами
методов со специальными именами (__dunder__, от double underscore). Это и делает
язык расширяемым: ваш класс может участвовать во всех операциях наравне со встроенными.
Ключевая деталь, о которую спотыкаются даже опытные разработчики: неявный поиск
специальных методов идёт по типу, а не по экземпляру. len(x) — это не
x.__len__(), а примерно type(x).__len__(x). Словарь экземпляра и __getattr__
при этом игнорируются.
class Weird:
def __init__(self) -> None:
self.__len__ = lambda: 42 # кладём dunder в экземпляр
w = Weird()
print(w.__len__()) # 42 — как обычный атрибут работает
print(len(w)) # TypeError: object of type 'Weird' has no len()
Это не каприз, а оптимизация: интерпретатор читает готовый указатель из таблицы слотов типа, минуя дорогой поиск атрибута. Из-за этого же нельзя «домешать» dunder-метод конкретному объекту — только классу.
Операторы, NotImplemented и отражённые методы
Бинарный оператор — это переговоры двух типов. Левый операнд получает шанс первым; если
он «не умеет» работать с правым, он возвращает (не бросает!) NotImplemented, и
интерпретатор пробует отражённый метод правого.
и переопределяет __radd__?"} B -- да --> R1["b.__radd__(a)"] B -- нет --> L1["a.__add__(b)"] L1 --> C{"вернул NotImplemented?"} C -- нет --> OK["результат"] C -- да --> R2["b.__radd__(a)"] R1 --> C2{"вернул NotImplemented?"} C2 -- нет --> OK C2 -- да --> L1 R2 --> D{"вернул NotImplemented?"} D -- нет --> OK D -- да --> ERR["TypeError: unsupported operand type(s)"]
Отсюда железное правило: в dunder-методах, которые не умеют работать с чужим типом,
возвращайте NotImplemented, а не бросайте TypeError. Иначе вы отбираете у второго
операнда право попробовать — и Money + Decimal упадёт там, где мог бы сработать
Decimal.__radd__. Кстати, NotImplemented — это объект-синглтон, а не исключение;
использовать его в булевом контексте нельзя (с 3.9 это DeprecationWarning, в
дальнейшем — TypeError), и путать с NotImplementedError тоже не стоит.
Равенство и хеш: контракт, который ломают чаще всего
Контракт из документации по object.__hash__
короткий и жёсткий:
a == b⟹hash(a) == hash(b). Обратное неверно (коллизии допустимы).- Хеш объекта не должен меняться за время его жизни.
- Если вы определили
__eq__и не определили__hash__, Python выставит__hash__ = None— объект станет нехешируемым. Это защита, а не наказание.
class Point:
def __init__(self, x: int, y: int) -> None:
self.x, self.y = x, y
def __eq__(self, other: object) -> bool:
if not isinstance(other, Point):
return NotImplemented # даём шанс другому типу
return (self.x, self.y) == (other.x, other.y)
p = Point(1, 2)
print(p == Point(1, 2)) # True
# print({p}) # TypeError: unhashable type: 'Point'
Правильные способы получить хешируемый value-object — либо frozen=True в датаклассе,
либо явный __hash__ по тем же полям, что и __eq__, при гарантии их неизменности:
from dataclasses import dataclass
@dataclass(frozen=True, slots=True) # eq=True по умолчанию → __hash__ сгенерируется
class Point:
x: int
y: int
print({Point(1, 2), Point(1, 2)}) # {Point(x=1, y=2)} — один элемент
Классическая авария: изменяемый объект положили в set, потом изменили поле — и он
«потерялся» в собственном множестве, потому что лежит в корзине по старому хешу.
Именно поэтому ключами словаря могут быть только неизменяемые объекты
(https://courses.digitable.life/post/python/03-collections/).
Ещё несколько фактов, которые экономят часы отладки:
print(hash(1) == hash(1.0) == hash(True)) # True — числа одного значения
d = {1: "int", True: "bool", 1.0: "float"}
print(d) # {1: 'float'} — один ключ, три перезаписи!
print(float("nan") == float("nan")) # False — NaN не равен себе
nan = float("nan")
print(nan in [nan]) # True! list сначала сравнивает по is
print(hash(-1)) # -2 — деталь CPython: -1 занят под ошибку
Хеш строк и байтов рандомизируется при каждом запуске (PEP 456, защита от
hash-flooding DoS). Поэтому порядок обхода set строк меняется между запусками, и
полагаться на него нельзя — а если для воспроизводимости очень надо, есть переменная
окружения PYTHONHASHSEED.
Упорядочивание
Операторы сравнения — это __lt__, __le__, __gt__, __ge__ (a < b пробует
a.__lt__(b), затем отражённое b.__gt__(a)). Для сортировки достаточно __lt__:
list.sort и sorted не используют ничего другого. Остальные операторы дешевле всего
получить декоратором:
from functools import total_ordering
@total_ordering
class Version:
def __init__(self, text: str) -> None:
self.parts = tuple(int(p) for p in text.split("."))
def __eq__(self, other: object) -> bool:
if not isinstance(other, Version):
return NotImplemented
return self.parts == other.parts
def __lt__(self, other: "Version") -> bool:
if not isinstance(other, Version):
return NotImplemented
return self.parts < other.parts
def __hash__(self) -> int:
return hash(self.parts)
print(sorted([Version("1.10.0"), Version("1.9.3")])[0].parts) # (1, 9, 3)
Кортежи здесь — рабочая лошадка: они сравниваются лексикографически, поэтому
self.parts < other.parts даёт корректный semver-порядок в одну строку.
Представление: __repr__ против __str__
__repr__ пишут для разработчика: он должен быть однозначным и в идеале выглядеть
как выражение, воссоздающее объект. __str__ — для пользователя; если его нет,
используется __repr__. В f-строках {x} вызывает __format__/__str__, а {x!r} —
__repr__.
class Temperature:
def __init__(self, celsius: float) -> None:
self.celsius = celsius
def __repr__(self) -> str:
return f"Temperature(celsius={self.celsius!r})"
def __str__(self) -> str:
return f"{self.celsius:.1f} °C"
def __format__(self, spec: str) -> str:
if spec == "f": # свой мини-язык форматов
return f"{self.celsius * 9 / 5 + 32:.1f} °F"
return format(str(self), spec)
t = Temperature(21.456)
print(repr(t)) # Temperature(celsius=21.456)
print(t) # 21.5 °C
print(f"{t:f}") # 70.6 °F
print(f"{t:>12}") # ' 21.5 °C' — выравнивание уходит в format(str(...))
Правило продакшена: у каждого доменного класса должен быть осмысленный __repr__.
Он попадёт в логи, в pytest-диффы, в отладчик. Датаклассы генерируют его бесплатно —
одна из главных причин их использовать.
Истинность объекта
if obj: вызывает __bool__; если его нет — __len__ (пусто = ложь); если нет и
его — объект истинен. Ложными считаются None, False, нули всех числовых типов,
пустые коллекции и строки.
class Basket:
def __init__(self, items: list[str]) -> None:
self.items = items
def __len__(self) -> int:
return len(self.items)
print(bool(Basket([]))) # False — через __len__
Здесь же живёт одна из самых дорогих ошибок в дата-коде: numpy.ndarray и
pandas.Series намеренно делают __bool__ ошибкой, потому что «истинность массива»
неоднозначна:
import numpy as np
# ValueError: The truth value of an array with more than one element is ambiguous
if np.array([1, 2]):
...
if np.array([1, 2]).any(): # правильно: явно сказать, какую агрегацию вы имели в виду
...
И вторая: проверка if value: вместо if value is not None: съедает легитимные 0,
"" и []. Для необязательных параметров всегда пишите явное сравнение с None.
Собираем всё вместе: корректный value-object
Ниже — законченный пример, где протоколы работают вместе. Это типовой «денежный» объект, который встречается в любой финтех-кодовой базе.
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from functools import total_ordering
@total_ordering
@dataclass(frozen=True, slots=True) # неизменяемый, хешируемый, компактный
class Money:
"""Сумма в конкретной валюте. Неизменяемая по построению."""
amount: Decimal
currency: str = "RUB"
def _check(self, other: Money) -> None:
if self.currency != other.currency:
raise ValueError(f"нельзя смешивать {self.currency} и {other.currency}")
def __add__(self, other: object) -> Money:
if not isinstance(other, Money):
return NotImplemented # пусть попробует правый операнд
self._check(other)
return Money(self.amount + other.amount, self.currency)
def __radd__(self, other: object) -> Money:
# sum() стартует с целого 0 — поддерживаем этот частный случай
if other == 0:
return self
return NotImplemented
def __mul__(self, factor: int | Decimal) -> Money:
if not isinstance(factor, (int, Decimal)):
return NotImplemented
return Money(self.amount * factor, self.currency)
__rmul__ = __mul__ # 3 * money работает так же
def __neg__(self) -> Money:
return Money(-self.amount, self.currency)
def __lt__(self, other: Money) -> bool:
if not isinstance(other, Money):
return NotImplemented
self._check(other)
return self.amount < other.amount
def __str__(self) -> str:
return f"{self.amount:,.2f} {self.currency}".replace(",", " ")
cart = [Money(Decimal("1200.50")), Money(Decimal("99.99")), Money(Decimal("15000"))]
print(sum(cart)) # 16 300.49 RUB — работает через __radd__
print(max(cart)) # 15 000.00 RUB — работает через __lt__
print(3 * cart[1]) # 299.97 RUB — через __rmul__
print(repr(cart[1])) # Money(amount=Decimal('99.99'), currency='RUB')
print(cart[0] == Money(Decimal("1200.50"))) # True — сгенерированный __eq__
print(cart[0] == "1200.50") # False — NotImplemented → откат на сравнение по id
print(len({Money(Decimal("1")), Money(Decimal("1"))})) # 1 — хешируется корректно
Что здесь идиоматично и почему:
Decimal, а неfloat— деньги в двоичной плавающей точке считать нельзя.frozen=Trueдаёт неизменяемость,__eq__и__hash__даром;slots=Trueубирает__dict__и экономит память (детали — в https://courses.digitable.life/post/python/05-oop/).NotImplementedвместоTypeError— сохраняем возможность взаимодействия с чужими типами.- Разные ошибки для разных ситуаций: «чужой тип» — это
NotImplemented, а «своя, но другая валюта» — этоValueError, потому что это ошибка предметной области (https://courses.digitable.life/post/python/08-errors-and-exceptions/).
Типичные грабли модели данных
# 1. Изменяемый аргумент по умолчанию: объект создаётся ОДИН раз при определении
def add(item, bucket=[]): # ловушка
bucket.append(item)
return bucket
print(add(1), add(2)) # [1, 2] [1, 2] — общий список!
def add_ok(item, bucket=None): # правильно
bucket = [] if bucket is None else bucket
bucket.append(item)
return bucket
# 2. Изменяемый атрибут класса — общий на все экземпляры
class Team:
members = [] # ловушка: один список на весь класс
def __init__(self):
self.members2 = [] # правильно: свой у каждого
# 3. Умножение списка копирует ССЫЛКИ
grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid) # [[1,0,0],[1,0,0],[1,0,0]] — три ярлыка на один ряд
grid = [[0] * 3 for _ in range(3)] # правильно
# 4. `is` вместо `==` для значений
x = 1000
y = 1000
print(x is y) # в REPL False, внутри одной функции True — не полагайтесь
print(x == y) # True — единственно верная проверка значения
# 5. id() переиспользуется после смерти объекта
print(id([1]) == id([2])) # может быть True: первый список уже освобождён
# 6. Мутация коллекции во время итерации
d = {"a": 1, "b": 2}
# for k in d: del d[k] # RuntimeError: dictionary changed size during iteration
for k in list(d): # правильно: итерируем по снимку ключей
del d[k]
# 7. `sorted` возвращает список, `sort` возвращает None
rows = [3, 1, 2]
# best = rows.sort()[0] # TypeError: 'NoneType' is not subscriptable
best = sorted(rows)[0] # правильно
Отдельный класс ошибок — общее изменяемое состояние на границе модулей: вернули
наружу внутренний список, вызывающий код его мутировал, инвариант класса сломался.
Лечится либо возвратом копии (list(self._items)), либо неизменяемым типом
(tuple(self._items)), либо аннотацией Sequence[T] вместо list[T], чтобы mypy
запретил вызывать append (https://courses.digitable.life/post/python/07-typing/).
Честно: где эта модель выигрывает, а где мешает
Выигрывает. Единая модель «всё — объект с таблицей протоколов» даёт то, чего нет
почти нигде: пользовательский тип встраивается в язык на равных со встроенными.
Именно поэтому numpy может определить a @ b для матриц, pandas — df[df.age > 30],
pathlib — Path("/tmp") / "file", а sqlalchemy — трансляцию User.age > 30 в SQL.
В языках со статическими интерфейсами и без перегрузки операторов (Go) такие библиотеки
физически невозможны — сравните с
основами Go, где явность важнее
выразительности. Плюс единообразие: выучив протокол один раз, вы понимаете любую
библиотеку.
Мешает. За гибкость платят три раза:
- Скоростью. Каждая операция — динамическая диспетчеризация плюс аллокация
результата. Нет value-типов, нет «числа на стеке»;
int— 28 байт в куче. Массив из миллиона чисел в Python — это миллион объектов и миллион указателей, в C — 8 МБ подряд. Отсюда вся дата-экосистема живёт вnumpy/arrow, а не в списках (https://courses.digitable.life/post/python/14-data-stack/). - Предсказуемостью. Подсчёт ссылок — деталь CPython;
__del__не деструктор;isврёт из-за кешей; порядок финализации при выходе интерпретатора не определён. - Параллелизмом. Изменяемый разделяемый объект без блокировки — гонка. Исторически от неё спасал GIL, но он же и ограничивал масштабирование; в свободнопоточных сборках защиты больше нет, и цена изменяемого состояния стала явной.
Куда не тащить. Задачи, где стоимость объекта критична: жёсткий реалтайм, тесные численные циклы без векторизации, счёт на десятки миллионов мелких сущностей в памяти, софт с требованием предсказуемых пауз. Там либо C/Rust/Go, либо Python в роли «клея» поверх нативного ядра — что, собственно, и есть его самая сильная роль.
Как это проверяют в продакшене
- Тесты на контракт. Для каждого value-object:
a == b ⟹ hash(a) == hash(b), рефлексивность, симметричность, сравнение с чужим типом даётFalse, а не исключение. Отлично ложится на property-based тесты сhypothesis(https://courses.digitable.life/post/python/12-testing/). - Линтеры ловят классику.
ruffправилоB006— изменяемый аргумент по умолчанию,B008— вызов в дефолте,PLW0642,E711/E712— сравнение сNone/Trueчерез==,F632—isс литералом. - mypy запрещает мутацию по типу. Принимайте
Sequence/Mapping, возвращайте конкретные типы. Это дешевле любых договорённостей в код-ревью. - Профиль памяти.
tracemalloc.take_snapshot()в двух точках и сравнение — самый быстрый способ найти растущий кеш.gc.get_objects()+Counter(type(o).__name__)показывает, каких объектов стало больше. - Ревью-чеклист по классу: есть ли
__repr__; согласованы ли__eq__и__hash__; неизменяем ли объект, если он используется как ключ; возвращают ли операторыNotImplemented; нет ли изменяемых атрибутов класса; не отдаёт ли метод наружу внутреннюю коллекцию.
Мини-итог
- Объект = идентичность + тип + значение.
isпро первое,==про третье. - Имя — ярлык. Присваивание перевешивает ярлык, а не копирует и не мутирует.
- Изменяемость — свойство типа. Неизменяемость поверхностная: кортеж со списком внутри изменяем «изнутри».
- Копирование бывает трёх уровней;
copy()почти никогда не то, что вы имели в виду, аdeepcopy()дорог. Лучший ответ — неизменяемые объекты иreplace. - Освобождение памяти в CPython детерминированное (рефкаунт) плюс сборщик циклов; но
завязываться на это нельзя — ресурсы закрывает
with. - Синтаксис = dunder-методы, и ищутся они по типу, а не по экземпляру.
- Контракт
__eq__/__hash__— самый нарушаемый в языке;frozen=Trueрешает его бесплатно.
Источники
- The Python Language Reference — Data model — первоисточник, читать целиком.
- Expressions: comparisons and operator precedence — правила вызова отражённых методов.
copy— Shallow and deep copy operationsgc— Garbage Collector interface и Design of CPython’s Garbage Collector в Developer’s Guide.weakref— Weak references- PEP 683 — Immortal Objects, PEP 703 — Making the GIL Optional, PEP 456 — Secure and interchangeable hash algorithm.
- Luciano Ramalho, Fluent Python, 2nd ed. — главы про Pythonic Object, Special Methods, Object References.
- Anthony Shaw, CPython Internals — как всё это выглядит со стороны C.
- Brett Cannon, серия «Unravelling Python’s syntax» — построчный разбор того, во что разворачивается каждая конструкция языка.
Что дальше
Модель данных объясняет, почему коллекции ведут себя так, как ведут. Теперь посмотрим, как они устроены внутри и сколько стоит каждая операция: Коллекции: списки, словари, множества, кортежи и их устройство.