---
jupytext:
  formats: md:myst
  text_representation:
    extension: .md
    format_name: myst
kernelspec:
  display_name: Python 3.14
  language: python
  name: python3
---

```{code-cell} python
:tags: [remove-cell]

import os, sys
os.environ["PATH"] = f"{os.path.dirname(sys.executable)}:{os.environ['PATH']}"
```

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

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

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

```{code-cell} python
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__)
```

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

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

```{code-cell} python
class Featured:
    kind: ClassVar[str] = "featured"

class SpecialProduct(Product, Featured):
    pass

print([base.__name__ for base in SpecialProduct.__mro__])
print(SpecialProduct(100).kind)
```

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

[`__slots__`](https://docs.python.org/3.14/reference/datamodel.html#slots)
обрабатывается при создании класса, после выполнения его тела. В CPython
это задаёт устройство будущих экземпляров: для `x` и `y` предусмотрены места
под ссылки на значения. Поддержка `__dict__` у экземпляров появляется, только
если словарь унаследован или явно указан в `__slots__`.

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

```{code-cell} python
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__"))
```

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

```{code-cell} python
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)
```

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

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

```{code-cell} python
:tags: [raises-exception]

point.extra = 42
```

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

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

```{code-cell} python
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__"))
```

```{code-cell} python
:tags: [raises-exception]

fixed.extra = 42
```

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

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

```{code-cell} python
:tags: [raises-exception]

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

class NamedPoint(SlottedPoint, NameMixin):
    __slots__ = ()
```

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

```{code-cell} python
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__"))
```

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

- При большом числе экземпляров экономия памяти становится заметной.
- Фиксированный набор полей помогает ловить опечатки (`obj.nmae = ...`).

Для класса с `@dataclass` слоты можно включить через
[`@dataclass(slots=True)`](https://docs.python.org/3.14/library/dataclasses.html#dataclasses.dataclass) без ручного объявления `__slots__`.

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

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

```{code-cell} python
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}")
```

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

```{code-cell} python
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)
    ])
```

У `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__` тоже не влияет на категорию.

```{code-cell} python
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)
```

Дескриптор в `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__` сообщает дескриптору имя атрибута
при создании класса:

```{code-cell} python
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))
```

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

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

---

## 4. Методы и `staticmethod`

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

```{code-cell} python
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__
```

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

1. При чтении `f.bar` Python находит функцию `Foo.bar` в классе.
2. Вызывает её `__get__(f, Foo)` и получает связанный метод.
3. Вызов метода передаёт `f` первым аргументом в `Foo.bar`.

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

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

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

```{code-cell} python
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__` управляет доступом:

```{code-cell} python
class Greeter:
    @StaticMethod
    def normalize(name: str) -> str:
        return name.strip().title()

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

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

---

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

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

```{code-cell} python
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")
```

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

[`functools.cached_property`](https://docs.python.org/3.14/library/functools.html#functools.cached_property)
вычисляет значение при первом чтении и сохраняет его. Напишем упрощённый аналог:

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

```{code-cell} python
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}")
```

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

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

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

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

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

```{code-cell} python
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__` три раза, а вычисляемую функцию — один.
Присваивание проходит через добавленный `__set__`:

```{code-cell} python
:tags: [raises-exception]

data_report.total = 100
```

---

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

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

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

```{code-cell} python
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__)
```

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

```{code-cell} python
:tags: [raises-exception]

o.quantity = -1
```

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

```{code-cell} python
:tags: [raises-exception]

o.price = "expensive"
```

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

### `Range`, `Regex`

```{code-cell} python
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}")
```

```{code-cell} python
:tags: [raises-exception]

p.age = 300
```

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


## Материалы

- [Руководство по дескрипторам](https://docs.python.org/3.14/howto/descriptor.html)
