Конструкция match/case. Примеры и особенности использования

Курс по Python: https://stepik.org/course/100707

На предыдущих занятиях мы в целом увидели, как можно прописывать различные шаблоны для проверок отдельных констант и переменных, а также упорядоченных коллекций list, tuple и более сложных – dict, set. На этом занятии посмотрим некоторые реальные приложения конструкции match/case, а также увидим ограничения при использовании этих операторов.

Давайте предположим, что нам нужно организовать подключение к БД. Для этого формируется словарь с необходимыми данными, например, такой:

request = {'server': '127.0.0.1', 'login': 'root', 'password': '1234', 'port': 24}

Затем, с помощью нашей функции connect_db() выполним проверку данных в запросе request и вернем результат подключения:

result = connect_db(request)
print(result)

Саму же функцию запишем в следующем виде:

def connect_db(connect: dict) -> str:
    match connect:
        case {'server': host, 'login': login, 'password': psw, 'port': port}:
            return f"connection: {host}@{login}.{psw}:{port}"
        case {'server': host, 'login': login, 'password': psw}:
            port = 22
            return f"connection: {host}@{login}.{psw}:{port}"
        case _:  # wildcard
            return "error connection"

Здесь сразу бросается в глаза дублирование кода в первых двух блоках case, а именно, строчка с возвращением f-строки. Давайте это поправим. Вынесем ее из блока match и запишем в самом конце функции:

def connect_db(connect: dict) -> str:
    match connect:
        case {'server': host, 'login': login, 'password': psw, 'port': port}:
            pass
        case {'server': host, 'login': login, 'password': psw}:
            port = 22
        case _:  # wildcard
            return "error connection"
 
    return f"connection: {host}@{login}.{psw}:{port}"

Смотрите, в первом блоке case записан оператор pass, который ничего не делает. Но он нужен, т.к. Python не разрешает объявление пустых блоков case, в них обязательно должен быть хотя бы один оператор. Именно поэтому я и прописал pass. Во втором блоке также стоит один оператор, который создает переменную port со значением 22. Если первые два блока case не сработают, то третий отработает в любом случае и функция connect_db() вернет строку "error connection". Значит, если мы доходим до последней строки функции, то сработал или первый case, или второй. В любом случае у нас будут переменные host, login, psw и port, так как все созданные переменные внутри блока match сохраняются и за его пределами. Поэтому формирование f-строки пройдет без ошибок и будет возвращен требуемый результат. Так мы избавляемся от дублирования кода при одинаковом наборе переменных.

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

book_1 = ('Балакирев', 'Python', 2022)
book_2 = ['Балакирев', 'Python ООП', 2022, 3432.27]
book_3 = {'author': 'Балакирев', 'title': 'Нейросети', 'year': 2020}
book_4 = {'author': 'Балакирев', 'title': 'Keras + Tensorflow', 'price': 5430, 'year': 2020}

Наша задача написать функцию, которая бы обрабатывала все эти варианты представления книг и выдавала кортеж в формате:

(автор, название, год, цена)

Пусть эта функция называется book_to_tuple() и вызывается в программе следующим образом:

result = book_to_tuple(book_1)
print(result)

Саму функцию удобно реализовать с помощью операторов match/case, например, так:

def book_to_tuple(data: dict | tuple | list) -> tuple | None:
    match data:
        case author, title, year:
            price = None
        case author, title, year, price, *_:
            pass
        case {'author': author, 'title': title, 'year': year, 'price': price}:
            pass
        case {'author': author, 'title': title, 'year': year}:
            price = None
        case _:  # wildcard
            return None
 
    return author, title, year, price

Смотрите, параметр data может быть разных типов: словарь, кортеж или список. На выходе функция должна выдавать или кортеж, или значение None, если входные данные не соответствуют ожидаемым форматам. Далее, первый блок case распаковывает кортеж или список, состоящий из трех элементов в формате: автор, название, год издания. Если шаблон срабатывает, то формируется четвертая переменная price со значением None. Следующий шаблон ожидает также список или кортеж, состоящий, как минимум из четырех элементов в порядке: автор, название, год издания, цена. Далее могут идти какие-либо другие элементы, мы их просто игнорируем. Следующие два блока проверяют словари. Словарь может содержать или три ключа, тогда формируется четвертая переменная price со значением None, или четыре ключа с полным набором необходимых переменных. Если ни один из шаблонов не срабатывает, то попадаем в последний блок case, который возвращает значение None, то есть, несоответствие форматов. Если же отрабатывает один из предыдущих блоков case, то переходим к последней команде функции, где возвращается кортеж со значениями переменных author, title, year, price.

Как видите, все достаточно просто и очевидно. Но здесь опять же есть небольшое дублирование кода: дважды записана команда price = None. По логике ее можно вынести за пределы оператора match и записать перед ним:

def book_to_tuple(data: dict | tuple | list) -> tuple | None:
    price = None
    match data:
        case author, title, year:
            pass
        case author, title, year, price, *_:
            pass
        case {'author': author, 'title': title, 'year': year, 'price': price}:
            pass
        case {'author': author, 'title': title, 'year': year}:
            pass
        case _:  # wildcard
            return None
 
    return author, title, year, price

Получим абсолютно тот же самый результат. (Операторы pass можно не считать дублированием кода).

Давайте немного усложним задачу и сделаем дополнительную проверку на целочисленный тип переменной year и ее принадлежность диапазону значений (min_year; max_year). Если сделать это «в лоб», то получим что то вроде:

def book_to_tuple(data: dict | tuple | list, min_year=1800, max_year=3000) -> tuple | None:
    price = None
    match data:
        case author, title, int(year) if min_year < year < max_year:
            pass
        case author, title, int(year), price, *_ if min_year < year < max_year:
            pass
        case {'author': author, 'title': title, 'year': int(year), 'price': price} if min_year < year < max_year:
            pass
        case {'author': author, 'title': title, 'year': int(year)} if min_year < year < max_year:
            pass
        case _:  # wildcard
            return None
 
    return author, title, year, price

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

def book_to_tuple(data: dict | tuple | list, min_year=1800, max_year=3000) -> tuple | None:
    price = None
    match data:
        case author, title, int(year):
            pass
        case author, title, int(year), price, *_:
            pass
        case {'author': author, 'title': title, 'year': int(year), 'price': price}:
            pass
        case {'author': author, 'title': title, 'year': int(year)}:
            pass
        case _:  # wildcard
            return None
 
    if not (min_year < year < max_year):
        return None
 
    return author, title, year, price

То есть, мы буквально всю проверку вынесли в одну строчку оператора if. Этот пример показывает, что нас никто не ограничивает в различных комбинациях операторов match и if для написания более удобного и читабельного текста программы.

В заключение этого занятия покажу еще одну особенность конструкции match/case. Для простоты предположим, что у нас имеется некая целочисленная переменная:

cmd = 10

и в операторе match хотели бы выделить две ситуации со значениями 3 и 5. Очевидно, сделать это можно следующим образом:

match cmd:
    case 3:
        print("3")
    case 5:
        print("5")

Но опытный разработчик знает, что явно прописывать константы в программе не лучший ход. Правильнее было бы сначала задать константы с числами 3 и 5, а уже потом их использовать в операторах case. Давайте так и сделаем:

CMD_3 = 3
CMD_5 = 5
 
cmd = 3
 
match cmd:
    case CMD_3:
        print("3")
    case CMD_5:
        print("5")

Однако, при запуске обнаруживается синтаксическая ошибка. Почему? Дело в том, что в Python нет констант как таковых. Здесь объявлены две переменные CMD_3 и CMD_5, которые указываются в операторах case. В действительности, это аналогично тому, что мы в case пропишем произвольную переменную, например, такую:

match cmd:
    case _:
        print("3")
    case CMD_5:
        print("5")

Да, принципиально мы ничего не изменили, просто написали другое имя переменной. Но вы помните, что она означает – любые данные, т.е. срабатывает всегда, поэтому второй блок case здесь приводит к ошибке. Если мы его уберем, то программа всегда будет выводить число 3:

match cmd:
    case _:
        print("3")

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

match cmd:
    case int(cmd) as x if x == CMD_3:
        print("3")
    case int(cmd) as x if x == CMD_5:
        print("5")

Однако, выглядит это слишком громоздко. Тут проще было бы воспользоваться операторами if/elif/else. Во втором случае разработчики разрешают нам использовать переменные как константы, если переменная указана через точку. Но что это значит? Например, мы можем переменные CMD_3 и CMD_5 вынести в отдельный файл (модуль), а потом подключить его через import в текущем модуле:

import consts
 
cmd = 3
 
match cmd:
    case consts.CMD_3:
        print("3")
    case consts.CMD_5:
        print("5")

Тогда никаких проблем не возникнет и программа будет работать, как задумано.

Или, можно пойти на еще одну хитрость и определить константы внутри какого-либо класса, например, так:

class Consts:
    CMD_3 = 3
    CMD_5 = 5
 
 
cmd = 3
 
match cmd:
    case Consts.CMD_3:
        print("3")
    case Consts.CMD_5:
        print("5")

В этом случае тоже все отработает как надо без ошибок.

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

Курс по Python: https://stepik.org/course/100707

Видео по теме