Match/case vs If/else. Сравниванием скорость работы операторов в Python 3.10 +18



Прошло уже достаточно времени с момента релиза Python версии 3.10. Самым главным и самым ожидаемым было введение оператора match/case (он же pattern matching). 

Однако далеко не всем разработчикам из нашего комьюнити зашел данный оператор. Свидетельствуют этому даже комментарии под статьями на хабре (статья 1статья 2), которые были посвящены match/case.

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

Начинаем

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

import random as rnd
def create_rnd_data():
    words = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0,
         'здесь', 'дом', 'да', 'потому', 'сторона',
         'какой-то', 'думать', 'сделать', 'страна',
         'жить', 'чем', 'мир', 'об', 'последний', 'случай',
         'голова', 'более', 'делать', 'что-то', 'смотреть',
         'ребенок', 'просто', 'конечно', 'сила', 'российский',
         'конец', 'перед', 'несколько']
    data = rnd.choices(words, k=500000)
    return data

Создадим несколько простых проверок данных, получаемых в функции create_rnd_data:

# простые проверки данных при помощи if/else
def test_if(data):
    for word in data:
        if word in ['дом', 'думать', 'что-то', 'просто']:
            pass
        elif isinstance(word, int):
            pass
        elif isinstance(word, str) and len(word) > 3:
            pass
        elif isinstance(word, str) and word.startswith("д"):
            pass
        else:
            pass
# те же проверки при помощи match/case
def test_match(data):
    for word in data:
        match word:
            case 'дом'|'думать'|'что-то'|'просто':
                pass
            case int(word):
                pass
            case str(word) if len(word) > 3:
                pass
            case str(word) if word.startswith("д"):
                pass
            case _:
                pass

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

import timeit
# создаем случайные данные для теста
test_data = create_rnd_data()
# количество повторений
repeats = 1000
# считаем результаты
time_repeat_if = timeit.timeit("test_if(test_data)",
                               setup="from __main__ import test_if, test_data",
                               number=repeats)

time_repeat_match = timeit.timeit("test_match(test_data)",
                                  setup="from __main__ import test_match, test_data",
                                  number=repeats)
print("РЕЗУЛЬТАТ IF/ELSE:   ", time_repeat_if/repeats)
print("РЕЗУЛЬТАТ MATCH/CASE:", time_repeat_match/repeats)
>>> РЕЗУЛЬТАТ IF/ELSE:    0.1284590820000085
>>> РЕЗУЛЬТАТ MATCH/CASE: 0.4847222329999931
Зачем делаются проверки isinstance в if/else?

Так как в match/case проверка типов данных происходит при помощи int, str, чтобы все проверки в if/else были эквиваленты проверкам match/case, то взамен str, int делаются проверки isinstance(..., str/int). Так же необходимо учитывать, что Python - язык с динамической типизацией, и не всегда есть гарантия того, что данные в списке будут четко того типа, который вы ожидаете.

Хммм… В 3.8 раза скорость выполнения match/case оказалась медленнее, чем скорость if/else. Но не будем загадывать наперед, может быть, match/case окажется быстрее при работе с более сложными структурами, например, при проверке словарей.

Создадим некоторый список словарей:

def create_rnd_data():
    names = ["phone", "TV", "PC", "car", "home", "case", "bird", "chicken",
            "dish", "float", "C++", "data", ""]
    prices = [500, 100, 1400, 2000, 750, 3500, 5000, 120, 50, 4200]
    goods = []
    for i in range(500000):
        name = names[i%len(names)]
        price = prices[i%len(prices)]
        goods.append({"name": name, "price": price})
    return goods

Проверим, как поведут себя if/else и match/case при работе со словарями:

import timeit
def test_if(data):
    for element in data:
        if element.get("name") in ["phone", "TV"] and isinstance(element.get("price"), int) and element.get("price") > 2000:
            pass
        elif element.get("name") == "case" and isinstance(element.get("price"), int) and element.get("price") <= 750:
            pass
        elif element.get("name") == "case" and isinstance(element.get("price"), int) and element.get("price") == 750:
            pass
        elif isinstance(element.get("name"), str) and element.get("name"):
            pass
        elif isinstance(element.get("price"), int) and element.get("price") > 1000:
            pass
        else:
            pass
def test_match(data):
    for element in data:
        match element:
            case {"name": "phone"|"TV", "price": int(price)} if price > 2000:
                pass
            case {"name": "case", "price": int(price)} if price <= 750:
                pass
            case {"name": "case", "price": 750}:
                pass
            case {"name": str(name), "price": _} if name:
                pass
            case {"name": _, "price": int(price)} if price > 1000:
                pass
            case _:
                pass

# создаем случайные данные для теста
test_data = create_rnd_data()
# количество повторений
repeats = 1000
# считаем результаты
time_repeat_if = timeit.timeit("test_if(test_data)",
                               setup="from __main__ import test_if, test_data",
                               number=repeats)

time_repeat_match = timeit.timeit("test_match(test_data)",
                                  setup="from __main__ import test_match, test_data",
                                  number=repeats)
print("РЕЗУЛЬТАТ IF/ELSE:   ", time_repeat_if/repeats)
print("РЕЗУЛЬТАТ MATCH/CASE:", time_repeat_match/repeats)
>>> РЕЗУЛЬТАТ IF/ELSE:   0.25263675300000616
>>>РЕЗУЛЬТАТ MATCH/CASE: 1.2811748609999996

В 5 раз оператор match/case уступил по скорости if/else. Рано делать выводы, проверим работу с еще более сложными структурами, например, создадим свой класс и выполним те же проверки:

import timeit

# создаем случайные данные
class Goods:
    __match_args__= ('name', 'price')
    def __init__(self, name, price):
        self.name = name
        self.price = price
        
names = ["phone", "TV", "PC", "car", "home", "case", "bird", "chicken",
        "dish", "float", "C++", "data", ""]
prices = [500, 100, 1400, 2000, 750, 3500, 5000, 120, 50, 4200]
goods = []
for i in range(500000):
    name = names[i%len(names)]
    price = prices[i%len(prices)]
    goods.append(Goods(name=name, price=price))

# функции-проверки
def test_if(data):
    for element in data:
        if isinstance(element, Goods):
            if element.name in ["phone", "TV"] and isinstance(element.price, int) and element.price > 2000:
                pass
            elif element.name == "case" and isinstance(element.price, int) and element.price <= 750:
                pass
            elif element.name == "case" and isinstance(element.price, int) and element.price == 750:
                pass
            elif element.name:
                pass
            elif isinstance(element.price, int) and element.price > 1000:
                pass
            else:
                pass
def test_match(data):
    for element in data:
        match element:
            case Goods("phone"|"TV" as name, int(price)) if price > 2000:
                pass
            case Goods(name="case", price=int(price)) if price <= 750:
                pass
            case Goods(name="case", price=750):
                pass
            case Goods(str(name), _) if name:
                pass
            case Goods(_, price) if price > 1000:
                pass
            case _:
                pass

# случайные данные для теста
test_data = goods
# количество повторений
repeats = 1000
# считаем результаты
time_repeat_if = timeit.timeit("test_if(test_data)",
                               setup="from __main__ import test_if, test_data",
                               number=repeats)

time_repeat_match = timeit.timeit("test_match(test_data)",
                                  setup="from __main__ import test_match, test_data",
                                  number=repeats)
print("РЕЗУЛЬТАТ IF/ELSE:   ", time_repeat_if/repeats)
print("РЕЗУЛЬТАТ MATCH/CASE:", time_repeat_match/repeats)
>>> РЕЗУЛЬТАТ IF/ELSE:    0.17162651800001186
>>> РЕЗУЛЬТАТ MATCH/CASE: 1.2821951220000118

В 7.5 раз...

Вывод

Я не стал проверять оператор match/case на списках, кортежах, множествах и других структурах, не стал нагружать сложными условиями, так как, думаю, очевидно можно сделать пару выводов:

  1. Если вы занимаетесь каким-либо вычислениями на Python, или в вашем приложении много циклов/много проверок - НЕ стоит использовать оператор match/case, так как он может замедлить выполнение кода в несколько раз;

  2. Если в приложении есть проверки на формы ввода, либо проверки if/else слишком большие, но проверки выполняются не очень часто (например, при клике пользователя на кнопку), то оператор match/case может стать хорошей альтернативой if/else, так как он сочетает в себе много хороший функций (см предыдущие статьи во введении);

  3. Будем надеяться и ждать оптимизацию match/case, так как версия Python 3.10 молодая и вышла только месяц назад.




Комментарии (6):

  1. Tanner
    /#23762495 / +3

    Дежавю. Вроде как пару дней назад видел эту статью.

  2. vba
    /#23762993 / +2

    Почему Карл, почему?...

  3. Andy_U
    /#23763005 / +2

    В последнее сравнение я бы в функцию test_if() в каждую ветку добавил бы пару сравнений

    isinstance(element, dict) and len(element) == 2 and ...

    А иначе, если element, это не словарь, то в этой функции вылетит ошибка AttributeError, а вторая отработает без ошибок. Ну и вторая функция проверяет, что в словаре 2 элемента, а данная - нет. А тогда разница будет - тройка. Уже лучше, и, да, за читабельность кода надо платить.

  4. Dmitriy_Volkov
    /#23766145 / +2

    Наверное стоило сделать ещё тест, который продемонстрировал бы сложность О. Может при сотне кейсов время выполнения и сравняется.

    Предположу, что при ровных руках разработчиков сложность match/case можно довести до O(1), ну или хотя бы O(log n), в зависимости от мапы под капотом. А вот if/else всегда будет O(n).

  5. Deosis
    /#23766883

    Рано делать выводы, проверим работу с еще более сложными структурами

    Попробуйте наоборот энум на 50 вариантов. Скорее всего, чем проще проверка, тем эффективнее match/case.

  6. Vaindante
    /#23775337

    Ну проверки у вас не верные, поэтому и время не верное, если написать более правильно то и результат другой

    def test_match(data):
        for word in data:
            match word:
                case 'дом' | 'думать' | 'что-то' | 'просто':
                    pass
                case int():
                    print(word)
                case str() if len(word) > 3:
                    pass
                case str() if word.startswith("д"):
                    pass
                case _:
                    pass
                  
    >>> РЕЗУЛЬТАТ MATCH/CASE: 0.21404509879399847

    да это по прежнему дольше чем классический if else, но разница в скорости совсем другая