Дескрипторы - одна из самых загадочных и малопонятных тем для начинающих python-разработчиков. Давайте разберемся, что же это такое, зачем нужно и как применять дескрипторы на практике.
Что такое дескриптор и зачем он нужен
Дескриптор - это объект, который определяет поведение доступа к атрибуту другого объекта. Он как бы "описывает" атрибут и управляет тем, как к нему обращаться - получать значение, устанавливать или удалять.
Главное отличие дескриптора от обычного свойства класса в том, что дескриптор привязан к классу, а не к экземпляру. Поэтому один дескриптор может управлять атрибутами всех объектов данного класса.
Дескриптор - это атрибут объекта со связанным поведением, то есть такой атрибут, при доступе к которому его поведение переопределяется методом протокола дескриптора.
Класс считается дескриптором, если реализует хотя бы один из методов: __get__()
, __set__()
или __delete__()
. Эти методы вызываются автоматически при обращении к атрибуту.
__get__()
- для получения значения атрибута__set__()
- для назначения значения__delete__()
- для удаления атрибута
Благодаря дескрипторам мы можем:
- Валидировать значения атрибутов
- Логировать доступ к атрибутам
- Лениво вычислять атрибуты
- Кешировать значения
- Создавать свойства только для чтения
В общем, дескрипторы - очень мощный механизм, который открывает массу возможностей для гибкой настройки поведения классов. В Python есть несколько разновидностей встроенных дескрипторов:
Дескрипторы свойств
Это самый распространенный тип дескрипторов, который мы определяем сами в коде для настройки поведения свойств класса.
Методы класса как дескрипторы
Любой метод класса в Python является дескриптором. Когда мы обращаемся к методу объекта, на самом деле вызывается __get__()
метода класса.
Статические методы как дескрипторы
Аналогично методы класса, статические методы тоже реализуют дескрипторный протокол для доступа к ним через экземпляр.
Дескрипторы в метаклассах
Метаклассы в Python позволяют изменять поведение классов "на лету". Они тоже опираются на механизм дескрипторов.
Работа с дескрипторами на практике
Давайте разберем на практических примерах, как создавать и использовать разные типы дескрипторов.
Создание простого дескриптора свойства
Напишем дескриптор, который будет выводить в консоль информацию о доступе к атрибуту.
class AccessLogger: def __init__(self): self.values = {} def __get__(self, obj, objtype=None): value = self.values.get(obj, 'No value') print(f'Getting value {value!r} for {obj!r}') return value def __set__(self, obj, value): print(f'Setting value {value!r} for {obj!r}') self.values[obj] = value
Теперь применим его в классе:
class MyClass: x = AccessLogger() obj = MyClass() obj.x = 123 print(obj.x)
При запуске мы увидим в консоли сообщения о доступе к атрибуту x
:
Setting value 123 for <__main__.MyClass object at 0x7fa48f48fc10> Getting value 123 for <__main__.MyClass object at 0x7fa48f48fc10>
Дескриптор для валидации данных
Рассмотрим пример дескриптора, который будет проверять, что назначаемое свойству значение является целым положительным числом:
class PositiveInteger: def __set_name__(self, owner, name): self.name = name def __set__(self, instance, value): if not isinstance(value, int): raise TypeError(f'{self.name} must be an integer!') if value < 0: raise ValueError(f'{self.name} must be positive!') instance.__dict__[self.name] = value class Order: amount = PositiveInteger()
Теперь при попытке назначить свойству amount
некорректное значение будет вызвана ошибка:
order = Order() order.amount = 5 # ok order.amount = 0 # Error! order.amount = -1 # Error!
Дескриптор только для чтения
Часто бывает нужно сделать свойство доступным только для чтения. Дескрипторы позволяют легко это реализовать, определив только метод __get__()
:
class ReadOnly: def __set_name__(self, owner, name): self.name = name def __get__(self, instance, owner): if instance is None: return self return instance.__dict__.get(self.name, None) class Car: mileage = ReadOnly() c = Car() c.mileage = 100 # Ошибка, только для чтения!
Дескриптор с логированием обращений
Рассмотрим еще один полезный пример - дескриптор, который будет логировать каждое обращение к атрибуту:
import logging class LoggedAccess: def __init__(self, name): self.name = name self.logger = logging.getLogger(name) def __get__(self, obj, cls): value = getattr(obj, self.name, None) self.logger.debug(f'Getting {self.name} value {value!r}') return value def __set__(self, obj, value): self.logger.debug(f'Setting {self.name} to {value!r}') setattr(obj, self.name, value) def __delete__(self, obj): self.logger.debug(f'Deleting {self.name}') delattr(obj, self.name) class Config: debug = LoggedAccess('debug') c = Config() c.debug = True # логируем доступ
Общие дескрипторы и дескрипторы экземпляра
Дескрипторы бывают общими (shared) и дескрипторами экземпляра (instance). Это определяется по контексту доступа:
- Общие дескрипторы одинаковы для всех экземпляров класса
- Дескрипторы экземпляра уникальны для каждого экземпляра
Чтобы сделать дескриптор общим, нужно хранить данные в самом дескрипторе. А для дескриптора экземпляра данные следует хранить в экземпляре класса, например в __dict__
. К сожалению, при работе с дескрипторами возможны и типичные ошибки.
Замена дескриптора в экземпляре, а не классе
Нельзя назначать дескриптор отдельному экземпляру, это работать не будет:
class MyClass: x = MyDescriptor() obj = MyClass() obj.x = 10 # Ошибка!
Нужно изменять сам класс:
MyClass.x = OtherDescriptor()
Один дескриптор для разных классов
Также неверно использовать один дескриптор для атрибутов разных классов:
d = MyDescriptor() class A: x = d class B: x = d a = A() b = B() a.x = 10 # Ошибка! дескриптор не различает классы
Нужно создавать отдельный дескриптор для каждого класса.
Дескриптор без методов доступа
Определение "пустого" дескриптора без реализации методов типа __get__()
вызовет ошибку при обращении к атрибуту.
Циклическая зависимость дескрипторов
Если дескрипторы обращаются друг к другу, может возникнуть рекурсия и переполнение стека.
Например:
class D1: def __get__(self, obj, cls): return obj.d2 # цикл! class D2: def __get__(self, obj, cls): return obj.d1 # цикл! class MyClass: d1 = D1() d2 = D2()
Такие зависимости лучше избегать.
Лучшие практики работы с дескрипторами
Чтобы извлечь максимум пользы из дескрипторов и избежать ошибок, рекомендуется придерживаться следующих правил:
- Четко определять область действия дескриптора
- Тестировать дескрипторы отдельно от класса
- Документировать назначение дескрипторов
- Использовать composition вместо наследования
- Ограничивать мутацию состояния дескриптора
Следуя этим советам, можно сделать код с дескрипторами максимально надежным и поддерживаемым.