写在前面
我知道在 C++11 已经内置std::shared_ptr、std::unique_ptr的今天,再去讨论 Boost 智能指针,很多人第一反应是“还有必要吗?”。
我的回答是:有必要,而且比你想的更必要。
Boost 的智能指针组件,尤其是boost::shared_ptr,是现代 C++ 智能指针设计的“母本”。标准库的shared_ptr几乎原封不动地继承了它的设计思路,但 Boost 家族里还有几个成员,比如scoped_ptr、intrusive_ptr、weak_ptr,它们各自解决的问题在今天依然存在,在某些场景下甚至比标准库方案更顺手。
这篇文章不是把官方文档翻一遍然后抄给你看,而是结合我在几个实际项目里用 Boost 智能指针踩过的坑、做过的取舍,把整个家族的成员、内部机制、适用场景、容易出错的地方系统地捋一遍。不求面面俱到,但求每一段都能落地。
老规矩,先给结论:如果你正在看这篇文章,说明你已经在处理 C++ 的资源管理问题,大概率是受了裸指针的苦。先别急着把代码里的new全换成shared_ptr,读完这篇再动手,你会少踩很多坑。
1. 为什么还需要去啃Boost的智能指针
1.1 Boost系列在项目中的历史存在感
很多老项目的代码库里,Boost 不是“可选项”,而是“硬依赖”。
我之前维护过一个跨平台的服务端模块,代码从早期 C++ 时代一直演进过来,里面大量使用boost::shared_ptr、boost::weak_ptr、boost::bind。模块内部逻辑层层嵌套,如果想把所有 Boost 智能指针全部迁到std::版本,不是改个名字那么简单——涉及跨动态库边界传递、ABI 兼容问题、第三方库接口签名,整个过程堪比一场小规模重构。
所以,对于还在维护这类代码的同学来说,理解 Boost 版本的智能指针不只是“学点历史知识”,而是每天写代码都要面对的现实。
另外还有一个常被忽略的点:Boost 库本身对编译环境兼容性非常宽,很多老编译环境里std::shared_ptr不可用或者有缺陷,但 Boost 版本能正常工作。这也是很多嵌入式、游戏后端项目仍然选择 Boost 的原因。
1.2 C++11标准库智能指针和Boost是什么关系
讲清楚一件事:std::shared_ptr就是 Boost 智能指针的“官方转正版”。
C++11 标准化的时候,标准委员会把 Boost 里经过大规模实践检验的shared_ptr、weak_ptr直接吸收进了标准库,连设计细节、成员函数名都几乎一样。std::unique_ptr则在一定程度上吸收了scoped_ptr的思想,但有本质区别——scoped_ptr禁止拷贝也禁止转移,unique_ptr允许通过移动语义转移所有权。
这意味着:一旦你把 Boost 版本的智能指针用熟练了,切到标准库版本几乎零成本。反过来,如果你现在还在编写必须兼容旧环境、或者对接旧第三方库的代码,Boost 版本依然是可靠选择。
1.3 什么人适合以Boost版本为主线学习
我的建议是三类人:
一是维护老项目的开发者,代码库里已经大量使用 Boost 智能指针,你必须把它彻底吃透才敢改动那些涉及生命周期管理的逻辑。
二是开发跨平台基础库的开发者,需要保证在多种编译器、多种 C++ 标准下行为一致的智能指针,Boost 提供了比标准库更宽泛的兼容面。
三是想深入理解智能指针底层机制的人。Boost 源码是可读性相对较好的开源代码,比如shared_ptr的控制块设计、引用计数原子操作等,读一遍源码比你用标准库黑盒十年学到的东西都多。
我认识的不少 C++ 开发者,日常用std::版本,但做性能分析、排查疑难内存问题时,都会回头翻 Boost 的实现细节来找思路。这就是"母本"的价值。
2. Boost智能指针家族成员盘点:每个成员解决什么问题
Boost 智能指针不是一个组件,而是一整个家族。很多人只知道shared_ptr,忽略其他成员,结果是用一把锤子敲所有钉子,遇到scoped_ptr更适合的场景也用shared_ptr硬上,白白引入不必要的开销。
下面把家族成员捋一遍,顺便讲清楚到底什么时候该用哪一个。
2.1 独占所有权场景:scoped_ptr与scoped_array
boost::scoped_ptr是 Boost 里的“独占所有权智能指针”,它的名字也很直白:作用域指针。
核心特点是:不可拷贝、不可转移、所有权唯一且严格绑定在作用域内。当scoped_ptr离开作用域时,自动 delete 所管理的对象。
#include <boost/scoped_ptr.hpp> void demo_scoped() { boost::scoped_ptr<Config> cfg(new Config(42)); // 使用 cfg->GetTimeout() // 离开函数时自动释放,无需手动 delete }为什么需要这样一个看起来"功能很少"的指针?因为它把"独占 + 自动释放"这两个语义用类型系统固化了下来。你写boost::scoped_ptr<T>的时候,阅读代码的人立刻知道:这个对象的生命周期就限定在当前作用域,不会外传,不用担心悬垂引用。
scoped_array是专门针对new[]分配的数组的重载版本,析构时调用delete[]:
#include <boost/scoped_array.hpp> void demo_scoped_array() { boost::scoped_array<int> arr(new int[1024]); arr[0] = 1; // 支持 operator[] // 离开作用域自动 delete[] }在 C++11 之后,std::unique_ptr基本覆盖了这两个场景(数组版本需要写std::unique_ptr<T[]>),但在老项目里,scoped_ptr依然随处可见。它最大的价值是“语义最精确”:既不允许拷贝也不允许移动,把意外转移所有权的可能性从设计上就消灭了。
2.2 共享所有权核心:shared_ptr的引用计数与控制块
boost::shared_ptr是整个 Boost 智能指针家族的核心成员,解决的核心问题是:一个堆对象同时被多个持有者引用,谁最后一个离开谁负责释放。
#include <boost/shared_ptr.hpp> struct Task { virtual ~Task() {} void Run() { /* do something */ } }; void demo_shared() { // 方式一:直接 new boost::shared_ptr<Task> t1(new Task()); // 方式二:make_shared,更推荐 boost::shared_ptr<Task> t2 = boost::make_shared<Task>(); // 拷贝构造,引用计数变为 2 boost::shared_ptr<Task> t3 = t2; // t1、t2、t3 离开作用域后,Task 对象被释放一次且仅一次 }从命名就能看出语义:多个指针共享同一个对象的所有权。每个shared_ptr实例内部分别持有两个东西——原始指针px和指向控制块的指针pn。控制块里保存着引用计数(use_count)、弱引用计数(weak_count)、删除器、分配器等信息。
关键点:原始指针和控制块是两个独立分配的对象(用make_shared时它们被合并为一次分配,后面专门讲)。所有指向同一个对象的shared_ptr实例都共享同一个控制块指针,因此use_count的增减是原子的。
Boost 的shared_ptr还有一个标准库版本早期没有的特性:别名构造(aliasing constructor),后面单独展开。
2.3 打破循环引用的利器:weak_ptr
boost::weak_ptr不是独立管理对象生命周期的指针,它是一种"观察者指针"。
weak_ptr从一个shared_ptr构造出来,它知道目标对象的地址,但不保有所有权。它内部只增加控制块的弱引用计数(weak_count),不增加强引用计数(use_count)。因此,即使有多个weak_ptr指向一个对象,只要所有shared_ptr都析构了,对象照样被释放。
#include <boost/shared_ptr.hpp> #include <boost/weak_ptr.hpp> void demo_weak() { boost::shared_ptr<Task> sptr = boost::make_shared<Task>(); boost::weak_ptr<Task> wptr = sptr; // 尝试提升为 shared_ptr boost::shared_ptr<Task> locked = wptr.lock(); if (locked) { // 对象仍然存活,安全使用 } }那weak_ptr的实际价值是什么?最典型的就是打破shared_ptr循环引用,以及实现"缓存/观察者"模式——前者涉及两个对象互相持有shared_ptr,后者涉及一对象需要知道另一对象是否还活着但不希望阻止它死亡。
2.4 面向特殊所有权模型:intrusive_ptr
boost::intrusive_ptr是家族里比较特立独行的成员,它要求被管理的对象自己实现引用计数,而不是由shared_ptr在控制块里替你计数。
#include <boost/intrusive_ptr.hpp> // 对象内部持有引用计数,需要自己实现 ref_counted 接口 class RefCounted { public: RefCounted() : ref_count_(0) {} virtual ~RefCounted() {} // 这两个函数是 intrusive_ptr 需要的 friend void intrusive_ptr_add_ref(RefCounted* p) { ++p->ref_count_; } friend void intrusive_ptr_release(RefCounted* p) { if (--p->ref_count_ == 0) { delete p; } } private: int ref_count_; }; void demo_intrusive() { boost::intrusive_ptr<RefCounted> obj(new RefCounted()); }它的优势有两个:一是可以从不完整的类型转换回裸指针,适合从void*或接口层返回对象;二是它不要求对象一定由智能指针构造——你可以把一个已经用裸指针管理着、且自带引用计数的对象,安全地托管给intrusive_ptr。
常见的适用场景是第三方库(比如早期图形引擎、COM 风格组件)已经用自己的引用计数协议管理对象,你不想再造一套控制块,直接适配intrusive_ptr即可。
我把家族成员的选择逻辑整理成一个表,方便直接对照:
| 智能指针 | 所有权模型 | 可拷贝 | 可转移 | 典型场景 |
|---|---|---|---|---|
| scoped_ptr | 独占 | 否 | 否 | 局部作用域独占对象 |
| scoped_array | 独占 | 否 | 否 | 局部作用域独占数组 |
| shared_ptr | 共享 | 是 | 是 | 多个持有者共享对象 |
| weak_ptr | 无所有权 | 是 | 是 | 观察、打破循环引用 |
| intrusive_ptr | 对象自计数 | 是 | 是 | 对接已有引用计数协议的对象 |
3. shared_ptr内部机制拆解:计数、控制块与线程安全真相
3.1 引用计数和控制块的内部布局
shared_ptr表面上看起来是"一个指针",实际上内部是两个指针的组合:一个指向被管理的对象(px),另一个指向控制块(pn)。
控制块里放着什么?不同实现略有差异,但核心成员大致如下:
- use_count:强引用计数,也就是当前有多少个
shared_ptr实例共享这个对象 - weak_count:弱引用计数,当前有多少个
weak_ptr引用这个控制块 - 删除器 deleter:析构对象时调用什么释放函数,默认是
delete,你也可以放自定义函数 - 分配器 allocator:控制块本身的内存分配器
这里有一个初学者最容易忽略的点:释放对象的时机不是"use_count 归零"这么简单。
当最后一个shared_ptr析构时,use_count 变为 0,此时会立刻调用 deleter 释放被管理的对象。但是控制块本身不一定被释放,因为可能还有weak_ptr指向它。只有 weak_count 也归零时,控制块才会被释放。
这个设计非常巧妙:weak_ptr要知道对象是否还活着,就必须访问控制块里的 use_count,所以控制块必须活到最后一个weak_ptr消失为止。
3.2 一个容易被误会的点:make_shared和普通构造的区别
用boost::shared_ptr<T>(new T())和boost::make_shared<T>()构造有什么区别?
区别在于内存分配次数。普通构造要分配两次内存:一次是new T()给对象本身,另一次是shared_ptr构造时给控制块分配内存。
make_shared把这两次合并成一次:它在堆上申请一块足够大的内存,前半部分放对象,后半部分放控制块。对象和控制块在内存上相邻,对缓存友好,也节省了一次malloc调用。
// 两次分配:对象一次,控制块一次 boost::shared_ptr<Task> p(new Task()); // 一次分配:对象+控制块连续存放 boost::shared_ptr<Task> p = boost::make_shared<Task>();但make_shared也不是没有代价:因为对象和控制块共用一块内存,当最后一个shared_ptr析构时,对象的析构函数会执行,但这块内存整体要继续等到 weak_count 归零,否则控制块信息会丢失。如果对象很大,持有很大资源,但仍有不少weak_ptr在引用,就会出现"对象析构了但内存没释放"的情况——这个 object 占用的空间依然在堆上,严格说不是泄漏,但确实会感觉资源没有完全释放。
所以,大对象配合长期存活的weak_ptr缓存池使用时要斟酌。爆一个典型场景:缓存池用weak_ptr持有大纹理对象,底层纹理已经delete了,但只要weak_ptr还在,存储纹理数据的堆内存就一直挂着。
我处理过类似的案例,最后改用"普通构造 + 自定义内存池"绕开了这个问题。这个取舍后面在避坑环节专门说。
3.3 线程安全的边界:哪些操作安全,哪些不安全
这是面试必考,也是写并发代码最容易出问题的地方。很多人的印象是 "shared_ptr 是线程安全的",这个说法太笼统,实际上要分三个层次看:
第一层:引用计数本身的增减是线程安全的。多个线程同时拷贝/析构指向同一个对象的shared_ptr,Boost 保证引用计数不会被多线程搞坏,对象不会被重复释放或泄漏。
第二层:同一个 shared_ptr 实例的并发读写是不安全的。比如线程 A 正在给shared_ptr赋值(这会修改内部指针),线程 B 同时读它,这两个操作没有原子性保证,会数据竞争。
第三层:shared_ptr 指向的对象内部的并发保护,与 shared_ptr 无关。shared_ptr 只管理生命周期,不管理数据同步。多个线程通过同一个shared_ptr访问对象,对象内部的成员变量仍需加锁或原子操作。
我见过一个线上 bug,就是一个人把 shared_ptr 当成"线程安全的万能钥匙",多个线程直接往 shared_ptr 里塞新对象,另一个线程读取时不加锁,结果偶尔读到悬空指针。问题不在 shared_ptr,而在把他自己的并发操作误认为是原子的。
Boost 的文档里其实写得很明确:如果要支持对同一个 shared_ptr 实例的并发读写,需要额外用原子操作接口,比如boost::atomic_load(&p)/boost::atomic_store(&p)。标准库 C++11 的std::atomic_load也是同样的道理。
weak_ptr的lock()操作是线程安全的:它会以原子方式读取控制块的 use_count,若不为 0,则在增加计数的同时返回一个指向对象的shared_ptr,否则返回空。这样"检查对象是否存活 + 延长生命周期"两个动作作为一个原子步骤完成,避免检查完对象存活但还没来得及用就被释放的竞态。
4. 一级实战:weak_ptr在项目里最常见的三种用法
weak_ptr是 Boost 智能指针家族里最容易被忽视、但实际价值极高的成员。在此写三个我实际用过的案例,比纯看 API 更直观。
4.1 打破循环引用:两个对象互相持有对方
最经典的场景是父子结构或观察者模式:
#include <boost/shared_ptr.hpp> #include <boost/weak_ptr.hpp> class Child; class Parent { public: boost::shared_ptr<Child> child_; ~Parent() { /* 释放资源 */ } }; class Child { public: boost::weak_ptr<Parent> parent_; // 关键:用 weak_ptr 持有父对象 };如果 Parent 用shared_ptr<Child>持有子,Child 再用shared_ptr<Parent>持有父,两个对象的引用计数永远无法归零,谁都无法触发析构,内存泄漏。
Child 里改成boost::weak_ptr<Parent> parent_之后,Parent 的引用计数只由外部持有者控制,析构时机恢复正常。Child 使用 parent 时先lock()提升,提升成功说明 parent 还活着,失败说明 parent 已销毁,逻辑安全。
注意这里一个细节:用完lock()返回的临时 shared_ptr 后,最好立即释放,不要长期保存,否则又变相延长了对象生命周期。临时对象作为局部变量、用完即丢,问题不大。
4.2 缓存与观察者:想用就提升,不用就放弃
我最近在某项目里实现过一个目录监听器。业务方注册回调时传入一个shared_ptr<Listener>,但监听器内部维护的是weak_ptr<Listener>列表。
这样有几个好处:
- 注册的监听器如果已经被业务方销毁,监听器内部在遍历时
lock()失败,自动跳过,不会调用悬空对象 - 不需要业务方显式“反注册”,被销毁的监听器自然失效,
weak_ptr不持有所有权,不会阻止对象析构
如果监听器用shared_ptr保存 Listener,那业务方即使不再需要这个 Listener 对象,它也会因为被监听器强引用而无法析构,这就是内存泄漏,而且很难排查。改成weak_ptr后,生命周期管理回归到业务那边,监听器只做观察,不反客为主。
4.3 多线程任务队列中的异步安全
还有一个常用场景:任务队列里持有的是weak_ptr<TaskContext>,线程池取出任务后先lock()。
void ThreadPoolWorker::Run() { boost::weak_ptr<TaskContext> task_weak = task_queue_.Pop(); boost::shared_ptr<TaskContext> task = task_weak.lock(); if (!task) { return; // 任务上下文已被外部取消或超时清除 } task->Execute(); }外部通过清空所有shared_ptr<TaskContext>来取消任务:由于任务队列持的是weak_ptr,取消后线程池自然拿不到强引用,任务被优雅地跳过。这个模式避免了“强引用队列导致已取消任务继续执行”的问题,也避免了裸指针队列的悬空风险。
5. 深入Boost特有而标准库没有的特性
很多教程只把 Boost 和标准库智能指针对比“名称不同”,却忽略了一个重要事实:Boost 版本有几个标准库没有的特性,尤其对底层开发者非常有用。
5.1 别名构造函数:让指针指向子对象但共享所有权
Boost 的shared_ptr支持一种特殊构造方式:
boost::shared_ptr<Base> base(new Derived()); // 创建指向 base 内部某个成员的 shared_ptr, // 但它和 base 共享所有权,不影响生命周期 boost::shared_ptr<Member> member(base, &base->data_);第二个构造函数参数传入的是一个别名指针——new shared_ptr 的 px 指向base->data_,但 pn(控制块指针)和原来的 base 相同。
这意味着:member 知道自己“属于”base,任何一方离开作用域都不会真的释放对象,只有最后一个持有者离开时才释放。
标准库在 C++17 之前没有这个特性,很多做框架底层的人用 Boost 版本拿到“指针语义上的子对象 + 所有权上的整体控制”这种灵活度。你在写接口返回内部成员但同时允许调用方安全持有它时,这个特性非常有用。
5.2 动态指针转换:static_pointer_cast / dynamic_pointer_cast / const_pointer_cast
Boost 提供了和裸指针的static_cast、dynamic_cast、const_cast对应的智能指针版本:
boost::shared_ptr<Base> base = ...; boost::shared_ptr<Derived> derived = boost::dynamic_pointer_cast<Derived>(base); if (derived) { // 转换成功 }这些函数返回一个新的shared_ptr,与原对象共享所有权,但指针类型变了。如果转换失败(如dynamic_pointer_cast目标类型不符合),返回空shared_ptr,不会造成引用计数混乱。
这个特性对多态对象尤其重要。没有它,你必须先做裸指针 cast 再把裸指针包成 shared_ptr,两次构造很容易出错。
5.3 比较运算符与自定义删除器
shared_ptr既支持比较内部裸指针地址的 operator<,也支持通过std::owner_less比较控制块地址,后者是唯一可靠的控制块排序方式。当你用 shared_ptr 作为 map/set 的键时,用 owner_less 排序可以避免“两个 shared_ptr 指向不同地址但共享同一对象”时重复插入的问题。
自定义删除器也很好用:
void DeleteFileHandle(File* f) { /* 不是 delete,而是 fclose */ } boost::shared_ptr<File> file(std::fopen("conf.ini", "r"), DeleteFileHandle);文件句柄不是new出来的,默认 delete 会出错,但通过自定义删除器,shared_ptr 可以在离开作用域时自动 fclose。这个思路延伸到 socket、锁、mmap 区域,什么都能管。
6. 容易踩的五个坑与排查过程
6.1 坑一:把栈对象交给shared_ptr导致双重释放
我见过不止一次这种代码:
void Bad() { Config cfg; boost::shared_ptr<Config> ptr(&cfg); // 危险 // ptr 析构时 delete &cfg,但 cfg 是栈对象! }栈对象在函数结束时会被编译器自动调用析构,shared_ptr 再 delete 一次,双重释放,程序直接崩溃。这种 bug 的表现往往是“有时正常有时崩溃”,非常难定位。
排查思路:只要出现不稳定的析构崩溃,先检查所有被 shared_ptr 管理的对象是否都来自new(或make_shared)。尤其是从某个接口返回的裸指针,你很难判断它是堆上还是栈上。我的建议是:一切裸指针交给 shared_ptr 之前,明确对象生命周期来源;拿不准就立刻改接口设计,不要让一个裸指针在两种所有权模式下摇摆。
6.2 坑二:类内部shared_ptr自持有导致对象永远无法析构
有一个经典错误,类的成员函数里保留了一个指向自己的shared_ptr:
class Server { public: void Start() { self_ = shared_from_this(); // 错误示范:用 weak_ptr 或者交给外部持有 } private: boost::shared_ptr<Server> self_; };如果 self_ 持有了唯一一个指向自己的 shared_ptr,这个对象的引用计数恒为 1,永远无法归零,析构永远不会触发——这叫“自我循环引用”,比父子循环引用更隐蔽。
需要让对象在自己的成员函数里安全获取 shared_ptr 时,正确做法是继承boost::enable_shared_from_this<T>,然后用shared_from_this()。要确认一点:这个函数必须在对象已经被某个 shared_ptr 管理后才能调用,否则抛出异常或返回空。
有个相关教训:如果你在构造函数里调用shared_from_this(),保证会失败——因为控制块还没建立。所有需要自引用的初始化逻辑要挪到 init 函数或工厂函数里。
6.3 坑三:Boost和标准库智能指针混用导致控制块双份
这是老项目迁移路上最常见的坑。
void DoSomething(std::shared_ptr<Config> cfg); boost::shared_ptr<Config> bcfg = boost::make_shared<Config>(); DoSomething(bcfg); // 编译失败,类型不兼容Boost 的shared_ptr和std::shared_ptr不是同一个类型,互相隐式转换会失败。如果强行通过bcfg.get()拿到裸指针再包成std::shared_ptr,就会得到两个完全独立的管理者,各自维护一套引用计数,谁先归零谁先释放,另一个必然悬空。
这个问题的解药只有一个:一个代码库里,要么全用 Boost,要么全用 std,不能混合。如果必须跨边界传递,用.get()取出裸指针传递,由调用方明确接管所有权,并保证生命周期。不要在边界两边各造一套控制块。
我在一个老项目里就吃过这个亏,当时为了“逐步迁移”,把函数参数从std::shared_ptr换回boost::shared_ptr改了一部分,结果又碰上兼容层接口,两边一混合,线上出现偶发崩溃。排查了很久才发现是一个底层对象被两个控制块管理了。
6.4 坑四:intrusive_ptr忘记实现释放函数造成泄漏
intrusive_ptr依靠你提供的intrusive_ptr_add_ref/intrusive_ptr_release来计数和释放。如果你只实现了add_ref忘记实现release,编译期可能还是能过(取决于代码结构),但析构时对象永远不会 delete,运行期内存泄漏。
我建议给intrusive_ptr管理的对象定义一个统一的基类,把两个函数做成模板:
template <typename T> void intrusive_ptr_add_ref(T* p) { p->AddRef(); } template <typename T> void intrusive_ptr_release(T* p) { p->Release(); }所有需要被intrusive_ptr管理的对象统一实现AddRef/Release接口,避免各写各的导致漏实现。
6.5 坑五:大对象长期被weak_ptr引用导致释放延迟
前面提过,weak_ptr的存在会延迟控制块内存的释放。对普通小对象无所谓,但如果是几 MB 的大纹理、大缓冲区,这个问题就变得明显。
我遇到一个场景:缓存池用weak_ptr保存大纹理引用,业务上纹理已经删光,但weak_ptr还在,纹理对象的析构函数执行了,但内部那个大缓冲区始终没有真正归还给内存池。表现就是 RSS 迟迟不降。
处理方案二选一:
- 用普通构造而不是
make_shared,让对象内存和控制块内存分离,控制块虽没释放,但大对象内存可以第一时间归还 - 定期清理缓存池里的
weak_ptr,把已经失效的条目清出去,顺带释放控制块
这也是为什么"能用 make_shared 就用"这句话不是绝对真理——在弱引用多的场景里,反而要用非 make_shared 构造。
7. 和标准库智能指针的全面对比与选型建议
老读者都知道,我一向反对"什么新用什么"的盲目升级。这一节给一个清晰的选择框架。
7.1 功能对比表
| 项目 | boost::shared_ptr | std::shared_ptr (C++11) |
|---|---|---|
| 引用计数机制 | 控制块独立/合并 | 控制块独立/合并 |
| 线程安全 | 计数原子操作 | 计数原子操作 |
| make_shared | boost::make_shared | std::make_shared |
| 数组支持 | 需要scoped_array/shared_array | C++17 起 shared_ptr<T[]> |
| 别名构造 | 支持 | 无(C++17 前) |
| 动态指针转换 | 提供 | C++17 提供 |
| 自定义删除器 | 支持 | 支持 |
| 与老编译环境兼容 | 好 | 受限 |
7.2 什么时候坚持用Boost版本
首先,凡是你在维护的老代码库,里面 Boost 和第三方库已经深度耦合,不要为了“看起来现代”去全部替换,风险大于收益。
其次,如果你的编译器停留在 C++03 时代(某些嵌入式、车载领域很常见),标准库压根没有shared_ptr,Boost 是唯一可选的高质量方案。
第三,你的项目需要使用shared_ptr的别名构造(C++17 之前的编译环境),或深度依赖intrusive_ptr与第三方库配合。
7.3 新项目怎么办
如果你在写全新代码,编译器支持 C++14 及以上,且项目没有历史包袱,我的建议是:默认用标准库版本,但把 Boost 智能指针的思维方式带过去。
什么意思?就是按成员语义选类型:独占用unique_ptr,共享用shared_ptr,观察用weak_ptr。不要只靠 shared_ptr 打天下,也不要在不需要共享所有权的地方滥用 shared_ptr,那只是消耗引用计数的原子操作开销而已。
还有一点:新代码如果要定义一个“必须被智能指针管理的类型”,优先考虑shared_from_this这种设计,而不是让对象裸奔出去再想办法。从接口设计阶段就确定所有权模型,比事后打补丁省心得多。
7.4 混用与迁移的保守建议
如果你的老代码库短期内不打算全面迁移,至少要做一个“边界隔离”:对外接口的统一所有权语义(比如全部用裸指针传入传出,由调用方管理生命周期),内部实现自选 Boost 或 std。这样至少不会出现同一对象两边各管一套的问题。
渐进式迁移的顺序我建议是:先迁移纯内部局部对象,再迁移函数参数签名,最后动跨模块接口。每动一步都要确认控制块没有双重管理。这个节奏慢一些,但可靠性高得多。
8. 我的使用习惯:关于构造、传参与返回的实践建议
最后分享几个我用 Boost 智能指针多年沉淀下来的习惯,算是经验之谈。
第一个习惯:优先用 make_shared,但在弱引用缓存场景放弃它。
一般场景,make_shared 一次分配、缓存友好,还能避免裸指针在 shared_ptr 构造前出现异常导致泄漏。但在大量弱引用存在的场景,我用普通构造,并且给控制块提供默认分配器,这样大对象内存能在最后一个强引用离开时快速释放。
第二个习惯:参数传递用 const boost::shared_ptr & 还是按值,要想清楚。
如果函数只是读取对象内容、不打算延长生命周期,我接受const boost::shared_ptr<T>&,但这个引用本身不能保存到异步任务里,否则一旦外部 shared_ptr 析构,局部引用就悬了。如果函数要把对象保存到异步任务、缓存或队列,那必须按值传,增加一次引用计数,确保目标线程内安全。
// 只是读取,用 const 引用 void Inspect(const boost::shared_ptr<Config>& cfg); // 需要保存跨线程使用,按值传 void EnqueueTask(boost::shared_ptr<Task> task);第三个习惯:返回智能指针尽量返回 shared_ptr 而不是裸指针,但避免返回一个不代表所有权的裸指针。只要返回 shared_ptr,配套设施如enable_shared_from_this和 weak_ptr 才能正常工作,调用方也才能安全地把对象放进缓存。
第四个习惯:一定给 shared_ptr 提供明确的自定义删除器,如果管理的对象不是 new 来的。文件句柄、数据库连接、mmap 区域这些东西本来不是 new 出来的,配上正确删除器后,智能指针的“自动释放”才能扩展到所有资源类型,而不只是堆内存。顺带还能在删除器里加入调试日志,排查生命周期问题时特别好用。
第五个习惯:给控制块写日志埋点。在自定义删除器里打印一条“对象 X 被释放”的日志,你就能在线上看到对象确切的析构时刻,比对业务日志判断是否有提前释放或延迟释放。这个习惯帮我定位过不少疑难问题,成本极低,收益很高。
另外补充一个关于weak_ptr::lock()的细节:Boost 版本和标准库版本的 lock 返回行为基本一致,但要注意 lock 之后的临时 shared_ptr 生命周期不要跨队列传输。有些人喜欢把 lock 结果直接塞进异步任务 lambda 里,很容易意外延长对象生命周期,让原本预期的“任务已取消”失效。
我在实际项目中就碰到过,任务队列持 weak_ptr,外部把共享对象销毁了,结果任务线程从 weak_ptr.lock() 拿到了一个非空 shared_ptr,但因为这个 shared_ptr 临时变量不小心被捕获进 lambda 并保存了比较久,后来外部再次期望对象已销毁时,它仍然存活着。最后排查下来是生命周期被无意延长,不是 weak_ptr 失效。
这种问题只有靠保持代码里所有权意图清晰来避免:lock() 提升的强引用,只用于当前同步代码段内,绝不隐式捕获保存。
Boost 智能指针的细节还有很多,但把这几个核心成员和易错点吃透,日常开发基本够用了。说到底,智能指针本身不是一个“花哨工具”,它是把资源所有权语义显式化的手段。理解清楚每种指针代表的所有权关系,比记住一堆 API 重要得多。