Дескрипторы и __slots__

⏱ 40 мин · 🧩 заданий: 5

Что такое дескриптор

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

Метод Вызывается при
__get__(self, instance, owner) чтении obj.attr (и Class.attr, тогда instance is None)
__set__(self, instance, value) присваивании obj.attr = value
__delete__(self, instance) del obj.attr
__set_name__(self, owner, name) создании класса: дескриптор узнаёт своё имя
class Loud:
    def __set_name__(self, owner, name):
        print(f"  __set_name__: класс {owner.__name__}, атрибут {name!r}")
        self.name = name

    def __get__(self, instance, owner):
        print(f"  __get__: instance={instance!r:.30}")
        if instance is None:
            return self
        return instance.__dict__.get(self.name, "<пусто>").upper()

    def __set__(self, instance, value):
        print(f"  __set__: {value!r}")
        instance.__dict__[self.name] = value

class User:
    nickname = Loud()          # здесь сработает __set_name__

u = User()
u.nickname = "neo"
print(u.nickname)
print(User.nickname)

Дескриптор-валидатор

Классический пример — проверка полей, как в ORM (Django models.Field, SQLAlchemy Column):

class Typed:
    def __init__(self, kind, *, min_value=None):
        self.kind = kind
        self.min_value = min_value

    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        if not isinstance(value, self.kind):
            raise TypeError(f"{self.name}: ожидался {self.kind.__name__}, получен {type(value).__name__}")
        if self.min_value is not None and value < self.min_value:
            raise ValueError(f"{self.name}: меньше {self.min_value}")
        instance.__dict__[self.name] = value

class Product:
    name = Typed(str)
    price = Typed(float, min_value=0.0)
    qty = Typed(int, min_value=0)

    def __init__(self, name, price, qty):
        self.name, self.price, self.qty = name, price, qty

p = Product("Кофе", 199.0, 3)
print(p.name, p.price, p.qty, p.__dict__)
for bad in [("Чай", "дёшево", 1), ("Чай", 50.0, -1)]:
    try:
        Product(*bad)
    except (TypeError, ValueError) as e:
        print(type(e).__name__, e)

Один класс Typed переиспользуется для всех полей — в отличие от property, который пришлось бы писать для каждого поля отдельно.

Дескрипторы данных и не-данных

  • Дескриптор данных — есть __set__ или __delete__ (например, property).
  • Дескриптор не-данных — только __get__ (например, функции, classmethod, staticmethod).

От этого зависит приоритет при чтении obj.attr:

obj.attr → 1. дескриптор ДАННЫХ в классе (и его родителях)? → его __get__ → 2. obj.__dict__["attr"]? → значение → 3. дескриптор не-данных или обычный атрибут класса → __get__ / значение → 4. __getattr__, если определён → иначе AttributeError
class DataDesc:
    def __get__(self, obj, owner): return "data descriptor"
    def __set__(self, obj, value): pass

class NonDataDesc:
    def __get__(self, obj, owner): return "non-data descriptor"

class A:
    d = DataDesc()
    n = NonDataDesc()

a = A()
a.__dict__["d"] = "instance dict"
a.__dict__["n"] = "instance dict"
print(a.d)        # дескриптор данных сильнее __dict__
print(a.n)        # __dict__ сильнее дескриптора не-данных

Поэтому cached_property (не-данных) работает: первый раз вычисляет и кладёт значение в __dict__, а потом __dict__ перекрывает дескриптор.

Методы — тоже дескрипторы

class Greeter:
    def hello(self, name):
        return f"Привет, {name}"

g = Greeter()
f = Greeter.__dict__["hello"]           # «сырая» функция в словаре класса
print(type(f).__name__, hasattr(f, "__get__"))
bound = f.__get__(g, Greeter)           # именно это делает Python при g.hello
print(bound, bound("Анна"))
print(Greeter.__dict__["hello"] is Greeter.hello)   # через класс — та же функция

property, classmethod, staticmethod, functools.cached_property — всё это дескрипторы, написанные на C или Python.

slots

По умолчанию атрибуты экземпляра хранятся в словаре __dict__ — гибко, но расточительно. __slots__ заменяет словарь фиксированными ячейками:

import sys

class WithDict:
    def __init__(self, x, y):
        self.x, self.y = x, y

class WithSlots:
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x, self.y = x, y

a, b = WithDict(1, 2), WithSlots(1, 2)
print("с __dict__:", sys.getsizeof(a) + sys.getsizeof(a.__dict__), "байт")
print("со __slots__:", sys.getsizeof(b), "байт")
print(hasattr(b, "__dict__"), type(WithSlots.x).__name__)   # каждый слот — дескриптор!
try:
    b.z = 3
except AttributeError as e:
    print("AttributeError:", e)

Когда полезно: миллионы мелких объектов (точки, узлы графа, записи из БД) — экономия памяти до 40–50% и чуть более быстрый доступ.

Ограничения slots

  • Нельзя добавлять новые атрибуты (иногда это плюс — опечатка self.nmae сразу даст ошибку).
  • Наследник без своих __slots__ снова получает __dict__.
  • Для слабых ссылок нужен слот "__weakref__".
  • В dataclass проще всего: @dataclass(slots=True).

getattribute и getattr

  • __getattribute__ вызывается при каждом обращении к атрибуту — именно он реализует алгоритм выше. Переопределять его опасно и редко нужно.
  • __getattr__ вызывается, только когда атрибут не найден обычным путём — удобен для прокси и значений по умолчанию.
class Proxy:
    def __init__(self, target):
        self._target = target
        self.calls = 0

    def __getattr__(self, name):              # только для отсутствующих атрибутов
        self.calls += 1
        return getattr(self._target, name)

p = Proxy([3, 1, 2])
p.append(0)
p.sort()
print(p._target, p.calls)                   # _target и calls нашлись обычным путём

Вопрос на собеседовании

«Как работает property внутри?» — property — это дескриптор данных: класс с методами __get__, __set__ и __delete__, которые вызывают переданные функции fget/fset/fdel. Поскольку это дескриптор данных, он имеет приоритет над __dict__ экземпляра.

Итог

  • Дескриптор — объект в атрибуте класса с __get__/__set__/__delete__; __set_name__ сообщает ему имя.
  • Дескриптор данных (есть __set__) сильнее __dict__ экземпляра, дескриптор не-данных — слабее.
  • Функции, property, classmethod, staticmethod — дескрипторы.
  • __slots__ убирает __dict__, экономит память и запрещает новые атрибуты.
  • __getattr__ — для отсутствующих атрибутов, __getattribute__ — для всех.

Практика решено 0 из 5

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Программа на Python

Напишите дескриптор Positive, который разрешает присваивать атрибуту только положительные числа (иначе ValueError). Имя атрибута узнайте в __set_name__ и храните значение в instance.__dict__ под этим именем. Класс Order уже использует его для полей qty и price.

Какие тесты проверяют код (5)
o = Order(2, 100); assert (o.qty, o.price) == (2, 100)
o = Order(1, 1)
try:
    o.qty = 0
    ok = False
except ValueError:
    ok = True
assert ok, "qty = 0 должно давать ValueError"

try:
    Order(1, -5)
    ok = False
except ValueError:
    ok = True
assert ok, "Отрицательная цена должна давать ValueError"

a, b = Order(1, 10), Order(2, 20); assert (a.price, b.price) == (10, 20), "Значения должны храниться у каждого экземпляра отдельно"
assert isinstance(Order.qty, Positive), "При обращении через класс верните сам дескриптор"
Задание 2 Программа на Python

Создайте класс Pixel с полями x, y, color, у которого есть __slots__, чтобы у объектов не было __dict__ и нельзя было добавить посторонний атрибут.

Какие тесты проверяют код (3)
p = Pixel(1, 2, "red"); assert (p.x, p.y, p.color) == (1, 2, "red")
assert not hasattr(Pixel(0, 0, "x"), "__dict__"), "У объекта со __slots__ нет __dict__"
p = Pixel(0, 0, "x")
try:
    p.alpha = 1
    ok = False
except AttributeError:
    ok = True
assert ok, "Посторонний атрибут добавить нельзя"

Задание 3 Один вариант ответа

Что выведет код?

class D:
    def __get__(self, obj, owner):
        return "из дескриптора"

class A:
    x = D()

a = A()
a.__dict__["x"] = "из __dict__"
print(a.x)
Задание 4 Один вариант ответа

Как обычная функция в классе становится связанным методом при обращении obj.method?

Задание 5 Несколько вариантов

Что верно про __slots__?

Войдите, чтобы отмечать пройденные уроки.