Мы продолжаем говорить о метапрограммировании в 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}
Есть несколько возможных вариантов использования этой особенности. Все они разной степени полезности, итак:
- В версиях Python =< 3.5, если нам требовалось сохранить порядок объявления методов в классе, мы могли бы вернуть
collections.OrderedDict
из метода__prepare__
, в версиях старше встроенные словари уже сохраняют порядок добавления ключей, поэтому необходимость вOrderedDict
отпала. - В модуле стандартной библиотеки
enum
используется кастомный dict-like объект, чтобы определять случаи, когда атрибут класса дублируется при объявлении. Код можно посмотреть здесь. - Совсем не 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/