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

How is the s=s+c string concat optimization decided?

简写:如果s是一个字符串,那么s = s + 'c'可能就地修改字符串,而t = s + 'c'不能.但是操作 s + 'c' 怎么知道它在哪个场景中呢?

长版:

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

如果 s 是唯一的引用,

s = s + 'c' 可以就地修改字符串,因为程序只希望 s 成为扩展字符串。如果额外字符的末尾有 space,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_000Try it online!)的基准测试结果:

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  

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

但不能保证此优化会持续存在。例如,我注意到你的 fast() 在当前开发 CPython main 分支上并不比你的 slow() 快(这是当前正在进行的工作最终 3.11 版本)。

人们应该依赖这个吗?

如前所述,无法保证此优化会持续存在。 “严肃的”Python 程序员应该知道不要依赖狡猾的 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 ...