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


Мы продолжаем говорить о метапрограммировании в 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 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):

  1. mayorovp
    /#19226133

    def __get__(self, instance, cls):  
    self.instance = instance  
    self.cls = cls  
    return self  

    А не будет ли тут проблем с мутабельностью? Если создать два объекта, и у каждого попытаться получить bound method — не получится ли, что это будет один и тот же метод, привязанный ко второму объекту?


    Ну и еще поправка. Интерпретатор помещает в словарь не unbound функции, а простые функции. Unbound function — это отдельная сущность, которая ведет себя немного иначе.


    Кстати, вот еще одна проблема. Допустим, у нас есть вот такой класс:


    class Foo:
        bar = t1000.terminate
    
    foo = Foo()

    Если теперь взять метод foo.bar — он окажется привязан к объекту foo, хотя должен бы к t1000.

    • Bahusss
      /#19227233

      А не будет ли тут проблем с мутабельностью?


      А какие именно? У двух разных объектов будет 2 разных instance, поэтому привязка должна быть точной.

      Интерпретатор помещает в словарь не unbound функции, а простые функции.


      Насколько я помню термина unbound function в питоне нет, есть unbound method, но для Python 3 он не актуален, в тексте статьи я написал «unbound функции» подразумевая, что это обычные функции, которые после привязки к объекту станут методами. Может быть стоит переписать этот участок, чтобы он никого не смущал.

      Если теперь взять метод foo.bar — он окажется привязан к объекту foo, хотя должен бы к t1000.


      Это интересный кейс, спасибо) Но привязка к определенному объекту\классу достаточно сильно усложнит код примера, поэтому я использовал самый простой и короткий вариант. Он конечно оказался с сайд-эффектами.

      • mayorovp
        /#19227291

        А какие именно? У двух разных объектов будет 2 разных instance, поэтому привязка должна быть точной.

        Зато у них будет одинаковый self. Тот самый, который возвращается из __get__


        Насколько я помню термина unbound function в питоне нет, есть unbound method, но для Python 3 он не актуален

        Да, вы правы, для Python 3 он не актуален. Тем страньше что вам потребовалось сохранение cls — ведь обычные методы его не сохраняют...

        • Bahusss
          /#19227481

          Зато у них будет одинаковый self. Тот самый, который возвращается из __get__

          Ну если хранить какое-то состояние в классе PolyMethod, то да, возможны проблемы, но это легко решается созданием каждый раз нового объекта в __get__.

          Тем страньше что вам потребовалось сохранение cls — ведь обычные методы его не сохраняют...

          classmethod требует объект класса первым параметром.

          • mayorovp
            /#19227527

            Ну если хранить какое-то состояние в классе PolyMethod, то да, возможны проблемы, но это легко решается созданием каждый раз нового объекта в __get__.

            Но вы же так не делаете.


            classmethod требует объект класса первым параметром.

            Декоратор classmethod сам этот объект класса туда засовывает. Если этого не происходит — значит, для полиметодов поломался сам механизм декораторов.

          • mayorovp
            /#19227637 / +1

            Только что проверил вот такой пример:


            class Test(metaclass=PolyMeta):
              @classmethod
              def baz(self, x :str):
                pass

            Как и ожидалось, classmethod не заработал. Более того, он упал с ошибкой 'classmethod' object has no attribute '__annotations__' в методе add_implementation. До вызова __get__ дело даже не дошло.

            • Bahusss
              /#19227939

              Да, спасибо что заметили – код в статье разъехался с конечной его версией, я обновил реализацию PolyMethod.

              Но вы же так не делаете.

              Я и состояние не храню.

              Декоратор classmethod сам этот объект класса туда засовывает.

              Только в том случае, если вы вызываете этот метод у класса «CLASS.method», потому что classmethod – это декоратор, который создает хитрый дескриптор. В момент создания PolyMethod-ов, класса еще нет, поэтому и classmethod о нем ничего не знает.

              • mayorovp
                /#19228321

                Я и состояние не храню.

                А это, блин, что по-вашему?


                        self.instance = instance  
                        self.cls = cls

                Вот к чему это приводит:


                class Foo(metaclass=PolyMeta):
                    def __init__(self, name):
                      self.name = name
                
                    def bar(self, x: str):
                      print(self.name)
                
                a = Foo("a").bar
                b = Foo("b").bar
                
                a("") #"b", хотя должно быть "a"
                b("") #"b"

              • mayorovp
                /#19228355

                Только в том случае, если вы вызываете этот метод у класса «CLASS.method», потому что classmethod – это декоратор, который создает хитрый дескриптор. В момент создания PolyMethod-ов, класса еще нет, поэтому и classmethod о нем ничего не знает.

                Ну так и надо ему в этом помочь. Вместо этой сложной конструкции из _get_callable_func и двух условий можно сделать вот так:


                def __call__(self, arg):
                    impl = self.implementations[type(arg)]
                    return impl.__get__(self.instance, self.cls).__call__(arg)

                А в текущем виде декораторы classmethod и staticmethod вообще никак не влияют на поведение функции — это же неправильно.

                • Bahusss
                  /#19228409

                  Ну так и надо ему в этом помочь.

                  Этим приведенный вами код и занимается, как и мой. Но ваш вариант более элегантен, спасибо.

                  А в текущем виде декораторы classmethod и staticmethod вообще никак не влияют на поведение функции — это же неправильно.

                  Это очевидно, потому что в примере мы заменяем все эти методы на один PolyMethod, который by-design обертка, которую я не старался сделать похожей во всем на оригинальные методы. Задачи такой не было: «Совсем не production-ready код, но очень хороший пример».

                  Вот к чему это приводит:

                  Выглядит как очень вырожденный случай, которым никто не воспользуется. Будем считать что в моем коде есть минорный баг, куда же без них.

                  • mayorovp
                    /#19228433

                    Нет, это выглядит как уничтожение концепции bound method.

  2. Bahusss
    /#19227223

    --

  3. Zanak
    /#19229105

    Мета программирование позволяет вмешаться в процесс создания типа данных. Круто. Как быть с кейсами, когда это реально облегчает жизнь?
    — Абстрактные классы — принято. А если мне требуется, например, форсировать объявление всех свойств инстанса слотами и сделать класс абстрактным?
    — Про плагины не убедили.
    — Про метаданные тоже. dir(...) вроде ни кто не отменял? Хотя да, можно запрятать атрибуты от любителей подглядывать в потроха класса в обход его публичного интерфейса.
    — Это вообще можно использовать как вопрос на собеседовании:

    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
    Изменится поведение класса A, если в процессе работы изменится значение SOME_SETTING?
    — Singletone вполне можно построить и без metaprogramming.
    — Про джангу с ее моделями и формами — наверное да, метаклассы там работают.