Дескрипторы в Python

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

Дескрипторы Python или, в более общем смысле, просто дескрипторы предоставляют нам мощную технику для написания пригодного к повторному использованию кода, который можно использовать между классами. Они могут показаться похожими на концепцию наследования, но технически это не так. Это универсальный способ перехвата доступа к атрибутам. Дескрипторы — это механизм, лежащий в основе статических методов свойств, методов класса, суперметодов и т. д.

Дескрипторы были добавлены в Python версии 2.2, и с тех пор они считаются волшебными вещами, которые придали традиционным классам новый стиль. Это классы, которые позволяют вам делать управляемыми свойства в другом классе. В частности, они реализуют интерфейс для методов __get__(), __set__() и __delete__(), что делает их интересными по многим причинам.

Что такое дескриптор

Проще говоря, класс, который реализует метод __get__(), __set()__ или __delete()__ для объекта, известен как «дескриптор». Цитируя прямо из официальной документации Python, дескриптор — это атрибут объекта со связанным поведением, то есть такой атрибут, при доступе к которому его поведение переопределяется методом протокола дескриптора. Это методы __get__(), __set__() и __delete__().

Связанное поведение (англ. binding behavior) применительно к дескрипторам означает привязку способа, которым может устанавливаться, запрашиваться (получаться) или удаляться значение, к данной переменной, объекту или набору данных. Это взаимодействие привязано к части данных, оно применяется только к тем данным, для которых вы его установили.

Дескрипторы можно дополнительно разделить на дескрипторы данных и дескрипторы не-данных. Если дескриптор, который вы пишете, имеет только метод __get__(), то это дескриптор не-данных. А реализация, включающая методы __set__() и __delete__(), называется дескриптором данных. Дескрипторы, не относящиеся к данным, доступны только для чтения, тогда как дескрипторы данных доступны как для чтения, так и для записи.

Важно отметить, что дескрипторы назначаются классу, а не экземпляру класса. При изменении класса перезаписывается или удаляется сам дескриптор, а не активируется его код.

Методы дескриптора

Наконец, класс дескриптора не ограничивается наличием только трех упомянутых методов. То есть он также может содержать любой другой атрибут, кроме методов __get__(), __set__() и __delete__().

Давайте более подробно разберем методы get(), set() и delete():

  • self — это экземпляр создаваемого вами дескриптора
  • object — это экземпляр объекта, к которому прикреплен ваш дескриптор
  • type — это тип объекта, к которому присоединен дескриптор
  • value — это значение, которое присваивается атрибуту дескриптора. К примеру, get(self, object, type), set(self, object, value) или delete(self, object)
  • __get__() обращается к атрибуту, когда вы хотите извлечь некоторую информацию. Он возвращает значение атрибута или вызывает исключение AttributeError, если запрошенный атрибут отсутствует
  • __set__() вызывается в операции присвоения атрибута, которая устанавливает значение атрибута. Данный метод ничего не возвращает, но может вызвать исключение AttributeError
  • __delete__() управляет операцией удаления, т. е. служит для удаления атрибута из объекта. Также ничего не возвращает

Теперь давайте разберемся с назначением дескрипторов и разберем это на нескольких примерах!

[python_ad_block]

Назначение дескрипторов

Давайте определим класс Car, имеющий три атрибута, а именно марку, модель и объём топливного бака (make, model и fuel_cap). Мы будем использовать метод __init__() для инициализации атрибутов класса. Затем мы воспользуемся волшебной функцией __str__(), которая просто вернет вывод трех атрибутов, которые вы передадите классу при создании объекта.

Обратите внимание, что метод __str__() возвращает строковое представление объекта. Он вызывается, когда для объекта класса вызывается функция print() или str().

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car1 = Car("BMW","X7",40)
print(car1)
# BMW model X7 with a fuel capacity of 40 ltr.

Все выглядит великолепно!

Теперь давайте изменим емкость топливного бака автомобиля на -40. Выглядеть это будет следующим образом:

car2 = Car("BMW","X7",-40)
print(car2)
# BMW model X7 with a fuel capacity of -40 ltr.

Подождите, что-то не так, не правда ли? Емкость топливного бака никак не может быть отрицательной. Однако Python принимает этот ввод без ошибок. Это связано с тем, что Python — динамический язык программирования, который не поддерживает явную проверку типов.

Чтобы избежать этой проблемы, давайте добавим условие if в метод __init__() и проверим, является ли введенный объем топливного бака валидным. Если введенный объем невалидный, создадим исключение ValueError. К примеру, это можно сделать так:

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car1 = Car("BMW","X7",40)
print(car1)
# BMW model X7 with a fuel capacity of 40 ltr.
car2 = Car("BMW","X7",-40)
print(car2)
----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-22-1c3d23be72f7> in <module>
----> 1 car2 = Car("BMW","X7",-40)
      2 print(car2)


<ipython-input-20-1e154783588d> in __init__(self, make, model, fuel_cap)
      5         self.fuel_cap = fuel_cap
      6         if self.fuel_cap < 0:
----> 7             raise ValueError("Fuel Capacity can never be less than zero")
      8
      9     def __str__(self):


ValueError: Fuel Capacity can never be less than zero

Из приведенного выше вывода видно, что на данный момент все работает нормально, поскольку программа выдает ошибку ValueError, если объем топлива ниже нуля.

Однако может возникнуть другая проблема. К примеру, если введенный объем топлива является числом с плавающей запятой или строкой. Целочисленным значением может быть не только запас топлива, но и марка и модель автомобиля. Во всех этих случаях программа не сможет вызвать исключение.

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car(-40,"X7",40)
print(car2)
# -40 model X7 with a fuel capacity of 40 ltr.

Чтобы справиться с таким случаем, вы можете подумать о добавлении еще одного условия if или, возможно, использовать метод isinstance() для проверки типов.

На этот раз давайте воспользуемся встроенным методом isinstance() для обработки ошибки. К примеру, проверку можно осуществить следующим образом:

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if isinstance(self.make, str):
            print(self.make)
        else:
            raise ValueError("Make of the car can never be an integer")

        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
# BMW
# BMW model X7 with a fuel capacity of 40 ltr.
car2 = Car(-40,"X7",40)
print(car2)
----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-34-75b08cba454f> in <module>
----> 1 car2 = Car(-40,"X7",40)
      2 print(car2)


<ipython-input-31-175690bf3b98> in __init__(self, make, model, fuel_cap)
      7             print(self.make)
      8         else:
----> 9             raise ValueError("Make of the car can never be an integer")
     10
     11         if self.fuel_cap < 0:


ValueError: Make of the car can never be an integer

Здорово! Таким образом, мы смогли справиться и с этой ошибкой.

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

Однако, что делать, если позже вы захотите изменить атрибут топливной емкости на отрицательное значение -40? В данном случае это не сработает, так как проверка типов будет производиться в методе __init__() только один раз. Как вы знаете, метод __init__() является конструктором и вызывается только один раз при создании объекта класса. Следовательно, пользовательская проверка типов позже завершится ошибкой.

Давайте разберемся на примере:

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if isinstance(self.make, str):
            print(self.make)
        else:
            raise ValueError("Make of the car can never be an integer")

        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
# BMW
# BMW model X7 with a fuel capacity of 40 ltr.

car2.make = -40
print(car2)
# -40 model X7 with a fuel capacity of 40 ltr.

И вот! Вы смогли вырваться из проверки типов.

Теперь подумайте вот о чем. Что, если у вас есть много других атрибутов автомобиля? К примеру, пробег, цена, аксессуары и т. д., которые также требуют проверки типов, и вам также нужна функциональность, в которой некоторые из этих атрибутов имеют доступ только для чтения. Разве это не будет сильно раздражать?

Что ж, для решения всех вышеперечисленных проблем в Python есть дескрипторы!

Как вы узнали выше, любой класс, реализующий магические методы __get__(), __set__() или __delete__() для объекта протокола дескриптора, называется дескриптором. Они также дают вам дополнительный контроль над тем, как атрибут должен работать, например, должен ли он иметь доступ только для чтения или для чтения и записи.

Теперь давайте расширим приведенный выше пример, добавив методы дескриптора. Выглядеть это будет примерно следующим образом:

class Descriptor:
    def __init__(self):
        self.__fuel_cap = 0
    def __get__(self, instance, owner):    
        return self.__fuel_cap
    def __set__(self, instance, value):
        if isinstance(value, int):
            print(value)
        else:
            raise TypeError("Fuel Capacity can only be an integer")

        if value < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

        self.__fuel_cap = value

    def __delete__(self, instance):
        del self.__fuel_cap
class Car:
    fuel_cap = Descriptor()
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
# 40
# BMW model X7 with a fuel capacity of 40 ltr.

Не волнуйтесь, если дескриптор класса покажется сперва неясным. Давайте разобьем его на маленькие части и разберемся, что по сути делает каждый метод.

Работа каждого метода дескриптора

Метод __init__() класса дескриптора имеет нулевую локальную переменную __fuel_cap. Двойное подчеркивание в начале означает, что переменная является приватной. Оно нужно для того, чтобы отличить атрибут топливной емкости класса Descriptor от класса Car.

Как вы уже знаете, метод __get__() используется для получения атрибутов и возвращает переменную со значением емкости топлива. Он принимает три аргумента: объект дескриптора, экземпляр класса, содержащего экземпляр объекта дескриптора, т. е. car2, и, наконец, владельца — класс, к которому принадлежит экземпляр, т. е. класс Car. В этом методе вы просто возвращаете атрибут value, т.е. fuel_cap, значение которого устанавливается в методе __set__().

Метод __set__() вызывается, когда атрибуту присваивается значение, и, в отличие от метода __get__(), он ничего не возвращает. Он имеет два аргумента помимо самого объекта дескриптора, т. е. экземпляр, совпадающий с методом __get__(), и аргумент value — значение, которое вы присваиваете атрибуту. В этом методе вы проверяете, является ли значение, которое вы хотите присвоить атрибуту fuel_cap, целым числом или нет. Если нет, вы вызываете исключение TypeError. Затем в том же методе вы также проверяете, является ли значение отрицательным. И, если это так, вы вызываете исключение ValueError. После проверки на наличие ошибок вы обновляете атрибут fuel_cap равным значению.

Наконец, метод __delete__(). Он вызывается при удалении атрибута из объекта и аналогичен методу __set__(). Кроме того, он также ничего не возвращает.

Класс Car остается прежним. Единственное изменение, которое вы делаете, это добавление экземпляра fuel_cap класса Descriptor(). Обратите внимание, что, как упоминалось ранее, экземпляр класса дескриптора должен быть добавлен в класс как атрибут класса, а не как атрибут экземпляра.

Как только вы устанавливаете локальную переменную fuel_cap в методе __init__() на экземпляр fuel_cap, она вызывает метод __set__() класса Descriptor.

Тестирование дескриптора

Теперь давайте изменим запас топлива на отрицательное значение и посмотрим, вызовет ли программа исключение ValueError.

car2 = Car("BMW","X7",-40)
print(car2)
-40



----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-115-1c3d23be72f7> in <module>
----> 1 car2 = Car("BMW","X7",-40)
      2 print(car2)


<ipython-input-107-3e1f3e97d3c7> in __init__(self, make, model, fuel_cap)
      4         self.make = make
      5         self.model = model
----> 6         self.fuel_cap = fuel_cap
      7
      8     def __str__(self):


<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
     11
     12         if value < 0:
---> 13             raise ValueError("Fuel Capacity can never be less than zero")
     14
     15         self.__fuel_cap = value


ValueError: Fuel Capacity can never be less than zero

Если вы помните, раньше проверка типов при изменении значения атрибута на отрицательное число провалилась, поскольку осуществлялась только один раз, в методе __init__(). Давайте обновим значение fuel_cap до строкового значения и выясним, не приведет ли это к ошибке.

car2.fuel_cap = -1
-1



----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-120-dea9dbe96ebe> in <module>
----> 1 car2.fuel_cap = -1


<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
     11
     12         if value < 0:
---> 13             raise ValueError("Fuel Capacity can never be less than zero")
     14
     15         self.__fuel_cap = value


ValueError: Fuel Capacity can never be less than zero
car2.fuel_cap = "BMW"
----------------------------------------

TypeErrorTraceback (most recent call last)

<ipython-input-121-0b316a9872c6> in <module>
----> 1 car2.fuel_cap = "BMW"


<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
      8             print(value)
      9         else:
---> 10             raise TypeError("Fuel Capacity can only be an integer")
     11
     12         if value < 0:


TypeError: Fuel Capacity can only be an integer

Идеально! Как видите, когда вы позже обновляете атрибут топливной емкости, проверка работает.

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

Давайте разберемся в этом на примере ниже.

car3 = Car("BMW","X7",48) #created a new instance 'car3' with different values
# 48

Когда вы распечатаете экземпляр car2, вы заметите, что значения были переопределены для car3.

print(car2)
# BMW model X7 with a fuel capacity of 48 ltr.

Заключение

В этой статье мы познакомились с дескрипторами в Python, обсудили принципы их работы и особенности применения. Это руководство было предназначено для тех, кто знаком с Python и стремится освоить продвинутый уровень.

Надеемся, данная статья была вам полезна! Успехов в написании кода!

Перевод статьи «Python Descriptors».