Создание дескрипторов классов

На предыдущем занятии мы с вами рассмотрели создание объектов-свойств и видели, что для определения нескольких однотипных таких объектов приходится дублировать программный код, что не очень хорошо. Чтобы этого избежать можно воспользоваться механизмом дескрипторов и сейчас вы узнаете что это такое.

В самом простом случае дескриптор – это класс, в котором определены следующие специальные методы:

class CoordValue:
    def __get__(self, instance, owner):
        return self.__value
 
    def __set__(self, instance, value):
        self.__value = value
 
    def __delete__(self, obj):
        del self.__value

Как вы уже догадались, get – это геттер, set – сеттер, а delete – вызывается при удалении свойства дескриптора. И далее, используя этот класс, создадим два дескриптора в классе Point:

class Point:
    coordX = CoordValue()
    coordY = CoordValue()
 
    def __init__(self, x = 0, y = 0):
        self.coordX = x
        self.coordY = y

Сразу скажу, что это не совсем верная реализация дескрипторов, но я намеренно допустил ошибку, чтобы вы лучше поняли материал. Если визуально представить эти классы и экземпляр объекта:

pt = Point(1, 2)

то это будет выглядеть так. Смотрите, здесь свойства экземпляра coordX и coordY берутся непосредственно из класса Point. Мы в этом можем убедиться, если выведем их id:

print( id(pt.coordX), id(Point.coordX) )

И даже операция в конструкторе класса:

self.coordX = x

не создает новое локальное свойство, так как в дескрипторах оператор присваивания перегружен и идет просто вызов метода __set__ класса CoordValue. При этом вызове параметр self ссылается на объект coordX, в котором приватному значению __value присваивается значение value. То же самое и при обращении к coordY. Только здесь self будет ссылаться уже на второй объект и информация записывается в другую переменную __value.

И если мы будем работать с дескрипторами подобно свойствам объекта, то вроде бы у нас все работает как надо:

pt = Point(1, 2)
print( pt.coordX, pt.coordY )
pt.coordX = 100
print( pt.coordX, pt.coordY )

Но давайте теперь создадим еще один экземпляр:

pt = Point(1, 2)
pt2 = Point(10, 20)

Смотрите, при выполнении мы видим, что свойства обоих объектов оказываются идентичными:

print( pt.coordX, pt.coordY )
print( pt2.coordX, pt2.coordY )

И вы уже догадались почему: coordX, coordY второго экземпляра также берутся напрямую из класса Point, то есть, это все те же самые дескрипторы.

Давайте теперь поправим программу и сделаем так, чтобы координаты точки хранились непосредственно в экземплярах классов. Перепишем класс дескриптора вот в таком виде:

class CoordValue:
    def __init__(self, name):
        self.__name = name
 
    def __get__(self, instance, owner):
        return instance.__dict__[self.__name]
 
    def __set__(self, instance, value):
        instance.__dict__[self.__name] = value

Здесь параметр instance ссылается на экземпляр класса Point, для которого был вызван метод. И через него, используя коллекцию __dict__ мы делаем обработку локальных свойств: возвращаем их или устанавливаем. А, чтобы знать имя локального свойства, здесь был добавлен конструктор с параметром name – имя свойства. И мы его сохраняем в экземпляре объекта-дескриптора в приватной переменной __name.

Далее, в самом классе Point сделаем такие вызовы:

coordX = CoordValue("coordX")
coordY = CoordValue("coordY")

И, теперь, при запуске программы, мы видим разные значения координат для разных экземпляров классов:

pt = Point(1, 2)
pt2 = Point(10, 20)
print( pt.coordX, pt.coordY )
print( pt2.coordX, pt2.coordY )

Но эту программу можно еще упростить и улучшить. Начиная с версии Python 3.6 и выше, у дескрипторов появился новый весьма полезный метод:

def __set_name__(self, owner, name):
    print(name)
    self.__name = name

Он вызывается автоматически при создании экземпляра дескриптора и в параметре name хранится имя экземпляра класса. Мы вполне можем его использовать для создания соответствующих локальных атрибутов в экземплярах классов Point. И конструктор нам больше не нужно переопределять, уберем его. В самом классе Point создадим дескрипторы без указания имен:

coordX = CoordValue()
coordY = CoordValue()

и, запуская программу, видим, имена coordX и coordY и полную работоспособность наших дескрипторов.

И у вас здесь может возникнуть вопрос: у нас в экземплярах класса есть локальные атрибуты coordX, coordY и доступ к дескрипторам класса Point с теми же именами? Как Python «понимает», что нужно обращаться именно к дескрипторам, а не к локальным свойствам объекта pt? Все дело в приоритетах. Если в самом классе (или базовом классе) имеется дескриптор с тем же именем, что и свойство в коллекции __dict__, то приоритет отдается дескриптору и именно он берется для записи или считывания значений. Поэтому наша программа корректно работала.

Но здесь тоже есть одна тонкость: дескрипторы в Python бывают двух видов: для данных и для не данных (non-data descriptor). Последний отличается тем, что реализует только один метод __get__:

class NoDataDescr:
    def __set_name__(self, owner, name):
        self.__name = name
 
    def __get__(self, instance, owner):
        return "NoDataDescr __get__"

И, если его создать в классе Point:

noData = NoDataDescr()

то при запуске мы увидим, строчку из этого «дескриптора не данных». Но, если выполним присвоение свойству с тем же именем:

pt.noData = "hello"

то наш дескриптор noData пропадет из локального окружения экземпляра pt. Что, в общем то не удивительно, т.к. в этом дескрипторе нет метода set для изменения соответствующего локального свойства.

Но, чаще всего на практике используют дескрипторы для данных, то есть, и для записи и для считывания значений. Поэтому этот последний момент просто нужно знать, чтобы избежать лишних ошибок.

И в заключение отмечу такую вещь. Дескрипторы – это безусловно классный, красивый и гибкий механизм, позволяющий создавать красивый программный код. Однако, прежде чем использовать какой-либо инструмент, задайте себе вопрос: действительно ли он здесь нужен, не проще ли все реализовать двумя методами: геттером и сеттером и, как говорится, не городить огород? Неоправданное усложнение программы это также плохо как и отказ от существующих инструментов языка программирования. При проектировании программ следует придерживаться грани, когда с одной стороны простые способы реализации, а с другой – возможные проблемы при масштабировании проекта. Но, это все приходит с опытом и, так как дорогу осилит только идущий, то вперед на покорение дескрипторов и свойств классов. Для начала попробуйте реализовать выполнить следующие задания на Python.

Задания для самоподготовки

1. Объявите класс Calendar для хранения даты: день, месяц, год. Определите свойства для записи и считывания этой информации из этого класса. (Дополнение: используя __slots__ разрешите использовать только строго определенные локальные свойства в экземплярах класса).

2. Объявите класс Rectangle, хранящий координаты верхней левой и правой нижней точек. Создайте дескрипторы для записи и считывания этих значений в классе (атрибуты с данными координат должны быть приватными).