Оператор match case в Python: структурное сопоставление с образцом

Оператор match появился в Python 3.10 и часто воспринимается как долгожданный switch case. На самом деле он умеет намного больше: проверяет структуру данных, раскладывает списки и словари по переменным, сопоставляет объекты по атрибутам. Правильно примененный match заменяет десяток строк из isinstance, len и индексов.
Разберем все виды образцов на практических примерах и посмотрим, где match действительно лучше цепочки if/elif, а где нет. Базовые условные конструкции описаны в статье условия в Python.
Синтаксис и порядок проверки
Общая форма выглядит так:
match выражение:
case образец_1:
...
case образец_2 if условие:
...
case _:
... # все остальное
Python вычисляет выражение один раз и проверяет образцы сверху вниз. Выполняется только первый подошедший блок case, проваливания в следующий, как в C, нет, и break писать не нужно. Если ни один образец не подошел и нет case _, ничего не происходит, исключения не будет.
Слова match и case - мягкие ключевые слова. Это значит, что старый код с переменной match (например, результат re.match) продолжает работать.
Литералы: замена switch case
Самый простой случай - сравнение с константами: числами, строками, None, True, False.
def http_status(code: int) -> str:
match code:
case 200:
return "OK"
case 301 | 302:
return "Перенаправление"
case 404:
return "Не найдено"
case 500:
return "Ошибка сервера"
case _:
return "Неизвестный код"
print(http_status(302)) # -> Перенаправление
print(http_status(418)) # -> Неизвестный код
Образец _ (wildcard) подходит к любому значению и играет роль ветки default. Его стоит ставить последним: образцы после него никогда не сработают, и интерпретатор сообщит об этом как об ошибке синтаксиса.
Литералы сравниваются через ==, а None, True и False - через is. Поэтому case True не сработает для числа 1, хотя 1 == True.
Несколько значений в одном case
Оператор | объединяет альтернативы: ветка срабатывает, если подходит любая из них. Так решается запрос "python match case несколько значений":
def day_type(day: str) -> str:
match day.lower():
case "сб" | "вс":
return "выходной"
case "пн" | "вт" | "ср" | "чт" | "пт":
return "рабочий"
case _:
raise ValueError(f"неизвестный день: {day}")
print(day_type("Вс")) # -> выходной
Захват значений и главная ловушка
Если в образце стоит простое имя, это не сравнение с переменной, а захват: образец подходит к любому значению и присваивает его этому имени.
command = "stop"
match command:
case "start":
print("запуск")
case other:
print(f"неизвестная команда: {other}")
# -> неизвестная команда: stop
Отсюда главная ловушка match. Попытка сравнить с константой, записанной в переменную, молча ломает логику:
OK = 200
NOT_FOUND = 404
status = 404
match status:
case OK: # это захват, а не сравнение с 200
print("успех")
case NOT_FOUND:
print("не найдено")
# SyntaxError: name capture 'OK' makes remaining patterns unreachable
Здесь Python хотя бы выдаст ошибку, потому что после захватывающего образца другие ветки недостижимы. Но если такой case окажется последним, ошибки не будет, и он просто проглотит любое значение, перезаписав переменную. Чтобы сравнивать с константами, используйте имена с точкой: атрибуты модуля, класса или перечисления. Такие имена образец трактует как значение.
from enum import Enum
from http import HTTPStatus
class Color(Enum):
RED = "red"
GREEN = "green"
def describe(c: Color) -> str:
match c:
case Color.RED:
return "стоп"
case Color.GREEN:
return "можно ехать"
status = 404
match status:
case HTTPStatus.OK:
print("успех")
case HTTPStatus.NOT_FOUND:
print("не найдено")
# -> не найдено
Перечисления Enum и match отлично дополняют друг друга. HTTPStatus наследует от int, поэтому сравнение с обычным числом работает.
Условия в образце: guard if
После образца можно добавить if с произвольным условием. Ветка выполнится, только если образец подошел и условие истинно. Захваченные переменные в условии уже доступны.
def classify(n: int) -> str:
match n:
case 0:
return "ноль"
case x if x < 0:
return "отрицательное"
case x if x % 2 == 0:
return "четное положительное"
case _:
return "нечетное положительное"
print([classify(v) for v in (-3, 0, 4, 7)])
# -> ['отрицательное', 'ноль', 'четное положительное', 'нечетное положительное']
Если условие ложно, проверка продолжается со следующего case. Учтите, что захват в образце уже произошел, даже если guard не прошел: переменная x останется присвоенной.
Образцы последовательностей
Здесь match начинает по-настоящему выигрывать у if. Образец в квадратных или круглых скобках проверяет, что значение - последовательность нужной длины, и сразу раскладывает элементы по переменным.
def handle(command: str) -> str:
match command.split():
case []:
return "пустая команда"
case ["help"]:
return "доступно: go, take, drop"
case ["go", direction]:
return f"идем на {direction}"
case ["take", item]:
return f"берем {item}"
case ["take", *items]:
return f"берем сразу: {', '.join(items)}"
case ["drop", ("меч" | "щит") as item]:
return f"бросаем {item}"
case [verb, *_]:
return f"не понимаю команду {verb}"
print(handle("go север")) # -> идем на север
print(handle("take ключ лампа")) # -> берем сразу: ключ, лампа
print(handle("drop щит")) # -> бросаем щит
print(handle("drop камень")) # -> не понимаю команду drop
Что тут происходит:
["go", direction]подходит к списку ровно из двух элементов, где первый равен "go", и захватывает второй;*itemsсобирает оставшиеся элементы в список, как при распаковке; звездочка допустима одна на образец;*_означает "любой хвост, он не нужен";as itemзахватывает значение, подошедшее к вложенному образцу с альтернативами.
Важное исключение: строки, байты и bytearray не считаются последовательностями для match. Образец [a, b] не разберет строку "ab" на символы. Это сделано специально, иначе строки постоянно путались бы со списками.
Образцы словарей
Образец в фигурных скобках проверяет, что значение - отображение (например, dict), и в нем есть указанные ключи. Лишние ключи игнорируются, что идеально подходит для разбора JSON.
def process(event: dict) -> str:
match event:
case {"type": "click", "x": int(x), "y": int(y)}:
return f"клик в точке ({x}, {y})"
case {"type": "key", "key": str(key)} if len(key) == 1:
return f"нажата клавиша {key}"
case {"type": "resize", "size": [w, h]}:
return f"новый размер {w}x{h}"
case {"type": str(kind), **rest}:
return f"событие {kind} без обработчика, поля: {sorted(rest)}"
case _:
return "некорректное событие"
print(process({"type": "click", "x": 10, "y": 20, "ts": 1700}))
# -> клик в точке (10, 20)
print(process({"type": "resize", "size": [800, 600]}))
# -> новый размер 800x600
print(process({"type": "scroll", "dy": -3}))
# -> событие scroll без обработчика, поля: ['dy']
Обратите внимание на int(x): это не вызов функции, а образец класса, который одновременно проверяет тип и захватывает значение. Если в JSON придет "x": "10", ветка не сработает. Конструкция **rest собирает остальные ключи в словарь. Если вы часто разбираете JSON-ответы, пригодится материал про модуль json.
Образцы классов
Образец вида ИмяКласса(...) проверяет isinstance и сопоставляет атрибуты объекта. Атрибуты можно указывать по именам:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
@dataclass
class Circle:
center: Point
r: float
@dataclass
class Rect:
top_left: Point
w: float
h: float
def area(shape) -> float:
match shape:
case Circle(r=r):
return 3.14159 * r ** 2
case Rect(w=w, h=h) if w == h:
return w * w
case Rect(w=w, h=h):
return w * h
case _:
raise TypeError(f"неизвестная фигура: {shape!r}")
def where(p: Point) -> str:
match p:
case Point(x=0, y=0):
return "начало координат"
case Point(x=0):
return "на оси Y"
case Point(0, y): # позиционная форма
return "недостижимо"
case Point(x, 0):
return f"на оси X, x={x}"
case _:
return "где-то на плоскости"
print(area(Rect(Point(0, 0), 3, 4))) # -> 12
print(where(Point(5, 0))) # -> на оси X, x=5
Позиционная форма Point(x, 0) работает благодаря атрибуту класса __match_args__, который задает порядок атрибутов. Dataclass создает его автоматически из порядка полей. Для обычного класса добавьте его вручную: __match_args__ = ("x", "y"). Без него позиционные образцы выбросят TypeError.
Ветка "недостижимо" в примере специально показывает порядок проверки: Point(0, y) никогда не сработает, потому что все точки с x == 0 уже перехватила ветка выше. Образцы классов удобно сочетать с иерархиями из статьи про классы и объекты.
Вложенные образцы: вычисление выражений
Образцы можно вкладывать друг в друга на любую глубину, и здесь match раскрывается полностью. Классический пример - вычисление арифметического выражения, записанного вложенными кортежами. Такой формат часто получается после разбора текста парсером.
def evaluate(expr):
match expr:
case int() | float() as number:
return number
case ("+", left, right):
return evaluate(left) + evaluate(right)
case ("*", left, right):
return evaluate(left) * evaluate(right)
case ("-", operand):
return -evaluate(operand)
case ("/", _, 0):
raise ZeroDivisionError("деление на ноль в выражении")
case ("/", left, right):
return evaluate(left) / evaluate(right)
case _:
raise ValueError(f"неизвестное выражение: {expr!r}")
# (2 + 3) * -4
print(evaluate(("*", ("+", 2, 3), ("-", 4)))) # -> -20
print(evaluate(("/", 10, 4))) # -> 2.5
Каждая ветка одновременно проверяет, что выражение - кортеж нужной длины, что первый элемент - определенный оператор, и раскладывает операнды по переменным. Ветка с делением на ноль показывает, как литерал 0 (он равен и 0.0, ведь сравнение идет через ==) внутри последовательности отсекает частный случай раньше общего. Рекурсивный вызов evaluate разбирает вложенные выражения любой глубины. Тот же подход используют при обходе абстрактных синтаксических деревьев модуля ast: образцы классов вроде ast.BinOp(op=ast.Add()) читаются почти как описание грамматики.
Обратите внимание на int() | float() as number: пустые скобки в образце класса означают "только проверка типа, без разбора атрибутов". Заметьте также, что bool - подкласс int, поэтому True пройдет эту проверку. Если это нежелательно, поставьте выше отдельную ветку case bool(): с ошибкой.
Практика: обработчик сообщений чат-бота
Типичная задача, где match экономит много кода, - разбор входящих сообщений с разной структурой. Допустим, бот получает словари из API и должен реагировать на команды, текст и вложения:
def reply(message: dict) -> str:
match message:
case {"text": str(text)} if text.startswith("/"):
match text.split():
case ["/start"]:
return "Привет! Напишите /help"
case ["/remind", minutes, *words] if minutes.isdigit() and words:
return f"Напомню через {minutes} мин: {' '.join(words)}"
case [command, *_]:
return f"Команда {command} не поддерживается"
case {"text": str(text)}:
return f"Вы написали {len(text)} символов"
case {"photo": [*sizes]} if sizes:
return f"Фото получено, вариантов размера: {len(sizes)}"
case {"document": {"file_name": str(name)}}:
return f"Документ {name} сохранен"
case _:
return "Не понимаю такое сообщение"
print(reply({"text": "/remind 15 позвонить маме"}))
# -> Напомню через 15 мин: позвонить маме
print(reply({"document": {"file_name": "отчет.pdf", "size": 2048}}))
# -> Документ отчет.pdf сохранен
print(reply({"sticker": "cat"}))
# -> Не понимаю такое сообщение
Здесь показаны сразу несколько приемов. Внешний match определяет тип сообщения по набору ключей, вложенный разбирает текст команды на слова. Guard if minutes.isdigit() and words отсекает некорректные напоминания, которые затем попадают в общую ветку [command, *_]. Образец {"document": {"file_name": str(name)}} заглядывает во вложенный словарь, игнорируя лишние поля вроде размера. На цепочке if та же логика заняла бы втрое больше строк с ручными проверками isinstance и in.
Порядок веток снова важен: команды проверяются раньше обычного текста, потому что команда тоже подходит под образец {"text": str(text)}.
Проверка полноты с assert_never
Python не проверяет, что match обработал все возможные варианты: если в перечисление добавят новое значение, функция молча вернет None. Эту проблему решает статический анализатор типов (mypy, pyright) вместе с typing.assert_never, который появился в Python 3.11:
from enum import Enum
from typing import assert_never
class Status(Enum):
NEW = "new"
PAID = "paid"
SHIPPED = "shipped"
def label(status: Status) -> str:
match status:
case Status.NEW:
return "новый"
case Status.PAID:
return "оплачен"
case Status.SHIPPED:
return "отправлен"
case _:
assert_never(status)
print(label(Status.PAID)) # -> оплачен
Пока все значения перечисления обработаны, анализатор считает ветку case _ недостижимой, и тип status в ней сужается до Never. Стоит добавить Status.CANCELLED и забыть про него в match, как mypy сообщит об ошибке еще до запуска программы. Во время выполнения assert_never выбросит AssertionError, поэтому ошибка не пройдет незамеченной и без анализатора. Тот же прием работает с объединениями типов вроде int | str | None.
Типичные ошибки при использовании match
- Голое имя вместо константы.
case MAX_SIZE:захватывает значение, а не сравнивает. Используйте имена с точкой или перечисления. - Неправильный порядок веток. Общие образцы, поставленные выше частных, перехватывают значения. Пишите ветки от самых конкретных к самым общим.
- Ожидание точного совпадения словаря. Образец
{"type": "a"}подходит к любому словарю с таким ключом, лишние ключи не мешают. Для проверки отсутствия лишних ключей добавьте**restи guardif not rest. - Строки как последовательности. Образец
[first, *_]не подходит к строке. Сначала превратите ее в список. - Скобки не задают тип. Образцы
[a, b]и(a, b)эквивалентны: оба подходят и к списку, и к кортежу. Если тип важен, используйтеlist([a, b])илиtuple((a, b)). - Позиционные образцы без __match_args__. Для обычного класса
Point(x, y)в образце выброситTypeError, пока вы не объявите порядок атрибутов.
Виды образцов: сводная таблица
| Образец | Пример | Что проверяет |
|---|---|---|
| литерал | case 404: | равенство значению |
| захват | case x: | подходит всегда, присваивает имя |
| wildcard | case _: | подходит всегда, ничего не присваивает |
| значение с точкой | case Color.RED: | равенство константе |
| альтернатива | case "a" | "b": | любой из вариантов |
| последовательность | case [x, *rest]: | тип, длина, элементы |
| отображение | case {"id": id_}: | наличие ключей и их значения |
| класс | case Point(x=0): | isinstance и атрибуты |
| as | case [1, _] as pair: | захват всего подобразца |
match case или if/elif
Для простого сравнения одной переменной с тремя константами match не дает выигрыша: if/elif или словарь соответствий здесь не хуже. Словарь вообще часто оказывается лучшим "switch" в Python:
handlers = {"start": start, "stop": stop, "pause": pause}
handlers.get(command, unknown)()
А вот когда нужно одновременно проверить тип, длину, наличие ключей и достать вложенные значения, match выигрывает с большим отрывом. Сравните разбор события через if:
if (isinstance(event, dict) and event.get("type") == "resize"
and isinstance(event.get("size"), (list, tuple))
and len(event["size"]) == 2):
w, h = event["size"]
...
и через match: case {"type": "resize", "size": [w, h]}:. Одна строка описывает форму данных, и ее читают как схему.
Короткое правило выбора:
- сравнение с несколькими значениями одной переменной -
if/elif, словарь илиmatchна вкус; - разбор структуры (команды, JSON, AST, сообщения протокола) -
match; - сложные логические условия на разных переменных -
if/elif; - код должен работать на Python 3.9 и ниже - только
if.
Частые вопросы
Как сделать default в match case?
Последней веткой поставьте case _:. Она подходит к любому значению. Если нужно еще и использовать это значение, напишите case other:, и оно окажется в переменной other.
С какой версии Python работает match case?
С Python 3.10. На 3.9 и более старых версиях код с match выдаст SyntaxError еще на этапе разбора файла, поэтому библиотекам с поддержкой старых версий его использовать нельзя.
Почему case с моей переменной срабатывает всегда?
Простое имя в образце означает захват, а не сравнение. Используйте имя с точкой (Config.LIMIT, Color.RED) или guard: case x if x == limit:.
Можно ли разобрать строку по символам образцом последовательности?
Нет, строки специально исключены из образцов последовательностей. Разбейте строку заранее: match list(text): или match text.split():.
Можно ли в case проверять строку регулярным выражением?
Прямой поддержки нет: образцы сравнивают строки только на точное равенство. Есть два рабочих варианта. Первый - guard: case str(s) if re.fullmatch(r"\d{6}", s):. Второй - сначала выполнить re.match, а затем сопоставлять результат: match m.groups() if m else None: с образцами последовательностей для групп. Второй вариант удобен, когда регулярное выражение одно, а дальнейшая логика зависит от содержимого групп. Если шаблонов много и они разные, обычная цепочка if с re.fullmatch часто читается проще, чем десяток guard-условий.
Быстрее ли match, чем if/elif?
Нет, заметного выигрыша в скорости нет: образцы проверяются по очереди, как ветки if. Выбирайте match ради читаемости при разборе структур данных, а не ради производительности.