【C++ 面试真题】聊聊 C++ 的裸指针改造
接手一个 C++ 老项目,几千行代码里散落着裸 new/delete、malloc/free:谁拥有谁说不清、哪条路径漏了释放没人知道------改一行崩三处。这是工程里最真实的内存战役,也是内存管理篇的收官题。背得出"换智能指针"只是及格,真考你的是"改造的先后路线、和 C API 怎么交接、纯观察的裸指针该不该动、真实问题怎么一步步排查"。本文按"改造 + 排查 + 综合应用"讲透。
一、开场:遗留代码的病灶
❓ 一个满地裸指针的项目,问题通常有哪几类?
✅ 先诊断、再动刀。四类典型病灶:
| 病灶 | 典型代码 | 危害 |
|---|---|---|
| 所有权不明 | 两个函数都 delete 同一指针 | double free |
| 路径遗漏 | 异常/提前 return 漏 delete | 泄漏 |
| 配对混用 | malloc 配 delete、new\[\] 配 delete | UB(Undefined Behavior),崩溃诡异 |
| 接口契约靠猜 | 返回的指针"谁释放?"没人知道 | 泄漏或 UAF(Use-After-Free) |
回答思路:先说"诊断重于动手"------搞清每块内存的 owner 是谁,再分类改造;上来无脑替换智能指针,多线程/共享场景反而会引入循环引用新坑。
二、改造路线:四步走,风险递增
❓ 改造顺序怎么定?
✅ 按风险从低到高四步走,每步可独立验证:
第一步:局部 new/delete → unique_ptr(最安全,立刻消灭泄漏):
cpp
// 改造前
void f() {
char* buf = new char[1024];
if (!ok) { delete[] buf; return; }
process(buf);
delete[] buf;
}
// 改造后:三条路径的 delete 全消失
void f() {
auto buf =
make_unique<char[]>(1024);
if (!ok) return;
process(buf.get());
}
第二步:工厂/接口返回值 → 返回 unique_ptr(把所有权写进类型):
cpp
// 旧:裸指针返回,调用方猜要不要删
Conn* createConn();
// 新:签名就说清"所有权交给你"
unique_ptr<Conn> createConn();
第三步:所有权确实共享的 → shared_ptr(慎用,先确认"真共享"):
cpp
// 多个组件都要持有、生命周期不定
shared_ptr<Cache> cache_;
// 谨记:能 unique 不 shared,
// 互相引用处配 weak_ptr
第四步:C API 交接 → 智能指针 + 自定义删除器(下节专讲)。
🎯 顺序即策略:先 1 后 2 再 3------大部分代码走到第 1、2 步就治好了;第 3 步是"确认共享"而不是"图省事"。shared_ptr 滥用是改造中新生的最大坏味道。
三、和 C API 交接:删除器的妙用
❓ 和 C 接口(malloc 的、fopen 的、第三方库的)怎么交接?
✅ 用自定义删除器把"怎么释放"打包进智能指针------资源的"关闭姿势"一次性说清:
cpp
// FILE* 的正确归宿:fclose
auto file = unique_ptr<FILE,
decltype(&fclose)>(
fopen("a.txt", "r"),
fclose);
// 离开作用域自动 fclose
// malloc 的内存:free 来收
auto buf = unique_ptr<void,
decltype(&free)>(
malloc(4096), free);
// 第三方库:库自己的释放函数
auto h = unique_ptr<Handle,
LibDeleter>(
LibCreate(),
LibDeleter{});
💡 这招的精髓:资源管理权和资源语义解耦------unique_ptr 负责"必然释放",删除器负责"按规矩释放"。无论多古董的 C 接口,都能纳入 RAII 体系。
⚠️ 两个细节:unique_ptr<void, D>合法(void 也行,只要删除器懂它);fclose指针在 MSVC 上因[[nodiscard]]标注可能需要包一层 lambda------动手时留意编译器提示。
四、不该动的:观察指针与 C 兼容层
❓ 所有裸指针都要消灭吗?
✅ 不。两种裸指针是合法且更好的存在:
① 纯观察指针------只是"看看",不参与生命周期:
cpp
// 参数只是借用:传裸指针/引用
void print(const string* s);
// 不需要 shared_ptr 接收------
// 所有权语义和开销都是误导
② 与 C 交互的边界 ------get() 把管辖区内的资源临时递给老接口:
cpp
auto buf = make_unique<char[]>(n);
legacy_fill(buf.get(), n);
// C 函数填完就完,指针还在
// unique_ptr 手里,安全
🎯 判断标准一句话:"谁负责释放"如果是这个指针,必须智能指针化;如果只是"指过去看看",裸指针/引用就是正确工具。消灭的是"所有权裸指针",不是裸指针本身。
五、综合应用:一次完整的问题排查
❓ 线上服务内存持续上涨,怎么一步步排查?
✅ 按流程走一遍(面试讲出这个流程就是高级感):
① 先定性:泄漏还是正常波动?
text
监控:RSS 曲线只涨不跌?
压测后回落 → 缓存/空闲池,非泄漏
单调上涨 → 疑似泄漏,进入 ②
② 复现与插桩 :测试环境同负载跑,开 -fsanitize=address------退出时 LeakSanitizer 直接给出每块泄漏的分配调用栈。
③ 读栈定位病灶:
text
Direct leak of 1024 byte(s)
#1 Conn::Conn() conn.cpp:42
#2 Pool::create() pool.cpp:77
顺着栈看代码:pool.cpp:77 里 new 出的 Conn 注册进了列表,析构却没人清------病灶是"注册了没注销",不是简单的"忘了 delete"。
④ 按改造路线修 :注册列表本来就用裸指针存(这是合法观察),但创建处 该返回 unique_ptr,进列表时 shared_ptr/weak_ptr 按真实所有权定。
⑤ 回归验证:ASan 跑干净 + 监控曲线平稳,结案。
💡 注意 ③ 的教训:工具报告的是"分配点",病灶常在"所有权设计"------修一行 delete 是止痛,理顺所有权才是治病。
六、改造纪律:别引入新问题
❓ 改造最容易引入哪些新坑?
✅ 四个高频新坑,动手前心里有数:
- shared_ptr 循环引用------图省事全换 shared,父子互指成环,泄漏换了马甲回来。互指处必须 weak_ptr;
- 所有权双轨 ------同一资源一会儿裸指针一会儿智能指针,边界处 double free。入口唯一:资源诞生地立即进智能指针,此后只传引用/get();
- 生命周期延长过度------本来作用域即释放的资源被 shared_ptr 拖到全局,内存峰值上升;
- 线程边界的悬空------回调里持有的裸指针,对象早被析构。异步持有一律 shared_ptr/weak_ptr + lock。
🎯 一条总纪律:每块内存自诞生起,owner 唯一且写在类型上。改造完成的标志不是"没有裸指针",而是"每行代码都能回答:这块内存现在归谁"。
七、面试高频追问
❓ Q1:接手百万行裸指针代码,从哪里开始?
✅ 不是大面积替换,三步:先挂 clang-tidy 拿全量报告做"泄漏地图";再挑热点路径 (崩溃/泄漏集中的模块)按四步路线改造、ASan 验证;同时立规矩------新代码必须 RAII,老代码"touch 到就改"。技术债靠增量消化,一次性重写是最大风险。
❓ Q2:unique_ptr.get() 交出去的指针,对方存起来用了怎么办?
✅ 这就是引入 UAF 的典型操作。纪律是:get() 只用于同步调用、当场用完 的场景;对方若需要"持有",要么传 shared_ptr(共享),要么传 weak_ptr(观测 + lock),不能存裸指针过夜。
❓ Q3:资源不是内存(文件/socket/锁)也能这么拯救吗?
✅ 能,这正是 RAII 的本意------unique_ptr<FILE, decltype(&fclose)> 管 FILE,lock_guard 管锁,自定义删除器管任意"打开/关闭"型资源。RAII 拯救的不只是内存,是一切成对出现的资源操作。
❓ Q4:为什么说"修一行 delete 是止痛"?
✅ 工具给的分配栈只是症状发生地,病因是所有权设计(谁注册、谁注销、谁共享)。头痛医头的补丁会漏掉同类路径------同构的 bug 在下一个函数里等着。改造必须落到"owner 唯一"的结构上。
❓ Q5:改造后如何证明没变慢?
✅ 智能指针的额外开销可量化:unique_ptr 零开销;shared_ptr 每次 copy/reset 是原子计数(纳秒级)。基准测试对比改造前后热点路径;确认无循环引用、无过度生命周期延长,性能差距通常在噪声内------结构正确的代码才有资格谈性能。
❓ Q6:C++ 项目里混着 malloc 的部分(第三方库)怎么管?
✅ 边界收口:库的 API 用删除器包装 (第三节的 unique_ptr + free/closer),包装层以内是 RAII 世界、以外是库的地盘------门面上永远只出现智能指针和引用。malloc 家族本身不消灭,只是被圈养在边界上。
八、总结速查表
| 考点 | 一句话结论 |
|---|---|
| 第一步 | 局部 new/delete → unique_ptr |
| 第二步 | 工厂返回 unique_ptr,所有权入签名 |
| 第三步 | 真共享才 shared,互指配 weak |
| C API 交接 | 自定义删除器打包释放语义 |
| 合法裸指针 | 纯观察、get() 当场用 |
| 排查流程 | 定性 → ASan → 读栈 → 修所有权 → 回归 |
| 新坑 | 循环引用、双轨所有权、悬空回调 |
| 总纪律 | 每块内存 owner 唯一且写在类型上 |
一句话回顾
拯救散落的 free/delete:先诊断所有权、按"unique → 接口 → shared → C 交接"四步走 ;C API 用删除器圈进 RAII 边界;纯观察的裸指针不动;排查靠"定性 → ASan → 读栈 → 修所有权",而完成的标志是------每块内存都能回答"现在归谁"。内存管理篇到此收官,下期进入多线程篇,敬请关注 👋