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

16

简短版:如果s是一个字符串,那么s = s + 'c'可能会就地修改字符串,而t = s + 'c'则不行。但是,s + 'c'操作如何知道它处于哪种情况?

详细版:

t = s + 'c'需要创建一个单独的字符串,因为程序之后想要旧字符串作为s,新字符串作为t

s = s + 'c'可以在原地修改字符串,如果s是唯一的引用,因为程序只想让s成为扩展后的字符串。CPython实际上会进行这种优化,如果末尾有额外的空间。

考虑这些函数,它们重复添加一个字符:

def fast(n):
    s = ''
    for _ in range(n):
        s = s + 'c'
        t = s
        del t

def slow(n):
    s = ''
    for _ in range(n):
        t = s + 'c'
        s = t
        del t

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

fast :   9 ms    9 ms    9 ms    9 ms   10 ms 
slow : 924 ms  927 ms  931 ms  933 ms  945 ms

请注意,额外的 t = ss = t 使得两个变量都等价地引用字符串,接着 del t 只留下了 s,因此在下一次循环迭代中,s 再次成为唯一引用该字符串的变量。因此,这两个函数之间唯一的区别是将 s + 'c' 赋给 st 的顺序。
我们还要反汇编字节码。我用 != 标记了唯一的三个不同点。如预期所述,只有 STORE_FASTLOAD_FAST 的变量不同。但是,在包括 BINARY_ADD 在内的指令序列直到最后一个都是相同的。那么,BINARY_ADD 如何知道是否进行了优化呢?
      import dis                                 import dis
      dis.dis(fast)                              dis.dis(slow)
---------------------------------------------------------------------------
    0 LOAD_CONST     1 ('')                    0 LOAD_CONST     1 ('')
    2 STORE_FAST     1 (s)                     2 STORE_FAST     1 (s)
                                                                 
    4 LOAD_GLOBAL    0 (range)                 4 LOAD_GLOBAL    0 (range)
    6 LOAD_FAST      0 (n)                     6 LOAD_FAST      0 (n)
    8 CALL_FUNCTION  1                         8 CALL_FUNCTION  1
   10 GET_ITER                                10 GET_ITER      
>> 12 FOR_ITER      18 (to 32)             >> 12 FOR_ITER      18 (to 32)
   14 STORE_FAST     2 (_)                    14 STORE_FAST     2 (_)
                                                               
   16 LOAD_FAST      1 (s)                    16 LOAD_FAST      1 (s)
   18 LOAD_CONST     2 ('c')                  18 LOAD_CONST     2 ('c')
   20 BINARY_ADD                              20 BINARY_ADD    
   22 STORE_FAST     1 (s)        !=          22 STORE_FAST     3 (t)
                                                               
   24 LOAD_FAST      1 (s)        !=          24 LOAD_FAST      3 (t)
   26 STORE_FAST     3 (t)        !=          26 STORE_FAST     1 (s)
                                                               
   28 DELETE_FAST    3 (t)                    28 DELETE_FAST    3 (t)
   30 JUMP_ABSOLUTE 12                        30 JUMP_ABSOLUTE 12
>> 32 LOAD_CONST     0 (None)              >> 32 LOAD_CONST     0 (None)
   34 RETURN_VALUE                            34 RETURN_VALUE  

Binary_add不知道,它调用的C代码执行了优化,因为它看到了引用计数(因此知道它是安全的)。 - ead
最好将其限制在特定版本上。即将推出一些非常重要的优化(针对特定内置类型的操作码专业化已经在CPython存储库中),但它们不适用于您计时的版本 - 而且坦率地说,在查看了3.8源代码之后,这似乎确实令人惊讶(并且有趣!)它能够正常工作。 - MisterMiyagi
但是,这段C代码在两个版本中看到的引用计数不是一样的吗? - no comment
4
魔法发生在string_concatenate()函数中(在Python/ceval.c中),该函数被调用以将两个字符串进行BINARY_ADD操作。它会预先查看下一个字节码指令,并且只有在该指令是对加法的第一个操作数进行STORE操作时,才会考虑进行原地修改。 - jasonharper
1
@uhoh 感谢您指出另一个重复投票。我现在也在另一个问题上留下了评论。 - MisterMiyagi
显示剩余6条评论
1个回答

14
这是来自Python 3.10分支的code in question(在ceval.c中,并从同一文件的BINARY_ADD操作码的实现中调用)。正如@jasonharper在评论中指出的那样,它会预先查看是否将下一个BINARY_ADD的结果绑定到与左手加数相同的名称。在fast()中是这样的(操作数来自s并存储到s中),但在slow()中不是这样的(操作数来自s但存储到t中)。
但是,并没有保证这种优化会持续存在。例如,我注意到您的fast()在当前开发的CPython main分支上(这是最终发布3.11版本的当前工作进展)中并不比slow()快。
人们应该依靠它吗?
正如前面提到的,不能保证这种优化会持续存在。 “严肃”的Python程序员应该知道不要依赖于不可靠的CPython特定技巧,而且,PEP 8明确警告不要依赖于此特定技巧:
代码应以不劣于其他Python实现(PyPy,Jython,IronPython,Cython,Psyco等)的方式编写。
例如,不要依赖于CPython对形如a += ba = a + b的语句中原地字符串连接的高效实现。

谢谢,现在正在详细查看。在“主函数”中,“快速”的速度变慢了还是“慢速”的速度变快了? - no comment
“fast ()”变慢了—在“main”的“BINARY_ADD”实现中不再存在检查Unicode操作数的特殊代码。没有什么是“免费的”:该检查微不足道地减慢了每个加法运算,包括像“1 + 2”这样的运算。3.11将开始尝试动态重写操作码,以适应运行时发现的类型。工作正在进行中。 - Tim Peters
是的,我一直在想它是否会减慢其他事情的速度。那么,这是否意味着大量使用这种方式构建字符串的旧代码会突然变慢,还是正在进行的工作可能会重新引入优化? - no comment
不知道。请查看刚才的编辑以获取更多信息。 - Tim Peters

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