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''
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}
Есть несколько возможных вариантов использования этой особенности. Все они разной степени полезности, итак:
- В версиях 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 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!