Перейти к содержимому

Лекция 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) выполняет такой поиск:

  1. Найти x в type(obj).__mro__ (поиск по классам с учётом наследования). Запомнить найденное как cls_attr.
  2. Если cls_attrdata-дескриптор (есть __set__ или __delete__), вызвать cls_attr.__get__(obj, type(obj)) и вернуть результат немедленно.
  3. Иначе посмотреть в obj.__dict__. Если ключ x есть — вернуть его значение.
  4. Иначе, если cls_attrnon-data-дескриптор (есть только __get__), вызвать cls_attr.__get__(...).
  5. Иначе, если cls_attr найден (обычный атрибут класса) — вернуть его.
  6. Иначе вызвать type(obj).__getattr__(obj, 'x'), если он определён.
  7. Иначе — поднять 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 = 42
print(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__")) # True
print(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.0
try:
p.quantity = "много" # TypeError
except 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 = 10
print(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 = f
a = 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. Краткие итоги

  1. Атрибуты хранятся в __dict__ экземпляра и класса, но доступ — это алгоритм, а не словарный lookup; им управляет __getattribute__.
  2. Порядок поиска: data-дескриптор класса -> __dict__ экземпляра -> non-data-дескриптор/атрибут класса -> __getattr__ -> AttributeError.
  3. __getattribute__ вызывается всегда (риск рекурсии), __getattr__ — только при провале поиска. Внутри хуков делегируйте в object/super().
  4. __slots__ убирает __dict__, экономит память и ускоряет доступ ценой динамичности; экономия теряется при «дырке» в иерархии.
  5. Дескриптор — объект с __get__/__set__/__delete__. Data-дескриптор (__set__/__delete__) приоритетнее данных экземпляра; non-data (только __get__) — уступает им.
  6. На дескрипторах построены property, методы, classmethod, staticmethod и __slots__ — это единый базовый механизм объектной модели.
  7. Дескриптор-валидатор хранит состояние в экземпляре (имя из __set_name__), а не в себе.

9. Вопросы для самопроверки

  1. Что произойдёт при obj.x, если x есть и в __dict__ экземпляра, и как data-дескриптор в классе? А если как non-data-дескриптор?
  2. Чем отличаются __getattribute__ и __getattr__? Почему внутри __getattribute__ опасно писать self.something?
  3. Как корректно реализовать __setattr__, чтобы не получить бесконечную рекурсию?
  4. Почему обычная функция автоматически получает self, а присваивание obj.method = ... способно её «затереть»?
  5. Чем data-дескриптор отличается от non-data, и как это объясняет поведение property против обычного метода?
  6. Зачем дескриптору __set_name__ и почему нельзя хранить значение поля в self дескриптора?
  7. В каких случаях __slots__ действительно экономит память, а в каких экономия теряется?
  8. Как через дескриптор реализованы classmethod и staticmethod? В чём разница их __get__?