C---速成课-全-
C++ 速成课(全)
原文:
zh.annas-archive.org/md5/90f13074f21ebbdc75a90b713671246e译者:飞龙
前言
*拿起那把旧画笔,跟我们一起画吧。
—Bob Ross*

系统编程的需求巨大。随着网页浏览器、移动设备和物联网的普及,或许现在正是成为一名系统程序员的最佳时机。高效、可维护且正确的代码在任何情况下都是需求,而我坚信 C++ 是做这项工作的正确语言通常来说。
在一位有经验的程序员手中,C++ 可以比任何其他系统编程语言生成更小、更高效、且更易读的代码。它是一种致力于零开销抽象机制的语言——让你的程序既快速又易于编程——并且能够直接映射到硬件——因此在你需要时,能够提供低级别的控制。使用 C++ 编程时,你站在那些花费数十年精心打造一个极为强大和灵活语言的巨人肩膀上。
学习 C++ 的一个巨大好处是,你可以免费使用 C++ 标准库(stdlib)。stdlib 由三部分组成:容器、迭代器和算法。如果你曾经手写过自己的快速排序算法,或者编写过系统代码并且遭遇过缓冲区溢出、悬空指针、使用后释放和双重释放等问题,那么你一定会喜欢熟悉 stdlib。它为你提供了无与伦比的类型安全性、正确性和效率。此外,你还会喜欢你的代码变得如此简洁和富有表现力。
C++ 编程模型的核心是对象生命周期,它能强有力地保证程序使用的资源(如文件、内存和网络套接字)能够正确释放,即使在发生错误条件时也是如此。当使用得当时,异常可以清理代码中大量的错误条件检查杂乱无章的部分。此外,移动/拷贝语义提供了安全性、效率和灵活性,以一种早期系统编程语言(如 C)根本无法提供的方式来管理资源所有权。
C++ 是一门活生生的语言;在过去的 30 年中,C++ 的国际标准化组织(ISO)委员会定期对该语言进行改进。过去十年发布了几次标准更新:C++11、C++14 和 C++17,分别在 2011、2014 和 2017 年发布。你可以期待在 2020 年推出新的 C++20。
当我提到现代 C++时,我指的是采用这些新增功能和范式的最新 C++ 版本。这些更新对语言进行了严重的精炼,提高了语言的表达能力、效率、安全性和整体可用性。从某些标准来看,C++ 从未如此受欢迎,而且它短期内不会消失。如果你决定投资学习 C++,它将在未来的岁月里为你带来丰厚的回报。
关于本书
尽管有许多非常高质量的书籍可供现代 C++ 程序员使用,例如 Scott Meyer 的 Effective Modern C++ 和 Bjarne Stroustrup 的 The C++ Programming Language 第 4 版,但它们通常相当高级。一些入门级的 C++ 书籍也有,但它们常常跳过重要的细节,因为它们是为那些完全没有编程经验的人设计的。对于有经验的程序员来说,C++ 语言的学习路径并不清晰。
我更喜欢有意识地学习复杂的主题,从其基本元素开始构建概念。C++ 因为其基本元素紧密嵌套在一起,导致它具有令人畏惧的声誉,使得很难构建完整的语言图像。当我学习 C++ 时,我曾经在书籍、视频和疲惫的同事间反复跳跃,努力理清语言的框架。因此,我写了这本书,是我五年前希望能拥有的那本书。
谁应该阅读本书?
本书适合已经熟悉基本编程概念的中级到高级程序员。如果你没有专门的 系统 编程经验也没关系,经验丰富的应用程序员同样欢迎阅读。
注意
如果你是一名资深的 C 程序员或正在考虑是否投资学习 C++ 的系统程序员,请务必阅读第 xxxvii 页 的《C 程序员序曲》进行详细了解。
本书内容是什么?
本书分为两部分。第一部分涵盖 C++ 核心语言。你将直接学习地道的现代 C++,而不是按时间顺序介绍 C++ 语言(从旧版 C++ 98 到现代 C++11/14/17)。第二部分则将带你进入 C++ 标准库(stdlib)的世界,学习其中最重要和最基本的概念。
第一部分:C++ 核心语言
第一章:快速入门 本章节将帮助你设置 C++ 开发环境。你将编译并运行你的第一个程序,并学习如何调试它。
第二章:类型 在这一章中,你将探索 C++ 的类型系统。你将学习基本类型,这是所有其他类型构建的基础。接下来,你将了解普通数据类型和功能齐全的类。你将深入研究构造函数、初始化和析构函数的角色。
第三章:引用类型 本章将介绍存储其他对象内存地址的对象。这些类型是许多重要编程模式的基石,它们使你能够编写灵活且高效的代码。
第四章:对象生命周期 本章继续讨论类的不变性和构造函数,并在存储持续时间的上下文中展开。析构函数与资源获取即初始化(RAII)范式一起介绍。你将学习异常处理及其如何强制执行类的不变性,并补充 RAII。经过关于移动语义和复制语义的讨论后,你将学习如何通过构造函数和赋值操作符来实现这些概念。
第五章:运行时多态性 本章将介绍接口,这是一个允许你编写运行时多态代码的编程概念。你将学习继承和对象组合的基础知识,这些知识构成了如何在 C++ 中实现接口的基础。
第六章:编译时多态性 本章介绍模板,这是一种允许你编写多态代码的语言特性。你还将探索概念,这是将添加到未来 C++ 版本中的语言特性,以及命名转换函数,它允许你将对象从一种类型转换为另一种类型。
第七章:表达式 现在你将深入学习操作数和运算符。在牢牢掌握类型、对象生命周期和模板的基础上,你将准备好深入探讨 C++ 语言的核心组件,而表达式就是第一个重要的环节。
第八章:语句 本章将探讨构成函数的各种元素。你将学习表达式语句、复合语句、声明语句、迭代语句和跳转语句。
第九章:函数 第一部分的最后一章扩展了如何将语句安排成工作单元的讨论。你将了解函数定义、返回类型、重载解析、可变参数函数、可变参数模板和函数指针的细节。你还将学习如何使用函数调用操作符和 lambda 表达式创建可调用的用户自定义类型。你将探索 std::function,这是一个提供统一容器来存储可调用对象的类。
第二部分:C++ 库和框架
第十章:测试 本章将带你进入单元测试和模拟框架的精彩世界。你将练习测试驱动开发,开发一个自动驾驶系统的软件,并学习 Boost Test、Google Test、Google Mock 等框架。
第十一章:智能指针 本章将解释 stdlib 提供的特殊工具类,用于处理动态对象的所有权。
第十二章:实用工具 本章将概述你可以在 stdlib 和 Boost 库中使用的类型、类和函数,以解决常见的编程问题。你将了解数据结构、数值函数和随机数生成器。
第十三章:容器 本章将概述 Boost 库和标准库中许多特殊数据结构,这些数据结构帮助你组织数据。你将了解顺序容器、关联容器和无序关联容器。
第十四章:迭代器 这是你在上一章学习的容器与下一章字符串之间的接口。你将学习不同种类的迭代器,以及它们的设计如何为你提供极大的灵活性。
第十五章:字符串 本章教你如何在单一容器系列中处理人类语言数据。你还将学习字符串中内置的特殊功能,这些功能使你能够执行常见的任务。
第十六章:流 本章将向你介绍输入和输出操作的核心概念。你将学习如何使用格式化和非格式化操作处理输入输出流,以及如何使用操控符。你还将学习如何从文件中读取和写入数据。
第十七章:文件系统 本章将概述标准库中用于操作文件系统的功能。你将学习如何构建和操作路径,检查文件和目录,以及枚举目录结构。
第十八章:算法 这是一个快速参考,介绍了你可以轻松通过标准库解决的数十个问题。你将了解可供使用的高质量算法的广泛范围。
第十九章:并发与并行性 本章教你一些标准库中的多线程编程的简单方法。你将学习到 futures、互斥锁、条件变量和原子操作。
第二十章:使用 Boost Asio 进行网络编程 在本章中,你将学习如何构建高性能的网络通信程序。你将看到如何使用 Boost Asio 进行阻塞和非阻塞的输入输出操作。
第二十一章:编写应用程序 本章总结了全书的内容,讨论了几个重要的主题。你将了解程序支持功能,它们允许你与应用程序生命周期进行交互。你还将学习 Boost ProgramOptions,这是一个简化编写接受用户输入的控制台应用程序的库。
注意
访问伴随网站 ccc.codes/ 获取本书中包含的代码清单。
第一章:致 C 程序员的前言
阿瑟·丹特:他怎么了? 高·赫滕弗斯特:他的脚与鞋子不匹配。
—道格拉斯·亚当斯,《银河系漫游指南》,“适配第十一条”

本前言是为有经验的 C 程序员准备的,帮助他们决定是否阅读本书。非 C 程序员可以跳过这一部分。
比雅尔内·斯特劳斯特鲁普(Bjarne Stroustrup)从 C 语言发展出了 C++。尽管 C++ 并不完全兼容 C,但写得好的 C 程序通常也是合法的 C++ 程序。举例来说,Brian Kernighan 和 Dennis Ritchie 编写的《C 程序设计语言》中的每个例子,都是合法的 C++ 程序。
C 语言在系统编程社区广泛使用的一个主要原因是,C 允许程序员以比汇编语言更高的抽象层次进行编程。这通常能产生更清晰、少出错、且更易维护的代码。
一般来说,系统程序员不愿为编程便利性支付额外开销,因此 C 语言遵循零开销原则:你不用的,就不需要为它付费。强类型系统就是零开销抽象的典型例子。它只在编译时用于检查程序正确性。编译后,类型信息将消失,生成的汇编代码将不再体现类型系统的痕迹。
作为 C 语言的后代,C++ 也非常重视零开销抽象和直接映射到硬件。这种承诺不仅仅局限于 C++ 支持的 C 语言特性。C++ 在 C 基础上构建的一切,包括新的语言特性,都遵循这些原则,任何偏离这些原则的地方都是经过深思熟虑的。实际上,一些 C++ 特性比对应的 C 代码还要少开销。例如,constexpr 关键字就是一个例子。它指示编译器在编译时评估表达式(如果可能的话),如 清单 1 中的程序所示。
#include <cstdio>
constexpr int isqrt(int n) {
int i=1;
while (i*i<n) ++i;
return i-(i*i!=n);
}
int main() {
constexpr int x = isqrt(1764); ➊
printf("%d", x);
}
清单 1:演示 constexpr 的程序
isqrt 函数计算参数 n 的平方根。从 1 开始,该函数递增局部变量 i,直到 i*i 大于或等于 n。如果 i*i == n,则返回 i;否则,返回 i-1。请注意,isqrt 的调用有一个字面值,因此编译器理论上可以为你计算结果。结果最终只会是一个值 ➊。
在 GCC 8.3 上编译 清单 1,目标为 x86-64,使用 -O2 优化选项,生成的汇编代码可见于 清单 2。
.LC0:
.string "%d"
main:
sub rsp, 8
mov esi, 42 ➊
mov edi, OFFSET FLAT:.LC0
xor eax, eax
call printf
xor eax, eax
add rsp, 8
ret
清单 2:编译 清单 1 后生成的汇编代码
这里最显著的结果是 main 中的第二条指令 ➊;编译器并不是在运行时计算 1764 的平方根,而是计算出结果并输出指令,将 x 处理为 42。当然,你可以使用计算器计算平方根并手动插入结果,但使用 constexpr 提供了许多好处。这种方法可以减少与手动复制粘贴相关的许多错误,使你的代码更加富有表现力。
注意
如果你不熟悉 x86 汇编语言,请参阅《汇编语言艺术》(第 2 版,Randall Hyde 著)和《专业汇编语言》(Richard Blum 著)。
升级到超级 C
现代 C++ 编译器会支持大部分你的 C 编程习惯。这使得你能够轻松接受一些 C++ 语言提供的策略性优点,同时故意避开该语言的深层主题。我们可以将这种 C++ 称为超级 C,它有几个值得讨论的原因。首先,经验丰富的 C 程序员可以通过将简单的、策略层面的 C++ 概念应用到他们的程序中,立即受益。其次,超级 C并非惯用的 C++。简单地在 C 程序中撒上引用和 auto 实例,可能会使你的代码更健壮、更易读,但要想充分利用这些特性,你还需要学习其他概念。第三,在一些苛刻的环境中(例如,嵌入式软件、某些操作系统内核和异构计算),可用的工具链对 C++ 的支持不完全。在这种情况下,你仍然可以从一些 C++ 惯用语中获益,而超级 C 很可能是被支持的。本节介绍了一些可以立即应用到代码中的超级 C 概念。
注意
一些 C 支持的结构在 C++ 中无法使用。请参见本书配套网站的链接部分, ccc.codes。
函数重载
请考虑以下来自标准 C 库的转换函数:
char* itoa(int value, char* str, int base);
char* ltoa(long value, char* buffer, int base);
char* ultoa(unsigned long value, char* buffer, int base);
这些函数实现相同的目标:它们将一个整型转换为 C 风格的字符串。在 C 语言中,每个函数必须有唯一的名称。但在 C++ 中,只要函数的参数不同,多个函数可以共享相同的名称;这就是所谓的函数重载。你可以利用函数重载创建自己的转换函数,正如列表 3 所示。
char* toa(int value, char* buffer, int base) {
--snip--
}
char* toa(long value, char* buffer, int base)
--snip--
}
char* toa(unsigned long value, char* buffer, int base) {
--snip--
}
int main() {
char buff[10];
int a = 1; ➊
long b = 2; ➋
unsigned long c = 3; ➌
toa(a, buff, 10);
toa(b, buff, 10);
toa(c, buff, 10);
}
列表 3:调用重载函数
每个函数中第一个参数的数据类型不同,因此 C++ 编译器从传递给 toa 的参数中获得足够的信息,以调用正确的函数。每次 toa 调用都是指向一个唯一的函数。这里,你创建了变量 a ➊、b ➋ 和 c ➌,它们是不同类型的 int 对象,对应于三个 toa 函数中的一个。这比定义不同名称的函数更方便,因为你只需要记住一个名称,编译器会搞清楚调用哪个函数。
参考资料
指针是 C 语言(以及扩展到大多数系统编程)的一个关键特性。它们通过传递数据地址而不是实际数据,使你能够高效地处理大量数据。指针对 C++也同样重要,但你可以使用额外的安全特性来防止空指针解引用和无意的指针重新赋值。
引用是对指针处理的重大改进。它们与指针类似,但有一些关键的区别。从语法上讲,引用与指针在两个重要方面有所不同。首先,你使用&来声明引用,而不是*,正如示例 4 所展示的那样。
struct HolmesIV {
bool is_sentient;
int sense_of_humor_rating;
};
void make_sentient(HolmesIV*); // Takes a pointer to a HolmesIV
void make_sentient(HolmesIV&); // Takes a reference to a HolmesIV
示例 4:展示如何声明接受指针和引用的函数的代码
其次,你使用点操作符.与成员进行交互,而不是箭头操作符->,正如示例 5 所示。
void make_sentient(HolmesIV* mike) {
mike->is_sentient = true;
}
void make_sentient(HolmesIV& mike) {
mike.is_sentient = true;
}
示例 5:演示点操作符和箭头操作符使用的程序
在底层,引用等同于指针,因为它们也是一种零开销的抽象。编译器生成的代码相似。为了说明这一点,考虑在 GCC 8.3 上编译make_sentient函数的结果,目标架构为 x86-64,使用-O2优化选项。示例 6 包含了通过编译示例 5 生成的汇编代码。
make_sentient(HolmesIV*):
mov BYTE PTR [rdi], 1
ret
make_sentient(HolmesIV&):
mov BYTE PTR [rdi], 1
ret
示例 6:通过编译示例 5 生成的汇编代码
然而,在编译时,引用相比原始指针提供了一些安全性,因为一般来说,引用不能为 null。
对于指针,你可能会添加一个nullptr检查以确保安全。例如,你可能会对make_sentient添加检查,就像在示例 7 中所示的那样。
void make_sentient(HolmesIV* mike) {
if(mike == nullptr) return;
mike->is_sentient = true;
}
示例 7:对示例 5 中的make_sentient函数进行重构,以执行nullptr检查
在使用引用时,这样的检查是不必要的;然而,这并不意味着引用总是有效的。考虑以下函数:
HolmesIV& not_dinkum() {
HolmesIV mike;
return mike;
}
not_dinkum函数返回一个引用,该引用保证非 null。但它指向的是垃圾内存(可能是从not_dinkum返回的栈帧中)。你绝不能这样做。结果将是彻底的痛苦,也就是未定义的运行时行为:它可能崩溃,可能给出错误,或者可能做出完全意想不到的事情。
引用的另一个安全特性是它们不能被重新设置。换句话说,一旦引用被初始化,就不能再指向另一个内存地址,正如示例 8 所示。
int main() {
int a = 42;
int& a_ref = a; ➊
int b = 100;
a_ref = b; ➋
}
示例 8:演示引用不能被重新设置的程序
你将a_ref声明为对int a的引用 ➊。无法重新为a_ref指向另一个int。你可能尝试使用赋值操作符=重置a ➋,但这实际上是将a的值设置为b的值,而不是将a_ref设置为引用b。在该代码片段运行后,a和b都等于100,并且a_ref仍然指向a。清单 9 提供了使用指针的等效代码。
int main() {
int a = 42;
int* a_ptr = &a; ➊
int b = 100;
*a_ptr = b; ➋
}
清单 9:使用指针的等效程序,参考清单 8
在这里,你使用*声明指针,而不是& ➊。你将b的值赋给a_ptr指向的内存 ➋。使用引用时,你不需要在等号左边加任何装饰。但如果你省略*,例如在*a_ptr中,编译器会抱怨你试图将int类型赋给指针类型。
引用实际上是具有额外安全防护和一些语法糖的指针。当你将引用放在等号的左侧时,你是在将右侧等号的值赋给指针所指向的值。
auto 初始化
C 语言通常要求你重复多次类型信息,而在 C++中,你只需使用auto关键字一次,就可以表达变量的类型信息。编译器将知道变量的类型,因为它知道用于初始化变量的值的类型。考虑以下 C++变量初始化示例:
int x = 42;
auto y = 42;
在这里,x和y都是int类型。你可能会惊讶地发现编译器能够推导出y的类型,但请注意,42 是一个整数字面量。使用auto时,编译器会推导出等号右侧的类型=,并将变量的类型设置为相同类型。由于整数字面量是int类型,因此在此示例中,编译器推导出y的类型也是int。在如此简单的示例中,这似乎没有太大好处,但请考虑用一个函数的返回值初始化变量,如清单 10 所示。
#include <cstdlib>
struct HolmesIV {
--snip--
};
HolmesIV* make_mike(int sense_of_humor) {
--snip--
}
int main() {
auto mike = make_mike(1000);
free(mike);
}
清单 10:一个使用函数返回值初始化变量的玩具程序
auto关键字更易读,且比显式声明变量类型更有利于代码重构。如果你在声明函数时自由使用auto,当你需要更改make_mike的返回类型时,你将需要做的工作会更少。随着代码复杂性增加,特别是涉及到标准库中模板代码时,auto的优势更加明显。auto关键字使编译器为你做所有类型推导的工作。
注意
你也可以在auto后添加const、volatile、&和*限定符。
命名空间和结构体、联合体和枚举的隐式类型定义
C++将类型标签视为隐式typedef名称。在 C 语言中,当你想使用struct、union或enum时,你必须使用typedef关键字为你创建的类型指定一个名称。例如:
typedef struct Jabberwocks {
void* tulgey_wood;
int is_galumphing;
} Jabberwock;
在 C++中,你可能会对这样的代码嗤之以鼻。因为typedef关键字可以是隐式的,C++允许你像这样声明Jabberwock类型:
struct Jabberwock {
void* tulgey_wood;
int is_galumphing;
};
这样做更加方便,并且可以节省一些输入时间。如果你还想定义一个Jabberwock函数会怎样呢?嗯,你不应该这么做,因为将数据类型和函数使用相同的名称可能会引起混淆。不过,如果你真的决定这么做,C++允许你声明一个namespace来为标识符创建不同的作用域。这有助于保持用户类型和函数的整洁,如列表 11 所示。
#include <cstdio>
namespace Creature { ➊
struct Jabberwock {
void* tulgey_wood;
int is_galumphing;
};
}
namespace Func { ➋
void Jabberwock() {
printf("Burble!");
}
}
列表 11:使用命名空间消除具有相同名称的函数和类型的歧义
在这个例子中,Jabberwock结构体和Jabberwock函数现在和谐共存。通过将每个元素放置在自己的namespace中——结构体放在Creature命名空间 ➊,函数放在Func命名空间 ➋——你就能消除歧义,明确你指的是哪个 Jabberwock。你可以通过几种方式来消除歧义。最简单的方法是用它的namespace来限定名称,例如:
Creature::Jabberwock x;
Func::Jabberwock();
你还可以使用using指令导入namespace中的所有名称,这样你就不再需要使用完全限定的元素名称了。列表 12 使用了Creature命名空间。
#include <cstdio>
namespace Creature {
struct Jabberwock {
void* tulgey_wood;
int is_galumphing;
};
}
namespace Func {
void Jabberwock() {
printf("Burble!");
}
}
using namespace Creature; ➊
int main() {
Jabberwock x; ➋
Func::Jabberwock();
}
列表 12:使用using namespace来引用Creature命名空间中的类型
using namespace ➊使你能够省略namespace限定符 ➋。但你仍然需要在Func::Jabberwock前加上限定符,因为它不属于Creature命名空间。
使用namespace是 C++的惯用法,是一种零开销的抽象。就像类型的其他标识符一样,namespace在编译器生成汇编代码时会被去除。在大型项目中,它对于将不同库的代码进行分离非常有帮助。
C 和 C++目标文件的混合使用
如果你小心操作,C 和 C++代码是可以和平共存的。有时,C 编译器需要链接由 C++编译器生成的目标文件(反之亦然)。虽然这是可能的,但需要一些额外的工作。
有两个问题与链接文件相关。首先,C 和 C++ 代码中的调用约定可能不匹配。例如,调用函数时堆栈和寄存器的设置协议可能不同。这些调用约定是语言级别的不匹配,通常与函数的编写方式无关。其次,C++ 编译器生成的符号与 C 编译器不同。有时,链接器必须通过名称识别一个对象。C++ 编译器通过修饰对象,将一个名为 修饰名 的字符串与对象关联,来提供帮助。由于函数重载、调用约定和 namespace 的使用,编译器必须通过装饰对函数进行额外的信息编码,而不仅仅是它的名称。这是为了确保链接器能够唯一地识别该函数。不幸的是,C++ 中关于如何进行修饰没有标准(这就是为什么在链接翻译单元时,你应该使用相同的工具链和设置)。C 链接器不了解 C++ 名称修饰,如果在 C++ 中链接 C 代码时没有抑制修饰(反之亦然),这可能会引发问题。
解决方法很简单。你只需使用 extern "C" 语句包裹你希望以 C 风格链接的代码,如清单 13 所示。
// header.h
#ifdef __cplusplus
extern "C" {
#endif
void extract_arkenstone();
struct MistyMountains {
int goblin_count;
};
#ifdef __cplusplus
}
#endif
清单 13:使用 C 风格链接
这个头文件可以在 C 和 C++ 代码之间共享。之所以可行,是因为 __cplusplus 是一个 C++ 编译器定义的特殊标识符(但 C 编译器没有定义)。因此,C 编译器在预处理完成后会看到清单 14 中的代码。清单 14 显示了剩余的代码。
void extract_arkenstone();
struct MistyMountains {
int goblin_count;
};
清单 14:在 C 环境中,预处理器处理清单 13 后剩下的代码
这只是一个简单的 C 头文件。在预处理过程中,#ifdef __cplusplus 语句之间的代码会被移除,因此 extern "C" 包裹器不可见。对于 C++ 编译器,__cplusplus 在 header.h 中定义,因此它会看到清单 15 的内容。
extern "C" {
void extract_arkenstone();
struct MistyMountains {
int goblin_count;
};
}
清单 15:在 C++ 环境中,预处理器处理清单 13 后剩下的代码
现在 extract_arkenstone 和 MistyMountains 都已用 extern "C" 包裹,因此编译器知道使用 C 链接。现在你的 C 源代码可以调用已编译的 C++ 代码,你的 C++ 源代码也可以调用已编译的 C 代码。
C++ 主题
本节将简要介绍一些使 C++ 成为首选系统编程语言的核心主题。无需过于担心细节。以下小节的重点是激发你的兴趣。
简洁表达思想和重用代码
精心编写的 C++ 代码具有优雅和紧凑的特质。考虑以下简单操作,从 ANSI-C 到现代 C++ 的演变:遍历一个包含 n 个元素的数组 v,正如 列表 16 所示。
#include <cstddef>
int main() {
const size_t n{ 100 };
int v[n];
// ANSI-C
size_t i;
for (i=0; i<n; i++) v[i] = 0; ➊
// C99
for (size_t i=0; i<n; i++) v[i] = 0; ➋
// C++17
for (auto& x : v) x = 0; ➌
}
列表 16:一个展示多种方式遍历数组的程序
这个代码片段展示了在 ANSI-C、C99 和 C++ 中声明循环的不同方式。在 ANSI-C ➊ 和 C99 ➋ 示例中,索引变量 i 对你要完成的任务没有直接帮助,你要做的是访问 v 中的每个元素。C++ 版本 ➌ 使用了 基于范围 的 for 循环,它遍历 v 中的值范围,同时隐藏了迭代如何实现的细节。像 C++ 中许多零开销抽象一样,这种构造让你可以专注于意义而不是语法。基于范围的 for 循环可以与许多类型一起使用,甚至可以让它们与用户定义的类型一起工作。
说到用户定义类型,它们允许你直接在代码中表达思想。假设你想设计一个名为navigate_to的函数,告诉一个假设的机器人根据 x 和 y 坐标导航到某个位置。请看下面的原型函数:
void navigate_to(double x, double y);
x 和 y 是什么?它们的单位是什么?用户必须阅读文档(或可能是源代码)才能弄清楚。比较以下改进后的原型:
struct Position{
--snip--
};
void navigate_to(const Position& p);
这个函数要清晰得多。关于 navigate_to 接受什么参数没有任何模糊之处。只要你有一个有效构造的 Position,你就知道该如何调用 navigate_to。关于单位、转换等的担忧现在归构造 Position 类的人员负责。
你也可以使用 const 指针在 C99/C11 中接近这种清晰度,但 C++ 也使返回类型紧凑且富有表现力。假设你想为机器人写一个名为 get_position 的附属函数,顾名思义,它获取位置。在 C 中,你有两种选择,如 列表 17 所示。
Position* get_position(); ➊
void get_position(Position* p); ➋
列表 17:返回用户定义类型的 C 风格 API
在第一个选项中,调用者负责清理返回值 ➊,它可能已经进行了动态分配(尽管从代码中无法看出)。调用者负责在某个地方分配一个 Position 并将其传递给 get_position ➋。这种方式更符合 C 风格,但语言却成了障碍:你只是想获取一个位置对象,却不得不担心是调用者还是被调用函数负责分配和释放内存。C++ 让你通过直接从函数返回用户定义的类型来简洁地完成所有这些操作,正如 列表 18 所示。
Position➊ get_position() {
--snip--
}
void navigate() {
auto p = get_position(); ➋
// p is now available for use
--snip--
}
列表 18:在 C++ 中按值返回用户定义类型
因为get_position返回一个值➊,编译器可以省略复制,所以就像是你直接构造了一个自动的Position变量➋;没有运行时开销。从功能上讲,你实际上处于类似于 C 风格通过引用传递的示例 17 的情况。
C++ 标准库
C++标准库(stdlib)是从 C 迁移的重要原因之一。它包含高性能的通用代码,并且保证在符合标准的环境中立即可用。stdlib 的三个主要组件是容器、迭代器和算法。
容器是数据结构。它们负责存储对象序列。它们是正确、安全的,并且(通常)至少和你手动实现的效率相当,这意味着写你自己的这些容器版本将需要巨大努力,而且不可能比 stdlib 容器更好。容器被清晰地分为两大类:顺序容器和关联容器。顺序容器在概念上类似于数组;它们提供对元素序列的访问。关联容器包含键/值对,因此容器中的元素可以通过键查找。
stdlib 的算法是用于常见编程任务的通用函数,例如计数、查找、排序和转换。就像容器一样,stdlib 算法质量极高,并且适用范围广泛。用户通常不需要实现自己的版本,使用 stdlib 算法能大大提高程序员的生产力、代码安全性和可读性。
迭代器将容器与算法连接起来。对于许多 stdlib 算法应用,您想操作的数据通常存储在容器中。容器暴露迭代器以提供一个统一的接口,算法则消费这些迭代器,避免程序员(包括 stdlib 的实现者)为每种容器类型实现自定义算法。
示例 19 展示了如何使用几行代码对值容器进行排序。
#include <vector>
#include <algorithm>
#include <iostream>
int main() {
std::vector<int> x{ 0, 1, 8, 13, 5, 2, 3 }; ➊
x[0] = 21; ➋
x.push_back(1); ➌
std::sort(x.begin(), x.end()); ➍
std::cout << "Printing " << x.size() << " Fibonacci numbers.\n"; ➎
for (auto number : x) {
std::cout << number << std::endl; ➏
}
}
示例 19:使用 stdlib 对值容器进行排序
背后有大量计算在进行,但代码简洁且富有表现力。首先,你初始化了一个 std::vector 容器 ➊。向量(Vector)是标准库中的动态数组。初始化括号({0, 1, ...})设置了 x 中包含的初始值。你可以像访问数组元素一样,通过括号([])和索引号访问 vector 中的元素。你用这种方法将第一个元素设置为 21 ➋。因为 vector 数组是动态大小的,你可以使用 push_back 方法向其中添加元素 ➌。std::sort 的神奇调用展示了标准库算法的强大功能 ➍。x.begin() 和 x.end() 方法返回的迭代器被 std::sort 用来就地排序 x。sort 算法通过使用迭代器与 vector 解耦。
得益于迭代器,你可以类似地使用标准库中的其他容器。例如,你可以使用 list(标准库中的双向链表)而不是使用 vector。因为 list 也通过 .begin() 和 .end() 方法暴露了迭代器,你可以像对待 vector 迭代器一样对 list 迭代器调用 sort。
此外,清单 19 使用了输入输出流(iostreams)。输入输出流是标准库用于执行缓冲输入输出的机制。你使用输出运算符 (<<) 将 x.size()(x 中元素的数量)、一些字符串字面量和斐波那契数列元素 number 流式传输到 std::cout,它封装了标准输出流 ➎ ➏。std::endl 对象是一个输入输出操控符,它会写入 \n 并刷新缓冲区,确保整个流在执行下一条指令之前被写入标准输出。
现在,想象一下你需要跳过多少环节才能用 C 语言写出一个等效的程序,你就会明白为什么标准库(stdlib)是如此有价值的工具。
Lambda 表达式
Lambda 表达式,在某些圈子里也被称为匿名函数,是另一种强大的语言特性,它提升了代码的局部性。在某些情况下,你需要将指针传递给函数,以便将指针作为新创建线程的目标,或者对序列中的每个元素执行某种变换。定义一个一次性使用的自由函数通常不方便。这时,Lambda 表达式就派上用场了。Lambda 表达式是一个新的、与调用参数同行定义的自定义函数。考虑下面这个一行代码,它计算 x 中偶数的数量:
auto n_evens = std::count_if(x.begin(), x.end(),
[] (auto number) { return number % 2 == 0; });
这个代码片段使用了标准库的 count_if 算法来计算 x 中偶数的数量。std::count_if 的前两个参数与 std::sort 相同;它们是定义算法操作范围的迭代器。第三个参数是 lambda 表达式。这个语法可能看起来有点陌生,但基础知识其实非常简单:
[capture] (arguments) { body }
捕获包含了你需要从 lambda 定义的作用域中获取的对象,用于在函数体内进行计算。参数定义了 lambda 预期被调用时所接受的参数名称和类型。函数体包含了你希望在调用时完成的计算。它可能会返回值,也可能不会。编译器会根据你暗示的类型推导出函数的原型。
在上面的std::count_if调用中,lambda 不需要捕获任何变量。它所需的所有信息都作为一个单独的参数number传入。因为编译器知道x中包含元素的类型,所以你用auto声明number的类型,编译器会为你推导出类型。lambda 会被调用,并将x中的每个元素作为number参数传入。在函数体内,当number能被2整除时,lambda 才返回true,因此只有偶数会被计入。
Lambda 在 C 语言中不存在,实际上也不可能重建它们。每次需要一个函数对象时,你必须声明一个单独的函数,而且无法像在其他语言中那样将对象捕获到函数中。
使用模板的通用编程
通用编程是编写一次代码,使其能与不同的类型一起工作,而不必通过复制和粘贴每种你希望支持的类型来多次重复相同的代码。在 C++中,你使用模板来生成通用代码。模板是一种特殊的参数,它告诉编译器表示多种可能类型。
你已经使用过模板:stdlib 中的所有容器都使用模板。在大多数情况下,这些容器中对象的类型并不重要。例如,判断容器中元素数量的逻辑或返回其第一个元素的逻辑并不依赖于元素的类型。
假设你想编写一个函数来加和三个相同类型的数字。你希望接受任何可加的类型。在 C++中,这是一个直接的通用编程问题,你可以通过模板直接解决,就像示例 20 所示。
template <typename T>
T add(T x, T y, T z) { ➊
return x + y + z;
}
int main() {
auto a = add(1, 2, 3); // a is an int
auto b = add(1L, 2L, 3L); // b is a long
auto c = add(1.F, 2.F, 3.F); // c is a float
}
示例 20:使用模板创建通用的add函数
当你声明add ➊时,你不需要知道T。你只需要知道所有的参数和返回值都是T类型,并且T是可加的。当编译器遇到add被调用时,它会推导出T并为你生成一个定制的函数。这就是一种真正的代码重用!
类不变式与资源管理
也许 C++带给系统编程的最大创新是对象生命周期。这个概念源自 C 语言,在 C 语言中,根据对象在代码中的声明方式,对象具有不同的存储持续时间。
C++ 在此内存管理模型的基础上,提供了构造函数和析构函数。这些特殊函数是属于 用户定义类型 的方法。用户定义类型是 C++ 应用程序的基本构建块。可以将它们视为可以包含函数的 struct 对象。
对象的构造函数在其存储持续时间开始后立即调用,析构函数在其存储持续时间结束前立即调用。构造函数和析构函数都是没有返回类型的函数,且名称与封闭类相同。要声明析构函数,可以在类名的开头加上 ~,正如 列表 21 所示。
#include <cstdio>
struct Hal {
Hal() : version{ 9000 } { // Constructor ➊
printf("I'm completely operational.\n");
}
~Hal() { // Destructor ➋
printf("Stop, Dave.\n");
}
const int version;
};
列表 21:包含构造函数和析构函数的 Hal 类
Hal 类中的第一个方法是 构造函数 ➊。它设置 Hal 对象并建立其 类不变量。不变量是类的特性,一旦构造完成便不会改变。借助编译器和运行时的帮助,程序员决定类的不变量是什么,并确保代码强制执行这些不变量。在这种情况下,构造函数将不变量 version 设置为 9000。析构函数 是第二个方法 ➋。每当 Hal 即将被释放时,它会在控制台上打印 "Stop, Dave."(让 Hal 唱“Daisy Bell”留给读者作为练习)。
编译器确保对于具有静态、局部和线程局部存储持续时间的对象,构造函数和析构函数会自动调用。对于具有动态存储持续时间的对象,您需要使用关键字 new 和 delete 来替代 malloc 和 free,列表 22 做了说明。
#include <cstdio>
struct Hal {
--snip--
};
int main() {
auto hal = new Hal{}; // Memory is allocated, then constructor is called
delete hal; // Destructor is called, then memory is deallocated
}
-----------------------------------------------------------------------
I'm completely operational.
Stop, Dave.
列表 22:创建和销毁 Hal 对象的程序
如果(无论出于何种原因)构造函数无法使对象达到良好状态,它通常会抛出一个 异常。作为 C 程序员,您可能在使用某些操作系统 API(例如,Windows 结构化异常处理)时处理过异常。当抛出异常时,栈会被展开,直到找到一个异常处理器,程序在此时会恢复。合理使用异常可以清理代码,因为您只需要在合适的地方检查错误条件。C++ 对异常提供了语言级的支持,正如 列表 23 所示。
#include <exception>
try {
// Some code that might throw a std::exception ➊
} catch (const std::exception &e) {
// Recover the program here. ➋
}
列表 23:try-catch 块
您可以将可能抛出异常的代码放在 try 语句后面的代码块中 ➊。如果在任何时候抛出异常,栈将展开(优雅地销毁任何超出作用域的对象),并运行您在 catch 表达式后面放置的代码 ➋。如果没有抛出异常,则此 catch 代码不会执行。
构造函数、析构函数和异常与 C++的另一个核心主题密切相关,那就是将对象的生命周期与它所拥有的资源绑定。这就是资源分配即初始化(RAII)概念(有时也叫做构造函数获取,析构函数释放)。考虑列表 24 中的 C++类。
#include <system_error>
#include <cstdio>
struct File {
File(const char* path, bool write) { ➊
auto file_mode = write ? "w" : "r"; ➋
file_pointer = fopen(path, file_mode); ➌
if (!file_pointer) throw std::system_error(errno, std::system_category()); ➍
}
~File() {
fclose(file_pointer);
}
FILE* file_pointer;
};
列表 24:一个File类
File的构造函数➊接受两个参数。第一个参数与文件的path相对应,第二个参数是一个bool,表示文件模式是应该以写模式(true)还是读模式(false)打开。这个参数的值通过三元运算符?:设置file_mode➋。三元运算符会评估一个布尔表达式,并根据布尔值返回两个值中的一个。例如:
x ? val_if_true : val_if_false
如果布尔表达式x为true,则表达式的值为val_if_true。如果x为false,则值为val_if_false。
在列表 24 中的File构造函数代码片段中,构造函数尝试以读/写访问权限打开位于path的文件 ➌。如果出现任何问题,调用将把file_pointer设置为nullptr,这是 C++中一个类似于 0 的特殊值。当发生这种情况时,你会抛出一个system_error ➍。system_error只是一个封装了系统错误详细信息的对象。如果file_pointer不是nullptr,那么它是有效的。这就是该类的不变量。
现在考虑列表 25 中的程序,它使用了File。
#include <cstdio>
#include <system_error>
#include <cstring>
struct File {
--snip–
};
int main() {
{ ➊
File file("last_message.txt", true); ➋
const auto message = "We apologize for the inconvenience.";
fwrite(message, strlen(message), 1, file.file_pointer);
} ➌
// last_message.txt is closed here!
{
File file("last_message.txt", false); ➍
char read_message[37]{};
fread(read_message, sizeof(read_message), 1, file.file_pointer);
printf("Read last message: %s\n", read_message);
}
}
-----------------------------------------------------------------------
We apologize for the inconvenience.
列表 25:一个使用File类的程序
大括号 ➊ ➌ 定义了一个作用域。因为第一个file位于这个作用域内,作用域定义了file的生命周期。一旦构造函数返回➋,你就知道file.file_pointer是有效的,这要归功于类的不变量;根据File构造函数的设计,你知道file.file_pointer在File对象的生命周期内必须是有效的。你使用fwrite写入消息。无需显式调用fclose,因为file过期,析构函数会为你清理file.file_pointer➌。你再次打开File,但这次是为了读访问 ➍。只要构造函数返回,你就知道last_message.txt已成功打开,并继续读取到read_message中。打印完消息后,file的析构函数被调用,file.file_pointer再次被清理。
有时你需要动态内存分配的灵活性,但仍希望依赖 C++的对象生命周期,以确保不会泄漏内存或不小心出现“使用已释放内存”的问题。这正是智能指针的作用,它通过所有权模型管理动态对象的生命周期。一旦没有智能指针拥有某个动态对象,该对象会被销毁。
其中一个智能指针是unique_ptr,它模拟了独占所有权。列表 26 展示了它的基本用法。
#include <memory>
struct Foundation{
const char* founder;
};
int main() {
std::unique_ptr<Foundation> second_foundation{ new Foundation{} }; ➊
// Access founder member variable just like a pointer:
second_foundation->founder = "Wanda";
} ➋
示例 26:使用unique_ptr的程序
你动态分配了一个Foundation,并使用大括号初始化语法将得到的Foundation*指针传递给second_foundation的构造函数 ➊。second_foundation的类型是unique_ptr,它只是一个 RAII 对象,包装了动态Foundation。当second_foundation被销毁时 ➋,动态Foundation会被适当地销毁。
智能指针与普通的裸指针不同,因为裸指针只是一个内存地址。你必须手动管理与地址相关的所有内存管理工作。另一方面,智能指针处理了所有这些繁琐的细节。通过将动态对象包装在智能指针中,你可以放心,当对象不再需要时,内存会被适当地清理。编译器知道对象不再需要,因为当智能指针超出作用域时,它的析构函数会被调用。
移动语义
有时,你想要转移一个对象的所有权;这在很多情况下都会遇到,例如使用unique_ptr时。你不能复制一个unique_ptr,因为一旦其中一个unique_ptr的副本被销毁,剩下的unique_ptr会持有对已删除对象的引用。与其复制对象,你可以利用 C++的move语义将所有权从一个unique_ptr转移到另一个,如示例 27 所示。
#include <memory>
struct Foundation{
const char* founder;
};
struct Mutant {
// Constructor sets foundation appropriately:
Mutant(std::unique_ptr<Foundation> foundation)
: foundation(std::move(foundation)) {}
std::unique_ptr<Foundation> foundation;
};
int main() {
std::unique_ptr<Foundation> second_foundation{ new Foundation{} }; ➊
// ... use second_foundation
Mutant the_mule{ std::move(second_foundation) }; ➋
// second_foundation is in a 'moved-from' state
// the_mule owns the Foundation
}
示例 27:移动unique_ptr的程序
和之前一样,你创建了unique_ptr<Foundation> ➊。你使用它一段时间后,决定将所有权转移给Mutant对象。move函数告诉编译器你想进行转移。在构造the_mule ➋后,Foundation的生命周期通过它的成员变量与the_mule的生命周期关联。
放松并享受你的鞋子
C++是最优秀的系统编程语言。你在 C 语言中的大部分知识可以直接迁移到 C++中,但你也将学习到许多新概念。你可以通过使用 Super C 逐步将 C++融入到你的 C 程序中。当你掌握 C++的一些深层主题后,你会发现写现代 C++相比 C 带来了许多显著的优势。你将能够用简洁的代码表达思想,利用强大的标准库在更高的抽象层次上工作,使用模板来提高运行时性能和代码重用,并依赖 C++的对象生命周期来管理资源。
我相信你在学习 C++时所做的投资将带来巨大的回报。读完这本书后,我想你会同意这个观点。
第二章:**第一部分
C++ 核心语言**
先爬行,后来我们在碎玻璃上爬行。
—斯科特·迈尔斯,《Effective STL》
第一部分 教你 C++ 核心语言中的关键概念。第一章 设置了工作环境,并引入了一些语言结构,包括对象的基础知识,这是你用来编写 C++ 程序的主要抽象。
接下来的五章将探讨对象和类型——C++ 的核心。与其他一些编程书籍不同,你不会从一开始就建立 Web 服务器或发射火箭。 第一部分 中的所有程序只是输出到命令行。重点是建立你对语言的心理模型,而不是寻求即时满足。
第二章 详细讲解了类型,这一语言结构定义了你的对象。
第三章 在 第二章 的讨论基础上,扩展了对引用类型的讨论,引用类型描述了引用其他对象的对象。
第四章 描述了对象生命周期,这是 C++ 最强大的特性之一。
第五章 和 第六章 探讨了使用模板的编译时多态性和使用接口的运行时多态性,它们使你能够编写松耦合且高度可重用的代码。
拥有 C++ 对象模型的基础知识后,你就可以准备深入学习 第七章 到 第九章。这些章节介绍了表达式、语句和函数,它们是你在语言中完成工作的工具。虽然这些语言结构出现在 第一部分 的最后,可能会让人觉得有些奇怪,但如果没有对对象及其生命周期的深入理解,除了最基础的特性,其他所有这些语言结构都无法理解。
作为一种全面、雄心勃勃且功能强大的语言,C++ 可能会让新手感到不知所措。为了让它更易于接近,第一部分 按照顺序展开,结构紧密,像故事一样进行阅读。
第一部分 是入场券。你在学习 C++ 核心语言中所付出的所有努力,换来的是进入 第二部分 中丰富的库和框架自助餐的资格。
第三章:启动并运行**
. . . 由于如此猛烈的冲击,我摔倒在地,发现自己昏迷不醒,跌入草地下九寻深的坑中。. . . 低头一看,我发现自己穿着一双结实的靴子,带有异常坚固的绑带。我牢牢地抓住它们,用尽全力一再拉扯。
—鲁道夫·拉斯佩,《缪特豪森男爵的独特冒险》

在本章中,你将首先设置一个 C++ 开发环境,这是一个工具集合,使你能够开发 C++软件。你将使用开发环境编译你的第一个 C++ 控制台应用程序,这是一个可以从命令行运行的程序。接下来,你将了解开发环境的主要组件及其在生成你编写的应用程序中的作用。接下来的章节将涵盖足够的 C++基础内容,帮助你构建有用的示例程序。
C++因其学习难度较大而声名显赫。的确,C++是一门庞大、复杂且富有雄心的语言,即便是经验丰富的 C++程序员也经常学习新的模式、特性和用法。
一个主要的细微差别在于,C++特性紧密结合在一起。不幸的是,这常常给新手带来困扰。由于 C++概念之间紧密耦合,初学者很难明确从何处入手。本书的第一部分通过有条理、系统化的方式引导你穿越这些复杂的内容,但它必须从某个地方开始。本章将介绍足够的内容,帮助你入门。不要过于担心细节!
一个基本 C++程序的结构
在这一节中,你将编写一个简单的 C++程序,并进行编译和运行。你将 C++源代码写入人类可读的文本文件中,这些文件称为源文件。然后,使用编译器将你的 C++代码转换为可执行的机器代码,这就是计算机可以运行的程序。
让我们开始吧,创建你的第一个 C++源文件。
创建你的第一个 C++源文件
打开你最喜欢的文本编辑器。如果你还没有偏好的编辑器,可以尝试 Linux 上的 Vim、Emacs 或 gedit;Mac 上的 TextEdit;或者 Windows 上的 Notepad。输入列表 1-1 中的代码,并将文件保存到桌面,命名为main.cpp。
#include <cstdio> ➊
int main➋(){
printf("Hello, world!"); ➌
return 0; ➍
}
--------------------------------------------------------------------------
Hello, world! ➌
列表 1-1:你的第一个 C++程序将Hello, world!输出到屏幕上。
列表 1-1 源文件编译成一个程序,该程序会将字符Hello, world!输出到屏幕上。根据惯例,C++源文件的扩展名为.cpp。
注意
在本书中,代码列表会在程序源代码之后立即展示程序输出;输出部分将以灰色显示。数字标注将与产生输出的行对应。例如,列表 1-1 中的printf语句负责输出Hello, world!,因此它们共享相同的标注 ➌。
主程序:C++程序的起点
如 列表 1-1 所示,C++ 程序有一个单一的入口点,叫做 main 函数 ➋。入口点 是在用户运行程序时执行的函数。函数 是一段代码,它可以接受输入、执行一些指令并返回结果。
在 main 中,你调用了 printf 函数,它将字符 Hello, world! 打印到控制台 ➌。然后程序通过返回退出码 0 给操作系统退出 ➍。退出码 是操作系统用来确定程序运行状况的整数值。通常,退出码 0 表示程序运行成功。其他退出码可能表示出现了问题。在 main 中包含返回语句是可选的;默认情况下,退出码为 0。
printf 函数在程序中没有定义;它在 cstdio 库中 ➊。
库:引入外部代码
库 是可以导入到程序中的有用代码集合,避免重新发明轮子。几乎所有的编程语言都有某种方式将库功能集成到程序中:
-
Python、Go 和 Java 有
import。 -
Rust、PHP 和 C# 有
use/using。 -
JavaScript、Lua、R 和 Perl 有
require/requires。 -
C 和 C++ 有
#include。
列表 1-1 包含了 cstdio ➊,这是一个执行输入/输出操作的库,例如打印到控制台。
编译器工具链
在编写完 C++ 程序的源代码后,下一步是将源代码转化为可执行程序。编译器工具链(或 工具链)是由三个元素组成的集合,它们依次运行,将源代码转换为程序:
-
预处理器 执行基本的源代码处理。例如,
#include <cstdio>➊ 是一个指令,告诉预处理器将cstdio库的相关信息直接包含到程序的源代码中。当预处理器完成源文件的处理后,它会生成一个单一的翻译单元。每个翻译单元会被传递给编译器进行进一步处理。 -
编译器 读取翻译单元并生成 目标文件。目标文件包含一种称为目标代码的中间格式。这些文件包含数据和指令的中间格式,大多数人无法理解。编译器一次处理一个翻译单元,因此每个翻译单元对应一个单独的目标文件。
-
链接器从目标文件生成程序。链接器还负责查找你在源代码中包含的库。例如,当你编译列表 1-1 时,链接器会找到
cstdio库,并包括程序所需的所有内容来使用printf函数。请注意,cstdio头文件与cstdio库是不同的。头文件包含了如何使用该库的信息。你将在第二十一章中进一步了解库和源代码的组织。
设置你的开发环境
所有 C++ 开发环境都包含编辑源代码的方法和将源代码转化为程序的编译器工具链。通常,开发环境还包含一个调试器——一个非常有价值的程序,它允许你逐行跟踪程序,以找到错误。
当所有这些工具——文本编辑器、编译器工具链和调试器——被捆绑到一个程序中时,这个程序被称为集成开发环境(IDE)。对于初学者和老手来说,IDE 都能大大提高生产力。
注意
不幸的是,C++没有一个可以用来交互式执行 C++ 代码片段的解释器。这与其他语言如 Python、Ruby 和 JavaScript 不同,它们有解释器。一些网络应用程序可以让你测试并共享小型 C++ 代码片段。比如 Wandbox(wandbox.org/),它允许你编译并运行代码,以及 Matt Godbolt 的 Compiler Explorer(www.godbolt.org/),它允许你检查代码生成的汇编代码。这两者都支持多种编译器和系统。
每个操作系统都有自己的源代码编辑器和编译器工具链,因此本节按操作系统划分。跳到与你相关的部分。
Windows 10 及更高版本:Visual Studio
截至目前,微软 Windows 上最流行的 C++ 编译器是 Microsoft Visual C++ 编译器(MSVC)。获取 MSVC 的最简单方法是按照以下步骤安装 Visual Studio 2017 IDE:
-
下载 Visual Studio 2017 的社区版。链接可以在https://ccc.codes/找到。
-
运行安装程序,如果需要,允许其进行更新。
-
在安装 Visual Studio 屏幕中,确保选择了C++ 桌面开发工作负载。
-
点击安装以安装 Visual Studio 2017 和 MSVC。
-
点击启动以启动 Visual Studio 2017。整个过程可能需要几小时,具体取决于你的机器速度和所选内容。典型的安装需要 20GB 到 50GB 的空间。
设置一个新项目:
-
选择文件 ▸ 新建 ▸ 项目。
-
在已安装中,点击Visual C++并选择常规。在中间面板中选择空项目。
-
输入 hello 作为项目名称。您的窗口应该像图 1-1 一样,但位置会根据您的用户名有所不同。点击确定。
![图片]()
图 1-1:Visual Studio 2017 新建项目向导
-
在工作区左侧的解决方案资源管理器窗格中,右键点击源文件文件夹并选择添加 ▸ 现有项。请参见图 1-2。
![图片]()
图 1-2:将现有源文件添加到 Visual Studio 2017 项目中
-
选择您之前在清单 1-1 中创建的main.cpp文件。(或者,如果您尚未创建此文件,请选择新建项而不是现有项。将文件命名为main.cpp,并将清单 1-1 中的内容输入到编辑窗口中。)
-
选择生成 ▸ 生成解决方案。如果输出框中出现任何错误信息,请确保您正确输入了清单 1-1。如果仍然收到错误信息,请仔细阅读以寻找提示。
-
选择调试 ▸ 无调试启动,或按 CTRL-F5 来运行您的程序。字母
Hello, world!应该会打印到控制台上(接着会出现Press Any Key to Continue)。
macOS: Xcode
如果您使用的是 macOS,应该安装 Xcode 开发环境。
-
打开App Store。
-
搜索并安装Xcode IDE。根据您的机器和网络连接的速度,安装可能需要超过一小时。安装完成后,打开终端并导航到您保存main.cpp的目录。
-
在终端中输入
clang++ main.cpp -o hello来编译您的程序。-o选项告诉工具链输出结果的位置。(如果出现编译器错误,请检查您是否正确输入了程序。) -
在终端中输入
./hello来运行您的程序。屏幕上应该会显示文本Hello, world!。
要编译并运行程序,请打开 Xcode IDE 并按照以下步骤操作:
-
选择文件 ▸ 新建 ▸ 项目。
-
选择macOS ▸ 命令行工具,然后点击下一步。在下一个对话框中,您可以修改创建项目文件目录的位置。现在接受默认设置并点击创建。
-
将项目命名为 hello,并将其类型设置为C++。请参见图 1-3。
-
现在,您需要将代码从清单 1-1 导入到您的项目中。一种简单的方法是将main.cpp的内容复制并粘贴到您的项目的main.cpp中。另一种方法是使用 Finder 将您的main.cpp替换为项目中的main.cpp。(通常在创建新项目时不需要处理此问题。这只是本教程需要处理多种操作环境的一个产物。)
-
点击运行。

图 1-3:Xcode 中的新建项目对话框
Linux 和 GCC
在 Linux 上,您可以选择两种主要的 C++编译器:GCC 和 Clang。截止目前,最新的稳定版是 9.1,而最新的 Clang 主要版本是 8.0.0。在本节中,您将安装这两种编译器。一些用户发现其中一个的错误信息比另一个更有帮助。
注意
GCC 是 GNU 编译器集合(GNU Compiler Collection)的缩写。GNU 发音为“guh-NEW”,是“GNU’s Not Unix!”的递归缩写。GNU 是一个类 Unix 操作系统和一套计算机软件。
尝试通过操作系统的包管理器安装 GCC 和 Clang,但要小心。您的默认仓库可能包含旧版本,这些版本可能没有 C++ 17 的支持。如果您的版本不支持 C++ 17,您将无法编译书中的某些示例,因此您需要安装更新版本的 GCC 或 Clang。为了简洁起见,本章介绍了如何在 Debian 上以及从源代码进行安装。您可以调查如何在您选择的 Linux 版本上执行类似操作,或者设置与本章列出的操作系统之一的开发环境。
在 Debian 上安装 GCC 和 Clang
根据您在阅读本章节时,个人软件包档案(Personal Package Archives)中包含的软件,您可能能够直接使用 Debian 的高级包工具(APT)安装 GCC 8.1 和 Clang 6.0.0。本节展示了如何在 Ubuntu 18.04(截至本书出版时的最新 LTS 版本)上安装 GCC 和 Clang。
-
打开终端。
-
更新并升级当前安装的软件包:
$ sudo apt update && sudo apt upgrade -
安装 GCC 8 和 Clang 6.0:
$ sudo apt install g++-8 clang-6.0 -
测试 GCC 和 Clang:
$ g++-8 --version g++-8 (Ubuntu 8-20180414-1ubuntu2) 8.0.1 20180414 (experimental) [trunk revision 259383] Copyright (C) 2018 Free Software Foundation, Inc. This is free software; see the source for copying conditions.There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. $ clang++-6.0 --version clang version 6.0.0-1ubuntu2 (tags/RELEASE_600/final) Target: x86_64-pc-linux-gnu Thread model: posix InstalledDir: /usr/bin
如果任何命令返回错误,提示命令未找到,则说明相应的编译器未正确安装。尝试搜索有关您收到的错误信息,尤其是在您的包管理器的文档和论坛中查找相关信息。
从源代码安装 GCC
如果您无法通过包管理器找到最新的 GCC 或 Clang 版本(或您的 Unix 变种没有包管理器),您始终可以从源代码安装 GCC。请注意,这需要很长时间(可能长达几个小时),而且您可能需要动手解决安装过程中出现的错误。这些错误通常需要您自己进行研究来解决。要安装 GCC,请按照gcc.gnu.org/上的说明操作。本节总结了该网站上更为详细的文档。
注意
为了简洁起见,本教程没有详细说明 Clang 的安装。有关更多信息,请参考 clang.llvm.org/ 。
要从源代码安装 GCC 8.1,请执行以下操作:
-
打开终端。
-
更新并升级当前安装的软件包。例如,使用 APT 时,您可以执行以下命令:
$ sudo apt update && sudo apt upgrade -
从
gcc.gnu.org/mirrors.html上的可用镜像站点下载文件 gcc-8.1.0.tar.gz 和 gcc-8.1.0.tar.gz.sig。这些文件可以在 releases/gcc-8.1.0 中找到。 -
(可选)验证包的完整性。首先,导入相关的 GnuPG 密钥。你可以在镜像站点上找到这些密钥。例如:
$ gpg --keyserver keyserver.ubuntu.com --recv C3C45C06 gpg: requesting key C3C45C06 from hkp server keyserver.ubuntu.com gpg: key C3C45C06: public key "Jakub Jelinek <jakub@redhat.com>" imported gpg: key C3C45C06: public key "Jakub Jelinek <jakub@redhat.com>" imported gpg: no ultimately trusted keys found gpg: Total number processed: 2 gpg: imported: 2 (RSA: 1)验证你下载的内容:
$ gpg --verify gcc-8.1.0.tar.gz.sig gcc-8.1.0.tar.gz gpg: Signature made Wed 02 May 2018 06:41:51 AM DST using DSA key ID C3C45C06 gpg: Good signature from "Jakub Jelinek <jakub@redhat.com>" gpg: WARNING: This key is not certified with a trusted signature! gpg: There is no indication that the signature belongs to the owner. Primary key fingerprint: 33C2 35A3 4C46 AA3F FB29 3709 A328 C3A2 C3C4 5C06你看到的警告意味着我没有在我的机器上将签名者的证书标记为可信。为了验证签名确实属于所有者,你需要通过其他方式验证签名密钥(例如,亲自见面或通过其他途径验证主密钥指纹)。有关 GNU 隐私保护(GPG)的更多信息,请参考 Michael W. Lucas 的 PGP & GPG: Email for the Practical Paranoid,或者访问 https://gnupg.org/download/integrity_check.html 获取关于 GPG 完整性检查功能的具体信息。
-
解压缩包(此命令可能需要几分钟):
$ tar xzf gcc-8.1.0.tar.gz -
导航到新创建的 gcc-8.1.0 目录:
$ cd gcc-8.1.0 -
下载 GCC 的先决条件:
$ ./contrib/download_prerequisites --snip-- gmp-6.1.0.tar.bz2: OK mpfr-3.1.4.tar.bz2: OK mpc-1.0.3.tar.gz: OK isl-0.18.tar.bz2: OK All prerequisites downloaded successfully. -
使用以下命令配置 GCC:
$ mkdir objdir $ cd objdir $ ../configure --disable-multilib checking build system type... x86_64-pc-linux-gnu checking host system type... x86_64-pc-linux-gnu --snip-- configure: creating ./config.status config.status: creating Makefile说明文档可以在
gcc.gnu.org/install/configure.html上找到。 -
构建 GCC 二进制文件(可能需要整晚时间,因为这可能需要几个小时):
$ make完整的说明文档可以在
gcc.gnu.org/install/build.html上找到。 -
测试你的 GCC 二进制文件是否正确构建:
$ make -k check完整的说明文档可以在
gcc.gnu.org/install/test.html上找到。 -
安装 GCC:
$ make install此命令将一批二进制文件放入操作系统的默认可执行目录,通常是 /usr/local/bin。完整的说明文档可以在
gcc.gnu.org/install/上找到。 -
通过执行以下命令验证 GCC 是否正确安装:
$ x86_64-pc-linux-gnu-gcc-8.1.0 --version如果你收到一个错误,指示找不到命令,说明你的安装没有成功。请参考 gcc-help 邮件列表,网址是
gcc.gnu.org/ml/gcc-help/。注意
你可能想将繁琐的
x86_64-pc-linux-gnu-gcc-8.1.0别名为类似g++8的简短名称,例如,可以使用以下命令:$ sudo ln -s /usr/local/bin/x86_64-pc-linux-gnu-gcc-8.1.0 /usr/local/bin/g++8 -
导航到你保存 main.cpp 的目录,并使用 GCC 编译你的程序:
$ x86_64-pc-linux-gnu-gcc-8.1.0 main.cpp -o hello -
-o标志是可选的;它告诉编译器输出程序的名称。因为你指定了程序名称为hello,你应该可以通过输入 ./hello 来运行你的程序。如果出现编译错误,确保你正确输入了程序的代码。(编译错误应该能帮助你找出问题所在。)
文本编辑器
如果你不想使用前面提到的 IDE,你可以使用简单的文本编辑器来编写 C++ 代码,比如 Notepad(Windows)、TextEdit(Mac)或 Vim(Linux);不过,也有一些优秀的编辑器是专门为 C++ 开发设计的。选择一个让你最有效率的开发环境。
如果你正在使用 Windows 或 macOS,你已经有了一个高质量、功能齐全的 IDE,即 Visual Studio 或 Xcode。Linux 系统的选择包括 Qt Creator (www.qt.io/ide/), Eclipse CDT (eclipse.org/cdt/), 和 JetBrains 的 CLion (www.jetbrains.com/clion/). 如果你是 Vim 或 Emacs 用户,你会发现有很多 C++ 插件。
注意
如果跨平台 C++ 对你很重要,我强烈推荐你看看 JetBrains 的 CLion。虽然 CLion 是一款付费产品,但与许多竞争对手不同,JetBrains 在发布时确实为学生和开源项目维护者提供了折扣和免费许可证。
引导 C++
本节为你提供了足够的上下文,以支持接下来的章节中的示例代码。你可能会有关于细节的问题,接下来的章节会为你解答。在此之前,不必慌张!
C++ 类型系统
C++ 是一种面向对象的语言。对象是具有状态和行为的抽象。想象一个现实世界中的物体,比如开关。你可以描述它的 状态,例如开关当前的状态。它是开着还是关着?它能承受的最大电压是多少?它在房子的哪个房间?你还可以描述开关的 行为。它是从一种状态(开)切换到另一种状态(关)吗?还是它是一个调光开关,可以在开和关之间设定多个状态?
描述一个对象的行为和状态的集合称为它的 类型。C++ 是一种 强类型语言,意味着每个对象都有一个预定义的数据类型。
C++ 有一种内建的整数类型,叫做 int。一个 int 对象可以存储整数(它的状态),并且支持许多数学运算(它的行为)。
要使用 int 类型执行任何有意义的任务,你需要创建一些 int 对象并命名它们。命名的对象称为 变量。
声明变量
你通过提供变量的类型,然后是变量名,最后加上分号来声明变量。以下示例声明了一个名为 the_answer 的变量,类型为 int:
int➊ the_answer➋;
类型 int ➊ 后面跟着变量名 the_answer ➋。
初始化变量的状态
当你声明变量时,你是在初始化它们。对象初始化 确定了对象的初始状态,例如设置它的值。我们将在第二章中详细讨论初始化的细节。现在,你可以使用等号 (=) 在变量声明后面设置变量的初始值。例如,你可以在一行中声明并赋值 the_answer:
int the_answer = 42;
运行这一行代码后,你将得到一个名为 the_answer 的变量,类型为 int,值为 42。你可以将变量赋值为数学表达式的结果,例如:
int lucky_number = the_answer / 6;
这一行计算表达式 the_answer / 6 并将结果赋值给 lucky_number。int 类型支持许多其他操作,如加法 +、减法 -、乘法 * 和模除运算 %。
注意
如果你不熟悉模除运算,或者想知道当你将两个整数相除并且有余数时会发生什么,说明你提出了很好的问题。这些问题将在第七章中详细解答。
条件语句
条件语句 允许你在程序中做出决策。这些决策依赖于布尔表达式,布尔表达式的值为真或假。例如,你可以使用 比较运算符,例如“大于”或“不等于”,来构建布尔表达式。
一些与 int 类型配合使用的基本比较运算符出现在清单 1-2 中的程序里。
int main() {
int x = 0;
42 == x; // Equality
42 != x; // Inequality
100 > x; // Greater than
123 >= x; // Greater than or equal to
-10 < x; // Less than
-99 <= x; // Less than or equal to
}
清单 1-2:使用比较运算符的程序
该程序没有输出(编译并运行清单 1-2 来验证这一点)。虽然程序没有输出,但编译它有助于验证你编写了有效的 C++ 代码。要生成更有趣的程序,你可以使用条件语句,如 if。
一个 if 语句包含一个布尔表达式和一个或多个嵌套语句。根据布尔表达式的值是为真还是为假,程序决定执行哪个嵌套语句。if 语句有几种形式,但基本用法如下:
if (➊boolean-expression) ➋statement
如果布尔表达式 ➊ 为真,则执行嵌套语句 ➋;否则不执行。
有时候,你会希望一组语句一起执行,而不是单个语句。这样的语句组称为 复合语句。要声明一个复合语句,只需将语句组用大括号 { } 包围即可。你可以在 if 语句中使用复合语句,如下所示:
if (➊boolean-expression) { ➋
statement1;
statement2;
--snip--
}
如果布尔表达式 ➊ 为真,则复合语句 ➋ 中的所有语句都执行;否则,它们都不执行。
你可以通过 else if 和 else 语句来扩展 if 语句。这些可选的附加语句让你可以描述更复杂的分支行为,如清单 1-3 所示。
➊ if (boolean-expression-1) statement-1
➋ else if (boolean-expression-2) statement-2
➌ else statement-3
清单 1-3:带有 else if 和 else 分支的 if 语句
首先,布尔表达式 ➊ 会被求值。如果布尔表达式 ➊ 为真,则求值语句 1,if 语句停止执行。如果布尔表达式 ➊ 为假,则布尔表达式 ➋ 被求值。如果为真,则求值语句 2。否则,求值语句 3。请注意,语句 1、语句 2 和语句 3 是互斥的,它们共同覆盖了 if 语句的所有可能结果。三者中只会执行一个。
你可以包含任何数量的else if子句,也可以完全省略它们。与最初的if语句一样,评估每个else if子句时的布尔表达式按顺序进行。当某个布尔表达式评估为true时,评估停止,执行相应的语句。如果没有else if表达式评估为true,则else子句中的语句-3总是执行。(与else if子句一样,else也是可选的。)
考虑示例 1-4,它使用if语句来确定打印哪一条语句。
#include <cstdio>
int main() {
int x = 0; ➊
if (x > 0) printf("Positive.");
else if (x < 0) printf("Negative.");
else printf("Zero.");
}
-----------------------------------------------------------------------
Zero.
示例 1-4:一个具有条件行为的程序
编译程序并运行。你的结果应该是Zero。现在改变x的值 ➊。程序现在打印什么?
注意
注意,示例 1-4 中的main函数省略了返回语句。由于main是一个特殊函数,返回语句是可选的。
函数
函数是接受任意数量输入对象的代码块,这些输入对象被称为参数或实参,并且可以向调用者返回输出对象。
你按照示例 1-5 中展示的一般语法声明函数。
return-type➊ function_name➋(par-type1 par_name1➌, par-type2 par_name2➍) {
--snip--
return➎ return-value;
}
示例 1-5:C++ 函数的一般语法
该函数声明的第一部分是返回变量的类型 ➊,例如int。当函数返回一个值 ➎ 时,return-value的类型必须与return-type匹配。
然后,在声明返回类型之后,声明函数的名称 ➋。紧跟在函数名称后的圆括号中包含了函数所需的任何数量的以逗号分隔的输入参数。每个参数也都有类型和名称。
示例 1-5 有两个参数。第一个参数 ➌ 的类型是par-type1,名称为par_name1,第二个参数 ➍ 的类型是par-type2,名称为par_name2。参数表示传递给函数的对象。
紧随其后的花括号包含函数的主体。这是一个复合语句,包含函数的逻辑。在该逻辑中,函数可能会决定向调用者返回一个值。返回值的函数会有一个或多个return语句。一旦函数返回,执行停止,程序的控制流返回到调用该函数的地方。让我们看一个例子。
示例:阶跃函数
为了演示,本节展示了如何构建一个名为step_function的数学函数,该函数对于所有负数参数返回-1,对于零值参数返回0,对于所有正数参数返回1。示例 1-6 展示了你如何编写step_function。
int step_function(int ➊x) {
int result = 0; ➋
if (x < 0) {
result = -1; ➌
} else if (x > 0) {
result = 1; ➍
}
return result; ➎
}
示例 1-6:一个阶跃函数,对于负值返回 -1,对于零返回 0,对于正值返回 1
step_function接受一个单一的参数x ➊。result变量被声明并初始化为0 ➋。接下来,if语句会将result设置为-1 ➌,如果x小于0。如果x大于0,if语句会将result设置为1 ➍。最后,result被返回给调用者 ➎。
调用函数
要调用(或调用)一个函数,你需要使用所需函数的名称、括号和以逗号分隔的所需参数列表。编译器按从上到下的顺序读取文件,因此函数的声明必须出现在第一次使用它之前。
考虑清单 1-7 中的程序,它使用了step_function。
int step_function(int x) {
--snip--
}
int main() {
int value1 = step_function(100); // value1 is 1
int value2 = step_function(0); // value2 is 0
int value3 = step_function(-10); // value3 is -1
}
清单 1-7:使用step_function的程序。(该程序没有输出。)
清单 1-7 调用step_function三次,传入不同的参数,并将结果赋给value1、value2和value3变量。
如果你能打印这些值,那该有多好呢?幸运的是,你可以使用printf函数通过不同的变量构建输出。诀窍在于使用printf格式说明符。
printf 格式说明符
除了打印常量字符串(如清单 1-1 中的Hello, world!),printf还可以将多个值组合成格式良好的字符串;它是一种特殊的函数,可以接受一个或多个参数。
printf的第一个参数始终是格式字符串。格式字符串为要打印的字符串提供了模板,并且包含任意数量的特殊格式说明符。格式说明符告诉printf如何解释和格式化跟随在格式字符串后的参数。所有格式说明符都以%开头。
例如,int的格式说明符是%d。每当printf在格式字符串中看到%d时,它就知道格式说明符后面需要一个int类型的参数。然后,printf会用参数的实际值替换格式说明符。
注意
printf函数是writef函数的衍生版,writef函数最初出现在 BCPL 中,这是一种已废弃的编程语言,由 Martin Richards 于 1967 年设计。向writef提供%H、%I和%O说明符会通过WRITEHEX、WRITED和WRITEOCT函数输出十六进制、十进制和八进制。%d说明符的来源尚不清楚(也许来自WRITED中的 D?),但我们只能使用它。
考虑以下printf调用,它打印字符串Ten 10, Twenty 20, Thirty 30:
printf("Ten %d➊, Twenty %d➋, Thirty %d➌", 10➍, 20➎, 30➏);
第一个参数"Ten %d, Twenty %d, Thirty %d"是格式字符串。请注意,这里有三个格式说明符%d ➊ ➋ ➌。格式字符串后面也有三个参数 ➍ ➎ ➏。当printf构建输出时,它会将位置➊的参数替换为位置➍的参数,将位置➋的参数替换为位置➎的参数,将位置➌的参数替换为位置➏的参数。
IOSTREAMS、PRINTF 和输入输出教学法
人们对教 C++新手使用哪种标准输出方法有非常强烈的意见。一个选择是printf,它的历史可以追溯到 C 语言。另一个选择是cout,它是 C++标准库中iostream库的一部分。本书教授两者:在第一部分中讲解printf,在第二部分中讲解cout。原因如下。
这本书是通过一点一点地建立你的 C++知识的。每一章都是按顺序设计的,因此你无需凭空猜测就能理解代码示例。或多或少,你会清楚每一行代码的作用。因为printf相当原始,到了第三章,你会有足够的知识来准确了解它是如何工作的。
相比之下,cout涉及大量的 C++概念,直到第一部分的结尾,你才有足够的背景知识理解它是如何工作的。(什么是流缓冲区?什么是operator<<?什么是方法?flush()是如何工作的?等一下,cout在析构函数中自动刷新?什么是析构函数?什么是setf?实际上,什么是格式标志?BitmaskType是什么?天哪,什么是操控器?等等。)
当然,printf有一些问题,一旦你学会了cout,你应该更倾向于使用它。使用printf时,容易在格式说明符和参数之间引入不匹配,这可能导致奇怪的行为、程序崩溃,甚至安全漏洞。使用cout意味着你不需要格式化字符串,因此无需记住格式说明符。你再也不会遇到格式字符串和参数之间的不匹配。I/O 流也是可扩展的,这意味着你可以将输入和输出功能集成到你自己的类型中。
本书直接教授现代 C++,但在这一特定话题上,它有意稍微妥协了一些现代主义的教条,以采取一种有条理的线性方法。作为附带的好处,你将为遇到printf说明符做好准备,而这在你的编程生涯中很可能会发生。大多数语言,如 C、Python、Java 和 Ruby,都有printf说明符的功能,而 C#、JavaScript 等语言中也有类似的功能。
重新审视 step_function
让我们看另一个使用step_function的例子。清单 1-8 包含了变量声明、函数调用和printf格式说明符。
#include <cstdio> ➊
int step_function(int x) { ➋
--snip--
}
int main() { ➌
int num1 = 42; ➍
int result1 = step_function(num1); ➎
int num2 = 0;
int result2 = step_function(num2);
int num3 = -32767;
int result3 = step_function(num3);
printf("Num1: %d, Step: %d\n", num1, result1); ➏
printf("Num2: %d, Step: %d\n", num2, result2);
printf("Num3: %d, Step: %d\n", num3, result3);
return 0;
}
--------------------------------------------------------------------------
Num1: 42, Step: 1 ➏
Num2: 0, Step: 0
Num3: -32767, Step: -1
清单 1-8:一个应用step_function处理多个整数并打印结果的程序
由于程序使用了printf,因此包含了cstdio ➊。step_function ➋已经定义好,可以在程序后续使用,而main ➌则定义了程序的入口点。
注意
本书中的某些清单将相互依赖。为了节省纸张,你将看到使用--snip--符号来表示重复部分没有更改。
在main函数中,你初始化了一些int类型的变量,比如num1 ➍。接着,你将这些变量传递给step_function,并初始化结果变量来存储返回的值,比如result1 ➎。
最后,您通过调用 printf 打印返回的值。每次调用都以格式字符串开始,例如 "Num1: %d, Step: %d\n" ➏。每个格式字符串中都嵌入了两个 %d 格式说明符。根据 printf 的要求,格式字符串后面有两个参数,num1 和 result1,它们分别对应这两个格式说明符。
注释
注释 是人类可读的注释,您可以将其添加到源代码中。您可以使用 // 或 /**/ 符号来添加注释。// 符号告诉编译器忽略从第一个斜杠到下一个换行符之间的所有内容,这意味着您可以将注释嵌入到代码行内或单独放在新的一行:
// This comment is on its own line
int the_answer = 42; // This is an in-line comment
您可以使用 /**/ 符号在代码中包含多行注释:
/*
* This is a comment
* That lives on multiple lines
* Don’t forget to close
*/
注释以 /* 开始,以 */ 结束。(起始和结束斜杠之间的星号是可选的,但通常会使用它们。)
何时使用注释是一个永恒的争论话题。一些编程界的名人建议,代码应当具有足够的表现力和自解释性,以至于注释几乎不再必要。他们可能会说,描述性的变量名、简短的函数和良好的测试通常就是您所需的所有文档。其他程序员则喜欢到处放置注释。
您可以培养自己的哲学。编译器会完全忽略您所做的任何事情,因为它从不解释注释。
调试
软件工程师最重要的技能之一就是高效、有效的调试。大多数开发环境都有调试工具。在 Windows、macOS 和 Linux 上,调试工具都非常优秀。学会好好使用它们是一项快速回报的投资。本节提供了如何使用调试器逐步执行程序的快速指南,您可以参考清单 1-8。您可以跳到最相关的环境。
Visual Studio
Visual Studio 拥有一款优秀的内置调试器。我建议您在 Debug 配置下调试程序。这会导致工具链构建一个增强调试体验的目标。唯一需要在 Release 模式下调试的原因是诊断一些仅在 Release 模式下发生,而在 Debug 模式下不会发生的罕见情况。
-
打开 main.cpp 并找到
main的第一行。 -
单击
main的第一行对应的行号左侧的边距以插入断点。您点击的位置会出现一个红色圆圈,如图 1-4 所示。![image]()
图 1-4:插入断点
-
选择 调试 ▸ 开始调试。程序将运行到您插入断点的那一行。调试器将暂停程序执行,并出现一个黄色箭头指示下一条将要执行的指令,如图 1-5 所示。
![image]()
图 1-5:调试器在断点处暂停执行。
-
选择调试 ▸ 步过。步过操作执行指令而不“进入”任何函数调用。默认情况下,步过的快捷键是 F10。
-
因为下一行调用了
step_function,选择调试 ▸ 步入来调用step_function并在第一行处中断。你可以通过进入/跳过它的指令继续调试这个函数。默认情况下,步入的快捷键是 F11。 -
要使执行返回到
main,请选择调试 ▸ 步出。默认情况下,这个操作的快捷键是 SHIFT-F11。 -
通过选择调试 ▸ 窗口 ▸ Autos来检查 Autos 窗口。你可以看到一些重要变量的当前值,如图 1-6 所示。
![image]()
图 1-6:Autos 窗口显示当前断点处变量的值。
你可以看到
num1被设置为 42,result1被设置为 1。为什么num2有一个乱码值?因为初始化为 0 的操作还没有发生:它是下一个要执行的指令。
注意
调试器刚刚强调了一个非常重要的低级细节:分配对象的存储和初始化对象的值是两个不同的步骤。你将在第四章中了解更多关于存储分配和对象初始化的内容。
Visual Studio 调试器支持更多的功能。欲了解更多信息,请查看Visual Studio 文档链接:ccc.codes/。
Xcode
Xcode 也有一个优秀的内置调试器,完全集成到 IDE 中。
-
打开main.cpp并定位到
main的第一行。 -
点击第一行,然后选择调试 ▸ 断点 ▸ 在当前行添加断点。断点出现,如图 1-7 所示。
![image]()
图 1-7:插入断点
-
选择运行。程序将运行到插入的断点所在的行。调试器将暂停程序执行,出现一个绿色箭头表示下一个要执行的指令,如图 1-8 所示。
![image]()
图 1-8:调试器在断点处暂停执行。
-
选择调试 ▸ 步过来执行指令,而不“进入”任何函数调用。默认情况下,步过的快捷键是 F6。
-
因为下一行调用了
step_function,选择调试 ▸ 步入来调用step_function并在第一行处中断。你可以通过进入/跳过它的指令继续调试这个函数。默认情况下,步入的快捷键是 F7。 -
要使执行返回到
main,请选择调试 ▸ 步出。默认情况下,步出的快捷键是 F8。 -
在main.cpp屏幕底部检查 Autos 窗口。你可以看到一些重要变量的当前值,如图 1-9 所示。
![image]()
图 1-9:自动窗口显示当前断点处变量的值。
你可以看到
num1被设置为 42,result1被设置为 1。为什么num2有一个乱码值?因为初始化为 0 的操作还没有发生:它是下一个要执行的指令。
Xcode 调试器支持更多功能。欲了解更多信息,请查看 Xcode 文档链接 ccc.codes/。
使用 GDB 和 LLDB 调试 GCC 和 Clang
GNU 项目调试器(GDB)是一个强大的调试器(www.gnu.org/software/gdb/)。你可以使用命令行与 GDB 交互。要在编译时启用调试支持,使用 g++ 或 clang++ 编译时,必须添加 -g 标志。
你的包管理器很可能已经包含 GDB。例如,要使用高级包工具(APT)安装 GDB,可以输入以下命令:
$ sudo apt install gdb
Clang 还拥有一个出色的调试器,称为低级调试器(LLDB),你可以在 lldb.llvm.org/ 下载。它被设计用来与本节中的 GDB 命令一起使用,因此为了简洁起见,我不会详细介绍 LLDB。你可以使用 LLDB 调试使用 GCC 调试支持编译的程序,也可以使用 GDB 调试使用 Clang 调试支持编译的程序。
注意
Xcode 在后台使用 LLDB。
要使用 GDB 调试 Listing 1-8(在 page 20 页)中的程序,按照以下步骤操作:
-
在命令行中,导航到你存储头文件和源文件的文件夹。
-
使用调试支持编译程序:
$ g++-8 main.cpp -o stepfun -g -
使用
gdb调试程序,你应该会看到以下交互式控制台会话:$ gdb stepfun GNU gdb (Ubuntu 7.7.1-0ubuntu5~14.04.2) 7.7.1 Copyright (C) 2014 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl. html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "x86_64-linux-gnu". Type "show configuration" for configuration details. For bug reporting instructions, please see: <http://www.gnu.org/software/gdb/bugs/>. Find the GDB manual and other documentation resources online at: <http://www.gnu.org/software/gdb/documentation/>. For help, type "help". Type "apropos word" to search for commands related to "word"... Reading symbols from stepfun...done. (gdb) -
要插入断点,可以使用
break命令,它接受一个参数,该参数是源文件的名称和你想要断点的行号,两者之间用冒号(:)分隔。例如,假设你想要在 main.cpp 的第一行设置断点。在 Listing 1-8 中,这在第 5 行(虽然你可能需要根据你的源代码位置调整)。你可以在(gdb)提示符下使用以下命令设置断点:(gdb) break main.cpp:5 -
你还可以告诉
gdb在特定函数处按名称设置断点:(gdb) break main -
无论哪种方式,你现在都可以执行你的程序:
(gdb) run Starting program: /home/josh/stepfun Breakpoint 1, main () at main.cpp:5 5 int num1 = 42; (gdb) -
要单步执行一条指令,可以使用
step命令逐行跟踪程序的执行,包括进入函数:(gdb) step 6 int result1 = step_function(num1); -
要继续单步执行,按 ENTER 重复上一个命令:
(gdb) step_function (x=42) at step_function.cpp:4 -
要从函数调用中回退出来,可以使用
finish命令:(gdb) finish Run till exit from #0 step_function (x=42) at step_function.cpp:7 0x0000000000400546 in main () at main.cpp:6 6 int result1 = step_function(num1); Value returned is $1 = 1 -
要执行一条指令而不进入函数调用,可以使用
next命令:(gdb) next 8 int num2 = 0; -
要检查当前变量的值,可以使用
info locals命令:(gdb) info locals num2 = -648029488 result2 = 32767 num1 = 42 result1 = 1 num3 = 0 result3 = 0请注意,任何尚未初始化的变量将没有合理的值。
-
要继续执行直到下一个断点(或者程序完成),可以使用
continue命令:(gdb) continue Continuing. Num1: 42, Step: 1 Num2: 0, Step: 0 Num3: -32768, Step: -1 [Inferior 1 (process 1322) exited normally] -
使用
quit命令可以随时退出gdb。
GDB 支持更多功能。有关详细信息,请查阅文档:https://sourceware.org/gdb/current/onlinedocs/gdb/。
总结
本章带你搭建了一个有效的 C++开发环境,并编译了你的第一个 C++程序。你了解了构建工具链的组成部分以及它们在编译过程中的作用。接着,你探索了一些 C++的基本主题,如数据类型、声明变量、语句、条件语句、函数和printf。本章最后通过一个教程指导你设置调试器,并逐步调试你的项目。
注意
如果你在设置环境时遇到问题,可以在线搜索错误信息。如果仍然无法解决,可以将问题发布到 Stack Overflow:stackoverflow.com/,C++子版块:www.reddit.com/r/cpp_questions/,或 C++ Slack 频道:cpplang.now.sh/。
练习
尝试这些练习来巩固本章所学内容。(本书的配套代码可在ccc.codes获取。)
1-1. 创建一个名为absolute_value的函数,它返回其单个参数的绝对值。整数x的绝对值定义为:如果x大于或等于 0,则返回x本身;否则,返回x乘以−1。你可以使用清单 1-9 中的程序作为模板:
#include <cstdio>
int absolute_value(int x) {
// Your code here
}
int main() {
int my_num = -10;
printf("The absolute value of %d is %d.\n", my_num,
absolute_value(my_num));
}
清单 1-9:使用absolute_value函数的程序模板
1-2. 尝试使用不同的值运行程序。你是否看到了预期的结果?
1-3. 使用调试器运行程序,逐步执行每条指令。
1-4. 编写另一个名为sum的函数,它接受两个int类型的参数并返回它们的和。你如何修改清单 1-9 中的模板来测试你的新函数?
1-5. C++拥有一个活跃的在线社区,互联网上充满了许多优秀的 C++相关资源。可以查看 CppCast 播客:http://cppcast.com/。搜索 CppCon 和 C++Now 的 YouTube 视频。将https://cppreference.com/和http://www.cplusplus.com/添加到浏览器的书签中。
1-6. 最后,从https://isocpp.org/std/the-standard/下载国际标准化组织(ISO)C++ 17 标准的副本。不幸的是,官方 ISO 标准是有版权的,需要购买。幸运的是,你可以免费下载“草案”版本,它与正式版的唯一区别是外观上的。
注意 由于 ISO 标准的页码会因版本不同而有所差异,本书将使用与标准本身相同的命名规则来引用特定章节。该命名规则通过将章节名称用方括号括起来来引用章节。子章节则通过句点分隔。例如,要引用包含在引言部分的 C++对象模型章节,应写作 [intro.object]。
进一步阅读
-
实用程序员:从学徒到大师 由 Andrew Hunt 和 David Thomas 编写(Addison-Wesley Professional, 2000)
-
使用 GDB、DDD 和 Eclipse 进行调试艺术 由 Norman Matloff 和 Peter Jay Salzman 编写(No Starch Press, 2008)
-
PGP 与 GPG: 实用偏执者的电子邮件 由 Michael W. Lucas 编写(No Starch Press, 2006)
-
GNU Make 书籍 由 John Graham-Cumming 编写(No Starch Press, 2015)
第四章:类型
*哈丁曾说过:“成功仅靠计划是不够的,还必须即兴发挥。”我会即兴发挥。
—艾萨克·阿西莫夫,《基地》

如第一章所述,类型声明了编译器如何解释和使用一个对象。在 C++程序中,每个对象都有一个类型。本章首先对基本类型进行详细讨论,然后介绍用户自定义类型。在这个过程中,你将学习到几种控制流结构。
基本类型
基本类型是最基本的对象类型,包括整数、浮点数、字符、布尔值、byte、size_t和void。有些人称这些基本类型为原始类型或内建类型,因为它们是核心语言的一部分,几乎总是可以使用。这些类型可以在任何平台上使用,但它们的特性,如大小和内存布局,取决于实现。
基本类型在两者之间取得了平衡。一方面,它们尝试映射 C++构造与计算机硬件之间的直接关系;另一方面,它们通过允许程序员编写一次代码并在多个平台上运行,从而简化了跨平台编程。接下来的部分将进一步介绍这些基本类型的细节。
整型类型
整型类型存储整数:即那些没有小数部分的数字。整型的四种大小是short int、int、long int和long long int。每种类型可以是有符号或无符号的。有符号变量可以是正数、负数或零,而无符号变量必须是非负数。
整型类型默认是有符号的int,这意味着你可以在程序中使用以下简写:short、long和long long,而不是short int、long int和long long int。表 2-1 列出了所有可用的 C++整型类型,指出每种类型是有符号还是无符号,跨平台时每种类型的大小(以字节为单位),以及每种类型的格式说明符。
表 2-1: 整型类型、大小和格式说明符
| 类型 | 有符号 | 字节大小 | printf 格式说明符 |
|---|---|---|---|
| 32 位操作系统 | 64 位操作系统 | ||
| Windows | Linux/Mac | Windows | Linux/Mac |
Short |
是 | 2 | 2 |
unsigned short |
否 | 2 | 2 |
int |
是 | 4 | 4 |
unsigned int |
否 | 4 | 4 |
long |
是 | 4 | 4 |
unsigned long |
否 | 4 | 4 |
long long |
是 | 8 | 8 |
unsigned long long |
否 | 8 | 8 |
注意,整型类型的大小在不同平台上有所不同:64 位的 Windows 和 Linux/Mac 对于long整数的大小不同(分别是 4 和 8)。
通常,编译器会警告格式说明符和整数类型之间的不匹配。但在使用 printf 语句时,你必须确保格式说明符是正确的。这里列出格式说明符,以便在后续的示例中可以将整数打印到控制台。
注意
如果你想强制使用保证的整数大小,可以使用 <cstdint> 库中的整数类型。例如,如果你需要一个恰好为 8、16、32 或 64 位的有符号整数,你可以使用 int8_t、int16_t、int32_t 或 int64_t。你可以找到符合要求的最快、最小、最大、有符号和无符号整数类型。但由于并非每个平台都可以使用这个头文件,所以你应仅在没有其他替代方案时使用 cstdint 类型。
字面量 是程序中的硬编码值。你可以使用四种硬编码的 整数 字面量 表示法:
二进制 使用前缀 0b
八进制 使用前缀 0
十进制 这是默认值
十六进制 使用前缀 0x
这四种不同的表示法都用于表示相同的整数集合。例如,列表 2-1 展示了如何使用每种非十进制表示法为多个整数变量赋值,并使用整数字面量。
#include <cstdio>
int main() {
unsigned short a = 0b10101010; ➊
printf("%hu\n", a);
int b = 0123; ➋
printf("%d\n", b);
unsigned long long d = 0xFFFFFFFFFFFFFFFF; ➌
printf("%llu\n", d);
}
--------------------------------------------------------------------------
170 ➊
83 ➋
18446744073709551615 ➌
列表 2-1:一个分配多个整数变量并用适当格式说明符打印它们的程序
该程序使用每种非十进制整数表示法(如二进制 ➊、八进制 ➋ 和十六进制 ➌),并通过 printf 打印每个值,使用适当的格式说明符,具体见表 2-1。每个 printf 的输出会作为以下注释出现。
注意
整数字面量可以包含任意数量的单引号('),以提高可读性。这些单引号会被编译器完全忽略。例如,1000000 和 1'000'000 都是等于一百万的整数字面量。
有时,打印无符号整数的十六进制表示法或(很少)八进制表示法是有用的。你可以分别使用 printf 格式说明符 %x 和 %o,如列表 2-2 所示。
#include <cstdio>
int main() {
unsigned int a = 3669732608;
printf("Yabba %x➊!\n", a);
unsigned int b = 69;
printf("There are %u➋,%o➌ leaves here.\n", b➍, b➎);
}
--------------------------------------------------------------------------
Yabba dabbad00➊!
There are 69➋,105➌ leaves here.
列表 2-2:一个使用无符号整数的八进制和十六进制表示法的程序
十进制 3669732608 的十六进制表示为 dabbad00,它出现在输出的第一行,作为十六进制格式说明符 %x ➊ 的结果。十进制的 69 在八进制中是 105。无符号整数格式说明符 %u ➋ 和八进制整数格式说明符 %o ➌ 分别对应于 ➍ 和 ➎ 的参数。printf 语句将这些数值 ➋➌ 替换到格式字符串中,输出信息为 这里有 69,105 片叶子。
警告
八进制前缀源自 B 语言,当时 PDP-8 计算机和八进制字面量非常普遍。C 语言及其扩展 C++延续了这个可疑的传统。你必须小心,例如在硬编码邮政编码时:
int mit_zip_code = 02139; // Won't compile
去掉十进制字面量中的前导零,否则它们将不再是十进制数。这行代码无法编译,因为 9 不是八进制数字。
默认情况下,整数字面量的类型是以下之一:int、long 或 long long。整数字面量的类型是这三种类型中最小的那一个。(这是由语言定义的,并会由编译器强制执行。)
如果你想要更多控制,可以为整数字面量提供后缀来指定其类型(后缀不区分大小写,因此你可以选择自己喜欢的样式):
-
unsigned后缀u或U -
long后缀l或L -
long long后缀ll或LL
你可以将 unsigned 后缀与 long 或 long long 后缀结合使用,以指定符号性和大小。表 2-2 显示了后缀组合可能的类型。允许的类型用勾号(✓)表示。对于二进制、八进制和十六进制字面量,你可以省略 u 或 U 后缀。这些用星号(*)表示。
表 2-2: 整数后缀
| 类型 | (无) | l/L | ll/LL | u/U | ul/UL | ull/ULL |
|---|---|---|---|---|---|---|
int |
✓ | |||||
long |
✓ | ✓ | ||||
long long |
✓ | ✓ | ✓ | |||
unsigned int |
* | ✓ | ||||
unsigned long |
* | * | ✓ | ✓ | ||
unsigned long long |
* | * | * | ✓ | ✓ | ✓ |
最小允许的类型仍然适应整数字面量的类型即为结果类型。这意味着,在所有允许的类型中,最小的类型将适用。例如,整数字面量 112114 可以是 int、long 或 long long。由于 int 可以存储 112114,因此结果类型是 int。如果你真的希望使用 long,你可以指定 112114L(或 112114l)。
浮点类型
浮点类型存储的是实数的近似值(在我们的定义中,实数可以是任何有小数点和分数部分的数字,例如 0.33333 或 98.6)。虽然无法在计算机内存中精确表示任意实数,但可以存储一个近似值。如果这让你难以相信,只需想一想像 π 这样的数字,它有无限多的位数。考虑到计算机内存是有限的,你怎么可能表示无限多的位数呢?
与所有类型一样,浮点类型占用有限的内存,这被称为类型的精度。浮点类型的精度越高,近似实数时就越准确。C++ 提供了三种精度级别的近似:
**float** 单精度
**double** 双精度
**long double** 扩展精度
与整数类型一样,每种浮点数表示方式都依赖于实现。此部分不会详细讨论浮点类型,但请注意,这些实现中有许多细微差别。
在主要的桌面操作系统中,float类型通常有 4 字节的精度。double和long double类型通常有 8 字节的精度(双精度)。
大多数不涉及科学计算应用的用户可以放心忽略浮点表示的细节。在这种情况下,一个好的通用规则是使用double。
注意
对于那些无法忽略细节的用户,请查看与你的硬件平台相关的浮点规格。浮点存储和算术的主要实现概述在 IEEE 浮点运算标准 IEEE 754 中。
浮点字面量
浮点字面量默认是双精度。如果需要单精度,使用f或F后缀;要使用扩展精度,使用l或L后缀,如下所示:
float a = 0.1F;
double b = 0.2;
long double c = 0.3L;
你还可以在字面量中使用科学计数法:
double plancks_constant = 6.62607004➊e-34➋;
不允许在有效数字(基数➊)和后缀(指数部分➋)之间有空格。
浮点格式说明符
格式说明符%f显示带有小数位的float,而%e则以科学计数法显示相同的数字。你可以让printf决定使用这两者中的哪一个,使用%g格式说明符,它选择%e或%f中更紧凑的一个。
对于double,只需在所需的说明符前添加l(小写L);对于long double,则添加L。例如,如果你想要一个带有小数位的double,你可以指定%lf、%le或%lg;对于long double,则指定%Lf、%Le或%Lg。
请参见列出 2-3,该示例探讨了打印浮点数的不同选项。
#include <cstdio>
int main() {
double an = 6.0221409e23; ➊
printf("Avogadro's Number: %le➋ %lf➌ %lg➍\n", an, an, an);
float hp = 9.75; ➎
printf("Hogwarts' Platform: %e %f %g\n", hp, hp, hp);
}
--------------------------------------------------------------------------
Avogadro's Number: 6.022141e+23➋ 602214090000000006225920.000000➌
6.02214e+23➍
Hogwarts' Platform: 9.750000e+00 9.750000 9.75
列出 2-3:一个打印多个浮点数的程序
本程序声明了一个名为an的double ➊。格式说明符%le ➋ 给出科学计数法6.022141e-23,而%lf ➌ 给出了十进制表示602214090000000006225920.000000。%lg ➍ 说明符选择了科学计数法6.02214e-23。名为hp的float ➎ 使用%e和%f说明符产生类似的printf输出。但格式说明符%g决定提供十进制表示 9.75,而不是科学计数法。
一般来说,使用%g来打印浮点类型。
注意
实际上,你可以省略double格式说明符前的l前缀,因为printf会将float类型的参数提升为double精度。
字符类型
字符类型用于存储人类语言数据。六种字符类型包括:
**char** 默认类型,始终为 1 字节。可能是有符号也可能是无符号。(示例:ASCII。)
**char16_t** 用于 2 字节字符集。(示例:UTF-16。)
**char32_t** 用于 4 字节字符集。(示例:UTF-32。)
**signed char** 与char相同,但保证为有符号。
**unsigned char** 与char相同,但保证为无符号。
**wchar_t** 大到足以容纳实现区域中最大字符。(示例:Unicode。)
char、signed char 和 unsigned char 被称为窄字符,而 char16_t、char32_t 和 wchar_t 被称为宽字符,这是由于它们相对的存储需求。
字符常量
字符常量 是单个常量字符。所有字符都被单引号(' ')括起来。如果字符不是 char 类型,你还必须提供一个前缀:L 表示 wchar_t,u 表示 char16_t,U 表示 char32_t。例如,'J' 声明了一个 char 常量,而 L'J' 声明了一个 wchar_t 常量。
转义序列
有些字符不会在屏幕上显示。相反,它们会强制显示执行一些操作,比如将光标移动到屏幕左侧(回车符)或将光标向下移动一行(换行符)。其他字符虽然会显示在屏幕上,但它们是 C++ 语言语法的一部分,如单引号或双引号,因此你必须非常小心地使用它们。为了将这些字符放入 char 中,你可以使用 转义序列,如 表 2-3 中所列。
表 2-3: 保留字符及其转义序列
| 值 | 转义序列 |
|---|---|
| 换行符 | \n |
| 水平制表符 | \t |
| 垂直制表符 | \v |
| 退格符 | \b |
| 回车符 | \r |
| 换页符 | \f |
| 响铃符 | \a |
| 反斜杠 | \\ |
| 问号 | ? 或 \? |
| 单引号 | \' |
| 双引号 | \" |
| 空字符 | \0 |
Unicode 转义字符
你可以使用 通用字符名称 来指定 Unicode 字符常量,并且你可以通过两种方式来形成通用字符名称:前缀 \u 后跟 4 位十六进制 Unicode 码点,或者前缀 \U 后跟 8 位十六进制 Unicode 码点。例如,你可以将字符 A 表示为 '\u0041',将啤酒杯字符
表示为 U'\U0001F37A'。
格式说明符
char 的 printf 格式说明符是 %c。wchar_t 的格式说明符是 %lc。
列表 2-4 初始化了两个字符常量 x 和 y。你可以使用这些变量来构建 printf 调用。
#include <cstdio>
int main() {
char x = 'M';
wchar_t y = L'Z';
printf("Windows binaries start with %c%lc.\n", x, y);
}
--------------------------------------------------------------------------
Windows binaries start with MZ.
列表 2-4:一个程序,赋值给多个字符类型的变量并打印它们
该程序输出 Windows 二进制文件以 MZ 开头。尽管 M 是窄字符 char,而 Z 是宽字符,但 printf 可以正常工作,因为程序使用了正确的格式说明符。
注意
所有 Windows 二进制文件的前两个字节是字符 M 和 Z,这是对 MS-DOS 可执行文件格式设计者 Mark Zbikowski 的致敬。
布尔类型
布尔类型有两种状态:真(true)和假(false)。唯一的布尔类型是 bool。整数类型和 bool 类型可以方便地进行转换:true 状态转换为 1,false 转换为 0。任何非零整数都转换为 true,0 转换为 false。
布尔常量
要初始化布尔类型,你可以使用两个布尔常量:true 和 false。
格式说明符
bool 类型没有格式说明符,但你可以在 printf 中使用 int 格式说明符 %d 来输出 true 为 1,false 为 0。原因是 printf 会将任何小于 int 的整型值提升为 int。列表 2-5 展示了如何声明一个布尔变量并检查其值。
#include <cstdio>
int main() {
bool b1 = true; ➊ // b1 is true
bool b2 = false; ➋ // b2 is false
printf("%d %d\n", b1, b2); ➌
}
--------------------------------------------------------------------------
1 0 ➌
列表 2-5:使用 printf 语句打印 bool 变量
你将 b1 初始化为 true ➊,将 b2 初始化为 false ➋。通过将 b1 和 b2 作为整数打印(使用 %d 格式说明符),你会得到 b1 为 1,b2 为 0 ➌。
比较运算符
运算符 是对 操作数 执行计算的函数。操作数只是对象。(关于运算符的详细内容请参考 第 182 页的“逻辑运算符”部分。)为了能够使用 bool 类型给出有意义的示例,你将在本节快速了解比较运算符,在下一节了解逻辑运算符。
你可以使用多个运算符来构建布尔表达式。回忆一下,比较运算符接受两个参数并返回一个 bool。可用的运算符有相等(==)、不等(!=)、大于(>)、小于(<)、大于或等于(>=)以及小于或等于(<=)。
列表 2-6 展示了如何使用这些运算符来产生布尔值。
#include <cstdio>
int main() {
printf(" 7 == 7: %d➊\n", 7 == 7➋);
printf(" 7 != 7: %d\n", 7 != 7);
printf("10 > 20: %d\n", 10 > 20);
printf("10 >= 20: %d\n", 10 >= 20);
printf("10 < 20: %d\n", 10 < 20);
printf("20 <= 20: %d\n", 20 <= 20);
}
--------------------------------------------------------------------------
7 == 7: 1 ➊
7 != 7: 0
10 > 20: 0
10 >= 20: 0
10 < 20: 1
20 <= 20: 1
列表 2-6:使用比较运算符
每个比较产生一个布尔结果 ➋,并且 printf 语句将布尔值打印为 int ➊。
逻辑运算符
逻辑运算符 对 bool 类型的布尔逻辑进行求值。你可以通过它们需要的操作数个数来分类运算符。一元运算符 需要一个操作数,二元运算符 需要两个,三元运算符 需要三个,以此类推。你还可以通过描述它们操作数的类型进一步对运算符进行分类。
一元 取反 运算符(!)接受一个操作数并返回其相反值。换句话说,!true 得到 false,而 !false 得到 true。
逻辑运算符与(&&)和或(||)是二元运算符。逻辑与仅在两个操作数都为 true 时返回 true。逻辑或只要任一操作数为 true 就返回 true。
注意
当你读取布尔表达式时,! 读作“非”,例如“a 且 非 b”表示表达式 a && !b。
逻辑运算符刚开始可能看起来让人困惑,但它们很快就变得直观。列表 2-7 展示了逻辑运算符的用法。
#include <cstdio>
int main() {
bool t = true;
bool f = false;
printf("!true: %d\n", !t); ➊
printf("true && false: %d\n", t && f); ➋
printf("true && !false: %d\n", t && !f); ➌
printf("true || false: %d\n", t || f); ➍
printf("false || false: %d\n", f || f); ➎
}
--------------------------------------------------------------------------
!true: 0 ➊
true && false: 0 ➋
true && !false: 1 ➌
true || false: 1 ➍
false || false: 0 ➎
列表 2-7:展示使用逻辑运算符的程序
在这里,你可以看到取反运算符 ➊、逻辑与运算符 ➋➌ 和逻辑或运算符 ➍➎。
std::byte 类型
系统程序员有时需要直接处理原始内存,它是没有类型的位的集合。在这种情况下,使用std::byte类型,该类型可以在<cstddef>头文件中找到。std::byte类型允许进行按位逻辑运算(你将在第七章中遇到这些),除此之外几乎没有其他功能。将此类型用于原始数据而不是整型可以帮助避免常见的难以调试的编程错误。
请注意,与<cstddef>中的大多数其他基本类型不同,std::byte在 C 语言中没有完全对应的类型(即“C 类型”)。像 C++一样,C 语言有char和unsigned char。这些类型使用起来不那么安全,因为它们支持许多std::byte不支持的操作。例如,你可以对char执行算术运算(如加法+),但不能对std::byte执行类似的操作。奇怪的std::前缀称为命名空间,你将在“命名空间”一节中了解更多内容(见第 216 页)(现在,暂时将命名空间std::视为类型名称的一部分)。
注意
关于如何发音std有两种观点。一种是将其视为首字母缩略词,发音为“ess-tee-dee”;另一种是将其视为首字母缩写,发音为“stood”。当提到std命名空间中的类时,通常会隐含命名空间运算符::。所以你可以将std::byte发音为“stood byte”,或者,如果你不喜欢简洁的表达方式,可以发音为“ess-tee-dee colon colon byte”。
size_t 类型
你使用size_t类型,该类型也可以在<cstddef>头文件中找到,用来编码对象的大小。size_t对象保证它们的最大值足以表示所有对象的最大字节数。从技术上讲,这意味着size_t可能占用 2 个字节或 200 个字节,具体取决于实现。在实践中,它通常与 64 位架构上的unsigned long long相同。
注意
size_t类型是 C 库头文件中的 C 类型,但它与 C++版本相同,后者位于std命名空间中。有时,你会看到(技术上正确的)构造std::size_t。
sizeof
一元运算符sizeof接受一个类型操作数,并返回该类型的大小(以字节为单位)。sizeof运算符始终返回一个size_t。例如,sizeof(float)返回float类型所占的字节数。
格式说明符
size_t的常用格式说明符是%zu(用于十进制表示)或%zx(用于十六进制表示)。列表 2-8 展示了你如何检查系统上多个整数类型的大小。
#include <cstddef>
#include <cstdio>
int main() {
size_t size_c = sizeof(char); ➊
printf("char: %zu\n", size_c);
size_t size_s = sizeof(short); ➋
printf("short: %zu\n", size_s);
size_t size_i = sizeof(int); ➌
printf("int: %zu\n", size_i);
size_t size_l = sizeof(long); ➍
printf("long: %zu\n", size_l);
size_t size_ll = sizeof(long long); ➎
printf("long long: %zu\n", size_ll);
}
--------------------------------------------------------------------------
char: 1 ➊
short: 2 ➋
int: 4 ➌
long: 4 ➍
long long: 8 ➎
列表 2-8:一个打印多个整数类型字节大小的程序。(输出来自 Windows 10 x64 机器。)
清单 2-8 评估char ➊、short ➋、int ➌、long ➍和long long ➎的sizeof,并使用%zu格式说明符打印它们的大小。结果将根据操作系统有所不同。回想一下表 2-1,每个环境定义了其自身的整数类型大小。特别注意清单 2-8 中long类型的返回值;Linux 和 macOS 定义了 8 字节的long类型。
void
void类型没有值的集合。因为void对象不能保存任何值,所以 C++不允许使用void对象。你在特殊情况下使用void,例如用于没有返回值的函数的返回类型。例如,taunt函数不返回任何值,因此它的返回类型声明为void:
#include <cstdio>
void taunt() {
printf("Hey, laser lips, your mama was a snow blower.");
}
在第三章中,你将学习其他void类型的特殊用途。
数组
数组是具有相同类型变量的序列。数组类型包括元素类型和包含的元素数量。你可以将这些信息结合在一起,通过声明语法来表示:元素类型位于方括号前,方括号内包含数组的大小。
例如,下面一行声明了一个包含 100 个int对象的数组:
int my_array[100];
数组初始化
有一个简便的方法可以用花括号初始化数组的值:
int array[] = { 1, 2, 3, 4 };
你可以省略数组的长度,因为它可以根据花括号内的元素数量在编译时推断出来。
访问数组元素
你可以通过使用方括号来访问数组元素,方括号中包含所需的索引。在 C++中,数组索引是从 0 开始的,因此第一个元素位于索引0,第十个元素位于索引9,依此类推。清单 2-9 演示了如何读取和写入数组元素。
#include <cstdio>
int main() {
int arr[] = { 1, 2, 3, 4 }; ➊
printf("The third element is %d.\n", arr[2]➋);
arr[2] = 100; ➌
printf("The third element is %d.\n", arr[2]➍);
}
--------------------------------------------------------------------------
The third element is 3\. ➋
The third element is 100\. ➍
清单 2-9:一个索引数组的程序
这段代码声明了一个名为arr的四元素数组,包含元素1、2、3和4 ➊。在下一行 ➋,它打印第三个元素。然后,它将第三个元素赋值为 100 ➌,所以当它重新打印第三个元素 ➍时,值为100。
for 循环概览
for循环让你重复(或迭代)执行语句指定次数。你可以规定一个起始点和其他条件。初始化语句在第一次迭代之前执行,因此你可以在for循环中初始化使用的变量。条件表达式是在每次迭代之前评估的表达式。如果它的结果为true,则继续迭代。如果为false,则for循环终止。迭代语句在每次迭代后执行,当你需要增加变量以覆盖一系列值时,这很有用。for循环的语法如下:
for(init-statement; conditional; iteration-statement) {
--snip--
}
例如,清单 2-10 展示了如何使用for循环查找数组中的最大值。
#include <cstddef>
#include <cstdio>
int main() {
unsigned long maximum = 0; ➊
unsigned long values[] = { 10, 50, 20, 40, 0 }; ➋
for(size_t i=0; i < 5; i++) { ➌
if (values[i] > maximum➍) maximum = values[i]; ➎
}
printf("The maximum value is %lu", maximum); ➏
}
--------------------------------------------------------------------------
The maximum value is 50 ➏
清单 2-10:查找数组中包含的最大值
你将maximum ➊初始化为可能的最小值;这里是 0,因为它是无符号的。接下来,你初始化数组values ➋,并使用for循环 ➌对其进行迭代。迭代器变量i的范围从 0 到 4(包括 4)。在for循环内,你访问values中的每个元素,并检查该元素是否大于当前的maximum ➍。如果是,你将maximum设置为该新值 ➎。当循环完成时,maximum将等于数组中的最大值,接着打印maximum的值 ➏。
注意
如果你之前编写过 C 或 C++代码,你可能会想知道为什么列表 2-10 使用size_t而不是int作为i的类型。考虑到values理论上可能占用最大允许的存储空间,虽然size_t保证能够索引其中的任何值,而int不能。在实际中,这没有太大区别,但从技术上讲,size_t是正确的。
基于范围的 for 循环
在列表 2-10 中,你已经看到如何使用for循环 ➌来迭代数组的元素。通过使用基于范围的 for 循环,你可以省略迭代器变量i。对于某些对象,如数组,for理解如何迭代对象内的值范围。以下是基于范围的 for 循环的语法:
for(element-type➊ element-name➋ : array-name➌) {
--snip--
}
你声明一个迭代器变量element-name ➋,其类型为element-type ➊。element-type必须与正在迭代的数组中的元素类型匹配。这个数组称为array-name ➌。
列表 2-11 用基于范围的for循环重构了列表 2-10。
#include <cstdio>
int main() {
unsigned long maximum = 0;
unsigned long values[] = { 10, 50, 20, 40, 0 };
for(unsigned long value : values➊) {
if (value➋ > maximum) maximum = value➌;
}
printf("The maximum value is %lu.", maximum);
}
--------------------------------------------------------------------------
The maximum value is 50.
列表 2-11:用基于范围的 for 循环重构列表 2-10
注意
你将在第七章学习关于表达式的内容。现在,先把表达式想象成一些代码片段,它们对你的程序产生影响。
列表 2-11 大大改进了列表 2-10。一眼看去,你就知道for循环在迭代values ➊。由于你已经丢弃了迭代器变量i,for循环的主体简化了;因此,你可以直接使用values中的每个元素 ➋➌。
慷慨使用基于范围的for循环。
数组中的元素数量
使用sizeof运算符来获取数组的总字节大小。你可以使用一个简单的技巧来确定数组中的元素数量:将数组的大小除以单个元素的大小:
short array[] = { 104, 105, 32, 98, 105, 108, 108, 0 };
size_t n_elements = sizeof(array)➊ / sizeof(short)➋;
在大多数系统上,sizeof(array) ➊将计算为 16 字节,而sizeof(short) ➋将计算为 2 字节。无论short的大小如何,n_elements将始终初始化为 8,因为该因子会相互抵消。此计算发生在编译时,因此以这种方式计算数组的长度不会产生运行时成本。
sizeof(x)/sizeof(y)这种写法有点像黑客技术,但它在旧代码中广泛使用。在第二部分中,你将学习其他存储数据的选项,这些选项不需要外部计算它们的长度。如果你真的必须使用数组,你可以安全地通过std::size函数来获取元素的数量,该函数在<iterator>头文件中可用。
注意
作为附加好处,std::size可以与任何公开size方法的容器一起使用。这包括第十三章中的所有容器。这在编写泛型代码时尤其有用,这是你将在第六章中探讨的主题。此外,如果你意外地传递了不支持的类型,如指针,它会拒绝编译。
C 风格字符串
字符串是连续的字符块。C 风格字符串或空字符终止字符串在其末尾附加一个零字节(一个空字符)以表示字符串的结束。由于数组元素是连续的,你可以将字符串存储在字符类型的数组中。
字符串字面量
通过将文本括在引号("")中来声明字符串字面量。像字符字面量一样,字符串字面量支持 Unicode:只需在字面量前添加适当的前缀,如L。以下示例将字符串字面量分配给数组english和chinese:
char english[] = "A book holds a house of gold.";
char16_t chinese[] = u"\u4e66\u4e2d\u81ea\u6709\u9ec4\u91d1\u5c4b";
注意
惊讶!你一直在使用字符串字面量:你的printf语句的格式化字符串就是字符串字面量。
这段代码生成了两个变量:english,其内容是A book holds a house of gold.,以及chinese,其内容是书中自有黄金屋的 Unicode 字符。
格式说明符
窄字符字符串(char*)的格式说明符是%s。例如,你可以将字符串包含到格式化字符串中,如下所示:
#include <cstdio>
int main() {
char house[] = "a house of gold.";
printf("A book holds %s\n ", house);
}
--------------------------------------------------------------------------
A book holds a house of gold.
注意
将 Unicode 打印到控制台是出奇的复杂。通常,你需要确保选择了正确的代码页,而这个话题远远超出了本书的范围。如果你需要将 Unicode 字符嵌入到字符串字面量中,请查看wprintf,它位于<cwchar>头文件中。
连续的字符串字面量会被连接在一起,任何中间的空格或换行都会被忽略。因此,你可以在源代码中将字符串字面量分布在多行,编译器会将它们当作一个整体来处理。例如,你可以将这个例子重构如下:
#include <cstdio>
int main() {
char house[] = "a "
"house "
"of " "gold.";
printf("A book holds %s\n ", house);
}
--------------------------------------------------------------------------
A book holds a house of gold.
通常,当你的源代码中有一个长字符串字面量,会跨越多行时,这种结构只在提高可读性时有用。生成的程序是相同的。
ASCII
美国标准信息交换码(ASCII)表将整数分配给字符。表 2-4 显示了 ASCII 表。对于每个十进制(0d)和十六进制(0x)的整数值,都会显示相应的控制代码或可打印字符。
表 2-4: ASCII 表
| 控制代码 | 可打印字符 | ||||||
|---|---|---|---|---|---|---|---|
| 0d | 0x | Code | 0d | 0x | Char | 0d | 0x |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 0 | 0 | NULL |
32 | 20 | SPACE |
64 | 40 |
| 1 | 1 | SOH |
33 | 21 | ! |
65 | 41 |
| 2 | 2 | STX |
34 | 22 | " |
66 | 42 |
| 3 | 3 | ETX |
35 | 23 | # |
67 | 43 |
| 4 | 4 | EOT |
36 | 24 | ` | 控制代码 | 可打印字符 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 0d | 0x | Code | 0d | 0x | Char | 0d | 0x |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 0 | 0 | NULL |
32 | 20 | SPACE |
64 | 40 |
| 1 | 1 | SOH |
33 | 21 | ! |
65 | 41 |
| 2 | 2 | STX |
34 | 22 | " |
66 | 42 |
| 3 | 3 | ETX |
35 | 23 | # |
67 | 43 |
| 68 | 44 | D |
100 | 64 | d |
||
| 5 | 5 | ENQ |
37 | 25 | % |
69 | 45 |
| 6 | 6 | ACK |
38 | 26 | & |
70 | 46 |
| 7 | 7 | BELL |
39 | 27 | ' |
71 | 47 |
| 8 | 8 | BS |
40 | 28 | ( |
72 | 48 |
| 9 | 9 | HT |
41 | 29 | ) |
73 | 49 |
| 10 | 0a | LF |
42 | 2a | * |
74 | 4a |
| 11 | 0b | VT |
43 | 2b | + |
75 | 4b |
| 12 | 0c | FF |
44 | 2c | , |
76 | 4c |
| 13 | 0d | CR |
45 | 2d | - |
77 | 4d |
| 14 | 0e | SO |
46 | 2e | . |
78 | 4e |
| 15 | 0f | SI |
47 | 2f | / |
79 | 4f |
| 16 | 10 | DLE |
48 | 30 | 0 |
80 | 50 |
| 17 | 11 | DC1 |
49 | 31 | 1 |
81 | 51 |
| 18 | 12 | DC2 |
50 | 32 | 2 |
82 | 52 |
| 19 | 13 | DC3 |
51 | 33 | 3 |
83 | 53 |
| 20 | 14 | DC4 |
52 | 34 | 4 |
84 | 54 |
| 21 | 15 | NAK |
53 | 35 | 5 |
85 | 55 |
| 22 | 16 | SYN |
54 | 36 | 6 |
86 | 56 |
| 23 | 17 | ETB |
55 | 37 | 7 |
87 | 57 |
| 24 | 18 | CAN |
56 | 38 | 8 |
88 | 58 |
| 25 | 19 | EM |
57 | 39 | 9 |
89 | 59 |
| 26 | 1a | SUB |
58 | 3a | : |
90 | 5a |
| 27 | 1b | ESC |
59 | 3b | ; |
91 | 5b |
| 28 | 1c | FS |
60 | 3c | < |
92 | 5c |
| 29 | 1d | GS |
61 | 3d | = |
93 | 5d |
| 30 | 1e | RS |
62 | 3e | > |
94 | 5e |
| 31 | 1f | US |
63 | 3f | ? |
95 | 5f |
ASCII 码 0 到 31 是 控制字符,用于控制设备。这些字符大多已经过时。当美国标准协会在 1960 年代正式化 ASCII 时,现代设备包括打字机、磁带读卡器和点阵打印机。一些仍在常用的控制代码如下:
-
0 (NULL) 被编程语言用作字符串终止符。
-
4 (EOT),传输结束符,终止 shell 会话和 PostScript 打印机通信。
-
7 (BELL) 使设备发出声音。
-
8 (BS),退格键,使设备擦除最后一个字符。
-
9(HT),水平制表符,将光标移动若干个空格到右侧。
-
10(LF),换行符,在大多数操作系统中用作行结束符。
-
13(CR),回车符,和 LF 结合使用,在 Windows 系统上作为行结束符。
-
26(SUB),替代字符/文件结束符/
ctrl-Z,暂停当前正在执行的交互式进程,在大多数操作系统上均适用。
ASCII 表的其余部分,即从 32 到 127 的编码,是可打印字符。这些字符表示英文字母、数字和标点符号。
在大多数系统中,char 类型的表示为 ASCII。虽然这种关系并非严格保证,但它已成为事实上的标准。
现在是时候将你对 char 类型、数组、for 循环和 ASCII 表的知识结合起来了。示例 2-12 展示了如何创建一个包含字母的数组,打印结果,然后将该数组转换为大写字母并再次打印。
#include <cstdio>
int main() {
char alphabet[27];➊
for (int i = 0; i<26; i++) {
alphabet[i] = i + 97; ➋
}
alphabet[26] = 0; ➌
printf("%s\n", alphabet); ➍
for (int i = 0; i<26; i++) {
alphabet[i] = i + 65; ➎
}
printf("%s", alphabet); ➏
}
--------------------------------------------------------------------------
abcdefghijklmnopqrstuvwxyz➍
ABCDEFGHIJKLMNOPQRSTUVWXYZ➏
示例 2-12:使用 ASCII 打印小写和大写字母
首先,你声明一个长度为 27 的 char 数组来保存 26 个英文字母以及一个空终止符 ➊。接下来,使用 for 循环从 0 到 25 进行迭代,迭代器为 i。字母 a 在 ASCII 中的值为 97。通过将 97 加到迭代器 i,你可以生成 alphabet 中的所有小写字母 ➋。为了使 alphabet 成为一个空终止字符串,你将 alphabet[26] 设置为 0 ➌。然后,你打印结果 ➍。
接下来,你打印大写字母。字母 A 在 ASCII 中的值为 65,因此你需要相应地重置字母表中的每个元素 ➎ 并再次调用 printf ➏。
用户定义类型
用户定义类型 是用户可以定义的类型。用户定义类型的三大类如下:
枚举类型 是用户定义类型中最简单的一种。枚举类型可以取的值被限制为一组可能的值。枚举非常适合用来建模类别概念。
类 是功能更全面的类型,它使你能够灵活地将数据和函数配对。仅包含数据的类称为普通数据类;你将在本节中学习它们。
联合类型 是一种特别的用户定义类型。所有成员共享同一内存位置。联合类型很危险,容易被误用。
枚举类型
使用关键字 enum class 来声明枚举类型,后跟类型名称和它可以取的值的列表。这些值是任意的字母数字字符串,代表你想表示的任何类别。在底层,这些值只是整数,但它们使得你能够通过使用程序员定义的类型,而不是任意整数,从而编写更安全、更具表现力的代码。例如,示例 2-13 声明了一个名为 Race 的 enum class,它可以取七个值之一。
enum class Race {
Dinan,
Teklan,
Ivyn,
Moiran,
Camite,
Julian,
Aidan
};
示例 2-13:一个包含 Neal Stephenson 的《Seveneves》中的所有种族的枚举类
要将枚举变量初始化为某个值,使用类型名称后跟两个冒号::和所需的值。例如,以下是如何声明变量langobard_race并将其值初始化为Aidan:
Race langobard_race = Race::Aidan;
注意
从技术上讲,enum class是两种枚举类型之一:它被称为作用域枚举。为了与 C 语言兼容,C++还支持一种非作用域枚举,它使用enum而不是enum class声明。主要的区别在于作用域枚举要求在值之前使用枚举类型后跟::,而非作用域枚举则不需要。非作用域enum类比作用域枚举更不安全,因此除非绝对必要,最好避免使用它们。它们在 C++中主要是出于历史原因,特别是为了与 C 代码的互操作性。有关详细信息,请参阅 Scott Meyers 的《Effective Modern C++》,第 10 项。
switch 语句
switch 语句根据条件的值将控制权转移到多个语句之一,该条件可以是整数类型或枚举类型。switch关键字表示一个 switch 语句。
switch 语句提供了条件分支。当执行 switch 语句时,控制权转移到符合条件的case,或者如果没有任何 case 匹配条件表达式,则转移到default condition。case关键字表示一个 case,而default关键字表示默认条件。
有点令人困惑的是,执行会继续直到 switch 语句的末尾或遇到break关键字。你几乎总是会在每个条件的末尾找到一个break。
switch 语句有很多组件。列表 2-14 展示了它们是如何组合的。
switch➊(condition➋) {
case➌ (case-a➍): {
// Handle case a here
--snip--
}➎ break➏;
case (case-b): {
// Handle case b here
--snip--
} break;
// Handle other conditions as desired
--snip--
default➐: {
// Handle the default case here
--snip--
}
}
列表 2-14:展示 switch 语句如何组合在一起的草图
所有的 switch 语句都以switch关键字 ➊开始,后面跟着括号中的condition ➋。每个 case 以case关键字 ➌开始,后面跟着该 case 的枚举或整数值 ➍。例如,如果condition ➋等于case-a ➍,则包含Handle case a here的代码块将执行。在每个 case 语句后 ➎,你需要放一个break关键字 ➏。如果condition与任何 case 都不匹配,则执行default case ➐。
注意
每个 case 的括号是可选的,但强烈建议使用它们。如果不使用括号,有时会出现意外的行为。
使用枚举类的 switch 语句
列表 2-15 使用 switch 语句对Race枚举类进行操作,生成定制的问候语。
#include <cstdio>
enum class Race { ➊
Dinan,
Teklan,
Ivyn,
Moiran,
Camite,
Julian,
Aidan
};
int main() {
Race race = Race::Dinan; ➋
switch(race) { ➌
case Race::Dinan: { ➍
printf("You work hard.");
} break;➎
case Race::Teklan: {
printf("You are very strong.");
} break;
case Race::Ivyn: {
printf("You are a great leader.");
} break;
case Race::Moiran: {
printf("My, how versatile you are!");
} break;
case Race::Camite: {
printf("You're incredibly helpful.");
} break;
case Race::Julian: {
printf("Anything you want!");
} break;
case Race::Aidan: {
printf("What an enigma.");
} break;
default: {
printf("Error: unknown race!"); ➏
}
}
}
--------------------------------------------------------------------------
You work hard.
列表 2-15:根据选择的Race打印不同问候语的程序
enum class ➊声明了枚举类型Race,你用它将race初始化为Dinan ➋。switch 语句 ➌评估条件race以确定应该将控制权转交给哪个条件。由于你在代码的前面已经将其硬编码为Dinan,所以执行将转到 ➍,并打印You work hard. 在 ➎的break终止了 switch 语句。
在 ➏ 的 default 条件是一个安全功能。如果有人向枚举类中添加了新的 Race 值,程序将在运行时检测到该未知的种族并打印错误信息。
尝试将 race ➋ 设置为不同的值。输出结果是如何变化的?
普通数据类(POD)
类是用户定义的包含数据和函数的类型,它们是 C++ 的核心和灵魂。最简单的类是 普通数据类(POD)。POD 是简单的容器。你可以把它们看作是包含潜在 不同 类型元素的异构数组。类中的每个元素称为 成员。
每个 POD 都以关键字 struct 开头,后面跟着 POD 的名称。接下来,你列出成员的类型和名称。考虑以下声明具有四个成员的 Book 类:
struct Book {
char name[256]; ➊
int year; ➋
int pages; ➌
bool hardcover; ➍
};
一个 Book 包含一个 char 数组 name ➊、一个 int year ➋、一个 int pages ➌ 和一个 bool hardcover ➍。
你像声明其他变量一样声明 POD 变量:通过类型和名称。然后,你可以使用点运算符(.)访问变量的成员。
列表 2-16 使用了 Book 类型。
#include <cstdio>
struct Book {
char name[256];
int year;
int pages;
bool hardcover;
};
int main() {
Book neuromancer; ➊
neuromancer.pages = 271; ➋
printf("Neuromancer has %d pages.", neuromancer.pages); ➌
}
--------------------------------------------------------------------------
Neuromancer has 271 pages. ➌
列表 2-16:使用 POD 类型 Book 来读取和写入成员的示例
首先,你声明一个 Book 类型的变量 neuromancer ➊。接着,使用点运算符(.)将 neuromancer 的页数设置为 271 ➋。最后,你打印一条消息并提取 neuromancer 的页数,再次使用点运算符 ➌。
注意
POD 类型具有一些有用的低级特性:它们与 C 兼容,可以使用高效的机器指令来复制或移动它们,并且可以高效地表示在内存中。
C++ 保证成员在内存中是按顺序排列的,尽管某些实现要求成员沿着字边界对齐,这取决于 CPU 寄存器的长度。通常的做法是,在 POD 定义中按照从大到小的顺序排列成员。
联合体
联合体是 POD 类型的“表亲”,它将所有成员放在同一位置。你可以把联合体看作是对一块内存的不同视图或解释。它们在某些低级场景中非常有用,比如在跨架构一致的结构体序列化中、处理与 C/C++ 互操作相关的类型检查问题,甚至在打包位域时。
列表 2-17 演示了如何声明一个联合体:只需使用 union 关键字代替 struct。
union Variant {
char string[10];
int integer;
double floating_point;
};
列表 2-17:一个示例联合体
联合体 Variant 可以被解释为一个 char[10]、一个 int 或一个 double。它仅占用与其最大成员(在此情况下可能是 string)相同的内存空间。
你使用点运算符(.)来指定联合体的解释。从语法上看,这与访问 POD 成员类似,但在底层完全不同。
由于联合体的所有成员都在同一位置,你很容易导致数据损坏。清单 2-18 展示了这一危险。
#include <cstdio>
union Variant {
char string[10];
int integer;
double floating_point;
};
int main() {
Variant v; ➊
v.integer = 42; ➋
printf("The ultimate answer: %d\n", v.integer); ➌
v.floating_point = 2.7182818284; ➍
printf("Euler's number e: %f\n", v.floating_point); ➎
printf("A dumpster fire: %d\n", v.integer); ➏
}
--------------------------------------------------------------------------
The ultimate answer: 42 ➌
Euler's number e: 2.718282 ➎
A dumpster fire: -1961734133➏
清单 2-18:使用联合体Variant的程序,参见清单 2-17
你在➊声明了一个Variant v。接下来,你将v解释为整数,设置它的值为 42 ➋,并打印它 ➌。然后,你将v重新解释为float并重新赋值 ➍。你将它打印到控制台,所有看起来都很好 ➎。到目前为止一切正常。
灾难发生在你再次试图将v解释为整数时 ➏。你在将欧拉数 ➍ 赋值时覆盖了v的原始值(42) ➋。
这就是联合体的主要问题:你需要自己跟踪哪种解释是合适的。编译器不会帮助你。
你应该避免在除非极少数情况外使用联合体,在本书中你不会看到它们。第 379 页的“variant”讨论了当你需要多类型功能时,一些更安全的选项。
功能全面的 C++类
POD 类只包含数据成员,有时这就是你从类中需要的全部。然而,仅使用 POD 设计程序可能会带来很多复杂性。你可以通过封装来应对这种复杂性,封装是一种将数据与操作它的函数绑定的设计模式。将相关的函数和数据放在一起有助于简化代码,至少有两种方式。首先,你可以把相关代码放在一个地方,这有助于你推理程序的行为。你可以理解代码片段是如何工作的,因为它在一个地方描述了程序的状态以及代码如何修改该状态。其次,你可以使用称为信息隐藏的做法,将类的一些代码和数据隐藏起来,避免它们被程序的其他部分访问。
在 C++中,你通过在类定义中添加方法和访问控制来实现封装。
方法
方法是成员函数。它们在类、数据成员和某些代码之间创建了一个明确的连接。定义一个方法就像在类定义中添加一个函数一样简单。方法可以访问类的所有成员。
考虑一个示例类ClockOfTheLongNow,它跟踪年份。你定义一个int year成员和一个递增它的add_year方法:
struct ClockOfTheLongNow {
void add_year() { ➊
year++; ➋
}
int year; ➌
};
add_year方法的声明➊看起来像是任何一个不带参数且不返回值的函数。在方法内部,你会递增➋成员变量year ➌。清单 2-19 展示了如何使用该类来跟踪年份。
#include <cstdio>
struct ClockOfTheLongNow {
--snip--
};
int main() {
ClockOfTheLongNow clock; ➊
clock.year = 2017; ➋
clock.add_year(); ➌
printf("year: %d\n", clock.year); ➍
clock.add_year(); ➎
printf("year: %d\n", clock.year); ➏
}
--------------------------------------------------------------------------
year: 2018 ➍
year: 2019 ➏
清单 2-19:使用ClockOfTheLongNow结构的程序
你声明了ClockOfTheLongNow实例clock ➊,然后将clock的year设置为2017 ➋。接下来,你在clock上调用add_year方法 ➌,然后打印clock.year的值 ➍。你通过递增 ➎并再次打印 ➏来完成程序。
访问控制
访问控制 限制类成员的访问。公共 和 私有 是两种主要的访问控制。任何人都可以访问公共成员,但只有类本身可以访问其私有成员。所有 struct 成员默认都是公共的。
私有成员在封装中扮演着重要角色。再考虑一下 ClockOfTheLongNow 类。目前,year 成员可以从任何地方访问——无论是读取还是写入。假设你想防止 year 的值小于 2019。你可以通过两步来实现这一点:你将 year 设为私有,并要求所有使用该类的用户(消费者)只能通过类的方法与 year 进行交互。清单 2-20 说明了这种方法。
struct ClockOfTheLongNow {
void add_year() {
year++;
}
bool set_year(int new_year) { ➊
if (new_year < 2019) return false; ➋
year = new_year;
return true;
}
int get_year() { ➌
return year;
}
private: ➍
int year;
};
清单 2-20:一个更新版的 ClockOfTheLongNow,来自 清单 2-19,封装了 year
你已为 ClockOfTheLongNow 添加了两个方法:一个 setter ➊ 和一个 getter ➌,用于 year。你不允许 ClockOfTheLongNow 的用户直接修改 year,而是通过 set_year 设置 year。这种输入验证确保了 new_year 永远不会小于 2019 ➋。如果小于 2019,代码返回 false 并且 year 保持不变。否则,year 会被更新并返回 true。要获取 year 的值,用户需要调用 get_year。
你已经使用访问控制标签 private ➊ 禁止消费者访问 year。现在,用户只能在 ClockOfTheLongNow 内部访问 year。
class 关键字
你可以将 struct 关键字替换为 class 关键字,后者默认声明成员为 private。除了默认的访问控制外,使用 struct 和 class 关键字声明的类是一样的。例如,你可以通过以下方式声明 ClockOfTheLongNow:
class ClockOfTheLongNow {
int year;
public:
void add_year() {
--snip--
}
bool set_year(int new_year) {
--snip--
}
int get_year() {
--snip--
}
};
声明类的方式是个人风格问题。除了默认的访问控制外,struct 和 class 没有任何区别。我偏爱使用 struct 关键字,因为我喜欢先列出公共成员。但你会看到各种各样的约定,外面的人使用的风格不同。培养一种风格并坚持下去。
初始化成员
既然已经封装了 year,你现在必须使用方法来与 ClockOfTheLongNow 交互。清单 2-21 展示了如何将这些方法组合成一个程序,尝试将年份设置为 2018。操作失败后,程序将年份设置为 2019,增加年份,然后打印最终的值。
#include <cstdio>
struct ClockOfTheLongNow {
--snip--
};
int main() {
ClockOfTheLongNow clock; ➊
if(!clock.set_year(2018)) { ➋ // will fail; 2018 < 2019
clock.set_year(2019); ➌
}
clock.add_year(); ➍
printf(“year: %d”, clock.get_year());
}
--------------------------------------------------------------------------
year: 2020 ➎
清单 2-21:使用 ClockOfTheLongNow 的程序,演示如何使用方法
你声明了一个时钟 ➊ 并尝试将其年份设置为 2018 ➋。这失败了,因为 2018 小于 2019,程序随后将年份设置为 2019 ➌。你将年份增加了一次 ➍ 然后打印其值。
在第一章中,你看到未初始化的变量可能会包含随机数据,在调试时你可以观察到这一点。ClockOfTheLongNow 结构体也有同样的问题:当 clock 被声明 ➊ 时,year 是未初始化的。你希望保证 year 在任何情况下都不小于 2019。这种要求被称为类不变性:类的一个特性,它始终为真(即永远不会改变)。
在这个程序中,clock 最终达到了一个良好的状态 ➌,但你可以通过使用构造函数来做得更好。构造函数从对象生命周期的开始就初始化对象,并强制执行类的不变性。
构造函数
构造函数是具有特殊声明的特殊方法。构造函数的声明不指定返回类型,且它们的名称与类名相同。例如,清单 2-22 中的构造函数没有参数,并将 year 设置为 2019,这使得 year 默认为 2019。
#include <cstdio>
struct ClockOfTheLongNow {
ClockOfTheLongNow() { ➊
year = 2019; ➋
}
--snip--
};
int main() {
ClockOfTheLongNow clock; ➌
printf("Default year: %d", clock.get_year()); ➍
}
--------------------------------------------------------------------------
Default year: 2019 ➍
清单 2-22:通过无参构造函数改进 清单 2-21
该构造函数不接受任何参数 ➊,并将 year 设置为 2019 ➋。当你声明一个新的 ClockOfTheLongNow ➌ 时,year 默认为 2019。你通过 get_year 访问 year 并将其打印到控制台 ➍。
如果你想用自定义的年份初始化一个 ClockOfTheLongNow 呢?构造函数可以接受任意数量的参数。你可以实现任意多个构造函数,只要它们的参数类型不同。
考虑清单 2-23 中的例子,在其中你添加了一个接受 int 的构造函数。构造函数将 year 初始化为该参数的值。
#include <cstdio>
struct ClockOfTheLongNow {
ClockOfTheLongNow(int year_in) { ➊
if(!set_year(year_in)) { ➋
year = 2019; ➌
}
}
--snip--
};
int main() {
ClockOfTheLongNow clock{ 2020 }; ➍
printf("Year: %d", clock.get_year()); ➎
}
--------------------------------------------------------------------------
Year: 2020 ➎
清单 2-23:通过另一个构造函数扩展 清单 2-22
新的构造函数 ➊ 接受一个类型为 int 的 year_in 参数。你调用 set_year 并传入 year_in ➋。如果 set_year 返回 false,说明调用者提供了无效输入,你会用默认值 2019 覆盖 year_in ➌。在 main 中,你使用新构造函数 ➍ 创建一个时钟对象,然后打印结果 ➎。
ClockOfTheLongNow clock{ 2020 }; 的调用被称为初始化。
注意
你可能不喜欢无声地将无效的 year_in 实例修正为 2019 ➌。我也不喜欢这样。异常可以解决这个问题;你将在“异常”一节中学习它们,详见 第 98 页。
初始化
对象初始化,或者简单地说,初始化,是将对象“唤醒”的过程。不幸的是,对象初始化的语法较为复杂。幸运的是,初始化过程是直接的。本节将 C++对象初始化的复杂过程提炼成易于理解的叙述。
将基础类型初始化为零
让我们从初始化一个基础类型的对象为零开始。可以通过四种方式实现这一点:
int a = 0; ➊// Initialized to 0
int b{}; ➋// Initialized to 0
int c = {}; ➌// Initialized to 0
int d; ➍// Initialized to 0 (maybe)
其中三种方法是可靠的:使用文字值显式设置值 ➊,使用花括号{} ➋,或使用等号加花括号= {} ➌。没有额外标记声明对象 ➍ 是不可靠的;它只在某些特定情况下有效。即使你知道这些情况,也不应依赖这种行为,因为它会引起混淆。
使用花括号{}来初始化变量,不出所料,称为花括号初始化。C++初始化语法混乱的部分原因在于,语言最初从 C 语言发展而来,而 C 语言中的对象生命周期是原始的,后来发展为具有强大和丰富功能的对象生命周期。语言设计者将花括号初始化引入现代 C++,以帮助平滑过渡因初始化语法产生的尖锐问题。简而言之,无论对象的作用域或类型如何,花括号初始化始终适用,而其他表示法则不然。在本章后续部分,你将学习一条通用规则,鼓励广泛使用花括号初始化。
将基本类型初始化为任意值
初始化为任意值类似于将基本类型初始化为零:
int e = 42; ➊ // Initialized to 42
int f{ 42 }; ➋ // Initialized to 42
int g = { 42 };➌ // Initialized to 42
int h(42); ➍ // Initialized to 42
有四种方法:等号 ➊、花括号初始化 ➋、等号加花括号初始化 ➌ 和圆括号 ➍。所有这些都会产生相同的代码。
初始化 POD 类型
初始化 POD 的标记通常遵循基本类型的规则。示例 2-24 通过声明一个包含三个成员的 POD 类型,并使用不同的值初始化其实例,展示了它们的相似性。
#include <cstdint>
struct PodStruct {
uint64_t a;
char b[256];
bool c;
};
int main() {
PodStruct initialized_pod1{}; ➊ // All fields zeroed
PodStruct initialized_pod2 = {}; ➋ // All fields zeroed
PodStruct initialized_pod3{ 42, "Hello" }; ➌ // Fields a & b set; c = 0
PodStruct initialized_pod4{ 42, "Hello", true }; ➍ // All fields set
}
示例 2-24:一个展示多种方式初始化 POD 的程序
将 POD 对象初始化为零类似于将基本类型的对象初始化为零。花括号 ➊ 和等号加花括号 ➋ 方法产生相同的代码:字段初始化为零。
警告
你不能使用等号零方法来初始化 POD 类型。以下代码无法编译,因为在语言规则中明确禁止使用:
PodStruct initialized_pod = 0;
将 POD 初始化为任意值
你可以使用花括号初始化器将字段初始化为任意值。花括号初始化器中的参数必须与 POD 成员的类型匹配。参数从左到右的顺序与成员从上到下的顺序匹配。任何省略的成员都会被置为零。成员a和b在初始化initialized_pod3 ➌ 后分别初始化为42和Hello,而c则被置为零(设置为 false),因为你在花括号初始化中省略了它。initialized_pod4 ➍ 的初始化包括了c的参数(true),因此其值在初始化后被设置为 true。
等号加花括号初始化的工作方式完全相同。例如,你可以将 ➍ 替换为:
PodStruct initialized_pod4 = { 42, "Hello", true };
你只能从右到左省略字段,因此以下代码无法编译:
PodStruct initialized_pod4 = { 42, true };
警告
你不能使用圆括号来初始化 POD 类型。以下代码无法编译:
PodStruct initialized_pod(42, "Hello", true);
初始化数组
你像初始化 POD 类型一样初始化数组。数组和 POD 声明的主要区别在于数组指定了长度。回想一下,这个参数写在方括号[]中。
当你使用花括号初始化器来初始化数组时,长度参数变得可选;编译器可以根据花括号初始化器中的参数数量推断出大小参数。
清单 2-25 展示了初始化数组的一些方法。
int main() {
int array_1[]{ 1, 2, 3 }; ➊ // Array of length 3; 1, 2, 3
int array_2[5]{}; ➋ // Array of length 5; 0, 0, 0, 0, 0
int array_3[5]{ 1, 2, 3 }; ➌ // Array of length 5; 1, 2, 3, 0, 0
int array_4[5]; ➍ // Array of length 5; uninitialized values
}
清单 2-25:一个程序列出初始化数组的各种方式
数组array_1的长度为三,元素值为 1、2 和 3 ➊。数组array_2的长度为五,因为你指定了长度参数 ➋。花括号初始化器为空,因此所有五个元素初始化为零。数组array_3的长度也是五,但花括号初始化器不为空。它包含三个元素,因此剩余的两个元素初始化为零 ➌。数组array_4没有花括号初始化器,因此它包含未初始化的对象 ➍。
警告
array_4是否初始化实际上取决于与初始化基本类型相同的规则。对象的存储持续时间,你将在《对象的存储持续时间》章节中学习,位于第 89 页,决定了这些规则。如果你明确初始化,这些规则无需记忆。
完全特性的类
与基本类型和 POD 类型不同,完全特性的类总是被初始化。换句话说,完全特性类的构造函数在初始化过程中总是会被调用。调用哪个构造函数取决于在初始化时提供的参数。
清单 2-26 中的类有助于澄清如何使用完全特性的类。
#include <cstdio>
struct Taxonomist {
Taxonomist() { ➊
printf("(no argument)\n");
}
Taxonomist(char x) { ➋
printf("char: %c\n", x);
}
Taxonomist(int x) { ➌
printf("int: %d\n", x);
}
Taxonomist(float x) { ➍
printf("float: %f\n", x);
}
};
清单 2-26:一个类在初始化时宣布其调用的构造函数
Taxonomist类有四个构造函数。如果不提供参数,将调用无参数构造函数➊。如果在初始化时提供了char、int或float,则分别调用相应的构造函数:➋、➌或➍。在每种情况下,构造函数会通过printf语句提醒你。
清单 2-27 使用不同的语法和参数初始化了几个Taxonomists。
#include <cstdio>
struct Taxonomist {
--snip--
};
int main() {
Taxonomist t1; ➊
Taxonomist t2{ 'c' }; ➋
Taxonomist t3{ 65537 }; ➌
Taxonomist t4{ 6.02e23f }; ➍
Taxonomist t5('g'); ➎
Taxonomist t6 = { 'l' }; ➏
Taxonomist t7{}; ➐
Taxonomist t8(); ➑
}
--------------------------------------------------------------------------
(no argument) ➊
char: c ➋
int: 65537 ➌
float: 602000017271895229464576.000000 ➍
char: g ➎
char: l ➏
(no argument) ➐
清单 2-27:一个程序使用Taxonomist类的各种初始化语法
如果没有任何花括号或圆括号,将调用无参数构造函数➊。与 POD 和基本类型不同,你可以依赖这种初始化,无论你在哪里声明该对象。使用花括号初始化器时,char ➋,int ➌和float ➍构造函数按预期调用。你还可以使用圆括号➎和等号加花括号语法➏;这些都会调用预期的构造函数。
尽管功能完整的类总是会被初始化,但有些程序员喜欢对所有对象使用相同的初始化语法以保持统一性。使用大括号初始化器时,这没有问题;默认构造函数会按预期被调用 ➐。
不幸的是,使用圆括号 ➑ 会导致一些令人惊讶的行为。你不会得到任何输出。
如果你稍微眯一下眼睛,这个初始化 ➑ 看起来像一个函数声明,事实上它就是一个。由于一些晦涩的语言解析规则,你所声明给编译器的是一个尚未定义的函数t8,它不接受任何参数,并返回一个Taxonomist类型的对象。哎呀。
注意
在第 244 页的函数声明部分详细讲解了函数声明。但目前,你只需知道你可以提供一个函数声明,定义函数的修饰符、名称、参数和返回类型,然后在稍后的定义中提供函数体*。
这个广为人知的问题被称为最烦人的解析,也是 C++社区将大括号初始化语法引入语言的主要原因之一。窄化转换是另一个问题。
窄化转换
每当遇到隐式窄化转换时,大括号初始化会生成警告。这是一个很好的功能,可以帮助你避免一些严重的错误。考虑以下示例:
float a{ 1 };
float b{ 2 };
int narrowed_result(a/b); ➊ // Potentially nasty narrowing conversion
int result{ a/b }; ➋ // Compiler generates warning
将两个float字面量相除会得到一个 float 类型的结果。在初始化narrowed_result ➊时,编译器会默默地将a/b(0.5)的结果窄化为 0,因为你使用了圆括号( )来初始化。当你使用大括号初始化器时,编译器会生成警告 ➋。
初始化类成员
你可以使用大括号初始化器来初始化类的成员,如此处所示:
struct JohanVanDerSmut {
bool gold = true; ➊
int year_of_smelting_accident{ 1970 }; ➋
char key_location[8] = { "x-rated" }; ➌
};
gold成员使用等号初始化 ➊,year_of_smelting_accident使用大括号初始化 ➋,而key_location使用大括号加等号初始化 ➌。不能使用圆括号来初始化成员变量。
准备好大括号
初始化对象的选项甚至让经验丰富的 C++程序员感到困惑。这里有一个通用的规则,可以简化初始化过程:在所有地方都使用大括号初始化器。大括号初始化器几乎在所有情况下都能按预期工作,并且能带来最少的意外。因此,大括号初始化也被称为统一初始化。本书的其余部分都会遵循这一指导原则。
警告
在某些 C++标准库类中,你将打破使用大括号初始化器的规则。第二部分会清楚地阐明这些规则的例外情况。
析构函数
一个对象的析构函数是它的清理函数。析构函数在对象销毁之前被调用。析构函数几乎从不被显式调用:编译器会确保每个对象的析构函数在适当的时候被调用。你通过使用波浪符~后跟类的名称来声明一个类的析构函数。
以下Earth类具有一个析构函数,打印Making way for hyperspace bypass:
#include <cstdio>
struct Earth {
~Earth() { // Earth's destructor
printf("Making way for hyperspace bypass");
}
}
定义析构函数是可选的。如果决定实现析构函数,则它不能接受任何参数。你可能希望在析构函数中执行的操作包括释放文件句柄、刷新网络套接字和释放动态对象。
如果没有定义析构函数,系统会自动生成一个默认析构函数。默认析构函数的行为是不会执行任何操作。
你将在“追踪对象生命周期”章节中学习更多关于析构函数的内容,详见第 96 页。
总结
本章介绍了 C++的基础知识,即其类型系统。你首先学习了基本类型,它们是所有其他类型的构建块。接着,你学习了用户定义的类型,包括enum class、POD 类和完全特性的 C++类。最后,你通过讨论构造函数、初始化语法和析构函数结束了对类的学习。
命令行符号
2-1. 创建一个enum class Operation,其值为Add、Subtract、Multiply和Divide。
2-2. 创建一个struct Calculator。它应该有一个构造函数,接受一个Operation。
2-3. 在Calculator上创建一个名为int calculate(int a, int b)的方法。调用时,该方法应根据构造函数的参数执行加法、减法、乘法或除法,并返回结果。
2-4. 尝试不同的方式初始化Calculator实例。
进一步阅读
-
ISO 国际标准 ISO/IEC(2017 年)— C++编程语言(国际标准化组织;瑞士日内瓦;*
isocpp.org/std/the-standard/) -
《C++程序设计语言》,第 4 版,作者:Bjarne Stroustrup(Pearson Education,2013 年)
-
《有效的现代 C++》,作者:Scott Meyers(O’Reilly Media,2014 年)
-
《C++简化:普通数据类型》,作者:Andrew Koenig 和 Barbara E. Moo(Dr. Dobb’s,2002 年;
www.drdobbs.com/c-made-easier-plain-old-data/184401508/)
第五章:引用类型**
每个人都知道,调试比写程序要难两倍。那么,如果你在编写程序时已经尽可能聪明,那你怎么可能调试它呢?
—布赖恩·肯尼汉*

引用类型存储对象的内存地址。这些类型使得高效编程成为可能,许多优雅的设计模式也包含它们。在本章中,我将讨论两种引用类型:指针和引用。我还会讨论this、const和auto。
指针
指针是用来引用内存地址的基本机制。指针编码了与另一个对象交互所需的两部分信息——即对象的地址和对象的类型。
你可以通过在指向类型后面加一个星号(*)来声明一个指针的类型。例如,你可以如下声明一个指向int的指针my_ptr:
int* my_ptr;
指针的格式说明符是%p。例如,要打印my_ptr中的值,你可以使用如下代码:
printf("The value of my_ptr is %p.", my_ptr);
指针是非常低级的对象。尽管它们在大多数 C 程序中扮演着核心角色,但 C++提供了更高级的构造, 有时更高效,可以避免直接处理内存地址的需要。尽管如此,指针仍然是一个基础概念,你无疑会在系统编程中遇到它。
在本节中,你将学习如何找到一个对象的地址,并将结果赋值给一个指针变量。你还将学习如何执行相反的操作,这称为解引用:给定一个指针,你可以获取位于相应地址的对象。
你将进一步了解数组,这是管理对象集合的最简单构造,以及数组如何与指针相关联。作为低级构造,数组和指针相对危险。你将了解指针和数组相关程序出错时可能会发生的情况。
本章介绍了两种特殊类型的指针:void指针和std::byte指针。这些非常有用的类型具有一些特殊的行为,您需要记住这些行为。此外,您还将学习如何使用nullptr编码空指针,并学习如何在布尔表达式中使用指针来判断它们是否为空。
变量地址
你可以通过在变量前加上取地址操作符(&)来获取变量的地址。你可能想用这个操作符来初始化一个指针,使其“指向”相应的变量。这样的编程需求在操作系统编程中非常常见。例如,主要的操作系统,如 Windows、Linux 和 FreeBSD,都有大量使用指针的接口。
列表 3-1 演示了如何获取一个int的地址。
#include <cstdio>
int main() {
int gettysburg{}; ➊
printf("gettysburg: %d\n", gettysburg); ➋
int *gettysburg_address = &gettysburg; ➌
printf("&gettysburg: %p\n", gettysburg_address); ➍
}
列表 3-1:一个包含取地址操作符&和一个糟糕双关语的程序
首先,你声明整数gettysburg ➊并打印其值 ➋。然后你声明一个指针,名为gettysburg_address,指向该整数的地址 ➌;注意,星号(*)出现在指针前面,而&符号出现在gettysburg前面。最后,你打印指针到屏幕上 ➍,以显示gettysburg整数的地址。
如果你在 Windows 10(x86)上运行示例 3-1,你应该看到以下输出:
gettysburg: 0
&gettysburg: 0053FBA8
在 Windows 10 x64 上运行相同的代码会产生以下输出:
gettysburg: 0
&gettysburg: 0000007DAB53F594
你的输出应当在gettysburg上具有相同的值,但gettysburg_address每次应有所不同。这种变化是由于地址空间布局随机化,这是一种安全特性,旨在通过打乱重要内存区域的基础地址来防止攻击者利用漏洞。
地址空间布局随机化
为什么地址空间布局随机化能有效阻止攻击?当黑客在程序中发现可利用的漏洞时,他们有时会将恶意负载塞入用户提供的输入中。为了防止黑客让恶意负载执行,设计的第一个安全特性是将所有数据区域设置为不可执行。如果计算机试图将数据作为代码执行,那么理论上它会发现异常并通过异常终止程序。
一些极为聪明的黑客通过精心设计包含所谓的返回导向程序的漏洞,找到了以完全出乎意料的方式重新利用可执行代码指令的方法。这些漏洞可以安排调用相关的系统 API,使它们的负载能够被标记为可执行,从而突破非可执行内存的防护措施。
地址空间布局随机化通过随机化内存地址来对抗返回导向编程,使得攻击者难以重新利用现有代码,因为他们不知道代码在内存中的具体位置。
另外请注意,在示例 3-1 的输出中,gettysburg_address在 x86 架构下包含 8 个十六进制数字(4 字节),而在 x64 架构下包含 16 个十六进制数字(8 字节)。这应该是有道理的,因为在现代桌面系统上,指针的大小与 CPU 的通用寄存器相同。x86 架构具有 32 位(4 字节)的通用寄存器,而 x64 架构具有 64 位(8 字节)的通用寄存器。
解引用指针
解引用操作符(*)是一个一元操作符,用于访问指针所指向的对象。这是地址操作符的逆操作。给定一个地址,你可以获得驻留在该地址的对象。像地址操作符一样,系统程序员常常使用解引用操作符。许多操作系统 API 会返回指针,如果你想访问指针所指向的对象,你就需要使用解引用操作符。
不幸的是,解引用运算符可能会给初学者带来很多符号上的困惑,因为解引用运算符、指针声明和乘法都使用星号。请记住,你在声明指针时,应该在指向对象的类型后加上星号;但是,你在指针前加上解引用运算符——一个星号——像这样:
*gettysburg_address
在通过在指针前加解引用运算符来访问一个对象后,你可以像对待任何其他指向类型的对象一样对待该结果。例如,因为gettysburg是一个整数,你可以使用gettysburg_address将值 17325 写入gettysburg。正确的语法如下:
*gettysburg_address = 17325;
因为解引用指针——也就是*gettysburg_address——出现在等号的左侧,所以你正在向存储gettysburg的地址写入数据。
如果解引用指针出现在等号的右侧或其他地方,你就是在从该地址读取数据。要获取gettysburg_address指向的int,只需加上解引用运算符。例如,以下语句将打印gettysburg中存储的值:
printf("%d", *gettysburg_address);
清单 3-2 使用解引用运算符进行读写操作。
#include <cstdio>
int main() {
int gettysburg{};
int* gettysburg_address = &gettysburg; ➊
printf("Value at gettysburg_address: %d\n", *gettysburg_address); ➋
printf("Gettysburg Address: %p\n", gettysburg_address); ➌
*gettysburg_address = 17325; ➍
printf("Value at gettysburg_address: %d\n", *gettysburg_address); ➎
printf("Gettysburg Address: %p\n", gettysburg_address); ➏
--------------------------------------------------------------------------
Value at gettysburg_address: 0 ➋
Gettysburg Address: 000000B9EEEFFB04 ➌
Value at gettysburg_address: 17325 ➎
Gettysburg Address: 000000B9EEEFFB04 ➏
清单 3-2:一个使用指针进行读写操作的示例程序(输出来自 Windows 10 x64 机器)
首先,你将gettysburg初始化为零。然后,你将指针gettysburg_address初始化为gettysburg的地址 ➊。接着,你打印gettysburg_address指向的int ➋以及gettysburg_address本身的值 ➌。
你将值 17325 写入gettysburg_address指向的内存 ➍,然后再次打印该指针指向的值 ➎ 和地址 ➏。
如果你直接将值 17325 赋给gettysburg而不是gettysburg_address指针,像这样,清单 3-2 在功能上是完全相同的:
gettysburg = 17325;
本示例说明了指向对象(gettysburg)与指向该对象的解引用指针(*gettysburg_address)之间的密切关系。
成员指针运算符
成员指针运算符,或称为箭头运算符(->),执行两个同时的操作:
-
它解引用一个指针。
-
它访问指向对象的成员。
你可以使用这个运算符来减少符号摩擦,也就是程序员在编写代码时表达意图时遇到的阻力,尤其是在处理指向类的指针时。你将在各种设计模式中处理指向类的指针。例如,你可能需要将指向类的指针作为函数参数传递。如果接收函数需要与该类的成员进行交互,成员指针运算符就是完成该任务的工具。
清单 3-3 使用箭头运算符从ClockOfTheLongNow对象中读取year(该对象在清单 2-22 中实现,位于第 58 页)。
#include <cstdio>
struct ClockOfTheLongNow {
--snip--
};
int main() {
ClockOfTheLongNow clock;
ClockOfTheLongNow* clock_ptr = &clock; ➊
clock_ptr->set_year(2020); ➋
printf("Address of clock: %p\n", clock_ptr); ➌
printf("Value of clock's year: %d", clock_ptr->get_year()); ➍
}
--------------------------------------------------------------------------
Address of clock: 000000C6D3D5FBE4 ➌
Value of clock's year: 2020 ➍
列表 3-3:使用指针和箭头操作符操作ClockOfTheLongNow对象(输出来自一台 Windows 10 x64 机器)
你声明一个clock,然后将其地址存储在clock_ptr中➊。接着,你使用箭头操作符将clock的year成员设置为 2020➋。最后,你打印clock的地址➌以及year的值➍。
你可以使用解引用(*)和成员访问(.)操作符来实现相同的结果。例如,你可以将列表 3-3 的最后一行写成如下:
printf("Value of clock's year: %d", (*clock_ptr).get_year());
首先,你解引用clock_ptr,然后访问year。虽然这与调用指向成员的操作符等效,但它是一种冗长的语法,并且没有比更简单的替代方式带来额外的好处。
注意
现在,使用括号来强调运算顺序。第七章会详细讲解操作符的优先级规则。
指针与数组
指针与数组有几个相似之处。指针表示对象的位置,而数组则表示连续对象的起始位置和长度。
只要有一点触发,数组就会退化为指针。退化后的数组会失去长度信息,并转换为指向数组第一个元素的指针。例如:
int key_to_the_universe[]{ 3, 6, 9 };
int* key_ptr = key_to_the_universe; // Points to 3
首先,你初始化一个包含三个元素的int数组key_to_the_universe。接下来,你将int指针key_ptr初始化为指向key_to_the_universe,此时key_ptr会退化为一个指向数组的指针。初始化后,key_ptr指向key_to_the_universe的第一个元素。
列表 3-4 初始化一个包含College对象的数组,并将该数组作为指针传递给一个函数。
#include <cstdio>
struct College {
char name[256];
};
void print_name(College* college_ptr➊) {
printf("%s College\n", college_ptr->name➋);
}
int main() {
College best_colleges[] = { "Magdalen", "Nuffield", "Kellogg" };
print_name(best_colleges);
}
--------------------------------------------------------------------------
Magdalen College ➋
列表 3-4:展示数组退化为指针的程序
print_name函数接受一个指向College的指针作为参数➊,因此在调用print_name时,best_colleges数组会退化为指向其第一个元素的指针。因为数组退化成指向第一个元素的指针,所以➊处的college_ptr指向best_colleges中的第一个College。
列表 3-4 中也有另一个数组退化➋。你使用箭头操作符(->)访问college_ptr指向的College的name成员,而college_ptr本身是一个char数组。printf的格式说明符%s期望一个 C 风格的字符串,它是一个char指针,name退化为一个指针来满足printf的要求。
处理退化
通常,你会将数组作为两个参数传递:
-
指向第一个数组元素的指针
-
数组的长度
使这种模式得以实现的机制是方括号([]),它们与指针的作用与数组相同。列表 3-5 中使用了这种技术。
#include <cstdio>
struct College {
char name[256];
};
void print_names(College* colleges➊, size_t n_colleges➋) {
for (size_t i = 0; i < n_colleges; i++) { ➌
printf("%s College\n", colleges[i]➍.name➎);
}
}
int main() {
College oxford[] = { "Magdalen", "Nuffield", "Kellogg" };
print_names(oxford, sizeof(oxford) / sizeof(College));
}
--------------------------------------------------------------------------
Magdalen College
Nuffield College
Kellogg College
列表 3-5:展示将数组传递给函数的常见用法的程序
print_names函数接受两个参数的数组:指向第一个College元素的指针➊和元素数量n_colleges➋。在print_names内部,你使用for循环和索引i进行迭代。i的值从0迭代到n_colleges-1➌。
你通过访问第i个元素➍来提取对应的学院名称,然后获取name成员➎。
这种将指针与大小相结合的数组传递方法在 C 风格的 API 中无处不在,例如在 Windows 或 Linux 系统编程中。
指针运算
要获取数组中第n个元素的地址,你有两种选择。首先,你可以采取直接的方式,通过方括号([])获取第n个元素,然后使用取地址(&)运算符:
College* third_college_ptr = &oxford[2];
指针运算,即对指针进行加法和减法的规则,提供了一种替代方法。当你对指针进行加法或减法操作时,编译器会根据指向类型的大小计算出正确的字节偏移。例如,将 4 加到一个uint64_t指针上会增加 32 个字节:uint64_t占用 8 个字节,因此 4 个uint64_t占用 32 个字节。因此,下面的操作等同于前一种获取数组中第n个元素地址的方法:
College* third_college_ptr = oxford + 2;
指针是危险的
无法将指针转换为数组,这是件好事。你不应该需要这么做,而且一般来说,编译器也不可能从指针恢复数组的大小。但编译器无法阻止你尝试做所有危险的事情。
缓冲区溢出
对于数组和指针,你可以通过括号运算符([])或指针运算来访问任意的数组元素。这些是低级编程中非常强大的工具,因为你可以或多或少不经过抽象直接与内存交互。这让你对系统拥有精细的控制,某些环境(例如在系统编程环境中实现网络协议或嵌入式控制器时)中是必需的。然而,强大的能力伴随着巨大的责任,你必须非常小心。指针的简单错误可能会导致灾难性且神秘的后果。
清单 3-6 对两个字符串执行低级别操作。
#include <cstdio>
int main() {
char lower[] = "abc?e";
char upper[] = "ABC?E";
char* upper_ptr = upper; ➊ // Equivalent: &upper[0]
lower[3] = 'd'; ➋ // lower now contains a b c d e \0
upper_ptr[3] = 'D'; // upper now contains A B C D E \0
char letter_d = lower[3]; ➌ // letter_d equals 'd'
char letter_D = upper_ptr[3]; // letter_D equals 'D'
printf("lower: %s\nupper: %s", lower, upper); ➍
lower[7] = 'g'; ➎ // Super bad. You must never do this.
}
--------------------------------------------------------------------------
lower: abcde ➍
upper: ABCDE
The time is 2:14 a.m. Eastern time, August 29th. Skynet is now online. ➎
清单 3-6:包含缓冲区溢出的程序
在初始化lower和upper字符串后,你初始化upper_ptr指向upper中的第一个元素➊。然后,你将lower和upper的第四个元素(问号)重新赋值为d和D➋ ➌。注意,lower是一个数组,而upper_ptr是一个指针,但机制是相同的。到目前为止,一切顺利。
最后,你犯了一个大错,写出了超出边界的内存➎。通过访问索引为7的元素➍,你已经越过了分配给lower的存储空间。没有进行边界检查;这段代码可以在没有警告的情况下编译。
在运行时,你会遇到 未定义行为。未定义行为意味着 C++ 语言规范没有规定会发生什么,因此你的程序可能会崩溃、暴露安全漏洞或启动一个人工通用智能 ➎。
括号和指针算术之间的关系
要理解越界访问的影响,你必须理解括号操作符和指针算术之间的关系。考虑到你可以用指针算术和解引用操作符,而不是括号操作符来编写示例 3-6,正如在示例 3-7 中所示。
#include <cstdio>
int main() {
char lower[] = "abc?e";
char upper[] = "ABC?E";
char* upper_ptr = &upper[0];
*(lower + 3) = 'd';
*(upper_ptr + 3) = 'D';
char letter_d = *(lower + 3); // lower decays into a pointer when we add
char letter_D = *(upper_ptr + 3);
printf("lower: %s\nupper: %s", lower, upper);
*(lower + 7) = 'g'; ➊
}
示例 3-7:一个使用指针算术的与 示例 3-6 等效的程序
lower 数组的长度为 6(包括字母 a–e 和一个空终止符)。现在应该清楚为什么给 lower[7] 赋值 ➊ 是危险的了。在这种情况下,你正在写入一些不属于 lower 的内存。这可能导致访问违规、程序崩溃、安全漏洞和数据损坏。这些错误可能非常隐蔽,因为错误发生的点和 bug 显现出来的点可能相距甚远。
空指针和 std::byte 指针
有时,指向的类型并不重要。在这种情况下,你使用 void 指针 void*。void 指针有重要的限制,其中最主要的是你不能解引用一个 void*。因为指向的类型已被删除,解引用没有意义(回想一下 void 对象的值集是空的)。出于类似的原因,C++ 禁止 void 指针算术。
有时,你希望在字节级别与原始内存进行交互。举例来说,这包括低级操作,如在文件和内存之间复制原始数据、加密和压缩。你不能使用 void 指针来执行这些操作,因为位运算和算术操作被禁用了。在这种情况下,你可以使用 std::byte 指针。
nullptr 和布尔表达式
指针可以有一个特殊的字面值,nullptr。通常,等于 nullptr 的指针不指向任何东西。例如,你可以使用 nullptr 来表示没有更多内存可以分配,或者某个错误发生了。
指针有一个隐式转换为 bool 的机制。任何不等于 nullptr 的值会隐式转换为 true,而 nullptr 会隐式转换为 false。这在一个返回指针的函数成功执行时非常有用。一个常见的惯用法是,函数在失败时返回 nullptr。一个经典例子是内存分配。
引用
引用 是比指针更安全、更方便的版本。你通过在类型名后附加 & 声明引用。引用不能轻易赋值为 null,且不能被 重新定位(或重新赋值)。这些特性消除了指针中一些常见的错误。
处理引用的语法比指针更简洁。你无需使用指针的成员操作符和解引用操作符,引用就像是指向类型一样直接使用。
清单 3-8 特征是一个引用参数。
#include <cstdio>
struct ClockOfTheLongNow {
--snip--
};
void add_year(ClockOfTheLongNow&➊ clock) {
clock.set_year(clock.get_year() + 1); ➋ // No deref operator needed
}
int main() {
ClockOfTheLongNow clock;
printf("The year is %d.\n", clock.get_year()); ➌
add_year(clock); ➍ // Clock is implicitly passed by reference!
printf("The year is %d.\n", clock.get_year()); ➎
}
--------------------------------------------------------------------------
The year is 2019\. ➌
The year is 2020\. ➎
清单 3-8:使用引用的程序
你通过使用与星号不同的&符号将clock参数声明为ClockOfTheLongNow的引用 ➊。在add_year中,你像使用ClockOfTheLongNow类型一样使用clock ➋:无需使用笨拙的解引用和指针到引用操作符。首先,你打印year的值 ➌。接着,在调用点,你直接将一个ClockOfTheLongNow对象传入add_year ➍:无需获取它的地址。最后,你再次打印year的值,说明它已经增加 ➎。
指针和引用的使用
指针和引用在很大程度上是可以互换的,但两者各有优缺点。如果你有时必须改变引用类型的值——也就是说,如果你必须改变引用类型所指向的内容——你必须使用指针。许多数据结构(包括下一节将介绍的前向链表)要求你能够更改指针的值。由于引用不能重新指向并且通常不应赋值为nullptr,因此它们有时不适用。
前向链表:典型的基于指针的数据结构
前向链表是一种由一系列元素组成的简单数据结构。每个元素保存一个指向下一个元素的指针。链表中的最后一个元素保存一个nullptr。将元素插入链表非常高效,并且元素在内存中可以是不连续的。图 3-1 说明了它们的布局。

图 3-1:链表
清单 3-9 演示了单向链表元素的一种可能实现。
struct Element {
Element* next{}; ➊
void insert_after(Element* new_element) { ➋
new_element->next = next; ➌
next = new_element; ➍
}
char prefix[2]; ➎
short operating_number; ➏
};
清单 3-9:具有操作编号的链表 Element 实现
每个element都有一个指向链表中next元素的指针 ➊,并初始化为nullptr。你通过insert_after方法插入一个新元素 ➋。它将new_element的next成员设置为this的next ➌,然后将this的next设置为new_element ➍。图 3-2 演示了这个插入过程。你并没有改变任何Element对象的内存位置;你只是修改了指针的值。

图 3-2:插入元素到链表中
每个Element还包含一个prefix数组 ➎和一个operating_number短整数 ➏。
清单 3-10 遍历一个Element类型的风暴兵链表,并沿途打印其操作编号。
#include <cstdio>
struct Element {
--snip--
};
int main() {
Element trooper1, trooper2, trooper3; ➊
trooper1.prefix[0] = 'T';
trooper1.prefix[1] = 'K';
trooper1.operating_number = 421;
trooper1.insert_after(&trooper2); ➋
trooper2.prefix[0] = 'F';
trooper2.prefix[1] = 'N';
trooper2.operating_number = 2187;
trooper2.insert_after(&trooper3); ➌
trooper3.prefix[0] = 'L';
trooper3.prefix[1] = 'S';
trooper3.operating_number = 005; ➍
for (Element *cursor = &trooper1➎; cursor➏; cursor = cursor->next➐) {
printf("stormtrooper %c%c-%d\n",
cursor->prefix[0],
cursor->prefix[1],
cursor->operating_number); ➑
}
}
--------------------------------------------------------------------------
stormtrooper TK-421 ➑
stormtrooper FN-2187 ➑
stormtrooper LS-5 ➑
清单 3-10:演示前向链表的程序
清单 3-10 初始化了三个暴风兵 ➊。元素trooper1被赋予操作编号 TK-421,然后你将其作为链表中的下一个元素插入 ➋。元素trooper2和trooper3的操作编号分别为 FN-2187 和 LS-005,也被插入到链表中 ➌➍。
for循环遍历链表。首先,你将光标指针分配给trooper1的地址 ➎。这是链表的起始位置。在每次迭代之前,你确保cursor不是nullptr ➏。每次迭代后,你将cursor设置为next元素 ➐。在循环内,你打印每个暴风兵的操作编号 ➑。
使用引用
指针提供了很大的灵活性,但这种灵活性是以安全性为代价的。如果你不需要重定向和nullptr的灵活性,引用是首选的引用类型。
让我们再强调一次,引用不能被重新设置。清单 3-11 初始化了一个int引用,然后尝试用new_value重新设置它。
#include <cstdio>
int main() {
int original = 100;
int& original_ref = original;
printf("Original: %d\n", original); ➊
printf("Reference: %d\n", original_ref); ➋
int new_value = 200;
original_ref = new_value; ➌
printf("Original: %d\n", original); ➍
printf("New Value: %d\n", new_value); ➎
printf("Reference: %d\n", original_ref); ➏
}
--------------------------------------------------------------------------
Original: 100 ➊
Reference: 100 ➋
Original: 200 ➍
New Value: 200 ➎
Reference: 200 ➏
清单 3-11:演示无法重新设置引用的程序
该程序初始化一个名为original的int变量为 100。然后声明一个对original的引用,称为original_ref。从此以后,original_ref将始终指向original。这一点通过打印original的值 ➊ 和original_ref所引用的值 ➋ 来说明。它们是相同的。
接下来,你初始化另一个int变量new_value为 200,并将其赋值给original ➌。仔细阅读:此赋值 ➌ 并不会重新设置original_ref指向new_value。而是将new_value的值赋给它所指向的对象(即original)。
结果是,这些变量——original、original_ref和new_value——的值都为 200 ➍➎➏。
this 指针
记住,方法与类相关,类的实例是对象。当你编写方法时,有时你需要访问当前对象,即正在执行该方法的对象。
在方法定义中,你可以使用this指针访问当前对象。通常不需要this,因为在访问成员时this是隐式的。但是有时你可能需要消除歧义——例如,如果你声明了一个与成员变量同名的方法参数。例如,你可以重写清单 3-9,明确指出你指的是哪个Element,如清单 3-12 所示。
struct Element {
Element* next{};
void insert_after(Element* new_element) {
new_element->next = this->next; ➊
this->next ➋ = new_element;
}
char prefix[2];
short operating_number;
};
清单 3-12:使用this指针重写清单 3-9
在这里,next被替换为this->next ➊➋。这些清单在功能上是相同的。
有时,你需要this来解决成员与参数之间的歧义,正如清单 3-13 所示。
struct ClockOfTheLongNow {
bool set_year(int year➊) {
if (year < 2019) return false;
this->year = year; ➋
return true;
}
--snip--
private:
int year; ➌
};
清单 3-13:使用this的冗长ClockOfTheLongNow定义
year 参数 ➊ 与 year 成员 ➌ 同名。方法参数总是会遮蔽成员,这意味着当你在该方法中输入 year 时,它指的是 year 参数 ➊,而不是 year 成员 ➌。这没问题:你可以通过 this ➋ 来消除歧义。
const 正确性
const 关键字(常用作“常量”的缩写)大致意味着“我承诺不会修改”。它是一种安全机制,防止无意中(以及潜在的灾难性)修改成员变量。你将在函数和类的定义中使用 const 来指定一个变量(通常是引用或指针)在该函数或类中不会被修改。如果代码试图修改一个 const 变量,编译器将发出错误。当正确使用时,const 是所有现代编程语言中最强大的语言特性之一,因为它有助于你在编译时消除许多常见的编程错误。
让我们看看 const 的一些常见用法。
const 参数
将参数标记为 const 使得它不能在函数的作用域内被修改。const 指针或引用为你提供了一种高效的机制,可以将对象传递给函数进行只读使用。列表 3-14 中的函数接受一个 const 指针。
void petruchio(const char* shrew➊) {
printf("Fear not, sweet wench, they shall not touch thee, %s.", shrew➋);
shrew[0] = "K"; ➌ // Compiler error! The shrew cannot be tamed.
}
列表 3-14:一个接受 const 指针的函数(此代码无法编译。)
petruchio 函数通过 const 引用 ➊ 接受一个 shrew 字符串。你可以从 shrew ➋ 中读取,但尝试写入它会导致编译错误 ➌。
const 方法
将方法标记为 const 表示你承诺在该 const 方法中不会修改当前对象的状态。换句话说,这些方法是只读的。
要将方法标记为 const,请将 const 关键字放在参数列表之后但在方法体之前。例如,你可以像 列表 3-15 中演示的那样更新 ClockOfTheLongNow 对象的 get_year 方法,添加 const。
struct ClockOfTheLongNow {
--snip--
int get_year() const ➊{
return year;
}
private:
int year;
};
列表 3-15:使用 const 更新 ClockOfTheLongNow
你只需要将 const 放在参数列表和方法体之间 ➊。如果你尝试在 get_year 中修改 year,编译器将生成一个错误。
持有 const 引用和指针的对象不能调用非 const 方法,因为非 const 方法可能会修改对象的状态。
列表 3-16 中的 is_leap_year 函数接受一个 const ClockOfTheLongNow 引用,并判断其是否为闰年。
bool is_leap_year(const ClockOfTheLongNow& clock) {
if (clock.get_year() % 4 > 0) return false;
if (clock.get_year() % 100 > 0) return true;
if (clock.get_year() % 400 > 0) return false;
return true;
}
列表 3-16:一个判断是否为闰年的函数
如果 get_year 没有被标记为 const 方法,列表 3-16 将无法编译,因为 clock 是一个 const 引用,不能在 is_leap_year 中被修改。
const 成员变量
你可以通过将 const 关键字添加到成员的类型中来标记成员变量为 const。这些 const 成员变量在初始化后不能被修改。
在清单 3-17 中,Avout类包含两个成员变量,一个是const类型,一个不是const类型。
struct Avout {
const➊ char* name = "Erasmas";
ClockOfTheLongNow apert; ➋
};
清单 3-17:具有const成员的Avout类
name成员是const,这意味着指向的值无法修改 ➊。另一方面,apert不是const ➋。
当然,const Avout引用不能被修改,因此通常的规则仍然适用于apert:
void does_not_compile(const Avout& avout) {
avout.apert.add_year(); // Compiler error: avout is const
}
有时你想标记一个成员变量为const,但又希望使用传入构造函数的参数来初始化该成员。为此,你可以使用成员初始化列表。
成员初始化列表
成员初始化列表是初始化类成员的主要机制。要声明一个成员初始化列表,在构造函数的参数列表后加上冒号,然后插入一个或多个逗号分隔的成员初始化器。成员初始化器是成员的名称,后面跟着一个大括号初始化{ }。成员初始化器允许你在运行时设置const字段的值。
清单 3-18 中的示例通过引入成员初始化列表改进了清单 3-17。
#include <cstdio>
struct ClockOfTheLongNow {
--snip--
};
struct Avout {
Avout(const char* name, long year_of_apert) ➊
:➋ name➌{ name }➍, apert➎{ year_of_apert }➏ {
}
void announce() const { ➐
printf("My name is %s and my next apert is %d.\n", name, apert.get_year());
}
const char* name;
ClockOfTheLongNow apert;
};
int main() {
Avout raz{ "Erasmas", 3010 };
Avout jad{ "Jad", 4000 };
raz.announce();
jad.announce();
}
--------------------------------------------------------------------------
My name is Erasmas and my next apert is 3010.
My name is Jad and my next apert is 4000.
清单 3-18:声明并初始化两个Avout对象的程序
Avout构造函数接受两个参数,一个是name,另一个是year_of_apert ➊。通过插入一个冒号 ➋ 来添加成员初始化列表,接着列出你要初始化的每个成员的名称 ➌➎ 和大括号初始化 ➍➏。还添加了一个const类型的announce方法,用于打印Avout构造函数的状态 ➐。
所有成员初始化在构造函数体执行之前都会被执行。这有两个优点:
-
它确保在构造函数执行之前所有成员都有效,因此你可以专注于初始化逻辑,而不是成员错误检查。
-
成员只会初始化一次。如果你在构造函数中重新赋值成员,可能会做一些额外的工作。
注意
你应该按它们在类定义中出现的顺序排列成员初始化器,因为它们的构造函数将按此顺序被调用。
说到消除额外的工作,是时候介绍auto了。
auto 类型推导
作为一种强类型语言,C++为其编译器提供了大量信息。当你初始化元素或从函数返回时,编译器可以根据上下文推断出类型信息。auto关键字告诉编译器为你执行这样的推断,从而免去你输入冗余的类型信息。
使用 auto 初始化
在几乎所有情况下,编译器可以根据初始化值确定对象的正确类型。这种赋值包含冗余信息:
int answer = 42;
编译器知道answer是int类型,因为 42 是int类型。
你也可以使用auto代替:
auto the_answer { 42 }; // int
auto foot { 12L }; // long
auto rootbeer { 5.0F }; // float
auto cheeseburger { 10.0 }; // double
auto politifact_claims { false }; // bool
auto cheese { "string" }; // char[7]
当你使用括号()和单独的=进行初始化时,这种方式同样适用:
auto the_answer = 42;
auto foot(12L);
--snip--
因为你尽可能使用{}进行统一初始化,本节将不再讨论这些替代方法。
单独使用这些简单的初始化帮助并不能给你带来太多好处;然而,当类型变得更加复杂时——例如,处理来自标准库容器的迭代器——它确实节省了很多输入工作量。它还使你的代码在重构时更具弹性。
auto 和引用类型
通常会在 auto 上添加修饰符,如 &、* 和 const。这些修饰符分别添加了预期的含义(引用、指针和 const):
auto year { 2019 }; // int
auto& year_ref = year; // int&
const auto& year_cref = year; // const int&
auto* year_ptr = &year; // int*
const auto* year_cptr = &year; // const int*
向 auto 声明中添加修饰符的行为如你所预期:如果你添加了修饰符,结果类型将确保包含该修饰符。
auto 和代码重构
auto 关键字有助于简化代码并提高代码在重构时的弹性。考虑示例 3-19 中的基于范围的 for 循环示例。
struct Dwarf {
--snip--
};
Dwarf dwarves[13];
struct Contract {
void add(const Dwarf&);
};
void form_company(Contract &contract) {
for (const auto& dwarf : dwarves) { ➊
contract.add(dwarf);
}
}
示例 3-19:在基于范围的 for 循环中使用 auto 的示例
如果 dwarves 的类型发生变化,基于范围的 for 循环中的赋值 ➊ 不需要修改。dwarf 类型将根据其环境自适应,就像中土世界的矮人一样。
一般来说,始终使用 auto。
注意
使用大括号初始化时,可能会出现一些边缘情况,导致你得到意想不到的结果,但这种情况很少,特别是在 C++17 修复了一些过于苛刻的行为后。在 C++17 之前,使用带大括号 {} 的 auto 会指定一个特殊对象 std::initializer_list,你将在第十三章中遇到它。
总结
本章介绍了两种引用类型:引用和指针。在此过程中,你学习了成员指针操作符、指针与数组的相互作用以及 void/byte 指针。你还了解了 const 的含义及其基本用法、this 指针和成员初始化列表。此外,本章还介绍了 auto 类型推导。
练习
3-1. 阅读有关 CVE-2001-0500 的内容,这是微软 Internet 信息服务中的一个缓冲区溢出漏洞。(此漏洞通常被称为 Code Red 蠕虫漏洞。)
3-2. 向示例 3-6 中添加一个 read_from 和一个 write_to 函数。这些函数应根据需要读取或写入 upper 或 lower。进行边界检查以防止缓冲区溢出。
3-3. 向示例 3-9 中添加一个 Element* previous,以创建一个双向链表。向 Element 添加一个 insert_before 方法。使用两个独立的 for 循环从前向后遍历列表,再从后向前遍历。打印每个循环中的 operating_number。
3-4. 使用没有显式类型的方式重新实现示例 3-11。 (提示:使用 auto。)
3-5. 浏览第二章中的示例。哪些方法可以标记为 const?你在哪里可以使用 auto?
进一步阅读
-
《C++ 编程语言》,第 4 版,Bjarne Stroustrup 著(Pearson Education,2013)
-
《C++ 核心准则》由 Bjarne Stroustrup 和 Herb Sutter 编著(*
github.com/isocpp/CppCoreGuidelines/*) -
《East End Functions》由 Phil Nash 编著(2018 年;*
levelofindirection.com/blog/east-end-functions.html*) -
《引用常见问题解答》由标准 C++基金会编写(*
isocpp.org/wiki/faq/references/*)
第六章:对象生命周期
你曾经拥有的东西,现在它们拥有你。
—Chuck Palahniuk, 搏击俱乐部

对象生命周期是 C++对象在其生命周期中经历的一系列阶段。本章从讨论对象的存储持续时间开始,即为对象分配存储的时间。你将学习对象生命周期如何与异常处理结合,以一种稳健、安全和优雅的方式处理错误和清理工作。章节的最后讨论了移动和拷贝语义,帮助你对对象的生命周期进行细粒度的控制。
对象的存储持续时间
对象是一个具有类型和值的存储区域。当你声明一个变量时,你就创建了一个对象。变量只是一个有名字的对象。
分配、释放和生命周期
每个对象都需要存储。你通过分配过程为对象保留存储。当你不再使用对象时,你通过释放过程释放对象的存储。
对象的存储持续时间从对象分配开始,到对象被释放结束。对象的生命周期是一个运行时属性,受对象存储持续时间的约束。对象的生命周期从构造函数完成时开始,到析构函数被调用前结束。总的来说,每个对象都会经历以下几个阶段:
-
对象的存储持续时间开始,存储被分配。
-
对象的构造函数被调用。
-
对象的生命周期开始。
-
你可以在程序中使用该对象。
-
对象的生命周期结束。
-
对象的析构函数被调用。
-
对象的存储持续时间结束,存储被释放。
内存管理
如果你曾经在应用程序语言中编程过,你很可能使用过自动内存管理器或垃圾回收器。在运行时,程序会创建对象。垃圾回收器会定期检查哪些对象不再被程序需要,并安全地释放它们。这个方法让程序员不必担心管理对象的生命周期,但它也带来了几个成本,包括运行时性能问题,并且需要一些强大的编程技巧,比如确定性的资源管理。
C++采用了一种更高效的方法。其折衷之处在于,C++程序员必须深入了解存储持续时间。这是我们的工作,而不是垃圾回收器的工作,我们需要设计对象的生命周期。
自动存储持续时间
自动对象会在一个代码块开始时分配,并在代码块结束时释放。这个代码块是自动对象的作用域。自动对象被认为具有自动存储持续时间。请注意,即使函数参数的符号上看起来在函数体外部,函数参数也是自动的。
在清单 4-1 中,函数power_up_rat_thing是自动变量nuclear_isotopes和waste_heat的作用域。
void power_up_rat_thing(int nuclear_isotopes) {
int waste_heat = 0;
--snip--
}
清单 4-1:一个包含两个自动变量nuclear_isotopes和waste_heat的函数
每次调用power_up_rat_thing时,nuclear_isotopes和waste_heat都会被分配。在power_up_rat_thing返回之前,这些变量会被释放。
由于你无法在power_up_rat_thing之外访问这些变量,自动变量也被称为局部变量。
静态存储持续时间
使用static或extern关键字声明一个静态对象。你在声明函数的同一层次上声明静态变量——在全局作用域(或命名空间作用域)中。具有全局作用域的静态对象具有静态存储持续时间,它们在程序启动时分配,并在程序停止时释放。
在清单 4-2 中的程序通过调用power_up_rat_thing函数来为 Rat Thing 提供能量。当这样做时,Rat Thing 的能量增加,变量rat_things_power在每次能量提升之间跟踪能量水平。
#include <cstdio>
static int rat_things_power = 200; ➊
void power_up_rat_thing(int nuclear_isotopes) {
rat_things_power = rat_things_power + nuclear_isotopes; ➋
const auto waste_heat = rat_things_power * 20; ➌
if (waste_heat > 10000) { ➍
printf("Warning! Hot doggie!\n"); ➎
}
}
int main() {
printf("Rat-thing power: %d\n", rat_things_power); ➏
power_up_rat_thing(100); ➐
printf("Rat-thing power: %d\n", rat_things_power);
power_up_rat_thing(500);
printf("Rat-thing power: %d\n", rat_things_power);
}
--------------------------------------------------------------------------
Rat-thing power: 200
Rat-thing power: 300
Warning! Hot doggie! ➑
Rat-thing power: 800
清单 4-2:一个包含静态变量和多个自动变量的程序
变量rat_things_power➊是静态变量,因为它在全局作用域中使用static关键字声明。作为在全局作用域中声明的另一个特点是,rat_things_power可以从翻译单元中的任何函数访问。(回想一下第一章,翻译单元是预处理器处理单个源文件后产生的内容。)在➋处,你看到power_up_rat_thing通过nuclear_isotopes的数量增加rat_things_power。由于rat_things_power是静态变量——因此它的生命周期是程序的生命周期——每次调用power_up_rat_thing时,rat_things_power的值都会延续到下一次调用。
接下来,你根据新的rat_things_power值计算产生的废热,并将结果存储在自动变量waste_heat中➌。它的存储持续时间从调用power_up_rat_thing开始,到power_up_rat_thing返回时结束,因此它的值不会在函数调用之间保存。最后,你检查waste_heat是否超过1000的阈值➍。如果是,你打印警告信息➎。
在main中,你交替打印rat_things_power的值➏并调用power_up_rat_thing➐。
一旦你将 Rat Thing 的能量从300增加到800,你将在输出中看到警告信息➑。由于rat_things_power具有静态存储持续时间,因此修改rat_things_power的效果会持续到程序的生命周期。
当你使用static关键字时,你指定了内部链接。内部链接意味着一个变量对其他翻译单元不可访问。你也可以指定外部链接,使变量对其他翻译单元可访问。对于外部链接,你使用extern关键字而不是static。
你可以像下面这样修改清单 4-2 以实现外部链接:
#include <cstdio>
extern int rat_things_power = 200; // External linkage
--snip--
使用extern而不是static,你可以从其他翻译单元访问rat_things_power。
局部静态变量
局部静态变量是一种特殊的静态变量,它是局部的——而不是全局的——变量。局部静态变量在函数作用域内声明,就像自动变量一样。但它们的生命周期从包含函数第一次调用开始,直到程序退出时结束。
例如,你可以将清单 4-2 重构,使rat_things_power成为一个局部静态变量,如清单 4-3 所示。
#include <cstdio>
void power_up_rat_thing(int nuclear_isotopes) {
static int rat_things_power = 200;
rat_things_power = rat_things_power + nuclear_isotopes;
const auto waste_heat = rat_things_power * 20;
if (waste_heat > 10000) {
printf("Warning! Hot doggie!\n");
}
printf("Rat-thing power: %d\n", rat_things_power);
}
int main() {
power_up_rat_thing(100);
power_up_rat_thing(500);
}
清单 4-3:使用局部静态变量重构清单 4-2。
与清单 4-2 不同,由于rat_things_power的局部作用域,你不能从power_up_rat_thing函数外部引用它。这是一个被称为封装的编程模式的例子,封装是将数据与操作该数据的函数捆绑在一起。它有助于防止意外修改。
静态成员
静态成员是类的成员,不与类的特定实例关联。正常的类成员生命周期嵌套在类的生命周期内,但静态成员具有静态存储持续时间。
这些成员本质上与在全局作用域声明的静态变量和函数类似;然而,你必须使用包含类的名称来引用它们,使用作用域解析运算符::。事实上,你必须在全局作用域初始化静态成员。你不能在包含类定义内初始化静态成员。
注意
静态成员初始化规则有一个例外:你可以在类定义中声明并定义整型类型,只要它们也是const。
像其他静态变量一样,静态成员只有一个实例。所有包含静态成员的类实例共享相同的成员,因此如果你修改了静态成员,所有类实例都会看到这个修改。为了说明这一点,你可以将清单 4-2 中的power_up_rat_thing和rat_things_power转换为RatThing类的静态成员,如清单 4-4 所示。
#include <cstdio>
struct RatThing {
static int rat_things_power; ➊
static➋ void power_up_rat_thing(int nuclear_isotopes) {
rat_things_power➌ = rat_things_power + nuclear_isotopes;
const auto waste_heat = rat_things_power * 20;
if (waste_heat > 10000) {
printf("Warning! Hot doggie!\n");
}
printf("Rat-thing power: %d\n", rat_things_power);
}
};
int RatThing::rat_things_power = 200; ➍
int main() {
RatThing::power_up_rat_thing(100); ➎
RatThing::power_up_rat_thing(500);
}
清单 4-4:使用静态成员重构清单 4-2
RatThing类包含作为静态成员变量的rat_things_power ➊ 和作为静态方法的power_up_rat_thing ➋。由于rat_things_power是RatThing的成员,你无需使用作用域解析符号 ➌;你可以像访问其他成员一样访问它。
你可以看到作用域解析符号的作用,在这里rat_things_power被初始化 ➍,并在这里调用了静态方法power_up_rat_thing ➎。
线程局部存储持续时间
并发程序中的一个基本概念是线程。每个程序有一个或多个线程,可以执行独立的操作。线程执行的指令序列称为它的执行线程。
程序员在使用多个执行线程时必须格外小心。多个线程可以安全执行的代码称为线程安全代码。可变的全局变量是许多线程安全问题的根源。有时,你可以通过为每个线程提供自己的一份变量副本来避免这些问题。你可以通过指定对象具有线程存储持续时间来实现这一点。
你可以通过在static或extern关键字前添加thread_local关键字,将任何具有静态存储持续时间的变量修改为线程局部存储持续时间。如果仅指定thread_local,则默认使用static。变量的连接性保持不变。
Listing 4-3 不是线程安全的。根据读写的顺序,rat_things_power可能会被破坏。你可以通过将rat_things_power指定为thread_local,使 Listing 4-3 变得线程安全,如下所示:
#include <cstdio>
void power_up_rat_thing(int nuclear_isotopes) {
static thread_local int rat_things_power = 200; ➊
--snip--
}
现在,每个线程都会代表它自己的鼠标物体(Rat Thing);如果一个线程修改了它的rat_things_power,该修改不会影响其他线程。每个rat_things_power的副本初始化为 200 ➊。
注意
并发编程在第十九章中有更详细的讨论。线程存储持续时间在此处列出,以补充完整。
动态存储持续时间
具有动态存储持续时间的对象会根据请求进行分配和解除分配。你可以手动控制动态对象的生命周期何时开始,何时结束。由于这个原因,动态对象也被称为分配对象。
分配动态对象的主要方式是使用new 表达式。new 表达式以new关键字开始,后面跟着所需的动态对象类型。new 表达式创建给定类型的对象,并返回指向新创建对象的指针。
考虑以下示例,其中你创建了一个具有动态存储持续时间的int类型,并将其保存在一个名为my_int_ptr的指针中:
int*➊ my_int_ptr = new➋ int➌;
你声明了一个指向int的指针,并用右侧等号的 new 表达式的结果对其进行初始化 ➊。new 表达式由new关键字 ➋ 和所需类型int ➌ 组成。当 new 表达式执行时,C++运行时分配内存来存储一个int,然后返回其指针。
你也可以在新表达式中初始化一个动态对象,如下所示:
int* my_int_ptr = new int{ 42 }; // Initializes dynamic object to 42
在为 int 分配存储空间后,动态对象将像往常一样初始化。初始化完成后,动态对象的生命周期开始。
你通过 删除表达式 来释放动态对象,该表达式由 delete 关键字和指向动态对象的指针组成。删除表达式始终返回 void。
要释放由 my_int_ptr 指向的对象,可以使用以下删除表达式:
delete my_int_ptr;
删除对象所在内存的值是未定义的,这意味着编译器可以生成留下任何内容的代码。实际上,主要编译器会尽可能高效,因此通常对象的内存会保持不变,直到程序为其他用途重新使用它。你需要实现一个自定义析构函数,例如将一些敏感内容清零。
注意
由于编译器通常不会在删除对象后清理内存,可能会发生一种微妙且潜在严重的错误,称为 释放后使用。如果你删除了一个对象并意外地重新使用它,程序可能看起来正常工作,因为释放的内存可能仍然包含合理的值。在某些情况下,问题直到程序投入生产很长时间后才会显现出来,或者直到安全研究人员找到漏洞的利用方式并公开披露!
动态数组
动态数组 是具有动态存储持续时间的数组。你可以通过 数组新表达式 来创建动态数组。数组新表达式的形式如下:
new MyType[n_elements] { init-list }
MyType 是数组元素的期望类型,n_elements 是所需数组的长度,init-list 是一个可选的初始化列表,用于初始化数组。数组新表达式返回一个指向新分配数组第一个元素的指针。
在以下示例中,你分配一个长度为 100 的 int 数组,并将结果保存到一个名为 my_int_array_ptr 的指针中:
int* my_int_array_ptr = new int[100➊];
元素的数量 ➊ 不需要保持不变:数组的大小可以在运行时确定,这意味着括号中的值 ➊ 可以是一个变量,而不是字面量。
要释放动态数组,使用 数组删除表达式。与数组新表达式不同,数组删除表达式不需要指定长度:
delete[] my_int_array_ptr
像删除表达式一样,数组删除表达式返回 void。
内存泄漏
权力伴随着责任,因此你必须确保分配的动态对象也被释放。如果不这样做,会导致 内存泄漏,即程序不再需要的内存没有被释放。当内存泄漏发生时,你消耗了一个环境中的资源,且永远无法恢复。这可能会导致性能问题,甚至更严重的后果。
注意
实际上,你程序的操作环境可能会为你清理泄漏的资源。例如,如果你编写的是用户模式代码,现代操作系统将在程序退出时清理资源。然而,如果你编写的是内核代码,这些操作系统则不会清理资源。你只有在计算机重新启动时才会回收它们。
追踪对象生命周期
对象生命周期对新手来说既令人生畏又强大。让我们通过一个例子来澄清它,探索每种存储持续时间。
请参考 清单 4-5 中的 Tracer 类,每当一个 Tracer 对象被构造或析构时,它都会打印一条消息。你可以使用这个类来调查对象生命周期,因为每个 Tracer 清楚地指示了它的生命周期何时开始和结束。
#include <cstdio>
struct Tracer {
Tracer(const char* name➊) : name{ name }➋ {
printf("%s constructed.\n", name); ➌
}
~Tracer() {
printf("%s destructed.\n", name); ➍
}
private:
const char* const name;
};
清单 4-5:一个 Tracer 类,用于宣布构造和析构
构造函数接受一个参数 ➊ 并将其保存到成员 name ➋ 中。然后,它打印包含 name 的消息 ➌。析构函数 ➍ 也打印包含 name 的消息。
请参考 清单 4-6 中的程序。四个不同的 Tracer 对象有不同的存储持续时间。通过查看程序中 Tracer 输出的顺序,你可以验证你学到的关于存储持续时间的知识。
#include <cstdio>
struct Tracer {
--snip--
};
static Tracer t1{ "Static variable" }; ➊
thread_local Tracer t2{ "Thread-local variable" }; ➋
int main() {
const auto t2_ptr = &t2;
printf("A\n"); ➌
Tracer t3{ "Automatic variable" }; ➍
printf("B\n");
const auto* t4 = new Tracer{ "Dynamic variable" }; ➎
printf("C\n");
}
清单 4-6:一个使用 清单 4-5 中的 Tracer 类来说明存储持续时间的程序
清单 4-6 包含一个具有静态持续时间 ➊、线程局部持续时间 ➋、自动持续时间 ➍ 和动态持续时间 ➎ 的 Tracer。在 main 中的每一行之间,你会打印字符 A、B 或 C 作为参考 ➌。
运行程序会生成 清单 4-7 中的输出。
Static variable constructed.
Thread-local variable constructed.
A ➌
Automatic variable constructed.
B
Dynamic variable constructed.
C
Automatic variable destructed.
Thread-local variable destructed.
Static variable destructed.
清单 4-7:运行 清单 4-6 的示例输出
在 main 的第一行 ➌ 之前,静态和线程局部变量 t1 和 t2 已经初始化 ➊ ➋。你可以在 清单 4-7 中看到:在打印 A 之前,这两个变量已经打印了它们的初始化消息。作为一个自动变量,t3 的作用域由封闭的函数 main 限制。因此,t3 在初始化时构造,紧接着在 A 后面。
在 B 之后,你会看到与 t4 初始化对应的消息 ➎。注意,没有由 Tracer 的动态析构函数生成相应的消息。原因是你(故意)泄漏了 t4 指向的对象。由于没有 delete t4 的命令,析构函数永远不会被调用。
在 main 返回之前,C 会打印。因为 t3 是一个自动变量,其作用域为 main,所以在此时它会被销毁,因为 main 正在返回。
最后,静态和线程局部变量 t1 和 t2 在程序退出前被销毁,从而输出了 清单 4-7 中的最后两条消息。
异常
异常是用来传达错误条件的类型。当错误条件发生时,你抛出一个异常。在你抛出异常后,异常处于处理中状态。当异常处于处理中时,程序停止正常执行,并搜索一个可以处理该异常的异常处理器。在这个过程中,超出作用域的对象将被销毁。
在无法局部处理错误的情况下,比如在构造函数中,你通常会使用异常。异常在这种情况下在管理对象生命周期中发挥着至关重要的作用。
另一种传达错误条件的方式是将错误代码作为函数原型的一部分返回。这两种方法是互补的。在发生可以局部处理的错误,或者在程序正常执行过程中预期会发生的错误时,通常会返回错误代码。
throw关键字
要抛出异常,使用throw关键字,后跟一个可抛出的对象。
大多数对象都是可抛出的。但遵循一个好习惯是使用<stdexcept>头文件中可用的异常之一,如std::runtime_error。runtime_error构造函数接受一个以空字符结尾的const char*,用于描述错误条件的性质。你可以通过what方法来获取这个消息,what方法不接受任何参数。
在清单 4-8 中的Groucho类,每当你使用参数0xFACE调用forget方法时,它都会抛出异常。
#include <stdexcept>
#include <cstdio>
struct Groucho {
void forget(int x) {
if (x == 0xFACE) {
throw➊ std::runtime_error➋{ "I'd be glad to make an exception." };
}
printf("Forgot 0x%x\n", x);
}
};
清单 4-8:Groucho类
要抛出异常,清单 4-8 使用throw关键字 ➊,后跟一个std::runtime_error对象 ➋。
使用try-catch块
你使用try-catch块来为一段代码建立异常处理程序。在try块内,放置可能抛出异常的代码。在catch块内,你指定一个处理程序来处理每种可以处理的异常类型。
清单 4-9 演示了使用try-catch块来处理Groucho对象抛出的异常。
#include <stdexcept>
#include <cstdio>
struct Groucho {
--snip--
};
int main() {
Groucho groucho;
try { ➊
groucho.forget(0xC0DE); ➋
groucho.forget(0xFACE); ➌
groucho.forget(0xC0FFEE); ➍
} catch (const std::runtime_error& e➎) {
printf("exception caught with message: %s\n", e.what()); ➏
}
}
清单 4-9:使用try-catch来处理Groucho类的异常
在main中,你构造了一个Groucho对象,并建立了一个try-catch块 ➊。在try部分,你调用了groucho类的forget方法,并传入了几个不同的参数:0xC0DE ➋、0xFACE ➌和0xC0FFEE ➍。在catch部分,你通过打印消息到控制台 ➏来处理任何std::runtime_error异常 ➎。
当你运行清单 4-9 中的程序时,输出如下:
Forgot 0xc0de
exception caught with message: I'd be glad to make an exception.
当你用参数0xC0DE ➋调用forget时,groucho打印了Forgot 0xc0de并返回。当你用参数0xFACE ➌调用forget时,groucho抛出了异常。这个异常中断了正常的程序执行,因此forget再也没有被调用 ➍。相反,正在处理中异常被捕获 ➎,并打印了它的消息 ➏。
继承的速成课程
在介绍 std 库异常之前,你需要在一个非常高的层次上理解简单的 C++类继承。类可以有子类,子类继承父类的功能。清单 4-10 中的语法定义了这种关系。
struct Superclass {
int x;
};
struct Subclass : Superclass { ➊
int y;
int foo() {
return x + y; ➋
}
};
清单 4-10:定义超类和子类
Superclass没有什么特别之处。但Subclass的声明 ➊ 是特殊的。它使用: Superclass语法定义了继承关系。Subclass继承了Superclass中未标记为私有的成员。你可以看到它的作用,Subclass使用字段 x ➋。这个字段属于Superclass,但是由于Subclass继承自Superclass,所以 x 是可以访问的。
异常使用这些继承关系来判断一个处理程序是否捕获某个异常。处理程序将捕获给定类型及其任何子类型的异常。
stdlib 异常类
你可以使用继承将类安排成父子关系。继承对代码如何处理异常有很大影响。std 库提供了一个简洁明了的现有异常类型层次结构,供你使用。对于简单的程序,你应该尝试使用这些类型。为什么要重新发明轮子呢?
标准异常类
std 库在<stdexcept>头文件中为你提供了标准异常类。当你编程处理异常时,这些应该是你首先参考的内容。所有标准异常类的超类是std::exception类。std::exception中的所有子类可以分为三组:逻辑错误、运行时错误和语言支持错误。虽然语言支持错误通常与作为程序员的你无关,但你肯定会遇到逻辑错误和运行时错误。图 4-1 总结了它们之间的关系。

图 4-1:std 库异常如何嵌套在 std::exception 下
逻辑错误
逻辑错误源自logic_error类。一般来说,通过更小心的编程,你可以避免这些异常。一个主要的例子是,当一个类的逻辑前置条件未被满足时,例如当类的不变式无法建立时。(回想一下第二章中提到的类不变式,它是类的一个特性,总是为真。)
由于类不变式是由程序员定义的,编译器和运行时环境无法在没有帮助的情况下强制执行它。你可以使用类构造函数检查各种条件,如果无法建立类的不变式,可以抛出异常。如果失败是由于例如传递给构造函数的参数不正确,则抛出logic_error是一个合适的选择。
logic_error有几个子类,你需要了解它们:
-
domain_error报告与有效输入范围相关的错误,特别是在数学函数中。例如,平方根函数只支持非负数(在实数情况下)。如果传递一个负数作为参数,平方根函数可能会抛出domain_error异常。 -
invalid_argument异常报告一般性的无效参数。 -
length_error异常报告某些操作会违反最大大小限制。 -
out_of_range异常报告某个值不在预期的范围内。经典的例子是对数据结构进行边界检查的索引。
运行时错误
运行时错误派生自runtime_error类。这些异常帮助你报告程序范围外的错误条件。类似于logic_error,runtime_error也有一些子类,你可能会发现它们有用:
-
system_error报告操作系统遇到错误。你可以从这种异常中获取很多信息。在<system_error>头文件中,有大量的错误代码和错误条件。当构造一个system_error时,关于错误的信息会被打包进去,便于你判断错误的性质。.code()方法返回一个类型为std::errc的enum class,它有大量的值,例如bad_file_descriptor、timed_out和permission_denied。 -
overflow_error和underflow_error分别报告算术溢出和下溢错误。
其他错误直接继承自exception。一个常见的错误是bad_alloc异常,它报告new操作未能分配所需的动态存储内存。
语言支持错误
你不会直接使用语言支持错误。它们存在的目的是表示某些核心语言特性在运行时失败。
处理异常
异常处理的规则基于类继承。当异常被抛出时,如果抛出的异常类型与catch处理程序的异常类型匹配,或者抛出的异常类型继承自catch处理程序的异常类型,catch块就会处理该异常。
例如,以下处理程序捕获任何继承自std::exception的异常,包括std::logic_error:
try {
throw std::logic_error{ "It's not about who wrong "
"it's not about who right" };
} catch (std::exception& ex) {
// Handles std::logic_error as it inherits from std::exception
}
以下特殊处理程序捕获任何异常,不论其类型:
try {
throw 'z'; // Don't do this.
} catch (...) {
// Handles any exception, even a 'z'
}
特殊的处理程序通常作为安全机制,用于记录程序因未能捕获特定类型异常而导致的灾难性失败。
你可以通过将多个catch语句链在一起,处理来自同一try块的不同类型异常,如下所示:
try {
// Code that might throw an exception
--snip--
} catch (const std::logic_error& ex) {
// Log exception and terminate the program; there is a programming error!
--snip--
} catch (const std::runtime_error& ex) {
// Do our best to recover gracefully
--snip--
} catch (const std::exception& ex) {
// This will handle any exception that derives from std:exception
// that is not a logic_error or a runtime_error.
--snip--
} catch (...) {
// Panic; an unforeseen exception type was thrown
--snip--
}
在程序的入口点中常见这样的代码。
重新抛出异常
在catch块中,你可以使用 throw 关键字继续查找合适的异常处理程序。这被称为重新抛出异常。有一些不寻常但重要的情况,你可能想在决定如何处理异常之前进一步检查它,如 Listing 4-11 所示。
try {
// Some code that might throw a system_error
--snip--
} catch(const std::system_error& ex) {
if(ex.code()!= std::errc::permission_denied){
// Not a permission denied error
throw; ➊
}
// Recover from a permission denied
--snip--
}
Listing 4-11:重新抛出错误
在这个例子中,可能抛出system_error的代码被包装在try-catch块中。所有的system_error都被处理,但除非是EACCES (权限被拒绝)错误,否则你会重新抛出异常 ➊。这种方法会带来一些性能损失,且最终的代码通常不必要地复杂。
你可以定义一个新的异常类型,而不是重新抛出异常,并为EACCES错误创建一个单独的catch处理程序,如 Listing 4-12 所示。
try {
// Throw a PermissionDenied instead
--snip--
} catch(const PermissionDenied& ex) {
// Recover from an EACCES error (Permission Denied) ..
--snip--
}
Listing 4-12:捕获特定异常而非重新抛出
如果抛出std::system_error,PermissionDenied处理程序 ➊将不会捕获它。(当然,如果你愿意,仍然可以保留std::system_error处理程序来捕获此类异常。)
用户定义的异常
你可以随时定义自己的异常;通常,这些用户定义的异常继承自std::exception。std 库中的所有类使用的是从std::exception派生的异常类型。这使得你可以使用一个catch块捕获所有异常,无论是来自你的代码还是 std 库。
noexcept 关键字
关键字noexcept是另一个与异常相关的术语,你应该了解它。你可以并且应该标记任何不可能抛出异常的函数为noexcept,如下所示:
bool is_odd(int x) noexcept {
return 1 == (x % 2);
}
被标记为noexcept的函数具有严格的契约。当你使用标记为noexcept的函数时,你可以放心,它不能抛出异常。作为交换,你必须在标记自己的函数为noexcept时格外小心,因为编译器不会为你检查。如果你的代码在标记为noexcept的函数中抛出异常,那就非常糟糕。C++运行时将调用std::terminate函数,默认情况下会通过abort退出程序。你的程序无法恢复:
void hari_kari() noexcept {
throw std::runtime_error{ "Goodbye, cruel world." };
}
标记一个函数为noexcept可以启用一些依赖于函数无法抛出异常的代码优化。实质上,编译器可以使用移动语义,这可能会更快(更多内容请参见“移动语义”部分,第 122 页)。
注意
查看 Scott Meyers 的《Effective Modern C++》第 14 条,详细讨论noexcept。核心思想是,有些移动构造函数和移动赋值操作符可能会抛出异常,例如,如果它们需要分配内存,而系统内存不足。除非移动构造函数或移动赋值操作符另有说明,否则编译器必须假设移动操作可能会导致异常。这会禁用某些优化。
调用栈与异常
调用栈 是一个运行时结构,存储有关活动函数的信息。当一段代码(调用者)调用一个函数(被调用者)时,机器通过将信息压入调用栈来跟踪谁调用了谁。这允许程序有许多相互嵌套的函数调用。被调用者随后可以通过调用另一个函数,反过来成为调用者。
栈
栈是一个灵活的数据容器,可以容纳动态数量的元素。所有栈都支持两个基本操作:压入 元素到栈顶和 弹出 这些元素。它是一个后进先出(LIFO)数据结构,如 图 4-2 所示。

图 4-2:元素被压入和弹出栈的过程
正如其名字所示,调用栈在功能上类似于同名的数据容器。每次调用一个函数时,关于该函数调用的信息会被安排成一个 栈帧 并压入调用栈。由于每次函数调用都会将一个新的栈帧压入栈中,被调用者可以自由地调用其他函数,形成任意深度的调用链。每当一个函数返回时,它的栈帧会从调用栈的顶部弹出,执行控制权将恢复到上一个栈帧所指示的位置。
调用栈与异常处理
运行时会寻找离抛出的异常最近的异常处理程序。如果当前栈帧中有匹配的异常处理程序,它将处理该异常。如果没有找到匹配的处理程序,运行时会展开调用栈,直到找到一个合适的处理程序。任何生命周期结束的对象都会按常规方式销毁。
在析构函数中抛出异常
如果在析构函数中抛出异常,那么你就像在玩链锯。这类异常必须在析构函数内部被捕获。
假设抛出了一个异常,在展开栈的过程中,析构函数在正常清理时抛出了另一个异常。现在你就有了 两个 正在发生的异常。C++ 运行时应该如何处理这种情况?
你可以对这个问题有自己的看法,但运行时会调用 terminate。考虑 列表 4-13,它演示了当你从析构函数抛出异常时可能发生的情况:
#include <cstdio>
#include <stdexcept>
struct CyberdyneSeries800 {
CyberdyneSeries800() {
printf("I'm a friend of Sarah Connor."); ➊
}
~CyberdyneSeries800() {
throw std::runtime_error{ "I'll be back." }; ➋
}
};
int main() {
try {
CyberdyneSeries800 t800; ➌
throw std::runtime_error{ "Come with me if you want to live." }; ➍
} catch(const std::exception& e) { ➎
printf("Caught exception: %s\n", e.what()); ➏
}
}
--------------------------------------------------------------------------
I'm a friend of Sarah Connor. ➊
列表 4-13:一个程序,演示了在析构函数中抛出异常的危险性
注意
列表 4-13 调用了 std::terminate,因此根据你的环境,你可能会看到一个讨厌的弹窗提示。
首先,你声明了CyberdyneSeries800类,它有一个简单的构造函数,打印一条消息➊,还有一个彻底好斗的析构函数,抛出一个未捕获的异常➋。在main中,你设置了一个try块,其中初始化了一个名为t800的CyberdyneSeries800对象➌,并抛出了一个runtime_error异常➍。在更好的情况下,catch块➎会处理这个异常,打印其消息➏,并优雅地退出。因为t800是try块中的自动变量,它会在查找异常处理程序的正常过程中被析构➒。由于t800在其析构函数中抛出了异常➋,你的程序调用了std::terminate并突然结束。
一般来说,将析构函数视为noexcept。
一个 SimpleString 类
通过一个扩展的示例,让我们探索构造函数、析构函数、成员和异常如何协同工作。示例 4-14 中的SimpleString类允许你将 C 风格的字符串连接起来,并打印结果。
#include <stdexcept>
struct SimpleString {
SimpleString(size_t max_size) ➊
: max_size{ max_size }, ➋
length{} { ➌
if (max_size == 0) {
throw std::runtime_error{ "Max size must be at least 1." }; ➍
}
buffer = new char[max_size]; ➎
buffer[0] = 0; ➏
}
~SimpleString() {
delete[] buffer; ➐
}
--snip--
private:
size_t max_size;
char* buffer;
size_t length;
};
示例 4-14:SimpleString类的构造函数和析构函数
构造函数➊接受一个max_size参数。这个值是你的字符串的最大长度,包括一个空字符终止符。成员初始化器➋将这个长度保存到max_size成员变量中。这个值还用于数组 new 表达式来分配一个缓冲区,用于存储你的字符串➎。得到的指针被存储在buffer中。你将长度初始化为零➌,并确保至少为一个空字节分配足够的大小➍。由于字符串最初是空的,你将缓冲区的第一个字节赋值为零➏。
注意
因为max_size是一个size_t,它是无符号的,不能为负数,所以你不需要检查这个虚假的条件。
SimpleString类拥有一个资源——由buffer指向的内存——当不再需要时,必须释放。析构函数包含一行代码➐,用于释放buffer。因为你已经将buffer的分配和释放与SimpleString的构造函数和析构函数配对,所以你永远不会泄露存储。
这种模式称为资源获取即初始化(RAII)或构造函数获取,析构函数释放(CADRe)。
注意
SimpleString类仍然有一个隐式定义的拷贝构造函数。尽管它可能永远不会泄露存储,但如果被拷贝,它可能会导致双重释放。你将在“拷贝语义”章节中学习拷贝构造函数,见第 115 页。只需注意,示例 4-14 是一个教学工具,而不是生产级的代码。
追加和打印
SimpleString类还不太有用。示例 4-15 添加了打印字符串和向字符串末尾追加一行的功能。
#include <cstdio>
#include <cstring>
#include <stdexcept>
struct SimpleString {
--snip--
void print(const char* tag) const { ➊
printf("%s: %s", tag, buffer);
}
bool append_line(const char* x) { ➋
const auto x_len = strlen➌(x);
if (x_len + length + 2 > max_size) return false; ➍
std::strncpy➎(buffer + length, x, max_size - length);
length += x_len;
buffer[length++] = '\n';
buffer[length] = 0;
return true;
}
--snip--
};
示例 4-15:SimpleString的print和append_line方法
第一个方法print ➊打印你的字符串。为方便起见,你可以提供一个tag字符串,这样你就能将print的调用与结果对应起来。这个方法是const的,因为它不需要修改SimpleString的状态。
append_line方法 ➋接受一个以空字符结尾的字符串x,并将其内容——加上一个换行符——追加到buffer中。如果x成功追加,返回true;如果空间不足,则返回false。首先,append_line必须确定x的长度。为此,你使用<cstring>头文件中的strlen函数 ➌,该函数接受一个以空字符结尾的字符串并返回其长度:
size_t strlen(const char* str);
你使用strlen来计算x的长度,并用结果初始化x_len。这个结果用于计算将x(一个换行符)和一个空字节追加到当前字符串后是否会导致字符串长度超过max_size ➍。如果会,append_line返回false。
如果有足够的空间追加x,你需要将它的字节复制到buffer中的正确位置。std::strncpy函数 ➎(来自<cstring>头文件)是完成此工作的一个可能工具。它接受三个参数:destination地址、source地址和要复制的字符数num:
char* std::strncpy(char* destination, const char* source, std::size_t num);
strncpy函数会将最多num个字节从source复制到destination中。复制完成后,它将返回destination(你会丢弃这个返回值)。
在将复制到buffer中的字节数x_len加到length后,你通过在buffer末尾添加换行符\n和空字节来完成操作。你返回true以表明已成功将输入x作为一行追加到buffer末尾。
警告
请非常小心使用strncpy。很容易忘记在source字符串中添加空字符,或者在destination字符串中分配不够的空间。这两种错误都会导致未定义的行为。我们将在本书的第二部分中介绍一个更安全的替代方案。
使用 SimpleString
列表 4-16 展示了一个使用SimpleString的示例,你可以在其中追加多个字符串并打印中间结果到控制台。
#include <cstdio>
#include <cstring>
#include <exception>
struct SimpleString {
--snip--
}
int main() {
SimpleString string{ 115 }; ➊
string.append_line("Starbuck, whaddya hear?");
string.append_line("Nothin' but the rain."); ➋
string.print("A") ➌
string.append_line("Grab your gun and bring the cat in.");
string.append_line("Aye-aye sir, coming home."); ➍
string.print("B") ➎
if (!string.append_line("Galactica!")) { ➏
printf("String was not big enough to append another message."); ➐
}
}
列表 4-16:SimpleString的方法
首先,你创建一个SimpleString,其max_length=115 ➊。你使用append_line方法两次 ➋将一些数据添加到string中,然后打印内容并附上标签A ➌。接着你追加更多的文本 ➍并再次打印内容,这次附上标签B ➎。当append_line判断SimpleString已经没有足够的空间 ➏时,它返回false ➐。(作为SimpleString的用户,检查这种情况是你的责任。)
列表 4-17 包含了运行该程序的输出结果。
A: Starbuck, whaddya hear? ➊
Nothin' but the rain.
B: Starbuck, whaddya hear? ➋
Nothin' but the rain.
Grab your gun and bring the cat in.
Aye-aye sir, coming home.
String was not big enough to append another message. ➌
列表 4-17:运行列表 4-16 中的程序输出结果
如预期的那样,字符串在 A ➊ 处包含 Starbuck, whaddya hear?\nNothin' but the rain.\n。(回想一下 第二章,\n 是换行符。)在附加了 Grab your gun and bring the cat in. 和 Aye-aye sir, coming home. 后,您会在 B ➋ 处得到预期的输出。
当 Listing 4-17 尝试将 Galactica! 附加到 string 时,append_line 返回 false,因为 buffer 中没有足够的空间。这导致打印出消息 String was not big enough to append another message ➌。
组合一个 SimpleString
考虑在 Listing 4-18 中演示的,当你定义一个包含 SimpleString 成员的类时,会发生什么。
#include <stdexcept>
struct SimpleStringOwner {
SimpleStringOwner(const char* x)
: string{ 10 } { ➊
if (!string.append_line(x)) {
throw std::runtime_error{ "Not enough memory!" };
}
string.print("Constructed");
}
~SimpleStringOwner() {
string.print("About to destroy"); ➋
}
private:
SimpleString string;
};
Listing 4-18: SimpleStringOwner 的实现
如成员初始化器➊所示,string 在 SimpleStringOwner 的构造函数执行后完全构造,并且其类的不变式已建立。这说明了对象成员在构造过程中的顺序:成员在封闭对象的构造函数之前被构造。这很有道理:如果你不了解成员的不变式,怎么能确立类的不变式呢?
析构函数的工作方式恰好相反。在 ~SimpleStringOwner() ➋ 中,你需要确保 string 的类不变式成立,这样你才能打印其内容。所有成员在对象的析构函数被调用后才会被析构。
Listing 4-19 演示了 SimpleStringOwner 的使用。
--snip--
int main() {
SimpleStringOwner x{ "x" };
printf("x is alive\n");
}
--------------------------------------------------------------------------
Constructed: x ➊
x is alive
About to destroy: x ➋
Listing 4-19: 一个包含 SimpleStringOwner 的程序
如预期的那样,x 的成员 string 被适当地创建,因为 对象的成员构造函数在对象的构造函数之前被调用,因此输出消息 Constructed: x ➊。作为自动变量,x 会在 main 返回之前销毁,你会看到 About to destroy: x ➋。此时,成员 string 仍然有效,因为成员析构函数在封闭对象的析构函数之后被调用。
调用栈展开
Listing 4-20 演示了异常处理和栈展开是如何协同工作的。你在 main 中建立了一个 try-catch 块,然后进行了一系列的函数调用,其中一个调用导致了异常。
--snip--
void fn_c() {
SimpleStringOwner c{ "cccccccccc" }; ➊
}
void fn_b() {
SimpleStringOwner b{ "b" };
fn_c(); ➋
}
int main() {
try { ➌
SimpleStringOwner a{ "a" };
fn_b(); ➍
SimpleStringOwner d{ "d" }; ➎
} catch(const std::exception& e) { ➏
printf("Exception: %s\n", e.what());
}
}
Listing 4-20: 一个演示如何使用 SimpleStringOwner 和调用栈展开的程序
Listing 4-21 显示了运行 Listing 4-20 程序的结果。
Constructed: a
Constructed: b
About to destroy: b
About to destroy: a
Exception: Not enough memory!
Listing 4-21: 运行 Listing 4-20 程序的输出
你已经设置了一个 try-catch 块 ➌。第一个 SimpleStringOwner,a,成功构造并且你看到 Constructed: a 被打印到控制台。接着,调用了 fn_b ➍。注意,你仍然在 try-catch 块中,因此任何抛出的 exception 都会被处理。在 fn_b 中,另一个 SimpleStringOwner,b,成功构造,且 Constructed: b 被打印到控制台。接着,又调用了另一个函数 fn_c ➋。
让我们暂停一下,看看调用栈的样子,哪些对象仍然存活,异常处理情况如何。你有两个 SimpleStringOwner 对象存活且有效:a 和 b。调用栈的情况是 main() → fn_b() → fn_c(),并且你在 main 中设置了异常处理器来处理任何异常。图 4-3 总结了这种情况。
在 ➊ 处,你遇到了一个小问题。回想一下,SimpleStringOwner 有一个成员 SimpleString,它的 max_size 始终初始化为 10。当你尝试构造 c 时,SimpleStringOwner 的构造函数抛出了一个 exception,因为你试图附加 "cccccccccc",它的长度是 10,且太大,无法与换行符和空字符一起放入。
现在,异常正在飞行中。栈将会展开,直到找到合适的处理程序,所有由于此展开而超出作用域的对象都会被销毁。处理程序位于栈的最上方 ➏,因此 fn_c 和 fn_b 会被展开。由于 SimpleStringOwner b 是 fn_b 中的自动变量,它会被销毁,且你会看到 About to destroy: b 被打印到控制台。在 fn_b 之后,try{} 中的自动变量会被销毁。这包括 SimpleStringOwner a,因此你会看到 About to destroy: a 被打印到控制台。

图 4-3: 当 fn_c 调用 SimpleStringOwner c 的构造函数时的调用栈
一旦在 try{} 块中发生异常,后续语句将不再执行。因此,d 永远不会被初始化 ➎,你也不会看到 d 的构造函数打印到控制台。在调用栈被展开后,执行会立即转到 catch 块。最终,你会将 Exception: Not enough memory! 的消息打印到控制台 ➏。
异常与性能
在你的程序中,你必须处理错误;错误是不可避免的。当你正确使用异常且没有发生错误时,你的代码比手动进行错误检查的代码更快。如果发生错误,异常处理有时可能会更慢,但相较于替代方案,你在程序的健壮性和可维护性上会获得巨大的提升。《优化 C++》的作者库尔特·冈瑟罗斯说得好:“使用异常处理会让程序在正常执行时更快,在失败时表现得更好。”当 C++ 程序正常执行时(没有抛出异常),检查异常不会带来运行时开销。只有在抛出异常时,你才会支付额外的开销。
希望你已经认识到异常在惯用 C++ 程序中的核心作用。不幸的是,有时你可能无法使用异常。一个例子是嵌入式开发,那里需要实时保证。在这种情况下,工具(目前)根本不存在。幸运的话,这种情况很快会有所改变,但目前,在大多数嵌入式环境中,你只能在没有异常的情况下工作。另一个例子是一些遗留代码。异常因为它们与 RAII 对象的结合方式而显得非常优雅。当析构函数负责清理资源时,堆栈展开是确保资源不会泄漏的直接有效方式。在遗留代码中,你可能会发现手动资源管理和错误处理,而不是使用 RAII 对象。这使得使用异常非常危险,因为堆栈展开只有在 RAII 对象存在时才是安全的。没有它们,你可能会轻易泄漏资源。
异常的替代方案
在无法使用异常的情况下,并非一切都失去希望。尽管你需要手动跟踪错误,但仍有一些有用的 C++ 特性可以帮助你稍微减轻负担。首先,你可以通过暴露一些方法来手动强制类的不变性,从而传达是否能够建立类的不变性,示例如下:
struct HumptyDumpty {
HumptyDumpty();
bool is_together_again();
--snip--
};
在惯用的 C++ 编程中,你可能会在构造函数中直接抛出异常,但在这里,你必须记得在调用代码中检查并将这种情况视为错误条件来处理:
bool send_kings_horses_and_men() {
HumptyDumpty hd{};
if (hd.is_together_again()) return false;
// Class invariants of hd are now guaranteed.
// Humpty Dumpty had a great fall.
--snip--
return true;
}
第二种补充的应对策略是使用 结构化绑定声明 来返回多个值,这是一个语言特性,允许你从函数调用中返回多个值。你可以利用这个特性来返回成功标志和通常的返回值,如 列表 4-22 中所示。
struct Result { ➊
HumptyDumpty hd;
bool success;
};
Result make_humpty() { ➋
HumptyDumpty hd{};
bool is_valid;
// Check that hd is valid and set is_valid appropriately
return { hd, is_valid };
}
bool send_kings_horses_and_men() {
auto [hd, success] = make_humpty(); ➌
if(!success) return false;
// Class invariants established
--snip--
return true;
}
列表 4-22:展示结构化绑定声明的代码片段
首先,声明一个包含HumptyDumpty和success标志的 POD ➊。接下来,定义函数make_humpty ➋,该函数构建并验证一个HumptyDumpty。此类方法被称为工厂方法,因为它们的目的是初始化对象。make_humpty函数将该对象和成功标志打包成一个Result并返回。调用站点的语法 ➌ 演示了如何将Result解包成多个由auto类型推导的变量。
注意
你将在“结构绑定”章节中更详细地了解结构绑定,参见第 222 页。
复制语义
复制语义是“复制的含义”。在实践中,程序员使用这个术语来表示复制对象的规则:在x被复制到y之后,它们是等价的和独立的。也就是说,复制后x == y为真(等价性),并且修改x不会导致y的修改(独立性)。
复制是非常常见的,特别是在按值传递对象给函数时,如清单 4-23 所示。
#include <cstdio>
int add_one_to(int x) {
x++; ➊
return x;
}
int main() {
auto original = 1;
auto result = add_one_to(original); ➋
printf("Original: %d; Result: %d", original, result);
}
--------------------------------------------------------------------------
Original: 1; Result: 2
清单 4-23:一个示例程序,说明按值传递会生成副本
这里,add_one_to按值传递其参数x。然后它修改x的值 ➊。这个修改与调用者是隔离的 ➋;original不受影响,因为add_one_to接收到的是副本。
对于用户定义的 POD 类型,情况类似。按值传递导致每个成员值被复制到参数中(逐成员复制),如清单 4-24 所示。
struct Point {
int x, y;
};
Point make_transpose(Point p) {
int tmp = p.x;
p.x = p.y;
p.y = tmp;
return p;
}
清单 4-24:函数make_transpose生成 POD 类型Point的副本。
当调用make_transpose时,它接收到Point的副本p,而原始数据不受影响。
对于基本类型和 POD 类型,情况非常简单。复制这些类型是逐成员复制,这意味着每个成员都会被复制到对应的目标位置。这实际上是从一个内存地址到另一个内存地址的按位复制。
完整特性的类则需要更多的思考。完整特性的类的默认复制语义也是逐成员复制,这可能非常危险。再考虑一下SimpleString类。如果你允许用户对一个活跃的SimpleString类进行逐成员复制,那将是灾难性的。两个SimpleString类将指向同一个buffer,当这两个副本都向同一个buffer中添加内容时,它们将相互覆盖。图 4-4 总结了这种情况。

图 4-4:SimpleString类默认复制语义的示意图
这个结果很糟糕,但更糟糕的是当SimpleString类开始析构时发生的事情。当其中一个SimpleString类被析构时,buffer会被释放。当剩余的SimpleString类尝试写入它的buffer时——砰!——你会遇到未定义的行为。最终,剩余的SimpleString类将被析构并再次释放buffer,从而导致通常所说的双重释放。
注意
与它的恶名昭著的表兄“使用后释放”类似,双重释放可能导致微妙且难以诊断的错误,这些错误很少出现。双重释放发生在你两次释放一个对象时。请回想一下,一旦你释放了一个对象,它的存储生命周期就结束了。此时,这块内存处于未定义状态,如果你析构一个已经被析构的对象,你就会遇到未定义的行为。在某些情况下,这可能会导致严重的安全漏洞。
你可以通过控制拷贝语义来避免这种灾难。你可以指定拷贝构造函数和拷贝赋值运算符,具体内容将在以下章节中介绍。
拷贝构造函数
有两种方法可以复制一个对象。一种是使用拷贝构造,它创建一个副本并将其分配给一个全新的对象。拷贝构造函数与其他构造函数类似:
struct SimpleString {
--snip--
SimpleString(const SimpleString& other);
};
请注意,other是const。你正在从某个原始的SimpleString对象进行拷贝,且没有修改它的理由。你像使用其他构造函数一样使用拷贝构造函数,采用统一初始化语法和大括号初始化器:
SimpleString a;
SimpleString a_copy{ a };
第二行调用了SimpleString的拷贝构造函数,使用a生成a_copy。
让我们实现SimpleString的拷贝构造函数。你需要的是一种被称为深拷贝的方法,即将原始buffer指向的数据复制到一个新的buffer中,如图 4-5 所示。

图 4-5:SimpleString类的深拷贝示意图
与其拷贝指针buffer,不如在自由存储区上进行新的分配,然后复制原始buffer指向的所有数据。这将给你两个独立的SimpleString类。清单 4-25 实现了SimpleString的拷贝构造函数:
SimpleString(const SimpleString& other)
: max_size{ other.max_size }, ➊
buffer{ new char[other.max_size] }, ➋
length{ other.length } { ➌
std::strncpy(buffer, other.buffer, max_size); ➍
}
清单 4-25:SimpleString类的拷贝构造函数
你为max_size ➊、buffer ➋ 和 length ➌ 使用成员初始化器,并传递other上的相应字段。你可以使用数组new ➋ 来初始化buffer,因为你知道other.max_size大于 0。拷贝构造函数的主体包含一个语句 ➍,该语句将other.buffer指向的内容复制到buffer指向的数组中。
清单 4-26 通过使用现有的SimpleString来初始化一个SimpleString,从而使用了这个拷贝构造函数:
--snip--
int main() {
SimpleString a{ 50 };
a.append_line("We apologize for the");
SimpleString a_copy{ a }; ➊
a.append_line("inconvenience."); ➋
a_copy.append_line("incontinence."); ➌
a.print("a");
a_copy.print("a_copy");
}
--------------------------------------------------------------------------
a: We apologize for the
inconvenience.
a_copy: We apologize for the
incontinence.
清单 4-26:使用SimpleString类的拷贝构造函数的程序
在程序中,SimpleString a_copy ➊ 是通过复制构造从 a 创建的。它等同于并独立于原始对象。你可以将不同的消息追加到 a ➋ 和 a_copy ➌ 的末尾,且这些更改是相互独立的。
当将 SimpleString 作为值传递到函数中时,会调用复制构造函数,如 列表 4-27 所示。
--snip--
void foo(SimpleString x) {
x.append_line("Change lost.");
}
int main() {
SimpleString a { 20 };
foo(a); // Invokes copy constructor
a.print("Still empty");
}
--------------------------------------------------------------------------
Still empty:
列表 4-27:一个示例程序,说明通过值传递对象时会调用复制构造函数
注意
你不应该通过值传递以避免修改。使用 const 引用。
复制的性能影响可能很大,特别是在涉及到自由存储区分配和缓冲区复制的情况下。例如,假设你有一个管理 gigabyte 数据生命周期的类。每次复制对象时,都需要分配并复制一 gigabyte 的数据。这可能需要很长时间,所以你应该确保确实需要进行复制。如果可以通过传递 const 引用来避免复制,强烈建议这么做。
复制赋值
另一种在 C++ 中进行复制的方法是使用 复制赋值运算符。你可以创建对象的副本并将其赋值给另一个已存在的对象,如 列表 4-28 所示。
--snip--
void dont_do_this() {
SimpleString a{ 50 };
a.append_line("We apologize for the");
SimpleString b{ 50 };
b.append_line("Last message");
b = a; ➊
}
列表 4-28:使用默认的复制赋值运算符创建对象的副本并将其赋值给另一个已存在的对象
注意
列表 4-28 中的代码会导致未定义行为,因为它没有用户定义的复制赋值运算符。
第 ➊ 行 复制赋值 将 a 赋值给 b。复制赋值与复制构造的主要区别在于,在复制赋值中,b 可能已经有一个值。在复制 a 之前,必须清理 b 的资源。
警告
简单类型的默认复制赋值运算符只是将源对象的成员复制到目标对象。在 *SimpleString* 的情况下,这非常危险,原因有二。首先,原始的 *SimpleString* 类的缓冲区被重写,而没有释放动态分配的 *char* 数组。其次,现在两个 *SimpleString* 类拥有相同的缓冲区,这可能导致悬空指针和双重释放。你必须实现一个复制赋值运算符,确保资源的干净交接。
复制赋值运算符使用 operator= 语法,如 列表 4-29 所示。
struct SimpleString {
--snip--
SimpleString& operator=(const SimpleString& other) {
if (this == &other) return *this; ➊
--snip--
return *this; ➋
}
}
列表 4-29:一个用户定义的 SimpleString 类的复制赋值运算符
复制赋值运算符返回结果的引用,这个引用总是 *this ➋。通常的好做法是检查 other 是否引用了 this ➊。
你可以按照以下准则为 SimpleString 实现复制赋值:释放当前 buffer 的资源,然后像复制构造一样复制 other,如 列表 4-30 所示。
SimpleString& operator=(const SimpleString& other) {
if (this == &other) return *this;
const auto new_buffer = new char[other.max_size]; ➊
delete[] buffer; ➋
buffer = new_buffer; ➌
length = other.length; ➍
max_size = other.max_size; ➎
std::strncpy(buffer, other.buffer, max_size); ➏
return *this;
}
列表 4-30:SimpleString 的复制赋值运算符
复制赋值操作符首先会分配一个适当大小的new_buffer ➊。接着,你清理buffer ➋。其余部分与清单 4-25 中的复制构造函数基本相同。你复制buffer ➌,length ➍和max_size ➎,然后将other.buffer中的内容复制到你自己的buffer ➏。
清单 4-31 演示了SimpleString复制赋值如何工作(如清单 4-30 中实现的那样)。
--snip--
int main() {
SimpleString a{ 50 };
a.append_line("We apologize for the"); ➊
SimpleString b{ 50 };
b.append_line("Last message"); ➋
a.print("a"); ➌
b.print("b"); ➍
b = a; ➎
a.print("a"); ➏
b.print("b"); ➐
}
--------------------------------------------------------------------------
a: We apologize for the ➌
b: Last message ➍
a: We apologize for the ➏
b: We apologize for the ➐
清单 4-31:一个展示SimpleString类复制赋值的程序
你首先声明两个SimpleString类,分别包含不同的消息:字符串a包含We apologize for the ➊,而b包含Last message ➋。你打印这些字符串以验证它们包含你指定的文本 ➌➍。接下来,你将b复制赋值为a ➎。此时,a和b包含相同消息的副本,即We apologize for the ➏➐。但——这很重要——这个消息位于两个不同的内存位置。
默认复制
通常,编译器会为复制构造和复制赋值生成默认实现。默认实现是对每个类成员调用复制构造函数或复制赋值操作符。
任何时候一个类管理资源时,你必须非常小心默认的复制语义;它们很可能是错误的(就像你在SimpleString中看到的那样)。最佳实践是明确声明默认的复制赋值和复制构造对这样的类是可接受的,可以使用default关键字。比如,Replicant类具有默认的复制语义,正如这里展示的那样:
struct Replicant {
Replicant(const Replicant&) = default;
Replicant& operator=(const Replicant&) = default;
--snip--
};
有些类根本不能或不应该被复制——例如,如果你的类管理一个文件,或者它代表了一个用于并发编程的互斥锁。你可以使用delete关键字来抑制编译器生成复制构造函数和复制赋值操作符。例如,Highlander类就不能被复制:
struct Highlander {
Highlander(const Highlander&) = delete;
Highlander& operator=(const Highlander&) = delete;
--snip--
};
任何试图复制Highlander的操作都会导致编译错误:
--snip--
int main() {
Highlander a;
Highlander b{ a }; // Bang! There can be only one.
}
我强烈建议你为任何拥有资源的类(如打印机、网络连接或文件)显式定义复制赋值操作符和复制构造函数。如果不需要自定义行为,可以使用default或delete。这将帮助你避免许多棘手且难以调试的错误。
复制指南
当你实现复制行为时,请考虑以下标准:
正确性 你必须确保类的不变性得到维护。SimpleString类演示了默认的复制构造函数如何违反不变性。
独立性 复制赋值或复制构造后,原始对象和复制品在修改时不应该相互改变对方的状态。如果你只是将一个SimpleString的buffer复制到另一个,写入一个buffer可能会覆盖另一个buffer的数据。
等价性 原始对象和副本应该是相同的。相同性的语义取决于上下文。但通常,对原始对象应用的操作,在副本上应用时应该得到相同的结果。
移动语义
在处理大量数据时,复制可能会非常耗时。通常,你只想将资源的所有权从一个对象转移到另一个对象。你可以进行复制并销毁原始对象,但这通常效率低下。相反,你可以移动。
移动语义是移动对复制语义的补充,它要求在将对象y移动到对象x之后,x等价于y的原始值。移动之后,y进入一个特殊状态,称为移动后状态。对于移动后的对象,你只能进行两种操作:(重新)赋值或销毁它们。请注意,将对象y移动到对象x并不只是重新命名:这些是独立的对象,具有独立的存储和可能独立的生命周期。
类似于你指定复制行为的方式,你可以使用移动构造函数和移动赋值运算符来指定对象的移动方式。
复制可能是浪费的
假设你想通过以下方式将SimpleString移动到SimpleStringOwner中:
--snip--
void own_a_string() {
SimpleString a{ 50 };
a.append_line("We apologize for the");
a.append_line("inconvenience.");
SimpleStringOwner b{ a };
--snip--
}
你可以为SimpleStringOwner添加一个构造函数,然后像示例 4-32 中演示的那样,复制构造它的SimpleString成员。
struct SimpleStringOwner {
SimpleStringOwner(const SimpleString& my_string) : string{ my_string }➊ { }
--snip--
private:
SimpleString string; ➋
};
示例 4-32:包含浪费复制的成员初始化的简单方法
这种方法中隐藏了浪费。你有一个复制构造 ➊,但调用者在构造string ➋后,再也不会使用指向的对象。图 4-6 说明了这个问题。

图 4-6:使用string的复制构造函数是浪费的。
你应该将SimpleString a的核心内容移到SimpleStringOwner的string字段中。图 4-7 展示了你想要实现的目标:SimpleString Owner b偷走了buffer并将SimpleString a置于可销毁状态。

图 4-7:将a的缓冲区交换到b中
移动a后,b的SimpleString等价于a的原始状态,而a处于可销毁状态。
移动可能是危险的。如果你不小心使用了移动后的a,将引发灾难。当a被移动后,SimpleString的类不变量不再满足。
幸运的是,编译器内置了保护机制:左值和右值。
值类别
每个表达式有两个重要特性:它的 类型 和 值类别。值类别描述了该表达式可以进行哪些操作。得益于 C++ 的演化特性,值类别是复杂的:一个表达式可以是“广义左值”(glvalue)、“纯右值”(prvalue)、 “过期值”(xvalue)、左值(一个不是 xvalue 的 glvalue),或者右值(一个 prvalue 或 xvalue)。幸运的是,对于初学者来说,你不需要了解大多数值类别的细节。
我们将考虑值类别的一个简化视图。目前,你只需要对左值和右值有一个大致的理解。左值 是任何有名字的值,而 右值 是任何不是左值的东西。
考虑以下初始化:
SimpleString a{ 50 };
SimpleStringOwner b{ a }; // a is an lvalue
SimpleStringOwner c{ SimpleString{ 50 } }; // SimpleString{ 50 } is an rvalue
这些术语的词源是 右值 和 左值,指的是它们在构造中相对于等号的位置。在语句 int x = 50; 中,x 位于等号左侧(左值),而 50 位于等号右侧(右值)。这些术语并不完全准确,因为你也可以在等号的右侧使用左值(例如在复制赋值中)。
注意
ISO C++ 标准在 [basic] 和 [expr] 中详细说明了值类别。
左值和右值引用
你可以通过 左值引用 和 右值引用 向编译器传达一个函数接受左值或右值。到目前为止,本书中的每个引用参数都是左值引用,这些由单个 & 表示。你也可以通过 && 来接受一个右值引用参数。
幸运的是,编译器在判断对象是左值还是右值时表现得非常出色。实际上,你可以定义多个具有相同名称但参数不同的函数,编译器会根据你调用函数时传入的参数自动调用正确的版本。
清单 4-33 包含了两个名为 ref_type 的函数,用于辨别调用者传递的是左值还是右值引用。
#include <cstdio>
void ref_type(int &x) { ➊
printf("lvalue reference %d\n", x);
}
void ref_type(int &&x) { ➋
printf("rvalue reference %d\n", x);
}
int main() {
auto x = 1;
ref_type(x); ➌
ref_type(2); ➍
ref_type(x + 2); ➎
}
--------------------------------------------------------------------------
lvalue reference 1 ➌
rvalue reference 2 ➍
rvalue reference 3 ➎
清单 4-33:包含左值和右值引用重载函数的程序
int &x 版本 ➊ 接受一个左值引用,而 int &&x 版本 ➋ 接受一个右值引用。你调用 ref_type 三次。首先,调用左值引用版本,因为 x 是一个左值(它有名字) ➌。第二,调用右值引用版本,因为 2 是一个没有名字的整数常量 ➍。第三,x 加 2 的结果没有绑定到一个名字,因此它是一个右值 ➎。
注意
定义多个具有相同名称但不同参数的函数称为 函数重载,这是你将在第九章中详细探讨的内容。
std::move 函数
你可以使用来自<utility>头文件的std::move函数将左值引用转换为右值引用。清单 4-34 更新了清单 4-33,以说明std::move函数的使用。
#include <utility>
--snip--
int main() {
auto x = 1;
ref_type(std::move(x)); ➊
ref_type(2);
ref_type(x + 2);
}
--------------------------------------------------------------------------
rvalue reference 1 ➊
rvalue reference 2
rvalue reference 3
清单 4-34:使用std::move将x转换为右值,更新清单 4-33
如预期的那样,std::move将左值x转换为右值➊。你永远不会调用左值ref_type重载。
注意
C++委员会本应将std::move命名为std::rvalue,但我们只能使用现有的名称。std::move函数实际上并不移动任何东西——它只是进行类型转换。
使用std::move时要非常小心,因为它移除了保护措施,使你可以与一个已移动的对象交互。你只能对一个已移动的对象执行两种操作:销毁它或重新赋值。
现在应该可以清楚地理解左值和右值语义如何支持移动语义。如果是左值,则移动被抑制;如果是右值,则启用移动。
移动构造
移动构造函数看起来像复制构造函数,只不过它们接受右值引用而不是左值引用。
请参阅清单 4-35 中的SimpleString移动构造函数。
SimpleString(SimpleString&& other) noexcept
: max_size{ other.max_size }, ➊
buffer(other.buffer),
length(other.length) {
other.length = 0; ➋
other.buffer = nullptr;
other.max_size = 0;
}
清单 4-35:SimpleString的移动构造函数
因为other是右值引用,所以你可以对其进行“自食其力”。在SimpleString的情况下,这很容易:将other的所有字段复制到this➊,然后将other的字段清零➋。后一步很重要:它将other置于一个已移动的状态。(考虑一下,如果你没有清除other的成员,当other销毁时会发生什么。)
执行此移动构造函数的成本比执行复制构造函数要低得多。
移动构造函数设计为不抛出异常,因此你会将其标记为noexcept。你的首选应为使用noexcept的移动构造函数;通常,编译器无法使用会抛出异常的移动构造函数,而会使用复制构造函数。编译器更喜欢慢而正确的代码,而不是快而不正确的代码。
移动赋值
你还可以通过operator=创建类似于复制赋值的移动赋值运算符。移动赋值运算符采用右值引用,而不是const左值引用,通常会标记为noexcept。清单 4-36 实现了SimpleString的移动赋值运算符。
SimpleString& operator=(SimpleString&& other) noexcept { ➊
if (this == &other) return *this; ➋
delete[] buffer; ➌
buffer = other.buffer; ➍
length = other.length;
max_size = other.max_size;
other.buffer = nullptr; ➎
other.length = 0;
other.max_size = 0;
return *this;
}
清单 4-36:SimpleString的移动赋值运算符
你通过右值引用语法和noexcept限定符声明移动赋值运算符,就像移动构造函数➊一样。自引用检查➋处理将SimpleString移动赋值给自身的情况。在将this的字段赋值给other的字段之前,你会清理buffer➌,并将other的字段清零➎。除了自引用检查➋和清理➌之外,移动赋值运算符和移动构造函数在功能上是相同的。
现在SimpleString是可移动的,你可以完成SimpleStringOwner的SimpleString构造函数:
SimpleStringOwner(SimpleString&& x) : string{ std::move(x)➊ } { }
x是一个左值,所以你必须将std::move x传入string的移动构造函数 ➊。你可能会觉得std::move很奇怪,因为x是一个右值引用。请记住,左值/右值和左值引用/右值引用是不同的描述符。
考虑一下如果这里不需要std::move会怎么样:假设你从x移动了内容然后在构造函数内部使用它?这可能会导致难以诊断的错误。记住,除非重新赋值或析构,否则你不能使用已移动的对象。做任何其他操作都会导致未定义的行为。
列表 4-37 展示了SimpleString的移动赋值。
--snip--
int main() {
SimpleString a{ 50 };
a.append_line("We apologize for the"); ➊
SimpleString b{ 50 };
b.append_line("Last message"); ➋
a.print("a"); ➌
b.print("b"); ➍
b = std::move(a); ➎
// a is "moved-from"
b.print("b"); ➏
}
--------------------------------------------------------------------------
a: We apologize for the ➌
b: Last message ➍
b: We apologize for the ➏
列表 4-37:一个展示SimpleString类移动赋值的程序
如同列表 4-31 中所示,你首先声明两个具有不同消息的SimpleString类:字符串a包含We apologize for the ➊,b包含Last message ➋。你打印这些字符串以验证它们是否包含你指定的字符串 ➌➍。接下来,你将b移赋给a ➎。注意,你需要使用std::move将a强制转换为右值。移赋后,a进入已移动状态,你不能在不重新赋值的情况下使用它。现在,b拥有了原来属于a的消息We apologize for the ➏。
最终产品
现在你有了一个完整实现的SimpleString,它支持移动和复制语义。列表 4-38 将这些都汇总在一起供你参考。
#include <cstdio>
#include <cstring>
#include <stdexcept>
#include <utility>
struct SimpleString {
SimpleString(size_t max_size)
: max_size{ max_size },
length{} {
if (max_size == 0) {
throw std::runtime_error{ "Max size must be at least 1." };
}
buffer = new char[max_size];
buffer[0] = 0;
}
~SimpleString() {
delete[] buffer;
}
SimpleString(const SimpleString& other)
: max_size{ other.max_size },
buffer{ new char[other.max_size] },
length{ other.length } {
std::strncpy(buffer, other.buffer, max_size);
}
SimpleString(SimpleString&& other) noexcept
: max_size(other.max_size),
buffer(other.buffer),
length(other.length) {
other.length = 0;
other.buffer = nullptr;
other.max_size = 0;
}
SimpleString& operator=(const SimpleString& other) {
if (this == &other) return *this;
const auto new_buffer = new char[other.max_size];
delete[] buffer;
buffer = new_buffer;
length = other.length;
max_size = other.max_size;
std::strncpy(buffer, other.buffer, max_size);
return *this;
}
SimpleString& operator=(SimpleString&& other) noexcept {
if (this == &other) return *this;
delete[] buffer;
buffer = other.buffer;
length = other.length;
max_size = other.max_size;
other.buffer = nullptr;
other.length = 0;
other.max_size = 0;
return *this;
}
void print(const char* tag) const {
printf("%s: %s", tag, buffer);
}
bool append_line(const char* x) {
const auto x_len = strlen(x);
if (x_len + length + 2 > max_size) return false;
std::strncpy(buffer + length, x, max_size - length);
length += x_len;
buffer[length++] = '\n';
buffer[length] = 0;
return true;
}
private:
size_t max_size;
char* buffer;
size_t length;
};
列表 4-38:一个完整的SimpleString类,支持复制和移动语义
编译器生成的方法
五个方法控制着移动和复制行为:
-
析构函数
-
复制构造函数
-
移动构造函数
-
复制赋值运算符
-
移动赋值运算符
编译器在某些情况下可以为每个方法生成默认实现。不幸的是,哪些方法会被生成的规则是复杂的,而且在不同的编译器中可能不一致。
你可以通过将这些方法设置为default/delete,或者根据需要实现它们,来消除这种复杂性。这个通用规则被称为五法则,因为有五个方法需要指定。明确地指定这些方法虽然花费一些时间,但可以避免未来的许多麻烦。
另一种方式是记住图 4-8,它总结了你实现的五个函数和编译器为你生成的每个函数之间的交互。

图 4-8:一张图表,展示了在不同输入下编译器生成的方法
如果你什么都不提供,编译器将会生成所有五个析构/复制/移动函数。这就是零规则。
如果你显式地定义了析构函数/复制构造函数/复制赋值操作符,你会得到所有三个。这是危险的,正如之前通过SimpleString所演示的那样:很容易陷入编译器实际上将所有的移动操作都转换为复制操作的意外情况。
最后,如果你只为你的类提供移动语义,编译器将不会自动生成任何东西,除了析构函数。
总结
你已经完成了对对象生命周期的探索。你的旅程从存储持续时间开始,在那里你看到了从构造到析构的对象生命周期。随后研究异常处理展示了灵活的、生命周期感知的错误处理,并加深了你对 RAII 的理解。最后,你看到了复制和移动语义如何让你对对象生命周期进行细粒度的控制。
习题
4-1. 创建一个struct TimerClass。在它的构造函数中,记录当前时间到一个名为timestamp的字段(与 POSIX 函数gettimeofday进行比较)。
4-2. 在TimerClass的析构函数中,记录当前时间并减去构造时的时间。这个时间大致是定时器的年龄。打印这个值。
4-3. 为TimerClass实现一个复制构造函数和复制赋值操作符。复制应共享时间戳值。
4-4. 为TimerClass实现一个移动构造函数和一个移动赋值操作符。一个被移动的TimerClass在析构时不应向控制台打印任何输出。
4-5. 扩展TimerClass的构造函数,接受一个额外的const char* name参数。当TimerClass被析构并打印到标准输出时,包含定时器的名称。
4-6. 尝试你的TimerClass。创建一个定时器并将它传入一个执行一些计算密集型操作的函数中(例如,在循环中进行大量数学运算)。验证你的定时器是否按预期工作。
4-7. 找出SimpleString类中的每个方法(清单 4-38)。尝试从头开始重新实现它,而不参考书中的内容。
进一步阅读
-
优化的 C++:提升性能的验证技巧,作者:Kurt Guntheroth(O'Reilly Media,2016)
-
Effective Modern C++: 提升你使用 C++11 和 C++14 的 42 个具体方法,作者:Scott Meyers(O'Reilly Media,2015)
第七章:运行时多态性
有一天,构造师 Trurl 组装了一台能够从 n 开始创造任何东西的机器。
—斯坦尼斯瓦夫·莱姆,《赛博利亚》

在本章中,你将学习什么是多态性以及它解决了哪些问题。接着,你将学习如何实现运行时多态性,这使你能够通过在程序执行期间替换组件来改变程序的行为。章初将讨论运行时多态性代码中的几个关键概念,包括接口、对象组合和继承。然后,你将开发一个持续的示例,展示如何使用多种类型的日志记录器记录银行交易。最后,你将通过使用更优雅的基于接口的解决方案来重构这个初始的、幼稚的解决方案。
多态性
多态代码是你只需编写一次,便可与不同类型一起重用的代码。最终,这种灵活性带来了松耦合和高度可重用的代码。它消除了繁琐的复制和粘贴,使代码更加易于维护和可读。
C++提供了两种多态方法。编译时多态代码包含了可以在编译时确定的多态类型。另一种方法是运行时多态性,它则包含了在运行时确定的类型。你选择哪种方法取决于你是否知道要在编译时还是运行时使用的多态类型。由于这些紧密相关的主题涉及较多内容,因此被分为两章进行讲解。第六章将重点讨论编译时多态性。
一个激励示例
假设你负责实现一个Bank类,该类用于在账户之间转账。审计对Bank类的交易非常重要,因此你提供了通过ConsoleLogger类支持日志记录,如示例 5-1 所示。
#include <cstdio>
struct ConsoleLogger {
void log_transfer(long from, long to, double amount) { ➊
printf("%ld -> %ld: %f\n", from, to, amount); ➋
}
};
struct Bank {
void make_transfer(long from, long to, double amount) { ➌
--snip-- ➍
logger.log_transfer(from, to, amount); ➎
}
ConsoleLogger logger;
};
int main() {
Bank bank;
bank.make_transfer(1000, 2000, 49.95);
bank.make_transfer(2000, 4000, 20.00);
}
--------------------------------------------------------------------------
1000 -> 2000: 49.950000
2000 -> 4000: 20.000000
示例 5-1:一个使用ConsoleLogger的Bank类
首先,你实现了一个ConsoleLogger,其中包含一个log_transfer方法➊,该方法接受交易的详细信息(发送者、接收者、金额)并打印出来➋。Bank类有一个make_transfer方法➌,该方法(概念上)处理交易➍,然后使用logger成员➎记录交易。Bank和ConsoleLogger有各自不同的关注点——Bank处理银行逻辑,ConsoleLogger处理日志记录。
假设你需要实现不同类型的日志记录器。例如,你可能需要一个远程服务器日志记录器,一个本地文件日志记录器,或者甚至一个将作业发送到打印机的日志记录器。此外,你还必须能够在运行时更改程序的日志记录方式(例如,管理员可能需要将日志记录从网络日志切换到本地文件系统日志,因为某些服务器维护)。
你如何完成这样的任务?
一种简单的方法是使用enum class在各种日志记录器之间切换。清单 5-2 为清单 5-1 添加了一个FileLogger。
#include <cstdio>
#include <stdexcept>
struct FileLogger {
void log_transfer(long from, long to, double amount) { ➊
--snip--
printf("[file] %ld,%ld,%f\n", from, to, amount);
}
};
struct ConsoleLogger {
void log_transfer(long from, long to, double amount) {
printf("[cons] %ld -> %ld: %f\n", from, to, amount);
}
};
enum class LoggerType { ➋
Console,
File
};
struct Bank {
Bank() : type { LoggerType::Console } { } ➌
void set_logger(LoggerType new_type) { ➍
type = new_type;
}
void make_transfer(long from, long to, double amount) {
--snip--
switch(type) { ➎
case LoggerType::Console: {
consoleLogger.log_transfer(from, to, amount);
break;
} case LoggerType::File: {
fileLogger.log_transfer(from, to, amount);
break;
} default: {
throw std::logic_error("Unknown Logger type encountered.");
} }
}
private:
LoggerType type;
ConsoleLogger consoleLogger;
FileLogger fileLogger;
};
int main() {
Bank bank;
bank.make_transfer(1000, 2000, 49.95);
bank.make_transfer(2000, 4000, 20.00);
bank.set_logger(LoggerType::File); ➏
bank.make_transfer(3000, 2000, 75.00);
}
--------------------------------------------------------------------------
[cons] 1000 -> 2000: 49.950000
[cons] 2000 -> 4000: 20.000000
[file] 3000,2000,75.000000
清单 5-2:一个更新后的清单 5-1,具有运行时多态的日志记录器
你(理论上)通过实现一个FileLogger来添加日志到文件的能力 ➊。你还创建了一个enum class LoggerType ➋,这样你就可以在运行时切换日志记录行为。你在Bank构造函数中将类型字段初始化为Console ➌。在更新后的Bank类中,你添加了一个set_logger函数 ➍来执行所需的日志记录行为。你在make_transfer中使用type来switch到正确的日志记录器 ➎。要更改Bank类的日志记录行为,你可以使用set_logger方法 ➏,对象会在内部处理分派。
添加新的日志记录器
清单 5-2 是有效的,但这种方法存在一些设计问题。添加新的日志记录类型需要你在代码中进行多次更新:
-
你需要编写一个新的日志记录器类型。
-
你需要向
enum class LoggerType添加一个新的enum值。 -
你必须在
switch语句中添加一个新的案例 ➎。 -
你必须将新的日志记录类作为成员添加到
Bank中。
对于一个简单的更改来说,这可真是很多工作!
考虑一种替代方法,让Bank持有一个指向日志记录器的指针。这样,你可以直接设置指针,完全去除LoggerType。你利用了所有日志记录器具有相同函数原型的事实。这就是接口的思想:Bank类不需要知道它持有的Logger引用的实现细节,只需要知道如何调用其方法。
如果我们可以将ConsoleLogger替换为另一个支持相同操作的类型,岂不是很好吗?比如一个FileLogger?
允许我向你介绍接口。
接口
在软件工程中,接口是一个不包含数据或代码的共享边界。它定义了所有接口实现都同意支持的函数签名。实现是声明支持接口的代码或数据。你可以把接口看作是实现接口的类与该类的用户(也叫消费者)之间的契约。
消费者知道如何使用实现,因为他们知道契约。实际上,消费者从不需要知道底层实现类型。例如,在清单 5-1 中,Bank是ConsoleLogger的消费者。
接口强加了严格的要求。接口的消费者只能使用接口中明确定义的方法。Bank类不需要知道ConsoleLogger是如何执行其功能的。它只需要知道如何调用log_transfer方法。
接口促进了高度可重用且松耦合的代码。你可以理解指定接口的符号,但你需要了解一些关于对象组合和实现继承的知识。
对象组合与实现继承
对象组合是一种设计模式,其中一个类包含其他类类型的成员。另一种过时的设计模式叫做 实现继承,它实现了运行时多态性。实现继承允许你构建类的层次结构,每个子类从其父类继承功能。多年来,积累的实现继承经验使得许多人认为它是一种反模式。例如,Go 和 Rust——两种新兴且越来越受欢迎的系统编程语言——完全不支持实现继承。由于两个原因,简要讨论实现继承是必要的:
-
你可能会在遗留代码中遇到它。
-
你定义 C++ 接口的独特方式与实现继承有共同的血脉,因此你会熟悉这些机制。
注意
如果你正在处理充满实现继承的 C++ 代码,请参阅 《C++程序设计语言》第四版,Bjarne Stroustrup 著,第二十章和 21 章。
定义接口
不幸的是,C++ 中没有 interface 关键字。你必须使用过时的继承机制来定义接口。这只是你在编程这个已有 40 多年历史的语言时必须应对的一个古老遗留问题。
列表 5-3 展示了一个完全指定的 Logger 接口以及一个实现该接口的相应 ConsoleLogger。在 列表 5-3 中至少有四种构造方式对你来说是陌生的,本节将逐一讲解这些内容。
#include <cstdio>
struct Logger {
virtual➊ ~Logger()➋ = default;
virtual void log_transfer(long from, long to, double amount) = 0➌;
};
struct ConsoleLogger : Logger ➍ {
void log_transfer(long from, long to, double amount) override ➎ {
printf("%ld -> %ld: %f\n", from, to, amount);
}
};
列表 5-3:一个 Logger 接口和一个重构的 ConsoleLogger
为了解析 列表 5-3,你需要了解 virtual 关键字 ➊、虚拟析构函数 ➋、=0 后缀和纯虚方法 ➌、基类继承 ➍,以及 override 关键字 ➎。理解这些后,你将知道如何定义一个接口。接下来的章节将详细讨论这些概念。
基类继承
第四章深入探讨了 exception 类是所有其他标准库异常的基类,以及 logic_error 和 runtime_error 类是如何从 exception 类派生出来的。这两个类反过来又成为描述更详细错误条件的其他派生类的基类,例如 invalid_argument 和 system_error。嵌套的异常类形成了一个类层次结构的示例,并代表了一种实现继承设计。
你使用以下语法声明派生类:
struct DerivedClass : BaseClass {
--snip--
};
要为 DerivedClass 定义继承关系,你使用冒号(:)后跟基类的名称 BaseClass。
派生类的声明方式与其他类相同。其好处在于你可以将派生类的引用当作基类引用类型来使用。列表 5-4 中用DerivedClass引用替代了BaseClass引用。
struct BaseClass {}; ➊
struct DerivedClass : BaseClass {}; ➋
void are_belong_to_us(BaseClass& base) {} ➌
int main() {
DerivedClass derived;
are_belong_to_us(derived); ➍
}
列表 5-4:用派生类替代基类的程序
DerivedClass ➋继承自BaseClass ➊。are_belong_to_us函数接受一个指向BaseClass的引用参数base ➌。由于DerivedClass继承自BaseClass ➍,因此你可以用DerivedClass的实例来调用它。
相反的情况并不成立。列表 5-5 尝试用基类替代派生类。
struct BaseClass {}; ➊
struct DerivedClass : BaseClass {}; ➋
void all_about_that(DerivedClass& derived) {} ➌
int main() {
BaseClass base;
all_about_that(base); // No! Trouble! ➍
}
列表 5-5:该程序尝试用基类替代派生类。(此列表无法编译。)
在这里,BaseClass ➊并没有继承自DerivedClass ➋。(继承关系是相反的。)all_about_that函数接受一个DerivedClass类型的参数 ➌。当你尝试用BaseClass ➍来调用all_about_that时,编译器会报错。
你希望从类中派生的主要原因是为了继承其成员。
成员继承
派生类继承自基类的非私有成员。类可以像使用普通成员一样使用继承的成员。成员继承的预期好处是,你可以在基类中定义功能,而不必在派生类中重复它。不幸的是,经验使得许多程序员社区的人避免使用成员继承,因为它相比基于组合的多态性,更容易导致脆弱、难以理解的代码。(这也是为什么许多现代编程语言排除了成员继承。)
列表 5-6 中的类展示了成员继承。
#include <cstdio>
struct BaseClass {
int the_answer() const { return 42; } ➊
const char* member = "gold"; ➋
private:
const char* holistic_detective = "Dirk Gently"; ➌
};
struct DerivedClass : BaseClass ➍
void announce_agency() {
// This line doesn't compile:
// printf("%s's Holistic Detective Agency\n", holistic_detective); { ➎
}
};
int main() {
DerivedClass x;
printf("The answer is %d\n", x.the_answer()); ➏
printf("%s member\n", x.member); { ➐
}
--------------------------------------------------------------------------
The answer is 42 ➏
gold member ➐
列表 5-6:使用继承成员的程序
在这里,BaseClass有一个公共方法 ➊,一个公共字段 ➋,以及一个私有字段 ➌。你声明一个DerivedClass继承自BaseClass ➍,然后在main中使用它。由于它们作为公共成员被继承,the_answer ➏和member ➐可以在DerivedClass x上访问。然而,取消注释 ➎ 会导致编译错误,因为holistic_detective是私有的,因此不会被派生类继承。
虚方法
如果你希望允许派生类重写基类的方法,可以使用virtual关键字。通过在方法定义中添加virtual,你声明如果派生类提供了实现,则使用派生类的实现。在实现中,你需要在方法声明中添加override关键字,如列表 5-7 所示。
#include <cstdio>
struct BaseClass {
virtual➊ const char* final_message() const {
return "We apologize for the incontinence.";
}
};
struct DerivedClass : BaseClass ➋ {
const char* final_message() const override ➌ {
return "We apologize for the inconvenience.";
}
};
int main() {
BaseClass base;
DerivedClass derived;
BaseClass& ref = derived;
printf("BaseClass: %s\n", base.final_message()); ➍
printf("DerivedClass: %s\n", derived.final_message()); ➎
printf("BaseClass&: %s\n", ref.final_message()); ➏
}
--------------------------------------------------------------------------
BaseClass: We apologize for the incontinence. ➍
DerivedClass: We apologize for the inconvenience. ➎
BaseClass&: We apologize for the inconvenience. ➏
列表 5-7:使用虚拟成员的程序
BaseClass包含一个虚拟成员 ➊。在DerivedClass中 ➋,你重写了继承的成员,并使用了override关键字 ➌。当手头是BaseClass实例时,使用的是BaseClass的实现 ➍。当手头是DerivedClass实例时,即使你通过BaseClass引用来操作,它依然使用的是DerivedClass的实现 ➎。
如果你想要求派生类实现某个方法,可以在方法定义后加上=0后缀。你用virtual关键字和=0后缀来标记纯虚方法。含有任何纯虚方法的类无法被实例化。在 Listing 5-8 中,考虑基类使用纯虚方法的重构,这与 Listing 5-7 相似。
#include <cstdio>
struct BaseClass {
virtual const char* final_message() const = 0; ➊
};
struct DerivedClass : BaseClass ➋ {
const char* final_message() const override ➌ {
return "We apologize for the inconvenience.";
}
};
int main() {
// BaseClass base; // Bang! ➍
DerivedClass derived;
BaseClass& ref = derived;
printf("DerivedClass: %s\n", derived.final_message()); ➎
printf("BaseClass&: %s\n", ref.final_message()); ➏
}
--------------------------------------------------------------------------
DerivedClass: We apologize for the inconvenience. ➎
BaseClass&: We apologize for the inconvenience. ➏
Listing 5-8: 使用纯虚方法重构 Listing 5-7 的示例
=0后缀指定了一个纯虚方法 ➊,这意味着你不能实例化BaseClass——只能从它派生。DerivedClass仍然继承自BaseClass ➋,并且你提供了必需的final_message ➌。试图实例化BaseClass会导致编译错误 ➍。DerivedClass和BaseClass引用的行为和之前一样 ➎➏。
注意
虚函数可能会带来运行时开销,尽管成本通常较低(通常在常规函数调用的 25%以内)。编译器会生成 虚函数表(vtables),其中包含函数指针。在运行时,接口的消费者通常并不知道其底层类型,但它知道如何调用接口的方法(这要归功于 vtable)。在某些情况下,链接器可以检测到所有接口的使用并 去虚拟化函数调用。这会将函数调用从 vtable 中移除,从而消除相关的运行时开销。
纯虚类和虚析构函数
你可以通过从只包含纯虚方法的基类派生来实现接口继承。这类类被称为纯虚类。在 C++中,接口总是纯虚类。通常,你会为接口添加虚拟析构函数。在一些罕见的情况下,如果没有将析构函数标记为虚拟函数,可能会导致资源泄漏。参见 Listing 5-9,该示例说明了未添加虚拟析构函数的危险。
#include <cstdio>
struct BaseClass {};
struct DerivedClass : BaseClass➊ {
DerivedClass() { ➋
printf("DerivedClass() invoked.\n");
}
~DerivedClass() { ➌
printf("~DerivedClass() invoked.\n");
}
};
int main() {
printf("Constructing DerivedClass x.\n");
BaseClass* x{ new DerivedClass{} }; ➍
printf("Deleting x as a BaseClass*.\n");
delete x; ➎
}
--------------------------------------------------------------------------
Constructing DerivedClass x.
DerivedClass() invoked.
Deleting x as a BaseClass*.
Listing 5-9: 说明基类中非虚析构函数危险性的示例
这里你看到一个DerivedClass类继承自BaseClass ➊。这个类有一个构造函数 ➋ 和析构函数 ➌,它们在被调用时会打印信息。在main函数中,你通过new分配并初始化一个DerivedClass,并将结果赋值给一个BaseClass指针 ➍。当你delete这个指针 ➎时,BaseClass的析构函数会被调用,但DerivedClass的析构函数不会被调用!
为BaseClass的析构函数添加虚拟关键字可以解决这个问题,正如 Listing 5-10 所示。
#include <cstdio>
struct BaseClass {
virtual ~BaseClass() = default; ➊
};
struct DerivedClass : BaseClass {
DerivedClass() {
printf("DerivedClass() invoked.\n");
}
~DerivedClass() {
printf("~DerivedClass() invoked.\n"); ➋
}
};
int main() {
printf("Constructing DerivedClass x.\n");
BaseClass* x{ new DerivedClass{} };
printf("Deleting x as a BaseClass*.\n");
delete x; ➌
}
--------------------------------------------------------------------------
Constructing DerivedClass x.
DerivedClass() invoked.
Deleting x as a BaseClass*.
~DerivedClass() invoked. ➋
列表 5-10:对列表 5-9 的重构,带虚拟析构函数
添加虚拟析构函数➊会导致在删除BaseClass指针➌时调用DerivedClass的析构函数,从而导致DerivedClass的析构函数打印消息➋。
在声明接口时声明虚拟析构函数是可选的,但要小心。如果你忘记在接口中实现虚拟析构函数,并不小心做了类似列表 5-9 的操作,你可能会泄漏资源,并且编译器不会警告你。
注意
声明一个受保护的非虚拟析构函数是声明一个公共虚拟析构函数的一个不错替代方案,因为它会在编写删除基类指针的代码时导致编译错误。有些人不喜欢这种方法,因为最终你必须创建一个具有公共析构函数的类,如果你从这个类派生,就会遇到相同的问题。
实现接口
要声明一个接口,声明一个纯虚类。要实现一个接口,必须从它派生。因为接口是纯虚的,所有实现都必须实现接口的所有方法。
标记这些方法时使用override关键字是一个好习惯。这表明你打算重写一个虚拟函数,让编译器帮助你避免一些简单的错误。
使用接口
作为消费者,你只能处理接口的引用或指针。编译器无法预先知道为底层类型分配多少内存:如果编译器能够知道底层类型,你最好使用模板。
设置成员有两种选择:
构造函数注入 使用构造函数注入时,通常使用接口引用。因为引用不能重新绑定,它们在对象的生命周期内不会改变。
属性注入 使用属性注入时,你通过一个方法来设置指针成员。这样可以改变该成员指向的对象。
你可以通过在构造函数中接受一个接口指针,同时提供一个方法来将指针设置为其他对象,从而结合这些方法。
通常,当注入的字段在对象的生命周期内不会改变时,你会使用构造函数注入。如果你需要更改该字段的灵活性,你将提供方法来执行属性注入。
更新银行日志记录器
Logger接口允许你提供多个日志记录实现。这允许Logger消费者使用log_transfer方法记录转账日志,而不需要知道日志记录的实现细节。你已经在列表 5-2 中实现了ConsoleLogger,接下来让我们看看如何添加另一个名为FileLogger的实现。为了简便起见,在这个代码中,你只修改了日志输出的前缀,但你可以想象如何实现一些更复杂的行为。
列表 5-11 定义了一个 FileLogger。
#include <cstdio>
struct Logger {
virtual ~Logger() = default; ➊
virtual void log_transfer(long from, long to, double amount) = 0; ➋
};
struct ConsoleLogger : Logger ➌ {
void log_transfer(long from, long to, double amount) override ➍ {
printf("[cons] %ld -> %ld: %f\n", from, to, amount);
}
};
struct FileLogger : Logger ➎ {
void log_transfer(long from, long to, double amount) override ➏ {
printf("[file] %ld,%ld,%f\n", from, to, amount);
}
};
列表 5-11:Logger,ConsoleLogger 和 FileLogger
Logger 是一个纯虚类(接口),具有默认的虚析构函数 ➊ 和一个方法 log_transfer ➋。ConsoleLogger 和 FileLogger 是 Logger 的实现,因为它们从该接口派生 ➌➎。你已经实现了 log_transfer 并在两者上放置了 override 关键字 ➍➏。
现在我们将看看如何使用构造函数注入或属性注入来更新 Bank。
构造函数注入
使用构造函数注入,你有一个 Logger 引用,并将其传入 Bank 类的构造函数。列表 5-12 在 列表 5-11 的基础上,添加了适当的 Bank 构造函数。这样,你可以确定特定 Bank 实例化时将执行的日志记录类型。
--snip--
// Include Listing 5-11
struct Bank {
Bank(Logger& logger) : logger{ logger }➊ { }
void make_transfer(long from, long to, double amount) {
--snip--
logger.log_transfer(from, to, amount);
}
private:
Logger& logger;
};
int main() {
ConsoleLogger logger;
Bank bank{ logger }; ➋
bank.make_transfer(1000, 2000, 49.95);
bank.make_transfer(2000, 4000, 20.00);
}
--------------------------------------------------------------------------
[cons] 1000 -> 2000: 49.950000
[cons] 2000 -> 4000: 20.000000
列表 5-12:使用构造函数注入、接口和对象组合重构列表 5-2,以取代笨重的 enum class 方法
Bank 类的构造函数使用成员初始化器 ➊ 设置 logger 的值。引用不能重新赋值,因此 logger 所指向的对象在 Bank 生命周期内不会改变。你在 Bank 构造时就确定了日志记录器的选择 ➋。
属性注入
你也可以选择使用属性注入来将 Logger 插入到 Bank 中,而不是使用构造函数注入。这种方法使用指针而不是引用。因为指针可以重新赋值(与引用不同),你可以随时更改 Bank 的行为。列表 5-13 是 列表 5-12 的属性注入变体。
--snip--
// Include Listing 5-11
struct Bank {
void set_logger(Logger* new_logger) {
logger = new_logger;
}
void make_transfer(long from, long to, double amount) {
if (logger) logger->log_transfer(from, to, amount);
}
private:
Logger* logger{};
};
int main() {
ConsoleLogger console_logger;
FileLogger file_logger;
Bank bank;
bank.set_logger(&console_logger); ➊
bank.make_transfer(1000, 2000, 49.95); ➋
bank.set_logger(&file_logger); ➌
bank.make_transfer(2000, 4000, 20.00); ➍
}
--------------------------------------------------------------------------
[cons] 1000 -> 2000: 49.950000 ➋
[file] 2000,4000,20.000000 ➍
列表 5-13:使用属性注入重构列表 5-12
set_logger 方法使你能够在 Bank 对象的生命周期中的任何时刻注入新的日志记录器。当你将日志记录器设置为 ConsoleLogger 实例 ➊ 时,你会在日志输出中得到一个 [cons] 前缀 ➋。当你将日志记录器设置为 FileLogger 实例 ➌ 时,你会得到一个 [file] 前缀 ➍。
选择构造函数注入或属性注入
无论选择构造函数注入还是属性注入,取决于设计需求。如果你需要能够在对象生命周期内修改对象成员的基础类型,你应该选择指针和属性注入方法。但使用指针和属性注入的灵活性是有代价的。在本章中的 Bank 示例中,你必须确保不要将 logger 设置为 nullptr,或者在使用 logger 之前检查这个条件。还有一个问题是默认行为是什么:logger 的初始值是多少?
一种可能的做法是提供构造函数注入和属性注入。这鼓励任何使用你的类的人考虑如何初始化它。列表 5-14 说明了实现这种策略的一种方式。
#include <cstdio>
struct Logger {
--snip--
};
struct Bank {
Bank(Logger* logger) : logger{ logger }{} ➊
void set_logger(Logger* new_logger) { ➋
logger = new_logger;
}
void make_transfer(long from, long to, double amount) {
if (logger) logger->log_transfer(from, to, amount);
}
private:
Logger* logger;
};
代码清单 5-14:对 Bank 的重构,包含构造函数和属性注入
如你所见,你可以包括一个构造函数 ➊ 和一个 setter ➋。这要求 Bank 的用户初始化日志记录器,哪怕是 nullptr。之后,用户可以通过属性注入轻松更换这个值。
总结
在本章中,你学习了如何定义接口、虚函数在使继承有效方面扮演的核心角色,以及一些使用构造函数和属性注入器的通用规则。无论你选择哪种方法,接口继承与组合的结合为大多数运行时多态应用提供了足够的灵活性。你可以以几乎没有开销的方式实现类型安全的运行时多态性。接口鼓励封装和松耦合设计。通过简单、专注的接口,你可以通过使代码跨项目可移植,来促进代码重用。
练习
5-1. 你没有在你的 Bank 中实现一个会计系统。设计一个名为 AccountDatabase 的接口,能够在银行账户中获取和设置金额(通过 long 类型的 id 来标识账户)。
5-2. 生成一个实现了 AccountDatabase 的 InMemoryAccountDatabase。
5-3. 在 Bank 中添加一个 AccountDatabase 引用成员。使用构造函数注入将 InMemoryAccountDatabase 添加到 Bank。
5-4. 修改 ConsoleLogger 以接受一个 const char* 类型的参数进行构造。当 ConsoleLogger 进行日志记录时,将该字符串添加到日志输出的前面。注意,你可以在不修改 Bank 的情况下修改日志记录行为。
进一步阅读
- C++ API 设计 作者:Martin Reddy(Elsevier,2011)
第八章:编译时多态
越灵活,越有趣。
—玛莎·斯图尔特*

在本章中,你将学习如何通过模板实现编译时多态。你将学习如何声明和使用模板,强制类型安全,并探讨模板的更多高级用法。本章最后会对 C++ 中的运行时多态和编译时多态进行比较。
模板
C++ 通过模板实现编译时多态。模板是一个带有模板参数的类或函数。这些参数可以代表任何类型,包括基本类型和用户自定义类型。当编译器看到模板与某个类型一起使用时,它会生成一个专门的模板实例。
模板实例化 是从模板创建类或函数的过程。有些时候,令人困惑的是,你也可以将“模板实例化”称为模板实例化过程的结果。模板实例化有时被称为具体类和具体类型。
这个大致的想法是,与其到处复制粘贴常见代码,不如编写一个模板;当编译器遇到模板参数的新类型组合时,它会生成新的模板实例。
声明模板
你用一个template 前缀来声明模板,前缀由关键字 template 和尖括号 < > 组成。在尖括号内,你放置一个或多个模板参数的声明。你可以使用 typename 或 class 关键字后跟标识符来声明模板参数。例如,模板前缀 template<typename T> 表明该模板接受一个模板参数 T。
注意
typename 和 class 关键字的共存是不幸且令人困惑的。它们的意思相同。(由于历史原因,它们都被支持。)本章始终使用 typename。
模板类定义
考虑 列表 6-1 中的 MyTemplateClass,它接受三个模板参数:X、Y 和 Z。
template➊<typename X, typename Y, typename Z> ➋
struct MyTemplateClass➌ {
X foo(Y&); ➍
private:
Z* member; ➎
};
列表 6-1:一个具有三个模板参数的模板类
template 关键字 ➊ 开始模板前缀,其中包含模板参数 ➋。这个 template 前言导致 MyTemplateClass ➌ 的剩余声明有些特别。在 MyTemplateClass 中,你像使用任何完全指定的类型(如 int 或用户定义的类)一样使用 X、Y 和 Z。
foo 方法接受一个 Y 引用并返回一个 X ➍。你可以声明包含模板参数的成员类型,比如指向 Z 的指针 ➎。除了特殊的前缀 ➊ 外,这个模板类与非模板类基本相同。
模板函数定义
你还可以指定模板函数,比如在列表 6-2 中也接受三个模板参数:X、Y 和 Z 的 my_template_function。
template<typename X, typename Y, typename Z>
X my_template_function(Y& arg1, const Z* arg2) {
--snip--
}
清单 6-2:一个具有三个模板参数的模板函数
在 my_template_function 的函数体内,你可以根据需要使用 arg1 和 arg2,只要你返回一个类型为 X 的对象。
实例化模板
要实例化一个模板类,请使用以下语法:
tc_name➊<t_param1➋, t_param2, ...> my_concrete_class{ ... }➌;
tc_name ➊ 是你放置模板类名称的地方。接下来,你填写你的模板参数 ➋。最后,你将模板名称和参数的组合视为普通类型:你可以使用任何初始化语法 ➌。
实例化一个模板函数是类似的:
auto result = tf_name➊<t_param1➋, t_param2, ...>(f_param1➌, f_param2, ...);
tf_name ➊ 是你放置模板函数名称的地方。你按照模板类的方式填写参数 ➋。你将模板名称和参数的组合视为普通类型。你通过括号和函数参数来调用这个模板函数实例化 ➌。
所有这些新的符号对初学者来说可能很令人生畏,但一旦习惯了,就不会那么难。实际上,它们在一组语言特性中得到了应用,这些特性被称为命名转换函数。
命名转换函数
命名转换 是语言特性,用于显式地将一种类型转换为另一种类型。你在无法使用隐式转换或构造函数获取所需类型的情况下,谨慎使用命名转换。
所有命名转换接受一个对象参数,即你希望转换的 object-to-cast,以及一个类型参数,即你希望转换成的目标类型 desired-type:
named-conversion<desired-type>(object-to-cast)
例如,如果你需要修改一个 const 对象,你首先需要去掉 const 限定符。命名转换函数 const_cast 允许你执行此操作。其他命名转换帮助你逆转隐式转换(static_cast)或以不同类型重新解释内存(reinterpret_cast)。
注意
尽管命名转换函数在技术上不是模板函数,但它们在概念上与模板非常相似——这一关系体现在它们的语法相似性上。
const_cast
const_cast 函数去掉了 const 修饰符,允许修改 const 值。object-to-cast 是某个 const 类型的对象,所需的目标类型是去掉 const 限定符的该类型。
请考虑清单 6-3 中的 carbon_thaw 函数,它接受一个 const 引用的 encased_solo 参数。
void carbon_thaw(const➊ int& encased_solo) {
//encased_solo++; ➋ // Compiler error; modifying const
auto& hibernation_sick_solo = const_cast➌<int&➍>(encased_solo➎);
hibernation_sick_solo++; ➏
}
清单 6-3:使用 const_cast 的函数。取消注释会导致编译器错误。
encased_solo 参数是 const ➊,因此任何试图修改它的行为 ➋ 都会导致编译器错误。你可以使用 const_cast ➌ 来获取非 const 引用 hibernation_sick_solo。const_cast 接受一个模板参数,即你希望转换为的类型 ➍。它还接受一个函数参数,即你希望去除 const 的对象 ➎。然后,你就可以通过新的非 const 引用 ➏ 来修改 encased_solo 指向的 int。
只使用 const_cast 来获取对 const 对象的写访问权限。任何其他类型的转换都将导致编译错误。
注意
显然,你可以使用 const_cast 向对象的类型添加 const,但不应该这么做,因为它冗长且不必要。最好使用隐式转换。在 第七章 中,你将学习 volatile 修饰符是什么。你也可以使用 const_cast 从对象中移除 volatile 修饰符。
static_cast
static_cast 反转一个明确定义的隐式转换,例如整数类型到另一个整数类型。object-to-cast 是某种类型,desired-type 可以隐式地转换成该类型。你可能需要使用 static_cast 的原因是,一般来说,隐式转换不可逆。
示例 6-4 中的程序定义了一个 increment_as_short 函数,该函数接受一个 void 指针参数。它使用 static_cast 从这个参数创建一个 short 指针,递增指向的 short,并返回结果。在一些低级应用中,如网络编程或处理二进制文件格式,你可能需要将原始字节解释为整数类型。
#include <cstdio>
short increment_as_short(void*➊ target) {
auto as_short = static_cast➋<short*➌>(target➍);
*as_short = *as_short + 1;
return *as_short;
}
int main() {
short beast{ 665 };
auto mark_of_the_beast = increment_as_short(&beast);
printf("%d is the mark_of_the_beast.", mark_of_the_beast);
}
--------------------------------------------------------------------------
666 is the mark_of_the_beast.
示例 6-4:使用 static_cast 的程序
target 参数是一个 void 指针 ➊。你使用 static_cast 将 target 转换为 short* ➋。模板参数是所需的类型 ➌,函数参数是你想要转换的对象 ➍。
注意,short* 到 void* 的隐式转换是明确定义的。尝试使用 static_cast 进行未定义的转换,例如将 char* 转换为 float*,将导致编译错误:
float on = 3.5166666666;
auto not_alright = static_cast<char*>(&on); // Bang!
要执行这样的链锯杂技,你需要使用reinterpret_cast。
reinterpret_cast
有时在低级编程中,你必须执行一些未定义类型转换。在系统编程中,尤其是在嵌入式环境下,你通常需要完全控制如何解释内存。reinterpret_cast 给了你这种控制,但确保这些转换的正确性完全是你的责任。
假设你的嵌入式设备在内存地址 0x1000 处保存了一个 unsigned long 类型的定时器。你可以使用 reinterpret_cast 来读取定时器,正如 示例 6-5 中所示。
#include <cstdio>
int main() {
auto timer = reinterpret_cast➊<const unsigned long*➋>(0x1000➌);
printf("Timer is %lu.", *timer);
}
示例 6-5:使用 reinterpret_cast 的程序。该程序将编译,但除非 0x1000 是可读的,否则你应预期程序在运行时崩溃。
reinterpret_cast ➊ 需要一个类型参数,对应于所需的指针类型 ➋ 和结果应指向的内存地址 ➌。
当然,编译器无法知道地址 0x1000 处的内存是否包含一个 unsigned long。完全由你负责确保正确性。因为你要为这个非常危险的构造承担全部责任,编译器强制你使用 reinterpret_cast。例如,你不能将 timer 的初始化替换为以下行:
const unsigned long* timer{ 0x1000 };
编译器会抱怨将int转换为指针。
narrow_cast
列表 6-6 展示了一个自定义的static_cast,它执行运行时检查以检测缩小。缩小是信息丢失的过程。想象一下从int转换为short。只要int的值能适应short,转换就是可逆的,不会发生缩小。如果int的值太大,超出了short的最大值,那么转换就是不可逆的,会导致缩小。
让我们实现一个名为narrow_cast的转换,它会检查缩小并在检测到时抛出runtime_error。
#include <stdexcept>
template <typename To➊, typename From➋>
To➌ narrow_cast(From➍ value) {
const auto converted = static_cast<To>(value); ➎
const auto backwards = static_cast<From>(converted); ➏
if (value != backwards) throw std::runtime_error{ "Narrowed!" }; ➐
return converted; ➑
}
列表 6-6:narrow_cast的定义
narrow_cast函数模板有两个模板参数:您要转换的类型To ➊和您要转换的类型From ➋。您可以看到这些模板参数在函数的返回类型 ➌ 和参数值的类型 ➍ 中的实际应用。首先,您使用static_cast执行请求的转换,得到converted ➎。接着,您将转换方向反转(从converted转换为类型From),得到backwards ➏。如果value不等于backwards,说明您进行了缩小,因此抛出一个异常 ➐。否则,返回converted ➑。
您可以在列表 6-7 中看到narrow_cast的实际应用。
#include <cstdio>
#include <stdexcept>
template <typename To, typename From>
To narrow_cast(From value) {
--snip--
}
int main() {
int perfect{ 496 }; ➊
const auto perfect_short = narrow_cast<short>(perfect); ➋
printf("perfect_short: %d\n", perfect_short); ➌
try {
int cyclic{ 142857 }; ➍
const auto cyclic_short = narrow_cast<short>(cyclic); ➎
printf("cyclic_short: %d\n", cyclic_short);
} catch (const std::runtime_error& e) {
printf("Exception: %s\n", e.what()); ➏
}
}
--------------------------------------------------------------------------
perfect_short: 496 ➌
Exception: Narrowed! ➏
列表 6-7:使用narrow_cast的程序。(输出来自在 Windows 10 x64 上的执行。)
首先,您将perfect初始化为 496 ➊,然后将其narrow_cast为短整型perfect_short ➋。此操作不会出现异常,因为值 496 可以轻松适应 Windows 10 x64 上的 2 字节short(最大值为 32767)。您会看到预期的输出 ➌。接下来,您将cyclic初始化为 142857 ➍,并尝试将其narrow_cast为短整型cyclic_short ➎。这会抛出一个runtime_error,因为 142857 大于short的最大值 32767。narrow_cast中的检查会失败。您会在output中看到异常 ➏。
请注意,在实例化时,您只需要提供一个模板参数,即返回类型 ➋➎。编译器可以根据使用情况推断出From参数。
mean:模板函数示例
请参阅列表 6-8 中计算double数组均值的函数,该函数使用求和除法方法。
#include <cstddef>
double mean(const double* values, size_t length) {
double result{}; ➊
for(size_t i{}; i<length; i++) {
result += values[i]; ➋
}
return result / length; ➌
}
列表 6-8:计算数组均值的函数
您将result变量初始化为零 ➊。接下来,通过遍历每个索引i,将对应的元素添加到result中 ➋。然后,您将result除以length并返回 ➌。
泛型化均值
假设您想支持其他数值类型的mean计算,例如float或long。您可能会想,“这就是函数重载的作用!”从本质上来说,您是对的。
清单 6-9 重载了mean,使其接受一个long数组。最简单的方法是复制并粘贴原始代码,然后将double替换为long。
#include <cstddef>
long➊ mean(const long*➋ values, size_t length) {
long result{}; ➌
for(size_t i{}; i<length; i++) {
result += values[i];
}
return result / length;
}
清单 6-9:清单 6-8 的一个重载版本,接受long数组
这确实是大量的复制粘贴,而且你几乎没有做任何改变:返回类型➊,函数参数➋,以及result ➌。
随着你添加更多类型,这种方法无法扩展。如果你想支持其他整型类型,比如short类型或uint_64类型怎么办?float类型呢?如果后来你想重构mean中的某些逻辑呢?你将面临大量繁琐且容易出错的维护工作。
在清单 6-9 中,mean有三个更改,所有更改都涉及将double类型替换为long类型。理想情况下,每当编译器遇到不同类型的使用时,它可以自动为你生成该函数的版本。关键是逻辑没有变化——只是类型发生了变化。
解决这个复制粘贴问题所需要的是泛型编程,这是一种使用尚未指定的类型进行编程的编程风格。你可以利用 C++对模板的支持实现泛型编程。模板允许编译器基于正在使用的类型实例化自定义类或函数。
现在你知道如何声明模板了,再看看mean函数。你仍然希望mean能够接受广泛的类型——不仅仅是double类型——但你不希望一遍又一遍地复制粘贴相同的代码。
考虑如何将清单 6-8 重构为一个模板函数,正如清单 6-10 中所演示的那样。
#include <cstddef>
template<typename T> ➊
T➋ mean(constT*➌ values, size_t length) {
T➍ result{};
for(size_t i{}; i<length; i++) {
result += values[i];
}
return result / length;
}
清单 6-10:将清单 6-8 重构为模板函数
清单 6-10 以模板前缀➊开始。这个前缀传递了一个模板参数T。接下来,你更新mean,将T替换为double ➋➌➍。
现在,你可以用许多不同的类型来使用mean。每当编译器遇到使用新类型的mean时,它会执行模板实例化。这就好像你做了复制粘贴和替换类型的操作,但编译器在执行细节导向的、单调的任务上比你要强得多。考虑清单 6-11 中的示例,它计算double、float和size_t类型的均值。
#include <cstddef>
#include <cstdio>
template<typename T>
T mean(const T* values, size_t length) {
--snip--
}
int main() {
const double nums_d[] { 1.0, 2.0, 3.0, 4.0 };
const auto result1 = mean<double>(nums_d, 4); ➊
printf("double: %f\n", result1);
const float nums_f[] { 1.0f, 2.0f, 3.0f, 4.0f };
const auto result2 = mean<float>(nums_f, 4); ➋
printf("float: %f\n", result2);
const size_t nums_c[] { 1, 2, 3, 4 };
const auto result3 = mean<size_t>(nums_c, 4); ➌
printf("size_t: %zu\n", result3);
}
--------------------------------------------------------------------------
double: 2.500000
float: 2.500000
size_t: 2
清单 6-11:使用模板函数mean的程序
三个模板被实例化了➊➋➌;这就像你手动生成了清单 6-12 中孤立的重载函数。(每个模板实例化包含了类型,类型以粗体显示,表示编译器为模板参数替换了类型。)
double mean(const double* values, size_t length) {
double result{};
for(size_t i{}; i<length; i++) {
result += values[i];
}
return result / length;
}
float mean(const float* values, size_t length) {
float result{};
for(size_t i{}; i<length; i++) {
result += values[i];
}
return result / length;
}
size_t mean(const size_t* values, size_t length) {
size_t result{};
for(size_t i{}; i<length; i++) {
result += values[i];
}
return result / length;
}
清单 6-12:为清单 6-11 生成的模板实例化
编译器为你做了很多工作,但你可能已经注意到,你必须两次输入指向数组的类型:一次是声明数组,另一次是指定模板参数。这变得很繁琐,并且可能导致错误。如果模板参数不匹配,通常会得到编译器错误或导致意外的类型转换。
幸运的是,调用模板函数时通常可以省略模板参数。编译器用来确定正确模板参数的过程叫做模板类型推导。
模板类型推导
通常情况下,你不需要提供模板函数的参数。编译器可以从使用情况中推导出这些参数,因此可以看到清单 6-11 在没有显式模板参数的情况下的重写版本,见清单 6-13。
#include <cstddef>
#include <cstdio>
template<typename T>
T mean(const T* values, size_t length) {
--snip--
}
int main() {
const double nums_d[] { 1.0, 2.0, 3.0, 4.0 };
const auto result1 = mean(nums_d, 4); ➊
printf("double: %f\n", result1);
const float nums_f[] { 1.0f, 2.0f, 3.0f, 4.0f };
const auto result2 = mean(nums_f, 4); ➋
printf("float: %f\n", result2);
const size_t nums_c[] { 1, 2, 3, 4 };
const auto result3 = mean(nums_c, 4); ➌
printf("size_t: %zu\n", result3);
}
--------------------------------------------------------------------------
double: 2.500000
float: 2.500000
size_t: 2
清单 6-13:一个没有显式模板参数的清单 6-11 重构版本
从使用情况来看,模板参数分别是double ➊、float ➋和size_t ➌。
注意
模板类型推导通常按照你预期的方式工作,但如果你编写大量通用代码,你可能会遇到一些细节问题。有关更多信息,请参阅 ISO 标准[temp]。另外,参考 Scott Meyers 的《Effective Modern C++》中的第 1 条和 Bjarne Stroustrup 的《C++程序设计语言(第 4 版)》中的第 23.5.1 节。
有时,模板参数无法推导。例如,如果模板函数的返回类型是一个完全独立于其他函数和模板参数的模板参数,你必须显式指定模板参数。
SimpleUniquePointer:一个模板类示例
唯一指针是一个围绕自由存储分配对象的 RAII 封装器。正如其名称所示,唯一指针在任何时刻只有一个所有者,因此当唯一指针的生命周期结束时,所指向的对象会被销毁。
在唯一指针中,底层对象的类型并不重要,这使得它们成为模板类的理想候选。考虑清单 6-14 中的实现。
template <typename T> ➊
struct SimpleUniquePointer {
SimpleUniquePointer() = default; ➋
SimpleUniquePointer(T* pointer)
: pointer{ pointer } { ➌
}
~SimpleUniquePointer() { ➍
if(pointer) delete pointer;
}
SimpleUniquePointer(const SimpleUniquePointer&) = delete;
SimpleUniquePointer& operator=(const SimpleUniquePointer&) = delete; ➎
SimpleUniquePointer(SimpleUniquePointer&& other) noexcept ➏
: pointer{ other.pointer } {
other.pointer = nullptr;
}
SimpleUniquePointer& operator=(SimpleUniquePointer&& other) noexcept { ➐
if(pointer) delete pointer;
pointer = other.pointer;
other.pointer = nullptr;
return *this;
}
T* get() { ➑
return pointer;
}
private:
T* pointer;
};
清单 6-14:一个简单的唯一指针实现
你通过一个模板前缀➊声明模板类,这样就确立了T作为封装对象的类型。接下来,使用default关键字➋指定默认构造函数。(回想一下第四章,当你需要一个默认构造函数和一个非默认构造函数时,必须使用default。)生成的默认构造函数会根据默认初始化规则将私有成员T*指针初始化为nullptr。你还有一个非默认构造函数,它接受一个T*并将私有成员指针设置为➌。因为指针可能是nullptr,析构函数在删除之前会进行检查➍。
因为你只想允许指向对象的唯一所有者,所以你删除了拷贝构造函数和拷贝赋值运算符 ➎。这样可以防止双重释放问题,正如在第四章中讨论的那样。然而,你可以通过添加移动构造函数 ➏ 来使你的唯一指针可移动。这会从 other 中窃取 pointer 的值,然后将 other 的指针设置为 nullptr,将指向对象的责任交给 this。一旦移动构造函数返回,已移动的对象会被销毁。因为已移动对象的指针被设置为 nullptr,所以析构函数不会删除指向的对象。
由于 this 可能已经拥有一个对象,这使得移动赋值变得复杂 ➐。你必须显式检查是否已经拥有该对象,因为如果未能删除指针,会导致资源泄漏。通过这次检查后,你执行与拷贝构造函数相同的操作:将 pointer 设置为 other.pointer 的值,然后将 other.pointer 设置为 nullptr。这确保了被移动的对象不会删除指向的对象。
你可以通过调用 get 方法直接访问底层指针 ➑。
让我们请出老朋友 Tracer,它出现在列表 4-5 中,来调查 SimpleUniquePointer。考虑一下列表 6-15 中的程序。
#include <cstdio>
#include <utility>
template <typename T>
struct SimpleUniquePointer {
--snip--
};
struct Tracer {
Tracer(const char* name) : name{ name } {
printf("%s constructed.\n", name); ➊
}
~Tracer() {
printf("%s destructed.\n", name); ➋
}
private:
const char* const name;
};
void consumer(SimpleUniquePointer<Tracer> consumer_ptr) {
printf("(cons) consumer_ptr: 0x%p\n", consumer_ptr.get()); ➌
}
int main() {
auto ptr_a = SimpleUniquePointer(new Tracer{ "ptr_a" });
printf("(main) ptr_a: 0x%p\n", ptr_a.get()); ➍
consumer(std::move(ptr_a));
printf("(main) ptr_a: 0x%p\n", ptr_a.get()); ➎
}
--------------------------------------------------------------------------
ptr_a constructed. ➊
(main) ptr_a: 0x000001936B5A2970 ➍
(cons) consumer_ptr: 0x000001936B5A2970 ➌
ptr_a destructed. ➋
(main) ptr_a: 0x0000000000000000 ➎
列表 6-15:一个使用 Tracer 类调查 SimpleUniquePointers 的程序
首先,你动态分配一个名为 ptr_a 的 Tracer。这会打印出第一条消息 ➊。然后,你使用得到的 Tracer 指针来构造一个名为 ptr_a 的 SimpleUniquePointer。接下来,你使用 ptr_a 的 get() 方法来获取其 Tracer 的地址,并打印 ➍。然后你使用 std::move 将 ptr_a 的 Tracer 转交给 consumer 函数,这会将 ptr_a 移动到 consumer_ptr 参数中。
现在,consumer_ptr 拥有 Tracer。你使用 consumer_ptr 的 get() 方法来获取 Tracer 的地址,然后打印 ➌。注意这个地址与 ➍ 打印的地址相同。当 consumer 返回时,consumer_ptr 被销毁,因为它的生命周期是 consumer 的作用域。因此,ptr_a 会被析构 ➋。
请记住,ptr_a 已经处于一个“已移动”状态——你已经将它的 Tracer 移动到 consumer。你使用 ptr_a 的 get() 方法来说明它现在持有一个 nullptr ➎。
由于有了 SimpleUniquePointer,你就不会泄漏一个动态分配的对象;此外,因为 SimpleUniquePointer 仅在背后携带一个指针,所以移动语义非常高效。
注意
SimpleUniquePointer 是对 stdlib 的 std::unique_ptr 的教学性实现,它是称为智能指针的 RAII 模板家族的一员。你将在第二部分中学习这些内容。
模板中的类型检查
模板是类型安全的。在模板实例化过程中,编译器将模板参数粘贴到模板中。如果生成的代码不正确,编译器将不会生成该实例化。
考虑列表 6-16 中的模板函数,它对一个元素进行平方并返回结果。
template<typename T>
T square(T value) {
return value * value; ➊
}
列表 6-16:一个对值进行平方的模板函数
T 有一个隐式要求:它必须支持乘法 ➊。
如果你尝试使用 square,例如使用 char*,编译将失败,如列表 6-17 所示。
template<typename T>
T square(T value) {
return value * value;
}
int main() {
char my_char{ 'Q' };
auto result = square(&my_char); ➊ // Bang!
}
列表 6-17:一个模板实例化失败的程序。(这个程序无法编译。)
指针不支持乘法,因此模板初始化失败 ➊。
square 函数非常简单,但失败的模板初始化错误信息却不简单。在 MSVC v141 上,你会看到这个:
main.cpp(3): error C2296: '*': illegal, left operand has type 'char *'
main.cpp(8): note: see reference to function template instantiation 'T *square<char*>(T)' being compiled
with
[
T=char *
]
main.cpp(3): error C2297: '*': illegal, right operand has type 'char *'
在 GCC 7.3 上,你会看到这个:
main.cpp: In instantiation of 'T square(T) [with T = char*]':
main.cpp:8:32: required from here
main.cpp:3:16: error: invalid operands of types 'char*' and 'char*' to binary
'operator*'
return value * value;
~~~~~~^~~~~~~
这些错误信息展示了模板初始化失败时 notoriously cryptic 的错误信息。
尽管模板实例化确保了类型安全,但检查发生在编译过程的非常晚阶段。当编译器实例化模板时,它将模板参数类型粘贴到模板中。类型插入之后,编译器尝试编译结果。如果实例化失败,编译器会在模板实例化内发出错误信息。
C++ 模板编程与鸭子类型语言有相似之处。鸭子类型语言(如 Python)会推迟类型检查,直到运行时。其基本哲学是,如果一个对象看起来像鸭子并且叫声像鸭子,那么它就应该是鸭子类型。不幸的是,这意味着你无法在程序执行之前判断一个对象是否支持某个特定操作。
使用模板时,直到你尝试编译它,你才知道实例化是否会成功。尽管鸭子类型语言可能会在运行时崩溃,但模板可能会在编译时崩溃。
这种情况在 C++ 社区中被认为是不可接受的,因此有一个精彩的解决方案,叫做概念。
概念
概念 限制模板参数,允许在实例化时而不是首次使用时进行参数检查。通过在实例化时捕获使用问题,编译器可以为你提供友好的、有用的错误代码——例如,“你尝试使用 char* 实例化这个模板,但该模板需要一个支持乘法的类型。”
概念允许你直接在语言中表达模板参数的要求。
不幸的是,概念尚未正式成为 C++ 标准的一部分,尽管它们已经被投票纳入 C++ 20。截止目前,GCC 6.0 及之后的版本支持概念技术规范,而微软正在积极努力在其 C++ 编译器 MSVC 中实现概念。尽管它们还不是正式标准,但出于以下几个原因,深入了解概念是值得的:
-
它们将从根本上改变你实现编译时多态性的方法。熟悉概念将带来巨大的回报。
-
它们提供了一个概念框架,用于理解在模板被误用时,你可以采取的一些临时解决方案,以获得更好的编译器错误信息。
-
它们提供了从编译时模板到接口的优秀概念桥梁,接口是实现运行时多态性的主要机制(详见第五章)。
-
如果你可以使用 GCC 6.0 或更高版本,概念是可用的,只需启用
-fconcepts编译器标志。
警告
C++ 20 的最终概念规范几乎肯定会与概念技术规范有所不同。本节介绍了根据概念技术规范指定的概念,以便你可以跟上。
定义一个概念
概念是一个模板。它是一个常量表达式,涉及模板参数,在编译时评估。把概念看作是一个大的谓词:一个评估为true或false的函数。
如果一组模板参数符合给定概念的标准,那么在用这些参数实例化时,该概念会评估为true;否则,评估为false。当概念评估为false时,模板实例化将失败。
你可以使用关键字concept声明概念,语法与常规模板函数定义类似:
template<typename T1, typename T2, ...>
concept bool ConceptName() {
--snip--
}
类型特征
概念验证类型参数。在概念中,你操作类型以检查其属性。你可以手动实现这些操作,也可以使用标准库中内建的类型支持库。该库包含检查类型属性的工具,这些工具统称为类型特征。它们可以在<type_traits>头文件中找到,并且属于std命名空间。表 6-1 列出了常用的类型特征。
备注
有关标准库中可用的类型特征的详细列表,请参见 Nicolai M. Josuttis 的《C++标准库》第 2 版第 5.4 章。
表 6-1: 选自<type_traits>头文件的类型特征
| 类型特征 | 检查模板参数是否是… |
|---|---|
is_void |
void |
is_null_pointer |
nullptr |
is_integral |
bool、char类型、int类型、short类型、long类型或long long类型 |
is_floating_point |
float、double或long double |
is_fundamental |
任何一个is_void、is_null_pointer、is_integral或is_floating_point |
is_array |
数组类型;即包含方括号[]的类型 |
is_enum |
枚举类型(enum) |
is_class |
类类型(但不是联合类型) |
is_function |
函数类型 |
is_pointer |
指针;包括函数指针,但不包括类成员指针和nullptr |
is_reference |
引用类型(包括左值和右值) |
is_arithmetic |
is_floating_point或is_integral |
is_pod |
一个简单的旧数据类型;即,可以作为普通 C 中的数据类型表示的类型 |
is_default_constructible |
可以默认构造;即,可以没有参数或初始化值地构造 |
is_constructible |
是否可以使用给定的模板参数构造:此类型特征允许用户提供超出当前考虑类型的其他模板参数 |
is_copy_constructible |
可以通过复制构造 |
is_move_constructible |
可以通过移动构造 |
is_destructible |
是否可以析构 |
is_same |
与附加模板参数类型相同(包括const和volatile修饰符) |
is_invocable |
可以使用给定的模板参数调用:此类型特征允许用户提供超出当前考虑类型的其他模板参数 |
每个类型特征都是一个模板类,接受一个模板参数,即你想要检查的类型。你可以通过模板的静态成员value提取结果。如果类型参数满足条件,该成员的值为true;否则为false。
考虑类型特征类is_integral和is_floating_point。它们用于检查一个类型是否是(你猜对了)整数类型或浮点类型。这两个模板都接受一个模板参数。在清单 6-18 中的示例检查了多个类型的类型特征。
#include <type_traits>
#include <cstdio>
#include <cstdint>
constexpr const char* as_str(bool x) { return x ? "True" : "False"; } ➊
int main() {
printf("%s\n", as_str(std::is_integral<int>::value)); ➋
printf("%s\n", as_str(std::is_integral<const int>::value)); ➌
printf("%s\n", as_str(std::is_integral<char>::value)); ➍
printf("%s\n", as_str(std::is_integral<uint64_t>::value)); ➎
printf("%s\n", as_str(std::is_integral<int&>::value)); ➏
printf("%s\n", as_str(std::is_integral<int*>::value)); ➐
printf("%s\n", as_str(std::is_integral<float>::value)); ➑
}
--------------------------------------------------------------------------
True ➋
True ➌
True ➍
True ➎
False ➏
False ➐
False ➑
清单 6-18:使用类型特征的程序
清单 6-18 定义了便捷函数as_str ➊,用来打印布尔值,返回字符串True或False。在main函数中,你打印了各种类型特征实例化的结果。模板参数int ➋、const int ➌、char ➍和uint64_t ➎传递给is_integral时,都会返回true。引用类型 ➏➐ 和浮点类型 ➑ 返回false。
注意
请记住,printf没有为bool类型提供格式说明符。与其使用整数格式说明符%d作为替代,清单 6-18 使用了as_str函数,根据bool的值返回字符串字面量True或False。由于这些值是字符串字面量,你可以根据需要对它们进行大小写转换。
类型特征通常是概念的构建块,但有时你需要更多的灵活性。类型特征告诉你什么类型是,但有时你还必须指定模板如何使用这些类型。为此,你需要使用要求(requirements)。
要求
要求是对模板参数的临时约束。每个概念可以为其模板参数指定任意数量的要求。要求被编码为requires关键字后跟函数参数和主体的要求表达式。
一系列语法要求构成了要求表达式的主体。每个语法要求对模板参数施加约束。要求表达式的形式如下:
requires (arg-1, arg-2, ...➊) {
{ expression1➋ } -> return-type1➌;
{ expression2 } -> return-type2;
--snip--
}
requires表达式接受你在requires关键字后面放置的参数 ➊。这些参数的类型来源于模板参数。接下来是语法要求,每个要求用{ } ->表示。你在每对大括号内放置一个任意表达式 ➋。这个表达式可以涉及任何数量的参数表达式。
如果实例化导致语法表达式无法编译,则该语法要求失败。假设表达式在没有错误的情况下计算,接下来的检查是该表达式的返回类型是否与箭头->后面的类型匹配 ➌。如果表达式结果的计算类型不能隐式转换为返回类型 ➌,则语法要求失败。
如果任何语法要求失败,requires表达式的求值结果为false。如果所有语法要求都通过,requires表达式的求值结果为true。
假设你有两种类型,T和U,你想知道是否可以使用相等==和不等!=运算符比较这两种类型的对象。编码此要求的一种方法是使用以下表达式。
// T, U are types
requires (T t, U u) {
{ t == u } -> bool; // syntactic requirement 1
{ u == t } -> bool; // syntactic requirement 2
{ t != u } -> bool; // syntactic requirement 3
{ u != t } -> bool; // syntactic requirement 4
}
requires表达式接受两个参数,每个参数的类型分别为T和U。requires表达式中的每个语法要求都是使用t和u进行==或!=比较的表达式。所有四个语法要求都强制要求返回bool类型的结果。任何两个满足该requires表达式的类型,都能保证支持==和!=的比较。
从 Requires 表达式构建概念
因为requires表达式在编译时求值,概念可以包含任意数量的它们。尝试构造一个防止误用mean的概念。清单 6-19 注释了一些之前在清单 6-10 中使用的隐式要求。
template<typename T>
T mean(T* values, size_t length) {
T result{}; ➊
for(size_t i{}; i<length; i++) {
result ➋+= values[i];
}
➌return result / length;
}
清单 6-19:带有对T隐式要求的注释的 6-10 重新列出
你可以看到这段代码暗示了三个要求:
-
T必须是默认可构造的 ➊。 -
T支持operator+=➋。 -
将一个
T除以一个size_t得到一个T➌。
从这些要求中,你可以创建一个名为Averageable的概念,正如清单 6-20 中演示的那样。
template<typename T>
concept bool Averageable() {
return std::is_default_constructible<T>::value ➊
&& requires (T a, T b) {
{ a += b } -> T; ➋
{ a / size_t{ 1 } } -> T; ➌
};
}
清单 6-20:一个Averageable概念。注释与要求和mean的主体一致。
你使用类型特性is_default_constructible来确保T是默认可构造的 ➊,可以对两个T类型进行加法操作 ➋,并且能够将T除以size_t ➌并得到T类型的结果。
记住,概念只是谓词;你正在构建一个布尔表达式,当模板参数被支持时,它返回true,而当不被支持时,它返回false。这个概念由一个类型特性 ➊ 和一个包含两个要求表达式 ➋➌ 的requires组成。如果三个要求中的任何一个返回false,那么该概念的约束未被满足。
使用概念
声明概念比使用它们要麻烦得多。要使用一个概念,只需在typename关键字的位置使用该概念的名称。
例如,你可以通过Averageable概念重构清单 6-13,如清单 6-21 所示。
#include <cstddef>
#include <type_traits>
template<typename T>
concept bool Averageable() { ➊
--snip--
}
template<Averageable➋ T>
T mean(const T* values, size_t length) {
--snip--
}
int main() {
const double nums_d[] { 1.0f, 2.0f, 3.0f, 4.0f };
const auto result1 = mean(nums_d, 4);
printf("double: %f\n", result1);
const float nums_f[] { 1.0, 2.0, 3.0, 4.0 };
const auto result2 = mean(nums_f, 4);
printf("float: %f\n", result2);
const size_t nums_c[] { 1, 2, 3, 4 };
const auto result3 = mean(nums_c, 4);
printf("size_t: %d\n", result3);
}
--------------------------------------------------------------------------
double: 2.500000
float: 2.500000
size_t: 2
清单 6-21:使用Averageable重构清单 6-13
定义Averageable ➊后,你只需将其替代typename ➋使用即可。无需进一步修改。从编译清单 6-13 生成的代码与从编译清单 6-21 生成的代码是完全相同的。
其回报是在你尝试使用一个非Averageable类型的mean时:你会在实例化时收到编译器错误。这比你从原始模板中得到的编译器错误信息要清晰得多。
看看在清单 6-22 中mean的实例化,在那里你“意外”尝试对double指针数组求平均值。
--snip—
int main() {
auto value1 = 0.0;
auto value2 = 1.0;
const double* values[] { &value1, &value2 };
mean(values➊, 2);
}
清单 6-22:使用非Averageable参数的错误模板实例化
使用values ➊时存在几个问题。编译器能告诉你这些问题吗?
如果没有概念,GCC 6.3 会产生清单 6-23 中显示的错误信息。
<source>: In instantiation of 'T mean(const T*, size_t) [with T = const
double*; size_t = long unsigned int]':
<source>:17:17: required from here
<source>:8:12: error: invalid operands of types 'const double*' and 'const
double*' to binary 'operator+'
result += values[i]; ➊
~~~~~~~^~~~~~~~~~
<source>:8:12: error: in evaluation of 'operator+=(const double*, const
double*)'
<source>:10:17: error: invalid operands of types 'const double*' and 'size_t'
{aka 'long unsigned int'} to binary 'operator/'
return result / length; ➋
~~~~~~~^~~~~~~~
清单 6-23:使用 GCC 6.3 编译清单 6-22 时的错误信息
你可能会觉得mean的普通用户看到这个错误信息时会非常困惑。i ➊是什么?为什么const double*会参与到除法运算中 ➋?
概念提供了更具启发性的错误信息,正如清单 6-24 所展示的那样。
<source>: In function 'int main()':
<source>:28:17: error: cannot call function 'T mean(const T*, size_t) [with T
= const double*; size_t = long unsigned int]'
mean(values, 2); ➊
^
<source>:16:3: note: constraints not satisfied
T mean(const T* values, size_t length) {
^~~~
<source>:6:14: note: within 'template<class T> concept bool Averageable()
[with T = const double*]'
concept bool Averageable() {
^~~~~~~~~~~
<source>:6:14: note: with 'const double* a'
<source>:6:14: note: with 'const double* b'
<source>:6:14: note: the required expression '(a + b)' would be ill-formed ➋
<source>:6:14: note: the required expression '(a / b)' would be ill-formed ➌
清单 6-24:使用 GCC 7.2 编译启用概念的清单 6-22 时的错误信息
这个错误信息非常棒。编译器告诉你哪个参数(values)没有满足某个约束 ➊。然后它告诉你values不是Averageable,因为它没有满足两个必需的表达式 ➋➌。你立刻知道如何修改你的参数,以便成功实例化这个模板。
当概念被纳入 C++标准时,std 库可能会包含许多概念。概念的设计目标是程序员不必自己定义太多的概念;相反,他们应该能够在模板前缀中组合概念和临时需求。表 6-2 提供了你可能期望包含的一些概念的部分列表,这些概念借用了 Andrew Sutton 在 Origins 库中实现的概念。
注意
请参见github.com/asutton/origin/了解更多关于 Origins 库的信息。要编译接下来的示例,你可以安装 Origins 并使用 GCC 6.0 或更高版本,并加上-fconcepts标志。
表 6-2:Origins 库中包含的概念
| 概念 | 一种类型,其… |
|---|---|
Conditional |
可以显式转换为 bool |
Boolean |
是 Conditional 并支持 !、&& 和 || 布尔运算 |
Equality_comparable |
支持 == 和 != 操作,返回一个 Boolean |
Destructible |
可以被销毁(比较 is_destructible) |
Default_constructible |
可以默认构造(比较 is_default_constructible) |
Movable |
支持移动语义:它必须是可移动赋值和可移动构造的(比较 is_move_assignable,is_move_constructible) |
Copyable |
支持复制语义:它必须是可复制赋值和可复制构造的(比较 is_copy_assignable,is_copy_constructible) |
Regular |
是默认可构造的,可复制的,并且是 Equality_comparable |
Ordered |
是 Regular 且完全有序(本质上,它可以被排序) |
Number |
是 Ordered 并支持诸如 +、-、/ 和 * 等数学运算 |
Function |
支持调用;也就是说,你可以调用它(比较 is_invocable) |
Predicate |
是一个 Function 并返回 bool |
Range |
可以在基于范围的 for 循环中进行迭代 |
有几种方法可以将约束构建到模板前缀中。如果模板参数仅用于声明函数参数的类型,你可以完全省略模板前缀:
return-type function-name(Concept1➊ arg-1, …) {
--snip--
}
因为你使用的是概念而不是 typename 来定义参数的类型 ➊,所以编译器知道相关的函数是一个模板。你甚至可以在参数列表中混合使用概念和具体类型。换句话说,每当你在函数定义中使用概念时,该函数就变成了一个模板。
列表 6-25 中的模板函数接受一个 Ordered 元素的数组并找到最小值。
#include <origin/core/concepts.hpp>
size_t index_of_minimum(Ordered➊* x, size_t length) {
size_t min_index{};
for(size_t i{ 1 }; i<length; i++) {
if(x[i] < x[min_index]) min_index = i;
}
return min_index;
}
列表 6-25:使用 Ordered 概念的模板函数
即使没有模板前缀,index_of_minimum 也是一个模板,因为 Ordered ➊ 是一个概念。这个模板可以像其他模板函数一样进行实例化,正如列表 6-26 中所示。
#include <cstdio>
#include <cstdint>
#include <origin/core/concepts.hpp>
struct Goblin{};
size_t index_of_minimum(Ordered* x, size_t length) {
--snip--
}
int main() {
int x1[] { -20, 0, 100, 400, -21, 5123 };
printf("%zu\n", index_of_minimum(x1, 6)); ➊
unsigned short x2[] { 42, 51, 900, 400 };
printf("%zu\n", index_of_minimum(x2, 4)); ➋
Goblin x3[] { Goblin{}, Goblin{} };
//index_of_minimum(x3, 2); ➌ // Bang! Goblin is not Ordered.
}
--------------------------------------------------------------------------
4 ➊
0 ➋
列表 6-26:一个使用列表 6-25 中 index_of_minimum 的例子。取消注释 ➌ 会导致编译失败。
int ➊ 和 unsigned short ➋ 数组的实例化成功,因为这些类型是 Ordered(见表 6-2)。
然而,Goblin 类不是 Ordered,如果你尝试编译 ➌,模板实例化会失败。重要的是,错误信息会很有帮助:
error: cannot call function 'size_t index_
of_minimum(auto:1*, size_t) [with auto:1 = Goblin; size_t = long unsigned int]'
index_of_minimum(x3, 2); // Bang! Goblin is not Ordered.
^
note: constraints not satisfied
size_t index_of_minimum(Ordered* x, size_t length) {
^~~~~~~~~~~~~~~~
note: within 'template<class T> concept bool origin::Ordered() [with T =
Goblin]'
Ordered()
你知道 index_of_minimum 的实例化失败了,问题出在 Ordered 概念上。
特定需求表达式
概念是一种相对重量级的机制,用于强制执行类型安全性。有时,您只需要在模板前缀中直接强制执行某些要求。您可以将 requires 表达式直接嵌入到模板定义中,以实现这一点。请考虑 清单 6-27 中的 get_copy 函数,它接受一个指针并安全地返回指向对象的副本。
#include <stdexcept>
template<typename T>
requires➊ is_copy_constructible<T>::value ➋
T get_copy(T* pointer) {
if (!pointer) throw std::runtime_error{ "Null-pointer dereference" };
return *pointer;
}
清单 6-27:一个具有特定要求表达式的模板函数
模板前缀包含 requires 关键字 ➊,它开始了要求表达式。在这种情况下,类型特征 is_copy_constructible 确保 T 是可拷贝的 ➋。这样,如果用户错误地尝试使用指向不可拷贝对象的指针来 get_copy,他们会看到模板实例化失败的清晰解释。请参考 清单 6-28 中的示例。
#include <stdexcept>
#include <type_traits>
template<typename T>
requires std::is_copy_constructible<T>::value
T get_copy(T* pointer) { ➊
--snip--
}
struct Highlander {
Highlander() = default; ➋
Highlander(const Highlander&) = delete; ➌
};
int main() {
Highlander connor; ➍
auto connor_ptr = &connor; ➎
auto connor_copy = get_copy(connor_ptr); ➏
}
--------------------------------------------------------------------------
In function 'int main()':
error: cannot call function 'T get_copy(T*) [with T = Highlander]'
auto connor_copy = get_copy(connor_ptr);
^
note: constraints not satisfied
T get_copy(T* pointer) {
^~~~~~~~
note: 'std::is_copy_constructible::value' evaluated to false
清单 6-28:使用 清单 6-27 中的 get_copy 模板的程序。此代码无法编译。
get_copy ➊ 的定义后跟着一个 Highlander 类的定义,该类包含一个默认构造函数 ➋ 和一个已删除的拷贝构造函数 ➌。在 main 中,您初始化了一个 Highlander ➍,获取了它的引用 ➎,并尝试用结果实例化 get_copy ➏。由于 Highlander 只能有一个(它不可拷贝),清单 6-28 会产生一个非常清晰的错误消息。
static_assert:前提的临时解决方案
从 C++17 开始,概念不再是标准的一部分,因此它们在不同编译器之间不一定可用。在此期间,您可以应用一个临时的解决方案:static_assert 表达式。这些断言在编译时进行评估。如果断言失败,编译器会发出错误,并可选地提供诊断消息。static_assert 的形式如下:
static_assert(boolean-expression, optional-message);
在没有概念的情况下,您可以在模板的主体中包含一个或多个 static_assert 表达式,以帮助用户诊断使用错误。
假设您想在不依赖概念的情况下改进 mean 的错误消息。您可以结合使用类型特征和 static_assert 来实现类似的效果,如 清单 6-29 所示。
#include <type_traits>
template <typename T>
T mean(T* values, size_t length) {
static_assert(std::is_default_constructible<T>(),
"Type must be default constructible."); ➊
static_assert(std::is_copy_constructible<T>(),
"Type must be copy constructible."); ➋
static_assert(std::is_arithmetic<T>(),
"Type must support addition and division."); ➌
static_assert(std::is_constructible<T, size_t>(),
"Type must be constructible from size_t."); ➍
--snip--
}
清单 6-29:使用 static_assert 表达式改善 清单 6-10 中 mean 的编译时错误。
您会看到常见的类型特征,用于检查 T 是否可以默认构造 ➊ 和拷贝构造 ➋,并且您提供了错误方法以帮助用户诊断模板实例化问题。您使用 is_arithmetic ➌,该方法如果类型参数支持算术操作(+,-,/ 和 *)则返回 true,以及 is_constructible ➍,它确定是否可以从 size_t 构造一个 T。
使用static_assert作为概念的代理是一种变通方法,但它被广泛使用。通过使用类型特征,你可以暂时解决问题,直到概念被纳入标准。如果你使用现代的第三方库,你会经常看到static_assert;如果你为他人(包括未来的自己)编写代码,考虑使用static_assert和类型特征。
编译器,通常程序员,也不会阅读文档。通过将要求直接嵌入代码中,你可以避免过时的文档问题。在缺乏概念的情况下,static_assert是一个很好的临时替代方案。
非类型模板参数
使用typename(或class)关键字声明的模板参数称为类型模板参数,它代表某种尚未指定的类型。另外,你可以使用非类型模板参数,它们代表某种尚未指定的值。非类型模板参数可以是以下任意类型:
-
一个整数类型
-
一个左值引用类型
-
一个指针(或指向成员的指针)类型
-
一个
std::nullptr_t(即nullptr的类型) -
一个
enum class
使用非类型模板参数允许你在编译时将一个值注入到通用代码中。例如,你可以构建一个名为get的模板函数,在编译时检查数组越界访问,通过将你想访问的索引作为非类型模板参数传入。
回想一下第三章,如果你将一个数组传递给函数,它会衰变为指针。你可以改为传递数组引用,尽管它的语法比较难以接受:
element-type(¶m-name)[array-length]
例如,示例 6-30 包含一个get函数,它首次尝试执行带边界检查的数组访问。
#include <stdexcept>
int& get(int (&arr)[10]➊, size_t index➋) {
if (index >= 10) throw std::out_of_range{ "Out of bounds" }; ➌
return arr[index]; ➍
}
示例 6-30:带有边界检查的数组元素访问函数
get函数接受一个长度为 10 的int数组引用 ➊ 和一个要提取的index ➋。如果index超出范围,它会抛出一个out_of_bounds异常 ➌;否则,它会返回对应元素的引用 ➍。
你可以在三方面改进示例 6-30,这些都通过非类型模板参数实现,使得get函数中的值变得通用。
首先,你可以通过将get函数改为模板函数来放宽arr引用int数组的要求,如示例 6-31 所示。
#include <stdexcept>
template <typename T➊>
T&➋ get(T➌ (&arr)[10], size_t index) {
if (index >= 10) throw std::out_of_range{ "Out of bounds" };
return arr[index];
}
示例 6-31:对示例 6-30 的重构,以接受一个通用类型的数组
正如你在本章中所做的,你已经通过将具体类型(此处为int)替换为模板参数来使函数通用化 ➊➋➌。
其次,你可以通过引入一个非类型模板参数Length来放宽arr引用长度为 10 的数组的要求。示例 6-32 展示了如何做:只需声明一个size_t Length模板参数,并在代码中替代 10。
#include <stdexcept>
template <typename T, size_t Length➊>
T& get (T(&arr)[Length➋], size_t index) {
if (index >= Length➌) throw std::out_of_range{ "Out of bounds" };
return arr[index];
}
示例 6-32:对示例 6-31 的重构,以接受一个长度为通用值的数组
这个思想是一样的:你不是替换一个特定的类型(int),而是替换一个特定的整数值(10)➊➋➌。现在,你可以在任何大小的数组中使用这个函数。
第三,你可以通过将size_t index作为另一个非类型模板参数来执行编译时边界检查。这允许你用static_assert替换std::out_of_range,如示例 6-33 所示。
#include <cstdio>
template <size_t Index➊, typename T, size_t Length>
T& get(T (&arr)[Length]) {
static_assert(Index < Length, "Out-of-bounds access"); ➋
return arr[Index➌];
}
int main() {
int fib[]{ 1, 1, 2, 0 }; ➍
printf("%d %d %d ", get<0>(fib), get<1>(fib), get<2>(fib)); ➎
get<3>(fib) = get<1>(fib) + get<2>(fib); ➏
printf("%d", get<3>(fib)); ➐
//printf("%d", get<4>(fib)); ➑
}
--------------------------------------------------------------------------
1 1 2 ➎3 ➐
示例 6-33:一个使用编译时边界检查数组访问的程序
你将size_t索引参数移到了一个非类型模板参数中 ➊,并用正确的名称Index ➌更新了数组访问。因为Index现在是一个编译时常量,你还将logic_error替换为static_assert,当你不小心尝试访问越界元素时,它会打印友好的信息Out-of-bounds access ➋。
示例 6-33 还展示了在main中使用get的示例。你首先声明了一个长度为 4 的int数组fib ➍。然后,你使用get ➎打印数组的前三个元素,设置第四个元素 ➏,并打印它 ➐。如果你取消注释越界访问 ➑,编译器会因为static_assert而生成错误。
变参模板
有时候,模板必须接受一个未知数量的参数。编译器在模板实例化时知道这些参数,但你希望避免为每种不同数量的参数编写许多不同的模板。这就是变参模板的存在意义。变参模板接受一个可变数量的参数。
你通过一个具有特殊语法的最终模板参数来表示变参模板,即typename... arguments。省略号表示arguments是一个参数包类型,意味着你可以在模板中声明参数包。参数包是一个接受零个或多个函数参数的模板参数。这些定义可能看起来有些抽象,因此请考虑以下基于SimpleUniquePointer的变参模板示例。
回想一下示例 6-14,你将一个原始指针传递给SimpleUniquePointer的构造函数。示例 6-34 实现了一个make_simple_unique函数,用于处理基础类型的构造。
template <typename T, typename... Arguments➊>
SimpleUniquePointer<T> make_simple_unique(Arguments... arguments➋) {
return SimpleUniquePointer<T>{ new T{ arguments...➌ } };
}
示例 6-34:实现一个make_simple_unique函数,以简化SimpleUniquePointer的使用
你定义了参数包类型Arguments ➊,这声明了make_simple_unique为一个变参模板。这个函数将参数 ➋ 传递给模板参数T的构造函数 ➌。
结果是,现在你可以非常轻松地创建SimpleUniquePointer,即使所指向的对象有一个非默认构造函数。
注意
示例 6-34 有一个略微更高效的实现。如果arguments是一个右值,你可以直接将其移动到T的构造函数中。标准库包含一个名为std::forward的函数,位于<utility>头文件中,它将检测arguments是左值还是右值,并分别执行复制或移动操作。有关更多信息,请参阅 Scott Meyers 的《Effective Modern C++》中的第 23 条。
高级模板话题
对于日常的多态编程,模板是你最常用的工具。事实证明,模板也被广泛应用于各种高级设置,特别是在实现库、高性能程序和嵌入式系统固件时。本节概述了这一广阔领域的一些主要特征。
模板特化
要理解高级模板用法,首先必须理解模板特化。模板实际上不仅可以接受concept和typename参数(类型参数)。它们还可以接受基本类型,如char(值参数),以及其他模板。由于模板参数具有极大的灵活性,你可以根据这些参数的特性做出许多编译时决定。你可以根据这些参数的不同特性拥有不同版本的模板。例如,如果类型参数是Ordered而不是Regular,你可能能够使一个通用程序更加高效。以这种方式编程被称为模板特化。有关模板特化的更多信息,请参阅 ISO 标准[temp.spec]。
名称绑定
模板实例化的另一个关键组件是名称绑定。名称绑定有助于确定编译器在模板中匹配命名元素到具体实现的规则。例如,命名元素可以是模板定义的一部分、局部名称、全局名称,或者来自某个命名空间。如果你想编写大量模板代码,你需要了解绑定是如何发生的。如果你处于这种情况,请参考 David Vandevoorde 等人的《C++ Templates: The Complete Guide》中的第九章,“模板中的名称”,以及[temp.res]。
类型函数
类型函数接受类型作为参数并返回一个类型。构建概念的类型特征与类型函数密切相关。你可以将类型函数与编译时控制结构结合使用,以便在编译时进行一般计算,例如编程控制流。通常,使用这些技术进行编程被称为模板元编程。
模板元编程
模板元编程以生成极为巧妙且对除最强大的程序员外几乎无人能懂的代码而闻名。幸运的是,一旦概念成为 C++标准的一部分,模板元编程应该会变得更容易为我们这些普通人所理解。在那之前,请小心谨慎。对于那些希望深入了解这一主题的人,可以参考Modern C++ Design: Generic Programming and Design Patterns Applied(安德烈·亚历山大斯库著)和C++ Templates: The Complete Guide(大卫·范德沃尔德等著)。
模板源代码组织
每次实例化模板时,编译器必须能够生成使用该模板所需的所有代码。这意味着关于如何实例化自定义类或函数的所有信息必须在与模板实例化相同的翻译单元内可用。到目前为止,最流行的实现方法是在头文件中完全实现模板。
这种方法有一些小的 inconveniences。编译时间可能会增加,因为具有相同参数的模板可能会被多次实例化。它还减少了隐藏实现细节的能力。幸运的是,泛型编程的好处远远超过这些不便。(主要的编译器可能会尽量减少编译时间和代码重复的问题。)
头文件模板也有一些优势:
-
让其他人使用你的代码非常容易:只需要对一些头文件应用
#include(而不是编译库,确保结果对象文件对链接器可见,等等)。 -
对于编译器来说,将仅包含头文件的模板内联是非常容易的,这可以在运行时提高代码的执行速度。
-
编译器通常可以在所有源代码都可用时做得更好,从而优化代码。
运行时多态与编译时多态
当你需要多态时,应该使用模板。但有时你不能使用模板,因为你直到运行时才知道与你的代码一起使用的类型。记住,模板实例化仅在你将模板的参数与类型配对时才会发生。此时,编译器可以为你实例化一个自定义类。在某些情况下,你可能无法在编译时执行这种配对,或者至少,在编译时执行配对会非常繁琐,直到程序执行时才能进行。
在这种情况下,你可以使用运行时多态。而模板是实现编译时多态的机制,运行时机制是接口。
总结
在本章中,你探讨了 C++中的多态。本章开始时讨论了多态是什么,以及它为何如此有用。你探索了如何通过模板在编译时实现多态。你了解了使用概念进行类型检查,然后探讨了一些高级主题,如变参模板和模板元编程。
练习
6-1. 一系列值的众数是最常出现的值。使用以下签名实现一个众数函数:int mode(const int* values, size_t length)。如果遇到错误情况,如输入具有多个众数或没有值,则返回零。
6-2. 将 mode 实现为模板函数。
6-3. 修改 mode 函数以接受 Integer 类型概念。验证 mode 无法使用浮动类型(如 double)实例化。
6-4. 重构 列表 6-13 中的 mean 函数,使其接受数组,而不是指针和长度参数。使用 列表 6-33 作为参考。
6-5. 使用 第五章的示例,将 Bank 改为接受模板参数的模板类。使用该类型参数作为账户类型,而不是 long。验证使用 Bank<long> 类时代码是否仍然有效。
6-6. 实现一个 Account 类并实例化一个 Bank<Account>。在 Account 类中实现函数以跟踪余额。
6-7. 将 Account 变为接口。实现 CheckingAccount 和 SavingsAccount。创建一个程序,包含多个支票账户和储蓄账户。使用 Bank<Account> 实现账户间的多个交易。
进一步阅读
-
C++ 模板:完全指南(第 2 版),由 David Vandevoorde、Nicolai M. Josuttis 和 Douglas Gregor 编写(Addison-Wesley,2017 年)
-
有效的现代 C++:42 种方法改进你对 C++11 和 C++14 的使用 由 Scott Meyers 编写(O'Reilly Media,2015 年)
-
C++ 编程语言(第 4 版),由 Bjarne Stroustrup 编写(Pearson Education,2013 年)
-
现代 C++ 设计:通用编程与设计模式应用 由 Andrei Alexandrescu 编写(Addison-Wesley,2001 年)
第九章:表达式**
这就是人类创造性天才的本质:不是文明的建筑物,也不是可以终结一切的闪光武器,而是像精子攻击卵子的过程一样,滋养新概念的语言。
—Dan Simmons,* 《超越人类》

表达式是产生结果和副作用的计算。通常,表达式包含操作数和对其进行操作的运算符。许多运算符被内置在核心语言中,你将在本章中看到大多数运算符。本章开始讨论内置运算符,然后介绍重载运算符new、用户定义字面量,并进一步探讨类型转换。当你创建自己的用户定义类型时,通常需要描述这些类型如何转换为其他类型。在学习constexpr常量表达式和广泛误解的volatile关键字之前,你将探讨这些用户定义的转换。
运算符
运算符,如加法(+)和取地址(&)运算符,会对称为操作数的参数进行操作,这些操作数可以是数值或对象。在本节中,我们将介绍逻辑运算符、算术运算符、赋值运算符、增量/减量运算符、比较运算符、成员访问运算符、三元条件运算符和逗号运算符。
逻辑运算符
C++表达式套件包括完整的逻辑运算符。在这一类别中,有(常规)运算符与(&&)、或(||)和非(!),它们接受可转换为bool的操作数并返回bool类型的对象。此外,按位逻辑运算符适用于像bool、int和unsigned long这样的整数类型。这些运算符包括与(&)、或(|)、异或(^)、取反(~)、左移(<<)和右移(>>)。每个运算符在位级别执行布尔操作,并返回与其操作数匹配的整数类型。
表 7-1 列出了所有这些逻辑运算符,并附有一些示例。
表 7-1: 逻辑运算符
| 运算符 | 名称 | 示例表达式 | 结果 |
|---|---|---|---|
x & y |
按位与 | 0b1100 & 0b1010 |
0b1000 |
x | y |
按位或 | 0b1100 | 0b1010 |
0b1110 |
x ^ y |
按位异或 | 0b1100 ^ 0b1010 |
0b0110 |
~x |
按位取反 | ~0b1010 |
0b0101 |
x << y |
按位左移 | 0b1010 << 2``0b0011 << 4 |
0b101000``0b110000 |
x >> y |
按位右移 | 0b1010 >> 2``0b10110011 >> 4 |
0b10``0b1011 |
x && y |
与 | true && false``true && true |
false``true |
x || y |
或 | true || false``false || false |
true``false |
!x |
非 | !true``!false |
false``true |
算术运算符
额外的 Unary 和 Binary 算术运算符 适用于整数和浮动点类型(也称为 算术类型)。在你需要执行数学计算的地方,你会使用内建的算术运算符。它们执行一些最基本的工作元素,无论是递增索引变量还是执行计算密集型的统计模拟。
一元算术运算符
一元加 + 和 一元减 - 运算符接受一个算术操作数。两个运算符都将其操作数提升为 int。因此,如果操作数的类型是 bool、char 或 short int,则表达式的结果为 int。
一元加除了提升之外没有太多作用;而一元减则会翻转操作数的符号。例如,给定 char x = 10,+x 会得到一个值为 10 的 int,而 -x 会得到一个值为-10 的 int。
二元算术运算符
除了两个一元算术运算符外,还有五个二元算术运算符:加法 +、减法 -、乘法 *、除法 / 和 取模 %。这些运算符接受两个操作数并执行相应的数学运算。和它们的一元运算符一样,这些二元运算符会对它们的操作数进行整数提升。例如,两个 char 操作数相加将得到一个 int。同样也有浮动点提升规则:
-
如果一个操作数是
long double,则另一个操作数将提升为long double。 -
如果一个操作数是
double,则另一个操作数将提升为double。 -
如果一个操作数是
float,则另一个操作数将提升为float。
如果没有浮动点提升规则适用,则接下来检查任一操作数是否为带符号。如果是,则两个操作数都变为带符号。最后,和浮动点类型的提升规则一样,较大的操作数的大小将用于提升另一个操作数:
-
如果一个操作数是
long long,则另一个操作数将提升为long long。 -
如果一个操作数是
long,则另一个操作数将提升为long。 -
如果一个操作数是
int,则另一个操作数将提升为int。
尽管这些规则并不太复杂以至于难以记忆,但我建议通过依赖 auto 类型推导来检查你的工作。只需将表达式的结果赋值给一个 auto 声明的变量,并检查推导出的类型。
不要混淆类型转换和提升。类型转换是当你有一个某种类型的对象,并需要将其转换为另一种类型时。而提升则是一组规则,用于解释字面值。例如,如果你有一个 2 字节的 short 类型的平台,并对一个值为 40000 的 unsigned short 进行了有符号转换,结果会是整数溢出和未定义行为。这与对字面量 40000 进行提升规则处理完全不同。如果它需要带符号,则字面量的类型是带符号的 int,因为带符号的 short 不能容纳这么大的值。
注意
你可以使用你的 IDE,甚至是 RTTI 的*typeid*来打印类型到控制台。
表格 7-2 总结了算术运算符。
表格 7-2: 算术运算符
| 运算符 | 名称 | 示例 | 结果 |
|---|---|---|---|
+x |
一元加法 | +10 |
10 |
-x |
一元减法 | -10 |
-10 |
x + y |
二进制加法 | 1 + 2 |
3 |
x - y |
二进制减法 | 1 - 2 |
-1 |
x * y |
二进制乘法 | 10 * 20 |
200 |
x / y |
二进制除法 | 300 / 15 |
20 |
x % y |
二进制模运算 | 42 % 5 |
2 |
在表格 7-1 和表格 7-2 中,许多二进制运算符也有相应的赋值运算符。
赋值运算符
赋值运算符执行指定的操作,然后将结果赋值给第一个操作数。例如,加法赋值 x += y 计算值 x + y 并将 x 赋值为结果。你也可以使用表达式 x = x + y 达到类似的效果,但赋值运算符语法更简洁,且至少在运行时效率更高。表格 7-3 总结了所有可用的赋值运算符。
表格 7-3: 赋值运算符
| 运算符 | 名称 | 示例 | 结果(x 的值) |
|---|---|---|---|
x = y |
简单赋值 | x = 10 |
10 |
x += y |
加法赋值 | x += 10 |
15 |
x -= y |
减法赋值 | x -= 10 |
-5 |
x *= y |
乘法赋值 | x *= 10 |
50 |
x /= y |
除法赋值 | x /= 2 |
2 |
x %= y |
模运算赋值 | x %= 2 |
1 |
x &= y |
位运算与赋值 | x &= 0b1100 |
0b0100 |
x |= y |
位运算或赋值 | x |= 0b1100 |
0b1101 |
x ^= y |
位运算异或赋值 | x ^= 0b1100 |
0b1001 |
x <<= y |
位运算左移赋值 | x <<= 2 |
0b10100 |
x >>= y |
位运算右移赋值 | x >>= 2 |
0b0001 |
注意
在使用赋值运算符时,提升规则并不适用;被赋值操作数的类型不会改变。例如,给定 int x = 5,在 x /= 2.0f 之后,x 的类型仍然是 int。
增量和减量运算符
在表格 7-4 中列出了四个(一元)增量/减量运算符。
表格 7-4: 增量和减量运算符(给定 x=5 的值)
| 运算符 | 名称 | 计算后 x 的值 | 表达式的结果 |
|---|---|---|---|
++x |
前缀增量 | 6 |
6 |
x++ |
后缀增量 | 6 |
5 |
--x |
前缀减量 | 4 |
4 |
x-- |
后缀减量 | 4 |
5 |
如 表 7-4 所示,自增运算符将操作数的值增加 1,而自减运算符则减少 1。运算符返回的值取决于它是前缀还是后缀运算符。前缀运算符将在修改后返回操作数的值,而后缀运算符则会在修改前返回操作数的值。
比较运算符
六个比较运算符比较给定的操作数并计算为 bool,如 表 7-5 所述。对于算术操作数,与算术运算符相同的类型转换(提升)会发生。比较运算符也适用于指针,并且它们的工作方式大致符合你的预期。
注意
指针比较有一些细微差别。有兴趣的读者可以参阅 [expr.rel].
表 7-5: 比较运算符
| 运算符 | 名称 | 示例(均评估为真) |
|---|---|---|
x == y |
等于运算符 | 100 == 100 |
x != y |
不等于运算符 | 100 != 101 |
x < y |
小于运算符 | 10 < 20 |
x > y |
大于运算符 | -10 > -20 |
x <= y |
小于或等于运算符 | 10 <= 10 |
x >= y |
大于或等于运算符 | 20 >= 10 |
成员访问运算符
你使用 成员访问运算符 与指针、数组及你将在 第二部分 中遇到的许多类进行交互。六个这样的运算符包括 下标 []、间接 *、取地址 &、对象成员 . 和 指针成员 ->。你在 第三章 中已经遇到过这些运算符,但本节提供了简要总结。
注意
也有 指向对象成员的指针。和 指向指针成员的指针 ->* 运算符,但这些并不常见。请参阅 [expr.mptr.oper]。
下标运算符 x[y] 提供对由 x 指向的数组的第 y 个元素的访问,而间接运算符 *x 提供对 x 指向的元素的访问。你可以使用取地址运算符 &x 创建一个指向元素 x 的指针。这本质上是间接运算符的反操作。对于有成员 y 的元素 x,你使用对象成员运算符 x.y。你也可以访问指向对象的成员;给定一个指针 x,你使用指针成员运算符 x->y 访问 x 指向的对象。
三元条件运算符
三元条件运算符 x ? y : z 是一种语法糖,接受三个操作数(因此是“三元”)。它将第一个操作数 x 作为布尔表达式进行求值,并根据布尔值是否为 true 或 false(分别)返回第二个操作数 y 或第三个操作数 z。考虑以下返回 1 的步进函数,当参数 input 为正时;否则返回零:
int step(int input) {
return input > 0 ? 1 : 0;
}
使用等效的 if-then 语句,你也可以以下列方式实现 step:
int step(int input) {
if (input > 0) {
return 1;
} else {
return 0;
}
}
这两种方法在运行时是等效的,但三元条件运算符需要的输入较少,通常会产生更简洁的代码。请大胆使用它。
注意
条件三元运算符有一个更时髦的名字: Elvis 运算符。如果你将书顺时针旋转 90 度并眯起眼睛,你会明白为什么:?:
逗号运算符
逗号运算符,另一方面,通常不会促进更简洁的代码。它允许在一个更大的表达式内,多个由逗号分隔的表达式进行求值。这些表达式从左到右求值,最右边的表达式是返回值,正如清单 7-1 所示。
#include <cstdio>
int confusing(int &x) {
return x = 9, x++, x / 2;
}
int main() {
int x{}; ➊
auto y = confusing(x); ➋
printf("x: %d\ny: %d", x, y);
}
--------------------------------------------------------------------------
x: 10
y: 5
清单 7-1:一个使用逗号运算符的令人困惑的函数
在调用confusing后,x等于10 ➊,y等于5 ➋。
注意
这是 C 语言在大学时代更加狂野、没有拘束的结构遗留物,逗号运算符允许一种特定类型的面向表达式的编程。避免使用逗号运算符;它的使用非常罕见,且容易引起混乱。
运算符重载
对于每种基本类型,本节中涵盖的某些运算符将可用。对于用户定义的类型,你可以通过使用运算符重载来指定这些运算符的自定义行为。要为用户定义的类指定运算符的行为,只需将方法命名为operator后面紧跟运算符;确保返回类型和参数与要处理的操作数类型匹配。
清单 7-2 定义了一个CheckedInteger。
#include <stdexcept>
struct CheckedInteger {
CheckedInteger(unsigned int value) : value{ value } ➊ { }
CheckedInteger operator+(unsigned int other) const { ➋
CheckedInteger result{ value + other }; ➌
if (result.value < value) throw std::runtime_error{ "Overflow!" }; ➍
return result;
}
const unsigned int value; ➎
};
清单 7-2:一个CheckedInteger类,它在运行时检测溢出
在这个类中,你定义了一个构造函数,它接受一个unsigned int。这个参数被用来➊初始化公共字段value ➎。因为value是const,CheckedInteger是不可变的——构造后,不能修改CheckedInteger的状态。这里感兴趣的方法是operator+ ➋,它允许你将一个普通的unsigned int加到CheckedInteger上,从而生成一个具有正确value的新CheckedInteger。operator+的返回值在➌构造。每当加法导致unsigned int溢出时,结果将小于原始值。你在➍检查这个条件。如果检测到溢出,就会抛出异常。
第六章描述了type_traits,它允许你在编译时确定类型的特征。一个相关的类型支持家族可以在<limits>头文件中找到,它允许你查询算术类型的各种属性。
在 <limits> 中,模板类 numeric_limits 提供了多个成员常量,用于获取模板参数的相关信息。其中一个例子是 max() 方法,它返回给定类型的最大有限值。你可以使用这个方法来测试 CheckedInteger 类。列表 7-3 展示了 CheckedInteger 的行为。
#include <limits>
#include <cstdio>
#include <stdexcept>
struct CheckedInteger {
--snip--
};
int main() {
CheckedInteger a{ 100 }; ➊
auto b = a + 200; ➋
printf("a + 200 = %u\n", b.value);
try {
auto c = a + std::numeric_limits<unsigned int>::max(); ➌
} catch(const std::overflow_error& e) {
printf("(a + max) Exception: %s\n", e.what());
}
}
--------------------------------------------------------------------------
a + 200 = 300
(a + max) Exception: Overflow!
列表 7-3:一个演示 CheckedInteger 使用的程序
在构造 CheckedInteger ➊ 后,你可以将其与 unsigned int ➋ 相加。由于结果值 300 可以确保适合 unsigned int,此语句会在不抛出异常的情况下执行。接下来,你将相同的 CheckedInteger a 加到 unsigned int 的最大值上(通过 numeric_limits) ➌。这会导致溢出,operator+ 的重载会检测到这一点,并抛出 overflow_error 异常。
重载 Operator new
请回顾 第四章,你可以使用 new 运算符来分配具有动态存储持续时间的对象。默认情况下,new 运算符会在自由存储上分配内存,以为你的动态对象腾出空间。自由存储,也叫 堆,是一个由实现定义的存储位置。在桌面操作系统中,内核通常管理自由存储(例如,Windows 上的 HeapAlloc 和 Linux/macOS 上的 malloc),且通常非常庞大。
自由存储可用性
在某些环境中,比如 Windows 内核或嵌入式系统,默认情况下没有自由存储可用。在其他场景中,例如游戏开发或高频交易,自由存储分配的延迟太大,因为你将其管理交给了操作系统。
你可以尝试完全避免使用自由存储,但这样会带来严重限制。其中一个主要的限制是无法使用标准库容器,阅读 第二部分 后,你会同意这是一个巨大的损失。与其接受这些严格限制,不如重载自由存储操作,掌控内存分配。你可以通过重载 new 运算符来实现这一点。
头文件
在支持自由存储操作的环境中,<new> 头文件包含以下四个运算符:
-
void* operator new(size_t); -
void operator delete(void*); -
void* operator new[](size_t); -
void operator delete[](void*);
请注意,new 运算符的返回类型是 void*。自由存储运算符处理的是原始、未初始化的内存。
你可以提供这四个运算符的自定义版本。你只需要在程序中定义一次,编译器将使用你的版本而不是默认版本。
自由存储管理是一个令人惊讶的复杂任务。一个主要问题是 内存碎片化。随着时间的推移,大量的内存分配和释放可能会导致自由存储区域中散布着许多空闲的内存块。这可能导致某些情况,虽然有大量的空闲内存,但它们分散在已经分配的内存区域中。当这种情况发生时,大的内存请求会失败,尽管从技术上讲,空闲内存足够提供给请求者。图 7-1 展示了这种情况。所需的内存分配有足够的内存,但可用内存是非连续的。

图 7-1:内存碎片化问题
桶
一种方法是将分配的内存划分为所谓的 桶,每个桶的大小是固定的。当你请求内存时,环境会分配整个桶,即使你没有请求全部的内存。例如,Windows 提供了两个函数来分配动态内存:VirtualAllocEx 和 HeapAlloc。
VirtualAllocEx 函数是低级的,它允许你提供许多选项,例如将内存分配到哪个进程、首选的内存地址、请求的大小以及权限,比如内存是否应该是可读、可写和可执行的。该函数永远不会分配少于 4096 字节(一个所谓的 页)。
另一方面,HeapAlloc 是一个较高级的函数,当可以时,它分配少于一页的内存;否则,它会代表你调用 VirtualAllocEx。至少在 Visual Studio 编译器中,new 默认会调用 HeapAlloc。
这种安排通过对内存分配进行桶大小的四舍五入来避免内存碎片化,但需要一些附加开销。像 Windows 这样的现代操作系统将有相当复杂的内存分配方案,支持不同大小的内存分配。除非你想要控制,否则你不会看到这些复杂性。
控制自由存储
清单 7-4 演示了如何实现非常简单的 Bucket 和 Heap 类。这些类将帮助控制动态内存分配:
#include <cstddef>
#include <new>
struct Bucket { ➊
const static size_t data_size{ 4096 };
std::byte data[data_size];
};
struct Heap {
void* allocate(size_t bytes) { ➋
if (bytes > Bucket::data_size) throw std::bad_alloc{};
for (size_t i{}; i < n_heap_buckets; i++) {
if (!bucket_used[i]) {
bucket_used[i] = true;
return buckets[i].data;
}
}
throw std::bad_alloc{};
}
void free(void* p) { ➌
for (size_t i{}; i < n_heap_buckets; i++) {
if (buckets[i].data == p) {
bucket_used[i] = false;
return;
}
}
}
static const size_t n_heap_buckets{ 10 };
Bucket buckets[n_heap_buckets]{}; ➍
bool bucket_used[n_heap_buckets]{}; ➎
};
清单 7-4:Heap 和 Bucket 类
Bucket 类 ➊ 负责占用内存空间。作为对 Windows 堆管理器的致敬,桶的大小被硬编码为 4096。所有的管理逻辑都集中在 Heap 类中。
Heap 中有两个重要的成员:buckets ➍ 和 bucket_used ➎。buckets 成员存放所有的 Buckets,这些 Buckets 被紧凑地打包成一个连续的字符串。bucket_used 成员是一个相对较小的数组,包含 bool 类型的对象,用来跟踪 buckets 中同一索引的 Bucket 是否已经被借出。两个成员都初始化为零。
Heap 类有两个方法:allocate ➋ 和 free ➌。allocate 方法首先检查请求的字节数是否大于桶的大小。如果是,它会抛出一个 std::bad_alloc 异常。一旦大小检查通过,Heap 会遍历 buckets,寻找一个在 bucket_used 中没有标记为 true 的桶。如果找到了,它会返回与该 Bucket 关联的 data 成员指针。如果找不到未使用的 Bucket,它会抛出一个 std::bad_alloc 异常。free 方法接受一个 void* 并遍历所有的 buckets,寻找匹配的 data 成员指针。如果找到了,它会将对应桶的 bucket_used 设置为 false 并返回。
使用我们的堆
分配一个 Heap 的一种方式是将其声明为命名空间作用域内的对象,这样它就具有静态存储持续时间。因为它的生命周期从程序启动时就开始,所以你可以在 operator new 和 operator delete 重载中使用它,正如 示例 7-5 中所示。
Heap heap; ➊
void* operator new(size_t n_bytes) {
return heap.allocate(n_bytes); ➋
}
void operator delete(void* p) {
return heap.free(p); ➌
}
示例 7-5:重载 new 和 delete 运算符以使用 示例 7-4 中的 Heap 类
示例 7-5 声明了一个 Heap ➊ 并在 new 运算符重载 ➋ 和 delete 运算符重载 ➌ 中使用它。现在,如果你使用 new 和 delete,动态内存管理将使用 heap,而不是环境提供的默认自由存储。示例 7-6 测试了重载的动态内存管理。
#include <cstdio>
--snip--
int main() {
printf("Buckets: %p\n", heap.buckets); ➊
auto breakfast = new unsigned int{ 0xC0FFEE };
auto dinner = new unsigned int { 0xDEADBEEF };
printf("Breakfast: %p 0x%x\n", breakfast, *breakfast); ➋
printf("Dinner: %p 0x%x\n", dinner, *dinner); ➌
delete breakfast;
delete dinner;
try {
while (true) {
new char;
printf("Allocated a char.\n"); ➍
}
} catch (const std::bad_alloc&) {
printf("std::bad_alloc caught.\n"); ➎
}
}
--------------------------------------------------------------------------
Buckets: 00007FF792EE3320 ➊
Breakfast: 00007FF792EE3320 0xc0ffee ➋
Dinner: 00007FF792EE4320 0xdeadbeef ➌
Allocated a char. ➍
Allocated a char.
Allocated a char.
Allocated a char.
Allocated a char.
Allocated a char.
Allocated a char.
Allocated a char.
Allocated a char.
Allocated a char.
std::bad_alloc caught. ➎
示例 7-6:演示使用 Heap 管理动态分配的程序
你已经打印出了 heap 中第一个 buckets 元素的内存地址 ➊。这是借给第一个 new 调用的内存位置。通过打印 breakfast ➋ 的内存地址和指向的值,你验证了这一点。注意,内存地址与 heap 中第一个 Bucket 的内存地址相同。你对 dinner ➌ 指向的内存做了同样的操作。注意,内存地址比 breakfast 的内存地址大正好 0x1000。这与 Bucket 的 4096 字节大小完全一致,正如 const static 成员 Bucket::data_size 中定义的那样。
在打印了 ➋➌ 后,你删除了 breakfast 和 dinner。然后,你不加节制地分配 char 对象,直到 heap 内存用尽并抛出 std::bad_alloc 异常为止。每次分配时,你都会打印出 Allocated 一个 char,从 ➍ 开始。你会看到,在出现 std::bad_alloc 异常 ➎ 之前,总共有 10 行。注意,这正好是你在 Heap::n_heap_buckets 中设置的 buckets 数量。这意味着,每分配一个 char,你就占用了 4096 字节的内存!
定位运算符
有时,你不想覆盖所有的自由存储分配。在这种情况下,你可以使用定位运算符,它们对预分配的内存执行适当的初始化:
-
void* operator new(size_t, void*); -
void operator delete(size_t, void*); -
void* operator new[](void*, void*); -
void operator delete[](void*, void*);
使用放置运算符,你可以手动在任意内存中构造对象。这具有一个优势,即可以手动操作对象的生命周期。然而,你不能使用delete来释放由此生成的动态对象。你必须直接调用对象的析构函数(并且只能调用一次!),如示例 7-7 所示。
#include <cstdio>
#include <cstddef>
#include <new>
struct Point {
Point() : x{}, y{}, z{} {
printf("Point at %p constructed.\n", this); ➊
}
~Point() {
printf("Point at %p destructed.\n", this); ➋
}
double x, y, z;
};
int main() {
const auto point_size = sizeof(Point);
std::byte data[3 * point_size];
printf("Data starts at %p.\n", data); ➌
auto point1 = new(&data[0 * point_size]) Point{}; ➍
auto point2 = new(&data[1 * point_size]) Point{}; ➎
auto point3 = new(&data[2 * point_size]) Point{}; ➏
point1->~Point(); ➐
point2->~Point(); ➑
point3->~Point(); ➒
}
--------------------------------------------------------------------------
Data starts at 0000004D290FF8E8\. ➌
Point at 0000004D290FF8E8 constructed. ➍
Point at 0000004D290FF900 constructed. ➎
Point at 0000004D290FF918 constructed. ➏
Point at 0000004D290FF8E8 destructed. ➐
Point at 0000004D290FF900 destructed. ➑
Point at 0000004D290FF918 destructed. ➒
示例 7-7:使用放置new初始化动态对象
构造函数 ➊ 打印一条消息,指示在特定地址构造了一个Point,析构函数 ➋ 打印一条消息,指示该Point正在被销毁。你已经打印了data的地址,这是放置new初始化Point的第一个地址 ➌。
请注意,每个new运算符都在你的data数组所占用的内存中分配了Point ➍➎➏。你必须单独调用每个析构函数 ➐➑➒。
运算符优先级和结合性
当表达式中出现多个运算符时,运算符优先级和运算符结合性决定了如何解析表达式。优先级高的运算符会比优先级低的运算符与其操作数绑定得更紧密。如果两个运算符具有相同的优先级,它们的结合性将决定如何绑定操作数。结合性可以是从左到右或从右到左。
表 7-6 包含了所有 C++运算符,按其优先级排序并附有结合性注释。每一行包含一个或多个具有相同优先级的运算符,并附有描述和其结合性。越高的行优先级越高。
表 7-6: 运算符优先级和结合性
| 运算符 | 描述 | 结合性 |
|---|---|---|
a::b |
范围解析 | 从左到右 |
a++ |
后缀递增 | 从左到右 |
a-- |
后缀递减 | |
fn() |
函数调用 | |
a[b] |
下标 | |
a->b |
指针的成员 | |
a.b |
对象的成员 | |
Type(a) |
函数式类型转换 | |
Type{ a } |
函数式类型转换 | |
++a |
前缀递增 | 从右到左 |
--a |
前缀递减 | |
+a |
一元加 | |
-a |
一元减 | |
!a |
逻辑非 | |
~a |
按位取反 | |
(Type)a |
C 风格转换 | |
*a |
解引用 | |
&a |
地址 | |
sizeof(Type) |
类型大小 | |
new Type |
动态分配 | |
new Type[] |
动态分配(数组) | |
delete a |
动态释放 | |
delete[] a |
动态释放(数组) | |
.*``->* |
指向成员的指针 指向对象的指针 | 从左到右 |
a * b``a / b``a % b |
乘法 除法 取余 | 从左到右 |
a + b``a - b |
加法 减法 | 从左到右 |
a << b``a >> b |
按位左移 按位右移 | 从左到右 |
a < b |
小于 | 从左到右 |
a > b |
大于 | |
a <= b |
小于或等于 | |
a >= b |
大于或等于 | |
a == b``a != b |
等于不等于 | 从左到右 |
a & b |
位运算与 | 从左到右 |
a ^ b |
位运算异或 | 从左到右 |
a | b |
位运算或 | 从左到右 |
a && b |
逻辑与 | 从左到右 |
a || b |
逻辑或 | 从左到右 |
a ? b : c |
三元运算 | 从右到左 |
throw a |
抛出 | |
a = b |
赋值 | |
a += b |
和赋值 | |
a -= b |
差赋值 | |
a *= b |
积赋值 | |
a /= b |
商赋值 | |
a %= b |
余数赋值 | |
a <<= b |
位运算左移赋值 | |
a >>= b |
位运算右移赋值 | |
a &= b |
位运算与赋值 | |
a ^= b |
位运算异或赋值 | |
a |= b |
位运算或赋值 | |
a, b |
逗号 | 从左到右 |
注意
你还没有遇到作用域解析运算符(它首次出现在第八章),但表 7-6 包括了它以保持完整性。
因为 C++有很多运算符,所以运算符的优先级和结合性规则可能很难追踪。为了读者的心理健康,尽量使表达式尽可能清晰。
请考虑以下表达式:
*a++ + b * c
由于后缀加法运算的优先级高于解引用运算符*,它首先绑定到参数a,这意味着a++的结果是解引用运算符的参数。乘法*的优先级高于加法+,所以乘法运算符*绑定到b和c,加法运算符+绑定到*a++和b * c的结果。
你可以通过添加括号来强制表达式中的优先级,因为括号的优先级高于任何运算符。例如,你可以使用括号重写前面的表达式:
(*(a++)) + (b * c)
通常来说,在哪里可能让读者对运算符的优先级产生困惑,就在哪儿加上括号。如果结果看起来有点复杂(像这个例子一样),那么你的表达式可能太复杂了;你可以考虑将其拆分成多个语句。
计算顺序
计算顺序决定了表达式中运算符的执行顺序。一个常见的误解是,优先级和计算顺序是等同的:它们不是。优先级是一个编译时概念,决定了运算符如何与操作数绑定。计算顺序是一个运行时概念,决定了运算符执行的调度顺序。
通常,C++对操作数的执行顺序没有明确规定。 虽然运算符按照前述部分中清晰定义的方式与操作数绑定,但这些操作数的计算顺序是不确定的。编译器可以以任何它喜欢的方式来安排操作数的计算顺序。
你可能会误以为下面表达式中的括号决定了stop、drop和roll函数的执行顺序,或者某种从左到右的结合性有运行时效果:
(stop() + drop()) + roll()
它们不会。roll函数可能会在stop和drop的执行之前、之后或之间执行。如果你需要操作按特定顺序执行,只需将它们放入按所需顺序排列的单独语句中,如下所示:
auto result = stop();
result = result + drop();
result = result + roll();
如果不小心,甚至可能会导致未定义行为。考虑以下表达式:
b = ++a + a;
因为表达式++a和a的执行顺序没有指定,并且++a + a的值取决于哪个表达式先计算,所以b的值无法很好地定义。
在一些特殊情况下,执行顺序是由语言指定的。最常遇到的情况如下:
-
内置逻辑与运算符
a && b和内置逻辑或运算符a || b保证a在b之前执行。 -
三元运算符
a ? b : c保证a在b和c之前执行。 -
逗号运算符
a, b保证a在b之前执行。 -
在
new表达式中,构造函数的参数会在调用分配器函数之前执行。
你可能会想知道,为什么 C++不强制执行顺序,比如从左到右,以避免混淆。答案很简单:通过不任意限制执行顺序,语言允许编译器开发者发现巧妙的优化机会。
注意
有关执行顺序的更多信息,请参见[expr]。
用户定义字面量
第二章讲解了如何声明字面量,这是你在程序中直接使用的常量值。它们帮助编译器将嵌入的值转换为所需的类型。每种基本类型都有自己的字面量语法。例如,char字面量用单引号声明,如'J',而wchar_t则用L前缀声明,如L'J'。你可以使用F或L后缀来指定浮点数的精度。
为了方便,你还可以创建自己的用户定义字面量。与内置字面量一样,用户定义字面量为编译器提供了类型信息的语法支持。虽然你几乎不需要声明用户定义字面量,但值得一提的是,你可能会在库中看到它们。标准库<chrono>头文件广泛使用字面量,提供给程序员一种简洁的时间类型语法——例如,700ms表示 700 毫秒。由于用户定义字面量相对较少,我不会在这里深入讨论它们。
注意
进一步参考,请参阅 Bjarne Stroustrup 的《C++编程语言》第 4 版第 19.2.6 节。
类型转换
当你拥有一种类型但希望将其转换为另一种类型时,你会执行类型转换。根据情况,类型转换可以是显式的或隐式的。本节将讨论这两种类型的转换,并涵盖提升、浮点到整数的转换、整数到整数的转换以及浮点到浮点的转换。
类型转换是相当常见的。例如,给定一个计数和总和,你可能需要计算一些整数的平均值。由于计数和总和是存储在整型变量中的(而且你不想截断小数部分),你会希望将平均值计算为浮点数。为此,你需要使用类型转换。
隐式类型转换
隐式类型转换可以发生在任何需要特定类型但你提供了不同类型的地方。这些转换发生在几种不同的上下文中。
“二进制算术运算符”在第 183 页中概述了所谓的提升规则。实际上,这些规则是一种隐式转换。每当发生算术操作时,较短的整型会被提升为int类型。在算术操作中,整型也可以被提升为浮点型。所有这些操作都发生在后台。结果是,在大多数情况下,类型系统会自动让路,让你专注于编程逻辑。
不幸的是,在某些情况下,C++在默默地进行类型转换时有些过于激进。考虑从double转换为uint_8的隐式转换:
#include <cstdint>
int main() {
auto x = 2.7182818284590452353602874713527L;
uint8_t y = x; // Silent truncation
}
你应该希望编译器在这里生成警告,但从技术上讲,这在 C++中是有效的。因为这种转换会丢失信息,所以它是一个窄化转换,使用大括号初始化{}可以防止这种转换:
#include <cstdint>
int main() {
auto x = 2.7182818284590452353602874713527L;
uint8_t y{ x }; // Bang!
}
回想一下,大括号初始化不允许窄化转换。从技术上讲,大括号初始化器是一种显式转换,因此我将在第 201 页的《显式类型转换》中讨论这一点。
浮点到整数的转换
浮点类型和整型可以在算术表达式中和平共存。原因是隐式类型转换:当编译器遇到混合类型时,它会执行必要的提升,以确保算术操作按预期进行。
整数到整数的转换
整数可以转换为其他整数类型。如果目标类型是signed,那么只要值可以表示,一切正常。如果不能表示,行为由实现定义。如果目标类型是unsigned,结果是能容纳在该类型中的位数。换句话说,高位会丢失。
考虑示例 7-8,它演示了如何因符号转换导致未定义行为。
#include <cstdint>
#include <cstdio>
int main() {
// 0b111111111 = 511
uint8_t x = 0b111111111; ➊// 255
int8_t y = 0b111111111; ➋// Implementation defined.
printf("x: %u\ny: %d", x, y);
}
--------------------------------------------------------------------------
x: 255 ➊
y: -1 ➋
示例 7-8:由符号转换引起的未定义行为
列表 7-8 会隐式地将一个过大无法放入 8 位整数中的整数(511,或者 9 个 1)转换为 x 和 y,其中 x 和 y 分别是 unsigned 和 signed。x 的值保证为 255 ➊,而 y 的值则依赖于实现。在 Windows 10 x64 机器上,y 等于 -1 ➋。对 x 和 y 的赋值都涉及到可能会被花括号初始化语法避免的窄化转换。
浮动点到浮动点的转换
浮动点数可以隐式地转换为其他浮动点数,反之亦然。只要目标值能够容纳源值,一切正常。当无法容纳时,会发生未定义行为。同样,花括号初始化可以防止潜在的危险转换。考虑列表 7-9 中的例子,它展示了因窄化转换导致的未定义行为。
#include <limits>
#include <cstdio>
int main() {
double x = std::numeric_limits<float>::max(); ➊
long double y = std::numeric_limits<double>::max(); ➋
float z = std::numeric_limits<long double>::max(); ➌ // Undefined Behavior
printf("x: %g\ny: %Lg\nz: %g", x, y, z);
}
--------------------------------------------------------------------------
x: 3.40282e+38
y: 1.79769e+308
z: inf
列表 7-9:窄化转换导致的未定义行为
你可以安全地进行从 float 到 double ➊ 和从 double 到 long double ➋ 的隐式转换。不幸的是,将一个 long double 的最大值赋给 float 会导致未定义行为 ➌。
转换为 bool
指针、整数和浮动点数都可以隐式地转换为 bool 对象。如果值非零,隐式转换的结果为 true。否则,结果为 false。例如,值 int{ 1 } 转换为 true,而 int{} 转换为 false。
指向 void 的指针*
指针总是可以隐式地转换为 void*,如列表 7-10 所示。
#include <cstdio>
void print_addr(void* x) {
printf("0x%p\n", x);
}
int main() {
int x{};
print_addr(&x); ➊
print_addr(nullptr); ➋
}
--------------------------------------------------------------------------
0x000000F79DCFFB74 ➊
0x0000000000000000 ➋
列表 7-10:指针隐式转换为 void*。输出来自 Windows 10 x64 机器。
列表 7-10 由于指针隐式转换为 void*,因此能够编译通过。print_addr 函数打印 x 的地址 ➊ 和 nullptr、0 的值 ➋。
显式类型转换
显式类型转换也称为 强制类型转换。进行显式类型转换的首选方法是带花括号的初始化 {}。这种方法的主要优点是完全类型安全且无窄化。使用花括号初始化确保在编译时仅允许安全、行为良好且无窄化的转换。列表 7-11 显示了一个例子。
#include <cstdio>
#include <cstdint>
int main() {
int32_t a = 100;
int64_t b{ a }; ➊
if (a == b) printf("Non-narrowing conversion!\n"); ➋
//int32_t c{ b }; // Bang! ➌
}
--------------------------------------------------------------------------
Non-narrowing conversion! ➋
列表 7-11:4 字节和 8 字节整数的显式类型转换
这个简单的例子使用了带花括号的初始化 ➊ 来从 int32_t 构建一个 int64_t。这是一个行为良好的转换,因为可以确保没有丢失任何信息。你总是可以将 32 位数据存储在 64 位中。经过基本类型的行为良好的转换后,原值总是等于结果(根据 operator==)。
这个例子尝试进行一个行为不良(缩小)的转换 ➌。编译器会生成错误。如果你没有使用大括号初始化器{},编译器不会报错,如列表 7-12 所示。
#include <limits>
#include <cstdio>
#include <cstdint>
int main() {
int64_t b = std::numeric_limits<int64_t>::max();
int32_t c(b); ➊ // The compiler abides.
if (c != b) printf("Narrowing conversion!\n"); ➋
}
--------------------------------------------------------------------------
Narrowing conversion! ➋
列表 7-12:重构后的列表 7-11,没有使用大括号初始化器。
你将一个 64 位整数转换为 32 位整数 ➊。由于这种转换是缩小的,表达式c != b的结果为true ➋。这种行为非常危险,这也是第二章建议尽可能使用大括号初始化器的原因。
C 风格强制转换
回想一下第六章,命名转换函数允许你执行大括号初始化器不允许的危险类型转换。你也可以进行 C 风格的强制转换,但这主要是为了保持不同语言之间的一些兼容性。它们的用法如下:
(desired-type)object-to-cast
对于每一个 C 风格强制转换,都存在某种static_casts、const_casts和reinterpret_casts的组合,可以实现所需的类型转换。C 风格强制转换比命名的强制转换要危险得多(这已经是说得很严重了)。
C++显式类型转换的语法故意丑陋且冗长。这是为了突出代码中的一个点,即类型系统的严格规则在这里被弯曲或打破。C 风格的强制转换没有做到这一点。此外,从强制转换中并不清楚程序员打算进行什么样的转换。当你使用命名的转换函数时,编译器至少可以强制执行一些约束。例如,当使用 C 风格强制转换时,如果你只打算进行reinterpret_cast,就很容易忘记const正确性。
假设你想在一个函数内部将const char*数组当作无符号数处理。编写类似于列表 7-13 中演示的代码是非常简单的。
#include <cstdio>
void trainwreck(const char* read_only) {
auto as_unsigned = (unsigned char*)read_only;
*as_unsigned = 'b'; ➊ // Crashes on Windows 10 x64
}
int main() {
auto ezra = "Ezra";
printf("Before trainwreck: %s\n", ezra);
trainwreck(ezra);
printf("After trainwreck: %s\n", ezra);
}
--------------------------------------------------------------------------
Before trainwreck: Ezra
列表 7-13:一个 C 风格强制转换的灾难,意外去除了const限定符(read_only)。 (该程序的行为未定义;输出来自 Windows 10 x64 机器。)
现代操作系统强制执行内存访问模式。列表 7-13 尝试写入存储字符串文字Ezra的内存 ➊。在 Windows 10 x64 上,这会导致程序崩溃并发生内存访问冲突(因为该内存是只读的)。
如果你使用reinterpret_cast尝试这样做,编译器会生成错误,正如列表 7-14 所演示的那样。
#include <cstdio>
void trainwreck(const char* read_only) {
auto as_unsigned = reinterpret_cast<unsigned char*>(read_only); ➊
*as_unsigned = 'b'; // Crashes on Windows 10 x64
}
int main() {
auto ezra = "Ezra";
printf("Before trainwreck: %s\n", ezra);
trainwreck(ezra);
printf("After trainwreck: %s\n", ezra);
}
列表 7-14:重构后的列表 7-13,使用了reinterpret_cast。 (此代码无法编译。)
如果你真的打算放弃const正确性,你需要在这里加上const_cast ➊。代码会自我记录这些意图,并使这些故意的规则突破易于发现。
用户定义的类型转换
在用户自定义类型中,你可以提供用户自定义的转换函数。这些函数告诉编译器在隐式和显式类型转换过程中,你的用户自定义类型是如何行为的。你可以使用以下模式声明这些转换函数:
struct MyType {
operator destination-type() const {
// return a destination-type from here.
--snip--
}
}
例如,Listing 7-15 中的struct可以像只读的int一样使用。
struct ReadOnlyInt {
ReadOnlyInt(int val) : val{ val } { }
operator int() const { ➊
return val;
}
private:
const int val;
};
Listing 7-15: 一个包含用户自定义类型转换到int的ReadOnlyInt类
操作符int方法在➊定义了用户自定义的类型转换从 ReadOnlyInt 到 int。现在,得益于隐式转换,你可以像使用常规的int类型一样使用ReadOnlyInt类型:
struct ReadOnlyInt {
--snip--
};
int main() {
ReadOnlyInt the_answer{ 42 };
auto ten_answers = the_answer * 10; // int with value 420
}
有时,隐式转换可能会导致意外的行为。你应该始终尽量使用显式转换,特别是对于用户自定义类型。你可以通过explicit关键字实现显式转换。显式构造函数指示编译器不要将构造函数视为隐式转换的手段。你可以为你的用户自定义转换函数提供相同的指导原则:
struct ReadOnlyInt {
ReadOnlyInt(int val) : val{ val } { }
explicit operator int() const {
return val;
}
private:
const int val;
};
现在,你必须使用static_cast显式地将ReadOnlyInt转换为int:
struct ReadOnlyInt {
--snip--
};
int main() {
ReadOnlyInt the_answer{ 42 };
auto ten_answers = static_cast<int>(the_answer) * 10;
}
通常,这种方法倾向于促使代码更少歧义。
常量表达式
常量表达式是可以在编译时求值的表达式。出于性能和安全的考虑,每当计算可以在编译时而非运行时完成时,你应该这么做。涉及字面量的简单数学运算是可以在编译时求值的表达式的一个显著例子。
你可以通过使用constexpr表达式来扩展编译器的能力。每当计算表达式所需的所有信息在编译时就已经具备时,如果该表达式标记为constexpr,编译器就必须这样做。这种简单的承诺可以对代码的可读性和运行时性能产生意想不到的巨大影响。
const和constexpr密切相关。constexpr强制要求一个表达式可以在编译时求值,而const则强制要求一个变量在某个作用域内(运行时)不能更改。所有的constexpr表达式都是const,因为它们在运行时始终是固定的。
所有的constexpr表达式都以一个或多个基础类型(int, float, wchar_t等)开始。你可以通过使用运算符和constexpr函数在这些类型之上进行扩展。常量表达式主要用于替代代码中手动计算的值。这通常会产生更强健、易于理解的代码,因为你可以消除所谓的魔法值——直接粘贴到源代码中的手动计算常量。
一个多彩的例子
考虑以下例子,其中你为项目使用的某个库使用了通过色相-饱和度-明度(HSV)表示法编码的Color对象:
struct Color {
float H, S, V;
};
大致来说,色相(hue)对应一系列颜色,如红色、绿色或橙色。饱和度(saturation)对应颜色的鲜艳程度或强度。明度(value)对应颜色的亮度。
假设你想使用红绿蓝(RGB)表示法实例化Color对象。你可以使用转换器手动计算 RGB 到 HSV 的转换,但这是一个典型例子,你可以使用constexpr来消除魔法值。在你编写转换函数之前,你需要一些工具函数,即min、max和modulo。清单 7-16 实现了这些函数。
#include <cstdint>
constexpr uint8_t max(uint8_t a, uint8_t b) { ➊
return a > b ? a : b;
}
constexpr uint8_t max(uint8_t a, uint8_t b, uint8_t c) { ➋
return max(max(a, b), max(a, c));
}
constexpr uint8_t min(uint8_t a, uint8_t b) { ➌
return a < b ? a : b;
}
constexpr uint8_t min(uint8_t a, uint8_t b, uint8_t c) { ➍
return min(min(a, b), min(a, c));
}
constexpr float modulo(float dividend, float divisor) { ➎
const auto quotient = dividend / divisor; ➏
return divisor * (quotient - static_cast<uint8_t>(quotient));
}
清单 7-16:多个用于操作uint8_t对象的constexpr函数
每个函数都标记为constexpr,这告诉编译器该函数必须在编译时进行求值。max函数 ➊ 使用三元操作符返回最大的参数值。max的三参数版本 ➋ 使用比较的传递性;通过分别对a, b和a, c使用二参数max,你可以找到这个中间结果的最大值,从而得到总体最大值。由于二参数版本的max是constexpr,这一做法完全合法。
注意
你不能使用<math.h>头文件中的fmax,原因相同:它不是constexpr。
min版本 ➌ ➍ 完全遵循相同的逻辑,唯一的修改是比较顺序被翻转了。modulo函数 ➎ 是一个快速简便的constexpr版本的 C 函数fmod,用于计算第一个参数(被除数)除以第二个参数(除数)后的浮点余数。由于fmod不是constexpr,你需要自己手动实现。首先,你获得商 ➏。接着,通过static_cast和减法得到商的整数部分。将商的小数部分与除数相乘,得到结果。
拥有一组constexpr工具函数,你现在可以实现你的转换函数rgb_to_hsv,如清单 7-17 所示。
--snip--
constexpr Color rgb_to_hsv(uint8_t r, uint8_t g, uint8_t b) {
Color c{}; ➊
const auto c_max = max(r, g, b);
c.V = c_max / 255.0f; ➋
const auto c_min = min(r, g, b);
const auto delta = c.V - c_min / 255.0f;
c.S = c_max == 0 ? 0 : delta / c.V; ➌
if (c_max == c_min) { ➍
c.H = 0;
return c;
}
if (c_max == r) {
c.H = (g / 255.0f - b / 255.0f) / delta;
} else if (c_max == g) {
c.H = (b / 255.0f - r / 255.0f) / delta + 2.0f;
} else if (c_max == b) {
c.H = (r / 255.0f - g / 255.0f) / delta + 4.0f;
}
c.H *= 60.0f;
c.H = c.H >= 0.0f ? c.H : c.H + 360.0f;
c.H = modulo(c.H, 360.0f); ➎
return c;
}
清单 7-17:一个从 RGB 到 HSV 的constexpr转换函数
你已经声明并初始化了Color c ➊,它最终将由rgb_to_hsv返回。Color, V的值在 ➋ 通过缩放r、g和b的最大值来计算。接下来,饱和度S通过计算 RGB 最小值和最大值之间的距离并按V ➌进行缩放来计算。如果你将 HSV 值想象成存在于一个圆柱体内,饱和度是沿水平轴的距离,明度是沿垂直轴的距离。色相则是角度。为了简洁起见,我不会详细介绍如何计算这个角度,但计算在 ➍ 和 ➎ 之间实现。基本上,它涉及计算色相作为从主色成分角度的偏移量。然后对结果进行缩放和取模,使其适应 0 到 360 度的区间,并存储到H中。最后,返回c。
注意
有关将 HSV 转换为 RGB 的公式解释,请参见 en.wikipedia.org/wiki/HSL_and_HSV#Color_conversion_formulae。
这里有很多内容,但这些都是在编译时计算的。这意味着,当你初始化颜色时,编译器会初始化一个 Color,并填充所有的 HSV 字段浮动值:
--snip--
int main() {
auto black = rgb_to_hsv(0, 0, 0);
auto white = rgb_to_hsv(255, 255, 255);
auto red = rgb_to_hsv(255, 0, 0);
auto green = rgb_to_hsv( 0, 255, 0);
auto blue = rgb_to_hsv( 0, 0, 255);
// TODO: Print these, output.
}
你已经告诉编译器,每个颜色值都是可以在编译时计算的。根据你在程序其他部分如何使用这些值,编译器可以决定是在编译时还是运行时进行计算。结果是,编译器通常可以生成带有硬编码 魔法数字 的指令,对应于每个 Color 的正确 HSV 值。
constexpr 的必要性
对于哪些函数可以是 constexpr,存在一些限制,但随着每个新版本的 C++,这些限制已经逐渐放宽。
在某些环境中,如嵌入式开发,constexpr 是不可或缺的。一般来说,如果一个表达式可以声明为 constexpr,你应该强烈考虑这么做。使用 constexpr 而不是手动计算的常量值,可以使你的代码更加具有表现力。通常,这也能显著提高运行时的性能和安全性。
Volatile 表达式
volatile 关键字告诉编译器,通过此表达式进行的每次访问都必须视为可见的副作用。这意味着访问不能被优化掉,也不能与其他可见的副作用重新排序。在某些环境中,如嵌入式编程中,读取和写入某些特殊内存区域会对底层系统产生影响,因此 volatile 关键字至关重要。它防止编译器优化掉这些访问。列表 7-18 通过包含编译器通常会优化掉的指令,说明了为何你可能需要 volatile 关键字。
int foo(int& x) {
x = 10; ➊
x = 20; ➋
auto y = x; ➌
y = x; ➍
return y;
}
列表 7-18:包含死存储和冗余加载的函数
由于 x 在被赋值为 ➊ 后未被使用,便在重新赋值为 ➋ 之前被称为 死存储,是一个直接的优化候选项。还有一个类似的情况,其中 x 被用来设置 y 的值两次,而没有任何中间指令 ➌➍。这叫做 冗余加载,也是一个优化候选项。
你可能希望任何优秀的编译器将前面的函数优化为类似于 列表 7-19 的内容。
int foo(int& x) {
x = 20;
return x;
}
列表 7-19:对 列表 7-18 的一种合理优化
在某些环境中,冗余读取和死存储可能对系统产生可见的副作用。通过将 volatile 关键字添加到 foo 的参数中,你可以避免优化器去除这些重要的访问,如 列表 7-20 所示。
int foo(volatile int& x) {
x = 10;
x = 20;
auto y = x;
y = x;
return y;
}
列表 7-20:volatile 修改版 列表 7-18
现在,编译器将发出指令,执行你编程中所有的读写操作。
一个常见的误解是,volatile 与并发编程有关。事实并非如此。标记为 volatile 的变量通常不是线程安全的。第二部分讨论了 std::atomic,它保证了某些线程安全的原始类型。volatile 和 atomic 经常被混淆!
总结
本章介绍了运算符的主要特性,它们是程序中的基本工作单元。你探索了类型转换的几个方面,并从环境中接管了动态内存管理。你还接触到了 constexpr / volatile 表达式。掌握了这些工具后,你几乎可以执行任何系统编程任务。
练习
7-1. 创建一个 UnsignedBigInteger 类,能够处理比 long 更大的数字。你可以使用字节数组作为内部表示(例如,uint8_t[] 或 char[])。为 operator+ 和 operator- 实现运算符重载。进行溢出检查。对于有勇气的读者,还可以实现 operator*、operator/ 和 operator%。确保你的运算符重载在 int 类型和 UnsignedBigInteger 类型中都能正常工作。实现 operator int 类型转换。如果会发生类型缩小,进行运行时检查。
7-2. 创建一个 LargeBucket 类,能够存储最多 1MB 的数据。扩展 Heap 类,使其在分配大于 4096 字节的数据时返回一个 LargeBucket。确保当 Heap 无法分配合适大小的桶时,仍然抛出 std::bad_alloc 异常。
进一步阅读
- ISO 国际标准 ISO/IEC (2017) — 编程语言 C++ (国际标准化组织;瑞士日内瓦;*
isocpp.org/std/the-standard/*)
第十章:STATEMENTS**
进步不是来自早起的人——进步是由懒人寻找更简单的方法来做事情所带来的。
—Robert A. Heinlein,《爱情需要足够的时间》

每个 C++ 函数由一系列 语句 组成,语句是指定执行顺序的编程构造。本章通过理解对象生命周期、模板和表达式,来探索语句的细微差别。
表达式语句
表达式语句 是一个表达式后跟一个分号(;)。表达式语句构成了程序中的大多数语句。您可以将任何表达式转换为语句,这应该在您需要评估一个表达式但又想丢弃其结果时进行。当然,这只有在评估该表达式会引起副作用时才有用,比如打印到控制台或修改程序的状态。
清单 8-1 包含了几个表达式语句。
#include <cstdio>
int main() {
int x{};
++x; ➊
42; ➋
printf("The %d True Morty\n", x); ➌
}
--------------------------------------------------------------------------
The 1 True Morty ➌
清单 8-1:包含几个表达式语句的简单程序
➊ 处的表达式语句有副作用(递增 x),但 ➋ 处的没有。两者都是有效的(尽管 ➋ 处的没有用)。对 printf ➌ 的函数调用也是一个表达式语句。
复合语句
复合语句,也叫 块,是一系列由花括号 { } 包围的语句。块在控制结构中很有用,例如 if 语句,因为您可能希望执行多个语句而不仅仅是一个。
每个块声明了一个新的作用域,称为 块作用域。正如您在 第四章 中所学,声明在块作用域内的具有自动存储期限的对象,其生命周期与块的生命周期绑定。块内声明的变量按照其声明的反向顺序销毁。
清单 8-2 使用了来自 清单 4-5(位于 第 97 页)的可靠 Tracer 类来探索块作用域。
#include <cstdio>
struct Tracer {
Tracer(const char* name) : name{ name } {
printf("%s constructed.\n", name);
}
~Tracer() {
printf("%s destructed.\n", name);
}
private:
const char* const name;
};
int main() {
Tracer main{ "main" }; ➊
{
printf("Block a\n"); ➋
Tracer a1{ "a1" }; ➌
Tracer a2{ "a2" }; ➍
}
{
printf("Block b\n"); ➎
Tracer b1{ "b1" }; ➏
Tracer b2{ "b2" }; ➐
}
}
--------------------------------------------------------------------------
main constructed. ➊
Block a ➋
a1 constructed. ➌
a2 constructed.➍
a2 destructed.
a1 destructed.
Block b ➎
b1 constructed. ➏
b2 constructed. ➐
b2 destructed.
b1 destructed.
main destructed.
清单 8-2:一个使用 Tracer 类探索复合语句的程序
清单 8-2 首先初始化了一个名为 main 的 Tracer ➊。接着,您会生成两个复合语句。第一个复合语句以左花括号 { 开始,后跟该块的第一条语句,该语句打印 Block a ➋。您创建了两个 Tracer,a1 ➌ 和 a2 ➍,然后用右花括号 } 结束该块。这两个 tracers 在执行通过 Block a 后被销毁。请注意,这两个 tracers 的销毁顺序与它们的初始化顺序相反:先是 a2,然后是 a1。
还请注意,紧接着 Block a 后面是另一个复合语句,您打印了 Block b ➎,然后构造了两个 tracers,b1 ➏ 和 b2 ➐。它的行为是相同的:先是 b2 销毁,然后是 b1。一旦执行通过 Block b,main 的作用域结束,Tracer main 最终销毁。
声明语句
声明语句(或简称声明)在程序中引入标识符,例如函数、模板和命名空间。本节将探讨这些熟悉的声明的一些新特性,以及类型别名、属性和结构绑定。
注意
表达式static_assert,你在第六章中学过,也是一个声明语句。
函数
函数声明,也叫做函数的签名或原型,指定了函数的输入和输出。声明不需要包括参数名,只需要包括它们的类型。例如,下面这行代码声明了一个名为randomize的函数,该函数接受一个uint32_t的引用并返回void:
void randomize(uint32_t&);
不是成员函数的函数被称为非成员函数,有时也叫自由函数,它们总是声明在main()外部,在命名空间范围内。函数定义包括函数声明以及函数的主体。函数的声明定义了函数的接口,而函数的定义则定义了它的实现。例如,下面的定义是randomize函数的一种可能实现:
void randomize(uint32_t& x) {
x = 0x3FFFFFFF & (0x41C64E6D * x + 12345) % 0x80000000;
}
注意
这个randomize实现是一个线性同余生成器,一种原始类型的随机数生成器。有关生成随机数的更多信息,请参见第 241 页的“进一步阅读”部分。
正如你可能已经注意到的,函数声明是可选的。那么它们为什么存在呢?
答案是,你可以在代码中使用已声明的函数,只要它们最终在某个地方被定义。你的编译工具链可以自动处理这个问题。(你将在第二十一章中了解它是如何工作的。)
清单 8-3 中的程序确定了随机数生成器从数字 0x4c4347 转换到数字 0x474343 需要多少次迭代。
#include <cstdio>
#include <cstdint>
void randomize(uint32_t&); ➊
int main() {
size_t iterations{}; ➋
uint32_t number{ 0x4c4347 }; ➌
while (number != 0x474343) { ➍
randomize(number); ➎
++iterations; ➏
}
printf("%zu", iterations); ➐
}
void randomize(uint32_t& x) {
x = 0x3FFFFFFF & (0x41C64E6D * x + 12345) % 0x80000000; ➑
}
--------------------------------------------------------------------------
927393188 ➐
清单 8-3:一个在main中使用函数但直到稍后才定义的程序
首先,你声明randomize ➊。在main中,你将iterations计数变量初始化为零 ➋,并将number变量初始化为 0x4c4347 ➌。一个while循环检查number是否等于目标值 0x4c4347 ➍。如果不相等,你调用randomize ➎并递增iterations ➏。注意,你还没有定义randomize。一旦number等于目标值,你会在从main返回之前打印iterations的值 ➐。最后,你定义randomize ➑。程序的输出显示,要随机抽取目标值,几乎需要十亿次迭代。
尝试删除randomize的定义并重新编译。你应该会得到一个错误,提示无法找到randomize的定义。
你也可以像处理非成员函数一样,将方法声明与定义分开。例如,以下RandomNumberGenerator类将ran``domize函数替换为next:
struct RandomNumberGenerator {
explicit RandomNumberGenerator(uint32_t seed) ➊
: number{ seed } {} ➋
uint32_t next(); ➌
private:
uint32_t number;
};
你可以构建一个带有seed值➊的RandomNumberGenerator,它用这个值来初始化number成员变量➋。你已按照与非成员函数相同的规则声明了next函数➌。为了提供next的定义,你必须使用作用域解析符和类名来指定你要定义的方法。否则,定义一个方法与定义一个非成员函数是一样的:
uint32_t➊ RandomNumberGenerator::➋next() {
number = 0x3FFFFFFF & (0x41C64E6D * number + 12345) % 0x80000000; ➌
return number; ➍
}
这个定义与声明➊共享相同的返回类型。RandomNumberGenerator::构造指定你正在定义一个方法➋。函数的细节基本相同➌,只是你返回的是随机数生成器的状态的副本,而不是写入参数引用➋。
示例 8-4 演示了如何重构示例 8-3 以包含RandomNumberGenerator。
#include <cstdio>
#include <cstdint>
struct RandomNumberGenerator {
explicit RandomNumberGenerator(uint32_t seed)
: iterations{}➊, number { seed }➋ {}
uint32_t next(); ➌
size_t get_iterations() const; ➍
private:
size_t iterations;
uint32_t number;
};
int main() {
RandomNumberGenerator rng{ 0x4c4347 }; ➎
while (rng.next() != 0x474343) { ➏
// Do nothing...
}
printf("%zu", rng.get_iterations()); ➐
}
uint32_t RandomNumberGenerator::next() { ➑
++iterations;
number = 0x3FFFFFFF & (0x41C64E6D * number + 12345) % 0x80000000;
return number;
}
size_t RandomNumberGenerator::get_iterations() const { ➒
return iterations;
}
--------------------------------------------------------------------------
927393188 ➐
示例 8-4:使用RandomNumberGenerator类重构示例 8-3
如示例 8-3 所示,你已将声明与定义分开。声明了一个将iterations成员初始化为零➊并将其number成员设置为seed➋的构造函数后,next➌和get_iterations➍方法的声明没有包含实现。在main函数中,你使用0x4c4347的种子值➎初始化RandomNumberGenerator类,并调用next方法提取新的随机数➏。结果是一样的➐。与之前一样,next和get_iterations的定义位于main函数中的调用之后➑➒。
注意
分离定义和声明的实用性可能不太明显,因为你迄今为止处理的都是单一源文件程序。第二十一章探讨了多个源文件程序,其中分离声明和定义带来了巨大的好处。
命名空间
命名空间可以防止命名冲突。在大型项目中或导入库时,命名空间对于消除歧义、精确定位你要查找的符号至关重要。
将符号放入命名空间中
默认情况下,你声明的所有符号都会进入全局命名空间。全局命名空间包含所有你可以在不添加命名空间限定符的情况下访问的符号。除了std命名空间中的几个类,你所使用的对象都仅存在于全局命名空间中。
要将符号放入除全局命名空间外的命名空间中,你需要在命名空间块中声明该符号。命名空间块的形式如下:
namespace BroopKidron13 {
// All symbols declared within this block
// belong to the BroopKidron13 namespace
}
命名空间可以通过两种方式进行嵌套。首先,你可以简单地嵌套命名空间块:
namespace BroopKidron13 {
namespace Shaltanac {
// All symbols declared within this block
// belong to the BroopKidron13::Shaltanac namespace
}
}
其次,你可以使用作用域解析符:
namespace BroopKidron13::Shaltanac {
// All symbols declared within this block
// belong to the BroopKidron13::Shaltanac namespace
}
后者的方法更加简洁。
在命名空间中使用符号
要使用命名空间中的符号,您始终可以使用作用域解析运算符来指定符号的完全限定名称。这可以帮助您避免在大型项目中或使用第三方库时的命名冲突。如果您和另一个程序员使用相同的符号,您可以通过将该符号放入命名空间中来避免歧义。
列表 8-5 展示了如何使用完全限定的符号名称来访问命名空间中的符号。
#include <cstdio>
namespace BroopKidron13::Shaltanac { ➊
enum class Color { ➋
Mauve,
Pink,
Russet
};
}
int main() {
const auto shaltanac_grass{ BroopKidron13::Shaltanac::Color::Russet➌ };
if(shaltanac_grass == BroopKidron13::Shaltanac::Color::Russet) {
printf("The other Shaltanac's joopleberry shrub is always "
"a more mauvey shade of pinky russet.");
}
}
--------------------------------------------------------------------------
The other Shaltanac's joopleberry shrub is always a more mauvey shade of pinky russet.
列表 8-5:使用作用域解析运算符的嵌套命名空间块
列表 8-5 使用了嵌套命名空间 ➊ 并声明了一个 Color 类型 ➋。要使用 Color,您需要使用作用域解析运算符来指定符号的全名 BroopKidron13::Shaltanac::Color。因为 Color 是一个 enum class,所以您需要使用作用域解析运算符来访问它的值,正如您将 shaltanac_grass 赋值给 Russet ➌ 时一样。
使用指令
您可以使用 using 指令 来避免大量输入。using 指令将符号导入到一个块中,或者如果您在命名空间作用域声明 using 指令,则导入到当前命名空间。无论哪种方式,您只需输入一次完全的命名空间路径。其使用模式如下:
using my-type;
相应的 my-type 被导入到当前命名空间或块中,这意味着您不再需要使用其全名。列表 8-6 通过使用指令重构了列表 8-5。
#include <cstdio>
namespace BroopKidron13::Shaltanac {
enum class Color {
Mauve,
Pink,
Russet
};
}
int main() {
using BroopKidron13::Shaltanac::Color; ➊
const auto shaltanac_grass = Color::Russet➋;
if(shaltanac_grass == Color::Russet➌) {
printf("The other Shaltanac's joopleberry shrub is always "
"a more mauvey shade of pinky russet.");
}
}
--------------------------------------------------------------------------
The other Shaltanac's joopleberry shrub is always a more mauvey shade of pinky russet.
列表 8-6:使用指令重构列表 8-5
通过 main 中的 using 指令 ➊,您不再需要输入命名空间 BroopKidron13::Shaltanac 来使用 Color ➋➌。
如果小心使用,您可以通过 using namespace 指令将给定命名空间中的所有符号导入到全局命名空间中。
列表 8-7 详细说明了列表 8-6:命名空间 BroopKidron13::Shaltanac 包含多个符号,您希望将它们导入到全局命名空间中,以避免大量输入。
#include <cstdio>
namespace BroopKidron13::Shaltanac {
enum class Color {
Mauve,
Pink,
Russet
};
struct JoopleberryShrub {
const char* name;
Color shade;
};
bool is_more_mauvey(const JoopleberryShrub& shrub) {
return shrub.shade == Color::Mauve;
}
}
using namespace BroopKidron13::Shaltanac; ➊
int main() {
const JoopleberryShrub➋ yours{
"The other Shaltanac",
Color::Mauve➌
};
if (is_more_mauvey(yours)➍) {
printf("%s's joopleberry shrub is always a more mauvey shade of pinky"
"russet.", yours.name);
}
}
--------------------------------------------------------------------------
The other Shaltanac's joopleberry shrub is always a more mauvey shade of pinky
russet.
列表 8-7:重构后的列表 8-6,多个符号导入到全局命名空间中
通过 using namespace 指令 ➊,您可以在程序中使用类 ➋、枚举类 ➌、函数 ➍ 等,而无需输入完全限定的名称。当然,您需要非常小心,避免覆盖全局命名空间中的现有类型。通常,在单个翻译单元中出现过多的 using namespace 指令是不好的做法。
注意
您绝不应该在头文件中放置 using namespace 指令。每个包含您的头文件的源文件都会将该 using 指令中的所有符号转存到全局命名空间中。这可能会导致非常难以调试的问题。
类型别名
一个 类型别名 定义了一个名称,指向一个先前定义的名称。你可以将类型别名作为现有类型名称的同义词使用。
类型和所有引用它的类型别名之间没有区别。此外,类型别名不能改变现有类型名称的含义。
要声明一个类型别名,你可以使用以下格式,其中 type-alias 是类型别名的名称,type-id 是目标类型:
using type-alias = type-id;
列表 8-8 使用了两个类型别名,String 和 ShaltanacColor。
#include <cstdio>
namespace BroopKidron13::Shaltanac {
enum class Color {
Mauve,
Pink,
Russet
};
}
using String = const char[260]; ➊
using ShaltanacColor = BroopKidron13::Shaltanac::Color; ➋
int main() {
const auto my_color{ ShaltanacColor::Russet }; ➌
String saying { ➍
"The other Shaltanac's joopleberry shrub is "
"always a more mauvey shade of pinky russet."
};
if (my_color == ShaltanacColor::Russet) {
printf("%s", saying);
}
}
列表 8-8:对 列表 8-7 的重构,使用了类型别名
列表 8-8 声明了一个类型别名 String,它指向 const char[260] ➊。该列表还声明了一个 ShaltanacColor 类型别名,指向 BroopKidron13::Shaltanac::Color ➋。你可以使用这些类型别名作为直接替代,简化代码。在 main 中,你使用 ShaltanacColor 来去除所有嵌套的命名空间 ➌,并使用 String 使 saying 的声明更加简洁 ➍。
注意
类型别名可以出现在任何作用域中——块作用域、类作用域或命名空间作用域。
你可以将模板参数引入类型别名中。这使得有两种重要的用途:
-
你可以对模板参数进行部分应用。部分应用是将一些参数固定到模板中,生成一个具有更少模板参数的新模板的过程。
-
你可以为一个模板定义一个类型别名,使用完全指定的模板参数集。
模板实例化可能会非常冗长,而类型别名可以帮助你避免腕管综合症。
列表 8-9 声明了一个具有两个模板参数的 NarrowCaster 类。然后,你使用类型别名部分应用其中一个参数,生成一个新类型。
#include <cstdio>
#include <stdexcept>
template <typename To, typename From>
struct NarrowCaster const { ➊
To cast(From value) {
const auto converted = static_cast<To>(value);
const auto backwards = static_cast<From>(converted);
if (value != backwards) throw std::runtime_error{ "Narrowed!" };
return converted;
}
};
template <typename From>
using short_caster = NarrowCaster<short, From>; ➋
int main() {
try {
const short_caster<int> caster; ➌
const auto cyclic_short = caster.cast(142857);
printf("cyclic_short: %d\n", cyclic_short);
} catch (const std::runtime_error& e) {
printf("Exception: %s\n", e.what()); ➍
}
}
--------------------------------------------------------------------------
Exception: Narrowed! ➍
列表 8-9:使用类型别名对 NarrowCaster 类进行部分应用
首先,你实现了一个 NarrowCaster 模板类,它具有与 列表 6-6 中的 narrow_cast 函数模板相同的功能(在 第 154 页):它会执行 static_cast,然后检查是否发生了缩窄 ➊。接着,你声明了一个类型别名 short_caster,将 short 部分应用为 To 类型到 NarrowCast 中。在 main 中,你声明了一个类型为 short_caster<int> 的 caster 对象 ➌。short_caster 类型别名中的单个模板参数应用于类型别名中的剩余类型参数——From ➋。换句话说,类型 short_cast<int> 与 NarrowCaster<short, int> 同义。最终结果是相同的:使用 2 字节的 short 类型,当你尝试将值为 142857 的 int 转换为 short 时,会出现缩窄异常 ➍。
结构化绑定
结构化绑定使你能够将对象解包成它们的组成部分。任何其非静态数据成员是公共的类型都可以通过这种方式解包——例如,在第二章中介绍的 POD(普通数据类)类型。结构化绑定语法如下:
auto [object-1, object-2, ...] = plain-old-data;
这一行将通过逐个剥离 POD 对象初始化任意数量的对象(object-1、object-2,依此类推)。这些对象从上到下剥离 POD,并从左到右填充结构化绑定。考虑一个read_text_file函数,它接受一个字符串参数,该参数对应文件路径。比如,如果文件被锁定或不存在,函数可能会失败。你有两种处理错误的选项:
-
你可以在
read_text_file中抛出异常。 -
你可以从函数返回一个成功的状态码。
让我们来探索第二种选择。
示例 8-10 中的 POD 类型将作为read_text_file函数的返回类型。
struct TextFile {
bool success; ➊
const char* contents; ➋
size_t n_bytes; ➌
};
示例 8-10:一个TextFile类型,它将由read_text_file函数返回
首先,一个标志会告诉调用者函数调用是否成功 ➊。接下来是file的内容 ➋及其大小n_bytes ➌。
read_text_file的原型如下所示:
TextFile read_text_file(const char* path);
你可以使用结构化绑定声明将TextFile解包成程序中的各个部分,正如在示例 8-11 中所示。
#include <cstdio>
struct TextFile { ➊
bool success;
const char* data;
size_t n_bytes;
};
TextFile read_text_file(const char* path) { ➋
const static char contents[]{ "Sometimes the goat is you." };
return TextFile{
true,
contents,
sizeof(contents)
};
}
int main() {
const auto [success, contents, length]➌ = read_text_file("REAMDE.txt"); ➍
if (success➎) {
printf("Read %zu bytes: %s\n", length➏, contents➐);
} else {
printf("Failed to open REAMDE.txt.");
}
}
--------------------------------------------------------------------------
Read 27 bytes: Sometimes the goat is you.
示例 8-11:一个模拟读取文本文件的程序,它返回一个 POD,你可以在结构化绑定中使用它
你声明了TextFile ➊,然后为read_text_file提供了一个虚拟定义 ➋。(它实际上并不读取文件;更多内容将在第二部分中讨论。)
在main函数内,你调用read_text_file ➍并使用结构化绑定声明将结果解包到三个不同的变量中:success、contents和length ➌。在结构化绑定之后,你可以像声明这些变量时一样使用它们 ➎➏➐。
注意
结构化绑定声明中的类型不必匹配。
属性
属性将实现定义的特性应用于表达式语句。你通过使用包含一个或多个以逗号分隔的属性元素的双括号[[ ]]来引入属性。
表 8-1 列出了标准属性。
表 8-1: 标准属性
| 属性 | 含义 |
|---|---|
[[noreturn]] |
表示一个函数没有返回值。 |
[[deprecated("reason")]] |
表示该表达式已弃用;即不推荐使用它。"reason"是可选的,表示弃用的原因。 |
[[fallthrough]] |
表示一个 switch 语句的 case 打算穿透到下一个 switch 语句的 case。这可以避免编译器检查 switch case 穿透错误,因为这种情况不常见。 |
[[nodiscard]] |
表示应使用以下函数或类型声明。如果使用该元素的代码丢弃了值,编译器应发出警告。 |
[[maybe_unused]] |
表示以下元素可能未被使用,编译器不应对此发出警告。 |
[[carries_dependency]] |
在 <atomic> 头文件中使用,帮助编译器优化某些内存操作。你不太可能直接遇到这个。 |
列表 8-12 演示了通过定义一个永不返回的函数来使用 [[noreturn]] 属性。
#include <cstdio>
#include <stdexcept>
[[noreturn]] void pitcher() { ➊
throw std::runtime_error{ "Knuckleball." }; ➋
}
int main() {
try {
pitcher(); ➌
} catch(const std::exception& e) {
printf("exception: %s\n", e.what()); ➍
}
}
--------------------------------------------------------------------------
Exception: Knuckleball. ➍
列表 8-12:演示使用 [[noreturn]] 属性的程序
首先,你使用 [[noreturn]] 属性声明 pitcher 函数 ➊。在该函数中,你抛出一个异常 ➋。因为你总是抛出异常,所以 pitcher 永远不会返回(因此使用 [[noreturn]] 属性)。在 main 中,你调用 pitcher ➌ 并处理捕获的异常 ➍。当然,这段代码即使没有 [[noreturn]] 属性也能正常工作,但向编译器提供这些信息可以让它更全面地推理你的代码(并有可能优化你的程序)。
使用属性的情况较少,但它们仍然能向编译器传达有用的信息。
选择语句
选择语句 表示条件控制流。选择语句有两种类型,分别是 if 语句和 switch 语句。
if 语句
if 语句具有在 列表 8-13 中显示的熟悉形式。
if (condition-1) {
// Execute only if condition-1 is true ➊
} else if (condition-2) { // optional
// Execute only if condition-2 is true ➋
}
// ... as many else ifs as desired
--snip--
} else { // optional
// Execute only if none of the conditionals is true ➌
}
列表 8-13:if 语句的语法
遇到 if 语句时,首先评估条件 1 表达式。如果它为 true,则执行 ➊ 处的代码块,if 语句停止执行(不会考虑任何 else if 或 else 语句)。如果为 false,则按顺序评估 else if 语句的条件。这些是可选的,你可以根据需要提供任意数量。
例如,如果条件 2 评估为 true,则会执行 ➋ 处的代码块,剩余的 else if 或 else 语句不会被考虑。最后,如果所有前面的条件都评估为 false,则执行 ➌ 处的 else 块。与 else if 块一样,else 块是可选的。
列表 8-14 中的函数模板将 else 参数转换为 Positive、Negative 或 Zero。
#include <cstdio>
template<typename T>
constexpr const char* sign(const T& x) {
const char* result{};
if (x == 0) { ➊
result = "zero";
} else if (x > 0) { ➋
result = "positive";
} else { ➌
result = "negative";
}
return result;
}
int main() {
printf("float 100 is %s\n", sign(100.0f));
printf("int -200 is %s\n", sign(-200));
printf("char 0 is %s\n", sign(char{}));
}
--------------------------------------------------------------------------
float 100 is positive
int -200 is negative
char 0 is zero
列表 8-14:if 语句的示例用法
sign 函数接受一个参数,并确定该参数是等于 0 ➊、大于 0 ➋,还是小于 0 ➌。根据匹配的条件,它将自动变量 result 设置为三种字符串之一——zero、positive 或 negative,并将此值返回给调用者。
初始化语句与 if
你可以通过向 if 和 else if 声明中添加一个 init-state 语句来绑定对象的作用域,如 列表 8-15 所示。
if (init-statement; condition-1) {
// Execute only if condition-1 is true
} else if (init-statement; condition-2) { // optional
// Execute only if condition-2 is true
}
--snip--
列表 8-15:带有初始化的if语句
你可以将此模式与结构化绑定一起使用,以实现优雅的错误处理。列表 8-16 通过使用初始化语句将TextFile限定在if语句中,重构了列表 8-11。
#include <cstdio>
struct TextFile {
bool success;
const char* data;
size_t n_bytes;
};
TextFile read_text_file(const char* path) {
--snip--
}
int main() {
if(const auto [success, txt, len]➊ = read_text_file("REAMDE.txt"); success➋)
{
printf("Read %d bytes: %s\n", len, txt); ➌
} else {
printf("Failed to open REAMDE.txt."); ➍
}
}
--------------------------------------------------------------------------
Read 27 bytes: Sometimes the goat is you. ➌
列表 8-16:使用结构化绑定和if语句处理错误的列表 8-11 的扩展
你将结构化绑定声明移到了if语句的初始化语句部分 ➊。这样每个解包的对象——success、txt和len——的作用域就限制在了if块中。你直接在if的条件表达式中使用success来判断read_text_file是否成功 ➋。如果成功,你会打印REAMDE.txt的内容 ➌;如果失败,则打印错误信息 ➍。
constexpr if 语句
你可以使if语句成为constexpr语句;这样的语句称为constexpr if语句。constexpr if语句在编译时被求值。对应于true条件的代码块会被执行,而其余部分会被忽略。
constexpr if的使用方式与常规的if语句相同,正如列表 8-17 所示。
if constexpr (condition-1) {
// Compile only if condition-1 is true
} else if constexpr (condition-2) { // optional; can be multiple else ifs
// Compile only if condition-2 is true
}
--snip--
} else { // optional
// Compile only if none of the conditionals is true
}
列表 8-17:constexpr if 语句的使用
与模板和<type_traits>头文件结合使用时,constexpr if语句非常强大。constexpr if的一个主要用途是根据类型参数的一些特性,在函数模板中提供自定义行为。
列表 8-18 中的函数模板value_of接受指针、引用和值。根据传入参数的对象类型,value_of返回指向的值或值本身。
#include <cstdio>
#include <stdexcept>
#include <type_traits>
template <typename T>
auto value_of(T x➊) {
if constexpr (std::is_pointer<T>::value) { ➋
if (!x) throw std::runtime_error{ "Null pointer dereference." }; ➌
return *x; ➍
} else {
return x; ➎
}
}
int main() {
unsigned long level{ 8998 };
auto level_ptr = &level;
auto &level_ref = level;
printf("Power level = %lu\n", value_of(level_ptr)); ➏
++*level_ptr;
printf("Power level = %lu\n", value_of(level_ref)); ➐
++level_ref;
printf("It's over %lu!\n", value_of(level++)); ➑
try {
level_ptr = nullptr;
value_of(level_ptr);
} catch(const std::exception& e) {
printf("Exception: %s\n", e.what()); ➒
}
}
--------------------------------------------------------------------------
Power level = 8998 ➏
Power level = 8999 ➐
It's over 9000! ➑
Exception: Null pointer dereference. ➒
列表 8-18:一个使用constexpr if语句的示例函数模板value_of
value_of函数模板接受一个参数x ➊。你使用std::is_pointer<T>类型特征来判断参数是否为指针类型,并作为constexpr if语句中的条件表达式 ➋。如果x是指针类型,你检查是否为nullptr,如果遇到nullptr则抛出异常 ➌。如果x不是nullptr,你解引用它并返回结果 ➍。否则,x不是指针类型,因此直接返回它(因为它是一个值) ➎。
在main函数中,你多次实例化value_of,分别使用unsigned long指针 ➏、unsigned long引用 ➐、unsigned long ➑和nullptr ➒。
在运行时,constexpr if 语句消失;每个 value_of 的实例化包含一个分支语句或另一个分支。你可能会想知道为什么这样的功能有用。毕竟,程序应该在运行时做有用的事情,而不是在编译时。只要回到示例 7-17(见第 206 页),你会发现编译时求值通过消除魔法值,能显著简化你的程序。
还有其他一些例子,其中编译时求值非常流行,特别是在为他人创建库时。因为库的编写者通常无法知道用户将如何使用他们的库,他们需要编写通用代码。通常,他们会使用你在第六章中学到的技巧,这样他们就可以实现编译时多态。像 constexpr 这样的构造可以在编写此类代码时提供帮助。
注意
如果你有 C 语言背景,你会立刻意识到编译时求值的实用性,因为它几乎完全取代了预处理器宏的需求。
switch 语句
第二章 首次介绍了著名的 switch 语句。本节深入探讨了将初始化语句添加到 switch 声明中的方法。用法如下:
switch (init-expression➊; condition) {
case (case-a): {
// Handle case-a here
} break;
case (case-b): {
// Handle case-b here
} break;
// Handle other conditions as desired
default: {
// Handle the default case here
}
}
与 if 语句一样,你可以在 switch 语句中进行实例化 ➊。
示例 8-19 在 switch 语句中使用了初始化语句。
#include <cstdio>
enum class Color { ➊
Mauve,
Pink,
Russet
};
struct Result { ➋
const char* name;
Color color;
};
Result observe_shrub(const char* name) { ➌
return Result{ name, Color::Russet };
}
int main() {
const char* description;
switch (const auto result➍ = observe_shrub("Zaphod"); result.color➎) {
case Color::Mauve: {
description = "mauvey shade of pinky russet";
break;
} case Color::Pink: {
description = "pinky shade of mauvey russet";
break;
} case Color::Russet: {
description = "russety shade of pinky mauve";
break;
} default: {
description = "enigmatic shade of whitish black";
}}
printf("The other Shaltanac's joopleberry shrub is "
"always a more %s.", description); ➏
}
--------------------------------------------------------------------------
The other Shaltanac's joopleberry shrub is always a more russety shade of
pinky mauve. ➏
示例 8-19:在 switch 语句中使用初始化表达式
你声明了熟悉的 Color enum class ➊,并将其与 char* 成员连接,形成了 POD 类型 Result ➋。函数 observe_shrub 返回一个 Result ➌。在 main 中,你在初始化表达式中调用 observe_shrub 并将结果存储在 result 变量 ➍ 中。在 switch 的条件表达式中,你提取了此 result 的 color 元素 ➎。该元素决定了执行的 case(并设置 description 指针) ➏。
与 if 语句加初始化器语法一样,在初始化表达式中初始化的任何对象都绑定到 switch 语句的作用域内。
迭代语句
迭代语句 会重复执行一个语句。四种迭代语句分别是 while 循环、do-while 循环、for 循环和基于范围的 for 循环。
while 循环
while 循环是基本的迭代机制。用法如下:
while (condition) {
// The statement in the body of the loop
// executes upon each iteration
}
在执行循环的每次迭代之前,while 循环会先评估 condition 表达式。如果为 true,循环继续。如果为 false,循环终止,如示例 8-20 所示。
#include <cstdio>
#include <cstdint>
bool double_return_overflow(uint8_t& x) { ➊
const auto original = x;
x *= 2;
return original > x;
}
int main() {
uint8_t x{ 1 }; ➋
printf("uint8_t:\n===\n");
while (!double_return_overflow(x)➌) {
printf("%u ", x); ➍
}
}
--------------------------------------------------------------------------
uint8_t:
===
2 4 8 16 32 64 128 ➍
示例 8-20:一个程序,每次迭代时将 uint8_t 类型的值加倍,并打印新的值
你声明了一个 double_return_overflow 函数,该函数通过引用接收一个 8 位无符号整数 ➊。该函数将参数加倍,并检查是否导致溢出。如果发生溢出,它返回 true。如果没有溢出,返回 false。
在进入 while 循环之前,你将变量 x 初始化为 1 ➋。while 循环中的条件表达式会评估 double_return_overflow(x) ➌。由于你是通过引用传递 x,它会对 x 进行加倍,这是它的副作用。该函数还会返回一个值,告诉你加倍是否导致了 x 的溢出。当条件表达式的结果为 true 时,循环将继续执行,但 double_return_overflow 被写成返回 true,当循环应该停止时。你通过在前面加上逻辑非运算符(!)来修复这个问题。(回顾 第七章,该操作会将 true 转换为 false,将 false 转换为 true。)因此,while 循环实际上是在问:“如果不是 double_return_overflow 为 true...”
最终结果是,你依次打印出 2、4、8,依此类推直到 128 ➍。
注意,值 1 从未打印,因为评估条件表达式会将 x 加倍。你可以通过将条件语句放在循环末尾来修改这种行为,这样就会得到一个 do-while 循环。
do-while 循环
do-while 循环与 while 循环相同,只是条件语句在循环完成后评估,而不是在循环之前。其用法如下:
do {
// The statement in the body of the loop
// executes upon each iteration
} while (condition);
由于条件在循环结束时进行评估,你可以保证循环至少会执行一次。
示例 8-21 将 示例 8-20 重构为 do-while 循环。
#include <cstdio>
#include <cstdint>
bool double_return_overflow(uint8_t& x) {
--snip--
}
int main() {
uint8_t x{ 1 };
printf("uint8_t:\n===\n");
do {
printf("%u ", x); ➊
} while (!double_return_overflow(x)➋);
}
--------------------------------------------------------------------------
uint8_t:
===
1 2 4 8 16 32 64 128 ➊
示例 8-21:一个程序,它在每次迭代时将 uint8_t 的值加倍并打印新值
注意,来自 示例 8-21 的输出现在以 1 开始 ➊。你所需要做的只是重新格式化 while 循环,将条件放在循环的末尾 ➋。
在大多数涉及迭代的情况中,你有三个任务:
-
初始化某个对象。
-
在每次迭代前更新对象。
-
检查对象的值以满足某个条件。
你可以使用 while 或 do-while 循环来完成这些任务的一部分,但 for 循环提供了内建的功能,使得这些操作变得更加简便。
for 循环
for 循环是一个包含三个特殊表达式的迭代语句:初始化、条件 和 迭代,这些将在接下来的部分中进行描述。
初始化表达式
初始化表达式类似于 if 的初始化:它只会在第一次迭代之前执行一次。在初始化表达式中声明的任何对象的生命周期都被限制在 for 循环的作用域内。
条件表达式
for 循环的条件表达式会在每次循环迭代之前进行评估。如果条件为 true,则循环继续执行。如果条件为 false,则循环终止(这种行为与 while 循环和 do-while 循环的条件完全相同)。
与 if 和 switch 语句类似,for 允许你初始化具有与语句相同作用域的对象。
迭代表达式
在每次 for 循环的迭代后,迭代表达式会进行评估。这个评估发生在条件表达式评估之前。请注意,迭代表达式在成功迭代后进行评估,因此在第一次迭代之前不会执行迭代表达式。
为了更清晰地说明,以下列表列出了 for 循环的典型执行顺序:
-
初始化表达式
-
条件表达式
-
(循环主体)
-
迭代表达式
-
条件表达式
-
(循环主体)
步骤 4 到 6 会重复执行,直到条件表达式返回 false。
用法
列表 8-22 演示了如何使用 for 循环。
for(initialization➊; conditional➋; iteration➌) {
// The statement in the body of the loop
// executes upon each iteration
}
列表 8-22:使用 for 循环
for 循环的初始化 ➊、条件 ➋ 和迭代 ➌ 表达式位于括号中,位于 for 循环主体之前。
使用索引进行迭代
for 循环非常适合遍历类数组对象的组成元素。你使用一个辅助的 索引 变量来遍历数组对象有效索引的范围。你可以使用这个索引按顺序与每个数组元素进行交互。列表 8-23 使用一个索引变量来打印数组的每个元素及其索引。
#include <cstdio>
int main() {
const int x[]{ 1, 1, 2, 3, 5, 8 }; ➊
printf("i: x[i]\n"); ➋
for (int i{}➌; i < 6➍; i++➎) {
printf("%d: %d\n", i, x[i]);
}
}
--------------------------------------------------------------------------
i: x[i] ➋
0: 1
1: 1
2: 2
3: 3
4: 5
5: 8
列表 8-23:遍历斐波那契数列数组的程序
你初始化一个名为 x 的 int 数组,包含前六个斐波那契数 ➊。在打印输出标题 ➋ 后,你构建一个包含初始化 ➌、条件 ➍ 和迭代 ➎ 表达式的 for 循环。初始化表达式首先执行,并将索引变量 i 初始化为零。
列表 8-23 显示了一种自 1950 年代以来未曾改变的编码模式。你可以通过使用现代的基于范围的 for 循环来消除大量样板代码。
基于范围的 for 循环
基于范围的 for 循环在没有索引变量的情况下遍历一系列值。范围(或 范围表达式)是一个对象,基于范围的 for 循环知道如何遍历它。许多 C++ 对象是有效的范围表达式,包括数组。(你将在 第二部分 中学习到的所有 stdlib 容器也是有效的范围表达式。)
用法
基于范围的 for 循环用法如下所示:
for(range-declaration : range-expression) {
// The statement in the body of the loop
// executes upon each iteration
}
范围声明 声明一个命名变量。这个变量必须与范围表达式所暗示的类型相同(你可以使用 auto)。
列表 8-24 重构了 列表 8-23 ,使用基于范围的 for 循环。
#include <cstdio>
int main() {
const int x[]{ 1, 1, 2, 3, 5, 8 }; ➊
for (const auto element➋ : x➌) {
printf("%d ", element➍);
}
}
--------------------------------------------------------------------------
1 1 2 3 5 8
列表 8-24:一个基于范围的for循环,迭代前六个斐波那契数
你仍然声明一个数组x,包含六个斐波那契数 ➊。基于范围的for循环包含一个范围声明表达式 ➋,在其中声明element变量来保存范围的每个元素。它还包含范围表达式x ➌,其中包含你希望迭代并打印的元素 ➍。
这段代码整洁多了!
范围表达式
你可以定义自己的类型,这些类型也可以作为有效的范围表达式。但是,你需要在你的类型上指定几个函数。
每个范围都暴露了begin和end方法。这些函数代表了基于范围的for循环与范围交互的通用接口。两个方法都返回迭代器。迭代器是一个支持operator!=、operator++和operator*的对象。
让我们看看这些部分是如何结合在一起的。在底层,基于范围的for循环看起来就像列表 8-25 中的循环。
const auto e = range.end();➊
for(auto b = range.begin()➋; b != e➌; ++b➍) {
const auto& element➎ = *b;
}
列表 8-25:一个模拟基于范围的for循环的for循环
初始化表达式存储了两个变量,b ➋ 和 e ➊,分别初始化为range.begin()和range.end()。条件表达式检查b是否等于e,如果相等,则表示循环已完成 ➌(这是惯例)。迭代表达式使用前缀操作符 ➍ 增加b。最后,迭代器支持解引用操作符*,因此可以提取指向的元素 ➎。
注意
begin和end返回的类型不需要相同。要求是begin上的operator!=接受一个end参数,以支持比较begin != end。
一个斐波那契范围
你可以实现一个FibonacciRange,它将生成一个任意长的斐波那契数列。从上一节中,你知道这个范围必须提供一个返回迭代器的begin和end方法。在本示例中,这个迭代器称为FibonacciIterator,它必须提供operator!=、operator++和operator*。
列表 8-26 实现了一个FibonacciIterator和一个FibonacciRange。
struct FibonacciIterator {
bool operator!=(int x) const {
return x >= current; ➊
}
FibonacciIterator& operator++() {
const auto tmp = current; ➋
current += last; ➌
last = tmp; ➍
return *this; ➎
}
int operator*() const {
return current; ➏
}
private:
int current{ 1 }, last{ 1 };
};
struct FibonacciRange {
explicit FibonacciRange(int max➐) : max{ max } { }
FibonacciIterator begin() const { ➑
return FibonacciIterator{};
}
int end() const { ➒
return max;
}
private:
const int max;
};
列表 8-26:FibonacciIterator和FibonacciRange的实现
FibonacciIterator 有两个字段,current 和 last,它们初始化为 1。它们跟踪 Fibonacci 序列中的两个值。其 operator!= 检查传入的参数是否大于或等于 current ➊。回想一下,这个参数是在基于范围的 for 循环中的条件表达式里使用的。如果范围内还有元素,它应该返回 true;否则返回 false。operator++ 出现在迭代表达式中,负责为下一次迭代设置迭代器。你首先将 current 值保存到临时变量 tmp ➋。接下来,你通过 last 递增 current,得到下一个 Fibonacci 数字 ➌。(这遵循 Fibonacci 序列的定义。)然后你将 last 设置为 tmp ➍ 并返回对 this 的引用 ➎。最后,你实现了 operator*,它直接返回 current ➏。
FibonacciRange 要简单得多。它的构造函数接受一个最大参数,定义了范围的上限 ➐。begin 方法返回一个新的 FibonacciIterator ➑,而 end 方法返回 max ➒。
现在应该显而易见为什么你需要在 FibonacciIterator 上实现 bool operator!=(int x),而不是比如说在 bool operator!=(const FibonacciIterator& x) 上实现:一个 FibonacciRange 从 end() 返回一个 int。
你可以在基于范围的 for 循环中使用 FibonacciRange,正如在 清单 8-27 中所展示的那样。
#include <cstdio>
struct FibonacciIterator {
--snip--
};
struct FibonacciRange {
--snip--;
};
int main() {
for (const auto i : FibonacciRange{ 5000 }➊) {
printf("%d ", i); ➋
}
}
--------------------------------------------------------------------------
1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181 ➋
清单 8-27:在程序中使用 FibonacciRange
在 清单 8-26 中实现 FibonacciIterator 和 FibonacciRange 需要一些工作,但其回报是巨大的。在 main 中,你只需构造一个带有所需上限的 FibonacciRange ➊,基于范围的 for 循环会为你处理其他所有事情。你只需在 for 循环中使用生成的元素 ➋。
清单 8-27 与 清单 8-28 功能上是等价的,后者将基于范围的 for 循环转换成传统的 for 循环。
#include <cstdio>
struct FibonacciIterator {
--snip--
};
struct FibonacciRange {
--snip--;
};
int main() {
FibonacciRange range{ 5000 };
const auto end = range.end();➊
for (auto x = range.begin()➋; x != end ➌; ++x ➍) {
const auto i = *x;
printf("%d ", i);
}
}
--------------------------------------------------------------------------
1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181
清单 8-28:使用传统 for 循环重构 清单 8-27
清单 8-28 展示了所有部分如何结合在一起。调用 range.begin() ➋ 会返回一个 FibonacciIterator。当你调用 range.end() ➊ 时,它会返回一个 int。这些类型直接来源于 FibonacciRange 中 begin() 和 end() 方法的定义。条件语句 ➌ 在 FibonacciIterator 上使用 operator!=(int) 来实现以下行为:如果迭代器 x 已经超过了传给 operator!= 的 int 参数,条件语句将评估为 false,并且循环结束。你还实现了 FibonacciIterator 上的 operator++,所以 ++x ➍ 会在 FibonacciIterator 中递增 Fibonacci 数字。
当你对比 清单 8-27 和 8-28 时,你可以看到基于范围的 for 循环隐藏了多少繁琐的工作。
注意
你可能会想:“当然,基于范围的 for 循环看起来更简洁,但实现 FibonacciIterator 和 FibonacciRange 需要做很多工作。”这是一个很好的观点,对于一次性使用的代码,你可能不会以这种方式重构代码。范围的主要用途是,当你编写库代码、编写你会经常重用的代码,或者只是使用别人编写的范围时。
跳转语句
跳转语句,包括 break、continue 和 goto,用于转移控制流。与选择语句不同,跳转语句并不具有条件性。你应该避免使用它们,因为它们几乎总是可以被更高级的控制结构所替代。这里讨论这些语句是因为你可能在旧版 C++ 代码中看到它们,它们仍然在许多 C 代码中起着核心作用。
跳出语句
break 语句终止外层迭代或 switch 语句的执行。一旦 break 完成,控制流会转移到紧跟在 for、基于范围的 for、while、do-while 或 switch 语句之后的语句。
你已经在 switch 语句中使用过 break;一旦某个分支执行完毕,break 语句就会终止 switch 语句。回想一下,如果没有 break 语句,switch 语句会继续执行所有后续的分支。
清单 8-29 重构了 清单 8-27,当迭代器 i 等于 21 时跳出基于范围的 for 循环。
#include <cstdio>
struct FibonacciIterator {
--snip--
};
struct FibonacciRange {
--snip--;
};
int main() {
for (auto i : FibonacciRange{ 5000 }) {
if (i == 21) { ➊
printf("*** "); ➋
break; ➌
}
printf("%d ", i);
}
}
--------------------------------------------------------------------------
1 2 3 5 8 13 *** ➋
清单 8-29:重构自 清单 8-27,当迭代器等于 21 时跳出
添加了一个 if 语句,用来检查 i 是否等于 21 ➊。若是,它会打印三个星号 *** ➋ 并执行 break ➌。注意输出结果:程序没有打印 21,而是打印了三个星号,并且 for 循环终止了。与 清单 8-27 的输出结果比较。
continue 语句
continue 语句跳过外层迭代语句的其余部分,并继续下一次迭代。清单 8-30 将 清单 8-29 中的 break 替换为 continue。
#include <cstdio>
struct FibonacciIterator {
--snip--
};
struct FibonacciRange {
--snip--;
};
int main() {
for (auto i : FibonacciRange{ 5000 }) {
if (i == 21) {
printf("*** "); ➊
continue; ➋
}
printf("%d ", i);
}
}
--------------------------------------------------------------------------
1 2 3 5 8 13 *** ➊34 55 89 144 233 377 610 987 1597 2584 4181
清单 8-30:将 清单 8-29 重构为使用 continue 替代 break
当 i 等于 21 时,你仍然打印三个星号 ➊,但你使用 continue 替代 break ➋。这导致 21 不再打印,类似于 清单 8-29;然而,与 清单 8-29 不同, 清单 8-30 会继续迭代。(比较输出结果。)
goto 语句
goto 语句是一个无条件跳转。goto 语句的目标是一个标签。
标签
标签 是你可以添加到任何语句的标识符。标签为语句赋予了名称,但它们对程序没有直接影响。要分配标签,只需在语句前加上所需标签的名称,后跟一个冒号。
列表 8-31 为一个简单程序添加了 luke 和 yoda 标签。
#include <cstdio>
int main() {
luke: ➊
printf("I'm not afraid.\n");
yoda: ➋
printf("You will be.");
}
--------------------------------------------------------------------------
I'm not afraid.
You will be.
列表 8-31:带标签的简单程序
标签 ➊➋ 本身不执行任何操作。
goto 的使用
goto 语句的用法如下:
goto label;
例如,你可以使用 goto 语句不必要地使列表 8-32 中的简单程序变得晦涩。
#include <cstdio>
int main() {
goto silent_bob; ➊
luke:
printf("I'm not afraid.\n");
goto yoda; ➌
silent_bob:
goto luke; ➋
yoda:
printf("You will be.");
}
--------------------------------------------------------------------------
I'm not afraid.
You will be.
列表 8-32:展示 goto 语句的意大利面代码
在列表 8-32 中的控制流先跳转到 silent_bob ➊,再到 luke ➋,然后到 yoda ➌。
goto 在现代 C++ 程序中的作用
在现代 C++ 中,goto 语句没有什么好的用途。不要使用它们。
注意
在写得不好的 C++(以及大多数 C 代码)中,你可能会看到 goto 被用作一种原始的错误处理机制。很多系统编程涉及获取资源、检查错误条件以及清理资源。RAII(资源获取即初始化)范式巧妙地抽象了这些细节,但 C 语言并没有 RAII。有关更多信息,请参见 C 程序员的序言,见第 xxxvii 页。
总结
在本章中,你学习了可以在程序中使用的不同类型的语句。它们包括声明和初始化、选择语句以及迭代语句。
注意
请记住,try-catch 块也是语句,但它们已经在第四章中详细讨论过。
习题
8-1. 将列表 8-27 重构为独立的翻译单元:一个用于 main,另一个用于 FibonacciRange 和 FibonacciIterator。使用头文件共享两个翻译单元之间的定义。
8-2. 实现一个 PrimeNumberRange 类,可用于在范围表达式中迭代所有小于给定值的素数。再次使用单独的头文件和源文件。
8-3. 将 PrimeNumberRange 集成到列表 8-27 中,增加另一个循环,生成所有小于 5,000 的素数。
进一步阅读
-
ISO 国际标准 ISO/IEC(2017)— 编程语言 C++(国际标准化组织;瑞士日内瓦;
isocpp.org/std/the-standard/) -
《随机数生成与蒙特卡罗方法》,第二版,詹姆斯·E·詹特尔著(Springer-Verlag,2003)
-
《随机数生成与准蒙特卡罗方法》,哈拉尔德·尼德赖特著(SIAM 第 63 卷,1992)
第十一章:函数
函数应该只做一件事,做好这件事,且只做这件事。
—罗伯特·C·马丁,《代码整洁之道》

本章将继续讨论函数,这些函数将代码封装成可重用的组件。现在你已经掌握了 C++基础知识,本章首先通过更加深入地讲解修饰符、说明符和返回类型来回顾函数,这些内容出现在函数声明中并专门化函数的行为。
然后你将学习重载解析以及接受可变数量的参数,接着探索函数指针、类型别名、函数对象和久负盛名的 lambda 表达式。本章的最后将介绍std::function,然后再次回顾main函数并接受命令行参数。
函数声明
函数声明具有以下熟悉的形式:
prefix-modifiers return-type func-name(arguments) suffix-modifiers;
你可以为函数提供多个可选的修饰符(或说明符)。修饰符会以某种方式改变函数的行为。一些修饰符出现在函数声明或定义的开头(前缀修饰符),而其他修饰符出现在结尾(后缀修饰符)。前缀修饰符出现在返回类型之前,后缀修饰符出现在参数列表之后。
没有明确的语言原因说明为什么某些修饰符作为前缀或后缀出现:因为 C++有着悠久的历史,这些特性是逐步演变而来的。
前缀修饰符
到此为止,你已经了解了几个前缀修饰符:
-
前缀
static表示一个非类成员的函数具有内部链接,意味着该函数在此翻译单元外部不会被使用。不幸的是,这个关键字具有双重作用:如果它修饰的是一个方法(即类中的函数),它表示该函数不与类的实例化关联,而是与类本身关联(见第四章)。 -
修饰符
virtual表示方法可以被子类重写。修饰符override则向编译器表明子类打算重写父类的虚函数(见第五章)。 -
修饰符
constexpr表示函数应在编译时进行求值(见第七章)。 -
修饰符
[[noreturn]]表示该函数不会返回(见第八章)。回想一下,这个属性有助于编译器优化你的代码。
另一个前缀修饰符是inline,它在优化代码时指导编译器的作用。
在大多数平台上,函数调用会编译成一系列指令,如下所示:
-
将参数放入寄存器和调用栈中。
-
将返回地址压入调用栈。
-
跳转到被调用的函数。
-
函数完成后,跳转到返回地址。
-
清理调用栈。
这些步骤通常执行得非常迅速,并且如果你在多个地方使用一个函数,减少的二进制文件大小可能会带来显著的收益。
内联函数意味着将函数的内容直接复制并粘贴到执行路径中,省去了五个步骤的必要。这意味着当处理器执行你的代码时,它将立即执行函数的代码,而不是执行调用函数时所需的(适度的)程序。如果你更倾向于这种对速度的轻微提升,而不介意增加的二进制文件大小,可以使用inline关键字来向编译器表明这一点。inline关键字提示编译器的优化器将函数直接内联,而不是执行函数调用。
向函数添加inline不会改变其行为;它只是编译器偏好的表达方式。你必须确保如果你定义了inline函数,必须在所有翻译单元中都这么做。另外请注意,现代编译器通常会在适当的地方内联函数,尤其是当一个函数仅在一个翻译单元内使用时。
后缀修饰符
在本书的这一部分,你已经了解了两个后缀修饰符:
-
修饰符
noexcept表示该函数永远不会抛出异常。它使得某些优化成为可能(见第四章)。 -
修饰符
const表示该方法不会修改其类的实例,从而允许const引用类型调用该方法(见第四章)。
本节将探讨另外三个后缀修饰符:final、override和volatile。
final 和 override
final修饰符表示一个方法不能被子类重写。它实际上是virtual的反义词。列表 9-1 尝试重写一个final方法并导致编译错误。
#include <cstdio>
struct BostonCorbett {
virtual void shoot() final➊ {
printf("What a God we have...God avenged Abraham Lincoln");
}
};
struct BostonCorbettJunior : BostonCorbett {
void shoot() override➋ { } // Bang! shoot is final.
};
int main() {
BostonCorbettJunior junior;
}
列表 9-1:一个类尝试重写一个 final 方法(这段代码无法编译)。
这个列表将shoot方法标记为final ➊。在继承自BostonCorbett的BostonCorbettJunior中,你尝试override(重写)shoot方法 ➋。这将导致编译错误。
你还可以将final关键字应用于整个类,禁止该类成为父类,正如列表 9-2 中所示。
#include <cstdio>
struct BostonCorbett final ➊ {
void shoot() {
printf("What a God we have...God avenged Abraham Lincoln");
}
};
struct BostonCorbettJunior : BostonCorbett ➋ { }; // Bang!
int main() {
BostonCorbettJunior junior;
}
列表 9-2:一个类尝试从一个 final 类继承(这段代码无法编译)。
BostonCorbett类被标记为final ➊,当你尝试在BostonCorbettJunior中继承它时会导致编译错误 ➋。
注意
final和override在技术上不是语言关键字;它们是标识符。与关键字不同,标识符只有在特定上下文中使用时才会获得特殊含义。这意味着你可以在程序的其他地方使用final和override作为符号名,从而导致像virtual void final() override这样的疯狂构造。尽量避免这么做。
每当使用接口继承时,应该将实现类标记为 final,因为这个修饰符可以促使编译器执行一种叫做 去虚拟化(devirtualization)的优化。当虚拟调用被去虚拟化时,编译器会消除与虚拟调用相关的运行时开销。
volatile
回想一下 第七章,volatile 对象的值可以随时变化,因此编译器必须将对 volatile 对象的所有访问视为可见副作用,以便进行优化。volatile 关键字表示可以对 volatile 对象调用方法。这类似于 const 方法可以应用于 const 对象。结合这两个关键字,它们定义了一个方法的 const/volatile 资格(有时称为 cv 资格),如 列表 9-3 所示。
#include <cstdio>
struct Distillate {
int apply() volatile ➊ {
return ++applications;
}
private:
int applications{};
};
int main() {
volatile ➋ Distillate ethanol;
printf("%d Tequila\n", ethanol.apply()➌);
printf("%d Tequila\n", ethanol.apply());
printf("%d Tequila\n", ethanol.apply());
printf("Floor!");
}
--------------------------------------------------------------------------
1 Tequila ➌
2 Tequila
3 Tequila
Floor!
列表 9-3:展示如何使用 volatile 方法
在这个示例中,你在 Distillate 类上声明了 apply 方法 vola``tile ➊。你还在 main 中创建了一个名为 ethanol 的 volatile Distillate ➋。由于 apply 方法是 volatile 的,你仍然可以调用它 ➌(即使 ethanol 是 volatile)。
如果你没有标记 apply volatile ➊,当你尝试调用它时,编译器会抛出错误 ➌。就像你不能对 const 对象调用非 const 方法一样,你不能对 volatile 对象调用非 volatile 方法。想象一下如果可以执行这样的操作会发生什么:非 volatile 方法是编译器优化的候选,因为如 第七章 中所述,许多种内存访问可以在不改变程序可观察副作用的情况下被优化掉。
编译器应该如何处理因使用 volatile 对象——它要求所有内存访问被视为可观察的副作用——来调用一个非 volatile 方法时产生的矛盾?编译器的回答是,将这种矛盾视为错误。
auto 返回类型
有两种方式声明函数的返回值:
-
(主要)像之前一样,使用返回类型来引导函数声明。
-
(次要)通过使用
auto,让编译器推导出正确的返回类型。
和 auto 类型推导一样,编译器会推导出返回类型,固定运行时类型。
这个特性应该谨慎使用。因为函数定义本身就是文档,因此在可能的情况下,最好提供具体的返回类型。
auto 和函数模板
auto 类型推导的主要用例是在函数模板中,其中返回类型可能依赖(以潜在复杂的方式)于模板参数。其用法如下:
auto my-function(arg1-type arg1, arg2-type arg2, ...) {
// return any type and the
// compiler will deduce what auto means
}
可以将 auto 返回类型推导语法扩展为通过箭头操作符 -> 提供返回类型作为后缀。这样,你可以附加一个表达式,该表达式计算出函数的返回类型。其用法如下:
auto my-function(arg1-type arg1, arg2-type arg2, ...) -> type-expression {
// return an object with type matching
// the type-expression above
}
通常,你不会使用这种冗长的形式,但在某些情况下它非常有用。例如,这种形式的 auto 类型推导通常与 decltype 类型表达式搭配使用。decltype 类型表达式返回另一个表达式的结果类型。它的用法如下:
decltype(expression)
这个表达式会解析为表达式的结果类型。例如,以下 decltype 表达式返回 int,因为整数字面量 100 的类型是 int:
decltype(100)
在模板的泛型编程之外,decltype 是一种罕见的用法。
你可以结合 auto 返回类型推导和 decltype 来记录函数模板的返回类型。考虑 示例 9-4 中的 add 函数,它定义了一个 add 函数模板,用来将两个参数相加。
#include <cstdio>
template <typename X, typename Y>
auto add(X x, Y y) -> decltype(x + y) { ➊
return x + y;
}
int main() {
auto my_double = add(100., -10);
printf("decltype(double + int) = double; %f\n", my_double); ➋
auto my_uint = add(100U, -20);
printf("decltype(uint + int) = uint; %u\n", my_uint); ➌
auto my_ulonglong = add(char{ 100 }, 54'999'900ull);
printf("decltype(char + ulonglong) = ulonglong; %llu\n", my_ulonglong); ➍
}
--------------------------------------------------------------------------
decltype(double + int) = double; 90.000000 ➋
decltype(uint + int) = uint; 80 ➌
decltype(char + ulonglong) = ulonglong; 55000000 ➍
示例 9-4:使用 decltype 和 auto 返回类型推导
add 函数使用 auto 类型推导结合 decltype 类型表达式 ➊。每次你用两个类型 X 和 Y 实例化模板时,编译器会评估 decltype(X + Y),并确定 add 的返回类型。在 main 中,你提供了三种实例化。首先,你将一个 double 和一个 int 相加 ➋。编译器确定 decltype(double{ 100\. } + int{ -10 }) 是一个 double,这就确定了该 add 实例化的返回类型。反过来,这也将 my_double 的类型设定为 double ➋。你还有两个其他的实例化:一个是 unsigned int 和 int(结果是 unsigned int ➌),另一个是 char 和 unsigned long long(结果是 unsigned long long ➍)。
重载解析
重载解析 是编译器在将函数调用与其正确实现匹配时执行的过程。
回顾 第四章,函数重载允许你指定具有相同名称但不同类型和可能不同参数的函数。编译器通过将函数调用中的参数类型与每个重载声明中的类型进行比较,从而选择其中的一个重载。编译器会在可能的选项中选择最佳的,如果无法选择最佳选项,它将生成编译错误。
大致而言,匹配过程如下:
-
编译器会寻找一个精确的类型匹配。
-
编译器会尝试使用整数和浮点数的转换来获得合适的重载(例如,从
int到long或从float到double)。 -
编译器会尝试使用标准转换来进行匹配,比如将整数类型转换为浮点数,或者将指向子类的指针转换为指向父类的指针。
-
编译器会寻找用户定义的转换。
-
编译器会寻找一个变参函数。
变参函数
变参函数接受可变数量的参数。通常,你通过明确列出所有参数来指定函数所接受的参数数量。使用变参函数时,你可以接受任意数量的参数。变参函数 printf 就是一个典型的例子:你提供一个格式说明符和任意数量的参数。因为 printf 是变参函数,所以它接受任何数量的参数。
注意
机智的 Pythonista 会立刻注意到变参函数与 *args/**kwargs 之间的概念关系。
你通过将 ... 放置为函数参数列表的最后一个参数来声明变参函数。当调用变参函数时,编译器会将传入的参数与声明的参数进行匹配。多余的参数将打包成 ... 表示的变参。
你不能直接从变参中提取元素。相反,你需要使用 <cstdarg> 头文件中的工具函数来访问每个单独的参数。
表 9-1 列出了这些工具函数。
表 9-1: <cstdarg> 头文件中的工具函数
| 函数 | 描述 |
|---|---|
va_list |
用于声明表示变参参数的局部变量 |
va_start |
启用访问变参参数 |
va_end |
用于结束对变参参数的遍历 |
va_arg |
用于遍历变参参数中的每个元素 |
va_copy |
创建变参参数的副本 |
工具函数的使用有些复杂,最好通过一个连贯的示例来展示。考虑 示例 9-5 中的变参 sum 函数,它包含一个变参参数。
#include <cstdio>
#include <cstdint>
#include <cstdarg>
int sum(size_t n, ...➊) {
va_list args; ➋
va_start(args, n); ➌
int result{};
while (n--) {
auto next_element = va_arg(args, int); ➍
result += next_element;
}
va_end(args); ➎
return result;
}
int main() {
printf("The answer is %d.", sum(6, 2, 4, 6, 8, 10, 12)); ➏
}
--------------------------------------------------------------------------
The answer is 42\. ➏
示例 9-5:一个具有变参列表的 sum 函数
你将 sum 声明为变参函数 ➊。所有变参函数必须声明一个 va_list。你将其命名为 args ➋。va_list 需要通过 va_start 初始化 ➌,后者接受两个参数。第一个参数是 va_list,第二个是变参参数的大小。你通过 va_args 函数遍历变参中的每个元素。第一个参数是 va_list,第二个是参数类型 ➍。遍历完成后,你通过 va_end 来结束遍历,传入 va_list 结构体 ➎。
你调用 sum 函数时传入七个参数:第一个是变参参数的数量(六个),后面是六个数字(2, 4, 6, 8, 10, 12)➏。
变参函数是从 C 语言继承下来的。通常,变参函数不安全,是常见的安全漏洞源。
变参函数至少存在两个主要问题:
-
变参参数不是类型安全的。(注意
va_arg的第二个参数是类型。) -
变参参数的元素数量必须单独跟踪。
编译器无法帮助你解决这些问题。
幸运的是,变参模板提供了一种更安全且性能更高的实现变参函数的方式。
变参模板
变参模板使你能够创建接受变参且类型相同的函数模板。它们使你能够利用模板引擎的强大功能。要声明变参模板,你需要添加一个特殊的模板参数,叫做模板参数包。清单 9-6 展示了它的用法。
template <typename...➊ Args>
return-type func-name(Args...➋ args) {
// Use parameter pack semantics
// within function body
}
清单 9-6:一个带有参数包的模板函数
模板参数包是模板参数列表的一部分 ➊。当你在函数模板 ➋ 中使用Args时,它被称为函数参数包。有一些特殊的操作符可以与参数包一起使用:
-
你可以使用
sizeof...(args)来获取参数包的大小。 -
你可以使用特殊语法
other_function(args...)调用一个函数(例如other_function)。这会展开参数包args,并允许你对参数包中的参数进行进一步处理。
使用参数包编程
不幸的是,无法直接对参数包进行索引。你必须从函数模板内部调用自己——这个过程叫做编译时递归——以递归地遍历参数包中的元素。
清单 9-7 展示了这一模式。
template <typename T, typename... Args>
void my_func(T x➊, Args...args) {
// Use x, then recurse:
my_func(args...); ➋
}
清单 9-7:一个示范编译时递归与参数包的模板函数。与其他用法清单不同,清单中包含的省略号是字面上的。
关键是要在参数包之前添加一个常规模板参数 ➊。每次调用my_func时,x会吸收第一个参数,其余的会打包到args中。要调用时,你使用args...构造来展开参数包 ➋。
递归需要一个停止条件,因此你添加一个没有参数的函数模板特化:
template <typename T>
void my_func(T x) {
// Use x, but DON'T recurse
}
重新审视求和函数
考虑在清单 9-8 中作为变参模板实现的(经过大幅改进的)sum函数。
#include <cstdio>
template <typename T>
constexpr➊ T sum(T x) { ➋
return x;
}
template <typename T, typename... Args>
constexpr➌ T sum(T x, Args... args) { ➍
return x + sum(args...➎);
}
int main() {
printf("The answer is %d.", sum(2, 4, 6, 8, 10, 12)); ➏
}
--------------------------------------------------------------------------
The answer is 42\. ➏
清单 9-8:使用模板参数包替代va_args的清单 9-5 的重构版
第一个函数 ➋ 是处理停止条件的重载;如果函数只有一个参数,你只需返回参数x,,因为单个元素的和就是该元素。变参模板 ➍ 遵循清单 9-7 中概述的递归模式。它从参数包args中去除一个参数x,然后返回x加上递归调用sum时展开的参数包 ➎ 的结果。由于所有这些通用编程都可以在编译时计算,所以你将这些函数标记为constexpr ➊➌。这种编译时计算是主要的优势,相较于清单 9-5,虽然它们的输出相同,但会在运行时计算结果 ➏。(既然不需要,为什么要支付运行时的代价呢?)
当你只想对一系列值(如列表 9-5 中的值)应用单一的二元运算符(如加法或减法)时,你可以使用折叠表达式而非递归。
折叠表达式
折叠表达式计算在参数包的所有参数上使用二元运算符的结果。折叠表达式与可变参数模板不同,但相关。它们的使用方法如下:
(... binary-operator parameter-pack)
例如,你可以使用以下折叠表达式来对名为args的参数包中的所有元素进行求和:
(... + args)
列表 9-9 将 9-8 重构为使用折叠表达式而非递归。
#include <cstdio>
template <typename... T>
constexpr auto sum(T... args) {
return (... + args); ➊
}
int main() {
printf("The answer is %d.", sum(2, 4, 6, 8, 10, 12)); ➋
}
--------------------------------------------------------------------------
The answer is 42\. ➋
列表 9-9:将 列表 9-8 使用折叠表达式进行重构
你通过使用折叠表达式来简化sum函数,而不是使用递归方法 ➊。最终结果是相同的 ➋。
函数指针
函数式编程是一种编程范式,强调函数求值和不可变数据。函数式编程中的一个主要概念是将函数作为参数传递给另一个函数。
你可以通过传递函数指针来实现这一点。函数占用内存,就像对象一样。你可以通过常规的指针机制引用这个内存地址。然而,与对象不同的是,你不能修改指向的函数。从这个角度看,函数在概念上类似于const对象。你可以获取函数的地址并调用它们,仅此而已。
声明函数指针
要声明一个函数指针,请使用以下丑陋的语法:
return-type (*pointer-name)(arg-type1, arg-type2, ...);
这与函数声明的外观相同,只是函数名被替换为(*pointer-name)。
像往常一样,你可以使用取地址符号&来获取函数的地址。然而,这不是必须的;你也可以直接使用函数名作为指针。
列表 9-10 展示了如何获取并使用函数指针。
#include <cstdio>
float add(float a, int b) {
return a + b;
}
float subtract(float a, int b) {
return a - b;
}
int main() {
const float first{ 100 };
const int second{ 20 };
float(*operation)(float, int) {}; ➊
printf("operation initialized to 0x%p\n", operation); ➋
operation = &add; ➌
printf("&add = 0x%p\n", operation); ➍
printf("%g + %d = %g\n", first, second, operation(first, second)); ➎
operation = subtract; ➏
printf("&subtract = 0x%p\n", operation); ➐
printf("%g - %d = %g\n", first, second, operation(first, second)); ➑
}
--------------------------------------------------------------------------
operation initialized to 0x0000000000000000 ➋
&add = 0x00007FF6CDFE1070 ➍
100 + 20 = 120 ➎
&subtract = 0x00007FF6CDFE10A0 ➐
100 - 20 = 80 ➑
列表 9-10:一个展示函数指针的程序。(由于地址空间布局随机化,地址 ➍➐ 在运行时会有所不同。)
这个列表展示了两个具有相同函数签名的函数,add和subtract。由于函数签名匹配,这些函数的指针类型也会匹配。你初始化一个接受float和int作为参数并返回float的函数指针operation ➊。接下来,你打印初始化后operation的值,它是nullptr ➋。
然后,你使用取地址符号将add的地址赋值给operation ➌,并打印其新地址 ➍。你调用operation并打印结果 ➎。
为了说明你可以重新赋值函数指针,你将operation赋值为subtract,而不使用取地址符号 ➏,打印operation的新值 ➐,最后打印结果 ➑。
类型别名和函数指针
类型别名为编程提供了一种简洁的方式来使用函数指针。其使用方法如下:
using alias-name = return-type(*)(arg-type1, arg-type2, ...)
例如,你可以在清单 9-10 中定义一个operation_func类型别名:
using operation_func = float(*)(float, int);
如果你将使用相同类型的函数指针,这非常有用;它确实可以清理代码。
函数调用操作符
你可以通过重载函数调用操作符operator()()使用户定义类型可调用或可执行。这样的类型被称为函数类型,函数类型的实例被称为函数对象。函数调用操作符允许任意组合的参数类型、返回类型和修饰符(除了static)。
你可能希望使用户定义类型可调用的主要原因是与期望使用函数调用操作符的代码进行互操作。你会发现许多库(如 stdlib)使用函数调用操作符作为函数对象的接口。例如,在第十九章中,你将学习如何使用std::async函数创建一个异步任务,它接受一个可以在单独线程上执行的任意函数对象。它使用函数调用操作符作为接口。发明std::async的委员会本可以要求你暴露一个比如run的方法,但他们选择了函数调用操作符,因为它允许通用代码使用相同的符号来调用函数或函数对象。
清单 9-11 展示了函数调用操作符的使用。
struct type-name {
return-type➊ operator()➋(arg-type1 arg1, arg-type2 arg2, ...➌) {
// Body of function-call operator
}
}
清单 9-11:函数调用操作符的使用
函数调用操作符具有特殊的operator()方法名称 ➋。你声明任意数量的参数 ➌,并且你还决定适当的返回类型 ➊。
当编译器评估函数调用表达式时,它将对第一个操作数调用函数调用操作符,并将其余操作数作为参数传递。函数调用表达式的结果是调用相应的函数调用操作符的结果。
一个计数示例
请参阅清单 9-12 中的CountIf函数类型,该类型计算特定char在空终止字符串中的出现频率。
#include <cstdio>
#include <cstdint>
struct CountIf {
CountIf(char x) : x{ x } { }➊
size_t operator()(const char* str➋) const {
size_t index{}➌, result{};
while (str[index]) {
if (str[index] == x) result++; ➍
index++;
}
return result;
}
private:
const char x;
};
int main() {
CountIf s_counter{ 's' }; ➎
auto sally = s_counter("Sally sells seashells by the seashore."); ➏
printf("Sally: %zu\n", sally);
auto sailor = s_counter("Sailor went to sea to see what he could see.");
printf("Sailor: %zu\n", sailor);
auto buffalo = CountIf{ 'f' }("Buffalo buffalo Buffalo buffalo "
"buffalo buffalo Buffalo buffalo."); ➐
printf("Buffalo: %zu\n", buffalo);
}
--------------------------------------------------------------------------
Sally: 7
Sailor: 3
Buffalo: 16
清单 9-12:一个计算空终止字符串中字符出现次数的函数类型
你通过使用构造函数来初始化CountIf对象,该构造函数接受一个char ➊。你可以像调用函数一样调用这个结果函数对象,传递一个空终止字符串作为参数 ➋,因为你已经实现了函数调用操作符。函数调用操作符通过index变量 ➌ 遍历参数str中的每个字符,每当字符与x字段匹配时,result变量就会递增 ➍。由于调用该函数不会修改CountIf对象的状态,因此你已将其标记为const。
在 main 中,你已经初始化了 CountIf 函数对象 s_counter,它将计算字母 s 的频率 ➎。你可以像使用函数一样使用 s_counter ➏。你甚至可以初始化一个 CountIf 对象,并直接将函数运算符作为右值对象使用 ➐。在某些场景中,这样做可能会很方便,比如你可能只需要调用该对象一次。
你可以将函数对象用作部分应用。列表 9-12 在概念上与 列表 9-13 中的 count_if 函数类似。
#include <cstdio>
#include <cstdint>
size_t count_if(char x➊, const char* str) {
size_t index{}, result{};
while (str[index]) {
if (str[index] == x) result++;
index++;
}
return result;
}
int main() {
auto sally = count_if('s', "Sally sells seashells by the seashore.");
printf("Sally: %zu\n", sally);
auto sailor = count_if('s', "Sailor went to sea to see what he could see.");
printf("Sailor: %zu\n", sailor);
auto buffalo = count_if('f', "Buffalo buffalo Buffalo buffalo "
"buffalo buffalo Buffalo buffalo.");
printf("Buffalo: %zu\n", buffalo);
}
--------------------------------------------------------------------------
Sally: 7
Sailor: 3
Buffalo: 16
列表 9-13:模拟 列表 9-12 的自由函数
count_if 函数有一个额外的参数 x ➊,但除此之外,它几乎与 CountIf 的函数运算符相同。
注意
在函数式编程术语中,CountIf 是将 x 部分应用到 count_if 的 partial application。当你将一个参数部分应用到函数时,你固定了该参数的值。这样的部分应用的产物是另一个接受少一个参数的函数。
声明函数类型通常比较冗长。你可以通过 Lambda 表达式显著减少样板代码。
Lambda 表达式
Lambda 表达式 简洁地构造了无名的函数对象。函数对象隐含了函数类型,从而提供了一种快速声明函数对象的方法。Lambda 不提供任何额外的功能,只是以传统的方式声明函数类型。但当你只需要在一个特定的上下文中初始化函数对象时,它们非常方便。
用法
Lambda 表达式有五个组成部分:
-
*captures*:函数对象的成员变量(即部分应用的参数) -
*参数*:调用函数对象所需的参数 -
*body*:函数对象的代码 -
*specifiers*:如constexpr、mutable、noexcept和[[noreturn]]等元素 -
*返回类型*:函数对象返回的类型
Lambda 表达式的用法如下:
[captures➊] (parameters➋) specifiers➌ -> return-type➍ { body➎ }
仅捕获和函数体是必需的,其他部分都是可选的。你将在接下来的几节中深入了解这些组件。
每个 Lambda 组件都有一个与之直接对应的函数对象。为了在函数对象(如 CountIf)与 Lambda 表达式之间架起桥梁,查看 列表 9-14,其中列出了来自 列表 9-12 的 CountIf 函数类型,并附有注释,表示 Lambda 表达式在使用时的类似部分。
struct CountIf {
CountIf(char x) : x{ x } { } ➊
size_t➍ operator()(const char* str➋) const➎ {
--snip--➌
}
private:
const char x; ➋
};
列表 9-14:比较 CountIf 类型声明与 Lambda 表达式
您在 CountIf 构造函数中设置的成员变量类似于 lambda 的捕获 ➊。函数调用运算符的参数 ➋、主体 ➌ 和返回类型 ➍ 类似于 lambda 的参数、主体和返回类型。最后,修饰符可以应用于函数调用运算符 ➎ 和 lambda。(Lambda 表达式使用示例中的数字与 列表 9-14 相对应。)
Lambda 参数与主体
Lambda 表达式生成函数对象。作为函数对象,lambda 是可调用的。大多数时候,您希望在调用时让函数对象接受参数。
lambda 的主体就像一个函数的主体:所有的参数都具有函数作用域。
您使用与函数相同的语法来声明 lambda 的参数和主体。
例如,以下 lambda 表达式生成一个函数对象,该对象将对其 int 参数进行平方操作:
[](int x) { return x*x; }
该 lambda 接受一个 int x,并在 lambda 的主体内使用它进行平方操作。
列表 9-15 使用了三个不同的 lambda 表达式来转换数组 1, 2, 3。
#include <cstdio>
#include <cstdint>
template <typename Fn>
void transform(Fn fn, const int* in, int* out, size_t length) { ➊
for(size_t i{}; i<length; i++) {
out[i] = fn(in[i]); ➋
}
}
int main() {
const size_t len{ 3 };
int base[]{ 1, 2, 3 }, a[len], b[len], c[len];
transform([](int x) { return 1; }➌, base, a, len);
transform([](int x) { return x; }➍, base, b, len);
transform([](int x) { return 10*x+5; }➎, base, c, len);
for (size_t i{}; i < len; i++) {
printf("Element %zu: %d %d %d\n", i, a[i], b[i], c[i]);
}
}
--------------------------------------------------------------------------
Element 0: 1 1 15
Element 1: 1 2 25
Element 2: 1 3 35
列表 9-15:三个 lambda 表达式和一个 transform 函数
transform 模板函数 ➊ 接受四个参数:一个函数对象 fn,一个 in 数组和一个 out 数组,以及这些数组的相应 length。在 transform 中,您会对 in 的每个元素调用 fn,并将结果赋值给 out 的相应元素 ➋。
在 main 中,您声明了一个 base 数组 1, 2, 3,它将作为 in 数组使用。在同一行中,您还声明了三个未初始化的数组 a, b 和 c,它们将作为 out 数组使用。第一次调用 transform 时传递了一个始终返回 1 的 lambda ([](int x) { return 1; }) ➌,结果被存储在 a 中。(注意,lambda 不需要名字!)第二次调用 transform ([](int x) { return x; }) 简单地返回其参数 ➍,结果被存储在 b 中。第三次调用 transform 时,lambda 将参数乘以 10 并加上 5 ➎。结果被存储在 c 中。然后,您将输出打印到一个矩阵中,其中每一列展示了在每种情况下应用于不同 lambda 的转换。
请注意,您将 transform 声明为模板函数,这使得您可以使用任何函数对象重复使用它。
默认参数
您可以为 lambda 提供默认参数。默认的 lambda 参数行为与默认的函数参数相同。调用者可以为默认参数指定值,在这种情况下,lambda 使用调用者提供的值。如果调用者没有指定值,lambda 则使用默认值。
列表 9-16 展示了默认参数的行为。
#include <cstdio>
int main() {
auto increment = [](auto x, int y = 1➊) { return x + y; };
printf("increment(10) = %d\n", increment(10)); ➋
printf("increment(10, 5) = %d\n", increment(10, 5)); ➌
}
--------------------------------------------------------------------------
increment(10) = 11 ➋
increment(10, 5) = 15 ➌
列表 9-16:使用默认的 lambda 参数
增量 lambda 有两个参数,x 和 y。但 y 参数是可选的,因为它具有默认参数 1 ➊。如果你在调用函数时没有为 y 指定参数 ➋,则增量返回 1 + x。如果你确实为 y 提供了一个参数 ➌,则使用该值。
通用 Lambda
通用 lambda 是 lambda 表达式模板。对于一个或多个参数,你可以指定 auto 而不是具体类型。这些 auto 类型将成为模板参数,意味着编译器会为该 lambda 创建一个自定义实例化。
列表 9-17 演示了如何将通用 lambda 分配给一个变量,然后在两个不同的模板实例化中使用该 lambda。
#include <cstdio>
#include <cstdint>
template <typename Fn, typename T➊>
void transform(Fn fn, const T* in, T* out, size_t len) {
for(size_t i{}; i<len; i++) {
out[i] = fn(in[i]);
}
}
int main() {
constexpr size_t len{ 3 };
int base_int[]{ 1, 2, 3 }, a[len]; ➋
float base_float[]{ 10.f, 20.f, 30.f }, b[len]; ➌
auto translate = [](auto x) { return 10 * x + 5; }; ➍
transform(translate, base_int, a, l); ➎
transform(translate, base_float, b, l); ➏
for (size_t i{}; i < l; i++) {
printf("Element %zu: %d %f\n", i, a[i], b[i]);
}
}
--------------------------------------------------------------------------
Element 0: 15 105.000000
Element 1: 25 205.000000
Element 2: 35 305.000000
列表 9-17:使用通用 lambda
你向 transform 添加了第二个模板参数 ➊,你用它作为 in 和 out 的指向类型。这使你可以将 transform 应用于任何类型的数组,而不仅仅是 int 类型的数组。为了测试升级后的 transform 模板,你声明了两个具有不同指向类型的数组:int ➋ 和 float ➌。(回想一下第三章,10.f 中的 f 表示一个 float 字面量。)接下来,你将一个通用的 lambda 表达式赋值给 translate ➍。这使你可以在每次实例化 transform 时使用相同的 lambda:当你用 base_int ➎ 和 base_float ➏ 进行实例化时。
如果没有通用 lambda,你将需要像下面这样显式声明参数类型:
--snip–
transform([](int x) { return 10 * x + 5; }, base_int, a, l); ➎
transform([](double x) { return 10 * x + 5; }, base_float, b, l); ➏
到目前为止,你一直依赖编译器推断 lambda 的返回类型。这对于通用 lambda 尤其有用,因为通常 lambda 的返回类型会依赖于其参数类型。但是,如果你愿意,你也可以显式声明返回类型。
Lambda 返回类型
编译器会为你推断 lambda 的返回类型。要接管编译器的推断,你可以使用箭头 -> 语法,如下所示:
[](int x, double y) -> double { return x + y; }
这个 lambda 表达式接受一个 int 和一个 double,并返回一个 double。
你还可以使用 decltype 表达式,这在使用通用 lambda 时非常有用。例如,考虑以下 lambda:
[](auto x, double y) -> decltype(x+y) { return x + y; }
在这里,你显式声明 lambda 的返回类型为将 x 加到 y 后得到的类型。
你很少需要显式指定 lambda 的返回类型。
一个更常见的需求是你必须在调用之前将一个对象注入到 lambda 中。这就是 lambda 捕获的作用。
Lambda 捕获
Lambda 捕获将对象注入到 lambda 中。注入的对象有助于修改 lambda 的行为。
通过在括号[]内指定捕获列表来声明 lambda 的捕获。捕获列表位于参数列表之前,可以包含任意数量的逗号分隔的参数。然后,在 lambda 的主体内使用这些参数。
一个 lambda 可以按引用捕获或按值捕获。默认情况下,lambda 按值捕获。
lambda 的捕获列表类似于函数类型的构造函数。清单 9-18 将清单 9-12 中的 CountIf 改写为 lambda s_counter。
#include <cstdio>
#include <cstdint>
int main() {
char to_count{ 's' }; ➊
auto s_counter = to_count➋ {
size_t index{}, result{};
while (str[index]) {
if (str[index] == to_count➌) result++;
index++;
}
return result;
};
auto sally = s_counter("Sally sells seashells by the seashore."➍);
printf("Sally: %zu\n", sally);
auto sailor = s_counter("Sailor went to sea to see what he could see.");
printf("Sailor: %zu\n", sailor);
}
--------------------------------------------------------------------------
Sally: 7
Sailor: 3
清单 9-18:将清单 9-12 中的 CountIf 改写为 lambda
你初始化一个名为 to_count 的 char 类型变量,赋值为字母 s ➊。接下来,你在分配给 s_counter 的 lambda 表达式中捕获 to_count ➋。这样,to_count 就可以在 lambda 表达式的主体内使用 ➌。
要通过引用捕获一个元素,而不是通过值捕获,可以在捕获对象的名称前加上与号 &。清单 9-19 在 s_counter 中添加了一个引用捕获,使其在 lambda 调用中保持累积计数。
#include <cstdio>
#include <cstdint>
int main() {
char to_count{ 's' };
size_t tally{};➊
auto s_counter = to_count, &tally➋ {
size_t index{}, result{};
while (str[index]) {
if (str[index] == to_count) result++;
index++;
}
tally += result;➌
return result;
};
printf("Tally: %zu\n", tally); ➍
auto sally = s_counter("Sally sells seashells by the seashore.");
printf("Sally: %zu\n", sally);
printf("Tally: %zu\n", tally); ➎
auto sailor = s_counter("Sailor went to sea to see what he could see.");
printf("Sailor: %zu\n", sailor);
printf("Tally: %zu\n", tally); ➏
}
--------------------------------------------------------------------------
Tally: 0 ➍
Sally: 7
Tally: 7 ➎
Sailor: 3
Tally: 10 ➏
清单 9-19:在 lambda 中使用引用捕获
你将计数器变量 tally 初始化为零 ➊,然后 s_counter lambda 通过引用捕获 tally(注意与号 &) ➋。在 lambda 的主体中,你添加一条语句,在每次调用时通过 result 增加 tally,然后返回 ➌。结果是,无论你调用多少次 lambda,tally 都会跟踪总计数。在第一次调用 s_counter 之前,你打印 tally 的值 ➍(此时为零)。当你用 Sally sells seashells by the seashore. 调用 s_counter 后,tally 的值为 7 ➎。最后一次调用 s_counter,传入 Sailor went to sea to see what he could see. 时返回 3,因此 tally 的值为 7 + 3 = 10 ➏。
默认捕获
到目前为止,你需要通过名称捕获每个元素。有时,这种捕获方式被称为命名捕获。如果你懒得一个个捕获,可以通过默认捕获来捕获 lambda 中所有使用的自动变量。要在捕获列表中指定值捕获,使用单一的等号 =。要指定引用捕获,使用单一的与号 &。
例如,你可以将清单 9-19 中的 lambda 表达式“简化”,通过引用执行默认捕获,如清单 9-20 中所示。
--snip--
auto s_counter = &➊ {
size_t index{}, result{};
while (str[index]) {
if (str[index] == to_count➋) result++;
index++;
}
tally➌ += result;
return result;
};
--snip--
清单 9-20:通过引用的默认捕获简化 lambda 表达式
你通过➊指定默认引用捕获,这意味着 lambda 表达式体内的任何自动变量都会通过引用捕获。这里有两个变量:to_count ➋ 和 tally ➌。
如果你编译并运行重构后的清单,你将获得相同的输出。然而,请注意,to_count 现在是通过引用捕获的。如果你在 lambda 表达式体内不小心修改了它,变化会影响到所有 lambda 调用以及 main 中的 to_count(它是一个自动变量)。
如果你改为使用值捕获,会发生什么呢?你只需要将捕获列表中的 = 改为 &,如清单 9-21 中所示。
--snip--
auto s_counter = =➊ {
size_t index{}, result{};
while (str[index]) {
if (str[index] == to_count➋) result++;
index++;
}
tally➌ += result;
return result;
};
--snip--
清单 9-21:将清单 9-20 修改为按值捕获而不是按引用捕获(此代码无法编译。)
你将默认捕获更改为按值捕获 ➊。to_count的捕获不受影响 ➋,但尝试修改tally会导致编译错误 ➌。你不能修改按值捕获的变量,除非你在 lambda 表达式中添加mutable关键字。mutable关键字允许你修改按值捕获的变量,这包括调用该对象的非const方法。
清单 9-22 添加了mutable修饰符,并具有默认的按值捕获。
#include <cstdio>
#include <cstdint>
int main() {
char to_count{ 's' };
size_t tally{};
auto s_counter = =➊ mutable➋ {
size_t index{}, result{};
while (str[index]) {
if (str[index] == to_count) result++;
index++;
}
tally += result;
return result;
};
auto sally = s_counter("Sally sells seashells by the seashore.");
printf("Tally: %zu\n", tally); ➌
printf("Sally: %zu\n", sally);
printf("Tally: %zu\n", tally); ➍
auto sailor = s_counter("Sailor went to sea to see what he could see.");
printf("Sailor: %zu\n", sailor);
printf("Tally: %zu\n", tally); ➎
}
--------------------------------------------------------------------------
Tally: 0
Sally: 7
Tally: 0
Sailor: 3
Tally: 0
清单 9-22:一个使用默认按值捕获的mutable lambda 表达式
你通过值声明了默认捕获 ➊,并使得 lambda 表达式 s_counter成为mutable ➋。每次打印tally ➌➍➎时,你都得到零值。为什么呢?
因为tally是按值复制的(通过默认捕获),lambda 表达式中的tally本质上是一个完全不同的变量,只是恰好有相同的名字。对 lambda 表达式中tally的修改不会影响main中的自动tally变量。main()中的tally被初始化为零,并且从未被修改。
你也可以将默认捕获与命名捕获混合使用。例如,你可以使用以下方式,通过引用进行默认捕获,并通过值复制to_count:
auto s_counter = &➊,to_count➋ {
--snip--
};
这指定了通过引用进行默认捕获 ➊,并通过值捕获to_count ➋。
尽管执行默认捕获看起来像是一种简单的捷径,但最好避免使用它。明确声明捕获要比使用默认捕获更好。如果你发现自己在说“我就使用默认捕获,因为变量太多了,不想一一列出”,那么你可能需要重构代码。
捕获列表中的初始化表达式
有时你希望在捕获列表中初始化一个全新的变量。也许重命名一个捕获的变量可以让 lambda 表达式的意图更加清晰。或者你可能想把一个对象传入 lambda 中,因此需要初始化一个变量。
要使用初始化表达式,只需声明新变量的名称,后跟等号以及你想初始化变量的值,正如清单 9-23 所演示的那样。
auto s_counter = &tally➊,my_char=to_count➋ {
size_t index{}, result{};
while (str[index]) {
if (str[index] == my_char➌) result++;
--snip--
};
清单 9-23:在 lambda 捕获中使用初始化表达式
捕获列表包含一个简单的命名捕获,你通过引用捕获了tally ➊。lambda 表达式还按值捕获了to_count,但是你选择使用变量名my_char来代替 ➋。当然,你需要在 lambda 表达式内部使用my_char而不是to_count ➌。
注意
捕获列表中的初始化表达式也被称为初始化捕获(init capture)。
捕获 this
有时 lambda 表达式包含一个外部类。你可以使用[*this]或[this]来分别通过值或通过引用捕获外部对象(由this指向)。
Listing 9-24 实现了一个 LambdaFactory,它生成计数的 lambda 并跟踪 tally。
#include <cstdio>
#include <cstdint>
struct LambdaFactory {
LambdaFactory(char in) : to_count{ in }, tally{} { }
auto make_lambda() { ➊
return this➋ {
size_t index{}, result{};
while (str[index]) {
if (str[index] == to_count➌) result++;
index++;
}
tally➍ += result;
return result;
};
}
const char to_count;
size_t tally;
};
int main() {
LambdaFactory factory{ 's' }; ➎
auto lambda = factory.make_lambda(); ➏
printf("Tally: %zu\n", factory.tally);
printf("Sally: %zu\n", lambda("Sally sells seashells by the seashore."));
printf("Tally: %zu\n", factory.tally);
printf("Sailor: %zu\n", lambda("Sailor went to sea to see what he could see."));
printf("Tally: %zu\n", factory.tally);
}
--------------------------------------------------------------------------
Tally: 0
Sally: 7
Tally: 7
Sailor: 3
Tally: 10
Listing 9-24:一个 LambdaFactory 示例,展示了如何使用 this 捕获
LambdaFactory 构造函数接受一个字符并使用它初始化 to_count 字段。make_lambda ➊ 方法展示了如何按引用捕获 this ➋ 并在 lambda 表达式中使用 to_count ➌ 和 tally ➍ 成员变量。
在 main 中,你初始化了一个 factory ➎ 并使用 make_``lambda 方法 ➏ 创建了一个 lambda。输出与 Listing 9-19 相同,因为你按引用捕获了 this,并且 tally 的状态在每次调用 lambda 时都会持续。
澄清示例
捕获列表有很多种可能性,但一旦你掌握了基础——按值和按引用捕获——就不会有太多意外。Table 9-2 提供了一些简短的澄清示例,供你将来参考。
表 9-2: Lambda 捕获列表的澄清示例
| 捕获列表 | 含义 |
|---|---|
[&] |
默认按引用捕获 |
[&,i] |
默认按引用捕获;按值捕获 i |
[=] |
默认按值捕获 |
[=,&i] |
默认按值捕获;按引用捕获 i |
[i] |
按值捕获 i |
[&i] |
按引用捕获 i |
[i,&j] |
按值捕获 i;按引用捕获 j |
[i=j,&k] |
按值捕获 j 为 i;按引用捕获 k |
[this] |
按引用捕获 enclosing object |
[*this] |
按值捕获 enclosing object |
[=,*this,i,&j] |
默认按值捕获;按值捕获 this 和 i;按引用捕获 j |
constexpr Lambda 表达式
所有的 lambda 表达式都是 constexpr,只要该 lambda 可以在编译时调用。你可以选择明确声明 constexpr,如下所示:
[] (int x) constexpr { return x * x; }
如果你希望确保 lambda 满足所有 constexpr 要求,则应将其标记为 constexpr。从 C++17 开始,这意味着不能进行动态内存分配,不能调用非 constexpr 函数等。标准委员会计划在每次发布中放宽这些限制,因此如果你编写了大量使用 constexpr 的代码,务必复习最新的 constexpr 约束。
std::function
有时你只是想要一个统一的容器来存储可调用对象。<functional> 头文件中的 std::function 类模板是一个多态封装器,封装了一个可调用对象。换句话说,它是一个通用的函数指针。你可以将静态函数、函数对象或 lambda 存储到一个 std::function 中。
注意
*function* 类在标准库中。我们提前展示它,因为它自然地适应了这个场景。
使用 functions,你可以:
-
在调用者不需要知道函数实现的情况下调用
-
赋值、移动和复制
-
具有空状态,类似于
nullptr
声明函数
要声明一个function,必须提供一个包含可调用对象原型的单一模板参数:
std::function<return-type(arg-type-1, arg-type-2, etc.)>
std::function类模板有多个构造函数。默认构造函数以空模式构造std::function,意味着它不包含任何可调用对象。
空函数
如果你调用一个没有包含对象的std::function,std::function将抛出一个std::bad_function_call异常。请参阅 Listing 9-25。
#include <cstdio>
#include <functional>
int main() {
std::function<void()> func; ➊
try {
func(); ➋
} catch(const std::bad_function_call& e) {
printf("Exception: %s", e.what()); ➌
}
}
--------------------------------------------------------------------------
Exception: bad function call ➌
Listing 9-25:默认std::function构造函数和std::bad_function_call异常
你使用默认构造函数构造了一个std::function ➊。模板参数void()表示一个不接受参数并返回void的函数。因为你没有给func赋值一个可调用对象,所以它处于空状态。当你调用func ➋时,它抛出一个std::bad_function_call异常,你捕获并打印出来 ➌。
将可调用对象赋给函数
要将可调用对象赋给function,你可以使用function的构造函数或赋值运算符,如 Listing 9-26 所示。
#include <cstdio>
#include <functional>
void static_func() { ➊
printf("A static function.\n");
}
int main() {
std::function<void()> func { [] { printf("A lambda.\n"); } }; ➋
func(); ➌
func = static_func; ➍
func(); ➎
}
--------------------------------------------------------------------------
A lambda. ➌
A static function. ➎
Listing 9-26:使用function的构造函数和赋值运算符
你声明了一个静态函数static_func,它不接受任何参数并返回void ➊。在main函数中,你创建了一个名为func的函数 ➋。模板参数表示func包含的可调用对象不接受任何参数并返回void。你用一个打印消息A lambda的 lambda 表达式初始化了func。然后你立即调用func ➌,它调用了包含的 lambda 并打印了预期的消息。接下来,你将static_func赋值给func,这替换了你在构造时赋给它的 lambda ➍。然后你调用func,它调用了static_func而不是 lambda,因此你看到打印出了A static function. ➎。
扩展示例
你可以用可调用对象构造一个function,只要该对象支持由function的模板参数所隐含的函数语义。
Listing 9-27 使用了一个std::function实例数组,并将其填充了一个静态函数(用于计数空格)、一个来自 Listing 9-12 的CountIf函数对象,以及一个计算字符串长度的 lambda。
#include <cstdio>
#include <cstdint>
#include <functional>
struct CountIf {
--snip--
};
size_t count_spaces(const char* str) {
size_t index{}, result{};
while (str[index]) {
if (str[index] == ' ') result++;
index++;
}
return result;
}
std::function➊<size_t(const char*)➋> funcs[]{
count_spaces, ➌
CountIf{ 'e' }, ➍
[](const char* str) { ➎
size_t index{};
while (str[index]) index++;
return index;
}
};
auto text = "Sailor went to sea to see what he could see.";
int main() {
size_t index{};
for(const auto& func : funcs➏) {
printf("func #%zu: %zu\n", index++, func(text)➐);
}
}
--------------------------------------------------------------------------
func #0: 9 ➌
func #1: 7 ➍
func #2: 44 ➎
Listing 9-27:使用std::function数组遍历具有不同底层类型的统一可调用对象集合
你声明了一个名为funcs的std::function数组 ➊,它具有静态存储持续时间。模板参数是一个接受const char*并返回size_t的函数原型 ➋。在funcs数组中,你传入了一个静态函数指针 ➌,一个函数对象 ➍,以及一个 lambda ➎。在main函数中,你使用基于范围的for循环遍历funcs中的每个函数 ➏。你将文本Sailor went to sea to see what he could see.传递给每个func,并打印结果。
注意,从main的角度来看,funcs中的所有元素都是相同的:你只需要用一个以空字符结尾的字符串来调用它们,并返回一个size_t ➐。
注意
使用function可能会带来运行时开销。出于技术原因,function可能需要进行动态分配以存储可调用对象。编译器也很难优化掉function调用,因此你通常会遭遇间接函数调用。间接函数调用需要额外的指针解引用。
主函数和命令行
所有 C++程序必须包含一个名为main的全局函数。这个函数被定义为程序的入口点,即程序启动时调用的函数。程序在启动时可以接受任何数量的环境提供的参数,这些参数称为命令行参数。
用户通过命令行参数向程序传递信息,以定制程序行为。当你执行命令行程序时,你可能已经使用过此功能,例如在执行copy(在 Linux 中为cp)命令时:
$ copy file_a.txt file_b.txt
当调用这个命令时,你指示程序通过将这些值作为命令行参数传递,将file_a.txt复制到file_b.txt。就像你可能习惯的命令行程序一样,你可以将值作为命令行参数传递给 C++程序。
你可以通过如何声明main来选择你的程序是否处理命令行参数。
三个主要的重载
你可以通过向main声明添加参数来访问命令行参数。
main有三种有效的重载形式,如清单 9-28 所示。
int main(); ➊
int main(int argc, char* argv[]); ➋
int main(int argc, char* argv[], impl-parameters); ➌
清单 9-28:main的有效重载
第一个重载 ➊ 不接受任何参数,这就是你在本书中迄今为止使用main()的方式。如果你想忽略程序提供的任何参数,使用这种形式。
第二个重载 ➋ 接受两个参数,argc和argv。第一个参数argc是一个非负数,对应于argv中元素的数量。环境会自动计算这个值:你不需要为argc提供元素数量。第二个参数argv是一个指向以空字符结尾的字符串的指针数组,对应于从执行环境传递的一个参数。
第三个重载 ➌:是第二个重载 ➋:的扩展,它接受任意数量的额外实现参数。这样,目标平台可以向程序提供一些附加参数。在现代桌面环境中,实现参数并不常见。
通常,操作系统会将程序可执行文件的完整路径作为第一个命令行参数传递。这种行为取决于你的操作环境。在 macOS、Linux 和 Windows 上,执行文件的路径是第一个参数。该路径的格式依赖于操作系统。(第十七章深入讨论了文件系统。)
探索程序参数
让我们构建一个程序,探索操作系统如何将参数传递给你的程序。清单 9-29 打印命令行参数的数量,然后逐行打印每个参数的索引和值。
#include <cstdio>
#include <cstdint>
int main(int argc, char** argv) { ➊
printf("Arguments: %d\n", argc); ➋
for(size_t i{}; i<argc; i++) {
printf("%zu: %s\n", i, argv[i]); ➌
}
}
清单 9-29:一个打印命令行参数的程序。将此程序编译为list_929。
你使用argc/argv重载来声明main,这使得命令行参数可以传递给你的程序 ➊。首先,通过argc打印命令行参数的数量 ➋。然后,你遍历每个参数,打印它的索引和值 ➌。
让我们看看一些示例输出(在 Windows 10 x64 上)。这是一次程序调用:
$ list_929 ➊
Arguments: 1 ➋
0: list_929.exe ➌
在这里,除了程序的名称list_929 ➊之外,你没有提供其他命令行参数。(根据你编译清单的方式,你应该将此替换为你的可执行文件的名称。)在一台 Windows 10 x64 机器上,结果是程序接收到一个参数 ➋,即可执行文件的名称 ➌。
这里是另一次调用:
$ list_929 Violence is the last refuge of the incompetent. ➊
Arguments: 9
0: list_929.exe
1: Violence
2: is
3: the
4: last
5: refuge
6: of
7: the
8: incompetent.
在这里,你提供了额外的程序参数:Violence is the last refuge of the incompetent. ➊。从输出中可以看出,Windows 将命令行按空格拆分,结果是总共有九个参数。
在主要的桌面操作系统中,你可以通过将短语用引号括起来来强制操作系统将其视为单一参数,如下所示:
$ list_929 "Violence is the last refuge of the incompetent."
Arguments: 2
0: list_929.exe
1: Violence is the last refuge of the incompetent.
一个更复杂的例子
现在你已经了解了如何处理命令行输入,接下来我们考虑一个更复杂的例子。直方图是一种显示分布相对频率的图示。让我们构建一个程序,计算命令行参数中字母分布的直方图。
从两个辅助函数开始,这两个函数判断给定的char是否是大写字母或小写字母:
constexpr char pos_A{ 65 }, pos_Z{ 90 }, pos_a{ 97 }, pos_z{ 122 };
constexpr bool within_AZ(char x) { return pos_A <= x && pos_Z >= x; } ➊
constexpr bool within_az(char x) { return pos_a <= x && pos_z >= x; } ➋
pos_A, pos_Z, pos_a和pos_z常量分别包含字母 A、Z、小写字母 a 和 z 的 ASCII 值(参见表 2-4 中的 ASCII 表)。within_AZ函数通过判断某个char x的值是否介于pos_A和pos_Z之间(包含边界)来确定它是否是大写字母 ➊。within_az函数对小写字母执行相同的操作 ➋。
现在你已经有了一些处理命令行的 ASCII 数据的元素,让我们构建一个AlphaHistogram类,它可以接受命令行元素并存储字符频率,如清单 9-30 所示。
struct AlphaHistogram {
void ingest(const char* x); ➊
void print() const; ➋
private:
size_t counts[26]{}; ➌
};
清单 9-30:一个接受命令行元素的AlphaHistogram
AlphaHistogram将把每个字母的频率存储在counts数组中 ➌。每当构造一个AlphaHistogram时,这个数组会初始化为零。ingest方法将接受一个以空字符结束的字符串并适当地更新counts ➊。然后,print方法将显示存储在counts中的直方图信息 ➋。
首先,考虑清单 9-31 中ingest方法的实现。
void AlphaHistogram::ingest(const char* x) {
size_t index{}; ➊
while(const auto c = x[index]) { ➋
if (within_AZ(c)) counts[c - pos_A]++; ➌
else if (within_az(c)) counts[c - pos_a]++; ➍
index++; ➎
}
}
清单 9-31:ingest 方法的实现
因为 x 是一个以 null 结尾的字符串,你事先不知道它的长度。所以,你初始化一个 index 变量 ➊,并使用 while 循环一次提取一个 char c ➋。当 c 为 null 时,循环终止,这意味着字符串的结束。在循环内部,你使用 within_AZ 辅助函数判断 c 是否为大写字母 ➌。如果是,你将 pos_A 从 c 中减去,这样就能将大写字母标准化到 0 到 25 的区间,以便与 counts 对应。对于小写字母,你使用 within_az 辅助函数 ➍ 进行同样的检查,并在 c 为小写字母时更新 counts。如果 c 既不是大写字母也不是小写字母,counts 不受影响。最后,在继续循环前,你递增 index ➎。
现在,考虑如何 打印 counts,如 清单 9-32 所示。
void AlphaHistogram::print() const {
for(auto index{ pos_A }; index <= pos_Z; index++) { ➊
printf("%c: ", index); ➋
auto n_asterisks = counts[index - pos_A]; ➌
while (n_asterisks--) printf("*"); ➍
printf("\n"); ➎
}
}
清单 9-32:print 方法的实现
为了打印直方图,你需要循环遍历从 A 到 Z 的每个字母 ➊。在循环内部,首先打印 index 字母 ➋,然后通过从 counts 中提取正确的字母来确定打印多少个星号 ➌。你使用 while 循环 ➍ 打印正确数量的星号,最后打印一个换行符 ➎。
清单 9-33 展示了 AlphaHistogram 的应用。
#include <cstdio>
#include <cstdint>
constexpr char pos_A{ 65 }, pos_Z{ 90 }, pos_a{ 97 }, pos_z{ 122 };
constexpr bool within_AZ(char x) { return pos_A <= x && pos_Z >= x; }
constexpr bool within_az(char x) { return pos_a <= x && pos_z >= x; }
struct AlphaHistogram {
--snip--
};
int main(int argc, char** argv) {
AlphaHistogram hist;
for(size_t i{ 1 }; i<argc; i++) { ➊
hist.ingest(argv[i]); ➋
}
hist.print(); ➌
}
--------------------------------------------------------------------------
$ list_933 The quick brown fox jumps over the lazy dog
A: *
B: *
C: *
D: *
E: ***
F: *
G: *
H: **
I: *
J: *
K: *
L: *
M: *
N: *
O: ****
P: *
Q: *
R: **
S: *
T: **
U: **
V: *
W: *
X: *
Y: *
Z: *
清单 9-33:一个展示 AlphaHistogram 的程序
在程序名称之后,你遍历每个命令行参数 ➊,并将每个参数传入 AlphaHistogram 对象的 ingest 方法 ➋。所有参数都处理完后,你打印出 histogram ➌。每一行对应一个字母,星号显示对应字母的绝对频率。如你所见,短语 The quick brown fox jumps over the lazy dog 包含了英语字母表中的每个字母。
退出状态
main 函数可以返回一个 int,表示程序的退出状态。返回值的含义由环境定义。例如,在现代桌面系统中,返回值为零表示程序执行成功。如果没有显式给出 return 语句,编译器会自动添加一个隐式的 return 0。
总结
本章深入探讨了函数,包括如何声明和定义函数,如何使用众多关键字修改函数行为,如何指定返回类型,如何进行重载解析,以及如何处理可变数量的参数。在讨论了如何获取指向函数的指针之后,你还学习了 lambda 表达式及其与函数对象的关系。然后,你了解了程序的入口点——main 函数,以及如何获取命令行参数。
练习
9-1. 实现一个 fold 函数模板,原型如下:
template <typename Fn, typename In, typename Out>
constexpr Out fold(Fn function, In* input, size_t length, Out initial);
例如,你的实现必须支持以下用法:
int main() {
int data[]{ 100, 200, 300, 400, 500 };
size_t data_len = 5;
auto sum = fold([](auto x, auto y) { return x + y; }, data, data_len,
0);
printf("Sum: %d\n", sum);
}
sum的值应该是 1,500。使用fold来计算以下量:最大值、最小值和大于 200 的元素数量。
9-2. 实现一个程序,接受任意数量的命令行参数,计算每个参数的字符长度,并打印出参数长度分布的直方图。
9-3. 实现一个all函数,其原型如下:
template <typename Fn, typename In>
constexpr bool all(Fn function, In* input, size_t length);
Fn函数类型是一个谓词,支持bool operator()(In)。你的all函数必须测试function是否对input的每个元素返回true。如果是,返回true;否则,返回false。
例如,你的实现必须支持以下用法:
int main() {
int data[]{ 100, 200, 300, 400, 500 };
size_t data_len = 5;
auto all_gt100 = all([](auto x) { return x > 100; }, data, data_len);
if(all_gt100) printf("All elements greater than 100.\n");
}
进一步阅读
-
C++中的函数式编程:如何通过函数式技巧提升你的 C++程序,作者:Ivan Čukić(Manning,2019)
-
清洁代码:敏捷软件工艺手册,作者:Robert C. Martin(Pearson Education,2009)
第十二章:**第二部分
C++库和框架**
*NEO: 为什么我的眼睛会疼?
MORPHEUS: 你以前从未使用过它们。*
—《黑客帝国》
第二部分让你接触到 C++库和框架的世界,包括 C++标准库(stdlib)和 Boost 库(Boost)。后者是一个开源志愿者项目,旨在制作急需的 C++库。
在第十章中,你将参观几个测试和模拟框架。与第一部分的主要区别是,第二部分中的大部分示例是单元测试。这些测试让你练习测试代码,而单元测试通常比基于printf的示例程序更简洁和富有表现力。
第十一章广泛地讨论了智能指针,它们管理动态对象并促进任何编程语言中最强大的资源管理模型。
第十二章探讨了许多实现常见编程任务的实用工具。
第十三章深入探讨了一个庞大的容器套件,这些容器可以存储和操作对象。
第十四章解释了迭代器,这是所有容器提供的公共接口。
第十五章回顾了字符串和字符串操作,它们用于存储和操作人类语言数据。
第十六章讨论了流,这是执行输入输出操作的一种现代方法。
第十七章阐明了文件系统库,它提供了与文件系统交互的功能。
第十八章概述了令人眼花缭乱的算法阵列,这些算法查询并操作迭代器。
第十九章概述了并发的主要方法,它使得你的程序能够同时运行多个执行线程。
第二十章回顾了 Boost ASIO,这是一个跨平台的网络和低级输入/输出编程库,采用异步方法。
第二十一章提供了几个应用框架,这些框架实现了日常应用编程中所需的标准结构。
第二部分可以作为一个快速参考,但你的首次阅读应该是顺序进行的。
第十三章:测试**
“[电脑]是怎么拿到恩德兄弟的照片并把它放进这个《仙境》程序的图形中的?” “格拉夫上校,我当时不在现场,程序是怎么写的我不知道。我只知道电脑从未带任何人来过这个地方。”
—奥森·斯科特·卡德,《安德的游戏》

有许多方法可以用来测试你的软件。所有这些测试方法的共同点是,每个测试都会为你的代码提供某种输入,你需要评估测试的输出是否合适。环境的性质、调查的范围和评估的形式在不同的测试类型中差异很大。本章介绍了如何使用几种不同的框架进行测试,但这些内容可以扩展到其他测试方法。在开始之前,让我们快速了解几种不同的测试类型。
单元测试
单元测试验证一组集中的、凝聚的代码——一个单元,比如一个函数或类——是否按程序员的意图正常运行。好的单元测试会将被测试的单元与其依赖项隔离开来。有时这可能会很困难:单元可能依赖于其他单元。在这种情况下,你可以使用模拟(mocks)来代替这些依赖项。模拟是你在测试期间专门使用的虚拟对象,用于精确控制单元的依赖项在测试中的表现。模拟还可以记录单元与它们的交互方式,这样你就可以测试单元是否按预期与依赖项交互。你还可以使用模拟来模拟一些罕见事件,比如系统内存不足,通过编程让它们抛出异常。
集成测试
测试多个单元一起工作被称为集成测试。集成测试也可以指测试软件与硬件之间的交互,这是系统程序员常常涉及的内容。集成测试是单元测试之上的一个重要层级,因为它确保你编写的软件能作为一个系统协同工作。这些测试是对单元测试的补充,而不是替代。
验收测试
验收测试确保你的软件符合所有客户的要求。高效的软件团队可以利用验收测试来指导开发。当所有的验收测试通过时,你的软件就可以交付了。因为这些验收测试成为代码库的一部分,所以它们在重构或特性回归时提供了内建的保护,防止在添加新特性时破坏现有功能。
性能测试
性能测试评估软件是否满足有效性要求,例如执行速度或内存/功耗。优化代码本质上是一个经验性的过程。你可以(也应该)有一些关于哪些代码部分可能导致性能瓶颈的想法,但除非你进行测量,否则无法确定。并且,除非你再次进行测量,否则无法知道你为了优化所做的代码修改是否真的提升了性能。你可以使用性能测试来给你的代码添加测量功能,并提供相关的度量。仪表化是一种测量产品性能、检测错误并记录程序执行方式的技术。有时,客户有严格的性能要求(例如,计算不能超过 100 毫秒,或系统不能分配超过 1MB 的内存)。你可以自动化测试这些要求,并确保未来的代码更改不会违反它们。
代码测试可能是一个抽象、枯燥的话题。为了避免这种情况,下一部分将介绍一个扩展示例,为讨论提供背景。
扩展示例:刹车
假设你正在为一辆自动驾驶汽车编写软件。你们团队的软件非常复杂,涉及数十万行代码。整个软件解决方案由多个二进制文件组成。为了部署你的软件,你必须将二进制文件上传到汽车(这是一个相对耗时的过程)。对代码进行修改、编译、上传并在实际车辆中执行,每次迭代需要几个小时。
写出所有车辆软件的庞大任务被分解为多个团队。每个团队负责一个服务,例如方向盘控制、音视频或车辆检测。服务通过服务总线相互交互,其中每个服务发布事件。其他服务根据需要订阅这些事件。这种设计模式被称为服务总线架构。
你的团队负责自动刹车服务。该服务必须判断是否即将发生碰撞,如果发生碰撞,便指示汽车刹车。你的服务订阅了两种事件类型:SpeedUpdate类,通知你车辆的速度已经变化,以及CarDetected类,通知你前方有其他车辆被检测到。每当检测到即将发生碰撞时,你的系统负责向服务总线发布BrakeCommand。这些类出现在 Listing 10-1 中。
struct SpeedUpdate {
double velocity_mps;
};
struct CarDetected {
double distance_m;
double velocity_mps;
};
struct BrakeCommand {
double time_to_collision_s;
};
Listing 10-1: 你的服务所交互的 POD 类
你将使用具有publish方法的ServiceBus对象发布BrakeCommand:
struct ServiceBus {
void publish(const BrakeCommand&);
--snip--
};
首席架构师希望你暴露一个 observe 方法,以便你可以订阅服务总线上的 SpeedUpdate 和 CarDetected 事件。你决定构建一个名为 AutoBrake 的类,并在程序的入口点初始化它。AutoBrake 类将保留对服务总线 publish 方法的引用,并通过其 observe 方法订阅 SpeedUpdate 和 CarDetected 事件,如清单 10-2 所示。
template <typename T>
struct AutoBrake {
AutoBrake(const T& publish);
void observe(const SpeedUpdate&);
void observe(const CarDetected&);
private:
const T& publish;
--snip--
};
清单 10-2:提供自动刹车服务的 AutoBrake 类
图 10-1 总结了服务总线 ServiceBus、自动刹车系统 AutoBrake 和其他服务之间的关系。

图 10-1:服务与服务总线之间交互的高级示意图
该服务集成到汽车的软件中,生成类似于清单 10-3 中的代码。
--snip--
int main() {
ServiceBus bus;
AutoBrake auto_brake{ [&bus➊] (const auto& cmd) {
bus.publish(cmd); ➋
}
};
while (true) { // Service bus's event loop
auto_brake.observe(SpeedUpdate{ 10L }); ➌
auto_brake.observe(CarDetected{ 250L, 25L }); ➍
}
}
清单 10-3:使用 AutoBrake 服务的示例入口点
你通过一个 lambda 来构造 AutoBrake,它捕获对 ServiceBus 的引用 ➊。AutoBrake 何时决定刹车的所有细节对其他团队完全隐藏。服务总线调解所有服务间的通信。你只是将 AutoBrake 的任何命令直接传递给 ServiceBus ➋。在事件循环中,ServiceBus 可以将 SpeedUpdate ➌ 和 CarDetected 对象 ➍ 传递给你的 auto_brake 的 observe 方法。
实现 AutoBrake
实现 AutoBrake 的概念上简单的方法是通过编写一些代码、编译生成的二进制文件、将其上传到汽车并手动测试功能来进行迭代。这个方法可能会导致程序(和汽车)崩溃,并浪费大量时间。更好的方法是编写代码、编译单元测试二进制文件,并在桌面开发环境中运行。你可以更快速地在这些步骤之间迭代;一旦你对所编写的代码是否按预期工作有了合理的信心,就可以进行带有实际汽车的手动测试。
单元测试二进制文件 将是一个简单的控制台应用程序,针对桌面操作系统。在单元测试二进制文件中,你将运行一套单元测试,将特定输入传递给 AutoBrake 并断言它产生预期的结果。
在与管理团队商讨后,你收集了以下需求:
-
AutoBrake将把汽车的初始速度视为零。 -
AutoBrake应该具有可配置的灵敏度阈值,该阈值基于预测的碰撞发生前的秒数。灵敏度不得低于 1 秒,默认灵敏度为 5 秒。 -
AutoBrake必须在SpeedUpdate观察之间保存汽车的速度。 -
每次
AutoBrake观察到CarDetected事件时,如果预测的碰撞时间少于配置的灵敏度阈值,它必须发布一个BrakeCommand。
因为你有这样一个完善的需求列表,下一步是尝试使用 测试驱动开发(TDD) 实现自动刹车服务。
注意
因为本书是关于 C++ 的,而不是物理学,你的 AutoBrake 仅在车子直接在你面前时起作用。
测试驱动开发
在单元测试采用的历史过程中,曾有一些勇敢的软件工程师想:“如果我知道我将为这个类编写一堆单元测试,为什么不先编写测试呢?”这种编写软件的方式,被称为 TDD,它是软件工程界一场伟大的宗教战争的基石。Vim 还是 Emacs?制表符还是空格?使用 TDD 还是不使用 TDD?本书谦虚地避免对这些问题发表评论。但我们将使用 TDD,因为它与单元测试的讨论非常契合。
TDD 的优势
编写一个测试来编码需求在实现解决方案之前,是 TDD 背后的基本理念。支持者认为,以这种方式编写的代码通常更模块化、健壮、清晰且设计良好。编写良好的测试是为其他开发人员记录代码的最佳方式。一个好的测试套件是一个完全可工作的示例集,永远不会失去同步。它可以防止在添加新特性时出现功能回退。
单元测试还可以作为提交 bug 报告的绝佳方式,方法是编写一个失败的单元测试。一旦 bug 被修复,它将始终保持修复状态,因为单元测试和修复 bug 的代码会成为测试套件的一部分。
红-绿-重构
TDD 实践者有一个口号:红色、绿色、重构。红色是第一步,它意味着实现一个失败的测试。这么做有几个原因,最主要的是确保你真的在测试某个东西。你可能会惊讶于,设计一个没有任何断言的测试是多么常见。接下来,你实现使测试通过的代码。仅此而已。这将把测试从红色变为绿色。现在,你已经有了工作的代码和通过的测试,你可以重构你的生产代码。重构是指在不改变功能的前提下重组现有代码。例如,你可能会找到一种更优雅的方式来编写相同的代码,或者用第三方库替代你的代码,或者重写你的代码以获得更好的性能特征。
如果你不小心破坏了某些东西,你会立即知道,因为你的测试套件会告诉你。然后,你继续使用 TDD 实现类的其余部分。接下来,你可以开始处理碰撞阈值。
编写一个骨架 AutoBrake 类
在你编写测试之前,你需要编写一个 骨架类,它实现了一个接口,但没有提供任何功能。在 TDD 中,这非常有用,因为如果没有你正在测试的类的外壳,你无法编译测试。
请参考 示例 10-4 中的骨架 AutoBrake 类。
struct SpeedUpdate {
double velocity_mps;
};
struct CarDetected {
double distance_m;
double velocity_mps;
};
struct BrakeCommand {
double time_to_collision_s;
};
template <typename T>
struct AutoBrake {
AutoBrake(const T& publish➊) : publish{ publish } { }
void observe(const SpeedUpdate& cd) { } ➋
void observe(const CarDetected& cd) { } ➌
void set_collision_threshold_s(double x) { ➍
collision_threshold_s = x;
}
double get_collision_threshold_s() const { ➎
return collision_threshold_s;
}
double get_speed_mps() const { ➏
return speed_mps;
}
private:
double collision_threshold_s;
double speed_mps;
const T& publish;
};
示例 10-4:一个骨架 AutoBrake 类
AutoBrake类有一个构造函数,接受模板参数publish ➊,并将其保存在一个const成员中。一个需求指出,你将使用BrakeCommand调用publish。使用模板参数T允许你针对任何支持用BrakeCommand调用的类型编写通用代码。你提供了两个不同的观察函数:每个函数订阅你想要关注的事件类型 ➋➌。由于这只是一个骨架类,函数体中没有任何指令。你只需要一个暴露适当方法并能够编译通过的类。因为这些方法返回void,你甚至不需要返回语句。
你实现了一个 setter ➍和 getter ➎方法。这些方法调解与私有成员变量collision_threshold_s的交互。一个需求意味着关于collision_threshold_s有效值的类不变式。因为该值在构造后可能会发生变化,你不能仅仅通过构造函数来建立类的不变式。你需要一种方式来在整个对象生命周期内强制执行这个类的不变式。你可以使用 setter 方法在类设置成员的值之前进行验证。getter 方法允许你读取collision_threshold_s的值,但不允许修改它。这强制执行了一种外部常量性。
最后,你有一个speed_mps ➏的 getter 方法,但没有对应的 setter 方法。这类似于将speed_mps设为公共成员,唯一的重要区别是如果它是公共的,外部类就可以修改speed_mps。
断言:单元测试的构建块
单元测试最重要的组成部分是断言,它检查某个条件是否满足。如果条件不满足,则相应的测试将失败。
清单 10-5 实现了一个assert_that函数,当某个布尔statement为false时,它会抛出一个带有错误信息的异常。
#include <stdexcept>
constexpr void assert_that(bool statement, const char* message) {
if (!statement➊) throw std::runtime_error{ message }; ➋
}
int main() {
assert_that(1 + 2 > 2, "Something is profoundly wrong with the universe."); ➌
assert_that(24 == 42, "This assertion will generate an exception."); ➍
}
--------------------------------------------------------------------------
terminate called after throwing an instance of 'std::runtime_error'
what(): This assertion will generate an exception. ➍
清单 10-5:一个演示assert_that的程序(输出来自 GCC v7.1.1 编译的二进制文件)
assert_that函数检查statement ➊参数是否为false,如果是,则抛出带有message参数 ➋的异常。第一个断言检查1 + 2 > 2,该断言通过 ➌。第二个断言检查24 == 42,该断言失败并抛出一个未捕获的异常 ➍。
需求:初始速度为零
假设汽车的初始速度为零。在AutoBrake中实现此功能之前,你需要编写一个单元测试来编码这一需求。你将把单元测试实现为一个函数,创建一个AutoBrake对象,执行类中的方法,并对结果进行断言。清单 10-6 包含了一个编码初始速度为零的需求的单元测试。
void initial_speed_is_zero() {
AutoBrake auto_brake{ [](const BrakeCommand&) {} }; ➊
assert_that(auto_brake.get_speed_mps() == 0L, "speed not equal 0"); ➋
}
清单 10-6:一个编码初始速度为零的单元测试
你首先构造一个带有空BrakeCommand publish函数的AutoBrake ➊。这个单元测试只关心AutoBrake的初始车速。因为这个单元测试不关心AutoBrake如何或何时发布BrakeCommand,你给它提供一个最简单的参数,这样仍然可以编译。
注意
单元测试的一个微妙但重要的特点是,如果你不关心待测试单元的某些依赖项,你可以提供一个空的实现,执行一些无害的默认行为。这个空实现有时被称为桩(stub)。
在initial_speed_is_zero中,你只想断言汽车的初速为零,其他什么都不做 ➋。你使用 getter get_speed_mps并将返回值与0进行比较。就这样;如果初速不是零,assert会抛出异常。
现在你需要一种方法来运行单元测试。
测试工具
测试工具是执行单元测试的代码。你可以制作一个测试工具,它会调用你的单元测试函数,如initial_speed_is_zero,并优雅地处理失败的断言。参考列表 10-7 中的测试工具run_test。
#include <exception>
--snip--
void run_test(void(*unit_test)(), const char* name) {
try {
unit_test(); ➊
printf("[+] Test %s successful.\n", name); ➋
} catch (const std::exception& e) {
printf("[-] Test failure in %s. %s.\n", name, e.what()); ➌
}
}
列表 10-7:测试工具
run_test工具接受一个名为unit_test的函数指针作为单元测试,并在try-catch语句中调用它 ➊。只要unit_test没有抛出异常,run_test将打印一条友好的消息,表示单元测试通过,然后返回 ➋。如果抛出任何exception,测试失败并打印一条不赞同的消息 ➌。
为了创建一个单元测试程序来运行你所有的单元测试,你将run_test测试工具放置在新程序的main函数中。整个单元测试程序如下所示:列表 10-8。
#include <stdexcept>
struct SpeedUpdate {
double velocity_mps;
};
struct CarDetected {
double distance_m;
double velocity_mps;
};
struct BrakeCommand {
double time_to_collision_s;
};
template <typename T>
struct AutoBrake {
--snip--
};
constexpr void assert_that(bool statement, const char* message) {
if (!statement) throw std::runtime_error{ message };
}
void initial_speed_is_zero() {
AutoBrake auto_brake{ [](const BrakeCommand&) {} };
assert_that(auto_brake.get_speed_mps() == 0L, "speed not equal 0");
}
void run_test(void(*unit_test)(), const char* name) {
try {
unit_test();
printf("[+] Test %s successful.\n", name);
} catch (const std::exception& e) {
printf("[-] Test failure in %s. %s.\n", name, e.what());
}
}
int main() {
run_test(initial_speed_is_zero, "initial speed is 0"); ➊
}
--------------------------------------------------------------------------
[-] Test failure in initial speed is 0\. speed not equal 0\. ➊
列表 10-8:单元测试程序
当你编译并运行这个单元测试二进制文件时,你可以看到单元测试initial_speed_is_zero失败,并显示一条有用的消息 ➊。
注意
因为列表 10-8 中的AutoBrake成员speed_mps没有初始化,所以该程序具有未定义的行为。实际上并不能确定测试是否会失败。当然,解决方案是,你不应该编写具有未定义行为的程序。
让测试通过
为了让initial_speed_is_zero通过,所需的唯一操作是将speed_mps在AutoBrake的构造函数中初始化为零:
template <typename T>
struct AutoBrake {
AutoBrake(const T& publish) : speed_mps{}➊, publish{ publish } { }
--snip--
};
只需将初始化值设置为零 ➊。现在,如果你更新、编译并运行列表 10-8 中的单元测试程序,你会看到更加愉快的输出:
[+] Test initial speed is 0 successful.
要求:默认的碰撞阈值为五
默认的碰撞阈值需要是 5。参考列表 10-9 中的单元测试。
void initial_sensitivity_is_five() {
AutoBrake auto_brake{ [](const BrakeCommand&) {} };
assert_that(auto_brake.get_collision_threshold_s() == 5L,
"sensitivity is not 5");
}
列表 10-9:一个编码了初速必须为零要求的单元测试
你可以将此测试插入到测试程序中,如列表 10-10 所示。
--snip--
int main() {
run_test(initial_speed_is_zero, "initial speed is 0");
run_test(initial_sensitivity_is_five, "initial sensitivity is 5");
}
--------------------------------------------------------------------------
[+] Test initial speed is 0 successful.
[-] Test failure in initial sensitivity is 5\. sensitivity is not 5.
清单 10-10:将initial-sensitivity-is-5测试添加到测试工具中
正如预期的那样,清单 10-10 显示initial_speed_is_zero仍然通过,而新的测试initial_sensitivity_is_five失败。
现在,让它通过。像清单 10-11 中所示,向AutoBrake添加适当的成员初始化器。
template <typename T>
struct AutoBrake {
AutoBrake(const T& publish)
: collision_threshold_s{ 5 }, ➊
speed_mps{},
publish{ publish } { }
--snip--
};
清单 10-11:更新AutoBrake以满足碰撞阈值要求
新的成员初始化器 ➊ 将collision_threshold_s设置为 5。重新编译测试程序后,你可以看到initial_sensitivity_is_five现在通过了:
[+] Test initial speed is 0 successful.
[+] Test initial sensitivity is 5 successful.
接下来,处理类的不变量,即灵敏度必须大于 1。
要求:灵敏度必须始终大于 1
为了使用异常编码灵敏度验证错误,你可以构建一个测试,期望当collision_threshold_s被设置为小于 1 的值时抛出异常,正如清单 10-12 所示。
void sensitivity_greater_than_1() {
AutoBrake auto_brake{ [](const BrakeCommand&) {} };
try {
auto_brake.set_collision_threshold_s(0.5L); ➊
} catch (const std::exception&) {
return; ➋
}
assert_that(false, "no exception thrown"); ➌
}
清单 10-12:一个编码灵敏度始终大于 1 要求的测试
你期望当调用auto_brake的set_collision_threshold_s方法并传入值 0.5 时,它会抛出异常 ➊。如果它确实抛出异常,你会捕获该异常并立即从测试中返回 ➋。如果set_collision_threshold_s没有抛出异常,你会使用no excep``tion thrown的消息来失败一个断言 ➌。
接下来,按照清单 10-13 中的示范,将sensitivity_greater_than_1添加到测试工具中。
--snip--
int main() {
run_test(initial_speed_is_zero, "initial speed is 0");
run_test(initial_sensitivity_is_five, "initial sensitivity is 5");
run_test(sensitivity_greater_than_1, "sensitivity greater than 1"); ➊
}
--------------------------------------------------------------------------
[+] Test initial speed is 0 successful.
[+] Test initial sensitivity is 5 successful.
[-] Test failure in sensitivity greater than 1\. no exception thrown. ➊
清单 10-13:将set_collision_threshold_s添加到测试工具中
正如预期的那样,新的单元测试失败 ➊。
你可以实现验证,使得测试通过,正如清单 10-14 所示。
#include <exception>
--snip--
template <typename T>
struct AutoBrake {
--snip--
void set_collision_threshold_s(double x) {
if (x < 1) throw std::exception{ "Collision less than 1." };
collision_threshold_s = x;
}
}
清单 10-14:更新AutoBrake的set_collision_threshold方法以验证其输入
重新编译并执行单元测试套件后,测试变为绿色:
[+] Test initial speed is 0 successful.
[+] Test initial sensitivity is 5 successful.
[+] Test sensitivity greater than 1 successful.
接下来,你想确保AutoBrake在每次SpeedUpdate之间保存汽车的速度。
要求:在更新之间保存汽车的速度
清单 10-15 中的单元测试编码了AutoBrake保存汽车速度的要求。
void speed_is_saved() {
AutoBrake auto_brake{ [](const BrakeCommand&) {} }; ➊
auto_brake.observe(SpeedUpdate{ 100L }); ➋
assert_that(100L == auto_brake.get_speed_mps(), "speed not saved to 100"); ➌
auto_brake.observe(SpeedUpdate{ 50L });
assert_that(50L == auto_brake.get_speed_mps(), "speed not saved to 50");
auto_brake.observe(SpeedUpdate{ 0L });
assert_that(0L == auto_brake.get_speed_mps(), "speed not saved to 0");
}
清单 10-15:编码AutoBrake保存汽车速度的要求
在构建了一个AutoBrake ➊后,你将velocity_mps等于 100 的SpeedUpdate传递给它的observe方法 ➋。接下来,你通过get_speed_mps方法从auto_brake中获取速度,并期望它等于 100 ➌。
注意
通常来说,每个测试应该有一个单独的断言。这个测试违反了对规则的最严格解释,但并没有违背其精神。所有的断言都在检查同一个、一致的要求,那就是每当观察到SpeedUpdate时,速度都会被保存。
你以通常的方式将清单 10-15 中的测试添加到测试工具中,正如清单 10-16 所示。
--snip--
int main() {
run_test(initial_speed_is_zero, "initial speed is 0");
run_test(initial_sensitivity_is_five, "initial sensitivity is 5");
run_test(sensitivity_greater_than_1, "sensitivity greater than 1");
run_test(speed_is_saved, "speed is saved"); ➊
}
--------------------------------------------------------------------------
[+] Test initial speed is 0 successful.
[+] Test initial sensitivity is 5 successful.
[+] Test sensitivity greater than 1 successful.
[-] Test failure in speed is saved. speed not saved to 100\. ➊
清单 10-16:将节速单元测试添加到测试工具中
不出所料,新的测试失败了➊。为了让这个测试通过,你需要实现适当的observe函数:
template <typename T>
struct AutoBrake {
--snip--
void observe(const SpeedUpdate& x) {
speed_mps = x.velocity_mps; ➊
}
};
你从SpeedUpdate中提取velocity_mps并将其存储到speed_mps成员变量中➊。重新编译测试二进制文件后,单元测试通过了:
[+] Test initial speed is 0 successful.
[+] Test initial sensitivity is 5 successful.
[+] Test sensitivity greater than 1 successful.
[+] Test speed is saved successful.
最后,你需要确保AutoBrake能够计算出正确的碰撞时间,并且在适当的时候,使用publish函数发布一个BrakeCommand。
需求:当检测到碰撞时,AutoBrake 发布 BrakeCommand
计算碰撞时间的相关方程直接来源于高中物理。首先,你计算你车与检测到的车之间的相对速度:

如果你的相对速度是恒定的且为正,那么车辆最终会发生碰撞。你可以通过以下方式计算碰撞的时间:

如果 Time[Collision]大于零并且小于或等于collision_threshold_s,你就会调用publish并发布一个BrakeCommand。清单 10-17 中的单元测试将碰撞阈值设置为 10 秒,并观察那些指示碰撞的事件。
void alert_when_imminent() {
int brake_commands_published{}; ➊
AutoBrake auto_brake{
&brake_commands_published➋ {
brake_commands_published++; ➌
} };
auto_brake.set_collision_threshold_s(10L); ➍
auto_brake.observe(SpeedUpdate{ 100L }); ➎
auto_brake.observe(CarDetected{ 100L, 0L }); ➏
assert_that(brake_commands_published == 1, "brake commands published not
one"); ➐
}
清单 10-17:刹车事件的单元测试
在这里,你将本地变量brake_commands_published初始化为零➊。这个变量用于追踪publish回调被调用的次数。你将这个本地变量通过引用传递给用于构造auto_brake的 lambda 表达式➋。注意,你会递增brake_commands_published➌。由于 lambda 是通过引用捕获的,因此你可以在单元测试中稍后检查brake_commands_published的值。接下来,你将set_collision_threshold设置为 10 ➍。你将汽车的速度更新为每秒 100 米➎,然后你检测到一辆距离 100 米的车,速度为 0 米每秒(它已经停止)➏。AutoBrake类应该能判断出 1 秒后会发生碰撞。这应该会触发一个回调,进而递增brake_commands_published。断言 ➐ 确保回调仅发生一次。
在将代码添加到main之后,编译并运行,结果是一个新的红色测试:
[+] Test initial speed is 0 successful.
[+] Test initial sensitivity is 5 successful.
[+] Test sensitivity greater than 1 successful.
[+] Test speed is saved successful.
[-] Test failure in alert when imminent. brake commands published not one.
你可以实现代码来使得这个测试通过。清单 10-18 提供了发布刹车命令所需的所有代码。
template <typename T>
struct AutoBrake {
--snip--
void observe(const CarDetected& cd) {
const auto relative_velocity_mps = speed_mps - cd.velocity_mps; ➊
const auto time_to_collision_s = cd.distance_m / relative_velocity_mps; ➋
if (time_to_collision_s > 0 && ➌
time_to_collision_s <= collision_threshold_s ➍) {
publish(BrakeCommand{ time_to_collision_s }); ➎
}
}
};
清单 10-18:实现刹车功能的代码
首先,你计算相对速度➊。接下来,使用这个值来计算碰撞时间➋。如果这个值为正➌且小于或等于碰撞阈值➍,你就发布一个BrakeCommand➎。
重新编译并运行单元测试套件后,测试通过了:
[+] Test initial speed is 0 successful.
[+] Test initial sensitivity is 5 successful.
[+] Test sensitivity greater than 1 successful.
[+] Test speed is saved successful.
[+] Test alert when imminent successful.
最后,你需要检查AutoBrake是否不会在碰撞发生时间晚于collision_threshold_s时调用publish发布BrakeCommand。你可以重新利用alert_when_imminent单元测试,如清单 10-19 中所示。
void no_alert_when_not_imminent() {
int brake_commands_published{};
AutoBrake auto_brake{
&brake_commands_published {
brake_commands_published++;
} };
auto_brake.set_collision_threshold_s(2L);
auto_brake.observe(SpeedUpdate{ 100L });
auto_brake.observe(CarDetected{ 1000L, 50L });
assert_that(brake_commands_published == 0 ➊, "brake command published");
}
清单 10-19:测试如果碰撞不在碰撞阈值内,汽车不会发出BrakeCommand
这改变了设置。你的汽车阈值设置为 2 秒,速度为每秒 100 米。汽车在 1000 米外被检测到,速度为每秒 50 米。AutoBrake类应预测 20 秒内发生碰撞,这超过了 2 秒的阈值。你还更改了断言➊。
在将此测试添加到main并运行单元测试套件后,你得到了以下结果:
[+] Test initial speed is 0 successful.
[+] Test initial sensitivity is 5 successful.
[+] Test sensitivity greater than 1 successful.
[+] Test speed is saved successful.
[+] Test alert when imminent successful.
[+] Test no alert when not imminent successful. ➊
对于这个测试用例,你已经拥有了通过此测试所需的所有代码➊。测试一开始没有失败的测试,违背了红、绿、重构的经典法则,但这没关系。这个测试用例与alert_when_imminent紧密相关。TDD 的重点不是盲目遵循严格的规则。TDD 是一组合理宽松的指南,帮助你编写更好的软件。
添加一个服务总线接口
AutoBrake类有一些依赖:CarDetected、SpeedUpdated和一个通用的依赖于可以调用的publish对象,它接受一个BrakeCommand参数。CarDetected和SpeedUpdated类是简单的数据类型,直接在单元测试中使用很方便。publish对象的初始化稍微复杂一些,但得益于 lambda 表达式,这并不难。
假设你想重构服务总线。你希望接受一个std::function来订阅每个服务,如清单 10-20 中的新IServiceBus接口所示。
#include <functional>
using SpeedUpdateCallback = std::function<void(const SpeedUpdate&)>;
using CarDetectedCallback = std::function<void(const CarDetected&)>;
struct IServiceBus {
virtual ~IServiceBus() = default;
virtual void publish(const BrakeCommand&) = 0;
virtual void subscribe(SpeedUpdateCallback) = 0;
virtual void subscribe(CarDetectedCallback) = 0;
};
清单 10-20:IServiceBus接口
因为IServiceBus是一个接口,你无需了解其实现细节。这是一个很好的解决方案,因为它允许你自己将服务总线接入。但是有一个问题。如何在隔离环境中测试AutoBrake?如果你尝试使用生产总线,你就进入了集成测试领域,而你希望的是易于配置的、独立的单元测试。
模拟依赖
幸运的是,你并不依赖于实现:你依赖于接口。你可以创建一个实现了IServiceBus接口的模拟类,并在AutoBrake中使用它。模拟是你专门为测试依赖于模拟的类而生成的特殊实现。
现在,当你在单元测试中使用AutoBrake时,AutoBrake与模拟对象交互,而不是生产服务总线。因为你完全控制模拟的实现,而且模拟是专为单元测试设计的类,你可以在测试依赖于接口的类时拥有极大的灵活性:
-
你可以捕捉关于模拟被调用的详细信息。这可以包括参数信息和模拟被调用的次数等。
-
你可以在模拟中执行任意计算。
换句话说,您完全控制着AutoBrake的依赖项的输入和输出。当服务总线在publish调用中抛出内存溢出异常时,AutoBrake如何处理?您可以对这一点进行单元测试。AutoBrake注册了多少次SpeedUpdates的回调?同样,您可以对这一点进行单元测试。
清单 10-21 展示了一个简单的模拟类,您可以在单元测试中使用它。
struct MockServiceBus : IServiceBus {
void publish(const BrakeCommand& cmd) override {
commands_published++; ➊
last_command = cmd; ➋
}
void subscribe(SpeedUpdateCallback callback) override {
speed_update_callback = callback; ➌
}
void subscribe(CarDetectedCallback callback) override {
car_detected_callback = callback; ➍
}
BrakeCommand last_command{};
int commands_published{};
SpeedUpdateCallback speed_update_callback{};
CarDetectedCallback car_detected_callback{};
};
清单 10-21:MockServiceBus的定义
publish方法记录每次发布BrakeCommand的次数 ➊ 和发布的last_command ➋。每次AutoBrake向服务总线发布命令时,您会看到MockServiceBus的成员发生更新。您很快就会发现,这允许您对AutoBrake在测试中的行为进行非常强大的断言。您保存了用于订阅服务总线的回调函数 ➌➍。这使您能够通过手动调用这些回调函数来模拟事件。
现在,您可以将注意力集中到重构AutoBrake上。
重构 AutoBrake
清单 10-22 用最小的更改更新了AutoBrake,以使单元测试二进制文件重新编译(但不一定通过!)。
#include <exception>
--snip--
struct AutoBrake { ➊
AutoBrake(IServiceBus& bus) ➋
: collision_threshold_s{ 5 },
speed_mps{} {
}
void set_collision_threshold_s(double x) {
if (x < 1) throw std::exception{ "Collision less than 1." };
collision_threshold_s = x;
}
double get_collision_threshold_s() const {
return collision_threshold_s;
}
double get_speed_mps() const {
return speed_mps;
}
private:
double collision_threshold_s;
double speed_mps;
};
清单 10-22:重构后的AutoBrake骨架,接受IServiceBus引用
请注意,所有的observe函数都已被移除。此外,AutoBrake不再是一个模板 ➊。相反,它在构造函数中接受一个IServiceBus引用 ➋。
您还需要更新您的单元测试,以使测试套件重新编译。一个受 TDD 启发的方法是将所有无法编译的测试注释掉,并更新AutoBrake,直到所有失败的单元测试通过。然后,逐个取消注释每个单元测试。使用新的IServiceBus模拟重新实现每个单元测试,然后更新AutoBrake,使测试通过。
让我们试试吧。
重构单元测试
由于您已更改构造AutoBrake对象的方式,您需要重新实现每个测试。前三个很简单:清单 10-23 只需将模拟对象传入AutoBrake构造函数中。
void initial_speed_is_zero() {
MockServiceBus bus{}; ➊
AutoBrake auto_brake{ bus }; ➋
assert_that(auto_brake.get_speed_mps() == 0L, "speed not equal 0");
}
void initial_sensitivity_is_five() {
MockServiceBus bus{}; ➊
AutoBrake auto_brake{ bus }; ➋
assert_that(auto_brake.get_collision_threshold_s() == 5,
"sensitivity is not 5");
}
void sensitivity_greater_than_1() {
MockServiceBus bus{}; ➊
AutoBrake auto_brake{ bus }; ➋
try {
auto_brake.set_collision_threshold_s(0.5L);
} catch (const std::exception&) {
return;
}
assert_that(false, "no exception thrown");
}
清单 10-23:使用MockServiceBus重新实现的单元测试函数
由于这三个测试处理的功能与服务总线无关,您不需要对AutoBrake进行任何重大更改也不足为奇。您需要做的就是创建一个MockServiceBus ➊ 并将其传递到AutoBrake的构造函数中 ➋。运行单元测试套件后,您将看到以下内容:
[+] Test initial speed is 0 successful.
[+] Test initial sensitivity is 5 successful.
[+] Test sensitivity greater than 1 successful.
接下来,查看speed_is_saved测试。AutoBrake类不再暴露observe函数,但由于你在模拟的服务总线上保存了SpeedUpdateCallback,你可以直接调用回调函数。如果AutoBrake正确订阅了,该回调将更新汽车的速度,并且你将在调用get_speed_mps方法时看到效果。列表 10-24 包含了重构内容。
void speed_is_saved() {
MockServiceBus bus{};
AutoBrake auto_brake{ bus };
bus.speed_update_callback(SpeedUpdate{ 100L }); ➊
assert_that(100L == auto_brake.get_speed_mps(), "speed not saved to 100"); ➋
bus.speed_update_callback(SpeedUpdate{ 50L });
assert_that(50L == auto_brake.get_speed_mps(), "speed not saved to 50");
bus.speed_update_callback(SpeedUpdate{ 0L });
assert_that(0L == auto_brake.get_speed_mps(), "speed not saved to 0");
}
列表 10-24:使用MockServiceBus重新实现speed_is_saved单元测试函数
这个测试与之前的实现差异不大。你调用了存储在模拟总线上的speed_update_callback函数 ➊。你确保AutoBrake对象正确更新了汽车的速度 ➋。编译并运行该单元测试套件后,得到如下输出:
[+] Test initial speed is 0 successful.
[+] Test initial sensitivity is 5 successful.
[+] Test sensitivity greater than 1 successful.
[-] Test failure in speed is saved. bad function call.
请记住,bad function call消息来自于std::bad_func``tion_call异常。这是预期中的情况:你仍然需要从AutoBrake进行订阅,因此当你调用它时,std::function会抛出异常。
考虑列表 10-25 中的方法。
struct AutoBrake {
AutoBrake(IServiceBus& bus)
: collision_threshold_s{ 5 },
speed_mps{} {
bus.subscribe(this {
speed_mps = update.velocity_mps;
});
}
--snip--
}
列表 10-25:将AutoBrake订阅到来自IServiceBus的速度更新
多亏了std::function,你可以将回调作为 lambda 传递给bus的订阅方法,该 lambda 捕获了speed_mps。 (注意,你不需要保存bus的副本。) 重新编译并运行单元测试套件后,得到如下输出:
[+] Test initial speed is 0 successful.
[+] Test initial sensitivity is 5 successful.
[+] Test sensitivity greater than 1 successful.
[+] Test speed is saved successful.
接下来,你将执行第一个与警报相关的单元测试no_alert_when_not_imminent。列表 10-26 展示了如何根据新架构更新这个测试。
void no_alert_when_not_imminent() {
MockServiceBus bus{};
AutoBrake auto_brake{ bus };
auto_brake.set_collision_threshold_s(2L);
bus.speed_update_callback(SpeedUpdate{ 100L }); ➊
bus.car_detected_callback(CarDetected{ 1000L, 50L }); ➋
assert_that(bus.commands_published == 0, "brake commands were published");
}
列表 10-26:使用IServiceBus更新no_alert_when_not_imminent测试
如同在speed_is_saved测试中,你通过在bus模拟对象上调用回调来模拟服务总线上的事件 ➊➋。重新编译并运行单元测试套件时,结果会出现预期的失败。
[+] Test initial speed is 0 successful.
[+] Test initial sensitivity is 5 successful.
[+] Test sensitivity greater than 1 successful.
[+] Test speed is saved successful.
[-] Test failure in no alert when not imminent. bad function call.
你需要使用CarDetectedCallback进行订阅。你可以将其添加到AutoBus构造函数中,正如列表 10-27 中展示的那样。
struct AutoBrake {
AutoBrake(IServiceBus& bus)
: collision_threshold_s{ 5 },
speed_mps{} {
bus.subscribe(this {
speed_mps = update.velocity_mps;
});
bus.subscribe(this➊, &bus➋ {
const auto relative_velocity_mps = speed_mps - cd.velocity_mps;
const auto time_to_collision_s = cd.distance_m / relative_velocity_mps;
if (time_to_collision_s > 0 &&
time_to_collision_s <= collision_threshold_s) {
bus.publish(BrakeCommand{ time_to_collision_s }); ➌
}
});
}
--snip--
}
列表 10-27:一个更新后的AutoBrake构造函数,将其接入服务总线
你所做的只是移植了原来的observe方法,用于处理CarDetected事件。lambda 表达式通过引用捕获了this ➊和bus ➋。捕获this使你能够计算碰撞时间,而捕获bus则可以在条件满足时发布BrakeCommand ➌。现在,单元测试的二进制文件输出如下内容:
[+] Test initial speed is 0 successful.
[+] Test initial sensitivity is 5 successful.
[+] Test sensitivity greater than 1 successful.
[+] Test speed is saved successful.
[+] Test no alert when not imminent successful.
最后,开启最后一个测试项alert_when_imminent,如列表 10-28 所示。
void alert_when_imminent() {
MockServiceBus bus{};
AutoBrake auto_brake{ bus };
auto_brake.set_collision_threshold_s(10L);
bus.speed_update_callback(SpeedUpdate{ 100L });
bus.car_detected_callback(CarDetected{ 100L, 0L });
assert_that(bus.commands_published == 1, "1 brake command was not published");
assert_that(bus.last_command.time_to_collision_s == 1L,
"time to collision not computed correctly."); ➊
}
列表 10-28:重构alert_when_imminent单元测试
在MockServiceBus中,您实际上将最后发布到总线的BrakeCommand保存为一个成员变量。在测试中,您可以使用这个成员变量来验证碰撞时间是否计算正确。如果一辆车以 100 米每秒的速度行驶,它将在 1 秒钟内撞上停在 100 米外的静止汽车。您可以通过引用我们模拟的bus ➊上的time_to_collision_s字段,检查BrakeCommand是否记录了正确的碰撞时间。
重新编译并重新运行,您终于将测试套件恢复到全绿状态:
[+] Test initial speed is 0 successful.
[+] Test initial sensitivity is 5 successful.
[+] Test sensitivity greater than 1 successful.
[+] Test speed is saved successful.
[+] Test no alert when not imminent successful.
[+] Test alert when imminent successful.
重构现已完成。
重新评估单元测试解决方案
回顾单元测试解决方案,您可以识别出几个与AutoBrake无关的组件。这些是通用的单元测试组件,您可以在未来的单元测试中重复使用。回想一下在清单 10-29 中创建的两个辅助函数。
#include <stdexcept>
#include <cstdio>
void assert_that(bool statement, const char* message) {
if (!statement) throw std::runtime_error{ message };
}
void run_test(void(*unit_test)(), const char* name) {
try {
unit_test();
printf("[+] Test %s successful.\n", name);
return;
} catch (const std::exception& e) {
printf("[-] Test failure in %s. %s.\n", name, e.what());
}
}
清单 10-29:一个简洁的单元测试框架
这两个函数反映了单元测试的两个基本方面:进行断言和运行测试。自己编写简单的assert_that函数和run_test框架是可行的,但这种方法扩展性不强。通过依赖单元测试框架,您可以做得更好。
单元测试与模拟框架
单元测试框架提供了常用的功能和您需要的架构,以便将您的测试组织成一个用户友好的程序。这些框架提供了大量功能,帮助您创建简洁、富有表现力的测试。本节将介绍几种流行的单元测试和模拟框架。
Catch 单元测试框架
由 Phil Nash 开发的最直观的单元测试框架 Catch 可以在github.com/catchorg/Catch2/找到。因为它是一个仅包含头文件的库,您只需下载单头版本并在每个包含单元测试代码的翻译单元中包含它即可设置 Catch。
注意
在写作时,Catch 的最新版本是 2.9.1。
定义入口点
告诉 Catch 通过#define CATCH_CONFIG_MAIN提供您的测试二进制文件的入口点。Catch 单元测试套件的启动过程如下:
#define CATCH_CONFIG_MAIN
#include "catch.hpp"
就是这样。在catch.hpp头文件中,它会查找CATCH_CONFIG_MAIN预处理器定义。存在时,Catch 会自动添加一个main函数,您无需手动编写。它会自动获取您定义的所有单元测试,并将其包裹在一个漂亮的测试框架中。
定义测试用例
在“单元测试”一节中,你在 第 282 页 定义了每个单元测试的单独函数。然后你会将该函数的指针作为第一个参数传递给 run_test。你将测试的名称作为第二个参数传递,这有点冗余,因为你已经为第一个参数所指向的函数提供了描述性的名称。最后,你还需要实现自己的 assert 函数。Catch 会隐式处理所有这些步骤。对于每个单元测试,你使用 TEST_CASE 宏,而 Catch 会为你处理所有集成工作。
清单 10-30 演示了如何构建一个简单的 Catch 单元测试程序。
#define CATCH_CONFIG_MAIN
#include "catch.hpp"
TEST_CASE("AutoBrake") { ➊
// Unit test here
}
--------------------------------------------------------------------------
==========================================================================
test cases: 1 | 1 passed ➊
assertions: - none - ➋
清单 10-30:一个简单的 Catch 单元测试程序
Catch 入口点检测到你声明了一个名为 AutoBrake 的测试 ➊。它还提供了一个警告,指出你没有进行任何断言 ➋。
进行断言
Catch 自带了一组内置的断言,它包含两类不同的断言宏:REQUIRE 和 CHECK。它们的区别在于,REQUIRE 会立即使测试失败,而 CHECK 会允许测试继续运行(但仍会导致失败)。CHECK 在某些情况下非常有用,特别是当一组相关的断言失败时,它能引导程序员进行调试。此外,还有 REQUIRE_FALSE 和 CHECK_FALSE,它们检查包含的语句是否评估为假,而不是为真。在某些情况下,这可能是表达要求的更自然方式。
你只需要用 REQUIRE 宏包装一个布尔表达式。如果表达式评估为假,断言失败。你提供一个 断言表达式,如果断言通过则为真,若失败则为假:
REQUIRE(assertion-expression);
让我们来看一下如何将 REQUIRE 与 TEST_CASE 结合起来构建单元测试。
注意
因为这是 Catch 中最常用的断言,所以我们在这里使用 REQUIRE。有关更多信息,请参考 Catch 文档。
将 initial_speed_is_zero 测试重构为 Catch
清单 10-31 显示了重构为使用 Catch 的 initial_speed_is_zero 测试。
#define CATCH_CONFIG_MAIN
#include "catch.hpp"
#include <functional>
struct IServiceBus {
--snip--
};
struct MockServiceBus : IServiceBus {
--snip--
};
struct AutoBrake {
--snip--
};
TEST_CASE➊("initial car speed is zero"➋) {
MockServiceBus bus{};
AutoBrake auto_brake{ bus };
REQUIRE(auto_brake.get_speed_mps() == 0); ➌
}
清单 10-31:将 initial_speed_is_zero 单元测试重构为使用 Catch
你使用 TEST_CASE 宏定义一个新的单元测试 ➊。该测试由它的唯一参数 ➋ 描述。在 TEST_CASE 宏的主体内,你继续进行单元测试。你还会看到 REQUIRE 宏的应用 ➌。要查看 Catch 如何处理失败的测试,注释掉 speed_mps 成员初始化器以导致测试失败,并观察程序的输出,如 清单 10-32 所示。
struct AutoBrake {
AutoBrake(IServiceBus& bus)
: collision_threshold_s{ 5 }/*,
speed_mps{} */{ ➊
--snip--
};
清单 10-32:故意注释掉 speed_mps 成员初始化器以导致测试失败(使用 Catch)
适当的成员初始化器 ➊ 被注释掉,导致测试失败。在 清单 10-31 中重新运行 Catch 测试套件,输出结果如 清单 10-33 所示。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
catch_example.exe is a Catch v2.0.1 host application.
Run with -? for options
------------------------------------------------------------------------------
initial car speed is zero
------------------------------------------------------------------------------
c:\users\jalospinoso\catch-test\main.cpp(82)
..............................................................................
c:\users\jalospinoso\catch-test\main.cpp(85):➊ FAILED:
REQUIRE( auto_brake.get_speed_mps()L == 0 ) ➋
with expansion:
-92559631349317830736831783200707727132248687965119994463780864.0 ➌
==
0
==============================================================================
test cases: 1 | 1 failed
assertions: 1 | 1 failed
示例 10-33:在实现示例 10-31 后运行测试套件的输出
这是远远优于你在自定义单元测试框架中生成的输出。Catch 会告诉你单元测试失败的确切行号➊,并为你打印出这一行➋。接着,它会展开这行并显示在运行时遇到的实际值。你可以看到get_speed_mps()返回的怪异(未初始化的)值显然不是0 ➌。将这个输出与自定义单元测试的输出进行比较,我相信你会同意使用 Catch 立刻能带来价值。
断言和异常
Catch 还提供了一种特殊的断言,叫做REQUIRE_THROWS。这个宏要求包含的表达式必须抛出异常。为了在自定义单元测试框架中实现类似的功能,可以参考这个多行的庞然大物:
try {
auto_brake.set_collision_threshold_s(0.5L);
} catch (const std::exception&) {
return;
}
assert_that(false, "no exception thrown");
还有其他感知异常的宏。你可以使用REQUIRE_NOTHROW和CHECK_NOTHROW宏要求某个表达式的求值不能抛出异常。你还可以通过使用REQUIRE_THROWS_AS和CHECK_THROWS_AS宏来指定你期望抛出的异常类型。这些宏期望第二个参数描述预期的类型。它们的使用方式类似于REQUIRE;你只需提供一个必须抛出异常的表达式,才能使断言通过:
REQUIRE_THROWS(expression-to-evaluate);
如果表达式没有抛出异常,断言将失败。
浮点数断言
AutoBrake 类涉及浮点数运算,我们一直在忽略可能非常严重的断言问题。因为浮点数会产生舍入误差,使用operator==进行相等性检查并不是一个好主意。更稳健的方法是测试浮点数之间的差异是否足够小。在 Catch 中,你可以轻松处理这些情况,使用Approx类,如示例 10-34 所示。
TEST_CASE("AutoBrake") {
MockServiceBus bus{};
AutoBrake auto_brake{ bus };
REQUIRE(auto_brake.get_collision_threshold_s() == Approx(5L));
}
示例 10-34:使用Approx类重构“将灵敏度初始化为五”的测试
Approx 类帮助 Catch 执行浮点值的容差比较。它可以出现在比较表达式的任意一侧。它有合理的默认容差设置,但你可以精细控制具体的容差设置(请参见 Catch 文档中的epsilon, margin和scale)。
失败
你可以使用FAIL()宏使 Catch 测试失败。当与条件语句结合使用时,这有时会很有用,如下所示:
if (something-bad) FAIL("Something bad happened.")
如果有合适的REQUIRE语句,请使用它。
测试用例和测试部分
Catch 支持测试用例和测试部分的概念,这使得在单元测试中进行常见的设置和拆卸变得更加容易。请注意,每个测试在构造AutoBrake时都有一些重复的准备工作:
MockServiceBus bus{};
AutoBrake auto_brake{ bus };
无需一遍又一遍地重复这些代码。Catch 对这个常见设置的解决方案是使用嵌套的 SECTION 宏。你可以在基本的使用模式中将 SECTION 宏嵌套在 TEST_CASE 中,如 清单 10-35 中所示。
TEST_CASE("MyTestGroup") {
// Setup code goes here ➊
SECTION("MyTestA") { ➋
// Code for Test A
}
SECTION("MyTestB") { ➌
// Code for Test B
}
}
清单 10-35:一个包含嵌套宏的 Catch 设置示例
你可以在TEST_CASE ➊的开始时一次性完成所有的设置。当 Catch 看到 SECTION 宏嵌套在一个 TEST_CASE 内时,它(从概念上讲)会将所有的设置复制并粘贴到每个 SECTION 中 ➋➌。每个 SECTION 都独立运行,因此通常在 TEST_CASE 中创建的对象的副作用不会在 SECTION 宏之间互相影响。此外,你可以在另一个 SECTION 宏中嵌套一个 SECTION 宏。如果你有大量的设置代码用于一组紧密相关的测试,这可能会很有用(尽管将该组测试拆分成单独的 TEST_CASE 可能更为合理)。
让我们看看这种方法是如何简化 AutoBrake 单元测试套件的。
将 AutoBrake 单元测试重构为 Catch
清单 10-36 将所有单元测试重构为 Catch 风格。
#define CATCH_CONFIG_MAIN
#include "catch.hpp"
#include <functional>
#include <stdexcept>
struct IServiceBus {
--snip--
};
struct MockServiceBus : IServiceBus {
--snip--
};
struct AutoBrake {
--snip--
};
TEST_CASE("AutoBrake"➊) {
MockServiceBus bus{}; ➋
AutoBrake auto_brake{ bus }; ➌
SECTION➍("initializes speed to zero"➎) {
REQUIRE(auto_brake.get_speed_mps() == Approx(0));
}
SECTION("initializes sensitivity to five") {
REQUIRE(auto_brake.get_collision_threshold_s() == Approx(5));
}
SECTION("throws when sensitivity less than one") {
REQUIRE_THROWS(auto_brake.set_collision_threshold_s(0.5L));
}
SECTION("saves speed after update") {
bus.speed_update_callback(SpeedUpdate{ 100L });
REQUIRE(100L == auto_brake.get_speed_mps());
bus.speed_update_callback(SpeedUpdate{ 50L });
REQUIRE(50L == auto_brake.get_speed_mps());
bus.speed_update_callback(SpeedUpdate{ 0L });
REQUIRE(0L == auto_brake.get_speed_mps());
}
SECTION("no alert when not imminent") {
auto_brake.set_collision_threshold_s(2L);
bus.speed_update_callback(SpeedUpdate{ 100L });
bus.car_detected_callback(CarDetected{ 1000L, 50L });
REQUIRE(bus.commands_published == 0);
}
SECTION("alert when imminent") {
auto_brake.set_collision_threshold_s(10L);
bus.speed_update_callback(SpeedUpdate{ 100L });
bus.car_detected_callback(CarDetected{ 100L, 0L });
REQUIRE(bus.commands_published == 1);
REQUIRE(bus.last_command.time_to_collision_s == Approx(1));
}
}
------------------------------------------------------------------------------
==============================================================================
All tests passed (9 assertions in 1 test case)
清单 10-36:使用 Catch 框架实现单元测试
在这里,TEST_CASE 被重命名为 AutoBrake,以反映其更通用的用途 ➊。接下来,TEST_CASE 的主体开始于所有 AutoBrake 单元测试共享的公共设置代码 ➋➌。每个单元测试都已转换为一个 SECTION 宏 ➍。你为每个部分命名 ➎,然后将特定于测试的代码放入 SECTION 主体内。Catch 会将设置代码与每个 SECTION 主体拼接起来,完成所有的工作。换句话说,每次你都会得到一个新的 AutoBrake:SECTIONS 的顺序在这里并不重要,它们是完全独立的。
Google Test
Google Test 是另一个极为流行的单元测试框架。Google Test 遵循 xUnit 单元测试框架的传统,因此,如果你熟悉 Java 的 junit 或 .NET 的 nunit,使用 Google Test 会非常得心应手。使用 Google Test 的一个好处是,Google Mocks(一个模拟框架)已与其合并。
配置 Google Test
Google Test 启动需要一些时间。与 Catch 不同,Google Test 不是一个仅包含头文件的库。你必须从 github.com/google/googletest/ 下载它,将其编译成一组库,并根据需要将这些库链接到你的测试项目中。如果你使用流行的桌面构建系统,如 GNU Make、Mac Xcode 或 Visual Studio,提供了一些模板,可以用来启动相关库的构建。
要了解如何启动和运行 Google Test,请参考仓库中docs目录下的 Primer 文档。
注意
截至目前,Google Test 的最新版本是 1.8.1. 有关将 Google Test 集成到 Cmake 构建中的一种方法,请参阅本书的配套源代码,链接在 ccc.codes,中。*
在你的单元测试项目中,必须执行两个操作来设置 Google Test。首先,确保你的 Google Test 安装目录中的 include 文件夹在单元测试项目的头文件搜索路径中。这将使你能够在测试中使用#include "gtest/gtest.h"。其次,你需要指示链接器将gtest和gtest_main静态库从 Google Test 安装目录中包含进来。确保链接正确的架构和配置设置以匹配你的计算机。
注意
在 Visual Studio 中设置 Google Test 时,常见的一个问题是,Google Test 的 C/C++ > 代码生成 > 运行时库选项必须与你项目的选项匹配。默认情况下,Google Test 将运行时静态编译(即使用/MT 或 MTd 选项)。这个选择与默认的动态编译运行时不同(例如,Visual Studio 中的/MD 或/MDd 选项)。
定义入口点
当你将gtest_main链接到单元测试项目中时,Google Test 会为你提供一个main()函数。可以将其看作是 Google Test 对 Catch 中#define CATCH_CONFIG_MAIN的类比;它会找到你定义的所有单元测试,并将它们整合成一个良好的测试工具。
定义测试用例
要定义测试用例,你只需使用TEST宏提供单元测试,这与 Catch 的TEST_CASE非常相似。列表 10-37 展示了一个 Google Test 单元测试的基本设置。
#include "gtest/gtest.h" ➊
TEST➋(AutoBrake➌, UnitTestName➍) {
// Unit test here ➎
}
--------------------------------------------------------------------------
Running main() from gtest_main.cc ➏
[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from AutoBrake
[ RUN ] AutoBrake.UnitTestName
[ OK ] AutoBrake.UnitTestName (0 ms)
[----------] 1 test from AutoBrake (0 ms total)
[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (1 ms total)
[ PASSED ] 1 test. ➐
列表 10-37:一个示例的 Google Test 单元测试
首先,包含gtest/gtest.h头文件➊。这将引入你定义单元测试所需的所有定义。每个单元测试都以TEST宏开始➋。你用两个标签定义每个单元测试:一个测试用例名称,例如AutoBrake ➌,和一个测试名称,例如UnitTestName ➍。这些大致相当于 Catch 中的TEST_CASE和SECTION名称。一个测试用例包含一个或多个测试。通常,你将具有共同主题的测试放在一起。框架会将这些测试分组,这在一些更高级的用法中非常有用。不同的测试用例可以有相同名称的测试。
你将单元测试的代码放在大括号➎中。当你运行生成的单元测试二进制文件时,你会看到 Google Test 为你提供了一个入口点➏。由于你没有提供任何断言(或可能抛出异常的代码),所以你的单元测试顺利通过➐。
做出断言
Google Test 的断言比 Catch 的 REQUIRE 少了一些魔法。虽然它们也是宏,但 Google Test 的断言需要程序员做更多的工作。在 REQUIRE 中,它会解析布尔表达式并确定你是否在测试相等、大于关系等,而 Google Test 的断言不会。你必须分别传递断言的每个组成部分。
Google Test 提供了许多其他选择来编写断言。表 10-1 总结了它们。
表 10-1: Google Test 断言
| 断言 | 验证 |
|---|---|
ASSERT_TRUE(condition) |
condition 为真。 |
ASSERT_FALSE(condition) |
condition 为假。 |
ASSERT_EQ(val1, val2) |
val1 == val2 为真。 |
ASSERT_FLOAT_EQ(val1, val2) |
val1 - val2 是一个舍入误差(float)。 |
ASSERT_DOUBLE_EQ(val1, val2) |
val1 - val2 是一个舍入误差(double)。 |
ASSERT_NE(val1, val2) |
val1 != val2 为真。 |
ASSERT_LT(val1, val2) |
val1 < val2 为真。 |
ASSERT_LE(val1, val2) |
val1 <= val2 为真。 |
ASSERT_GT(val1, val2) |
val1 > val2 为真。 |
ASSERT_GE(val1, val2) |
val1 >= val2 为真。 |
ASSERT_STREQ(str1, str2) |
两个 C 风格的字符串 *str1* 和 *str2* 内容相同。 |
ASSERT_STRNE(str1, str2) |
两个 C 风格的字符串 *str1* 和 *str2* 内容不同。 |
ASSERT_STRCASEEQ(str1, str2) |
忽略大小写的情况下,两个 C 风格的字符串 *str1* 和 *str2* 内容相同。 |
ASSERT_STRCASENE(str1, str2) |
忽略大小写的情况下,两个 C 风格的字符串 *str1* 和 *str2* 内容不同。 |
ASSERT_THROW(statement, ex_type) |
评估 *statement* 会导致抛出类型为 *ex_type* 的异常。 |
ASSERT_ANY_THROW(statement) |
评估 *statement* 会导致抛出任何类型的异常。 |
ASSERT_NO_THROW(statement) |
评估 *statement* 不会抛出任何异常。 |
ASSERT_HRESULT_SUCCEEDED(statement) |
*statement* 返回的 HRESULT 对应一个成功(仅限 Win32 API)。 |
ASSERT_HRESULT_FAILED(statement) |
*statement* 返回的 HRESULT 对应一个失败(仅限 Win32 API)。 |
让我们结合单元测试定义和断言来看看 Google Test 的实际应用。
将 initial_car_speed_is_zero 测试重构为 Google Test
使用故意破坏的 AutoBrake 在 清单 10-32 中,你可以运行以下单元测试,看看测试框架的失败信息是什么样的。(回想一下,你注释掉了 speed_mps 的成员初始化器。)清单 10-38 使用 ASSERT_FLOAT_EQ 来断言汽车的初始速度为零。
#include "gtest/gtest.h"
#include <functional>
struct IServiceBus {
--snip--
};
struct MockServiceBus : IServiceBus {
--snip--
};
struct AutoBrake {
AutoBrake(IServiceBus& bus)
: collision_threshold_s{ 5 }/*,
speed_mps{} */ {
--snip--
};
TEST➊(AutoBrakeTest➋, InitialCarSpeedIsZero➌) {
MockServiceBus bus{};
AutoBrake auto_brake{ bus };
ASSERT_FLOAT_EQ➍(0➎, auto_brake.get_speed_mps()➏);
}
--------------------------------------------------------------------------
Running main() from gtest_main.cc
[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from AutoBrakeTest
[ RUN ] AutoBrakeTest.InitialCarSpeedIsZero
C:\Users\josh\AutoBrake\gtest.cpp(80): error: Expected equality of these values:
0 ➎
auto_brake.get_speed_mps()➏
Which is: -inf
[ FAILED ] AutoBrakeTest➋.InitialCarSpeedIsZero➌ (5 ms)
[----------] 1 test from AutoBrakeTest (5 ms total)
[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (7 ms total)
[ PASSED ] 0 tests.
[ FAILED ] 1 test, listed below:
[ FAILED ] AutoBrakeTest.InitialCarSpeedIsZero
1 FAILED TEST
清单 10-38:故意注释掉 collision_threshold_s 成员初始化器以导致测试失败(使用 Google Test)
你声明一个单元测试 ➊,测试用例名称为 AutoBrakeTest ➋,测试名称为 InitialCarSpeedIsZero ➌。在测试中,你设置 auto_brake 并断言 ➍ 车的初始速度为零 ➎。请注意,常量值是第一个参数,而你正在测试的数量是第二个参数 ➏。
就像示例 10-33 中的 Catch 输出一样,示例 10-38 中的 Google Test 输出也非常清晰。它告诉你测试失败,指出失败的断言,并很好地提示你如何修复问题。
测试夹具
与 Catch 的 TEST_CASE 和 SECTION 方法不同,Google Test 的方法是在涉及共同设置时,制定 测试夹具类。这些夹具是继承自框架提供的 ::testing::Test 类的类。
你计划在测试中使用的任何成员应标记为 public 或 protected。如果你需要一些设置或拆卸计算,可以将其放入(默认的)构造函数或析构函数中(分别)。
注意
你也可以将这样的设置和拆卸逻辑放在重写的 SetUp() 和 TearDown() 函数中,尽管你通常不需要这样做。一个例外是如果拆卸计算可能抛出异常。因为你通常不应该允许未捕获的异常从析构函数中抛出,所以你必须将此类代码放在 TearDown() 函数中。(回想一下在第 106 页“析构函数中的抛出”中提到的,当另一个异常已经被抛出时,在析构函数中抛出未捕获的异常会调用 std::terminate。)
如果测试夹具类似于 Catch 的 TEST_CASE,那么 TEST_F 就像 Catch 的 SECTION。与 TEST 一样,TEST_F 也接受两个参数。第一个 必须 是测试夹具类的准确名称。第二个是单元测试的名称。示例 10-39 说明了 Google Test 测试夹具的基本用法。
#include "gtest/gtest.h"
struct MyTestFixture➊ : ::testing::Test➋ { };
TEST_F(MyTestFixture➌, MyTestA➍) {
// Test A here
}
TEST_F(MyTestFixture, MyTestB➎) {
// Test B here
}
--------------------------------------------------------------------------
Running main() from gtest_main.cc
[==========] Running 2 tests from 1 test case.
[----------] Global test environment set-up.
[----------] 2 tests from MyTestFixture
[ RUN ] MyTestFixture.MyTestA
[ OK ] MyTestFixture.MyTestA (0 ms)
[ RUN ] MyTestFixture.MyTestB
[ OK ] MyTestFixture.MyTestB (0 ms)
[----------] 2 tests from MyTestFixture (1 ms total)
[----------] Global test environment tear-down
[==========] 2 tests from 1 test case ran. (3 ms total)
[ PASSED ] 2 tests.
示例 10-39:Google Test 测试夹具的基本设置
你声明一个类 MyTestFixture ➊,它继承自 Google Test 提供的 ::testing::Test 类 ➋。你使用该类的名称作为 TEST_F 宏的第一个参数 ➌。然后,单元测试可以访问 MyTestFixture 中的任何公共或受保护方法,并且你可以使用 MyTestFixture 的构造函数和析构函数来执行任何公共的测试设置/拆卸操作。第二个参数是单元测试的名称 ➍➎。
接下来,让我们看看如何使用 Google Test 测试夹具重新实现 AutoBrake 单元测试。
使用 Google Test 重构 AutoBrake 单元测试
示例 10-40 将所有 AutoBrake 单元测试重新实现到 Google Test 的测试夹具框架中。
#include "gtest/gtest.h"
#include <functional>
struct IServiceBus {
--snip--
};
struct MockServiceBus : IServiceBus {
--snip--
};
struct AutoBrake {
--snip--
};
struct AutoBrakeTest : ::testing::Test { ➊
MockServiceBus bus{};
AutoBrake auto_brake { bus };
};
TEST_F➋(AutoBrakeTest➌, InitialCarSpeedIsZero➍) {
ASSERT_DOUBLE_EQ(0, auto_brake.get_speed_mps()); ➎
}
TEST_F(AutoBrakeTest, InitialSensitivityIsFive) {
ASSERT_DOUBLE_EQ(5, auto_brake.get_collision_threshold_s());
}
TEST_F(AutoBrakeTest, SensitivityGreaterThanOne) {
ASSERT_ANY_THROW(auto_brake.set_collision_threshold_s(0.5L)); ➏
}
TEST_F(AutoBrakeTest, SpeedIsSaved) {
bus.speed_update_callback(SpeedUpdate{ 100L });
ASSERT_EQ(100, auto_brake.get_speed_mps());
bus.speed_update_callback(SpeedUpdate{ 50L });
ASSERT_EQ(50, auto_brake.get_speed_mps());
bus.speed_update_callback(SpeedUpdate{ 0L });
ASSERT_DOUBLE_EQ(0, auto_brake.get_speed_mps());
}
TEST_F(AutoBrakeTest, NoAlertWhenNotImminent) {
auto_brake.set_collision_threshold_s(2L);
bus.speed_update_callback(SpeedUpdate{ 100L });
bus.car_detected_callback(CarDetected{ 1000L, 50L });
ASSERT_EQ(0, bus.commands_published);
}
TEST_F(AutoBrakeTest, AlertWhenImminent) {
auto_brake.set_collision_threshold_s(10L);
bus.speed_update_callback(SpeedUpdate{ 100L });
bus.car_detected_callback(CarDetected{ 100L, 0L });
ASSERT_EQ(1, bus.commands_published);
ASSERT_DOUBLE_EQ(1L, bus.last_command.time_to_collision_s);
}
--------------------------------------------------------------------------
Running main() from gtest_main.cc
[==========] Running 6 tests from 1 test case.
[----------] Global test environment set-up.
[----------] 6 tests from AutoBrakeTest
[ RUN ] AutoBrakeTest.InitialCarSpeedIsZero
[ OK ] AutoBrakeTest.InitialCarSpeedIsZero (0 ms)
[ RUN ] AutoBrakeTest.InitialSensitivityIsFive
[ OK ] AutoBrakeTest.InitialSensitivityIsFive (0 ms)
[ RUN ] AutoBrakeTest.SensitivityGreaterThanOne
[ OK ] AutoBrakeTest.SensitivityGreaterThanOne (1 ms)
[ RUN ] AutoBrakeTest.SpeedIsSaved
[ OK ] AutoBrakeTest.SpeedIsSaved (0 ms)
[ RUN ] AutoBrakeTest.NoAlertWhenNotImminent
[ OK ] AutoBrakeTest.NoAlertWhenNotImminent (1 ms)
[ RUN ] AutoBrakeTest.AlertWhenImminent
[ OK ] AutoBrakeTest.AlertWhenImminent (0 ms)
[----------] 6 tests from AutoBrakeTest (3 ms total)
[----------] Global test environment tear-down
[==========] 6 tests from 1 test case ran. (4 ms total)
[ PASSED ] 6 tests.
示例 10-40:使用 Google Test 实现 AutoBrake 单元测试
首先,你实现测试夹具 AutoBrakeTest ➊。这个类封装了所有单元测试中的公共设置代码:构造一个 MockServiceBus 并用它构造一个 AutoBrake。每个单元测试通过 TEST_F 宏来表示 ➋。这些宏需要两个参数:测试夹具,例如 AutoBrakeTest ➌,以及测试的名称,例如 InitialCarSpeedIsZero ➍。在单元测试的主体中,你有每个断言的正确调用,例如 ASSERT_DOUBLE_EQ ➎ 和 ASSERT_ANY_THROW ➏。
比较 Google Test 和 Catch
正如你所看到的,Google Test 和 Catch 之间存在几个主要的区别。最显著的初步印象应该是你在安装 Google Test 并使其在你的解决方案中正常工作时所投入的精力。Catch 则处于这个范围的另一端:作为一个仅包含头文件的库,它在你的项目中工作几乎是微不足道的。
另一个主要的区别是断言。对于新手来说,REQUIRE 比 Google Test 的断言风格更简单易用。对于另一个 xUnit 框架的资深用户来说,Google Test 可能看起来更自然。失败的消息也有所不同。最终,这取决于你自己判断哪种风格更为合理。
最后是性能。从理论上讲,Google Test 的编译速度会比 Catch 快,因为每个单元测试单元中必须编译 Catch 的所有内容。这是仅包含头文件的库的权衡;你在设置 Google Test 时所做的投入,最终会通过更快的编译速度得到回报。根据单元测试套件的大小,这一点可能会或不会显而易见。
Boost Test
Boost Test 是一个单元测试框架,它作为 Boost C++ 库(简称 Boost)的一部分发布。Boost 是一个优秀的开源 C++ 库集合。它有着孵化许多最终被纳入 C++ 标准的想法的历史,尽管并非所有 Boost 库都旨在最终被纳入标准。你会在本书的其余部分看到提到许多 Boost 库,Boost Test 是其中的第一个。有关如何将 Boost 安装到你的环境中的帮助,请参阅 Boost 的主页 www.boost.org 或查看本书的配套代码。
注意
截至出版时,Boost 库的最新版本是 1.70.0。
你可以通过三种模式使用 Boost Test:作为仅包含头文件的库(像 Catch),作为静态库(像 Google Test),或者作为共享库,这将在运行时链接 Boost Test 模块。动态库的使用可以在你有多个单元测试二进制文件时节省相当多的磁盘空间。你可以构建一个单一的共享库(如 .so 或 .dll),然后在运行时加载它,而不是将单元测试框架嵌入到每个单元测试二进制文件中。
正如你在探索 Catch 和 Google Test 时所发现的,每种方法都有其权衡之处。Boost Test 的一个主要优势是它允许你根据自己的需要选择最佳模式。如果项目发生变化,切换模式并不困难,因此一种可能的做法是首先将 Boost Test 用作仅包含头文件的库,并随着需求的变化转向其他模式。
设置 Boost Test
要在仅头文件模式下设置 Boost Test(即 Boost 文档中所称的“单头文件变体”),你只需包含<boost/test/included/unit_test.hpp>头文件。为了让这个头文件能够编译,你需要定义一个用户自定义的BOOST_TEST_MODULE名称。例如:
#define BOOST_TEST_MODULE test_module_name
#include <boost/test/included/unit_test.hpp>
不幸的是,如果你有多个翻译单元,就无法采用这种方法。对于这种情况,Boost Test 包含了可以使用的预构建静态库。通过链接这些库,你避免了为每个翻译单元编译相同的代码。当采取这种方法时,你需要在单元测试套件的每个翻译单元中包含boost/test/unit_test.hpp头文件:
#include <boost/test/unit_test.hpp>
在一个翻译单元中,你还需要包含BOOST_TEST_MODULE定义:
#define BOOST_TEST_MODULE AutoBrake
#include <boost/test/unit_test.hpp>
你还必须配置链接器,以包含 Boost Test 安装中附带的适当 Boost Test 静态库。所选静态库对应的编译器和架构必须与单元测试项目的其余部分匹配。
设置共享库模式
要在共享库模式下设置 Boost Test,你必须在每个单元测试套件的翻译单元中添加以下行:
#define BOOST_TEST_DYN_LINK
#include <boost/test/unit_test.hpp>
在一个翻译单元中,你还必须定义BOOST_TEST_MODULE:
#define BOOST_TEST_MODULE AutoBrake
#define BOOST_TEST_DYN_LINK
#include <boost/test/unit_test.hpp>
与静态库的使用一样,你必须指示链接器包含 Boost Test。在运行时,单元测试共享库也必须可用。
定义测试用例
你可以使用BOOST_AUTO_TEST_CASE宏在 Boost Test 中定义一个单元测试,该宏接受一个参数,表示测试的名称。列表 10-41 展示了基本用法。
#define BOOST_TEST_MODULE TestModuleName ➊
#include <boost/test/unit_test.hpp> ➋
BOOST_AUTO_TEST_CASE➌(TestA➍) {
// Unit Test A here ➎
}
--------------------------------------------------------------------------
Running 1 test case...
*** No errors detected
列表 10-41:使用 Google Test 实现AutoBrake单元测试
测试模块的名称是TestModuleName ➊,你将其定义为BOOST_TEST_MODULE。你包含boost/test/unit_test.hpp头文件 ➋,该文件提供了你所需的所有 Boost Test 组件。BOOST_AUTO_TEST_CASE声明 ➌ 表示一个名为TestA ➍的单元测试。单元测试的主体位于大括号之间 ➎。
进行断言
Boost 中的断言与 Catch 中的断言非常相似。BOOST_TEST宏类似于 Catch 中的REQUIRE宏。你只需提供一个表达式,如果断言通过,该表达式的值为 true;如果断言失败,则为 false:
BOOST_TEST(assertion-expression)
要求某个表达式在求值时抛出异常,可以使用 BOOST_REQUIRE_THROW 宏,它类似于 Catch 的 REQUIRE_THROWS 宏,但你还必须提供你希望抛出的异常类型。其使用方法如下:
BOOST_REQUIRE_THROW(expression, desired-exception-type);
如果 *expression* 没有抛出 *desired-exception-type* 类型的异常,则断言将失败。
让我们看看使用 Boost Test 的 AutoBrake 单元测试套件是什么样的。
将 initial_car_speed_is_zero 测试重构为 Boost Test
你将使用 Listing 10-32 中故意破坏的 AutoBrake,其缺少 speed_mps 的成员初始化器。Listing 10-42 使 Boost Test 处理一个失败的单元测试。
#define BOOST_TEST_MODULE AutoBrakeTest ➊
#include <boost/test/unit_test.hpp>
#include <functional>
struct IServiceBus {
--snip--
};
struct MockServiceBus : IServiceBus {
--snip--
};
struct AutoBrake {
AutoBrake(IServiceBus& bus)
: collision_threshold_s{ 5 }/*,
speed_mps{} */➋ {
--snip--
};
BOOST_AUTO_TEST_CASE(InitialCarSpeedIsZero➌) {
MockServiceBus bus{};
AutoBrake auto_brake{ bus };
BOOST_TEST(0 == auto_brake.get_speed_mps()); ➍
}
--------------------------------------------------------------------------
Running 1 test case...
C:/Users/josh/projects/cpp-book/manuscript/part_2/10-testing/samples/boost/
minimal.cpp(80): error: in "InitialCarSpeedIsZero": check 0 == auto_brake.
get_speed_mps() has failed [0 != -9.2559631349317831e+61] ➎
*** 1 failure is detected in the test module "AutoBrakeTest"
Listing 10-42: 故意注释掉 speed_mps 成员初始化器以导致测试失败(使用 Boost Test)
测试模块名称为 AutoBrakeTest ➊。在注释掉 speed_mps 成员初始化器 ➋ 后,你有了 InitialCarSpeedIsZero 测试 ➌。BOOST_TEST 断言测试 speed_mps 是否为零 ➍。与 Catch 和 Google Test 一样,你会看到一个详细的错误信息,告诉你出了什么问题 ➎。
测试夹具
与 Google Test 类似,Boost Test 使用测试夹具的概念来处理常见的设置代码。使用测试夹具很简单,只需声明一个 RAII 对象,其中测试的设置逻辑包含在该类的构造函数中,拆卸逻辑包含在析构函数中。与 Google Test 不同,你不必在测试夹具中继承父类。测试夹具可以与任何用户定义的结构一起使用。
要在单元测试中使用测试夹具,你使用 BOOST_FIXTURE_TEST_CASE 宏,该宏接受两个参数。第一个参数是单元测试的名称,第二个参数是测试夹具类。在宏体内,你实现一个单元测试,就像它是测试夹具类的方法一样,如 Listing 10-43 所示。
#define BOOST_TEST_MODULE TestModuleName
#include <boost/test/unit_test.hpp>
struct MyTestFixture { }; ➊
BOOST_FIXTURE_TEST_CASE➋(MyTestA➌, MyTestFixture) {
// Test A here
}
BOOST_FIXTURE_TEST_CASE(MyTestB➍, MyTestFixture) {
// Test B here
}
--------------------------------------------------------------------------
Running 2 test cases...
*** No errors detected
Listing 10-43: 说明 Boost 测试夹具的使用
在这里,你定义一个名为 MyTestFixture ➊ 的类,并将其作为 BOOST_FIXTURE_TEST_CASE 的第二个参数 ➋。你声明了两个单元测试:MyTestA ➌ 和 MyTestB ➍。在 MyTestFixture 中执行的任何设置操作都会影响每个 BOOST_FIXTURE_TEST_CASE。
接下来,你将使用 Boost Test 测试夹具重新实现 AutoBrake 测试套件。
使用 Boost Test 重构 AutoBrake 单元测试
Listing 10-44 使用 Boost Test 的测试夹具实现了 AutoBrake 单元测试套件。
#define BOOST_TEST_MODULE AutoBrakeTest
#include <boost/test/unit_test.hpp>
#include <functional>
struct IServiceBus {
--snip--
};
struct MockServiceBus : IServiceBus {
--snip--
};
struct AutoBrakeTest { ➊
MockServiceBus bus{};
AutoBrake auto_brake{ bus };
};
BOOST_FIXTURE_TEST_CASE➋(InitialCarSpeedIsZero, AutoBrakeTest) {
BOOST_TEST(0 == auto_brake.get_speed_mps());
}
BOOST_FIXTURE_TEST_CASE(InitialSensitivityIsFive, AutoBrakeTest) {
BOOST_TEST(5 == auto_brake.get_collision_threshold_s());
}
BOOST_FIXTURE_TEST_CASE(SensitivityGreaterThanOne, AutoBrakeTest) {
BOOST_REQUIRE_THROW(auto_brake.set_collision_threshold_s(0.5L),
std::exception);
}
BOOST_FIXTURE_TEST_CASE(SpeedIsSaved, AutoBrakeTest) {
bus.speed_update_callback(SpeedUpdate{ 100L });
BOOST_TEST(100 == auto_brake.get_speed_mps());
bus.speed_update_callback(SpeedUpdate{ 50L });
BOOST_TEST(50 == auto_brake.get_speed_mps());
bus.speed_update_callback(SpeedUpdate{ 0L });
BOOST_TEST(0 == auto_brake.get_speed_mps());
}
BOOST_FIXTURE_TEST_CASE(NoAlertWhenNotImminent, AutoBrakeTest) {
auto_brake.set_collision_threshold_s(2L);
bus.speed_update_callback(SpeedUpdate{ 100L });
bus.car_detected_callback(CarDetected{ 1000L, 50L });
BOOST_TEST(0 == bus.commands_published);
}
BOOST_FIXTURE_TEST_CASE(AlertWhenImminent, AutoBrakeTest) {
auto_brake.set_collision_threshold_s(10L);
bus.speed_update_callback(SpeedUpdate{ 100L });
bus.car_detected_callback(CarDetected{ 100L, 0L });
BOOST_TEST(1 == bus.commands_published);
BOOST_TEST(1L == bus.last_command.time_to_collision_s);
}
--------------------------------------------------------------------------
Running 6 test cases...
*** No errors detected
Listing 10-44: 使用 Boost Test 实现单元测试
你定义了测试夹具类 AutoBrakeTest 来执行 AutoBrake 和 MockServiceBus 的设置 ➊。它与 Google Test 的测试夹具相同,只是你不需要继承任何框架提供的父类。你通过 BOOST_FIXTURE_TEST_CASE 宏来表示每个单元测试 ➋。其余的测试使用 BOOST_TEST 和 BOOST_REQUIRE_THROW 断言宏;否则,这些测试看起来与 Catch 测试非常相似。与 TEST_CASE 和 SECTION 元素不同,你有一个测试夹具类和 BOOST_FIXTURE_TEST_CASE。
总结:测试框架
尽管本节介绍了三种不同的单元测试框架,但实际上有数十种高质量的选项可供选择。没有任何一个框架是绝对优越的。大多数框架都支持相同的基本功能集,而一些更高级的功能则会有不同的支持程度。总的来说,你应该根据最适合你工作风格和提高生产力的框架来选择单元测试框架。
模拟框架
你刚刚探索的单元测试框架适用于各种设置。例如,完全可以使用 Google Test 构建集成测试、验收测试、单元测试,甚至是性能测试。这些测试框架支持广泛的编程风格,它们的创建者对你如何设计软件以使其可测试有着相对保守的看法。
模拟框架比单元测试框架有更多的主观看法。根据不同的模拟框架,你必须遵循一定的设计指南来确定类之间的依赖关系。AutoBrake 类使用了一种现代设计模式,叫做 依赖注入。AutoBrake 类依赖于一个 IServiceBus,你通过 AutoBrake 的构造函数将其注入。你还将 IServiceBus 设为一个接口。也存在其他实现多态行为的方法(如模板),每种方法都有其优缺点。
本节讨论的所有模拟框架都与依赖注入非常兼容。模拟框架在不同程度上减少了定义自己模拟对象的需求。回想一下,你实现了一个 MockServiceBus 来允许你单元测试 AutoBrake,如 列表 10-45 所示。
struct MockServiceBus : IServiceBus {
void publish(const BrakeCommand& cmd) override {
commands_published++;
last_command = cmd;
};
void subscribe(SpeedUpdateCallback callback) override {
speed_update_callback = callback;
};
void subscribe(CarDetectedCallback callback) override {
car_detected_callback = callback;
};
BrakeCommand last_command{};
int commands_published{};
SpeedUpdateCallback speed_update_callback{};
CarDetectedCallback car_detected_callback{};
};
列表 10-45:你自己编写的MockServiceBus
每次你想添加一个涉及与IServiceBus交互的新单元测试时,你可能需要更新你的MockServiceBus类。这是繁琐且容易出错的。此外,你也不清楚是否可以将这个模拟类共享给其他团队:你在其中实现了很多自己的逻辑,这对比如轮胎压力传感器团队可能没有什么用。而且,每个测试可能有不同的要求。模拟框架使你能够定义模拟类,通常通过宏或模板的魔法。在每个单元测试中,你可以根据该测试的需要自定义模拟。这对于单一的模拟定义来说是非常困难的。
模拟声明与模拟特定测试定义的解耦对于两个原因来说非常强大。首先,你可以为每个单元测试定义不同的行为。这允许你例如为某些单元测试模拟异常条件,而不为其他测试模拟。其次,它使得单元测试更加具体。通过将自定义模拟行为放置在单元测试中,而不是在单独的源文件中,开发人员更清楚地了解测试的目标是什么。
使用模拟框架的净效果是它使得模拟变得更加简单。模拟变得容易时,良好的单元测试(以及 TDD)变得可行。如果没有模拟,单元测试可能会非常困难;由于依赖关系的缓慢或易出错,测试可能会变得缓慢、不可靠且脆弱。例如,在你试图使用 TDD 将新功能实现到类中时,通常更倾向于使用模拟数据库连接,而不是完整的生产实例。
本节介绍了两个模拟框架,Google Mock 和 HippoMocks,并简要提到另外两个框架,FakeIt 和 Trompeloeil。由于缺乏编译时代码生成技术,创建模拟框架在 C++中比在大多数其他语言中要困难,尤其是在具有类型反射的语言中,类型反射是一种允许代码程序化推理类型信息的语言特性。因此,有许多高质量的模拟框架,每个框架都有自己的权衡,这些权衡源自模拟 C++的根本困难。
Google Mock
最流行的模拟框架之一是 Google C++模拟框架(或 Google Mock),它作为 Google Test 的一部分包含在内。它是最古老且功能最丰富的模拟框架之一。如果你已经安装了 Google Test,集成 Google Mock 非常简单。首先,确保你在链接器中包含了gmock静态库,就像你为gtest和gtest_main做的那样。接下来,添加#include "gmock/gmock.h"。
如果你使用 Google Test 作为单元测试框架,那么只需要进行上述设置。Google Mock 将与其姐妹库无缝配合工作。如果你使用的是其他单元测试框架,你需要在二进制文件的入口点提供初始化代码,如示例 10-46 所示。
#include "gmock/gmock.h"
int main(int argc, char** argv) {
::testing::GTEST_FLAG(throw_on_failure) = true; ➊
::testing::InitGoogleMock(&argc, argv); ➋
// Unit test as usual, Google Mock is initialized
}
示例 10-46:将 Google Mock 添加到第三方单元测试框架中
GTEST_FLAG中的throw_on_failure ➊ 会导致 Google Mock 在一些 mock 相关的断言失败时抛出异常。调用InitGoogleMock ➋ 会消耗命令行参数,并进行必要的定制(更多细节请参见 Google Mock 文档)。
Mock 一个接口
对于每个需要 mock 的接口,都有一些不太愉快的仪式。你需要将接口的每个virtual函数转换成一个宏。对于非const方法,你使用MOCK_METHOD*,而对于const方法,则使用MOCK_CONST_METHOD*,并用函数参数的个数替换*。MOCK_METHOD的第一个参数是virtual函数的名称,第二个参数是函数的原型。例如,为了构建一个 mock 的IServiceBus,你需要编写如示例 10-47 所示的定义。
struct MockServiceBus : IServiceBus { ➊
MOCK_METHOD1➋(publish➌, void(const BrakeCommand& cmd)➍);
MOCK_METHOD1(subscribe, void(SpeedUpdateCallback callback));
MOCK_METHOD1(subscribe, void(CarDetectedCallback callback));
};
示例 10-47:一个 Google Mock 的MockServiceBus
MockServiceBus的定义开头与任何其他IServiceBus实现的定义相同 ➊。接下来,你会使用三次MOCK_METHOD ➋。第一个参数 ➌ 是virtual函数的名称,第二个参数 ➍ 是函数的原型。
自己生成这些定义有点繁琐。在MockServiceBus的定义中,并没有比IServiceBus中已有的额外信息。无论好坏,这就是使用 Google Mock 的一项成本。你可以通过使用 Google Mock 分发包中的scripts/generator文件夹内的gmock_gen.py工具来减轻生成这些样板代码的负担。你需要安装 Python 2,并且不能保证在所有情况下都能正常工作。有关更多信息,请参见 Google Mock 文档。
现在你已经定义了一个MockServiceBus,可以在单元测试中使用它。与自己定义的 mock 不同,你可以为每个单元测试专门配置一个 Google Mock。你在配置中有极大的灵活性。成功的 mock 配置的关键是使用适当的期望(expectations)。
期望(Expectations)
期望(expectation)就像是 mock 对象的断言;它表示 mock 期望在什么情况下被调用以及它应当做出什么响应。所谓的“情况”是通过称为匹配器(matchers)的对象来指定的。而“它应该做出什么响应”的部分称为动作(action)。接下来的部分将介绍这些概念。
期望通过EXPECT_CALL宏来声明。该宏的第一个参数是模拟对象,第二个参数是预期的方法调用。这个方法调用可以选择性地包含每个参数的匹配器。这些匹配器帮助 Google Mock 判断某个特定的方法调用是否符合预期调用。格式如下:
EXPECT_CALL(mock_object, method(matchers))
有几种方式可以对期望值进行断言,选择哪种方式取决于你对被测试单元与模拟对象交互的要求有多严格。你在乎代码是否调用了你没有预期的模拟函数吗?这实际上取决于应用场景。这就是为什么有三种选择:烦人的、友好的和严格的。
烦人的模拟对象 是默认选项。如果烦人模拟对象的函数被调用且没有与之匹配的EXPECT_CALL,Google Mock 会打印一个关于“不感兴趣的调用”的警告,但测试并不会因为这个不感兴趣的调用而失败。你可以通过在测试中添加一个EXPECT_CALL来快速修复并抑制不感兴趣的调用警告,因为调用就不再是未预期的了。
在某些情况下,可能会有太多不感兴趣的调用。在这种情况下,你应该使用友好的模拟对象。友好的模拟对象不会因为不感兴趣的调用而发出警告。
如果你非常担心与模拟对象的任何未考虑到的交互,你可能会使用严格的模拟对象。如果任何未包含在EXPECT_CALL中的调用发生,严格的模拟对象会使测试失败。
每种模拟对象类型都是一个类模板。实例化这些类的方式非常简单,如 Listing 10-48 中所述。
MockServiceBus naggy_mock➊;
::testing::NiceMock<MockServiceBus> nice_mock➋;
::testing::StrictMock<MockServiceBus> strict_mock➌;
Listing 10-48:Google Mock 的三种不同风格
烦人的模拟对象 ➊ 是默认选项。每个::testing::NiceMock ➋ 和 ::testing::StrictMock ➌ 都需要一个模板参数,即底层模拟对象的类。这三种选项都可以作为EXPECT_CALL的有效第一个参数。
一般来说,你应该使用友好的模拟对象。使用烦人和严格的模拟对象可能导致非常脆弱的测试。当你使用严格的模拟对象时,考虑是否真的有必要对被测试单元与模拟对象的交互做出如此严格的限制。
EXPECT_CALL的第二个参数是你预期被调用的方法的名称,后面跟着你期望该方法使用的参数。有时这很简单,其他时候,你可能需要表达更复杂的条件来指定哪些调用匹配,哪些不匹配。在这种情况下,你可以使用匹配器。
匹配器
当模拟对象的方法带有参数时,你可以自由决定调用是否匹配期望。在简单的情况下,你可以使用字面值。如果模拟方法以完全相同的字面值被调用,则调用会匹配期望;否则,不匹配。另一方面,你可以使用 Google Mock 的::testing::_对象,它告诉 Google Mock,任何值都匹配。
假设,例如,你想调用publish,并且不关心参数是什么。Listing 10-49 中的EXPECT_CALL将是合适的选择。
--snip--
using ::testing::_; ➊
TEST(AutoBrakeTest, PublishIsCalled) {
MockServiceBus bus;
EXPECT_CALL(bus, publish(_➋));
--snip--
}
Listing 10-49: 在期望中使用::testing::_匹配器
为了让单元测试更简洁,你使用了using来处理::testing::_➊。你使用_来告诉 Google Mock,任何带有单个参数的publish调用都会匹配 ➋。
一个稍微更具选择性的匹配器是类模板::testing::A,它仅在方法调用时使用特定类型的参数时才会匹配。这个类型作为A的模板参数来表达,因此A<MyType>只会匹配MyType类型的参数。在 Listing 10-50 中,对 Listing 10-49 的修改展示了一个更严格的期望,它要求publish的参数为BrakeCommand。
--snip--
using ::testing::A; ➊
TEST(AutoBrakeTest, PublishIsCalled) {
MockServiceBus bus;
EXPECT_CALL(bus, publish(A<BrakeCommand>()➋));
--snip--
}
Listing 10-50: 在期望中使用::testing::A匹配器
再次使用using ➊,并使用A<BrakeCommand>来指定只有BrakeCommand类型的参数才能匹配这个期望。
另一个匹配器,::testing::Field,允许你检查传递给模拟对象的参数中的字段。Field匹配器接受两个参数:一个指向你想要检查的字段的指针,另一个是用来表示该字段是否符合标准的匹配器。假设你想要更加具体地指定对publish的调用 ➋:你希望指定time_to_collision_s等于 1 秒。你可以通过 Listing 10-49 中重构的代码实现这一任务,该代码在 Listing 10-51 中显示。
--snip--
using ::testing::Field; ➊
using ::testing::DoubleEq; ➋
TEST(AutoBrakeTest, PublishIsCalled) {
MockServiceBus bus;
EXPECT_CALL(bus, publish(Field(&BrakeCommand::time_to_collision_s➌,
DoubleEq(1L)➍)));
--snip--
}
Listing 10-51: 在期望中使用Field匹配器
你使用using来简化Field ➊和DoubleEq ➋的期望代码。Field匹配器接受指向你关心的字段time_to_collision_s ➌的指针,以及决定该字段是否符合标准的匹配器DoubleEq ➍。
还有许多其他的匹配器,它们在表 10-2 中进行了总结。但请参考 Google Mock 文档了解它们的所有用法细节。
表 10-2: Google Mock 匹配器
| 匹配器 | 当参数是...时匹配 |
|---|---|
_ |
任何正确类型的值 |
A<type>)() |
给定的*type*的值 |
An<type>)() |
给定的*type*的值 |
Ge(value) |
大于或等于*value* |
Gt(value) |
大于*value* |
Le(value) |
小于或等于*value* |
Lt(value) |
小于*value* |
Ne(value) |
不等于*value* |
IsNull() |
空值 |
NotNull() |
非空值 |
Ref(variable) |
*variable*的引用 |
DoubleEq(variable) |
一个大致等于*variable*的double值 |
FloatEq(variable) |
一个大致等于*variable*的float值 |
EndsWith(str) |
以*str*结尾的字符串 |
HasSubstr(str) |
一个包含子字符串*str*的字符串 |
StartsWith(str) |
一个以*str*开头的字符串 |
StrCaseEq(str) |
一个与*str*相等的字符串(忽略大小写) |
StrCaseNe(str) |
一个与*str*不相等的字符串(忽略大小写) |
StrEq(str) |
一个与*str*相等的字符串 |
StrNeq(string) |
字符串不等于*str* |
注意
匹配器的一个有益特性是你可以将它们用作你单元测试中的另一种断言。另一种宏是EXPECT_THAT(value, matcher) 或 *ASSERT_THAT*(value, matcher)。例如,你可以替换掉该断言
ASSERT_GT(power_level, 9000);
使用更具语法美感的
ASSERT_THAT(power_level, Gt(9000));
你可以使用EXPECT_CALL与StrictMock来强制测试单元与模拟对象的交互方式。但你也可能需要指定模拟对象应如何响应调用的次数。这被称为期望的基数。
基数
最常见的指定基数的方法可能是Times,它指定模拟对象应该期望被调用的次数。Times方法接受一个参数,可以是整数字面量或表 10-3 中列出的函数之一。
表 10-3: Google Mock 中基数指定符的列表
| 基数 | 指定一个方法将被调用的次数... |
|---|---|
AnyNumber() |
任意次数 |
AtLeast(n) |
至少 n 次 |
AtMost(n) |
最多 n 次 |
Between(m, n) |
在 m 和 n 之间的次数 |
Exactly(n) |
正好 n 次 |
列表 10-52 详细说明了列表 10-51,指明publish必须只被调用一次。
--snip--
using ::testing::Field;
using ::testing::DoubleEq;
TEST(AutoBrakeTest, PublishIsCalled) {
MockServiceBus bus;
EXPECT_CALL(bus, publish(Field(&BrakeCommand::time_to_collision_s,
DoubleEq(1L)))).Times(1)➊;
--snip--
}
列表 10-52:在期望中使用Times基数指定符
Times调用 ➊ 确保publish被精确调用一次(无论你使用的是友好、严格还是苛刻的模拟)。
注意
同样,你可以指定 Times(Exactly(1))。
现在,你已经掌握了一些工具,可以指定预期调用的标准和基数,你可以自定义模拟对象如何响应这些期望。为此,你需要使用动作。
动作
像基数一样,所有的操作都通过EXPECT_CALL语句进行链式调用。这些语句有助于澄清模拟期望被调用的次数,每次调用时返回的值,以及它应该执行的任何副作用(如抛出异常)。WillOnce和WillRepeatedly操作指定了模拟在接收到查询时应该执行的动作。这些操作可能会变得相当复杂,但为了简洁起见,本节只涵盖两种用法。首先,你可以使用Return构造返回值给调用者:
EXPECT_CALL(jenny_mock, get_your_number()) ➊
.WillOnce(Return(8675309)) ➋
.WillRepeatedly(Return(911))➌;
你按照常规方式设置一个EXPECT_CALL,然后添加一些操作,指定每次调用get_your_number时jenny_mock将返回什么值 ➊。这些操作按从左到右的顺序读取,因此第一个操作WillOnce ➋指定第一次调用get_your_number时,jenny_mock返回值8675309。下一个操作WillRepeatedly ➌指定在所有后续调用中,返回值911。
因为IServiceBus不会返回任何值,所以你需要让操作稍微复杂一些。对于高度可定制的行为,你可以使用Invoke构造,它使你能够传递一个Invocable,该对象将在模拟方法调用时使用传入的精确参数。假设你想保存对AutoBrake通过subscribe注册的回调函数的引用。你可以通过Invoke轻松实现这一点,正如示例 10-53 所示。
CarDetectedCallback callback; ➊
EXPECT_CALL(bus, subscribe(A<CarDetectedCallback>()))
.Times(1)
.WillOnce(Invoke(&callback➋ {
callback = callback_in; ➍
}));
示例 10-53:使用Invoke保存对AutoBrake通过subscribe注册的回调函数的引用
当subscribe第一次(也是唯一一次)使用CarDetectedCallback被调用时,WillOnce(Invoke(...))操作将调用作为参数传入的 lambda。这个 lambda 通过引用捕获了声明的CarDetectedCallback ➊。根据定义,lambda 具有与subscribe函数相同的函数原型,因此你可以使用自动类型推断 ➌ 来确定callback_in的正确类型(它是CarDetectedCallback)。最后,你将callback_in赋值给callback ➍。现在,你可以通过调用你的callback ➊来将事件传递给任何subscribe的对象。Invoke构造是操作的瑞士军刀,因为你可以在完全了解调用参数的情况下执行任意代码。调用参数是模拟方法在运行时接收到的参数。
将所有内容整合起来
在重新考虑我们的AutoBrake测试套件时,你可以将 Google Test 单元测试二进制文件重新实现为使用 Google Mock,而不是手动编写的模拟,正如示例 10-54 所示。
#include "gtest/gtest.h"
#include "gmock/gmock.h"
#include <functional>
using ::testing::_;
using ::testing::A;
using ::testing::Field;
using ::testing::DoubleEq;
using ::testing::NiceMock;
using ::testing::StrictMock;
using ::testing::Invoke;
struct NiceAutoBrakeTest : ::testing::Test { ➊
NiceMock<MockServiceBus> bus;
AutoBrake auto_brake{ bus };
};
struct StrictAutoBrakeTest : ::testing::Test { ➋
StrictAutoBrakeTest() {
EXPECT_CALL(bus, subscribe(A<CarDetectedCallback>())) ➌
.Times(1)
.WillOnce(Invoke(this {
car_detected_callback = x;
}));
EXPECT_CALL(bus, subscribe(A<SpeedUpdateCallback>())) ➍
.Times(1)
.WillOnce(Invoke(this {
speed_update_callback = x;
}));;
}
CarDetectedCallback car_detected_callback;
SpeedUpdateCallback speed_update_callback;
StrictMock<MockServiceBus> bus;
};
TEST_F(NiceAutoBrakeTest, InitialCarSpeedIsZero) {
ASSERT_DOUBLE_EQ(0, auto_brake.get_speed_mps());
}
TEST_F(NiceAutoBrakeTest, InitialSensitivityIsFive) {
ASSERT_DOUBLE_EQ(5, auto_brake.get_collision_threshold_s());
}
TEST_F(NiceAutoBrakeTest, SensitivityGreaterThanOne) {
ASSERT_ANY_THROW(auto_brake.set_collision_threshold_s(0.5L));
}
TEST_F(StrictAutoBrakeTest, NoAlertWhenNotImminent) {
AutoBrake auto_brake{ bus };
auto_brake.set_collision_threshold_s(2L);
speed_update_callback(SpeedUpdate{ 100L });
car_detected_callback(CarDetected{ 1000L, 50L });
}
TEST_F(StrictAutoBrakeTest, AlertWhenImminent) {
EXPECT_CALL(bus, publish(
Field(&BrakeCommand::time_to_collision_s, DoubleEq{ 1L
}))
).Times(1);
AutoBrake auto_brake{ bus };
auto_brake.set_collision_threshold_s(10L);
speed_update_callback(SpeedUpdate{ 100L });
car_detected_callback(CarDetected{ 100L, 0L });
}
示例 10-54:使用 Google Mock 重新实现单元测试,而不是自己编写模拟
在这里,你实际上有两个不同的测试固定器:NiceAutoBrakeTest ➊ 和 StrictAutoBrakeTest ➋。NiceAutoBrakeTest测试实例化了一个NiceMock。这对于InitialCarSpeedIsZero、InitialSensitivityIsFive和SensitivityGreaterThanOne非常有用,因为你不希望测试与模拟对象的任何实际交互;这不是这些测试的重点。但你确实希望关注AlertWhenImminent和NoAlertWhenNotImminent。每次发布事件或订阅类型时,它可能会对你的系统产生重大影响。在这种情况下,使用StrictMock的偏执是有道理的。
在StrictAutoBrakeTest的定义中,你可以看到使用WillOnce/Invoke方法保存每个订阅的回调 ➌➍。这些回调用于AlertWhenImminent和NoAlertWhenNotImminent,以模拟来自服务总线的事件。即使在后台有大量的模拟逻辑,这也使单元测试看起来简洁、清晰且简短。记住,你甚至不需要一个正常工作的服务总线来进行所有这些测试!
HippoMocks
Google Mock 是最早的 C++模拟框架之一,至今仍是主流选择。HippoMocks 是由 Peter Bindels 创建的一个替代性模拟框架。作为一个仅包含头文件的库,HippoMocks 的安装非常简单。只需从 GitHub 拉取最新版本(github.com/dascandy/hippomocks/)。你必须在你的测试中包含"hippomocks.h"头文件。HippoMocks 可以与任何测试框架一起使用。
注意
截至发稿时,HippoMocks 的最新版本是 v5.0。
要使用 HippoMocks 创建模拟对象,首先需要实例化一个MockRespository对象。默认情况下,所有从这个MockRepository派生的模拟对象都需要严格的顺序期望。如果每个期望没有按你指定的确切顺序被调用,测试将会失败。通常,这不是你想要的。要修改这种默认行为,可以将MockRepository上的autoExpect字段设置为false:
MockRepository mocks;
mocks.autoExpect = false;
现在你可以使用MockRepository来生成IServiceBus的一个模拟对象。这是通过(成员)函数模板Mock完成的。这个函数将返回一个指向你新创建的模拟对象的指针:
auto* bus = mocks.Mock<IServiceBus>();
HippoMocks的一个主要卖点在这里得到了展示:注意你不需要像使用 Google Mock 那样生成任何宏化的样板代码来模拟IServiceBus。该框架可以处理普通接口,无需你额外的努力。
设置期望也非常简单。为此,请在MockRespository上使用ExpectCall宏。ExpectCall宏接受两个参数:一个指向你模拟对象的指针和一个指向你期望的方法的指针:
mocks.ExpectCall(bus, IServiceBus::subscribe_to_speed)
这个示例添加了一个期望,即bus.subscribe_to_speed将被调用。你可以向此期望添加几个匹配器,具体如表 10-4 所总结。
表 10-4: HippoMocks 匹配器
| 匹配器 | 指定期望匹配的条件 . . . |
|---|---|
With(args) |
调用参数与*args*匹配 |
Match(predicate) |
用调用参数调用*predicate*时返回 true |
After(expectation) |
*expectation* 已经满足(这对引用先前注册的调用很有用。) |
你可以定义在响应ExpectCall时执行的操作,详情请见表 10-5。
表 10-5: HippoMocks 操作
| Action | 在调用时执行以下操作: |
|---|---|
Return(value) |
返回 *value* 给调用者 |
Throw(exception) |
抛出*exception* |
Do(callable) |
使用调用参数执行*callable* |
默认情况下,HippoMocks 要求期望准确地满足一次(类似于 Google Mock 的 .Times(1) 基数)。
例如,你可以通过以下方式表达期望,即 publish 被调用时,BrakeCommand 的 time_to_collision_s 为 1.0:
mocks.ExpectCall➊(bus, IServiceBus::publish)
.Match➋([](const BrakeCommand& cmd) {
return cmd.time_to_collision_s == Approx(1); ➌
});
你使用ExpectCall来指定bus应该使用publish方法被调用 ➊。你通过Match匹配器 ➋来细化这个期望,Match接受一个谓词,该谓词接受与publish方法相同的参数——一个const BrakeCommand引用。如果BrakeCommand的time_to_collision_s字段为 1.0,你返回true;否则,你返回false ➌,这是完全兼容的。
注意
从 v5.0 版本开始,HippoMocks 不再内置支持近似匹配器。相反,使用了 Catch 的 Approx ➌。
HippoMocks 支持自由函数的函数重载。它也支持方法的重载,但语法不太美观。如果你使用 HippoMocks,最好避免在接口中使用方法重载,因此最好按以下方式重构 IServiceBus:
struct IServiceBus {
virtual ~IServiceBus() = default;
virtual void publish(const BrakeCommand&) = 0;
virtual void subscribe_to_speed(SpeedUpdateCallback) = 0;
virtual void subscribe_to_car_detected(CarDetectedCallback) = 0;
};
注意
有一种设计哲学认为,接口中不应有重载方法,因此如果你认同这一点,那么 HippoMocks 不支持重载方法的问题就不再重要了。
现在subscribe不再是重载的,可以使用 HippoMocks。列表 10-55 重构了测试套件,使用 HippoMocks 与 Catch。
#include "hippomocks.h"
--snip--
TEST_CASE("AutoBrake") {
MockRepository mocks; ➊
mocks.autoExpect = false;
CarDetectedCallback car_detected_callback;
SpeedUpdateCallback speed_update_callback;
auto* bus = mocks.Mock<IServiceBus>();
mocks.ExpectCall(bus, IServiceBus::subscribe_to_speed) ➋
.Do(& {
speed_update_callback = x;
});
mocks.ExpectCall(bus, IServiceBus::subscribe_to_car_detected) ➌
.Do(& {
car_detected_callback = x;
});
AutoBrake auto_brake{ *bus };
SECTION("initializes speed to zero") {
REQUIRE(auto_brake.get_speed_mps() == Approx(0));
}
SECTION("initializes sensitivity to five") {
REQUIRE(auto_brake.get_collision_threshold_s() == Approx(5));
}
SECTION("throws when sensitivity less than one") {
REQUIRE_THROWS(auto_brake.set_collision_threshold_s(0.5L));
}
SECTION("saves speed after update") {
speed_update_callback(SpeedUpdate{ 100L }); ➍
REQUIRE(100L == auto_brake.get_speed_mps());
speed_update_callback(SpeedUpdate{ 50L });
REQUIRE(50L == auto_brake.get_speed_mps());
speed_update_callback(SpeedUpdate{ 0L });
REQUIRE(0L == auto_brake.get_speed_mps());
}
SECTION("no alert when not imminent") {
auto_brake.set_collision_threshold_s(2L);
speed_update_callback(SpeedUpdate{ 100L }); ➎
car_detected_callback(CarDetected{ 1000L, 50L });
}
SECTION("alert when imminent") {
mocks.ExpectCall(bus, IServiceBus::publish) ➏
.Match([](const auto& cmd) {
return cmd.time_to_collision_s == Approx(1);
});
auto_brake.set_collision_threshold_s(10L);
speed_update_callback(SpeedUpdate{ 100L });
car_detected_callback(CarDetected{ 100L, 0L });
}
}
列表 10-55:重新实现列表 10-54,使用 HippoMocks 和 Catch,而不是 Google Mock 和 Google Test。
注意
本节将 HippoMocks 与 Catch 配合使用以进行演示,但 HippoMocks 与本章讨论的所有单元测试框架都兼容。
你创建了 MockRepository ➊,并通过设置 autoExpect 为 false 来放宽严格的调用顺序要求。在声明了两个回调函数后,你创建了一个 IServiceBusMock(无需定义模拟类!),然后设置期望 ➋➌,这些期望将把你的回调函数与 AutoBrake 关联起来。最后,你使用对模拟总线的引用创建 auto_brake。
initializes speed to zero, initializes sensitivity to five和throws when sensitivity less than one测试不需要与模拟进行进一步交互。事实上,作为一个严格模拟,bus不会让任何进一步的交互发生,而不会抱怨。由于 HippoMocks 不允许像 Google Mock 那样的友好模拟,这实际上是 Listing 10-54 和 Listing 10-55 之间的一个根本区别。
在saves speed after update测试 ➍中,你发出一系列speed_update回调,并像之前一样断言速度被正确保存。因为bus是一个严格模拟,你也在隐式地断言没有与服务总线的进一步交互发生。
在no alert when not imminent测试中,无需对speed_update_callback ➎做任何更改。因为模拟是严格的(且你不期望发布BrakeCommand),因此无需其他期望。
注意
HippoMocks 为其模拟提供了NeverCall方法,如果被调用,将提高测试和错误的清晰度。
然而,在alert when imminent测试中,你期望程序会在BrakeCommand上调用publish,因此你设置了这个期望 ➏。你使用Match匹配器提供一个谓词,用于检查time_to_collision_s是否大约等于1。测试的其余部分与之前相同:你向AutoBrake发送SpeedUpdate事件和随后的CarDetected事件,这应导致检测到碰撞。
HippoMocks 是一个比 Google Mock 更精简的模拟框架。它需要的额外设置较少,但灵活性稍差。
注意
HippoMocks 在模拟自由函数方面比 Google Mock 更灵活。HippoMocks 可以直接模拟自由函数和静态类函数,而 Google Mock 则要求你重写代码以使用接口。
关于其他模拟选项的说明:FakeIt 和 Trompeloeil
还有许多其他优秀的模拟框架可用。为了避免这一长章节变得更加冗长,让我们简要地看一下另外两个框架:FakeIt(由 Eran Pe’er 开发,可在github.com/eranpeer/FakeIt/找到)和 Trompeloeil(由 Björn Fahller 开发,可在github.com/rollbear/trompeloeil/找到)。
FakeIt 在使用模式上与 HippoMocks 相似,并且它是一个仅包含头文件的库。不同之处在于,它在构建期望时遵循默认记录模式。FakeIt 并不像指定期望那样一开始就明确,而是在测试结束时验证模拟方法是否正确调用。当然,操作仍然需要在开始时指定。
尽管这种方法完全有效,我更喜欢 Google Mock/HippoMocks 的方法,即在一个简洁的位置提前指定所有期望及其相关操作。
Trompeloeil(来自法语 trompe-l’œil,意为“欺骗眼睛”)可以被视为 Google Mock 的现代替代品。与 Google Mock 类似,它需要为每个要模拟的接口编写一些宏定义的模板代码。作为对这些额外工作的回报,您将获得许多强大的功能,包括动作,例如设置测试变量、根据调用参数返回值以及禁止特定的调用。与 Google Mock 和 HippoMocks 一样,Trompeloeil 需要您提前指定期望和操作(更多细节请参见文档)。
摘要
本章通过扩展的自动驾驶车辆自动制动系统构建示例,探索了 TDD 的基础知识。您自己编写了测试和模拟框架,然后了解了使用现有测试和模拟框架的许多好处。您了解了 Catch、Google Test 和 Boost Test 作为可能的测试框架。对于模拟框架,您深入了解了 Google Mock 和 HippoMocks(简要提及了 FakeIt 和 Trompeloeil)。每个框架都有其优缺点。选择哪个框架,主要应该由哪个框架能使您工作更高效和富有成效来决定。
注意
在本书的其余部分,示例将以单元测试的形式进行。因此,我必须为这些示例选择一个框架。我选择了 Catch,有几个原因。首先,Catch 的语法最简洁,且非常适合书籍形式。以头文件模式编译时,Catch 比 Boost Test 编译得更快。这可以被视为对该框架的推荐(是的,它是),但我的目的是不鼓励使用 Google Test、Boost Test 或其他任何测试框架。您应该在仔细考虑(并且希望有些实验)的基础上做出这样的决策。
练习
10-1. 您的汽车公司已经完成了一个服务的开发,该服务基于观察到的路边标志来检测限速。限速检测团队将定期向事件总线发布以下类型的对象:
struct SpeedLimitDetected {
unsigned short speed_mps;
}
服务总线已扩展以包含这种新类型:
#include <functional>
--snip--
using SpeedUpdateCallback = std::function<void(const SpeedUpdate&)>;
using CarDetectedCallback = std::function<void(const CarDetected&)>;
using SpeedLimitCallback = std::function<void(const SpeedLimitDetected&)>;
struct IServiceBus {
virtual ~IServiceBus() = default;
virtual void publish(const BrakeCommand&) = 0;
virtual void subscribe(SpeedUpdateCallback) = 0;
virtual void subscribe(CarDetectedCallback) = 0;
virtual void subscribe(SpeedLimitCallback) = 0;
};
更新服务以支持新接口,并确保测试仍然通过。
10-2. 为最后已知的速度限制添加一个私有字段。实现一个该字段的 getter 方法。
10-3. 产品负责人希望您将最后已知的速度限制初始化为 39 米每秒。实现一个单元测试,检查一个新构建的 AutoBrake 对象,该对象的最后已知速度限制为 39。
10-4. 使单元测试通过。
10-5. 实现一个单元测试,在该测试中,使用与 SpeedUpdate 和 CarDetected 相同的回调技术发布三个不同的 SpeedLimitDetected 对象。调用每个回调后,检查 AutoBrake 对象上最后已知的速度限制,以确保其匹配。
10-6. 使所有单元测试通过。
10-7. 实现一个单元测试,其中最后已知的速度限制是每秒 35 米,且当前速度为每秒 34 米。确保AutoBrake没有发布任何BrakeCommand。
10-8. 确保所有单元测试都通过。
10-9. 实现一个单元测试,其中最后已知的速度限制是每秒 35 米,然后发布一个速度更新SpeedUpdate,速度为每秒 40 米。确保只发布一个BrakeCommand。time_to_collision_s字段应等于 0。
10-10. 确保所有单元测试都通过。
10-11. 实现一个新的单元测试,其中最后已知的速度限制是每秒 35 米,然后发布一个速度更新SpeedUpdate,速度为每秒 30 米。然后发布一个SpeedLimitDetected,其speed_mps为每秒 25 米。确保只发布一个BrakeCommand。time_to_collision_s字段应等于 0。
10-12. 确保所有单元测试都通过。
进一步阅读
-
通过实例规范 由 Gojko Adzic 著(Manning, 2011)
-
BDD 实践 由 John Ferguson Smart 著(Manning, 2014)
-
优化 C++:提高性能的有效技术 由 Kurt Guntheroth 著(O’Reilly, 2016)
-
敏捷软件开发与敏捷原则、模式与实践(C#实现) 由 Robert C. Martin 著(Prentice Hall, 2006)
-
测试驱动开发:通过实例 由 Kent Beck 著(Pearson, 2002)
-
引导测试的面向对象软件开发 由 Steve Freeman 和 Nat Pryce 著(Addison-Wesley, 2009)
-
“编辑器之战。”
en.wikipedia.org/wiki/Editor_war -
“制表符与空格:永恒的圣战” 由 Jamie Zawinski 著。
www.jwz.org/doc/tabs-vs-spaces.html -
“TDD 死了吗?” 由 Martin Fowler 著。
martinfowler.com/articles/is-tdd-dead/
第十四章:智能指针**
*如果你想做好一些小事,就自己做。如果你想做伟大的事情并产生巨大影响,就学会委派。
—约翰·C·麦克斯韦尔*

在本章中,你将探索 stdlib 和 Boost 库。这些库包含了一组智能指针,它们使用你在第四章中学到的 RAII 范式来管理动态对象。它们还促进了任何编程语言中最强大的资源管理模型。由于一些智能指针使用分配器来定制动态内存分配,本章还概述了如何提供用户定义的分配器。
智能指针
动态对象具有最灵活的生命周期。灵活性带来了巨大的责任,因此你必须确保每个动态对象只会被析构一次。在小型程序中,这看起来可能不太可怕,但外表常常是欺骗性的。想想异常如何影响动态内存管理吧。每次出现错误或异常时,你都需要追踪已成功分配的内存,并确保按照正确的顺序释放它们。
幸运的是,你可以使用 RAII 来处理这种繁琐的事情。通过在 RAII 对象的构造函数中获取动态存储,在析构函数中释放动态存储,泄漏(或双重释放)动态内存变得相对困难。这使得你能够通过移动和拷贝语义来管理动态对象的生命周期。
你可以自己编写这些 RAII 对象,但你也可以使用一些优秀的预先编写好的实现,称为智能指针。智能指针是行为类似指针并实现 RAII 的类模板,用于动态对象。
本节深入探讨了 stdlib 和 Boost 中提供的五种选项:作用域指针、唯一指针、共享指针、弱指针和侵入式指针。它们的所有权模型区分了这五种智能指针类别。
智能指针所有权
每个智能指针都有一个所有权模型,指定它与动态分配对象的关系。当智能指针拥有一个对象时,智能指针的生命周期保证至少与该对象的生命周期一样长。换句话说,当你使用智能指针时,你可以放心地知道被指向的对象是活的,并且不会泄漏。智能指针管理它所拥有的对象,因此你不会忘记销毁它,因为 RAII 已经为你处理了。
在选择使用哪种智能指针时,你的所有权需求决定了你的选择。
作用域指针
作用域指针表示对单个动态对象的不可转移、独占拥有权。不可转移意味着作用域指针不能从一个作用域转移到另一个作用域。独占拥有权意味着它们不能被复制,因此没有其他智能指针可以拥有作用域指针的动态对象。(回想一下在《内存管理》章节中提到的,关于对象的作用域,它是对象在程序中的可见范围,见第 90 页)。
boost::scoped_ptr 在 <boost/smart_ptr/scoped_ptr.hpp> 头文件中定义。
注意
没有标准库作用域指针。
构造
boost::scoped_ptr 接受一个模板参数,该参数对应于被指向的类型,例如 boost::scoped_ptr<int> 表示“指向 int 的作用域指针”类型。
所有智能指针,包括作用域指针,都有两种模式:空 和 满。空智能指针不拥有任何对象,类似于 nullptr。当智能指针被默认构造时,它开始时是空的。
作用域指针提供了一个构造函数,接受一个原始指针。(被指向的类型必须与模板参数匹配。)这将创建一个满作用域指针。通常的惯用法是使用 new 创建一个动态对象并将结果传递给构造函数,如下所示:
boost::scoped_ptr<PointedToType> my_ptr{ new PointedToType };
这一行动态分配了一个 PointedToType,并将其指针传递给作用域指针构造函数。
引入誓言破坏者
为了探索作用域指针,让我们创建一个 Catch 单元测试套件和一个 DeadMenOfDunharrow 类,用于跟踪有多少对象仍然存活,如示例 11-1 所示。
#define CATCH_CONFIG_MAIN ➊
#include "catch.hpp" ➋
#include <boost/smart_ptr/scoped_ptr.hpp> ➌
struct DeadMenOfDunharrow { ➍
DeadMenOfDunharrow(const char* m="") ➎
: message{ m } {
oaths_to_fulfill++; ➏
}
~DeadMenOfDunharrow() {
oaths_to_fulfill--; ➐
}
const char* message;
static int oaths_to_fulfill;
};
int DeadMenOfDunharrow::oaths_to_fulfill{};
using ScopedOathbreakers = boost::scoped_ptr<DeadMenOfDunharrow>; ➑
示例 11-1:设置一个带有 DeadMenOfDunharrow 类的 Catch 单元测试套件,用于研究作用域指针
首先,你声明 CATCH_CONFIG_MAIN,这样 Catch 会提供一个入口点 ➊,并包含 Catch 头文件 ➋,然后是 Boost 作用域指针的头文件 ➌。接下来,你声明 DeadMenOfDunharrow 类 ➍,它接受一个可选的空终止字符串并将其保存到 message 字段 ➎。一个名为 oaths_to_fulfill 的 static int 字段用于跟踪已经构造的 DeadMenOfDunharrow 对象的数量。因此,你在构造函数中递增 ➏,在析构函数中递减 ➐。最后,你声明 ScopedOathbreakers 类型别名以便于使用 ➑。
CATCH 示例
从现在开始,你将在大多数示例中使用 Catch 单元测试。为了简洁起见,示例省略了以下 Catch 流程:
#define CATCH_CONFIG_MAIN
#include "catch.hpp"
所有包含 TEST_CASE 的示例都需要这个前言。
此外,每个示例中的所有测试用例都通过,除非有注释指示相反。为了简洁起见,示例省略了“所有测试通过”这一输出。
最后,使用先前示例中的自定义类型、函数和变量的测试将省略它们,以简化代码。
基于所有权的隐式布尔转换
有时你需要判断一个 scoped_ptr 是否拥有一个对象,或者它是否为空。方便的是,scoped_ptr 会根据其所有权状态隐式转换为 bool:如果它拥有一个对象则为 true,否则为 false。清单 11-2 展示了这种隐式转换行为是如何工作的。
TEST_CASE("ScopedPtr evaluates to") {
SECTION("true when full") {
ScopedOathbreakers aragorn{ new DeadMenOfDunharrow{} }; ➊
REQUIRE(aragorn); ➋
}
SECTION("false when empty") {
ScopedOathbreakers aragorn; ➌
REQUIRE_FALSE(aragorn); ➍
}
}
清单 11-2:boost::scoped_ptr 隐式转换为 bool。
当你使用带指针的构造函数 ➊ 时,scoped_ptr 会转换为 true ➋。当你使用默认构造函数 ➌ 时,scoped_ptr 会转换为 false ➍。
RAII 包装器
当scoped_ptr拥有一个动态对象时,它确保正确的动态对象管理。在scoped_ptr的析构函数中,它会检查是否拥有一个对象。如果拥有,scoped_ptr的析构函数会删除该动态对象。
清单 11-3 通过在 scoped_ptr 初始化之间检查静态变量 oaths_to_fulfill,展示了这种行为。
TEST_CASE("ScopedPtr is an RAII wrapper.") {
REQUIRE(DeadMenOfDunharrow::oaths_to_fulfill == 0); ➊
ScopedOathbreakers aragorn{ new DeadMenOfDunharrow{} }; ➋
REQUIRE(DeadMenOfDunharrow::oaths_to_fulfill == 1); ➌
{
ScopedOathbreakers legolas{ new DeadMenOfDunharrow{} }; ➍
REQUIRE(DeadMenOfDunharrow::oaths_to_fulfill == 2); ➎
} ➏
REQUIRE(DeadMenOfDunharrow::oaths_to_fulfill == 1); ➐
}
清单 11-3:boost::scoped_ptr 是一个 RAII 包装器。
在测试开始时,oaths_to_fulfill 为 0,因为你还没有构造任何 DeadMenOfDunharrow 对象 ➊。你构造了 scoped_ptr aragorn 并传入指向动态 DeadMenOfDunharrow 对象的指针 ➋。这使得 oaths_to_fulfill 增加到 1 ➌。接着在一个嵌套作用域中,你声明了另一个 scoped_ptr legolas ➍。由于 aragorn 仍然存在,oaths_to_fulfill 此时为 2 ➎。等到内层作用域结束,legolas 超出作用域并析构,带走了一个 DeadMenOfDunharrow ➏。这使得 DeadMenOfDunharrow 减少到 1 ➐。
指针语义
为了方便,scoped_ptr 实现了解引用运算符 operator* 和成员解引用运算符 operator->,这些运算符仅仅将调用委托给被拥有的动态对象。你甚至可以通过 get 方法从 scoped_ptr 中提取出原始指针,正如 清单 11-4 所演示的那样。
TEST_CASE("ScopedPtr supports pointer semantics, like") {
auto message = "The way is shut";
ScopedOathbreakers aragorn{ new DeadMenOfDunharrow{ message } }; ➊
SECTION("operator*") {
REQUIRE((*aragorn).message == message); ➋
}
SECTION("operator->") {
REQUIRE(aragorn->message == message); ➌
}
SECTION("get(), which returns a raw pointer") {
REQUIRE(aragorn.get() != nullptr); ➍
}
}
清单 11-4:boost::scoped_ptr 支持指针语义。
你构造了 scoped_ptr aragorn 并将 message 设置为 The way is shut ➊,你在三个不同的场景中测试指针语义。首先,你可以使用 operator* 来解引用底层指向的动态对象。在这个例子中,你解引用 aragorn 并提取 message 来验证它是否匹配 ➋。你也可以使用 operator-> 来执行成员解引用 ➌。最后,如果你想获取指向动态对象的原始指针,可以使用 get 方法来提取它 ➍。
与 nullptr 的比较
scoped_ptr 类模板实现了比较运算符 operator== 和 operator!=,这些运算符仅在比较 scoped_ptr 与 nullptr 时才有定义。从功能上讲,这与隐式的 bool 转换基本相同,正如 清单 11-5 所展示的那样。
TEST_CASE("ScopedPtr supports comparison with nullptr") {
SECTION("operator==") {
ScopedOathbreakers legolas{};
REQUIRE(legolas == nullptr); ➊
}
SECTION("operator!=") {
ScopedOathbreakers aragorn{ new DeadMenOfDunharrow{} };
REQUIRE(aragorn != nullptr); ➋
}
}
清单 11-5:boost::scoped_ptr 支持与 nullptr 的比较。
空的 scoped 指针等于(==) nullptr ➊,而非空的 scoped 指针不等于(!=) nullptr ➋。
交换
有时你希望交换一个 scoped_ptr 所拥有的动态对象与另一个 scoped_ptr 所拥有的动态对象。这被称为 对象交换,scoped_ptr 包含一个 swap 方法来实现这一行为,如 清单 11-6 所示。
TEST_CASE("ScopedPtr supports swap") {
auto message1 = "The way is shut.";
auto message2 = "Until the time comes.";
ScopedOathbreakers aragorn {
new DeadMenOfDunharrow{ message1 } ➊
};
ScopedOathbreakers legolas {
new DeadMenOfDunharrow{ message2 } ➋
};
aragorn.swap(legolas); ➌
REQUIRE(legolas->message == message1); ➍
REQUIRE(aragorn->message == message2); ➎
}
清单 11-6:boost::scoped_ptr 支持 swap。
你构造了两个 scoped_ptr 对象,aragorn ➊ 和 legolas ➋,每个对象都有不同的消息。在你执行 aragorn 和 legolas 之间的交换 ➌ 后,它们交换了动态对象。当你交换后获取它们的消息时,你会发现它们已经交换了 ➍ ➎。
重置与替换 scoped_ptr
你通常不希望在 scoped_ptr 对象销毁之前析构它所拥有的对象。例如,你可能希望用一个新的动态对象替换它所拥有的对象。你可以使用 scoped_ptr 的重载 reset 方法来处理这两项任务。
如果你不提供任何参数,reset 只会销毁所拥有的对象。
如果你提供一个新的动态对象作为参数,reset 将首先销毁当前拥有的对象,然后获取该参数的所有权。清单 11-7 通过为每种情况提供一个测试,展示了这种行为。
TEST_CASE("ScopedPtr reset") {
ScopedOathbreakers aragorn{ new DeadMenOfDunharrow{} }; ➊
SECTION("destructs owned object.") {
aragorn.reset(); ➋
REQUIRE(DeadMenOfDunharrow::oaths_to_fulfill == 0); ➌
}
SECTION("can replace an owned object.") {
auto message = "It was made by those who are Dead.";
auto new_dead_men = new DeadMenOfDunharrow{ message }; ➍
REQUIRE(DeadMenOfDunharrow::oaths_to_fulfill == 2); ➎
aragorn.reset(new_dead_men); ➏
REQUIRE(DeadMenOfDunharrow::oaths_to_fulfill == 1); ➐
REQUIRE(aragorn->message == new_dead_men->message); ➑
REQUIRE(aragorn.get() == new_dead_men); ➒
}
}
清单 11-7:boost::scoped_ptr 支持 reset。
两个测试的第一步都是构造一个 scoped_ptr 指针 aragorn,它拥有一个 DeadMenOfDunharrow ➊。在第一个测试中,你不带参数地调用 reset ➋。这会导致 scoped_ptr 析构它所拥有的对象,oaths_to_fulfill 减少到 0 ➌。
在第二个测试中,你创建了新的、动态分配的 new_dead_men,并附加了自定义的 message ➍。这将使 oaths_to_fill 增加到 2,因为 aragorn 依然存活 ➎。接下来,你调用 reset,并以 new_dead_men 作为参数 ➏,这会做两件事:
-
它导致原本由
aragorn所拥有的DeadMenOfDunharrow被析构,这使得oaths_to_fulfill减少到 1 ➐。 -
它将
new_dead_men作为由aragorn所拥有的动态分配对象。当你解引用message字段时,会发现它与new_dead_men所持有的message匹配 ➑。(等效地,aragorn.get()返回new_dead_men➒。)
不可转移性
你不能移动或复制 scoped_ptr,使其成为不可转移的。清单 11-8 展示了尝试移动或复制 scoped_ptr 会导致无效程序。
void by_ref(const ScopedOathbreakers&) { } ➊
void by_val(ScopedOathbreakers) { } ➋
TEST_CASE("ScopedPtr can") {
ScopedOathbreakers aragorn{ new DeadMenOfDunharrow };
SECTION("be passed by reference") {
by_ref(aragorn); ➌
}
SECTION("not be copied") {
// DOES NOT COMPILE:
by_val(aragorn); ➍
auto son_of_arathorn = aragorn; ➐
}
SECTION("not be moved") {
// DOES NOT COMPILE:
by_val(std::move(aragorn)); ➏
auto son_of_arathorn = std::move(aragorn); ➐
}
}
清单 11-8:boost::scoped_ptr 是不可转移的。(此代码无法编译。)
首先,你声明接受scoped_ptr引用 ➊ 和值 ➋ 的虚拟函数。你仍然可以通过引用 ➌ 传递scoped_ptr,但是尝试通过值传递将无法编译 ➍。此外,尝试使用scoped_ptr的复制构造函数或复制赋值操作符 ➎ 也将无法编译。如果你尝试使用std::move移动一个scoped_ptr,你的代码也将无法编译 ➏➐。
注意
通常,使用boost::scoped_ptr不会比使用原始指针产生额外的开销。
boost::scoped_array
boost::scoped_array是一个用于动态数组的作用域指针。它支持与boost::scoped_ptr相同的用法,但它还实现了operator[],因此你可以像操作原始数组一样与作用域数组的元素进行交互。清单 11-9 说明了这一附加功能。
TEST_CASE("ScopedArray supports operator[]") {
boost::scoped_array<int➊> squares{
new int➋[5] { 0, 4, 9, 16, 25 }
};
squares[0] = 1; ➌
REQUIRE(squares[0] == 1); ➍
REQUIRE(squares[1] == 4);
REQUIRE(squares[2] == 9);
}
清单 11-9:boost::scoped_array实现了operator[]。
你声明scoped_array的方式与声明scoped_ptr相同,使用单一的模板参数 ➊。对于scoped_array,模板参数是数组中包含的类型 ➋,而不是数组的类型。你将一个动态数组传递给squares的构造函数,使得动态数组squares成为该数组的所有者。你可以使用operator[]来写入 ➌ 和读取 ➍ 元素。
支持的部分操作列表
到目前为止,你已经了解了作用域指针的主要特性。作为参考,表 11-1 列出了所有已讨论的运算符,以及一些尚未覆盖的运算符。在表格中,ptr是一个原始指针,而s_ptr是一个作用域指针。有关更多信息,请参阅 Boost 文档。
表 11-1: 所有支持的boost::scoped_ptr操作
| 操作 | 说明 |
|---|---|
scoped_ptr<...>{ } 或 scoped_ptr <...>{ nullptr } |
创建一个空的作用域指针。 |
scoped_ptr <...>{ ptr } |
创建一个作用域指针,拥有由 ptr 指向的动态对象。 |
~scoped_ptr<...>() |
如果已满,则对拥有的对象调用delete。 |
s_ptr1.swap(s_ptr2) |
交换 s_ptr1 和 s_ptr2 之间的拥有对象。 |
swap(s_ptr1, s_ptr2) |
与swap方法相同的自由函数。 |
s_ptr.reset() |
如果已满,则对s_ptr拥有的对象调用delete。 |
s_ptr.reset(ptr) |
删除当前拥有的对象,然后获取 ptr 的所有权。 |
ptr = s_ptr.get() |
返回原始指针ptr;s_ptr保持所有权。 |
*s_ptr |
对拥有对象的解引用操作符。 |
s_ptr-> |
对拥有对象的成员解引用操作符。 |
bool{ s_ptr } |
bool转换:如果已满则为true,如果为空则为false。 |
唯一指针
一个唯一指针对单一动态对象拥有可转移的独占所有权。你可以移动唯一指针,这使得它们具有可转移性。它们也拥有独占所有权,因此不能被复制。标准库提供了一个在<memory>头文件中的unique_ptr。
注意
Boost 并不提供独占指针。
构造
std::unique_ptr接受一个模板参数,对应于所指向的类型,例如std::unique_ptr<int>表示“指向int类型的独占指针”。
与作用域指针类似,独占指针具有一个默认构造函数,将独占指针初始化为空。它还提供一个接受原始指针的构造函数,该构造函数获取所指向的动态对象的所有权。一个构造方法是使用new创建一个动态对象,并将结果传递给构造函数,像这样:
std::unique_ptr<int> my_ptr{ new int{ 808 } };
另一种方法是使用std::make_unique函数。make_unique是一个模板函数,它接受所有参数并将它们转发到模板参数的适当构造函数中。这避免了使用new的需要。通过使用std::make_unique,你可以将前面的对象初始化重写为:
auto my_ptr = make_unique<int>(808);
make_unique函数是为了避免在使用 C++旧版本的new时出现一些微妙的内存泄漏问题而创建的。然而,在 C++的最新版本中,这些内存泄漏问题已经不再发生。你选择使用哪种构造函数主要取决于你的偏好。
支持的操作
std::unique_ptr函数支持boost::scoped_ptr支持的所有操作。例如,你可以使用以下类型别名作为清单 11-1 到 11-7 中的ScopedOathbreakers的替代:
using UniqueOathbreakers = std::unique_ptr<DeadMenOfDunharrow>;
独占指针和作用域指针的主要区别之一是,你可以移动独占指针,因为它们是可转移的。
可转移的、独占的所有权
不仅独占指针是可转移的,而且它们具有独占所有权(你不能复制它们)。清单 11-10 演示了如何使用unique_ptr的移动语义。
TEST_CASE("UniquePtr can be used in move") {
auto aragorn = std::make_unique<DeadMenOfDunharrow>(); ➊
SECTION("construction") {
auto son_of_arathorn{ std::move(aragorn) }; ➋
REQUIRE(DeadMenOfDunharrow::oaths_to_fulfill == 1); ➌
}
SECTION("assignment") {
auto son_of_arathorn = std::make_unique<DeadMenOfDunharrow>(); ➍
REQUIRE(DeadMenOfDunharrow::oaths_to_fulfill == 2); ➎
son_of_arathorn = std::move(aragorn); ➏
REQUIRE(DeadMenOfDunharrow::oaths_to_fulfill == 1); ➐
}
}
清单 11-10:std::unique_ptr支持用于转移所有权的移动语义。
这个清单创建了一个名为aragorn的unique_ptr ➊,你将在两个不同的测试中使用它。
在第一次测试中,你将aragorn通过std::move移动到son_of_arathorn的移动构造函数中 ➋。因为aragorn将其DeadMenOfDunharrow的所有权转移给了son_of_arathorn,所以oaths_to_fulfill对象的值仍然是 1 ➌。
第二次测试通过make_unique构造son_of_arathorn ➍,这将oaths_to_fulfill的值推至 2 ➎。接下来,你使用移动赋值操作符将aragorn移入son_of_arathorn ➏。同样,aragorn将所有权转移给son_of_aragorn。由于son_of_aragorn一次只能拥有一个动态对象,因此移动赋值操作符会销毁当前拥有的对象,然后清空aragorn的动态对象。这导致oaths_to_fulfill的值减小至 1 ➐。
独占数组
与boost::scoped_ptr不同,std::unique_ptr内置了对动态数组的支持。你只需将数组类型作为模板参数,像这样使用独占指针的类型:std::unique_ptr<int[]>。
非常重要的是,你不要使用动态数组 T[] 来初始化 std::unique_ptr<T>。这样做会导致未定义的行为,因为你会导致对数组执行 delete(而不是 delete[])。编译器无法拯救你,因为 operator new[] 返回的指针与 operator new 返回的指针是无法区分的。
和 scoped_array 类似,unique_ptr 到数组类型提供了 operator[] 来访问元素。清单 11-11 演示了这一概念。
TEST_CASE("UniquePtr to array supports operator[]") {
std::unique_ptr<int[]➊> squares{
new int[5]{ 1, 4, 9, 16, 25 } ➋
};
squares[0] = 1; ➌
REQUIRE(squares[0] == 1); ➍
REQUIRE(squares[1] == 4);
REQUIRE(squares[2] == 9);
}
清单 11-11:std::unique_ptr 到数组类型支持 operator[]。
模板参数 int[] ➊ 指示 std::unique_ptr 拥有一个动态数组。你传入一个新创建的动态数组 ➋,然后使用 operator[] 来设置第一个元素 ➌;接着你使用 operator[] 来检索元素 ➍。
删除器
std::unique_ptr 有第二个可选模板参数,称为删除器类型。unique pointer 的 删除器 是在 unique pointer 需要销毁其拥有的对象时调用的内容。
unique_ptr 实例化包含以下模板参数:
std::unique_ptr<T, Deleter=std::default_delete<T>>
这两个模板参数分别是 T,表示拥有的动态对象类型,以及 Deleter,表示负责释放拥有对象的对象类型。默认情况下,Deleter 是 std::default_delete<T>,它调用 delete 或 delete[] 来删除动态对象。
要编写自定义删除器,所需的只是一个可调用的类似函数的对象,该对象可以使用 T* 来调用。(unique pointer 会忽略删除器的返回值。)你将此删除器作为第二个参数传递给 unique pointer 的构造函数,如 清单 11-12 所示。
#include <cstdio>
auto my_deleter = [](int* x) { ➊
printf("Deleting an int at %p.", x);
delete x;
};
std::unique_ptr<int➋, decltype(my_deleter)➌> my_up{
new int,
my_deleter
};
清单 11-12:将自定义删除器传递给 unique pointer
拥有的对象类型是 int ➋,所以你声明了一个 my_deleter 函数对象,它接受一个 int* ➊。你使用 decltype 来设置删除器模板参数 ➌。
自定义删除器和系统编程
当 delete 不提供你需要的资源释放行为时,你会使用自定义删除器。在某些环境下,你可能永远不需要自定义删除器,而在其他情况下,例如系统编程,你可能会发现它们非常有用。考虑一个简单的例子,使用 <cstdio> 头文件中的底层 API fopen、fprintf 和 fclose 管理文件。
fopen 函数打开一个文件,其签名如下:
FILE*➊ fopen(const char *filename➋, const char *mode➌);
成功时,fopen 返回一个非 nullptr 值的 FILE* ➊。失败时,fopen 返回 nullptr 并将静态 int 变量 errno 设置为一个错误代码,例如访问被拒绝(EACCES = 13)或没有此文件(ENOENT = 2)。
注意
请参阅 errno.h 头文件,以查看所有错误条件及其对应的整数值。
FILE*文件句柄是操作系统管理的文件的引用。句柄是操作系统中某些资源的一个不透明、抽象的引用。fopen函数接受两个参数:filename ➋是你想要打开的文件路径,mode ➌是表 11-2 中列出的六个选项之一。
表 11-2:fopen的六种mode选项
| 字符串 | 操作 | 文件存在: | 文件不存在: | 备注 |
|---|---|---|---|---|
r |
读 | fopen失败 |
||
w |
写 | 覆盖 | 创建 | 如果文件存在,所有内容会被丢弃。 |
a |
附加 | 创建 | 总是写入文件末尾。 | |
r+ |
读/写 | fopen失败 |
||
w+ |
读/写 | 覆盖 | 创建 | 如果文件存在,所有内容会被丢弃。 |
a+ |
读/写 | 创建 | 总是写入文件末尾。 |
一旦使用完文件,你必须手动用fclose关闭它。未关闭文件句柄是资源泄漏的常见来源,如下所示:
void fclose(FILE* file);
要写入文件,可以使用fprintf函数,它类似于将内容打印到控制台的printf,但fprintf将内容打印到文件中。fprintf函数的使用方法与printf完全相同,只不过你需要在格式字符串之前提供文件句柄作为第一个参数:
int➊ fprintf(FILE* file➋, const char* format_string➌, ...➍);
成功时,fprintf返回写入打开文件的字符数 ➊ ➋。format_string与printf的格式字符串相同 ➌,变参也是一样的 ➍。
你可以使用std::unique_ptr管理FILE。显然,当你准备关闭文件时,你不希望调用delete来释放FILE*文件句柄。相反,你需要使用fclose来关闭。因为fclose是一个类似函数的对象,接受FILE*作为参数,所以它是一个合适的删除器。
清单 11-13 中的程序将字符串HELLO, DAVE.写入文件HAL9000,并使用唯一指针来执行打开文件的资源管理。
#include <cstdio>
#include <memory>
using FileGuard = std::unique_ptr<FILE, int(*)(FILE*)>; ➊
void say_hello(FileGuard file➋) {
fprintf(file.get(), "HELLO DAVE"); ➌
}
int main() {
auto file = fopen("HAL9000", "w"); ➍
if (!file) return errno; ➎
FileGuard file_guard{ file, fclose }; ➏
// File open here
say_hello(std::move(file_guard)); ➐
// File closed here
return 0;
}
清单 11-13:使用std::unique_ptr和自定义删除器管理文件句柄的程序
这个列表将FileGuard类型别名简化为➊(注意,删除器类型与fclose的类型匹配)。接下来是一个sa_hello函数,它按值接受FileGuard ➋。在sa_hello内,你用fprintf HELLO DAVE将内容写入file ➌。由于file的生命周期与sa_hello绑定,文件会在sa_hello返回时被关闭。在main函数中,你以w模式打开文件HAL9000,这会创建或覆盖该文件,并将原始FILE*文件句柄保存到file ➍。你检查file是否为nullptr,表示打开文件时发生错误,如果HAL9000无法打开,则返回errno ➎。接着,你通过传递文件句柄file和自定义删除器fclose来构造一个FileGuard ➏。此时,文件已打开,并且由于自定义删除器,file_guard会自动管理文件的生命周期。
要调用say_hello,需要将所有权传递到该函数中(因为它按值接受FileGuard)➐。回想一下在“值类别”中提到的内容(见第 124 页),像file_guard这样的变量是左值。这意味着你必须通过std::move将它转移到say_hello中,这样就会将HELLO DAVE写入文件。如果省略了std::move,编译器会尝试将其复制到say_hello中。由于unique_ptr有一个删除的拷贝构造函数,这将导致编译错误。 |
当say_hello返回时,它的FileGuard参数会被销毁,且自定义删除器会在文件句柄上调用fclose。基本上,不可能泄漏文件句柄。你已经将其绑定到了FileGuard的生命周期上。 |
支持的操作的部分列表
表 11-3 列出了所有支持的std::unique_ptr操作。在此表中,ptr是一个原始指针,u_ptr是一个独占指针,del是一个删除器。 |
表 11-3: 所有支持的std::unique_ptr操作
| 操作 | 说明 |
|---|---|
unique_ptr<...>{ } 或 unique_ptr<...>{ nullptr } |
创建一个空的独占指针,使用std::default_delete<...>删除器。 |
unique_ptr<...>{ ptr } |
创建一个拥有ptr指向的动态对象的独占指针。使用std::default_delete<...>删除器。 |
unique_ptr<...>{ ptr, del } |
创建一个拥有ptr指向的动态对象的独占指针。使用 del 作为删除器。 |
unique_ptr<...>{ move(u_ptr) } |
创建一个拥有u_ptr指向的动态对象的独占指针。将所有权从 u_ptr 转移到新创建的独占指针。还会移动 u_ptr 的删除器。 |
~unique_ptr<...>() |
如果已满,则对拥有的对象调用删除器。 |
u_ptr1 = move(u_ptr2) |
将 u_ptr2 的拥有对象和删除器的所有权转移到 u_ptr1。如果已经有对象,则销毁当前拥有的对象。 |
u_ptr1.swap(u_ptr2) |
在 u_ptr1 和 u_ptr2 之间交换拥有的对象和删除器。 |
swap(u_ptr1, u_ptr2) |
一个与swap方法相同的自由函数。 |
u_ptr.reset() |
如果已满,则对 u_ptr 拥有的对象调用删除器。 |
u_ptr.reset(ptr) |
删除当前拥有的对象;然后获得 ptr 的所有权。 |
ptr = u_ptr.release() |
返回原始指针 ptr;u_ptr 变为空。删除器不会被调用。 |
ptr = u_ptr.get() |
返回原始指针 ptr;u_ptr 保持所有权。 |
*u_ptr |
对拥有的对象执行解引用操作符。 |
u_ptr-> |
对拥有的对象执行成员解引用操作符。 |
u_ptr[index] |
引用索引处的元素(仅限数组)。 |
bool{ u_ptr } |
bool转换:如果已满则为true,如果为空则为false。 |
u_ptr1 == u_ptr2u_ptr1 != u_ptr2u_ptr1 > u_ptr2u_ptr1 >= u_ptr2u_ptr1 < u_ptr2u_ptr1 <= u_ptr2 |
比较操作符;相当于对原始指针执行比较操作符。 |
u_ptr.get_deleter() |
返回对删除器的引用。 |
共享指针
共享指针对单个动态对象拥有可转移、非独占的所有权。你可以移动共享指针,这使得它们是可转移的,而且你可以复制它们,这使得它们的所有权是非独占的。
非独占所有权意味着shared_ptr会检查是否有其他shared_ptr对象也拥有该对象,在销毁它之前。这样,最后一个拥有者将是释放该对象的对象。
标准库中在<memory>头文件中提供了std::shared_ptr,而 Boost 则在<boost/smart_ptr/shared_ptr.hpp>头文件中提供了boost::shared_ptr。这里我们使用标准库版本。
注意
标准库和 Boost 的shared_ptr基本相同,唯一的显著区别是 Boost 的 shared pointer 不支持数组,并且需要使用boost::shared_array类(位于<boost/smart_ptr/shared_array.hpp>中)。Boost 提供了一个共享指针是为了向后兼容,但你应该使用标准库的共享指针。
构造
std::shared_ptr指针支持与std::unique_ptr相同的所有构造函数。默认构造函数会生成一个空的共享指针。若要建立对动态对象的所有权,你可以将一个指针传递给shared_ptr构造函数,如下所示:
std::shared_ptr<int> my_ptr{ new int{ 808 } };
你还可以使用一个推导参数的std::make_shared模板函数,将参数传递给所指向类型的构造函数:
auto my_ptr = std::make_shared<int>(808);
通常你应该使用make_shared。共享指针需要一个控制块,它跟踪多个量,包括共享所有者的数量。当你使用make_shared时,你可以同时分配控制块和被拥有的动态对象。如果你先使用operator new,然后再分配一个共享指针,那你就是进行了两次分配,而不是一次。
注意
有时你可能不想使用make_shared。例如,如果你要使用weak_ptr,即使你能释放对象,你仍然需要控制块。在这种情况下,你可能会更倾向于使用两个分配。
由于控制块是一个动态对象,shared_ptr对象有时需要分配动态对象。如果你想控制shared_ptr的分配方式,可以重载operator new。但这就像用大炮打麻雀一样。一个更合适的方法是提供一个可选的模板参数,称为分配器类型。
指定分配器
分配器负责分配、创建、销毁和释放对象。默认分配器std::allocator是一个在<memory>头文件中定义的模板类。默认分配器从动态存储区分配内存,并接受一个模板参数。(你将在“分配器”一章中了解如何使用用户自定义分配器来定制这一行为,见第 365 页)。
shared_ptr 构造函数和 make_shared 都有一个分配器类型模板参数,总共包含三个模板参数:指向的类型、删除器类型和分配器类型。由于复杂的原因,你只需要声明指向的类型参数。你可以将其他参数类型视为从指向的类型中推导出来的。
例如,以下是一个完整的 make_shared 调用,包含一个构造函数参数、一个自定义删除器和一个显式的 std::allocator:
std::shared_ptr<int➊> sh_ptr{
new int{ 10 }➋,
[](int* x) { delete x; } ➌,
std::allocator<int>{} ➍
};
在这里,你为指向的类型 ➊ 指定了一个单一的模板参数 int。在第一个参数中,你为 int 分配并初始化内存 ➋。接下来是一个自定义删除器 ➌,作为第三个参数,你传递一个 std::allocator ➍。
出于技术原因,你无法在 make_shared 中使用自定义删除器或自定义分配器。如果你需要自定义分配器,可以使用 make_shared 的姐妹函数,即 std::allocate_shared。std::allocate_shared 函数将分配器作为第一个参数,并将其余的参数转发给拥有对象的构造函数:
auto sh_ptr = std::allocate_shared<int➊>(std::allocator<int>{}➋, 10➌);
与 make_shared 一样,你将拥有的类型指定为模板参数 ➊,但是将分配器作为第一个参数 ➋。其余的参数会转发给 int 的构造函数 ➌。
注意
对于好奇的人,以下是不能使用自定义删除器与 make_shared 的两个原因。首先,make_shared 使用 new 来为拥有的对象和控制块分配空间。适合 new 的删除器是 delete,因此通常自定义删除器不合适。其次,自定义删除器通常无法知道如何处理控制块,只能处理拥有的对象。
无法使用 make_shared 或 allocate_shared 指定自定义删除器。如果你想在共享指针中使用自定义删除器,必须直接使用适当的 shared_ptr 构造函数之一。
支持的操作
std::shared_ptr 支持 std::unique_ptr 和 boost::scoped_ptr 支持的所有操作。你可以使用以下类型别名来替代 Listings 11-1 到 11-7 中的 ScopedOathbreakers 和 Listings 11-10 到 11-13 中的 UniqueOathbreakers:
using SharedOathbreakers = std::shared_ptr<DeadMenOfDunharrow>;
共享指针和独占指针之间的主要功能差异在于,你可以复制共享指针。
可转移的、非独占所有权
共享指针是可转移的(你可以移动它们),并且具有非独占所有权(你可以复制它们)。Listing 11-10,展示了独占指针的移动语义,对于共享指针也是一样的。 Listing 11-14 证明共享指针也支持复制语义。
TEST_CASE("SharedPtr can be used in copy") {
auto aragorn = std::make_shared<DeadMenOfDunharrow>();
SECTION("construction") {
auto son_of_arathorn{ aragorn }; ➊
REQUIRE(DeadMenOfDunharrow::oaths_to_fulfill == 1); ➋
}
SECTION("assignment") {
SharedOathbreakers son_of_arathorn; ➌
son_of_arathorn = aragorn; ➍
REQUIRE(DeadMenOfDunharrow::oaths_to_fulfill == 1); ➎
}
SECTION("assignment, and original gets discarded") {
auto son_of_arathorn = std::make_shared<DeadMenOfDunharrow>(); ➏
REQUIRE(DeadMenOfDunharrow::oaths_to_fulfill == 2);➐
son_of_arathorn = aragorn; ➑
REQUIRE(DeadMenOfDunharrow::oaths_to_fulfill == 1); ➒
}
}
Listing 11-14: std::shared_ptr 支持复制。
在构造共享指针aragorn之后,您有三个测试。第一个测试说明,您用来构建son_``of_arathorn ➊的复制构造函数共享同一个DeadMenOfDunharrow ➋。
在第二个测试中,您构造了一个空的共享指针son_of _ara``thorn ➌,然后展示复制赋值 ➍ 也不会改变DeadMenOfDunharrow的数量 ➎。
第三个测试说明,当您构造完整的共享指针son_of_arathorn ➏时,DeadMenOfDunharrow的数量增加到 2 ➐。当您将aragorn复制赋值给son_of_arathorn ➑时,son_of_arathorn删除了其DeadMenOfDunharrow,因为它拥有唯一所有权。然后增加了aragorn拥有的DeadMenOfDunharrow的引用计数。因为两个共享指针拥有同一个DeadMenOfDunharrow,所以oaths_to_fulfill从 2 减少到 1 ➒。
共享数组
shared array是拥有动态数组并支持operator[]的共享指针。它的工作方式与唯一数组相同,只是它具有非排他性所有权。
删除器
对于共享指针而言,删除器的工作方式与对唯一指针的工作方式相同,只是您无需提供删除器类型的模板参数。只需将删除器作为第二个构造函数参数传递即可。例如,要将清单 11-12 转换为使用共享指针,您只需插入以下类型别名:
using FileGuard = std::shared_ptr<FILE>;
现在,您正在管理具有共享所有权的FILE*文件句柄。
支持操作的部分列表
表 11-4 提供了支持的shared_ptr构造函数的大部分完整列表。在本表中,ptr是原始指针,sh_ptr是共享指针,u_ptr是唯一指针,del是删除器,alc是分配器。
表 11-4: 所有支持的std::shared_ptr构造函数
| 操作 | 注释 |
|---|---|
shared_ptr<...>{ } or shared_ptr<...>{ nullptr } |
创建一个空的共享指针,使用std::default_delete<T>和std::allocator<T>。 |
shared_ptr<...>{ ptr, [del], [alc] } |
创建一个共享指针,拥有由 ptr 指向的动态对象。默认情况下使用std::default_delete<T>和std::allocator<T>;否则,使用 del 作为删除器,alc 作为分配器(如果提供)。 |
shared_ptr<...>{ sh_ptr } |
创建一个共享指针,拥有由共享指针sh_ptr指向的动态对象。从sh_ptr复制所有权到新创建的共享指针。还复制了sh_ptr的删除器和分配器。 |
shared_ptr<...>{ sh_ptr , ptr } |
一个别名构造函数:生成的共享指针持有对 ptr 的未管理引用,但参与 sh_ptr 的引用计数。 |
shared_ptr<...>{ move(sh_ptr) } |
创建一个共享指针,拥有由共享指针sh_ptr指向的动态对象。将所有权从sh_ptr转移到新创建的共享指针。还移动了sh_ptr的删除器。 |
shared_ptr<...>{ move(u_ptr) } |
创建一个共享指针,拥有由独占指针 u_ptr 指向的动态对象。将所有权从 u_ptr 转移到新创建的共享指针,并移动 u_ptr 的删除器。 |
表 11-5 列出了大多数支持的std::shared_ptr操作。在此表中,ptr是原始指针,sh_ptr是共享指针,u_ptr是独占指针,del是删除器,alc是分配器。
表 11-5: 大多数支持的std::shared_ptr操作
| 操作 | 备注 |
|---|---|
~shared_ptr<...>() |
如果没有其他拥有者,则调用删除器删除拥有的对象。 |
sh_ptr1 = sh_ptr2 |
将 sh_ptr2 的拥有权和删除器复制到 sh_ptr1,拥有者数量加 1。如果没有其他拥有者,则销毁当前拥有的对象。 |
sh_ptr = move(u_ptr) |
将拥有的对象和删除器的所有权从 u_ptr 转移到 sh_ptr。如果没有其他拥有者,则销毁当前拥有的对象。 |
sh_ptr1 = move(sh_ptr2) |
将拥有的对象和删除器的所有权从 sh_ptr2 转移到 sh_ptr1。如果没有其他拥有者,则销毁当前拥有的对象。 |
sh_ptr1.swap(sh_ptr2) |
在 sh_ptr1 和 sh_ptr2 之间交换拥有的对象和删除器。 |
swap(sh_ptr1, sh_ptr2) |
一个与swap方法相同的自由函数。 |
sh_ptr.reset() |
如果满了,并且没有其他拥有者,则调用删除器删除 sh_ptr 拥有的对象。 |
sh_ptr.reset(ptr, [del], [alc]) |
如果没有其他拥有者,则删除当前拥有的对象;然后接管 ptr 的拥有权。可以选择提供删除器 del 和分配器 alc,默认为std::default_delete<T>和std::allocator<T>。 |
ptr = sh_ptr.get() |
返回原始指针 ptr;sh_ptr 保留拥有权。 |
*sh_ptr |
对拥有对象的解引用操作符。 |
sh_ptr-> |
对拥有对象的成员解引用操作符。 |
sh_ptr.use_count() |
引用拥有当前对象的共享指针总数;如果为空则为零。 |
sh_ptr[index] |
返回索引处的元素(仅适用于数组)。 |
bool{ sh_ptr } |
bool转换:如果满了返回true,如果为空返回false。 |
sh_ptr1 == sh_ptr2sh_ptr1 != sh_ptr2sh_ptr1 > sh_ptr2sh_ptr1 >= sh_ptr2sh_ptr1 < sh_ptr2sh_ptr1 <= sh_ptr2 |
比较操作符;等价于在原始指针上评估比较操作符。 |
sh_ptr.get_deleter() |
返回删除器的引用。 |
弱指针
弱指针是一种特殊的智能指针,它不拥有所引用对象的所有权。弱指针允许你跟踪一个对象,并且仅在被跟踪的对象仍然存在时才能将弱指针转换为共享指针。这允许你对对象生成临时拥有权。像共享指针一样,弱指针是可移动和可复制的。
弱指针的一个常见用途是缓存。在软件工程中,缓存是一个临时存储数据的数据结构,目的是加速数据的读取。缓存可以保持指向对象的弱指针,这样一旦所有其他所有者释放它们,缓存中的对象就会被销毁。定期,缓存可以扫描其存储的弱指针,并修剪掉那些没有其他所有者的指针。
标准库提供了std::weak_ptr,而 Boost 库提供了boost::weak_ptr。这两者本质上是相同的,仅供与各自的共享指针std::shared_ptr和boost::shared_ptr一起使用。
构造
弱指针的构造函数与作用域指针、唯一指针和共享指针完全不同,因为弱指针并不直接拥有动态对象。默认构造函数会构造一个空的弱指针。要构造一个跟踪动态对象的弱指针,必须使用共享指针或另一个弱指针来构造。
例如,以下代码将一个共享指针传递给弱指针的构造函数:
auto sp = std::make_shared<int>(808);
std::weak_ptr<int> wp{ sp };
现在,弱指针wp将跟踪由共享指针sp拥有的对象。
获取暂时所有权
弱指针通过调用其lock方法来暂时拥有它所跟踪的对象。lock方法总是创建一个共享指针。如果被跟踪的对象仍然存活,返回的共享指针会拥有该对象。如果被跟踪的对象已不再存活,返回的共享指针则为空。参考示例 11-15。
TEST_CASE("WeakPtr lock() yields") {
auto message = "The way is shut.";
SECTION("a shared pointer when tracked object is alive") {
auto aragorn = std::make_shared<DeadMenOfDunharrow>(message); ➊
std::weak_ptr<DeadMenOfDunharrow> legolas{ aragorn }; ➋
auto sh_ptr = legolas.lock(); ➌
REQUIRE(sh_ptr->message == message); ➍
REQUIRE(sh_ptr.use_count() == 2); ➎
}
SECTION("empty when shared pointer empty") {
std::weak_ptr<DeadMenOfDunharrow> legolas;
{
auto aragorn = std::make_shared<DeadMenOfDunharrow>(message); ➏
legolas = aragorn; ➐
}
auto sh_ptr = legolas.lock(); ➑
REQUIRE(nullptr == sh_ptr); ➒
}
}
示例 11-15:std::weak_ptr暴露了一个lock方法,用于获取暂时的所有权。
在第一次测试中,你创建了一个共享指针aragorn ➊,并赋予它一个消息。接着,使用aragorn ➋构造一个弱指针legolas。这样,legolas就开始跟踪由aragorn拥有的动态对象。当你调用弱指针的lock方法 ➌ 时,aragorn仍然存活,因此你获得了共享指针sh_ptr,它也拥有同样的DeadMenOfDunharrow对象。你通过断言message相同 ➍,并且使用计数为 2 ➎来确认这一点。
在第二次测试中,你也创建了一个aragorn共享指针 ➏,但这次你使用了赋值运算符 ➐,因此之前为空的弱指针legolas现在开始跟踪由aragorn拥有的动态对象。接下来,aragorn超出作用域并死亡。此时,legolas继续跟踪一个已死的对象。当你此时调用lock方法 ➑ 时,得到的是一个空的共享指针 ➒。
高级模式
在一些共享指针的高级用法中,你可能需要创建一个类,使得实例能够创建指向自身的共享指针。std::enable_shared_from_this类模板实现了这种行为。从用户的角度来看,唯一需要做的就是在类定义中继承enable_shared_from_this。这将暴露出shared_from_this和weak_from_this方法,它们分别生成指向当前对象的shared_ptr或weak_ptr。这是一个小众情况,但如果你想查看更多细节,请参考[util.smartptr.enab]。
支持的操作
表 11-6 列出了大多数支持的弱指针操作。在该表中,w_ptr是一个弱指针,sh_ptr是一个共享指针。
表 11-6: 大多数支持的std::shared_ptr操作
| 操作 | 说明 |
|---|---|
weak_ptr<...>{ } |
创建一个空的弱指针。 |
weak_ptr<...>{ w_ptr } 或 weak_ptr<...>{ sh_ptr } |
跟踪弱指针 w_ptr 或共享指针 sh_ptr 所指向的对象。 |
weak_ptr<...>{ move(w_ptr) } |
跟踪 w_ptr 所指向的对象;然后清空 w_ptr。 |
~weak_ptr<...>() |
对跟踪的对象没有影响。 |
w_ptr1 = sh_ptr 或 w_ptr1 = w_ptr2 |
用 sh_ptr 所拥有的对象或 w_ptr2 所跟踪的对象替换当前跟踪的对象。 |
w_ptr1 = move(w_ptr2) |
用 w_ptr2 所跟踪的对象替换当前跟踪的对象,并清空 w_ptr2。 |
sh_ptr = w_ptr.lock() |
创建共享指针 sh_ptr,拥有 w_ptr 所跟踪的对象。如果跟踪的对象已过期,则 sh_ptr 为空。 |
w_ptr1.swap(w_ptr2) |
交换 w_ptr1 和 w_ptr2 之间的跟踪对象。 |
swap(w_ptr1, w_ptr2) |
与swap方法相同的自由函数。 |
w_ptr.reset() |
清空弱指针。 |
w_ptr.use_count() |
返回拥有跟踪对象的共享指针数量。 |
w_ptr.expired() |
如果跟踪的对象已过期,则返回true,否则返回false。 |
sh_ptr.use_count() |
返回拥有所拥有对象的共享指针的总数;如果为空则为零。 |
侵入式指针
侵入式指针是指向具有嵌入式引用计数的对象的共享指针。因为共享指针通常保持引用计数,所以它们不适合拥有此类对象。Boost 提供了一种实现,称为boost::intrusive_ptr,在<boost/smart_ptr/intrusive_ptr.hpp>头文件中定义。
很少会遇到需要使用侵入式指针的情况。但有时你会使用包含嵌入式引用的操作系统或框架。例如,在 Windows COM 编程中,侵入式指针非常有用:继承自IUnknown接口的 COM 对象具有AddRef和Release方法,分别用于增加和减少嵌入式引用计数。
每次创建一个intrusive_ptr时,都会调用intrusive_ptr_add_ref函数。当intrusive_ptr被销毁时,它会调用intrusive_ptr_release自由函数。当引用计数降到零时,你负责在intrusive_ptr_release中释放适当的资源。要使用intrusive_ptr,你必须提供这些函数的合适实现。
清单 11-16 演示了使用 DeadMenOfDunharrow 类的侵入式指针。请参考该清单中的 intrusive_ptr_add_ref 和 intrusive_ptr_release 的实现。
#include <boost/smart_ptr/intrusive_ptr.hpp>
using IntrusivePtr = boost::intrusive_ptr<DeadMenOfDunharrow>; ➊
size_t ref_count{}; ➋
void intrusive_ptr_add_ref(DeadMenOfDunharrow* d) {
ref_count++; ➌
}
void intrusive_ptr_release(DeadMenOfDunharrow* d) {
ref_count--; ➍
if (ref_count == 0) delete d; ➎
}
清单 11-16: intrusive_ptr_add_ref 和 intrusive_ptr_release 的实现
使用类型别名IntrusivePtr可以减少一些输入量 ➊。接下来,你声明了一个具有静态存储期的ref_count ➋。这个变量跟踪活动侵入式指针的数量。在intrusive_ptr_add_ref中,你会增加ref_count ➌。在intrusive_ptr_release中,你会减少ref_count ➍。当ref_count降至零时,你删除DeadMenOfDunharrow对象 ➎。
注意
在使用清单 11-16 中的设置时,务必确保只使用一个动态的 DeadMenOfDunharrow 对象与侵入式指针。ref_count 方法只能正确追踪一个对象。如果你有多个由不同侵入式指针拥有的动态对象,ref_count 将变得无效,导致错误的 delete 行为 ➎。
清单 11-17 展示了如何在清单 11-16 的设置中使用侵入式指针。
TEST_CASE("IntrusivePtr uses an embedded reference counter.") {
REQUIRE(ref_count == 0); ➊
IntrusivePtr aragorn{ new DeadMenOfDunharrow{} }; ➋
REQUIRE(ref_count == 1); ➌
{
IntrusivePtr legolas{ aragorn }; ➍
REQUIRE(ref_count == 2); ➎
}
REQUIRE(DeadMenOfDunharrow::oaths_to_fulfill == 1); ➏
}
清单 11-17: 使用 boost::intrusive_ptr
这个测试首先检查ref_count是否为零 ➊。接下来,通过传递动态分配的DeadMenOfDunharrow对象 ➋ 来构造一个侵入式指针。这会将ref_count增加到 1,因为创建侵入式指针会调用intrusive_ptr_add_ref ➌。在一个块作用域内,你构造了另一个侵入式指针legolas,它与aragorn共享所有权 ➍。这将ref_count增加到 2 ➎,因为创建侵入式指针会调用intrusive_ptr_add_ref。当legolas超出块作用域时,它会被析构,从而调用intrusive_ptr_release。这会将ref_count减少到 1,但不会导致删除所拥有的对象 ➏。
智能指针选项总结
表 11-7 总结了可在 stdlib 和 Boost 中使用的所有智能指针选项。
表 11-7: stdlib 和 Boost 中的智能指针
| 类型名称 | stdlib 头文件 | Boost 头文件 | 可移动/可转移所有权 | 可复制/非独占所有权 |
|---|---|---|---|---|
scoped_ptr |
<boost/smart_ptr/scoped_ptr.hpp> |
|||
scoped_array |
<boost/smart_ptr/scoped_array.hpp> |
|||
unique_ptr |
<memory> |
✓ | ||
shared_ptr |
<memory> |
<boost/smart_ptr/shared_ptr.hpp> |
✓ | ✓ |
shared_array |
<boost/smart_ptr/shared_array.hpp> |
✓ | ✓ | |
weak_ptr |
<memory> |
<boost/smart_ptr/weak_ptr.hpp> |
✓ | ✓ |
intrusive_ptr |
<boost/smart_ptr/intrusive_ptr.hpp> |
✓ | ✓ |
分配器
分配器是低级对象,负责处理内存请求。stdlib 和 Boost 库使你能够提供分配器,定制库如何分配动态内存。
在大多数情况下,默认分配器std::allocate完全足够。它使用operator new(size_t)分配内存,该操作从自由存储区(即堆)中分配原始内存。它使用operator delete(void*)释放内存,该操作从自由存储区中释放原始内存。(请回顾《重载new操作符》中的内容,在第 189 页中提到,operator new和operator delete是在<new>头文件中定义的。)
在某些场景中,比如游戏、高频交易、科学分析和嵌入式应用,默认自由存储操作所带来的内存和计算开销是不可接受的。在这些场景中,实现自定义分配器相对容易。请注意,除非你进行了一些性能测试,表明默认分配器是瓶颈,否则你真的不应该实现自定义分配器。自定义分配器的背后理念是,你对自己特定程序的了解远超过默认分配器模型的设计者,因此你可以做出改进,提升分配性能。
至少,你需要提供一个具有以下特征的模板类,才能使其作为分配器工作:
-
一个合适的默认构造函数
-
一个对应模板参数的
value_type成员 -
一个模板构造函数,可以在处理
value_type变化时复制分配器的内部状态 -
一个
allocate方法 -
一个
deallocate方法 -
一个
operator==和一个operator!=
列表 11-18 中的MyAllocator类实现了一个简单的教学版本的std::allocate,用于跟踪你进行了多少次分配和释放。
#include <new>
static size_t n_allocated, n_deallocated;
template <typename T>
struct MyAllocator {
using value_type = T; ➊
MyAllocator() noexcept{ } ➋
template <typename U>
MyAllocator(const MyAllocator<U>&) noexcept { } ➌
T* allocate(size_t n) { ➍
auto p = operator new(sizeof(T) * n);
++n_allocated;
return static_cast<T*>(p);
}
void deallocate(T* p, size_t n) { ➎
operator delete(p);
++n_deallocated;
}
};
template <typename T1, typename T2>
bool operator==(const MyAllocator<T1>&, const MyAllocator<T2>&) {
return true; ➏
}
template <typename T1, typename T2>
bool operator!=(const MyAllocator<T1>&, const MyAllocator<T2>&) {
return false; ➐
}
列表 11-18:一个基于std::allocate的MyAllocator类
首先,你声明value_type类型别名为T,这是实现分配器的要求之一➊。接下来是默认构造函数➋和模板构造函数➌。这两个构造函数都是空的,因为分配器没有状态可以传递。
allocate方法➍通过使用operator new分配所需字节数sizeof(T) * n来模拟std::allocate。接下来,它增加了静态变量n_allocated,这样你就可以跟踪分配次数以进行测试。allocate方法随后返回指向新分配内存的指针,在返回之前将void*转换为相关的指针类型。
deallocate方法➎通过调用operator delete来模拟std::allocate。类似于allocate,它增加了用于测试的n_deallocated静态变量,并返回。
最后的任务是实现一个operator==和一个operator!=,接受新的类模板。因为分配器没有状态,任何实例都与其他实例相同,因此operator==返回true ➏,而operator!=返回true ➐。
注意
示例 11-18 是一个教学工具,实际上并没有提高分配效率。它只是包装了new和delete的调用。
到目前为止,唯一你知道使用分配器的类是std::shared_ptr。考虑一下示例 11-19 如何将MyAllocator与std::allocate共享一起使用。
TEST_CASE("Allocator") {
auto message = "The way is shut.";
MyAllocator<DeadMenOfDunharrow> alloc; ➊
{
auto aragorn = std::allocate_shared<DeadMenOfDunharrow>(alloc➋, message➌);
REQUIRE(aragorn->message == message); ➍
REQUIRE(n_allocated == 1); ➎
REQUIRE(n_deallocated == 0); ➏
}
REQUIRE(n_allocated == 1); ➐
REQUIRE(n_deallocated == 1); ➑
}
示例 11-19:使用MyAllocator与std::shared_ptr
你创建了一个名为alloc的MyAllocator实例 ➊。在一个块内,你将alloc作为第一个参数传递给allocate_shared ➋,它创建了一个包含自定义message的共享指针aragorn ➌。接着,你确认aragorn包含正确的message ➍,n_allocated为 1 ➎,n_deallocated为 0 ➏。
在aragorn超出块作用域并被销毁后,你可以验证n_allocated仍为 1 ➐,而n_deallocated现在为 1 ➑。
注意
因为分配器处理底层细节,你可以深入到非常细微的地方来指定它们的行为。参见 ISO C++ 17 标准中的[allocator.requirements],以获取详细的说明。
总结
智能指针通过 RAII 管理动态对象,你可以提供分配器来定制动态内存分配。根据你选择的智能指针,你可以将不同的所有权模式编码到动态对象中。
练习
11-1. 重新实现示例 11-13,使用std::shared_ptr而不是std::unique_ptr。注意,尽管你将所有权要求从独占变为非独占,但你仍然将所有权转移给了call_hello函数。
11-2. 从调用say_hello中移除std::move。然后再调用一次say_hello。注意,file_guard的所有权不再被转移到say_hello函数中。这允许多次调用。
11-3. 实现一个Hal类,在其构造函数中接受一个std::shared_ptr<FILE>。在 Hal 的析构函数中,将短语Stop, Dave.写入共享指针持有的文件句柄。实现一个write_status函数,将短语I'm completely operational.写入文件句柄。以下是你可以使用的类声明:
struct Hal {
Hal(std::shared_ptr<FILE> file);
~Hal();
void write_status();
std::shared_ptr<FILE> file;
};
11-4. 创建多个Hal实例并调用write_status。注意,你不需要跟踪有多少Hal实例是打开的:文件管理通过共享指针的共享所有权模型来处理。
进一步阅读
-
ISO 国际标准 ISO/IEC (2017) — C++编程语言(国际标准化组织;瑞士日内瓦;
isocpp.org/std/the-standard/) -
《C++程序设计语言》(第 4 版),作者:比雅尼·斯特劳斯特鲁普(Pearson Education,2013 年)
-
《Boost C++库》(第 2 版),作者:博里斯·舍林(XML Press,2014 年)
-
《C++标准库:教程与参考》(第 2 版),作者:尼古拉·M·乔苏蒂斯(Addison-Wesley Professional,2012 年)
第十五章:实用工具**
“*看,世界上有比我们更强大的东西。但如果你知道怎么搭顺风车,你就能去到任何地方,” Raven 说。
“没错,我完全理解你在说什么。”
— Neal Stephenson,《雪崩》

stdlib 和 Boost 库提供了大量满足常见编程需求的类型、类和函数。合起来,这些五花八门的工具被称为 实用工具。除了它们小巧、简洁且专注的特点,实用工具在功能上各不相同。
在本章中,你将学习几种简单的 数据结构,它们处理许多常见情境,其中你需要让对象包含其他对象。接下来是关于 日期和时间 的讨论,涵盖了若干编码日历和时钟的方式以及测量经过时间的手段。本章的最后,我们将深入了解许多可用的 数值和数学工具。
注意
日期/时间和数值/数学的讨论将对某些读者非常有兴趣,而对其他人只是略有兴趣。如果你属于后者,可以随意浏览这些章节。
数据结构
在这些库中,stdlib 和 Boost 提供了一个宝贵的有用的 数据结构 集合。数据结构 是一种存储对象并允许对这些存储对象进行一组操作的类型。并没有什么神奇的编译器魔法让本节中的实用数据结构工作;你完全可以在足够的时间和精力下实现你自己的版本。但是,为什么要重新发明轮子呢?
tribool
tribool 是一种类似 bool 的类型,支持三种状态,而不是两种:真、假和不确定。Boost 提供了 <boost/logic/tribool.hpp> 头文件中的 boost::logic::tribool。 示例 12-1 演示了如何使用 true、false 和 boost::logic::indeterminate 类型初始化 Boost 的 tribool。
#include <boost/logic/tribool.hpp>
using boost::logic::indeterminate; ➊
boost::logic::tribool t = true➋, f = false➌, i = indeterminate➍;
示例 12-1:初始化 Boost tribool
为了方便,using 声明将 indeterminate 从 boost::logic 中拉入 ➊。然后,你将 tribool t 初始化为 true ➋,f 初始化为 false ➌,i 初始化为 indeterminate ➍。
tribool 类会隐式转换为 bool。如果 tribool 是 true,它将转换为 true;否则,转换为 false。tribool 类还支持 operator!,当 tribool 为 false 时返回 true;否则返回 false。最后,indeterminate 支持 operator(),它接受一个 tribool 参数,如果该参数是 indeterminate,则返回 true;否则返回 false。
示例 12-2 采样了这些布尔转换。
TEST_CASE("Boost tribool converts to bool") {
REQUIRE(t); ➊
REQUIRE_FALSE(f); ➋
REQUIRE(!f); ➌
REQUIRE_FALSE(!t); ➍
REQUIRE(indeterminate(i)); ➎
REQUIRE_FALSE(indeterminate(t)); ➏
}
示例 12-2:将 tribool 转换为 bool
这个测试演示了 bool 转换 ➊➋、operator! ➌➍ 和 indeterminate ➍➎ 的基本结果。
布尔操作
tribool 类支持所有布尔运算符。每当 tribool 表达式不涉及 indeterminate 值时,结果与等效的布尔表达式相同。当涉及 indeterminate 时,结果可以是 indeterminate,正如清单 12-3 所示。
TEST_CASE("Boost Tribool supports Boolean operations") {
auto t_or_f = t || f;
REQUIRE(t_or_f); ➊
REQUIRE(indeterminate(t && indeterminate)); ➋
REQUIRE(indeterminate(f || indeterminate)); ➌
REQUIRE(indeterminate(!i)); ➍
}
清单 12-3:boost::tribool 支持布尔操作。
由于 t 和 f 都不是 indeterminate,因此 t || f 的求值就像普通的布尔表达式一样,所以 t_or_f 为 true ➊。涉及 indeterminate 的布尔表达式可能会得到 indeterminate。布尔与 ➋、或 ➌、非 ➍ 操作在没有足够信息的情况下会求值为 indeterminate。
何时使用 tribool
除了描述薛定谔的猫的生死状态,你还可以在某些操作可能需要较长时间的设置中使用 tribool。在这种情况下,tribool 可以描述操作是否成功。一个 indeterminate 值可以表示操作仍在进行中。
tribool 类使得 if 语句变得简洁明了,正如清单 12-4 所示。
TEST_CASE("Boost Tribool works nicely with if statements") {
if (i) FAIL("Indeterminate is true."); ➊
else if (!i) FAIL("Indeterminate is false."); ➋
else {} // OK, indeterminate ➌
}
清单 12-4:使用 if 语句与 tribool
第一个表达式 ➊ 仅在 tribool 为 true 时求值,第二个表达式 ➋ 仅在其为 false 时求值,第三个表达式 ➌ 仅在 indeterminate 情况下执行。
注意
提到 tribool 可能会让你不禁皱眉头,心想,为什么不用整数表示,0 为 false,1 为 true,其他值为 indeterminate 呢?你可以这样做,但考虑到 tribool 类型支持所有常见的布尔操作,同时能正确传播 indeterminate 值。为什么要重新发明轮子呢?
部分支持的操作列表
表 12-1 提供了最常用的 boost::tribool 操作列表。在此表中,tb 是一个 boost::tribool。
表 12-1: 最常用的 boost::tribool 操作
| 操作 | 备注 |
|---|---|
tribool{} tribool{ false } |
构造一个值为 false 的 tribool。 |
tribool{ true } |
构造一个值为 true 的 tribool。 |
tribool{ indeterminate } |
构造一个值为 indeterminate 的 tribool。 |
tb.safe_bool() |
如果 tb 为 true,则结果为 true,否则为 false。 |
indeterminate(tb) |
如果 tb 是 indeterminate,则结果为 true,否则为 false。 |
| !tb | 如果 tb 为 false,则结果为 true,否则为 false。 |
tb1 && tb2 |
如果 tb1 和 tb2 都为 true,则结果为 true;如果 tb1 或 tb2 为 false,则结果为 false;否则,结果为 indeterminate。 |
tb1 || tb2 |
如果 tb1 或 tb2 为 true,则结果为 true;如果 tb1 和 tb2 都为 false,则结果为 false;否则,结果为 indeterminate。 |
bool{ tb } |
如果 tb 为 true,则结果为 true,否则为 false。 |
可选
optional 是一个类模板,包含一个可能存在也可能不存在的值。optional 的主要使用场景是作为可能失败的函数的返回类型。与其抛出异常或返回多个值,不如让函数返回一个 optional,如果函数成功执行,optional 会包含一个值。
标准库在 <optional> 头文件中提供了 std::optional,Boost 在 <boost/optional.hpp> 头文件中提供了 boost::optional。
考虑 清单 12-5 中的设置。take 函数仅在你选择 Pill::Blue 时才返回一个 TheMatrix 实例;否则,take 返回一个 std::nullopt,这是一个未初始化状态的标准库提供的 std::optional 类型常量。
#include <optional>
struct TheMatrix { ➊
TheMatrix(int x) : iteration { x } { }
const int iteration;
};
enum Pill { Red, Blue }; ➋
std::optional<TheMatrix>➌ take(Pill pill➍) {
if(pill == Pill::Blue) return TheMatrix{ 6 }; ➎
return std::nullopt; ➏
}
清单 12-5:一个返回 std::optional 的 take 函数
TheMatrix 类型接受一个 int 类型的构造函数参数,并将其存储到 iteration 成员中 ➊。enum 类型 Pill 包含 Red 和 Blue 两个值 ➋。take 函数返回一个 std::optional<TheMatrix> ➌,并接受一个 Pill 类型的参数 ➍。如果传入 Pill::Blue 给 take 函数,它会返回一个 TheMatrix 实例 ➎;否则,它会返回一个 std::nullopt ➏。
首先,参考 清单 12-6,你选择了蓝色药丸。
TEST_CASE("std::optional contains types") {
if (auto matrix_opt = take(Pill::Blue)) { ➊
REQUIRE(matrix_opt->iteration == 6); ➋
auto& matrix = matrix_opt.value();
REQUIRE(matrix.iteration == 6); ➌
} else {
FAIL("The optional evaluated to false.");
}
}
清单 12-6:测试探索 std::optional 类型与 Pill::Blue 的使用
你选择了蓝色药丸,结果是 std::optional 结果中包含一个初始化的 TheMatrix,因此 if 语句的条件表达式会计算为 true ➊。清单 12-6 还演示了如何使用 operator-> ➋ 和 value() ➌ 来访问底层值。
那么,选择红色药丸会发生什么呢?参考 清单 12-7。
TEST_CASE("std::optional can be empty") {
auto matrix_opt = take(Pill::Red); ➊
if (matrix_opt) FAIL("The Matrix is not empty."); ➋
REQUIRE_FALSE(matrix_opt.has_value()); ➌
}
清单 12-7:测试探索 std::optional 类型与 Pill::Red
你选择了红色药丸 ➊,结果是 matrix_opt 为空。这意味着 matrix_opt 转换为 false ➋,并且 has_value() 也返回 false ➌。
部分支持的操作列表
表 12-2 提供了最常见的 std::optional 操作列表。在此表中,opt 是 std::optional<T> 类型,t 是类型为 T 的对象。
表 12-2: 最常见的 std::optional 操作
| 操作 | 备注 |
|---|---|
optional<T>{} optional<T>{std::nullopt} |
构造一个空的 optional。 |
optional<T>{ opt } |
从 opt 复制构造一个 optional。 |
optional<T>{ move(opt) } |
从 opt 移动构造一个 optional,构造完成后 opt 为空。 |
optional<T>{ t } opt = t |
将 t 复制到 optional 中。 |
optional<T>{ move(``t``) } opt = move(t) |
将 t 移动到 optional 中。 |
opt->mbr |
成员解引用;访问由 opt 包含的对象的 mbr 成员。 |
*opt opt.value() |
返回对 opt 中包含的对象的引用;value() 检查是否为空,并抛出 bad_optional_access 异常。 |
opt.value_or(T{ ... }) |
如果 opt 包含对象,则返回该对象的副本;否则返回该参数。 |
bool{ opt } opt.has_value() |
如果 opt 包含对象,则返回 true;否则返回 false。 |
opt1.swap(opt2) swap(opt1, opt2) |
交换 opt1 和 opt2 中包含的对象。 |
opt.reset() |
销毁 opt 中包含的对象,opt 在 reset 后为空。 |
opt.emplace(...) |
在原地构造一个类型,将所有参数转发给适当的构造函数。 |
make_optional<T>(...) |
构造 optional 的便捷函数;将参数转发给适当的构造函数。 |
opt1 == opt2opt1 != opt2opt1 > opt2opt1 >= opt2opt1 < opt2opt1 <= opt2 |
在评估两个 optional 对象的相等性时,如果两个都为空或都包含对象且这些对象相等,则返回 true;否则返回 false。进行比较时,空的 optional 总是小于包含值的 optional。否则,结果是比较所包含的类型。 |
pair
pair 是一个类模板,包含两个不同类型的对象作为一个整体。对象是有序的,你可以通过 first 和 second 成员访问它们。pair 支持比较运算符,具有默认的复制/移动构造函数,并支持结构化绑定语法。
标准库在 <utility> 头文件中有 std::pair,而 Boost 在 <boost/pair.hpp> 头文件中有 boost::pair。
注意
Boost 还在 <boost/compressed_pair.hpp> 头文件中提供了 boost::compressed_pair。当其中一个成员为空时,它会稍微更高效。
首先,你创建一些简单的类型来构造一个对,例如清单 12-8 中的简单 Socialite 和 Valet 类。
#include <utility>
struct Socialite { const char* birthname; };
struct Valet { const char* surname; };
Socialite bertie{ "Wilberforce" };
Valet reginald{ "Jeeves" };
清单 12-8:Socialite 和 Valet 类
现在你已经拥有了 Socialite 和 Valet 类型的对象 bertie 和 reginald,你可以构造一个 std::pair 并尝试提取元素。清单 12-9 使用 first 和 second 成员来访问所包含的类型。
TEST_CASE("std::pair permits access to members") {
std::pair<Socialite, Valet> inimitable_duo{ bertie, reginald }; ➊
REQUIRE(inimitable_duo.first.birthname == bertie.birthname); ➋
REQUIRE(inimitable_duo.second.surname == reginald.surname); ➌
}
清单 12-9:std::pair 支持成员提取。
你通过传入想要复制的对象来构造一个 std::pair ➊。你可以使用 std::pair 的 first 和 second 成员从 inimitable_duo 中提取出 Socialite ➋ 和 Valet ➌。然后,你可以将它们的 birthname 和 surname 成员与原始数据进行比较。
清单 12-10 显示了 std::pair 成员提取和结构化绑定语法。
TEST_CASE("std::pair works with structured binding") {
std::pair<Socialite, Valet> inimitable_duo{ bertie, reginald };
auto& [idle_rich, butler] = inimitable_duo; ➊
REQUIRE(idle_rich.birthname == bertie.birthname); ➋
REQUIRE(butler.surname == reginald.surname); ➌
}
清单 12-10:std::pair 支持结构化绑定语法。
在这里,你使用结构化绑定语法 ➊ 将 inimitable_duo 的 first 和 second 成员的引用提取到 idle_rich 和 butler 中。正如清单 12-9 所示,你确保 birthname ➋ 和 surname ➌ 与原始数据匹配。
支持操作的部分列表
表 12-3 提供了最常见的std::pair操作列表。在此表中,pr是一个std::pair<A, B>,a是A类型的对象,b是B类型的对象。
表 12-3: 最常见的std::pair操作
| 操作 | 说明 |
|---|---|
pair<...>{} |
构造一个空的pair。 |
pair<...>{ pr } |
从 pr 进行复制构造。 |
pair<...>{ move(pr) } |
从 pr 进行移动构造。 |
pair<...>{ a, b } |
通过复制 a 和 b 构造一个pair。 |
pair<...>{ move(a), move(b) } |
通过移动 a 和 b 构造一个pair。 |
pr1 = pr2 |
从 pr2 进行复制赋值。 |
pr1 = move(pr2) |
从 pr2 进行移动赋值。 |
pr.first get<0>(pr) |
返回对first元素的引用。 |
pr.second get<1>(pr) |
返回对second元素的引用。 |
get<T>(pr) |
如果first和second具有不同类型,返回类型 T 的元素的引用。 |
pr1.swap(pr2) swap(pr1, pr2) |
交换 pr1 和 pr2 所包含的对象。 |
make_pair<...>(a, b) |
构造pair的便利函数。 |
pr1 == pr2pr1 != pr2pr1 > pr2pr1 >= pr2pr1 < pr2pr1 <= pr2 |
如果first和second都相等,则相等。大于/小于比较从first开始。如果first成员相等,则比较second成员。 |
tuple
tuple是一个类模板,接受任意数量的异质元素。它是pair的泛化,但tuple不像pair那样暴露其成员为first、second等。相反,你使用非成员函数模板get来提取元素。
标准库提供了std::tuple和std::get在<tuple>头文件中,Boost 提供了boost::tuple和boost::get在<boost/tuple/tuple.hpp>头文件中。
让我们添加一个第三个类,Acquaintance,来测试一个tuple:
struct Acquaintance { const char* nickname; };
Acquaintance hildebrand{ "Tuppy" };
要提取这些元素,你有两种使用get的方式。在主要情况下,你可以始终提供一个对应于你要提取的元素的零基索引的模板参数。如果tuple不包含具有相同类型的元素,你还可以提供一个对应于你要提取的元素类型的模板参数,如列表 12-11 所示。
TEST_CASE("std::tuple permits access to members with std::get") {
using Trio = std::tuple<Socialite, Valet, Acquaintance>;
Trio truculent_trio{ bertie, reginald, hildebrand };
auto& bertie_ref = std::get<0>(truculent_trio); ➊
REQUIRE(bertie_ref.birthname == bertie.birthname);
auto& tuppy_ref = std::get<Acquaintance>(truculent_trio); ➋
REQUIRE(tuppy_ref.nickname == hildebrand.nickname);
}
列表 12-11:std::tuple支持成员提取和结构绑定语法。
你可以通过与构建std::pair类似的方式构建std::tuple。首先,使用get<0>提取Socialite成员 ➊。由于Socialite是第一个模板参数,因此使用 0 作为std::get模板参数。然后,使用std::get<Acquaintance>提取Acquaintance成员 ➋。由于只有一个Acquaintance类型的元素,你可以使用这种get访问模式。
像pair一样,tuple也允许使用结构绑定语法。
支持操作的部分列表
表 12-4 列出了最常用的std::tuple操作。在此表中,tp是一个std::tuple<A, B>,a是类型为A的对象,b是类型为B的对象。
表 12-4: 最常用的std::tuple操作
| 操作 | 说明 |
|---|---|
tuple<...>{ [alc] } |
构造一个空的tuple。默认使用std::allocate作为分配器 alc。 |
tuple<...>{ [alc], tp } |
从 tp 复制构造。默认使用std::allocate作为分配器 alc。 |
tuple<...>{ [alc],move(tp) } |
从 tp 移动构造。默认使用std::allocate作为分配器 alc。 |
tuple<...>{ [alc], a, b } |
通过复制 a 和 b 构造一个tuple。默认使用std::allocate作为分配器 alc。 |
tuple<...>{ [alc], move(a), move(b) } |
通过移动 a 和 b 构造一个tuple。默认使用std::allocate作为分配器 alc。 |
tp1 = tp2 |
从 tp2 复制赋值。 |
tp1 = move(tp2) |
从 tp2 移动赋值。 |
get<i>(tp) |
返回第 i 个元素的引用(从零开始)。 |
get<T>(tp) |
返回类型为 T 的元素的引用。如果有多个元素共享此类型,编译会失败。 |
tp1.swap(tp2) swap(tp1, tp2) |
交换 tp1 和 tp2 中包含的对象。 |
make_tuple<...>(a, b) |
用于构造tuple的便捷函数。 |
tuple_cat<...>(tp1, tp2) |
连接所有作为参数传入的 tuple。 |
tp1 == tp2tp1 != tp2tp1 > tp2tp1 >= tp2tp1 < tp2tp1 <= tp2 |
如果所有元素相等,则为相等。大于/小于的比较从第一个元素到最后一个元素进行。 |
any
any是一个类,用于存储任何类型的单个值。它不是一个类模板。要将any转换为具体类型,你使用any cast,它是一个非成员函数模板。任何类型转换都是类型安全的;如果你尝试转换any且类型不匹配,将抛出异常。使用any,你可以进行某些类型的泛型编程,而无需模板。
标准库中有std::any(在<any>头文件中),而 Boost 库中有boost::any(在<boost/any.hpp>头文件中)。
要将一个值存储到any中,使用emplace方法模板。它接受一个模板参数,对应你想要存储到any中的类型(即存储类型)。你传递给emplace的任何参数都会转发到给定存储类型的适当构造函数中。要提取值,使用any_cast,它接受一个模板参数,对应any当前的存储类型(称为any的状态)。你将any作为唯一参数传递给any_cast。只要any的状态与模板参数匹配,你就能获得所需的类型。如果状态不匹配,会抛出bad_any_cast异常。
列表 12-12 演示了与std::any的这些基本交互。
#include <any>
struct EscapeCapsule {
EscapeCapsule(int x) : weight_kg{ x } { }
int weight_kg;
}; ➊
TEST_CASE("std::any allows us to std::any_cast into a type") {
std::any hagunemnon; ➋
hagunemnon.emplace<EscapeCapsule>(600); ➌
auto capsule = std::any_cast<EscapeCapsule>(hagunemnon); ➍
REQUIRE(capsule.weight_kg == 600);
REQUIRE_THROWS_AS(std::any_cast<float>(hagunemnon), std::bad_any_cast); ➎
}
列表 12-12:std::any和std::any_cast允许你提取具体类型。
你声明了EscapeCapsule类 ➊。在测试中,你构造了一个名为hagunemnon的空std::any对象 ➋。接下来,你使用emplace存储了一个weight_kg = 600的EscapeCapsule对象 ➌。你可以使用std::any_cast将EscapeCapsule取回,存储到一个名为capsule的新EscapeCapsule对象中 ➍。最后,你展示了尝试将hagunemnon转换为float类型时会导致std::bad_any_cast异常的情况 ➎。
支持操作的部分列表
表 12-5 提供了最受支持的std::any操作列表。在这个表中,ay 是一个std::any对象,t 是类型为 T 的对象。
表 12-5: 最受支持的std::any操作
| 操作 | 注释 |
|---|---|
any{} |
构造一个空的any对象。 |
any{ ay } |
从 ay 进行复制构造。 |
any{ move(ay) } |
从 ay 进行移动构造。 |
any{ move(t) } |
构造一个包含从 t 原地构造的对象的any对象。 |
ay = t |
销毁当前由 ay 包含的对象;复制 t。 |
ay = move(t) |
销毁当前由 ay 包含的对象;移动 t。 |
ay1 = ay2 |
从 ay2 进行复制赋值。 |
ay1 = move(ay2) |
从 ay2 进行移动赋值。 |
ay.emplace<T>(...) |
销毁当前由 ay 包含的对象;在原地构造一个 T 对象,将参数...转发给适当的构造函数。 |
ay.reset() |
销毁当前包含的对象。 |
ay1.swap(ay2) swap(ay1, ay2) |
交换 ay1 和 ay2 包含的对象。 |
make_any<T>(...) |
用于构造any的便利函数,在原地构造一个 T 对象,将参数...转发给适当的构造函数。 |
t = any_cast<T>(ay) |
将 ay 转换为类型 T。如果类型 T 与包含对象的类型不匹配,则抛出std::bad_any_cast异常。 |
variant
variant是一个类模板,用于存储类型受限于用户定义的模板参数列表的单个值。variant 是类型安全的union(参见“Unions”第 53 页)。它与any类型共享许多功能,但variant要求您明确列举所有要存储的类型。
标准库中在<variant>头文件中有std::variant,Boost 库中在<boost/variant.hpp>头文件中有boost::variant。
清单 12-13 演示了创建另一个名为BugblatterBeast的类型,以便variant可以与EscapeCapsule一起包含。
#include <variant>
struct BugblatterBeast {
BugblatterBeast() : is_ravenous{ true }, weight_kg{ 20000 } { }
bool is_ravenous;
int weight_kg; ➊
};
清单 12-13:std::variant可以包含预定义类型列表中的一个对象。
除了还包含一个weight_kg成员变量 ➊,BugblatterBeast与EscapeCapsule完全独立。
构造 variant
variant只有在满足以下两个条件之一时才能进行默认构造:
-
第一个模板参数是默认可构造的。
-
它是
monostate,一个旨在表明 variant 可以具有空状态的类型。
因为BugblatterBeast是可默认构造的(意味着它有一个默认构造函数),将其作为模板参数列表中的第一个类型,使得你的 variant 也可以默认构造,如下所示:
std::variant<BugblatterBeast, EscapeCapsule> hagunemnon;
要将一个值存储到variant中,你使用emplace方法模板。与any一样,variant接受一个模板参数,表示你希望存储的类型。这个模板参数必须包含在variant的模板参数列表中。要提取一个值,你可以使用非成员函数模板get或get_if。这些函数接受所需类型或模板参数列表中的索引,表示所需类型。如果get失败,它会抛出一个bad_variant_access异常,而get_if返回一个nullptr。
你可以使用index()成员函数来确定与variant当前状态对应的类型,它返回当前对象类型在模板参数列表中的索引。
列出 12-14 展示了如何使用emplace来更改variant的状态,并使用index来确定包含对象的类型。
TEST_CASE("std::variant") {
std::variant<BugblatterBeast, EscapeCapsule> hagunemnon;
REQUIRE(hagunemnon.index() == 0); ➊
hagunemnon.emplace<EscapeCapsule>(600); ➋
REQUIRE(hagunemnon.index() == 1); ➌
REQUIRE(std::get<EscapeCapsule>(hagunemnon).weight_kg == 600); ➍
REQUIRE(std::get<1>(hagunemnon).weight_kg == 600); ➎
REQUIRE_THROWS_AS(std::get<0>(hagunemnon), std::bad_variant_access); ➏
}
列出 12-14:std::get允许你从std::variant中提取具体类型。
在默认构造hagunemnon之后,调用index返回 0,因为这是正确模板参数的索引➊。接下来,你插入一个EscapeCapsule ➋,这使得index返回 1➌。std::get<EscapeCapsule> ➍和std::get<1> ➎都展示了提取包含类型的相同方法。最后,尝试调用std::get来获取一个与variant当前状态不符的类型会导致抛出bad_variant_access异常➏。
你可以使用非成员函数std::visit将一个可调用对象应用到一个 variant。这有一个优点,即可以派发正确的函数来处理包含的对象,而不必通过std::get显式指定类型。列出 12-15 展示了基本用法。
TEST_CASE("std::variant") {
std::variant<BugblatterBeast, EscapeCapsule> hagunemnon;
hagunemnon.emplace<EscapeCapsule>(600); ➊
auto lbs = std::visit([](auto& x) { return 2.2*x.weight_kg; }, hagunemnon); ➋
REQUIRE(lbs == 1320); ➌
}
列出 12-15:std::visit允许你将一个可调用对象应用到std::variant的包含类型。
首先,你调用emplace将值 600 存储到hagunemnon ➊中。因为BugblatterBeast和EscapeCapsule都有一个weight_kg成员,你可以使用std::visit在hagunemnon上调用一个 lambda,该 lambda 执行正确的转换(每公斤 2.2 磅)到weight_kg字段➋并返回结果➌(注意你不需要包含任何类型信息)。
比较 variant 和 any
宇宙足够大,可以容纳any和variant。通常无法推荐一个优于另一个,因为它们各自有其优缺点。
any更为灵活;它可以接受任何类型,而variant只能包含一个预定类型的对象。它通常避免使用模板,因此一般来说编程更为简单。
variant 的灵活性较差,因此更安全。通过使用 visit 函数,你可以在编译时检查操作的安全性。使用 any 时,你需要构建自己的类似 visit 的功能,并且需要在运行时检查(例如,检查 any_cast 的结果)。
最后,variant 比 any 更具性能优势。尽管当包含的类型过大时,any 可以执行动态分配,但 variant 不会这样做。
部分支持的操作列表
表 12-6 提供了最常见的 std::variant 操作列表。在该表中,vt 是一个 std::variant,t 是类型为 T 的对象。
表 12-6: 最常用的 std::variant 操作
| 操作 | 备注 |
|---|---|
variant<...>{} |
构造一个空的 variant 对象。第一个模板参数必须是可默认构造的。 |
variant<...>{ vt } |
从 vt 复制构造。 |
variant<...>{ move(vt) } |
从 vt 移动构造。 |
variant<...>{ move(t) } |
构造一个包含原地构造对象的 variant 对象。 |
vt = t |
解构当前由 vt 包含的对象;复制 t。 |
vt = move(t) |
解构当前由 vt 包含的对象;移动 t。 |
vt1 = vt2 |
从 vt2 复制赋值。 |
vt1 = move(vt2) |
从 vt2 移动赋值。 |
vt.emplace<T>(...) |
解构当前由 vt 包含的对象;在原地构造一个 T,并将参数 ... 转发给适当的构造函数。 |
vt.reset() |
销毁当前包含的对象。 |
vt.index() |
返回当前包含对象类型的零基索引。(顺序由 std::variant 的模板参数确定。) |
vt1.swap(vt2) swap(vt1, vt2) |
交换 vt1 和 vt2 中包含的对象。 |
make_variant<T>(...) |
用于构造 tuple 的便捷函数;在原地构造一个 T,并将参数 ... 转发给适当的构造函数。 |
std::visit(vt, callable) |
使用包含的对象调用 callable。 |
std::holds_alternative<T>(vt) |
如果包含的对象类型是 T,则返回 true。 |
std::get<I>(vt) std::get<T>(vt) |
如果包含的对象类型是 T 或第 i 种类型,则返回该对象。否则,抛出 std::bad_variant_access 异常。 |
std::get_if<I>(&vt) std::get_if<T>(&vt) |
如果包含的对象类型是 T 或第 i 种类型,则返回指向该对象的指针。否则,返回 nullptr。 |
vt1 == vt2 vt1 != vt2 vt1 > vt2 vt1 >= vt2 vt1 < vt2 vt1 <= vt2 |
比较 vt1 和 vt2 中包含的对象。 |
日期和时间
在标准库和 Boost 之间,有许多库可以处理日期和时间。当处理日历日期和时间时,可以查看 Boost 的 DateTime 库。当你需要获取当前时间或测量经过时间时,可以查看 Boost 或标准库的 Chrono 库,以及 Boost 的 Timer 库。
Boost DateTime
Boost DateTime 库通过一个基于格里历的丰富系统支持日期编程,格里历是全球最广泛使用的民用历法。日历比表面看起来更复杂。例如,考虑以下摘自美国海军天文台的《日历简介》的段落,它描述了闰年的基本知识:
每一个能被 4 整除的年份都是闰年,除了能被 100 整除的年份,但这些世纪年份如果能被 400 整除,则为闰年。例如,1700 年、1800 年和 1900 年不是闰年,但 2000 年是闰年。
与其尝试自己构建太阳历函数,不如包含 DateTime 的日期编程功能,使用以下头文件:
#include <boost/date_time/gregorian/gregorian.hpp>
你将使用的主要类型是boost::gregorian::date,它是日期编程的主要接口。
构造日期
有几种构造date的选项。你可以默认构造一个date,其值为特殊日期boost::gregorian::not_a_``date_time。要构造一个有效日期的date,你可以使用一个接受三个位数参数的构造函数:年份、月份和日期。以下语句构造了一个日期为 1986 年 9 月 15 日的date d:
boost::gregorian::date d{ 1986, 9, 15 };
另外,你可以使用boost::gregorian::from_string工具函数从字符串构造日期,如下所示:
auto d = boost::gregorian::from_string("1986/9/15");
如果你传递一个无效的日期,date构造函数将抛出一个异常,如bad_year、bad_day_of_month或bad_month。例如,示例 12-16 试图构造一个日期为 1986 年 9 月 32 日的日期。
TEST_CASE("Invalid boost::Gregorian::dates throw exceptions") {
using boost::gregorian::date;
using boost::gregorian::bad_day_of_month;
REQUIRE_THROWS_AS(date(1986, 9, 32), bad_day_of_month); ➊
}
示例 12-16:boost::gregorian::date构造函数会对无效日期抛出异常。
因为 9 月 32 日不是一个有效的日期,date构造函数会抛出bad_day_of_month异常 ➊。
注意
由于 Catch 的限制,你不能在REQUIRE_THROWS_AS宏中使用大括号初始化日期 ➊。
你可以通过非成员函数boost::gregorian::day_clock::local_day或boost::gregorian::day_clock::universal_day来获取当前日期,分别获取基于系统时区设置的本地日期和 UTC 日期:
auto d_local = boost::gregorian::day_clock::local_day();
auto d_univ = boost::gregorian::day_clock::universal_day();
一旦构造了一个日期,你不能改变它的值(它是不可变的)。然而,日期支持复制构造和复制赋值。
访问日期成员
你可以通过日期的许多const方法来检查一个date的特性。表 12-7 提供了部分列表。在这个表中,d是一个date。
表 12-7: 支持最多的boost::gregorian::date访问器
| 访问器 | 说明 |
|---|---|
d.year() |
返回date的年份部分。 |
d.month() |
返回date的月份部分。 |
d.day() |
返回date的天数部分。 |
d.day_of_week() |
返回一周中的星期几,作为greg_day_of_week类型的enum。 |
d.day_of_year() |
返回一年中的第几天(从 1 到 366)。 |
d.end_of_month() |
返回一个设置为 d 所在月份最后一天的日期对象。 |
d.is_not_a_date() |
如果 d 不是一个日期,返回 true。 |
d.week_number() |
返回 ISO 8601 周数。 |
清单 12-17 展示了如何构造一个 date 并使用 表格 12-7 中的访问器。
TEST_CASE("boost::gregorian::date supports basic calendar functions") {
boost::gregorian::date d{ 1986, 9, 15 }; ➊
REQUIRE(d.year() == 1986); ➋
REQUIRE(d.month() == 9); ➌
REQUIRE(d.day() == 15); ➍
REQUIRE(d.day_of_year() == 258); ➎
REQUIRE(d.day_of_week() == boost::date_time::Monday); ➏
}
清单 12-17:boost::gregorian::date 支持基本的日历功能。
在这里,你构造了一个表示 1986 年 9 月 15 日的 date ➊。然后,从中提取出年份 ➋、月份 ➌、日期 ➍、年份中的天数 ➎ 和星期几 ➏。
日历运算
你可以对日期进行简单的日历运算。当你将一个日期减去另一个日期时,得到的是一个 boost::gregorian::date_duration。date_duration 的主要功能是存储一个整数天数,你可以通过 days 方法提取出来。清单 12-18 展示了如何计算两个 date 对象之间的天数差。
TEST_CASE("boost::gregorian::date supports calendar arithmetic") {
boost::gregorian::date d1{ 1986, 9, 15 }; ➊
boost::gregorian::date d2{ 2019, 8, 1 }; ➋
auto duration = d2 - d1; ➌
REQUIRE(duration.days() == 12008); ➍
}
清单 12-18:减去 boost::gregorian::date 对象得到一个 boost::gregorian::date_duration。
在这里,你构造了一个表示 1986 年 9 月 15 日的 date ➊ 和一个表示 2019 年 8 月 1 日的 date ➋。你将这两个日期相减,得到一个 date_duration ➌。使用 days 方法,你可以提取这两个日期之间的天数 ➍。
你也可以使用一个表示天数的 long 参数来构造一个 date_duration。你可以将一个 date_duration 加到一个日期上,得到另一个日期,正如 清单 12-19 所展示的那样。
TEST_CASE("date and date_duration support addition") {
boost::gregorian::date d1{ 1986, 9, 15 }; ➊
boost::gregorian::date_duration dur{ 12008 }; ➋
auto d2 = d1 + dur; ➌
REQUIRE(d2 == boost::gregorian::from_string("2019/8/1")); ➍
}
清单 12-19:将一个 date_duration 加到一个 date 上得到另一个 date。
你构造了一个表示 1986 年 9 月 15 日的 date ➊ 和一个表示 12008 天的 duration ➋。根据 清单 12-18,你知道这一天加上 12008 天将得到 2019 年 8 月 1 日。因此,将它们相加后 ➌,得到的日期符合预期 ➍。
日期区间
日期区间 表示两个日期之间的时间间隔。DateTime 提供了一个 boost::gregorian::date_period 类,具有三个构造函数,如 表格 12-8 中所描述。在此表格中,构造函数 d1 和 d2 是 date 类型的参数,dp 是一个 date_period。
表格 12-8: 支持的 boost::gregorian::date_period 构造函数
| 访问器 | 说明 |
|---|---|
date_period{ d1, d2 } |
创建一个包括 d1 但不包括 d2 的时间段;如果 d2 <= d1,则无效。 |
date_period{ d, n_days } |
创建一个从 d 到 d+n_days 的时间段。 |
date_period{ dp } |
复制构造函数。 |
date_period 类支持许多操作,例如 contain 方法,它接受一个 date 参数,如果该参数包含在 period 中,则返回 true。清单 12-20 展示了这个操作。
TEST_CASE(+boost::gregorian::date supports periods+) {
boost::gregorian::date d1{ 1986, 9, 15 }; ➊
boost::gregorian::date d2{ 2019, 8, 1 }; ➋
boost::gregorian::date_period p{ d1, d2 }; ➌
REQUIRE(p.contains(boost::gregorian::date{ 1987, 10, 27 })); ➍
}
清单 12-20:使用 contains 方法判断一个日期是否在特定时间区间内,应用于 boost::gregorian::date_period
在这里,你构造了两个日期,1986 年 9 月 15 日 ➊ 和 2019 年 8 月 1 日 ➋,然后用它们构造一个 date_period ➌。使用 contains 方法,你可以确定 date_period 包含了 1987 年 10 月 27 日 ➍ 这个日期。
表 12-9 包含了其他一些 date_period 操作的部分列表。在此表中,p、p1 和 p2 是 date_period 类,而 d 是一个 date。
表 12-9: 支持的 boost::gregorian::date_period 操作
| 访问器 | 说明 |
|---|---|
p.begin() |
返回第一个日期。 |
p.last() |
返回最后一天。 |
p.length() |
返回包含的天数。 |
p.is_null() |
如果时间段无效(例如,结束时间在开始时间之前),返回 true。 |
p.contains(d) |
如果 d 在 p 内,返回 true。 |
p1.contains(p2) |
如果 p2 的所有部分都在 p1 内,返回 true。 |
p1.intersects(p2) |
如果 p2 中的任何部分落在 p1 中,返回 true。 |
p.is_after(d) |
如果 p 在 d 之后,返回 true。 |
p.is_before(d) |
如果 p 在 d 之前,返回 true。 |
其他 DateTime 特性
Boost 的 DateTime 库包含了三大类编程:
日期 日期编程就是你刚才了解的基于日历的编程。
时间 时间编程允许你处理具有微秒分辨率的时钟,位于 <boost/date_time/posix_time/posix_time.hpp> 头文件中。其原理与日期编程类似,但你处理的是时钟而非公历。
本地时间 本地时间编程仅仅是具有时区意识的时间编程。它位于 <boost/date_time/time_zone_base.hpp> 头文件中。
注意
为了简洁起见,本章不会详细讨论时间和本地时间编程。有关信息和示例,请参见 Boost 文档。
Chrono
stdlib 的 Chrono 库提供了多种时钟,位于 <chrono> 头文件中。当你需要编写依赖时间的程序或对代码进行计时时,通常会使用这些时钟。
注意
Boost 还提供了一个位于 <boost/chrono.hpp> 头文件中的 Chrono 库。它是 stdlib Chrono 库的超集,包含了例如进程特定时钟、线程特定时钟以及用户定义的时间输出格式等功能。
时钟
Chrono 库中有三种时钟可供使用;每种时钟提供不同的保证,且都位于 std::chrono 命名空间中:
-
std::chrono::system_clock是系统范围的实时时钟。有时也称为 挂钟,即从特定实现的开始日期算起的实际经过时间。大多数实现都指定从 Unix 开始日期 1970 年 1 月 1 日午夜起算。 -
std::chrono::steady_clock保证它的值永远不会减少。这看起来可能是一个荒谬的保证,但计量时间比看起来复杂。比如,系统可能需要处理闰秒或不准确的时钟。 -
std::chrono::high_resolution_clock具有最短的滴答周期:滴答是时钟能够测量的最小原子变化。
这三种时钟都支持静态成员函数 now,该函数返回一个时间点,表示当前时钟的时间值。
时间点
时间点 表示一个特定时刻,Chrono 使用 std::chrono::time_point 类型来编码时间点。从用户的角度来看,time_point 对象非常简单。它们提供了一个 time_since_epoch 方法,返回从时间点到时钟的纪元之间经过的时间。这个经过的时间称为时长。
纪元是一个实现定义的参考时间点,表示时钟的起始点。Unix 纪元(或 POSIX 时间)从 1970 年 1 月 1 日开始,而 Windows 纪元从 1601 年 1 月 1 日开始(对应于 400 年公历周期的开始)。
time_since_epoch 方法并不是从 time_point 获取时长的唯一方法。你还可以通过相减两个 time_point 对象来获取它们之间的时长。
时长
std::chrono::duration 表示两个 time_point 对象之间的时间。时长暴露了一个 count 方法,用于返回该时长中的时钟滴答数。
清单 12-21 显示了如何从三种可用的时钟中获取当前时间,提取每个时钟纪元以来的时长,并将它们转换为滴答数。
TEST_CASE("std::chrono supports several clocks") {
auto sys_now = std::chrono::system_clock::now(); ➊
auto hires_now = std::chrono::high_resolution_clock::now(); ➋
auto steady_now = std::chrono::steady_clock::now(); ➌
REQUIRE(sys_now.time_since_epoch().count() > 0); ➍
REQUIRE(hires_now.time_since_epoch().count() > 0); ➎
REQUIRE(steady_now.time_since_epoch().count() > 0); ➏
}
清单 12-21:std::chrono 支持多种类型的时钟。
你可以从 system_clock ➊、high_resolution_clock ➋ 和 steady_clock ➌ 获取当前时间。对于每个时钟,你都可以使用 time_since_epoch 方法将时间点转换为自该时钟纪元以来的时长。接着,你立即调用 count 方法,得到一个滴答数,该滴答数应该大于零 ➍ ➎ ➏。
除了通过时间点推导时长外,你还可以直接构造时长。std::chrono 命名空间包含了一些辅助函数来生成时长。为了方便起见,Chrono 提供了许多用户自定义的时长字面量,这些字面量位于 std::literals::chrono_literals 命名空间中。它们提供了一些语法糖,是便捷的语言语法,旨在简化开发者的工作,用于定义时长字面量。
表 12-10 显示了这些辅助函数及其字面量等价物,每个表达式都对应一个小时的时长。
表 12-10: std::chrono 辅助函数和用于创建时长的用户定义字面量
| 辅助函数 | 字面量等价物 |
|---|---|
nanoseconds(3600000000000) |
3600000000000ns |
microseconds(3600000000) |
3600000000us |
milliseconds(3600000) |
3600000ms |
seconds(3600) |
3600s |
minutes(60) |
60m |
hours(1) |
1h |
例如,清单 12-22 展示了如何使用std::chrono::seconds构造 1 秒的持续时间,以及使用ms持续时间字面量构造 1,000 毫秒的持续时间。
#include <chrono>
TEST_CASE("std::chrono supports several units of measurement") {
using namespace std::literals::chrono_literals; ➊
auto one_s = std::chrono::seconds(1); ➋
auto thousand_ms = 1000ms; ➌
REQUIRE(one_s == thousand_ms); ➍
}
清单 12-22:std::chrono支持多种度量单位,它们是可比较的。
在这里,你引入std::literals::chrono_literals命名空间,以便访问持续时间字面量 ➊。你从seconds辅助函数 ➋ 构造了一个名为one_s的持续时间,从ms持续时间字面量 ➌ 构造了另一个名为thousand_ms的持续时间。这两个是等价的,因为一秒包含一千毫秒 ➍。
Chrono 提供了函数模板std::chrono::duration_cast,用于将持续时间从一种单位转换为另一种单位。与其他与类型转换相关的函数模板(例如static_cast)一样,duration_cast接受一个对应目标持续时间的单一模板参数,以及一个对应要转换的持续时间的单一参数。
清单 12-23 展示了如何将纳秒持续时间转换为秒持续时间。
TEST_CASE("std::chrono supports duration_cast") {
using namespace std::chrono; ➊
auto billion_ns_as_s = duration_cast<seconds➋>(1'000'000'000ns➌);
REQUIRE(billion_ns_as_s.count() == 1); ➍
}
清单 12-23:std::chrono支持std::chrono::duration_cast。
首先,你引入std::chrono命名空间,以便轻松访问duration_cast、持续时间辅助函数和持续时间字面量 ➊。接下来,你使用ns持续时间字面量来指定一个十亿纳秒的持续时间 ➌,并将其作为参数传递给duration_cast。你将duration_cast的模板参数指定为秒 ➋,因此结果持续时间billion_ns_as_s等于 1 秒 ➍。
等待
有时,你会使用持续时间来指定程序等待的时间段。标准库提供了位于<thread>头文件中的并发原语,其中包含了非成员函数std::this_thread::sleep_for。sleep_for函数接受一个duration参数,表示你希望当前执行线程等待或“休眠”的时间长度。
清单 12-24 展示了如何使用sleep_for。
#include <thread>
#include <chrono>
TEST_CASE("std::chrono used to sleep") {
using namespace std::literals::chrono_literals; ➊
auto start = std::chrono::system_clock::now(); ➋
std::this_thread::sleep_for(100ms); ➌
auto end = std::chrono::system_clock::now(); ➍
REQUIRE(end - start >= 100ms); ➎
}
清单 12-24:std::chrono与<thread>一起使用,以使当前线程休眠。
如之前所述,你引入了chrono_literals命名空间,以便访问持续时间字面量 ➊。你根据system_clock记录了当前时间,并将结果time_point保存到start变量中 ➋。接下来,你调用sleep_for,传入一个 100 毫秒的持续时间(即十分之一秒) ➌。然后你再次记录当前时间,将结果time_point保存到end ➍。因为程序在调用std::chrono::system_clock之间休眠了 100 毫秒,所以从start减去end得到的持续时间应该至少为100ms ➎。
计时
为了优化代码,你必须进行准确的测量。你可以使用 Chrono 来衡量一系列操作所需的时间。这让你能够确认某个特定的代码路径实际上是导致观察到的性能问题的原因。它还使你能够为优化工作进展建立一个客观的衡量标准。
Boost 的 Timer 库包含了位于 <boost/timer/timer.hpp> 头文件中的 boost::timer::auto_cpu_timer 类,这是一个 RAII 对象,它在构造函数中开始计时,在析构函数中停止计时。
你可以仅使用 stdlib Chrono 库构建你自己的简易 Stopwatch 类。Stopwatch 类可以保存一个 duration 对象的引用。在 Stopwatch 的析构函数中,你可以通过引用设置 duration。列表 12-25 提供了一个实现。
#include <chrono>
struct Stopwatch {
Stopwatch(std::chrono::nanoseconds& result➊)
: result{ result }, ➋
start{ std::chrono::high_resolution_clock::now() } { } ➌
~Stopwatch() {
result = std::chrono::high_resolution_clock::now() - start; ➍
}
private:
std::chrono::nanoseconds& result;
const std::chrono::time_point<std::chrono::high_resolution_clock> start;
};
列表 12-25:一个简单的 Stopwatch 类,用于计算其生命周期的持续时间
Stopwatch 构造函数需要一个 nanoseconds 类型的引用 ➊,你将其存储到 result 字段中,使用成员初始化器 ➋。你还通过将 start 字段设置为 now() 的结果来保存当前的 high_resolution_clock 时间 ➌。在 Stopwatch 的析构函数中,你再次调用 now() 来获取 high_resolution_clock 的时间,并用 start 减去它,得到 Stopwatch 生命周期的持续时间。你使用 result 引用来写入 duration ➍。
列表 12-26 展示了 Stopwatch 的实际应用,在一个循环中执行百万次浮点除法,并计算每次迭代的平均时间。
#include <cstdio>
#include <cstdint>
#include <chrono>
struct Stopwatch {
--snip--
};
int main() {
const size_t n = 1'000'000; ➊
std::chrono::nanoseconds elapsed; ➋
{
Stopwatch stopwatch{ elapsed }; ➌
volatile double result{ 1.23e45 }; ➍
for (double i = 1; i < n; i++) {
result /= i; ➎
}
}
auto time_per_division = elapsed.count() / double{ n }; ➏
printf("Took %gns per division.", time_per_division); ➐
}
--------------------------------------------------------------------------
Took 6.49622ns per division. ➐
列表 12-26:使用 Stopwatch 来估算 double 除法所需的时间
首先,你初始化一个变量 n 为一百万,这表示程序将执行的总迭代次数 ➊。然后,你声明了 elapsed 变量,用于存储所有迭代过程中所用的时间 ➋。在一个代码块中,你声明了一个 Stopwatch 并将 elapsed 引用传递给构造函数 ➌。接着,你声明了一个名为 result 的 double 类型变量,并为其赋一个无意义的初值 ➍。你将这个变量声明为 volatile,以防编译器尝试优化掉循环。在循环内,你执行一些任意的浮点除法操作 ➎。
一旦代码块执行完毕,stopwatch 将被析构。这会将 stopwatch 的持续时间写入 elapsed,然后你可以使用它来计算每次循环迭代的纳秒平均数,并将结果存入 time_per_addition 变量 ➏。你通过 printf 打印 time_per_division 来结束程序 ➐。
数值运算
本节讨论了如何处理数字,重点是常见的数学函数和常量;如何处理复数;生成随机数、数字极限和转换;以及计算比率。
数值函数
stdlib 数值和 Boost 数学库提供了大量的数值/数学函数。为了简洁起见,本章仅提供快速参考。有关详细内容,请参见 ISO C++ 17 标准中的 [numerics] 和 Boost 数学文档。
表 12-11 提供了 stdlib 数学库中许多常见的非成员数学函数的部分列表。
表 12-11: stdlib 中常用数学函数的部分列表
| 函数 | 计算 . . . | 整数 | 浮点数 | 头文件 |
|---|---|---|---|---|
abs(x) |
x 的绝对值。 | ✓ | <cstdlib> |
|
div(x, y) |
x 除以 y 的商和余数。 | ✓ | <cstdlib> |
|
abs(x) |
x 的绝对值。 | ✓ | <cmath> |
|
fmod(x, y) |
x 除以 y 的浮点数余数。 | ✓ | <cmath> |
|
remainder(x, y) |
x 除以 y 的带符号余数。 | ✓ | ✓ | <cmath> |
fma(x, y, z) |
将前两个参数相乘,并将其乘积加到第三个参数;也称为融合乘法加法;即,x * y + z。 |
✓ | ✓ | <cmath> |
max(x, y) |
x 和 y 的最大值。 | ✓ | ✓ | <algorithm> |
min(x, y) |
x 和 y 的最小值。 | ✓ | ✓ | <algorithm> |
exp(x) |
e^x 的值。 |
✓ | ✓ | <cmath> |
exp2(x) |
2^x 的值。 |
✓ | ✓ | <cmath> |
log(x) |
x 的自然对数;即,ln x。 | ✓ | ✓ | <cmath> |
log10(x) |
x 的常用对数;即,log10 x。 | ✓ | ✓ | <cmath> |
log2(x) |
x 的以 2 为底的对数;即,log10 x。 | ✓ | ✓ | <cmath> |
gcd(x, y) |
x 和 y 的最大公约数。 | ✓ | <numeric> |
|
lcm(x, y) |
x 和 y 的最小公倍数。 | ✓ | <numeric> |
|
erf(x) |
x 的高斯误差函数。 | ✓ | ✓ | <cmath> |
pow(x, y) |
x^y 的值。 | ✓ | ✓ | <cmath> |
sqrt(x) |
x 的平方根。 | ✓ | ✓ | <cmath> |
cbrt(x) |
x 的立方根。 | ✓ | ✓ | <cmath> |
hypot(x, y) |
x² + y² 的平方根。 |
✓ | ✓ | <cmath> |
sin(x)``cos(x)``tan(x)``asin(x)``acos(x)``atan(x) |
相关的三角函数值。 | ✓ | ✓ | <cmath> |
sinh(x)``cosh(x)``tanh(x)``asinh(x)``acosh(x)``atanh(x) |
相关的双曲函数值。 | ✓ | ✓ | <cmath> |
ceil(x) |
大于或等于 x 的最小整数。 | ✓ | ✓ | <cmath> |
floor(x) |
小于或等于 x 的最大整数。 | ✓ | ✓ | <cmath> |
round(x) |
与 x 最接近的整数;在中点情况下远离零。 | ✓ | ✓ | <cmath> |
isfinite(x) |
如果 x 是有限数,则值为 true。 |
✓ | ✓ | <cmath> |
isinf(x) |
如果 x 是无限大数,则值为 true。 |
✓ | ✓ | <cmath> |
注意
其他专门的数学函数位于 <cmath> 头文件中。例如,用于计算拉盖尔多项式和厄尔米特多项式、椭圆积分、圆柱贝塞尔函数和诺伊曼函数以及黎曼ζ函数的函数都出现在该头文件中。
复数
复数 的形式为 a+bi,其中 i 是 虚数,它与自身相乘等于负一;即 i*i=-1。虚数在控制理论、流体动力学、电气工程、信号分析、数论和量子物理等多个领域都有应用。复数的 a 部分称为其 实部,b 部分称为其 虚部。
标准库提供了 <complex> 头文件中的 std::complex 类模板。它接受一个模板参数,用于指定实部和虚部的底层类型。这个模板参数必须是基本的浮点类型之一。
要构造一个 complex,你可以传入两个参数:实部和虚部。complex 类还支持拷贝构造和拷贝赋值。
非成员函数 std::real 和 std::imag 可以分别从 complex 中提取实部和虚部,如列表 12-27 所示。
#include <complex>
TEST_CASE("std::complex has a real and imaginary component") {
std::complex<double> a{0.5, 14.13}; ➊
REQUIRE(std::real(a) == Approx(0.5)); ➋
REQUIRE(std::imag(a) == Approx(14.13)); ➌
}
列表 12-27:构造一个 std::complex 并提取其组成部分
你构造了一个实部为 0.5,虚部为 14.13 的 std::complex ➊。你使用 std::real 提取实部 ➋,使用 std::imag 提取虚部 ➌。
表 12-12 包含了 std::complex 支持的部分操作列表。
表 12-12: std::complex 操作的部分列表
| 操作 | 备注 |
|---|---|
| c1+c2c1-c2c1*c2c1/c2 | 执行加法、减法、乘法和除法。 |
| c+sc-sc*sc/s | 将标量 s 转换为一个复数,其中实部等于标量值,虚部为零。此转换支持上一行中的相应复数操作(加法、减法、乘法或除法)。 |
real``(c) |
提取实部。 |
imag``(c) |
提取虚部。 |
abs``(c) |
计算幅度。 |
arg``(c) |
计算相位角。 |
norm``(c) |
计算平方幅度。 |
conj``(c) |
计算复共轭。 |
proj``(c) |
计算黎曼球投影。 |
sin``(c) |
计算正弦。 |
cos``(c) |
计算余弦。 |
tan``(c) |
计算正切。 |
asin``(c) |
计算反正弦。 |
acos``(c) |
计算反余弦。 |
atan``(c) |
计算反正切。 |
c = polar(m, a) |
计算由幅度 m 和角度 a 确定的复数。 |
数学常数
Boost 提供了一套常用的数学常数,这些常数定义在 <boost /math/constants/constants.hpp> 头文件中。共有超过 70 个常数可用,您可以通过从 boost::math::float_constants、boost::math::double_constants 和 boost::math::long_double_constants 中获取相关的全局变量,分别获得 float、double 或 long double 类型的常数。
其中一个可用常数是 four_thirds_pi,它近似为 4π/3。计算半径为 r 的球体体积的公式是 4π*r³/3,因此您可以引入这个常数,轻松计算这种体积。列表 12-28 说明了如何计算半径为 10 的球体体积。
#include <cmath>
#include <boost/math/constants/constants.hpp>
TEST_CASE("boost::math offers constants") {
using namespace boost::math::double_constants; ➊
auto sphere_volume = four_thirds_pi * std::pow(10, 3); ➋
REQUIRE(sphere_volume == Approx(4188.7902047));
}
列表 12-28:boost::math 命名空间提供常数
这里,您引入了命名空间 boost::math::double_constants,它带来了所有 Boost 数学常数的 double 类型版本 ➊。接下来,您通过计算 four_thirds_pi 乘以 10³ 来计算 sphere_volume ➋。
表 12-13 提供了一些 Boost 数学库中常用的常数。
表 12-13: 一些最常用的 Boost 数学常数
| 常数 | 值 | 近似值 | 备注 |
|---|---|---|---|
half |
1/2 | 0.5 | |
third |
1/3 | 0.333333 | |
two_thirds |
2/3 | 0.66667 | |
three_quarters |
3/4 | 0.75 | |
root_two |
√2 | 1.41421 | |
root_three |
√3 | 1.73205 | |
half_root_two |
√2 / 2 | 0.707106 | |
ln_two |
ln(2) | 0.693147 | |
ln_ten |
ln(10) | 2.30258 | |
pi |
π | 3.14159 | 阿基米德常数 |
two_pi |
2π | 6.28318 | 单位圆的周长 |
four_thirds_pi |
4π/3 | 4.18879 | 单位球的体积 |
one_div_two_pi |
1/(2π) | 1.59155 | 高斯积分 |
root_pi |
√ π | 1.77245 | |
e |
e | 2.71828 | 欧拉常数 e |
e_pow_pi |
e^π | 23.14069 | 盖尔方德常数 |
root_e |
√e | 1.64872 | |
log10_e |
log10(e) | 0.434294 | |
degree |
π / 180 | 0.017453 | 每度的弧度数 |
radian |
180 / π | 57.2957 | 每弧度的度数 |
sin_one |
sin(1) | 0.84147 | |
cos_one |
cos(1) | 0.5403 | |
phi |
(1 + √5) / 2 | 1.61803 | 费迪亚斯黄金比例 φ |
ln_phi |
ln(φ) | 0.48121 |
随机数
在一些场景中,生成随机数是常见的需求。在科学计算中,您可能需要基于随机数运行大量模拟。这些随机数需要模拟来自具有特定特性的随机过程,例如来自泊松分布或正态分布的抽取。此外,通常希望这些模拟是可重复的,因此负责生成随机数的代码——即随机数引擎——应在相同输入下生成相同的输出。这类随机数引擎有时被称为伪随机数引擎。
在密码学中,你可能需要随机数来保护信息。在这种情况下,必须几乎不可能有人获得相似的随机数流;因此,偶然使用伪随机数引擎往往会严重破坏本应安全的加密系统。
基于这些原因以及其他原因,你不应尝试自行构建随机数生成器。构建一个正确的随机数生成器出乎意料地困难。很容易在你的随机数生成器中引入模式,这可能会对使用你的随机数作为输入的系统造成严重且难以诊断的副作用。
注意
如果你对随机数生成感兴趣,请参考 Brian D. Ripley 的《随机模拟》第二章,了解科学应用,或者 Jean-Philippe Aumasson 的《严肃的密码学》第二章,了解密码学应用。
如果你需要随机数,可以直接查看标准库中的<random>头文件,或者 Boost 中的<boost/math/...>头文件中的随机库。
随机数引擎
随机数引擎生成随机位。在 Boost 和标准库之间,有众多候选者可供选择。这里有一个通用规则:如果你需要可重复的伪随机数,可以考虑使用梅森旋转引擎std::mtt19937_64。如果你需要密码学上安全的随机数,可以考虑使用std::random_device。
梅森旋转引擎具有一些在模拟中理想的统计特性。你为它的构造函数提供一个整数种子值,这完全决定了随机数序列。所有的随机引擎都是函数对象;要获得一个随机数,使用函数调用operator()。 清单 12-29 展示了如何使用种子 91586 构造梅森旋转引擎并调用该引擎三次。
#include <random>
TEST_CASE("mt19937_64 is pseudorandom") {
std::mt19937_64 mt_engine{ 91586 }; ➊
REQUIRE(mt_engine() == 8346843996631475880); ➋
REQUIRE(mt_engine() == 2237671392849523263); ➌
REQUIRE(mt_engine() == 7333164488732543658); ➍
}
清单 12-29:mt19937_64是一个伪随机数引擎。
在这里,你构造了一个mt19937_64梅森旋转引擎,种子为 91586 ➊。因为它是一个伪随机引擎,所以你每次都会得到相同的随机数序列 ➋ ➌ ➍。这个序列完全由种子决定。
清单 12-30 展示了如何构造一个random_device并调用它以获得密码学安全的随机值。
TEST_CASE("std::random_device is invocable") {
std::random_device rd_engine{}; ➊
REQUIRE_NOTHROW(rd_engine()); ➋
}
清单 12-30:random_device是一个函数对象。
你使用默认构造函数 ➊ 构造一个random_device。结果对象rd_engine ➋ 是可调用的,但你应该将该对象视为不透明的。与清单 12-29 中的梅森旋转引擎不同,random_device是按设计不可预测的。
注意
由于计算机本身是确定性的,std::random_device无法对密码学安全性做出任何强有力的保证。
随机数分布
随机数分布 是一个数学函数,它将数字映射到概率密度。大致的思路是,如果你从一个具有特定分布的随机变量中抽取无限样本,并绘制样本值的相对频率,那么该图形将呈现该分布的形状。
分布分为两大类:离散 和 连续。一个简单的类比是,离散分布映射整数值,而连续分布映射浮点值。
大多数分布接受自定义参数。例如,正态分布是一个连续分布,接受两个参数:均值和方差。其密度呈现一个熟悉的钟形曲线,围绕均值对称,如 图 12-1 所示。离散均匀分布是一个随机数分布,它将均等的概率分配给介于最小值和最大值之间的数字。其密度在最小值到最大值的范围内呈平坦状,如 图 12-2 所示。

图 12-1:正态分布概率密度函数的表示

图 12-2:均匀分布概率密度函数的表示
你可以使用相同的标准库 Random 库轻松生成来自常见统计分布的随机数,例如均匀分布和正态分布。每个分布在其构造函数中接受一些参数,这些参数对应于基础分布的参数。要从分布中抽取一个随机变量,你可以使用函数调用 operator() 并传入一个随机数引擎的实例,例如梅森旋转算法。
std::uniform_int_distribution 是一个类模板,位于 <random> 头文件中,它接受一个模板参数,指定你希望从分布中抽取的值类型,例如 int。你通过将最小值和最大值作为构造函数参数传入来指定均匀分布的范围。范围内的每个数字都有相等的概率。这可能是最常见的分布,出现在一般的软件工程上下文中。
清单 12-31 演示了如何从最小值为 1、最大值为 10 的均匀分布中抽取一百万个样本,并计算样本均值。
TEST_CASE("std::uniform_int_distribution produces uniform ints") {
std::mt19937_64 mt_engine{ 102787 }; ➊
std::uniform_int_distribution<int> int_d{ 0, 10 }; ➋
const size_t n{ 1'000'000 }; ➌
int sum{}; ➍
for (size_t i{}; i < n; i++)
sum += int_d(mt_engine); ➎
const auto sample_mean = sum / double{ n }; ➏
REQUIRE(sample_mean == Approx(5).epsilon(.1)); ➐
}
清单 12-31:uniform_int_distribution 模拟了来自离散均匀分布的抽取。
你用种子值 102787 ➊ 构造了一个梅森旋转算法实例,然后用最小值 0 和最大值 10 ➋ 构造一个 uniform_int_distribution。接着你初始化一个变量 n 来存储迭代次数 ➌,并初始化一个变量来保存所有均匀随机变量的 sum ➍。在循环中,你通过 operator() 从均匀分布中抽取随机变量,并传入梅森旋转算法实例 ➎。
离散均匀分布的均值是最小值与最大值之和除以 2。这里,int_d的均值为 5。你可以通过将sum除以样本数量n来计算样本均值 ➏。你可以有很高的信心断言,这个sample_mean大约为 5 ➐。
随机数分布部分列表
表 12-14 包含了<random>中随机数分布的部分列表,它们的默认模板参数和构造函数参数。
表 12-14: <random>中的随机数分布
| 分布 | 说明 |
|---|---|
uniform_int_distribution<int>{ min, max } |
具有最小值 min 和最大值 max 的离散均匀分布。 |
uniform_real_distribution<double>{ min, max } |
具有最小值 min 和最大值 max 的连续均匀分布。 |
normal_distribution<double>{ m, s } |
具有均值 m 和标准差 s 的正态分布。通常用于建模多个独立随机变量的加法积。也称为高斯分布。 |
lognormal_distribution<double>{ m, s } |
具有均值 m 和标准差 s 的对数正态分布。通常用于建模多个独立随机变量的乘法积。也称为 Galton 分布。 |
chi_squared_distribution<double>{ n } |
具有自由度 n 的卡方分布。通常用于推理统计学中。 |
cauchy_distribution<double>{ a, b } |
具有位置参数 a 和尺度参数 b 的 Cauchy 分布。用于物理学中。也称为 Lorentz 分布。 |
fisher_f_distribution<double>{ m, n } |
具有自由度 m 和 n 的 F 分布。通常用于推理统计学中。也称为 Snedecor 分布。 |
student_t_distribution<double>{ n } |
具有自由度 n 的 T 分布。通常用于推理统计学中。也称为学生 T 分布。 |
bernoulli_distribution{ p } |
具有成功概率 p 的伯努利分布。通常用于建模单次布尔值结果的实验。 |
binomial_distribution<int>{ n, p } |
具有 n 次试验和成功概率 p 的二项分布。通常用于建模在一系列伯努利实验中有放回抽样时的成功次数。 |
geometric_distribution<int>{ p } |
具有成功概率 p 的几何分布。通常用于建模在一系列伯努利实验中,第一次成功前发生的失败次数。 |
poisson_distribution<int>{ m } |
具有均值 m 的泊松分布。通常用于建模固定时间间隔内发生的事件数量。 |
exponential_distribution<double>{ l } |
具有均值 1/l 的指数分布,其中 l 被称为 lambda 参数。通常用于建模泊松过程中的事件间隔时间。 |
gamma_distribution<double>{ a, b } |
具有形状参数 a 和尺度参数 b 的 Gamma 分布。指数分布和卡方分布的推广。 |
weibull_distribution<double>{ k, l } |
具有形状参数 k 和尺度参数 l 的 Weibull 分布。常用于建模故障时间。 |
extreme_value_distribution<double>{ a, b } |
具有位置参数 a 和尺度参数 b 的极值分布。常用于建模独立随机变量的最大值。也称为 Gumbel 类型-I 分布。 |
注意
Boost Math 提供了更多的随机数分布,位于<boost/math/...>系列头文件中,例如 beta 分布、超几何分布、对数分布和反正态分布。
数值限制
标准库提供了std::numeric_limits类模板,该模板位于<limits>头文件中,用于在编译时提供关于各种算术类型的属性信息。例如,如果你想要确定给定类型T的最小有限值,可以使用静态成员函数std::numeric_limits<T>::min()来获取该值。
示例 12-32 展示了如何使用min来促进下溢。
#include <limits>
TEST_CASE("std::numeric_limits::min provides the smallest finite value.") {
auto my_cup = std::numeric_limits<int>::min(); ➊
auto underfloweth = my_cup - 1; ➋
REQUIRE(my_cup < underfloweth); ➌
}
示例 12-32:使用std::numeric_limits<T>::min()来促进int下溢。尽管在本文发布时,主要的编译器生成的代码通过了测试,但该程序包含未定义行为。
首先,你将my_cup变量设置为最小的int值,通过使用std::numeric_limits<int>::min() ➊。接下来,你故意通过从my_cup中减去 1 来引发下溢 ➋。因为my_cup是int类型可以取的最小值,所以my_cup发生了下溢,正如俗话所说的那样。这导致了一个荒谬的情况,即underfloweth大于my_cup ➌,尽管你是通过从my_cup中减去得到underfloweth的。
注意
这种静默下溢已成为无数软件安全漏洞的根源。不要依赖这种未定义的行为!
许多静态成员函数和成员常量可用于std::numeric_limits。表 12-15 列出了其中一些最常用的。
表 12-15: std::numeric_limits中的一些常见成员常量
| 操作 | 说明 |
|---|---|
numeric_limits<T>::is_signed |
如果 T 是有符号类型,则为true。 |
numeric_limits<T>::is_integer |
如果 T 是整数,则为true。 |
numeric_limits<T>::has_infinity |
标识 T 是否可以编码无限值。(通常,所有浮点类型都具有无限值,而整数类型则没有。) |
numeric_limits<T>::digits10 |
标识 T 可以表示的数字位数。 |
numeric_limits<T>::min() |
返回 T 的最小值。 |
numeric_limits<T>::max() |
返回 T 的最大值。 |
注意
Boost Integer 提供了一些额外的功能,用于反射整数类型,比如确定最快或最小的整数,或具有至少 N 位的最小整数。
Boost 数值转换
Boost 提供了数值转换库,其中包含了一组用于在数字对象之间转换的工具。boost::converter 类模板位于 <boost/numeric/conversion/converter.hpp> 头文件中,封装了从一种类型到另一种类型的特定数值转换代码。你必须提供两个模板参数:目标类型 T 和源类型 S。你可以指定一个数值转换器,将 double 转换为 int,通过简单的类型别名 double_to_int:
#include <boost/numeric/conversion/converter.hpp>
using double_to_int = boost::numeric::converter<int➊, double➋>;
要使用你新建的类型别名 double_to_int 进行转换,你有几个选择。首先,你可以使用它的静态方法 convert,该方法接受一个 double ➋ 并返回一个 int ➊,正如 列表 12-33 所示。
TEST_CASE("boost::converter offers the static method convert") {
REQUIRE(double_to_int::convert(3.14159) == 3);
}
列表 12-33:boost::converter 提供了静态方法 convert。
在这里,你只需调用 convert 方法并传入值 3.14159,boost::convert 会将其转换为 3。
因为 boost::convert 提供了函数调用 operator(),你可以构造一个函数对象 double_to_int 并使用它来进行转换,正如 列表 12-34 所示。
TEST_CASE("boost::numeric::converter implements operator()") {
double_to_int dti; ➊
REQUIRE(dti(3.14159) == 3); ➋
REQUIRE(double_to_int{}(3.14159) == 3); ➌
}
列表 12-34:boost::converter 实现了 operator()。
你构造了一个名为 dti 的 double_to_int 函数对象 ➊,并用相同的参数 3.14159 ➋ 调用它,正如 列表 12-33 所示。结果是相同的。你还可以选择构造一个临时函数对象并直接使用 operator(),这将得到相同的结果 ➌。
使用 boost::converter 的一个主要优点,相比于 static_cast 等替代方案,是运行时边界检查。如果一个转换会导致溢出,boost::converter 将抛出 boost::numeric::positive_overflow 或 boost::numeric::negative_overflow 异常。列表 12-35 展示了当你尝试将一个非常大的 double 转换为 int 时的这种行为。
#include <limits>
TEST_CASE("boost::numeric::converter checks for overflow") {
auto yuge = std::numeric_limits<double>::max(); ➊
double_to_int dti; ➋
REQUIRE_THROWS_AS(dti(yuge)➌, boost::numeric::positive_overflow➍);
}
列表 12-35:boost::converter 检查溢出。
你使用 numeric_limits 来获得一个 yuge 值 ➊。你构造了一个 double _``to_int 转换器 ➋,并用它尝试将 yuge 转换为 int ➌。这会抛出一个 positive_overflow 异常,因为该值太大,无法存储 ➍。
你可以使用模板参数自定义 boost::converter 的转换行为。例如,你可以自定义溢出处理,使其抛出一个自定义异常或执行其他操作。你还可以自定义舍入行为,这样就不会从浮动值中截断小数,而是执行自定义的舍入操作。详细信息请参见 Boost 数值转换文档。
如果你对默认的 boost::converter 行为满意,你可以使用 boost::numeric_cast 函数模板作为快捷方式。该函数模板接受一个模板参数,表示转换目标类型,并接受一个参数,表示源数值。列表 12-36 提供了对 列表 12-35 的更新,使用了 boost::numeric_cast。
#include <limits>
#include <boost/numeric/conversion/cast.hpp>
TEST_CASE("boost::boost::numeric_cast checks overflow") {
auto yuge = std::numeric_limits<double>::max(); ➊
REQUIRE_THROWS_AS(boost::numeric_cast<int>(yuge), ➋
boost::numeric::positive_overflow ➌);
}
列表 12-36:boost::numeric_cast 函数模板也执行运行时边界检查。
和以前一样,你使用 numeric_limits 获取一个 yuge 值 ➊。当你尝试将 yuge 转换为 int ➋ 时,你会得到一个 positive_overflow 异常,因为该值太大,无法存储 ➌。
注意
boost::numeric_cast 函数模板是你在 列表 6-6(第 154 页)中自定义的 narrow_cast 的合适替代。
编译时有理数算术运算
stdlib 中的 std::ratio(位于 <ratio> 头文件中)是一个类模板,使你能够在编译时进行有理数算术运算。你向 std::ratio 提供两个模板参数:一个分子和一个分母。这定义了一个新类型,你可以使用该类型来计算有理数表达式。
你可以通过使用模板元编程技术来执行 std::ratio 的编译时计算。例如,要乘以两个 ratio 类型,你可以使用 std::ratio_multiply 类型,该类型将两个 ratio 类型作为模板参数。你可以通过结果类型的静态成员变量来提取分子和分母。
列表 12-37 说明了如何在编译时将 10 乘以 2/3。
#include <ratio>
TEST_CASE("std::ratio") {
using ten = std::ratio<10, 1>; ➊
using two_thirds = std::ratio<2, 3>; ➋
using result = std::ratio_multiply<ten, two_thirds>; ➌
REQUIRE(result::num == 20); ➍
REQUIRE(result::den == 3); ➎
}
列表 12-37:使用 std::ratio 进行编译时有理数算术运算
你声明了 std::ratio 类型的 ten ➊ 和 two_thirds ➋ 作为类型别名。为了计算 ten 和 two_thirds 的积,你再次声明了另一个类型 result,使用 std::ratio_multiply 模板 ➌。通过使用静态成员 num 和 den,你可以提取结果 20/3 ➍ ➎。
当然,当你能在编译时进行计算时,最好尽量避免在运行时进行计算。这样你的程序将更加高效,因为它们在运行时需要做的计算更少。
随机数分布的部分列表
表 12-16 包含了 stdlib <ratio> 库提供的部分操作列表。
表 12-16: <ratio> 中可用操作的部分列表
| 分布 | 说明 |
|---|---|
ratio_add<r1, r2> |
将 r1 和 r2 相加 |
ratio_subtract<r1, r2> |
从 r1 中减去 r2 |
ratio_multiply<r1, r2> |
计算 r1 和 r2 的乘积 |
ratio_divide<r1, r2> |
将 r1 除以 r2 |
ratio_equal<r1, r2> |
测试 r1 是否等于 r2 |
ratio_not_equal<r1, r2> |
测试 r1 是否不等于 r2 |
ratio_less<r1, r2> |
测试 r1 是否小于 r2 |
ratio_greater<r1, r2> |
测试 r1 是否大于 r2 |
ratio_less_equal<r1, r2> |
测试 r1 是否小于或等于 r2 |
ratio_greater_equal<r1, r2> |
测试 r1 是否大于或等于 r2 |
micro |
字面值:ratio<1, 1000000> |
milli |
字面值:ratio<1, 1000> |
centi |
字面值:ratio<1, 100> |
deci |
字面值:ratio<1, 10> |
deca |
字面值:ratio<10, 1> |
hecto |
字面值:ratio<100, 1> |
kilo |
字面值:ratio<1000, 1> |
mega |
字面值:ratio<1000000, 1> |
giga |
字面值:ratio<1000000000, 1> |
总结
在本章中,你学习了一些小巧、简单、聚焦的实用工具,它们服务于常见的编程需求。数据结构,如tribool、optional、pair、tuple、any和variant,处理了许多常见场景,在这些场景中,你需要将对象包含在一个通用的结构中。在接下来的章节中,这些数据结构中的一些将在整个标准库中多次出现。你还学习了日期/时间和数字/数学功能。这些库实现了非常具体的功能,但当你有此类需求时,这些库是无价的。
练习
12-1. 重新实现 Listing 6-6 中的narrow_cast,使其返回一个std::optional。如果强制转换会导致精度丢失,请返回一个空的 optional,而不是抛出异常。编写单元测试,确保你的解决方案有效。
12-2. 实现一个程序,生成随机字母数字密码并将其写入控制台。你可以将可能字符的字母表存储到一个char[]数组中,并使用离散均匀分布,最小值为零,最大值为字母表数组的最后一个索引。使用加密安全的随机数引擎。
进一步阅读
-
ISO 国际标准 ISO/IEC (2017) — C++编程语言(国际标准化组织;瑞士日内瓦;
isocpp.org/std/the-standard/) -
《Boost C++库》,第 2 版,博里斯·谢林著(XML Press,2014 年)
-
《C++标准库:教程与参考》,第 2 版,尼科莱·M·乔苏提斯著(Addison-Wesley Professional,2012 年)
第十六章:容器
修复std::vector中的 bug 既是喜悦(它是最棒的数据结构)又是恐惧(如果我搞砸了,世界就爆炸)。
—Stephan T. Lavavej(Visual C++库的首席开发人员)。2016 年 8 月 22 日凌晨 3:11 的推文*

标准模板库(STL)是 stdlib 的一部分,提供容器以及操作容器的算法,迭代器作为两者之间的接口。在接下来的三章中,你将进一步了解这些组件的更多信息。
容器是一种特殊的数据结构,它以有序的方式存储对象,并遵循特定的访问规则。容器有三种类型:
-
序列容器按顺序存储元素,就像数组一样。
-
关联容器存储排序后的元素。
-
无序关联容器存储哈希化对象。
关联容器和无序关联容器提供快速的单个元素查找。所有容器都是 RAII 包装器,围绕它们包含的对象,因此它们管理元素的存储期限和生命周期。此外,每个容器都提供一些成员函数,用于对对象集合执行各种操作。
现代 C++程序总是使用容器。你为特定应用选择哪个容器取决于所需的操作、包含对象的特性以及在特定访问模式下的效率。本章将概述 STL 和 Boost 之间所涵盖的广泛容器领域。由于这些库中有如此多的容器,你将重点探索其中最流行的几种。
序列容器
序列容器是 STL 容器,允许顺序访问成员。也就是说,你可以从容器的一端开始,迭代到另一端。但除了这一共同点,序列容器是一个多样化且形态各异的队伍。有些容器具有固定长度;而有些容器可以根据程序需求缩小或增大。有些容器允许直接索引进入容器,而其他容器只能顺序访问。此外,每个序列容器具有独特的性能特征,使得它在某些情况下具有优势,而在其他情况下则可能不适用。
使用序列容器应该是直观的,因为你自从在第 42 页上看到内建的或“C 风格”的数组T[]后,就已经接触过一个基本的容器。你将从更复杂、更酷的“弟弟”std::array开始探索序列容器。
数组
STL 在 <array> 头文件中提供了 std::array。array 是一个顺序容器,包含固定大小的连续元素序列。它结合了内置数组的极高性能和效率,同时支持复制/移动构造/赋值,知道自身大小,提供边界检查成员访问等现代功能。
在几乎所有情况下,你都应该使用 array 而不是内置数组。它支持与 operator[] 类似的几乎所有使用模式来访问元素,因此没有很多需要使用内置数组的情况。
注意
Boost 还在 Boost Array 的 <boost/array.hpp> 中提供了一个 boost::array。除非你使用的是非常旧的 C++ 工具链,否则不需要使用 Boost 版本。
构造
array<T, S > 类模板接受两个模板参数:
-
包含的类型 T
-
数组 S 的固定大小
你可以使用相同的规则来构造 array 和内置数组。总结《数组》章节中第 42 页的规则,推荐的方法是使用大括号初始化来构造 array。大括号初始化将数组填充为大括号内的值,并将其余元素填充为零。如果省略初始化大括号,array 将根据其存储持续时间包含未初始化的值。清单 13-1 展示了几种 array 声明的大括号初始化示例。
#include <array>
std::array<int, 10> static_array{} ➊
TEST_CASE("std::array") {
REQUIRE(static_array[0] == 0); ➋
SECTION("uninitialized without braced initializers") {
std::array<int, 10> local_array; ➌
REQUIRE(local_array[0] != 0); ➍
}
SECTION("initialized with braced initializers") {
std::array<int, 10> local_array{ 1, 1, 2, 3 }; ➎
REQUIRE(local_array[0] == 1);
REQUIRE(local_array[1] == 1);
REQUIRE(local_array[2] == 2);
REQUIRE(local_array[3] == 3);
REQUIRE(local_array[4] == 0); ➏
}
}
清单 13-1:初始化一个 std::array。你可能会收到来自 REQUIRE(local_array[0] != 0); ➍ 的编译器警告,因为 local_array 包含未初始化的元素。
你声明了一个名为 static_array 的包含 10 个 int 对象的 array,它使用静态存储持续时间 ➊。你没有使用大括号初始化,但根据《数组》章节中第 42 页的初始化规则,它的元素仍然被初始化为零 ➋。
接下来,你尝试声明另一个包含 10 个 int 对象的 array,这次使用自动存储持续时间 ➌。因为你没有使用大括号初始化,local_array 包含未初始化的元素(这些元素等于零的概率极低 ➍)。
最后,你使用大括号初始化声明另一个 array 并填充前四个元素 ➎。其余所有元素都被设置为零 ➏。
元素访问
你可以通过三种主要方法访问任意的 array 元素:
-
operator[] -
at -
get
operator[] 和 at 方法接受一个 size_t 类型的参数,表示所需元素的索引。这两者的区别在于边界检查:如果索引参数超出范围,at 会抛出一个 std::out_of_range 异常,而 operator[] 会导致未定义行为。函数模板 get 接受一个与之规格相同的模板参数。由于它是一个模板,索引必须在编译时已知。
注意
回想一下在《size_t类型》一节中,位于第 41 页的内容,size_t对象保证其最大值足以表示所有对象的最大字节大小。正因为如此,operator[]和at方法使用size_t而非int,后者并不做出此类保证。
使用get的一个重要优势是,你可以获得编译时的边界检查,正如列表 13-2 所示。
TEST_CASE("std::array access") {
std::array<int, 4> fib{ 1, 1, 0, 3}; ➊
SECTION("operator[] can get and set elements") {
fib[2] = 2; ➋
REQUIRE(fib[2] == 2); ➌
// fib[4] = 5; ➍
}
SECTION("at() can get and set elements") {
fib.at(2) = 2; ➎
REQUIRE(fib.at(2) == 2); ➏
REQUIRE_THROWS_AS(fib.at(4), std::out_of_range); ➐
}
SECTION("get can get and set elements") {
std::get<2>(fib) = 2; ➑
REQUIRE(std::get<2>(fib) == 2); ➒
// std::get<4>(fib); ➓
}
}
列表 13-2:访问array元素。取消注释// fib[4] = 5; ➍ 将导致未定义行为,而取消注释// std::get<4>(fib); ➓ 将导致编译失败。
你声明了一个长度为 4 的数组fib ➊。使用operator[] ➋你可以设置元素并检索它们 ➌。你注释掉的越界写入将导致未定义行为;operator[]没有边界检查 ➍。
你可以使用at进行相同的读取 ➎ 和写入 ➏ 操作,并且可以安全地执行越界操作,因为有边界检查 ➐。
最后,你可以使用std::get来设置 ➑ 和获取 ➒ 元素。get元素还会进行边界检查,因此如果取消注释,// std::get<4>(fib); ➓ 将无法编译。
你还有front和back方法,它们分别返回数组的第一个和最后一个元素的引用。如果数组长度为零,调用这些方法将导致未定义行为,正如列表 13-3 所示。
TEST_CASE("std::array has convenience methods") {
std::array<int, 4> fib{ 0, 1, 2, 0 };
SECTION("front") {
fib.front() = 1; ➊
REQUIRE(fib.front() == 1); ➋
REQUIRE(fib.front() == fib[0]); ➌
}
SECTION("back") {
fib.back() = 3; ➍
REQUIRE(fib.back() == 3); ➎
REQUIRE(fib.back() == fib[3]); ➏
}
}
列表 13-3:在std::array上使用便捷方法front和back
你可以使用front和back方法来设置 ➊➍ 和获取 ➋➎ array的第一个和最后一个元素。当然,fib[0]与fib.front() ➌ 完全相同,fib[3]与fib.back() ➏ 完全相同。front()和back()方法只是便捷方法。此外,如果你在编写通用代码时,某些容器可能提供front和back,但不提供operator[],因此最好使用front和back方法。
存储模型
array不进行内存分配;相反,它像内建数组一样,包含其所有元素。这意味着复制通常会很昂贵,因为每个组成元素都需要复制。移动可能会很昂贵,具体取决于array的底层类型是否也支持移动构造和移动赋值,而这些操作相对便宜。
每个array底层其实就是一个内建数组。实际上,你可以通过四种不同的方法提取指向array第一个元素的指针:
-
常用的方法是使用
data方法。正如其宣传所说,它返回指向第一个元素的指针。 -
其他三种方法涉及使用取地址操作符
&在第一个元素上,这些元素可以通过operator[]、at和front获得。
你应该使用data。如果array为空,基于取地址操作的方法将返回未定义行为。
列表 13-4 展示了如何通过这四种方法获得指针。
TEST_CASE("We can obtain a pointer to the first element using") {
std::array<char, 9> color{ 'o', 'c', 't', 'a', 'r', 'i', 'n', 'e' };
const auto* color_ptr = color.data(); ➊
SECTION("data") {
REQUIRE(*color_ptr == 'o'); ➋
}
SECTION("address-of front") {
REQUIRE(&color.front() == color_ptr); ➌
}
SECTION("address-of at(0)") {
REQUIRE(&color.at(0) == color_ptr); ➍
}
SECTION("address-of [0]") {
REQUIRE(&color[0] == color_ptr); ➎
}
}
代码清单 13-4:获取std::array第一个元素的指针
初始化array color后,你可以通过data方法 ➊ 获取指向第一个元素的指针,即字母o。当你解引用得到的color_ptr时,你会如预期得到字母o ➋。这个指针与通过address-of-加front ➌、at ➍ 和operator[] ➎ 方法获得的指针是相同的。
总结数组时,你可以使用size或max_size方法查询array的大小。(这两个方法对于array来说是相同的。)因为array的大小是固定的,这些方法的值在编译时就已经确定。
迭代器速成课程
容器与算法之间的接口就是迭代器。迭代器是一种知道容器内部结构的类型,并向容器元素暴露类似指针的简单操作。第十四章专门讲解迭代器,但你在这里需要了解一些基本知识,以便你能够探索如何使用迭代器操作容器,以及容器如何向用户暴露迭代器。
迭代器有不同的种类,但它们都至少支持以下操作:
-
获取当前元素(
operator*) -
转到下一个元素(
operator++) -
将一个迭代器赋值给另一个迭代器(
operator=)
你可以通过所有 STL 容器(包括array)的begin和end方法提取迭代器。begin方法返回一个指向第一个元素的迭代器,而end方法返回指向最后一个元素之后的元素的指针。图 13-1 展示了begin和end迭代器在一个包含三个元素的数组中的指向位置。

图 13-1:一个包含三个元素的array的半开区间
在图 13-1 中的排列,其中end()指向最后一个元素之后的位置,称为半开区间。这可能一开始看起来不太直观——为什么不使用闭区间,让end()指向最后一个元素——但半开区间有其优势。例如,如果一个容器为空,begin()会返回与end()相同的值。这让你能够知道,无论容器是否为空,只要迭代器等于end(),就表示你已经遍历了容器。
代码清单 13-5 展示了半开区间迭代器和空容器的行为。
TEST_CASE("std::array begin/end form a half-open range") {
std::array<int, 0> e{}; ➊
REQUIRE(e.begin()➋ == e.end()➌);
}
代码清单 13-5:对于一个空的array,begin迭代器等于end迭代器。
在这里,你构造了一个空数组e ➊,并且begin ➋ 和end ➌ 迭代器是相等的。
代码清单 13-6 展示了如何使用迭代器在一个非空的array上执行类似指针的操作。
TEST_CASE("std::array iterators are pointer-like") {
std::array<int, 3> easy_as{ 1, 2, 3 }; ➊
auto iter = easy_as.begin(); ➋
REQUIRE(*iter == 1); ➌
++iter; ➍
REQUIRE(*iter == 2);
++iter;
REQUIRE(*iter == 3); ➎
++iter; ➏
REQUIRE(iter == easy_as.end()); ➐
}
代码清单 13-6:基本的array迭代器操作
array easy_as 包含元素 1、2 和 3 ➊。你在 easy_as 上调用 begin 来获取指向第一个元素的迭代器 iter ➋。解引用操作符返回第一个元素 1,因为这是 array 中的第一个元素 ➌。接下来,你递增 iter,使其指向下一个元素 ➍。你继续以这种方式进行,直到到达最后一个元素 ➎。最后一次递增指针会让你超出最后一个元素 ➏,因此 iter 等于 easy_as.end(),表示你已经遍历了整个 array ➐。
回忆一下在《范围表达式》章节中提到的内容(见 第 235 页),你可以通过暴露 begin 和 end 方法来构建自定义类型以用于范围表达式,就像在列表 8-29 中的 FibonacciIterator 一样。实际上,容器已经为你做了所有这些工作,这意味着你可以将任何 STL 容器作为范围表达式使用。列表 13-7 通过遍历一个 array 来展示这一点。
TEST_CASE("std::array can be used as a range expression") {
std::array<int, 5> fib{ 1, 1, 2, 3, 5 }; ➊
int sum{}; ➋
for (const auto element : fib) ➌
sum += element; ➍
REQUIRE(sum == 12);
}
列表 13-7:基于范围的 for 循环和 arrays
你初始化了一个 array ➊ 和一个 sum 变量 ➋。因为 array 是一个有效的范围,你可以在基于范围的 for 循环中使用它 ➌。这使你能够累加每个 element 的 sum ➍。
支持的部分操作列表
表 13-1 提供了部分array操作的列表。在此表中,a、a1和a2的类型为std::array<T, S>,t的类型为T,S是数组的固定长度,i的类型为size_t。
表 13-1: std::array 操作的部分列表
| 操作 | 说明 |
|---|---|
array<T, S>{ ... } |
执行新构建数组的花括号初始化。 |
~array |
析构数组包含的所有元素。 |
a1 = a2 |
将 a2 的所有成员复制赋值给 a1 的成员。 |
a.at(i) |
返回 a 的第 i 个元素的引用。如果越界,则抛出std::out_of_range。 |
a[i] |
返回 a 的第 i 个元素的引用。如果越界,行为未定义。 |
get<i>``(a) |
返回 a 的第 i 个元素的引用。如果越界,编译失败。 |
a.front() |
返回对第一个元素的引用。 |
a.back() |
返回对最后一个元素的引用。 |
a.data() |
返回指向第一个元素的原始指针,如果数组非空。对于空数组,返回一个有效但不可解引用的指针。 |
a.empty() |
如果数组的大小为零,则返回true;否则返回false。 |
a.size() |
返回数组的大小。 |
a.max_size() |
与 a.size() 相同。 |
a.fill(t) |
将 t 复制赋值给 a 的每个元素。 |
a1.swap(a2)``swap(a1, a2) |
交换 a1 和 a2 中的每个元素。 |
a.begin() |
返回指向第一个元素的迭代器。 |
a.cbegin() |
返回指向第一个元素的const迭代器。 |
a.end() |
返回指向最后一个元素后一个位置的迭代器。 |
a.cend() |
返回指向最后一个元素之后的 const 迭代器。 |
a1 == a2a1 != a2a1 > a2a1 >= a2a1 < a2a1 <= a2 |
如果所有元素相等,则相等。大于/小于比较从第一个元素到最后一个元素进行。 |
注意
Table 13-1 中的部分操作可以作为快速且合理全面的参考。有关详细信息,请参考免费在线文献 cppreference.com/ 和 cplusplus.com/,以及 Bjarne Stroustrup 的《C++ 程序设计语言》第 4 版的第三十一章,以及 Nicolai M. Josuttis 的《C++ 标准库》第二版中的第七章](ch07.xhtml#ch07),8 和 12 。
Vectors
在 STL 的 <vector> 头文件中,std::vector 是一个顺序容器,存储着一个动态大小的、连续的元素序列。vector 动态管理其存储,不需要程序员的外部帮助。
vector 是顺序数据结构中的工作马。仅有少量开销,你就能获得比 array 更多的灵活性。而且,vector 支持几乎与 array 相同的所有操作,并且增加了许多其他功能。如果你手头有固定数量的元素,你应该强烈考虑使用 array,因为它相对于 vector 会有一些小的开销减少。在所有其他情况下,你的首选顺序容器是 vector。
注意
Boost 容器库还包含了一个位于 <boost/container/vector.hpp> 头文件中的 boost::container::vector。
构造
类模板 std::vector<T, Allocator> 接受两个模板参数。第一个是元素类型 T,第二个是分配器类型 Allocator,这是可选的,默认值为 std::allocator<T>。
相比数组,你在构造 vector 时具有更大的灵活性。vector 支持用户定义的分配器,因为 vector 需要动态分配内存。你可以默认构造一个不包含任何元素的 vector。你可能想构造一个空的 vector,以便根据运行时的情况填充一个可变数量的元素。Listing 13-8 展示了默认构造一个 vector 并检查它是否包含元素。
#include <vector>
TEST_CASE("std::vector supports default construction") {
std::vector<const char*➊> vec; ➋
REQUIRE(vec.empty()); ➌
}
Listing 13-8:vector 支持默认构造。
你声明了一个包含 const char* 类型元素的 vector ➊,名为 vec。由于它是默认构造的 ➋,因此 vector 不包含任何元素,empty 方法返回 true ➌。
你可以使用花括号初始化来初始化 vector。类似于如何用花括号初始化数组,这种方式会用指定的元素填充 vector,如 Listing 13-9 所示。
TEST_CASE("std::vector supports braced initialization ") {
std::vector<int> fib{ 1, 1, 2, 3, 5 }; ➊
REQUIRE(fib[4] == 5); ➋
}
Listing 13-9:vector 支持花括号初始化。
这里,你构造了一个名为fib的vector并使用大括号初始化器 ➊。初始化后,vector包含五个元素 1、1、2、3 和 5 ➋。
如果你想用许多相同的值来填充一个vector,你可以使用其中一个填充构造函数。要进行填充构造vector,你首先传入一个size_t值,表示你要填充的元素数量。你还可以选择传入一个const引用对象,以便进行复制。有时,你可能希望将所有元素初始化为相同的值,例如跟踪与特定索引相关的计数。你可能还有一个用于跟踪程序状态的某个用户定义类型的vector,你可能需要通过索引来追踪这些状态。
不幸的是,使用大括号初始化构造对象的通用规则在这里失效了。对于vector,你必须使用圆括号来调用这些构造函数。对于编译器来说,std::vector<int>{ 99, 100 }指定了一个包含 99 和 100 两个元素的初始化列表,这将构造一个包含 99 和 100 两个元素的vector。如果你想要一个包含 99 个 100 的副本的vector,该怎么办呢?
通常,编译器会尽力将初始化列表视为用于填充vector的元素。你可以尝试记住这些规则(参考 Scott Meyers 的《Effective Modern C++》第 7 条)或者干脆决定在使用标准库容器构造函数时总是使用圆括号。
清单 13-10 展示了 STL 容器的一般初始化列表/大括号初始化规则。
TEST_CASE("std::vector supports") {
SECTION("braced initialization") {
std::vector<int> five_nine{ 5, 9 }; ➊
REQUIRE(five_nine[0] == 5); ➋
REQUIRE(five_nine[1] == 9); ➌
}
SECTION("fill constructor") {
std::vector<int> five_nines(5, 9); ➍
REQUIRE(five_nines[0] == 9); ➎
REQUIRE(five_nines[4] == 9); ➏
}
}
清单 13-10:一个vector支持大括号初始化器和填充构造函数。
第一个示例使用大括号初始化构造了一个包含两个元素的vector ➊:索引 0 处的 5 ➋ 和索引 1 处的 9 ➌。第二个示例使用圆括号调用填充构造函数 ➍,该构造函数将vector填充为五个 9 的副本,因此第一个 ➎ 和最后一个 ➏ 元素都是 9。
注意
这种符号冲突是不幸的,并非经过深思熟虑的权衡结果。其原因纯粹是历史原因,并与向后兼容性相关。
你还可以通过传入目标范围的begin和end迭代器来从半开区间构造vector。在各种编程上下文中,你可能希望从某个范围中提取出一部分子集并将其复制到vector中以进行进一步处理。例如,你可以构造一个vector,复制一个array中包含的所有元素,就像在清单 13-11 中展示的那样。
TEST_CASE("std::vector supports construction from iterators") {
std::array<int, 5> fib_arr{ 1, 1, 2, 3, 5 }; ➊
std::vector<int> fib_vec(fib_arr.begin(), fib_arr.end()); ➋
REQUIRE(fib_vec[4] == 5); ➌
REQUIRE(fib_vec.size() == fib_arr.size()); ➍
}
清单 13-11:从范围构造一个vector
你使用五个元素构造了数组fib_arr ➊。要使用fib_arr中的元素构造fib_vec,你需要调用fib_arr的begin和end方法 ➋。结果是,构造的vector包含了array的元素副本 ➌,并且具有相同的size ➍。
从高层次来看,你可以把这个构造函数理解为接受指向某个目标序列的开始和结束的指针。它将会复制这个目标序列。
移动和复制语义
使用vector时,你可以完全支持复制/移动构造和赋值。任何vector的复制操作可能非常昂贵,因为这些是逐元素的或深度复制。而移动操作通常非常快速,因为包含的元素位于动态内存中,移动前的vector可以简单地将所有权转移到移动后的vector;不需要移动包含的元素。
元素访问
vector支持与array相同的大多数元素访问操作:at、operator[]、front、back和data。
与array一样,你可以使用size方法查询vector中包含的元素数量。该方法的返回值可能在运行时发生变化。你还可以使用empty方法来确定vector是否包含任何元素,如果vector不包含元素,它返回true;否则返回false。
添加元素
你可以使用各种方法向vector中插入元素。如果你想替换vector中的所有元素,可以使用assign方法,该方法接受一个初始化列表并替换所有现有元素。如果需要,vector将调整大小以容纳更多的元素,如示例 13-12 所示。
TEST_CASE("std::vector assign replaces existing elements") {
std::vector<int> message{ 13, 80, 110, 114, 102, 110, 101 }; ➊
REQUIRE(message.size() == 7); ➋
message.assign({ 67, 97, 101, 115, 97, 114 }); ➌
REQUIRE(message[5] == 114); ➍
REQUIRE(message.size() == 6); ➎
}
示例 13-12:vector的assign方法
在这里,你构造了一个包含七个元素的vector ➊。当你赋值一个新的、更小的初始化列表 ➌时,所有元素都会被替换 ➍,并且vector的size会更新,以反映新的内容 ➎。
如果你想向vector中插入一个单一的新元素,可以使用insert方法,该方法需要两个参数:一个迭代器和一个要插入的元素。它会在迭代器指向的现有元素之前插入给定元素的副本,如示例 13-13 所示。
TEST_CASE("std::vector insert places new elements") {
std::vector<int> zeros(3, 0); ➊
auto third_element = zeros.begin() + 2; ➋
zeros.insert(third_element, 10); ➌
REQUIRE(zeros[2] == 10); ➍
REQUIRE(zeros.size() == 4); ➎
}
示例 13-13:vector的insert方法
你用三个零初始化了一个vector ➊,并生成了一个指向zeros第三个元素的迭代器 ➋。接下来,你通过传递迭代器和值 10 来将值 10 插入到第三个元素之前 ➌。现在,zeros的第三个元素是 10 ➍。zeros向量现在包含四个元素 ➎。
每次使用insert时,现有的迭代器都会变得无效。例如,在示例 13-13 中,你不能重新使用third_element:vector可能已经重新调整大小并在内存中重新定位,导致旧的迭代器悬挂在垃圾内存中。
要将一个元素插入到vector的末尾,可以使用push_back方法。与insert不同,push_back不需要迭代器作为参数。只需提供要复制到vector中的元素,如示例 13-14 所示。
TEST_CASE("std::vector push_back places new elements") {
std::vector<int> zeros(3, 0); ➊
zeros.push_back(10); ➋
REQUIRE(zeros[3] == 10); ➌
}
示例 13-14:vector的push_back方法
再次地,你初始化了一个包含三个零的vector ➊,但这次你使用push_back方法将元素 10 插入到vector的末尾 ➋。vector现在包含四个元素,最后一个元素是 10 ➌。
你可以使用emplace和emplace_back方法在原地构造新元素。emplace方法是一个变参模板,像insert一样,它将一个迭代器作为第一个参数。其余的参数将被转发到适当的构造函数。emplace_back方法也是一个变参模板,但像push_back一样,它不需要迭代器。它接受任意数量的参数,并将这些参数转发到适当的构造函数。列表 13-15 通过将一些pair添加到vector中来展示这两种方法。
#include <utility>
TEST_CASE("std::vector emplace methods forward arguments") {
std::vector<std::pair<int, int>> factors; ➊
factors.emplace_back(2, 30); ➋
factors.emplace_back(3, 20); ➌
factors.emplace_back(4, 15); ➍
factors.emplace(factors.begin()➎, 1, 60);
REQUIRE(factors[0].first == 1); ➏
REQUIRE(factors[0].second == 60); ➐
}
列表 13-15:vector的emplace_back和emplace方法
在这里,你默认构造了一个包含int类型pair的vector ➊。使用emplace_back方法,你将三个pair推入vector中:2,30 ➋;3,20 ➌;以及 4,15 ➍。这些值直接传递给pair的构造函数,从而在原地构造了pair。接着,你使用emplace方法通过传递factors.begin()的结果作为第一个参数,向vector的开头插入一个新的pair ➎。这会导致vector中的所有元素向下移动,为新的pair腾出空间(1 ➏,60 ➐)。
注意
std::vector<std::pair<int, int>>其实没有什么特别的。它和其他的vector一样。这个顺序容器中的每个元素恰好是一个pair。由于pair有一个接受两个参数的构造函数,一个用于first,一个用于second,emplace_back可以通过直接传递这两个值来将一个新元素添加到pair中。
由于emplace方法可以原地构造元素,因此它们似乎应该比插入方法更高效。这种直觉通常是正确的,但由于复杂且令人不满意的原因,它并不总是更快。一般来说,使用emplace方法。如果你发现性能瓶颈,也可以尝试插入方法。有关详细讨论,请参阅 Scott Meyers 的《Effective Modern C++》第 42 条。
存储模型
尽管vector的元素在内存中是连续的,像array一样,但相似之处仅此而已。vector的大小是动态的,因此它必须能够调整大小。vector的分配器管理着支撑vector的动态内存。
由于内存分配开销较大,vector会请求比实际需要的元素数量更多的内存空间。一旦它无法再添加更多元素,它会请求额外的内存。vector的内存总是连续的,因此如果现有vector的末尾没有足够的空间,它会分配一个全新的内存区域,并将所有元素移动到新区域中。vector所包含的元素数量称为它的大小,而它在不需要重新调整大小之前理论上能容纳的元素数量称为它的容量。图 13-2 展示了一个包含三元素的vector,并且额外有三元素的容量。

图 13-2:vector存储模型
如图 13-2 所示,vector在最后一个元素之后继续存在。容量决定了vector在这块空间中能容纳多少元素。在此图中,大小是三,容量是六。你可以把vector中的内存想象成一个礼堂:它可能有 500 的容量,但观众人数只有 250。
这种设计的结果是,向vector末尾插入元素非常快速(除非vector需要重新调整大小)。在其他位置插入则会增加额外的开销,因为vector需要移动元素以腾出空间。
你可以通过capacity方法获取vector当前的容量,也可以通过max_size方法获取vector理论上能扩展到的最大容量。
如果你提前知道自己需要某个容量,可以使用reserve方法,它接受一个size_t类型的参数,表示你希望为多少个元素预留空间。另一方面,如果你刚刚删除了几个元素,并希望将内存归还给分配器,你可以使用shrink_to_fit方法,表示你有多余的容量。分配器可以决定是否减少容量(这是一个非强制性的调用)。
此外,你可以使用clear方法删除vector中的所有元素,并将其大小设置为零。
代码清单 13-16 展示了所有这些与存储相关的方法,呈现了一个连贯的故事:你创建一个空的vector,预留一大块空间,添加一些元素,释放多余的容量,最后清空vector。
#include <cstdint>
#include <array>
TEST_CASE("std::vector exposes size management methods") {
std::vector<std::array<uint8_t, 1024>> kb_store; ➊
REQUIRE(kb_store.max_size() > 0);
REQUIRE(kb_store.empty()); ➋
size_t elements{ 1024 };
kb_store.reserve(elements); ➌
REQUIRE(kb_store.empty());
REQUIRE(kb_store.capacity() == elements); ➍
kb_store.emplace_back();
kb_store.emplace_back();
kb_store.emplace_back();
REQUIRE(kb_store.size() == 3); ➎
kb_store.shrink_to_fit();
REQUIRE(kb_store.capacity() >= 3); ➏
kb_store.clear(); ➐
REQUIRE(kb_store.empty());
REQUIRE(kb_store.capacity() >= 3); ➑
}
代码清单 13-16:vector的存储管理功能。(严格来说,kb_store.capacity() >= 3 ➏ ➑不是保证的,因为这个调用是非强制性的。)
你构建了一个名为kb_store的vector数组对象,用于存储 1 KiB 的块➊。除非你使用的是没有动态内存的特殊平台,否则kb_store.max_size()的值会大于零;因为你对vector进行了默认初始化,它是空的➋。
接下来,你为 1,024 个元素保留空间 ➌,这并不会改变 vector 的空状态,但它会增加容量以匹配 ➍。此时,vector 已预留了 1,024 × 1 KiB = 1 MiB 的连续空间。保留空间后,你插入了三个数组,并检查kb_store.size()是否按预期增加 ➎。
你已经为 1,024 个元素保留了空间。为了将 1,024 - 3 = 1,021 个未使用的元素释放回分配器,你调用了shrink_to_fit,它将容量减少为 3 ➏。
最后,你在vector ➐上调用了clear,它销毁了所有元素并将其大小减少为零。然而,容量保持不变,因为你没有再次调用shrink_to_fit ➑。这很重要,因为如果你以后再添加元素,vector 不希望做额外的工作。
支持操作的部分列表
表 13-2 提供了vector操作的部分列表。在此表中,v、v1和v2是std::vector<T>类型,t是T类型,alc是合适的分配器,itr是迭代器。星号(*)表示在某些情况下,该操作会使指向v元素的原始指针和迭代器失效。
表 13-2: std::vector操作的部分列表
| 操作 | 备注 |
|---|---|
vector<T>{ ..., [alc]} |
执行新构造的 vector 的花括号初始化。默认使用 alc=std::allocator<T>。 |
vector<T>(s,[t], [alc]) |
用 t 的 s 个副本填充新构造的 vector。如果没有提供 t,则默认构造 T 的实例。 |
vector<T>(v) |
对 v 进行深度复制;分配新内存。 |
vector<T>(move(v)) |
获取 v 中元素的内存所有权,不会重新分配内存。 |
~vector |
销毁 vector 包含的所有元素并释放动态内存。 |
v.begin() |
返回指向第一个元素的迭代器。 |
v.cbegin() |
返回指向第一个元素的const迭代器。 |
v.end() |
返回指向最后一个元素之后位置的迭代器。 |
v.cend() |
返回指向最后一个元素之后位置的const迭代器。 |
v1 = v2 |
v1 销毁其元素;复制每个 v2 元素。只有在需要调整大小以适应 v2 的元素时才会分配内存。* |
v1 = move(v2) |
v1 销毁其元素;移动每个 v2 元素。只有在需要调整大小以适应 v2 的元素时才会分配内存。* |
v.at(0) |
访问 v 的第 0 个元素。如果越界,抛出std::out_of_range异常。 |
v[0] |
访问 v 的第 0 个元素。如果越界,行为未定义。 |
v.front() |
访问第一个元素。 |
v.back() |
访问最后一个元素。 |
v.data() |
返回指向第一个元素的原始指针(如果数组非空)。对于空数组,返回一个有效但不可解引用的指针。 |
v.assign({ ... }) |
用元素替换 v 的内容 ....* |
v.assign(s, t) |
用 s 个 t 的副本替换 v 的内容。* |
v.empty() |
如果 vector 的大小为零,则返回 true;否则返回 false。 |
v.size() |
返回 vector 中元素的数量。 |
v.capacity() |
返回 vector 可以容纳的最大元素数量,而无需调整大小。 |
v.shrink_to_fit() |
可能会减少 vector 的存储,使 capacity() 等于 size()。* |
v.resize(s, [t]) |
调整 v 的大小为 s 个元素。如果缩小 v,会销毁末尾的元素。如果扩展 v,则插入默认构造的 T 元素,或者如果提供了 t,则插入 t 的副本。* |
v.reserve(s) |
增加 vector 的存储,以便它至少能够容纳 s 个元素。* |
v.max_size() |
返回 vector 可以扩展到的最大可能大小。 |
v.clear() |
删除 v 中的所有元素,但容量保持不变。* |
v.insert(itr, t) |
在由 itr 指向的元素之前插入 t 的副本;v 的范围必须包含 itr。* |
v.push_back(t) |
在 v 的末尾插入 t 的副本。* |
v.emplace(itr, ...) |
通过将参数 ... 转发给适当的构造函数,在 itr 指向的元素之前就地构造一个 T 元素。* |
v.emplace_back(...) |
通过将参数 ... 转发给适当的构造函数,在 v 的末尾就地构造一个 T 元素。* |
v1.swap(v2)``swap(v1, v2) |
交换 v1 和 v2 的每个元素。* |
v1 == v2v1 != v2v1 > v2v1 >= v2v1 < v2v1 <= v2 |
如果所有元素相等,则为相等。大于/小于的比较从第一个元素到最后一个元素进行。 |
小众顺序容器
在大多数需要顺序数据结构的情况下,vector 和 array 容器是首选。如果你事先知道所需的元素数量,使用 array。如果不知道,使用 vector。
你可能会遇到一个特殊的情况,在这种情况下,vector 和 array 无法提供你所需的性能特性。本节重点介绍了一些可能在这种情况下提供更高性能特性的替代顺序容器。
双端队列
deque(发音为“deck”)是一个顺序容器,具有快速的插入和删除操作,支持从前端和后端进行操作。Deque 是 double-ended queue 的合成词。STL 实现的 std::deque 可通过 <deque> 头文件使用。
注意
Boost 容器库还包含了一个 boost::container::deque,定义在 <boost/container/deque.hpp> 头文件中。
vector 和 deque 有非常相似的接口,但它们的内部存储模型完全不同。vector 保证所有元素在内存中是连续的,而 deque 的内存通常是分散的,类似于 vector 和 list 的混合体。这使得大规模调整大小操作更加高效,并且支持在容器的前端快速插入/删除元素。
构造和访问成员对 vector 和 deque 来说是相同的操作。
由于 deque 的内部结构复杂,它没有暴露 data 方法。作为交换,你可以访问 push_front 和 emplace_front,它们与 vector 中你熟悉的 push_back 和 emplace_back 相对应。Listing 13-17 展示了如何使用 push_back 和 push_front 向 deque 中插入 char 类型的值。
#include <deque>
TEST_CASE("std::deque supports front insertion") {
std::deque<char> deckard;
deckard.push_front('a'); ➊ // a
deckard.push_back('i'); ➋ // ai
deckard.push_front('c'); // cai
deckard.push_back('n'); // cain
REQUIRE(deckard[0] == 'c'); ➌
REQUIRE(deckard[1] == 'a');
REQUIRE(deckard[2] == 'i');
REQUIRE(deckard[3] == 'n');
}
Listing 13-17:deque 支持 push_front 和 push_back。
在构造一个空的 deque 后,你将交替的字母推送到 deque 的前端 ➊ 和后端 ➋,使其包含元素 c、a、i 和 n ➌。
注意
例如,尝试提取一个字符串,如 &deckard[0],将是一个非常糟糕的主意,因为 deque 对内部布局没有任何保证。
deque 没有实现的 vector 方法及其缺失的解释如下:
capacity, reserve 由于内部结构复杂,计算容量可能效率不高。而且,deque 的分配相对较快,因为 deque 不会重新定位现有元素,因此不需要提前预留内存。
data deque 的元素不是连续存储的。
表 13-3 总结了 deque 提供的额外运算符,而 vector 没有。在该表中,d 的类型是 std::deque<T>,t 的类型是 T。星号(*)表示在某些情况下,此操作会使迭代器失效,指向 v 元素的迭代器失效。(指向现有元素的指针保持有效。)
表 13-3: std::deque 操作的部分列表
| 操作 | 备注 |
|---|---|
d.emplace_front(...) |
通过将所有参数转发给适当的构造函数,在 d 的前端原地构造一个元素。* |
d.push_front(t) |
通过复制 t 在 d 的前端原地构造一个元素。* |
d.pop_front() |
移除 d 的前端元素。* |
List
list 是一种序列容器,具有快速的插入/删除操作,但不支持随机访问元素。STL 实现的 std::list 可以通过 <list> 头文件使用。
注意
Boost 容器库还包含了 <boost/container/list.hpp> 头文件中的 boost::container::list。
list 实现为双向链表,这是一种由 节点 组成的数据结构。每个节点包含一个元素、一个前向链接(“flink”)和一个后向链接(“blink”)。这与 vector 完全不同,后者将元素存储在连续的内存中。因此,你不能使用 operator[] 或 at 来访问 list 中的任意元素,因为这些操作效率非常低。(这些方法在 list 中根本不可用,因为它们的性能表现非常差。)其权衡是,在 list 中插入和移除元素的速度要快得多。你只需要更新元素邻居的 flinks 和 blinks,而不需要移动可能很大的连续元素范围。
list 容器支持与 vector 相同的构造函数模式。 |
你可以对列表执行特殊操作,例如使用 splice 方法将元素从一个列表拼接到另一个列表,使用 unique 方法移除连续的重复元素,甚至使用 sort 方法对容器中的元素进行排序。例如,考虑 remove_if 方法。remove_if 方法接受一个函数对象作为参数,并在遍历 list 时对每个元素调用该函数对象。如果返回 true,remove_if 就会移除该元素。Listing 13-18 说明了如何使用 remove_if 方法通过 lambda 谓词删除 list 中的所有偶数。
#include <list>
TEST_CASE("std::list supports front insertion") {
std::list<int> odds{ 11, 22, 33, 44, 55 }; ➊
odds.remove_if([](int x) { return x % 2 == 0; }); ➋
auto odds_iter = odds.begin(); ➌
REQUIRE(*odds_iter == 11); ➍
++odds_iter; ➎
REQUIRE(*odds_iter == 33);
++odds_iter;
REQUIRE(*odds_iter == 55);
++odds_iter;
REQUIRE(odds_iter == odds.end()); ➏
}
Listing 13-18:list 支持 remove_if。
在此,你使用大括号初始化填充 int 类型对象的 list ➊。接下来,你使用 remove_if 方法移除所有偶数 ➋。因为只有偶数对 2 取余为零,所以这个 lambda 表达式用来测试一个数字是否是偶数。为了验证 remove_if 已经移除偶数元素 22 和 44,你创建一个指向列表开头的迭代器 ➌,检查其值 ➍,并递增 ➎,直到达到列表末尾 ➏。
所有 vector 方法在 list 中没有实现,以及它们未实现的解释如下: |
capacity, reserve, shrink_to_fit 由于 list 是增量地分配内存,因此不需要定期调整大小。 |
operator[], at 在 list 上随机访问元素代价昂贵。 |
data 不需要,因为 list 元素不是连续存储的。 |
表 13-4 总结了 list 提供但 vector 不提供的额外操作符。在此表中,lst、lst1 和 lst2 是 std::list<T> 类型,t 是 T 类型。itr1、itr2a 和 itr2b 是 list 迭代器。星号 (*) 表示在某些情况下,该操作会使指向 v 元素的迭代器无效。(指向现有元素的指针仍然有效。) |
表 13-4: std::list 操作的部分列表 |
| 操作 | 备注 |
|---|---|
lst.emplace_front(...) |
通过将所有参数转发给相应的构造函数,在 d 的前端构造一个元素。 |
lst.push_front(t) |
通过复制 t 在 d 的前端构造一个元素。 |
lst.pop_front() |
移除 d 中位于前端的元素。 |
lst.push_back(t) |
通过复制 t 在 d 的末尾构造一个元素。 |
lst.pop_back() |
移除 d 中位于末尾的元素。 |
lst1.splice(itr1,lst2, [itr2a], [itr2b]) |
将 lst2 中的元素转移到 lst1 中的 itr1 位置。可选地,只转移 itr2a 处的元素或从 itr2a 到 itr2b 半开区间内的元素。 |
lst.remove(t) |
移除 lst 中所有等于 t 的元素。 |
lst.remove_if(pred) |
删除 lst 中符合 pred 条件的元素;pred 接受一个类型为 T 的单一参数。 |
lst.unique(pred) |
根据函数对象 pred 消除 lst 中相邻重复的元素,pred 接受两个 T 类型参数并返回 t1 == t2。 |
lst1.merge(lst2, comp) |
根据函数对象 comp 将 lst1 和 lst2 合并,comp 接受两个 T 类型参数并返回 t1 < t2。 |
lst.sort(comp) |
根据函数对象 comp 对 lst 进行排序。 |
lst.reverse() |
反转 lst 中元素的顺序(会改变 lst)。 |
注意
STL 还在<forward_list>头文件中提供了std::forward_list,它是一个单向链表,只允许朝一个方向遍历。forward_list比list稍微高效,且在需要存储极少量(或没有)元素的情况下进行了优化。
栈
STL 提供了三种容器适配器,它们封装了其他 STL 容器,并为特定情况暴露了特殊接口。这些适配器分别是栈(stack)、队列(queue)和优先队列(priority queue)。
栈(stack)是一种具有两种基本操作的数据结构:压栈(push)和弹栈(pop)。当你将一个元素压入栈中时,你将该元素插入到栈的末端。当你从栈中弹出一个元素时,你将元素从栈的末端移除。这个排列方式叫做后进先出(last-in, first-out):最后被压入栈的元素是第一个被弹出的元素。
STL 在<stack>头文件中提供了std::stack。类模板stack有两个模板参数,第一个是被封装容器的底层类型,例如int,第二个是被封装容器的类型,例如deque或vector。第二个参数是可选的,默认值为deque。
要构造一个stack,你可以传递一个deque、vector或list的引用来封装。这样,stack会将其操作,如push和pop,转换为底层容器能够理解的方法,比如push_back和pop_back。如果没有提供构造函数参数,stack默认使用deque。第二个模板参数必须与此容器的类型匹配。
要获取stack顶部元素的引用,可以使用top方法。
清单 13-19 演示了如何使用stack来封装vector。
#include <stack>
TEST_CASE("std::stack supports push/pop/top operations") {
std::vector<int> vec{ 1, 3 }; ➊ // 1 3
std::stack<int, decltype(vec)> easy_as(vec); ➋
REQUIRE(easy_as.top() == 3); ➌
easy_as.pop(); ➍ // 1
easy_as.push(2); ➎ // 1 2
REQUIRE(easy_as.top() == 2); ➏
easy_as.pop(); // 1
REQUIRE(easy_as.top() == 1);
easy_as.pop(); //
REQUIRE(easy_as.empty()); ➐
}
清单 13-19:使用stack封装vector
你构造一个名为vec的int类型的vector,其中包含元素 1 和 3 ➊。接着,你将vec传入新stack的构造函数,并确保提供第二个模板参数decltype(vec) ➋。stack中的顶部元素现在是 3,因为这是vec中的最后一个元素 ➌。在第一次pop之后 ➍,你将新元素 2 压入stack ➎。此时,top元素是 2 ➏。经过另一次pop-top-pop的操作后,stack为空 ➐。
表格 13-5 总结了stack的操作。在此表中,s、s1和s2的类型为std::stack<T>;t的类型为T;ctr是类型为ctr_type<T>的容器。
表 13-5: std::stack操作概述
| 操作 | 备注 |
|---|---|
stack<T, [ctr_type]>([ctr]) |
使用 ctr 作为内部容器引用构造 T 类型的栈。如果没有提供容器,则构造一个空的 deque。 |
s.empty() |
如果容器为空,返回true。 |
s.size() |
返回容器中元素的数量。 |
s.top() |
返回stack顶部元素的引用。 |
s.push(t) |
将 t 的副本放入容器末尾。 |
s.emplace(...) |
通过转发...到适当的构造函数,在原地构造一个 T。 |
s.pop() |
移除容器末尾的元素。 |
s1.swap(s2)``swap(s1,s2) |
交换 s1 和 s2 的内容。 |
队列
队列是一种数据结构,像栈一样,它的基本操作是推入(push)和弹出(pop)。与栈不同,队列是先进先出(first-in, first-out)。当你将一个元素推入队列时,你是将元素插入队列的末尾。当你弹出一个元素时,你是从队列的开头移除元素。这样,在队列中待得最久的元素就是最先被弹出的元素。
STL 提供了std::queue,它位于<queue>头文件中。像stack一样,queue接受两个模板参数。第一个参数是被包装容器的底层类型,第二个参数是被包装容器的类型,默认为deque。
在 STL 容器中,你只能使用deque或list作为queue的底层容器,因为从vector的前端推入和弹出元素效率较低。
你可以使用front和back方法访问队列前端或后端的元素。
列表 13-20 展示了如何使用queue来包装deque。
#include <queue>
TEST_CASE("std::queue supports push/pop/front/back") {
std::deque<int> deq{ 1, 2 }; ➊
std::queue<int> easy_as(deq); ➋ // 1 2
REQUIRE(easy_as.front() == 1); ➌
REQUIRE(easy_as.back() == 2); ➍
easy_as.pop(); ➎ // 2
easy_as.push(3); ➏ // 2 3
REQUIRE(easy_as.front() == 2); ➐
REQUIRE(easy_as.back() == 3); ➑
easy_as.pop(); // 3
REQUIRE(easy_as.front() == 3);
easy_as.pop(); //
REQUIRE(easy_as.empty()); ➒
}
列表 13-20: 使用queue包装deque
你从一个包含元素 1 和 2 的deque开始 ➊,并将其传入一个名为easy_as的队列 ➋。使用front和back方法,你可以验证队列的开头是 1 ➌,结尾是 2 ➍。当你弹出第一个元素 1 时,队列中只剩下单一元素 2 ➎。然后你将 3 推入队列 ➏,此时front方法返回 2 ➐,back方法返回 3 ➑。再进行两次pop-front操作后,队列为空 ➒。
表 13-6 总结了queue的操作。在这张表中,q、q1和q2是std::queue<T>类型;t是T类型;ctr是ctr_type<T>类型的容器。
表 13-6: std::queue操作概述
| 操作 | 备注 |
|---|---|
queue<T, [ctr_type]>([ctr]) |
使用 ctr 作为内部容器构造 T 类型的队列。如果没有提供容器,则构造一个空的deque。 |
q.empty() |
如果容器为空,返回true。 |
q.size() |
返回容器中元素的数量。 |
q.front() |
返回队列前端元素的引用。 |
q.back() |
返回 queue 中最后一个元素的引用。 |
q.push(t) |
将 t 的副本放到容器的末尾。 |
q.emplace(...) |
通过转发 ... 到适当的构造函数,原地构造一个 T。 |
q.pop() |
移除容器中前面的元素。 |
q1.swap(q2) swap(q1, q2) |
交换 q2 和 q1 的内容。 |
优先队列(堆)
优先队列(也叫堆)是一种支持 push 和 pop 操作的数据结构,它根据某个用户指定的 比较器对象 对元素进行排序。比较器对象是一个函数对象,接受两个参数,并在第一个参数小于第二个参数时返回 true。当你从优先队列中 pop 一个元素时,你会移除根据比较器对象确定的最大元素。
STL 提供了 <queue> 头文件中的 std::priority_queue。priority_queue 有三个模板参数:
-
包装容器的底层类型
-
包装容器的类型
-
比较器对象的类型
只有底层类型是必需的。包装容器类型默认为 vector(可能因为它是最常用的顺序容器),比较器对象类型默认为 std::less。
注意
std::less 类模板可在 <functional> 头文件中找到,如果第一个参数小于第二个参数,则返回 true。
priority_queue 的接口与 stack 相同。唯一的区别是栈按照后进先出的顺序 pop 元素,而优先队列则根据比较器对象的标准来 pop 元素。
清单 13-21 展示了 priority_queue 的基本用法。
#include <queue>
TEST_CASE("std::priority_queue supports push/pop") {
std::priority_queue<double> prique; ➊
prique.push(1.0); // 1.0
prique.push(2.0); // 2.0 1.0
prique.push(1.5); // 2.0 1.5 1.0
REQUIRE(prique.top() == Approx(2.0)); ➋
prique.pop(); // 1.5 1.0
prique.push(1.0); // 1.5 1.0 1.0
REQUIRE(prique.top() == Approx(1.5)); ➌
prique.pop(); // 1.0 1.0
REQUIRE(prique.top() == Approx(1.0)); ➍
prique.pop(); // 1.0
REQUIRE(prique.top() == Approx(1.0)); ➎
prique.pop(); //
REQUIRE(prique.empty()); ➏
}
清单 13-21:priority_queue 的基本用法
在这里,你默认构造一个 priority_queue ➊,它内部初始化一个空的 vector 来存储元素。你将元素 1.0、2.0 和 1.5 推入 priority_queue,它会按降序对元素进行排序,因此容器中的元素顺序是 2.0 1.5 1.0。
你确认 top 返回的是 2.0 ➋,然后从 priority_queue 中移除该元素,再用新元素 1.0 调用 push。此时容器中的元素顺序变为 1.5 ➌ 1.0 ➍ 1.0 ➎,你通过一系列的 top 和 pop 操作验证这一点,直到容器为空 ➏。
注意
priority_queue 将其元素存储在树结构中,因此如果你查看其底层容器,内存顺序将与 清单 13-21 所示的顺序不匹配。
表 13-7 总结了 priority_queue 的操作。在此表中,pq、pq1 和 pq2 的类型是 std::priority_queue<T>;t 的类型是 T;ctr 是类型为 ctr_type<T> 的容器;srt 是类型为 srt_type<T> 的容器。
表 13-7: std::priority_queue 操作总结
| 操作 | 说明 |
|---|---|
priority_queue <T, [ctr_type], [cmp_type]>([cmp], [ctr]) |
使用ctr作为内部容器,srt作为比较器对象,构造一个priority_queue。如果没有提供容器,则构造一个空的deque,并默认使用std::less作为排序器。 |
pq.empty() |
如果容器为空,返回true。 |
pq.size() |
返回容器中的元素数量。 |
pq.top() |
返回容器中最大元素的引用。 |
pq.push(t) |
将 t 的副本放到容器的末尾。 |
pq.emplace(...) |
通过转发...到适当的构造函数来原地构造一个 T。 |
pq.pop() |
移除容器末尾的元素。 |
pq1.swap(pq2) swap(pq1, pq2) |
交换 s2 和 s1 的内容。 |
Bitsets
bitset是一种存储固定大小位序列的数据结构。你可以操作每一位。
STL 提供了std::bitset,位于<bitset>头文件中。类模板bitset接受一个对应所需大小的单一模板参数。你也可以使用bool 数组实现类似的功能,但bitset在空间效率上进行了优化,并提供了一些特殊的便捷操作。
注意
STL 专门化了std::vector<bool>,因此它可能像bitset一样从相同的空间效率中受益。(回想一下在第 178 页的“模板特化”中提到的,模板特化是使某些类型的模板实例化更加高效的过程。)Boost 提供了boost::dynamic_bitset,它在运行时提供动态大小。
默认构造的bitset包含所有零(假)位。要初始化具有其他内容的bitset,你可以提供一个unsigned long long值。该整数的按位表示设置bitset的值。你可以使用operator[]访问bitset中的单个位。列表 13-22 展示了如何用整数字面量初始化bitset并提取其元素。
#include <bitset>
TEST_CASE("std::bitset supports integer initialization") {
std::bitset<4> bs(0b1010); ➊
REQUIRE_FALSE(bs[0]); ➋
REQUIRE(bs[1]); ➌
REQUIRE_FALSE(bs[2]); ➍
REQUIRE(bs[3]); ➎
}
列表 13-22:使用整数初始化bitset
你用 4 位nybble 0101 ➊初始化一个bitset。因此,第一 ➋ 和第三 ➍ 个元素为零,第二 ➌ 和第四 ➎ 个元素为 1。
你还可以提供一个字符串表示所需的bitset,如列表 13-23 所示。
TEST_CASE("std::bitset supports string initialization") {
std::bitset<4> bs1(0b0110); ➊
std::bitset<4> bs2("0110"); ➋
REQUIRE(bs1 == bs2); ➌
}
列表 13-23:使用字符串初始化bitset
在这里,你使用相同的整数 nybble 0b0110 ➊构造一个名为bs1的bitset,并使用字符串字面量0110 ➋构造另一个名为bs2的bitset。这两种初始化方式生成相同的bitset对象 ➌。
表 13-8 总结了bitset的操作。在此表中,bs、bs 1和bs 2的类型为std::bitset<N>,而i是一个size_t。
表 13-8: std::bitset操作总结
| 操作 | 说明 |
|---|---|
bitset<N>([val]) |
构造一个初始值为 val 的bitset,其中 val 可以是由 0 和 1 组成的字符串或unsigned long long。默认构造函数将所有位初始化为零。 |
bs[i] |
返回第 i 位的值:1 返回 true;0 返回 false。 |
bs.test(i) |
返回第 i 位的值:1 返回 true;0 返回 false。执行边界检查;抛出std::out_of_range异常。 |
bs.set() |
将所有位设置为 1。 |
bs.set(i, val) |
将第 i 位设置为 val。执行边界检查;抛出std::out_of_range异常。 |
bs.reset() |
将所有位设置为 0。 |
bs.reset(i) |
将第 i 位设置为零。执行边界检查;抛出std::out_of_range异常。 |
bs.flip() |
翻转所有位:(0 变为 1;1 变为 0)。 |
bs.flip(i) |
翻转第 i 位。执行边界检查;抛出std::out_of_range异常。 |
bs.count() |
返回设置为 1 的位数。 |
bs.size() |
返回bitset的大小 N。 |
bs.any() |
如果任何位都设置为 1,返回true。 |
bs.none() |
如果所有位都设置为 0,返回true。 |
bs.all() |
如果所有位都设置为 1,返回true。 |
bs.to_string() |
返回bitset的string表示形式。 |
bs.to_ulong() |
返回bitset的unsigned long表示形式。 |
bs.to_ullong() |
返回bitset的unsigned long long表示形式。 |
特殊顺序 Boost 容器
Boost 提供了大量的特殊容器,这里没有足够的空间来探讨它们的所有特性。表 13-9 提供了其中一些容器的名称、头文件和简要描述。 |
注意
请参考 Boost 容器文档获取更多信息。
表 13-9: 特殊 Boost 容器
| 类/头文件 | 描述 |
|---|---|
boost::intrusive::*``<boost/intrusive/*.hpp> |
入侵式容器对它们所包含的元素有要求(例如,元素必须继承自某个基类)。作为交换,它们提供了显著的性能提升。 |
boost::container::stable_vector``<boost/container/stable_vector.hpp> |
一个没有连续元素的向量,但保证只要元素未被删除(如同list一样),迭代器和对元素的引用将保持有效。 |
boost::container::slist``<boost/container/slist.hpp> |
一个带有快速size方法的forward_list。 |
boost::container::static_vector``<boost/container/static_vector.hpp> |
介于数组和向量之间的混合容器,存储动态数量的元素,最多到固定大小。元素像array一样存储在stable_vector的内存中。 |
boost::container::small_vector``<boost/container/small_vector.hpp> |
一种类似于vector的容器,优化用于存储少量元素。包含一些预分配的空间,避免动态分配。 |
boost::circular_buffer``<boost/circular_buffer.hpp> |
一种固定容量、类似队列的容器,以循环方式填充元素;一旦达到容量,新的元素会覆盖最旧的元素。 |
boost::multi_array``<boost/multi_array.hpp> |
一种类似数组的容器,接受多维度。你可以指定一个三维的 multi_array x,而不是例如多个数组的数组,从而允许元素访问,如 x[5][1][2]。 |
boost::ptr_vector``boost::ptr_list``<boost/ptr_container/*.hpp> |
拥有智能指针集合可能不是最优的选择。指针向量以更高效和用户友好的方式管理动态对象集合。 |
注意
Boost Intrusive 还包含一些专用的容器,在某些情况下提供性能优势。这些容器主要对于库的实现者有用。
关联容器
关联容器 允许非常快速的元素搜索。顺序容器具有某种自然顺序,允许你从容器的开始迭代到结束,并按照特定顺序进行遍历。关联容器略有不同,这个容器家族沿着三个轴进行划分:
-
元素是否包含键(一个集合)或键值对(一个映射)
-
元素是否有序
-
键是否是 唯一 的
集合
STL 中 <set> 头文件提供的 std::set 是一个关联容器,包含已排序的唯一元素,称为 键。因为 set 存储的是排序元素,你可以高效地进行插入、删除和查找操作。此外,set 支持对其元素进行有序迭代,并且你可以通过比较器对象完全控制键的排序方式。
注意
Boost 还提供了 <boost/container/set.hpp> 头文件中的 boost::container::set。
构造
类模板 set<T, Comparator, Allocator> 接受三个模板参数:
-
键类型
T -
比较器类型默认为
std::less -
分配器类型默认为
std::allocator<T>
构造 set 时你有很大的灵活性。以下每个构造函数都接受一个可选的比较器和分配器(其类型必须与相应的模板参数匹配):
-
一个默认构造函数,初始化一个空的
set -
移动和复制构造函数具有常见的行为
-
一个范围构造函数,将范围内的元素复制到集合中
-
一个大括号初始化器
列表 13-24 展示了这些构造函数的每一个。
#include <set>
TEST_CASE("std::set supports") {
std::set<int> emp; ➊
std::set<int> fib{ 1, 1, 2, 3, 5 }; ➋
SECTION("default construction") {
REQUIRE(emp.empty()); ➌
}
SECTION("braced initialization") {
REQUIRE(fib.size() == 4); ➍
}
SECTION("copy construction") {
auto fib_copy(fib);
REQUIRE(fib.size() == 4); ➎
REQUIRE(fib_copy.size() == 4); ➏
}
SECTION("move construction") {
auto fib_moved(std::move(fib));
REQUIRE(fib.empty()); ➐
REQUIRE(fib_moved.size() == 4); ➑
}
SECTION("range construction") {
std::array<int, 5> fib_array{ 1, 1, 2, 3, 5 };
std::set<int> fib_set(fib_array.cbegin(), fib_array.cend());
REQUIRE(fib_set.size() == 4); ➒
}
}
列表 13-24:set 的构造函数
你可以默认构造 ➊ 和大括号初始化 ➋ 两个不同的 set。默认构造的 set 叫做 emp,是空的 ➌,而大括号初始化的 set 叫做 fib,包含四个元素 ➍。你在大括号初始化器中包括了五个元素,那为什么只有四个元素?回想一下,set 的元素是唯一的,因此 1 只会出现一次。
接下来,你复制构造了 fib,这将导致两个 set,其大小为 4 ➎ ➏。另一方面,移动构造函数会清空被移动的 set ➐ 并将元素转移到新的 set ➑。
然后,你可以从一个区间初始化一个 set。你构造了一个包含五个元素的 array,然后将其作为区间传递给 set 构造函数,使用 cbegin 和 cend 方法。与之前代码中的花括号初始化一样,set 只包含四个元素,因为重复的元素会被丢弃 ➒。
移动与复制语义
除了移动/复制构造函数外,还提供了移动/复制赋值操作符。与其他容器的复制操作一样,set 的复制操作可能非常慢,因为每个元素都需要被复制,而移动操作通常很快,因为元素存储在动态内存中。set 可以简单地传递所有权,而不干扰元素。
元素访问
你有几种方法可以从 set 中提取元素。基本方法是 find,它接受一个键的 const 引用并返回一个迭代器。如果 set 包含与键匹配的元素,find 将返回一个指向找到元素的迭代器。如果 set 中没有该元素,它将返回指向 end 的迭代器。lower_bound 方法返回一个指向第一个不小于键参数的元素的迭代器,而 upper_bound 方法返回第一个大于给定键的元素。
set 类支持两种额外的查找方法,主要是为了兼容非唯一的关联容器:
-
count方法返回与键匹配的元素的数量。由于set中的元素是唯一的,count要么返回 0,要么返回 1。 -
equal_range方法返回一个半开区间,其中包含所有与给定键匹配的元素。该区间返回一个std::pair迭代器,first指向匹配的元素,second指向first之后的元素。如果equal_range没有找到匹配的元素,first和second都会指向第一个大于给定键的元素。换句话说,equal_range返回的pair等价于lower_bound的first和upper_bound的second。
列表 13-25 演示了这两种访问方法。
TEST_CASE("std::set allows access") {
std::set<int> fib{ 1, 1, 2, 3, 5 }; ➊
SECTION("with find") { ➋
REQUIRE(*fib.find(3) == 3);
REQUIRE(fib.find(100) == fib.end());
}
SECTION("with count") { ➌
REQUIRE(fib.count(3) == 1);
REQUIRE(fib.count(100) == 0);
}
SECTION("with lower_bound") { ➍
auto itr = fib.lower_bound(3);
REQUIRE(*itr == 3);
}
SECTION("with upper_bound") { ➎
auto itr = fib.upper_bound(3);
REQUIRE(*itr == 5);
}
SECTION("with equal_range") { ➏
auto pair_itr = fib.equal_range(3);
REQUIRE(*pair_itr.first == 3);
REQUIRE(*pair_itr.second == 5);
}
}
列表 13-25:set 成员访问
首先,你构造一个包含四个元素 1、2、3、5 的set ➊。使用find,你可以获取指向元素 3 的迭代器。你也可以确定 8 不在set中,因为find返回一个指向end的迭代器 ➋。你可以使用count获取类似的信息,当你传入键 3 时返回 1,当你传入键 8 时返回 0 ➌。当你将 3 传递给lower_bound方法时,它返回指向 3 的迭代器,因为这是第一个不小于给定参数的元素 ➍。另一方面,当你将其传递给upper_bound时,你得到指向元素 5 的指针,因为这是第一个大于给定参数的元素 ➎。最后,当你将 3 传递给equal_range方法时,你得到一对迭代器。first迭代器指向 3,second迭代器指向 5,即紧跟在 3 后面的元素 ➏。
set还通过其begin和end方法暴露迭代器,因此你可以使用基于范围的for循环从最小元素到最大元素遍历set。
添加元素
当向set中添加元素时,你有三种选择:
-
insert:将一个现有元素复制到set中 -
emplace:在set中原地构造一个新元素 -
emplace_hint:像emplace一样原地构造一个新元素(因为添加元素需要排序)。不同之处在于,emplace_hint方法将一个迭代器作为第一个参数。这个迭代器是搜索的起点(提示)。如果迭代器接近新插入元素的正确位置,这可以显著提高效率。Listing 13-26 展示了将元素插入到
set中的几种方式。
TEST_CASE("std::set allows insertion") {
std::set<int> fib{ 1, 1, 2, 3, 5 };
SECTION("with insert") { ➊
fib.insert(8);
REQUIRE(fib.find(8) != fib.end());
}
SECTION("with emplace") { ➋
fib.emplace(8);
REQUIRE(fib.find(8) != fib.end());
}
SECTION("with emplace_hint") { ➌
fib.emplace_hint(fib.end(), 8);
REQUIRE(fib.find(8) != fib.end());
}
}
Listing 13-26: 向set中插入元素
insert ➊和emplace ➋都会将元素 8 添加到fib中,因此当你用 8 调用find时,你会得到一个指向新元素的迭代器。你可以用emplace_hint ➌以更高效的方式实现相同的效果。因为你预先知道新元素 8 大于set中的所有其他元素,所以你可以使用end作为提示。
如果你尝试将一个已经存在于set中的键通过insert、emplace或emplace_hint插入,那么操作将没有任何效果。这三种方法都会返回一个std::pair<Iterator, bool>,其中second元素表示操作是否导致了插入(true)或没有插入(false)。first指向的迭代器指向的是新插入的元素,或者是阻止插入的现有元素。
移除元素
你可以使用erase方法从set中移除元素,erase被重载以接受一个键、一个迭代器或一个半开区间,如 Listing 13-27 所示。
TEST_CASE("std::set allows removal") {
std::set<int> fib{ 1, 1, 2, 3, 5 };
SECTION("with erase") { ➊
fib.erase(3);
REQUIRE(fib.find(3) == fib.end());
}
SECTION("with clear") { ➋
fib.clear();
REQUIRE(fib.empty());
}
}
Listing 13-27: 从set中移除元素
在第一个测试中,你使用键值 3 调用 erase,这将从 set 中移除相应的元素。当你在 3 上调用 find 时,返回一个指向 end 的迭代器,表示没有找到匹配的元素 ➊。在第二个测试中,你调用 clear,这会从 set 中删除所有元素 ➋。
存储模型
集合操作速度很快,因为集合通常是通过 红黑树 实现的。这些结构将每个元素当作一个节点。每个节点有一个父节点和最多两个子节点,分别是左子节点和右子节点。每个节点的子节点按顺序排序,所有左子节点都小于右子节点。这样,只要树的分支大致平衡(长度相等),就能比线性遍历更快地进行搜索。红黑树在插入和删除后具有重新平衡分支的附加功能。
注意
有关红黑树的详细信息,请参考 Adam Drozdek 的《C++ 数据结构与算法》。
部分支持的操作列表
表 13-10 总结了 set 的操作。操作 s、s1 和 s2 的类型是 std::set<T,[cmp_type<T>]>。T 是包含的元素/键类型,itr、beg 和 end 是 set 的迭代器。变量 t 是一个 T。一个十字标记 () 表示返回 std::pair<Iterator, bool> 的方法,其中迭代器指向结果元素,且 bool 等于 true 表示方法插入了元素,false 表示元素已存在。
表 13-10: std::set 操作总结
| 操作 | 备注 |
|---|---|
set<T>{ ..., [cmp], [alc] } |
对新构造的 set 执行大括号初始化。默认使用 cmp=std::less<T> 和 alc=std::allocator<T>。 |
set<T>{ beg, end, [cmp], [alc] } |
范围构造函数,复制从半开区间 beg 到 end 的元素。默认使用 cmp=std::less<T> 和 alc=std::allocator<T>。 |
set<T>(s) |
深拷贝 s;分配新内存。 |
set<T>(move(s)) |
接管内存所有权;元素来自 s。没有分配内存。 |
~set |
析构 set 中包含的所有元素并释放动态内存。 |
s1 = s2 |
s1 析构其元素;复制每个 s2 元素。只有在需要调整大小以适应 s2 元素时才会分配内存。 |
s1 = move(s2) |
s1 析构其元素;移动每个 s2 元素。只有在需要调整大小以适应 s2 元素时才会分配内存。 |
s.begin() |
返回指向第一个元素的迭代器。 |
s.cbegin() |
返回指向第一个元素的 const 迭代器。 |
s.end() |
返回指向最后一个元素之后的迭代器。 |
s.cend() |
返回指向最后一个元素之后的 const 迭代器。 |
s.find(t) |
返回指向匹配 t 的元素的迭代器,如果没有这样的元素则返回 s.end()。 |
s.count(t) |
如果 set 中包含 t,则返回 1;否则返回 0。 |
s.equal_range(t) |
返回一个pair类型的迭代器,表示与t匹配的半开区间的元素。 |
s.lower_bound(t) |
返回一个迭代器,指向第一个不小于t的元素,如果没有此类元素,则返回s.end()。 |
s.upper_bound(t) |
返回一个迭代器,指向第一个大于t的元素,如果没有此类元素,则返回s.end()。 |
s.clear() |
删除集合中的所有元素。 |
s.erase(t) |
删除与t相等的元素。 |
s.erase(itr) |
删除itr指向的元素。 |
s.erase(beg, end) |
删除从 beg 到 end 的半开区间内的所有元素。 |
s.insert(t) |
将t的副本插入集合中。 |
s.emplace(...) |
通过转发参数构造一个 T。 |
s.emplace_hint(itr, ...) |
通过转发参数构造一个 T,并使用itr作为提示,指示插入新元素的位置。 |
s.empty() |
如果集合的大小为零,返回true;否则返回false。 |
s.size() |
返回集合中的元素数量。 |
s.max_size() |
返回集合中元素的最大数量。 |
s.extract(t)s.extract(itr) |
获取一个节点句柄,拥有匹配t或由itr指向的元素。(这是移除仅能移动的元素的唯一方法。) |
s1.merge(s2)s1.merge(move(s2)) |
将s2中的每个元素合并到s1中。如果参数是右值,则将元素移动到s1中。 |
s1.swap(s2) swap(s1, s2) |
交换s1和s2中的每个元素。 |
多重集合(Multisets)
STL 的<set>头文件中提供的std::multiset是一个关联容器,包含排序的、非唯一的键。multiset支持与set相同的操作,但它会存储冗余的元素。这对以下两种方法有重要影响:
-
方法
count可以返回 0 以外的值。multiset的count方法会告诉你有多少个元素匹配给定的键。 -
方法
equal_range可以返回包含多个元素的半开区间。multiset的equal_range方法将返回一个包含所有匹配给定键的元素的区间。
如果需要存储多个相同键的元素,可能希望使用multiset而非set。例如,可以通过将地址作为键,并将每个住户作为元素,来存储一个地址的所有居民。如果使用set,则只能存储一个居民。
示例 13-28 展示了如何使用multiset。
TEST_CASE("std::multiset handles non-unique elements") {
std::multiset<int> fib{ 1, 1, 2, 3, 5 };
SECTION("as reflected by size") {
REQUIRE(fib.size() == 5); ➊
}
SECTION("and count returns values greater than 1") {
REQUIRE(fib.count(1) == 2); ➋
}
SECTION("and equal_range returns non-trivial ranges") {
auto [begin, end] = fib.equal_range(1); ➌
REQUIRE(*begin == 1); ➍
++begin;
REQUIRE(*begin == 1); ➎
++begin;
REQUIRE(begin == end); ➏
}
}
示例 13-28:访问multiset元素
与 Listing 13-24 中的 set 不同,multiset 允许多个 1,因此 size 返回 5,即你在大括号初始化器中提供的元素数量 ➊。当你计算 1 的数量时,你会得到 2 ➋。你可以使用 equal_range 来遍历这些元素。使用结构化绑定语法,你可以获得 begin 和 end 迭代器 ➌。你遍历这两个 1 ➎,然后到达半开区间的结束位置 ➏。
表 13-10 中的每个操作都适用于 multiset。
注意
Boost 还在 <boost/container/set.hpp> 头文件中提供了一个 boost::container::multiset。
无序集合
STL 中 <unordered_set> 头文件提供的 std::unordered_set 是一个关联容器,包含 无序、唯一的键。unordered_set 支持与 set 和 multiset 相同的大多数操作,但其内部存储模型完全不同。
注意
Boost 还在 <boost/unordered_set.hpp> 头文件中提供了一个 boost::unordered_set。
与使用比较器将元素排序到红黑树中不同,unordered_set 通常实现为哈希表。在没有自然顺序的键,并且不需要按照特定顺序遍历集合的情况下,你可能会希望使用 unordered_set。你可能会发现,在许多情况下,你可以使用 set 或 unordered_set。尽管它们看起来非常相似,但它们的内部表示方式是根本不同的,因此它们的性能特点也会有所不同。如果性能是一个问题,测量两者的表现,并使用更合适的那一个。
存储模型:哈希表
哈希函数,或称为 哈希器,是一个接受键并返回一个唯一的 size_t 值,称为哈希码的函数。unordered_set 将其元素组织成一个哈希表,哈希表将哈希码与一个或多个元素的集合(称为 桶)关联起来。为了查找元素,unordered_set 会计算它的哈希码,然后在哈希表中搜索对应的桶。
如果你从未见过哈希表,可能会觉得这信息有些难以理解,那么我们来看一个例子。假设你有一大群人,需要将他们分成一些有意义的组,以便更容易地找到某个人。你可以按照生日将人分组,这样你会得到 365 个组(如果你算上闰年的 2 月 29 日,就有 366 个组)。生日就像是一个哈希函数,为每个人返回 365 个值中的一个。每个值形成一个桶,所有在同一个桶中的人有着相同的生日。在这个例子中,要找到某个人,你首先确定他的生日,这样就能找到正确的桶。然后,你可以在桶中搜索,找到你要找的人。
只要哈希函数足够快速,并且每个桶中的元素数量不太多,unordered_set 的性能比它们有序的对等物更为出色:包含的元素数不会增加插入、搜索和删除的时间。当两个不同的键有相同的哈希值时,称为 哈希冲突。当发生哈希冲突时,意味着这两个键会在同一个桶中。在前面的生日例子中,许多人会有相同的生日,因此会发生很多哈希冲突。哈希冲突越多,桶的大小越大,查找正确元素时在桶中花费的时间就越多。
哈希函数有几个要求:
-
它接受一个
Key类型并返回一个size_t的哈希值。 -
它不会抛出异常。
-
相等的键值会产生相等的哈希值。
-
不相等的键值通常会产生不相等的哈希值。(哈希冲突的概率很低。)
STL 在 <functional> 头文件中提供了哈希器类模板 std::hash<T>,其中包含了基础类型、枚举类型、指针类型、optional、variant、智能指针等的特化。例如,Listing 13-29 说明了 std::hash<long> 如何满足等价性标准。
#include <functional>
TEST_CASE("std::hash<long> returns") {
std::hash<long> hasher; ➊
auto hash_code_42 = hasher(42); ➋
SECTION("equal hash codes for equal keys") {
REQUIRE(hash_code_42 == hasher(42)); ➌
}
SECTION("unequal hash codes for unequal keys") {
REQUIRE(hash_code_42 != hasher(43)); ➍
}
}
Listing 13-29: std::hash<long> 为相等的键返回相等的哈希值,为不相等的键返回不相等的哈希值。
你构造了一个类型为 std::hash<long> 的哈希器 ➊,并用它计算了 42 的哈希值,将结果存储在 size_t hash_code_42 中 ➋。当你再次使用 hasher 计算 42 的哈希值时,得到相同的结果 ➌。当你用 43 调用哈希器时,得到不同的值 ➍。
一旦 unordered_set 对一个键进行哈希,它就可以获得一个桶。由于桶是一个可能匹配的元素列表,你需要一个函数对象来确定键与桶元素之间的相等性。STL 在 <functional> 头文件中提供了类模板 std::equal_to<T>,它简单地调用参数的 operator==,正如 Listing 13-30 所示。
#include <functional>
TEST_CASE("std::equal_to<long> returns") {
std::equal_to<long> long_equal_to; ➊
SECTION("true when arguments equal") {
REQUIRE(long_equal_to(42, 42)); ➋
}
SECTION("false when arguments unequal") {
REQUIRE_FALSE(long_equal_to(42, 43)); ➌
}
}
Listing 13-30: std::equal_to<long> 调用其参数的 operator== 来判断相等性。
在这里,你初始化了一个名为 long_equal_to 的 equal_to<long> ➊。当你用相等的参数调用 long_equal_to 时,它返回 true ➋。当你用不相等的参数调用时,它返回 false ➌。
注意
为了简洁,本章不讨论如何实现你自己的哈希和相等性函数,如果你想根据用户定义的键类型构造无序容器,你将需要这些函数。请参阅《C++标准库》第 2 版(Nicolai Josuttis 著)第七章。
构造
类模板 std::unordered_set<T, Hash, KeyEqual, Allocator 需要四个模板参数:
-
键类型
T -
Hash哈希函数类型,默认值为std::hash<T> -
KeyEqual相等性函数类型,默认值为std::equal_to<T> -
Allocator分配器类型,默认为std::allocator<T>
unordered_set 支持与 set 相等的构造函数,只是针对不同的模板参数做了调整(set 需要一个 Comparator,而 unordered_set 需要一个 Hash 和 KeyEqual)。例如,你可以在示例 13-24 中将 unordered_set 作为 set 的替代品,因为 unordered_set 具有范围构造函数和复制/移动构造函数,并且支持花括号初始化。
支持的集合操作
unordered_set 支持表 13-10 中列出的所有 set 操作,除了 lower_bound 和 upper_bound,因为 unordered_set 不对其元素进行排序。
桶管理
通常,你选择 unordered_set 的原因是其高性能。不幸的是,这种性能是有代价的:unordered_set 对象具有一些复杂的内部结构。你可以使用各种控制项和旋钮来检查和修改该内部结构的运行时状态。
你可以通过自定义 unordered_set 的桶数量来进行第一步控制(即桶的数量,而不是特定桶中元素的数量)。每个 unordered_set 构造函数将 size_t bucket_count 作为第一个参数,默认为一些实现定义的值。表 13-11 列出了主要的 unordered_set 构造函数。
表 13-11: unordered_set 构造函数
| 操作 | 备注 |
|---|---|
unordered_set<T>(``[bck], [hsh], [keq], [alc]) |
桶大小 bck 有一个实现定义的默认值。默认使用 hsh=std::hash<T>、keq=std::equal_to<T> 和 alc=std::allocator<T>。 |
unordered_set<T>(..., [bck], [hsh], [keq], [alc]) |
执行新构造的无序集合的花括号初始化。 |
unordered_set<T>(beg, end [bck], [hsh], [keq], [alc]) |
构造一个无序集合,元素范围为从 beg 到 end 的半开区间。 |
unordered_set<T>(s) |
s 的深拷贝;分配新内存。 |
unordered_set<T>(move(s)) |
获取内存所有权;s 中的元素。没有分配。 |
你可以使用 bucket_count 方法检查 unordered_set 中桶的数量。你还可以使用 max_bucket_count 方法获取最大桶数量。
unordered_set在运行时性能中的一个重要概念是其负载因子,即每个桶中元素的平均数量。你可以使用load_factor方法获取unordered_set的负载因子,它等价于size()除以bucket_count()。每个unordered_set都有一个最大负载因子,这会触发桶数量的增加,并可能导致所有包含元素的昂贵重新哈希。重新哈希是一个操作,其中元素会被重新组织到新的桶中。这需要为每个元素生成新的哈希值,这可能是一个相对计算昂贵的操作。
你可以通过max_load_factor获取最大负载因子,它是重载的,因此你可以设置一个新的最大负载因子(默认值为 1.0)。
为了避免在不合适的时机发生昂贵的重新哈希操作,你可以通过使用rehash方法手动触发重新哈希,rehash方法接受一个size_t类型的参数来指定所需的桶数量。你还可以使用reserve方法,该方法接受一个size_t类型的参数来指定所需的元素数量。
示例 13-31 展示了这些基本的桶管理操作。
#include <unordered_set>
TEST_CASE("std::unordered_set") {
std::unordered_set<unsigned long> sheep(100); ➊
SECTION("allows bucket count specification on construction") {
REQUIRE(sheep.bucket_count() >= 100); ➋
REQUIRE(sheep.bucket_count() <= sheep.max_bucket_count()); ➌
REQUIRE(sheep.max_load_factor() == Approx(1.0)); ➍
}
SECTION("allows us to reserve space for elements") {
sheep.reserve(100'000); ➎
sheep.insert(0);
REQUIRE(sheep.load_factor() <= 0.00001); ➏
while(sheep.size() < 100'000)
sheep.insert(sheep.size()); ➐
REQUIRE(sheep.load_factor() <= 1.0); ➑
}
}
示例 13-31:unordered_set桶管理
你构造了一个unordered_set并指定了 100 的桶数量➊。这样会导致bucket_count至少为 100➋,并且必须小于或等于max_bucket_count➌。默认情况下,max_load_factor为 1.0 ➍。
在下一个测试中,你调用reserve方法为十万个元素预留足够的空间➎。插入一个元素后,load_factor应该小于或等于百万分之一(0.00001)➏,因为你已经为十万个元素预留了足够的空间。只要低于这个阈值,就不需要重新哈希。插入十万个元素后➐,load_factor仍应小于或等于 1 ➑。这表明,由于使用了reserve,你不需要重新哈希。
无序多重集合
STL 的<unordered_set>头文件中的std::unordered_multiset是一个关联容器,包含无序的、非唯一的键。unordered_multiset支持与unordered_set相同的所有构造函数和操作,但它会存储重复的元素。这个关系类似于unordered_set与set:equal_range和count的行为有所不同,以考虑键的非唯一性。
注意
Boost 还提供了一个boost::unordered_multiset,位于<boost/unordered_set.hpp>头文件中。
映射
STL 的<map>头文件中提供的std::map是一个关联容器,包含键值对。map的键是排序且唯一的,且map支持与set相同的所有操作。实际上,你可以将set看作是一个特殊类型的map,其中包含键和空值。因此,map支持高效的插入、删除和查找,并且你可以通过比较器对象控制排序。
使用map而不是一对对的集合的主要优势在于,map作为关联数组工作。关联数组使用键而不是整数值的索引。想想你如何使用at和operator[]方法访问顺序容器中的索引。因为顺序容器的元素有自然的顺序,所以你用整数来引用它们。关联数组允许你使用除了整数之外的类型来引用元素。例如,你可以使用字符串或float作为键。
为了支持关联数组操作,map支持许多有用的操作;例如,允许你通过关联的键插入、修改和检索值。
构造
类模板map<Key, Value, Comparator, Allocator>包含四个模板参数。第一个是键类型Key。第二个是值类型Value。第三个是比较器类型,默认为std::less。第四个参数是分配器类型,默认为std::allocator<T>。
map的构造函数与set的构造函数直接对应:一个默认构造函数用于初始化一个空的map;移动和复制构造函数具有通常的行为;一个范围构造函数将范围中的元素复制到map中;以及一个大括号初始化器。主要的区别在于大括号初始化器,因为你需要初始化键值对,而不仅仅是键。为了实现这种嵌套初始化,你使用嵌套的初始化列表,正如列表 13-32 所示。
#include <map>
auto colour_of_magic = "Colour of Magic";
auto the_light_fantastic = "The Light Fantastic";
auto equal_rites = "Equal Rites";
auto mort = "Mort";
TEST_CASE("std::map supports") {
SECTION("default construction") {
std::map<const char*, int> emp; ➊
REQUIRE(emp.empty()); ➋
}
SECTION("braced initialization") {
std::map<const char*, int> pub_year { ➌
{ colour_of_magic, 1983 }, ➍
{ the_light_fantastic, 1986 },
{ equal_rites, 1987 },
{ mort, 1987 },
};
REQUIRE(pub_year.size() == 4); ➎
}
}
列表 13-32:std::map支持默认构造和大括号初始化。
在这里,你使用默认构造函数构造一个map,其中键的类型是const char*,值的类型是int ➊。这将导致一个空的map ➋。在第二个测试中,你再次使用键类型为const char*、值类型为int的map ➌,但这次使用大括号初始化 ➍将四个元素打包到map中 ➎。
移动和复制语义
map的移动和复制语义与set相同。
存储模型
map和set使用相同的红黑树内部结构。
元素访问
使用map而不是set的pair对象的主要优点是,map提供了两种关联数组操作:operator[]和at。与支持这些操作的顺序容器(如vector和array)不同,它们需要一个size_t类型的索引参数,map需要一个Key类型的参数,并返回对应值的引用。与顺序容器一样,at会在给定的key在map中不存在时抛出std::out_of_range异常。与顺序容器不同的是,如果key不存在,operator[]不会导致未定义行为;相反,它会(默默地)默认构造一个Value并将对应的键值对插入到 map 中,即使你只打算执行读取操作,如 Listing 13-33 所示。
TEST_CASE("std::map is an associative array with") {
std::map<const char*, int> pub_year { ➊
{ colour_of_magic, 1983 },
{ the_light_fantastic, 1986 },
};
SECTION("operator[]") {
REQUIRE(pub_year[colour_of_magic] == 1983); ➋
pub_year[equal_rites] = 1987; ➌
REQUIRE(pub_year[equal_rites] == 1987); ➍
REQUIRE(pub_year[mort] == 0); ➎
}
SECTION("an at method") {
REQUIRE(pub_year.at(colour_of_magic) == 1983); ➏
REQUIRE_THROWS_AS(pub_year.at(equal_rites), std::out_of_range); ➐
}
}
Listing 13-33: std::map是一个具有多种访问方法的关联数组。
你构造了一个名为map的pub_year,它包含两个元素 ➊。接下来,你使用operator[]提取与键colour_of_magic对应的值 ➋。你还使用operator[]插入新的键值对equal_rites,1987 ➌,然后检索它 ➍。注意,当你尝试检索一个不存在的键mort时,map 会默默地为你默认初始化一个int ➎。
使用at,你仍然可以设置和检索 ➏ 元素,但如果你尝试访问一个不存在的键,会得到std::out_of_range异常 ➐。
map支持所有类似set的元素检索操作。例如,map支持find,它接受一个key参数并返回一个指向键值对的迭代器,或者如果没有找到匹配的键,则返回指向map末尾的迭代器。类似地,map还支持count、equal_range、lower_bound和upper_bound等操作。
添加元素
除了元素访问方法operator[]和at外,你还可以使用set提供的所有insert和emplace方法。你只需将每个键值对视为std::pair<Key, Value>。与set一样,insert返回一个包含迭代器和bool的pair。迭代器指向插入的元素,bool值表示insert是否添加了新元素(true)或没有(false),如 Listing 13-34 所示。
TEST_CASE("std::map supports insert") {
std::map<const char*, int> pub_year; ➊
pub_year.insert({ colour_of_magic, 1983 }); ➋
REQUIRE(pub_year.size() == 1); ➌
std::pair<const char*, int> tlfp{ the_light_fantastic, 1986 }; ➍
pub_year.insert(tlfp); ➎
REQUIRE(pub_year.size() == 2); ➏
auto [itr, is_new] = pub_year.insert({ the_light_fantastic, 9999 }); ➐
REQUIRE(itr->first == the_light_fantastic);
REQUIRE(itr->second == 1986); ➑
REQUIRE_FALSE(is_new); ➒
REQUIRE(pub_year.size() == 2); ➓
}
Listing 13-34: std::map支持insert方法来添加新元素。
你默认构造了一个map ➊,并使用带花括号初始化器的insert方法来插入一个pair ➋。这个构造大致相当于以下内容:
pub_year.insert(std::pair<const char*, int>{ colour_of_magic, 1983 });
插入之后,map现在包含一个元素 ➌。接下来,你创建了一个独立的pair ➍,然后将它作为参数传递给insert ➎。这会将一个副本插入到map中,因此它现在包含两个元素 ➏。
当你尝试使用相同的 the_light_fantastic 键 ➐ 调用 insert 插入新元素时,你会得到一个指向你已经插入的元素的迭代器 ➎。键(first)和值(second)匹配 ➑。返回值 is_new 表示没有插入新元素 ➒,你仍然拥有两个元素 ➓。该行为与 set 的 insert 行为一致。 |
map 还提供了 insert_or_assign 方法,区别于 insert,它会覆盖现有的值。与 insert 不同,insert_or_assign 接受独立的键和值参数,如 示例 13-35 所示。
TEST_CASE("std::map supports insert_or_assign") {
std::map<const char*, int> pub_year{ ➊
{ the_light_fantastic, 9999 }
};
auto [itr, is_new] = pub_year.insert_or_assign(the_light_fantastic, 1986); ➋
REQUIRE(itr->second == 1986); ➌
REQUIRE_FALSE(is_new); ➍
}
示例 13-35:std::map 支持 insert_or_assign 来覆盖现有元素。
你构造了一个包含单个元素 ➊ 的 map,然后调用 insert_or_assign 将与键 the_light_fantastic 关联的值重新赋值为 1986 ➋。迭代器指向现有元素,当你查询相应的值时,使用 second 你会看到值已更新为 1986 ➌。is_new 返回值也表明你已经更新了现有元素,而不是插入了新元素 ➍。 |
移除元素
类似于 set,map 支持 erase 和 clear 来移除元素,如 示例 13-36 所示。
TEST_CASE("We can remove std::map elements using") {
std::map<const char*, int> pub_year {
{ colour_of_magic, 1983 },
{ mort, 1987 },
}; ➊
SECTION("erase") {
pub_year.erase(mort); ➋
REQUIRE(pub_year.find(mort) == pub_year.end()); ➌
}
SECTION("clear") {
pub_year.clear(); ➍
REQUIRE(pub_year.empty()); ➎
}
}
示例 13-36:std::map 支持元素移除。
你构造了一个包含两个元素 ➊ 的 map。在第一次测试中,你对键为 mort 的元素调用 erase ➋,所以当你尝试 find 它时,你会得到 end ➌。在第二次测试中,你清空了 map ➍,这导致 empty 返回 true ➎。
支持的操作列表
表 13-12 总结了 map 的支持操作。键 k 的类型是 K。值 v 的类型是 V。P 是类型 pair<K, V>,p 的类型是 P。map m 的类型是 map<K, V>。匕首符号 () 表示一个返回 std::pair<Iterator, bool> 的方法,其中迭代器指向结果元素,bool 为 true 表示该方法插入了一个元素,false 表示该元素已经存在。 |
表 13-12: map 操作部分支持列表
| 操作 | 备注 |
|---|---|
map<T>{ ..., [cmp], [alc] } |
执行新构造的 map 的大括号初始化。默认使用 cmp=std::less<T> 和 alc=std::allocator<T>。 |
map<T>{ beg, end, [cmp], [alc] } |
范围构造函数,将元素从半开区间 beg 到 end 进行复制。默认使用 cmp=std::less<T> 和 alc=std::allocator<T>。 |
map<T>(m) |
对 m 进行深拷贝;分配新的内存。 |
map<T>(move(m)) |
获取内存所有权;元素来自 m。没有内存分配。 |
~map |
销毁 map 中的所有元素并释放动态内存。 |
m1 = m2 |
m1 销毁其元素;复制每个 m2 元素。仅在需要调整大小以适应 m2 的元素时才会分配内存。 |
m1 = move(m2) |
m1 销毁其元素;移动每个 m2 元素。仅在需要调整大小以适应 m2 的元素时才会分配内存。 |
m.at(k) |
访问与键 k 对应的值。如果未找到该键,则抛出std::out_of_bounds异常。 |
m[k] |
访问与键 k 对应的值。如果未找到该键,则使用 k 和默认初始化的值插入一个新的键值对。 |
m.begin() |
返回一个迭代器,指向第一个元素。 |
m.cbegin() |
返回一个const迭代器,指向第一个元素。 |
m.end() |
返回一个迭代器,指向最后一个元素之后的位置。 |
m.cend() |
返回一个const迭代器,指向最后一个元素之后的位置。 |
m.find(k) |
返回一个迭代器,指向匹配 k 的元素,如果没有此类元素,则返回 m.end()。 |
m.count(k) |
如果 map 包含 k,则返回 1;否则返回 0。 |
m.equal_range(k) |
返回一个pair,其中包含对应于匹配 k 的元素的半开区间的两个迭代器。 |
m.lower_bound(k) |
返回一个迭代器,指向第一个不小于 k 的元素,如果没有此类元素,则返回 t.end()。 |
m.upper_bound(k) |
返回一个迭代器,指向第一个大于 k 的元素,如果没有此类元素,则返回 t.end()。 |
m.clear() |
移除 map 中的所有元素。 |
m.erase(k) |
移除具有键 k 的元素。 |
m.erase(itr) |
移除 itr 指向的元素。 |
m.erase(beg, end) |
移除从 beg 到 end 的半开区间内的所有元素。 |
m.insert(p) |
将 p 对的副本插入到 map 中。 |
m.insert_or_assign(k, v) |
如果 k 存在,使用 v 覆盖对应的值。如果 k 不存在,将 k,v 对插入到 map 中。 |
m.emplace(...) |
通过转发参数...在原地构造一个 P。 |
m.emplace_hint(k, ...) |
通过转发参数...在原地构造一个 P。使用 itr 作为插入新元素的提示位置。 |
m.try_emplace(itr, ...) |
如果 key k 存在,则不做任何操作。如果 k 不存在,则通过转发参数...在原地构造一个 V。 |
m.empty() |
如果 map 的大小为零,返回true;否则返回false。 |
m.size() |
返回 map 中的元素数量。 |
m.max_size() |
返回 map 中元素的最大数量。 |
m.extract(k)m.extract(itr) |
获取一个节点句柄,该句柄拥有与 k 匹配的元素或 itr 指向的元素。(这是移除仅能移动的元素的唯一方式。) |
m1.merge(m2)m1.merge(move(m2)) |
将 m2 的每个元素拼接到 m1 中。如果参数是右值,则将元素移动到 m1 中。 |
m1.swap(m2)``swap(m1, m2) |
交换 m1 和 m2 的每个元素。 |
多重映射(Multimaps)
STL 中的 std::multimap 位于 <map> 头文件中,是一个包含具有 非唯一 键的键值对的关联容器。由于键不唯一,multimap 不支持 map 的关联数组特性。也就是说,不支持 operator[] 和 at。与 multiset 一样,multimap 主要通过 equal_range 方法提供元素访问,如 清单 13-37 所示。
TEST_CASE("std::multimap supports non-unique keys") {
std::array<char, 64> far_out {
"Far out in the uncharted backwaters of the unfashionable end..."
}; ➊
std::multimap<char, size_t> indices; ➋
for(size_t index{}; index<far_out.size(); index++)
indices.emplace(far_out[index], index); ➌
REQUIRE(indices.count('a') == 6); ➍
auto [itr, end] = indices.equal_range('d'); ➎
REQUIRE(itr->second == 23); ➏
itr++;
REQUIRE(itr->second == 59); ➐
itr++;
REQUIRE(itr == end);
}
清单 13-37:std::multimap 支持非唯一键。
你构造了一个包含消息的 array ➊。你还默认构造了一个名为 indices 的 multimap<char, size_t>,它将用于存储消息中每个字符的索引 ➋。通过遍历数组,你可以将每个字符及其索引作为新元素存储在 multimap 中 ➌。由于允许使用非唯一键,你可以使用 count 方法来查看以键 a 插入的索引数量 ➍。你还可以使用 equal_range 方法获取键 d 的半开区间 ➎。利用结果中的 begin 和 end 迭代器,你可以看到消息中字母 d 的索引分别是 23 ➏ 和 59 ➐。
除了 operator[] 和 at,表 13-12 中的每个操作也适用于 multimap。(请注意,count 方法可以返回除 0 和 1 以外的其他值。)
无序映射和无序多重映射
无序映射和无序多重映射与无序集合和无序多重集合完全类似。std::unordered_map 和 std::unordered_multimap 可以在 STL 的 <unordered_map> 头文件中找到。这些关联容器通常使用像 set 那样的红黑树。它们还需要哈希函数和等价性函数,并支持桶接口。
注意
Boost 在 <boost/unordered_map.hpp> 头文件中提供了 boost::unordered_map 和 boost::unordered_multimap。
特定领域的关联容器
当需要关联数据结构时,使用 set、map 及其相关的非唯一和无序对立物作为默认选择。当出现特殊需求时,Boost 库提供了许多专门的关联容器,如 表 13-13 所示。
表 13-13: 特殊 Boost 容器
| 类/头文件 | 描述 |
|---|---|
boost::container::flat_map``<boost/container/flat_map.hpp> |
类似于 STL 中的 map,但它的实现方式像一个有序的向量。这意味着快速的随机元素访问。 |
boost::container::flat_set``<boost/container/flat_set.hpp> |
类似于 STL 中的 set,但它的实现方式像一个有序的向量。这意味着快速的随机元素访问。 |
boost::intrusive::*``<boost/intrusive/*.hpp> |
内侵式容器对其包含的元素提出要求(例如要求继承自特定的基类)。作为交换,它们提供了显著的性能提升。 |
boost::multi_index_container``<boost/multi_index_container.hpp> |
允许你创建关联数组,可以使用多个索引,而不仅仅是一个(像 map 一样)。 |
boost::ptr_set``boost::ptr_unordered_map``boost::ptr_unordered_set``<boost/ptr_container/*.hpp> |
拥有智能指针的集合可能效率较低。指针向量以更高效和更友好的方式管理动态对象的集合。 |
boost::bimap``< boost/bimap.hpp> |
Bimap 是一个关联容器,允许两种类型都作为键使用。 |
boost::heap::binomial_heap``boost::heap::d_ary_heap``boost::heap::fibonacci_heap``boost::heap::pairing_heap``boost::heap::priority_queue``boost::heap::skew_heap``<boost/heap/*.hpp> |
Boost 堆容器实现了priority_queue的更高级、更具功能性的版本。 |
图和属性树
本节讨论了两个专门的 Boost 库,它们在特定领域具有重要作用:建模图和属性树。图是一个对象集合,其中某些对象之间存在配对关系。这些对象被称为顶点,它们之间的关系称为边。图 13-3 展示了一个包含四个顶点和五条边的图。

图 13-3:一个包含四个顶点和五条边的图
每个方框代表一个顶点,每个箭头代表一条边。
属性树是一个存储嵌套键值对的树形结构。属性树的键值对的层次结构使其成为映射和图的混合体;每个键值对与其他键值对之间存在关系。图 13-4 展示了一个包含嵌套键值对的示例属性树。

图 13-4:一个示例属性树
根元素有三个子元素:name、year 和 features。在图 13-4 中,name 的值为 finfisher,year 的值为 2014,features 有三个子元素:process,值为 LSASS,driver,值为 mssounddx.sys,arch,值为 32。
Boost 图形库
Boost 图形库(BGL)是一套用于存储和操作图的集合和算法。BGL 提供了三种表示图的容器:
-
<boost/graph/adjacency_list.hpp>头文件中的boost::adjacency_list -
<boost/graph/adjacency_matrix.hpp>头文件中的boost::adjacency_matrix -
<boost/graph/edge_list.hpp>头文件中的boost::edge_list
你使用两个非成员函数来构建图:boost::add_vertex和boost::add_edge。要向 BGL 图容器中添加一个顶点,你需要将图对象传递给add_vertex,该函数会返回新顶点对象的引用。要添加一条边,我们传递源顶点、目标顶点以及图给add_edge。
BGL 包含了一些特定于图形的算法。你可以通过将图形对象传递给非成员函数boost::num_vertices来计算图中的顶点数,通过boost::num_edges来计算边的数量。你还可以查询图中的相邻顶点。如果两个顶点共享一条边,它们是相邻的。要获取与特定顶点相邻的顶点,你可以将该顶点和图形对象传递给非成员函数boost::adjacent_vertices。这将返回一个半开区间,作为std::pair的迭代器。
清单 13-38 展示了如何构建图 13-3 中表示的图,计算其顶点和边,并计算相邻的顶点。
#include <set>
#include <boost/graph/adjacency_list.hpp>
TEST_CASE("boost::adjacency_list stores graph data") {
boost::adjacency_list<> graph{}; ➊
auto vertex_1 = boost::add_vertex(graph);
auto vertex_2 = boost::add_vertex(graph);
auto vertex_3 = boost::add_vertex(graph);
auto vertex_4 = boost::add_vertex(graph); ➋
auto edge_12 = boost::add_edge(vertex_1, vertex_2, graph);
auto edge_13 = boost::add_edge(vertex_1, vertex_3, graph);
auto edge_21 = boost::add_edge(vertex_2, vertex_1, graph);
auto edge_24 = boost::add_edge(vertex_2, vertex_4, graph);
auto edge_43 = boost::add_edge(vertex_4, vertex_3, graph); ➌
REQUIRE(boost::num_vertices(graph) == 4); ➍
REQUIRE(boost::num_edges(graph) == 5); ➎
auto [begin, end] = boost::adjacent_vertices(vertex_1, graph); ➏
std::set<decltype(vertex_1)> neighboors_1 { begin, end }; ➐
REQUIRE(neighboors_1.count(vertex_2) == 1); ➑
REQUIRE(neighboors_1.count(vertex_3) == 1); ➒
REQUIRE(neighboors_1.count(vertex_4) == 0); ➓
}
清单 13-38:boost::adjacency_list存储图形数据。
在这里,你已经构造了一个名为graph ➊的adjacency_list,然后使用add_vertex ➋添加了四个顶点。接着,使用add_edge ➌添加了图 13-3 中表示的所有边。然后,num_vertices告诉你已添加了四个顶点 ➍,而num_edges告诉你已添加了五条边 ➎。
最后,你已经确定了与vertex_1相邻的adjacent_vertices,并将其拆解为迭代器begin和end ➏。你使用这些迭代器构造一个std::set ➐,用于显示vertex_2 ➑和vertex_3 ➒是相邻的,但vertex_4 不是 ➓。
Boost 属性树
Boost 提供了boost::property_tree::ptree,它位于<boost/property_tree/ptree.hpp>头文件中。这是一个属性树,它允许我们构建和查询属性树,并支持一些有限的序列化为各种格式。
树ptree是默认可构造的。默认构造会构建一个空的ptree。
你可以使用ptree的put方法插入元素,该方法接受路径和数值参数。路径是由一个或多个嵌套键组成的序列,键之间由句点(.)分隔,数值是任意类型的对象。
你可以使用get_child方法从ptree中获取子树,该方法接受所需子树的路径。如果子树没有任何子项(即所谓的叶节点),你还可以使用模板方法get_value从键值对中提取相应的值;get_value接受一个模板参数,该参数对应所需的输出类型。
最后,ptree支持序列化和反序列化为几种格式,包括 Javascript 对象表示法(JSON)、Windows 初始化文件(INI)格式、可扩展标记语言(XML)以及一种名为 INFO 的ptree特定格式。例如,要将ptree以 JSON 格式写入文件,你可以使用<boost/property_tree/json_parser.hpp>头文件中的boost::property_tree::write_json函数。write_json函数接受两个参数:所需输出文件的路径和一个ptree引用。
清单 13-39 通过构建一个表示图 13-4 中属性树的 ptree,将其写入文件作为 JSON,并读取回来,展示了这些基本的 ptree 函数。
#include <boost/property_tree/ptree.hpp>
#include <boost/property_tree/json_parser.hpp>
TEST_CASE("boost::property_tree::ptree stores tree data") {
using namespace boost::property_tree;
ptree p; ➊
p.put("name", "finfisher");
p.put("year", 2014);
p.put("features.process", "LSASS");
p.put("features.driver", "mssounddx.sys");
p.put("features.arch", 32); ➋
REQUIRE(p.get_child("year").get_value<int>() == 2014); ➌
const auto file_name = "rootkit.json";
write_json(file_name, p); ➍
ptree p_copy;
read_json(file_name, p_copy); ➎
REQUIRE(p_copy == p); ➏
}
--------------------------------------------------------------------------
{
"name": "finfisher",
"year": "2014",
"features": {
"process": "LSASS",
"driver": "mssounddx.sys",
"arch": "32"
}
} ➍
清单 13-39:boost::property_tree::ptree 方法存储树形数据。输出显示了 rootkit.json 的内容。
在这里,你默认构造了一个 ptree ➊,并用图 13-4 中显示的键值填充它。具有父级的键(如 arch ➋)使用句点表示适当的路径。通过使用 get_child,你提取了键 year 的子树。因为它是一个叶节点(没有子节点),所以你还调用了 get_value,并将输出类型指定为 int ➌。
接下来,你将 ptree 的 JSON 表示写入文件 rootkit.json ➍。为了确保你得到相同的属性树,你默认构造了另一个名为 p_copy 的 ptree,并将其传递给 read_json ➎。这个副本与原始对象等价 ➏,说明序列化-反序列化操作是成功的。
初始化列表
你可以通过结合 STL 的 <initializer_list> 头文件中的 std::initializer_list 容器,在用户定义的类型中接受初始化列表。initializer_list 是一个类模板,接受一个模板参数,对应于初始化列表中包含的底层类型。这个模板作为访问初始化列表元素的简单代理。
initializer_list 是不可变的,并支持三种操作:
-
size方法返回initializer_list中元素的数量。 -
begin和end方法返回常规的半开区间迭代器。
通常,你应该设计函数以值传递的方式接受 initializer_list。
清单 13-40 实现了一个 SquareMatrix 类,该类存储具有相同行数和列数的矩阵。在内部,该类将元素保存在一个 vector 的 vector 中。
#include <cmath>
#include <stdexcept>
#include <initializer_list>
#include <vector>
size_t square_root(size_t x) { ➊
const auto result = static_cast<size_t>(sqrt(x));
if (result * result != x) throw std::logic_error{ "Not a perfect square." };
return result;
}
template <typename T>
struct SquareMatrix {
SquareMatrix(std::initializer_list<T> val) ➋
: dim{ square_root(val.size()) }, ➌
data(dim, std::vector<T>{}) { ➍
auto itr = val.begin(); ➎
for(size_t row{}; row<dim; row++){
data[row].assign(itr, itr+dim); ➏
itr += dim; ➐
}
}
T& at(size_t row, size_t col) {
if (row >= dim || col >= dim)
throw std::out_of_range{ "Index invalid." }; ➑
return data[row][col]; ➒
}
const size_t dim;
private:
std::vector<std::vector<T>> data;
};
清单 13-40:一个 SquareMatrix 的实现
在这里,你声明了一个方便的 square_root 函数,它用于查找 size_t 的平方根,如果参数不是完全平方数,则抛出异常 ➊。SquareMatrix 类模板定义了一个构造函数,该构造函数接受一个名为 val 的 std::initializer ➋。这允许使用大括号进行初始化。
首先,您需要确定SquareMatrix的尺寸。使用square_root函数计算val.size()的平方根➌,并将其存储到dim字段中,该字段表示SquareMatrix实例的行数和列数。然后,您可以使用dim初始化向量的向量data,使用其填充构造函数➍。这些vector中的每一个都对应于SquareMatrix中的一行。接下来,您提取一个指向initializer_list中第一个元素的迭代器➎。您迭代SquareMatrix中的每一行,将相应的vector分配给适当的半开区间➏。您在每次迭代时递增迭代器,以指向下一行➐。
最后,您实现了一个at方法来允许元素访问。您执行边界检查➑,然后通过提取适当的vector和元素➒返回对所需元素的引用。
列表 13-41 说明了如何使用带花括号初始化生成SquareMatrix对象。
TEST_CASE("SquareMatrix and std::initializer_list") {
SquareMatrix<int> mat { ➊
1, 2, 3, 4,
5, 0, 7, 8,
9, 10, 11, 12,
13, 14, 15, 16
};
REQUIRE(mat.dim == 4); ➋
mat.at(1, 1) = 6; ➌
REQUIRE(mat.at(1, 1) == 6); ➍
REQUIRE(mat.at(0, 2) == 3); ➎
}
列表 13-41:使用带花括号初始化器的SquareMatrix
您使用花括号初始化器设置了SquareMatrix➊。因为初始化列表包含 16 个元素,所以最终得到了dim为 4➋。您可以使用at来获取任何元素的引用,这意味着您可以设置➌和获取➍➎元素。
摘要
本章始于讨论两个主要的序列容器,array和vector,它们在广泛的应用中提供了性能和功能的良好平衡。接下来,您了解了几种序列容器——deque、list、stack、queue、priority_queue和bitset——它们在vector无法满足特定应用要求时提供了解决方案。然后,您探讨了主要的关联容器,set和map,以及它们的无序/多重排列。您还了解了两个小众 Boost 容器,graph和ptree。本章以简短讨论如何将initializer_list集成到用户定义类型中结束。
练习
13-1. 编写一个程序,用于默认构造一个std::vector的无符号长整型。打印vector的capacity,然后reserve 10 个元素。接下来,将斐波那契序列的前 20 个元素追加到vector中。再次打印capacity。capacity是否与vector中的元素数量匹配?为什么?打印vector的元素,使用 range-based for循环。
13-2. 使用std::array重写列表 2-9,2-10 和 2-11 在第二章中。
13-3. 编写一个接受任意数量命令行参数并按字母数字顺序打印它们的程序。使用std::set<const char*>存储元素,然后迭代set以获取排序结果。您需要实现一个自定义比较器来比较两个 C 风格字符串。
13-4. 编写一个程序,默认构造一个无符号长整型的 std::vector。打印 vector 的 capacity,然后 reserve 10 个元素。接下来,将 Fibonacci 序列的前 20 个元素追加到该向量中。再次打印 capacity。capacity 是否与向量中的元素数量相匹配?为什么或为什么不匹配?使用基于范围的 for 循环打印 vector 的元素。
13-5. 考虑以下程序,它对一个计算 Fibonacci 序列和的函数进行性能分析:
#include <chrono>
#include <cstdio>
#include <random>
long fib_sum(size_t n) { ➊
// TODO: Adapt code from Exercise 12.1
return 0;
}
long random() { ➋
static std::mt19937_64 mt_engine{ 102787 };
static std::uniform_int_distribution<long> int_d{ 1000, 2000 };
return int_d(mt_engine);
}
struct Stopwatch { ➌
Stopwatch(std::chrono::nanoseconds& result)
: result{ result },
start{ std::chrono::system_clock::now() } { }
~Stopwatch() {
result = std::chrono::system_clock::now() - start;
}
private:
std::chrono::nanoseconds& result;
const std::chrono::time_point<std::chrono::system_clock> start;
};
long cached_fib_sum(const size_t& n) { ➍
static std::map<long, long> cache;
// TODO: Implement me
return 0;
}
int main() {
size_t samples{ 1'000'000 };
std::chrono::nanoseconds elapsed;
{
Stopwatch stopwatch{elapsed};
volatile double answer;
while(samples--) {
answer = fib_sum(random()); ➎
//answer = cached_fib_sum(random()); ➏
}
}
printf("Elapsed: %g s.\n", elapsed.count() / 1'000'000'000.); ➐
}
该程序包含一个计算密集型的函数 fib_sum ➊,用于计算给定长度的 Fibonacci 序列的和。从练习 13-1 中改编代码,(a)生成适当的向量,(b)使用基于范围的 for 循环计算结果和。random 函数 ➋ 返回一个介于 1,000 和 2,000 之间的随机数,Stopwatch 类 ➌ 从 Listing 12-25 在 第十二章 中借用,帮助你确定经过的时间。在程序的 main 中,你对 fib_sum 函数进行百万次评估,使用随机输入 ➎。你会计时并在退出程序 ➐ 前打印结果。编译程序并运行几次,以了解程序的运行时间。(这被称为 基准线。)
13-6. 接下来,注释掉 ➎ 并取消注释 ➏。实现函数 cached_fib_sum ➍,首先检查是否已经为给定的长度计算了 fib_sum。(将长度 n 视为缓存的键。) 如果缓存中存在该键,则直接返回结果。如果键不存在,则使用 fib_sum 计算正确的答案,将新的键值对存入 cache,然后返回结果。重新运行程序。它是否更快了?尝试使用 unordered_map 而不是 map。你能使用 vector 吗?你能让程序运行得有多快?
实现一个类似于 Listing 13-38 中的 SquareMatrix 的矩阵类。你的 Matrix 应该允许行列数不相等。将构造函数的第一个参数设为 Matrix 的行数。
进一步阅读
-
ISO 国际标准 ISO/IEC (2017) — 编程语言 C++(国际标准化组织;瑞士日内瓦;
isocpp.org/std/the-standard/) -
《Boost C++ 库》,第二版,Boris Schäling 著(XML Press,2014)
-
《C++ 标准库:教程与参考》,第二版,Nicolai M. Josuttis 著(Addison-Wesley Professional,2012)
第十七章:迭代器**
*说“朋友”然后进入。
—J.R.R. 托尔金*, 《魔戒》

迭代器是 STL 组件,用于提供容器和算法之间的接口以操作它们。迭代器是一个接口,指向知道如何遍历特定序列的类型,并暴露类似指针的简单操作来访问元素。
每个迭代器至少支持以下操作:
-
访问当前元素(
operator*)进行读取和/或写入 -
转到下一个元素(
operator++) -
拷贝构造
迭代器根据支持的额外操作进行分类。这些类别决定了哪些算法可用,以及你可以在通用代码中对迭代器做什么。在本章中,你将学习这些迭代器类别、便利函数和适配器。
迭代器类别
迭代器的类别决定了它支持的操作。这些操作包括读取和写入元素、前向和后向遍历、重复读取和访问随机元素。
由于接受迭代器的代码通常是通用的,因此迭代器的类型通常是一个模板参数,你可以通过概念来编码它,正如你在“概念”一章中学习的那样,见第 163 页。尽管你可能不需要直接与迭代器交互(除非你在编写库),但你仍然需要了解迭代器的类别,以免将算法应用于不合适的迭代器。如果你这么做了,可能会遇到难以理解的编译器错误。回想一下在“模板中的类型检查”一章中提到的内容,见第 161 页,由于模板实例化的方式,从不适当类型参数生成的错误消息通常是难以理解的。
输出迭代器
你可以使用 输出迭代器 来写入并递增,但不能做其他操作。可以将输出迭代器视为一个无底洞,你将数据丢入其中。
使用输出迭代器时,你先写入,再递增,再写入,再递增,一直如此。一旦你向输出迭代器写入内容,在至少递增一次之前不能再写入。同样,向输出迭代器递增之后,在写入之前不能再递增。
要向输出迭代器写入内容,使用解引用操作符(*)解引用迭代器,并将值赋给结果引用。要递增输出迭代器,使用 operator++ 或 operator++(int)。
再次强调,除非你在编写 C++ 库,否则不太可能需要实现自己的输出迭代器类型;然而,你会频繁使用它们。
一个常见的用法是像使用输出迭代器一样写入容器。为此,你可以使用插入迭代器。
插入迭代器
插入迭代器(或 插入器)是一个输出迭代器,它包装一个容器,并将写操作(赋值)转化为插入操作。STL 的 <iterator> 头文件中有三个插入迭代器,作为类模板存在:
-
std::back_insert_iterator -
std::front_insert_iterator -
std::insert_iterator
STL 还提供了三个便利函数,用于构建这些迭代器:
-
std::back_inserter -
std::front_inserter -
std::inserter
back_insert_iterator 将迭代器写入操作转换为对容器 push_back 的调用,而 front_insert_iterator 则是调用 push_front。这两种插入迭代器都公开了一个接受容器引用的构造函数,而它们对应的便利函数只接受一个参数。显然,被包装的容器必须实现相应的方法。例如,vector 不适用于 front_insert_iterator,而 set 也不适用于这两者。
insert_iterator 接受两个构造函数参数:一个容器用于包装,另一个是指向该容器中某个位置的迭代器。然后,insert_iterator 将写入操作转换为对容器 insert 方法的调用,并将你在构造时提供的位置作为第一个参数。例如,你可以使用 insert_iterator 将元素插入到顺序容器的中间,或者在 set 中带提示地添加元素。
注意
在内部,所有的插入迭代器完全忽略 operator++、operator++(int) 和 operator*。容器不需要在插入之间执行这个中间步骤,但这通常是输出迭代器的一个要求。
代码示例 14-1 通过向 deque 添加元素,展示了三种插入迭代器的基本用法。
#include <deque>
#include <iterator>
TEST_CASE("Insert iterators convert writes into container insertions.") {
std::deque<int> dq;
auto back_instr = std::back_inserter(dq); ➊
*back_instr = 2; ➋ // 2
++back_instr; ➌
*back_instr = 4; ➍ // 2 4
++back_instr;
auto front_instr = std::front_inserter(dq); ➎
*front_instr = 1; ➏ // 1 2 4
++front_instr;
auto instr = std::inserter(dq, dq.begin()+2); ➐
*instr = 3; ➑ // 1 2 3 4
instr++;
REQUIRE(dq[0] == 1);
REQUIRE(dq[1] == 2);
REQUIRE(dq[2] == 3);
REQUIRE(dq[3] == 4); ➒
}
代码示例 14-1:插入迭代器将写入操作转换为容器插入。
首先,你使用 back_inserter 构建一个 back_insert_iterator 来包装一个名为 dq 的 deque ➊。当你向 back_insert_iterator 写入时,它会将写入操作转换为 push_back,因此 deque 只包含一个元素 2 ➋。因为输出迭代器在再次写入之前需要进行递增操作,所以你接着进行一次递增 ➌。当你向 back_insert_iterator 写入 4 时,它会再次将写入操作转换为 push_back,使得 deque 包含元素 2 4 ➍。
接下来,你可以使用 front_inserter 构建一个 front_insert_iterator 来包装 dq ➎。将 1 写入这个新构造的插入器时,它会调用 push_front,因此双端队列包含元素 1 2 4 ➏。
最后,你可以通过传递 dq 和一个指向其第三个元素(4)的迭代器,使用 inserter 构建一个 insert_iterator。当你向这个插入器写入 3 ➑ 时,它会将元素插入到由构造时传入的迭代器所指向元素之前 ➐。最终,dq 包含元素 1 2 3 4 ➒。
表 14-1 总结了插入迭代器。
表 14-1: 插入迭代器汇总
| 类 | 便利函数 | 委托函数 | 示例容器 |
|---|---|---|---|
back_insert_iterator |
back_inserter |
push_back |
向量、双端队列、列表 |
front_insert_iterator |
front_inserter |
push_front |
双端队列、列表 |
insert_iterator |
inserter |
insert |
向量,双端队列,列表,集合 |
支持的输出迭代器操作列表
表 14-2 总结了输出迭代器支持的操作。
表 14-2: 输出迭代器支持的操作
| 操作 | 备注 |
|---|---|
*itr=t |
写入输出迭代器。操作后,迭代器可以递增,但不一定可以解引用。 |
++itr++ |
递增迭代器。操作后,迭代器要么可以解引用,要么已耗尽(超出末尾),但不一定可以递增。 |
迭代器类型{ itr } |
从itr拷贝构造一个迭代器。 |
输入迭代器
你可以使用输入迭代器来读取、递增并检查相等性。它是输出迭代器的对立面。你只能通过输入迭代器遍历一次。
从输入迭代器读取时的常见模式是获取一个半开范围,包含begin和end迭代器。要遍历这个范围,你使用operator*读取begin迭代器,然后通过operator++递增。接下来,你判断迭代器是否等于end。如果是,你已经耗尽了范围。如果不是,你可以继续读取/递增。
注意
输入迭代器是使“基于范围的for循环”在第 234 页中讨论的范围表达式工作的魔法。
输入迭代器的一个典型用法是包装程序的标准输入(通常是键盘)。一旦从标准输入读取一个值,它就消失了。你不能返回开始并重播。这种行为与输入迭代器支持的操作非常契合。
在“迭代器速成课程”中,你学习到每个容器都暴露了具有begin/cbegin/end/cend方法的迭代器。所有这些方法至少是输入迭代器(并且可能支持额外的功能)。例如,清单 14-2 演示了如何从forward_list中提取范围并手动操作迭代器以进行读取。
#include <forward_list>
TEST_CASE("std::forward_list begin and end provide input iterators") {
const std::forward_list<int> easy_as{ 1, 2, 3 }; ➊
auto itr = easy_as.begin(); ➋
REQUIRE(*itr == 1); ➌
itr++; ➍
REQUIRE(*itr == 2);
itr++;
REQUIRE(*itr == 3);
itr++;
REQUIRE(itr == easy_as.end()); ➎
}
清单 14-2:与来自forward_list的输入迭代器交互
你创建一个包含三个元素的forward_list ➊。容器的常量性意味着元素是不可变的,因此迭代器仅支持读取操作。你通过forward_list的begin方法提取一个迭代器 ➋。使用operator*,你提取由itr指向的元素 ➌,并随后执行必要的递增操作 ➍。一旦通过读取/递增耗尽了范围,itr等于forward_list的end ➎。
表 14-3 总结了输入迭代器支持的操作。
表 14-3: 输入迭代器支持的操作
| 操作 | 备注 |
|---|---|
*itr |
解引用指向的成员。可能是只读的,也可能不是。 |
itr->mbr |
解引用迭代器指向的对象的成员 mbr。 |
++itr itr++ |
递增迭代器。操作后,迭代器要么可解引用,要么已被耗尽(超出末尾)。 |
itr1 == itr2itr1 != itr2 |
比较迭代器是否相等(是否指向相同元素)。 |
iterator-type{ itr } |
通过迭代器 itr 复制构造一个新的迭代器。 |
前向迭代器
前向迭代器是一种具备额外功能的输入迭代器:前向迭代器还可以进行多次遍历、默认构造和复制赋值。在所有情况下,你都可以使用前向迭代器代替输入迭代器。
所有 STL 容器都提供前向迭代器。因此,在示例 14-2 中使用的forward_list实际上提供了一个前向迭代器(它也是一个输入迭代器)。
示例 14-3 更新了示例 14-2,使其能够多次遍历forward_list。
TEST_CASE("std::forward_list’s begin and end provide forward iterators") {
const std::forward_list<int> easy_as{ 1, 2, 3 }; ➊
auto itr1 = easy_as.begin(); ➋
auto itr2{ itr1 }; ➌
int double_sum{};
while (itr1 != easy_as.end()) ➍
double_sum += *(itr1++);
while (itr2 != easy_as.end()) ➎
double_sum += *(itr2++);
REQUIRE(double_sum == 12); ➏
}
示例 14-3:双向遍历前向迭代器两次
同样,你创建了一个包含三个元素的forward_list ➊。你通过forward_list的begin方法提取出一个名为itr1的迭代器 ➋,然后创建一个名为itr2的副本 ➌。你遍历itr1 ➍ 和itr2 ➎,在两次遍历过程中求和。最终的double_sum等于 12 ➏。
表 14-4 总结了前向迭代器支持的操作。
表 14-4: 前向迭代器支持的操作
| 操作 | 说明 |
|---|---|
*itr |
解引用所指向的成员。可能是只读的,也可能不是。 |
itr->mbr |
解引用迭代器 itr 所指向对象的成员 mbr。 |
++itr itr++ |
递增迭代器,使其指向下一个元素。 |
itr1 == itr2itr1 != itr2 |
比较迭代器是否相等(是否指向相同元素)。 |
iterator-type{} |
默认构造一个迭代器。 |
iterator-type{ itr } |
通过迭代器 itr 复制构造一个新的迭代器。 |
itr1 = itr2 |
将迭代器 itr2 赋值给 itr1。 |
双向迭代器
双向迭代器是一种前向迭代器,它也可以向后迭代。你可以在所有情况下使用双向迭代器代替前向或输入迭代器。
双向迭代器允许使用operator--和operator—(int)进行反向迭代。提供双向迭代器的 STL 容器包括array, list, deque, vector,以及所有有序关联容器。
示例 14-4 展示了如何使用list的双向迭代器进行双向遍历。
#include <list>
TEST_CASE("std::list begin and end provide bidirectional iterators") {
const std::list<int> easy_as{ 1, 2, 3 }; ➊
auto itr = easy_as.begin(); ➋
REQUIRE(*itr == 1); ➌
itr++; ➍
REQUIRE(*itr == 2);
itr--; ➎
REQUIRE(*itr == 1); ➏
REQUIRE(itr == easy_as.cbegin());
}
示例 14-4:std::list的方法begin和end提供双向迭代器。
在这里,你创建了一个包含三个元素的list ➊。你通过list的begin方法提取出一个迭代器,命名为itr ➋。像输入迭代器和前向迭代器一样,你可以解引用 ➌ 并递增 ➍ 该迭代器。此外,你还可以递减迭代器 ➎,以便回到已经遍历过的元素 ➏。
表 14-5 总结了双向迭代器支持的操作。
表 14-5: 双向迭代器支持的操作
| 操作 | 说明 |
|---|---|
*itr |
解引用指向的成员,可能是只读的,也可能不是。 |
itr->mbr |
解引用迭代器指向对象的成员 mbr。 |
++itritr++ |
递增迭代器,使其指向下一个元素。 |
--itritr-- |
递减迭代器,使其指向前一个元素。 |
itr1 == itr2itr1 != itr2 |
比较两个迭代器是否相等(指向相同元素)。 |
iterator-type{} |
默认构造一个迭代器。 |
iterator-type{ itr } |
从 itr 复制构造一个迭代器。 |
itr1 = itr2 |
将迭代器 itr1 赋值为 itr2。 |
随机访问迭代器
一个随机访问迭代器是一个支持随机元素访问的双向迭代器。你可以在所有情况下使用随机访问迭代器替代双向、前向和输入迭代器。
随机访问迭代器允许使用operator[]进行随机访问,还支持迭代器算术运算,比如加法或减法整数值,以及通过减法计算其他迭代器之间的距离。提供随机访问迭代器的 STL 容器有array、vector和deque。列表 14-5 展示了如何通过随机访问迭代器从vector访问任意元素。
#include <vector>
TEST_CASE("std::vector begin and end provide random-access iterators") {
const std::vector<int> easy_as{ 1, 2, 3 }; ➊
auto itr = easy_as.begin(); ➋
REQUIRE(itr[0] == 1); ➌
itr++; ➍
REQUIRE(*(easy_as.cbegin() + 2) == 3); ➎
REQUIRE(easy_as.cend() - itr == 2); ➏
}
列表 14-5:与随机访问迭代器的交互
你创建了一个包含三个元素的vector ➊。你通过vector的begin方法提取出一个叫itr的迭代器 ➋。由于这是一个随机访问迭代器,你可以使用operator[]来解引用任意元素 ➌。当然,你仍然可以使用operator++来递增迭代器 ➍。你还可以通过加法或减法来操作迭代器,以访问给定偏移量的元素 ➎➏。
支持的随机访问迭代器操作列表
表 14-6 总结了随机访问迭代器支持的操作。
表 14-6: 随机访问迭代器支持的操作
| 操作 | 说明 |
|---|---|
itr[n] |
解引用索引为 n 的元素。 |
itr+nitr-n |
返回位于 itr 偏移量 n 处的迭代器。 |
itr2-itr1 |
计算 itr1 和 itr2 之间的距离。 |
*itr |
解引用指向的成员,可能是只读的,也可能不是。 |
itr->mbr |
解引用迭代器指向对象的成员 mbr。 |
++itritr++ |
递增迭代器,使其指向下一个元素。 |
--itritr-- |
递减迭代器,使其指向前一个元素。 |
itr1 == itr2itr1 != itr2 |
比较两个迭代器是否相等(指向相同元素)。 |
iterator-type{} |
默认构造一个迭代器。 |
iterator-type{ itr } |
从 itr 复制构造一个迭代器。 |
itr1 < itr2itr1 > itr2itr1 <= itr2itr1 >= itr2 |
执行对应的迭代器位置比较。 |
连续迭代器
连续迭代器 是一种随机访问迭代器,其元素在内存中是相邻的。对于一个连续迭代器 itr,所有元素 itr[n] 和 itr[n+1] 对于所有有效的索引 n 和偏移量 i,都满足以下关系:
&itr[n] + i == &itr[n+i]
vector 和 array 容器提供连续的迭代器,但 list 和 deque 不提供。
可变迭代器
所有的前向迭代器、双向迭代器、随机访问迭代器和连续迭代器都可以支持只读模式或读写模式。如果一个迭代器支持读写,你可以将值赋给通过解引用迭代器返回的引用。这种迭代器被称为 可变迭代器。例如,一个支持读取和写入的双向迭代器被称为可变双向迭代器。
到目前为止,在每个例子中,用于支撑迭代器的容器都是 const。这会产生指向 const 对象的迭代器,这当然是不可写的。清单 14-6 从一个(非 const)deque 中提取出一个可变的随机访问迭代器,允许你对容器的任意元素进行写操作。
#include <deque>
TEST_CASE("Mutable random-access iterators support writing.") {
std::deque<int> easy_as{ 1, 0, 3 }; ➊
auto itr = easy_as.begin(); ➋
itr[1] = 2; ➌
itr++; ➍
REQUIRE(*itr == 2); ➎
}
清单 14-6:一个可变的随机访问迭代器允许写操作。
你构造一个包含三个元素的 deque ➊,然后获取一个指向第一个元素的迭代器 ➋。接下来,你将值 2 写入第二个元素 ➌。然后,你增加迭代器,使其指向刚刚修改的元素 ➍。当你解引用该元素时,你将得到你写入的值 ➎。
图 14-1 说明了输入迭代器与其所有功能更强的后代之间的关系。

图 14-1:输入迭代器类别及其嵌套关系
总结来说,输入迭代器仅支持读取和递增。前向迭代器也是输入迭代器,因此它们也支持读取和递增,但还允许你多次迭代它们的范围(“多次遍历”)。双向迭代器也是前向迭代器,但它们额外允许进行递减操作。随机访问迭代器也是双向迭代器,但你可以直接访问序列中的任意元素。最后,连续迭代器是随机访问迭代器,它保证其元素在内存中是连续的。
辅助迭代器函数
如果你编写处理迭代器的通用代码,应该使用来自 <iterator> 头文件的 辅助迭代器函数 来操作迭代器,而不是直接使用支持的操作。这些迭代器函数执行常见的任务,如遍历、交换和计算迭代器之间的距离。使用辅助函数而不是直接操作迭代器的主要优势在于,辅助函数会检查迭代器的类型特征,并确定执行所需操作的最有效方法。此外,辅助迭代器函数使通用代码更加通用,因为它可以适用于更广泛的迭代器。
std::advance
std::advance 辅助迭代器函数允许你按所需的数量递增或递减。此函数模板接受一个迭代器引用和一个整数值,该值对应你希望移动迭代器的距离:
void std::advance(InputIterator&➊ itr, Distance➋ d);
InputIterator 模板参数必须至少是输入迭代器 ➊,而 Distance 模板参数通常是一个整数 ➋。
advance 函数不执行边界检查,因此必须确保没有超出迭代器位置的有效范围。
根据迭代器的类别,advance 将执行最有效的操作来实现所需的效果:
输入迭代器 advance 函数将调用 itr++ 正确次数;dist 不能为负数。
双向迭代器 该函数将调用 itr++ 或 itr-- 正确次数。
随机访问迭代器 它将调用 itr+=dist;dist 可以是负数。
注意
随机访问迭代器在使用 advance 时会比其他类型的迭代器更高效,因此如果你想避免最坏情况(线性时间)的性能,可以考虑使用 operator+= 而不是 advance。
示例 14-7 展示了如何使用 advance 操作随机访问迭代器。
#include <iterator>
TEST_CASE("advance modifies input iterators") {
std::vector<unsigned char> mission{ ➊
0x9e, 0xc4, 0xc1, 0x29,
0x49, 0xa4, 0xf3, 0x14,
0x74, 0xf2, 0x99, 0x05,
0x8c, 0xe2, 0xb2, 0x2a
};
auto itr = mission.begin(); ➋
std::advance(itr, 4); ➌
REQUIRE(*itr == 0x49);
std::advance(itr, 4); ➍
REQUIRE(*itr == 0x74);
std::advance(itr, -8); ➎
REQUIRE(*itr == 0x9e);
}
示例 14-7:使用 advance 操作连续迭代器
这里,你初始化了一个名为 mission 的 vector,包含 16 个 unsigned char 对象 ➊。接着,你通过 mission 的 begin 方法提取一个名为 itr 的迭代器 ➋,并在 itr 上调用 advance 将其前进四个元素,使其指向第四个元素(值为 0x49) ➌。再前进四个元素,指向第八个元素(值为 0x74) ➍。最后,你调用 advance 将迭代器后退 8 个值,使其再次指向第一个元素(值为 0x9e) ➎。
std::next 和 std::prev
std::next 和 std::prev 辅助迭代器函数是函数模板,用于计算相对于给定迭代器的偏移量。它们返回一个指向所需元素的新迭代器,而不会修改原始迭代器,如下所示:
ForwardIterator std::next(ForwardIterator& itr➊, Distance d=1➋);
BidirectionalIterator std::prev(BidirectionalIterator& itr➌, Distance d=1➍);
next 函数接受至少一个前向迭代器 ➊,并且可选地接受一个距离 ➋,它返回一个指向相应偏移量的迭代器。如果 itr 是双向迭代器,这个偏移量可以是负数。prev 函数模板与 next 在反向操作时类似:它至少接受一个双向迭代器 ➌,并且可选地接受一个距离 ➍(这个距离可以是负数)。
next 和 prev 都不执行边界检查。这意味着你必须确保自己的数学计算是正确的,并且在序列范围内,否则你会遇到未定义的行为。
注意
对于 next 和 prev,除非是右值,否则 itr 保持不变,在这种情况下会使用 advance 来提高效率。
示例 14-8 演示了如何使用 next 获取一个新的迭代器,指向给定偏移量处的元素。
#include <iterator>
TEST_CASE("next returns iterators at given offsets") {
std::vector<unsigned char> mission{
0x9e, 0xc4, 0xc1, 0x29,
0x49, 0xa4, 0xf3, 0x14,
0x74, 0xf2, 0x99, 0x05,
0x8c, 0xe2, 0xb2, 0x2a
};
auto itr1 = mission.begin(); ➊
std::advance(itr1, 4); ➋
REQUIRE(*itr1 == 0x49); ➌
auto itr2 = std::next(itr1); ➍
REQUIRE(*itr2 == 0xa4); ➎
auto itr3 = std::next(itr1, 4); ➏
REQUIRE(*itr3 == 0x74); ➐
REQUIRE(*itr1 == 0x49); ➑
}
示例 14-8:使用 next 获取迭代器的偏移量
如同 示例 14-7 中所示,你初始化一个包含 16 个 unsigned char 的 vector,并提取一个指向第一个元素的迭代器 itr1 ➊。你使用 advance 将迭代器递增四个元素 ➋,使其指向值为 0x49 的元素 ➌。第一次使用 next 时省略了距离参数,默认为 1 ➍。这会生成一个新的迭代器 itr2,它指向 itr1 后一个元素 ➎。
你第二次调用 next,并传递一个距离参数 4 ➏。这会产生另一个新的迭代器 itr3,它指向 itr1 后四个元素 ➐。这些调用都不会影响原始的迭代器 itr1 ➑。
std::distance
std::distance 辅助迭代器函数允许你计算两个输入迭代器 itr1 和 itr2 之间的距离:
Distance std::distance(InputIterator itr1, InputIterator itr2);
如果迭代器不是随机访问的,itr2 必须指向 itr1 后面的元素。确保 itr2 在 itr1 之后是个好习惯,因为如果你不小心违反了这一要求并且迭代器不是随机访问的,你将遇到未定义的行为。
示例 14-9 演示了如何计算两个随机访问迭代器之间的距离。
#include <iterator>
TEST_CASE("distance returns the number of elements between iterators") {
std::vector<unsigned char> mission{ ➊
0x9e, 0xc4, 0xc1, 0x29,
0x49, 0xa4, 0xf3, 0x14,
0x74, 0xf2, 0x99, 0x05,
0x8c, 0xe2, 0xb2, 0x2a
};
auto eighth = std::next(mission.begin(), 8); ➋
auto fifth = std::prev(eighth, 3); ➌
REQUIRE(std::distance(fifth, eighth) == 3); ➍
}
示例 14-9:使用 distance 获取迭代器之间的距离
在初始化 vector ➊ 后,你使用 std::next ➋ 创建一个指向 第八 个元素的迭代器。你在 第八 个元素上使用 std::prev,通过传递 3 作为第二个参数 ➌ 来获得指向 第五 个元素的迭代器。当你将 第五 个元素和 第八 个元素作为参数传递给 distance 时,你会得到 3 ➍。
std::iter_swap
std::iter_swap 辅助迭代器函数允许你交换两个前向迭代器 itr1 和 itr2 指向的值:
Distance std::iter_swap(ForwardIterator itr1, ForwardIterator itr2);
迭代器不需要具有相同的类型,只要它们指向的类型可以相互赋值。示例 14-10 演示了如何使用 iter_swap 交换两个 vector 元素。
#include <iterator>
TEST_CASE("iter_swap swaps pointed-to elements") {
std::vector<long> easy_as{ 3, 2, 1 }; ➊
std::iter_swap(easy_as.begin()➋, std::next(easy_as.begin(), 2)➌);
REQUIRE(easy_as[0] == 1); ➍
REQUIRE(easy_as[1] == 2);
REQUIRE(easy_as[2] == 3);
}
示例 14-10:使用 iter_swap 交换指向的元素
在构造一个元素为3 2 1的vector之后 ➊,你对第一个元素 ➋ 和最后一个元素 ➌ 调用iter_swap。交换后,vector包含元素1 2 3 ➍。
其他迭代器适配器
除了插入迭代器,STL 还提供了移动迭代器适配器和逆向迭代器适配器来修改迭代器行为。
注意
STL 还提供了流迭代器适配器,你将在第十六章中学习它们,内容涉及流。
移动迭代器适配器
移动迭代器适配器是一个类模板,它将所有迭代器访问转换为移动操作。头文件<iterator>中的便利函数模板std::make_move_iterator接受一个迭代器参数并返回一个移动迭代器适配器。
移动迭代器适配器的经典用法是将一系列对象移动到一个新的容器中。考虑清单 14-11 中的玩具类Movable,它存储一个名为id的int值。
struct Movable{
Movable(int id) : id{ id } { } ➊
Movable(Movable&& m) {
id = m.id; ➋
m.id = -1; ➌
}
int id;
};
清单 14-11:Movable类存储一个int。
Movable的构造函数接受一个int并将其存储到id字段中 ➊。Movable也是可移动构造的;它将从其移动构造函数参数中偷取id ➋,并将其替换为−1 ➌。
清单 14-12 构造了一个Movable对象的vector,名为donor,并将它们移动到一个名为recipient的vector中。
#include <iterator>
TEST_CASE("move iterators convert accesses into move operations") {
std::vector<Movable> donor; ➊
donor.emplace_back(1); ➋
donor.emplace_back(2);
donor.emplace_back(3);
std::vector<Movable> recipient{
std::make_move_iterator(donor.begin()), ➌
std::make_move_iterator(donor.end()),
};
REQUIRE(donor[0].id == -1); ➍
REQUIRE(donor[1].id == -1);
REQUIRE(donor[2].id == -1);
REQUIRE(recipient[0].id == 1); ➎
REQUIRE(recipient[1].id == 2);
REQUIRE(recipient[2].id == 3);
}
清单 14-12:使用移动迭代器适配器将迭代器操作转换为移动操作
在这里,你默认构造一个名为donor的vector ➊,并用id字段为 1、2 和 3 的三个Movable对象调用emplace_back ➋。然后,你使用vector的范围构造函数,传入donor的begin和end迭代器,并将它们传递给make_move_iterator ➌。这将所有迭代器操作转换为移动操作,因此会调用Movable的移动构造函数。结果,donor的所有元素都处于已移动状态 ➍,而recipient的所有元素与donor之前的元素相匹配 ➎。
逆向迭代器适配器
逆向迭代器适配器是一个类模板,它交换迭代器的增量和递减操作符。其效果是通过应用逆向迭代器适配器,你可以反转算法的输入。一个常见的使用逆向迭代器的场景是从容器的末尾向后搜索。例如,假设你将日志推送到一个deque的末尾,并希望查找满足某些条件的最新条目。
几乎所有容器都在第十三章中暴露了逆向迭代器,提供rbegin/rend/crbegin/crend方法。例如,你可以创建一个容器,它包含另一个容器的逆序,如清单 14-13 所示。
TEST_CASE("reverse iterators can initialize containers") {
std::list<int> original{ 3, 2, 1 }; ➊
std::vector<int> easy_as{ original.crbegin(), original.crend() }; ➋
REQUIRE(easy_as[0] == 1); ➌
REQUIRE(easy_as[1] == 2);
REQUIRE(easy_as[2] == 3);
}
清单 14-13:创建一个包含另一个容器元素逆序的容器
在这里,你创建了一个包含元素3 2 1的list ➊。接着,你使用crbegin和crend方法构建了一个反向顺序的vector ➋。这个vector包含1 2 3,是list元素的反向顺序 ➌。
尽管容器通常直接暴露反向迭代器,但你也可以手动将正常的迭代器转换为反向迭代器。<iterator>头文件中的便利函数模板std::make_reverse_iterator接受一个单一的迭代器参数,并返回一个反向迭代器适配器。
反向迭代器设计用于与半开区间配合使用,这些半开区间与正常的半开区间正好相反。在内部,反向半开区间有一个rbegin迭代器,指向半开区间的end后一个位置,并且有一个rend迭代器,指向半开区间的begin,如图 14-2 所示。

图 14-2:一个反向半开区间
然而,这些实现细节对用户是透明的。迭代器会按预期进行解引用。只要区间不为空,你就可以解引用反向开始迭代器,它将返回第一个元素。但你不能解引用反向结束迭代器。
为什么要引入这种表示上的复杂性?通过这种设计,你可以轻松地交换半开区间的开始和结束迭代器,以生成一个反向半开区间。例如,列表 14-14 使用std::make_reverse_iterator将普通迭代器转换为反向迭代器,实现了与列表 14-13 相同的任务。
TEST_CASE("make_reverse_iterator converts a normal iterator") {
std::list<int> original{ 3, 2, 1 };
auto begin = std::make_reverse_iterator(original.cend()); ➊
auto end = std::make_reverse_iterator(original.cbegin()); ➋
std::vector<int> easy_as{ begin, end }; ➌
REQUIRE(easy_as[0] == 1);
REQUIRE(easy_as[1] == 2);
REQUIRE(easy_as[2] == 3);
}
列表 14-14:make_reverse_iterator函数将普通迭代器转换为反向迭代器
特别注意你从original中提取的迭代器。要创建begin迭代器,你需要从original中提取一个end迭代器,并将其传递给make_reverse_iterator ➊。反向迭代器适配器会交换递增和递减操作符,但它需要从正确的位置开始。同样,你需要在原始序列的开始处终止,因此你将cbegin的结果传递给make_reverse_iterator,以生成正确的结束迭代器 ➋。将这些传递给easy_as的范围构造器 ➌,会产生与列表 14-13 相同的结果。
注意
所有反向迭代器都暴露一个base方法,该方法将反向迭代器转换回普通迭代器。
总结
在这一短小的章节中,你学习了所有的迭代器类别:输出迭代器、输入迭代器、前向迭代器、双向迭代器、随机访问迭代器和连续迭代器。了解每种类别的基本特性为你提供了一个框架,用于理解容器如何与算法连接。该章节还介绍了迭代器适配器,使你能够自定义迭代器行为,以及辅助迭代器函数,帮助你用迭代器编写通用代码。
练习
14-1. 使用std::prev而不是std::next,创建一个与清单 14-8 相应的推论。
14-2. 编写一个名为sum的函数模板,该模板接受一个半开区间的int对象并返回该序列的和。
14-3. 编写一个程序,使用清单 12-25 中的Stopwatch类来确定在给定来自大std::forward_list和大std::vector的正向迭代器时,std::advance的运行时性能。随着容器中元素数量的增加,运行时性能如何变化?(尝试数十万或数百万个元素。)
进一步阅读
-
C++标准库:教程与参考,第 2 版,作者 Nicolai M. Josuttis(Addison-Wesley Professional,2012 年)
-
C++模板:完全指南,第 2 版,作者 David Vandevoorde 等人(Addison-Wesley,2017 年)
第十八章:STRINGS**
如果你用一个人能理解的语言和他交谈,那会打动他的头脑。如果你用他的语言和他说话,那会打动他的心。
—纳尔逊·曼德拉*

STL 提供了一种专门的 字符串容器 用于处理人类语言数据,如单词、句子和标记语言。std::basic_string 是一个类模板,可以根据字符串的底层字符类型进行特化,位于 <string> 头文件中。作为一个顺序容器,basic_string 本质上类似于 vector,但具有一些特殊的功能,用于处理语言数据。
STL 的 basic_string 在安全性和功能性上相较于 C 风格的字符串或空终止字符串有了显著提升,而且由于人类语言数据充斥着现代程序,你很可能会发现 basic_string 是不可或缺的。
std::string
STL 提供了四种 basic_string 特化形式,在 <string> 头文件中定义。每种特化形式使用你在第二章中学习到的基本字符类型之一来实现字符串:
-
std::string用于char,适用于像 ASCII 这样的字符集。 -
std::wstring用于wchar_t,其大小足以包含实现区域设置中的最大字符。 -
std::u16string用于char16_t,适用于像 UTF-16 这样的字符集。 -
std::u32string用于char32_t,适用于像 UTF-32 这样的字符集。
你将使用具有适当底层类型的特化形式。因为这些特化形式具有相同的接口,本章中的所有示例将使用 std::string。
构造
basic_string 容器接受三个模板参数:
-
底层字符类型,
T -
底层类型的特性,
Traits -
分配器,
Alloc
在这些参数中,只有 T 是必需的。STL 中的 std::char_traits 模板类位于 <string> 头文件中,抽象了字符和字符串操作,隐藏了底层字符类型的细节。此外,除非你计划支持自定义字符类型,否则你不需要实现自己的类型特性,因为 char_traits 已经为 char、wchar_t、char16_t 和 char32_t 提供了特化。如果标准库为某个类型提供了特化,除非你需要某种特殊行为,否则不必自己实现。
合起来,一个 basic_string 特化形式看起来像这样,其中 T 是字符类型:
std::basic_string<T, Traits=std::char_traits<T>, Alloc=std::allocator<T>>
注意
在大多数情况下,你将处理其中一个预定义的特化形式,尤其是 string 或 wstring。然而,如果你需要自定义分配器,你将需要适当地特化 basic_string。
basic_string<T> 容器支持与 vector<T> 相同的构造函数,并提供额外的便利构造函数用于转换 C 风格字符串。换句话说,string 支持 vector<char> 的构造函数,wstring 支持 vector<wchar_t> 的构造函数,依此类推。与 vector 一样,除了当你确实想要使用初始化列表时,所有 basic_string 的构造函数都需要使用圆括号。
你可以默认构造一个空字符串,或者如果你想用重复的字符填充一个string,你可以使用填充构造函数,通过传递一个size_t和一个char,正如清单 15-1 所示。
#include <string>
TEST_CASE("std::string supports constructing") {
SECTION("empty strings") {
std::string cheese; ➊
REQUIRE(cheese.empty()); ➋
}
SECTION("repeated characters") {
std::string roadside_assistance(3, 'A'); ➌
REQUIRE(roadside_assistance == "AAA"); ➍
}
}
清单 15-1:string的默认构造函数和填充构造函数
在你默认构造一个string ➊之后,它不包含任何元素 ➋。如果你想用重复的字符填充string,你可以使用填充构造函数,通过传入你想要填充的元素个数及其值 ➌。这个例子将一个字符串填充了三个A字符 ➍。
注意
你将在本章稍后了解 std::string 的比较操作符。因为你通常通过原始指针或原始数组来处理 C 风格字符串,所以操作符只有在给定相同对象时才返回 true。然而,对于 std::string,操作符==如果内容相同则返回 true。如清单 15-1 所示,即使其中一个操作数是 C 风格字符串字面量,比较也能正常工作。
string构造函数还提供了两个基于const char*的构造函数。如果传入的参数指向一个以 null 结尾的字符串,string构造函数可以自行确定输入的长度。如果指针不指向一个以 null 结尾的字符串,或者你只想使用string的前一部分,你可以传递一个长度参数,告诉string构造函数需要复制多少元素,正如清单 15-2 所示。
TEST_CASE("std::string supports constructing substrings ") {
auto word = "gobbledygook"; ➊
REQUIRE(std::string(word) == "gobbledygook"); ➋
REQUIRE(std::string(word, 6) == "gobble"); ➌
}
清单 15-2:从 C 风格字符串构造string
你创建了一个名为word的const char*,指向 C 风格字符串字面量gobbledygook ➊。接着,你通过传入word来构造一个string。如预期的那样,结果的string包含gobbledygook ➋。在接下来的测试中,你传入数字6作为第二个参数。这导致string只取word的前六个字符,结果string包含gobble ➌。
此外,你还可以从其他string构造string。作为一个 STL 容器,string完全支持复制和移动语义。你还可以通过传入一个子字符串——另一个字符串的连续子集,来构造string。清单 15-3 展示了这三种构造方法。
TEST_CASE("std::string supports") {
std::string word("catawampus"); ➊
SECTION("copy constructing") {
REQUIRE(std::string(word) == "catawampus"); ➋
}
SECTION("move constructing") {
REQUIRE(std::string(move(word)) == "catawampus"); ➌
}
SECTION("constructing from substrings") {
REQUIRE(std::string(word, 0, 3) == "cat"); ➍
REQUIRE(std::string(word, 4) == "wampus"); ➎
}
}
清单 15-3:string对象的复制、移动和子字符串构造
注意
在清单 15-3 中,word处于一个已移动的状态,正如你从“移动语义”部分(见第 122 页)所记得的那样,这意味着它只能被重新赋值或销毁。
在这里,你构造了一个名为 word 的 string,包含字符 catawampus ➊。复制构造产生了另一个 string,包含 word 的字符副本 ➋。移动构造偷取了 word 的字符,结果是一个包含 catawampus 的新 string ➌。最后,你可以基于子字符串构造一个新的 string。通过传递 word、起始位置为 0 和长度为 3,你构造了一个包含字符 cat 的新 string ➍。如果你改为传递 word 和起始位置为 4(不指定长度),你会得到从第四个字符到原始字符串末尾的所有字符,结果为 wampus ➎。 |
string 类还支持使用 std::string_literals::operator""s 进行字面量构造。其主要优点是符号简洁,但你也可以使用 operator""s 在 string 中轻松嵌入 null 字符,正如 示例 15-4 所示。 |
TEST_CASE("constructing a string with") {
SECTION("std::string(char*) stops at embedded nulls") {
std::string str("idioglossia\0ellohay!"); ➊
REQUIRE(str.length() == 11); ➋
}
SECTION("operator\"\"s incorporates embedded nulls") {
using namespace std::string_literals; ➌
auto str_lit = "idioglossia\0ellohay!"s; ➍
REQUIRE(str_lit.length() == 20); ➎
}
}
示例 15-4:构造一个 string
在第一次测试中,你使用字面量 idioglossia\0ellohay! ➊ 构造了一个 string,该字符串包含 idioglossia ➋,由于嵌入了 null 字符,字面量的其余部分没有被复制到 string 中。在第二次测试中,你引入了 std::string_literals 命名空间 ➌,这样就可以使用 operator""s 从字面量直接构造一个 string ➍。与 std::string 构造函数 ➊ 不同,operator""s 返回一个包含整个字面量的字符串——包括嵌入的 null 字节 ➎。 |
表 15-1 总结了构造 string 的选项。在此表中,c 是 char,n 和 pos 是 size_t,str 是 string 或 C 风格字符串,c_str 是 C 风格字符串,beg 和 end 是输入迭代器。 |
表 15-1: 支持的 std::string 构造函数
| 构造函数 | 生成一个包含的字符串 |
|---|---|
string() |
没有字符。 |
string(n, c) |
c 重复 n 次。 |
string(str, pos, [n]) |
str 中从 pos 到 pos+n 的半开区间。如果省略 n,子字符串将从 pos 到 str 的末尾。 |
string(c_str, [n]) |
c_str 的副本,长度为 n。如果 c_str 是以 null 结尾的,n 默认设置为以 null 结尾的字符串的长度。 |
string(beg, end) |
beg 到 end 半开区间内元素的副本。 |
string(str) |
str 的副本。 |
string(move(str)) |
str 的内容,构造后处于已移动状态。 |
string{ c1, c2, c3 } |
字符 c1, c2 和 c3。 |
"my string literal"s |
一个包含字符 my string literal 的字符串。 |
字符串存储和小字符串优化
和 vector 完全一样,string 使用动态存储来连续存储其组成元素。因此,vector 和 string 在复制/移动构造/赋值语义上非常相似。例如,复制操作可能比移动操作更昂贵,因为包含的元素位于动态内存中。 |
最流行的 STL 实现具有 小字符串优化(SSO)。如果 string 的内容足够小,SSO 会将其内容存储在对象的存储区内(而不是动态存储)。一般而言,少于 24 字节的 string 是 SSO 的候选者。实现者之所以做出此优化,是因为在许多现代程序中,大多数 string 都是短的。(vector 没有任何小优化。)
注意
实际上,SSO 以两种方式影响移动操作。首先,如果 string 移动,任何对 string 元素的引用都会失效。其次,string 的移动操作可能比 vector 慢,因为 string 需要检查 SSO。
一个 string 有一个 大小(或 长度)和一个 容量。大小是 string 中包含的字符数,而容量是 string 在需要调整大小之前能够容纳的字符数。
表 15-2 包含读取和操作 string 的大小和容量的方法。在此表中,n 是 size_t 类型。星号 (*) 表示在某些情况下,这个操作会使指向 s 元素的原始指针和迭代器无效。
表 15-2: 支持的 std::string 存储和长度方法
| 方法 | 返回值 |
|---|---|
s.empty() |
如果 s 不包含任何字符,则返回 true;否则返回 false。 |
s.size() |
s 中字符的数量。 |
s.length() |
与 s.size() 相同 |
s.max_size() |
s 的最大可能大小(由于系统/运行时的限制)。 |
s.capacity() |
在需要调整大小之前,s 能够容纳的字符数量。 |
s.shrink_to_fit() |
void;发出一个非绑定请求,将 s.capacity() 缩减到 s.size()。* |
s.reserve([n]) |
void;如果 n > s.capacity(),则调整大小以便 s 至少能容纳 n 个元素;否则,发出非绑定请求*,将 s.capacity() 缩减到 n 或 s.size(),取两者中的较大值。 |
注意
截至新闻发布时,草案 C++20 标准更改了当 reserve 方法的参数小于 string 的大小时的行为。这将与 vector 的行为相匹配,在这种情况下没有效果,而是等同于调用 shrink_to_fit。
请注意,string 的大小和容量方法与 vector 非常相似。这是由于它们存储模型的紧密性所致。
元素和迭代器访问
因为 string 提供对连续元素的随机访问迭代器,所以它相应地暴露了与 vector 类似的元素和迭代器访问方法。
为了与 C 风格的 API 进行互操作,string 还暴露了一个 c_str 方法,该方法返回一个不可修改的、以 null 结尾的字符串版本,作为 const char*,正如 清单 15-5 所示。
TEST_CASE("string's c_str method makes null-terminated strings") {
std::string word("horripilation"); ➊
auto as_cstr = word.c_str(); ➋
REQUIRE(as_cstr[0] == 'h'); ➌
REQUIRE(as_cstr[1] == 'o');
REQUIRE(as_cstr[11] == 'o');
REQUIRE(as_cstr[12] == 'n');
REQUIRE(as_cstr[13] == '\0'); ➍
}
清单 15-5:从 string 中提取一个 null 终止的字符串
你构造了一个包含字符 horripilation ➊ 的 string,并使用其 c_str 方法提取一个名为 as_cstr 的 null 终止字符串 ➋。由于 as_cstr 是一个 const char*,你可以使用 operator[] 来说明它包含与 word 相同的字符 ➌,并且它是 null 终止的 ➍。
注意
std::string 类还支持 operator[],其行为与 C 风格字符串相同。
通常,c_str 和 data 返回相同的结果,唯一的区别是 data 返回的引用可以是非 const 的。每当你操作一个 string 时,实施通常会确保支持 string 的连续内存以 null 终止符结束。列表 15-6 中的程序通过打印调用 data 和 c_str 及其地址的结果来展示这种行为。
#include <string>
#include <cstdio>
int main() {
std::string word("pulchritudinous");
printf("c_str: %s at 0x%p\n", word.c_str(), word.c_str()); ➊
printf("data: %s at 0x%p\n", word.data(), word.data()); ➋
}
--------------------------------------------------------------------------
c_str: pulchritudinous at 0x0000002FAE6FF8D0 ➊
data: pulchritudinous at 0x0000002FAE6FF8D0 ➋
列表 15-6:说明 c_str 和 data 返回等效地址
c_str 和 data 返回相同的结果,因为它们指向相同的地址 ➊ ➋。由于该地址是一个 null 终止的 string 的起始位置,printf 对两次调用的输出结果相同。
表 15-3 列出了 string 的访问方法。注意,表中的 n 是 size_t 类型。
表 15-3: 支持的 std::string 元素和迭代器访问方法
| 方法 | 返回值 |
|---|---|
s.begin() |
一个指向第一个元素的迭代器。 |
s.cbegin() |
一个指向第一个元素的 const 迭代器。 |
s.end() |
一个指向超出最后一个元素位置的迭代器。 |
s.cend() |
一个指向超出最后一个元素位置的 const 迭代器。 |
s.at(n) |
引用 s 中的第 n 个元素。如果越界,抛出 std::out_of_range。 |
s[n] |
引用 s 中的第 n 个元素。如果 n > s.size(),则行为未定义。此外,s[s.size()] 必须为 0,因此写入一个非零值到该字符是未定义行为。 |
s.front() |
引用第一个元素。 |
s.back() |
引用最后一个元素。 |
s.data() |
如果字符串非空,返回指向第一个元素的原始指针。如果字符串为空,返回指向一个 null 字符的指针。 |
s.c_str() |
返回一个不可修改的、以 null 终止的 s 内容版本。 |
字符串比较
注意,string 支持与其他字符串以及原始 C 风格字符串的比较,使用常见的比较操作符。例如,等号 operator== 如果左右两侧的大小和内容相同,则返回 true,而不等号 operator!= 返回相反的结果。其余比较操作符执行 字典顺序比较,即按字母顺序排列,其中 A < Z < a < z,并且在其他条件相同的情况下,较短的单词小于较长的单词(例如,pal < palindrome)。列表 15-7 展示了比较的例子。
注意
从技术上讲,字典顺序比较依赖于 string 的编码。理论上,可能存在一个系统使用默认编码,其中字母表的顺序完全混乱(例如,几乎被淘汰的 EBCDIC 编码,它将小写字母排在大写字母之前),这将影响 string 比较。对于与 ASCII 兼容的编码,你不需要担心,因为它们默认具有预期的字典顺序行为。
TEST_CASE("std::string supports comparison with") {
using namespace std::literals::string_literals; ➊
std::string word("allusion"); ➋
SECTION("operator== and !=") {
REQUIRE(word == "allusion"); ➌
REQUIRE(word == "allusion"s); ➍
REQUIRE(word != "Allusion"s); ➎
REQUIRE(word != "illusion"s); ➏
REQUIRE_FALSE(word == "illusion"s); ➐
}
SECTION("operator<") {
REQUIRE(word < "illusion"); ➑
REQUIRE(word < "illusion"s); ➒
REQUIRE(word > "Illusion"s); ➓
}
}
示例 15-7:string 类支持比较
在这里,你引入了 std::literals::string_literals 命名空间,以便可以轻松地使用 operator""s 来构造一个 string ➊。你还构造了一个名为 word 的 string,其中包含字符 allusion ➋。在第一组测试中,你检查了 operator== 和 operator!=。
你可以看到,word 等于(==)allusion,无论是作为 C 风格字符串 ➌ 还是作为 string ➍,但是它不等于(!=)包含 Allusion ➎ 或 illusion ➏ 的 string。像往常一样,operator== 和 operator!= 总是返回相反的结果 ➐。
下一组测试使用 operator< 来显示 allusion 小于 illusion ➑,因为 a 在字典顺序上小于 i。比较操作适用于 C 风格字符串和 string ➒。示例 15-7 还显示了 Allusion 小于 allusion ➓,因为 A 在字典顺序上小于 a。
表 15-4 列出了 string 的比较方法。请注意,表中的 other 是一个 string 或 char* C 风格的字符串。
表 15-4: 支持的 std::string 比较运算符
| 方法 | 返回值 |
|---|---|
s == other |
如果 s 和 other 具有相同的字符和长度,则返回 true;否则返回 false |
s != other |
operator== 的相反操作 |
s.compare(other) |
如果 s == other,则返回 0;如果 s < other,则返回负数;如果 s > other,则返回正数 |
s < other > other <= other >= other |
根据字典顺序排序的相应比较操作结果 |
操作元素
对于元素操作,string 提供了 许多 方法。它支持 vector<char> 的所有方法,并且还有许多其他有助于处理人类语言数据的方法。
添加元素
要向 string 中添加元素,可以使用 push_back,它会将一个字符插入到字符串末尾。当你想向 string 的末尾插入多个字符时,可以使用 operator+= 来追加一个字符、一个以 null 结尾的 char* 字符串,或一个 string。你也可以使用 append 方法,该方法有三种重载形式。首先,你可以传递一个 string 或一个以 null 结尾的 char* 字符串,以及一个可选的偏移量和一个可选的字符数来追加。其次,你可以传递一个长度和一个 char,它将把指定数量的 char 追加到字符串末尾。第三,你可以追加一个半开区间。示例 15-8 展示了所有这些操作。
TEST_CASE("std::string supports appending with") {
std::string word("butt"); ➊
SECTION("push_back") {
word.push_back('e'); ➋
REQUIRE(word == "butte");
}
SECTION("operator+=") {
word += "erfinger"; ➌
REQUIRE(word == "butterfinger");
}
SECTION("append char") {
word.append(1, 's'); ➍
REQUIRE(word == "butts");
}
SECTION("append char*") {
word.append("stockings", 5); ➎
REQUIRE(word == "buttstock");
}
SECTION("append (half-open range)") {
std::string other("onomatopoeia"); ➏
word.append(other.begin(), other.begin()+2); ➐
REQUIRE(word == "button");
}
}
示例 15-8:追加到 string
首先,你初始化一个名为word的string,包含字符butt ➊。在第一个测试中,你调用push_back并添加字母e ➋,结果是butte。接下来,你使用operator+=将erfinger添加到word中 ➌,结果是butterfinger。在第一次调用append时,你追加一个单独的s ➍,得到butts。(这个操作和push_back一样。)append的第二个重载允许你提供一个char*和一个长度。通过提供stockings和长度5,你将stock添加到word中,得到buttstock ➎。由于append支持半开区间,你还可以构造一个名为other的string,包含字符onomatopoeia ➏,并通过半开区间将前两个字符追加到word中,得到button ➐。
注意
回顾“测试用例和章节”中的第 308 页,每个 Catch 单元测试的SECTION是独立运行的,因此对word的修改彼此独立:每个测试的设置代码都会重置word。
删除元素
要从string中删除元素,你有几种选择。最简单的方法是使用pop_back,它和vector一样,删除string中的最后一个字符。如果你想删除所有字符(从而得到一个空的string),可以使用clear方法。当你需要更精确地删除元素时,可以使用erase方法,它提供了多种重载方式。你可以提供一个索引和长度,删除相应的字符。你也可以提供一个迭代器来删除单个元素,或者提供一个半开区间来删除多个元素。列表 15-9 展示了如何从string中删除元素。
TEST_CASE("std::string supports removal with") {
std::string word("therein"); ➊
SECTION("pop_back") {
word.pop_back();
word.pop_back(); ➋
REQUIRE(word == "there");
}
SECTION("clear") {
word.clear(); ➌
REQUIRE(word.empty());
}
SECTION("erase using half-open range") {
word.erase(word.begin(), word.begin()+3); ➍
REQUIRE(word == "rein");
}
SECTION("erase using an index and length") {
word.erase(5, 2);
REQUIRE(word == "there"); ➎
}
}
列表 15-9:从string中删除元素
你构造一个名为word的string,包含字符therein ➊。在第一个测试中,你调用pop_back两次,首先删除字母n,然后删除字母i,因此word包含字符there ➋。接下来,你调用clear,这将删除word中的所有字符,使其变为空string ➌。最后两个测试使用erase删除word中某些字符的子集。在第一次使用中,你使用半开区间删除前三个字符,因此word包含rein ➍。在第二次使用中,你删除从索引 5(即therein中的i)开始,长度为两个字符的部分 ➎。像第一个测试一样,这将得到字符there。
替换元素
要同时插入和删除元素,可以使用string来调用replace方法,它有多个重载版本。
首先,你可以提供一个半开区间和一个以空字符结尾的char*或string,然后replace将同时执行对半开区间内所有元素的erase操作,并在原区间位置插入提供的string。其次,你可以提供两个半开区间,replace将插入第二个区间,而不是string。
替代替换一个范围,你可以使用索引或单一的迭代器和长度。你可以提供一个新的半开范围、一个字符和大小,或一个 string,replace 将在隐式范围内替换新元素。示例 15-10 演示了这些可能性中的一些。
TEST_CASE("std::string replace works with") {
std::string word("substitution"); ➊
SECTION("a range and a char*") {
word.replace(word.begin()+9, word.end(), "e"); ➋
REQUIRE(word == "substitute");
}
SECTION("two ranges") {
std::string other("innuendo");
word.replace(word.begin(), word.begin()+3,
other.begin(), other.begin()+2); ➌
REQUIRE(word == "institution");
}
SECTION("an index/length and a string") {
std::string other("vers");
word.replace(3, 6, other); ➍
REQUIRE(word == "subversion");
}
}
示例 15-10:替换 string 的元素
在这里,你构造了一个名为 word 的 string,其内容为 substitution ➊。在第一次测试中,你将从索引 9 到末尾的所有字符替换为字母 e,得到单词 substitute ➋。接下来,你将 word 的前三个字母替换为一个包含 innuendo 的 string 的前两个字母 ➌,得到 institution。最后,你使用另一种通过索引和长度来指定目标序列的方式,将字符 stitut 替换为字符 vers,得到 subversion ➍。
string 类提供了一个 resize 方法,用于手动设置 string 的长度。resize 方法接受两个参数:新的长度和一个可选的 char。如果新的 string 长度较小,resize 会忽略 char。如果新的 string 长度较大,resize 会按所需次数附加 char 以达到期望的长度。示例 15-11 展示了 resize 方法的使用。
TEST_CASE("std::string resize") {
std::string word("shamp"); ➊
SECTION("can remove elements") {
word.resize(4); ➋
REQUIRE(word == "sham");
}
SECTION("can add elements") {
word.resize(7, 'o'); ➌
REQUIRE(word == "shampoo");
}
}
示例 15-11:调整 string 大小
你构造了一个名为 word 的 string,其内容为字符 shamp ➊。在第一次测试中,你将 word 调整为长度 4,使其包含 sham ➋。在第二次测试中,你将 resize 为长度 7,并提供可选字符 o 作为扩展 word 的值 ➌。这导致 word 包含 shampoo。
在 第 482 页 的“构造”部分,解释了一个可以提取连续字符序列并创建新 string 的子字符串构造函数。你还可以使用 substr 方法生成子字符串,该方法接受两个可选参数:一个位置参数和一个长度。位置默认值为 0(string 的开始),长度默认值为 string 的其余部分。示例 15-12 演示了如何使用 substr。
TEST_CASE("std::string substr with") {
std::string word("hobbits"); ➊
SECTION("no arguments copies the string") {
REQUIRE(word.substr() == "hobbits"); ➋
}
SECTION("position takes the remainder") {
REQUIRE(word.substr(3) == "bits"); ➌
}
SECTION("position/index takes a substring") {
REQUIRE(word.substr(3, 3) == "bit"); ➍
}
}
示例 15-12:从 string 中提取子字符串
你声明了一个名为 word 的 string,其内容为 hobbits ➊。如果你调用不带参数的 sub``str,你只是简单地复制了 string ➋。当你提供位置参数 3 时,substr 提取从第 3 个元素开始直到 string 末尾的子字符串,结果为 bits ➌。最后,当你提供位置(3)和长度(3)时,你将得到 bit ➍。
字符串操作方法总结
表 15-5 列出了 string 的许多插入和删除方法。在此表中,str 是一个字符串或 C 风格的 char* 字符串,p 和 n 是 size_t 类型,ind 是 size_t 索引或指向 s 的迭代器,n 和 i 是 size_t 类型,c 是 char,beg 和 end 是迭代器。星号 (*) 表示此操作在某些情况下会使原始指针和迭代器失效,无法访问 v 的元素。 |
表 15-5: 支持的 std::string 元素操作方法 |
| 方法 | 描述 |
|---|---|
s.insert(ind, str, [p], [n]) |
将从 p 开始的 str 的 n 个元素插入到 s 中,插入位置在 ind 之前。如果没有提供 n,则插入整个 string 或直到 char* 的第一个空字符;p 默认为 0。* |
s.insert(ind, n, c) |
在 ind 之前插入 n 个 c 的副本。* |
s.insert(ind, beg, end) |
将从 beg 到 end 的半开区间插入到 ind 之前。* |
s.append(str, [p], [n]) |
等同于 s.insert(s.end(), str, [p], [n])。* |
s.append(n, c) |
等同于 s.insert(s.end(), n, c)。* |
s.append(beg, end) |
将从 beg 到 end 的半开区间追加到 s 的末尾。* |
s += c s += str |
将 c 或 str 追加到 s 的末尾。* |
s.push_back(c) |
将 c 添加到 s 的末尾。* |
s.clear() |
移除 s 中的所有字符。* |
s.erase([i], [n]) |
从位置 i 开始移除 n 个字符;i 默认为 0,n 默认为 s 的剩余字符。* |
s.erase(itr) |
删除由 itr 指向的元素。* |
s.erase(beg, end) |
删除从 beg 到 end 的半开区间中的元素。* |
s.pop_back() |
移除 s 的最后一个元素。* |
s.resize(n,``[c]) |
调整字符串大小,使其包含 n 个字符。如果此操作增加了字符串的长度,则会添加 c 的副本,默认为 0。* |
s.replace(i, n1, str, [p], [n2]) |
从索引 i 开始用 str 中从 p 开始的 n2 个元素替换 n1 个字符。默认情况下,p 为 0,n2 为 str.length()。* |
s.replace(beg, end, str) |
用 str 替换半开区间 beg 到 end 的元素。* |
s.replace(p, n, str) |
用 str 从索引 p 开始到 p+n 位置替换元素。* |
s.replace(beg1, end1, beg2, end2) |
用从 beg2 到 end2 的半开区间替换从 beg1 到 end1 的半开区间。* |
s.replace(ind, c, [n]) |
用 cs 从 ind 开始替换 n 个元素。* |
s.replace(ind, beg, end) |
用半开区间 beg 到 end 替换从 ind 开始的元素。* |
s.substr([p], [c]) |
返回从 p 开始,长度为 c 的子字符串。默认情况下,p 为 0,c 为字符串的剩余部分。 |
s1.swap(s2) swap(s1, s2) |
交换 s1 和 s2 的内容。* |
搜索 |
除了前述方法,string 还提供了几个 搜索方法,它们可以帮助你找到感兴趣的子字符串和字符。每个方法执行特定类型的搜索,选择哪个方法取决于应用的具体需求。
find
string 提供的第一个方法是 find,它的第一个参数可以是 string、C 风格的 string 或 char。这个参数是你希望在 this 中定位的元素。你还可以选择性地提供第二个 size_t 类型的位置参数,告诉 find 从哪里开始查找。如果 find 未能找到子字符串,它将返回一个特殊的 size_t 值,即常量 static 成员 std::string::npos。示例 15-13 演示了 find 方法。
TEST_CASE("std::string find") {
using namespace std::literals::string_literals;
std::string word("pizzazz"); ➊
SECTION("locates substrings from strings") {
REQUIRE(word.find("zz"s) == 2); // pi(z)zazz ➋
}
SECTION("accepts a position argument") {
REQUIRE(word.find("zz"s, 3) == 5); // pizza(z)z ➌
}
SECTION("locates substrings from char*") {
REQUIRE(word.find("zaz") == 3); // piz(z)azz ➍
}
SECTION("returns npos when not found") {
REQUIRE(word.find('x') == std::string::npos); ➎
}
}
示例 15-13:在 string 中查找子字符串
这里,你构建了一个名为 word 的 string,其内容为 pizzazz ➊。在第一次测试中,你调用 find,并传入包含 zz 的 string,返回 2 ➋,即 pi``z``zazz 中第一个 z 的索引。当你提供位置参数 3,即 piz``z``azz 中第二个 z 时,find 定位到第二个 zz,其起始位置为 5 ➌。第三次测试中,你使用 C 风格的字符串 zaz,find 返回 3,再次对应 piz``z``azz 中的第二个 z ➍。最后,你尝试查找字符 x,但 pizzazz 中没有该字符,所以 find 返回 std::string::npos ➎。
rfind
rfind 方法是 find 的一种替代方法,它接受相同的参数,但以 反向 搜索。你可能会希望在某些情况下使用这个功能,比如,如果你在查找 string 末尾的特定标点符号,就如 示例 15-14 所示。
TEST_CASE("std::string rfind") {
using namespace std::literals::string_literals;
std::string word("pizzazz"); ➊
SECTION("locates substrings from strings") {
REQUIRE(word.rfind("zz"s) == 5); // pizza(z)z ➋
}
SECTION("accepts a position argument") {
REQUIRE(word.rfind("zz"s, 3) == 2); // pi(z)zazz ➌
}
SECTION("locates substrings from char*") {
REQUIRE(word.rfind("zaz") == 3); // piz(z)azz ➍
}
SECTION("returns npos when not found") {
REQUIRE(word.rfind('x') == std::string::npos); ➎
}
}
示例 15-14:在 string 中反向查找子字符串
使用相同的 word ➊,你使用与 示例 15-13 相同的参数来测试 rfind。给定 zz,rfind 返回 5,即 pizza``z``z 中倒数第二个 z ➋。当你提供位置参数 3 时,rfind 则返回 pi``z``zazz 中的第一个 z ➌。因为子字符串 zaz 只有一个出现,rfind 返回与 find 相同的位置 ➍。像 find 一样,当给定 x 时,rfind 返回 std::string::npos ➎。
find_*_of
而 find 和 rfind 用于定位 string 中的精确子序列,一系列相关的函数可以找到给定参数中包含的第一个字符。
find_first_of 函数接受一个 string,并定位该 string 中包含的第一个字符。你还可以选择性地提供一个 size_t 类型的位置参数,指示 find_first_of 从哪里开始查找。如果 find_first_of 未能找到匹配的字符,它将返回 std::string::npos。示例 15-15 演示了 find_first_of 函数。
TEST_CASE("std::string find_first_of") {
using namespace std::literals::string_literals;
std::string sentence("I am a Zizzer-Zazzer-Zuzz as you can plainly see."); ➊
SECTION("locates characters within another string") {
REQUIRE(sentence.find_first_of("Zz"s) == 7); // (Z)izzer ➋
}
SECTION("accepts a position argument") {
REQUIRE(sentence.find_first_of("Zz"s, 11) == 14); // (Z)azzer ➌
}
SECTION("returns npos when not found") {
REQUIRE(sentence.find_first_of("Xx"s) == std::string::npos); ➍
}
}
示例 15-15:在 string 中查找集合的第一个元素
名为sentence的string包含I am a Zizzer-Zazzer-Zuzz as you can plainly see. ➊。在这里,你调用find_first_of并传入字符串Zz,它匹配小写和大写的z。返回值是7,对应于sentence中的第一个Z,即Z``izzer ➋。在第二个测试中,你再次传入字符串Zz,但同时传入位置参数11,对应Zizz``e``r中的e。结果是14,对应Z``azzer中的Z ➌。最后,你调用find_first_of并传入Xx,结果是std::string::npos,因为sentence中没有x(或X) ➍。
string提供了三种find_first_of变体:
-
find_first_not_of返回string参数中不包含的第一个字符。与其提供一个包含你想要查找的元素的string,你应该提供一个你不想找到的字符组成的string。 -
find_last_of执行反向匹配;与从string的开头或某个位置参数开始搜索并向结尾进行不同,find_last_of从string的结尾或某个位置参数开始,向开头搜索。 -
find_last_not_of结合了前两种变体:你传入一个不希望找到的元素组成的string,而find_last_not_of则从末尾反向搜索。
你选择的find函数取决于你的算法需求。你是否需要从string的末尾开始搜索,例如查找标点符号?如果是,使用find_last_of。你是否在寻找string中的第一个空格?如果是,使用find_first_of。你是否想反转搜索,查找第一个不属于某个集合的元素?那么,根据你是想从字符串的开头还是结尾开始,使用find_first_not_of或find_last_not_of。
示例 15-16 展示了这三种find_first_of变体。
TEST_CASE("std::string") {
using namespace std::literals::string_literals;
std::string sentence("I am a Zizzer-Zazzer-Zuzz as you can plainly see."); ➊
SECTION("find_last_of finds last element within another string") {
REQUIRE(sentence.find_last_of("Zz"s) == 24); // Zuz(z) ➋
}
SECTION("find_first_not_of finds first element not within another string") {
REQUIRE(sentence.find_first_not_of(" -IZaeimrz"s) == 22); // Z(u)zz ➌
}
SECTION("find_last_not_of finds last element not within another string") {
REQUIRE(sentence.find_last_not_of(" .es"s) == 43); // plainl(y) ➍
}
}
示例 15-16:string的find_first_of方法的替代方案
在这里,你初始化与示例 15-15 相同的sentence ➊。在第一个测试中,你对Zz使用find_last_of,它从字符串的末尾反向搜索任何z或Z,返回24,即Zuz``z中的最后一个z ➋。接下来,你使用find_first_not_of并传入一堆字符(不包括字母u),结果是22,即Z``u``zz中第一个u的位置 ➌。最后,你使用find_last_not_of查找最后一个不等于空格、句点、e或s的字符。结果是43,即plainl``y中的y的位置 ➍。
字符串搜索方法总结
表 15-6 列出了许多string的搜索方法。请注意,s2是一个字符串;cstr是一个 C 风格的char*字符串;c是一个char类型;n、l和pos是表中的size_t类型。
表 15-6: 支持的std::string搜索算法
| 方法 | 从 p 开始搜索并返回…的位置 |
|---|---|
s.find(s2, [p]) |
第一个子串等于 s2;p 默认为 0。 |
s.find(cstr, [p], [l]) |
第一个子串等于 cstr 的前 l 个字符;p 默认为 0;l 默认为 cstr 的长度(以空字符为终止)。 |
s.find(c, [p]) |
第一个字符等于 c;p 默认为 0。 |
s.rfind(s2, [p]) |
最后一个子串等于 s2;p 默认为npos。 |
s.rfind(cstr, [p], [l]) |
最后一个子串等于 cstr 的前 l 个字符;p 默认为npos;l 默认为 cstr 的长度(以空字符为终止)。 |
s.rfind(c, [p]) |
最后一个字符等于 c;p 默认为npos。 |
s.find_first_of(s2, [p]) |
第一个字符包含在 s2 中;p 默认为 0。 |
s.find_first_of(cstr, [p], [l]) |
第一个字符包含在 cstr 的前 l 个字符中;p 默认为 0;l默认为 cstr 的长度(以空字符为终止)。 |
s.find_first_of(c, [p]) |
第一个字符等于 c;p 默认为 0。 |
s.find_last_of(s2, [p]) |
最后一个字符包含在 s2 中;p 默认为 0。 |
s.find_last_of(cstr, [p], [l]) |
最后一个字符包含在 cstr 的前 l 个字符中;p 默认为 0;l 默认为 cstr 的长度(以空字符为终止)。 |
s.find_last_of(c, [p]) |
最后一个字符等于 c;p 默认为 0。 |
s.find_first_not_of(s2, [p]) |
第一个字符不包含在 s2 中;p 默认为 0。 |
s.find_first_not_of(cstr, [p], [l]) |
第一个字符不包含在 cstr 的前 l 个字符中;p 默认为 0;l 默认为 cstr 的长度(以空字符为终止)。 |
s.find_first_not_of(c, [p]) |
第一个字符不等于 c;p 默认为 0。 |
s.find_last_not_of(s2, [p]) |
最后一个字符不包含在 s2 中;p 默认为 0。 |
s.find_last_not_of(cstr, [p], [l]) |
最后一个字符不包含在 cstr 的前 l 个字符中;p 默认为 0;l 默认为 cstr 的长度(以空字符为终止)。 |
s.find_last_not_of(c, [p]) |
最后一个字符不等于 c;p 默认为 0。 |
数值转换
STL 提供了将string或wstring与基本数值类型之间进行转换的函数。给定一个数值类型,你可以使用std::to_string和std::to_wstring函数生成其string或wstring表示。这两个函数都为所有数值类型提供了重载。列表 15-17 展示了string和wstring的使用。
TEST_CASE("STL string conversion function") {
using namespace std::literals::string_literals;
SECTION("to_string") {
REQUIRE("8675309"s == std::to_string(8675309)); ➊
}
SECTION("to_wstring") {
REQUIRE(L"109951.1627776"s == std::to_wstring(109951.1627776)); ➋
}
}
列表 15-17:string的数字转换函数
注意
由于double类型本身的精度限制,第二个单元测试 ➋ 可能在你的系统上失败。
第一个示例使用to_string将int 8675309转换为string ➊;第二个示例使用to_wstring将double 109951.1627776转换为wstring ➋。
你也可以反向转换,从 string 或 wstring 转换为数字类型。每个数字转换函数都接受一个包含字符串编码数字的 string 或 wstring 作为第一个参数。接下来,你可以提供一个可选的指向 size_t 的指针。如果提供了,转换函数将写入它所能转换的最后一个字符的索引(或者如果它解码了所有字符,则写入输入 string 的长度)。默认情况下,这个索引参数为 nullptr,此时转换函数不会写入索引。当目标类型是整数类型时,你可以提供第三个参数:一个 int,表示编码字符串的进制。这个进制参数是可选的,默认值为 10。
每个转换函数如果无法执行转换,会抛出 std::invalid_argument,如果转换的值超出相应类型的范围,则抛出 std::out_of_range。
表 15-7 列出了这些转换函数及其目标类型。在此表中,s 是一个字符串。如果 p 不是 nullptr,转换函数将把 s 中第一个未转换字符的位置写入 p 指向的内存中。如果所有字符都已编码,则返回 s 的长度。这里,b 是 s 中数字的进制表示。注意,p 默认为 nullptr,b 默认为 10。
表 15-7: std::string 和 std::wstring 的支持的数字转换函数
| 函数 | 将 s 转换为 |
|---|---|
stoi(s, [p], [b]) |
一个 int |
stol(s, [p], [b]) |
一个 long |
stoll(s, [p], [b]) |
一个 long long |
stoul(s, [p], [b]) |
一个 unsigned long |
stoull(s, [p], [b]) |
一个 unsigned long long |
stof(s, [p]) |
一个 float |
stod(s, [p]) |
一个 double |
stold(s, [p]) |
一个 long double |
to_string(n) |
一个 string |
to_wstring(n) |
一个 wstring |
示例 15-18 演示了几个数字转换函数。
TEST_CASE("STL string conversion function") {
using namespace std::literals::string_literals;
SECTION("stoi") {
REQUIRE(std::stoi("8675309"s) == 8675309); ➊
}
SECTION("stoi") {
REQUIRE_THROWS_AS(std::stoi("1099511627776"s), std::out_of_range); ➋
}
SECTION("stoul with all valid characters") {
size_t last_character{};
const auto result = std::stoul("0xD3C34C3D"s, &last_character, 16); ➌
REQUIRE(result == 0xD3C34C3D);
REQUIRE(last_character == 10);
}
SECTION("stoul") {
size_t last_character{};
const auto result = std::stoul("42six"s, &last_character); ➍
REQUIRE(result == 42);
REQUIRE(last_character == 2);
}
SECTION("stod") {
REQUIRE(std::stod("2.7182818"s) == Approx(2.7182818)); ➎
}
}
示例 15-18:string 的字符串转换函数
首先,使用 stoi 将 8675309 转换为整数 ➊。在第二次测试中,尝试使用 stoi 将 string 1099511627776 转换为整数。由于该值对于 int 来说过大,stoi 抛出 std::out_of_range ➋。接下来,使用 stoi 转换 0xD3C34C3D,但提供了两个可选参数:指向 size_t 的指针 last_character 和一个十六进制进制 ➌。last_character 对象的值为 10,即 0xD3C34C3D 的长度,因为 stoi 能解析每个字符。下一个测试中的 string 为 42six,包含无法解析的字符 six。当你这次调用 stoul 时,result 为 42,last_character 等于 2,即 s 中 six 的位置 ➍。最后,你使用 stod 将 string 2.7182818 转换为 double ➎。
注意
Boost 的 Lexical Cast 提供了一种基于模板的替代方法,用于数值转换。有关 boost::lexical_cast 的文档,请参考 <boost/lexical_cast.hpp> 头文件中的文档。
字符串视图
字符串视图 是一个表示常量、连续字符序列的对象。它非常类似于 const string 引用。实际上,字符串视图类通常实现为指向字符序列的指针和长度。
STL 提供了类模板 std::basic_string_view,位于 <string_view> 头文件中,它类似于 std::basic_string。模板 std::basic_string_view 对四种常用字符类型都有特化:
-
char有string_view -
wchar_t有wstring_view -
char16_t有u16string_view -
char32_t有u32string_view
本节讨论了 string_view 的特化用于演示,但讨论内容同样适用于其他三种特化。
string_view 类支持大多数与 string 相同的方法;实际上,它被设计成可以替代 const string&。
构造
string_view 类支持默认构造,因此它的长度为零,并且指向 nullptr。重要的是,string_view 支持从 const string& 或 C 风格字符串隐式构造。你可以从 char* 和 size_t 构造 string_view,这样你就可以手动指定所需的长度,以便获取子串或处理嵌入的空字符。 Listing 15-19 说明了 string_view 的使用。
TEST_CASE("std::string_view supports") {
SECTION("default construction") {
std::string_view view; ➊
REQUIRE(view.data() == nullptr);
REQUIRE(view.size() == 0);
REQUIRE(view.empty());
}
SECTION("construction from string") {
std::string word("sacrosanct");
std::string_view view(word); ➋
REQUIRE(view == "sacrosanct");
}
SECTION("construction from C-string") {
auto word = "viewership";
std::string_view view(word); ➌
REQUIRE(view == "viewership");
}
SECTION("construction from C-string and length") {
auto word = "viewership";
std::string_view view(word, 4); ➍
REQUIRE(view == "view");
}
}
Listing 15-19:string_view 的构造函数
默认构造的 string_view 指向 nullptr,并且是空的 ➊。当你从 string ➋ 或 C 风格字符串 ➌ 构造 string_view 时,它会指向原始内容。最后的测试提供了可选的长度参数 4,意味着 string_view 只指向前四个字符 ➍。
虽然 string_view 也支持复制构造和赋值,但不支持移动构造和赋值。这个设计是合理的,因为 string_view 不拥有它所指向的字符序列。
支持的 string_view 操作
string_view 类支持与 const string& 相同的许多操作,并且语义相同。以下列出了 string 和 string_view 之间共享的所有方法:
迭代器 begin, end, rbegin, rend, cbegin, cend, crbegin, crend
元素访问 operator[], at, front, back, data
容量 size, length, max_size, empty
搜索 find, rfind, find_first_of, find_last_of, find_first_not_of, find_last_not_of
提取 copy, substr
比较 compare, operator==, operator!= , operator<, operator>, operator<=, operator>=
除了这些共享的方法,string_view还支持remove_prefix方法,用于从string_view的开始位置移除指定数量的字符,以及remove_suffix方法,用于从末尾移除字符。列表 15-20 展示了这两种方法。
TEST_CASE("std::string_view is modifiable with") {
std::string_view view("previewing"); ➊
SECTION("remove_prefix") {
view.remove_prefix(3); ➋
REQUIRE(view == "viewing");
}
SECTION("remove_suffix") {
view.remove_suffix(3); ➌
REQUIRE(view == "preview");
}
}
列表 15-20: 使用remove_prefix和remove_suffix修改string_view
在这里,你声明了一个string_view,它引用了字符串字面量previewing ➊。第一个测试调用remove_prefix,参数为3 ➋,这将从string_view的前面移除三个字符,因此它现在引用viewing。第二个测试则调用remove_suffix,参数为3 ➌,这会从string_view的末尾移除三个字符,结果是preview。
所有权、使用和效率
因为string_view并不拥有它所引用的序列,所以你必须确保string_view的生命周期是被引用序列生命周期的子集。
string_view最常见的用法之一是作为函数参数。当你需要与不可变的字符序列交互时,它是首选。考虑列表 15-21 中的count_vees函数,它用于计算字符序列中字母v的频率。
#include <string_view>
size_t count_vees(std::string_view my_view➊) {
size_t result{};
for(auto letter : my_view) ➋
if (letter == 'v') result++; ➌
return result; ➍
}
列表 15-21: count_vees 函数
count_vees函数接受一个名为my_view的string_view ➊,你使用基于范围的for循环 ➋遍历它。每当my_view中的字符等于v时,你就增加result变量 ➌,并在遍历完整个序列后返回该变量 ➍。
你可以通过简单地将string_view替换为const string&来重新实现列表 15-21,正如在列表 15-22 中所展示的那样。
#include <string>
size_t count_vees(const std::string& my_view) {
--snip--
}
列表 15-22: 重新实现的count_vees函数,使用const string&代替string_view
如果string_view仅仅是const string&的替代品,那为什么还要使用它呢?其实,如果你用std::string调用count_vees,并没有什么区别:现代编译器会生成相同的代码。
如果你用字符串字面量来调用count_vees,则会有很大区别:当你将字符串字面量作为const string&传递时,你会构造一个string。而当你将字符串字面量作为string_view传递时,你会构造一个string_view。构造string可能更昂贵,因为它可能需要分配动态内存,并且必须复制字符。而string_view只是一个指针和一个长度(不需要复制或分配内存)。
正则表达式
正则表达式,也叫做regex,是定义搜索模式的字符串。正则表达式在计算机科学中有着悠久的历史,并形成了一种用于搜索、替换和提取语言数据的迷你语言。STL 在<regex>头文件中提供了正则表达式的支持。
正则表达式在谨慎使用时可以非常强大、声明式且简洁;然而,也很容易写出完全无法理解的正则表达式。请有意地使用正则表达式。
模式
你使用叫做模式的字符串来构建正则表达式。模式使用特定的正则表达式语法来表示一个期望的字符串集,这些语法规定了构建模式的语法。换句话说,模式定义了你感兴趣的所有可能字符串的子集。STL 支持一些语法,但这里的重点是默认语法,即修改过的 ECMAScript 正则表达式语法(有关详细信息,请参见[re.grammar])。
字符类
在 ECMAScript 语法中,你将字面字符与特殊标记混合使用来描述你期望的字符串。最常见的标记可能是字符类,它代表一组可能的字符:\d 匹配任何数字,\s 匹配任何空白字符,\w 匹配任何字母数字(“单词”)字符。
表 15-8 列出了几个示例正则表达式及其可能的解释。
表 15-8:仅使用字符类和字面量的正则表达式模式
| 正则表达式模式 | 可能描述 |
|---|---|
\d\d\d-\d\d\d-\d\d\d\d |
一个美国电话号码,例如 202-456-1414 |
\d\d:\d\d \wM |
一个时间,格式为 HH:MM AM/PM,例如 08:49 PM |
\w\w\d\d\d\d\d\d |
一个美国邮政编码,包含前置的州代码,例如 NJ07932 |
\w\d-\w\d |
一个天文机械人标识符,例如 R2-D2 |
c\wt |
一个以c开头并以t结尾的三字母单词,例如cat或cot |
你还可以通过将d、s或w大写来反转字符类,得到相反的效果:\D匹配任何非数字,\S匹配任何非空白字符,\W匹配任何非单词字符。
此外,你可以通过在方括号 [] 中显式列出它们来构建自己的字符类。例如,字符类 [02468] 包含偶数数字。你还可以使用连字符作为快捷方式来包含隐含的范围,因此字符类 [0-9a-fA-F] 包含任何十六进制数字,无论字母是否大写。最后,你可以通过在列表前加上脱字符 ^ 来反转自定义字符类。例如,字符类 [^aeiou] 包含所有非元音字符。
量词
你可以通过使用量词来减少一些打字,这些量词指定左边的字符应该重复一定次数。表 15-9 列出了正则表达式量词。
表 15-9: 正则表达式量词
| 正则表达式量词 | 指定数量 |
|---|---|
| * | 0 次或更多次 |
| + | 1 次或更多次 |
| ? | 0 次或 1 次 |
| 正好 n 次 | |
| 介于 n 和 m 之间(包括 n 和 m) | |
| 至少 n 次 |
使用量词,你可以通过模式c\w*t指定所有以c开头并以t结尾的单词,因为\w*匹配任意数量的字母数字字符。
组
组是字符的集合。你可以通过将字符放入括号中来指定一个组。组在多个方面都有用,包括指定一个特定的集合以便最终提取和量化。
例如,你可以改进表 15-8 中的邮政编码模式,使用量词和分组,像这样:
(\w{2})?➊(\d{5})➋(-\d{4})?➌
现在你有了三个组:可选的状态➊、邮政编码➋,以及一个可选的四位数字后缀➌。正如你稍后将看到的,这些组使得从正则表达式中解析数据变得更加容易。
其他特殊字符
表 15-10 列出了可用于正则表达式模式的其他特殊字符。
表 15-10: 示例特殊字符
| 字符 | 指定内容 |
|---|---|
| X|Y | 字符 X 或 Y |
| \Y | 字符 Y 作为字面量(换句话说,转义它) |
| \n | 换行符 |
| \r | 回车符 |
| \t | 制表符 |
| \0 | 空字符 |
| \xYY | 对应 YY 的十六进制字符 |
basic_regex
STL 的std::basic_regex类模板位于<regex>头文件中,表示由模式构造的正则表达式。basic_regex类接受两个模板参数,一个是字符类型,另一个是可选的 traits 类。你几乎总是希望使用其中一种便捷的特化:std::regex用于std::basic_regex<char>,或std::wregex用于std::basic_regex<wchar_t>。
构建regex的主要方式是通过传递包含正则表达式模式的字符串字面量。由于模式中需要大量转义字符,尤其是反斜杠\,因此使用原始字符串字面量,如R"()",是一个好主意。构造函数接受一个第二个可选参数,用于指定语法标志,如正则表达式语法。
虽然regex主要用于作为正则表达式算法的输入,但它确实提供了一些方法,允许用户与之交互。它支持常见的复制、移动构造和赋值操作,以及swap,还有以下功能:
-
assign(``s``)将模式重新分配给s -
mark_count()返回模式中的组数 -
flags()返回构造时发出的语法标志
示例 15-23 展示了如何构造一个邮政编码regex并检查其子组。
#include <regex>
TEST_CASE("std::basic_regex constructs from a string literal") {
std::regex zip_regex{ R"((\w{2})?(\d{5})(-\d{4})?)" }; ➊
REQUIRE(zip_regex.mark_count() == 3); ➋
}
示例 15-23:使用原始字符串字面量构造regex并提取其组数
在这里,你使用模式(\w{2})?(\d{5})(-\d{4})? ➊构造了一个名为zip_regex的regex。通过使用mark_count方法,你会看到zip_regex包含三个组➋。
算法
<regex>类包含三种算法,用于将std::basic_regex应用于目标字符串:匹配、搜索或替换。你选择哪一种取决于手头的任务。
匹配
匹配 尝试将正则表达式与 整个 string 进行匹配。STL 提供了 std::regex_match 函数用于匹配,它有四种重载形式。
首先,你可以为 regex_match 提供一个 string、一个 C 字符串,或者一个形成半开区间的开始和结束迭代器。下一个参数是一个可选的 std::match_results 对象的引用,用于接收匹配的详细信息。下一个参数是定义匹配的 std::basic_regex,最后一个参数是一个可选的 std::regex_constants::match_flag_type,用于指定高级用例的附加匹配选项。regex_match 函数返回一个 bool,如果找到匹配则为 true,否则为 false。
总结来说,你可以通过以下方式调用 regex_match:
regex_match(beg, end, [mr], rgx, [flg])
regex_match(str, [mr], rgx, [flg])
可以提供从 beg 到 end 的半开区间,或一个 string/C 字符串 str 来进行搜索。你也可以选择提供一个名为 mr 的 match_results 来存储找到的所有匹配的详细信息。显然,你必须提供一个正则表达式 rgx。最后,flg 标志很少使用。
注意
有关匹配标志 flg 的详细信息,请参考 [re.alg.match]。
子匹配是与某个分组对应的匹配字符串的子序列。ZIP 代码匹配的正则表达式 (\w{2})(\d{5})(-\d{4})? 可以根据字符串产生两个或三个子匹配。例如,TX78209 包含两个子匹配 TX 和 78209,而 NJ07936-3173 包含三个子匹配 NJ、07936 和 -3173。
match_results 类存储零个或多个 std::sub_match 实例。sub_match 是一个简单的类模板,公开一个 length 方法来返回子匹配的长度,以及一个 str 方法来从 sub_match 构建一个 string。
有些令人困惑的是,如果 regex_match 成功匹配一个字符串,match_results 会将整个匹配字符串作为第一个元素,然后将任何子匹配存储为后续元素。
match_results 类提供了 表 15-11 中列出的操作。
表 15-11: match_results 的支持操作
| 操作 | 描述 |
|---|---|
mr.empty() |
检查匹配是否成功。 |
mr.size() |
返回子匹配的数量。 |
mr.max_size() |
返回子匹配的最大数量。 |
mr.length([i]) |
返回子匹配 i 的长度,默认值为 0。 |
mr.position([i]) |
返回子匹配 i 的第一个位置的字符,默认值为 0。 |
mr.str([i]) |
返回表示子匹配 i 的字符串,默认值为 0。 |
mr [i] |
返回一个引用,指向与子匹配 i 对应的 std::sub_match 类,默认值为 0。 |
mr.prefix() |
返回一个引用,指向与匹配前序列对应的 std::sub_match 类。 |
mr.suffix() |
返回一个引用,指向与匹配后序列对应的 std::sub_match 类。 |
mr.format(str) |
返回一个 string,其内容按照格式字符串 str 排列。有三个特殊序列: 表示匹配前的字符,$' 表示匹配后的字符, |
| 表示匹配的字符。 | |
mr.begin()mr.end()mr.cbegin()mr.cend() |
返回指向子匹配序列的相应迭代器。 |
std::sub_match 类模板有预定义的特化来与常见的字符串类型一起使用:
-
std::csub_match用于const char* -
std::wcsub_match用于const wchar_t* -
std::ssub_match用于std::string -
std::wssub_match用于std::wstring
不幸的是,你将不得不手动跟踪所有这些特化,因为 std::regex_match 的设计。这种设计通常会让新手感到困惑,因此让我们来看一个例子。列表 15-24 使用 ZIP 代码正则表达式 (\w{2})(\d{5})(-\d{4})? 来匹配字符串 NJ07936-3173 和 Iomega Zip 100。
#include <regex>
#include <string>
TEST_CASE("std::sub_match") {
std::regex regex{ R"((\w{2})(\d{5})(-\d{4})?)" }; ➊
std::smatch results; ➋
SECTION("returns true given matching string") {
std::string zip("NJ07936-3173");
const auto matched = std::regex_match(zip, results, regex); ➌
REQUIRE(matched); ➍
REQUIRE(results[0] == "NJ07936-3173"); ➎
REQUIRE(results[1] == "NJ"); ➏
REQUIRE(results[2] == "07936");
REQUIRE(results[3] == "-3173");
}
SECTION("returns false given non-matching string") {
std::string zip("Iomega Zip 100");
const auto matched = std::regex_match(zip, results, regex); ➐
REQUIRE_FALSE(matched); ➑
}
}
列表 15-24:regex_match 尝试将 regex 匹配到 string。
你构造了一个带有原始文字的 regex:R"((\w{2})(\d{5})(-\d{4})?)" ➊,并默认构造了一个 smatch ➋。在第一次测试中,你用 regex_match 对有效的 ZIP 代码 NJ07936-3173 ➌ 进行匹配,返回 true 值 matched 以表示成功 ➍。因为你为 regex_match 提供了一个 smatch,它将有效的 ZIP 代码作为第一个元素 ➎,接着是每个子组 ➏。
在第二次测试中,你使用 regex_match 对无效的 ZIP 代码 Iomega Zip 100 ➐ 进行匹配,匹配失败并返回 false ➑。
搜索
搜索 尝试将正则表达式匹配到字符串的 一部分。STL 提供了 std::regex_search 函数用于搜索,它本质上是 regex_match 的替代方案,即使只有字符串的一部分匹配 regex,它也会成功。
例如,字符串 The string NJ07936-3173 is a ZIP Code. 包含一个 ZIP 代码。但使用 std::regex_match 对其应用 ZIP 正则表达式将返回 false,因为 regex 没有匹配到 整个 字符串。然而,使用 std::regex_search 会返回 true,因为字符串中嵌入了有效的 ZIP 代码。列表 15-25 演示了 regex_match 和 regex_search 的使用。
TEST_CASE("when only part of a string matches a regex, std::regex_ ") {
std::regex regex{ R"((\w{2})(\d{5})(-\d{4})?)" }; ➊
std::string sentence("The string NJ07936-3173 is a ZIP Code."); ➋
SECTION("match returns false") {
REQUIRE_FALSE(std::regex_match(sentence, regex)); ➌
}
SECTION("search returns true") {
REQUIRE(std::regex_search(sentence, regex)); ➍
}
}
列表 15-25:比较 regex_match 和 regex_search
如前所述,你构造了 ZIP regex ➊。你还构造了示例字符串 sentence,其中嵌入了有效的 ZIP 代码 ➋。第一个测试使用 regex_match 对 sentence 和 regex 进行匹配,返回 false ➌。第二个测试则调用 regex_search,使用相同的参数,返回 true ➍。
替换
替换 将正则表达式匹配的内容替换为替换文本。STL 提供了 std::regex_replace 函数来进行替换。
在最基本的用法中,你传递给 regex_replace 三个参数:
-
一个源
string/C-string/半开区间进行搜索 -
一个正则表达式
-
一个替换字符串
例如,示例 15-26 将短语 queueing and cooeeing in eutopia 中的所有元音字母替换为下划线(_)。
TEST_CASE("std::regex_replace") {
std::regex regex{ "[aeoiu]" }; ➊
std::string phrase("queueing and cooeeing in eutopia"); ➋
const auto result = std::regex_replace(phrase, regex, "_"); ➌
REQUIRE(result == "q_____ng _nd c_____ng _n __t_p__"); ➍
}
示例 15-26:使用 std::regex_replace 将元音字母替换为下划线
你构造一个包含所有元音字母集合 ➊ 的 std::regex,并且创建一个名为 phrase 的 string,其中包含元音丰富的内容 queueing and cooeeing in eutopia ➋。接着,你调用 std::regex_replace,传入 phrase、正则表达式和字符串字面量 _ ➌,它将所有元音字母替换为下划线 ➍。
注意
Boost Regex 提供了与 STL 在 <boost/regex.hpp> 头文件中的正则表达式支持相对应的功能。另一个 Boost 库,Xpressive,提供了一种替代方法,可以直接在 C++ 代码中表达正则表达式。它具有一些主要优点,如表达能力和编译时语法检查,但其语法不可避免地与标准的正则表达式语法(如 POSIX、Perl 和 ECMAScript)有所不同。
Boost 字符串算法
Boost 的字符串算法库提供了丰富的 string 操作函数。它包含了常见的字符串处理任务的函数,例如修剪、大小写转换、查找/替换和评估特征。你可以在 boost::algorithm 命名空间和 <boost/algorithm/string.hpp> 便捷头文件中访问所有 Boost 字符串算法函数。
Boost Range
范围是一个概念(在第六章编译时多态性的意义上),它有一个起点和终点,允许你遍历其中的元素。范围旨在改进传递半开范围作为一对迭代器的做法。通过将这对迭代器替换为一个单一对象,你可以组合算法,通过使用一个算法的范围结果作为另一个算法的输入。例如,如果你想将一系列字符串转换为全大写并对它们进行排序,你可以将一个操作的结果直接传递给另一个。这种操作单独使用迭代器通常是无法做到的。
范围目前还不是 C++ 标准的一部分,但已有多个实验性实现。其中一个实现是 Boost Range,并且由于 Boost 字符串算法广泛使用 Boost Range,现在我们来了解一下它。
Boost Range 概念类似于 STL 容器概念。它提供了常见的 begin/end 方法,用于暴露范围内元素的迭代器。每个范围都有一个遍历类别,它指示范围支持的操作:
-
一个单向范围允许一次性、正向迭代。
-
一个正向范围允许(无限次)正向迭代,并满足单向范围的要求。
-
一个双向范围允许正向和反向迭代,并满足正向范围的要求。
-
一个随机访问范围允许任意元素访问,并满足双向范围的要求。
Boost 字符串算法是为 std::string 设计的,它满足随机访问范围的概念。在大多数情况下,Boost 字符串算法接受 Boost Range 而不是 std::string 对用户来说是完全透明的抽象。在阅读文档时,你可以将 Range 心理上替换为 string。
谓词
Boost 字符串算法广泛地集成了谓词。你可以通过引入 <boost/algorithm/string/predicate.hpp> 头文件直接使用它们。这个头文件中的大多数谓词接受两个范围 r1 和 r2,并根据它们之间的关系返回 bool。例如,谓词 starts_with 如果 r1 以 r2 开头,则返回 true。
每个谓词都有一个不区分大小写的版本,你可以通过在方法名前加字母 i 来使用,如 istarts_with。列表 15-27 演示了 starts_with 和 istarts_with。
#include <string>
#include <boost/algorithm/string/predicate.hpp>
TEST_CASE("boost::algorithm") {
using namespace boost::algorithm;
using namespace std::literals::string_literals;
std::string word("cymotrichous"); ➊
SECTION("starts_with tests a string's beginning") {
REQUIRE(starts_with(word, "cymo"s)); ➋
}
SECTION("istarts_with is case insensitive") {
REQUIRE(istarts_with(word, "cYmO"s)); ➌
}
}
列表 15-27:starts_with 和 istarts_with 都检查范围的起始字符。
你初始化一个包含 cymotrichous 的 string ➊。第一次测试显示,当使用 word 和 cymo ➋ 时,starts_with 返回 true。不区分大小写的版本 istarts_with 在使用 word 和 cYmO ➌ 时也返回 true。
请注意,<boost/algorithm/string/predicate.hpp> 还包含一个 all 谓词,它接受一个范围 r 和一个谓词 p。如果 p 对 r 中的所有元素计算结果为 true,则返回 true,正如 列表 15-28 所示。
TEST_CASE("boost::algorithm::all evaluates a predicate for all elements") {
using namespace boost::algorithm;
std::string word("juju"); ➊
REQUIRE(all(word➋, [](auto c) { return c == 'j' || c =='u'; }➌));
}
列表 15-28:all 谓词评估范围内所有元素是否满足谓词。
你初始化一个包含 juju 的字符串 ➊,并将其作为范围 ➋ 传递给 all。你传递一个 lambda 谓词,它对字母 j 和 u 返回 true ➌。因为 juju 只包含这些字母,all 返回 true。
表 15-12 列出了 <boost/algorithm/string/predicate.hpp> 中可用的谓词。在此表中,r, r1 和 r2 是字符串范围,p 是元素比较谓词。
表 15-12: Boost 字符串算法库中的谓词
| 谓词 | 返回 true 如果 |
|---|---|
starts_with(r1, r2, [p])``istarts_with(r1, r2) |
r1 以 r2 开头;p 用于逐字符比较。 |
ends_with(r1, r2, [p])``iends_with(r1, r2) |
r1 以 r2 结尾;p 用于逐字符比较。 |
contains(r1, r2, [p])``icontains(r1, r2) |
r1 包含 r2;p 用于逐字符比较。 |
equals(r1, r2, [p])``iequals(r1, r2) |
r1 等于 r2;p 用于逐字符比较。 |
lexicographical_compare(r1, r2, [p])``ilexicographical_compare(r1, r2) |
r1 在字典顺序上小于 r2;p 用于逐字符比较。 |
all(r, [p]) |
r 的所有元素对于 p 返回 true。 |
以 i 开头的函数变种是不区分大小写的。
分类器
分类器 是评估字符某些特征的谓词。<boost/algorithm/string/classification.hpp> 头文件提供了用于创建分类器的生成器。生成器 是一种非成员函数,类似于构造函数。一些生成器接受参数,以自定义分类器。
注意
当然,你也可以像使用自己定义的函数对象(比如 lambda)一样,轻松地创建你自己的谓词,但 Boost 为了方便提供了一些现成的分类器。
is_alnum 生成器,例如,用于创建一个分类器来判断一个字符是否为字母数字字符。示例 15-29 说明了如何独立使用这个分类器或与 all 一起使用。
#include <boost/algorithm/string/classification.hpp>
TEST_CASE("boost::algorithm::is_alnum") {
using namespace boost::algorithm;
const auto classifier = is_alnum(); ➊
SECTION("evaluates alphanumeric characters") {
REQUIRE(classifier('a')); ➋
REQUIRE_FALSE(classifier('$')); ➌
}
SECTION("works with all") {
REQUIRE(all("nostarch", classifier)); ➍
REQUIRE_FALSE(all("@nostarch", classifier)); ➎
}
}
示例 15-29:is_alum 生成器判断字符是否为字母数字。
在这里,你从 is_alnum 生成器构造一个 classifier ➊。第一个测试使用 classifier 来评估字符 a 是否为字母数字 ➋,而 $ 则不是 ➌。由于所有分类器都是作用于字符的谓词,你可以将它们与前一节讨论的 all 谓词结合使用,以确定 nostarch 是否包含所有字母数字字符 ➍,而 @nostarch 则不包含 ➎。
表 15-13 列出了 <boost/algorithm/string/classification.hpp> 中可用的字符分类。在这个表中,r 是一个字符串范围,beg 和 end 是元素比较谓词。
表 15-13: Boost 字符串算法库中的字符谓词
| 谓词 | 当元素是 . . . 时返回 true |
|---|---|
is_space |
空格 |
is_alnum |
字母数字字符 |
is_alpha |
字母字符 |
is_cntrl |
控制字符 |
is_digit |
十进制数字 |
is_graph |
图形字符 |
is_lower |
小写字母 |
is_print |
可打印字符 |
is_punct |
标点符号字符 |
is_upper |
大写字母 |
is_xdigit |
十六进制数字 |
is_any_of(r) |
包含在 r 中 |
is_from_range(beg, end) |
包含在从 beg 到 end 的范围内 |
查找器
查找器 是一个概念,用来确定范围内与某些特定条件(通常是谓词或正则表达式)匹配的元素位置。Boost 字符串算法库在 <boost/algorithm/string/finder.hpp> 头文件中提供了一些生成器,用于生成查找器。
例如,nth_finder 生成器接受一个范围 r 和一个索引 n,它创建一个查找器,搜索一个范围(由 begin 和 end 迭代器表示),查找 r 的第 n 次出现,如 示例 15-30 所示。
#include <boost/algorithm/string/finder.hpp>
TEST_CASE("boost::algorithm::nth_finder finds the nth occurrence") {
const auto finder = boost::algorithm::nth_finder("na", 1); ➊
std::string name("Carl Brutananadilewski"); ➋
const auto result = finder(name.begin(), name.end()); ➌
REQUIRE(result.begin() == name.begin() + 12); ➍ // Brutana(n)adilewski
REQUIRE(result.end() == name.begin() + 14); ➎ // Brutanana(d)ilewski
}
示例 15-30:nth_finder 生成器创建一个查找器,用于定位一个序列的第 n 次出现。
你可以使用 nth_finder 生成器来创建 finder,它会定位范围内 na 的第二个实例(n 是从零开始的) ➊。接下来,你构造一个包含 Carl Brutananadilewski 的 name ➋,并使用 name 的 begin 和 end 迭代器调用 finder ➌。result 是一个范围,其 begin 指向 Brutana``n``adilewski 中第二个 n ➍,而 end 指向 Brutanana``d``ilewski 中第一个 d ➎。
表 15-14 列出了 <boost/algorithm/string/finder.hpp> 中可用的查找器。在此表中,s 是字符串,p 是元素比较谓词,n 是整数值,beg 和 end 是迭代器,rgx 是正则表达式,r 是字符串范围。
表 15-14: Boost 字符串算法库中的查找器
| 生成器 | 创建一个查找器,当被调用时返回... |
|---|---|
first_finder(s, p) |
使用 p 查找匹配 s 的第一个元素 |
last_finder(s, p)` |
使用 p 查找匹配 s 的最后一个元素 |
nth_finder(s, p, n) |
使用 p 查找匹配 s 的第 n 个元素 |
head_finder(n) |
前 n 个元素 |
tail_finder(n) |
后 n 个元素 |
token_finder(p) |
匹配 p 的字符 |
range_finder(r)``range_finder(beg, end) |
不考虑输入,始终返回 r |
regex_finder(rgx) |
匹配 rgx 的第一个子字符串 |
注意
Boost 字符串算法指定了一个格式化器概念,它将查找器的结果呈现给替换算法。只有高级用户才需要这些算法。更多信息,请参考 <boost/algorithm/string/find_format.hpp> 头文件中的 find_format 算法文档。
修改算法
Boost 包含了许多用于修改 string(范围)的算法。在 <boost/algorithm/string/case_conv.hpp>、<boost/algorithm/string/trim.hpp> 和 <boost/algorithm/string/replace.hpp> 头文件中,存在将大小写转换、修剪、替换和删除多种不同方式的算法。
例如,to_upper 函数将把字符串中的所有字母转换为大写。如果你想保持原始字符串不变,可以使用 to_upper_copy 函数,它会返回一个新的对象。示例 15-31 说明了 to_upper 和 to_upper_copy。
#include <boost/algorithm/string/case_conv.hpp>
TEST_CASE("boost::algorithm::to_upper") {
std::string powers("difficulty controlling the volume of my voice"); ➊
SECTION("upper-cases a string") {
boost::algorithm::to_upper(powers); ➋
REQUIRE(powers == "DIFFICULTY CONTROLLING THE VOLUME OF MY VOICE"); ➌
}
SECTION("_copy leaves the original unmodified") {
auto result = boost::algorithm::to_upper_copy(powers); ➍
REQUIRE(powers == "difficulty controlling the volume of my voice"); ➎
REQUIRE(result == "DIFFICULTY CONTROLLING THE VOLUME OF MY VOICE"); ➏
}
}
示例 15-31:to_upper 和 to_upper_copy 都会将 string 的字母转换为大写。
你创建了一个名为 powers 的 string ➊。第一次测试调用 to_upper 函数作用于 powers ➋,它会原地修改 powers,使其包含所有大写字母 ➌。第二次测试使用 _copy 变体,创建一个名为 result 的新 string ➍。此时,powers 字符串不受影响 ➎,而 result 包含一个全大写的版本 ➏。
一些 Boost 字符串算法,例如 replace_first,也有不区分大小写的版本。只需在前面加上 i,匹配将不受大小写限制。对于像 replace_first 这样的算法,它们还有 _copy 变种,任何排列组合都能正常工作(replace_first、ireplace_first、replace_first_copy 和 ireplace_first_copy)。
replace_first 算法及其变种接受输入范围 s、匹配范围 m 和替换范围 r,并将 s 中第一个匹配 m 的实例替换为 r。列表 15-32 说明了 replace_first 和 i_replace_first。
#include <boost/algorithm/string/replace.hpp>
TEST_CASE("boost::algorithm::replace_first") {
using namespace boost::algorithm;
std::string publisher("No Starch Press"); ➊
SECTION("replaces the first occurrence of a string") {
replace_first(publisher, "No", "Medium"); ➋
REQUIRE(publisher == "Medium Starch Press"); ➌
}
SECTION("has a case-insensitive variant") {
auto result = ireplace_first_copy(publisher, "NO", "MEDIUM"); ➍
REQUIRE(publisher == "No Starch Press"); ➎
REQUIRE(result == "MEDIUM Starch Press"); ➏
}}
列表 15-32:replace_first 和 i_replace_first 都会替换匹配的 string 序列。
在这里,你构造了一个名为 publisher 的 string,其值为 No Starch Press ➊。第一个测试调用 replace_first,以 publisher 作为输入字符串,No 作为匹配字符串,Medium 作为替换字符串 ➋。随后,publisher 的值变为 Medium Starch Press ➌。第二个测试使用不区分大小写并执行复制的 ireplace_first_copy 变种。你分别将 NO 和 MEDIUM 作为匹配和替换字符串 ➍,此时 result 包含 MEDIUM Starch Press ➏,而 publisher 不受影响 ➎。
表 15-15 列出了 Boost 字符串算法中许多可用的修改算法。在这个表格中,r、s、s1 和 s2 是字符串;p 是元素比较谓词;n 是整数值;rgx 是正则表达式。
表 15-15: Boost 字符串算法库中的修改算法
| 算法 | 描述 |
|---|---|
to_upper(s)``to_upper_copy(s) |
将 s 转换为全大写 |
to_lower(s)``to_lower_copy(s) |
将 s 转换为全小写 |
trim_left_copy_if(s, [p])``trim_left_if(s, [p])``trim_left_copy(s)``trim_left(s) |
移除 s 中的前导空格 |
trim_right_copy_if(s, [p])``trim_right_if(s, [p])``trim_right_copy(s)``trim_right(s) |
移除 s 中的尾随空格 |
trim_copy_if(s, [p])``trim_if(s, [p])``trim_copy(s)``trim(s) |
移除 s 中的前导和尾随空格 |
replace_first(s1, s2, r)``replace_first_copy(s1, s2, r)``ireplace_first(s1, s2, r)``ireplace_first_copy(s1, s2, r) |
将 s1 中第一个出现的 s2 替换为 r |
erase_first(s1, s2)``erase_first_copy(s1, s2)``ierase_first(s1, s2)``ierase_first_copy(s1, s2) |
删除 s1 中第一个出现的 s2 |
replace_last(s1, s2, r)``replace_last_copy(s1, s2, r)``ireplace_last(s1, s2, r)``ireplace_last_copy(s1, s2, r) |
将 s1 中最后一个出现的 s2 替换为 r |
erase_last(s1, s2)``erase_last_copy(s1, s2)``ierase_last(s1, s2)``ierase_last_copy(s1, s2) |
删除 s1 中最后一个出现的 s2 |
replace_nth(s1, s2, n, r)``replace_nth_copy(s1, s2, n, r)``ireplace_nth(s1, s2, n, r)``ireplace_nth_copy(s1, s2, n, r) |
替换 s1 中第 n 次出现的 s2 为 r |
erase_nth(s1, s2, n)``erase_nth_copy(s1, s2, n)``ierase_nth(s1, s2, n)``ierase_nth_copy(s1, s2, n) |
删除 s1 中第 n 次出现的 s2 |
replace_all(s1, s2, r)``replace_all_copy(s1, s2, r)``ireplace_all(s1, s2, r)``ireplace_all_copy(s1, s2, r) |
用 r 替换 s1 中所有 s2 的出现 |
erase_all(s1, s2)``erase_all_copy(s1, s2)``ierase_all(s1, s2)``ierase_all_copy(s1, s2) |
删除 s1 中所有 s2 的出现 |
replace_head(s, n, r)``replace_head_copy(s, n, r) |
用 r 替换 s 的前 n 个字符 |
erase_head(s, n)``erase_head_copy(s, n) |
删除 s 的前 n 个字符 |
replace_tail(s, n, r)``replace_tail_copy(s, n, r) |
用 r 替换 s 的最后 n 个字符 |
erase_tail(s, n)``erase_tail_copy(s, n) |
删除 s 的最后 n 个字符 |
replace_regex(s, rgx, r)``replace_regex_copy(s, rgx, r) |
替换 s 中 rgx 的第一次出现为 r |
erase_regex(s, rgx)``erase_regex_copy(s, rgx) |
删除 s 中 rgx 的第一次出现 |
replace_all_regex(s, rgx, r)``replace_all_regex_copy(s, rgx, r) |
替换 s 中所有 rgx 的实例为 r |
erase_all_regex(s, rgx)``erase_all_regex_copy(s, rgx) |
删除 s 中所有 rgx 的实例 |
拆分与连接
Boost 字符串算法包含用于拆分和连接字符串的函数,分别位于 <boost/algorithm/string/split.hpp> 和 <boost/algorithm/string/join.hpp> 头文件中。
要拆分一个 string,你需要提供 split 函数一个 STL 容器 res、一个范围 s 和一个谓词 p。它将使用谓词 p 来确定分隔符,并将结果插入到 res 中。 列表 15-33 演示了 split 函数。
#include <vector>
#include <boost/algorithm/string/split.hpp>
#include <boost/algorithm/string/classification.hpp>
TEST_CASE("boost::algorithm::split splits a range based on a predicate") {
using namespace boost::algorithm;
std::string publisher("No Starch Press"); ➊
std::vector<std::string> tokens; ➋
split(tokens, publisher, is_space()); ➌
REQUIRE(tokens[0] == "No"); ➍
REQUIRE(tokens[1] == "Starch");
REQUIRE(tokens[2] == "Press");
}
列表 15-33:split 函数将一个 string 拆分成多个标记。
再次使用 publisher ➊,你创建一个名为 tokens 的 vector 来存储结果 ➋。你调用 split,将 tokens 作为结果容器,publisher 作为范围,is_space 作为你的谓词 ➌。这将把 publisher 按空格拆分。之后,tokens 包含 No, Starch 和 Press,正如预期的那样 ➍。
你可以使用 join 执行逆操作,它接受一个 STL 容器 seq 和一个分隔符字符串 sep。join 函数会将 seq 中的每个元素与 sep 分隔符连接在一起。
列表 15-34 演示了 join 函数的实用性以及牛津逗号的不可或缺性。
#include <vector>
#include <boost/algorithm/string/join.hpp>
TEST_CASE("boost::algorithm::join staples tokens together") {
std::vector<std::string> tokens{ "We invited the strippers",
"JFK", "and Stalin." }; ➊
auto result = boost::algorithm::join(tokens, ", "); ➋
REQUIRE(result == "We invited the strippers, JFK, and Stalin."); ➌
}
列表 15-34:join 函数将 string 标记与分隔符连接在一起。
你实例化了一个名为 tokens 的 vector,包含三个 string 对象 ➊。接着,你使用 join 将 token 的构成元素用逗号和空格连接在一起 ➋。结果是一个单一的 string,其中包含了通过逗号和空格连接的构成元素 ➌。
表 15-16 列出了 <boost/algorithm/string/split.hpp> 和 <boost/algorithm/string/join.hpp> 中提供的许多拆分/连接算法。在此表中,res, s, s1, s2 和 sep 是字符串;seq 是字符串的范围;p 是元素比较谓词;rgx 是正则表达式。
表 15-16: Boost 字符串算法库中的 split 和 join 算法
| 函数 | 描述 |
|---|---|
find_all(res, s1, s2)``ifind_all(res, s1, s2)``find_all_regex(res, s1, rgx)``iter_find(res, s1, s2) |
查找 s1 中所有出现的 s2 或 rgx,将每个结果写入 res |
split(res, s, p)``split_regex(res, s, rgx)``iter_split(res, s, s2) |
使用 p、rgx 或 s2 拆分 s,并将结果写入 res |
join(seq, sep) |
返回一个 string,使用 sep 作为分隔符连接 seq 中的元素 |
join_if(seq, sep, p) |
返回一个 string,连接 seq 中所有匹配 p 的元素,并使用 sep 作为分隔符 |
查找
Boost 字符串算法在 <boost/algorithm/string/find.hpp> 头文件中提供了许多查找范围的函数。这些函数本质上是 表 15-8 中查找器的便捷封装。
例如,find_head 函数接受一个范围 s 和一个长度 n,并返回一个包含 s 的前 n 个元素的范围。示例 15-35 演示了 find_head 函数的用法。
#include <boost/algorithm/string/find.hpp>
TEST_CASE("boost::algorithm::find_head computes the head") {
std::string word("blandishment"); ➊
const auto result = boost::algorithm::find_head(word, 5); ➋
REQUIRE(result.begin() == word.begin()); ➌ // (b)landishment
REQUIRE(result.end() == word.begin()+5); ➍ // bland(i)shment
}
示例 15-35:find_head 函数从 string 的开头创建一个范围。
你构建了一个名为 word 的 string,其中包含 blandishment ➊。然后,你将它和长度参数 5 一起传递给 find_head ➋。result 的 begin 指向 word 的开始位置 ➌,end 指向第五个元素之后的位置 ➍。
表 15-17 列出了 <boost/algorithm/string/find.hpp> 中提供的许多查找算法。在此表中,s, s1 和 s2 是字符串;p 是元素比较谓词;rgx 是正则表达式;n 是一个整数值。
表 15-17: Boost 字符串算法库中的查找算法
| 谓词 | 查找 . . . |
|---|---|
find_first(s1, s2)``ifind_first(s1, s2) |
s1 中首次出现 s2 的位置 |
find_last(s1, s2)``ifind_last(s1, s2) |
s1 中最后一次出现 s2 的位置 |
find_nth(s1, s2, n)``ifind_nth(s1, s2, n) |
s1 中第 n 次出现 s2 的位置 |
find_head(s, n) |
s 的前 n 个字符 |
find_tail(s, n) |
s 的最后 n 个字符 |
find_token(s, p) |
s 中第一个与 p 匹配的字符 |
find_regex(s, rgx) |
s 中与 rgx 匹配的第一个子字符串 |
find(s, fnd) |
将 fnd 应用于 s 的结果 |
Boost Tokenizer
Boost Tokenizer 的 boost::tokenizer 是一个类模板,它提供了一个 string 中包含的标记序列的视图。一个 tokenizer 接受三个可选的模板参数:一个 tokenizer 函数,一个迭代器类型,和一个字符串类型。
tokenizer 函数 是一个谓词,用来判断一个字符是否是分隔符(返回 true)或不是(返回 false)。默认的 tokenizer 函数将空格和标点符号视为分隔符。如果你想明确指定分隔符,可以使用 boost::char_separator<char> 类,它接受一个包含所有分隔符字符的 C 字符串。例如,boost::char_separator<char>(";|,") 会在分号(;)、管道符号(|)和逗号(,)处分割。
迭代器类型和字符串类型与你想要分割的 string 类型对应。默认情况下,它们分别是 std::string::const_iterator 和 std::string。
因为 tokenizer 不会分配内存,而 boost::algorithm::split 会,因此当你只需要一次迭代 string 的标记时,强烈建议使用前者。
tokenizer 提供了 begin 和 end 方法,它们返回输入迭代器,因此你可以将其视为一个与底层标记序列对应的值范围。
清单 15-36 按逗号分割标志性回文 A man, a plan, a canal, Panama!。
#include<boost/tokenizer.hpp>
#include<string>
TEST_CASE("boost::tokenizer splits token-delimited strings") {
std::string palindrome("A man, a plan, a canal, Panama!"); ➊
boost::char_separator<char> comma{ "," }; ➋
boost::tokenizer<boost::char_separator<char>> tokens{ palindrome, comma }; ➌
auto itr = tokens.begin(); ➍
REQUIRE(*itr == "A man"); ➎
itr++; ➏
REQUIRE(*itr == " a plan");
itr++;
REQUIRE(*itr == " a canal");
itr++;
REQUIRE(*itr == " Panama!");
}
清单 15-36:boost::tokenizer 按指定分隔符分割字符串。
在这里,你构建了 palindrome ➊,char_separator ➋ 和相应的 tokenizer ➌。接下来,你使用其 begin 方法 ➍ 从 tokenizer 中提取一个迭代器。你可以像通常那样处理结果迭代器,解引用其值 ➎ 并递增到下一个元素 ➏。
本地化
locale 是一个用于编码文化偏好的类。locale 概念通常被编码在你的应用程序运行的操作环境中。它还控制许多偏好设置,例如字符串比较;日期和时间、货币和数字格式;邮政编码和 ZIP 代码;以及电话号码。
STL 提供了 std::locale 类以及 <locale> 头文件中的许多辅助函数和类。
由于简洁性(并且部分原因是本书的主要读者是讲英语的人),本章将不再深入探讨 locales。
总结
本章详细介绍了std::string及其生态系统。你在探索它与std::vector的相似性后,学习了它处理人类语言数据的内建方法,例如比较、添加、删除、替换和搜索。你了解了数字转换函数如何让你在数字和字符串之间转换,并且分析了std::string_view在传递字符串时的作用。你还学习了如何利用正则表达式执行基于复杂模式的匹配、搜索和替换。最后,你深入了解了 Boost 字符串算法库,它补充并扩展了std::string的内建方法,提供了额外的搜索、替换、修剪、删除、分割和连接方法。
练习
15-1. 重构清单 9-30 和 9-31 中的直方图计算器,改用std::string。根据程序的输入构造一个string,并修改AlphaHistogram的ingest方法,使其接受string_view或const string&。使用基于范围的for循环遍历已输入的string元素。将counts字段的类型替换为关联容器。
15-2. 实现一个程序,判断用户输入的是否为回文。
15-3. 实现一个程序,计算用户输入中的元音字母个数。
15-4. 实现一个支持加法、减法、乘法和除法的计算器程序,能够处理任意两个数字。考虑使用std::string的find方法和数字转换函数。
15-5. 通过以下方式扩展你的计算器程序:允许多种操作或模运算符,并接受浮动小数点数或括号。
15-6. 可选:阅读更多关于[本地化]的信息。
进一步阅读
-
ISO 国际标准 ISO/IEC (2017) — 编程语言 C++(国际标准化组织;瑞士日内瓦;*
isocpp.org/std/the-standard/*) -
C++编程语言,第 4 版,作者:Bjarne Stroustrup(Pearson Education,2013)
-
Boost C++库,第 2 版,作者:Boris Schäling(XML Press,2014)
-
C++标准库:教程与参考,第 2 版,作者:Nicolai M. Josuttis(Addison-Wesley Professional,2012)
第十九章:流**
*要么写些值得阅读的东西,要么做些值得书写的事情。
—本杰明·富兰克林*

本章介绍流这一主要概念,它使你能够使用一个通用框架连接来自任何源的输入和任何目标的输出。你将了解构成该通用框架的基本元素的类、几个内置功能,并学习如何将流集成到用户定义的类型中。
流
流 模拟 数据流。在流中,数据在对象之间流动,这些对象可以对数据执行任意处理。当你使用流时,输出是进入流的数据,输入是流中出来的数据。这些术语反映了用户视角下的流。
在 C++ 中,流是执行输入输出(I/O)的主要机制。无论数据源或目标是什么,你都可以使用流作为连接输入和输出的通用语言。STL 使用类继承来编码不同流类型之间的关系。这些层次结构中的主要类型有:
-
<ostream>头文件中的std::basic_ostream类模板代表输出设备 -
<istream>头文件中的std::basic_istream类模板代表输入设备 -
<iostream>头文件中的std::basic_iostream类模板代表同时具有输入输出功能的设备
这三种流类型都需要两个模板参数。第一个对应流的底层数据类型,第二个对应特征类型。
本节从用户的角度介绍流,而不是从库实现者的角度。你将了解流的接口,并知道如何使用 STL 内置的流支持与标准 I/O、文件和字符串进行交互。如果你必须实现一种新的流(例如,为新的库或框架),你将需要一份 ISO C++ 17 标准、一些工作示例以及大量的咖啡。I/O 很复杂,你会看到这种复杂性在流实现的内部结构中有所体现。幸运的是,设计良好的流类会将这些复杂性隐藏起来,使得用户不必直接面对。
流类
所有用户交互的 STL 流类都来源于basic_istream、basic_ostream,或者通过basic_iostream同时继承这两者。声明每种类型的头文件还为这些模板提供了char和wchar_t的特化,如表 16-1 所示。这些广泛使用的特化在处理人类语言数据的输入输出时尤其有用。
表 16-1: 主要流模板的模板特化
| 模板 | 参数 | 特化 | 头文件 |
|---|---|---|---|
basic_istream |
char |
istream |
<istream> |
basic_ostream |
char |
ostream |
<ostream> |
basic_iostream |
char |
iostream |
<iostream> |
basic_istream |
wchar_t |
wistream |
<istream> |
basic_ostream |
wchar_t |
wostream |
<ostream> |
basic_iostream |
wchar_t |
wiostream |
<iostream> |
表 16-1 中的对象是你可以在程序中使用的抽象,你可以利用它们编写通用代码。你想写一个将输出日志记录到任意源的函数吗?如果是,你可以接受一个 ostream 引用参数,而不需要处理所有那些令人头疼的实现细节。(稍后在“输出文件流”部分第 542 页,你将学到如何实现这一点。)
通常,你可能需要与用户(或程序的执行环境)进行 I/O 操作。全局流对象提供了一个方便的基于流的封装,供你操作。
全局流对象
STL 在 <iostream> 头文件中提供了几个 全局流对象,它们封装了输入、输出和错误流 stdin、stdout 和 stderr。这些实现定义的标准流是你程序与其执行环境之间的预连接通道。例如,在桌面环境中,stdin 通常绑定到键盘,stdout 和 stderr 则绑定到控制台。
注意
回想一下,在 第一部分中,你看到过广泛使用 printf 向 stdout 写入数据。
表 16-2 列出了全局流对象,所有这些对象都位于 std 命名空间中。
表 16-2: 全局流对象
| 对象 | 类型 | 目的 |
|---|---|---|
cout``wcout |
ostream``wostream |
输出,如屏幕 |
cin``wcin |
istream``wistream |
输入,如键盘 |
cerr``wcerr |
ostream``wostream |
错误输出(无缓冲) |
clog``wclog |
ostream``wostream |
错误输出(有缓冲) |
那么如何使用这些对象呢?流类支持的操作可以分为两类:
格式化操作 可能会在执行 I/O 之前对输入参数进行一些预处理
未格式化操作 直接执行 I/O 操作
接下来的部分会依次解释这些类别。
格式化操作
所有格式化 I/O 都通过两个函数传递:标准流操作符,operator<< 和 operator>>。你会认出这些是来自“逻辑运算符”部分的左移和右移操作符第 182 页。有些令人困惑的是,流重载了左移和右移操作符,赋予它们完全不同的功能。表达式 i << 5 的语义完全依赖于 i 的类型。如果 i 是一个整数类型,这个表达式的意思是 取 i 并将其按左移五个二进制位。如果 i 不是一个整数类型,它意味着 将值 5 写入 i。虽然这种符号冲突很不幸,但在实际应用中并不会造成太大问题。只需要注意你使用的类型,并且充分测试你的代码。
输出流重载了operator<<,它被称为输出操作符或插入器。basic_ostream类模板为所有基本类型(除了void和nullptr_t)及一些 STL 容器(如basic_string、complex和bitset)重载了输出操作符。作为ostream的用户,你无需担心这些重载如何将对象转换为可读输出。
清单 16-1 展示了如何使用输出操作符将各种类型写入cout。
#include <iostream>
#include <string>
#include <bitset>
using namespace std;
int main() {
bitset<8> s{ "01110011" };
string str("Crying zeros and I'm hearing ");
size_t num{ 111 };
cout << s; ➊
cout << '\n'; ➋
cout << str; ➌
cout << num; ➍
cout << "s\n"; ➎
}
-----------------------------------------------------------------------
01110011 ➊➋
Crying zeros and I'm hearing 111s ➌➍➎
清单 16-1:使用cout和operator<<写入标准输出
你使用输出操作符<<将bitset ➊、char ➋、string ➌、size_t ➍和一个以空字符终止的字符串文字 ➎通过cout写入标准输出。尽管你向控制台输出了五种不同类型的数据,但你无需处理序列化问题。(考虑如果使用printf`来得到类似的输出,你将不得不跳过多少障碍。)
标准流操作符的一个非常棒的特点是,它们通常会返回对流的引用。从概念上讲,重载通常是这样定义的:
ostream& operator<<(ostream&, char);
这意味着你可以将输出操作符链接在一起。通过这种技巧,你可以重构清单 16-1,使得cout只出现一次,正如清单 16-2 所示。
#include <iostream>
#include <string>
#include <bitset>
using namespace std;
int main() {
bitset<8> s{ "01110011" };
string str("Crying zeros and I'm hearing ");
size_t num{ 111 };
cout << s << '\n' << str << num << "s\n"; ➊
}
-----------------------------------------------------------------------
01110011
Crying zeros and I'm hearing 111s ➊
清单 16-2:通过链式调用输出操作符重构清单 16-1
由于每次调用operator<<都会返回一个对输出流(此处为cout)的引用,你只需将这些调用链接在一起,就能获得相同的输出 ➊。
输入流重载了operator>>,它被称为输入操作符或提取器。basic_istream类为所有与basic_ostream相同的类型提供了对应的输入操作符重载,同样作为用户,你也可以在很大程度上忽略反序列化的细节。
清单 16-3 展示了如何使用输入操作符从cin读取两个double对象和一个string,然后将推导出的数学运算结果输出到标准输出。
#include <iostream>
#include <string>
using namespace std;
int main() {
double x, y;
cout << "X: ";
cin >> x; ➊
cout << "Y: ";
cin >> y; ➋
string op;
cout << "Operation: ";
cin >> op; ➌
if (op == "+") {
cout << x + y; ➍
} else if (op == "-") {
cout << x - y; ➎
} else if (op == "*") {
cout << x * y; ➏
} else if (op == "/") {
cout << x / y; ➐
} else {
cout << "Unknown operation " << op; ➑
}
}
清单 16-3:一个原始计算器程序,使用cin和operator<<收集输入
在这里,你收集了两个double类型的值x ➊和y ➋,接着是string op ➌,它编码了所需的运算类型。通过if语句,你可以输出指定运算的结果,如加法 ➍、减法 ➎、乘法 ➏和除法 ➐,或者告诉用户op是未知的 ➑。
要使用该程序,你需要按照指示在控制台中输入请求的值。一个换行符将会把输入(作为 stdin)传递给cin,如清单 16-4 所示。
X: 3959 ➊
Y: 6.283185 ➋
Operation: * ➌
24875.1 ➍
清单 16-4:一个示例程序运行,计算地球的周长(以英里为单位),来自清单 16-3
你输入了两个double对象:地球的半径,单位为英里,3959 ➊ 和 2π,6.283185 ➋,并指定了乘法* ➌。结果是地球的周长,单位为英里 ➍。注意,对于整数值➊,你不需要提供小数点;流会智能地知道有一个隐式的小数点。
注意
你可能会想,如果在示例 16-4 中输入一个非数字字符串作为X ➊ 或 Y ➋ 会发生什么。流进入错误状态,稍后你将在本章的“流状态”部分(第 530 页)了解这个问题。在错误状态下,流停止接受输入,程序将不再接受任何输入。
未格式化操作
当你在处理基于文本的流时,通常会想使用格式化操作符;然而,如果你在处理二进制数据或编写需要低级访问流的代码时,你需要了解未格式化操作。未格式化输入输出涉及很多细节。为了简洁起见,本节提供了相关方法的总结,如果你需要使用未格式化操作,请参考[input.output]。
istream类有许多未格式化的输入方法。这些方法在字节级别操作流,并在表 16-3 中进行了总结。在此表中,is是类型为std::istream <T>,s是char*,n是流大小,pos是位置类型,d是类型T的定界符。
表 16-3: istream的未格式化读取操作
| 方法 | 描述 |
|---|---|
is.get([c]) |
返回下一个字符,或者如果提供了字符引用 c,则写入该字符。 |
is.get(s, n, [d])is.getline(s, n, [d]) |
操作get将最多 n 个字符读取到缓冲区 s 中,遇到换行符时停止,若提供了 d,则在遇到 d 时停止。操作getline与之相同,唯一的区别是它还会读取换行符。两者都会将终止的空字符写入 s。你必须确保s有足够的空间。 |
is.read(s, n)is.readsome(s, n) |
操作read将最多 n 个字符读取到缓冲区 s 中;遇到文件结尾时会报错。操作readsome与之相同,唯一的区别是它不把文件结尾视为错误。 |
is.gcount() |
返回is上次未格式化读取操作所读取的字符数。 |
is.ignore() |
提取并丢弃一个字符。 |
is.ignore(n, [d]) |
提取并丢弃最多 n 个字符。如果提供了 d,则在遇到 d 时停止。 |
is.peek() |
返回下一个待读取的字符,但不提取它。 |
is.unget() |
将最后提取的字符放回字符串中。 |
is.putback(c) |
如果c是最后提取的字符,执行unget操作。否则,设置badbit。详见“流状态”部分。 |
输出流有相应的未格式化写入操作,它们在非常低的层次上操作流,如表 16-4 所总结。在该表中,os 是 std::ostream <T> 类型,s 是 char*,n 是流的大小。
表 16-4: ostream 的未格式化写入操作
| 方法 | 描述 |
|---|---|
os.put(c) |
将 c 写入流 |
os.write(s, n) |
将 n 个字符从 s 写入流 |
os.flush() |
将所有缓冲数据写入底层设备 |
基本类型的特殊格式化
所有基本类型,除了 void 和 nullptr,都重载了输入和输出操作符,但有些类型有特殊规则:
char 和 wchar_t 输入操作符会跳过空白字符来处理字符类型。
char* 和 wchar_t* 输入操作符首先跳过空白字符,然后读取字符串,直到遇到另一个空白字符或文件结尾(EOF)。必须为输入保留足够的空间。
void* 地址格式依赖于实现,输入和输出操作符也是如此。在桌面系统上,地址通常以十六进制字面量形式表示,如 32 位的 0x01234567 或 64 位的 0x0123456789abcdef。
bool 输入和输出操作符将布尔值视为数字:true 为 1,false 为 0。
数字类型 输入操作符要求输入必须以至少一个数字开头。格式不正确的输入数字会导致零值结果。
这些规则乍一看可能有些奇怪,但一旦习惯了,它们其实相当简单明了。
注意
避免读取 C 风格字符串,因为你需要确保为输入数据分配了足够的空间。未进行充分检查会导致未定义行为,可能带来严重的安全漏洞。建议使用 std::string 替代。
流状态
流的状态指示了输入/输出是否失败。每种流类型都暴露出常量静态成员,统称为它的位标志,这些标志指示流的可能状态:goodbit、badbit、eofbit 和 failbit。要判断流是否处于特定状态,可以调用返回bool值的成员函数,表示流是否处于对应状态。表 16-5 列出了这些成员函数、true结果对应的流状态以及该状态的含义。
表 16-5: 可能的流状态、它们的访问方法及其含义
| 方法 | 状态 | 含义 |
|---|---|---|
good() |
goodbit |
流处于良好的工作状态。 |
eof() |
eofbit |
流遇到文件结尾(EOF)。 |
fail() |
failbit |
输入或输出操作失败,但流可能仍处于良好的工作状态。 |
bad() |
badbit |
发生了灾难性错误,流不处于良好状态。 |
注意
要将流的状态重置为良好的工作状态,可以调用其 clear() 方法。
流实现了隐式的布尔转换(operator bool),因此你可以简单直接地检查流是否处于良好的工作状态。例如,你可以使用一个简单的while循环逐词从 stdin 读取输入,直到遇到 EOF(或其他失败条件)。清单 16-5 展示了一个使用此技巧生成 stdin 单词计数的简单程序。
#include <iostream>
#include <string>
int main() {
std::string word; ➊
size_t count{}; ➋
while (std::cin >> word) ➌
count++; ➍
std::cout << "Discovered " << count << " words.\n"; ➎
}
清单 16-5:一个从 stdin 读取并计数单词的程序
你声明一个名为word的string类型变量来接收来自 stdin 的单词➊,并将count变量初始化为零➋。在while循环的布尔表达式中,你尝试将新的输入赋值给word➌。当成功时,你会增加count的值➍。一旦失败——例如,遇到 EOF——你就停止增加并打印最终的计数结果➎。
你可以尝试两种方法来测试清单 16-5。首先,你可以直接调用程序,输入一些文本,然后提供 EOF。如何发送 EOF 取决于你的操作系统。在 Windows 命令行中,你可以通过按 CTRL-Z 并回车来输入 EOF。在 Linux bash 或 OS X shell 中,你按 CTRL-D。清单 16-6 演示了如何从 Windows 命令行调用清单 16-5。
$ listing_16_5.exe ➊
Size matters not. Look at me. Judge me by my size, do you? Hmm? Hmm. And well
you should not. For my ally is the Force, and a powerful ally it is. Life
creates it, makes it grow. Its energy surrounds us and binds us. Luminous
beings are we, not this crude matter. You must feel the Force around you;
here, between you, me, the tree, the rock, everywhere, yes. ➋
^Z ➌
Discovered 70 words. ➍
清单 16-6:通过在控制台输入来调用清单 16-5 中的程序
首先,你调用你的程序➊。接着,输入一些任意文本,后跟换行符➋。然后发出 EOF。在 Windows 命令行中,命令行上会显示一些有些晦涩的序列^Z,此时你必须按回车键。这会导致std::cin进入eofbit状态,从而结束清单 16-5 中的while循环➌。程序显示你已将 70 个单词发送到 stdin ➍。
在 Linux 和 Mac 以及 Windows PowerShell 中,你有另一个选择。你可以将文本保存到一个文件中,比如yoda.txt,而不是直接在控制台中输入。诀窍是使用cat命令读取文本文件,然后使用管道操作符|将内容传递给你的程序。管道操作符将程序左侧的 stdout 传递到右侧程序的 stdin。以下命令演示了这一过程:
$ cat yoda.txt➊ |➋ ./listing_15_4➌
Discovered 70 words.
cat命令读取yoda.txt的内容➊。管道操作符➋将cat的 stdout 传递到listing_15_4的 stdin➌。由于cat在遇到yoda.txt的结尾时会发送 EOF,因此你无需手动输入 EOF。
有时你希望在出现某些故障位时,流会抛出异常。你可以通过流的exceptions方法轻松做到这一点,该方法接受一个参数,表示你希望抛出异常的位。如果你希望多个位抛出异常,只需使用布尔 OR (|)将它们连接起来。
示例 16-7 展示了如何重构 示例 16-5,以便用异常处理 badbit,并默认处理 eofbit/failbit。
#include <iostream>
#include <string>
using namespace std;
int main() {
cin.exceptions(istream::badbit); ➊
string word;
size_t count{};
try { ➋
while(cin >> word) ➌
count++;
cout << "Discovered " << count << " words.\n"; ➍
} catch (const std::exception& e) { ➎
cerr << "Error occurred reading from stdin: " << e.what(); ➏
}
}
示例 16-7:重构 示例 16-5 来处理 badbit 异常
程序通过调用 std::cin 上的异常方法开始 ➊。由于 cin 是一个 istream,你将 istream::badbit 作为 exception 参数传递,表示希望每当 cin 进入灾难性状态时抛出异常。为了处理可能出现的异常,你将现有代码包裹在一个 try-catch 块中 ➋,这样,如果 cin 在读取输入时设置了 badbit ➌,用户就不会收到关于词数的消息 ➍。相反,程序会捕获由此产生的异常 ➎ 并打印错误信息 ➏。
缓冲与刷新
许多 ostream 类模板在底层涉及操作系统调用,例如,写入控制台、文件或网络套接字。与其他函数调用相比,系统调用通常比较慢。为了避免每输出一个元素都调用一次系统调用,应用程序可以等待多个元素一起输出,从而提高性能。
排队行为被称为 缓冲。当流清空缓冲区并输出内容时,这被称为 刷新。通常,这种行为对用户是完全透明的,但有时你可能希望手动刷新 ostream。为此(以及其他任务),你可以使用操控符。
操控符
操控符 是一些特殊的对象,用于修改流的输入解释方式或格式化输出。操控符的存在是为了执行许多类型的流操作。例如,std::ws 修改一个 istream,跳过空白字符。以下是一些其他适用于 ostream 的操控符:
-
std::flush会将任何缓冲区中的输出直接刷新到ostream。 -
std::ends发送一个空字节。 -
std::endl类似于std::flush,不过它会先发送一个换行符再进行刷新。
表 16-6 总结了 <istream> 和 <ostream> 头文件中的操控符。
表 16-6: <istream> 和 <ostream> 头文件中的四个操控符
| 操控符 | 类 | 行为 |
|---|---|---|
ws |
istream |
跳过所有空白字符 |
flush |
ostream |
通过调用其 flush 方法将任何缓冲数据写入流 |
ends |
ostream |
发送一个空字节 |
endl |
ostream |
发送换行并刷新输出 |
例如,你可以将 示例 16-7 中的 ➍ 替换为以下内容:
cout << "Discovered " << count << " words." << endl;
这将打印一个换行符,并同时刷新输出。
注意
作为一般规则,当程序在一段时间内已经完成向流输出文本时,使用 std::endl,当你知道程序很快会继续输出文本时,使用 \n。
标准库提供了许多其他操作符,位于 <ios> 头文件中。例如,你可以确定 ostream 是以文本方式(boolalpha)还是数字方式(noboolalpha)表示布尔值;以八进制(oct)、十进制(dec)或十六进制(hex)表示整数值;以十进制表示浮点数(fixed)或科学记数法表示(scientific)。只需将其中一个操作符传递给 ostream,使用 operator<<,那么所有后续插入的相应类型的数据都会被操控(不仅仅是紧接着的一个操作数)。
你还可以使用 setw 操作符设置流的宽度参数。流的宽度参数会根据流的不同产生不同的效果。例如,在 std::cout 中,setw 将固定分配给下一个输出对象的字符数。此外,对于浮点输出,setprecision 将设置随后的数字精度。
示例 16-8 演示了这些操作符如何执行与各种 printf 格式说明符类似的功能。
#include <iostream>
#include <iomanip>
using namespace std;
int main() {
cout << "Gotham needs its " << boolalpha << true << " hero."; ➊
cout << "\nMark it " << noboolalpha << false << "!"; ➋
cout << "\nThere are " << 69 << "," << oct << 105 << " leaves in here."; ➌
cout << "\nYabba " << hex << 3669732608 << "!"; ➍
cout << "\nAvogadro's number: " << scientific << 6.0221415e-23; ➎
cout << "\nthe Hogwarts platform: " << fixed << setprecision(2) << 9.750123; ➏
cout << "\nAlways eliminate " << 3735929054; ➐
cout << setw(4) << "\n"
<< 0x1 << "\n"
<< 0x10 << "\n"
<< 0x100 << "\n"
<< 0x1000 << endl; ➑
}
-----------------------------------------------------------------------
Gotham needs its true hero. ➊
Mark it 0! ➋
There are 69,151 leaves in here. ➌
Yabba dabbad00! ➍
Avogadro's Number: 6.022142e-23 ➎
the Hogwarts platform: 9.75 ➏
Always eliminate deadc0de ➐
1
10
100
1000 ➑
示例 16-8:演示 <iomanip> 头文件中一些操作符的程序
第一行的 boolalpha 操作符使布尔值以文本形式打印为 true 和 false ➊,而 noboolalpha 则使其以 1 和 0 形式打印 ➋。对于整数值,你可以使用 oct ➌ 打印为八进制,或使用 hex ➍ 打印为十六进制。对于浮点值,你可以使用 scientific ➎ 指定科学记数法,并且可以通过 setprecision 设置打印的数字精度,使用 fixed 指定十进制表示法 ➏。因为操作符应用于所有后续插入流中的对象,所以当你在程序结尾打印另一个整数值时,最后使用的整数操作符(hex)会被应用,因此你将得到一个十六进制表示 ➐。最后,你使用 setw 设置输出字段宽度为 4,然后打印一些整数值 ➑。
表 16-7 总结了常见操作符的示例。
表 16-7: <iomanip> 头文件中可用的许多操作符
| 操作符 | 行为 |
|---|---|
boolalpha |
以文本形式表示布尔值,而非数字形式。 |
noboolalpha |
以数字形式表示布尔值,而非文本形式。 |
oct |
以八进制表示整数值。 |
dec |
以十进制表示整数值。 |
hex |
以十六进制表示整数值。 |
setw(n) |
将流的宽度参数设置为 n。具体效果取决于流。 |
setprecision(p) |
设置浮点数精度为 p。 |
fixed |
以十进制表示浮点数。 |
scientific |
以科学记数法表示浮点数。 |
注意
请参阅 Nicolai M. Josuttis 所著《C++ 标准库》第 2 版的 第十五章,或参考 [iostream.format]。
用户定义类型
你可以通过实现某些非成员函数,使用户自定义类型与流兼容。要为YourType实现输出操作符,以下函数声明可以满足大多数用途:
ostream&➊ operator<<(ostream&➋ s, const YourType& m ➌);
在大多数情况下,你只需返回➊接收到的相同ostream ➋。如何将输出发送到ostream是由你决定的。但通常,这涉及访问YourType上的字段 ➌,可选地执行一些格式化和转换,然后使用输出操作符。例如,清单 16-9 展示了如何为std::vector实现输出操作符,以打印其大小、容量和元素。
#include <iostream>
#include <vector>
#include <string>
using namespace std;
template <typename T>
ostream& operator<<(ostream& s, vector<T> v) { ➊
s << "Size: " << v.size()
<< "\nCapacity: " << v.capacity()
<< "\nElements:\n"; ➋
for (const auto& element : v)
s << "\t" << element << "\n"; ➌
return s; ➍
}
int main() {
const vector<string> characters {
"Bobby Shaftoe",
"Lawrence Waterhouse",
"Gunter Bischoff",
"Earl Comstock"
}; ➎
cout << characters << endl; ➏
const vector<bool> bits { true, false, true, false }; ➐
cout << boolalpha << bits << endl; ➑
}
-----------------------------------------------------------------------
Size: 4
Capacity: 4
Elements: ➋
Bobby Shaftoe ➌
Lawrence Waterhouse ➌
Gunter Bischoff ➌
Earl Comstock ➌
Size: 4
Capacity: 32
Elements: ➋
true ➌
false ➌
true ➌
false ➌
清单 16-9:演示如何为vector实现输出操作符的程序
首先,你定义一个自定义输出操作符作为模板,使用模板参数作为std::vector的模板参数➊。这样,你就可以将输出操作符应用于多种类型的vector(只要类型T也支持输出操作符)。输出的前三行显示vector的大小和容量,以及标题Elements,指示接下来是vector的元素➋。接下来的for循环遍历vector中的每个元素,将每个元素分别发送到ostream中➌。最后,返回流引用s ➍。
在main中,你初始化了一个名为characters的vector,其中包含四个字符串 ➎。借助你定义的输出操作符,你可以像处理基本类型一样,直接将characters发送到cout ➏。第二个示例使用了一个名为bits的vector<bool>,你也用四个元素初始化它 ➐,并打印到标准输出 ➑。注意,你使用了boolalpha操作符,这样当你定义的输出操作符运行时,bool元素会以文本形式打印 ➌。
你还可以提供用户自定义的输入操作符,其工作方式类似。一个简单的推论如下:
istream&➊ operator>>(istream&➋ s, YourType& m ➌);
与输出操作符类似,输入操作符通常返回➊接收到的相同流 ➋。然而,与输出操作符不同,YourType的引用通常不会是const,因为你希望使用流中的输入来修改相应的对象 ➌。
清单 16-10 演示了如何为deque指定输入操作符,使其将元素推送到容器中,直到插入失败(例如,遇到 EOF 字符)。
#include <iostream>
#include <deque>
using namespace std;
template <typename T>
istream& operator>>(istream& s, deque<T>& t) { ➊
T element; ➋
while (s >> element) ➌
t.emplace_back(move(element)); ➍
return s; ➎
}
int main() {
cout << "Give me numbers: "; ➏
deque<int> numbers;
cin >> numbers; ➐
int sum{};
cout << "Cumulative sum:\n";
for(const auto& element : numbers) {
sum += element;
cout << sum << "\n"; ➑
}
}
-----------------------------------------------------------------------
Give me numbers: ➏ 1 2 3 4 5 ➐
Cumulative sum:
1 ➑
3 ➑
6 ➑
10 ➑
15 ➑
清单 16-10:演示如何为deque实现输入操作符的程序
你的用户定义的输入运算符是一个函数模板,因此你可以接受任何支持输入运算符的 deque 类型 ➊。首先,你构造一个 T 类型的元素,以便从 istream 中存储输入 ➋。接下来,你使用熟悉的 while 结构从 istream 接受输入,直到输入操作失败 ➌。(回想一下“流状态”一节,流可能因多种原因进入失败状态,包括到达文件末尾或遇到 I/O 错误。)每次插入后,你将结果 move 到 deque 的 emplace_back 中,以避免不必要的拷贝 ➍。插入完成后,你只需返回 istream 引用 ➎。
在 main 中,你提示用户输入数字 ➏,然后使用插入运算符对新初始化的 deque 执行插入操作,将元素从标准输入流插入。在本示例程序的运行中,你输入了数字 1 到 5 ➐。为了增加趣味性,你通过保持一个累积和并对每个元素进行迭代,打印每次迭代的结果 ➑。
注意
前面的示例是简单的用户定义输入和输出运算符的实现。你可能希望在生产代码中扩展这些实现。例如,这些实现仅适用于 ostream 类,这意味着它们无法与任何非 char 序列一起使用。
字符串流
字符串流类 提供了从字符序列中读取和写入的功能。这些类在多个场合都非常有用。输入字符串尤其有用,如果你想将字符串数据解析为不同类型。因为你可以使用输入运算符,所以所有标准的操作符功能都可以使用。输出字符串非常适合从可变长度的输入中构建字符串。
输出字符串流
输出字符串流 为字符序列提供输出流语义,它们都从 <sstream> 头文件中的类模板 std::basic_ostringstream 派生,并提供以下特化:
using ostringstream = basic_ostringstream<char>;
using wostringstream = basic_ostringstream<wchar_t>;
输出字符串流支持与 ostream 相同的所有功能。每当你向字符串流发送输入时,流会将这些输入存储到内部缓冲区中。你可以将其视为与 string 的 append 操作在功能上等效(除了字符串流可能更高效)。
输出字符串流还支持 str() 方法,它有两种操作模式。如果没有传递参数,str 返回内部缓冲区的副本作为 basic_string(因此 ostringstream 返回 string;wostringstream 返回 wstring)。如果传递了一个 basic_string 参数,字符串流将用该参数的内容替换其缓冲区的当前内容。清单 16-11 演示了如何使用 ostringstream,将字符数据发送到其中,构建一个 string,重置其内容并重复此过程。
#include <string>
#include <sstream>
TEST_CASE("ostringstream produces strings with str") {
std::ostringstream ss; ➊
ss << "By Grabthar's hammer, ";
ss << "by the suns of Worvan. ";
ss << "You shall be avenged."; ➋
const auto lazarus = ss.str(); ➌
ss.str("I am Groot."); ➍
const auto groot = ss.str(); ➎
REQUIRE(lazarus == "By Grabthar's hammer, by the suns"
" of Worvan. You shall be avenged.");
REQUIRE(groot == "I am Groot.");
}
清单 16-11:使用 ostringstream 构建字符串
在声明一个ostringstream ➊之后,你像使用其他任何ostream一样使用它,利用输出操作符发送三个独立的字符序列 ➋。接下来,你调用不带参数的str,它生成一个名为lazarus的string ➌。然后你使用带有字符串字面量I am Groot ➍调用str,这会替换ostringstream的内容 ➎。
注意
回忆一下,在“C 风格字符串”部分,第 45 页提到过,你可以将多个字符串字面量放在连续的行中,编译器会将它们视为一个字符串。这完全是为了源代码格式化的目的。
输入字符串流
输入字符串流为字符序列提供输入流语义,它们都继承自<sstream>头文件中的类模板std::basic_istringstream,该类提供了以下特化:
using istringstream = basic_istringstream<char>;
using wistringstream = basic_istringstream<wchar_t>;
这些特化类似于basic_ostringstream。你可以通过传递一个适当特化的basic_string(对于istringstream是string,对于wistringstream是wstring)来构造输入字符串流。列表 16-12 演示了通过构造一个包含三个数字的字符串输入流,并使用输入操作符提取它们。(回忆一下在“格式化操作”中提到的内容,关于第 525 页,空白符是字符串数据的适当分隔符。)
TEST_CASE("istringstream supports construction from a string") {
std::string numbers("1 2.23606 2"); ➊
std::istringstream ss{ numbers }; ➋
int a;
float b, c, d;
ss >> a; ➌
ss >> b; ➍
ss >> c;
REQUIRE(a == 1);
REQUIRE(b == Approx(2.23606));
REQUIRE(c == Approx(2));
REQUIRE_FALSE(ss >> d); ➎
}
列表 16-12:使用string构建istringstream对象并提取数值类型
你从字面量1 2.23606 2 ➊构建一个string,并将其传入名为ss ➋的istringstream构造函数。这使得你可以像处理任何其他输入流一样,使用输入操作符解析出int对象 ➌和float对象 ➍。当你耗尽流并且输出操作符失败时,ss会转换为false ➎。
支持输入和输出的字符串流
此外,如果你需要一个支持输入和输出操作的字符串流,可以使用basic_stringstream,它具有以下特化:
using stringstream = basic_stringstream<char>;
using wstringstream = basic_stringstream<wchar_t>;
该类支持输入和输出操作符、str方法以及从字符串构造的功能。列表 16-13 演示了如何使用输入和输出操作符的组合从字符串中提取标记。
TEST_CASE("stringstream supports all string stream operations") {
std::stringstream ss;
ss << "Zed's DEAD"; ➊
std::string who;
ss >> who; ➋
int what;
ss >> std::hex >> what; ➌
REQUIRE(who == "Zed's");
REQUIRE(what == 0xdead);
}
列表 16-13:使用stringstream进行输入和输出
你创建了一个stringstream,并使用输出操作符发送Zed's DEAD ➊。接下来,你使用输入操作符从stringstream中解析出Zed's ➋。因为DEAD是一个有效的十六进制整数,所以你使用输入操作符和std::hex操纵符将其提取为int ➌。
注意
所有字符串流都是可移动的。
字符串流操作总结
表 16-8 提供了 basic_stringstream 操作的部分列表。在此表中,ss, ss1 和 ss2 类型为 std::basic_stringstream<T>;s 为 std::basic_string<``T``>;obj 为格式化对象;pos 为位置类型;dir 为 std::ios_base::seekdir;flg 为 std::ios_base::iostate。
表 16-8: std::basic_stringstream 操作的部分列表
| 操作 | 备注 |
|---|---|
basic_stringstream<T>``{ [s], [om] } |
执行新构造的字符串流的花括号初始化。默认为空字符串 s 和 in|out 打开模式 om。 |
basic_stringstream<T>``{ move(ss) } |
获取 ss 的内部缓冲区所有权。 |
~basic_stringstream |
析构内部缓冲区。 |
ss.rdbuf() |
返回原始字符串设备对象。 |
ss.str() |
获取字符串设备对象的内容。 |
ss.str(s) |
将字符串设备对象的内容设置为 s。 |
ss >> obj |
从字符串流中提取格式化数据。 |
ss << obj |
将格式化数据插入到字符串流中。 |
ss.tellg() |
返回输入位置索引。 |
ss.seekg(pos)ss.seekg(pos, dir) |
设置输入位置指示符。 |
ss.flush() |
同步底层设备。 |
ss.good()ss.eof()ss.bad()!ss |
检查字符串流的位状态。 |
ss.exceptions(flg) |
配置字符串流,在 flg 中的某一位被设置时抛出异常。 |
ss1.swap(ss2)``swap(ss1, ss2) |
交换 ss1 和 ss2 的每个元素。 |
文件流
文件流类 提供了读取和写入字符序列的功能。文件流类的结构遵循字符串流类的结构。文件流类模板可用于输入、输出或二者兼有。
文件流类提供了相较于使用原生系统调用操作文件内容的以下主要优势:
-
您将获得常规流接口,这些接口提供了丰富的功能,用于格式化和操作输出。
-
文件流类是文件的 RAII 包装器,这意味着不可能泄露资源,例如文件。
-
文件流类支持移动语义,因此您可以精确控制文件的作用范围。
使用流打开文件
您可以选择两种方式使用文件流打开文件。第一种方法是 open 方法,它接受 const char* filename 和一个可选的 std::ios_base::openmode 位掩码参数。openmode 参数可以是 表 16-9 中列出的多种值组合之一。
表 16-9: 可能的流状态、其访问方法及含义
标志 (in std::ios) |
文件 | 含义 |
|---|---|---|
in |
必须存在 | 读取 |
out |
如果不存在则创建 | 删除文件,然后写入 |
app |
如果不存在则创建 | 追加 |
in|out |
必须存在 | 从开头读写 |
| `in | app` | 如果文件不存在则创建 |
| `out | app` | 如果文件不存在则创建 |
| `out | trunc` | 如果文件不存在则创建 |
| `in | out | app` |
| `in | out | trunc` |
此外,你可以将binary标志添加到这些组合中的任何一个,以使文件处于二进制模式。在二进制模式下,流不会转换特殊字符序列,如行结束符(例如,Windows 上的回车符加换行符)或 EOF。
指定要打开的文件的第二种方法是使用流的构造函数。每个文件流提供一个构造函数,接受与open方法相同的参数。所有文件流类都是对它们所拥有的文件句柄的 RAII 封装,因此当文件流对象析构时,文件会自动清理。你也可以手动调用close方法,该方法不接受任何参数。如果你知道文件操作已经完成,但你的代码结构使得文件流类对象不会立即析构,那么你可能想手动调用这个方法。
文件流也有默认构造函数,这些构造函数不会打开任何文件。要检查文件是否已打开,可以调用is_open方法,该方法不接受任何参数,返回一个布尔值。
输出文件流
输出文件流提供字符序列的输出流语义,它们都从std::basic_ofstream类模板派生,该模板定义在<fstream>头文件中,并提供以下特化:
using ofstream = basic_ofstream<char>;
using wofstream = basic_ofstream<wchar_t>;
默认的basic_ofstream构造函数不会打开文件,而非默认构造函数的第二个可选参数默认设置为ios::out。
每当你向文件流发送输入时,流会将数据写入相应的文件。清单 16-14 展示了如何使用ofstream将简单的消息写入文本文件。
#include <fstream>
using namespace std;
int main() {
ofstream file{ "lunchtime.txt", ios::out|ios::app }; ➊
file << "Time is an illusion." << endl; ➋
file << "Lunch time, " << 2 << "x so." << endl; ➌
}
-----------------------------------------------------------------------
lunchtime.txt:
Time is an illusion. ➋
Lunch time, 2x so. ➌
清单 16-14:一个打开文件 lunchtime.txt 并向其中追加消息的程序。(输出对应程序执行一次后 lunchtime.txt 的内容。)
你初始化了一个名为file的ofstream对象,使用路径lunchtime.txt和标志out与app ➊。因为这个标志组合是追加输出,所以你通过输出运算符发送到此文件流的数据会被追加到文件末尾。如预期,文件包含你通过输出运算符传递的消息 ➋➌。
得益于ios::app标志,如果lunchtime.txt文件存在,程序会将输出追加到该文件。例如,如果你再次运行程序,输出将是:
Time is an illusion.
Lunch time, 2x so.
Time is an illusion.
Lunch time, 2x so.
程序的第二次迭代将相同的短语添加到了文件末尾。
输入文件流
输入文件流提供字符序列的输入流语义,它们都从std::basic_ifstream类模板派生,该模板定义在<fstream>头文件中,并提供以下特化:
using ifstream = basic_ifstream<char>;
using wifstream = basic_ifstream<wchar_t>;
默认的basic_ifstream构造函数不会打开文件,而非默认构造函数的第二个可选参数默认为ios::in。
每当你从文件流中读取数据时,流会从相应的文件中读取数据。考虑下面的示例文件,numbers.txt:
-54
203
9000
0
99
-789
400
列表 16-15 包含了一个程序,使用ifstream从包含整数的文本文件中读取数据并返回最大值。输出与调用程序并传递numbers.txt文件路径相对应。
#include <iostream>
#include <fstream>
#include <limits>
using namespace std;
int main() {
ifstream file{ "numbers.txt" }; ➊
auto maximum = numeric_limits<int>::min(); ➋
int value;
while (file >> value) ➌
maximum = maximum < value ? value : maximum; ➍
cout << "Maximum found was " << maximum << endl; ➎
}
-----------------------------------------------------------------------
Maximum found was 9000 ➎
列表 16-15:一个读取文本文件 numbers.txt 并打印其最大整数的程序
你首先初始化一个istream来打开numbers.txt文本文件 ➊。接着,使用int类型的最小值初始化最大值变量 ➋。通过典型的输入流和while循环组合 ➌,你遍历文件中的每个整数,在找到更大值时更新最大值 ➍。一旦文件流无法再解析任何整数,你就将结果打印到标准输出 ➎。
处理失败
与其他流一样,文件流默默失败。如果你使用文件流构造函数打开文件,你必须检查is_open方法来确定流是否成功打开了文件。这个设计与大多数其他标准库对象不同,后者通过异常来强制执行不变量。很难说为什么库实现者选择了这种方法,但事实是,你可以相对容易地选择基于异常的方法。
你可以创建自己的工厂函数,用异常处理文件打开失败。列表 16-16 展示了如何实现一个名为open的ifstream工厂。
#include <fstream>
#include <string>
using namespace std;
ifstream➊ open(const char* path➋, ios_base::openmode mode = ios_base::in➌) {
ifstream file{ path, mode }; ➍
if(!file.is_open()) { ➎
string err{ "Unable to open file " };
err.append(path);
throw runtime_error{ err }; ➏
}
file.exceptions(ifstream::badbit);
return file; ➐
}
列表 16-16:一个工厂函数,用于生成处理异常而非默默失败的 ifstream
你的工厂函数返回一个ifstream ➊,并接受与文件流构造函数(以及open方法)相同的参数:文件path ➋和openmode ➌。你将这两个参数传递给ifstream的构造函数 ➍,然后判断文件是否成功打开 ➎。若未成功,你抛出一个runtime_error ➏;若成功,你告诉结果ifstream在未来每当其badbit被设置时抛出异常 ➐。
文件流操作概述
表 16-10 提供了basic_fstream操作的部分列表。在这个表格中,fs, fs1和fs2是std:: basic_fstream <T>类型;p是一个 C 风格字符串,std::string或std::filesystem::path;om是std::ios_base::openmode;s是std::basic_string<``T``>;obj是一个格式化对象;pos是一个位置类型;dir是std::ios_base::seekdir;flg是std::ios_base::iostate。
表 16-10: std::basic_fstream 操作的部分列表
| 操作 | 备注 |
|---|---|
basic_fstream<T>``{ [p], [om] } |
对新构建的文件流进行花括号初始化。如果提供了 p,则尝试在路径 p 打开文件。默认情况下不打开,并且使用`in |
basic_fstream<T>``{ move(fs) } |
获取 fs 的内部缓冲区的所有权。 |
~basic_fstream |
析构内部缓冲区。 |
fs.rdbuf() |
返回原始字符串设备对象。 |
fs.str() |
获取文件设备对象的内容。 |
fs.str(s) |
将文件设备对象的内容放入 s 中。 |
fs >> obj |
从文件流中提取格式化数据。 |
fs << obj |
将格式化数据插入到文件流中。 |
fs.tellg() |
返回输入位置索引。 |
fs.seekg(pos)fs.seekg(pos, dir) |
设置输入位置指示器。 |
fs.flush() |
同步底层设备。 |
fs.good()fs.eof()fs.bad()``!fs |
检查文件流的状态位。 |
fs.exceptions(flg) |
配置文件流,在 flg 中的某一位被设置时抛出异常。 |
fs1.swap(fs2)``swap(fs1, fs2) |
交换 fs1 中的每个元素与 fs2 中的一个元素。 |
流缓冲区
流不会直接读写数据。背后,它们使用流缓冲区类。从高层次来看,流缓冲区类 是模板类,负责发送或提取字符。除非你计划实现自己的流库,否则实现细节不重要,但需要知道它们在多个上下文中存在。你通过使用流的rdbuf方法来获取流缓冲区,这是所有流都提供的。
向 sdout 写文件
有时你只想将输入文件流的内容直接写入输出流。为此,你可以从文件流中提取流缓冲区指针,并将其传递给输出操作符。例如,你可以使用cout以如下方式将文件内容输出到 stdout:
cout << my_ifstream.rdbuf()
就这么简单。
输出流缓冲迭代器
输出流缓冲迭代器 是模板类,暴露了一个输出迭代器接口,将写入操作转换为底层流缓冲区的输出操作。换句话说,这些是适配器,允许你像使用输出迭代器一样使用输出流。
要构造输出流缓冲迭代器,可以使用ostreambuf_iterator模板类(在<iterator>头文件中)。它的构造函数接受一个输出流参数和一个对应于构造函数参数模板参数(字符类型)的单一模板参数。示例 16-17 展示了如何从cout构造一个输出流缓冲迭代器。
#include <iostream>
#include <iterator>
using namespace std;
int main() {
ostreambuf_iterator<char> itr{ cout }; ➊
*itr = 'H'; ➋
++itr; ➌
*itr = 'i'; ➍
}
-----------------------------------------------------------------------
H➋i➍
示例 16-17:使用ostreambuf_iterator类将消息Hi写入 stdout
在这里,你从cout构造一个输出流缓冲区迭代器 ➊,然后像通常的输出操作符那样进行写操作:赋值 ➋,递增 ➌,赋值 ➍,以此类推。结果是逐字符输出到标准输出(stdout)。(回顾“输出迭代器”中关于输出操作符的处理方法,见第 464 页。)
输入流缓冲区迭代器
输入流缓冲区迭代器是模板类,暴露出一个输入迭代器接口,将读取操作转换为对底层流缓冲区的读取操作。这与输出流缓冲区迭代器完全类似。
要构造一个输入流缓冲区迭代器,使用istreambuf_iterator模板类,该类位于<iterator>头文件中。与ostreambuf_iterator不同,它接受一个流缓冲区参数,因此你必须在要适配的输入流上调用rdbuf()。这个参数是可选的:istreambuf_iterator的默认构造函数对应于输入迭代器的范围结束迭代器。例如,清单 16-18 展示了如何使用string的基于范围的构造函数从std::cin构造一个字符串。
#include <iostream>
#include <iterator>
#include <string>
using namespace std;
int main() {
istreambuf_iterator<char> cin_itr{ cin.rdbuf() } ➊, end{} ➋;
cout << "What is your name? "; ➌
const string name{ cin_itr, end }; ➍
cout << "\nGoodbye, " << name; ➎
}
-----------------------------------------------------------------------
What is your name? ➌josh ➍
Goodbye, josh➎
清单 16-18:使用输入流缓冲区迭代器从cin构造一个字符串
你从cin的流缓冲区构造一个istreambuf_iterator ➊,以及范围结束迭代器 ➋。向程序的用户发送提示 ➌ 后,你使用string name的基于范围的构造函数 ➍ 构造该字符串。当用户输入内容(以 EOF 结束)时,字符串的构造函数会复制输入内容。然后,你使用他们的name向用户告别 ➎。(回顾“流状态”部分,见第 530 页,不同操作系统向控制台发送 EOF 的方法有所不同。)
随机访问
有时你可能需要对流进行随机访问(特别是文件流)。输入和输出操作符显然不支持这种用例,因此basic_istream和basic_ostream提供了单独的随机访问方法。这些方法跟踪光标或位置,也就是流中当前字符的索引。位置指示输入流将读取的下一个字节或输出流将写入的下一个字节。
对于输入流,你可以使用tellg和seekg两种方法。tellg方法不接受参数,返回当前位置。seekg方法允许你设置光标位置,并且有两个重载。第一个选项是提供一个pos_type位置参数,用于设置读取位置。第二个选项是提供一个off_type偏移量参数,以及一个ios_base::seekdir方向参数。pos_type和off_type由basic_istream或basic_ostream的模板参数决定,但通常它们会转换为整数类型。seekdir类型有以下三种值:
-
ios_base::beg指定位置参数相对于起始位置。 -
ios_base::cur指定位置参数相对于当前位置。 -
ios_base::end指定位置参数是相对于文件末尾的。
对于输出流,您可以使用两个方法tellp和seekp。它们大致与输入流的tellg和seekg方法类似:p代表 put,g代表 get。
考虑一个文件introspection.txt,其内容如下:
The problem with introspection is that it has no end.
清单 16-19 展示了如何使用随机访问方法来重置文件游标。
#include <fstream>
#include <exception>
#include <iostream>
using namespace std;
ifstream open(const char* path, ios_base::openmode mode = ios_base::in) { ➊
--snip--
}
int main() {
try {
auto intro = open("introspection.txt"); ➋
cout << "Contents: " << intro.rdbuf() << endl; ➌
intro.seekg(0); ➍
cout << "Contents after seekg(0): " << intro.rdbuf() << endl; ➎
intro.seekg(-4, ios_base::end); ➏
cout << "tellg() after seekg(-4, ios_base::end): "
<< intro.tellg() << endl; ➐
cout << "Contents after seekg(-4, ios_base::end): "
<< intro.rdbuf() << endl; ➑
}
catch (const exception& e) {
cerr << e.what();
}
}
-----------------------------------------------------------------------
Contents: The problem with introspection is that it has no end. ➌
Contents after seekg(0): The problem with introspection is that it has no end. ➎
tellg() after seekg(-4, ios_base::end): 49 ➐
Contents after seekg(-4, ios_base::end): end. ➑
清单 16-19:使用随机访问方法读取文本文件中任意字符的程序
使用清单 16-16 中的工厂函数 ➊,您打开文本文件introspection.txt ➋。接下来,使用rdbuf方法 ➌将内容打印到 stdout,重置游标到文件的第一个字符 ➍,然后再次打印内容。请注意,这两次输出是相同的(因为文件没有变化) ➎。然后,您使用seekg的相对偏移重载来导航到文件末尾前第四个字符 ➏。使用tellg,您会发现这是第 49 个字符(以零为基础的索引) ➐。当您将输入文件打印到 stdout 时,输出只有end.,因为这些是文件中的最后四个字符 ➑。
注意
Boost 提供了一个 IOStream 库,具有 std 库所没有的丰富附加功能,包括内存映射文件 I/O、压缩和过滤等功能。
总结
在本章中,您了解了流,这是提供执行 I/O 的公共抽象的主要概念。您还了解了文件作为 I/O 的主要源和目标。您首先了解了 stdlib 中的基本流类,以及如何执行格式化和非格式化操作、检查流状态和处理异常错误。您了解了操作符和如何将流整合到用户定义的类型、字符串流和文件流中。本章的高潮是流缓冲区迭代器,它使您能够将流适配为迭代器。
练习
16-1. 实现一个输出操作符,打印“扩展示例:刹车”中的AutoBrake信息(参见第 283 页)。包括车辆当前的碰撞阈值和速度。
16-2. 编写一个程序,接受 stdin 中的输出,将其大写,并将结果写入 stdout。
16-3. 阅读 Boost IOStream 的介绍文档。
16-4. 编写一个程序,接受一个文件路径,打开文件并打印有关文件内容的摘要信息,包括单词计数、平均单词长度和字符的直方图。
进一步阅读
-
《标准 C++ IOStreams 和区域设置:高级程序员指南与参考》,作者:Angelika Langer(Addison-Wesley Professional,2000)
-
ISO 国际标准 ISO/IEC(2017)— C++编程语言(国际标准化组织;瑞士日内瓦;*
isocpp.org/std/the-standard/*) -
Boost C++库,第二版,由 Boris Schäling 编写(XML Press,2014 年)
第二十章:文件系统**
“所以,你是 UNIX 专家。”当时,兰迪仍然傻到会因为这种关注而感到受宠若惊,而他应该意识到这些话语其实是一种令人毛骨悚然的警告。
—尼尔·斯蒂芬森*, 《密码锁》

本章将教你如何使用 stdlib 的文件系统库对文件系统执行操作,如操作和检查文件、列举目录以及与文件流互操作。
stdlib 和 Boost 包含文件系统库。stdlib 的文件系统库起源于 Boost 的,因此它们在很大程度上是可以互换的。本章将重点介绍 stdlib 的实现。如果你有兴趣了解更多关于 Boost 的信息,请参考 Boost 文件系统文档。Boost 和 stdlib 的实现大致相同。
注意
C++标准有一个将 Boost 库纳入标准的历史。这允许 C++社区在将新特性纳入 C++标准之前,先通过 Boost 获得这些特性的使用经验。
文件系统概念
文件系统模型有几个重要概念。核心实体是文件。一个文件是一个支持输入输出并存储数据的文件系统对象。文件存在于名为目录的容器中,目录可以嵌套在其他目录中。为了简化,目录被视为文件。包含文件的目录称为该文件的父目录。
路径是一个字符串,用于标识特定的文件。路径以一个可选的根名称开始,这是一个特定于实现的字符串,例如 Windows 上的C:或//localhost,接着是一个可选的根目录,这是另一个特定于实现的字符串,例如类 Unix 系统上的/。路径的其余部分是由实现定义的分隔符分隔的目录序列。路径可以选择性地以非目录文件终止。路径可以包含特殊名称“.”和“..”,分别表示当前目录和父目录。
一个硬链接是一个目录条目,它为一个现有的文件分配了一个名称,符号链接(或符号链接)为一个路径(该路径可能存在,也可能不存在)分配一个名称。一个以另一个路径(通常是当前目录)为参考点的路径称为相对路径,而规范路径明确标识了文件的位置,不包含特殊名称“.”和“..”,且不包含任何符号链接。绝对路径是任何明确标识文件位置的路径。规范路径与绝对路径的一个主要区别是,规范路径不能包含特殊名称“.”和“..”。
警告
如果目标平台不提供分层文件系统,stdlib 文件系统可能不可用。
std::filesystem::path
std::filesystem::path 是文件系统库中用于建模路径的类,你有许多构造路径的选项。也许最常见的两种方式是默认构造函数,它构造一个空路径,以及接受字符串类型的构造函数,它创建由字符串中的字符表示的路径。像所有其他文件系统类和函数一样,path 类位于 <filesystem> 头文件中。
在本节中,你将学习如何从 string 表示构造路径,将其分解为组成部分,并进行修改。在许多常见的系统和应用程序编程上下文中,你需要与文件进行交互。由于每个操作系统对文件系统的表示都是独特的,stdlib 的文件系统库提供了一个欢迎的抽象,使得你能够轻松编写跨平台代码。
构造路径
path 类支持与其他 path 对象以及与 string 对象进行比较,使用 operator==。但是,如果你只是想检查 path 是否为空,它提供了一个返回布尔值的 empty 方法。清单 17-1 展示了如何构造两个 path(一个为空,一个非空)并对其进行测试。
#include <string>
#include <filesystem>
TEST_CASE("std::filesystem::path supports == and .empty()") {
std::filesystem::path empty_path; ➊
std::filesystem::path shadow_path{ "/etc/shadow" }; ➋
REQUIRE(empty_path.empty()); ➌
REQUIRE(shadow_path == std::string{ "/etc/shadow" }); ➍
}
清单 17-1:构造 std::filesystem::path
你构造了两个路径:一个使用默认构造函数 ➊,另一个指向 /etc/shadow ➋。由于你使用了默认构造函数,empty_path 的 empty 方法返回 true ➌。shadow_path 等于一个包含 /etc/shadow 的 string,因为你使用相同的内容构造了它 ➍。
分解路径
path 类包含一些分解方法,这些方法实际上是专门的字符串处理工具,允许你提取路径的各个组成部分,例如:
-
root_name()返回根名称。 -
root_directory()返回根目录。 -
root_path()返回根路径。 -
relative_path()返回相对于根路径的路径。 -
parent_path()返回父路径。 -
filename()返回文件名部分。 -
stem()返回去除扩展名后的文件名。 -
extension()返回扩展名。
清单 17-2 提供了这些方法返回的值,针对的是指向一个非常重要的 Windows 系统库 kernel32.dll 的路径。
#include <iostream>
#include <filesystem>
using namespace std;
int main() {
const filesystem::path kernel32{ R"(C:\Windows\System32\kernel32.dll)" }; ➊
cout << "Root name: " << kernel32.root_name() ➋
<< "\nRoot directory: " << kernel32.root_directory() ➌
<< "\nRoot path: " << kernel32.root_path() ➍
<< "\nRelative path: " << kernel32.relative_path() ➎
<< "\nParent path: " << kernel32.parent_path() ➏
<< "\nFilename: " << kernel32.filename() ➐
<< "\nStem: " << kernel32.stem() ➑
<< "\nExtension: " << kernel32.extension() ➒
<< endl;
}
-----------------------------------------------------------------------
Root name: "C:" ➋
Root directory: "\\" ➌
Root path: "C:\\" ➍
Relative path: "Windows\\System32\\kernel32.dll" ➎
Parent path: "C:\\Windows\\System32" ➏
Filename: "kernel32.dll" ➐
Stem: "kernel32" ➑
Extension: ".dll" ➒
清单 17-2:打印路径各种分解结果的程序
你使用原始字符串字面量构造指向 kernel32 的路径,以避免需要转义反斜杠 ➊。你提取根名称 ➋、根目录 ➌ 和 kernel32 的根路径 ➍ 并将它们输出到标准输出。接下来,你提取相对路径,它显示的是相对于根路径 C:\ 的路径 ➎。父路径是 kernel32.dll 的父路径,它只是包含该文件的目录 ➏。最后,你提取文件名 ➐、文件名主体 ➑ 和扩展名 ➒。
注意,你不需要在任何特定操作系统上运行示例 17-2。没有任何解析方法要求路径实际指向一个存在的文件。你只是提取路径内容的组成部分,而不是指向的文件。当然,不同的操作系统会产生不同的结果,特别是对于分隔符(例如,在 Linux 上是正斜杠)。
注意
示例 17-2 演示了 std::filesystem::path 有一个 operator<<,它在路径的开头和结尾打印引号。在内部,它使用了 <iomanip> 头文件中的模板类 std::quoted,该类简化了带引号字符串的插入和提取。此外,记住在字符串字面量中必须转义反斜杠,这就是为什么你在源代码中看到路径中有两个反斜杠,而不是一个的原因。
修改路径
除了解析方法外,path 还提供了几个 修改器方法,允许你修改路径的各种特征:
-
clear()清空path。 -
make_preferred()将所有目录分隔符转换为实现首选的目录分隔符。例如,在 Windows 上,它将通用分隔符/转换为系统首选的反斜杠\。 -
remove_filename()移除路径中的文件名部分。 -
replace_filename(p)用路径 p 替换path的文件名。 -
replace_extension(p)用路径 p 替换path的扩展名。 -
remove_extension()移除路径中的扩展名部分。
示例 17-3 演示了如何使用多个修改器方法操作路径。
#include <iostream>
#include <filesystem>
using namespace std;
int main() {
filesystem::path path{ R"(C:/Windows/System32/kernel32.dll)" };
cout << path << endl; ➊
path.make_preferred();
cout << path << endl; ➋
path.replace_filename("win32kfull.sys");
cout << path << endl; ➌
path.remove_filename();
cout << path << endl; ➍
path.clear();
cout << "Is empty: " << boolalpha << path.empty() << endl; ➎
}
-----------------------------------------------------------------------
"C:/Windows/System32/kernel32.dll" ➊
"C:\\Windows\\System32\\kernel32.dll" ➋
"C:\\Windows\\System32\\win32kfull.sys" ➌
"C:\\Windows\\System32\\" ➍
Is empty: true ➎
示例 17-3:使用修改器方法操作路径。(输出来自 Windows 10 x64 系统。)
如在示例 17-2 中所示,你构造了一个指向 kernel32 的 path,尽管这个路径是非const的,因为你将要修改它 ➊。接下来,使用 make_preferred 将所有目录分隔符转换为系统首选的目录分隔符。示例 17-3 显示了来自 Windows 10 x64 系统的输出,因此它将斜杠 (/) 转换为反斜杠 (\) ➋。使用 replace_filename,你将文件名从 kernel32.dll 替换为 win32kfull.sys ➌。再次注意,由该路径描述的文件不需要在你的系统上实际存在;你只是操作路径。最后,使用 remove_filename 方法移除文件名 ➍,然后使用 clear 完全清空 path 的内容 ➎。
文件系统路径方法总结
表 17-1 包含了 path 的可用方法的部分列表。注意表中 p、p1 和 p2 是 path 对象,而 s 是 stream。
表 17-1: std::filestystem::path 操作总结
| 操作 | 备注 |
|---|---|
path{} |
构造一个空路径。 |
Path{ s, [f] } |
从字符串类型 s 构造路径;f 是一个可选的 path::format 类型,默认为实现定义的路径格式。 |
Path{ p }p1 = p2 |
复制构造/赋值。 |
Path{ move(p) }p1 = move(p2) |
移动构造/赋值。 |
p.assign(s) |
将 p 赋值给 s,丢弃当前内容。 |
p.append(s)p / s |
将 s 追加到 p 后,包含适当的分隔符 path::preferred_separator。 |
p.concat(s)p + s |
将 s 追加到 p 后,不包括分隔符。 |
p.clear() |
清除内容。 |
p.empty() |
如果 p 为空,则返回 true。 |
p.make_preferred() |
将所有目录分隔符转换为实现首选的目录分隔符。 |
p.remove_filename() |
移除文件名部分。 |
p1.replace_filename(p2) |
将 p1 的文件名替换为 p2 的文件名。 |
p1.replace_extension(p2) |
将 p1 的扩展名替换为 p2 的扩展名。 |
p.root_name() |
返回根名称。 |
p.root_directory() |
返回根目录。 |
p.root_path() |
返回根路径。 |
p.relative_path() |
返回相对路径。 |
p.parent_path() |
返回父路径。 |
p.filename() |
返回文件名。 |
p.stem() |
返回 stem 部分。 |
p.extension() |
返回扩展名。 |
p.has_root_name() |
如果 p 有根名称,则返回 true。 |
p.has_root_directory() |
如果 p 有根目录,则返回 true。 |
p.has_root_path() |
如果 p 有根路径,则返回 true。 |
p.has_relative_path() |
如果 p 有相对路径,则返回 true。 |
p.has_parent_path() |
如果 p 有父路径,则返回 true。 |
p.has_filename() |
如果 p 有文件名,则返回 true。 |
p.has_stem() |
如果 p 有 stem 部分,则返回 true。 |
p.has_extension() |
如果 p 有扩展名,则返回 true。 |
p.c_str()p.native() |
返回 p 的本地字符串表示。 |
p.begin()p.end() |
顺序访问路径的元素,作为半开区间。 |
s << p |
将 p 写入 s。 |
s >> p |
将 s 读入 p。 |
p1.swap(p2)``swap(p1, p2) |
交换 p1 和 p2 中的每个元素。 |
p1 == p2p1 != p2p1 > p2p1 >= p2p1 < p2p1 <= p2 |
按字典顺序比较两个路径 p1 和 p2。 |
文件与目录
path 类是文件系统库的核心元素,但它的任何方法都不会与文件系统直接交互。相反,<filesystem> 头文件包含了非成员函数来执行这些操作。可以把 path 对象看作是声明你想与之交互的文件系统组件,而 <filesystem> 头文件则包含了执行这些操作的函数。
这些函数具有友好的错误处理接口,允许你将路径拆分成例如目录名、文件名和扩展名等部分。使用这些函数,你可以使用许多工具与环境中的文件进行交互,而无需使用特定操作系统的应用程序编程接口。
错误处理
与环境文件系统交互可能会导致错误,例如找不到文件、权限不足或不支持的操作。因此,文件系统库中每个与文件系统交互的非成员函数必须向调用者传达错误条件。这些非成员函数提供了两种选项:抛出异常或设置错误变量。
每个函数有两个重载版本:一个允许你传递一个 std::system_error 的引用,另一个则省略该参数。如果你提供引用,函数会将 system_error 设置为一个错误条件(如果发生错误)。如果不提供引用,函数将抛出一个 std::filesystem::filesystem_error(继承自 std::system_error 的异常类型)。
路径组合函数
作为使用 path 构造函数的替代方法,你可以构造各种类型的路径:
-
absolute(p,[ec])返回一个绝对路径,指向与 p 相同的位置,但is_absolute()返回 true。 -
canonical(p,[ec])返回一个规范路径,指向与 p 相同的位置。 -
current_path([ec])返回当前路径。 -
relative(p,[base], [ec])返回一个相对路径,其中 p 相对于base。 -
temp_directory_path([ec])返回一个用于临时文件的目录。结果保证是一个已存在的目录。
请注意,current_path支持重载,因此你可以设置当前目录(类似于 Posix 系统中的 cd 或 chdir)。只需提供一个路径参数,例如 current_path(p, [ec])。
清单 17-4 展示了这些函数的应用实例。
#include <filesystem>
#include <iostream>
using namespace std;
int main() {
try {
const auto temp_path = filesystem::temp_directory_path(); ➊
const auto relative = filesystem::relative(temp_path); ➋
cout << boolalpha
<< "Temporary directory path: " << temp_path ➌
<< "\nTemporary directory absolute: " << temp_path.is_absolute() ➍
<< "\nCurrent path: " << filesystem::current_path() ➎
<< "\nTemporary directory's relative path: " << relative ➏
<< "\nRelative directory absolute: " << relative.is_absolute() ➐
<< "\nChanging current directory to temp.";
filesystem::current_path(temp_path); ➑
cout << "\nCurrent directory: " << filesystem::current_path(); ➒
} catch(const exception& e) {
cerr << "Error: " << e.what(); ➓
}
}
-----------------------------------------------------------------------
Temporary directory path: "C:\\Users\\lospi\\AppData\\Local\\Temp\\" ➌
Temporary directory absolute: true ➍
Current path: "c:\\Users\\lospi\\Desktop" ➎
Temporary directory's relative path: "..\\AppData\\Local\\Temp" ➏
Relative directory absolute: false ➐
Changing current directory to temp. ➑
Current directory: "C:\\Users\\lospi\\AppData\\Local\\Temp" ➒
清单 17-4:一个使用多个路径组合函数的程序。(输出来自 Windows 10 x64 系统。)
你可以使用 temp_directory_path 构造路径,它返回系统的临时文件目录 ➊,然后使用 relative 确定其相对路径 ➋。打印临时路径 ➌ 后,is_absolute 说明该路径是绝对路径 ➍。接着,打印当前路径 ➎ 以及临时目录相对于当前路径的路径 ➏。由于这是相对路径,is_absolute 返回 false ➐。一旦你将路径更改为临时路径 ➑,然后打印当前目录 ➒。当然,你的输出可能与 清单 17-4 中的输出不同,如果系统不支持某些操作,甚至可能会出现 exception ➓。(回想一下章节开始时的警告:C++ 标准允许某些环境可能不支持文件系统库中的部分或全部功能。)
检查文件类型
你可以使用以下函数检查文件的属性:
-
is_block_file(p,[ec])用于判断 p 是否是 块文件,这是一种在某些操作系统中使用的特殊文件(例如,Linux 中的块设备,允许你以固定大小的块传输随机可访问的数据)。 -
is_character_file(p,[ec])用于判断 p 是否是 字符文件,这是一种在某些操作系统中使用的特殊文件(例如,Linux 中的字符设备,允许你发送和接收单个字符)。 -
is_regular_file(p,[ec])用于判断 p 是否是常规文件。 -
is_symlink(p,[ec])用于判断 p 是否是符号链接,它是指向另一个文件或目录的引用。 -
is_empty(p,[ec])用于判断 p 是否是一个空文件或空目录。 -
is_directory(p,[ec])用于判断 p 是否是一个目录。 -
is_fifo(p,[ec])用于判断 p 是否是 命名管道,这是一种在许多操作系统中使用的特殊进程间通信机制。 -
is_socket(p,[ec])用于判断 p 是否是 套接字,这也是许多操作系统中使用的另一种特殊进程间通信机制。 -
is_other(p,[ec])用于判断 p 是否是除常规文件、目录或符号链接之外的某种文件。
Listing 17-5 使用 is_directory 和 is_regular_file 来检查四个不同的路径。
#include <iostream>
#include <filesystem>
using namespace std;
void describe(const filesystem::path& p) { ➊
cout << boolalpha << "Path: " << p << endl;
try {
cout << "Is directory: " << filesystem::is_directory(p) << endl; ➋
cout << "Is regular file: " << filesystem::is_regular_file(p) << endl; ➌
} catch (const exception& e) {
cerr << "Exception: " << e.what() << endl;
}
}
int main() {
filesystem::path win_path{ R"(C:/Windows/System32/kernel32.dll)" };
describe(win_path); ➍
win_path.remove_filename();
describe(win_path); ➎
filesystem::path nix_path{ R"(/bin/bash)" };
describe(nix_path); ➏
nix_path.remove_filename();
describe(nix_path); ➐
}
Listing 17-5:一个使用 is_directory 和 is_regular_file 检查四个典型的 Windows 和 Linux 路径的程序。
在一台 Windows 10 x64 机器上,运行 Listing 17-5 程序输出了以下结果:
Path: "C:/Windows/System32/kernel32.dll" ➍
Is directory: false ➍
Is regular file: true ➍
Path: "C:/Windows/System32/" ➎
Is directory: true ➎
Is regular file: false ➎
Path: "/bin/bash" ➏
Is directory: false ➏
Is regular file: false ➏
Path: "/bin/" ➐
Is directory: false ➐
Is regular file: false ➐
在一台 Ubuntu 18.04 x64 机器上,运行 Listing 17-5 程序输出了以下结果:
Path: "C:/Windows/System32/kernel32.dll" ➍
Is directory: false ➍
Is regular file: false ➍
Path: "C:/Windows/System32/" ➎
Is directory: false ➎
Is regular file: false ➎
Path: "/bin/bash" ➏
Is directory: false ➏
Is regular file: true ➏
Path: "/bin/" ➐
Is directory: true ➐
Is regular file: false ➐
首先,你定义了 describe 函数,它接受一个单一的 path ➊ 参数。打印路径后,你还会打印该路径是否是一个目录 ➋ 或常规文件 ➌。在 main 中,你传递了四个不同的路径给 describe:
-
C:/Windows/System32/kernel32.dll➍ -
C:/Windows/System32/➎ -
/bin/bash➏ -
/bin/➐
注意,结果是操作系统特定的。
检查文件和目录
你可以使用以下函数检查各种文件系统属性:
-
current_path([p], [ec]),如果提供了 p,则将程序的当前路径设置为 p;否则,它返回程序的当前路径。 -
exists(p,[ec])返回文件或目录是否存在于 p。 -
equivalent(p1, p2,[ec])返回 p1 和 p2 是否指向同一个文件或目录。 -
file_size(p,[ec])返回位于 p 的常规文件的字节大小。 -
hard_link_count(p,[ec])返回 p 的硬链接数量。 -
last_write_time(p,[t] [ec]),如果提供了 tec``t,则将 p 的最后修改时间设置为 t;否则,它会返回 p 的最后修改时间。(t 是一个std::chrono::time_point。) -
permissions(p, prm,[ec])设置 p 的权限。 prm 是std::filesystem::perms类型,这是一个基于 POSIX 权限位模型的枚举类。(参考 [fs.enum.perms]。) -
read_symlink(p,[ec])返回符号链接 p 的目标。 -
space(p,[ec])返回文件系统 p 占用的空间信息,形式为std::filesystem::space_info。该 POD 包含三个字段:容量(总大小)、free(可用空间)和available(可供非特权进程使用的可用空间)。所有字段都是无符号整数类型,以字节为单位度量。 -
status(p,[ec])返回文件或目录 p 的类型和属性,形式为std::filesystem::file_status。该类包含一个type方法,该方法不接受任何参数,返回一个std::filesystem::file_type类型的对象,这个枚举类包含描述文件类型的值,如not_found、regular、directory等。symlink file_status类还提供一个permissions方法,不接受任何参数,返回一个std::filesystem::perms类型的对象。(详细信息参考 [fs.class.file_status]。) -
symlink_status(p,[ec])返回状态,不跟随符号链接。
如果你熟悉类似 Unix 的操作系统,肯定多次使用过 ls(“列出”命令)来列举文件和目录。在类似 DOS 的操作系统(包括 Windows)中,你可以使用类似的 dir 命令。稍后你将在本章中(在 Listing 17-7)使用这些函数来构建自己的简单列出程序。
现在你已经知道如何检查文件和目录,让我们来看一下如何操作路径所指向的文件和目录。
操作文件和目录
此外,文件系统库包含许多操作文件和目录的方法:
-
copy(p1, p2,[opt], [ec])将文件或目录从 p1 复制到 p2。你可以提供一个std::filesystem::copy_optionsopt来自定义copy_file的行为。这个enum类可以接受多个值,包括 none(如果目标已存在则报告错误)、skip_existing(保留现有文件)、overwrite_existing(覆盖现有文件)和update_existing(如果 p1 较新则覆盖)。 (详细信息参考 [fs.enum.copy.opts]。) -
copy_file(p1, p2,[opt], [ec])类似于 copy,除了如果 p1 不是常规文件时,它会生成错误。 -
copy_file(p1,p2, [opt], [ec])` 类似于 copy,除了如果 p1 不是常规文件时,它会生成错误。 -
create_directory(p, [ec])创建目录 p。 -
create_directories(p, [ec])类似于递归调用create_directory,因此,如果嵌套路径包含不存在的父目录,使用这种形式。 -
create_hard_link(tgt,lnk, [ec])` 在 lnk 处创建指向 tgt 的硬链接。 -
create_symlink(tgt,lnk, [ec])` 在 lnk 处创建指向 tgt 的符号链接。 -
create_directory_symlink``(tgt,lnk, [ec])应用于目录,而不是create_symlink。 -
remove``(p, [ec])删除文件或空目录 p(不跟随符号链接)。 -
remove_all``(p, [ec])递归删除文件或目录 p(不跟随符号链接)。 -
rename``(p1,p2, [ec])将 p1 重命名为 p2。 -
resize_file``(p,new_size, [ec])将 p(如果是常规文件)调整为 new_size。如果该操作增加了文件大小,新的空间将被零填充。否则,操作会从文件末尾裁剪 p。
你可以创建一个程序,使用这些方法中的几种来复制、调整大小和删除文件。列表 17-6 通过定义一个打印文件大小和修改时间的函数来说明这一点。在main函数中,程序创建并修改了两个path对象,并在每次修改后调用该函数。
#include <iostream>
#include <filesystem>
using namespace std;
using namespace std::filesystem;
using namespace std::chrono;
void write_info(const path& p) {
if (!exists(p)) { ➊
cout << p << " does not exist." << endl;
return;
}
const auto last_write = last_write_time(p).time_since_epoch();
const auto in_hours = duration_cast<hours>(last_write).count();
cout << p << "\t" << in_hours << "\t" << file_size(p) << "\n"; ➋
}
int main() {
const path win_path{ R"(C:/Windows/System32/kernel32.dll)" }; ➌
const auto reamde_path = temp_directory_path() / "REAMDE"; ➍
try {
write_info(win_path); ➎
write_info(reamde_path); ➏
cout << "Copying " << win_path.filename()
<< " to " << reamde_path.filename() << "\n";
copy_file(win_path, reamde_path);
write_info(reamde_path); ➐
cout << "Resizing " << reamde_path.filename() << "\n";
resize_file(reamde_path, 1024);
write_info(reamde_path); ➑
cout << "Removing " << reamde_path.filename() << "\n";
remove(reamde_path);
write_info(reamde_path); ➒
} catch(const exception& e) {
cerr << "Exception: " << e.what() << endl;
}
}
-----------------------------------------------------------------------
"C:/Windows/System32/kernel32.dll" 3657767 720632 ➎
"C:\\Users\\lospi\\AppData\\Local\\Temp\\REAMDE" does not exist. ➏
Copying "kernel32.dll" to "REAMDE"
"C:\\Users\\lospi\\AppData\\Local\\Temp\\REAMDE" 3657767 720632 ➐
Resizing "REAMDE"
"C:\\Users\\lospi\\AppData\\Local\\Temp\\REAMDE" 3659294 1024 ➑
Removing "REAMDE"
"C:\\Users\\lospi\\AppData\\Local\\Temp\\REAMDE" does not exist. ➒
列表 17-6:一个示例程序,展示了几种与文件系统交互的方法。(输出来自 Windows 10 x64 系统。)
write_info函数接受一个path参数。你检查该路径是否存在 ➊,如果不存在,则打印错误信息并立即返回。如果路径存在,打印消息显示其最后的修改时间(自纪元以来的小时数)和文件大小 ➋。
在main中,你创建了一个指向kernel32.dll的路径win_path ➌ 和一个指向文件系统临时文件目录中不存在的文件REAMDE的路径reamde_path ➍。(回顾表 17-1,你可以使用operator/连接两个路径对象。)在try-catch块中,你对这两个路径调用write_info ➎➏。(如果你使用的是非 Windows 机器,输出会有所不同。你可以将win_path修改为系统中存在的文件来继续操作。)
接下来,你将win_path处的文件复制到reamde_path,并在其上调用write_info ➐。注意,与之前的情况 ➏ 相比,reamde_path处的文件存在,且它的最后写入时间和文件大小与kernel32.dll相同。
然后,你将reamde_path处的文件大小调整为 1024 字节,并调用write_info ➑。注意,最后的写入时间从 3657767 增加到 3659294,文件大小从 720632 减少到 1024。
最后,你删除reamde_path处的文件并调用write_info ➒,它告诉你该文件已经不存在。
注意
文件系统如何在后台调整文件大小因操作系统不同而异,超出了本书的范围。但你可以从概念上理解调整大小操作,类似于对std::vector的resize操作。操作系统会丢弃文件末尾不适合新大小的数据。
目录迭代器
文件系统库提供了两个类用于迭代目录中的元素:std::filesystem::directory_iterator和std::filesystem::recursive_directory_iterator。directory_iterator不会进入子目录,而recursive_directory_iterator会。 本节介绍了directory_iterator,但是recursive_directory_iterator是一个可以替换的实现,并支持所有以下操作。
构造
directory_iterator的默认构造函数会生成结束迭代器。(回忆一下,输入结束迭代器表示输入范围已经用尽。)另一个构造函数接受路径,它表示你想要枚举的目录。可选地,你可以提供std::filesystem::directory_options,它是一个enum类位掩码,包含以下常量:
-
none指示迭代器跳过目录符号链接。如果迭代器遇到权限拒绝,则会产生错误。 -
follow_directory_symlink跟随符号链接。 -
skip_permission_denied如果迭代器遇到权限拒绝,会跳过目录。
此外,你还可以提供一个std::error_code,像所有其他接受error_code的文件系统库函数一样,如果在构造过程中发生错误,它会设置此参数,而不是抛出异常。
表 17-2 总结了构造directory_iterator的这些选项。请注意,表中的p是path,d是directory,op是directory_options,ec是error_code。
表 17-2: std::filesystem::directory_iterator操作总结
| 操作 | 备注 |
|---|---|
directory_iterator{} |
构造结束迭代器。 |
directory_iterator{ p, [op], [ec] } |
构造一个指向目录 p 的目录迭代器。参数 op 默认为none。如果提供,ec 会接收错误条件,而不是抛出异常。 |
directory_iterator { d }d1 = d2 |
复制构造/赋值。 |
directory_iterator { move(d) }d1 = move(d2) |
移动构造/赋值。 |
目录条目
输入迭代器directory_iterator和recursive_directory_iterator会为它们遇到的每个条目生成一个std::filesystem::directory_entry元素。directory_entry类存储一个path,以及一些关于该path的属性,这些属性通过方法公开。表 17-3 列出了这些方法。请注意,表中的de是一个directory_entry。
表 17-3: std::filesystem::directory_entry操作总结
| 操作 | 描述 |
|---|---|
de.path() |
返回引用的路径。 |
de.exists() |
如果引用的路径在文件系统中存在,则返回true。 |
de.is_block_file() |
如果引用的路径是块设备,则返回true。 |
de.is_character_file() |
如果引用的路径是字符设备,则返回true。 |
de.is_directory() |
如果引用的路径是一个目录,则返回true。 |
de.is_fifo() |
如果引用路径是命名管道,则返回true。 |
de.is_regular_file() |
如果引用路径是常规文件,则返回true。 |
de.is_socket() |
如果引用路径是套接字,则返回true。 |
de.is_symlink() |
如果引用路径是符号链接,则返回true |
de.is_other() |
如果引用路径是其他类型,则返回true。 |
de.file_size() |
返回引用路径的大小。 |
de.hard_link_count() |
返回引用路径的硬链接数量。 |
de.last_write_time([t]) |
如果提供了t,则设置引用路径的最后修改时间;否则,返回最后修改时间。 |
de.status() de.symlink_status() |
返回引用路径的std::filesystem::file_status。 |
你可以使用directory_iterator和表 17-3 中的多个操作,创建一个简单的目录列出程序,正如 Listing 17-7 所展示的那样。
#include <iostream>
#include <filesystem>
#include <iomanip>
using namespace std;
using namespace std::filesystem;
using namespace std::chrono;
void describe(const directory_entry& entry) { ➊
try {
if (entry.is_directory()) { ➋
cout << " *";
} else {
cout << setw(12) << entry.file_size();
}
const auto lw_time =
duration_cast<seconds>(entry.last_write_time().time_since_epoch());
cout << setw(12) << lw_time.count()
<< " " << entry.path().filename().string()
<< "\n"; ➌
} catch (const exception& e) {
cout << "Error accessing " << entry.path().string()
<< ": " << e.what() << endl; ➍
}
}
int main(int argc, const char** argv) {
if (argc != 2) {
cerr << "Usage: listdir PATH";
return -1; ➎
}
const path sys_path{ argv[1] }; ➏
cout << "Size Last Write Name\n";
cout << "------------ ----------- ------------\n"; ➐
for (const auto& entry : directory_iterator{ sys_path }) ➑
describe(entry); ➒
}
-----------------------------------------------------------------------
> listdir c:\Windows
Size Last Write Name
------------ ----------- ------------
* 13177963504 addins
* 13171360979 appcompat
--snip--
* 13173551028 WinSxS
316640 13167963236 WMSysPr9.prx
11264 13167963259 write.exe
Listing 17-7:一个使用std::filesystem::directory_iterator列举给定目录的文件和目录的程序。(输出来自 Windows 10 x64 系统。)
注意
你应该将程序的名称从listdir修改为与你的编译器输出相匹配的任何值。
首先定义一个describe函数,该函数接受一个path引用 ➊,用于检查路径是否为目录 ➋,并为目录打印星号,为文件打印相应的大小。接下来,确定该条目自纪元以来的最后修改时间(以秒为单位),并将其与条目关联的文件名一起打印 ➌。如果发生任何异常,打印错误信息并返回 ➍。
在main函数中,首先检查用户是否使用单个参数调用了程序,如果没有,则返回一个负数 ➎。接下来,使用单个参数 ➏ 构造路径,打印一些华丽的输出头部 ➐,遍历目录中的每个entry ➑,并将其传递给describe ➒。
递归目录迭代
recursive_directory_iterator 是 directory_iterator 的替代品,支持相同的所有操作,但会列举子目录。你可以结合使用这些迭代器,构建一个计算给定目录中文件和子目录的大小和数量的程序。Listing 17-8 展示了如何实现。
#include <iostream>
#include <filesystem>
using namespace std;
using namespace std::filesystem;
struct Attributes {
Attributes& operator+=(const Attributes& other) {
this->size_bytes += other.size_bytes;
this->n_directories += other.n_directories;
this->n_files += other.n_files;
return *this;
}
size_t size_bytes;
size_t n_directories;
size_t n_files;
}; ➊
void print_line(const Attributes& attributes, string_view path) {
cout << setw(14) << attributes.size_bytes
<< setw(7) << attributes.n_files
<< setw(7) << attributes.n_directories
<< " " << path << "\n"; ➋
}
Attributes explore(const directory_entry& directory) {
Attributes attributes{};
for(const auto& entry : recursive_directory_iterator{ directory.path() }) { ➌
if (entry.is_directory()) {
attributes.n_directories++; ➍
} else {
attributes.n_files++;
attributes.size_bytes += entry.file_size(); ➎
}
}
return attributes;
}
int main(int argc, const char** argv) {
if (argc != 2) {
cerr << "Usage: treedir PATH";
return -1; ➏
}
const path sys_path{ argv[1] };
cout << "Size Files Dirs Name\n";
cout << "-------------- ------ ------ ------------\n";
Attributes root_attributes{};
for (const auto& entry : directory_iterator{ sys_path }) { ➐
try {
if (entry.is_directory()) {
const auto attributes = explore(entry); ➑
root_attributes += attributes;
print_line(attributes, entry.path().string());
root_attributes.n_directories++;
} else {
root_attributes.n_files++;
error_code ec;
root_attributes.size_bytes += entry.file_size(ec); ➒
if (ec) cerr << "Error reading file size: "
<< entry.path().string() << endl;
}
} catch(const exception&) {
}
}
print_line(root_attributes, argv[1]); ➓
}
-----------------------------------------------------------------------
> treedir C:\Windows
Size Files Dirs Name
------------ ----- ----- ------------
802 1 0 C:\Windows\addins
8267330 9 5 C:\Windows\apppatch
--snip--
11396916465 73383 20480 C:\Windows\WinSxS
21038460348 110950 26513 C:\Windows ➓
Listing 17-8:一个使用std::filesystem::recursive_directory_iterator列出给定路径子目录中文件数量和总大小的程序。(输出来自 Windows 10 x64 系统。)
注意
你应该将程序的名称从treedir修改为与你的编译器输出相匹配的任何值。
在声明用于存储会计数据的Attributes类➊后,你定义了一个print_line函数,它以用户友好的方式展示Attributes实例,并附带路径字符串➋。接下来,你定义了一个explore函数,它接受一个directory_entry引用并递归地遍历它➌。如果结果的entry是一个目录,你会增加目录计数器➍;否则,你会增加文件计数和总大小➎。
在main函数中,你检查程序是否确实传入了两个参数。如果没有,你会返回错误代码 -1 ➏。你使用(非递归的)directory_iterator枚举sys_path所指的目标路径中的内容➐。如果一个entry是目录,你会调用explore来确定其属性➑,然后将其打印到控制台。你还会增加root_attributes中的n_directories成员来进行统计。如果entry不是目录,你会相应地增加root_attributes中的n_files和size_bytes成员➒。
完成遍历所有sys_path子元素后,你会打印root_attributes作为最后一行输出➓。例如,清单 17-8 中的最后一行输出显示该特定 Windows 目录包含 110,950 个文件,占用 21,038,460,348 字节(约 21GB)和 26,513 个子目录。
fstream 互操作性
除了字符串类型外,你还可以使用std::filesystem::path或std::filesystem::directory_entry来构造文件流(basic_ifstream、basic_ofstream 或 basic_fstream)。
例如,你可以遍历一个目录并构造一个ifstream来读取你遇到的每个文件。清单 17-9 展示了如何检查每个 Windows 可执行文件(如 .sys、.dll、.exe 等)开头的魔术 MZ 字节,并报告任何违反此规则的文件。
#include <iostream>
#include <fstream>
#include <filesystem>
#include <unordered_set>
using namespace std;
using namespace std::filesystem;
int main(int argc, const char** argv) {
if (argc != 2) {
cerr << "Usage: pecheck PATH";
return -1; ➊
}
const unordered_set<string> pe_extensions{
".acm", ".ax", ".cpl", ".dll", ".drv",
".efi", ".exe", ".mui", ".ocx", ".scr",
".sys", ".tsp"
}; ➋
const path sys_path{ argv[1] };
cout << "Searching " << sys_path << " recursively.\n";
size_t n_searched{};
auto iterator = recursive_directory_iterator{ sys_path,
directory_options::skip_permission_denied }; ➌
for (const auto& entry : iterator) { ➍
try {
if (!entry.is_regular_file()) continue;
const auto& extension = entry.path().extension().string();
const auto is_pe = pe_extensions.find(extension) != pe_extensions.end();
if (!is_pe) continue; ➎
ifstream file{ entry.path() }; ➏
char first{}, second{};
if (file) file >> first;
if (file) file >> second; ➐
if (first != 'M' || second != 'Z')
cout << "Invalid PE found: " << entry.path().string() << "\n"; ➑
++n_searched;
} catch(const exception& e) {
cerr << "Error reading " << entry.path().string()
<< ": " << e.what() << endl;
}
}
cout << "Searched " << n_searched << " PEs for magic bytes." << endl; ➒
}
----------------------------------------------------------------------
listing_17_9.exe c:\Windows\System32
Searching "c:\\Windows\\System32" recursively.
Searched 8231 PEs for magic bytes.
清单 17-9:搜索 Windows System32 目录中的 Windows 便携式可执行文件
在main函数中,你检查是否正好传入了两个参数,并根据情况返回相应的错误代码➊。你构建了一个unordered_set,其中包含与便携式可执行文件相关的所有扩展名➋,这些扩展名将用于检查文件扩展名。你使用带有directory_options::skip_permission_denied选项的recursive_directory_iterator来枚举指定路径中的所有文件➌。你遍历每个条目➍,跳过所有不是常规文件的条目,并通过尝试在pe_extensions中find该条目来判断该条目是否为便携式可执行文件。如果条目没有这种扩展名,你就跳过该文件➎。
要打开文件,只需将entry的路径传递给ifstream的构造函数➏。然后使用得到的输入文件流将文件的前两个字节读入first和second➐。如果这两个字符不是MZ,则向控制台打印一条消息➑。无论如何,都要增加一个名为n_searched的计数器。在用完目录迭代器后,你需要打印一个包含n_searched的消息给用户,然后从main返回➒。
总结
在本章中,你学习了 stdlib 文件系统功能,包括路径、文件、目录和错误处理。这些功能使你能够编写与环境中文件交互的跨平台代码。本章的内容以一些重要的操作、目录迭代器和文件流的互操作性为结尾。
习题
17-1. 实现一个程序,接受两个参数:一个路径和一个扩展名。该程序应该递归地搜索给定路径,并打印任何具有指定扩展名的文件。
17-2. 改进 Listing 17-8 中的程序,使其可以接受一个可选的第二个参数。如果第一个参数以连字符(-)开头,程序将读取紧跟连字符后面的所有连续字母,并将每个字母解析为一个选项。第二个参数则变成搜索的路径。如果选项列表中包含R,则执行递归目录操作。否则,不使用递归目录迭代器。
17-3. 请参阅dir或ls命令的文档,并在你改进版的 Listing 17-8 中实现尽可能多的选项。
进一步阅读
-
Windows NT 文件系统内部结构:开发者指南,作者:Rajeev Nagar(O'Reilly,1997)
-
Boost C++库(第二版),作者:Boris Schäling(XML Press,2014)
-
Linux 编程接口:Linux 和 UNIX 系统编程手册,作者:Michael Kerrisk(No Starch Press,2010)
第二十一章:算法
这才是编程的精髓。通过将一个复杂的想法拆解成小步骤,甚至是一个愚蠢的机器也能处理时,你自己已经学到了关于它的某些东西。
—道格拉斯·亚当斯,《Dirk Gently 的全息侦探事务所》

算法是一种解决一类问题的过程。std 库和 Boost 库包含了大量你可以在程序中使用的算法。因为许多聪明的人花了大量时间来确保这些算法的正确性和效率,所以你通常不需要尝试自己编写排序算法等。
由于本章涵盖了几乎整个 std 库算法集,因此篇幅较长;然而,每个算法的介绍都很简洁。首次阅读时,你应该浏览每一节,了解可以使用的各种算法。不要试图记住它们。相反,应该专注于获得对未来编写代码时,能通过它们解决哪些问题的洞察。这样,当你需要使用某个算法时,你可以说:“等等,难道不是有人已经发明过这个轮子了吗?”
在开始使用算法之前,你需要对复杂度和并行性有所了解。这两种算法特性是决定你的代码性能的主要因素。
算法复杂度
算法复杂度描述了计算任务的难度。一种量化这种复杂度的方法是使用巴赫曼-兰道或“大 O”表示法。大 O 表示法根据计算随着输入大小的变化情况来描述函数。此表示法仅包括复杂度函数的主项。主项是输入大小增加时增长最快的项。
例如,复杂度大约随着每个额外输入元素增加一个固定值的算法,其 Big O 表示法为O(N),而复杂度在额外输入下不会变化的算法,其 Big O 表示法为O(1)。
本章描述了 std 库算法,它们属于以下五个复杂度类别。为了让你了解这些算法如何扩展,每个类别都列出了其 Big O 符号以及输入从 1,000 个元素增加到 10,000 个元素时,因主项而需要的大致额外操作次数。每个示例提供了一个具有给定复杂度类别的操作,其中N是涉及该操作的元素数量:
常数时间 O(1) 无需额外计算。一个例子是确定std::vector的大小。
对数时间 O(log N) 大约需要进行一次额外的计算。一个例子是查找std::set中的元素。
线性时间 O(N) 大约需要 9,000 次额外计算。一个例子是对集合中的所有元素求和。
准线性时间 O(N log N) 大约增加 37,000 次计算。一个例子是快速排序,常用的排序算法。
多项式时间(或二次时间)O(N²) 大约增加 99,000,000 次计算。一个例子是将一个集合中的所有元素与另一个集合中的所有元素进行比较。
计算机科学的一个完整领域致力于根据计算问题的难度来对其进行分类,因此这是一个复杂的话题。本章提到的每个算法的复杂度取决于目标序列的大小如何影响所需工作量。实际上,你应该对性能进行分析,以确定某个算法是否具备合适的扩展性。但这些复杂度类别可以让你大致了解某个算法的开销。
执行策略
一些算法,通常被称为并行算法,可以将一个算法分解,使得独立的实体可以同时在不同部分解决问题。许多标准库算法允许你通过执行策略来指定并行性。执行策略表示算法允许的并行度。从标准库的角度看,算法可以按顺序执行或并行执行。顺序算法一次只能由单个实体处理问题;并行算法可以有多个实体共同协作解决问题。
此外,并行算法可以是向量化的或非向量化的。向量化算法允许实体以未指定的顺序执行工作,甚至允许单个实体同时处理问题的多个部分。例如,需要在实体之间进行同步的算法通常是不可向量化的,因为同一实体可能会多次尝试获取锁,导致死锁。
<execution>头文件中存在三种执行策略:
-
std::execution::seq指定顺序执行(非并行执行)。 -
std::execution::par指定并行执行。 -
std::execution::par_unseq指定并行且向量化的执行。
对于那些支持执行策略的算法,默认策略是seq,这意味着你必须显式选择并行执行及其相关的性能优势。请注意,C++标准没有明确指定这些执行策略的具体含义,因为不同平台处理并行性的方式不同。当你提供非顺序执行策略时,你仅仅是在声明“这个算法是安全的,可以并行化”。
在第一章中,你将更详细地探讨执行策略。目前,只需注意一些算法允许并行性。
警告
本章中的算法描述并不完整。它们包含足够的信息,能够为您提供有关标准库中许多可用算法的良好背景。我建议您在确定适合自己需求的算法后,查阅本章末尾的“进一步阅读”部分中的资源。接受可选执行策略的算法,在提供非默认策略时,通常会有不同的要求,特别是在涉及迭代器时。例如,如果一个算法通常接受输入迭代器,使用执行策略通常会导致该算法要求使用前向迭代器。列出这些差异会使已经相当庞大的章节更长,因此描述中省略了这些差异。
如何使用本章
本章是一本快速参考,包含 50 多个算法。每个算法的覆盖面简洁明了。每个算法以简短的描述开始,紧接着是该算法的函数声明的简写表示,并附有每个参数的解释。声明中用括号表示可选参数。接下来,列出了算法的复杂度。最后是一个非详尽但具有说明性的示例,展示了该算法的应用。本章中的几乎所有示例都是单元测试,隐含地包括以下前言:
#include "catch.hpp"
#include <vector>
#include <string>
using namespace std;
如有需要,参阅相关小节[算法]获取详细信息。
非修改序列操作
非修改序列操作是一个在序列上执行计算但不以任何方式修改序列的算法。您可以将这些算法视为const算法。本节中解释的每个算法都在<algorithm>头文件中。
all_of
all_of算法用于判断序列中的每个元素是否符合用户指定的某些标准。
如果目标序列为空,或者pred对序列中的所有元素返回true,则算法返回true;否则,返回false。
bool all_of([ep], ipt_begin, ipt_end, pred);
参数
-
一个可选的
std::execution执行策略ep(默认:std::execution::seq) -
一对
InputIterator对象,ipt_begin和ipt_end,表示目标序列 -
一个一元谓词
pred,接受目标序列中的一个元素
复杂度
线性 该算法最多调用pred``distance(ipt_begin, ipt_end)次。
示例
#include <algorithm>
TEST_CASE("all_of") {
vector<string> words{ "Auntie", "Anne's", "alligator" }; ➊
const auto starts_with_a =
[](const auto& word➋) {
if (word.empty()) return false; ➌
return word[0] == 'A' || word[0] == 'a'; ➍
};
REQUIRE(all_of(words.cbegin(), words.cend(), starts_with_a)); ➎
const auto has_length_six = [](const auto& word) {
return word.length() == 6; ➏
};
REQUIRE_FALSE(all_of(words.cbegin(), words.cend(), has_length_six)); ➐
}
在构造一个包含string对象的vector,名为words ➊之后,您构造了一个名为starts_with_a的 lambda 谓词,它接受一个名为word的单一对象 ➋。如果word为空,starts_with_a返回false ➌;否则,如果word以a或A开头,返回true ➍。由于所有的word元素都以a或A开头,当应用starts_with_a时,all_of返回true ➎。
在第二个例子中,你构造了谓词has_length_six,只有当word的长度为六时,它才返回true ➏。因为alligator的长度不是六,all_of在应用has_length_six到words时返回false ➐。
any_of
any_of算法判断序列中是否有任何元素满足用户指定的标准。
如果目标序列为空,或pred对于序列中的任何元素为true,则算法返回false;否则返回false。
bool any_of([ep], ipt_begin, ipt_end, pred);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
InputIterator对象,ipt_begin和ipt_end,表示目标序列 -
一个一元谓词
pred,接受来自目标序列的一个元素
复杂度
线性 算法最多调用pred distance(ipt_begin, ipt_end)次。
示例
#include <algorithm>
TEST_CASE("any_of") {
vector<string> words{ "Barber", "baby", "bubbles" }; ➊
const auto contains_bar = [](const auto& word) {
return word.find("Bar") != string::npos;
}; ➋
REQUIRE(any_of(words.cbegin(), words.cend(), contains_bar)); ➌
const auto is_empty = [](const auto& word) { return word.empty(); }; ➍
REQUIRE_FALSE(any_of(words.cbegin(), words.cend(), is_empty)); ➎
}
在构造了一个包含string对象的vector,命名为words ➊之后,你构造了一个名为contains_bar的 lambda 谓词,它接受一个名为word的单一对象 ➋。如果word包含子串Bar,它返回true;否则返回false。因为Barber包含Bar,any_of在应用contains_bar时返回true ➌。
在第二个例子中,你构造了谓词is_empty,只有当word为空时,它才返回true ➍。因为没有任何单词为空,any_of在应用is_empty到words时返回false ➎。
none_of
none_of算法判断序列中是否没有任何元素满足用户指定的标准。
如果目标序列为空,或pred对于序列中的任何元素为true,则算法返回true;否则返回false。
bool none_of([ep], ipt_begin, ipt_end, pred);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
InputIterator对象,ipt_begin和ipt_end,表示目标序列 -
一个一元谓词
pred,接受来自目标序列的一个元素
复杂度
线性 算法最多调用pred distance(ipt_begin, ipt_end)次。
示例
#include <algorithm>
TEST_CASE("none_of") {
vector<string> words{ "Camel", "on", "the", "ceiling" }; ➊
const auto is_hump_day = [](const auto& word) {
return word == "hump day";
}; ➋
REQUIRE(none_of(words.cbegin(), words.cend(), is_hump_day)); ➌
const auto is_definite_article = [](const auto& word) {
return word == "the" || word == "ye";
}; ➍
REQUIRE_FALSE(none_of(words.cbegin(), words.cend(), is_definite_article)); ➎
}
在构造了一个包含string对象的vector,命名为words ➊之后,你构造了一个名为is_hump_day的 lambda 谓词,它接受一个名为word的单一对象 ➋。如果word等于hump day,它返回true;否则返回false。因为words中不包含hump day,所以none_of在应用is_hump_day时返回true ➌。
在第二个例子中,你构造了谓词is_definite_article,只有当word是定冠词时,它才返回true ➍。因为the是定冠词,none_of在应用is_definite_article到words时返回false ➎。
for_each
for_each算法对序列中的每个元素应用某个用户定义的函数。
该算法对目标序列的每个元素应用 fn。虽然 for_each 被认为是一个不修改序列的操作,如果 ipt_begin 是一个可变迭代器,fn 可以接受一个非 const 参数。fn 返回的任何值都会被忽略。
如果省略了 ep,for_each 将返回 fn。否则,for_each 返回 void。
for_each([ep], ipt_begin, ipt_end, fn);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
InputIterator对象,ipt_begin和ipt_end,表示目标序列 -
一个一元函数,
fn,接受目标序列中的一个元素
复杂度
线性 该算法恰好调用 fn distance(ipt_begin, ipt_end) 次。
附加要求
-
如果省略了
ep,fn必须是可移动的。 -
如果提供了
ep,fn必须是可复制的。
示例
#include <algorithm>
TEST_CASE("for_each") {
vector<string> words{ "David", "Donald", "Doo" }; ➊
size_t number_of_Ds{}; ➋
const auto count_Ds = &number_of_Ds➌ {
if (word.empty()) return; ➎
if (word[0] == 'D') ++number_of_Ds; ➏
};
for_each(words.cbegin(), words.cend(), count_Ds); ➐
REQUIRE(3 == number_of_Ds); ➑
}
在构建一个包含 string 对象的 vector,名为 words ➊ 和一个计数器变量 number_of_Ds ➋ 后,构建捕获 number_of_Ds 引用的 lambda 谓词 count_Ds ➌,并接收一个名为 word ➍ 的单一对象。如果 word 为空,则返回 ➎;否则,如果 word 的第一个字母是 D,则递增 number_of_Ds ➏。
接下来,使用 for_each 遍历每个单词,将每个单词传递给 count_Ds ➐。结果是 number_of_Ds 为三 ➑。
for_each_n
for_each_n 算法对序列中的每个元素应用某个用户定义的函数。
该算法对目标序列的每个元素应用 fn。虽然 for_each_n 被认为是一个不修改序列的操作,如果 ipt_begin 是一个可变迭代器,fn 可以接受一个非 const 参数。fn 返回的任何值都会被忽略。它返回 ipt_begin+n。
InputIterator for_each_n([ep], ipt_begin, n, fn);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一个
InputIteratoript_begin,表示目标序列的第一个元素 -
一个整数
n,表示期望的迭代次数,以便表示目标序列的半开区间为ipt_begin到ipt_begin+n(Size是n的模板类型)。 -
一个一元函数
fn,接受目标序列中的一个元素
复杂度
线性 该算法恰好调用 fn n 次。
附加要求
-
如果省略了
ep,fn必须是可移动的。 -
如果提供了
ep,fn必须是可复制的。 -
n必须是非负数。
示例
#include <algorithm>
TEST_CASE("for_each_n") {
vector<string> words{ "ear", "egg", "elephant" }; ➊
size_t characters{}; ➋
const auto count_characters = &characters➌ {
characters += word.size(); ➎
};
for_each_n(words.cbegin(), words.size(), count_characters); ➏
REQUIRE(14 == characters); ➐
}}
在构建一个包含 string 对象的 vector,名为 words ➊ 和一个计数器变量 characters ➋ 后,构建捕获 characters 引用的 lambda 谓词 count_characters ➌,并接收一个名为 word ➍ 的单一对象。lambda 将 word 的长度加到 characters 上 ➎。
接下来,使用 for_each_n 遍历每个单词,将每个单词传递给 count_characters ➏。结果是 characters 为 14 ➐。
find, find_if, 和 find_if_not
find、find_if 和 find_if_not 算法查找序列中第一个匹配某些用户定义标准的元素。
这些算法返回指向目标序列中第一个匹配value元素的InputIterator(find),在与pred一起调用时返回true(find_if),或者在与pred一起调用时返回false(find_if_not)。
如果算法未找到匹配项,则返回ipt_end。
InputIterator find([ep], ipt_begin, ipt_end, value);
InputIterator find_if([ep], ipt_begin, ipt_end, pred);
InputIterator find_if_not([ep], ipt_begin, ipt_end, pred);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
InputIterator对象,ipt_begin和ipt_end,表示目标序列 -
一个与目标序列的底层类型(
find)相等可比较的const引用value,或者一个接受目标序列底层类型作为单一参数的谓词(find_if和find_if_not)
复杂度
线性 该算法最多进行distance(ipt_begin, ipt_end)次比较(find)或调用pred(find_if 和 find_if_not)。
示例
#include <algorithm>
TEST_CASE("find find_if find_if_not") {
vector<string> words{ "fiffer", "feffer", "feff" }; ➊
const auto find_result = find(words.cbegin(), words.cend(), "feff"); ➋
REQUIRE(*find_result == words.back()); ➌
const auto defends_digital_privacy = [](const auto& word) {
return string::npos != word.find("eff"); ➍
};
const auto find_if_result = find_if(words.cbegin(), words.cend(),
defends_digital_privacy); ➎
REQUIRE(*find_if_result == "feffer"); ➏
const auto find_if_not_result = find_if_not(words.cbegin(), words.cend(),
defends_digital_privacy); ➐
REQUIRE(*find_if_not_result == words.front()); ➑
}
在构造一个包含string对象的vector,命名为words ➊之后,你使用find来定位feff ➋,它位于words的末尾 ➌。接下来,你构造了谓词defends_digital_privacy,如果word包含字母eff ➍,则返回true。然后你使用find_if来定位words中第一个包含eff的字符串 ➎,即feffer ➏。最后,你使用find_if_not将defends_digital_privacy应用于words ➐,它返回第一个元素fiffer(因为它不包含eff) ➑。
find_end
find_end算法查找子序列的最后一次出现。
如果算法未找到符合条件的序列,则返回fwd_end1。如果find_end确实找到了一个子序列,则返回一个ForwardIterator,指向最后一个匹配子序列的第一个元素。
InputIterator find_end([ep], fwd_begin1, fwd_end1,
fwd_begin2, fwd_end2, [pred]);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
两对
ForwardIterator,fwd_begin1/fwd_end1和fwd_begin2/fwd_end2,表示目标序列 1 和 2 -
一个可选的二元谓词
pred,用于比较两个元素是否相等
复杂度
二次 该算法最多进行以下次数的比较或调用pred:
distance(fwd_begin2, fwd_end2) * (distance(fwd_begin1, fwd_end1) -
distance(fwd_begin2, fwd_end2) + 1)
示例
#include <algorithm>
TEST_CASE("find_end") {
vector<string> words1{ "Goat", "girl", "googoo", "goggles" }; ➊
vector<string> words2{ "girl", "googoo" }; ➋
const auto find_end_result1 = find_end(words1.cbegin(), words1.cend(),
words2.cbegin(), words2.cend()); ➌
REQUIRE(*find_end_result1 == words1[1]); ➍
const auto has_length = [](const auto& word, const auto& len) {
return word.length() == len; ➎
};
vector<size_t> sizes{ 4, 6 }; ➏
const auto find_end_result2 = find_end(words1.cbegin(), words1.cend(),
sizes.cbegin(), sizes.cend(),
has_length); ➐
REQUIRE(*find_end_result2 == words1[1]); ➑
}
在构造一个包含string对象的vector,命名为words1 ➊,另一个名为words2 ➋之后,你调用find_end来确定words1中哪个元素开始匹配words2的子序列 ➌。结果是find_end_result1,其值为girl ➍。
接下来,你构造了一个 lambda 表达式has_length,它接受两个参数word和len,如果word.length()等于len ➎,则返回true。你构造了一个名为sizes的size_t类型的vector ➏,并用words1、sizes和has_length调用find_end ➐。结果find_end_result2指向words1中第一个长度为4的元素,后面的单词长度为6。由于girl的长度为4,googoo的长度为6,所以find_end_result2指向girl ➑。
find_first
find_first_of算法查找序列 1 中第一个等于序列 2 中某个元素的位置。
如果提供了pred,算法查找目标序列 1 中第一个满足对于序列 2 中的某个j,pred(i, j)为true的元素 i。
如果find_first_of没有找到该子序列,则返回ipt_end1。如果find_first_of找到一个子序列,则返回一个指向第一个匹配子序列元素的InputIterator。(注意,如果ipt_begin1也是一个ForwardIterator,find_first_of将返回一个ForwardIterator。)
InputIterator find_first_of([ep], ipt_begin1, ipt_end1,
fwd_begin2, fwd_end2, [pred]);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
InputIterator对象,ipt_begin1/ipt_end1,表示目标序列 1 -
一对
ForwardIterator对象,fwd_begin2/fwd_end2,表示目标序列 2 -
一个可选的二元谓词
pred,用于比较两个元素是否相等
复杂度
二次 算法最多进行以下次数的比较或pred调用:
distance(ipt_begin1, ipt_end1) * distance(fwd_begin2, fwd_end2)
示例
#include <algorithm>
TEST_CASE("find_first_of") {
vector<string> words{ "Hen", "in", "a", "hat" }; ➊
vector<string> indefinite_articles{ "a", "an" }; ➋
const auto find_first_of_result = find_first_of(words.cbegin(),
words.cend(),
indefinite_articles.cbegin(),
indefinite_articles.cend()); ➌
REQUIRE(*find_first_of_result == words[2]); ➍
}
在构造一个包含string对象的vector,名为words ➊,以及另一个名为indefinite_articles ➋之后,调用find_first_of来确定words中哪个元素开始的子序列等于indefinite_articles ➌。结果是find_first_of_result,其值为元素a ➍。
adjacent_find
adjacent_find算法找到序列中的第一个重复元素。
算法查找目标序列中第一个相邻元素相等的位置,或者如果提供了pred,算法查找目标序列中第一个满足pred(i, i+1)为true的元素。
如果adjacent_find没有找到该元素,则返回fwd_end。如果adjacent_find找到了该元素,则返回一个指向该元素的ForwardIterator。
ForwardIterator adjacent_find([ep], fwd_begin, fwd_end, [pred]);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
ForwardIterator对象,fwd_begin/fwd_end,表示目标序列 -
一个可选的二元谓词
pred用于比较两个元素是否相等
复杂度
线性 如果没有提供执行策略,算法最多进行以下次数的比较或pred调用:
min(distance(fwd_begin, i)+1, distance(fwd_begin, fwd_end)-1)
其中 i 是返回值的索引。
示例
#include <algorithm>
TEST_CASE("adjacent_find") {
vector<string> words{ "Icabod", "is", "itchy" }; ➊
const auto first_letters_match = [](const auto& word1, const auto& word2) { ➋
if (word1.empty() || word2.empty()) return false;
return word1.front() == word2.front();
};
const auto adjacent_find_result = adjacent_find(words.cbegin(), words.cend(),
first_letters_match); ➌
REQUIRE(*adjacent_find_result == words[1]); ➍
}
在构造一个包含string对象的vector,名为words ➊之后,构造一个名为first_letters_match的 lambda,该 lambda 接受两个单词并判断它们是否以相同的字母开头 ➋。调用adjacent_find来确定哪个元素与后续字母具有相同的首字母 ➌。结果adjacent_find_result ➍为is,因为它与itchy共享首字母 ➍。
count
count算法统计序列中符合某些用户定义标准的元素数量。
算法返回目标序列中i元素的数量,其中pred(i)为true,或者value == i。通常,DifferenceType是size_t,但它取决于InputIterator的实现。当你想要统计某个特定值的出现次数时,你使用count,而当你有一个更复杂的谓词想要用于比较时,你使用count_if。
DifferenceType count([ep], ipt_begin, ipt_end, value);
DifferenceType count_if([ep], ipt_begin, ipt_end, pred);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
InputIterator对象,ipt_begin/ipt_end,表示目标序列。 -
一个
value或一个一元谓词pred,用于评估目标序列中的元素x是否应被计数。
复杂度
线性 如果没有给定执行策略,算法会进行distance (ipt_begin, ipt_end)次比较或pred调用。
示例
#include <algorithm>
TEST_CASE("count") {
vector<string> words{ "jelly", "jar", "and", "jam" }; ➊
const auto n_ands = count(words.cbegin(), words.cend(), "and"); ➋
REQUIRE(n_ands == 1); ➌
const auto contains_a = [](const auto& word) { ➍
return word.find('a') != string::npos;
};
const auto count_if_result = count_if(words.cbegin(), words.cend(),
contains_a); ➎
REQUIRE(count_if_result == 3); ➏
}
在构造一个包含string对象的vector,名为words ➊之后,你用它来调用count,值为and ➋。这会返回1,因为一个元素等于and ➌。接下来,你构造一个名为contains_a的 lambda,它接受一个单词并判断它是否包含a ➍。你调用count_if来确定有多少个单词包含a ➎。结果为3,因为有三个元素包含a ➏。
不匹配
mismatch算法用于查找两个序列中的第一个不匹配项。
算法找到来自序列 1 和序列 2 的第一个不匹配元素对i、j。具体来说,它找出第一个索引 n,使得i = (ipt_begin1 + n);j = (ipt_begin2 + n);并且i != j或pred(i, j) == false。
返回的pair中的迭代器类型与ipt_begin1和ipt_begin2的类型相等。
pair<Itr, Itr> mismatch([ep], ipt_begin1, ipt_end1,
ipt_begin2, [ipt_end2], [pred]);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq)。 -
两对
InputIterator,ipt_begin1/ipt_end1和ipt_begin2/ipt_end2,表示目标序列1和2。如果你没有提供ipt_end2,则序列 1 的长度隐含着序列 2 的长度。 -
一个可选的二元谓词
pred用于比较两个元素是否相等。
复杂度
线性 如果没有给定执行策略,最坏情况下算法会进行以下次数的比较或pred调用:
min(distance(ipt_begin1, ipt_end1), distance(ipt_begin2, ipt_end2))
示例
#include <algorithm>
TEST_CASE("mismatch") {
vector<string> words1{ "Kitten", "Kangaroo", "Kick" }; ➊
vector<string> words2{ "Kitten", "bandicoot", "roundhouse" }; ➋
const auto mismatch_result1 = mismatch(words1.cbegin(), words1.cend(),
words2.cbegin()); ➌
REQUIRE(*mismatch_result1.first == "Kangaroo"); ➍
REQUIRE(*mismatch_result1.second == "bandicoot"); ➎
const auto second_letter_matches = [](const auto& word1,
const auto& word2) { ➏
if (word1.size() < 2) return false;
if (word2.size() < 2) return false;
return word1[1] == word2[1];
};
const auto mismatch_result2 = mismatch(words1.cbegin(), words1.cend(),
words2.cbegin(), second_letter_matches); ➐
REQUIRE(*mismatch_result2.first == "Kick"); ➑
REQUIRE(*mismatch_result2.second == "roundhouse"); ➒
}
在构造两个vector类型的string序列,名为words1 ➊和words2 ➋之后,你将它们作为mismatch的目标序列 ➌。这会返回一个pair,指向元素Kangaroo和bandicoot ➍ ➎。接下来,你构造一个名为second_letter_matches的 lambda,它接受两个单词并判断它们的第二个字母是否相同 ➏。你调用mismatch来找出第一个第二个字母不匹配的元素对 ➐。结果是元素对Kick ➑和roundhouse ➒。
相等
equal算法用于判断两个序列是否相等。
算法用于判断序列 1 的元素是否与序列 2 的元素相等。
bool equal([ep], ipt_begin1, ipt_end1, ipt_begin2, [ipt_end2], [pred]);
参数
-
一个可选的
std::execution执行策略ep(默认:std::execution::seq)。 -
两对
InputIterator,ipt_begin1/ipt_end1和ipt_begin2/ipt_end2,表示目标序列 1 和 2。如果没有提供ipt_end2,则序列 1 的长度意味着序列 2 的长度。 -
一个可选的二元谓词
pred,用于比较两个元素是否相等。
复杂度
线性 当没有给出执行策略时,算法在最坏情况下进行以下数量的比较或调用pred:
min(distance(ipt_begin1, ipt_end1), distance(ipt_begin2, ipt_end2))
示例
#include <algorithm>
TEST_CASE("equal") {
vector<string> words1{ "Lazy", "lion", "licks" }; ➊
vector<string> words2{ "Lazy", "lion", "kicks" }; ➋
const auto equal_result1 = equal(words1.cbegin(), words1.cend(),
words2.cbegin()); ➌
REQUIRE_FALSE(equal_result1); ➍
words2[2] = words1[2]; ➎
const auto equal_result2 = equal(words1.cbegin(), words1.cend(),
words2.cbegin()); ➏
REQUIRE(equal_result2); ➐
}
在构造两个名为words1和words2的vector<string> ➊ ➋ 后,您将它们作为equal的目标序列 ➌。因为它们的最后一个元素lick和kick不相等,equal_result1为false ➍。在将words2的第三个元素设置为words1的第三个元素 ➎ 后,您再次使用相同的参数调用equal ➏。因为序列现在相同,equal_result2为true ➐。
is_permutation
is_permutation算法确定两个序列是否是排列,即它们包含相同的元素,但可能顺序不同。
算法确定是否存在序列 2 的某个排列,使得序列 1 的元素等于该排列的元素。
bool is_permutation([ep], fwd_begin1, fwd_end1, fwd_begin2, [fwd_end2], [pred]);
参数
-
一个可选的
std::execution执行策略ep(默认:std::execution::seq)。 -
两对
ForwardIterator,fwd_begin1/fwd_end1和fwd_begin2/fwd_end2,表示目标序列 1 和 2。如果没有提供fwd_end2,则序列 1 的长度意味着序列 2 的长度。 -
一个可选的二元谓词
pred,用于比较两个元素是否相等。
复杂度
二次方 当没有给出执行策略时,算法在最坏情况下进行以下数量的比较或调用pred:
distance(fwd_begin1, fwd_end1) * distance(fwd_begin2, fwd_end2)
示例
#include <algorithm>
TEST_CASE("is_permutation") {
vector<string> words1{ "moonlight", "mighty", "nice" }; ➊
vector<string> words2{ "nice", "moonlight", "mighty" }; ➋
const auto result = is_permutation(words1.cbegin(), words1.cend(),
words2.cbegin()); ➌
REQUIRE(result); ➍
}
在构造两个名为words1和words2的vector<string> ➊ ➋ 后,您将它们作为is_permutation的目标序列 ➌。因为words2是words1的排列,is_permutation返回true ➍。
注意
search
search算法用于定位子序列。
算法在序列 1 中定位序列 2。换句话说,它返回序列 1 中的第一个迭代器 i,使得对于每个非负整数n,*(i + n)等于*(ipt_begin2 + n),或者如果提供了谓词pred(*(i + n), *(ipt_begin2 + n))为true。如果序列 2 为空,search算法返回ipt_begin1,如果没有找到子序列,则返回ipt_begin2。这与find不同,因为它定位的是子序列,而不是单个元素。
ForwardIterator search([ep], fwd_begin1, fwd_end1,
fwd_begin2, fwd_end2, [pred]);
参数
-
一个可选的
std::execution执行策略ep(默认:std::execution::seq)。 -
两对
ForwardIterator,fwd_begin1/fwd_end1和fwd_begin2/fwd_end2,表示目标序列 1 和 2 -
一个可选的二元谓词
pred,用于比较两个元素是否相等
复杂度
二次复杂度 如果没有给定执行策略,最坏情况下该算法会进行以下次数的比较或 pred 调用:
distance(fwd_begin1, fwd_end1) * distance(fwd_begin2, fwd_end2)
示例
#include <algorithm>
TEST_CASE("search") {
vector<string> words1{ "Nine", "new", "neckties", "and",
"a", "nightshirt" }; ➊
vector<string> words2{ "and", "a", "nightshirt" }; ➋
const auto search_result_1 = search(words1.cbegin(), words1.cend(),
words2.cbegin(), words2.cend()); ➌
REQUIRE(*search_result_1 == "and"); ➍
vector<string> words3{ "and", "a", "nightpant" }; ➎
const auto search_result_2 = search(words1.cbegin(), words1.cend(),
words3.cbegin(), words3.cend()); ➏
REQUIRE(search_result_2 == words1.cend()); ➐
}
在构建了两个名为 words1 ➊ 和 words2 ➋ 的 vector 类型的 string 序列后,你将它们作为 search 的目标序列 ➌。由于 words2 是 words1 的子序列,search 返回指向 and 的迭代器 ➍。包含 string 对象的 vector words3 ➎ 包含了单词 nightpant 而不是 nightshirt,因此使用它而不是 words2 调用 search 会返回 words1 的末尾迭代器 ➐。
search_n
search_n 算法定位包含相同连续值的子序列。
该算法在序列中查找 count 个连续的 values,并返回一个指向第一个 value 的迭代器,或者如果未找到此子序列,则返回 fwd_end。与 adjacent_find 不同,它定位的是一个子序列而不是单个元素。
ForwardIterator search_n([ep], fwd_begin, fwd_end, count, value, [pred]);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
ForwardIterator,fwd_begin/fwd_end,表示目标序列 -
一个整数型
count值,表示你想查找的连续匹配的数量 -
一个
value,表示你要查找的元素 -
一个可选的二元谓词
pred,用于比较两个元素是否相等
复杂度
线性 如果没有给定执行策略,最坏情况下该算法会进行 distance(fwd_begin, fwd_end) 次比较或 pred 调用。
示例
#include <algorithm>
TEST_CASE("search_n") {
vector<string> words{ "an", "orange", "owl", "owl", "owl", "today" }; ➊
const auto result = search_n(words.cbegin(), words.cend(), 3, "owl"); ➋
REQUIRE(result == words.cbegin() + 2); ➌
}
在构建了一个名为 words 的 vector 类型的 string 序列后 ➊,你将它作为 search_n 的目标序列 ➋。由于 words 中包含三个 owl 单词的实例,它会返回指向第一个实例的迭代器 ➌。
变异序列操作
一个 变异序列操作 是一种算法,它对序列进行计算,并允许以某种方式修改序列。本节中解释的每个算法都位于 <algorithm> 头文件中。
copy
copy 算法将一个序列复制到另一个序列中。
该算法将目标序列复制到 result 中,并返回接收序列的末尾迭代器。你有责任确保 result 表示一个具有足够空间来存储目标序列的序列。
OutputIterator copy([ep], ipt_begin, ipt_end, result);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
InputIterator对象,ipt_begin和ipt_end,表示目标序列 -
一个
OutputIterator,result,接收复制的序列
复杂度
线性 该算法会从目标序列中复制元素,恰好执行 distance(ipt_begin, ipt_end) 次。
附加要求
序列 1 和 2 必须不重叠,除非操作是 向左复制。例如,对于一个包含 10 个元素的向量 v,std::copy(v.begin()+3, v.end(), v.begin()) 是合法的,但 std::copy(v.begin(), v.begin()+7, v.begin()+3) 不是。
注意
回顾一下“插入迭代器”中的 back_inserter,见 第 464 页,它返回一个输出迭代器,将写操作转换为在底层容器上的插入操作。
示例
#include <algorithm>
TEST_CASE("copy") {
vector<string> words1{ "and", "prosper" }; ➊
vector<string> words2{ "Live", "long" }; ➋
copy(words1.cbegin(), words1.cend(), ➌
back_inserter(words2)➍);
REQUIRE(words2 == vector<string>{ "Live", "long", "and", "prosper" }); ➎
}
在构造两个 vector 类型的 string 对象后 ➊ ➋,你使用 copy,将 words1 作为待复制序列 ➌,words2 作为目标序列 ➍。结果是 words2 包含了 words1 的内容,并追加到原始内容后 ➎。
copy_n
copy_n 算法将一个序列复制到另一个序列中。
该算法将目标序列复制到 result 中,并返回接收序列的末尾迭代器。你需要确保 result 代表一个具有足够空间存储目标序列的序列,并且 n 代表目标序列的正确长度。
OutputIterator copy_n([ep], ipt_begin, n, result);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一个表示目标序列起始位置的开始迭代器,
ipt_begin -
目标序列的大小,
n -
一个
OutputIterator result,接收复制后的序列
复杂度
线性 该算法将从目标序列中复制 distance(ipt_begin, ipt_end) 次元素。
附加要求
序列 1 和 2 必须不包含相同的对象,除非操作是 向左复制。
示例
#include <algorithm>
TEST_CASE("copy_n") {
vector<string> words1{ "on", "the", "wind" }; ➊
vector<string> words2{ "I'm", "a", "leaf" }; ➋
copy_n(words1.cbegin(), words1.size(), ➌
back_inserter(words2)); ➍
REQUIRE(words2 == vector<string>{ "I'm", "a", "leaf",
"on", "the", "wind" }); ➎
}
在构造两个 vector 类型的 string 对象后 ➊ ➋,你使用 copy_n,将 words1 作为待复制序列 ➌,words2 作为目标序列 ➍。结果是 words2 包含了 words1 的内容,并追加到原始内容后 ➎。
copy_backward
copy_backward 算法将一个序列的元素反向复制到另一个序列中。
该算法将序列 1 复制到序列 2 中,并返回接收序列的末尾迭代器。元素会反向复制,但在目标序列中仍然按原顺序出现。你需要确保序列 1 有足够的空间来存储序列 2。
OutputIterator copy_backward([ep], ipt_begin1, ipt_end1, ipt_end2);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
InputIterator对象,ipt_begin1和ipt_end1,表示序列 1 -
一个
InputIterator,ipt_end2,表示序列 2 末尾之后的位置
复杂度
线性 该算法将从目标序列中复制 distance(ipt_begin1, ipt_end1) 次元素。
附加要求
序列 1 和 2 必须不重叠。
示例
#include <algorithm>
TEST_CASE("copy_backward") {
vector<string> words1{ "A", "man", "a", "plan", "a", "bran", "muffin" }; ➊
vector<string> words2{ "a", "canal", "Panama" }; ➋
const auto result = copy_backward(words2.cbegin(), words2.cend(), ➌
words1.end()); ➍
REQUIRE(words1 == vector<string>{ "A", "man", "a", "plan",
"a", "canal", "Panama" }); ➎
}
在构造了两个string类型的vector对象 ➊ ➋后,你调用copy_backward,以words2作为要复制的序列 ➌,words1作为目标序列 ➍。结果是,word2的内容替换了words1的最后三个单词 ➎。
move
move算法将一个序列移动到另一个序列中。
算法将目标序列移动并返回接收序列的结束迭代器。你有责任确保目标序列的元素至少与源序列一样多。
OutputIterator move([ep], ipt_begin, ipt_end, result);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
InputIterator对象,ipt_begin和ipt_end,表示目标序列 -
一个
InputIterator,result,表示要移动到的序列的起始位置
复杂度
线性 算法从目标序列中移动元素,恰好distance(ipt_begin, ipt_end)次。
附加要求
-
序列不得重叠,除非是向左移动。
-
类型必须是可移动的,但不一定是可复制的。
示例
#include <algorithm>
struct MoveDetector { ➊
MoveDetector() : owner{ true } {} ➋
MoveDetector(const MoveDetector&) = delete;
MoveDetector& operator=(const MoveDetector&) = delete;
MoveDetector(MoveDetector&& o) = delete;
MoveDetector& operator=(MoveDetector&&) { ➌
o.owner = false;
owner = true;
return *this;
}
bool owner;
};
TEST_CASE("move") {
vector<MoveDetector> detectors1(2); ➍
vector<MoveDetector> detectors2(2); ➎
move(detectors1.begin(), detectors1.end(), detectors2.begin()); ➏
REQUIRE_FALSE(detectors1[0].owner); ➐
REQUIRE_FALSE(detectors1[1].owner); ➑
REQUIRE(detectors2[0].owner); ➒
REQUIRE(detectors2[1].owner); ➓
}
首先,你声明了MoveDetector类 ➊,它定义了一个默认构造函数,将唯一的成员owner设置为true ➋。它删除了复制构造函数和移动构造函数,以及复制赋值运算符,但定义了一个移动赋值运算符,用于交换owner ➌。
在构造了两个MoveDetector对象的vector ➍ ➎后,你调用move,以detectors1作为要move的序列,detectors2作为目标序列 ➏。结果是,detector1的元素处于moved from状态 ➐➑,而detectors2的元素被移动到detectors2 ➒➓。
move_backward
move_backward算法将一个序列的反向内容移动到另一个序列中。
算法将序列 1 移动到序列 2,并返回一个指向最后一个移动元素的迭代器。元素向后移动,但会以原始顺序出现在目标序列中。你有责任确保目标序列的元素至少与源序列一样多。
OutputIterator move_backward([ep], ipt_begin, ipt_end, result);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
InputIterator对象,ipt_begin和ipt_end,表示目标序列 -
一个
InputIterator,result,表示要移动到的序列
复杂度
线性 算法从目标序列中移动元素,恰好distance(ipt_begin, ipt_end)次。
附加要求
-
序列不得重叠。
-
类型必须是可移动的,但不一定是可复制的。
示例
#include <algorithm>
struct MoveDetector { ➊
--snip--
};
TEST_CASE("move_backward") {
vector<MoveDetector> detectors1(2); ➋
vector<MoveDetector> detectors2(2); ➌
move_backward(detectors1.begin(), detectors1.end(), detectors2.end()); ➍
REQUIRE_FALSE(detectors1[0].owner); ➎
REQUIRE_FALSE(detectors1[1].owner); ➏
REQUIRE(detectors2[0].owner); ➐
REQUIRE(detectors2[1].owner); ➑
}
首先,你声明了MoveDetector类 ➊(有关实现,请参见“move”章节,第 595 页)。
在构造了两个MoveDetector对象的vector后 ➋ ➌,你调用move,将detectors1作为要move的序列,detectors2作为目标序列 ➍。结果是,detector1的元素处于已移动出状态 ➎➏,detector2的元素处于已移动入状态 ➐➑。
swap_ranges
swap_ranges算法将一个序列的元素交换到另一个序列中。
该算法对序列 1 和序列 2 的每个元素调用swap,并返回接收序列的结束迭代器。你有责任确保目标序列的元素数量至少与源序列相同。
OutputIterator swap_ranges([ep], ipt_begin1, ipt_end1, ipt_begin2);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq)。 -
一对
ForwardIterator,ipt_begin1和ipt_end1,表示序列 1。 -
一个
ForwardIterator,ipt_begin2,表示序列 2 的开始。
复杂度
线性 该算法会调用swap正好distance(ipt_begin1, ipt_end1)次。
附加要求
每个序列中包含的元素必须是可交换的。
示例
#include <algorithm>
TEST_CASE("swap_ranges") {
vector<string> words1{ "The", "king", "is", "dead." }; ➊
vector<string> words2{ "Long", "live", "the", "king." }; ➋
swap_ranges(words1.begin(), words1.end(), words2.begin()); ➌
REQUIRE(words1 == vector<string>{ "Long", "live", "the", "king." }); ➍
REQUIRE(words2 == vector<string>{ "The", "king", "is", "dead." }); ➎
}
在构造了两个包含string对象的vector后 ➊ ➋,你调用swap,将words1和words2作为要交换的序列 ➌。结果是words1和words2交换内容 ➍ ➎。
transform
transform算法修改一个序列中的元素,并将其写入另一个序列。
该算法对目标序列的每个元素调用unary_op并将其输出到输出序列,或者对每个目标序列中的相应元素调用binary_op。
OutputIterator transform([ep], ipt_begin1, ipt_end1, result, unary_op);
OutputIterator transform([ep], ipt_begin1, ipt_end1, ipt_begin2,
result, binary_op);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq)。 -
一对
InputIterator对象,ipt_begin1和ipt_end1,表示目标序列。 -
一个可选的
InputIterator,ipt_begin2,表示第二个目标序列。你必须确保第二个目标序列的元素数量至少与第一个目标序列相同。 -
一个
OutputIterator,result,表示输出序列的开始。 -
一个一元操作,
unary_op,用于将目标序列的元素转换为输出序列的元素。如果你提供了两个目标序列,则提供一个二元操作binary_op,它接受每个目标序列中的一个元素,并将它们转换为输出序列中的元素。
复杂度
线性 该算法会调用unary_op或binary_op,正好调用distance(ipt_begin1, ipt_end1)次。
示例
#include <algorithm>
#include <boost/algorithm/string/case_conv.hpp>
TEST_CASE("transform") {
vector<string> words1{ "farewell", "hello", "farewell", "hello" }; ➊
vector<string> result1;
auto upper = [](string x) { ➋
boost::algorithm::to_upper(x);
return x;
};
transform(words1.begin(), words1.end(), back_inserter(result1), upper); ➌
REQUIRE(result1 == vector<string>{ "FAREWELL", "HELLO",
"FAREWELL", "HELLO" }); ➍
vector<string> words2{ "light", "human", "bro", "quantum" }; ➎
vector<string> words3{ "radar", "robot", "pony", "bit" }; ➏
vector<string> result2;
auto portmantize = [](const auto &x, const auto &y) { ➐
const auto x_letters = min(size_t{ 2 }, x.size());
string result{ x.begin(), x.begin() + x_letters };
const auto y_letters = min(size_t{ 3 }, y.size());
result.insert(result.end(), y.end() - y_letters, y.end() );
return result;
};
transform(words2.begin(), words2.end(), words3.begin(),
back_inserter(result2), portmantize); ➑
REQUIRE(result2 == vector<string>{ "lidar", "hubot", "brony", "qubit" }); ➒
}
在构造了一个包含string对象的vector后 ➊,你构造了一个名为upper的 lambda,它按值接受一个string并使用 Boost 的to_upper算法将其转换为大写,如第十五章中讨论的 ➋。你使用transform,将words1作为目标序列,使用一个空的results1``vector的back_inserter,并将upper作为一元操作 ➌。调用transform后,results1包含了words1的大写版本 ➍。
在第二个示例中,您构造了两个 vector 类型的 string 对象 ➎➏。您还构造了一个名为 portmantize 的 lambda 函数,该函数接受两个 string 对象 ➐。该 lambda 返回一个新的 string,包含第一个参数的前两个字母和第二个参数的后三个字母。您将两个目标序列、一个指向空 vector 的 back_inserter 以及 portmantize ➑ 一同传递。result2 包含了 words1 和 words2 的混合词 ➒。
replace
replace 算法将序列中的某些元素替换为新的元素。
算法查找目标序列元素 x,对于满足 x == old_ref 或 pred(x) == true 的元素,将其赋值为 new_ref。
void replace([ep], fwd_begin, fwd_end, old_ref, new_ref);
void replace_if([ep], fwd_begin, fwd_end, pred, new_ref);
void replace_copy([ep], fwd_begin, fwd_end, result, old_ref, new_ref);
void replace_copy_if([ep], fwd_begin, fwd_end, result, pred, new_ref);
参数
-
一个可选的
std::execution执行策略ep(默认值:std::execution::seq) -
一对
ForwardIterator,fwd_begin和fwd_end,表示目标序列 -
一个
OutputIterator,result,表示输出序列的起始位置 -
一个
oldconst引用,表示要查找的元素 -
一个一元谓词
pred,用于判断元素是否符合替换条件 -
一个
new_refconst引用,表示要替换的元素
复杂度
线性 算法调用 pred 恰好 distance(fwd_begin, fwd_end) 次。
附加要求
每个序列中的元素必须能够与 old_ref 进行比较,并且能够赋值给 new_ref。
示例
#include <algorithm>
#include <string_view>
TEST_CASE("replace") {
using namespace std::literals; ➊
vector<string> words1{ "There", "is", "no", "try" }; ➋
replace(words1.begin(), words1.end(), "try"sv, "spoon"sv); ➌
REQUIRE(words1 == vector<string>{ "There", "is", "no", "spoon" }); ➍
const vector<string> words2{ "There", "is", "no", "spoon" }; ➎
vector<string> words3{ "There", "is", "no", "spoon" }; ➏
auto has_two_os = [](const auto& x) { ➐
return count(x.begin(), x.end(), 'o') == 2;
};
replace_copy_if(words2.begin(), words2.end(), words3.begin(), ➑
has_two_os, "try"sv);
REQUIRE(words3 == vector<string>{ "There", "is", "no", "try" }); ➒
}
首先引入 std::literals 命名空间 ➊,这样您就可以稍后使用 string_view 字面量。构造一个包含 string 对象的 vector ➋ 后,您调用 replace 并使用该 vector ➌ 来将所有 try 替换为 spoon ➍。
在第二个示例中,您构造了两个 vector 类型的 string 对象 ➎➏ 和一个名为 has_two_os 的 lambda 函数,该函数接受一个字符串并返回 true,如果该字符串恰好包含两个 o ➐。然后,您将 words2 作为目标序列,words3 作为目标序列传递给 replace_copy_if,它对 words2 中的每个元素应用 has_two_os,并将满足条件的元素替换为 try ➑。结果是 words2 不受影响,而 words3 中的元素 spoon 被替换为 try ➒。
fill
fill 算法用某个值填充序列。
算法将一个值写入目标序列的每个元素。fill_n 函数返回 opt_begin + n。
void fill([ep], fwd_begin, fwd_end, value);
OutputIterator fill_n([ep], opt_begin, n, value);
参数
-
一个可选的
std::execution执行策略ep(默认值:std::execution::seq) -
一个
ForwardIterator,fwd_begin,表示目标序列的起始位置 -
一个
ForwardIterator,fwd_end,表示序列末尾的下一个位置 -
一个表示元素数量的
Size n -
一个要写入目标序列每个元素的
value
复杂度
线性 算法将 value 赋值给目标序列的每个元素,恰好 distance(fwd_begin, fwd_end) 或 n 次。
附加要求
-
value参数必须能够写入序列。 -
Size类型的对象必须可以转换为整型。
示例
#include <algorithm>
// If police police police police, who polices the police police?
TEST_CASE("fill") {
vector<string> answer1(6); ➊
fill(answer1.begin(), answer1.end(), "police"); ➋
REQUIRE(answer1 == vector<string>{ "police", "police", "police",
"police", "police", "police" }); ➌
vector<string> answer2; ➍
fill_n(back_inserter(answer2), 6, "police"); ➎
REQUIRE(answer2 == vector<string>{ "police", "police", "police",
"police", "police", "police" }); ➏
}
你首先初始化一个包含六个空元素的vector,其中包含string对象 ➊。接下来,使用vector作为目标序列并将police作为值来调用fill ➋。结果是你的vector包含六个police ➌。
在第二个示例中,你初始化一个空的vector,其中包含string对象 ➍。然后,你用back_inserter调用fill_n,指向空的vector、长度为 6,并将police作为值 ➎。结果和之前一样:你的vector包含六个police ➏。
generate
generate算法通过调用一个函数对象来填充序列。
算法调用generator并将结果赋值到目标序列中。generate_n函数返回opt_begin+n。
void generate([ep], fwd_begin, fwd_end, generator);
OutputIterator generate_n([ep], opt_begin, n, generator);
参数
-
可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一个
ForwardIterator,fwd_begin,表示目标序列的起始位置 -
一个
ForwardIterator,fwd_end,表示序列末尾之后的位置 -
一个表示元素数量的
Size n -
一个
generator,当没有参数调用时,生成一个元素以写入目标序列
复杂度
线性 算法调用generator恰好distance(fwd_begin, fwd_end)次或n次。
附加要求
-
value参数必须可以写入序列。 -
Size类型的对象必须可以转换为整型。
示例
#include <algorithm>
TEST_CASE("generate") {
auto i{ 1 }; ➊
auto pow_of_2 = [&i]() { ➋
const auto tmp = i;
i *= 2;
return tmp;
};
vector<int> series1(6); ➌
generate(series1.begin(), series1.end(), pow_of_2); ➍
REQUIRE(series1 == vector<int>{ 1, 2, 4, 8, 16, 32 }); ➎
vector<int> series2; ➏
generate_n(back_inserter(series2), 6, pow_of_2); ➐
REQUIRE(series2 == vector<int>{ 64, 128, 256, 512, 1024, 2048 }); ➑
}
你首先初始化一个名为i的int为 1 ➊。接着,你创建一个名为pow_of_2的 lambda,它通过引用获取i ➋。每次调用pow_of_2时,它将i加倍,并返回加倍前的值。然后,你初始化一个包含六个元素的vector,其元素类型为int ➌。然后,你用vector作为目标序列,pow_of_2作为生成器来调用generate ➍。结果是vector包含前六个 2 的幂 ➎。
在第二个示例中,你初始化一个空的vector,其中包含int对象 ➏。接下来,你使用back_inserter调用generate_n,传入空的vector、大小为 6 和pow_of_2作为生成器 ➐。result是接下来的六个 2 的幂 ➑。注意,pow_of_2有状态,因为它通过引用捕获了i。
remove
remove算法从序列中移除某些元素。
算法将所有pred为true或元素等于value的元素移动,确保剩余元素的顺序保持不变,并返回指向第一个移动元素的迭代器。这个迭代器被称为结果序列的逻辑结束。序列的物理大小保持不变,通常remove调用后会跟着调用容器的erase方法。
ForwardIterator remove([ep], fwd_begin, fwd_end, value);
ForwardIterator remove_if([ep], fwd_begin, fwd_end, pred);
ForwardIterator remove_copy([ep], fwd_begin, fwd_end, result, value);
ForwardIterator remove_copy_if([ep], fwd_begin, fwd_end, result, pred);
参数
-
可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
ForwardIterator,fwd_begin和fwd_end,表示目标序列 -
一个
OutputIterator,result,表示目标序列(如果是复制的情况下) -
一个表示要移除元素的
value -
一个一元谓词
pred,用于判断元素是否符合移除的标准
复杂度
线性 该算法调用pred或与value进行比较的次数恰好是distance(fwd_begin, fwd_end)次。
附加要求
-
目标序列的元素必须是可移动的。
-
如果进行复制,元素必须是可复制的,且目标序列和源序列不能重叠。
示例
#include <algorithm>
TEST_CASE("remove") {
auto is_vowel = [](char x) { ➊
const static string vowels{ "aeiouAEIOU" };
return vowels.find(x) != string::npos;
};
string pilgrim = "Among the things Billy Pilgrim could not change "
"were the past, the present, and the future."; ➋
const auto new_end = remove_if(pilgrim.begin(), pilgrim.end(), is_vowel); ➌
REQUIRE(pilgrim == "mng th thngs Blly Plgrm cld nt chng wr th pst, "
"th prsnt, nd th ftr.present, and the future."); ➍
pilgrim.erase(new_end, pilgrim.end()); ➎
REQUIRE(pilgrim == "mng th thngs Blly Plgrm cld nt chng wr th "
"pst, th prsnt, nd th ftr."); ➏
}
首先,你创建一个名为is_vowel的 lambda 函数,当给定的char是元音时返回true ➊。接着,构造一个名为pilgrim的string,其中包含一个句子 ➋。然后,调用remove_if,以pilgrim作为目标句子,is_vowel作为谓词 ➌。每当remove_if遇到一个元音时,它会将剩余字符向左移动,从而消除句子中的所有元音。结果是,pilgrim包含了原始句子,去除了元音,并加上了present, and the future.这一短语 ➍。这个短语包含 24 个字符,这正好是remove_if从原句中移除的元音数量。present, and the future.这个短语是移除过程中剩余字符串移动所产生的碎片。
为了消除这些剩余元素,你保存remove_if返回的迭代器new_end,它指向新目标序列中最后一个字符后的一个位置,即present, and the future.中的p。要消除这些元素,你只需在pilgrim上使用erase方法,erase方法有一个接受半开区间的重载。你将remove_if返回的逻辑末尾new_end作为开始迭代器,同时将pilgrim.end()作为结束迭代器 ➎。结果是,pilgrim现在等于去除元音后的原始句子 ➏。
这种将remove(或remove_if)与erase方法结合使用的方式,称为擦除-移除惯用法,被广泛应用。
unique
unique算法从序列中移除冗余元素。
该算法移动所有pred判断为true的重复元素,或是相等的元素,确保剩余的元素是唯一的且保留原始顺序。它返回指向新逻辑末尾的迭代器。与std::remove一样,物理存储不会改变。
ForwardIterator unique([ep], fwd_begin, fwd_end, [pred]);
ForwardIterator unique_copy([ep], fwd_begin, fwd_end, result, [pred]);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
ForwardIterator,fwd_begin和fwd_end,表示目标序列 -
一个
OutputIterator,result,表示目标序列(如果是复制的情况下) -
一个二元谓词
pred,用于判断两个元素是否相等
复杂度
线性 该算法调用pred的次数恰好是distance(fwd_begin, fwd_end) - 1次。
附加要求
-
目标序列的元素必须是可移动的。
-
如果是复制,目标序列的元素必须是可复制的,并且目标范围与目标位置的范围不能重叠。
示例
#include <algorithm>
TEST_CASE("unique") {
string without_walls = "Wallless"; ➊
const auto new_end = unique(without_walls.begin(), without_walls.end()); ➋
without_walls.erase(new_end, without_walls.end()); ➌
REQUIRE(without_walls == "Wales"); ➍
}
你首先构造一个包含多个重复字符的string ➊。然后,你使用string作为目标序列调用unique ➋。这将返回逻辑上的结束位置,并将其赋值给new_end。接下来,你删除从new_end到without_walls.end()的范围 ➌。这是删除-移除模式的推论:最终你会得到Wales,其中包含连续的唯一字符 ➍。
reverse
reverse算法反转序列的顺序。
该算法通过交换元素或将其复制到目标序列来反转序列。
void reverse([ep], bi_begin, bi_end);
OutputIterator reverse_copy([ep], bi_begin, bi_end, result);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
BidirectionalIterator,bi_begin和bi_end,表示目标序列。 -
一个
OutputIterator,result,表示目标序列(如果是复制)。
复杂度
线性 该算法精确调用swap distance(bi_begin, bi_end)/2次。
附加要求
-
目标序列的元素必须是可交换的。
-
如果是复制,目标序列的元素必须是可复制的,并且目标范围与目标位置的范围不能重叠。
示例
#include <algorithm>
TEST_CASE("reverse") {
string stinky = "diaper"; ➊
reverse(stinky.begin(), stinky.end()); ➋
REQUIRE(stinky == "repaid"); ➌
}
你首先构造一个包含单词diaper的string ➊。接下来,你使用此string作为目标序列调用 reverse ➋。结果是单词repaid ➌。
sample
sample算法生成随机且稳定的子序列。
该算法从种群序列中抽取min(pop_end - pop_begin, n)个元素。稍微不直观的是,当且仅当ipt_begin是正向迭代器时,抽样结果才会被排序。它返回结果目标序列的结束位置。
OutputIterator sample([ep], ipt_begin, ipt_end, result, n, urb_generator);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
InputIterator对象,ipt_begin和ipt_end,表示种群序列(即要抽样的序列)。 -
一个
OutputIterator,result,表示目标序列。 -
一个
Distance类型的n,表示要抽样的元素数量。 -
一个
UniformRandomBitGenerator类型的urb_generator,例如在第十二章中介绍的 Mersenne Twisterstd::mt19937_64。
复杂度
线性 该算法的复杂度与distance(ipt_begin, ipt_end)成比例。
示例
#include <algorithm>
#include <map>
#include <string>
#include <iostream>
#include <iomanip>
#include <random>
using namespace std;
const string population = "ABCD"; ➊
const size_t n_samples{ 1'000'000 }; ➋
mt19937_64 urbg; ➌
void sample_length(size_t n) { ➍
cout << "-- Length " << n << " --\n";
map<string, size_t> counts; ➎
for (size_t i{}; i < n_samples; i++) {
string result;
sample(population.begin(), population.end(),
back_inserter(result), n, urbg); ➏
counts[result]++;
}
for (const auto[sample, n] : counts) { ➐
const auto percentage = 100 * n / static_cast<double>(n_samples);
cout << percentage << " '" << sample << "'\n"; ➑
}
}
int main() {
cout << fixed << setprecision(1); ➒
sample_length(0); ➓
sample_length(1);
sample_length(2);
sample_length(3);
sample_length(4);
}
-----------------------------------------------------------------------
-- Length 0 --
100.0 ''
-- Length 1 --
25.1 'A'
25.0 'B'
25.0 'C'
24.9 'D'
-- Length 2 --
16.7 'AB'
16.7 'AC'
16.6 'AD'
16.6 'BC'
16.7 'BD'
16.7 'CD'
-- Length 3 --
25.0 'ABC'
25.0 'ABD'
25.0 'ACD'
25.0 'BCD'
-- Length 4 --
100.0 'ABCD'
你首先构造一个名为population的const string,其中包含字母ABCD ➊。然后你初始化一个名为n_samples的const size_t,值为一百万 ➋,以及一个名为urbg的 Mersenne Twister ➌。所有这些对象的存储持续时间都是静态的。
此外,您初始化了一个名为sample_length的函数,该函数接受一个名为n的size_t参数➍。在该函数中,您构造一个map类型的string到size_t对象的集合➎,用于统计每次调用sample的频率。在一个for循环中,您调用sample,将population作为种群序列,将back_inserter作为目标序列的result字符串,n作为样本长度,以及urbg作为随机位生成器➏。
在一百万次迭代后,您迭代counts中的每个元素➐,并打印给定长度n的每个样本的概率分布➑。
在main函数中,您使用fixed和setprecision配置浮点数格式➒。最后,您使用从0到4的每个值调用sample_length➓。
因为string提供了随机访问迭代器,sample提供稳定(已排序)的样本。
警告
请注意,输出不包含像 DC 或 CAB 这样的未排序样本。这个排序行为可能并不是算法名称中显而易见的,所以请小心!
洗牌
shuffle算法生成随机排列。
该算法随机化目标序列,使得这些元素的每种可能排列出现的概率相等。
void shuffle(rnd_begin, rnd_end, urb_generator);
参数
-
一对
RandomAccessIterator(随机访问迭代器)rnd_begin和rnd_end,表示目标序列。 -
一个
UniformRandomBitGenerator(均匀随机位生成器)urb_generator,例如在第十二章中介绍的梅森旋转算法std::mt19937_64
复杂度
线性 该算法恰好交换distance(rnd_begin, rnd_end) - 1次。
附加要求
目标序列的元素必须是可交换的。
示例
#include <algorithm>
#include <map>
#include <string>
#include <iostream>
#include <random>
#include <iomanip>
using namespace std;
int main() {
const string population = "ABCD"; ➊
const size_t n_samples{ 1'000'000 }; ➋
mt19937_64 urbg; ➌
map<string, size_t> samples; ➍
cout << fixed << setprecision(1); ➎
for (size_t i{}; i < n_samples; i++) {
string result{ population }; ➏
shuffle(result.begin(), result.end(), urbg); ➐
samples[result]++; ➑
}
for (const auto[sample, n] : samples) { ➒
const auto percentage = 100 * n / static_cast<double>(n_samples);
cout << percentage << " '" << sample << "'\n"; ➓
}
}
-----------------------------------------------------------------------
4.2 'ABCD'
4.2 'ABDC'
4.1 'ACBD'
4.2 'ACDB'
4.2 'ADBC'
4.2 'ADCB'
4.2 'BACD'
4.2 'BADC'
4.1 'BCAD'
4.2 'BCDA'
4.1 'BDAC'
4.2 'BDCA'
4.2 'CABD'
4.2 'CADB'
4.1 'CBAD'
4.1 'CBDA'
4.2 'CDAB'
4.1 'CDBA'
4.2 'DABC'
4.2 'DACB'
4.2 'DBAC'
4.1 'DBCA'
4.2 'DCAB'
4.2 'DCBA'
您首先构造一个名为population的const string,其中包含字母ABCD➊。您还初始化一个名为n_samples的const size_t,它的值为一百万➋,一个名为urbg的梅森旋转算法(Mersenne Twister)➌,以及一个map类型的string到size_t对象的集合➍,用于统计每个shuffle样本的频率。此外,您使用fixed和setprecision配置浮点数格式➎。
在for循环中,您将population复制到一个名为sample的新字符串中,因为shuffle会修改目标序列➏。然后,您调用shuffle,将result作为目标序列,urbg作为随机位生成器➐,并将结果记录在samples中➑。
最后,您迭代sample中的每个元素➒并打印每个样本的概率分布➓。
请注意,与sample不同,shuffle始终生成一个无序的元素分布。
排序及相关操作
排序操作是一个将序列重新排列为所需方式的算法。
每个排序算法都有两个版本:一个接受名为 比较操作符 的函数对象,另一个使用 operator<。比较操作符是一个函数对象,可以使用两个对象进行比较。它返回 true 如果第一个参数是 小于 第二个参数;否则返回 false。x < y 的排序解释是 x 排在 y 前面。本节中解释的所有算法都位于 <algorithm> 头文件中。
注意
注意,operator< 是一个有效的比较操作符。
比较操作符必须是传递的。这意味着对于任何元素 a、b 和 c,比较操作符 comp 必须保持以下关系:如果 comp(a, b) 和 comp(b, c),那么 comp(a, c)。这应该是合理的:如果 a 排在 b 前面,且 b 排在 c 前面,那么 a 必须排在 c 前面。
sort
sort 算法对序列进行排序(不稳定)。
注意
稳定排序会保留相等元素的相对顺序,而不稳定排序可能会重新排序它们。
算法就地对目标序列进行排序。
void sort([ep], rnd_begin, rnd_end, [comp]);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
RandomAccessIterator,rnd_begin和rnd_end,表示目标序列 -
一个可选的比较操作符,
comp
复杂度
准线性 O(N log N),其中 N = distance(rnd_begin, rnd_end)
附加要求
目标序列的元素必须是可交换的、可移动构造的和可移动赋值的。
示例
#include <algorithm>
TEST_CASE("sort") {
string goat_grass{ "spoilage" }; ➊
sort(goat_grass.begin(), goat_grass.end()); ➋
REQUIRE(goat_grass == "aegilops"); ➌
}
你首先构造一个包含单词 spoilage 的 string ➊。接着,你用这个 string 作为目标序列调用 sort ➋。结果是 goat_``grass 现在包含了单词 aegilops(一种侵入性杂草的属名) ➌。
stable_sort
stable_sort 算法对序列进行稳定排序。
算法就地对目标序列进行排序。相等元素保持其原始顺序。
void stable_sort([ep], rnd_begin, rnd_end, [comp]);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
RandomAccessIterator,rnd_begin和rnd_end,表示目标序列 -
一个可选的比较操作符,
comp
复杂度
多对数线性 O(N log² N),其中 N = distance(rnd_begin, rnd_end)。如果有额外内存可用,复杂度将减少到准线性。
附加要求
目标序列的元素必须是可交换的、可移动构造的和可移动赋值的。
示例
#include <algorithm>
enum class CharCategory { ➊
Ascender,
Normal,
Descender
};
CharCategory categorize(char x) { ➋
switch (x) {
case 'g':
case 'j':
case 'p':
case 'q':
case 'y':
return CharCategory::Descender;
case 'b':
case 'd':
case 'f':
case 'h':
case 'k':
case 'l':
case 't':
return CharCategory::Ascender;
}
return CharCategory::Normal;
}
bool ascension_compare(char x, char y) { ➌
return categorize(x) < categorize(y);
}
TEST_CASE("stable_sort") {
string word{ "outgrin" }; ➍
stable_sort(word.begin(), word.end(), ascension_compare); ➎
REQUIRE(word == "touring"); ➏
}
这个例子使用升部字母和降部字母对string进行排序。在排版学中,升部字母是指其一部分延伸到字体的平均线以上的字母。降部字母是指其一部分延伸到基线以下的字母。常见的降部字母有g、j、p、q和y。常见的升部字母有b、d、f、h、k、l和t。这个例子使用stable_sort,使得所有升部字母排在所有其他字母之前,所有降部字母排在所有其他字母之后。既不属于升部字母也不属于降部字母的字母则排在中间。作为一个stable_sort,具有相同升部/降部分类的字母的相对顺序不能发生变化。
你首先定义了一个enum class,名为CharCategory,它有三个可能的值:Ascender、Normal或Descender ➊。接下来,你定义了一个函数,用来将给定的字符分类到CharCategory中 ➋。(回想一下在第 50 页的“Switch 语句”部分,若不包含break,标签会“穿透”。)你还定义了一个ascension_compare函数,用于将两个给定的char对象转换为CharCategory对象,并通过operator<进行比较 ➌。由于enum class对象会隐式转换为int对象,并且你按预期的顺序定义了CharCategory,因此这将使得升部字母排在正常字母前面,正常字母排在降部字母前面。
在测试用例中,你初始化了一个包含单词outgrin的string ➍。接下来,你调用stable_sort,以该string作为目标序列,ascension_compare作为比较运算符 ➎。结果是,word现在包含了touring ➏。注意,t,唯一的升部字母,出现在所有正常字符之前(这些字符的顺序和outgrin中的顺序相同),而这些正常字符又出现在g之前,g是唯一的降部字母。
partial_sort
partial_sort算法将一个序列排序为两组。
如果是修改,算法会对目标序列中的前(rnd_middle – rnd_first)个元素进行排序,使得rnd_begin到rnd_middle中的所有元素都小于其余元素。如果是复制,算法会将前min(distance(ipt_begin, ipt_end), distance(rnd_begin, rnd_end))个已排序的元素放入目标序列,并返回一个指向目标序列末尾的迭代器。
基本上,部分排序允许你在不排序整个序列的情况下,找到排序序列中的前几个元素。例如,如果你有一个序列 D C B A,你可以对前两个元素进行部分排序,得到结果 A B D C。前两个元素和对整个序列进行排序的结果相同,但其余元素则没有进行排序。
void partial_sort([ep], rnd_begin, rnd_middle, rnd_end, [comp]);
RandomAccessIterator partial_sort_copy([ep], ipt_begin, ipt_end,
rnd_begin, rnd_end, [comp]);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
如果是修改,表示目标序列的三元组
rnd_begin、rnd_middle和rnd_end的RandomAccessIterator -
如果是复制,表示目标序列的
ipt_begin和ipt_end一对,以及表示目标序列的rnd_begin和rnd_end一对 -
一个可选的比较运算符,
comp
复杂度
准线性 O(N log N),其中 N = distance(rnd_begin, rnd_end) * log(distance(rnd_begin, rnd_middle) 或 distance(rnd_begin, rnd_end) * log(min(distance(rnd_begin, rnd_end), distance(ipt_begin, ipt_end)) 用于复制变体
附加要求
目标序列的元素必须是可交换的、可移动构造的,并且可移动赋值的。
示例
#include <algorithm>
bool ascension_compare(char x, char y) {
--snip--
}
TEST_CASE("partial_sort") {
string word1{ "nectarous" }; ➊
partial_sort(word1.begin(), word1.begin() + 4, word1.end()); ➋
REQUIRE(word1 == "acentrous"); ➌
string word2{ "pretanning" }; ➍
partial_sort(word2.begin(), word2.begin() + 3, ➎
word2.end(), ascension_compare);
REQUIRE(word2 == "trepanning"); ➏
}
首先,你初始化一个包含单词nectarous的string ➊。接着,你用这个string作为目标序列,和第五个字母(a)作为partial_sort的第二个参数调用partial_sort ➋。结果是,序列现在包含单词acentrous ➌。注意,acentrous的前四个字母已经排序,并且它们小于序列中的剩余字符。
在第二个示例中,你初始化一个包含单词pretanning的string ➍,并将其用作partial_sort的目标序列 ➎。在这个示例中,你指定第四个字符(t)作为partial_sort的第二个参数,并使用stable_sort示例中的ascension_compare函数作为比较运算符。结果是,序列现在包含单词trepanning ➏。注意,前面三个字母是按ascension_compare排序的,并且partial_sort的第二个参数中的剩余字符都不小于前三个字符。
注意
从技术上讲,前面的示例中的 REQUIRE 语句可能会在某些标准库实现中失败。因为std::partial_sort并不保证稳定性,结果可能会有所不同。
is_sorted
is_sorted算法用于判断序列是否已排序。
如果目标序列按照operator<或(如果给定)comp排序,则该算法返回true。is_sorted_until算法返回指向第一个未排序元素的迭代器,或者如果目标序列已排序,则返回rnd_end。
bool is_sorted([ep], rnd_begin, rnd_end, [comp]);
ForwardIterator is_sorted_until([ep], rnd_begin, rnd_end, [comp]);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
RandomAccessIterator,rnd_begin和rnd_end,表示目标序列 -
一个可选的比较运算符,
comp
复杂度
线性 该算法比较distance(rnd_begin, rnd_end)次。
示例
#include <algorithm>
bool ascension_compare(char x, char y) {
--snip--
}
TEST_CASE("is_sorted") {
string word1{ "billowy" }; ➊
REQUIRE(is_sorted(word1.begin(), word1.end())); ➋
string word2{ "floppy" }; ➌
REQUIRE(word2.end() == is_sorted_until(word2.begin(), ➍
word2.end(), ascension_compare));
}
首先,你构造一个包含单词billowy的string ➊。接着,你用这个string作为目标序列调用is_sort,它返回true ➋。
在第二个示例中,你构造一个包含单词floppy的string ➌。然后,你用这个string作为目标序列调用is_sorted_until,它返回rnd_end,因为该序列已排序 ➍。
nth_element
nth_element算法将序列中的特定元素放到其正确的排序位置。
这个部分排序算法以以下方式修改目标序列:rnd_nth指向的位置就像整个范围已排序一样。所有从rnd_begin到rnd_nth-1的位置的元素都小于rnd_nth。如果rnd_nth == rnd_end,则函数不执行任何操作。
bool nth_element([ep], rnd_begin, rnd_nth, rnd_end, [comp]);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一组三个
RandomAccessIterator,rnd_begin、rnd_nth和rnd_end,表示目标序列 -
一个可选的比较运算符,
comp
复杂度
线性 该算法比较distance(rnd_begin, rnd_end)次。
附加要求
目标序列的元素必须是可交换的、可移动构造的和可移动赋值的。
示例
#include <algorithm>
TEST_CASE("nth_element") {
vector<int> numbers{ 1, 9, 2, 8, 3, 7, 4, 6, 5 }; ➊
nth_element(numbers.begin(), numbers.begin() + 5, numbers.end()); ➋
auto less_than_6th_elem = [&elem=numbers[5]](int x) { ➌
return x < elem;
};
REQUIRE(all_of(numbers.begin(), numbers.begin() + 5, less_than_6th_elem)); ➍
REQUIRE(numbers[5] == 6 ); ➎
}
你首先构造一个包含数字序列 1 到 10 的int对象的vector ➊。然后,你使用这个vector作为目标序列,调用nth_element ➋。接着,你初始化一个名为less_than_6th_elem的 lambda,它使用operator<比较一个int与numbers中的第六个元素 ➌。这使得你可以检查所有第六个元素之前的元素是否都小于第六个元素 ➍。第六个元素是 6 ➎。
二分查找
二分查找算法假设目标序列已经排序。与在未指定序列上进行通用查找相比,这些算法具有理想的复杂度特性。本节中解释的每个算法都位于<algorithm>头文件中。
lower_bound
lower_bound算法在已排序的序列中找到一个分区。
该算法返回一个迭代器,指向元素result,它将序列划分,使得result之前的元素都小于value,而result及其后的所有元素不小于value。
ForwardIterator lower_bound(fwd_begin, fwd_end, value, [comp]);
参数
-
一对
ForwardIterator,fwd_begin和fwd_end,表示目标序列 -
一个用于划分目标序列的
value -
一个可选的比较运算符,
comp
复杂度
对数 如果提供了一个随机迭代器,复杂度为O(log N),其中N = distance(fwd_begin, fwd_end);否则,复杂度为O(N)
附加要求
目标序列必须根据operator<或提供的comp进行排序。
示例
#include <algorithm>
TEST_CASE("lower_bound") {
vector<int> numbers{ 2, 4, 5, 6, 6, 9 }; ➊
const auto result = lower_bound(numbers.begin(), numbers.end(), 5); ➋
REQUIRE(result == numbers.begin() + 2); ➌
}
你首先构造一个int对象的vector ➊。然后,你使用这个vector作为目标序列,并提供value为5,调用lower_bound ➋。结果是第三个元素,5 ➌。元素2和4小于5,而元素5、6、6和9不小于5。
upper_bound
upper_bound算法在已排序的序列中找到一个分区。
该算法返回一个迭代器,指向元素result,它是目标序列中大于value的第一个元素。
ForwardIterator upper_bound(fwd_begin, fwd_end, value, [comp]);
参数
-
一对
ForwardIterator,fwd_begin和fwd_end,表示目标序列 -
用于划分目标序列的
value -
一个可选的比较运算符,
comp
复杂度
对数级 如果提供一个随机迭代器,O(log N),其中N = distance (fwd_begin, fwd_end);否则,O(N)
附加要求
目标序列必须按照operator<或提供的comp进行排序。
示例
#include <algorithm>
TEST_CASE("upper_bound") {
vector<int> numbers{ 2, 4, 5, 6, 6, 9 }; ➊
const auto result = upper_bound(numbers.begin(), numbers.end(), 5); ➋
REQUIRE(result == numbers.begin() + 3); ➌
}
首先构造一个int类型的vector对象 ➊。接着,调用upper_bound,将这个vector作为目标序列,value为5 ➋。结果是第四个元素6,它是目标序列中大于value的第一个元素 ➌。
equal_range
equal_range算法在排序序列中查找一系列特定的元素。
算法返回一个std::pair的迭代器,表示等于value的半开区间。
ForwardIteratorPair equal_range(fwd_begin, fwd_end, value, [comp]);
参数
-
一对
ForwardIterator,fwd_begin和fwd_end,表示目标序列 -
要查找的
value -
一个可选的比较运算符,
comp
复杂度
对数级 如果提供一个随机迭代器,O(log N),其中N = distance (fwd_begin, fwd_end);否则,O(N)
附加要求
目标序列必须按照operator<或提供的comp进行排序。
示例
#include <algorithm>
TEST_CASE("equal_range") {
vector<int> numbers{ 2, 4, 5, 6, 6, 9 }; ➊
const auto[rbeg, rend] = equal_range(numbers.begin(), numbers.end(), 6); ➋
REQUIRE(rbeg == numbers.begin() + 3); ➌
REQUIRE(rend == numbers.begin() + 5); ➍
}
首先构造一个int类型的vector对象 ➊。接着,调用equal_range,将这个vector作为目标序列,value为6 ➋。结果是一个表示匹配范围的迭代器对。第一个迭代器指向第四个元素 ➌,第二个迭代器指向第六个元素 ➍。
binary_search
binary_search算法在排序序列中查找特定元素。
如果范围包含value,算法返回true。具体来说,如果目标序列包含元素x,使得x < value和value < x都不成立,则返回true。如果提供了comp,则当目标序列包含元素x,且comp(x, value)和comp(value, x)都不成立时,返回true。
bool binary_search(fwd_begin, fwd_end, value, [comp]);
参数
-
一对
ForwardIterator,fwd_begin和fwd_end,表示目标序列 -
要查找的
value -
一个可选的比较运算符,
comp
复杂度
对数级 如果提供一个随机迭代器,O(log N),其中N = distance (fwd_begin, fwd_end);否则,O(N)
附加要求
目标序列必须按照operator<或提供的comp进行排序。
示例
#include <algorithm>
TEST_CASE("binary_search") {
vector<int> numbers{ 2, 4, 5, 6, 6, 9 }; ➊
REQUIRE(binary_search(numbers.begin(), numbers.end(), 6)); ➋
REQUIRE_FALSE(binary_search(numbers.begin(), numbers.end(), 7)); ➌
}
首先构造一个int类型的vector对象 ➊。接着,调用binary_search,将这个vector作为目标序列,值为6。由于序列中包含 6,binary_search返回true ➋。当你调用binary_search并传入7时,它返回false,因为目标序列中不包含7 ➌。
划分算法
一个分区序列包含两个连续的、不同的元素组。这些组不会混合,第二个不同组的第一个元素称为分区点。标准库包含用于分区序列、确定序列是否已分区以及查找分区点的算法。本节中解释的每个算法都在<algorithm>头文件中。
is_partitioned
is_partitioned算法用于确定一个序列是否已分区。
注意
如果所有具有某些属性的元素都出现在没有这些属性的元素之前,则序列被认为是分区的。
如果目标序列中所有对pred评估为true的元素都出现在其他元素之前,则算法返回true。
bool is_partitioned([ep], ipt_begin, ipt_end, pred);
参数
-
一个可选的
std::execution执行策略,ep(默认:std::execution::seq) -
一对
InputIterator对象,ipt_begin和ipt_end,表示目标序列 -
一个谓词,
pred,用于确定组成员资格
复杂度
线性 最多需要对pred进行distance(ipt_begin, ipt_end)次评估
示例
#include <algorithm>
TEST_CASE("is_partitioned") {
auto is_odd = [](auto x) { return x % 2 == 1; }; ➊
vector<int> numbers1{ 9, 5, 9, 6, 4, 2 }; ➋
REQUIRE(is_partitioned(numbers1.begin(), numbers1.end(), is_odd)); ➌
vector<int> numbers2{ 9, 4, 9, 6, 4, 2 }; ➍
REQUIRE_FALSE(is_partitioned(numbers2.begin(), numbers2.end(), is_odd)); ➎
}
你首先构造一个名为is_odd的 lambda,如果给定的数字是奇数,则返回true ➊。接着,你构造一个int对象的vector ➋,并使用这个vector作为目标序列,is_odd作为谓词调用is_partitioned。因为序列中的所有奇数都排在偶数前面,所以is_partitioned返回true ➌。
然后,你构造另一个int对象的vector ➍,并再次使用这个vector作为目标序列,is_odd作为谓词调用is_partitioned。因为该序列并没有把所有的奇数放在偶数前面(4 是偶数,且排在第二个 9 之前),所以is_partitioned返回false ➎。
partition
partition算法用于对序列进行分区。
该算法会修改目标序列,使其根据pred进行分区。它返回分区点。元素的原始顺序不一定会被保留。
ForwardIterator partition([ep], fwd_begin, fwd_end, pred);
参数
-
一个可选的
std::execution执行策略,ep(默认:std::execution::seq) -
一对
ForwardIterator,fwd_begin和fwd_end,表示目标序列 -
一个谓词,
pred,用于确定组成员资格
复杂度
线性 最多需要对pred进行distance(fwd_begin, fwd_end)次评估
附加要求
目标序列的元素必须是可交换的。
示例
#include <algorithm>
TEST_CASE("partition") {
auto is_odd = [](auto x) { return x % 2 == 1; }; ➊
vector<int> numbers{ 1, 2, 3, 4, 5 }; ➋
const auto partition_point = partition(numbers.begin(),
numbers.end(), is_odd); ➌
REQUIRE(is_partitioned(numbers.begin(), numbers.end(), is_odd)); ➍
REQUIRE(partition_point == numbers.begin() + 3); ➎
}
你首先构造一个名为is_odd的 lambda,如果给定的数字是odd(奇数)则返回true ➊。接着,你构造一个int对象的vector ➋,并使用这个vector作为目标序列,is_odd作为谓词调用partition。你将结果分区点赋值给partition_point ➌。
当你在目标序列上调用is_partitioned,并以is_odd作为谓词时,它返回true ➍。根据算法的规范,你不能依赖于组内的顺序,但是partition_point将始终是第四个元素,因为目标序列包含三个奇数 ➎。
partition_copy
partition_copy算法对一个序列进行分区。
该算法通过在每个元素上评估pred来对目标序列进行分区。所有true元素复制到opt_true中,所有false元素复制到opt_false中。
ForwardIteratorPair partition_copy([ep], ipt_begin, ipt_end,
opt_true, opt_false, pred);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
InputIterator对象,ipt_begin和ipt_end,表示目标序列 -
一个
OutputIterator,opt_true,用于接收true元素的副本 -
一个
OutputIterator,opt_false,用于接收false元素的副本 -
一个谓词,
pred,用于确定组成员资格
复杂度
线性 精确地进行distance(ipt_begin, ipt_end)次pred评估
附加要求
-
目标序列的元素必须是可复制赋值的。
-
输入和输出范围不能重叠。
示例
#include <algorithm>
TEST_CASE("partition_copy") {
auto is_odd = [](auto x) { return x % 2 == 1; }; ➊
vector<int> numbers{ 1, 2, 3, 4, 5 }, odds, evens; ➋
partition_copy(numbers.begin(), numbers.end(),
back_inserter(odds), back_inserter(evens), is_odd); ➌
REQUIRE(all_of(odds.begin(), odds.end(), is_odd)); ➍
REQUIRE(none_of(evens.begin(), evens.end(), is_odd)); ➎
}
首先构造一个名为is_odd的 lambda,如果给定的数字是odd(奇数),则返回true ➊。接下来,构造一个包含从 1 到 5 的int对象的vector,以及两个空的vector对象,分别名为odds和evens ➋。然后,使用partition_copy,将numbers作为目标序列,一个back_inserter插入到odds作为true元素的输出,一个back_inserter插入到evens作为false元素的输出,is_odd作为谓词 ➌。结果是,所有odds中的元素都是奇数 ➍,而evens中的元素没有奇数 ➎。
stable_partition
stable_partition算法稳定地对序列进行分区。
注意
稳定分区可能比不稳定分区需要更多的计算,因此用户可以选择。
该算法会改变目标序列,使其根据pred进行分区,并返回分区点。元素的原始顺序将被保留。
BidirectionalIterator stable_partition([ep], bid_begin, bid_end, pred);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
BidirectionalIterator,bid_begin和bid_end,表示目标序列 -
一个谓词,
pred,用于确定组成员资格
复杂度
准线性 O(N log N)次交换,其中N = distance(bid_begin, bid_end),或者如果有足够的内存,O(N)次交换。
附加要求
目标序列的元素必须是可交换的、可移动构造的,并且可以进行移动赋值。
示例
#include <algorithm>
TEST_CASE("stable_partition") {
auto is_odd = [](auto x) { return x % 2 == 1; }; ➊
vector<int> numbers{ 1, 2, 3, 4, 5 }; ➋
stable_partition(numbers.begin(), numbers.end(), is_odd); ➌
REQUIRE(numbers == vector<int>{ 1, 3, 5, 2, 4 }); ➍
}
首先,你构造一个名为is_odd的 lambda,它返回true,如果给定的数字是odd ➊。接下来,你构造一个int类型的vector对象 ➋,并使用stable_partition,以这个vector作为目标序列,is_odd作为谓词 ➌。结果是vector包含元素 1、3、5、2、4,因为这是唯一能够在保持原始组内顺序的情况下划分这些数字的方法 ➍。
合并算法
合并算法将两个已排序的目标序列合并,使得结果序列包含两个目标序列的副本,并且也是排序的。本节中解释的每个算法都位于<algorithm>头文件中。
合并
merge算法合并两个已排序的序列。
该算法将两个目标序列复制到目标序列中。如果提供了operator<或comp,目标序列将根据这些进行排序。
OutputIterator merge([ep], ipt_begin1, ipt_end1,
ipt_begin2, ipt_end2, opt_result, [comp]);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
两对
InputIterator,ipt_begin和ipt_end,表示目标序列 -
一个
OutputIterator,opt_result,表示目标序列 -
一个谓词,
pred,用于确定组成员资格
复杂度
线性 最多进行N-1次比较,其中N = distance(ipt_begin1, ipt_end1) + distance(ipt_begin2, ipt_end2)
附加要求
如果提供了operator<或comp,则目标序列必须根据这些进行排序。
示例
#include <algorithm>
TEST_CASE("merge") {
vector<int> numbers1{ 1, 4, 5 }, numbers2{ 2, 3, 3, 6 }, result; ➊
merge(numbers1.begin(), numbers1.end(),
numbers2.begin(), numbers2.end(),
back_inserter(result)); ➋
REQUIRE(result == vector<int>{ 1, 2, 3, 3, 4, 5, 6 }); ➌
}
你构造三个vector对象:两个包含已排序的int对象,另一个为空➊。接下来,你将非空的vector与空的vector合并,并使用空的vector作为目标序列,利用back_inserter ➋。result包含了原始序列中所有元素的副本,并且它本身也已排序 ➌。
极值算法
一些被称为极值算法的算法,用于确定最小值和最大值元素,或者限制元素的最小值或最大值。本节中解释的每个算法都位于<algorithm>头文件中。
最小值和最大值
min或max算法用于确定序列的极值。
这些算法使用operator<或comp,并返回最小值(min)或最大值(max)对象。minmax算法同时返回这两个值,作为一个std::pair,其中first为最小值,second为最大值。
T min(obj1, obj2, [comp]);
T min(init_list, [comp]);
T max(obj1, obj2, [comp]);
T max(init_list, [comp]);
Pair minmax(obj1, obj2, [comp]);
Pair minmax(init_list, [comp]);
参数
-
两个对象,
obj1和obj2,或者 -
一个初始化列表,
init_list,表示要比较的对象 -
一个可选的比较函数,
comp
复杂度
常数或线性 对于需要obj1和obj2的重载,恰好有一个比较。对于初始化列表,最多进行N-1次比较,其中N是初始化列表的长度。对于minmax,给定初始化列表,比较次数将增长到3/2 N。
附加要求
元素必须是可复制构造的,并且可以使用给定的比较方法进行比较。
示例
#include <algorithm>
TEST_CASE("max and min") {
auto length_compare = [](const auto& x1, const auto& x2) { ➊
return x1.length() < x2.length();
};
string undisc="undiscriminativeness", vermin="vermin";
REQUIRE(min(undisc, vermin, length_compare) == "vermin"); ➋
string maxim="maxim", ultra="ultramaximal";
REQUIRE(max(maxim, ultra, length_compare) == "ultramaximal"); ➌
string mini="minimaxes", maxi="maximin";
const auto result = minmax(mini, maxi, length_compare); ➍
REQUIRE(result.first == maxi); ➎
REQUIRE(result.second == mini); ➏
}
你首先初始化一个名为length_compare的 lambda,它使用operator<来比较两个输入的长度 ➊。接着,你使用min来确定undiscriminativeness和vermin哪个长度较小 ➋,并使用max来确定maxim和ultramaximal哪个长度较大 ➌。最后,你使用minmax来确定minimaxes和maximin哪个具有最小和最大长度 ➍。结果是一个对 ➎➏。
min_element 和 max_element
min_element或max_element算法确定一个序列的极值。
这些算法使用operator<或comp,并返回指向最小值(min_element)或最大值(max_element)的迭代器。minimax_element算法同时返回最小值和最大值,作为一个std::pair,first表示最小值,second表示最大值。
ForwardIterator min_element([ep], fwd_begin, fwd_end, [comp]);
ForwardIterator max_element([ep], fwd_begin, fwd_end, [comp]);
Pair minmax_element([ep], fwd_begin, fwd_end, [comp]);
参数
-
一个可选的
std::execution执行策略,ep(默认值:std::execution::seq) -
一对
ForwardIterator,fwd_begin和fwd_end,表示目标序列 -
一个可选的比较函数,
comp
复杂度
线性 对于max和min,最多进行N-1次比较,其中N=distance(fwd_begin, fwd_end);对于minmax,则为3/2 N
附加要求
元素必须能够使用给定的操作进行比较。
示例
#include <algorithm>
TEST_CASE("min and max element") {
auto length_compare = [](const auto& x1, const auto& x2) { ➊
return x1.length() < x2.length();
};
vector<string> words{ "civic", "deed", "kayak", "malayalam" }; ➋
REQUIRE(*min_element(words.begin(), words.end(),
length_compare) == "deed"); ➌
REQUIRE(*max_element(words.begin(), words.end(),
length_compare) == "malayalam"); ➍
const auto result = minmax_element(words.begin(), words.end(),
length_compare); ➎
REQUIRE(*result.first == "deed"); ➏
REQUIRE(*result.second == "malayalam"); ➐
}
你首先初始化一个名为length_compare的 lambda,它使用operator<来比较两个输入的长度 ➊。接着,你初始化一个包含四个单词的string对象vector,名为words ➋。你使用min_element来确定这些单词中最小的那个,通过将它作为目标序列,并将length_compare作为比较函数(deed) ➌,然后使用max_element来确定最大的单词(malayalam) ➍。最后,你使用minmax_element,它返回最小值和最大值,作为一个std::pair ➎。first元素表示最短的word ➏,second元素表示最长的word ➐。
clamp
clamp算法对值进行约束。
该算法使用operator<或comp来判断obj是否在low到high的范围内。如果在范围内,算法直接返回obj;否则,如果obj小于low,则返回low;如果obj大于high,则返回high。
T& clamp(obj, low, high, [comp]);
参数
-
一个对象,
obj -
一个
low和high对象 -
一个可选的比较函数,
comp
复杂度
常数 最多进行两次比较
附加要求
这些对象必须能够使用给定的操作进行比较。
示例
#include <algorithm>
TEST_CASE("clamp") {
REQUIRE(clamp(9000, 0, 100) == 100); ➊
REQUIRE(clamp(-123, 0, 100) == 0); ➋
REQUIRE(clamp(3.14, 0., 100.) == Approx(3.14)); ➌
}
在第一个示例中,你将9000限制在从 0 到 100 的区间内。因为 9000 > 100,所以结果是100 ➊。在第二个示例中,你将-123限制在同一区间内。因为−123 < 0,所以结果是0 ➋。最后,你将3.14限制在区间内,由于它在区间内,因此结果是3.14 ➌。
数值操作
<numeric> 头文件在 第十二章 中讨论过,你在那时学习了它的数学类型和函数。它还提供了非常适合数值操作的算法。本节介绍了其中的许多算法。本节中解释的每个算法都在 <numeric> 头文件中。
常用操作符
一些标准库的数值操作允许你传递操作符以自定义行为。为方便起见,<functional> 头文件提供了以下类模板,通过 operator(T x, T y) 暴露各种二元算术操作:
-
plus<T>实现加法x + y。 -
minus<T>实现减法x - y。 -
multiplies<T>实现乘法x * y。 -
divides<T>实现除法x / y。 -
modulus<T>实现模运算x % y。
例如,你可以使用 plus 模板来加两个数字,像这样:
#include <functional>
TEST_CASE("plus") {
plus<short> adder; ➊
REQUIRE(3 == adder(1, 2)); ➋
REQUIRE(3 == plus<short>{}(1,2)); ➌
}
首先,你实例化一个名为 adder 的 plus ➊,然后用 1 和 2 调用它,结果是 3 ➋。你也可以完全省略变量,直接使用新构造的 plus 来实现相同的结果 ➌。
注意
通常,除非你正在使用需要这些操作符类型的泛型代码,否则不会使用它们。
iota
iota 算法将序列填充为递增的值。
算法从 start 开始,依次将递增值赋给目标序列。
void iota(fwd_begin, fwd_end, start);
参数
-
一对迭代器
fwd_begin和fwd_end,表示目标序列 -
一个
start值
复杂度
线性 N 次增量和赋值,其中 N=distance(fwd_begin, fwd_end)
附加要求
对象必须能够赋值给 start。
示例
#include <numeric>
#include <array>
TEST_CASE("iota") {
array<int, 3> easy_as; ➊
iota(easy_as.begin(), easy_as.end(), 1); ➋
REQUIRE(easy_as == array<int, 3>{ 1, 2, 3 }); ➌
}
首先,你初始化一个长度为 3 的 int 对象数组 ➊。接着,你调用 iota,将 array 作为目标序列,1 作为 start 值 ➋。结果是 array 包含元素 1、2 和 3 ➌。
累加
accumulate 算法按顺序折叠一个序列。
注意
折叠一个序列意味着对序列的元素应用特定操作,同时将累积结果传递给下一个操作。
该算法将 op 应用于 start 和目标序列的第一个元素。然后它将结果与目标序列的下一个元素再次应用 op,以此类推,直到遍历目标序列中的每个元素。大致来说,这个算法将目标序列的元素和 start 值相加,并返回结果。
T accumulate(ipt_begin, ipt_end, start, [op]);
参数
-
一对迭代器
ipt_begin和ipt_end,表示目标序列 -
一个
start值 -
一个可选的二元操作符
op,默认为plus
复杂度
线性 N 次应用 op,其中 N=distance(ipt_begin, ipt_end)
附加要求
目标序列的元素必须是可复制的。
示例
#include <numeric>
TEST_CASE("accumulate") {
vector<int> nums{ 1, 2, 3 }; ➊
const auto result1 = accumulate(nums.begin(), nums.end(), -1); ➋
REQUIRE(result1 == 5); ➌
const auto result2 = accumulate(nums.begin(), nums.end(),
2, multiplies<>()); ➍
REQUIRE(result2 == 12); ➎
}
你首先初始化一个长度为3的vector类型的int对象 ➊。接着,你使用vector作为目标序列,并将-1作为start值调用accumulate ➋。结果是 −1 + 1 + 2 + 3 = 5 ➌。
在第二个示例中,你使用相同的目标序列,但start值为2,操作符改为multiplies。结果是 2 * 1 * 2 * 3 = 12 ➎。
reduce
reduce算法对一个序列进行折叠(不一定按顺序)。
该算法与accumulate相同,只是它接受一个可选的execution并且不保证操作符应用的顺序。
T reduce([ep], ipt_begin, ipt_end, start, [op]);
参数
-
一个可选的
std::execution执行策略,ep(默认为std::execution::seq) -
一对迭代器,
ipt_begin和ipt_end,表示目标序列 -
一个
start值 -
一个可选的二元操作符,
op,默认为plus
复杂度
线性 N 次op应用,其中N=distance(ipt_begin, ipt_end)
附加要求
-
如果省略了
ep,元素必须是可移动的。 -
如果提供了
ep,元素必须是可复制的。
示例
#include <numeric>
TEST_CASE("reduce") {
vector<int> nums{ 1, 2, 3 }; ➊
const auto result1 = reduce(nums.begin(), nums.end(), -1); ➋
REQUIRE(result1 == 5); ➌
const auto result2 = reduce(nums.begin(), nums.end(),
2, multiplies<>()); ➍
REQUIRE(result2 == 12); ➎
}
你首先初始化一个长度为3的vector类型的int对象 ➊。接着,你使用vector作为目标序列,并将-1作为start值调用reduce ➋。结果是 −1 + 1 + 2 + 3 = 5 ➌。
在第二个示例中,你使用相同的目标序列,但start值为2,操作符改为multiplies。结果是 2 * 1 * 2 * 3 = 12 ➎。
inner_product
inner_product算法计算两个序列的内积。
注意
内积(或点积)是与一对序列相关的标量值。
该算法将op2应用于目标序列中每一对对应元素,并使用op1将它们与start相加。
T inner_product([ep], ipt_begin1, ipt_end1, ipt_begin2, start, [op1], [op2]);
参数
-
一对迭代器,
ipt_begin1和ipt_end1,表示目标序列 1 -
一个迭代器,
ipt_begin2,表示目标序列 2 -
一个
start值 -
两个可选的二元操作符,
op1和op2,默认为plus和multiply
复杂度
线性 N 次op1和op2应用,其中N=distance(ipt_begin1, ipt_end1)
附加要求
元素必须是可复制的。
示例
#include <numeric>
TEST_CASE("inner_product") {
vector<int> nums1{ 1, 2, 3, 4, 5 }; ➊
vector<int> nums2{ 1, 0,-1, 0, 1 }; ➋
const auto result = inner_product(nums1.begin(), nums1.end(),
nums2.begin(), 10); ➌
REQUIRE(result == 13); ➍
}
你首先初始化两个vector类型的int对象 ➊ ➋。接着,你使用这两个vector对象作为目标序列,并将10作为start值调用inner_product ➌。结果是 10 + 1 * 1 + 2 * 0 + 3 * 1 + 4 * 0 + 4 * 1 = 13 ➍。
adjacent_difference
adjacent_difference算法生成相邻元素的差值。
注意
相邻差值是对每一对邻近元素应用某个操作的结果。
该算法将目标序列的第一个元素设置为目的序列的第一个元素。对于每个后续元素,它将op应用于前一个元素和当前元素,并将返回值写入result。该算法返回目的序列的结尾。
OutputIterator adjacent_difference([ep], ipt_begin, ipt_end, result, [op]);
参数
-
一对迭代器,
ipt_begin和ipt_end,表示目标序列。 -
一个迭代器,
result,表示目标序列。 -
一个可选的二元操作符,
op,默认为minus。
复杂度
线性 N-1 次 op 应用,其中 N=distance(ipt_begin, ipt_end)
附加要求
-
如果省略
ep,元素必须是可移动的。 -
如果你提供了
ep,元素必须是可复制的。
示例
#include <numeric>
TEST_CASE("adjacent_difference") {
vector<int> fib{ 1, 1, 2, 3, 5, 8 }, fib_diff; ➊
adjacent_difference(fib.begin(), fib.end(), back_inserter(fib_diff)); ➋
REQUIRE(fib_diff == vector<int>{ 1, 0, 1, 1, 2, 3 }); ➌
}
你首先初始化一个 int 类型的 vector 对象,一个包含斐波那契数列的前六个数字,另一个为空 ➊。接下来,你调用 adjacent_difference,将两个 vector 对象作为目标序列 ➋。结果如预期所示:第一个元素等于斐波那契数列的第一个元素,后续元素是相邻差(1 – 1 = 0),(2 – 1 = 1),(3 – 2 = 1),(5 – 3 = 2),(8 – 5 = 3) ➌。
partial_sum
partial_sum 算法生成部分和。
该算法将累加器设置为目标序列的第一个元素。对于目标序列中的每个后续元素,算法将该元素添加到累加器中,然后将累加器写入目标序列。该算法返回目标序列的末尾。
OutputIterator partial_sum(ipt_begin, ipt_end, result, [op]);
参数
-
一对迭代器,
ipt_begin和ipt_end,表示目标序列。 -
一个迭代器,
result,表示目标序列。 -
一个可选的二元操作符,
op,默认为plus。
复杂度
线性 N-1 次 op 应用,其中 N=distance(ipt_begin, ipt_end)
示例
#include <numeric>
TEST_CASE("partial_sum") {
vector<int> num{ 1, 2, 3, 4 }, result; ➊
partial_sum(num.begin(), num.end(), back_inserter(result)); ➋
REQUIRE(result == vector<int>{ 1, 3, 6, 10 }); ➌
}
你首先初始化两个 int 类型的 vector 对象,一个名为 num 包含前四个计数值,另一个名为 result 是空的 ➊。接下来,你调用 partial_sum,以 num 作为目标序列,result 作为目的地 ➋。第一个元素等于目标序列的第一个元素,后续元素是部分和(1 + 2 = 3),(3 + 3 = 6),(6 + 4 = 10) ➌。
其他算法
为了防止一章内容过长,许多算法被省略。本节对它们进行了概述。
(最大)堆操作
长度为 N 的范围是最大堆,如果对于所有 0 < i < N,! Image 处的元素(向下取整)不会小于 i 处的元素。这些结构在需要快速查找最大元素和插入元素的情况下具有较强的性能特点。
<algorithm> 头文件包含了许多有助于处理此类范围的函数,例如 表 18-1 中的那些。详情请参见 [alg.heap.operations]。
表 18-1: <algorithm> 头文件中的堆相关算法
| 算法 | 描述 |
|---|---|
is_heap |
检查一个范围是否是最大堆 |
is_heap_until |
查找最大堆的最大子范围 |
make_heap |
创建一个最大堆 |
push_heap |
添加一个元素 |
pop_heap |
移除最大元素 |
sort_heap |
将最大堆转换为已排序范围 |
对已排序范围的集合操作
<algorithm> 头文件包含对已排序范围进行集合操作的函数,如表 18-2 所示。详情请参见[alg.set.operations]。
表 18-2: <algorithm> 头文件中的集合相关算法
| 算法 | 描述 |
|---|---|
includes |
如果一个范围是另一个范围的子集,则返回true |
set_difference |
计算两个集合的差集 |
set_intersection |
计算两个集合的交集 |
set_symmetric_difference |
计算两个集合的对称差集 |
set_union |
计算两个集合的并集 |
其他数值算法
<numeric> 头文件包含了除“数值运算”部分介绍的函数之外的多个其他函数。表 18-3 列出了它们。详情请参见[numeric.ops]。
表 18-3: <numeric> 头文件中的附加数值算法
| 算法 | 描述 |
|---|---|
exclusive_scan |
类似于partial_sum,但将第i个元素排除在第i个和之外 |
inclusive_scan |
类似于partial_sum,但不按顺序执行,并且需要关联操作 |
transform_reduce |
应用一个函数对象;然后进行不按顺序的归约 |
transform_exclusive_scan |
应用一个函数对象;然后计算排他性扫描 |
transform_inclusive_scan |
应用一个函数对象;然后计算包含性扫描 |
内存操作
<memory> 头文件包含了多个低级别的函数,用于处理未初始化的内存。表 18-4 列出了它们。详情请参见[memory.syn]。
表 18-4: <memory> 头文件中用于未初始化内存的操作
| 算法 | 描述 |
|---|---|
uninitialized_copy``uninitialized_copy_n``uninitialized_fill``uninitialized_fill_n |
将对象复制到未初始化的内存中 |
uninitialized_move``uninitialized_move_n |
将对象移动到未初始化的内存中 |
uninitialized_default_construct``uninitialized_default_construct_n``uninitialized_value_construct``uninitialized_value_construct_n |
在未初始化的内存中构造对象 |
destroy_at``destroy``destroy_n |
销毁对象 |
Boost Algorithm
Boost Algorithm 是一个庞大的算法库,部分与标准库重叠。由于篇幅限制,表 18-5 仅列出了标准库中未包含的算法的快速参考。有关更多信息,请参阅 Boost Algorithm 文档。
表 18-5: Boost Algorithm 中的附加算法
| 算法 | 描述 |
|---|---|
boyer_moore``boyer_moore_horspool``knuth_morris_pratt |
用于搜索值序列的快速算法 |
hex``unhex |
写入/读取十六进制字符 |
gather |
接受一个序列并将满足谓词的元素移动到给定位置 |
find_not |
查找序列中第一个不等于某个值的元素 |
find_backward |
类似于 find,但从后向前查找 |
is_partitioned_until |
返回从目标序列的第一个元素开始的最大分区子序列的结束迭代器 |
apply_permutation``apply_reverse_permutation |
接受一个项目序列和一个顺序序列,并根据顺序序列重新排列项目序列 |
is_palindrome |
如果序列是回文,则返回 true |
关于范围的说明
第八章介绍了作为基于范围的 for 循环的一部分的范围表达式。回顾这一讨论,范围是一个概念,它公开 begin 和 end 方法来返回迭代器。由于你可以对迭代器施加要求以支持某些操作,因此你可以对范围施加传递性要求,使其提供某些迭代器。每个算法都有特定的操作要求,这些要求反映在它们所需的迭代器类型中。由于你可以用范围来封装算法输入序列的要求,因此你必须理解各种范围类型,以理解每个算法的约束。
和概念一样,范围尚未正式成为 C++ 的一部分。尽管理解范围、迭代器和算法之间的关系仍然会带来巨大的好处,但也有两个缺点。首先,算法仍然需要迭代器作为输入参数,因此即使有了范围,你仍然需要手动提取迭代器(例如,使用 begin 和 end)。其次,像其他函数模板一样,当你违反算法的操作要求时,可能会得到极其糟糕的错误信息。
正在进行将范围正式引入语言的工作。事实上,概念和范围很可能会同时进入 C++ 标准,因为它们的结合非常自然。
如果你想尝试实现一个可能的范围操作,请参考 Boost Range。
进一步阅读
-
ISO 国际标准 ISO/IEC (2017) — 编程语言 C++(国际标准化组织;瑞士日内瓦;
isocpp.org/std/the-standard/) -
《C++标准库:教程与参考》,第 2 版,尼科莱·约苏蒂斯著(Addison-Wesley Professional, 2012)
-
维克托·亚当奇克的《算法复杂性》(https://www.cs.cmu.edu/~adamchik/15-121/lectures/Algorithmic%20Complexity/complexity.html)
-
《Boost C++库》,第 2 版,博里斯·谢林著(XML Press, 2014)
第二十二章:并发与并行
高级监视员有她自己的格言:“给我看一个完全平稳的操作,我会告诉你那是某人掩盖错误的结果。真正的船只会摇摆。”
— 弗兰克·赫伯特,《沙丘圣殿》

在编程中,并发意味着在给定时间段内运行两个或更多任务。并行意味着两个或更多任务在同一时刻运行。这两个术语常常可以互换使用而不会产生负面后果,因为它们关系密切。本章介绍了这两个概念的基础知识。由于并发和并行编程是庞大而复杂的主题,全面的探讨需要一本完整的书籍。在本章末尾的“进一步阅读”部分,您可以找到相关书籍。
在本章中,您将学习如何使用 future 进行并发和并行编程。接下来,您将学习如何通过互斥量、条件变量和原子操作来安全地共享数据。然后,本章将演示如何利用执行策略加速代码,同时也可能带来潜在的风险。
并发编程
并发程序拥有多个执行线程(简称线程),这些线程是指令的序列。在大多数运行时环境中,操作系统充当调度程序,决定何时执行线程的下一条指令。每个进程可以有一个或多个线程,这些线程通常共享资源,例如内存。由于调度程序决定线程执行的时机,程序员通常无法依赖线程的执行顺序。作为交换,程序可以在同一时间段内(或者同时)执行多个任务,这通常会导致显著的加速。要观察从串行到并发版本的加速,系统需要具有并发硬件,例如多核处理器。
本节从异步任务开始,这是使程序并发的高级方法。接下来,您将学习一些基本的方法来协调这些任务,特别是在它们处理共享可变状态时。然后,您将了解一些低级功能,这些功能可用于在高层工具无法满足性能需求的独特情况下使用。
异步任务
引入并发到程序中的一种方式是创建异步任务。异步任务不需要立即获得结果。要启动异步任务,可以使用std::async函数模板,该模板位于<future>头文件中。
async
当你调用 std::async 时,第一个参数是启动策略 std::launch,它有两个值可选:std::launch::async 或 std::launch::deferred。如果你传递 launch::async,运行时会创建一个新线程来启动任务。如果传递 deferred,运行时会等到你需要任务结果时才会执行(有时这种模式被称为 延迟求值)。这个第一个参数是可选的,默认为 async|deferred,意味着具体使用哪种策略由实现决定。std::async 的第二个参数是一个函数对象,表示你想执行的任务。函数对象接受的参数数量和类型没有限制,且它可以返回任何类型。std::async 函数是一个可变参数模板,包含一个函数参数包。你传递的任何额外参数都会在异步任务启动时用于调用函数对象。此外,std::async 会返回一个名为 std::future 的对象。
以下是简化的 async 声明,帮助总结:
std::future<FuncReturnType> std::async([policy], func, Args&&... args);
现在你知道如何调用 async,让我们来看一下如何与其返回值进行交互。
回到未来
future 是一个类模板,用于保存异步任务的结果值。它有一个模板参数,对应异步任务的返回值类型。例如,如果你传递一个返回 string 的函数对象,async 会返回一个 future<string>。给定一个 future,你可以通过三种方式与异步任务进行交互。
首先,你可以通过 valid 方法查询 future 是否有效。一个有效的 future 会关联一个共享状态。异步任务有共享状态,以便它们可以传递结果。任何由 async 返回的 future 在你获取异步任务的返回值之前都会是有效的,之后共享状态的生命周期结束,如 示例 19-1 所示。
#include <future>
#include <string>
using namespace std;
TEST_CASE("async returns valid future") {
using namespace literals::string_literals;
auto the_future = async([] { return "female"s; }); ➊
REQUIRE(the_future.valid()); ➋
}
示例 19-1:async 函数返回一个有效的 future。
你启动一个异步任务,它简单地返回一个 string ➊。因为 async 总是返回一个有效的 future,所以 valid 返回 true ➋。
如果你默认构造一个 future,它没有关联共享状态,因此 valid 会返回 false,如 示例 19-2 所示。
TEST_CASE("future invalid by default") {
future<bool> default_future; ➊
REQUIRE_FALSE(default_future.valid()); ➋
}
示例 19-2:默认构造的 future 是无效的。
你默认构造一个 future ➊,然后 valid 返回 false ➋。
其次,你可以通过 get 方法从有效的 future 中获取值。如果异步任务尚未完成,调用 get 会阻塞当前执行的线程,直到结果可用。示例 19-3 演示了如何使用 get 获取返回值。
TEST_CASE("async returns the return value of the function object") {
using namespace literals::string_literals;
auto the_future = async([] { return "female"s; }); ➊
REQUIRE(the_future.get() == "female"); ➋
}
示例 19-3:async 函数返回一个有效的 future。
你使用async来启动一个异步任务➊,然后在返回的future对象上调用get方法。正如预期的那样,结果是你传递给async的函数对象的返回值➋。
如果异步任务抛出异常,future将收集该异常,并在你调用get时抛出它,正如清单 19-4 所展示的那样。
TEST_CASE("get may throw ") {
auto ghostrider = async(
[] { throw runtime_error{ "The pattern is full." }; }); ➊
REQUIRE_THROWS_AS(ghostrider.get(), runtime_error); ➋
}
清单 19-4:get方法将抛出异步任务抛出的异常。
你将一个抛出runtime_error的 lambda 传递给async➊。当你调用get时,它会抛出该异常➋。
第三,你可以使用std::wait_for或std::wait_until来检查异步任务是否已完成。选择哪个取决于你想传递的chrono对象的类型。如果你有一个duration对象,你将使用wait_for;如果你有一个time_point对象,你将使用wait_until。两者都返回一个std::future_status,它有三种可能的值:
-
future_status::deferred表示异步任务将被懒惰评估,因此一旦调用get,任务就会执行。 -
future_status::ready表示任务已完成,结果已经准备好。 -
future_status::timeout表示任务尚未准备好。
如果任务在指定的等待时间之前完成,async会提前返回。
清单 19-5 展示了如何使用wait_for检查异步任务的状态。
TEST_CASE("wait_for indicates whether a task is ready") {
using namespace literals::chrono_literals;
auto sleepy = async(launch::async, [] { this_thread::sleep_for(100ms); }); ➊
const auto not_ready_yet = sleepy.wait_for(25ms); ➋
REQUIRE(not_ready_yet == future_status::timeout); ➌
const auto totally_ready = sleepy.wait_for(100ms); ➍
REQUIRE(totally_ready == future_status::ready); ➎
}
清单 19-5:使用wait_for检查异步任务的状态
首先,你使用async启动一个异步任务,该任务仅等待最多 100 毫秒后再返回➊。接下来,你调用wait_for并设置等待时间为 25 毫秒➋。由于任务仍在睡眠中(25 < 100),wait_for返回future_status::timeout ➌。你再次调用wait_for并等待最多 100 毫秒 ➍。因为第二次wait_for会在async任务完成后结束,所以最终的wait_for会返回future_status::ready ➎。
注意
从技术上讲,清单 19-5 中的断言并不保证总是会通过。页面 389 中介绍的“等待”引入了this_thread::sleep_for,它并不精确。操作环境负责调度线程,可能会在指定的时间后再调度睡眠中的线程。
异步任务示例
清单 19-6 包含了factorize函数,它用于查找一个整数的所有因数。
注意
清单 19-6 中的因式分解算法效率非常低,但对于本示例足够用了。要了解高效的整数因式分解算法,请参考 Dixon 算法、连分式因式分解算法或二次筛法。
#include <set>
template <typename T>
std::multiset<T> factorize(T x) {
std::multiset<T> result{ 1 }; ➊
for(T candidate{ 2 }; candidate <= x; candidate++) { ➋
if (x % candidate == 0) { ➌
result.insert(candidate); ➍
x /= candidate; ➎
candidate = 1; ➏
}
}
return result;
}
清单 19-6:一个非常简单的整数因式分解算法
该算法接受一个单一的参数x,并通过初始化一个包含 1 的set开始 ➊。接下来,它从 2 迭代到x ➋,检查candidate是否与之取模后结果为 0 ➌。若是,则candidate是一个因子,并将其添加到因子set中 ➍。你将x除以刚刚发现的因子 ➎,然后通过将candidate重置为 1 重新开始搜索 ➏。
由于整数分解是一个难题(并且因为 Listing 19-6 效率低下),调用factorize可能需要相较于本书中大多数函数更长的时间。这使得它成为异步任务的一个理想候选。factor_task函数在 Listing 19-7 中使用了第十二章中 Listing 12-25 中的Stopwatch来封装factorize,并返回一个格式化良好的消息。
#include <set>
#include <chrono>
#include <sstream>
#include <string>
using namespace std;
struct Stopwatch {
--snip--
};
template <typename T>
set<T> factorize(T x) {
--snip--
}
string factor_task(unsigned long x) { ➊
chrono::nanoseconds elapsed_ns;
set<unsigned long long> factors;
{
Stopwatch stopwatch{ elapsed_ns }; ➋
factors = factorize(x); ➌
}
const auto elapsed_ms =
chrono::duration_cast<chrono::milliseconds>(elapsed_ns).count(); ➍
stringstream ss;
ss << elapsed_ms << " ms: Factoring " << x << " ( "; ➎
for(auto factor : factors) ss << factor << " "; ➏
ss << ")\n";
return ss.str(); ➐
}
Listing 19-7: 一个包装factorize调用并返回格式化消息的factor_task函数
和factorize类似,factor_task也接受一个单一的参数x进行分解 ➊。(为了简化,factor_task接受一个unsigned long类型的参数,而不是模板参数)。接下来,你在一个嵌套作用域中初始化一个Stopwatch ➋,然后调用factorize来分解x ➌。结果是,elapsed_ns包含了factorize执行时经过的纳秒数,而factors则包含了x的所有因子。
接下来,你通过首先将elapsed_ns转换为毫秒数 ➍,构建一个格式化良好的字符串。你将这些信息写入名为ss的stringstream对象 ➎,然后写入x的因子 ➏。最后,返回生成的string ➐。
Listing 19-8 使用factor_task分解六个不同的数字,并记录总的程序运行时间。
#include <set>
#include <array>
#include <vector>
#include <iostream>
#include <limits>
#include <chrono>
#include <sstream>
#include <string>
using namespace std;
struct Stopwatch {
--snip--
};
template <typename T>
set<T> factorize(T x) {
--snip--
}
string factor_task(unsigned long long x) {
--snip--
}
array<unsigned long long, 6> numbers{ ➊
9'699'690,
179'426'549,
1'000'000'007,
4'294'967'291,
4'294'967'296,
1'307'674'368'000
};
int main() {
chrono::nanoseconds elapsed_ns;
{
Stopwatch stopwatch{ elapsed_ns }; ➋
for(auto number : numbers) ➌
cout << factor_task(number); ➍
}
const auto elapsed_ms =
chrono::duration_cast<chrono::milliseconds>(elapsed_ns).count(); ➎
cout << elapsed_ms << "ms: total program time\n"; ➏
}
-----------------------------------------------------------------------
0 ms: Factoring 9699690 ( 1 2 3 5 7 11 13 17 19 )
1274 ms: Factoring 179426549 ( 1 179426549 )
6804 ms: Factoring 1000000007 ( 1 1000000007 )
29035 ms: Factoring 4294967291 ( 1 4294967291 )
0 ms: Factoring 4294967296 ( 1 2 )
0 ms: Factoring 1307674368000 ( 1 2 3 5 7 11 13 )
37115ms: total program time
Listing 19-8: 一个使用factor_task来分解六个不同数字的程序
你构建了一个包含六个不同大小和素数性质的numbers数组 ➊。接下来,你初始化一个Stopwatch ➋,遍历numbers中的每个元素 ➌,并调用factor_task进行分解 ➍。然后,你计算程序的运行时间(以毫秒为单位) ➎,并打印出来 ➏。
输出结果显示,某些数字,如 9,699,690、4,294,967,296 和 1,307,674,368,000,几乎可以立即分解,因为它们包含较小的因子。然而,素数需要相当长的时间。请注意,由于程序是单线程的,整个程序的运行时间大致等于分解每个数字所花费时间的总和。
如果将每个factor_task视为异步任务会怎样?Listing 19-9 演示了如何使用async实现这一点。
#include <set>
#include <vector>
#include <array>
#include <iostream>
#include <limits>
#include <chrono>
#include <future>
#include <sstream>
#include <string>
using namespace std;
struct Stopwatch {
--snip--
};
template <typename T>
set<T> factorize(T x) {
--snip--
}
string factor_task(unsigned long long x) {
--snip--
}
array<unsigned long long, 6> numbers{
--snip--
};
int main() {
chrono::nanoseconds elapsed_ns;
{
Stopwatch stopwatch{ elapsed_ns }; ➊
vector<future<string>> factor_tasks; ➋
for(auto number : numbers) ➌
factor_tasks.emplace_back(async(launch::async, factor_task, number)); ➍
for(auto& task : factor_tasks) ➎
cout << task.get(); ➏
}
const auto elapsed_ms =
chrono::duration_cast<chrono::milliseconds>(elapsed_ns).count(); ➐
cout << elapsed_ms << " ms: total program time\n"; ➑
}
-----------------------------------------------------------------------
0 ms: Factoring 9699690 ( 1 2 3 5 7 11 13 17 19 )
1252 ms: Factoring 179426549 ( 1 179426549 )
6816 ms: Factoring 1000000007 ( 1 1000000007 )
28988 ms: Factoring 4294967291 ( 1 4294967291 )
0 ms: Factoring 4294967296 ( 1 2 )
0 ms: Factoring 1307674368000 ( 1 2 3 5 7 11 13 )
28989 ms: total program time
Listing 19-9: 一个使用factor_task异步地分解六个不同数字的程序
如在示例 19-8 中所示,你初始化一个Stopwatch来记录程序执行的时长 ➊。接下来,你初始化一个名为factor_tasks的vector,它包含future<string>类型的对象 ➋。你遍历numbers ➌,调用async并使用launch::async策略,指定factor_task为函数对象,并传递number作为任务的参数。你对每个生成的future调用emplace_back,将其加入到factor_tasks ➍。现在,async已经启动了每个任务,你遍历factor_tasks中的每个元素 ➎,调用get来获取每个task的结果,并将其写入cout ➏。一旦从所有的future中收到了值,你就能计算出执行所有任务所用的毫秒数 ➐,并将其写入cout ➑。
由于并发性,示例 19-9 的总程序时间大约等于最大任务执行时间(28,988 毫秒),而不是任务执行时间的总和,如在示例 19-8 中所示(37,115 毫秒)。
注意
示例 19-8 和示例 19-9 中的时间会因每次运行而有所不同。
共享与协调
使用异步任务进行并发编程是简单的,只要任务不需要同步,并且不涉及共享可变数据。例如,考虑一个简单的情境,其中两个线程访问同一个整数。一个线程会递增这个整数,而另一个线程会递减它。为了修改变量,每个线程必须读取变量的当前值,进行加法或减法操作,然后将变量写回内存。如果没有同步机制,这两个线程将以未定义的交错顺序执行这些操作。这种情况有时被称为竞争条件,因为结果取决于哪个线程先执行。示例 19-10 展示了这种情况有多么灾难性。
#include <future>
#include <iostream>
using namespace std;
void goat_rodeo() {
const size_t iterations{ 1'000'000 };
int tin_cans_available{}; ➊
auto eat_cans = async(launch::async, [&] { ➋
for(size_t i{}; i<iterations; i++)
tin_cans_available--; ➌
});
auto deposit_cans = async(launch::async, [&] { ➍
for(size_t i{}; i<iterations; i++)
tin_cans_available++; ➎
});
eat_cans.get(); ➏
deposit_cans.get(); ➐
cout << "Tin cans: " << tin_cans_available << "\n"; ➑
}
int main() {
goat_rodeo();
goat_rodeo();
goat_rodeo();
}
-----------------------------------------------------------------------
Tin cans: -609780
Tin cans: 185380
Tin cans: 993137
示例 19-10:展示了未同步、可变共享数据访问可能带来的灾难性后果
注意
由于程序存在未定义行为,在运行示例 19-10 时,你将获得不同的结果。
示例 19-10 涉及定义一个名为goat_rodeo的函数,它包含一个灾难性的竞争条件,以及一个调用goat_rodeo三次的main函数。在goat_rodeo中,你初始化了共享数据tin_cans_available ➊。接下来,你启动一个名为eat_cans的异步任务 ➋,在该任务中,一群山羊会将共享变量tin_cans_available递减一百万次 ➌。然后,你启动另一个名为deposit_cans的异步任务 ➍,该任务会递增tin_cans_available ➎。启动这两个任务后,你通过调用get等待它们完成(顺序无关) ➏➐。任务完成后,你打印出tin_cans_available变量的值 ➑。
从直觉上讲,你可能会期望每个任务完成后 tin_cans_available 等于零。毕竟,无论你如何排序递增和递减,如果它们的次数相等,它们会相互抵消。你调用了三次 goat_rodeo,每次调用的结果都完全不同。
表 19-1 说明了在 清单 19-10 中,无同步访问如何导致问题。
表 19-1: eat_cans 和 deposit_cans 的一种可能调度
| eat_cans | deposit_cans | cans_available |
|---|---|---|
读取 cans_available (0) |
0 | |
读取 cans_available (0) ➊ |
0 | |
计算 cans_available+1 (1) |
0 | |
计算 cans_available-1 (-1) ➌ |
0 | |
写入 cans_available+1 (1) ➋ |
1 | |
写入 cans_available-1 (-1) ➍ |
-1 |
表 19-1 显示了交替读取和写入如何带来灾难。在这种特殊情况下,deposit_cans 的读取 ➊ 在 eat_cans 的写入 ➋ 之前发生,因此 deposit_cans 计算了一个过时的结果 ➌。更糟糕的是,它在写入时覆盖了 eat_cans 的写入 ➍。
这个数据竞争问题的根本原因是 对可变共享数据的无同步访问。你可能会问,为什么每当一个线程计算 cans_available+1 或 cans_available-1 时,cans_available 不会立即更新?答案在于,表 19-1 中的每一行都表示某个指令执行完毕的时刻,而加法、减法、读取和写入内存的指令是分开的。由于 cans_available 变量是共享的,并且两个线程都在没有同步其操作的情况下写入它,因此指令在运行时会以未定义的方式交替执行(并带来灾难性后果)。在接下来的子节中,你将学习三种应对这种情况的工具:互斥量、条件变量 和原子操作。
互斥量
互斥算法(mutex)是一种防止多个线程同时访问资源的机制。互斥量是 同步原语,支持两种操作:锁定和解锁。当一个线程需要访问共享数据时,它会锁定互斥量。根据互斥量的性质以及是否有其他线程已获得锁,锁定操作可能会被阻塞。当线程不再需要访问时,它会解锁互斥量。
<mutex> 头文件提供了几种互斥选项:
-
std::mutex提供基本的互斥功能。 -
std::timed_mutex提供了带有超时的互斥功能。 -
std::recursive_mutex提供了允许同一线程递归锁定的互斥功能。 -
std::recursive_timed_mutex提供了允许同一线程递归锁定的互斥功能,并且有超时功能。
<shared_mutex> 头文件提供了两个额外的选项:
-
std::shared_mutex提供共享互斥功能,这意味着多个线程可以同时拥有该互斥锁。这个选项通常用于多个读线程可以访问共享数据,而写线程需要独占访问的场景。 -
std::shared_timed_mutex提供共享互斥功能,并实现了带有超时的锁定机制。
注意
为了简单起见,本章仅介绍互斥锁。有关其他选项的更多信息,请参见[thread.mutex]。
mutex类只定义了一个单一的默认构造函数。当你需要获得互斥访问时,你可以在mutex对象上调用两个方法之一:lock或try_lock。如果调用lock,它不接受任何参数并返回void,调用线程会阻塞,直到mutex可用。如果调用try_lock,它也不接受任何参数并返回一个bool,它会立即返回。如果try_lock成功获得了互斥访问,它会返回true,并且调用线程现在拥有锁。如果try_lock失败,它会返回false,并且调用线程没有获得锁。要释放互斥锁,你只需调用unlock方法,它不接受任何参数并返回void。
列表 19-11 展示了一种基于锁的方式来解决列表 19-10 中的竞态条件。
#include <future>
#include <iostream>
#include <mutex>
using namespace std;
void goat_rodeo() {
const size_t iterations{ 1'000'000 };
int tin_cans_available{};
mutex tin_can_mutex; ➊
auto eat_cans = async(launch::async, [&] {
for(size_t i{}; i<iterations; i++) {
tin_can_mutex.lock(); ➋
tin_cans_available--;
tin_can_mutex.unlock(); ➌
}
});
auto deposit_cans = async(launch::async, [&] {
for(size_t i{}; i<iterations; i++) {
tin_can_mutex.lock(); ➍
tin_cans_available++;
tin_can_mutex.unlock(); ➎
}
});
eat_cans.get();
deposit_cans.get();
cout << "Tin cans: " << tin_cans_available << "\n";
}
int main() {
goat_rodeo(); ➏
goat_rodeo(); ➐
goat_rodeo(); ➑
}
-----------------------------------------------------------------------
Tin cans: 0 ➏
Tin cans: 0 ➐
Tin cans: 0 ➑
列表 19-11:使用mutex解决列表 19-10 中的竞态条件
你在goat_rodeo ➊中添加了一个名为tin_can_mutex的mutex,它对tin_cans_available提供互斥访问。在每个异步任务中,线程在修改tin_cans_available之前会获取一个锁 ➋➍。修改完成后,线程会解锁 ➌➎。注意,每次运行结束时,tin_cans_available的最终数量为零 ➏➐➑,这表明你已经修复了竞态条件。
互斥锁实现
在实践中,互斥锁有多种实现方式。最简单的互斥锁可能是自旋锁,其中线程会执行一个循环,直到锁被释放。这种锁通常可以最小化一个线程释放锁与另一个线程获取锁之间的时间。但它在计算上是昂贵的,因为 CPU 会花费大量时间检查锁是否可用,而其他线程本可以进行有生产力的工作。通常,互斥锁需要原子指令,如compare-and-swap、fetch-and-add或test-and-set,这样它们就能在一个操作中检查并获取锁。
现代操作系统,如 Windows,提供了比自旋锁更高效的替代方案。例如,基于异步过程调用的互斥锁允许线程在等待互斥锁时进入等待状态。一旦互斥锁变得可用,操作系统会唤醒等待的线程,并将互斥锁的所有权交给该线程。这使得其他线程可以在 CPU 上做有生产力的工作,而不是被自旋锁占用。
一般来说,除非互斥锁成为程序的瓶颈,否则你不需要关心操作系统如何实现互斥锁的细节。
如果你认为处理mutex锁定是 RAII 对象的完美任务,你是对的。假设你忘记调用unlock释放一个互斥锁,比如因为它抛出了异常。当下一个线程来尝试通过lock获取这个互斥锁时,你的程序会停滞不前。正因如此,标准库提供了用于处理互斥锁的 RAII 类,位于<mutex>头文件中。在那里,你会找到几个类模板,它们都接受互斥锁作为构造函数参数,并且有一个与互斥锁类型对应的模板参数:
-
std::lock_guard是一个不可复制、不可移动的 RAII 封装器,它在构造函数中接受一个互斥锁对象,并调用lock。然后在析构函数中调用unlock。 -
std::scoped_lock是一个避免死锁的 RAII 封装器,用于多个互斥锁。 -
std::unique_lock实现了一个可移动的互斥锁所有权封装器。 -
std::shared_lock实现了一个可移动的共享互斥锁所有权封装器。
为简洁起见,本节重点讨论lock_guard。清单 19-12 展示了如何重构清单 19-11,以使用lock_guard代替手动操作mutex。
#include <future>
#include <iostream>
#include <mutex>
using namespace std;
void goat_rodeo() {
const size_t iterations{ 1'000'000 };
int tin_cans_available{};
mutex tin_can_mutex;
auto eat_cans = async(launch::async, [&] {
for(size_t i{}; i<iterations; i++) {
lock_guard<mutex> guard{ tin_can_mutex }; ➊
tin_cans_available--;
}
});
auto deposit_cans = async(launch::async, [&] {
for(size_t i{}; i<iterations; i++) {
lock_guard<mutex> guard{ tin_can_mutex }; ➋
tin_cans_available++;
}
});
eat_cans.get();
deposit_cans.get();
cout << "Tin cans: " << tin_cans_available << "\n";
}
int main() {
goat_rodeo();
goat_rodeo();
goat_rodeo();
}
-----------------------------------------------------------------------
Tin cans: 0
Tin cans: 0
Tin cans: 0
清单 19-12:重构清单 19-11 以使用lock_guard
与其使用lock和unlock来管理互斥,你可以在需要同步的每个作用域开始时构造一个lock_guard ➊➋。由于你的互斥机制是mutex,你需要将其指定为lock_guard模板参数。清单 19-11 和清单 19-12 在运行时行为上是等价的,包括程序执行所需的时间。RAII 对象不会引入比手动释放和获取锁更高的运行时成本。
不幸的是,互斥锁涉及运行时成本。你可能也注意到,执行清单 19-11 和 19-12 的时间明显比执行清单 19-10 要长。原因是获取和释放锁是相对昂贵的操作。在清单 19-11 和清单 19-12 中,tin_can_mutex被获取然后释放了两百万次。相较于增减一个整数,获取或释放锁花费的时间要多得多,因此使用互斥锁来同步异步任务是次优的。在某些情况下,你可以通过使用原子操作采取可能更高效的方法。
注意
有关异步任务和未来值的更多信息,请参阅[futures.async]。
原子操作
单词atomic来自希腊语átomos,意为“不可分割”。当一个操作在不可分割的单元中发生时,这个操作就是原子的。另一个线程无法观察到操作进行到一半的状态。当你在清单 19-10 中引入锁来生成清单 19-11 时,你使得增量和减量操作变得原子化,因为异步任务无法再交错地读取和写入tin_cans_available。正如你在运行这个基于锁的解决方案时体验到的那样,这种方法非常慢,因为获取锁是非常昂贵的。
另一种方法是使用std::atomic类模板,该模板在<atomic>头文件中提供了常用于无锁并发编程的原语。无锁并发编程解决了数据竞争问题,而不涉及锁。在许多现代架构中,CPU 支持原子指令。使用原子操作,你可能能够通过依赖原子硬件指令来避免锁。
本章不会详细讨论std::atomic或如何设计自己的无锁解决方案,因为这非常难以正确实现,最好留给专家。不过,在简单的情况下,比如在清单 19-10 中,你可以使用std::atomic来确保增量或减量操作无法被拆分。这样可以巧妙地解决数据竞争问题。
std::atomic模板为所有基本类型提供了特化,如表 19-2 所示。
表 19-2: std::atomic模板对基本类型的特化
| 模板特化 | 别名 |
|---|---|
std::atomic<bool> |
std::atomic_bool |
std::atomic<char> |
std::atomic_char |
std::atomic<unsigned char> |
std::atomic_uchar |
std::atomic<short> |
std::atomic_short |
std::atomic<unsigned short> |
std::atomic_ushort |
std::atomic<int> |
std::atomic_int |
std::atomic<unsigned int> |
std::atomic_uint |
std::atomic<long> |
std::atomic_long |
std::atomic<unsigned long> |
std::atomic_ulong |
std::atomic<long long> |
std::atomic_llong |
std::atomic<unsigned long long> |
std::atomic_ullong |
std::atomic<char16_t> |
std::atomic_char16_t |
std::atomic<char32_t> |
std::atomic_char32_t |
std::atomic<wchar_t> |
std::atomic_wchar_t |
表 19-3 列出了std::atomic的一些支持操作。std::atomic模板没有拷贝构造函数。
表 19-3: std::atomic的支持操作
| 操作 | 描述 |
|---|---|
a{}a{ 123 } |
默认构造函数。将值初始化为 123。 |
a.is_lock_free() |
如果 a 是无锁的,则返回 true。(取决于 CPU。) |
a.store(123) |
将值 123 存储到 a 中。 |
a.load()a`() |
返回存储的值。 |
a.exchange(123) |
将当前值替换为 123,并返回旧值。这是一个“读-修改-写”操作。 |
a.compare_exchange_weak(10, 20)a.compare_exchange_strong(10, 20) |
如果当前值为 10,则替换为 20。若值被替换,返回 true。有关弱交换与强交换的详情,请参见[atomic]。 |
注意
对于数值类型,特化操作提供了附加的操作,如表 19-4 所列。
表 19-4: std::atomic a 的数值特化支持的操作
| 操作 | 描述 |
|---|---|
a.fetch_add(123)a+=123 |
用当前值加上参数的结果替换当前值。返回修改前的值。这是一个“读-修改-写”操作。 |
a.fetch_sub(123)a-=123 |
用当前值减去参数的结果替换当前值。返回修改前的值。这是一个“读-修改-写”操作。 |
a.fetch_and(123)a&=123 |
用当前值与参数进行按位与运算的结果替换当前值。返回修改前的值。这是一个“读-修改-写”操作。 |
a.fetch_or(123)a|=123 |
用当前值与参数进行按位或运算的结果替换当前值。返回修改前的值。这是一个“读-修改-写”操作。 |
a.fetch_xor(123)a^=123 |
用当前值与参数进行按位异或运算的结果替换当前值。返回修改前的值。这是一个“读-修改-写”操作。 |
a++a-- |
增加或减少 a 的值。 |
因为清单 19-12 是一个适合无锁解决方案的典型例子,你可以将tin_cans_available的类型替换为atomic_int,并移除mutex。这样可以防止像表 19-1 所示的竞争条件。清单 19-13 实现了这一重构。
#include <future>
#include <iostream>
#include <atomic>
using namespace std;
void goat_rodeo() {
const size_t iterations{ 1'000'000 };
atomic_int➊ tin_cans_available{};
auto eat_cans = async(launch::async, [&] {
for(size_t i{}; i<iterations; i++)
tin_cans_available--; ➋
});
auto deposit_cans = async(launch::async, [&] {
for(size_t i{}; i<iterations; i++)
tin_cans_available++; ➌
});
eat_cans.get();
deposit_cans.get();
cout << "Tin cans: " << tin_cans_available << "\n";
}
int main() {
goat_rodeo();
goat_rodeo();
goat_rodeo();
}
-----------------------------------------------------------------------
Tin cans: 0
Tin cans: 0
Tin cans: 0
清单 19-13:使用atomic_int而非mutex解决竞争条件
你将int替换为atomic_int ➊并移除mutex。因为递减 ➋ 和递增 ➌ 运算符是原子的,竞争条件仍然得到解决。
注意
有关原子操作的更多信息,请参见[atomics]。
你可能还注意到,从清单 19-12 到 19-13,性能有了显著的提升。一般来说,使用原子操作将比获取互斥锁更快。
警告
除非你有一个非常简单的并发访问问题,比如本节中的例子,否则你真的不应该尝试自己实现无锁解决方案。请参考 Boost Lockfree 库,获取高质量、经过彻底测试的无锁容器。像往常一样,你必须决定基于锁的实现还是无锁实现更为优化。
条件变量
条件变量是一种同步原语,它会阻塞一个或多个线程,直到被通知。另一个线程可以通知条件变量。通知后,条件变量可以解除阻塞一个或多个线程,使它们能够继续执行。一个非常流行的条件变量模式涉及一个线程执行以下操作:
-
获取一些与等待线程共享的互斥锁。
-
修改共享状态。
-
通知条件变量。
-
释放互斥锁。
任何在条件变量上等待的线程会执行以下操作:
-
获取互斥锁。
-
在条件变量上等待(这会释放互斥锁)。
-
当另一个线程通知条件变量时,当前线程醒来并可以执行一些工作(这会自动重新获取互斥锁)。
-
释放互斥锁。
由于现代操作系统的复杂性,有时线程会无故醒来。因此,重要的是在等待的线程醒来时,验证条件变量确实已被通知。
标准库在<condition_variable>头文件中提供了std::condition_variable,它支持多种操作,包括表 19-5 中的操作。condition_variable仅支持默认构造,并且拷贝构造函数被删除。
表 19-5: std::condition_variable cv 支持的操作
| 操作 | 描述 |
|---|---|
cv.notify_one() |
如果有线程在等待条件变量,此操作会通知其中一个线程。 |
cv.notify_all() |
如果有线程在等待条件变量,此操作会通知所有线程。 |
cv.wait(lock, [pred]) |
在通知者拥有的互斥锁上获取锁,唤醒时返回。如果提供了pred,则确定通知是否是虚假通知(返回false)还是有效通知(返回true)。 |
cv.wait_for(lock, [durn], [pred]) |
与 cv.wait相同,除了wait_for只等待durn。如果超时发生且未提供pred,返回std::cv_status::timeout;否则,返回std::cv_status::no_timeout。 |
cv.wait_until(lock, [time], [pred]) |
与wait_for相同,只是使用std::chrono::time_point而不是std::chrono::duration。 |
例如,您可以重构清单 19-12,使得放置罐子任务在吃罐子任务之前完成,使用条件变量,正如清单 19-14 所示。
#include <future>
#include <iostream>
#include <mutex>
#include <condition_variable>
using namespace std;
void goat_rodeo() {
mutex m; ➊
condition_variable cv; ➋
const size_t iterations{ 1'000'000 };
int tin_cans_available{};
auto eat_cans = async(launch::async, [&] {
unique_lock<mutex> lock{ m }; ➌
cv.wait(lock, [&] { return tin_cans_available == 1'000'000; }); ➍
for(size_t i{}; i<iterations; i++)
tin_cans_available--;
});
auto deposit_cans = async(launch::async, [&] {
scoped_lock<mutex> lock{ m }; ➎
for(size_t i{}; i<iterations; i++)
tin_cans_available++;
cv.notify_all(); ➏
});
eat_cans.get();
deposit_cans.get();
cout << "Tin cans: " << tin_cans_available << "\n";
}
int main() {
goat_rodeo();
goat_rodeo();
goat_rodeo();
}
-----------------------------------------------------------------------
Tin cans: 0
Tin cans: 0
Tin cans: 0
清单 19-14:使用条件变量确保所有罐子在被吃之前都被放置
你声明一个 mutex ➊ 和一个 condition_variable ➋,你将用它们来协调异步任务。在 吃罐头 任务中,你获取一个 unique_lock 对 mutex 的锁,并将其与一个谓词一起传递给 wait,该谓词如果有罐头可用时返回 true ➌。该方法将释放 mutex,然后阻塞直到满足两个条件:condition_variable 唤醒该线程,并且有一百万个罐头可用 ➍(记住,你必须检查所有罐头是否可用,因为可能会发生虚假唤醒)。在 存罐头 任务中,你获取对 mutex ➎ 的锁,存入罐头,然后通知所有在 condition_variable 上阻塞的线程 ➏。
请注意,与之前的所有方法不同,tin_cans_available 不可能为负,因为存罐头和吃罐头的顺序是有保障的。
注意
有关条件变量的更多信息,请参考 [thread.condition]。
低级并发设施
标准库的 <thread> 库包含用于并发编程的低级设施。例如,std::thread 类模拟了操作系统线程。然而,最好不要直接使用 thread,而是通过更高级的抽象设计并发,比如任务。如果你需要低级线程访问,[thread] 提供了更多信息。
但是,<thread> 库确实包含了几个有用的函数,用于操作当前线程:
-
std::this_thread::yield函数不接受任何参数,并返回void。yield的确切行为取决于环境,但通常它提供一个提示,操作系统应该给其他线程一个运行的机会。这在例如,当某个资源的锁竞争很激烈时非常有用,你希望帮助所有线程获得访问的机会。 -
std::this_thread::get_id函数不接受任何参数,并返回一个类型为std::thread::id的对象,这是一个轻量级线程,支持比较操作符和operator<<。通常,它被用作关联容器中的键。 -
std::this_thread::sleep_for函数接受一个std::chrono::duration参数,阻塞当前线程的执行,直到至少经过指定的时长,并返回void。 -
std::this_thread::sleep_until接受一个std::chrono::time_point,并返回void。它完全类似于sleep_for,只不过它会阻塞线程直到至少达到指定的time_point。
当你需要这些功能时,它们是不可或缺的。否则,你真的不应该需要与 <thread> 头文件交互。
并行算法
第十八章介绍了 stdlib 的算法,其中许多算法接受一个可选的第一个参数,称为其执行策略,由一个std::execution值进行编码。在支持的环境中,有三个可能的值:seq、par和par_unseq。后两个选项表示你希望并行执行算法。
示例:并行排序
示例 19-15 展示了如何通过将单一参数从seq改为par,大幅影响程序运行时间,方法是对十亿个数字进行两种方式的排序。
#include <algorithm>
#include <vector>
#include <numeric>
#include <random>
#include <chrono>
#include <iostream>
#include <execution>
using namespace std;
// From Listing 12-25:
struct Stopwatch {
--snip--
};
vector<long> make_random_vector() { ➊
vector<long> numbers(1'000'000'000);
iota(numbers.begin(), numbers.end(), 0);
mt19937_64 urng{ 121216 };
shuffle(numbers.begin(), numbers.end(), urng);
return numbers;
}
int main() {
cout << "Constructing random vectors...";
auto numbers_a = make_random_vector(); ➋
auto numbers_b{ numbers_a }; ➌
chrono::nanoseconds time_to_sort;
cout << " " << numbers_a.size() << " elements.\n";
cout << "Sorting with execution::seq...";
{
Stopwatch stopwatch{ time_to_sort };
sort(execution::seq, numbers_a.begin(), numbers_a.end()); ➍
}
cout << " took " << time_to_sort.count() / 1.0E9 << " sec.\n";
cout << "Sorting with execution::par...";
{
Stopwatch stopwatch{ time_to_sort };
sort(execution::par, numbers_b.begin(), numbers_b.end()); ➎
}
cout << " took " << time_to_sort.count() / 1.0E9 << " sec.\n";
}
-----------------------------------------------------------------------
Constructing random vectors... 1000000000 elements.
Sorting with execution::seq... took 150.489 sec.
Sorting with execution::par... took 17.7305 sec.
示例 19-15:使用std::sort和std::execution::seq与std::execution::par对十亿个数字进行排序。(结果来自一台 Windows 10 x64 机器,配有两颗 Intel Xeon E5-2620 v3 处理器。)
make_random_vector函数 ➊ 生成一个包含十亿个唯一数字的vector。你创建两个副本,numbers_a ➋ 和 numbers_b ➌。你分别对每个vector进行排序。在第一种情况下,你使用顺序执行策略进行排序 ➍,Stopwatch显示操作花费了大约两分半钟(约 150 秒)。在第二种情况下,你使用并行执行策略进行排序 ➎。相比之下,Stopwatch显示操作只花费了大约 18 秒。顺序执行耗时大约是并行执行的 8.5 倍。
并行算法并非魔法
不幸的是,并行算法并非魔法。尽管它们在简单情况中表现得非常出色,例如在示例 19-15 中的sort,但在使用时仍需小心。每当算法产生超出目标序列的副作用时,你就必须深入思考竞态条件。一个警示信号是任何向算法传递函数对象的算法。如果函数对象有共享的可变状态,执行的线程将共享访问,可能会发生竞态条件。例如,考虑示例 19-16 中的并行transform调用。
#include <algorithm>
#include <vector>
#include <iostream>
#include <numeric>
#include <execution>
int main() {
std::vector<long> numbers{ 1'000'000 }, squares{ 1'000'000 }; ➊
std::iota(numbers.begin(), numbers.end(), 0); ➋
size_t n_transformed{}; ➌
std::transform(std::execution::par, numbers.begin(), numbers.end(), ➍
squares.begin(), [&n_transformed] (const auto x) {
++n_transformed; ➎
return x * x; ➏
});
std::cout << "n_transformed: " << n_transformed << std::endl; ➐
}
-----------------------------------------------------------------------
n_transformed: 187215 ➐
示例 19-16:由于非原子访问n_transformed而导致的竞态条件程序
你首先初始化两个vector对象,numbers和squares,它们包含一百万个元素 ➊。接着,你使用iota填充其中一个 ➋,并将变量n_transformed初始化为0 ➌。然后,你使用并行执行策略调用transform,将numbers作为目标序列,squares作为结果序列,并传入一个简单的 lambda ➍。这个 lambda 会递增n_transformed ➎,并返回参数x的平方 ➏。由于多个线程会执行这个 lambda,必须对n_transformed的访问进行同步 ➐。
上一节介绍了两种解决此问题的方法:锁和原子操作。在这种情况下,最好的方法可能就是直接使用std::atomic_size_t来替代size_t。
总结
本章对并发性和并行性进行了非常高层次的概述。此外,你还学习了如何启动异步任务,这使你能够轻松地将多线程编程概念引入你的代码中。尽管将并行和并发概念引入程序中可以显著提升性能,但你必须小心避免引入竞态条件,这些竞态条件可能导致未定义的行为。你还学习了几种同步访问可变共享状态的机制:互斥锁、条件变量和原子操作。
练习
19-1. 编写你自己的基于自旋锁的互斥锁,命名为 SpinLock。暴露 lock、try_lock 和 unlock 方法。你的类应该删除拷贝构造函数。尝试使用 std::lock_guard<SpinLock> 来管理你的类的实例。
19-2. 阅读著名的双重检查锁定模式(DCLP)及其不应该使用的原因。(参见 Scott Meyers 和 Andrei Alexandrescu 在“进一步阅读”部分提到的文章。)然后了解如何使用 std::call_once 确保可调用对象只被调用一次,详见 [thread.once.callonce]。
19-3. 创建一个线程安全的队列类。该类必须暴露一个类似于 std::queue 的接口(见 [queue.defn])。内部使用 std::queue 来存储元素。使用 std::mutex 来同步访问这个内部的 std::queue。
19-4. 向你的线程安全队列添加 wait_and_pop 方法和一个 std::condition_variable 成员。当用户调用 wait_and_pop 且队列包含元素时,它应该弹出队列中的元素并返回。如果队列为空,线程应该阻塞,直到有元素可用,然后继续弹出元素。
19-5. (可选)阅读 Boost Coroutine2 文档,特别是“概述”、“介绍”和“动机”部分。
进一步阅读
-
“C++ 与双重检查锁定的危险:第一部分”由 Scott Meyers 和 Andrei Alexandrescu 编写(http://www.drdobbs.com/cpp/c-and-the-perils-of-double-checked-locki/184405726/)
-
ISO 国际标准 ISO/IEC (2017) — C++ 编程语言(国际标准化组织;瑞士日内瓦;
isocpp.org/std/the-standard/) -
C++ 并发实战,第二版,作者:Anthony Williams(Manning,2018)
-
“有效的并发性:了解何时使用活动对象而非互斥锁”,由 Herb Sutter 编写(https://herbsutter.com/2010/09/24/effective-concurrency-know-when-to-use-an-active-object-instead-of-a-mutex/)
-
Effective Modern C++: 42 种改进你使用 C++ 11 和 C++ 14 的具体方法,由 Scott Meyers 编写(O'Reilly Media,2014)
-
彼得·L·蒙哥马利的《现代整数分解算法综述》。《CWI 季刊》7.4(1994 年):337–365。
第二十三章:NETWORK PROGRAMMING WITH BOOST ASIO**
任何在使用电脑时迷失时间的人,都知道那种做梦的倾向、实现梦想的冲动,以及错过午餐的习惯。
—蒂姆·伯纳斯-李*

Boost Asio 是一个用于低级 I/O 编程的库。在本章中,你将了解 Boost Asio 的基本网络功能,它使程序能够轻松高效地与网络资源进行交互。不幸的是,从 C++17 开始,标准库中并没有包含网络编程库。因此,Boost Asio 在许多具有网络组件的 C++ 程序中发挥着核心作用。
尽管 Boost Asio 是 C++ 开发者在想要将跨平台、高性能 I/O 融入程序时的主要选择,但它是一个出了名的复杂库。这种复杂性与对低级网络编程的不熟悉相结合,可能会让新手感到过于压倒。如果你觉得本章晦涩难懂,或者如果你不需要关于网络编程的信息,你可以跳过这一章。
注意
Boost Asio 还包含用于与串口、流和一些操作系统特定对象进行 I/O 的功能。事实上,这个名称来源于“异步 I/O”这个短语。欲了解更多信息,请参阅 Boost Asio 文档。
Boost Asio 编程模型
在 Boost 编程模型中,一个 I/O 上下文对象 抽象了处理异步数据处理的操作系统接口。这个对象是 I/O 对象 的注册表,I/O 对象会发起异步操作。每个对象都知道其对应的服务,而上下文对象则在其中进行调解。
注意
所有 Boost Asio 类都出现在 <boost/asio.hpp> 方便的头文件中。
Boost Asio 定义了一个单一的服务对象,boost::asio::io_context。它的构造函数接受一个可选的整数参数,称为并发提示,它表示 io_context 应该允许并发运行的线程数量。例如,在一台八核机器上,你可以按如下方式构造一个 io_context:
boost::asio::io_context io_context{ 8 };
你将把相同的 io_context 对象传递给你的 I/O 对象的构造函数。一旦你设置好所有 I/O 对象,你将调用 io_context 上的 run 方法,它会阻塞直到所有待处理的 I/O 操作完成。
最简单的 I/O 对象之一是 boost::asio::steady_timer,你可以用它来安排任务。它的构造函数接受一个 io_context 对象和一个可选的 std::chrono::time_point 或 std::chrono_duration。例如,下面的代码构造了一个三秒钟后过期的 steady_timer:
boost::asio::steady_timer timer{
io_context, std::chrono::steady_clock::now() + std::chrono::seconds{ 3 }
};
您可以使用阻塞或非阻塞调用等待定时器。要阻塞当前线程,您使用定时器的wait方法。其结果与使用“Chrono”中学到的std::this_thread::sleep_for基本相似,您可以在第 387 页找到相关内容。要进行异步等待,您使用定时器的async_wait方法。这接受一个称为回调的函数对象。操作系统将在线程唤醒时调用该函数对象。由于现代操作系统带来的复杂性,这可能是由于定时器到期或其他原因。
一旦定时器到期,您可以创建另一个定时器,如果您想进行额外的等待。如果您等待一个已经到期的定时器,它将立即返回。这可能不是您打算做的事情,所以确保只在未到期的定时器上等待。
要检查定时器是否已经到期,函数对象必须接受一个boost::system::error_code。error_code类是一个表示操作系统特定错误的简单类。它隐式转换为bool(如果表示错误条件则为true;否则为false)。如果回调的error_code评估为false,则定时器已经到期。
一旦您使用async_wait排队了一个异步操作,您将在您的io_context对象上调用run方法,因为此方法会阻塞,直到所有异步操作完成。
清单 20-1 演示了如何构建和使用用于阻塞和非阻塞等待的定时器。
#include <iostream>
#include <boost/asio.hpp>
#include <chrono>
boost::asio::steady_timer make_timer(boost::asio::io_context& io_context) { ➊
return boost::asio::steady_timer{
io_context,
std::chrono::steady_clock::now() + std::chrono::seconds{ 3 }
};
}
int main() {
boost::asio::io_context io_context; ➋
auto timer1 = make_timer(io_context); ➌
std::cout << "entering steady_timer::wait\n";
timer1.wait(); ➍
std::cout << "exited steady_timer::wait\n";
auto timer2 = make_timer(io_context); ➎
std::cout << "entering steady_timer::async_wait\n";
timer2.async_wait([] (const boost::system::error_code& error) { ➏
if (!error) std::cout << "<<callback function>>\n";
});
std::cout << "exited steady_timer::async_wait\n";
std::cout << "entering io_context::run\n";
io_context.run(); ➐
std::cout << "exited io_context::run\n";
}
-----------------------------------------------------------------------
entering steady_timer::wait
exited steady_timer::wait
entering steady_timer::async_wait
exited steady_timer::async_wait
entering io_context::run
<<callback function>>
exited io_context::run
清单 20-1:使用boost::asio::steady_timer进行同步和异步等待的程序
您定义make_timer函数来构建在三秒后到期的steady_timer。在main中,您初始化程序的io_context,并从make_timer构造第一个定时器。当您在此定时器上调用wait时,线程将在三秒后继续。接下来,您使用make_timer构造另一个定时器,然后使用在定时器到期时打印<<callback_function>>的 lambda 调用async_wait。最后,您在您的io_context上调用run以开始处理操作。
使用 Asio 进行网络编程
Boost Asio 包含用于在几个重要网络协议上执行基于网络的 I/O 的设施。现在您已经了解了io_context的基本用法以及如何排队异步 I/O 操作,您可以探索如何执行更复杂的 I/O 操作。在本节中,您将扩展对等待定时器的了解,并使用 Boost Asio 的网络 I/O 设施。通过本章结束时,您将知道如何构建可以在网络上通信的程序。
互联网协议套件
互联网协议(IP)是跨网络传输数据的主要协议。每个参与者在 IP 网络中被称为主机,每个主机都会获得一个 IP 地址用于标识自己。IP 地址有两种版本:IPv4 和 IPv6。IPv4 地址是 32 位,IPv6 地址是 128 位。
互联网控制消息协议(ICMP)被网络设备用于发送支持 IP 网络运行的信息。ping 和 traceroute 程序使用 ICMP 消息来查询网络。通常,最终用户应用程序不需要直接与 ICMP 交互。
要在 IP 网络中发送数据,通常使用传输控制协议(TCP)或用户数据报协议(UDP)。一般来说,当你需要确保数据到达目的地时,使用 TCP;当你需要确保数据快速传输时,使用 UDP。TCP 是一个面向连接的协议,接收方会确认它已收到目标消息。UDP 是一个简单的无连接协议,没有内建的可靠性。
注意
你可能会想知道在 TCP/UDP 的上下文中,“连接”是什么意思,或者觉得“无连接”协议似乎很荒谬。这里的连接指的是在网络中的两个参与者之间建立一个通道,以保证消息的传输和顺序。这些参与者通过握手建立连接,并且有一种机制相互通知,表示它们想要关闭连接。而在无连接协议中,参与者直接向另一个参与者发送数据包,而不先建立通道。
使用 TCP 和 UDP 时,网络设备通过端口彼此连接。端口是一个范围从 0 到 65,535(2 字节)的整数,指定在特定网络设备上运行的某个服务。通过这种方式,一台设备可以运行多个服务,每个服务可以单独寻址。当一台设备(称为客户端)与另一台设备(称为服务器)建立通信时,客户端指定它想连接的端口。当你将设备的 IP 地址与端口号配对时,结果就叫做套接字。
例如,一个 IP 地址为 10.10.10.100 的设备可以通过将一个 Web 服务器应用程序绑定到端口 80 来提供网页。这会在 10.10.10.100:80 上创建一个服务器套接字。接着,一个 IP 地址为 10.10.10.200 的设备启动一个 Web 浏览器,打开一个“随机高端口”,例如 55123。这会在 10.10.10.200:55123 上创建一个客户端套接字。然后,客户端通过在客户端套接字和服务器套接字之间创建 TCP 连接来连接到服务器。同时,其他许多进程可能在任何一台或两台设备上运行,并且有许多其他网络连接同时存在。
互联网分配号码管理局(IANA)维护着一个分配号码的列表,用于标准化某些类型的服务所使用的端口(该列表可以在 www.iana.org/ 上找到)。表 20-1 提供了这个列表中的一些常用协议。
表 20-1: IANA 分配的知名协议
| 端口 | TCP | UDP | 关键词 | 描述 |
|---|---|---|---|---|
| 7 | ✓ | ✓ | echo | 回显协议 |
| 13 | ✓ | ✓ | daytime | 日间协议 |
| 21 | ✓ | ftp | 文件传输协议 | |
| 22 | ✓ | ssh | 安全外壳协议 | |
| 23 | ✓ | telnet | Telnet 协议 | |
| 25 | ✓ | smtp | 简单邮件传输协议 | |
| 53 | ✓ | ✓ | domain | 域名系统 |
| 80 | ✓ | http | 超文本传输协议 | |
| 110 | ✓ | pop3 | 邮局协议 | |
| 123 | ✓ | ntp | 网络时间协议 | |
| 143 | ✓ | imap | 互联网邮件访问协议 | |
| 179 | ✓ | bgp | 边界网关协议 | |
| 194 | ✓ | irc | 互联网中继聊天 | |
| 443 | ✓ | https | 超文本传输协议(安全) |
Boost Asio 支持通过 ICMP、TCP 和 UDP 进行网络 I/O。为了简洁起见,本章仅讨论 TCP,因为这三种协议中所涉及的 Asio 类非常相似。
注意
如果你不熟悉网络协议,Charles M. Kozierok 的《TCP/IP 指南》是一本权威的参考书。*
主机名解析
当客户端想要连接到服务器时,它需要服务器的 IP 地址。在某些情况下,客户端可能已经有了这个信息。而在其他情况下,客户端可能只有一个服务名称。将服务名称转换为 IP 地址的过程称为 主机名解析。Boost Asio 包含 boost::asio::ip::tcp::resolver 类来执行主机名解析。要构造解析器,你只需要传递一个 io_context 实例作为唯一的构造参数,示例如下:
boost::asio::ip::tcp::resolver my_resolver{ my_io_context };
要执行主机名解析,你可以使用 resolve 方法,该方法接受至少两个 string_view 类型的参数:主机名和服务。你可以为服务提供一个关键字或端口号(有关一些示例关键字,请参阅 表 20-1)。resolve 方法返回一组 boost::asio::ip::tcp::resolver::basic_resolver_entry 对象,这些对象提供了几个有用的方法:
-
endpoint获取 IP 地址和端口。 -
host_name获取主机名。 -
service_name获取与该端口关联的服务名称。
如果解析失败,resolve 会抛出一个 boost::system::system_error。或者,你可以传递一个 boost::system::error_code 引用,代替抛出异常,将错误信息传递给它。例如,示例 20-2 使用 Boost Asio 确定 No Starch Press 网站服务器的 IP 地址和端口。
#include <iostream>
#include <boost/asio.hpp>
int main() {
boost::asio::io_context io_context; ➊
boost::asio::ip::tcp::resolver resolver{ io_context }; ➋
boost::system::error_code ec;
for(auto&& result : resolver.resolve("www.nostarch.com", "http", ec)) { ➌
std::cout << result.service_name() << " " ➍
<< result.host_name() << " " ➎
<< result.endpoint() ➏
<< std::endl;
}
if(ec) std::cout << "Error code: " << ec << std::endl; ➐
}
-----------------------------------------------------------------------
http [www.nostarch.com](http://www.nostarch.com) 104.20.209.3:80
http [www.nostarch.com](http://www.nostarch.com) 104.20.208.3:80
示例 20-2:使用 Boost Asio 阻塞主机名解析
注意
你的结果可能会根据 No Starch Press 网站服务器在 IP 地址空间中的位置而有所不同。
你初始化一个io_context ➊和一个boost::asio::ip::tcp::resolver ➋。在基于范围的for循环内,你迭代每个result ➌并提取service_name ➍、host_name ➎和endpoint ➏。如果resolve遇到错误,你将其打印到标准输出 ➐。
你可以使用async_resolve方法执行异步主机名解析。与resolve一样,你将主机名和服务作为前两个参数传递。此外,你提供一个回调函数对象,接受两个参数:system_error_code和一个basic_resolver_entry对象的范围。清单 20-3 展示了如何将清单 20-2 重构为使用异步主机名解析。
#include <iostream>
#include <boost/asio.hpp>
int main() {
boost::asio::io_context io_context;
boost::asio::ip::tcp::resolver resolver{ io_context };
resolver.async_resolve("www.nostarch.com", "http", ➊
[](boost::system::error_code ec, const auto& results) { ➋
if (ec) { ➌
std::cerr << "Error:" << ec << std::endl;
return; ➍
}
for (auto&& result : results) { ➎
std::cout << result.service_name() << " "
<< result.host_name() << " "
<< result.endpoint() << " "
<< std::endl; ➏
}
}
);
io_context.run(); ➐
}
-----------------------------------------------------------------------
http [www.nostarch.com](http://www.nostarch.com) 104.20.209.3:80
http [www.nostarch.com](http://www.nostarch.com) 104.20.208.3:80
清单 20-3:重构清单 20-2 以使用async_resolve
设置与清单 20-2 相同,直到你在解析器上调用async_resolve ➊。你传递与之前相同的主机名和服务,但你添加了一个回调参数,该参数接受必需的参数 ➋。在回调 lambda 的主体中,你检查是否存在错误条件 ➌。若存在错误,你打印一个友好的错误信息并return ➍。在没有错误的情况下,你像之前一样迭代结果 ➎,打印service_name、host_name和endpoint ➏。与定时器一样,你需要在io_context上调用run,以便让异步操作有机会完成 ➐。
连接中
一旦通过主机名解析或自行构建的方式获取到端点范围,你就准备好进行连接了。
首先,你需要一个boost::asio::ip::tcp::socket,它是一个抽象操作系统底层套接字的类,用于在 Asio 中使用。套接字接受一个io_context作为参数。
第二步,你需要调用boost::asio::connect函数,该函数接受一个表示你想连接的端点的socket作为第一个参数,接受一个endpoint范围作为第二个参数。你可以提供一个error_code引用作为可选的第三个参数;否则,connect会在出现错误时抛出system_error异常。如果成功,connect会返回一个单一的endpoint,即成功连接的输入范围中的endpoint。此时,socket对象表示系统环境中的一个真实套接字。
清单 20-4 展示了如何连接到 No Starch Press 的网络服务器。
#include <iostream>
#include <boost/asio.hpp>
int main() {
boost::asio::io_context io_context;
boost::asio::ip::tcp::resolver resolver{ io_context }; ➊
boost::asio::ip::tcp::socket socket{ io_context }; ➊
try {
auto endpoints = resolver.resolve("www.nostarch.com", "http"); ➌
const auto connected_endpoint = boost::asio::connect(socket, endpoints); ➍
std::cout << connected_endpoint; ➎
} catch(boost::system::system_error& se) {
std::cerr << "Error: " << se.what() << std::endl; ➏
}
}
-----------------------------------------------------------------------
104.20.209.3:80 ➎
清单 20-4:连接到 No Starch 网站服务器
你构建一个resolver ➊,如同在 Listing 20-3 中所示。此外,你使用相同的io_context初始化一个socket ➋。接下来,你调用resolve方法以获取与【www.nostarch.com】(http://www.nostarch.com)在端口 80 上关联的每个endpoint ➌。回想一下,每个endpoint都是一个 IP 地址和与所解析的主机对应的端口。在这种情况下,resolve使用域名系统确定【www.nostarch.com】(http://www.nostarch.com)在端口 80 上的 IP 地址是 104.20.209.3。然后,你使用socket和endpoint调用connect ➍,它返回connect成功连接的endpoint ➎。如果发生错误,resolve或connect将抛出异常,你将捕获该异常并将其打印到 stderr ➏。
你也可以使用boost::asio::async_connect进行异步连接,它接受与connect相同的两个参数:一个socket和一个endpoint范围。第三个参数是一个函数对象,充当回调,它必须接受一个error_code作为第一个参数,endpoint作为第二个参数。Listing 20-5 展示了如何进行异步连接。
#include <iostream>
#include <boost/asio.hpp>
int main() {
boost::asio::io_context io_context;
boost::asio::ip::tcp::resolver resolver{ io_context };
boost::asio::ip::tcp::socket socket{ io_context };
boost::asio::async_connect(socket, ➊
resolver.resolve("www.nostarch.com", "http"), ➋
[] (boost::system::error_code ec, const auto& endpoint){ ➌
std::cout << endpoint; ➍
});
io_context.run(); ➎
}
-----------------------------------------------------------------------
104.20.209.3:80 ➍
Listing 20-5: 异步连接到 No Starch web 服务器
配置与 Listing 20-4 中的完全相同,只不过你将connect替换为async_connect,并传入相同的第一个➊和第二个➋参数。第三个参数是你的回调函数对象➌,在其中你将endpoint打印到 stdout ➍。像所有异步 Asio 程序一样,你需要对io_context调用run ➎。
缓冲区
Boost Asio 提供了几个缓冲区类。缓冲区(或数据缓冲区)是存储临时数据的内存。Boost Asio 缓冲区类形成了所有 I/O 操作的接口。在你进行任何网络连接操作之前,你需要一个用于读取和写入数据的接口。为此,你只需要三种缓冲区类型:
-
boost::asio::const_buffer持有一个缓冲区,一旦构造完成,就无法修改。 -
boost::asio::mutable_buffer持有一个可以在构造后修改的缓冲区。 -
boost::asio::streambuf持有一个基于std::streambuf的自动可调整大小的缓冲区。
所有三个缓冲区类提供了两个重要方法来访问其底层数据:data和size。
mutable_buffer和const_buffer类的data方法返回指向底层数据序列中第一个元素的指针,而它们的size方法返回该序列中元素的数量。这些元素是连续的。两个缓冲区都提供默认构造函数,初始化为空缓冲区,正如 Listing 20-6 所示。
#include <boost/asio.hpp>
TEST_CASE("const_buffer default constructor") {
boost::asio::const_buffer cb; ➊
REQUIRE(cb.size() == 0); ➋
}
TEST_CASE("mutable_buffer default constructor") {
boost::asio::mutable_buffer mb; ➌
REQUIRE(mb.size() == 0); ➍
}
Listing 20-6: 默认构造const_buffer和mutable_buffer生成空缓冲区。
使用默认构造函数➊➌,你构建了空的缓冲区,其size为零➋➍。
mutable_buffer 和 const_buffer 都提供接受 void* 和 size_t 的构造函数,这些构造函数对应于你要封装的数据。请注意,这些构造函数并不拥有指向的内存,因此你必须确保该内存的存储周期至少与所构造的缓冲区的生命周期一样长。这是一个设计决策,给你作为 Boost Asio 用户提供最大灵活性。不幸的是,它也可能导致一些棘手的错误。未能正确管理缓冲区及其指向的对象的生命周期将导致未定义行为。
列表 20-7 演示了如何使用基于指针的构造函数构造缓冲区。
#include <boost/asio.hpp>
#include <string>
TEST_CASE("const_buffer constructor") {
boost::asio::const_buffer cb{ "Blessed are the cheesemakers.", 7 }; ➊
REQUIRE(cb.size() == 7); ➋
REQUIRE(*static_cast<const char*>(cb.data()) == 'B'); ➌
}
TEST_CASE("mutable_buffer constructor") {
std::string proposition{ "Charity for an ex-leper?" };
boost::asio::mutable_buffer mb{ proposition.data(), proposition.size() }; ➍
REQUIRE(mb.data() == proposition.data()); ➎
REQUIRE(mb.size() == proposition.size()); ➏
}
列表 20-7:使用基于指针的构造函数构造 const_buffer 和 mutable_buffer
在第一次测试中,你使用 C 风格字符串和固定长度 7 ➊ 来构造一个 const_buffer。这个固定长度小于字符串字面量 Blessed are the cheesemakers. 的长度,因此这个缓冲区仅引用 Blessed 而不是整个字符串。这说明你可以选择数组的一个子集(就像你在“字符串视图”一节中学习的 std::string_view,在第 500 页)。得到的缓冲区大小为 7 ➋,如果你将 data 指针转换为 const char*,你会发现它指向你的 C 风格字符串中的字符 B ➌。
在第二次测试中,你通过在缓冲区的构造函数中调用 string 的 data 和 size 成员来构造一个 mutable_buffer ➍。得到的缓冲区的 data ➎ 和 size ➏ 方法返回与原始 string 相同的数据。
boost::asio::streambuf 类接受两个可选的构造函数参数:一个 size_t 类型的最大大小和一个分配器。默认情况下,最大大小为 std::numeric_limits<std::size_t>,而分配器类似于标准库容器的默认分配器。streambuf 输入序列的初始大小始终为零,如列表 20-8 所示。
#include <boost/asio.hpp>
TEST_CASE("streambuf constructor") {
boost::asio::streambuf sb; ➊
REQUIRE(sb.size() == 0); ➋
}
列表 20-8:默认构造 streambuf
你默认构造了一个 streambuf ➊,当你调用它的 size 方法时,它返回 0 ➋。
你可以将 streambuf 的指针传递给 std::istream 或 std::ostream 的构造函数。回想一下在“流类”一节中,第 524 页 提到过,这些是 basic_istream 和 basic_ostream 的特化版本,用于向底层同步或源暴露流操作。列表 20-9 演示了如何使用这些类向 streambuf 写入数据并随后读取数据。
TEST_CASE("streambuf input/output") {
boost::asio::streambuf sb; ➊
std::ostream os{ &sb }; ➋
os << "Welease Wodger!"; ➌
std::istream is{ &sb }; ➍
std::string command; ➎
is >> command; ➏
REQUIRE(command == "Welease"); ➐
}
列表 20-9:向 streambuf 写入数据并读取数据
你再次构造一个空的 streambuf ➊,并将其地址传递给 ostream 的构造函数 ➋。然后,你将字符串 Welease Wodger! 写入 ostream,这会将字符串写入底层的 streambuf ➌。
接下来,你再次使用 streambuf 的地址来创建一个 istream ➍。然后,你创建一个 string ➎ 并将 istream 写入该 string ➏。回想一下在 第 529 页 中的“基本类型的特殊格式化”部分,该操作将跳过任何前导空格,然后读取接下来的字符串直到下一个空格。这会得到字符串的第一个单词 Welease ➐。
Boost Asio 还提供了方便的函数模板 boost::asio::buffer,该模板接受一个 std::array 或 std::vector 的 POD 元素,或者一个 std::string。例如,你可以使用以下构造方法来创建一个由 std::string 支持的 mutable_buffer,如 Listing 20-7 中所示:
std::string proposition{ "Charity for an ex-leper?" };
auto mb = boost::asio::buffer(proposition);
buffer 模板是特化过的,因此如果你提供一个 const 参数,它将返回一个 const_buffer。换句话说,要将 proposition 转换为 const_buffer,只需将其设置为 const 即可:
const std::string proposition{ "Charity for an ex-leper?" };
auto cb = boost::asio::buffer(proposition);
你现在已经创建了一个 const_buffer cb。
此外,你还可以创建一个动态缓冲区,这是一个由 std::string 或 std::vector 支持的动态可调整大小的缓冲区。你可以使用 boost::asio::dynamic_buffer 函数模板来创建该缓冲区,传入 string 或 vector,并根据情况返回 boost::asio::dynamic_string_buffer 或 boost::asio::dynamic_vector_buffer。例如,你可以使用以下构造方法创建一个动态缓冲区:
std::string proposition{ "Charity for an ex-leper?" };
auto db = boost::asio::dynamic_buffer(proposition);
尽管动态缓冲区是动态可调整大小的,但请记住,vector 和 string 类使用分配器,而分配操作可能相对较慢。因此,如果你知道要写入缓冲区的数据量,使用非动态缓冲区可能会带来更好的性能。像往常一样,测量和实验将帮助你决定采取哪种方法。
使用缓冲区读取和写入数据
通过掌握如何使用缓冲区存储和检索数据的知识,你可以学习如何从套接字中提取数据。你可以使用内置的 Boost Asio 函数将数据从活动的 socket 对象读取到缓冲区对象中。对于阻塞读取,Boost Asio 提供了三种函数:
-
boost::asio::read尝试读取固定大小的数据块。 -
boost::asio::read_at尝试从一个偏移位置开始读取固定大小的数据块。 -
boost::asio::read_until尝试读取直到分隔符、正则表达式或任意谓词匹配为止。
这三种方法都将 socket 作为第一个参数,将缓冲区对象作为第二个参数。其余参数是可选的,具体取决于你使用的是哪种函数:
-
完成条件 是一个函数对象,它接受一个
error_code和一个size_t参数。如果 Asio 函数遇到错误,error_code将被设置,而size_t参数表示迄今为止已传输的字节数。该函数对象返回一个size_t,对应剩余要传输的字节数,如果操作已完成,则返回 0。 -
匹配条件 是一个函数对象,接受由开始和结束迭代器指定的范围。它必须返回一个
std::pair,其中第一个元素是指示下一个匹配尝试起始点的迭代器,第二个元素是bool,表示该范围是否包含匹配项。 -
boost::system::error_code引用,函数将在遇到错误条件时设置此值。
表 20-2 列出了调用读取函数的多种方式。
表 20-2: read、read_at 和 read_until 的参数
| 调用 | 描述 |
|---|---|
read(s, b, [cmp], [ec]) |
从socket s 读取一定数量的数据到可变缓冲区 b,依据完成条件 cmp。如果遇到错误条件,则设置error_code ec;否则,抛出system_error。 |
read_at(s, off, b, [cmp], [ec]) |
从socket s 开始,按size_t偏移量 off,从某个位置读取一定数量的数据到可变缓冲区 b,依据完成条件 cmp。如果遇到错误条件,则设置error_code ec;否则,抛出system_error。 |
read_until(s, b, x, [ec]) |
从socket s 读取数据到可变缓冲区 b,直到满足由 x 表示的条件,x 可以是以下之一:char、string_view、boost::regex,或匹配条件。如果遇到错误条件,则设置error_code ec;否则,抛出system_error。 |
你也可以从缓冲区向活动的socket对象写入数据。对于阻塞式写入,Boost Asio 提供了两个函数:
-
boost::asio::write尝试写入固定大小的数据块。 -
boost::asio::write_at尝试从偏移量开始写入固定大小的数据块。
表 20-3 展示了如何调用这两个方法。它们的参数与读取方法的参数类似。
表 20-3: write 和 write_at 的参数
| 调用 | 描述 |
|---|---|
write(s, b, [cmp], [ec]) |
从const缓冲区 b,将一定数量的数据写入socket s,依据完成条件 cmp。如果遇到错误条件,则设置error_code ec;否则,抛出system_error。 |
write_at(s, off, b, [cmp], [ec]) |
从const缓冲区 b,按size_t偏移量 off 开始,将一定数量的数据写入socket s,依据完成条件 cmp。如果遇到错误条件,则设置error_code ec;否则,抛出system_error。 |
注意
调用读取和写入函数有很多种排列方式。在将 Boost Asio 集成到代码中时,务必仔细阅读文档。
超文本传输协议 (HTTP)
HTTP 是支撑 web 的 30 年历史的协议。尽管它是一个非常复杂的协议,涉及到网络的使用,但它的普遍性使它成为最相关的选择之一。在接下来的部分中,你将使用 Boost Asio 发出非常简单的 HTTP 请求。并不严格要求你对 HTTP 有扎实的基础,因此你可以在首次阅读时跳过这一部分。不过,这里提供的信息为下一部分中的示例增添了一些背景,并提供了进一步学习的参考资料。
HTTP 会话有两个参与方:客户端和服务器。HTTP 客户端通过 TCP 发送一个纯文本请求,其中包含一行或多行,由回车符和换行符(“CR-LF 换行符”)分隔。
第一行是请求行,其中包含三个标记:HTTP 方法、统一资源定位符(URL)和请求的 HTTP 版本。例如,如果客户端想要获取名为 index.htm 的文件,状态行可能是 GET /index.htm HTTP/1.1。
请求行之后紧接着的是一个或多个 头部,它们定义了 HTTP 事务的参数。每个头部包含一个键和值。键必须由字母数字字符和短横线组成。键和值之间用冒号和空格分隔。CR-LF 换行符标识头部的结束。以下头部在请求中尤为常见:
-
Host指定请求的服务的域名。你可以选择性地包括端口。例如,Host: [www.google.com](http://www.google.com)指定 www.google.com 作为请求服务的主机。 -
Accept指定响应中可接受的媒体类型,以 MIME 格式表示。例如,Accept: text/plain指定请求者可以处理纯文本。 -
Accept-Language指定响应可接受的人类语言。例如,Accept-Language: en-US指定请求者可以处理美式英语。 -
Accept-Encoding指定响应可接受的编码方式。例如,Accept-Encoding: identity指定请求者可以处理没有任何编码的内容。 -
Connection指定当前连接的控制选项。例如,Connection: close指定响应完成后将关闭连接。
你通过额外的 CR-LF 换行符来终止头部。对于某些类型的 HTTP 请求,你还会在头部之后包括一个主体。如果这样做,你还需要包含 Content-Length 和 Content-Type 头部。Content-Length 值指定请求主体的字节长度,而 Content-Type 值指定主体的 MIME 格式。
HTTP 响应的第一行是 状态行,其中包括响应的 HTTP 版本、状态码和原因短语。例如,状态行 HTTP/1.1 200 OK 表示请求成功(“OK”)。状态码始终是三位数字。首位数字表示状态码的类别:
1**(信息性) 请求已接收。
2**(成功) 请求已接收并被接受。
3**(重定向) 需要进一步操作。
4**(客户端错误) 请求有误。
5**(服务器错误) 请求似乎没问题,但服务器遇到内部错误。
在状态行之后,响应包含任意数量的头部,格式与请求相同。许多相同的请求头也常见于响应头中。例如,如果 HTTP 响应包含主体,响应头将包括 Content-Length 和 Content-Type。
如果你需要编写 HTTP 应用程序,绝对应该参考 Boost Beast 库,它提供高性能、低级的 HTTP 和 WebSocket 功能。它建立在 Asio 之上,并与其无缝协作。
注意
有关 HTTP 及其安全性问题的优秀处理,请参考 《The Tangled Web: A Guide to Securing Modern Web Applications》 by Michal Zalewski。有关详细内容,请参考互联网工程任务组(IETF)的 RFCs 7230、7231、7232、7233、7234 和 7235。
实现一个简单的 Boost Asio HTTP 客户端
在本节中,你将实现一个(非常)简单的 HTTP 客户端。你将构建一个 HTTP 请求,解析端点,连接到 Web 服务器,写入请求并读取响应。Listing 20-10 展示了一种可能的实现方式。
#include <boost/asio.hpp>
#include <iostream>
#include <istream>
#include <ostream>
#include <string>
std::string request(std::string host, boost::asio::io_context& io_context) { ➊
std::stringstream request_stream;
request_stream << "GET / HTTP/1.1\r\n"
"Host: " << host << "\r\n"
"Accept: text/html\r\n"
"Accept-Language: en-us\r\n"
"Accept-Encoding: identity\r\n"
"Connection: close\r\n\r\n";
const auto request = request_stream.str(); ➋
boost::asio::ip::tcp::resolver resolver{ io_context };
const auto endpoints = resolver.resolve(host, "http"); ➌
boost::asio::ip::tcp::socket socket{ io_context };
const auto connected_endpoint = boost::asio::connect(socket, endpoints); ➍
boost::asio::write(socket, boost::asio::buffer(request)); ➎
std::string response;
boost::system::error_code ec;
boost::asio::read(socket, boost::asio::dynamic_buffer(response), ec); ➏
if (ec && ec.value() != 2) throw boost::system::system_error{ ec }; ➐
return response;
}
int main() {
boost::asio::io_context io_context;
try {
const auto response = request("www.arcyber.army.mil", io_context); ➑
std::cout << response << "\n"; ➒
} catch(boost::system::system_error& se) {
std::cerr << "Error: " << se.what() << std::endl;
}
}
-----------------------------------------------------------------------
HTTP/1.1 200 OK
Pragma: no-cache
Content-Type: text/html; charset=utf-8
X-UA-Compatible: IE=edge
pw_value: 3ce3af822980b849665e8c5400e1b45b
Access-Control-Allow-Origin: *
X-Powered-By:
Server:
X-ASPNET-VERSION:
X-FRAME-OPTIONS: SAMEORIGIN
Content-Length: 76199
Cache-Control: private, no-cache
Expires: Mon, 22 Oct 2018 14:21:09 GMT
Date: Mon, 22 Oct 2018 14:21:09 GMT
Connection: close
<!DOCTYPE html>
<html lang="en-US">
<head id="Head">
--snip--
</body>
</html>
Listing 20-10:完成对美国陆军网络指挥部 Web 服务器的简单请求
你首先定义一个 request 函数,它接受一个 host 和一个 io_context,并返回一个 HTTP 响应 ➊。首先,你使用 std::stringstream 来构建一个包含 HTTP 请求的 std::string ➋。接着,你使用 boost::asio::ip::tcp::resolver 解析 host ➌,并将 boost::asio::ip::tcp::socket 连接到结果端点范围 ➍。(这与 Listing 20-4 中的方法相匹配。)
然后,你向你已连接的服务器发送 HTTP 请求。你使用 boost::asio::write,传入已连接的 socket 和你的 request。因为 write 接受 Asio 缓冲区,你使用 boost::asio::buffer 从你的请求(它是一个 std::string)创建一个 mutable_buffer ➎。
接下来,你从服务器读取 HTTP 响应。因为你事先不知道响应的长度,所以你创建一个名为 response 的 std::string 来接收响应。最终,你将使用它来支持一个动态缓冲区。为了简化,HTTP 请求包含一个 Connection: close 头部,它会导致服务器在发送响应后立即关闭连接。这将导致 Asio 返回一个“文件结束”错误代码(值为 2)。因为你预期这种行为,你声明一个 boost::system::error_code 来接收该错误。
接下来,你调用boost::asio::read,传入已连接的socket、一个将接收响应的动态缓冲区以及error_condition ➏。你使用boost::asio_dynamic_buffer从response构造动态缓冲区。read返回后,立即检查是否有其他类型的error_condition,例如文件结束错误(此时会抛出异常) ➐。否则,返回response。
在main函数中,你调用request函数,传入www.arcyber.army.mil主机和一个io_context对象 ➑。最后,你将响应打印到标准输出 ➒。
异步读取与写入
你也可以使用 Boost Asio 进行异步读写。相应的异步函数与它们的阻塞对应函数类似。对于异步读取,Boost Asio 提供了三个函数:
-
boost::asio::async_read尝试读取固定大小的数据块。 -
boost::asio::async_read_at尝试从一个偏移量开始读取固定大小的数据块。 -
boost::asio::async_read_until尝试读取直到遇到分隔符、正则表达式或任意条件为止。
Boost Asio 还提供了两个异步写入函数:
-
boost::asio::async_write尝试写入固定大小的数据块。 -
boost::asio::async_write_at尝试从一个偏移量开始写入固定大小的数据块。
这五个异步函数接受与它们的阻塞函数相同的参数,唯一不同的是它们的最后一个参数总是一个回调函数对象,该对象接受两个参数:一个boost::system::error_code表示函数是否遇到错误,以及一个size_t表示传输的字节数。对于异步的write函数,你需要判断 Asio 是否写入了整个负载。因为这些调用是异步的,所以你的线程在等待 I/O 完成时不会被阻塞。相反,操作系统会在 I/O 请求的某个部分完成时回调你的线程。
由于回调的第二个参数是一个size_t,表示已传输的字节数,你可以通过计算来确定是否还有数据需要写入。如果有,你必须通过传递剩余数据来调用另一个异步写入函数。
示例 20-11 包含了示例 20-10 的一个异步版本。请注意,使用异步函数稍微复杂一些,但它有一个一致的模式,通过回调和处理程序贯穿整个请求的生命周期。
#include <boost/asio.hpp>
#include <iostream>
#include <string>
#include <sstream>
using ResolveResult = boost::asio::ip::tcp::resolver::results_type;
using Endpoint = boost::asio::ip::tcp::endpoint;
struct Request {
explicit Request(boost::asio::io_context& io_context, std::string host)
: resolver{ io_context },
socket{ io_context },
host{ std::move(host) } { ➊
std::stringstream request_stream;
request_stream << "GET / HTTP/1.1\r\n"
"Host: " << this->host << "\r\n"
"Accept: text/plain\r\n"
"Accept-Language: en-us\r\n"
"Accept-Encoding: identity\r\n"
"Connection: close\r\n"
"User-Agent: C++ Crash Course Client\r\n\r\n";
request = request_stream.str(); ➋
resolver.async_resolve(this->host, "http",
[this] (boost::system::error_code ec, const ResolveResult& results) {
resolution_handler(ec, results); ➌
});
}
void resolution_handler(boost::system::error_code ec,
const ResolveResult& results) {
if (ec) { ➍
std::cerr << "Error resolving " << host << ": " << ec << std::endl;
return;
}
boost::asio::async_connect(socket, results,
[this] (boost::system::error_code ec, const Endpoint& endpoint){
connection_handler(ec, endpoint); ➎
});
}
void connection_handler(boost::system::error_code ec,
const Endpoint& endpoint) { ➏
if (ec) {
std::cerr << "Error connecting to " << host << ": "
<< ec.message() << std::endl;
return;
}
boost::asio::async_write(socket, boost::asio::buffer(request),
[this] (boost::system::error_code ec, size_t transferred){
write_handler(ec, transferred);
});
}
void write_handler(boost::system::error_code ec, size_t transferred) { ➐
if (ec) {
std::cerr << "Error writing to " << host << ": " << ec.message()
<< std::endl;
} else if (request.size() != transferred) {
request.erase(0, transferred);
boost::asio::async_write(socket, boost::asio::buffer(request),
[this] (boost::system::error_code ec,
size_t transferred){
write_handler(ec, transferred);
});
} else {
boost::asio::async_read(socket, boost::asio::dynamic_buffer(response),
[this] (boost::system::error_code ec,
size_t transferred){
read_handler(ec, transferred);
});
}
}
void read_handler(boost::system::error_code ec, size_t transferred) { ➑
if (ec && ec.value() != 2)
std::cerr << "Error reading from " << host << ": "
<< ec.message() << std::endl;
}
const std::string& get_response() const noexcept {
return response;
}
private:
boost::asio::ip::tcp::resolver resolver;
boost::asio::ip::tcp::socket socket;
std::string request, response;
const std::string host;
};
int main() {
boost::asio::io_context io_context;
Request request{ io_context, "www.arcyber.army.mil" }; ➒
io_context.run(); ➓
std::cout << request.get_response();
}
-----------------------------------------------------------------------
HTTP/1.1 200 OK
Pragma: no-cache
Content-Type: text/html; charset=utf-8
X-UA-Compatible: IE=edge
pw_value: 3ce3af822980b849665e8c5400e1b45b
Access-Control-Allow-Origin: *
X-Powered-By:
Server:
X-ASPNET-VERSION:
X-FRAME-OPTIONS: SAMEORIGIN
Content-Length: 76199
Cache-Control: private, no-cache
Expires: Mon, 22 Oct 2018 14:21:09 GMT
Date: Mon, 22 Oct 2018 14:21:09 GMT
Connection: close
<!DOCTYPE html>
<html lang="en-US">
<head id="Head">
--snip--
</body>
</html>
示例 20-11:一个示例 20-9 的异步重构
首先你声明一个 Request 类来处理 Web 请求。它有一个构造函数,接受一个 io_context 和一个包含你要连接的主机的 string ➊。就像在 示例 20-9 中一样,你使用 std::stringstream 创建一个 HTTP GET 请求,并将结果 string 保存在 request 字段中 ➋。接下来,你使用 async_resolve 请求与所请求的 host 对应的端点。在回调函数中,你调用当前 Request 的 resolution_handler 方法 ➌。
resolution_handler 接收来自 async_resolve 的回调。它首先检查是否有错误条件,如果发现错误,则将错误输出到 stderr 并返回 ➍。如果 async_resolve 没有返回错误,resolution_handler 会使用 results 变量中包含的端点调用 async_connect。它还会传入当前 Request 的 socket 字段,async_connect 将在其中创建连接。最后,它会将一个连接回调作为第三个参数传递。在回调函数中,你调用当前请求的 connection_handler 方法 ➎。
connection_handler ➏ 的模式与 resolution_handler 方法类似。它检查是否存在错误条件,如果有,就将错误输出到 stderr 并返回;否则,它会通过调用 async_write 来继续处理请求,async_write 接受三个参数:活动的 socket、一个可变缓冲区包装的 request 和一个回调函数。回调函数将调用当前请求的 write_handler 方法。
你在这些处理函数中看到了模式吗?write_handler ➐ 会检查是否有错误,然后继续判断整个请求是否已经发送。如果没有,你仍然需要写入一些请求内容,因此你需要相应地调整 request 并再次调用 async_write。如果 async_write 已经将整个请求写入了 socket,那么就该读取响应了。为此,你调用 async_read,使用你的 socket、一个动态缓冲区来包装 response 字段,并传入一个回调函数,该函数会在当前请求上调用 read_handler 方法。
read_handler ➑ 首先检查是否有错误。由于你的请求使用了 Connection: close 头部,你预计会遇到文件结束错误(错误码为 2),就像在 示例 20-10 中一样,因此你忽略它。如果遇到其他类型的错误,你会将错误打印到 stderr 并返回。此时,你的请求已经完成。(呼,终于结束了。)
在 main 中,你声明了一个 io_context 并初始化一个 Request 对象,目标是 www.arcyber.army.mil ➒。由于你使用了异步函数,因此你在 io_context 上调用 run 方法 ➓。当 io_context 返回时,你就知道没有异步操作在等待,因此你将当前 Request 对象中的响应内容打印到标准输出(stdout)。
服务
在 Boost Asio 上构建一个服务器本质上与构建客户端类似。为了接受 TCP 连接,你使用 boost::asio::ip::tcp::acceptor 类,该类的构造函数唯一的参数是 boost::asio::io_context 对象。
使用阻塞方式接受 TCP 连接时,你使用 acceptor 对象的 accept 方法,该方法接收一个 boost::asio::ip::tcp::socket 引用,该引用将保存客户端的套接字,另有一个可选的 boost::error_code 引用,用来保存任何发生的错误条件。如果你没有提供 boost::error_code,且发生了错误,accept 会抛出一个 boost::system_error 异常。一旦 accept 返回且没有错误,你可以使用传入的 socket 来进行读写,使用之前在处理客户端时使用的相同读写方法。
例如,示例 20-12 演示了如何构建一个回显服务器,它接收一条消息并将其大写后发送回客户端。
#include <iostream>
#include <string>
#include <boost/asio.hpp>
#include <boost/algorithm/string/case_conv.hpp>
using namespace boost::asio;
void handle(ip::tcp::socket& socket) { ➊
boost::system::error_code ec;
std::string message;
do {
boost::asio::read_until(socket, dynamic_buffer(message), "\n"); ➋
boost::algorithm::to_upper(message); ➌
boost::asio::write(socket, buffer(message), ec); ➍
if (message == "\n") return; ➎
message.clear();
} while(!ec); ➏
}
int main() {
try {
io_context io_context;
ip::tcp::acceptor acceptor{ io_context,
ip::tcp::endpoint(ip::tcp::v4(), 1895) }; ➐
while (true) {
ip::tcp::socket socket{ io_context };
acceptor.accept(socket); ➑
handle(socket); ➒
}
} catch (std::exception& e) {
std::cerr << e.what() << std::endl;
}
}
示例 20-12:一个大写回显服务器
你声明了一个接受 socket 引用的 handle 函数,该引用对应客户端,并处理来自客户端的消息 ➊。在一个 do-while 循环中,你从客户端读取一行文本到一个名为 message 的 string 变量中 ➋,然后使用 示例 15-31 中展示的 to_upper 函数将其转换为大写 ➌,并将其写回客户端 ➍。如果客户端发送了一个空行,你会退出 handle ➎;否则,如果没有发生错误条件,你会清空消息内容并继续循环 ➏。
在 main 中,你初始化了一个 io_context 和一个 acceptor,使程序绑定到 localhost:1895 套接字 ➐。在一个无限循环中,你创建一个 socket 并在 acceptor 上调用 accept ➑。只要没有抛出异常,socket 就代表了一个新的客户端,你可以将这个 socket 传递给 handle 来处理请求 ➒。
注意
在示例 20-12 中,选择监听端口 1895。这个选择在技术上并不重要,只要你电脑上没有其他程序正在使用这个端口。然而,关于如何决定程序监听的端口,有一些指导原则。IANA 维护了一个注册端口的列表,地址是 www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.txt ,你可能想避免使用其中的端口。另外,现代操作系统通常要求程序拥有提升的权限,才能绑定到端口值为 1023 或以下的 系统端口。* 端口 1024 到 49151 通常不需要提升权限,称为* 用户端口。* 端口 49152 到 65535 是* 动态/私有端口,* 因为这些端口通常不会被 IANA 注册,因此使用它们一般是安全的。*
要与 Listing 20-12 中的服务器交互,你可以使用GNU Netcat,这是一个网络工具,允许你创建入站和出站的 TCP 和 UDP 连接,并读写数据。如果你使用的是类 Unix 系统,你可能已经安装了它。如果没有,请访问https://nmap.org/ncat/。Listing 20-13 展示了一个连接到大写回显服务器的示例会话。
$ ncat localhost 1895 ➊
The 300 ➋
THE 300
This is Blasphemy! ➋
THIS IS BLASPHEMY!
This is madness! ➋
THIS IS MADNESS!
Madness...? ➋
MADNESS...?
This is Sparta! ➋
THIS IS SPARTA!
➌
Ncat: Broken pipe. ➍
Listing 20-13:使用 Netcat 与大写回显服务器交互
Netcat(ncat)需要两个参数:主机和端口 ➊。启动程序后,每次输入的行都会从服务器返回一个大写结果。当你将文本输入到标准输入(stdin)时,Netcat 将其发送到服务器 ➋,服务器将以大写形式响应。当你发送一个空行 ➌时,服务器终止套接字连接,你将看到Broken pipe ➍。
要使用异步方式接受连接,可以在acceptor上使用async_accept方法,该方法接受一个参数:一个回调对象,该对象接受error_code和socket。如果发生错误,error_code将包含错误信息;否则,socket代表成功连接的客户端。之后,你可以像在阻塞方式中一样使用这个套接字。
异步连接导向型服务器的常见模式是使用std::enable_shared_from_this模板,具体讨论可参见《高级模式》一章中的第 362 页。其思想是为每个连接创建一个会话对象的共享指针。当你在会话对象内注册读取和写入回调时,你会在回调对象中捕获一个指向this的共享指针,这样在 I/O 操作等待期间,会话对象依然存活。一旦没有 I/O 操作待处理,会话对象和所有共享指针一起销毁。Listing 20-14 展示了如何使用异步 I/O 重新实现大写回显服务器。
#include <iostream>
#include <string>
#include <boost/asio.hpp>
#include <boost/algorithm/string/case_conv.hpp>
#include <memory>
using namespace boost::asio;
struct Session : std::enable_shared_from_this<Session> {
explicit Session(ip::tcp::socket socket) : socket{ std::move(socket) } { } ➊
void read() {
async_read_until(socket, dynamic_buffer(message), '\n', ➋
[self=shared_from_this()] (boost::system::error_code ec,
std::size_t length) {
if (ec || self->message == "\n") return; ➌
boost::algorithm::to_upper(self->message);
self->write();
});
}
void write() {
async_write(socket, buffer(message), ➍
[self=shared_from_this()] (boost::system::error_code ec,
std::size_t length) {
if (ec) return; ➎
self->message.clear();
self->read();
});
}
private:
ip::tcp::socket socket;
std::string message;
};
void serve(ip::tcp::acceptor& acceptor) {
acceptor.async_accept(&acceptor {
serve(acceptor); ➐
if (ec) return;
auto session = std::make_shared<Session>(std::move(socket)); ➑
session->read();
});
}
int main() {
try {
io_context io_context;
ip::tcp::acceptor acceptor{ io_context,
ip::tcp::endpoint(ip::tcp::v4(), 1895) };
serve(acceptor);
io_context.run(); ➒
} catch (std::exception& e) {
std::cerr << e.what() << std::endl;
}
}
Listing 20-14:使用异步版本的 Listing 20-12
首先,你需要定义一个Session类来管理连接。在构造函数中,你将对应连接客户端的socket的所有权转移过来,并将其存储为成员 ➊。
接下来,声明一个read方法,它会在socket上调用async_read_until,将数据读取到dynamic_buffer中,直到遇到下一个换行符\n ➋。回调对象使用shared_from_this方法将其捕获为shared_ptr。当回调被触发时,函数检查是否存在错误条件或空行,如果是,返回 ➌。否则,回调会将message转换为大写,并调用write方法。
write方法遵循与read方法类似的模式。它调用async_read,传入socket、message(现在为大写)和回调函数 ➍。在回调函数内,您检查是否存在错误条件,如果有则立即返回➎。否则,您知道 Asio 成功地将大写的message发送到了客户端,因此您调用clear方法来准备处理客户端的下一个消息。接着,您调用read方法,重新开始这个过程。
接下来,您定义一个接受acceptor对象的serve函数。在该函数内,您调用async_accept方法并传入一个回调函数来处理连接➏。回调函数首先使用acceptor再次调用serve,这样程序就可以立即处理新的连接➐。这就是使异步处理在服务器端如此强大的秘密所在:您可以同时处理多个连接,因为运行中的线程无需在处理另一个连接之前服务于一个客户端。接下来,您检查是否存在错误条件,如果有则退出;否则,您创建一个拥有新Session对象的shared_ptr ➑。该Session对象将拥有acceptor为您设置的socket。然后,您在新的Session对象上调用read方法,由于shared_from_this捕获,它会在shared_ptr中创建第二个引用。现在一切准备就绪!一旦由于客户端的空行或某些错误条件导致read和write周期结束,shared_ptr引用会归零,Session对象将被销毁。
最后,在main中,您构造一个io_context和一个acceptor,与示例 20-12 中的定义相同。然后,您将acceptor传递给serve函数以开始服务循环,并在io_context上调用run以启动异步操作的服务➒。
多线程 Boost Asio
为了使您的 Boost Asio 程序支持多线程,您可以简单地创建任务,调用run方法在您的io_context对象上运行。当然,这并不会让您的程序变得安全,所有在“共享与协调”章节中关于第 647 页的警告依然有效。示例 20-15 演示了如何根据示例 20-14 将您的服务器进行多线程处理。
#include <iostream>
#include <string>
#include <boost/asio.hpp>
#include <boost/algorithm/string/case_conv.hpp>
#include <memory>
#include <future>
struct Session : std::enable_shared_from_this<Session> {
--snip--
};
void serve(ip::tcp::acceptor& acceptor) {
--snip--
}
int main() {
const int n_threads{ 4 };
boost::asio::io_context io_context{ n_threads };
ip::tcp::acceptor acceptor{ io_context,
ip::tcp::endpoint(ip::tcp::v4(), 1895) }; ➊
serve(acceptor); ➋
std::vector<std::future<void>> futures;
std::generate_n(std::back_inserter(futures), n_threads, ➌
[&io_context] {
return std::async(std::launch::async,
[&io_context] { io_context.run(); }); ➍
});
for(auto& future : futures) { ➎
try {
future.get(); ➏
} catch (const std::exception& e) {
std::cerr << e.what() << std::endl;
}
}
}
示例 20-15:为您的异步回声服务器启用多线程
您的Session和serve定义是相同的。在main中,您声明n_threads常量,表示您将用于服务的线程数,一个io_context对象,以及与示例 12-12 中相同参数的acceptor对象 ➊。接下来,您调用serve以开始async_accept循环 ➋。
或多或少,main 函数几乎与示例 12-12 相同。不同之处在于,你将为运行 io_context 分配多个线程,而不仅仅是一个。首先,你初始化一个 vector 来存储每个 future,对应于你将启动的任务。其次,你使用类似的方法,通过 std::generate_n 创建任务 ➌。作为生成函数对象,你传递一个 lambda,调用 std::async ➍。在 std::async 调用中,你传递执行策略 std::launch::async 和一个函数对象,该对象调用 run 来运行你的 io_context。
现在你已经为运行 io_context 分配了一些任务,Boost Asio 就开始运行了。你将希望等待所有异步操作完成,因此你需要对存储在 futures 中的每个 future 调用 get ➎。此循环完成后,每个 Request 都已完成,你准备好打印结果响应的摘要 ➏。
有时创建额外的线程并将它们分配给处理 I/O 是有意义的。通常,一个线程就足够了。你必须衡量这种优化(以及并发代码带来的相关困难)是否值得。
总结
本章介绍了 Boost Asio,一个用于低级 I/O 编程的库。你学习了如何在 Asio 中排队异步任务并提供线程池的基础知识,以及如何与其基本的网络功能进行交互。你编写了几个程序,包括一个使用同步和异步方法的简单 HTTP 客户端和一个回声服务器。
练习
20-1. 使用 Boost Asio 文档调查 UDP 类与本章中学习的 TCP 类的类似功能。将示例 20-14 中的大写回声服务器重写为一个 UDP 服务。
20-2. 使用 Boost Asio 文档调查 ICMP 类。编写一个程序,对给定子网中的所有主机进行 ping 测试,执行网络分析。调查 Nmap,一款免费的网络映射程序,网址为 nmap.org/。
20-3. 调查 Boost Beast 文档。使用 Beast 重写示例 20-10 和 20-11。
20-4. 使用 Boost Beast 编写一个 HTTP 服务器,从目录中提供文件。有关帮助,请参考文档中提供的 Boost Beast 示例项目。
进一步阅读
-
TCP/IP 指南,作者:Charles M. Kozierok(No Starch Press,2005)
-
错综复杂的网络:现代 Web 应用程序安全指南,作者:Michal Zalewski(No Starch Press,2012)
-
Boost C++ 库(第二版),作者:Boris Schäling(XML Press,2014)
-
Boost.Asio C++ 网络编程(第二版),作者:Wisnu Anggoro 和 John Torjo(Packt,2015)
第二十四章:编写应用程序
对于一群没有毛发的猿人,我们实际上已经发明了一些相当了不起的东西。
—Ernest Cline*,《玩家一号》

本章包含了一些重要的主题,通过教授构建真实世界应用程序的基础知识,帮助你更好地理解 C++的实际应用。首先讨论 C++内置的程序支持,允许你与应用生命周期进行交互。接着,你将学习 Boost ProgramOptions,这是一个非常优秀的开发控制台应用程序的库,它提供了接受用户输入的功能,省去了你重新发明轮子的麻烦。此外,你还将学习一些关于预处理器和编译器的特殊主题,这些内容你在构建源代码超过一个文件的应用程序时,可能会遇到。
程序支持
有时你的程序需要与操作环境的应用生命周期进行交互。本节涵盖了三类主要的交互:
-
处理程序终止和清理
-
与环境的通信
-
管理操作系统信号
为了帮助说明本节中的各种功能,你将使用清单 21-1 作为框架。它使用了一个改进版的类模拟,类比于清单 4-5 中的Tracer类,来自第四章,帮助跟踪在各种程序终止场景中哪些对象被清理。
#include <iostream>
#include <string>
struct Tracer { ➊
Tracer(std::string name_in)
: name{ std::move(name_in) } {
std::cout << name << " constructed.\n";
}
~Tracer() {
std::cout << name << " destructed.\n";
}
private:
const std::string name;
};
Tracer static_tracer{ "static Tracer" }; ➋
void run() { ➌
std::cout << "Entering run()\n";
// ...
std::cout << "Exiting run()\n";
}
int main() {
std::cout << "Entering main()\n"; ➍
Tracer local_tracer{ "local Tracer" }; ➎
thread_local Tracer thread_local_tracer{ "thread_local Tracer" }; ➏
const auto* dynamic_tracer = new Tracer{ "dynamic Tracer" }; ➐
run(); ➑
delete dynamic_tracer; ➒
std::cout << "Exiting main()\n"; ➓
}
-----------------------------------------------------------------------
static Tracer constructed. ➋
Entering main() ➍
local Tracer constructed. ➎
thread_local Tracer constructed. ➏
dynamic Tracer constructed. ➐
Entering run() ➑
Exiting run() ➑
dynamic Tracer destructed. ➒
Exiting main() ➓
local Tracer destructed. ➎
thread_local Tracer destructed. ➏
static Tracer destructed. ➋
清单 21-1:一个用于调查程序终止和清理功能的框架
首先,你声明了一个Tracer类,它接受一个任意的std::string标签,并在Tracer对象构造和析构时向 stdout 报告 ➊。接着,你声明了一个具有静态存储持续时间的Tracer ➋。run函数报告程序进入和退出时的情况 ➌。中间部分是一个单独的注释,你将在后续的部分中用其他代码替换。在main中,你进行一次声明 ➍;初始化具有局部 ➎、线程局部 ➏ 和动态 ➐ 存储持续时间的Tracer对象;并调用run ➑。然后,你删除动态的Tracer对象 ➒,并宣布即将从main返回 ➓。
警告
如果清单 21-1 中的输出让你感到惊讶,请在继续之前复习一下第 89 页中的“对象的存储持续时间”!
处理程序终止和清理
<cstdlib>头文件包含了若干用于管理程序终止和资源清理的函数。程序终止函数可以分为两个大类:
-
那些导致程序终止的交互
-
注册回调函数,当程序终止即将发生时
使用 std::atexit 的终止回调
要注册一个在程序正常终止时调用的函数,你可以使用std::atexit函数。你可以注册多个函数,它们将按注册的逆序被调用。回调函数不接受任何参数,并且返回void。如果std::atexit成功注册了一个函数,它将返回一个非零值;否则,返回零。
示例 21-2 展示了你可以注册一个atexit回调,并且它将在预期的时刻被调用。
#include <cstdlib>
#include <iostream>
#include <string>
struct Tracer {
--snip--
};
Tracer static_tracer{ "static Tracer" };
void run() {
std::cout << "Registering a callback\n"; ➊
std::atexit([] { std::cout << "***std::atexit callback executing***\n"; }); ➋
std::cout << "Callback registered\n"; ➌
}
int main() {
--snip--
}
-----------------------------------------------------------------------
static Tracer constructed.
Entering main()
local Tracer constructed.
thread_local Tracer constructed.
dynamic Tracer constructed.
Registering a callback
Callback registered ➌
dynamic Tracer destructed.
Exiting main()
local Tracer destructed.
thread_local Tracer destructed.
***std::atexit callback executing*** ➋
static Tracer destructed.
示例 21-2:注册一个atexit回调
在run中,你宣布即将注册一个回调 ➊,你注册了一个 ➋,然后你宣布即将从run返回 ➌。在输出中,你可以清楚地看到回调发生在你从main返回后,并且所有非静态对象都已销毁。
编写回调函数时,有两个重要的注意事项:
-
你不能从回调函数中抛出未捕获的异常。这样会导致调用
std::terminate。 -
你需要非常小心与程序中的非静态对象交互。
atexit回调函数在main返回后执行,因此除非特别小心保持它们的存活,否则所有局部、线程局部和动态对象将在此时被销毁。
警告
你可以使用 std::atexit 注册至少 32 个函数,尽管确切的限制由实现定义。
使用 std::exit 退出
在本书中,你一直通过从main返回来终止程序。在某些情况下,比如多线程程序中,你可能希望以其他方式优雅地退出程序,尽管你应该避免引入相关的复杂性。你可以使用std::exit函数,它接受一个整数int作为程序的退出代码。它将执行以下清理步骤:
-
与当前线程关联的线程局部对象和静态对象将被销毁。任何
atexit回调函数将被调用。 -
所有的 stdin、stdout 和 stderr 都会被刷新。
-
任何临时文件都会被删除。
-
程序会将给定的状态码报告给操作环境,之后操作环境会恢复控制。
示例 21-3 通过注册一个atexit回调并在run内部调用exit,展示了std::exit的行为。
#include <cstdlib>
#include <iostream>
#include <string>
struct Tracer {
--snip--
};
Tracer static_tracer{ "static Tracer" };
void run() {
std::cout << "Registering a callback\n"; ➊
std::atexit([] { std::cout << "***std::atexit callback executing***\n"; }); ➋
std::cout << "Callback registered\n"; ➌
std::exit(0); ➍
}
int main() {
--snip--
}
-----------------------------------------------------------------------
static Tracer constructed.
Entering main()
local Tracer constructed.
thread_local Tracer constructed.
dynamic Tracer constructed.
Registering a callback ➊
Callback registered ➌
thread_local Tracer destructed.
***std::atexit callback executing*** ➍
static Tracer destructed.
示例 21-3:调用std::exit
在run中,你宣布正在注册一个回调 ➊,你通过atexit注册了一个回调 ➋,你宣布完成注册 ➌,然后你使用零作为参数调用exit ➍。将示例 21-3 的程序输出与示例 21-2 的输出进行比较。请注意,以下几行没有出现:
dynamic Tracer destructed.
Exiting main()
local Tracer destructed.
根据std::exit的规则,调用栈上的局部变量不会被清理。当然,因为程序从run中没有返回到main,所以delete也不会被调用。哎呀。
这个例子突出了一个重要的考虑因素:你不应该使用std::exit来处理正常的程序执行。这里提到它是为了完整性,因为你可能会在早期的 C++代码中看到它。
注意
<cstdlib>头文件还包括一个std::quick_exit,它会调用你用std::at_quick_exit注册的回调,std::at_quick_exit的接口类似于std::atexit。主要的区别在于,at_quick_exit回调不会执行,除非你显式地调用quick_exit,而atexit回调在程序即将退出时总会执行。
std::abort
要结束一个程序,你也可以使用std::abort来实现这一目标。这个函数接受一个整数值的状态码,并立即将其返回给操作环境。没有对象的析构函数被调用,也没有std::atexit回调被触发。清单 21-4 展示了如何使用std::abort。
#include <cstdlib>
#include <iostream>
#include <string>
struct Tracer {
--snip--
};
Tracer static_tracer{ "static Tracer" };
void run() {
std::cout << "Registering a callback\n"; ➊
std::atexit([] { std::cout << "***std::atexit callback executing***\n"; }); ➋
std::cout << "Callback registered\n"; ➌
std::abort(); ➍
}
int main() {
--snip--
}
-----------------------------------------------------------------------
static Tracer constructed.
Entering main()
local Tracer constructed.
thread_local Tracer constructed.
dynamic Tracer constructed.
Registering a callback
Callback registered
清单 21-4:调用std::abort
在run中,你再次声明你正在注册一个回调 ➊,你用atexit注册一个回调并宣布注册完成 ➌。这一次,你改为调用abort ➍。注意,在宣布完成回调注册 ➊ 后,没有输出打印出来。程序没有清理任何对象,且你的atexit回调没有被调用。
正如你想象的那样,std::abort并没有太多典型的使用场景。你最可能遇到的一个场景是std::terminate的默认行为,当同时有两个异常发生时,它会被调用。
与环境的通信
有时候,你可能希望启动另一个进程。例如,Google 的 Chrome 浏览器会启动多个进程来服务一个浏览器会话。这通过依赖操作系统的进程模型来增强一些安全性和鲁棒性。例如,Web 应用和插件通常会运行在独立的进程中,这样如果它们崩溃,整个浏览器就不会崩溃。此外,通过将浏览器的渲染引擎运行在一个独立的进程中,任何安全漏洞也变得更难被利用,因为 Google 将该进程的权限限制在所谓的沙盒环境中。
std::system
你可以使用位于<cstdlib>头文件中的std::system函数来启动一个独立的进程,它接受一个 C 风格的字符串作为要执行的命令,并返回一个int,对应于命令的返回码。实际行为依赖于操作环境。例如,在 Windows 机器上,该函数会调用cmd.exe,而在 Linux 机器上会调用/bin/sh。该函数在命令执行时会阻塞。
清单 21-5 展示了如何使用std::system来 ping 一个远程主机。(如果你不是使用类似 Unix 的操作系统,你需要将command的内容更新为适合你操作系统的命令。)
#include <cstdlib>
#include <iostream>
#include <string>
int main() {
std::string command{ "ping -c 4 google.com" }; ➊
const auto result = std::system(command.c_str()); ➋
std::cout << "The command \'" << command
<< "\' returned " << result << "\n";
}
-----------------------------------------------------------------------
PING google.com (172.217.15.78): 56 data bytes
64 bytes from 172.217.15.78: icmp_seq=0 ttl=56 time=4.447 ms
64 bytes from 172.217.15.78: icmp_seq=1 ttl=56 time=12.162 ms
64 bytes from 172.217.15.78: icmp_seq=2 ttl=56 time=8.376 ms
64 bytes from 172.217.15.78: icmp_seq=3 ttl=56 time=10.813 ms
--- google.com ping statistics ---
4 packets transmitted, 4 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 4.447/8.950/12.162/2.932 ms
The command 'ping -c 4 google.com' returned 0 ➌
清单 21-5:使用 std::system 调用 ping 工具(输出来自 macOS Mojave 版本 10.14。)
首先,你初始化一个名为 command 的 string,其内容为 ping -c 4 google.com ➊。然后,你通过传递 command 的内容来调用 std::system ➋。这将导致操作系统调用 ping 命令并传递参数 -c 4(指定发送四次 ping)和地址 google.com。接着,你打印一个状态信息,报告 std::system 的返回值 ➌。
std::getenv
操作环境通常具有 环境变量,用户和开发人员可以设置这些变量,以帮助程序查找运行所需的重要信息。<cstdlib> 头文件包含了 std::getenv 函数,它接受一个 C 风格字符串作为参数,表示你想查找的环境变量的名称,并返回一个 C 风格字符串,包含对应变量的内容。如果未找到该变量,函数将返回 nullptr。
清单 21-6 说明了如何使用 std::getenv 获取 路径变量,该变量包含了包含重要可执行文件的目录列表。
#include <cstdlib>
#include <iostream>
#include <string>
int main() {
std::string variable_name{ "PATH" }; ➊
std::string result{ std::getenv(variable_name.c_str()) }; ➋
std::cout << "The variable " << variable_name
<< " equals " << result << "\n"; ➌
}
-----------------------------------------------------------------------
The variable PATH equals /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
清单 21-6:使用 std::getenv 获取路径变量(输出来自 macOS Mojave 版本 10.14。)
首先,你初始化一个名为 variable_name 的 string,其内容为 PATH ➊。接下来,你将调用 std::getenv 获取 PATH 的结果,并将其存储在一个名为 result 的字符串中 ➋。然后,你将结果打印到标准输出 ➌。
操作系统信号管理
操作系统信号是异步通知,发送给进程,通知程序发生了某个事件。<csignal> 头文件包含了六个宏常量,代表操作系统发送给程序的不同信号(这些信号与操作系统无关):
-
SIGTERM表示终止请求。 -
SIGSEGV表示无效的内存访问。 -
SIGINT表示外部中断,例如键盘中断。 -
SIGILL表示无效的程序镜像。 -
SIGABRT表示异常终止条件,例如std::abort。 -
SIGFPE表示浮点错误,例如除以零。
要为这些信号注册处理程序,你可以使用 <csignal> 头文件中的 std::signal 函数。它接受一个 int 类型的参数,表示信号宏列表中的一个信号。第二个参数是一个函数指针(而不是函数对象!),指向一个接受 int 类型信号宏并返回 void 的函数。这个函数必须使用 C 链接(尽管大多数实现也允许 C++ 链接)。你将在本章后面学习 C 链接。现在,只需在你的函数定义前加上 extern "C"。请注意,由于中断的异步性质,任何对全局可变状态的访问都必须进行同步。
清单 21-7 包含一个等待键盘中断的程序。
#include <csignal>
#include <iostream>
#include <chrono>
#include <thread>
#include <atomic>
std::atomic_bool interrupted{}; ➊
extern "C" void handler(int signal) {
std::cout << "Handler invoked with signal " << signal << ".\n"; ➋
interrupted = true; ➌
}
int main() {
using namespace std::chrono_literals;
std::signal(SIGINT, handler); ➍
while(!interrupted) { ➎
std::cout << "Waiting..." << std::endl; ➏
std::this_thread::sleep_for(1s);
}
std::cout << "Interrupted!\n"; ➐
}
-----------------------------------------------------------------------
Waiting...
Waiting...
Waiting...
Handler invoked with signal 2.
Interrupted! ➐
清单 21-7:使用 std::signal 注册键盘中断
你首先声明一个名为 interrupted 的 atomic_bool,用于存储程序是否收到键盘中断 ➊(它具有静态存储期,因为你不能在 std::signal 中使用函数对象,因此必须使用非成员函数来处理回调)。接下来,你声明一个回调处理程序,接受一个名为 signal 的 int,将其值打印到标准输出 ➋,并将 interrupted 设置为 true ➌。
在 main 中,你将 SIGINT 中断代码的信号处理程序设置为 handler ➍。在循环中,你通过打印消息 ➏ 并休眠一秒 ➐ 来等待程序被中断 ➎。程序一旦被中断,你将打印消息并从 main 返回 ➐。
注意
通常,你可以通过按 CTRL-C 来引发现代操作系统中的键盘中断。
Boost ProgramOptions
大多数控制台应用程序接受命令行参数。正如你在《三种 main 重载》一节中学到的,在第 272 页,你可以定义 main 来接受参数 argc 和 argv,操作环境会分别用参数的数量和内容来填充它们。你总是可以手动解析这些参数并相应地修改程序的行为,但有一个更好的方法:Boost ProgramOptions 库是编写控制台应用程序的重要组成部分。
注意
本节中介绍的所有 Boost ProgramOptions 类都可以在 <boost/program_options.hpp> 头文件中找到。
你可能会想编写自己的参数解析代码,但 ProgramOptions 是一个更明智的选择,原因有四个:
-
它更加方便。 一旦你学会了 ProgramOptions 的简洁声明式语法,你可以轻松地用几行代码描述相当复杂的控制台接口。
-
它轻松处理错误。 当用户错误使用你的程序时,ProgramOptions 会告诉用户如何错误使用程序,而无需你做额外的工作。
-
它自动生成帮助提示。 根据你的声明式标记,ProgramOptions 会为你创建格式良好、易于使用的文档。
-
它超越了命令行。 如果你想从配置文件或环境变量中获取配置,转换命令行参数非常简单。
ProgramOptions 包含三个部分:
-
选项描述 允许你指定允许的选项。
-
解析器组件 从命令行、配置文件和环境变量中提取选项名称和值。
-
存储组件 提供了访问已类型化选项的接口。
在接下来的子章节中,你将学习这些部分的内容。
选项描述
选项描述组件由三个主要类组成:
-
boost::program_options::option_description描述一个单一选项。 -
boost::program_options::value_semantic知道单个选项的期望类型。 -
boost::program_options::options_description是一个容器,包含多个option_description类型的对象。
你构造一个options_description来指定程序选项的描述。可选地,你可以在构造函数中包含一个单独的字符串参数,描述你的程序。如果你包含它,它会在描述中打印出来,但不会对功能产生任何影响。接下来,你使用它的add_options方法,这会返回一个特殊类型的对象boost::program_options::options_description_easy_init。这个类有一个特殊的operator(),接受至少两个参数。
第一个参数是你想要添加的选项的名称。ProgramOptions 非常智能,因此你可以提供一个长名称和一个短名称,用逗号分隔。例如,如果你有一个名为threads的选项,ProgramOptions 会将命令行中的--threads参数绑定到这个选项。如果你将选项命名为threads,t,ProgramOptions 会将--threads或-t绑定到你的选项。
第二个参数是选项的描述。你可以使用value_semantic、C 风格字符串描述或两者的组合。因为options_description_easy_init从operator()返回对自身的引用,你可以将这些调用链式连接起来,形成程序选项的简洁表示。通常,你不会直接创建value_semantic对象,而是使用便捷的模板函数boost::program_options::value来生成它们。它接受一个单一的模板参数,对应于选项的期望类型。生成的指针指向一个具有将文本输入(例如来自命令行的输入)解析为期望类型的代码的对象。例如,要指定一个int类型的选项,你会调用value<int>()。
结果指向的对象将具有多个方法,允许你指定选项的附加信息。例如,你可以使用default_value方法来设置选项的默认值。例如,要指定一个int类型的选项默认值为 42,你可以使用以下结构:
value<int>()->default_value(42)
另一个常见的模式是可以接受多个标记的选项。这样的选项允许元素之间有空格,并且它们会被解析为一个单一的字符串。为了实现这一点,只需使用multitoken方法。例如,要指定一个选项可以接受多个std::string值,你可以使用以下结构:
value<std::string>()->multitoken()
如果你希望允许同一个选项的多个实例,你可以指定一个std::vector作为值,如下所示:
value<std::vector<std::string>>()
如果你有一个布尔选项,可以使用方便的函数boost::program_options::bool_switch,该函数接受一个指向bool的指针。如果用户包含相应的选项,函数将把指针指向的bool设置为 true。例如,以下构造将会把名为flag的bool设置为true,如果包含了相应的选项:
bool_switch(&flag)
options_description类支持operator<<,因此你可以轻松创建格式良好的帮助对话框,而无需额外的努力。清单 21-8 展示了如何使用 ProgramOptions 为名为mgrep的示例程序创建一个program_options对象。
#include <boost/program_options.hpp>
#include <iostream>
#include <string>
int main(int argc, char** argv) {
using namespace boost::program_options;
bool is_recursive{}, is_help{};
options_description description{ "mgrep [options] pattern path1 path2 ..."
}; ➊
description.add_options()
("help,h", bool_switch(&is_help), "display a help dialog") ➋
("threads,t", value<int>()->default_value(4),
"number of threads to use") ➌
("recursive,r", bool_switch(&is_recursive),
"search subdirectories recursively") ➍
("pattern", value<std::string>(), "pattern to search for") ➎
("paths", value<std::vector<std::string>>(), "path to search"); ➏
std::cout << description; ➐
}
-----------------------------------------------------------------------
mgrep [options] pattern path1 path2 ...:
-h [ --help ] display a help dialog
-t [ --threads ] arg (=4) number of threads to use
-r [ --recursive ] search subdirectories recursively
--pattern arg pattern to search for
--path arg path to search
清单 21-8:使用 Boost ProgramOptions 生成格式良好的帮助对话框
首先,你使用自定义的使用字符串初始化一个options_description对象 ➊。接着,你调用add_options并开始添加选项:一个布尔标志,用于指示是否显示帮助对话框 ➋,一个int,用于指示使用多少线程 ➌,另一个布尔标志,用于指示是否以递归方式搜索子目录 ➍,一个std::string,用于指示在文件中搜索的pattern ➎,以及一个std::string值的列表,表示要搜索的paths ➏。然后,你将description写入标准输出 ➐。
假设你尚未实现的 mgrep 程序将始终需要pattern和paths参数。你可以将这些转换为位置参数,正如其名字所示,它们将根据位置分配参数。为此,你需要使用boost::program_options::positional_options_description类,该类不需要任何构造函数参数。你使用add方法,该方法接受两个参数:一个 C 风格字符串,表示你希望转换为位置参数的选项,以及一个int,表示你希望绑定的参数数量。你可以多次调用add来添加多个位置参数。但顺序很重要,位置参数将从左到右绑定,所以你第一次调用add时,绑定的是左侧的位置参数。对于最后一个位置参数,你可以使用数字-1来告诉 ProgramOptions 将所有剩余的元素绑定到相应的选项。
清单 21-9 提供了一段代码片段,你可以将其附加到清单 21-7 中的main函数,以添加位置参数。
positional_options_description positional; ➊
positional.add("pattern", 1); ➋
positional.add("path", -1); ➌
清单 21-9:将位置参数添加到清单 21-8 中
你初始化了一个没有任何构造函数参数的positional_options_description ➊。接着,你调用add方法并传入参数pattern和1,这将把第一个位置参数绑定到pattern选项 ➋。你再次调用add方法,这次传入参数path和-1 ➌,这将把剩余的位置参数绑定到path选项。
解析选项
现在你已经声明了程序如何接受选项,你可以解析用户输入。可以从环境变量、配置文件和命令行获取配置。为了简洁起见,本节只讨论最后一种情况。
注意
有关如何从环境变量和配置文件获取配置信息,请参考 Boost ProgramOptions 文档,特别是教程部分。
为了解析命令行输入,你使用 boost::program_options::command_line_parser 类,该类接受两个构造函数参数:一个 int 类型的参数对应于 argc,即命令行上的参数个数,另一个 char** 类型的参数对应于 argv,即命令行上参数的值(或内容)。该类提供了多个重要方法,你将使用这些方法来声明解析器如何解释用户输入。
首先,你将调用其 options 方法,该方法接受一个对应于你的 options_description 的参数。接下来,你将使用 positional 方法,该方法接受一个对应于你的 positional_options_description 的参数。最后,你将调用 run 方法,而不传递任何参数。这会导致解析器解析命令行输入并返回一个 parsed_options 对象。
清单 21-10 提供了一段代码,你可以将其追加到 main 中,放在 清单 21-8 之后,用于集成一个 command_line_parser。
command_line_parser parser{ argc, argv }; ➊
parser.options(description); ➋
parser.positional(positional); ➌
auto parsed_result = parser.run(); ➍
清单 21-10:将 command_line_parser 添加到 清单 21-8
你通过传递 main 中的参数 ➊ 来初始化一个名为 parser 的 command_line_parser。接着,你将 options_description 对象传递给 options 方法 ➋,并将 positional_options_description 传递给 positional 方法 ➌。然后你调用 run 方法生成 parsed_options 对象 ➍。
警告
如果用户传入无法解析的输入,例如提供了不在你的描述中的选项,解析器将抛出一个继承自 std::exception 的异常。
存储和访问选项
你将程序选项存储到 boost::program_options::variables_map 类中,该类的构造函数不接受任何参数。为了将解析后的选项放入 variables_map,你使用 boost::program_options::store 方法,该方法的第一个参数是一个 parsed_options 对象,第二个参数是一个 variables_map 对象。然后你调用 boost::program_options::notify 方法,该方法接受一个 variables_map 对象作为参数。此时,你的 variables_map 包含了用户指定的所有选项。
清单 21-11 提供了一段代码,你可以将其追加到 main 中,放在 清单 21-10 之后,用于将结果解析为 variables_map。
variables_map vm; ➊
store(parsed_result, vm); ➋
notify(vm); ➌
清单 21-11:将结果存储到 variables_map 中
首先声明一个 variables_map ➊。接下来,你将从列表 21-10 中传递你的 parsed_result 和新声明的 variables_map 给 store ➋。然后在你的 variables_map 上调用 notify ➌。
variables_map 类是一个关联容器,基本上类似于 std::map<std::string, boost::any>。要提取一个元素,你可以通过传递选项名称作为键,使用 operator[]。结果是一个 boost::any,因此你需要使用其 as 方法将其转换为正确的类型。(你在《any》一章中已经学习过 boost::any,参考 第 378 页。)使用 empty 方法检查任何可能为空的选项非常重要。如果你没有这样做并且强行转换 any,将会导致运行时错误。
列表 21-12 展示了如何从 variables_map 中检索值。
if (is_help) std::cout << "Is help.\n"; ➊
if (is_recursive) std::cout << "Is recursive.\n"; ➋
std::cout << "Threads: " << vm["threads"].as<int>() << "\n"; ➌
if (!vm["pattern"].empty()) { ➍
std::cout << "Pattern: " << vm["pattern"].as<std::string>() << "\n"; ➎
} else {
std::cout << "Empty pattern.\n";
}
if (!vm["path"].empty()) { ➏
std::cout << "Paths:\n";
for(const auto& path : vm["path"].as<std::vector<std::string>>()) ➐
std::cout << "\t" << path << "\n";
} else {
std::cout << "Empty path.\n";
}
列表 21-12:从 variables_map 中检索值
由于你使用 bool_switch 值来处理 help 和 recursive 选项,你只需直接使用这些布尔值来判断用户是否请求了这两个选项 ➊➋。由于 threads 有默认值,你无需确认它是否为空,因此可以直接使用 as<int> 提取其值 ➌。对于那些没有默认值的选项,例如 pattern,你首先检查它是否为空 ➍。如果这些选项不为空,你可以使用 as<std::string> 提取它们的值 ➎。对 path 选项也做相同的操作 ➏,它允许你使用 as<std::vector<std::string>> 提取用户提供的集合 ➐。
将一切结合起来
现在你已经具备了组装基于 ProgramOptions 的应用所需的所有知识。列表 21-13 展示了一种将之前的代码片段结合在一起的方法。
#include <boost/program_options.hpp>
#include <iostream>
#include <string>
int main(int argc, char** argv) {
using namespace boost::program_options;
bool is_recursive{}, is_help{};
options_description description{ "mgrep [options] pattern path1 path2 ..." };
description.add_options()
("help,h", bool_switch(&is_help), "display a help dialog")
("threads,t", value<int>()->default_value(4),
"number of threads to use")
("recursive,r", bool_switch(&is_recursive),
"search subdirectories recursively")
("pattern", value<std::string>(), "pattern to search for")
("path", value<std::vector<std::string>>(), "path to search");
positional_options_description positional;
positional.add("pattern", 1);
positional.add("path", -1);
command_line_parser parser{ argc, argv };
parser.options(description);
parser.positional(positional);
variables_map vm;
try {
auto parsed_result = parser.run(); ➊
store(parsed_result, vm);
notify(vm);
} catch (const std::exception& e) {
std::cerr << e.what() << "\n";
return -1;
}
if (is_help) { ➋
std::cout << description;
return 0;
}
if (vm["pattern"].empty()) { ➌
std::cerr << "You must provide a pattern.\n";
return -1;
}
if (vm["path"].empty()) { ➍
std::cerr << "You must provide at least one path.\n";
return -1;
}
const auto threads = vm["threads"].as<int>();
const auto& pattern = vm["pattern"].as<std::string>();
const auto& paths = vm["path"].as<std::vector<std::string>>();
// Continue program here ... ➎
std::cout << "Ok." << std::endl;
}
列表 21-13:使用之前的代码片段的完整命令行参数解析应用
与之前的代码片段不同的是,你将调用解析器的 run 函数封装在一个 try-catch 块中,以减轻用户提供的错误输入 ➊。如果他们确实提供了错误输入,你只需捕获异常,打印错误到 stderr,并 return。
一旦你声明并存储了程序选项,像在列表 21-8 到 21-12 中的示例一样,你首先检查用户是否请求了帮助提示 ➋。如果请求了,你只需打印用法并退出,因为无需进行任何进一步的检查。接下来,你进行一些错误检查,确保用户提供了模式 ➌ 和至少一个路径 ➍。如果没有,你将打印一个错误并显示程序的正确用法后退出;否则,你可以继续编写程序 ➎。
列表 21-14 展示了你的程序的各种输出,程序已被编译成二进制文件 mgrep。
$ ./mgrep ➊
You must provide a pattern.
$ ./mgrep needle ➋
You must provide at least one path.
$ ./mgrep --supercharge needle haystack1.txt haystack2.txt ➌
unrecognised option '--supercharge'
$ ./mgrep --help ➍
mgrep [options] pattern path1 path2 ...:
-h [ --help ] display a help dialog
-t [ --threads ] arg (=4) number of threads to use
-r [ --recursive ] search subdirectories recursively
--pattern arg pattern to search for
--path arg path to search
$ ./mgrep needle haystack1.txt haystack2.txt haystack3.txt ➎
Ok.
$ ./mgrep --recursive needle haystack1.txt ➏
Ok.
$ ./mgrep -rt 10 needle haystack1.txt haystack2.txt ➐
Ok.
列表 21-14:来自 列表 21-13 中程序的各种调用和输出
前三次调用由于不同的原因返回错误:你没有提供模式 ➊,你没有提供路径 ➋,或者你提供了一个无法识别的选项 ➌。
在下一次调用中,由于你提供了--help选项 ➍,你将获得友好的帮助对话框。最后三次调用正确解析,因为它们都包含模式和至少一个路径。第一次调用没有任何选项 ➎,第二次调用使用了长选项语法 ➏,第三次调用使用了短选项语法 ➐。
编译中的特殊话题
本节解释了几个重要的预处理器特性,帮助你理解双重包含问题(将在下一小节中描述)以及如何解决该问题。你将学习如何通过使用编译器标志优化代码的不同选项。此外,你还将了解如何使用特殊的语言关键字,使链接器能够与 C 语言互操作。
重新审视预处理器
预处理器是一个在编译之前对源代码进行简单转换的程序。你通过预处理器指令给预处理器指令。所有预处理器指令都以井号(#)开始。回顾一下“编译器工具链”部分,在第 5 页中,#include是一个预处理器指令,它告诉预处理器将相应的头文件内容直接复制并粘贴到源代码中。
预处理器还支持其他指令。最常见的是宏,它是一个已赋予名称的代码片段。每当你在 C++代码中使用该名称时,预处理器会将该名称替换为宏的内容。
两种不同类型的宏是类对象宏和函数宏。你可以使用以下语法来声明类对象宏:
#define <NAME> <CODE>
其中,NAME 是宏的名称,CODE是用来替换该名称的代码。例如,清单 21-15 展示了如何将字符串字面量定义为宏。
#include <cstdio>
#define MESSAGE "LOL" ➊
int main(){
printf(MESSAGE); ➋
}
-----------------------------------------------------------------------
LOL
清单 21-15:一个包含类对象宏的 C++程序
你定义了宏MESSAGE,它对应的代码是"LOL" ➊。接下来,你将MESSAGE宏作为格式字符串传递给printf ➋。在预处理器完成对清单 21-15 的处理后,它会呈现为编译器看到的清单 21-16。
#include <cstdio>
int main(){
printf("LOL");
}
清单 21-16:预处理清单 21-15 的结果
预处理器在这里无非是一个复制粘贴工具。宏消失了,剩下的就是一个简单的程序,它将LOL打印到控制台。
注意
如果你想检查预处理器所做的工作,编译器通常有一个标志,允许你仅执行预处理步骤,从而限制编译。这样,编译器会输出每个翻译单元的预处理源文件。在 GCC、Clang 和 MSVC 等编译器中,你可以使用-E标志。
类似函数的宏就像对象宏,只不过它可以在标识符后接受一系列参数:
#define <NAME>(<PARAMETERS>) <CODE>
你可以在代码中使用这些参数,允许用户自定义宏的行为。列表 21-17 包含了类似函数的宏SAY_LOL_WITH。
#include <cstdio>
#define SAY_LOL_WITH(fn) fn("LOL") ➊
int main() {
SAY_LOL_WITH(printf); ➋
}
列表 21-17:一个具有类似函数宏的 C++程序
SAY_LOL_WITH宏接受一个名为fn的单一参数 ➊。预处理器将宏粘贴到表达式fn("LOL")中。当它评估SAY_LOL_WITH时,预处理器会将printf粘贴到表达式中 ➋,从而生成一个类似于列表 21-16 的翻译单元。
条件编译
预处理器还提供了条件编译功能,这是一种基本的if-else逻辑。条件编译有几种变体,但你最有可能遇到的是列表 21-18 中所示的形式。
#ifndef MY_MACRO ➊
// Segment 1 ➋
#else
// Segment 2 ➌
#endif
列表 21-18:一个带有条件编译的 C++程序
如果在预处理器评估#ifndef ➊时,MY_MACRO未定义,列表 21-18 会缩减为// Segment 1 ➋表示的代码。如果MY_MACRO已经#defined,列表 21-18 会评估为// Segment 2 ➌表示的代码。#else是可选的。
双重包含
除了使用#include,你应该尽量少用预处理器。预处理器非常原始,如果你过度依赖它,会导致很难调试的错误。这一点通过#include可以看出,它只是一个简单的复制粘贴命令。
由于你只能定义一个符号一次(这个规则被称为单一定义规则),因此必须确保你的头文件不会试图重新定义符号。最容易犯这个错误的方式是重复包含相同的头文件,这就是双重包含问题。
避免双重包含问题的常见方法是使用条件编译来制作包含保护。包含保护检测头文件是否已经被包含过。如果已经包含过,它会通过条件编译来清空该头文件。列表 21-19 展示了如何为头文件添加包含保护。
// step_function.h
#ifndef STEP_FUNCTION_H ➊
int step_function(int x);
#define STEP_FUNCTION_H ➋
#endif
列表 21-19:一个更新了包含保护的step_function.h
当预处理器第一次在源文件中包含step_function.h时,宏STEP_FUNCTION_H尚未定义,因此#ifndef ➊会包含直到#endif之间的代码。在这段代码中,你会#define宏STEP_FUNCTION_H ➋。这样,如果预处理器再次包含step_function.h,#ifndef STEP_FUNCTION_H会返回假值,不会生成任何代码。
包含保护符号非常普遍,以至于大多数现代工具链都支持 #pragma once 这种特殊语法。如果其中一个支持的预处理器看到这一行,它将像头文件有包含保护一样处理。这减少了不少繁琐的步骤。使用这种结构,你可以将示例 21-19 重构为示例 21-20。
#pragma once ➊
int step_function(int x);
示例 21-20:更新了 #pragma once 的 step_function.h
你所做的只是用 #pragma once ➊ 开始了头文件,这是首选方法。一般来说,每个头文件应以 #pragma once 开始。
编译器优化
现代编译器可以对代码执行复杂的变换,以提高运行时性能并减少二进制文件的大小。这些变换被称为优化,它们对程序员有一定成本。优化必然会增加编译时间。此外,优化后的代码通常比非优化代码更难调试,因为优化器通常会消除和重新排列指令。简而言之,在编程时通常希望关闭优化,但在测试和生产时则应开启。因此,编译器通常提供几种优化选项。表 21-1 描述了一个这样的例子——GCC 8.3 中的优化选项,尽管这些标志在主要编译器中都比较常见。
表 21-1: GCC 8.3 优化选项
| 标志 | 描述 |
|---|---|
-O0 (默认) |
通过关闭优化来减少编译时间,提供良好的调试体验,但运行时性能较差。 |
-O 或 -O1 |
执行大多数可用的优化,但省略那些可能会消耗大量(编译)时间的优化。 |
-O2 |
执行 -O1 的所有优化,并加上几乎所有不会大幅增加二进制文件大小的优化。编译时间可能比 -O1 要长得多。 |
-O3 |
执行 -O2 的所有优化,并加上许多可能大幅增加二进制文件大小的优化。再次强调,这比 -O1 和 -O2 的编译时间要长。 |
-Os |
类似于 -O2 进行优化,但优先考虑减少二进制文件大小。你可以将其(大致)看作是 -O3 的对立面,-O3 优先考虑性能,而可能增加二进制文件大小。所有不会增加二进制文件大小的 -O2 优化都会执行。 |
-Ofast |
启用所有 -O3 优化,并且包含一些可能违反标准合规性的危险优化。请注意。 |
-Og |
启用不会降低调试体验的优化。提供合理优化、快速编译和易于调试的良好平衡。 |
一般来说,除非有充分理由进行更改,否则应使用 -O2 进行生产环境的二进制文件编译。调试时使用 -Og。
与 C 的链接
您可以允许 C 代码通过语言链接来引用您程序中的函数和变量。语言链接指示编译器生成具有特定格式的符号,以便于其他目标语言。例如,要允许 C 程序使用您的函数,您只需在代码中添加extern "C"语言链接。
请参考清单 21-21 中的sum.h头文件,它为sum生成了一个 C 兼容的符号。
// sum.h
#pragma once
extern "C" int sum(const int* x, int len);
清单 21-21:使sum函数对 C 链接器可用的头文件
现在,编译器将生成 C 链接器可以使用的对象。要在 C 代码中使用此函数,您只需像往常一样声明sum函数:
int sum(const int* x, size_t len);
然后指示您的 C 链接器包含 C++目标文件。
注意
根据 C++标准, pragma 是一种向编译器提供超出源代码中嵌入信息的额外信息的方法。这些信息由实现定义,因此编译器不要求以任何方式使用 pragma 指定的信息。 pragma 是希腊语词根,意思是“事实”。
您还可以反向互操作:通过将 C 编译器生成的目标文件提供给链接器,将 C 编译器输出用于 C++程序中。
假设 C 编译器生成了一个等效于sum的函数。您可以使用sum.h头文件进行编译,并且链接器可以毫无问题地使用目标文件,这要归功于语言链接。
如果您有多个外部函数,可以使用大括号{},正如清单 21-22 所示。
// sum.h
#pragma once
extern "C" {
int sum_int(const int* x, int len);
double sum_double(const double* x, int len);
--snip--
}
清单 21-22:重构了清单 21-21,其中包含多个带有extern修饰符的函数。
sum_int和sum_double函数将具有 C 语言链接。
注意
您还可以通过 Boost Python 实现 C++和 Python 之间的互操作。详情请参阅 Boost 文档。
总结
在本章中,您首先了解了支持程序功能,它们允许您与应用程序生命周期进行交互。接下来,您探索了 Boost ProgramOptions,它使您能够使用声明式语法轻松地接受用户输入。然后,您研究了一些编译中的精选主题,这些主题在扩展 C++应用程序开发时非常有帮助。
练习
21-1. 在清单 20-12 中的异步大写回显服务器中添加优雅的键盘中断处理。添加一个具有静态存储持续时间的关闭开关,供会话对象和接受器在排队更多异步 I/O 之前检查。
21-2. 在清单 20-10 中的异步 HTTP 客户端中添加程序选项。它应该接受主机选项(例如www.nostarch.com)和一个或多个资源(例如/index.htm)。它应该为每个资源创建一个单独的请求。
21-3. 在第 21-2 题的程序中添加一个选项,接受一个目录,在该目录下写入所有 HTTP 响应。从每个主机/资源组合中派生出文件名。
21-4. 实现 mgrep 程序。它应该包含你在第二部分中学到的许多库。研究 Boost 算法中的 Boyer-Moore 查找算法(在 <boost/algorithm/searching/boyer_moore.hpp> 头文件中)。使用 std::async 启动任务,并确定一种方法来协调任务之间的工作。
进一步阅读
-
Boost C++ 库,第二版,作者:Boris Schäling(XML Press,2014)
-
C++ API 设计,作者:Martin Reddy(Morgan Kaufmann,2011)










浙公网安备 33010602011771号