Пожалуй, разговор о метаклассах стоит начать с многим известной цитаты Тима Питерса:
Метаклассы – это магия, о которой 99% пользователей не стоит даже задумываться. Если вам интересно, нужны ли они вам – тогда точно нет. Люди, которым они на самом деле нужны, знают, зачем, и что с ними делать.
Но за что же мы все с вами любим Python? Конечно же, в первую очередь, за его простоту (но это не про сегодняшний разговор), ну а во-вторых за его гибкость. Метаклассы - это, как раз, тот инструмент, который может добавить вашему коду гибкости, но, как известно, с большой силой приходит и большая ответственность, поэтому необходимо хотя бы на базовом уровне понимать, что такое метаклассы и зачем они могут использоваться.
Так что давайте разбираться, действительно ли эта тема настолько сложна?
Чтобы ответить на вопрос, что такое метаклассы, нужно, в первую очередь, разобраться с тем, что такое классы.
По своей сути, класс - это модель, которая позволяет создавать объекты определенного типа, описывающая их структуру (начальное состояние и набор полей) и поведение (то есть методы). Само-собой, это же справедливо и для Python, но, как мы помним, все в Python это объект, и класс не исключение. Мы можем записать класс в переменную, передать аргументом в функцию, создать в runtime’е и вернуть как результат выполнения некоторой функции.
Когда используется ключевое слово class
, то Python создает этот объект автоматически.
>>> class A:
... pass
...
>>> a_obj = A()
>>> a_obj
<__main__.A object at 0x1090b6770>
Мы пишем классы, чтобы создавать объекты. Но так как класс - это тоже объект, то он тоже должен строится на основании чего-то.
Так вот метакласс - это модель, на основе которой строятся классы. Можно называть его «фабрикой классов». Тут можно провести аналогию с классом и объектом. Если вы понимаете, как объект относится к классу и как это работает, то понять метаклассы должно быть не трудно, это работает довольно схожим образом.
Думаю, всем знакома такая вещь, как type
. Если вызвать type
с одним аргументом, то мы получим тип аргумента, который мы ей передали.
>>> type(1)
<class 'int'>
>>> type("Python")
<class 'str'>
>>> type(True)
<class 'bool'>
>>> type(A)
<class 'type'>
>>> type(type)
<class 'type'>
Но у type
есть и другое применение. Если вызвать type
с тремя аргументами, то мы можем создать класс на лету. Первый аргумент - это название будущего класса, второй - кортеж родителей, третий - словарь с атрибутами и методами.
type(name, bases, attrs)
То есть в type
мы передаем описание класса, а как результат мы получаем готовый класс.
>>> A = type("A", tuple(), {})
>>> A
<class '__main__.A'>
Далее этот класс можно использовать как и обычный класс, который мы можем просто определить на глобальном уровне (от него можно наследоваться, создавать атрибуты и объекты, вызывать методы и т.д.).
Например, следующие два объявления будут идентичными:
class B(A):
c = 1
def __init__(self, d):
self.d = d
def init(self, d):
self.d = d
B = type(
"B",
(A,),
{
"c": 1,
"__init__": init,
}
)
Таким образом, классу можно задать любой набор родителей (передавая их в кортеже) и любой набор методов и атрибутов (просто передавая их в качестве словаря). Почему у нас это получается? Потому что type
- это метакласс, который Python использует под капотом для создания классов. Большинство классов являются объектами type
. type
, в свою очередь, сам себе класс.
>>> class A:
... pass
...
>>> A.__class__
<class 'type'>
>>> int.__class__
<class 'type'>
>>> dict.__class__
<class 'type'>
>>> type.__class__
<class 'type'>
Всю эту иерархию можно представить следующим образом. Класс является объектом метакласса, который, в свою очередь, уже может создавать объекты:
MyClass = MyMeta()
my_obj = MyClass()
Но type
- это ведь базовый метакласс. Что если мне нужно создать кастомный с некоторой логикой? Для создания кастомного метакласса достаточно определить класс, который будет наследоваться от type
.
Чтобы дать Python инструкцию строить некоторый класс на основе нашего метакласса, его надо указать в объявлении класса с помощью ключевого слова metaclass
.
class MyMeta(type):
pass
class A(metaclass=MyMeta):
pass
На просторах интернета вы все еще можете увидеть информацию об магическом атрибуте __metaclass__
. Такое объявление метакласса было справедливо для Python 2.x версии. В версии 3.x если вы объявите метакласс таким образом, то он попросту не будет работать:
-
Python 2.x
class A(object): __metaclass__ = MyMeta
-
Python 3.x
class A(metaclass=MyMeta): pass
type
, как базовый метакласс, определяет магические методы, которые кастомные метаклассы могут переопределять для придания кастомному метаклассу поведения отличного от type
, которое работает по умолчанию:
-
__prepare__
: определяет пространство имен класса, в котором буду хранится атрибуты и методы во время создания класса метаклассом. Должен вернуть наследникаdict
(UserDict
или перегрузка__setitem__
/__getitem__
не прокатит)class MyNamespace(dict): def __setitem__(self, name, value): if not name.startswith("_"): name = name.upper() super().__setitem__(name, value) class MyMeta(type): def __new__(mcs, name, bases, attrs, **extra_kwargs): attrs["new_attr"] = 1 return super().__new__(mcs, name, bases, attrs) @classmethod def __prepare__(mcs, name, bases, **extra_kwargs): return MyNamespace() class A(metaclass=MyMeta): a = 1 @classmethod def some(cls): print("some") assert A.NEW_ATTR == 1 assert A.A == 1
-
__new__
: вызывается в метаклассе перед созданием экземпляра класса, основанного на этом метаклассеclass MyMeta(type): def __new__(mcs, name, bases, attrs, **extra_kwargs): if extra_kwargs.get("from_str"): bases = (str,) + bases attrs["new_attr"] = 1 return super().__new__(mcs, name, bases, attrs) class A(metaclass=MyMeta, from_str=True): pass assert A.new_attr == 1 assert issubclass(A, str)
-
__init__
: вызывается для установки атрибутов/методов класса после создания классаclass MyMeta(type): def __init__(cls, name, bases, attrs): super().__init__(name, bases, attrs) cls.__fields__ = {} class A(metaclass=MyMeta): pass assert A.__fields__ == {}
-
__call__
: вызывается при создании объекта классаclass MyMeta(type): def __call__(cls, *args, **kwargs): obj = super().__call__(*args, **kwargs) obj.new_attr = 1 return obj class A(metaclass=MyMeta): pass a = A() assert a.new_attr == 1
Аргументы методов:
mcs
- ссылка на метакласс (аналогично какcls
- ссылка на класс, аself
- ссылка на объект класса)name
- имя класса, который создается данным метаклассомbases
- кортеж родителей классаattrs
- namespace класса (используется для построения__dict__
класса, хранит методы и атрибуты)**extra_kwargs
- любые аргументы ключ-значение, которые были указаны в определении классаcls
- ссылка на класс, который создается данным метаклассом
Как же работает метакласс?
class MyMeta(type):
@classmethod
def __prepare__(mcs, name, bases, **extra_kwargs):
return super().__prepare__(name, bases, **extra_kwargs)
def __new__(mcs, name, bases, attrs, **extra_kwargs):
return super().__new__(mcs, name, bases, attrs)
def __init__(cls, name, bases, attrs):
super().__init__(name, bases, attrs)
def __call__(cls, *args, **kwargs):
return super().__call__(*args, **kwargs)
class A(metaclass=MyMeta):
pass
a = A()
- Определяется кортеж родителей класса (также на этом этапе вычисляется MRO)
- Определяется метакласс, который будет использован для построения данного класса
- Имя класса, кортеж родителей,
extra_kwargs
передаются в__prepare__
__prepare__
возвращает namespace, в котором будут храниться методы и аттрибуты класса во время его создания- Интерпретатор читает тело класса и заполняет ими созданный namespace
- Имя класса, кортеж родителей, namespace с аттрибутами и методами,
extra_kwargs
передаются в__new__
__new__
возвращает созданный класс- К созданному классу применяется
__init__
из метакласса - На этом шаге класс считается созданным
- Метод
__call__
вызывается уже если мы создаем объект класса
Что будет, если объявить в метаклассе методы (метаметоды) или аттрибуты? Будут ли они доступны? И ответ должен быть очевиден, если проводить аналогию с классом и объектом. Метаметоды и аттрибуты метакласса будут доступны из класса (как методы и атрибуты класса), но не из его объекта.
Также если объявить dunder методы в метаклассе, то для класса они будут работать как методы класса.
class MyMeta(type):
mcs_attr = True
def mcs_method(cls):
pass
def __str__(cls):
return "mcs_str"
class A(metaclass=MyMeta):
pass
assert A.mcs_attr
assert A.mcs_method
assert str(A) == "mcs_str"
Так как метакласс - это класс, то его можно наследовать и в дочернем классе переопределять или дополнять функционал родительского класса.
class XMeta(type):
def __new__(mcs, name, bases, attrs, **extra_kwargs):
attrs["x_attr"] = True
return super().__new__(mcs, name, bases, attrs)
class YMeta(XMeta):
def __init__(cls, name, bases, attrs):
super().__init__(name, bases, attrs)
cls.y_attr = True
class A(metaclass=YMeta):
pass
assert A.x_attr
assert A.y_attr
Существует также риск попадания в конфликт метаклассов. Такое происходит, если мы пытаемся применить его в середине дерева наследования, а какой-то из базовых классов строится также на основе кастомного метакласса.
class AMeta(type):
pass
class A(metaclass=AMeta):
pass
class BMeta(type):
pass
class B(metaclass=BMeta):
pass
class C(A, B):
pass
Для разрешения есть небольшой трюк: нужно отнаследоваться от метаклассов, которые участвуют в построении классов из дерева наследования, и применить его как метакласс к нашему классу.
class AMeta(type):
pass
class A(metaclass=AMeta):
pass
class BMeta(type):
pass
class B(metaclass=BMeta):
pass
class CMeta(AMeta, BMeta):
pass
class C(A, B, metaclass=CMeta):
pass
В Python 3.6 стал доступен еще один хук: __init_subclass__
. Его можно использовать как замену некоторых простых метаклассов. Разница в том, что данный хук получает уже реализованный класс, после чего можно внести в него изменения. В данном кейсе сперва класс создается метаклассом, потом он передается в __init_subclass__
.
class A:
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
cls.new_attr = True
class B(A):
pass
assert B.new_attr
Тут, разве что, есть один важный момент: данный метод не принимает кейворд аргументы. Стандартная его реализация ничего не делает.
Итак, подведем некоторые итоги. Сами по себе метаклассы не так уж сложны, просто они обычно используется для различных нестандартных манипуляций с классом, таких как изменение перечня родителей, внесение изменений в __dict__
и т.д. Они обладают способностью подключаться до создания нового объекта или после его создания. Основные цели, для которых их можно использовать:
- Перехват создания класса
- Изменение созданного класса
- Перехват создания объекта класса
- Избежание дублирования некоторого кода в кодовой базе
В Python есть возможность работать с абстрактными классами. Напомню, что это классы, которые, как правило, выступают в качестве интерфейса, который должен быть реализован в дочерних классах. Если в дочернем классе не был переопределен хотя бы один абстрактный метод, то объект такого класса нельзя будет создать, так как дочерний класс также будет абстрактным.
Чтобы создать абстрактный класс, нам нужно отнаследоваться от класса ABC
из модуля abc
и определить хотя бы один abstractmethod
.
from abc import ABC, abstractmethod
class A(ABC):
@abstractmethod
def connect(self):
...
Но что если нам нужно создать абстрактный класс, который будет строиться на основе какого-то кастомного метакласса.
from abc import ABC, abstractmethod
class MyMeta(type):
pass
class A(ABC, metaclass=MyMeta):
@abstractmethod
def connect(self):
...
В данном случае мы получим конфликт метаклассов, так как ABC
сам по себе является классом, который строится на основе кастомного метакласса.
В данном случае мы можем просто наш метакласс отнаследовать не от type
, а от ABCMeta
.
from abc import ABCMeta, abstractmethod
class MyMeta(ABCMeta):
pass
class A(metaclass=MyMeta):
@abstractmethod
def connect(self):
...
И если класс, который мы строим на основе данного метакласса потом будет включать хотя бы один abstractmethod
, то он будет абстрактным.
Как правило, одну и ту же задачу можно решить как с помощью метаклассов, так и с помощью декораторов класса. Давайте разберемся, в чем разница между этими двумя подходами?
Разберем некоторый практический пример. Допустим, нам нужно сделать набор классов для рассылки уведомлений пользователям. Исходя из того, какой тип нотификации нужно отправить, должна быть возможность выбрать класс, который может выполнить эту задачу.
from enum import Enum
class NotificationType(Enum):
EMAIL = "EMAIL"
SMS = "SMS"
Также у нас должна быть возможность регистрировать новые каналы для уведомлений.
Мы можем создать метакласс NotificationRegistry
, в котором создадим атрибут _registry
, который будет использоваться для хранения зарегистрированных классов.
Для регистрации новых классов мы можем использовать метод __new__
, в котором после создания класса будет добавляться ключ-значение в словарь _registry
.
Единственное, что осталось сделать, так это создать метод класса get_notification_class
который будет возвращать зарегистрированный класс для некоторого типа уведомления.
class NotificationRegistry(type):
_registry = {}
def __new__(mcs, name, bases, attrs, **extra_kwargs):
c = super().__new__(mcs, name, bases, attrs, **extra_kwargs)
mcs._registry[c.notification_type] = c
return c
@classmethod
def get_notification_class(mcs, type_: NotificationType):
return mcs._registry[type_]
class EmailNotification(metaclass=NotificationRegistry):
notification_type = NotificationType.EMAIL
class SMSNotification(metaclass=NotificationRegistry):
notification_type = NotificationType.SMS
Далее, если нам надо отправить уведомление, то достаточно импортировать класс NotificationRegistry
и вызвать метод get_notification_class
, который вернет нужный класс.
Рассмотрим, как такую же задачу можно решить с помощью декоратора класса. Для удобства поместим _registry
и get_notification_class
снова в класс, но теперь этот класс будет не метаклассом, а обычным классом, который содержит метод register
. register
- это декоратор с параметром, который мы будем применять к классам, которые надо зарегистрировать.
class NotificationRegistry:
_registry = {}
@classmethod
def register(cls, notification_type):
def decorator(notification_class):
cls._registry[notification_type] = notification_class
return notification_class
return _wrap
@classmethod
def get_notification_class(cls, type_: NotificationType):
return cls._registry[type_]
@NotificationRegistry.register(NotificationType.EMAIL)
class EmailNotification:
pass
@NotificationRegistry.register(NotificationType.SMS)
class SMSNotification:
pass
Сравним два подхода.
Код с декоратором, как правило, будет получаться проще, так как он просто получает класс и далее можно производить некоторые манипуляции, когда как в метаклассе нужно определять dunder
метод, через super
вызывать метод родительского класса.
Если же говорить о влиянии на ход выполнения, то декоратор может принимать аргументы, как в нашем случае, ну а на работу метакласса можно повлиять с помощью атрибутов класса, который мы строим, или же написать дополнительные кейворд аргументы при определении класса.
Если же говорить про runtime, то при использовании метаклассов у нас есть хуки и мы можем внести изменения на любом этапе создания класса, как до, так и после. Ну а в случае с декораторами, мы получаем уже готовый созданный класс. Из этого могут вытекать некоторые ограничения: например в метаклассе мы можем изменить __slots__
класса, в декораторе класса нет.
Следующее ограничение, которое стоит отметить, так это то, что если нам нужно как-то переопределить магические методы для самого класса, а не его объекта, то это можно сделать только в метаклассе.
Если же говорить про тестирование, то тут, опять же, декоратор, как правило, протестировать будет проще. Иногда можно будет обойтись без создания дополнительного класса в тесте только чтобы протестировать какой-то функционал.
Что же будет, если мы к одному классу применим и декоратор и метакласс.
class MyMeta(type):
@classmethod
def __prepare__(mcs, name, bases, **extra_kwargs):
print("Preparing a namespace for", name)
return super().__prepare__(name, bases, **extra_kwargs)
def __new__(mcs, name, bases, attrs, **extra_kwargs):
print("Creating class", name)
return super().__new__(mcs, name, bases, attrs)
def __init__(cls, name, bases, attrs):
print("Initializing class", name)
super().__init__(name, bases, attrs)
def __call__(cls, *args, **kwargs):
print("Creating instance of", cls)
return super().__call__(*args, **kwargs)
def class_decorator(cls):
print("Decorating", cls)
return cls
@class_decorator
class A(metaclass=MyMeta):
pass
a = A()
На консоль мы получим следующий вывод:
Preparing a namespace for A
Creating class A
Initializing class A
Decorating <class '__main__.A'>
Creating instance of <class '__main__.A'
Почему мы получаем такой вывод?
- Сперва Python создает класс с помощью метакласса в том порядке, который мы уже рассмотрели ранее
- Когда класс полностью создан метаклассом, то только тогда уже к нему применяется декоратор класса
- Ну а когда мы создаем объект, то вызывается
__call__
из метакласса
Давайте рассмотрим некоторые кейсы, где вам могут пригодится метаклассы.
Какой может быть звоночек, что вам, возможно, нужен метакласс:
- вам нужно для класса выставить понятный и простой API
- вам нужно создавать классы в соответствии с текущим контекстом
- вы хотите избежать повторения некоторого куска кода (определение атрибутов, properties, методов)
- нужно организовать архитектуру, которая в дальнейшем будет легко расширяться
Допустим, что вы часто применяете один и тот же декоратор к некоторым классам:
from datetime import datetime
from uuid import UUID
import attr
@attr.s(frozen=True, auto_attribs=True)
class Event:
created_at: datetime
@attr.s(frozen=True, auto_attribs=True)
class Order(Event):
id: UUID
customer_id: UUID
amount: int
От всех них мы можем избавиться путем написания метакласса, в котором в методе __new__
будем декорировать класс.
from datetime import datetime
from uuid import UUID
import attr
class EventMeta(type):
def __new__(mcs, name, bases, attrs, **extra_kwargs):
new_cls = super().__new__(mcs, name, bases, attrs, **extra_kwargs)
return attr.s(frozen=True, auto_attribs=True)(new_cls)
class Event(metaclass=EventMeta):
created_at: datetime
class Order(Event):
id: UUID
customer_id: UUID
amount: int
Да, вокруг этого паттерна много хейта. Но, в некоторых ситуациях он действительно может быть полезен.
class SingletonMeta(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class A(metaclass=SingletonMeta):
pass
Допустим, нам необходимо реализовать паттерн шаблонный метод. Суть такая, что у нас есть базовый класс, который отвечает за основную логику, а некоторые шаги перекладываются на подклассы.
from abc import ABC, abstractmethod
class Notification(ABC):
def send_to_channel(self, channel, recipient, message):
pass
def send(self, user, message):
contacts = self.get_contacts(user)
self.send_to_channel(self.channel_id, contacts, message)
@abstractmethod
@property
def channel_id(self) -> int:
...
@abstractmethod
def get_contacts(self, user):
...
class SMSNotification(Notification):
channel_id = 123
def get_contacts(self, user):
...
Если в базовом классе мы определили абстрактный property
, то в дочернем классе будет проверяться только его наличие. А что если нам нужно как-то проверить этот property
. Можно создать метакласс, который будет отвечать за валидацию.
import inspect
from abc import ABCMeta, abstractmethod
class NotificationMeta(ABCMeta):
def __new__(mcs, name, bases, attrs, **extra_kwargs):
new_cls = super().__new__(mcs, name, bases, attrs, **extra_kwargs)
if inspect.isabstract(new_cls):
return new_cls
if not isinstance(attrs["channel_id"], int):
raise TypeError("channel_id should be integer number.")
return new_cls
class Notification(metaclass=NotificationMeta):
def send_to_channel(self, channel, recipient, message):
pass
def send(self, user, message):
contacts = self.get_contacts(user)
self.send_to_channel(self.channel_id, contacts, message)
@property
@abstractmethod
def channel_id(self) -> int:
...
@abstractmethod
def get_contacts(self, user):
...
class SMSNotification(Notification):
channel_id = 123
def get_contacts(self, user):
...
Используя атрибуты метаклассов, мы также можем написать реализацию фабрики, которую в последующем будет легко расширять новыми классами. Идея основана на ведении реестра конкретных (неабстрактных) подклассов:
class NotificationRegistry(type):
_registry = {}
def __new__(mcs, name, bases, attrs, **extra_kwargs):
c = super().__new__(mcs, name, bases, attrs, **extra_kwargs)
mcs._registry[c.notification_type] = c
return c
@classmethod
def get_notification_class(mcs, type_: NotificationType):
return mcs._registry[type_]
class EmailNotification(metaclass=NotificationRegistry):
notification_type = NotificationType.EMAIL
class SMSNotification(metaclass=NotificationRegistry):
notification_type = NotificationType.SMS
Одно важное замечание: чтобы данная реализация работала, нужно, чтобы все подклассы были импортированы. Если какой-то из подклассов не будет импортирован, то он не будет зарегистрирован.
Используя данный подход можно в своем коде организовать расширяемую архитектуру, которую можно будет дополнять плагинами.
Пожалуй, этот подход я чаще всего использовал в своей практике. Суть в том, что при создании класса можно добавить в него некоторые атрибуты.
Например, в самом метаклассе может быть определена некоторая логика, которая использует некоторый атрибут, который может быть добавлен на этапе создания.
class MyMeta(type):
def __new__(mcs, name, bases, attrs, **extra_kwargs):
new_cls = super().__new__(mcs, name, bases, attrs, **extra_kwargs)
new_cls._new_attr = True
return new_cls
def some_logic(cls):
assert cls._new_attr
class A(metaclass=MyMeta):
pass
A.some_logic()
Во-первых, если это приведет к чрезмерному усложнению. Если код с метаклассом получается таким, что:
- в нем сложно разобраться
- его сложно поддерживать
- его сложно модифицировать
- его сложно тестировать
- чтобы с ним работать, нужно много держать в голове
и без метакласса можно обойтись, то, пожалуй, это стоит сделать.
Во-вторых если этого можно избежать простым наследованием, декоратором или с помощью init_subclass в Python выше 3.6, то, пожалуй, от метакласса можно отказаться. Более простое решение, скорее всего, будет лучшим.
Так что прежде, чем использовать метакласс, выдохните и обдумайте это еще раз, действительно ли это здесь нужно. Если можно обойтись без него, то скорее всего это стоит сделать. Результат получится более читабельным и простым для поддержки и отладки.
Спасибо за внимание!