Дескрипторы — одна из тех фич Python, о которых многие слышали, но мало кто использует напрямую. При этом они лежат в основе @property, @classmethod, @staticmethod, слотов и даже обычного доступа к методам.
Разберём, что такое дескрипторы, как их писать и когда они реально полезны.
Что такое дескриптор
Дескриптор — это объект, который определяет, как происходит доступ к атрибуту другого объекта. Технически это любой объект, у которого есть хотя бы один из методов: get, set, delete.
Когда Python видит obj.attr, он не просто берёт значение из obj.__dict__. Происходит сложный протокол поиска, и если attr оказывается дескриптором — вызывается его метод.
Простейший пример:
class Verbose:
"""Дескриптор, который логирует доступ"""
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
value = obj.__dict__.get(self.name)
print(f"Getting {self.name}: {value}")
return value
def __set__(self, obj, value):
print(f"Setting {self.name} to {value}")
obj.__dict__[self.name] = value
class Person:
name = Verbose()
age = Verbose()
p = Person()
p.name = "Kolyan"
p.age = 30
print(p.name)
При присваивании p.name = "Kolnya" Python видит, что Person.name — дескриптор с методом set, и вызывает Verbose.__set__(Person.name, p, "Alice").
Два типа дескрипторов
Python различает data descriptors и non-data descriptors.
Data descriptor — имеет set и/или delete. Имеет высший приоритет.
Non-data descriptor — имеет только get. Уступает атрибутам экземпляра.
Порядок поиска атрибута obj.attr:
Data descriptor в классе или его родителях
Атрибут в
obj.__dict__Non-data descriptor в классе
Атрибут в классе
getattr(если определён)
Поэтому @property (data descriptor) нельзя переопределить в экземпляре, а обычный метод (non-data descriptor) можно:
class Example:
@property
def prop(self):
return "property"
def method(self):
return "method"
e = Example()
# аопытка переопределить property
e.__dict__['prop'] = "overridden"
print(e.prop) # "property" — data descriptor побеждает
# Переопределяем метод
e.__dict__['method'] = lambda: "overridden"
print(e.method()) # "overridden" — __dict__ побеждает non-data descriptor
Валидирующий дескриптор
Одно из главных применений дескрипторов — валидация при присваивании. Вместо кучи property с одинаковой логикой:
class Validated:
"""Базовый валидирующий дескриптор"""
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = f'_{name}'
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)
def __set__(self, obj, value):
self.validate(value)
setattr(obj, self.private_name, value)
def validate(self, value):
"""Переопределяется в наследниках"""
pass
class PositiveNumber(Validated):
def validate(self, value):
if not isinstance(value, (int, float)):
raise TypeError(f"{self.public_name} must be a number")
if value <= 0:
raise ValueError(f"{self.public_name} must be positive")
class NonEmptyString(Validated):
def validate(self, value):
if not isinstance(value, str):
raise TypeError(f"{self.public_name} must be a string")
if not value.strip():
raise ValueError(f"{self.public_name} cannot be empty")
class Product:
name = NonEmptyString()
price = PositiveNumber()
quantity = PositiveNumber()
def __init__(self, name, price, quantity):
self.name = name
self.price = price
self.quantity = quantity
# Использование
product = Product("Laptop", 999.99, 10)
product.price = -100 # ValueError: price must be positive
product.name = " " # ValueError: name cannot be empty
Дескрипторы позволяют вынести повторяющуюся логику валидации в переиспользуемые компоненты. Один PositiveNumber — и все числовые поля во всех классах защищены.
set_name: знакомство с Python 3.6+
До Python 3.6 дескрипторы не знали своего имени. Приходилось передавать его явно:
class OldWay:
name = Descriptor('name') # Дублирование
age = Descriptor('age')
__set_name__(self, owner, name) вызывается автоматически при создании класса. owner класс, в котором дескриптор определён, name имя атрибута.
class AutoNamed:
def __set_name__(self, owner, name):
print(f"I am {name} in {owner.__name__}")
self.name = name
class MyClass:
foo = AutoNamed() # I am foo in MyClass
bar = AutoNamed() # I am bar in MyClass
Как работает @property
@property — это просто встроенный дескриптор. Можно реализовать его самостоятельно:
class MyProperty:
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
self.__doc__ = doc if doc else (fget.__doc__ if fget else None)
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 __delete__(self, obj):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(obj)
def getter(self, fget):
return type(self)(fget, self.fset, self.fdel, self.__doc__)
def setter(self, fset):
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel, self.__doc__)
class Circle:
def __init__(self, radius):
self._radius = radius
@MyProperty
def radius(self):
"""Radius of the circle"""
return self._radius
@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value
c = Circle(5)
print(c.radius) # 5
c.radius = 10 # OK
c.radius = -1 # ValueError
Методы getter, setter, deleter возвращают новый экземпляр дескриптора с обновлённым соответствующим методом. Так можно использовать декораторную цепочку @radius.setter.
Как работают методы
Обычные методы — тоже дескрипторы. Функция в Python — non-data descriptor:
def func(self):
return "I'm a method"
print(type(func).__get__) # <slot wrapper '__get__' ...>
Когда вы обращаетесь к obj.method, Python вызывает type(obj).__dict__['method'].__get__(obj, type(obj)). Функция возвращает bound method — объект, который хранит ссылку на obj и вызывает функцию с ним как первым аргументом.
class Demo:
def method(self):
return f"Called on {self}"
d = Demo()
# Это эквивалентно:
d.method()
Demo.__dict__['method'].__get__(d, Demo)()
@staticmethod и @classmethod — тоже дескрипторы, просто с другой логикой get:
class StaticMethod:
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
return self.func # Возвращаем функцию как есть
class ClassMethod:
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
if objtype is None:
objtype = type(obj)
# Возвращаем bound method, но привязанный к классу
return self.func.__get__(objtype, type(objtype))
Ленивые вычисления с кешированием
Популярный паттерн — вычислить значение один раз и закешировать:
class cached_property:
"""Вычисляет значение при первом доступе, потом берёт из кеша"""
def __init__(self, func):
self.func = func
self.attrname = None
self.__doc__ = func.__doc__
def __set_name__(self, owner, name):
self.attrname = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Проверяем кеш в __dict__ экземпляра
cache = obj.__dict__
if self.attrname not in cache:
cache[self.attrname] = self.func(obj)
return cache[self.attrname]
class DataProcessor:
def __init__(self, data):
self.data = data
@cached_property
def processed(self):
print("Processing... (expensive operation)")
return [x * 2 for x in self.data]
dp = DataProcessor([1, 2, 3, 4, 5])
print(dp.processed) # Processing.. [2, 4, 6, 8, 10]
print(dp.processed) # [2, 4, 6, 8, 10] без Processing
print(dp.processed) # [2, 4, 6, 8, 10] снова из кеша
Это non-data descriptor. При первом вызове значение сохраняется в obj.__dict__, и при следующих обращениях dict имеет приоритет над non-data дескриптором.
Вообще, щас в питоне есть встроенный functools.cached_property, но понимание механизма полезно.
Дескрипторы для слотов
slots — механизм экономии памяти, который заменяет dict на фиксированный набор атрибутов. slots реализованы через дескрипторы:
class WithSlots:
__slots__ = ('x', 'y')
# Python создаёт дескрипторы автоматически
print(type(WithSlots.x)) # <class 'member_descriptor'>
member_descriptor — это C-реализация дескриптора, который читает и пишет по фиксированному смещению в памяти объекта.
Типизированные атрибуты
Дескрипторы хорошо сочетаются с type hints для создания строго типизированных атрибутов:
from typing import Generic, TypeVar, Type, Any, get_type_hints
T = TypeVar('T')
class TypedAttribute(Generic[T]):
def __set_name__(self, owner: Type, name: str):
self.name = name
self.private_name = f'_{name}'
# Получаем тип из аннотации
hints = get_type_hints(owner)
self.expected_type = hints.get(name)
def __get__(self, obj: Any, objtype: Type = None) -> T:
if obj is None:
return self # type: ignore
return getattr(obj, self.private_name)
def __set__(self, obj: Any, value: T) -> None:
if self.expected_type and not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} must be {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
setattr(obj, self.private_name, value)
def typed_attributes(cls):
"""Декоратор класса: заменяет аннотированные атрибуты на TypedAttribute"""
hints = get_type_hints(cls)
for name, type_hint in hints.items():
if not hasattr(cls, name) or isinstance(getattr(cls, name), TypedAttribute):
setattr(cls, name, TypedAttribute())
return cls
@typed_attributes
class User:
name: str
age: int
email: str
def __init__(self, name: str, age: int, email: str):
self.name = name
self.age = age
self.email = email
user = User("Alice", 30, "alice@example.com")
user.age = "thirty" # TypeError: age must be int, got str
Это простая рантайм проверка типов на основе аннотаций. Для прода же лучше использовать pydantic или attrs.
Когда используем дескрипторы
Хороший выбор:
Переиспользуемая логика доступа к атрибутам (то есть всякая валидация, преобразование, логирование)
Ленивые вычисления с кешированием
ORM-поля (SQLAlchemy, Django ORM — построены на дескрипторах)
Создание DSL и декларативных API
Перебор:
Простая валидация в одном классе — хватит
@propertyОдин-два атрибута, не стоит заводить инфраструктуру
Когда dataclasses или attrs решают задачу проще
Нюансы
Хранение данных в дескрипторе:
class Wrong:
def __init__(self):
self.value = None # Shared между всеми экземплярами
def __get__(self, obj, objtype=None):
return self.value
def __set__(self, obj, value):
self.value = value # Перезаписывает для всех
class MyClass:
attr = Wrong()
a = MyClass()
b = MyClass()
a.attr = 1
print(b.attr) # 1
Данные нужно хранить в экземпляре (obj.__dict__ или setattr(obj, ...)), не в дескрипторе.
None при доступе через класс:
class Descriptor:
def __get__(self, obj, objtype=None):
if obj is None:
return self # важно!!
return obj.__dict__.get('value')
При MyClass.attr (без экземпляра) obj будет None. Если не обработать, получите AttributeError или странное поведение.
Итак, если @property или dataclass решают задачу — используйте их. Дескрипторы нужны, когда логика действительно переиспользуется и достаточно сложна, чтобы оправдать абстракцию.

Если хочется не просто «пощупать» дескрипторы, а системно прокачать базу Python-разработчика, посмотрите программу Python Developer. Basic: синтаксис, работа с БД, API, асинхронщина и веб на Django/FastAPI. На выходе — практические навыки и первые проекты в портфолио. Пройдите входное тестирование и получите скидку на курс.
Комментарии (7)

IisNuINu
29.01.2026 17:30было очень интересно и поучительно про это прочитать. Да возможности предоставляемые данным механизмом интересные, но какова цена? Теперь уж не приходится удивлятся тому, что объекты в питоне ОЧЕНЬ медленные.

Autochthon
29.01.2026 17:30С трудом верится, что этот язык изобрел математик. Собрание лоскутов и заплаток и никакой концептуальной целостности. Особенно умиляет зачем он какие-то классы изобрел с наследованием когда присвоение null приводит к стиранию типа. Операторные скобки отступами это отдельный анекдот: копируешь блок кода для вставки и бонусом кроме кода вставляешь вложенность.

ABy
29.01.2026 17:30При присваивании p.name = "Kolnya" Python видит, что Person.name — дескриптор с методом set, и вызывает Verbose.__set__(Person.name, p, "Alice")
Хитро.

APh
29.01.2026 17:30При присваивании
p.name= "Kolnya"Python видит, чтоPerson.name— дескриптор с методомset, и вызываетVerbose.__set__(Person.name, p, "Alice").Я понимаю — 4 часа ночи... Но, кто-то может пояснить, как взаимодействует треугольник Колян, Колня и Алиса? o_O
Dair_Targ
Для типизации хорошо б не использовать вообще в коде
Any. На SO есть ответ про то, как полностью их типизировать.ValeryIvanov
Согласен, что использование
Anyлучше избегать любыми способами. Но вот обобщённый тип для владельца дескриптора считаю оверкиллом, так как пользы в большинстве случаев он никакой не представляет. В примере из статьи, дескрипторTypedAttributeиспользует исключительно словарь экземпляра владельца дескриптора, то естьinstance: objectвполне хватит. Если отinstanceтребуется обладать какой-то определённой логикой, то прямо так и пишемinstance: HasSomeBehaviour.И к тому же, если дескрипторы которые выступают в роли декоратора могут получить тип владельца автоматически, то вот для обычных дескрипторов - это будет выглядеть так себе.
Выглядит не прямо ужасно, но если mapped_column никак не будет использовать обобщённый тип владельца, то это просто захламление кода.