【C++ 面试真题】聊聊 C++ 的智能指针
智能指针是 C++ 内存管理的"正解"------上一期说的三类内存事故,答案全在这里。背得出"shared_ptr 引用计数"只是及格,真考你的是"unique_ptr 凭什么零开销、控制块是什么、make_shared 好在哪、循环引用怎么破、手撕一个 shared_ptr 要写哪些函数"。本文把三种智能指针的用法、原理、避坑一次讲透。
一、开场:智能指针家族有哪些?
❓ 介绍一下 C++ 的智能指针?
✅ <memory> 里的现役三种 + 一个退役选手:
| 指针 | 引入 | 所有权 | 拷贝 | 开销 |
|---|---|---|---|---|
| unique_ptr | [C++11] |
独占 | ❌ 只能移动 | 零开销 |
| shared_ptr | [C++11] |
共享(计数) | ✅ 计数 +1 | 引用计数 |
| weak_ptr | [C++11] |
观测不拥有 | ✅ 不增计数 | 配合 shared |
| auto_ptr | C++98 | "拷贝即转移" | 危险 | C++17 已移除 |
回答思路:先报"独占/共享/观测"三种角色,再点 auto_ptr 的历史教训------面试官大概率会挑 shared_ptr 的原理或 unique_ptr 的零开销深入。
💡 共同本质:都是把"所有权"编码进类型的小对象------析构时按所有权规则自动释放资源(RAII)。栈上小对象,代价趋近于零。
二、unique_ptr:独占,零开销
❓ unique_ptr 怎么用?为什么说它零开销?
✅ 同一时刻只有一个 unique_ptr 拥有对象------不能拷贝、只能移动:
cpp
auto p = make_unique<int>(42);
// auto p2 = p; // ❌ 编译报错
auto p2 = std::move(p); // ✅ 所有权转移
// p 现在是 nullptr
零开销的底气 :它就是裸指针的薄包装,析构时 delete------没有计数、没有额外内存,生成的代码和手写 new/delete 相同(优化后甚至无差别)。默认选择:要独占资源时,它优于裸指针。
两个进阶用法:
cpp
// ① 数组特化:
// 会逐个析构,配 delete[]
auto arr =
make_unique<int[]>(100);
// ② 自定义删除器:类型的一部分
auto f = unique_ptr<FILE,
int(*)(FILE*)>(
fopen("a.txt", "r"),
&fclose);
⚠️
unique_ptr<T, D>的删除器 D 是类型的一部分------lambda 删除器得写全类型(或用 decltype),这是它和 shared_ptr 删除器(构造期传入、不影响类型)的关键区别。
三、shared_ptr:共享,引用计数
❓ shared_ptr 的原理是什么?
✅ 每个对象配一个控制块(control block),里面存两套计数:
text
┌──────────────┐
│ 控制块 │
│ strong count │ ← shared 计数
│ weak count │ ← weak 观测计数
└──────────────┘
▲
┌─────┴─────┐
│ 堆上的对象 │
└───────────┘
↑ 被多个 shared_ptr 指着
- 拷贝 shared_ptr → 强计数 +1(原子操作);
- 析构 → 强计数 -1;减到 0 → 析构对象;弱计数也归 0 → 释放控制块本身。
cpp
auto p = make_shared<string>("hi");
{
auto p2 = p; // 强计数 2
} // p2 死,计数 1
// p 死,计数 0 → 析构 string
💡 make_shared 为什么更好 :
shared_ptr<T>(new T)要两次堆分配 (对象一次、控制块一次);make_shared一次分配把对象和控制块放同一块内存------更快、缓存更友好,还免除了"构造参数求值和 new 之间穿插其他代码"的异常安全缝隙。
四、shared_ptr 的两个坑
❓ shared_ptr 最容易踩什么坑?
✅ 两个经典坑:
坑①:循环引用------两个对象互相持 shared_ptr,计数永不归零:
cpp
struct B;
struct A {
shared_ptr<B> b_;
~A() { cout << "~A"; }
};
struct B {
shared_ptr<A> a_; // ❌ 互指
~B() { cout << "~B"; }
};
auto a = make_shared<A>();
auto b = make_shared<B>();
a->b_ = b; // B 计数 2
b->a_ = a; // A 计数 2
// 离开作用域:a、b 各减 1
// 互指的对方还握着 1 → 永不析构 → 泄漏
解法:把"被指向方"改成 weak_ptr------父子关系里子指父、缓存指源对象、观察者指主体,都该用 weak:
cpp
struct B {
weak_ptr<A> a_; // ✅ 只观测
};
坑②:线程安全的真实范围 ------计数是原子的 (多线程各自拷贝/析构同一个对象的 shared_ptr 安全);但它指向的对象本身不保护 ------多线程并发写 *p 照样要加锁。
⚠️ 另一个隐蔽坑:同一裸指针构造多个 shared_ptr ------
shared_ptr<T> a(p); shared_ptr<T> b(p);会造出两个控制块 ,double free。想从同一裸指针共享,用b = a;。
五、weak_ptr:观测不拥有
❓ weak_ptr 怎么用?
✅ 指向 shared_ptr 管理的对象但不增加强计数,三个核心操作:
cpp
auto sp = make_shared<int>(42);
weak_ptr<int> wp = sp; // 观测
// ① 过期了吗?
if (wp.expired()) { /* 对象没了 */ }
// ② 安全访问:lock() 拿 shared_ptr
if (auto locked = wp.lock()) {
// 活着:这段时间 *locked 安全
}
// ③ use_count() 看强计数
lock() 是标准姿势 :它原子地"检查存活 + 临时升格",拿到手的使用期内对象保证不被释放------比先 expired() 再解引用安全(两步之间对象可能正好析构)。
典型场景:缓存 (持有引用但不阻止回收)、观察者列表 、打破循环引用(上节的父子关系)。
六、手撕 shared_ptr(高频手撕题)
❓ 手写一个简化版 shared_ptr 要写什么?
✅ 骨架四件套:构造、拷贝、析构、移动(真实版还要 swap/赋值/数组支持等):
cpp
template<class T>
class SharedPtr {
T* p_ = nullptr;
long* cnt_ = nullptr; // 控制块
public:
explicit SharedPtr(T* p)
: p_(p), cnt_(new long(1)) {}
// 拷贝:计数 +1
SharedPtr(const SharedPtr& o)
: p_(o.p_), cnt_(o.cnt_) {
++*cnt_;
}
// 析构:计数 -1,归零释放
~SharedPtr() {
if (cnt_ &&
--*cnt_ == 0) {
delete p_;
delete cnt_;
}
}
// 移动:接管、原者置空
SharedPtr(SharedPtr&& o) noexcept
: p_(o.p_), cnt_(o.cnt_) {
o.p_ = nullptr;
o.cnt_ = nullptr;
}
T& operator*() { return *p_; }
T* operator->() { return p_; }
};
面试加分点:指出这份骨架的缺陷 ------计数不是原子的(真实版要 atomic<long>);自赋值在拷贝赋值里要先 ++*o.cnt_ 再减自己的;不支持别名构造与 weak_ptr。能主动说出哪里不完整,比背出完美代码更加分。
七、面试高频追问
❓ Q1:为什么 auto_ptr 被废弃?
✅ 它用拷贝语法实现所有权转移 ------auto_ptr b = a; 后 a 变空,和"拷贝"的直觉完全相反,放进容器里就是灾难(拷贝元素导致源失效)。C++11 有了移动语义,unique_ptr 用显式 std::move 表达转移,auto_ptr 于 C++17 正式移除。
❓ Q2:unique_ptr 为什么不能放进 vector 后拷贝容器?
✅ vector 扩容要搬移元素,搬移需要拷贝或移动------unique_ptr 只能移动,std::move 下完全合法。真正不行的是"要求元素可拷贝"的老代码或算法。容器里放 vector<unique_ptr<T>> 是现代 C++ 的常见组合。
❓ Q3:shared_ptr 的计数为什么必须是原子的?
✅ 多线程可能同时拷贝/析构各自的 shared_ptr 副本------计数是共享数据,非原子加减会丢更新,导致过早释放(UAF)或永不释放(泄漏)。原子操作保证计数正确,但代价是每次拷贝/析构都有原子指令的开销。
❓ Q4:enable_shared_from_this 是干什么的?
✅ 对象内部想安全拿到管理自己的 shared_ptr(常见于异步回调时延长自身寿命)------直接 shared_ptr<T>(this) 会造第二个控制块直接 double free。继承 enable_shared_from_this<T> 后用 shared_from_this(),内部持一个 weak_ptr 桥接。
❓ Q5:make_shared 有什么缺点?
✅ 对象和控制块同块内存------weak_ptr 还活着时整个块都不能释放 (对象已析构,但内存要等弱计数归零),大对象 + 长寿 weak_ptr 的场景会延迟内存归还。此时用 shared_ptr<T>(new T) 分离两块更好。
❓ Q6:智能指针和裸指针还能共存吗?
✅ 能且应该:所有权用智能指针,纯观察用裸指针/引用 ------函数只是"看看"参数就传 T*/T&(不参与生命周期管理),这样接口清爽、语义明确。裸指针的危险只在"靠它管理所有权"时才存在。
八、总结速查表
| 考点 | 一句话结论 |
|---|---|
| 三种角色 | 独占/共享/观测 |
| unique_ptr | 零开销、只能移动、默认首选 |
| 删除器 | unique 是类型,shared 是构造参数 |
| 控制块 | 强计数 + 弱计数 |
| make_shared | 一次分配、异常安全 |
| 循环引用 | 互指 shared 泄漏,改 weak |
| 线程安全 | 计数原子,对象不保护 |
| 同裸指针两次构造 | 两个控制块,double free |
| lock() | 原子"检查 + 升格"的标准访问 |
| 手撕四件套 | 构造/拷贝/析构/移动 + 说出缺陷 |
一句话回顾
unique_ptr 独占零开销、shared_ptr 共享靠控制块计数、weak_ptr 观测不拥有:默认 unique、确需共享才 shared、互相引用处必 weak;make_shared 一次分配更好,但 weak_ptr 长寿会延迟内存归还;手撕 shared_ptr 时,能主动说出"计数该原子、自赋值要防"这些缺陷,就是最好的答案。
如果您觉得本篇内容对你有帮助,欢迎点赞 👍、收藏 ⭐、转发 📢。下期我们继续内存管理篇------聊聊内存泄漏与进程内存布局:泄漏怎么排查、布局图长什么样、全局对象什么时候构造,一次讲清,敬请关注 👋