前端里常说“基础类型在栈,引用类型在堆”。
Python 里更常说“变量名绑定到对象”。
先记住这个区别:
前端入门模型:
- 基础类型:栈中保存值
- 引用类型:栈中保存地址,堆中保存对象
Python 推荐模型:
- 栈帧中保存名字和引用
- 堆中保存对象
- 不管
int、str、list、dict,本质上名字都是引用到对象
这篇不是 Python 源码级内存布局,而是业务开发时能用的心智模型。
一、先把前端的栈和堆讲清楚
前端学习时通常这样解释:
let age = 18;
let nums = [10, 20, 30];
前端入门模型可以简化为:
所以基础类型赋值像复制值:
let age = 18;
let otherAge = age;
otherAge = 20;
console.log(age); // 18
console.log(otherAge); // 20
内存图:
数组赋值像复制地址:
let nums1 = [10, 20, 30];
let nums2 = nums1;
nums2.push(40);
console.log(nums1); // [10, 20, 30, 40]
console.log(nums2); // [10, 20, 30, 40]
内存图:
但 Python 不能直接照搬“基础类型值直接放栈里”。
二、Python 的栈和堆:栈帧保存名字,堆保存对象
Python 运行代码时,也可以理解为有执行栈。
但 Python 的重点不是“变量盒子里直接放值”,而是:
- 当前作用域里有一张“名字表”
- 名字表里保存对象引用
- 对象本身在堆中
看代码:
age = 18
nums = [10, 20, 30]
内存图:
这里和前端入门模型最大的差别是:JS 入门模型里,基础类型常被理解成“值直接在栈里”;Python 里,age 这个名字保存的是引用,真正的 18 是 int 对象。
可以用 id() 观察对象身份:
age = 18
nums = [10, 20, 30]
print(id(age)) # 打印:地址A,代表 int 对象 18 的地址
print(id(nums)) # 打印:地址B,代表 list 对象的地址
print(id(nums[0])) # 打印:地址C,代表 int 对象 10 的地址
print(hex(id(age))) # 打印:0x001,用十六进制查看地址
id() 适合学习时观察对象身份,业务代码里很少用它做判断。
三、Python 的变量不是盒子,是名字
看这段:
age = 18
other_age = age
内存图:
可以验证:
age = 18
other_age = age
print(age is other_age) # 打印:True,说明两个名字指向同一个对象
print(id(age)) # 打印:地址A
print(id(other_age)) # 打印:地址A,和 age 一样
is 看对象身份,== 看值是否相等。
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # 打印:True,值相等
print(a is b) # 打印:False,不是同一个对象
四、不可变对象:不能改对象,只能换引用
Python 里的 int、float、bool、str、tuple 都是常见不可变对象。
看代码:
age = 18
print(id(age)) # 打印:地址A,代表 int 对象 18
age = age + 1
print(id(age)) # 打印:地址B,代表 int 对象 19,和上面不同
print(age) # 打印:19
不可变不是说 age 变量不能重新赋值。
而是说:age 指向的那个 int 对象 18,不能在原地变成 19。
当你写 age = age + 1 时,Python 是让 age 这个名字改为指向另一个对象。
内存变化可以理解成:
重点:不是原地修改,而是创建新的引用进行重新绑定
字符串也是一样,name = name + '-python' 不是原地把字符串变长,而是得到新字符串,再让 name 绑定过去。
所以,不可变对象的“修改”,本质是重新绑定引用。
五、可变对象:引用不换,对象内部能改
Python 里的 list、dict、set 是常见可变对象。
可变的意思是:栈里的名字仍然指向同一个对象,堆里的对象内部内容可以原地修改(也就是内存地址不变)。
nums = [10, 20, 30]
print(id(nums)) # 打印:地址A
nums.append(40)
print(id(nums)) # 打印:地址A,和 append 前一样
print(nums) # 打印:[10, 20, 30, 40]
可以理解成:
id(nums) 不变,因为 nums 还是指向同一个 list 对象,变的是堆里 list 对象的内部内容。
再看共享引用:
nums1 = [10, 20, 30]
nums2 = nums1
nums2.append(40)
print(nums1) # 打印:[10, 20, 30, 40]
print(nums2) # 打印:[10, 20, 30, 40]
print(nums1 is nums2) # 打印:True,两个名字指向同一个 list
内存图:
所以通过 nums2 修改,nums1 也能看到。这和前端引用类型的心智很接近。
六、列表内部保存的也是引用
nums = [10, 20, 30, 40]
list 对象里不是直接塞了 10、20、30、40 的值本身,而是保存了一组引用:
也就是说:变量名保存引用,容器的每个槽位保存的也是引用。
看修改:
nums = [10, 20, 30, 40]
print(id(nums)) # 打印:地址A,list 对象的地址
print(id(nums[0])) # 打印:地址B,int 对象 10 的地址
nums[0] = 100
print(id(nums)) # 打印:地址A,list 对象地址不变
print(id(nums[0])) # 打印:地址C,int 对象 100 的地址
print(nums) # 打印:[100, 20, 30, 40]
变化过程:
所以,list 对象本身没有换,id(nums) 不变;int 对象 10 没有被改成 100,只是 list 第 0 个槽位改为保存新的引用。
这句话很重要:列表可变,指的是列表槽位可以换引用;整数不可变,指的是 int 对象本身不能被改。
七、需要复制时才 copy
如果你想要两个独立的列表,要显式复制:
a = [1, 2, 3]
b = a.copy()
b.append(4)
print(a) # 打印:[1, 2, 3]
print(b) # 打印:[1, 2, 3, 4]
print(a is b) # 打印:False,copy 后是两个不同 list
内存图:
八、函数传参:形参也是名字
函数调用时,会创建新的栈帧。形参是这个函数栈帧里的名字,调用方传进来的对象会绑定到形参上。
不可变对象
def update_age(age):
age = age + 1
print('函数内部:', age) # 打印:函数内部: 19
age = 18
update_age(age)
print('函数外部:', age) # 打印:函数外部: 18
可以理解成:
函数内部只是让形参 age 指向了新对象,所以外部 age 不变。
可变对象
def add_user(users):
users.append('王五')
users = ['张三', '李四']
add_user(users)
print(users) # 打印:['张三', '李四', '王五']
可以理解成:
函数内部没有让 users 重新绑定到新对象,而是原地修改了同一个 list 对象,所以外部能看到变化。
判断关键:
- 重新绑定名字:通常不影响外部。
- 原地修改对象:所有引用这个对象的地方都会看到变化。
九、浅拷贝:只复制外层容器
先记一句话:
浅拷贝会复制外层容器。但是外层容器里面保存的引用,不会继续往里面复制。
如果是一层普通列表,问题不大:
nums = [1, 2, 3]
new_nums = nums.copy()
new_nums.append(4)
print(nums) # 打印:[1, 2, 3]
print(new_nums) # 打印:[1, 2, 3, 4]
因为 nums 和 new_nums 已经是两个不同的外层 list。
但嵌套结构就要小心:
users = [
{'name': '张三'},
{'name': '李四'},
]
new_users = users.copy()
new_users[0]['name'] = '张三丰'
print(users) # 打印:[{'name': '张三丰'}, {'name': '李四'}]
print(new_users) # 打印:[{'name': '张三丰'}, {'name': '李四'}]
为什么 users 也被改了?
因为 users.copy() 只复制了外层 list:
users指向外层list Anew_users指向外层list B
但是两个外层 list 的第 0 个位置,仍然指向同一个 dict。
内存图:
所以 new_users[0]['name'] = '张三丰' 修改的是同一个 dict 对象,users[0] 也能看到变化。
如果要连内部对象一起复制,用深拷贝:
import copy
new_users = copy.deepcopy(users)
深拷贝的内存图可以理解成:
先记:
- 赋值:不复制对象
- 浅拷贝:复制外层容器
- 深拷贝:外层和内部对象都复制
十、tuple 不可变,但里面的对象可能可变
这个点容易懵,关键是把两层分开看:
- tuple 的槽位不能换。
- 槽位指向的是对象,如果这个对象是可变对象,它自己仍然可以改。
看代码:
data = ([1, 2], [3, 4])
data[0].append(100)
print(data) # 打印:([1, 2, 100], [3, 4])
data 是 tuple,所以 data[0] 这个槽位不能换成另一个对象。
但是 data[0] 当前指向的是一个 list,list 是可变对象。
所以 data[0].append(100) 改的是这个 list 的内部内容,不是替换 tuple 的槽位。
不能这样:
# data[0] = [100, 200]
因为这是想把 tuple 的第 0 个槽位,改为指向另一个 list。
所以:tuple 不可变,指的是槽位不能换引用;list 可变,指的是 list 对象内部可以改。
十一、垃圾回收:引用没了,对象才可能被清理
Python 对象在堆中。对象能不能被回收,关键看还有没有引用指向它。
a = [1, 2, 3]
b = a
del a
print(b) # 打印:[1, 2, 3]
del a 删除的是栈帧里的名字 a,不是直接删除堆里的 list 对象。
因为 b 还指向这个对象,所以它还活着。
如果再执行:
b = None
原来的 list 对象没人引用了,就有机会被回收。
Python 常见内存回收机制可以先这样理解:
| 机制 | 作用 |
|---|---|
| 引用计数 | 对象被多少引用指向 |
| 循环垃圾回收 | 处理对象之间互相引用导致的循环 |
循环引用:
a = []
a.append(a)
这个 list 对象内部引用了自己。
这类问题需要垃圾回收器处理。
业务开发里不用手动管理每块内存,但要注意:无用数据如果一直被全局变量、缓存、闭包等引用,就不会被释放。