在Python中,哪种方式是首选的字符串连接方法?

430

由于Python的 string 不能被更改,我想知道如何更有效地连接字符串?

我可以这样写:

s += stringfromelsewhere

或者像这样:

s = []

s.append(somestring)
    
# later
    
s = ''.join(s)

在撰写这个问题的时候,我发现了一篇好文章谈论这个主题。

http://www.skymind.com/~ocrow/python_string/

但是它是关于Python 2.x的,所以问题就是在Python 3中是否有所改变?


1
https://dev59.com/XGkw5IYBdhLWcg3wPIB7#10043677 - Mikko Ohtamaa
12个回答

493

追加字符串到一个字符串变量中的最佳方式是使用++=。这是因为它既易读又快速。它们的速度也是一样的,你选择哪个取决于个人口味,后者是最常见的。以下是使用 timeit 模块计时的结果:

a = a + b:
0.11338996887207031
a += b:
0.11040496826171875

然而,那些建议使用列表并将其附加到字符串再连接这些列表的人之所以这样做,是因为将字符串附加到列表中可能比将字符串扩展快得多。在某些情况下,这确实是正确的。例如,在这里,我们对一个包含一百万个单字符字符串的字符串和列表进行了一百万次附加操作:

a += b:
0.10780501365661621
a.append(b):
0.1123361587524414

好的,事实证明,即使生成的字符串长度为一百万个字符,使用追加仍然更快。

现在让我们尝试将一个一千个字符长的字符串追加一万次:

a += b:
0.41823482513427734
a.append(b):
0.010656118392944336
因此,最终字符串大约有100MB长。这种方法很慢,使用列表添加数据的速度更快。这个时间不包括最后的 a.join()。那么这需要多长时间?
a.join(a):
0.43739795684814453

糟糕。结果发现即使在这种情况下,使用append/join仍然较慢。

那么这个建议是从哪里来的?Python 2吗?

a += b:
0.165287017822
a.append(b):
0.0132720470428
a.join(a):
0.114929914474

如果你正在使用非常长的字符串(通常不会有100MB内存的字符串),那么在那种情况下,使用追加/连接操作可能会稍微快一些。但是真正的关键在于Python 2.3。我甚至不会向你展示时间,因为它太慢了,还没有完成。这些测试突然需要花费几分钟的时间。除了追加/连接之外,在后来的版本中都和以前一样快。

没错,在Python语言的早期,字符串拼接非常缓慢。但在2.4版之后,它不再缓慢了(或者至少在Python 2.4.7中如此),因此在2008年时,当Python 2.3停止更新时,使用追加/连接的建议已经过时了,你应该停止使用这种方法。 :-)

(更新: 后来我仔细测试后发现,在Python 2.3中对于两个字符串使用++=也更快。使用''.join()进行连接的建议可能是一个误解)

然而,这只适用于CPython解释器。其他实现可能会有其他问题。这也是为什么过早优化是万恶之源的又一个原因。除非你先进行度量,否则不要使用一个被认为是“更快”的技术。

因此,"最好"的字符串拼接版本是使用+或+=。如果这对你来说太慢了,那就尝试其他方法。

那么为什么我在我的代码中经常使用追加/连接呢?因为有时候它实际上更容易理解。特别是当你希望将要拼接的内容用空格、逗号或换行符隔开时。


11
如果你有多个字符串(n > 10),那么"".join(list_of_strings)仍然更快。 - Mikko Ohtamaa
13
+= 之所以快速,是因为在 CPython 中有一种性能优化技巧:当引用计数为 1 时,它会快速执行。然而,在几乎所有其他 Python 实现中(除了一个相当特殊配置的 PyPy 版本),这种技巧都不起作用。 - user78110
23
为什么这篇文章会得到那么多点赞?使用仅在一个特定实现上有效并且本质上需要使用脆弱的技巧来修复二次时间算法的算法究竟有何优势呢?而且你完全误解了“过早优化是万恶之源”的意义。这句话所说的是小型优化。从O(n^2)到O(n)并不是一种小型优化。 - Wes
13
这是实际的引语:“我们应该忘记小效率,大约97%的时间:过早优化是万恶之源。然而,在那关键的3%中,我们不应该放弃机会。一个好的程序员不会被这种推理所安抚,他会明智地仔细查看关键代码;但前提是在确定了那段代码之后。” - Wes
2
没有人说 a + b 很慢。当你执行多次 a = a + b 时,它是二次的。a + b + c 不慢,我再重复一遍,_不慢_,因为它只需要遍历每个字符串一次,而使用 a = a + b 的方法则必须多次重新遍历之前的字符串(假设这是在某种循环中)。请记住,字符串是不可变的。 - Wes
显示剩余12条评论

58

如果您正在拼接许多值,则都不应使用。附加列表是昂贵的。您可以使用StringIO来代替。特别是如果您在许多操作中构建它。

from cStringIO import StringIO
# python3:  from io import StringIO

buf = StringIO()

buf.write('foo')
buf.write('foo')
buf.write('foo')

buf.getvalue()
# 'foofoofoo'

如果您已经从其他操作中返回了完整的列表,则只需使用''.join(aList)
来自Python FAQ:最有效地将许多字符串连接在一起的方法是什么?

str and bytes objects are immutable, therefore concatenating many strings together is inefficient as each concatenation creates a new object. In the general case, the total runtime cost is quadratic in the total string length.

To accumulate many str objects, the recommended idiom is to place them into a list and call str.join() at the end:

chunks = []
for s in my_strings:
    chunks.append(s)
result = ''.join(chunks)

(another reasonably efficient idiom is to use io.StringIO)

To accumulate many bytes objects, the recommended idiom is to extend a bytearray object using in-place concatenation (the += operator):

result = bytearray()
for b in my_bytes_objects:
    result += b

编辑:我很愚蠢,把结果倒过来粘贴,使得似乎将内容添加到列表比cStringIO更快。我还添加了对bytearray/str连接的测试,以及使用较大字符串的较大列表的第二轮测试。(python 2.7.3)

大型字符串列表的ipython测试示例

try:
    from cStringIO import StringIO
except:
    from io import StringIO

source = ['foo']*1000

%%timeit buf = StringIO()
for i in source:
    buf.write(i)
final = buf.getvalue()
# 1000 loops, best of 3: 1.27 ms per loop

%%timeit out = []
for i in source:
    out.append(i)
final = ''.join(out)
# 1000 loops, best of 3: 9.89 ms per loop

%%timeit out = bytearray()
for i in source:
    out += i
# 10000 loops, best of 3: 98.5 µs per loop

%%timeit out = ""
for i in source:
    out += i
# 10000 loops, best of 3: 161 µs per loop

## Repeat the tests with a larger list, containing
## strings that are bigger than the small string caching 
## done by the Python
source = ['foo']*1000

# cStringIO
# 10 loops, best of 3: 19.2 ms per loop

# list append and join
# 100 loops, best of 3: 144 ms per loop

# bytearray() +=
# 100 loops, best of 3: 3.8 ms per loop

# str() +=
# 100 loops, best of 3: 5.11 ms per loop

3
cStringIO在Py3中不存在。请使用io.StringIO代替。 - lvc
4
关于为什么重复添加字符串会很耗费资源:请参考此链接http://www.joelonsoftware.com/articles/fog0000000319.html。 - Wes
等等,你说“追加列表[很耗费]”时,是指“追加字符串”对吧? - khuongduybui
@khuongduybui 可能应该说“向列表追加是昂贵的”。 - jdi
测量.join()的示例实际上是在测量.append()。要测量.join(),我们应该说%timeit "".join(source) - lpozo
@lpozo 嗯,这是一个好观点,但可能有两件不同的事情需要测试。最初的意图是测试构建列表,然后连接它。您可以添加另一个测试,假设您已经有了一个列表,并且只计时连接操作。 - jdi

54
在Python >= 3.6中,新的f-string是一种高效的字符串拼接方式。
>>> name = 'some_name'
>>> number = 123
>>>
>>> f'Name is {name} and the number is {number}.'
'Name is some_name and the number is 123.'

5
如果f'{a}{b}'不比a += b或者a + b更加高效,那么我不明白这如何对一个专门关于性能的问题有实质性的回应。这个特性是语法糖(当然是好用和有用的语法糖!),而不是性能优化。 - Charles Duffy

23
使用加号进行字符串拼接是最不稳定和跨平台的拼接方式,因为它不支持所有值。PEP8标准反对此方法,鼓励长期使用format()、join()和append()。正如链接的“编程建议”部分所述:“例如,不要依赖于CPython的就地字符串连接高效实现,用于形式为a += b或a = a + b的语句。即使在CPython中,这种优化也很脆弱(它仅适用于某些类型),并且在不使用引用计数的实现中根本不存在。在库的性能敏感部分,应该使用''.join()形式。这将确保拼接在线性时间内在各种实现中发生。”

7
参考链接会更好 :) - user4911648
5
多么荒谬的局面。这是人们学习的最基本技能之一,可是象牙塔里的巫师们发布了一个PEP来阻止它,因为他们认为它很脆弱。 - Magnus Lind Oxlund
人们被教导这样做,而且它确实有效,在初级编程中也不会有太大的影响。但是那些关注效率的人需要深入了解一下。我认为设计语言的方式并不“荒谬”——你还能怎么设计呢? - AnotherParker

10

你编写这个函数

def str_join(*args):
    return ''.join(map(str, args))

那么你可以在任何你想要的地方简单地调用它

str_join('Pine')  # Returns : Pine
str_join('Pine', 'apple')  # Returns : Pineapple
str_join('Pine', 'apple', 3)  # Returns : Pineapple3

1
str_join = lambda *str_list: ''.join(s for s in str_list) - Rick

10

你可以用不同的方式来实现。

str1 = "Hello"
str2 = "World"
str_list = ['Hello', 'World']
str_dict = {'str1': 'Hello', 'str2': 'World'}

# Concatenating With the + Operator
print(str1 + ' ' + str2)  # Hello World

# String Formatting with the % Operator
print("%s %s" % (str1, str2))  # Hello World

# String Formatting with the { } Operators with str.format()
print("{}{}".format(str1, str2))  # Hello World
print("{0}{1}".format(str1, str2))  # Hello World
print("{str1} {str2}".format(str1=str_dict['str1'], str2=str_dict['str2']))  # Hello World
print("{str1} {str2}".format(**str_dict))  # Hello World

# Going From a List to a String in Python With .join()
print(' '.join(str_list))  # Hello World

# Python f'strings --> 3.6 onwards
print(f"{str1} {str2}")  # Hello World

我通过阅读以下文章,创建了这个小总结。


8
正如@jdi所提到的,Python文档建议使用str.joinio.StringIO进行字符串拼接。并且说开发人员应该在循环中预期+=会带来二次时间复杂度,即使自从Python 2.4以来已经进行了优化。正如这个答案所说:

如果Python检测到左侧参数没有其他引用,则调用realloc尝试通过原地调整字符串的大小来避免复制。这不是您应该依赖的东西,因为它是一个实现细节,并且因为如果realloc需要频繁移动字符串,性能会降低到O(n^2)。

我将展示一个实际代码的例子,它天真地依赖于+=这种优化,但实际上并未应用。下面的代码将短字符串的可迭代对象转换为更大的块,以便在批量API中使用。
def test_concat_chunk(seq, split_by):
    result = ['']
    for item in seq:
        if len(result[-1]) + len(item) > split_by: 
            result.append('')
        result[-1] += item
    return result

这段代码由于二次时间复杂度,可能需要运行数小时。以下是使用建议数据结构的替代方案:

import io

def test_stringio_chunk(seq, split_by):
    def chunk():
        buf = io.StringIO()
        size = 0
        for item in seq:
            if size + len(item) <= split_by:
                size += buf.write(item)
            else:
                yield buf.getvalue()
                buf = io.StringIO()
                size = buf.write(item)
        if size:
            yield buf.getvalue()

    return list(chunk())

def test_join_chunk(seq, split_by):
    def chunk():
        buf = []
        size = 0
        for item in seq:
            if size + len(item) <= split_by:
                buf.append(item)
                size += len(item)
            else:
                yield ''.join(buf)                
                buf.clear()
                buf.append(item)
                size = len(item)
        if size:
            yield ''.join(buf)

    return list(chunk())

并且还有一个微基准测试:

import timeit
import random
import string
import matplotlib.pyplot as plt

line = ''.join(random.choices(
    string.ascii_uppercase + string.digits, k=512)) + '\n'
x = []
y_concat = []
y_stringio = []
y_join = []
n = 5
for i in range(1, 11):
    x.append(i)
    seq = [line] * (20 * 2 ** 20 // len(line))
    chunk_size = i * 2 ** 20
    y_concat.append(
        timeit.timeit(lambda: test_concat_chunk(seq, chunk_size), number=n) / n)
    y_stringio.append(
        timeit.timeit(lambda: test_stringio_chunk(seq, chunk_size), number=n) / n)
    y_join.append(
        timeit.timeit(lambda: test_join_chunk(seq, chunk_size), number=n) / n)
plt.plot(x, y_concat)
plt.plot(x, y_stringio)
plt.plot(x, y_join)
plt.legend(['concat', 'stringio', 'join'], loc='upper left')
plt.show()

micro-benchmark


8
如果您要连接的字符串是字面值,请使用字符串文字连接
re.compile(
        "[A-Za-z_]"       # letter or underscore
        "[A-Za-z0-9_]*"   # letter, digit or underscore
    )

这对于想要评论字符串的一部分(如上所述)或者想要在字面量的一部分使用原始字符串或三引号但不是全部时非常有用。
由于这是在语法层发生的,它使用零个连接运算符。

8
推荐的方法仍然是使用append和join。

1
从我的答案中可以看出,这取决于你要连接多少个字符串。我对此进行了一些计时(请参阅我在评论中链接的讲话),通常情况下,除非超过十个,否则使用加号(+)。 - Lennart Regebro
1
PEP8提到了这一点(https://www.python.org/dev/peps/pep-0008/#programming-recommendations)。其理由是,虽然CPython对于使用+=进行字符串连接有特殊的优化,但其他实现可能没有。 - Quantum7

6

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