Дескриптор что это? Одна из самых загадочных и малопонятных тем для начинающих python-разработчиков

Дескрипторы - одна из самых загадочных и малопонятных тем для начинающих 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 вместо наследования
  • Ограничивать мутацию состояния дескриптора

Следуя этим советам, можно сделать код с дескрипторами максимально надежным и поддерживаемым.

Комментарии