Поиск атрибутов и дескрипторы: почему словарь экземпляра не всегда побеждает
Поиск атрибута — не «сначала экземпляр»: data-дескрипторы типа сильнее __dict__ экземпляра, который сильнее non-data. __getattribute__ работает на каждом доступе, __getattr__ — только на промахе. property — data-дескриптор; __set_name__ сообщает полям ORM их имена.
Кеширующий слой выглядел безобидно: __getattr__ на классе клиента лениво строил дорогие саб-клиенты при первом обращении. Год всё работало. Потом кто-то поправил опечатку внутри самого __getattr__ — и метод стал трогать self._registry до того, как _registry присваивался в __init__. Симптомом была не AttributeError с указанием на опечатку. Это был RecursionError: maximum recursion depth exceeded с тысячекадровым трейсбеком, в продакшене, при создании объекта. Механика: self._registry промахнулся мимо словаря экземпляра, Python вызвал __getattr__('_registry'), тело которого снова тронуло self._registry, который снова промахнулся. Каждый инженер, дебажив это, в тот день выучил один и тот же урок из descriptor HOWTO: доступ к атрибуту в Python — это не «посмотреть в словарь». Это точный алгоритм с двумя хуками, срабатывающими в разные моменты, и протоколом дескрипторов, стоящим выше словаря экземпляра, — машинерия, на которой работают property, методы, __slots__, ORM и pydantic.
После этого урока ты будешь знать, почему RecursionError в __init__ — это цепочка промахов по атрибуту, и как та же механика питает property, поля ORM и связанные методы.
Настоящий порядок поиска: object.getattribute
Каждый obj.x вызывает type(obj).__getattribute__(obj, "x"), и дефолтная реализация выполняет алгоритм — порядок важен и является источником большинства загадок «почему мой атрибут игнорируется». Когда в следующий раз задашься вопросом, почему property нельзя затенить или почему запись в self.__dict__['x'] ничего не меняет снаружи, — пройди эти пять шагов:
- Пройти
type(obj).__mro__, ища"x"в__dict__каждого класса. - Если найден и это data-дескриптор (определяет
__set__или__delete__) — вызвать его__get__; до словаря экземпляра дело не дойдёт. - Иначе проверить
obj.__dict__— здесь побеждает обычный атрибут экземпляра. - Иначе, если найденный на шаге 1 атрибут класса — non-data-дескриптор (только
__get__; функции — ровно это), вызвать его__get__. Обычные атрибуты класса возвращаются как есть. - Нигде нет →
AttributeError, который запускает__getattr__, если тот определён.
Так что «экземпляр затеняет класс» верно лишь для случаев без дескрипторов и без data. property определяет __set__ (даже read-only — его __set__ поднимает AttributeError) и потому является data-дескриптором: именно поэтому property нельзя случайно затенить через self.x = 1 — само присваивание маршрутизируется в property.__set__. И именно поэтому функции, будучи non-data-дескрипторами, затенить можно: obj.method = lambda: ... работает. Связанные методы существуют потому, что шаг 4 вызывает function.__get__(obj, cls), возвращающий объект bound method, — методы не особый синтаксис, а протокол дескрипторов, применённый к функциям.
getattr против getattribute
__getattribute__ перехватывает каждый доступ к атрибуту — переопределите его, и вы платите его стоимость за любой self.anything, а любой self.attr внутри него рекурсирует, если не делегировать через object.__getattribute__(self, name). __getattr__ — дешёвый хук: вызывается только после того, как обычный поиск поднял AttributeError. Это делает его правильным выбором для ленивой загрузки и прокси — ноль накладных расходов на попаданиях — и это же породило рекурсию из Хука: промах атрибута внутри __getattr__ снова входит в __getattr__. Защитный паттерн:
class LazyClient:
def __init__(self):
self._cache = {} # присвоено до того, как возможен промах
def __getattr__(self, name): # вызывается ТОЛЬКО на промахе поиска
# защищаемся от промахов на внутренних именах,
# чтобы не уйти в рекурсию
if name.startswith("_"):
raise AttributeError(name)
client = expensive_build(name)
self._cache[name] = client
self.__dict__[name] = client # следующий доступ: обычное попадание, __getattr__ пропущен
return clientЗапись результата в self.__dict__ — трюк, который стоит украсть: второй доступ — попадание в словарь экземпляра на шаге 3, и __getattr__ больше не срабатывает — лениво при первом касании, нативная скорость потом.
Класс определяет `x = property(get_x)`, а неосторожный __init__ делает `self.__dict__['x'] = 5` напрямую. Что вернёт `obj.x`?
Пишем дескриптор: валидируемые поля и set_name
Дескриптор — любой объект, чей класс определяет __get__/__set__/__delete__ и который лежит в атрибуте класса (атрибуты экземпляра дескрипторами не считаются никогда). Классическое продакшен-применение — переиспользуемое валидируемое поле: валидация пишется один раз и вешается на много атрибутов:
class Positive:
def __set_name__(self, owner, name): # вызывается при создании класса
self.slot = "_" + name # узнаёт имя своего атрибута
def __get__(self, obj, objtype=None):
if obj is None:
return self # доступ через класс: Account.balance
return getattr(obj, self.slot)
def __set__(self, obj, value): # __set__ делает его DATA-дескриптором
if value <= 0:
raise ValueError(f"{self.slot[1:]} must be positive, got {value!r}")
setattr(obj, self.slot, value)
class Account:
balance = Positive() # одна строка на валидируемое поле
limit = Positive()
def __init__(self, balance, limit):
self.balance = balance # проходит через Positive.__set__
self.limit = limit__set_name__ (Python 3.6+) — тихий герой: дескриптор узнаёт, какому атрибуту его присвоили, и вы перестаёте передавать имя дважды (balance = Positive("balance")). Это ровно та машинерия, что лежит под полями Django ORM, колонками SQLAlchemy и моделями в стиле pydantic: объект-поле уровня класса перехватывает каждое чтение и запись, так что user.email = "x" может валидировать, помечать экземпляр «грязным» для UPDATE или приводить типы, — а User.email (заметьте obj is None в __get__) возвращает само поле, чтобы query-DSL строил выражения User.email == "a@b.c". Компромисс честный: каждый доступ к атрибуту становится вызовом на уровне Python, примерно в 2–5 раз медленнее чтения из словаря экземпляра, — поэтому ORM «тормозит» в плотных циклах по миллиону строк, а массовые пути (values_list, .model_dump) обходят дескрипторы.
Ваш дескриптор валидируемого поля сохраняет значение через `self.value = value` внутри __set__ (на самом дескрипторе), а не на obj. Тесты с одним экземпляром зелёные. Что сломается в продакшене?
- 01Воспроизведите порядок поиска атрибута для obj.x и объясните, где property, обычные атрибуты и методы выигрывают или проигрывают.
- 02Почему __getattr__ — правильный хук для ленивой загрузки, но источник рекурсии, и как дескрипторы с __set_name__ питают поля ORM?
Доступ к атрибуту — алгоритм, а не чтение словаря. obj.x вызывает type(obj).__getattribute__, который сначала сканирует MRO типа: найденный там data-дескриптор — всё, что определяет __set__ или __delete__: property, валидируемые поля, слоты __slots__ — отвечает раньше, чем вообще будет проверен __dict__ экземпляра; поэтому property не затеняется, а контрабандная запись в __dict__['x'] остаётся недостижимой. Лишь затем побеждает словарь экземпляра, а ниже него — non-data-дескрипторы: функции определяют только __get__, и именно на четвёртом шаге function.__get__ изготавливает связанные методы — потому и работают переопределения методов per-instance. __getattr__ — иной зверь, чем __getattribute__: второй перехватывает каждый доступ (переопределили — делегируйте через object.__getattribute__, иначе рекурсия), первый срабатывает только после промаха — идеален для ленивой загрузки, с классическим отказом, когда тело хука само промахивается по атрибуту, и трюком кеширования в __dict__, делающим последующие чтения нативно быстрыми. Свой дескриптор — это четыре метода: __get__ (вернуть self при доступе через класс), __set__ (валидация; само его наличие делает вас data-дескриптором), опциональный __delete__ и __set_name__, чтобы поле узнало своё имя при создании класса. Это весь фокус под полями Django, колонками SQLAlchemy и моделями pydantic — с честной ценой вызова уровня Python на каждый доступ, примерно 2–5x от обычного чтения, которую массовые API сознательно обходят. Теперь, когда встретишь RecursionError из __getattr__ или property, упрямо игнорирующий значение, только что записанное в self.__dict__, — ты знаешь, какой слой алгоритма отвечает и что именно нужно поправить.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.