Descriptor Guide

Автор:

Raymond Hettinger

контакт:

<python at rcn dot com>

Дескриптори дозволяють об’єктам налаштовувати пошук атрибутів, зберігання та видалення.

Цей посібник складається з чотирьох основних розділів:

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

Новий клас тепер реєструє доступ як до ім’я, так і до віку:

>>> 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__(). Це використовується лише у випадках, коли дескриптору потрібно знати або клас, у якому він був створений, або назву змінної класу, якій він був призначений. (Цей метод, якщо він присутній, викликається, навіть якщо клас не є дескриптором.)

Дескриптори викликаються оператором крапки під час пошуку атрибутів. Якщо до дескриптора звертаються опосередковано за допомогою 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. За бажанням він перевіряє задану мінімальну або максимальну довжину. Він також може перевірити визначений користувачем предикат.

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)

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

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

The full C implementation can be found in type_getattro() and _PyType_Lookup() in Objects/typeobject.c.

Заклик від супер

Логіка пошуку з пунктиром super полягає в методі __getattribute__() для об’єкта, який повертає super().

Пошук із пунктиром, наприклад super(A, obj).m, шукає obj.__class__.__mro__ для базового класу B відразу після A, а потім повертає B". __dict__['m'].__get__(obj, A). Якщо не є дескриптором, m повертається без змін.

The full C implementation can be found in super_getattro() in Objects/typeobject.c. A pure Python equivalent can be found in Guido’s Tutorial.

Короткий опис логіки виклику

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

Важливо пам’ятати:

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

  • Класи успадковують цей механізм від object, type або super().

  • Перевизначення __getattribute__() запобігає автоматичним викликам дескриптора, оскільки вся логіка дескриптора міститься в цьому методі.

  • object.__getattribute__() і type.__getattribute__() здійснюють різні виклики __get__(). Перший включає екземпляр і може включати клас. Другий ставить None для екземпляра та завжди включає клас.

  • Дескриптори даних завжди перевизначають словники примірників.

  • Дескриптори, не пов’язані з даними, можуть бути перевизначені словниками примірників.

Автоматичне сповіщення про ім’я

Іноді дескриптору бажано знати, яке ім’я змінної класу було призначено. Коли створюється новий клас, метаклас type сканує словник нового класу. Якщо будь-який із записів є дескрипторами та якщо вони визначають __set_name__(), цей метод викликається з двома аргументами. Власник — це клас, де використовується дескриптор, а ім’я — це змінна класу, якій призначено дескриптор.

The implementation details are in type_new() and set_names() in Objects/typeobject.c.

Оскільки логіка оновлення знаходиться в type.__new__(), сповіщення надходять лише під час створення класу. Якщо дескриптори додаються до класу пізніше, __set_name__() потрібно буде викликати вручну.

Приклад ORM

The following code is a simplified skeleton showing how data descriptors could be used to implement an 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, щоб визначити моделі, які описують схему для кожної таблиці в базі даних:

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

Протокол дескриптора простий і пропонує захоплюючі можливості. Кілька варіантів використання настільки поширені, що вони були попередньо упаковані у вбудовані інструменти. Властивості, прив’язані методи, статичні методи, методи класу та __слоти__ базуються на протоколі дескриптора.

Властивості

Виклик 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!r} of {type(obj).__name__!r} object has no getter'
             )
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError(
                f'property {self._name!r} of {type(obj).__name__!r} object has no setter'
             )
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError(
                f'property {self._name!r} of {type(obj).__name__!r} object 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. Подальші вдосконалення програми вимагають перерахунку комірки при кожному доступі; однак програміст не хоче впливати на існуючий клієнтський код, який безпосередньо звертається до атрибута. Рішення полягає в тому, щоб загорнути доступ до атрибута значення в дескриптор даних властивості:

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

Внутрішньо зв’язаний метод зберігає базову функцію та зв’язаний екземпляр:

>>> 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) --> .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 виглядала б так:

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)

The functools.update_wrapper() call adds a __wrapped__ attribute that refers to the underlying function. Also it carries forward the attributes necessary to make the wrapper look like the wrapped function: __name__, __qualname__, __doc__, and __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)
        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)

The code path for hasattr(type(self.f), '__get__') was added in Python 3.9 and makes it possible for classmethod() to support chained decorators. For example, a classmethod and property could be chained together. In Python 3.11, this functionality was deprecated.

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

The functools.update_wrapper() call in ClassMethod adds a __wrapped__ attribute that refers to the underlying function. Also it carries forward the attributes necessary to make the wrapper look like the wrapped function: __name__, __qualname__, __doc__, and __annotations__.

Об’єкти-члени та __slots__

Коли клас визначає __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: property 'dept' of 'Immutable' object has no setter
>>> 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
        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 і встановіть 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'