Лекция 8. Модель атрибутов и дескрипторы
1. Введение
В предыдущих лекциях мы пользовались @property, методами и атрибутами, не задумываясь о том, как интерпретатор находит, читает и записывает их. Сегодня мы спустимся на самый «низкий» уровень объектной модели Python — к механизмам, которые управляют доступом к атрибутам.
Понимание этого уровня отличает разработчика, который использует @property, от разработчика, который понимает, почему @property работает, и может построить собственные инструменты такого класса: системы валидации, ORM, библиотеки сериализации.
План лекции:
- Где хранятся атрибуты:
__dict__и алгоритм поиска атрибута. - Хуки доступа:
__getattribute__,__getattr__,__setattr__,__delattr__. __slots__: экономия памяти и её ограничения.- Протокол дескрипторов:
__get__/__set__/__delete__; data- и non-data-дескрипторы. - Как
property, методы,classmethodиstaticmethodреализованы через дескрипторы. - Практика: поле-валидатор как дескриптор (
Positive,Typed).
2. Где живут атрибуты: __dict__
У большинства объектов в Python атрибуты хранятся в обычном словаре __dict__. Он есть и у экземпляра, и у класса.
class Point: dimensions = 2 # атрибут класса
def __init__(self, x, y): self.x = x # атрибуты экземпляра self.y = y
p = Point(1, 2)print(p.__dict__) # {'x': 1, 'y': 2}print(Point.__dict__['dimensions']) # 2Атрибуты экземпляра лежат в p.__dict__, атрибуты класса — в Point.__dict__. Когда вы пишете p.x, Python должен решить, откуда брать значение: из экземпляра, из класса, из родительских классов. Этим занимается строго определённый алгоритм.
3. Алгоритм доступа к атрибуту
3.1. Что происходит при obj.x
Когда Python выполняет obj.x, он не просто «лезет в словарь». Вызывается type(obj).__getattribute__(obj, 'x'), и реализация по умолчанию (в object) выполняет такой поиск:
- Найти
xвtype(obj).__mro__(поиск по классам с учётом наследования). Запомнить найденное какcls_attr. - Если
cls_attr— data-дескриптор (есть__set__или__delete__), вызватьcls_attr.__get__(obj, type(obj))и вернуть результат немедленно. - Иначе посмотреть в
obj.__dict__. Если ключxесть — вернуть его значение. - Иначе, если
cls_attr— non-data-дескриптор (есть только__get__), вызватьcls_attr.__get__(...). - Иначе, если
cls_attrнайден (обычный атрибут класса) — вернуть его. - Иначе вызвать
type(obj).__getattr__(obj, 'x'), если он определён. - Иначе — поднять
AttributeError.
Ключевой вывод: data-дескриптор класса имеет приоритет над данными экземпляра, а данные экземпляра — над non-data-дескриптором. Этот порядок объясняет почти всё «магическое» поведение атрибутов.
3.2. __getattribute__ и __getattr__
| Метод | Когда вызывается | Риск |
|---|---|---|
__getattribute__ | При каждом доступе к атрибуту | Легко устроить бесконечную рекурсию |
__getattr__ | Только когда обычный поиск провалился | Безопаснее; удобен для «ленивых»/прокси-атрибутов |
class Demo: def __getattribute__(self, name): print(f" __getattribute__({name!r})") return super().__getattribute__(name)
def __getattr__(self, name): print(f" __getattr__({name!r}) — последний шанс") raise AttributeError(name)
d = Demo()d.existing = 42print(d.existing) # __getattribute__ найдёт в __dict__try: d.missing # поиск провалился -> __getattr__except AttributeError: passГлавная ловушка __getattribute__ — обращение к собственному атрибуту внутри него:
class Broken: def __getattribute__(self, name): return self.__dict__[name] # self.__dict__ снова зовёт __getattribute__ -> рекурсия!
class Fixed: def __getattribute__(self, name): return super().__getattribute__(name) # делегируем object — рекурсии нет3.3. __setattr__ и __delattr__
__setattr__ перехватывает любое присваивание obj.x = value, в том числе внутри __init__. Поэтому писать в нём self.x = ... напрямую нельзя — снова рекурсия; нужно делегировать в object:
class Frozen: """Объект, неизменяемый после создания."""
def __init__(self, **kwargs): for k, v in kwargs.items(): object.__setattr__(self, k, v) # обходим собственный __setattr__ object.__setattr__(self, "_frozen", True)
def __setattr__(self, name, value): if getattr(self, "_frozen", False): raise AttributeError(f"Объект заморожен, нельзя менять {name!r}") object.__setattr__(self, name, value)__delattr__ работает аналогично для del obj.x.
4. __slots__: контроль над __dict__
4.1. Зачем
По умолчанию у каждого экземпляра есть __dict__ — это гибко, но дорого по памяти, особенно когда объектов миллионы. __slots__ заменяет словарь набором заранее объявленных слотов, которые реализуются как дескрипторы на уровне класса.
class WithDict: def __init__(self, x, y): self.x = x self.y = y
class WithSlots: __slots__ = ("x", "y") def __init__(self, x, y): self.x = x self.y = y
a = WithDict(1, 2)b = WithSlots(1, 2)print(hasattr(a, "__dict__")) # Trueprint(hasattr(b, "__dict__")) # False — словаря нет, экземпляр компактнее4.2. Эффекты и ограничения
- Нельзя добавлять атрибуты, не объявленные в
__slots__(b.z = 1->AttributeError). Это и плюс (защита от опечаток), и минус (потеря динамичности). - При наследовании слоты складываются по всей иерархии. Если хотя бы один класс в цепочке не объявляет
__slots__, у экземпляров снова появится__dict__, и экономия памяти теряется. - Имя слота — это уже дескриптор, поэтому слот конфликтует с переменной класса того же имени и с
@propertyтого же имени. - Чтобы при
__slots__всё же разрешить произвольные атрибуты, добавляют"__dict__"прямо в кортеж слотов.
__slots__ уместен для «массовых» объектов-данных (точки, записи, узлы графа), а не для каждого класса подряд.
5. Дескрипторы
5.1. Определение
Дескриптор — это объект, класс которого реализует хотя бы один из методов протокола:
__get__(self, instance, owner)— чтение атрибута;__set__(self, instance, value)— запись атрибута;__delete__(self, instance)— удаление атрибута;__set_name__(self, owner, name)— вызывается автоматически при создании класса-владельца и сообщает дескриптору имя, под которым он объявлен.
Дескриптор «оживает», только когда он размещён как атрибут класса, а не экземпляра.
5.2. Data- и non-data-дескрипторы
- Data-дескриптор определяет
__set__и/или__delete__. Имеет приоритет над__dict__экземпляра. - Non-data-дескриптор определяет только
__get__. Уступает__dict__экземпляра.
Это различие — основа поведения из раздела 3. Именно поэтому функция (non-data-дескриптор) может быть «затёрта» атрибутом экземпляра с тем же именем, а property (data-дескриптор) — нет.
5.3. Дескриптор с валидацией и __set_name__
Классический сценарий — типизированное поле с проверками, переиспользуемое во многих классах.
class Typed: """Дескриптор: хранит значение заданного типа в __dict__ экземпляра."""
def __init__(self, expected_type, *, default=None): self.expected_type = expected_type self.default = default
def __set_name__(self, owner, name): # Python сам зовёт этот метод при создании класса-владельца. self.public_name = name self.private_name = f"_{name}"
def __get__(self, instance, owner): if instance is None: return self # доступ через класс: Product.price -> сам дескриптор return getattr(instance, self.private_name, self.default)
def __set__(self, instance, value): if not isinstance(value, self.expected_type): raise TypeError( f"{self.public_name} должен быть {self.expected_type.__name__}, " f"получено {type(value).__name__}" ) setattr(instance, self.private_name, value)
class Product: name = Typed(str) price = Typed(float, default=0.0) quantity = Typed(int, default=0)
def __init__(self, name, price, quantity): self.name = name # пройдёт через Typed.__set__ -> валидация self.price = price self.quantity = quantity
p = Product("Книга", 500.0, 3)print(p.price) # 500.0try: p.quantity = "много" # TypeErrorexcept TypeError as e: print(e)Частая ошибка — хранить значение в самом дескрипторе (
self.value = value). Дескриптор один на класс (а не на экземпляр), поэтому все объекты разделят одно значение. Состояние хранят в экземпляре (instance.__dict__), используя имя из__set_name__.
6. Что под капотом построено на дескрипторах
6.1. property — это дескриптор
property — встроенный data-дескриптор. Его несложно реализовать самому, и это лучший способ понять механику:
class my_property: def __init__(self, fget=None, fset=None): self.fget = fget self.fset = fset
def __get__(self, instance, owner): if instance is None: return self if self.fget is None: raise AttributeError("свойство недоступно для чтения") return self.fget(instance)
def __set__(self, instance, value): if self.fset is None: raise AttributeError("свойство недоступно для записи") self.fset(instance, value)
def setter(self, fset): return type(self)(self.fget, fset)
class Circle: def __init__(self, radius): self._radius = radius
@my_property def radius(self): return self._radius
@radius.setter def radius(self, value): if value <= 0: raise ValueError("радиус должен быть положительным") self._radius = value
c = Circle(5)c.radius = 10print(c.radius) # 10Так как property определяет __set__, он data-дескриптор — и его нельзя «обойти» значением в __dict__ экземпляра.
6.2. Методы и функции
То, что обычная функция автоматически получает self, — следствие того, что функция является non-data-дескриптором. При обращении obj.method вызывается function.__get__(obj, type(obj)), который возвращает связанный метод (bound method) с уже «вшитым» obj.
def f(self): return self
class A: pass
A.f = fa = A()print(a.f) # <bound method ...> — связанный методprint(A.f) # <function ...> — несвязанная функцияprint(a.f.__self__ is a) # TrueПоскольку функция — non-data-дескриптор, её можно «затереть» атрибутом экземпляра (a.f = 123), и тогда a.f вернёт 123. Это прямое следствие алгоритма из раздела 3.
6.3. classmethod и staticmethod
Оба — тоже дескрипторы, отличаются тем, что возвращает их __get__:
staticmethod.__get__возвращает функцию как есть, без привязки — поэтомуself/clsне подставляются.classmethod.__get__возвращает метод, связанный с классом, а не с экземпляром — поэтому первым аргументом приходитcls.
Упрощённые реализации, демонстрирующие суть:
class my_staticmethod: def __init__(self, func): self.func = func
def __get__(self, instance, owner): return self.func # никакой привязки
class my_classmethod: def __init__(self, func): self.func = func
def __get__(self, instance, owner): from functools import partial return partial(self.func, owner) # первым аргументом — классВывод: единый протокол дескрипторов лежит в основе property, обычных методов, classmethod, staticmethod и даже слотов __slots__.
7. Практика: поле-валидатор Positive
Соберём небольшую иерархию переиспользуемых дескрипторов-валидаторов. Базовый класс задаёт хранение и шаблонный метод validate, а потомки уточняют проверку.
class Field: """Базовый data-дескриптор: хранит значение в __dict__ экземпляра."""
def __set_name__(self, owner, name): self.name = name self.storage = f"_{name}"
def __get__(self, instance, owner): if instance is None: return self return getattr(instance, self.storage, None)
def __set__(self, instance, value): self.validate(value) setattr(instance, self.storage, value)
def validate(self, value): """Переопределяется в потомках; по умолчанию проверок нет."""
class Number(Field): def validate(self, value): if not isinstance(value, (int, float)): raise TypeError(f"{self.name!r} должно быть числом, получено {type(value).__name__}")
class Positive(Number): def validate(self, value): super().validate(value) if value <= 0: raise ValueError(f"{self.name!r} должно быть положительным, получено {value!r}")
class Account: balance = Positive() rate = Positive()
def __init__(self, balance, rate): self.balance = balance # проходит через Positive.__set__ self.rate = rate
acc = Account(100, 0.05)print(acc.balance, acc.rate) # 100 0.05
for bad in (-10, 0, "много"): try: acc.balance = bad except (TypeError, ValueError) as e: print(type(e).__name__, "->", e)Обратите внимание на разделение ответственности: Field отвечает за хранение, Number и Positive — за проверки. Один и тот же Positive переиспользуется в любом числе классов, и валидация срабатывает на каждое присваивание, потому что это data-дескриптор.
8. Краткие итоги
- Атрибуты хранятся в
__dict__экземпляра и класса, но доступ — это алгоритм, а не словарный lookup; им управляет__getattribute__. - Порядок поиска: data-дескриптор класса ->
__dict__экземпляра -> non-data-дескриптор/атрибут класса ->__getattr__->AttributeError. __getattribute__вызывается всегда (риск рекурсии),__getattr__— только при провале поиска. Внутри хуков делегируйте вobject/super().__slots__убирает__dict__, экономит память и ускоряет доступ ценой динамичности; экономия теряется при «дырке» в иерархии.- Дескриптор — объект с
__get__/__set__/__delete__. Data-дескриптор (__set__/__delete__) приоритетнее данных экземпляра; non-data (только __get__) — уступает им. - На дескрипторах построены
property, методы,classmethod,staticmethodи__slots__— это единый базовый механизм объектной модели. - Дескриптор-валидатор хранит состояние в экземпляре (имя из
__set_name__), а не в себе.
9. Вопросы для самопроверки
- Что произойдёт при
obj.x, еслиxесть и в__dict__экземпляра, и как data-дескриптор в классе? А если как non-data-дескриптор? - Чем отличаются
__getattribute__и__getattr__? Почему внутри__getattribute__опасно писатьself.something? - Как корректно реализовать
__setattr__, чтобы не получить бесконечную рекурсию? - Почему обычная функция автоматически получает
self, а присваиваниеobj.method = ...способно её «затереть»? - Чем data-дескриптор отличается от non-data, и как это объясняет поведение
propertyпротив обычного метода? - Зачем дескриптору
__set_name__и почему нельзя хранить значение поля вselfдескриптора? - В каких случаях
__slots__действительно экономит память, а в каких экономия теряется? - Как через дескриптор реализованы
classmethodиstaticmethod? В чём разница их__get__?