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

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

Теперь посмотрим как можно изменять вызовы методов. Больше о возможностях метапрограммирования вы сможете узнать на курсе 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'<User: {self.name}>'  u1 = User('Pavel')   # Начиная с этого момента все пользователи будут Павлами u2 = User('Stepan')  >>> id(u1) == id(u2) True >>> u2 <User: Pavel> >>> User.instance <User: Pavel> # Как тебе такое, Илон? >>> u1.instance.instance.instance.instance <User: Pavel>

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

>>> User('Roman') <User: Roman> >>> User('Alexey', 'Petrovich', 66)  # конструктор не принимает столько параметров! <User: Roman> # Но если бы конструктор 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 super().__setattr__(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!


ссылка на оригинал статьи https://habr.com/post/422415/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *