Привет Хабр! В данной статье я расскажу о там, как работают метаклассы в python, что конкретно они делают, где их можно использовать и почему чаще всего лучше этого не делать.
Данная статья скорее нацелена на начинающих авторов библиотек или просто любопытных читателей, которые просто хотят узнать что-то новое о Python.
Для того, чтобы разобраться в том, что-такое метакласс, давайте для начала обратимся к официальному глоссарию.
Метакласс это класс объекта класса. Определение класса создает имя класса, словарь класса и список базовых классов. Метакласс отвечает за принятие этих трех аргументов и создание класса.
Метаклассы использовались для регистрации объектов, доступа к атрибутам, добавления потокобезопасности, отслеживания создания объектов, реализации синглтонов и многих других задач.
Дисклеймер
Сразу скажу, в целом для создания синглтонов, отслеживания создания объектов, доступа к атрибутам и регистрации объектов можно спокойно обойтись и без метаклассов. При использовании альтернативных вариантов, могут быть свои ограничения, но чаще всего вы с ними не столкнетесь или даже их наличие все еще будет удовлетворять условиям вашей задачи.
Для подавляющего количества задач, использование метаклассов является абсолютно избыточным. Помимо этого, в большинстве случаев, когда вам может показаться, что вы нашли отличное место для метакласса, скорее всего вашу задачу можно решить используя декораторы, магический метод __init_subclass__
или используя дескрипторы.
Важно отметить, что если вы еще не знаете, для чего вам нужны метаклассы, скорее всего вам не нужно их использовать. Чаще всего линейные разработчики сталкиваются с такими структурами только если читают исходники сторонних библиотек и я лично совершенно не рекомендую использовать метаклассы решая любого рода бизнес задачи.
Оглавление
Введение
Для чего же тогда метаклассы все-таки могут использоваться? В одной из прошлых статей рассказывая о декораторах, я упоминал, о декорации или переопределении магических методов __init__
и/или __new__
, а также об изменении или добавлении атрибутов объектов класса или его экземпляров.
Для справки статья про «магические методы»
Еще раз хочу акцентировать внимание на том, что подобного рода подходы являются неявными, усложняют поддержку и увеличивают сложность кода.
Сам я допускаю их применение только для изолированного в себе функционала, полностью покрытого тестами, с крайне малой вероятностью требующего модификацию в дальнейшем, а главное, позволяющего значительно сократить время разработки в случае его использования.
Данная цитата подходит и под мотивацию использования метаклассов. В целом механизм метаклассов схоже относится к созданию объекта класса, как уже сам объект класса относится к созданию экземпляра.
Для начала вернемся к части определения «Класс объекта класса» и рассмотрим подробнее, что конкретно это значит.
В большинстве объектно-ориентированных языков программирования механизм метаклассов представляется собой реализацию по умолчанию. В python таким метаклассом является объект type
, но также Python позволяет создать собственные метаклассы.
Давайте посмотрим, как это выглядит.
Примечание: в Python не только класс создает объекты, но и сам класс и его метакласс также являются объектами.
Важно отметить, что в Python при инициализации (вызове метода __init__
) экземпляра метакласса type
мы можем передать в него либо один аргумент, либо 3 (а может быть и больше). В первом случае (на этом этапе нас интересует именно он), передав единственным аргументом экземпляр какого-либо класса мы ожидаем получить объект класса этого экземпляра.
Примечание: под экземпляром метакласса type
мы подразумеваем объект класса.
Импорты из примеров
from __future__ import annotations
import enum
from types import MappingProxyType
from typing import Any, cast, TypeVar
# Допустим, у нас есть какой-либо список
first_list = [1, 2, 3]
# В целом, не важно, как именно был создан список
second_list = list(range(1, 4))
# Мы имеем 2 экземпляра класса list и можем это проверить
type(first_list) == type(second_list) == list
# True
# А теперь давайте посмотрим, что будет, если мы передадим в
# инициализацию type не экземпляр, а сам объект класса
type(list)
# <class 'type'>
Мы получили объект класса type
, так как именно type
является метаклассом по умолчанию. К сожалению данный пример является не особо наглядным, так как то, что произошло на самом деле, на уровне самого языка является неявным. Поэтому давайте обратимся к более осязаемому примеру.
Попробуем посмотреть, что будет, если вместо класса list
передать класс Enum
из встроенной библиотеки enum
. На этот раз мы можем посмотреть как именно реализован данный класс.
Мы видим следующее объявление.
class Enum(metaclass=EnumType):
...
При объявлении класса явно был передан аргумент metaclass
со значением EnumType
. Теперь мы знаем, что за создание объекта класса Enum
, и всех классов, которые наследуются от класса него отвечает метакласс EnumType
.
# Проверим, что классом объекта класса enum.Enum является уже
# не type а переданный при объявлении метакласс EnumType
type(enum.Enum)
# <class 'enum.EnumType'>
# Получаем ожидаемое поведение
# Теперь проверим, что данное поведение сохраняется и у
# унаследованных от Enum классов
class NewEnum(enum.Enum):
CONST = enum.auto()
type(NewEnum)
# <class 'enum.EnumType'>
# Получаем ожидаемое поведение
Во втором случае инициализации экземпляра класса type
мы создаем новый объект класса с именем, переданным первым аргументом, а также передаем кортеж базовых классов и словарь атрибутов нового объекта класса, например так.
Рассмотрим пример использования.
# Расширим класс list, добавив в него свойство sum, которое будет
# возвращать сумму элементов списка
ListWithSum = type(
"ListWithSum", # Имя нового класса
(list,), # Указываем list как базовый класс
# Добавим в словарь атрибутов класса свойство, которое
# возвращает сумму элементов списка
{"sum": property(lambda self: sum(self)) }
)
# Проверим, имя класса
ListWithSum.__name__
# 'ListWithSum'
# Проверим список базовых классов
ListWithSum.__bases__
# (<class 'list'>,)
# Проверим наличие атрибута sum в словаре атрибутов класса
ListWithSum.__dict__.get("sum")
# <property object at 0x7a4a47728a40>
# И наконец убедимся, что все работает
new_list = ListWithSum(range(4))
new_list
# [0, 1, 2, 3]
new_list.sum
# 6
# Получаем ожидаемое поведение во всех кейсах
Создание класса выше практически эквивалентно следующей записи, за исключением некоторых тонкостей объявления классов классическим способом.
class ListWithSum(list):
@property
def sum(self):
return sum(self)
Важно отметить, что для реализации задачи, стоящей в примере выше нет никакого смысла использовать динамическое создание объекта класса через type
, хотя при решении более сложных задач, такой подход может быть чуть ли не единственным жизнеспособным вариантом.
Например, если вы пишете библиотеку, целью которой является генерация api, схем данных, спецификаций или каких либо других объектов, чья структура может быть относительно произвольной, то приведенный выше подход имел бы смысл.
Также стоит отметить, что во многих случаях динамическое создание классов можно реализовать и более привычным образом. Ниже приведен пример динамической генерации абстрактной модели django
для использования в качестве базового класса модели.
Примечание: в случае написания подобного рода конструкций может иметь смысл кэширование уже созданных объектов классов по значениям передаваемых в функцию comment_model_factory
аргументов, но всегда лучше проверить эффективность таких доработок.
from django.db import models
from django.utils.translation import gettext_lazy as _
def comment_model_factory(
target_model: str | models.Model,
target_kwargs: dict[str, Any] | None = None,
bases: tuple[type] = ()
) -> models.Model:
"""Создает абстрактный класс модели django, в котором первичный ключ в поле
target_id ссылается на таблицу, указанную в параметре target_model
Args:
target_model: класс или строка модели, для которой нужно
добавить модель комментариев
target_kwargs: именованные аргументы, которые будут переданы в
инициализацию дескриптора для атрибута target
bases: базовые классы для создаваемого класса модели
Returns:
models.Model: абстрактный класс модели django для определения
таблицы комментариев
"""
# Указываем аргументы для поля target по умолчанию и расширяем
# их переданными в вызов функции
target_kwargs = {
"related_name": "comments",
"on_delete": models.CASCADE,
**(target_kwargs or {})
}
# Определяем новый класс, наследуемый от models.Model и классов,
# переданных в вызов функции
class Comment(*bases, models.Model):
# Определяем поле, которое зависит от параметров,
# переданных в вызов функции
target = models.ForeignKey(target_model, **target_kwargs)
# Определяем остальные поля
comment = models.TextField("Комментарий")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
# Помечаем класс абстрактным, чтобы на его основе не
# была создана таблица в базе данных
abstract = True
# возвращаем созданный класс
return Comment
# Наследуемся от класса, созданного в процессе выполнения функции
# comment_model_factory. Важно отметить, что мы могли бы записать
# результат выполнения функции в отдельную переменную и использовать
# ее значение в качестве базового класса для многих других классов
class ContractComment(
comment_model_factory(
"app.Contract",
target_kwargs={"verbose_name": _("Договор")}
)
):
"""Комментарий к договору."""
class Meta:
verbose_name = _("Комментарий к договору")
verbose_name_plural = _("Комментарии к договорам")
Создание метакласса
Если мы определяем собственный метакласс, то он базово работает аналогично type
и на его основе мы также можем создавать новые объекты классов, а также передавать какие либо дополнительные аргументы, ожидаемые в вызове магического метода __new__
описанного метакласса.
Примечание: когда мы указываем атрибут metaclass
при объявлении класса а также при объявлении его подклассов, вызывается метод __new__
указанного метакласса, в который передается имя объявленного класса, кортеж его базовых классов, список атрибутов класса и дополнительные именованные аргументы, указанные при объявлении класса.
Вот минимальный пример создания и использования пользовательского метакласса.
# Объявляем метакласс
class MetaClass(type):
def __new__(mcs, name, bases, namespace):
print("Создание объекта класса")
return super().__new__(mcs, name, bases, namespace)
# Применение метакласса выглядит следующим образом
class Foo(metaclass=MetaClass):
pass
# Сразу после объявления класса Foo будет напечатан следующий текст
# Создание объекта класса
Метакласс может наследоваться от другого метакласса, отличного от type
class Meta(type):
def __new__(mcs, name, bases, attrs):
print(f"Вызов Meta.__new__")
cls = super().__new__(mcs, name, bases, attrs)
return cls
class InheritedMeta(Meta):
def __new__(mcs, name, bases, attrs):
print(f"Вызов InheritedMeta.__new__")
cls = super().__new__(mcs, name, bases, attrs)
return cls
class Foo(metaclass=InheritedMeta):
pass
# Вызов InheritedMeta.__new__
# Вызов Meta.__new__
Имейте в виду, что при множественном наследовании от классов с разными метаклассами, вы можете встретить ошибку.
TypeError: metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its bases
# Так можно
class FooMeta(type): ...
class BarMeta(FooMeta): ... # наследуемся от FooMeta
class FooBase(metaclass=FooMeta): ...
class BarBase(metaclass=BarMeta): ...
class FooBar(FooBase, BarBase): ...
# А вот так уже нельзя
class FooMeta(type): ...
class BarMeta(type): ... # наследуемся от type
class FooBase(metaclass=FooMeta): ...
class BarBase(metaclass=BarMeta): ...
class FooBar(FooBase, BarBase): ...
У вас есть возможность создать метакласс, у которого уже определен пользовательский метакласс
class Meta(type):
def __new__(mcs, name, bases, attrs):
# Этот метод будет вызван при объявлении класса MetaWithMeta
cls = super().__new__(mcs, name, bases, attrs)
print(f"Вызов Meta.__new__ при создании {cls}")
return cls
class MetaWithMeta(type, metaclass=Meta):
def __new__(mcs, name, bases, attrs):
# Этот метод будет вызван при объявлении классов FooBase и Foo
cls = super().__new__(mcs, name, bases, attrs)
print(f"Вызов MetaWithMeta.__new__ при создании {cls}")
return cls
# Вызов Meta.__new__ при создании <class '__main__.MetaWithMeta'>
class Foo(metaclass=MetaWithMeta):
pass
# Вызов MetaWithMeta.__new__ при создании <class '__main__.Foo'>
Помимо этого метаклассом может быть не только непосредственно класс, но и другой вызываемый объект, например функция.
def meta_function(name, bases, namespace):
print("Создание объекта класса")
print("Имя класса", name)
print("Базовые классы", bases)
return type(name, bases, namespace)
class Foo(dict, metaclass=meta_function):
pass
# Сразу после объявления класса Foo будет напечатан следующий текст
# Создание объекта класса
# Имя класса Foo
# Базовые классы (<class 'dict'>,)
Сам я таким вариантом описания метакласса в реальной практике не пользуюсь. Но упомянуть о такой возможности все-таки стоит.
Применение
Теперь мы можем попробовать сделать что-то полезное и реализуем метакласс, который будет регистрировать классы исключений и проверять, что они не повторяются.
Примечание: Такой подход для решения текущей задачи может быть полезен, если мы, например хотим после инициализации всего приложения иметь возможность сразу сгенерировать openapi спецификацию для всех созданных исключений.
from __future__ import annotations
T = TypeVar("T")
# Функция, которая будет аннотировать наш класс, как любой другой, переданный входным параметром
def mixin_for(_: T) -> T:
return object
# Объявляем метакласс, для этого нам нужно наследоваться от type
# также вы можете использовать наследование, например, от abc.ABCMeta
class ExceptionManager(type):
# Добавляем в атрибуты метакласса словарь, в котором будем
# регистрировать новые классы исключений
_registered: dict[str, type[ICustomException]] = {}
# Определяем метод, который будет создавать объекты новых классов
def __new__(
mcs,
name: str,
bases: tuple[type],
namespace: dict[str, Any],
# Раз мы начали сразу с объемного примера, то рассмотрим и
# дополнительную опцию. При определении метакласса у нас есть
# возможность добавлять параметры, ожидаемые при определении
# новых классов, что зачастую бывает очень полезным.
# Данный параметр будет отвечать за необходимость
# регистрации исключения
register: bool = True,
):
"""Создание объекта класса
Args:
name: имя класса
bases: базовые классы, от которых будет наследоваться новый класс
namespace: словарь с атрибутами класса
register: регистрировать исключение
"""
# Здесь мы создаем новый класс и приводим его аннотацию к
# ожидаемому типу
cls = cast(
type[ICustomException],
# Создаем новый объект класса, используя метод
# __new__ родительского класса, а именно type
super().__new__(mcs, name, bases, namespace)
)
# Регистрируем новый класс
# В данном примере не будем разделять проверки при регистрации
# исключений и без регистрации.
# Для примера использования параметра registry будем передавать
# валидные данные
if register:
mcs._register_exception(cls)
return cls
@classmethod
def _register_exception(mcs, cls: type[ICustomException]):
# Не регистрируем базовый класс, унаследованный от
# приватного базового класса
if cls.__base__ is _BaseCustomException:
return
# Проверяем, присутствие ICustomException в списке базовых классов
# Данная проверка в целом не обязательна.
# В крайнем случае вы можете на "доверительной основе"
# (чего бы я не рекомендовал) надеяться, что у всех регистрируемых
# исключений будут атрибуты, чье наличие ожидается в метаклассе
if ICustomException not in cls.__mro__:
raise ValueError(
f"{ICustomException} отсутствует "
f"в списке базовых классов для {cls}"
)
# Проверяем наличие атрибута code и его значение
code = getattr(cls, "code", None)
if not code:
raise AttributeError(
f"Для класса {cls} "
f"должен быть определен атрибут code"
)
# Проверяем были ли зарегистрированы исключения с аналогичным кодом
if code in mcs._registered:
raise AttributeError(
f"Ошибка в определении класса {cls}. "
f"Код ошибки {code} уже используется"
)
# Регистрируем новое исключение
mcs._registered[code] = cls
@classmethod
def get_registered(mcs) -> MappingProxyType[str, type[ICustomException]]:
# Тут важно, что метод get_registered является методом метакласса
# и в mcs будет передано значение ExceptionManager
# Возвращаем неизменяемый словарь зарегистрированных исключений
return MappingProxyType(mcs._registered)
@property
def registered(cls) -> MappingProxyType[str, type[ICustomException]]:
# Тут важно, что это свойство, определенное в метаклассе
# И оно будет доступно в классе аналогично тому, как было
# бы доступно свойство, определенное в классе его экземпляру.
# Исключением будет то, что в cls будет передан объект класса
# атрибут registered которого мы запрашиваем
# Возвращаем неизменяемый словарь зарегистрированных исключений
return ExceptionManager.get_registered()
# Тут мы могли бы использовать и MappingProxyType(cls._registered)
# так как атрибуты метакласса будут доступны и в порожденном классе
# Объявляем интерфейс для будущих исключений, это необходимо для того, чтобы
# иметь общий и единообразный интерфейс взаимодействия с порождаемыми в
# дальнейшем объектами, а также для верной подсветки типов
class ICustomException(mixin_for(Exception)):
code: str
# Добавляем промежуточный класс, от которого мы будем наследоваться базовым
# классом он необходим для того, чтобы мы могли определить, какой класс будет
# являться базовым и не регистрировать его в ExceptionManager.
# Вместо этого, в целом можно использовать механику абстрактных классов, но
# вне интерфейса, на мой взгляд, этого лучше не делать
class _BaseCustomException(ICustomException, Exception):
...
# Определяем базовый класс, который будет являться минимальной реализацией,
# начиная с этого класса созданием классов будет управлять
# метакласс ExceptionManager
class BaseCustomException(_BaseCustomException, metaclass=ExceptionManager):
...
Теперь давайте рассмотрим пример использования и проверим, что все работает.
# Создаем первое исключение
class FooException(BaseCustomException):
code = "foo_exception"
# Проверяем, что зарегистрировался только класс FooException
ExceptionManager.get_registered()
# mappingproxy({'foo_exception': <class '__main__.FooException'>})
# Также мы можем проверить результат и следующим образом
BaseCustomException.registered
# mappingproxy({'foo_exception': <class '__main__.FooException'>})
# А вот на уровне экземпляра класса доступа к атрибутам метакласса уже не будет
foo_exception = FooException()
hasattr(foo_exception, "registered")
# False
# Давайте попробуем зарегистрировать новое исключение на базе уже существующего
class BarException(FooException):
...
# Тут мы получаем ошибку так как не изменили код ошибки
# AttributeError: Ошибка в определении класса <class '__main__.BarException'>.
# Код ошибки foo_exception уже используется
# Заменяем код ошибки
class BarException(FooException):
code = "bar_exception"
# И проверяем, что оба исключения корректно зарегистрировались
BaseCustomException.registered
# mappingproxy({'foo_exception': <class '__main__.FooException'>,
# 'bar_exception': <class '__main__.BarException'>})
# Получаем ожидаемый результат
# Cоздать исключение, которое не должно быть зарегистрировано
# При определении дополнительных параметров метода __new__ метакласса у нас
# появляется возможность указывать их во время объявления объекта класса вместе
# с базовыми классами как именованные аргументы
class NotRegisteredException(FooException, register=False):
code = "not_registered_exception"
# В данном случае у нас все еще остается доступ к атрибутам метакласса
NotRegisteredException.registered
# mappingproxy({'foo_exception': <class '__main__.FooException'>,
# 'bar_exception': <class '__main__.BarException'>})
# А также мы видим, что класс ожидаемо не был зарегистрирован
У нас получилось достаточно много кода. Но пока не совсем понятны преимущества именно такой реализации.
Альтернативы
Ниже я приведу упрощенные примеры 2 других вариантов реализации, которые также имеют право на жизнь и расскажу о плюсах и минусах обоих.
Вариант реализации через декораторы.
# Аналогично создаем контейнер для регистрации исключений
_registered_exceptions: dict[str, type[ICustomException]] = {}
# Функция для получения зарегистрированных исключений
def get_registered_exceptions():
return MappingProxyType(_registered_exceptions)
# Декоратор для регистрации исключений
def register_exceptions(
cls: type[ICustomException]
) -> type[ICustomException]:
code = getattr(cls, "code", None)
# Тут должны быть проверки, аналогичные проверкам
# ExceptionManager._register_exception в примере выше,
# но мы представим, что работаем только с валидными данными.
# Регистрируем новое исключение
_registered_exceptions[code] = cls
return cls
# Также определяем интерфейс
class ICustomException(mixin_for(Exception)):
code: str
# И базовый класс
class BaseCustomException(ICustomException, Exception):
...
# В целом в данном кейсе можно было бы обойтись и без вспомогательных классов
# в виде ICustomException и BaseCustomException, но их наличие значительно
# упрощает дальнейшую поддержку кода, поэтому, я рекомендую использовать
# как интерфейсы так и базовые классы, если вы собираетесь придерживаться
# единообразной логики работы с многими классами
@register_exceptions
class FooException(BaseCustomException):
code = "foo_exception"
# Проверим, что класс FooException зарегистрировался
get_registered_exceptions()
# mappingproxy({'foo_exception': <class '__main__.FooException'>})
# Получаем ожидаемый результат
После прочтения у вас может сложиться, отличный от представленного мной,
список плюсов и минусов, но лично для себя я выделяю следующие пункты.
Плюсы:
Нужно писать меньше кода в начале;
Мы можем вешать декораторы только на те исключения, которые хотим зарегистрировать (хотя в примере с метаклассами мы можем явно указать, какие исключения регистрировать не нужно, что я считаю предпочтительным подходом);
Хорошая читаемость, мы точно знаем, что исключение где-то регистрируется.
Минусы:
Для регистрации исключений всегда нужно декорировать класс;
Мы не можем получить из класса зарегистрированные исключения (хоть мы и можем модифицировать класс, неявные изменения являются менее предпочтительными);
Велика вероятность забыть или не знать о необходимости регистрации исключений;
Мы теряем централизованное взаимодействие с классами;
Мы ограничены только регистрацией исключений и не можем свободно расширять функционал;
Вариант реализации через __init_subclass__
. Метод __init_subclass__
вызывается при создании объекта класса, который наследуется от класса, в котором определен данный метод. Подробнее о нем можно почитать тут.
Создаем класс, удовлетворяющий постановке задачи.
# Создаем базовый класс, который будет регистрировать дочерние классы в методе __init_subclass__
class BaseCustomException(Exception):
# Добавляем в атрибуты класса словарь, в котором будем регистрировать новые классы исключений
_registered: dict[str, type[BaseCustomException]] = {}
code: str
def __init_subclass__(cls, /, register: bool = True, **kwargs):
# Вызываем родительский метод
super().__init_subclass__(**kwargs)
# При необходимости регистрируем исключения
if register:
cls._register_exception()
@classmethod
def _register_exception(cls):
code = getattr(cls, "code", None)
# Тут должны быть аналогичные проверки
cls._registered[code] = cls
@classmethod
def get_registered(cls) -> MappingProxyType[str, type[BaseCustomException]]:
# Возвращаем неизменяемый словарь зарегистрированных исключений
return MappingProxyType(cls._registered)
А теперь попробуем повторить ожидаемое поведение реализации на метаклассах
# Попробуем создать новое исключение
class FooException(BaseCustomException):
code = "foo_exception"
# Проверим, что исключение было зарегистрировано
FooException.get_registered()
# mappingproxy({'foo_exception': <class '__main__.FooException'>})
# получаем ожидаемое поведение
# Теперь попробуем наследоваться глубже
class BarException(FooException):
code = "bar_exception"
# Проверим, что исключение было зарегистрировано
BarException.get_registered()
# mappingproxy({'foo_exception': <class '__main__.FooException'>,
'bar_exception': <class '__main__.BarException'>})
# получаем ожидаемое поведение
# И в завершении проверяем, что мы можем не регистрировать исключение
# Тут синтаксис будет аналогичен поведению метаклассов
class NotRegisteredException(FooException, register=False):
code = "not_registered_exception"
NotRegisteredException.get_registered()
# mappingproxy({'foo_exception': <class '__main__.FooException'>,
'bar_exception': <class '__main__.BarException'>})
# Получаем ожидаемое поведение
Плюсы:
Требуется меньше кода и его легче поддерживать;
Мы имеем возможность передавать аргументы при объявлении дочерних классов;
Мы все еще работаем с классами и можем пользоваться всеми их преимуществами;
Мы можем легко наследоваться от многих классов, у которых определен метод
__init_subclass__
.
Минусы:
Мы потеряли возможность создавать свойства для объекта классов;
Хоть использование
__init_subclass__
полностью покрывает изначально поставленную задачу, но метаклассы все же дают намного большую гибкость.
Стоит отметить, что самым оптимальным решением для задачи, поставленной в примере будет являться именно этот и в большинстве случаев, когда вам нужна «незначительная магия», старайтесь смотреть в сторону именно __init_subclass__
.
Метаклассы в python достаточно сложны как в описании, так и в поддержке, а также имеют ряд не очевидных проблем. Для того, чтобы уменьшить необходимость их использования был добавлен рассматриваемый метод. Подробнее о мотивации можно почитать в PEP 487.
Особенности использования метаклассов
В любом случае давайте еще немного углубимся в метаклассы.
На самом деле метод __new__
является не единственным магическим методом, который потенциально может быть полезно переопределить в метаклассе.
В целом, вас могут интересовать следующие 5 методов:
__instancecheck__
этот метод отвечает за проверки типаisinstance(instance, class)
вызываемого для экземпляра класса;__subclasscheck__
этот метод отвечает за проверки типаissubclass(subclass, class)
вызываемого для объекта класса;__prepare__
вызывается перед вызовом метода__new__
метакласса и возвращает объект, подобный словарю, который заменяет собой словарь локальных переменных (третий позиционный аргумент вызова__new__
для метакласса);__init__
инициализация объекта класса;__call__
вызов объекта класса.
Важно отметить, что методы __instancecheck__
и __subclasscheck__
будут искаться только в метаклассе и не могут быть определены как методы класса в самом классе. Поэтому при необходимости переопределить проверки isinstance
и issubclass
вам неизбежно придется использовать метакласс. Данные магические методы были предложены в PEP-3119 вместе с абстрактным базовым классом abc.ABC
.
В контексте данной статьи их переопределение рассмотрено не будет, так как не должно вызывать сложностей в виду своей однозначности использования, а в указанном PEP есть исчерпывающий пример использования.
Метод __prepare__
на самом деле является боле чем экзотическим и шанс того, что он может вам понадобиться катастрофически мал, поэтому я просто оставлю вам PEP-3115 в котором описана мотивация и пример использования.
А вот определение методов __call__
и __init__
на уровне метакласса думаю, стоит рассмотреть детальнее, так как, процесс создания объекта класса и экземпляров класса в таком случае уже не так прозрачен. Давайте для начала реализуем пример и пристально посмотрим, что происходит.
# Определяем метакласс
class MetaWithCall(type):
def __init__(
cls,
name: str,
bases: tuple[type],
namespace: dict[str, Any],
**kwargs
):
# Этот код должен выполниться после создания объекта
# класса в методе __new__
print(f"Инициализируем объект класса")
super().__init__(name, bases, namespace)
cls._kwargs = kwargs
print(f"Class: {cls}")
print(f"Name: {name}")
print(f"Bases: {bases}")
print(f"Kwargs: {kwargs}")
def __new__(
mcs,
name: str,
bases: tuple[type],
namespace: dict[str, Any],
**kwargs
):
# Этот код должен выполниться во время объявления класса,
# в котором текущий класс указан метаклассом
print("Создаем объект класса в метаклассе")
cls = super().__new__(mcs, name, bases, namespace)
print(f"Name: {name}")
print(f"Bases: {bases}")
print(f"Class: {cls}")
print(f"Kwargs: {kwargs}")
return cls
def __call__(cls, *args, **kwargs):
# Этот код должен выполниться перед созданием экземпляра класса,
# в котором текущий класс указан метаклассом
print(f"Вызов объекта класса в метаклассе")
instance = super().__call__(*args, **cls._kwargs, **kwargs)
print(f"Возвращаем экземпляр класса в метаклассе")
return instance
# ---STAGE 1---
# Объявляем класс, Foo, который наследуется от словаря,
# указываем метаклассом MetaWithCall
# и передаем именованный аргумент в объявлении класса
class Foo(dict, metaclass=MetaWithCall, extra_kwarg=True):
def __init__(self, *args, **kwargs):
# Этот код должен выполниться после создания экземпляра класса
# в методе __new__ текущего класса
print(f"Инициализируем экземпляр")
super().__init__(*args, **kwargs)
print(f"Instance: {self}")
print(f"Args: {args}")
print(f"Kwargs: {kwargs}")
def __new__(cls, *args, **kwargs):
# Этот код должен выполниться по время вызова объекта текущего класса
print("Создаем экземпляр класса в классе")
instance = super().__new__(cls)
print(f"Class: {cls}")
print(f"Instance: {instance}")
print(f"Args: {args}")
print(f"Kwargs: {kwargs}")
return instance
# Сразу после объявления класса Foo выведется следующий текст
# Создаем объект класса в метаклассе
# Name: Foo
# Bases: (<class 'dict'>,)
# Class: <class '__main__.Foo'>
# Kwargs: {'extra_kwarg': True}
# Инициализируем объект класса
# Class: <class '__main__.Foo'>
# Name: Foo
# Bases: (<class 'dict'>,)
# Kwargs: {'extra_kwarg': True}
# ---STAGE 2---
foo = Foo(foo=True)
# Тут будет выведен следующий текст
# Вызов объекта класса в метаклассе
# Создаем экземпляр класса в классе
# Class: <class '__main__.Foo'>
# Instance: {}
# Args: ()
# Kwargs: {'extra_kwarg': True, 'foo': True}
# Инициализируем экземпляр
# Instance: {'extra_kwarg': True, 'foo': True}
# Args: ()
# Kwargs: {'extra_kwarg': True, 'foo': True}
# Возвращаем экземпляр класса в метаклассе
Какие выводы из этого можно сделать.
STAGE 1
В момент, когда мы определяем класс Foo
сначала вызывается метод MetaWithCall.__new__
. На данном этапе мы создаем объект класса и имеем доступ к имени класса, локальным атрибутам, базовым классам, а так же к аргументам, которые были переданы как именованные при объявлении класса, причем мы можем модифицировать их значения еще до создания объекта класса.
После завершения выполнения метода MetaWithCall.__new__
, вызывается метод MetaWithCall.__init__
, в котором мы имеем доступ ко все тем же аргументам за исключением того, что первым аргументом прокидывается не MetaWithCall
а объект класса Foo
. На этом этапе мы можем модифицировать класс.
STAGE 2
В момент, когда мы создаем экземпляр класса Foo
, сначала вызывается метод MetaWithCall.__call__
. Внутри данного метода мы можем модифицировать аргументы, переданные для создания экземпляра класса Foo
. Помимо этого первым аргументом передается объект класса Foo
Внутри метода MetaWithCall.__call__
мы через super
вызываем метод type.__call__
, который, в свою очередь, начинает создание экземпляра класса, вызывая метод Foo.__new__
, в который первым атрибутом также передается объект класса Foo
. Помимо этого внутри метода MetaWithCall.__call__
мы передали в вызовы Foo.__new__
и Foo.__init__
аргумент extra_kwarg
, указанный при объявлении класса Foo
.
На данном этапе мы можем выполнить какую-то логику до создания экземпляра, а также модифицировать его после создания.
После завершения выполнения Foo.__new__
будет вызван метод Foo.__init__
, в котором первым аргументом будет передан экземпляр класса, созданный в Foo.__new__
. На данном этапе мы также можем модифицировать экземпляр класса.
Финальным шагом мы возвращаемся в выполнение функции MetaWithCall.__call__
и имеем доступ к созданному экземпляру класса на уровне метакласса, где также можем его модифицировать.
Результат выполнения функции MetaWithCall.__call__
будет возвращен и его значение, а именно: созданный экземпляр класса Foo
- будет присвоено переменной foo
.
Использование с __init_subclass__
Также в контексте темы важно упомянуть еще одну особенность, касающуюся использования метаклассов вместе с методом __init_subclass__
.
# Определяем метакласс
class MetaClass(type):
def __new__(cls, name, bases, attrs, metaclass_kwarg=None, **kwargs):
# Сейчас нас интересует исключительно передача аргументов,
# поэтому просто их распечатаем
print("Внутри __new__ метакласса", {"metaclass_kwarg": metaclass_kwarg, **kwargs})
return super().__new__(cls, name, bases, attrs)
def __init__(cls, name, bases, attrs, metaclass_kwarg=None, **kwargs):
print(
"Внутри __init__ метакласса",
{"metaclass_kwarg": metaclass_kwarg, **kwargs}
)
super().__init__(
name,
bases,
attrs,
metaclass_kwarg=metaclass_kwarg,
**kwargs
)
# Объявляем базовый класс с использованием метакласса и __init_subclass__
class BaseClass(metaclass=MetaClass, metaclass_kwarg=1):
# Определяем метод, который также позволяет передавать аргументы при
# объявлении класса
def __init_subclass__(cls, /, subclass_kwarg=None, **kwargs):
# Тут делаем аналогично
print(
"Внутри __init_subclass__",
{"subclass_kwarg": subclass_kwarg, **kwargs}
)
super().__init_subclass__(**kwargs)
# Тут будет выведено, но это пока что нас не интересует
# Внутри __new__ метакласса {'metaclass_kwarg': 1}
# Внутри __init__ метакласса {'metaclass_kwarg': 1}
# Объявляем подкласс, и передадим ему аргументы, которые ожидают метакласс,
# метод __init_subclass__ и аргумент, который должен попасть в kwargs
class Foo(BaseClass, metaclass_kwarg=1, subclass_kwarg=2, other_kwarg=3):
pass
# Внутри __new__ метакласса
# {'metaclass_kwarg': 1, 'subclass_kwarg': 2, 'other_kwarg': 3}
# Внутри __init_subclass__ {'subclass_kwarg': None}
# Внутри __init__ метакласса
# {'metaclass_kwarg': 1, 'subclass_kwarg': 2, 'other_kwarg': 3}
# Неожиданно получаем следующий результат и видим, что,
# хоть __init_subclass__ и выполнился между выполнениями __new__ и __init__,
# никакие аргументы в него переданы не были
Данное поведение обусловлено тем, что ожидаемые аргументы при использовании метаклассов и использовании __init_subclass__
прокидываются по-разному и непосредственно вместе оба подхода не предполагалось использовать, так как __init_subclass__
был создан как раз для частичной замены метаклассов.
Python позволяет нам определить и то и то вместе, но есть ряд ограничений. В целом есть возможность исправить проблему с аргументами, которых мы не досчитались в вызове __init_subclass__
. Но для исправления нужны значительные «танцы с бубнами».
Я крайне не рекомендую вам пытаться подружить метаклассы и __init_subclass__
, но вы можете почитать об этом в ответе на stackoverflow. Я сомневаюсь, что это самое элегантное решение, но ничего вразумительней я не нашел.
Классический пример
Давайте напоследок рассмотрим еще один хороший пример.
Реализация Singleton. Данный пример позаимствован из статьи.
from __future__ import annotations
class SingletonMeta(type):
_instance: SingletonBase | None
def __init__(cls, name, bases, namespace):
# На данном этапе мы модифицируем объект класса
# во время его объявления
super().__init__(name, bases, namespace)
# Этот код необходим в случае наследования от класса,
# который уже наследуется от SingletonBase и при этом,
# экземпляр родительского класса уже создавался
# иначе во время наследования объект нового класса
# унаследует и значение атрибута _instance с записанным
# в нем экземпляром класса
cls._instance = None
def __call__(cls, *args, **kwargs):
# Этот код мы вызываем перед type.__call__ и соответственно
# до того, как начинаем создавать экземпляр класса.
# Важно отметить, что на этом этапе мы работаем с объектом
# класса и изменяя его атрибуты мы модифицируем атрибуты
# именно объекта класса. Значение атрибута _instance
# в метаклассе SingletonMeta или родительских классах не изменится.
# Проверяем записан ли в объекте класса экземпляр
if cls._instance is None:
# Если не записан, то создаем экземпляр класса и сохраняем
# в атрибут объекта класса
cls._instance = super().__call__(*args, **kwargs)
# возвращаем экземпляр класса
return cls._instance
# Создаем базовый класс Singleton
class SingletonBase(metaclass=SingletonMeta):
pass
# Наследуемся от базового класса
class FooSingleton(SingletonBase):
pass
# Проверяем, что все работает при дальнейшем наследовании
class BarSingleton(FooSingleton):
pass
# Создаем первый экземпляр
FooSingleton()
# <__main__.FooSingleton object at 0x73b255685940>
# Проверяем, что при повторной попытке создания экземпляра
# возвращается тот же объект
FooSingleton()
# <__main__.FooSingleton object at 0x73b255685940>
# Создаем экземпляр дочернего класса
BarSingleton()
# <__main__.BarSingleton object at 0x73b255685a90>
# Проверяем, что значение _instance в родительском
# классе не изменилось
FooSingleton()
# <__main__.FooSingleton object at 0x73b255685940>
# Получаем ожидаемое поведение
В целом, как и пишет автор оригинальной статьи, мы можем обойтись и без метаклассов. Конечно, использование метода класса __new__
, наверное, не лучший вариант альтернативной реализации, так как он ограничен в своем использовании особенностями наследования.
Мы можем решить поставленную задачу и по средством использования декораторов или __init_subclass__
, как и в примерах выше, однако, сейчас мы, наверное видим один из немногих случаев, когда решение в метаклассах действительно является элегантнее и проще, чем его альтернативы. Я надеюсь, вы мне поверите, и оставлю возможность реализации альтернативных подходов вам.
Заключение
Для чего же это все может быть нужно. В целом, мы можем организовать централизованную логику создания, управления и модификации как классов, так и экземпляров классов не только на уровне самого класса, но и на уровне метакласса, что позволяет организовать дополнительный слой инкапсуляции того или иного функционала. Это может быть полезно, когда мы заботимся, например, об оптимизации. Сюда, наверное, войдет поведение синглтонов и кэширование в целом.
Также такие приемы могут быть полезны при централизованном управлении сессиями подключения к чему либо. Помимо этого, вам может потребоваться взаимодействовать с каким-то api и, возможно, вы бы хотелось отделить разного рода преобразование данных, контроль сессий, или подавление ошибок от слоя непосредственной реализации классов.
Использовать ли для всех этих целей именно метакласс все еще вопрос спорный, так как python обладает достаточным инструментарием для решения подобного рода задач и без использования метаклассов. Действительно вынудить вас их использовать может достаточно узкий класс задач.
Однако, периодически возникают случаи, когда нам действительно важен порядок выполнения того или иного кода и нам очень нужно что-то сделать, например, непосредственно перед созданием объекта класса и в то же время иметь доступ к другим классам, с аналогичным метаклассом. В данном случае использование метаклассов будет иметь смысл (если только в вашей программе нет какой-то фундаментальной ошибки, исправив которую, порядок создания объекта класса перестанет играть такое значение).
Важно отметить, что я видел в интернете добротное количество примеров с какими-то минимальными реализациями кастомных ORM в контексте вопроса рассмотрения применимости метаклассов. Вот пример одной из таких статей. В контексте данной статьи я не буду рассматривать схожий пример, но аналогичная задача может быть решена не только по средством использования метаклассов, но и просто использования дескрипторов (или их комбинации с __init_subclass__
).
O дескрипторах я упоминал в статье про декораторы и планирую подробнее рассказать в одной из следующих статей. К сожалению, детали работы с дескрипторами выходят за рамки текущей статьи, но, если вам интересно, я оставлю ссылку на гайд по их использованию.
Если вы действительно хотите понять как используются метаклассы на практике, рекомендую погрузиться в реализацию pydantic.BaseModel и pydantic._internal._model_construction.ModelMetaclass.
Надеюсь данная статья оказалась вам полезной и вы смогли найти для себя что-то новое.