2025, Dec 06 03:02
Подклассы str и структурное сопоставление в Python: почему case Flux('asdf') не срабатывает
Почему позиционный шаблон case для подкласса str в сопоставлении с образцом Python не срабатывает: особая проверка равенства и что делать с __match_args__.
Структурное сопоставление с образцом в Python 3.10+ — мощный инструмент, но у него есть тонкий крайний случай, который проявляется, когда вы наследуетесь от str и переопределяете равенство. Если вы полагаетесь на BLOB‑подобные обёртки для строк, чтобы сохранять семантику парсинга — например, чтобы обёрнутые строки не считались равными обычным str, — шаблон вида case MyType("text") будет вести себя не так, как ожидается. Ниже — что на самом деле происходит и с какими компромиссами придётся мириться.
Как воспроизвести проблему
В следующем фрагменте класс наследуется от str, настраивает равенство так, чтобы сравнения принимались только с экземплярами того же класса, и затем пытается использовать match/case с позиционным классовым шаблоном. Логика минимальная, но показательная.
class Flux(str):
def __eq__(self, rhs):
return self.__class__ == rhs.__class__ and str.__eq__(self, rhs)
def __ne__(self, rhs):
return not self == rhs
# Guarantees needed by the design
assert Flux('a') == Flux('a')
assert Flux('a') != 'a'
# Now try structural pattern matching
hit = False
match Flux("asdf"):
case Flux("asdf"):
hit = True
assert hit
На первый взгляд совпадение должно произойти, но этого не происходит. Быстрая диагностическая печать участвующих классов внутри метода сравнения показывает, что правый операнд оказывается обычной str, а не экземпляром подкласса, из‑за чего сравнение по заданной политике равенства проваливается.
Что на самом деле делают классовые шаблоны
Классовые шаблоны сопоставляют по атрибутам. Когда вы пишете case SomeType(attr="value"), движок концептуально проверяет, что субъект является экземпляром SomeType, а затем сравнивает атрибут по имени. Если вы используете позиционные подпаттерны, как в case SomeType("value"), Pythonу нужен маппинг от позиционных индексов к именам атрибутов, который предоставляет атрибут класса __match_args__.
Подумайте о следующем преобразовании как о ментальной модели. В случае именованного шаблона атрибута:
match obj:
case Gadget(kind="sensor"):
...
сопоставление ведёт себя как проверка isinstance плюс сравнение атрибута:
isinstance(obj, Gadget) and obj.kind == "sensor"
При позиционном сопоставлении __match_args__ сопоставляет позиции с именами атрибутов, так что case Gadget("sensor") аналогичен:
isinstance(obj, Gadget) and getattr(obj, Gadget.__match_args__[0]) == "sensor"
Чтобы это работало, класс должен выставлять атрибуты вместе с __match_args__:
from typing import ClassVar
class Gizmo:
__match_args__: ClassVar[tuple[str, ...]] = ("kind",)
def __init__(self, kind: str):
self.kind = kind
Особый случай для str и её подклассов
Для набора встроенных типов задокументировано важное исключение. Для них допускается одиночный позиционный подпаттерн, который сопоставляется со всем объектом, а не с именованным атрибутом. Один из таких специальных типов — str. Поскольку подкласс str наследует это поведение, шаблон вроде case Sub("asdf") превращается в проверку isinstance для подкласса с последующим сравнением на равенство с обычным строковым литералом.
Иными словами, действует следующая концептуальная подстановка:
match obj:
case Flux("asdf"):
...
превращается в эквивалент:
isinstance(obj, Flux) and obj == "asdf"
Это и объясняет сбой. Переопределённое равенство в подклассе намеренно не признаёт экземпляр подкласса равным обычной str, поэтому сопоставление не срабатывает.
К чему приводят ограничения
Одновременно получить все следующие свойства нельзя: класс наследуется от str; экземпляр подкласса не равен «сырой» строке, как в Flux("foo") != "foo"; и позиционный шаблон case Flux("bar"), который совпадает с Flux("bar"). Специальная обработка str вынуждает позиционный классовый шаблон использовать проверку равенства для всей строки, а пользовательское равенство отклоняет сравнение с plain str — эти требования напрямую конфликтуют.
Что реально работает
Если вам нужно сопоставление по атрибутам с позиционным синтаксисом, смоделируйте объект через атрибуты и определите __match_args__. Именно этого контракта Python ожидает от классовых шаблонов, когда нет особых встроенных исключений. Ниже минимальный пример, согласованный с тем, как должны работать классовые шаблоны:
from typing import ClassVar
class Widget:
__match_args__: ClassVar[tuple[str, ...]] = ("label",)
def __init__(self, label: str):
self.label = label
ok = False
match Widget("asdf"):
case Widget("asdf"):
ok = True
assert ok
Это работает, потому что позиционный подпаттерн сопоставляется с атрибутом label через __match_args__, и движок выполняет сравнение атрибута, а не специальное сравнение «целого объекта», применяемое для str.
Почему это важно
Структурное сопоставление — это не синтаксический синоним вызова __eq__. Классовые шаблоны в основе своей опираются на атрибуты, за исключением случаев, когда Python делает оговорки для некоторых встроенных типов. Наследование от str переводит ваш тип на этот особый путь, где позиционный подпаттерн трактуется как проверка равенства всего строкового значения. Если ваша семантика равенства намеренно асимметрична по отношению к обычной str, совпадение не произойдёт, даже если содержимое строк выглядит одинаково.
Выводы
Проектируя типы для сопоставления с образцом, чётко определяйте, какой инвариант вам важнее. Если вам нужна жёсткая граница с plain str, чтобы Flux("x") != "x" оставалось истинным, тогда позиционный классовый шаблон case Flux("x") с этим несовместим. Если же вы хотите, чтобы такой шаблон совпадал, придётся либо разрешить равенство с «сырой» str, либо не наследоваться от str и вместо этого предоставить реальный атрибут с __match_args__. Понимание различий между шаблонами, основанными на атрибутах, и особым сравнением «всего объекта» для str помогает избежать неожиданных несовпадений и делает код сопоставления предсказуемым.