智能指针要解决的核心问题只有一个:对象的所有权到底归谁,以及由谁在什么时机释放资源。裸指针虽然灵活,但一旦涉及异常、提前返回或多分支逻辑,delete 就很容易被漏掉;更隐蔽的问题是同一块内存被多个指针指向时,重复释放或悬空访问几乎不可避免。C++ 标准库提供的 unique_ptr、shared_ptr 和 weak_ptr 并不是魔法,它们都是围绕 RAII 和引用计数这两个基础机制,在编译期和运行期分别做文章。

一、从裸指针到 RAII:智能指针的基础骨架
RAII(Resource Acquisition Is Initialization)是智能指针的起点。它的思路是在构造函数中获得资源,在析构函数中释放资源;当栈对象离开作用域时,析构函数会被自动调用。智能指针本质上就是一个栈上的类模板,内部保存一个裸指针,析构时根据所有权策略决定是否 delete。最简化的智能指针通常长这样:
template <typename T>
class SimplePtr {
public:
explicit SimplePtr(T* p = nullptr) : ptr_(p) {}
~SimplePtr() {
delete ptr_;
}
// 禁止拷贝,避免重复释放
SimplePtr(const SimplePtr&) = delete;
SimplePtr& operator=(const SimplePtr&) = delete;
T* operator->() const {
return ptr_;
}
T& operator*() const {
return *ptr_;
}
private:
T* ptr_;
};
这个简化的 SimplePtr 已经具备独占智能指针的雏形:它接管了裸指针的所有权,析构时负责释放。问题在于它完全禁止拷贝,如果函数返回或容器存储需要转移所有权,就不够用。真正的智能指针在拷贝和移动语义上做出了不同选择:unique_ptr 保持独占,只允许移动;shared_ptr 允许多个指针共享同一对象,通过引用计数决定最后一次释放的时机。因此理解智能指针底层,主要就是理解这些拷贝控制操作背后发生了什么。
在讨论 shared_ptr 的具体实现前,还有一点值得强调:智能指针本身的大小和裸指针不同。unique_ptr 如果使用默认删除器,大小通常就是一个指针;shared_ptr 则通常包含两个指针,一个指向对象,一个指向控制块。这个差异不是标准强制规定的,但主流标准库实现都采用了类似布局,后面的控制块分析会进一步说明原因。
二、shared_ptr 的控制块:引用计数如何工作
shared_ptr 的引用计数并不是直接放在被管理对象内部。C++ 标准没有要求对象本身携带计数,标准库实现通常使用一个独立的控制块(control block)。控制块中至少包含强引用计数、弱引用计数、删除器和分配器。强引用计数记录当前有多少个 shared_ptr 实例指向同一个对象;弱引用计数则记录多少个 weak_ptr 观察这个控制块。为什么需要两个计数?因为如果只有强计数,当强计数归零时对象可以销毁,但控制块本身不能销毁,仍可能有 weak_ptr 需要查询对象是否存活。让弱引用计数也归零时,控制块才会真正释放。
控制块创建的时机非常关键。使用 make_shared<T>() 时,标准库可以一次分配同时容纳对象和控制块,内存布局更紧凑,也减少一次堆分配。而如果先 new 出裸指针再交给 shared_ptr 构造,比如 shared_ptr<T> sp(new T()),控制块必须单独分配。这里有一个很常见的误用:用同一个裸指针构造两个 shared_ptr。两个 shared_ptr 会各自创建独立控制块,都认为对象的强引用计数是 1,第一个析构时释放对象,第二个析构时再次释放同一块内存,导致未定义行为。正确做法是让参数类型明确表达所有权转移,比如 shared_ptr(T* p) 的构造只发生一次。
#include <atomic>
#include <cstddef>
template <typename T>
class SharedPtr;
struct ControlBlockBase {
std::atomic<long> strong_count{1};
std::atomic<long> weak_count{1};
virtual ~ControlBlockBase() = default;
void add_strong() {
strong_count.fetch_add(1, std::memory_order_relaxed);
}
void release_strong() {
if (strong_count.fetch_sub(1, std::memory_order_acq_rel) == 1) {
destroy_object();
release_weak();
}
}
void add_weak() {
weak_count.fetch_add(1, std::memory_order_relaxed);
}
void release_weak() {
if (weak_count.fetch_sub(1, std::memory_order_acq_rel) == 1) {
delete this;
}
}
virtual void destroy_object() = 0;
};
template <typename T>
struct ControlBlock final : ControlBlockBase {
T* object;
explicit ControlBlock(T* obj) : object(obj) {}
void destroy_object() override {
delete object;
}
};
template <typename T>
class SharedPtr {
public:
SharedPtr() = default;
explicit SharedPtr(T* p)
: object_(p),
control_(p ? new ControlBlock<T>(p) : nullptr) {}
SharedPtr(const SharedPtr& other)
: object_(other.object_),
control_(other.control_) {
if (control_) {
control_->add_strong();
}
}
SharedPtr(SharedPtr&& other) noexcept
: object_(other.object_),
control_(other.control_) {
other.object_ = nullptr;
other.control_ = nullptr;
}
~SharedPtr() {
if (control_) {
control_->release_strong();
}
}
private:
T* object_ = nullptr;
ControlBlockBase* control_ = nullptr;
};
上面的简化实现展示了控制块的两个计数器如何配合。release_strong 在强计数减到 0 时先销毁对象,再释放一次弱计数。注意 weak_count 初始值为 1 并不是表示有一个 weak_ptr,而是表示控制块自身的生命引用。每次创建 weak_ptr 时增加弱计数,weak_ptr 析构时减少弱计数;当强计数归零后释放的那个虚拟弱引用也参与计数,因此控制块只有在所有 weak_ptr 都销毁后才 delete this。这种设计让 weak_ptr::lock 可以在不访问对象的情况下判断对象是否仍然存活。
线程安全方面,引用计数的增减通常使用原子操作,所以多个线程同时拷贝、析构指向同一对象的 shared_ptr 不会破坏计数。但 shared_ptr 本身并不是完全线程安全的:同一个 shared_ptr 实例被多个线程同时修改时,仍然需要外部同步。更关键的是,引用计数保护的是控制块的计数,不保护被管理对象。如果多个线程通过不同 shared_ptr 同时访问同一个普通对象,对象内部的线程安全问题仍然要由开发者自己处理。这个区别在面试和实际故障排查中经常被忽略。
三、unique_ptr 的独占语义和零开销
unique_ptr 选择了完全不同的实现路线。它在语义上独占对象所有权,因此直接禁止拷贝构造和拷贝赋值。移动构造会把源指针置空,避免两个 unique_ptr 指向同一个对象。由于不需要引用计数字段,unique_ptr 使用默认删除器时大小通常与裸指针相同,这让它在参数传递、容器存储等场景中几乎没有额外开销。
#include <cstddef>
template <typename T, typename Deleter = std::default_delete<T>>
class UniquePtr {
public:
UniquePtr() = default;
explicit UniquePtr(T* p) : ptr_(p) {}
UniquePtr(UniquePtr&& other) noexcept
: ptr_(other.release()),
deleter_(std::move(other.deleter_)) {}
UniquePtr& operator=(UniquePtr&& other) noexcept {
if (this != &other) {
reset(other.release());
deleter_ = std::move(other.deleter_);
}
return *this;
}
~UniquePtr() {
reset();
}
UniquePtr(const UniquePtr&) = delete;
UniquePtr& operator=(const UniquePtr&) = delete;
T* get() const {
return ptr_;
}
T* release() {
T* tmp = ptr_;
ptr_ = nullptr;
return tmp;
}
void reset(T* p = nullptr) {
T* old = ptr_;
ptr_ = p;
if (old) {
deleter_(old);
}
}
T* operator->() const {
return ptr_;
}
T& operator*() const {
return *ptr_;
}
private:
T* ptr_ = nullptr;
Deleter deleter_{};
};
删除器的处理也很有意思。unique_ptr<T, Deleter> 在类型中携带删除器,如果删除器是无捕获的 lambda 或空函数对象,编译器可以通过空基类优化把它压缩到零字节;而 shared_ptr 把删除器存放在控制块里,删除器类型不会影响 shared_ptr 的大小。这个差异导致 unique_ptr 使用自定义删除器时,类型就变了,不同删除器的 unique_ptr 无法放进同一个容器;shared_ptr 则没有这种限制,因为删除器被运行时多态地保存在控制块中。
reset 和 release 是 unique_ptr 两个容易混淆的接口。release 只是放弃所有权并返回裸指针,不会删除对象;reset 会先删除旧对象再接管新指针。移动赋值通常先释放当前对象,再接管其他对象的所有权。正因为删除器是类型的一部分,实现移动赋值时需要同时移动删除器,否则源对象的删除器状态可能丢失。如果删除器不可移动或包含引用,编译期就会给出错误,这也体现了 C++ 类型系统对所有权语义的约束。
四、weak_ptr 如何观察对象并破解循环引用
weak_ptr 不是独立管理资源的智能指针,它必须从 shared_ptr 构造,用来观察对象但不影响对象的生命周期。weak_ptr 内部保存一个指向控制块的指针,并增加弱引用计数。它没有 operator* 和 operator->,因为对象可能已经销毁,直接解引用会悬空。要访问对象,必须先调用 lock(),lock 会检查控制块中的强引用计数是否大于 0,如果大于 0 就返回一个指向该对象的 shared_ptr,否则返回空 shared_ptr。
template <typename T>
class WeakPtr {
public:
WeakPtr() = default;
WeakPtr(const SharedPtr<T>& sp)
: object_(sp.object_),
control_(sp.control_) {
if (control_) {
control_->add_weak();
}
}
~WeakPtr() {
if (control_) {
control_->release_weak();
}
}
SharedPtr<T> lock() const {
if (!control_) {
return SharedPtr<T>();
}
long expected = control_->strong_count.load();
while (expected > 0) {
if (control_->strong_count.compare_exchange_weak(
expected, expected + 1)) {
SharedPtr<T> sp;
sp.object_ = object_;
sp.control_ = control_;
return sp;
}
}
return SharedPtr<T>();
}
private:
T* object_ = nullptr;
ControlBlockBase* control_ = nullptr;
};
lock() 的实现必须处理竞争条件:在检查 strong_count 大于 0 之后、增加计数之前,对象可能被另一个线程销毁。标准库使用原子比较交换(compare_exchange)来保证只在计数不为 0 时递增,从而安全地获得一个强引用。上面的简化代码用 compare_exchange_weak 演示这个思路,但没有处理内存序的细节。实际标准库实现会使用 acquire/release 语义保证对象创建和销毁之间的可见性。这个细节也是 weak_ptr 比很多人想象中更复杂的原因。为了展示核心逻辑,上面的 WeakPtr 省略了与 SharedPtr 的友元声明,实际工程实现还需要严格的异常安全保证。
weak_ptr 最常见的用途是打破循环引用。如果两个对象互相持有 shared_ptr,它们的强引用计数永远无法归零。例如一个双向链表节点既持有 next 又持有 prev,如果都用 shared_ptr,前驱节点和后继节点会互相拉住,整个链表无法释放。把其中一侧改成 weak_ptr 后,弱引用不增加强计数,节点可以在没有外部 shared_ptr 持有它时被销毁。另一个典型场景是缓存:通过 weak_ptr 保存观察,使用前 lock 可以判断缓存对象是否还存活,避免悬空访问。
总结一下,这三类智能指针的底层分工非常清晰:unique_ptr 用编译期约束实现零开销独占;shared_ptr 用控制块和原子引用计数实现共享所有权;weak_ptr 用弱引用计数和 lock 机制实现安全观察。理解这些机制后,所谓循环引用、双重释放、线程安全等常见问题就不再是黑盒。写代码时选择合适的智能指针,本质上是明确所有权和生命周期,而不是仅为了少写 delete。