Сегодня мы поговорим про то, что такое дескрипторы в 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».