Дескрипторы в Python: механизм под property и ORM-полями
Почему obj.field возвращает не то, что вы туда положили, и как Django строит поля модели. Разбор протокола дескрипторов, порядка поиска атрибутов и мест, где механизм ломается.

Вы пишете class User(models.Model): name = models.CharField(max_length=100), потом создаёте объект и обращаетесь к user.name — получаете строку. Но в классе-то лежит объект CharField, а не строка. Куда он делся и откуда взялась строка? Ответ — протокол дескрипторов. Это тот же механизм, на котором держатся property, classmethod, staticmethod и поля любой ORM. Разберём, как Python решает, что вернуть при obj.attr, чем data-дескриптор отличается от non-data и где эта логика даёт неожиданный результат. Всё актуально для CPython 3.10–3.13.
Что такое дескриптор
Дескриптор — это объект, у класса которого определён хотя бы один из методов __get__, __set__, __delete__. Важно: методы должны быть у класса дескриптора, а сам дескриптор — атрибутом класса, а не экземпляра. Положите его в self.__dict__ — протокол не сработает.
Сигнатуры такие:
# CPython 3.10+
class Descriptor:
def __get__(self, instance, owner=None):
# instance — объект, через который идёт обращение (или None при доступе через класс)
# owner — сам класс-владелец
...
def __set__(self, instance, value):
...
def __delete__(self, instance):
...
Минимальный рабочий пример — дескриптор, который логирует чтение и запись и хранит значение в словаре экземпляра:
# Python 3.10+
class LoggedField:
def __set_name__(self, owner, name):
# вызывается автоматически при создании класса-владельца
# name — имя, под которым дескриптор объявлен в классе
self.private_name = '_' + name
def __get__(self, instance, owner=None):
if instance is None:
return self # доступ через класс: User.age -> сам дескриптор
value = getattr(instance, self.private_name)
print(f'read {self.private_name} -> {value}')
return value
def __set__(self, instance, value):
print(f'write {self.private_name} = {value}')
setattr(instance, self.private_name, value)
class User:
age = LoggedField()
def __init__(self, age):
self.age = age
u = User(30) # write _age = 30
print(u.age) # read _age -> 30, затем 30
Обратите внимание на __set_name__ — он появился в Python 3.6 и избавляет от необходимости передавать имя поля в конструктор дескриптора. Интерпретатор вызывает его один раз при создании класса, передавая имя атрибута.
Зачем хранить значение в экземпляре, а не в дескрипторе
Частая ошибка новичка — сложить значение в сам дескриптор: self.value = value. Дескриптор существует в единственном экземпляре на весь класс. Все объекты User будут делить одно поле age, и запись одного объекта затрёт остальные. Значение хранят либо в instance.__dict__ под отдельным ключом, либо в отдельном WeakKeyDictionary, если складывать в экземпляр нельзя (например, у него __slots__).
Data-дескриптор против non-data: кто побеждает
Здесь начинается неочевидное. Порядок, в котором Python ищет атрибут при obj.attr, зависит от того, какие методы есть у дескриптора.
- Data-дескриптор — определяет
__set__и/или__delete__. Имеет приоритет над__dict__экземпляра. - Non-data дескриптор — определяет только
__get__. Уступает__dict__экземпляра.
Упрощённый порядок разрешения obj.attr, который выполняет object.__getattribute__:
- Найти
attrвtype(obj).__mro__. Если это data-дескриптор — вызвать его__get__и вернуть результат. Конец. - Иначе искать
attrвobj.__dict__. Нашли — вернуть значение как есть. - Иначе если из шага 1 остался non-data дескриптор — вызвать его
__get__. - Иначе если из шага 1 остался обычный атрибут класса — вернуть его.
- Иначе
AttributeError(или__getattr__, если определён).
Из этого следует практическое различие. property — это data-дескриптор (у него есть __set__, даже если сеттер не задан — тогда __set__ кидает AttributeError). Поэтому property нельзя перекрыть, записав одноимённый ключ в __dict__: шаг 1 срабатывает раньше. А обычная функция — non-data дескриптор (у неё есть __get__, но нет __set__), поэтому одноимённый атрибут экземпляра спокойно затеняет метод.
Разница между data и non-data дескриптором — это не деталь реализации, а причина, по которой property защищён от случайной перезаписи, а метод класса — нет. Один и тот же трюк obj.attr = ... в одном случае молча заменит поведение, в другом упадёт с ошибкой.
Как это увидеть руками
# Python 3.10+
class A:
@property
def x(self): # data-дескриптор: есть __set__ (кидает AttributeError)
return 42
def method(self): # non-data дескриптор: только __get__
return 'hi'
a = A()
a.__dict__['x'] = 999 # пишем прямо в словарь экземпляра
print(a.x) # 42 — property победил __dict__
a.__dict__['method'] = lambda: 'shadow'
print(a.method()) # 'shadow' — метод затенён
Как property и classmethod построены на этом
Всё, что кажется "магией" декораторов, — обычные классы-дескрипторы:
propertyхранит функции fget/fset/fdel и в__get__вызывает fget, в__set__— fset. Наличие__set__делает его data-дескриптором.- функция (тип
function) реализует__get__, который возвращает связанный метод — bound method с уже подставленнымself. Это и есть механизм, превращающийobj.methodв вызов с первым аргументом. classmethodиstaticmethod— тоже дескрипторы, различаются тем, что именно возвращает их__get__: уclassmethodв__get__связывается класс вместо экземпляра.
Дескрипторы в ORM: поля модели
Возвращаемся к вопросу из начала. В Django (актуально для 4.x–5.x) поле модели — не совсем чистый дескриптор в момент объявления, но результат тот же. При создании класса модели метакласс собирает объявленные поля, а обращение к атрибуту на экземпляре проходит через дескрипторы, которые Django навешивает под капотом (например, DeferredAttribute для обычных полей и отдельные дескрипторы для связей вроде ForeignKey).
Практический смысл для вас как разработчика:
- обращение
user.authorкForeignKeyможет выполнить SQL-запрос — за__get__прячется отложенная загрузка. Отсюда классическая проблема N+1: цикл по объектам с обращением к связанному полю бьёт в базу на каждой итерации. user.tagsкManyToManyFieldвозвращает не список, а менеджер — тоже поведение дескриптора, а не хранимое значение.
Идея построения кастомного дескриптора-поля (схематично, не Django API):
# схематично, для понимания принципа
class TypedField:
def __init__(self, expected_type):
self.expected_type = expected_type
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner=None):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError(f'{self.name}: ожидался {self.expected_type.__name__}')
instance.__dict__[self.name] = value
class Product:
price = TypedField(int)
p = Product()
p.price = 100 # ок
p.price = 'abc' # TypeError: price: ожидался int
Именно так, через __set__ с валидацией, работает идея типизированных и валидируемых полей. Конкретный API Django поверх этого богаче — сверяйте с документацией по кастомным полям для вашей версии.
Где механизм ломается
- Дескриптор в экземпляре не работает. Протокол срабатывает только когда дескриптор — атрибут класса.
obj.__dict__['field'] = descriptor_instanceничего не даст: при чтении вернётся сам объект дескриптора, без вызова__get__. - Общее состояние при хранении в дескрипторе. Описано выше: одно поле на все экземпляры. Проявляется не сразу, а когда объектов становится больше одного.
- Конфликт с
__slots__. Если у класса есть__slots__, у экземпляра нет__dict__, и складывать значение вinstance.__dict__нельзя. Придётся хранить вWeakKeyDictionaryвнутри дескриптора — с оглядкой на то, что ключом должен быть хешируемый объект. - Забыли
if instance is Noneв__get__. Доступ через класс (User.age) передаётinstance=None. Без обработки этого случая интроспекция, автодополнение и обращение через класс упадут. - Наследование и переопределение. Дескриптор ищется по всему
__mro__. Если поле переопределено в подклассе обычным значением, оно затенит дескриптор родителя — но только если оно non-data-контекста; для data-дескриптора логика приоритета сохраняется на уровне того класса, где он объявлен.
Что делать дальше
Если вы пишете библиотеку с валидацией полей или собственный слой доступа к данным — начните с __set_name__ и хранения в instance.__dict__, это покрывает 90% случаев без утечек памяти. Для отладки чужого кода привыкайте проверять тип атрибута через type(SomeClass).__dict__['attr'] — так вы увидите дескриптор, а не результат его __get__. И прочитайте официальный HOWTO по дескрипторам: там разобран точный алгоритм __getattribute__ с исходником на чистом Python, который полезно один раз пройти построчно.
Транскрибатор встреч: Запись созвона или диктофона — в текст с разделением по спикерам, протоколом и списком задач.
Спросить за 25 ₽Источники: Descriptor HowTo Guide — официальная документация Python, Data model: Implementing Descriptors — Python Language Reference
Частые вопросы
Чем отличается data-дескриптор от non-data и почему это важно?
Почему нельзя хранить значение прямо в дескрипторе через self.value?
У меня Python 3.6 — сработает ли __set_name__?
Дескриптор не срабатывает — __get__ не вызывается. Что проверить?
Дескрипторы замедляют доступ к атрибутам в проде?
Как property связан с дескрипторами?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.