Python: метапрограммирование в продакшене. Часть вторая

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

lstyghddpotgif7f6jg8cl6kcbi.jpeg

Теперь посмотрим как можно изменять вызовы методов. Больше о возможностях метапрограммирования вы сможете узнать на курсе Advanced Python.


Отладка и трейсинг вызовов

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

Следующий метакласс замеряет время выполнения каждого метода в классе и его экземплярах, а также время создания самого экземпляра:

from contextlib import contextmanager  
import logging  
import time  
import wrapt  

@contextmanager  
def timing_context(operation_name):  
    """Этот контекст менеджер замеряет время выполнения произвольной операции"""
    start_time = time.time()  
    try:  
        yield  
     finally:  
        logging.info('Operation "%s" completed in %0.2f seconds', 
                          operation_name, time.time() - start_time)  

@wrapt.decorator  
def timing(func, instance, args, kwargs):
    """
    Замеряет время выполнения произвольной фукнции или метода.
    Здесь мы используем библиотеку https://wrapt.readthedocs.io/en/latest/
    чтобы безболезненно декорировать методы класса и статические методы
    """
    with timing_context(func.__name__):  
        return func(*args, **kwargs)  

class DebugMeta(type):   
    def __new__(mcs, name, bases, attrs):  
        for attr, method in attrs.items():  
            if not attr.startswith('_'):  
                # оборачиваем все методы декоратором            
                attrs[attr] = timing(method)  
        return super().__new__(mcs, name, bases, attrs)  

    def __call__(cls, *args, **kwargs):  
        with timing_context(f'{cls.__name__} instance creation'):  
            # замеряем время выполнения создания экземпляра
            return super().__call__(*args, **kwargs)

Посмотрим на отладку в действии:

class User(metaclass=DebugMeta):  

    def __init__(self, name):  
        self.name = name  
        time.sleep(.7)  

    def login(self):  
        time.sleep(1)  

    def logout(self):  
        time.sleep(2)  

    @classmethod  
    def create(cls):  
        time.sleep(.5)  

user = User('Michael')  
user.login()  
user.logout()  
user.create()

# Вывод логгера
INFO:__main__:Operation "User instance creation" completed in 0.70 seconds
INFO:__main__:Operation "login" completed in 1.00 seconds
INFO:__main__:Operation "logout" completed in 2.00 seconds
INFO:__main__:Operation "create" completed in 0.50 seconds

Попробуйте самостоятельно расширить DebugMeta и логгировать сигнатуру методов и их stack-trace.


Паттерн «одиночка» и запрет наследования

А теперь перейдем к экзотическим случаям использования метаклассов в питоновских проектах.

Наверняка многие из вас используют обычный питоновский модуль для реализации шаблона проектирования одиночка (он же Singleton), ведь это намного удобнее и быстрее, чем писать соответствующий метакласс. Однако давайте напишем одну из его реализаций ради академического интереса:

class Singleton(type):  
    instance = None  

    def __call__(cls, *args, **kwargs):  
        if cls.instance is None:  
            cls.instance = super().__call__(*args, **kwargs)  

     return cls.instance  

class User(metaclass=Singleton):  

    def __init__(self, name):  
        self.name = name  

    def __repr__(self):  
        return f''

u1 = User('Pavel')  
# Начиная с этого момента все пользователи будут Павлами
u2 = User('Stepan')

>>> id(u1) == id(u2)
True
>>> u2

>>> User.instance

# Как тебе такое, Илон?
>>> u1.instance.instance.instance.instance

У этой реализации есть интересный нюанс — поскольку конструктор класса во второй раз не вызывается, то можно ошибиться и не передать туда нужный параметр и во время выполнения ничего не произойдет, если экземпляр уже был создан. Например:

>>> User('Roman')

>>> User('Alexey', 'Petrovich', 66)  # конструктор не принимает столько параметров!

# Но если бы конструктор User до этого момента еще не вызывался
# мы бы получили TypeError!

А теперь взглянем на еще более экзотический вариант: запрет на наследование от определенного класса.

class FinalMeta(type):  

    def __new__(mcs, name, bases, attrs):  
        for cls in bases:  
            if isinstance(cls, FinalMeta):  
                raise TypeError(f"Can't inherit {name} class from 
                                final {cls.__name__}") 

        return super().__new__(mcs, name, bases, attrs)  

class A(metaclass=FinalMeta):
    """От меня нельзя наследоваться!"""  
    pass  

class B(A):  
    pass

# TypeError: Can't inherit B class from final A
# Ну я же говорил!

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

Например можно в параметр metaclass при объявлении класса передать функцию и возвращать из нее разные экземпляры метаклассов в зависимости от каких-то условий, например:

def get_meta(name, bases, attrs):
    if SOME_SETTING:
        return MetaClass1(name, bases, attrs)
    else:
        return MetaClass2(name, bases, attrs)

class A(metaclass=get_meta):
    pass

Но более интересный пример — это использование extra_kwargs параметров при объявлении классов. Допустим, вы хотите с помощью метакласса поменять поведение определенных методов в классе и у каждого класса эти методы могут называться по-разному. Что же делать? А вот что

# Параметризуем наш `DebugMeta` метакласс из примера выше
class DebugMetaParametrized(type):  

    def __new__(mcs, name, bases, attrs, **extra_kwargs):  
        debug_methods = extra_kwargs.get('debug_methods', ())  

        for attr, value in attrs.items():  
            # Замеряем время исполнения только для методов, имена которых  
            # переданы в параметре `debug_methods`:  
            if attr in debug_methods:  
                attrs[attr] = timing(value)  
        return super().__new__(mcs, name, bases, attrs)

class User(metaclass=DebugMetaParametrized, debug_methods=('login', 'create')):
    ...

user = User('Oleg')  
user.login()
# Метод "logout" залогирован не будет. 
user.logout()
user.create()

На мой взгляд, получилось очень элегантно! Можно придумать достаточно много паттернов использования такой параметризации, однако помните главное правило — все хорошо в меру.


Примеры использования метода __prepare__

Напоследок расскажу про возможное использование метода __prepare__. Как уже говорилось выше, этот метод должен вернуть объект-словарь, который интерпретатор заполняет в момент парсинга тела класса, например если __prepare__ возвращает объект d = dict(), то при чтении следующего класса:

class A:
    x = 12
    y = 'abc'
    z = {1: 2}

Интерпретатор выполнит такие операции:

d['x'] = 12
d['y'] = 'abc'
d['z'] = {1: 2}

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


  1. В версиях Python =< 3.5, если нам требовалось сохранить порядок объявления методов в классе, мы могли бы вернуть collections.OrderedDict из метода __prepare__, в версиях старше встроенные словари уже сохраняют порядок добавления ключей, поэтому необходимость в OrderedDict отпала.
  2. В модуле стандартной библиотеки enum используется кастомный dict-like объект, чтобы определять случаи, когда атрибут класса дублируется при объявлении. Код можно посмотреть здесь.
  3. Совсем не production-ready код, но очень хороший пример — поддержка параметрического полиморфизма.

Например, рассмотрим следующий класс c тремя реализациями одного полиморфного метода:

class Terminator:  

    def terminate(self, x: int):  
        print(f'Terminating INTEGER {x}')  

    def terminate(self, x: str):  
        print(f'Terminating STRING {x}')  

    def terminate(self, x: dict):  
        print(f'Terminating DICTIONARY {x}')  

t1000 = Terminator()  
t1000.terminate(10)  
t1000.terminate('Hello, world!')  
t1000.terminate({'hello': 'world'})

# Вывод
Terminating DICTIONARY 10
Terminating DICTIONARY Hello, world!
Terminating DICTIONARY {'hello': 'world'}

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

class PolyDict(dict):  
    """  
    Словарь, который при сохранении одного и того же ключа 
    оборачивает все его значения в один PolyMethod.  
    """
    def __setitem__(self, key: str, func):  
        if not key.startswith('_'):  

            if key not in self:  
                super().__setitem__(key, PolyMethod())  
            self[key].add_implementation(func)
            return None

        return super().__setitem__(key, func)

class PolyMethod:  
    """  
    Обертка для полиморфного метода, которая хранит связь между типом аргумента
    и реализацией метода для данного типа. Для данного объекта мы реализуем 
    протокол дескриптора, чтобы поддержать полиморфизм для всех типов методов:
    instance method, staticmethod, classmethod.
    """  
    def __init__(self):  
        self.implementations = {}  
        self.instance = None  
        self.cls = None  

    def __get__(self, instance, cls):  
        self.instance = instance  
        self.cls = cls  
        return self  

    def __call__(self, arg):  
        impl = self.implementations[type(arg)]  

        if self.instance:  
            return impl(self.instance, arg)  
        elif self.cls:  
            return impl(self.cls, arg)  
        else:  
            return impl(arg)  

    def add_implementation(self, func):  
        # расчитываем на то, что метод принимает только 1 параметр  
        arg_name, arg_type = list(func.__annotations__.items())[0]  
        self.implementations[arg_type] = func

Самое интересное в коде выше — это объект PolyMethod, который хранит реестр с реализациями одного и того же метода в зависимости от типа аргумента переданного в этот метод. A объект PolyDict мы вернем из метода __prepare__ и тем самым сохраним разные реализации методов с одинаковым именем terminate. Важный момент — при чтении тела класса и при создании объекта attrs интерпретатор помещает туда так называемые unbound функции, эти функции еще не знают у какого класса или экземпляра они будут вызваны. Нам пришлось реализовать протокол дескриптора, чтобы определить контекст во время вызова функции и передать первым параметром либо self либо cls, либо ничего не передавать если вызван staticmethod.

В итоге мы увидим следующую магию:

class PolyMeta(type):  

    @classmethod
    def __prepare__(mcs, name, bases):  
        return PolyDict()  

class Terminator(metaclass=PolyMeta):  
   ...  

t1000 = Terminator()  
t1000.terminate(10)  
t1000.terminate('Hello, world!')  
t1000.terminate({'hello': 'world'})

# Вывод
Terminating INTEGER 10
Terminating STRING Hello, world!
Terminating DICTIONARY {'hello': 'world'}

>>> t1000.terminate
<__main__.PolyMethod object at 0xdeadcafe>

Если вы знаете еще какие-нибудь интересные использования метода __prepare__, пишите, пожалуйста, в комментариях.


Заключение

Метапрограммирование — одна из многих тем, рассказываемых мной на интенсиве Advanced Python. В рамках курса я также расскажу, как эффективно использовать принципы SOLID и GRASP в разработке больших проектов на Python, проектировать архитектуру приложений и писать высокопроизводительный и качественный код. Буду рад увидеться с вами в стенах Binary District!

© Habrahabr.ru