Дескрипторы: что под @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.c — type_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:
- Data descriptor в classes по MRO (
type.__mro__)- Instance
__dict__- 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.
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.c — property_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.c — classmethod_init, classmethod_descr_get; Objects/funcobject.c — staticmethod_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.c — type_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:
- Python видит
setattrна instancea. Идёт вtp_setattro→_PyObject_GenericSetAttrWithDict. - Lookup
'balance'вAccount.__mro__(Account → object). НашёлPositive()вAccount.__dict__['balance']. - Проверка: data descriptor? Yes (
__set__есть) → вызватьPositive.__set__(positive_instance, a, 200). __set__проверяет200 < 0→ False, OK.- Вызывает
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.c — type_getattro_impl для type lookup.
Cross-course context
LowCardinality: словарное кодирование в ClickHouseКлючевые выводы
- Descriptor protocol — три метода:
__get__(self, obj, owner=None),__set__(self, obj, value),__delete__(self, obj). Плюс четвёртый__set_name__(self, owner, name)(PEP 487, Python 3.6+) — вызывается один раз при создании класса. - Data vs non-data descriptor определяется наличием
__set__или__delete__. Lookup precedence: data descriptor (class) > instance__dict__> non-data descriptor (class). Enforced в_PyObject_GenericGetAttrWithDict(Objects/object.c). @property— data descriptor (определяет__set__всегда, даже если setter не задан). Поэтомуobj.x = valueвсегда идёт через__set__, не может быть «обойдено» прямым изменениемobj.__dict__.@classmethod,@staticmethod— non-data descriptors (только__get__). Поэтому instance__dict__имеет precedence — instance attribute может «затенить» class method (полезно для mocking, обычно anti-pattern).__set_name__срабатывает только при class creation, не при динамическомsetattr(cls, 'x', desc). Workaround: вручную вызватьdesc.__set_name__(cls, 'x').- 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).