Одним из самых нашумевших нововведений 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) является очень важным свойством для гарантий корректности и рефакторинга программ.

Полезные ссылки

  1. PEP 635 – Structural Pattern Matching: Motivation and Rationale

  2. PEP 636 – Structural Pattern Matching: Tutorial

  3. Mypy

Комментарии (0)