Python часто ругают за то, что он медленный, но есть несколько способов ускорить код. В этой статье поговорим про обработку списков.
8К открытий9К показов
Python часто ругают за то, что он медленный. Однако в нем существует несколько подходов, которые позволяют писать достаточно быстрый код. Сегодня поговорим про обработку списков.
TL;DR Используйте списковые включения (list comprehensions), генераторные выражения (generator expressions) и генераторы везде, где только можно. Это поможет сэкономить память и время выполнения программы.
Антон Сычугов
Ведущий разработчик CoMagic.dev (IT-платформа UIS и CoMagic)
Списковые включения (List comprehensions)
Например у нас есть большой список словарей (объявления контекстной рекламы):
import timeit
import time
import itertools
import random
now = time.time()
ads_list = list({’bid’: random.randint(1, 1000), ’date’: now — i, ’id’: 1000000 — i} for i in range(1000000))
Зададим начальное время выборки и конечное
date_start = now — 60*60*24*7
date_end = now — 60*60*24*3
И попробуем выбрать все объявления, ставка которых выше 600 и дата попадает в выбранный интервал. Затем возьмем первые 1000 элементов полученного списка. Сначала решим задачу «в лоб»:
def many_lists():
expensive_ads = [a for a in ads_list if a[’bid’] > 600]
expensive_in_interval = [a for a in expensive_ads if a[’date’] > date_start and a[’date’] < date_end]
return expensive_in_interval[:1000]
timeit.timeit(many_lists, number=100)
9.126969000000031
Ок, попробуем немного оптимизировать и сделаем проверку даты там же, где и ставки:
def many_lists_improved():
expensive_ads = [a for a in ads_list if a[’bid’] > 600 and a[’date’] > date_start and a[’date’] < date_end]
return expensive_ads[:1000]
timeit.timeit(many_lists_improved, number=100)
8.304767416000004
Уже лучше, но не сильно лучше.
Генераторные выражения (generator expressions)
Попробуем использовать генераторные выражения (для получения среза будем использовать функцию islice из itertools, которая возвращает итератор по срезу):
def generators():
expensive_ads = (a for a in ads_list if a[’bid’] > 600)
expensive_in_interval = (a for a in expensive_ads if a[’date’] > date_start and a[’date’] < date_end)
return list(itertools.islice(expensive_in_interval, 0, 1000))
timeit.timeit(generators, number=100)
2.422867124999925
Итог: увеличение производительности более чем в 3 раза.
Генераторные фунции (generator functions)
Если предикатов фильтрации или обработчиков элементов списка много, то удобнее использовать генераторы. Они могут не дать прироста скорости, но помогут сэкономить память.
Генераторной фунцией в python называется функция, которая ведет себя как итератор. Для определения генераторной функции нужно использовать ключевое слово yield:
def count_five():
for i in range(5):
yield i
Например у нас есть список кортежей (чтобы не было соблазна менять словарь на месте) с данными объявлений, и мы хотим выбрать все объявления из списка, попадающие в наш интервал, а также протэгировать по условиям ставки.
Попробуем для начала решить задачу в лоб и в каждой функции-обработчике возвращать новый список, а затем решим задачу с помощью генераторов:
import sys
import time
import random
import psutil
from timeit import timeit
now = time.time()
ads_list = list((1000000 — i, now — i, random.randint(1, 1000)) for i in range(1000000))
date_end = now — 60*60*24*3
date_start = now — 60*60*24*7
def below_3days(ad_list):
return [a for a in ad_list if a[1] < date_end] def above_7days(ad_list): return [a for a in ad_list if a[1] > date_start]
def tag_bid(ad_list):
return [(a[0], a[1], a[2] > 500 and ’expensive’ or ’cheap’) for a in ad_list]
def above_7days_gen(ad_list):
for a in ad_list:
if a[1] > date_start:
yield a
def below_3days_gen(ad_list):
for a in ad_list:
if a[1] < date_end: yield a def tag_bid_gen(ad_list): for a in ad_list: tag = a[2] > 500 and ’expensive’ or ’cheap’
yield (a[0], a[1], tag)
list_pipeline = lambda ads: below_3days(above_7days(tag_bid(ads)))
gen_pipeline = lambda ads: below_3days_gen(above_7days_gen(tag_bid_gen(ads)))
pipelines = {
’list’: list_pipeline,
’gen’: gen_pipeline
}
def run_pipeline(ad_list, pipeline):
processed = pipeline(ad_list)
return sorted(processed, key=lambda item: item[0])
if __name__ == ’__main__’:
try:
pipeline = pipelines[sys.argv[1]]
except (IndexError, KeyError):
print(’invalid arguments, use `list` or `gen` to choose pipeline’)
sys.exit(1)
process = psutil.Process()
mem_before = process.memory_info().rss
tm = timeit(lambda: run_pipeline(ads_list, pipeline), number=1)
consumption = process.memory_info().rss — mem_before
print(’consumption:’, consumption/1024, ’KB, in’, round(tm, 2), ’s’)
Запустим наш скрипт сначала с ключом list:
python run_pipeline.py list
consumption: 15568.0 KB, in 0.25 s
А потом с ключом gen:
python run_pipeline.py gen
consumption: 6112.0 KB, in 0.25 s
Как можно увидеть, скорость выполнения не изменилась, но мы сэкономили память (почти трехкратная разница), потому что генераторы не создают новых списков, а обрабатывают один элемент за итерацию.
И напоследок
Не всегда операторы в python ведут себя так, как мы привыкли. Например сложение списков:
Как видно, инструкция 28 в случае `+` простое сложение, а в случае `+=` — сложение на месте, которое не приводит к созданию нового списка. += в данном случае сопоставим по производительности с list.extend:
Генераторы и итераторы помогают сэкономить память или время выполнения, но у них есть и особенности, из-за которых они могут не подойти. Например, по генераторам можно итерироваться только один раз, в отличие от списков.
Выводы
На примерах выше мы увидели, что python предоставляет нам некоторую возможность для маневра при обработке списков, главное знать об этих возможностях и использовать их там, где они подходят.