C++服务器开发精髓
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

1.11 stl中的智能指针类详解

C/C++最为人诟病的是内存泄露问题,后来的大多数语言都内置了内存分配与释放功能,有的甚至对语言的使用者屏蔽了内存指针这一概念。这里对此不置褒贬,手动分配与释放内存有利有弊,自动分配与释放内存亦如此,这是两种不同的设计哲学。有人认为,内存如此重要,怎能放心将其交给用户去管理呢?另外一些人则认为,内存如此重要,怎能放心将其交给系统去管理呢?在C/C++中,内存泄露的问题一直困扰着广大开发者,因此各类库和工具也一直在努力尝试各种方法去检测和避免内存泄露,例如 boost,因此智能指针技术应运而生。

1.11.1 C++98/03的尝试——std::auto_ptr

现在讨论std::auto_ptr不免让人怀疑是不是有点过时了,确实如此,C++11标准废弃了std::auto_ptr(在C++17标准中被移除),取而代之的是std::unique_ptr。这里之所以介绍std::auto_ptr的用法及它在设计上的不足之处,是想让读者了解C++中智能指针的发展历程。我们在了解一项技术过去的样子和发展轨迹后,就能更好地掌握它。

std::auto_ptr的基本用法如下:

智能指针对象 sp1 和 sp2 均持有一个在堆上分配的 int 对象,值都是 8,这两块堆内存都在sp1和sp2释放时得到释放。这是std::auto_ptr的基本用法。

sp是smart pointer(智能指针)的简写。

std::auto_ptr容易让人误用的地方是其不常用的复制语义,即当复制一个std::auto_ptr对象时(拷贝复制或 operator=复制),原 std::auto_ptr 对象所持有的堆内存对象也会被转移给复制出来的新std::auto_ptr对象。示例代码如下:

在以上代码中分别利用了拷贝构造(sp1=>sp2)和赋值构造(sp3=>sp4)来创建新的std::auto_ptr对象,因此sp1持有的堆对象被转移给sp2,sp3持有的堆对象被转移给sp4。示意图如下。

程序执行结果如下:

因为 std::auto_ptr 是不常用的复制语义,所以我们应该避免在 stl 容器中使用std::auto_ptr,例如不应该写出如下代码:

当用算法对容器进行操作时(如最常见的容器元素遍历),很难避免不对容器中的元素进行赋值传递,这样便会使容器中的多个元素被置为空指针,这不是我们希望看到的,可能会造成一些意想不到的错误。

作为std::auto_ptr的替代者,std::unique_ptr吸取了这个教训,下文会详细介绍。

正因为std::auto_ptr的设计存在缺陷,所以C++11标准充分借鉴和吸收了boost库中智能指针的设计思想,引入了三种新类型的智能指针,即 std::unique_ptr、std::shared_ptr和std::weak_ptr。

C++11没有全部照搬boost智能指针类型,而是选择了其中三个最实用的类型。boost还有 scoped_ptr,在 C++11中可以通过 std::unique_ptr达到与 boost::scoped_ptr一样的效果。

所有智能指针类(包括std::unique_ptr)均被定义于头文件<memory>中。

在C++11及后续语言规范中,std::auto_ptr已被废弃,在我们的代码中不应再使用它。

1.11.2 std::unique_ptr

std::unique_ptr 对其持有的堆内存具有唯一拥有权,也就是说该智能指针对资源(即其管理的堆内存)的引用计数永远是 1,std::unique_ptr 对象在销毁时会释放其持有的堆内存。可以采用以下方式初始化一个std::unique_ptr对象:

我们应该尽量采用初始化方式 3去创建一个 std::unique_ptr,而不是采用初始化方式1和2,因为初始化方式3更安全。

让很多人对C++11规范吐槽的地方之一是,C++11新增了std::make_shared方法创建一个 std::shared_ptr 对象,却没有提供相应的 std::make_unique 方法创建一个std::unique_ptr对象,该方法直到C++14时才被添加进来。当然,在C++11中很容易实现一个这样的方法:

鉴于 std::auto_ptr 的前车之鉴,std::unique_ptr 禁止复制语义,为了达到这个效果,std::unique_ptr类的拷贝构造函数和赋值运算符(operator=)均被标记为=delete。

因此,以下代码是无法通过编译的:

不过禁止复制语义也存在特例,例如可以通过一个函数返回一个std::unique_ptr:

以上代码从func函数中得到一个std::unique_ptr对象,然后返回给sp1。

既然 std::unique_ptr 不能被复制,那么如何将一个 std::unique_ptr 对象持有的堆内存转移给另外一个呢?答案是使用移动构造,示例代码如下:

以上代码利用了std::move将sp1持有的堆内存(值为123)转移给sp2,再将sp2转移给 sp3。最后,sp1 和 sp2 不再持有堆内存的引用,变成一个空的智能指针对象。并不是所有对象的std::move操作都有意义,只有实现了移动构造函数(Move Constructor)或移动赋值运算符(operator=)的类才行,而std::unique_ptr正好实现了二者。以下是该实现的伪代码:

这就是std::unique_ptr具有移动语义的原因。

std::unique_ptr不仅可以持有一个堆对象,也可以持有一组堆对象,示例如下:

程序执行结果如下:

std::shared_ptr和std::weak_ptr也可以持有一组堆对象,用法与 std::unique_ptr相同,下文不再赘述。

在默认情况下,智能指针对象在析构时只会释放其持有的堆内存(调用 delete或者delete[]),但是假设这块堆内存代表的对象还对应一种需要回收的资源(如操作系统的套接字句柄、文件句柄等),我们则可以通过给智能指针自定义资源回收函数来实现资源回收。假设现在有一个Socket类,对应操作系统的套接字句柄,在回收时需要关闭该对象,我们则可以这样自定义智能指针对象的资源释放函数,以std::unique_ptr为例:

自定义std::unique_ptr的资源释放函数的语法规则如下:

其中,T 是我们要释放的对象类型,DeletorFuncPtr 是一个自定义函数指针。以上加粗代码行表示DeletorFuncPtr的写法有点复杂,我们可以使用decltype(deletor)让编译器自己推导deletor的类型,因此可以将加粗代码行修改如下:

1.11.3 std::shared_ptr

std::unique_ptr对其持有的资源具有独占性,而 std::shared_ptr持有的资源可以在多个std::shared_ptr之间共享,每多一个std::shared_ptr对资源的引用,资源引用计数就会增加1,在每一个指向该资源的std::shared_ptr对象析构时,资源引用计数都会减少1,最后一个 std::shared_ptr 对象析构时,若发现资源计数为 0,则将释放其持有的资源。多个线程之间递增和减少资源的引用计数都是安全的(注意:这不意味着多个线程同时操作std::shared_ptr管理的资源是安全的)。std::shared_ptr提供了一个use_count方法来获取当前管理的资源的引用计数。除了上面描述的内容,std::shared_ptr的用法和std::unique_ptr基本相同。

下面是一个初始化std::shared_ptr的示例:

和std::unique_ptr一样,我们应该优先使用std::make_shared初始化一个 std::shared_ptr对象。

再来看另外一段代码:

整个程序的执行结果如下:

1.11.4 std::enable_shared_from_this

在实际开发中有时需要在类中返回包裹当前对象(this)的一个std::shared_ptr对象给外部使用,C++新标准也为我们考虑到了这一点,有如此需求的类只要继承自std::enable_shared_from_this<T>模板对象即可。用法如下:

在以上代码中,类A继承自std::enable_shared_from_this<A>并提供了一个getSelf方法返回自身的std::shared_ptr对象,在getSelf方法中调用了shared_from_this方法。

std::enable_shared_from_this使用起来比较方便,但也存在很多注意事项。

注意事项一:不应该共享栈对象的this指针给智能指针对象。

假设我们将上面代码中main函数的第1行生成A对象的方式改成一个栈变量:

则运行修改后的代码,会发现程序在“std::shared_ptr<A> sp2=a.getSelf();”处崩溃。这是因为,智能指针管理的是堆对象,栈对象会在函数调用结束后自行销毁,因此不能通过shared_from_this()将该对象交由智能指针对象管理。切记:智能指针最初设计的目的就是管理堆对象。

注意事项二:std::enable_shared_from_this的循环引用问题

再来看另外一段代码:

乍一看上面的代码好像没有问题,让我们实际运行一下看看输出结果:

我们会发现,在程序的整个生命周期内,只有 A类构造函数的调用输出,没有 A类析构函数的调用输出,这意味着新建的A对象产生内存泄漏了!

我们来分析一下新建的A对象为什么得不到释放。程序在执行完加粗代码行后,spa出了其作用域准备析构,在析构时其发现仍然有另一个std::shared_ptr对象即A::m_SelfPtr引用了A,因此spa只会将对A的引用计数递减为1,然后销毁自身。现在很矛盾:必须销毁 A 才能销毁其成员变量 m_SelfPtr,而销毁 A 前必须先销毁 m_SelfPtr。这就是 std::enable_shared_from_this 的循环引用问题。我们在实际开发中应该避免做出这样的逻辑设计,在这种情形下即使使用了智能指针也会造成内存泄漏。也就是说,一个资源的生命周期可以交给一个智能指针对象来管理,但是该智能指针的生命周期不可以再交给该资源来管理。

1.11.5 std::weak_ptr

std::weak_ptr是一个不控制资源生命周期的智能指针,是对对象的一种弱引用,只提供了对其管理的资源的一个访问手段,引入它的目的是协助std::shared_ptr工作。

std::weak_ptr 可以从一个 std::shared_ptr 或另一个 std::weak_ptr 对象构造,std::shared_ptr可以直接赋值给std::weak_ptr,也可以通过std::weak_ptr的lock函数来获得std::shared_ptr。它的构造和析构不会引起引用计数的增加或减少。std::weak_ptr 可用来解决 std::shared_ptr 相互引用时的死锁问题,即两个 std::shared_ptr 相互引用,这两个智能指针的资源引用计数就永远不可能减少为0,资源永远不会被释放。

示例代码如下:

程序执行结果如下:

无论通过何种方式创建std::weak_ptr,都不会增加资源的引用计数,因此每次输出的sp1的引用计数值都是1。

既然std::weak_ptr不管理所引用资源的生命周期,其引用的资源就可能在某个时刻失效。我们需要使用std::weak_ptr引用该资源时,如何得知该资源是否有效呢?std::weak_ptr提供了一个 expired 方法来做这项检测,该方法返回 true,说明其引用的资源已失效;返回 false,说明该资源仍然有效,这时可以使用 std::weak_ptr 的 lock 方法得到一个std::shared_ptr对象后继续操作资源。以下代码演示了该用法:

有读者可能对以上代码产生疑问,既然使用了std::weak_ptr的expired方法判断了对象是否有效,那么为什么不直接使用std::weak_ptr对象对引用资源进行操作呢?这实际上是行不通的,std::weak_ptr 类没有重写 operator->和 operator*方法,因此不能像std::shared_ptr 或 std::unique_ptr 一样直接操作对象,std::weak_ptr 类也没有重写 operator bool()操作,因此不能通过std::weak_ptr对象直接判断其引用的资源是否存在:

在多线程场景下,即使刚刚使用了std::weak_ptr的expired方法判断其引用的资源是否有效,但若不加一些多线程保护策略,却接着调用 std::weak_ptr 的 lock 方法尝试得到一个std::shared_ptr对象去操作资源,则仍然可能存在安全隐患。其原因是,在调用lock方法期间,引用的资源可能恰好被销毁,这可能会造成比较棘手的问题。示例代码如下:

在以上代码中,m_tmpConn 是 TcpSession 的成员变量,其类型是 std::weak_ptr<TcpConnection>。由于两个线程同时操作 m_tmpConn,所以即使线程 1 在希望使用m_tmpConn引用的资源时,先用 expired方法判断对应的 TcpConnection是否有效,再用lock 方法尝试得到一个 std::shared_ptr 对象,但如果在 lock 方法调用前,线程 2 释放了m_tmpConn引用的资源,那么线程1接下来的逻辑仍然存在安全隐患。在多线程场景下,这是一种常见的错误,需要避免。

因此,std::weak_ptr的正确使用场景是引用的资源如果可用就使用,不可用就不使用,不参与资源的生命周期管理。例如在网络分层结构中,Session 对象(会话对象)利用Connection对象(连接对象)提供的服务进行工作,但是 Session 对象不管理 Connection对象的生命周期,Session管理Connection的生命周期是不合理的,因为网络底层出错时会导致Connection对象被销毁,此时Session对象如果强行持有Connection对象,则与事实矛盾。

std::weak_ptr应用场景中的经典例子是订阅者模式或者观察者模式。这里以订阅者为例来说明,消息发布器只有在某个订阅者存在的情况下才会向其发布消息,不能管理订阅者的生命周期。

1.11.6 智能指针对象的大小

一个std::unique_ptr对象的大小与裸指针的大小相同(即sizeof(std::unique_ptr<T>)==sizeof(void*)),而 std::shared_ptr 的大小是 std::unique_ptr 的两倍。以下是分别在 Visual Studio 2019和gcc/g++4.8上(将二者都编译成x64程序)测试的结果。

测试代码如下:

Visual Studio 2019的运行结果如下图所示。

gcc/g++的运行结果如下图所示。

在 32位机器上,std::unique_ptr占 4字节,std::shared_ptr和 std::weak_ptr均占 8字节;在64位机器上,std_unique_ptr占8字节,std::shared_ptr和std::weak_ptr均占16字节。也就是说,std_unique_ptr 的大小总是和原始指针的大小一样,std::shared_ptr 和std::weak_ptr的大小是原始指针的大小的两倍。

1.11.7 使用智能指针时的注意事项

C++新标准提倡的理念之一是不再手动调用delete或者free函数去释放堆内存,而是把它们交给新标准提供的各种智能指针对象。C++新标准中的各种智能指针是如此实用与强大,在现在的 C++项目开发中,我们应该尽量使用它们。智能指针虽然好用,但稍不注意,也可能存在许多难以发现的bug,下面总结了一些实用经验。

1.一旦使用了智能指针管理一个对象,就不该再使用原始裸指针去操作它

看一段代码:

这段代码创建了一个堆对象 pSubscriber,然后利用智能指针spSubscriber去管理它,私下却利用原始指针pSubscriber销毁了该对象,这让智能指针对象spSubscriber“情何以堪”!

注意,一旦智能指针对象接管了我们的资源,对资源的所有操作就都应该通过智能指针对象进行,不建议再通过原始指针进行。当然,除了 std::weak_ptr,std::unique_ptr 和std::shared_ptr都提供了用于获取原始指针的get函数。

2.知道在哪些场合使用哪种类型的智能指针

在通常情况下,如果我们的资源不需要在其他地方共享,就应该优先使用std::unique_ptr,反之使用 std::shared_ptr。当然,这是在该智能指针需要管理资源的生命周期的情况下进行的;如果不需要管理对象的生命周期,则请使用std::weak_ptr。

3.认真考虑,避免操作某个引用资源已经释放的智能指针

通过前面的例子,我们很容易知道一个智能指针持有的资源是否有效,但还是建议在不同的场景下谨慎一些,因为在某些场景下很容易误判。例如下面的代码:

在以上代码中,sp2 是 sp1 的引用,sp1 被置空后,sp2 也一同为空。这时调用sp2->doSomething(),sp2->(即operator->)在内部会调用get方法获取原始指针对象,这时得到一个空指针(地址为0),继续调用doSomething()会导致程序崩溃。

有些读者可能觉得以上代码片段存在的问题是显而易见的,让我们把这个例子放到实际项目中再看一下:

以上代码来自实际的商业项目,其崩溃的原因是调用了 conn->peerAddress 方法。为什么这个方法的调用可能会引发程序崩溃呢?现在还可以显而易见地看出问题吗?

其崩溃的原因是传入的 conn 对象和上一个例子中的 sp2 一样都是另一个std::shared_ptr 的引用,当连接断开时,对应的 TcpConnection 对象可能早已被销毁,而conn 引用会变成空指针(严格来说,是不再持有一个 TcpConnection 对象的引用),此时调用TcpConnection的peerAddress方法就会产生和上一个示例一样的错误。

4.作为类成员变量,应该优先使用前置声明(forward declarations)

我们知道,为了减少编译依赖、加快编译速度和减少生成的二进制文件的大小,C/C++项目一般在*.h文件中对指针类型尽量使用前置声明,而不是直接包含对应类的头文件。例如:

同样的道理,在头文件中使用智能指针对象作为类成员变量时,也应该优先使用前置声明去引用智能指针对象的包裹类,而不是直接包含包裹类的头文件。

Modern C/C++已经成为 C/C++的开发趋势,建议读者善用和熟练使用本节介绍的后三种智能指针对象。