☰
C++智能指针模板应用详细介绍
2026/10/20 15:22:01 网站建设 项目流程

前言


C++ 智能指针是 RAII(resource acquisition is initialization)在堆对象管理上的标准答案:把「谁负责 delete」这件事编码进类型,让所有权在编译期或运行期有明确归属。标准库提供三种:std::unique_ptr表示独占所有权,std::shared_ptr表示共享所有权,std::weak_ptr表示不增加引用计数的观察者。三者都是类模板,但模板参数的含义、删除器的存放方式、控制块的分配策略各不相同。


典型误解集中在几处。第一,以为用了shared_ptr就不会泄漏——两个对象互相持有shared_ptr时引用计数永远不归零,这是循环引用,必须用weak_ptr打破。第二,以为shared_ptr是线程安全的——它的控制块(引用计数)是线程安全的,但被指向对象的读写不是,多线程访问同一对象仍需同步。第三,以为std::make_shared和shared_ptr<T>(new T)完全等价——前者一次分配,后者两次,而且在存在weak_ptr时内存释放时机不同。


本文按「独占 → 共享 → 观察」的顺序讲三种指针的模板参数与所有权语义,再讲删除器、工厂函数和类型擦除这些模板化用法,最后给出一个观察者模式的完整示例。代码基准为 C++17;涉及 C++20 的改动会单独标注。


一、unique_ptr:独占所有权


1.1 模板形态


// 适用于 C++17(声明示意)
namespace std {
template <class T, class Deleter = std::default_delete<T>>
class unique_ptr;

template <class T, class Deleter = std::default_delete<T>>
class unique_ptr<T[], Deleter>; // 数组特化
}

删除器是类型的一部分。unique_ptr<T, D>与unique_ptr<T>是不同类型,不能互相赋值。默认删除器std::default_delete<T>是无状态的空类,常见实现借助空基类优化,让sizeof(unique_ptr<T>)等于sizeof(T*);但标准并不保证这一点,不要把它写进静态断言。


1.2 所有权转移


unique_ptr不可拷贝,只能移动:


// 适用于 C++17
#include <memory>

std::unique_ptr<int> make_value(int v) {
return std::make_unique<int>(v); // C++14 起
}

std::unique_ptr<int> a = make_value(1);
std::unique_ptr<int> b = std::move(a); // a 变为 nullptr
// std::unique_ptr<int> c = b; // 错误:拷贝构造被删除

std::move只是把表达式转成右值引用,真正把指针搬过去的是移动构造函数:源指针置空,目标接管。移动之后不要读a指向的内容,它已经是空指针。


1.3 数组与自定义删除器


数组要用unique_ptr<T[]>,它提供operator[]并在析构时调用delete[]:


// 适用于 C++17
auto buffer = std::make_unique<int[]>(10); // 10 个元素,值初始化
buffer[0] = 42;

用unique_ptr<int>管理new int[10]是错的:默认删除器调用delete而不是delete[],属于 UB。反过来,用unique_ptr<int[]>管理单个new int也不匹配。


自定义删除器适合包装 C 接口资源:


// 适用于 C++17
#include <cstdio>
#include <memory>

struct FileCloser {
void operator()(std::FILE* f) const noexcept {
if (f != nullptr) std::fclose(f);
}
};

using FilePtr = std::unique_ptr<std::FILE, FileCloser>;

FilePtr open_file(const char* path) {
return FilePtr(std::fopen(path, "rb"));
}

FileCloser是空类,FilePtr的大小通常仍是一个指针。如果删除器有状态(比如持有一个日志对象),它会被存进unique_ptr内部,对象尺寸随之增大。


1.4 作为函数参数




场景推荐写法理由



只观察,不接管T*或T&不涉及所有权,调用方保留指针

接管所有权std::unique_ptr<T>按值移动语义清晰,调用方必须std::move

可能接管也可能不接管std::unique_ptr<T>&&意图明确,但不如按值直观

返回新对象按值返回std::unique_ptr<T>移动构造,无拷贝



按值传递unique_ptr是最清楚的「 sink 」语义:函数签名本身告诉调用方「这个对象归我了」。如果只是想用一下对象,传T*或T&就够了,不要为了「显得现代」而传const std::unique_ptr<T>&——那既限制了调用方(不能传栈对象),又暗示了错误的所有权关系。


二、shared_ptr:共享所有权


2.1 控制块与引用计数


shared_ptr由两部分组成:指向对象的指针,以及指向控制块的指针。控制块里存强引用计数、弱引用计数、删除器和分配器。拷贝一个shared_ptr增加强计数,销毁减少;计数归零时调用删除器销毁对象。


// 适用于 C++17
#include <memory>

auto p = std::make_shared<int>(42);
auto q = p; // 强计数变为 2
long n = p.use_count(); // 通常为 2,多线程下只是瞬时快照
q.reset(); // 强计数回到 1

use_count()在并发环境下不保证反映「此刻」的精确值,只适合调试和日志,不要用它做同步判断。C++17 弃用、C++20 移除了unique()成员,判断是否独占应改用use_count() == 1且明确知道没有并发。


2.2 make_shared 与直接构造


// 适用于 C++17
std::shared_ptr<int> a(new int(1)); // 两次分配:对象 + 控制块
std::shared_ptr<int> b = std::make_shared<int>(1); // 一次分配:对象与控制块同块

make_shared在常见实现上把对象和控制块放在同一块内存里,少一次分配,局部性也更好。代价是:只要还有weak_ptr存活,整块内存(包括对象所占的空间)都不能释放,因为控制块和对象在同一块分配里。对象析构函数会先执行,但内存要等弱计数也归零。


make_shared也无法指定自定义删除器,且它用::new分配,对需要特殊对齐或特殊分配器的类型不适用。


2.3 同一个裸指针只能构造一次


// 适用于 C++17 —— 错误
int* raw = new int(5);
std::shared_ptr<int> p1(raw);
std::shared_ptr<int> p2(raw); // 两个独立控制块

p1和p2各自认为自己是唯一所有者,各自在计数归零时delete raw,结果是重复释放。正确做法是从一开始就用make_shared,或者只让一个shared_ptr接管裸指针,其余从它拷贝。


同类错误还有在类内部写shared_ptr<T>(this):如果对象已经由外部shared_ptr管理,这会创建第二个控制块。需要从this得到shared_ptr时,继承std::enable_shared_from_this<T>并调用shared_from_this()。


2.4 别名构造与指针转换


shared_ptr有一个别名构造函数(aliasing constructor),它共享控制块但指向另一个地址:


// 适用于 C++17
struct Widget {
int id;
int payload;
};

auto w = std::make_shared<Widget>();
std::shared_ptr<int> pid(w, &w->id); // 与 w 共享所有权,指向 id 成员

pid的计数与w共享,w释放时pid也不会让对象提前销毁。这个特性常用于返回「指向对象内部子对象」的shared_ptr。


类型转换用std::static_pointer_cast、std::dynamic_pointer_cast、std::const_pointer_cast;C++17 还提供std::reinterpret_pointer_cast。它们都保留控制块,不会产生第二个所有权。


三、weak_ptr:不拥有,只观察


3.1 打破循环引用


// 适用于 C++17
#include <memory>
#include <string>

struct Node {
std::string name;
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // 弱引用,不参与计数
};

int main() {
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->prev = a; // 若这里也用 shared_ptr,a 和 b 永远不释放
return 0;
}

如果prev也是shared_ptr,a的计数被b->prev持有,b的计数被a->next持有,两个计数都不会归零。把其中一个改成weak_ptr后,强引用形成单向链,计数可以正常归零。


3.2 使用前必须 lock


weak_ptr不能直接解引用,必须先提升为shared_ptr:


// 适用于 C++17
std::weak_ptr<int> wp = a;
if (auto sp = wp.lock()) {
// 提升成功,对象在这次作用域内保证存活
int v = *sp;
} else {
// 对象已销毁
}

lock()是原子操作:它与「最后一个shared_ptr析构」之间存在同步,保证提升成功时对象一定还没被销毁。不要写成if (!wp.expired()) { auto sp = wp.lock(); ... }——expired()与lock()之间可能被其他线程插进来销毁对象,这两步不是原子的。


3.3 观察者模式的自然载体


weak_ptr非常适合「主题持有观察者列表,但不拥有观察者」的场景:观察者销毁后,主题下一次通知时lock()失败,顺手把失效项清掉。完整示例见下一节。


四、模板化用法:工厂、删除器与类型擦除


4.1 工厂函数模板


// 适用于 C++17
#include <memory>
#include <utility>

template <typename T, typename... Args>
std::unique_ptr<T> make_object(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

这基本就是std::make_unique的实现思路。自己写的时候要注意:new T(...)与unique_ptr构造之间如果抛异常,new出来的内存会泄漏;标准库的实现用了一些技巧保证异常安全,业务代码直接调std::make_unique即可。C++14 起标准库已提供它,不必自己造。


4.2 删除器决定类型是否可擦除




特性unique_ptrshared_ptr



删除器是否是类型的一部分是否(存在控制块里)

不同删除器能否互相赋值不能可以(同类型 T)

对象大小通常等于裸指针(无状态删除器)通常两个指针大小

拷贝禁止允许

开销无引用计数原子计数增减



因为shared_ptr的删除器被类型擦除进控制块,std::shared_ptr<void>可以持有任意类型的指针,只要构造时给出正确的删除器。这是它比unique_ptr灵活的地方,也是它更重的原因。


4.3 用 shared_ptr 管理数组


C++17 起shared_ptr<T[]>有专门支持,operator[]可用:


// 适用于 C++17
std::shared_ptr<int[]> arr(new int[10], std::default_delete<int[]>());
arr[0] = 1;

C++17 之前需要用shared_ptr<int>(new int[10], [](int* p) { delete[] p; })。C++20 起std::make_shared支持数组形式,但支持的编译器版本不一,使用前要查工具链的支持表。


五、完整示例:主题与观察者


// 适用于 C++17
#include <algorithm>
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>

class Observer {
public:
virtual ~Observer() = default;
virtual void on_event(const std::string& event) = 0;
};

class Subject {
public:
void subscribe(const std::shared_ptr<Observer>& obs) {
observers_.push_back(obs);
}

void notify(const std::string& event) {
// 先清理已经失效的观察者,再逐个通知
observers_.erase(
std::remove_if(observers_.begin(), observers_.end(),
[](const std::weak_ptr<Observer>& w) { return w.expired(); }),
observers_.end());

for (auto& w : observers_) {
if (auto obs = w.lock()) {
obs->on_event(event);
}
}
}

private:
std::vector<std::weak_ptr<Observer>> observers_;
};

class ConsoleObserver : public Observer {
public:
explicit ConsoleObserver(std::string name) : name_(std::move(name)) {}
void on_event(const std::string& event) override {
std::cout << name_ << ": " << event << '\n';
}
private:
std::string name_;
};

int main() {
Subject subject;
auto a = std::make_shared<ConsoleObserver>("A");
auto b = std::make_shared<ConsoleObserver>("B");

subject.subscribe(a);
subject.subscribe(b);
subject.notify("start");

b.reset(); // B 被销毁,weak_ptr 自动失效
subject.notify("stop"); // 只剩 A 收到
return 0;
}

Subject用weak_ptr存储观察者,因此不延长观察者的生命周期;观察者被销毁后,下一次notify会先erase掉过期项。如果把容器换成vector<shared_ptr<Observer>>,观察者就永远无法销毁,这是事件总线里最常见的泄漏来源。


注意notify里先清理再遍历,避免在遍历过程中修改容器导致迭代器失效;remove_if与erase的组合是标准的 erase-remove 惯用法。


常见坑点



  1. ❌std::shared_ptr<int> p(new int(1));和std::shared_ptr<int> q(p.get());


✅ 从已有shared_ptr拷贝,或统一用make_shared同一裸指针构造两次会产生两个控制块,导致重复释放。



  1. ❌ 两个对象互相持有shared_ptr


✅ 其中一侧改成std::weak_ptr循环引用让强计数永远不归零,对象无法销毁。



  1. ❌if (!wp.expired()) { auto sp = wp.lock(); use(*sp); }


✅if (auto sp = wp.lock()) { use(*sp); }expired()与lock()之间的状态可能变化,判断与提升必须一步完成。



  1. ❌ 认为shared_ptr线程安全,多个线程直接改同一对象


✅ 只依赖控制块的原子性;对象本身用互斥量或原子类型保护shared_ptr保证计数安全,不保证被指向对象的访问安全。



  1. ❌std::unique_ptr<int> p(new int[10]);


✅std::unique_ptr<int[]> p(new int[10]);或std::make_unique<int[]>(10)默认删除器用delete而不是delete[],属于 UB。



  1. ❌ 在类里写shared_ptr<T>(this)返回自身


✅ 继承std::enable_shared_from_this<T>,用shared_from_this()否则会创建第二个控制块,对象被析构两次。



  1. ❌ 用use_count()做业务判断或同步


✅ 用weak_ptr::lock()判断对象是否存活 多线程下use_count()只是一个瞬时快照,不构成同步。



  1. ❌ 把unique_ptr按const unique_ptr<T>&传参表示「只读使用」


✅ 传T*或T&,接管所有权时按值传unique_ptr<T>引用参数限制了调用方,也模糊了所有权语义。


总结




指针所有权拷贝主要开销典型场景



unique_ptr独占禁止,可移动几乎为零工厂返回值、成员、RAII 包装

shared_ptr共享允许控制块 + 原子计数多方共享生命周期

weak_ptr不拥有允许控制块中的弱计数打破循环、观察者、缓存



智能指针的价值不只是「自动 delete」,而是把所有权关系写进类型:看到unique_ptr<T>就知道只有一个所有者,看到shared_ptr<T>就知道生命周期由多方共同决定,看到weak_ptr<T>就知道它随时可能失效。模板参数和删除器的设计进一步把「怎么释放」也纳入了类型系统。


实际工程里应优先用unique_ptr,只有在确实需要共享生命周期时才升级到shared_ptr,并且一旦出现「双向关系」就立刻考虑weak_ptr。这套组合能覆盖绝大多数资源管理场景,剩下的少数情况(自定义分配器、跨 DLL 边界、与 C 接口交互)再针对性处理。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询