Python 可变对象与不可变对象深度解析:为什么 `tuple` 里可以放 `list`?

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]

因为 xy 引用的是同一个对象。

我们可以用 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 只是重新绑定到了一个新字符串对象。

这就是不可变对象的本质:

不是变量不能变,而是变量指向的那个对象本身不能变。


四、可变与不可变的核心区别

我们可以用一张表来总结:

类型 创建后内容能否原地修改 典型类型 常见操作
可变对象 可以 listdictset appendupdateadd
不可变对象 不可以 intstrtuple 重新赋值、创建新对象

看代码最直观:

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 开发、数据处理、配置管理中很常见。尤其是写工具函数、类方法、缓存逻辑时,一定要警惕默认参数里的 listdictset


十、复制对象时也要注意"浅拷贝"和"深拷贝"

可变对象还会带来另一个常见问题:复制。

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 开发中,可以遵循这些原则:

  1. 默认优先使用简单、明确的数据结构。

    临时处理数据时用 listdict 没问题,重点是明确是否会修改它们。

  2. 函数不要偷偷修改传入参数。

    如果必须原地修改,建议在函数名中体现,例如 sort_inplace()update_config_inplace()

  3. 不要把可变对象作为默认参数。

    使用 None 作为默认值,然后在函数内部创建新对象。

  4. 需要作为 dict key 或缓存参数时,使用不可变对象。

    比如把 list 转成 tuple

  5. 配置、常量、领域模型尽量不可变。

    可以使用 tuplefrozensetdataclass(frozen=True) 等。

  6. 理解浅拷贝和深拷贝。

    嵌套数据结构中,浅拷贝只复制外层,深拷贝才会递归复制内部对象。

  7. 不要误解 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 排查,比十篇教程更能让人成长。

相关推荐
小白学大数据1 小时前
业务落地:Python 列表在 AI 接口开发中的实战应用
人工智能·爬虫·python·microsoft
源图客1 小时前
【亚马逊 SP-API 实战】Java 实现单体商品 Listing 创建 + 图片上传完整教程(亲测可用)
开发语言·亚马逊电商
SWAGGY..1 小时前
【C++初阶】:(11)list的功能介绍&&list迭代器模拟实现
开发语言·c++
源图客1 小时前
【亚马逊 SP-API 实战】Java 批量创建变体 Listing(父商品 + 子变体 + 独立图片)完整教程(亲测可用)
java·大数据·python
Cinthia10031 小时前
学习深度学习过程中对线性代数的几何理解
python·线性代数·机器学习
Xpower 171 小时前
Codex 桌面端更新后 Chrome 插件和 Computer Use 不可用,怎么排查和修复
前端·人工智能·chrome·python·学习
不会C语言的男孩2 小时前
C++ Primer 第3章:字符串、向量和数组
开发语言·c++
兰令水2 小时前
leecodecode【反前后指针】【2026.5.31打卡-java版本】
java·开发语言
Dovis(誓平步青云)3 小时前
《QT学习第四篇:常见事件与UDP、TCP、文件系统、(锁、信号量、条件变量》
c语言·开发语言·汇编·qt