Learning Platform
Глоссарий Troubleshooting
Урок 05.04 · 28 мин
Продвинутый
Descriptor protocol__get____set____set_name__@propertyData vs non-data descriptor

Дескрипторы: что под @property, classmethod, staticmethod

@property, @classmethod, @staticmethod — три самых используемых декоратора в OOP-Python. Все три — это дескрипторы (descriptors) — объекты с особыми методами __get__/__set__/__delete__, которые перехватывают attribute access на уровне CPython. Когда вы пишете obj.x, под капотом дёргается type_getattro_impl() из Objects/typeobject.c — самые важные ~200 строк CPython после lookdict(). И там descriptor protocol — главное правило поиска.

В этом уроке разберём descriptor protocol (три метода + четвёртый __set_name__), правило data vs non-data descriptor lookup precedence (одно из fundamental правил Python data model), и реализуем собственный data descriptor Positive для валидации.


Descriptor protocol — три метода + set_name

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

МетодСигнатураЧто делает
__get__(self, obj, owner=None)Вызывается при чтении атрибута: obj.x или Class.x
__set__(self, obj, value)Вызывается при присваивании: obj.x = value
__delete__(self, obj)Вызывается при del obj.x
__set_name__(self, owner, name)Вызывается при создании класса (Python 3.6+); сообщает дескриптору, под каким именем он живёт

__set_name__ — особый: вызывается один раз при создании класса (через type_new_impl() в Objects/typeobject.c), не при каждом access. Используется чтобы дескриптор знал своё имя без передачи в конструкторе.

class TypedAttr:
    def __init__(self, expected_type):
        self.expected_type = expected_type
        # name будет установлен в __set_name__

    def __set_name__(self, owner, name):
        # owner = класс (например Person), name = имя атрибута (например 'age')
        self.name = '_' + name      # сохраняем under "private" имя

    def __get__(self, obj, owner=None):
        if obj is None:
            return self              # Class.attr → возвращаем сам descriptor
        return getattr(obj, self.name)

    def __set__(self, obj, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(f"expected {self.expected_type.__name__}, got {type(value).__name__}")
        setattr(obj, self.name, value)


class Person:
    age = TypedAttr(int)            # __set_name__ автоматически вызван с (Person, 'age')
    name = TypedAttr(str)           # __set_name__ автоматически вызван с (Person, 'name')

p = Person()
p.age = 30                          # OK
p.name = "Alice"                    # OK
p.age = "thirty"                    # TypeError: expected int, got str

Этот pattern — типичная реализация typed attributes: декларативно объявляем правила в class body, дескрипторы перехватывают setattr и валидируют. Pydantic, attrs, marshmallow всё используют тот же mechanism под капотом.

Cite: Objects/typeobject.ctype_init_subclass() вызывает __set_name__ для всех дескрипторов в tp_dict; PEP 487 ввёл __set_name__ в Python 3.6.


Data vs non-data descriptor — правило precedence

Это fundamental правило Python attribute lookup. Если вы запомните только одну вещь из этого урока — пусть будет это:

Lookup precedence:

  1. Data descriptor в classes по MRO (type.__mro__)
  2. Instance __dict__
  3. Non-data descriptor в classes по MRO

Что такое «data» и «non-data»?

ТипУсловиеПримеры
Data descriptorопределены __set__ ИЛИ __delete__@property, ваш TypedAttr выше
Non-data descriptorопределён только __get__ (без __set__/__delete__)@classmethod, @staticmethod, обычные функции (методы)

Алгоритм lookup для obj.x (упрощённо _PyObject_GenericGetAttrWithDict в Objects/object.c):

// псевдо-код
PyObject* lookup_attr(obj, name) {
    Py_TYPE(obj) → cls;
    cls.__mro__ → mro;

    // 1. Поиск в classes (по MRO):
    desc = NULL;
    for klass in mro:
        if name in klass.__dict__:
            desc = klass.__dict__[name];
            break;

    // 2. Если desc - data descriptor → выигрывает (skip instance dict):
    if (desc && has_set_or_delete(type(desc))) {
        return desc.__get__(obj, cls);
    }

    // 3. Иначе - проверяем instance __dict__:
    if (name in obj.__dict__) {
        return obj.__dict__[name];
    }

    // 4. Если desc был non-data descriptor - вызываем его:
    if (desc && has_get(type(desc))) {
        return desc.__get__(obj, cls);
    }

    // 5. Если desc - просто class attribute - возвращаем как есть:
    if (desc) return desc;

    // 6. Иначе - AttributeError:
    raise AttributeError(name);
}

Key insight: data descriptor имеет precedence > instance __dict__, а non-data descriptor имеет precedence < instance __dict__. Это enforced на уровне CPython, в _PyObject_GenericGetAttr.

Lookup precedence — data vs non-data descriptor
1. Data descriptor (class)@property, custom @setterИмеют __set__ или __delete__. Найдены в любом класс'е по MRO (cls.__mro__). ВЫИГРЫВАЮТ над instance __dict__ - всегда вызывается __get__/__set__.
2. Instance __dict__obj.__dict__[name]Per-instance attributes, заданные через obj.x = value (когда нет data descriptor в class). PyDictObject lookup за O(1) avg.
3. Non-data descriptor (class)@classmethod, @staticmethod, обычные методыИмеют только __get__ (нет __set__/__delete__). Если есть в class по MRO, но НЕ в instance dict → вызывается __get__. Можно 'затенить' установкой instance attribute.
4. Class attribute (regular)cls.x = 10 (просто значение)Если ни data, ни non-data descriptor - просто class attribute. Возвращается как есть. Та же precedence что non-data descriptor (после instance dict).

Cite: Objects/typeobject.c — функция type_getattro_impl() (~150 строк); Objects/object.c_PyObject_GenericGetAttrWithDict() для не-type объектов.


@property = data descriptor (имеет set)

@property — встроенный data descriptor. Под капотом:

# property - C-implemented тип в Objects/descrobject.c, упрощённо:
class property:
    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        self.__doc__ = doc

    def __get__(self, obj, owner=None):
        if obj is None: return self
        if self.fget is None:
            raise AttributeError("unreadable attribute")
        return self.fget(obj)

    def __set__(self, obj, value):       # <-- наличие __set__ делает property DATA descriptor
        if self.fset is None:
            raise AttributeError("can't set attribute")
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError("can't delete attribute")
        self.fdel(obj)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

Ключевое: property определяет __set__ (даже если вы не передали fset в @property__set__ всё равно есть, просто бросает AttributeError при попытке установки). Это делает property data descriptor → выигрывает над instance.__dict__:

class Account:
    def __init__(self, balance):
        self._balance = balance

    @property
    def balance(self):
        return self._balance / 100      # cents → dollars

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("balance не может быть негативным")
        self._balance = value * 100     # dollars → cents

a = Account(10000)
print(a.balance)            # 100.0 - вызвался @property fget
a.balance = 50              # вызвался @balance.setter fset
print(a._balance)           # 5000

# Попытка обойти property через прямой setattr:
a.__dict__['balance'] = 999  # установили в instance __dict__
print(a.balance)             # 50.0  (не 999!) - property всё равно вызывается
                             # потому что data descriptor > instance __dict__

Это и есть enforcement: вы не можете случайно перезаписать property через obj.x = value — всегда будет вызван __set__. Это корень того, что @property — больше чем «getter syntactic sugar», это runtime enforcement.

Cite: Objects/descrobject.cproperty_init, property_descr_get, property_descr_set.


@classmethod / @staticmethod = non-data descriptors

@classmethod и @staticmethod — только __get__, без __set__. Это делает их non-data descriptors → instance __dict__ имеет precedence над ними.

# classmethod (упрощённая Python-реализация; в реальности C-implemented):
class classmethod:
    def __init__(self, func):
        self.func = func

    def __get__(self, obj, owner=None):
        # Возвращает bound method с класс'ом вместо self:
        return MethodType(self.func, owner)

class C:
    @classmethod
    def info(cls):
        return f"class {cls.__name__}"

c = C()
print(c.info())       # 'class C'  - cls = C
print(C.info())       # 'class C'  - cls = C

Почему это non-data descriptor? classmethod определяет только __get__ (через MethodType оборачивает функцию binding’ом cls к первому аргументу). Это значит: instance __dict__ имеет precedence:

c = C()
c.info = lambda: "instance shadow"      # установили в c.__dict__
print(c.info())                         # 'instance shadow' - instance dict выиграл

То же для @staticmethod — non-data descriptor, который просто возвращает функцию as-is при __get__. Это позволяет «перекрыть» через instance attribute, что иногда полезно для testing/mocking, но обычно — anti-pattern.

Why difference? Сделать @property data descriptor имеет смысл (защита от случайной перезаписи); делать @classmethod data descriptor — не имеет (вы хотите, чтобы class method выглядел как обычный method, mockable through instance).

Cite: Objects/funcobject.cclassmethod_init, classmethod_descr_get; Objects/funcobject.cstaticmethod_descr_get.


set_name pitfall — почему dynamic descriptor add не срабатывает

__set_name__ вызывается только при создании класса через type_new_impl(). Если вы добавили дескриптор после создания класса (динамически через setattr), __set_name__ не вызовется:

class Desc:
    def __set_name__(self, owner, name):
        print(f"__set_name__ called: owner={owner.__name__}, name={name}")
        self.name = '_' + name

    def __get__(self, obj, owner=None):
        return getattr(obj, self.name)

    def __set__(self, obj, value):
        setattr(obj, self.name, value)


class Static:
    x = Desc()                  # __set_name__ called: owner=Static, name='x'

print(Static().x)               # Works (self.name = '_x')

# Динамическая попытка:
class Dynamic:
    pass

Dynamic.y = Desc()              # __set_name__ НЕ вызовется!

d = Dynamic()
d.y = 10                        # AttributeError: 'Desc' object has no attribute 'name'
                                # потому что __set_name__ так и не установил self.name

Почему: type_new_impl() обходит tp_dict нового класса один раз — при создании класса. Динамическое присвоение через setattr(Class, 'attr', desc) идёт через generic tp_setattro, который не вызывает __set_name__.

Workaround: либо передавать имя в конструктор (Desc('y')), либо вручную вызывать:

desc = Desc()
desc.__set_name__(Dynamic, 'y')   # manual call
Dynamic.y = desc

Cite: Objects/typeobject.ctype_new_set_names() обходит tp_dict only at class creation; PEP 487 limitation.


Реализация: Positive дескриптор

Соберём всё вместе. Реализуем data descriptor Positive, который проверяет, что atribute >= 0:

class Positive:
    """Data descriptor: запрещает негативные значения."""
    def __set_name__(self, owner, name):
        self.name = '_' + name              # сохраняем во _attr

    def __get__(self, obj, owner=None):
        if obj is None:
            return self                     # Class.attr → сам descriptor
        return getattr(obj, self.name)

    def __set__(self, obj, value):
        if value < 0:
            raise ValueError(f"{self.name[1:]} не может быть негативным; got {value}")
        setattr(obj, self.name, value)


class Account:
    balance = Positive()
    interest_rate = Positive()

    def __init__(self, balance, interest_rate):
        self.balance = balance              # вызовет Positive.__set__
        self.interest_rate = interest_rate


a = Account(100, 0.05)
print(a.balance)            # 100
a.balance = 200             # OK
print(a.balance)            # 200

a.balance = -10             # ValueError: balance не может быть негативным

Что произошло шаг за шагом для a.balance = 200:

  1. Python видит setattr на instance a. Идёт в tp_setattro_PyObject_GenericSetAttrWithDict.
  2. Lookup 'balance' в Account.__mro__ (Account → object). Нашёл Positive() в Account.__dict__['balance'].
  3. Проверка: data descriptor? Yes (__set__ есть) → вызвать Positive.__set__(positive_instance, a, 200).
  4. __set__ проверяет 200 < 0 → False, OK.
  5. Вызывает setattr(a, '_balance', 200) → пишет в a.__dict__['_balance'] (через рекурсию _PyObject_GenericSetAttrWithDict, но теперь имя _balance не имеет descriptor, идёт прямо в instance dict).

Lookup a.balance симметрично через Positive.__get__.

Cite: Python descriptor HOWTO — официальная документация Raymond Hettinger; Objects/typeobject.ctype_getattro_impl для type lookup.


Cross-course context

LowCardinality: словарное кодирование в ClickHouse

Ключевые выводы

  1. Descriptor protocol — три метода: __get__(self, obj, owner=None), __set__(self, obj, value), __delete__(self, obj). Плюс четвёртый __set_name__(self, owner, name) (PEP 487, Python 3.6+) — вызывается один раз при создании класса.
  2. Data vs non-data descriptor определяется наличием __set__ или __delete__. Lookup precedence: data descriptor (class) > instance __dict__ > non-data descriptor (class). Enforced в _PyObject_GenericGetAttrWithDict (Objects/object.c).
  3. @property — data descriptor (определяет __set__ всегда, даже если setter не задан). Поэтому obj.x = value всегда идёт через __set__, не может быть «обойдено» прямым изменением obj.__dict__.
  4. @classmethod, @staticmethod — non-data descriptors (только __get__). Поэтому instance __dict__ имеет precedence — instance attribute может «затенить» class method (полезно для mocking, обычно anti-pattern).
  5. __set_name__ срабатывает только при class creation, не при динамическом setattr(cls, 'x', desc). Workaround: вручную вызвать desc.__set_name__(cls, 'x').
  6. Custom data descriptor (Positive, TypedAttr, …) — каноничный pattern для validation, type-checking, lazy properties. Используется фреймворками: Pydantic, attrs, marshmallow, Django ORM fields.

В уроке M04-05 разберём __slots__ и @dataclass: первый — про память (member_descriptor для каждого attr, без __dict__), второй — про codegen через exec(), и их связку @dataclass(slots=True).

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Что отличает data descriptor от non-data descriptor?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6