📑 本页目录(点开跳转)
02 · 值语义与所有权:谁负责释放
⏱ 90 分钟 | ⭐ Python 的 = 默认共享,C++ 的 = 默认复制 —— 两边的默认值正好反着
🎯 一句话
在 Python 里你从来不用回答「谁负责释放」,因为语言替你答了;到了 C++,这个问题必须由代码里的某个符号明确回答 —— 而源码里那些 const Tensor&、std::move、unique_ptr 就是答案本身。
读懂它们不需要会写 C++,只需要知道每一个符号在说「我拿走了」还是「我只是借来看看」。
🧩 一、同一个问题被切成了三块,这一章只管第三块
你在站内会撞见三个长得很像、其实完全不同的现象。它们分属三个板块,先把界划清楚,免得你在这一章找一个不在这一章的东西。
| 现象 | 一句话 | 归哪一章 |
|---|---|---|
| 名字绑到对象 | b = a 之后 a is b 为真,改 b 就是改 a |
《Python 会咬你的地方》01 |
| 缓冲区被共享 | y = x[:2] 之后是两个不同的对象,但它们指着同一块内存 |
《NumPy 与向量化思维》04 |
| ⭐ 谁负责释放 | 这块内存什么时候消失、由谁决定 | 这一章 |
前两块 Python 都替你处理了,所以你从来没被问过第三个问题。下面这段把前两块跑出来,好让你确认自己认得它们 —— 然后这一章就再也不碰它们了:
# c02_three.py
import sys
# ① Python 的 = :给同一个对象再挂一个名字
a = [1, 2, 3]
b = a
b.append(4)
print("① Python: a =", a, " b =", b, " 同一个对象吗:", a is b, " id 相同吗:", id(a) == id(b))
# ② NumPy 的切片:两个不同的对象,共用同一块缓冲区
import numpy as np
x = np.array([1, 2, 3, 4])
y = x[:2]
y[0] = 99
print("② NumPy : x =", x, " y =", y, " 同一个对象吗:", x is y, " y 借的是谁的内存:", y.base is x)
# ③ 引用计数:Python 自己数
c = []
print("③ refcount 一开始:", sys.getrefcount(c) - 1) # 减掉传参那一次
d = c
print(" 多挂一个名字后:", sys.getrefcount(c) - 1)
del d
print(" del 掉之后 :", sys.getrefcount(c) - 1)
实跑输出:
操作步骤
- Python: a = [1, 2, 3, 4] b = [1, 2, 3, 4] 同一个对象吗: True id 相同吗: True
- NumPy : x = [99 2 3 4] y = [99 2] 同一个对象吗: False y 借的是谁的内存: True
- refcount 一开始: 1
- 多挂一个名字后: 2
- del 掉之后 : 1
⭐ 第 ③ 段是这一章的引子。 Python 内置了一个计数器:每多一个名字指向某个对象,计数 +1;每少一个,计数 -1;归零就释放。这件事你从来没写过一行代码去做它,它是语言的一部分。
⭐ C++ 没有这个内置计数器。 所以「什么时候释放」必须写在代码里,而 C++ 提供了好几种写法 —— 源码里那一堆符号,就是不同的写法。
🧩 二、⚠️ 第一个反直觉:C++ 的 = 默认是复制
这是从 Python 下来的人最容易读错的一处,而且它不报错,只是让你把源码理解反。
// c02_copy.cpp
// g++ -O2 -std=c++17 c02_copy.cpp -o c02_copy
#include <cstdio>
#include <vector>
#include <string>
#include <utility>
struct Loud {
std::string name;
std::vector<double> data;
Loud(std::string n, size_t k) : name(std::move(n)), data(k, 1.0) {
std::printf(" [构造] %s (%zu 个 double)\n", name.c_str(), data.size());
}
Loud(const Loud& o) : name(o.name + "-拷贝"), data(o.data) {
std::printf(" [拷贝构造] %s <- %s ⚠️ 复制了 %zu 个 double\n",
name.c_str(), o.name.c_str(), data.size());
}
Loud(Loud&& o) noexcept : name(std::move(o.name)), data(std::move(o.data)) {
std::printf(" [移动构造] %s ⭐ 只搬了指针,原对象现在有 %zu 个\n",
name.c_str(), o.data.size());
}
~Loud() {
std::printf(" [析构] %s\n", name.empty() ? "(已被搬空)" : name.c_str());
}
};
void take_by_value(Loud x) { std::printf(" -> 函数里拿到 %s\n", x.name.c_str()); }
void take_by_ref(const Loud& x) { std::printf(" -> 函数里拿到 %s(没拷贝)\n", x.name.c_str()); }
int main() {
std::printf("① 传值:\n");
{ Loud a("a", 3); take_by_value(a); }
std::printf("\n② 传 const 引用:\n");
{ Loud b("b", 3); take_by_ref(b); }
std::printf("\n③ 传值 + std::move:\n");
{ Loud c("c", 3); take_by_value(std::move(c)); }
return 0;
}
实跑输出(g++ 15.1.0,-O2 -std=c++17):
操作步骤
⭐ 三件事一次看清:
- ① 传值 = 真的复制了一份。
data里那 3 个 double 被逐个复制,函数结束时那份副本被单独析构。如果data里是 1000 万个数,这里就复制 1000 万个数。 - ②
const Loud&一次拷贝都没有。整个过程只有一次构造一次析构 —— 函数里拿到的就是外面那个对象本身。 - ③
std::move之后走的是移动构造,输出里那句「原对象现在有 0 个」是关键:数据被搬走了,源对象空了。
📋 两边的默认值对照:
b = a 的意思 |
改 b 会影响 a 吗 |
|
|---|---|---|
| Python | 给同一个对象再挂一个名字 | ✅ 会(它们本来就是一个东西) |
| C++ | 把 a 整个复制一份给 b |
❌ 不会(两份互不相干) |
⚠️ 所以从 Python 带过来的直觉在这里会给你两种相反的错:以为「不会拷贝」的地方悄悄拷贝了一整个矩阵(性能问题),以为「会跟着变」的地方根本没变(正确性问题)。
🧩 三、于是源码里到处是 const Tensor&
上一节的输出只有 3 个 double,看不出代价。把规模放大量一下 —— 每个 vector 约 78 KB,5000 个:
// c02_cost.cpp
// g++ -O2 -std=c++17 c02_cost.cpp -o c02_cost
#include <cstdio>
#include <vector>
#include <chrono>
#include <utility>
using Clock = std::chrono::steady_clock;
static double ms_since(Clock::time_point t) {
return std::chrono::duration<double, std::milli>(Clock::now() - t).count();
}
int main() {
const size_t N = 10'000; // 每个 vector 1 万个 double ≈ 78 KB
const int R = 5000;
// 计时之外先把 5000 个源 vector 都造好,两组用的是同样的东西
std::vector<std::vector<double>> poolA(R, std::vector<double>(N, 3.0));
std::vector<std::vector<double>> poolB(R, std::vector<double>(N, 3.0));
std::vector<std::vector<double>> bag;
double sink = 0;
// ① 拷贝:源对象还留着,所以必须重新分配一块再逐字节复制
bag.clear(); bag.reserve(R);
auto t = Clock::now();
for (int i = 0; i < R; ++i) bag.push_back(poolA[i]); // ⚠️ 拷贝构造
double t_copy = ms_since(t);
sink += bag[0][0] + poolA[0].size(); // ⭐ 源还在,size 仍是 N
// ② 移动:告诉编译器「源我不要了」,于是只搬指针
bag.clear(); bag.reserve(R);
t = Clock::now();
for (int i = 0; i < R; ++i) bag.push_back(std::move(poolB[i])); // ⭐ 移动构造
double t_move = ms_since(t);
sink += bag[0][0];
std::printf("%d 个 vector,每个 %.0f KB(合计 %.0f MB)\n",
R, N * sizeof(double) / 1024.0, R * N * sizeof(double) / 1048576.0);
std::printf("① 拷贝进 bag: %8.2f ms\n", t_copy);
std::printf("② 移动进 bag: %8.2f ms\n", t_move);
std::printf(" 倍数 : %8.1f x\n", t_copy / t_move);
std::printf("移动之后,源 poolB[0] 的 size = %zu(被搬空了)\n", poolB[0].size());
std::printf("拷贝之后,源 poolA[0] 的 size = %zu(还在)\n", poolA[0].size());
std::printf("(sink=%.1f)\n", sink);
return 0;
}
跑三次的实跑结果:
| 次 | ① 拷贝 | ② 移动 | 倍数 |
|---|---|---|---|
| 1 | 264.02 ms | 0.02 ms | 16197x |
| 2 | 280.50 ms | 0.02 ms | 17104x |
| 3 | 300.58 ms | 0.02 ms | 18107x |
⭐ 倍数大到没什么意义,重点是它的形状:移动那一栏基本不随数据量增长 —— 它每次只搬 3 个指针(起点、终点、容量上限),跟里面装 1 万个还是 1 亿个 double 无关。拷贝那一栏则是老老实实分配 381 MB 再逐字节复制。
⚠️ 顺带一个诚实的记录:我最早的版本还量了「传 const&」这一组,结果是 0.00 ms —— 编译器在 -O2 下把整个调用优化没了,算出来的倍数是几万到几百万,纯属噪声。所以正文里的对照换成了上面这组:两边都真的把东西放进了 bag,只有「要不要复制」这一点不同。
🚦 读法:把 & 当成一句声明
这就是为什么 PyTorch 的 C++ 源码里,函数签名几乎清一色长这样:
Tensor add(const Tensor& self, const Tensor& other, const Scalar& alpha);
读它的时候在心里翻译成:
| 你看到的 | 它在说 |
|---|---|
const Tensor& |
「借我看看,我不改它,也不负责释放它」 —— 出了这个函数它还是外面那个 |
Tensor& |
「借我用,我会改它」 —— 外面能看见改动 |
Tensor |
「给我一份」 —— 要么复制、要么你 move 给我,反正这份归我了 |
Tensor&& |
「我只收你不要了的那种」 |
⭐ 这四行是本章最实用的一张表。 读源码卡住时,八成是把某个 & 读漏了。
🧩 四、std::move 不移动任何东西
这个名字起得很坏,坑过所有人。
⭐ std::move(x) 在运行时什么都不做。 它是一个纯粹的类型转换 —— 把 x 标记成「这是一个可以被掏空的东西」,好让编译器在重载里挑中移动那个版本而不是拷贝那个版本。真正搬东西的是移动构造函数/移动赋值,不是 std::move 本身。
所以更准确的读法是:std::move(x) = 一句承诺,「x 我之后不用了,随你处置」。
⚠️ 承诺一旦给出去,就必须遵守。 上一节输出里那两行是硬证据:
要点
移动之后,源 poolB[0] 的 size = 0(被搬空了)
拷贝之后,源 poolA[0] 的 size = 10000(还在)
💀 被 move 走的对象处于「有效但未指定」状态 —— 它还是一个合法对象(析构它是安全的,给它重新赋值也是安全的),但里面装的是什么没有任何保证。上面碰巧是 0,是因为 std::vector 的移动实现会把源置空;换一个类型可能就不是 0。
📋 两条能保命的规矩:
| 规矩 | 为什么 |
|---|---|
| ⭐ move 之后,只允许对它做两件事:重新赋值,或者让它析构 | 读它的内容是「合法但结果没有保证」,最难查 |
| ⚠️ 不要对准备继续用的东西 move | 编译器不会拦你,症状是后面某处数据莫名变空 |
🛑 读到这里可以停 —— 前半章讲完了(约 31 分钟)。 后半章还有:RAII:C++ 版的
with· 四种指针,四种所有权声明 · 💀 悬垂:这一章真正存在的理由 · ⭐ 于是Tensor长成了一个「把手」 回来的时候不用重读,直接从下一节接着看就行。
🧩 五、RAII:C++ 版的 with
现在回答开头那个问题:既然 C++ 没有内置的引用计数,那释放靠什么?
⭐ 靠作用域。 一个对象在离开它所在的大括号时,析构函数一定会被调用。于是 C++ 的惯用法是:把「要还回去的东西」包成一个对象,让析构函数去还。 这套做法有个名字叫 RAII(Resource Acquisition Is Initialization,资源获取即初始化)。
// c02_raii.cpp
// g++ -O2 -std=c++17 c02_raii.cpp -o c02_raii
#include <cstdio>
#include <stdexcept>
#include <memory>
struct Guard { // 一个最小的 RAII 对象
const char* what;
explicit Guard(const char* w) : what(w) { std::printf(" 拿到 %s\n", what); }
~Guard() { std::printf(" 还回 %s\n", what); }
};
void may_throw(bool boom) {
Guard g("锁"); // ⭐ 出这个函数时一定还锁,不管怎么出去
std::printf(" 干活中…\n");
if (boom) throw std::runtime_error("中途炸了");
std::printf(" 正常干完\n");
}
struct Node {
int id;
explicit Node(int i) : id(i) { std::printf(" Node%d 出生\n", id); }
~Node() { std::printf(" Node%d 死亡\n", id); }
};
int main() {
std::printf("① 正常返回:\n");
may_throw(false);
std::printf("\n② 抛异常:\n");
try { may_throw(true); }
catch (const std::exception& e) { std::printf(" 接住了:%s\n", e.what()); }
std::printf("\n③ unique_ptr(唯一所有者):\n");
{
std::unique_ptr<Node> p = std::make_unique<Node>(1);
std::unique_ptr<Node> q = std::move(p); // ⭐ 所有权搬走,不能拷贝
std::printf(" p 现在是空的吗: %s\n", p ? "不是" : "是");
std::printf(" q 指着 Node%d\n", q->id);
} // ⭐ q 出作用域 -> Node1 死
std::printf("\n④ shared_ptr(大家共有,最后一个走的关灯):\n");
{
std::shared_ptr<Node> a = std::make_shared<Node>(2);
std::printf(" 持有者 %ld 个\n", a.use_count());
{
std::shared_ptr<Node> b = a; // ⭐ 拷贝 = 计数 +1,不复制 Node
std::printf(" 多了一个 b,持有者 %ld 个\n", a.use_count());
}
std::printf(" b 走了,持有者 %ld 个(Node2 还活着)\n", a.use_count());
}
std::printf(" a 也走了 ↑\n");
return 0;
}
实跑输出:
操作步骤
- 正常返回:
- 拿到 锁
- 干活中…
- 正常干完
- 还回 锁
- 抛异常:
- 拿到 锁
- 干活中…
- 还回 锁
- 接住了:中途炸了
- unique_ptr(唯一所有者):
- Node1 出生
- p 现在是空的吗: 是
- q 指着 Node1
- Node1 死亡
- shared_ptr(大家共有,最后一个走的关灯):
- Node2 出生
- 持有者 1 个
- 多了一个 b,持有者 2 个
- b 走了,持有者 1 个(Node2 还活着)
- Node2 死亡
- a 也走了 ↑
⭐ ①② 的对比是 RAII 的全部价值:抛异常那一路,「干完」那行没打印,但「还回 锁」打印了,而且是在 catch 接住之前。作用域一结束析构就跑,正常返回和异常离开走的是同一条路。
📋 和 Python 的对应关系:
| Python | C++ | 差别 |
|---|---|---|
with open(f) as x: |
{ std::ifstream x(f); ... } |
Python 要你记得写 with;C++ 是只要对象在栈上就自动生效 |
__exit__ |
析构函数 | 同一个位置 |
try / finally |
析构函数 | C++ 没有 finally,因为 RAII 覆盖了它的用途 |
⭐ 回扣 01 章:那一章说 py::gil_scoped_release 是 RAII 对象、所以「哪怕函数中途抛异常锁也一定会被还回去」—— 上面 ② 那三行输出就是这句话的实况。构造时放开锁,析构时拿回来,中途炸了照样拿回来。
🧩 六、四种指针,四种所有权声明
unique_ptr / shared_ptr 那两段(③④)演示的是同一件事的两种回答。把它们和裸指针、引用放在一起:
| 你在源码里看到 | 谁负责释放 | 能不能拷贝 | 读作 |
|---|---|---|---|
T*(裸指针) |
⚠️ 文档说了算,代码里看不出来 | 能(但拷的只是地址) | 「这里有个地址,别的什么都别假设」 |
T&(引用) |
❌ 不是我 | —— | 「借我看看,出了这个函数它还归外面」 |
std::unique_ptr<T> |
⭐ 我,而且只有我 | ❌ 不能拷贝,只能 move | 「这东西归我,要给别人就得 std::move」 |
std::shared_ptr<T> |
最后一个走的人 | ✅ 能(计数 +1) | 「大家共有,谁都可能是最后一个」 |
std::weak_ptr<T> |
❌ 不是我 | ✅ 能 | 「我盯着它,但不延长它的命,用之前得先问它还在不在」 |
⭐ 读源码时把这一列当成注释读,比看真注释可靠 —— 注释会过期,类型不会,编译器会强制它成立。三条最有信息量的:
- 看到
unique_ptr出现在返回值上,你立刻知道「这个函数造了个东西,所有权交给你了」。 - 看到
unique_ptr作为参数且传值,你立刻知道「调用方必须std::move进来,之后就别用了」。 - 看到
weak_ptr,八成是在打破循环引用 —— 两个shared_ptr互相指着对方,计数永远归不了零,就泄漏了。⚠️ 这是shared_ptr唯一的经典漏洞,和 Python 引用计数的循环引用问题是同一个病(Python 靠额外的循环垃圾回收器兜底,C++ 靠你写weak_ptr)。
⚠️ shared_ptr 不是「更安全的 unique_ptr」,别无脑用它:它要多存一个控制块(计数在那里),计数的加减是原子操作,多线程下会真的争抢缓存行(03 章会看到这件事的代价)。默认选 unique_ptr,确实说不清谁最后走才用 shared_ptr。
🧩 七、💀 悬垂:这一章真正存在的理由
前面都是「怎么写对」。这一节是「写错了会怎样」,而它的可怕之处不是崩溃,是不崩溃。
// c02_dangle.cpp
// g++ -O2 -std=c++17 c02_dangle.cpp -o c02_dangle
#include <cstdio>
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
const int* p = v.data(); // ⚠️ 记住这个地址
std::printf("push 之前: data=%p capacity=%zu p[0]=%d\n",
(const void*)p, v.capacity(), p[0]);
for (int i = 0; i < 100; ++i) v.push_back(i); // 容量不够 -> 换一块更大的地方
std::printf("push 之后: data=%p capacity=%zu\n",
(const void*)v.data(), v.capacity());
std::printf("地址变了吗: %s\n", p == v.data() ? "没变" : "⚠️ 变了");
std::printf("v[0] 从新地址读 = %d (正确)\n", v[0]);
// ⚠️⚠️ 下面这一行是【未定义行为】:p 指着已经被释放的那块内存
std::printf("p[0] 从旧地址读 = %d (💀 未定义行为,本次没崩)\n", p[0]);
return 0;
}
连跑三次的实跑输出(只摘关键行):
| 次 | v[0] 从新地址读 |
p[0] 从旧地址读 |
退出码 |
|---|---|---|---|
| 1 | 10 | 618801184 | 0 |
| 2 | 10 | -283301856 | 0 |
| 3 | 10 | -1883166688 | 0 |
💀💀 三次跑出三个完全不同的垃圾数,一次都没崩,退出码全是 0。
⭐ 这就是「未定义行为」的真实模样,也是这一章存在的全部理由:
| Python 出错时 | C++ 悬垂时 | |
|---|---|---|
| 有没有异常 | 有,IndexError / AttributeError |
❌ 没有 |
| 有没有 traceback | 有,指到出错那一行 | ❌ 没有 |
| 能不能复现 | 能 | ⚠️ 时好时坏,加个 printf 可能就"好了" |
| 错在哪一行 | 就是报错那一行 | ⚠️ 可能在几百行之前 —— 崩的地方和错的地方无关 |
⚠️ 注意它出错的机制:p 拿到地址的那一刻是完全合法的;是后来的 push_back 让 vector 换了一块更大的内存,把旧的那块还给了系统。p 自己什么都没做错,是它指着的东西跑了。
📋 跨语言时最常见的三种悬垂(全是 01 章第五节那个坑的展开):
| 情形 | 谁跑了 |
|---|---|
C++ 那边存了 float* ptr = arr.data(),Python 那边这个数组被回收了 |
数组的内存 |
| C++ 存了指向某个元素的指针,容器随后扩容 / 重排 | 整块缓冲区搬家了(就是上面这个实验) |
| 函数返回了一个指向局部变量的引用或指针 | 局部变量随函数栈帧一起消失 |
🚦 对照:Python 遇到同一种情况会拦住你
Python 不是没有这个问题,是它加了一道锁:只要还有人在看着某块缓冲区,就不许把它挪走。
# c02_buffer.py
buf = bytearray(b"hello")
mv = memoryview(buf) # ⭐ 有人在看着这块内存了
try:
buf.extend(b"world") # 会导致重新分配 —— 正是上面 C++ 那个实验干的事
except BufferError as e:
print("BufferError:", e)
mv.release() # 看完了,松手
buf.extend(b"world")
print("release 之后再 extend 就成功了:", bytes(buf))
实跑输出:
要点
BufferError: Existing exports of data: object cannot be re-sized
mv.release() 之后再 extend 就成功了: b'helloworld'
⭐ 同样是「有人指着我,而我想换个地方」,Python 抛了一个能看见的异常,C++ 打印了 618801184 然后正常退出。 这一条差别,就是你为什么必须自己去读那些 const& 和 unique_ptr。
🛑 读到这里可以停 —— 已经读了约 57 分钟。 最后一段还有(约 31 分钟):⭐ 于是
Tensor长成了一个「把手」 · 检查点与走神救援 回来的时候不用重读,直接从下一节接着看就行。
🧩 八、⭐ 于是 Tensor 长成了一个「把手」
把前面几节合起来,能解释一个你天天在用、但可能没想过为什么的现象:PyTorch 里 b = a 从来不复制数据。
在 C++ 那一侧,at::Tensor 不是一个装着数据的大对象,它是一个只有一个指针那么大的把手,真正的数据挂在它指向的那个实现对象上。用标准库的 shared_ptr 可以把这个形状完整复现出来:
// c02_handle.cpp
// g++ -O2 -std=c++17 c02_handle.cpp -o c02_handle
#include <cstdio>
#include <vector>
#include <string>
#include <memory>
// 真正装数据的那个东西
struct Impl {
std::vector<float> data;
explicit Impl(size_t n) : data(n, 1.0f) { std::printf(" Impl(%zu) 出生\n", n); }
~Impl() { std::printf(" Impl 死亡\n"); }
};
// 外面这层只是一个「把手」:里面就一个指针
struct Handle {
std::shared_ptr<Impl> impl;
explicit Handle(size_t n) : impl(std::make_shared<Impl>(n)) {}
size_t size() const { return impl->data.size(); }
long owners() const { return impl.use_count(); }
};
int main() {
std::printf("sizeof(std::vector<float>) = %zu 字节\n", sizeof(std::vector<float>));
std::printf("sizeof(std::string) = %zu 字节\n", sizeof(std::string));
std::printf("sizeof(std::shared_ptr<Impl>) = %zu 字节\n", sizeof(std::shared_ptr<Impl>));
std::printf("sizeof(std::unique_ptr<Impl>) = %zu 字节\n", sizeof(std::unique_ptr<Impl>));
std::printf("sizeof(Handle) = %zu 字节 ⭐ 和里面装多少数据无关\n\n",
sizeof(Handle));
Handle a(1'000'000); // 100 万个 float
std::printf("a 有 %zu 个 float,持有者 %ld\n", a.size(), a.owners());
Handle b = a; // ⭐ 拷贝「把手」,不拷贝那 100 万个 float
std::printf("b = a 之后,持有者 %ld,b 也有 %zu 个\n", b.owners(), b.size());
std::printf("a 和 b 指着同一块数据吗: %s\n",
a.impl->data.data() == b.impl->data.data() ? "是" : "否");
std::printf("主函数结束 ↓\n");
return 0;
}
实跑输出:
算一算
sizeof(std::vector<float>) = 24 字节
sizeof(std::string) = 32 字节
sizeof(std::shared_ptr<Impl>) = 16 字节
sizeof(std::unique_ptr<Impl>) = 8 字节
sizeof(Handle) = 16 字节 ⭐ 和里面装多少数据无关
Impl(1000000) 出生
a 有 1000000 个 float,持有者 1
b = a 之后,持有者 2,b 也有 1000000 个
a 和 b 指着同一块数据吗: 是
主函数结束 ↓
Impl 死亡
⭐ Handle 只有 16 字节,里面装 100 万个 float 也还是 16 字节。 而 b = a 之后持有者变成 2、两边 data() 是同一个地址、Impl 只死了一次 —— 这正是第二节那个「C++ 的 = 默认复制」的例外形状:复制的确发生了,只不过复制的是把手,不是数据。
⚠️ 注意这不是语言的例外,是设计者主动选的:Handle 里唯一的成员是 shared_ptr,而 shared_ptr 的拷贝构造被定义成「计数 +1」。默认规则没变,是这个类的成员选择让「复制它」变得很便宜。
Python 那一侧看得到同一件事:
# c02_torch_ptr.py
import torch
x = torch.ones(1000)
y = x
z = x.clone()
print("y = x -> data_ptr 相同:", x.data_ptr() == y.data_ptr())
print("z = clone-> data_ptr 相同:", x.data_ptr() == z.data_ptr())
实跑输出(torch 2.13.0+cpu):
关键信息
⭐ 于是「显存为什么没释放」这类问题有了 C++ 侧的解释:del x 只是让 Python 少一个名字;底下那块显存要等持有者计数归零才真的还回去,而计数里可能还有别人 —— 一个还没被回收的中间结果、一个存进 list 的张量、一段计算图。⚠️ 具体怎么省显存不在这一章,AI 基础设施 09 有完整的账。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 《Python 会咬你的地方》01 | 三方分工的第一块:「名字绑到对象」。b = a 之后 a is b 为真是那边的正题,本章第一节只是引用它来划界 |
| 《NumPy 与向量化思维》04 | 三方分工的第二块:「缓冲区被共享」。y.base is x 为什么成立、什么时候是视图什么时候是拷贝,全在那边 |
| 01 章 | 那一章第五节最后一个坑写着「C++ 存了指向 Python 对象内存的指针,而 Python 那边已经把它回收了 —— 谁负责释放是 02 章的正题」,⭐ 第七节就是那句话的兑现;第五节还解释了 gil_scoped_release 为什么抛异常也能还锁 |
| 03 章 | 第六节说 shared_ptr 的原子计数「多线程下会真的争抢缓存行」—— 那件事的代价在下一章量出来 |
| AI 基础设施 09 | 第八节说显存要等持有者计数归零;怎么让它归零、能省多少是那一章的账,本章不重算 |
| 《PyTorch 这个框架本身》 | state_dict、detach、计算图什么时候放手,是 Python 接口那一侧的事 |
✅ 检查点
- 「名字绑到对象」「缓冲区被共享」「谁负责释放」分别归哪一章?本章管哪一个?
- Python 和 C++ 的
b = a默认行为分别是什么?从 Python 带过来的直觉会造成哪两种相反的错? c02_copy.cpp里,传值、传const&、传值+std::move三种写法各触发了什么?「原对象现在有 0 个」这句话说明了什么?- 5000 个 78 KB 的 vector,拷贝进容器和移动进容器实跑各是多少毫秒?移动那一栏为什么基本不随数据量增长?
- 为什么正文最后没有拿「传
const&」那一组做对照? std::move在运行时做了什么?被 move 走的对象之后只允许对它做哪两件事?- RAII 是什么?
c02_raii.cpp抛异常那一路,哪一行输出证明了它有效?C++ 为什么不需要finally? T*/T&/unique_ptr/shared_ptr/weak_ptr各自在声明「谁负责释放」的什么答案?weak_ptr最常用来解决什么问题?c02_dangle.cpp三次跑出的旧地址读数分别是多少?退出码是多少?这说明「未定义行为」长什么样?- 同样是「有人指着我而我想换地方」,Python 的
memoryview+bytearray会发生什么? sizeof(Handle)是多少字节?为什么它和里面装 100 万个 float 无关?这解释了 PyTorch 里的哪个现象?
👀 答案
- 「名字绑到对象」 → 《Python 会咬你的地方》01;「缓冲区被共享」 → 《NumPy 与向量化思维》04;「谁负责释放」 → 本章。前两块 Python 都替你处理了,所以你从来没被问过第三个问题。
- Python 的
b = a是给同一个对象再挂一个名字(a is b为真,改b就是改a);C++ 的b = a是把a整个复制一份(两份互不相干)。两种相反的错:以为不会拷贝的地方悄悄拷贝了一整个矩阵(性能问题),以为会跟着变的地方根本没变(正确性问题)。 - 传值 → 拷贝构造(那 3 个 double 被逐个复制,函数结束时副本单独析构);传
const Loud&→ 一次拷贝都没有,全程只有一次构造一次析构;传值+std::move→ 移动构造。「原对象现在有 0 个」说明数据被搬走了、源对象空了。 - 拷贝 264.02 / 280.50 / 300.58 ms,移动 0.02 ms(三次都是),倍数 16197x / 17104x / 18107x。移动只搬 3 个指针(起点、终点、容量上限),跟里面装 1 万个还是 1 亿个 double 无关;拷贝要真的分配 381 MB 再逐字节复制。
- 因为编译器在
-O2下把那个调用整个优化没了,量出来是 0.00 ms,算出的倍数是几万到几百万,纯属噪声。换成的对照里两边都真的把东西放进了容器,只差「要不要复制」这一点。 - 运行时什么都不做 —— 它只是一个类型转换,把
x标记成「可以被掏空」,好让编译器挑中移动版本的重载。真正搬东西的是移动构造/移动赋值。之后只允许:重新赋值,或者让它析构。读它的内容是「有效但未指定」。 - RAII = 把「要还回去的东西」包成对象,让析构函数去还;对象离开大括号时析构一定执行。抛异常那一路,「正常干完」没打印但「还回 锁」打印了,而且在
catch接住之前。不需要finally,是因为析构函数已经覆盖了它的用途。 T*:代码里看不出来,文档说了算;T&:不是我负责,借来看看;unique_ptr:我,而且只有我,不能拷贝只能move;shared_ptr:最后一个走的人关灯;weak_ptr:不是我,盯着但不延长它的命。weak_ptr最常用来打破循环引用(两个shared_ptr互指,计数永远归不了零就泄漏)。- 618801184 / -283301856 / -1883166688,三次全不一样;退出码全是 0,一次都没崩。所以未定义行为的样子是:没有异常、没有 traceback、时好时坏、崩的地方和错的地方无关(可能在几百行之前)。
- Python 会抛
BufferError: Existing exports of data: object cannot be re-sized—— 只要还有人在看着这块缓冲区就不许挪。mv.release()之后同样的extend就成功了。同一种情况,Python 抛了个看得见的异常,C++ 打印了 618801184 然后正常退出。 - 16 字节,因为它唯一的成员是一个
shared_ptr(sizeof也是 16),数据挂在它指向的实现对象上。这解释了 PyTorch 里b = a从不复制数据:实跑中y = x之后data_ptr()相同、z = x.clone()之后不同。也解释了del x之后显存可能没还 —— 要等持有者计数归零。
🛑 可以停在这里
⚡ 走神救援
🧭 同一个问题被切成三块,这一章只管第三块:「名字绑到对象」归 Python 板块、「缓冲区被共享」归 NumPy 板块、⭐ 「谁负责释放」是本章。⭐ 前两块 Python 都替你答了——它内置引用计数,而 C++ 没有,所以「什么时候释放」必须写在代码里。
⚠️ 第一个反直觉:C++ 的
=默认是复制,和 Python 正好反着。 ⭐ 代价的形状是:拷贝的耗时随数据量走,移动几乎恒定为零——移动只搬几个指针,与数据量无关。 ⚠️ 诚实记录:其中一组被编译器优化成了 0,倍数纯属噪声,所以对照换了一组。🔑 ⭐ 最实用的一张表是「把
&当声明读」:const T&是「借我看看,不改也不负责释放」、T&是「借我用,我会改」、T是「给我一份」、T&&是「只收你不要了的」。⭐std::move运行时什么都不做——它只是个类型转换,一句「我之后不用了」的承诺。⭐ RAII 是靠作用域还东西:实跑抛异常那一路,「正常干完」没打印,而「还回锁」打印了、且在 catch 之前——⭐ 这就是上一章那个释放锁的原理,也是 C++ 不需要 finally 的原因。
💀 这一章真正的理由是「不崩」:拿到内部指针之后让容器扩容,旧指针读出来的是垃圾——三次全不同,而退出码都是 0。⭐⭐ 未定义行为的样子就是:没异常、没 traceback、时好时坏、崩的地方和错的地方无关。 ⭐ 同一种情况 Python 会拦你,抛的是一句明确的「还有人在用这块内存,不能改大小」。
⭐ 最后一节的回报:那个把手的大小和它里面装了多少数据完全无关——
Tensor就是这个形状。
下一节 👉 03-cache与数据布局.md