
聊一个几乎所有写过代码的人都碰到过、但又没几个人真正想通的底层概念不可变性immutability。尤其是数字和字符串这两个最基础的家伙绝大多数新手都会栽在给字符串赋值不就是修改吗这种直觉陷阱里等真到排查线上问题时才发现连对象身份都没搞清楚。这篇文章就专门拆解数字与字符串的不可变性讲清楚变量和对象之间的关系、内存层面到底发生了什么、为什么编程语言要这么设计以及这些机制如何直接影响到你的日常编码。不扯源码级别的深挖但会把原理讲到能用来排查问题的程度。无论是刚入门还没搞懂a 1为什么不改变原对象的初学者还是想补足底层认知的老手都能从里面捞到点干货。1. 先搞清楚不可变到底指什么1.1 变量 vs 对象最常见的第一层误解很多人听到数字不可变第一反应是不对啊我写a 5; a a 1a 明明从 5 变成了 6哪来的不可变这里必须先把概念掰开揉碎变量是一个名字、一个标签、一个指针它指向内存中的某个对象。而数字5和数字6是两个完全不同的对象。a 5做的是把变量 a 绑定到表示 5 的那个对象上a a 1则是在内存中重新创建了一个表示 6 的新对象然后让 a 改绑到新对象上。数字本身从来没有变过——对象 5 始终是那个 56 也始终是那个 6。变的只是变量的绑定关系。这就好比一旁放着写着5的卡片和一摞写着6的卡片你把手里的标签从5那张卡片上撕下来贴到6那张卡片上。卡片没有改是标签换了位置。同样字符串s hello执行s s world并不是在原来的hello后面追加内容而是重新创建了一个hello world字符串对象并把s指向它。原来的hello对象仍然存在于内存中如果没有其他引用后续会被垃圾回收。理解这层关系是理解不可变性的前提。如果你还停留在变量就是装着数据的盒子这个认知阶段后面所有内容看起来都会矛盾重重。1.2 不可变对象的生活化类比要对不可变性建立直觉可以用一个生活类比想想你身份证上的号码。身份证号在你出生时就定了你不可能修改它——你只能换一张新身份证上面的号码也是同一个。某个特定身份证号一旦存在它永远代表那个人内容不会变。或者类比一首已发表的歌曲的音频文件刻录成 CD 之后这个 CD 上的数据固定了。你想听不同版本只能再刻一张新盘原始盘上的内容一动没动。编程里的不可变对象就是这样的固定盘。一旦创建它的内容就定死了对数字来说5永远是5你没法让对象5变成6。对字符串来说hello永远是hello你没法原地把第三个字符改成x。这一点跟列表截然不同。列表是可变对象你可以原地修改lst[0] 100直接改的是同一个列表对象的内容列表的身份标识不变。这也是为什么列表中经常能原地操作而字符串、数字只能生成新对象。2. 数字的不可变性底层存储与对象模型2.1 整数对象在内存里长什么样以 Python 为例整数在 CPython 中对应PyLongObject结构体。这段结构体不仅存储数值本身还包含引用计数Py_ssize_t和类型指针ob_type。也就是说在 Python 这类动态语言里每个整数都是一个完整的对象而不是单纯一个寄存器里的二进制补码。正因如此每次你写5 1Python 都会创建一个新对象6并可能触发内存分配。看起来轻量实际上并不像 C 语言里int x 5; x 6;那样只是改写寄存器里的几个字节。C 语言里的int是原始值类型变量直接就是内存里的数值。你写x 6本质上就是把 x 所在内存位置的二进制位从0101改成0110。从这个角度看C 的int也不是不可变这个语义下的对象——它只是一个值根本谈不上变不变因为变量本身就是值。而在 Python、Java、JavaScript 这类语言里数字通常被设计成不可变对象或原始值类型Pythonint是不可变对象x 1创建新对象。JavaInteger是不可变的int是原始值。JavaScriptNumber是原始值所有运算都返回新值。这里要注意一个关键点不可变设计不是技术做不到的妥协而是一种主动选择。要判断不可变性的价值得看它带来的四个实际收益。第一个收益是哈希安全性。字典/Map 的键必须可哈希而哈希的前提是对象状态稳定。如果5作为键放进字典后某段代码偷偷把5的哈希值改了整个字典的查找逻辑立刻崩溃。不可变性保证了哈希永远有效。第二个收益是引用共享。如果对象不可变两个变量完全可以让它们指向同一个对象而不用担心一方修改影响另一方。CPython 对小整数通常是 -5 到 256做了缓存驻留所有变量引用5时实际都指向同一个PyLongObject。因为不可变这种共享才绝对安全。第三个收益是线程安全。多线程环境下读一个不可变对象永远不需要加锁因为没有任何线程能改它。第四个收益是逻辑可预测。代码里出现100它就永远是100不会因为某个函数内部改了它而导致外层数据受到意外影响。2.2 小整数驻留与地址复用CPython 里有个经典机制小整数对象池。解释器启动时会预先创建好 -5 到 256 之间的整数对象这些对象全局共享。a 100 b 100 print(a is b) # True因为都是同一个缓存对象 c 257 d 257 print(c is d) # False超出驻留范围各自创建新对象is比较的是对象身份内存地址比较的是值。第一次运行时a is b结果为 True就是因为 100 在驻留范围内a 和 b 都引用同一个缓存对象。而 257 不在范围内每次都新建对象。这个机制最直接的实际影响就是不要用is比较整数。你以为if x is 256可能没问题但if x is 257随时可能因为对象不同而返回 False。这也是面试中非常经典的一个考点背后就是不可变性 驻留机制协同作用的结果。顺带一提字符串也有类似机制叫做字符串驻留intern后面细讲。2.3 为什么数字必须不可变围绕为什么数字必须设计成不可变有一个非常直观的自反论证如果数字可变那2这个对象可以被修改成3那1 1 2这条数学事实就变得不可靠了。等式右边可能在你算到一半时变成 3。更实际一点在数据库、缓存、科学计算中数字被海量共享引用。一旦允许原地修改一个模块修改了共享数字对象所有引用它的模块全部跟着变排查这种 bug 的成本高到无法接受。数学上数值是永恒的、抽象的、不能在时间轴上被改写的存在。编程语言把这个哲学直觉落到了类型系统里——数字的每个值都是一个独立的、固定的事实。对比一下可变对象有多麻烦就知道了列表lst [1, 2, 3]你传给一个函数函数里lst.append(4)外面的列表立刻多了一个元素。传参时如果不刻意复制一份你根本没有能力阻止函数内部修改你的数据。这正是很多多线程 bug 和隐蔽数据污染的根源。所以数字的不可变性不是限制而是保护。3. 字符串的不可变性结构、驻留与性能3.1 字符串的内存布局字符串在 Python 中是一个包含了字符序列、长度、哈希缓存等信息的对象。在 CPython 的实现里PyUnicodeObject不仅存着字符数据还有一个缓存的哈希值字段hash。这个哈希缓存就是字符串不可变带来的关键红利因为字符串不可变解释器可以放心地在第一次计算哈希后缓存结果后续所有字典查找、集合去重都直接复用。如果字符串可变这个缓存就必须每次重新计算或者干脆不能缓存否则要么读到脏数据要么每次查找都 O(n) 扫描。不光 PythonJava 的String同样把hashCode做了缓存。JDK 源码里String类有个private int hash字段第一次调用hashCode()时计算并保存后续直接返回。对比一下就知道C 语言里的字符串本质是char[]也就是一个字符数组完全可变。你可以直接str[0] X原地改掉内容。这种设计给了极致灵活性但代价是C 字符串不能作为可靠的哈希键除非你每次拷贝一份再哈希、多线程共享时必须做同步、以及大量因就地修改导致的缓冲区漏洞。C 的字符串设计是历史产物灵活性优先安全性靠程序员自己保证。而高级语言普遍选择字符串不可变核心原因可以安全共享传参不用拷贝。可以安全作为字典键。线程安全。实现哈希缓存提升查找性能。3.2 字符串驻留何时是同一个对象字符串跟小整数一样有驻留机制但触发条件更微妙。CPython 里看起来像标识符的短字符串字母、数字、下划线组成可能会被驻留而包含空格、特殊字符或较长的字符串通常不会。s1 hello s2 hello print(s1 is s2) # 通常为 True字面量命中了驻留 s3 hello world s4 hello world print(s3 is s4) # 通常为 FalseCPython 对含空格的短字面量不一定驻留 s5 .join([he, llo]) print(s1 is s5) # False运行期动态创建的字符串不一定复用驻留对象不同 Python 版本、不同实现PyPy、MicroPython对驻留的细节策略不同。所以千万别在业务代码里依赖字符串驻留的判定但了解它能帮你理解一些诡异现象为什么两个内容一样的字符串is比较有时是True有时是False。驻留的收益是在重复创建相同短字符串时节省内存。比如代码里 1000 次执行x error如果每次都新建一个字符串对象浪费内存驻留后1000 个变量全部指向同一个error对象。这背后同样依赖不可变性——只有内容不会变才能放心共享。3.3 不可变字符串的拼接性能问题这是不可变性带来最实际、最让新手抓狂的性能陷阱。因为字符串不可变每次拼接都会创建一个全新的字符串对象并复制两份原始内容。s for i in range(10000): s str(i)这段代码的复杂度是 O(n²)。第一轮循环复制 1 个字符第二轮复制 2 个第三轮复制 3 个……第 n 轮复制 n 个总共复制了 123...n n(n1)/2 次即 5000 万次字符复制。而改用列表 joinparts [] for i in range(10000): parts.append(str(i)) s .join(parts)append是原地操作列表可变复杂度 O(1) 均摊最后的join只需一次性分配结果空间再扫描一遍拼接总体复杂度 O(n)。这是我实际踩过最深的坑之一。早年写文本处理脚本拿拼几万个片段跑了十几秒换成join后秒开。后来做 Java 时又踩了一次Java 的String同样不可变拼接虽然编译器会优化成StringBuilder但在循环里反复拼接的优化效果并不总是理想的。正确姿势是直接在循环外创建StringBuilder循环里append。这里额外给你一个可落地的规则循环内拼接字符串数量超过几百次或者你无法预估最终长度时一律用收集 joinPython或 StringBuilderJava。数量很小几次、几十次时直接完全没有性能问题不用过度设计。4. 不可变性对日常编码的连锁影响4.1 赋值、传参引用语义下发生了什么Python 里所有变量都是引用。对于不可变对象这个引用语义和值语义在表面上没有区别def add_one(n): n 1 return n x 10 y add_one(x) print(x) # 10原值不受影响函数内部n 1做的是创建新对象 11让 n 指向它。x 仍然指向 10所以外部完全无感。这种表现让人误以为 Python 是值传递。实际上它传递的是引用的副本但因为指向的是不可变对象所以操作不会影响原对象效果上和值传递一致。一旦换成可变对象引用语义立刻暴露def add_one_to_list(lst): lst.append(100) data [1, 2, 3] add_one_to_list(data) print(data) # [1, 2, 3, 100]原对象被修改了这就是为什么在 Python 中面试常问传值还是传引用——标准答案应该是传引用但对不可变对象的行为表现为传值。理解了数字和字符串的不可变性这类问题直接秒杀。还有一个实践建议如果函数需要修改字符串或数字别指望原对象被改直接把新值作为返回值传出去。这是符合不可变语义的规范做法。4.2 运算符的隐藏陷阱对不可变对象和可变对象的行为完全不同# 数字 a 10 b a a 5 # a 15, b 10。a 指向新对象b 仍指向原对象。 # 字符串 s hello t s s world # s hello world, t hello # 列表 lst1 [1, 2] lst2 lst1 lst1 [3] # lst1 和 lst2 都是 [1, 2, 3]因为列表的 是原地修改对于不可变对象a x等价于a a x变量被重新绑定。对于列表lst [3]调用的是__iadd__底层就是extend原地修改不生成新对象。这个区别在生产环境中影响巨大。比如你有两个变量引用同一个字符串对其中一个做操作另一个完全不受影响但同样的操作放在两个引用同一列表的变量上另一个也会跟着变。很多数据同步的 bug 就是这种明明改了一个另一个为什么也变了造成的。4.3 深浅拷贝与哈希不可变带来的便利先聊拷贝。因为不可变对象无法被修改拷贝一个不可变对象实际上没有意义——两个变量指向同一个对象完全安全。import copy a hello b copy.deepcopy(a) print(a is b) # True拷贝直接返回原对象 c (1, 2, 3) # 元组也是不可变 d copy.deepcopy(c) print(c is d) # Truecopy模块对不可变对象的处理是直接返回原引用。这不仅省内存也避免了拷贝后修改互不影响这种需求根本没有存在的必要——反正谁都改不了。再聊哈希。一个对象要作为字典的键必须满足两个条件可哈希有__hash__且不可变。数字和字符串天然满足这两条所以它们一直都是最主流的字典键类型。为什么可变对象不能作为字典键假设你写lst [1, 2]把它放进字典d {lst: value}。然后执行lst.append(3)列表的哈希值可能变化如果它实现了基于内容的哈希但它在字典中的位置还是按旧哈希值计算的查找时算出来的新哈希值对应的槽位根本找不到这个键。字典直接乱掉。实际踩坑场景你想用列表作为一组配置的键用了lst当键结果运行时报TypeError: unhashable type: list——这不是语言限制太狠这是在保护你的字典不陷入逻辑混乱。正确的替代方案是把列表转换成元组不可变版本再当键。# 错误示范 # d {[1, 2, 3]: config} # TypeError # 正确做法 d {(1, 2, 3): config} print(d[(1, 2, 3)]) # config5. 常见坑位排查与速查表5.1 五个高频踩坑场景第一个坑用is比较字符串或整数。很多人看了一些驻留机制的文章就喜欢用is判断两个字符串是否相等。但驻留策略在不同 Python 实现、不同代码路径中表现不一致同一份代码换个环境结果都可能不同。判断相等永远用判断身份只有在你明确知道对象来自同一处时才用is。# 危险示例 if s1 is s2: # 依赖于驻留不可靠 ... # 安全写法 if s1 s2: ...第二个坑循环拼接字符串。前面用复杂度公式展示过O(n²) 在小规模数据上没感觉但到几万、几十万次时直接卡死。排查方法先在代码里搜和字符串变量拼接的循环替换成join或StringBuilder。第三个坑默认参数误用可变对象。这个坑是不可变性的对立面——官方文档警告过无数次但依然人人踩def add_task(task, tasks[]): # 默认参数是可变列表 tasks.append(task) return tasks print(add_task(a)) # [a] print(add_task(b)) # [a, b]共享了同一个默认列表函数定义时默认参数[]就被创建了后续每次调用都会复用这个同一个列表对象。因为列表可变每次追加都在污染这个默认值。修复方法是把默认参数改成不可变对象None函数内部再创建新列表def add_task(task, tasksNone): if tasks is None: tasks [] tasks.append(task) return tasks第四个坑以为replace()修改了原字符串。str.replace()、str.upper()、str.strip()这些方法全都返回新字符串原字符串保持原样。新手经常写email userexample.com email.strip() # 你以为有处理空格了实际上原字符串一点没变 print(repr(email)) # userexample.com 正确做法是email email.strip()把返回值赋回去。这个坑的本质就是你还没吃透字符串不可变这一条。第五个坑在循环里反复用拼接来构造 SQL、JSON、HTTP 报文导致大量临时对象产生GC 压力巨大。大规模文本组装场景直接用模板引擎或字符串构建器性能能提升几十倍不止。5.2 可变与不可变对象对照表类别典型类型原地修改身份id哈希适合做键常见操作影响不可变数字int/float不支持不变运算产生新对象支持完美重新绑定不可变字符串str不支持不变方法返回新对象支持有缓存完美创建新对象不可变元组tuple不支持不变支持元素必须可哈希可以无法增删改可变列表list支持不变不支持unhashable不行append原地改可变字典dict支持不变不支持unhashable不行d[k]v原地改可变集合set支持不变不支持unhashable不行add原地改这套对照表在代码审查时可以当检查清单用。看到某个类型的方法调用后马上想一下它是返回新对象还是原地修改能帮你避免一大半由不可变性理解不到位引发的逻辑错误。5.3 几个实用检测技巧在 Python 中排查不可变性相关的困惑用三个内置工具就能弄清绝大部分情况id()获取对象身份内存地址的封装。a a 1前后id(a)变了说明变量指向了新对象。is判断两个变量是否指向同一个对象。sys.getrefcount(obj)查看对象的引用计数。你可以观察字符串/整数被多少变量引用理解驻留和共享的规模。import sys s hello print(sys.getrefcount(s)) # 通常远大于 1说明大量脚本共用同一个驻留对象 n 100 print(sys.getrefcount(n)) # 同样很高因为小整数被很多地方引用还有一个很实用的排查习惯在写代码前先问自己一句——这个对象我能原地改吗如果不能那我需要把新值接住。这种习惯一旦养成不可变性相关的问题基本与你无缘。收个尾我在实际项目中的一点体会做后端和数据处理这些年我越来越觉得不可变性这个概念不是教科书里抽象的理论而是所有健壮代码的地基之一。数字和字符串的不可变性是入门第一课但偏偏是生产环境 bug 的一大来源。个人最深的体会是宁可显式重新赋值也不要依赖隐式修改的错觉。我见过太多因为默认参数、陷阱、字符串方法返回值没接住而引发的线上事故每一类都在本文的坑位清单里。排查这类问题的套路其实都一样先用id()确认变量到底指向哪个对象再判断操作是原地改还是生成新对象问题一目了然。最后再分享一个编码习惯给函数传字符串和数字时大胆放心传不需要考虑保护性拷贝但传列表、字典时先想清楚函数内部会不会改它如果不希望被改主动传copy.copy()或copy.deepcopy()。这一条简单到不值一提但能让你的代码少掉无数隐蔽的副作用。数字与字符串的不可变性最终教会我们的其实是一件事用一个不可变的视角看数据变化才不会成为混乱的来源。