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

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