Appearance
手写智能指针(unique_ptr / shared_ptr / weak_ptr)
手写
shared_ptr是 C++ 面试的压轴题:考察 RAII、拷贝/移动语义、原子引用计数、 删除器的类型擦除与访问语义。原理与用法见 智能指针专题。 两档指的是shared_ptr:4.1 精简版(堆上独立控制块,先交)与 4.2 完整版(类型擦除 + 同块分配 +weak_ptr);unique_ptr作为前置的独占版本单列在第三节。
一、考察点与澄清
常见问法:手写 unique_ptr(含删除器)、手写 shared_ptr(含控制块)、补 weak_ptr::lock。 动手前需澄清:
- 是否需要
weak_ptr(决定控制块内一个计数还是两个,是本题最大的分支); - 是否支持自定义删除器(
unique_ptr的删除器是类型的一部分;shared_ptr的存在控制块中); - 计数是否需线程安全(是则用
std::atomic;否则可从普通整数起讲到原子演进)。
二、接口与约束
- 接口
unique_ptr:reset/release/get/operator*/operator->/operator bool、移动构造与移动赋值shared_ptr:拷贝与移动构造/赋值、reset/swap/get/use_count/operator*/operator->/operator boolweak_ptr:lock/expired、拷贝构造与赋值
- 不变量
- 强引用计数等于指向该控制块的
shared_ptr数量; - 弱引用计数等于
weak_ptr数量加 1(控制块自持一份,故不会在最后一个强引用释放时被提前销毁)。
- 强引用计数等于指向该控制块的
- 生命周期:对象在强计数归零时销毁;控制块在强、弱计数均归零时销毁,二者是独立事件
- 线程安全:控制块的计数增减为原子操作;被管理对象本身不因此获得线程安全
- 异常安全:控制块先构造完成再接管对象指针,避免中途抛异常泄漏;析构为
noexcept - 前置条件:不得用同一裸指针构造多个
shared_ptr;unique_ptr移动后源为空
三、配套:unique_ptr(独占所有权)
实现范围:精简版,覆盖独占语义(禁拷贝、移动、
reset/release/get/operator*/operator->/operator bool)与删除器模板参数。 未实现T[]数组特化、operator[]、比较运算符与带状态删除器的完整转发。
cpp
template <class T, class D = std::default_delete<T>>
class UniquePtr {
public:
UniquePtr() noexcept = default;
explicit UniquePtr(T* p) noexcept : p_(p) {}
UniquePtr(T* p, D d) noexcept : p_(p), d_(std::move(d)) {}
UniquePtr(const UniquePtr&) = delete; // 独占:禁拷贝
UniquePtr& operator=(const UniquePtr&) = delete;
UniquePtr(UniquePtr&& o) noexcept : p_(o.p_), d_(std::move(o.d_)) { o.p_ = nullptr; }
UniquePtr& operator=(UniquePtr&& o) noexcept {
if (this != &o) { reset(); p_ = o.p_; d_ = std::move(o.d_); o.p_ = nullptr; }
return *this;
}
~UniquePtr() { reset(); }
void reset(T* p = nullptr) noexcept { // 先析构旧资源,再接管新资源
if (p_) d_(p_);
p_ = p;
}
T* release() noexcept { T* p = p_; p_ = nullptr; return p; } // 放弃所有权
T& operator*() const noexcept { return *p_; }
T* operator->() const noexcept { return p_; }
T* get() const noexcept { return p_; }
explicit operator bool() const noexcept { return p_ != nullptr; }
private:
T* p_ = nullptr;
[[no_unique_address]] D d_{}; // 空删除器不占空间
};技术选型
- 删除器作为模板参数,即类型的一部分
- 各
unique_ptr可携带不同删除器,且空删除器可用[[no_unique_address]]做到零占用。 - 与
shared_ptr相反:后者的删除器存于控制块,属于类型擦除。
- 各
- 禁拷贝、只允许移动
- 独占语义的直接表达;移动构造标
noexcept,容器搬移时才敢使用移动。
- 独占语义的直接表达;移动构造标
release()与reset()职责分离release()放弃所有权(返回裸指针、不析构);reset()先析构旧对象再接管新指针。
- 接管新指针的顺序为「先析构旧资源」
- 若先接管再析构,等于把刚拿到的新资源删掉。
- 数组特化另行处理
UniquePtr<T[]>需使用delete[],且不提供operator*/operator->,因为元素访问语义不同。
小问答
- 这个版本差在哪? 无
T[]数组特化与operator[]、无比较运算符,删除器未实现有状态版本的完整空基类优化。 - 与
std::unique_ptr的差别? 标准版另有make_unique、数组特化、pointertypedef(支持 fancy pointer)与有状态删除器的优化存储。 - 与
shared_ptr的差别? 无控制块、无原子计数;对象大小就是指针(空删除器不占空间),析构可为constexpr。 - 移动后源的状态?
p_ == nullptr(本版显式置空;标准库只要求「合法但未指定」)。
四、实现:shared_ptr(两档交付)
面试节奏:先交精简版(下面 4.1,结构即库实现的结构,约 60 行),再按追问补到完整版(4.2)。
| 档位 | 控制块形态 | 缺口 | 适用场景 |
|---|---|---|---|
| 精简版 | 模板化 ControlBlock<T>(强/弱计数 + T*),与对象分两次分配 | 无 weak_ptr(计数已预留)、无 make_shared、无删除器类型擦除(SharedPtr<Base> 不能接 SharedPtr<Derived>) | 题目只要求「共享所有权 + 引用计数」;时间紧 |
| 完整版 | 非模板 ControlBlock 基类 + 虚 dispose,对象与控制块同块分配 | —— | 追问涉及 weak_ptr、删除器、派生类转换或分配次数时 |
4.1 精简版:堆上独立控制块(先交这个)
实现范围:共享所有权本体 —— 构造/拷贝/移动/析构、
reset(无参与接管两种)、swap、get/operator*/operator->/operator bool/use_count。 控制块为模板化结构、持有T*;未实现weak_ptr、make_shared、删除器类型擦除与派生类转换。
cpp
template <class T>
class SharedPtr {
struct ControlBlock { // 堆上独立分配,与对象分开(库实现即此结构)
std::atomic<long> strong{1};
std::atomic<long> weak{1}; // 自持一份,为 weak_ptr 预留
T* obj;
explicit ControlBlock(T* p) : obj(p) {}
void release_strong() noexcept {
// acq_rel:保证此前的写对销毁路径可见,且其他线程不会观察到半销毁状态
if (strong.fetch_sub(1, std::memory_order_acq_rel) == 1) { delete obj; obj = nullptr; release_weak(); }
}
void release_weak() noexcept {
if (weak.fetch_sub(1, std::memory_order_acq_rel) == 1) delete this;
}
};
public:
SharedPtr() noexcept = default;
explicit SharedPtr(T* p) : cb_(p ? new ControlBlock(p) : nullptr) {} // 接管一个裸指针
SharedPtr(const SharedPtr& o) noexcept : cb_(o.cb_) {
if (cb_) cb_->strong.fetch_add(1, std::memory_order_relaxed); // 仅计数,relaxed 足够
}
SharedPtr(SharedPtr&& o) noexcept : cb_(o.cb_) { o.cb_ = nullptr; }
SharedPtr& operator=(const SharedPtr& o) noexcept {
if (this != &o) { SharedPtr tmp(o); swap(tmp); } // 先拷贝后释放:自赋值安全
return *this;
}
SharedPtr& operator=(SharedPtr&& o) noexcept {
if (this != &o) { SharedPtr tmp(std::move(o)); swap(tmp); }
return *this;
}
~SharedPtr() { if (cb_) cb_->release_strong(); }
void swap(SharedPtr& o) noexcept { std::swap(cb_, o.cb_); }
void reset() noexcept { SharedPtr tmp; swap(tmp); }
void reset(T* p) { SharedPtr tmp(p); swap(tmp); } // 接管 p 的所有权
T* get() const noexcept { return cb_ ? cb_->obj : nullptr; }
T& operator*() const noexcept { return *get(); }
T* operator->() const noexcept { return get(); }
explicit operator bool() const noexcept { return cb_ != nullptr; }
long use_count() const noexcept { return cb_ ? cb_->strong.load(std::memory_order_relaxed) : 0; }
private:
ControlBlock* cb_ = nullptr;
};小问答
- 这个版本差在哪? 无
weak_ptr、无make_shared、无删除器类型擦除(控制块模板化,故SharedPtr<Base>接不了SharedPtr<Derived>)、无enable_shared_from_this。 - 与 STL 的差别? 库实现的控制块非模板(
_Sp_counted_base+ 虚dispose),因此支持别名构造、删除器类型擦除与派生类转换;本版与对象分两次分配,库版可用make_shared合并。 - 为什么这样取舍? 控制块独立 = 与库结构同构、后续扩展有落点;弱计数已预留,加
weak_ptr不必改结构;总量约 60 行且不含类型擦除的复杂度。 - 取舍要点? 控制块独立分配而非把计数塞进对象或指针;强计数用原子(递增
relaxed、递减到零acq_rel);拷贝赋值走「临时对象 +swap」;reset()与reset(T*)分离。 - 不建议再写更简的版本:单指针计数既不支持
weak_ptr、计数也常写成非原子,而且并不比本版短,省下的行数换来的是无法回答追问。
4.2 完整版:控制块 + SharedPtr + WeakPtr(一个编译单元)
实现范围:在 4.1 基础上补入四件事 —— ① 非模板控制块基类 + 虚
dispose(删除器类型擦除, 也是派生类转换的前提);② 对象与控制块同块分配(make_shared);③WeakPtr与lock;④SharedPtr只保存控制块指针。构件按依赖顺序排列,可整段复制编译。 未实现别名构造、static_pointer_cast系列、get_deleter与enable_shared_from_this。
cpp
#include <atomic>
#include <cstddef>
#include <new>
#include <utility>
// 控制块:强/弱计数 + 类型擦除的销毁动作
class ControlBlock {
public:
std::atomic<long> strong{1};
std::atomic<long> weak{1};
virtual void dispose() noexcept = 0; // 类型擦除:调用正确的 T 析构
virtual ~ControlBlock() = default;
void release_strong() noexcept {
// acq_rel:既保证看到对象内容的写入,也避免其他线程观察到半销毁状态
if (strong.fetch_sub(1, std::memory_order_acq_rel) == 1) { dispose(); release_weak(); }
}
void release_weak() noexcept {
if (weak.fetch_sub(1, std::memory_order_acq_rel) == 1) delete this;
}
};
// 单块分配:控制块与 T 的存储位于同一块内存(即 make_shared 省去的那次分配)
template <class T>
struct Cb final : ControlBlock {
alignas(T) unsigned char buf[sizeof(T)];
T* ptr;
template <class... A>
explicit Cb(A&&... a) : ptr(reinterpret_cast<T*>(buf)) {
::new (static_cast<void*>(buf)) T(std::forward<A>(a)...);
}
void dispose() noexcept override { ptr->~T(); }
};
template <class T>
class SharedPtr {
template <class U> friend class WeakPtr;
public:
SharedPtr() noexcept = default;
explicit SharedPtr(ControlBlock* cb) noexcept : cb_(cb) {} // 接管一个已持有的引用
SharedPtr(const SharedPtr& o) noexcept : cb_(o.cb_) {
if (cb_) cb_->strong.fetch_add(1, std::memory_order_relaxed); // 仅计数,relaxed 足够
}
SharedPtr(SharedPtr&& o) noexcept : cb_(o.cb_) { o.cb_ = nullptr; }
SharedPtr& operator=(const SharedPtr& o) noexcept {
if (this != &o) { SharedPtr tmp(o); swap(tmp); } // 先拷贝后释放:自赋值与别名均安全
return *this;
}
SharedPtr& operator=(SharedPtr&& o) noexcept {
if (this != &o) { SharedPtr tmp(std::move(o)); swap(tmp); }
return *this;
}
~SharedPtr() { if (cb_) cb_->release_strong(); }
void swap(SharedPtr& o) noexcept { std::swap(cb_, o.cb_); }
void reset() noexcept { SharedPtr tmp; swap(tmp); }
T* get() const noexcept { return cb_ ? static_cast<Cb<T>*>(cb_)->ptr : nullptr; }
T& operator*() const noexcept { return *get(); }
T* operator->() const noexcept { return get(); }
explicit operator bool() const noexcept { return cb_ != nullptr; }
long use_count() const noexcept { return cb_ ? cb_->strong.load(std::memory_order_relaxed) : 0; }
private:
ControlBlock* cb_ = nullptr;
};
template <class T, class... A>
SharedPtr<T> make_shared(A&&... a) { // 一次分配
return SharedPtr<T>(new Cb<T>(std::forward<A>(a)...));
}
template <class T>
class WeakPtr {
public:
WeakPtr() noexcept = default;
WeakPtr(const SharedPtr<T>& sp) noexcept : cb_(sp.cb_) {
if (cb_) cb_->weak.fetch_add(1, std::memory_order_relaxed);
}
WeakPtr(const WeakPtr& o) noexcept : cb_(o.cb_) {
if (cb_) cb_->weak.fetch_add(1, std::memory_order_relaxed);
}
WeakPtr& operator=(const WeakPtr& o) noexcept {
if (this != &o) { WeakPtr tmp(o); std::swap(cb_, tmp.cb_); }
return *this;
}
~WeakPtr() { if (cb_) cb_->release_weak(); }
// 必须先判断 strong != 0 再增 1:分两步(先读后加)时,中间可能被其他线程减到 0 并销毁对象
SharedPtr<T> lock() const noexcept {
if (!cb_) return {};
long c = cb_->strong.load(std::memory_order_relaxed);
while (c != 0 && !cb_->strong.compare_exchange_weak(c, c + 1, std::memory_order_acq_rel,
std::memory_order_relaxed)) {
// CAS 失败时 c 被更新为当前值,继续循环
}
return c == 0 ? SharedPtr<T>{} : SharedPtr<T>(cb_); // 接管该次 CAS 取得的 +1
}
bool expired() const noexcept { return !cb_ || cb_->strong.load(std::memory_order_relaxed) == 0; }
private:
ControlBlock* cb_ = nullptr;
};小问答
- 比精简版多了什么? 虚
dispose(删除器类型擦除)、对象与控制块同块分配、WeakPtr::lock,以及SharedPtr只保存控制块指针。 - 与 STL 还差什么? 别名构造、
static_pointer_cast系列、get_deleter、enable_shared_from_this、分配器支持,以及std::atomic<std::shared_ptr>的全部接口。 make_shared的代价? 存在weak_ptr时整块内存不能提前释放(对象已析构,内存仍被控制块占住)。- 为什么
lock()必须用 CAS 循环? 提升操作要与「另一线程把强计数减到 0」互斥;先读后加存在中间窗口。
4.3 技术选型(shared_ptr 家族)
- 控制块用虚
dispose做类型擦除- 控制块不能是模板,否则每个
T都会暴露给所有持有者;虚函数让shared_ptr<T>只依赖一个非模板基类。
- 控制块不能是模板,否则每个
- 对象与控制块同块分配(
Cb<T>内嵌alignas(T)缓冲)- 省一次分配并改善局部性;代价是存在
weak_ptr时整块内存不能提前释放。
- 省一次分配并改善局部性;代价是存在
- 计数用
std::atomic<long>:递增用relaxed,递减到零用acq_rel- 计数递增不发布任何数据,
relaxed足够; - 递减到 0 时需要
acq_rel,以保证此前的写对销毁路径可见,且其他线程不会观察到半销毁状态。
- 计数递增不发布任何数据,
- 拷贝赋值采用「先拷贝构造临时对象再
swap」- 对自赋值与别名均安全,无需额外的判等前置检查。
weak_ptr::lock用 CAS 循环而非「读后加」- 提升操作必须与「另一线程把强计数减到 0」互斥;先读后加存在中间窗口。
- 控制块的释放条件为强、弱计数同时归零
- 若只判强计数,仍持有
weak_ptr的线程会在访问控制块时悬垂。
- 若只判强计数,仍持有
SharedPtr只保存控制块指针,get()从控制块取对象地址- 两个指针可退化为一个,对象地址由控制块的派生类给出。
五、常见错误实现
cpp
// 同一裸指针构造两个 shared_ptr -> 两套控制块、各计数 1 -> double free
T* raw = new T;
SharedPtr<T> a(raw);
SharedPtr<T> b(raw); // 错误;应从 a 拷贝cpp
// lock() 写成先读后加:两步之间对象可能已被销毁
if (cb_->strong.load() > 0) { cb_->strong.fetch_add(1); return SharedPtr<T>(cb_); }cpp
// 拷贝赋值先释放再拷贝:自赋值或别名时会把对象删除
release(); ptr_ = o.ptr_; count_ = o.count_; ++*count_; // 错误;应先拷贝构造临时再 swap六、高频问答
Q1. 控制块为什么要虚 dispose(或函数指针)?
weak_ptr 与控制块需要类型擦除:控制块不能是模板,否则每个 T 都会暴露给所有持有者。 libstdc++ 用 _Sp_counted_base + virtual _M_dispose(),删除器存放在控制块中。
Q2. make_shared 省了什么,代价是什么?
省一次 malloc(对象与控制块同块,缓存更友好);代价是只要还有 weak_ptr, 整块内存就不能释放(对象已析构但内存占用保留),而 shared_ptr(new T) 可更早归还对象内存。
Q3. shared_ptr 的线程安全边界?
| 操作 | 安全性 |
|---|---|
多线程对同一控制块计数增减(拷贝/析构不同的 shared_ptr 对象) | 安全(计数为原子) |
| 多线程读写被指向对象 | 不安全 |
多线程读写同一个 shared_ptr 对象本身 | 不安全(对象本体非原子) |
Q4. enable_shared_from_this 的作用?
使对象能从 this 安全取得 shared_ptr(内部持有一个 weak_ptr),避免从裸 this 另建控制块而产生两套计数。
Q5. 两者的删除器差异?
unique_ptr 的删除器是类型的一部分(模板参数,可空基类优化,各对象可不同); shared_ptr 的删除器存于控制块中(类型擦除),故 T 相同的 shared_ptr 即使删除器不同也可互相拷贝赋值。
Q6. 为什么 weak_ptr 存在时控制块不能提前释放?
控制块释放条件为 strong == 0 && weak == 0。若在 strong == 0 时就 delete 控制块, 仍持有 weak_ptr 的线程通过 lock() / expired() 访问控制块即为悬垂。
七、演进路径(4.1 → 4.2)
| 步骤 | 补入的构件 | 解决的缺口 |
|---|---|---|
| 1 | 非模板控制块基类 + 虚 dispose | 删除器类型擦除;SharedPtr<Base> 能接 SharedPtr<Derived>(4.1 的模板化控制块做不到) |
| 2 | 对象与控制块同块分配(Cb<T> + alignas(T) 缓冲) | 少一次分配、改善局部性(make_shared 的形态,代价见 Q2) |
| 3 | WeakPtr + lock() 的 CAS 循环 | 观察语义;提升操作必须与「强计数归零」互斥 |
| 4 | SharedPtr 只存控制块指针,get() 由控制块给出 | 两个指针可退化为一个,对象地址由派生控制块提供 |
八、常见错误
| 错误 | 后果 |
|---|---|
| 移动构造/赋值后未将源置空 | double free |
| 拷贝赋值先释放再拷贝 | 自赋值时删除自身资源 |
同一裸指针构造两个 shared_ptr | double free |
先接管指针再 new 控制块 | 中途抛异常泄漏对象(应先构造控制块再接管) |
控制块释放条件写成 strong == 0 即 delete | weak_ptr::lock 访问已释放控制块 |
lock() 采用「先读后加」 | 与最后一个强引用析构竞态 |
将 shared_ptr 的删除器写成模板参数 | 语义退化为 unique_ptr,类型不可互换 |
关联:
../smart-pointers/unique_ptr.md、../smart-pointers/shared_ptr.md、../smart-pointers/weak_ptr.md。