open atlas
↑ К треку
Python для JS/TS-разработчиков PY · 03 · 02

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

Поиск атрибута — не «сначала экземпляр»: data-дескрипторы типа сильнее __dict__ экземпляра, который сильнее non-data. __getattribute__ работает на каждом доступе, __getattr__ — только на промахе. property — data-дескриптор; __set_name__ сообщает полям ORM их имена.

PY Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Кеширующий слой выглядел безобидно: __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'] ничего не меняет снаружи, — пройди эти пять шагов:

  1. Пройти type(obj).__mro__, ища "x" в __dict__ каждого класса.
  2. Если найден и это data-дескриптор (определяет __set__ или __delete__) — вызвать его __get__; до словаря экземпляра дело не дойдёт.
  3. Иначе проверить obj.__dict__ — здесь побеждает обычный атрибут экземпляра.
  4. Иначе, если найденный на шаге 1 атрибут класса — non-data-дескриптор (только __get__; функции — ровно это), вызвать его __get__. Обычные атрибуты класса возвращаются как есть.
  5. Нигде нет → 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. Тесты с одним экземпляром зелёные. Что сломается в продакшене?

Вспомните перед уходом
  1. 01
    Воспроизведите порядок поиска атрибута для obj.x и объясните, где property, обычные атрибуты и методы выигрывают или проигрывают.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

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

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.