Сопоставление с шаблоном структуры (Structural pattern matching) и ситуации, где оно неприменимо

В Python 3.10 добавлено сопоставление с шаблоном структуры (Structural pattern matching) через оператор match, который некоторые называют реализацией switch в Python. По данной теме начинает появляться много туториалов, и один из самых адекватных подготовил португальский программист Родриго Жирао Серрао (Rodrigo Girão Serrão) в виде двух блог-постов с описанием данного нововведения и антипаттернов его использования. В данном материале представлены оба блог-поста полностью, за исключением краткого резюме.

Источники:

Structural pattern matching tutorial

Structural pattern matching anti-patterns

Содержание

  • 1. Сопоставление с шаблоном структуры (Structural pattern matching)
    • 1.1. Введение
    • 1.2. Варианты сопоставления с шаблоном структур, которые уже есть в Python
    • 1.3. Наша первая попытка с оператором match
    • 1.4. Базовая структура сопоставления с шаблоном
    • 1.5. Сопоставление структуры объектов
    • 1.6. __match_args__
    • 1.7. Подстановочные символы (wildcards)
    • 1.8. Присвоение индивидуальной части шаблона
    • 1.9. Обход рекурсивных структур
    • 1.10. Осторожнее с восторгами
    • 1.11. Полезные ссылки
  • 2. Антипатеррны при использовании сопоставления с шаблоном структуры
    • 2.1. Введение
    • 2.2. Должен быть только один очевидный вариант
    • 2.3. Короткий и приятный оператор if
    • 2.4. Действуйте с (большим) умом
    • 2.5. Базовые виды соотнесений (mappings)
    • 2.6. getattr
    • 2.7. Универсальная функция с однократным использованием
    • 2.8. Полезные ссылки

1. Сопоставление с шаблоном структуры (Structural pattern matching)

1.1. Введение

Сопоставление с шаблоном структуры (Structural pattern matching) — нововведение Python, которое может выглядеть как простой оператор switch, как и во многих других языках, но все же оператор match добавляется в Python не в качестве простого оператора switch.

Можно обратиться к PEP 634, 635 и 636. В них много информации о том, что сопоставление с шаблоном структуры привнесет в Python, как им пользоваться, описание причин его добавления и т.д. В данной статье мы постараемся сосредоточиться на использовании этой новой функции для написания прекрасного кода.

1.2. Варианты сопоставления с шаблоном структуры, которые уже есть в Python

Сопоставление с шаблоном структуры — не уникальная новинка для Python. Уже давно мы можем, например, выполнить присваивание с помощью звездочек:

>>> a, *b, c = [1, 2, 3, 4, 5]
>>> a
1
>>> b
[2, 3, 4]
>>> c
5

А еще мы можем сделать глубокую распаковку (deep unpacking):

>>> name, (r, g, b) = ("red", (250, 23, 10))
>>> name
'red'
>>> r
250
>>> g
23
>>> b
10

Данные моменты подробно рассмотрены в “Unpacking with starred assignments” и “Deep unpacking”, так что можно почитать, если вы не знакомы с тем, как использовать данные функции для написания питонического кода. В операторе match будут использоваться идеи, лежащие в основе присваивания с помощью звездочки и глубокой распаковки, поэтому будет полезно понимать, как все это работает.

1.3. Наша первая попытка с оператором match

В качестве первого примера с оператором match давайте реализуем факториал, который часто используется для освоения рекурсии. Вот такой вариант:

def factorial(n):
    if n == 0 or n == 1:
        return 1
    else:
        return n * factorial(n-1)
factorial(5)    # 120

Вместо оператора if мы могли бы использовать сопоставление:

def factorial(n):
    match n:
        case 0 | 1:
            return 1
        case _:
            return n * factorial(n - 1)
factorial(5)

Обратим внимание на пару моментов: мы начинаем оператор match с ввода match n, то есть, у нас предусмотрено разное поведение в зависимости от того, чем будет n. Далее у нас есть операторы case, которые можно рассматривать в разных возможных сценариях обработки. За каждым case должен следовать шаблон, с которым мы попытаемся сопоставить n.

Шаблоны также могут содержать альтернативные варианты, обозначенные через | в case 0 | 1, которые будут сопоставляться, если n равно 0 или 1. Второй шаблон case _: подходит для сопоставления чего угодно (когда вам без разницы, что сопоставлять), поэтому он действует примерно как else из первого определения.

1.4. Базовая структура сопоставления с шаблоном (Pattern matching)

Несмотря на то, что операторы сопоставления могут использоваться аналогично простым операторам if, как показано выше, они по-настоящему проявляют себя, когда мы имеем дело со структурированными данными:

def normalise_colour_info(colour):
    """Нормализуем информацию о цвете в (name, (r, g, b, alpha))."""

    match colour:
        case (r, g, b):
            name = ""
            a = 0
        case (r, g, b, a):
            name = ""
        case (name, (r, g, b)):
            a = 0
        case (name, (r, g, b, a)):
            pass
        case _:
            raise ValueError("Unknown colour info.")
    return (name, (r, g, b, a))

# Выводит ('', (240, 248, 255, 0))
print(normalise_colour_info((240, 248, 255)))
# Выводит ('', (240, 248, 255, 0))
print(normalise_colour_info((240, 248, 255, 0)))
# Выводит ('AliceBlue', (240, 248, 255, 0))
print(normalise_colour_info(("AliceBlue", (240, 248, 255))))
# Выводит ('AliceBlue', (240, 248, 255, 0.3))
print(normalise_colour_info(("AliceBlue", (240, 248, 255, 0.3))))

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

Это гораздо лучше в сравнении с эквивалентным кодом на основе операторов if:

def normalise_colour_info(colour):
    """Нормализуем информацию о цвете в (name, (r, g, b, alpha))."""

    if not isinstance(colour, (list, tuple)):
        raise ValueError("Unknown colour info.")

    if len(colour) == 3:
        r, g, b = colour
        name = ""
        a = 0
    elif len(colour) == 4:
        r, g, b, a = colour
        name = ""
    elif len(colour) != 2:
        raise ValueError("Unknown colour info.")
    else:
        name, values = colour
        if not isinstance(values, (list, tuple)) or len(values) not in [3, 4]:
            raise ValueError("Unknown colour info.")
        elif len(values) == 3:
            r, g, b = values
            a = 0
        else:
            r, g, b, a = values
    return (name, (r, g, b, a))

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

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

def normalise_colour_info(colour):
    """Нормализуем информацию о цвете в (name, (r, g, b, alpha))."""

    match colour:
        case (int(r), int(g), int(b)):
            name = ""
            a = 0
        case (int(r), int(g), int(b), int(a)):
            name = ""
        case (str(name), (int(r), int(g), int(b))):
            a = 0
        case (str(name), (int(r), int(g), int(b), int(a))):
            pass
        case _:
            raise ValueError("Unknown colour info.")
    return (name, (r, g, b, a)))

# Выводит ('AliceBlue', (240, 248, 255, 0))
print(normalise_colour_info(("AliceBlue", (240, 248, 255))))
# Появляется ValueError: Unknown colour info.
print(normalise_colour_info2(("Red", (255, 0, "0"))))

Как воспроизвести всю эту валидацию с помощью операторов if?

1.5. Сопоставление структуры объектов

Сопоставление с шаблоном структуры также можно использовать для сопоставления структуры экземпляров класса. Давайте вернемся к классу Point2D, которым я пользовался в качестве примера в нескольких постах, например в постах про __str__ и __repr__ :

class Point2D:
    """Класс для представления точек в 2D-пространстве"""

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

    def __str__(self):
        """Красивое представление объекта"""
        return f"({self.x}, {self.y})"

    def __repr__(self):
        """Однозначный способ восстановления объекта"""
        return f"Point2D({repr(self.x)}, {repr(self.y)})"

Представим, что мы хотим написать небольшую функцию, которая принимает Point2D и записывает небольшое описание того, где находится точка. Мы можем использовать сопоставление с шаблоном для захвата значений атрибутов x и y . Более того, мы можем использовать короткие операторы if , чтобы сузить список желательных видов сопоставлений!

Посмотрим на данный пример:

def describe_point(point):
    """Читаемое описание для положения точки"""

    match point:
        case Point2D(x=0, y=0):
            desc = "at the origin"
        case Point2D(x=0, y=y):
            desc = f"in the vertical axis, at y = {y}"
        case Point2D(x=x, y=0):
            desc = f"in the horizontal axis, at x = {x}"
        case Point2D(x=x, y=y) if x == y:
            desc = f"along the x = y line, with x = y = {x}"
        case Point2D(x=x, y=y) if x == -y:
            desc = f"along the x = -y line, with x = {x} and y = {y}"
        case Point2D(x=x, y=y):
            desc = f"at {point}"

    return "The point is " + desc

# Выводит "The point is at the origin"
print(describe_point(Point2D(0, 0)))
# Выводит "The point is in the horizontal axis, at x = 3"
print(describe_point(Point2D(3, 0)))
# Выводит "# The point is along the x = -y line, with x = 3 and y = -3"
print(describe_point(Point2D(3, -3)))
# Выводит "# The point is at (1, 2)"
print(describe_point(Point2D(1, 2)))

1.6. __match_args__

Вы заметили все эти x = и y = в вышеприведенном фрагменте кода, они не раздражают вас? Каждый раз, когда я писал новый шаблон для экземпляра Point2D , мне приходилось указывать, какой аргумент станет x , а какой станет y . Для классов, в которых очередность не произвольная, мы можем использовать __match_args__ , чтобы сообщить Python желательное для нас сопоставление match с атрибутами нашего объекта.

Вот более короткая версия вышеприведенного примера, в которой используется __match_args__ , сообщающий Python очередность сопоставления аргументов Point2D :

class Point2D:
    """Класс для представления точек в 2D-пространстве"""

    __match_args__ = ["x", "y"]
    def __init__(self, x, y):
        self.x = x
        self.y = y

def describe_point(point):
    """Читаемое описание для положения точки"""

    match point:
        case Point2D(0, 0):
            desc = "at the origin"
        case Point2D(0, y):
            desc = f"in the vertical axis, at y = {y}"
        case Point2D(x, 0):
            desc = f"in the horizontal axis, at x = {x}"
        case Point2D(x, y):
            desc = f"at {point}"

    return "The point is " + desc

# Prints "The point is at the origin"
print(describe_point(Point2D(0, 0)))
# Prints "The point is in the horizontal axis, at x = 3"
print(describe_point(Point2D(3, 0)))
# Prints "# The point is at (1, 2)"
print(describe_point(Point2D(1, 2)))

1.7. Подстановочные символы (wildcards)

Звездочка *

Можно сделать вот так:

>>> head, *body, tail = range(10)
>>> print(head, body, tail)
0 [1, 2, 3, 4, 5, 6, 7, 8] 9

Здесь *body указывает Python вставлять в body все, что не входит в head или tail . Можно использовать подстановочные символы * и ** . Символ * подходит для списков и кортежей, сопоставляя с остающейся частью:

def rule_substitution(seq):
    new_seq = []
    while seq:
        match seq:
            case [x, y, z, *tail] if x == y == z:
                new_seq.extend(["3", x])
            case [x, y, *tail] if x == y:
                new_seq.extend(["2", x])
            case [x, *tail]:
                new_seq.extend(["1", x])
        seq = tail
    return new_seq

seq = ["1"]
print(seq[0])
for _ in range(10):
    seq = rule_substitution(seq)
    print("".join(seq))

"""
Выводит:
1
11
21
1211
111221
312211
13112221
1113213211
31131211131221
13211311123113112211
11131221133112132113212221
"""

Таким образом создается вышеуказанная последовательность (sequence), в которой каждое число создается на основе предыдущего через описание увиденного. Например, если мы находим три одинаковые цифры подряд, например "222" , то переписываем их в виде "32" . С помощью оператора match становится намного чище.

В вышеприведенных операторах case часть шаблона *tail соответствует оставшейся части последовательности, так как мы используем только x , y и z для сопоставления в начале последовательности.

Простое сопоставление со словарем

Аналогичным образом мы можем использовать ** для сопоставления с оставшейся частью словаря. Но сначала давайте посмотрим, что произойдет при сопоставлении словарей:

d = {0: "oi", 1: "uno"}
match d:
    case {0: "oi"}:
        print("yeah.")
# выводит "yeah".

Несмотря на то, что в d есть ключ 1 со значением "uno" , и это не указано в единственном операторе case, сопоставление выполняется успешно и мы добавляем оператор.

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

Двойная звездочка **

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

d = {0: "oi", 1: "uno"}
match d:
    case {0: "oi", **remainder}:
        print(remainder)
# выводит {1: 'uno'}

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

d = {0: "oi", 1: "uno"}
match d:
    case {0: "oi", **remainder} if not remainder:
        print("Single key in the dictionary")
    case {0: "oi"}:
        print("Has key 0 and extra stuff.")
# Has key 0 and extra stuff.

Еще можно использовать переменные (variables) для сопоставления со значениями заданных ключей:

d = {0: "oi", 1: "uno"}
match d:
    case {0: zero_val, 1: one_val}:
        print(f"0 mapped to {zero_val} and 1 to {one_val}")
# 0 соотносится с oi и 1 соотносится с uno

1.8. Присвоение имени отдельной части шаблона

Иногда может потребоваться сопоставление с более структурированным шаблоном, а затем дать имя какой-нибудь части шаблона или ему всему, чтобы иметь возможность пользоваться в дальнейшем. Так бывает особенно часто, если в шаблоне есть альтернативы, которые добавляются с помощью | :

def go(direction):
    match direction:
        case "North" | "East" | "South" | "West":
            return "Alright, I'm going!"
        case _:
            return "I can't go that way..."

print(go("North"))      # Alright, I'm going!
print(go("asfasdf"))    # I can't go that way...

Теперь представим, что логика для обработки данного «перехода» вложена во что-нибудь более сложное:

def act(command):
    match command.split():
        case "Cook", "breakfast":
            return "I love breakfast."
        case "Cook", *wtv:
            return "Cooking..."
        case "Go", "North" | "East" | "South" | "West":
            return "Alright, I'm going!"
        case "Go", *wtv:
            return "I can't go that way..."
        case _:
            return "I can't do that..."

print("Go North")       # Alright, I'm going!
print("Go asdfasdf")    # I can't go that way...
print("Cook breakfast") # I love breakfast.
print("Drive")          # I can't do that...

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

def act(command):
    match command.split():
        case "Cook", "breakfast":
            return "I love breakfast."
        case "Cook", *wtv:
            return "Cooking..."
        case "Go", "North" | "East" | "South" | "West" as direction:
            return f"Alright, I'm going {direction}!"
        case "Go", *wtv:
            return "I can't go that way..."
        case _:
            return "I can't do that..."

print("Go North")       # Alright, I'm going North!
print("Go asdfasdf")    # I can't go that way...

1.9. Обход рекурсивных структур

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

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

Можно написать небольшой вариант match для данной задачи:

import ast

def prefix(tree):
    match tree:
        case ast.Expression(expr):
            return prefix(expr)
        case ast.Constant(value=v):
            return str(v)
        case ast.BinOp(lhs, op, rhs):
            match op:
                case ast.Add():
                    sop = "+"
                case ast.Sub():
                    sop = "-"
                case ast.Mult():
                    sop = "*"
                case ast.Div():
                    sop = "/"
                case _:
                    raise NotImplementedError()
            return f"{sop} {prefix(lhs)} {prefix(rhs)}"
        case _:
            raise NotImplementedError()

print(prefix(ast.parse("1 + 2 + 3", mode="eval")))     # + + 1 2 3
print(prefix(ast.parse("2**3 + 6", mode="eval"))       # + * 2 3 6
# Выводит '- + 1 * 2 3 / 5 7', нужно время, чтобы это переварить.
print(prefix(ast.parse("1 + 2*3 - 5/7", mode="eval")))

1.10. Осторожнее с восторгами

А теперь предостережение: match – не всегда лучшее решение. Например, вернемся к вышеприведенному примеру с префиксной нотацией. Возможно, есть более эффективные способы преобразования каждого возможного двоичного оператора в его строковое представление?

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

На самом деле, мы можем (и, вероятно, должны) сделать что-то другое в такой ситуации. Например,

import ast

def op_to_str(op):
    ops = {
        ast.Add: "+",
        ast.Sub: "-",
        ast.Mult: "*",
        ast.Div: "/",
    }
    return ops.get(op.__class__, None)

def prefix(tree):
    match tree:
        case ast.Expression(expr):
            return prefix(expr)
        case ast.Constant(value=v):
            return str(v)
        case ast.BinOp(lhs, op, rhs):
            sop = op_to_str(op)
            if sop is None:
                raise NotImplementedError()
            return f"{sop} {prefix(lhs)} {prefix(rhs)}"
        case _:
            raise NotImplementedError()

print(prefix(ast.parse("1 + 2 + 3", mode="eval")))     # + + 1 2 3
print(prefix(ast.parse("2*3 + 6", mode="eval"))        # + * 2 3 6
# Выводит '- + 1 * 2 3 / 5 7', нужно время, чтобы это переварить.
print(prefix(ast.parse("1 + 2*3 - 5/7", mode="eval")))

1.11. Полезные ссылки

PEP 622 -- Structural Pattern Matching,

PEP 634 -- Structural Pattern Matching: Specification

PEP 635 -- Structural Pattern Matching: Motivation and Rationale

PEP 636 -- Structural Pattern Matching: Tutorial

Dynamic Pattern Matching with Python

Python 3.10 Pattern Matching in Action, YouTube video by “Big Python”

2. Антипатеррны при использовании сопоставления с шаблоном структуры

2.1. Введение

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

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

2.2. Должен быть только один очевидный вариант

Как говорится в Дзене Python,

"Должен быть один – и желательно только один – очевидный вариант"

Может показаться, что введение оператора match нарушает данный принцип. Однако вы должны помнить, что смысл оператора match не в том, чтобы служить базовым switch , ведь у нас уже есть много альтернатив для него – ведь если бы целью match было служить простым переключателем, то он, вероятно, назывался бы «switch», а не «match» .

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

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

2.3. Короткий и приятный оператор if

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

def collatz_path(n):
    path = [n]
    while n != 1:
        match n % 2:
            case 0:
                n //= 2
            case 1:
                n = 3*n + 1
        path.append(n)
    return path

Это дает следующие два примера выходных данных:

>>> collatz_path(8)
[8, 4, 2, 1]
>>> collatz_path(15)
[15, 46, 23, 70, 35, 106, 53, 160, 80, 40, 20, 10, 5, 16, 8, 4, 2, 1]

Если мы посмотрим на использование match в данной ситуации, то увидим, что он в основном служил простым переключателем для сопоставления 0 или 1, единственных двух значений, которыми может завершиться операция n % 2 для положительного целого числа n . Но если мы используем простой if , то можем написать точно такой же код и сэкономить одну строку кода:

def collatz_path(n):
    path = [n]
    while n != 1:
        if n % 2:
            n = 3*n + 1
        else:
            n //= 2
        path.append(n)
    return path

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

2.4. Действуйте с (большим) умом

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

Если вы никогда о нем не слышали, Правило 30 представляет собой «элементарный клеточный автомат». Можно представить его себе как правило, которое получает три бита (три нуля/единицы) и производит новый бит, в зависимости от полученных трех битов. Автоматы и правда очень-очень интересны, но их обсуждение выходит за рамки данной статьи. Давайте просто посмотрим на возможную реализацию автомата «Правило 30»:

def rule30(bits):
    match bits:
        case 0, 0, 0:
            return 0
        case 0, 0, 1:
            return 1
        case 0, 1, 0:
            return 1
        case 0, 1, 1:
            return 1
        case 1, 0, 0:
            return 1
        case 1, 0, 1:
            return 0
        case 1, 1, 0:
            return 0
        case 1, 1, 1:
            return 0

Выглядит как разумное использование оператора match , за исключением того, что мы только что написали 16 строк кода. Хорошо, вы правы, давайте соберем правила, которые возвращают те же значения и которые должны сделать код короче:

def rule30(bits):
    match bits:
        case 0, 0, 0 | 1, 0, 1 | 1, 1, 0 | 1, 1, 1:
            return 0
        case 0, 0, 1 | 0, 1, 0 | 0, 1, 1 | 1, 0, 0:
            return 1

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

Проведя совсем небольшое исследование, вы, возможно, узнаете, что «Правило 30» можно записать в виде замкнутой формулы, которая зависит от трех входных битов. То есть, нам не нужно выполнять match по входным битам со всеми возможными входами, мы можем просто вычислить выход:

def rule30(bits):
    p, q, r = bits
    return (p + q + r + q*r) % 2

Вы можете возразить, что данная формула прячет взаимосвязь между несколькими входами и их выходами. В принципе, вы правы, но явное «Правило 30», записанное с match , мало что говорит вам о том, почему каждый вход соответствует каждому выходу, так почему бы не сделать его коротким и приятным глазу?

2.5. Базовые типы соотнесений (mappings)

Извлечение из словарей

Есть много случаев, когда вам просто нужно взять значение и соотнести его с чем-то другим. В качестве примера возьмем данный фрагмент кода, который принимает выражение и записывает его в префиксной нотации:

import ast

def prefix(tree):
    match tree:
        case ast.Expression(expr):
            return prefix(expr)
        case ast.Constant(value=v):
            return str(v)
        case ast.BinOp(lhs, op, rhs):
            match op:
                case ast.Add():
                    sop = "+"
                case ast.Sub():
                    sop = "-"
                case ast.Mult():
                    sop = "*"
                case ast.Div():
                    sop = "/"
                case _:
                    raise NotImplementedError()
            return f"{sop} {prefix(lhs)} {prefix(rhs)}"
        case _:
            raise NotImplementedError()

print(prefix(ast.parse("1 + 2 + 3", mode="eval")))     # + + 1 2 3
print(prefix(ast.parse("2**3 + 6", mode="eval"))       # + * 2 3 6
# Последний выводит '- + 1 * 2 3 / 5 7', нужно подумать.
print(prefix(ast.parse("1 + 2*3 - 5/7", mode="eval")))

Заметили внутренний match для преобразования op внутри BinOp в строку? Во-первых, такое вложение match занимает слишком много места по вертикали и отвлекает нас от того, что действительно важно, а именно от обхода рекурсивной структуры дерева. Получается, что мы могли бы реорганизовать данный фрагмент в служебную функцию:

import ast

def op_to_str(op):
    match op:
        case ast.Add():
            sop = "+"
        case ast.Sub():
            sop = "-"
        case ast.Mult():
            sop = "*"
        case ast.Div():
            sop = "/"
        case _:
            raise NotImplementedError()
    return sop

def prefix(tree):
    match tree:
        case ast.Expression(expr):
            return prefix(expr)
        case ast.Constant(value=v):
            return str(v)
        case ast.BinOp(lhs, op, rhs):
            return f"{op_to_str(op)} {prefix(lhs)} {prefix(rhs)}"
        case _:
            raise NotImplementedError()

print(prefix(ast.parse("1 + 2 + 3", mode="eval")))     # + + 1 2 3
print(prefix(ast.parse("2**3 + 6", mode="eval"))       # + * 2 3 6
# Последний выводит '- + 1 * 2 3 / 5 7', нужно подумать.
print(prefix(ast.parse("1 + 2*3 - 5/7", mode="eval")))

Благодаря этому проще читать и интерпретировать функцию prefix , но теперь у нас другая проблема, которая меня по-настоящему раздражает: простая, но длинная функция op_to_str . Для каждого вида операторов, который вы будете поддерживать, ваша функция увеличивается на две строки. Если заменить match цепочкой из операторов if и elif , экономится одна строка сверху.

Исправление, которое я предложил в исходной статье, заключалось в использовании словаря для соотнесения типа op с его строковым представлением:

def op_to_str(op):
    ops = {
        ast.Add: "+",
        ast.Sub: "-",
        ast.Mult: "*",
        ast.Div: "/",
    }
    return ops.get(op.__class__, None)

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

2.6. getattr

Нам доступен еще один полезный механизм, функция getattr , одна из трех встроенных функций Python: hasattr , getattr и setattr .

Я пишу интерпретатор APL под названием RGSPL , и в нем есть функция visit_F , где мне нужно соотнести символы APL, например +, – и ⍴ с соответствующей функцией Python, которая его реализует. Данные функции Python, реализующие поведение символов, находятся в файле functions.py. Если бы я использовал оператор match, вот как могла бы выглядеть visit_F:

import functions

def visit_F(self, func):
    """Берем вызываемую функцию"""

    name = func.token.type.lower()  # Get the name of the symbol.
    match name:
        case "plus":
            function = functions.plus
        case "minus":
            function = functions.minus
        case "reshape":
            function = functions.reshape
        case _:
            function = None
    if function is None:
        raise Exception(f"Could not find function {name}.")
    return function

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

Однако здесь есть подвох: мне еще предстоит пройти долгий путь в моем проекте RGSPL, и у меня уже есть пара десятков данных примитивов, поэтому мой оператор match будет примерно 40 строк в длину, если бы я использовал данное решение, или 20 строк, если бы я использовал решение со словарем, с парой ключ-значение на каждую строку.

К счастью, в Python можно использовать getattr для получения атрибута из объекта, если у меня есть имя данного атрибута. Неслучайно значение указанной выше переменной name должно совпадать с названием функции, определенной в functions.py :

import functions

getattr(functions, "plus", None)        # возвращает functions.plus
getattr(functions, "reshape", None)     # возвращает functions.reduce
getattr(functions, "fasfadf", None)     # возвращает None

Благодаря функции getattr размер моей visit_F не меняется независимо от того, сколько функций я добавляю в файл functions.py :

def visit_F(self, func):
    """Берем вызываемую функцию"""

    name = func.token.type.lower()      # Get the name of the symbol.
    function = getattr(functions, name, None)
    if function is None:
        raise Exception(f"Could not find function {name}.")
    return function

Функцию getattr также можно использовать для получения атрибутов из экземпляра класса, например,

class Foo:
    def __ini__(self, a, b):
        self.a = a
        self.b = b

foo = Foo(3, 4)
print(getattr(foo, "a"))    # prints 3
bar = Foo(10, ";")
print(getattr(bar, ";"))    # prints ';'

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

Продолжая разговор о хорошем знании доступных инструментов, перейдем к последнему варианту, для которого match подходит плохо. Он связан с вызовом различных функций для данных различных типов.

2.7. Универсальная функция с однократным использованием

Если у вас есть опыт программирования на Java, вы знакомы с концепцией перегрузки функции: вы реализуете одну и ту же функцию несколько раз, но можете указать поведение функции для разных видов и (или) количеств аргументов.

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

def pretty_print(arg):
    if isinstance(arg, complex):
        print(f"{arg.real} + {arg.imag}i")
    elif isinstance(arg, (list, tuple)):
        for i, elem in enumerate(arg):
            print(i, elem)
    elif isinstance(arg, dict):
        for key, value in arg.items():
            print(f"{key}: {value}")
    else:
        print(arg)

И этом работает вот так:

>>> pretty_print(3)
3
>>> pretty_print([2, 5])
0 2
1 5
>>> pretty_print(3+4j)
3.0 + 4.0i

Думаю, вы заметили, что ветвление с помощью оператора if предназначено лишь для разделения различных типов, которые могут быть присвоены arg . Логика обработки может быть разной, но конечная цель всегда одна и та же: красиво вывести объект. Но что, если код для обработки каждого вида аргумента занимает 10 или 20 строк? Вы получите действительно длинную функцию для того, что по сути будет встроенными подфункциями.

Можно разделить все эти подфункции, используя декоратор functools.singledispatch :

import functools

@functools.singledispatch
def pretty_print(arg):
    print(arg)

@pretty_print.register(complex)
def _(arg):
    print(f"{arg.real} + {arg.imag}i")

@pretty_print.register(list)
@pretty_print.register(tuple)
def _(arg):
    for i, elem in enumerate(arg):
        print(i, elem)

@pretty_print.register(dict)
def _(arg):
    for key, value in arg.items():
        print(f"{key}: {value}")

И затем все это можно использовать точно так же, как исходную функцию:

>>> pretty_print(3)
3
>>> pretty_print([2, 5])
0 2
1 5
>>> pretty_print(3+4j)
3.0 + 4.0i

Пример с pretty_print – не самый лучший, так как вы тратите столько же строк на декоратор, сколько при определении реальных подфункций. Но он показывает вам шаблон, который вы, возможно, искали. Больше информации про singledispatch можно получить в документации.

2.8. Полезные ссылки

PEP 622 -- Structural Pattern Matching

PEP 634 -- Structural Pattern Matching: Specification

PEP 635 -- Structural Pattern Matching: Motivation and Rationale

PEP 636 -- Structural Pattern Matching: Tutorial

PEP 443 -- Single-dispatch generic functions

Python 3 Documentation, The Python Standard Library, getattr

Python 3 Documentation, The Python Standard Library, functools.singledispatch

Wikipedia, “Collatz Conjecture”

WolframAlpha, “Rule 30”

Wikipedia, “Rule 30”