Документация Python неофициальный перевод
Содержание страницы

Руководство по дескрипторамDescriptor Guide

Автор:

Raymond Hettinger

Контакт:

<python at rcn dot com>

Дескрипторы позволяют объектам настраивать поиск, хранение и удаление атрибутов.

В этом руководстве четыре основных раздела:

  1. «Введение» даёт базовый обзор, плавно переходя от простых примеров, добавляя по одной возможности за раз. Начните отсюда, если вы новичок в дескрипторах.

  2. Второй раздел показывает полный практический пример дескриптора. Если вы уже знаете основы, начинайте с него.

  3. Третий раздел представляет более техническое руководство, которое углубляется в детальные механизмы работы дескрипторов. Большинству людей не нужна такая степень детализации.

  4. Последний раздел содержит чистые аналоги Python для встроенных дескрипторов, написанных на C. Прочитайте его, если вам интересно, как функции превращаются в связанные методы или как реализованы распространённые инструменты, такие как classmethod(), staticmethod(), property() и __slots__.

ВведениеPrimer

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

Простой пример: дескриптор, возвращающий константуSimple example: A descriptor that returns a constant

Класс Ten – это дескриптор, у которого метод __get__() всегда возвращает константу 10:

class Ten:
    def __get__(self, obj, objtype=None):
        return 10

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

class A:
    x = 5                       # Обычный атрибут класса
    y = Ten()                   # Экземпляр дескриптора

Интерактивный сеанс показывает разницу между обычным поиском атрибута и поиском через дескриптор:

>>> a = A()                     # Создать экземпляр класса A
>>> a.x                         # Обычный поиск атрибута
5
>>> a.y                         # Поиск через дескриптор
10

При поиске атрибута a.x оператор точки находит 'x': 5 в словаре класса. При поиске a.y оператор точки находит экземпляр дескриптора, распознаваемый по его методу __get__. Вызов этого метода возвращает 10.

Обратите внимание, что значение 10 не хранится ни в словаре класса, ни в словаре экземпляра. Вместо этого значение 10 вычисляется по требованию.

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

В следующем разделе мы создадим нечто более полезное – динамический поиск.

Динамический поискDynamic lookups

Интересные дескрипторы обычно выполняют вычисления вместо возврата констант:

import os

class DirectorySize:

    def __get__(self, obj, objtype=None):
        return len(os.listdir(obj.dirname))

class Directory:

    size = DirectorySize()              # Экземпляр дескриптора

    def __init__(self, dirname):
        self.dirname = dirname          # Обычный атрибут экземпляра

Интерактивный сеанс показывает, что поиск динамический – каждый раз вычисляются разные, обновлённые ответы:

>>> s = Directory('songs')
>>> g = Directory('games')
>>> s.size                              # В каталоге songs двадцать файлов
20
>>> g.size                              # В каталоге games три файла
3
>>> os.remove('games/chess')            # Удалить игру
>>> g.size                              # Счётчик файлов обновляется автоматически
2

Помимо демонстрации того, как дескрипторы могут выполнять вычисления, этот пример также раскрывает назначение параметров __get__(). Параметр self – это size, экземпляр DirectorySize. Параметр obj – это либо g, либо s, экземпляр Directory. Именно параметр obj позволяет методу __get__() узнать целевой каталог. Параметр objtype – это класс Directory.

Управляемые атрибутыManaged attributes

Популярное применение дескрипторов – управление доступом к данным экземпляра. Дескриптор назначается публичному атрибуту в словаре класса, в то время как фактические данные хранятся как приватный атрибут в словаре экземпляра. Методы дескриптора __get__() и __set__() вызываются при обращении к публичному атрибуту.

В следующем примере age – публичный атрибут, а _age – приватный атрибут. При обращении к публичному атрибуту дескриптор регистрирует поиск или обновление:

import logging

logging.basicConfig(level=logging.INFO)

class LoggedAgeAccess:

    def __get__(self, obj, objtype=None):
        value = obj._age
        logging.info('Accessing %r giving %r', 'age', value)
        return value

    def __set__(self, obj, value):
        logging.info('Updating %r to %r', 'age', value)
        obj._age = value

class Person:

    age = LoggedAgeAccess()             # Экземпляр дескриптора

    def __init__(self, name, age):
        self.name = name                # Обычный атрибут экземпляра
        self.age = age                  # Вызывает __set__()

    def birthday(self):
        self.age += 1                   # Вызывает и __get__(), и __set__()

Интерактивный сеанс показывает, что весь доступ к управляемому атрибуту age регистрируется, а обычный атрибут name – нет:

>>> mary = Person('Mary M', 30)         # Первоначальное обновление возраста записывается
INFO:root:Updating 'age' to 30
>>> dave = Person('David D', 40)
INFO:root:Updating 'age' to 40

>>> vars(mary)                          # Фактические данные находятся в приватном атрибуте
{'name': 'Mary M', '_age': 30}
>>> vars(dave)
{'name': 'David D', '_age': 40}

>>> mary.age                            # Получить доступ к данным и записать поиск
INFO:root:Accessing 'age' giving 30
30
>>> mary.birthday()                     # Обновления также записываются
INFO:root:Accessing 'age' giving 30
INFO:root:Updating 'age' to 31

>>> dave.name                           # Обычный поиск атрибута не записывается
'David D'
>>> dave.age                            # Записывается только управляемый атрибут
INFO:root:Accessing 'age' giving 40
40

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

Настраиваемые именаCustomized names

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

В этом примере класс Person имеет два экземпляра дескриптора: name и age. Когда класс Person определён, он делает обратный вызов __set_name__() в LoggedAccess, чтобы имена полей были записаны, давая каждому дескриптору свои public_name и private_name:

import logging

logging.basicConfig(level=logging.INFO)

class LoggedAccess:

    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name

    def __get__(self, obj, objtype=None):
        value = getattr(obj, self.private_name)
        logging.info('Accessing %r giving %r', self.public_name, value)
        return value

    def __set__(self, obj, value):
        logging.info('Updating %r to %r', self.public_name, value)
        setattr(obj, self.private_name, value)

class Person:

    name = LoggedAccess()                # Первый экземпляр дескриптора
    age = LoggedAccess()                 # Второй экземпляр дескриптора

    def __init__(self, name, age):
        self.name = name                 # Вызывает первый дескриптор
        self.age = age                   # Вызывает второй дескриптор

    def birthday(self):
        self.age += 1

Интерактивный сеанс показывает, что класс Person вызвал __set_name__(), чтобы имена полей были записаны. Здесь мы вызываем vars() для поиска дескриптора без его активации:

>>> vars(vars(Person)['name'])
{'public_name': 'name', 'private_name': '_name'}
>>> vars(vars(Person)['age'])
{'public_name': 'age', 'private_name': '_age'}

Новый класс теперь регистрирует доступ как к name, так и к age:

>>> pete = Person('Peter P', 10)
INFO:root:Updating 'name' to 'Peter P'
INFO:root:Updating 'age' to 10
>>> kate = Person('Catherine C', 20)
INFO:root:Updating 'name' to 'Catherine C'
INFO:root:Updating 'age' to 20

Два экземпляра Person содержат только приватные имена:

>>> vars(pete)
{'_name': 'Peter P', '_age': 10}
>>> vars(kate)
{'_name': 'Catherine C', '_age': 20}

Заключительные мыслиClosing thoughts

Дескриптором мы называем любой объект, который определяет __get__(), __set__() или __delete__().

Дескрипторы могут дополнительно иметь метод __set_name__(). Он используется только в случаях, когда дескриптору нужно знать либо класс, в котором он был создан, либо имя переменной класса, которой он был присвоен. (Если этот метод присутствует, он вызывается, даже если класс не является дескриптором.)

Дескрипторы вызываются оператором точки при поиске атрибута. Если дескриптор доступен косвенно через vars(some_class)[descriptor_name], возвращается экземпляр дескриптора без его вызова.

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

Основная мотивация дескрипторов – предоставить точку входа, позволяющую объектам, хранящимся в переменных класса, контролировать то, что происходит во время поиска атрибута.

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

Дескрипторы используются повсеместно в языке. Именно так функции превращаются в связанные методы. Распространенные инструменты, такие как classmethod(), staticmethod(), property() и functools.cached_property(), все реализованы как дескрипторы.

Полный практический примерComplete Practical Example

В этом примере создается практичный и мощный инструмент для поиска пресловутых труднообнаруживаемых ошибок повреждения данных.

Класс ValidatorValidator class

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

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

from abc import ABC, abstractmethod

class Validator(ABC):

    def __set_name__(self, owner, name):
        self.private_name = '_' + name

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

    def __set__(self, obj, value):
        self.validate(value)
        setattr(obj, self.private_name, value)

    @abstractmethod
    def validate(self, value):
        pass

Пользовательские валидаторы должны наследовать от Validator и предоставлять метод validate() для проверки различных ограничений по мере необходимости.

Пользовательские валидаторыCustom validators

Вот три практические утилиты для проверки данных:

  1. OneOf проверяет, что значение является одним из ограниченного набора опций.

  2. Number проверяет, что значение является либо int, либо float. Опционально он проверяет, что значение находится между заданным минимумом или максимумом.

  3. String проверяет, что значение является str. Опционально он проверяет заданную минимальную или максимальную длину. Он также может проверять определенный пользователем предикат.

class OneOf(Validator):

    def __init__(self, *options):
        self.options = set(options)

    def validate(self, value):
        if value not in self.options:
            raise ValueError(
                f'Expected {value!r} to be one of {self.options!r}'
            )

class Number(Validator):

    def __init__(self, minvalue=None, maxvalue=None):
        self.minvalue = minvalue
        self.maxvalue = maxvalue

    def validate(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError(f'Expected {value!r} to be an int or float')
        if self.minvalue is not None and value < self.minvalue:
            raise ValueError(
                f'Expected {value!r} to be at least {self.minvalue!r}'
            )
        if self.maxvalue is not None and value > self.maxvalue:
            raise ValueError(
                f'Expected {value!r} to be no more than {self.maxvalue!r}'
            )

class String(Validator):

    def __init__(self, minsize=None, maxsize=None, predicate=None):
        self.minsize = minsize
        self.maxsize = maxsize
        self.predicate = predicate

    def validate(self, value):
        if not isinstance(value, str):
            raise TypeError(f'Expected {value!r} to be a str')
        if self.minsize is not None and len(value) < self.minsize:
            raise ValueError(
                f'Expected {value!r} to be no smaller than {self.minsize!r}'
            )
        if self.maxsize is not None and len(value) > self.maxsize:
            raise ValueError(
                f'Expected {value!r} to be no bigger than {self.maxsize!r}'
            )
        if self.predicate is not None and not self.predicate(value):
            raise ValueError(
                f'Expected {self.predicate} to be true for {value!r}'
            )

Практическое применениеPractical application

Вот как валидаторы данных могут быть использованы в реальном классе:

class Component:

    name = String(minsize=3, maxsize=10, predicate=str.isupper)
    kind = OneOf('wood', 'metal', 'plastic')
    quantity = Number(minvalue=0)

    def __init__(self, name, kind, quantity):
        self.name = name
        self.kind = kind
        self.quantity = quantity

Дескрипторы предотвращают создание недопустимых экземпляров:

>>> Component('Widget', 'metal', 5)      # Заблокировано: 'Widget' не в верхнем регистре
Traceback (most recent call last):
    ...
ValueError: Expected <method 'isupper' of 'str' objects> to be true for 'Widget'

>>> Component('WIDGET', 'metle', 5)      # Заблокировано: 'metle' написано с ошибкой
Traceback (most recent call last):
    ...
ValueError: Expected 'metle' to be one of {'metal', 'plastic', 'wood'}

>>> Component('WIDGET', 'metal', -5)     # Заблокировано: -5 отрицательное
Traceback (most recent call last):
    ...
ValueError: Expected -5 to be at least 0

>>> Component('WIDGET', 'metal', 'V')    # Заблокировано: 'V' не является числом
Traceback (most recent call last):
    ...
TypeError: Expected 'V' to be an int or float

>>> c = Component('WIDGET', 'metal', 5)  # Разрешено: входные данные корректны

Техническое руководствоTechnical Tutorial

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

АннотацияAbstract

Определяет дескрипторы, обобщает протокол и показывает, как дескрипторы вызываются. Приводится пример, показывающий, как работают объектно-реляционные отображения.

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

Определение и введениеDefinition and introduction

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

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

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

Протокол дескриптораDescriptor protocol

descr.__get__(self, obj, type=None)

descr.__set__(self, obj, value)

descr.__delete__(self, obj)

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

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

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

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

Обзор вызова дескрипторовOverview of descriptor invocation

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

Но чаще дескриптор вызывается автоматически при доступе к атрибуту.

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

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

Вызов из экземпляраInvocation from an instance

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

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

Логика точечного поиска находится в object.__getattribute__(). Вот эквивалент на чистом Python:

def find_name_in_mro(cls, name, default):
    "Emulate _PyType_Lookup() in Objects/typeobject.c"
    for base in cls.__mro__:
        if name in vars(base):
            return vars(base)[name]
    return default

def object_getattribute(obj, name):
    "Emulate PyObject_GenericGetAttr() in Objects/object.c"
    null = object()
    objtype = type(obj)
    cls_var = find_name_in_mro(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)

Обратите внимание: в коде __getattribute__() нет ловушки __getattr__(). Именно поэтому прямой вызов __getattribute__() или вызов с super().__getattribute__ полностью обходит __getattr__().

Вместо этого именно оператор точки и функция getattr() отвечают за вызов __getattr__() всякий раз, когда __getattribute__() возбуждает AttributeError. Их логика инкапсулирована во вспомогательной функции:

def getattr_hook(obj, name):
    "Emulate slot_tp_getattr_hook() in Objects/typeobject.c"
    try:
        return obj.__getattribute__(name)
    except AttributeError:
        if not hasattr(type(obj), '__getattr__'):
            raise
    return type(obj).__getattr__(obj, name)             # getattr

Вызов из классаInvocation from a class

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

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

Полную реализацию на C можно найти в type_getattro() и _PyType_Lookup() в Objects/typeobject.c.

Вызов из superInvocation from super

Логика точечного поиска в super находится в методе __getattribute__() объекта, возвращаемого super().

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

Полную реализацию на C можно найти в super_getattro() в Objects/typeobject.c. Эквивалент на чистом Python можно найти в руководстве Гвидо.

Сводка по логике вызоваSummary of invocation logic

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

Важно запомнить следующее:

  • Дескрипторы вызываются методом __getattribute__().

  • Классы наследуют этот механизм от object, type или super().

  • Переопределение __getattribute__() предотвращает автоматические вызовы дескрипторов, поскольку вся логика дескрипторов находится в этом методе.

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

  • Дескрипторы данных всегда переопределяют словари экземпляров.

  • Дескрипторы не-данных могут быть переопределены словарями экземпляров.

Автоматическое уведомление об имениAutomatic name notification

Иногда желательно, чтобы дескриптор знал имя переменной класса, которой он был присвоен. Когда создаётся новый класс, метакласс type сканирует словарь нового класса. Если какие-либо записи являются дескрипторами и в них определён __set_name__(), этот метод вызывается с двумя аргументами. Владелец – это класс, в котором используется дескриптор, а имя – это переменная класса, которой был присвоен дескриптор.

Детали реализации находятся в type_new() и set_names() в Objects/typeobject.c.

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

Пример ORMORM example

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

Основная идея в том, что данные хранятся во внешней базе данных. Экземпляры 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'

Эквиваленты на чистом PythonPure Python Equivalents

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

СвойстваProperties

Вызов 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() реализован на уровне протокола дескрипторов, вот эквивалент на чистом Python, реализующий большую часть основной функциональности:

class Property:
    "Emulate PyProperty_Type() in 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

    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
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError
        self.fdel(obj)

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

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

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

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

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

class Cell:
    ...

    @property
    def value(self):
        "Recalculate the cell before returning value"
        self.recalc()
        return self._value

В этом примере подойдёт как встроенная property(), так и наш эквивалент Property().

Функции и методыFunctions and methods

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

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

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

class MethodType:
    "Emulate PyMethod_Type in 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)

    def __getattribute__(self, name):
        "Emulate method_getset() in Objects/classobject.c"
        if name == '__doc__':
            return self.__func__.__doc__
        return object.__getattribute__(self, name)

    def __getattr__(self, name):
        "Emulate method_getattro() in Objects/classobject.c"
        return getattr(self.__func__, name)

    def __get__(self, obj, objtype=None):
        "Emulate method_descr_get() in Objects/classobject.c"
        return self

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

class Function:
    ...

    def __get__(self, obj, objtype=None):
        "Simulate func_descr_get() in Objects/funcobject.c"
        if obj is None:
            return self
        return MethodType(self, obj)

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

class D:
    def f(self):
         return self

class D2:
    pass

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

>>> 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 0x00B18C90>

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

Виды методовKinds of methods

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

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

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

Преобразование

Вызывается от объекта

Вызывается от класса

функция

f(obj, *args)

f(*args)

staticmethod

f(*args)

f(*args)

classmethod

f(type(obj), *args)

f(cls, *args)

Статические методыStatic methods

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

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

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

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

class E:
    @staticmethod
    def f(x):
        return x * 10
>>> E.f(3)
30
>>> E().f(3)
30

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

import functools

class StaticMethod:
    "Emulate PyStaticMethod_Type() in Objects/funcobject.c"

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

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

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

    @property
    def __annotations__(self):
        return self.f.__annotations__

Вызов functools.update_wrapper() добавляет атрибут __wrapped__, который ссылается на базовую функцию. Он также переносит атрибуты, необходимые для того, чтобы обёртка выглядела как обёрнутая функция, включая __name__, __qualname__ и __doc__.

Методы классаClass methods

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

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):
        "Emulate dict_fromkeys() in 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 выглядела бы так:

import functools

class ClassMethod:
    "Emulate PyClassMethod_Type() in Objects/funcobject.c"

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

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

Вызов functools.update_wrapper() в ClassMethod добавляет атрибут __wrapped__, который ссылается на базовую функцию. Он также переносит атрибуты, необходимые для того, чтобы обёртка выглядела как обёрнутая функция: __name__, __qualname__, __doc__ и __annotations__.

Объекты-члены и __slots__Member objects and __slots__

Когда класс определяет __slots__, он заменяет словари экземпляров массивом фиксированной длины для значений слотов. С точки зрения пользователя это имеет несколько последствий:

1. Обеспечивает немедленное обнаружение ошибок из-за опечаток в именах атрибутов. Разрешены только имена атрибутов, указанные в __slots__:

class Vehicle:
    __slots__ = ('id_number', 'make', 'model')
>>> auto = Vehicle()
>>> auto.id_nubmer = 'VYE483814LQEX'
Traceback (most recent call last):
    ...
AttributeError: 'Vehicle' object has no attribute 'id_nubmer'

2. Помогает создавать неизменяемые объекты, где дескрипторы управляют доступом к приватным атрибутам, хранящимся в __slots__:

class Immutable:

    __slots__ = ('_dept', '_name')          # Замена словаря экземпляра

    def __init__(self, dept, name):
        self._dept = dept                   # Сохранение в частный атрибут
        self._name = name                   # Сохранение в частный атрибут

    @property                               # Дескриптор только для чтения
    def dept(self):
        return self._dept

    @property
    def name(self):                         # Дескриптор только для чтения
        return self._name
>>> mark = Immutable('Botany', 'Mark Watney')
>>> mark.dept
'Botany'
>>> mark.dept = 'Space Pirate'
Traceback (most recent call last):
    ...
AttributeError: property 'dept' of 'Immutable' object has no setter
>>> mark.location = 'Mars'
Traceback (most recent call last):
    ...
AttributeError: 'Immutable' object has no attribute 'location'

3. Экономит память. В 64-битной сборке Linux экземпляр с двумя атрибутами занимает 48 байт с __slots__ и 152 байта без него. Этот шаблон «приспособленец», вероятно, имеет значение только при создании большого количества экземпляров.

4. Увеличивает скорость. Чтение переменных экземпляра происходит на 35% быстрее с __slots__ (согласно измерениям на Python 3.10 на процессоре Apple M1).

5. Блокирует инструменты вроде functools.cached_property(), которым для корректной работы требуется словарь экземпляра:

from functools import cached_property

class CP:
    __slots__ = ()                          # Устраняет словарь экземпляра

    @cached_property                        # Требуется словарь экземпляра
    def pi(self):
        return 4 * sum((-1.0)**n / (2.0*n + 1.0)
                       for n in reversed(range(100_000)))
>>> CP().pi
Traceback (most recent call last):
  ...
TypeError: No '__dict__' attribute on 'CP' instance to cache 'pi' property.

Невозможно создать точную замену __slots__ на чистом Python, поскольку для этого требуется прямой доступ к C-структурам и управление выделением памяти объектов. Однако можно построить достаточно точную симуляцию, где реальная C-структура для слотов эмулируется частным списком _slotvalues. Чтение и запись в эту частную структуру управляются дескрипторами членов:

null = object()

class Member:

    def __init__(self, name, clsname, offset):
        'Emulate PyMemberDef in Include/descrobject.h'
        # См. также descr_new() в Objects/descrobject.c
        self.name = name
        self.clsname = clsname
        self.offset = offset

    def __get__(self, obj, objtype=None):
        'Emulate member_get() in Objects/descrobject.c'
        # См. также PyMember_GetOne() в Python/structmember.c
        if obj is None:
            return self
        value = obj._slotvalues[self.offset]
        if value is null:
            raise AttributeError(self.name)
        return value

    def __set__(self, obj, value):
        'Emulate member_set() in Objects/descrobject.c'
        obj._slotvalues[self.offset] = value

    def __delete__(self, obj):
        'Emulate member_delete() in Objects/descrobject.c'
        value = obj._slotvalues[self.offset]
        if value is null:
            raise AttributeError(self.name)
        obj._slotvalues[self.offset] = null

    def __repr__(self):
        'Emulate member_repr() in Objects/descrobject.c'
        return f'<Member {self.name!r} of {self.clsname!r}>'

Метод type.__new__() отвечает за добавление объектов-членов в переменные класса:

class Type(type):
    'Simulate how the type metaclass adds member objects for slots'

    def __new__(mcls, clsname, bases, mapping, **kwargs):
        'Emulate type_new() in Objects/typeobject.c'
        # type_new() вызывает PyTypeReady(), который вызывает add_methods()
        slot_names = mapping.get('slot_names', [])
        for offset, name in enumerate(slot_names):
            mapping[name] = Member(name, clsname, offset)
        return type.__new__(mcls, clsname, bases, mapping, **kwargs)

Метод object.__new__() отвечает за создание экземпляров, которые имеют слоты вместо словаря экземпляра. Вот примерная симуляция на чистом Python:

class Object:
    'Simulate how object.__new__() allocates memory for __slots__'

    def __new__(cls, *args, **kwargs):
        'Emulate object_new() in Objects/typeobject.c'
        inst = super().__new__(cls)
        if hasattr(cls, 'slot_names'):
            empty_slots = [null] * len(cls.slot_names)
            object.__setattr__(inst, '_slotvalues', empty_slots)
        return inst

    def __setattr__(self, name, value):
        'Emulate _PyObject_GenericSetAttrWithDict() Objects/object.c'
        cls = type(self)
        if hasattr(cls, 'slot_names') and name not in cls.slot_names:
            raise AttributeError(
                f'{cls.__name__!r} object has no attribute {name!r}'
            )
        super().__setattr__(name, value)

    def __delattr__(self, name):
        'Emulate _PyObject_GenericSetAttrWithDict() Objects/object.c'
        cls = type(self)
        if hasattr(cls, 'slot_names') and name not in cls.slot_names:
            raise AttributeError(
                f'{cls.__name__!r} object has no attribute {name!r}'
            )
        super().__delattr__(name)

Чтобы использовать симуляцию в реальном классе, достаточно унаследоваться от Object и установить метакласс в Type:

class H(Object, metaclass=Type):
    'Instance variables stored in slots'

    slot_names = ['x', 'y']

    def __init__(self, x, y):
        self.x = x
        self.y = y

На этом этапе метакласс загрузил объекты-члены для x и y:

>>> from pprint import pp
>>> pp(dict(vars(H)))
{'__module__': '__main__',
 '__doc__': 'Instance variables stored in slots',
 'slot_names': ['x', 'y'],
 '__init__': <function H.__init__ at 0x7fb5d302f9d0>,
 'x': <Member 'x' of 'H'>,
 'y': <Member 'y' of 'H'>}

При создании экземпляров у них есть список slot_values, в котором хранятся атрибуты:

>>> h = H(10, 20)
>>> vars(h)
{'_slotvalues': [10, 20]}
>>> h.x = 55
>>> vars(h)
{'_slotvalues': [55, 20]}

Атрибуты с ошибками в написании или не назначенные атрибуты вызовут исключение:

>>> h.xz
Traceback (most recent call last):
    ...
AttributeError: 'H' object has no attribute 'xz'