news 2026/10/23 5:17:25

Python中可变对象与不可变对象详细教程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python中可变对象与不可变对象详细教程

前言


"可变"与"不可变"是理解 Python 数据模型的第一道坎,也是最容易产生诡异 bug 的地方:明明只是把列表传进函数"改一下",函数外面的列表却也跟着变了;明明写了a = b然后改a,b却纹丝不动。这些现象背后是同一个机制——Python 的变量存的是对象的引用,而对象分成"能改"和"不能改"两类。


先澄清一个常见误解:有人以为"不可变对象就是不能变的变量"。不是。不可变(immutable)说的是对象本身的状态不可改变,但指向它的名字随时可以改指向别的对象。另外,判断"有没有产生新对象"的标准只有一个:id()变没变。本文围绕这两条讲清可变与不可变的机制、+=的两种命运,以及传参时的真实行为。示例基于 Python 3.8+。


一、哪些类型可变,哪些不可变




类型可变性说明



list可变可增删改元素

dict可变可增删改键值

set可变可增删元素

bytearray可变可原地改字节

int/float/bool不可变运算产生新对象

str不可变任何"修改"都是新建字符串

tuple不可变元素不能换,但元素本身可以可变

frozenset不可变可哈希,能当字典键

bytes不可变与bytearray相对



要注意tuple的"不可变"只到顶层:元组里的引用不能换,但引用指向的对象照样能改。


# 适用于 Python 3.8+
t = (1, [2, 3])
t[1].append(4) # 合法:改的是列表内部
print(t) # (1, [2, 3, 4])
try:
t[1] = [9] # 非法:换掉引用不行
except TypeError as e:
print("改了引用:", type(e).__name__) # TypeError

不可变类型可以直接当字典键,可变类型不行,因为字典要求键可哈希(hashable),而可变对象的哈希值会随内容变化,破坏查找。


二、id() 是判断"是否新对象"的唯一标准


id(obj)返回对象在其生命周期内的唯一标识(在 CPython 里就是内存地址)。只要id变了,就说明产生了一个新对象。


# 适用于 Python 3.8+
a = [1, 2, 3]
b = a # 只是多了一个名字,指向同一个对象
print(id(a) == id(b)) # True

b.append(4)
print(a) # [1, 2, 3, 4],因为 a 和 b 是同一个对象

c = a[:] # 切片产生新列表
print(id(a) == id(c)) # False
print(a is c) # False

is比较的就是id,即"是不是同一个对象";==比较内容是否相等。不要用is比较数值或字符串是否相等——小整数缓存和字符串驻留(interning)只是 CPython 的实现细节,不保证成立。


# 适用于 Python 3.8+
a = 256
b = 256
print(a is b) # 通常是 True:-5~256 的小整数被缓存(实现细节,不要依赖)

c = 1000
d = 1000
print(c is d) # 不要依赖这里的 True/False——是否复用同一对象是实现细节
print(c == d) # True:比较内容永远可靠

注意:直接写256 is 256这类字面量之间的is比较,从 Python 3.8 起会触发SyntaxWarning,因为它几乎总是写错了。要比较就统一用==;is只留给None、True、False以及自定义的哨兵对象。


三、+= 有两种命运:原地,还是新建


x += y的行为取决于类型:



  • 如果类型实现了__iadd__(如list),走原地修改,id不变;

  • 否则退化为x = x + y,新建对象并把名字重新绑定。


# 适用于 Python 3.8+
lst = [1, 2]
print(id(lst))
lst += [3] # 列表:原地扩展
print(id(lst)) # 和上面一样,id 没变

tup = (1, 2)
print(id(tup))
tup += (3,) # 元组:等价于 tup = tup + (3,),新对象
print(id(tup)) # id 变了

s = "ab"
print(id(s))
s += "c" # 字符串不可变:新对象
print(id(s)) # id 变了

这解释了一个经典现象:函数里lst += [x]会影响外部,因为改的是同一个列表;而tup += (x,)只是把函数内的名字重新绑定,外部看不到。


四、传参传的是什么


Python 的传参既不是"值传递"也不是"引用传递",准确说法是按对象引用传递(call by object reference),也叫"按赋值传递":形参是实参对象的一个新名字。



  • 函数内修改对象的内部→ 外部可见(前提是对象可变)。

  • 函数内给形参重新赋值→ 只是把局部名字指向别处,外部不受影响。


# 适用于 Python 3.8+
def mutate(items):
items.append("新元素") # 改内部,外部可见


def rebind(items):
items = ["完全新列表"] # 重新绑定,外部不受影响


data = ["原始"]
mutate(data)
print(data) # ['原始', '新元素']

rebind(data)
print(data) # ['原始', '新元素'],没变

想"复制一份再改",必须显式复制:list(x)、x.copy()、x[:]做浅拷贝(shallow copy),copy.deepcopy(x)做深拷贝(deep copy)。


五、不可变的代价与收益


不可变对象需要新建才能"修改",所以频繁拼接字符串、反复对大整数做运算,都会产生大量临时对象。经典反例是循环里用s += piece拼字符串;更好的做法是先把片段收进列表,最后"".join(parts)一次拼好——因为字符串拼接在频繁扩容时开销明显,而join只需一次分配。


# 适用于 Python 3.8+
parts = ["a", "b", "c"]
result = "".join(parts)
print(result) # abc

# 需要可变字符序列时,可先用 bytearray 或列表
buf = bytearray()
buf.extend(b"abc")
buf.append(ord("d"))
print(bytes(buf)) # b'abcd'

不可变的收益同样明显:值不会在背后被改掉,可以被多个名字安全共享,还能进set、当字典键。默认优先用不可变类型,需要可变时再换成列表或字典,是减少 bug 的有效习惯。


常见坑点


坑 1:可变默认参数被所有调用共享。


❌


# 适用于 Python 3.8+(反例)
def add(item, box=[]):
box.append(item)
return box

第一次add(1)得到[1],第二次add(2)得到[1, 2]——默认值在定义时创建,只有一份。


✅def add(item, box=None):,函数体里box = [] if box is None else box。


坑 2:以为赋值会复制对象。


❌b = a后改b,以为a不受影响(当a是列表时)。


✅ 记住b = a只是多一个名字;要独立用a.copy()或a[:]。


坑 3:用is判断内容相等。


❌if name is "admin":—— 字符串驻留是实现细节,随时可能不成立。


✅ 用if name == "admin":;is只留给None、True、False和哨兵对象。


坑 4:对元组元素做"修改"。


❌ 以为t = (1, 2); t += (3,)是原地改 —— 实际新生成了元组。


✅ 需要频繁增删就用列表;元组保持不可变语义。


坑 5:浅拷贝当深拷贝用。


❌ 嵌套列表用copy.copy或切片,改内层子列表时原对象也变了。


✅ 嵌套结构用copy.deepcopy;并注意深拷贝会递归复制、可能有循环引用开销。


坑 6:把可变对象当字典键或集合元素。


❌d = {[1, 2]: "x"}—— 抛TypeError: unhashable type: 'list'。


✅ 换成元组(1, 2)做键。


坑 7:在循环里反复s += piece拼长字符串。


❌ 每次拼接都新建字符串,累积大量临时对象。


✅ 收集片段后用"".join(parts)一次拼好。


总结




概念结论



可变类型list/dict/set/bytearray

不可变类型int/float/str/tuple/frozenset/bytes

判断是否新对象只看id()变没变

isvs==is比身份(id),==比内容

+=有__iadd__就原地,否则新建并重绑定

传参按对象引用传递;改内部可见,重新绑定不可见

复制浅拷贝copy()/[:];深拷贝copy.deepcopy()



一句话总结:可变与不可变的全部现象,都能用"变量是引用、id判身份、能改则原地、不能改则新建"这四句解释。写代码时默认选不可变类型、给可变默认参数换成None哨兵、需要独立副本时显式拷贝,就能躲开这一大类 bug。


(补充:小整数缓存与字符串驻留都是 CPython 的实现细节,官方不保证;Python 3 与 Python 2 在这一点的行为一致,但 Python 2.7 已于 2020 年 1 月 1 日停止维护,无需再作兼容考虑。)




版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 5:17:13

Python中函数的创建及调用

前言 函数在 Python 里有两种诞生方式:用 def 起名创建,或用 lambda 匿名创建。创建之后,它就是一个普通的对象——可以赋值、可以放进列表、可以当参数传给别的函数、也可以被函数返回。调用则更丰富:按位置调、按关键字调、用 *…

作者头像 李华
网站建设 2026/10/18 2:05:58

COMSOL声学模型超声无损检测仿真:核心思路与完整建模

从事超声无损检测仿真这几年,我越来越觉得COMSOL是最适合把这个领域“讲清楚”的工具。声学模型看着门槛不高,但真要拿它复现一次超声波探伤过程、算出A扫描信号,中间涉及物理场耦合、网格、时间步长和边界吸收的讲究特别多。今天我就把COMSO…

作者头像 李华
网站建设 2026/10/18 20:58:06

PostgresML 自托管完全指南:从 APT 安装到 GPU 加速与 pgvector 实战

后端人工智能机器学习RAG向量数据库 【免费下载链接】postgresml Postgres with GPUs for ML/AI apps. 项目地址: https://gitcode.com/gh_mirrors/po/postgresml 点击查看 免费下载 PostgresML 本质上是 PostgreSQL 的一个扩展,因此自托管 PostgresML …

作者头像 李华
网站建设 2026/10/18 20:58:08

OpenEuler Shell脚本起步:核心语法与调试排错实战

刚接触 OpenEuler 的 Shell 编程时,很多人觉得脚本不过就是把几条 Linux 命令按顺序写进一个文件,然后执行一下,仅此而已。这个理解不算错,但真正动手之后你会发现,一个看起来再简单的起步脚本,里面也藏着解…

作者头像 李华
网站建设 2026/10/18 20:58:07

前缀和与哈希表:LeetCode 560题和为K的子数组最优解

LeetCode Hot 100 里的 560 题“和为 K 的子数组”,我前前后后刷了三遍,每次都有新的理解。这道题在面试和算法学习中出现的频率极高,看起来只是“数一数有多少个连续子数组的和等于 k”,可真正把它讲透的人不多。很多解法上来就丢…

作者头像 李华
网站建设 2026/10/18 23:08:23

提示词进阶实战:从拆解任务到驾驭多模态生成

不聊虚的,直接聊怎么用。这篇是提示词系列第四篇,前几篇把角色设定、上下文管理、输出约束这些基础过了一遍,这篇集中讲进阶场景:复杂任务拆解、编程场景提示词、多模态生成提示词,还有最常见的报错现场。核心就一句话…

作者头像 李华