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'<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!

    Binary District

    180,00

    Курсы, хакатоны и конференции по новым технологиям

    Поделиться публикацией
    Комментарии 14
      0
      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.

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


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

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


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

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


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

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


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

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

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

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

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

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

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


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

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

                +1

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


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

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

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

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

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

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

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

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


                            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"
                      0
                      Только в том случае, если вы вызываете этот метод у класса «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 вообще никак не влияют на поведение функции — это же неправильно.

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

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

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

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

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

                        Выглядит как очень вырожденный случай, которым никто не воспользуется. Будем считать что в моем коде есть минорный баг, куда же без них.
                          0
                          Нет, это выглядит как уничтожение концепции bound method.
            0
            --
              0
              --
              0
              Мета программирование позволяет вмешаться в процесс создания типа данных. Круто. Как быть с кейсами, когда это реально облегчает жизнь?
              — Абстрактные классы — принято. А если мне требуется, например, форсировать объявление всех свойств инстанса слотами и сделать класс абстрактным?
              — Про плагины не убедили.
              — Про метаданные тоже. 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.
              — Про джангу с ее моделями и формами — наверное да, метаклассы там работают.

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

              Самое читаемое