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