s=s+c 字符串连接优化是如何决定的?

don*_*ode 18 python performance cpython internals

简短版本:如果s是字符串,则s = s + \'c\'可以就地修改字符串,但t = s + \'c\'不能。但是操作如何s + \'c\'知道它处于哪种场景呢?

\n

长版:

\n

t = s + \'c\'需要创建一个单独的字符串,因为程序随后需要旧字符串 ass和新字符串 as t

\n

s = s + \'c\'如果是唯一的引用,则可以就地修改字符串s,因为程序只想s成为扩展字符串。如果末尾有多余字符的空间,CPython 实际上会执行此优化。

\n

考虑这些重复添加字符的函数:

\n
def fast(n):\n    s = \'\'\n    for _ in range(n):\n        s = s + \'c\'\n        t = s\n        del t\n\ndef slow(n):\n    s = \'\'\n    for _ in range(n):\n        t = s + \'c\'\n        s = t\n        del t\n
Run Code Online (Sandbox Code Playgroud)\n

基准测试结果n = 100_000在线尝试!):

\n
fast :   9 ms    9 ms    9 ms    9 ms   10 ms \nslow : 924 ms  927 ms  931 ms  933 ms  945 ms\n
Run Code Online (Sandbox Code Playgroud)\n

请注意, extrat = ss = t使两个变量等效于对字符串的引用,然后del t仅留下s,因此在下一次循环迭代时,s再次成为对字符串的唯一引用。s + \'c\'因此,两个函数之间的唯一区别是分配给s和 的顺序t

\n

我们还可以反汇编字节码。!=我在中间标记了仅有的三个差异。STORE_FAST正如预期的那样,只有和 的变量LOAD_FAST不同。但在 之前(包括 )BINARY_ADD,字节码是相同的。那么如何知道BINARY_ADD是否要优化呢?

\n
      import dis                                 import dis\n      dis.dis(fast)                              dis.dis(slow)\n---------------------------------------------------------------------------\n    0 LOAD_CONST     1 (\'\')                    0 LOAD_CONST     1 (\'\')\n    2 STORE_FAST     1 (s)                     2 STORE_FAST     1 (s)\n                                                                 \n    4 LOAD_GLOBAL    0 (range)                 4 LOAD_GLOBAL    0 (range)\n    6 LOAD_FAST      0 (n)                     6 LOAD_FAST      0 (n)\n    8 CALL_FUNCTION  1                         8 CALL_FUNCTION  1\n   10 GET_ITER                                10 GET_ITER      \n>> 12 FOR_ITER      18 (to 32)             >> 12 FOR_ITER      18 (to 32)\n   14 STORE_FAST     2 (_)                    14 STORE_FAST     2 (_)\n                                                               \n   16 LOAD_FAST      1 (s)                    16 LOAD_FAST      1 (s)\n   18 LOAD_CONST     2 (\'c\')                  18 LOAD_CONST     2 (\'c\')\n   20 BINARY_ADD                              20 BINARY_ADD    \n   22 STORE_FAST     1 (s)        !=          22 STORE_FAST     3 (t)\n                                                               \n   24 LOAD_FAST      1 (s)        !=          24 LOAD_FAST      3 (t)\n   26 STORE_FAST     3 (t)        !=          26 STORE_FAST     1 (s)\n                                                               \n   28 DELETE_FAST    3 (t)                    28 DELETE_FAST    3 (t)\n   30 JUMP_ABSOLUTE 12                        30 JUMP_ABSOLUTE 12\n>> 32 LOAD_CONST     0 (None)              >> 32 LOAD_CONST     0 (None)\n   34 RETURN_VALUE                            34 RETURN_VALUE  \n
Run Code Online (Sandbox Code Playgroud)\n

Tim*_*ers 16

下面是来自 Python 3.10 分支的相关代码ceval.c(在 中,并从同一文件的操作BINARY_ADD码实现中调用)。正如 @jasonharper 在评论中指出的那样,它会向前查看,看看接下来的结果是否BINARY_ADD绑定到左侧加数来自的相同名称。在 中fast(),它是(操作数来自s且结果存储到 中s),但在slow()它中不是(操作数来自s但存储到 中t)。

但不能保证这种优化会持续下去。例如,我注意到您的fast()速度并不比slow()当前开发的 CPythonmain分支(这是当前正在进行的最终 3.11 版本的工作)快。

人们应该依赖这个吗?

如前所述,无法保证这种优化会持续存在。“严肃的”Python 程序员应该知道不要依赖狡猾的 CPython 特定技巧,事实上,PEP 8明确警告不要依赖这一特定技巧:

代码的编写方式不应损害 Python 的其他实现(PyPy、Jython、IronPython、Cython、Psyco 等)。

例如,不要依赖 CPython 对以下形式的语句的就地字符串连接的高效实现a += ba = a + b...