Pattern matching в Python - конструкция match case

Сопоставление с шаблоном (pattern matching) с помощью операторов match и case было добавлено в Python 3.10 в 2021 году. Паттерн-матчинг позволяет писать более читаемый и лаконичный код, сокращая количество условных операторов и циклов. Он особенно полезен при работе со сложными иерархическими данными, такими как JSON, XML или YAML Операторы match и case имеют следующий синтаксис:

match выражение:
    case шаблон_1:
        ...
    case шаблон_n:
        ... 
    case _:
        ... 

Если выражение совпадает с шаблоном, то выполняется код внутри блока case. Выражение case _: совпадает с любыми случаями. Если мы поместим его в самое начало то код внутри него будет постоянно выполнятся так как значение сопоставляется последовательно с каждым шаблоном, а это значит что выполняется блок, который совпал первым.

Вот простой пример использования:

def a_or_b_or_c(value):
    match value:
        case "._":
            print("A")
        case "_...":
            print("B")
        case "_._.":
            print("C")
        case _:
            print("Что-то другое")  


a_or_b_or_c("._") # A
a_or_b_or_c(".._") # Что-то другое

Если я помещу case _: в начало, то в обоих случаях будет выведено "Что-то другое".

Истинное предназначение pattern matching

match/case не просто замена if/else. Его истинное предназначение скрыто в самом названии - сопоставление с шаблонами. Например мы можем сопоставлять коллекции:

def handle_message(message):
    match message:
        case [name, _, _, final]: # любая последовательность из 4-х элементов
            print(name, final)
        case ["Aboba", _, _, *rest]: # любая последовательность, которая начинается с "Aboba"
            print("1", rest)
        case [str(name), _, _, (float(lat), float(lon))]: # типизированная последовательность, состоящая из пяти элементов
            print(name, lat, lon)
        case [name, *rest, something]: # любая последовательность из n элементов
            print(rest)
        case _:
            print("nothing")


handle_message(["aaaaa", "bbbbb", "ccccc", "ddddd"]) # первый шаблон
handle_message(["1", "bbbbb", "ccccc", "ddddd"]) # второй шаблон
handle_message(["john", "bbbbb", "ccccc", (61.59282, -49.4211)]) # третий шаблон
handle_message(["aaaaa", "bbbbb", "ccccc", "ddddd", "eeeee"]) # четвертый шаблон
handle_message(1234) # пятый шаблон

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

  1. case [name, _, _, final]:

    • Это шаблон, который соответствует любому списку из четырех элементов. В этом случае функция печатает первый и последний элемент списка.
  2. case ["Aboba", _, _, *rest]:

    • Это шаблон, который соответствует списку, начинающемуся с Aboba. В этом случае функция печатает строку 1 и все элементы списка, кроме первого и последних четырех.
  3. case [str(name), _, _, (float(lat), float(lon))]:

    • Это типизированный шаблон, который соответствует списку из пяти элементов. В этом случае функция печатает первый элемент списка (имя) и последние два элемента списка (координаты).
  4. case [name, *rest, something]:

    • Это шаблон, который соответствует списку из любого количества элементов. В этом случае функция печатает все элементы списка, кроме первого.
  5. case _:

    • Это шаблон, который соответствует любому входному списку, не попадающему под предыдущие шаблоны. В этом случае функция печатает строку nothing.

Если запустить этот код, он будет работать, как ожидалось:

aaaaa ddddd
1 ddddd
john (61.59282, -49.4211)
['bbbbb', 'ccccc', 'ddddd']
nothing

Также match/case может быть вложенным:

from typing import Union


def process_data(data: Union[int, list]) -> str:
    match data:
        case 0:
            return "Ноль"
        case x if isinstance(x, int):
            return f"Целое число: {x}"
        case [a, b]:
            match a:
                case "яблоко":
                    return f"Фрукт: {a}, кол-во: {b}"
                case "банан":
                    return f"Фрукт: {a}, кол-во: {b}"
                case _:
                    return "Неизвестные данные"
        case _:
            return "Неизвестные данные"


print(process_data(0)) # Ноль
print(process_data(42)) # Целое число: 42  
print(process_data(["яблоко", 5])) # Фрукт: яблоко, кол-во: 5
print(process_data(["банан", 3])) # Фрукт: банан, кол-во: 3
print(process_data(["a", 10])) # Неизвестные данные
print(process_data("random")) # Неизвестные данные

Функция process_data:

  • Она принимает аргумент data типа Union[int, list], что означает, что функция может принимать целое число или список.
  • Внутри функции используется конструкция match, которая позволяет сравнивать data с различными значениями и возвращать соответствующие строки.

Варианты обработки data:

  • Если data равно 0, функция возвращает строку Ноль.
  • Если data является целым числом, функция возвращает строку в формате Целое число: <число>.
  • Если data является списком, функция использует внутреннюю конструкцию match для сравнения первого элемента списка (a) с конкретными значениями.

    • Если a равно "яблоко", функция возвращает строку в формате Фрукт: {fruit}, кол-во: {count}.
    • Если a равно "банан", функция также возвращает строку в формате Фрукт: {fruit}, кол-во: {count}.
    • В любом другом случае, функция возвращает строку "Неизвестные данные".
  • Если data не является ни целым числом, ни списком, функция также возвращает строку "Неизвестные данные".

Как видите, в case даже используется оператор if.

Сравнение скорости match/case и if/else

Я прочитал статью на Хабре о том, что match/case работает медленнее чем if/else. Эксперимент в статье был проведен в версии Python 3.10. Сейчас же актуальной версией является Python 3.12.3. Мне стало интересно, является ли эта информация актуальной или сейчас дела обстоят иначе.

В той статье приведен такой код:

import random as rnd
import timeit



def create_rnd_data():
    words = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0,
        'здесь', 'дом', 'да', 'потому', 'сторона',
        'какой-то', 'думать', 'сделать', 'страна',
        'жить', 'чем', 'об', 'последний', 'случай',        
        'голова', 'более', 'делать', 'что-то', 'смотреть',
        'ребенок', 'просто', 'конечно', 'сила', 'российский',
        'конец', 'перед', 'несколько']    
    data = rnd.choices(words, k=500000)
    return data


def test_if(data):
    for word in data:
        if word in ['дом', 'думать', 'что-то', 'просто']:
            pass
        elif isinstance(word, int):
            pass
        elif isinstance(word, str) and len(word) > 3:
            pass
        elif isinstance(word, str) and word.startswith("д"):
            pass
        else:
            pass

# те же проверки при помощи match/case
def test_match(data):
    for word in data:
        match word:
            case 'дом'|'думать'|'что-то'|'просто':
                pass
            case int(word):
                pass
            case str(word) if len(word) > 3:
                pass
            case str(word) if word.startswith("д"):
                pass
            case _:
                pass



# создаем случайные данные для теста
test_data = create_rnd_data()
# количество повторений
repeats = 100
# считаем результаты
time_repeat_if = timeit.timeit("test_if(test_data)", setup="from __main__ import test_if, test_data", number=repeats)

time_repeat_match = timeit.timeit("test_match(test_data)", setup="from __main__ import test_match, test_data", number=repeats)

print("РЕЗУЛЬТАТ IF/ELSE:   ", time_repeat_if/repeats)
print("РЕЗУЛЬТАТ MATCH/CASE:", time_repeat_match/repeats)

Результат в таком тесте был следующий:

РЕЗУЛЬТАТ IF/ELSE:    0.03925879499991424
РЕЗУЛЬТАТ MATCH/CASE: 0.24128189100010786

Разница в этом прогоне составила 6 с небольшим раз! Теперь берем второй тест из статьи:

def create_rnd_data():
    names = ["phone", "TV", "PC", "car", "home", "case", "bird", "chicken", "dish", "float", "C++", "data", ""]
    prices = [500, 100, 1400, 2000, 750, 3500, 5000, 120, 50, 4200]
    goods = []
    for i in range(500000):
        name = names[i%len(names)]
        price = prices[i%len(prices)]
        goods.append({"name": name, "price": price})
    return goods


def test_if(data):
    for element in data:
        if element.get("name") in ["phone", "TV"] and isinstance(element.get("price"), int) and element.get("price") > 2000:
            pass
        elif element.get("name") == "case" and isinstance(element.get("price"), int) and element.get("price") <= 750:
            pass
        elif element.get("name") == "case" and isinstance(element.get("price"), int) and element.get("price") == 750:
            pass
        elif isinstance(element.get("name"), str) and element.get("name"):
            pass
        elif isinstance(element.get("price"), int) and element.get("price") > 1000:
            pass
        else;
            pass

def test_match(data):
    for element in data:
        match element:
            case {"name": "phone"|"TV", "price": int(price)} if price > 2000:
                pass
            case {"name": "case", "price": int(price)} if price <= 750:
                pass
            case {"name": "case", "price": 750}:
                pass
            case {"name": str(name), "price": _} if name:
                pass
            case {"name": _, "price": int(price)} if price > 1000:
                pass
            case _:
                pass

В этот раз результат был следующим:

РЕЗУЛЬТАТ IF/ELSE:    0.06701861700013978
РЕЗУЛЬТАТ MATCH/CASE: 0.5592147159998422

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

Немного про оценку производительности ЯП

Небольшой оффтоп, так как в кругах программистов могут неправильно понять. Говоря о Python, важно понимать сферу его применения. На этом языке чаще всего пишут приложения по типу web серверов, ботов, CLI приложений и тд, а это все объединяется одним термином - I/O bound нагрузка. Это означает, что время выполнения больше зависит не от скорости процессора, а от времени ожидания ввода/вывода. В программах такого типа скорость работы ЯП не сильно важна, так как основную часть времени программа ждет данных (работа с сетью, работа с БД, пользовательский ввод), поэтому веб сервер чаще пишут на JS или Python, чем на C/C++ или на чем-то более низкоуровневом. Но все же, отдельные компоненты веб серверов могут быть написаны на более быстрых языках, но только в случае, когда какой-то элемент становится слишком медленным, из-за чего страдает производительность в целом.


ООП. Введение в объектно-ориентированное программирование в Python

Объектно-ориентированное программирование - одна из самых распространенных парадигм. Знание ООП позволит сделать код более гибким и масштабируемым

Модули в языке Python. Разбиение программ на модули. Установка сторонних библиотек

Для написания сложных программ необходимо использовать сторонние библиотеки. Они позволяют использовать готовый функционал. Также можно использовать модули для компоновки вашего приложения.

Классы исключений. Создание собственных исключений

В этой статье описано, что означают различные классы исключений. Также рассмотрено создание собственных классов исключений.

Обработка исключений. Конструкция try/except/finally

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