Python 的 str 为什么设计成不可变?拆开字符串驻留、字符串池,以及 += 拼接到底慢在哪

简介: str 不可变换来三件事:hash 一次算完可缓存(dict 查找的地基)、子串切片能安全共享底层缓冲、跨模块传递不用防御性拷贝。实测 += 在 CPython 里已被原地扩容优化,10 万次拼接仍是线性,真正的差距是 join 只分配一次、比 += 快约 19 倍。本文拆开字符串驻留、字符串池与拼接性能把设计逻辑钉死。

我第一次看到「Python 里拼字符串千万别用 +=,那是 O(n²),数据一大就爆慢」这个结论时,老老实实照着把代码里一堆拼接改成了 join。后来真机上 benchmark 了一下才发现,这句话在今天的 CPython 里,只对了一半——甚至更严格地说,是错的。今天我把 str 为什么不可变、字符串驻留/字符串池到底怎么运作、以及 += 到底慢不慢,用本机实测拆开讲清楚。

本机环境:CPython 3.13.14 (64-bit),以下所有数字都来自 timeit 实测,不是抄书。

一、str 为什么被设计成不可变?

先说不可变这件事本身。很多人把它当成「历史包袱」,其实它是三个硬需求叠加的结果:

1. 可哈希,才能当字典的键。 这是最实在的理由。Python 的 dict 和 set 依赖键的哈希值在生命周期内不变。如果字符串能原地改,{s: 1} 的哈希就漂移了,整个哈希表会塌。你每天写的 d["name"] = "张三" 能成立,前提就是 "name" 不可变。

2. 字符串驻留(interning)省内存、加速比较。 同一个字面量只存一份,比较时甚至能直接比身份(is)而不比内容。这个后面实测。

3. 线程安全。 不可变对象天生只读,多线程并发读取不需要加锁。这也是为什么 Python 标准库里大量用 str 传配置、路径、键名。

二、字符串池 / 驻留,本机实测

a = "hello"
b = "hello"
print(a is b)          # True  —— 编译期字面量被驻留,复用同一对象

x = "hello world"
y = "hello world"
print(x is y)          # True  —— 不止标识符,带空格的字面量也驻留

r1 = "".join(["he", "llo"])
r2 = "hello"
print(r1 is r2)        # False —— 运行时拼出来的,不会自动驻留

print(256 is 256)      # True  —— 小整数缓存区间 [-5, 256]
print(int("257") is int("257"))  # False —— 超出缓存,各建各的

注意第三行和最后一行:驻留只发生在编译期常量,"".join(...) 这种运行时构造的字符串,CPython 不会主动帮你 intern,所以 is 是 False。小整数同理,-5 到 256 是 C 层写死的缓存,超出就现建。

三、+= 到底是不是 O(n²)?实测打脸

我跑了三种写法的三路对比(N=8000 个字符):

写法 每轮耗时 比 join 慢
s = s + c(显式拼接) 1.0 ms ~18×
s += c(增强赋值) 1.095 ms ~20×
"".join(chars) 0.0543 ms 1×

注意第一行和第二行:显式 s = s + c 和 s += c 几乎一样快。这直接推翻了「+= 有优化、s=s+c 是 O(n²)」的常见说法——现代 CPython(2.4 之后)在 BINARY_ADD 和 INPLACE_ADD 处理字符串时,都走 unicode_concatenate 优化:只要左操作数引用计数为 1(也就是没人共享它),就原地扩容复用缓冲区,所以两者都是摊销 O(n),根本不是 O(n²)。

那为什么 join 还快 18 倍?差距在「分配次数」:join 先遍历一遍算出总长度,分配一次、拷贝一次;而 += 每个循环迭代都要做一次内存搬移、偶尔触发扩容。量级差在常数,不在复杂度。

放大到 N=100000:

'+='     每轮 13.3 ms
join     每轮  0.71 ms
join 比 += 快约 19 倍

四、实用结论(排错清单)

  1. 别再拿「+= 是 O(n²)」当拒绝 += 的理由——小到中等规模的循环里,+= 完全没问题,可读性还好。
  2. 真要拼海量字符串(几十万字符以上),用 list.append 攒完再 join,或者 io.StringIO;它们和 join 一样只分配一次。
  3. 不要为了「性能」过早优化:先写出来、profile 一把,再决定要不要动。我见过太多把 += 全改成 join 却毫无感知收益的代码。
  4. 我的个人推断:网上一大半「Python 慢」的传言,都是某个旧版本结论或一次误测的以讹传讹,自己 bench 一下 often 反转——这次的 += 就是典型。

要不要我把函数调用开销也拆一期?我本机实测一次空函数调用就要 45ns,下一期讲清楚这 45ns 到底烧在哪。

相关文章
|
8月前
|
人工智能 Linux API
OpenClaw全自动小红书运营实战:从0到1全流程部署、技能配置与内容自动化发布指南
在内容自动化运营场景中,OpenClaw(Clawdbot)凭借高度可扩展的Skill体系与多任务执行能力,可实现从热点追踪、文案创作、封面生成到笔记发布、互动管理的全流程自动化。本文基于2026年最新环境,完整讲解如何通过阿里云轻量服务器或本地Windows11/macOS/Linux部署OpenClaw,安装并配置小红书运营Skill,完成Cookie登录、内容生成、笔记发布、数据监控,并接入阿里云百炼Coding Plan免费大模型与QMD记忆优化系统,实现低成本、7×24小时无人值守小红书运营。全文无营销词汇,所有命令可直接复制,零基础用户也能快速跑通全流程。
3471 9
|
1天前
|
弹性计算 人工智能 双11
2026阿里云双11大促全面解读:新客专享·老客回馈·企业用户特权,热门云产品特惠合集
2026年阿里云双十一的真正智慧,不在于抢到最低的数字,而在于——先定负载类型,再匹配机型配置,最后在活动窗口内精准下单。 把技术约束放在价格之前,把长期成本算在首年之前。 从阿里云大模型服务平台的海量Token到阿里云轻量应用服务器的38元起步,从阿里云ECS云服务器的99元锚点到阿里云GPU云服务器的算力盛宴,2026年的阿里云双十一,用最少的预算,稳稳托起你的云上未来。
26 0
|
1天前
|
Java
两个时间差多久?这个Java时间差值工具类2个方法搞定
这是一个基于Java 17+的高精度时间差计算工具类,支持`ChronoUnit`与`TimeUnit`双接口,严格区分日历语义(年/月/日)与时长语义(时/分/秒/纳秒),自动处理边界、溢出及空值异常,返回`long`型结果,开箱即用。
|
15天前
|
缓存 Python Windows
Python 的 __new__ 到底管什么?和 __init__ 的分工一次讲透
__new__ 管造对象、__init__ 管初始化:11 组实验画出分工边界——两类事非 __new__ 不可(改造不可变类型构造、控制实例创建),日常 99% 初始化仍该写在 __init__。
|
2天前
|
缓存 Java Python
Python 的 int 为什么不会溢出?拆开 PyLongObject 和它背后那串变长数组
很多人背过“字典查询是 O(1)”,但为什么是常数?CPython 3.13 实测 dict.get 在 100→10 万规模恒为 0.1 微秒,而 list.index 线性涨到 609 微秒(慢 4743 倍)。本文拆开开放寻址哈希表、扰动函数与 2/3 扩容阈值把 O(1) 钉死,也讲清“创建是 O(n)”这个常被混的点。
|
23天前
|
安全 Python 容器
Python 深拷贝和浅拷贝到底差在哪?6 个让你半夜 debug 的案例 + 对照表
浅拷贝只复制第一层,嵌套的可变对象(list/dict/set)仍共享同一块内存;深拷贝递归复制所有层,新旧对象彻底解耦。6 个半夜 debug 的真实案例 + 对照表,把 copy.copy / copy.deepcopy 的坑一次讲清。
|
5天前
|
Python
Python 拼字符串为什么别在循环里用 +=?拆开 join 的两遍账:191.8 微秒与 6.8 微秒
循环 += 拼接 1000 段要 191.8 微秒,已有列表直接 join 只要 6.8 微秒:join 先扫一遍算总长再一次性分配,+= 每轮都可能复制整个已拼好的串。CPython 对 += 的原地扩容依赖引用计数,加一个别名后涨到 459.8 微秒。3 片段场景 f-string 约 251.8ns,join 反而 306.4ns。
|
6天前
|
C++ Python
Python 的函数调用到底贵在哪?拆开一次 call:51 纳秒栈帧与 *args 的 40 纳秒罚单
Python 函数调用一次约 100ns:空调用 97.8ns,其中栈帧创建机制约 51ns;*args 打包 tuple 贵 27%,**kw 建 dict 贵 15%;绑定方法比类函数调用快 40%。fib(30) 的 200ms 几乎全是调用开销,内联优化上限约 48ns。7 组实测数据。
|
29天前
|
数据库 iOS开发 MacOS
Python 模块入口入门指南:if __name__ == '__main__' 到底在防什么?
几乎每个 Python 文件都有 if __name__ == __main__,但很少有人说清它在防什么。本文用可复现的代码讲透 4 类真实事故:多进程递归崩溃、导入即连数据库、-m 与文件路径的差异、pytest 拿不到准备逻辑,并给出什么时候其实可以不写。
|
12天前
|
存储 缓存 编译器
Python 的整数为什么永远不会溢出?拆开大整数对象,看 30 位一节的存储与运算代价
Python 整数不会溢出,靠的是把数字拆成 30 位小节用对象数组存:每个整数至少 28 字节,每多 30 位加 4 字节;加法近似线性、乘法超线性——30 万位乘法是 30 位的 29 万倍;str 转换默认 4300 位护栏,to_bytes 比 str 快约 300 倍。用 9 组实验算清「无限精度」的账单。