Многие считают, что метапрограммирование в 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 вызывает метаметоды метакласса в момент создания самого класса:


  1. Интерпретатор определяет и находит классы-родители для текущего класса (если они есть).
  2. Интерпретатор определяет метакласс (MetaClass в нашем случае).
  3. Вызывается метод MetaClass.__prepare__ – он должен возвратить dict-like объект, в который будут записаны атрибуты и методы класса. После этого объект будет передан в метод MetaClass.__new__ через аргумент attrs. О практическом использовании этого метода мы поговорим немного позже в примерах.
  4. Интерпретатор читает тело класса User и формирует параметры для передачи их в метакласс MetaClass.
  5. Вызывается метод MetaClass.__new__ – метод-коструктор, возвращает созданный объект класса. C аргументами name, bases и attrs мы уже встречались, когда передавали их в функцию type, а о параметре **extra_kwargs мы поговорим немного позже. Если тип аргумента attrs был изменен с помощью __prepare__, то его необходимо конвертировать в dict, прежде чем передать в вызов метода super().
  6. Вызывается метод MetaClass.__init__ – метод-инициализатор, с помощью которого в класс можно добавить дополнительные атрибуты и методы в объект класса. На практике используется в случаях, когда метаклассы наследуются от других метаклассов, в остальном все что можно сделать в __init__, лучше сделать в __new__. Например параметр __slots__ можно задать только в методе __new__, записав его в объект attrs.
  7. На этом шаге класс считается созданным.

А теперь создадим экземпляр нашего класса User и посмотрим на цепочку вызовов:


user = User(name='Alyosha')

  1. В момент вызова User(...) интерпретатор вызывает метод MetaClass.__call__(name='Alyosha'), куда передает объект класса и переданные аргументы.
  2. MetaClass.__call__ вызывает User.__new__(name='Alyosha') – метод-конструктор, который создает и возвращает экземпляр класса User
  3. Далее MetaClass.__call__ вызывает User.__init__(name='Alyosha') – метод-инициализатор, который добавляет новые атрибуты к созданному экземпляру.
  4. MetaClass.__call__ возвращает созданный и проинициализированный экземпляр класса User.
  5. В этот момент экземпляр класса считается созданным.

Это описание, конечно, не покрывает все нюансы использования метаклассов, но его достаточно, чтобы начать применять метапрограммирование для реализации некоторых архитектурных паттернов. Вперед – к примерам!


Абстрактные классы


И самый первый пример можно найти в стандартной библиотеке: 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.

Комментарии (6)


  1. hardtop
    09.10.2018 18:38

    Спасибо, отличная статья.


  1. vmm86
    09.10.2018 21:38

    Недавно в рабочем проекте потребовалось условно создавать класс, у которого в зависимости от тех или иных входных параметров должен быть:

    • тот или иной набор инстанс-методов,
    • каждый из которых имеет свой атрибут помимо self,
    • также к каждому такому методу должен быть применён кастомный декоратор, импортированный из стороннего пакета.
    Пытался использовать приёмы метапрограммирования, но инстанс-методы оказывались привязанными (bounded) не к самому классу, а к метаклассам, в которых они объявлялись. Рабочим оказалось, может быть, не самое красивое решение — условно конструировать строку с исходным кодом для создания класса и выполнять её с помощью exec(), добавляя созданный класс в globals того модуля, где его требовалось получить.


    1. Bahusss Автор
      09.10.2018 21:41

      А почему бы для этих целей не использовать фабрику классов, которая в зависимости от переданных параметров возвращала бы нужный класс с нужным набором методов?


      1. vmm86
        09.10.2018 22:06

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


    1. mayorovp
      10.10.2018 09:30
      +1

      А зачем вы объявляли методы в метаклассе?


  1. rumlod
    10.10.2018 17:41

    Спасибо, теперь я понимаю как тип class передается параметром функции…