Дескрипторы — одна из тех фич 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. На выходе — практические навыки и первые проекты в портфолио. Пройдите входное тестирование и получите скидку на курс.
