Понимание списка Python против +=

Сегодня я пытался найти метод, чтобы сделать некоторую обработку строк в Python. Более старший программист, чем я сказал, не использовать += но использовать ''.join() Я мог бы также прочитать это, например: http://wiki.python.org/moin/PythonSpeed/. Но я сам проверил это и нашел немного странные результаты (я не пытаюсь их угадать, но я хочу понять). Идея заключалась в том, если бы была строка "This is \"an example text\" содержащие пробелы "строка должна быть преобразована в Thisis"an example text"containingspaces Пробелы удаляются, но только за пределами кавычек.

Я измерил производительность двух разных версий моего алгоритма, используя ''.join(list) и один с помощью +=

import time

#uses '+=' operator
def strip_spaces ( s ):
    ret_val = ""
    quote_found = False
    for i in s:
        if i == '"':
            quote_found = not quote_found

        if i == ' ' and quote_found == True:
            ret_val += i

        if i != ' ':
            ret_val += i
    return ret_val

#uses "".join ()   
def strip_spaces_join ( s ):
    #ret_val = ""
    ret_val = []
    quote_found = False
    for i in s:
        if i == '"':
            quote_found = not quote_found

        if i == ' ' and quote_found == True:
            #ret_val = ''.join( (ret_val, i) )
            ret_val.append(i)

        if i != ' ':
            #ret_val = ''.join( (ret_val,i) )
            ret_val.append(i)
    return ''.join(ret_val)


def time_function ( function, data):
    time1 = time.time();
    function(data)
    time2 = time.time()
    print "it took about {0} seconds".format(time2-time1)

На моей машине это дало этот вывод с небольшим преимуществом для алгоритма, использующего +=

print '#using += yields ', timeit.timeit('f(string)', 'from __main__ import string, strip_spaces as f', number=1000)
print '#using \'\'.join() yields ', timeit.timeit('f(string)', 'from __main__ import string, strip_spaces_join as f', number=1000)

когда рассчитано время:

#using += yields  0.0130770206451
#using ''.join() yields  0.0108470916748

Разница действительно незначительная. Но почему ''.join() неясно выполняет функцию, которая использует +=, но, похоже, есть небольшое преимущество для версии '.join(). Я проверял это на Ubuntu 12.04 с python-2.7.3

4 ответа

Решение

Используйте правильную методологию при сравнении алгоритмов; использовать timeit модуль для устранения колебаний в загрузке процессора и подкачки.

С помощью timeit показывает, что есть небольшая разница между этими двумя подходами, но ''.join() немного быстрее:

>>> s = 1000 * string
>>> timeit.timeit('f(s)', 'from __main__ import s, strip_spaces as f', number=100)
1.3209099769592285
>>> timeit.timeit('f(s)', 'from __main__ import s, strip_spaces_join as f', number=100)
1.2893600463867188
>>> s = 10000 * string
>>> timeit.timeit('f(s)', 'from __main__ import s, strip_spaces as f', number=100)
14.545105934143066
>>> timeit.timeit('f(s)', 'from __main__ import s, strip_spaces_join as f', number=100)
14.43651008605957

Большая часть работы в ваших функциях - это циклическая обработка каждого символа и проверка кавычек и пробелов, а не сама конкатенация строк. Кроме того, ''.join() вариант делает больше работы; Сначала вы добавляете элементы в список (это заменяет += операции объединения строк), затем вы объединяете эти значения в конце, используя ''.join(), И этот метод все еще немного быстрее.

Вы можете отказаться от выполняемой работы, чтобы сравнить только часть конкатенации:

def inplace_add_concatenation(s):
    res = ''
    for c in s:
        res += c

def str_join_concatenation(s):
    ''.join(s)

который показывает:

>>> s = list(1000 * string)
>>> timeit.timeit('f(s)', 'from __main__ import s, inplace_add_concatenation as f', number=1000)
6.113742113113403
>>> timeit.timeit('f(s)', 'from __main__ import s, str_join_concatenation as f', number=1000)
0.6616439819335938

Это показывает ''.join() конкатенация по-прежнему намного быстрее, чем +=, Разница в скорости лежит в петле; s это список в обоих случаях, но ''.join() перебирает значения в C, в то время как другая версия должна делать все это в Python. И в этом вся разница.

(Это может быть много деталей, о которых ОП уже знает, но полное рассмотрение проблемы может помочь другим, кто в конечном итоге ответит на этот вопрос)

Проблема в mystring += suffix является то, что строки являются неизменяемыми, так что это на самом деле эквивалентно mystring = mystring + suffix, Таким образом, реализация должна создать новый строковый объект, скопировать все символы из mystring к нему, а затем скопировать все символы из suffix после этого. Тогда mystring имя отскок, чтобы обратиться к новой строке; исходный строковый объект, на который ссылался mystring нетронутым

Само по себе это на самом деле не проблема. Любой метод объединения этих двух строк должен сделать это, в том числе ''.join([mystring, suffix]); на самом деле это еще хуже, потому что он должен сначала создать объект списка, а затем выполнить итерацию по нему, и в то время как нет фактической передачи данных при объединении пустой строки между mystring а также suffix это займет по крайней мере одну инструкцию, чтобы разобраться.

куда += становится проблемой, когда вы делаете это неоднократно. Что-то вроде этого:

mystring = ''
for c in 'abcdefg' * 1000000:
    mystring += c

Помни что mystring += c эквивалентно mystring = mystring + c, Таким образом, на первой итерации цикла он оценивает '' + 'a' копирование всего 1 символа Далее это делает 'a' + 'b' копирование всего 2 символа. затем 'ab' + 'c' для 3 символов, затем 'abc' + 'd' на 4, и я думаю, вы можете видеть, куда это идет. Каждый последующий += повторяет всю работу предыдущей, а затем копирует новую строку. Это становится чрезвычайно расточительным.

''.join(...) Это лучше, потому что там вы ждете, пока не узнаете все строки, чтобы скопировать любую из них, а затем копируете каждую из них прямо на правильное место в конечном строковом объекте. Вопреки тому, что было сказано в некоторых комментариях и ответах, это сохраняется, даже если вам нужно изменить цикл, чтобы добавить строки в список строк, а затем join их после петли. Списки не являются неизменяемыми, поэтому добавление в список изменяет его на месте, и ему также нужно только добавить одну ссылку, а не копировать все символы в строке. Делать тысячи добавлений в список намного быстрее, чем тысячи строк += операции.

Повторная строка += теоретически проблема даже без циклов, если вы просто напишите свой исходный код примерно так:

s = 'foo'
s += 'bar'
s += 'baz'
...

Но на практике вы вряд ли будете писать достаточно длинную последовательность кода, подобную той, что написана от руки, если только строки не очень большие. Так что просто следите за += в циклах (или рекурсивных функциях).


Причина, по которой вы можете не увидеть этот результат, когда пытаетесь рассчитать время, заключается в том, что на самом деле есть оптимизация для строки += в интерпретаторе CPython. Давайте вернемся к моему глупому примеру цикла:

mystring = ''
for c in 'abcdefg' * 1000000:
    mystring += c

Каждый раз, когда это делает mystring = mystring + c старая ценность mystring становится мусором и удаляется, а имя mystring заканчивается ссылкой на вновь созданную строку, которая начинается именно с содержимого старого объекта. Мы могли бы оптимизировать это, признав, что mystring вот-вот станет мусором, поэтому мы можем делать с ним все, что захотим, без посторонней помощи. Таким образом, даже если строки неизменны на уровне Python, на уровне реализации мы сделаем их динамически расширяемыми и реализуем target += source либо путем обычного выделения новой строки и метода копирования, либо путем расширения целевой строки и копирования только исходных символов, в зависимости от того, target будет сделан мусор.

Проблема этой оптимизации в том, что ее легко нарушить. Он прекрасно работает на небольших автономных циклах (которые проще всего преобразовать в использование join, Кстати). Но если вы делаете что-то более сложное и случайно сталкиваетесь с несколькими ссылками на строки, вдруг код может работать намного медленнее.

Скажем, у вас есть некоторые журналирующие вызовы в цикле, и система журналирования на некоторое время буферизует свои сообщения, чтобы распечатать их все сразу (должно быть безопасно; строки неизменяемы). Ссылки на ваши строки в системе регистрации могут остановить += оптимизация применима.

Скажем, вы написали свой цикл как рекурсивную функцию (что Python на самом деле не очень нравится, но все же), создавая строку с += по какой-то причине. Внешние кадры стека будут по-прежнему иметь ссылки на старые значения.

Или, может быть, то, что вы делаете со строками, генерирует серию объектов, поэтому вы передаете их классу; если класс хранит строку непосредственно в экземпляре, оптимизация прекращается, но если класс сначала манипулирует ими, тогда оптимизация все еще работает.

По сути, производительность того, что выглядит как действительно простая примитивная операция, либо нормальная, либо очень плохая, и зависит от другого кода, отличного от кода, использующего +=, В крайних случаях вы можете внести изменения в совершенно отдельный файл (может быть, даже в пакет стороннего производителя), что приведет к значительному снижению производительности в одном из ваших модулей, который не менялся долгое время!

Кроме того, я понимаю, что += оптимизацию легко реализовать на CPython, потому что она использует подсчет ссылок; вы можете легко определить, когда целевая строка является мусором, посмотрев на его счетчик ссылок, тогда как с более сложной сборкой мусора вы не сможете определить, пока не удалите ссылку и не дождетесь запуска сборщика мусора; слишком поздно, чтобы принять решение о том, как реализовать +=, Итак, опять же, действительно простой базовый код, который не должен иметь проблем с переносимостью между реализациями Python, может неожиданно запускаться слишком медленно, чтобы быть полезным, когда вы перемещаете его в другую реализацию.


И вот несколько сравнительных тестов, чтобы показать масштаб проблемы:

import timeit

def plus_equals(data):
    s = ''
    for c in data:
        s += c

def simple_join(data):
    s = ''.join(data)

def append_join(data):
    l = []
    for c in data:
        l.append(c)
    s = ''.join(l)

def plus_equals_non_garbage(data):
    s = ''
    for c in data:
        dummy = s
        s += c

def plus_equals_maybe_non_garbage(data):
    s = ''
    for i, c in enumerate(data):
        if i % 1000 == 0:
            dummy = s
        s += c

def plus_equals_enumerate(data):
    s = ''
    for i, c in enumerate(data):
        if i % 1000 == -1:
            dummy = s
        s += c

data = ['abcdefg'] * 1000000

for f in (
    plus_equals,
    simple_join,
    append_join,
    plus_equals_non_garbage,
    plus_equals_maybe_non_garbage,
    plus_equals_enumerate,
  ):
    print '{:30}{:20.15f}'.format(f.__name__, timeit.timeit(
        'm.{0.__name__}(m.data)'.format(f),
        setup='import __main__ as m',
        number=1
    ))

На моей системе это печатает:

plus_equals                      0.066924095153809
simple_join                      0.013648986816406
append_join                      0.086287975311279
plus_equals_non_garbage        540.663727998733521
plus_equals_maybe_non_garbage    0.731688976287842
plus_equals_enumerate            0.156824111938477

Оптимизация += работает очень хорошо, когда работает (даже избивая тупого append_join версия немного). Мои цифры показывают, что вы можете оптимизировать код, заменив append + join с += в некоторых случаях, но выгода не стоит того, чтобы какое-то другое будущее изменение случайно получило выброс (и, скорее всего, оно было бы невероятно маленьким, если бы в цикле была какая-то другая фактическая работа; а если нет, то вы должны использовать что-то вроде simple_join версия).

Сравнивая plus_equals_maybe_non_garbage в plus_equals_enumerate Вы можете видеть, что даже если оптимизация не удалась только один на каждую тысячу += операций, все еще есть 5-кратная потеря производительности.

Оптимизация += на самом деле предназначен только для того, чтобы спасти людей, которые не являются опытными программистами на Python или просто быстро и лениво пишут какой-то рабочий код. Если вы думаете о том, что вы делаете, вы должны использовать join,


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

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

Например:

def strip_spaces_gen(s):
    quote_found = False
    for i in s:
        if i == '"':
            quote_found = not quote_found
        if i == ' ' and quote_found == True:
            # Note: you (c|sh)ould drop the == True, but I'll leave it here so as to not give an unfair advantage over the other functions
            yield i
        if i != ' ':
            yield i

def strip_spaces_join_gen(ing):
     return ''.join(strip_spaces_gen(ing))

Это выглядит примерно так же (как объединение) для более короткой строки:

In [20]: s = "This is \"an example text\" containing spaces"

In [21]: %timeit strip_spaces_join_gen(s)
10000 loops, best of 3: 22 us per loop

In [22]: %timeit strip_spaces(s)
100000 loops, best of 3: 13.8 us per loop

In [23]: %timeit strip_spaces_join(s)
10000 loops, best of 3: 23.1 us per loop

Но быстрее для больших строк.

In [24]: s = s * 1000

In [25]: %timeit strip_spaces_join_gen(s)
100 loops, best of 3: 12.9 ms per loop

In [26]: %timeit strip_spaces(s)
100 loops, best of 3: 17.1 ms per loop

In [27]: %timeit strip_spaces_join(s)
100 loops, best of 3: 17.5 ms per loop

Разница в производительности между += а также .join зависит от множества факторов:

  1. Операционная система Выполнение этого для все более крупных строк в Unix-подобных системах или системах Windows может дать совершенно разные результаты. Как правило, вы увидите гораздо большее увеличение времени выполнения под Windows.

  2. Реализация Python. По умолчанию мы говорим о CPython, но есть и другие реализации, такие как Jython или PyPy. Давайте посмотрим на PyPy. Используя исходный код из ответа выше:

    CPython 2.7:
    python concat.py 
    inplace_add_concatenation: 0.420897960663
    str_join_concatenation:    0.061793088913
    ratio: 6.81140833169
    
    PyPy 1.9:
    pypy concat.py 
    inplace_add_concatenation: 1.26573014259
    str_join_concatenation:    0.0392870903015
    ratio: 32.2174570038
    

Хотя PyPy славится своими ускорениями по сравнению с CPython, он медленнее += версия. Это было намеренное решение, не включающее оптимизацию `+='в версии PyPy по умолчанию.

Практическое правило, касающееся производительности: "Никогда не угадай, всегда измеряй".

Также чтение документов помогает:

6 Подробности реализации CPython: если s и t являются обеими строками, некоторые реализации Python, такие как CPython, обычно могут выполнять оптимизацию на месте для назначений вида s = s + t или s += t. Когда это применимо, эта оптимизация делает квадратичное время выполнения гораздо менее вероятным. Эта оптимизация зависит как от версии, так и от реализации. Для чувствительного к производительности кода предпочтительно использовать метод str.join(), который обеспечивает согласованную производительность линейной конкатенации между версиями и реализациями."""

от http://docs.python.org/2/library/stdtypes.html

Другие вопросы по тегам