Многие считают, что метапрограммирование в Python излишне усложняет код, но если использовать его правильно, то можно быстро и элегантно реализовать сложные паттерны проектирования. Помимо этого, такие известные Python-фреймворки, как Django, DRF и SQLAlchemy, используют метаклассы, чтобы обеспечить легкую расширяемость и простое переиспользование кода.

В этой статье расскажу, почему не стоит бояться использовать метапрограммирование в своих проектах и покажу, для каких задач оно подходит лучше всего. Еще больше о возможностях метапрограммирования можно узнать на курсе Advanced Python.
Для сначала давайте вспомним основы метапрограммирования в Python. Не лишним будет добавить, что все что написано ниже относится к версии Python 3.5 и выше.
Краткий экскурс в модель данных Python
Итак, все мы знаем, что все в Python является объектом, и не секрет, что для каждого объекта существует некий класс, которым он был порожден, например:
>>> def f(): pass >>> type(f) <class 'function'>
Тип объекта или же класс, которым объект был порожден, можно определить с помощью встроенной функции type, которая имеет достаточно интересную сигнатуру вызова (о ней речь пойдет немного позже). Такого же эффекта можно добиться, если вывести атрибут __class__ у любого объекта.
Итак, для создания функций служит некий встроенный класс function. Посмотрим, что мы сможем сделать с его помощью. Для этого возьмем заготовку из встроенного модуля types:
>>> from types import FunctionType >>> FunctionType <class 'function'> >>> help(FunctionType) class function(object) | function(code, globals[, name[, argdefs[, closure]]]) | | Create a function object from a code object and a dictionary. | The optional name string overrides the name from the code object. | The optional argdefs tuple specifies the default argument values. | The optional closure tuple supplies the bindings for free variables.
Как мы видим, любая функция в Python – это экземпляр описанного выше класса. Давайте теперь попробуем создать новую функцию, не прибегая к её объявлению через def. Для этого нам потребуется научиться создавать объекты кода с помощью встроенной в интерпретатор функции compile:
# создаем объект кода, который выводит строку "Hello, world!" >>> code = compile('print("Hello, world!")', '<repl>', 'eval') >>> code <code object <module> at 0xdeadbeef, file "<repl>", line 1> # создаем функцию, передав в конструктор объект кода, # глобальные переменные и название функции >>> func = FunctionType(code, globals(), 'greetings') >>> func <function <module> at 0xcafefeed> >>> func.__name__ 'greetings' >>> func() Hello, world!
Отлично! С помощью мета-инструментов мы научились создавать функции «на лету», однако на практике подобное знание используется редко. Теперь давайте взглянем, как создаются объекты-классы и объекты-экземпляры этих классов:
>>> class User: pass >>> user = User() >>> type(user) <class '__main__.User'> >>> type(User) <class 'type'>
Вполне очевидно, что класс User используется для создания экземпляра user, намного интереснее посмотреть на класс type, который используется для создания самого класса User. Вот здесь мы и обратимся ко второму варианту вызова встроенной функции type, которая по совместительству является метаклассом для любого класса в Python. Метакласс по определению – это класс, экземпляром которого является другой класс. Метаклассы позволяют нам настраивать процесс создания класса и частично управлять процессом создания экземпляра класса.
Согласно документации, второй вариант сигнатуры type(name, bases, attrs) – возвращает новый тип данных или, если по-простому – новый класс, причем атрибут name станет атрибутом __name__ у возвращенного класса, bases – список классов-родителей будет доступен как __bases__, ну а attrs – dict-like объект, содержащий все атрибуты и методы класса, перейдет в __dict__. Принцип работы функции можно описать в виде простого псевдокода на Python:
type(name, bases, attrs) ~ class name(bases): attrs
Посмотрим, как можно, используя только вызов type, сконструировать совершенно новый класс:
>>> User = type('User', (), {}) >>> User <class '__main__.User'>
Как видим, нам не требуется использовать ключевое слово class, чтобы создать новый класс, функция type справляется и без этого, теперь давайте рассмотрим пример посложнее:
class User: def __init__(self, name): self.name = name class SuperUser(User): """Encapsulate domain logic to work with super users""" group_name = 'admin' @property def login(self): return f'{self.group_name}/{self.name}'.lower() # Теперь создадим аналог класса SuperUser "динамически" CustomSuperUser = type( # Название класса 'SuperUser', # Список классов, от которых новый класс наследуется (User, ), # Атрибуты и методы нового класса в виде словаря { '__doc__': 'Encapsulate domain logic to work with super users', 'group_name': 'admin', 'login': property(lambda self: f'{self.group_name}/{self.name}'.lower()), } ) assert SuperUser.__doc__ == CustomSuperUser.__doc__ assert SuperUser('Vladimir').login == CustomSuperUser('Vladimir').login
Как видно из примеров выше, описание классов и функций с помощью ключевых слов class и def – это всего лишь синтаксический сахар и любые типы объектов можно создавать обычными вызовами встроенных функций. А теперь, наконец, поговорим о том, как можно использовать динамическое создание классов в реальных проектах.
Динамическое создание форм и валидаторов
Иногда нам требуется провалидировать информацию от пользователя или из других внешних источников согласно заранее известной схеме данных. Например, мы хотим изменять форму логина пользователя из админки – удалять и добавлять поля, менять стратегию их валидации и т.д.
Для иллюстрации, попробуем динамически создать Django-форму, описание схемы которой хранится в следующем json формате:
{ "fist_name": { "type": "str", "max_length": 25 }, "last_name": { "type": "str", "max_length": 30 }, "age": { "type": "int", "min_value": 18, "max_value": 99 } }
Теперь на основе описания выше создадим набор полей и новую форму с помощью уже известной нам функции type:
import json from django import forms fields_type_map = { 'str': forms.CharField, 'int': forms.IntegerField, } # form_description – наш json с описание формата deserialized_form_description: dict = json.loads(form_description) form_attrs = {} # выбираем класс объекта поля в форме в зависимости от его типа for field_name, field_description in deserialized_form_description.items(): field_class = fields_type_map[field_description.pop('type')] form_attrs[field_name] = field_class(**field_description) user_form_class = type('DynamicForm', (forms.Form, ), form_attrs) >>> form = user_form_class({'age': 101}) >>> form <DynamicForm bound=True, valid=Unknown, fields=(fist_name;last_name;age)> >>> form.is_valid() False >>> form.errors {'fist_name': ['This field is required.'], 'last_name': ['This field is required.'], 'age': ['Ensure this value is less than or equal to 99.']}
Супер! Теперь можно передать созданную форму в шаблон и отрендерить ее для пользователя. Такой же подход можно использовать и с другими фреймворками для валидации и представления данных (DRF Serializers, marshmallow и другие).
Конфигурируем создание нового класса через метакласс
Выше мы рассмотрели уже «готовый» метакласс type, но чаще всего в коде вы будете создавать свои собственные метаклассы и использовать их для конфигурации создания новых классов и их экземпляров. В общем случае «болванка» метакласса выглядит так:
class MetaClass(type): """ Описание принимаемых параметров: mcs – объект метакласса, например <__main__.MetaClass> name – строка, имя класса, для которого используется данный метакласс, например "User" bases – кортеж из классов-родителей, например (SomeMixin, AbstractUser) attrs – dict-like объект, хранит в себе значения атрибутов и методов класса cls – созданный класс, например <__main__.User> extra_kwargs – дополнительные keyword-аргументы переданные в сигнатуру класса args и kwargs – аргументы переданные в конструктор класса при создании нового экземпляра """ def __new__(mcs, name, bases, attrs, **extra_kwargs): return super().__new__(mcs, name, bases, attrs) def __init__(cls, name, bases, attrs, **extra_kwargs): super().__init__(cls) @classmethod def __prepare__(mcs, cls, bases, **extra_kwargs): return super().__prepare__(mcs, cls, bases, **kwargs) def __call__(cls, *args, **kwargs): return super().__call__(*args, **kwargs)
Чтобы воспользоваться этим метаклассом для конфигурации класса User, используется следующий синтаксис:
class User(metaclass=MetaClass): def __new__(cls, name): return super().__new__(cls) def __init__(self, name): self.name = name
Самое интересное – это порядок, в котором интерпретатор Python вызывает метаметоды метакласса в момент создания самого класса:
- Интерпретатор определяет и находит классы-родители для текущего класса (если они есть).
- Интерпретатор определяет метакласс (
MetaClassв нашем случае). - Вызывается метод
MetaClass.__prepare__– он должен возвратить dict-like объект, в который будут записаны атрибуты и методы класса. После этого объект будет передан в методMetaClass.__new__через аргументattrs. О практическом использовании этого метода мы поговорим немного позже в примерах. - Интерпретатор читает тело класса
Userи формирует параметры для передачи их в метаклассMetaClass. - Вызывается метод
MetaClass.__new__– метод-коструктор, возвращает созданный объект класса. C аргументамиname,basesиattrsмы уже встречались, когда передавали их в функциюtype, а о параметре**extra_kwargsмы поговорим немного позже. Если тип аргументаattrsбыл изменен с помощью__prepare__, то его необходимо конвертировать вdict, прежде чем передать в вызов методаsuper(). - Вызывается метод
MetaClass.__init__– метод-инициализатор, с помощью которого в класс можно добавить дополнительные атрибуты и методы в объект класса. На практике используется в случаях, когда метаклассы наследуются от других метаклассов, в остальном все что можно сделать в__init__, лучше сделать в__new__. Например параметр__slots__можно задать только в методе__new__, записав его в объектattrs. - На этом шаге класс считается созданным.
А теперь создадим экземпляр нашего класса User и посмотрим на цепочку вызовов:
user = User(name='Alyosha')
- В момент вызова
User(...)интерпретатор вызывает методMetaClass.__call__(name='Alyosha'), куда передает объект класса и переданные аргументы. MetaClass.__call__вызываетUser.__new__(name='Alyosha')– метод-конструктор, который создает и возвращает экземпляр классаUser- Далее
MetaClass.__call__вызываетUser.__init__(name='Alyosha')– метод-инициализатор, который добавляет новые атрибуты к созданному экземпляру. MetaClass.__call__возвращает созданный и проинициализированный экземпляр классаUser.- В этот момент экземпляр класса считается созданным.
Это описание, конечно, не покрывает все нюансы использования метаклассов, но его достаточно, чтобы начать применять метапрограммирование для реализации некоторых архитектурных паттернов. Вперед – к примерам!
Абстрактные классы
И самый первый пример можно найти в стандартной библиотеке: ABCMeta – метакласс позволяет объявить любой наш класс абстрактным и заставить всех его наследников реализовывать заранее заданные ��етоды, свойства и атрибуты, вот посмотрите:
from abc import ABCMeta, abstractmethod class BasePlugin(metaclass=ABCMeta): """ Атрибут класса supported_formats и метод run обязаны быть реализованы в наследниках этого класса """ @property @abstractmethod def supported_formats(self) -> list: pass @abstractmethod def run(self, input_data: dict): pass
Если в наследнике не будут реализованы все абстрактные методы и атрибуты, то при попытке создать экземпляр класса-наследника мы получим TypeError:
class VideoPlugin(BasePlugin): def run(self): print('Processing video...') plugin = VideoPlugin() # TypeError: Can't instantiate abstract class VideoPlugin # with abstract methods supported_formats
Использование абстрактных классов помогает сразу зафиксировать интерфейс базового класса и избежать ошибок при наследовании в будущем, например опечатки в названии переопределенного метода.
Система плагинов с автоматической регистрацией
Достаточно часто метапрограммирование применяют для реализации различных паттернов проектирования. Почти любой известный фреймворк использует метаклассы для создания registry-объектов. Такие объекты хранят в себе ссылки на другие объекты и позволяют их быстро получать в любом месте программы. Рассмотрим простой пример авторегистрации плагинов для проигрывания медиафайлов различных форматов.
Реализация метакласса:
class RegistryMeta(ABCMeta): """ Метакласс, который создает реестр из классов наследников. Реестр хранит ссылки вида "формат файла" -> "класс плагина" """ _registry_formats = {} def __new__(mcs, name, bases, attrs): cls: 'BasePlugin' = super().__new__(mcs, name, bases, attrs) # не обрабатываем абстрактные классы (BasePlugin) if inspect.isabstract(cls): return cls for media_format in cls.supported_formats: if media_format in mcs._registry_formats: raise ValueError(f'Format {media_format} is already registered') # сохраняем ссылку на плагин в реестре mcs._registry_formats[media_format] = cls return cls @classmethod def get_plugin(mcs, media_format: str): try: return mcs._registry_formats[media_format] except KeyError: raise RuntimeError(f'Plugin is not defined for {media_format}') @classmethod def show_registry(mcs): from pprint import pprint pprint(mcs._registry_formats)
А вот и сами плагины, реализацию BasePlugin возьмем из предыдущего примера:
class BasePlugin(metaclass=RegistryMeta): ... class VideoPlugin(BasePlugin): supported_formats = ['mpg', 'mov'] def run(self): ... class AudioPlugin(BasePlugin): supported_formats = ['mp3', 'flac'] def run(self): ...
После выполнения этого кода интерпретатором в нашем реестре будут зарегистрированы 4 формата и 2 плагина, которые могут обрабатывать эти форматы:
>>> RegistryMeta.show_registry() {'flac': <class '__main__.AudioPlugin'>, 'mov': <class '__main__.VideoPlugin'>, 'mp3': <class '__main__.AudioPlugin'>, 'mpg': <class '__main__.VideoPlugin'>} >>> plugin_class = RegistryMeta.get_plugin('mov') >>> plugin_class <class '__main__.VideoPlugin'> >>> plugin_class().run() Processing video...
Тут стоит отметить еще один интересный нюанс работы с метаклассами, благодаря неочевидному method resolution order, мы можем вызвать метод show_registry не только у класса RegistyMeta, но и у любого другого класса метаклассом которых он является:
>>> AudioPlugin.get_plugin('avi') # RuntimeError: Plugin is not found for avi
Использование имен атрибутов в качестве метаданных
С помощью метаклассов можно использовать названия атрибутов классов в качестве метаданных для других объектов. Ничего непонятно? Но я уверен вы уже видели этот подход множество раз, например декларативное объявление полей модели в Django:
class Book(models.Model): title = models.Charfield(max_length=250)
В пример выше title – это имя питоновского идентификатора, оно же используется и для названия колонки в таблице book, хотя мы это нигде явно не указывали. Да, подобная «магия» может быть реализована с помощью метапрограммирования. Давайте, например, реализуем систему передачи ошибок приложения на фронтенд, чтобы у каждого сообщения был читаемый код, который может быть использован для перевода сообщения на другой язык. Итак, у нас есть объект сообщения, который можно сконвертировать в json:
class Message: def __init__(self, text, code=None): self.text = text self.code = code def to_json(self): return json.dumps({'text': self.text, 'code': self.code})
Все наши сообщения об ошибках будем хранить в отдельном «namespace»:
class Messages: not_found = Message('Resource not found') bad_request = Message('Request body is invalid') ... >>> Messages.not_found.to_json() {"text": "Resource not found", "code": null}
Теперь мы хотим, чтобы code стал не null, а not_found, для этого напишем следующий метакласс:
class MetaMessage(type): def __new__(mcs, name, bases, attrs): for attr, value in attrs.items(): # проходим по всем описанным в классе атрибутам с типом Message # и заменяем поле code на называние атрибута # (если code не задан за��анее) if isinstance(value, Message) and value.code is None: value.code = attr return super().__new__(mcs, name, bases, attrs) class Messages(metaclass=MetaMessage): ...
Посмотрим как наши сообщения выглядят теперь:
>>> Messages.not_found.to_json() {"text": "Resource not found", "code": "not_found"} >>> Messages.bad_request.to_json() {"text": "Request body is invalid", "code": "bad_request"}
То что надо! Теперь вы знаете что делать, чтобы по формату данных можно было легко отыскать код, который их обрабатывает.
Кэширование метаданных о классе и его наследниках
Еще один частый случай – это кэширование каких-либо статических данных на этапе создания класса, чтобы не тратить время на их вычисление во время работы приложения. К тому же некоторые данные можно обновлять при создании новых экземпляров классов, например, счетчик количества созданных объектов.
Как это можно использовать? Допустим, вы разрабатываете фреймворк для построения отчетов и таблиц и у вас есть такой объект:
class Row(metaclass=MetaRow): name: str age: int ... def __init__(self, **kwargs): self.counter = None for attr, value in kwargs.items(): setattr(self, attr, value) def __str__(self): out = [self.counter] # аттрибут __header__ будет динамически добавлен в метаклассе for name in self.__header__[1:]: out.append(getattr(self, name, 'N/A')) return ' | '.join(map(str, out))
Мы хотим сохранять и увеличивать счетчик при создании нового ряда, а также хотим сгенерировать заголовок результирующей таблицы заранее. Metaclass to the rescue!
class MetaRow(type): # глобальный счетчик всех созданных рядов row_count = 0 def __new__(mcs, name, bases, attrs): cls = super().__new__(mcs, name, bases, attrs) # Кэшируем список всех полей в ряду отсортированный по алфавиту cls.__header__ = ['№'] + sorted(attrs['__annotations__'].keys()) return cls def __call__(cls, *args, **kwargs): # создание нового ряда происходит здесь row: 'Row' = super().__call__(*args, **kwargs) # увеличиваем глобальный счетчик cls.row_count += 1 # выставляем номер текущего ряда row.counter = cls.row_count return row
Здесь нужно пояснить 2 вещи:
- У класс
Rowнет атрибутов класса с именамиnameиage– это аннотации типов, поэтому их нет в ключах словаряattrs, и, чтобы получить список полей, мы используем атрибут класса__annotations__. - Операция
cls.row_count += 1должна была ввести вас в заблуждение: как же так? Ведьclsэто классRowу него нет атрибутаrow_count. Всё верно, но как я уже объяснял выше – если у созданного класса нет атрибута или метода, который пытаются вызывать, то интерпретатор идет дальше по цепочке базовых классов – если и в них нет – происходит поиск в метаклассе. В таких случаях, чтобы никого не запутать лучше использовать другую запись:MetaRow.row_count += 1.
Смотрите, как элегантно теперь можно отобразить всю таблицу:
rows = [ Row(name='Valentin', age=25), Row(name='Sergey', age=33), Row(name='Gosha'), ] print(' | '.join(Row.__header__)) for row in rows: print(row)
№ | age | name 1 | 25 | Valentin 2 | 33 | Sergey 3 | N/A | Gosha
Кстати, отображение и работу с таблицой можно инкапсулировать в какой-нибудь отдельный класс Sheet.
Продолжение следует...
В следующей части этой статьи я расскажу как использовать метаклассы для отладки кода вашего приложения, как параметризовать создание метакласса, и покажу основные примеры использования метода __prepare__. Stay tuned!
Более подробно про метаклассы и дескрипторы в Python я буду рассказывать в рамках интенсива Advanced Python.
