Сообщить об ошибке.

Соответствие структуре шаблона, конструкция match/case

В Python 3.10 введена новая конструкция match/case, которая называется Structural pattern matching (соответствие структуре шаблона). Оператор match был введен для того, чтобы быть больше чем просто похожим на оператор switch, который присутствует во многих других языках программирования. PEP 634, 635 и 636 содержат много информации о том, что приносит конструкция match/case в Python 3.10, а также обоснование его добавления.

Во многих случаях конструкция match/case, может упростить и повысить читабельность кода Python. Этот материал акцентирует внимание на практическом использовании конструкции match/case, чтобы писать короткий и красивый код.

Шаблоны в операторах case, конструкции match/case состоят из последовательностей, словарей, примитивных типов данных (int, float,str и т.д.), а также экземпляров классов. Сопоставление с образцом позволяет программам извлекать информацию из сложных типов данных, переходить к структуре данных и применять определенные действия на основе различных форм данных.

Общий синтаксис конструкции match/case:

match subject:
    case <pattern_1>:
        <action_1>
    case <pattern_2>:
        <action_2>
    case <pattern_3>:
        <action_3>
    case _:
        <action_wildcard>

Оператор match принимает выражение subject и сравнивает его значение с последовательными шаблонами, заданными как один или несколько блоков case. В частности, сопоставление с образцом работает следующим образом:

  • использование данных с типом и формой (subject);
  • оценка subject в заявлении match;
  • сравнение subject с каждым шаблоном в заявлении case сверху вниз, пока совпадение не будет подтверждено.
  • выполнение действия action, связанного с шаблоном подтвержденного совпадения;
  • если точное совпадение не подтверждено, то в качестве совпадающего случая будет использоваться последний case c подстановочным знаком '_', если он указан. Если точное совпадение не подтверждено и case _: - не существует, то весь блок match не выполняется.

Обратите внимание, что большинство литералов сравниваются по равенству. Однако синглтоны: True, False и None сравниваются по идентичности.

Содержание:


Базовые приемы использования match/case.

Для примера простого использования match/case реализуем функцию факториала. Пример взят из материала "Рекурсия в Python":

def factorial(n):
    if n == 0 or n == 1:
        return 1
    else:
        return n * factorial(n-1)

factorial(5)    
# 120

Теперь вместо конструкции if/else будем использовать сопоставление match/case:

def factorial(n):
    match n:
        case 0 | 1:
            return 1
        case _:
            return n * factorial(n - 1)

factorial(5)

Обратите внимание на пару моментов: конструкция начинается с выражения match n, это означает, что можно делать разные вещи в зависимости от того, какое значение передается в n. В конструкции также присутствуют операторы case, которые можно рассматривать в различных возможных сценариях, которые необходимо обработать. Каждый оператор case должен сопровождаться шаблоном, с которым оператор match будет пытаться сопоставить значение переменной n.

Шаблоны также могут содержать альтернативы, которые можно перечислить через '|' (или). В примере выше, паттерн case 0 | 1: означает: значение n равно 0 или 1. Второй шаблон примера, case _:, является значением по умолчанию, т. е. если не один шаблон не подошел (не сработал), то выбирается case _: (работает как оператор else без условия).

Иногда может потребоваться сопоставление с более структурированным шаблоном, указанным в case

def go(direction):
    match direction:
        case "North" | "East" | "South" | "West":
            return "Хорошо, я пошел!"
        case _:
            return "Неизвестное направление..."

print(go("North"))
# Хорошо, я пошел!
print(go("asfasdf"))    
# Неизвестное направление...

Теперь представьте, что логика обработки переменной “direction” вложена в нечто более сложное:

def act(command):
    match command.split():
        case "Cook", "breakfast":
            return "Я люблю завтракать."
        case "Cook", *wtv:
            return "Что то готовится..."
        case "Go", "North" | "East" | "South" | "West":
            return "Хорошо, я пошел!"
        case "Go", *wtv:
            return "Неизвестное направление..."
        case _:
            return "Я не могу этого сделать..."

print(act("Go North"))
# Хорошо, я пошел!
print(act("Go asdfasdf"))
# Неизвестное направление...
print(act("Cook breakfast")) 
# Я люблю завтракать.
print(act("Drive"))          
# Я не могу этого сделать...

Можно зафиксировать совпадение, оставив опции открытыми, а затем записать результат совпадения в переменную:

def act(command):
    match command.split():
        case "Cook", "breakfast":
            return "Я люблю завтракать."
        case "Cook", *wtv:
            return f"Готовится {wtv}..."
        # результат совпадения записывается в переменную `direction`
        case "Go", "North" | "East" | "South" | "West"  as direction:
            return f"Хорошо, я пошел на {direction}!"
        case "Go", *wtv:
            return f"{wtv} - неизвестное направление."
        case _:
            return "Я не могу этого сделать..."

print(act("Go North"))
# Хорошо, я пошел на  North!
print(act("Go asdfasdf"))
# asdfasdf -  неизвестное направление.

Соответствие шаблону базовой структуры.

Хотя оператор соответствия match может использоваться как простой оператор if/else, как показано выше, он действительно эффективен, когда нужно сопоставить структурированные данные:

def normalise_colour_info(colour):
    """Нормализация структуры цвета, как (name, (r, g, b, alpha))."""
    match colour:
        case (r, g, b):
            name = ""
            a = 0
        case (r, g, b, a):
            name = ""
        case (name, (r, g, b)):
            a = 0
        case (name, (r, g, b, a)):
            pass
        case _:
            raise ValueError("Unknown colour info.")
    return (name, (r, g, b, a))

>>> print(normalise_colour_info((240, 248, 255)))
# ('', (240, 248, 255, 0))
>>> print(normalise_colour_info((240, 248, 255, 0)))
# ('', (240, 248, 255, 0))
>>> print(normalise_colour_info(("AliceBlue", (240, 248, 255))))
# ('AliceBlue', (240, 248, 255, 0))
>>> print(normalise_colour_info(("AliceBlue", (240, 248, 255, 0.3))))
# ('AliceBlue', (240, 248, 255, 0.3))

Обратите внимание, что каждый case содержит выражение, подобное левой части присвоения с распаковкой, и когда структура цвета совпадает со структурой, указанной в определенном case, то тогда значения присваиваются переменным, указанным в case.

Это большое улучшение по сравнению с эквивалентным кодом с операторами if/else:

def normalise_colour_info(colour):
    """Нормализация структуры цвета, как (name, (r, g, b, alpha))."""

    if not isinstance(colour, (list, tuple)):
        raise ValueError("Unknown colour info.")

    if len(colour) == 3:
        r, g, b = colour
        name = ""
        a = 0
    elif len(colour) == 4:
        r, g, b, a = colour
        name = ""
    elif len(colour) != 2:
        raise ValueError("Unknown colour info.")
    else:
        name, values = colour
        if not isinstance(values, (list, tuple)) or len(values) not in [3, 4]:
            raise ValueError("Unknown colour info.")
        elif len(values) == 3:
            r, g, b = values
            a = 0
        else:
            r, g, b, a = values
    return (name, (r, g, b, a))

Версия функции нормализации цвета становится еще лучше, если добавить проверку типов, запрашивая конкретные значения:

def normalise_colour_info2(colour):
    match colour:
        case (int(r), int(g), int(b)):
            name = ""
            a = 0
        case (int(r), int(g), int(b), int(a)):
            name = ""
        case (str(name), (int(r), int(g), int(b))):
            a = 0
        case (str(name), (int(r), int(g), int(b), int(a))):
            pass
        case _:
            raise ValueError("Unknown colour info.")
    return (name, (r, g, b, a)))

print(normalise_colour_info(("AliceBlue", (240, 248, 255))))
# ('AliceBlue', (240, 248, 255, 0))
print(normalise_colour_info2(("Red", (255, 0, "0"))))
# ValueError: Unknown colour info.

Соответствие структуре объектов Python.

Конструкция match/case также может использоваться для сопоставления структуры экземпляров класса. Создадим класс Point(), который будет представлять точки в двумерном пространстве:

class Point:
    x: int
    y: int

Теперь надо написать небольшую функцию, которая берет Point() и записывает небольшое описание того, где находится точка. Можно использовать сопоставление с образцом match/case для захвата значений атрибутов x и y объекта Point(), более того, можно использовать однострочные операторы if/else, чтобы помочь сузить тип совпадений!

Смотрим пример:

def describe_point(point):
    """Удобочитаемое описание положения точки."""

    match point:
        case Point(x=0, y=0):
            desc = "в начале координат"
        case Point(x=0, y=y):
            desc = f"на вертикальной оси, при y = {y}"
        case Point(x=x, y=0):
            desc = f"на горизонтальной оси, при x = {x}"
        # использование однострочного оператора if/else
        case Point(x=x, y=y) if x == y:
            desc = f"вдоль линии x = y, где x = y = {x}"
        case Point(x=x, y=y) if x == -y:
            desc = f"вдоль линии x = -y, где x = {x} и y = {y}"
        case Point(x=x, y=y):
            desc = f"в позиции {point}"

    return "Точка находится " + desc

print(describe_point(Point(0, 0)))
# Точка находится в начале координат
print(describe_point(Point(3, 0)))
# Точка находится на горизонтальной оси, при x = 3
print(describe_point(Point(3, -3)))
# Точка находится вдоль линии x = -y, где x = 3 и y = -3
print(describe_point(Point(1, 2)))
# Точка находится в позиции (1, 2)

Обратите внимание, что для создания нового шаблона экземпляра Point(), приходится указывать, какой аргумент был x, а какой y (Point(x=x, y=y)). Можно использовать позиционные параметры с некоторыми встроенными классами, которые обеспечивают упорядочение их атрибутов (например, dataclasses). Также можно определить конкретную позицию для атрибутов в шаблонах, установив специальный атрибут __match_args__ в классах.

Вот более короткая версия приведенного выше примера, в котором используется __match_args__ при определении класса Point():

class Point:
    __match_args__ = ["x", "y"]
    x: int
    y: int

def describe_point(point):
    """Удобочитаемое описание положения точки."""

    match point:
        case Point(0, 0):
            desc = "в начале координат"
        case Point(0, y):
            desc = f"на вертикальной оси, при y = {y}"
        case Point(x, 0):
            desc = f"на горизонтальной оси, при x = {x}"
        case Point(x, y):
            desc = f"в позиции {point}"

    return "Точка находится " + desc

print(describe_point(Point(0, 0)))
# Точка находится в начале координат
print(describe_point(Point(3, 0)))
# Точка находится на горизонтальной оси, при x = 3
print(describe_point(Point(1, 2)))
# The point is at (1, 2)

Вложенные паттерны.

Паттерны могут быть произвольно вложенными. Например, если данные представляют собой короткий список точек, они могут быть сопоставлены следующим образом:

match points:
    case []:
        print("Нет точек для сопоставления.")
    case [Point(0, 0)]:
        print("Единственная точка в начале координат.")
    case [Point(x, y)]:
        print(f"Единственная точка с координатами {x}, {y}.")
    case [Point(0, y1), Point(0, y2)]:
        print(f"Две точки на оси Y в точке {y1}, {y2}.")
    case _:
        print("В списке есть еще кое-что.")

Конструкция match/case + распаковка последовательности или словаря.

Еще одна классная вещь, которую можно сделать в конструкции сопоставления match/case - это использовать эффект распаковки последовательностей (одна звездочка *) и словарей (две звездочки **).

Распаковка последовательности и конструкция match/case.

Смотрим на следующий код, который распаковывает последовательность:

>>> head, *body, tail = range(10)
>>> print(head, body, tail)
# 0 [1, 2, 3, 4, 5, 6, 7, 8] 9

Здесь *body сообщает Python, что нужно вставлять в переменную body все, что не входит в head или tail. В конструкции match/case можно использовать распаковку * со списками и кортежами. Другими словами шаблоны последовательностей поддерживают подстановочные знаки: [x, y, *rest] и (x, y, *rest) работают аналогично подстановочным знакам при распаковке. После * также может быть имя _, следовательно (x, y, *_) соответствует последовательности по крайней мере из двух элементов без привязки остальных элементов.

Например нужно создать последовательность, где каждое число получается из предыдущего, сопоставляя первые цифры. Например, если в начале есть три одинаковые цифры подряд, например 222, то они переписываются их как 32, далее, оставшееся часть последовательности, снова оценивается в цикле, пока совпадения не закончатся.

С оператором match код становится намного чище:

def rule_substitution(seq):
    new_seq = []
    while seq:
        match seq:
            case [x, y, z, *tail] if x == y == z:
                new_seq.extend(["3", x])
            case [x, y, *tail] if x == y:
                new_seq.extend(["2", x])
            case [x, *tail]:
                new_seq.extend(["1", x])
        seq = tail
    return new_seq

seq = ["1"]
for _ in range(10):
    seq = rule_substitution(seq)
    print("".join(seq))

# 11
# 21
# 1211
# 111221
# 312211
# 13112221
# 1113213211
# 31131211131221
# 13211311123113112211
# 11131221133112132113212221

Распаковка словаря и конструкция match/case.

Точно так же мы можем использовать ** для сопоставления остатка словаря. Но сначала посмотрим, каково поведение match/case при сопоставлении словарей:

d = {0: "oi", 1: "uno"}
match d:
    case {0: "oi"}:
        return True
    case _:
        return False

# True

Если словарь d имеет пару ключ/значение 0: "oi" и этот ключ/значение указан в единственном операторе case {0: "oi"}, то происходит совпадение. При сопоставлении со словарями необходимо заботится только о совпадении структуры, которая была явно упомянута в case. В отличие от шаблонов последовательности, любые другие дополнительные ключи, которые есть в исходном словаре, игнорируются.

Также поддерживается знак распаковки словарей ** - запишется остаток словаря от сопоставления. (Но **_ был бы лишним, поэтому его использование недопустимо.)

Например ситуация, в которой необходимо получить исходный словарь, за исключением явно указанного в case ключа/значения. В этом случае можно использовать знак распаковки словаря **.

d = {0: "oi", 1: "uno"}
match d:
    case {0: "oi", **remainder}:
        print(remainder)

# {1: 'uno'}

Такое поведение оператора match со словарями, можно использовать в своих интересах:

d = {0: "oi", 1: "uno"}
match d:
    case {0: "oi", **remainder} if not remainder:
        print("Словарь состоит одной пары ключ/значение")
    case {0: "oi", **remainder}:
        print(f"Словарь включает 0: 'oi' и имеет дополнительно {remainder}.")

# Словарь включает 0: 'oi' и имеет дополнительно {1: "uno"}.

Кроме того можно использовать переменные для сопоставления значений заданных ключей:

d = {0: "oi", 1: "uno"}
match d:
    case {0: zero, 1: one}:
        print(f"Ключ 0 сопоставлено с `{zero}` и ключ 1 с `{one}`")

# Ключ 0 сопоставлен с `oi` и ключ 1 с `uno`

Именованные константы (перечисления) и конструкция match/case.

В шаблонах могут использоваться именованные константы (перечисления). Эти именованные константы должны иметь имена, разделенные точками, чтобы константа не интерпретировалась как переменная захвата:

from enum import Enum
class Color(Enum):
    RED = 0
    GREEN = 1
    BLUE = 2

match color:
    case Color.RED:
        print("Это красный")
    case Color.GREEN:
        print("Трава зеленая")
    case Color.BLUE:
        print("Небо синее")

Ключевые особенности оператора match/case.

  • Как и при распаковке, шаблоны кортежей и списков (pattern в операторе case) имеют одно и то же значение и фактически соответствуют произвольным последовательностям. Технически, subject (в операторе match) должен быть последовательностью. Поэтому важным исключением является то, что шаблоны pattern не соответствуют итераторам. Кроме того, во избежание распространенной ошибки, шаблоны последовательностей, используемые в pattern не соответствуют строкам.
  • Шаблоны последовательностей ( pattern в операторе case) поддерживают подстановочные знаки: [x, y, *rest] и (x, y, *rest), работают аналогично подстановочным знакам при распаковке. Имя после * также может быть _, поэтому (x, y, *_) соответствует последовательности по крайней мере из двух элементов без привязки остальных элементов.
  • При использовании словаря в качестве шаблона (pattern в операторе case), например: case {"bandwidth": b, "latency": l} будут сравниваться (фиксироваться) ключи "bandwidth" и "latency". В отличие от шаблонов с последовательностями, дополнительные ключи игнорируются. Также поддерживается подстановочный знак **. (Но **_ было бы излишним, поэтому не допускается.)
  • Подшаблоны могут быть захвачены с помощью ключевого слова as: case (Point(x1, y1), Point(x2, y2) as p2): .... Это связывает x1, y1, x2, y2, как и следовало ожидать без предложения as, и p2 со всем вторым элементом subject.
  • Большинство литералов сравниваются при помощи оператора ==. Однако синглтоны True, False и None сравниваются по идентичности.
  • Именованные константы могут использоваться в шаблонах pattern. Эти именованные константы должны иметь имена, разделенные точками, чтобы константа не интерпретировалась как переменная захвата:

    from enum import Enum
    class Color(Enum):
        RED = 0
        GREEN = 1
        BLUE = 2
    
    match color:
        case Color.RED:
            print("I see red!")
        case Color.GREEN:
            print("Grass is green")
        case Color.BLUE:
            print("I'm feeling the blues :(")