15 指针和容器

15.1 引言

C++提供了简单的,内置的底层类型,用于保存和引用数据。其中对象和数组用于保存数据,指针则用于引用这些数据。

数组
元素
元素
元素
元素
元素
指针
指针
对象

为了以更通用的方式保存和引用数据,标准库提供了容器和迭代器。它们都支持通用算法。为了正确且高效地使用容器和迭代器,需要封装一组数据及访问和操作它们的函数。

容器
元素
元素
元素
元素
元素
迭代器

指针作为数据在内存中的地址,虽已足够通用且抽象,但借助指针表达资源的所有权却显得非常困难。为此,标准库提供了资源管理指针,即将指针封装成类,通过调用其成员函数,访问其目标对象。

资源管理指针
成员函数
指针
对象

这些由标准库提供的,针对内置数据类型的封装,在时间和空间效率上,与被封装的内置数据类型一样出色。

这些标准库类型并没有什么“魔法”,程序设计者完全可以根据自己的需要,使用与标准库相同的技术,设计和实现自己的“智能指针”和“专用容器”。

15.2 指针类型

指针更泛化的概念是,一种可以引用对象,并根据其类型访问其目标对象的对象。内置指针,如“int*”,是其中的一个例子,但指针还可以有很多其它形态。如下表所示:

广义指针说明
T*狭义指针,平凡指针,内置指针,指向T类型的对象,或T类型对象的数组
T&内置引用,引用T类型的对象,本质上是一个隐式解引用的指针
unique_ptr<T>对T类型的对象拥有独占所有权的指针,不可能有第二个unique_ptr指向该对象
shared_ptr<T>对T类型的对象拥有共享所有权的指针,可以有多个shared_ptr同时指向该对象
week_ptr<T>指向shared_ptr所拥有的对象,必须先转换为shared_ptr,才能访问其目标对象
span<T>指向T类型对象的数组
string_view指向字符串或其子串的常量指针
???_iterator<C>C容器的某种迭代器,“???”表示迭代器的具体类型

所谓一个指针拥有对某个对象的所有权,是指它不但指向这个对象,而且还要负责销毁该对象。拥有独占所有权的指针,是唯一指向对象并负责销毁该对象的指针,而拥有共享所有权的指针,则只是同时指向对象的多个指针中的一个,通常由最后一个与该对象失去联系的指针负责销毁对象。

指向/销毁
拥有独占所有权的指针
对象

指向/失联
指向/失联
指向/销毁
拥有共享所有权的指针
拥有共享所有权的指针
拥有共享所有权的指针
对象

对目标对象拥有所有权的指针,通常当其被销毁(如离开作用域)时,在析构函数中销毁目标对象,或放弃与目标对象的联系。并不是所有指针都拥有对目标对象的所有权,比如T*、span这样的指针,它们即便离开了作用域,其目标对象也不会被自动销毁,进而引发潜在的内存泄漏风险。

与指针有关的另一个问题是所谓悬空指针,即目标对象已被销毁的指针。对一个悬空指针做解引用是最令人讨厌的错误之一。这样做的结果在技术上未定义。在实践中,有可能发生的事情是意外访问到了占据被销毁对象所处内存的其它某个对象。读取该指针可能会得到本不想得到的数据,写入该指针则可能扰乱不相干的数据结构。对于这种情况,程序崩溃也许是最好的结果。这至少比产生错误的结果但程序还在带病运行要好。

C++核心准则(C++ Core Guidelines,CCG)提供了避免内存泄漏和悬空指针的指导性原则,以及通过静态检查杜绝类似缺陷的合理化建议。这里再补充一些规避指针问题的一般性方法:

15.2.1 unique_ptr和shared_ptr

对于任何程序而言,妥善地管理资源都是其最关键的任务之一。这里所说的资源,更多是指那些在需要时获取并在事后释放的东西,比如内存、锁、套接字、线程句柄、文件描述符,等等。对于长时间运行的程序,未能及时释放资源,即存在泄漏,会导致严重的性能下降,甚至崩溃。即使是短时间运行的程序,发生资源泄漏也是一件令人尴尬的事情,它可能导致资源短缺,进而拖慢运行速度达几个数量级。

标准库组件被设计成不泄漏任何资源。它们依赖于基本语言的支持,凭借成对出现的构造和析构函数,分配和释放资源,以确保资源的寿命不会超出其持有者对象的生命周期。Vector类(模板)利用构造和析构函数,管理其元素的生命周期,就是个很好的例子。所有的标准库容器都以类似的方式实现。重要的是,这种方法可以与基于异常的错误处理机制完美地融合,彻底杜绝有构造无析构的可能。例如:

局部对象lock的构造函数负责对互斥锁加锁,在加锁成功前,构造函数不会返回,调用runInThread函数的线程不会继续。无论是正常返回,还是有异常抛出,当局部对象lock离开作用域时,其析构函数总会被执行,其中包含解锁互斥锁的代码。

这是RAII(资源获取即初始化)技术的典型应用。RAII是C++针对资源管理的惯用手法。象vector、map、string这样的容器,以及iostream这样的流对象,都以类似的方式管理它们的缓冲区、文件句柄等资源。

到目前为止,所关注的对象都是在作用域内定义的对象,即所谓局部对象。这样的对象在其离开作用域时,所属类型的析构函数被隐式执行。但那些在自由存储中创建的对象则没有这么幸运。它们的析构函数必须在delete操作符的作用下,显式地被执行。一旦遗漏或跳过delete操作,该对象的析构函数就不会被执行,进而引发资源泄漏。为此,标准库在<memory>头文件中提供了两个“智能指针”类模板,确保那些位于自由存储中的对象,也能被正确地析构:

这些“智能指针”的基本用途就是防止“粗心”的程序引发资源泄漏。例如:

在这里,如果a或者b的值为0,由于在抛出异常(throw -1)和返回(return -1)之前“忘记”销毁对象(delete x),导致该对象自身在自由存储中所占用的内存得不到释放,同时该对象的析构函数(其中可能包含与释放资源有关的操作)也不会被执行。这两方面都将导致资源泄漏。

借助unique_ptr可以很好地解决这个问题。例如:

这里的x不再是一个内置指针,而是一个unique_ptr类型的局部对象。无论a和b的值是几,也无论是从return语句返回,还是从throw语句抛出异常,作为局部对象的x总会被销毁,其析构函数负责销毁new所创建的对象。该对象自身在自由存储中所占用的内存会被释放,该对象的析构函数(其中可能包含与释放资源有关的操作)也会被执行。资源泄漏被彻底杜绝。

具有讽刺意味的是,针对这里的问题,更简单的解决方案似乎应该是放弃使用new来创建对象。例如:

不幸的是,在所有现存的C++代码中,new和指针以及引用的滥用,的确是一个十分棘手的问题,而且还大有愈演愈烈的趋势。

不可否认,new和指针以及引用在某些场合下还是不可或缺的,尤其是在需要动态创建对象的场合。与其小心翼翼地使用传统的内置指针,不如换成更加安全易用的unique_ptr,毕竟这是一种几乎没有额外空间和时间开销的轻量级机制。它的进阶用途包括将在自由存储中创建的对象传入函数或从函数中返回。例如:

makeX函数返回的是一个指向动态创建于自由存储中的X对象的unique_ptr指针。该函数的调用者,通过此指针访问动态创建的对象,而无需显式地销毁该对象。unique_ptr的析构函数会执行这个操作。例如:

与vector是对象序列的句柄一样,unique_ptr也是单个对象或对象数组的句柄。两者都控制着其它对象的生命周期(使用RAII),并且都依赖于拷贝省略和转移语义,使return语句的执行简单而高效。

shared_ptr与unique_ptr很类似,区别在于shared_ptr之间是拷贝而非转移。一个对象可以被多个shared_ptr指向,它们共享对目标对象的所有权,仅当最后一个shared_ptr被销毁时,目标对象才被销毁。例如:

当程序执行到bar函数中时,共有x、y和z三个shared_ptr指针指向动态创建于自由存储中的X对象:

main
foo
bar
指向
指向
指向
x
y
z
X对象

bar函数返回时,shared_ptr类型的局部对象z被销毁,但它的析构函数并不会销毁其目标对象,因为此时尚有同为shared_ptr类型的x和y指向该对象:

main
foo
指向
指向
x
y
X对象

同样地,foo函数返回时,shared_ptr类型的局部对象y被销毁,它的析构函数也不会销毁其目标对象,因为此时尚有同为shared_ptr类型的x指向该对象:

main
指向
x
X对象

只有当main函数返回时,shared_ptr类型的局部对象x被销毁,它正是最后一个指向目标对象的shared_ptr指针,它的析构函数负责销毁该对象:

main
销毁
x
X对象

由以上过程可见,shared_ptr实际提供的是某种程度上的垃圾回收机制——动态创建于自由存储中的对象,一旦没有任何shared_ptr指针指向它,即被销毁。基于shared_ptr的垃圾回收,虽然不是毫无代价但也并非十分昂贵,但它确实使共享对象的生命周期难以预测。因此,只有在确实需要共享对目标对象的所有权时,才使用shared_ptr。

先在自由存储中创建一个对象,然后将指向它的内置指针传递给unique_ptr或者shared_ptr的构造函数,这个过程有些繁琐,而且还可能导致潜在的错误,比如忘记将内置指针传递给unique_ptr或者shared_ptr的构造函数,或者所传递的内置指针并不指向在自由存储中创建的对象。为了规避此类问题,标准库在<memory>头文件中提供了直接创建对象并返回unique_ptr或者shared_ptr指针的函数,make_unique和make_shared。例如:

传递给make_unique和make_shared函数的参数,就是传递给所创建对象构造函数的参数。

相比于单独使用new创建对象,再将其地址传递给shared_ptr而言,使用make_shared函数不仅更方便,而且效率也更高,因为它不需要单独分配引用计数。引用计数在shared_ptr的实现中非常重要。

有了unique_ptr和shared_ptr这样的“智能指针”,甚至可以为许多程序实施彻底的“禁止直接new”规则。然而,这些“智能指针”在概念上仍然是指针,因此,它们在资源管理策略方面,只能作为一个备选方案,第一选择仍然是容器,以及其它在更高概念层次上能够胜任资源管理工作的类型。特别是,shared_ptr本身并没有为其所有者提供任何用于读写共享对象的规则。数据竞争和其它形式的混淆,并不能简单地通过消除资源管理问题而获得解决。

选择unique_ptr或shared_ptr这样的“智能指针”,而不使用资源句柄,或其它专门为解决资源问题而设计的基础设施,如vector、thread等,其中很重要的一个原因,就是前者提供了明确的指针语义,即可以被当做指针看待:

如果只是想从函数中返回一个对象的集合,其实并不需要使用指针。作为资源句柄的容器,凭借拷贝省略和转移语义,可以简单而高效地完成这个任务。

C++11:资源管理指针(unique_ptr和shared_ptr)

15.2.2 span

在传统代码中,范围错误,一直是导致C和C++程序发生严重错误的主要原因之一。它会导致结果不正确、进程崩溃,甚至安全问题。借助容器、算法和基于范围的for循环,可以显著减少这类问题,而且还可以做更多的事情。范围错误的一个主要来源,是人们习惯于向函数传递平凡或“智能指针”形式的参数,同时依靠额外的约束来指定可访问区域的边界。在除资源句柄以外的代码中使用指针,其目标对象的数量最好不要超过一个。但如果没有额外的支持,这条建议通常很难做到。标准库的string_view可以提供帮助,但它是只读的,而且仅适用于字符序列。程序员们通常会要求更多,例如在底层代码中读写缓冲区时,既要保证高性能,同时还要避免缓冲区溢出等范围错误,是出了名的困难。头文件<span>中的span本质上是用一个指针和一个长度的组合来表示元素序列:

span
序列
begin
size
指针
长度
元素
元素
元素
元素
元素

span仅用于内存连续的元素序列,如内置数组和动态数组(vector)等。和指针一样,span并不拥有其可访问序列的所有权。这方面与string_view和起止迭代器对非常相像。

下面是一种非常常见的接口风格:

这里假设指针p指向由n个整型元素组成的序列。问题是,这个假设真的成立吗?

接受span型参数的foo函数类似下面这样:

而对该函数的调用:

直接从数组创建span,编译器会自动计算数组的长度,即其中元素的个数。这是最安全,也最简洁的写法。其它情况下,发生错误的概率降低了,错误检测也变得更加容易,因为代码设计者必须显式地构造span对象。

一般而言,span形式的函数接口,比指针加长度形式的函数接口更简单,且无需额外地检查。例如:

与容器的情形类似,对span的下标操作,如“p[i]”,也不会执行范围检查,因此下标越界的结果将是未定义的。

C++20:用于读写连续数组的span

15.3 容器

标准库(注意不是标准模板库)提供了几个不完全适配STL框架的容器,如内置数组、array、string等。它们虽然也能储存元素,也是容器,但相对于其它STL容器,使用时存在更多限制,或被添加了额外的设施,这使得它们在STL上下文中显得笨拙,将它们独立出来有助于简化对STL的描述。这些容器有时也被称为准容器,如下表所示:

容器说明
T[N]内置数组,大小固定,包含N个内存连续的T类型元素,会被隐式转换为T*
array<T,N>类似于内置数组,大小固定,包含N个内存连续的T类型元素,解决了内置数组的多数问题
bitset<N>大小固定的N比特序列
vector<bool>一种特殊的动态数组,可被视为一个大小不固定的比特序列
pair<U,V>包含两个类型分别U和V的元素
tuple<T...>包含任意数量个任意类型的元素
basic_string<C>由C类型的字符构成的字符串,提供一系列针对字符串的操作
valarray<T>由T类型的数字构成的数组,支持数学运算

为何标准库会提供如此众多的容器?它们各自满足于常见的不同需求,这些需求有时会发生重叠。就算标准库不提供,程序员们也会自己实现一套。例如:

所有这些容器都可被看作是,标准库为满足大型程序员社区的专业需求,而提供的定制化服务。只靠一个容器不可能满足他们的所有需求,特别是这些需求本身就存在着诸多矛盾,如“允许动态增加内存”和“保证分配在固定位置”间的矛盾,“添加新元素时原有元素不能移动”和“保证内存连续”间的矛盾等。

15.3.1 array

在头文件<array>中定义的array容器,在一段连续内存中保存指定类型的元素。元素的数量在编译时指定。因此,可以在堆栈、对象或静态存储中分配array及其元素。元素被分配的作用域与array被定义的作用域一致。最好将array直接理解为内置数组,其大小固定不变,但与内置数组不同,它没有隐式的、可能令人惊讶的指针类型转换,而且还提供了一系列方便元素访问与操作的函数和算法。与使用内置数组相比,使用array没有增加任何额外的时间和空间开销。array并不遵循在STL容器中常见的句柄模型,它直接包含元素序列本身,而非指向元素序列的指针。array可被视为内置数组的安全版本。

array可以而且必须通过初始化列表初始化。例如:

初始化列表中初值个数必须小于或等于为array指定的元素个数。

array的元素个数不是可选的,而必须是显式给定的常量表达式,其类型必须为整型,其值必须为正。array的元素类型必须明确指定。下面的代码展示了上述限制:

如果一定要通过变量指定容器中的元素个数,可以使用vector。例如:

必要时,可以将array传递给带有指针型参数的C风格函数,但需要做必要的转换。例如:

既然已经有了vector,为什么还要提供array呢?相比于vector,array通过牺牲灵活性,换取了更好的性能,因为它更简单。在栈中创建的array,其元素也在栈中,而在栈中创建的vector,其元素却位于自由存储中。直接访问栈中的元素,显然比通过句柄间接访问自由存储中的元素,性能更佳,而且位于自由存储中的内存资源,需要在运行时动态地分配和释放,这本身也会带来性能损失。但是,作为一种相对稀缺的资源,尤其在一些嵌入式系统中,栈空间非常宝贵,且一旦发生溢出,后果往往是致命的,这时使用vector可能是更好的选择。而在另一些应用领域,比如存在致命安全需求的实时控制系统中,为了避免free或delete操作可能引发的内存碎片化,甚至内存耗尽,在自由存储中分配内存可能会被明令禁止,这时使用array显然更为合适。

既然已经有了内置数组,为什么还要提供array呢?array知道它的大小,因此更容易结合标准库算法一起使用,而且可以通过“=”操作符直接拷贝。例如:

相对于内置数组,array最大的优势是它杜绝了令人惊讶和讨厌的隐式指针转换。例如:

假设sizeof(Shape) < sizeof(Circle):

C++11:固定大小的连续序列容器array

15.3.2 bitset

系统中的很多属性,比如I/O流的状态等,通常被表示为一组二进制位的组合,访问其中某个特定的二进制位,是一种很常见的操作。解决这类问题的传统方法就是位运算,用位与判断某个特定位是0还是1,用位与或者位或将某个特定位清0或者置1,用自异或实现翻转。bitset<N>类通过对N比特的二进制序列[0,N)的操作表达此概念,其中N是事先提供给编译器的二进制位数。整型的最大类型是long long,最多只能表示64比特,但如果需要表示更多的比特位,则可以借助数组或结构体,而使用bitset会方便得多。即便用不到64比特,使用bitset通常也会获得比整型更好的性能。

bitset可以用字符串或整数初始化。例如:

bitset支持常用的位运算和移位操作。例如:

移位操作符(此处为“>>”)移入0。

to_string、to_ulong和to_ullong都是bitset的成员函数,可将其转换为string、unsigned long和unsigned long long类型的字符串和整数。例如:

to_string得到的字符串,从右至左,权重依次升高。

bitset对象也可以直接输出。例如:

bitset还提供了许多用于访问比特集合的函数,例如:all、any、none、count、flip,等等。

15.3.3 pair

从一个函数返回两个值的情况非常多见。有很多方法可以做到这一点,其中最简单也是最好的方法是专门定义一个结构,其中包含一个需要返回给调用者的处理结果,和一个表示错误的错误代码或成功失败标志。例如:

对于规模不大的程序,这种从一个函数返回两个值的方法很好,而且非常可读。但如果这样的函数很多,就必须得定义许多这样的结构,这会导致名称和约定的激增。特别是对于那些需要一致命名的泛型代码而言,这种做法的效果通常会很差。为此,标准库提供了pair,作为对“专门用于保存一对值的结构”用例的泛型支持。例如:

pair的成员被命名为first和second。从实现者的角度看,这是有道理的,但在应用程序代码中,代之以有意义的名称,无疑会更加可读。借助结构化绑定,可以做到这一点。例如:

标准库的pair定义在<utility>头文件中。利用pair返回一对值的做法,在标准库中屡见不鲜。例如在有序序列中,搜索匹配于某个给定值的元素范围的equal_range算法函数:

对于有序序列[first:last),equal_range函数返回由两个ForwardIterator组成的pair,第一个ForwardIterator指向与val匹配的元素范围中的第一个元素,第二个ForwardIterator指向该范围最后一个元素的下一个位置,cmp是用于在元素间做小于比较的比较器对象。使用该函数的代码,类似下面这个样子:

pair支持“=”、“==”和“<”等操作,前提是其内部元素的类型得支持相应的操作。借助类型推导,可以在不显式写出其内部元素具体类型的前提下,轻松创建pair类型的对象。例如:

以上代码中的p和q都是pair<vector<string>::iterator,int>类型的对象。

在不需要泛型的场合,没有必要使用pair,相反,具有命名成员的简单结构,会令代码更易于维护。

15.3.4 tuple

与数组一样,所有STL容器都只能保存类型相同的元素。但有时,确实需要将不同类型的数据组合成一个对象。这种容器称为异构容器。pair可以算作一种异构容器,但它只能包含两个元素。为此,标准库提供了tuple,可容纳数量不限的异构元素。例如:

一个元素(成员)都没有的tuple,即所谓空tuple,在内存中也要占一个字节。例如:

tuple中的元素(成员)是独立的,彼此间没有任何约束,除非将其封装在强制执行它的类中。

对于单一的特定用途,简单的struct通常更适用。但在很多泛型编程场合,tuple的灵活性可以避免以没有成员的助记名称为代价定义许多struct。可以通过get函数访问tuple中的元素(成员)。例如:

tuple中的元素(成员)从0开始编号,get函数的索引参数必须是常量。get函数是一个模板函数,带有一个数值型模板参数,即元素索引。

通过索引访问tuple中的元素(成员)是泛型的,不够优雅且易出错。幸运的是,tuple中具有唯一类型的元素(成员)可以通过其类型获得。例如:

get函数返回的是对tuple中元素(成员)的引用,可以借此修改tuple的内容。例如:

tuple的多数应用隐藏在更高级别架构的实现中。例如,借助结构化绑定,访问tuple中的元素(成员):

这种用法在处理函数的多个返回值时非常有效。例如:

tuple的优势在于,可以方便地存储或传递,未知数量个未知类型的数据。

借助递归,可以遍历任意tuple中的所有元素。例如:

“sizeof...(Ts)”给出变长类型参数表中的参数个数。

使用print函数则更简单直接。例如:

与pair一样,tuple也支持“=”、“==”和“<”等操作,前提是其内部元素的类型得支持相应的操作。pair和只有两个元素(成员)的tuple可以互换使用。

C++11:元组(tuple)

C++14:按类型寻址元组

15.4 可变类型容器

标准库提供了三种用于表示可变类型容器的数据类型,如下表所示:

可变类型容器说明
union内置类型,存储指定类型集的对象
variant<T...>定义在<variant>头文件中,存储指定类型集的对象
optional<T>定义在<optional>头文件中,存储T类型的对象或空值
any定义在<any>头文件中,存储任意类型对象

15.4.1 variant

与显式使用union相比,使用variant<T...>通常会更安全,也更方便。比如一个函数的返回值可能是处理结果,也可能是错误对象:

当赋值或初始化一个variant类型的变量时,它会记住其中值的类型,并根据类型获取相应的值。例如:

这种处理函数返回值的方式,对于那些不喜欢异常的开发者颇具吸引力。另外,将函数的参数声明为variant类型,可以用一个函数统一处理不同类型的数据,而不必为每一种类型定义一个独立的函数。例如:

这种根据对象的类型决定适当行为的处理模式非常普遍,但效率相对较低,更好的方式是借助visit函数,它充分利用了C++语言根据参数类型匹配重载版本的语法特性。例如:

visit函数会根据variant对象(*node)的实际类型,调用可调用对象(Overloaded)中适当版本的函数操作符函数(operator())。这使程序设计者可以一种类型安全且易于扩展的方式处理variant中存储的不同类型的数据。

任何以与当前存储类型不同的类型访问variant的尝试,都将引发bad_variant_access异常。

C++17:variant

15.4.2 optional

optional<T>从缔造者的角度看,相当于一个variant<T,nothing>。例如:

而从使用者的角度看,一个optional<T>类型的对象则相当于一个T*,要么指向一个真实存在的T类型对象,要么就是一个什么也不指向的空指针(nullptr)。例如:

optional类型对于那些可能返回也可能不返回对象的函数非常有用。注意这里“*”的奇怪用法,optional被视为指向对象的指针,而非对象本身。

对optional而言,扮演空指针(nullptr)的值是空对象({}),必要的非空检测不可或缺。例如:

不做非空检测,就贸然访问optional类型的对象,结果将是未定义的,且不会抛出异常。例如:

C++17:optional

15.4.3 any

any可以包含任意类型的值,并且知道它包含的是哪种类型(如果有值的话),可将其视作variant的无约束版本。例如:

当赋值或初始化一个any类型的变量时,它会记住其中值的类型,并根据类型获取相应的值。例如:

如果试图以与所存储类型不同的类型访问any,将导致bad_any_access异常被抛出。

C++17:any

15.5 建议