Лекция 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():
При чтении
f.barPython находит функциюFoo.barв классе.Вызывает её
__get__(f, Foo)и получает связанный метод.Вызов метода передаёт
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
Так проверку значений можно вынести из класса модели в отдельные объекты. Дальше в курсе разберём, как метакласс собирает такие поля в модель.