Дескрипторы — одна из тех фич 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:

  1. Data descriptor в классе или его родителях

  2. Атрибут в obj.__dict__

  3. Non-data descriptor в классе

  4. Атрибут в классе

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