Python Модель данных Python: объекты, ссылки, изменяемость, dunder-методы
0%

Модель данных Python: объекты, ссылки, изменяемость, dunder-методы

Модель данных 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’ом.

Имя, объект и таблица слотов типа в CPython

Отсюда первый практический вывод: никакой операции «над переменной» не существует — все операции идут через тип объекта. 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, и интерпретатор пробует отражённый метод правого.

Отсюда железное правило: в dunder-методах, которые не умеют работать с чужим типом, возвращайте NotImplemented, а не бросайте TypeError. Иначе вы отбираете у второго операнда право попробовать — и Money + Decimal упадёт там, где мог бы сработать Decimal.__radd__. Кстати, NotImplemented — это объект-синглтон, а не исключение; использовать его в булевом контексте нельзя (с 3.9 это DeprecationWarning, в дальнейшем — TypeError), и путать с NotImplementedError тоже не стоит.

Равенство и хеш: контракт, который ломают чаще всего

Контракт из документации по object.__hash__ короткий и жёсткий:

  1. a == bhash(a) == hash(b). Обратное неверно (коллизии допустимы).
  2. Хеш объекта не должен меняться за время его жизни.
  3. Если вы определили __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 для матриц, pandasdf[df.age > 30], pathlibPath("/tmp") / "file", а sqlalchemy — трансляцию User.age > 30 в SQL. В языках со статическими интерфейсами и без перегрузки операторов (Go) такие библиотеки физически невозможны — сравните с основами Go, где явность важнее выразительности. Плюс единообразие: выучив протокол один раз, вы понимаете любую библиотеку.

Мешает. За гибкость платят три раза:

  1. Скоростью. Каждая операция — динамическая диспетчеризация плюс аллокация результата. Нет value-типов, нет «числа на стеке»; int — 28 байт в куче. Массив из миллиона чисел в Python — это миллион объектов и миллион указателей, в C — 8 МБ подряд. Отсюда вся дата-экосистема живёт в numpy/arrow, а не в списках (https://courses.digitable.life/post/python/14-data-stack/).
  2. Предсказуемостью. Подсчёт ссылок — деталь CPython; __del__ не деструктор; is врёт из-за кешей; порядок финализации при выходе интерпретатора не определён.
  3. Параллелизмом. Изменяемый разделяемый объект без блокировки — гонка. Исторически от неё спасал 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 через ==, F632is с литералом.
  • 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 решает его бесплатно.

Источники

Что дальше

Модель данных объясняет, почему коллекции ведут себя так, как ведут. Теперь посмотрим, как они устроены внутри и сколько стоит каждая операция: Коллекции: списки, словари, множества, кортежи и их устройство.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов