Как стать автором
Обновить

Дескрипторы Python. Подробное руководство

Уровень сложностиСложный
Время на прочтение34 мин
Количество просмотров5.3K

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

Что такое дескриптор. Давайте для начала обратимся к официальному глоссарию.

Дескриптор - это любой объект, который определяет магические методы __get__, __set__ или __delete__. Когда атрибутом класса является дескриптор, срабатывает особое поведение поиска атрибута. Обычно значения атрибутов класса, получаемые через оператор точки (Foo.attr), ищутся в словаре атрибутов класса Foo.__dict__, но если значением атрибута является дескриптор, то, в зависимости от цели поиска атрибута (получение значения, присваивание, удаление), будет вызван соответствующий метод дескриптора.

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

Справочно оставляю вам ссылки на официальные документацию и руководство по описанию и использованию дескрипторов.

Оглавление

Первое знакомство

Дескрипторы в python используется для решения обширного количества задач: от валидации, сохранения истории значений, логирования и кэширования до реализации ORM и взаимодействия с API.

Для начала рассмотрим методы, присущие дескрипторам:

  • __get__ отвечает за получение атрибута класса владельца (доступ к атрибуту класса) или экземпляра этого класса (доступ к атрибуту экземпляра);

  • __set__ вызывается для установки атрибута экземпляра класса владельца на новое значение;

  • __delete__ вызывается для удаления атрибута экземпляра класса владельца.

Помимо методов __get__, __set__ и __delete__, неотъемлемой частью темы дескрипторов является и метод __set_name__.

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

Также дескриптор может обладать атрибутом __objclass__, он используется для указания класса, в котором был определен текущий объект. Использование данного атрибута является достаточно экзотическим и шанс того, что он вам пригодится очень мал, поэтому в контексте данной статьи мы не будем его рассматривать. Подробнее можно прочитать тут.

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

Импорты из примеров
from abc import ABC, abstractmethod
from functools import cached_property
from typing import Callable, Any, Self
from dataclasses import dataclass
from pprint import pprint
# Определяем дескриптор
class Descriptor:
    # Имя атрибута, значением которого является экземпляр дескриптора
    _name: str | None = None

    # В каждом методе мы имеем доступ к экземпляру класса
    # дескриптора через self
    def __set_name__(self, owner, name):
        # Записываем имя атрибута, чтобы иметь к нему доступ
        # добавим префикс _ к имени, чтобы далее мы записывали и изменяли
        # приватный атрибут экземпляра класса 
        self._name = f"_{name}"

    def __get__(self, instance, owner: type = None):
        print("Попытка получения атрибута объекта класса или его экземпляра")
        print(f"Экземпляр: {instance}")
        print(f"Объект класса: {owner}")

        # Если обращаемся к атрибуту класса владельца, возвращаем дескриптор
        if not instance:
            # В целом, вы можете вернуть что угодно, что вас интересует при
            # обращении к атрибуту объекта класса
            return self

        # Пробуем получить значение атрибута из экземпляра владельца
        return getattr(instance, self._name)

    def __set__(self, instance, value: str):
        print("Попытка установить значение атрибуту экземпляра класса")
        print(f"Экземпляр: {instance}")
        print(f"Значение: {value}")
        # Присваиваем значение атрибуту экземпляра владельца
        setattr(instance, self._name, value)

    def __delete__(self, instance):
        print("Попытка удаления атрибута экземпляра класса")
        print(f"Экземпляр: {instance}")
        # Удаляем атрибут экземпляра владельца
        delattr(instance, self._name)


# Определяем класс владельца
class Foo:
    # Определяем атрибут с дескриптором
    attr = Descriptor()
    # Определяем обычный атрибут
    other_attr = False

Сразу скажу, что поведение дескрипторов при обращении к атрибутам класса и при обращении к атрибутам экземпляра класса значительно отличаются.

Обращение к атрибуту класса

Первым рассмотрим поведение дескрипторов при использовании с объектом класса.

# Посмотрим, как выглядит словарь атрибутов класса
Foo.__dict__
# mappingproxy({'attr': <__main__.Descriptor at 0x75ceec508440>,
#               'other_attr': False, ...})
# Как мы видим, в словаре атрибутов класса присутствуют оба, определенных
# в теле класса атрибута, и атрибут attr имеет ожидаемое значение - экземпляр
# класса Descriptor

# Если мы обратимся к атрибуту other_attr класса Foo, мы ожидаемо получим 
# его значение - False
# А вот с атрибутом attr дела обстоят иначе

attr_value = Foo.attr

# При обращении к атрибуту, будет выведен следующий текст
# Попытка получения атрибута объекта класса или его экземпляра
# Экземпляр: None
# Объект класса: <class '__main__.Foo'>

# Мы видим, что при обращении к атрибуту attr был вызван метод __get__ 
# экземпляра класса Descriptor
# При вызове первым аргументом был передан None, так как мы обращались к 
# атрибуту класса, а не его экземпляра. Вторым аргументом был передан объект
# класса, к атрибуту которого мы обращались

# Помимо этого переменной attr_value было присвоено значение, равное
# результату выполнения функции __get__
attr_value
# <__main__.Descriptor at 0x75ceec508440>
# Так как экземпляр не был передан, вернулся экземпляр дескриптора

# Теперь взглянем, что будет при попытке присвоить значение атрибуту
Foo.attr = 1
# Еще раз взглянем на словарь атрибутов объекта класса
Foo.__dict__
# mappingproxy({'attr': 1,
#               'other_attr': False, ...})

# Мы совершенно точно переопределили значение атрибута attr. 
# Больше мы не имеем дело с дескрипторов, теперь это просто 1

# Мы можем спокойно удалить атрибут, как перед попыткой задать значение,
# так и после, мы удалим сам атрибут с дескриптором.
del Foo.attr
# Снова смотрим в словарь
Foo.__dict__
# mappingproxy({'other_attr': False, ...})
# Нашего атрибута больше нет

Какие выводы можно из этого сделать. Метод __get__ полноценно работает с объектом класса и вызывается при попытке получить значение атрибута. Методы __set__ и __delete__ не предназначены для реализации какого либо ожидаемого поведения в контексте работы с объектом класса (во всяком случае, в подавляющем большинстве сценариев).

Обращение к атрибуту экземпляра

Теперь давайте исследуем взаимодействие с экземпляром класса.

# Так как мы удалили атрибут attr из прошлого класса, создадим его еще раз
class Foo:
    attr = Descriptor()

# Создаем экземпляр класса Foo
foo = Foo()

# Присваиваем значение атрибуту дескриптора
foo.attr = 1
# Попытка установить значение атрибуту экземпляра класса
# Экземпляр: <__main__.Foo object at 0x75ceec508980>
# Значение: 1
# На этот раз метод __set__ был вызван

# Пробуем получить значение атрибута attr экземпляра класса Foo
foo_attr = foo.attr
# Попытка получения атрибута объекта класса или его экземпляра
# Экземпляр: <__main__.Foo object at 0x75ceec508980>
# Объект класса: <class '__main__.Foo'>

# Как можно заметить, теперь мы имеем доступ как к объекту класса,
# так и к его экземпляру внутри метода __get__

# Проверяем, получили ли мы ожидаемый результат
foo_attr
# 1
# Ожидаемое значение

# Дополнительно проверим приватный атрибут, с которым мы взаимодействуем
# в экземпляре владельца
foo._attr
# 1
# Ожидаемо получаем то же значение

# Теперь проверим корректность работы метода __delete__
del foo.attr
# Попытка удаления атрибута экземпляра класса
# Экземпляр: <__main__.Foo object at 0x75ceec508980>

foo.attr
# Попытка получения атрибута объекта класса или его экземпляра
# Экземпляр: <__main__.Foo object at 0x75ceec508980>
# Объект класса: <class '__main__.Foo'>
# AttributeError: 'Foo' object has no attribute '_attr'. Did you mean: 'attr'?

# Мы видим, что был вызван метод __get__ и уже внутри него 
# было вызвано исключение.

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

Частая ошибка

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

Давайте попробуем совершить эту ошибку, и посмотрим, что будет.

# Определяем дескриптор, который записывает значение не в экземпляр,
# а в приватный атрибут _value
class Descriptor:
    # ВАЖНО!!! Не делайте так
    _value: str | None = "Значение из дескриптора"

    def __get__(self, instance, owner: type = None):
        return self._value

    def __set__(self, instance, value):
        self._value = value


# Объявляем класс владельца
class Foo:
    attr = Descriptor()

Теперь понаблюдаем за тем, что именно мы получаем при обращении к атрибуту Foo.attr.

# Создаем экземпляр класса владельца
foo_1 = Foo()

# Обратимся к атрибуту attr
foo_1.attr
# 'Значение из дескриптора'
# Получаем ожидаемое значение по умолчанию для Descriptor._value

# Теперь попробуем присвоить новое значение
foo_1.attr = "Новое значение для первого экземпляра"

# Проверим, что значение изменилось
foo_1.attr
# 'Новое значение для первого экземпляра'
# Никаких проблем пока не видно

# А теперь давайте попробуем создать еще один экземпляр
foo_2 = Foo()
# Проверим значение в дескрипторе
foo_2.attr
# 'Новое значение для первого экземпляра'
# Неожиданное поведение

# Помимо этого, если мы изменим значение во втором экземпляре
foo_2.attr = "Новое значение для второго экземпляра"

# То в первом экземпляре оно тоже изменится
foo_1.attr
# 'Новое значение для второго экземпляра'

Думаю, почти никогда такое поведение не будет ожидаемым, но почему же это происходит.

Когда мы выполняем attr = Descriptor(), значение атрибута attr класса Foo является экземпляром дескриптора и присутствует в словаре атрибутов объекта класса владельца. При создании экземпляра класса новые экземпляры дескрипторов не создаются, а используется уже присутствующий в словаре класса.

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

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

Приоритет получения атрибутов

В данном разделе введем 2 новых понятия:

  • Data дескриптор - определен метод __set__ или __delete__

  • No-data дескриптор - определен только метод __get__

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

Если в словаре экземпляра есть запись с тем же именем, что и у data дескриптора, то дескриптор имеет приоритет над словарем экземпляра. В случае с no-data дескриптором, приоритет имеет словарь экземпляра.

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

No-data дескриптор

Первым рассмотрим пример no-data дескриптора.

# Определяем no-data дескриптор
class NoDataDescriptor:
    def __get__(self, instance, owner: type = None):
        # Возвращаем значение из дескриптора
        return "Значение дескриптора"


class Foo:
    attr = NoDataDescriptor()


# Создаем экземпляр
foo = Foo()
# Проверяем, что дескриптор возвращает значение
foo.attr
# 'Значение дескриптора'

# А теперь попробуем переопределить значение
# Обратите внимание, что в NoDataDescriptor не определен метод 
# __set__, и значение будет записано в словарь экземпляра Foo
foo.attr = 'Значение из словаря экземпляра'
# Проверим значение, получаемое при обращении к атрибуту
foo.attr
# 'Значение из словаря экземпляра'
# Так как приоритет словаря экземпляра выше приоритета no-data дескриптора,
# ожидаемо получаем значение из словаря

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

На самом деле такой подход бывает очень полезен, если у вас есть какая-то дополнительная логика, связанная с кэшированием, созданием фабричных значений для каждого отдельного экземпляра, ленивой загрузкой тех или иных значений (переводов, результата выполнения того или иного запроса и т.д.).

Для лучшего понимания, приведу пример с созданием списка при необходимости. В python тип list является изменяемым и если мы сделаем что-то вроде

class Foo:
    attr = []

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

Решить данную проблему мы может, например, так.

# Объявляем дескриптор, который при обращении к атрибуту
# возвращает новый список
class ListAttr:
    _name: str

    def __set_name__(self, owner, name):
        # Записываем имя атрибута
        self._name = name

    def __get__(self, instance, owner: type = None):
        print("Вызов метода __get__")
        # Записываем новое значение в словарь экземпляра
        setattr(instance, self._name, [])
        # Так как мы не определяем метод __set__, мы можем использовать
        # запись выше, в противном случае, вам может потребоваться
        # использовать альтернативный вариант записи в словарь атрибута
        # instance.__dict__[self._name] = []
        
        # Возвращаем значение из словаря
        return getattr(instance, self._name)
        # После выполнения этого метода значение по атрибуту _name
        # экземпляра владельца всегда будет возвращаться из словаря
        # экземпляра, так как мы имеем дело с no-data дескриптором

# Объявляем класс владельца
class Foo:
    attr = ListAttr()

# Создаем экземпляр
foo = Foo()
# Сразу добавляем значение в список
foo.attr.append(1)
# В консоль выводится следующий текст
# Вызов метода __get__

# Проверяем, что значением является список
foo.attr
# [1]
# При повторном обращении к атрибуту __get__ не вызывается
# Получаем ожидаемое поведение

# А теперь проверим, что новый экземпляр будет создан с новым значением
Foo().attr
# Вызов метода __get__
# []
# Получаем ожидаемое значение

Также, забегая вперед, при использовании no-data дескрипторов в классе в качестве декораторов, у нас есть возможность переопределять методы на уровне экземпляра класса, что невозможно при использовании data дескрипторов.

Data дескрипторы

Теперь рассмотрим поведение получения атрибутов при использовании data дескриптора.

# Определяем data дескриптор
class DataDescriptor:
    _name: str

    def __set_name__(self, owner, name):
        # Записываем имя атрибута
        self._name = name

    def __get__(self, instance, owner: type = None):
        # Возвращаем значение из дескриптора
        return "Значение дескриптора"

    def __set__(self, instance, value):
        # Запишем передаваемое значение в словарь экземпляра
        instance.__dict__[self._name] = value

# Определяем класс владельца
class Foo:
    attr = DataDescriptor()

    def __init__(self):
        self.attr = "Значение словаря экземпляра"

# Создаем экземпляр
foo = Foo()
# Проверяем значение атрибута
foo.attr
# 'Значение дескриптора'
# Получаем ожидаемое поведение

# Проверим, что в словаре экземпляра другое значение
foo.__dict__["attr"]
# 'Значение словаря экземпляра'
# Получаем ожидаемое поведение

Как мы видим, приоритет data дескрипторов выше, чем у словаря экземпляра. На данном этапе не будем подробно останавливаться на том, зачем это может быть нужно, так как это стандартное ожидаемое поведение.

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

Крайние случаи

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

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

# Определяем класс дескриптора, который будет маскировать часть строки
class MaskDescriptor:
    _name: str

    def __set_name__(self, owner, name):
        # Записываем имя атрибута
        self._name = name

    def __set__(self, instance, value):
        # Записываем замаскированное значение в словарь экземпляра
        instance.__dict__[self._name] = self._mask(value)

    @staticmethod
    def _mask(value):
        # В целом, тут может быть любая интересующая вас функция
        left_part = value[:4]
        right_part = value[-4:]
        middle_part = "****...****"
        return f"{left_part}{middle_part}{right_part}"

# ВАЖНО!!! Если вы не передадите значение атрибуту password, то в текущей
# реализации вам вернется экземпляр дескриптора
@dataclass
class Foo:
    name: str
    password: str = MaskDescriptor()

# Создаем экземпляр
foo = Foo(name="Какое-то имя", password="Какой-то пароль")

# Выведем строковое представление экземпляра в консоль
foo
# Foo(name='Какое-то имя', password='Како****...****роль')
# Как мы видим, маска применилась и мы получили ожидаемое поведение

# Пробуем обратиться к атрибуту
foo.password
# 'Како****...****роль'
# Никаких проблем

# А теперь попробуем переопределить значение
foo.password = "Новый пароль"
# Посмотрим, что вышло
foo.password
# 'Новы****...****роль'
# Мы ожидаемо работаем с дескриптором и маска применилась на новое значение

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

class DataWithoutSetImplementationDescriptor:
    def __get__(self, instance, owner: type = None):
        return # вернуть что-то

    def __set___(self, instance, value):
        # Просто вызываем AttributeError, этого достаточно, чтобы
        # дескриптор считался data дескриптором
        raise AttributeError

Дескрипторы с аргументами

Дескриптор, как и любой другой класс имеет метод __init__, эта опция помогает настраивать поведение дескриптора при его инициализации. В целом, то, как для чего можно использовать __init__ метод в классах, не перечислить, поэтому давайте сразу рассмотрим какой-нибудь прикладной пример и пойдем дальше.

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

Для начала опишем класс дескриптора.

# Опишем дескриптор, который будет валидировать целочисленные значения
class IntegerField:
    # Указываем аннотации для приватных атрибутов дескриптора
    _name: str
    _default: int | None
    _gt: int | None
    _lt: int | None
    _required: bool

    def __init__(
        self, 
        default: int | None = None,
        gt: int | None = None, 
        lt: int | None = None,
        required: bool = True
    ):
        """Инициализируем экземпляр дескриптора

        Args:
            default: значение по умолчанию
            required: обязательный ли атрибут
            gt: больше какого числа должно быть значение
            lt: меньше какого числа должно быть значение
        """
        # Записываем значения в атрибуты экземпляра
        self._default = default
        self._required = required
        self._gt = gt
        self._lt = lt

    def __set_name__(self, owner, name):
        # Записываем имя атрибута
        self._name = name

    def __set__(self, instance, value):
        # Записываем провалидированное значение
        instance.__dict__[self._name] = self.validate(value)

    def validate(self, value):
        # Если не передано значение, устанавливаем значение по умолчанию
        if self._default is not None and value is None:
            value = self._default
        # Проверяем обязательность
        if self._required and value is None:
            raise ValueError(f"{self._name} обязательный атрибут")
        # Проверяем входит ли значение в граничные условия
        if self._gt is not None and value is not None and value <= self._gt:
            raise ValueError(
                f"Значение атрибута {self._name} должно быть больше {self._gt}"
            )
        if self._lt is not None and value is not None and value >= self._lt:
            raise ValueError(
                f"Значение атрибута {self._name} должно быть меньше {self._lt}"
            )
        return value

А теперь проверим корректность работы данного дескриптора.

# Создаем класс для проверки валидации
class Foo:
    attr_1: int = IntegerField(default=4)
    attr_2: int = IntegerField(required=True)
    attr_3: int = IntegerField(gt=10)
    attr_4: int = IntegerField(lt=10)

    # Мы не можем использовать dataclass в данном примере, поэтому
    # определим метод __init__ следующим образом
    def __init__(self, attr_1=None, attr_2=None, attr_3=None, attr_4=None):
        self.attr_1 = attr_1
        self.attr_2 = attr_2
        self.attr_3 = attr_3
        self.attr_4 = attr_4

# Для примера, в начале нарушим все условия
Foo(attr_3=1, attr_4=11)
# ValueError: attr_2 обязательный атрибут
Foo(attr_2=0, attr_3=1, attr_4=11)
# ValueError: Значение атрибута attr_3 должно быть больше 10
Foo(attr_2=0, attr_3=11, attr_4=11)
# ValueError: Значение атрибута attr_4 должно быть меньше 10
Foo(attr_2=0, attr_3=11, attr_4=9)
# <__main__.Foo object at 0x786200332650>

# Проверяем словарь экземпляра
Foo(attr_2=0, attr_3=11, attr_4=9).__dict__
# {'attr_1': 4, 'attr_2': 0, 'attr_3': 11, 'attr_4': 9}
# Получаем ожидаемое поведение

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

Дескрипторы как декораторы

Раз уж дескриптор является классом и может принимать аргументы в методе __init__ или __call__ или любом другом, мы справедливо можем использовать его и в качестве декоратора.

Я уже рассказывал о дескрипторах-декораторах в своей статье про декораторы. Вы можете сразу посмотреть конкретный пример создания универсального декоратора для функций, методов класса, методов экземпляра класса и статических методов.

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

# Определяем декоратор, который будет кэшировать свойство экземпляра владельца
class CachedProperty:
    _property: property

    def __init__(self, _property: property):
        # Записываем декорируемое свойство в атрибут класса дескриптора
        self._property = _property

    def __set_name__(self, owner, name):
        # Записываем имя атрибута
        self._name = name

    def __get__(self, instance, owner):
        # Если происходит обращение к атрибуту класса владельца,
        # возвращаем экземпляр дескриптора
        if instance is None:
            return self

        # Если в словаре экземпляра нет значения,
        # получаем его из свойства и записываем в экземпляр
        if self._name not in instance.__dict__:
            # Записываем результат выполнения функции
            instance.__dict__[self._name] = (
                # Так как property является экземпляром класса дескриптора,
                # мы можем вызвать метод property.__get__, передав в него
                # класс и экземпляр владельца.
                self._property.__get__(instance, owner)
            )
            print("Рассчитано")
        else:
            print("Этот код не будет выполнен")

        # Возвращаем результат
        return instance.__dict__[self._name]

Проверим, что дескриптор отрабатывает корректно.

# Определяем класс для теста
class Foo:
    attr = 2

    # Декорируем свойство дескриптором
    @CachedProperty
    # Определяем метод как свойство
    @property
    def attr_x_10(self):
        # Свойство, которое возвращает
        # результат умножения self.attr на 10
        return self.attr * 10


# Создаем экземпляр класса
foo = Foo()

# Пробуем получить значение в первый раз
foo.attr_x_10
# Рассчитано
# 20
# Ожидаемо получаем рассчитанное значение 20

# Пробуем получить значение во второй раз
foo.attr_x_10
# 20
# Ожидаемо никакой текст более не выводится.
# Так как мы имеем дело с no-data дескриптором, после того, как мы записали 
# значение в словарь экземпляра, который имеет приоритет, мы получаем
# значение сразу из него, и код из __get__ метода более не выполняется

# Проверим, что с новым экземпляром все также работает
foo2 = Foo()
foo2.attr_x_10
# Рассчитано
# 20

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

В данном примере я хотел показать, как выглядит декорирование дескриптором и коснуться взаимодействия с другими дескрипторами.

Встроенные дескрипторы

В python существуют следующие встроенные дескрипторы:

  • classmethod - no-data дескриптор, который передает первым аргументом декорируемой функции объект класса владельца.

  • staticmethod - no-data дескриптор, который имитирует поведение обычной функции при объявлении в классе.

  • property - data дескриптор, который позволяет внутри класса описывать одноименные методы, добавляя к ним поведение __get__, __set__, __delete__ методов.

В целом, про classmethod и staticmethod ничего интересного более сказать, нельзя.

Думаю, в этом же разделе стоит упомянуть, что поведение атрибута класса __slots__ также связано с темой дескрипторов. Атрибут __slots__ позволяет явно объявлять элементы данных (например свойства) и запрещать создание __dict__ и __weakref__ (если они не указаны в __slots__ или не доступны в родительском элементе).

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

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

А вот дескриптор property мы рассмотрим подробней.

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

from __future__ import annotations


class Property:
    # Функция, которая определяет поведение __get__ метода
    _fget: Callable
    # Функция, которая определяет поведение __set__ метода
    _fset: Callable
    # Функция, которая определяет поведение __delete__ метода
    _fdel: Callable

    def __init__(
        self,
        fget: Callable | None = None,
        fset: Callable | None = None,
        fdel: Callable | None = None,
    ) -> None:
        """Инициализируем дескриптор свойства

        В чем основная идея. Чаще всего, от property нам требуется только
        возможность обращения к методу как к атрибуту ради получения данных, 
        которые мы рассчитываем на основании атрибутов экземпляра класса.
        Инициализация свойства первым аргументом принимает именно функцию,
        которая реализует поведение метода __get__.

        Тем самым мы можем ограничиться слудующим:

        class Foo:
            attr = 1

            @property
            def attr_x_2(self)
                return self.attr * 2

        Будем честны, это удобно

        Помимо этого мы можем сразу создать свойство, которое обладает всеми
        реализациями и установить его как обычный атрибут, например так

        class Foo:
            attr = 1
            attr_x_2 = property(lambda self: self.attr * 2, ...)

        Args:
            fget: Функция, которая определяет поведение __get__ метода.
            fset: Функция, которая определяет поведение __set__ метода.
            fdel: Функция, которая определяет поведение __delete__ метода.
        """
        self._fget = fget
        self._fset = fset
        self._fdel = fdel

    def getter(self, fget: Callable) -> Property:
        # Данным методом декорируем реализацию метода __get__.

        # Мы создаем новый объект класса Property, который является 
        # дескриптором. Затем мы создаем экземпляр дескриптора, передавая в
        # метод __init__ декорированную функцию и функции для реализации 
        # остальных методов, которые были декорированы до этого
        return type(self)(fget, self._fset, self._fdel)

    def setter(self, fset: Callable) -> Property:
        # Данным методом декорируем реализацию метода __set__.
        # Остальная логика соответствует описанной в Property.getter
        return type(self)(self._fget, fset, self._fdel)

    def deleter(self, fdel: Callable) -> Property:
        # Данным методом декорируем реализацию метода __delete__.
        # Остальная логика соответствует описанной в Property.getter
        return type(self)(self._fget, self._fset, fdel)

    def __get__(self, instance, owner=None) -> Any:
        # Прокидываем экземпляр владельца в декорированную функцию при попытке
        # получения значения атрибута экземпляра владельца
        return self._fget(instance)

    def __set__(self, instance, value) -> None:
        # Прокидываем экземпляр владельца и значение в декорированную функцию
        # при попытке присвоения значения атрибуту экземпляра владельца
        self._fset(instance, value)

    def __delete__(self, instance) -> None:
        # Прокидываем экземпляр владельца в декорированную функцию
        # при попытке удаления атрибута экземпляра владельца
        self._fdel(instance)

Теперь детально посмотрим, как это все работает. Для начала создадим класс, в котором реализуем все 3 метода.

# Объявляем класс владельца
class Foo:
    _attr: Any

    def __init__(self, value: Any):
        self._attr = value

    # Декорируем дескриптором Property функцию для получения значения
    @Property
    def attr(self):
        return self._attr

    # На данном этапе, в теле класса, атрибут attr является экземпляром
    # дескриптора Property, и мы имеем доступ к его атрибутам.
    
    # Важно помнить, что методы экземпляра дескриптора Property getter,
    # setter и deleter также возвращают экземпляр дескриптора Property
    
    # Декорируем методом Property.setter функцию для присвоения значения
    @attr.setter
    def attr(self, value: Any):
        self._attr = value

    # Декорируем методом Property.deleter функцию для удаления атрибута
    @attr.deleter
    def attr(self):
        # Вместо удаления, будем устанавливать значение None
        self._attr = None

    # Хочу акцентировать внимание на том, что на данном этапе мы имеет
    # единственный атрибут attr, значением которого является экземпляр
    # дескриптора Property, в котором определены все 3 функции, указанные
    # в методе __init__

Проверим, что все работает корректно.

# Создаем экземпляр
foo = Foo(True)

# Проверяем, что можем получить значение приватного атрибута _attr
# обращаясь к свойству attr
foo.attr
# True

# Присваиваем новое значение
foo.attr = False

# Проверяем, что значение приватного атрибута изменилось
foo._attr
# False

# А вместе с ним и значение свойства
foo.attr
# False

# Пробуем удалить атрибут. Mы ожидаем, что значение станет равно None
del foo.attr

# Проверяем, что значение равно None
foo.attr is None
# True
# Получаем ожидаемый результат

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

Вложенные дескрипторы

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

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

Допустим, у нас есть произвольное количество объектов, которые должны содержать конфиденциальные данные пользователей, но эти данные нужно запрашивать со стороннего ресурса. Мы заранее не знаем, сколько данных нам нужно запросить и хотим запросить все сразу в самом конце выполнения запроса пользователя, но мы не знаем где именно будут находиться объекты которые должны содержать данные.

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

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

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

Контейнер с объектами
from __future__ import annotations


# Давайте набросаем фейковый контекст, который будет загружать данные
class Context:
    # Тут будут храниться данные, полученные из стороннего источника
    _data: dict[str, dict] | None = None
    # Тут будут храниться зарегистрированные поля (дескрипторы)
    _fields: list[LazyField]

    def __init__(self):
        # Создаем контейнер для полей
        self._fields = []

    def add(self, field: LazyField):
        # Добавляем поле в контейнер
        self._fields.append(field)

    def insert(self):
        # Заполняем зарегистрированные поля
        if not self._data:
            # Загружаем данные, если их еще нет
            self.load()

        # Итерируемся по полям
        for field in self._fields:
            # Получаем запись из всех данных по id
            row = self._data.get(field.data_id)
            # Пробуем получить значение поля
            if row:
                value = row.get(field.name)
            else:
                value = None
            # Заполняем значение
            field.fill(value)

    def load(self):
        # Загружаем фэйковые данные
        self._data = {
            "1": {"first_name": "Имя 1", "last_name": "Фамилия 1"},
            "2": {"first_name": "Имя 2", "last_name": "Фамилия 2"},
        }

# Данный контекст должен инициализироваться при каждом запросе
# пользователя. Обычно это делается с помощью ContextVar
_context = Context()


def get_context() -> Context:
    # Представим, что забираем значение из ContextVar
    return _context


def insert_lazy():
    # Заполняем данные
    get_context().insert()

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

Proxy объект
class _LazyProxy:
    # Класс, для подмены типа proxy объекта
    @property
    def __class__(self):
        return str


def make_proxy(field: LazyField):
    """Данная функция нужна для того, чтобы в зависимости от 
    значения filled наших полей мы могли работать с дескриптором 
    или с заполненным значением

    Args:
        field: Экземпляр дескриптора

    Returns:

    """
    class Proxy(_LazyProxy):
        # Прокси объект, который будет использоваться вместо 
        # значения дескриптора

        def __getattr__(self, item):
            # Здесь в зависимости от флага filled и запрашиваемого
            # атрибута, берем значение либо из дескриптора, либо
            # из атрибута value дескриптора
            if field.filled and item not in field.FIELD_ATTRS:
                _self = field.value
            else:
                _self = field
            return getattr(_self, item)

        # На самом деле в оригинальном решении, здесь был пласт
        # кода для полной имитации поведения строки. 
        # Важно, что нужны отдельные доработки для вывода в консоль
        # и сериализации в JSON. 
        # Помимо этого вам может пригодиться принудительная вставка
        # данных если строка участвует в форматировании.
        # Здесь представлен минимальный пример для корректного
        # отображения в консоли.
        def __str__(self):
            # Тут и ниже указано прямое использование метода
            # __getattr__ в обход __getattribute__ для того,
            # чтобы не провалиться в бесконечную рекурсию.
            return self.__getattr__("__str__")()

        def __repr__(self):
            return self.__getattr__("__repr__")()

        def __format__(self, format_spec):
            return self.__getattr__("__format__")(format_spec)

    # Создаем экземпляр прокси объекта
    proxy: _LazyProxy = Proxy()
    # Записываем его в приватный атрибут дескриптора
    field._proxy = proxy
    # Возвращаем прокси объект
    return proxy

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

class LazyField:
    # Поля дескриптора, которые будут доступны для получения через прокси
    # объект вне зависимости от значения атрибута _filled
    FIELD_ATTRS: set = {
        "filled", "value", "name", "_filled", "_instance", "_name"
    }

    # Атрибут класса владельца, отвечающий за хранение ID записи
    _ID_FIELD = "pk"
    # Значение по умолчанию, устанавливаемое в случае отсутствия данных
    _DEFAULT = "Нет значения"
    # Имя атрибута дескриптора
    _name: str

    # Экземпляр прокси объекта
    _proxy: _LazyProxy = None
    # Экземпляр владельца
    _instance: Any = None
    # Зарегистрировано ли поле в контексте
    _registered: bool = False
    # Заполнено ли значение из контекста
    _filled: bool = False

    # Так как дескриптор будет вложенным и мы не будем напрямую
    # объявлять его в классе владельца, вместо __set_name__, для 
    # сохранения имени атрибута используем метод __init__
    def __init__(self, name):
        # При инициализации устанавливаем имя атрибута дескриптора
        self._name = name

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

    def __get__(self, instance, owner):
        # При обращении к атрибуту класса, возвращаем дескриптор
        # В целом, данная проверка здесь не обязательна, так как мы ожидаем,
        # что всегда будем иметь дело с экземпляром владельца
        if instance is None:
          return self

        # Если обращение происходит к атрибуту экземпляра, но
        # поле не зарегистрировано в контексте, регистрируем его        
        if not self._registered:
            self.register(instance)
        # Если поле заполнено, возвращаем значение
        elif self.filled:
            return str(self.value)
        # в противном случае возвращаем прокси объект
        return self._proxy

    def register(self, instance):
        # Регистрируем поле.
        # Записываем экземпляр владельца в атрибут дескриптора
        self._instance = instance
        # Добавляем поле (дескриптор) в контекст
        self._context.add(self)
        # Помечаем поле зарегистрированным
        self._registered = True

    def fill(self, value: Any = None):
        # Записываем в атрибут экземпляра владельца переданное значение
        # или значение по умолчанию
        self.value = value or self._DEFAULT
        # Помечаем поле заполненным
        self._filled = True

    @cached_property
    def _context(self) -> Context:
        # Получаем контекст
        return get_context()

    @property
    def value(self):
        # Получаем значение из атрибута экземпляра владельца
        return getattr(self._instance, self._name)

    @value.setter
    def value(self, value):
        # Присваиваем значение атрибуту экземпляра владельца
        setattr(self._instance, self._name, value)

    @property
    def filled(self):
        # Заполнено ли поле
        return self._filled

    @property
    def name(self):
        # Имя атрибута дескриптора
        return self._name

    @property
    def data_id(self):
        # Значение id из экземпляра владельца
        return getattr(self._instance, self._ID_FIELD, None)

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

class LazyFieldProxy:
    # Данный класс используется как дескриптор на целевом объекте
    # Здесь создается вложенный дескриптор и прокси объект

    def __set_name__(self, owner, name: str):
        # Записываем имя атрибута дескриптора
        self._name = name

    def __get__(self, instance, owner):
        # Если обращение к атрибуту класса, возвращаем дескриптор
        if instance is None:
            return self
          
        # Если не установлен одноименный атрибут в словаре экземпляра
        # записываем в него прокси объект.
        # Обратите внимание, что мы имеем дело с no-data дескриптором.
        # При дальнейшем обращении к атрибуту, мы будем получать именно
        # прокси объект
        if self._name not in instance.__dict__:
            # Создаем вложенный дескриптор
            field = LazyField(self._name)
            # Создаем прокси объект и записываем в словарь
            # атрибутов экземпляра владельца
            instance.__dict__[self._name] = make_proxy(field)

        # Возвращаем вызов __get__ метода вложенного дескриптора
        return instance.__dict__[self._name].__get__(instance, owner)

Давайте посмотрим, как работает описанное выше решение.

# Создаем тестовый класс пользователя
class User:
    # Добавляем атрибут, где будет храниться id записи 
    # в словаре фейковых данных
    pk: str
    # Добавляем атрибуты дескрипторов, значения для которых
    # мы хотим получить единовременно, но не сразу
    first_name: str = LazyFieldProxy()
    last_name: str = LazyFieldProxy()

    # Записываем id
    def __init__(self, pk: str):
        self.pk = pk

# Создаем экземпляры пользователей
user_1 = User(pk="1")
user_2 = User(pk="2")
# Для данного пользователя нет фейковых данных в контексте и вместо значений
# его атрибутов, должна подставиться фраза 'Нет значения'
user_3 = User(pk="3")

# Формируем словарь данных, которые нас интересуют
result = {
    "User 1": {
        "first_name": user_1.first_name,
        "last_name": user_1.last_name,
    },
    "User 2": {
        "first_name": user_2.first_name,
        "last_name": user_2.last_name,
    },
    "User 3": {
        "first_name": user_3.first_name,
        "last_name": user_3.last_name,
    },
}

# Смотрим, что мы имеем на данный момент
result
# {'User 1': {'first_name': <__main__.LazyField object at 0x7c5d1f311160>,
#             'last_name': <__main__.LazyField object at 0x7c5d1d180410>},
#  'User 2': {'first_name': <__main__.LazyField object at 0x7c5d1d180550>,
#             'last_name': <__main__.LazyField object at 0x7c5d1f2f08a0>},
#  'User 3': {'first_name': <__main__.LazyField object at 0x7c5d1f2f09d0>,
#             'last_name': <__main__.LazyField object at 0x7c5d1f286690>}}

# Как мы видим, распечатались экземпляры дескрипторов.

# Проверим, зарегистрировались ли поля в контексте
get_context()._fields
# [<__main__.LazyField at 0x7c5d1f311160>,
#  <__main__.LazyField at 0x7c5d1d180410>,
#  <__main__.LazyField at 0x7c5d1d180550>,
#  <__main__.LazyField at 0x7c5d1f2f08a0>,
#  <__main__.LazyField at 0x7c5d1f2f09d0>,
#  <__main__.LazyField at 0x7c5d1f286690>]
# Все 6 дескрипторов ожидаемо зарегистрированы

# Заполняем данные, из контекста
insert_lazy()
# Важно отметить, что те атрибуты, которые в процессе выполнения кода не 
# запрашивались, не будут заполнены при вызове данной команды. Это как раз
# и является одной из наших целей 

# Проверяем результат
result
# {'User 1': {'first_name': 'Имя 1', 'last_name': 'Фамилия 1'},
#  'User 2': {'first_name': 'Имя 2', 'last_name': 'Фамилия 2'},
#  'User 3': {'first_name': 'Нет значения', 'last_name': 'Нет значения'}}
# Ожидаемо видим заполненные данные

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

Управление группой дескрипторов

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

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

Я видел много примеров имитации поведения ORM, как в официальной документации, так и в других статьях на темы дескрипторов и метаклассов, но мало где можно встретить добротный пример организации заполнения и присвоения значений атрибутов с управлением на уровне экземпляра класса для всех полей сразу, поэтому хочу показать как это могло бы выглядеть.

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

# Создаем класс, который будет указывать на то,
# что значение аргумента не было передано
class NotSet: ...


# Описываем базовый дескриптор поля модели ORM
class BaseField:
    # Тип, соответствовать которому должно значение атрибута
    _type: type
    # Имя атрибута дескриптора
    _name: str
    # Приватное имя атрибута, к которому мы будем обращаться
    # в экземпляре владельца
    _protected_name: str
    # Значение атрибута по умолчанию
    _default: Any
    # Флаг обязательности поля
    _required: bool

    def __init__(
        self, 
        default: Any = NotSet,
        required: bool = True,
        **kwargs
    ):
        """Инициализируем экземпляр дескриптора

        Args:
            default: Значение по умолчанию
            required: Флаг обязательности поля
            **kwargs: Прочие аргументы
        """
        self._default = default
        self._required = required

    def __set_name__(self, owner, name: str):
        # Записываем имя атрибута дескриптора
        self._name = name
        # Записываем приватное имя атрибута
        self._protected_name = f"_{name}"

    def __get__(self, instance, owner=None):
        # При обращении к классу владельца, возвращаем дескриптор
        if instance is None:
            return self

        # Если значение еще не установлено, пробуем установить
        # значение по умолчанию
        if not hasattr(instance, self._protected_name):
            self._set_default_value(instance)
        # Возвращаем значение из экземпляра владельца
        return getattr(instance, self._protected_name)

    def __set__(self, instance, value):
        # Присваиваем значение экземпляру владельца
        self._set_value(instance, value)

    def _set_default_value(self, instance):
        # Если установлено значение по умолчанию, записываем его
        if self._default is not NotSet:
            self._set_value(instance, self._default)
        # Если поле не обязательное, устанавливаем None
        elif not self._required:
            self._set_value(instance, None)
        # В противном случае вызываем исключение
        else:
            raise ValueError(f"Атрибут {self._name} обязательный")

    def _set_value(self, instance, value):
        # Валидируем значение перед его присвоением
        self._validate_value(value)
        # Присваиваем значение приватному атрибуту экземпляра владельца
        setattr(instance, self._protected_name, value)

    def _validate_value(self, value):
        # Проверяем обязательность поля
        if self._required and value is None:
            raise ValueError(f"Атрибут {self._name} обязательный")
        # Проверяем соответствие типов
        if value is not None and not isinstance(value, self._type):
            raise ValueError(
                f"Значение атрибута {self._name} должно быть"
                f" экземпляром {self._type}, а не {type(value)}"
            )

    def _prepare_data_before_dump(self, value):
        # В этой функции будем преобразовывать значение перед сохранением в
        # базе данных при необходимости
        return value

    def _prepare_data_before_load(self, value):
        # В этой функции будем преобразовывать значение из базы данных перед
        # сохранением в экземпляре владельца при необходимости
        return value

    def dump(self, instance):
        # Получаем значение
        value = self.__get__(instance)
        # Здесь мы возвращаем значение для базы данных
        return self._prepare_data_before_dump(value)

    def load(self, instance, value):
        # Преобразуем значение из базы данных
        value = self._prepare_data_before_load(value)
        # Присваиваем значение атрибуту экземпляра владельца
        self._set_value(instance, value)

Добавим несколько простых реализаций для полей ORM

IntegerField (Дескриптор для целочисленных значений)
# Дескриптор для целочисленных значений
class IntegerField(BaseField):
    _type = int
    _gt: int | NotSet
    _lt: int | NotSet

    # Добавляем аргументы, уникальные для этого дескриптора
    def __init__(
        self, 
        gt: int | NotSet = NotSet,
        lt: int | NotSet = NotSet,
        **kwargs
    ):
        super().__init__(**kwargs)
        self._gt = gt
        self._lt = lt

    def _validate_value(self, value):
        super()._validate_value(value)
        # Добавляем валидацию максимального значения
        if (
            self._gt is not NotSet 
            and value is not None
            and value <= self._gt
        ):
            raise ValueError(
                f"Значение атрибута {self._name} "
                f"должно быть больше {self._gt}"
            )
        # Добавляем валидацию минимального значения
        if (
            self._lt is not NotSet 
            and value is not None 
            and value >= self._lt
        ):
            raise ValueError(
                f"Значение атрибута {self._name} "
                f"должно быть меньше {self._lt}"
            )
CharField (Дескриптор для строковых значений)
# Дескриптор для строковых значений
class CharField(BaseField):
    _type = str
    _max_length: int | NotSet

    def __init__(self, max_length: int | NotSet = NotSet, **kwargs):
        super().__init__(**kwargs)
        self._max_length = max_length

    def _validate_value(self, value):
        super()._validate_value(value)
        # Добавляем валидацию максимальной длины строки
        if (
            self._max_length is not NotSet
            and value is not None
            and len(value) > self._max_length
        ):
            raise ValueError(
                f"Значение атрибута {self._name} "
                f"должно быть короче {self._max_length} символов"
            )
PointField (Дескриптор для пользовательского типа данных)
# Для наглядности преобразования данных,
# добавим пользовательский класс структуры данных
@dataclass
class Point:
    x: float
    y: float

    # Эта функция будет вызываться при сохранении в базе данных
    def model_dump(self):
        return {"x": self.x, "y": self.y}


# Дескриптор, обслуживающий тип Point
class PointField(BaseField):
    _type = Point

    def _prepare_data_before_dump(self, value):
        # Получаем словарь вместо класса, перед сохранением
        return value.model_dump()

    def _prepare_data_before_load(self, value):
        # Во время загрузки, преобразуем словарь в экземпляр
        # класса Point
        return self._type(**value)

А теперь нам необходимо реализовать класс, который будет работать с дескрипторами, которые мы описали выше.

В целом, решение данной задачи можно как усложнить, используя метаклассы, так и упростить, ограничившись использованием __set_name__ для добавления полей в атрибут __fields__ класса BaseModel, описанного ниже.

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

Подробнее о метаклассах и методе __init_subclass__ я рассказывал в своей прошлой статье про метаклассы.

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

Структура фэйковой базы данных
# Тут мы просто определяем удобочитаемые псевдонимы для типов
TableName = str # Имя таблицы базы данных
RecordId = str | int # ID записи в таблице базы данных
FieldName = str # Имя поля или колонки (как вам удобно)
FieldValue = Any # Значение поля или ячейки (как вам удобно)

# Псевдоним типа структуры хранения данных конкретной записи
RecordData = dict[FieldName, FieldValue]
# Псевдоним типа структуры таблицы
Table = dict[RecordId, RecordData]
# Псевдоним типа структуры базы данных
Database = dict[TableName, Table]

# Возможно кому-то так будет проще
Database
dict[str, dict[str | int, dict[str, Any]]]

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

Для справки информация про метод __mro__.

# База данных по умолчанию
db: Database = {}

# Функция для получения базы данных по умолчанию
def get_default_db():
    return db


# Определяем базовый класс модели ORM 
class BaseModel:
    # База данных, с которой будет взаимодействовать модель ORM
    __database__: Database
    # Имя таблицы базы данных, с которой будет взаимодействовать модель ORM
    __table__: TableName
    # Поля, значения которых должны быть зафиксированы в базе данных
    __fields__: dict[str, BaseField]

    # Данная функция будет вызываться каждый раз
    # при создании нового подкласса BaseModel
    def __init_subclass__(
        cls,
        # Значения данных аргументов можно передать
        # при объявлении подкласса
        database: Database | None = None,
        table: TableName | None = None,
        **kwargs
    ):
        # Вызываем родительский метод
        super().__init_subclass__(**kwargs)

        # Для каждого класса переопределяем __fields__, так как
        # словарь является изменяемым типом данных, а мы не хотим
        # менять набор полей в родительских классах
        cls.__fields__ = {}

        # Для каждого подкласса указываем базу данных и имя таблицы
        # Если не передана конкретная база данных, используем 
        # базу данных по умолчанию
        cls.__database__ = get_default_db() if database is None else database
        # Если аргумент не передан, то именем таблицы будет имя класса
        # в нижнем регистре
        cls.__table__ = (table or cls.__name__).lower()
        # Если в базе данных нет таблицы с указанным именем, создаем
        if cls.__table__ not in cls.__database__:
            cls.__database__[cls.__table__] = {}

        # Пройдемся по всем классам, начиная с первого родителя.
        # Это необходимо для того, чтобы найти поля-дескрипторы
        # в каждом родительском классе, так как в словаре атрибутов
        # объекта класса хранятся только те атрибуты, которые были
        # объявлены в его теле или присвоены после создания
        for _cls in reversed(cls.__mro__):
            # Проходимся по всем атрибутам в словаре каждого из классов
            for name, field in cls.__dict__.items():
                # Если значение атрибута является дескриптором поля,
                # записываем его в словарь полей объявляемого класса
                if isinstance(field, BaseField):
                    cls.__fields__[name] = field

    def __init__(self, pk: str | int, _from_db: bool = False, **kwargs):
        """Инициализируем экземпляр модели ORM

        Args:
            pk: Значение первичного ключа записи
            _from_db: Признак получения записи из базы данных
            **kwargs: Значения полей
        """

        # Устанавливаем значение первичного ключа
        self.pk = pk

        # Все прочие переданные аргументы устанавливаем как атрибуты класса
        for key, value in kwargs.items():
            setattr(self, key, value)

        # Если получаем значения не из бд, то инициализируем
        # все ожидаемые поля
        if not _from_db:
            self.__init_fields__()

    def __init_fields__(self):
        # Тут просто обращаемся к атрибутам всех полей.
        # Это нужно для того, чтобы запустить
        # валидации на уровне дескрипторов
        for name in self.__fields__:
            getattr(self, name)

    def save(self):
        # Тут мы сохраняем данные в базе данных.
        # Создаем временный словарь для записи значений
        _data = {}
        # Проходимся по всем полям
        for name, field in self.__fields__.items():
            # Записываем значение, подготовленное для базы данных в словарь
            _data[name] = field.dump(self)

        # Записываем данные по значению первичного ключа в таблицу базы данных
        self._table[self.pk] = _data

    @classmethod
    def get_by_id(cls, pk: str | int) -> Self:
        # Этот метод класса будет возвращать экземпляр класса модели ORM
        # в котором будут записаны значения из базы данных.

        # Пробуем получить данные из таблицы базы данных
        _data = cls._get_table().get(pk, None)
        if _data is None:
            # Если такой записи нет, вызываем исключение
            raise ValueError(
                f"Запись с ключом {pk} "
                f"не представлена в базе данных"
            )

        # Создаем экземпляр, указывая, что получаем данные из базы данных
        instance = cls(pk=pk, _from_db=True)

        # Для каждого поля-дескриптора пытаемся установить значение
        # из базы данных
        for name, field in instance.__fields__.items():
            # Присваиваем экземпляру значения, из базы данных,
            field.load(instance, _data.get(name))

        # Возвращаем экземпляр класса владельца
        return instance

    @classmethod
    def _get_table(cls) -> Table:
        # Получаем таблицу для модели ORM из базы данных
        return cls.__database__[cls.__table__]

    @property
    def _table(self) -> Table:
        # Свойство для получения таблицы для модели ORM из базы данных
        return self._get_table()

    def __repr__(self):
        # Меняем строковое представление экземпляра класса для наглядности.
        # В целом, тут может быть любая другая логика
        data = {name: getattr(self, name) for name in self.__fields__}
        data = {"pk": self.pk, **data}
        attrs = [f"{key}={value}" for key, value in data.items()]
        return f"{self.__class__.__name__}({", ".join(attrs)})"

Проверяем работоспособность описанного выше функционала. Создадим модель ORM с несколькими полями и посмотрим, что происходит.

# Создаем модель город
class City(BaseModel):
    name: str = CharField(max_length=30)
    population: int | None = IntegerField(gt=0, required=False)
    location: Point = PointField()

# Проверим, что таблица city создалась
db
# {'city': {}}

# Создадим экземпляр города
city_1 = City(pk=1, name="Город 1", population=10, location=Point(1, 1))
city_1
# City(pk=1, name=Город 1, population=10, location=Point(x=1, y=1))

# Проверим, что до сохранения он не присутствует в баз данных
db
# {'city': {}}

# Сохраняем значение
city_1.save()

# Проверяем, появилось ли оно в базе данных
db
# {'city': {1: {'name': 'Город 1',
#               'population': 10,
#               'location': {'x': 1, 'y': 1}}}}
# Получаем ожидаемое поведение

# Попробуем добавить еще один город в базу данных
city_2 = City(pk=2, name="Город 2", location=Point(2, 5))
city_2.save()

db
# {'city': {1: {'location': {'x': 1, 'y': 1},
#               'name': 'Город 1',
#               'population': 10},
#           2: {'location': {'x': 2, 'y': 5},
#               'name': 'Город 2',
#               'population': None}}}
# Получаем ожидаемое поведение

# А теперь попробуем получить город из базы данных
City.get_by_id(pk=1)
# City(pk=1, name=Город 1, population=10, location=Point(x=1, y=1))
# Все работает

# Пробуем запросить город, по pk, который не представлен в базе данных
City.get_by_id(pk=3)
# ValueError: Запись с ключом 3 не представлена в базе данных
# Ожидаемо получаем исключение

Также важно, что мы почти всегда работаем более чем с одной таблицей. Давайте проверим, что произойдет, если создать еще одну модель ORM.

# Объявляем новую модель
class Book(BaseModel):
    name: str = CharField(max_length=30)

# Создаем экземпляр книги и сохраняем его в базе данных
book = Book(pk=1, name="Какая-то книга")
book.save()

# Проверяем содержимое базы данных
db
# {'book': {1: {'name': 'Какая-то книга'}},
#  'city': {1: {'location': {'x': 1, 'y': 1},
#               'name': 'Город 1',
#               'population': 10}, ...}
# Создалась новая таблица, все корректно работает

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

# Другая база данных для примера
other_db: Database = {}

# Объявляем новую модель
class Article(BaseModel, database=other_db, table="article_in_other_db"):
    name: str = CharField(max_length=30)

# Создаем и сохраняем экземпляр статьи
article = Article(pk=1, name="Какая-то статья")
article.save()

# Проверяем, что новая таблица не присутствует в первой базе данных
db.keys()
# dict_keys(['city', 'book'])

# Проверяем, что указанная база данных содержит новую запись
other_db
# {'article_in_other_db': {1: {'name': 'Какая-то статья'}}}
# Получаем ожидаемое поведение

Заключение

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

В первых частях статьи, я старался акцентировать внимание на различных тонкостях, о которых знают далеко не все даже опытные разработчики, а в последних показать более прикладные варианты использования дескрипторов.

Надеюсь многие смогут найти что-то новое для себя.

Теги:
Хабы:
+5
Комментарии3

Публикации

Работа

Data Scientist
45 вакансий

Ближайшие события