Вначале была эта статья. Потом к ней появился комментарий. А в результате я углубился в чтение матчасти, закопался в дебаг и смог оптимизировать код из первой части этой истории. Предлагаю вместе со мной пройтись по основным моментам.

Для начала хочу поблагодарить Mogost. Благодаря его комментарию я пересмотрел подход к Пайтону. Я и ранее слыхал о том, что среди пайтонистов достаточно много неэкономных ребят (при обращении с памятью), а теперь выяснилось, что я как-то незаметно для себя присоединился к этой тусовке.

Итак, начнем. Давайте порассуждаем, а какие вообще были узкие места.

Постоянные if:
if isinstance(self.custom_handlers, property):
if self.custom_handlers and e.__class__ in self.custom_handlers:
if e.__class__ not in self.exclude:


и это не предел. Поэтому часть if-ов я убрал, кое-что перенес в __init__, т.е. туда, где это будет вызвано один раз. Конкретно проверка на property в коде должна быть вызвана единоразово, т.к. декоратор применяется к методу и закрепляется за ним. И property класса, соответственно, останется неизменным. Поэтому и незачем проверять property постоянно.

Отдельный момент это if in. Профайлер показал, что на каждый такой in отдельный вызов, поэтому я решил все хэндлеры объединить в один dict. Это позволило избежать if-ов вообще, взамен используя просто:
self.handlers.get(e.__class__, Exception)(e)


таким образом в self.handlers у нас находится dict, который в качестве значения по умолчанию содержит функцию, рейзящую остальные исключения.

Отдельного внимания, конечно же, заслуживает wrapper. Это та самая функция, которая вызывается каждый раз, когда вызывается декоратор. Т.е. здесь лучше по максимуму избежать лишних проверок и всяких нагрузок, по возможности вынеся их в __init__ или в __call__. Вот какой wrapper был ранее:
def wrapper(self, *args, **kwargs):
        if self.custom_handlers:
            if isinstance(self.custom_handlers, property):
                self.custom_handlers = self.custom_handlers.__get__(self, self.__class__)

        if asyncio.iscoroutinefunction(self.func):
            return self._coroutine_exception_handler(*args, **kwargs)
        else:
            return self._sync_exception_handler(*args, **kwargs)


количество проверок зашкаливает. Это все будет вызываться на каждом вызове декоратора. Поэтому wrapper стал таким:
    def __call__(self, func):
        self.func = func

        if iscoroutinefunction(self.func):
            def wrapper(*args, **kwargs):
                return self._coroutine_exception_handler(*args, **kwargs)
        else:
            def wrapper(*args, **kwargs):
                return self._sync_exception_handler(*args, **kwargs)

        return wrapper


напомню, __call__ будет вызван один раз. Внутри __call__ мы в зависимости от степени асинхронности функции возвращаем саму функцию или корутин. И дополнительно хочу заметить, что asyncio.iscoroutinefunction делает дополнительный вызов, поэтому я перешел на inspect.iscoroutinefunction. Собственно, бенчи (cProfile) для asyncio и inspect:

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.000    0.000 <string>:1(<module>)
        1    0.000    0.000    0.000    0.000 coroutines.py:160(iscoroutinefunction)
        1    0.000    0.000    0.000    0.000 inspect.py:158(isfunction)
        1    0.000    0.000    0.000    0.000 inspect.py:179(iscoroutinefunction)
        1    0.000    0.000    0.000    0.000 {built-in method builtins.exec}
        1    0.000    0.000    0.000    0.000 {built-in method builtins.isinstance}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}


   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.000    0.000 <string>:1(<module>)
        1    0.000    0.000    0.000    0.000 inspect.py:158(isfunction)
        1    0.000    0.000    0.000    0.000 inspect.py:179(iscoroutinefunction)
        1    0.000    0.000    0.000    0.000 {built-in method builtins.exec}
        1    0.000    0.000    0.000    0.000 {built-in method builtins.isinstance}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}


Полный код:
from inspect import iscoroutinefunction

from asyncio import QueueEmpty, QueueFull
from concurrent.futures import TimeoutError


class ProcessException(object):

    __slots__ = ('func', 'handlers')

    def __init__(self, custom_handlers=None):
        self.func = None

        if isinstance(custom_handlers, property):
            custom_handlers = custom_handlers.__get__(self, self.__class__)

        def raise_exception(e: Exception):
            raise e

        exclude = {
            QueueEmpty: lambda e: None,
            QueueFull: lambda e: None,
            TimeoutError: lambda e: None
        }

        self.handlers = {
            **exclude,
            **(custom_handlers or {}),
            Exception: raise_exception
        }

    def __call__(self, func):
        self.func = func

        if iscoroutinefunction(self.func):
            def wrapper(*args, **kwargs):
                return self._coroutine_exception_handler(*args, **kwargs)
        else:
            def wrapper(*args, **kwargs):
                return self._sync_exception_handler(*args, **kwargs)

        return wrapper

    async def _coroutine_exception_handler(self, *args, **kwargs):
        try:
            return await self.func(*args, **kwargs)
        except Exception as e:
            return self.handlers.get(e.__class__, Exception)(e)

    def _sync_exception_handler(self, *args, **kwargs):
        try:
            return self.func(*args, **kwargs)
        except Exception as e:
            return self.handlers.get(e.__class__, Exception)(e)



И наверное, пример был бы неполным без timeit. Поэтому используя пример из вышеупомянутого комментария:
class MathWithTry(object):
    def divide(self, a, b):
        try:
            return a // b
        except ZeroDivisionError:
            return 'Делить на ноль нельзя, но можно умножить'


и пример из текста предыдущей статьи (ВНИМАНИЕ! в пример из текста в лямбду мы передаем e. В предыдущей статье этого не было и добавилось только в нововведениях):
class Math(object):
    @property
    def exception_handlers(self):
        return {
            ZeroDivisionError: lambda e: 'Делить на ноль нельзя, но можно умножить'
        }
    
    @ProcessException(exception_handlers)
    def divide(self, a, b):
        return a // b


вот вам результаты:
timeit.timeit('math_with_try.divide(1, 0)', number=100000, setup='from __main__ import math_with_try')
0.05079065300014918

timeit.timeit('math_with_decorator.divide(1, 0)', number=100000, setup='from __main__ import math_with_decorator')
0.16211646200099494


В качестве заключения хочу сказать, что оптимизация, на мой взгляд, процесс достаточно непростой и тут важно не увлечься и не оптимизировать что-то в ущерб читаемости. Иначе дебажить по оптимизированному будет крайне сложно.

Благодарю за ваши комментарии. Жду комментариев и к этой статье тоже :)

P.S. благодаря замечаниям пользователей хабра удалось еще больше ускорить, вот, что получилось:
from inspect import iscoroutinefunction

from asyncio import QueueEmpty, QueueFull
from concurrent.futures import TimeoutError


class ProcessException(object):

    __slots__ = ('handlers',)

    def __init__(self, custom_handlers=None):
        if isinstance(custom_handlers, property):
            custom_handlers = custom_handlers.__get__(self, self.__class__)

        raise_exception = ProcessException.raise_exception

        exclude = {
            QueueEmpty: lambda e: None,
            QueueFull: lambda e: None,
            TimeoutError: lambda e: None
        }

        self.handlers = {
            **exclude,
            **(custom_handlers or {}),
            Exception: raise_exception
        }

    def __call__(self, func):
        handlers = self.handlers

        if iscoroutinefunction(func):
            async def wrapper(*args, **kwargs):
                try:
                    return await func(*args, **kwargs)
                except Exception as e:
                    return handlers.get(e.__class__, handlers[Exception])(e)
        else:
            def wrapper(*args, **kwargs):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    return handlers.get(e.__class__, handlers[Exception])(e)

        return wrapper

    @staticmethod
    def raise_exception(e: Exception):
        raise e



timeit.timeit('divide(1, 0)', number=100000, setup='from __main__ import divide')
0.13714907199755544


Ускорилось на 0.03 в среднем. Спасибо Kostiantyn и Yngvie.

P.S. Обновлено! Я еще больше оптимизировал код, на основе замечаний из комментариев onegreyonewhite и resetme. Заменил self.func на просто func и self.handlers вынес в переменную. Выполнение дополнительно ускорилось, особенно заметно, если повторов на нолик больше. Привожу timeit:
timeit.timeit('t.divide_with_decorator(1, 0)', number=1000000, setup='from __main__ import t')
1.1116105649998644


До этой оптимизации выполнение с таким же значением number занимало 1.24 в среднем.

P.S. я еще больше оптимизировал, вынеся в @staticmethod функцию raise_exception из __init__ и к ней обращаюсь через переменную, чтобы убрать обращение через точку. Собственно, среднее время выполнения стало:
timeit.timeit('t.divide_with_decorator(1, 0)', number=1000000, setup='from __main__ import t')
1.0691639049982768


это для метода. А функции вызываются еще быстрее (в среднем):
timeit.timeit('div(1, 0)', number=1000000, setup='from __main__ import div')
1.0463485610016505

Комментарии (27)


  1. Yngvie
    02.11.2019 23:02

    Есть подозрение, что эта строчка делает не то, что от нее ожидают.


    self.handlers.get(e.__class__, Exception)(e)

    Второй параметр — это значение по умолчанию, а не ключ по умолчанию. Вместо вызова raise_exception просто вернётся Exception


    1. ivanuzzo Автор
      03.11.2019 00:14

      благодарю за поправку! внесу в статью (и в свой код)


  1. resetme
    02.11.2019 23:04

    Еще можно слегка ускорить используя partial:

    from functools import partial
    ...
        def __call__(self, func):
            self.func = func
    
            if iscoroutinefunction(self.func):
                return partial(self._coroutine_exception_handler, self)
            return partial(self._sync_exception_handler, self)
    ...
    


    1. ivanuzzo Автор
      03.11.2019 00:13

      возможно, я что-то делаю не так, но у меня timeit показывает одинаковое время для моего и вашего варианта. А еще ваше решение справедливо только для методов, но не для функций.


      1. resetme
        03.11.2019 00:27

        Еще нолик добавьте к количеству лупов. В примере маловато для быстрых машин. Еще может быть Python не собран с С модулем functools, а в коде есть файлбэк на питоновскую версию. Пропробуйте симортировать модуль _functools. Если импортируется, то все ОК.


        1. ivanuzzo Автор
          03.11.2019 00:29

          _functools импортируется. Но ваш пример не работает для функций, потому что там self нету


          1. resetme
            03.11.2019 00:58

            А метод разве не функция которой передается self первым параметром?


            1. ivanuzzo Автор
              03.11.2019 01:08

              да, метод — это функция, принадлежащая классу. Поэтому и разделяют понятия функция и метод. Для функций ваш код не работает, потому что вы пытаетесь в них тоже передавать self. Таким образом, передается на аргумент больше и рейзится TypeError.


        1. ivanuzzo Автор
          03.11.2019 00:43

          а, кстати, почему partial ускоряет выполнение? я попытался разобраться, только ни к чему не пришел пока что.


          1. resetme
            03.11.2019 01:21

            self как бы вмораживается в вызов и на него не таратится время на его передачу в стек, а вся машинерия на С, от суда есть прирост но незначительный. А лучше вообще не вызывать, как в соседней ветку.

            Странно вы все же измеряете. Вот так у меня:

            $ python3 -m timeit -s 'from test import math_with_try' 'math_with_try.divide(1,0)'
            1000000 loops, best of 3: 0.415 usec per loop
            $ python3 -m timeit -s 'from test import math_with_decorator' 'math_with_decorator.divide(1,0)'
            1000000 loops, best of 3: 1.08 usec per loop
            $ python3 -m timeit -s 'from test import math_with_partial' 'math_with_partial.divide(1,0)'
            1000000 loops, best of 3: 0.998 usec per loop
            


            1. ivanuzzo Автор
              03.11.2019 01:33

              благодарю за объяснение, partial — полезно учесть для работы с методами. Но если я правильно понял, то для функций эта оптимизация не имеет значения?


              1. resetme
                03.11.2019 02:36

                Небольшой прирост будет:

                $ python3 -m timeit -s 'from test1 import divide_decorator' 'divide_decorator(1,0)'                       
                1000000 loops, best of 3: 1.07 usec per loop
                $ python3 -m timeit -s 'from test1 import divide_partial' 'divide_partial(1,0)'                           
                1000000 loops, best of 3: 0.993 usec per loop

                Только вызов как-то вот так нужно сделать:
                    def __call__(self, func):
                        if iscoroutinefunction(func):
                            return partial(self._coroutine_exception_handler, func)
                        return partial(self._sync_exception_handler, func)
                
                    def _sync_exception_handler(self, func, *args, **kwargs):
                        try:
                            return func(*args, **kwargs)
                        except Exception as e:
                            return self.handlers.get(e.__class__, Exception)(e)
                

                Получается так чем больше аргументов в partial передаешь, там больше выигрыш.


                1. ivanuzzo Автор
                  03.11.2019 19:39

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


                  1. resetme
                    04.11.2019 10:38

                    Вы все же некорректно измеряете. Вам функция возвращает время выполнения цикла 100000 итераций. Во время выполнения на машине могут произойти прерывания и интрепретатор Python может получить меньший квант времени, а это скажется на времени исполнения этого цикла.

                    Если запускать timeit из командной строки, то выводится лучшее время из 3 запусков цикла. Это позволяет нивелировать прерывания во время исполнения, так как лучшее время исполнения даст та итерация во время которой произойдет наименьшее количество прерываний.

                    Обпатите внимание на это предложение в документации docs.python.org/3/library/timeit.html

                    Note however that timeit() will automatically determine the number of repetitions only when the command-line interface is used.

                    Другая проблема в самом декораторе. По сути он замалчивает исключения по таймауту и исключения от асинхронной очереди. Что в корне неправильно. Они должны быть обработаны и желательно не в декораторе, а в коде метода или функции. Иначе ваш декоратор может привести к ситуации когда замолченное исключение будет годами прятать баг. Лучше не замалчивать, а дать упасть приложению, а по трейсбеку найти проблему и пофиксить.

                    Сейчас в мире Python получилось так что мы имеем два Python: один асинхронный, а другой синхронный. Они в принципе разные и лучше код для разных питонов держать отдельно. Иначе выходят вот такие библиотеки, которые позволяют любым путем, но выполнить функцию будь она асинхронная или синхронная: github.com/miyakogi/syncer/blob/master/syncer.py

                    Да и просто расставив sync/await не получить правильно работающий асинхронный код. Если убрать асинхронность из декоратора, то вообщем ничего не изменится для вызываемого под ним метода или функции. По существу обработка исключений — это не IO bound задача для которой придумали асинхронность, поэтому нет смысла обрабатывать исключения асинхронно, так как в момент обработки может прилететь какое-то совсем другое исключение и совсем из другого места кода.

                    Для обучения это декоратор хороший пример и мне понравился Ваш подход к постоянному улучшению. Да и обе статьи получились интересные.


                    1. ivanuzzo Автор
                      04.11.2019 23:17

                      Другая проблема в самом декораторе. По сути он замалчивает исключения по таймауту и исключения от асинхронной очереди.


                      не, тут все правильно. Это специфика моего проекта. Там сервер в инфинити лупе ожидает запросов с клиента и отвечает. А если ничего не пришло, возникает TimeoutError. Есть ряд исключений, которые мне приходится игнорировать, потому что они происходят в цикле. Постоянно. Но это конкретно мой проект. А в общем, конечно, лучше убрать.

                      P.S. благодарю за положительную оценку статей


  1. Kostiantyn
    02.11.2019 23:04

    У Вас ещё wrapper дополнительно вызывает другой метод — что в Python весьма не бесплатно.
    Т. е. содержимое методов _coroutine_exception_handler и _sync_exception_handler по хорошему бы переместить внутрь объявления самих wrapper функций.


    1. ivanuzzo Автор
      03.11.2019 00:24

      обожаю хабр :) да, это очень важный момент, выполняться стало аж на 0.03 быстрее, добавлю в статью (и в свой код). Благодарю за замечание


  1. Kizzeon
    02.11.2019 23:05
    -1

    ИМХО Высокопроизводительные задачи лучше перекладывать на C(++) или другие производительные языки(Rust, Go(в некоторых задачах)).
    В python что что а оптимизации кот наплакал и результат чаще всего не превышает пары десятков процентов, та и в целом язык отстаёт по скорости от С-шных собратьев.
    Для задач автоматизации по типу парсинга и обработки данных он отлично подходит(scrapy, bs, lxml). Читаемость кода, скорость и асинхронность здесь на хорошем уровне. В случае если нужна производительность лично предпочёл бы C.


  1. ADR
    03.11.2019 02:45

    Здесь явно не то что вы хотите:


    custom_handlers = custom_handlers.__get__(self, self.__class__)

    Нужно использовать дескрипторы чтобы получить правильный self


    1. ADR
      03.11.2019 04:10

      Пример (парсер ест двойные пустые строки):


      import types
      from asyncio import QueueEmpty, QueueFull
      from concurrent.futures import TimeoutError
      from functools import wraps
      from inspect import iscoroutinefunction
      
      class ProcessException:
          __slots__ = ('func', 'custom_handlers')
      
          exclude = {
              QueueEmpty: lambda e: None,
              QueueFull: lambda e: None,
              TimeoutError: lambda e: None
          }
      
          def __init__(self, custom_handlers=None):
              self.func = None
              self.custom_handlers = custom_handlers
      
          def __call__(self, func):
              self.func = func
      
              if isinstance(self.custom_handlers, property):
                  return self
      
              return self._get_wrapper()
      
          def __get__(self, instance, owner):
              setattr(owner, self.func.__name__, self._get_wrapper(instance, owner))
      
              return getattr(instance, self.func.__name__)  # return bounded method
      
          def _get_wrapper(self, instance=None, owner=None):
              if isinstance(self.custom_handlers, property):
                  self.custom_handlers = self.custom_handlers.__get__(instance, owner)
      
              handlers = {
                  **self.exclude,
                  **(self.custom_handlers or {}),
                  Exception: self._raise_exception
              }
              del self.custom_handlers
      
              if iscoroutinefunction(self.func):
                  async def wrapper(*args, **kwargs):
                      try:
                          return await self.func(*args, **kwargs)
                      except Exception as e:
                          return handlers.get(type(e), handlers[Exception])(e)
      
              else:
                  def wrapper(*args, **kwargs):
                      try:
                          return self.func(*args, **kwargs)
                      except Exception as e:
                          return handlers.get(type(e), handlers[Exception])(e)
      
              return wraps(self.func)(wrapper)
      
          @staticmethod
          def _raise_exception(e: Exception):
              raise e
      
      class Math(object):
          def __init__(self, error_message: str = 'Cannot divide by zero!'):
              self.error_message = error_message
      
          @property
          def exception_handlers(self):
              return {
                  ZeroDivisionError: lambda e: self.error_message
              }
      
          @ProcessException(exception_handlers)
          def divide(self, a, b):
              return a // b
      
      @ProcessException({ZeroDivisionError: lambda e: 'Cannot divide by zero!'})
      def divide(a, b):
          return a // b
      
      assert Math().divide(4, 2) == 2
      assert divide(4, 2) == 2
      assert Math().divide(4, 0) == 'Cannot divide by zero!'
      assert divide(4, 0) == 'Cannot divide by zero!'
      assert Math().divide.__name__ == 'divide'
      

      В принципе всю эту магию можно было и в во врапер запихнуть.


      1. ivanuzzo Автор
        04.11.2019 00:12

        я пытаюсь получить значение property, оно вернется даже если я передам object.__class__:

        custom_handlers = custom_handlers.__get__(self, object.__class__)
        


        если вы считаете, что такое поведение неправильное и может привести к багу — можете ли вы привести шаги воспроизведения этого бага?


        1. ADR
          04.11.2019 02:56

          Проблема будет при обращении к self внутри проперти:


          from inspect import iscoroutinefunction
          
          from asyncio import QueueEmpty, QueueFull
          from concurrent.futures import TimeoutError
          
          class ProcessException(object):
              __slots__ = ('handlers',)
          
              def __init__(self, custom_handlers=None):
                  if isinstance(custom_handlers, property):
                      custom_handlers = custom_handlers.__get__(self, self.__class__)
          
                  raise_exception = ProcessException.raise_exception
          
                  exclude = {
                      QueueEmpty: lambda e: None,
                      QueueFull: lambda e: None,
                      TimeoutError: lambda e: None
                  }
          
                  self.handlers = {
                      **exclude,
                      **(custom_handlers or {}),
                      Exception: raise_exception
                  }
          
              def __call__(self, func):
                  handlers = self.handlers
          
                  if iscoroutinefunction(func):
                      async def wrapper(*args, **kwargs):
                          try:
                              return await func(*args, **kwargs)
                          except Exception as e:
                              return handlers.get(e.__class__, handlers[Exception])(e)
                  else:
                      def wrapper(*args, **kwargs):
                          try:
                              return func(*args, **kwargs)
                          except Exception as e:
                              return handlers.get(e.__class__, handlers[Exception])(e)
          
                  return wrapper
          
              @staticmethod
              def raise_exception(e: Exception):
                  raise e
          
          class Math(object):
              def __init__(self, error_message: str = 'Cannot divide by zero!'):
                  self.error_message = error_message
          
              @property
              def exception_handlers(self):
                  return {
                      ZeroDivisionError: lambda e: self.error_message
                  }
          
              @ProcessException(exception_handlers)
              def divide(self, a, b):
                  return a // b
          
          assert Math().divide(4, 2) == 2
          assert Math().divide(4, 0) == 'Cannot divide by zero!'
          assert Math().divide.__name__ == 'divide'
          

          AttributeError: 'ProcessException' object has no attribute 'error_message'


  1. onegreyonewhite
    03.11.2019 13:30

    class ProcessException:
         ...
         def __call__(self, func):
            self.func = func
    
            if iscoroutinefunction(self.func):
                def wrapper(*args, **kwargs):
                    return self._coroutine_exception_handler(*args, **kwargs)
            else:
                def wrapper(*args, **kwargs):
                    return self._sync_exception_handler(*args, **kwargs)
    
            return wrapper
    

    В этом месте вообще не вижу смысла в том, чтобы каждый раз возвращать wrapper и ещё и создавать его. Почему бы сразу не возвращать метод объекта?
    Более того, каждый раз когда вызывается self., то Python ищет этот атрибут в словаре атрибутов. Если возвращать сразу метод, то это минус один сложный вызов (внутри wrapper'а).
    Либо, как предложил ADR, логику запихнуть во wrapper, но создавать по методу на каждую декорацию мне кажется расточительным по памяти и немного нагромождённым для одного метода.


    1. ivanuzzo Автор
      03.11.2019 14:38

      мы не можем сразу возвращать метод объекта, потому что метод может быть с async, а значит, придется его дождаться (await), а уже потом обернуть в try except. Но чтобы дождаться, функция-контейнер (в нашем случае, __call__) тоже должна быть async. Со всеми отсюда вытекающими. Мне не нужен async __call__.


      1. onegreyonewhite
        03.11.2019 15:51

        Во-первых, wrapper тоже не async, а значит "мне не нужен async call" — решаемая проблема.
        Во-вторых, оберните только async: для чего оборачивать обычный метод?


        1. ivanuzzo Автор
          03.11.2019 19:30
          +1

          для того, чтобы вернуть обработчик исключения (если таковой есть). В секции except тоже происходит вызов функции и в сценарии с wrapper-ом она вполне вписывается. Не очень представляю, как можно выполнить обработчик, если мы не будем оборачивать во wrapper. Я, кстати, на основе комментариев выше кое-что еще оптимизировал. Поубирал self и получилось еще быстрее — особенно заметно, когда лупов на нолик больше, чем в примере выше. Т.е. ранее было 1.24, стало — 1.08 секунды для 1000000 повторов. Сейчас добавлю в статью обновленный код.


  1. ivanuzzo Автор
    03.11.2019 20:26
    +1

    Я еще больше оптимизировал. Вынес в @staticmethod из __init__ функцию raise_exception и через переменную уже к ней обращаюсь (чтобы убрать вызов через точку). В итоге среднее выполнение для 1000000 повторов стало 1.06. Сейчас отредактирую пример кода выше.