Pattern matching. Теперь и в Python

?v=1
Привет!

В юбилейный минор третьего питона наконец-то завезли pattern matching. Саму концепцию сложно назвать новой, она уже реализована во многих языках, причём как нового поколения (Rust, Golang), так и у тех, кому уже за 0×18 (Java).


Анонсировал pattern matching Гвидо ван Россум, автор языка программирования Python и «великодушный пожизненный диктатор»

Меня зовут Денис Кайшев, я код-ревьюер на курсе «Мидл Python-разработчик». В этом посте хочу рассказать, зачем в Python pattern matching и как с ним работать.

Синтаксически конструкция pattern matching по сути аналогична тому, как это представлено в ряде других языков:

match_expr:
    | star_named_expression ',' star_named_expressions?
    | named_expression
match_stmt: "match" match_expr ':' NEWLINE INDENT case_block+ DEDENT
case_block: "case" patterns [guard] ':' block
guard: 'if' named_expression
patterns: value_pattern ',' [values_pattern] | pattern
pattern: walrus_pattern | or_pattern
walrus_pattern: NAME ':=' or_pattern
or_pattern: '|'.closed_pattern+
closed_pattern:
    | capture_pattern
    | literal_pattern
    | constant_pattern
    | group_pattern
    | sequence_pattern
    | mapping_pattern
    | class_pattern
capture_pattern: NAME !('.' | '(' | '=')
literal_pattern:
    | signed_number !('+' | '-')
    | signed_number '+' NUMBER
    | signed_number '-' NUMBER
    | strings
    | 'None'
    | 'True'
    | 'False'
constant_pattern: attr !('.' | '(' | '=')
group_pattern: '(' patterns ')'
sequence_pattern: '[' [values_pattern] ']' | '(' ')'
mapping_pattern: '{' items_pattern? '}'
class_pattern:
    | name_or_attr '(' ')'
    | name_or_attr '(' ','.pattern+ ','? ')'
    | name_or_attr '(' ','.keyword_pattern+ ','? ')'
    | name_or_attr '(' ','.pattern+ ',' ','.keyword_pattern+ ','? ')'
signed_number: NUMBER | '-' NUMBER
attr: name_or_attr '.' NAME
name_or_attr: attr | NAME
values_pattern: ','.value_pattern+ ','?
items_pattern: ','.key_value_pattern+ ','?
keyword_pattern: NAME '=' or_pattern
value_pattern: '*' capture_pattern | pattern
key_value_pattern:
    | (literal_pattern | constant_pattern) ':' or_pattern
    | '**' capture_pattern

Может показаться сложным и запутанным, но на самом деле всё сводится примерно к такому виду:
match some_expression:
    case pattern_1:
        ...
    case pattern_2:
        ...

Это выглядит куда понятнее и приятнее глазу.

Сами шаблоны разбиты на несколько групп:

  • Literal Patterns;
  • Capture Patterns;
  • Wildcard Pattern;
  • Constant Value Patterns;
  • Sequence Patterns;
  • Mapping Patterns;
  • Class Patterns.

Расскажу немного о каждой из них.

Literal Patterns


Паттерн Literal, как намекает нам название, предполагает сопоставление между собой ряда значений, а именно строк, чисел, булевых значений и NULLNone.

Это выглядит как string == 'string', используется метод __eq__.

match number:
    case 42:
        print('answer')
    case 43:
        print('not answer')

Capture Patterns


Шаблон захвата позволяет связать переменную с заданным в шаблоне именем и использовать это имя внутри локальной области видимости.
match greeting:
    case "":
        print('Hello my friend')
    case name:
        print(f'Hello  {name}')

Wildcard Pattern


Если вариантов сопоставления слишком много, то можно использовать _ , что является неким значением по умолчанию и будет совпадать со всеми элементами в конструкции match

match number:
    case 42:
        print("Its’s forty two")
    case _:
        print("I don’t know, what it is")

Constant Value Patterns


При использовании констант нужно использовать dotted names, к примеру перечисления, иначе сработает паттерн захвата.
OK = 200
CONFLICT = 409

response = {'status': 409, 'msg': 'database error'}
match response['status'], response['msg']:
    case OK, ok_msg:
        print('handler 200')
    case CONFLICT, err_msg:
        print('handler 409')
    case _:
        print('idk this status')

И ожидаемый результат будет не самым очевидным.

Sequence Patterns


Позволяет сопоставлять списки, кортежи и любые другие объекты от collections.abc.Sequence, кроме str, bytes, bytearray.
answer = [42]
match answer:
    case []:   
        print('i do not find answer')
    case [x]:
        print('asnwer is 42')
    case [x, *_]:
        print('i find more than one answers')

Теперь нет необходимости каждый раз вызывать len() для проверки количества элементов в списке, так как будет вызван метод __len__.

Mapping Patterns


Эта группа немного похожа на предыдущую, только здесь мы сопоставляем словари, или, если быть точным, объекты типа collections.abc.Mapping. Их можно достаточно неплохо сочетать друг с другом.
args = (1, 2)
kwargs = {'kwarg': 'kwarg', 'one_more_kwarg': 'one_more_kwarg'}

def match_something(*args, **kwargs):
    match (args, kwargs):
        case (arg1, arg2), {'kwarg': kwarg}:
            print('i find positional args and one keyword args')
        case (arg1, arg2), {'kwarg': kwarg, 'one_more_kwarg': one_more_kwarg}:
            print('i find a few keyword args')
        case _:
            print('i cannot match anything')

match_something(*args, **kwargs)

И всё бы ничего, но есть особенность. Этот паттерн гарантирует вхождение этого ключа (ключей) в словарь, но длина словаря не имеет значения. Поэтому на экране появится i find positional args and one keyword args.

Class patterns


Что касается пользовательских типов данных, то здесь используется синтаксис, схожий с инициализацией объекта.

Вот как это будет выглядеть на примере дата-классов:

from dataclasses import dataclass

@dataclass
class Coordinate:
    x: int
    y: int
    z: int

coordinate = Coordinate(1, 2, 3)
match coordinate:
    case Coordinate(0, 0, 0):
        print('Zero point')
    case _:
        print('Another point')

Также можно использовать if, или так называемый guard. Если условие ложно, то сопоставление с шаблоном продолжится. Стоит отметить, что сначала происходит сопоставление с шаблоном, и только после этого проверяется условие:
case Coordinate(x, y, z) if z == 0:
    print('Point in the plane XY')

Если использовать непосредственно классы, то нужен атрибут __match_args__, в котором необходимы позиционные аргументы (для namedtuple и dataclasses __match_args__ генерируется автоматически).
class Coordinate:
    __match_args__ = ['x', 'y', 'z']

    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z

сoordinate = Сoordinate(1, 2, 3)
match Сoordinate:
    case Сoordinate(0, 0, 0):
        print('Zero Сoordinate')
    case Сoordinate(x, y, z) if z == 0:
        print('Сoordinate in the plane Z')
    case _:
        print('Another Сoordinate')

Иначе вызовется исключение TypeError: Coordinate () accepts 0 positional sub-patterns (3 given)

Что в итоге?


Фактически это выглядит как очередной синтаксический сахар наряду с недавним walrus operator. Реализация в текущем её виде преобразует блоки оператора match в эквивалентные конструкции if/else, а именно в байт-код, который имеет такой же эффект.
Армин Ронахер, создатель веб-фреймворка Flask для Python, очень ёмко описал текущее состояние Pattern matching

Да, сложно спорить: код станет несколько чище, нежели это была бы башня из if/else на треть экрана. Но и назвать это тем, что производит вау-эффект, тоже нельзя. Неплохо, что оно вводится: местами это будет удобно использовать, но не везде. Так или иначе, главное с этой новизной не переусердствовать, не бежать быстрее обновлять все проекты на 3.10 и всё переписывать, ведь:

Now is better than never. Although never is often better than right now.

Будете использовать? Если да, то где?

© Habrahabr.ru