В процессе написания очередной программы задумался над тем, какой способ создания списков в Python работает быстрее. Большинство моих знакомых используют квадратные скобки. А некоторые совсем забыли о существовании функции list(). Предлагаю Вашему вниманию небольшое исследование. Узнаем правы ли коллеги. А заодно на примере простой задачи посмотрим как можно проводить свои собственные исследования.
Для работы нам понадобятся встроенные в стандартную библиотеку модули dis
и timeit
, а так же matplotlib
. Его необходимо установить отдельно командой pip install matplotlib
. Впрочем, мы задействуем сей шедевр графикопостроения на минималках. Для графиков вполне можно обойтись электронной таблицей.
Полный код исследования доступен по ссылке https://gitlab.com/dzhoker1/function-list-or-square-brackets
Наивный подход
Начнём с создания пустых списков.
data_one = []
data_two = list()
Используя модуль dis, который занимается дизассемблированием байтового кода Python в мнемонику, можно заглянуть во внутреннюю механику создания списков. В первом случае получаем:
1 0 BUILD_LIST 0
2 STORE_NAME 0 (data_one)
4 LOAD_CONST 0 (None)
6 RETURN_VALUE
Функция list() отработает чуть дольше:
1 0 LOAD_NAME 0 (list)
2 CALL_FUNCTION 0
4 STORE_NAME 1 (data_two)
6 LOAD_CONST 0 (None)
8 RETURN_VALUE
Итак, есть первая гипотеза. Квадратные скобки не просто так стали привычкой. Они и работают быстрее. Нужен всего лишь создать список BUILD_LIST, вместо загрузки функции LOAD_NAME и её вызова CALL_FUNCTION. Но давайте продолжим исследование.
А что, если список создавать не пустым, а с данными внутри? Проверим.
Для строки кода dis('data_one = [1, 2, 3]')
мы получим следующий вывод:
1 0 BUILD_LIST 0
2 LOAD_CONST 0 ((1, 2, 3))
4 LIST_EXTEND 1
6 STORE_NAME 0 (data_one)
8 LOAD_CONST 1 (None)
10 RETURN_VALUE
Что касается list(), строка dis('data_two = list((1, 2, 3))')
вернёт нам:
1 0 LOAD_NAME 0 (list)
2 LOAD_CONST 0 ((1, 2, 3))
4 CALL_FUNCTION 1
6 STORE_NAME 1 (data_two)
8 LOAD_CONST 1 (None)
10 RETURN_VALUE
Неужели ничья? А что, если построить немного графиков? Для каждого из 2-х вариантов сделаем замеры через timeit, несколько раз изменяя размер создаваемого списка.
y = (2, 4, 8, 12, 16)
x1 = [timeit('data_one = [1, 2]'),
timeit('data_one = [1, 2, 3, 4]'),
timeit('data_one = [1, 2, 3, 4, 5, 6, 7, 8]'),
timeit('data_one = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]'),
timeit('data_one = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]')]
x2 = [timeit('data_two = list((1, 2))'),
timeit('data_two = list((1, 2, 3, 4))'),
timeit('data_two = list((1, 2, 3, 4, 5, 6, 7, 8))'),
timeit('data_two = list((1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12))'),
timeit('data_two = list((1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16))')]
plt.plot(y, x1, y, x2)
plt.legend(('[]', 'list()'))
plt.show()
На выходе любопытный график и мысль о том, что делаю что-то не так.
Да, квадратные скобки оказались быстрее функции. Но кто же в здравом уме формирует список вручную? Для этого есть куда более удобные способы. А значит переходим на второй виток исследований.
Функция range() для создания списков
Теперь у нас будет три претендента на самый быстрый способ создания списка.
N = 10
data_one = [i for i in range(N)]
data_two = list(range(N))
data_three = [*range(N)]
print(data_one == data_two == data_three)
Нижняя строка кода нужна, чтобы убедится в одинаковом результате. Ведь тестировать решения, которые дают различные ответы изначально неверно.
Начнём с дизассемблирования наших претендентов. list comprehension вернул огромную портянку мнемоники
1 0 LOAD_CONST 0 (<code object <listcomp> at 0x000001EF50E4D2F0, file "<dis>", line 1>)
2 LOAD_CONST 1 ('<listcomp>')
4 MAKE_FUNCTION 0
6 LOAD_NAME 0 (range)
8 LOAD_NAME 1 (N)
10 CALL_FUNCTION 1
12 GET_ITER
14 CALL_FUNCTION 1
16 STORE_NAME 2 (data_one)
18 LOAD_CONST 2 (None)
20 RETURN_VALUE
Disassembly of <code object <listcomp> at 0x000001EF50E4D2F0, file "<dis>", line 1>:
1 0 BUILD_LIST 0
2 LOAD_FAST 0 (.0)
>> 4 FOR_ITER 8 (to 14)
6 STORE_FAST 1 (i)
8 LOAD_FAST 1 (i)
10 LIST_APPEND 2
12 JUMP_ABSOLUTE 4
>> 14 RETURN_VALUE
Обратите внимание на цикл (пять последних строчек вывода начиная с >> 4 FOR_ITER
). Эти действия будут повторяться многократно, а следовательно повлияют на суммарное быстродействие. Да и в целом мнемоника выглядит серьёзно и страшно.
Посмотрим на проигравшую в первом раунде функцию list().
1 0 LOAD_NAME 0 (list)
2 LOAD_NAME 1 (range)
4 LOAD_NAME 2 (N)
6 CALL_FUNCTION 1
8 CALL_FUNCTION 1
10 STORE_NAME 3 (data_two)
12 LOAD_CONST 0 (None)
14 RETURN_VALUE
Ого! Так просто? Собрали имена функций и переменных, пара звонков (вызовов функций) и список готов.
И остался вариант 3, самый короткий по количеству нажатий на клавиатуру.
1 0 BUILD_LIST 0
2 LOAD_NAME 0 (range)
4 LOAD_NAME 1 (N)
6 CALL_FUNCTION 1
8 LIST_EXTEND 1
10 STORE_NAME 2 (data_three)
12 LOAD_CONST 0 (None)
14 RETURN_VALUE
Как мы видим, квадратные скобки и тут оказались компактными и лаконичными.
Время устроить серию замеров времени (масло масляное рулит). Построим график для списков от 50 до 1000 c шагом 50. А для достоверности каждый эксперимент проведём 10_000 раз.
Генераторное выражение выбывает из гонки, заняв третье место. Коллеги "однострочники", вы проиграли. Впрочем, результаты дизассемблирования намекали на слабое звено с самого начала.
Функция range(). Часть 2
Устраиваем повторный забег, чтобы узнать кто же быстрее, list()
или []
. 50 экспериментов (создаём список от 100 до 5000 с шагом 100), проведённые по 100_000 раз.
И у нас ничья. Оба подхода оказались одинаковыми по быстродействию. Но, что если всё дело в функции range()?
Финальный раунд со случайными числами
Чтобы исключить влияние функции range(), проведём финальную серию замеров. Создадим генератор, заполненный случайными числами в указанном нами диапазоне. А потом превратим его в список при помощи list() и [].
N = 10
MIN = -100_000
MAX = 10_000_000
test_data_one = (random.randint(MIN, MAX) for _ in range(N))
test_data_two = (random.randint(MIN, MAX) for _ in range(N))
data_one = [*test_data_one]
data_two = list(test_data_two)
В последний раз воспользуемся модулем dis
. Квадратные скобки вернут:
1 0 BUILD_LIST 0
2 LOAD_NAME 0 (test_data_one)
4 LIST_EXTEND 1
6 STORE_NAME 1 (data_one)
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
А функция list() не планирует отставать:
1 0 LOAD_NAME 0 (list)
2 LOAD_NAME 1 (test_data_two)
4 CALL_FUNCTION 1
6 STORE_NAME 2 (data_two)
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
В таком случае построим финальный график для вынесения вердикта. 200 экспериментов от 100 до 20_000 с шагом 100. Каждый эксперимент проведён по 1 млн. раз.
Похоже на пульс программиста в ожидании результата. И результаты говорят о том, что функция list()
проигрывает []
при создании списка из генератора.
Вывод: при работе со списками использование квадратных скобок не только экономит нажатие клавиш на клавиатуре, но зачастую показывает лучшие результаты быстродействия. Впрочем, это всего лишь миллионные доли секунды. Для простых задач разницу сложно заметить. Но если привыкли работать со списком через скобки, мысль об экономии тактов процессора будет греть вам душу.
UPD: Исследования проводились на Python 3.9.10
Комментарии (29)
SergeiMinaev
16.06.2022 02:36list() - ладно. Больше печалит необходимость в диктах заключать ключи в кавычки. У {} vs dict() такие же отличия в производительности, но когда надо просто создать большой словарь, обычно юзаю dict().
hedger
16.06.2022 02:53Интересно бы взглянуть на байт-код для dict и {}. Но наверняка там такая же история и dict вызывается как функция с kwarg, которые не требуют кавычек по своей природе.
При создании через {} ключом может быть и отличный от строки тип, поэтому автоматически конвертировать в неё нельзя.
sdore
16.06.2022 23:22Важнее тут не столько константы различных типов, сколько возможность указать в качестве ключа переменную:
{k+1: v*2 for k, v in d.items()}
dmitrysvd
16.06.2022 12:35+1печалит необходимость в диктах заключать ключи в кавычки
Как тогда отличить переменную от значения ключа?
SergeiMinaev
16.06.2022 16:03Как в JS: mydict.key или {key: val} - всегда строка, а название ключа надо из переменной брать, то mydict[var].
sdore
16.06.2022 23:23И делать тридцать миллионов императивных присвоений вместо краткого лаконичного
{}
?green_fenix
17.06.2022 10:27В js все же можно использовать переменные в объявлении объекта - надо обернуть переменную в []. Т.е.
{[foo]: 'bar'}
domix32
16.06.2022 05:23+1Так там же вроде издавна у [] обычные динамические массивы были, которые время только на реалокации тратили, а list был структурой более нетривиальной, который мог в некоторых случаях быть оптимизирован до такого же массива, типа того же случая со статичными данными, но в общем случае проигрывал на накладных расходах.
lxsmkv
16.06.2022 06:38-1Это как-то не соотносится с Дзеном питона (aka PEP 20)
There should be one-- and preferably only one --obvious way to do it.
igor6130
16.06.2022 07:40+4А что скажете про несколько способов форматирования строк? :)
shpaker
16.06.2022 10:01Интересно, а сколько их всего? Я вот так сходу пяток насчитал.
проценты
'foo %s' % 'bar'
тупо в лоб вот так
'foo' + 'bar'
метод формат
'foo {}'.format('bar')
f-строки
f'foo {bar!r}'
шаблоны
string.Template
lxsmkv
17.06.2022 03:56Да не, я все понимаю. Мой комментарий скорее ирония. Ведь часто начинают дизайн любой системы со стремлением к идеалу, а потом все эти представления ломаются о реальнось.
Чего уж говорить, когда у языка даже есть старая и новая версия. Это просто об колено :)В конце концов любим его таким какой он есть, не вполне идеальным, но очень удобным и, по большому счету, доступным. Ведь все синтаксические примочки типа лямбд, распаковки массивов, генераторы списков, цикл for-else, можно использовать, а можно писать по старинке.
P.S: Мне тут пару месяцев пришлось ковыряться с JS, так Python после этого как глоток свежего воздуха.
Myxach
16.06.2022 08:10-1and preferably only one
И я думаю говорится про оптимальный вариант. Присвоевание может быть и такое
a = 2
b = 0while b!=a:
b = random.randint(-100,100);тоже очевидный, но...
Deterok
16.06.2022 07:59+2Это конечно хорошо, но если мне нужно будет от python скорость выполнения я предпочту использовать модуль написаный на другом ЯП или даже просто другой ЯП. Мне кажется скорость выполнения которая грядет в 3.11 приятный декор, а не фича языка. В любом случае спасибо за статью.
shpaker
16.06.2022 09:50Тот случай когда использую для создания списков
[ ]
тупо потому что наlist()
ругается pylint вот такимR1734: Consider using [] instead of list() (use-list-literal)
.upd: сейчас увидел, что в доке пайлинта как раз написано про скорость исполнения:
Emitted when using list() to create an empty list instead of the literal []. The literal is faster as it avoids an additional function call.
amarao
16.06.2022 12:22+19Любая статья "что быстрее в питоне" вызывает недоумение, потому что и так медленно, и так медленно, и если кого-то начинает волновать скорость создания списка в питоне, то этот человек ошибся языком программирования. Потому что в быстрых языках программирования константный массив создаётся за 0 наносекунд. Почему? Потому что он уже в .data или .rodata, готовый.
sheknitrtch
16.06.2022 23:48+1Подпишите, пожалуйста, оси на графике. Не понятно что означают числа по оси Y: чем больше - тем быстрее или наоборот?
dzhoker1 Автор
17.06.2022 15:36+1Ось Y - время в секундах. Чем больше времени, чем выше график, тем больше времени нужно, тем дольше работает код, т.е. медленнее.
Liroluka
17.06.2022 10:28Напомнило:
- Я использую С потому что он быстрее всех
- Но ведь твой проект будет работать аналогично и на других языках
- ЗАТКНИСЬ, ЗАТКНИСЬ
Но пост хорош, читать интересно))
AmdY
Для большинства языков свойственно - вызов функции медленнее чем вызов конструкции языка. Но с такими вещами по хорошему должен оптимизатор справляться.
hedger
В данном случае происходит ровно то, что и должно - вызов функции. И оптимизировать его будет некорректно, потому что никто не мешает написать list = tuple, например. Или свой супер-конструктор объекта, мимикрирующего под список. И интерпретатор байт-кода должен будет выполнить именно его, для чего внутри и нужно сделать честный вызов по имени.
vldF
Во всех нормальных компиляторах эта задача решается. Например, можно посмотреть на полное имя этой функции, даже если вы напишите такую же функцию с таким же именем, ее полное имя будет другим. Не уверен, как лучше реализовать этот механизм в пайтоне, но в jvm-based языка это работает
Насчёт того, почему это не делает оптимизатор – я не знаком с архитектурой парсера Пайтона (но, кстати, предполагаю, что поведение может отличаться в разных его реализациях, по этому, было бы полезно указать тут конкретную реализацию и, возможно, сравнить ее с другими), но, наверное, оптимизация может оказаться слишком дорогой по времени (хотя сложно в это верится)
andreymal
В пайтоне это реализовать невозможно. Или у вас есть идеи, как в компиляторе проанализировать, например,