2025, Nov 01 16:46

Двойное поведение атрибутов в Python: дескриптор против метакласса

Как задать разное поведение атрибута на классе и экземпляре в Python: дескриптор с перегрузкой __get__ вместо метакласса; корректная статическая типизация в mypy

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

Воспроизведение схемы через метакласс

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

from typing import Any
class Field:
    def __init__(self, label: str) -> None:
        self.key = label
    def __repr__(self):
        return f"Field(name={self.key!r})"
class ModelMeta(type):
    def __new__(mcls, type_name: str, parents: tuple[type, ...], attrs: dict[str, Any]) -> type:
        hints = attrs.get('__annotations__', {})
        attrs.update({k: Field(k) for k in hints})
        return super().__new__(mcls, type_name, parents, attrs)
class RecordBase(metaclass=ModelMeta):
    def __init__(self, payload: dict[str, Any]) -> None:
        if self.__class__ == RecordBase:
            raise RuntimeError
        self.__dict__ = payload
class Person(RecordBase):
    name: str
    height: int
p = Person({'name': 'Tom', 'height': 180})
print(Person.height)  # Field(name='height')
print(p.height)       # 180

Где типизатор возражает

Метакласс добавляет атрибуты класса, опираясь на аннотации. Во время выполнения обращение к атрибуту на классе возвращает маркеры, а на экземпляре — сохранённые значения. Но статически типизатор видит лишь то, что у класса есть аннотированные атрибуты, и трактует эти аннотации как общие и для класса, и для экземпляра, если явно не добавить отдельные аннотации уровня класса (например, ClassVar). В итоге получаются либо неверные типы для доступа с класса, либо дублирование одного и того же атрибута с двумя аннотациями — чего как раз хочется избежать.

Рабочий подход: дескрипторы с перегрузками

Чтобы задать правило «при доступе с класса возвращается один тип, при доступе с экземпляра — другой», перенаправьте доступ через дескриптор и перегрузите его метод __get__. Перегрузки подсказывают типизатору: при обращении к дескриптору на классе он возвращает маркер, а при обращении на экземпляре — значение аннотированного типа. Поведение на рантайме остаётся таким же, как в варианте с метаклассом, но типовая модель становится точной.

from typing import Any, Callable, overload, Never
class Marker:
    def __init__(self, title: str) -> None:
        self.title = title
    def __repr__(self) -> str:
        return f"Marker(name={self.title})"
class CoreRow:
    __needed__: set[str] = set()
    def __init__(self, record: dict[str, Any]) -> None:
        if self.__class__ == CoreRow:
            raise RuntimeError
        self.__stash__ = record
class Slot[TR: CoreRow, TV: Any]:
    def __init__(self, fn: Callable[[TR], TV] | Callable[[type[TR]], TV]):
        self._inner = fn
    def __set_name__(self, owner: type[TR], attr: str):
        self.attr = attr
        self.flag = Marker(attr)
        if not owner.__needed__:
            owner.__needed__ = set()
        owner.__needed__.add(attr)
    @overload
    def __get__(self, instance: TR, owner: type[TR]) -> TV:
        ...
    @overload
    def __get__(self, instance: None, owner: type[TR]) -> Marker:
        ...
    def __get__(self, instance: TR | None, owner: type[TR]) -> TV | Marker:
        if instance is None:
            return self.flag
        return instance.__stash__[self.attr]
# Тело Slot в этом шаблоне никогда не выполняется на рантайме
# и мы используем Never, чтобы удовлетворить проверяющий типы.
def abort() -> Never:
    raise RuntimeError()
class User(CoreRow):
    @Slot 
    def name(self_or_cls) -> str: 
        abort()
    @Slot
    def height(self_or_cls) -> int:
        abort()
u = User({'name': 'Tom', 'height': 180})
print(User.height)  # Marker(name=height)
print(u.height)     # 180

Перегрузки __get__ делают намерение очевидным: instance is None соответствует доступу с уровня класса и возвращает Marker; иначе возвращается сохранённое значение. Хук __set_name__ дескриптора привязывает имя атрибута, создаёт маркер и фиксирует ключи, которые должны присутствовать в исходном словаре.

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

Этот подход сохраняет удобство декларативного списка полей и одновременно даёт статическому анализу точные сведения. Аннотации не приходится дублировать, типы при доступе и с класса, и с экземпляра определяются корректно, а поведение в рантайме остаётся прежним: на классе расположены маркеры, у экземпляров — реальные значения.

Выводы

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

Статья основана на вопросе со StackOverflow от JoniKauf и ответе от JoniKauf.