某国内头部私募(类似幻方量化)的初级 quant 第一次用 C++ 写了一个五日滚动 VWAP 函数。它加载 510300.SH 收盘价、用 new double[5] 申一段 buffer、算滚动均值、返回结果。单元测试过。集成测试过。两周后,同一个函数被一段每秒跑一万次的热路径调用,交易进程在一天之内常驻内存悄悄涨到 80 GB,直到内核 OOM-killer 把它收掉。bug:与 new double[5] 配对的 delete[] 落在集成测试根本走不到的某条分支上,每一次循环静悄悄漏掉 40 字节。这种 bug 你在 Python 里从来不会撞到——解释器的引用计数会在最后一个名字超出作用域的瞬间把对象释放,栈与堆的整个问题都被 PyObject 机制藏起来了。在 C++ 里这个问题是可见的,而这种可见性正是 C++ 仍然部署在低延迟系统里的全部原因。本课是整模块的概念转折点:你第一次看到堆、看到指针是什么、看到当没人负责释放你拿走的内存时 AddressSanitizer 会说什么。
一段话讲清栈与堆
每次函数调用都会压入一个栈帧(stack frame),里面装着该函数的参数与局部变量。函数返回时,栈帧弹出——名字消失、内存回收、任何指向那块栈帧的指针都变成悬空。用 new 做出的分配活在另一个区域——堆(heap)上,并一直存在,直到某个 delete 把它释放(或程序退出)。栈很快(每一次 push 一条 CPU 指令)但很小(Linux 上通常 8 MB),寿命与调用栈绑定。堆很大(以 GB 计)、你想留多久留多久,但每一次分配花上几百周期,而每一次你忘了释放的分配都是永久的损失。
void demo() {
int x = 1; // stack
int* p = new int(2); // heap
std::cout << x << ' ' << *p << '\n';
delete p;
}
x 在 demo 的栈帧里;demo 一返回,x 就没了。p 也在栈帧里(指针本身也是一个值),但它指向的那 4 字节 int 活在堆上,会比 demo 活得久,除非有人调用 delete。那行 delete p; 就是契约。忘了,4 字节就泄漏。CPython 把这个区分藏起来,是因为 Python 里每个 int 都在堆上、都被引用计数;C++ 把它暴露出来,是因为可预测的分配正是当年把 C++ 推到交易桌上的延迟预算的来源。
裸指针:声明、取址、解引用
裸指针就是带类型的内存地址。T* p; 声明一个指向 T 的指针。&x 取 x(一个 lvalue)的地址。*p 对指针解引用——把那个地址上的 T 还给你。nullptr 是 C++11 的空指针字面量——永远写 nullptr,不要写 NULL(那是一个宏,展开成 0,会丢类型安全),也不要写裸 0。
double price = 4.20;
double* p = &price;
*p = 4.21; // price 现在是 4.21
p = nullptr; // 不再指向任何东西
指针算术在数组上有效。如果 arr 是 double[5],那么 arr + i 就是地址 &arr[i],*(arr + i) 就是值 arr[i]。同一种索引发生了两次——arr[i] 字面上就是 *(arr + i) 的语法糖。这就是一个对 C 数组求和的 sum 函数所依赖的基础:
double sum(const double* arr, std::size_t n) { double s = 0.0; for (std::size_t i = 0; i < n; ++i) { s += arr[i]; } return s; }
参数 const double* arr 是一个你承诺不改的 double 指针;std::size_t n 是第 2 课里的无符号大小类型。函数不知道——也无从知道——arr 背后那个数组实际有多大;调用方把大小另传过来。这种不对称正是你过会儿要遇到的「C 数组退化陷阱」的第一个征兆。
引用:不可为空的别名
引用是 C++ 用来说「这是同一个对象的另一个名字」的工具。T& r = x; 在构造时把 r 绑到 x;从那一刻起,r 与 x 就是同一个对象。绑定不可重绑——r = y; 是把 y 的值赋给 x,并不让 r 转去指 y。引用不能为空,因为它在构造时必须用一个真实存在的对象去初始化。
函数参数的决策规则。当「没有这个元素」不是一个有意义的回答时——传一个价格、传一个订单、传一笔行情更新——用引用(T& 或 const T&)。当 null 是一个有意义的回答时——一次可能落空的查找、一个可选回调、一棵树里可能是根节点的父指针——用指针(T*)。
void scale_by_ref(double& x, double k) { x *= k; }
void scale_by_ptr(double* x, double k) { if (x != nullptr) *x *= k; }
引用版本在调用处更简洁(scale_by_ref(price, 1.01)),编译器也永远不会让 null 溜进来。指针版本是在 null 真实存在、函数必须处理它时所必需。刻意选;能用 & 就别伸手抓 *。
数组、退化以及 std::array / std::vector 的修复
C 风格数组是这门语言里最古老、最坑的一块。double arr[5] = {1.0, 2.0, 3.0, 4.0, 5.0}; 在栈上声明一个五元素数组,大小是编译期固定的。在声明它的函数内部,sizeof(arr) 返回 40(5 个 double × 8 字节),你可以用 sizeof(arr) / sizeof(arr[0]) 算出元素个数。但一旦把 arr 传给另一个函数,数组就退化(decay)成指针——那个函数里的 sizeof 就只返 8,一个指针的大小:
void caller() {
double arr[5] = {1.0, 2.0, 3.0, 4.0, 5.0};
std::cout << sizeof(arr) << '\n'; // 40 on a 64-bit build
print_size(arr); // prints 8
}
void print_size(double* arr) {
std::cout << sizeof(arr) << '\n';
}
这就是 sizeof(arr)/sizeof(arr[0]) 陷阱。它在声明所在作用域里有效,数组一被跨函数传出去就静悄悄说谎。C++ 的替代品:std::array<T, N> 用于编译期固定大小(把大小写进类型、不退化,arr.size() 永远可靠);std::vector<T> 用于运行期大小(堆上、可增长,在几乎每一个 C++ 代码库里都是默认容器)。
把 std::vector<double> 只教到操作子集——一个干活的开发者每天用的五个操作:
#include <vector>
std::vector<double> v;
v.push_back(1.0);
v.push_back(2.0);
for (const auto& x : v) {
std::cout << x << ' ';
}
std::cout << '\n' << v.size() << '\n';
push_back(x) 追加,size() 取元素数,v[i] 不做边界检查地索引(快),v.at(i) 带边界检查地索引(越界抛 std::out_of_range),范围 for (const auto& x : v) 迭代。完整的迭代器 / reserve / emplace_back / 迭代器失效故事归模块 3.4.2;上面这五个操作的子集对本模块要做的一切都够用。
动态分配:new、delete 与 AddressSanitizer
「在堆上分配」的原始形式是 new。new T(args) 构造一个 T 并返回 T*;配套释放是 delete p。对数组,new T[N] 分配一个数组并返回 T*;配套释放是 delete[] p——这条配对规则是强制的。把 new 与 delete[]、或 new[] 与 delete 配错,是 undefined behaviour:实际上堆会悄悄损坏,程序后来在跟 bug 现场毫无关系的地方崩。
这是本课最承重的练习:故意制造一个泄漏、看到报告、修掉它。
double* prices = new double[5]{4.18, 4.20, 4.22, 4.19, 4.21};
double total = 0.0;
for (std::size_t i = 0; i < 5; ++i) { total += prices[i]; }
std::cout << total << '\n';
// no delete[] — leaked
用 g++ -std=c++17 -Wall -Wextra -g -O0 -fsanitize=address main.cpp -o main(或 CMake 端 -DCMAKE_BUILD_TYPE=Debug 加上 -fsanitize=address 到 target_compile_options / target_link_options)重建,运行,程序结束时你会看到一份 ASan 报告,指着未释放的分配,带上文件名与行号。在循环之后加上 delete[] prices;,报告就干净了。
现在故意制造一次 use-after-free:delete[] prices; std::cout << prices[0];。重建,运行,ASan 报告大致是:
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x60200000eff0 at pc 0x000000401234
READ of size 8 at 0x60200000eff0 thread T0
#0 0x401233 in main main.cpp:9
freed by thread T0 here:
#0 0x7f8a1c2b3b40 in operator delete[](void*) (/usr/lib/...)
#1 0x401200 in main main.cpp:8
具体地址与栈帧编号会变;真正承重的字符是 AddressSanitizer: heap-use-after-free、READ of size 8,以及紧跟着的 freed by thread T0 here: 块,后面那个栈帧指向你的 delete[] 那一行。像读栈追踪一样读它:最顶层栈帧是坏访问发生的地方,freed by 块是内存被释放的地方。两个指针指向同一个事实。「释放后使用」的国内通用译名也称「悬空指针访问」,二者择一并在同一课内保持一致——本课使用「释放后使用」对齐 ASan 报告中 heap-use-after-free 的字面翻译。
接下来整个模块都要靠的修复
「我拥有这块分配,我的析构函数负责释放它」的生产形式,是 <memory> 里的 std::unique_ptr<T[]>,在模块 3.4.2 的第 1 课讲。本课让你写裸 new / delete 并看着 AddressSanitizer 抓住它们的失败,完全是为了让第 4 课的 RAII 故事——以及 3.4.2 的智能指针故事——有一个真问题可解。今天可带走的实践:把上面那段会漏的循环改成 std::vector<double>,bug 就变得无从可写:
std::vector<double> prices = {4.18, 4.20, 4.22, 4.19, 4.21};
double total = 0.0;
for (const auto& p : prices) { total += p; }
std::cout << total << '\n';
当 prices 走出作用域,它的析构函数释放堆上的缓冲区——没有 delete[] 可忘。第 4 课会解释那个析构函数是怎么接上去的。
Formula Explorer
leak_bytes = sizeof_T * count_per_call * calls_per_sec阅读清单:《C++ Primer》第 5 版 中译 第 12 章(动态内存)、第 6.2 节(参数传递);zh.cppreference.com 上 std::vector / std::array / new / delete 的页面;《C++ Core Guidelines》中译片段「不要裸 new,用 make_unique」(R.11)作为第 4 课的预告;国内 quant 招聘的 C++ 笔试题中「指针 vs 引用 vs 智能指针」三选一是高频题——本课覆盖前两者,3.4.2 覆盖第三者。工具一句:国内开发者用 GDB / ASan 的频率与海外无差异;如本地 GCC 版本过旧(< 7.0)导致 ASan 报告路径异常,建议通过国内 conda-forge 镜像安装较新的 GCC 工具链。
通往下一课
你现在拥有一个能用的栈-堆心智模型,能声明并使用裸指针与引用,知道 C 数组退化陷阱与 std::array / std::vector 这两个修复,也能把一份 AddressSanitizer 报告读到足以找到出事行。把你带到这里的那个泄漏,是更深问题的症状:这块分配归谁拥有,什么时候被释放?第 4 课用 C++ 里最承重的一个想法回答它——资源获取即初始化(Resource Acquisition Is Initialization, RAII)。你会写一个 FileHandle 类,构造函数打开文件、析构函数关闭文件,并看着这个类把本课的 bug 变得无从可写。
练习
Exercise
给定函数 void run() { double* prices = new double[5]{4.18, 4.20, 4.22, 4.19, 4.21}; double total = 0.0; for (std::size_t i = 0; i < 5; ++i) { total += prices[i]; } std::cout << total << "\n"; }:
(a) 用 -fsanitize=address -g 编译并运行,抓取 AddressSanitizer 报告,说出它指出哪一块堆分配。(b) 在 run 结尾加上 delete[] prices;,确认报告现在干净了。(c) 在 delete[] 之后加上 std::cout << prices[0];,确认 AddressSanitizer 报告 heap-use-after-free。(d) 把 run 改写,让 prices 是一个含同样五个值的 std::vector<double>,去掉 new / delete[],用同样的 flag 重建,确认报告干净。
提示
LeakSanitizer: detected memory leaks,并指向 new double[5]{...} 的分配点作为来源。对 (c),READ of size 8 行后紧跟的 freed by thread T0 here: 块定位 use-after-free。提示
std::vector<double> prices = {4.18, 4.20, 4.22, 4.19, 4.21}; 内部在堆上分配并在析构时释放;同样的五次循环现在透过 prices[i](或范围 for)读取,缓冲区在 run 返回时被析构函数释放。