Effective Modern C++ 读书笔记:智能指针
智能指针是现代 C++ 中的一个重要内容,以至于当使用现代 C++ 编程时,智能指针可以完全取代裸指针。
使用 C 风格裸指针,存在很多已经被人诟病已久的问题,比如:
- 裸指针从声明中,无法看出它指向的是对象还是数组;
- 无法通过裸指针本身来判断,是否需要析构它的资源;
- 就算知道需要析构资源,也不知道应该怎么析构,是直接调用
delete还是调用某个用于释放资源的函数; - 另外,同第1条,也不可能知道应该调用
delete还是delete[],这都带来了很多风险; - 在使用裸指针的工程中,时刻都需要考虑这些资源在不同路径下的状态,除了常规代码逻辑外,还需要考虑发生异常时的路径。一旦有所疏忽,便会出现资源泄漏或未定义行为;
- 如果资源释放时,没有将裸指针置为 0,那么将来再次意外解引用时,就会遇到悬挂指针问题;
为了解决这些问题,现代 C++ 提出了几种智能指针,它的本质是依靠 C++ 的 RAII 设计理念,将资源管理和对象生命周期绑定在一起,从而避免让程序员主动去操作资源释放的行为。unique_ptr 和 shared_ptr 是两种最常见的智能指针,另外还有 weak_ptr 用于解决 shared_ptr 的循环引用问题,在后续内容中会逐个展开讨论。auto_ptr 在 C++11 中被 unique_ptr 取代,所以不要再使用。
条款 18:优先使用 unique_ptr
当需要使用指针来引用一块资源时,考虑使用智能指针;当需要使用智能指针时,优先考虑 unique_ptr。这里的原则是,大多数情况下,我们分配的资源,是交给“一个”目标使用的,所以专属所有权通常就足够了。
unique_ptr 使用移动语义来实现指针的转移,它没有复制操作,所以才能实现“唯一性”。
它最常见的用处是作为工厂函数的返回值。一个工厂函数,通常是在函数内部创建一个位于堆上的对象,返回之后,需要交接资源的所有权,这就是 unique_ptr 的用武之地。交接之后,资源的责任人变成了调用工厂函数的一方。
自定义析构器
unique_ptr 具有自定义析构函数。一个自定义析构函数,是指当智能指针被析构时,选择调用的函数。比如,我们希望在析构智能指针时,打印一些日志,那么就可以使用自定义析构函数。
自定义析构函数可以通过智能指针的第二个模板参数来指定,它可以是函数指针,函数对象或 lambda 表达式等可调用类型,它接受一个参数,类型为原始资源的裸指针。比如:
1 | auto delFunc = [](Object* obj) { |
示例中,使用 lambda 表达式作为智能指针的自定义析构函数。
通常,我们知道,unique_ptr 对象的大小,和裸指针的大小是一样的。不过,当引入智能指针时,问题需要进一步讨论。
当析构器是函数指针时,这个函数指针需要保存在 unique_ptr 的对象内,所以通常 unique_ptr 的大小会增加几个字节(指针大小);当析构器是函数对象时,析构器对 unique_ptr 空间的影响,则取决于函数对象本身占用的存储空间;当析构器使用 lambda 表达式指定时,因为我们知道,lambda 表达式的本质也是函数对象,对于空捕获列表的 lambda 表达式,不会对 unique_ptr 的空间产生额外需求,但当存在捕获列表时,占用空间则与捕获列表中对象的空间占用相同。
最后,虽然 unique_ptr 可以用来保存一个数组资源,也就是 std::unique_ptr<T[]>,但绝大多数场景下没有这个必要,请使用 vector,array 等线性容器来替代它。
同时,当需要将 unique_ptr 转换为 shared_ptr 时,不要尝试获取它的裸指针后,初始化新的 shared_ptr,这样将很可能带来资源被重复释放的问题。C++ 提供了从 unique_ptr 向 shared_ptr 的类型转换:
1 | // 将工厂函数返回的 unique_ptr 直接转换为 shared_ptr,也是允许的 |
条款 19:谨慎使用 shared_ptr
和 unique_ptr 不同,shared_ptr 中需要注意的使用问题更多一些,我们按书中的思路依次展开。
组成结构
shared_ptr 自身也是指针,但它是共享所有权的智能指针,这意味着多个shared_ptr 可以指向同一块资源,而只有当资源被一个 shared_ptr 指向时,这个 shared_ptr 析构时才负责释放资源。所以,shared_ptr 需要一个计数器,或者严谨点来说,是资源需要一个共享智能指针的计数器。
每个 shared_ptr 的实例由两个指针组成,第一个指针指向负责管理的资源位置,第二个指针指向一个资源控制块。如果有新的 shared_ptr 也指向这个资源,那么它的控制块指针也指向相同的资源控制块。如上提到的计数器就保存在控制块中,另外,控制块中也包括了如自定义的析构器、资源分配器等内容。
虽然 shared_ptr 占用了两个指针的大小,然而它的操作带来的性能开销却远大于 unique_ptr(unique_ptr 和裸指针基本一致)。这由于以下几个原因:
- 第一个创建一块资源的
shared_ptr对象,需要负责分配控制块内存; - 增加或减少对资源的共享引用时,需要增减计数器,而为了避免并发问题,增减操作必须是原子操作,这就带来了额外的开销。考虑缓存和机器行为,原子操作可能带来性能的不稳定性;
正因为原子操作计数器的原因,移动构造一个 shared_ptr(计数器不增加),要比复制构造一个 shared_ptr(计数器+1)要更快。
插一句,Rust 中的引用计数,直接提供了两个实现,Rc<T> 和 Arc<T>,分别用于非并发程序下的共享所有权和并发程序下的共享所有权,前者没有使用原子操作实现引用计数,这样就避免了非并发程序时引用计数的性能开销。并发导致的性能损失,应该只有在真正需要时才需要承担。
除此之外,我们还需要注意在使用 shared_ptr 的几个问题。
自定义析构器
和 unique_ptr 不同,shared_ptr 的析构器并不是智能指针对象的一部分,因为它保存在控制块中,所以,不会涉及到上一节,不同析构器占用空间的讨论。
1 | auto loggingDel = [](Widget *pw) { |
正因为如此,指向相同资源的不同 shared_ptr 可以在初始化时,接受不同的析构器,然后,只有最后一个 shared_ptr 离开作用域时,会调用当前的析构器。注意,同一个资源,只对应一个析构器,看代码:
1 | auto deleter1 = [](int *p) { delete p; std::cout << "Deleter1\n"; }; |
代码中有两个资源,10 和 20,他们的控制块中,自定义析构器分别指向了 deleter1 和 deleter2,初始化时,计数器的值都为 1。在 a 处,ptr1 被 ptr2 赋值为指向资源 20,那么资源 10 的计数器值为 0,触发资源 10 的析构器 deleter1,而此时资源 20 的计数器值为 2;在 b 处,ptr1 释放,但资源 20 的计数器值变为 1,不用调用资源析构;在 c 处,ptr2 释放,资源 20 的计数器值变为 0,调用资源 20 的析构器 deleter2。
因为 shared_ptr 本身不带有析构器类型,所以即使拥有不同的析构器,它们也可以放在同一个容器中:std::vector<std::shared_ptr<int>> vp {ptr1, ptr2};。
创建智能指针
有几种方法可以创建 shared_ptr:
- 使用
std::make_shared接口函数。它总会分配一个新的对象,同时创建一个新的控制块,并返回一个新的shared_ptr,但它不能指定自定义析构器。 - 从
unique_ptr出发构造。由于unique_ptr并没有控制块,所以使用它创建shared_ptr时,编译器会创建一个控制块出来。这个过程,会让unique_ptr失去对资源的所有权。 - 从一个裸指针来构造。这是最灵活也是最危险的一种方式,使用这种方法会创建一个控制块,如上边已经用到的代码。
shared_ptr构造函数可以接受一个可调用对象作为自定义析构器,这也是唯一一种可以自定义析构器的方式。
对第三种方式展开讨论,它存在一个很容易犯的使用错误,编译器无法报告错误。看以下代码:
1 | auto pw = new Widget; |
我们已经知道,这种创建智能指针的方式,会创建一个控制块,那么,构造 spw1 和 spw2 时,就会分别创建一个控制块,其内部计数器的值均为 1,那么,当 a 处 spw1 释放时,资源 pw 就会被析构掉,而在 b 处,spw2 释放时,就会重复释放资源 pw。
我们应该尽量避免使用这种方式来构造智能指针,除非你真的想利用这里边的灵活性。如果一定要做,那么确保多个析构器中,只有一个析构器会去析构资源,而它作为主析构器,必须在最后被初始化,比如上例中,deleter2 作为主析构器,去析构资源。更合理的建议是,不要用一个裸指针对象去初始化智能指针,而是在初始化智能指针时,同时分配资源:
1 | std::shared_ptr<Widget> spw1(new Widget, deleter2); |
陷阱:如无必要,不要使用裸指针对象来初始化带有自定义析构器的 shared_ptr。
衍生话题
看书中给出的这个示例代码:
1 | std::vector<std::shared_ptr<Widget>> processedWidgets; |
process() 成员函数需要做一个事情,把当前对象以 shared_ptr 方式保存到 processedWidgets 中,最容易想到的实现是:
1 | void Widget::process() { |
结合上一小节的知识,就会意识到这里存在问题,每一个 Widget 实例对象,在 process 之后,都会生成自己的智能指针,并拥有自己的控制块,所以智能指针析构时,这里就会发生重复释放资源的问题!(对象自己的析构函数和智能指针管理资源的析构器都会去析构同一个资源)
C++ 委员会已经意识到这个问题,也为我们提供了解决方案,也就是使用 enable_shared_from_this:
1 | class Widget: public std::enable_shared_from_this<Widget> { |
将 Widget 对象继承自 enable_shared_from_this<Widget> 类型,它会在内部维护一个 weak_ptr 对象(拥有共享所有权但不会重复创建控制块),通过一个成员函数 shared_from_this来使用 weak_ptr 获取 shared_ptr,从而帮我们解决这个问题。
这里还有一个最佳实践,因为 enable_shared_from_this 使用的一个前提是,Widget 需要有一个控制块,也就意味着它需要有一个 shared_ptr 已经指向它(控制块存在),才可以正常调用 shared_from_this,所以,需要避免对象被多种方式管理,比如还在栈上或裸指针指向。
这个最佳实践是实现一个工厂函数,返回 shared_ptr 来管理对象,并禁止调用构造函数:
1 | class Widget: public std::enable_shared_from_this<Widget> { |
工厂函数可以确保对象只能被 shared_ptr 管理,且在调用 process 之前(或者说调用 shared_from_this 之前),一定已经存在一个 shared_ptr 指向它。
技巧:使用 enable_shared_from_this 和对外部隐藏构造函数的方式,来安全地实现在类的成员函数内,获取指向该对象的 shared_ptr。
总结
如果你担心这些 shared_ptr 带来的性能问题和潜在陷阱,那么就应该考虑下代码中是否真的需要共享所有权。如果 unique_ptr 可以完成任务,还是要优先使用 unique_ptr。
从 unique_ptr 创建一个 shared_ptr 很容易,只需要分配一个控制块就行,C++ 已经提供了这种能力。但反过来则不行,我们永远无法将一个 shared_ptr 转变为一个 unique_ptr,即使它的计数器为 1。这也就意味着,如果不有所克制,你的工程中,一定会面临 shared_ptr 泛滥的问题,这意味着代码已经开始变味。
条款 20:使用 weak_ptr 作为 shared_ptr 的补充
weak_ptr 不是一种新的智能指针,它是一种特殊的 shared_ptr。它使用和 shared_ptr 相同的控制块,所以也拥有两个指针。weak_ptr 使用 shared_ptr 创建:
1 | auto shared_pointer = std::make_shared<int>(); |
weak_ptr 不同于 shared_ptr 的地方在于,它不会影响 shared_ptr 的引用计数。但因为它依然拥有控制块,所以可以利用里边的信息,实现检查智能指针是否悬挂,这是和裸指针所不一样的地方。
因为检查是否悬挂之后,我们通常希望在没有悬挂时操作智能指针,那么“检查”和“操作”分开执行时,在并发程序中就会遇到竞争的问题,所以,它也提供了一种原子性的操作方式:
1 | // 若 weak_pointer 悬挂,shared_pointer == nullptr |
weak_ptr 最常用的地方是解决 shared_ptr 的环形引用导致资源泄漏的问题,想必是学习智能指针一定会接触的问题。书中还提到了另一种 weak_ptr 的用途,即用它来实现检查资源是否失效的需求。
如果我们想设计一个缓存机制,使用容器保存一些智能指针,它们指向一些已分配的资源。如果容器中存储的是 shared_ptr,那么当所有权被转移到外部时,资源就会被析构,如果想要缓存资源而不是直接析构,就用 weak_ptr 所取代,并在必要时,比如重新加载资源到缓存时,通过 weak_ptr 的悬挂检测来决定。
我个人认为不是很常用的做法,也比较取巧,就不列出代码了。
最后提一下,weak_ptr 只是不修改 shared_ptr 的引用计数,但不代表着它没有性能开销,控制块中还有关于 weak_ptr 的计数器,它依然会原子地修改这个计数器。
谬误:weak_ptr 并没有比 shared_ptr 操作效率高。
条款 21:除非有充分的理由,否则应该使用 make 函数创建智能指针对象
C++ 有三个 make 函数,它们分别是 std::make_unique,std::make_shared 和 std::allocate_shared,其中,std::make_unique 是在 C++14 中引入的,另外两个是在 C++11 中引入的。
从前边的讨论中我们知道,可以使用 new 来初始化一个智能指针,而不需要考虑 delete 的事情,不过,还是优先推荐使用 make 函数来初始化智能指针。
使用 make 函数的优点
C++ 中有一种很常见的异常问题,被程序员们广泛讨论,见代码:
1 | // 有一个函数,接受两个参数,一个智能指针,一个值 |
如果,我们通过 new 来初始化智能指针,再调用一个函数来求第二个值,并把这些放在一条语句中,C++ 是允许的:
1 | func(std::shared_ptr<Widget>(new Widget), compute()); |
但这种写法会带来一个异常安全问题。
因为 C++ 并不规定同一个函数参数的参数列表中,各表达式的求值顺序,所以,这个参数列表中,三个操作:new Widget,compute() 和 shared_ptr<Widget>() 将会以随机的方式执行(严谨一些说,new Widget 会在智能指针构造前完成,但 compute() 会在任意位置完成)。如果,compute() 刚好在 new Widget 和智能指针构造之间执行,而它发生了异常,那么,new Widget 的资源便会发生资源泄漏,永远无法被释放。
有几种不同的方案可以避免这个问题:
1 | // 使用 make 函数 |
对于第三种做法,存在一个小问题。因为 ptr 是左值,所以默认按复制传入形参,而复制操作,会让 shared_ptr 的引用计数增加,带来了额外的性能开销。第二种写法,因为第一个实参是右值,按移动传入,不会带来性能开销。第三种方案可以适当改进一下:func(std::move(ptr), compute());。
陷阱:参数列表中多个表达式有存在异常时,将导致手动分配的内存出现内存泄漏,这是 C++ 很常见的一个陷阱。
另一个使用 make 的优点是,它的性能更好。对于 shared_ptr 来说,因为它除了被指向的资源外,还有一块控制块,也位于堆内存中,如果调用 make_shared 操作,那么只需要进行一次内存分配,同时用于资源数据使用和控制块使用。
而如果使用 new 的方式,将先后进行两次内存分配。向操作系统的一次内存申请,其性能将优于两次申请。这同样适用于 std::allocate_shared 操作。
使用 make 函数的不足
和之前的一些条款一样,使用 make 函数并不是一个完美替代 new 操作来创建智能指针的方案,它也存在一些无法被使用的场景。
第一个场景是自定义析构器。从前边条款中得知,make 函数不能指定自定义析构器,而只能通过 new 关键字来实现:
1 | std::unique_ptr<Widget, decltype(Deleter)> upw(new Widget, Deleter); |
第二个场景是,当希望通过初始化列表作为参数,来调用被指向对象的特殊构造函数时,make 函数会将其按小括号初始化来对待,这在我们之前讨论初始化列表时,也提到过。这里再重复啰嗦一次,见下边代码:
1 | auto ptr = std::make_unique<std::vector<int>>(10, 20); |
这样一条语句,它生成的是一个指向 10 个值为 20 的 vector 的智能指针,还是指向包含 2 个元素,值分别为 10 和 20 的 vector 的智能指针?想必你也比较熟悉,答案是前者。
make 函数做不到通过这一条语句实现后者,这是因为 make 函数内部的实现,在处理对形参的完美转发时,使用的是圆括号,而不是大括号。
如果一定要使用初始化列表来初始化这个资源,可以分开来写:
1 | auto args = {10, 20}; |
第三个场景,前边提到,使用 make 函数可以让对资源内存的分配和对控制块内存的分配,合并在一起申请,从而提高性能,然而,当讨论 weak_ptr 时,这个问题变的复杂。
在控制块中,除了保存引用计数外,还保存着其他信息,比如弱引用计数(注意,weak_ptr 用引用计数来判断自己是否失效,而它构造和析构改变的是弱引用计数)。当引用计数为 0 但弱引用计数不为 0 时(shared_ptr 没有了,但 weak_ptr 还存在一些),我们认为资源应该被释放,这没问题,这里的问题是,如果使用 make 函数统一将资源和控制块分配在一块内存块上,因为弱引用计数还存在,所以控制块不能析构,导致位于同一块内存块的资源内存,也不能被尽早析构(虽然它已经没有用了)。只有当控制块的弱引用计数也为 0 后,整个控制块应该被析构时,操作系统才开始考虑对整个内存块(包括资源内存和控制块内存)进行析构操作。
如果我们的资源内存占用比较大,而 weak_ptr 长时间没有释放,那么这块内存就带来了长期驻留内存的资源占用问题。
使用 new 操作来初始化智能指针,反而可以避免这个问题。虽然这不是一个很急迫的问题,但讨论一下,也是蛮有意思的。技术权衡的正反面,只有了解细节才能在选型时找到最优解。
条款 22:使用 Pimpl 用法时要注意的地方
Pimpl 是 C++ 编程中的一种习惯用法,它全称是 Pointer to implementation。具体来说,比如我们实现一个自定义类型:
1 | // Widget.h |
通常会这么写代码。但这里有一个编译性能的问题。如果我们的 Widget.h 文件没有变,但 Object.h 文件发生了变化,那么对于引用 Widget.h 的用户代码来说,编译时,Widget.h 文件也会被重新编译一遍。当工程代码中间的此类依赖很多很复杂时,编译时间会受到很大的影响。
Pimpl 就可以解决这个问题。它的重构做法是:
- 在
Widget.h头文件中,创建一个类内定义的对象,假设名为Impl,它的实现放在Widget.cpp中。 - 再将和
Object相关的数据都放到Impl类型的实现中,而在Widget.h的类声明中,只放置一个指向Impl对象的指针。
1 | // Widget.h |
这样,在 Widget.h 中,就没有了对 Object.h 的引入,从而,修改 Object.cpp 时,引用 Widget.h 头文件的用户代码,就不会再重复编译。
使用智能指针来重新实现
在现代 C++ 中,我们希望能尽可能避免显式使用 new 和 delete 关键字,而是把资源管理交给 RAII 技术。所以很容易想到使用智能指针来替代裸指针。因为 pImpl 是被 Widget 对象独占所有权的,所以使用 unique_ptr 是顺理成章的事情。
1 | // Widget.h |
这个代码看似没问题,并且代码自身编译也没问题,但在调用 Widget 的用户代码那边,编译报错了。报错是,无法找到一个对 Object 的完整实例化。unique_ptr 作为指针,并不需要关心其指向的对象的实例化情况,所以这里看起来很奇怪。
原因是,编译器在为我们创造默认析构函数时,当看到 unique_ptr,会做一个 static_assert,检查 unique_ptr 是否能找到其指向对象的 sizeof 和 delete 运算符定义。这么做是考虑到生成更高效的析构代码。
为了强行解决这个问题,我们可以手动实现析构函数,但只需要生成默认版本的析构函数即可。析构函数放在 Widget.cpp 的末尾,这样,当编译器想要调用析构函数时,一定会先看到 Impl 的定义,也就能知道智能指针指向对象的信息了。另外,因为自定义析构函数后,编译器不会为我们生成默认移动构造函数和移动运算符函数,所以这两个函数也得手动实现一趟,当然,编译器默认生成的复制和移动版本是浅拷贝,就算不是这个原因,大概率也得手动生成一份深拷贝版本。
1 | // Widget.h |
言归正传,虽然 unique_ptr 作为独占所有权指针,用在这里再合适不可,但如果想使用 shared_ptr,就会发现,后者并没有前述的这些问题,这是因为 unique_ptr 的析构器是其类型的一部分,编译器会努力生成最优的代码来让 unique_ptr 做到裸指针的性能,而 shared_ptr 的析构器却不是类型的一部分,所以它不关心析构性能,也就没有在析构时去做完整实例化对象的检查。
智能指针是现代 C++ 中很重要的一部分内容,值得仔细了解。但事实上,编写 demo 时,即使知道裸指针和 unique_ptr 的性能相同,但大多数人还是喜欢先使用裸指针,我仔细想过,唯一的原因可能就是语法差异,语法 * 和写几个单词(auto 和 unique_ptr,make_unique)还是有差距的。
适应新技术需要一个过程,但习惯之后,便会收获便捷和安全。
本系列的其他文章:
- Effective Modern C++ 读书笔记:类型推导
- Effective Modern C++ 读书笔记:auto
- Effective Modern C++ 读书笔记:转向现代C++
- Effective Modern C++ 读书笔记:智能指针
- Effective Modern C++:右值引用、移动语义和完美转发
- Effective Modern C++:lambda 表达式
- Effective Modern C++:并发 API
- Effective Modern C++:微调
本文同步发布在知乎账号下:https://zhuanlan.zhihu.com/p/1943341648118547139













