Что такое дескрипторы и их использование в Python 3.6+

Spread the love

Что такое дескрипторы? Очень частый вопрос на собеседованиях. Сложность вопроса в том что реально в своих проектах почти ни кто не использует дескрипторы. Вы можете проработать все жизнь программистом 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:

  1. Получим значение атрибута, some_variable = obj.a
  2. Изменим его значение, obj.a = 'new value'
  3. Удалим атрибут, 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% потребностей (например, те же дескрипторов). Дескрипторы, безусловно, являются хорошим инструментом для привязки поведения к атрибутам. Хотя метаклассы потенциально могут делать то же самое, дескриптор может решить проблему более изящно.

Была ли вам полезна эта статья?
[68 / 4.7]

Spread the love
Подписаться
Уведомление о
guest
13 Комментарий
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
qwerty
qwerty
4 лет назад

Действительно, только с этой статьёй как-то всё легко просто и понятно стало с дескрипторами.
Спасибо!

Croadden
Croadden
4 лет назад

Что-то из magic методов не реализовано. Код с @quantity.setter не работал до тех пор, пока не добавил классу Order наследование от object. Python 2.7

Croadden
Croadden
4 лет назад
Reply to  Editorial Team

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

Croadden
Croadden
4 лет назад

Окончательная версия кода так же падает в строке:
> instance.__dict__[self.name] = value
В self.name происходит обращение к классу NonNegative, у которого нет атрибута name. На самом деле, мы должны обращаться к атрибуту:
> instance._name
Т.к. при вызове set name становится приватным.
Ну и опять же, наследование от object потеряли.

ardon
ardon
4 лет назад
Reply to  Croadden

я сейчас, читаю -изучаю эту тему.И хочу уточнить.
в этой строке instance.__dict__[self.name] = value —– заменить на —> instance._name = value ??

Олег
Олег
3 лет назад
Reply to  Croadden

Почему нет name? Мы же его задаём:
“`def __set_name__(self, owner, name):
self.name = name“`

В питоне 3+ наследование от object излишне.

Jhartum
3 лет назад

А если мне нужно для каждого аттрибута сделать отдельную проверку на что либо?Это как-то можно реализовать?

dev
dev
3 лет назад

Стоило отметить что instance является экземпляром класса Order. Т.е. экземпляр NonNegative меняет __dict__ “родительского” ордера

deeps
deeps
3 лет назад
Last edited 3 лет назад by deeps
Николай
Николай
3 лет назад

В примере с использованием setter’a и getter’a ошибка…

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 при инициализации объекта… Чтобы исправить ошибку в 5 строке нужно указать объект свойство вместо приватной переменной:
self.quantity = quantity

anan
anan
2 лет назад

Жаль, не могу себя пересилить и продраться через грамматические ошибки из третьего класса.

Voronwe
Voronwe
2 лет назад

В описании дескриптора неточность перевода:

If any of those methods are defined for an object, it is said to be a descriptor.

Следует перевести как:

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