2025, Nov 28 06:03
Почему операторы в Python обходят дескрипторы и как правильно блокировать __eq__
Почему оператор проглатывает ошибки дескриптора в магических методах, как выровнять поведение __eq__ и безопасно запретить переопределения через метакласс.
Блокировать отдельные методы в подклассах кажется простой задачей, пока в игру не вступают магические методы. Типичный подход — использовать метакласс, который заменяет запрещённые методы на дескрипторы данных, бросающие исключение, чтобы любой доступ сразу же падал. Это отлично работает при прямом вызове метода. Но когда тот же метод запускается оператором, вступает в силу модель данных Python, и кажется, будто исключение пропало. Разберём, что на самом деле происходит и как добиться единообразного поведения.
Демонстрация проблемы
from typing import override
class TrapAttr:
def __get__(self, inst: object, owner: object = None):
print("Exception incoming")
raise RuntimeError()
def __set__(self, inst: object, owner: object = None): ...
class Hook(type):
def __new__(mcls, cls_name: str, parents: tuple[type, ...], ns: dict[str, object]):
ns["__eq__"] = TrapAttr()
return super().__new__(mcls, cls_name, parents, ns)
class BaseThing:
@override
def __eq__(self, rhs: object):
return True
class DerivedThing(BaseThing, metaclass=Hook):
pass
print(BaseThing() == 0) # True
print(DerivedThing() == 0) # будет выведено 'Exception incoming'
# False
print(DerivedThing().__eq__(0)) # будет выведено 'Exception incoming'
# вызывает RuntimeError
Прямой вызов специального метода приводит к исключению — всё по ожиданиям. При использовании оператора сообщение от дескриптора действительно печатается, но выражение возвращает False вместо того, чтобы пробросить исключение дальше.
Что происходит под капотом
Специальные методы — часть языкового протокола. Хотя многие из них почти напрямую соответствуют операторам, при использовании оператора Python не ищет их обычным доступом к атрибутам. Вместо этого интерпретатор обращается к зарезервированным слотам под эти методы и следует протоколу с запасными путями. В случае сравнения на равенство, если разрешение специального метода у левого операнда не даёт пригодной реализации, Python пробует отражённую операцию у правого операнда.
В примере доступ к дескриптору печатает сообщение и выбрасывает исключение. Когда процесс запускает оператор, протокол продолжает работу так, будто реализации нет, и пытается выполнить отражённую операцию, что даёт False. Поэтому создаётся впечатление, что исключение «проглатывается» при использовании оператора, но не при прямом вызове метода.
Есть и другой важный момент: подменять магические методы объектами, не являющимися методами, не рекомендуется. Эти имена зарезервированы и ожидаются как корректные методы экземпляра. Отступление от этого контракта легко приводит к поведению, не гарантированному в разных реализациях или версиях.
Минимальное изменение, чтобы оба пути выбрасывали исключение
Если всё же нужно, чтобы оператор и прямой вызов вели себя одинаково, сделайте так, чтобы дескриптор возвращал вызываемое значение и выбрасывал исключение внутри него. Тогда протокол операторов найдёт в слоте функцию и действительно выполнит её — и в этот момент ваше исключение будет поднято в обоих случаях.
class TrapAttr:
def __get__(self, inst: object, owner: object = None):
print("Exception incoming")
def boom(other):
raise RuntimeError()
return boom
def __set__(self, inst: object, owner: object = None): ...
Это выравнивает оба пути выполнения, не меняя внешнего поведения протокола. Однако оговорка остаётся в силе: подменять имена специальных методов чем-то, кроме обычных методов, — практика нежелательная.
Более безопасный подход: отклонять на этапе создания класса
Вместо перехвата использования во время выполнения лучше вовсе не создавать класс, если он пытается переопределить запрещённые методы. Раз у вас уже есть метакласс, выполните проверку после создания объекта класса и проверьте эффективный результат MRO.
blocked_set = {"__eq__"}
class TypeGate(type):
def __new__(mcls, cls_name: str, parents: tuple[type, ...], ns: dict[str, object]):
cls = super().__new__(mcls, cls_name, parents, ns)
for meth in blocked_set:
if getattr(cls, meth, None) is not getattr(object, meth, None):
raise TypeError(f"Method {meth} can't be overriden in class {cls.__name__}")
return cls
Того же можно добиться и без метакласса, разместив проверку в __init_subclass__ базового класса. Логика та же: она запускается после создания подкласса и отвергает недопустимые переопределения.
blocked_set = {"__eq__"}
class GuardBase:
def __init_subclass__(cls, **kwargs):
for meth in blocked_set:
if getattr(cls, meth, None) is not getattr(object, meth, None):
raise TypeError(f"Method {meth} can't be overridden in class {cls.__name__}")
super().__init_subclass__(**kwargs)
Почему это важно
Специальные методы участвуют в протоколе с запасными путями. Считать, что они работают как обычные атрибуты, — прямой путь к сюрпризам, особенно в части отражённых операторов. Опора на нестандартные дескрипторы для «магических» имён легко уводит в зону неопределённости, тогда как запрет переопределений на этапе определения класса даёт детерминированный ранний отказ.
Выводы
Если вам нужна одинаковая ошибка и при операторе, и при прямом вызове, возвращайте из дескриптора вызываемое и поднимайте исключение внутри него. Если требуется надёжно контролировать, что могут переопределять подклассы, проверяйте это при создании класса — либо в метаклассе после создания, либо в __init_subclass__ базового класса. И главное: помните, что при выполнении операторов магические методы — это не обычные атрибаты; ими управляет протокол модели данных с собственной логикой разрешения и откатов.