Лекция 8. Дескрипторы#

1. Вспоминаем, как работают атрибуты#

У обычного экземпляра пользовательского класса есть __dict__ — словарь его атрибутов. vars(obj) возвращает этот же словарь.

from typing import ClassVar

class Product:
    kind: ClassVar[str] = "product"
    quantity: int

    def __init__(self, price: int) -> None:
        self.price = price

product = Product(100)
product.quantity = 2
print(product.__dict__)
print(vars(product) is product.__dict__)
{'price': 100, 'quantity': 2}
True

__mro__ (Method Resolution Order): порядок поиска в классах#

В __mro__ хранится порядок поиска атрибутов в классе и его базовых классах. Здесь kind найдётся в Product, до проверки Featured:

class Featured:
    kind: ClassVar[str] = "featured"

class SpecialProduct(Product, Featured):
    pass

print([base.__name__ for base in SpecialProduct.__mro__])
print(SpecialProduct(100).kind)
['SpecialProduct', 'Product', 'Featured', 'object']
product

__slots__: фиксированные поля#

__slots__ обрабатывается при создании класса, после выполнения его тела. В CPython это задаёт устройство будущих экземпляров: для x и y предусмотрены места под ссылки на значения. Поддержка __dict__ у экземпляров появляется, только если словарь унаследован или явно указан в __slots__.

Каждый новый экземпляр получает память под эти поля; в нашем примере __init__ записывает в них начальные значения.

class SlottedPoint:
    __slots__ = ("x", "y")

    def __init__(self, x: int, y: int) -> None:
        self.x, self.y = x, y

point = SlottedPoint(1, 2)
print(point.x, point.y)
print(hasattr(point, "__dict__"))
1 2
False

При создании класса Python также добавляет встроенные дескрипторы x и y. Каждый знает, где в экземпляре находится соответствующее поле, и управляет доступом к нему:

from types import MemberDescriptorType
from typing import cast

x_slot = cast("MemberDescriptorType", vars(SlottedPoint)["x"])
print(type(x_slot).__name__)
print(x_slot.__get__(point, SlottedPoint))
x_slot.__set__(point, 10)
print(point.x)
member_descriptor
1
10

Чтение point.x вызывает __get__ дескриптора слота, а присваивание point.x = 10 — его __set__. Так атрибут работает без __dict__.

Произвольное поле добавить нельзя:

point.extra = 42
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[6], line 1
----> 1 point.extra = 42

AttributeError: 'SlottedPoint' object has no attribute 'extra' and no __dict__ for setting new attributes

Слоты и наследование#

Подкласс без собственного __slots__ получает __dict__. Унаследованные поля остаются в слотах. Если новых полей нет, указываем __slots__ = ():

class FlexiblePoint(SlottedPoint):
    extra: int

class FixedPoint(SlottedPoint):
    __slots__ = ()

flexible = FlexiblePoint(1, 2)
flexible.extra = 42
print(flexible.__dict__)

fixed = FixedPoint(1, 2)
print(fixed.x, fixed.y)
print(hasattr(fixed, "__dict__"))
{'extra': 42}
1 2
False
fixed.extra = 42
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[8], line 1
----> 1 fixed.extra = 42

AttributeError: 'FixedPoint' object has no attribute 'extra' and no __dict__ for setting new attributes

Множественное наследование#

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

class NameMixin:
    __slots__ = ("name",)
    name: str

class NamedPoint(SlottedPoint, NameMixin):
    __slots__ = ()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[9], line 5
      1 class NameMixin:
      2     __slots__ = ("name",)
      3     name: str
      4 
----> 5 class NamedPoint(SlottedPoint, NameMixin):
      6     __slots__ = ()

TypeError: multiple bases have instance lay-out conflict

Вторая база с пустым __slots__ совместима с полями первой:

class TupleMixin:
    __slots__ = ()
    x: int
    y: int

    def as_tuple(self) -> tuple[int, int]:
        return self.x, self.y

class TuplePoint(SlottedPoint, TupleMixin):
    __slots__ = ()

combined = TuplePoint(1, 2)
print(combined.as_tuple())
print(hasattr(combined, "__dict__"))
(1, 2)
False

Когда использовать __slots__#

  • При большом числе экземпляров экономия памяти становится заметной.

  • Фиксированный набор полей помогает ловить опечатки (obj.nmae = ...).

Для класса с @dataclass слоты можно включить через @dataclass(slots=True) без ручного объявления __slots__.

property поверх слотов#

В этом Circle радиус хранится в слоте _r, а площадь вычисляется через property. Словаря экземпляра здесь нет.

class Circle:
    __slots__ = ("_r",)
    _r: float

    def __init__(self, r: float) -> None:
        self._r = r

    @property
    def area(self) -> float:
        return 3.14159 * self._r ** 2

    @area.setter
    def area(self, value: float) -> None:
        self._r = (value / 3.14159) ** 0.5

c = Circle(5)
print(hasattr(c, "__dict__"))
print(f"area: {c.area}")
c.area = 100
print(f"new radius: {c._r}")
False
area: 78.53975
new radius: 5.641898218231844

Посмотрим на объекты в __dict__ самого класса. vars(Circle) позволяет получить их напрямую:

for name in ("_r", "area"):
    attribute: object = vars(Circle)[name]
    print(name, type(attribute).__name__)
    print([
        method for method in ("__get__", "__set__", "__delete__")
        if hasattr(type(attribute), method)
    ])
_r member_descriptor
['__get__', '__set__', '__delete__']
area property
['__get__', '__set__', '__delete__']

У property и слота _r есть одинаковые специальные методы. Через них Python управляет чтением, записью и удалением атрибута.


2. Что такое дескриптор#

Дескриптор (descriptor) — объект, класс которого реализует хотя бы один из методов:

  • __get__(self, instance, owner) — читаем obj.attr

  • __set__(self, instance, value) — пишем obj.attr = ...

  • __delete__(self, instance) — del obj.attr

При поиске атрибута различаются два вида дескрипторов:

  • data descriptor — у класса есть __set__ или __delete__. Если определён __get__, он вызывается раньше проверки словаря экземпляра.

  • non-data descriptor — есть __get__, но нет __set__ и __delete__. Его __get__ вызывается, только если одноимённого значения нет в словаре экземпляра.

Наличие __get__ само по себе не делает дескриптор data. __set_name__ тоже не влияет на категорию.

class Descriptor:
    def __get__(self, instance: object | None, owner: type[object]) -> int:
        print(f"__get__ called: instance={instance}, owner={owner}")
        return 42

class Foo:
    x = Descriptor()  # атрибут класса

f = Foo()
print(f.x)  # вызывает Descriptor.__get__
print(f.x)  # ещё раз
print(Foo.x)  # тоже (instance=None)
__get__ called: instance=<__main__.Foo object at 0x10bded400>, owner=<class '__main__.Foo'>
42
__get__ called: instance=<__main__.Foo object at 0x10bded400>, owner=<class '__main__.Foo'>
42
__get__ called: instance=None, owner=<class '__main__.Foo'>
42

Дескриптор в obj.__dict__ при чтении вернётся как обычное значение.

При obj.attr стандартный object.__getattribute__ ищет имя в классе и его базовых классах по type(obj).__mro__, а также в словаре экземпляра.

@property в примере с Circle создаёт в классе объект property. Его __get__ и __set__ вызывают переданные функции. __set__ есть и у property без setter: присваивание в таком случае вызывает AttributeError.


3. __set_name__ — знать своё имя#

Пусть поля a и b используют один класс дескриптора. Их значения нужно хранить под разными ключами в f.__dict__: "a" и "b". Но при f.a = 1 метод __set__ получает только f и 1 — имя a ему не передаётся.

Метод __set_name__ сообщает дескриптору имя атрибута при создании класса:

from typing import Self, overload

class NamedDescriptor[T]:
    def __set_name__(self, owner: type[object], name: str) -> None:
        print(f"__set_name__: owner={owner.__name__}, name={name}")
        self._name = name

    @overload
    def __get__(self, instance: None, owner: type[object]) -> Self: ...
    @overload
    def __get__(self, instance: object, owner: type[object]) -> T | str: ...
    def __get__(
        self, instance: object | None, owner: type[object]
    ) -> Self | T | str:
        if instance is None:
            return self
        return cast("T | str", vars(instance).get(self._name, "unset"))

    def __set__(self, instance: object, value: T) -> None:
        vars(instance)[self._name] = value

class Foo:
    a = NamedDescriptor[int]()  # __set_name__(Foo, "a")
    b = NamedDescriptor[int]()  # __set_name__(Foo, "b")

f = Foo()
f.a = 1
f.b = 2
print(f.a, f.b)
print(vars(f))
__set_name__: owner=Foo, name=a
__set_name__: owner=Foo, name=b
1 2
{'a': 1, 'b': 2}

type.__new__ вызывает метод при создании класса для каждого атрибута с __set_name__. Если добавить дескриптор в уже созданный класс, __set_name__ нужно вызвать вручную.

Перегрузки __get__ различают Foo.a, который возвращает дескриптор, и f.a, который возвращает значение или "unset". Этот вариант использует словарь через vars(instance) и требует __dict__.


4. Методы и staticmethod#

Функция, записанная в класс, — дескриптор: у неё есть __get__. При доступе через экземпляр он возвращает связанный метод (bound method), который хранит функцию в __func__, а экземпляр — в __self__:

from types import MethodType

class Foo:
    def bar(self) -> str:
        return "hello from bar"

f = Foo()
bound = cast("MethodType", f.bar)
print(bound)                   # связанный метод
print(Foo.bar)                 # исходная функция
print(bound.__self__ is f)
print(bound.__func__ is Foo.bar)
print(bound())                 # результат вызова
print(Foo.bar.__get__(f, Foo))  # связанный метод через явный вызов __get__
<bound method Foo.bar of <__main__.Foo object at 0x10bded400>>
<function Foo.bar at 0x112063ab0>
True
True
hello from bar
<bound method Foo.bar of <__main__.Foo object at 0x10bded400>>

Так работает f.bar():

  1. При чтении f.bar Python находит функцию Foo.bar в классе.

  2. Вызывает её __get__(f, Foo) и получает связанный метод.

  3. Вызов метода передаёт f первым аргументом в Foo.bar.

Поэтому f.bar() в этом примере вызывает ту же функцию, что и Foo.bar(f).

staticmethod как декоратор-дескриптор#

В аннотациях P сохраняет типы аргументов, а R — тип результата.

from collections.abc import Callable

# Упрощённая реализация staticmethod
class StaticMethod[**P, R]:
    def __init__(self, func: Callable[P, R]) -> None:
        self.func = func

    def __get__(
        self, instance: object, owner: type[object]
    ) -> Callable[P, R]:
        return self.func  # возвращает функцию без привязки к экземпляру

@StaticMethod получает функцию в конструкторе. Получившийся объект хранится в классе, а его __get__ управляет доступом:

class Greeter:
    @StaticMethod
    def normalize(name: str) -> str:
        return name.strip().title()

greeter = Greeter()
print(Greeter.normalize("  ivan  "))
print(greeter.normalize("  ivan  "))
Ivan
Ivan

В CPython встроенный staticmethod реализован на C. Свой вариант classmethod реализуете в ДЗ from_scratch.


5. Ленивые атрибуты своими руками#

Проверим категории уже знакомых классов:

descriptor_classes: tuple[type[object], ...] = (StaticMethod, property, MemberDescriptorType)
for descriptor_class in descriptor_classes:
    is_data = hasattr(descriptor_class, "__set__") or hasattr(descriptor_class, "__delete__")
    print(descriptor_class.__name__, "data" if is_data else "non-data")
StaticMethod non-data
property data
member_descriptor data

Кеширование через non-data descriptor#

functools.cached_property вычисляет значение при первом чтении и сохраняет его. Напишем упрощённый аналог:

Owner — тип экземпляра, T — тип вычисляемого значения.

class MyLazyProperty[Owner, T]:
    def __init__(self, func: Callable[[Owner], T]) -> None:
        self._func = func
        self._name = func.__name__
        self.get_calls = 0

    def __set_name__(self, owner: type[Owner], name: str) -> None:
        self._name = name

    @overload
    def __get__(self, instance: None, owner: type[Owner]) -> Self: ...
    @overload
    def __get__(self, instance: Owner, owner: type[Owner]) -> T: ...
    def __get__(
        self, instance: Owner | None, owner: type[Owner]
    ) -> Self | T:
        if instance is None:
            return self
        self.get_calls += 1
        print(f"__get__ called: name={self._name}")
        value = self._func(instance)
        vars(instance)[self._name] = value
        return value

class Report:
    def __init__(self, items: list[int]) -> None:
        self._items = items
        self.compute_calls = 0

    @MyLazyProperty
    def total(self) -> int:
        self.compute_calls += 1
        print("  [computing total, expensive!]")
        return sum(self._items)

r = Report([1, 2, 3, 4, 5])
print(vars(r))
print(r.total)  # вычисляет значение, здесь вызывается дексриптор
print(vars(r))
print(r.total)  # теперь это просто значение аттрибута из __dict__
print(r.total)
print(f"get calls: {Report.total.get_calls}, computations: {r.compute_calls}")
{'_items': [1, 2, 3, 4, 5], 'compute_calls': 0}
__get__ called: name=total
  [computing total, expensive!]
15
{'_items': [1, 2, 3, 4, 5], 'compute_calls': 1, 'total': 15}
15
15
get calls: 1, computations: 1

После первого чтения в vars(r) есть ключ "total". Поскольку MyLazyProperty не имеет __set__ и __delete__, следующие чтения возвращают эту запись: __get__ и вычисляемая функция вызываются по одному разу.

Примерно так же построен functools.cached_property из стандартной библиотеки.

Кеширование через data descriptor#

Добавим __set__, запрещающий присваивание. Теперь это data descriptor: при чтении __get__ вызывается раньше проверки словаря экземпляра, даже если в нём уже есть одноимённая запись.

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

class DataLazyProperty[Owner, T](MyLazyProperty[Owner, T]):
    @overload
    def __get__(self, instance: None, owner: type[Owner]) -> Self: ...
    @overload
    def __get__(self, instance: Owner, owner: type[Owner]) -> T: ...
    def __get__(
        self, instance: Owner | None, owner: type[Owner]
    ) -> Self | T:
        if instance is None:
            return self
        self.get_calls += 1
        print(f"__get__ called: name={self._name}")
        attributes = vars(instance)
        if self._name not in attributes:
            attributes[self._name] = self._func(instance)
        return cast("T", attributes[self._name])

    def __set__(self, instance: Owner, value: T) -> None:
        raise AttributeError("cached attribute is read-only")

class DataReport:
    def __init__(self, items: list[int]) -> None:
        self._items = items
        self.compute_calls = 0

    @DataLazyProperty
    def total(self) -> int:
        self.compute_calls += 1
        print("  [computing total, expensive!]")
        return sum(self._items)

data_report = DataReport([1, 2, 3, 4, 5])
print(data_report.total)
print(vars(data_report))
print(data_report.total)
print(data_report.total)
print(f"get calls: {DataReport.total.get_calls}, computations: {data_report.compute_calls}")
__get__ called: name=total
  [computing total, expensive!]
15
{'_items': [1, 2, 3, 4, 5], 'compute_calls': 1, 'total': 15}
__get__ called: name=total
15
__get__ called: name=total
15
get calls: 3, computations: 1

Здесь три чтения вызывают __get__ три раза, а вычисляемую функцию — один. Присваивание проходит через добавленный __set__:

data_report.total = 100
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[21], line 1
----> 1 data_report.total = 100

Cell In[20], line 19, in DataLazyProperty.__set__(self, instance, value)
     18     def __set__(self, instance: Owner, value: T) -> None:
---> 19         raise AttributeError("cached attribute is read-only")

AttributeError: cached attribute is read-only

6. Проверка значений при присваивании#

Дескриптор позволяет проверять значение при каждом присваивании полю.

Простой дескриптор PositiveInt#

class PositiveInt:
    def __set_name__(self, owner: type[object], name: str) -> None:
        self._name = f"_{name}"

    @overload
    def __get__(self, instance: None, owner: type[object]) -> Self: ...
    @overload
    def __get__(self, instance: object, owner: type[object]) -> int: ...
    def __get__(
        self, instance: object | None, owner: type[object]
    ) -> Self | int:
        if instance is None:
            return self
        return cast("int", getattr(instance, self._name))

    def __set__(self, instance: object, value: int) -> None:
        if not isinstance(value, int):
            raise TypeError(f"{self._name[1:]} must be int, got {type(value).__name__}")
        if value <= 0:
            raise ValueError(f"{self._name[1:]} must be positive, got {value}")
        setattr(instance, self._name, value)

class Order:
    __slots__ = ("_quantity", "_price")

    quantity = PositiveInt()
    price = PositiveInt()

o = Order()
o.quantity = 5
o.price = 100
print(f"quantity={o.quantity}, price={o.price}")
print(hasattr(o, "__dict__"))
print(type(Order.quantity).__name__)
print(type(vars(Order)["_quantity"]).__name__)
quantity=5, price=100
False
PositiveInt
member_descriptor

Проверка отклоняет отрицательное число:

o.quantity = -1
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[23], line 1
----> 1 o.quantity = -1

Cell In[22], line 20, in PositiveInt.__set__(self, instance, value)
     16     def __set__(self, instance: object, value: int) -> None:
     17         if not isinstance(value, int):
     18             raise TypeError(f"{self._name[1:]} must be int, got {type(value).__name__}")
     19         if value <= 0:
---> 20             raise ValueError(f"{self._name[1:]} must be positive, got {value}")
     21         setattr(instance, self._name, value)

ValueError: quantity must be positive, got -1

Неверный тип заметит mypy; при выполнении дескриптор тоже его проверит:

o.price = "expensive"
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[24], line 1
----> 1 o.price = "expensive"

Cell In[22], line 18, in PositiveInt.__set__(self, instance, value)
     16     def __set__(self, instance: object, value: int) -> None:
     17         if not isinstance(value, int):
---> 18             raise TypeError(f"{self._name[1:]} must be int, got {type(value).__name__}")
     19         if value <= 0:
     20             raise ValueError(f"{self._name[1:]} must be positive, got {value}")
     21         setattr(instance, self._name, value)

TypeError: price must be int, got str

o.quantity = 5 вызывает наш PositiveInt.__set__. Его setattr записывает значение в слот _quantity. Всё работает без __dict__.

Range, Regex#

import re

class Range:
    def __init__(self, lo: float, hi: float) -> None:
        self.lo, self.hi = lo, hi

    def __set_name__(self, owner: type[object], name: str) -> None:
        self._name = f"_{name}"

    @overload
    def __get__(self, instance: None, owner: type[object]) -> Self: ...
    @overload
    def __get__(self, instance: object, owner: type[object]) -> float: ...
    def __get__(
        self, instance: object | None, owner: type[object]
    ) -> Self | float:
        if instance is None:
            return self
        return cast("float", getattr(instance, self._name))

    def __set__(self, instance: object, value: float) -> None:
        if not (self.lo <= value <= self.hi):
            raise ValueError(f"value must be in [{self.lo}, {self.hi}], got {value}")
        setattr(instance, self._name, value)

class Regex:
    def __init__(self, pattern: str) -> None:
        self.pattern = re.compile(pattern)

    def __set_name__(self, owner: type[object], name: str) -> None:
        self._name = f"_{name}"

    @overload
    def __get__(self, instance: None, owner: type[object]) -> Self: ...
    @overload
    def __get__(self, instance: object, owner: type[object]) -> str: ...
    def __get__(
        self, instance: object | None, owner: type[object]
    ) -> Self | str:
        if instance is None:
            return self
        return cast("str", getattr(instance, self._name))

    def __set__(self, instance: object, value: str) -> None:
        if not self.pattern.match(value):
            raise ValueError(f"value must match {self.pattern.pattern!r}, got {value!r}")
        setattr(instance, self._name, value)

class Person:
    age = Range(0, 150)
    email = Regex(r"^[\w.]+@[\w.]+$")

p = Person()
p.age = 30
p.email = "ivan@example.com"
print(f"{p.email}, age {p.age}")
ivan@example.com, age 30
p.age = 300
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[26], line 1
----> 1 p.age = 300

Cell In[25], line 23, in Range.__set__(self, instance, value)
     21     def __set__(self, instance: object, value: float) -> None:
     22         if not (self.lo <= value <= self.hi):
---> 23             raise ValueError(f"value must be in [{self.lo}, {self.hi}], got {value}")
     24         setattr(instance, self._name, value)

ValueError: value must be in [0, 150], got 300

Так проверку значений можно вынести из класса модели в отдельные объекты. Дальше в курсе разберём, как метакласс собирает такие поля в модель.

Материалы#