Сообщить об ошибке.

Протокол дескриптора класса в Python

Технические аспекты дескриптора класса

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

В общем случае, дескриптор - это атрибут объекта с "привязкой поведения", доступ к которому был переопределен методами в протоколе дескриптора. Этими методами являются __get__(), __set__() и __delete__(). Если какой-либо из этих методов определен для объекта, то он называется дескриптором.

Поведение по умолчанию для доступа к атрибуту заключается в получении __get__(), установке __set__() или удалении атрибута __delete__() из словаря объекта. Например, a.x имеет цепочку поиска, начинающуюся с a.__dict__['x'], затем type(a).__dict__['x'] и продолжающуюся через базовые классы type(a), исключая метаклассы. Если искомое значение является объектом, который определяет один из методов дескриптора, то Python переопределяет поведение по умолчанию и вызывает вместо него метод дескриптора. Это происходит в так называемой цепочке приоритетов, и зависит от того, какие методы дескриптора были определены.

Смотрите также разделы:

Содержание:


Протокол дескрипторов класса.

descr.__get__(self, obj, type=None) -> value

descr.__set__(self, obj, value) -> None

descr.__delete__(self, obj) -> None

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

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

Дескрипторы данных и дескрипторы без данных различаются тем, как вычисляются переопределения по отношению к записям в словаре экземпляра.

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

Чтобы создать дескриптор данных только для чтения, необходимо определить методы как __get__(), так и __set__(), при этом метод __set__() должен вызывать исключение AttributeError. Определение метода __set__() с raise, достаточно, чтобы сделать его дескриптором данных.

Вызов дескрипторов.

Дескриптор можно вызвать напрямую с помощью desc.__get__(obj) или desc.__get__(None, cls). Но чаще всего дескриптор вызывается автоматически при доступе к атрибуту.

Выражение obj.x ищет атрибут x в цепочке пространств имен для obj. Если поиск находит дескриптор за пределами экземпляра __dict__, то вызывается его метод __get__() в соответствии с правилами приоритета, перечисленными ниже.

Детали вызова зависят от того, является ли obj объектом, классом или экземпляром super().

Вызов дескриптора из экземпляра класса.

Поиск в экземпляре класса просматривает цепочку пространств имен, и присваивает дескрипторам данных наивысший приоритет, за которыми следуют переменные экземпляра, затем дескрипторы, не относящиеся к данным, затем переменные класса и, наконец, __getattr__(), если он предоставлен.

Если для экземпляра a.x найден дескриптор, то он вызывается с помощью: desc.__get__(a, type(a)).

Для экземпляра, логика поиска при доступе через точку, как a.x, находится в object.__getattribute__(). Вот чистый эквивалент на Python:

def object_getattribute(obj, name):
    "Эмуляция PyObject_GenericGetAttr() в Objects/object.c"
    null = object()
    objtype = type(obj)
    cls_var = getattr(objtype, name, null)
    descr_get = getattr(type(cls_var), '__get__', null)
    if descr_get is not null:
        if (hasattr(type(cls_var), '__set__')
            or hasattr(type(cls_var), '__delete__')):
            # дескриптор данных
            return descr_get(cls_var, obj, objtype)
    if hasattr(obj, '__dict__') and name in vars(obj):
        # переменная экземпляра
        return vars(obj)[name]
    if descr_get is not null:
        # дескриптор, не содержащий данных
        return descr_get(cls_var, obj, objtype)
    if cls_var is not null:
        # переменная класса
        return cls_var
    raise AttributeError(name)

Интересно, что поиск атрибутов не вызывает object.__getattribute__() напрямую. Вместо этого и обращение через точку (a.x), и функция getattr() выполняют поиск атрибутов с помощью вспомогательной функции:

def getattr_hook(obj, name):
    "эмуляция slot_tp_getattr_hook() в Objects/typeobject.c"
    try:
        return obj.__getattribute__(name)
    except AttributeError:
        if not hasattr(type(obj), '__getattr__'):
            raise
    # __getattr__
    return type(obj).__getattr__(obj, name)

Итак, если __getattr__() существует, то он вызывается всегда, когда __getattribute__() вызывает исключение AttributeError (либо напрямую, либо в одном из вызовов дескриптора).

Кроме того, если пользователь вызывает object.__getattribute__() напрямую, то хук __getattr__() полностью игнорируется.

Вызов дескриптора из класса.

Для класса, логика поиска при доступе через точку, такого как A.x, находится в type.__getattribute__(). Шаги аналогичны шагам для object.__getattribute__(), но поиск в словаре экземпляра заменен поиском в порядке разрешения методов класса.

Если дескриптор найден, то он вызывается с помощью desc.__get__(None, A).

Вызов дескриптора из super().

При использовании super() логика поиска, при доступе через точку, находится в методе __getattribute__(), для объекта, возвращаемого функцией super().

При вызове super(A, obj).m, будет выполнятся поиск в obj.__class__.__mro__ для базового класса B сразу после A, а затем возвращает B.__dict__['m'].__get__(obj, A). Если это не дескриптор, то m возвращается без изменений.

Обобщение логики вызова дескриптора.

Механизм дескрипторов встроен в методы __getattribute__() для объектов, типов и super().

Важные моменты, которые следует помнить:

  • дескрипторы классов вызываются методом __getattribute__(),
  • классы наследуют этот механизм от объекта, типа или super(),
  • переопределение __getattribute__() предотвращает автоматические вызовы дескриптора, т.к. вся логика дескриптора находится в этом методе,
  • object.__getattribute__() и type.__getattribute__() выполняют разные вызовы метода __get__(). Первый включает экземпляр и может включать класс. Второй помещает None для экземпляра и всегда включает класс
  • дескрипторы данных всегда имеют приоритет над словарями экземпляров.
  • дескрипторы, не относящиеся к данным, могут быть переопределены словарями экземпляров.

Связывание дескриптора с именем переменной класса.

Иногда желательно, чтобы дескриптор знал, какое имя переменной класса ему было присвоено. Когда создается новый класс, то метакласс типа сканирует словарь нового класса. Если какая-либо из записей является дескриптором и если они определяют __set_name__(), то этот метод вызывается с двумя аргументами. Аргумент owner - это класс, в котором используется дескриптор, а аргумент name - переменная класса, которой был присвоен дескриптор.

Так как логика обновления имеет type.__new__(), то уведомления появляются только во время создания класса. Если дескрипторы будут добавлены в класс впоследствии, то метод __set_name__() необходимо будет вызвать вручную.

Пример использования дескрипторов.

Следующий код представляет собой упрощенную схему, которая показывает, как дескрипторы данных можно использовать для реализации объектно-реляционного сопоставления.

Основная идея заключается в том, что данные хранятся во внешней базе данных. Экземпляры Python содержат только ключи к таблицам базы данных. Дескрипторы заботятся о поиске или обновлении информации:

class Field:

    def __set_name__(self, owner, name):
        self.fetch = f'SELECT {name} FROM {owner.table} WHERE {owner.key}=?;'
        self.store = f'UPDATE {owner.table} SET {name}=? WHERE {owner.key}=?;'

    def __get__(self, obj, objtype=None):
        return conn.execute(self.fetch, [obj.key]).fetchone()[0]

    def __set__(self, obj, value):
        conn.execute(self.store, [value, obj.key])
        conn.commit()

Класс Field можно использовать для определения моделей, описывающих схему для каждой таблицы в базе данных:

class Movie:
    # Имя таблицы
    table = 'Movies'
    # Первичный ключ
    key = 'title'
    director = Field()
    year = Field()

    def __init__(self, key):
        self.key = key

class Song:
    table = 'Music'
    key = 'title'
    artist = Field()
    year = Field()
    genre = Field()

    def __init__(self, key):
        self.key = key

Чтобы использовать модели, сначала подключаемся к базе данных:

>>> import sqlite3
>>> conn = sqlite3.connect('entertainment.db')

Если запустить интерпретатор в интерактивном сеансе, то можно проследить как данные извлекаются из базы данных и как происходит их обновление:

>>> Movie('Star Wars').director
# 'George Lucas'
>>> jaws = Movie('Jaws')
>>> f'Released in {jaws.year} by {jaws.director}'
# 'Released in 1975 by Steven Spielberg'

>>> Song('Country Roads').artist
# 'John Denver'

>>> Movie('Star Wars').director = 'J.J. Abrams'
>>> Movie('Star Wars').director
# 'J.J. Abrams'

Примеры дескрипторов во встроенных инструментах Python.

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

Функция property() как дескриптор данных.

Вызов функции property() - это краткий способ создания дескриптора данных, который инициирует вызов функции при доступе к атрибуту.

property(fget=None, fset=None, fdel=None, doc=None) -> property

В документации показано типичное использование для определения управляемого атрибута x:

class C:
    def getx(self): return self.__x
    def setx(self, value): self.__x = value
    def delx(self): del self.__x
    x = property(getx, setx, delx, "I'm the 'x' property.")

Вот как реализована функция property() с точки зрения протокола дескриптора:

class Property:
    "Это эмуляция PyProperty_Type() в Objects/descrobject.c"

    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        if doc is None and fget is not None:
            doc = fget.__doc__
        self.__doc__ = doc
        self._name = ''

    def __set_name__(self, owner, name):
        self._name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError(f'unreadable attribute {self._name}')
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError(f"can't set attribute {self._name}")
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError(f"can't delete attribute {self._name}")
        self.fdel(obj)

    def getter(self, fget):
        prop = type(self)(fget, self.fset, self.fdel, self.__doc__)
        prop._name = self._name
        return prop

    def setter(self, fset):
        prop = type(self)(self.fget, fset, self.fdel, self.__doc__)
        prop._name = self._name
        return prop

    def deleter(self, fdel):
        prop = type(self)(self.fget, self.fset, fdel, self.__doc__)
        prop._name = self._name
        return prop

Встроенная функция property() всегда помогает, когда пользовательский интерфейс предоставляет доступ к атрибуту, а последующие изменения этого атрибута, требуют вмешательства метода.

Например, класс электронной таблицы может предоставить доступ к значению ячейки через Cell('b10').value. Последующие усовершенствования программы требуют, чтобы ячейка пересчитывалась при каждом доступе, но программист не хочет влиять на существующий клиентский код, который обращается к атрибуту напрямую. Решение состоит в том, чтобы обернуть доступ к значению атрибута в свойство дескриптора данных:

class Cell:
    ...

    @property
    def value(self):
        "Пересчет ячейки перед возвращением значения"
        self.recalc()
        return self._value

Дескрипторы в функциях и методах.

Объектно-ориентированные функции (методы) в Python основаны на простых функциях. Функции и методы легко объединяются, используя дескрипторы, не относящиеся к данным.

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

Методы можно создавать вручную с помощью types.MethodType, что примерно эквивалентно:

class MethodType:
    "Эмуляция PyMethod_Type в Objects/classobject.c"

    def __init__(self, func, obj):
        self.__func__ = func
        self.__self__ = obj

    def __call__(self, *args, **kwargs):
        func = self.__func__
        obj = self.__self__
        return func(obj, *args, **kwargs)

Для поддержки автоматического создания методов, функции включают метод __get__() для связывания методов во время доступа к атрибуту. Это означает, что функции не являются дескрипторами данных, которые возвращают связанные методы во время обращения через точку из экземпляра. Вот как это работает:

class Function:
    ...

    def __get__(self, obj, objtype=None):
        "Эмуляция func_descr_get() в Objects/funcobject.c"
        if obj is None:
            return self
        return MethodType(self, obj)

Запуск следующего класса в интерпретаторе показывает, как на практике работает дескриптор функции:

class D:
    def f(self, x):
         return x

Функция имеет атрибут __qualname__ для поддержки самоанализа:

>>> D.f.__qualname__
# 'D.f'

Доступ к функции через словарь класса не вызывает __get__(). Он просто возвращает базовый объект функции:

>>> D.__dict__['f']
# <function D.f at 0x00C45070>

Обращение по точке из класса вызывает __get__(), который просто возвращает базовую функцию без изменений:

>>> D.f
# <function D.f at 0x00C45070>

Интересное поведение возникает при доступе из экземпляра. Поиск вызывает __get__(), который возвращает связанный объект метода:

>>> d = D()
>>> d.f
# <bound method D.f of <__main__.D object at 0x00B18C90>>

Внутри связанный метод хранит базовую функцию и связанный экземпляр:

>>> d.f.__func__
# <function D.f at 0x00C45070>

>>> d.f.__self__
# <__main__.D object at 0x1012e1f98>

Если вы когда-нибудь задумывались, откуда берется self в обычных методах или откуда берется cls в методах класса, то вот оно!

Связывание простых функций в методы.

Дескрипторы, не относящиеся к данным, обеспечивают простой механизм для вариаций обычных шаблонов связывания функций в методы.

Напомним, что у функций есть метод __get__(), так что их можно преобразовать в метод при обращении к ним как к атрибутам. Дескриптор без данных преобразует вызов obj.f(*args) в f(obj, *args). Вызов cls.f(*args) становится f(*args).

Эта таблица суммирует привязку и два ее наиболее полезных варианта:

ПреобразованиеВызов из объектаВызов из класса
functionf(obj, *args)f(*args)
staticmethodf(*args)f(*args)
classmethodf(type(obj), *args)f(cls, *args)

Функция staticmethod() как дескриптор без данных.

Статические методы возвращают базовую функцию без изменений. Вызов c.f или C.f эквивалентен прямому поиску в object.__getattribute__(c, "f") или object.__getattribute__(C, "f"). В результате функция становится одинаково доступной либо из объекта, либо из класса.

Хорошими кандидатами в статические методы являются методы, которые не ссылаются на переменную self.

Например, пакет статистики может включать класс-контейнер для экспериментальных данных. Класс предоставляет обычные методы для вычисления среднего, медианы и других описательных статистик, зависящих от данных. Но могут быть полезные функции, которые концептуально связаны, но не зависят от данных. Например, erf(x) - это удобная процедура преобразования, которая используется в статистической работе, но не зависит напрямую от конкретного набора данных. Такой метод можно вызвать либо из объекта, либо из класса: s.erf(1.5) => .9332 или Sample.erf(1.5) => .9332.

Так как статические методы возвращают базовую функцию без изменений, примеры вызовов неинтересны:

class E:
    @staticmethod
    def f(x):
        return x * 10

>>> E.f(3)
# 30
>>> E().f(3)
# 30

Используя протокол, который не содержит дескрипторов данных, версия функции staticmethod() на Python будет выглядеть следующим образом

class StaticMethod:
    "Эмуляция PyStaticMethod_Type() в Objects/funcobject.c"

    def __init__(self, f):
        self.f = f

    def __get__(self, obj, objtype=None):
        return self.f

    def __call__(self, *args, **kwds):
        return self.f(*args, **kwds)

Функция classmethod() как дескриптор без данных.

В отличие от статических методов, методы класса добавляют ссылку на класс к списку аргументов перед вызовом функции. Этот формат одинаков для того, является ли вызывающий объект объектом или классом:

class F:
    @classmethod
    def f(cls, x):
        return cls.__name__, x

>>> F.f(3)
# ('F', 3)
>>> F().f(3)
# ('F', 3)

Такое поведение полезно, когда методу нужна только ссылка на класс, и он не зависит от данных, хранящихся в конкретном экземпляре. Одним из способов использования методов класса является создание альтернативных конструкторов классов. Например, метод класса dict.fromkeys() создает новый словарь из списка ключей. Чистый эквивалент Python:

class Dict(dict):
    @classmethod
    def fromkeys(cls, iterable, value=None):
        "Эмуляция dict_fromkeys() в Objects/dictobject.c"
        d = cls()
        for key in iterable:
            d[key] = value
        return d

Теперь новый словарь уникальных ключей можно построить следующим образом:

>>> d = Dict.fromkeys('abracadabra')
>>> type(d) is Dict
# True
>>> d
# {'a': None, 'b': None, 'r': None, 'c': None, 'd': None}

Используя протокол дескриптора без данных, версия функции classmethod() на чистом Python будет выглядеть так:

class ClassMethod:
    "Эмуляция PyClassMethod_Type() в Objects/funcobject.c"

    def __init__(self, f):
        self.f = f

    def __get__(self, obj, cls=None):
        if cls is None:
            cls = type(obj)
        if hasattr(type(self.f), '__get__'):
            return self.f.__get__(cls, cls)
        return MethodType(self.f, cls)

Путь к коду для hasattr(type(self.f), '__get__') был добавлен в Python 3.9 и позволяет classmethod() поддерживать связанные декораторы. Например, метод класса и свойство могут быть объединены в цепочку:

class G:
    @classmethod
    @property
    def __doc__(cls):
        return f'A doc for {cls.__name__!r}'

>>> G.__doc__
# "A doc for 'G'"