В процессе написания очередной программы задумался над тем, какой способ создания списков в 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)


  1. AmdY
    16.06.2022 00:41
    +7

    Для большинства языков свойственно - вызов функции медленнее чем вызов конструкции языка. Но с такими вещами по хорошему должен оптимизатор справляться.


    1. hedger
      16.06.2022 02:43
      +1

      В данном случае происходит ровно то, что и должно - вызов функции. И оптимизировать его будет некорректно, потому что никто не мешает написать list = tuple, например. Или свой супер-конструктор объекта, мимикрирующего под список. И интерпретатор байт-кода должен будет выполнить именно его, для чего внутри и нужно сделать честный вызов по имени.


      1. vldF
        17.06.2022 00:48

        Во всех нормальных компиляторах эта задача решается. Например, можно посмотреть на полное имя этой функции, даже если вы напишите такую же функцию с таким же именем, ее полное имя будет другим. Не уверен, как лучше реализовать этот механизм в пайтоне, но в jvm-based языка это работает

        Насчёт того, почему это не делает оптимизатор – я не знаком с архитектурой парсера Пайтона (но, кстати, предполагаю, что поведение может отличаться в разных его реализациях, по этому, было бы полезно указать тут конкретную реализацию и, возможно, сравнить ее с другими), но, наверное, оптимизация может оказаться слишком дорогой по времени (хотя сложно в это верится)


        1. andreymal
          17.06.2022 15:54
          +3

          как лучше реализовать этот механизм в пайтоне

          В пайтоне это реализовать невозможно. Или у вас есть идеи, как в компиляторе проанализировать, например,


          такое?
          def happy_debugging_losers():
              import base64
              with open('tmp.txt', 'w') as wfp:
                  wfp.write('b64decodeX19idWlsdGluc19fLmxpc3Q9dHVwbGUK')
              with open('tmp.txt', 'r') as rfp:
                  n = rfp.read(9)
                  exec(getattr(base64, n)(rfp.read()))
          
          print(type(list()))  # <class 'list'>
          happy_debugging_losers()
          print(type(list()))  # <class 'tuple'>


  1. SergeiMinaev
    16.06.2022 02:36

    list() - ладно. Больше печалит необходимость в диктах заключать ключи в кавычки. У {} vs dict() такие же отличия в производительности, но когда надо просто создать большой словарь, обычно юзаю dict().


    1. hedger
      16.06.2022 02:53

      Интересно бы взглянуть на байт-код для dict и {}. Но наверняка там такая же история и dict вызывается как функция с kwarg, которые не требуют кавычек по своей природе.

      При создании через {} ключом может быть и отличный от строки тип, поэтому автоматически конвертировать в неё нельзя.


      1. sdore
        16.06.2022 23:22

        Важнее тут не столько константы различных типов, сколько возможность указать в качестве ключа переменную:

        {k+1: v*2 for k, v in d.items()}


    1. dmitrysvd
      16.06.2022 12:35
      +1

      печалит необходимость в диктах заключать ключи в кавычки

      Как тогда отличить переменную от значения ключа?


      1. SergeiMinaev
        16.06.2022 16:03

        Как в JS: mydict.key или {key: val} - всегда строка, а название ключа надо из переменной брать, то mydict[var].


        1. sdore
          16.06.2022 23:23

          И делать тридцать миллионов императивных присвоений вместо краткого лаконичного {}?


          1. SergeiMinaev
            16.06.2022 23:43

            Немного не понял про тридцать миллионов. Можно пример?


          1. green_fenix
            17.06.2022 10:27

            В js все же можно использовать переменные в объявлении объекта - надо обернуть переменную в []. Т.е. {[foo]: 'bar'}


        1. VolDr
          17.06.2022 10:29

          Но в питоне ключ не всегда строка.


          1. SergeiMinaev
            17.06.2022 15:44

            Я в конце предложения привёл пример с переменной - `mydict[keyname]`.


  1. domix32
    16.06.2022 05:23
    +1

    Так там же вроде издавна у [] обычные динамические массивы были, которые время только на реалокации тратили, а list был структурой более нетривиальной, который мог в некоторых случаях быть оптимизирован до такого же массива, типа того же случая со статичными данными, но в общем случае проигрывал на накладных расходах.


    1. sdore
      16.06.2022 23:24
      +1

      type([]) is list


  1. lxsmkv
    16.06.2022 06:38
    -1

    Это как-то не соотносится с Дзеном питона (aka PEP 20)

    There should be one-- and preferably only one --obvious way to do it.


    1. igor6130
      16.06.2022 07:40
      +4

      А что скажете про несколько способов форматирования строк? :)


      1. shpaker
        16.06.2022 10:01

        Интересно, а сколько их всего? Я вот так сходу пяток насчитал.

        • проценты 'foo %s' % 'bar'

        • тупо в лоб вот так 'foo' + 'bar'

        • метод формат 'foo {}'.format('bar')

        • f-строки f'foo {bar!r}'

        • шаблоны string.Template


      1. lxsmkv
        17.06.2022 03:56

        Да не, я все понимаю. Мой комментарий скорее ирония. Ведь часто начинают дизайн любой системы со стремлением к идеалу, а потом все эти представления ломаются о реальнось.
        Чего уж говорить, когда у языка даже есть старая и новая версия. Это просто об колено :)

        В конце концов любим его таким какой он есть, не вполне идеальным, но очень удобным и, по большому счету, доступным. Ведь все синтаксические примочки типа лямбд, распаковки массивов, генераторы списков, цикл for-else, можно использовать, а можно писать по старинке.

        P.S: Мне тут пару месяцев пришлось ковыряться с JS, так Python после этого как глоток свежего воздуха.


    1. Myxach
      16.06.2022 08:10
      -1

      and preferably only one

      И я думаю говорится про оптимальный вариант. Присвоевание может быть и такое
      a = 2
      b = 0

      while b!=a:
      b = random.randint(-100,100);

      тоже очевидный, но...


    1. shpaker
      16.06.2022 09:52
      +5

      Мне кажется, что этот постулат потерял актуальность практически сразу.


  1. Deterok
    16.06.2022 07:59
    +2

    Это конечно хорошо, но если мне нужно будет от python скорость выполнения я предпочту использовать модуль написаный на другом ЯП или даже просто другой ЯП. Мне кажется скорость выполнения которая грядет в 3.11 приятный декор, а не фича языка. В любом случае спасибо за статью.


  1. 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.


  1. amarao
    16.06.2022 12:22
    +19

    Любая статья "что быстрее в питоне" вызывает недоумение, потому что и так медленно, и так медленно, и если кого-то начинает волновать скорость создания списка в питоне, то этот человек ошибся языком программирования. Потому что в быстрых языках программирования константный массив создаётся за 0 наносекунд. Почему? Потому что он уже в .data или .rodata, готовый.


  1. AlexeyUral
    16.06.2022 13:09

    Бегло пролистал, питон только учу, а на какой версии питона данные?


  1. sheknitrtch
    16.06.2022 23:48
    +1

    Подпишите, пожалуйста, оси на графике. Не понятно что означают числа по оси Y: чем больше - тем быстрее или наоборот?


    1. dzhoker1 Автор
      17.06.2022 15:36
      +1

      Ось Y - время в секундах. Чем больше времени, чем выше график, тем больше времени нужно, тем дольше работает код, т.е. медленнее.


  1. Liroluka
    17.06.2022 10:28

    Напомнило:

    - Я использую С потому что он быстрее всех

    - Но ведь твой проект будет работать аналогично и на других языках

    - ЗАТКНИСЬ, ЗАТКНИСЬ

    Но пост хорош, читать интересно))