Spec-Zone.ru › Python 3.13

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

Автор:

Raymond Hettinger

Контакт:

<python at rcn dot com>

  • Введение

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

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

    • Краткое описание
    • Определение и введение
    • Протокол дескриптора
    • Обзор вызова дескриптора
    • Вызов из экземпляра
    • Вызов из класса
    • Вызов из super
    • Краткое описание логики вызова
    • Автоматическое уведомление об имени
    • Пример 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

При поиске атрибута a.x оператор точки находит 'x': 5 в словаре класса. При поиске a.y оператор точки находит экземпляр дескриптора, распознаваемый по методу __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

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

Этот класс 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 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}'
            )

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

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

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)

Обратите внимание: в коде __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__

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

Логика поиска по точечной нотации, например 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

    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. Последующие улучшения программы требуют пересчитывать ячейку при каждом обращении к ней; однако программист не хочет затрагивать существующий клиентский код, обращающийся к атрибуту напрямую. Решение — обернуть доступ к атрибуту value в дескриптор данных property:

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)

    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, вот ответ!

Виды методов

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

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

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

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

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

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

функция

f(obj, *args)

f(*args)

статический метод

f(*args)

f(*args)

метод класса

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

Вызов 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}

Если использовать протокол дескрипторов, не являющихся дескрипторами данных, версия 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__

Когда класс определяет __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 байта без него. Этот шаблон проектирования «Приспособленец», вероятно, имеет значение только при создании большого количества экземпляров.

4. Увеличивает скорость. Чтение переменных экземпляра с __slots__ выполняется на 35 % быстрее (по результатам измерений на 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.

Невозможно создать точную замену __slots__ на чистом Python, поскольку для этого требуется прямой доступ к структурам 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–2025 Python Software Foundation
Licensed under the PSF License.
https://docs.python.org/3.13/howto/descriptor.html

Spec-Zone.ru

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