C++提供了简单的,内置的底层类型,用于保存和引用数据。其中对象和数组用于保存数据,指针则用于引用这些数据。
为了以更通用的方式保存和引用数据,标准库提供了容器和迭代器。它们都支持通用算法。为了正确且高效地使用容器和迭代器,需要封装一组数据及访问和操作它们的函数。
指针作为数据在内存中的地址,虽已足够通用且抽象,但借助指针表达资源的所有权却显得非常困难。为此,标准库提供了资源管理指针,即将指针封装成类,通过调用其成员函数,访问其目标对象。
这些由标准库提供的,针对内置数据类型的封装,在时间和空间效率上,与被封装的内置数据类型一样出色。
这些标准库类型并没有什么“魔法”,程序设计者完全可以根据自己的需要,使用与标准库相同的技术,设计和实现自己的“智能指针”和“专用容器”。
指针更泛化的概念是,一种可以引用对象,并根据其类型访问其目标对象的对象。内置指针,如“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)提供了避免内存泄漏和悬空指针的指导性原则,以及通过静态检查杜绝类似缺陷的合理化建议。这里再补充一些规避指针问题的一般性方法:
在对象离开作用域后,不要保留指向该对象的指针。具体而言就是绝不从函数返回指向本地对象的指针,或将来源不确定的指针,比如通过函数的参数传入的指针,保存在长期存在的数据结构中。系统性地使用容器和算法通常可以避免直接使用特定的编程技术,那些编程技术一旦使用不当,常常会导致这样或那样的指针问题
借助对目标对象拥有所有权的指针(unique_ptr或shared_ptr)访问在自由存储中动态创建的对象
指向静态对象(全局变量、静态局部变量、静态成员变量)的指针不可能悬空
尽可能将有关指针的算术运算交给资源句柄(如vector、unordered_map等)来处理
时刻牢记,象string_view、span这样的指针,都属于对目标对象不拥有所有权的指针
对于任何程序而言,妥善地管理资源都是其最关键的任务之一。这里所说的资源,更多是指那些在需要时获取并在事后释放的东西,比如内存、锁、套接字、线程句柄、文件描述符,等等。对于长时间运行的程序,未能及时释放资源,即存在泄漏,会导致严重的性能下降,甚至崩溃。即使是短时间运行的程序,发生资源泄漏也是一件令人尴尬的事情,它可能导致资源短缺,进而拖慢运行速度达几个数量级。
标准库组件被设计成不泄漏任何资源。它们依赖于基本语言的支持,凭借成对出现的构造和析构函数,分配和释放资源,以确保资源的寿命不会超出其持有者对象的生命周期。Vector类(模板)利用构造和析构函数,管理其元素的生命周期,就是个很好的例子。所有的标准库容器都以类似的方式实现。重要的是,这种方法可以与基于异常的错误处理机制完美地融合,彻底杜绝有构造无析构的可能。例如:
xxxxxxxxxx
61mutex m; // 用于保护对共享数据的访问
2...
3void runInThread() {
4 scoped_lock lock{ m }; // 加锁互斥锁
5 ... 可能抛出异常的代码 ...
6} // 解锁互斥锁
局部对象lock的构造函数负责对互斥锁加锁,在加锁成功前,构造函数不会返回,调用runInThread函数的线程不会继续。无论是正常返回,还是有异常抛出,当局部对象lock离开作用域时,其析构函数总会被执行,其中包含解锁互斥锁的代码。
这是RAII(资源获取即初始化)技术的典型应用。RAII是C++针对资源管理的惯用手法。象vector、map、string这样的容器,以及iostream这样的流对象,都以类似的方式管理它们的缓冲区、文件句柄等资源。
到目前为止,所关注的对象都是在作用域内定义的对象,即所谓局部对象。这样的对象在其离开作用域时,所属类型的析构函数被隐式执行。但那些在自由存储中创建的对象则没有这么幸运。它们的析构函数必须在delete操作符的作用下,显式地被执行。一旦遗漏或跳过delete操作,该对象的析构函数就不会被执行,进而引发资源泄漏。为此,标准库在<memory>头文件中提供了两个“智能指针”类模板,确保那些位于自由存储中的对象,也能被正确地析构:
unique_ptr代表唯一的所有权,其析构函数负责销毁目标对象
shared_ptr代表共享的所有权,最后一个指针的析构函数负责销毁目标对象
这些“智能指针”的基本用途就是防止“粗心”的程序引发资源泄漏。例如:
xxxxxxxxxx
151int foo(int a, int b) {
2 X* x{ new X };
3
4 if (!a)
5 throw -1;
6
7 if (!b)
8 return -1;
9
10 ... 使用X对象 ...
11
12 delete x;
13
14 return 0;
15}
在这里,如果a或者b的值为0,由于在抛出异常(throw -1)和返回(return -1)之前“忘记”销毁对象(delete x),导致该对象自身在自由存储中所占用的内存得不到释放,同时该对象的析构函数(其中可能包含与释放资源有关的操作)也不会被执行。这两方面都将导致资源泄漏。
借助unique_ptr可以很好地解决这个问题。例如:
xxxxxxxxxx
131int bar(int a, int b) {
2 unique_ptr<X> x{ new X };
3
4 if (!a)
5 throw - 1;
6
7 if (!b)
8 return -1;
9
10 ... 使用X对象 ...
11
12 return 0;
13}
这里的x不再是一个内置指针,而是一个unique_ptr类型的局部对象。无论a和b的值是几,也无论是从return语句返回,还是从throw语句抛出异常,作为局部对象的x总会被销毁,其析构函数负责销毁new所创建的对象。该对象自身在自由存储中所占用的内存会被释放,该对象的析构函数(其中可能包含与释放资源有关的操作)也会被执行。资源泄漏被彻底杜绝。
具有讽刺意味的是,针对这里的问题,更简单的解决方案似乎应该是放弃使用new来创建对象。例如:
xxxxxxxxxx
131int hum(int a, int b) {
2 X x;
3
4 if (!a)
5 throw - 1;
6
7 if (!b)
8 return -1;
9
10 ... 使用X对象 ...
11
12 return 0;
13}
不幸的是,在所有现存的C++代码中,new和指针以及引用的滥用,的确是一个十分棘手的问题,而且还大有愈演愈烈的趋势。
不可否认,new和指针以及引用在某些场合下还是不可或缺的,尤其是在需要动态创建对象的场合。与其小心翼翼地使用传统的内置指针,不如换成更加安全易用的unique_ptr,毕竟这是一种几乎没有额外空间和时间开销的轻量级机制。它的进阶用途包括将在自由存储中创建的对象传入函数或从函数中返回。例如:
xxxxxxxxxx
41auto makeX() {
2 // 在自由存储中动态创建X对象,并返回指向该对象的unique_ptr指针
3 return unique_ptr<X>{ new X };
4}
makeX函数返回的是一个指向动态创建于自由存储中的X对象的unique_ptr指针。该函数的调用者,通过此指针访问动态创建的对象,而无需显式地销毁该对象。unique_ptr的析构函数会执行这个操作。例如:
xxxxxxxxxx
51void fun() {
2 auto x = makeX();
3
4 // ... 使用X对象 ...
5} // 在unique_ptr指针的析构函数中,销毁动态创建于自由存储中的X对象
与vector是对象序列的句柄一样,unique_ptr也是单个对象或对象数组的句柄。两者都控制着其它对象的生命周期(使用RAII),并且都依赖于拷贝省略和转移语义,使return语句的执行简单而高效。
shared_ptr与unique_ptr很类似,区别在于shared_ptr之间是拷贝而非转移。一个对象可以被多个shared_ptr指向,它们共享对目标对象的所有权,仅当最后一个shared_ptr被销毁时,目标对象才被销毁。例如:
xxxxxxxxxx
141void bar(shared_ptr<X> z) {
2 cout << "bar" << endl;
3} // 销毁z时,不销毁动态创建于自由存储中的X对象
4
5void foo(shared_ptr<X> y) {
6 cout << "foo" << endl;
7 bar(y);
8} // 销毁y时,不销毁动态创建于自由存储中的X对象
9
10int main() {
11 shared_ptr<X> x{ new X };
12 foo(x);
13 cout << "main" << endl;
14} // 销毁x时,销毁动态创建于自由存储中的X对象
当程序执行到bar函数中时,共有x、y和z三个shared_ptr指针指向动态创建于自由存储中的X对象:
bar函数返回时,shared_ptr类型的局部对象z被销毁,但它的析构函数并不会销毁其目标对象,因为此时尚有同为shared_ptr类型的x和y指向该对象:
同样地,foo函数返回时,shared_ptr类型的局部对象y被销毁,它的析构函数也不会销毁其目标对象,因为此时尚有同为shared_ptr类型的x指向该对象:
只有当main函数返回时,shared_ptr类型的局部对象x被销毁,它正是最后一个指向目标对象的shared_ptr指针,它的析构函数负责销毁该对象:
由以上过程可见,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。例如:
xxxxxxxxxx
281class Employee {
2public:
3 Employee(void) : m_id(0), m_name("somebody"), m_salary(0) {}
4
5 Employee(int id, const char* name, double salary)
6 : m_id(id)
7 , m_name(name)
8 , m_salary(salary) {}
9
10 friend ostream& operator<<(ostream& os, const Employee& emp) {
11 return os << '{' << emp.m_id << ", \""
12 << emp.m_name << "\", "
13 << emp.m_salary << '}';
14 }
15
16private:
17 int m_id;
18 string m_name;
19 double m_salary;
20};
21
22int main() {
23 auto p1 = make_unique<Employee>();
24 cout << *p1 << endl; // p1是unique_ptr<Employee>类型的对象
25
26 auto p2 = make_shared<Employee>(100, "zhangfei", 20000);
27 cout << *p2 << endl; // p2是shared_ptr<Emplyee>类型的对象
28}
传递给make_unique和make_shared函数的参数,就是传递给所创建对象构造函数的参数。
相比于单独使用new创建对象,再将其地址传递给shared_ptr而言,使用make_shared函数不仅更方便,而且效率也更高,因为它不需要单独分配引用计数。引用计数在shared_ptr的实现中非常重要。
有了unique_ptr和shared_ptr这样的“智能指针”,甚至可以为许多程序实施彻底的“禁止直接new”规则。然而,这些“智能指针”在概念上仍然是指针,因此,它们在资源管理策略方面,只能作为一个备选方案,第一选择仍然是容器,以及其它在更高概念层次上能够胜任资源管理工作的类型。特别是,shared_ptr本身并没有为其所有者提供任何用于读写共享对象的规则。数据竞争和其它形式的混淆,并不能简单地通过消除资源管理问题而获得解决。
选择unique_ptr或shared_ptr这样的“智能指针”,而不使用资源句柄,或其它专门为解决资源问题而设计的基础设施,如vector、thread等,其中很重要的一个原因,就是前者提供了明确的指针语义,即可以被当做指针看待:
对于共享对象,需要借助指针引用该对象,shared_ptr是显而易见的选择,除非可以明确其所有者只有一个
对于多态对象,需要借助指针引用该对象,因为它的确切类型并不可知,unique_ptr是很自然的选择,但如果该对象需要共享,则应该使用shared_ptr
如果只是想从函数中返回一个对象的集合,其实并不需要使用指针。作为资源句柄的容器,凭借拷贝省略和转移语义,可以简单而高效地完成这个任务。
C++11:资源管理指针(unique_ptr和shared_ptr)
在传统代码中,范围错误,一直是导致C和C++程序发生严重错误的主要原因之一。它会导致结果不正确、进程崩溃,甚至安全问题。借助容器、算法和基于范围的for循环,可以显著减少这类问题,而且还可以做更多的事情。范围错误的一个主要来源,是人们习惯于向函数传递平凡或“智能指针”形式的参数,同时依靠额外的约束来指定可访问区域的边界。在除资源句柄以外的代码中使用指针,其目标对象的数量最好不要超过一个。但如果没有额外的支持,这条建议通常很难做到。标准库的string_view可以提供帮助,但它是只读的,而且仅适用于字符序列。程序员们通常会要求更多,例如在底层代码中读写缓冲区时,既要保证高性能,同时还要避免缓冲区溢出等范围错误,是出了名的困难。头文件<span>中的span本质上是用一个指针和一个长度的组合来表示元素序列:
span仅用于内存连续的元素序列,如内置数组和动态数组(vector)等。和指针一样,span并不拥有其可访问序列的所有权。这方面与string_view和起止迭代器对非常相像。
下面是一种非常常见的接口风格:
xxxxxxxxxx
41void foo(int* p, int n) {
2 for (int i = 0; i < n; ++i)
3 p[i] = 0;
4}
这里假设指针p指向由n个整型元素组成的序列。问题是,这个假设真的成立吗?
xxxxxxxxxx
71void bar(int x) {
2 int a[100];
3 foo(a, 100); // 可行
4 foo(a, 1000); // 范围错误
5 foo(a + 10, 100); // 范围错误
6 foo(a, x); // 不确定
7}
接受span型参数的foo函数类似下面这样:
xxxxxxxxxx
41void foo(span<int> p) {
2 for (int& x : p)
3 x = 0;
4}
而对该函数的调用:
xxxxxxxxxx
71void bar(int x) {
2 int a[100];
3 foo(a); // 可行,隐式创建一个“span<int>{a,100}”对象
4 foo(a, 1000); // 编译错误,需要提供一个span类型的实参
5 foo({a + 10, 1000}); // 范围错误
6 foo({a, x}); // 不确定
7}
直接从数组创建span,编译器会自动计算数组的长度,即其中元素的个数。这是最安全,也最简洁的写法。其它情况下,发生错误的概率降低了,错误检测也变得更加容易,因为代码设计者必须显式地构造span对象。
一般而言,span形式的函数接口,比指针加长度形式的函数接口更简单,且无需额外地检查。例如:
xxxxxxxxxx
71void foo(span<int> p);
2
3void bar(span<int> p) {
4 ...
5 foo(p);
6 ...
7}
与容器的情形类似,对span的下标操作,如“p[i]”,也不会执行范围检查,因此下标越界的结果将是未定义的。
C++20:用于读写连续数组的span
标准库(注意不是标准模板库)提供了几个不完全适配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类型的数字构成的数组,支持数学运算 |
为何标准库会提供如此众多的容器?它们各自满足于常见的不同需求,这些需求有时会发生重叠。就算标准库不提供,程序员们也会自己实现一套。例如:
pair和tuple可以存储不同类型的元素,STL容器只能存储相同类型的元素
array和tuple中存放元素的内存是连续的,list和map则是由指针连接而成的节点结构
bitset和vector<bool>只能存储比特,并要通过代理才能访问其中的元素,STL容器中的元素可以是任意类型,并能直接访问
basic_string要求其中的元素必须是某种类型的字符,并提供字符串操作,如拼接、区域本地化等
valarray要求其中的元素必须是某种类型的数字,并支持数学运算
所有这些容器都可被看作是,标准库为满足大型程序员社区的专业需求,而提供的定制化服务。只靠一个容器不可能满足他们的所有需求,特别是这些需求本身就存在着诸多矛盾,如“允许动态增加内存”和“保证分配在固定位置”间的矛盾,“添加新元素时原有元素不能移动”和“保证内存连续”间的矛盾等。
在头文件<array>中定义的array容器,在一段连续内存中保存指定类型的元素。元素的数量在编译时指定。因此,可以在堆栈、对象或静态存储中分配array及其元素。元素被分配的作用域与array被定义的作用域一致。最好将array直接理解为内置数组,其大小固定不变,但与内置数组不同,它没有隐式的、可能令人惊讶的指针类型转换,而且还提供了一系列方便元素访问与操作的函数和算法。与使用内置数组相比,使用array没有增加任何额外的时间和空间开销。array并不遵循在STL容器中常见的句柄模型,它直接包含元素序列本身,而非指向元素序列的指针。array可被视为内置数组的安全版本。
array可以而且必须通过初始化列表初始化。例如:
xxxxxxxxxx
21array<int, 3> a{ 1, 2, 3 };
2array<double, 5> b = { 1.2, 3.4, 5.6 };
初始化列表中初值个数必须小于或等于为array指定的元素个数。
array的元素个数不是可选的,而必须是显式给定的常量表达式,其类型必须为整型,其值必须为正。array的元素类型必须明确指定。下面的代码展示了上述限制:
xxxxxxxxxx
51array<int> a{ 1, 2, 3 }; // 编译错误,没有指定元素的个数
2int n = 2;
3array<string, n> b{ "hello", "world" }; // 编译错误,元素个数不能是变量
4array<double, 0> c; // 编译错误,元素个数必须是正数
5array<2> d{ "hello", "world" }; // 编译错误,没有指定元素的类型
如果一定要通过变量指定容器中的元素个数,可以使用vector。例如:
xxxxxxxxxx
21vector<int> e(n); // 0 0
2vector<int> f(n, 1); // 1 1
必要时,可以将array传递给带有指针型参数的C风格函数,但需要做必要的转换。例如:
xxxxxxxxxx
11void foo(int* p, int n); // 带有指针型参数的C风格函数
xxxxxxxxxx
61array<int, 10> a;
2...
3foo(a.data(), a.size()); // 可以将array传递给带有指针型参数的C风格函数
4foo(a, a.size()); // 编译错误,没有将array转换为指针
5...
6auto p = ranges::find(a, 123); // array也是范围,可以传递给STL中范围版本的算法函数
既然已经有了vector,为什么还要提供array呢?相比于vector,array通过牺牲灵活性,换取了更好的性能,因为它更简单。在栈中创建的array,其元素也在栈中,而在栈中创建的vector,其元素却位于自由存储中。直接访问栈中的元素,显然比通过句柄间接访问自由存储中的元素,性能更佳,而且位于自由存储中的内存资源,需要在运行时动态地分配和释放,这本身也会带来性能损失。但是,作为一种相对稀缺的资源,尤其在一些嵌入式系统中,栈空间非常宝贵,且一旦发生溢出,后果往往是致命的,这时使用vector可能是更好的选择。而在另一些应用领域,比如存在致命安全需求的实时控制系统中,为了避免free或delete操作可能引发的内存碎片化,甚至内存耗尽,在自由存储中分配内存可能会被明令禁止,这时使用array显然更为合适。
既然已经有了内置数组,为什么还要提供array呢?array知道它的大小,因此更容易结合标准库算法一起使用,而且可以通过“=”操作符直接拷贝。例如:
xxxxxxxxxx
41array<int, 3> a{ 1, 2, 3 };
2auto b = a; // 拷贝构造
3b[1] = 4;
4a = b; // 拷贝赋值
相对于内置数组,array最大的优势是它杜绝了令人惊讶和讨厌的隐式指针转换。例如:
xxxxxxxxxx
21class Shape { ... };
2class Circle : public Shape { ... };
xxxxxxxxxx
31Circle a[3];
2Shape* p = a; // 潜在风险
3p[1].draw(); // 灾难发生
xxxxxxxxxx
21array<Circle, 3> a;
2Shape* p = a; // 编译错误,array对象不能被隐式转换为指针类型
假设sizeof(Shape) < sizeof(Circle):
xxxxxxxxxx
111a[0] a[1] a[2]
2______________________ ______________________ ______________________
3/ \ / \ / \
4a -> |<----sizeof(Circle)---->|<----sizeof(Circle)---->|<----sizeof(Circle)---->|
5__________________________________________________________________________
6| Circle | Circle | Circle |
7|________________________|________________________|________________________|
8
9p -> |<-sizeof(Shape)->|<-sizeof(Shape)->|<-sizeof(Shape)->|
10\_______________/ \_______________/ \_______________/
11p[0] p[1] p[2]
C++11:固定大小的连续序列容器array
系统中的很多属性,比如I/O流的状态等,通常被表示为一组二进制位的组合,访问其中某个特定的二进制位,是一种很常见的操作。解决这类问题的传统方法就是位运算,用位与判断某个特定位是0还是1,用位与或者位或将某个特定位清0或者置1,用自异或实现翻转。bitset<N>类通过对N比特的二进制序列[0,N)的操作表达此概念,其中N是事先提供给编译器的二进制位数。整型的最大类型是long long,最多只能表示64比特,但如果需要表示更多的比特位,则可以借助数组或结构体,而使用bitset会方便得多。即便用不到64比特,使用bitset通常也会获得比整型更好的性能。
bitset可以用字符串或整数初始化。例如:
xxxxxxxxxx
21bitset<16> bs1{ "1111000011110000" };
2bitset<16> bs2{ 0b1111'0000'1111};
bitset支持常用的位运算和移位操作。例如:
xxxxxxxxxx
31bitset<16> bs3 = ~bs1;
2bitset<16> bs4 = bs1 & bs2;
3bitset<16> bs5 = bs1 >> 2;
移位操作符(此处为“>>”)移入0。
to_string、to_ulong和to_ullong都是bitset的成员函数,可将其转换为string、unsigned long和unsigned long long类型的字符串和整数。例如:
xxxxxxxxxx
41string binary(unsigned long ul) {
2 bitset<sizeof(ul) * 8> bs{ ul };
3 return bs.to_string();
4}
xxxxxxxxxx
11cout << binary(123) << endl; // 00000000000000000000000001111011
to_string得到的字符串,从右至左,权重依次升高。
bitset对象也可以直接输出。例如:
xxxxxxxxxx
11cout << bs1 << endl;
bitset还提供了许多用于访问比特集合的函数,例如:all、any、none、count、flip,等等。
从一个函数返回两个值的情况非常多见。有很多方法可以做到这一点,其中最简单也是最好的方法是专门定义一个结构,其中包含一个需要返回给调用者的处理结果,和一个表示错误的错误代码或成功失败标志。例如:
xxxxxxxxxx
41struct Result {
2 Entry entry;
3 Error error;
4};
xxxxxxxxxx
61Result searchByName(const vector<Entry>& entries, const string& name) {
2 Entry entry;
3 Error error = Error::found;
4 ... 在entries中搜索name ...
5 return {entry, error};
6}
xxxxxxxxxx
51Result result = searchByName(entries, name);
2if (result.error != Error::found) {
3 ... 处理错误 ...
4}
5... 使用result.entry ...
对于规模不大的程序,这种从一个函数返回两个值的方法很好,而且非常可读。但如果这样的函数很多,就必须得定义许多这样的结构,这会导致名称和约定的激增。特别是对于那些需要一致命名的泛型代码而言,这种做法的效果通常会很差。为此,标准库提供了pair,作为对“专门用于保存一对值的结构”用例的泛型支持。例如:
xxxxxxxxxx
61pair<Entry, Error> searchByName(const vector<Entry>& entries, const string& name) {
2 Entry entry;
3 Error error = Error::found;
4 ... 在entries中搜索name ...
5 return {entry, error};
6}
xxxxxxxxxx
51auto result = searchByName(entries, name);
2if (result.second != Error::found) {
3 ... 处理错误 ...
4}
5... 使用result.first ...
pair的成员被命名为first和second。从实现者的角度看,这是有道理的,但在应用程序代码中,代之以有意义的名称,无疑会更加可读。借助结构化绑定,可以做到这一点。例如:
xxxxxxxxxx
51auto [entry, error] = searchByName(entries, name);
2if (error != Error::found) {
3 ... 处理错误 ...
4}
5... 使用entry ...
标准库的pair定义在<utility>头文件中。利用pair返回一对值的做法,在标准库中屡见不鲜。例如在有序序列中,搜索匹配于某个给定值的元素范围的equal_range算法函数:
xxxxxxxxxx
31template<typename ForwardIterator, typename T, typename Compare>
2pair<ForwardIterator, ForwardIterator> equal_range(ForwardIterator first,
3 ForwardIterator last, const T& val, Compare cmp);
对于有序序列[first:last),equal_range函数返回由两个ForwardIterator组成的pair,第一个ForwardIterator指向与val匹配的元素范围中的第一个元素,第二个ForwardIterator指向该范围最后一个元素的下一个位置,cmp是用于在元素间做小于比较的比较器对象。使用该函数的代码,类似下面这个样子:
xxxxxxxxxx
41auto [from, to] = equal_range(entries.begin(), entries.end(), Entry{name},
2 [](const Entry& e1, const Entry& e2) { return e1.name < e2.name; });
3for (auto entry = from; entry != to; ++entry)
4 cout << *entry << endl;
pair支持“=”、“==”和“<”等操作,前提是其内部元素的类型得支持相应的操作。借助类型推导,可以在不显式写出其内部元素具体类型的前提下,轻松创建pair类型的对象。例如:
xxxxxxxxxx
51vector<string> v;
2...
3pair p{ v.begin(), 2 };
4...
5auto q = make_pair(v.begin(), 2);
以上代码中的p和q都是pair<vector<string>::iterator,int>类型的对象。
在不需要泛型的场合,没有必要使用pair,相反,具有命名成员的简单结构,会令代码更易于维护。
与数组一样,所有STL容器都只能保存类型相同的元素。但有时,确实需要将不同类型的数据组合成一个对象。这种容器称为异构容器。pair可以算作一种异构容器,但它只能包含两个元素。为此,标准库提供了tuple,可容纳数量不限的异构元素。例如:
xxxxxxxxxx
41tuple t1{};
2tuple<string, int, double> t2{ "Shark", 123, 4.56 };
3tuple t3{ "Shark"s, 123, 4.56 };
4auto t4 = make_tuple("Shark"s, 123, 4.56);
一个元素(成员)都没有的tuple,即所谓空tuple,在内存中也要占一个字节。例如:
xxxxxxxxxx
11cout << sizeof(t1) << endl; // 1
tuple中的元素(成员)是独立的,彼此间没有任何约束,除非将其封装在强制执行它的类中。
对于单一的特定用途,简单的struct通常更适用。但在很多泛型编程场合,tuple的灵活性可以避免以没有成员的助记名称为代价定义许多struct。可以通过get函数访问tuple中的元素(成员)。例如:
xxxxxxxxxx
11cout << get<0>(t2) << ' ' << get<1>(t2) << ' ' << get<2>(t2) << endl;
tuple中的元素(成员)从0开始编号,get函数的索引参数必须是常量。get函数是一个模板函数,带有一个数值型模板参数,即元素索引。
通过索引访问tuple中的元素(成员)是泛型的,不够优雅且易出错。幸运的是,tuple中具有唯一类型的元素(成员)可以通过其类型获得。例如:
xxxxxxxxxx
11cout << get<string>(t3) << ' ' << get<int>(t3) << ' ' << get<double>(t3) << endl;
get函数返回的是对tuple中元素(成员)的引用,可以借此修改tuple的内容。例如:
xxxxxxxxxx
31get<0>(t4) = "Tuna";
2get<int>(t4) = 654;
3get<double>(t4) = 3.21;
tuple的多数应用隐藏在更高级别架构的实现中。例如,借助结构化绑定,访问tuple中的元素(成员):
xxxxxxxxxx
31auto [fish, count, price] = t4;
2cout << fish << ' ' << count << ' ' << price << endl;
3fish = "Sea Bass"; // t4不受影响
这种用法在处理函数的多个返回值时非常有效。例如:
xxxxxxxxxx
11auto [fish, count, price] = todayCatch();
tuple的优势在于,可以方便地存储或传递,未知数量个未知类型的数据。
借助递归,可以遍历任意tuple中的所有元素。例如:
xxxxxxxxxx
71template<size_t N = 0, typename... Ts>
2constexpr void print(tuple<Ts...> tup) {
3 if constexpr (N < sizeof...(Ts)) {
4 cout << get<N>(tup) << ' ';
5 print<N + 1>(tup);
6 }
7}
“sizeof...(Ts)”给出变长类型参数表中的参数个数。
使用print函数则更简单直接。例如:
xxxxxxxxxx
31print(t1);
2print(t2);
3print(tuple{ "Norah", 17, "Gavin", 14, "Anya", 9, "Courtney", 9, "Ada", 0 });
与pair一样,tuple也支持“=”、“==”和“<”等操作,前提是其内部元素的类型得支持相应的操作。pair和只有两个元素(成员)的tuple可以互换使用。
C++11:元组(tuple)
C++14:按类型寻址元组
标准库提供了三种用于表示可变类型容器的数据类型,如下表所示:
可变类型容器 | 说明 |
---|---|
union | 内置类型,存储指定类型集的对象 |
variant<T...> | 定义在<variant>头文件中,存储指定类型集的对象 |
optional<T> | 定义在<optional>头文件中,存储T类型的对象或空值 |
any | 定义在<any>头文件中,存储任意类型对象 |
与显式使用union相比,使用variant<T...>通常会更安全,也更方便。比如一个函数的返回值可能是处理结果,也可能是错误对象:
xxxxxxxxxx
81variant<string, Error> composeMessage(istream& is) {
2 string msg;
3 ... 从输入流读取数据并组成消息 ...
4 if (noProblems)
5 return msg; // 返回string类型的处理结果
6
7 return Error{ someProblem }; // 返回Error类型的错误对象
8}
当赋值或初始化一个variant类型的变量时,它会记住其中值的类型,并根据类型获取相应的值。例如:
xxxxxxxxxx
91auto res = composeMessage(cin);
2if (holds_alternative<string>(res)) {
3 auto msg = get<string>(res);
4 ... 处理消息 ...
5}
6else {
7 auto err = get<Error>(res);
8 ... 处理错误 ...
9}
这种处理函数返回值的方式,对于那些不喜欢异常的开发者颇具吸引力。另外,将函数的参数声明为variant类型,可以用一个函数统一处理不同类型的数据,而不必为每一种类型定义一个独立的函数。例如:
xxxxxxxxxx
141using Node = variant<Expression, Statement, Declaration, Type>;
2
3void check(Node* node) {
4 if (holds_alternative<Expression>(*node)) {
5 Expression& e = get<Expression>(*node);
6 ... 检查表达式 ...
7 }
8 else
9 if (holds_alternative<Statement>(*node)) {
10 Statement& s = get<Statement>(*node);
11 ... 检查语句 ...
12 }
13 ... 检查其它语法元素 ...
14}
这种根据对象的类型决定适当行为的处理模式非常普遍,但效率相对较低,更好的方式是借助visit函数,它充分利用了C++语言根据参数类型匹配重载版本的语法特性。例如:
xxxxxxxxxx
91struct Overloaded {
2 void operator()(Expression& e) const { ... 检查表达式 ... }
3 void operator()(Statement& s) const { ... 检查语句 ... }
4 ... 检查其它语法元素 ...
5};
6
7void check(Node* node) {
8 visit(Overloaded{}, *node);
9}
visit函数会根据variant对象(*node)的实际类型,调用可调用对象(Overloaded)中适当版本的函数操作符函数(operator())。这使程序设计者可以一种类型安全且易于扩展的方式处理variant中存储的不同类型的数据。
任何以与当前存储类型不同的类型访问variant的尝试,都将引发bad_variant_access异常。
C++17:variant
optional<T>从缔造者的角度看,相当于一个variant<T,nothing>。例如:
xxxxxxxxxx
81optional<string> composeMessage(istream& is) {
2 string msg;
3 ... 从输入流读取数据并组成消息 ...
4 if (noProblems)
5 return msg; // 返回string类型的处理结果
6
7 return {}; // 返回optional类型的空对象
8}
而从使用者的角度看,一个optional<T>类型的对象则相当于一个T*,要么指向一个真实存在的T类型对象,要么就是一个什么也不指向的空指针(nullptr)。例如:
xxxxxxxxxx
81auto res = composeMessage(cin);
2if (res) {
3 auto msg = *res; // 注意这里的“*”,optional类型的res,被视为指向string的指针
4 ... 处理消息 ...
5}
6else {
7 ... 处理错误 ...
8}
optional类型对于那些可能返回也可能不返回对象的函数非常有用。注意这里“*”的奇怪用法,optional被视为指向对象的指针,而非对象本身。
对optional而言,扮演空指针(nullptr)的值是空对象({}),必要的非空检测不可或缺。例如:
xxxxxxxxxx
61int sum(optional<int> a, optional<int> b) {
2 int res = 0;
3 if (a) res += *a;
4 if (b) res += *b;
5 return res;
6}
不做非空检测,就贸然访问optional类型的对象,结果将是未定义的,且不会抛出异常。例如:
xxxxxxxxxx
31int sum(optional<int> a, optional<int> b) {
2 return *a + *b; // 这是在自找麻烦
3}
C++17:optional
any可以包含任意类型的值,并且知道它包含的是哪种类型(如果有值的话),可将其视作variant的无约束版本。例如:
xxxxxxxxxx
81any composeMessage(istream& is) {
2 string msg;
3 ... 从输入流读取数据并组成消息 ...
4 if (noProblems)
5 return msg; // 返回string类型的处理结果
6
7 return Error{ someProblem }; // 返回Error类型的错误对象
8}
当赋值或初始化一个any类型的变量时,它会记住其中值的类型,并根据类型获取相应的值。例如:
xxxxxxxxxx
31auto res = composeMessage(cin);
2auto msg = any_cast<string>(res);
3... 处理消息 ...
如果试图以与所存储类型不同的类型访问any,将导致bad_any_access异常被抛出。
C++17:any
库并非越大越复杂就越有用
所谓资源是指需要被显式或隐式地获取和释放的东西
用资源句柄管理资源(RAII)
相对于句柄,指针的问题在于它可以用于表示任何东西,因此不能轻易确定“原始”指针的用途
访问多态类型的对象时可以使用unique_ptr
仅当访问共享对象时才需要使用shared_ptr
与智能指针相比,优先选择含有语义的资源句柄
不要在本可以使用局部变量的场合使用智能指针
与shared_ptr相比,优先选择unique_ptr
仅当需要转移或传递所有权责任时,才使用unique_ptr或shared_ptr作为函数的参数或返回值
使用make_unique函数构造unique_ptr
使用make_shared函数构造shared_ptr
与垃圾回收机制相比,智能指针更加优越
与指针加长度的接口形式相比,span更加优越
span支持基于范围的for循环
如果一个序列的大小可以用constexpr表示,就可以使用array
arrray比内置数组更加优越
如果需要使用N个二进制比特,而N又不是任何内置整型的位宽(如8、16、32、64等),则建议使用bitset
不要过度使用pair和tuple,使用命名struct通常更易于阅读和理解
使用pair时,借助模板参数推导或make_pair函数,可以避免冗长的类型说明
使用tuple时,借助模板参数推导或make_tuple函数,可以避免冗长的类型说明
相比显式使用union,使用variant会更好
在使用variant的场合,考虑结合visit函数
在访问variant、optional或any类型的对象时,最好在访问前先做检查