Python 可变对象与不可变对象深度解析:为什么 `tuple` 里可以放 `list`?
Python 可变对象与不可变对象深度解析为什么tuple里可以放list在 Python 编程中很多看似“玄学”的问题最终都能回到一个核心概念对象。比如a[1,2,3]ba b.append(4)print(a)# [1, 2, 3, 4]再比如t([1,2],3)t[0].append(99)print(t)# ([1, 2, 99], 3)不少初学者看到第二段代码时会疑惑tuple 不是不可变对象吗为什么 tuple 里面的 list 还能被修改这篇文章就来把这个问题讲透。它不仅是 Python 基础语法问题更关系到函数参数、默认值陷阱、字典键、缓存设计、并发安全、数据建模等实际开发场景。一、先理解一句话Python 中一切皆对象在 Python 里变量并不是“盒子”对象也不是“变量的一部分”。更准确地说变量名只是一个标签它绑定到某个对象。例如x[1,2,3]可以理解为变量名 x --- 列表对象 [1, 2, 3]当我们写yx并不是复制了一个新列表而是让y也指向同一个列表对象x ----\ --- [1, 2, 3] y ----/所以x[1,2,3]yx y.append(4)print(x)# [1, 2, 3, 4]print(y)# [1, 2, 3, 4]因为x和y引用的是同一个对象。我们可以用id()查看对象身份x[1,2,3]yxprint(id(x))print(id(y))print(xisy)# Trueis比较的是两个变量是否指向同一个对象而比较的是值是否相等。a[1,2]b[1,2]print(ab)# True内容相等print(aisb)# False不是同一个对象这一区别是理解可变对象和不可变对象的第一把钥匙。二、什么是可变对象所谓可变对象是指对象创建之后其内部内容可以被原地修改。常见可变对象包括list dict set bytearray 自定义类的大多数实例例如列表numbers[1,2,3]print(id(numbers))numbers.append(4)print(numbers)print(id(numbers))你会发现调用append()后列表内容变了但对象身份通常没有变。也就是说修改发生在原对象内部原列表对象 [1, 2, 3] 变成 原列表对象 [1, 2, 3, 4]再看字典user{name:Alice,age:18}print(id(user))user[age]19user[city]Shanghaiprint(user)print(id(user))字典新增或修改键值对也是在原对象上完成的。三、什么是不可变对象不可变对象是指对象创建之后其内部状态不能被修改。常见不可变对象包括int float bool str tuple frozenset bytes None例如整数x10print(id(x))x1print(x)print(id(x))很多人以为x 1是把原来的整数对象从10改成了11。其实不是。整数对象10本身不会被修改。Python 会创建或绑定到另一个整数对象11然后让变量名x指向它。字符串也是一样shelloprint(id(s))s worldprint(s)print(id(s))字符串拼接后看起来s被修改了实际上原字符串hello没变s只是重新绑定到了一个新字符串对象。这就是不可变对象的本质不是变量不能变而是变量指向的那个对象本身不能变。四、可变与不可变的核心区别我们可以用一张表来总结类型创建后内容能否原地修改典型类型常见操作可变对象可以list、dict、setappend、update、add不可变对象不可以int、str、tuple重新赋值、创建新对象看代码最直观# 可变对象a[1,2,3]old_idid(a)a.append(4)print(a)# [1, 2, 3, 4]print(id(a)old_id)# True# 不可变对象sabcold_idid(s)sdprint(s)# abcdprint(id(s)old_id)# 通常为 False对开发者来说关键不在于背诵哪些类型可变、哪些类型不可变而在于理解可变对象会带来共享修改不可变对象会带来重新绑定。五、为什么tuple是不可变对象tuple的不可变性指的是tuple 中每个位置保存的引用不可改变。例如t(1,2,3)t[0]100会报错TypeError:tupleobjectdoesnotsupport item assignment因为你试图改变 tuple 第 0 个位置所保存的引用。我们可以把 tuple 想象成一个固定长度的引用容器t (obj1, obj2, obj3) 索引 0 --- obj1 索引 1 --- obj2 索引 2 --- obj3tuple 不允许你把某个格子从obj1换成objX也不允许增加或删除格子。所以这些操作都不行t(1,2,3)# 不允许修改元素引用# t[0] 100# 不允许删除元素# del t[1]# 不允许 append# t.append(4)这就是 tuple 的不可变性。六、为什么tuple里可以包含可变对象关键来了。看这段代码t([1,2],Python)t[0].append(3)print(t)输出([1,2,3],Python)这是不是说明 tuple 被修改了答案是tuple 本身没有被修改tuple 里面引用的 list 被修改了。我们可以画成这样t | v tuple 对象 索引 0 --- list 对象 [1, 2] 索引 1 --- str 对象 Python执行t[0].append(3)发生的不是把 t[0] 换成另一个对象而是通过 t[0] 找到那个 list然后修改 list 内部内容修改后t | v tuple 对象 索引 0 --- 同一个 list 对象 [1, 2, 3] 索引 1 --- 同一个 str 对象 Pythontuple 中保存的两个引用没有变。第 0 个位置仍然指向原来的 list。第 1 个位置仍然指向原来的字符串。只是那个 list 对象自己的内容发生了变化。我们可以用id()验证lst[1,2]t(lst,Python)print(id(t))print(id(t[0]))t[0].append(3)print(t)print(id(t))print(id(t[0]))你会发现tuple 的 id 没变 tuple[0] 指向的 list 的 id 也没变 list 的内容变了这就是问题的本质。七、tuple 的“不变”不是深度不变tuple的不可变性是一种浅层不可变。也就是说t([1,2],[3,4])这个 tuple 的结构不能变# 不允许# t[0] [100, 200]但 tuple 内部引用的对象如果本身是可变的就仍然可以变t[0].append(99)print(t)# ([1, 2, 99], [3, 4])所以我们可以总结tuple 不保证它包含的所有对象都不可变它只保证自己保存的引用关系不可变。这也是为什么下面的说法更严谨tuple 是不可变容器但它可以包含可变元素。八、一个容易踩坑的问题tuple 能不能作为 dict 的 key很多人知道字典的 key 必须是可哈希对象。而 tuple 通常是可哈希的point(10,20)d{point:坐标点}print(d[(10,20)])# 坐标点但是如果 tuple 里面包含 list就不行key([1,2],3)d{key:value}会报错TypeError:unhashabletype:list原因是tuple 是否可哈希不只取决于 tuple 自己还取决于它里面的元素是否都可哈希。print(hash((1,2,3)))# 可以print(hash((a,b)))# 可以# print(hash(([1, 2], 3))) # 报错为什么字典 key 必须可哈希因为字典需要根据 key 的哈希值快速定位数据。如果 key 在放入字典之后还能变化那么它的哈希值可能也会变化字典内部结构就会混乱。所以(1,2,3)可以作为 key。但([1,2],3)不可以作为 key因为其中的 list 是可变且不可哈希的。九、函数默认参数中的可变对象陷阱在真实项目中可变对象最常见的坑之一是函数默认参数。看这段代码defadd_item(item,container[]):container.append(item)returncontainerprint(add_item(A))print(add_item(B))print(add_item(C))你可能以为输出是[A][B][C]实际却是[A][A,B][A,B,C]原因是默认参数container[]只在函数定义时创建一次。之后每次调用函数如果没有传入container都会共享同一个列表。正确写法是defadd_item(item,containerNone):ifcontainerisNone:container[]container.append(item)returncontainerprint(add_item(A))print(add_item(B))print(add_item(C))这才会得到[A][B][C]这个问题在 Web 开发、数据处理、配置管理中很常见。尤其是写工具函数、类方法、缓存逻辑时一定要警惕默认参数里的list、dict、set。十、复制对象时也要注意“浅拷贝”和“深拷贝”可变对象还会带来另一个常见问题复制。a[[1,2],[3,4]]ba.copy()b[0].append(99)print(a)print(b)输出[[1,2,99],[3,4]][[1,2,99],[3,4]]为什么a也变了因为copy()是浅拷贝。它只复制外层列表内层列表仍然是共享的。a --- 外层列表 A --- 内层列表 [1, 2] b --- 外层列表 B ----/如果想彻底复制嵌套对象需要使用深拷贝importcopy a[[1,2],[3,4]]bcopy.deepcopy(a)b[0].append(99)print(a)# [[1, 2], [3, 4]]print(b)# [[1, 2, 99], [3, 4]]但深拷贝也不是越多越好。它可能带来性能开销也可能在复杂对象图中产生意料之外的问题。实践中要根据需求选择只复制外层结构浅拷贝 需要完全隔离嵌套数据深拷贝 数据本身不应变化优先考虑不可变结构十一、可变对象与不可变对象在函数传参中的表现Python 的函数参数传递本质上是“对象引用的传递”。看不可变对象defchange_number(x):x1print(函数内:,x)n10change_number(n)print(函数外:,n)输出函数内:11函数外:10因为x 1让局部变量x重新绑定到了新对象不影响外部的n。再看可变对象defchange_list(items):items.append(new)data[old]change_list(data)print(data)# [old, new]这里函数内部修改的是外部传入的同一个列表对象。如果你不希望函数修改外部对象可以显式复制defsafe_change_list(items):itemsitems.copy()items.append(new)returnitems data[old]new_datasafe_change_list(data)print(data)# [old]print(new_data)# [old, new]在团队项目中函数是否会修改传入对象最好通过命名、文档或类型提示说清楚。例如defnormalize_inplace(records:list[dict])-None:原地清洗 records。forrecordinrecords:record[name]record[name].strip().title()defnormalized(records:list[dict])-list[dict]:返回清洗后的新 records不修改原数据。result[]forrecordinrecords:new_recordrecord.copy()new_record[name]new_record[name].strip().title()result.append(new_record)returnresult一个函数是否“原地修改”往往直接决定代码是否容易维护。十二、实战案例配置对象应该用 list、dict 还是 tuple假设我们在写一个爬虫任务配置TASKS[{name:news,url:https://example.com/news},{name:blog,url:https://example.com/blog},]这很直观但也有风险。任何地方都可以修改它TASKS.append({name:test,url:https://test.com})TASKS[0][url]changed如果这是全局配置在大型项目中可能会引发难以追踪的问题。我们可以改成 tupleTASKS({name:news,url:https://example.com/news},{name:blog,url:https://example.com/blog},)这样能防止新增、删除、替换任务# TASKS.append(...) # 不允许# TASKS[0] {...} # 不允许但注意里面的字典仍然可以被修改TASKS[0][url]changed所以如果我们真的希望配置不可变可以进一步使用不可变结构fromdataclassesimportdataclassdataclass(frozenTrue)classTask:name:strurl:strTASKS(Task(namenews,urlhttps://example.com/news),Task(nameblog,urlhttps://example.com/blog),)现在# TASKS[0].url changed会报错。这是一种更安全、更清晰的设计方式。对于配置、常量、路由表、状态码映射等场景如果你希望数据不被意外修改应该尽量使用不可变结构。十三、进阶理解不可变对象为什么重要不可变对象并不是为了“限制自由”而是为了提升代码的可靠性。它至少有几个优势。第一减少副作用。不可变对象不会被某个函数悄悄改掉代码更容易推理。第二适合作为字典 key 和集合元素。因为它们通常可哈希能够参与高效查找。第三更适合并发场景。多个线程或协程共享不可变数据时不容易出现竞态修改。第四便于缓存。例如fromfunctoolsimportlru_cachelru_cachedeffib(n:int)-int:ifn2:returnnreturnfib(n-1)fib(n-2)print(fib(30))缓存函数的参数必须可哈希。不可变对象在这类场景中非常有价值。如果参数是 listlru_cachedeftotal(numbers):returnsum(numbers)# total([1, 2, 3]) # 报错list 不可哈希可以改成 tuplefromfunctoolsimportlru_cachelru_cachedeftotal(numbers:tuple[int,...])-int:returnsum(numbers)print(total((1,2,3)))这就是不可变对象带来的工程价值。十四、常见误区总结误区一不可变对象的变量不能重新赋值错误。x1x2完全可以。不可变说的是对象本身不能被修改不是变量名不能重新绑定。误区二tuple 里面的所有东西都不能变错误。t([1,2],3)t[0].append(4)可以。tuple 不能改的是它保存的引用不是引用对象的内部状态。误区三只要是 tuple 就能作为 dict key错误。# {(1, 2): ok} # 可以# {([1, 2], 3): bad} # 不可以tuple 里的元素也必须可哈希。误区四一定是创建新对象不一定。对于不可变对象通常是重新绑定sasb对于可变对象可能是原地修改lst[1]lst[2]print(lst)# [1, 2]尤其是列表的会改变原列表。a[1,2]ba a[3]print(b)# [1, 2, 3]但如果是a[1,2]ba aa[3]print(b)# [1, 2]print(a)# [1, 2, 3]a a [3]会创建新列表并让a重新绑定到新对象。这类细节在排查 bug 时非常关键。十五、最佳实践建议在日常 Python 开发中可以遵循这些原则默认优先使用简单、明确的数据结构。临时处理数据时用list、dict没问题重点是明确是否会修改它们。函数不要偷偷修改传入参数。如果必须原地修改建议在函数名中体现例如sort_inplace()、update_config_inplace()。不要把可变对象作为默认参数。使用None作为默认值然后在函数内部创建新对象。需要作为 dict key 或缓存参数时使用不可变对象。比如把list转成tuple。配置、常量、领域模型尽量不可变。可以使用tuple、frozenset、dataclass(frozenTrue)等。理解浅拷贝和深拷贝。嵌套数据结构中浅拷贝只复制外层深拷贝才会递归复制内部对象。不要误解 tuple 的不可变性。tuple 是“引用不可变”不是“深度不可变”。十六、一段小练习你能判断输出吗看下面代码a[1,2]t(a,3)a.append(4)print(t)答案是([1,2,4],3)因为t[0]指向的就是a那个列表。再看a[1,2]t(a,3)a[9,9]print(t)答案是([1,2],3)因为a [9, 9]只是让变量名a指向了一个新列表并没有改变 tuple 中原来保存的那个列表引用。这两段代码非常适合用来检验你是否真正理解了“变量名、对象、引用”三者之间的关系。十七、总结真正理解对象Python 才会变得清澈可变对象和不可变对象是 Python 中非常基础却极其重要的概念。简单总结可变对象对象内容可以原地修改如 list、dict、set。 不可变对象对象内容不能原地修改如 int、str、tuple。 tuple 不可变指 tuple 保存的元素引用不能被替换。 tuple 可包含可变对象因为被引用对象自身是否可变取决于那个对象的类型。所以tuple里可以包含list并且这个list可以被修改。这并不违反 tuple 的不可变性因为 tuple 本身保存的引用并没有改变。真正成熟的 Python 代码不只是“能跑”还应该“可预期、可维护、可推理”。当你理解了可变对象和不可变对象就会更容易写出安全的函数、更稳定的数据结构、更清晰的模块边界也能更从容地面对那些曾经让人困惑的 Python 行为。最后留一个问题给你在你的项目中是否遇到过因为 list、dict 被意外修改而导致的 bug你会选择复制数据、使用不可变结构还是通过代码规范来避免这类问题欢迎在评论区分享你的经验。很多时候一次真实的 bug 排查比十篇教程更能让人成长。