一、从"盒子"到"标签"

1.1 C语言的"盒子"模型

在 C 语言中,变量是一个有固定大小的"盒子":

1
2
3
4
int a = 10;  // 盒子 a 里装着 10
int b = a; // 把 a 盒子里的 10 拷贝一份,装进盒子 b
a = 20; // 把盒子 a 里的值改成 20
// b 仍然是 10,因为每个盒子独立存储自己的值

赋值(a = b)就是把 b 盒子里的东西拷贝一份到 a 盒子里。两个盒子互不干扰。

1.2 Python的"标签"模型

Python 的变量更像一张"便利贴"或"标签",而数据本身是堆内存中的一个"对象":

1
2
3
4
a = 10   # 创建整数对象 10,把标签 a 贴上去
b = a # 把标签 b 也贴到同一个对象 10 上
a = 20 # 把标签 a 从 10 撕下来,贴到新对象 20 上
# b 仍然贴在 10 上

赋值(a = b)不是拷贝对象,而是给同一个对象贴上两个标签。这与 C 语言中的指针(int* a)概念非常相似——变量存储的是对象的地址,而非对象本身。Python 隐藏了指针的语法,让一切变得更自动化。

二、核心对比:栈上的值 vs 堆上的对象

2.1 不可变数据:const 对象,修改即重造

不可变对象(如 int, str, tuple)就像 C 语言中用 const 修饰的、分配在堆上的对象。任何"修改"操作,实际上都经历了四步:

  1. malloc 一块新的内存
  2. 将旧对象的内容和新值结合,拷贝到新内存中
  3. 将变量的"标签"(指针)从旧对象指向新对象
  4. 旧对象的引用计数减一,如果为零则被垃圾回收(相当于 free

id() 函数可以验证——它返回对象的内存地址:

1
2
3
4
5
a = 42
print(id(a)) # 例如:140731834567808

a += 1
print(id(a)) # 例如:140731834567840 —— 地址变了!

a += 1 并没有修改原来的整数对象 42,而是创建了一个全新的整数对象 43,然后把标签 a 贴了过去。原来的 42 依然在内存中(直到被回收)。

字符串同理:

1
2
3
4
5
s = "hello"
print(id(s)) # 例如:2345678901234

s += " world"
print(id(s)) # 例如:2345678905678 —— 地址又变了!

2.2 可变数据:动态数组结构体,原地修改

可变对象(如 list, dict)就像 C 语言中一个指向动态数组的结构体——包含指针、长度、容量。变量的"标签"指向这个结构体。

当调用 .append() 或修改元素时,相当于通过指针直接修改了堆上那块内存的内容。变量的"标签"没有移动,它依然指向同一个地址,但地址里的数据变了:

1
2
3
4
5
6
7
8
lst = [1, 2, 3]
print(id(lst)) # 例如:2345678901234

lst.append(4)
print(id(lst)) # 2345678901234 —— 地址没变!

lst[0] = 100
print(id(lst)) # 2345678901234 —— 还是没变!

append 和索引赋值都是原地修改操作,它们直接改了共享的那块内存,标签始终指向同一个对象。

三、经典陷阱:函数参数传递

这是 C 程序员最容易困惑的地方。Python 的参数传递机制叫做传对象引用——形参和实参指向同一个对象,但形参本身是局部的。

3.1 场景A:传入不可变对象

1
2
3
4
5
6
7
def try_change(num):
num = 100 # 局部标签 num 指向新对象 100
# 外部的标签不受影响

x = 42
try_change(x)
print(x) # 输出:42

这类似于 C 语言的值传递——函数拿到的是值的拷贝,修改拷贝不影响原件。

3.2 场景B:传入可变对象并原地修改

1
2
3
4
5
6
7
def try_append(lst):
lst.append(4) # 通过指针修改了堆上的数据
# 外部的标签依然指向这个被修改了的对象

nums = [1, 2, 3]
try_append(nums)
print(nums) # 输出:[1, 2, 3, 4]

这类似于 C 语言的指针传递——函数拿到了地址,通过地址修改了数据,外部自然能看到变化。

3.3 场景C:传入可变对象并重新赋值

1
2
3
4
5
6
7
def try_replace(lst):
lst = [10, 20, 30] # 局部标签 lst 指向了全新的列表
# 外部的标签依然指向原来的列表

nums = [1, 2, 3]
try_replace(nums)
print(nums) # 输出:[1, 2, 3] —— 没有被修改!

这类似于在 C 函数内修改一个局部指针变量的指向(int* p = malloc(...);),而不会影响外部的指针。重新赋值 ≠ 原地修改,这是理解 Python 参数传递的关键。

四、元组的"伪"不可变

元组本身不可变,但如果元组中包含可变对象(如列表),列表的内容依然可以被修改:

1
2
3
t = (1, [2, 3], 4)
t[1].append(99)
print(t) # 输出:(1, [2, 3, 99], 4)

元组的不可变性指的是引用地址不变——t[1] 始终指向同一个列表对象。但这个列表对象本身是可变的,它的内容可以随意修改。这就像 C 语言中一个 const 指针——指针本身不能改(不能指向别的地址),但指针指向的数据如果不是 const 的,依然可以修改。

五、哈希性:为什么只有不可变对象能做字典的键

字典的键必须可哈希(hashable),因为字典内部依赖哈希值来快速定位键值对。哈希值的约定是:如果两个对象相等,它们的哈希值必须相同

如果用可变对象做键,对象被修改后哈希值也会变,字典就再也找不到这个键了——这会破坏字典的内部结构。因此,Python 要求字典的键必须是不可变的,保证哈希值在整个生命周期中不变。

1
2
3
4
5
d = {}
d[42] = "int key" # ✓ 不可变,可哈希
d["hello"] = "str key" # ✓ 不可变,可哈希
d[(1, 2)] = "tuple key" # ✓ 不可变,可哈希
# d[[1, 2]] = "list key" # ✗ TypeError: unhashable type: 'list'

六、总结对比表

特性 C语言类比 Python行为 典型类型
变量本质 内存盒子 / 指针 对象标签 / 引用 全部
不可变数据 const 对象,修改需 malloc 新内存 任何"修改"都创建新对象 int, str, tuple
可变数据 动态数组结构体,可原地修改 支持原地修改,内存地址不变 list, dict, set
函数传参 值传递 / 指针传递 传对象引用(一种方式)
修改方式 直接赋值 / 通过指针修改 = 重新绑定 / 原地方法修改
内存地址变化 不可变:地址变;可变:地址不变 同左
哈希性 不可变可哈希,可变不可哈希
线程安全 不可变天然安全 不可变天然安全,可变需加锁

核心记忆:在 Python 中,= 永远是让标签指向新对象,原地修改才是改对象本身。区分这两者,就掌握了可变与不可变的全部秘密。