Метакласи — Динамічне створення класів під капотом CPython
Метакласи — Динамічне створення класів під капотом CPython
Проблема: коли класів занадто багато, або потрібна тотальна стандартизація
У більшості мов програмування (наприклад, Java, C++ чи C#) клас — це статична, незмінна інструкція, за якою компілятор створює об'єкти в пам'яті. Ви не можете під час виконання програми змінити структуру класу, додати нові методи чи «на ходу» згенерувати абсолютно новий тип даних. Усі типи фіксуються на етапі компіляції.
Але Python — динамічна мова. Тут класи не просто описують об'єкти. Класи самі є об'єктами. Вони існують у пам'яті як повноцінні сутності типу type, які можна передавати як аргументи функцій, повертати, модифікувати та створювати в реальному часі.
Уявіть ситуацію: ви будуєте великий фреймворк для розробки API чи ORM. Користувачі вашого фреймворку будуть створювати сотні класів моделей. Ви хочете гарантувати, що:
- Кожен клас моделі обов'язково має унікальний атрибут
__tablename__. - Усі назви методів записані виключно в
snake_case. - Будь-який оголошений клас автоматично реєструється в центральному списку плагінів (Plugin Registry).
Можна спробувати перевіряти це під час запуску програми через рефлексію, або написати складні декоратори для кожного класу. Але декоратор легко забути написати, а перевірки після запуску — це втрачений час.
Саме для таких завдань — повного контролю над створенням, структурою та поведінкою самих класів — існують метакласи. Якщо спрощено:
Перед тим як перейти до метакласів, ми повинні детально розібратися з тим, як Python створює класи під капотом і чому класи — це звичайні об'єкти.
Частина I: Динамічна природа класів (Класи як об'єкти)
Коли інтерпретатор Python бачить ключове слово class, він виконує код у тілі класу в окремому просторі імен (тимчасовому словнику), а потім викликає конструктор спеціального класу, який створює об'єкт нашого класу.
Доведемо, що клас — це об'єкт:
class Dummy:
pass
print(type(Dummy)) # Виведе: <class 'type'>
Клас Dummy — це звичайний об'єкт у купі (heap) пам'яті. Він є екземпляром вбудованого класу type. Це означає, що type є метакласом за замовчуванням для всіх класів у Python.
Це створює цікаву подвійність функції type(). Вона виконує дві абсолютно різні ролі залежно від кількості аргументів, які ви їй передаєте:
type(obj)— приймає один аргумент і повертає клас (тип) об'єкта.type(name, bases, dict)— приймає три аргументи й створює новий об'єкт класу динамічно «на льоту».
Параметри конструктора type
Коли ми створюємо клас динамічно, конструктор type очікує три параметри:
__name__.__dict__ нашого класу.Створення класу вручну
Давайте порівняємо стандартне оголошення класу та його створення за допомогою type():
# dynamic_class.py
# 1. Класичний опис класу
class User:
species = "Homo sapiens"
def __init__(self, username: str):
self.username = username
def greet(self) -> str:
return f"Вітаю, я {self.username}!"
# 2. Динамічне створення еквівалентного класу через type()
def user_init(self, username: str):
self.username = username
def user_greet(self) -> str:
return f"Вітаю, я {self.username}!"
# Створюємо клас і записуємо в змінну DynamicUser
DynamicUser = type(
"User", # Ім'я класу
(object,), # Кортеж базових класів (наслідуємось від object)
{
"species": "Homo sapiens", # Атрибут класу
"__init__": user_init, # Метод ініціалізації
"greet": user_greet, # Звичайний метод
}
)
Тепер ми можемо використовувати обидва класи абсолютно однаково. Вони створюють об'єкти з ідентичною поведінкою та структурою:
# Продовження dynamic_class.py
# Створюємо екземпляр звичайного класу
user1 = User("arakviel")
print(f"User 1: {user1.greet()} ({user1.species})")
# Створюємо екземпляр динамічного класу
user2 = DynamicUser("kostyl_dev")
print(f"User 2: {user2.greet()} ({user2.species})")
# Перевіряємо їхні класи
print(f"Тип User: {type(User)}")
print(f"Тип DynamicUser: {type(DynamicUser)}")
print(f"Чи є user2 екземпляром DynamicUser? {isinstance(user2, DynamicUser)}")
Зверніть увагу: змінна, у яку ви записуєте динамічно створений клас (у нашому випадку DynamicUser), не обов'язково має збігатися з першим аргументом конструктора ("User"). Перший аргумент — це саме ім'я об'єкта класу в системі типів (__name__), а змінна — це просто посилання на цей об'єкт.
class MyClass: ..., під капотом відбувається саме виклик type("MyClass", bases, dict). Різниця лише в тому, що синтаксис class є більш читабельним декларативним «цукром».Частина II: Власний метаклас — контроль над народженням класу
Ієрархія типів у Python
Перш ніж писати власний метаклас, треба чітко зрозуміти ієрархію типів у Python. Вона виглядає як матрьошка:
- Звичайний екземпляр (
obj) є об'єктом свого класу (class MyClass). - Клас (
class MyClass) є об'єктом свого метакласу (type). - Метаклас (
type) є об'єктом самого себе (type(type) is type).
Ця рекурсивна замкнутість — одна з найелегантніших особливостей моделі даних Python:
# type_hierarchy.py
class Animal:
pass
class Dog(Animal):
pass
d = Dog()
# Звичайна ієрархія екземпляра
print(type(d)) # <class '__main__.Dog'>
print(type(Dog)) # <class 'type'> ← клас Dog є об'єктом type
print(type(Animal)) # <class 'type'>
print(type(type)) # <class 'type'> ← type є об'єктом самого себе!
# Ієрархія наслідування (між класами)
print(Dog.__bases__) # (<class '__main__.Animal'>,)
print(Animal.__bases__) # (<class 'object'>,)
print(type.__bases__) # (<class 'object'>,) ← type наслідується від object
print(object.__bases__) # () ← object є коренем ієрархії
object — корінь ієрархії наслідування. type — корінь ієрархії метакласів. При цьому isinstance(type, object) == True і isinstance(object, type) == True — вони взаємозалежні.Написання першого метакласу
Метаклас — це клас, що успадковується від type. Так само, як звичайні класи успадковуються від object. Щоб метаклас вступив у дію, ми вказуємо metaclass=... у заголовку класу:
class MyMeta(type):
...
class MyClass(metaclass=MyMeta):
...
Тепер MyClass є об'єктом MyMeta, а не type. Метаклас MyMeta отримає повний контроль над тим, що відбуватиметься в момент, коли Python «читатиме» і «компілюватиме» тіло класу MyClass.
Два ключових методи, які слід перевизначати у метакласі:
name), кортеж батьківських класів (bases) та словник простору імен (namespace — майбутній __dict__). Повертає новий об'єкт класу. Тут зручно модифікуватиnamespace до того, як клас буде сформований. Параметр mcs — традиційне позначення для метакласу (за аналогією з cls у класметодах).__new__. Отримує вже готовий об'єкт класу cls. Тут зручно реєструвати клас десь або виконувати дії над уже створеним класом.Перший метаклас: логування створення класів
Напишемо метаклас, що просто логує факт оголошення кожного нового класу:
# first_metaclass.py
class LoggingMeta(type):
"""Метаклас-спостерігач: логує кожне створення класу."""
def __new__(mcs, name: str, bases: tuple, namespace: dict):
print(f"[META __new__] Починаємо створення класу '{name}'")
print(f" Базові класи: {[b.__name__ for b in bases]}")
print(f" Атрибути простору імен: {[k for k in namespace if not k.startswith('__')]}")
# Делегуємо реальне створення класу базовому type.__new__
cls = super().__new__(mcs, name, bases, namespace)
print(f"[META __new__] Клас '{name}' створено. Об'єкт: {cls}")
return cls
def __init__(cls, name: str, bases: tuple, namespace: dict):
print(f"[META __init__] Ініціалізуємо клас '{name}'")
super().__init__(name, bases, namespace)
print(f"[META __init__] Клас '{name}' готовий до використання.\n")
# Оголошуємо класи з нашим метакласом
class Vehicle(metaclass=LoggingMeta):
max_speed: int = 0
def move(self) -> str:
return "Рухаюсь"
class Car(Vehicle): # Car теж отримає LoggingMeta через спадкування!
max_speed: int = 200
def honk(self) -> str:
return "Бі-бі!"
Це ключове спостереження: метаклас успадковується підкласами. Car сам не вказує metaclass=LoggingMeta, але оскільки Car успадковується від Vehicle, а у Vehicle метаклас — LoggingMeta, Python автоматично застосовує той самий метаклас і до Car. Саме тому Django ORM достатньо один раз прописати метаклас у базовому класі Model — і всі тисячі моделей у проекті автоматично матимуть потрібну поведінку.
Повний lifecycle: від class до obj()
Коли Python зустрічає оголошення класу і потім створення екземпляра, відбувається наступна строга послідовність викликів:
MyClass(). Плутати ці два рівні — найчастіша помилка при роботі з метакласами.Частина III: Практичне застосування метакласів
Сценарій 1: Валідація структури класу (обов'язкові поля)
Перший класичний сценарій: гарантувати, що кожен клас-нащадок обов'язково визначає певний атрибут або метод. Зробимо метаклас, що перевіряє наявність __tablename__ у кожній моделі бази даних:
# strict_model_meta.py
from typing import Any
class StrictModelMeta(type):
"""
Метаклас для ORM-моделей.
Гарантує, що кожна не-абстрактна модель визначає __tablename__.
"""
_REQUIRED_ATTRS = {"__tablename__"}
def __new__(mcs, name: str, bases: tuple, namespace: dict) -> type:
cls = super().__new__(mcs, name, bases, namespace)
# Пропускаємо сам базовий клас Model та абстрактні класи
is_base = not bases # клас без батьків — сам є базовим
is_abstract = namespace.get("__abstract__", False)
if not is_base and not is_abstract:
missing = mcs._REQUIRED_ATTRS - set(namespace)
if missing:
raise TypeError(
f"Клас '{name}' не визначає обов'язкові атрибути: "
f"{', '.join(sorted(missing))}. "
f"Кожна модель повинна мати атрибут __tablename__."
)
return cls
class Model(metaclass=StrictModelMeta):
"""Абстрактний базовий клас для всіх моделей."""
__abstract__ = True # Сам Model пропускається перевіркою
# ✅ Правильно — є __tablename__
class User(Model):
__tablename__ = "users"
def __init__(self, name: str):
self.name = name
class Article(Model):
__tablename__ = "articles"
# ❌ Неправильно — немає __tablename__ → TypeError при оголошенні класу!
try:
class BadModel(Model):
pass # Забули __tablename__!
except TypeError as e:
print(f"TypeError: {e}")
Зверніть увагу: помилка виникає не при BadModel(), а вже під час оголошення класу BadModel. Метаклас перехоплює момент народження класу і забороняє його, якщо структура некоректна. У великих проектах це знаходить помилки ще при старті сервера, не при першому зверненні до БД.
Сценарій 2: Plugin Registry (Автоматична реєстрація плагінів)
Другий потужний сценарій — система плагінів. Кожен клас, що успадковується від базового, автоматично реєструється в центральному словнику:
# plugin_registry.py
from __future__ import annotations
from typing import ClassVar
class PluginMeta(type):
"""
Метаклас, що автоматично реєструє всі підкласи у словнику _registry.
Базовий клас, що його використовує, виключається з реєстру.
"""
def __init__(cls, name: str, bases: tuple, namespace: dict) -> None:
super().__init__(name, bases, namespace)
# Реєструємо лише нащадків (не сам базовий клас)
if bases:
plugin_name = namespace.get("name", name.lower())
# Отримуємо реєстр з першого батьківського класу, що має метаклас
root = bases[0]
while hasattr(root, "__bases__") and root.__bases__:
potential_root = root.__bases__[0]
if not hasattr(potential_root, "_registry"):
break
root = potential_root
# Знаходимо справжній корінь з _registry
for base in bases:
if hasattr(base, "_registry"):
base._registry[plugin_name] = cls
break
class Formatter(metaclass=PluginMeta):
"""Базовий клас для форматерів виводу."""
_registry: ClassVar[dict[str, type[Formatter]]] = {}
def format(self, data: object) -> str:
raise NotImplementedError
# Реєструються автоматично при оголошенні!
class JSONFormatter(Formatter):
name = "json"
def format(self, data: object) -> str:
import json
return json.dumps(data, ensure_ascii=False, indent=2)
class CSVFormatter(Formatter):
name = "csv"
def format(self, data: object) -> str:
if isinstance(data, list) and data:
keys = data[0].keys() if isinstance(data[0], dict) else []
rows = [",".join(keys)]
rows += [",".join(str(row.get(k, "")) for k in keys) for row in data]
return "\n".join(rows)
return str(data)
class PlainTextFormatter(Formatter):
name = "text"
def format(self, data: object) -> str:
return str(data)
# Використання через реєстр
def get_formatter(fmt_name: str) -> Formatter:
cls = Formatter._registry.get(fmt_name)
if cls is None:
available = ", ".join(Formatter._registry)
raise ValueError(f"Невідомий формат '{fmt_name}'. Доступні: {available}")
return cls()
# ─── Демонстрація ────────────────────────────────────────────────────────────
print("Зареєстровані форматери:", list(Formatter._registry.keys()))
data = [{"name": "Іван", "age": 30}, {"name": "Марія", "age": 25}]
for fmt in ("json", "csv", "text"):
formatter = get_formatter(fmt)
print(f"\n[{fmt.upper()}]")
print(formatter.format(data))
Додати новий форматер тепер надзвичайно просто: достатньо оголосити клас, що успадковується від Formatter. Ніякої ручної реєстрації, ніякого редагування списків. Метаклас робить усе автоматично.
Сценарій 3: Автоматичне перетворення імен методів у snake_case
Метаклас може трансформувати сам простір імен класу ще до його остаточного збирання:
# snake_case_meta.py
import re
def to_snake_case(name: str) -> str:
"""CamelCase → snake_case."""
s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
class SnakeCaseMeta(type):
"""
Метаклас, що автоматично переіменовує методи з camelCase у snake_case.
Корисно при інтеграції з Java/JS бібліотеками або при рефакторингу.
"""
def __new__(mcs, name: str, bases: tuple, namespace: dict) -> type:
new_namespace: dict = {}
for key, value in namespace.items():
snake_key = to_snake_case(key)
if snake_key != key and callable(value):
print(f" Перейменовано: {key!r} → {snake_key!r}")
new_namespace[snake_key] = value
# Також залишаємо оригінал для зворотної сумісності
new_namespace[key] = value
else:
new_namespace[key] = value
return super().__new__(mcs, name, bases, new_namespace)
class APIClient(metaclass=SnakeCaseMeta):
"""Клас з методами у camelCase — метаклас додасть snake_case-аліаси."""
def getUserById(self, user_id: int) -> dict:
return {"id": user_id, "name": "Тестовий користувач"}
def createNewOrder(self, product: str, quantity: int) -> dict:
return {"product": product, "quantity": quantity, "status": "created"}
def deleteExpiredSessions(self) -> int:
return 42 # кількість видалених сесій
# Обидва варіанти працюють!
client = APIClient()
print(client.getUserById(1)) # camelCase (оригінал)
print(client.get_user_by_id(1)) # snake_case (автоматично додано метакласом)
Частина IV: Сучасна альтернатива — __init_subclass__ (PEP 487)
Python 3.6 вніс суттєве покращення для найпоширенішого use case метакласів. У 90% випадків метаклас потрібен лише для того, щоб зробити щось при оголошенні підкласу. PEP 487 вирішив цю задачу елегантно, не вимагаючи писати цілий метаклас.
Що таке __init_subclass__
Метод __init_subclass__ є звичайним методом класу, що викликається автоматично щоразу, коли оголошується підклас даного класу. Це означає, що базовий клас може «знати» про своїх нащадків без метакласу.
class Base:
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
# cls — це щойно оголошений підклас
print(f"Зареєстровано новий підклас: {cls.__name__}")
class Child(Base): # ← __init_subclass__ викликається тут!
pass
class GrandChild(Child): # ← і тут!
pass
Переписуємо Plugin Registry без метакласу
Давайте перепишемо попередній приклад із Plugin Registry, використовуючи __init_subclass__:
# modern_registry.py
from __future__ import annotations
from typing import ClassVar
class Formatter:
"""
Базовий клас для форматерів.
Замість метакласу використовуємо __init_subclass__.
"""
_registry: ClassVar[dict[str, type[Formatter]]] = {}
def __init_subclass__(cls, name: str = "", **kwargs) -> None:
"""
Автоматично викликається при оголошенні будь-якого підкласу Formatter.
Параметр name береться з заголовка класу: class JSON(Formatter, name="json")
"""
super().__init_subclass__(**kwargs)
# Якщо name не задано явно — використовуємо ім'я класу в нижньому регістрі
plugin_name = name or cls.__name__.lower()
Formatter._registry[plugin_name] = cls
print(f" [Registry] Зареєстровано: {plugin_name!r} → {cls.__name__}")
def format(self, data: object) -> str:
raise NotImplementedError
# Параметр name передається через заголовок класу!
class JSONFormatter(Formatter, name="json"):
def format(self, data: object) -> str:
import json
return json.dumps(data, ensure_ascii=False)
class CSVFormatter(Formatter, name="csv"):
def format(self, data: object) -> str:
return str(data)
class PlainTextFormatter(Formatter, name="text"):
def format(self, data: object) -> str:
return str(data)
print("\nРеєстр:", list(Formatter._registry.keys()))
Код став значно читабельнішим! Але найцікавіше — параметр name="json" у заголовку класу. Python автоматично передає всі «зайві» параметри із заголовка класу у __init_subclass__ через **kwargs. Це надзвичайно потужний механізм декларативного конфігурування.
Переписуємо валідацію структури без метакласу
# modern_validation.py
class Model:
"""Базовий клас моделей з валідацією через __init_subclass__."""
def __init_subclass__(cls, abstract: bool = False, **kwargs) -> None:
super().__init_subclass__(**kwargs)
if abstract:
return # Абстрактні класи пропускаємо
if not hasattr(cls, "__tablename__"):
raise TypeError(
f"Клас '{cls.__name__}' не визначає __tablename__. "
f"Кожна модель бази даних повинна мати цей атрибут."
)
print(f" [Model] Модель '{cls.__name__}' → таблиця '{cls.__tablename__}'")
class TimestampedModel(Model, abstract=True):
"""Абстрактний міксін — додає created_at та updated_at."""
created_at: str = ""
updated_at: str = ""
# ✅ Правильно
class User(Model):
__tablename__ = "users"
class Article(TimestampedModel): # TimestampedModel абстрактний → не вимагає tablename
__tablename__ = "articles" # але Article вже конкретна, тому вимагає
# ❌ Неправильно
try:
class BadModel(Model):
pass
except TypeError as e:
print(f"\nTypeError: {e}")
Метакласи проти __init_subclass__: коли що вибирати
| Критерій | Метаклас | __init_subclass__ |
|---|---|---|
| Синтаксична складність | Висока (новий клас) | Низька (метод у базовому класі) |
Модифікація namespace | ✅ Так (до фіксації класу) | ❌ Ні (клас вже готовий) |
| Кілька незалежних механізмів | Складно (конфлікти метакласів) | ✅ Легко (кожен визначає свій) |
| Читабельність | Нижча | Вища |
| Конфлікти при множинному спадкуванні | Часто | Рідко |
| Перейменування/додавання атрибутів | ✅ Через namespace | Обмежено (через setattr) |
| Рекомендований підхід (Python 3.6+) | Для складних трансформацій | Для більшості use cases |
ClassA має метаклас MetaA, а ClassB — метаклас MetaB, то class C(ClassA, ClassB) призведе до TypeError, якщо MetaA і MetaB не пов'язані спадкуванням. Один із метакласів повинен бути підкласом іншого. __init_subclass__ цієї проблеми не має.Підсумок: де метакласи живуть у реальному коді
Метакласи — потужний, але «важкий» інструмент. Більшість Python-розробників ніколи не пишуть власних метакласів — і це правильно. Але вони щодня використовують їхні результати:
- Django ORM:
ModelBase— метаклас, що читає оголошені поля, збирає метадані та реєструє кожну модель. - SQLAlchemy:
DeclarativeMetaреалізує декларативний стильBase. - Pydantic (v1):
ModelMetaclassобробляє анотації типів та поля. - Python
abc.ABCMeta: метаклас, що відстежує абстрактні методи та забороняє інстанціювання класів з незаповненими абстрактними методами. enum.EnumMeta: метаклас, що перетворює атрибути класу на членів переліку.
✅ Використовуйте метакласи коли...
namespace) до фіксації класу, або коли __init_subclass__ недостатньо (наприклад, потрібно змінити ім'я методів при оголошенні).✅ Використовуйте `__init_subclass__` коли...
❌ Не використовуйте метакласи коли...
__init_subclass__ або __post_init__ (для dataclasses). Метаклас — це найнижчий рівень API Python, і він має свою ціну в читабельності.Ключові принципи, які варто пам'ятати
| Принцип | Деталь |
|---|---|
| Клас — це об'єкт | type(MyClass) повертає type або кастомний метаклас |
type — конструктор класів | type(name, bases, dict) створює клас динамічно |
| Метаклас успадковується | Підкласи отримують метаклас батьківського класу автоматично |
Порядок: __new__ → __init__ | Спочатку метакласу, потім — у самого класу при інстанціюванні |
__prepare__ | Опційний метод метакласу для задання кастомного словника namespace |
__init_subclass__ | Сучасна альтернатива для більшості сценаріїв (Python 3.6+) |
| Конфлікти метакласів | Виникають при множинному спадкуванні від класів з різними метакласами |
Дескриптори — Магія доступу до атрибутів
Глибоке дослідження протоколу дескрипторів Python — методів __get__, __set__, __delete__ та __set_name__. Алгоритм пошуку атрибутів, різниця між data та non-data дескрипторами, а також практичні сценарії використання — від валідації полів до реалізації ORM і ледачого обчислення.
Dataclasses, NamedTuple та сучасні контейнери Python
Вичерпний розбір сучасних способів опису структур даних у Python — від ручного написання __init__ до @dataclass, NamedTuple, TypedDict і Enum. Порівняння продуктивності, використання пам'яті та зручності синтаксису.