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__ согласует семантику выполнения и статическую типизацию. Метакласс, который подставляет маркеры, удобен, но непрозрачен для типизаторов; перенос логики в дескриптор делает различие между доступом на уровне класса и экземпляра явным и упрощает жизнь и инструментам, и разработчикам.