☰
数字与字符串的不可变性:变量、对象与内存机制
2026/9/29 2:51:48 网站建设 项目流程

聊一个几乎所有写过代码的人都碰到过、但又没几个人真正想通的底层概念:不可变性(immutability)。尤其是数字和字符串这两个最基础的家伙,绝大多数新手都会栽在"给字符串赋值不就是修改吗"这种直觉陷阱里,等真到排查线上问题时才发现连对象身份都没搞清楚。

这篇文章就专门拆解数字与字符串的不可变性,讲清楚变量和对象之间的关系、内存层面到底发生了什么、为什么编程语言要这么设计,以及这些机制如何直接影响到你的日常编码。不扯源码级别的深挖,但会把原理讲到能用来排查问题的程度。无论是刚入门还没搞懂a += 1为什么不改变原对象的初学者,还是想补足底层认知的老手,都能从里面捞到点干货。

1. 先搞清楚"不可变"到底指什么

1.1 变量 vs 对象:最常见的第一层误解

很多人听到"数字不可变"第一反应是:"不对啊,我写a = 5; a = a + 1,a 明明从 5 变成了 6,哪来的不可变?"

这里必须先把概念掰开揉碎:变量是一个名字、一个标签、一个指针,它指向内存中的某个对象。而数字5和数字6,是两个完全不同的对象。a = 5做的是把变量 a 绑定到表示 5 的那个对象上;a = a + 1则是在内存中重新创建了一个表示 6 的新对象,然后让 a 改绑到新对象上。

数字本身从来没有变过——对象 5 始终是那个 5,6 也始终是那个 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 + 1,Python 都会创建一个新对象6,并可能触发内存分配。看起来轻量,实际上并不像 C 语言里int x = 5; x = 6;那样只是改写寄存器里的几个字节。

C 语言里的int是原始值类型,变量直接就是内存里的数值。你写x = 6,本质上就是把 x 所在内存位置的二进制位从0101改成0110。从这个角度看,C 的int也不是"不可变"这个语义下的对象——它只是一个"值",根本谈不上变不变,因为变量本身就是值。

而在 Python、Java、JavaScript 这类语言里,数字通常被设计成不可变对象或原始值类型:

  • Python:int是不可变对象,x += 1创建新对象。
  • Java:Integer是不可变的,int是原始值。
  • JavaScript:Number是原始值,所有运算都返回新值。

这里要注意一个关键点:不可变设计不是"技术做不到"的妥协,而是一种主动选择。要判断不可变性的价值,得看它带来的四个实际收益。

第一个收益是哈希安全性。字典/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) 扫描。

不光 Python,Java 的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) # 通常为 False,CPython 对含空格的短字面量不一定驻留 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 个,总共复制了 1+2+3+...+n = n(n+1)/2 次,即 5000 万次字符复制。

而改用列表 +join:

parts = [] 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。

这里额外给你一个可落地的规则:循环内拼接字符串数量超过几百次,或者你无法预估最终长度时,一律用收集 + join(Python)或 StringBuilder(Java)。数量很小(几次、几十次)时,直接+完全没有性能问题,不用过度设计。

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) # True

copy模块对不可变对象的处理是直接返回原引用。这不仅省内存,也避免了"拷贝后修改互不影响"这种需求根本没有存在的必要——反正谁都改不了。

再聊哈希。一个对象要作为字典的键,必须满足两个条件:可哈希(有__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)]) # config

5. 常见坑位排查与速查表

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, tasks=None): if tasks is None: tasks = [] tasks.append(task) return tasks

第四个坑:以为replace()修改了原字符串。str.replace()、str.upper()、str.strip()这些方法全都返回新字符串,原字符串保持原样。新手经常写:

email = " user@example.com " email.strip() # 你以为有处理空格了,实际上原字符串一点没变 print(repr(email)) # ' user@example.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()。这一条简单到不值一提,但能让你的代码少掉无数隐蔽的副作用。数字与字符串的不可变性,最终教会我们的其实是一件事:用一个不可变的视角看数据,变化才不会成为混乱的来源。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询