Одним из самых нашумевших нововведений Python 3.10 стало так называемое структурное сопоставление с образцом (structural pattern matching). Этот мощный инструмент берёт своё начало в функциональных языках программирования, а в последнее время постепенно появляется и во многих мейнстримовых языках (Java, C#, Kotlin, Swift, и т.д.). Как всегда, Python старается не отставать и идти в ногу со временем. Так зачем же популярные языки программирования добавляют поддержку этого механизма? В чём его отличие от простого условного оператора if
? И вообще, в чём практическая польза сопоставления с образцом? Пробуем разобраться далее.
О чём эта статья
Все примеры кода приведены для версии Python 3.10
Предыстория
В Python существует возможность распаковки последовательностей (sequence unpacking), которая позволяет одновременно присваивать нескольким переменным значения элементов последовательности:
numbers = [1, 2, 3]
[one, two, three] = numbers
# По сравнению с использованием индексов массива
one = numbers[0]
two = numbers[1]
three = numbers[2]
Оба варианта приводят к одному и тому же результату, но, думаю, можно согласиться, что использование распаковки повышает выразительность и сокращает количество строк кода. Такой механизм работает, если количество указанных переменных совпадает с количеством элементов в последовательности. Здесь важно отметить, что, хотя мы и используем квадратные скобки (как при обычном объявлении списков) в левой части выражения распаковки, новый список [one, two, three]
не создаётся. Эта особенность нам пригодится дальше для понимания работы конструкций match
и case
.
Подобным образом можно распаковывать не только списки, но и кортежи, а множественное присваивание — это фактически использование механизма распаковки кортежа:
answers = ("yes", "no")
(yes, no) = answers
# Скобки можно опустить
answers = "yes", "no"
yes, no = answers
# Множественное присваивание
yes, no = "yes", "no"
Для выбора только определённых элементов последовательности, например первого и последнего, может использоваться следующий синтаксис:
numbers = [1, 2, 3, 4, 5]
[one, _, _, _, five] = numbers
# Или более кратко
one, *_, five = numbers
print(one) # 1
print(five) # 5
print(_) # [2, 3, 4]
Элементы списка, которые нас не интересуют в данном случае, будут присвоены переменной _
. Символ нижнего подчёркивания обычно используется для обозначения того факта, что нам неважно конкретное значение переменной.
Переходим к сопоставлению
Сопоставление с образцом в некотором смысле является расширением идеи распаковки последовательностей. В одном из примеров выше в роли образца выступает выражение [one, two, three]
до оператора =
.
numbers = [1, 2, 3]
[one, two, three] = numbers
С этим образцом и сопоставляется (другими словами, сравнивается) значение переменной numbers
. Если это сопоставление успешно, то значения соответствующих элементов списка присваиваются указанным переменным. Если же структура (в случае списка — это количество элементов) переменной numbers
будет отличаться от образца, то сопоставление будет считаться неудачным, и возникнет исключение ValueError
:
numbers = [1, 2]
[one, two, three] = numbers
# ValueError: not enough values to unpack (expected 3, got 2)
Match и case
Замечательно, но что, если нас не устраивает исключение ValueError
в случае неудачного сопоставления, и мы хотим как-то заранее проверить, что структура списка numbers
соответствует нашему образцу? Одним из вариантов может быть явная проверка условий в if
и elif
:
numbers = [1, 2, 3]
if isinstance(numbers, list):
if len(numbers) == 3:
[one, two, three] = numbers
print("В списке три элемента:", one, two, three)
# В списке три элемента: 1 2 3
elif len(numbers) == 2:
[one, two] = numbers
print("В списке два элемента:", one, two)
Новые ключевые слова match
и case
делают такого рода проверки более выразительными:
numbers = [1, 2, 3]
match numbers:
case [one, two, three]:
print("В списке три элемента:", one, two, three)
# В списке три элемента: 1 2 3
case [one, two]:
print("В списке два элемента:", one, two)
После ключевого слова match
указывается выражение для сопоставления, в данном случае — переменная numbers
. В каждом блоке case
находится образец, с которым мы хотим сопоставить значение переменной numbers
. Также как и проверка условий в if
и elif
, сопоставление с образцом в каждом блоке case
может быть либо успешным, либо неудачным. В случае успешного сопоставления переменным указанным в образце (one
, two
, three
) присваиваются значения соответствующих элементов списка numbers
, ещё говорят, что переменные связываются со значениями.
Если образец не подходит, то сопоставление считается неудачным, переменные не связываются со значениями, и происходит переход к следующему блоку case
для проверки образца там. При первом успешном сопоставлении оставшиеся образцы в других блоках case
не проверяются.
Образцы позволяют проверять не только длину списка, но и конкретные значения элементов:
numbers = [1, 100, 1]
match numbers:
case [1, number, 1]:
print(number) # 100
Сопоставление с таким образцом будет успешным, если список numbers
состоит из трёх элементов, а также первый и последний элемент равны 1
. В случае успешного сопоставления, переменная number
связывается со значением второго элемента списка.
Сопоставление с литералами
В блоках case
выше мы рассматривали только образцы последовательностей (sequence patterns) для сопоставления. Конечно, в качестве образцов могут использоваться и другие выражения. Одним из самых простых для понимания видов образцов являются литералы, например, числа и строки. Сопоставление с такими образцами фактически эквивалентно простому сравнению с помощью оператора ==
.
color = "yellow"
match color:
case "red":
print("Красный")
case "yellow":
print("Жёлтый") # Жёлтый
number = 42
match number:
case 1:
print("Один")
case 42:
print("Сорок два") # Сорок два
Литералы True
, False
и None
также могут использоваться как образцы. Такое сопоставление будет эквивалентно использованию оператора is
:
is_success = True
match is_success:
case None:
print("Результат отсутствует")
case False:
print("Неудача")
case True:
print("Успех") # Успех
Аналогичный пример выше с использованием блока if
выглядел бы следующим образом:
if is_success is None:
print("Результат отсутствует")
elif is_success is False:
print("Неудача")
elif is_success is True:
print("Успех") # Успех
Захват значений в образце
Об образцах в блоках case
следует думать как об особых конструкциях языка. Исполнение кода в образце после ключевого слова case
может отличаться от исполнения точно такого же кода в другом месте программы вне блока case
. Одним из примеров этого различия являются так называемые захватывающие образцы (capture patterns). Рассмотрим следующий пример:
color = "yellow"
red = "red"
match color:
case red:
# Значение color успешно сопоставляется с образцом red ¯\_(ツ)_/¯
print(color) # yellow
print(red) # yellow
Возможно, кого-то удивит, что значение переменной color
соответствует образцу red
в данном случае, ведь значение переменной red
— это строка "red"
. Но в блоке case
действуют свои правила! Здесь red
— это не переменная со значением "red"
, а образец, сопоставление с которым всегда проходит успешно, именно поэтому подобные образцы называются неопровержимыми (irrefutable).
Образец red
как бы захватывает значение color
целиком, и после успешного сопоставления переменная red
связывается со значением "yellow"
. Конечно, вместо red
мы могли бы использовать более подходящее имя, чтобы не вносить такую путаницу, но имя red
особенно наглядно показывает этот не совсем интуитивный момент.
Wildcard
Помните, как мы использовали нижнее подчёркивание _
при распаковке последовательностей для обозначения элементов, которые нас не интересуют?
[first, _, last] = [100, 200, 300]
Для подобных целей существует особый образец _
, который называется wildcard и является специальным видом захватывающего образца (capture pattern). Образец _
также является неопровержимым, иначе говоря, любое значение соответствует этому образцу, и обычно используется для обработки сценариев по умолчанию, наподобие ветки else
в условном операторе if
. Блок case
с образцом _
обязательно должен быть последним в блоке match
:
def parse_answer(answer: str) -> bool | None:
"""
Аннотация типа bool | None означает,
что функция возвращает либо значение типа bool, либо None.
"""
match answer:
case "yes":
return True
case "no":
return False
case _:
# Выполнится, если сопоставление с двумя образцами выше
# было неудачным
return None
Сопоставление с именованными значениями
Мы уже выяснили, что использование имён в образцах позволяет захватывать значения (см. образец red
в предыдущем разделе). В таком случае что же делать, если мы хотим использовать в качестве образца какое-либо именованное значение, например, переменную?
Здесь работает следующее правило: если в образце есть .
, то происходит сопоставление со значением соответствующего атрибута, если же .
в образце нет, то используется захват значения:
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
user: str
some_other_number: int
settings = Settings(user="Гвидо", some_other_number=42)
match "Гвидо":
case settings.user:
# Используется сравнение со значением settings.user
print("Гвидо!") # Гвидо!
case _:
print("Неизвестный пользователь")
match "Гвидо":
case username:
# Используется захват значения, переменная username
# связывается со значением "Гвидо"
print(username) # Гвидо
Этот же механизм позволяет использовать перечисления (enum) в качестве образцов:
from enum import Enum
class Color(Enum):
YELLOW = 1
RED = 2
def color_to_string(color: Color) -> str:
match color:
case Color.YELLOW:
return "yellow"
case Color.RED:
return "red"
Словари
Словари также можно проверять на соответствие специальным образцам:
numbers = {"one": 1, "two": 2, "three": 3}
match numbers:
case {"one": 1, "two": two}:
print(two) # 2
В примере выше словарь numbers
соответствует образцу, и переменная two
связывается со значением в словаре по ключу "two"
. Как видно из примера, особенностью сопоставления словарей является то, что дополнительные ключи, которые не указаны в образце, не учитываются при сопоставлении. В качестве ключей в образце могут использоваться либо литералы, либо образцы именованных значений (см. предыдущий раздел).
Охранные выражения
Охранные выражения (guards) позволяют задать дополнительные условия в блоке case
, которые нельзя указать в образце. Посмотрим на следующий пример:
numbers = [1, 2]
match numbers:
case [one, two] if one < two:
print(one, "<", two) # 1 < 2
Список numbers
успешно сопоставится с образцом, только если условие в охранном выражении будет истинно. В случае, если условие в охранном выражении вернёт False
, то сопоставление будет считаться неудачным.
Группа образцов
В блоках case
можно сопоставлять значения с несколькими образцами, используя разделитель |
, что означает логическое ИЛИ. Например, следующий код сопоставляет значение number
в трёх блоках case
:
number = 2
match number:
case 1 | 2 | 3:
print("Один, два или три") # Один, два или три
case 4:
print("Четыре")
case _:
print("Другое число")
Образец в первом блоке case
имеет опцию ИЛИ, т.е. если значение number
соответствует любому из значений в этом образце, то сопоставление будет успешным, и выполнится код блока case
.
Классы как образцы
Одной из самых часто используемых функций в Python является проверка isinstance
. Эта функция позволяет проверить класс объекта, чтобы убедиться, что с объектом можно выполнить определённые действия:
from dataclasses import dataclass
@dataclass(frozen=True)
class Rectangle:
length: float
width: float
shape = Rectangle(length=4.5, width=2.5)
# ...
if isinstance(shape, Rectangle):
# Здесь мы можем быть уверены,
# что у объекта shape есть атрибуты length и width
if shape.width == 2.5:
length = shape.length
print(f"Площадь прямоугольника = { length * 2.5 }")
# Площадь прямоугольника = 11.25
Использование сопоставления с образцом позволяет проверять не только класс, но и внутреннюю структуру объекта:
# продолжение предыдущего примера
match shape:
case Rectangle(length, width=2.5):
# Переменная length связывается со значением shape.length
print(f"Площадь прямоугольника = { length * 2.5 }")
# Площадь прямоугольника = 11.25
В примере выше сопоставление будет успешным только в том случае, если shape
— это объект класса Rectangle
и ширина этого прямоугольника равна 2.5.
Здесь следует ещё раз обратить внимание, что код в образце никогда не создаёт новых объектов (аналогично распаковка списка не создаёт нового списка). Выражение Rectangle(length, width=2.5)
в блоке case
— это образец, а не создание объекта Rectangle
.
Mypy и проверка на полноту
Далее в примерах я буду использовать аннотации типов и инструмент для статической проверки типов Mypy версии 0.961 с настройками по умолчанию. Аннотации типов в Python необязательны, но их использование и проверка Mypy позволяет значительно повысить надёжность кода.
Добавим в пример из предыдущего раздела несколько новых геометрических фигур:
# продолжение предыдущего примера
@dataclass(frozen=True)
class Circle:
radius: float
@dataclass(frozen=True)
class Square:
side: float
# Shape используется только для аннотаций типов и означает,
# что значения типа Shape -- это объекты одного из трёх классов
Shape = Rectangle | Circle | Square
Рассмотрим, как нам может помочь сопоставление с образцом при работе с геометрическими фигурами. Добавим функцию для вычисления площади фигуры:
# продолжение предыдущего примера
import math
def calc_area(shape: Shape) -> float:
"""Вычисляет площадь фигуры."""
match shape:
case Rectangle(length, width):
return length * width
case Circle(radius):
return math.pi * radius**2
case Square(side):
return side**2
print(calc_area(Rectangle(2.5, 4.5))) # 11.25
print(calc_area(Circle(3.0))) # 28.2743...
print(calc_area(Square(1.5))) # 2.25
Мы добавили соответствующий образец для каждого класса, объектом которого может быть shape
. Как видно, образцы классов позволяют в одном выражении проверить фактический класс shape
и связать нужные переменные с атрибутами объекта. Если запустить Mypy для проверки текущего кода, то никаких ошибок не будет, — все типы данных используются корректно.
Продолжим работу над нашей программой. Ещё одной необходимой функцией для работы с геометрическими фигурами может быть вычисление периметра:
# продолжение предыдущего примера
def calc_perimeter(shape: Shape) -> float:
"""Вычисляет периметр фигуры."""
match shape:
case Rectangle(l, w):
return 2 * (l + w)
case Circle(r):
return 2 * math.pi * r
print(calc_perimeter(Rectangle(2.5, 4.5))) # 14.0
print(calc_perimeter(Circle(3.0))) # 18.8495...
print(calc_perimeter(Square(1.5))) # ???
Внимательный читатель заметит, что в этот раз мы забыли указать образец для класса Square
, что с большой вероятностью приведёт к ошибкам в работе нашей программы. Однако если запустить Mypy, то мы сразу увидим соответствующую ошибку:
pattern_matching.py:38: error: Missing return statement
Found 1 error in 1 file (checked 1 source file)
Это жалоба на то, что наши образцы не покрывают всех возможных случаев — и, действительно, так оно и есть! Когда мы определяем функцию, мы должны всегда включать все образцы, которые можно сопоставить с любым входным значением. В случае нашего типа Shape
— мы должны указывать три образца для каждой геометрической фигуры.
Если мы расширим наш тип Shape
и добавим новую фигуру Triangle
:
# продолжение предыдущего примера
@dataclass(frozen=True)
class Triangle:
a: float
b: float
c: float
Shape = Rectangle | Circle | Square | Triangle
то при проверке Mypy сообщит нам обо всех местах в коде, где нужно добавить образец для новой фигуры во все блоки match
:
pattern_matching.py:31: error: Missing return statement
pattern_matching.py:46: error: Missing return statement
Found 2 errors in 1 file (checked 1 source file)
В нашем случае мы должны расширить блоки match
в двух функциях для вычисления площади и периметра треугольника. Если представить, что наша программа гораздо больше, и таких функций не две, а несколько десятков, то ценность проверки на полноту (exhaustiveness checking) значительно возрастает.
Итоги
Сопоставление с образцом является очень мощным средством языка. Этот механизм позволяет выполнять сложные проверки структуры объектов, что даёт возможность сделать код программы более выразительным и легкочитаемым. В свою очередь использование Mypy для статической проверки блоков match
и case
повышает надёжность кода, а проверка на полноту (exhaustiveness checking) является очень важным свойством для гарантий корректности и рефакторинга программ.