Python 拼字符串为什么别在循环里用 +=?拆开 join 的两遍账:191.8 微秒与 6.8 微秒

0 阅读4分钟

写 Python 的人几乎都听过一句话:字符串不可变,循环里别用 += 拼接,用 join。

这句话是对的,但很多文章只说结论,不告诉你贵在哪。本文用 1000 段字符串的实测,把 += 和 join 的账拆到底,顺手验证一个常被说错的细节:CPython 对 += 的"原地优化"到底在什么条件下失效。

一、结论先行

  • 循环 += 拼接 1000 段:191.8 µs;list.append 后 join:45.1 µs;已有列表直接 join:6.8 µs
  • += 贵的根源是每一轮都可能复制整个已拼好的字符串;join 先扫一遍算总长,再一次性分配
  • CPython 对 s += x 有原地扩容优化,但它依赖引用计数条件:手工加一个别名后,同样循环涨到 459.8 µs(扣掉别名基线约 442.6 µs,是纯 += 的 2.3 倍)
  • 少片段场景(3 段)别迷信 join:f-string 约 251.8 ns,% 约 249.1 ns,join 列表约 306.4 ns,str.format 约 490.8 ns

一句话:循环里攒字符串用 join;只有两三个片段要拼,用 f-string 就行。

二、先看实测:1000 段拼接

测试条件:1000 段、每段 8 个字符,timeit 取 7 轮中位数。

写法µs/次(1000 段)相对
s = '' 循环 s += p191.8基准
buf.append(p) 后 ''.join(buf)45.1快 4.3 倍
已有列表直接 ''.join(parts)6.8快 28 倍
''.join([str(i) for i in range(1000)])134.9—
每 100 段 join 一次(分块)136.0不如一次性 join

注意最后一行:分块 join 并没有更快。它多了一次中间列表构造和多次 join 调用,反而从 45.1 µs 涨到 136.0 µs。只有当数据本身是流式到达、无法一次性拿到全部片段时,分块 join 才值得考虑。

三、为什么 join 快:两遍账

str.join 的实现思路是两遍:

  1. 第一遍遍历所有片段,累加每个字符串的长度,得到结果串的总长
  2. 一次性分配这块内存,再把每个片段拷进去

而 s += p 每一轮做的是"造一个新字符串,把旧内容复制进去,再追加新片段"。随着 s 越来越长,每轮要复制的内容也越来越多——这是二次级别的复制成本,不是 1000 次小操作。

所以差距不是"join 的 C 循环更快"这么简单,而是分配次数和复制量级的差距。

四、字节码:+= 并没有特殊指令

LOAD_FAST_LOAD_FAST   16 (s, x)
BINARY_OP             13 (+=)
STORE_FAST            1 (s)

3.13 里 s += x 编译成 BINARY_OP 13 (+=),没有专门的"原地拼接"指令。真正的优化发生在解释器执行这条指令时:当左侧是 str 且引用计数允许复用时,CPython 会尝试原地扩容而不是新建一个对象。

五、原地优化什么时候失效

验证方法:在每轮拼接前加一个别名 t = s,让对象多一个引用,再删掉别名。

写法µs/次
纯 s += p191.8
t = s 后 s += p,再 del t459.8
只做 t = s 和 del t(基线,不拼接)17.2

扣掉 17.2 µs 的别名操作基线,破坏引用计数条件后约 442.6 µs,是纯 += 的 2.3 倍。

这也解释了为什么这个优化"看不见摸不着":本次还测了 200 次 += 后的 id(s),出现了 165 个不同 id。也就是说,扩容时对象地址可能变化,不能靠 id 是否相同判断有没有原地优化——真正的证据是上面这组时间差。

六、少片段场景:join 不一定赢

把片段减到 3 个,结论会反转:

写法ns/次
% 格式化249.1
f-string251.8
''.join([...])306.4
a + ' ' + str(b) + ' ' + c312.1
str.format490.8

原因很直白:join 要先构造一个列表,这层开销在只有 3 个片段时盖过了它的复制优势。str.format 最慢,是因为它要走完整的格式规范解析。

所以不要背"永远用 join"。片段多、来自可迭代对象 → join;片段少、写死在代码里 → f-string。

七、实战守则

  • 循环里绝不 += 攒字符串:用一个列表收集,最后一次性 join
  • 已有列表就直接 ''.join(list),不要在 join 里再写一遍列表推导(除非本来就要转换类型)
  • 别为了"优化"做分块 join:实测更慢,只在流式场景下用
  • 日志/告警里的拼接先看量级:3 个片段用 f-string,几十上百个片段用 join
  • 别依赖 += 的原地优化:它依赖引用计数条件,一旦有人持有旧引用就失效;也不要用 id 去验证它

一句话总结:字符串拼接的成本不在"加",在"复制"。join 用两遍扫描换一次分配,这就是它比循环 += 快 28 倍的全部秘密。