Долгожданные инструкции Switch-Case в Python

Python 3.10 обрел немало интересных возможностей, одна из которых привлекла мое внимание в особенности  —  структурное сопоставление с шаблоном, иначе говоря инструкции switch/case.

Несмотря на свою повсеместность в большинстве языков, в Python инструкции switch до сих пор отсутствовали.

Еще в 2006 году создавалось предложение PEP 3103, в котором рекомендовалось реализовать поддержку инструкций switch-case. Однако в результате опроса на PyCon 2007 эта функция не получила достаточной поддержки, и разработчики ее отложили.

Гораздо позднее уже в 2020 году Гвидо Ван Россум, создатель Python, опубликовал первую документацию, описывающую новые инструкции switch, технику, которая, согласно PEP 634, была названа как структурное сопоставление с шаблоном.

Однако, как вы вскоре увидите, здесь нам предлагается намного большее, нежели просто инструкции switch-case (что подразумевает match-case).

Посмотрим, как эта логика работает.

1. Структурное сопоставление с шаблоном

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

Например:

http_code = "418"

match http_code:
    case "200":
        print("OK")
        do_something_good()
    case "404":
        print("Not Found")
        do_something_bad()
    case "418":
        print("I'm a teapot")
        make_coffee()
    case _:
        print("Code not found")

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

Сразу становится очевидным, что “да”, можно выстроить ту же логику, используя набор инструкций if-elif-else:

http_code = "418"

if http_code == "418":
    print("OK")
    do_something_good()
elif http_code == "404":
    print("Not Found")
    do_something_bad()
elif http_code == "418"
    print("I'm a teapot")
    make_coffee()
else:
    print("Code not found")

Тем не менее, с помощью инструкций match-case мы избавляемся от повторения http_code ==, что повышает чистоту кода при тестировании на соответствие многим разным условиям.

Другой пример

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

match x:
    case host, port:
        mode = "http"
    case host, port, mode:
        pass

Здесь мы ожидаем получения деталей соединения в формате кортежа и присваиваем данные значения правильным переменным.

В этом случае, если mode соединения в кортеже не определен (например, было передано только два значения  —  host и port), мы предполагаем, что режим соединения http.

Тем не менее в других случаях можно ожидать, что режим будет определен явно. Тогда вместо этого мы получаем кортеж, наподобие (<host>, <port>, "ftp"), и mode уже не устанавливается как http .

Если же мы пропишем ту же логику с помощью if-else, то получится вот что:

if isinstance(x, tuple) and len(x) == 2:
    host, port = x
    mode = "http"
elif isinstance(x, tuple) and len(x) == 3:
    host, port, mode = x

Предпочесть можно любой из этих вариантов, но лично для меня реализация через match-case выглядит намного чище.

Реальный пример с форматом JSON

Еще один интересный случай  —  это способность по-разному парсить объекты словаря в зависимости от их структуры. Отличным тестовым примером будет парсинг датасета SQuAD 2.

Этот датасет представляет чрезвычайно популярный набор пар вопрос-ответ, используемых для обучения моделей МО отвечать на вопросы. Скачать его можно так:

{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "cellular-horror",
   "metadata": {},
   "outputs": [],
   "source": [
    "import requests\n",
    "import json"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "operational-techno",
   "metadata": {},
   "outputs": [],
   "source": [
    "url = 'https://rajpurkar.github.io/SQuAD-explorer/dataset/'\n",
    "file = 'train-v2.0.json'"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "successful-cornwall",
   "metadata": {},
   "outputs": [],
   "source": [
    "res = requests.get(f'{url}{file}')\n",
    "# write to file\n",
    "with open(file, 'wb') as f:\n",
    "    for chunk in res.iter_content(chunk_size=4):\n",
    "        f.write(chunk)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.10.0a6"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}

Если взглянуть на структуру SQuAD, то очевидно, что она состоит из нескольких уровней, и это потребуется учесть при парсинге:

Обучающий датасет в формате JSON

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

Если взглянуть на несколько последних, то мы видим, что список qas содержит и answers, и plausible_answers, в то время как в предыдущих образцах присутствуют только answers:

Последняя запись этого датасета представляет другой формат словаря (используемый несколькими записями в файле)

Попробуем с помощью match-case создать более чистую альтернативу тяжелой логике if-else, необходимой для обработки всего этого. Сначала загружаем данные:

Загружаем данные с помощью json.load

Данный JSON-файл содержит несколько слоев. После обращения к squad['data'] нужно перебрать каждую group вопросов, затем каждый paragraph, а затем все qas (вопрос-ответы). Выглядеть это будет так:

for group in squad['data']:
    for paragraph in group['paragraphs']:
        for qa in paragraph['qas']:
            # вставить код сюда

А здесь начинается самое интересное. Используя логику if-else, мы получим следующее:

{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "harmful-excuse",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/plain": [
       "([('When did Beyonce start becoming popular?', 'in the late 1990s'),\n",
       "  ('What areas did Beyonce compete in when she was growing up?',\n",
       "   'singing and dancing'),\n",
       "  (\"When did Beyonce leave Destiny's Child and become a solo singer?\", '2003'),\n",
       "  ('In what city and state did Beyonce  grow up? ', 'Houston, Texas'),\n",
       "  ('In which decade did Beyonce become famous?', 'late 1990s')],\n",
       " [('Physics has broadly agreed on the definition of what?', 'matter'),\n",
       "  ('Who coined the term partonic matter?', 'Alfvén'),\n",
       "  ('What is another name for anti-matter?', 'Gk. common matter'),\n",
       "  ('Matter usually does not need to be used in conjunction with what?',\n",
       "   'a specifying modifier'),\n",
       "  ('What field of study has a variety of unusual contexts?', 'physics')])"
      ]
     },
     "execution_count": 1,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "new_squad = []\n",
    "\n",
    "for group in squad['data']:\n",
    "    for paragraph in group['paragraphs']:\n",
    "        for qa in paragraph['qas']:\n",
    "            question = qa['question']\n",
    "            if 'answers' in qa.keys() and len(qa['answers']) > 0:\n",
    "                answers = qa['answers'][0]['text']\n",
    "            elif 'plausible_answers' in qa.keys() and len(qa['plausible_answers']) > 0:\n",
    "                answers = qa['plausible_answers'][0]['text']\n",
    "            else:\n",
    "                answers = None\n",
    "            new_squad.append((question, answers))\n",
    "\n",
    "new_squad[:5], new_squad[-5:]"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.10.0a6"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}

Не очень красиво, но работает. Теперь перепишем все это с помощью match-case:

{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": 4,
   "metadata": {},
   "outputs": [],
   "source": [
    "# initialize list where we will place all of our data\n",
    "new_squad = []\n",
    "\n",
    "# we need to loop through groups -> paragraphs -> qa_pairs\n",
    "for group in squad['data']:\n",
    "    for paragraph in group['paragraphs']:\n",
    "        for qa_pair in paragraph['qas']:\n",
    "            # we pull out the question\n",
    "            question = qa_pair['question']\n",
    "            # now the NEW match-case logic to check if we have 'answers' or 'plausible_answers'\n",
    "            match qa_pair:\n",
    "                case {'answers': [{'text': answer}]}:\n",
    "                    # because the case pattern assigns 'answer' for us, we pass\n",
    "                    pass\n",
    "                case {'plausible_answers': [{'text': answer}]}:\n",
    "                    # we perform same check but for 'plausible_answers'\n",
    "                    pass\n",
    "                case _:\n",
    "                    # this is our catchall, we will set answer to None\n",
    "                    answer = None\n",
    "            # append dictionary sample to parsed squad\n",
    "            new_squad.append({\n",
    "                'question': question,\n",
    "                'answer': answer\n",
    "            })"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.8.5"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}

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

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

Ну а пока, этот вариант просто выглядит круто  —  я восхищен!

Спасибо за внимание!

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи James Briggs: Switch-Case Statements Are Coming to Python

Предыдущая статьяОптимизация работы баз данных с PostgreSQL 12
Следующая статьяВведение в программирование на Rust