В каком случае для декоратора с состоянием предпочтительнее вызываемый объект, а не замыкание?
Вызываемый объект предпочтительнее, когда состояние декоратора должно быть явно представлено объектом, настраиваться через атрибуты, проверяться во время выполнения или расширяться методами и наследованием. Для небольшого локального состояния замыкание обычно короче и проще.
Класс с методом __call__ объединяет данные и поведение в одном экземпляре. Это делает жизненный цикл состояния более очевидным, но требует учитывать особенности объектов-декораторов, особенно при декорировании методов.
В Python функции являются объектами, а механизм __call__ позволяет сделать вызываемым экземпляр обычного класса. Благодаря этому поведение функции можно представить не только функцией-замыканием, но и объектом с собственным состоянием и набором методов.
Такой подход решает практическую проблему усложняющихся декораторов: когда кроме обёртки вызова появляются счётчики, настройки, сброс состояния, диагностика или несколько связанных операций, хранить всё в локальных переменных замыкания становится менее наглядно.
Предположим, декоратор должен считать вызовы функции. Замыкание решает задачу компактно, но состояние скрыто внутри созданной функции; для управления им приходится добавлять специальные функции или атрибуты.
У вызываемого объекта состояние явно находится в экземпляре. Однако такой объект не является функцией автоматически: у него может не быть ожидаемых метаданных, а при декорировании методов может нарушиться привязка self. Неверный выбор приводит либо к излишне сложной реализации, либо к ошибкам во время вызова методов.
Вызываемый объект реализует __call__, поэтому синтаксис вызова остаётся привычным. В конструкторе можно сохранить исходную функцию и создать состояние экземпляра, а в __call__ — обновлять состояние перед делегированием вызова.
После применения декоратора имя load ссылается на экземпляр CountCalls. Вызов load() запускает его __call__, а load.count напрямую показывает состояние. update_wrapper переносит основные метаданные исходной функции и устанавливает ссылку __wrapped__, что помогает инструментам интроспекции.
Главный компромисс — явность против простоты. Объект удобнее расширять методами, конфигурировать и тестировать по состоянию, но он требует написания класса и аккуратной поддержки функциональности, которую обычно ожидают от функций.
При декорировании методов экземпляр класса-декоратора должен корректно поддерживать протокол дескрипторов. Иначе объект-декоратор не получит автоматически экземпляр класса как первый аргумент. Для этого обычно реализуют __get__ или используют отдельный вызываемый объект-обёртку, корректно работающий через types.MethodType.
Состояние также нужно проектировать с учётом потоков и рекурсии. Обычный инкремент счётчика не следует считать безопасным способом синхронизации между потоками; при совместном доступе нужны подходящие средства синхронизации или иной дизайн состояния.
В сервисе требуется декоратор для измерения числа обращений к нескольким операциям. Замыкание делает каждую обёртку короткой, но сброс счётчика, экспорт статистики и добавление меток требуют дополнительных соглашений между внутренними функциями.
Можно оставить замыкание и прикрепить счётчик как атрибут. Плюс — минимальный код; минус — состояние и операции над ним не образуют явного интерфейса, а случайная замена атрибута легко ломает статистику.
Можно создать вызываемый объект для каждой операции. Его методы могут предоставлять сброс, получение снимка и форматирование статистики, а отдельный экземпляр изолирует состояние каждой функции. Это выбранное решение, поскольку требования включают управление состоянием и дальнейшее расширение.
При этом методы сервиса не следует бездумно декорировать таким объектом: сначала нужно обеспечить дескрипторное поведение. После этого статистика остаётся отдельной для каждой обёрнутой операции, операции управления становятся тестируемыми, а исходные имена и документация сохраняются благодаря update_wrapper.
update_wrapper важен для вызываемого объекта?Без него объект-декоратор обычно имеет имя и документацию класса, а не исходной функции. Это ухудшает трассировки, документацию, диагностику и работу инструментов интроспекции. update_wrapper переносит стандартные атрибуты функции и сохраняет __wrapped__, но он не делает объект функцией и не исправляет проблемы привязки методов.
__call__?При обращении к атрибуту экземпляра Python обычно использует дескрипторный протокол функции и создаёт связанный метод. Произвольный объект с одним __call__ таким дескриптором не становится автоматически, поэтому исходный экземпляр класса может не попасть в аргументы вызова.
Надёжное решение — реализовать __get__, возвращающий объект с корректно связанным вызовом, либо применять функцию-обёртку, созданную внутри __get__. Это отдельная обязанность от хранения состояния: наличие __call__ само по себе не гарантирует поведение обычного метода.
Общее состояние экземпляра доступно всем потокам, которые вызывают одну и ту же обёртку. Операция увеличения счётчика состоит из чтения и записи, поэтому при конкурирующих вызовах обновления могут конфликтовать.
Если точное значение важно, доступ к состоянию защищают подходящим примитивом синхронизации или используют другую архитектуру сбора статистики. Если счётчик диагностический и допустимы приблизительные данные, синхронизация может быть неоправданной, но это должно быть осознанным компромиссом.