Дескриптор Python смешал значения объектов: где хранить поле

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

Разделить правило и значение

У дескриптора две роли, которые полезно разделить. Он описывает правило доступа к полю, но само пользовательское значение принадлежит экземпляру модели. Метод __set__ получает и объект правила, и конкретный instance. Поэтому после проверки он записывает данные в словарь instance, используя отдельное внутреннее имя. __get__ читает их из того же места.

__set_name__ вызывается при создании класса и сообщает дескриптору имя атрибута, под которым он установлен. Это позволяет один раз описать общий механизм, а для quota и retries получить разные внутренние ключи. Здесь дескриптор создаётся отдельно для каждого объявления поля. Повторное использование одного и того же объекта под несколькими именами потребовало бы другого способа хранения метаданных.

Пример проверяет три свойства: два экземпляра не делят значения, два поля одного экземпляра не конфликтуют, неправильное присваивание не портит предыдущее состояние. Последняя проверка определяет порядок: сначала валидация целиком, затем запись. Случайная запись перед raise дала бы исключение и одновременно оставила бы модель повреждённой.

Хранить данные в экземпляре

class PositiveInt:
    def __set_name__(self, owner, name):
        self.storage_name = "_positive_" + name

    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        return instance.__dict__[self.storage_name]

    def __set__(self, instance, value):
        if type(value) is not int or value <= 0:
            raise ValueError("positive integer required")
        instance.__dict__[self.storage_name] = value

class Account:
    quota = PositiveInt()
    retries = PositiveInt()

    def __init__(self, quota, retries):
        self.quota = quota
        self.retries = retries

a = Account(10, 2)
b = Account(20, 3)
a.quota = 15
try:
    a.quota = True
except ValueError:
    pass
else:
    raise AssertionError("bool accepted as quota")
assert (a.quota, a.retries, b.quota, b.retries) == (15, 2, 20, 3)
assert isinstance(Account.quota, PositiveInt)
print(a.quota, a.retries, b.quota, b.retries)

Проверка type(value) is int здесь выбрана намеренно: bool является подклассом int, но бизнес-лимит True выглядит как ошибка вызывающего кода. В другой модели допустимость целочисленных подклассов может быть полезной. Сначала сформулируйте требование поля, затем выберите проверку. Сам дескриптор только определяет место применения правила.

Проверить контракт доступа

Доступ Account.quota возвращает объект правила. Это обычное соглашение для __get__ при instance is None: инструменты могут исследовать поле через класс. Доступ до инициализации в нашей модели приводит к отсутствию ключа. Если поле должно иметь значение по умолчанию, задайте его явно и определите, создаётся ли новое значение для каждого объекта. Для неизменяемого положительного числа это проще, чем для коллекции.

Наличие __set__ делает дескриптор дескриптором данных. Его обработчик участвует в разрешении атрибута раньше обычного значения с таким же публичным именем в __dict__. Внутренний префикс помогает не пересечься с объявленным именем поля, но не создаёт защиты от сознательного вмешательства в словарь объекта. Это механизм поддержания интерфейса модели, а не граница безопасности между недоверенными частями программы.

Если класс использует __slots__ без __dict__, выбранное хранение не подойдёт. Для такой модели понадобится объявленное место хранения или иной механизм. Не переносите дескриптор между моделями автоматически: способ размещения значений является частью его контракта. Для одной-двух уникальных проверок обычный property часто остаётся самым понятным вариантом.

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

Продолжить практику

Для начала закрепите защиту инвариантов через property в бесплатном курсе по ООП, типизации и тестированию. Откройте программу и оформите сценарий отказа с сохранением значения. Дальше «Python для профессионалов» продолжает специальные возможности объектов и метапрограммирование, полезные для повторно используемых правил доступа.

Продолжить на практике

  • Python для продвинутых: ООП, типизация и тестирование ↗

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