【问题标题】:python list comprehension vs +=python 列表理解 vs +=
【发布时间】:2013-05-27 21:57:09
【问题描述】:

今天我试图找到一种方法,在 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 对此进行了测试

【问题讨论】:

  • 在测试时序时,使用timeit 模块。
  • 对于大于约 400 个字符的字符串,使用带有join 的列表更快。
  • @Blender 字符串的长度最终为 43000000,还是我误解了你?
  • 您在这里也没有真正比较相同的东西。您正在比较多个 += 与多个 appends,然后是单个 ''.join()
  • 我觉得我应该提一下:这不仅仅与性能有关。可读性也很重要(以及能够调试和推理代码),.join 每次都在这里获胜。

标签: python performance list-comprehension augmented-assignment


【解决方案1】:

在比较算法时使用正确的方法;使用timeit module 消除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() 连接仍然比+= 快很多heck。速度差在于回路; s 在这两种情况下都是一个列表,但 ''.join() 循环遍历 C 中的值,而另一个版本必须完成它在 Python 中循环的所有工作。这让这里变得与众不同。

【讨论】:

  • 只有另一个答案提升了+= 这是否也适用于您的答案中的 implace_add_concatenation,因为在这种情况下,我肯定会(仍然天真)编写 += 而不是遍历所有单字符而不是 ''.join()
  • 我不确定我是否关注你。
  • 你已经写了 def inplace_add_concatenation(s): res = '' for c in s: res += c 但是在这里你迭代字符串中的所有字符而不是做res += s which doesn't' t迭代,但无论如何我都会收到消息。比 res += c
  • @hetepeperfan:是的,因为这基本上是将序列s 的所有元素连接到一个字符串中所要做的。这就是''.join(s) 的原因之一;它不必进行这样的循环(而是在 C 中这样做)。但是您问题中的示例函数必须循环无论如何,因此''.join() 无法获胜;您所做的所有工作要求您一个一个地循环输入字符。因此,您无法利用''.join()
  • 另外,+= 很容易出现拼写错误和/或混淆代码... *颤抖*。
【解决方案2】:

另一种选择是编写一个使用生成器连接的函数,而不是每次都附加到列表中。

例如:

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 并获得性能,因为您不需要测试明显的..
  • @MartijnPieters 复制并粘贴 OP 的源代码是我的借口 :) (我也认为可能还有其他一些收获,但认为扭曲那段代码是不公平的,因为我们真的在测试生成器/连接位)
  • 是的,我同意还有其他可以更有效地完成的事情。我承认对 == True 部分有点不公平,我确实看到 OP 最初犯了这个错误。 :-)
  • 生成器函数对于较长的字符串更快的原因是,到那时您开始击败必须逐个元素增长列表的list.append() 调用。在内部,''.join() 仍然需要构建一个列表,但可以在 C 中完成。
【解决方案3】:

(这可能是 OP 已经知道的很多细节,但是完整地解决这个问题可以帮助其他最终解决这个问题的人)

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(...) 更好,因为在那里你等到你知道所有的字符串来复制它们中的任何一个,然后将每个字符串直接复制到最终字符串对象中的正确位置。与某些 cmets 和答案所说的相反,即使您必须修改循环以将字符串附加到字符串列表,然后在循环之后 join 它们,情况仍然如此。 Lists 不是不可变的,所以 appending 到一个列表会修改它,它也只需要附加一个引用而不是复制字符串中的所有字符。对列表执行数千个附加操作比执行数千个字符串+= 操作要快得多。

重复字符串+= 理论上是一个问题,即使没有循环,如果你只是编写你的源代码:

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

但实际上,您不太可能手动编写足够长的代码序列,除非涉及的字符串非常庞大。因此,请注意循环(或递归函数)中的 +=


尝试计时时可能看不到此结果的原因是,实际上在 CPython 解释器中对字符串 += 进行了优化。让我们回到我愚蠢的示例循环:

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

每次mystring = mystring + c 执行此操作时,mystringold 值都会变成垃圾并被删除,而名称 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_garbageplus_equals_enumerate,您可以看到即使优化只在千分之一的+= 操作中失败,性能仍然会损失5 倍。

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


总结:使用+= 适合固定 少量的连接。 join 总是更适合使用循环来构建字符串。在实践中,由于+= 优化,您可能看不到从+=join 的移植代码的巨大改进。无论如何你还是应该使用join,因为优化是不可靠的,而且当它无法启动时的差异可能是巨大的。

【讨论】:

  • 谢谢,另一个详细的答案:) 我同意我在很多情况下使用+=,因为我根本不知道''.join() 我使用+=,因为我有一些其他的经验面向对象的语言,例如'C++'。尽管我知道同样的问题也可能出现在 C++ 中。
【解决方案4】:

+=.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
    

尽管与 CPython 相比,PyPy 以加速而闻名, += 版本较慢。这是一个深思熟虑的愿望,不包括 PyPy 默认版本中的 `+=' 优化。

处理性能的经验法则:“永远不要猜测,永远衡量。”

阅读文档也有帮助:

6 CPython 实现细节:如果 s 和 t 都是字符串,一些 诸如 CPython 之类的 Python 实现通常可以就地执行 优化 s = s + t 或 s += t 形式的赋值。什么时候 适用,这种优化使二次运行时间大大减少 可能。这个优化既是版本也是实现 依赖。对于性能敏感的代码,最好使用 str.join() 方法确保一致的线性连接 跨版本和实现的性能。"""

来自http://docs.python.org/2/library/stdtypes.html#typesseq

【讨论】:

  • +1 表示“永远不要猜测,永远衡量。”。性能关键组件在此原则下生死存亡。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2017-10-04
  • 1970-01-01
  • 1970-01-01
  • 2020-12-03
  • 1970-01-01
  • 2014-08-26
相关资源
最近更新 更多