Вызываемый объект хранится атрибутом класса и реализует get : почему обращение к нему через экземпляр может...

Вызываемый объект хранится атрибутом класса и реализует __get__: почему обращение к нему через экземпляр может автоматически добавить экземпляр в вызов?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Потому что такой объект становится дескриптором. При доступе через экземпляр Python вызывает его __get__, а реализация этого метода может вернуть связанный вызываемый объект, который передаст экземпляр первым аргументом.

Само наличие __call__ автоматическую передачу экземпляра не обеспечивает. Для этого нужен именно протокол дескрипторов и корректная реализация __get__.

Исторический контекст

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

Благодаря дескрипторам один и тот же атрибут класса может по-разному вести себя при доступе через класс и через экземпляр. Это позволяет отделить хранение объекта от логики его связывания с владельцем.

Постановка проблемы

Обычный вызываемый объект, помещённый в атрибут класса, не обязан получать экземпляр автоматически. Если он не реализует __get__, обращение через экземпляр обычно возвращает тот же объект, поэтому вызов не получает self как дополнительный аргумент.

Если добавить __get__, но неверно реализовать его, можно получить лишний аргумент, потерять доступ к экземпляру или неожиданно разрешить перекрытие атрибута значением из __dict__ экземпляра. Поэтому важно учитывать не только вызываемость объекта, но и правила поиска атрибутов.

Подробное решение

При обращении к атрибуту Python проверяет класс и его базовые классы. Если найденный объект имеет метод __get__, он рассматривается как дескриптор, и вызывается примерно с двумя параметрами: экземпляром и классом.

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

from types import MethodType class Handler: def __call__(self, service, value): return service.prefix + str(value) def __get__(self, instance, owner): if instance is None: return self return MethodType(self, instance) class Service: prefix = 'id:' handle = Handler() print(Service.handle(1)) # объект Handler, без связывания print(Service().handle(1)) # id:1

MethodType создаёт связанный вызываемый объект: при вызове Service().handle(1) объект Service передаётся первым аргументом в Handler.__call__.

Такой дескриптор является неуправляющим, потому что определяет только __get__. Значение с тем же именем в словаре экземпляра может его перекрыть. Если дескриптор дополнительно определяет __set__ или __delete__, он становится управляющим, и правила доступа меняются.

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

Ситуация из практики

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

Вариант с обычным вызываемым объектом прост, но экземпляр сервиса не передаётся автоматически. Замыкание может захватить экземпляр, однако тогда его нужно создавать отдельно для каждого сервиса, а жизненный цикл и отладка состояния становятся менее прозрачными. functools.partial удобен для фиксированных аргументов, но сам по себе не превращает объект в дескриптор.

Выбран дескриптор с __get__, возвращающий связанный объект. Это сохраняет декларативное объявление обработчика в классе и обеспечивает доступ к экземпляру при вызове. Компромисс — дополнительная сложность протокола и необходимость явно решить, должен ли обработчик быть общим для всех экземпляров или создавать отдельное состояние.

Что кандидаты часто упускают

1. Что вернёт дескриптор при обращении к атрибуту через сам класс?

Обычно __get__ получает instance=None. Хорошая реализация в этом случае возвращает сам дескриптор, чтобы его можно было инспектировать, переиспользовать или вызвать в классовом контексте. Возврат связанного объекта и при таком доступе может сделать API непредсказуемым.

2. Достаточно ли определить у вызываемого объекта только __call__, чтобы он получил экземпляр класса?

Нет. __call__ отвечает лишь за возможность вызвать объект. Автоматическое связывание выполняется дескрипторным протоколом через __get__; без него объект класса не получает специальной обработки только потому, что он вызываемый.

3. Может ли экземпляр перекрыть такой дескриптор собственным атрибутом?

Если дескриптор определяет только __get__, да. Такой дескриптор неуправляющий, поэтому значение в instance.__dict__ обычно имеет приоритет при доступе через экземпляр. Дескриптор с __set__ или __delete__ является управляющим и получает приоритет над обычным атрибутом экземпляра.