Python 与 C/C++ 内存模型对比总结

Python 与 C/C++ 内存模型对比总结

前言

本文从底层内存的角度,对比 C/C++ 的值语义与 Python 的引用语义,内容覆盖变量存储、赋值行为、容器底层结构、闭包迟绑定陷阱,以及 lambda i=i: i 的修复原理。行文尽量口语化,只保留我认为需要记住的部分。

两点说明:

  • 文中关于 Python 的描述以 CPython 为准,涉及内存结构的部分属于实现层面,不同解释器(PyPy、Jython)或不同版本可能略有出入。
  • 为了讲清主干,正文会做一些简化,明显说过头的地方我单独放在每节末尾的「边界」里。

一、核心区别:值语义与引用语义

1.1 C/C++:值语义

  1. 变量就是那块内存本身。
  2. 自动存储期的基础类型(intchardouble)在栈上分配空间,数值直接存在这块空间里
  3. int a = 100;
    • 栈上分配 4 字节
    • 内存中直接存放数值 100
  4. int a = b = c = d = 100;
    • 栈上 4 块相互独立的内存
    • 4 份独立数值,地址互不相同,互不影响
  5. a = 200;
    • 原地覆盖栈内存里的数据,不涉及新内存

一句话:C/C++ 操作的是数据本身。

1.2 Python:引用语义

  1. CPython 中,intstr、容器等对象都分配在上,不存在「栈上直接存数值」这回事。
  2. Python 的变量是一个名字(存在栈帧或模块命名空间里),保存的是堆对象的地址,而不是数据本身。
  3. a = 100
    • 堆上创建 int(100) 对象(包含引用计数、类型指针、数值字段)
    • 名字 a 指向这个堆对象
  4. a = b = c = d = 100
    • 4 个不同的名字
    • 都指向堆上同一个 100 对象(依托小整数池复用)
  5. a = 200
    • 不修改原对象(Python 的 int 不可变)
    • 堆上新建 200 对象
    • 名字 a 改为指向新对象

一句话:Python 操作的是对象的引用(地址标签)。

1.3 两种模型的对照

对比项 C/C++ Python
变量是什么 一块内存本身 一个指向对象的名字
数值存在哪 栈上(自动存储期变量) 堆上的对象里
a = b = 100 两份独立数据 两个名字指向同一对象
a = 200 做了什么 原地覆盖 新建对象 + 改指向
类型检查 编译期 运行期
内存谁来管 手动(或 RAII) 引用计数 + 循环 GC

1.4 边界(这几处别说过头)

  • 小整数池只覆盖 -5 ~ 256 范围外的整数不保证复用;只不过在同一个代码块内,相同的字面量可能被编译器折叠成同一个常量对象,所以"看起来"也复用了。
  • a = 200 新建对象」不等于 100 立刻被回收。 如果 bcd 还指向它,引用计数只是减 1,对象照样活着。
  • 「栈上」只针对自动存储期变量。 C 里的全局变量、static 变量并不在栈上。
  • 字符串驻留是有条件的。 只有"看起来像标识符"的字符串字面量在编译期强制驻留,其余情况属于实现的可选优化,不能依赖。

二、Python 的引用机制,可以类比 C++ 的 shared_ptr

这一节是类比,不是等价关系 ------ 但拿来建立直觉很好用。

2.1 相似的地方

相似点 说明
计数式管理 都用引用计数决定堆内存何时释放
共享而非拷贝 多变量指向同一对象时,只复制指针,不复制对象本体
归零即释放 计数降到 0,自动释放堆内存

2.2 关键区别

区别 C++ shared_ptr Python
是否显式 需要手动声明类型 所有对象默认就走引用计数,没有例外
普通类型 int 仍是栈上值语义 同样走堆对象 + 引用计数
循环引用 互相持有 → 计数永不归零 → 泄漏 额外有分代循环 GC,能回收容器形成的环
缓存机制 标准库没有内置 有小整数池、字符串驻留

一个容易被忽略的细节:shared_ptr 本身是个「指针对象」(还有一层控制块),而 Python 的名字更接近「裸指针 + 自动计数」。所以类比时要留个心眼。


三、三大容器的底层结构

统一前提

  • 容器对象本体在
  • 容器内部通常不存放元素本体,而是存放指向元素的指针/引用

3.1 list ------ 动态指针数组

  1. 底层是堆上的动态指针数组 ,附带两个量:size(实际元素数)和 capacity(已申请的容量)。
  2. 可变lst[i] = 99 只是把数组里第 i 个指针换掉,不修改原来那个对象。
  3. 可扩容:容量不够时,新建一个更大的堆数组 → 拷贝所有指针 → 释放旧数组。
  4. 整个过程里,列表对象自身的地址不变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 ------ 固定不可变指针数组

这里是重点,也是容易理解偏的地方。

元组的「不可变」指的是 ------ 元组内部那些指针本身不可变,而不是"指针指向的内容永远不变"。

  1. 元组创建后,内部所有元素的指针被固定下来,禁止增删,也禁止替换指针。
  2. 禁止的操作:t[0] = 20 ------ 需要改动元组内部的指针,直接 TypeError
  3. 允许的操作:
python 复制代码
t = (10, [1, 2])
t[1].append(3)      # OK:没有动元组的指针,只是改了它指向的那个 list 的内部数据
print(t)            # (10, [1, 2, 3])

口诀:元组锁的是指针,不锁指针指向的内容。

C++ 类比:相当于一个 conststd::tuple,里面装了一个 vector ------ 元组保证「元素的指针不变」,但你完全可以通过这个指针去改它指向的内容。

3.3 dict ------ 哈希表

  1. 底层是堆上的哈希表,每个条目记录 hash 值、key 指针、value 指针
  2. CPython 3.6 之后用的是索引数组 + 紧凑条目数组的结构 ------ 这也是它「顺便」变成有序的原因。
  3. 采用开放寻址 (配合扰动探测),查找/增删平均 O(1)
  4. key 必须是可哈希 的类型(intstrtuple 等不可变对象),以保证哈希值稳定。
  5. 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 内存层面发生了什么

  1. 循环变量 i 自始至终只有一个,它存在于外层的命名空间里,不会每轮循环新建一个变量。
  2. 循环过程中,名字 i 依次指向堆对象 012
  3. lambda 捕获的是外层的变量名 i(准确说是它所在的那个作用域单元),而不是「当下的数值快照」。
  4. 循环结束时,i 停在堆对象 2 上。
  5. 真正调用 f() 时才去读取 i 当前指向谁 ------ 所以三个函数都读到 2

术语上这叫 迟绑定(late binding):闭包绑定的是变量(作用域),不是值。

4.3 一个常被追问的点:01 去哪了

  1. 012 三个堆对象在循环中都正常创建过。
  2. 01 落在小整数池范围内,所以对象本身不会销毁,会一直常驻。
  3. 但三个 lambda 从来没有保存过 01 的指针 ------ 它们只绑定了名字 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 没有变 */
}

调用发生时,amain 的栈帧上,xf 的栈帧上,是两块不同地址的内存 。默认情况下 C/C++ 做的是值拷贝 :把实参的值写进形参那块内存。所以函数一进门,形参拿到的天然就是一份独立快照,改它不会影响外面。

(想让形参和实参指向同一块内存,必须显式写成 int& xint* 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 核心机制

函数默认参数是在「定义函数的那一刻」立即求值的,而不是调用时求值。

于是:

  1. 每一轮循环创建 lambda 时,当场把外层 i 指向的那个对象指针拷贝一份,存进自己的默认参数槽里。
  2. 等价于分别写出了三个函数:
python 复制代码
lambda i=0: i
lambda i=1: i
lambda i=2: i
  1. 每个 lambda 内部各自持有一份当下的对象指针
  2. 从此与外层的 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 这种不可变类型,「引用快照」和「值快照」的效果一样,所以没问题;

但如果默认值本身是个可变对象,各个闭包依然共享同一个对象:

python 复制代码
data = [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: ipartial、工厂函数

附录:同一个机制带来的另一个坑 ------ 可变默认参数

既然「默认参数在定义时求值」,那它自然只求值一次。如果把可变对象当默认值,就会在多次调用之间被共享:

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=[])
利用的特性 默认参数定义时求值 默认参数定义时求值
结果 想要的效果 ✅ 不想要的效果 ❌

区别只在于:前者我们主动利用 它来固定一个值,后者被它意外坑到


小结

  1. C/C++ 操作栈上的数据本身;Python 操作堆对象的引用。 这是所有差异的源头。
  2. 两边都有栈。 C/C++ 每次调用在栈上开新帧,形参是新内存;Python 同样有帧栈,每次调用压入新帧,形参是帧内按位置编号的槽位 ------ 所以形参和实参在两个不同位置这件事,两边是一致的。同时存在的帧绝不重叠,返回后空间回收复用。
  3. 参数传递是这条差异最直接的体现 :C/C++ 默认把实参的 拷进形参那块新内存;Python 只拷地址,形参和实参指向同一个对象。
  4. Python 的引用机制可以类比 shared_ptr:计数式回收 + 天然共享,但 Python 多了循环 GC 和内置缓存。
  5. 容器统一规律 :本体在堆上,内部存的是指针而不是元素本身。所以 list 能换指针、能扩容;tuple 锁死了指针但锁不住指针指向的内容。
  6. 闭包陷阱的根源:没有捕获列表,闭包默认绑定的就是「变量」而不是「值」,于是出现迟绑定。
  7. 修复的核心 :利用「默认参数在定义时求值」这一特性,在定义的那一刻把对象的引用固定下来,从而与外层变量的后续变化脱钩 ------ 顺带记住它的反面用法(可变默认参数),以及「快照的是引用不是副本」这个边界。
相关推荐
心态还需努力呀1 小时前
把 Flameshot 编译进鸿蒙 PC:一次 Qt C++ 截图工具的源码级移植实战
c++·qt·harmonyos
砚底藏山河1 小时前
容错重试与指数退避:网络抖动手抖不再丢数据(魔码量化实战 #04)
java·数据库·python·金融
今儿敲了吗2 小时前
02词云生成器
笔记·python
李高钢2 小时前
Python Tornado 框架入门:从零搭建你的第一个异步 Web 应用
前端·python·tornado
白色的北极熊2 小时前
c语言 输出菱形字符串
c语言·开发语言
科学实验家2 小时前
最小生成树:Prim,kruskal
数据结构·c++·算法
学习智者2 小时前
《玄》IDE v3.6.3重磅发布:全功能修复与性能飞跃
开发语言·c++·ide·算法·中文语言 玄
2402_882893862 小时前
C++异常:从概念到实践的一篇博客
c++·异常
m0_734571762 小时前
深入理解C++ 析构函数<二>析构顺序
开发语言·c++