Руководство по использованию дескрипторов

Автор

Раймонд Хеттингер

Связаться с

<python at rcn dot com>

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

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

  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. Это означает, что каждый экземпляр может иметь только один атрибут 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}

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

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

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

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

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

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

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

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

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

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

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

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

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

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. По желанию, проверяется заданная минимальная или максимальная длина. Он также может проверять значение, определенное пользователем predicate.

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__(). Если для атрибута определен любой из этих методов, то говорят, что это descriptor.

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

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

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

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

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

descr.__delete__(self, obj) -> None

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

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

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

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

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

Дескриптор можно вызвать напрямую с помощью 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__(), но поиск в словаре экземпляров заменен поиском в словаре класса method resolution order.

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

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

Вызов от супервайзера

Логика точечного поиска 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 эквивалент можно найти в Guido’s Tutorial.

Краткое описание логики вызова

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

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

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

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

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

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

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

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

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

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

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

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

Пример ORM

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

Основная идея заключается в том, что данные хранятся во внешней базе данных. В экземплярах 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 для определения models, которые описывают схему для каждой таблицы в базе данных:

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'unreadable attribute {self._name}')
        return self.fget(obj)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 выглядела бы следующим образом:

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

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

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

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

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

Объекты-члены и __слоты__

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

1. Provides immediate detection of bugs due to misspelled attribute assignments. Only attribute names specified in __slots__ are allowed:

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. Helps create immutable objects where descriptors manage access to private attributes stored in __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: can't set attribute
>>> mark.location = 'Mars'
Traceback (most recent call last):
    ...
AttributeError: 'Immutable' object has no attribute 'location'

3. Saves memory. On a 64-bit Linux build, an instance with two attributes takes 48 bytes with __slots__ and 152 bytes without. This flyweight design pattern likely only matters when a large number of instances are going to be created.

4. Improves speed. Reading instance variables is 35% faster with __slots__ (as measured with Python 3.10 on an Apple M1 processor).

5. Blocks tools like functools.cached_property() which require an instance dictionary to function correctly:

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

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

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

    def __new__(cls, *args):
        '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'{type(self).__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'{type(self).__name__!r} object has no attribute {name!r}'
            )
        super().__delattr__(name)

Чтобы использовать симуляцию в реальном классе, просто наследуйте от Object и установите metaclass в 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'
Вернуться на верх