Перевод статьи «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.
Заключение
Я знаю, о чем вы думаете — всем известно выражение что все эти метаклассы полезны, только если вы пишете свой фреймворк. В какой то мере это правильно, но если вы обнаружите в своем проекте повторяющийся шаблон кода, почему бы не подумать о метаклассах?
Часть «мета» — ключ к тому, чтобы увидеть их полезность. Когда вы видите, что тратите много времени или совершаете ошибки, выполняя «работу», возможно, есть место для некоторой «метаработы»!
Метаклассы могут быть использованы для обеспечения соблюдения соглашений, управления реализацией или обеспечения легкого расширения.
Знаете ли вы еще какое-нибудь классное приложение метаклассов? Поделитесь ими в комментариях!