Python列表推导式与+=操作的区别

3
今天我在尝试找到一种方法,在Python中对字符串进行某些处理。比我资深的程序员告诉我不要使用+=而是使用''.join(),我也可以在这里阅读到这个建议:http://wiki.python.org/moin/PythonSpeed/#Use_the_best_algorithms_and_fastest_tools。但是我自己测试后发现了一些奇怪的结果(并非我试图置疑他们,而是我想理解)。我的想法是,如果有一个包含空格的字符串"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)

使用timeit计时:

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

两种方式的差别非常小。但是为什么''.join()方法没有明显地优于使用+=的方法,但是 ''.join() 的版本似乎有一些小优势。 我在 Ubuntu 12.04 中使用 python-2.7.3 进行了测试。


6
测试代码运行时间时,使用timeit模块。 - Martijn Pieters
1
对于大于约400个字符的字符串,使用带有join的列表会更快。 - Blender
2
你在这里并没有真正比较相同的东西。你正在比较多个 += 和多个 append,然后跟随一个单独的 ''.join() - Jeff Tratner
@hetepeperfan:对于每个字符串,您只执行了一次试验。这远远不足以得出结论。 - Blender
2
我觉得我应该提一下:这不仅仅是关于性能的问题。可读性也很重要(以及能够调试和推理代码),在这里.join总是胜出的。 - Andy Hayden
显示剩余5条评论
4个回答

8

在比较算法时,请使用正确的方法; 使用timeit模块以消除CPU利用率和交换中的波动。

使用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中完成所有循环。这就是区别所在。


刚才有另一个答案提倡使用+=,那么这是否也适用于您的回答中的inplace_add_concatenation呢?因为在那种情况下,我肯定(仍然很幼稚)会写成+=而不是迭代所有单个字符而不是''.join()。 - hetepeperfan
我不确定我理解你的意思。 - Martijn Pieters
你写了以下代码: def inplace_add_concatenation(s): res = '' for c in s: res += c 但是在这里,你遍历了字符串中的所有字符,而不是使用res += s,它不需要遍历,但我会收到消息。那么,可以将代码改为以下形式: res += c - hetepeperfan
此外,+=非常容易出现打错字和/或令人困惑的代码... 发抖 - Andy Hayden
@hetepeperfan 我认为运算符重载非常有用,而且你可以不用“+=”惯用法来使用它。虽然我同意这是一个品味问题... - Andy Hayden
显示剩余2条评论

3
另一种选择是编写一个使用生成器连接字符串的函数,而不是每次将字符串添加到列表中。
例如:
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

boolean_name == True 是什么意思?去掉 == True 可以提高性能,因为你不需要测试显而易见的内容。 - Martijn Pieters
@MartijnPieters 复制粘贴 OP 的源代码是我的借口 :) (我认为可能还有其他一些收获,但是认为扭曲该代码部分是不公平的,因为我们真正测试的是生成器/连接器部分) - Andy Hayden
是的,我同意还有其他更有效率的方法。而且我承认对于 == True 部分有点不公平,因为我最初看到 OP 犯了这个错误。 :-) - Martijn Pieters
1
生成器函数之所以对于较长的字符串更快,是因为此时您开始击败list.append()调用,后者必须逐个元素地增加列表。在内部,''.join()仍然需要构建一个列表,但可以使用C语言来完成。 - Martijn Pieters

3

这可能是问题的细节,但全面了解问题可以帮助其他遇到此问题的人。

mystring += suffix 中的问题是字符串是不可变的,所以实际上等同于 mystring = mystring + suffix。因此,实现必须创建一个新的字符串对象,将 mystring 中的所有字符复制到它中,然后复制 suffix 中的所有字符。然后,mystring 名称被重新绑定以引用新字符串;原始字符串对象仍然保持不变。

单独看这个问题实际上并不是问题。任何连接这两个字符串的方法都必须这样做,包括 ''.join([mystring, suffix]);这实际上更糟糕,因为它必须首先构建一个列表对象,然后迭代该列表,并且虽然在将空字符串插入 mystringsuffix 之间时没有实际的数据传输,但至少需要一个指令来处理。

+= 变成问题的地方是当你重复使用它时。像这样:

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上易于实现,因为它利用了引用计数; 您可以通过查看其引用计数轻松地确定目标字符串何时成为垃圾,而使用更复杂的垃圾收集,则只能在删除引用并等待垃圾收集器运行后才能确定;远远太晚来决定如何实现 += 。 因此,看起来非常简单的基本代码,应该没有任何可移植性问题,但当您将其移动到另一个实现时,它可能突然运行得太慢而无法使用。


以下是一些基准测试结果以显示问题的规模:

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_garbageplus_equals_enumerate进行比较,即使优化只在每千次+=操作中失败一次,也会导致性能损失增加5倍。

+=的优化实际上只是为了拯救那些没有经验的Python程序员或者只是快速懒惰地编写一些草稿代码的人。如果你思考自己在做什么,应该使用join


总结:对于固定数量的连接,使用+=是可以的。但是对于使用循环构建字符串,join总是更好的选择。实际上,由于+=的优化,从+=转换到join可能不会带来巨大的改进。无论如何,你仍然应该使用join,因为这种优化并不可靠,当它失败时,差异可能是巨大的。


谢谢您提供的详细答案 :) 我同意我在许多情况下使用了 +=,因为我根本不知道 ''.join()。我使用 += 是因为我有其他面向对象语言(例如C ++)的一些经验。尽管我知道在C ++中也可能出现相同的问题。 - hetepeperfan

1
< p > < code > += 和 < code > .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#typesseq

1
+1 for "永远不要猜测,总是要度量。" 在性能关键的组件中,这一原则至关重要。 - Nisan.H

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接