2025, Nov 02 21:01
Protocol для неизменяемой Enum‑константы уровня класса
Как описать в Python через Protocol неизменяемую Enum‑константу уровня класса: ClassVar, Final, ReadOnly по PEP 767 и текущее состояние поддержки типизаторов.
Когда вы проектируете семейство классов, которые публикуют одну и ту же константу типа Enum в виде доступного только для чтения атрибута класса, это соглашение удобно описывать через Protocol. Но возникает загвоздка: как выразить в Protocol неизменяемую переменную класса так, чтобы принимались классы с неизменной Enum‑константой, не превращая её в свойство экземпляра или classmethod.
Постановка задачи
Предположим, у нас есть Enum и пара классов, закрепляющих константу уровня класса за разными элементами перечисления. Атрибут неизменяемый и должен рассматриваться как переменная класса.
from typing import ClassVar, Protocol, Final, Literal
from enum import Enum
class RoleKind(Enum):
ALPHA = 0
BETA = 1
class EngineA:
category: Final = RoleKind.ALPHA
class EngineB:
category: Final = RoleKind.BETA
Цель — определить Protocol, которому соответствуют и EngineA, и EngineB, фиксируя, что category — это атрибут уровня класса, доступный только для чтения, с типом RoleKind.
Что идёт не так
Интуитивно можно попытаться оформить Protocol так, будто category — это свойство экземпляра, либо будто Final можно напрямую указать внутри Protocol. Такие варианты не дают желаемого результата:
class ContractA(Protocol):
@property
def category(self) -> RoleKind: ...
class ContractB(Protocol):
category: Final[RoleKind]
Другие подходы — например, обернуть тип в ClassVar за свойством или перейти к сигнатуре в духе classmethod — в этом контексте не поддерживаются и по-прежнему не выражают идею «переменная класса только для чтения»:
class ContractC(Protocol):
@property
def category(self) -> ClassVar[RoleKind]: ...
class ContractD(Protocol):
@property
@classmethod
def category(cls) -> RoleKind: ...
Даже попытка задействовать Generic вместе с Literal не решает задачу формулировки Protocol для этого случая.
Суть проблемы
Нужно выразить в Protocol атрибут класса, который по смыслу является только для чтения и хранит RoleKind, соответствуя классам, где атрибут один раз задан на уровне класса через Final. Рассматривать его как свойство экземпляра, classmethod или обычный атрибут экземпляра — значит увести модель от исходного намерения. Требуется способ прямо в типовой системе Protocol сказать: «переменная класса только для чтения».
Решение
Обозначение, передающее переменную класса только для чтения внутри Protocol, выглядит так:
class ContractSpec(Protocol):
category: ClassVar[ReadOnly[RoleKind]]
Это соответствует семантике, описанной в PEP 767 для атрибутов класса. Как отмечено в спецификации, такая форма сейчас в статусе черновика и не поддерживается ни одним типизатором.
Зачем это нужно
Точное описание констант уровня класса в Protocol усиливает контракт API и облегчает безопасный рефакторинг. Намерение становится прозрачным: атрибут относится к классу, а не к экземпляру, и он неизменяем. Когда несколько реализаций публикуют такую Enum‑константу, объединение их под единым Protocol проясняет дизайн и убирает неопределённость вокруг формы атрибута и его изменяемости.
Выводы
Если нужно выразить в Protocol переменную класса только для чтения, корректная форма — category: ClassVar[ReadOnly[RoleKind]] в духе семантики PEP 767. Хотя типизаторы пока это не поддерживают, именно такое направление точно отражает замысел неизменяемых атрибутов класса в определениях Protocol. До появления поддержки учитывайте это ограничение и держите контракты классов согласованными, чтобы переход к точной аннотации прошёл без трений, как только она станет доступна.