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

简介: 循环 += 拼接 1000 段要 191.8 微秒,已有列表直接 join 只要 6.8 微秒:join 先扫一遍算总长再一次性分配,+= 每轮都可能复制整个已拼好的串。CPython 对 += 的原地扩容依赖引用计数,加一个别名后涨到 459.8 微秒。3 片段场景 f-string 约 251.8ns,join 反而 306.4ns。

系列:Python 底层设计逻辑 | 环境:本机受管 Python,CPython 3.13.14

写 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 的实现思路是两遍:

  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 += 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 倍的全部秘密。

相关文章
|
监控 数据安全/隐私保护 开发者
阿里云开发者社区用户服务协议
阿里云开发者社区用户服务协议
1295968 7
|
7月前
|
人工智能 Linux API
OpenClaw全自动小红书运营实战:从0到1全流程部署、技能配置与内容自动化发布指南
在内容自动化运营场景中,OpenClaw(Clawdbot)凭借高度可扩展的Skill体系与多任务执行能力,可实现从热点追踪、文案创作、封面生成到笔记发布、互动管理的全流程自动化。本文基于2026年最新环境,完整讲解如何通过阿里云轻量服务器或本地Windows11/macOS/Linux部署OpenClaw,安装并配置小红书运营Skill,完成Cookie登录、内容生成、笔记发布、数据监控,并接入阿里云百炼Coding Plan免费大模型与QMD记忆优化系统,实现低成本、7×24小时无人值守小红书运营。全文无营销词汇,所有命令可直接复制,零基础用户也能快速跑通全流程。
3460 9
|
20小时前
|
存储 弹性计算 容灾
阿里云国际渠道代理商:ECS数据备份与恢复实战 快照、云盘与Cloud Backup容灾指南
本文详解阿里云国际版ECS数据安全三重防线:云盘多副本(防硬件故障)、自动快照(分钟级整盘恢复)、Cloud Backup(跨地域/不可变/文件级备份),兼顾RPO/RTO与成本优化,助力企业构建高可用、防勒索、合规的数据容灾体系。(239字)
|
20小时前
|
数据库
分片上传的会话态、分片态与合并态:断点续传的三层校验规范
以一次"客户端报告分片成功、服务端合并报分片不存在"的故障为样本,区分分片上传的会话态、分片态、合并态,给出服务端清单核对、合并前三项校验与未完成会话清理的规范,每条附验收标准。
|
1天前
|
C++ Python
Python 的函数调用到底贵在哪?拆开一次 call:51 纳秒栈帧与 *args 的 40 纳秒罚单
Python 函数调用一次约 100ns:空调用 97.8ns,其中栈帧创建机制约 51ns;*args 打包 tuple 贵 27%,**kw 建 dict 贵 15%;绑定方法比类函数调用快 40%。fib(30) 的 200ms 几乎全是调用开销,内联优化上限约 48ns。7 组实测数据。
|
9天前
|
JSON 缓存 C语言
Python 的 True + True 为什么等于 2?bool 是 int 子类,这个设计坑了多少人
True+True==2、isinstance(True,int) 为真:bool 是 int 的子类,True 就是 1。9 组实验讲透这个 C 时代设计遗产的顺手与埋雷——字典键碰撞、JSON 序列化、== 与 is 混用三大坑一次避开。
|
7天前
|
存储 缓存 编译器
Python 的整数为什么永远不会溢出?拆开大整数对象,看 30 位一节的存储与运算代价
Python 整数不会溢出,靠的是把数字拆成 30 位小节用对象数组存:每个整数至少 28 字节,每多 30 位加 4 字节;加法近似线性、乘法超线性——30 万位乘法是 30 位的 29 万倍;str 转换默认 4300 位护栏,to_bytes 比 str 快约 300 倍。用 9 组实验算清「无限精度」的账单。
|
10天前
|
缓存 Python Windows
Python 的 __new__ 到底管什么?和 __init__ 的分工一次讲透
__new__ 管造对象、__init__ 管初始化:11 组实验画出分工边界——两类事非 __new__ 不可(改造不可变类型构造、控制实例创建),日常 99% 初始化仍该写在 __init__。
|
11天前
|
缓存 Java 编译器
Python 里 a is b 有时 True 有时 False?小整数缓存和常量折叠的底层真相
is 比的是对象身份:CPython 预建 [-5,256] 整数对象全进程共享,这个范围内 is 恒 True;257 以上网传每次新对象是错的,真相在常量折叠。6 组实验把边界一条条画清。
|
13天前
|
安全 Java C++
Python 到底是值传递还是引用传递?一个赋值实验讲透,别再背结论了
Python 既不是值传递也不是引用传递,而是传对象引用。本文用一个赋值实验讲透:函数收到的不是实参副本也不是实参本身,而是同一个对象的引用;并澄清 mutable 与 immutable、默认参数记仇、+= 不总是等于 + 三个经典陷阱。