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

3703963214be5c689fb9bea60826ab7d.png

Прошло уже достаточно времени с момента релиза 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 молодая и вышла только месяц назад.

© Habrahabr.ru