写 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 += p |
191.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 的实现思路是两遍:
- 第一遍遍历所有片段,累加每个字符串的长度,得到结果串的总长
- 一次性分配这块内存,再把每个片段拷进去
而 s += p 每一轮做的是"造一个新字符串,把旧内容复制进去,再追加新片段"。随着 s 越来越长,每轮要复制的内容也越来越多------这是二次级别的复制成本,不是 1000 次小操作。
所以差距不是"join 的 C 循环更快"这么简单,而是分配次数和复制量级的差距。
四、字节码:+= 并没有特殊指令
scss
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 += p |
191.8 |
t = s 后 s += p,再 del t |
459.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-string | 251.8 |
''.join([...]) |
306.4 |
a + ' ' + str(b) + ' ' + c |
312.1 |
str.format |
490.8 |
原因很直白:join 要先构造一个列表 ,这层开销在只有 3 个片段时盖过了它的复制优势。str.format 最慢,是因为它要走完整的格式规范解析。
所以不要背"永远用 join"。片段多、来自可迭代对象 → join;片段少、写死在代码里 → f-string。
七、实战守则
- 循环里绝不
+=攒字符串 :用一个列表收集,最后一次性join - 已有列表就直接
''.join(list),不要在join里再写一遍列表推导(除非本来就要转换类型) - 别为了"优化"做分块 join:实测更慢,只在流式场景下用
- 日志/告警里的拼接先看量级:3 个片段用 f-string,几十上百个片段用 join
- 别依赖
+=的原地优化 :它依赖引用计数条件,一旦有人持有旧引用就失效;也不要用id去验证它
一句话总结:字符串拼接的成本不在"加",在"复制"。join 用两遍扫描换一次分配,这就是它比循环 += 快 28 倍的全部秘密。