5 интересных вариантов использования метаклассов в Python

Перевод статьи «When to use metaclasses in Python: 5 interesting use cases», опубликованный сайтом webdevblog.ru.

Метаклассы упоминаются среди самых продвинутых возможностей Python. Знание того, как их использовать, воспринимается коллегами как наличие черного пояса Python. Но полезны ли они для всех собеседований или конференций? Давай выясним! Эта статья покажет вам 5 практических применений метаклассов. 

Что такое метаклассы — короткий обзор

Предположим, что вы знаете разницу между классами и объектами. Тогда метаклассы не должны быть для вас слишком уж сложными. Если кратко, то они являются классами для классов (отсюда и «мета» в их названии).

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

Самая простая реализация метакласса, которая ничего не делает, выглядит следующим образом:

class MyMeta(type):
    def __new__(cls, name, bases, namespace):
        # cls - MyMeta
        # name - имя определяемого класса (MyClass в этом примере)
        # bases - базовые классы для построенного класса
        # namespace - словарь определенных в класса методов и полей
        # в нашем случае - {'x': 3}         

        # super().__new__ просто возвращает новый класс
        return super().__new__(cls, name, bases, namespace)

class MyClass(metaclass=MyMeta):
    x = 3

1. Избегание повторения декораторов или декорирование всех подклассов

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

@attr.s(frozen=True, auto_attribs=True)
class Event:
    created_at: datetime
 
 
@attr.s(frozen=True, auto_attribs=True)
class InvoiceIssued(Event):
    invoice_uuid: UUID
    customer_uuid: UUID
    total_amount: Decimal
    total_amount_currency: Currency
    due_date: datetime
 
 
@attr.s(frozen=True, auto_attribs=True)
class InvoiceOverdue(Event):
    invoice_uuid: UUID
    customer_uuid: UUID

Возможно, это не слишком много повторений, но мы все же можем от них избавиться, для этого напишем метакласс для Event:

class EventMeta(type):
    def __new__(cls, name, bases, namespace):
        new_cls = super().__new__(cls, name, bases, namespace)
        return attr.s(frozen=True, auto_attribs=True)(new_cls)  # (!)
 
 
class Event(metaclass=EventMeta):
    created_at: datetime
 
 
class InvoiceIssued(Event):
    invoice_uuid: UUID
    customer_uuid: UUID
    total_amount: Decimal
    total_amount_currency: Currency
    due_date: datetime
 
 
class InvoiceOverdue(Event):
    invoice_uuid: UUID
    customer_uuid: UUID

Самая важная строка attr.s (frozen = True, auto_attribs = True) (new_cls) она имеет дело с декоратором наших подклассов. Ключом к пониманию этого примера является тот факт, что синтаксис с «at sign» (@) является просто синтаксическим сахаром над такой конструкцией:

class Event:
    created_at: datetime
 
Event = attr.s(frozen=True, auto_attribs=True)(Event)

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

Говоря о классах, давайте подумаем о наследовании в контексте Template Method design pattern. Проще говоря, мы определяем алгоритм в базовом классе, но мы оставляем один или несколько шагов (или атрибутов) в качестве абстрактных методов (или свойств) для переопределения в подклассе. Рассмотрим этот пример:

class JsonExporter(abc.ABC):
    def export(self):
        # тут какая нибудь полезная логика
        with open(self._filename) as f:
            for row in self._build_row_from_raw_data():
                pass  # do some other stuff
 
    @abc.abstractmethod
    @property
    def _filename(self):
        pass
 
    @abc.abstractmethod
    def _build_row_from_raw_data(self, raw_data):
        pass
    
 
class InvoicesExporter(JsonExporter):
    _filename = 'invoices.json'
 
    def _build_row_from_raw_data(self, raw_data):
        return {'invoice_uuid': raw_data[0]}

У нас есть простой базовый класс JsonExporter. Достаточно создать его подкласс и предоставить реализацию для свойства _filename и метода _build_row_from_raw_data. Тем не менее, abc обеспечивает проверку только на отсутствие этих параметров. Если мы, например, хотели бы проверить больше вещей, таких как уникальность имен файлов или их правильность (например, всегда заканчивается на .json), мы также можем написать метакласс для этого:

import inspect
 
"""
Мы наследуем метакласс abc (ABCMeta), чтобы избежать конфликтов метаклассов
"""
class JsonExporterMeta(abc.ABCMeta):
    _filenames = set()
 
    def __new__(cls, name, bases, namespace):
        # сначала выполнить логику abc
        new_cls = super().__new__(cls, name, bases, namespace)
 
        """
        Нет необходимости запускать проверки для абстрактного класса
        """
        if inspect.isabstract(new_cls):  # 2
            return new_cls
 
        """
        Проверяем, является _filename строкой
        """
        if not isinstance(namespace['_filename'], str):
            raise TypeError(f'_filename attribute of {name} class has to be string!')
 
        """
        Проверяем, имеет ли _filename расширение .json
        """
        if not namespace['_filename'].endswith('.json'):
            raise ValueError(f'_filename attribute of {name} class has to end with ".json"!')
 
        """
         Проверяем уникальность _filename среди других подклассов.
        """
        if namespace['_filename'] in cls._filenames:
            raise ValueError(f'_filename attribute of {name} class is not unique!')
 
        cls._filenames.add(namespace['_filename'])
 
        return new_cls
 
 
"""
Теперь мы не будем наследовать от abc.ABC, вместо этого будем использовать наш новый метакласс
"""
class JsonExporter(metaclass=JsonExporterMeta):
    pass  # The rest of the class remains unchanged, so I skipped it
 
 
class BadExporter(JsonExporter):
    _filename = 0x1233  # That's going to fail one of the checks
 
    def _build_row_from_raw_data(self, raw_data):
        return {'invoice_uuid': raw_data[0]}

Говоря о ABC, это еще одна вещь, которая реализована с использованием метаклассов. Смотрите доклад Леонардо Джордани — Abstract Base Classes: a smart use of metaclasses.

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

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

class StrategyMeta(abc.ABCMeta):
    """
    Храним отображение внешне используемых имен в классы.
    """
    registry: Dict[str, 'Strategy'] = {}
 
    def __new__(cls, name, bases, namespace):
        new_cls = super().__new__(cls, name, bases, namespace)
 
        """
        Регистрируем каждый класс
        """
        if not inspect.isabstract(new_cls):
            cls.registry[new_cls.name] = new_cls
 
        return new_cls 
 
 
class Strategy(metaclass=StrategyMeta):
    @property
    @abc.abstractmethod
    def name(self):
        pass
 
    @abc.abstractmethod
    def validate_credentials(self, login: str, password: str) -> bool:
        pass
 
    @classmethod
    def for_name(cls, name: str) -> 'Strategy':
        """
        Используем реестр для создания новых классов
        """
        return StrategyMeta.registry[name]()
 
 
class AlwaysOk(Strategy):
    name = 'always_ok'
 
    def validate_credentials(self, login: str, password: str) -> bool:
        # Imma YESman!
        return True
 
# Пример использования
Strategy.for_name('always_ok').validate_credentials('john', 'x')

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

Менее абстрактным примером может быть система плагинов для линтера (подумайте о Pylint или flake8). Подклассифицируя абстрактный класс Plugin, мы будем не только предоставлять наши пользовательские проверки, но и регистрировать плагин. Кстати, если вы ищете, как написать такой плагин для Pylint — посмотрите мою статью Writing custom checkers for Pylint.

4. Декларативный способ построения GUI

Признательность за это замечательное приложение метаклассов достается Андерсу Хаммарквисту — автору EuroPython talk Metaclasses for fun and profit: Making a declarative GUI implementation.

По сути, идея состоит в том, чтобы превратить императивный код, ответственный за создание GUI из компонентов…

class MyWindow(Gtk.Window):
    def __init__(self):
        super().__init__(self, title="Hello, window!")
        self.box = Gtk.VBox()
        self.add(self.box)
        self.label = GtkLabel(label="Hello, label!")
        self.box.add(self.label)

… в это:

class Top(Window):
    title = "Hello, window!"
    class Group(VBox):
        class Title(Label):
            label = '"Hello, label!"'

Просто вау. Это впечатляет, потому что не только упрощает полученный код, но и намного лучше подходит для наших целей. Визуальная композиция с помощью вложенных классов выглядит для нас более естественно, учитывая, что конечный результат очень похож — компоненты, вложенные друг в друга.

Если вас интересуют детали реализации (и проблемы, с которыми пришлось столкнуться автору), смотрите доклад. Код доступен на bitbucket: https://bitbucket.org/iko/ep2016-declarative-gui/

5. Добавление атрибутов — Django ORM Model.DoesNotExist

Если у вас есть некоторый опыт работы с Django, вы наверняка заметили, что каждому классу модели присваивается исключение DidNotExist. Последний является атрибутом класса. Но откуда это берется? Ну, Django использует метаклассы. Для моделей они делает много вещей от проверки до динамического добавления нескольких атрибутов, таких как исключения DoesNotExist и MultipleObjectsReturned.

# django/db/models/base.py:128
        if not abstract:
            new_class.add_to_class(
                'DoesNotExist',
                subclass_exception(
                    'DoesNotExist',
                    tuple(
                        x.DoesNotExist for x in parents if hasattr(x, '_meta') and not x._meta.abstract
                    ) or (ObjectDoesNotExist,),
                    module,
                    attached_to=new_class))
            new_class.add_to_class(
                'MultipleObjectsReturned',
                subclass_exception(
                    'MultipleObjectsReturned',
                    tuple(
                        x.MultipleObjectsReturned for x in parents if hasattr(x, '_meta') and not x._meta.abstract
                    ) or (MultipleObjectsReturned,),
                    module,
                    attached_to=new_class))

Заметка о __init_subclass__

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

Существует также риск попадания в конфликт метаклассов, если мы попытаемся применить его в середине иерархии. Например, вы не сможете просто использовать собственный метакласс для своей модели Django. Вы должны будете использовать трюк, — создать подкласс django.db.models.Model (django.db.models.base.ModelBase), и написать свою собственную логику в __new__, а затем создать свой собственный базовый класс для всех моделей вместо использования django.db.models.Model. Похоже, что нужно сделать много работы.

К счастью для нас, начиная с Python3.6, есть еще один доступный хук: __init_subclass__. Он может заменить большинство (если не все) метаклассы.

class Strategy(abc.ABC):
    _registry: Dict[str, 'Strategy'] = {}
 
    def __init_subclass__(cls, **kwargs):
        """
        Это неявный метод класса. Он будет вызываться только для классов ниже по иерархии, а не для Strategy.
        """
        super().__init_subclass__(**kwargs)
        Strategy._registry[cls.name] = cls
 
    @property
    @abc.abstractmethod
    def name(self):
        pass
 
    @abc.abstractmethod
    def validate_credentials(self, login: str, password: str) -> bool:
        pass
 
    @classmethod
    def for_name(cls, name: str) -> 'Strategy':
        return StrategyMeta.registry[name]()
 
 
class AlwaysOk(Strategy):
    name = 'always_ok'
 
    def validate_credentials(self, login: str, password: str) -> bool:
        return True
 
# Использование
Strategy.for_name('always_ok').validate_credentials('john', 'x')

Подробнее смотри PEP 487.

Заключение

Я знаю, о чем вы думаете — всем известно выражение что все эти метаклассы полезны, только если вы пишете свой фреймворк. В какой то мере это правильно, но если вы обнаружите в своем проекте повторяющийся шаблон кода, почему бы не подумать о метаклассах?

Часть «мета» — ключ к тому, чтобы увидеть их полезность. Когда вы видите, что тратите много времени или совершаете ошибки, выполняя «работу», возможно, есть место для некоторой «метаработы»!

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

Знаете ли вы еще какое-нибудь классное приложение метаклассов? Поделитесь ими в комментариях!