Дескрипторы и property в Python: как устроено внутри
property — не магия, а обёртка над протоколом дескрипторов. Разбираем, что вызывается при доступе к атрибуту, почему __set__ побеждает __dict__ и где кроется цена абстракции.

Вы пишете obj.value — и не задумываетесь, что за этой строкой стоит цепочка вызовов, в которой участвуют минимум три словаря, метакласс и протокол из трёх методов. Пока всё работает — детали не важны. Но стоит смешать property с __slots__, унаследоваться от класса с data-дескриптором или получить непонятный AttributeError при доступе к атрибуту, который вроде бы присвоен, — приходится знать, в каком порядке Python ищет атрибут и почему property ведёт себя не так, как обычное поле.
Разбираем на CPython 3.12: как property реализована через протокол дескрипторов, чем data-дескриптор отличается от non-data и в каком порядке type.__getattribute__ перебирает источники значения. Речь только о внутреннем устройстве доступа к атрибуту — метаклассы и __getattr__ как fallback затрагиваем ровно настолько, насколько они влияют на порядок поиска.
Протокол дескрипторов: три метода
Дескриптор — это объект, у которого определён хотя бы один из методов __get__, __set__, __delete__. Когда такой объект лежит в словаре класса (не экземпляра), Python при доступе к соответствующему атрибуту экземпляра вызывает не сам объект, а его метод протокола.
Сигнатуры в 3.12 такие:
class Descriptor:
def __get__(self, obj, objtype=None):
# obj — экземпляр, через который идёт доступ (или None при доступе через класс)
# objtype — сам класс
...
def __set__(self, obj, value):
...
def __delete__(self, obj):
...Ключевое различие, вокруг которого крутится всё поведение:
- Data-дескриптор — определён
__set__или__delete__(достаточно одного из них). - Non-data-дескриптор — определён только
__get__.
Разница не косметическая: она меняет приоритет относительно __dict__ экземпляра. Data-дескриптор перекрывает запись в словаре экземпляра, non-data — уступает ей. Отсюда растёт вся практическая разница между функцией (которая non-data, потому что у неё только __get__) и property (которая data, потому что у неё есть __set__).
Порядок поиска: что делает type.__getattribute__
За доступ к атрибуту через точку отвечает object.__getattribute__ (для экземпляров) или type.__getattribute__ (для доступа через класс). Упрощённый порядок для obj.name, где obj — экземпляр класса C:
- Пройти по MRO класса
Cи найтиnameв словарях классов. Запомнить найденное какmeta_attr. - Если
meta_attr— data-дескриптор (есть__set__/__delete__), сразу вызвать его__get__и вернуть результат. Дальше не идём. - Иначе заглянуть в
obj.__dict__. Еслиnameтам есть — вернуть это значение. - Иначе, если
meta_attr— non-data-дескриптор, вызвать его__get__. - Иначе, если
meta_attr— обычное значение из класса, вернуть его. - Иначе —
AttributeError(после чего в дело вступает__getattr__, если он есть).
Именно шаги 2 и 3 объясняют весь фокус. Data-дескриптор проверяется до словаря экземпляра, обычное поле — после.
property — это не особая синтаксическая конструкция, а объект класса property с методами __get__, __set__, __delete__. Всё поведение, которое кажется магией, — прямое следствие того, что у неё есть __set__, и значит, она data-дескриптор.
Как это доказать руками
Соберём минимальный дескриптор, который логирует обращения, и посмотрим, кто побеждает — он или __dict__ экземпляра.
# CPython 3.12
class Logged:
def __init__(self, name):
self.name = name # имя атрибута для хранения в __dict__ экземпляра
def __get__(self, obj, objtype=None):
if obj is None:
return self # доступ через класс: Temperature.celsius
print(f"get {self.name}")
return obj.__dict__[self.name]
def __set__(self, obj, value):
print(f"set {self.name} = {value}")
obj.__dict__[self.name] = value
class Temperature:
celsius = Logged("celsius")
t = Temperature()
t.celsius = 21 # печатает: set celsius = 21
print(t.__dict__) # {'celsius': 21} — значение реально лежит в __dict__
print(t.celsius) # печатает: get celsius, затем 21Важный момент: значение хранится в t.__dict__['celsius'], но прочитать его напрямую в обход дескриптора нельзя — t.celsius всё равно уходит в __get__, потому что Logged — data-дескриптор (есть __set__). Уберите __set__ — и t.celsius начнёт читать напрямую из __dict__, минуя __get__, как только там появится ключ.
Почему функции — non-data-дескрипторы
Обычная функция в Python имеет __get__, но не имеет __set__. При доступе obj.method её __get__ возвращает связанный метод (bound method) с уже подставленным self. Раз это non-data-дескриптор, вы можете затенить метод, положив что-то в __dict__ экземпляра:
class Service:
def ping(self):
return "pong"
s = Service()
s.ping = lambda: "overridden" # кладём в s.__dict__
print(s.ping()) # overridden — __dict__ побеждает non-data-дескрипторС property такой трюк не пройдёт: попытка s.prop = ... уйдёт в её __set__, а если сеттера нет — вы получите AttributeError: can't set attribute.
property — это класс, не ключевое слово
Декоратор @property создаёт экземпляр встроенного класса property. Схематично поведение эквивалентно такому чистому Python (реальная реализация в CPython написана на C, но семантика та же):
# схематично — эквивалент встроенного property
class property_pure:
def __init__(self, fget=None, fset=None, fdel=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
def __get__(self, obj, objtype=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):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value)
def setter(self, fset):
return type(self)(self.fget, fset, self.fdel)Обратите внимание: __set__ определён всегда, даже когда сеттера нет. Поэтому property без сеттера — по-прежнему data-дескриптор, и попытка присвоить значение падает с AttributeError, а не тихо кладёт значение в __dict__. Это осознанное решение: read-only property должно оставаться read-only, а не превращаться в обычное поле при первой записи.
Цена абстракции: замеры и где ломается
Каждое обращение к property — это вызов Python-функции поверх поиска атрибута, тогда как обычное поле — прямое чтение из словаря. На горячем пути разница заметна.
Прикидочный замер на 3.12 (числа зависят от машины и сборки, воспроизводите у себя через timeit):
import timeit
class Plain:
def __init__(self):
self.x = 1
class WithProp:
def __init__(self):
self._x = 1
@property
def x(self):
return self._x
p = Plain()
w = WithProp()
print(timeit.timeit(lambda: p.x, number=10_000_000))
print(timeit.timeit(lambda: w.x, number=10_000_000))На типичной машине доступ через property выходит примерно в 2–4 раза дороже прямого чтения поля. В абсолютных числах это единицы-десятки наносекунд на обращение — для бизнес-логики незаметно, для цикла на миллионы итераций внутри численного кода может стать узким местом. Мерьте свой случай, а не верьте порядку цифры вслепую.
Где подход ломается
- property + __slots__. Если у класса есть
__slots__и в них попало имя, совпадающее сproperty, вы получите конфликт: слот сам создаёт дескриптор для этого имени в словаре класса, и второй дескриптор его перекроет. Храните данные в отдельном приватном слоте (_x), аpropertyназывайте другим именем (x). - Наследование и переопределение. Чтобы добавить сеттер в подклассе, нельзя просто написать новый метод — надо пересобрать property целиком либо использовать
ParentClass.prop.setter. Частичное переопределение тихо теряетfgetродителя. - Дескриптор без учёта per-instance-хранилища. Наивный дескриптор, который хранит значение в
self(в самом дескрипторе), а не вobj.__dict__, будет общим на все экземпляры класса — классическая ошибка. Значение всегда кладите в экземпляр. - __set_name__. С 3.6 Python при создании класса вызывает
__set_name__(self, owner, name)у дескрипторов — это способ узнать, под каким именем дескриптор объявлен, без ручного дублирования строки в конструкторе. Если пишете свой дескриптор, используйте его вместо явной передачи имени.
Что делать дальше
Если ваша задача — валидация или ленивое вычисление одного-двух атрибутов, хватает property. Если один и тот же тип проверки нужен на десятке полей и в разных классах — выносите логику в собственный дескриптор с __set_name__: это устраняет дублирование и держит валидацию в одном месте. Для ленивого значения, вычисляемого один раз, посмотрите на functools.cached_property — это non-data-дескриптор, который после первого вызова кладёт результат в __dict__ и дальше отдаёт напрямую, без повторного вызова.
Следующий шаг для понимания механики — прочитать официальный HOWTO по дескрипторам: там приведён эталонный чистый-Python эквивалент __getattribute__, по которому удобно сверять порядок поиска. А в проде смотрите на профиль: если property оказалась на горячем пути численного цикла, переносите значение в локальную переменную до цикла или переходите на прямое поле.
Транскрибатор встреч: Запись созвона или диктофона — в текст с разделением по спикерам, протоколом и списком задач.
Спросить за 25 ₽Источники: Descriptor HowTo Guide — Python documentation, Data model: Implementing Descriptors — Python documentation
Частые вопросы
А если у меня Python 3.8 или 3.9 — порядок поиска другой?
Почему присваивание read-only property падает с AttributeError, а не создаёт поле в экземпляре?
Мой дескриптор возвращает одно и то же значение для всех экземпляров — что не так?
Как понять, data-дескриптор передо мной или non-data?
Стоит ли заменять все поля на property ради инкапсуляции?
property и cached_property можно использовать вместе на одном атрибуте?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.