python数据类型可变与不可变深入分析
发布时间:2026/10/11 10:45:53 锦皓数字建站

前言「可变与不可变」是 Python 里最基础、也最容易被人以为自己懂了的知识点。很多人能背出「list 可变、tuple 不可变、str 不可变」却在实际写代码时踩中可变默认参数、浅拷贝、别名共享这几类经典陷阱。原因在于可变性不是一条孤立的性质它跟身份、赋值、函数传参、拷贝这四件事连在一起单独记结论没用。这里要先纠正一个常见的模糊说法「Python 是传值还是传引用」标准答案是都不准确。Python 的参数传递通常被描述为「传对象引用」call by object reference或「传共享」call by sharing。函数拿到的是实参所指对象的引用副本也就是说函数内外的名字指向同一个对象但函数内给形参重新赋值只改变本地的名字不影响调用方。本文按「怎么判断可变性 → 身份怎么看 → 传参怎么发生 → 怎么拷贝」的顺序讲透这条链路。示例均为 Python 3 写法涉及实现细节的地方会明确标注。一、怎么判断可变与不可变判断标准只有一条能不能在不新建对象的前提下改变这个对象的值。不可变int、float、bool、str、bytes、tuple、frozenset、range。可变list、dict、set、bytearray。看具体操作。lst.append(1)之后id(lst)不变——原地改了。s a得到一个新字符串原字符串id不变、内容也不变。# 适用于 Python 3.8lst [1, 2]before id(lst)lst.append(3)print(before id(lst)) # Truelist 原地修改s abbefore_s id(s)s2 s cprint(s) # ab原字符串不变print(before_s id(s2)) # False得到的是新对象不可变对象还有一个「副作用」因为不可变所以能安全地被共享、被缓存也因此可哈希。这正是 tuple 能当字典的键、list 不能的原因。二、id() 与身份什么时候会新建对象id(obj)返回对象在内存中的标识在对象存活期间保持不变。is运算符比较的就是这个身份。用它能看出「什么时候悄悄新建了对象」。# 适用于 Python 3.8a [1, 2, 3]b a # 别名同一对象print(a is b) # Truec a[:] # 切片产生新列表print(a is c) # Falseprint(a c) # True内容相同但身份不同d a.copy() # 同样是新列表print(a is d) # False几个容易混淆的地方小整数缓存。CPython 会预先缓存 -5 到 256 之间的整数对象所以在这个范围内a 100; b 100; a is b为True。这是实现细节不要依赖。超出范围后同样的写法可能就变成False。字符串驻留。同样地看起来「相同」的字符串有时是同一个对象有时不是这也是实现细节。因此有一条硬规则is只用来判断身份最典型的是is None比值一律用。三、函数传参共享到了什么程度函数调用时形参和实参指向同一个对象。于是如果函数原地修改这个对象append、extend、d[k] v调用方能看到变化。如果函数给形参重新赋值x ...只是让本地的名字改指别处调用方不受影响。# 适用于 Python 3.8def mutate(items):items.append(new) # 原地改影响调用方def rebind(items):items items [new] # 重新绑定不影响调用方data [a]mutate(data)print(data) # [a, new]data2 [a]rebind(data2)print(data2) # [a]没变理解这一点就能明白为什么有时「函数偷偷改了传进去的列表」会让人抓狂调用方以为传的是副本其实传的是同一个对象。要避免这种意外可以在函数入口先list(items)复制一份或者约定「需要新结果就显式返回」。四、可变默认参数最经典的陷阱# 适用于 Python 3.8def add_item(item, bucket[]): # 危险写法bucket.append(item)return bucketprint(add_item(a)) # [a]print(add_item(b)) # [a, b]不是 [b]原因是默认值表达式只在def执行时求值一次之后每次调用都复用同一个列表对象。第一次调用往里面 append 了a这个对象就一直是[a]。正确写法是用None作默认值在函数体里判断。# 适用于 Python 3.8def add_item(item, bucketNone):if bucket is None:bucket []bucket.append(item)return bucketprint(add_item(a)) # [a]print(add_item(b)) # [b]None本身不可变作为哨兵值不会被污染。判断要用is None不要用 None。五、浅拷贝与深拷贝copy.copy()做浅拷贝新建外层容器但里面的元素还是原来那些对象。copy.deepcopy()做深拷贝递归地复制所有层级。# 适用于 Python 3.8import copyoriginal [[1, 2], [3, 4]]shallow copy.copy(original)shallow[0].append(99) # 改的是内层列表两层都受影响print(original) # [[1, 2, 99], [3, 4]]original2 [[1, 2], [3, 4]]deep copy.deepcopy(original2)deep[0].append(99)print(original2) # [[1, 2], [3, 4]]不受影响方式外层容器内层对象何时用b a共享共享确实要用别名时a.copy()/a[:]/copy.copy(a)新对象共享只改外层结构时copy.deepcopy(a)新对象新对象内层也会被改时deepcopy内部用一张 memo 表记录「已经复制过的对象」所以它能正确处理自引用和循环引用——不会因为 A 引用 B、B 又引用 A 而无限递归。但遇到不可深拷贝的对象比如打开的文件对象、某些系统资源句柄会抛异常这一点要提前知道。常见坑点用可变对象当默认参数。❌def f(x, lst[]):—— 默认值只求值一次多次调用共享同一个列表数据会累积。✅ 默认写None函数体内if lst is None: lst []。以为赋值就是复制。❌b a之后改b发现a也变了还以为是解释器的问题。✅ 赋值只是给同一个对象再起一个名字要独立副本用list(a)、a.copy()或a[:]。浅拷贝之后就放心改嵌套结构。❌copy.copy(matrix)之后改matrix[0][0]原对象跟着变——浅拷贝只复制了外层。✅ 嵌套可变结构要用copy.deepcopy()或者自己逐层构造新对象。在函数里给形参重新赋值以为能改到调用方。❌def add(lst): lst lst [1]—— 只是本地重新绑定调用方的列表纹丝不动。✅ 想影响调用方就原地改lst.append(1)、lst [1]对 list 是原地或者把新对象return回去。用is比较值还依赖小整数缓存。❌if x is 300:—— 小整数缓存只覆盖 -5 到 256这是 CPython 实现细节写不得。✅ 比值用is只用于is None这类身份判断。把可变对象当 dict 键或放进 set 后又改了它。❌d[obj] 1之后修改obj的属性再去d[obj]就找不到了。✅ 能当键的对象一旦用上就别再改或者干脆用内容本身字符串、tuple当键。以为深拷贝一定「永远可用、永远不共享」。❌ 对包含文件句柄或自定义资源的对象直接deepcopy抛异常或复制出无意义的副本。✅ 这类对象实现自己的复制协议或改设计只传必要的数据真要共享就用浅拷贝。把不可变对象当作「不会出问题」的免检项。❌ 以为传 tuple 给函数就绝对安全忽视了 tuple 里面的 list 依然能被原地改。✅ 不可变只作用到直接元素这一层要彻底只读内层也得是不可变类型。总结概念结论判断可变性能否不新建对象就改值id()与is身份判断仅用于is None等场景赋值b a别名共享同一对象函数传参传对象引用副本原地改有效重新绑定无效默认参数求值时机只在def时求值一次故不能用可变对象浅拷贝外层新、内层共享深拷贝递归复制能处理循环引用但要求对象可复制一条贯穿全文的线索是别名带来的共享只在「原地修改」时才暴露。所以每次写a b、每次往函数里传 list、每次写下def f(x[])都停一秒问自己这里到底是共享还是复制问清这一点这类 bug 大半就避开了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。