为什么 'new_file += line + string' 比 'new_file = new_file + line + string' 快这么多?

Why is 'new_file += line + string' so much faster than 'new_file = new_file + line + string'?

当我们使用以下代码时,我们的代码需要 10 分钟才能完成 68,000 条记录:

new_file = new_file + line + string

然而,当我们执行以下操作时,只需 1 秒:

new_file += line + string

代码如下:

for line in content:
import time
import cmdbre

fname = "STAGE050.csv"
regions = cmdbre.regions
start_time = time.time()
with open(fname) as f:
        content = f.readlines()
        new_file_content = ""
        new_file = open("CMDB_STAGE060.csv", "w")
        row_region = ""
        i = 0
        for line in content:
                if (i==0):
                        new_file_content = line.strip() + "~region" + "\n"
                else:
                        country = line.split("~")[13]
                        try:
                                row_region = regions[country]
                        except KeyError:
                                row_region = "Undetermined"
                        new_file_content += line.strip() + "~" + row_region + "\n"
                print (row_region)
                i = i + 1
        new_file.write(new_file_content)
        new_file.close()
        end_time = time.time()
        print("total time: " + str(end_time - start_time))

我在 python 中编写的所有代码都使用第一个选项。这只是基本的字符串操作……我们正在从文件中读取输入,对其进行处理并将其输出到新文件。我 100% 确定第一种方法 运行 比第二种方法花费的时间大约长 600 倍,但为什么呢?

正在处理的文件是 csv,但使用 ~ 而不是逗号。我们在这里所做的就是采用这个 csv,它有一个国家列,并为国家地区添加一个列,例如LAC、EMEA、NA 等...cmdbre.regions 只是一个字典,所有约 200 个国家/地区作为键,每个地区作为值。

一旦我更改为追加字符串操作...循环在 1 秒内完成,而不是 10 分钟...csv 中有 68,000 条记录。

CPython(参考解释器)对就地字符串连接进行了优化(当附加的字符串没有其他参考时)。它在执行 + 时不能可靠地应用此优化,仅 +=+ 涉及两个实时引用,赋值目标和操作数,前者不涉及+ 操作,所以更难优化它)。

你不应该依赖这个,根据 PEP 8:

Code should be written in a way that does not disadvantage other implementations of Python (PyPy, Jython, IronPython, Cython, Psyco, and such).

For example, do not rely on CPython's efficient implementation of in-place string concatenation for statements in the form a += b or a = a + b . This optimization is fragile even in CPython (it only works for some types) and isn't present at all in implementations that don't use refcounting. In performance sensitive parts of the library, the ''.join() form should be used instead. This will ensure that concatenation occurs in linear time across various implementations.

根据问题编辑更新:是的,你破坏了优化。您连接了许多字符串,而不仅仅是一个,并且 Python 从左到右计算,因此它必须首先进行最左边的连接。因此:

new_file_content += line.strip() + "~" + row_region + "\n"

完全不同于:

new_file_content = new_file_content + line.strip() + "~" + row_region + "\n"

因为前者将所有 new 片段连接在一起,然后将它们一次全部附加到累加器字符串,而后者必须使用临时值从左到右评估每个添加不要涉及 new_file_content 本身。为清楚起见添加括号,就像您所做的那样:

new_file_content = (((new_file_content + line.strip()) + "~") + row_region) + "\n"

因为在到达之前它实际上并不知道类型,所以它不能假设所有这些都是字符串,所以优化不会启动。

如果您将代码的第二位更改为:

new_file_content = new_file_content + (line.strip() + "~" + row_region + "\n")

或稍微慢一点,但仍然比你的慢代码快很多倍,因为它保持了 CPython 优化:

new_file_content = new_file_content + line.strip()
new_file_content = new_file_content + "~"
new_file_content = new_file_content + row_region
new_file_content = new_file_content + "\n"

所以累积对 CPython 来说是显而易见的,你会解决性能问题。但坦率地说,只要执行这样的逻辑追加操作,您就应该使用 +=+= 的存在是有原因的,它为维护者和解释者提供了有用的信息。除此之外,就 DRY 而言,这是一种很好的做法;为什么不需要两次命名变量?

当然,根据 PEP8 指南,即使在这里使用 += 也是错误的形式。在大多数具有不可变字符串的语言中(包括大多数非 CPython Python 解释器),重复的字符串连接是 Schlemiel the Painter's Algorithm 的一种形式,这会导致严重的性能问题。正确的解决方案是构建 list 个字符串,然后 join 将它们全部合并,例如:

    new_file_content = []
    for i, line in enumerate(content):
        if i==0:
            # In local tests, += anonymoustuple runs faster than
            # concatenating short strings and then calling append
            # Python caches small tuples, so creating them is cheap,
            # and using syntax over function calls is also optimized more heavily
            new_file_content += (line.strip(), "~region\n")
        else:
            country = line.split("~")[13]
            try:
                    row_region = regions[country]
            except KeyError:
                    row_region = "Undetermined"
            new_file_content += (line.strip(), "~", row_region, "\n")

    # Finished accumulating, make final string all at once
    new_file_content = "".join(new_file_content)

即使在 CPython 字符串连接选项可用时通常也更快,并且在非 CPython Python 解释器上也将可靠地快速,因为它使用可变 list 有效地累积结果,然后允许 ''.join 预先计算字符串的总长度,一次分配最终字符串(而不是沿途增量调整大小),并恰好填充一次。

旁注:对于您的具体情况,您根本不应该累积或连接。您有一个输入文件和一个输出文件,可以逐行处理。每次您要追加或累积文件内容时,只需将它们写出来(我已经清理了一些代码以符合 PEP8 和其他小的样式改进):

start_time = time.monotonic()  # You're on Py3, monotonic is more reliable for timing

# Use with statements for both input and output files
with open(fname) as f, open("CMDB_STAGE060.csv", "w") as new_file:
    # Iterate input file directly; readlines just means higher peak memory use
    # Maintaining your own counter is silly when enumerate exists
    for i, line in enumerate(f):
        if not i:
            # Write to file directly, don't store
            new_file.write(line.strip() + "~region\n")
        else:
            country = line.split("~")[13]
            # .get exists to avoid try/except when you have a simple, constant default
            row_region = regions.get(country, "Undetermined")
            # Write to file directly, don't store
            new_file.write(line.strip() + "~" + row_region + "\n")
end_time = time.monotonic()
# Print will stringify arguments and separate by spaces for you
print("total time:", end_time - start_time)

深入实施细节

对于那些对实现细节感兴趣的人,CPython 字符串连接优化是在字节码解释器中实现的,而不是在 str 类型本身上实现的(从技术上讲,PyUnicode_Append变异优化,但它需要解释器的帮助来修复引用计数,因此它知道它可以安全地使用优化;没有解释器的帮助,只有 C 扩展模块才能从该优化中受益。

当解释器 detects that both operands are the Python level str type (at C layer, in Python 3, it's still referred to as PyUnicode, a legacy of 2.x days that wasn't worth changing), it calls a special unicode_concatenate function 时,它检查下一条指令是否是三个基本 STORE_* 指令之一。如果是,并且目标与左操作数相同,它会清除目标引用,因此 PyUnicode_Append 将只看到对操作数的单个引用,从而允许它为 str 调用优化代码有一个参考。

这意味着您不仅可以通过

打破优化
a = a + b + c

您也可以在相关变量不是顶级(全局、嵌套或局部)名称时随时中断它。如果你在对象属性、list 索引、dict 值等上操作,即使 += 也帮不了你,它不会看到“简单的 STORE",所以它不会清除目标引用,所有这些都会导致超慢、不就地的行为:

foo.x += mystr
foo[0] += mystr
foo['x'] += mystr

也是str类型特有的;在 Python 2 中,优化对 unicode 个对象没有帮助,在 Python 3 中,它对 bytes 个对象没有帮助,在这两个版本中都不会优化 str 的子类;那些总是走慢路。

基本上,对于刚接触 Python 的人来说,优化是在最简单的常见情况下尽可能好,但对于更复杂的情况也不会造成严重的麻烦。这只是强化了 PEP8 的建议:取决于你的解释器的实现细节是一个坏主意,当你可以 运行 在 every 解释器上更快,对于任何存储目标,通过做正确的东西并使用 str.join.

实际上,两者都可能同样慢,但对于一些优化,这实际上是官方 Python 运行时 (cPython) 的实现细节。

Python 中的字符串是不可变的 - 这意味着当您执行 "str1 + str2" 时,Python 必须创建第三个字符串对象,并复制 str1 和str2 到它 - 无论这些部分有多大。

inplace 运算符允许 Python 使用一些内部优化,以便 str1 中的所有数据不必再次复制 - 甚至可能它甚至允许一些缓冲区 space 用于进一步的连接选项。

当人们对语言的工作方式有所了解时,从小字符串构建大文本正文的方法是创建一个包含所有字符串的 Python 列表,在循环结束后,使对传递所有字符串组件的 str.join 方法的一次调用。这将始终如一地快速,即使在 Python 实施中也是如此,并且不依赖于能够触发的优化。

output = []
for ...:
    output.append(line)

new_file = "\n".join(output)