Мы продолжаем говорить о метапрограммировании в 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 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
Самое интересное в коде выше – это объект 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!
Комментарии (14)
Zanak
12.10.2018 22:30Мета программирование позволяет вмешаться в процесс создания типа данных. Круто. Как быть с кейсами, когда это реально облегчает жизнь?
— Абстрактные классы — принято. А если мне требуется, например, форсировать объявление всех свойств инстанса слотами и сделать класс абстрактным?
— Про плагины не убедили.
— Про метаданные тоже. dir(...) вроде ни кто не отменял? Хотя да, можно запрятать атрибуты от любителей подглядывать в потроха класса в обход его публичного интерфейса.
— Это вообще можно использовать как вопрос на собеседовании:
Изменится поведение класса A, если в процессе работы изменится значение SOME_SETTING?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
— Singletone вполне можно построить и без metaprogramming.
— Про джангу с ее моделями и формами — наверное да, метаклассы там работают.
mayorovp
А не будет ли тут проблем с мутабельностью? Если создать два объекта, и у каждого попытаться получить bound method — не получится ли, что это будет один и тот же метод, привязанный ко второму объекту?
Ну и еще поправка. Интерпретатор помещает в словарь не unbound функции, а простые функции. Unbound function — это отдельная сущность, которая ведет себя немного иначе.
Кстати, вот еще одна проблема. Допустим, у нас есть вот такой класс:
Если теперь взять метод
foo.bar
— он окажется привязан к объектуfoo
, хотя должен бы кt1000
.Bahusss Автор
А какие именно? У двух разных объектов будет 2 разных instance, поэтому привязка должна быть точной.
Насколько я помню термина unbound function в питоне нет, есть unbound method, но для Python 3 он не актуален, в тексте статьи я написал «unbound функции» подразумевая, что это обычные функции, которые после привязки к объекту станут методами. Может быть стоит переписать этот участок, чтобы он никого не смущал.
Это интересный кейс, спасибо) Но привязка к определенному объекту\классу достаточно сильно усложнит код примера, поэтому я использовал самый простой и короткий вариант. Он конечно оказался с сайд-эффектами.
mayorovp
Зато у них будет одинаковый self. Тот самый, который возвращается из
__get__
Да, вы правы, для Python 3 он не актуален. Тем страньше что вам потребовалось сохранение cls — ведь обычные методы его не сохраняют...
Bahusss Автор
Ну если хранить какое-то состояние в классе PolyMethod, то да, возможны проблемы, но это легко решается созданием каждый раз нового объекта в
__get__
.classmethod требует объект класса первым параметром.
mayorovp
Но вы же так не делаете.
Декоратор classmethod сам этот объект класса туда засовывает. Если этого не происходит — значит, для полиметодов поломался сам механизм декораторов.
mayorovp
Только что проверил вот такой пример:
Как и ожидалось, classmethod не заработал. Более того, он упал с ошибкой
'classmethod' object has no attribute '__annotations__'
в методе add_implementation. До вызова__get__
дело даже не дошло.Bahusss Автор
Да, спасибо что заметили – код в статье разъехался с конечной его версией, я обновил реализацию PolyMethod.
Я и состояние не храню.
Только в том случае, если вы вызываете этот метод у класса «CLASS.method», потому что classmethod – это декоратор, который создает хитрый дескриптор. В момент создания PolyMethod-ов, класса еще нет, поэтому и classmethod о нем ничего не знает.
mayorovp
А это, блин, что по-вашему?
Вот к чему это приводит:
mayorovp
Ну так и надо ему в этом помочь. Вместо этой сложной конструкции из _get_callable_func и двух условий можно сделать вот так:
А в текущем виде декораторы classmethod и staticmethod вообще никак не влияют на поведение функции — это же неправильно.
Bahusss Автор
Этим приведенный вами код и занимается, как и мой. Но ваш вариант более элегантен, спасибо.
Это очевидно, потому что в примере мы заменяем все эти методы на один PolyMethod, который by-design обертка, которую я не старался сделать похожей во всем на оригинальные методы. Задачи такой не было: «Совсем не production-ready код, но очень хороший пример».
Выглядит как очень вырожденный случай, которым никто не воспользуется. Будем считать что в моем коде есть минорный баг, куда же без них.
mayorovp
Нет, это выглядит как уничтожение концепции bound method.