Spec-Zone.ru › Python 3.11

Руководство по описателям (Descriptor HowTo)

Автор

Raymond Hettinger

Контакты

<python at rcn dot com>

  • Вводный обзор

    • Простой пример: описатель, возвращающий константу
    • Динамические запросы
    • Управляемые атрибуты
    • Настройка имен
    • Заключительные замечания
  • Полный практический пример

    • Класс валидатора
    • Настраиваемые валидаторы
    • Практическое применение
  • Техническое руководство

    • Аннотация
    • Определение и введение
    • Протокол описателей
    • Обзор вызова описателей
    • Вызов из экземпляра
    • Вызов из класса
    • Вызов из суперкласса
    • Резюме логики вызова
    • Автоматическое уведомление об именах
    • Пример ORM
  • Чисто-Python эквиваленты

    • Свойства
    • Функции и методы
    • Виды методов
    • Статические методы
    • Методы класса
    • Объекты-члены и __slots__

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

Это руководство состоит из четырёх основных разделов:

  1. Раздел «Вводный обзор» предоставляет базовые сведения, плавно переходя от простых примеров к добавлению новых возможностей. Начните с него, если вы новичок в использовании описателей.
  2. Второй раздел демонстрирует полный практический пример использования описателей. Если вы уже знакомы с основами, начните с него.
  3. Третий раздел представляет собой более техническое руководство, которое углубляется в подробные механизмы работы описателей. Большинству пользователей такой уровень детализации не нужен.
  4. В последнем разделе представлены чисто-Python эквиваленты встроенных описателей, написанных на C. Прочитайте его, если вы хотите узнать, как функции превращаются в связанные методы или как реализуются такие распространённые инструменты, как classmethod(), staticmethod(), property(), и __slots__.

Вводный обзор

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

Простой пример: описатель, возвращающий константу

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

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

Для использования описателя, его необходимо хранить в качестве переменной класса в другом классе:

class A:
    x = 5                       # Regular class attribute
    y = Ten()                   # Descriptor instance

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

>>> a = A()                     # Make an instance of class A
>>> a.x                         # Normal attribute lookup
5
>>> a.y                         # Descriptor lookup
10

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

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

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

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

Динамические запросы

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

import os

class DirectorySize:

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

class Directory:

    size = DirectorySize()              # Descriptor instance

    def __init__(self, dirname):
        self.dirname = dirname          # Regular instance attribute

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

>>> s = Directory('songs')
>>> g = Directory('games')
>>> s.size                              # The songs directory has twenty files
20
>>> g.size                              # The games directory has three files
3
>>> os.remove('games/chess')            # Delete a game
>>> g.size                              # File count is automatically updated
2

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

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

Популярное применение описателей — управление доступом к данным экземпляра. Описатель назначается публичному атрибуту в словаре класса, в то время как фактические данные хранятся в качестве приватного атрибута в словаре экземпляра. Методы __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()             # Descriptor instance

    def __init__(self, name, age):
        self.name = name                # Regular instance attribute
        self.age = age                  # Calls __set__()

    def birthday(self):
        self.age += 1                   # Calls both __get__() and __set__()

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

>>> mary = Person('Mary M', 30)         # The initial age update is logged
INFO:root:Updating 'age' to 30
>>> dave = Person('David D', 40)
INFO:root:Updating 'age' to 40

>>> vars(mary)                          # The actual data is in a private attribute
{'name': 'Mary M', '_age': 30}
>>> vars(dave)
{'name': 'David D', '_age': 40}

>>> mary.age                            # Access the data and log the lookup
INFO:root:Accessing 'age' giving 30
30
>>> mary.birthday()                     # Updates are logged as well
INFO:root:Accessing 'age' giving 30
INFO:root:Updating 'age' to 31

>>> dave.name                           # Regular attribute lookup isn't logged
'David D'
>>> dave.age                            # Only the managed attribute is logged
INFO:root:Accessing 'age' giving 40
40

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

Настройка имен

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

В этом примере класс 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()                # First descriptor instance
    age = LoggedAccess()                 # Second descriptor instance

    def __init__(self, name, age):
        self.name = name                 # Calls the first descriptor
        self.age = age                   # Calls the second descriptor

    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}

Заключительные замечания

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

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

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

Описатели работают только при использовании в качестве переменных класса. При размещении в экземплярах они не действуют.

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

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

Описатели используются на протяжении всего языка. Именно они преобразуют функции в связанные методы. Такие распространённые инструменты, как classmethod(), staticmethod(), property(), и functools.cached_property() реализованы как описатели.

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

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

Класс валидатор

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

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

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

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

  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 an 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}'
            )

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

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

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)      # Blocked: 'Widget' is not all uppercase
Traceback (most recent call last):
    ...
ValueError: Expected <method 'isupper' of 'str' objects> to be true for 'Widget'

>>> Component('WIDGET', 'metle', 5)      # Blocked: 'metle' is misspelled
Traceback (most recent call last):
    ...
ValueError: Expected 'metle' to be one of {'metal', 'plastic', 'wood'}

>>> Component('WIDGET', 'metal', -5)     # Blocked: -5 is negative
Traceback (most recent call last):
    ...
ValueError: Expected -5 to be at least 0
>>> Component('WIDGET', 'metal', 'V')    # Blocked: 'V' isn't a number
Traceback (most recent call last):
    ...
TypeError: Expected 'V' to be an int or float

>>> c = Component('WIDGET', 'metal', 5)  # Allowed:  The inputs are valid

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

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

Резюме

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

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

Определение и введение

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

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

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

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

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

descr.__set__(self, obj, value)

descr.__delete__(self, obj)

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

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

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

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

Обзор вызова дескриптора

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

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

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

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

Вызов из экземпляра

Поиск экземпляра сканирует цепочку пространств имен, придавая дескрипторам данных наивысший приоритет, затем переменным экземпляра, затем дескрипторам не данных, затем переменным класса и, наконец, __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)     # data descriptor
    if hasattr(obj, '__dict__') and name in vars(obj):
        return vars(obj)[name]                          # instance variable
    if descr_get is not null:
        return descr_get(cls_var, obj, objtype)         # non-data descriptor
    if cls_var is not null:
        return cls_var                                  # class variable
    raise AttributeError(name)

Обратите внимание, что нет __getattr__() обработчика в коде __getattribute__(). Именно поэтому вызов __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__

Вызов из класса

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

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

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

Вызов из 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 можно найти в учебнике Гвидо.

Резюме логики вызова

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

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

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

Автоматическое уведомление о имени

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

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

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

Пример 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'                    # Table name
    key = 'title'                       # Primary key
    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

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

Свойства

Вызов 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
        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"property '{self._name}' has no getter")
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError(f"property '{self._name}' has no setter")
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError(f"property '{self._name}' has no deleter")
        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. Последующие улучшения программы требуют перерасчета ячейки при каждом доступе; однако программист не хочет влиять на существующий клиентский код, обращающийся к атрибуту напрямую. Решением является упаковка доступа к атрибуту value в дескриптор свойства данных:

class Cell:
    ...

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

В этом примере сработают либо встроенная функция property(), либо наш Property() эквивалент.

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

Функционально-ориентированные возможности 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)

Для поддержки автоматического создания методов функции включают метод __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, x):
         return x

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

>>> 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).

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

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

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

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

функция

f(obj, *args)

f(*args)

staticmethod

f(*args)

f(*args)

classmethod

f(type(obj), *args)

f(cls, *args)

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

Статические методы возвращают исходную функцию без изменений. Вызов либо 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

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

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)

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

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

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

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}

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

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)
        if hasattr(type(self.f), '__get__'):
            # This code path was added in Python 3.9
            # and was deprecated in Python 3.11.
            return self.f.__get__(cls, cls)
        return MethodType(self.f, cls)

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

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

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

Объекты-члены и __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')          # Replace the instance dictionary

    def __init__(self, dept, name):
        self._dept = dept                   # Store to private attribute
        self._name = name                   # Store to private attribute

    @property                               # Read-only descriptor
    def dept(self):
        return self._dept

    @property
    def name(self):                         # Read-only descriptor
        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 байта без него. Этот паттерн проектирования «Flyweight» скорее всего, будет существенным только при большом количестве создаваемых экземпляров.

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

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

from functools import cached_property

class CP:
    __slots__ = ()                          # Eliminates the instance dict

    @cached_property                        # Requires an instance dict
    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.

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

null = object()

class Member:

    def __init__(self, name, clsname, offset):
        'Emulate PyMemberDef in Include/structmember.h'
        # Also see descr_new() in 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'
        # Also see PyMember_GetOne() in 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() calls PyTypeReady() which calls 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'

© 2001–2023 Python Software Foundation
Licensed under the PSF License.
https://docs.python.org/3.11/howto/descriptor.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API