Что такое дескрипторы и их использование в Python 3.6+
Что такое дескрипторы? Очень частый вопрос на собеседованиях. Сложность вопроса в том что реально в своих проектах почти ни кто не использует дескрипторы. Вы можете проработать все жизнь программистом python и ни разу не задействовать их ни в одном своем проекте. Но при этом вы будете почти постоянно использовать их через подключаемые сторонние библиотеки. Обычно говориться если вы захотели использовать их, остановитесь и лучше подумайте об архитектуре проекта. Возможно существует множество других более простых решений. И в большинстве случаев так и будет, за не большим исключением. Дескрипторы традиционно используются только если вы создаете ORM или новый фреймворк. Поэтому знать о них нужно, но не столько для их использования, а больше для понимания как работает магия python.
В сети можно найти множество статей описывающих что такое дескрипторы. Но большинство их них имеют достаточно перегруженное, сухое и не особо понятное описание. Поэтому я нашел и перевел, как мне кажется неплохое объяснение через использование примеров. Оригинал статьи
Вы видели похожий код или, может быть даже писали что-то подобное?
from sqlalchemy import Column, Integer, String class User(Base): id = Column(Integer, primary_key=True) name = Column(String)
Этот небольшой фрагмент был частично взят из учебника по популярной ORM библиотеки SQLAlchemy. Подобный код можно встреть наверно в любой ORM в python. А вы когда-нибудь задумывались, почему атрибуты id
и name
не передаются через метод __init__
и потом не привязываются к экземпляру класса, как это обычно делается в классе. Если да то в этой статье я расскажу, как и зачем это делается.
В начале я объясню что такое дескрипторы, зачем их использовать, как их используют в предыдущих версиях Python (<= 3.5,) и, наконец, как их использовать в Python 3.6 с новой функциональностью, описанной в PEP 487 — Упрощенная настройка создания классов
Что такое дескрипторы?
Самое известное определение дескриптора дано Раймондом Хеттингером в руководстве Descriptor HowTo Guide:
Дескриптор это атрибут объекта со “связанным поведением”, то есть такой атрибут, при доступе к которому его поведение переопределяется методом протокола дескриптора. Эти методы
__get__
,__set__
и__delete__
. Если хотя бы один из этих методов определен в объекте , то можно сказать что этот метод дескриптор.
Это описание можно встретить почти в каждой статье о дескрипторах. Но в реальности оно не особо понятное если читаешь его первый раз.
Попробуем рассказать о дескрипторах чуть проще. В python существует три варианта доступа к атрибуту. Допустим у нас есть атрибут a
объекта obj
:
- Получим значение атрибута,
some_variable = obj.a
- Изменим его значение,
obj.a = 'new value'
- Удалим атрибут,
del obj.a
Python позволяет перехватить выше упомянутые попытки доступа к атрибуту и переопределить связанное с этим доступом поведение. Это реализуется через механизм протокола дескрипторов.
Зачем нам нужны дексрипторы?
Давайте рассмотрим пример:
class Order: def __init__(self, name, price, quantity): self.name = name self.price = price self.quantity = quantity def total(self): return self.price * self.quantity apple_order = Order('apple', 1, 10) apple_order.total() # 10
Что не так с этим кодов? Если этот код начать использовать, мы столкнемся с проблемой. Наши данные ни как не проверяются. То есть цена (price) и количество (quantity) может принимать любое значение:
apple_order.quantity = -10 apple_order.total # -10, too good of a deal!
Вместо того чтобы использовать методы getter и setter и создавать новое API, давайте используем стандартный декоратор property для проверки значения атрибута quantity:
class Order: def __init__(self, name, price, quantity): self._name = name self.price = price self._quantity = quantity # (1) @property def quantity(self): return self._quantity @quantity.setter def quantity(self, value): if value < 0: raise ValueError('Cannot be negative.') self._quantity = value # (2) ... apple_order.quantity = -10 # ValueError: Cannot be negative
Мы преобразовали quantity
из простого атрибута в неотрицательное (non-negative) свойство (property). Обратите внимание на строку (1)
где мы переименовали атрибут в _quantity
что бы избежать получение в строке (2)
ошибки RecursionError
.
И это все что нам надо было сделать? Конечно нет. Мы забыли об атрибуте price
, который также не может быть отрицательным. Возможно, первым делом вы пытаетесь просто скопировать то что мы делали для атрибута _quantity
. А что если у нас будет двадцать таких атрибутов. Помните принцип DRY: когда вы обнаруживаете, что делаете одно и то же дважды, это хороший признак для удаления повторно используемого кода. Давайте посмотрим, чем нам в этом случае могут помочь дескрипторы.
Как использовать дескрипторы
При использовании дескрипторов наше новое определение класса станет таким:
class Order: price = NonNegative('price') # (3) quantity = NonNegative('quantity') def __init__(self, name, price, quantity): self._name = name self.price = price self.quantity = quantity def total(self): return self.price * self.quantity apple_order = Order('apple', 1, 10) apple_order.total() # 10 apple_order.price = -10 # ValueError: Cannot be negative apple_order.quantity = -10 # ValueError: Cannot be negative
Обратите внимание на атрибуты класса определенные до метода __init__
Это очень похоже на пример от SQLAlchemy приведенный в начале статьи. Теперь нам нужно создать класс NonNegative
и реализовать протокол дескрипторов:
class NonNegative: def __init__(self, name): self.name = name # (4) def __get__(self, instance, owner): return instance.__dict__[self.name] # (5) def __set__(self, instance, value): if value < 0: raise ValueError('Cannot be negative.') instance.__dict__[self.name] = value # (6)
Строка (4)
: атрибут name
необходим при создание объекта NonNegative
в строке (3)
, в данные момент еще не происходит изменения значения price
. Таким образом, мы явно передаем имя атрибута price
что бы использовать его как ключ при доступе к экземпляру __dict__
.
Позже мы увидим, как в Python 3.6+ мы можем избежать текущей избыточности кода.
Избыточности можно было бы избежать в более ранних версиях Python, но я думаю, что для объяснения потребуется слишком много усилий, и цель этого поста не в этом.
Строки (5)
и (6)
: вместо использования встроенный функций getattr
и setattr
, мы напрямую обращаемся к объекту __dict__
, что бы избежать ошибки RecursionError
, так как обращение через встроенные функции будут так же перехватываться протоколом дескрипторов.
Добро пожаловать в Python 3.6+
У нас все еще есть избыточность кода в строке (3)
. Как нам написать более чистое API по типу:
class Order: price = NonNegative() quantity = NonNegative() def __init__(self, name, price, quantity): ...
Давайте используем новый протокол дескрипторов появившийся в Python 3.6:
object.__set_name__(self, owner, name)
- Вызывается во время создания класса. В этом случае дескриптор назначается на имя атрибута.
С этим протоколом, мы можем удалить __init__
и привязать имя атрибута к дескриптору:
class NonNegative: ... def __set_name__(self, owner, name): self.name = name
Теперь окончательная версия нашего кода:
class NonNegative: def __get__(self, instance, owner): return instance.__dict__[self.name] def __set__(self, instance, value): if value < 0: raise ValueError('Cannot be negative.') instance.__dict__[self.name] = value def __set_name__(self, owner, name): self.name = name class Order: price = NonNegative() quantity = NonNegative() def __init__(self, name, price, quantity): self._name = name self.price = price self.quantity = quantity def total(self): return self.price * self.quantity apple_order = Order('apple', 1, 10) apple_order.total() # 10 apple_order.price = -10 # ValueError: Cannot be negative apple_order.quantity = -10 # ValueError: Cannot be negative
Заключение
Python — это язык программирования общего назначения. Мне нравится, что он не только обладает очень мощными функционалом, которые очень гибок (например, использование мета-классов), а также имеет высокоуровневое API для решения 99% потребностей (например, те же дескрипторов). Дескрипторы, безусловно, являются хорошим инструментом для привязки поведения к атрибутам. Хотя метаклассы потенциально могут делать то же самое, дескриптор может решить проблему более изящно.
Действительно, только с этой статьёй как-то всё легко просто и понятно стало с дескрипторами.
Спасибо!
Что-то из magic методов не реализовано. Код с @quantity.setter не работал до тех пор, пока не добавил классу Order наследование от object. Python 2.7
В Python 2.X вроде бы явное наследование от object обязательно.
На самом деле, нет. Вполне можно реализовать класс, не наследуясь от object 🙂
Другое дело, что возможны баги, как я и указал выше.
Окончательная версия кода так же падает в строке:
> instance.__dict__[self.name] = value
В self.name происходит обращение к классу NonNegative, у которого нет атрибута name. На самом деле, мы должны обращаться к атрибуту:
> instance._name
Т.к. при вызове set name становится приватным.
Ну и опять же, наследование от object потеряли.
я сейчас, читаю -изучаю эту тему.И хочу уточнить.
в этой строке instance.__dict__[self.name] = value —— заменить на —> instance._name = value ??
Почему нет name? Мы же его задаём:
«`def __set_name__(self, owner, name):
self.name = name«`
В питоне 3+ наследование от object излишне.
А если мне нужно для каждого аттрибута сделать отдельную проверку на что либо?Это как-то можно реализовать?
Стоило отметить что instance является экземпляром класса Order. Т.е. экземпляр NonNegative меняет __dict__ «родительского» ордера
Документация на русском по дескрипторам
https://digitology.tech/docs/python_3/howto/descriptor.html
https://digitology.tech/docs/python_3/reference/datamodel.html#descriptors
В примере с использованием setter’a и getter’a ошибка…
Данный код позволяет указать отрицательное значение для quantity при инициализации объекта… Чтобы исправить ошибку в 5 строке нужно указать объект свойство вместо приватной переменной:
self.quantity = quantity
Жаль, не могу себя пересилить и продраться через грамматические ошибки из третьего класса.
В описании дескриптора неточность перевода:
Следует перевести как: