Прямой ответ
Половина «магии» популярных библиотек — два механизма из data model: дескрипторы (объект с __get__/__set__/__delete__, живущий в атрибуте класса — протокол, через который работают property, classmethod, функции-как-методы и валидация полей в ORM) и метаклассы (класс, создающий классы — type как дефолтный; хук на момент рождения класса: регистрация подклассов, инъекция атрибутов, проверки контракта). Понимание снимает два практических блока: чтение чужого кода (поля SQLAlchemy/pydantic — дескрипторы и метакласс-сбор — ORM, pydantic) и написание своей «декларативной» механики без велосипедов. Главное решение практики: свой метакласс — почти никогда (современные замены проще и понятнее: __init_subclass__ для регистрации/валидации подклассов, __set_name__ для дескрипторов, class-decorator для трансформации класса — PEP 487 сделал метаклассы ненужными в 90% исторических сценариев); свой дескриптор — разумно (валидируемое поле, ленивый атрибут, кеш на инстансе — 20 строк понятного кода). И приоритет инструментов сверху вниз: обычная функция → property/dataclass → дескриптор → декоратор класса → __init_subclass__ → метакласс (каждая ступень — цена читаемости; прыжок через ступени — источник «магического» кода, который команда боится трогать).
Протокол дескрипторов: механика за property
Дескриптор — объект, реализующий протокол доступа; когда он лежит в атрибуте класса, обращение obj.attr перехватывается: obj.attr → Attr.__get__(obj, cls), присваивание → __set__(obj, value); функция — тоже дескриптор (её __get__ возвращает bound method — отсюда «методы знают self»). Классификация: data-дескрипторы (__set__/__delete__ есть) — приоритетнее __dict__ инстанса (перехватывают запись — валидация, property); non-data (только __get__) — instance dict перекрывает их (classmethod/функции; кеш-паттерн: вычислил — записал в obj.__dict__ под тем же именем — следующие чтения мимо дескриптора — классический ленивый атрибут). __set_name__(self, cls, name) (3.6+) — дескриптор узнаёт имя своего атрибута при создании класса (без него — ручная передача имени/магия — современный дескриптор пишется чисто). Практический пример — валидируемое поле: class Positive: def __set_name__(...): self.name = name; def __get__(...): return obj.__dict__.get(self.name); def __set__(obj, v): if v <= 0: raise ValueError; obj.__dict__[self.name] = v — переиспользуемая валидация в любом классе; тот же паттерн в основе полей ORM/сериализаторов — библиотеки делают то же, но со сбором метаданных.
Метаклассы: когда класс создаёт класс
Метакласс — вызываемое, порождающее класс: class Foo(metaclass=Meta) — Meta(name, bases, namespace) создаёт Foo; хуки __new__/__init__ метакласса исполняются один раз при определении класса (не при инстанцировании!) — окно для: сбора деклараций в реестр (поле класса → схема — как ORM собирает Column'ы), принудительных проверок (подкласс обязан реализовать X — иначе TypeError при импорте — ранняя ошибка вместо ночного падения), инъекции атрибутов/декорирования методов на лету. Наследование метаклассов — источник классических проблем («metaclass conflict» при смешении баз с разными метаклассами — решается общим предком-метаклассом, но сигнализирует: возможно, дизайн перегружен). Чем заменять в современном коде: __init_subclass__ — хук в родительском классе на каждый новый подкласс (регистрация в реестре, проверка обязательных атрибутов — без своего метакласса, обычный метод); __set_name__ — для дескрипторных полей; декоратор класса (@register class X — трансформация/регистрация явная, видна в месте определения); __class_getitem__ — дженерик-синтаксис (MyList[int] — типизация). Метакласс остаётся оправданным, когда нужно менять создание класса структурно (собирать поля из namespace до создания — ABCMeta/pydantic v1-наследие), и даже там библиотеки мигрируют на более простые хуки.
Где это живёт в вашем стеке
Карта «магии» типового проекта: property — дескриптор (вычисляемое поле); classmethod/staticmethod — дескрипторы; привязка методов — дескрипторный __get__ функций; dataclasses — class-декоратор (@dataclass генерирует __init__/__repr__ из аннотаций — метакласса нет — dataclass-выбор); pydantic — сбор полей из аннотаций (v2 — без пользовательского метакласса, core на Rust), валидация при присваивании (аналог data-дескриптора); SQLAlchemy Declarative — исторически метакласс (registry собирает mapper), 2.0 — декларативные базовые с __init_subclass__-подобной механикой; abc — ABCMeta (запрет инстанцирования без абстрактных методов — легитимный метакласс stdlib); enum — метакласс (сбор членов из namespace); ** Django models/формы** — метакласс-сбор полей (наследие, живое по сей день). Читая библиотеку: ищите metaclass=, __init_subclass__, классы с __get__/__set__ — три маркера объясняют почти всю декларативность. Пиша своё: начните с вопроса «что должно происходить — при доступе к атрибуту (дескриптор), при определении класса (__init_subclass__/декоратор) или при создании класса (метакласс)» — ответ почти всегда первые два.
Практический сценарий: реестр плагинов без метаклассов
Задача: базовый класс Exporter с подклассами по форматам (csv/json/parquet); фабрика должна находить их по имени формата — без ручного списка регистрации (забыл внести — баг). Первое решение «в лоб» — метакласс с реестром: работает, но команда спотыкается (metaclass conflict при наследовании от другого декларативного класса, отладка «когда это вызывается» непрозрачна). Современное решение — __init_subclass__ в базовом классе: def __init_subclass__(cls, format_name=None, **kw): super().__init_subclass__(**kw); if format_name: registry[format_name] = cls — подкласс объявляется class CsvExporter(Exporter, format_name="csv") — регистрация в момент определения, явная и видимая; проверка обязательного def export(self, rows) — там же (if not hasattr(cls, 'export'): raise TypeError — ошибка при импорте). Валидация параметра формата внутри класса — дескриптор Positive-класса из раздела выше (переиспользуется в трёх классах). Итог: ноль метаклассов, поведение то же; тесты — реестр содержит все форматы после импорта модулей (pytest-коллекция импортирует модули плагинов — параметризация по реестру). Контрпример из легаси того же проекта: метакласс-реестр 2014 года, который два разработчика пытались «просто отрефакторить» и сломали регистрацию в наследниках (MRO-тонкости) — разница не в строчках кода, а в том, что простой механизм чинится без археологии.
FAQ
Стоит ли учить метаклассы, если они «почти никогда»? Да — как читательскую грамотность: Django/pydantic-v1/attrs/библиотеки-ветераны полны ими, собеседования и code review чужих библиотек требуют понимания; писать свои — другое решение (цена поддержки метакласс-кода командой высока — «магию» должен понимать каждый, кто тронет класс).
Дескриптор против property — когда что?
property — одноразовая логика доступа к одному атрибуту (проще, локальнее); дескриптор — переиспользуемая (та же валидация в десяти классах/полях), типизированная (дескриптор-класс дружит с mypy через __get__-сигнатуры); правило: вторая копия property-логики — кандидат в дескриптор.
Почему dataclass не метакласс?
@dataclass — обычный class-декоратор: читает аннотации (cls.__annotations__), генерирует методы, возвращает класс — всё нужное доступно без вмешательства в создание класса; исторические метакласс-решения (pydantic v1) упрощены в новых версиях — тренд экосистемы однозначен.
__slots__ и дескрипторы совместимы?
Да, но с нюансом: слоты — тоже дескрипторы (data-дескриптор на класс-атрибуте), и кеш-паттерн «запись в obj.__dict__» со слотами не работает (__dict__ отсутствует) — ленивые атрибуты в slotted-классах — через отдельный слот-флаг; экономия памяти слотов и дескрипторные паттерны проектируются вместе.
Источники: Descriptor HowTo (Python docs), Data model, PEP 487 — ссылки в шапке.