Python 与 C/C++ 内存模型对比总结
前言
本文从底层内存的角度,对比 C/C++ 的值语义与 Python 的引用语义,内容覆盖变量存储、赋值行为、容器底层结构、闭包迟绑定陷阱,以及 lambda i=i: i 的修复原理。行文尽量口语化,只保留我认为需要记住的部分。
两点说明:
- 文中关于 Python 的描述以 CPython 为准,涉及内存结构的部分属于实现层面,不同解释器(PyPy、Jython)或不同版本可能略有出入。
- 为了讲清主干,正文会做一些简化,明显说过头的地方我单独放在每节末尾的「边界」里。
一、核心区别:值语义与引用语义
1.1 C/C++:值语义
- 变量就是那块内存本身。
- 自动存储期的基础类型(
int、char、double)在栈上分配空间,数值直接存在这块空间里。 int a = 100;- 栈上分配 4 字节
- 内存中直接存放数值
100
int a = b = c = d = 100;- 栈上 4 块相互独立的内存
- 4 份独立数值,地址互不相同,互不影响
a = 200;- 原地覆盖栈内存里的数据,不涉及新内存
一句话:C/C++ 操作的是数据本身。
1.2 Python:引用语义
- CPython 中,
int、str、容器等对象都分配在堆上,不存在「栈上直接存数值」这回事。 - Python 的变量是一个名字(存在栈帧或模块命名空间里),保存的是堆对象的地址,而不是数据本身。
a = 100- 堆上创建
int(100)对象(包含引用计数、类型指针、数值字段) - 名字
a指向这个堆对象
- 堆上创建
a = b = c = d = 100- 4 个不同的名字
- 都指向堆上同一个
100对象(依托小整数池复用)
a = 200- 不修改原对象(Python 的
int不可变) - 堆上新建
200对象 - 名字
a改为指向新对象
- 不修改原对象(Python 的
一句话:Python 操作的是对象的引用(地址标签)。
1.3 两种模型的对照
| 对比项 | C/C++ | Python |
|---|---|---|
| 变量是什么 | 一块内存本身 | 一个指向对象的名字 |
| 数值存在哪 | 栈上(自动存储期变量) | 堆上的对象里 |
a = b = 100 |
两份独立数据 | 两个名字指向同一对象 |
a = 200 做了什么 |
原地覆盖 | 新建对象 + 改指向 |
| 类型检查 | 编译期 | 运行期 |
| 内存谁来管 | 手动(或 RAII) | 引用计数 + 循环 GC |
1.4 边界(这几处别说过头)
- 小整数池只覆盖
-5 ~ 256。 范围外的整数不保证复用;只不过在同一个代码块内,相同的字面量可能被编译器折叠成同一个常量对象,所以"看起来"也复用了。 - 「
a = 200新建对象」不等于100立刻被回收。 如果b、c、d还指向它,引用计数只是减 1,对象照样活着。 - 「栈上」只针对自动存储期变量。 C 里的全局变量、
static变量并不在栈上。 - 字符串驻留是有条件的。 只有"看起来像标识符"的字符串字面量在编译期强制驻留,其余情况属于实现的可选优化,不能依赖。
二、Python 的引用机制,可以类比 C++ 的 shared_ptr
这一节是类比,不是等价关系 ------ 但拿来建立直觉很好用。
2.1 相似的地方
| 相似点 | 说明 |
|---|---|
| 计数式管理 | 都用引用计数决定堆内存何时释放 |
| 共享而非拷贝 | 多变量指向同一对象时,只复制指针,不复制对象本体 |
| 归零即释放 | 计数降到 0,自动释放堆内存 |
2.2 关键区别
| 区别 | C++ shared_ptr |
Python |
|---|---|---|
| 是否显式 | 需要手动声明类型 | 所有对象默认就走引用计数,没有例外 |
| 普通类型 | int 仍是栈上值语义 |
同样走堆对象 + 引用计数 |
| 循环引用 | 互相持有 → 计数永不归零 → 泄漏 | 额外有分代循环 GC,能回收容器形成的环 |
| 缓存机制 | 标准库没有内置 | 有小整数池、字符串驻留 |
一个容易被忽略的细节:
shared_ptr本身是个「指针对象」(还有一层控制块),而 Python 的名字更接近「裸指针 + 自动计数」。所以类比时要留个心眼。
三、三大容器的底层结构
统一前提
- 容器对象本体在堆上
- 容器内部通常不存放元素本体,而是存放指向元素的指针/引用
3.1 list ------ 动态指针数组
- 底层是堆上的动态指针数组 ,附带两个量:
size(实际元素数)和capacity(已申请的容量)。 - 可变 :
lst[i] = 99只是把数组里第 i 个指针换掉,不修改原来那个对象。 - 可扩容:容量不够时,新建一个更大的堆数组 → 拷贝所有指针 → 释放旧数组。
- 整个过程里,列表对象自身的地址不变 (
id()不变),变的只是它内部引用的那块数组。
| 操作 | 平均复杂度 | 原因 |
|---|---|---|
append |
均摊 O(1) | 有容量预留,偶尔触发扩容 |
pop()(尾部) |
O(1) | 只需挪动 size |
insert(0, x) / pop(0) |
O(n) | 后面所有指针都要搬 |
x in lst |
O(n) | 线性扫描 |
lst[i] |
O(1) | 按下标直接寻址 |
3.2 tuple ------ 固定不可变指针数组
这里是重点,也是容易理解偏的地方。
元组的「不可变」指的是 ------ 元组内部那些指针本身不可变,而不是"指针指向的内容永远不变"。
- 元组创建后,内部所有元素的指针被固定下来,禁止增删,也禁止替换指针。
- 禁止的操作:
t[0] = 20------ 需要改动元组内部的指针,直接TypeError。 - 允许的操作:
python
t = (10, [1, 2])
t[1].append(3) # OK:没有动元组的指针,只是改了它指向的那个 list 的内部数据
print(t) # (10, [1, 2, 3])
口诀:元组锁的是指针,不锁指针指向的内容。
C++ 类比:相当于一个
const的std::tuple,里面装了一个vector------ 元组保证「元素的指针不变」,但你完全可以通过这个指针去改它指向的内容。
3.3 dict ------ 哈希表
- 底层是堆上的哈希表,每个条目记录 hash 值、key 指针、value 指针。
- CPython 3.6 之后用的是索引数组 + 紧凑条目数组的结构 ------ 这也是它「顺便」变成有序的原因。
- 采用开放寻址 (配合扰动探测),查找/增删平均 O(1)。
- key 必须是可哈希 的类型(
int、str、tuple等不可变对象),以保证哈希值稳定。 - Python 3.7 起,保留插入顺序成为语言规范的一部分(3.6 只是 CPython 的实现细节,不要写在 3.6 上)。
3.4 三种容器横向对比
| 维度 | list | tuple | dict |
|---|---|---|---|
| 底层结构 | 动态指针数组 | 固定指针数组 | 哈希表(索引数组 + 紧凑条目) |
| 可变性 | 可变 | 指针不可变 | 可变(key 不可变) |
| 能否增删 | 可以 | 不可以 | 可以(增删键值对) |
| 下标访问 | O(1) | O(1) | 无下标,按键 O(1) |
| 查找元素 | in → O(n) |
in → O(n) |
按键 → 平均 O(1) |
| 有序性 | 有序(按位置) | 有序(按位置) | 3.7+ 按插入顺序 |
| 典型用途 | 需要频繁增删改的序列 | 结构固定、只读的序列(也可作 dict 的 key) | 键值映射、去重、计数 |
四、闭包陷阱:循环变量 i 的内存真相
4.1 问题代码
python
funcs = []
for i in range(3):
funcs.append(lambda: i)
print([f() for f in funcs]) # [2, 2, 2] ← 不是 0 1 2
4.2 内存层面发生了什么
- 循环变量
i自始至终只有一个,它存在于外层的命名空间里,不会每轮循环新建一个变量。 - 循环过程中,名字
i依次指向堆对象0→1→2。 lambda捕获的是外层的变量名i(准确说是它所在的那个作用域单元),而不是「当下的数值快照」。- 循环结束时,
i停在堆对象2上。 - 真正调用
f()时才去读取i当前指向谁 ------ 所以三个函数都读到2。
术语上这叫 迟绑定(late binding):闭包绑定的是变量(作用域),不是值。
4.3 一个常被追问的点:0、1 去哪了
0、1、2三个堆对象在循环中都正常创建过。0和1落在小整数池范围内,所以对象本身不会销毁,会一直常驻。- 但三个 lambda 从来没有保存过
0、1的指针 ------ 它们只绑定了名字i,所以即便对象还在堆上,也读不到了。
注意: 上面说的是「模块级」循环。如果循环写在函数里,
i存在的是该函数的栈帧局部变量里;模块级则存在模块的命名空间(字典)里。位置不同,但「绑定的是变量而不是值」这个结论完全一样。
4.4 顺带说清:形参、实参,以及两种语言里「拷贝」的不同含义
这一节是理解上面这个陷阱、以及第五节修复方案的桥梁 ------ 因为根源就在参数传递的规则上。
形参和实参,本来就是两个不同位置的东西
- 实参 (argument):调用时写出来的那个东西,待在调用方的地盘上。
- 形参 (parameter):函数定义里声明的名字,属于被调用方自己的地盘。
这里的「地盘」不是比喻 ------ 在两种语言里它都是栈帧 。Python 也一样有帧栈,具体见 4.5。
C/C++ 里这一点是肉眼可见的:
c
void f(int x) { x = 999; } /* x 是 f 栈帧上新开的一块内存 */
int main() {
int a = 1;
f(a); /* 把 a 的值复制进 f 栈帧的 x */
printf("%d", a); /* 1 ------ a 没有变 */
}
调用发生时,a 在 main 的栈帧上,x 在 f 的栈帧上,是两块不同地址的内存 。默认情况下 C/C++ 做的是值拷贝 :把实参的值写进形参那块内存。所以函数一进门,形参拿到的天然就是一份独立快照,改它不会影响外面。
(想让形参和实参指向同一块内存,必须显式写成 int& x 或 int* px。)
Python 调用时同样会新建一个帧,帧里的形参也是一组新名字,但名字里存的是地址:
python
a = [1, 2]
def f(x):
print(id(x) == id(a)) # True ------ 形参和实参指向同一个对象
x = 999 # 只是让 f 帧里的名字 x 改指向,与外面的 a 无关
f(a)
print(a) # [1, 2] ------ 外面没变
- Python 传参只拷贝地址,不拷贝对象本体。
- 所以通过形参修改可变对象的内容,调用方看得见(因为是同一个对象)。
- 但若对形参重新赋值,调用方看不见(只改了本帧那个名字的指向)。
两种语言的参数传递对照
| 对比项 | C/C++ | Python |
|---|---|---|
| 实参在哪里 | 调用方的栈帧 | 调用方的帧 |
| 形参在哪里 | 被调用方的栈帧(新内存) | 被调用方的帧(同在栈 上,新槽位) |
| 默认传递方式 | 值拷贝(把数据复制进形参) | 地址拷贝(形参指向同一个对象) |
| 改形参本身,实参变吗 | 不会 | 不会(只改了指向) |
| 改形参指向的对象内容,实参变吗 | 看是否用指针/引用 | 会(可变对象) |
| 想共享同一块内存 | 显式用 & 或 * |
默认就是共享对象 |
这就是闭包陷阱的根
回头看 lambda: i ------ 函数被调用时解释器会新开一个帧,里面的自由变量通过闭包单元 去读外层的 i。整个过程中从来没有发生过「把当前的值复制一份存起来」这个动作 ------ 因为 Python 的参数传递本来就是地址拷贝,闭包捕获沿用的也是同一套规则:捕获的是变量(作用域),不是值。
C++ 的 lambda 之所以没有这个问题,是因为「捕获什么、按值还是按引用」被明确写在了捕获列表里:[=] 按值捕获(当场复制一份进 lambda 的闭包对象),[&] 按引用捕获。
而 Python 没有捕获列表这个语法,默认效果大致相当于全部按引用捕获 ------ 所以想要快照,只能靠别的手段手工制造,也就是下一节的写法。
4.5 把话说明白:Python 也有栈,形参和实参同样在两个不同位置
上一节一直在说「名字」「槽位」,容易让人以为 Python 里这些东西是飘在堆上的散装对象。这里直接点破:
Python 也有栈,叫「帧栈」。 每调用一次函数,就往栈上压入一个新的帧(frame);函数返回,帧出栈。形参和局部变量就住在这个帧里。
形参存在哪?不是字典,而是帧里一块按位置编号的槽位数组。 用 co_varnames 就能看到下标:
python
import sys
def g(alpha, beta):
fr = sys._getframe()
print(fr.f_code.co_varnames)
print("alpha ->", fr.f_code.co_varnames.index("alpha"), "号槽")
print("beta ->", fr.f_code.co_varnames.index("beta"), "号槽")
g(1, 2)
# ('alpha', 'beta', 'fr')
# alpha -> 0 号槽
# beta -> 1 号槽
f_back 就是往栈上走一层 ------ 这条链本身就是调用栈:
python
def level3():
f = sys._getframe()
while f:
print(f.f_code.co_name)
f = f.f_back
def level2(): level3()
def level1(): level2()
level1()
# level3
# level2
# level1
# <module>
所以结论和 C/C++ 是一致的 :实参留在调用者 的帧里,形参放在被调用者的帧里,两者是栈上两个不同的位置。
关于「位置会不会重复」,这里要分两种情况讲,不然容易说错。
实测(CPython 3.13,连续 5 次调用):
| 情况 | 5 次调用拿到的帧地址 | 说明 |
|---|---|---|
| 前一次已经返回(帧已出栈) | 5 次完全相同 | 那块空间被复用了 |
| 帧还活着(嵌套调用 / 递归,被引用着) | 5 次各不相同 | 位置互不重叠 |
这两条合起来,恰恰就是栈的标准行为:
- 出栈之后,空间就还给栈了。 下一次调用直接接着用那块位置,所以「先后两次调用」的形参地址很可能一模一样 ------ 单看地址是看不出区别的。
- 同一时刻活着的两个帧,形参一定在不同位置,绝不共享。 嵌套调用、递归的每一层都是这样。
一句话:不是「每次调用的位置都不同」,而是「同时存在的帧绝不同址,返回后空间回收再复用」。
递归里最容易看出每一层的形参是各自独立的:
python
def walk(n, path):
path = path + [n] # 只重新绑定【本帧】的形参,别人管不着
if n > 0:
walk(n - 1, path) # 里层用里层自己那份
return path # 外层返回的还是外层自己那份
print(walk(3, [])) # [3] ← 里层改了 path,外层完全不受影响
对比 C:C 的形参也在栈上、也在自己那一帧,出栈后空间同样会被后续调用复用。所以「在栈上」「位置可复用」这一点上,两者其实是一回事。
真正不同的,还是那句老话:
| C/C++ | Python | |
|---|---|---|
| 形参槽里放的是 | 值本身(实参的副本) | 地址(指向实参那个对象) |
| 所以闭包要「快照」时 | 天然就是快照([=] 按值捕获) |
得手工制造(lambda i=i: i) |
实现层面的提醒: 上面说的「帧」在对外接口上就是
sys._getframe()返回的 frame 对象,其内部具体怎么分配(CPython 3.11 起帧的分配方式做过改动)属于实现细节,不同版本会有差异。但「调用压帧、返回弹帧、同时存在的帧互不重叠」这套语义是稳定的。
五、修复原理:lambda i=i: i
5.1 语法拆解
python
lambda i=i: i
# ↑ ↑
# │ └── 等号右侧:当前外层的名字 i 所指向的那个对象
# └────── 冒号前:lambda 自己的局部形参 i(新建的一个参数槽)
# i=i 合起来 = 给这个形参设置默认参数
结合上一节看就很清楚了:这里等于人为给 lambda 造了一个形参 ,让
i从「外层自由变量」变成「自己的局部名字」,从而脱离外层作用域。
5.2 核心机制
函数默认参数是在「定义函数的那一刻」立即求值的,而不是调用时求值。
于是:
- 每一轮循环创建 lambda 时,当场把外层
i指向的那个对象指针拷贝一份,存进自己的默认参数槽里。 - 等价于分别写出了三个函数:
python
lambda i=0: i
lambda i=1: i
lambda i=2: i
- 每个 lambda 内部各自持有一份当下的对象指针。
- 从此与外层的
i脱钩 ------ 调用时读的是自己内部的默认值,输出0, 1, 2。
5.3 两种写法对照
python
# 错误:绑定外层变量名 ------ 迟绑定
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs]) # [2, 2, 2]
# 正确:把当前值存进默认参数 ------ 即刻绑定
funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs]) # [0, 1, 2]
| 写法 | 绑定的对象 | 求值时机 | 结果 |
|---|---|---|---|
lambda: i |
外层的变量名 i |
调用时读取 | [2, 2, 2] |
lambda i=i: i |
当下的对象引用(存进默认参数槽) | 定义时拷贝 | [0, 1, 2] |
一处容易忽略的精度问题: 默认参数快照下来的是对象的引用 ,不是对象的副本。
对
int这种不可变类型,「引用快照」和「值快照」的效果一样,所以没问题;但如果默认值本身是个可变对象,各个闭包依然共享同一个对象:
pythondata = [1, 2] funcs = [lambda d=data: d for _ in range(3)] funcs[0]()[0] = 99 print([f() for f in funcs]) # [[99, 2], [99, 2], [99, 2]] ← 三个都变了这跟上一节说的规则是同一条:拷贝的永远是地址,不是内容。 真要各自独立,得自己
copy.deepcopy。
5.4 另一个常用解法:functools.partial
除了默认参数,也可以用 partial 把当前值"钉"进去,原理是一样的 ------ 都是在定义时刻把值固定下来:
python
from functools import partial
def show(x, tag):
return f"{tag}:{x}"
funcs = [partial(show, i, tag="v") for i in range(3)]
print([f() for f in funcs]) # ['v:0', 'v:1', 'v:2']
面试里如果被问到「还有什么办法」,除了
partial,还可以提「把 lambda 套一层工厂函数」,本质都是制造一个新的作用域,让值在那一刻被固定住。
六、对比总表
| # | 维度 | C/C++ | Python |
|---|---|---|---|
| 1 | 变量本质 | 内存盒子本身 | 指向堆对象的名字 |
| 2 | 数值存储 | 栈上(自动存储期) | 堆上的对象内部 |
| 3 | 赋值语义 | 值拷贝 | 改指向(重新绑定) |
| 4 | 修改行为 | 原地覆盖 | 不可变类型新建对象;可变类型就地修改 |
| 5 | 类型系统 | 编译期静态 | 运行期动态 |
| 6 | 内存管理 | 手动 / RAII / 智能指针 | 引用计数 + 分代循环 GC |
| 7 | 共享方式 | 指针或引用,需显式声明 | 天然共享,默认就是引用 |
| 8 | 循环引用 | shared_ptr 会泄漏 |
循环 GC 可回收容器环 |
| 9 | 缓存复用 | 标准库无内置 | 小整数池、字符串驻留 |
| 10 | 函数参数传递 | 值拷贝 :实参在调用方栈帧,形参在被调用方栈帧,是两块不同内存;改形参不影响外部(想共享需 & / *) |
地址拷贝:形参是新帧里的一个槽位,与实参指向同一对象;改形参的指向不影响外部,但改对象内容会 |
| 11 | 参数共享内存 | 需要显式声明 int& / int* |
默认就是共享同一个对象 |
| 12 | 函数调用栈 | 每次调用在栈上开新帧,形参是帧内新内存 | 同样有帧栈:每次调用压入新帧,形参是帧内按位置编号的槽位;同时存在的帧互不重叠,返回后空间回收复用 |
| 13 | 形参槽里放什么 | 值本身(实参的副本) | 地址(指向实参那个对象) |
| 14 | list | 类似 std::vector:连续存储、可扩容、原地改 |
动态指针数组;lst[i]=x 只换指针;id() 不变 |
| 15 | tuple | 类似 const std::array:长度固定 |
指针不可变,内容可变 :t[1].append(x) 合法 |
| 16 | dict | std::unordered_map(哈希)/ std::map(有序) |
哈希表,开放寻址;3.7+ 保留插入顺序 |
| 17 | 查找复杂度 | 哈希容器平均 O(1),有序容器 O(log n) | dict 按键平均 O(1),list/tuple 按值 O(n) |
| 18 | 闭包捕获 | lambda 捕获列表显式声明(值/引用可选) | 没有捕获列表,默认按变量捕获,因此有迟绑定问题 |
| 19 | 陷阱根源 | 捕获方式写错会悬垂引用 | 循环里创建闭包,全部读到最后一个值 |
| 20 | 修复手段 | 按值捕获 [=] / 显式拷贝 |
lambda i=i: i、partial、工厂函数 |
附录:同一个机制带来的另一个坑 ------ 可变默认参数
既然「默认参数在定义时求值」,那它自然只求值一次。如果把可变对象当默认值,就会在多次调用之间被共享:
python
def add(item, bucket=[]): # ⚠️ 这个 [] 只在定义时创建一次
bucket.append(item)
return bucket
print(add(1)) # [1]
print(add(2)) # [1, 2] ← 不是 [2]
print(add(3)) # [1, 2, 3]
正确写法:用 None 做哨兵,在函数体内创建。
python
def add(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
这和第五节是同一个原理的两种表现:
lambda i=i: i |
def f(bucket=[]) |
|
|---|---|---|
| 利用的特性 | 默认参数定义时求值 | 默认参数定义时求值 |
| 结果 | 想要的效果 ✅ | 不想要的效果 ❌ |
区别只在于:前者我们主动利用 它来固定一个值,后者被它意外坑到。
小结
- C/C++ 操作栈上的数据本身;Python 操作堆对象的引用。 这是所有差异的源头。
- 两边都有栈。 C/C++ 每次调用在栈上开新帧,形参是新内存;Python 同样有帧栈,每次调用压入新帧,形参是帧内按位置编号的槽位 ------ 所以形参和实参在两个不同位置这件事,两边是一致的。同时存在的帧绝不重叠,返回后空间回收复用。
- 参数传递是这条差异最直接的体现 :C/C++ 默认把实参的值 拷进形参那块新内存;Python 只拷地址,形参和实参指向同一个对象。
- Python 的引用机制可以类比
shared_ptr:计数式回收 + 天然共享,但 Python 多了循环 GC 和内置缓存。 - 容器统一规律 :本体在堆上,内部存的是指针而不是元素本身。所以
list能换指针、能扩容;tuple锁死了指针但锁不住指针指向的内容。 - 闭包陷阱的根源:没有捕获列表,闭包默认绑定的就是「变量」而不是「值」,于是出现迟绑定。
- 修复的核心 :利用「默认参数在定义时求值」这一特性,在定义的那一刻把对象的引用固定下来,从而与外层变量的后续变化脱钩 ------ 顺带记住它的反面用法(可变默认参数),以及「快照的是引用不是副本」这个边界。