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

Базовый обзор дескриптора класса в Python

Этот материал дает базовый обзор дескриптора класса, плавно переходя от простых примеров, добавляя по одной функции за раз. Начните здесь, если вы новичок в дескрипторах.

Смотрите также раздел "Как работают дескрипторы классов в Python".

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

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

class Ten:
    """Класс дескриптора"""

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

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

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

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

# экземпляр класса A
>>> a = A()
# поиск атрибута
>>> a.x
# 5

# поиск дескриптора
>>> a.y
# 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()

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

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

>>> s = Directory('songs')
>>> g = Directory('games')
# папка `songs` содержит 20 файлов
>>> s.size
# 20

# папка `games` содержит 3 файла
>>> g.size
# 3

# например удалим файл `chess` из папки `games`
>>> os.remove('games/chess')
# кол-во файлов обновляется автоматически
>>> g.size
# 2

Помимо демонстрации того, как дескрипторы могут выполнять вычисления, этот пример показывает назначение аргументов для метода __get__():

  • Аргумент self - это экземпляр дескриптора DirectorySize, то есть атрибут size.
  • Аргумент obj является либо экземпляром g, либо s, который позволяет методу __get__() узнать целевой экземпляр класса, в данном случае это Directory.
  • Аргумент 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('Доступ к %r дающий %r', 'age', value)
        return value

    def __set__(self, obj, value):
        logging.info('Обновление %r до %r', 'age', value)
        obj._age = value

class Person:

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

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

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

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

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

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

# Доступ к данным и регистрация поиска
>>> mary.age
# INFO:root:Доступ к 'age' дающий 30
# 30

# Обновления также регистрируются
>>> mary.birthday()
# INFO:root:Доступ к 'age' дающий 30
# INFO:root:Обновление 'age' до 31

# Обычный поиск атрибутов не регистрируется
>>> dave.name
# 'David D'

# Регистрируется только управляемый атрибут
>>> dave.age
# INFO:root:Доступ 'age' дающий 40
# 40

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

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

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

В следующем примере класс Person имеет два экземпляра дескриптора LoggedAccess - это 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('Доступ %r дающий %r', self.public_name, value)
        return value

    def __set__(self, obj, value):
        logging.info('Обновление %r до %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

Изменено в Python 3.12: исключения, возникающие в методе __set_name__ класса или типа, больше не переносятся с помощью RuntimeError. Контекстная информация добавляется к исключению в виде примечания. (Предоставлено Ирит Катриэль.)

При использовании интерактивного сеанса видно, что класс Person вызвал __set_name__() для записи имен полей. Для поиска дескриптора, без его физического вызова, можно использовать встроенную функцию vars():

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

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

>>> pete = Person('Peter P', 10)
# INFO:root:Обновление 'name' до 'Peter P'
# INFO:root:Обновление 'age' до 10
>>> kate = Person('Catherine C', 20)
# INFO:root:Обновление 'name' до 'Catherine C'
# INFO:root:Обновление 'age' до 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], экземпляр дескриптора возвращается без его фактического вызова.
  • Дескрипторы работают только при использовании в качестве переменных класса. Когда они помещаются в экземпляры, они не имеют никакого эффекта.
  • Основной мотивацией для дескрипторов является предоставление хука, позволяющего объектам, хранящимся в переменных класса, управлять тем, что происходит во время поиска атрибутов.
  • Традиционно вызывающий класс контролирует, что происходит во время поиска.
  • Дескрипторы используются во всем языке Python. Именно так функции превращаются в связанные методы. Встроенные функции, такие как classmethod(), staticmethod(), property() и functools.cached_property(), реализованы как дескрипторы.