Прошло уже достаточно времени с момента релиза 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 на списках, кортежах, множествах и других структурах, не стал нагружать сложными условиями, так как, думаю, очевидно можно сделать пару выводов:
Если вы занимаетесь каким-либо вычислениями на Python, или в вашем приложении много циклов/много проверок - НЕ стоит использовать оператор match/case, так как он может замедлить выполнение кода в несколько раз;
Если в приложении есть проверки на формы ввода, либо проверки if/else слишком большие, но проверки выполняются не очень часто (например, при клике пользователя на кнопку), то оператор match/case может стать хорошей альтернативой if/else, так как он сочетает в себе много хороший функций (см предыдущие статьи во введении);
Будем надеяться и ждать оптимизацию match/case, так как версия Python 3.10 молодая и вышла только месяц назад.
Комментарии (6)
Andy_U
29.11.2021 12:00+2В последнее сравнение я бы в функцию test_if() в каждую ветку добавил бы пару сравнений
isinstance(element, dict) and len(element) == 2 and ...
А иначе, если element, это не словарь, то в этой функции вылетит ошибка AttributeError, а вторая отработает без ошибок. Ну и вторая функция проверяет, что в словаре 2 элемента, а данная - нет. А тогда разница будет - тройка. Уже лучше, и, да, за читабельность кода надо платить.
Dmitriy_Volkov
30.11.2021 00:27+2Наверное стоило сделать ещё тест, который продемонстрировал бы сложность О. Может при сотне кейсов время выполнения и сравняется.
Предположу, что при ровных руках разработчиков сложность match/case можно довести до O(1), ну или хотя бы O(log n), в зависимости от мапы под капотом. А вот if/else всегда будет O(n).
Deosis
30.11.2021 09:16Рано делать выводы, проверим работу с еще более сложными структурами
Попробуйте наоборот энум на 50 вариантов. Скорее всего, чем проще проверка, тем эффективнее match/case.
Vaindante
01.12.2021 23:37Ну проверки у вас не верные, поэтому и время не верное, если написать более правильно то и результат другой
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, но разница в скорости совсем другая
Tanner
Дежавю. Вроде как пару дней назад видел эту статью.