2025, Dec 20 06:02

Уведомления из свойства в Python: безопасный хук на уровне экземпляра

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

Когда нужно, чтобы группа объектов реагировала на изменение состояния одного из своих участников, напрашивается идея «украсить» свойство и встроить побочный эффект. В Python такой прием на свойствах, привязанных к экземплярам, часто оборачивается проблемами: механизм дескрипторов легко нарушить, если обращаться с ним как с обычным атрибутом. Ниже — короткое объяснение, в чем загвоздка, и аккуратный способ организовать уведомления на уровне экземпляров, не ломая свойства и не связывая между собой несвязанные группы.

Постановка задачи

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

class Signal():
    def __init__(self):
        self._metric = None

    @property
    def metric(self):
        return self._metric

    @metric.setter
    def metric(self, val):
        self._metric = val
class Collector():
    def __init__(self, sensors):
        self.sensors = sensors

        def notifier(func):
            def proxy(*args):
                result = func(*args)  # оборачиваем аксессор/мутатор свойства
                self.refresh()        # пытаемся дернуть агрегатор
                return result
            return proxy

        for s in sensors:
            s.metric = notifier(s.metric)  # ломается: s.metric здесь — это доступ к значению, а не функция
            s.__class__.__dict__['metric'].fset = notifier(
                s.__class__.__dict__['metric'].fset
            )  # ломается: AttributeError: readonly attribute

    def refresh(self):
        self.total = sum([s.metric for s in self.sensors])

Что идет не так и почему

Первое присваивание подменяет доступ к свойству экземпляра результатом вызова обертки, и свойство больше не участвует ни в чтении, ни в записи значения. Вторая строка пытается изменить функцию‑сеттер, хранящуюся в дескрипторе на уровне класса, — в ответ возникает AttributeError: readonly attribute. Короче говоря, если обращаться со свойством как с обычным атрибутом или «на лету» менять его внутренности, это приводит к ошибкам и нарушает семантику дескрипторов.

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

Рабочий подход

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

class Signal():
    def __init__(self):
        self._metric = None

    @property
    def metric(self):
        return self._metric

    @metric.setter
    def metric(self, val):
        self._metric = val
        if hasattr(self, 'collector_sync') and callable(self.collector_sync):
            self.collector_sync()
class Collector():
    def __init__(self, sensors):
        self.sensors = sensors
        for s in self.sensors:
            s.collector_sync = self.refresh

    def refresh(self):
        self.total = sum([s.metric for s in self.sensors])

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

Почему это важно

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

Итоги

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