1.3 new 与 delete:手工记账的完整流程


1.3 new 与 delete:手工记账的完整流程

本节摘要:一条 new 表达式是"operator new 开户登记 + 构造函数初始化"两道工序,一条 delete 表达式是"析构函数 + operator delete 销账"两道反向工序。把工序拆开、还能逐道打印观察,是堆内存审计的基本功;本节用可重载的记账钩子把每一步亮在眼前。

上一节解释了开户登记与物理兑现的分离,本节回到语言层:new 到底做了什么。1.1 节说过堆区手工记账责任在人,但要负起责任,得先看清"手工动作"究竟包含哪几道工序。本节是 1.1 与 1.2 的汇合点,也是通往第 2 章 RAII 制度的必经站——制度之所以立得起来,正因为流程本身是确定的两道工序。

先看一笔账

下面的类把自己那两道工序全部挂钩打印。跑一遍,new 与 delete 的内幕就摊在桌面上了:

#include <cstdio> #include <cstdlib> #include <new> struct Ticket { int id; static int live; // 当前未销账的存量和 explicit Ticket(int i) : id(i) { ++live; std::printf(" 构造 票号 %d,存量 %d\n", id, live); } ~Ticket() { --live; std::printf(" 析构 票号 %d,存量 %d\n", id, live); } static void* operator new(std::size_t n) { // 工序一:开户登记 std::printf("operator new 申请 %zu 字节\n", n); if (void* p = std::malloc(n)) return p; throw std::bad_alloc{}; // 登记失败,抛异常报账 } static void operator delete(void* p) noexcept { // 反向末道工序:销账 std::printf("operator delete 销账\n"); std::free(p); } }; int Ticket::live = 0; int main() { Ticket* t = new Ticket(101); // 先 operator new,再构造函数 delete t; // 先析构函数,再 operator delete return 0; }

输出是确定的(字节数按主流 64 位实现的 int 大小):

operator new 申请 4 字节 构造 票号 101,存量 1 析构 票号 101,存量 0 operator delete 销账

解读这份流水:new 一共留下三条痕迹——登记、构造、存量加一;delete 也是三条——析构、存量减一、销账。顺序不可颠倒:构造必须在登记之后(得先有内存才有对象),析构必须在销账之前(得先析构对象再还内存)。C++ 标准把这两道工序钉死,任何实现都不例外。

图 1-3 一条 new 与一条 delete 的四道工序

图 1-3 一条 new 与一条 delete 的四道工序

一、开户失败的两种报账方式

operator new 登记失败时默认抛 std::bad_alloc——账本拒绝,程序收到异常。有些场合(禁用异常的嵌入式代码、 tolerate 失败的缓存逻辑)不想要异常,可用 nothrow 形式:失败返回空指针,账本以"批注退回"代替"报警报账"。

#include <cstdio> #include <new> int main() { // 不抛版本:失败返回空指针 if (int* p = new (std::nothrow) int[1000]) { p[0] = 1; std::printf("数组开户成功,首元素 %d\n", p[0]); delete[] p; // new[] 必须配对 delete[] } else { std::printf("开户被拒,返回空指针\n"); } // 巨额开户:小内存机器上可直接观察失败路径 if (int* big = new (std::nothrow) int[1u << 30]) { std::printf("大额开户成功\n"); delete[] big; } else { std::printf("大额开户被拒,程序继续运行\n"); } return 0; }

内存充裕的机器上两行都会成功;把可用内存限到几百 MB 再跑,第二行就走到拒绝分支。注意数组形式走的是 operator new[] 与 operator delete[] 这对接口,和单对象的 operator new/delete 是两套钩子,别混着重载。还有一条铁律在本节只需先立牌坊:new[] 开的户必须 delete[] 销,new 开的户必须 delete 销——错配的后果(未定义行为,典型表现是堆元数据损坏、崩在毫不相干的位置)留到 1.4 的坏账现场再展开。

二、案例:用记账钩子审出一次"只构造不销账"

背景:一个消息处理模块内存缓慢上涨,怀疑有泄漏,但代码里 delete 写得齐齐整整,评审看不出毛病。

操作:给最可疑的 Message 类挂上本节的记账钩子(operator new/delete 打印加存量统计),在压测环境跑十分钟,定期抓存量数字。同时把析构函数里也加了一行打印。

结果:流水显示 operator new 的次数持续增长,析构打印只出现了一部分——大量对象"开了户、进了存量,析构与销账再没出现过"。顺着计数差找到一条早退路径:某分支抛出业务异常,跳过了函数尾部的 delete。

解读:这就是手工记账的软肋——销账动作写在流程末尾,任何一条早退路径(return、异常、continue)都可能把销账甩掉。评审盯不出所有路径,钩子却能把漏网之鱼数出来。异常路径下 new 本身是安全的(构造没跑完时,工序一抛出会自动把已登记的内存退回),真正危险的是"开户成功之后、销账之前"之间的一切意外。

变式:同一套钩子换个方向也能审"过度记账"——把打印换成计数器,统计高频路径上的分配次数,为第 6 章的分配器优化提供账目依据。钩子不必手写,AddressSanitizer 等工具内置了更完整的记账流水(见 6.4),但亲手挂一遍钩子,才知道工具报告里每一行对应哪道工序。

💡 关键直觉:new 和 delete 不是"一句话",是"两道工序各两步"。所有堆内存坏账都能在图 1-3 的四道工序上指出断点——泄漏断在反向流程没跑,悬垂断在反向流程跑完还拿着旧地址,双重释放是反向流程跑了两遍。第 2 章的 RAII 制度,本质就是把"反向流程必须跑"从人工承诺变成语言保证。

本节要点回顾

  • 两道正向工序:operator new 开户登记,构造函数初始化;顺序由语言钉死。
  • 两道反向工序:析构函数清理,operator delete 销账;delete 表达式自动按此序执行。
  • 失败报账:默认抛 std::bad_alloc,nothrow 形式返回空指针,按场景选择。
  • 接口配对:new 配 delete、new[] 配 delete[],类级钩子也是两套独立接口。
  • 构造抛异常自动回滚:工序二失败时已登记内存自动退回,不泄漏;危险区间在开户成功到销账之间。
  • 钩子审计:重载 operator new/delete 加存量统计,是排查泄漏、统计分配频率的最低成本手段。

下一节把这些工序的断点一一摆上台面:泄漏、野指针、悬垂指针、双重释放四类坏账的现场复现与一次完整排错——看完你就明白,为什么单靠"写代码时小心"守不住堆账本。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U