Хабрахабр

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' 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__ возвращает объект d = dict(), то при чтении следующего класса: Напоследок расскажу про возможное использование метода __prepare__.

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 _get_callable_func(self, impl): # "достаем" функцию classmethod/staticmethod return getattr(impl, '__func__', impl) def __call__(self, arg): impl = self.implementations[type(arg)] callable_func = self._get_callable_func(impl) if isinstance(impl, staticmethod): return callable_func(arg) elif self.cls and isinstance(impl, classmethod): return callable_func(self.cls, arg) else: return callable_func(self.instance, arg) def add_implementation(self, func): callable_func = self._get_callable_func(func) # расчитываем на то, что метод принимает только 1 параметр arg_name, arg_type = list(callable_func.__annotations__.items())[0] self.implementations[arg_type] = func

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

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

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__, пишите, пожалуйста, в комментариях.

Заключение

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

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть