Рубріки: Теория

Structural pattern matching: что Python 3.10 нам готовит (часть I)

Павел Дмитриев

Начало весны 2021 года ознаменовалось релизом 6-й альфа-версии Python 3.10, в которую теперь включена новая синтаксическая конструкция: structural pattern matching (SPM).

В целом, команда разработки языка последние несколько лет активизировалась и просто-таки сыпет интересными возможностями, начиная с dataclass и заканчивая чуть не рассорившим всех навсегда “моржовым оператором” :=. В свежей же версии разработчики, кажется, решили побить все рекорды. Даже без героя этой статьи нововведений хватило бы на полноценную версию, но pattern matching — одно из самых больших нововведений со времен Python 3.

SPM получился инструментом универсальным и мощным, поэтому количество статей и туториалов, посвященных ему в ближайшее время будет очень велико. Я тоже постараюсь внести в это дело свой вклад, рассказав о новинке с помощью практических примеров.

Проблемы инноватора

Понятно, что использовать альфа-версии в продакшене никто не будет, да и в локальную систему желающих установить альфа-релиз в качестве основного будет немного. В целом, RC у Python-сообщества достаточно стабильные, да и первая бета уже не за горами, но вряд ли пока логично использовать версию 3.10 для чего-то, кроме тестов и экспериментов.

Есть несколько способов попробовать RC без лишнего засорения системы и, пожалуй, самый простой из них — использовать Docker, благо и PyCharm умеет работать с интерпретатором в контейнере. Именно так я и поступил, установив rc-alpine из официальных образов, предоставляемых мейнтейнерами Python.

Конечно, ни одна из IDE еще не поддерживает новые конструкции, а спектр “непонимания” варьируется от неправильной обработки отступов, до мириадов сообщений о синтаксических ошибках, которые приходится игнорировать. Кстати, очень интересно будет посмотреть, как быстро разработчики IDE добавят поддержку новых конструкций в свои программы.

Простые случаи: когда надоел elif

Итак, о чем вообще речь? Думаю, любой разработчик на Python многократно писал что-то вроде такого (код отчасти утрированный).

def handle_error(code):
    if code == 400:
        raise ValidationError()
    elif code == 500 or code == 501:
        raise ServerError()
    else:
        proceed()

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

def handle_error(code):
    match code:
        case 400:
            raise ValidationError()
        case 500 | 501:
            raise ServerError()
        case _:
            proceed()

Думаю, синтаксис тут достаточно очевиден и похож на многие другие языки. В качестве параметра оператора match мы передаем то, что хотим сопоставлять с шаблонами, после этого указываем один или несколько операторов case с возможными значениями. Интерпретатор по очереди сопоставляет значение из match с шаблоном из каждой ветки case и в случае совпадения выполняет код в ней. На этом сопоставление завершается. Как вы видите на примере, один case может содержать несколько вариантов, тогда они разделяются вертикальной чертой. Ожидаемо  _ “матчится” с любым значением.

Понятно, что многие на этом моменте удивятся: “И стоило ради этого все затевать?”, ведь и вариант с elif выглядит неплохо. Действительно, если бы возможности нового оператора только этим и ограничивались, вряд ли бы его добавляли, излишний синтаксический сахар идет вразрез с “Дзен Python”. На самом деле, новая фишка умеет сильно больше всего — давайте смотреть на более сложные примеры.

Случаи посложнее: строим свой KVS

День не предвещал проблем, но покой вашего уютного home office нарушил звонок продакт-менеджера. Доедая свой безглютеновый бутерброд с хумусом, он сообщил вам, что компания решила запустить новый подпроект, обладающий завидным потенциалом монетизации через венчурные инвестиции: свой key-value-storage-сервер с несколькими API. Ваши робкие возражения по поводу того, что на рынке их и так больше, чем необходимо, были отметены, поскольку часть партнеров компании страдает от терминальной стадией NIH-синдрома. В общем, надо делать MVP.

Начало выходит довольно тривиальным.

class KVServer:
    _version = 0.33451234234

    def __init__(self, protected=True):
        self._storage = {}
        self._protected = protected

По нашим воображаемым требованиям, нам нужно сделать KVS с защитой от удаления ключей, поэтому я “про запас” добавлю флаг protected, отображающий этот статус.

Начнем с первого API — строкового интерфейса, который будет использоваться в утилитах командной строки.  Начало функции будет очень простым.

def string_command(self, command: str):
    print(f"< {command}")
    match command.split():

Мы получаем строковую команду, выводим ее, делим с помощью .split() и полученный массив сопоставляем по шаблону. Начнем с самой нужной команды (после кода я буду приводить примеры вывода для него).

case ["version"]:
    print(f"> KVServer version {KVServer._version}")

# < version
# > KVServer version 0.33451234234

Самый простой сценарий: наша команда состоит из одного слова version, соотвественно .split() даст нам массив из одного этого элемента который и словит соответствующий case. Так как в ветках match списки и таплы равнозначны, эту ветку можно было записать как case (“version”, ), разницы тут никакой нет, кроме чуть менее красивого синтаксиса, обусловленного тем, как в Python объявляются таплы единичной длины.

Заодно добавляем и самый последний элемент нашего сопоставления по шаблону, упрощающий разработку и отладку.

case _:
    print("> Invalid command")

# < wut
# > Invalid command

Сервер работает. Теперь нам нужно сохранять значения.

case [("add" | "a"), key, *value]:
    self._storage[key] = " ".join(value)

# < add some1 This is value
# < a other1 More values

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

  • оператора add либо a (альтернативные варианты разделяются символом вертикальной черты);
  • любого слова-ключа, которое будет захвачено в параметр key;
  • всего остального списка, собираемого в value.

Сами видите, сколько проверок одновременно делает за нас Python. Нам же остается только собрать обратно строку-значение, соединив ее пробелами, и поместить ее в хранилище по ключу. Я понимаю, что такое поведение не очень корректно в реальной жизни, но не хочу усложнять пример.

Чтоб убедиться, что данные сохраняются, давайте добавим еще пару команд с синонимами.

case [("count" | "cnt")]:
    print(f"> {len(self._storage)} key(s) in storage")
case ["values"] | ["vals"] | ["uniques"]:
    u_cnt = len(set(self._storage.values()))
    print(f"> {u_cnt} unique values in storage")

# < cnt
# > 2 key(s) in storage
# < vals
# > 2 unique values in storage

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

Добавляем следующую полезную команду.

case [("list" | "ls"), *keys]:
    for key in keys:
        self._list(key)

Этот вариант вы уже легко можете интерпретировать: команда состоит из оператора, которым может быть list или ls, и списка операндов, собираемого в параметре keys.

Метод, “возвращающий” список ключей, выглядит максимально просто.

def _list(self, key):
    print(f"> {key} -> {self._storage.get(key)!r}")

# < list
# < ls some1 other1
# > some1 -> 'This is value'
# > other1 -> 'More values'

Тут стоит обратить внимание на то, что шаблон *keys “заматчится” и для пустого списка, как в первом случае, когда мы использовали команду list без параметров. К счастью, разработчики новой функции добавили способ, позволяющий делать дополнительные проверки, но про это немного позже. Пока добавляем команду сохранения наших данных куда-нибудь.

case ["save", ("file" | "cloud") as storage, path]:
    self._save_to(storage, path)

# < save cloud mycloud://somewhere:12
# > Saving 2 keys to cloud at mycloud://somewhere:12
# < save tape /dev/zip1
# > Invalid command

Метод _save_to я не буду приводить, он просто печатает строку с параметрами. Куда интереснее тут шаблон. Команда состоит ровно из трех слов, первое должно быть save, потом идет либо file либо cloud, которое при сопоставлении будет помещено в переменную storage, и, наконец, параметр path, сохраняемый в соответствующую переменную.

Вывод тестовых команд показывает, что неправильное значение tape в качестве потенциального места сохранения приводит к тому что команда не распознается.

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

Как говорил классик: “Ничто не вечно под Луной”. Поэтому добавляем в наш сервер удаление данных. Но для начала нам нужен способ переключать тот самый флаг защиты, делаем для этого следующий API.

case ["protection", ("on" | "off") as state]:
    self._protected = state == "on"
    print(f"> Protection state is {self._protected}")

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

def _delete(self, key):
    print(f"> Deleted {key}")
    _ = self._storage.pop(key, None)

Теперь добавляем нужные операторы.

case "drop" | [("rm" | "del" | "remove"), ("*" | "all")] if not self._protected:
    self._storage = {}
    print("> Storage is empty")

case [("rm" | "del" | "remove"), *keys] if not self._protected:
    for key in keys:
        self._delete(key)

Пойдем с конца. Вторая команда служит для удаления ключей по списку, синтаксис тут довольно очевидный, кроме дополнительной ветки if. Если последняя присутствует, помимо сопоставления шаблона интерпретатор еще и проверит условие, указанное в роли “защиты” (иногда этот if называют guard statement), и ветка будет выполнена, только если условие истинно. В данном случае мы просто проверяем, что у нас выключена защита.

Первая же команда чуть сложнее, она может быть задана либо с помощью оператора drop, либо с помощью тех же синонимов, что и команда удаления по ключам, но в роли списка ключей идет *. Так как это частный случай, он должен идти до более общего, иначе он будет перехватываться командой удаления по списку. Разумеется, тут мы тоже выполняем проверку на защиту.

Таким образом, наш простой сервер уже умеет делать довольно сложные вещи.

< rm some1
> Invalid command

< protection off
> Protection state is False
< rm some1
> Deleted some1
< ls some1
> some1 -> None

< protection on
> Protection state is True
< rm other1
> Invalid command

< drop
> Invalid command
< protection off
> Protection state is False
< rm *
> Storage is empty
< count
> 0 key(s) in storage
< protection on
> Protection state is True

Первая попытка удалить значение проваливается, так как у нас включена защита, об этом говорит сообщение Invalid command. После этого мы выключили защиту, удалили значение и убедились, что ключ удалился. Потом включили флаг защиты и проверили, что он работает, попытавшись удалить ключ. Финальный шаг — попытка удалить все из хранилища (спойлер: не даст защита), выключение защиты, удаление, еще одна попытка. На этот раз все вышло. В конце мы включили защиту обратно, она нам еще пригодится.

Суммарно весь оператор match занимает 25 строк и выглядит весьма наглядно. Желающие могут попробовать переписать его, используя комбинации if, elif, else с логическими операторами и срезами списков.

Мощь structural pattern matching на этом далеко не заканчивается. Но об этом — в следующей части.

If you have found a spelling error, please, notify us by selecting that text and pressing Ctrl+Enter.

Останні статті

Токсичные коллеги. Как не стать одним из них и прекратить ныть

В благословенные офисные времена, когда не было большой войны и коронавируса, люди гораздо больше общались…

07.12.2023

Делать что-то впервые всегда очень трудно. Две истории о начале карьеры PM

Вот две истории из собственного опыта, с тех пор, когда только начинал делать свою карьеру…

04.12.2023

«Тыжпрограммист». Как люди не из ІТ-отрасли обесценивают профессию

«Ты же программист». За свою жизнь я много раз слышал эту фразу. От всех. Кто…

15.11.2023

Почему чат GitHub Copilot лучше для разработчиков, чем ChatGPT

Отличные новости! Если вы пропустили, GitHub Copilot — это уже не отдельный продукт, а набор…

13.11.2023

Как мы используем ИИ и Low-Code технологии для разработки IT-продукта

Несколько месяцев назад мы с командой Promodo (агентство инвестировало в продукт более $100 000) запустили…

07.11.2023

Университет или курсы. Что лучше для получения IT-образования

Пару дней назад прочитал сообщение о том, что хорошие курсы могут стать альтернативой классическому образованию.…

19.10.2023