Skip to content

Instantly share code, notes, and snippets.

@transquiente
Forked from rineisky/metaclasses.md
Created October 12, 2022 13:45
Show Gist options
  • Save transquiente/d7dcefa7386722e731d3e089cd01eebc to your computer and use it in GitHub Desktop.
Save transquiente/d7dcefa7386722e731d3e089cd01eebc to your computer and use it in GitHub Desktop.
Стоит ли бояться метаклассов?

Стоит ли бояться метаклассов?

Пожалуй, разговор о метаклассах стоит начать с многим известной цитаты Тима Питерса:

Метаклассы – это магия, о которой 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()
  1. Определяется кортеж родителей класса (также на этом этапе вычисляется MRO)
  2. Определяется метакласс, который будет использован для построения данного класса
  3. Имя класса, кортеж родителей, extra_kwargs передаются в __prepare__
  4. __prepare__ возвращает namespace, в котором будут храниться методы и аттрибуты класса во время его создания
  5. Интерпретатор читает тело класса и заполняет ими созданный namespace
  6. Имя класса, кортеж родителей, namespace с аттрибутами и методами, extra_kwargs передаются в __new__
  7. __new__ возвращает созданный класс
  8. К созданному классу применяется __init__ из метакласса
  9. На этом шаге класс считается созданным
  10. Метод __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__ и т.д. Они обладают способностью подключаться до создания нового объекта или после его создания. Основные цели, для которых их можно использовать:

  • Перехват создания класса
  • Изменение созданного класса
  • Перехват создания объекта класса
  • Избежание дублирования некоторого кода в кодовой базе

ABCMeta

В 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, то он будет абстрактным.

Метакласс vs декоратор класса

Как правило, одну и ту же задачу можно решить как с помощью метаклассов, так и с помощью декораторов класса. Давайте разберемся, в чем разница между этими двумя подходами?

Разберем некоторый практический пример. Допустим, нам нужно сделать набор классов для рассылки уведомлений пользователям. Исходя из того, какой тип нотификации нужно отправить, должна быть возможность выбрать класс, который может выполнить эту задачу.

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, методов)
  • нужно организовать архитектуру, которая в дальнейшем будет легко расширяться

1. Декорирование подклассов

Допустим, что вы часто применяете один и тот же декоратор к некоторым классам:

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

2. Синглтон

Да, вокруг этого паттерна много хейта. Но, в некоторых ситуациях он действительно может быть полезен.

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

3. Валидация подкласса

Допустим, нам необходимо реализовать паттерн шаблонный метод. Суть такая, что у нас есть базовый класс, который отвечает за основную логику, а некоторые шаги перекладываются на подклассы.

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):
        ...

4. Регистрация подклассов — расширяемый шаблон стратегии

Используя атрибуты метаклассов, мы также можем написать реализацию фабрики, которую в последующем будет легко расширять новыми классами. Идея основана на ведении реестра конкретных (неабстрактных) подклассов:

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

Одно важное замечание: чтобы данная реализация работала, нужно, чтобы все подклассы были импортированы. Если какой-то из подклассов не будет импортирован, то он не будет зарегистрирован.

Используя данный подход можно в своем коде организовать расширяемую архитектуру, которую можно будет дополнять плагинами.

5. Добавление атрибутов

Пожалуй, этот подход я чаще всего использовал в своей практике. Суть в том, что при создании класса можно добавить в него некоторые атрибуты.

Например, в самом метаклассе может быть определена некоторая логика, которая использует некоторый атрибут, который может быть добавлен на этапе создания.

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, то, пожалуй, от метакласса можно отказаться. Более простое решение, скорее всего, будет лучшим.

Так что прежде, чем использовать метакласс, выдохните и обдумайте это еще раз, действительно ли это здесь нужно. Если можно обойтись без него, то скорее всего это стоит сделать. Результат получится более читабельным и простым для поддержки и отладки.

Спасибо за внимание!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment