Boost-C---库学习手册-全-

Boost C++ 库学习手册(全)

原文:zh.annas-archive.org/md5/9ADEA77D24CFF2D20B546F835360FD23

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Boost 不仅仅是一组有用的、可移植的、通用的 C++库。它还是一个重要的孵化器,其中的想法和概念会成为 ISO C++标准本身的一部分。如果您参与编写 C++软件的开发,学习使用 Boost 库将使您免于重复发明轮子,提高软件质量,并很可能提高您的生产力。

十年前,我第一次接触到 Boost 库,当时我正在寻找一个可移植的 C++正则表达式库。在接下来的几天里,将 Perl 和 Korn Shell 文本处理代码移植到 C++变得轻而易举,我立刻喜欢上了 Boost。自那时以来,我使用了更多的 Boost 库来编写软件,我经常发现自己深入研究文档,或者在邮件列表和在线论坛上提问,以了解库的语义和细微差别。尽管这样很有效,但我总是非常想念一本书,可以让我快速掌握最有用的 Boost 库,并帮助我更快地提高生产力。这就是那本书

Boost 拥有各种库,用于解决各种编程任务。本书是对 Boost 中最有用的一些库的教程介绍,以有效解决编程问题。所选的库代表了软件开发中横跨各种关注点的广度,包括数据结构和算法、文本处理、内存管理、异常安全、日期和时间计算、文件和目录管理、并发以及文件和网络 I/O 等。您将通过了解它帮助解决的问题类型、学习与之相关的基本概念以及查看一系列代码示例来了解库的使用方式。本书中介绍的库在后续示例中可以自由使用,让您了解在实践中 Boost 库之间经常发生的协同作用。

作为一组经过同行评审的开源库,Boost 深受社区专业知识的影响。我坚信本书将为您在使用 Boost 库方面提供坚实的实用基础。这种基础将反映在您编写的软件质量上,并使您有能力与 Boost 社区互动并为其做出有价值的贡献。

本书涵盖内容

第一章 介绍 Boost,讨论了如何设置开发环境以使用 Boost 库。我们涵盖了获取 Boost 库二进制包的不同方式,为不同配置构建它们的方法,以及在开发环境中使用它们的方法。

第二章 与 Boost 实用工具的初次接触,探讨了一些常见编程任务的一些 Boost 库,包括处理变体数据类型、处理命令行参数以及检测开发环境的配置参数。

第三章 内存管理和异常安全,解释了异常安全的含义,并展示了如何使用 Boost 和 C++11 提供的不同智能指针类型编写异常安全的代码。

第四章 处理字符串,探讨了 Boost 字符串算法库用于执行字符字符串的各种计算、Boost 范围库用于优雅地定义子序列、Boost 分词器库用于使用不同策略将字符串拆分为标记,以及 Boost 正则表达式库用于在文本中搜索复杂模式。

第五章,“STL 之外的有效数据结构”,涉及 Boost Container 库,重点关注 C++标准库中不可用的容器。我们看到了 Pointer Container 库用于存储动态分配对象的实际应用,并使用 Boost Iterator 库从底层容器生成各种值序列。

第六章,“Bimap 和多索引容器”,介绍了来自 Boost 的双向映射和多索引容器两个巧妙的容器模板。

第七章,“高阶和编译时编程”,深入使用 Boost 类型特征和模板元编程库进行编译时编程。我们首次研究了领域特定嵌入式语言,并使用 Boost Phoenix 构建基本表达式模板。我们使用 Boost Spirit 构建简单的解析器,使用 Spirit Qi DSEL。

第八章,“日期和时间库”,介绍了 Boost Date Time 和 Boost Chrono 库,用于表示日期、时间点、间隔和周期。

第九章,“文件、目录和 IOStreams”,介绍了使用 Boost Filesystem 库操作文件系统条目,以及使用 Boost IOStreams 库执行具有丰富语义的类型安全 I/O。

第十章,“使用 Boost 进行并发”,使用 Boost Thread 库和 Boost Coroutine 库编写并发逻辑,并展示了各种同步技术的实际应用。

第十一章,“使用 Boost Asio 进行网络编程”,展示了使用 Asio 库编写可扩展的 TCP 和 UDP 服务器和客户端的技术。

附录 A,“C++11 语言特性模拟”,总结了 C++11 移动语义和 Boost 在 C++03 中模拟了几个 C++11 特性。

本书需要什么

您需要一台能够运行支持 Boost 支持的 C++编译器工具链的操作系统的计算机。您可以在www.boost.org/doc/libs/release/libs/log/doc/html/log/installation.html找到更多详细信息。

要编译和运行本书中的代码,您需要安装 Boost 库的 1.56 版本或更高版本。有关更多详细信息,请参阅第一章,“介绍 Boost”。

本书中许多代码示例需要 C++11 支持,因此,您应选择具有良好 C++11 支持的编译器版本。您可以在en.cppreference.com/w/cpp/compiler_support找到更多详细信息。

可下载的源代码提供了一个 CMake 项目,以帮助您使用您喜欢的构建系统(gmake 或 Microsoft Visual Studio)快速构建所有示例。为了使用这个项目,您需要安装 CMake 2.8 或更高版本。有关更多详细信息,请参阅第一章,“介绍 Boost”。

本书尽量不重复在线参考手册中的内容。您应该结合本书和 Boost 库的在线参考手册,大量地发现额外的属性、函数和技术。您可以在www.boost.org/doc/libs/找到文档。

最后,本书中的代码清单有时为了简洁和重点而被删节。本书附带的代码示例是这些清单的完整版本,您在尝试构建示例时应使用它们。

本书适合谁

本书适用于每个对学习 Boost 感兴趣的 C++程序员。特别是,如果您以前从未使用过 Boost 库,学习 Boost C++库将帮助您快速了解、构建、部署和使用 Boost 库。如果您熟悉 Boost 库,但希望有一个跳板来深入了解并将您的专业知识提升到下一个水平,本书将为您全面介绍最有用的 Boost 库以及在实际代码中使用它们的方法。

Boost 是一组 C++库,自然,C++是本书中唯一使用的语言。您需要对 C++有很好的工作知识。特别是,您应该能够阅读使用 C++模板的代码,了解 C++编译模型,并能够在 Linux、Windows 或 Mac OS 上使用 C++开发环境。

本书通常不涵盖一般的 C++概念,但在一些章节的末尾列出的一些有用的 C++书籍和文章应该作为优秀的参考资料。

约定

在本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是一些样式的示例及其含义的解释。

文本中的代码词和 C++语言关键字显示如下:“我们将async_receive返回的字节数传递给处理程序。”

文本中的文件夹名称、文件名、文件扩展名、路径名、包含文件名显示如下:“头文件boost/asio.hpp包含了使用 Asio 库所需的大部分类型和函数”。

代码块设置如下:

46 int main() {
47   asio::io_service service;
48   UDPAsyncServer server(service, 55000);
49
50   boost::thread_group pool;
51   pool.create_thread([&service] { service.run(); });
52   pool.create_thread([&service] { service.run(); });
53   pool.join_all();
54 }

除了较小的代码片段外,每行代码都标有编号,以便在文本中进行引用。代码块中的重要代码行如上所示突出显示,并在文本中使用括号中的行号进行引用(第 51-52 行)。

任何命令行输入都写成如下形式:

$ g++ -g listing1.cpp -o listing1 -lboost_system -lboost_coroutine -lboost_date_time -std=c++11

重要的新编程术语以粗体显示。概念术语以斜体显示。

注意

关于一个主题的重要额外细节会以这种方式出现,就像边栏中那样。

提示

重要的注释、提示和技巧会以这种方式出现。

第一章:介绍 Boost

欢迎来了解关于最丰富的 C++库集合 Boost。在这个介绍性章节中,我们将看到:

  • Boost 的历史和发展

  • Boost 是什么?

  • 使用 Boost 库入门

就像本书中的所有章节一样,这是一个需要您输入命令、编写和测试代码的实践性章节。因此,您应该有一台配备相当现代的 C++编译器和互联网连接的计算机,以下载免费软件,包括 Boost 库。

一切是如何开始的

大约在 1997-98 年,当第一个 C++标准的草案定稿为 ISO/IEC 标准出版时,IBM 实验室的 Robert Klarer 构想了一个名为 BOOSE(发音为“booz”)的编程语言的想法,它将与 Java 在高性能嵌入式软件开发领域竞争,而后者一直以来都是针对这个领域的。在 1998 年为现已停刊的C++ Report杂志撰写的一篇文章中,C++专家 Herb Sutter 以一种半开玩笑的方式讽刺了这种新语言,它的名字据说扩展为 Bjarne 的面向对象软件环境。在这篇文章中,他声称可移植性和“可饮性”是这种语言的关键优势之一,它还据说在团队环境中促进了非凡的友谊,并使开发人员过分快乐、善于交流和充满激情。

虽然这是 1998 年愚人节的一篇文章,但事实仍然是,第一个 C++标准将拥有一个相当基本的标准库,包括内存分配子系统、类型通用容器和算法、字符串类、输入和输出设备的基本抽象,以及各种实用工具。大约在同一时间,C++标准委员会的一些人成立了一个小组,致力于制作一套高质量、经过同行评审、免费且开源的 C++库集合,这些库将具有广泛的适用性,并补充标准 C++的功能。受到 BOOSE 的启发,也许是因为它声称与 Java 竞争,后者是一种更新的语言,但具有更丰富的库,他们将这一倡议命名为 Boost,这个工作名称一直沿用至今(来源:Boost 网站的常见问题解答,www.boost.org)。

Boost 是什么?

Boost 是一套免费、经过同行评审、可移植、开源的 C++库。截至目前为止,Boost 已经发布了 57 个版本的库。在这段时间里,Boost 发布了一些非常有用的库,促进了正确、可移植、高效和可读的 C++代码。一些著名的标准委员会成员也是 Boost 最活跃的参与者,C++标准化的后续方向受到了 Boost 工作的重大影响。Boost 为标准委员会提供了他们需要完善的想法,以便 C++应该具有最佳新功能。一些 Boost 库被包括在 C++标准委员会的技术报告 1中,这大大增强了 C++ 2003 修订标准中定义的功能;这些功能包括语言和库功能。其中大多数库已经包含在 2011 年发布的 C++11 标准中。一些起源于 Boost 的库功能已经添加到最新的 C++标准修订版 C++14(2014 年发布)中。

多年来,Boost 增加了用于字符串和文本处理的库,包括正则表达式处理,与标准库兼容的通用容器,用于高效异常安全内存管理的智能指针,并发编程,网络编程,进程间通信,文件系统处理,模板元编程等等。以下表格列出了一些主要的 Boost 库,按类别分组。这只是一部分:

类别
内存管理 智能指针,对齐,池
数据结构 容器,数组,多索引,双向映射,指针容器,可选,变体,任意,元组,分配
算法 算法,范围
字符串和文本 转换,字符串算法,正则表达式,标记化器,Spirit,Xpressive
系统编程 系统,文件系统,Chrono,日期时间,线程,Asio,Interprocess
I/O IOStreams,Locale,Serialization,Format
高阶编程 函数,绑定,Phoenix,Signals2
通用编程 启用 If,类型特征,MPL,Fusion,Proto
语言特性仿真 Foreach,Move,Exception,Parameter
正确性和测试 测试,静态断言
其他 实用程序,日志,UUID,程序选项,CRC

由于一些高性能库(如 Boost.Asio 和 Boost.Intrusive)以及非常宽松和简单的 Boost 许可证,允许源重新分发、派生作品的分发以及非商业和商业目的的二进制形式的分发,Boost 库在行业中得到了各种用途。在接下来的部分中,我们将设置一个开发环境,使我们能够使用一致的约定在我们的 C++代码中使用任何 Boost 库。这应该能够为我们在本书的其余部分提供帮助。

开始使用 Boost 库

现在我们将为您设置一个开发沙箱,以便您可以使用 Boost 库编写代码。我们可以安装 Boost 库的二进制发行版,也可以从源代码构建它们。如果我们从源代码构建它们,我们需要解决一系列问题,从选择适当的库文件命名约定和构建库,到确保我们将它们链接到正确版本的库。还有需要处理的特定于平台的差异;我们将看看 Linux 和 Windows 环境。

必要的软件

在 Linux 上,我们只考虑 C++编译器(g++)版本 4.8.1 或更高版本,与GNU 编译器集合GCC)一起分发。在 Windows 上,我们将使用 Visual Studio 2013。您可以在 Boost 网站上获取每个 Boost 版本的更详尽的软件支持矩阵。

Linux 工具链

您应该能够在大多数主要的 Linux 发行版上构建 Boost。我使用的是 Lubuntu 14.04 32 位安装,配备了 GCC 4.8.1 和 Clang 3.4。您可能也可以在更旧的发行版上构建,因为 Boost 网站将 GCC 3.3 列为最低支持的版本。如果您还想要良好的 C++11 支持,请使用 GCC 4.8 或更高版本。

所需软件 最低版本 推荐版本 Ubuntu 软件包 Fedora/CentOS 软件包
GNU C++编译器 4.8.x 4.8.4 g++ gcc-c++
GNU 标准 C++库 4.8.x 4.8.4 libstdc++-dev libstdc++-devel
GNU 标准 C++运行时 4.8.x 4.8.4 libstdc++ libstdc++

如果您想使用 Clang 而不是 GCC,则推荐版本为 3.4 或更高版本。以下是 Ubuntu 上所需的软件包:

所需软件 最低版本 推荐版本 Ubuntu 软件包
LLVM 编译器工具链 3.2 3.4 llvm
LLVM C、C++和 Objective-C 编译器 3.2 3.4 clang
LLVM C++标准库 3.2 3.4 libc++-dev

Windows 工具链

您应该能够在 Visual Studio 7.1 及更高版本上构建 Boost。我在 Windows 7 64 位安装的 Visual Studio 2013 上使用:

所需软件 最低版本 推荐版本
Visual Studio 与 Visual C++ 7.1 12(2013)

我还建议在 Windows 上安装 7-Zip,以从.7z.tar.bz2存档中提取 Boost 源代码,这比.zip存档具有更好的压缩效果。

获取和构建 Boost 库

您可以从源代码构建 Boost 库,也可以在支持此类包的平台上将其安装为操作系统包。本书中的所有示例都使用 Boost 版本 1.57。您可以选择下载更近期的源代码版本,大部分讨论仍然适用。但是,一些细节可能会在不同版本之间发生变化,因此您应该准备查阅在线文档。

规划您的 Boost 沙盒

作为日常开发工作的一部分,我们需要访问 Boost 的头文件和库。大量的 Boost 库是仅头文件,这意味着您只需要包含适当的头文件并构建您的源代码。其他一些库必须构建为可以静态或动态链接到您的应用程序的二进制库。

如果我们从源代码构建,我们首先会在开发机器上确定一个目录,用于安装这些文件。选择是任意的,但如果存在惯例,我们可以遵循。因此,在 Linux 上,我们可以选择将库头文件和二进制文件安装在/opt/boost下。在 Windows 上,可以是f:\code\libraries\Boost。您可以选择不同的路径,只需避免其中包含空格以减少麻烦。

库命名约定

Boost 库二进制文件的名称可能一开始很难解读。因此,我们将学习库名称的构成。库名称有不同的布局。根据布局的不同,不同的组件会被添加到基本名称中,以便识别库的二进制兼容性和功能的不同方面。

库名称组件

每个库,无论是静态的还是共享的,都按照一个明确定义的方案命名。库的名称可以分为几个组件,其中并非所有都是必需的:

  • 前缀:库可能有一个前缀,通常是lib。在 Windows 上,只有静态库有这个前缀,而在 Unix 上,所有库都有这个前缀。

  • 工具集标识符:库名称可能会被标记为一个字符串,用于标识构建时所使用的工具集。工具集或工具链大致上是一组系统工具,包括编译器、链接器、存档工具等,用于构建库和程序。例如,vc120标识了 Microsoft Visual C++ 12.0 工具链。

  • 线程模型:如果一个库是线程安全的,也就是说,它可以在多线程程序中使用而不需要额外的同步,那么它的名称可能会被标记为mt,代表多线程。

  • ABI:ABI 代表应用程序二进制接口。这个组件包含了一些细节,比如库是否是调试库(d),是否链接到调试版本的运行时(g),以及链接到运行时的方式是静态的(s)还是动态的。因此,一个静态链接到发布版本运行时的调试库只会被标记为sd,而一个动态链接到调试版本的库会被标记为gd。动态链接到发布版本运行时的发布版本库将没有 ABI 标记。

  • 版本:这是 Boost 库的版本字符串。例如,1_57将是 Boost 1.57 库的版本标记。

  • 扩展名:库扩展名标识文件类型。在 Windows 上,动态库的扩展名是.dll,而静态库和导入库的扩展名是.lib。在 Linux 和其他一些 Unix 系统上,动态库的扩展名是.so,而静态库或存档的扩展名是.a。动态库扩展名通常带有版本后缀,例如.so.1.57.0

库名称布局

库名称由其组件组成的方式决定了其名称布局。Boost 支持三种名称布局:带版本号的、系统的和带标签的。

带版本号的布局

这是最复杂的布局,也是 Windows 上的默认布局。版本化布局名称的一般结构是libboost_<name>-<toolset>-<threading>-<ABI>-<version>.<ext>。例如,这是 Windows 上Boost.Filesystem库的调试 DLL:boost_filesystem-vc100-mt-gd-1_57.dll。文件名中的标记讲述了完整的故事。这个 DLL 是使用 Visual C++ 10.0 编译器(-vc100)构建的,是线程安全的(-mt),是一个调试 DLL(d),动态链接到调试版本的运行时(g)。Boost 的版本是 1.57(1_57)。

系统布局

Unix 上的默认布局是系统布局,去除了所有的名称装饰。在这个布局中,库名称的一般结构是libboost_<name>.<ext>。例如,这是 Linux 上的Boost.System共享库:libboost_filesystem.so.1.57.0。看着它,无法判断它是否支持多线程,是否是调试库,或者从版本化布局的文件名中获取的任何其他细节。扩展名的1.57.0后缀表示共享库的版本。这是 Unix 共享库版本的约定,不受 Boost 名称布局的影响。

标记布局

还有第三种布局称为标记布局,它在细节上介于版本化布局和系统布局之间。它去除了所有版本信息,但保留了其他信息。它的一般结构是libboost_<name>-<threading>-<ABI>.<ext>

这是使用非默认标记布局构建的 Windows 上的Boost.Exception静态库:libboost_filesystem-mt.lib。这是一个静态库,其lib-前缀表示。此外,-mt表示此库是线程安全的,缺少 ABI 指示器意味着这不是调试库(d),也不链接到静态运行时(s)。此外,它不链接到调试版本的运行时(g)。

版本化布局有点笨拙。在需要手动指定要链接的库名称的系统上,从一个 Boost 版本移动到下一个版本需要一些努力来修复构建脚本。系统布局有点简约,非常适合只需要给定库的一个变体的环境。但是,系统布局不能同时拥有库的调试版本和发布版本,或者线程安全和线程不安全的库。因此,在本书的其余部分,我们将只使用库的标记布局。我们还将只构建线程安全库(-mt)和共享库(.dll.so)。一些库只能构建为静态库,并且会被 Boost 构建系统自动创建。因此,现在我们终于有足够的信息来开始创建我们的 Boost 沙箱。

安装 Boost 二进制发行版

在 Microsoft Windows 和几个 Linux 发行版上,您可以安装 Boost 库的二进制发行版。以下表格列出了在一些流行操作系统上安装 Boost 的方法:

操作系统 包名称 安装方法
Microsoft Windows boost_1_57_0-msvc-12.0-64.exe(64 位)boost_1_57_0-msvc-12.0-32.exe(32 位) sourceforge.net/projects/boost/files/boost-binaries/下载可执行文件并运行可执行文件进行安装
Ubuntu libboost-all-dev
sudo apt-get install libboost-all-dev

|

Fedora/CentOS boost-devel
sudo yum install boost-devel

|

安装二进制发行版很方便,因为这是最快的上手方式。

在 Windows 上安装

从 Boost 1.54 开始,您可以从 SourceForge 下载使用 Microsoft Visual Studio 构建的 Boost 库的二进制发行版。下载可用作 64 位或 32 位可安装可执行文件,其中包含头文件、库、源代码、文档和工具。不同版本的 Visual Studio 有单独的发行版,从版本 12(VS 2013)向后退到版本 8(VS 2005)。可执行文件的名称形式为boost_ver-msvc-vcver-W.exe,其中ver是 Boost 版本(例如 1_57_0),vcver是 Visual C++的版本(例如 Visual Studio 2013 的 12.0),W是您操作系统的本机字长(例如 64 或 32)。

作为安装的一部分,您可以选择要安装 Boost 库的目录。假设您选择将其安装在boost-dir下。然后,以下目录包含必要的头文件和库:

目录 文件
boost-dir 这是 Boost 安装的基本目录。所有头文件都在boost子目录下的层次结构中。
boost-dir/libW-msvc-vcver 此目录包含所有变体的 Boost 库,静态和共享(DLL),调试和发布。库文件名遵循版本布局。W:32 或 64,取决于您安装的是 32 位版本还是 64 位版本。vcver:Visual Studio 版本。
boost-dir/doc 此目录包含 HTML 格式的库文档,并包含构建 PDF 文档的脚本。

在 Linux 上安装

在 Ubuntu 上,您需要安装libboost-all-dev软件包。您需要使用超级用户权限执行安装,因此运行以下命令:

$ sudo apt-get install libboost-all-dev

这将在以下目录中安装必要的头文件和库:

目录 文件
/usr/include 这包含了boost子目录下层次结构中存在的所有头文件。
/usr/lib/arch-linux-gnu 这包含了所有 Boost 库,静态和共享(DSO)。库文件名遵循系统布局。用x86_64替换 arch 以用于 64 位操作系统,用i386替换 arch 以用于 32 位操作系统。

在 CentOS/Fedora 上,您需要安装boost-devel软件包。您需要使用超级用户权限执行安装,因此运行以下命令:

$ sudo yum install boost-devel

这将在以下目录中安装必要的头文件和库:

目录 文件
/usr/include 这包含了 boost 目录下层次结构中存在的所有头文件。
/usr/lib 这包含了所有 Boost 库,静态和共享(DSO)。库文件名遵循系统布局。

从源代码构建和安装 Boost 库

从源代码构建 Boost 库提供了更多的灵活性,因为可以轻松定制构建、使用替代编译器/工具链,并更改默认的名称布局,就像我们计划的那样。我们将从 Boost 网站www.boost.orgsourceforge.net/projects/boost下载源代码存档。我更喜欢 7-Zip 或 bzip2 存档,因为它们具有最佳的压缩比。我们将使用 Boost 库版本 1.57,并且只会在 Linux 和 Windows 操作系统上构建它们。

可选软件包

当存在时,有几个可选软件包用于提供某些 Boost 库的额外功能。这些包括:

  • zlibbzip2开发库,被Boost.IOStream用于读取和写入gzipbzip2格式的压缩存档

  • ICU i18n开发库,被Boost.LocaleBoost.Regex用于支持 Unicode 正则表达式

  • expat XML 解析器库,被Boost.Graph库用于支持描述图形的 GraphML XML 词汇

其中一些库可能通过您的本地软件包管理系统提供,特别是在 Linux 上。当从这些软件包安装时,Boost 构建系统可能会自动找到这些库并默认链接它们。如果您选择从源代码构建这些库并将它们安装在非标准位置,那么您应该使用特定的环境变量来指向这些库的安装目录或includelibrary目录。以下表总结了这些可选库、它们的源网站、Ubuntu 软件包名称以及 Boost 在从源代码安装时识别它们所需的环境变量:

详情
Zlib 库(www.zlib.net) 环境变量:ZLIB_SOURCE(提取的源目录)Ubuntu 软件包:zlib1gzlib1g-devzlib1c
Bzip2 库(www.bzip.org/downloads.html) 环境变量:BZIP2_SOURCE(提取的源目录)Ubuntu 软件包:libbz2libbz2-dev
ICU 库(www.icu-project.org/download) 环境变量:HAVE_ICU=1``ICU_PATH(安装根目录)Ubuntu 软件包:libicu-dev
Expat 库(sourceforge.net/projects/expat) 环境变量:EXPAT_INCLUDE(expat 包含目录)和EXPAT_LIBPATH(expat 库目录)Ubuntu 软件包:libexpat1libexpat1-dev

我们将在第九章文件,目录和 IOStreams中使用gzipbzip2库来压缩数据,而我们将不会在本书的代码示例中使用 ICU 和 Expat 库。

在 Linux 上构建 Boost 库

如果您选择不安装 Boost 的二进制发行版,或者如果您的平台上没有这样的发行版可用,那么您必须从源代码构建 Boost 库。下载 Boost 库、zlibbzip2的源代码存档。假设您想要将 Boost 安装在/opt/boost目录中,从 shell 命令提示符执行以下步骤来使用 GNU 工具链构建 Boost:

  1. 创建一个目录并在其中提取 Boost 源代码存档:
$ mkdir boost-src
$ cd boost-src
$ tar xfj /path/to/archive/boost_1_57_0.tar.bz2
$ cd boost_1_57_0

  1. 为您的工具集生成 Boost 构建系统。如果您正在使用g++构建,以下内容应该有效。
$ ./bootstrap.sh

如果您使用的是 Clang,运行以下命令:

$ ./bootstrap.sh toolset=clang cxxflags="-stdlib=libc++ -std=c++11" linkflags="-stdlib=libc++"

  1. 提取bzip2zlib源代码存档,并记下它们被提取到的目录。

  2. 构建库并安装它们。对于 GCC,运行以下命令:

$ ./b2 install --prefix=/opt/boost --build-dir=../boost-build --layout=tagged variant=debug,release link=shared runtime-link=shared threading=multi cxxflags="-std=c++11" -sZLIB_SOURCE=<zlib-source-dir> -sBZIP2_SOURCE=<bzip2-source-dir>

对于 Clang,改为运行以下命令:

$ ./b2 install toolset=clang --prefix=/opt/boost --build-dir=../boost-build --layout=tagged variant=debug,release link=shared runtime-link=shared threading=multi cxxflags="-stdlib=libc++ -std=c++11" linkflags="-stdlib=libc++" -sZLIB_SOURCE=<zlib-source-dir> -sBZIP2_SOURCE=<bzip2-source-dir>

最后一步应该构建所有的 Boost 库并将它们安装在/opt/boost目录下,由--prefix选项标识。所有的库将安装在/opt/boost/lib下,所有的包含文件将安装在/opt/boost/include下。除了 Boost 库之外,您还应该看到libboost_zlib-mt.solibboost_bzip2-mt.so——zlibbzip2的动态共享对象,libboost_iostreams-mt.so依赖于它们。

  • --build-dir选项将标识构建的中间产品所在的目录。

  • --layout=tagged选项选择了库名称的标记布局。

  • 如果可能的话,我们将只构建线程安全(threading=multi)的共享库(link=shared),将它们链接到动态运行时(runtime-link=shared)。我们需要库的调试和发布版本(variant=debug,release)。

  • 使用-sZLIB_SOURCE=<zlib-source-dir>选项来指向构建目录,在第 3 步中提取zlib源代码的目录;同样,对于bzip2源代码目录,使用-sBZIP2_SOURCE=<bzip2-source-dir>

  • 如果您想要使用对 C++11 的支持构建 Boost 库,那么您应该使用cxxflags="-std=c++11"选项。在本书的其余部分,许多代码示例使用了 C++11 的特性。在这一点上启用 Boost 的 C++11 构建可能是一个好主意。确保您的编译器对 C++11 有良好的支持。对于 g++,版本应为 4.8.1 或更高。此外,确保您编译所有自己的代码时也使用了 Boost 库的 C++11。

注意

本书中的大多数示例使用了 C++11 的特性,因此在编译 Boost 时应该保持 C++11 选项开启。附录提供了本书中使用的重要 C++11 特性的简要介绍,并描述了如果您仍在使用旧编译器,如何使用 Boost 在 C++03 中模拟它们。

在 Windows 上构建 Boost 库

一旦您下载了 Boost 源存档,在 Windows 资源管理器会话中,创建一个名为boost-src的目录,并在此目录中提取源存档。假设您想要在boost-dir目录中安装 Boost,并且boost-build是保存构建中间产品的目录,请从命令提示符执行以下步骤:

  1. 初始化 32 位 Visual C++构建环境以构建 Boost 构建系统(即使您想要构建 64 位):
"C:\Program Files\Microsoft Visual Studio 12.0\VC\vcvarsall.bat" x86

  1. 在 64 位系统上安装 32 位 Visual Studio 时,Visual Studio 通常安装在C:\Program Files (x86)下,因此您将不得不运行以下命令:
"C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\vcvarsall.bat" x86

  1. 为您的工具集生成 Boost 构建系统:
cd /d drive:\path\to\boost-src
bootstrap.bat

  1. 如果您想要构建 64 位 Boost 库,请初始化 64 位 Visual C++构建环境:
"C:\Program Files\Microsoft Visual Studio 12.0\VC\vcvarsall.bat" x86_amd64

  1. 在 64 位系统上安装 32 位 Visual Studio 时,您将不得不运行以下命令:
"C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\vcvarsall.bat" x86_amd64

  1. 提取bzip2zlib源存档,并记下它们被提取到的目录。

  2. 构建库并安装它们。如果您想要构建 32 位库,请使用以下命令行:

b2 install --libdir=boost-dir\libs --includedir= boost-dir\include --build-dir= boost-build --layout=tagged variant=debug,release threading=multi link=shared runtime-link=shared -sZLIB_SOURCE=<zlib-src-dir> -sBZIP2_SOURCE=<bzip2-src-dir>

  1. 如果您想要构建 64 位库,请使用以下命令行:
b2 install --libdir=boost-dir\libs64 --includedir= boost-dir\include --build-dir= boost-build64 --layout=tagged variant=debug,release threading=multi link=shared runtime-link=shared address-model=64 –sZLIB_SOURCE=<zlib-src-dir> -sBZIP2_SOURCE=<bzip2-src-dir>

这最后一步在以下目录中构建并安装了必要的头文件和库:

目录 文件
boost-dir/include boost目录下的所有头文件。
boost-dir/libs 所有 32 位 Boost 库,静态和共享库(DLL),调试和发布。
boost-dir/libs64 所有 64 位 Boost 库,静态和共享库(DLL),调试和发布。

除了 Boost 库,您还应该看到boost_zlib-mt.dllboost_bzip2-mt.dll——boost_iostreams-mt.dll依赖的zlibbzip2的 DLL。

让我们来看看我们在前面命令中使用的各种选项:

  • --build-dir选项将标识出构建的中间产品所在的目录。

  • --layout=tagged选项选择了库名称的标记布局,如前所述。

  • 我们将只构建共享库(link=shared)。如果可能的话,将它们链接到动态运行时(runtime-link=shared),并创建线程安全库(threading=multi)。

  • 我们将需要库的调试版本和发布版本(variant=debug,release)。

  • 32 位和 64 位构建将在由--build-dir选项标识的不同中间目录中进行,并将被复制到由--libdir选项标识的不同库目录中。

  • address-model=64选项将触发 64 位构建。

在 Visual Studio 2013 下,C++11 支持会自动启用,您无需为此使用任何特定的开关。

在您的项目中使用 Boost 库

现在我们将编写我们的第一个小型 C++程序,该程序使用 Boost 文件系统库来检查命令行传递的文件名的存在,并在 Linux 和 Windows 上构建。

这是chkfile.cpp的清单:

 1 #include <iostream>
 2 #include <boost/filesystem.hpp>
 3 // define a short alias for the namespace
 4 namespace boostfs = boost::filesystem;
 5
 6 int main(int argc, char *argv[])
 7 {
 8   if (argc <= 1) {
 9     std::cerr << "Usage: " << argv[0] << " <filename>"
10               << std::endl;
11     return 1;
12   }
13
14   boostfs::path p(argv[1]);
15
16   if (boostfs::exists(p)) {17     std::cout << "File " << p << " exists." << std::endl;
18   } else {
19     std::cout << "File " << p << " does not exist." << '\n';
20   }
21
22   return 0;
23 }

在 Linux 上链接 Boost 库

如果您在非标准位置安装了 Boost(如果您没有从本机包安装它,这通常是情况),那么您需要确保您的预处理器可以使用编译器中的-I选项找到您包含的 Boost 头文件:

$ g++ -c chkfile.cpp -I/opt/boost/include -std=c++11

这一步将创建一个名为chkfile.o的目标文件,我们将把它链接到二进制文件。您可以使用-l选项指定要链接到的库。在非标准安装的情况下,您需要确保链接器可以使用-L选项找到要链接的库的路径:

$ g++ chkfile.o -o chkfile -L/opt/boost/lib -lboost_filesystem-mt -lboost_system-mt -std=c++11

注意

只有在使用 C++11 构建 Boost 库时才使用-std=c++11选项。

前面的命令行将适用于静态库或共享库。但是,如果找到了两种类型的库,它将使用共享版本。您可以使用适当的链接器选项覆盖此行为:

$ g++ chkfile.o -o chkfile -L/opt/boost/lib -Wl,-Bstatic -lboost_filesystem-mt -Wl,-Bdynamic -lboost_system-mt -std=c++11

在前面的案例中,filesystem库是静态链接的,而其他库是动态链接的。使用-Wl开关将其参数传递给链接器。在这种情况下,它传递了-Bstatic-Bdynamic开关。

如果您链接的是共享库,那么在运行时,动态链接器需要定位共享库并加载它。确保这一点的方法因 Unix 的不同版本而异。确保这一点的一种方法是使用rpath链接器指令在可执行文件中嵌入一个搜索路径:

$ g++ -o chkfile chkfile.o -L/opt/boost/lib -lboost_filesystem-mt -lboost_system-mt -Wl,-rpath,/opt/boost/lib:/usr/lib/boost -std=c++11

在运行二进制文件mytest的目标系统上,动态链接器将在/opt/boost/lib/usr/lib/boost下查找filesystemsystem共享库。

除了使用rpath机制之外,还有其他方法。Linux 使用一个叫做ldconfig的实用程序来定位共享库并更新搜索路径。有关更多详细信息,请查看ldconfig (8)的 man 页面。在 Solaris 上,crle实用程序执行类似的操作。

在 Windows 上链接 Boost 库

使用 Visual Studio IDE,我们将不得不调整某些项目设置,以便链接到 Boost 库。

首先,确保您的编译器能够找到必要的头文件:

  1. 在 Visual Studio 中打开您的 C++项目。从菜单中选择项目 | 项目属性

  2. 在弹出的属性页对话框中,展开配置属性并选择C/C++

  3. 通过添加路径到您的 Boost 包含目录,编辑附加包含目录的值。用分号与字段中的其他条目分隔开:在 Windows 上链接 Boost 库

  4. 接下来,确保您的链接器能够找到共享或静态库。在项目属性对话框中,在配置属性下,选择链接器

  5. 编辑附加库目录字段,添加路径到 Boost 库,用分号与字段中的其他条目分隔开:在 Windows 上链接 Boost 库

  6. 现在,您可以在 Windows 上利用 Boost 的自动链接功能自动链接到正确的库。要启用此功能,您必须定义BOOST_ALL_DYN_LINK预处理符号。要做到这一点,在项目属性对话框中,导航到配置属性 | C/C++ | 预处理器,并将BOOST_ALL_DYN_LINK添加到预处理器定义字段中,并用分号与其他条目分隔开。

如果您在 Windows 上使用默认布局(带版本号)构建了 Boost 库,这就是您正确链接所需做的一切。如果我们使用了标记布局,我们还必须定义第二个预处理符号BOOST_AUTO_LINK_TAGGED。如果我们使用系统布局进行命名,我们将需要定义BOOST_AUTO_LINK_NOMANGLE。如果没有这些定义,您将收到链接器错误:

在 Windows 上链接 Boost 库

现在你应该能够在 IDE 中构建你的项目而不会出现任何问题。为了运行你的程序,动态链接器必须能够找到动态库。在 Windows 上,你可以将 Boost 库的路径添加到 PATH 环境变量中。在 IDE 中运行程序时,你可以通过导航到调试 | 环境,将 Boost 库的路径添加到 PATH 变量中,如下截图所示:

在 Windows 上链接 Boost 库

构建本书中的代码示例

本书的每一章都包括示例源代码,也可以从 Packt 网站(www.packtpub.com)下载。你应该在你的开发机器上下载并构建这些示例。

CMake

为了构建示例,你需要安装 CMake,这是 C++程序最流行的跨平台构建工具之一。使用 CMake,你可以在你选择的操作系统上轻松生成一个构建系统,使用一组 CMake 规范。

你可以从www.cmake.org下载 CMake 的二进制包,或者下载源代码存档并在你选择的平台上构建它。

注意

最低版本要求:CMake 2.8。

Windows:Windows 有一个 32 位的 exe 安装程序,适用于 32 位和 64 位版本。

Linux:CMake 通常捆绑在所有主要的 Linux 发行版中,并作为一个可选包提供。请查阅你的发行版软件包库。

代码示例

下载源代码存档并将其解压到开发机器上的一个目录。解压后的目录布局如下:

代码示例

可下载的源代码存档包含每一章的单独目录。在每个章节目录中,你会找到每个示例的完整源代码。源代码文件的命名基于列表标识符。

列表标识符是本书中示例的唯一标签,如下截图所示:

代码示例

在这里,列表标识符是列表 11.18,表示这是第十一章中的第 18 个示例,使用 Boost Asio 进行网络编程。因此,在ch11文件夹中,你会找到listing11_18.cpp,其中包含了出现在第十一章中的异步 UDP 服务器示例,使用 Boost Asio 进行网络编程。在某些情况下,一个大的示例被分解成文本中的多个列表,但它们都是同一个源文件的一部分。在这种情况下,列表会被标记为字母;例如,列表 7.22a,7.22b,7.22c 等。你仍然可以期望有一个名为listing7_22.cpp的文件,其中包含了这些列表中的代码。

为了构建本书中的所有示例,你需要按照以下步骤进行:

  1. 确保安装了 CMake 2.8 或更高版本。

  2. 将本书的源代码存档解压到一个目录,比如srcdir

  3. 切换到源目录下的cmake_bin目录:

$ cd srcdir/lbcpp-src/cmake_bin

  1. 导出BOOST_DIR环境变量,指向 Boost 安装目录。

例如,在 Linux 上,如果是/opt/boost,你可以运行以下命令:

$ export BOOST_DIR=/opt/boost

如果你已经从发行版的软件包库中安装了 Boost,那么你可以跳过这一步。

在 Windows 上,如果你已经安装在f:\boost下,你可以运行这个命令:

set BOOST_DIR=f:\boost

  1. 如果 Boost 的包含目录和库目录没有共同的父目录,比如你安装了一个二进制发行版的 Boost,那么你应该跳过设置BOOST_DIR,而是设置以下两个环境变量:
  • BOOST_INCDIR应该设置为包含 Boost 头文件的目录,例如,在 Ubuntu 上为/usr/include

  • BOOST_LIBDIR应该设置为包含 Boost 库文件的目录,例如,在 Ubuntu 上为/usr/lib/x86_64-linux-gnu

  1. 使用 CMake 生成你选择的构建系统。

在 Linux 上,运行以下命令:

$ cmake

这将使用 GNU g++生成基于 Makefile 的构建系统。如果你想使用 clang++,可以像这样导出环境变量 CC 和 CXX:

export CC=`which clang`
export CXX=`which clang++`

在 Windows 上,运行以下命令:

$ cmake .. -G "Visual Studio 12"

这将生成一个 Visual C++ 2013 解决方案文件和项目文件。使用-G选项传递的字符串称为生成器字符串,用于标识要生成构建系统的工具链。CMake 文档列出了所有支持的生成器字符串。对于我们的目的,我们将使用Visual Studio 12Visual Studio 12 Win64

  1. 使用生成的构建系统构建源代码。

在 Linux 上,你可以通过简单地运行以下命令来构建它:

$ gmake

在 Windows 上,最好通过在 Visual C++ IDE 中打开生成的解决方案文件,然后构建所有源文件或一次构建一个源文件来构建。你可以通过运行在srcdir/lbcpp-src/bin下生成的可执行文件来运行示例。

我们在这本书中没有涵盖 CMake。值得自己进一步探索 CMake,一个很好的起点是 CMake Wiki(www.cmake.org/Wiki/CMake)。

自测问题

  1. Boost 库支持的不同类型的名称布局是什么?

a. 标记,本地和混淆

b. 标记,混淆和版本化

c. 标记,版本化和系统

d. 版本化,系统和装饰

  1. Boost 允许你在 Windows 上自动链接到必要的 Boost 库。

a. 真

b. 错误

  1. 以下文件名对你有什么了解?

boost_date_time-vc100-mt-gd-1_57.dll

选择所有适用的选项。

a. 这是 DateTime 库。

b. 这是一个线程安全的库。

c. 它是使用 g++构建的。

d. 这不是一个调试库。

  1. 以下库的名称布局是什么?

libboost_exception-mt-gd.lib

a. 标记

b. 系统

c. 版本化

d. 默认

总结

在本章中,我们概述了 Boost C++库,并为我们设置了一个开发环境,这应该帮助我们轻松地构建和运行 C++程序,使用我们将在本书的其余部分学习的 Boost 库。

在下一章中,我们将学习使用不同的 Boost 库的各种技术,这些库简化了一些日常编程任务,并为我们在后面的章节中要完成的繁重工作做好了准备。

第二章:Boost 实用程序的初次接触

在本书的过程中,我们将专注于处理不同子系统的多个 Boost 库,例如文件系统、线程、网络 I/O 和各种容器等。在每一章中,我们将深入探讨一些这样的库的细节。这一章不同之处在于,我们将挑选一些有用和多样化的技巧,这些技巧几乎可以帮助您解决所有编程情况。为此,我们为我们列出了以下主题:

  • 简单数据结构

  • 处理异构值

  • 处理命令行参数

  • 其他实用程序和编译时检查

这是一个厨房水槽章节,您可以不断回来查找一个在手头问题上似乎适用的有趣技术。

简单数据结构

在本节中,我们将介绍两个不同的库,它们将帮助您创建立即有用的简单数据结构:Boost.Optional 和 Boost.Tuple。Boost.Optional 可用于表示可选值;可能存在也可能不存在的对象。Boost.Tuple 用于创建异构值的有序集合。

Boost.Optional

让我们假设您需要在数据存储中维护有关音乐家的信息。除其他事项外,您可以查找艺术家发布的最新专辑。您已经用 C++编写了一个简单的 API 来实现这一点:

std::string find_latest_album_of(const std::string& artisteName);

为简单起见,我们将忽略两个或更多艺术家可能共享相同名称的可能性。以下是这个函数的一个简单实现:

 1 #include <string>
 2 #include <map>
 3
 4 typedef std::map<std::string, std::string> artiste_album_map;
 5
 6 extern artiste_album_map latest_albums;
 7
 8 std::string find_latest_album_of(
 9                     const std::string& artiste_name) {
10   auto iter = latest_albums.find(artiste_name);
11
12   if (iter != latest_albums.end()) {
13     return iter->second;
14   } else {
15     return "";
16   }
17 }

我们在一个名为latest_albums的映射中存储了艺术家的名字和他们的最新专辑。find_latest_album_of函数接受一个艺术家的名字,并使用std::mapfind成员函数来查找最新专辑。如果找不到条目,它会返回一个空字符串。现在,有些艺术家可能还没有发布专辑。对于这种情况返回一个空字符串似乎是合理的,直到你意识到音乐家有他们独特的怪癖,有时会发布没有名字的专辑。那么,你如何区分音乐家尚未发布专辑的情况和音乐家最新专辑没有标题的情况?在一种情况下,没有值可返回,而在另一种情况下,它是一个空字符串。

boost::optional<T>模板可用于表示可选值;可能存在也可能不存在的值。在这种情况下,它是为我们的问题量身定制的。要表示可能存在也可能不存在的std::string值,您可以使用boost::optional<std::string>。我们可以使用boost::optional重写find_latest_album_of函数,如下面的代码列表所示:

列表 2.1:使用 Boost.Optional

 1 #include <string>
 2 #include <map>
 3 #include <boost/optional.hpp>
 4
 5 typedef std::map<std::string, std::string> artiste_album_map;
 6
 7 extern artiste_album_map latest_albums;
 8 
 9 boost::optional<std::string> find_latest_album_of(
10                             const std::string& artiste_name) {
11   auto iter = latest_albums.find(artiste_name);
12
13   if (iter != latest_albums.end()) {
14     return iter->second;
15   } else {
16     return boost::none;
17   }
18 }

我们简单地返回找到的值(第 14 行),它会自动包装在boost::optional容器中。如果没有值可返回,我们返回一个特殊对象boost::none(第 16 行)。这会导致返回一个空的boost::optional对象。使用boost::optional的代码正是我们需要的;它检查容器中是否存在一个键,并返回值,或指示它不存在,而没有任何歧义(即空与无标题)。

提示

boost::optional的默认初始化实例始终为空。如果存储在boost::optional中的值是可移动的(参见附录,C++11 语言特性模拟),包装器optional对象也是可移动的。如果存储的值是可复制的,包装器optional对象也是可复制的。

我们可以将列表 2.1 中的查找函数泛化到任何具有类似映射或字典接口的容器中,如下所示:

列表 2.2:使用可选项进行通用查找

 1 #include <boost/optional.hpp>
 2
 3 template <typename C>
 4 boost::optional<typename C::mapped_type>
 5 lookup(const C& dict, const typename C::key_type& key)
 6 {
 7   typename C::const_iterator it = dict.find(key);
 8   if (it != dict.end()) {
 9     return it->second;
10   } else {
11     return boost::none;
12   }
13 }

在前面的代码中,我们已将lookup转换为函数模板,可以在任何mapmultimap、它们的无序变体或任何其他非标准容器上调用,暴露类似的接口。它是基于容器类型C进行参数化的。容器类型C必须具有嵌套类型定义:key_typemapped_type,对应于地图存储的键和值的类型;这是标准库中std:map和其他关联容器满足的约束。

typename关键字的使用(第 4、5、7 行)可能需要一些解释。如果我们从这些行中省略typename关键字,编译器将无法识别C::mapped_typeC::key_typeC::const_iterator作为类型的名称。因为mapped_typekey_typeconst_iterator是依赖于类型模板参数C的名称,所以需要告诉编译器它们标识类型。我们使用typename关键字来做到这一点。

访问存储在 boost::optional 中的值

您可以检查optional对象是否包含值或为空,并提取非空optional对象中存储的值:

 1 std::string artiste("Korn");
 2 boost::optional<std::string> album = 
 3                             find_latest_album_of(artiste);
 4 if (album) {
 5   std::cout << "The last album from " << artiste;
 6
 7   if (album->empty()) {
 8     std::cout << " is untitled\n";
 9   } else {
10     std::cout << " is named " << *album << '\n';
11   }
12 } else {
13   std::cout << "No information on albums from " 
14             << artiste << '\n';
15 }

在调用find_latest_album_of的代码中,为了测试返回的值是否为空,我们在布尔上下文中调用对象(第 4 行)。如果评估为true,这意味着album不为空。如果它有一个值,我们可以使用重载的operator*(第 10 行)获得对包含值的引用。我们可以使用重载的operator->访问底层对象的成员;在这种情况下,我们调用std::string的空成员函数(第 7 行)。我们还可以使用非空boost::optional对象的get成员函数来访问存储的值,而不是使用重载的operator*。通过调用operator*getoperator->对空的可选值进行解引用会导致运行时错误,这就是为什么我们首先检查optional对象是否为空,然后再尝试对其进行解引用。

get_value_or

使用optional,我们指示专辑可能有也可能没有值。但有时我们需要使用应该接受可选值但没有的 API。在这种情况下,我们可能希望返回带有一些默认值的空值。想象一下,问巴黎居民他们最喜欢的城市,对于那些没有回答的人,巴黎将被用作默认最爱:

 1 void printFavoriteCity(const std::string& name,
 2                        const std::string& city)
 3 {
 4   std::cout << name "'s favorite city is " << city << '\n';
 5 }
 6
 7 boost::optional<std::string> getFavoriteCity(
 8                           const std::string& resident_id);
 9 ...
10 std::string resident = "Serge";
11 boost::optional<std::string> fav_city = 
12                                     getFavoriteCity(resident);
13
14 printFavoriteCity(fav_city.get_value_or("Paris"));

如果想象中的getFavoriteCity函数返回一个空值,我们希望将Paris传递给printFavoriteCity函数。我们使用get_value_or成员函数来实现这一点(第 14 行)。

Boost.Optional 与指针

如果我们没有使用optional,那么find_last_album_oflookup函数会返回什么来指示没有找到值?它们要么需要返回指向动态分配对象的指针,要么在没有找到值时返回nullptr。除了使用动态内存,这还要求调用函数管理返回的动态分配对象的生命周期。这种情况可以通过智能指针(第三章,“内存管理和异常安全性”)来缓解,但它并不能消除昂贵的自由存储分配。boost::optional类消除了自由存储分配,并将封装的对象存储在其布局中。此外,它存储一个布尔标志来跟踪它是否被初始化。

Boost.Tuple

Boost Tuples 是一种将不同类型的数据组合成有序元组并传递它们的很酷的方法。结构也可以做同样的事情,但元组有一些特殊之处:

  • 您可以编写通用代码来操作各种元组,例如打印它们的所有成员并比较两个元组的结构和类型是否相似。

  • 每个新的结构或类在您的软件中定义了一个新的类型。类型应该表示接口和行为。用类型表示数据的每个临时聚集会导致类型的泛滥,这些类型在问题空间或其抽象中没有意义。

Boost Tuple 是一个非常有用的库,它可以帮助您方便地创建用于一起移动相关数据的模式,例如在函数之间交换数据。Boost Tuples 是std::pair的泛化,用于创建 2 元组。

提示

如果您正在使用支持良好的 C++11 的 C++编译器,应该使用标准库中的std::tuple工具,这是 C++11 标准中包含的 Boost 库之一。需要包含的头文件是<tuple>。我们在这里讨论的大部分内容都适用于std::tuple

创建元组

让我们看一个例子。给定不同时间点的股票价格系列,我们想要找出买入和卖出股票以最大化利润的最佳两个时间点。我们可以假设没有卖空的选项,也就是说,必须先买入再卖出。为简单起见,可以假定输入是一个双精度浮点数的向量。在这个向量中,我们对表示最佳买入和卖出股票的索引对感兴趣,以最大化利润:

清单 2.3:使用元组

 1 #include <boost/tuple/tuple.hpp>
 2 #include <vector>
 3
 4 boost::tuple<size_t, size_t, double>
 5      getBestTransactDays(std::vector<double> prices)
 6 {
 7   double min = std::numeric_limits<double>::max();
 8   double gain = 0.0, max_gain = 0.0;
 9   size_t min_day, max_day;
10   size_t buy_day;
11   for (size_t i = 0, days = prices.size(); i < days; ++i) {
12     if (prices[i] < min) {
13       min = prices[i];
14       min_day = i;
15     } else if ((gain = prices[i] - min) > max_gain) {
16       max_gain = gain;
17       buy_day = min_day;
18       max_day = i;
19     }
20   }
21
22   return boost::make_tuple(buy_day, max_day, max_gain);
23 }

函数getBestTransactDays返回一个包含两个无符号整数(size_t)和一个双精度浮点数(第 4 行)的元组,表示买入和卖出股票的最大利润的两个索引,以及可能的最大利润。函数的返回类型是boost::tuple<size_t, size_t, double>。头文件boost/tuple/tuple.hpp提供了处理元组所需的函数和类型(第 1 行)。

函数getBestTransactDays实现了一个简单的线性算法,通过遍历向量,跟踪到目前为止看到的最低股价。如果当前元素的值小于目前为止的最低股价,则将其设置为新的最低价,并记录其索引(第 12-14 行)。该函数还跟踪最大收益,即到目前为止记录的价格差的最大值。如果我们遇到一个与最低价的差值高于最大收益的元素,则将此差值记录为新的最大收益(第 15 行),并记录实现此收益所需的交易日(第 16-18 行)。

我们使用boost::make_tuple(第 22 行)创建元组,这是一个方便的函数,用于从其元素创建元组,而无需显式模板实例化。您也可以在第 22 行的位置创建并返回一个元组,如下所示:

22 boost::tuple<size_t, size_t, double> best_buy(buy_day, max_day, 
23                                         max_gain);
24 return best_buy;

正如您所看到的,boost::make_tuple更加紧凑,并且作为一个函数模板,它会自动解析其参数的类型,以创建正确类型的元组。这是一个经常见到的模式,您可以使用工厂函数模板来实例化类模板,从而自动检测类型。

访问元组元素

有几种方法可以访问元组中的元素。看一下调用getBestTransactDays函数的以下示例:

 1 std::vector<double> stockPrices;
 2 ...
 3 boost::tuple<size_t, size_t, double> best_buy = 
 4                              getBestTransactDays(stockPrices);
 5 
 6 size_t buyDay = boost::get<0>(best_buy);  // Access 0th element
 7 size_t sellDay = boost::get<1>(best_buy); // Access 1st element
 8 double profit = boost::get<2>(best_buy); // Access 2nd element

我们还可以使用boost::tie将元组中的元素解包到单独的变量中:

 1 size_t buyDay, sellDay;
 2 double profit;
 3 boost::tie(buyDay, sellDay, profit) =  
 4                 getBestTransactDays(stockPrices);

上一行代码将把元组的第一个元素分配给buyDay,第二个元素分配给sellDay,第三个元素分配给profit。如果我们只对元组中的部分元素感兴趣,可以使用boost::tuples::ignore忽略其他元素。以下是相同的示例,但这次我们使用boost::tuples::ignore忽略了sellDay

 1 size_t buyDay, sellDay;
 2 boost::tie(buyDay, sellDay, boost::tuples::ignore) =
 3                              getBestTransactDays(stockPrices);

比较元组

相同长度的元组可以使用关系运算符进行比较,例如==<><=>=。在任何这样的比较中,将比较每个位置上的对应元素。对应位置上的元素的类型不需要完全相同;它们只需要能够使用相关的关系运算符进行比较即可:

 1 boost::tuple<int, int, std::string> t1 = 
 2                          boost::make_tuple(1, 2, "Hello");
 3 boost::tuple<double, double, const char*> t2 = 
 4                         boost::make_tuple(1, 2, "Hi");
 5 assert(t1 < t2);   // because Hello < Hi

请注意,元组t1t2中的实际类型不同,但两者长度相同,并且相应位置的元素可以相互比较。通常,比较会在决定比较结果的第一对元素处停止。在这个例子中,所有三个元素都被比较,因为前两个元素相等。

 1 boost::tuple<int, int, std::string> t1 = 
 2                          boost::make_tuple(1, 20, "Hello");
 3 boost::tuple<double, double, const char*> t2 = 
 4                        boost::make_tuple(1, 2, "Hi");
 5 assert(t1 > t2);    // because 20 > 2

以下代码用于定义具有非常少代码的结构的关系运算符:

 1 struct my_type {
 2   int a;
 3   double b;
 4   char c;
 5 };
 6
 7 bool operator<(const my_type& left, const my_type& right) {
 8   return boost::make_tuple(left.a, left.b, left.c) <
 9                 boost::make_tuple(right.a, right.b, right.c);
10 }

使用元组编写通用代码

现在我们将编写一个通用函数来查找元组中元素的数量:

 1 template <typename T>
 2 size_t tuple_length(const T&) {
 3   return boost::tuples::length<T>::value;
 4 }

这个函数简单地使用boost::tuples::length<T>元函数来计算元组中元素的数量。这个计算是在编译时进行的。元函数只是一个类模板,它具有从其模板参数在编译时计算出的可访问的静态成员或嵌套类型(参见第七章,“高阶和编译时编程”,有一个更严格的定义)。在这种情况下,boost::tuples::length<T>元函数有一个名为value的公共静态成员,它被计算为元组T中的元素数量。如果您使用标准库中的元组,应该使用std::tuple_size<T>而不是boost::tuples::length<T>。这只是一个使用元函数和类型计算的通用编程的小例子。

使用异构值

在程序的生命周期中需要一个可以在不同时间点容纳不同类型数据的值并不是什么新鲜事。C++支持 C 的union构造,它本质上允许您拥有一个单一类型,可以在不同时间点假定不同底层 POD 类型的值。PODPlain Old Data类型,粗略地说,是不需要任何特殊初始化、销毁和复制步骤的类型,其语义等效物可以通过逐字复制其内存布局来创建。

这些限制意味着大多数 C++类,包括大多数标准库中的类,永远不能成为联合的一部分。从 C++11 开始,对联合的这些限制有所放宽,现在可以在联合中存储具有非平凡构造、销毁和复制语义(即非 POD 类型)的对象。但是,存储在联合中的这些对象的生命周期管理不是自动的,可能会很麻烦,因此最好避免。

来自 Boost 的两个库,Variant 和 Any,提供了有用的变体类型,提供了与联合相同的功能,但没有许多限制。使用 Variants 和 Any,在标准库容器中存储异构数据变得非常容易和无误。这些库代表了可辨别的联合类型。各种类型的值可以存储在可辨别的联合中,并且类型信息与值一起存储。

除了存储异构类型的数据,我们经常需要在不同表示之间进行转换,例如,文本到数字的转换以及反之。Boost Conversion 提供了一种无缝转换类型的方法,其中包括使用统一的语法进行类型转换。我们将在以下部分中查看 Any、Variant 和 Conversion 库。

Boost.Variant

Boost Variant 避免了 C++联合的所有问题,并提供了一个类似联合的构造,定义在一组任意类型上,而不仅仅是 POD 类型。我们可以使用 Boost Variant 头文件库定义一个变体数据类型,通过使用boost::variant模板实例化一个类型列表。类型列表标识了变体对象在不同时间点可以假定的不同类型的值。列表中的不同类型可以是多样的和不相关的,只需满足一个绑定条件——即每个类型都是可复制的或至少可移动的。甚至可以创建包含其他变体的变体。

在我们的第一个示例中,我们创建了一个整数、一个std::string和两个用户定义类型FooBar的变体。通过这个例子,我们说明了创建变体类型的约束以及可以对这种变体值执行的操作:

第 2.4 节:创建和使用变体

 1 #include <boost/variant.hpp>
 2 #include <string>
 3
 4 struct Foo {
 5   Foo(int n = 0) : id_(n) {} // int convertible to Foo
 6 private:
 7   int id_;
 8 };
 9 
10 struct Bar {
11   Bar(int n = 0) : id_(n) {} // int convertible to Bar
12 private:
13   int id_;
14 };  
15 
16 int main()
17 {
18   boost::variant<Foo, int, std::string> value; // error if Foo 
19                                 // not be default constructible
20   boost::variant<std::string, Foo, Bar> value2;
21 
22   value = 1;                 // sets int, not Foo
23   int *pi = boost::get<int>(&value);
24   assert(pi != 0);
25   value = "foo";             // sets std::string
26   value = Foo(42);           // sets Foo
27
28   // value2 = 1;             // ERROR: ambiguous - Foo or Bar?
29   // std::cout << value << ' ' << value2 << '\n'; // ERROR:
30                   // Foo, Bar cannot be streamed to ostream
31 }

我们创建了两个基本类型:Foo(第 4 行)和Bar(第 10 行);我们可以从int隐式初始化两者。我们定义了一个名为value的变体(第 18 行),它包含三种类型,Foointstd::string。第二个变体,value2(第 20 行),定义为std::stringFooBar

默认情况下,每个变体实例都被值初始化为其第一个类型的对象。因此,value被默认构造为Foo实例——在变体的类型参数列表中的第一个类型。同样,value2被默认构造为std::string——在其类型参数列表中的第一个类型。如果第一个类型是 POD 类型,它将被零初始化。因此,第一个类型必须是默认可构造的,变体才能是默认可构造的。

我们将一个整数赋给value(第 22 行)。这将使它成为int而不是Foo,因为整数可以隐式转换为Foo。我们使用boost::get<T>函数模板在value的地址上使用T=int(第 23 行)进行确认,并确认它不是空指针(第 24 行)。

我们将const char*赋给value(第 25 行),它隐式转换为std::string,并存储在value中,覆盖了先前存储的整数值。接下来,我们分配了一个Foo对象(第 26 行),它覆盖了先前的std::string值。

如果我们尝试将整数分配给value2(第 28 行,已注释),它将导致编译错误。变量value2被定义为std::stringFooBar的变体,整数可以隐式转换为FooBar,但都不是更好的选择,因此会导致歧义,编译器会抛出错误。通常情况下,变体的初始化和赋值不应该导致对变体中要实例化的类型产生歧义。

如果我们尝试将value的内容流式传输到std::cout(第 29 行,已注释),那么同样,我们将遇到编译错误。这是因为变体支持的类型之一(Foo)不可流式传输,这意味着它不能使用插入运算符(<<)写入到ostreams中。

访问变体中的值

我们使用boost::get<T>函数模板来访问变体中类型为T的值,其中T是我们想要的具体类型的值。当在变体引用上调用此函数时,如果存储的值不是指定类型,则返回对存储值的引用,或抛出boost::bad_get异常。当在指向变体的指针上调用时,如果存储的值不是指定类型,则返回存储值的地址,如果存储的值不是指定类型,则返回空指针。后者的行为可以用来测试变体是否存储了特定类型的值,就像在列表 2.4(第 23 行)中使用的那样。get<>的这种行为与dynamic_cast的行为非常相似:

第 2.5 节:访问变体中的值

 1 #include <boost/variant.hpp>
 2 #include <string>
 3 #include <cassert>
 4 
 5 int main() {
 6   boost::variant<std::string, int> v1;
 7   v1 = "19937";                    // sets string
 8   int i1;
 9 
10   try {    
11     i1 = boost::get<int>(v1);      // will fail, throw
12   } catch (std::exception& e) {
13     std::cerr << e.what() << '\n';
14   }
15 
16   int *pi = boost::get<int>(&v1);  // will return null
17   assert(pi == 0);
18 
19   size_t index = v1.which();        // returns 0
20 }

在前面的代码中,我们创建了一个变体v1,可以存储std::stringint值(第 6 行)。我们将v1设置为字符串"19937"(第 7 行)。我们使用boost::get<int>函数尝试从v1中获取整数(第 11 行),但由于此时v1存储的是一个字符串,所以会抛出异常。接下来,我们使用boost::get<int>的指针重载,该重载获取变体v1的地址。如果其类型与通过get函数的模板参数请求的类型匹配,则返回存储值的指针。如果不匹配,就像在这种情况下一样,将返回空指针(第 16 和 17 行)。最后,我们可以通过调用which成员函数获取当前存储在变体中的值的类型的从零开始的索引。由于v1包含std::string,并且v1的声明类型是boost::variant<std::string, int>,因此v1.which()应该返回变体声明中std::string的索引——在这种情况下是 0(第 19 行)。

编译时访问

变体中存储的值如何被消耗通常取决于值的类型。使用 if-else 梯子检查变体的每种可能类型可能会迅速加剧代码的可读性和可维护性。当然,我们可以使用变体的which成员方法找出当前值的类型的从零开始的索引,但这对我们目前没有什么用。相反,我们将看一下 Boost Variant 库提供的非常优雅和多功能的编译时访问机制,没有这个机制,处理变体将会相当麻烦。

这个想法是创建一个访问者类,其中包含一个重载的函数调用运算符(operator()),用于处理可能存储在变体中的每种类型。使用函数boost::apply_visitor,我们可以根据它包含的值的类型在变体对象上调用访问者类中的适当重载。

访问者类应该公开继承boost::static_visitor<T>模板,其中T是重载的函数调用运算符的返回类型。默认情况下,Tvoid。让我们看一个例子:

清单 2.6:变体的编译时访问

 1 #include <boost/variant.hpp>
 2 
 3 struct SimpleVariantVisitor :public boost::static_visitor<void>
 4 {
 5   void operator() (const std::string& s) const
 6   { std::cout << "String: " << s << '\n'; }
 7 
 8   void operator() (long n) const
 9   { std::cout << "long: " << n << '\n'; }
10 };
11 
12 int main()
13 {
14   boost::variant<std::string, long, double> v1;
15   v1 = 993.3773;
16 
17   boost::apply_visitor(SimpleVariantVisitor(), v1);
18 }

我们创建了一个类型为std::stringlongdouble的变体称为v1(第 14 行)。我们将其设置为类型为double的值(第 15 行)。最后,我们在v1上调用类型为SimpleVariantVistor的访问者(第 17 行)。SimpleVariantVisitor继承自boost::apply_visitor<void>(第 3 行),并包含std::string(第 5 行)和long(第 8 行)的重载,但没有double的重载。每个重载都将其参数打印到标准输出。

重载的解析发生在编译时而不是运行时。因此,每种值类型的重载必须可用。如果其参数类型是最适合变体中存储的值类型的类型,则会调用特定的重载。此外,如果所有类型都可以转换为重载的参数类型,则单个重载可以处理多种类型。

有趣的是,在前面的例子中,没有double的重载可用。然而,允许缩小转换,并且使用long的重载进行潜在的缩小。在这种情况下,long的重载处理longdouble类型。另一方面,如果我们有doublelong的单独重载,但没有std::string的重载,我们将会遇到编译错误。这是因为从std::stringlongdouble甚至没有缩小转换可用,重载解析将失败。作为编译时机制,这与变体对象中实际存储的值的类型无关。

通用访问者

您可以创建一个处理一系列类型的成员函数模板。在处理不同类型的代码没有显着差异的情况下,可能有意义使用这样的成员模板。以下是一个打印变体内容的访问者的示例:

清单 2.7:通用的编译时访问

 1 #include <boost/variant.hpp>
 2
 3 struct PrintVisitor : boost::static_visitor<>
 4 {
 5    template <typename T>
 6    void operator() (const T& t) const {
 7      std::cout << t << '\n';
 8    }
 9 };
10
11 boost::variant<std::string, double, long, Foo> v1;
12 boost::apply_visitor(PrintVisitor(), v1);

在上述代码中,我们定义了一个类型为std::stringdoublelongFoo的变体。访问者类PrintVisitor包含一个通用的operator()。只要变体中的所有类型都是可流化的,这段代码就会编译并将变体的值打印到标准输出。

将访问者应用于容器中的变体

通常,我们有一个包含变体对象的 STL 容器,并且我们希望使用我们的访问者访问每个对象。我们可以利用std::for_each STL 算法和boost::apply_visitor的单参数重载来实现这一目的。boost::apply_visitor的单参数重载接受一个访问者实例,并返回一个将访问者应用于传递的元素的函数对象。以下示例最好说明了用法:

 1 #include <boost/variant.hpp>
 2
 3 std::vector<boost::variant<std::string, double, long> > vvec;
 4 …
 5 std::for_each(vvec.begin(), vvec.end(),
 6                  boost::apply_visitor(SimpleVariantVisitor()));

定义递归变体

过去几年中,有一个特定的数据交换格式—JavaScript 对象表示法或 JSON—的流行度呈现了惊人的增长。它是一种简单的基于文本的格式,通常比 XML 更简洁。最初用作 JavaScript 中的对象文字,该格式比 XML 更易读。它也是一种相对简单的格式,易于理解和解析。在本节中,我们将使用boost::variants来表示格式良好的 JSON 内容,并看看变体如何处理递归定义。

JSON 格式

首先,我们将看一个人员记录的 JSON 表示的例子:

    {
        "Name": "Lucas",
        "Age": 38,
        "PhoneNumbers" : ["1123654798", "3121548967"],
        "Address" : { "Street": "27 Riverdale", "City": "Newtown", 
                             "PostCode": "902739"}
    }

上述代码是一个 JSON 对象的示例——它包含标识未命名对象属性的键值对。属性名称是带引号的字符串,例如"Name""Age""PhoneNumbers"(可以有多个)和"Address"。它们的值可以是简单字符串("Name")或数值("Age"),或这些值的数组("PhoneNumbers")或其他对象("Address")。一个冒号(:)将键与值分开。键值对之间用逗号分隔。对象中的键值对列表用大括号括起来。这种格式允许任意级别的嵌套,如"Address"属性的值本身就是一个对象。您可以创建更多嵌套对象,这些对象是其他嵌套对象属性的值。

您可以将许多这样的记录组合在一个数组中,这些记录被方括号括起来,并用逗号分隔:

[
    {
        "Name": "Lucas",
        "Age": 38,
        "PhoneNumbers" : ["1123654798", "3121548967"],
        "Address" : { "Street": "27 Riverdale", "City": "Newtown", 
                             "PostCode": "902739"}
    },
    {
        "Name": "Damien",
        "Age": 52,
        "PhoneNumbers" : ["6427851391", "3927151648"],
        "Address": {"Street": "11 North Ave.", "City" : "Rockport", 
                        "PostCode": "389203"}
    },
    … 
]

一个格式良好的 JSON 文本包含一个对象或零个或多个对象、数值、字符串、布尔值或空值的数组。对象本身包含零个或多个由唯一字符串表示的唯一属性。每个属性的值可以是字符串、数值、布尔值、空值、另一个对象或这些值的数组。因此,JSON 内容中的基本令牌是数值、字符串、布尔值和空值。聚合是对象和数组。

使用递归变体表示 JSON 内容

如果我们要声明一个变体来表示 JSON 中的基本令牌,它会是这样的:

 1 struct JSONNullType {};
 2 boost::variant<std::string, double, bool, JSONNullType> jsonToken;

类型JSONNullType是一个空类型,可以用来表示 JSON 中的空元素。

为了扩展这个变体以表示更复杂的 JSON 内容,我们将尝试表示一个 JSON 对象——一个键值对作为一种类型。键始终是字符串,但值可以是上面列出的任何类型或另一个嵌套对象。因此,JSON 对象的定义本质上是递归的,这就是为什么我们需要递归变体定义来对其进行建模。

为了在前述变体类型中包含 JSON 对象的定义,我们使用一个名为boost::make_recursive_variant的元函数。它接受一个类型列表,并将生成的递归变体类型定义为一个名为type的嵌套类型。因此,这是我们如何编写变体的递归定义的方式:

 1 #define BOOST_VARIANT_NO_FULL_RECURSIVE_VARIANT_SUPPORT
 2 #include <boost/variant.hpp>
 3
 4 struct JSONNullType {};
 5
 6 typedef boost::make_recursive_variant<
 7                      std::string,
 8                      double,
 9                      bool,
10                      JSONNullType,
11                      std::map<std::string,
12                               boost::recursive_variant_>
13                     >::type JSONValue;

第 1 行的#define语句可能对许多编译器是必要的,特别是对于支持递归变体的使用make_recursive_variant的限制。

我们使用boost::make_recursive_variant元函数(第 6 行)定义递归变体。在类型列表中,我们添加了一个新类型std::map,其键的类型为std::string(第 11 行),值的类型为boost::recursive_variant_(第 12 行)。特殊类型boost::recursive_variant_用于指示外部变体类型可以作为映射中的值出现。因此,我们在变体定义中捕获了 JSON 对象的递归特性。

这个定义还不完整。一个格式良好的 JSON 内容可能包含所有这些不同类型的元素的数组。这些数组也可以是对象属性的值,或者嵌套在其他数组中。如果我们选择用向量来表示一个数组,那么对前述定义的扩展就很容易了:

清单 2.8a:JSON 的递归变体

 1 #define BOOST_VARIANT_NO_FULL_RECURSIVE_VARIANT_SUPPORT
 2 #include <boost/variant.hpp>
 3
 4 struct JSONNullType {};
 5
 6 typedef boost::make_recursive_variant<
 7                      std::string,
 8                      double,
 9                      bool,
10                      JSONNullType,
11                      std::map<std::string,
12                               boost::recursive_variant_>,
13                      std::vector<boost::recursive_variant_>
14                     >::type JSONValue;
15
16 typedef std::vector<JSONValue> JSONArray;
17 typedef std::map<std::string, JSONValue> JSONObject;

我们添加了一个类型——std::vector<boost::recursive_variant_>(第 13 行),它表示了JSONValue对象的数组。凭借这一额外的行,我们现在支持了更多的可能性:

  • 顶层数组由 JSON 对象、其他 JSON 数组和基本类型的标记组成

  • 对象的数组值属性

  • 另一个 JSON 数组中的数组值元素

这是JSONValue的完整定义。此外,我们为递归聚合类型——JSON 数组和 JSON 对象创建了 typedefs(第 16 行和第 17 行)。

访问递归变体

我们现在将编写一个访问者,以标准表示法打印存储在变体中的 JSON 数据。访问递归变体与访问非递归变体没有区别。我们仍然需要定义能够处理变体可能存储的所有类型值的重载。此外,在递归聚合类型(在本例中为JSONArrayJSONObject)的重载中,我们可能需要递归访问其每个元素:

清单 2.8b:访问递归变体

 1 void printArrElem(const JSONValue& val);
 2 void printObjAttr(const JSONObject::value_type& val); 
 3
 4 struct JSONPrintVisitor : public boost::static_visitor<void>
 5 {
 6   void operator() (const std::string& str) const
 7   {
 8     std::cout << '"' << escapeStr(str) << '"';
 9   }
10
11   void operator() (const JSONNullType&) const
12   {
13     std::cout << "null";
14   }
15
16   template <typename T>
17   void operator()(const T& value) const
18   {
19     std::cout << std::boolalpha << value;
20   }
21
22   void operator()(const JSONArray& arr) const
23   {
24     std::cout << '[';
25
26     if (!arr.empty()) {
27       boost::apply_visitor(*this, arr[0]);
28       std::for_each(arr.begin() + 1, arr.end(), printArrElem);
29     }
30 
31     std::cout << "\n";
32   }
33
34   void operator()(const JSONObject& object) const
35   {
36     std::cout << '{';
37 
38     if (!object.empty()) {
39       const auto& kv_pair = *(object.begin());
40       std::cout << '"' << escapeStr(kv_pair.first) << '"';
41       std::cout << ':';
42       boost::apply_visitor(*this, kv_pair.second);
43
44       auto it = object.begin();
45       std::for_each(++it, object.end(), printObjAttr);
46     }
47     std::cout << '}';
48   }
49
50 };
51
52 void printArrElem(const JSONValue& val) {
53   std::cout << ',';
54   boost::apply_visitor(JSONPrintVisitor(), val);
55 }
56
57 void printObjAttr(const JSONObject::value_type& val) {
58   std::cout << ',';
59   std::cout << '"' << escapeStr(val.first) << '"';
60   std::cout << ':';
61   boost::apply_visitor(JSONPrintVisitor(), val.second);
62 }

访问者JSONPrintVisitor公开继承自boost::static_visitor<void>,并为 JSON 值的不同可能类型提供了operator()的重载。它有一个std::string的重载(第 6 行),它在转义任何嵌入引号和其他需要转义的字符后,用双引号打印字符串(第 8 行)。为此,我们假设有一个名为escapeStr的函数可用。我们还有一个JSONNullType(第 11 行)的重载,它只是打印不带引号的字符串null。其他类型的值,如doublebool,由成员模板处理(第 17 行)。对于bool值,它使用std::boolalpha ostream操作器(第 19 行)打印不带引号的字符串truefalse

主要工作由JSONArray(第 22 行)和JSONObject(第 34 行)的两个重载完成。JSONArray重载打印了用方括号括起来并用逗号分隔的数组元素。它打印了JSONValues向量的第一个元素(第 27 行),然后对这个向量应用std::for_each通用算法,从第二个元素开始打印后续元素并用逗号分隔(第 28 行)。为此,它将printArrElem函数的指针作为第三个参数传递给std::for_eachprintArrElem(第 52 行)函数通过应用JSONPrintVisitor(第 54 行)打印每个元素。

JSONObject重载将映射的元素打印为以逗号分隔的键值对列表。第一对被打印为带引号的转义键(第 40 行),然后是一个冒号(第 41 行),接着调用boost::apply_visitor(第 42 行)。后续的对通过使用std::for_eachprintObjAttr函数指针(第 45 行)迭代映射的剩余元素,以逗号分隔前面的对来打印。这个逻辑类似于JSONArray的重载。printObjAttr函数(第 57 行)打印传递给它的每个键值对,前缀是一个逗号(第 58 行),打印转义的带引号的键(第 59 行),打印一个冒号(第 60 行),并在变体值上调用访问者(第 61 行)。

Boost.Any

Boost Any 库采用了与 Boost Variant 不同的方法来存储异构数据。与 Variant 不同,Any 允许您存储几乎任何类型的数据,而不限于固定集合,并且保留存储数据的运行时类型信息。因此,它根本不使用模板,并且要求在使用 Boost Any 编译代码时启用运行时类型识别RTTI)(大多数现代编译器默认情况下保持启用)。

提示

为了使 Boost Any 库正常工作,您不能禁用程序的 RTTI 生成。

在下面的示例中,我们创建了boost::any的实例来存储数字数据、字符数组和非 POD 类型对象:

清单 2.9:使用 Boost Any

 1 #include <boost/any.hpp>
 2 #include <vector>
 3 #include <iostream>
 4 #include <string>
 5 #include <cassert>
 6 using boost::any_cast;
 7
 8 struct MyValue {
 9   MyValue(int n) : value(n) {}
10
11   int get() const { return value; }
12
13   int value;
14 };
15
16 int main() {
17   boost::any v1, v2, v3, v4;
18
19   assert(v1.empty());
20   const char *hello = "Hello";
21   v1 = hello;
22   v2 = 42;
23   v3 = std::string("Hola");
24   MyValue m1(10);
25   v4 = m1;
26
27   try {
28     std::cout << any_cast<const char*>(v1) << '\n';
29     std::cout << any_cast<int>(v2) << '\n';
30     std::cout << any_cast<std::string>(v3) << '\n';
31     auto x = any_cast<MyValue>(v4);
32     std::cout << x.get() << '\n';
33   } catch (std::exception& e) {
34     std::cout << e.what() << '\n';
35   }
36 }

您还可以使用any_cast的非抛出版本,而不是传递引用的方式,而是传递any对象的地址。如果存储的类型与要转换的类型不匹配,这将返回一个空指针,而不是抛出异常。以下代码片段说明了这一点:

 1 boost::any v1 = 42;2 boost::any v2 = std::string("Hello");
 3 std::string *str = boost::any_cast<std::string>(&v1);
 4 assert(str == nullptr);
 5 int *num = boost::any_cast<int>(&v2);
 6 assert(num == nullptr);
 7
 8 num = boost::any_cast<int>(&v1);
 9 str = boost::any_cast<std::string>(&v2);
10 assert(num != nullptr);
11 assert(str != nullptr);

我们将any对象的地址传递给any_cast(第 3、5、8 和 9 行),除非any_cast的类型参数与any对象中存储的值的类型匹配,否则它将返回空值。使用any_cast的指针重载,我们可以编写一个通用的谓词来检查any变量是否存储了给定类型的值:

template <typename T>
bool is_type(boost::any& any) {
  return ( !any.empty() && boost::any_cast<T>(&any) );
}

这就是您将如何使用它:

boost::any v1 = std::string("Hello");
assert( is_type<std::string>(v1) );

boost::any_cast的这种行为模拟了dynamic_cast的工作原理。

在清单 2.9 中,我们使用不同的boost::any类型的实例来存储不同类型的值。但是,同一个boost::any实例可以在不同的时间存储不同类型的值。以下代码片段使用anyswap成员函数说明了这一点:

 1 boost::any v1 = 19937;
 2 boost::any v2 = std::string("Hello");
 3
 4 assert(boost::any_cast<int>(&v1) != nullptr);
 5 assert(boost::any_cast<std::string>(&v2) != nullptr);
 6
 7 v1 = 22.36;
 8 v1.swap(v2);
 9 assert(boost::any_cast<std::string>(&v1) != nullptr);
10 assert(boost::any_cast<double>(&v2) != nullptr);

我们首先将double类型的值赋给v1(第 7 行),而它原来是int类型的值(第 1 行)。接下来,我们交换v1的内容与v2(第 8 行),而v2原来是std::string类型的值(第 2 行)。现在我们可以期望v1包含一个std::string值(第 9 行),而v2包含一个double值(第 10 行)。

除了使用any_cast的指针重载,我们还可以使用anytype成员函数来访问存储值的类型:

清单 2.10:在 Any 中访问类型信息

boost::any value;
value = 20;
if (value.type().hash_code() == typeid(int).hash_code()) {
  std::cout << boost::any_cast<int>(value) << '\n';
}

anytype成员函数返回一个std::type_info对象(在标准库头文件<typeinfo>中定义)。为了检查这个类型是否与给定的类型相同,我们将其与通过对给定类型应用typeid运算符获得的type_info对象进行比较(在本例中是int)。我们不直接比较这两个type_info对象,而是比较它们使用type_infohash_code成员函数获得的哈希码。

Boost.Conversion

如果您曾尝试解析文本输入(来自文件、标准输入、网络等)并尝试对其中的数据进行语义转换,您可能会感到需要一种将文本转换为数值的简便方法。相反的问题是根据数值和文本程序变量的值编写文本输出。basic_istreambasic_ostream类提供了读取和写入特定类型值的功能。然而,这些用法的编程模型并不直观或健壮。C++标准库及其扩展提供了各种转换函数,具有不同程度的控制、灵活性和普遍缺乏可用性。例如,存在一整套函数,用于在数值和字符格式之间进行转换,或者反过来(例如,atoistrtolstrtoditoaecvtfcvt等)。如果我们尝试编写用于类型转换的通用代码,我们甚至无法使用这些函数中的任何一个,因为它们只适用于特定类型之间的转换。我们如何定义一个通用的转换语法,可以扩展到任意类型?

Boost Conversion库引入了一对函数模板,提供了非常直观和统一的转换语法,也可以通过用户定义的特化进行扩展。我们将逐一查看转换模板。

lexical_cast

lexical_cast函数模板可用于将源类型转换为目标类型。其语法类似于各种 C++转换的语法:

#include <boost/lexical_cast.hpp>
namespace boost {
template <typename T, typename S>
T lexical_cast (const S& source);
}

以下示例显示了我们如何使用lexical_cast将字符串转换为整数:

清单 2.11:使用 lexical_cast

 1 std::string str = "1234";
 2
 3 try {
 4   int n = boost::lexical_cast<int>(str);
 5   assert(n == 1234);
 6 } catch (std::exception& e) {
 7   std::cout << e.what() << '\n';
 8 }

我们应用lexical_cast(第 4 行)将std::string类型的值转换为int类型的值。这种方法的美妙之处在于它可以为所有转换提供统一的语法,并且可以扩展到新类型。如果字符串不包含有效的数字字符串,则lexical_cast调用将抛出bad_lexical_cast类型的异常。

提供了lexical_cast函数模板的重载,允许转换字符数组的一部分:

#include <boost/lexical_cast.hpp>
namespace boost {
template <typename T >
T lexical_cast (const char* str, size_t size);
}

我们可以以以下方式使用前述函数:

 1 std::string str = "abc1234";
 2
 3 try {
 4   int n = boost::lexical_cast<int>(str.c_str() + 3, 4);
 5   assert(n == 1234);
 6 } catch (std::exception& e) {
 7   std::cout << e.what() << '\n';
 8 }

在转换可流式传输的类型的对象时,lexical_cast将对象流式传输到ostream对象,例如stringstream的实例,并将其作为目标类型读取回来。

提示

可流式传输的对象可以转换为字符流,并插入到ostream对象中,例如stringstream的实例。换句话说,如果定义了类型T,使得ostream& operator<<(ostream&, const T&),则称其为可流式传输。

为每个此类操作设置和拆卸流对象会产生一些开销。因此,在某些情况下,lexical_cast的默认版本可能无法提供最佳性能。在这种情况下,您可以为涉及的类型集合专门化lexical_cast模板,并使用快速库函数或提供自己的快速实现。Conversion库已经优化了所有常见类型对的lexical_cast

除了lexical_cast模板之外,还有其他模板可用于不同数值类型之间的转换(boost::numeric_cast)、类层次结构中的向下转换和交叉转换(polymorphic_downcastpolymorphic_cast)。您可以参考在线文档以获取有关这些功能的更多信息。

处理命令行参数

命令行参数,就像 API 参数一样,是帮助您调整命令行行为的遥控按钮。一组精心设计的命令行选项在很大程度上支持命令的功能。在本节中,我们将看到 Boost.Program_Options 库如何帮助您为自己的程序添加对丰富和标准化的命令行选项的支持。

设计命令行选项

C 为程序的命令行提供了最原始的抽象。使用传递给主函数的两个参数-参数的数量(argc)和参数的列表(argv)-您可以了解到传递给程序的每个参数及其相对顺序。以下程序打印出argv[0],这是程序本身的路径,用它调用程序。当使用一组命令行参数运行时,程序还会将每个参数分别打印在一行上。

大多数程序需要添加更多的逻辑和验证来验证和解释命令行参数,因此需要一个更复杂的框架来处理命令行参数:

1 int main(int argc, char *argv[])
2 {
3   std::cout << "Program name: " << argv[0] << '\n';
4
5   for (int i = 1; i < argc; ++i) {
6     std::cout << "argv[" << i << "]: " << argv[i] << '\n';
7   }
8 }

diff 命令-一个案例研究

程序通常会记录一组修改其行为的命令行选项和开关。让我们来看看 Unix 中diff命令的例子。diff命令是这样运行的:

$ diff file1 file2

它打印出两个文件内容之间的差异。有几种方式可以选择打印出差异。对于每个不同的块,您可以选择打印出几行额外的上下文,以更好地理解出现差异的上下文。这些周围的行或"上下文"在两个文件之间是相同的。为此,您可以使用以下的其中一种替代方案:

$ diff -U 5 file1 file2
$ diff --unified=5 file1 file2

在这里,您选择打印五行额外的上下文。您还可以通过指定默认值为三来选择默认值:

$ diff --unified file1 file2

在前面的例子中,-U--unified是命令行选项的例子。前者是一个由单个前导破折号和单个字母(-U)组成的短选项。后者是一个由两个前导破折号和多字符选项名称(--unified)组成的长选项。

数字5是一个选项值;是前面的选项(-U--unified)的参数。选项值与前面的短选项之间用空格分隔,但与前面的长选项之间用等号(=)分隔。

如果您正在"diffing"两个 C 或 C++源文件,您可以使用命令行开关或标志-p来获取更有用的信息。开关是一个不带选项值的选项。使用此开关,您可以打印出在检测到特定差异的上下文中 C 或 C++函数的名称。没有与之对应的长选项。

diff命令是一个非常强大的工具,可以在完整目录中查找文件内容的差异。当对比两个目录时,如果一个文件存在于一个目录中而另一个目录中不存在,diff默认会忽略此文件。但是,您可能希望查看新文件的内容。为此,您将使用-N--new-file开关。如果我们现在想要在两个 C++源代码目录上运行我们的diff命令来识别更改,我们可以这样做:

$ diff -pN –unified=5 old_source_dir new_source_dir

您不必眼尖才能注意到我们使用了一个名为-pN的选项。这实际上不是一个单一的选项,而是两个开关(-p)和(-N)合并在一起。

从这个案例研究中应该能够看出某些模式或约定:

  • 用单破折号开始短选项

  • 用双破折号开始长选项

  • 用空格分隔短选项和选项值

  • 用等号分隔长选项和选项值

  • 合并短开关

这些是高度符合 POSIX 的系统(如 Linux)上事实上标准化的约定。然而,并不是唯一遵循的约定。Windows 命令行经常使用前斜杠(/)代替连字符。它们通常不区分短选项和长选项,并有时使用冒号(:)代替等号来分隔选项和其选项值。Java 命令以及几个旧的 Unix 系统中的命令使用单个前导连字符来表示短选项和长选项。其中一些使用空格来分隔选项和选项值,无论是短选项还是长选项。在解析命令行时,如何处理从平台到平台变化的这么多复杂规则?这就是 Boost 程序选项库产生重大影响的地方。

使用 Boost.Program_Options

Boost 程序选项库为您提供了一种声明性的解析命令行的方式。您可以指定程序支持的选项和开关集合以及每个选项支持的选项值类型。您还可以指定要为命令行支持的约定集合。然后,您可以将所有这些信息提供给库函数,该函数解析和验证命令行,并将所有命令行数据提取到类似字典的结构中,从中可以访问单个数据位。现在,我们将编写一些代码来模拟diff命令的先前提到的选项:

清单 2.12a:使用 Boost 程序选项

 1 #include <boost/program_options.hpp>
 2
 3 namespace po = boost::program_options;
 4 namespace postyle = boost::program_options::command_line_style;
 5 
 6 int main(int argc, char *argv[])
 7 {
 8   po::options_description desc("Options");
 9   desc.add_options()
10      ("unified,U", po::value<unsigned int>()->default_value(3),
11             "Print in unified form with specified number of "
12             "lines from the surrounding context")
13      (",p", "Print names of C functions "
14             " containing the difference")
15      (",N", "When comparing two directories, if a file exists in"
16             " only one directory, assume it to be present but "
17             " blank in the other directory")
18      ("help,h", "Print this help message");

在前面的代码片段中,我们使用options_description对象声明了命令行的结构。连续的选项使用add_options返回的对象中的重载函数调用operator()来声明。您可以像在std::cout上级联调用插入运算符(<<)一样级联调用此运算符。这使得选项的规范非常易读。

我们声明了--unified-U选项,指定长选项和短选项在单个字符串中,用逗号分隔(第 10 行)。第二个参数表示我们期望一个数字参数,如果在命令行上未指定参数,则默认值将为3。第三个字段是选项的描述,将用于生成文档字符串。

我们声明了短选项-p-N(第 13 和 15 行),但由于它们没有相应的长选项,它们是以逗号开头,后跟短选项(",p"",N")。它们也不需要选项值,所以我们只提供它们的描述。

到目前为止一切顺利。现在我们将通过解析命令行并获取值来完成代码示例。首先,我们将指定在 Windows 和 Unix 中要遵循的风格:

清单 2.12b:使用 Boost 程序选项

19   int unix_style    = postyle::unix_style
20                      |postyle::short_allow_next;
21
22   int windows_style = postyle::allow_long
23                      |postyle::allow_short
24                      |postyle::allow_slash_for_short
25                      |postyle::allow_slash_for_long
26                      |postyle::case_insensitive
27                      |postyle::short_allow_next
28                      |postyle::long_allow_next;

前面的代码突出了 Windows 和 Unix 约定之间的一些重要区别:

  • 一个更或多或少标准化的 Unix 风格可预先准备好并称为unix_style。然而,我们必须自己构建 Windows 风格。

  • short_allow_next标志允许您用空格分隔短选项和其选项值;这在 Windows 和 Unix 上都可以使用。

  • allows_slash_for_shortallow_slash_for_long标志允许选项以斜杠开头;这是 Windows 上的常见做法。

  • case_insensitive标志适用于 Windows,通常习惯于不区分大小写的命令和选项。

  • 在 Windows 上,long_allow_next标志允许长选项和选项值用空格而不是等号分隔。

现在,让我们看看如何使用所有这些信息解析符合规范的命令行。为此,我们将声明一个variables_map类型的对象来读取所有数据,然后解析命令行:

清单 2.12c:使用 Boost 程序选项

29   po::variables_map vm;
30   try {
31     po::store(
32       po::command_line_parser(argc, argv)
33          .options(desc)
34          .style(unix_style)  // or windows_style
35          .run(), vm);
36
37     po::notify(vm); 
38
39     if (argc == 1 || vm.count("help")) {
40       std::cout << "USAGE: " << argv[0] << '\n'
41                 << desc << '\n';
42       return 0;
43     }
44   } catch (po::error& poe) {
45     std::cerr << poe.what() << '\n'
46               << "USAGE: " << argv[0] << '\n' << desc << '\n';
47     return EXIT_FAILURE;
48   }

我们使用command_line_parser函数创建一个命令行解析器(第 32 行)。我们在返回的解析器上调用options成员函数来指定在desc中编码的解析规则(第 33 行)。我们链式调用更多的成员函数,将其传递给解析器的style成员函数以指定预期的样式(第 34 行),并调用run成员函数来执行实际的解析。调用run返回一个包含从命令行解析的数据的数据结构。调用boost::program_options::store将从这个数据结构中解析的数据存储在variables_map对象vm中(第 31-35 行)。最后,我们检查程序是否在没有参数或使用help选项的情况下调用,并打印帮助字符串(第 39 行)。将option_description实例desc流式传输到ostream会打印一个帮助字符串,该字符串是根据desc中编码的命令行规则自动生成的(第 41 行)。所有这些都封装在一个 try-catch 块中,以捕获由对run的调用抛出的任何命令行解析错误(第 35 行)。在出现这样的错误时,将打印错误详细信息(第 45 行),并打印使用详细信息(第 46 行)。

如果你注意到,你会发现我们在第 37 行调用了一个名为notify(…)的函数。在更高级的用法中,我们可以选择使用从命令行读取的值来设置变量或对象成员,或执行其他后处理操作。这些操作可以在声明选项描述时为每个选项指定,但这些操作只能通过调用notify来启动。为了保持一致,不要删除对notify的调用。

现在我们可以提取通过命令行传递的值了:

清单 2.12d:使用 Boost 程序选项

49   unsigned int context = 0;
50   if (vm.count("unified")) {
51     context = vm["unified"].as<unsigned int>();
52   }
53
54   bool print_cfunc = (vm.count("p") > 0);

解析位置参数

如果你注意到了,你会注意到我们没有做任何事情来读取两个文件名;diff命令的两个主要操作数。我们之所以这样做是为了简单起见,现在我们将修复这个问题。我们这样运行diff命令:

$ diff -pN --unified=5 old_source_dir new_source_dir

old_source_dirnew_source_dir参数被称为位置参数。它们既不是选项也不是开关,也不是任何选项的参数。为了处理它们,我们将不得不使用一些新技巧。首先,我们必须告诉解析器我们期望的这些参数的数量和类型。其次,我们必须告诉解析器这些是位置参数。以下是代码片段:

 1 std::string file1, file2;
 2 po::options_description posparams("Positional params");
 3 posparams.add_options()
 4         ("file1", po::value<std::string>(&file1)->required(), "")
 5         ("file2", po::value<std::string>(&file2)->required(), "");
 6 desc.add(posparams);
 7
 8
 9 po::positional_options_description posOpts;
10 posOpts.add("file1", 1);  // second param == 1 indicates that
11 posOpts.add("file2", 1);  //  we expect only one arg each
12
13 po::store(po::command_line_parser(argc, argv)14                 .options(desc)
15                 .positional(posOpts)
16                 .style(windows_style)
17                 .run(), vm);

在前面的代码中,我们设置了一个名为posparams的第二个选项描述对象,用于识别位置参数。我们使用value参数的required()成员函数(第 4 和 5 行)添加了名称为"file1""file2"的选项,并指示这些参数是必需的。我们还指定了两个字符串变量file1file2来存储位置参数。所有这些都添加到主选项描述对象desc(第 6 行)。为了使解析器不寻找名为"--file1""--file2"的实际选项,我们必须告诉解析器这些是位置参数。这是通过定义一个positional_options_description对象(第 9 行)并添加应该被视为位置选项的选项(第 10 和 11 行)来完成的。在add(…)调用中的第二个参数指定了应该考虑该选项的位置参数的数量。由于我们想要一个文件名,分别用于选项file1file2,所以我们在两次调用中都指定为1。命令行上的位置参数根据它们添加到位置选项描述的顺序进行解释。因此,在这种情况下,第一个位置参数将被视为file1,第二个参数将被视为file2

多个选项值

在某些情况下,单个选项可能需要多个选项值。例如,在编译期间,你将多次使用-I选项来指定多个目录。为了解析这样的选项及其选项值,你可以将目标类型指定为矢量,如下面的代码片段所示:

 1 po::options_description desc("Options");
 2 desc.add_option()
 3      ("include,I", po::value<std::vector<std::string> >(),
 4       "Include files.")
 5      (…);

这将在这样的调用上起作用:

$ c++ source.cpp –o target -I path1 -I path2 -I path3

然而,在某些情况下,你可能想要指定多个选项值,但只指定一次选项本身。假设你正在运行一个命令来发现连接到一组服务器的每个资产(本地存储、NIC、HBA 等)的命令。你可以有这样一个命令:

$ discover_assets --servers svr1 svr2 svr3 --uid user

在这种情况下,为了模拟--server选项,你需要像这样使用multitoken()指令:

 1 po::options_description desc("Options");
 2 desc.add_option()
 3      ("servers,S", 
 4       po::value<std::vector<std::string> >()->multitoken(),
 5       "List of hosts or IPs.")
 6      ("uid,U", po::value<std::string>, "User name");

你可以通过变量映射这样检索矢量值参数:

1 std::vector<std::string> servers = vm["servers"];

或者,你可以在选项定义时使用变量挂钩,就像这样:

1 std::vector<std::string> servers;
2 desc.add_option()
3      ("servers,S",
4       po::value<std::vector<std::string> >(&servers
5          ->multitoken(),
6       "List of hosts or IPs.")…;

确保在解析命令行后不要忘记调用notify

提示

尝试支持在同一命令中同时使用多个令牌的位置参数和选项可能会使解析器混淆,通常应该避免。

程序选项库使用 Boost Any 进行实现。为了使程序选项库正常工作,你不能禁用程序的 RTTI 生成。

其他实用程序和编译时检查

Boost 包括许多微型库,提供小而有用的功能。它们中的大多数都不够复杂,无法成为单独的库。相反,它们被分组在Boost.UtilityBoost.Core下。我们将在这里看两个这样的库。

我们还将看一些有用的方法,尽早在编译时检测错误,并使用 Boost 的不同设施从程序的编译环境和工具链中获取信息。

BOOST_CURRENT_FUNCTION

在编写调试日志时,能够写入函数名称以及有关调用日志的函数的一些限定信息非常有用。这些信息(显然)在编译源代码时对编译器是可用的。然而,打印它的方式对不同的编译器是不同的。即使对于同一个编译器,可能有多种方法来做到这一点。如果你想编写可移植的代码,这是一个你必须注意隐藏的瑕疵。这方面最好的工具是宏BOOST_CURRENT_FUNCTION,正式是Boost.Utility的一部分,在下面的示例中展示了它的作用:

清单 2.13:漂亮打印当前函数名

 1 #include <boost/current_function.hpp>
 2 #include <iostream>
 3
 4 namespace FoFum {
 5 class Foo
 6 {
 7 public:
 8   void bar() {
 9     std::cout << BOOST_CURRENT_FUNCTION << '\n';
10     bar_private(5);
11   }
12
13   static void bar_static() {
14     std::cout << BOOST_CURRENT_FUNCTION << '\n';
15   }
16
17 private:
18   float bar_private(int x) const {
19     std::cout << BOOST_CURRENT_FUNCTION << '\n';
20   return 0.0;
21   }
22 };
23 } // end namespace FoFum
24
25 namespace {
26 template <typename T>
27 void baz(const T& x)
28 {
29   std::cout << BOOST_CURRENT_FUNCTION << '\n';
30 }
32 } // end unnamed namespace
33
34 int main()
35 {
36   std::cout << BOOST_CURRENT_FUNCTION << '\n';
37   FoFum::Foo f;
38   f.bar();
39   FoFum::Foo::bar_static();
40   baz(f);
41 }

根据你的编译器,你看到的输出格式会有所不同。GNU 编译器倾向于有更可读的输出,而在 Microsoft Visual Studio 上,你会看到一些非常复杂的输出,包括调用约定等细节。特别是,在 Visual Studio 上,模板实例化的输出要复杂得多。这是我在我的系统上看到的一个示例输出。

使用 GNU g++:

int main()
void FoFum::Foo::bar()
float FoFum::Foo::bar1(int) const
static void FoFum::Foo::bar_static()
void {anonymous}::baz(const T&) [with T = FoFum::Foo]

使用 Visual Studio:

int __cdecl main(void)
void __thiscall FoFum::Foo::bar(void)
float __thiscall FoFum::Foo::bar1(int) const
void __cdecl FoFum::Foo::bar_static(void)
void __cdecl 'anonymous-namespace'::baz<class FoFum::Foo>(const class FoFum::Foo &)

你可以立即看到一些不同之处。GNU 编译器从非静态方法中调用静态方法。在 Visual Studio 中,你必须根据调用约定进行区分(__cdecl用于静态成员方法以及全局方法,__thiscall用于实例方法)。你可能想看一下current_function.hpp头文件,以找出在幕后使用了哪些宏。例如,在 GNU 编译器中,是__PRETTY_FUNCTION__,而在 Visual Studio 中是__FUNCSIG__

Boost.Swap

Boost Swap 库是另一个有用的微型库,是 Boost Core 的一部分:

#include <boost/core/swap.hpp>
namespace boost {
  template<typename T1, typename T2>
  void swap(T1& left, T2& right);}

它围绕交换对象的一个众所周知的习语。让我们首先看看问题本身,以了解发生了什么。

std命名空间中有一个全局的swap函数。在许多情况下,对于在特定命名空间中定义的类型,可能会在相同的命名空间中提供一个专门的swap重载。在编写通用代码时,这可能会带来一些挑战。想象一个通用函数调用其参数的swap

 1 template <typename T>
 2 void process_values(T& arg1, T& arg2, …)
 3 {
 4   …
 5   std::swap(arg1, arg2);

在上面的代码片段中,我们在第 5 行调用std::swap来执行交换。虽然这是良好形式的,但在某些情况下可能不会产生期望的结果。考虑命名空间X中的以下类型和函数:

 1 namespace X {
 2   struct Foo {};
 3
 4   void swap(Foo& left, Foo& right) { 
 5     std::cout << BOOST_CURRENT_FUNCTION << '\n';
 6   }
 7 }

当然,X::Foo是一个平凡的类型,X::swap是一个无操作,但它们可以被一个有意义的实现替换,我们在这里所做的观点仍然成立。

那么,如果你在两个类型为X::Foo的参数上调用函数process_values会发生什么?

 1 X::Foo f1, f2;
 2 process_values(f1, f2, …); // calls process_values<X::Foo>

process_values的调用(第 2 行)将在传递给X::Foo的实例上调用std::swap,即f1f2。然而,我们可能希望在f1f2上调用X::swap,因为这是一个更合适的重载。有一种方法可以做到这一点;你可以调用boost::swap。下面是process_values模板片段的重写:

 1 #include <boost/core/swap.hpp>
 2
 3 template <typename T>
 4 void process_values(T& arg1, T& arg2, …)
 5 {
 6   …
 7   boost::swap(arg1, arg2);

如果你现在运行这段代码,你会看到X::swap重载将其名称打印到控制台。要理解boost::swap是如何调用适当的重载的,我们需要了解如何在没有boost::swap的情况下解决这个问题:

 1 template <typename T>
 2 void process_values(T& arg1, T& arg2, …)
 3 {
 4   …
 5   using std::swap;
 6   swap(arg1, arg2);

如果我们没有using声明(第 5 行),对swap的调用(第 6 行)仍然会成功,对于一个在命名空间中定义的类型T,该命名空间中定义了Tswap重载——这要归功于参数相关查找ADL)——X::FooX::swap就是这样的类型。然而,对于在全局命名空间中定义的类型,它会失败(假设你没有在全局命名空间中定义一个通用的swap)。有了using声明(第 5 行),我们为对swap的未限定调用创建了回退。当 ADL 成功找到命名空间级别的swap重载时,对swap的调用就会解析为这个重载。当 ADL 找不到这样的重载时,就会使用std::swap,如using声明所规定的那样。问题在于这是一个不明显的技巧,你必须知道才能使用它。你团队中的每个工程师都不一定都了解 C++中的所有名称查找规则。与此同时,他总是可以使用boost::swap,它本质上是将这段代码包装在一个函数中。现在你可以只使用一个版本的swap,并期望每次调用时调用最合适的重载。

编译时断言

编译时断言要求在代码的某个点上某些条件必须为真。任何条件的违反都会导致编译失败。这是一种在编译时发现错误的有效方法,否则这些错误可能会在运行时造成严重的困扰。它还可以帮助减少由于模板实例化失败而产生的编译器错误消息的数量和冗长程度。

运行时断言旨在证实代码中某些必须为真的条件的不变性。这样的条件可能是逻辑或算法的结果,也可能基于某些已记录的约定。例如,如果你正在编写一个将一个数字提高到某个幂的函数,那么你如何处理数和幂都为零的数学上未定义的情况?你可以使用断言来明确表达这一点,如下面的代码片段所示(第 6 行):

 1 #include <cassert>
 2
 3 double power(double base, double exponent)
 4 {
 5   // no negative powers of zero
 6   assert(base != 0 || exponent > 0);
 7   …
 8 }

这样的不变性违反表明存在错误或缺陷,需要修复,并导致程序在调试构建中发生灾难性故障。Boost 提供了一个名为BOOST_STATIC_ASSERT的宏,它接受一个可以在编译时求值的表达式,并在这个表达式求值为假时触发编译失败。

例如,您可能已经设计了一个内存分配器类模板,该模板仅用于“小”对象。当然,小是任意的,但您可以设计您的分配器以优化大小为 16 字节或更小的对象。如果您想强制正确使用您的类,您应该简单地阻止其对大于 16 字节的类的实例化。这是我们的第一个例子BOOST_STATIC_ASSERT,它可以帮助您强制执行分配器的小对象语义:

清单 2.16a:使用编译时断言

 1 #include <boost/static_assert.hpp>
 2
 3 template <typename T>
 4 class SmallObjectAllocator
 5 {
 6   BOOST_STATIC_ASSERT(sizeof(T) <= 16);
 7
 8 public:
 9   SmallObjectAllocator() {}
10 };

我们定义了一个名为SmallObjectAllocator的虚拟分配器模板(第 3 和第 4 行),并在类范围内调用BOOST_STATIC_ASSERT宏(第 6 行)。我们将一个必须在编译时可能求值的表达式传递给宏。现在,sizeof表达式总是由编译器求值的,而 16 是一个整数字面量,因此表达式sizeof(T) <= 16可以完全在编译时求值,并且可以传递给BOOST_STATIC_ASSERT。如果我们现在用类型Foo实例化SmallObjectAllocator,其大小为 32 字节,我们将由于第 6 行的静态断言而得到编译器错误。这是可以触发断言的代码:

清单 2.16b:使用编译时断言

11 struct Foo
12 {
13   char data[32];
14 };
15
16 int main()
17 {
18   SmallObjectAllocator<int> intAlloc;
19   SmallObjectAllocator<Foo> fooAlloc; // ERROR: sizeof(Foo) > 16
20 }

我们定义了一个类型Foo,其大小为 32 字节,大于SmallObjectAllocator支持的最大大小(第 13 行)。我们使用类型int(第 18 行)和Foo(第 19 行)实例化SmallObjectAllocator模板。SmallObjectAllocator<Foo>的编译失败,我们得到一个错误消息。

提示

C++11 支持使用新的static_assert关键字进行编译时断言。如果您使用的是 C++11 编译器,BOOST_STATIC_ASSERT在内部使用static_assert

实际的错误消息自然会因编译器而异,特别是在 C++03 编译器上。在 C++11 编译器上,因为这在内部使用static_assert关键字,错误消息往往更加统一和有意义。然而,在 C++11 之前的编译器上,您也可以得到一个相当准确的错误行。在我的系统上,使用 GNU g++编译器在 C++03 模式下,我得到了以下错误:

StaticAssertTest.cpp: In instantiation of 'class SmallObjectAllocator<Foo>':
StaticAssertTest.cpp:19:29:   required from here
StaticAssertTest.cpp:6:3: error: invalid application of 'sizeof' to incomplete type 'boost::STATIC_ASSERTION_FAILURE<false>'

编译器错误的最后一行引用了一个不完整的类型boost::STATIC_ASSERTION_FAILURE<false>,它来自BOOST_STATIC_ASSERT宏的内部。很明显,第 6 行出现了错误,静态断言失败。如果我切换到 C++11 模式,错误消息会更加合理:

StaticAssertTest.cpp: In instantiation of 'class SmallObjectAllocator<Foo>':
StaticAssertTest.cpp:19:29:   required from here
StaticAssertTest.cpp:6:3: error: static assertion failed: sizeof(T) <= 16

还有另一种静态断言宏的变体称为BOOST_STATIC_ASSERT,它将消息字符串作为第二个参数。对于 C++11 编译器,它只是打印这个消息作为错误消息。在 C++11 之前的编译器下,这个消息可能会或可能不会出现在编译器错误内容中。您可以这样使用它:

 1 BOOST_STATIC_ASSERT_MSG(sizeof(T) <= 16, "Objects of size more" 
 2                         " than 16 bytes not supported.");

并非所有表达式都可以在编译时求值。大多数情况下,涉及常量整数、类型大小和一般类型计算的表达式可以在编译时求值。Boost TypeTraits 库和 Boost Metaprogramming Library (MPL)提供了几个元函数,使用这些元函数可以在编译时对类型进行许多复杂的条件检查。我们用一个小例子来说明这种用法。我们将在后面的章节中看到更多这种用法的例子。

我们不仅可以在类范围内使用静态断言,还可以在函数和命名空间范围内使用。这是一个函数模板库的示例,允许对不同的 POD 类型进行位操作。在实例化这些函数时,我们在编译时断言传递的类型是 POD 类型:

清单 2.17:使用编译时断言

 1 #include <boost/static_assert.hpp>
 2 #include <boost/type_traits.hpp>
 3
 4 template <typename T, typename U>
 5 T bitwise_or (const T& left, const U& right)
 6 {
 7   BOOST_STATIC_ASSERT(boost::is_pod<T>::value && 
 8                       boost::is_pod<U>::value);
 9   BOOST_STATIC_ASSERT(sizeof(T) >= sizeof(U));
10
11   T result = left;
12   unsigned char *right_array =
13           reinterpret_cast<unsigned char*>(&right);
14   unsigned char *left_array =
15           reinterpret_cast<unsigned char*>(&result);
16   for (size_t i = 0; i < sizeof(U); ++i) {
17     left_array[i] |= right_array[i];
18   }
19
20   return result;
21 }

在这里,我们定义了一个函数bitwise_or(第 4 和 5 行),它接受两个对象,可能是不同类型和大小的,并返回它们内容的按位或。在这个函数内部,我们使用了元函数boost::is_pod<T>来断言传递的两个对象都是 POD 类型(第 7 行)。此外,因为函数的返回类型是T,即左参数的类型,我们断言函数必须始终首先调用较大的参数(第 9 行),以便没有数据丢失。

使用预处理宏进行诊断

在我作为软件工程师的职业生涯中,有很多次我曾经在建立在五种不同 Unix 和 Windows 上的单一代码库的产品上工作,通常是并行进行的。通常这些构建服务器会是大型服务器,附带数百吉字节的存储空间,用于多个产品进行构建。会有无数的环境、工具链和配置共存于同一服务器上。将这些系统稳定到一切都能完美构建的程度肯定花费了很长时间。有一天,地狱就在一夜之间降临了,尽管没有进行任何重大的提交,我们的软件开始表现得很奇怪。我们花了将近一天的时间才发现有人动了环境变量,结果我们使用了不同版本的编译器进行链接,并且使用了与我们的第三方库构建时不同的运行时进行链接。我不需要告诉你,即使在那个时候,这对于构建系统来说也不是理想的情况。不幸的是,你可能仍然会发现这样混乱的环境,需要很长时间来设置,然后被一个轻率的改变破坏。在半天的徒劳努力之后拯救我们的是明智地使用预处理宏在程序启动时倾倒有关构建系统的信息,包括编译器名称、版本、架构等。我们很快就能从程序倾倒的数据中获得足够的信息,在它不可避免地崩溃之前,我们就发现了编译器不匹配的问题。

这样的信息对于可能能够通过利用特定接口在每个编译器或平台上提供库的最优实现的库编写者来说是双重有用的,并且可以根据预处理宏定义进行条件编译。然而,使用这些宏的弊端在于不同编译器、平台和环境之间的绝对差异,包括它们的命名和功能是什么。Boost 通过其ConfigPredef库提供了一个更加统一的一组用于获取有关软件构建环境信息的预处理宏。我们将看一下这些库中一些有用的宏。

Predef库是一个仅包含头文件的库,提供了各种宏,用于在编译时获取有关构建环境的有用信息。可用的信息可以分为不同的类别。我们将看一下以下代码,以说明如何访问和使用这些信息,而不是提供一个选项的长列表并解释它们的作用——在线文档已经充分做到了这一点。

清单 2.18a:使用 Predef 中的诊断宏

 1 #include <boost/predef.h>
 2 #include <iostream>
 3
 4 void checkOs()
 5 {
 6   // identify OS
 7 #if defined(BOOST_OS_WINDOWS)
 8   std::cout << "Windows" << '\n';
 9 #elif defined(BOOST_OS_LINUX)
10   std::cout << "Linux" << '\n';
11 #elif defined(BOOST_OS_MACOS)
12   std::cout << "MacOS" << '\n';
13 #elif defined(BOOST_OS_UNIX)
14   std::cout << Another UNIX" << '\n'; // *_AIX, *_HPUX, etc. 
15 #endif
16 }

前面的函数使用了Predef库中的BOOST_OS_*宏来识别代码所构建的操作系统。我们只展示了三种不同操作系统的宏。在线文档提供了用于识别不同操作系统的完整列表的宏。

清单 2.18b:使用 Predef 中的诊断宏

 1 #include <boost/predef.h>
 2 #include <iostream>
 34 void checkArch()
 5 {
 6   // identify architecture
 7 #if defined(BOOST_ARCH_X86)
 8  #if defined(BOOST_ARCH_X86_64)
 9   std::cout << "x86-64 bit" << '\n';
10  #else
11   std::cout << "x86-32 bit" << '\n';
12  #endif
13 #elif defined(BOOST_ARCH_ARM)
14   std::cout << "ARM" << '\n';
15 #else
16   std::cout << "Other architecture" << '\n';
17 #endif
18 }

前面的函数使用了Predef库中的BOOST_ARCH_*宏来识别代码所构建的平台的架构。我们只展示了 x86 和 ARM 架构的宏;在线文档提供了用于识别不同架构的完整列表的宏。

清单 2.18c:使用 Predef 中的诊断宏

 1 #include <boost/predef.h>
 2 #include <iostream>
 3
 4 void checkCompiler()
 5 {
 6   // identify compiler
 7 #if defined(BOOST_COMP_GNUC)
 8   std::cout << "GCC, Version: " << BOOST_COMP_GNUC << '\n';
 9 #elif defined(BOOST_COMP_MSVC)
10   std::cout << "MSVC, Version: " << BOOST_COMP_MSVC << '\n';
11 #else
12   std::cout << "Other compiler" << '\n';
13 #endif
14 }

前面的函数使用了Predef库中的BOOST_COMP_*宏来识别用于构建代码的编译器。我们只展示了 GNU 和 Microsoft Visual C++编译器的宏。在线文档提供了用于识别不同编译器的完整宏列表。当定义了特定编译器的BOOST_COMP_*宏时,它会评估为其数值版本。例如,在 Visual Studio 2010 上,BOOST_COMP_MSVC评估为100030319。这可以被翻译为版本10.0.30319

2.18d 清单:使用 Predef 中的诊断宏

 1 #include <boost/predef.h>
 2 #include <iostream>
 3
 4 void checkCpp11()
 5 {
 6   // Do version checks
 7 #if defined(BOOST_COMP_GNUC)
 8  #if BOOST_COMP_GNUC < BOOST_VERSION_NUMBER(4, 8, 1)
 9    std::cout << "Incomplete C++ 11 support" << '\n';
10  #else
11    std::cout << "Most C++ 11 features supported" << '\n';
12  #endif
13 #elif defined(BOOST_COMP_MSVC)
14  #if BOOST_COMP_MSVC < BOOST_VERSION_NUMBER(12, 0, 0)
15    std::cout << "Incomplete C++ 11 support" << '\n';
16  #else
17    std::cout << "Most C++ 11 features supported" << '\n';
18  #endif
19 #endif
20 }

在上面的代码中,我们使用BOOST_VERSION_NUMBER宏来构建与当前版本的 GNU 或 Microsoft Visual C++编译器进行比较的版本。如果 GNU 编译器版本小于 4.8.1 或 Microsoft Visual Studio C++编译器版本小于 12.0,我们会打印出对 C++11 的支持可能不完整。

在本节的最后一个示例中,我们使用boost/config.hpp中的宏来打印编译器、平台和运行时库的名称(第 6、7 和 8 行)。我们还使用boost/version.hpp中定义的两个宏来打印所使用的 Boost 版本,一个作为字符串(第 10 行),一个作为数值(第 11 行):

2.19 清单:使用配置信息宏

 1 #include <boost/config.hpp>
 2 #include <boost/version.hpp>
 3 #include <iostream>
 4 
 5 void buildEnvInfo() {
 6   std::cout << "Compiler: " << BOOST_COMPILER << '\n'
 7             << "Platform: " << BOOST_PLATFORM << '\n'
 8             << "Library: " << BOOST_STDLIB << '\n';
 9
10   std::cout << "Boost version: " << BOOST_LIB_VERSION << '['
11                             << BOOST_VERSION << ']' << '\n';
12 }

自测问题

对于多项选择题,选择所有适用的选项:

  1. 使用boost::swap而不是std::swap的优点是什么?

a. 没有真正的优势

b. boost::swap会调用传递类型提供的交换重载

c. boost::swapstd::swap更快

d. boost::swap不会抛出异常

  1. 您能在单个调用中将访问者应用于多个变体参数吗?(提示:您可能需要查阅在线文档)

a. 是的。访问者只能应用于一个或两个变体参数

b. 是的。访问者可以应用于一个或多个参数

c. 不。成员操作符只接受一个变体参数

d. 以上都不是

  1. 以下是否是有效的编译时断言?

BOOST_STATIC_ASSERT(x == 0); // x is some variable

a. 是的,只要x是整数类型

b. 是的,只要x声明为const static数值变量

c. 不,x是一个变量,其值在编译时无法知道

d. 只有涉及sizeof的表达式在BOOST_STATIC_ASSERT中是有效的

  1. 当我们说类型X是 POD 类型时,我们是什么意思?

a. X没有用户定义的构造函数或析构函数

b. 通过按位复制其内存布局可以复制X

c. X没有用户定义的复制构造函数或复制赋值运算符

d. 以上所有

  1. 在类型为boost::variant<std::string, double>的默认构造对象中存储的类型和值是什么?

a. 类型是const char*,值是NULL

b. 类型是double,值是0.0

c. 类型是std::string,值是默认构造的std::string

d. 类型是boost::optional<double>,值为空

  1. 查看 Boost 库在线文档中 Boost.Optional 的参考资料。如果在一个空的optional对象上调用getget_ptr方法会发生什么?

a. 两者都会抛出boost::empty_optional异常

b. get抛出异常,而get_ptr返回空指针

c. get会断言,而get_ptr会返回空指针

d. getget_ptr都会断言

总结

本章是对几个 Boost 库的快速介绍,这些库帮助您完成重要的编程任务,如解析命令行、创建类型安全的变体类型、处理空值和执行编译时检查。

希望您已经欣赏了 Boost 库中的多样性以及它们为您的代码提供的表达能力。在这个过程中,您也会更加熟悉使用 Boost 库编译代码并根据需要链接到适当的库。

在下一章中,我们将看看如何使用各种 Boost 智能指针的变种以确定性地管理堆内存和其他资源,以及在异常安全的方式下。

参考资料

奇怪的递归模板模式:en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Curiously_Recurring_Template_Pattern

第三章:内存管理和异常安全

C++与 C 编程语言有很高的兼容性。C++保留了指针来表示和访问特定的内存地址,并通过newdelete运算符提供了手动内存管理原语。您还可以无缝地从 C++访问 C 标准库函数和大多数主要操作系统的 C 系统调用或平台 API。自然地,C++代码经常处理对各种 OS 资源的句柄,如堆内存、打开的文件、套接字、线程和共享内存。获取这些资源并未能释放它们可能会对您的程序产生不良后果,表现为隐匿的错误,包括内存泄漏和死锁。

在本章中,我们将探讨使用智能指针封装动态分配对象的指针的方法,以确保在不再需要时它们会自动释放。然后我们将这些技术扩展到非内存资源。在这个过程中,我们将理解什么是异常安全的代码,并使用智能指针来编写这样的代码。

这些主题分为以下几个部分:

  • 动态内存分配和异常安全

  • 智能指针

  • 唯一所有权语义

  • 共享所有权语义

在本章的某些部分,您将需要使用支持 C++11 的编译器。这将在各个部分中附加说明。

动态内存分配和异常安全

想象一下,您需要编写一个程序来旋转图像。您的程序接受文件名和旋转角度作为输入,读取文件的内容,执行处理,并返回输出。以下是一些示例代码。

 1 #include <istream>
 2 #include <fstream>
 3 typedef unsigned char byte;
 4 
 5 byte *rotateImage(std::string imgFile, double angle, 
 6                   size_t& sz) {
 7   // open the file for reading
 8   std::ifstream imgStrm(imgFile.c_str(), std::ios::binary);
 9 
10   if (imgStrm) {
11     // determine file size
12     imgStrm.seekg(0, std::ios::end);
13     sz = imgStrm.tellg();
14     imsStrm.seekg(0);        // seek back to start of stream
15
16     byte *img = new byte[sz]; // allocate buffer and read
17     // read the image contents
18     imgStrm.read(reinterpret_cast<char*>(img), sz);
19     // process it
20     byte *rotated = img_rotate(img, sz, angle);
21     // deallocate buffer
22     delete [] img;
23 
24     return rotated;
25   }
26 
27   sz = 0;
28   return 0;
29 }

旋转图像的实际工作是由一个名为img_rotate的虚构的 C++ API 完成的(第 20 行)。img_rotate函数接受三个参数:图像内容作为字节数组,数组的大小以非 const 引用的形式,以及旋转角度。它返回旋转后图像的内容作为动态分配的字节数组。通过作为第三个参数传递的引用返回该数组的大小。这是一个不完美的代码,更像是 C 语言。这样的代码在“野外”中非常常见,这就是为什么了解它的缺陷很重要。因此,让我们来剖析一下问题。

为了读取图像文件的内容,我们首先确定文件的大小(第 12-13 行),然后分配一个足够大的字节数组img来容纳文件中的所有数据(第 16 行)。我们读取图像内容(第 18 行),并在通过调用img_rotate进行图像旋转后,删除包含原始图像的缓冲区img(第 22 行)。最后,我们返回旋转后的图像的字节数组(第 24 行)。为简单起见,我们没有检查读取错误(第 18 行)。

在前面的代码中有两个明显的问题。如果图像旋转失败(第 19 行)并且img_rotate抛出异常,那么rotateImage函数将在不释放字节缓冲区img的情况下返回,这样就会泄漏。这是一个明显的例子,说明在面对异常时代码的行为不佳,也就是说,它不是异常安全的。此外,即使一切顺利,该函数也会返回旋转后的缓冲区(第 24 行),这本身是动态分配的。因此,我们完全将其释放的责任留给调用者,没有任何保证。我们应该做得更好。

还有一个不太明显的问题。 img_rotate函数应该已经记录了它如何分配内存,以便我们知道如何释放它——通过调用数组删除(delete [])运算符(第 22 行)。但是,如果开发img_rotate找到了更有效的自定义内存管理方案,并希望在下一个版本中使用呢?他们会避免这样做;否则,所有客户端代码都会中断,因为delete []运算符可能不再是正确的释放内存的方式。理想情况下,img_rotate API 的客户端不应该为此烦恼。

异常安全和 RAII

在前面的例子中,我们非正式地看了一下异常安全的概念。我们看到img_rotate API 可能抛出的潜在异常可能会在rotateImage函数中泄漏资源。事实证明,您可以根据一组标准称为The Abrahams Exception Safety Guarantees来推断代码在面对异常时的行为。它们以 Dave Abrahams 的名字命名,他是 Boost 的联合创始人和杰出的 C++标准委员会成员,他在 1996 年正式化了这些保证。此后,它们已经被其他人进一步完善,包括特别是 Herb Sutter,并列在下面:

  • 基本保证:中途终止的操作保留不变,并且不会泄漏资源

  • 强保证:中途终止的操作不会产生任何影响,即操作是原子的

  • 无异常保证:无法失败的操作

不满足这些标准的操作被称为“不安全的异常”或更通俗地说,不安全的异常。操作的适当异常安全级别是程序员的特权,但不安全的异常代码很少被接受。

用于使代码具有异常安全性的最基本和有效的 C++技术是名为Resource Acquisition is InitializationRAII)的奇特名称。 RAII 习惯提出了封装需要手动管理的资源的以下模型:

  1. 在包装对象的构造函数中封装资源获取。

  2. 在包装对象的析构函数中封装资源释放。

  3. 此外,为包装对象定义一致的复制和移动语义,或者禁用它们。

如果包装对象是在堆栈上创建的,则其析构函数也会在正常范围退出以及由于异常退出时调用。否则,包装对象本身应该由 RAII 习惯管理。粗略地说,您可以在堆栈上创建对象,也可以使用 RAII 来管理它们。在这一点上,我们需要一些例子,然后我们可以直接回到图像旋转示例并使用 RAII 进行修复:

 1 struct ScopeGuard
 2 {
 3   ScopeGuard(byte *buffer) : data_(buffer) {}
 4   ~ScopeGuard() { delete [] data_; }
 5
 6   byte *get() { return data_; }
 7 private:
 8   byte *data_;
 9 };
10 
11 byte *rotateImage(std::string imgFile, double angle, size_t& sz)
12 {
13   // open the file for reading
14   std::ifstream imgStrm(imgFile.c_str(), std::ios::binary);
15 
16   if (imgStrm) {
17     // determine file size
18     imgStrm.seekg(0, std::ios::end);
19     sz = imgStrm.tellg();
20     imgStrm.seekg(0);
21
22     // allocate buffer and read
23     ScopeGuard img(new byte[sz]);
24     // read the image contents
25     imgStrm.read(reinterpret_cast<char*>(img.get()), sz);
26     // process it
27     return img_rotate(img.get(), sz, angle);
28   } // ScopeGuard destructor
29 
30   sz = 0;
31   return 0;
32 }

前面的代码是一个谦虚的尝试,使rotateImage函数在img_rotate函数本身是异常安全的情况下是异常安全的。首先,我们定义了一个名为ScopeGuard(第 1-9 行)的struct,用于封装由数组new operator分配的字符数组。它以分配的数组指针作为其构造函数参数,并将数据成员data_设置为该指针(第 3 行)。它的析构函数使用数组delete运算符(第 4 行)释放其data_成员指向的数组。get成员函数(第 6 行)提供了一种从ScopeGuard对象获取底层指针的方法。

rotateImage函数内部,我们实例化了一个名为imgScopeGuard对象,它包装了使用数组new运算符分配的字节数组(第 23 行)。我们调用打开文件流的read方法,并将imgget方法获取的原始字节数组传递给它(第 25 行)。我们假设读取总是成功的,但在生产代码中,我们应该始终进行适当的错误检查。最后,我们调用img_rotate API 并返回它返回的旋转图像(第 27 行)。当我们退出作用域时,ScopeGuard析构函数被调用,并自动释放封装的字节数组(第 28 行)。即使img_rotate抛出异常,ScopeGuard析构函数仍将在堆栈展开的过程中被调用。通过使用ScopeGuard类的 RAII,我们能够声明rotateImage函数永远不会泄漏包含图像数据的缓冲区。

另一方面,由rotateImage返回的旋转图像的缓冲区可能会泄漏,除非调用者注意将其分配给指针,然后以异常安全的方式释放它。ScopeGuard类在其当前形式下并不适用。事实证明,Boost 提供了不同类型的智能指针模板来解决这些问题,值得理解这些智能指针以及它们帮助解决的资源获取模式和异常安全问题。

智能指针

智能指针,明确地说,是一个封装指针访问并经常管理与指针相关的内存的类。如果你注意到了,你会注意到智能指针与菠萝的相似之处——智能指针是类,而不是指针,就像菠萝并不是真正的苹果一样。摆脱水果类比,不同类型的智能指针通常具有额外的功能,如边界检查、空指针检查和访问控制等。在 C++中,智能指针通常重载解引用运算符(operator->),这允许使用operator->在智能指针上调用的任何方法调用都绑定到底层指针上。

Boost 包括四种不同语义的智能指针。此外,由于 C++经常使用指针来标识和操作对象数组,Boost 提供了两种不同的智能数组模板,它们通过指针封装了数组访问。在接下来的章节中,我们将研究 Boost 中不同类别的智能指针及其语义。我们还将看看std::unique_ptr,这是一个 C++11 智能指针类,它取代了 Boost 的一个智能指针,并支持 Boost 中不容易获得的语义。

独占所有权语义

考虑以下代码片段来实例化一个对象并调用其方法:

 1 class Widget;
 2 
 3 // …
 4 
 5 void useWidget()
 6 {
 7   Widget *wgt = new Widget;
 8   wgt->setTitle(...);
 9   wgt->setSize(...);
10   wgt->display(...);
11   delete wgt;
12 }

正如我们在前一节中看到的,前面的代码并不具有异常安全性。在动态内存上构造Widget对象(第 7 行)之后和销毁Widget对象(第 11 行)之前抛出的异常可能导致为Widget对象动态分配的内存泄漏。为了解决这个问题,我们需要类似于我们在前一节中编写的ScopeGuard类,而 Boost 则提供了boost::scoped_ptr模板。

boost::scoped_ptr

以下是使用scoped_ptr修复的前面的示例。scoped_ptr模板可以从头文件boost/scoped_ptr.hpp中获得。它是一个仅包含头文件的库,你不需要将你的程序链接到任何其他库:

清单 3.1:使用 scoped_ptr

 1 #include <boost/scoped_ptr.hpp>
 2 #include "Widget.h"  // contains the definition of Widget
 3 
 4 // …
 5 
 6 void useWidget()
 7 {
 8   boost::scoped_ptr<Widget> wgt(new Widget);
 9   wgt->setTitle(...);
10   wgt->setSize(...);
11   wgt->display(...);
12 }

在前面的代码中,wgtscoped_ptr<Widget>类型的对象,它是Widget*指针的替代品。我们用动态分配的Widget对象对其进行初始化(第 8 行),并且省略了delete的调用。这是使这段代码具有异常安全性所需的唯一两个更改。

scoped_ptr和 Boost 中的其他智能指针一样,在它们的析构函数中调用delete来释放封装的指针。当useWidget完成或者如果异常中止它,scoped_ptr实例wgt的析构函数将被调用,并且将销毁Widget对象并释放其内存。scoped_ptr中的重载解引用运算符(operator->)允许通过wgt智能指针访问Widget成员(第 9-11 行)。

boost::scoped_ptr模板的析构函数使用boost::checked_delete来释放封装指针指向的动态分配内存。因此,在boost::scoped_ptr实例超出范围时,封装指针指向的对象的类型必须在完全定义;否则,代码将无法编译。

boost::scoped_ptr是 Boost 智能指针中最简单的一个。它接管传递的动态分配指针,并在自己的析构函数中调用delete。这将使底层对象的生命周期绑定到封装scoped_ptr操作的范围,因此称为scoped_ptr。本质上,它在封装的指针上实现了 RAII。此外,scoped_ptr不能被复制。这意味着动态分配的对象在任何给定时间点只能被一个scoped_ptr实例包装。因此,scoped_ptr被认为具有唯一所有权语义。请注意,scoped_ptr实例不能存储在标准库容器中,因为它们在 C++11 意义上既不能被复制也不能被移动。

在下面的例子中,我们探索了scoped_ptr的一些更多特性:

清单 3.2:详细介绍 scoped_ptr

 1 #include <boost/scoped_ptr.hpp>
 2 #include <cassert>
 3 #include "Widget.h" // Widget definition
 4 // …
 5 
 6 void useTwoWidgets()
 7 {
 8   // default constructed scoped_ptr 
 9   boost::scoped_ptr<Widget> wgt;
10   assert(!wgt);          // null test - Boolean context
11 
12   wgt.reset(new Widget); // create first widget
13   assert(wgt);          // non-null test – Boolean context
14   wgt->display();        // display first widget
15   wgt.reset(new Widget); // destroy first, create second widget
16   wgt->display();        // display second widget
17   
18   Widget *w1 = wgt.get();  // get the raw pointer
19   Widget& rw1 = *wgt;      // 'dereference' the smart pointer
20   assert(w1 == &rw1);      // same object, so same address
21
22   boost::scoped_ptr<Widget> wgt2(new Widget);
23   Widget *w2 = wgt2.get();
24   wgt.swap(wgt2);
25   assert(wgt.get() == w2);  // effect of swap
26   assert(wgt2.get() == w1); // effect of swap
27 }

在这个例子中,我们首先使用默认构造函数(第 9 行)构造了一个scoped_ptr<Widget>类型的对象。这创建了一个包含空指针的scoped_ptr。任何尝试对这样一个智能指针进行解引用的行为都会导致未定义的行为,通常会导致崩溃。scoped_ptr支持隐式转换为布尔值;因此我们可以在布尔上下文中像wgt这样使用scoped_ptr对象来检查封装的指针是否为空。在这种情况下,我们知道它应该为空,因为它是默认构造的;因此,我们断言wgt为空(第 10 行)。

有两种方法可以改变scoped_ptr中包含的指针,其中一种是使用scoped_ptrreset成员方法。当我们在scoped_ptr上调用reset时,封装的指针被释放,并且scoped_ptr接管新传递的指针。因此,我们可以使用reset来改变scoped_ptr实例所拥有的指针(第 12 行)。随后,scoped_ptr包含一个非空指针,并且我们使用隐式转换scoped_ptr为布尔值的能力进行断言(第 13 行)。接下来,我们再次调用reset来在wgt中存储一个新的指针(第 15 行)。在这种情况下,先前存储的指针被释放,并且在存储新指针之前底层对象被销毁。

我们可以通过调用scoped_ptrget成员函数(第 18 行)来获取底层指针。我们还可以通过对智能指针进行解引用(第 19 行)来获取指向的对象的引用。我们断言这个引用和get返回的指针都指向同一个对象(第 20 行)。

当然,改变scoped_ptr中包含的指针的第二种方法是交换两个scoped_ptr对象,它们的封装指针被交换(第 24-26 行)。这是改变动态分配对象的拥有scoped_ptr的唯一方法。

总之,我们可以说一旦你用scoped_ptr包装了一个对象,它就永远不能从scoped_ptr中分离出来。scoped_ptr可以销毁对象并接管一个新对象(使用reset成员函数),或者它可以与另一个scoped_ptr中的指针交换。在这个意义上,scoped_ptr表现出独特的、可转移的所有权语义。

scoped_ptr 的用途

scoped_ptr是一个轻量级且多功能的智能指针,它不仅可以作为作用域保护器,还可以用于其他用途。下面是它在代码中的使用方式。

创建异常安全的作用域

scoped_ptr在创建异常安全的作用域时非常有用,当对象在某个作用域中动态分配。C++允许对象在堆栈上创建,通常这是你会采取的创建对象的方式,而不是动态分配它们。但是,在某些情况下,你需要通过调用返回指向动态分配对象的指针的工厂函数来实例化对象。这可能来自某个旧库,scoped_ptr可以成为这些指针的方便包装器。在下面的例子中,makeWidget就是一个这样的工厂函数,它返回一个动态分配的Widget

 1 class Widget { ... };
 2
 3 Widget *makeWidget() // Legacy function
 4 {
 5   return new Widget;
 6 }
 7 
 8 void useWidget()
 9 {
10   boost::scoped_ptr<Widget> wgt(makeWidget());
11   wgt->display();              // widget displayed
12 }   // Widget destroyed on scope exit

一般来说,前面形式中的useWidget将是异常安全的,只要从useWidget中调用的makeWidget函数也是异常安全的。

在函数之间转移对象所有权

作为不可复制的对象,scoped_ptr对象不能从函数中以值传递或返回。可以将scoped_ptr的非 const 引用作为参数传递给函数,这将重置其内容并将新指针放入scoped_ptr对象中。

清单 3.3:使用 scoped_ptr 进行所有权转移

 1 class Widget { ... };
 2
 3 void makeNewWidget(boost::scoped_ptr<Widget>& result)
 4 {
 5   result.reset(new Widget);
 6   result->setProperties(...);
 7 }
 8 
 9 void makeAndUseWidget()
10 {
11   boost::scoped_ptr<Widget> wgt; // null wgt
12   makeNewWidget(wgt);         // wgt set to some Widget object.
13   wgt->display();              // widget #1 displayed
14 
15   makeNewWidget(wgt);        // wgt reset to some other Widget.
16                              // Older wgt released.
17   wgt->display();            // widget #2 displayed
18 }

makeNewWidget函数使用传递给它的scoped_ptr<Widget>引用作为输出参数,用它来返回动态分配的对象(第 5 行)。每次调用makeNewWidget(第 12、15 行)都用新的动态分配的Widget对象替换其先前的内容,并删除先前的对象。这是一种将在函数内动态分配的对象所有权转移到函数外作用域的方法。这种方法并不经常使用,在 C++11 中使用std::unique_ptr有更多成语化的方法来实现相同的效果,这将在下一节中讨论。

作为类成员

在 Boost 的智能指针中,scoped_ptr通常只被用作函数中的本地作用域保护,但实际上,它也可以作为类成员来确保异常安全,是一个有用的工具。

考虑以下代码,其中类DatabaseHandler为了记录到文件和连接到数据库创建了两个虚构类型FileLoggerDBConnection的动态分配对象。FileLoggerDBConnection以及它们的构造函数参数都是用于说明目的的虚构类。

// DatabaseHandler.h
 1 #ifndef DATABASEHANDLER_H
 2 #define DATABASEHANDLER_H
 3
 4 class FileLogger;
 5 class DBConnection;
 6
 7 class DatabaseHandler
 8 {
 9 public:
10   DatabaseHandler();
11   ~DatabaseHandler();
12   // other methods here
13
14 private:
15   FileLogger *logger_;
16   DBConnection *dbconn_;
17 };
18
19 #endif /* DATABASEHANDLER_H */

前面的代码是DatabaseHandler类在头文件DatabaseHandler.h中的定义清单。FileLoggerDBConnection是不完整的类型,只被前向声明过。我们只声明了指向它们的指针,由于指针的大小不依赖于底层类型的大小,编译器不需要知道FileHandlerDBConnection的定义来确定DatabaseHandler类的总大小,而是以其指针成员的总大小来确定。

设计类的这种方式有一个优势。DatabaseHandler的客户端包括前面列出的DatabaseHandler.h文件,但不依赖于FileLoggerDBConnection的实际定义。如果它们的定义发生变化,客户端保持不受影响,无需重新编译。这本质上就是 Herb Sutter 所推广的Pimpl Idiom。类的实际实现被抽象在一个单独的源文件中:

// DatabaseHandler.cpp
 1 #include "DatabaseHandler.h"
 2 
 3 // Dummy concrete implementations
 4 class FileLogger
 5 {
 6 public:
 7   FileLogger(const std::string& logfile) {...}
 8 private:
 9   ...
10 };
11
12 class DBConnection
13 {
14 public:
15   DBConnection(const std::string& dbhost,
16                const std::string& username,
17                const std::string& passwd) {...}
18 private:
19   ...
20 };
21
22 // class methods implementation
23 DatabaseHandler::DatabaseHandler(const std::string& logFile,
24           const std::string& dbHost,
25           const std::string& user, const std::string& passwd)
26         : logger_(new FileLogger(logFile)), 
27           dbconn_(new DBConnection(dbHost, user, passwd))
28 {}
29
30 ~DatabaseHandler()
31 {
32   delete logger_;
33   delete dbconn_;
34 }
35 
36 // Other methods

在这个源文件中,我们可以访问FileLoggerDBConnection的具体定义。即使这些定义和我们的实现的其他部分发生了变化,只要DatabaseHandler的公共方法和类布局没有发生变化,DatabaseHandler的客户端就不需要改变或重新编译。

但这段代码非常脆弱,可能会泄漏内存和其他资源。考虑一下如果FileLogger构造函数抛出异常会发生什么(第 26 行)。为logger_指针分配的内存会自动释放,不会造成进一步的损害。异常从DatabaseHandler构造函数传播到调用上下文,DatabaseHandler的对象不会被实例化;目前为止一切都很好。

现在考虑如果FileLogger对象成功构造,然后DBConnection构造函数抛出异常(第 27 行)。在这种情况下,异常发生时为dbconn_指针分配的内存会自动释放,但为logger_指针分配的内存不会被释放。当异常发生时,任何非 POD 类型的完全构造成员的析构函数都会被调用。但logger_是一个原始指针,它是一个 POD 类型,因此它没有析构函数。因此,logger_指向的内存泄漏了。

一般来说,如果你的类有多个指向动态分配对象的指针,确保异常安全性就变得很具挑战性,大多数围绕使用 try/catch 块的过程性解决方案都不太好扩展。智能指针是解决这类问题的完美工具,只需很少的代码就可以解决。我们在下面使用scoped_ptr来修复前面的例子。这是头文件:

清单 3.4:将 scoped_ptr 用作类成员

// DatabaseHandler.h
 1 #ifndef DATABASEHANDLER_H
 2 #define DATABASEHANDLER_H
 3
 4 #include <boost/scoped_ptr.hpp>
 5
 6 class FileLogger;
 7 class DBConnection;
 8
 9 class DatabaseHandler
10 {
11 public:
12   DatabaseHandler(const std::string& logFile,
13        const std::string& dbHost, const std::string& user,
14        const std::string& passwd);
15   ~DatabaseHandler();
16   // other methods here
17
18 private:
19   boost::scoped_ptr<FileLogger> logger_;
20   boost::scoped_ptr<DBConnection> dbconn_;
21 
22   DatabaseHandler(const DatabaseHandler&);
23   DatabaseHandler& operator=(const DatabaseHandler&);
24 };
25 #endif /* DATABASEHANDLER_H */

logger_dbconn_现在是scoped_ptr实例,而不是原始指针(第 19 行和第 20 行)。另一方面,由于scoped_ptr是不可复制的,编译器无法生成默认的复制构造函数和复制赋值运算符。我们可以像这里做的那样禁用它们(第 22 行和第 23 行),或者自己定义它们。一般来说,为scoped_ptr定义复制语义只有在封装类型可复制时才有意义。另一方面,使用scoped_ptrswap成员函数可能更容易定义移动语义。现在让我们看看源文件的变化:

// DatabaseHandler.cpp
 1 #include "DatabaseHandler.h"
 2 
 3 // Dummy concrete implementations
 4 class FileLogger
 5 {
 6 public:
 7   FileLogger(const std::string& logfile) {...}
 8 private:
 9   ...
10 };
11
12 class DBConnection
13 {
14 public:
15   DBConnection(const std::string& dbhost,
16                const std::string& username,
17                const std::string& passwd) {...}
18 private:
19   ...
20 };
21
22 // class methods implementation
23 DatabaseHandler::DatabaseHandler(const std::string& logFile,
24             const std::string& dbHost, const std::string& user,
25             const std::string& passwd)
26         : logger_(new FileLogger(logFileName)),
27           dbconn_(new DBConnection(dbsys, user, passwd))
28 {}
29
30 ~DatabaseHandler()
31 {}
32 
33 // Other methods

我们在构造函数初始化列表中初始化了两个scoped_ptr实例(第 26 行和第 27 行)。如果DBConnection构造函数抛出异常(第 27 行),则会调用logger_的析构函数,它会清理动态分配的FileLogger对象。

DatabaseHandler析构函数为空(第 31 行),因为没有 POD 类型的成员,而scoped_ptr成员的析构函数会自动调用。但我们仍然必须定义析构函数。你能猜到为什么吗?如果让编译器生成定义,它会在头文件中的类定义范围内生成析构函数定义。在那个范围内,FileLoggerDBConnection没有完全定义,scoped_ptr的析构函数将无法编译通过,因为它们使用boost::checked_delete(第二章,“与 Boost 实用工具的初次接触”)

boost::scoped_array

scoped_ptr 类模板非常适用于单个动态分配的对象。现在,如果您还记得我们的激励示例,即编写图像旋转实用程序,我们需要在我们自定义的 ScopeGuard 类中包装一个动态数组,以使 rotateImage 函数具有异常安全性。Boost 提供了 boost::scoped_array 模板作为 boost::scoped_ptr 的数组类似物。boost::scoped_array 的语义与 boost::scoped_ptr 完全相同,只是它有一个重载的下标运算符 (operator[]) 用于访问封装数组的单个元素,并且不提供其他形式的间接操作符的重载 (operator*operator->)。在这一点上,使用 scoped_array 重写 rotateImage 函数将是有益的。

清单 3.5:使用 scoped_array

 1 #include <boost/scoped_array.hpp>
 2
 3 typedef unsigned char byte;
 4
 5 byte *rotateImage(const std::string &imgFile, double angle, 
 6                   size_t& sz) {
 7   // open the file for reading
 8   std::ifstream imgStrm(imgFile, std::ios::binary);
 9 
10   if (imgStrm) {
11     imgStrm.seekg(0, std::ios::end);
12     sz = imgStrm.tellg();            // determine file size
13     imgStrm.seekg(0);
14 
15     // allocate buffer and read
16     boost::scoped_array<byte> img(new byte[sz]);
17     // read the image contents
18     imgStrm.read(reinterpret_cast<char*>(img.get()), sz);
19 
20     byte first = img[0];  // indexed access
21     return img_rotate(img.get(), sz, angle);
22   }
23 
24   sz = 0;
25   return 0;
26 }

我们现在使用 boost::scoped_array 模板来代替我们的 ScopeGuard 类,以包装动态分配的数组(第 16 行)。在作用域退出时,由于正常执行或异常,scoped_array 的析构函数将调用包含动态数组的数组删除运算符 (delete[]) 并以异常安全的方式释放它。为了突出从 scoped_array 接口访问数组元素的能力,我们使用 scoped_array 的重载 operator[] 来访问第一个字节(第 20 行)。

scoped_array 模板主要用于处理大量动态数组的遗留代码。由于重载下标运算符,scoped_array 可以直接替换动态分配的数组。因此,将动态数组封装在 scoped_array 中是实现异常安全的快速途径。C++ 倡导使用 std::vector 而不是动态数组,这可能是你最终的目标。然而,作为几乎没有与向量相比的空间开销的包装器,scoped_array 可以帮助更快地过渡到异常安全的代码。

std::unique_ptr

C++ 11 引入了 std::unique_ptr 智能指针模板,它取代了已弃用的 std::auto_ptr,支持 boost::scoped_ptrboost::scoped_array 的功能,并且可以存储在标准库容器中。它在标准头文件 memory 中定义,与 C++11 中引入的其他智能指针一起。

std::unique_ptr 的成员函数很容易映射到 boost::scoped_ptr 的成员函数:

  • 默认构造的 unique_ptr 包含一个空指针(nullptr),就像默认构造的 scoped_ptr 一样。

  • 您可以调用 get 成员函数来访问包含的指针。

  • reset 成员函数释放旧指针并接管新指针的所有权(可以是空指针)。

  • swap 成员函数交换两个 unique_ptr 实例的内容,并且始终成功。

  • 您可以使用 operator* 对非空的 unique_ptr 实例进行解引用,并使用 operator-> 访问成员。

  • 您可以在布尔上下文中使用 unique_ptr 实例来检查是否为空,就像 scoped_ptr 实例一样。

  • 然而,在某些方面,std::unique_ptrboost::scoped_ptr 更灵活。

  • unique_ptr 是可移动的,不像 scoped_ptr。因此,它可以存储在 C++11 标准库容器中,并且可以从函数中返回。

  • 如果必须,您可以分离 std::unique_ptr 拥有的指针并手动管理它。

  • 有一个用于动态分配数组的 unique_ptr 部分特化。scoped_ptr 不支持数组,您必须使用 boost::scoped_array 模板来实现这一目的。

使用 unique_ptr 进行所有权转移

std::unique_ptr 智能指针可以像 boost::scoped_ptr 一样用作作用域保护。与 boost::scoped_ptr 不同,unique_ptr 实例 不需要绑定到单个作用域,可以从一个作用域移动到另一个作用域。

std::unique_ptr智能指针模板不能被复制,但支持移动语义。支持移动语义使得可以将std::unique_ptr用作函数返回值,从而在函数之间传递动态分配的对象的所有权。以下是一个这样的例子:

列表 3.6a:使用 unique_ptr

// Logger.h
 1 #include <memory>
 2
 3 class Logger
 4 {
 5 public:
 6   Logger(const std::string& filename) { ... }
 7   ~Logger() {...}
 8   void log(const std::string& message, ...) { ... }
 9   // other methods
10 };
11
12 std::unique_ptr<Logger> make_logger(
13                       const std::string& filename) {
14   std::unique_ptr<Logger> logger(new Logger(filename));
15   return logger;
16 }

make_logger函数是一个工厂函数,返回一个包装在unique_ptr中的Logger的新实例(第 14 行)。一个函数可以这样使用make_logger

列表 3.6b:使用 unique_ptr

 1 #include "Logger.h"
 2 
 3 void doLogging(const std::string& msg, ...)
 4 {
 5   std::string logfile = "/var/MyApp/log/app.log";
 6   std::unique_ptr<Logger> logger = make_logger(logfile);
 7   logger->log(msg, ...);
 8 }

在函数doLogging中,局部变量logger通过从make_logger返回的unique_ptr进行移动初始化(第 6 行)。因此,make_logger内部创建的unique_ptr实例的内容被移动到变量logger中。当logger超出范围时,即doLogging返回时(第 8 行),它的析构函数将销毁底层的Logger实例并释放其内存。

unique_ptr中包装数组

为了说明使用unique_ptr包装动态数组的用法,我们将再次重写图像旋转示例(列表 3.5),将scoped_ptr替换为unique_ptr

列表 3.7:使用 unique_ptr 包装数组

 1 #include <memory>
 2
 3 typedef unsigned char byte;
 4
 5 byte *rotateImage(std::string imgFile, double angle, size_t& sz)
 6 {
 7   // open the file for reading
 8   std::ifstream imgStrm(imgFile, std::ios::binary);
 9 
10   if (imgStrm) {
11     imgStrm.seekg(0, std::ios::end);
12     sz = imgStrm.tellg();      // determine file size
13     imgStrm.seekg(0);
14     
15     // allocate buffer and read
16     std::unique_ptr<byte[]> img(new byte[sz]);
17     // read the image contents
18     imgStrm.read(reinterpret_cast<char*>(img.get()),sz);
19     // process it
20     byte first = img[0];  // access first byte
21     return img_rotate(img.get(), sz, angle);
22   }
23 
24   sz = 0;
25   return 0;
26 }

除了包含不同的头文件(memory代替boost/scoped_ptr.hpp)之外,只需要编辑一行代码。在boost::scoped_array<byte>的位置,img的声明类型更改为std::unique_ptr<byte[]>(第 16 行)- 一个明确的替换。重载的operator[]仅适用于unique_ptr的数组特化,并用于引用数组的元素。

在 C++14 中使用 make_unique

C++14 标准库包含一个函数模板std::make_unique,它是一个用于在动态内存上创建对象实例并将其包装在std::unique_ptr中的工厂函数。以下示例是对列表 3.6b 的重写,用于说明make_unique的用法:

列表 3.8:使用 make unique

 1 #include "Logger.h"  // Listing 3.6a
 2 
 3 void doLogging(const std::string& msg, ...)
 4 {
 5   std::string filename = "/var/MyApp/log/app.log";
 6   std::unique_ptr<Logger> logger = 
 7                 std::make_unique<Logger>(filename);
 8   logger->log(msg, ...);
 9 }

std::make_unique函数模板将要构造的基础对象的类型作为模板参数,并将对象的构造函数的参数作为函数参数。我们直接将文件名参数传递给make_unique,它将其转发给Logger的构造函数(第 7 行)。make_unique是一个可变模板;它接受与实例化类型的构造函数参数匹配的变量数量和类型。如果Logger有一个两个参数的构造函数,比如一个接受文件名和默认日志级别的构造函数,我们将向make_unique传递两个参数:

// two argument constructor
Logger::Logger(const std::string& filename, loglevel_t level) {
  ...
}

std::unique_ptr<Logger> logger =
 std::make_unique<Logger>(filename, DEBUG);

假设loglevel_t描述用于表示日志级别的类型,DEBUG描述该类型的一个有效值,前面的片段说明了使用make_unique与多个构造函数参数的用法。

如果您已将代码库迁移到 C++11,应优先使用std::unique_ptr而不是boost::scoped_ptr

共享所有权语义

具有转移所有权能力的独特所有权语义对于大多数您使用智能指针的目的来说已经足够好了。但在一些现实世界的应用中,您需要在多个上下文中共享资源,而这些上下文中没有一个是明确的所有者。这样的资源只有在持有对共享资源的引用的所有上下文释放它们时才能释放。这种释放的时间和地点无法提前确定。

让我们通过一个具体的例子来理解这一点。在单个进程中,两个线程从内存中的同一动态分配区的不同部分读取数据。每个线程对数据进行一些处理,然后再读取更多数据。我们需要确保当最后一个线程终止时,动态分配的内存区域能够被清理释放。任何一个线程都可能在另一个线程之前终止;那么谁来释放缓冲区呢?

通过将缓冲区封装在一个智能包装器中,该包装器可以保持对其的引用计数,并且仅当计数变为零时才释放缓冲区,我们可以完全封装释放逻辑。缓冲区的用户应该切换到使用智能包装器,他们可以自由复制,并且当所有副本超出范围时,引用计数变为零并且缓冲区被释放。

boost::shared_ptr 和 std::shared_ptr

boost::shared_ptr智能指针模板提供了引用计数的共享所有权语义。它使用共享引用计数来跟踪对它的引用次数,该引用计数与包装的动态分配对象一起维护。与我们迄今为止看到的其他智能指针模板一样,它实现了 RAII 习语,负责在其析构函数中销毁和释放包装的对象,但只有当所有对它的引用都被销毁时才这样做,也就是说,引用计数变为零。它是一个仅包含头文件的库,通过包括boost/shared_ptr.hpp可用。

shared_ptr于 2007 年被包含在 C++标准委员会技术报告(俗称 TR1)中,这是 C++11 标准的前身,并作为std::tr1::shared_ptr提供。它现在是 C++11 标准库的一部分,作为std::shared_ptr通过标准 C++头文件memory提供。如果您将代码库迁移到 C++11,应该使用std::shared_ptr。本节中的大部分讨论都适用于两个版本;如果有任何区别,都会被指出。

您创建shared_ptr实例来拥有动态分配的对象。与boost::scoped_ptrstd::unique_ptr不同,您可以复制shared_ptr实例。std::shared_ptr还支持移动语义。它存储动态分配的指针和共享引用计数对象。每次通过复制构造函数复制shared_ptr时,指针和引用计数对象都会被浅复制。复制shared_ptr实例会导致引用计数增加。shared_ptr实例超出范围会导致引用计数减少。use_count成员函数可用于获取当前引用计数。以下是一个展示shared_ptr的示例:

清单 3.9:shared_ptr的示例

 1 #include <boost/shared_ptr.hpp>
 2 #include <iostream>
 3 #include <cassert>
 4 
 5 class Foo {
 6 public:
 7   Foo() {}
 8   ~Foo() { std::cout << "~Foo() destructor invoked." << '\n';}
 9 };
10 
11 typedef boost::shared_ptr<Foo> SPFoo;
12   
13 int main()
14 {
15   SPFoo f1(new Foo);
16   // SPFoo f1 = new Foo; // Won't work, explicit ctor
17   assert(f1.use_count() == 1);
18
19   // copy construction
20   SPFoo f2(f1);
21   assert(f1.use_count() == f2.use_count() && 
22          f1.get() == f2.get() && f1.use_count() == 2);
23   std::cout << "f1 use_count: " << f1.use_count() << '\n';
24          
25   SPFoo f3(new Foo);
26   SPFoo f4(f3);
27   assert(f3.use_count() == 2 && f3.get() == f4.get());
28   std::cout << "f3 use_count: " << f3.use_count() << '\n';
29  
30   // copy assignment
31   f4 = f1;
32   assert(f4.use_count() == f1.use_count() && 
33         f1.use_count() == 3 && f1.get() == f4.get());
34   assert(f3.use_count() == 1);
35   std::cout << "f1 use_count: " << f1.use_count() << '\n';
36   std::cout << "f3 use_count: " << f3.use_count() << '\n';
37 }

在上述代码中,我们定义了一个带有默认构造函数和打印一些消息的析构函数的Foo类(第 5-9 行)。我们包括了boost/shared_ptr.hpp(第 1 行),它提供了boost::shared_ptr模板。

在主函数中,我们定义了两个shared_ptr<Foo>实例f1(第 15 行)和f3(第 25 行),初始化为Foo类的两个不同动态分配的实例。请注意,shared_ptr构造函数是显式的,因此您不能使用赋值表达式使用隐式转换来复制初始化shared_ptr(第 16 行)。每个shared_ptr<Foo>实例在构造后的引用计数为 1(第 17 行和第 25 行)。接下来,我们创建f2作为f1的副本(第 20 行),并创建f4作为f3的副本(第 26 行)。复制会导致引用计数增加。shared_ptrget成员函数返回封装的指针,use_count成员函数返回当前引用计数。使用use_count,我们断言f1f2具有相同的引用计数,并使用get,我们断言它们包含相同的指针(第 21-22 行)。对于f3f4也是如此(第 27 行)。

接下来,我们将f1复制分配给f4(第 31 行)。结果,f4现在包含与f1f2相同的指针,并且不再与f3共享指针。现在,f1f2f4是指向相同指针的三个shared_ptr<Foo>实例,它们的共享引用计数变为 3(第 32-33 行)。f3不再与另一个实例共享其指针,因此其引用计数变为 1(第 34 行)。

运行上述代码,您可以期望以下输出:

f1 use_count: 2
f3 use_count: 2
f1 use_count: 3
f3 use_count: 1
~Foo() destructor invoked.
~Foo() destructor invoked.

引用计数在main函数结束时确实变为零,并且shared_ptr析构函数销毁了动态创建的Foo实例。

shared_ptr的用途

在 C++11 之前的代码中,由于其灵活性和易用性,boost::shared_ptrstd::tr1::shared_ptr往往是智能指针的默认选择,而不是boost::scoped_ptr。它用于超出纯共享所有权语义的目的,这使其成为最知名的智能指针模板。在 C++11 中,应该遏制这种普遍使用,而应该优先使用std::unique_ptrshared_ptr应该仅用于模拟真正的共享所有权语义。

作为类成员

考虑一个场景,应用程序的多个组件可以共享单个数据库连接以获得更好的性能。只要有一些组件在使用它,就可以在首次请求时创建这样的连接并将其缓存。当所有组件都使用完毕后,连接应该被关闭。这是共享所有权语义的定义,shared_ptr在这种情况下非常有用。让我们看看应用程序组件如何使用shared_ptr来封装共享数据库连接:

清单 3.10:将 shared_ptr 用作类成员

 1 class AppComponent
 2 {
 3 public:
 4  AppComponent() : spconn_(new DatabaseConnection(...))
 5  {}
 6 
 7  AppComponent( 
 8         const boost::shared_ptr<DatabaseConnection>& spc)
 9      : spconn_(spc) {}
11 
12  // Other public member
13  ...
14
15  boost::shared_ptr<DatabaseConnection> getConnection() {
16    return spconn_;
17  }
18 
19 private:
20  boost::shared_ptr<DatabaseConnection> spconn_;
21  // other data members
22 };

AppComponent是应用程序的一个组件,它使用包装在shared_ptr(第 20 行)中的数据库连接。默认构造的AppComponent创建一个新的数据库连接(第 4 行),但您始终可以通过传递包装在shared_ptr(第 7-9 行)中的现有数据库连接来创建AppComponent实例。getConnection成员函数检索包装在 shared_ptr 中的DatabaseConnection对象(第 16 行)。以下是一个例子:

 1 AppComponent c1;
 2 AppComponent c2(a.getConnection());

在此示例中,我们创建了两个AppComponent实例c1c2,它们共享相同的数据库连接。第二个实例是使用第一个实例缓存的shared_ptr包装的数据库连接通过getConnection方法获得的。无论c1c2的销毁顺序如何,只有当两者中的最后一个被销毁时,共享连接才会被销毁。

在标准库容器中存储动态分配的对象

标准库容器存储的对象被复制或移动到容器中,并随容器一起销毁。对象也通过复制或移动来检索。在 C++11 之前,没有支持移动语义,复制是在容器中存储对象的唯一机制。标准库容器不支持引用语义。您可以将动态分配对象的指针存储在容器中,但在其生命周期结束时,容器不会尝试通过指针销毁和释放这些对象。

您可以将动态分配的对象包装在shared_ptrunique_ptr中并将它们存储在容器中。假设您可以使用 C++11,如果将它们存储在单个容器中就足够了,那么std::unique_ptr就足够好了。但是,如果需要在多个容器中存储相同的动态分配对象,shared_ptr是包装器的最佳选择。当容器被销毁时,将调用每个shared_ptr实例的析构函数,并将该shared_ptr的引用计数减少。如果任何shared_ptr的引用计数为零,则其中存储的底层动态对象将被释放。以下示例说明了如何将shared_ptr中包装的对象存储在多个 STL 容器中:

清单 3.11:在容器中存储 shared_ptr

 1 class Person;
 2 typedef boost::shared_ptr<Person> PersonPtr;
 3 std::vector<PersonPtr> personList;
 4 std::multimap<std::string, PersonPtr> personNameMap;
 5 ...
 6 
 7 for (auto it = personList.begin(); 
 8      it != personList.end(); ++it) {
 9   personNameMap.insert(std::make_pair((*it)->name(), *it));
10 }

在前面的例子中,让我们假设有一个名为Person的类(第 1 行)。现在,给定一个类型为Person的对象列表,我们想要创建一个将名称映射到Person对象的映射。假设Person对象不能被复制,因此它们需要以指针的形式存储在容器中。我们为shared_ptr<Person>定义了一个类型别名称为PersonPtr(第 2 行)。我们还定义了用于存储Person对象列表的数据结构(std::vector<PersonPtr>(第 3 行))和将Person名称映射到Person对象的映射(std::multimap<std::string, PersonPtr>(第 4 行))。最后,我们从列表构造映射(第 7-9 行)。

personNameMap容器中的每个条目都被创建为一个人的名称和PersonPtr对象的std::pair(使用std::make_pair)。每个这样的条目都使用其insert成员函数插入到multimap中(第 9 行)。我们假设Person中有一个名为name的成员函数。PersonPtr对象作为shared_ptrvectormultimap容器之间共享。当两个容器中的最后一个被销毁时,Person对象也将被销毁。

除了shared_ptr,Boost 的指针容器提供了一种在容器中存储动态分配对象的替代方法。我们将在第五章中介绍指针容器,超越 STL 的有效数据结构。在第九章中,文件、目录和 IOStreams,处理 Boost 线程,我们将看到shared_ptr实例如何在线程之间共享。

非拥有别名 - boost::weak_ptr 和 std::weak_ptr

在上一节中,我们看到的一个例子是多个应用程序组件共享的数据库连接。这种使用方式有一定的缺点。在实例化旨在重用打开的数据库连接的应用程序组件时,您需要引用另一个使用连接的现有组件,并将该连接传递给新对象的构造函数。更可扩展的方法是解耦连接创建和应用程序组件创建,以便应用程序组件甚至不知道它们是否获得了新连接或现有可重用连接。但要求仍然是连接必须在所有客户端之间共享,并且在最后一个引用它消失时必须关闭连接。

构建这样一种机制的一种方法是使用数据库连接工厂,它根据调用者传递的连接参数创建到特定数据库实例的连接。然后将连接包装在shared_ptr中返回给调用者,并将其存储在可以查找的映射中。当新的客户端请求连接到相同数据库用户的相同实例时,工厂可以简单地从映射中查找现有连接并将其包装在shared_ptr中返回。以下是说明此逻辑的代码。它假设连接到数据库实例所需的所有信息都封装在DBCredentials对象中:

 1 typedef boost::shared_ptr<DatabaseConnection> DBConnectionPtr;
 2
 3 struct DBConnectionFactory
 4 {
 5   typedef std::map<DBCredentials, DBConnectionPtr> 
 6                                             ConnectionMap;
 7
 8   static DBConnectionPtr connect(const DBCredentials& creds)
 9   {
10     auto iter = conn_map_.find(creds);
11
12     if (iter != conn_map_.end()) {
13       return iter->second;
14     } else {
15       DBConnectionPtr dbconn(new DatabaseConnection(creds));
16       conn_map_[creds] = dbconn;
17       return dbconn;
18     }
19   }
20 
21   static ConnectionMap conn_map_;
22 };
23 
24 DBConnectionFactory::ConnectionMap 
25                                DBConnectionFactory::conn_map_;
26 int main()
27 {
28   DBCredentials creds(...);
29   DBConnectionPtr dbconn = DBConnectionFactory::connect(creds);
30   DBConnectionPtr dbconn2 =DBConnectionFactory::connect(creds);
31   assert(dbconn.get() == dbconn2.get() 
32          && dbconn.use_count() == 3);
33 }

在前面的代码中,DBConnectionFactory提供了一个名为connect的静态方法,它接受一个DBCredentials对象并返回一个shared_ptr包装的DatabaseConnectionDBConnectionPtr)(第 8-19 行)。我们调用DBConnectionFactory::connect两次,传递相同的凭据。第一次调用(第 28 行)应该导致创建一个新的连接(第 15 行),而第二次调用应该只是查找并返回相同的连接(第 10-13 行)。

这段代码存在一个主要问题:DBConnectionFactory将连接存储在静态的std::map conn_map_(第 21 行)中的shared_ptr中。结果是,只有在程序结束时conn_map_被销毁时,引用计数才会变为 0。否则,即使没有上下文使用连接,引用计数仍保持为 1。我们要求,当所有使用共享连接的上下文退出或过期时,连接应该被销毁。显然这个要求没有得到满足。

在地图中存储原始指针(DatabaseConnection*)而不是shared_ptrDBConnectionPtr)是不好的,因为我们需要为连接创建更多的shared_ptr实例时,能够使用我们分发的第一个shared_ptr实例。即使有方法可以解决这个问题(正如我们将在enable_shared_from_this中看到的),通过在连接映射中查找原始指针,我们也无法知道它是否仍在使用或已经被释放。

boost::weak_ptr模板,也可以在 C++11 中作为std::weak_ptr使用,是解决这个问题的正确工具。您可以使用一个或多个weak_ptr实例引用shared_ptr实例,而不会增加决定其生命周期的引用计数。使用weak_ptr实例,您可以安全地确定它所引用的shared_ptr是否仍然活动或已过期。如果没有过期,您可以使用weak_ptr实例来创建另一个引用相同对象的shared_ptr实例。现在我们将使用weak_ptr重写前面的示例:

清单 3.12:使用 weak_ptr

 1 typedef boost::shared_ptr<DatabaseConnection> DBConnectionPtr;
 2 typedef boost::weak_ptr<DatabaseConnection> DBConnectionWkPtr;
 3
 4 struct DBConnectionFactory
 5 {
 6   typedef std::map<DBCredentials, DBConnectionWkPtr> 
 7                                             ConnectionMap;
 8
 9   static DBConnectionPtr connect(const DBCredentials& creds) {
10      ConnectionIter it = conn_map_.find(creds);
11      DBConnectionPtr connptr;
12
13     if (it != conn_map_.end() &&
14         (connptr = it->second.lock())) {
15       return connptr;
16     } else {
17       DBConnectionPtr dbconn(new DatabaseConnection(creds));
18       conn_map_[creds] = dbconn;  // weak_ptr = shared_ptr;
19       return dbconn;
20     }
21   }
22 
23   static ConnectionMap conn_map_;
24 };
25 
26 DBConnectionFactory::ConnectionMap 
27                                DBConnectionFactory::conn_map_;
28 int main()
29 {
30   DBCredentials creds(...);
31   DBConnectionPtr dbconn = DBConnectionFactory::connect(creds);
32   DBConnectionPtr dbconn2 =DBConnectionFactory::connect(creds);
33   assert(dbconn.get() == dbconn2.get() 
34          && dbconn.use_count() == 2);
35 }

在这个例子中,我们修改了ConnectionMap的定义,将shared_ptr<DatabaseConnection>存储为weak_ptr<DatabaseConnection>(第 6-7 行)。当调用DBConnectionFactory::connect函数时,代码查找条目(第 10 行),失败时,创建一个新的数据库连接,将其包装在shared_ptr中(第 17 行),并将其存储为地图中的weak_ptr(第 18 行)。请注意,我们使用复制赋值运算符将shared_ptr分配给weak_ptr。新构造的shared_ptr被返回(第 19 行)。如果查找成功,它会尝试从中检索的weak_ptr上调用lock方法来构造一个shared_ptr(第 12 行)。如果由it->second表示的检索的weak_ptr引用一个有效的shared_ptrlock调用将自动返回另一个引用相同对象的shared_ptr,并将其分配给connptr变量并返回(第 15 行)。否则,lock调用将返回一个空的shared_ptr,我们将在else块中创建一个新的连接,就像之前描述的那样。

如果您只想检查weak_ptr实例是否引用有效的shared_ptr,而不创建一个新的shared_ptr引用对象,只需在weak_ptr上调用expired方法。只有当至少有一个shared_ptr实例仍然存在时,它才会返回false

weak_ptr是如何实现这一点的?实际上,shared_ptrweak_ptr是设计为相互配合使用的。每个shared_ptr实例都有两块内存:它封装的动态分配的对象和一个名为共享计数器的内存块,其中包含两个原子引用计数而不是一个。这两块内存都在所有相关的shared_ptr实例之间共享。共享计数器块也与引用这些shared_ptr实例的所有weak_ptr实例共享。

共享计数器中的第一个引用计数,使用计数,保持对shared_ptr的引用数量的计数。当此计数变为零时,封装的动态分配对象将被删除,shared_ptr将过期。第二个引用计数,弱引用计数,是weak_ptr引用的数量,加上 1(仅当有shared_ptr实例存在时)。只有当弱引用计数变为零时,也就是当所有shared_ptrweak_ptr实例都过期时,共享计数块才会被删除。因此,任何剩余的weak_ptr实例都可以通过检查使用计数来判断shared_ptr是否已过期,并查看它是否为 0。weak_ptrlock方法会原子地检查使用计数,并仅在它不为零时递增它,返回一个包装封装指针的有效shared_ptr。如果使用计数已经为零,lock将返回一个空的shared_ptr

一个 shared_ptr 的批评 - make_shared 和 enable_shared_from_this

shared_ptr已被广泛使用,超出了适当使用共享所有权语义的用例。这在一定程度上是由于它作为 C++ 技术报告 1TR1)发布的一部分而可用,而其他可行的选项,如 Boost 的指针容器(参见第五章,超出 STL 的有效数据结构)并不是 TR1 的一部分。但是shared_ptr需要额外的分配来存储共享计数,因此构造和销毁比unique_ptrscoped_ptr慢。共享计数本身是一个包含两个原子整数的对象。如果您从不需要共享所有权语义但使用shared_ptr,则需要为共享计数支付额外的分配,并且需要为原子计数的递增和递减操作付费,这使得复制shared_ptr变慢。如果您需要共享所有权语义但不关心weak_ptr观察者,那么您需要为弱引用计数占用的额外空间付费,而这是您不需要的。

缓解这个问题的一种方法是以某种方式将两个分配(一个用于对象,一个用于共享计数)合并为一个。boost::make_shared函数模板(C++11 中也有std::make_shared)是一个可变函数模板,正是这样做的。以下是您将如何使用它的方式:

清单 3.13:使用 make_shared

 1 #include <boost/make_shared.hpp>
 2
 3 struct Foo {
 4   Foo(const std::string& name, int num);
 5   ...
 6 };
 7
 8 boost::shared_ptr<Foo> spfoo = 
 9             boost::make_shared<Foo>("Foo", 10);
10

boost::make_shared函数模板将对象的类型作为模板参数,并将对象的构造函数的参数作为函数参数。我们调用make_shared<Foo>,将要用来构造Foo对象的参数传递给它(第 8-9 行)。然后函数会在内存中分配一个单一的内存块,其中放置对象,并一次性附加两个原子计数。请注意,您需要包含头文件boost/make_shared.hpp才能使用make_shared。这并不像看起来那么完美,但可能是一个足够好的折衷方案。这并不完美,因为现在它是一个单一的内存块而不是两个,并且在所有shared_ptrweak_ptr引用之间共享。

即使所有shared_ptr引用都消失并且对象被销毁,只有当最后一个weak_ptr消失时,其内存才会被回收。同样,只有在使用持久的weak_ptr实例并且对象大小足够大以至于成为一个问题时,这才是一个问题。

我们之前简要讨论过shared_ptr的另一个问题。如果我们从同一个原始指针创建两个独立的shared_ptr实例,那么它们将有独立的引用计数,并且两者都会尝试在适当的时候删除封装的对象。第一个会成功,但第二个实例的析构函数很可能会崩溃,试图删除一个已经删除的实体。此外,在第一个实例超出范围后,通过第二个shared_ptr尝试解引用对象的任何尝试都将同样灾难性。解决这个问题的一般方法是根本不使用shared_ptr,而是使用boost::intrusive_ptr——这是我们在下一节中探讨的内容。解决这个问题的另一种方法是为包装类的实例方法提供返回shared_ptr的能力,使用this指针。为此,你的类必须从boost::enable_shared_from_this类模板派生。下面是一个例子:

清单 3.14:使用 enable_shared_from_this

 1 #include <boost/smart_ptr.hpp>
 2 #include <boost/current_function.hpp>
 3 #include <iostream>
 4 #include <cassert>
 5
 6 class CanBeShared
 7        : public boost::enable_shared_from_this<CanBeShared> {
 8 public:
 9   ~CanBeShared() {
10     std::cout << BOOST_CURRENT_FUNCTION << '\n';
11   }
12   
13   boost::shared_ptr<CanBeShared> share()
14   {
15     return shared_from_this();
16   }
17 };
18 
19 typedef boost::shared_ptr<CanBeShared> CanBeSharedPtr;
20 
21 void doWork(CanBeShared& obj)
22 {
23   CanBeSharedPtr sp = obj.share();
24   std::cout << "Usage count in doWork "<<sp.use_count() <<'\n';
25   assert(sp.use_count() == 2);
26   assert(&obj == sp.get());
27 }
28 
29 int main()
30 {
31   CanBeSharedPtr cbs = boost::make_shared<CanBeShared>();
32   doWork(*cbs.get());
33   std::cout << cbs.use_count() << '\n';
34   assert(cbs.use_count() == 1);
35 }

在前面的代码中,类CanBeShared派生自boost::enable_shared_from_this<CanBeShared>(第 7 行)。如果你想知道为什么CanBeShared继承自一个类模板实例,该实例以CanBeShared本身作为模板参数,那么我建议你查阅一下奇异递归模板模式,这是一个 C++习惯用法,你可以在网上了解更多。现在,CanBeShared定义了一个名为share的成员函数,它返回包装在shared_ptr中的this指针(第 13 行)。它使用了它从基类继承的成员函数shared_from_this(第 15 行)来实现这一点。

main函数中,我们从类型为CanBeShared的动态分配对象(第 31 行)创建了CanBeSharedPtr的实例cbs(它是boost::shared_ptr<CanBeShared>typedef)。接下来,我们调用doWork函数,将cbs中的原始指针传递给它(第 32 行)。doWork函数被传递了一个对CanBeShared的引用(obj),并调用了它的share方法来获取相同对象的shared_ptr包装(第 23 行)。这个shared_ptr的引用计数现在变成了 2(第 25 行),它包含的指针指向obj(第 26 行)。一旦doWork返回,cbs上的使用计数就会回到 1(第 34 行)。

从对shared_from_this的调用返回的shared_ptr实例是从enable_shared_from_this<>基类中的weak_ptr成员实例构造的,并且仅在包装对象的构造函数结束时构造。因此,如果你在类的构造函数中调用shared_from_this,你将遇到运行时错误。你还应该避免在尚未包装在shared_ptr对象中的原始指针上调用它,或者在开始时就不是动态构造的对象上调用它。C++11 标准将这一功能标准化为std::enable_shared_from_this,可以通过标准头文件memory使用。我们在编写异步 TCP 服务器时广泛使用enable_shared_from_this,详见第十一章 网络编程使用 Boost Asio

如果你有雄辩的眼力,你会注意到我们只包含了一个头文件boost/smart_ptr.hpp。这是一个方便的头文件,将所有可用的智能指针功能集成到一个头文件中,这样你就不必记得包含多个头文件。

提示

如果你可以使用 C++11,那么在大多数情况下应该使用std::unique_ptr,只有在需要共享所有权时才使用shared_ptr。如果出于某种原因仍在使用 C++03,你应该尽可能利用boost::scoped_ptr,或者使用boost::shared_ptrboost::make_shared以获得更好的性能。

侵入式智能指针 - boost::intrusive_ptr

考虑一下当你将同一个指针包装在两个不是彼此副本的shared_ptr实例中会发生什么。

 1 #include <boost/shared_ptr.hpp>
 2 
 3 int main()
 4 {
 5   boost::shared_ptr<Foo> f1 = boost::make_shared<Foo>();
 6   boost::shared_ptr<Foo> f2(f1.get());  // don't try this
 7
 8   assert(f1.use_count() == 1 && f2.use_count() == 1);
 9   assert(f1.get() == f2.get());
10 } // boom!

在前面的代码中,我们创建了一个shared_ptr<Foo>实例(第 5 行)和第二个独立的shared_ptr<Foo>实例,使用与第一个相同的指针(第 6 行)。其结果是两个shared_ptr<Foo>实例都具有引用计数为 1(第 8 行的断言),并且都包含相同的指针(第 9 行的断言)。在作用域结束时,f1f2的引用计数都变为零,并且都尝试在相同的指针上调用delete(第 10 行)。由于双重删除的结果,代码几乎肯定会崩溃。从编译的角度来看,代码是完全合法的,但行为却不好。您需要防范对shared_ptr<Foo>的这种使用,但这也指出了shared_ptr的一个局限性。这个局限性是由于仅凭借原始指针,无法判断它是否已被某个智能指针引用。共享引用计数在Foo对象之外,不是其一部分。shared_ptr被称为非侵入式。

另一种方法是将引用计数作为对象本身的一部分进行维护。在某些情况下可能不可行,但在其他情况下将是完全可接受的。甚至可能存在实际维护这种引用计数的现有对象。如果您曾经使用过 Microsoft 的组件对象模型,您就使用过这样的对象。boost::intrusive_ptr模板是shared_ptr的一种侵入式替代品,它将维护引用计数的责任放在用户身上,并使用用户提供的钩子来增加和减少引用计数。如果用户愿意,引用计数可以成为类布局的一部分。这有两个优点。对象和引用计数在内存中相邻,因此具有更好的缓存性能。其次,所有boost::intrusive_ptr实例使用相同的引用计数来管理对象的生命周期。因此,独立的boost::intrusive_ptr实例不会造成双重删除的问题。实际上,您可以潜在地同时为同一个对象使用多个不同的智能指针包装器,只要它们使用相同的侵入式引用计数。

使用 intrusive_ptr

要管理类型为X的动态分配实例,您创建boost::intrusive_ptr<X>实例,就像创建其他智能指针实例一样。您只需要确保有两个全局函数intrusive_ptr_add_ref(X*)intrusive_ptr_release(X*)可用,负责增加和减少引用计数,并在引用计数变为零时调用delete删除动态分配的对象。如果X是命名空间的一部分,那么这两个全局函数最好也应该在相同的命名空间中定义,以便进行参数相关查找。因此,用户控制引用计数和删除机制,并且boost::intrusive_ptr提供了一个 RAII 框架,它们被连接到其中。请注意,如何维护引用计数是用户的权利,不正确的实现可能会导致泄漏、崩溃,或者至少是低效的代码。最后,这里是一些使用boost::intrusive_ptr的示例代码:

清单 3.15:使用 intrusive_ptr

 1 #include <boost/intrusive_ptr.hpp>
 2 #include <iostream>
 3 
 4 namespace NS {
 5 class Bar {
 6 public:
 7   Bar() : refcount_(0) {}
 8  ~Bar() { std::cout << "~Bar invoked" << '\n'; }
 9 
10   friend void intrusive_ptr_add_ref(Bar*);
11   friend void intrusive_ptr_release(Bar*);
12 
13 private:
14   unsigned long refcount_;
15 };
16 
17 void intrusive_ptr_add_ref(Bar* b) {
18   b->refcount_++;
19 }
20 
21 void intrusive_ptr_release(Bar* b) {
22   if (--b->refcount_ == 0) {
23     delete b;
24   }
25 }    
26 } // end NS
27 
28 
29 int main()
30 {
31   boost::intrusive_ptr<NS::Bar> pi(new NS::Bar, true);
32   boost::intrusive_ptr<NS::Bar> pi2(pi);
33   assert(pi.get() == pi2.get());
34   std::cout << "pi: " << pi.get() << '\n'
35             << "pi2: " << pi2.get() << '\n';
36 }

我们使用boost::intrusive_ptr来包装类Bar的动态分配对象(第 31 行)。我们还可以将一个intrusive_ptr<NS::Bar>实例复制到另一个实例中(第 32 行)。类Bar在成员变量refcount_中维护其引用计数,类型为unsigned long(第 14 行)。intrusive_ptr_add_refintrusive_ptr_release函数被声明为类Bar的友元(第 10 和 11 行),并且在与Bar相同的命名空间NS中(第 3-26 行)。intrusive_ptr_add_ref每次调用时都会增加refcount_intrusive_ptr_release会减少refcount_并在refcount_变为零时对其参数指针调用delete

Bar将变量refcount_初始化为零。我们将布尔值的第二个参数传递给intrusive_ptr构造函数,以便构造函数通过调用intrusive_ptr_add_ref(NS::Bar*)来增加Barrefcount_(第 31 行)。这是默认行为,intrusive_ptr构造函数的布尔值第二个参数默认为true,因此我们实际上不需要显式传递它。另一方面,如果我们处理的是一个在初始化时将其引用计数设置为 1 而不是 0 的类,那么我们不希望构造函数再次增加引用计数。在这种情况下,我们应该将第二个参数传递给intrusive_ptr构造函数为false。复制构造函数总是通过调用intrusive_ptr_add_ref增加引用计数。每个intrusive_ptr实例的析构函数调用intrusive_ptr_release,并将封装的指针传递给它。

虽然前面的示例说明了如何使用boost::intrusive_ptr模板,但如果你要管理动态分配的对象,Boost 提供了一些便利。boost::intrusive_ref_counter包装了一些通用的样板代码,这样你就不必自己编写那么多了。以下示例说明了这种用法:

清单 3.16:使用 intrusive_ptr 减少代码

 1 #include <boost/intrusive_ptr.hpp>
 2 #include <boost/smart_ptr/intrusive_ref_counter.hpp>
 3 #include <iostream>
 4 #include <cassert>
 5
 6 namespace NS {
 7 class Bar : public boost::intrusive_ref_counter<Bar> {
 8 public:
 9   Bar() {}
10   ~Bar() { std::cout << "~Bar invoked" << '\n'; }
11 };
12 } // end NS
13
14 int main() {
15   boost::intrusive_ptr<NS::Bar> pi(new NS::Bar);
16   boost::intrusive_ptr<NS::Bar> pi2(pi);
17   assert(pi.get() == pi2.get());
18   std::cout << "pi: " << pi.get() << '\n'
19             << "pi2: " << pi2.get() << '\n';
20   
21   assert(pi->use_count() == pi2->use_count()
22          && pi2->use_count() == 2);
23   std::cout << "pi->use_count() : " << pi->use_count() << '\n'
24          << "pi2->use_count() : " << pi2->use_count() << '\n';
25 }

我们不再维护引用计数,并为intrusive_ptr_add_refintrusive_ptr_release提供命名空间级别的重载,而是直接从boost::intrusive_ref_counter<Bar>公开继承类Bar。这就是我们需要做的全部。这也使得可以轻松地在任何时候获取引用计数,使用从intrusive_ref_counter<>继承到Baruse_count()公共成员。请注意,use_count()不是intrusive_ptr本身的成员函数,因此我们必须使用解引用运算符(operator->)来调用它(第 21-24 行)。

前面示例中使用的引用计数器不是线程安全的。如果要确保引用计数的线程安全性,请编辑示例,使用boost::thread_safe_counter策略类作为boost::intrusive_ref_counter的第二个类型参数:

 7 class Bar : public boost::intrusive_ref_counter<Bar, 
 8                               boost::thread_safe_counter>

有趣的是,Bar继承自boost::intrusive_ref_counter模板的一个实例化,该模板将Bar本身作为模板参数。这再次展示了奇特的递归模板模式的工作原理。

shared_array

就像boost::scoped_ptr有一个专门用于管理动态分配数组的模板一样,有一个名为boost::shared_array的模板,可以用来包装动态分配的数组,并使用共享所有权语义来管理它们。与scoped_array一样,boost::shared_array有一个重载的下标运算符(operator[])。与boost::shared_ptr一样,它使用共享引用计数来管理封装数组的生命周期。与boost::shared_ptr不同的是,shared_array没有weak_array。这是一个方便的抽象,可以用作引用计数的向量。我留给你进一步探索。

使用智能指针管理非内存资源

到目前为止,我们所见过的所有智能指针类都假设它们的资源是使用 C++的new运算符动态分配的,并且需要使用delete运算符进行删除。scoped_arrayshared_array类以及unique_ptr的数组部分特化都假设它们的资源是动态分配的数组,并使用数组delete运算符(delete[])来释放它们。动态内存不是程序需要以异常安全方式管理的唯一资源,智能指针忽视了这种情况。

shared_ptrstd::unique_ptr模板可以使用替代的用户指定的删除策略。这使它们适用于管理不仅是动态内存,而且几乎任何具有显式创建和删除 API 的资源,例如使用mallocfree进行 C 风格堆内存分配和释放,打开文件流,Unix 打开文件描述符和套接字,特定于平台的同步原语,Win32 API 句柄到各种资源,甚至用户定义的抽象。以下是一个简短的例子来结束本章:

 1 #include <boost/shared_ptr.hpp>
 2 #include <stdio.h>
 3 #include <time.h>
 4 
 5 struct FILEDeleter
 6 {
 7   void operator () (FILE *fp) const {
 8     fprintf(stderr, "Deleter invoked\n");
 9     if (fp) {
10       ::fclose(fp);
11     }
12   }
13 };
14 
15 int main()
16 {
18   boost::shared_ptr<FILE> spfile(::fopen("tmp.txt", "a+"), 
19                                  FILEDeleter());
20   time_t t;
21   time(&t);
22 
23   if (spfile) {
24     fprintf(spfile.get(), "tstamp: %s\n", ctime(&t));
25   }
26 }

我们将fopen返回的FILE指针包装在shared_ptr<FILE>对象中(第 18 行)。但是,shared_ptr模板对FILE指针一无所知,因此我们还必须指定删除策略。为此,我们定义了一个名为FILEDeleter的函数对象(第 5 行),它的重载函数调用运算符(operator(),第 7 行)接受FILE类型的参数,并在其上调用fclose(如果不为空)(第 10 行)。临时的FILEDeleter实例作为第二个删除器参数(第 19 行)传递给shared_ptr<FILE>的构造函数。shared_ptr<FILE>的析构函数调用传递的删除器对象上的重载函数调用运算符,将存储的FILE指针作为参数传递。在这种情况下,重载的operator->几乎没有用处,因此通过使用get成员函数访问原始指针执行包装指针的所有操作(第 24 行)。我们还可以在FILEDeleter函数对象的位置使用 lambda 表达式。我们在第七章中介绍 Lambda 表达式,高阶和编译时编程

如果您可以访问 C++11,最好始终使用std::unique_ptr来实现这些目的。使用std::unique_ptr,您必须为删除器的类型指定第二个模板参数。前面的例子将使用一个std::unique_ptr,只需进行以下编辑:

 1 #include <memory>
...
18   std::unique_ptr<FILE, FILEDeleter> spfile(::fopen("tmp.txt", 
19                                             "a+"), FILEDeleter());

我们包括 C++标准头文件memory,而不是boost/shared_ptr.hpp(第 1 行),并将fopen调用返回的FILE指针包装在unique_ptr实例中(第 18 行),传递给它一个临时的FILEDeleter实例(第 19 行)。唯一的额外细节是unique_ptr模板的第二个类型参数,指定删除器的类型。我们还可以在 FILEDeleter 函数对象的位置使用 C++ 11 Lambda 表达式。在介绍 Lambda 表达式之后的章节中,我们将看到这种用法。

自测问题

对于多项选择题,选择所有适用的选项:

  1. 亚伯拉罕的异常安全性保证是什么?

a. 基本的,弱的,和强的

b. 基本的,强的,不抛出异常

c. 弱的,强的,不抛出异常

d. 无,基本的,和强的

  1. boost::scoped_ptrstd::unique_ptr之间的主要区别是什么?

a. boost::scoped_ptr不支持移动语义

b. std::scoped_ptr没有数组的部分特化

c. std::unique_ptr可以存储在 STL 容器中

d. std::unique_ptr支持自定义删除器

  1. 为什么boost::shared_ptr比其他智能指针更重?

a. 它使用共享引用计数

b. 它支持复制和移动语义

c. 它使用每个封装对象的两个分配

d. 它不比其他智能指针更重

  1. 使用boost::make_shared创建shared_ptr的缺点是什么?

a. 它比直接实例化boost::shared_ptr

b. 它不是线程安全的

c. 它直到所有weak_ptr引用过期才释放对象内存

d. 它在 C++11 标准中不可用

  1. boost::shared_ptrstd::unique_ptr之间的主要区别是什么?

a. std::unique_ptr不支持复制语义

b. std::unique_ptr不支持移动语义

c. boost::shared_ptr不支持自定义删除器

d. boost::shared_ptr不能用于数组

  1. 如果您想从类X的成员函数中返回包装this指针的shared_ptr<X>,以下哪个会起作用?

a. return boost::shared_ptr<X>(this)

b. boost::enable_shared_from_this

c. boost::make_shared

d. boost::enable_shared_from_raw

总结

本章明确了代码异常安全性的要求,然后定义了使用智能指针以异常安全的方式管理动态分配对象的各种方法。我们研究了来自 Boost 和新的 C++11 标准引入的智能指针模板,并理解了不同的所有权语义以及侵入式和非侵入式引用计数。我们还有机会看看如何调整一些智能指针模板来管理非内存资源。

希望您已经理解了各种所有权语义,并能够明智地将本章中的技术应用于这些场景。在智能指针库中有一些我们没有详细介绍的功能,比如boost::shared_arrayboost::enable_shared_from_raw。您应该自行进一步探索它们,重点关注它们的适用性和缺陷。在下一章中,我们将学习使用 Boost 的字符串算法处理文本数据的一些巧妙而有用的技术。

参考资料

第四章:处理字符串

文本数据是现代应用程序处理的最重要和普遍的数据形式。通过直观的抽象有效地处理文本数据的能力是处理文本数据有效性的关键标志。Boost 有许多专门用于有效文本处理的库,增强和扩展了 C++标准库提供的功能。

在本章中,我们将介绍三个用于处理文本数据的关键 Boost 库。我们将从 Boost String Algorithms 库开始,这是一个通用文本数据算法库,提供了大量易于使用的文本操作,通常在标准库中被忽略。然后我们将介绍 Boost Tokenizer 库,这是一个基于各种标准对字符串数据进行标记的可扩展框架。之后,我们将研究一个用于搜索和解析字符串的正则表达式库 Boost.Regex,它也已经包含在 C++11 标准中。以下主题将出现在以下各节中:

  • 使用 Boost String Algorithms 库进行文本处理

  • 使用 Boost Tokenizer 库拆分文本

  • 使用 Boost.Regex 进行正则表达式

本章应该帮助您充分掌握 Boost 库中可用的文本处理技术。本书不涉及国际化问题,但本章讨论的大部分概念将适用于基于非拉丁字符集的书写系统的语言中的文本。

使用 Boost String Algorithms 库进行文本处理

文本数据通常表示为内存中连续布置的字符序列或字符串,并以特殊标记(空终止符)终止。虽然用于表示字符的实际数据类型可能因情况而异,但 C++标准库在类模板std::basic_string中抽象了字符串概念,该模板将字符数据类型作为参数。std::basic_string模板有三个类型参数:

  • 字符类型

  • 封装在特征类中的字符类型的一些固有属性和行为

  • 用于为std::basic_string分配内部数据结构的分配器类型

特征和分配器参数被默认设置,如下面的片段所示:

template <typename charT,
          typename Traits = std::char_traits<chart>,
          typename Allocator = std::allocator<chart>>
std::basic_string;

C++03 标准库还提供了std::basic_string的两个特化:

  • std::string 用于窄字符(8 位 char

  • std::wstring 用于宽字符(16 位或 32 位 wchar_t

在 C++11 中,我们还有两个:

  • std::u16string(用于u16char_t

  • std::u32string(用于u32char_t

除了这些类,纯旧的 C 风格字符串,即由空字符终止的charwchar_t数组,也是相当常用的,特别是在传统的 C++代码中。

标准库中存在两个主要缺陷,使得处理文本数据类型有时过于繁琐。首先,只有一组有限的可用算法可以应用于stringwstring。此外,大多数这些算法都是std::basic_string的成员函数,不适用于其他字符串表示形式,如字符数组。即使作为非成员函数模板可用的算法也处理迭代器而不是容器,使得代码繁琐且不够灵活。

考虑一下如何使用 C++标准库将字符串转换为大写:

清单 4.1:使用 std::transform 将字符串更改为大写

 1 #include <string>
 2 #include <algorithm>
 3 #include <cassert>
 4 #include <cctype>
 5 
 6 int main() {
 7   std::string song = "Green-tinted sixties mind";
 8   std::transform(song.begin(), song.end(), song.begin(),
 9                  ::toupper);
10 
11   assert(song == "GREEN-TINTED SIXTIES MIND");
12 }

我们使用std::transform算法将一系列字符转换为它们的大写形式,使用标准库中的toupper函数应用于每个字符(第 8-9 行)。要转换的字符序列由一对迭代器指定,指向字符串song的第一个字符(song.begin())和最后一个字符的下一个位置(song.end())——作为std::transform的前两个参数传递。转换后的序列被就地写回,从song.begin()开始,这是std::transform的第三个参数。如果您已经在 C++中编程了一段时间,可能不会看到太多问题,但是transform函数的普遍性有些掩盖了意图的表达。这就是 Boost String Algorithms 库的作用,它通过提供一系列有用的字符串算法函数模板来帮助,这些函数模板具有直观的命名并且有效地工作,有时甚至可以在不同的字符串抽象上使用。考虑以下替代前面代码的方法:

清单 4.2:使用 boost::to_upper 将字符串转换为大写

 1 #include <string>
 2 #include <boost/algorithm/string.hpp>
 3 #include <cassert>
 4
 5 int main()
 6 {
 7   std::string song = "Green-tinted sixties mind";
 8   boost::to_upper(song);
 9   assert(song == "GREEN-TINTED SIXTIES MIND");
10 }

要将字符串song转换为大写,可以调用boost::to_upper(song)(第 8 行)。我们包含头文件boost/algorithm/string.hpp(第 2 行)来访问boost::to_upper,它是来自 Boost String Algorithms 库的算法函数模板。它被命名为to_upper,而不是transform,只需要一个参数而不是四个,也没有迭代器——有什么不喜欢的呢?此外,您可以在裸数组上运行相同的代码:

清单 4.3:使用 boost::to_upper 将字符数组转换为大写

 1 #include <string>
 2 #include <boost/algorithm/string.hpp>
 3 #include <cassert>
 4
 5 int main()
 6 {
 7   char song[17] = "Book of Taliesyn";
 8   boost::to_upper(song);
 9   assert(std::string(song) == "BOOK OF TALIESYN");
10 }

但是迭代器让您选择要转换为大写的范围,而在这里,我们似乎只能将任何东西应用于整个字符串。实际上,这也不是问题,我们将会看到。

注意

Boost.Range

Boost String Algorithms 库中的算法实际上是在称为范围的抽象上工作,而不是在容器或迭代器上工作。一个范围只是一系列元素,可以以某种顺序完全遍历。粗略地说,像std::string这样的容器是一系列连续的单字节字符,而像std::list<Foo>这样的容器是类型为Foo的元素序列。因此,它们都符合有效的范围。

一个简单的范围可以由一对迭代器表示——一个指向范围中的第一个元素,另一个指向范围中最后一个元素的下一个元素。一个范围可以表示容器中所有元素的序列。进一步概括,范围可以被描述为容器的子序列,即容器中元素的子集,它们的相对顺序被保留。例如,容器中奇数索引的元素子序列是一个有效的范围。单个迭代器对可能不足以表示这样的范围;我们需要更多的构造来表示它们。

Boost.Range 库提供了生成和处理各种范围所需的必要抽象和函数。类模板boost::iterator_range用于使用一对迭代器表示不同类型的范围。Boost String Algorithms 中的算法接受范围作为参数,并返回范围,从而实现调用的链接,这是大多数 STL 算法无法实现的。在本章中,我们不会深入讨论 Boost.Range 的细节,但会发展对使用 String Algorithms 库的范围所需的直观理解。

如果我们只想转换字符串的一部分大小写,我们需要构造表示该部分的范围。我们可以使用boost::iterator_range类模板生成任意范围。下面是我们如何做到的:

清单 4.4:使用 to_upper 将字符串的一部分转换为大写

 1 #include <string>
 2 #include <boost/algorithm/string.hpp>
 3 #include <cassert>
 4
 5 int main()
 6 {
 7   std::string song = "Green-tinted sixties mind";
 8   typedef boost::iterator_range<std::string::iterator>
 9                                                RangeType; 
10   RangeType range = boost::make_iterator_range(
11                        song.begin() + 13, song.begin() + 20);
12   boost::to_upper(range);
13   assert(song == "Green-tinted SIXTIES mind");
14 }

具体来说,我们希望使用两个迭代器来构造字符串的范围。因此,范围的类型将是boost::iterator_range<std::string::iterator>。我们为这个相当长的类型名称创建了一个 typedef(第 8-9 行)。我们希望将字符串"Green-tinted sixties mind"中的单词"sixties"更改为大写。这个单词从字符串song的索引 13 开始,长度为 7 个字符。因此,定义包含"sixties"的范围的迭代器是song.begin() + 13song.begin() + 13 + 7,即song.begin() + 20。通过将这两个迭代器传递给函数模板boost::make_iterator_range(第 10-11 行)来构造实际范围(range)。我们将这个范围传递给boost::to_upper算法,它更改了子字符串"sixties"的大小写(第 12 行),并且我们断言预期的更改(第 13 行)。

这可能看起来是很多代码,但请记住,当您将算法应用于整个字符串或容器时,您不必构造显式范围。此外,如果您使用 C++11,auto关键字可以帮助减少冗长;因此,您可以像这样替换突出显示的行(8-11 行):

 8 auto range = boost::make_iterator_range(song.begin() + 13,
 9                                       song.begin() + 20);

您可以在附录中了解有关auto关键字的更多信息,C++11 语言特性模拟

从数组构造迭代器范围也并不完全不同:

清单 4.5:使用 to_upper 将 char 数组的一部分更改为大写

 1 #include <string>
 2 #include <boost/algorithm/string.hpp>
 3 #include <cassert>
 4
 5 int main()
 6 {
 7   char song[17] = "Book of Taliesyn";
 8 
 9   typedef boost::iterator_range<char*> RangeType; 
10   RangeType rng = boost::make_iterator_range(song + 8,
11                                              song + 16);
12   boost::to_upper(rng);
13   assert(std::string(song) == "Book of TALIESYN");
14 }

范围被定义为boost::iterator_range<char*>类型,数组的迭代器类型为char*(第 9 行)。再次,如果我们使用 C++11,我们可以使用auto来消除所有的语法痛苦。我们使用适当的偏移量(8 和 16)创建迭代器范围,限定单词"Taliesyn"(第 10-11 行),并使用boost::to_upper转换范围(第 12 行)。

使用 Boost 字符串算法

在本节中,我们将探讨可用的各种字符串算法,并了解它们可以应用的条件。不过,在我们查看具体算法之前,我们将首先尝试了解事情的一般方案。

考虑算法boost::contains。它检查作为第二个参数传递的字符串是否是作为第一个参数传递的字符串的子字符串:

清单 4.6:使用 boost::contains

 1 #include <boost/algorithm/string.hpp>
 2 #include <string>
 3 #include <cassert>
 4 
 5 int main() {
 6   std::string input = "linearize";
 7   std::string test = "near";
 8   assert(boost::contains(input, test));
 9 }

算法boost::contains应该返回 true,因为"linearize"包含子字符串"near"(第 8 行)。虽然调用boost::contains返回 true,但如果我们将test设置为"Near"而不是"near",它将返回 false。如果我们想要检查子字符串而不关心大小写,我们必须使用boost::icontains作为boost::contains的替代品。与boost::contains一样,来自 Boost 字符串算法的大多数算法都有一个不区分大小写的版本,带有i-前缀。

boost::contains不同,一些字符串算法根据传递给它的字符串生成修改后的字符串内容。例如,boost::to_lower将传递给它的字符串内容转换为小写。它通过就地更改字符串来实现这一点,从而修改其参数。算法的非变异版本称为boost::to_lower_copy,它复制传递的字符串,转换复制的字符串的大小写,并返回它,而不修改原始字符串。这样的非变异变体在其名称中具有_copy后缀。这里是一个简短的例子:

清单 4.7:使用 _boost 字符串算法的 _copy 版本

 1 #include <boost/algorithm/string.hpp>
 2 #include <string>
 3 #include <cassert>
 4 
 5 int main() {
 6   std::string str1 = "Find the Cost of Freedom";
 7   std::string str2 = boost::to_lower_copy(str1);
 8   assert(str1 != str2);
 9   boost::to_lower(str1);
10   assert(str1 == str2);
11   assert(str1 == "find the cost of freedom");
12 }

字符串str1首先被复制并转换为小写,使用非变异变体boost::to_lower_copy,结果被赋给str2(第 7 行)。此时,str1保持不变。接下来,str1被就地转换为小写,使用boost::to_lower(第 9 行)。此时,str1str2都具有相同的内容(第 10 行)。在接下来的大部分内容中,我们将使用区分大小写的变体和适用的变异变体,理解到算法的不区分大小写和非变异(复制)版本也存在。我们现在开始查看特定的算法。

查找算法

从 Boost String Algorithms 库中有几种find 算法的变体可用,所有这些算法都在另一个输入字符串中搜索字符串或模式。每个算法都将输入字符串和搜索字符串作为参数,将它们转换为范围,然后执行搜索。每个 find 变体都返回与搜索字符串或模式匹配的输入中的连续子序列作为范围。如果没有找到匹配项,则返回一个空范围。

find_first

我们首先看一下boost::find_first,它在另一个字符串中查找一个字符串:

清单 4.8:使用 boost::find_first

 1 #include <boost/algorithm/string.hpp>
 2 #include <string>
 3 #include <iostream>
 4 
 5 int main()
 6 {
 7   const char *haystack = "Mary had a little lamb";
 8   const char *needles[] = {"little", "Little", 0};
 9 
10   for (int i = 0; needles[i] != 0; ++i) {
11     auto ret = boost::find_first(haystack, needles[i]);
12   
13     if (ret.begin() == ret.end()) {
14       std::cout << "String [" << needles[i] << "] not found in"
15                 << " string [" << haystack << "\n";
16     } else {
17       std::cout << "String [" << needles[i] << "] found at " 
18                 << "offset " << ret.begin() - haystack
19                 << " in string [" << haystack << "\n";
20     }
21 
22     std::cout << "'" << ret << "'" << '\n';
23   }
24 }

我们有一个我们想要搜索的字符串数组,称为needles(第 8 行)。我们还有一个名为haystack的 C 风格字符串,在其中我们想要查找包含我们想要搜索的文本的搜索字符串(第 7 行)。我们循环遍历needles中的每个字符串,并调用boost::find_first算法在haystack中查找它(第 11 行)。我们检查搜索是否未能找到匹配项(第 13 行)。如果找到了匹配项,那么我们计算在haystack中找到匹配项的偏移量(第 18 行)。范围ret定义了输入字符串haystack的范围;因此,我们总是可以执行偏移计算,比如ret.begin() - haystack

第一次迭代将能够找到"little",而第二次迭代将无法找到"Little",因为boost::find_first是区分大小写的。如果我们使用boost::ifind_first执行不区分大小写的搜索,那么两者都会匹配。

我们使用 C++11 的auto关键字来避免编写一个笨拙的ret类型(第 11 行),但如果我们不得不写,它将是boost::iterator_range<char*>。请注意,我们实际上可以将从算法返回的范围ret流式传输到输出流(第 22 行)。

这个例子说明了在 C 风格字符数组上的技术,但将其应用到std::string将需要惊人地少的更改。如果haystack是一个std::string实例,那么唯一的变化将在我们计算偏移量的方式上(第 18 行):

               << "offset " << ret.begin() – haystack.begin()

由于haystack不是字符数组而是一个std::string,所以通过调用其begin()成员函数来获得其开始的迭代器。

如果我们想要找到haystack中搜索字符串的最后一个实例,而不是第一个实例,我们可以用boost::find_last替换boost::find_first。如果可能有多个匹配的标记,我们可以通过索引要求特定的匹配。为此,我们需要调用boost::find_nth,传递第三个参数,这将是匹配的基于零的索引。我们可以传递负索引来要求从末尾匹配。因此,传递-1会给我们最后一个匹配,-2会给我们倒数第二个匹配,依此类推。

find_all

要在输入字符串中找到所有匹配的子字符串,我们必须使用boost::find_all并将其传递给一个序列容器,以便将所有匹配的子字符串放入其中。以下是如何做的一个简短示例:

清单 4.9:使用 boost::find_all 查找所有匹配的子字符串

 1 #include <boost/algorithm/string.hpp>
 2 #include <string>
 3 #include <iostream>
 4 #include <vector>
 5
 6 int main()
 7 {
 8   typedef boost::iterator_range<std::string::const_iterator>
 9                                                 string_range;
10   std::vector<string_range> matches;
11   std::string str = "He deserted the unit while they trudged "
12                     "through the desert one night.";
13 
14   boost::find_all(matches, str, "desert");
15   for (auto match : matches) {
16     std::cout << "Found [" << "desert" << "] at offset "
17           << match.begin() - str.begin() << ".\n";
18   }
19 }

首先我们为适当的范围类型创建一个 typedef string_range(第 8-9 行)。boost::find_all算法将所有匹配的范围复制到范围的向量matches中(第 14 行)。我们使用 C++11 的新基于范围的 for 循环语法(第 15 行)遍历向量matches,并打印每个匹配被找到的偏移量(第 17 行)。巧妙的基于范围的 for 循环声明了一个循环变量match,用于迭代容器matches中的连续元素。使用auto关键字,match的类型会根据matches中包含的值的类型自动推断。使用范围的向量而不是字符串的向量,我们能够计算出匹配发生在str中的确切偏移量。

find_token

另一个有趣的查找算法是boost::find_token算法。使用这个算法,我们可以找到满足我们指定的某些谓词的字符的子字符串。我们可以使用一组预定义的谓词或定义自己的谓词,尽管后一种方法需要相当多的工作,我们在本书中不会尝试这种方法。在下一个示例中,我们在字符串中搜索具有四个或更多位数的十六进制数字。这也将说明如何使用函数执行重复搜索。

为此,我们使用boost::is_xdigit谓词,如果传递给它的特定字符是有效的十六进制字符,则返回 true。以下是示例代码:

清单 4.10:使用 boost::find_token 和谓词查找子字符串

 1 #include <boost/algorithm/string.hpp>
 2 #include <string>
 3 #include <iostream>
 4 
 5 int main()
 6 {
 7   std::string str = "The application tried to read from an "
 8                     "invalid address at 0xbeeffed";
 9 
10   auto token = boost::find_token(str, boost::is_xdigit(), 
11                                boost::token_compress_on);
12   while (token.begin() != token.end()) {
13     if (boost::size(token) > 3) {
14       std::cout << token << '\n';
15     }
16 
17     auto remnant = boost::make_iterator_range(token.end(), 
18                                             str.end());
19     token = boost::find_token(remnant, boost::is_xdigit(),
20                             boost::token_compress_on);
21   }
22 }

字符串str包含一个有趣的十六进制标记(0xbeeffed)。我们将str与谓词boost::is_xdigit的实例一起传递给boost::find_token,该谓词标识有效的十六进制数字(第 10 行)。我们使用boost::token_compress_on指示应该连接连续匹配的字符(第 11 行);默认情况下,此选项是关闭的。返回的范围token表示当前匹配的子字符串。只要返回的范围token不为空,即token.begin() != token.end()(第 12 行),我们就循环并在其长度大于 3 时打印其内容(第 13 行)。请注意在token上使用boost::size函数。这是可以用于计算范围属性的几个函数之一,比如它的开始和结束迭代器、大小等等。另外,请注意我们可以直接将像标记这样的范围对象流式传输到ostream对象,比如std::cout,以打印范围中的所有字符(第 14 行)。

在每次迭代中,我们使用find_token搜索匹配后的剩余字符串。剩余字符串被构造为一个名为remnant的范围(第 17-18 行)。remnant的开始是token.end(),即最后一个匹配标记之后的第一个位置。剩余部分的结束只是字符串str.end()的结束。

iter_find

遍历字符串并找到所有满足某些条件的子字符串是一个常见的用例,Boost 提供了一个更简单的方法来实现这一点。通过使用boost::iter_find算法,将输入字符串、查找器函数对象和一个序列容器传递给它以保存匹配的范围,我们可以在传递的容器中获取匹配的子字符串。以下是使用boost::iter_find重写的上面的示例:

清单 4.11:使用 boost::iter_find 和 boost::token_finder

 1 #include <boost/algorithm/string.hpp>
 2 #include <string>
 3 #include <iostream>
 4 #include <vector>
 5 #include <iterator>
 6 #include <algorithm>
 7
 8 struct MinLen
 9 {
10   bool operator()(const std::string& s) const 
11   { return s.size() > 3; }
12 };
13 
14 int main() {
15   std::string str = "The application tried to read from an "
16                     "invalid address at 0xbeeffed";
17 
18   std::vector<std::string> v;
19   auto ret = boost::iter_find(v, str, 
20                      boost::token_finder(boost::is_xdigit(), 
21                                   boost::token_compress_on));
22 
23   std::ostream_iterator<std::string> osit(std::cout, ", ");
24   std::copy_if(v.begin(), v.end(), osit, MinLen());
25 }

boost::find_regex算法可以搜索字符串中与正则表达式模式匹配的子字符串。我们将在本章后面处理使用 Boost.Regex 处理正则表达式时涵盖这个算法。

find

有一个通用的boost::find算法,大多数其他查找算法都是基于它实现的。使用可用的查找器-函数对象模板,作为字符串算法库的一部分,或编写我们自己的模板,我们可以让通用的boost::find字符串算法为我们执行各种搜索任务。以下是使用boost::last_finder函数对象与boost::find算法来查找最后一个匹配子字符串的示例——这正是boost::ifind_last所做的。boost::last_finder函数对象和类似它的其他函数对象接受一个可选的谓词,并且可以用于影响字符比较的方式。为了模拟ifind_last所做的不区分大小写的比较,我们需要传递一个以不区分大小写方式比较两个字符的谓词。为此,我们使用boost::is_iequal谓词:

  1 std::string haystack = "How little is too little";
  2 std::string needle = "Little";
  3 
 4 auto ret = boost::find(haystack,
 5                       boost::last_finder(needle,
 6                                   boost::is_iequal()));

我们在haystack上调用boost::find,传递boost::last_finder函数对象。由于我们希望last_finder执行不区分大小写的比较,因此我们传递了boost::is_iequal谓词的实例。这类似于boost::ifind_last,实际上就是它的实现方式。您甚至可以传递自己的字符比较谓词。假设您收到了一个编码消息,其中每个字符都向后移动了 4 个位置,并且环绕,因此aezd。您可以使用以下代码中的equalsShift函数对象来检查编码文本中是否存在特定的真实单词:

清单 4.12:使用 Boost 子字符串查找器的自定义谓词

 1 struct EqualsShift {
 2   EqualsShift(unsigned int n) : shift(n) {}
 3 
 4   bool operator()(char input, char search) const
 5   {
 6     int disp = tolower(input) - 'a' - shift;
 7     return tolower(search) == (disp >= 0)?'a':'z' + disp;
 8   }
 9 
10 private:
11   unsigned long shift;
12 };
13
14 // encoded ... How little is too little
15 std::string encoded = "Lsa pmxxpi mw xss pmxxpi";
16 std::string realWord = "little";
17 auto ret = boost::find(encoded,
18                        boost::first_finder(realWord,
19                                           EqualsShift(4)));

在不解码变量encoded中包含的整个字符串的情况下,我们希望找到一个encoded的子字符串,解码后与变量realWord中包含的字符串匹配。为了做到这一点,我们调用boost::find,传递两个参数,编码输入字符串称为encoded,以及一个谓词,只有在找到匹配的子字符串时才返回true(第 17-19 行)。

对于谓词,我们构造了一个临时类,类型为boost::first_finder,将两个参数传递给它的构造函数:要查找的单词是realWord,二进制谓词EqualShift(4)EqualsShift函数对象执行两个字符的不区分大小写比较:一个来自编码输入,一个来自要查找的单词。如果第一个字符是根据固定整数 N 进行的编码的第二个字符,则返回 true,如前面描述的(在我们的例子中 N=4)。

find_head 和 find_tail

还有一些find算法,比如boost::find_headboost::find_tail,它们本来可以被命名为prefixsuffix,因为它们确实是这样做的——从字符串中切出指定长度的前缀或后缀:

1 std::string run = "Run Forrest run";
2 assert( boost::find_head(run, 3) == "Run");
3 assert( boost::find_head(run, -3) == "Run Forrest ");
4 assert( boost::find_tail(run, 3) == "run");
5 assert( boost::find_ tail(run, -3) == " Forrest run");

您使用输入字符串和偏移量调用find_head。如果偏移量是正数Nfind_head返回输入字符串的前N个字符,如果N大于字符串的大小,则返回整个字符串。如果偏移量是负数-Nfind_head返回前size - N个字符,其中size表示字符串run中的字符总数。

您使用字符串和整数调用find_tail。当传递正整数N时,find_tail返回输入字符串的最后N个字符,如果N大于字符串的大小,则返回整个字符串。当传递负整数-N时,find_tail返回字符串中的最后size - N个字符,其中size表示字符串中的字符总数,如果N > size,则返回空字符串。

用于测试字符串属性的其他算法

存在一些方便的函数,使得某些常见操作非常容易编码。像boost::starts_withboost::ends_with(以及它们的不区分大小写的变体)这样的算法,测试特定字符串是否是另一个字符串的前缀或后缀。要确定两个字符串的字典顺序,可以使用boost::lexicographical_compare。您可以使用boost::equals检查相等性,并使用boost::contains检查一个字符串是否是另一个字符串的子字符串。每个函数都有相应的不区分大小写的变体,而区分大小写的变体则采用一个可选的谓词来比较字符。Boost 在线文档提供了这些函数及其行为的充分详细的列表。

大小写转换和修剪算法

更改字符串或其部分的大小写,并修剪前导或尾随的额外空格是非常常见的任务,但仅使用标准库需要一些努力。我们已经看到了boost::to_upperboost::to_lower以及它们的复制版本来执行大小写更改的操作。在本节中,我们将把这些算法应用于更有趣的范围,并且还将看看修剪算法。

大小写转换算法

如何将字符串中的交替字符转换为大写,而其余部分保持不变?由于boost::to_upper函数接受一个范围,我们需要以某种方式生成包含字符串中交替元素的范围。这样做的方法是使用范围适配器。Boost Range 库提供了许多适配器,允许从现有范围生成新的范围模式。我们正在寻找的适配器是strided适配器,它允许通过在每一步跳过固定数量的元素来遍历范围。我们只需要每步跳过一个元素:

清单 4.13:使用 Boost.Range 适配器生成非连续范围

 1 #include <boost/range.hpp>
 2 #include <boost/range/adaptors.hpp>
 3 #include <string>
 4 #include <iostream>
 5 #include <boost/algorithm/string.hpp>
 6 #include <cassert>
 7
 8 int main()
 9 {
10   std::string str = "funny text";
11   auto range = str | boost::adaptors::strided(2);
12   boost::to_upper(range);
13   assert(str == "FuNnY TeXt");
14 }

为了将boost::to_upper算法应用于偶数索引的字符,我们首先生成正确的范围。管道运算符(operator |)被重载以创建一个直观的链接语法,用于适配器,比如strided。使用表达式str | strided(2),我们实质上是将strided适配器应用于字符串str,并使用参数2来获得包含str的偶数索引元素的范围(第 11 行)。注意,strided适配器总是从输入的第一个字符开始。

可以通过编写以下内容来实现相同的效果:

auto range = boost::adaptors::stride(str, 2);

我更喜欢使用管道符号,因为它似乎更具表现力,特别是当需要链接更多的适配器时。在生成这个range之后,我们将to_upper应用于它(第 12 行),预期地,str的偶数索引字符被转换为大写(第 13 行)。

如果我们想对所有奇数索引执行相同的操作,那么我们需要解决一个问题。strided适配器以跳过两个元素之间的数字作为参数,但总是从输入的第一个字符开始。为了从索引 1 处开始而不是从 0 开始,我们必须从容器的元素(在这种情况下是索引 1)开始取一个片段,然后应用参数为2strided

首先取片段,我们使用另一个适配器,称为boost::adaptors::sliced。它以起始位置和结束位置的索引作为参数。在这种情况下,我们想从索引 1 开始并切片容器的其余部分。因此,我们可以像这样写整个表达式:

auto range = str | boost::adaptors::sliced(1, str.size() – 1)| boost::adaptors::strided(2);

以这种方式链接适配器是一种强大的方式,可以使用非常可读的语法即时生成范围。相同的技术也适用于 C 风格的字符数组。

修剪算法

对于修剪字符串,有三种主要的算法:boost::trim_left用于修剪字符串中的前导空白,boost::trim_right用于修剪字符串中的尾随空白,boost::trim用于修剪两者。修剪算法可能会改变输出的长度。每个算法都有一个带有谓词的_if变体,该谓词用于识别要修剪的字符。例如,如果您只想从从控制台读取的字符串中删除尾随换行符(经常需要这样做),您可以编写一个适当的谓词来仅识别换行符。最后,所有这些算法都有复制变体。如果我们列出可用算法的扩展列表,将会有十二种算法;trim_left有四种:trim_lefttrim_left_copytrim_left_iftrim_left_if_copytrim_righttrim各有四种。以下是在字符串上执行修剪的示例:

清单 4.14:使用 boost::trim 及其变体

 1 #include <boost/algorithm/string.hpp>
 2 #include <string>
 3 #include <iostream>
 4 #include <cassert>
 5 
 6 bool isNewline(char c) {
 7   return c == '\n';
 8 }
 9 
10 int main()
11 {
12   std::string input = "  Hello  ";
13   std::string input2 = "Hello   \n";
14   
15   boost::trim(input);
16   boost::trim_right_if(input2, isNewline);
17 
18   assert(*(input.end() - 1) != ' ');
19   assert(*(input2.end() - 1) != '\n' && 
20          *(input2.end() - 1) == ' ');
21 }

在清单 4.14 中,我们有两个字符串:input具有前导和尾随空格(第 12 行),input2具有尾随空格和末尾的换行符(第 13 行)。通过在input上应用boost::trim,前导和尾随空格被修剪(第 15 行)。如果我们在input2上应用boost::trim_right,它将删除所有尾随空格,包括空格和换行符。我们只想删除换行符,而不是空格;因此,我们编写了一个谓词isNewline来帮助选择需要修剪的内容。这种技术也可以用于非空白字符。

这些函数不适用于 C 风格数组,非复制版本期望一个名为erase的成员函数。它们适用于标准库中的basic_string特化,以及提供具有类似接口和语义的erase成员函数的其他类。

替换和删除算法

替换和删除算法是在字符串上执行搜索和替换操作的便捷函数。基本思想是查找一个或多个与搜索字符串匹配的内容,并用不同的字符串替换匹配项。擦除是替换的一种特殊情况,当我们用空字符串替换匹配项时。

这些操作可能会在原地执行时改变输入的长度,因为匹配的内容及其替换可能具有不同的长度。库中的核心算法是boost::find_format,所有其他算法都是基于它实现的。算法boost::replace_firstboost::replace_lastboost::replace_nthboost::replace_all分别用替换字符串替换输入中搜索字符串的第一个、最后一个、第 n 个或所有匹配的出现。相应的擦除算法简单地擦除匹配的部分。这些算法不适用于 C 风格数组:

清单 4.15:使用 boost::replace 和 boost::erase 变体

 1 #include <boost/algorithm/string.hpp>
 2 #include <string>
 3 #include <iostream>
 4 #include <cassert>
 5 
 6 int main()
 7 {
 8   std::string input = "Hello, World! Hello folks!";
 9   boost::replace_first(input, "Hello", "Hola");
10   assert(input == "Hola, World! Hello folks!");
11   boost::erase_first(input, "Hello");
12   assert(input == "Hola, World!  folks!");
13 }

在清单 4.15 中,我们首先使用boost::replace_first算法来将字符串"Hello"的第一个实例替换为"Hola"(第 9 行)。如果我们使用boost::replace_all,则会替换两个实例的"Hello",并且我们将得到"Hola, World! Hola folks!"。然后我们调用boost::erase_first来删除字符串中剩余的"Hello"(第 11 行)。这些算法中的每一个都有一个不区分大小写的变体,以不区分大小写的方式进行匹配。可以预见地,它们以i-前缀命名:ireplace_firstierase_first等等。

每个算法都有一个返回新字符串的_copy变体,而不是原地更改。以下是一个简短的示例:

std::string input = "Hello, World! Hello folks!";
auto output = boost::ireplace_last_copy(input, "hello", "Hola");
assert(input == "Hello, World! Hello folks!"); // input unchanged
assert(output == "Hello, World! Hola folks!"); // copy changed

请注意boost::ireplace_last_copy变体是如何工作的,以不区分大小写的方式匹配"hello",并在输入的副本中执行替换。

您可以使用boost::replace_headboost::replace_tail(以及它们的擦除变体)来替换或擦除字符串的前缀或后缀。boost::replace_regexboost::replace_regex_all算法使用正则表达式来查找匹配项,并用替换字符串替换它们。替换字符串可能包含特殊语法来引用匹配字符串的部分,有关详细信息,我们将在本章后面的 Boost.Regex 部分中详细介绍。

拆分和连接算法

Boost 提供了一个名为boost::split的算法,它基本上用于根据一些分隔符将输入字符串分割成标记。该算法接受输入字符串、用于识别分隔符的谓词和用于存储解析标记的序列容器。以下是一个示例:

清单 4.16:使用 boost::split 在简单标记上拆分字符串

 1 #include <boost/algorithm/string.hpp>
 2 #include <string>
 3 #include <iostream>
 4 #include <vector>
 5 #include <cassert>
 6
 7 int main()
 8 {
 9   std::string dogtypes = "mongrel, puppy, whelp, hound";
10   std::vector<std::string> dogs;
11   boost::split(dogs, dogtypes, boost::is_any_of(" ,"),
12                boost::token_compress_on);
13   
14   assert(dogs.size() == 4);
15   assert(dogs[0] == "mongrel" && dogs[1] == "puppy" &&
16          dogs[2] == "whelp" && dogs[3] == "hound");
17 }

清单 4.16 将列出出现在字符串dogtypes中的四种狗的类型,用逗号和空格分隔(第 9 行)。它使用boost::split算法来实现。dogtypes字符串使用谓词boost::is_any_of(" ,")进行标记化,该谓词将任何空格或逗号识别为分隔符(第 11 行)。boost::token_compress_on选项确保boost::split算法不会对每个相邻的分隔符字符返回空字符串,而是将它们组合在一起,将其视为单个分隔符(第 12 行)。如果我们想要在任何标点符号处拆分字符串,我们将使用boost::is_punct()而不是boost::is_any_of(…)。但是,这是一种相对不太灵活的标记化方案,只能使用有限的谓词集。

如果您只想使用另一个字符串作为分隔符拆分字符串,可以使用boost::iter_split

清单 4.17:使用 boost::iter_split 标记化字符串

 1 #include <boost/algorithm/string.hpp>
 2 #include <string>
 3 #include <iostream>
 4 #include <vector>
 5
 6 int main()
 7 {
 8   std::string dogtypes = 
 9                "mongrel and puppy and whelp and hound";
10   std::vector<std::string> dogs;
11   boost::iter_split(dogs, dogtypes, 
12                     boost::first_finder(" and "));
13   assert(dogs.size() == 4);
14   assert(dogs[0] == "mongrel" && dogs[1] == "puppy" &&
15          dogs[2] == "whelp" && dogs[3] == "hound");
16 }

boost::splitboost::iter_split之间的主要区别在于,在后者中,您使用查找器来识别分隔符,因此可以是特定的字符串。boost::iter_splitboost::iter_find都使用相同类型的参数,并使用查找器来搜索匹配的子字符串,但boost::iter_split返回位于两个匹配子字符串之间的标记,而它的补充boost::iter_find返回匹配的子字符串。

最后,当您尝试使用一些分隔符将一系列值串在一起时,boost::joinboost::join_if算法非常有用。boost::join连接序列中的所有值,而boost::join_if只连接满足传递的谓词的序列中的值。以下是boost::join的示例,它接受一个字符串向量和一个分隔符,并返回连接的字符串:

std::vector<std::string> vec{"mongrel", "puppy", "whelp", "hound"};
std::string joined = boost::join(vec, ", ");
assert(joined == "mongrel, puppy, whelp, hound");

在前面的示例中,我们看到另一个有用的 C++11 特性:统一初始化。我们使用大括号括起来并用逗号分隔的四个字符串序列来初始化向量vec。这种初始化语法适用于所有 STL 容器,并且可以用于具有特定类型构造函数的常规类。现在,如果我们想要挑选哪些字符串被连接,哪些不被连接,我们将使用boost::join_if,如下所示:

bool fiveOrLessChars(const std::string& s) { return s.size() <= 5; }

std::vector<std::string> vec{"mongrel", "puppy", "whelp", "hound"};
std::string joined = boost::join_if(vec, ", ", fiveOrLessChars);
assert(joined == "puppy, whelp, hound");

fiveOrLessChars谓词检查传递给它的字符串是否长度为五或更少。因此,字符串"mongrel"由于长度超过五而不出现在连接的字符串中。

使用 Boost Tokenizer 库拆分文本

我们在上一节中看到的boost::split算法使用谓词拆分字符串,并将标记放入序列容器中。它需要额外的存储空间来存储所有标记,并且用户对使用的标记化标准选择有限。根据各种标准将字符串拆分为一系列标记是一个常见的编程需求,Boost.Tokenizer 库提供了一个可扩展的框架来实现这一点。此外,这不需要额外的存储空间来存储标记。它提供了一个通用接口来从字符串中检索连续的标记。将字符串拆分为连续标记的标准作为参数传递。Tokenizer 库本身提供了一些可重用的常用标记策略进行拆分,但更重要的是,它定义了一个接口,使用该接口可以编写我们自己的拆分策略。它将输入字符串视为一系列标记的容器,可以从中解析出连续的标记。

基于分隔符的标记

首先,让我们看看如何将字符串拆分为其组成单词:

清单 4.19:使用 Boost Tokenizer 将字符串标记为单词

 1 #include <iostream>
 2 #include <boost/tokenizer.hpp>
 3 #include <string>
 4 
 5 int main()
 6 {
 7   std::string input = 
 8         "God knows, I've never been a spiritual man!";
 9 
10   boost::tokenizer<> tokenizer(input);
11
12   for (boost::tokenizer<>::iterator token = tokenizer.begin();
13         token != tokenizer.end(); ++token) {
14     std::cout << *token << '\n';
15   }
16 }

boost::tokenizer类模板抽象了标记化过程。我们创建boost::tokenizer的默认特化的实例,将输入字符串input传递给它(第 10 行)。接下来,使用boost::tokenizer的迭代器接口,我们将input拆分为连续的标记(第 12-14 行)。通常,您可以通过传递适当的标记策略来自定义字符串的拆分方式。由于我们没有显式地将其传递给boost::tokenizer模板,因此默认的标记策略将使用空格和标点符号作为标记的分隔符。上述代码将将以下输出打印到标准输出:

God
knows
I
ve
never
been
a
spiritual
man

因此,它不仅在空格上分割,还在逗号和撇号上分割;由于撇号,"I've"被分割成"I""ve"

如果我们想要根据空格和标点符号拆分输入,但不要在撇号上拆分,我们需要做更多工作。Boost 提供了一些可重用的模板,用于常用的拆分策略。boost::char_delimiter模板使用指定的字符作为分隔符拆分字符串。以下是代码:

清单 4.20:使用 boost::char_separator 的 Boost Tokenizer

 1 #include <boost/tokenizer.hpp>
 2 #include <string>
 3 #include <iostream>
 4
 5 int main()
 6 {
 7   std::string input = 
 8                "God knows, I've never been a spiritual man!";
 9
10   boost::char_separator<char> sep(" \t,.!?;./\"(){}[]<>");
11   typedef boost::tokenizer<boost::char_separator<char> > 
12                                                  tokenizer;
13   tokenizer mytokenizer(input, sep);
14   for (auto& token: mytokenizer) 
16   {
17     std::cout << token << '\n';
18   }
19 }

在这种情况下,我们首先使用boost::char_separator模板(第 10 行)构造拆分策略sep。由于我们正在拆分std::string类型的文本,其字符类型为char,因此必须将char作为参数传递给boost::char_separator,以指定分隔符的类型为char。我们还可以写boost::char_separator<std::string::value_type>,而不是boost::char_separator<char>,以更好地表达关系。我们构造要用作分隔符的标点符号和空白字符列表,并将其作为sep的构造函数参数传递。最后,我们构造分词器,将输入字符串input和拆分策略sep传递给它。我们使用基于范围的 for 循环迭代连续的标记,这比使用标记迭代器时的代码更简洁。

使用包含元字符的字段标记记录

boost::char_delimiter策略并不是唯一可用的拆分策略。考虑一个以逗号分隔的数据格式,如下所示:

Joe Reed,45,Bristol UK
Ophir Leibovitch,28,Netanya Israel
Raghav Moorthy,31,Mysore India

每行一个记录,每个记录有三个字段:一个人的姓名、年龄和居住城市。我们可以使用boost::char_separator策略解析这样的记录,将逗号作为分隔符传递给它。现在,如果我们想要使格式更丰富一些,我们可以包括人们的完整地址而不是他们目前的城市。但是地址是更长的字段,有时带有嵌入的逗号,这样的地址会破坏基于逗号作为分隔符的解析。因此,我们决定引用可能带有嵌入逗号的字符串:

Joe Reed,45,"33 Victoria St., Bristol UK"
Ophir Leibovitch,28,"19 Smilanski Street, Netanya, Israel"
Raghav Moorthy,31,"156A Railway Gate Road, Mysore India"

引用本身可能不够。有些地址可能有引号字符串,我们希望保留这些。为了解决这个问题,我们决定使用反斜杠(\)作为转义字符。以下是一个地址中带有引号字符串的第四条记录:

Amit Gupta,70,"\"Nandanvan\", Ghole Road, Pune, India"

现在的问题是,不再可能使用boost::char_separator策略来解析前述记录。对于这样的记录,我们应该使用boost::escaped_list_charboost::escaped_list_char策略是专门为这种用途量身定制的。默认情况下,它使用逗号(,)作为字段分隔符,双引号(")作为引号字符,反斜杠(\)作为转义字符。要在字段中包含逗号,请引用字段。要在字段中包含引号,请转义嵌入的引号。现在我们可以尝试解析前面讨论过的四个人中最复杂的记录:

清单 4.21:使用 boost::tokenizer 和 boost::escaped_list_separator

 1 #include <iostream>
 2 #include <boost/tokenizer.hpp>
 3 #include <string>
 4
 5 int main()
 6 {
 7   std::string input = "Amit Gupta,70,\"\\\"Nandanvan\\\", "
 8                       "Ghole Road, Pune, India\"";
 9
10   typedef boost::tokenizer<boost::escaped_list_separator<char> > 
11                                           tokenizer;
12   tokenizer mytokenizer(input);
13  
14   for (auto& tok: mytokenizer) 
15   {
16     std::cout << tok << '\n';
17   }
18 }

在第 12 行创建了boost::tokenizer<boost::escaped_list_separator<char>>的一个实例,使用了 typedef(第 10-11 行)。这实际上是唯一需要处理的操作变化,以适应这种新格式。变量input中硬编码的记录需要一些额外级别的转义,以使其成为有效的 C++字符串文字(第 7-8 行)。

如果记录具有不同的元字符集合,例如连字符(-)作为字段分隔符,斜杠(/)作为引号,波浪号(~)作为转义字符,我们需要明确指定这些选项,因为boost::escaped_list_separator<<char>>的默认选项将不再起作用。考虑一个名为 Alon Ben-Ari 的人,年龄为 35 岁,住在特拉维夫 Zamenhoff St. 11/5 号。使用指定的引号、字段分隔符和转义字符,这可以表示为:

/Alon Ben-Ari/-35-11~/5 Zamenhoff St., Tel Aviv

姓氏字段中的 Ben-Ari 有一个连字符。由于连字符也是字段分隔符,因此名字字段必须使用斜杠引起来。地址字段有一个斜杠,由于斜杠是引号字符,所以地址字段必须用转义字符(~)转义。现在轮到我们对其进行标记化了:

清单 4.22:使用 boost::escaped_list_separator 和奇特的分隔符

 1 #include <iostream>
 2 #include <boost/tokenizer.hpp>
 3 #include <string>
 4
 5 int main()
 6 {
 7   std::string input = 
 8        "/Alon Ben-Ari/-35-11~/5 Zamenhoff St., Tel Aviv";
 9
10   typedef boost::tokenizer<boost::escaped_list_separator<char> > 
11                                               tokenizer;
12   boost::escaped_list_separator<char> sep('~', '-', '/');
13   tokenizer mytokenizer(input, sep);
14  
15   for (auto& tok: mytokenizer) {
16     std::cout << tok << '\n';
17   }
18 }

这是输出:

Alon Ben-Ari
35
11/5 Zamenhoff Str., Tel Aviv

使用固定长度字段标记化记录

在金融交易和其他几个领域经常出现的一类数据格式是固定偏移量的记录。考虑以下代表支付指令的记录格式:

201408091403290000001881303614419ABNANL2AWSSDEUTDEMM720000000412000EUR…

在这里,记录几乎不可读,只能由程序使用。它具有固定偏移量的字段,解析程序必须知道其含义。这里描述了各个字段:

Offset 0, length 8: date of record in YYYYMMDD format.
Offset 8, length 9: time of record in HHMMSSmmm format where mmm represents milliseconds.
Offset 17, length 16: the transaction identifier for the transaction, numeric format.
Offset 33, length 11: the Swift Bank Identifier Code for the bank from which money is transferred.
Offset 44, length 11: the Swift Bank Identifier Code for the bank to which money is transferred.
Offset 55, length 12: the transaction amount.
Offset 67, length 3: the ISO code for the currency of transaction.

为了解析这样的记录,我们使用boost::offset_separator分割策略。这个类(注意它不是一个模板)以一对迭代器的形式接受连续标记的长度,用于解析。

解析前述支付指令的代码示例应该有助于说明这个想法:

清单 4.23:使用固定长度字段标记化记录

 1 #include <boost/tokenizer.hpp>
 2 #include <string>
 3 #include <iostream>
 4 
 5 int main()
 6 {
 7   std::string input =  
 8      "201408091403290000001881303614419ABNANL2AWSSDEUTDEMM72"
 9      "0000000412000EUR";
10   int lengths[] = {8, 9, 16, 11, 11, 12, 13};
11 
12   boost::offset_separator ofs(lengths, lengths + 7);
13   typedef boost::tokenizer<boost::offset_separator> tokenizer;
14   tokenizer mytokenizer(input, ofs);
15   
16   for (auto& token: mytokenizer) {
17     std::cout << token << '\n';
18   }
19 }

首先定义一个包含连续字段长度的数组(第 10 行),并使用它来初始化类型为boost::offset_separator的对象ofs(第 12 行)。我们也可以使用向量而不是数组,并将其begin()end()迭代器传递给offset_separator构造函数。然后创建一个标记化器,它根据ofs中指定的偏移量对字符串进行标记化(第 13-14 行),并使用基于范围的 for 循环打印连续的标记(第 16-18 行)。

该程序产生以下输出:

20140809
140329000
0001881303614419
ABNANL2AWSS
DEUTDEMM720
000000412000
EUR

我们看到连续的行上列出了日期、时间、ID、发送者 SWIFT 银行代码(发送者银行的标识符)、接收者 SWIFT 银行代码、金额和交易货币的值。

现在,如果所有字段都已解析并且仍有一些输入剩下会发生什么?默认行为是重新开始解析剩余的文本,并从开头应用长度偏移。这对某些格式可能有意义,对某些格式可能没有意义。如果要关闭此行为,以便在使用所有长度偏移后停止解析,应将第三个参数传递给boost::offset_separator的构造函数,并且其值应为false,如下所示:

boost::offset_separator ofs(lengths, lengths + nfields, 
 false);

在这里,lengths是长度偏移的数组,nfields是我们希望解析的字段数。

相反,如果输入短于长度之和会发生什么?默认行为是返回最后部分解析的字段并停止。假设您有一个格式,其中付款人的评论附加到每个交易记录中。评论是可选的,不一定存在。如果存在,可能有最大大小限制,也可能没有。第一种行为可以通过指定最大大小来解析最后一个评论字段,或者指定一个您不希望评论达到的任意大的大小,从而利用最后记录的部分解析。同样,如果要关闭此行为,以便遇到第一个部分字段时停止解析,应将第四个参数传递给boost::offset_separator构造函数,并且其值应为false

boost::offset_separator ofs(lengths, lengths + nfields, restart,
 false);

编写自己的标记函数

有许多情况下,您需要根据一些在 Boost 中不可重用的标准来解析字符串。虽然您可以使用boost::split等替代库,但是您可以通过插入自定义标记生成器来使用boost::tokenizer工具。标记生成器类封装了标记策略,并作为模板参数传递给boost::tokenizer

标记生成器可以定义为符合以下要求的函数对象:

  • 可复制分配。

  • 可复制构造。

  • 具有重载的公共函数调用运算符(operator())具有以下签名:

template <typename InputIterator, typename StringType>bool operator()(InputIterator& next,InputIterator end,StringType& token)

此运算符传递两个迭代器,定义了它在其中查找下一个标记的字符串部分。仅当找到新标记时,它才返回 true。在这种情况下,它将其第三个参数设置为标记,并将其第一个参数设置为字符串中标记结束后的第一个位置,从那里可以继续解析。如果未找到标记,则返回 false。我们必须在此函数中编写逻辑以识别连续的标记。

  • 具有公共成员函数void reset()。这可以用于清除用于保持字符串解析状态的任何成员变量。然后,可以使用对象的相同实例来解析多个输入。

这些函数由boost::tokenizer实现调用,而不是直接由程序员调用。

现在,我们编写一个标记生成器类,以从一些文本中选择带引号或括号的字符串。例如,给定字符串"我要从法兰克福(am Main)乘火车去法兰克福(an der Oder)", 我们想要提取出标记"am Main""an der Oder"。为了简化我们的实现,给定具有嵌套括号或引号的字符串,只需要检索最内部引号的内容。因此,给定字符串"tokenizer<char_separator<char>>", 它应该返回"char", 最内部的括号实体。以下是这样一个名为qstring_token_generator的类的代码:

清单 4.24a:qstring_token_generator 接口

 1 class qstring_token_generator
 2 {
 3 public:
 4   typedef std::string::const_iterator iterator;
 5
 6   qstring_token_generator(char open_q = '"',
 7              char close_q = '"', char esc_c = '\\',
 8              bool skip_empty = true);
 9 
10   bool operator() (iterator& next, iterator end,
11                    std::string& token);
12 
13   void reset();
14
15 private:
16   // helper functions to be defined
17
18   char start_marker;
19   char end_marker;
20   char escape_char;
21   bool skip_empty_tokens;
22   bool in_token;
23   bool in_escape;
24 };

qstring_token_generator类具有一个接受必要输入的构造函数:

  • 开始和结束标记字符,默认都是双引号(")

  • 转义字符,默认为反斜杠(\)

  • 一个布尔值,指示是否跳过空令牌,默认为 true(第 6-8 行)

用于存储这些值的相应私有变量被定义(第 18-21 行)。该类使用两个额外的状态变量来跟踪解析状态:in_token变量(第 22 行),在解析引号内的内容时为 true,否则为 false,以及in_escape变量(第 23 行),如果当前字符是转义序列的一部分则为 true,否则为 false。这是构造函数的实现:

清单 4.24b:qstring_token_generator 构造函数

 1   qstring_token_generator::qstring_token_generator
 2             (char open_q, char close_q, char esc_c,
 3              bool skip_empty) : 
 4      start_marker(open_q), end_marker(close_q), 
 5      escape_char(esc_c), skip_empty_tokens(skip_empty),
 6      in_token(false), in_escape(false)
 7   {}

请注意,in_tokenin_escape被初始化为 false。每次我们使用标记生成器接口迭代输入的连续标记时,标记生成器实现都会调用标记生成器重新解析输入。为了重新开始解析,必须重置任何内部解析状态。reset函数封装了这些操作,并在创建新的标记迭代器时由标记生成器调用。

这是重置函数的实现:

清单 4.24c:qstring_token_generator 重置函数

 1   void qstring_token_generator::reset()
 2   {
 3     in_token = false;
 4     in_escape = false;
 5   }

重置函数确保用于维护解析状态的内部变量被适当地重置以重新开始解析。

最后,解析算法是在重载的函数调用操作员成员(operator())中实现的。为了解析字符串,我们寻找开始和结束标记来识别标记的开始和结束,并将转义的开始和结束标记计为标记的一部分,并处理开始和结束标记是相同字符的情况。我们还处理引号标记嵌套的情况。我们将用qstring_token_generator类中的一些辅助私有函数来编写算法。

清单 4.24d:解析算法辅助函数

 1 iterator qstring_token_generator::start_token(iterator& next)
 2 {
 3   in_token = true;
 4   return ++next;
 5 }
 6
 7 std::string qstring_token_generator::end_token(iterator& next,
 8                                         iterator token_start) 
 9 {
10   in_token = false;
11   auto token_end = next++;
12   return std::string(token_start, token_end);
13 }

start_token函数的意思是每次我们识别出一个新标记的开始时调用它(第 1 行)。它将in_token标志设置为 true,增加迭代器next,并返回它的值。

end_token函数的意思是每次我们识别出一个标记的结束时调用它(第 7 行)。它将in_token标志设置为 false,增加迭代器next,并将完整的标记作为字符串返回。

现在我们需要编写逻辑来识别标记的开始和结束,并适当地调用前面的函数。我们直接在重载的operator()中执行这个操作:

清单 4.24e:解析算法

 1 bool operator() (iterator& next, iterator end,
 2                  std::string& token)
 3 {
 4   iterator token_start;
 5
 6   while (next != end) {
 7     if (in_escape) {
 8       // unset in_escape after reading the next char
 9       in_escape = false;
10     } else if (*next == start_marker) { // found start marker
11       if (!in_token) { // potential new token
12         token_start = start_token(next);
13         continue;
14       } else { // already in a quoted string
15         if (start_marker == end_marker) {
16           // Found end_marker, is equal to start_marker
17           token = end_token(next, token_start);
18           if (!token.empty() || !skip_empty_tokens) {
19             return true;
20           }
21         } else {
22           // Multiple start markers without end marker.
23           // Discard previous start markers, consider
24           //  inner-most token only.
25           token_start = start_token(next);
26           continue;
27         }
28       }
29     } else if (*next == end_marker) {
30       // Found end_marker, is not equal to start_marker
31       if (in_token) {
32         token = end_token(next, token_start);
33         if (!token.empty() || !skip_empty_tokens) {
34           return true;
35         }
36       }
37     } else if (*next == escape_char) {
38       in_escape = !in_escape;  // toggle
39     }
40     ++next;
41   }
42
43   return false;
44 }

我们使用 while 循环遍历输入的连续字符(第 6 行)。对于每个字符,我们检查它是否是转义字符(第 7 行),或者它是否是开始标记(第 10 行),结束标记(第 29 行)或转义字符(第 37 行)的前导字符。

如果找到未转义的开始标记,并且我们还没有在解析标记中(第 11 行),那么它可能代表一个新标记的开始。因此,我们调用start_token,记录标记的起始位置,并继续到下一个迭代(第 12-13 行)。但是,如果我们已经在解析标记中,并且找到了开始标记,那么有两种可能性。如果开始和结束标记恰好相同,那么这表示标记的结束(第 15 行)。在这种情况下,我们调用end_token来获取完整的标记并返回它,除非它为空并且设置了skip_empty_tokens(第 16-20 行)。如果开始和结束标记不相同,那么第二个开始标记表示嵌套标记。由于我们只想提取最嵌套的标记,我们丢弃先前的标记并调用start_token来指示我们有一个新标记的开始(第 25-26 行)。

如果结束标记与开始标记不同,并且我们找到它(第 29 行),那么我们调用end_token生成并返回找到的完整标记,除非它为空并且设置了skip_empty_tokens。最后,如果我们找到转义字符,我们设置in_escape标志(第 37-38 行)。

我们使用qstring_token_generator类来对我们的输入字符串进行标记化:

清单 4.25:使用自定义标记生成器提取括号字符串

 1  std::string input = "I'm taking a train from Frankfurt "
 2                    "(am Main) to Frankfurt (an der Oder)";
 3  bool skipEmpty = true;
 4  qstring_token_generator qsep('(', ')', '\\', skipEmpty);
 5  typedef boost::tokenizer<qstring_token_generator> qtokenizer;
 6  qtokenizer tokenizer(input, qsep);
 7
 8  unsigned int n = 0;
 9  for (auto& token: tokenizer) {
10    std::cout << ++n << ':' << token << '\n';
11 }

前面突出显示的代码显示了我们代码中的关键更改。我们定义了一个qstring_token_generator对象,它接受左引号和右引号字符(在本例中是左括号和右括号),并跳过空标记(第 4 行)。然后我们为boost::tokenizer<qstring_token_generator>(第 4 行)创建了一个 typedef,创建了一个该类型的标记生成器来解析输入(第 6 行),并打印连续的标记(第 10 行)。

使用 Boost.Regex 的正则表达式

当我们编写像boost::find_first("Where have all the flowers gone?", "flowers")这样的代码行时,我们是在要求在较大的字符串"Where have all the flowers gone?"(称为大海草堆)中找到字符串"flowers"(称为**针)的存在。针是模式;一个特定顺序中的七个特定字符,其存在必须在大海草堆中查找。然而,有时我们并不知道我们要找的确切字符串;我们只有一个抽象的想法或一个模式。正则表达式是一种表达这种抽象模式的强大语言。

正则表达式语法

正则表达式是一种字符串,它使用常规字符和一些具有特殊解释的字符的混合来编码文本的模式,这些字符统称为元字符。 Boost.Regex 库提供了消耗正则表达式字符串并生成搜索和验证符合特定模式的文本的逻辑的函数。例如,要定义模式“a 后面跟零个或多个 b”,我们使用正则表达式ab*。这个模式将匹配文本aababbabbb等。

原子

在非常基本的层面上,正则表达式由称为原子的一个或多个字符组成,每个原子都有一个关联的量词,跟在原子后面,还可以选择地有锚点,定义了如何相对于周围文本定位一些文本。量词可能是隐式的。原子可以是单个字符(或转义的元字符)、字符类、字符串或通配符。如果是字符串,必须将其括在括号中以指示它是一个原子。通配符匹配任何字符(除了换行符),并使用句点(.)元字符编写。

量词

没有尾随量词的单个原子只匹配自身的单个出现。当存在时,尾随量词确定了前面原子的最小和最大允许出现次数。一般的量词看起来像{m, M},其中m表示最小出现次数,M表示最大出现频率。省略最大值,如{m,}表示原子可以出现的最大次数是无限的。也可以使用一个数字作为{n}来匹配固定数量的实例。更常见的是,我们使用以下快捷量词:

  • *:等同于{0,},称为Kleene 星。表示可能不会发生的原子,或者可能发生任意次数。

  • +:等同于{1,}。表示必须至少出现一次的原子。

  • ?:等同于{0,1}。表示可选原子。

使用上述语法规则,我们在下表中构造摘要示例:

正则表达式 原子 量词 等效量词 匹配文本
W w None(隐式) {1} w
a* a * {0,} (空白),a,aa,aaa,aaaa,…
(abba)+ abba + {1,} abba, abbaabba, abbaabbaabba, …
a?b a,b ? {0,1} b,ab
(ab) (ab) {2,4} abab, ababab, abababab
.*x . 和 x * 和 None {0,}{1} x 和以 x 结尾的任何字符串

默认情况下,量词是贪婪的,会匹配尽可能多的字符。因此,对于字符串"abracadabra",正则表达式"a.*a"将匹配整个字符串,而不是更小的子字符串"abra""abraca""abracada",它们也都以'a'开头和结尾。如果我们只想匹配最小的匹配子字符串,我们需要覆盖贪婪的语义。为此,我们在量词"a.*?a"后面加上问号(?)元字符。

字符类

字符也可以与字符类匹配,字符类是一组功能相关字符的简写表示。以下是 Boost 库中预定义的字符类的部分列表:

字符类 简写形式 含义 补集
[[:digit:]] \d 任何十进制数字(0-9) \D
[[:space:]] \s 任何空白字符 \S
[[:word:]] \w 任何单词字符:字母、数字和下划线 \W
[[:lower:]] \l 任何小写字符
[[:upper:]] \u 任何大写字符
[[:punct:]] 任何标点字符

例如,\d是一个字符类,匹配一个十进制数字。它的补集`D匹配任何单个字符,除了十进制数字。\s匹配空白字符,\S匹配非空白字符。可以用方括号创建临时字符类;[aeiouAEIOU]匹配任何英语元音字母,[1-5]匹配 1 到 5 之间的数字(包括 1 和 5)。表达式[²-4]`匹配除了 2、3 和 4 之外的任何字符,并且方括号内的前导插入符号具有否定其后字符的作用。我们可以组合多个字符类,比如—[[:digit:][:lower:]]—来表示小写字母和十进制数字的集合。

锚点

某些元字符,称为锚点,不匹配字符,但可以用于匹配文本中的特定位置。例如,正则表达式中的插入符(^)匹配行的开头(换行符后面)。美元符($)匹配行的结尾(换行符前面)。此外,\b表示单词边界,而\B匹配除了单词边界之外的任何位置。

子表达式

一般来说,字符中的每个字符都被解释为一个独立的原子。为了将一串字符视为一个单独的原子,我们必须将其括在括号中。正则表达式中括号内的子字符串称为子表达式。跟在子表达式后面的量词适用于整个子表达式:

([1-9][0-9]*)(\s+\w+)*

前面的表达式表示一个数字([1-9][0-9]*)后面跟着零个或多个单词(\w+),它们之间和彼此之间由一个或多个空白字符(\s+)分隔。第二个 Kleene 星号由于括号的存在应用于整个子表达式\s+\w+

正则表达式库,包括 Boost.Regex,跟踪字符串的子字符串,这些子字符串与括号内的子表达式匹配。匹配的子表达式可以在正则表达式内部使用反向引用,如\1\2\3等。例如,在前面的正则表达式中,术语\1匹配前导数字,而\2匹配带有前导空格的最后匹配的单词。如果没有尾随单词,则不匹配任何内容。子表达式可以嵌套,并且按照它们在字符串中从左到右出现的左括号的顺序从 1 开始递增编号。

如果您想使用子表达式来能够对字符组应用量词和锚定,但不需要捕获它们以供以后引用,您可以使用形式为(?:expr)非捕获子表达式,其中括号内的前导元字符序列?:表示它是一个非捕获子表达式,expr是一些有效的正则表达式。这将把 expr 视为一个原子,但不会捕获它。括号内没有前导?:的子表达式因此被称为捕获组捕获子表达式

分离

您可以创建一个正则表达式,它是一个或多个正则表达式的逻辑或。为此,您可以使用|分离运算符。例如,要匹配包含小写和大写字符混合的单词,您可以使用表达式(\l|\u)+

您可以使用分离运算符来组合正则表达式并形成更复杂的表达式。例如,要匹配包含大写或小写字符的单词,或正整数,我们可以使用表达式(\l|\u)+|\d+

使用 Boost.Regex 来解析正则表达式

正则表达式是一个丰富的主题,在前面的段落中我们只是浅尝辄止。但这种基本的熟悉已经足够让我们开始使用 Boost.Regex 库。Boost.Regex 库是 C++ 11 标准中被接受的库之一,现在是 C++ 11 标准库的一部分,减去了处理 Unicode 字符的能力。

Boost 正则表达式库不是仅包含头文件,需要链接到 Boost.Regex 共享或静态库。它可以从头文件boost/regex.hpp中获得。在我使用本机包管理器安装 Boost 库的 Linux 桌面上,我使用以下命令行来构建正则表达式程序:

$ g++ source.cpp -o progname -lboost_regex

在从源代码安装 Boost 的 Linux 系统上,头文件可能位于非标准位置,如/opt/boost/include,库位于/opt/boost/lib下。在这样的系统上,我必须使用以下命令行来构建我的程序:

$ g++ source.cpp -o progname -I/opt/boost/include -L/opt/boost/lib -lboost_regex-mt -Wl,-rpath,/opt/boost/lib

-Wl-rpath/opt/boost/lib指令告诉链接器硬编码路径,从中加载共享库,如libboost_regex-mt,并帮助我们的程序在没有额外设置的情况下运行。在使用 Visual Studio 的 Windows 上,链接是自动的。

它使用boost::basic_regex模板来建模正则表达式,并为char类型提供其特化boost::regexwchar_t类型的boost::wregex作为 typedef。使用这个库,我们可以检查一个字符串是否符合某种模式或包含符合某种模式的子字符串,提取符合某种模式的字符串的所有子字符串,用另一个格式化的字符串替换与模式匹配的子字符串,并根据匹配表达式拆分字符串,这是最常用的几种操作。

匹配文本

考虑字符串"Alaska area"。我们想要将其与正则表达式a.*a匹配,以查看字符串是否符合模式。为此,我们需要调用boost::regex_match函数,该函数返回一个布尔值 true,表示成功匹配,否则返回 false。以下是代码:

清单 4.26:使用正则表达式匹配字符串

1 #include <boost/regex.hpp>
2 #include <string>
3 #include <cassert>
4 int main()
5 {
6   std::string str1 = "Alaska area";
7   boost::regex r1("a.*a");
8   assert(!boost::regex_match(str1, r1));
9 }

正则表达式"a.*a"封装在boost::regex的实例中。当我们将字符串与此表达式匹配时,匹配失败(第 8 行),因为字符串以大写'A'开头,而正则表达式期望在开头是小写'a'。我们可以通过构造并将boost::regex::icase作为标志传递给boost::regex构造函数来要求不区分大小写的正则表达式:

7   boost::regex r1("a.*a", boost::regex::icase);
8   assert(boost::regex_match(str1.begin(), str1.end(), r1));

请注意,我们调用了boost::regex_match的不同重载,它接受两个std::string的迭代器(第 8 行),只是为了说明另一种签名。您也可以像在清单 4.25 中那样使用const char*std::string调用boost::regex_match。函数的结果不依赖于变体。

搜索文本

如果我们想要搜索与特定正则表达式匹配的字符串的子字符串,我们应该使用boost::regex_search函数,而不是boost::regex_match。考虑字符串"An array of papers from the academia on Alaska area's fauna"。我们想要找到这个短语中属于同一个单词并以'a'开头和结尾的所有子字符串。要使用的正则表达式将是a\w*a。让我们看看如何使用boost::regex_search来做到这一点:

清单 4.27:搜索匹配正则表达式的子字符串

 1 #include <boost/regex.hpp>
 2 #include <string>
 3 #include <iostream>
 4 
 5 int main() {
 6   std::string str2 = "An array of papers from the academia "
 7                      "on Alaska area's fauna";
 8   boost::regex r2("a\\w*a");
 9   boost::smatch matches;
10   std::string::const_iterator start = str2.begin(),
11                               end = str2.end();
12
13   while (boost::regex_search(start, end, matches, r2)) { 
14     std::cout << "Matched substring " << matches.str()
15            << " at offset " << matches[0].first - str2.begin()
16            << " of length " << matches[0].length() << '\n';
17     start = matches[0].second;
18   }
19 }

这打印了以下行,每行都有一个以'a'开头和结尾的单词或单词的一部分:

Matched substring arra at offset 3 of length 4.
Matched substring academia at offset 28 of length 8.
Matched substring aska at offset 42 of length 4.
Matched substring area at offset 47 of length 4.
Matched substring auna at offset 58 of length 4.

在代码示例中,我们构造了字符串(第 6 行),正则表达式(第 8 行),以及boost::smatch的实例(第 9 行),它是boost::match_results模板的特化,用于输入类型为std::string时使用。我们在循环中搜索连续匹配的子字符串,调用boost::regex_search。我们将两个迭代器传递给boost::regex_searchsmatch实例称为matches,以及正则表达式r2(第 13 行)。您必须向boost::regex_search传递const迭代器(第 10、11 行),否则编译将无法解析函数调用,并显示大量不必要的消息。

类型为boost::smatch的对象matches在调用regex_search后存储有关与正则表达式匹配的子字符串的信息。它的str成员返回由正则表达式匹配的子字符串。boost::smatchboost::ssub_match对象的序列集合。当正则表达式匹配子字符串时,迭代器对的一部分存储在类型为boost::ssub_match的对象中。这存储在matches的索引 0 处,并作为matches[0]访问。ssub_matchfirstsecond成员是匹配的开始(第 15 行)和匹配结束的迭代器。成员函数length()返回匹配的长度(第 16 行)。在每次迭代结束时,我们将start迭代器设置为上一个匹配结束位置之后的第一个位置(第 17 行),以开始寻找下一个匹配。boost::ssub_match是模板boost::sub_match的特化,用于当输入字符串的类型为std::string时使用。

假设对于每个匹配,我们想要提取两个a之间的内容。为此,我们可以使用捕获子表达式。正则表达式会稍微修改为a(\\w*)a。要访问与括号子表达式匹配的内容,我们再次使用boost::smatch对象。对于正则表达式中的每个这样的子表达式,都会构造一个额外的boost::ssub_match对象,并将其添加到传递的boost::smatch对象的连续索引中。如果子表达式在字符串中匹配了任何内容,那么匹配该子表达式的子字符串的开始和结束将存储在ssub_match对象中。

这是我们如何使用修改后的正则表达式:

清单 4.28:解析匹配的子字符串和子表达式

 1 #include <boost/regex.hpp>
 2 #include <string>
 3 #include <iostream>
 4 int main()
 5 {
 6   std::string str2 = "An array of papers from the academia "
 7                      "on Alaska area's fauna";
 8  boost::regex r2("a(\\w*)a");
 9  boost::smatch matches;
10   std::string::const_iterator start = str2.begin(),
11                               end = str2.end();
12
13   while (boost::regex_search(start, end, matches, r2)) {
14     std::cout << "Matched substring '" << matches.str()
15          << "' following '" << matches.prefix().str()
16          << " preceding '" << matches.suffix().str() << "'\n";
17     start = matches[0].second;
18     for (size_t s = 1; s < matches.size(); ++s) {
19       if (matches[s].matched) {
20         std::cout << "Matched substring " << matches[s].str()
21            << " at offset " << matches[s].first – str2.begin()
22            << " of length " << matches[s].length() << '\n';
23       }
24     }
25   }
26 }

在内部循环(第 18 行)中,我们遍历所有子表达式,对于匹配任何子字符串的子表达式(第 19 行),我们使用boost::ssub_matchstr成员函数(第 20 行)打印匹配的子字符串,以及子字符串的偏移量(第 21 行)和长度(第 22 行)。matches对象的prefixsuffix方法分别返回匹配的子字符串之前和之后的部分,作为boost::ssub_match对象(第 15、16 行)。

boost::match_resultsboost::sub_match模板有不同的可用特化,适用于不同类型的输入,比如窄字符或宽字符数组,或者std::basic_stringstd::stringstd::wstring)的特化。

以下表总结了这些特化:

输入类型 std::match_results 特化 std::sub_match 特化
std::string std::smatch std::ssub_match
std::wstring std::wmatch std::wsub_match
const char* std::cmatch std::csub_match
const wchar_t* std::wcmatch std::wcsub_match

使用正则表达式对文本进行标记化

使用正则表达式解析输入是很多工作,应该有更好的抽象可用于应用程序员。事实上,您可以使用boost::regex_iteratorboost::regex_token_iterator来简化这种工作。假设我们想要挑选出字符串中以'a'开头和结尾的所有单词。以下是一个相对轻松的方法:

清单 4.29:使用 boost::regex_iterator 解析字符串

 1 #include <boost/regex.hpp>
 2 #include <string>
 3 #include <iostream>
 4
 5 int main()
 6 {
 7   std::string str2 = "An array of papers from the academia "
 8                      "on Alaska area's fauna";
 9   boost::regex r1("\\ba\\w*a\\b", boost::regex::icase);
10   boost::sregex_iterator rit(str2.begin(), str2.end(), r1), rend;
11 
12   while (rit != rend) {
13     std::cout << *rit++ << '\n';
14   }
15 }

该程序将以下文本打印到输出,由以'a'开头和结尾的三个单词组成:

academia
Alaska
area

boost::sregex_iterator是模板boost::regex_iterator的特化,用于当输入字符串的类型为std::string时使用。它的实例rit使用字符串迭代器初始化,定义了用于查找连续标记的输入字符串和正则表达式(第 10 行)。然后,它用于像任何其他迭代器一样迭代连续的标记(第 12 行)。

在前面的示例中,我们没有处理子表达式。因此,让我们看一个带有子表达式的示例。考虑一个字符串"animal=Llama lives_in=Llama and is related_to=vicuna"。它由一些由等号分隔的键值对组成,还有其他内容。如果我们想要提取所有这样的键值对,我们可以使用正则表达式\w+=\w+。我们假设键和值是不带嵌入标点或空格的单词。如果我们还想要分别挑选出键和值,我们可以使用捕获组,如(\w+)=(\w+)用于子表达式匹配。

通过使用boost::sregex_token_iterator,我们实际上可以相对容易地挑选出与单个子表达式匹配的子字符串。boost::sregex_token_iterator是模板boost::regex_token_iterator的特化,用于处理类型为std::string的输入字符串。它接受输入字符串、正则表达式和可选参数的迭代器,指定要迭代的子表达式。以下是引导代码:

清单 4.30:使用 boost::regex_token_iterator 解析输入字符串

 1 #include <boost/regex.hpp>
 2 #include <string>
 3 #include <iostream>
 4
 5 int main()
 6 {
 7   std::string str3 = "animal=Llama lives_in=Chile "
 8                      "and is related_to=vicuna";
 9   boost::regex r3("(\\w+)=(\\w+)");
10   int subindx[] = {2, 1};
11   boost::sregex_token_iterator tokit(str3.begin(), str3.end(),
12                                      r3, subindx), tokend;
13   while (tokit != tokend) {
14     std::cout << *tokit++ << '\n';
15   }
16   std::cout << '\n';
17 }

此代码打印以下输出:

Llama
animal
Chile
lives_in
vicuna
related_to

您可能已经注意到,我们打印的值后面跟着键。我们使用定义输入字符串的迭代器、正则表达式和数组subindx来初始化boost::sregex_token_iterator,该数组指定我们感兴趣的子表达式(第 11 行)。由于subindx的值为{2, 1}(第 10 行),第二个字段在第一个字段之前打印。除了数组,我们还可以传递标识子表达式索引的整数向量,或者标识我们感兴趣的唯一子表达式的单个整数。如果省略此参数,boost::regex_token_iterator的行为与boost::regex_iterator相同。数组的大小不需要传递,并且通过模板参数推导自动推断。

Boost String Algorithms 库中的一些算法提供了对 Boost.Regex 功能的便捷包装。boost::find_all_regex 算法接受一个序列容器、一个输入字符串和一个正则表达式,并通过单个函数调用将匹配正则表达式的输入字符串的所有子字符串放入序列容器中。boost::split_regex 容器将一个字符串分割成由匹配某个正则表达式的文本分隔的标记,并将这些标记放入序列容器中。以下是两者的示例;find_all_regex 将一个句子分割成单词,而 split_regex 将使用管道字符分隔的记录分割成字段:

清单 4.31:使用 find_all_regex 和 split_regex

 1 #include <boost/algorithm/string_regex.hpp>
 2 #include <boost/regex.hpp>
 3 #include <string>
 4 #include <iostream>
 5 #include <vector>
 6
 7 int main()
 8 {
 9   std::string line = "All that you touch";
10   std::vector<std::string> words;
11   boost::find_all_regex(words, line, boost::regex("\\w+"));
12
13   std::string record = "Pigs on the Wing|Dogs| Pigs| Sheep";
14   std::vector<std::string> fields;
15   boost::split_regex(fields, record, boost::regex("[\\|]"));
16
17   for (auto word: words) { std::cout << word << ","; }
18   std::cout << '\n';
19   for (auto field: fields) { std::cout << field << ","; }
20 }

这打印出以下输出:

All,ll,l,that,hat,at,t,you,ou,u,touch,ouch,ch,h,
Pigs on the Wing,Dogs, Pigs, Sheep,

请注意,第一行打印出了与正则表达式 \w+ 匹配的所有可能子字符串(第 11 行),而不仅仅是最大的不相交匹配子字符串。这是因为 find_all_regex 在输入中找到了每个匹配的子字符串。

替换文本

正则表达式的一个常见用途是搜索文本,并用其他文本替换匹配的文本。例如,我们可能想要扫描特定段落以寻找所有所有格短语(英国的女王,印度的文化,人们的选择等),并将它们转换为另一种形式(英国的女王,印度的文化,人们的选择等)。boost::regex_replace 函数模板可以很方便地实现这一目的。

首先,我们定义正则表达式 \w+'s\s+\w+。由于我们必须重新排列短语,我们必须使用子表达式来捕获匹配的部分。我们使用正则表达式 (\w+)'s\s+(\w+) 进行匹配。我们可以在替换字符串中使用编号的反向引用来引用子匹配,因此替换字符串是 "\2 of \1"。我们将这些与输入字符串一起传递给 boost::regex_replace,它将返回一个字符串,其中匹配的部分已适当替换。以下是代码:

清单 4.32:使用正则表达式查找/替换字符串

 1 #include <boost/regex.hpp>
 2 #include <cassert>
 3
 4 int main()
 5 {
 6   std::string str4 = "England's Queen, India's President, "
 7                      "people's choice";
 8   boost::regex r4("(\\w+)'s\\s+(\\w+)");
10   std::string rep = boost::regex_replace(str4, r4, "\\2 of \\1");
11   
12   assert(rep == "Queen of England, President of India, "
13                   "choice of people");
14 }

默认情况下,regex_replace 会替换所有匹配的子字符串。如果我们只想替换第一个匹配的子字符串,那么我们需要将 boost::regex_constants::format_first_only 作为第四个参数传递给 regex_replace

自测问题

对于多项选择题,选择所有适用的选项:

  1. Boost Range 如何帮助 Boost Algorithms 提供更好的接口?

a. 任何以单个参数表示的字符范围,而不是迭代器对

b. 它比迭代器对更快

c. 它支持 C 风格数组,并可扩展到其他抽象

d. 它提供更好的异常安全性

  1. 哪个算法生成了搜索所有匹配搜索字符串或模式的子字符串的最短代码?

a. boost::find_all

b. boost::find_all_regex

c. boost::find_first

d. boost::regex_iterator

  1. Boost Tokenizer 库提供了哪些标记化函数?

a. boost::char_separator

b. boost::split

c. boost::escaped_list_separator

d. boost::tokenizer

  1. 正则表达式 "\ba.*a" 匹配字符串 "two giant anacondas creeping around" 的哪一部分?

a. "ant anacondas creeping a"

b. "anacondas creeping a"

c. "ant anaconda"

d. "anaconda"

  1. 以下关于 boost::smatch 的哪个说法是正确的?

a. 它是 boost::match_results 的一个特化

b. 它仅存储匹配的子表达式

c. 它为每个子表达式存储一个 boost::ssub_match 对象

d. 其 str 成员返回匹配的子字符串

总结

在本章中,我们学习了使用 Boost String Algorithms 库中的各种杂项函数来执行对字符串数据类型的各种操作。然后我们看了一下通用的 Boost String Tokenizer 框架,它提供了一种高效和可扩展的方式来根据用户定义的条件对字符串进行标记化。最后,我们看了一下正则表达式,以及 Boost.Regex 库,它提供了匹配字符数据与正则表达式、搜索模式、标记化和使用正则表达式替换模式的能力。

本章应该为您提供了从 Boost 库中提供的基本文本处理工具的广泛视角。在这个过程中,我们还从 Boost Range 抽象中学到了一些有用的技巧。在下一章中,我们将转向 Boost 库中提供的各种数据结构。

第五章:超越 STL 的有效数据结构

C++标准库提供了一套丰富的通用容器,可用于各种常见的编程任务。这些包括序列容器,如std::vectorstd::dequestd::liststd::forward_list,以及有序和无序的关联容器,如std::mapstd::setstd::unordered_mapstd::unordered_set等等。

容器被遍历,并且它们的单个元素使用迭代器进行访问。C++根据它们对容器元素提供的访问类型(读取、写入、前向遍历、双向遍历和随机访问)定义了迭代器类别的层次结构。用于遍历容器的迭代器类型取决于容器的内部结构。

除了容器之外,还有一个通用算法库,用于读取和操作通用容器,使用一个或多个迭代器。这些库大量依赖通用编程,其中程序接口被抽象化,并且以数据类型的形式进行参数化。

这个通用容器、算法集合以及一堆相关的实用工具最初起源于标准模板库STL,由亚历山大·斯蒂潘诺夫和孟李在惠普实验室开发,并于 1994 年被接受为 C++标准库的一部分。STL 这个名字一直沿用至今,用来指代源自这项工作的标准库的部分内容,我们会宽泛地使用它来指代这样的库的部分内容。STL 容器和算法自那时以来一直在 C++软件中被广泛使用,但存在一些限制。在 C++11 之前,你只能在容器中存储可复制的对象。标准库中缺少了一些容器类,比如基于哈希的关联容器,而其他一些容器,比如优先队列,受到了限制。

截至 C++14,标准库中还没有适合存储动态分配对象指针的容器,尽管有了std::unique_ptr的可用性,自 C++11 以来处理起来更容易。您无法有效地搜索关联容器的内容,比如通过值而不是键来搜索std::map,也无法轻松地为自定义容器类编写与 STL 算法良好配合的迭代器。没有一个简单的库可以从各种标准格式(XML、JSON 等)中读取属性集或键值对到内存数据结构中。还有许多其他常见的用途,如果只使用标准库,就需要付出很大的努力。

在本章和下一章中,我们将看一下填补这些空白的主要 Boost 库。本章分为以下几个部分:

  • Boost 容器库

  • 使用 Boost 无序容器进行快速查找

  • 用于动态分配对象的容器

  • 使用 Boost.Assign 进行表达式初始化和赋值

  • 使用 Boost.Iterator 进行迭代模式

本章应该为您提供了一个扎实的基础,以便使用 Boost 中各种各样的数据结构库。

Boost 容器库

Boost 容器库实现了大部分 STL 容器模板,还提供了一些非标准容器。那么,重新实现 STL 容器有什么意义呢?为了理解这一点,让我们看看 STL 容器可以存储哪些对象,以及哪些对象不能存储。

要在std::vector中存储类型为T的对象,例如,类型T必须在定义类型为std::vector<T>的对象的地方是一个完整类型(即必须完全定义,而不仅仅是声明)。此外,在 C++11 之前,类型为T的对象必须是可复制和可赋值的。这些要求通常适用于除了std::vector之外的其他 STL 容器。一般来说,在 C++11 之前,STL 是一个复制密集型的框架:你将对象复制到 STL 容器中存储,容器在调整大小或重组时复制它们,容器在作用域结束时销毁这些副本。复制是一种在时间和内存方面昂贵的操作,也更容易出错,因此 STL 容器上的几个操作的异常安全性较弱。

C++11 引入了移动语义,使得可以通过移动或篡夺现有对象的状态来移动构造新对象,通常只交换整数和指针,完全避免任何非平凡和容易出错的复制操作。同样,对象的状态或内容可以在称为移动赋值的操作中移动到另一个现有对象中。在从临时对象构造或赋值时,默认应用移动语义,而在从左值对象复制时可以显式强制执行移动语义(参见附录,C++11 语言特性模拟)。这些能力使得 C++11 中标准库容器的操作可以得到显著优化,并且独立于复制语义。存储在 C++11 STL 容器中的对象如果是可移动构造的,则无需是可复制的。C++11 还允许在容器的布局中就地构造对象,而不需要先构造然后复制。

Boost Container 库提供了对移动感知的标准库容器的实现,不仅适用于 C++11 编译器,还适用于 C++03 编译器的 Boost 移动模拟(参见附录,C++11 语言特性模拟)。此外,它们还支持对象的就地构造。如果你在 C++03 编译器上,这是一个重要的功能。此外,Boost Container 库中的容器可以容纳不完整类型的对象,这使得可以定义有趣的递归结构,这是使用标准容器简单不可能的。

除了标准容器之外,Boost Container 库还实现了几个有用的非标准容器,适用于各种特定用途。

移动感知和原地构造

考虑以下用于封装char字符串的类,它是可移动但不可复制的。我们使用 Boost 移动模拟宏来定义其移动语义。在 C++11 环境中,这段代码转换为 C++11 移动语法,而在 C++03 中,它模拟了移动语义:

清单 5.1:可移动但不可复制的字符串

 1 #include <boost/move/move.hpp>
 2 #include <boost/swap.hpp>
 3 #include <cstring>
 4
 5 class String
 6 {
 7 private:
 8   BOOST_MOVABLE_BUT_NOT_COPYABLE(String)
 9
10 public:
11   String(const char *s = nullptr) : str(nullptr), sz(0) {
12     str = heapcpy(s, sz);
13   }
14
15   ~String() {
16     delete[] str;
17     sz = 0;
18   }
19
20   String(BOOST_RV_REF(String) that) : str(nullptr), sz(0) {
21     swap(that);
22   }
23
24   String& operator = (BOOST_RV_REF(String) rhs) {
25     String tmp(boost::move(rhs));
28
29    return *this;
30   }
31
32   void swap(String& rhs) {
33     boost::swap(this->sz, rhs.sz);
34     boost::swap(this->str, rhs.str);
35   }
36
37   const char *get() const {
38     return str;
39   }
40
41 private:
42   char *str;
43   size_t sz;
44
45  static char *heapcpy(const char *str, size_t& sz) {
46     char *ret = nullptr;
47
48     if (str) {
49       sz = std::strlen(str) + 1;
50       ret = new char[sz];
51       std::strncpy(ret, str, sz);
52     }
53
54     return ret;
55   }
56 };

在 C++11 之前的编译器上,尝试将String的实例存储在标准容器中将导致编译错误,因为String是不可复制的。以下是一些将 String 实例移入boost::container::vector的代码,它是std::vector的 Boost 对应物:

清单 5.2:将 String 对象推送到 Boost 向量

 1 #include <boost/container/vector.hpp>
 2 #include "String.h"  // for class String
 3 #include <cassert>
 4 
 5 int main() {
 6   boost::container::vector<String> strVec;
 7   String world("world");
 8   // Move temporary (rvalue)
 9   strVec.push_back(String("Hello"));
10   // Error, copy semantics needed
11   //strVec.push_back(world);
12   // Explicit move
13   strVec.push_back(boost::move(world));
14   // world nulled after move
15   assert(world.get() == nullptr);
16   // in-place construction
17   strVec.emplace_back("Hujambo Dunia!"); // Swahili
18
19   BOOST_FOREACH(String& str, strVec) {
20     std::cout <<str.get() << '\n';
21   }
22 }

在上述代码中,我们创建了一个 Boost vector(第 6 行),并将临时 String "Hello"附加到它(第 9 行)。这自动调用了移动语义,因为表达式String("Hello")是一个rvalue。我们构造了一个名为worldString变量(第 7 行),但如果我们尝试将它附加到strVec,将会失败,因为它会尝试复制world,但它是不可复制的(第 11 行)。

为了将world放入strVec,我们需要明确地移动它,使用boost::move(第 13 行)。一旦world移动到strVec中,它的内容就会移动到存储在strVec中的String对象中,因此其内容变为空(第 15 行)。最后,通过调用向量的emplace_back成员并传递 String 的构造函数参数来就地构造一个String对象(第 17 行)。清单 5.1 和 5.2 中的代码将在 C++11 编译器以及 C++11 上正确编译和工作。此外,在 C++11 上,Boost 移动模拟的宏简单地转换为 C++右值引用语法。请注意,我们使用BOOST_FOREACH宏而不是 C++11 基于范围的 for 循环来遍历向量(参见附录,C++11 语言特性模拟)。

代码打印以下行:

Hello
world
Hujambo Dunia!

请注意,在基于范围的 for 循环中,循环变量str是使用auto&引入的。如果我们没有在auto后使用尾随的&,编译器将尝试生成代码将strVec的每个元素复制到str中,这将失败,因为String是不可复制的。使用尾随的&确保str是对连续元素的引用。

除了vector,Boost 容器库还实现了其他标准容器,如dequelistsetmultisetmapmultimap,以及basic_string。这些是移动感知容器,非常类似于它们的 C++11 对应物,并且可以在使用移动模拟(通过 Boost.Move)的 C++11 之前的环境中使用。

非标准容器

除了标准容器外,Boost 容器库还提供了几个有用的非标准容器。本节是对这些容器及其适用性的快速概述。

平面关联容器

标准关联容器有两种类型:有序无序。有序容器如std:setstd::multisetstd::mapstd::multimap通常使用平衡搜索树实现(优化的红黑树实现是事实上的)。因此,它们按排序顺序存储它们的元素。无序容器std::unordered_setstd::unordered_multisetstd::unordered_mapstd::unordered_multimap基于哈希表。它们起源于 Boost 容器库,然后成为 C++TR1 发布和 C++11 标准库的一部分。这些容器将对象存储在称为哈希表的桶数组中,基于为对象计算的哈希值。在哈希表中存储对象的顺序没有固有的顺序,因此称为无序容器。

关联容器支持快速查找。有序容器使用平衡搜索树,支持对数时间搜索,无序容器使用哈希表,支持摊销常数时间搜索。这些不是唯一支持快速查找的数据结构。对排序序列进行二分搜索,允许对其元素进行随机位置访问,也可以在对数时间内执行。四个平面关联容器:flat_setflat_multisetflat_mapflat_multimap使用排序向量存储数据,并在向量上使用二分搜索执行查找。它们是标准库中有序和无序对应物的替代品,但对于插入和查找具有不同的性能特征:

清单 5.3:使用平面映射

 1 #include <iostream>
 2 #include <string>
 3 #include <boost/container/flat_map.hpp>
 4 
 5 int main()
 6 {
 7   boost::container::flat_map<std::string, std::string> 
 8           newCapitals;
 9 
10   newCapitals["Sri Lanka"] = "Sri Jayawardenepura Kotte";
11   newCapitals["Burma"] = "Naypyidaw";
12   newCapitals["Tanzania"] = "Dodoma";
13   newCapitals["Cote d'Ivoire"] = "Yamoussoukro"; 
14   newCapitals["Nigeria"] = "Abuja";
15   newCapitals["Kazakhstan"] = "Astana";
16   newCapitals["Palau"] = "Ngerulmud";
17   newCapitals["Federated States of Micronesia"] = "Palikir";
18 
19   for (const auto& entries : newCapitals) {
20     std::cout<< entries.first << ": " << entries.second
21               << '\n';
22   }
23 }

这个第一个例子列出了一些国家,它们的首都在过去几十年里发生了变化。如果你认为拉各斯仍然是尼日利亚的首都,那你会感到惊讶。地理因素除外,在前面的代码中并没有太多令人惊讶的事情。我们为boost::container::flat_map<std::string, std::string>创建了一个typedef,并实例化了一个这种类型的地图newCapitals,插入了国家和它们的新首都的字符串对。如果我们用std::map替换boost::container::flat_map,那么代码将可以在不进行任何更改的情况下工作。

扁平关联容器可以存储可复制或可移动的对象。对象以连续布局存储,而不使用指针进行间接引用。因此,为了存储某种类型的给定数量的对象,扁平容器将比基于树和哈希的容器占用更少的内存。插入会保持排序顺序,因此比其他关联容器更昂贵;特别是对于可复制但不可移动的值类型。此外,与标准关联容器不同,插入任何新元素或删除现有元素都会使所有迭代器失效。

迭代和查找往往比标准容器更快,缓存性能也更好,这是由于连续布局和二分查找的更快性能。插入到扁平容器中可能会导致重新分配,并且如果扁平容器的初始容量超过,则元素会被移动或复制。这可以通过在执行插入之前使用reserve成员函数来预留足够的空间来防止。下面的例子说明了这一方面:

清单 5.4:使用扁平集

 1 #include <boost/container/flat_set.hpp>
 2 #include <iostream>
 3 #include <string>
 4
 5 template<typename C>
 6 void printContainerInternals(const C& container) {
 7   std::cout << "Container layout" << '\n'
 8             << "-------------\n";
 9 
10   for (const auto& elem : container) {
11     std::cout << "[Addr=" << &elem
12               << "] : [value=" << elem << "]\n";
13   }
14 }
15 
16 int main()
17 {
18   boost::container::flat_set<std::string> someStrings;
19   someStrings.reserve(8);
20 
21   someStrings.insert("Guitar");
22   printContainerInternals(someStrings);
23 
24   someStrings.insert("Mandolin");
25   printContainerInternals(someStrings);
26 
27   someStrings.insert("Cello");
28   printContainerInternals(someStrings);
29 
30   someStrings.insert("Sitar");
31   printContainerInternals(someStrings);
32 }

这个例子展示了一种方法,来找出扁平关联容器的内部布局在连续插入后如何改变。我们定义了一个名为someStringsflat_set容器(第 18 行),并插入了八个字符串乐器的名称。在每次插入后,模板printContainer被调用以打印出内部向量中的连续地址,每个字符串都在其中。我们在插入之前(第 19 行)为八个元素保留了容量,并在此后插入了八个元素。由于一开始就有足够的容量,没有一个插入应该触发重新分配,你应该看到一组相当稳定的地址,只有字符串的顺序改变以保持排序顺序。如果我们注释掉保留的调用(第 19 行)并运行代码,我们可能会看到重新分配和地址的变化。

slist

boost::container::slist容器是一个类似于 SGI STL 实现中可用但从未成为标准的同名容器模板的单链表抽象。std::list容器是一个双链表。C++最终在 C++11 中引入了std::forward_list,得到了自己的单链表。slist是可移动的。

单链表的内存开销比双链表小,尽管某些操作的时间复杂度从常数变为线性。如果你需要一个支持相对频繁插入且不需要向后遍历的序列容器,单链表是一个不错的选择:

清单 5.5:使用 slist

 1 #include <boost/container/slist.hpp>
 2 #include <iostream>
 3 #include <string>
 4 
 5 int main()
 6 {
 7   boost::container::slist<std::string> geologic_eras;
 8 
 9   geologic_eras.push_front("Neogene");
10   geologic_eras.push_front("Paleogene");
11   geologic_eras.push_front("Cretaceous");
12   geologic_eras.push_front("Jurassic");
13   geologic_eras.push_front("Triassic");
14   geologic_eras.push_front("Permian");
15   geologic_eras.push_front("Carboniferous");
16   geologic_eras.push_front("Devonian");
17   geologic_eras.push_front("Silurian");
18   geologic_eras.push_front("Ordovician");
19   geologic_eras.push_front("Cambrian");
20 
21   for (const auto& str : geologic_eras) {
22     std::cout << str << '\n';
23   }
24 }

在这个简单的例子中,我们使用slist来存储连续的地质时代。与标准序列容器std::list不同,slist没有push_back方法来将元素追加到列表的末尾。这是因为为每个追加计算列表的末尾会使其成为一个线性操作,而不是一个常数操作。相反,我们使用push_front成员将每个新元素添加到列表的头部。列表中字符串的最终顺序是插入顺序的相反顺序(以及按照时期的年代顺序,最早的在前)。

单链表上的某些操作的时间复杂度比双链表上的等效操作要高。insert方法在std::list中是常数时间,但在slist中是线性时间。这是因为在双链表中,如std::list,可以使用前一个元素的链接来定位插入位置之前的元素,但在slist中需要从列表开头进行遍历。出于同样的原因,用于擦除给定位置的元素的erase成员函数和用于就地构造元素另一个元素之前的emplace成员函数与它们的std::list对应物相比也具有线性复杂度。因此,slist提供了成员函数insert_aftererase_afteremplace_after,它们提供了类似的功能,稍微改变了在给定位置之后插入、擦除和就地构造对象的语义,以在常数时间内添加元素到slist的开头,您可以使用before_begin成员函数获取指向head指针的迭代器,这是一个不可解引用的迭代器,当递增时,指向slist中的第一个元素。

现在我们可以重写清单 5.5,按年代顺序将地质时期插入slist中:

清单 5.6:将元素添加到 slist 的末尾

 1 #include <boost/container/slist.hpp>
 2 #include <iostream>
 3 #include <string>
 4 #include <cassert>
 5
 6 int main()
 7 {
 8   boost::container::slist<std::string> eras;
 9   boost::container::slist<std::string>::iterator last = 
10                                          eras.before_begin();
11
12   const char *era_names [] = {"Cambrian", "Ordovician", 
13                      "Silurian", "Devonian", "Carboniferous", 
14                      "Permian", "Triassic", "Jurassic", 
15                      "Cretaceous", "Paleogene", "Neogene"};
16
17   for (const char *period :era_names) {
18     eras.emplace_after(last, period);
19     ++last;
20   }
21
22   int i = 0;
23   for (const auto& str : eras) {
24     assert(str == era_names[i++]);
25   }
26 }

拼接

除了insertemplace之外,您还可以使用称为splice的操作在slist中的任何给定位置添加元素。拼接是链表上的一个有用操作,其中来自给定列表的一个或多个连续元素被移动到另一个链表的特定位置或同一列表中的不同位置。std::list容器提供了一个splice成员函数,允许您在常数时间内执行此操作。在slist中,splice成员函数的时间复杂度与被拼接的元素数量成线性关系,因为需要线性遍历来定位插入位置之前的元素。splice_after成员函数,像insert_afteremplace_after一样,在指定位置之后将元素移动到列表中:

清单 5.7:拼接 slists

 1 #include <boost/container/slist.hpp>
 2 #include <string>
 3 #include <iostream>
 4 
 5 typedef boost::container::slist<std::string> list_type;
 6 typedef list_type::iterator iter_type;
 7 
 8 int main()
 9 {
10   list_type dinos;
11   iter_type last = dinos.before_begin();
12 
13   const char *dinoarray[] = {"Elasmosaurus", "Fabrosaurus",
14                        "Galimimus", "Hadrosaurus", "Iguanodon",
15                        "Appatosaurus", "Brachiosaurus",
16                        "Corythosaurus", "Dilophosaurus"};
17 
18   // fill the slist
19   for (const char *dino : dinoarray) {
20     dinos.insert_after(last, dino);
21     ++last;
22   }
23 
24   // find the pivot
25   last = dinos.begin();
26   iter_type iter = last;
27
28   while (++iter != dinos.end()) {
29     if (*last > *iter) {
30       break;
31     }
32     ++last;
33   }
34 
35   // find the end of the tail
36   auto itend = last;
37   while (iter != dinos.end()) {
38     ++itend;
39     ++iter;
40   }
41
42   // splice after
43   dinos.splice_after(dinos.before_begin(), dinos,
44                   last, itend);
45   for (const auto& str: dinos) {
46    std::cout <<str<< '\n';
47   }
48 }

在这个代码示例中,我们有一个包含八个恐龙名称的数组,以英文字母表的前八个字母开头(第 13-16 行)。它是一个排序列表,被旋转了四个位置,所以以Elasmosaurus开头,并且在中间某处有Appatosaurus。我们使用insert_after(第 20 行)将其转换为slist,然后定位词典顺序最小的字符串所在的枢轴(第 29-30 行)。在循环结束时,iter指向dinos列表中词典顺序最小的字符串,last指向iter的前一个元素。这是我们想要使用的splice_after重载的原型,用于将列表的尾部移动到开头:

void splice_after(const_iterator add_after, slist& source,
          const_iterator start_after, const_iterator end);

要从源容器移动到目标的元素序列从start_after后面的元素开始,到end结束,两端都包括在内,即半开区间(start_afterend)。这些元素被插入到目标slist中,位置由add_after确定。我们可以使用第三个参数的迭代器last。对于第四个参数,我们计算列表中最后一个元素的位置(第 36-40 行)。迭代器itend现在指向列表中的最后一个元素。使用所选的splice_after重载,我们将所有元素从last开始,直到列表的末尾,移动到列表的开头(第 43-44 行)。

std::forward_list容器没有提供size成员函数来返回列表中元素的数量。这有助于确保其splice_after实现是常数时间。否则,在每次splice_after操作期间,需要计算转移到列表中的元素数量,并且需要将元素的总数增加相应的数量。仅仅为了支持这一点,splice_after将不得不是线性的,而不是常数时间。slist容器提供了一个size成员和几个splice_after的重载。我们使用的splice_after的重载是线性的,因为它使用线性遍历来计算这个数字。然而,如果我们在我们的代码中计算这个数字而不需要额外的循环,并将其传递给splice_after函数,那么它可以避免再次迭代并使用传递的数字。有两个splice_after的重载,它们接受用户提供的元素数量,并避免线性计算,从而提供常数时间的 splice。

以下是一个略微修改的片段来实现这一点:

35   // find the end of the tail
36   size_t count = 0;
37   auto itend = last;
38
39   while (iter != dinos.end()) {
40     ++itend;
41     ++iter;
42     ++count;
43   }
44
45   // splice after
46   dinos.splice_after(dinos.before_begin(), dinos,
47                   last, itend, count);

我们在确定要移动的迭代器范围时计算count,并将其传递给splice_after。我们必须确保我们对count的计算是正确的,否则行为将是未定义的。这个重载很有用,因为我们有一种方法来确定计数,而不增加我们调用代码的复杂性。

对于std::forward_listsplice_after的签名在语义上与boost::container::slist略有不同。以下是std::forward_listsplice_after成员的一个重载的原型:

void splice_after(const_iterator pos, std::forward_list& list,const_iterator before_first, const_iterator after_last);

迭代器before_firstafter_last标识了一个开放区间,实际转移的元素将是从before_first后面的元素开始,到after_last前面的元素结束,即开放区间(before_first, after_last)。使用这个函数,我们在这种情况下不需要编写循环来确定最后一个元素,因为我们可以简单地使用dinos.end()作为结束位置的标记。如果dinosstd::forward_list的一个实例,我们将编辑列表 5.7,从而节省六行代码:

37   dinos.splice_after(dinos.before_begin(), dinos,
38                      last, dinos.end());

std::forward_list中所有传输元素范围的splice_after重载都是线性的。虽然我们在boost::container::slist中看到了一个常数时间的重载,但我们必须编写线性复杂度的逻辑来传递正确的元素数量。因此,在许多情况下,如果您可以在不使用常数时间的size成员函数的情况下做到,使用std::forward_list的代码可能更易于维护,而且效率不会降低。

stable_vector

std::vector容器在连续的内存中存储对象。vector根据需要重新分配内部存储并复制或移动对象到新的存储空间,以便容纳额外的新对象。它允许使用索引快速随机访问存储的对象。在向向量的任意位置插入元素比在末尾追加元素要昂贵,因为插入需要移动插入点后的元素,以便为新元素腾出空间。这种行为还有一个含义。考虑以下代码:

列表 5.8:std::vector 中的迭代器失效

 1 #include <vector>
 2 #include <cassert>
 3 
 4 int main() {
 5   std::vector<int>v{1, 2, 3, 5};
 6   auto first = v.begin();
 7   auto last = first + v.size() - 1;
 8   assert(*last == 5);
 9   v.insert(last, 4);
10   // *last = 10;  // undefined behavior, invalid iterator
11   for (int i = 0; i < 1000; ++i) {
12     v.push_back(i);
13   }
14 
15   // *first = 0; // likely invalidated
16 }

在前面的代码中,我们创建了一个整数vector,名为v,并用四个整数进行初始化(第 5 行)。用于初始化向量的大括号括起来的逗号分隔值列表是一个非常方便的 C++11 构造,称为初始化列表。在 C++11 之前,您必须手动附加值,或者如我们将在本章后面看到的那样,使用Boost.Assign库。然后我们计算对象的最后一个元素的迭代器,作为从第一个迭代器的偏移量(第 7 行)。我们断言最后一个元素是 5(第 8 行)。接下来,我们在最后一个元素之前插入一个元素(第 9 行)。在此之后,迭代器last将被使无效,并且任何访问迭代器last的操作都将是未定义行为。实际上,在两个随机访问容器,即向量和双端队列中,迭代器的使无效经常发生。向向量执行任何写操作都可能使迭代器无效。例如,如果您在特定迭代器位置擦除一个元素,则所有现有的迭代器到后续位置的迭代器都将被使无效。甚至在向量的末尾添加一个元素也可能触发向量内部存储的调整大小,需要移动元素。这样的事件将使所有现有的迭代器无效。标准库vector是一个不稳定的容器boost::container::stable_vector是一个序列容器,提供了稳定的迭代器的随机访问,除非它们所指向的元素被擦除。请查看 Boost 文档页面上关于 stable_vector 的以下图片(www.boost.org/doc/libs/1_58_0/doc/html/container/non_standard_containers.html#container.non_standard_containers.stable_vector):

stable_vector

正如这里所说明的,stable_vector不会以连续的内存布局存储对象。相反,每个对象都存储在一个单独的节点中,并且一个连续的数组以插入顺序存储指向这些节点的指针。每个节点包含实际的对象,还包含指向数组中其位置的指针。迭代器指向这些节点,而不是数组中的位置。因此,具有现有对象的节点在插入新对象或删除一些现有对象后不会改变,并且它们的迭代器也保持有效。但是,当它们由于插入/删除而改变位置时,它们的后向指针会被更新。节点指针的连续数组仍然允许对元素进行随机访问。由于额外的指针和间接引用,stable_vector倾向于比std::vector慢,但这是稳定迭代器的权衡。以下是一些示例代码:

清单 5.9:稳定向量示例

 1 #include <boost/container/stable_vector.hpp>
 2 #include <cassert>
 3 #include <string>
 4
 5 int main()
 6 {
 7   const char *cloud_names[] = {"cumulus", "cirrus", "stratus",
 8                 "cumulonimbus", "cirrostratus", "cirrocumulus",
 9                 "altocumulus", "altostratus"};
10
11   boost::container::stable_vector<std::string> clouds;
12   clouds.reserve(4);
13   clouds.resize(4);   // To circumvent a bug in Boost 1.54
14
15   size_t name_count = sizeof(cloud_names)/sizeof(const char*);
16   size_t capacity = clouds.capacity();
17
18   size_t i = 0;
19   for (i = 0; i < name_count && i < capacity; ++i) {
20     clouds[i] = cloud_names[i];
21   }
22
23   auto first = clouds.begin();
24
25   for (; i < name_count; ++i) {
26     clouds.push_back(cloud_names[i]);
27   }
28
29   auto sixth = clouds.begin() + 5;
30
31   // 1 erase @4
32   clouds.erase(clouds.begin() + 4);
33   // 2 inserts @3
34   clouds.insert(clouds.begin() + 3, "stratocumulus");
35   clouds.insert(clouds.begin() + 3, "nimbostratus");
36
37   assert(*first == cloud_names[0]);
38   assert(sixth == clouds.begin() + 6); // not +5
39   assert(*sixth == cloud_names[5]);
40 }

使用stable_vector与使用vector没有区别,而且它也是移动感知的。在前面的示例中,我们想要将不同类型的云的名称存储在std::stringstable_vector中。有一个名为cloud_names的数组中存在八个云的名称(第 7-9 行)。我们创建了一个名为cloudsstable_vector来存储这些名称,并为仅四个元素保留了容量(第 12-13 行)。我们想要展示的是,一旦我们添加超出stable_vector容量的元素,需要扩展基础数组并移动现有数据,那么在更改容量之前计算的迭代器仍然保持有效。reserve完全有可能分配比请求的更多的容量,如果这比我们拥有的云名称的总数还要多,那么我们的示例就没有意义。

我们首先存储云的名称(第 18-21 行),而不会超出容量,并计算第一个元素的迭代器(第 23 行)。然后,我们追加剩余的云名称(如果有的话)(第 25-27 行)。如果有剩余的云名称,那么当它们中的第一个被存储时,会导致调整大小。

我们计算第六个元素的迭代器(第 29 行),删除第五个元素(第 32 行),并在第四个元素之前插入两个云名称(第 34-35 行)。在所有这些之后,迭代器first仍然指向第一个元素(第 37 行)。在我们计算迭代器sixth的时候,它指向第六个元素,其值为"cirrocumulus",即cloud_names数组中的第六个字符串。现在经过一次删除和两次插入,它应该是第七个元素(第 38 行),但其值应该保持不变(第 39 行)——就像迭代器一样稳定!

提示

自 Boost 1.54 以来,stable_vectorcapacity成员函数在调用reserve后返回了一个不正确的容量值。通过在调用capacity之前使用与reserve传递的参数一样大的参数调用resize成员函数(第 13 行),我们可以规避这个 bug,并确保随后调用capacity返回正确的值。一旦 bug 在以后的版本中修复,调用reserve后的resize应该是不需要的。

static_vector

boost::container::static_vector模板是一个类似于向量的容器,其大小上限在编译时定义。它在布局中分配了一个固定大小的未初始化存储空间,而不是在单独的缓冲区中动态分配。与vectorstable_vector不同,它不会在实例化时尝试对所有元素进行值初始化,后两者在构造函数参数中指定初始大小时会尝试对元素进行值初始化。由于没有堆分配和值初始化,static_vector实例化几乎没有开销。

static_vector就像普通的 vector 一样,但有一个重要的注意事项。尝试在static_vector中插入一个太多的元素会导致运行时错误,因此在尝试插入额外元素之前,您应该始终确保static_vector中有足够的空间:

清单 5.10:使用 static_vector

 1 #include <boost/current_function.hpp>
 2 #include <boost/container/static_vector.hpp>
 3 #include <iostream>
 4
 5 class ChattyInit
 6 {
 7 public:
 8   ChattyInit() {
 9     std::cout << BOOST_CURRENT_FUNCTION << '\n';
10   }
11 };
12
13 int main()
14 {
15   boost::container::static_vector<ChattyInit, 10> myvector;
16   std::cout << "boost::container::static_vector initialized"
17             <<'\n';
18   while (myvector.size() < myvector.capacity()) {
19     myvector.push_back(ChattyInit());
20   }
21
22   // cisv.push_back(ChattyInit()); // runtime error
23 }

我们构造了一个ChattyInit对象的static_vectorChattyInit是一个简单的类,其构造函数打印自己的名称。static_vector的固定大小被指定为一个数字模板参数(第 15 行)。运行上述代码在我的 GNU Linux 系统上使用 g++ 4.9 编译器打印如下内容:

boost::container::static_vector initialized
ChattyInit::ChattyInit()
ChattyInit::ChattyInit()
… 8 more lines …

我们可以看到,在static_vector初始化的过程中没有创建任何对象,只有在追加时才实例化单个对象。我们确保插入的元素总数不超过容器的容量(第 18 行)。由于static_vector的元素默认情况下不进行值初始化,因此当没有显式添加元素时,size成员函数返回零。与std::vector相比:

std::vector<ChattyInit> myvector(10); // 10 elems value-inited
assert(myvector.size() == 10);

如果我们实际上尝试添加一个太多的元素(第 22 行),程序将崩溃。boost::container::static_vector是一个有用的容器,如果您正在寻找一个快速的、大小受限的vector替代品。

使用 Boost 无序容器进行快速查找

C++03 中的四个标准关联容器:std::setstd::mapstd::multisetstd::multimap都是有序容器,它们使用平衡二叉搜索树以某种排序顺序存储它们的键。它们要求为它们的键定义一个排序关系,并提供对数复杂度的插入和查找。给定排序关系和两个键 A 和 B,我们可以确定 A 是在 B 之前还是 B 在 A 之前。如果两者都不在对方之前,键 A 和 B 被称为等价;这并不意味着 A 和 B 相等。事实上,有序容器对相等是不关心的,甚至根本不需要定义相等的概念。这就是为什么这样的关系被称为严格弱序

考虑以下示例:

 1 #include <string>
 2 #include <tuple>
 3 
 4 struct Person  {
 5   std::string name;
 6   int age;
 7   std::string profession;
 8   std::string nationality;
 9 };
10
11 bool operator < (const Person& p1, const Person& p2)
12 {
13   return std::tie(p1.nationality, p1.name, p1.age)
14          < std::tie(p2.nationality, p2.name, p2.age);
15 }

我们定义了一个类型Person,代表一个人,使用字段nameageprofessionnationality(第 3-9 行),然后使用operator<定义了一个不考虑profession字段的排序关系(第 11-15 行)。这允许对Person对象进行排序,但不能进行相等比较。如果!(p1 < p2)!(p2 < p1)都成立,那么两个Person对象p1p2将被视为等价。这对于任何具有相同nameagenationalityPerson对象都是成立的,而不考虑它们的profession。有序容器std::set不允许具有相等键的多个键,而std::multiset允许。同样,std::map不允许具有等价键的多个键值对,而std::multimap允许。因此,向std::map添加一个已经包含等价键的键值对会覆盖旧值。

有序容器使用一种称为红黑树的平衡二叉搜索树进行实现,具有多种优化。除了能够在对数时间内查找和插入键之外,它们还提供了一个关键的功能——对容器中的键进行有序遍历。然而,如果您不需要有序遍历,那么有更高效的替代方案可用——哈希表是最明显的选择。哈希表的适当实现支持平均常数时间查找和摊销常数时间插入,这比有序容器具有更好的缓存性能,并具有略高的空间开销。

Boost Unordered 库引入了四个基于哈希表的有序容器的对应物:boost::unordered_setboost::unordered_mapboost::unordered_multisetboost::unordered_multimap,它们在 2007 年成为 C++ TR1 版本的一部分,并在 C++11 标准库中包含。当然,您可以在 C++03 编译器中使用 Boost Unordered。

无序容器需要为其存储的对象定义相等的概念,但不需要排序的概念。因此,对于无序容器,等价性是根据相等性而不是排序来定义的。此外,无序容器需要一种方法来计算每个键的哈希值,以确定键存储在表中的位置。在接下来的代码示例中,我们将看到如何使用无序容器和计算对象的哈希值,重用我们之前介绍的Person类型。

清单 5.11:使用 unordered_sets

 1 #include <boost/unordered_set.hpp>
 2 #include <boost/functional/hash.hpp>
 3 #include <iostream>
 4 #include <cassert>
 5 #include "Person.h" // struct Person definition
 6
 7 bool operator==(const Person& left, const Person& right){
 8   return (left.name == right.name
 9          && left.age == right.age
10          && left.profession == right.profession
11          && left.nationality == right.nationality);
12 }
13
14 namespace boost
15 {
16   template <>
17   struct hash<Person>
18   {
19     size_t operator()(const Person& person) const{
20       size_t hash = 0;
21       boost::hash_combine(hash, 
22                          boost::hash_value(person.name)); 
23       boost::hash_combine(hash, 
24                        boost::hash_value(person.nationality)); 
25       return hash;
26     }
27   };
28 }
29
30 int main() {
31   boost::unordered_set<Person> persons;
32
33   Person p{"Ned Land", 40, "Harpooner","Canada"};
34   persons.insert(p); // succeeds
35
36   Person p1{"Ned Land", 32, "C++ Programmer","Canada"};
37   persons.insert(p1);  // succeeds
38
39   assert(persons.find(p) != persons.end());
40   assert(persons.find(p1) != persons.end());
41
42   Person p2 = p;
43   persons.insert(p2);   // fails
44   assert(persons.size() == 2);
45 }

前面的示例展示了如何使用unordered_set来存储我们在前面清单中定义的用户定义类型Person的对象。我们定义了一个Person对象的unordered_set(第 31 行),创建了两个Person对象pp1,并将它们插入名为Personsunordered_set(第 34、37 行)。我们定义了第三个Person对象p2,它是p的副本,并尝试插入此元素,但失败了(第 43 行)。容器作为一个集合(unordered_set)包含唯一元素。由于p2p的副本并且等于它,它的插入失败了。

unordered_set有几种计算其存储的每个对象的哈希值的方法。我们演示其中一种方法:打开boost命名空间(第 14 行),为所讨论的Person类型定义boost::hash函数模板的特化(第 21-24 行)。为了计算Person对象的哈希值,我们只考虑它的两个字段:namenationality。我们使用实用函数boost::hash_valueboost::hash_combine(生成各个字段的哈希值并将它们组合)。由于我们在确定Person对象的哈希值时只考虑一个人的姓名和国籍,因此代表具有相同姓名和国籍的个人的对象pp1最终具有相同的哈希值。但是,它们并不相等,因为它们的其他字段不同,因此两个对象都成功添加到集合中。另一方面,对象p2p的副本,当我们尝试将p2插入persons集合时,插入失败,因为集合不包含重复项,而p2p的重复项。boost::unordered_multisetboost::unordered_multimap容器是可以存储重复对象的基于哈希的容器。

计算好的哈希值对于确保对象在哈希表中分布良好非常重要。虽然boost::hash_valueboost::hash_combine实用函数模板有助于计算更复杂对象的哈希值,但它们的滥用可能导致低效的哈希算法。对于用户定义的类型,在许多情况下,最好使用经过数学验证的哈希算法,以利用用户定义类型的语义。如果您在unordered_setunordered_map中使用原语或标准类型如std::string作为键,则无需自己编写哈希函数,因为boost::hash已经做得很好。

查找值通常使用无序关联容器的findcount成员函数进行。find返回指向容器中存储的实际对象的迭代器,对应于传递的键,而count只返回键的出现次数。unordered_multisetunordered_multimapequal_range成员函数返回匹配对象的范围。对于unordered_setunordered_map,count 成员函数永远不会返回大于 1 的值。

动态分配对象的容器

面向对象编程在很大程度上依赖于使用多态基类引用来操作整个类层次结构的对象。往往,这些对象是动态分配的。当处理这样一整套对象时,STL 容器就显得力不从心;它们只能存储单一类型的具体对象,并需要复制或移动语义。不可能定义一个可以存储跨类层次结构的不同类对象的单一容器。虽然可以在容器中存储多态基类指针,但指针被视为 POD 类型,并且对于深复制语义几乎没有支持。动态分配对象的生命周期与 STL 无关。但是,定义一个需要单独管理指针生命周期的容器是笨拙的,而且没有任何来自容器的帮助。

Boost 指针容器库通过存储指向动态分配对象的指针并在容器生命周期结束时释放它们来解决这些问题。指针容器提供了一个接口,通过它可以操作底层对象,而无需进行指针间接。由于它们存储对象的指针,这些容器自然支持多态容器,无需任何额外的机制。

以下表格显示了指针容器及其标准库对应物:

Boost 的指针容器 标准库容器
boost::ptr_array std::array
boost::ptr_vector std::vector
boost::ptr_deque std::deque
boost::ptr_list std::list
boost::ptr_set / boost::ptr_multiset std::set / std::multiset
boost::ptr_unordered_set / boost::ptr_unordered_multiset std::unordered_set / std::unordered_multiset
boost::ptr_map / boost::ptr_multimap std::map / std::multimap
boost::ptr_unordered_map / boost::ptr_unordered_multimap std::unordered_map / std::unordered_multimap

Boost 为所有标准容器定义了指针容器的等价物。这些容器可用于存储多态指针,存储指针指向的底层对象不需要可复制或可移动。以下是一个基本示例以便开始:

清单 5.12:使用 Boost 指针容器

1 #include <boost/ptr_container/ptr_vector.hpp>
 2 #include <boost/noncopyable.hpp>
 3 #include <iostream>
 4 #include <boost/current_function.hpp>
 5
 6 class AbstractJob {
 7 public:
 8   virtual ~AbstractJob() {}
 9
10   void doJob() {
11     doStep1();
12     doStep2();
13   }
14
15 private:
16   virtual void doStep1() = 0;
17   virtual void doStep2() = 0;
18 };
19
20 class JobA : public AbstractJob
21 {
22   void doStep1() override {
23     std::cout << BOOST_CURRENT_FUNCTION << '\n';
24   }
25
26   void doStep2() override {
27     std::cout << BOOST_CURRENT_FUNCTION << '\n';
28   }
29 };
30
31 class JobB : public AbstractJob
32 {
33   void doStep1() override {
34     std::cout << BOOST_CURRENT_FUNCTION << '\n';
35   }
36
37   void doStep2() override {
38     std::cout << BOOST_CURRENT_FUNCTION << '\n';
39   }
40 };
41
42 int main()
43 {
44   boost::ptr_vector<AbstractJob> basePtrVec;
45
46   basePtrVec.push_back(new JobA);
47   basePtrVec.push_back(new JobB);
48
49   AbstractJob& firstJob = basePtrVec.front();
50   AbstractJob& lastJob = basePtrVec.back();
51
52   for (auto& job : basePtrVec) {
53     job.doJob();
54   }
55 }

在前面的例子中,AbstractJob是一个抽象基类(第 5 行),它定义了两个私有纯虚函数doStep1doStep2(第 16、17 行),以及一个非虚公共函数doJob,该函数调用这两个函数(第 10 行)。JobAJobBAbstractJob的两个具体实现,它们实现了虚函数doStep1doStep2。函数签名后面的override关键字(第 22、26、33 和 37 行)是 C++11 的一个特性,它澄清了特定函数覆盖了基类中的虚函数。在主函数中,我们创建了一个ptr_vectorAbstractJobs。注意模板参数不是指针类型(第 44 行)。然后我们将两个JobAJobB的具体实例附加到向量中(第 46 和 47 行)。我们使用front(第 49 行)和back(第 50 行)成员函数访问向量中的第一个和最后一个元素,它们都返回对底层对象的引用,而不是它们的指针。最后,我们在一个基于范围的 for 循环中读取存储的对象(第 52 行)。循环变量job声明为引用(auto&),而不是指针。指针容器的成员函数和迭代器返回的是对存储指针的引用,而不是它们指向的底层对象,提供了语法上的便利。

虽然基于范围的 for 循环和BOOST_FOREACH使得遍历集合变得容易,但如果需要,也可以直接使用迭代器接口:

49   typedef boost::ptr_vector<AbstractJob>::iterator iter_t;
50 
51   for (iter_t it = basePtrVec.begin(); 
52        it != basePtrVec.end(); ++it) {
53     AbstractJob& job = *it;
54     job.do();
55   }

再次注意,迭代器返回的是对底层对象的引用,而不是指针(第 53 行),即使容器存储的是指针。变量job是一个引用,因为AbstractJob是抽象的,不能被实例化。但是如果基类不是抽象的呢?考虑一个非抽象多态基类的以下示例:

清单 5.13:可复制的具体基类的陷阱

 1 struct ConcreteBase
 2 {
 3   virtual void doWork() {}
 4 };
 5
 6 struct Derived1 : public ConcreteBase
 7 {
 8   Derived1(int n) : data(n) {}
 9   void doWork() override { std::cout <<data <<"\n"; }
10   int data;
11 };
12
13 struct Derived2 : public ConcreteBase
14 {
15   Derived2(int n) : data(n) {}
16   void doWork() override { std::cout <<data << "\n"; }
17   int data;
18 };
19
20 int main()
21 {
22   boost::ptr_vector<ConcreteBase> vec;
23   typedef boost::ptr_vector<ConcreteBase>::iterator iter_t;
24                                                     
25   vec.push_back(new Derived1(1));
26   vec.push_back(new Derived2(2));
27
28   for (iter_t it = vec.begin(); it != vec.end(); ++it) {
29     ConcreteBase obj = *it;
30     obj.doWork();
31   }
32 }

前面的代码编译干净,但可能不会产生您期望的结果。在 for 循环的主体中,我们将派生类的每个对象分配给一个基类实例(第 29 行)。ConcreteBase的复制构造函数生效,我们得到的是一个切片对象和不正确的行为。

因此,最好通过将基类本身从boost::noncopyable派生出来来防止在一开始就进行复制,如下所示:

 1 #include <boost/noncopyable.hpp>
 2 
 3 class ConcreteBase : public boost::noncopyable

这将防止由于无意的复制而导致切片,从而使这样的代码被标记为编译错误。请注意,这将使层次结构中的所有类都不可复制。我们将在下一节中探讨如何向这样的层次结构添加复制语义。但在此之前,让我们看一下使用关联指针容器的方法。

我们可以在boost::ptr_setboost::ptr_multiset中存储动态分配的对象,包括多态对象。由于这些是有序容器,我们必须为容器中存储的值类型定义一个严格的弱排序关系。通常通过为该类型定义bool operator<来完成。如果您存储类层次结构的多态指针对象,您必须为层次结构中的所有对象定义一个排序关系,而不仅仅是特定具体类型的对象之间:

清单 5.14:使用关联指针容器 - ptr_set

 1 #include <boost/ptr_container/ptr_set.hpp>
 2 #include <boost/noncopyable.hpp>
 3 #include <string>
 4 #include <iostream>
 5 
 6 class Animal : boost::noncopyable
 7 {
 8 public:
 9   virtual ~Animal()
10   {};
11 
12   virtual std::string name() const = 0;
13 };
14 
15 class SnowLeopard : public Animal
16 {
17 public:
18   SnowLeopard(const std::string& name) : name_(name) {}
19 
20   virtual ~SnowLeopard() { std::cout << "~SnowLeopard\n"; }
21 
22   std::string name() const override
23   {
24     return name_ + ", the snow leopard";
25   }
26 
27 private:
28   std::string name_;
29 };
30 
31 class Puma : public Animal
32 {
33 public:
34   Puma(const std::string& name) : name_(name) {}
35   virtual ~Puma() { std::cout << "~Puma\n"; }
36 
37   virtual std::string name() const
38   {
39     return name_ + ", the puma";
40   }
41 
42 private:
43   std::string name_;
44 };
45 
46 bool operator<(const Animal& left, const Animal& right)
47 {
48   return left.name() < right.name();
49 }
50 
51 int main()
52 {
53   boost::ptr_set<Animal>animals;
54   animals.insert(new Puma("Kaju"));
55   animals.insert(new SnowLeopard("Rongi"));
56   animals.insert(new Puma("Juki"));
57 
58   for (auto&animal :animals) {
59     std::cout <<animal.name() << '\n';
60   }
61 }

这显示了使用“std :: ptr_set”存储动态分配对象的多态指针。 Animal抽象基类声明了一个纯虚函数name。两个派生类,SnowLeopardPuma,(代表两种真实的哺乳动物物种)对它们进行了覆盖。我们定义了一个名为animalsAnimal指针的ptr_set(第 53 行)。我们创建了两只名为KajuJuki的美洲狮和一只名为Rongi的雪豹,并将它们插入到集合animals中(第 54-56 行)。当我们遍历列表时,我们得到的是动态分配对象的引用,而不是指针(第 58, 59 行)。 “operator <”(第 46 行)比较任何两个动物,并按名称按字典顺序排序。如果没有此运算符,我们将无法定义ptr_set。以下是前述代码的输出:

Juki, the puma
Kaju, the puma
Rongi, the snow leopard
~Puma
~Puma
~SnowLeopard

前三行列出了三种动物,然后调用每个对象的析构函数并打印其身份,因为ptr_set容器实例超出范围。

关联指针容器的另一个常见用途是在映射或多重映射中存储多态对象:

清单 5.15:使用关联指针容器

 1 #include <boost/ptr_container/ptr_map.hpp>
 2 #include <iostream>
 3 // include definitions of Animal, SnowLeopard, Puma
 4 
 5 int main() {
 6   boost::ptr_multimap<std::string, Animal> animals;
 7   std::string kj = "Puma";
 8   std::string br = "Snow Leopard";
 9 
10   animals.insert(kj, new Puma("Kaju"));
11   animals.insert(br, new SnowLeopard("Rongi"));
12   animals.insert(kj, new Puma("Juki"));
13 
14   for (const auto&entry : animals) {
15     std::cout << "[" << entry.first << "]->" 
16               << entry.second->name() << '\n';
17   }
18 }

我们创建了一个名为animals的多重映射(第 6 行),它将物种名称作为键的类型为“std :: string”,并为每个键存储一个或多个该物种的动物的多态指针(第 10-12 行)。我们使用了与清单 5.14 中使用的相同的Animal层次结构。我们循环遍历多重映射中的所有条目,打印物种的名称,然后是特定动物的给定名称。以下是输出:

[SnowLeopard]->Rongi, the snow leopard
[Puma]->Kaju, the puma
[Puma]->Juki, the puma

每个Animal条目都是类型为“std :: pair <std :: string,Animal *>”的,因此可以使用成员firstsecond访问键和值。请注意,entry.second返回存储的指针,而不是底层对象的引用(第 16 行)。

指针容器的所有权语义

我们已经看到指针容器“拥有”我们存储在其中的动态分配对象,即容器负责在其生命周期结束时对它们进行解除分配。对象本身既不需要支持复制语义也不需要支持移动语义,因此很自然地想知道复制指针容器意味着什么。实际上,指针容器是可复制的,并支持简单的复制语义 - 在复制构造或复制分配指针容器时,它会动态分配源容器中每个对象的副本并存储指向该对象的指针。这对于任何既不是 POD 类型也没有复制构造函数的非多态类型都可以正常工作。对于多态类型,此行为会导致切片或在基类为抽象或不可复制时无法编译。为了创建具有多态对象的容器的深层副本,对象必须支持克隆接口。

为了支持在命名空间X中创建多态类型T的对象的副本,必须在命名空间X中定义具有以下签名的自由函数:

1 namespace X {
2   // definition of T
3   ...
4 
5   T* new_clone(const T& obj);
6 }

通过参数相关查找ADL)找到函数new_clone,并且预期返回传递给它的对象obj的副本,其运行时类型应与obj的相同。我们可以扩展动物示例;我们可以通过在每个Animal的子类中定义一个被覆盖的clone虚函数来实现这一点,以返回对象的副本。然后,new_clone自由函数只需调用传递对象上的克隆函数并返回克隆的指针:

清单 5.16:使对象和指针容器可克隆

1 #include <boost/ptr_container/ptr_vector.hpp>
 2 #include <boost/noncopyable.hpp>
 3 #include <string>
 4 #include <iostream>
 5 
 6 namespace nature
 7 {
 8 
 9 class Animal : boost::noncopyable
10 {
11 public:
12   // ...
13   virtual Animal *clone() const = 0;
14 };
15 
16 class SnowLeopard : public Animal
17 {
18 public:
19   // ...
20   SnowLeopard *clone() const override
21   {
22     return new SnowLeopard(name_);
23   }
24 
25 private:
26   std::string name_;
27 };
28 
29 class Puma : public Animal
30 {
31 public:
32   // ...
33   Puma *clone() const override
34   {
35     return new Puma(name_);
36   }
37 
38 private:
39   std::string name_;
40 };
41 
42 Animal *new_clone(const Animal& animal)
43 {
44   return animal.clone();
45 }
46 
47 } // end of namespace nature
48 
49 int main()
50 {
51   boost::ptr_vector<nature::Animal> animals, animals2;
52 
53   animals.push_back(new nature::Puma("Kaju"));
54   animals.push_back(new nature::SnowLeopard("Rongi"));
55   animals.push_back(new nature::Puma("Juki"));
56 
57   animals2 = animals.clone();
58 
59   for (auto&animal : animals2) {
60     std::cout <<animal.name() << '\n';
61   }
62 }

为了完全通用,我们将Animal及其派生类放入名为nature的命名空间(第 6 行),并在Animal中添加一个名为clone的纯虚函数(第 13 行)。我们在两个派生类中重写了clone方法(第 33、42 行),并根据clone方法实现了new_clone自由函数。我们声明了两个nature::Animal指针的ptr_vector容器:animalsanimals2(第 51 行),用三个毛茸茸的哺乳动物初始化了animals(第 53-55 行),最后,将animals的克隆分配给animals2(第 57 行)。如果我们不调用clone,而是写如下代码会怎样:

57   animals2 = animals;

在这种情况下,该行将无法编译,因为Animal是抽象且不可复制的,前一行将尝试对animals中的每个存储对象进行切片并将其复制到animals2中。如果Animal是可复制且非抽象的,这样的行将编译通过,但animals2将包含一些不幸的切片Animals

指针容器支持将对象的所有权从一个容器移动到另一个容器,即使这些容器的类型不同。您可以移动单个元素、一系列元素或一个容器的整个内容到另一个容器,这些操作类似于标准库std::list中的slice。以下示例说明了其中一些技术:

清单 5.17:在容器之间移动指针

 1 #include <boost/ptr_container/ptr_vector.hpp>
 2 #include <boost/ptr_container/ptr_list.hpp>
 3 #include <cassert>
 4 #include <iostream>
 5 // definitions of Animal, SnowLeopard, Puma in namespace nature 
 6 
 7 int main()
 8 {
 9   boost::ptr_vector<nature::Animal> mountA;
10   boost::ptr_vector<nature::Animal> mountB;
11   boost::ptr_list<nature::Animal> mountC;
12 
13   mountA.push_back(new nature::Puma("Kaju"));
14   mountA.push_back(new nature::SnowLeopard("Rongi"));
15   mountA.push_back(new nature::Puma("Juki"));
16   mountA.push_back(new nature::SnowLeopard("Turo"));
17 
18   size_t num_animals = mountA.size();
19 
20   for (auto&animal : mountA) {
21     std::cout << "MountA: " <<animal.name() << '\n';
22   }
23 
24   // Move all contents
25   mountB = mountA.release();
26   assert(mountA.size() == 0);
27   assert(mountB.size() == num_animals);
28 
29   // move one element
30   mountC.transfer(mountC.begin(), mountB.begin() + 1, mountB);
31   assert(mountB.size() == num_animals - 1);
32   assert(mountC.size() == 1);
33 
34   // move one element, second way
35   auto popped = mountB.pop_back();
36   mountC.push_back(popped.release());
37 
38   assert(mountB.size() + mountC.size() == num_animals);
39   assert(mountC.size() == 2);
40 
41   // move a range of elements
42   mountC.transfer(mountC.end(), mountB.begin(),
43                   mountB.end(), mountB);
44   assert(mountB.size() + mountC.size() == num_animals);
45   assert(mountC.size() == num_animals);
46 
47   for (auto&animal : mountC) {
48     std::cout << "MountC: " <<animal.name() << '\n';
49   }
50 }

上述示例说明了从一个容器移动元素到另一个容器的所有不同技术。两只PumaKajuJuki)和两只SnowLeopardRongiTuro)在 A 山上,因此向量mountA存储了 A 山上的动物。这四只动物决定搬到 B 山;向量mountB一开始是空的。然后,这四只Animals搬到了 B 山,因此我们使用mountArelease方法将mountA的内容移动到mountB(第 25 行)。在此之后,mountA中没有更多的Animals(第 26 行),而mountB包含了全部四只(第 27 行)。现在动物们想要过到 C 山,这是一种不同类型的山,很难攀登。C 山上的动物在名为mountCptr_list中被跟踪(而不是ptr_vector)。一开始,雪豹RongimountB中的第二个元素)带头攀登并成为第一个到达 C 山的动物。因此,我们使用mountCtransfer成员函数将mountB的第二个元素移动到mountC的开头(第 30 行)。接下来,另一只雪豹Turo冒险过到 C 山。我们首先从mountB的末尾弹出mountB的最后一个元素(第 35 行),然后在popped对象上调用release,并将返回的指针附加到mountC(第 36 行)。此时,mountB上还有两只Animals(第 39 行)。剩下的元素(两只美洲狮)通过调用mountCtransfer成员函数(第 42、43 行)从mountB移动到mountC的末尾,从而完成了动物的迁徙(第 45 行)。

transfer的第一个参数是标识目标容器中插入移动元素位置的迭代器。在三参数重载(第 30 行),第二个参数标识源容器中需要移动的元素的迭代器,第三个参数是源容器的引用。在四参数重载中,第二和第三个参数标识需要移动的源容器元素范围,第四个参数是源容器的引用。

如果您使用的是 C++11 之前的版本,您无法使用auto关键字来摆脱您不关心的类型名称(第 35 行)。在这种情况下,您需要将pop_back()的结果(或其他从容器中移除并返回元素的方法)存储在类型为container::auto_type的变量中。例如:

33   boost::ptr_vector<nature::Animal>::auto_type popped = 
34                                           mountB.pop_back();

指针容器中的空指针

考虑到指针容器存储指针并提供对基础对象的引用,如果存储空指针会发生什么?默认情况下,指针容器不允许空指针,并且尝试存储空指针将在运行时引发异常。您可以覆盖此行为并告诉编译器允许存储空指针。要做到这一点,您必须稍微修改容器定义,使用:

boost::ptr_container<boost::nullable<Animal>> animals;

而不是:

boost::ptr_container< Animal> animals;

优点有限,并且您还必须确保不解引用潜在的空指针。您的代码变得复杂,使用基于范围的 for 循环变得困难。这里是一个例子:

 1 std::ptr_vector< boost::nullable<Animal>> animalsAndNulls;
 2 ... // assign animals
 3
 4 for (auto it = animalsAndNulls.begin();
 5 it != animalsAndNulls.end(); ++it)
 6 {
 7    if (!boost::is_null(it)) {
 8      Animal& a = *it;
 9      // do stuff ...
10    }
11 }

最好避免存储空指针,而是使用库作者建议的 Null Object Pattern。您可以查看 Boost 在线文档,了解有关 Null Object Pattern 的更多详细信息(www.boost.org/doc/libs/1_57_0/libs/ptr_container/doc/guidelines.html#avoid-null-pointers-in-containers-if-possible)。

总之,Boost 指针容器是一组完整的用于指向动态分配对象的指针的容器,并且非常适合处理多态对象。在 C++11 中,实现类似语义的另一种方法是使用std::unique_ptr<T>的容器。通过充分优化,unique_ptr包装器的开销可能很小,并且性能与 Boost 的指针容器相当。虽然使用boost::shared_ptr<T>T为动态分配对象的类型)的容器适用于这里描述的用例,但它们具有更高的内存和运行时开销,除非需要共享所有权语义,否则不是最佳选择。

使用 Boost.Assign 进行表达式初始化和赋值

使用单个语句初始化对象或将一些文字值分配给它是生成对象内容的简洁方式。对于简单变量(如数字变量或字符串),这很容易做到,因为有现成的文字。另一方面,没有简单的语法方式来使用任意一组值初始化容器。这是因为使用文字表达更复杂的具有非平凡内部数据结构的对象是困难的。使用一些巧妙的模式和重载的运算符,Boost.Assign 库使得可以使用非常表达式的语法初始化和分配值给大量 STL 和 Boost 容器。

有了 C++11 中新的初始化列表统一初始化语法,这些任务可以在没有 Boost.Assign 的情况下完成。但是 Boost.Assign 是在 C++11 之前完成工作的唯一方法,并且还提供了一些通过初始化列表和统一初始化不容易获得的额外功能。

将值列表分配给容器

Boost.Assign 是 Boost 中那些巧妙的小库之一,您会在最小的机会中习惯使用它。这里是一个例子:

清单 5.18:将值列表分配给向量

 1 #include <string>
 2 #include <vector>
 3 #include <boost/assign.hpp>
 4 #include <cassert>
 5
 6 using namespace boost::assign;
 7
 8 int main()
 9 {
10   std::vector<std::string>greetings;
11   greetings += "Good morning", "Buenos dias", "Bongiorno";
12   greetings += "Boker tov", "Guten Morgen", "Bonjour";
13
14   assert(greetings.size() == 6);
15 }

向向量分配值列表从未像使用 Boost.Assign 那样有趣。通过重载逗号操作符(operator,)和operator+=,Boost Assign 库提供了一种简单的方法来向向量追加值列表。为了使用这些操作符,我们包含boost/assign.hpp(第 3 行)。using namespace指令使得在全局范围内可以使用 Boost Assign 中定义的操作符(第 6 行)。如果没有这个,我们将无法自由地使用这些操作符,表达能力也会消失。我们向向量greetings追加了三个英语、法语和意大利语的“早上好”问候语(第 11 行),然后又追加了三个希伯来语、德语和法语的问候语(第 12 行)。最终效果是一个包含六个字符串的向量(第 14 行)。我们也可以用 deque 替换向量,这样也能正常工作。如果你想要另一种插入模式,比如在列表或 deque 的头部插入,或者插入到地图中,Boost Assign 也可以满足你。这里还有一个例子:

清单 5.19:向其他容器分配元素

 1 #include <string>
 2 #include <map>
 3 #include <list>
 4 #include <deque>
 5 #include <boost/assign.hpp>
 6 #include <iostream>
 7 #include <boost/tuple/tuple.hpp>
 8
 9 using namespace boost::assign;
10
11 int main(){
12   std::deque<std::string>greets;
13   push_front(greets) = "Good night", "Buenas noches", 
14       "Bounanotte", "Lyla tov", "Gute nacht", "Bonne nuit";
15
16   std::map<std::string, std::string> rockCharacters;
17   insert(rockCharacters)
18         ("John Barleycorn", "must die")       // Traffic
19         ("Eleanor Rigby", "lives in a dream") // Beatles
20         ("Arnold Layne", "had a strange hobby")   // Floyd
21         ("Angie", "can't say we never tried")    // Stones
22         ("Harry", "play the honkytonk"); // Dire Straits
23
24   std::list<boost::tuple<std::string, std::string, 
25                         std::string>> trios;
25   push_back(trios)("Athos", "Porthos", "Aramis")
26                   ("Potter", "Weasley", "Granger")
27                   ("Tintin", "Snowy", "Haddock")
28                   ("Geller", "Bing", "Tribbiani")
29                   ("Jones", "Crenshaw", "Andrews");
30
31   std::cout << "Night greets:\n";
32   for (const auto& greet: greets) {
33     std::cout << greet << '\n';
34   }
35
36   std::cout << "\nPeople:\n";
37   for (const auto&character: rockCharacters) {
38     std::cout << character.first << ": "
39               << character.second << '\n';
40   }
41
42   std::cout << "Trios:\n";
43   for (auto& trio: trios) {
44     std::cout << boost::get<0>(trio) << ", " 
45               << boost::get<1>(trio) << ", " 
46               << boost::get<2>(trio) << '\n';
47   }
48 }

在这里,我们看到了给三种不同类型的容器赋值的例子。我们首先将六个不同语言的“晚安”问候语推入std::deque的头部(第 13-14 行)。我们使用 Boost Assign 中的push_front适配器来执行同名方法push_front,将这些值推入greets的 deque 中。在这个操作之后,列表中的最后一个字符串("Bonne nuit")将位于队列的前面。

如果你对摇滚乐有兴趣,并且和我一样老,你可能会在下一个例子中识别出角色:一个std::map,其中包含摇滚乐歌曲和专辑中的角色,以及他们在歌曲中的行为。使用insert适配器,在rockCharacters地图上调用同名方法,我们插入了五对字符串,每对将一个角色映射到一个行为(第 17-22 行)。insert适配器和类似它的其他适配器返回一个重载了operator()的对象,可以进行链式调用。通过对这个操作符的调用进行链式调用,值列表被创建。

我们使用的最后一个容器是std::list,为了好玩,我们保留了一个虚构中著名的三人组的列表。boost::tuple模板可以用来定义任意数量的不同类型元素的元组。在这里,我们使用了三个字符串的boost::tuple来表示一个三人组,并将这样的三人组列表保存在变量trios中(第 24 行)。使用 Boost Assign 中的push_back适配器将这些三人组追加到列表的末尾。在清单 5.17 中与std::vector一起使用的+=操作符调用了基础容器的push_back。然而,在这种情况下,需要使用push_back适配器来允许将值元组推入列表。

接下来,我们打印数据结构的内容。为了访问列表trios中每个元组的每个元素,我们使用boost::get模板,通过基于 0 的索引访问元组中的元素(第 44-45 行)。运行这段代码会打印以下输出:

Night greets:
Bonne nuit
Gute nacht
Lyla tov
Bounanotte
Buenas noches
Good night
People:
Angie: can't say we never tried
Arnold Layne: had a strange hobby
Eleanor Rigby: lives in a dream
John Barleycorn: must die
Harry: play the honkytonk
People:
Athos,Porthos, Aramis
Potter,Weasley, Granger
Tintin,Snowy, Haddock
Jones,Crenshaw, Andrews

使用值列表初始化容器

在前面的例子中,我们看到了向容器追加或插入值的各种方法,但 Boost.Assign 还允许您在构造时使用值初始化容器。语法与用于赋值的语法略有不同:

清单 5.20:使用 Boost Assign 进行聚合初始化

 1 #include <boost/assign.hpp>
 2 #include <boost/rational.hpp>
 3 #include <iterator>
 4 
 5 using namespace boost::assign;
 6 
 7 int main()
 8 {
 9   std::cout << "Catalan numbers:\n";
10   const std::vector<int> catalan = list_of(1)(1)(2)(5)
11                        (14)(42) (132)(429)(1430)(4862);
12
13   std::ostream_iterator<int>os(std::cout, " ");
14   std::copy(catalan.begin(), catalan.end(), os);
15
16   std::cout << "\nBernoulli numbers:\n";
17   const std::map<int, boost::rational<int>>bernoulli = 
18                       map_list_of(0, boost::rational<int>(1))
19                             (1, boost::rational<int>(1, 2))
20                             (2, boost::rational<int>(1, 6))
21                             (3, boost::rational<int>(0))
22                             (4, boost::rational<int>(-1, 30))
23                             (5, boost::rational<int>(0))
24                             (6, boost::rational<int>(1, 42))
25                             (7, boost::rational<int>(0));
26
27   for (auto&b : bernoulli) {
28     std::cout << 'B' << b.first << ": " << b.second << ", ";
29   }
30   std::cout << '\n';
31 }

前面的例子构建了前十个卡特兰数的向量。第 n 个卡特兰数(n 为非负整数)等于包含 n 个左括号和 n 个右括号的字符串的排列数,其中所有括号都正确匹配。我们使用boost::assign命名空间中的list_of适配器来构造前十个卡特兰数的列表,并用它来初始化向量catalan(第 10-11 行)。我们使用ostream_iterator来打印这个列表(第 13-14 行)。

接下来,我们创建了一个包含前八个伯努利数的std::map:键是序数位置,值是数本身。伯努利数是一系列有理数(可以表示为两个整数的比值),在数论和组合数学中出现。为了初始化这样一个映射,我们使用map_list_of适配器传递键和值,如示例所示(第 17-25 行)。为了表示有理数,我们使用在头文件boost/rational.hpp中定义的boost::rational模板。

这段代码打印了以下输出:

Catalan numbers:
1 1 2 5 14 42 132 429 1430 4862
Bernoulli numbers:
B0: 1/1, B1: 1/2, B2: 1/6, B3: 0/1, B4: -1/30, B5: 0/1, B6: 1/42, B7: 0/1,

有趣的是,你也可以使用 Boost Assign 创建匿名序列。这些序列可以构造为非常量 l-value 引用的序列,也可以构造为可以接受字面值的 const l-value 引用的序列。它们比list_of更有效地构造,并且可以用于初始化向量等序列容器。这些序列符合 Boost Range 概念,并且可以在任何可以使用范围的地方使用。以下是一个例子:

清单 5.21:创建匿名序列

1 #include <boost/assign.hpp>
 2 #include <iostream>
 3 
 4 using namespace boost::assign;
 5 
 6 template<typename RangeType>
 7 int inspect_range(RangeType&& rng)
 8 {
 9   size_t sz = boost::size(rng);
10 
11   if (sz > 0) {
12     std::cout << "First elem: " << *boost::begin(rng) << '\n';
13     std::cout <<"Last elem: " << *(boost::end(rng) - 1) << '\n';
14   }
15 
16   return sz;
17 }
18 
19 int main()
20 {
21   std::cout << inspect_range(
22                  cref_list_of<10>(1)(2)(3)(4)(5)(6)(7)(8));
23 
24   typedef std::map<std::string, std::string> strmap_t;
25   strmap_t helloWorlds =
26          cref_list_of<3, strmap_t::value_type>
27             (strmap_t::value_type("hello", "world"))
28             (strmap_t::value_type("hola", "el mundo"))
29             (strmap_t::value_type("hallo", "Welt"));
30 }

我们使用cref_list_of适配器创建了一个大小为十的匿名序列,但实际上只放了八个值进去(第 22 行)。如果我们有变量要放进序列而不是字符字面值,我们可以使用ref_list_of适配器,这将创建一个可变序列。我们使用boost::sizeboost::beginboost::end函数来操作范围,确定序列的长度(第 9 行)以及它的第一个和最后一个元素(第 12-13 行)。接下来,我们使用一个字符串对的匿名列表来初始化一个std::map(第 26-29 行)。请注意,map中的value_type嵌套 typedef 表示地图中每个键值对的类型。

C++11 引入了非常方便的聚合初始化语法,使用这种语法可以初始化任意容器。使用聚合初始化程序语法进行初始化在语法上比 Boost Assign 更简单,并且可能更有效。在 C++11 之前的环境中,Boost Assign 的初始化语法仍然是唯一的选择。以下是一些 C++11 聚合初始化的例子:

 1 std::vector<std::string>scholars{"Ibn Sina", "Ibn Rushd",
 2                                   "Al Khwarizmi", "Al Kindi"};
 3std::map<std::string, std::string> scholarsFrom
 4={{scholars[0], "Bukhara"},
 5      {scholars[1], "Cordoba"},
 6{scholars[2], "Khwarezm"},
 7                             {scholars[3], "Basra"}};

这个片段展示了使用花括号括起来的逗号分隔的值列表来初始化集合的方法。scholars向量用中世纪四位穆斯林学者的名字初始化,然后scholarsFrom映射用这些学者的名字作为键,他们的出生地作为值进行初始化。请注意,每个键值对都被括在花括号中,以逗号分隔的这样的对列表中。另外,请注意我们在初始化器中自由使用 l-values(比如scholars[0])以及字面值。

初始化指针容器并分配值

Boost Assign 库提供了特殊支持,以一种异常安全的方式为指针容器分配值和初始化指针容器。

以下简短的例子总结了用法:

清单 5.22:使用指针容器的 Boost Assign

 1 #include <boost/ptr_container/ptr_vector.hpp>
 2 #include <boost/ptr_container/ptr_map.hpp>
 3 #include <boost/assign/ptr_list_inserter.hpp>
 4 #include <boost/assign/ptr_map_inserter.hpp>
 5 #include <boost/assign/ptr_list_of.hpp>
 6 #include <string>
 7 #include <iostream>
 8 
 9 using namespace boost::assign;
10 
11 struct WorkShift
12 {
13   WorkShift(double start = 9.30, double end = 17.30)
14     : start_(start), end_(end)
15   {}
16 
17   double start_, end_;
18 };
19 
20 std::ostream& operator<<(std::ostream& os, const WorkShift& ws)
21 {
22   return os << "[" << ws.start_ <<" till " << ws.end_ << "]";
23 }
24 
25 int main()
26 {
27   boost::ptr_vector<WorkShift> shifts = ptr_list_of<WorkShift>
28                               (6.00, 14.00)();
29   ptr_push_back(shifts)(14.00, 22.00)(22.00, 6.00);
30 
31   boost::ptr_map<std::string, WorkShift> shiftMap;
32   ptr_map_insert(shiftMap)("morning", 6.00, 14.00)("day")
33             ("afternoon", 14.00, 22.00)("night", 22.00, 6.00);
34 
35   for (const auto& entry: shiftMap) {
36     std::cout << entry.first <<" " <<shiftMap.at(entry.first)
37               << '\n';
38   }
39 }

在这个例子中,我们定义了一个类型WorkShift,表示工作场所的一个班次,并封装了关于特定班次工作时间的信息。它的构造函数接受两个参数,班次的开始和结束时间,并将它们默认为 9.30 和 17.30(第 12 行)。我们创建了一个ptr_vectorWorkShift对象,并使用ptr_list_of适配器对它们进行初始化。我们不是传递构造好的对象,而是传递两个对象的构造函数参数:一个班次在 6.00 到 14.00 之间,另一个班次有默认的开始和结束时间(第 28 行)。

ptr_list_of的模板参数表示要实例化的类型。我们使用ptr_push_back适配器向ptr_vector添加了两个更多的 shifts。接下来,我们创建了一个名为shiftMapptr_map,其中包含字符串键,用于标识 shifts 的类型和指向值的 shift 对象(第 31 行)。然后,我们使用ptr_map_insert适配器将元素插入到地图中。我们通过调用operator()创建每个条目,将字符串键作为第一个参数传递,将WorkShift对象的构造函数参数作为剩余参数(第 32-33 行)。我们打印ptr_map的内容(第 35-38 行),使用WorkShift的重载流操作符(第 19 行)。以下是此程序的输出:

afternoon [14 till 22]
general [9.3 till 17.3]
morning [6 till 14]
night [22 till 6]

重要的是要理解为什么为初始化指针容器使用了一个单独的适配器类。例如,以下是一个完全有效的代码:

 1 boost::ptr_vector<WorkShift> shifts;
 2 boost::assign:push_back(shifts)(new WorkShift())
 3                              (new WorkShift(6.00, 14.00));

然而,在此示例中,库的用户(也就是我们)手动分配了两个新的WorkShift对象。这些分配的顺序不能由编译器保证。只有通过对boost::assign::push_back返回的适配器调用来保证它们附加到shifts的顺序。因此,对于前面的示例,编译器可能生成大致等效于以下内容的代码:

 1 boost::ptr_vector<WorkShift> shifts;
 2 WorkShift *w1 = new WorkShift(6.00, 14.00);
 3 WorkShift *w2 = new WorkShift();
 4 boost::assign::push_back(shifts)(w2)(w1);

如果在构造w2WorkShift的构造函数抛出异常(第 3 行),那么w1将会泄漏。为了确保异常安全,我们应该使用ptr_push_back

1 boost::ptr_vector<WorkShift> shifts;
2 boost::assign::ptr_push_back(shifts)()(6.00, 14.00);

相反,在boost::assign::ptr_push_back适配器中重载的operator()接受需要在shifts容器中的每个WorkShift对象的构造函数参数,并构造每个WorkShift对象,将这些参数转发到WorkShift构造函数。调用仅在构造对象后才返回到容器中。这确保在构造WorkShift对象时,所有先前构造的WorkShift对象已经是容器的一部分。因此,如果构造函数抛出异常,容器以及先前构造的对象都将被释放。

使用 Boost.Iterator 进行迭代模式

迭代是大多数编程问题中的基本任务,无论是遍历容器的元素,一系列自然数,还是目录中的文件。通过抽象化对值集合的迭代方式,我们可以编写通用代码来处理这样的集合,而不依赖于每个集合特定迭代方法。

标准库容器为此目的公开了迭代器,并且标准库中的通用算法可以通过其迭代器操作任何符合条件的容器,而不依赖于容器的特定类型或其内部结构。

Boost.Iterator 库提供了一个框架,用于为符合标准并与标准库中的算法兼容的自定义类编写迭代器。它还有助于将迭代概念推广到更抽象的对象集合,而不仅限于容器。

使用 Boost.Iterator 进行智能迭代

Boost Iterator 库提供了许多迭代器适配器,使得在容器和值序列上进行迭代更加表达和高效。迭代器适配器包装一个迭代器以产生另一个迭代器。适配的迭代器可能会或可能不会遍历底层迭代器所寻址的元素的整个范围。此外,它们可以被设计为返回不同的值,可能是不同类型的值,而不是底层迭代器。在本节中,我们将看一些来自 Boost 的此类迭代器适配器的示例。

过滤迭代器

过滤迭代器遍历基础元素序列的子序列。它们包装一个基础迭代器序列,并采用一元布尔谓词,用于确定从基础范围中包括哪些元素,跳过哪些元素。谓词将基础序列的一个元素作为单个参数,并返回 true 或 false。返回 true 的元素包括在迭代中,其余被过滤掉;因此得名。

您可以使用boost::make_filter_iterator函数模板创建过滤迭代器。您传递一个返回bool的一元函数对象(函数对象、lambda 或函数指针)。您还传递不是一个,而是两个迭代器:它包装的一个和标记序列结束的另一个。在下面的例子中,我们有一个Person对象的列表,我们需要编写代码,向每个七十岁或以上的人的银行帐户支付 100 美元:

清单 5.23:使用过滤迭代器

 1 #include <boost/iterator/filter_iterator.hpp>
 2 #include <boost/assign.hpp>
 3 #include <vector>
 4 #include <string>
 5 #include <iostream>
 6
 7 struct Person
 8 {
 9   std::string name;
10   int age;
11   std::string bank_ac_no;
12
13   Person(const std::string& name, int years,
14          const std::string& ac_no) : 
15          name(name), age(years), bank_ac_no(ac_no) {}
16 };
17
17 void payout(double sum, const std::string& ac_no) {
19   std::cout << "Credited a sum of "<< sum
20             <<" to bank account number " << ac_no << '\n';
21 }
22
23 template<typename Itertype>
24 void creditSum(Itertype first, Itertype last, double sum)
25 {
26   while (first != last) {
27     payout(sum, first->bank_ac_no);
28     first++;
29   }
30 }
31
32 bool seventyOrOlder(const Person& person)
33 {
34   return person.age >= 70;
35 }
36
37 int main()
38 {
39   std::vector<Person> people{{"A Smith", 71, "5702750"},
40                 {"S Bates", 56, "3920774"}, 
41                 {"L Townshend", 73, "9513914"}, 
42                 {"L Milford", 68, "1108419"}, 
43                 {"F Cornthorpe", 81, "8143919"}}; 
44                 
45   auto first = boost::make_filter_iterator(seventyOrOlder,
46                                people.begin(), people.end());
47
48   auto last = boost::make_filter_iterator(seventyOrOlder,
49                                people.end(), people.end());
50
51   creditSum(first, last, 100);
52 }

在这个例子中,函数payout接受一个帐号和一个金额,并向该帐号发起付款(第 17 行)。函数creditSum接受定义Person对象序列和金额的一对迭代器,并向序列中的每个Person发起该金额的付款,为每个调用payout(第 23-24 行)。我们有一个Person对象的向量(第 39 行),我们使用 C++11 的统一初始化语法初始化了五个人的详细信息。我们不能直接在向量中的所有元素范围上调用creditSum,因为我们只想将其信用额度授予七十岁或以上的人。为此,我们首先定义了谓词函数seventyOrOlder(第 32 行),它帮助我们选择候选条目,然后定义了过滤迭代器firstlast(第 45-49 行)。最后,我们使用一对过滤迭代器和要信用的总额调用creditSum(第 51 行)。

转换迭代器

转换迭代器允许您遍历序列,并在取消引用时返回将一元函数应用于序列的基础元素的结果。您可以使用boost::make_tranform_iterator构造转换迭代器,将一元函数对象和基础迭代器传递给它。

考虑包含科目名称作为键和科目分数作为值的std::map对象。我们使用转换迭代器来计算所有科目分数的总和,如下例所示:

清单 5.24:使用转换迭代器

 1 #include <iostream>
 2 #include <string>
 3 #include <vector>
 4 #include <map>
 5 #include <algorithm>
 6 #include <functional>
 7 #include <boost/assign.hpp>
 8 #include <boost/iterator/transform_iterator.hpp>
 9 #include <numeric> // for std::accumulate
10 using namespace boost::assign;
11
12 typedef std::map<std::string, int> scoremap;
13
14 struct GetScore : std::unary_function<
15                         const scoremap::value_type&, int>
16 {
17   result_type operator()(argument_type entry) const
18   {
19     return entry.second;
20   }
21 };
22
23 int main()
24 {
25   scoremap subjectScores{{"Physics", 80}, {"Chemistry", 78},
26                      {"Statistics", 88}, {"Mathematics", 92}};
27
28   boost::transform_iterator<GetScore,
29                             scoremap::iterator>
30                      first(subjectScores.begin(), GetScore()),
31                      last(subjectScores.end(), GetScore());
32
33   std::cout << std::accumulate(first, last, 0) << '\n';
34 }

映射subjectScores包含存储在每个科目名称下的各科成绩。我们使用 C++11 统一初始化语法来初始化映射(第 25-26 行)。我们想要遍历这个映射中的值并计算它们的总和。遍历subjectScores将给我们提供科目名称和成绩的键值对。为了从一对中提取分数,我们定义了一个函数对象GetScore(第 14-15 行)。然后我们定义了一对转换迭代器firstlast,每个都使用GetScore函数对象和基础迭代器构造,并指向subjectScores映射的开始和结束(第 28-31 行)。通过从firstlast调用std::accumulate,我们对映射中的分数求和(第 33 行)并打印结果。

注意,GetScore派生自std::unary_function<ArgType, RetType>,其中ArgType是函数对象的单个参数的类型,RetType是函数对象的返回类型。这对于 C++11 不是必需的,您不需要在 C++11 中从任何特定类派生GetScore

boost::transform_iterator一样,std::transform算法允许对序列中的每个元素应用转换,但您还必须将结果存储在序列中。转换迭代器允许您创建一个延迟序列,其元素在访问时被评估,而无需将它们存储在任何地方。

函数输出迭代器

函数输出迭代器对分配给它们的每个元素应用一元函数。您可以使用boost::make_function_output_iterator函数模板创建函数输出迭代器,将一元函数对象传递给它。然后,您可以使用std::copy或类似的算法将序列中的元素分配给函数输出迭代器。函数输出迭代器只是在分配给它的每个元素上调用函数。您可以在提供的函数对象中封装任何逻辑,将它们打印在引号中,将它们添加到另一个容器中,保持已处理元素的计数等。

在下面的示例中,我们有一个目录名称列表,并使用boost::function_output_iterator,将它们用空格分隔起来连接在一起,确保引用任何包含嵌入空格的字符串:

清单 5.25:使用函数输出迭代器

 1 #include <iostream>
 2 #include <string>
 3 #include <vector>
 4 #include <algorithm>
 5 #include <boost/assign.hpp>
 6 #include <boost/function_output_iterator.hpp>
 7
 8 struct StringCat
 9 {
10   StringCat(std::string& str) : result_(str) {}
11
12   void operator()(const std::string& arg) {
13     if (arg.find_first_of(" \t") != std::string::npos) {
14       result_ += " \"" + arg + "\"";
15     } else {
16       result_ += " " + arg;
17     }
18   }
19
20   std::string& result_;
21 };
22
23 int main()
24 {
25   std::vector<std::string> dirs{"photos", "videos",
26                             "books", "personal docs"};
27 
28   std::string dirString = "";
29   std::copy(dirs.begin(), dirs.end(),
30            boost::make_function_output_iterator(
31   StringCat(dirString)));
32   std::cout << dirString << '\n';
33 }

我们定义了一个函数对象StringCat,它在构造函数中存储对std::string的非常量引用(第 12 行)在一个名为result_的成员中。它定义了一个一元operator(),它接受一个字符串参数并将其附加到result_。如果传递的字符串中包含嵌入的空格或制表符,则用引号引起来并在前面加上一个空格(第 14 行)。否则,在前面加上一个空格而不引用(第 16 行)。

我们有一个名为dirs的目录名称列表(第 25-27 行),我们想按照这个方案将它们附加到一个名为dirString的字符串上(第 28 行)。为此,我们创建了一个StringCat的实例,将其传递给dirString的引用(第 31 行),并将其传递给boost::make_function_output_iterator,它返回一个输出迭代器(第 30 行)。我们使用std::copy将元素从dirs复制到返回的输出迭代器中,这样做的效果是通过多次调用StringCat函数对象来连接字符串。当std::copy返回时,dirString的内容如下:

photos videos books "personal docs"

您可以看到personal docs,这是一个单个目录的名称,被适当地引用了。

除了上面列出的迭代器适配器之外,还有其他我们在这里没有涵盖的迭代器适配器,包括boost::indirect_iteratorboost::function_input_iteratorboost::zip_iteratorboost::counting_iteratorboost::permutation_iterator。使用 Boost 网站上的文档来熟悉它们的使用模式,并探索如何在自己的代码中使用它们。

迭代器适配器提供了来自函数式编程语言和库(如 Python 的itertools)的一组常见习语。当您的 API 接受一对迭代器但没有通过函数对象或谓词过滤或适应元素的选项时,迭代器适配器特别有用。迭代器适配器使能的许多功能也可以通过使用更现代的 Boost Range Adaptors 来实现,可能语法更简洁。但是,如果您的 API 期望迭代器而不是范围,那么这些迭代器适配器将非常方便。

为自定义类创建符合规范的迭代器

除了提供迭代器适配器模板外,Boost.Iterator 库还提供了创建符合规范的迭代器的框架。在本节中,我们将使用 Boost.Iterator 库为线程化二叉搜索树创建符合规范的迭代器。二叉搜索树是一种将元素存储在树结构中的抽象数据类型。粗略地说,树中的每个节点都有零个、一个或两个子节点。节点的左子树中的所有元素都小于节点,节点的右子树中的所有元素都大于节点。没有子节点的节点称为叶子节点。线程化二叉搜索树被优化为以排序顺序遍历其元素,即所谓的中序遍历

我们实现了一个简单版本的线程化二叉搜索树,在其中我们将在每个节点中维护指向前驱和后继节点的指针。然后我们将提供一个双向迭代器接口,允许以元素顺序进行树的正向和反向遍历。

清单 5.26:一个简单的线程化二叉搜索树

  1 #include <iostream>
  2 #include <algorithm>
  3 #include <vector>
  4 #include <boost/assign.hpp>
 5 #include <boost/iterator.hpp>
 6 #include <boost/iterator/iterator_facade.hpp>
  7
  8 template<typename T>
  9 struct TreeNode
 10 {
 11   T data;
 12   TreeNode<T> *left, *right;
 13   TreeNode<T> *prev, *next;
 14
 15   TreeNode(const T& elem) : data(elem),
 16          left(nullptr), right(nullptr),
 17          prev(nullptr), next(nullptr)
 18   {}
 19
 20   ~TreeNode()
 21   {
 22     delete left;
 23     delete right;
 24   }
 25 };
 26
 27 template<typename T>
 28 class BSTIterator :
 29   public boost::iterator_facade <BSTIterator<T>, T,
 30                   boost::bidirectional_traversal_tag>
 31 {
 32 public:
 33   BSTIterator() : node_ptr(nullptr) {}
 34   explicit BSTIterator(TreeNode<T> *node) :
 35      node_ptr(node) {}
 36   BSTIterator(const BSTIterator<T>& that) :
 37      node_ptr(that.node_ptr) {}
 38
 39 private:
 40   TreeNode<T> *node_ptr;
 41
 42   friend class boost::iterator_core_access;
 43
 44   void increment() { node_ptr = node_ptr->next; }
 45   void decrement() { node_ptr = node_ptr->prev; }
 46
 47   bool equal(const BSTIterator<T>& that) const {
 48     return node_ptr == that.node_ptr;
 49   }
 50
 51   T& dereference() const { return node_ptr->data; }
 52 };
 53
 54 template<typename T>
 55 class BinarySearchTree
 56 {
 57 public:
 58   BinarySearchTree() : root(nullptr), first(nullptr),
 59                        last(nullptr) {}
 60   ~BinarySearchTree() {
 61     delete root;
 62     delete last;
 63   }
 64
 65   void insert(const T& elem) {
 66     if (!root) {
 67       root = new TreeNode<T>(elem);
 68       first = root;
 69       last = new TreeNode<T>(T());
 70       first->next = last;
 71       last->prev = first;
 72     } else {
 73       insert(elem, root);
 74     }
 75   }
 76
 77   BSTIterator<T>begin() { return BSTIterator<T>(first); }
 78   BSTIterator<T>end() { return BSTIterator<T>(last); }
 79
 80   BSTIterator<T>begin() const {
 81     return BSTIterator<const T>(first);
 82   }
 83   BSTIterator<T>end() const {
 84     return BSTIterator<const T>(last);
 85   }
 86
 87 private:
 88   TreeNode<T> *root;
 89   TreeNode<T> *first;
 90   TreeNode<T> *last;
 91
 92   void insert(const T& elem, TreeNode<T> *node) {
 93     if (elem < node->data) {
 94       if (node->left) {
 95         insert(elem, node->left);
 96       } else {
 97         node->left = new TreeNode<T>(elem);
 98         node->left->prev = node->prev;
 99         node->prev = node->left;
100         node->left->next = node;
101
102         if (!node->left->prev) {
103           first = node->left;
104         } else {
105           node->left->prev->next = node->left;
106         }
107       }
108     } else if (node->data < elem) {
109       if (node->right) {
110         insert(elem, node->right);
111       } else {
112         node->right = new TreeNode<T>(elem);
113         node->right->next = node->next;
114         node->next = node->right;
115         node->right->prev = node;
116
117         if (node->right->next) {
118           node->right->next->prev = node->right;
119         }
120       }
121     }
122   }
123 };

我们可以在以下代码中使用BinarySearchTree模板:

125 int main() 
126 {
127   BinarySearchTree<std::string> bst;
128   bst.insert("abc");
129   bst.insert("def");
130   bst.insert("xyz");
131
132   for(auto& x: bst) {
133     std::cout << x << '\n';
134   }
135 }

这个例子帮助我们说明了使用 Boost 迭代器框架为一个不太复杂的数据结构创建自定义迭代器的技术。线程化树的实现被故意简化以帮助理解。TreeNode<T>代表树中的每个节点,包含参数化类型T的值。BinarySearchTree<T>代表支持中序遍历的二叉搜索树。它存储三个TreeNode<T>类型的指针:树的根节点,指向最小元素的指针first,以及表示遍历结束的哨兵指针last(第 68-70 行)。最后,BSTIterator<T>代表BinarySearchTree<T>的双向迭代器类型,允许以两个方向遍历树的元素。

TreeNode<T>存储指向其leftright子节点的两个指针,以及指向其在值顺序中的前驱(prev)和后继(next)节点的两个指针(第 12-13 行)。新节点总是作为叶节点插入,新节点和在遍历顺序中其前后的节点的prevnext指针将被适当地调整。新元素是使用insert公共方法插入到树中的,插入的实际逻辑在insert方法的私有重载中(第 72-102 行)。BinarySearchTreebeginend方法返回树中的第一个元素的迭代器和另一个标记遍历结束的节点。

BSTIterator模板是我们最感兴趣的迭代器实现,它派生自boost::iterator_facade的一个特化(第 29-30 行)。特化需要三个参数:BSTIterator<T>本身,类型参数T,以及一个标签boost::bidirectional_traversal_tag,用于标识迭代器支持的遍历类型(在本例中是双向)。以派生类作为参数的基本模板是一个众所周知的 C++习语,称为奇异递归模板参数,用于实现虚方法调用的效果,但没有运行时成本。我们现在定义一组成员来完成实现。

BSTIterator模板保持对树中节点的TreeNode<T>指针(第 40 行)。这是使用默认构造函数和带有节点指针的构造函数进行初始化的(第 33-35 行)。同样重要的是,我们必须使BSTIterator可复制(第 36-37 行)。我们定义了一组私有成员函数,这些函数由 Boost 迭代器框架访问。框架代码通过一个名为boost::iterator_core_access的类访问这些函数,因此将其定义为friend类(第 42 行)。increment函数(第 44 行)和decrement函数(第 45 行)在我们使用operator++operator--增加或减少迭代器时被调用。它们将内部节点指针更改为指向遍历顺序(中序)中的下一个或上一个节点。当我们使用operator*对迭代器进行解引用时,将调用dereference函数。它返回对存储在每个节点中的数据元素的引用(第 51 行)。equal方法用于检查两个迭代器是否相等。例如,当您检查迭代器是否已经到达容器中值序列的末尾时,会调用它。

if (it == container.end())

这是我们需要做的全部工作来定义一个完全功能的迭代器。容器内部还需要做一些额外的工作。我们定义beginend方法,返回容器中值序列的开始和结束(第 77-78 行)。这些指针,first(第 89 行)和last(第 90 行),作为额外的成员,并由BinarySearchTree模板适当更新。指针first在每次插入新的最小元素时更新。指针last代表一个标记,超过这个标记前进遍历将永远无法进行,最初创建并且永远不会更新(第 69 行)。每次将新的最大元素添加到树中时,它的next指针指向last。提供beginend成员函数的 const 版本(第 80-85 行)是为了确保在常量容器上调用它们会得到不可变的迭代器。基本上采用相同的模式,您可以为符合标准库迭代器概念的容器推出自己的迭代器。许多标准库算法可以通过这样的迭代器接口用于自定义容器。迭代器的简洁实现(第 27-51 行)得益于 Boost 迭代器框架提供的抽象。

自测问题

对于多项选择题,选择所有适用的选项:

  1. 与有序/无序关联容器相比,对于平面关联容器以下哪些是正确的?

a. 需要更少的内存

b. 插入更快

c. 遍历较慢

d. 查找更快

  1. std::forward_list不提供size()成员函数,因为:

a. 线性时间大小成员不能支持单链表

b. 既 splice 又 size 成员不能是常数时间

c. 它将是线程不安全的

d. 以上所有

  1. static_vector的内部内存分配在哪里:

a. 栈

b. 取决于静态向量的创建位置

c. 自由存储区

d. 取决于使用的分配器

  1. 为了将 X 类型的对象存储在无序容器中,必须为 X 类型的对象定义/可用以下哪些?

a. 排序关系

b. 哈希函数

c. 相等比较

d. 复制构造函数

  1. 哪种数据结构允许对其元素进行随机访问,并支持在插入和删除其他元素时不会使迭代器失效?

a. static_vector

b. unordered_map

c. stable_vector

d. circular_buffer

总结

本章介绍了一系列 Boost 库,这些库提供了不同类型的容器,或者使得使用它们更容易。我们看了一些有用的非标准容器,扩展了标准库容器,看了一些设计用于存储动态分配对象指针的容器,看到了一些将元素分配给容器的表达方式,了解了基于哈希的无序容器,学习了不同的迭代集合的模式,并为自定义集合启用了迭代。

在下一章中,我们将继续研究 Boost 的容器库,并专注于支持根据多个条件高效查找对象的专用容器。

参考文献

在可能的情况下避免容器中的空指针:www.boost.org/doc/libs/1_57_0/libs/ptr_container/doc/guidelines.html#avoid-null-pointers-in-containers-if-possible

第六章:Bimap 和多索引容器

标准库有有序和无序的关联容器,用于存储对象并使用某个高效地查找它们。键可以是文本类型、数字类型或一级对象。对于有序容器,如std::setstd::map,键必须具有明确定义的排序关系,允许对任何一组键进行排序。对于无序容器,必须能够为每个键计算一个整数哈希值,并且另外确定任何两个键是否相等,以某种等价定义。键表示查找的索引或标准,并且所有标准库关联容器都支持仅使用单个标准进行查找。换句话说,您不能使用多个独立标准高效地查找对象。

假设您有一个称为PersonEntry的类型来描述一个人。PersonEntry类型具有名称、年龄、电话号码等属性。您将在容器中存储多个PersonEntry类型的对象,并且在不同的时间,您可能需要使用不同的属性(如名称、年龄、电话号码等)查找PersonEntry对象。虽然标准库容器在涉及集合的许多常见任务时表现出色,但当您需要一个基于多个标准高效存储数据并搜索数据的数据结构时,它们表现得很糟糕。Boost 提供了一小部分通用容器,专门用于这种需求,其中我们在本章中研究了其中的两个。本章分为以下几个部分:

  • 多标准查找的容器

  • Boost Multi-index 容器

  • Boost Bimap

多标准查找的容器

考虑一个PersonEntry类型的对象集合,如下面的代码所定义:

 1 struct PersonEntry
 2 {
 3   std::string name;
 4   std::string phoneNumber;
 5   std::string city;
 6 };

这种类型的对象可能代表电话簿中的一个条目。您将如何设计一个数据结构,使您能够按名称查找一个人?我们可以使用std::set存储PersonEntry对象,为PersonEntry定义适当的排序关系。由于我们想按名称搜索,因此应该按名称定义排序关系:

 1 bool operator<(const PersonEntry& left, 
 2                const PersonEntry& right) {
 3   return left.name< right.name;
 4 }

现在std::set仅存储唯一元素,任何两个具有相同名称的PersonEntry对象都将被视为重复。由于现实生活中常见同名,我们应该选择一个允许重复的容器,即std::multiset。然后我们可以使用以下代码插入元素并按名称查找它们:

清单 6.1:使用多重映射进行查找

 1 #include <set>
 2 #include <iostream>
 3 #include <string>
 4
 5 struct PersonEntry {
 6   std::string name;
 7   std::string phoneNumber;
 8   std::string city;
 9 };
10
11 int main() {
12   std::multiset<PersonEntry> directory;
13   PersonEntry p1{"Arindam Mukherjee", "550 888 9999", "Pune"};
14   PersonEntry p2{"Arindam Mukherjee", "990 770 2458", 
15                  "Calcutta"};
16   directory.insert(p1);
17   directory.insert(p2);
18   auto it1 = directory.lower_bound(
19                 PersonEntry{ "Arindam Mukherjee", "", "" });
20   auto it2 = directory.upper_bound(
21                 PersonEntry{ "Arindam Mukherjee", "", "" });
22
23   while (it1 != it2) {
24     std::cout << "Found: [" <<it1->name << ", "
25               <<it1->phoneNumber << ", " <<it1->city << "]\n";
26     ++it1;
27   }
28 }

我们创建了两个具有相同名称的人的PersonEntry对象(第 13-15 行),并将它们插入multiset(第 16-17 行)。使用了 C++11 的新颖统一初始化语法来初始化对象。然后我们查找名称为"Arindam Mukherjee"的对象。在multiset中正确的方法是确定匹配元素的范围。lower_bound成员函数返回指向第一个匹配元素的迭代器(第 18-19 行)。upper_bound成员函数返回指向紧随最后一个匹配元素的第一个元素的迭代器(第 20-21 行)。如果没有匹配的元素,两者都返回指向第一个元素的迭代器,如果有匹配的元素,则返回指向匹配元素后面的第一个元素的迭代器。然后我们遍历 low, high)定义的范围,并打印所有匹配的元素。如果您注意到,我们构造了临时的PersonEntry对象来执行查找。现在,如果想要进行反向查找,即根据电话号码查找并找出它属于谁,这是完全合理的。在前面的安排中,我们该如何做呢?我们可以始终通过容器执行线性搜索,或者我们可以使用一个按电话号码排序的对象的字典中的PersonEntry对象的引用的单独容器;这两种方法都不是特别优雅或高效。这就是 Boost Multi-index 库的用武之地。

Boost Multi-index 容器

Boost Multi-index 库实际上提供了一个称为multi_index_container的单个通用容器,用于存储对象和指定一个或多个索引的选项,通过这些索引可以查找对象。每个索引将在对象的潜在不同字段上使用不同的标准。索引被定义并指定为容器的模板参数,这确实使容器的声明有些令人生畏。但是,这最终使容器的实现更加紧凑,具有大量的编译时优化。事实上,使用这些容器最困难的部分实际上是确保它们的声明正确;因此让我们解构一下PersonEntry对象的这种容器的声明:

列表 6.2:定义多索引容器

 1 #include <boost/multi_index_container.hpp>
 2 #include <boost/multi_index/indexed_by.hpp>
 3 #include <boost/multi_index/ordered_index.hpp>
 4 #include <boost/multi_index/identity.hpp>
 5
 6 using namespace boost::multi_index;
 7
 8 typedef ordered_non_unique<identity<PersonEntry>> by_person;
 9 typedef multi_index_container<PersonEntry,
10                       indexed_by<by_person>> directory_t;

在前面的片段中,我们创建了PersonEntry对象的multi_index_container的 typedef(第 9-10 行)。我们使用了之前定义的名为person_index的单个索引(第 8 行)。person_index是用于在容器中查找对象的索引类型。它被定义为ordered_non_unique<identity<PersonEntry>>。这意味着索引通过它们定义的排序关系保持PersonEntry对象的顺序,并允许重复(非唯一)。这个索引提供了与std::multiset<PersonEntry>相同的语义。现在,如果我们想要按电话号码查找PersonEntry对象,我们需要定义额外的索引:

列表 6.3:定义多索引容器

 1 #include <boost/multi_index_container.hpp>
 2 #include <boost/multi_index/indexed_by.hpp>
 3 #include <boost/multi_index/ordered_index.hpp>
 4 #include <boost/multi_index/identity.hpp>
 5 #include <boost/multi_index/member.hpp>
 6 #include "PersonEntry.h"  // contains PersonEntry definition
 7 using namespace boost::multi_index;
 8
 9 typedef ordered_non_unique<member<PersonEntry, std::string,
10                           &PersonEntry::name>> by_name;
11 typedef ordered_unique<member<PersonEntry, std::string,
12                        &PersonEntry::phoneNumber>>by_phone;
13
14 typedef multi_index_container<PersonEntry,
15                             indexed_by<by_name,
16                                        by_phone>> directory_t;

在这里,我们定义了两种索引类型:一个名为by_name的索引类型,用于按名称字段查找对象,以及一个名为phone_index的第二索引类型,用于按电话号码查找(第 9-12 行)。我们使用member模板来指示我们希望基于PersonEntry的数据成员namephoneNumber(类型为std::string)创建索引。

我们将indexed_by模板的一个特化作为参数传递给multi_index_container模板。我们想要启用的所有索引都作为这个特化的参数列出(第 15-16 行)。现在让我们看看这些类型是如何工作的。我们假设列表 6.3 中的所有头文件都已包含,并且列表 6.3 中定义的所有类型都在以下列表中可用:

列表 6.4:使用 Boost Multi-index 容器

 1 int main()
 2 {
 3   directory_t phonedir;
 4   PersonEntry p1{"Arindam Mukherjee", "550 888 9999", "Pune"};
 5   PersonEntry p2{"Arindam Mukherjee", "990 770 2458", 
 6                  "Calcutta"};
 7   PersonEntry p3{"Ace Ventura", "457 330 1288", "Tampa"};
 8
 9   phonedir.insert(p1);
10   phonedir.insert(p2);
11   phonedir.insert(p3);
12 
13   auto iter = phonedir.find("Ace Ventura");
14   assert(iter != phonedir.end() && iter->city == "Tampa");
15
16   auto& ph_indx = phonedir.get<1>();
17   auto iter2 = ph_indx.find("990 770 2458");
18   assert(iter2 != ph_indx.end());
19   assert(iter2->city == "Calcutta");
20
21   for (auto& elem: ph_indx) {
22     std::cout << elem.name <<" lives in " << elem.city
23         << " and can be reached at "<< elem.phoneNumber
24         << '\n';
25   }
26 }

在这个例子中,我们创建了一个PersonEntry对象的多索引容器,按照列表 6.3 中定义的namephoneNumber字段进行索引。我们插入了三个PersonEntry对象(第 8-10 行)。然后我们在容器中按名称进行查找(第 12-13 行)。容器的行为默认为第一个索引by_name(列表 6.3,第 9-10 行)。因此,调用find方法使用第一个索引(by_name)进行查找。要按电话号码查找,我们需要获取对第二个索引的引用。为此,我们使用multi_index_containerget成员模板,传递1,这是by_phone索引的从零开始的位置(第 15 行)。然后我们可以像在std::set上一样在返回的索引引用上调用方法(第 16-18 行)。我们甚至可以使用基于范围的 for 循环结构(第 21 行)或使用实际迭代器来遍历索引。

在前面的例子中,两个索引都是有序的,这要求它们基于的元素(namephoneNumber字段)应该定义一个排序关系。在这种情况下,这两个字段都是std::string类型,因此排序关系是明确定义的。但是如果没有可用的排序关系,我们需要自己定义一个重载的operator<来进行排序。或者,我们可以定义一个函数对象来执行类型的两个元素之间的排序比较,并将其类型作为member模板的尾随参数传递。Boost Multi-index 的在线文档有更多详细信息。

如果为索引类型指定数字位置似乎不太理想,可以改用标签。这会稍微改变by_phone索引的声明,但可以使代码更易读。以下是如何为phone_index做到这一点:

 1 struct phone_tag {};
 2 typedef ordered_unique< <tag<phone_tag>, member<PersonEntry, 
 3          std::string, &PersonEntry::phoneNumber>> by_phone;
 4
 5 auto& ph_indx = phonedir.get<phone_tag>(); 

在上面的片段中,我们定义了一个名为phone_tag的空结构,只是作为特定索引的标签(第 1 行)。然后我们定义了索引类型by_phone,作为ordered_unique模板的特化。ordered_unique模板的第一个参数指定用于检索此索引的标签(phone_tag)。ordered_unique的第二个模板参数是member<PersonEntry, std::string, &PersonEntry::phoneNumber>;它指定每个PersonEntry对象的phoneNumber成员将用作此索引的键,并且它的类型是std::string(第 2-3 行)。最后,我们通过调用phonedirget成员模板来访问索引,但是传递的是标签phone_tag而不是数字索引(第 5 行)。

索引类型

ordered_uniqueordered_non_unique索引分别对应于std::setstd::multiset的语义。使用这些索引,不仅可以获得对数级别的查找和插入,还可以执行容器元素的有序遍历。如果您不关心有序遍历,还可以使用hashed_uniquehashed_non_unique索引,它们提供了出色的插入和查找性能(常数预期时间)。当然,散列索引不需要在元素上定义任何排序关系,但需要一种方法来生成它们的哈希值。这可以使用列表 5.11 中显示的无序容器的技术来实现。

有时,按插入顺序获取对象并根据不同标准执行查找是很重要的。要按插入顺序获取对象,我们需要使用sequenced索引。sequenced索引除了可选标签外不接受任何参数。我们可以将sequenced<>索引添加到我们在 6.3 清单中定义的directory_t类型中,如下所示:

 1 #include <boost/multi_index/sequenced_index.hpp>
 2 typedef multi_index_container<PersonEntry,
 3                             indexed_by<by_name,
 4                                        by_phone,
 5                             sequenced<>>> directory_t;

如果我们想要,我们可以将标签作为模板参数传递给sequenced。如果我们还想要按插入顺序获得此序列的随机访问迭代器,可以改用random_access<>索引:

 1 #include <boost/multi_index/random_access_index.hpp>
 2 typedef multi_index_container<PersonEntry,
 3                      indexed_by<by_name,
 4                           by_phone,
 5                           random_access<>>> directory_t;

现在假设您使用by_name索引按名称查找PersonEntry,并希望找出元素在插入顺序中的位置。迭代器与索引相关联,我们的迭代器与by_phone索引相关联。现在您还希望获得与random_access索引相同的元素的迭代器。然后,您可以计算该迭代器与random_access索引的起始迭代器之间的差异,以计算元素的序数位置。这样做的一般方法是使用multi_index_containerproject成员模板,如下例所示:

清单 6.5:使用迭代器投影

 1 // the necessary includes for Boost Multi-index
 2
 3 typedef multi_index_container<PersonEntry,
 4 indexed_by<by_name,by_phone, 
 5                               random_access<>>> directory_t;
 6
 7 int main()
 8 {
 9   directory_t phonedir;  // directory_t defined in listing 6.3
10
11   phonedir.insert(PersonEntry{"Dr. Dolittle", "639 420 7624", 
12                               "Atlanta"});
13   phonedir.insert(PersonEntry{"Arindam Mukherjee", 
14                               "990 770 2458", "Calcutta"});
15   phonedir.insert(PersonEntry{"Ace Ventura", "457 330 1288",
16                               "Tampa"});
17   phonedir.insert(PersonEntry{"Arindam Mukherjee", 
18                               "550 888 9999", "Pune"});
19
20   auto& name_index = phonedir.get<0>();
21   auto it = name_index.find("Ace Ventura");
22   auto& random_index = phonedir.get<2>();
23   if (it != name_index.end()) {
24     auto rit = phonedir.project<2>(it);
25     std::cout << "Element found: " << it->name 
26       << ", position = " <<rit - random_index.begin() << '\n';
27   }
28 }

我们使用find成员按名称查找元素,返回一个指向元素的迭代器it(第 21 行)。然后,我们使用get成员模板在索引 2 处获取与随机访问索引相关联的引用(第 22 行)。使用phonedirproject成员模板,我们在random_access索引中获取与it对应的迭代器(第 24 行)。返回的迭代器rit是一个随机访问迭代器,我们可以计算元素的从零开始的位置,即ritrandom_index的起始迭代器之间的差异。如果我们在这里使用sequenced<>索引而不是random_access<>索引(第 5 行),我们将无法通过计算两个迭代器的差异来计算位置(第 26 行)。相反,我们需要使用std::distance标准库函数来计算有序容器的开始和查找迭代器之间的偏移量。这将具有线性时间复杂度而不是常数时间。

使用 lambda 进行范围查找

有时,我们希望找到属性值在某个范围内的元素。我们可以使用更具表现力的语法,使用 Boost Lambda 进行范围查找,而不是使用multi_index_container及其索引的lower_boundupper_bound成员。Lambda 表达式将在本书的后面进行讨论(参见[第七章,“高阶和编译时编程”),但实际上您不需要理解其中的任何内容就可以遵循本示例。

列表 6.6:表达范围查找

 1 // include required Boost Multi-index headers
 2 #include <boost/lambda/lambda.hpp>
 3
 4 namespace bl = boost::lambda;  // lambda placeholder
 5
 6 int main()
 7 {
 8   directory_t phonedir;  // directory_t defined in listing 6.3
 9
10    phonedir.insert(PersonEntry{"Dr. Dolittle", "639 420 7624",
11                                "Atlanta"});
12    phonedir.insert(PersonEntry{"Arindam Mukherjee", 
13                                "990 770 2458", "Calcutta"});
14    phonedir.insert(PersonEntry{"Ace Ventura", "457 330 1288",
15                               "Tampa"});
16    phonedir.insert(PersonEntry{"Arindam Mukherjee", 
17                                "550 888 9999", "Pune"});
18
19   auto& name_index = phonedir.get<0>();
20   auto range = name_index.range("Ar" <= bl::_1, "D" > bl::_1);
21 
22   for (auto start = range.first; start != range.second; 
23        ++start) {
24     std::cout << start->name << ", " << start->phoneNumber 
25               << ", " << start->city << "\n";
26   }
27 }

使用列表 6.3 中定义的directory_t类型的multi_index_container,该容器使用by_nameby_phone索引,我们定义了一个名为phonedirPersonEntry对象的多索引容器(第 8 行),并将四个条目插入其中(第 10-17 行)。然后,我们查找所有名称词法大于或等于"Ar"且词法小于"D"的条目。为此,我们首先获取适当的索引,即by_name索引,它是第零个索引或默认索引。然后我们在该索引上调用range成员函数,通过 lambda 占位符_1boost::lambda::_1)传递两个确定范围结束的条件。语义上,std::string("Ar") <= _1表示我们正在寻找词法上不小于"Ar"的字符串,std::string("D") > _1表示我们正在寻找词法上小于"D"的字符串。这两个条件一起确定了哪些元素属于范围内,哪些元素属于范围外。结果是,我的两个同名人在范围内,而他们更有名的朋友不在范围内。该程序打印:

Arindam Mukherjee, 550 888 9999, Pune
Arindam Mukherjee, 990 770 2458, Calcutta

插入和更新

您可以向multi_index_container中添加新元素,并使用容器接口或任何其索引来擦除它们。通过索引接口添加和擦除元素的方式取决于索引的类型。通过容器的公共接口添加和擦除元素的方式由容器的第一个索引的类型定义。

在之前的示例中,我们已经使用insert成员函数向multi_index_containers中添加单个元素。我们使用了接受单个对象并将其添加到容器中适当位置的insert重载。我们还可以在类型为ordered_uniqueordered_non_uniquehashed_uniquehashed_non_unique的单个索引上使用此方法。但是在random_accesssequenced索引上,以及在使用此类索引作为其第一个索引的容器上,insert的单个参数重载不可用。您可以使用push_backpush_front将元素添加到末尾。您还可以使用接受要插入位置的迭代器作为额外参数的insert重载。同样对于erase,对于sequenced<>random_access<>索引,您只能使用指定要擦除的元素的迭代器的重载;而对于有序和散列索引,您实际上可以使用接受要查找并擦除所有匹配元素的值的重载。

您还可以使用replacemodify方法在多索引容器中更新值。以下代码片段说明了这些概念:

列表 6.7:在多索引容器上插入、擦除和更新

 1 // include required Boost Multi-Index headers
 2 #include <boost/lambda/lambda.hpp>
 3
 4 // by_name, by_phone defined Listing 6.3
 5 using namespace boost::multi_index;
 6
 7 typedef ordered_non_unique<member<PersonEntry, std::string, 
 8                             &PersonEntry::name>> by_name;
 9 typedef ordered_unique<member<PersonEntry, std::string, 
10                        &PersonEntry::phoneNumber>> by_phone;
11 typedef multi_index_container<PersonEntry,
12                              indexed_by<random_access<>,
13                                 by_name, by_phone>> phdir_t;
14
15 int main()
16 {
17   phdir_t phonedir;
18
19   phonedir.push_back(PersonEntry{"Dr. Dolittle",
20            "639 420 7624", "Atlanta"}); // insert won't work
21   auto& phindx = phonedir.get<2>();
22   phindx.insert(PersonEntry{"Arindam Mukherjee",
23                             "550 888 9999", "Pune"});
24   auto& nameindx = phonedir.get<1>();
25   nameindx.insert(PersonEntry{"Arindam Mukherjee",
26                               "990 770 2458", "Calcutta"});
27   phonedir.push_front(PersonEntry{"Ace Ventura", 
28                               "457 330 1288", "Tampa"});
29
30   nameindx.erase("Arindam Mukherjee");  // erases 2 matching
31   phonedir.erase(phonedir.begin());     // erases Ace Ventura
32   assert(phonedir.size() == 1);
33   std::cout <<"The lonesome "<< phonedir.begin()->name << '\n';
34
35   phonedir.push_back(PersonEntry{"Tarzan", "639 420 7624", 
36                                  "Okavango"});
37   assert(phonedir.size() == 1);
38   std::cout <<"Still alone "<< phonedir.begin()->name << '\n'; 
39 
40   phonedir.push_back(PersonEntry{"Tarzan", "9441500252",
41                                  "Okavango"});
42   assert(phonedir.size() == 2);
43
44   PersonEntry tarzan = *(phonedir.begin() + 1);
45   tarzan.phoneNumber = "639 420 7624";
46   assert(!phonedir.replace(phonedir.begin() + 1, tarzan));
47 }

在这个例子中,我们创建了一个PersonEntry对象的多索引容器,有三个索引:默认的random_access索引,name字段上的有序非唯一索引,以及phoneNumber字段上的有序唯一索引。我们首先使用容器的公共接口使用push_back方法添加了一个PersonEntry记录(第 19-20 行)。然后我们访问了电话索引(第 21 行)和名称索引(第 24 行)的引用。我们使用电话索引上的单参数insert重载添加了第二条记录(第 22 行),并使用名称索引上的相同重载添加了第三条记录(第 25-26 行)。接下来,我们使用容器的push_front方法添加了第四条记录(第 27-28 行),这将这条记录放在random_access索引的前面或开头。

然后我们调用了单参数erase重载,传递了与name字段匹配的字符串给名称索引(第 30 行)。这将擦除两条匹配的记录(第 22-23 行和 25-26 行插入)。然后我们擦除了容器开头的记录(第 31 行),删除了"Ace Ventura"的记录。剩下的唯一记录(第 32 行)被打印到控制台(第 33 行),应该打印出:

The lonesome Dr. Dolittle

接下来,我们使用push_back为名为Tarzan的人添加另一条记录(第 35-36 行)。有趣的是,Tarzan 先生的电话号码与 Dolittle 博士相同。但是因为phoneNumber字段上有唯一索引,这次插入不会成功,容器仍然保留了 Dolittle 博士的记录(第 37, 38 行)。我们通过为 Tarzan 添加一个具有唯一电话号码的新记录来解决这个问题(第 40-41 行),这次成功了(第 42 行)。

接下来,我们访问了 Tarzan 的记录,这将是插入顺序中的第二条记录,并创建了该对象的副本(第 44 行)。然后我们将tarzan对象的phoneNumber字段更改为与 Dolittle 博士相同的号码。我们尝试使用replace成员函数用修改后的对象替换容器中的 Tarzan 对象,但由于替换违反了电话号码的唯一性约束,replace方法无法更新记录,返回一个布尔值 false。我们也可以使用更高效的modify方法来代替replace。我们不会在本书中涵盖modify;在线文档是寻找参考的好地方。

每次插入都会更新所有索引,就像标准库中的关联容器和std::list一样,它们不会使任何现有的迭代器失效,甚至不会使其他索引生成的迭代器失效。擦除操作只会使被擦除的元素的迭代器失效。

Boost Bimap

存储对象并使用键查找它们是一项非常常见的编程任务,每种语言都通过本机构造或库(如字典或查找表)提供了一定程度的支持。在 C++中,std::mapstd::multimap容器(以及它们的无序变体)提供了查找表抽象。传统上,这些库只支持单向查找。给定一个键,你可以查找一个值,这对许多情况来说是足够的。但有时,我们也需要一种通过值查找键的方法,标准库的关联容器在这种情况下帮助不大;我们需要的是 Boost Bimap 库。

Boost Bimap 库提供了双向映射数据结构,允许使用键和值进行查找。让我们从一个例子开始,以了解它是如何工作的。我们将使用 Boost bimap 来存储国家和地区的名称以及它们的首都:

清单 6.8:使用 bimap

 1 #include <boost/bimap.hpp>
 2 #include <boost/assign.hpp>
 3 #include <string>
 4 #include <iostream>
 5 #include <cassert>
 6 using namespace boost::assign;
 7
 8 typedef boost::bimap<std::string, std::string> string_bimap_t;
 9
10 int main()
11 {
12   string_bimap_t countryCapitals;
13
14   insert(countryCapitals)("Slovenia", "Ljubljana")
15                          ("New Zealand", "Wellington")
16                          ("Tajikistan", "Bishkek")
17                          ("Chile", "Santiago")
18                          ("Jamaica", "Kingston");
19
20   string_bimap_t::left_map& countries = countryCapitals.left;
21   string_bimap_t::left_map::const_iterator it
22        = countries.find("Slovenia");
23   if (it != countries.end()) {
24     std::cout << "Capital of "<< it->first << " is "
25               << it->second << "\n";
26   }
27
28   string_bimap_t::right_map& cities = countryCapitals.right;
29   string_bimap_t::right_map::const_iterator it2
30        = cities.find("Santiago");
31   if (it2 != cities.end()) {
32      std::cout << it2->first <<" is the capital of "
33                << it2->second << "\n";
34   }
35
36   size_t size = countryCapitals.size();
37   countryCapitals.insert(
38        string_bimap_t::value_type("Chile", "Valparaiso"));
39   assert(countries.at("Chile") == "Santiago");
40   assert(size == countryCapitals.size());
41
42   countryCapitals.insert(
43     string_bimap_t::value_type("Norfolk Island", "Kingston"));
44   assert(cities.at("Kingston") == "Jamaica");
45   assert(size == countryCapitals.size());
46 }

类型bimap<string, string>将保存国家的名称并将其映射到首都,命名为string_bimap_t(第 8 行)。我们定义了一个这种类型的 bimap,称为countryCapitals(第 12 行),并使用 Boost Assign 的insert适配器(第 14-18 行)添加了五个国家及其首都的名称。

Bimap 定义了两个容器中值之间的关系或映射:一个左容器包含国家名称,一个右容器包含首都名称。我们可以得到 bimap 的左视图,将键(国家名称)映射到值(首都),以及右视图,将值(首都)映射到键(国家名称)。这代表了 bimap 的两种替代视图。我们可以使用 bimap 的成员leftright(第 20、28 行)来访问这两个替代视图。这两个视图具有与std::map非常相似的公共接口,或者借用在线文档中的简洁描述,它们与std::map具有相同的签名

到目前为止,国家集合和首都集合之间存在一对一的映射。现在我们尝试为智利的第二个首都 Valparaiso 插入一个条目(第 37-38 行)。它失败了(第 39-40 行),因为与std::map一样,但与std::multimap不同,键必须是唯一的。

现在考虑一下,如果我们尝试向 bimap(第 42-43 行)插入一个新的条目,用于一个新的国家Norfolk Island(澳大利亚的一个领土),其首都Kingston与地图上的另一个国家(牙买加)的名字相同会发生什么。与std::map中将会发生的情况不同,插入失败,bimap 中的条目数量没有变化(第 44-45 行)。在这种情况下,值也必须是唯一的,这对于std::map来说不是一个约束。但是,如果我们真的想要使用 Boost Bimap 来表示一对多或多对多的关系,我们将在下一节中看到我们有哪些选项。

集合类型

Boost Bimap 的默认行为是一对一映射,即唯一键和唯一值。但是,我们可以通过改变一些模板参数来支持一对多和多对多映射。为了用一个例子说明这样的用法,我们使用一个给定名称到昵称的映射(清单 6.9)。一个给定名称有时可能与多个昵称相关联,一个昵称也偶尔可以适用于多个给定名称。因此,我们希望建模一个多对多关系。为了定义一个允许多对多关系的 bimap,我们必须选择左右容器的集合类型与默认值(具有集合语义)不同。由于名称和昵称都可以是非唯一的,因此左右容器都应该具有多重集的语义。Boost Bimap 提供了集合类型说明符(参考下表),可以用作boost::bimap模板的模板参数。根据集合类型,bimap 的左视图或右视图的语义也会发生变化。以下是一个简短的表格,总结了可用的集合类型、它们的语义以及相应的视图(基于www.boost.org上的在线文档):

集合类型 语义 视图类型
set_of 有序,唯一。 map
multiset_of 有序,非唯一。 multimap
unordered_set_of 哈希,唯一。 unordered_map
unordered_multiset_of 哈希,非唯一。 unordered_multimap
unconstrained_set_of 无约束。 没有可用的视图
list_of 无序,非唯一。 键值对的链表
vector_of 无序,非唯一,随机访问序列。 键值对的向量

请注意,集合类型是在boost::bimaps命名空间中定义的,每种集合类型都有自己的头文件,必须单独包含。以下示例向您展示了如何使用集合类型与boost::bimap模板结合使用来定义多对多关系:

清单 6.9:多对多关系的 Bimaps

 1 #include <boost/bimap.hpp>
 2 #include <boost/bimap/multiset_of.hpp>
 3 #include <boost/assign.hpp>
 4 #include <string>
 5 #include <iostream>
 6 #include <cassert>
 7 using namespace boost::assign;
 8 namespace boostbi = boost::bimaps;
 9
10 typedef boost::bimap<boostbi::multiset_of<std::string>,
11             boostbi::multiset_of<std::string>> string_bimap_t;
12
13 int main()
14 {
15   string_bimap_t namesShortNames;
16
17   insert(namesShortNames)("Robert", "Bob")
18                          ("Robert", "Rob")
19                          ("William", "Will")
20                          ("Christopher", "Chris")
21                          ("Theodore", "Ted")
22                          ("Edward", "Ted");
23
24   size_t size = namesShortNames.size();
25   namesShortNames.insert(
26           string_bimap_t::value_type("William", "Bill"));
27   assert(size + 1 == namesShortNames.size());
28
29   namesShortNames.insert(
30           string_bimap_t::value_type("Christian", "Chris"));
31   assert(size + 2 == namesShortNames.size());
32
33   string_bimap_t::left_map& names = namesShortNames.left;
34   string_bimap_t::left_map::const_iterator it1
35        = names.lower_bound("William");
36   string_bimap_t::left_map::const_iterator it2
37        = names.upper_bound("William");
38
39   while (it1 != it2) {
40     std::cout << it1->second <<" is a nickname for "
41               << it1->first << '\n';
42     ++it1;
43   }
44
45   string_bimap_t::right_map& shortNames = 
46                                   namesShortNames.right;
46   
47   auto iter_pair = shortNames.equal_range("Chris");
48   for (auto it3 = iter_pair.first; it3 != iter_pair.second;
49        ++it3) {
50     std::cout << it3->first <<" is a nickname for "
51               << it3->second << '\n';
52   } 
53 }

我们需要使用的特定双射图容器类型是bimap<multiset_of<string>, multiset_of<string>>(第 10-11 行)。使用bimap<string, string>将给我们一个一对一的映射。如果我们想要一对多的关系,我们可以使用bimap<set_of<string>, multiset_of<string>>,或者简单地使用bimap<string, multiset_of<string>>,因为当我们不指定时,set_of是默认的集合类型。请注意,在代码中,我们使用boostbi作为boost::bimaps命名空间的别名(第 8 行)。

我们定义namesShortNames双射图来保存名称和昵称条目(第 15 行)。我们添加了一些条目,包括重复的名称Robert和重复的昵称Ted(第 17-22 行)。使用双射图的insert成员函数,添加了一个重复的名称William(第 25-26 行)和一个重复的昵称Chris(第 29-30 行);两个插入操作都成功了。

我们使用bimapleftright成员来访问左视图和右视图,左视图以名称作为键,右视图以昵称作为键(第 33 行,45 行)。左视图和右视图都与std::multimap兼容,并且我们可以像在std::multimaps上一样在它们上执行查找。因此,给定一个名称,要找到与其匹配的第一个条目,我们使用lower_bound成员函数(第 35 行)。要找到字典顺序大于名称的第一个条目,我们使用upper_bound成员函数(第 37 行)。我们可以使用这两个函数返回的迭代器迭代匹配条目的范围(第 39 行)。通常,lower_bound返回与传递的键词字典顺序相等或大于的第一个元素;因此,如果没有匹配的元素,lower_boundupper_bound返回相同的迭代器。我们还可以使用equal_range函数,它将下界和上界迭代器作为迭代器对返回(第 47 行)。

如果我们不关心地图的有序遍历,我们可以使用unordered_set_ofunordered_multiset_of集合类型。与所有无序容器一样,元素的相等性概念和计算元素的哈希值的机制必须可用。

std::map<T, U>这样的容器具有与bimap<T, unconstrained_set_of<U>>相同的语义。unconstrained_set_of集合类型不提供迭代或查找元素的方法,并且不要求元素是唯一的。而bimap<T, multiset_of<U>>允许非唯一值,它还支持按值查找,这是std::map不支持的。

list_ofvector_of集合类型,像unconstrained_set_of集合类型一样,既不强制唯一性,也不强制任何允许查找的结构。但是,它们可以逐个元素地进行迭代,与unconstrained_set_of不同,因此,您可以使用标准库算法如std::find执行线性搜索。vector_of提供了随机访问。可以使用其sort成员函数对其包含的实体进行排序,随后可以使用std::binary_search执行二分搜索。

更多使用双射图的方法

有几种方法可以使双射图的使用更加表达。在本节中,我们将探讨其中的一些。

标记访问

与其使用leftright来访问容器中的两个对立视图,您可能更喜欢使用更具描述性的名称来访问它们。您可以使用标记或空结构作为标记来实现这一点。这与 Boost 的多索引容器中通过标记而不是数值位置访问索引的方式非常相似。以下代码片段说明了这种技术:

 1 struct name {};
 2 struct nickname {};
 3
 4 typedef boost::bimap<
 5             boostbi::multiset_of<
 6                boostbi::tagged<std::string, name>>,
 7             boostbi::multiset_of<
 8                boostbi::tagged<std::string, nickname>>>
 9         string_bimap_t;
10
11 string_bimap_t namesShortNames;
12
13 auto& names = namesShortNames.by<name>();
14 auto& nicknames = namesShortNames.by<nickname>();

我们为要按名称访问的每个视图定义了一个空结构体标签(第 1-2 行)。然后,我们定义了 bimap 容器类型,使用tagged模板为我们的标签标记单独的集合(第 6、8 行)。最后,我们使用by成员模板来访问单独的视图。虽然使用标签的语法并不是最直接的,但使用by<tag>访问视图的表现力肯定可以使您的代码更清晰、更不容易出错。

使用range成员函数和 Boost Lambda 占位符,可以更简洁地编写对视图的搜索,就像我们在 Boost Multi-index 中所做的那样。以下是一个例子:

 1 #include <boost/bimap/support/lambda.hpp>
 2
 3 …
 4 string_bimap_t namesShortNames;
 5 …
 6 using boost::bimaps::_key;
 7 const auto& range = namesShortNames.right.range("Ch" <= _key,
 8                                                 _key < "W");
 9 
10 for (auto i1 = range.first; i1 != range.second; ++i1) {
11   std::cout << i1->first << ":" << i1->second << '\n';
12 }

调用right视图的range成员函数返回一个名为range的 Boost.Range 对象,实际上是一对迭代器(第 7-8 行)。我们提取两个单独的迭代器(第 10 行),然后遍历返回的范围,打印昵称和全名(第 10-11 行)。使用范围感知算法,我们可以简单地传递范围对象,而不必从中提取迭代器。如果您只想约束范围的一端,可以使用boost::bimaps::unbounded来表示另一端。

投影

从一个视图的迭代器,可以使用project成员模板或project_left/project_right成员函数获取到另一个视图的迭代器。假设给定一个名称,您想找出所有其他共享相同昵称的名称。以下是一种方法:

 1 auto i1 = names.find("Edward");
 2 auto i2 = namesShortNames.project<nickname>(i1);
 3
 4 const auto& range = shortNames.range(_key == i2->first, 
 5                                      _key == i2->first);
 6
 7 for (auto i3 = range.first; i3 != range.second; ++i3) {
 8   std::cout << i3->first << ":" << i3->second << '\n';
 9 }

我们首先使用names视图上的find成员函数获取到匹配名称的迭代器(第 1 行)。然后,我们使用project成员模板将此迭代器投影到昵称视图。如果我们不使用标记的键和值,我们应该使用project_leftproject_right成员函数,具体取决于我们要投影到哪个视图。这将返回昵称视图上相同元素的迭代器(第 2 行)。接下来,使用range成员函数,我们找到所有昵称等于i2->first的条目(第 4-5 行)。然后,通过循环遍历range返回的迭代器范围,打印昵称对(第 7-9 行)。

Boost Bimap 还有其他几个有用的功能,包括将容器视为元素对之间关系的集合的视图,以及在 bimap 中就地修改键和值的能力。www.boost.org上的在线 Bimap 文档非常全面,您应该参考它以获取有关这些功能的更多详细信息。

参考资料

对于多项选择题,选择所有适用的选项:

  1. 在下一章中,我们将转而关注函数组合和元编程技术,这些技术使我们能够编写功能强大、表达力强的应用程序,并具有出色的运行时性能。

a. std::set

b. std::multiset

c. std::unordered_set

d. std::unordered_multiset

  1. multi_index_container中删除一个元素只会使对已删除元素的迭代器失效,而不受索引的影响。

a. True

b. False

c. 取决于索引的类型

  1. 以下哪种 bimap 类型具有与multimap<T, U>等价的语义?

自测问题

multi_index_container上的ordered_non_unique索引具有以下语义:

c. bimap<multiset_of<T>, unconstrained_set_of<U>>

d. bimap<multiset_of<T>, multiset_if<U>>

总结

在本章中,我们专注于专门用于基于多个条件查找对象的容器。具体来说,我们看了 Boost Bimap,这是一个双向映射对象,其键和值都可以高效地查找。我们还看了 Boost Multi-index 容器,这是一种通用的关联容器,具有多个关联索引,每个索引都有助于根据一个条件高效查找对象。

a. bimap<T, multiset_of<U>>

b. bimap<multiset_of<T>, U>

多索引修改方法:www.boost.org/doc/libs/release/libs/multi_index/doc/reference/ord_indices.html#modif

第七章:高阶和编译时编程

许多标准库算法接受可调用实体,称为函数对象(函数指针、函数符等)作为参数。它们调用这些函数对象来计算容器中的各个元素的某个值或执行某些操作。因此,算法的一部分运行时逻辑被封装在一个函数或函数符中,并作为算法的参数提供。函数也可以返回函数对象而不是数据值。返回的函数对象可以应用于一组参数,并可能反过来返回一个值或另一个函数对象。这就产生了高阶变换。这种涉及传递和返回函数的编程风格称为高阶编程

C++模板使我们能够编写类型通用的代码。使用模板,可以在编译时执行分支和递归逻辑,并根据简单的构建块条件地包含、排除和生成代码。这种编程风格称为编译时编程模板元编程

在本章的第一部分,我们将学习使用 Boost Phoenix 库和 C++11 的绑定和 lambda 等设施在 C++中应用高阶编程的应用。在本章的下一部分,我们将学习 C++模板元编程技术,这些技术在编译时执行,帮助生成更高效和更具表现力的代码。在本章的最后一部分,我们将通过将高阶编程技术与元编程相结合,在 C++中创建领域特定语言。本章的主题分为以下几个部分:

  • 使用 Boost 进行高阶编程

  • 使用 Boost 进行编译时编程

  • 领域特定嵌入式语言

在这一章中,我们将探讨一种与面向对象和过程式编程不同的编程范式,它大量借鉴了函数式编程。我们还将开发通用编程技术,最终帮助我们实现更高效的模板库。

使用 Boost 进行高阶编程

考虑一个类型Book,它有三个字符串字段:ISBN、标题和作者(对于我们的目的,假设只有一个作者)。以下是我们可以选择定义这种类型的方式:

 1 struct Book
 2 {
 3   Book(const std::string& id,
 4        const std::string& name,
 5        const std::string& auth)
 6         : isbn(id), title(name), author(auth)
 7   {}
 8
 9   std::string isbn;
10   std::string title;
11   std::string author;
12 };
13
14 bool operator< (const Book& lhs, const Book& rhs)
12 {  return lhs.isbn < rhs.isbn;  }

它是一个带有三个字段和一个构造函数的struct,用于初始化这三个字段。isbn字段唯一标识书籍,因此用于定义Book对象的排序,使用重载的operator<(第 14 行)。

现在假设我们有一个std::vector中的这些Book对象的列表,并且我们想对这些书籍进行排序。由于重载的operator<,我们可以轻松地使用标准库的sort算法对它们进行排序:

 1 #include <vector>
 2 #include <string>
 3 #include <algorithm>
 4 #include <iostream>
 5
 6 // include the definition of struct Book
 7 
 8 int main()
 9 {
10   std::vector<Book> books;
11   books.emplace_back("908..511..123", "Little Prince",
12                      "Antoine St. Exupery");
13   books.emplace_back("392..301..109", "Nineteen Eighty Four",
14                      "George Orwell");
15   books.emplace_back("872..610..176", "To Kill a Mocking Bird",
16                      "Harper Lee");
17   books.emplace_back("392..301..109", "Animal Farm",
18                      "George Orwell");
19
20   std::sort(books.begin(), books.end());
21 }

在前面的代码中,我们将四个Book对象放入向量books中。我们通过调用emplace_back方法(第 11-18 行)而不是push_back来实现这一点。emplace_back方法(在 C++11 中引入)接受存储类型(Book)的构造函数参数,并在向量的布局中构造一个对象,而不是复制或移动预先构造的对象。然后我们使用std::sort对向量进行排序,最终使用Book对象的operator<。如果没有这个重载的运算符,std::sort将无法编译。

这一切都很好,但如果您想按 ISBN 的降序对书籍进行排序怎么办?或者您可能想按作者对书籍进行排序。此外,对于两本具有相同作者的书,您可能希望进一步按标题对它们进行排序。我们将在下一节中看到一种按这种方式对它们进行排序的方法。

函数对象

std::sort算法有一个三参数重载,第三个参数是一个用于比较两个元素的函数对象。这个函数对象应该在最终排序中如果第一个参数出现在第二个参数之前则返回 true,否则返回 false。因此,即使没有重载operator<,你也可以告诉std::sort如何比较两个元素并对向量进行排序。以下是使用排序函数进行排序的方法:

清单 7.1:将函数传递给算法

 1 bool byDescendingISBN(const Book& lhs, const Book& rhs)
 2 {  return lhs.isbn > rhs.isbn; }
 3 
 4 ...
 5 std::vector<Book> books;
 6 ...
 7 std::sort(books.begin(), books.end(), byDescendingISBN);

函数byDescendingISBN接受两本书的 const 引用,并在第一本书的 ISBN(lhs)在字典顺序上大于第二本书(rhs)的 ISBN 时返回 true,否则返回 false。该函数的签名与std::sort算法期望的函数对象兼容。为了按降序对books向量进行排序,我们将指向这个函数的指针传递给std::sort(第 7 行)。

函数指针绝不是你可以传递的唯一可调用实体。函数对象是一种重载了函数调用运算符成员(operator())的类型。通过在一组参数上应用或调用函数对象的实例,你调用了重载的operator()成员。在下面的例子中,我们定义了一个函数对象来按作者名对书籍进行排序,如果作者名相同,则按标题排序:

清单 7.2:定义和传递函数对象给算法

 1 ...
 2 struct CompareBooks
 3 {
 4   bool operator()(const Book& b1, const Book& b2) const {
 5     return (b1.author < b2.author)
 6            || (b1.author == b2.author 
 7                && b1.title < b2.title);
 8   }
 9 };
10
11 ...
12 std::vector<Book> books;
13 ...
14 std::sort(books.begin(), books.end(), CompareBooks());

我们定义了一个名为CompareBooks的函数对象,它重载了operator(),接受两个要比较的Book对象(第 4 行)。如果第一本书的作者名在字典顺序上小于第二本书的作者名,则返回 true。如果两本书的作者相同,则如果第一本书的标题在字典顺序上小于第二本书的标题,则返回 true。为了将这个函数对象作为排序标准使用,我们将CompareBooks的临时实例作为std::sort算法的第三个参数传递(第 14 行)。像CompareBooks这样将一个或多个参数映射到布尔真值的函数对象被称为谓词

提示

术语说明

我们使用术语函数对象来指代所有可调用的实体,可以在应用程序中传递和存储以供以后使用。这些包括函数指针和函数对象,以及其他类型的可调用实体,如未命名函数或lambda,我们将在本章中探讨。

函数对象简单地是定义了重载的函数调用运算符的类或结构。

一个接受一个或多个参数并将它们映射到布尔真值的函数对象通常被称为谓词

函数对象的arity是它所接受的参数数量。没有参数的函数具有 0-arity 或者是nullary,一个参数的函数具有 1-arity 或者是unary,两个参数的函数具有 2-arity 或者是binary,依此类推。

纯函数是一个其返回值仅取决于传递给它的参数值,并且没有副作用的函数。修改不属于函数的本地状态,执行 I/O,或者以其他方式修改执行环境都属于副作用。

当你希望函数对象在调用之间保留一些状态时,函数对象特别有用。例如,想象一下你有一个未排序的名字列表,你只想制作一个以特定字母开头的所有名字的逗号分隔列表。以下是一种方法:

清单 7.3:带状态的函数对象

 1 #include <vector>
 2 #include <string>
 3 #include <iostream>
 4 #include <algorithm>
 5
 6 struct ConcatIfStartsWith {
 7   ConcatIfStartsWith(char c) : startCh(c) {}
 8
 9   void operator()(const std::string& name) {
10     if (name.size() > 0 && name.at(0) == startCh) {
11       csNames += name + ", ";
12     }
13   }
14
15   std::string getConcat() const {
16     return csNames;
17   }
18
19   void reset() { csNames = ""; }
20
21 private:
22   char startCh;
23   std::string csNames;
24 };
25
26 int main() {
27   std::vector<std::string> names{"Meredith", "Guinnevere", 
28       "Mabel", "Myrtle", "Germaine", "Gwynneth", "Mirabelle"};
29
30   const auto& fe = std::for_each(names.begin(), names.end(), 
31                            ConcatIfStartsWith('G'));
32   std::cout << fe.getConcat() << '\n';
33 }

我们定义了一个名为ConcatIfStartsWith的函数对象(第 6 行),它存储一些状态,即要匹配的起始字符(startCh)和包含逗号分隔的名称列表的字符串(csNames)。当在名称上调用函数对象时,它会检查名称是否以指定字符开头,如果是,则将其连接到csNames(第 10-11 行)。我们使用std::for_each算法将ConcatIfStartsWith函数对象应用于名称向量中的每个名称(第 30-31 行),寻找以字母 G 开头的名称。我们传递的函数对象是一个临时的(第 31 行),但我们需要一个引用来访问其中存储的连接字符串。std::for_each算法实际上返回对传递的函数对象的引用,然后我们使用它来获取连接的字符串。这是输出,列出以 G 开头的名称:

Guinnevere, Germaine, Gwynneth, 

这说明了关于函数对象的一个重要观点;当您希望在连续调用函数之间保持状态时,它们特别有用。如果您需要在代码中的多个地方使用它们,它们也非常有用。通过直观地命名它们,可以在使用的地方清楚地表明它们的目的:

   const auto& fe = std::for_each(names.begin(), names.end(), 
                                  ConcatIfStartsWith('G'));

但有时,一个函数对象需要做的事情是微不足道的(例如,检查一个数字是偶数还是奇数)。通常,我们不需要在调用之间维护任何状态。我们甚至可能不需要在多个地方使用它。有时,我们正在寻找的功能可能已经以某种形式存在,也许作为对象的成员函数。在这种情况下,编写一个新的函数对象似乎有些过度。C++11 引入了 lambda 或未命名函数,以精确解决这种情况。

Lambda - 未命名函数文字

字符串"hello"是一个有效的 C++表达式。它有一个明确定义的类型(const char[6]),可以赋值给类型为const char*的变量,并传递给接受const char*类型参数的函数。同样,还有像3.141564000U这样的数字文字,像truefalse这样的布尔文字,等等。C++11 引入了lambda 表达式,用于在调用它们的地方定义匿名函数。通常简称为lambda(来自 Alonzo Church 的λ演算),它们由一个未绑定到函数名称的函数体组成,并用于在程序的词法范围内的任何点生成函数定义,您期望传递一个函数对象。让我们首先通过一个例子来了解如何做到这一点。

我们有一个整数列表,并希望使用std::find_if算法在列表中找到第一个奇数。传递给std::find_if的谓词是使用 lambda 定义的。

清单 7.4:使用 lambda

 1 #include <vector>
 2 #include <algorithm>
 3 #include <cassert>
 4 
 5 int main() {
 6   std::vector<int> vec{2, 4, 6, 8, 9, 1};
 7 
 8   auto it = std::find_if(vec.begin(), vec.end(),
 9                         [](const int& num) -> bool 
10                         {  return num % 2 != 0; }
11                         );
12 
13   assert(it != vec.end() && *it == 9);
14 }

计算一个数字是奇数还是偶数的 lambda 是作为第三个参数传递给std::find_if的代码块(第 9-10 行)。让我们单独看一下 lambda 以了解语法。首先,考虑这个函数做什么;给定一个整数,如果它是奇数则返回 true,否则返回 false。因此,我们有一个未命名函数,将int映射到bool。在 lambda-land 中编写这个的方式如下:

[](const int& num) -> bool

我们使用一对空方括号引入未命名函数,并通过编写类似于常规函数的参数列表,后跟一个箭头和返回类型来描述映射。在此之后,我们编写函数体,就像为正常函数编写一样:

{  return num % 2 != 0;  }

方括号对,通常称为lambda 引入者,不一定为空,我们很快就会看到。这种语法还有其他几种变体,但您可以仅使用这一小部分语法来定义 lambda。在简单情况下,lambda 的返回类型规范是可选的,编译器可以轻松从函数体中推断出返回类型。因此,我们可以重新编写前面示例中的 lambda,而不需要返回类型,因为函数体实际上非常简单:

[](const int& num) { return num % 2 != 0; }

Lambda 捕获

我们在前面的示例中定义的 lambda 是一个没有任何状态的纯函数。实际上,lambda 如何可能存储在调用之间持续存在的状态?实际上,lambda 可以访问来自周围范围的局部变量(以及全局变量)。为了启用这样的访问,我们可以在 lambda 引入器中指定捕获子句,列出了来自周围范围的哪些变量可以访问 lambda 以及如何。考虑以下示例,其中我们从名称向量中过滤出长度超过用户指定长度的名称,并返回仅包含较短名称的向量:

清单 7.5:带捕获的 lambda

 1 #include <vector>
 2 #include <string>
 3 #include <algorithm>
 4 #include <iterator>
 5 typedef std::vector<std::string> NameVec;
 6
 7 NameVec getNamesShorterThan(const NameVec& names,
 8                             size_t maxSize) {
 9   NameVec shortNames;
10   std::copy_if(names.begin(), names.end(),
11                std::back_inserter(shortNames),
12                maxSize {
13                   return name.size() <= maxSize;
14                }
15                );
16   return shortNames;
17 }

getNamesShorterThan函数接受两个参数:一个名为names的向量和一个名为maxSize的变量,该变量限制要过滤的字符串的大小。它将names向量中短于maxSize的名称复制到第二个名为shortNames的向量中,使用标准库中的std::copy_if算法。我们使用 lambda 表达式(第 12-14 行)生成std::copy_if的谓词。您可以看到我们在方括号中命名了来自周围词法范围的maxSize变量(第 12 行),并在 lambda 主体中访问它以比较传递的字符串的大小(第 13 行)。这使得 lambda 内部对maxSize变量的只读访问成为可能。如果我们想要潜在地访问周围范围中的任何变量而不是特定的变量,我们可以在方括号中使用等号来编写 lambda;这将隐式捕获来自周围范围的任何使用的变量:

= {
   return name.size() <= maxSize;
}

您可能希望修改来自周围范围的局部变量的副本,而不影响周围范围中的值。为了使您的 lambda 能够执行此操作,必须将其声明为 mutable:

= mutable -> bool {
 maxSize *= 2;
   return name.size() <= maxSize;
}

mutable关键字跟在参数列表后面,但如果指定了返回类型,则出现在返回类型之前。这不会影响周围范围中maxSize的值。

您还可以在 lambda 内部修改来自周围范围的变量。为此,必须通过在方括号中的变量名称前加上一个和符号来引用捕获变量。

这是使用 lambda 重写的 6.3 清单:

清单 7.6:lambda 中的引用捕获

 1 #include <vector>
 2 #include <string>
 3 #include <algorithm>
 4 #include <iostream>
 5
 6 int main() {
 7   std::string concat;
 8   char startCh = 'M';
 9   std::vector<std::string> names{"Meredith", "Guinnevere", "Mabel"
10                  , "Myrtle", "Germaine", "Gwynneth", "Mirabelle"};
11 
12   std::for_each(names.begin(), names.end(), 
13                &concat, startCh {
14                  if (name.size() > 0 && name[0] == startCh) {
15                    concat += name + ", ";
16                  }
17                });
18   std::cout << concat << '\n';
19 }

在前面的示例中,我们将来自向量names的所有以特定字符开头的名称连接起来。起始字符取自变量startCh。连接的字符串存储在变量concat中。我们对向量的元素调用std::for_each,并传递一个 lambda,该 lambda 显式地将concat作为引用捕获(带有前导和符号),并将startCh作为来自周围范围的只读值传递(第 13 行)。因此,它能够附加到concat(第 15 行)。此代码打印以下输出:

Meredith, Mabel, Myrtle, Mirabelle

在最新的 C++标准中,被称为 C++14,lambda 变得更加巧妙。您可以编写一个通用 lambda,其参数类型是根据上下文推断的。例如,在 C++14 中,您可以按照前面示例中的调用std::for_each,编写如下:

  std::for_each(names.begin(), names.end(), 
               &concat, startCh {
                 if (name.size() > 0 && name[0] == startCh) {
                   concat += name + ", ";
                 }
               });

lambda 的参数类型写为const auto&,编译器根据迭代序列中元素的类型推断为const std::string&

委托和闭包

假设您正在编写一个用于读取消息队列上传入消息的高级 C++ API。您的 API 的客户端必须注册其感兴趣的消息类型,并传递一个回调函数对象,当您感兴趣的消息到达时将调用该对象。您的 API 可以是Queue类的成员。以下是一个可能的 API 签名:

class Queue
{
public:
  ...
 template <typename CallbackType>
 int listen(MsgType msgtype, CallbackType cb);
  ...
};

listen成员模板接受两个参数:消息类型msgtype,用于标识感兴趣的消息,以及回调函数对象cb,当新消息到达时将调用它。由于我们希望客户端能够传递函数指针、成员函数指针、仿函数以及 lambda 作为回调,因此我们将listen作为一个成员模板参数化为回调类型。当然,回调应该具有特定的签名。假设它应该与以下函数的签名兼容:

void msgRead(Message msg);

在这里,Message是从队列中读取的消息的类型。listen成员模板有点过于宽松,因为它可以实例化为不符合前面签名的函数对象。对于不符合签名的回调,编译错误会发生在调用listen内部的回调处,而不是传递不符合签名的回调的地方。这可能会使调试编译器错误变得更加困难。

Boost.Function 库及其 C++11 版本std::function提供了专门设计用于解决此类问题的函数对象包装器。我们可以将函数msgRead的类型写为void (Message)。具有 N 个参数的函数类型的一般语法如下:

return-type(param1-type, param2-type, ..., paramN-type)

与之前的函数类型对应的更熟悉的函数指针类型将是:

return-type (*)(param1-type, param2-type, ..., paramN-type)

因此,函数int foo(double, const char*)的类型将是:

int(double, const char*);

指针将是以下类型:

int (*)(double, const char*);

使用具有适当函数类型的std::function,我们可以声明listen,以便它只接受符合正确签名的函数对象:

#include <boost/function.hpp>

class Queue
{
public:
  ...
 int listen(MsgType msgtype, boost::function<void(Message)> cb);
  ...
};

回调现在被声明为boost::function<void(Message)>类型。现在可以使用指向全局函数、仿函数或甚至 lambda 调用listen,只有当函数对象具有符合签名时才会编译。如果使用的是 C++11 编译器,我们可以使用std::function代替boost::function。在 C++11 之前的编译器上,boost::function支持最多十个参数的签名,而std::function没有任何这样的限制,因为它使用了 C++11 的可变模板。有关boost::function的更多特性及其与std::function的区别(这些区别很小),您可以参考在线文档。

将非静态成员函数作为回调需要更多的工作,因为非静态成员必须在其类的实例上调用。考虑以下类MessageHandler,它有一个成员handleMessage

class MessageHandler
{
public:
  ...
  void handleMessage(Message msg);
};

handleMessage成员函数会隐式地传递一个指向其所调用的MessageHandler对象的指针作为其第一个参数;因此它的有效签名是:

void(MessageHandler*, Message);

当我们想要将其作为回调传递给Queue::listen时,我们可能已经知道要调用handleMessage的对象,如果我们可以在调用 listen 时以某种方式附加该对象实例,那将是很好的。有几种方法可以做到这一点。

第一种方法涉及将对handleMessage的调用包装在 lambda 中,并将其传递给listen。以下代码片段说明了这一点:

清单 7.7:使用闭包的成员函数回调

 1 MessageHandler *handler = new MessageHandler(...);
 2 Queue q(...);
 3 ...
 4 q.listen(msgType, handler
 5                   {  handler->handleMessage(msg);  }
 6                   );

在这里,listen的第二个参数是使用 lambda 表达式生成的,它还捕获了来自周围范围的handler对象的指针。在这个例子中,handler是调用范围内的一个局部变量,但是 lambda 捕获了它并将其绑定到它生成的函数对象中。这个函数对象不会立即被调用,而是延迟到队列上接收到感兴趣的消息时,它会将调用转发到handler对象指针上的handleMessage方法。

handler指针是在调用范围内创建的,但通过 lambda 捕获变得间接可访问到另一个范围。这被称为动态作用域,在创建它们的词法作用域中绑定到变量的这种函数被称为闭包。当然,在调用handleMessage时,handler指针仍然必须指向一个有效的MessageHandler对象,而不仅仅是在 lambda 创建时。

很多时候,这样的 lambda 表达式会从类的成员函数内部生成,比如MessageHandler类的成员函数,并且会捕获this指针,从而简化语法:

清单 7.8:在 lambda 中捕获 this 指针

 1 class MessageHandler
 2 {
 3 public:
 4   ...
 5   void listenOnQueue(Queue& q, MessageType msgType) {
 6     q.listen(msgType, this 
 7                       { handleMsg(msg); } );
 8   }
 9 
10   void handleMsg(Message msg) { ... }
11 };

在前面的例子中,我们使用 lambda 表达式创建了一个闭包,它捕获了this指针(第 6 行)。在 lambda 内部调用handleMsg会自动绑定到this指针,就像在成员函数中一样。回调函数,特别是绑定到特定对象的回调函数,如前所述,有时被称为委托

boost::function / std::function包装器提供了一种有效的、经过类型检查的方式来传递和返回函数对象作为回调或委托。它们有时被称为多态函数包装器,因为它们完全将底层可调用实体(函数指针、函数对象等)的类型从调用者中抽象出来。大多数实现都会动态分配内存,因此您应该认真评估它们对运行时性能的影响。

部分函数应用

给定标准库函数pow

double pow(double base, double power);

考虑一下代码行x = pow(2, 3)的效果。当遇到这行代码时,函数pow立即被调用,带有两个参数,值为 2 和 3。函数pow计算 2 的 3 次方,并返回值 8.0,然后赋给x

现在,假设你有一个数字列表,你想把它们的立方放入另一个列表中。标准库算法std::transform非常适合这个任务。我们只需要找到正确的函数对象来将数字提升到它们的立方幂。以下函数对象接受一个数字参数,并使用pow函数将其提升到特定的幂:

#include <cmath>

struct RaiseTo {
  RaiseTo(double power) : power_(power) {}

  double operator()(double base) const {
    return pow(base, power_);
  }

  double power_;
};

我们也可以使用 lambda 表达式来生成函数对象,就像上一节的清单 7.7 和 7.8 中所示。使用RaiseTostd::transform算法,以下代码完成了任务:

std::vector<double> nums, raisedToThree;
...
std::transform(nums.begin(), nums.end(), 
               std::back_inserter(raisedToThree),
               RaiseTo(3));

RaiseTo中的核心计算是由pow函数完成的。RaiseTo函数对象通过构造函数参数和与std::transform期望的调用签名兼容的方式来固定幂。

想象一下,如果在 C++中可以不使用函数对象或 lambda 来做到这一点。如果使用以下虚构的语法,你可以做同样的事情吗?

std::transform(nums.begin(), nums.end(), 
               std::back_inserter(raisedToThree),
               pow(_, 3));

就好像你正在传递pow函数,其中有两个参数中的一个被固定为 3,并要求transform算法填写空白;提供要提升的数字。表达式pow(_, 3)将会评估为一个函数对象,接受一个参数而不是 2 个。我们基本上使用RaiseTo函数对象实现了这一点,但 Boost Bind 库及其 C++11 版本的std::bind帮助我们以更少的语法来实现这一点。正式地说,我们刚刚做的被称为部分函数应用

使用bind创建一个部分应用的pow函数对象,你需要写:

boost::bind(pow, _1, 3)

前面的表达式生成了一个无名的函数对象,它接受一个参数并返回它的值的 3 次方,使用标准库函数pow。与我们的虚构语法的相似之处应该是显而易见的。要立方的值作为生成的函数对象的唯一参数传递,并映射到特殊的占位符_1

清单 7.9:使用 Boost Bind

 1 #include <boost/bind.hpp>
 2 
 3 std::vector<double> nums, raisedToThree;
 4 std::transform(nums.begin(), nums.end(),
 5                std::back_inserter(raisedToThree),
 6                boost::bind(pow, _1, 3));

如果生成的函数对象接受更多的参数,则可以根据它们在参数列表中的位置将它们映射到占位符_2_3等。一般来说,第 n 个参数映射到占位符_n。Boost Bind 默认支持最多九个位置占位符(_1_9);std::bind可能支持更多(根据编译器的不同),但您需要从std::placeholders命名空间中访问它们,使用以下指令之一:

using std::placeholders::_1;
using std::placeholders::_2;
// etc. OR
using namespace std::placeholders;

您可以通过重新排序它们的参数而不改变函数 arity 来调整函数以实现新的功能。例如,给定返回true的函数std::less,如果它的第一个参数小于它的第二个参数,我们可以生成一个函数对象,如果它的第一个参数大于它的第二个参数,则返回true。以下表达式生成了这个:

boost::bind(std::less<int>(), _2, _1)

在这里,std::less<int>接受两个参数,我们生成了一个包装函数对象,它也接受两个参数,但在将它们传递给std::less之前交换它们的位置。我们可以直接在原地调用生成的函数对象,就像这样:

boost::bind(std::less<int>(), _2, _1)(1, 10)

我们可以安全地断言 1 不大于 10,但实际上是小于:

assert( std::less<int>()(1, 10) );
assert( !boost::bind(std::less<int>(), _2, _1)(1, 10) );

Boost Bind 还可用于生成委托,清单 7.7 和 7.8 中还演示了生成委托的其他方法。以下是使用boost::bind重写的清单 7.8:

清单 7.10:使用 Boost Bind 生成委托

 1 class MessageHandler
 2 {
 3 public:
 4   ...
 5   void listenOnQueue(Queue& q, MessageType msgType) {
 6     q.listen(msgType, boost::bind(&MessageHandler::handleMsg,
 7                                   this, _1));
 8   }
 9 
10   void handleMsg(Message msg) { ... }
11 };

我们必须将一个成员函数绑定到一个对象实例。我们通过将this绑定到MessageHandler::handleMsg的第一个参数(第 6-7 行)来实现这一点。这种技术通常用于在集合中的每个对象上调用成员函数。此外,boost::bind / std::bind智能地处理对象、指针、智能指针等,因此您无需根据对象的复制、指针或智能指针来编写不同的绑定器。在下面的示例中,我们获取了一个std::string的向量,使用size成员函数计算它们的长度,并将它们放入一个长度向量中:

清单 7.11:使用 Boost Bind 生成委托

 1 #include <functional>
 2 ...
 3 std::vector<std::string> names{"Groucho", "Chico", "Harpo"};
 4 std::vector<std::string::size_type> lengths;
 5 using namespace std::placeholders;
 67 std::transform(names.begin(), names.end(), 
 8                std::back_inserter(lengths),
 9                std::bind(&std::string::size, _1));

长度是通过在每个std::string对象上调用size成员函数来计算的。表达式std::bind(&std::string::size, _1)生成了一个未命名的函数对象,它调用传递给它的string对象的size成员。

即使names是指向std::string对象的指针或智能指针的向量,绑定表达式(第 9 行)也不需要改变。bind函数按值传递其参数。因此,在前面的示例中,每个字符串都被复制到生成的函数对象中,这可能导致性能问题。

另一个名为boost::mem_fn的函数模板及其标准库对应物std::mem_fn使得在对象上调用成员函数和生成委托变得更加容易。mem_fn函数模板创建了一个指向类成员的包装器。对于类X中的 arityN的成员函数fmem_fn(&X::f)生成一个 arityN+1的函数对象,其第一个参数必须是对对象的引用、指针或智能指针,该对象上调用成员函数。

我们可以编写清单 7.11 来使用mem_fn

 1 #include <boost/mem_fn.hpp> // <functional> for std
 2
...
 7 std::transform(names.begin(), names.end(), 
 8                std::back_inserter(lengths),
 9                boost::mem_fn(&std::string::size));

因为std::string::size是 nullary 的,boost::mem_fn生成的函数对象是一元的,并且可以直接与transform一起使用,无需额外的绑定。节省了不必写_1占位符,因此语法上更简洁。

当我们使用bind生成函数对象时,它不会立即检查参数类型和数量是否与绑定到的函数的签名匹配。只有在调用生成的函数对象时,编译器才会检测到参数类型和 arity 不匹配:

1 std::string str;
2 auto f = boost::bind(&std::string::size, 5); // binds to literal 5
3 auto g = boost::bind(&std::string::size, _1, 20); // binds two args

例如,即使你不能在数字文字 5 上调用 std::stringsize 成员函数(第 2 行),前面的代码也会编译。size 成员函数也不接受额外的数字参数(第 3 行)。但是一旦你尝试调用这些生成的函数对象,你将因为类型和参数数量不匹配而得到错误:

4 f(); // error: operand has type int, expected std::string
5 g(str); // error: std::string::size does not take two arguments

绑定重载的成员函数需要更多的语法工作。使用 bind 生成甚至是中等复杂度的函数是一个嵌套绑定的练习,这往往会产生难以维护的代码。一般来说,有了 C++11 lambda 的可用性以及在 C++14 中的进一步完善,应该优先使用 lambda 而不是 bind 作为生成匿名函数对象的机制。只有在使用 bind 使你的代码比 lambda 更具表现力时才使用它。

使用 Boost 进行编译时编程

模板允许我们编写独立于操作数特定类型的 C++ 代码,因此可以在大量类型的情况下不变地工作。我们可以创建函数模板类模板(或结构模板),它们接受类型参数、非类型参数(如常量整数)以及模板参数。当类模板的特化被实例化时,从未直接或间接调用的成员函数将不会被实例化。

C++ 模板的威力不仅仅在于能够编写通用代码。C++ 模板是一个强大的计算子系统,我们可以利用它来审视 C++ 类型,获取它们的属性,并编写复杂的递归和分支逻辑,这些逻辑在编译时执行。利用这些能力,我们可以定义对每种操作类型高度优化的通用接口。

使用模板进行基本的编译时控制流

在本节中,我们简要地看一下使用模板生成的分支和递归逻辑。

分支

考虑函数模板 boost::lexical_cast,它在第二章中介绍过,Boost 实用工具的初次尝试。要将 string 转换为 double,我们可以编写如下代码:

std::string strPi = "3.141595259";
double pi = boost::lexical_cast<double>(strPi);

lexical_cast 的主模板是这样声明的:

template <typename Target, typename Source>Target lexical_cast(const Source&);

lexical_cast 的默认实现(称为主模板)通过类似 ostringstream 的接口将源对象写入内存缓冲区,然后通过类似 istringstream 的另一个接口从中读取。这种转换可能会产生一些性能开销,但具有表现力的语法。现在假设对于一个特别性能密集型的应用程序,你想要提高这些字符串到双精度浮点数的转换性能,但又不想用其他函数调用替换 lexical_cast。你会怎么做?我们可以创建 lexical_cast 函数模板的显式特化,以便根据转换中涉及的类型在编译时执行分支。由于我们想要覆盖默认实现的 stringdouble 转换,这就是我们会写特化的方式:

清单 7.12:函数模板的显式特化

 1 namespace boost {
 2 template <>
 3 double lexical_cast<double, std::string>(
 4                          const std::string& str)
 5 {
 6   const char *numstr = str.c_str();
 7   char *end = nullptr;
 8   double ret = strtod(numstr, &end);
 9   
10   if (end && *end != '\0') {
11     throw boost::bad_lexical_cast();
12   }
13
14   return ret;
15 }
16 } // boost

template 关键字与空参数列表 (template<>) 表示这是特定类型参数的特化(第 2 行)。模板标识符 lexical_cast <double, std::string> 列出了特化生效的特定类型(第 3 行)。有了这个特化,编译器在看到这样的代码时会调用它:

std::string strPi = "3.14159259";
double pi = boost::lexical_cast<double>(strPi);

请注意,重载函数模板(而不仅仅是函数)是可能的。例如:

template<typename T> void foo(T);     // 1
template<typename T> void foo(T*);    // 2
template<typename T> T foo(T, T);     // 3
void foo(int);                        // 4
template<> void foo<double>(double);  // 5

int x;
foo(&x);   // calls 2
foo(4, 5); // calls 3
foo(10);   // calls 4
foo(10.0); // calls 5

在前面的例子中,foo是一个函数模板(1),它被重载(2 和 3)。函数foo本身也被重载(4)。函数模板foo(1)也被专门化(5)。当编译器遇到对foo的调用时,它首先寻找匹配的非模板重载,如果找不到,则寻找最专门化的模板重载。在没有匹配的专门化重载的情况下,这将简单地解析为主模板。因此,对foo(&x)的调用解析为template<typename T> void foo(T*)。如果不存在这样的重载,它将解析为template<typename T> void foo(T)

对于类模板也可以创建专门化。除了显式专门化之外,还可以创建类模板的部分专门化,为一类类型专门化一个类模板。

template <typename T, typename U>
class Bar { /* default implementation */ };

template <typename T>
class Bar<T*, T> { /* implementation for pointers */ };

在前面的例子中,主模板Bar接受两个类型参数。我们为Bar创建了一个部分特化,对于这些情况,其中这两个参数中的第一个是指针类型,第二个参数是第一个参数的指针类型。因此,实例化Bar<int, float>Bar<double, double*>将实例化主模板,但Bar<float*, float>Bar<Foo*, Foo>等将实例化部分特化模板。请注意,函数不能被部分指定。

递归

使用模板进行递归最好通过一个在编译时计算阶乘的例子来说明。类模板(以及函数模板)可以接受整数参数,只要这些值在编译时是已知的。

清单 7.13:使用模板进行编译时递归

 1 #include <iostream>
 2
 3 template <unsigned int N>
 4 struct Factorial
 5 {
 6   enum {value = N * Factorial<N-1>::value};
 7 };
 8
 9 template <>
10 struct Factorial<0>
11 {
12   enum {value = 1};  // 0! == 1
13 };
14
15 int main()
16 {
17   std::cout << Factorial<8>::value << '\n';  // prints 40320
18 }

用于计算阶乘的主模板定义了一个编译时常量枚举valueFactorial<N>中的value枚举包含N的阶乘值。这是通过递归计算的,通过实例化Factorial模板为N-1并将其嵌套的value枚举与N相乘来实现的。停止条件由专门化的Factorial为 0 提供。这些计算发生在编译时,因为Factorial模板被用逐渐变小的参数实例化,直到Factorial<0>停止进一步的实例化。因此,值40320完全在编译时计算,并嵌入到构建的二进制文件中。例如,我们可以编写以下内容,它将编译并在堆栈上生成一个包含 40320 个整数的数组:

int arr[Factorial<8>::value];  // an array of 40320 ints

Boost 类型特征

Boost 类型特征库提供了一组模板,用于在编译时查询类型的属性并生成派生类型。它们在通用代码中很有用,即使用参数化类型的代码,用于根据类型参数的属性选择最佳实现。

考虑以下模板:

 1 #include <iostream>
 2
 3 template <typename T>
 4 struct IsPointer {
 5   enum { value = 0 };
 6 };
 7
 8 template <typename T>
 9 struct IsPointer <T*> {
10   enum { value = 1 };
11 };
12
13 int main() {
14   std::cout << IsPointer<int>::value << '\n';
15   std::cout << IsPointer<int*>::value << '\n';
16 }

IsPointer模板有一个名为value的嵌套枚举。这在主模板中设置为 0。我们还为指针类型的参数定义了IsPointer的部分特化,并将嵌套的value设置为 1。这个类模板有什么用呢?对于任何类型T,只有当T是指针类型时,IsPointer<T>::value才为 1,否则为 0。IsPointer模板将其类型参数映射到一个编译时常量值 0 或 1,这可以用于进一步的编译时分支决策。

Boost 类型特征库中充满了这样的模板(包括boost::is_pointer),它们可以获取有关类型的信息,并且还可以在编译时生成新类型。它们可以用于选择或生成针对手头类型的最佳代码。Boost 类型特征在 2007 年被接受为 C++ TR1 版本,并且在 C++11 中,标准库中有一个类型特征库。

每个类型特征都在自己的头文件中定义,这样您就可以只包含您需要的那些类型特征。例如,boost::is_pointer将在boost/type_traits/is_pointer.hpp中定义。相应的std::is_pointer(在 C++11 中引入)定义在标准头文件type_traits中,没有单独的标准头文件。每个类型特征都有一个嵌入类型称为type,此外,它可能有一个名为value的 bool 类型成员。以下是使用一些类型特征的示例。

清单 7.14:使用类型特征

 1 #include <boost/type_traits/is_pointer.hpp>
 2 #include <boost/type_traits/is_array.hpp>
 3 #include <boost/type_traits/rank.hpp>
 4 #include <boost/type_traits/extent.hpp>
 5 #include <boost/type_traits/is_pod.hpp>
 6 #include <string>
 7 #include <iostream>
 8 #include <cassert>
 8
 9 struct MyStruct {
10   int n;
11   float f;
12   const char *s;
13 };
14
15 int main()
16 {
17 // check pointers
18   typedef int* intptr;
19   std::cout << "intptr is "
20             << (boost::is_pointer<intptr>::value ?"" :"not ") 
21             << "pointer type\n";
22 // introspect arrays
23   int arr[10], arr2[10][15];
24   if (boost::is_array<decltype(arr)>::value) {
25     assert(boost::rank<decltype(arr)>::value == 1);
26     assert(boost::rank<decltype(arr2)>::value == 2);
27     assert(boost::extent<decltype(arr)>::value == 10);
28     assert(boost::extent<decltype(arr2)>::value == 10);
29     assert((boost::extent<decltype(arr2), 1>::value) == 15);
30     std::cout << "arr is an array\n";
31   }
32
33 // POD vs non-POD types
34   std::cout << "MyStruct is " 
35             << (boost::is_pod<MyStruct>::value ?"" : "not ")
36             << "pod type." << '\n';
37   std::cout << "std::string is " 
38             << (boost::is_pod<std::string>::value ?"" : "not ")
40             << "pod type." << '\n';
41 }

在这个例子中,我们使用了许多类型特征来查询有关类型的信息。我们将类型intptr定义为整数指针(第 18 行)。将boost::is_pointer应用于intptr将返回 true(第 20 行)。

此处使用的decltype说明符是在 C++ 11 中引入的。它生成应用于表达式或实体的类型。因此,decltype(arr)(第 24 行)返回 arr 的声明类型,包括任何constvolatile限定符。这是计算表达式类型的有用手段。我们将boost::is_array特征应用于数组类型,显然返回 true(第 24 行)。要找到数组的维数或秩,我们使用特征boost::rank(第 25 和 26 行)。arr[10]的秩为 1(第 25 行),但arr2[10][15]的秩为 2(第 26 行)。boost::extent特征用于查找数组秩的范围。它必须传递数组的类型和秩。如果未传递秩,则默认为 0,并返回一维数组的范围(第 27 行)或多维数组的零维(第 28 行)。否则,应明确指定秩(第 29 行)。

boost::is_pod特征返回一个类型是否是 POD 类型。它对于一个没有任何构造函数或析构函数的简单结构,如MyStruct,返回 true(第 34 行),对于显然不是 POD 类型的std::string,返回 false(第 38 行)。

如前所述,这些特征中还有一个嵌入类型称为type。这被定义为boost::true_typeboost::false_type,具体取决于特征返回 true 还是 false。现在假设我们正在编写一个通用算法,将任意对象的数组复制到堆上的数组中。对于 POD 类型,整个数组的浅复制或memcpy就足够了,而对于非 POD 类型,我们需要逐个元素复制。

清单 7.15:利用类型特征

 1 #include <boost/type_traits/is_pod.hpp>
 2 #include <cstring>
 3 #include <iostream>
 4 #include <string>
 5 
 6 struct MyStruct {
 7   int n; float f;
 8   const char *s;
 9 };
10
11 template <typename T, size_t N>
12 T* fastCopy(T(&arr)[N], boost::true_type podType)
13 {
14   std::cerr << "fastCopy for POD\n";
15   T *cpyarr = new T[N];
16   memcpy(cpyarr, arr, N*sizeof(T));
17
18   return cpyarr;
19 }
20
21 template <typename T, size_t N>
22 T* fastCopy(T(&arr)[N], boost::false_type nonPodType)
23 {
24   std::cerr << "fastCopy for non-POD\n";
25   T *cpyarr = new T[N];
26   std::copy(&arr[0], &arr[N], &cpyarr[0]);
27
28   return cpyarr;
29 }
30
31 template <typename T, size_t N>
32 T* fastCopy(T(&arr)[N])
33 {
34   return fastCopy(arr, typename boost::is_pod<T>::type());
35 }
36
37 int main()
38 {
39   MyStruct podarr[10] = {};
40   std::string strarr[10];
41
42   auto* cpyarr = fastCopy(podarr);
43   auto* cpyarr2 = fastCopy(strarr);
44   delete []cpyarr;
45   delete []cpyarr2;
46 }

fastCopy函数模板在堆上创建数组的副本(第 31-35 行)。我们创建了两个重载:一个用于复制 POD 类型(第 11-12 行),另一个用于复制非 POD 类型(第 21-22 行),在第一种情况下添加boost::true_type类型的第二个参数,在第二种情况下添加boost::false_type类型的第二个参数。我们创建了两个数组:一个是 POD 类型MyStruct,另一个是非 POD 类型std::string(第 42-43 行)。我们在两者上调用fastCopy,这将解析为单参数重载(第 32 行)。这将调用fastCopy的两个参数重载,传递boost::is_pod<T>::type的实例作为第二个参数(第 34 行)。这将根据存储的类型T是 POD 类型还是非 POD 类型自动路由调用到正确的重载。

本书的范围内有许多类型特征,远远超出我们可以涵盖的范围。您可以使用类型特征来检查一个类型是否是另一个类型的基类(boost::is_base),一个类型是否可以被复制构造(boost::is_copy_constructible),是否具有特定的操作符(例如,boost::has_pre_increment),是否与另一个类型相同(boost::is_same)等等。在线文档是挖掘特征并找到适合当前工作的特征的好地方。

SFINAE 和 enable_if / disable_if

每次编译器遇到与函数模板同名的函数调用时,它会创建一个匹配模板和非模板重载的重载解析集。编译器根据需要推断模板参数,以确定哪些函数模板重载(及其特化)符合条件,并在此过程中实例化符合条件的模板重载。如果在模板的参数列表或函数参数列表中替换推断出的类型参数导致错误,这不会导致编译中止。相反,编译器会从重载解析集中移除该候选项。这被称为替换失败不是错误SFINAE。只有在过程结束时,重载解析集为空(没有候选项)或有多个同样好的候选项(歧义)时,编译器才会标记错误。

利用一些巧妙的技巧,涉及编译时类型计算,可以利用 SFINAE 有条件地包含模板或从重载解析集中排除它们。最简洁的语法是由boost::enable_if / boost::disable_if模板提供的,它们是 Boost.Utility 库的一部分。

让我们编写一个函数模板,将一个元素数组复制到另一个数组中。主模板的签名如下:

template <typename T, size_t N>
void copy(T (&lhs)[N], T (&rhs)[N]);

因此,您传递两个存储相同类型元素的相同大小的数组,第二个参数的元素按正确顺序复制到第一个数组中。我们还假设数组永远不会重叠;这保持了实现的简单性。不用说,这不是这样的赋值可以发生的最一般情况,但我们稍后会放宽一些这些限制。这是此模板的通用实现:

 1 template <typename T, size_t N>
 2 void copy(T (&lhs)[N], T (&rhs)[N])
 3 {
 4   for (size_t i = 0; i < N; ++i) {
 5     lhs[i] = rhs[i];
 6   }
 7 }

这里的第一个优化机会是当 T 是 POD 类型且位拷贝足够好且可能更快时。我们将为 POD 类型创建一个特殊的实现,并使用 SFINAE 仅在处理 POD 类型数组时选择此实现。我们的技术应该在处理非 POD 类型数组时将此重载排除在重载集之外。这是 POD 类型的特殊实现:

 1 // optimized for POD-type
 2 template <typename T, size_t N>
 3 void copy(T (&lhs)[N], T (&rhs)[N])
 4 {
 5   memcpy(lhs, rhs, N*sizeof(T));
 6 }

如果您注意到,这两个实现具有相同的签名,显然不能共存。这就是boost::enable_if模板发挥作用的地方。boost::enable_if模板接受两个参数:一个类型T和第二个类型E,默认为voidenable_if定义了一个名为type的嵌入类型,当T有一个名为type的嵌入类型且T::typeboost::true_type时,它被 typedef 为E。否则,不定义嵌入类型。使用enable_if,我们修改了优化实现。

清单 7.16:使用 enable_if

#include <boost/utility/enable_if.hpp>
#include <boost/type_traits/is_pod.hpp>

// optimized for POD-type
template <typename T, size_t N>
typename boost::enable_if<boost::is_pod<T>>::type
copy(T (&lhs)[N], T (&rhs)[N])
{
  memcpy(lhs, rhs, N*sizeof(T));
}

typename关键字是必需的,因为否则编译器无法知道表达式boost::enable_if<boost::is_pod<T>>::type是一个类型还是一个成员。

如果我们现在实例化一个非 POD 类型的数组,它将解析为默认实现:

std::string s[10], s1[10];
copy(s1, s);  // invokes the generic template

copy的调用会导致编译器实例化两个模板,但boost::is_pod<std::string>::typeboost::false_type。现在enable_if<false_type>没有嵌套类型,这是copy版本的返回类型规范所要求的。因此,存在替换失败,这个重载被从重载解析集中移除,并调用第一个或通用实现。现在考虑以下情况,我们尝试复制 POD 类型(double)的数组:

double d[10], d1[10];
copy(d1, d);

在当前情况下,POD 优化版本将不再遇到替换失败,但默认实现也将与此调用兼容。因此,会出现歧义,这将导致编译器错误。为了解决这个问题,我们必须确保通用实现这次从重载集中豁免自己。这是通过在通用实现的返回类型中使用 boost::disable_if(实际上是 boost::enable_if 的否定形式)来实现的。

清单 7.17:使用 disable_if

 1 template <typename T, size_t N>
 2 typename boost::disable_if<boost::is_pod<T>>::type
 3 copy(T (&lhs)[N], T (&rhs)[N])
 4 {
 5   for (size_t i = 0; i < N; ++i) {
 6     lhs[i] = rhs[i];
 7   }
 8 }

T 是 POD 类型时,is_pod<T>::typeboost::true_typeboost::disable_if<true_type> 没有嵌套的 type,因此在通用实现中会发生替换失败。这样,我们构建了两个互斥的实现,在编译时正确解析。

我们还可以使用 boost::enable_if_c<> 模板,它接受一个布尔参数而不是类型。boost::enable_if_c<true> 有一个嵌入的 type,而 boost::enable_if_c<false> 没有。在清单 7.17 中,返回类型将如下所示:

typename boost::disable_if_c<boost::is_pod<T>::value>::type

标准库在 C++11 中只有 std::enable_if,它的行为类似于 boost::enable_if_c,接受一个布尔参数而不是类型。它可以从标准头文件 type_traits 中获得。

Boost 元编程库(MPL)

Boost 元编程库,简称 MPL,是一个用于模板元编程的通用库。它在 Boost 代码库中无处不在,大多数库都使用了 MPL 的一些元编程功能。一些库,如 Phoenix、BiMap、MultiIndex 和 Variant,使用得非常频繁。它被广泛用于类型操作和通过条件选择特定模板实现进行优化。本节是关于 MPL 涉及的一些概念和技术的简要概述。

元函数

MPL 库的核心是元函数。形式上,元函数要么是只有类型参数的类模板,要么是一个类,它公开一个名为 type 的嵌入类型。实际上,如果有的话,类型参数类似于函数的参数,而根据参数在编译时计算得到的嵌入 type 类似于函数的返回值。

Boost Type Traits 库提供的类型特征是一流的元函数。考虑 boost::add_pointer 类型特征:

template <typename T>
struct add_pointer;

add_pointer<int>::type 类型是 int*add_pointer 模板是一个一元元函数,有一个类型参数和一个名为 type 的嵌入类型。

有时,类型计算的有效结果是数值型的 - 例如 boost::is_pointer<T>(布尔真值)或 boost::rank<T>(正整数)。在这种情况下,嵌入的 type 将具有一个名为 value 的静态成员,其中包含此结果,并且还可以直接从元函数中作为非类型成员的 value 访问。因此,boost::is_pointer<T>::type::valueboost::is_pointer<T>::value 都是有效的,后者更加简洁。

使用 MPL 元函数

MPL 与 Boost Type Traits 协同工作,使得许多元编程工作变得简单。为此,MPL 提供了许多将现有元函数组合在一起的元函数。

与类型特征一样,MPL 设施被分成独立的、高度细粒度的头文件。所有元函数都在 boost::mpl 命名空间中。我们可以使用 MPL 库将未命名的元函数组合成复合元函数。这与运行时的 lambda 和 bind 类似。以下代码片段使用 boost::mpl::or_ 元函数来检查一个类型是否是数组或指针:

清单 7.18:使用 MPL 元函数

 1 #include <boost/mpl/or.hpp>
 2 #include <boost/type_traits.hpp>
 34 if (boost::mpl::or_<
 5                     boost::is_pointer<int*>,
 6                     boost::is_array<int*>
 7                    >::value) {
 8   std::cout << "int* is a pointer or array type\n";
 9 }
10
11 if (boost::mpl::or_<
12                     boost::is_pointer<int[]>,
13                     boost::is_array<int[]>
14                    >::value) {
15   std::cout << "int* is a pointer or array type\n";
16 }

boost::mpl::or_ 元函数检查其参数元函数中是否有任何一个评估为 true。我们可以使用一种称为元函数转发的技术,创建自己的可重用元函数,将前述逻辑打包起来。

清单 7.19:创建自己的元函数

 1 #include <boost/mpl/or.hpp>
 2 #include <boost/type_traits.hpp>
 3
 4 template <typename T>
 5 struct is_pointer_or_array
 6       : boost::mpl::or_<boost::is_pointer<T>, 
 7                         boost::is_array<T>>
 8 {};

我们使用 boost::mpl::or_ 来组合现有的类型特性元函数,并从组合实体继承,如前述清单所示(第 6 行)。现在我们可以像使用任何类型特性一样使用 is_pointer_or_array

有时,我们需要将明显是非类型的数值参数传递给元函数。例如,为了比较类型 T 的大小是否小于另一类型 U 的大小,我们最终需要比较两个数值大小。让我们编写以下特性来比较两种类型的大小:

template <typename T, typename U> struct is_smaller;

is_smaller<T, U>::value 如果且仅如果 sizeof(T) 小于 sizeof(U),则为 true,否则为 false。

清单 7.20:使用整数包装器和其他元函数

 1 #include <boost/mpl/and.hpp>
 2 #include <boost/mpl/int.hpp>
 3 #include <boost/mpl/integral_c.hpp>
 4 #include <boost/mpl/less.hpp>
 5 #include <iostream>
 6 namespace mpl = boost::mpl;
 7
 8 template <typename L, typename R>
 9 struct is_smaller : mpl::less<
10                     mpl::integral_c<size_t, sizeof(L)>
11                    , mpl::integral_c<size_t, sizeof(R)>>
12 {};
13
14 int main()
15 {
16   if (is_smaller<short, int>::value) {
17     std::cout << "short is smaller than int\n";
18   } else { ... }
19 }

MPL 提供了一个元函数 boost::mpl::integral_c 来包装指定类型(size_tshort 等)的整数值。我们使用它来包装两种类型的大小。boost::mpl::less 元函数比较这两个大小,如果第一个参数在数值上小于第二个参数,则其嵌套的 value 只会设置为真。我们可以像使用其他特性一样使用它。

现在我们将尝试写一些稍微不那么琐碎的东西。我们想要编写一个函数来赋值数组。以下是函数模板的签名:

template <typename T, size_t M,
          typename S, size_t N>
void arrayAssign(T(&lhs)[M], S(&rhs)[N]);

类型 T(&)[M] 是指向 M 个类型为 T 的元素的数组的引用;S (&)[N] 也是如此。我们希望将第二个参数 rhs 赋给第一个参数 lhs

您可以将类型为 S[] 的数组赋给类型为 T[] 的数组,只要 ST 是相同类型,或者从 ST 的转换是允许的且不会导致信息丢失。此外,M 不能小于 N。我们将定义一个特性 is_array_assignable 来捕捉这些约束。因此,只有在满足前述约束时,is_array_assignable<T(&)[M], S(&)[N]>::value 才为真。

首先,我们需要定义三个辅助元函数:is_floating_assignableis_integer_assignableis_non_pod_assignableis_floating_assignable<T, S> 元函数检查是否可以将类型为 S 的数值赋给浮点类型 Tis_integer_assignable<T, S> 元函数检查 TS 是否都是整数,并且 TS 的赋值不会导致潜在的损失或缩小。因此,有符号整数不能赋给无符号整数,无符号整数只能赋给更大的有符号整数类型,依此类推。is_non_pod_assignable<T, S> 特性检查 ST 中至少有一个是非 POD 类型,并且是否存在从 ST 的赋值运算符。

然后,我们将使用这些和其他元函数来定义 is_array_assignable

清单 7.21:使用 MPL 定义有用的类型特性

 1 #include <boost/type_traits.hpp>
 2 #include <type_traits>
 3 #include <boost/mpl/and.hpp>
 4 #include <boost/mpl/or.hpp>
 5 #include <boost/mpl/not.hpp>
 6 #include <boost/mpl/greater.hpp>
 7 #include <boost/mpl/greater_equal.hpp>
 8 #include <boost/mpl/equal.hpp>
 9 #include <boost/mpl/if.hpp>
10 #include <boost/mpl/integral_c.hpp>
11 #include <boost/utility/enable_if.hpp>
12 #include <iostream>
13
14 namespace mpl = boost::mpl;
15
16 template <typename T, typename S>
17 struct is_larger
18    : mpl::greater<mpl::integral_c<size_t, sizeof(T)>
19                 , mpl::integral_c<size_t, sizeof(S)>>
20 {};
21 template <typename T, typename S>
22 struct is_smaller_equal
23   : mpl::not_<is_larger<T, S>>
24 {};
25
26 template <typename T, typename S>
27 struct is_floating_assignable
28    : mpl::and_<
29        boost::is_floating_point<T>
30      , boost::is_arithmetic<S>
31      , is_smaller_equal<S, T>
32      >
33 {};
34
35 template <typename T, typename S>
36 struct is_integer_assignable
37    : mpl::and_<
38        boost::is_integral<T>
39      , boost::is_integral<S>
40      , is_smaller_equal<S, T>
41      , mpl::if_<boost::is_signed<S>
42               , boost::is_signed<T>
43               , mpl::or_<boost::is_unsigned<T>
44                        , mpl::and_<boost::is_signed<T>
45                                  , is_larger<T, S>>
46                         >
47               >
48      >
49 {};
50
51 template <typename T, typename S>
52 struct is_non_pod_assignable
53    : mpl::and_<
54                mpl::not_<mpl::and_<boost::is_pod<T>
55                                  , boost::is_pod<S>>
56                         >
57              , std::is_assignable<T, S>
58               >
59 {};
60
61 template <typename T, typename U>
62 struct is_array_assignable
63    : boost::false_type
64 {};
65
66 template <typename T, size_t M, typename S, size_t N>
67 struct is_array_assignable<T (&)[M], S (&)[N]>
68    : mpl::and_<
69           mpl::or_<
70               boost::is_same<T, S>
71             , is_floating_assignable<T, S>
72             , is_integer_assignable<T, S>
73             , is_non_pod_assignable<T, S>
74              >
75         , mpl::greater_equal<mpl::integral_c<size_t, M>
76                            , mpl::integral_c<size_t, N>>
77         >
78 {};
79
80
81 template <typename T, size_t M, typename S, size_t N>
82 typename boost::enable_if<is_array_assignable<T(&)[M], 
83                                               S(&)[N]>>::type
84 assignArray(T (&target)[M], S (&source)[N])
85 { /* actual copying implementation */ }

is_array_assignable 元函数的主模板始终返回 false(第 61-64 行)。is_array_assignable 的部分特化(第 66-78 行)是实现的核心。它使用 mpl::or_ 元函数来检查是否满足以下任何一个条件:

  • 源类型和目标类型相同(第 70 行)

  • 目标类型是浮点数,源类型是数值,并且可以进行赋值而不会缩小(第 71 行)

  • 目标类型是整数(有符号或无符号),源类型是整数,并且可以进行赋值而不会缩小(第 72 行)

  • 源和目标类型中至少有一个是非 POD 类型,并且从源类型到目标类型的转换是可能的(第 73 行)

mpl::or_ 元函数类似于 C++ 的逻辑或运算符,如果传递的条件中有任何一个为真,则其静态成员 value 就设置为真。除了这个复合条件为真之外,还必须满足以下条件:

目标数组中的元素数量至少应与源数组中的元素数量一样多。

我们使用mpl::greater_equal元函数来比较这两个值MN。由于元函数需要获取类型参数,我们使用boost::mpl::integral_c包装器生成与MN对应的类型参数(第 75-76 行)。我们使用mpl::and_元函数计算条件 1-4 的逻辑或及其与条件 5 的逻辑与(第 61 行)。

我们使用boost::enable_if,它利用 SFINAE 在is_array_assignable返回 false 时禁用assignArray

现在让我们看一下is_integer_assignable的实现。它检查目标和源类型是否都是整数(第 38-39 行),并且源类型不大于目标类型(第 40 行)。此外,我们使用boost::mpl::if_元函数,它需要三个元函数;如果第一个元函数评估为true,则返回第二个元函数,否则返回第三个元函数。使用mpl::if_,我们表达了源类型和目标类型的约束(第 41-47 行)。如果源类型是有符号整数(第 41 行),那么目标类型也必须是有符号整数(第 42 行)。但是如果源类型是无符号整数,那么目标类型必须是无符号整数(第 43 行)或大于源类型的有符号整数(第 44-45 行)。其余的特性也是使用 Boost MPL 库设施类似地定义的。

元编程不仅是选择最佳实现或在编译时捕获违规的工具。它实际上有助于创建像boost::tupleboost::variant这样的表达性库,涉及重要的类型操作。我们只介绍了 Boost MPL 库中的一些基本抽象,以帮助您轻松进入模板元编程。如果您已经在本章中的示例中工作过,那么您应该没有问题自己进一步探索 MPL。

领域特定嵌入式语言

在本章的最后三分之一,我们主要看了高阶和编译时编程在领域特定嵌入式语言中的应用。

惰性评估

在 C++中,当我们看到以下代码时:

z = x + y();

我们知道当控制到达语句z = x + y()之后,z的值会立即计算。事实上,计算总和涉及对xy()表达式本身的评估。在这里,y可能是一个函数或一个函数符实例,因此对y()的调用将依次触发更多的评估。无论z是否以后被用于任何事情,它的值仍然会被计算。这是许多编程语言遵循的急切评估模型。实际情况稍微复杂一些,因为编译器可以重新排序和优化计算,但程序员对这个过程几乎没有控制。

如果我们能够推迟对这些表达式及其任何子表达式的评估,直到我们必须使用结果,会怎么样?这是许多函数式编程语言中看到的惰性评估模型,比如 Haskell。如果我们能够构造惰性评估的任意语言表达式,那么这些表达式就可以像函数符一样传递,并在必要时进行评估。想象一个名为integrate的函数,它评估任意函数的定积分,给定边界值:

double integrate(std::function<double(double)> func,
                 double low, double high);

想象一下通过调用以下代码来评估积分惰性评估

double result = integrate(x + 1/x, 1, 10);

关键是不急切评估表达式x + 1/x,而是将其作为惰性表达式传递给integrate函数。现在 C++没有任何内置机制来使用常规变量创建这样的惰性表达式。但是我们可以很容易地编写一个 lambda 来完成我们的工作:

result = integrate([](double) { return x + 1/x; }, 1, 10);

这样做虽然有一些语法噪音,但在许多应用中,lambda 和 bind 并不适用于复杂性。在本节中,我们简要研究表达式模板,更一般地说,领域特定嵌入式语言DSELs),这是在 C++中构建惰性评估函数对象的手段,可以在不牺牲表达语法的情况下完成工作。

表达式模板

那么,如何在领域语言中表达一个函数f(x)=x+1/x,而不是通过 C++的语法妥协来实现呢?为了创建一个通用解决方案,我们必须能够支持各种代数表达式。让我们从最基本的函数开始 - 一个常数函数,比如f(x)=5。无论x的值如何,这个函数应该始终返回 5。

以下函数对象可用于此目的:

清单 7.22a:表达式模板迷你库 - 惰性文字

 1 #include <iostream>2
 3 struct Constant {
 4   Constant(double val = 0.0) : val_(val) {}
 5   double operator()(double) const { return val_; }
 67   const double val_;
 8 };
 9
10 Constant c5(5);
11 std::cout << c5(1.0) << '\n';  // prints 5

operator()返回存储的val_并忽略它的参数,该参数是无名的。现在让我们看看如何使用类似的函数对象来表示f(x)=x这样的函数:

清单 7.22b:表达式模板迷你库 - 惰性变量

 1 struct Variable {
 2   double operator()(double x) { return x; }
 3 };
 4
 5 Variable x;
 6 std::cout << x(8) << '\n';  // prints 8
 7 std::cout << x(10) << '\n'; // prints 10

现在我们有一个产生传递给它的任何值的函数对象;正是f(x)=x所做的。但是如何表达一个类似x + 1/x的表达式呢?表示单变量任意函数的函数对象的一般形式应该如下:

struct Expr {
  ...
  double operator()(double x) {
    return (value computed using x);
  }
};

ConstantVariable都符合这个形式。但是考虑一个更复杂的表达式,比如f(x)=x+1/x。我们可以将它分解为两个子表达式x1/x,由二元操作+作用。表达式1/x可以进一步分解为两个子表达式1x,由二元操作/作用。

这可以用抽象语法树AST)来表示,如下所示:

表达式模板

树中的非叶节点表示操作。二元操作节点有两个子节点:左操作数是左子节点,右操作数是右子节点。AST 在根部有一个操作(+),并且有两个子表达式作为两个子节点。左子表达式是x,而右子表达式是1/x1/x进一步在一个子树中被分解,根部是操作(/),1是左子节点,x是右子节点。注意像1x这样的值只出现在叶级别,并且对应于我们定义的ConstantVariable类。所有非叶节点表示操作符。

我们可以将复杂表达式建模为由两个带有运算符的子表达式组成的表达式:

清单 7.22c:表达式模板迷你库 - 复杂表达式

 1 template <typename E1, typename E2, typename OpType>
 2 struct ComplexExpression {
 3   ComplexExpression(E1 left, E2 right) : left_(left), 
 4             right_(right) 
 5   {}
 6
 7   double operator()(double x) { 
 8     return OpType()(left_(x), right_(x));
 9   }
10
11   E1 left_; E2 right_;
12 };

当调用ComplexExpression函数对象时,也就是当它评估其左右子表达式然后对它们应用运算符(第 7 行),这将触发左右子表达式的评估。如果它们本身是ComplexExpression,那么它们将触发进一步的评估,深度优先遍历树。这是明确的延迟评估

现在,为了轻松生成复杂表达式函数对象,我们需要重载算术运算符,以组合ConstantVariableComplexExpression<>或原始算术类型的子表达式。为了更好地做到这一点,我们为所有类型的表达式创建一个名为Expr的抽象。我们还修改了ComplexExpression的定义以使用Expr

清单 7.22d:表达式模板迷你库 - 通用表达式

 1 template <typename E, typename Enable = void>
 2 struct Expr {
 3   Expr(E e) : expr_(e) {}
 4  
 5   double operator()(double x) { return expr_(x); }
 6 
 7 private: 
 8   E expr_;
 9 };
10
11 template <typename E1, typename E2, typename Op>
12 struct ComplexExpression
13 {
14   ComplexExpression(Expr<E1> left, Expr<E2> right) : 
15                    left_(left), right_(right) {}
16
17   double operator()(double d) {
18     return Op()(left_(d), right_(d));
19   }
20
21 private:
22   Expr<E1> left_;
23   Expr<E2> right_;
24 };

我们将传递包装在Expr中的各种表达式,例如Expr<Constant>Expr<ComplexExpression>等。如果您不确定为什么我们需要第二个模板参数Enable,那么稍等片刻就会得到答案。在此之前,我们将定义任何两个Expr之间的算术运算符,从operator+开始:

清单 7.22e:表达式模板迷你库 - 重载运算符

 1 #include <functional>
 2 
 3 template <typename E1, typename E2>
 4 Expr<ComplexExpression<E1, E2, std::plus<double>>> 
 5           operator+ (E1 left, E2 right)
 6 {
 7   typedef ComplexExpression <E1, E2,
 8                                 std::plus<double>> ExprType;
 9   return ExprType(Expr<E1>(left), Expr<E2>(right));
10 }

任何二元操作都将产生一个 ComplexExpression。由于我们将一切抽象为 Expr,所以我们从算术运算符中返回 Expr<ComplexExpression<…>>。在相同的行上很容易编写 operator-operator*operator/。我们可以在前面的实现中用 std::plus 替换为 std::minusstd::multiplesstd::divides

只有一个细节需要注意。有了前面的代码,我们可以写出以下形式的表达式:

Variable x;
Constant c1(1);
integrate(x + c1/x, 1, 10);

但我们无法使用数字文字来写 x + 1/x。为了做到这一点,我们必须自动将数字文字转换为 Constant。为此,我们将创建 Expr 的部分特化,并使用 boost::enable_if 为数字类型启用它。这就是 Expr 模板的 Enable 参数派上用场的地方。对于主模板,默认为 void,但它帮助我们编写包装算术类型文字的部分特化。

列表 7.22f:一个表达式模板迷你库 – 一个小技巧

 1 #include <boost/utility/enable_if.hpp>
 2 #include <boost/type_traits/is_arithmetic.hpp>
 34 template <typename E>
 5 struct Expr<E, typename boost::enable_if< 
 6                               boost::is_arithmetic<E>>::type> 
 7 {
 8   Expr(E& e) : expr_(Constant(e)) {}
 9
10   double operator()(double x) { return expr_(x); }
11
12   Constant expr_;
13 };

只有当 E 是算术类型(intdoublelong等)时,才会调用这个部分特化(partial specialization)。这将算术值存储为 Constant。有了这个改变,我们可以在我们的表达式中使用数字文字,只要表达式中有一个单一的 Variable,这些文字就会通过列表 7.22f 中的部分特化被包装为 Constant。现在我们可以仅使用自然的代数表达式生成一个函数器:

列表 7.22g:一个表达式模板迷你库 – 使用表达式

Variable x;
std::cout << (x + 1/x)(10) << '\n'; 
std::cout << ((x*x - x + 4)/(2*x))(10) << '\n';

我们可以对这个非常基本的 表达式模板 库进行许多更多的改进,即使代码不到一百行。但它已经允许我们使用非常简单的语法生成单变量的任意代数函数。这是一个特定领域语言的例子。而且,特别是因为我们使用有效的 C++ 语法来做所有这些,而不是定义一个新的语法,它被称为特定领域嵌入语言DSEL)或有时称为嵌入式特定领域语言EDSL)。现在我们将看一下 Boost Phoenix,一个复杂的惰性表达式库。

Boost Phoenix

Boost Phoenix 3 是一个在 C++ 中启用函数式编程构造的库。它定义了一个复杂而易读的 DSEL,其中包含大量的函数器和运算符,可以用来生成相当复杂的 lambda。它提供了一个全面的库,用于构造惰性表达式,并展示了表达式模板可以实现的优秀示例。本节简要介绍了如何使用 Phoenix 表达式作为 lambda,并将看到一些使用 Boost Spirit Parser Framework 的 Phoenix 示例。这是一个非常庞大的库,甚至在一个章节中都无法覆盖,更不用说它的一个子部分,但这个介绍应该足够提供足够的支持来掌握 Phoenix,同时还可以获得优秀的在线文档的好处。

Phoenix 表达式由演员组成,演员是惰性函数的抽象。演员用于生成未命名函数或 lambda。它们通过将一些参数绑定到值并保持其他参数未指定来支持部分函数应用。它们可以组合以生成更复杂的函数器。在这个意义上,Phoenix 是一个 lambda 语言库。

演员根据功能进行分类,并通过一组头文件公开。最基本的演员是 val,它表示惰性不可变值(与我们表达式模板示例中的 Constant 函数器类似)。ref 演员用于创建惰性可变变量引用,cref 演员生成惰性不可变引用。还有一整套定义惰性运算符的演员,包括算术运算符(+-)、比较运算符(<==>)、逻辑运算符(&&||)、位运算符(|^&)和其他类型的运算符。仅使用这些,我们就可以构造代数表达式,就像我们在下面的示例中所做的那样:

清单 7.23:使用 Phoenix 的惰性代数表达式

 1 #include <boost/phoenix/core.hpp>
 2 #include <boost/phoenix/operator.hpp>
 3 #include <iostream>
 4
 5 int main() {
 6   namespace phx = boost::phoenix;
 7   double eX;
 8   auto x = phx::ref(eX);
 9
10   eX = 10.0;
11   std::cout << (x + 1/x)() << '\n';              // prints 10.1
12   std::cout << ((x*x -x + 4) / (2*x))() << '\n'; // prints 4.7
13 }

使用boost::phoenix::ref,我们生成了一个用于惰性评估变量eXe代表eager)的 actor,并将其缓存在变量x中。表达式x + 1/xx*x – x + 4生成了匿名函数,就像清单 7.22 中的表达式模板一样,只是x已经绑定到变量eX。actor x的存在通过其影响了表达式中的数字文字;这些文字被包装在boost::phoenix::val中。表达式中使用的+-*/操作符是来自 Phoenix 的惰性操作符(就像我们在清单 7.22e 中为我们的表达式模板定义的操作符一样),并生成了匿名函数。

使用 Phoenix 有时可以非常简洁地编写简单的 lambda。看看我们如何使用std::for_each和 Phoenix 的惰性operator<<来打印向量中的每个元素:

清单 7.24:使用 Phoenix 的简单 lambda

 1 #include <boost/phoenix/core.hpp>
 2 #include <boost/phoenix/operator.hpp>
 3 #include <vector>
 4 #include <string>
 5 #include <iostream>
 6 #include <algorithm>
 7
 8 int main() {
 9   using boost::phoenix::arg_names::arg1;
10   std::vector<std::string> vec{"Lambda", "Iota", 
11                                "Sigma", "Alpha"};
12   std::for_each(vec.begin(), vec.end(), 
13                 std::cout << arg1 << '\n');
14 }

表达式std::cout << arg1实际上是生成一个函数对象的 lambda。actor arg1boost::phoenix::arg_names::arg1)代表函数对象的第一个参数,并且是惰性评估的。表达式std::cout << arg1中的arg1的存在调用了惰性operator<<并感染整个表达式,生成一个未命名函数,将其参数打印到标准输出。通常情况下,您可以使用arg1argN来引用使用 Phoenix 生成的 N 元函数的惰性参数。默认情况下,支持最多十个参数 actors(arg1arg10)。这类似于boost::bind_1_2等。您还可以使用boost::phoenix::placeholders::_1_2等。

Phoenix actors 不仅限于涉及运算符的表达式。我们可以生成惰性评估包含分支和循环结构的整个代码块的 actors。假设我们有一个乐队阵容中人员姓名的向量,并且我们想要打印一个人是歌手还是乐器演奏者:

清单 7.25:使用 Phoenix 的惰性控制结构

 1 #include <boost/phoenix/core.hpp>
 2 #include <boost/phoenix/statement/if.hpp>
 3 #include <boost/phoenix/operator.hpp>
 4 #include <algorithm>
 5 #include <vector>
 6 #include <iostream>
 7 
 8 int main() {
 9   namespace phx = boost::phoenix;
10   using namespace phx;
11   using phx::arg_names::arg1;
12
13   std::vector<std::string> names{"Daltrey", "Townshend", 
14                                  "Entwistle", "Moon"};
15   std::for_each(names.begin(), names.end(),   
16             if_(arg1 == "Daltrey") [
17               std::cout << arg1 << ", vocalist" << '\n'
18             ].else_[
19               std::cout << arg1 << ", instrumentalist" << '\n'
20             ]
21             );
22 }

我们想要遍历The Who四位传奇成员的姓氏向量,并列出他们的角色。对于(罗杰)达特里,角色将是一个歌手,而对于其他人来说,是乐器演奏者。我们使用std::for_each来迭代名单。我们通过使用 Phoenix 的语句 actors 生成的一元函数来传递给它一个 unary functor,具体来说是boost::phoenix::if_

语法足够直观,可以理解正在发生的事情。if_else_块中的实际语句被放在方括号中,而不是大括号(不能被重载),并且被惰性评估。如果有多个语句,它们需要用逗号分隔。注意else_是在前面的表达式上调用的成员调用,用点调用(第 18 行)。arg1的存在被称为感染语句,即它调用了惰性operator<<并导致文字字符串自动包装在boost::phoenix::val中(第 16、17、19 行)。运行此代码将打印以下内容:

Daltrey, vocalist
Townshend, instrumentalist
Entwistle, instrumentalist
Moon, instrumentalist

Phoenix 的强大之处已经显而易见。它使用标准 C++运算符重载和函数对象定义了一个表达力强的子语言,可以轻松生成所需的未命名函数或 lambda,并开始模仿宿主语言本身。Phoenix 库还有更多内容。它充斥着用于惰性评估 STL 容器成员函数和 STL 算法的 actors。让我们看一个例子来更好地理解这一点:

清单 7.26:用于 STL 算法和容器成员函数的 actors

 1 #include <vector>
 2 #include <string>
 3 #include <iostream>
 4 #include <boost/phoenix/core.hpp>
 5 #include <boost/phoenix/stl/algorithm.hpp>
 6 #include <boost/phoenix/stl/container.hpp>
 7 #include <cassert>
 8
 9 int main() {
10   namespace phx = boost::phoenix;
11   using phx::arg_names::arg1;
12   std::vector<std::string> greets{ "Hello", "Hola", "Hujambo", 
13                                    "Hallo" };
14   auto finder = phx::find(greets, arg1);
15   auto it = finder("Hujambo");
16
17   assert (phx::end(greets)() != it);
18   std::cout << *it << '\n';
19   assert (++it != greets.end());
20   std::cout << *it << '\n';
21 }

我们有一个包含不同语言的问候语(英语、西班牙语、斯瓦希里语和德语)的向量greets,我们想要搜索特定的问候语。我们想要使用 Phoenix 进行延迟搜索。Phoenix 提供了用于生成大多数 STL 算法的延迟版本的 actors。我们使用boost/phoenix/stl/algorithm.hpp头文件中可用的std::find算法的延迟形式(第 5 行),并调用boost::phoenix::find actor 来生成一个名为finder的一元函数对象(第 14 行)。finder函数对象以greets中要查找的字符串作为唯一参数。调用boost::phoenix::find(greets, arg1)需要两个参数并生成一个一元函数对象。第一个参数是对向量greets的引用,它会自动包装在cref actor 中并存储以供以后延迟评估。find的第二个参数是 Phoenix 占位符arg1

finder以要查找的字符串作为唯一参数调用时,它评估arg1 actor 以获取此字符串参数。它还评估它之前存储的cref actor 以获取对greets的引用。然后在greets向量上调用std::find,查找传递的字符串,返回一个迭代器。我们查找向量中存在的字符串Hujambo(第 15 行)。

为了检查返回的迭代器是否有效,我们需要将其与greets.end()进行比较。只是为了表明可以做到这一点,我们使用从头文件boost/phoenix/stl/algorithm.hpp中可用的boost::phoenix::end actor 生成end成员函数调用的延迟版本。调用boost::phoenix::end(greets)生成一个函数对象,我们通过在后面加括号来直接调用它。我们将结果与finder返回的迭代器进行比较(第 17 行)。我们打印find返回的迭代器指向的问候语以及其后的元素(第 18-20 行):

Hujambo
Hallo

Phoenix 的 actors 是多态的。您可以在任何支持通过std::find进行搜索的容器上应用boost::phoenix::find,并且可以查找底层容器可以存储的任何类型的对象。

在 Phoenix 的最后一个例子中,我们将看看如何定义自己的 actor,这些 actor 可以与 Phoenix 的其余部分相匹配。我们有一个名称向量,我们从中打印每个条目的第一个名称,使用std::for_each和使用 Phoenix 生成的函数对象。我们通过查找字符串中的第一个空格字符并提取直到该点的前缀来从名称字符串中提取名字。我们可以使用find actor 来定位空格,但是要提取前缀,我们需要一种延迟调用std::stringsubstr成员的方法。目前在 Phoenix 中没有substr actor 可用,因此我们需要自己编写:

清单 7.27:用户定义的 actors 和 STL actors

 1 #include <vector>
 2 #include <string>
 3 #include <iostream>
 4 #include <algorithm>
 5 #include <boost/phoenix/core.hpp>
 6 #include <boost/phoenix/function.hpp>
 7 #include <boost/phoenix/operator.hpp>
 8 #include <boost/phoenix/stl/container.hpp>
 9 #include <boost/phoenix/stl/algorithm.hpp>
10
11 struct substr_impl {
12   template<typename C, typename F1, typename F2>
13   struct result  {
14     typedef C type;
15   };
16
17   template<typename C, typename F1, typename F2>
18   C operator()(const C& c, const F1& offset, 
19               const F2& length) const
20   {  return c.substr(offset, length); }
21 };
22
23 int main() {
24   namespace phx = boost::phoenix;
25   using phx::arg_names::arg1;
26
27   std::vector<std::string> names{"Pete Townshend", 
28             "Roger Daltrey", "Keith Moon", "John Entwistle"};
29   phx::function<substr_impl> const substr = substr_impl();
30
31   std::for_each(names.begin(), names.end(), std::cout <<
32                substr(arg1, 0, phx::find(arg1, ' ')
33                                - phx::begin(arg1))
34                 << '\n');
35 }

我们编写了substr_impl函数对象,它有一个成员模板operator()(第 17 行)和一个名为result的元函数(第 12 行)。operator()是一个模板,用于使substr_impl多态化。任何具有名为substr的成员函数的类型C,它接受类型为F1F2的两个参数(可能是不同类型)都可以由这个单一实现覆盖(第 17-20 行)。result元函数中的type是包装函数(substr)的返回类型。实际的substr操作者是boost::phoenix::function<substr_impl>类型的实例(第 29 行)。我们使用刚刚定义的substr操作者来生成一个一元函数对象,然后将其传递给std::for_each算法(第 32-33 行)。由于我们想要从names向量中的每个字符串中提取第一个名字,所以第一个参数是arg1(传递给函数对象的名字),第二个偏移参数是 0,而第三个长度参数是字符串中第一个空格字符的偏移量。第三个参数被懒惰地计算为表达式boost::phoenix::find(arg1, ' ') – boost::phoenix::begin(arg1)find(arg1, ' ')是一个操作者,它使用我们在列表 7.26 中也使用的 Phoenix 的通用查找操作者来查找字符串中的第一个空格。begin(arg1)是一个操作者,它返回其参数(在本例中是字符串)的起始迭代器。它们之间的差异返回第一个名字的长度。

提升 Spirit 解析器框架

Boost Spirit 是一个非常流行的用于生成词法分析器和解析器的领域特定语言,它使用 Boost Phoenix。编写自定义词法分析器和解析器过去严重依赖于专门的工具,如 lex/flex、yacc/bison 和 ANTLR,这些工具从扩展巴科斯-瑙尔范式(EBNF)的语言中立规范生成 C 或 C++代码。Spirit 消除了在语言之外创建这样的规范的需要,也消除了从这样的规范翻译的工具的需要。它在 C++中定义了一个具有直观语法的声明式领域特定语言,并且只使用 C++编译器来生成解析器。Spirit 大量使用模板元编程,导致编译时间较慢,但生成的解析器在运行时非常高效。

Spirit 是一个包含 Spirit Lex(词法分析器)、Spirit Qi(解析器)和 Spirit Karma(生成器)的丰富框架。您可以单独使用它们,或者协作使用它们来构建强大的数据转换引擎。

本书中我们只关注 Spirit Qi。它主要用于根据一些指定的语法来解析文本数据,数据应该遵守以下目标:

  • 验证输入是否符合语法

  • 将符合语法的输入分解为有意义的语义组件

例如,我们可以解析一些输入文本,以验证它是否是有效的时间戳,如果是,提取时间戳的组件,如年、月、日、小时、分钟等。为此,我们需要为时间戳定义一个语法,并且需要定义在解析数据时要采取的操作,以其语义组成部分的形式。让我们看一个具体的例子。

使用 Spirit Qi

Spirit 提供了预定义解析器,可以使用 Spirit 定义的解析器操作者组合起来,为我们的需求定义解析器。一旦定义好,我们可以将解析器或其组件存储为可以与其他规则组合的规则。或者我们可以直接将其传递给 Qi 的解析 API,如parsephrase_parse,以及要解析的输入。

预定义解析器

Qi 提供了许多预定义的解析器,可以用来解析基本的数据片段。这些解析器可以在命名空间boost::spirit::qi下使用或别名。以下是这些解析器及其目的的列表:

输入类 解析器 目的
整数 int_, uint_, short_, ushort_, long_, ulong_, long_long, ulong_long 解析有符号和无符号整数
实数 float_, double_, long_double 解析带有小数点的实数
布尔 bool_, true_, false_ 解析字符串truefalse中的一个或两个
字符 char_, alpha, lower, upper,digit, xdigit, alnum,space, blank,punct, cntrl, graph, print 解析不同类别的字符,如字母、数字、十六进制数字、标点等。
字符串 String 解析特定字符串

在上表中列出的解析器是预定义对象,而不是类型。每个解析器都有对应的通用解析器模板。例如,模板boost::spirit::qi::int_parser可用于定义有符号整数的自定义解析器。还有许多其他模板,包括boost::spirit::qi::uint_parserboost::spirit::qi::bool_parser等等。

解析 API

Qi 提供了两个函数模板,parsephrase_parse,用于解析文本输入。每个函数都接受定义输入范围和解析器表达式的迭代器对。此外,phrase_parse接受第二个解析器表达式,用于匹配和跳过空白。以下简短的示例向您展示了使用 Spirit 的精髓:

清单 7.28:一个简单的 Spirit 示例

 1 #include <boost/spirit/include/qi.hpp>
 2 #include <cassert>
 3 namespace qi = boost::spirit::qi;
 4
 5 int main()
 6 {
 7   std::string str = "Hello, world!";
 8
 9   auto iter = str.begin();
10   bool success = qi::parse(iter, str.end(), qi::alpha);
11                            
12   assert(!success);
13   assert(iter - str.begin() == 1);
14 }

我们包含头文件boost/spirit/include/qi.hpp以便访问 Spirit Qi 函数、类型和对象。我们的输入是字符串Hello, world!,并且使用预定义解析器alpha,我们希望强制第一个字符是拉丁字母表中的字母,而不是数字或标点符号。为此,我们使用parse函数,将其传递给定义输入和alpha解析器的迭代器对(第 10 行)。parse函数如果成功解析输入则返回true,否则返回false。范围开始的迭代器被递增以指向输入中第一个未解析的字符。由于Hello, world!的第一个字符是 H,alpha解析器成功解析它,将iter递增 1(第 13 行),parse返回true(第 12 行)。请注意,第一个迭代器作为非 const 引用传递给parse,并且由parse递增;我们传递str.begin()的副本的原因。

解析器运算符和表达式

Spirit 定义了一些名为解析器运算符的重载运算符,可以用来将简单解析器组合成复杂的解析器表达式,包括预定义的解析器。以下表总结了其中一些运算符:

运算符 类型 目的 示例
>> (序列运算符) 二进制,中缀 两个解析器依次解析两个标记 string("Hello") >> string("world")匹配Helloworld
| (分歧运算符) 二进制,中缀 两个解析器中的任何一个都能解析标记,但不能同时解析 string("Hello") &#124; string("world")匹配Helloworld但不匹配Helloworld
* (Kleene 运算符) 一元,前缀 解析空字符串或一个或多个匹配的标记 *string("Hello")匹配空字符串、HelloHelloHello等。
+ (加号运算符) 一元,前缀 解析一个或多个匹配的标记 +string("Hello")匹配HelloHelloHello等,但不匹配空字符串。
~ (否定运算符) 一元,前缀 解析不匹配解析器的标记 ~xdigit将解析任何不是十六进制数字的字符。
- (可选运算符) 一元,前缀 解析空字符串或单个匹配的标记 -string("Hello")匹配Hello或空字符串。
- (差分运算符) 二进制,中缀 P1 - P2 解析 P1 可以解析而 P2 不能解析的任何标记 uint_ - ushort_匹配任何不是unsigned shortunsigned int。在一个有 2 字节short的系统上,匹配 65540 但不匹配 65530。
%(列表运算符) 二进制,中缀 P1 % D将输入在匹配 D 的分隔符处拆分为与 P1 匹配的标记 `+alnum % +(space
(顺序或运算符) 二进制,中缀

请注意,有一个一元operator-,即可选运算符,和一个二元operator-,即差运算符。

boost::spirit::qi::parse函数模板在解析时不会跳过任何空白字符。有时,在解析时忽略标记之间的空格是很方便的,boost::spirit::qi::phrase_parse就是这样做的。例如,解析器string("Hello") >> string("world")在使用boost::spirit::qi::parse时会解析Helloworld,但不会解析Hello, world!。但是,如果我们使用phrase_parse并忽略空格和标点,那么它也会解析Hello, world!

清单 7.29:使用 phrase_parse

 1 #include <boost/spirit/include/qi.hpp>
 2 #include <cassert>
 3 namespace qi = boost::spirit::qi;
 4
 5 int main()
 6 {
 7   std::string str = "Hello, world!";
 8
 9   auto iter = str.begin();
10   bool success = qi::parse(iter, str.end(),
11                   qi::string("Hello") >> qi::string("world"));
12
13   assert(!success);
14
15   iter = str.begin();
16   success = qi::phrase_parse(iter, str.end(),
17                   qi::string("Hello") >> qi::string("world"),
18                   +(qi::space|qi::punct));
19
20   assert(success);
21   assert(iter - str.begin() == str.size());
22 }

请注意,我们将+(space|punct)作为第四个参数传递给phrase_parse,告诉它要忽略哪些字符;空格和标点。

解析指令

解析指令是可以用来以某种方式改变解析器行为的修饰符。例如,我们可以使用no_case指令执行不区分大小写的解析,如下面的代码片段所示:

1   std::string str = "Hello, WORLD!";
2   iter = str.begin();
3   success = qi::phrase_parse(iter, str.end(),
4                   qi::string("Hello") >> 
5                     qi::no_case[qi::string("world")],
6                   +(qi::space|qi::punct));
7   assert(success);

skip指令可用于跳过输入的某个部分上的空白:

 1   std::string str = "Hello world";
 2   auto iter = str.begin();
 3   bool success = qi::parse(iter, str.end(),
 4                   qi::skip(qi::space)[qi::string("Hello") >> 
 5                                        qi::string("world")]);
 6   assert( success); 

指令qi::skip(qi::space)[parser]即使我们调用的是parse而不是phrase_parse也会忽略空格。它可以有选择地应用于解析器子表达式。

语义动作

在使用 Spirit 时,我们通常不仅仅是要验证一段文本是否符合某种语法;我们希望提取标记,并可能在某种计算中使用它们或将它们存储起来。我们可以将某个动作与解析器实例关联起来,以便在成功解析文本时运行,这个动作可以使用解析的结果进行必要的计算。这样的动作是使用方括号括起来的函数对象定义的,跟在它关联的解析器后面。

清单 7.30:定义与解析器关联的动作

 1 #include <boost/spirit/include/qi.hpp>
 2 #include <iostream>
 3 namespace qi = boost::spirit::qi;
 4
 5 void print(unsigned int n) {
 6   std::cout << n << '\n';
 7 }
 8
 9 int main() {
10   std::string str = "10 20 30 40 50 60";
11
12   auto iter = str.begin();
13   bool success = qi::phrase_parse(iter, str.end(),
14                                   +qi::uint_[print],
15                                   qi::space);
16   assert(success);
17   assert(iter == str.end());
18 }

在上面的示例中,我们使用uint_解析器(第 10 行)解析由空格分隔的无符号整数列表。我们定义一个print函数(第 5 行)来打印无符号整数,并将其作为一个动作与uint_解析器(第 14 行)关联起来。对于每个解析的无符号整数,前面的代码通过调用指定的动作在新行上打印它。动作也可以使用函数对象指定,包括由 Boost Bind 和 Boost Phoenix 生成的函数对象。

从原始到最复杂的每个解析器都有一个关联的属性,它设置为成功解析的结果,即当它应用于转换为适当类型的某些输入时匹配的文本。对于像uint_这样的简单解析器,该属性将是unsigned int类型。对于复杂的解析器,这可能是其组成解析器的属性的有序元组。当与解析器关联的动作被调用时,它会传递解析器属性的值。

表达式+qi::uint_[print]print函数与uint_解析器关联起来。如果我们想要将动作与复合解析器+qi::uint_关联起来,那么我们需要使用不同签名的函数,即带有类型为std::vector<unsigned int>的参数的函数,它将包含所有解析的数字。

 1 #include <vector>
 2
 3 void printv(std::vector<unsigned int> vn) 
 4 {
 5   for (const int& n: vn) {
 6     std::cout << n << '\n';
 7   }
 8 }
 9
10 int main() {
11   std::string str = "10 20 30 40 50 60";
12
13   auto iter = str.begin();
14   bool success = qi::phrase_parse(iter, str.end(),
15                                  (+qi::uint_)[printv],
16                                  qi::space);
17 }

我们还可以使用 Boost Bind 表达式和 Phoenix 操作来生成动作。因此,我们可以编写+qi::uint_[boost::bind(print, ::_1)]来在每个解析的数字上调用print。占位符::_1::_9由 Boost Bind 库在全局命名空间中定义。Spirit 提供了可以用于各种操作的 Phoenix 操作。以下代码片段展示了将解析的数字添加到向量中的方法:

 1 #include <boost/spirit/include/qi.hpp>
 2 #include <boost/spirit/include/phoenix_core.hpp>
 3 #include <boost/spirit/include/phoenix_operator.hpp>
 4 #include <boost/spirit/include/phoenix_stl.hpp> 
 5 
 6 int main() {
 7   using boost::phoenix::push_back;
 8 
 9   std::string str = "10 20 30 40 50 60";
10   std::vector<unsigned int> vec;
11   auto iter = str.begin();
12   bool status = qi::phrase_parse(iter, str.end(),
13                 +qi::uint_[push_back(boost::phoenix::ref(vec), 
14                                         qi::_1)],
15                  qi::space);
16 }

使用boost::phoenix::push_back操作表达式push_back(boost::phoenix::ref(vec), qi::_1)将每个解析的数字(由占位符qi::_1表示)附加到向量vec

parsephrase_parse函数模板的重载,它们接受一个属性参数,您可以直接将解析器解析的数据存储在其中。因此,我们可以将unsigned intvector作为属性参数传递,同时解析无符号整数的列表:

std::vector<unsigned int> result;
bool success = qi::phrase_parse(iter, str.end(),
 +qi::uint_, result,
                                qi::space);
for (int n: result) {std::cout << n << '\n';
}

规则

到目前为止,我们使用内联表达式生成了解析器。当处理更复杂的解析器时,缓存组件并重用它们是很有用的。为此,我们使用boost::spirit::qi::rule模板。规则模板最多接受四个参数,其中第一个即输入的迭代器类型是必需的。因此,我们可以缓存解析std::string对象中的空格的解析器,如下所示:

qi::rule<std::string::iterator> space_rule = qi::space; 

请注意,如上所定义的space_rule是一个遵循与qi::space相同语法的解析器。

往往我们对解析器解析的值感兴趣。要定义包含这样的解析器的规则,我们需要指定一个方法的签名,该方法将用于获取解析的值。例如,boost::spirit::qi::double_解析器的属性类型为double。因此,我们认为一个不带参数并返回double的函数是适当的签名double()。此签名作为规则的第二个模板参数传递:

qi::rule<std::string::iterator, double()> double_rule = 
                                                  qi::double_;

如果规则用于跳过空格,我们将用于识别要跳过的字符的解析器的类型指定为rule的第三个模板参数。因此,要定义一个由空格分隔的double列表的解析器,我们可以使用以下规则和qi::space_type,指定空格解析器的类型:

qi::rule<std::string::iterator, std::vector<double>(), 
                qi::space_type> doubles_p = +qi::double_;

当规则以一组解析器的组合形式定义时,规则解析的值是从各个组件解析器解析的值合成而来的。这称为规则的合成属性。规则模板的签名参数应与合成属性的类型兼容。例如,解析器+qi::double_返回一系列双精度浮点数,因此合成属性的类型是std::vector<std::double>

qi::rule<std::string::iterator, std::vector<double>(), 
                                 qi::space_type> doubles_p;
doubles_p %= +qi::double_;

请注意,我们将解析器分配给规则的操作在单独的一行上,使用operator %=。如果我们不使用%=操作符,而是使用普通的赋值操作符,那么使用+qi::double_成功解析的结果将不会传播到doubles_p的合成属性。由于%=操作符,我们可以将语义动作与doubles_p关联起来,以访问其合成值作为std::vector<double>,如下例所示:

std::string nums = "0.207879576 0.577215 2.7182818 3.14159259";
std::vector<double> result;
qi::phrase_parse(iter1, iter2,
 doubles_p[boost::phoenix::ref(result) == qi::_1],
                qi::space);

解析时间戳

考虑形式为 YYYY-mm-DD HH:MM:SS.ff 的时间戳,其中日期部分是必需的,时间部分是可选的。此外,时间的秒和小数秒部分也是可选的。我们需要定义一个合适的解析器表达式。

我们首先需要一种方法来定义固定长度无符号整数的解析器。boost::spirit::qi::int_parser模板非常适用于此目的。使用int_parser的模板参数,我们指定要使用的基本整数类型、数字系统的基数或基数,以及允许的最小和最大数字位数。因此,对于 4 位数的年份,我们可以使用解析器类型int_parser<unsigned short, 10, 4, 4>,最小宽度和最大宽度都为 4,因为我们需要固定长度的整数。以下是使用int_parser构造的规则:

#include <boost/spirit/include/qi.hpp>

namespace qi = boost::spirit::qi;

qi::int_parser<unsigned short, 10, 4, 4> year_p;
qi::int_parser<unsigned short, 10, 2, 2> month_p, day_p, hour_p, 
                                          min_p, sec_p;
qi::rule<std::string::iterator> date_p = 
   year_p >> qi::char_('-') >> month_p >> qi::char_('-') >> day_p;

qi::rule<std::string::iterator> seconds_p = 
            sec_p >> -(qi::char_('.') >> qi::ushort_);

qi::rule<std::string::iterator> time_p = 
   hour_p >> qi::char_(':') >> min_p 
             >> -(qi::char_(':') >> seconds_p);

qi::rule<std::string::iterator> timestamp_p = date_p >> -
                                        (qi::space >> time_p);

当然,我们需要定义操作来捕获时间戳的组件。为了简单起见,我们将操作与组件解析器相关联。我们将定义一个类型来表示时间戳,并将操作与解析器相关联,以设置此类型的实例的属性。

清单 7.31:简单的日期和时间解析器

1 #include <boost/spirit/include/qi.hpp>
 2 #include <boost/bind.hpp>
 3 #include <cassert>
 4 namespace qi = boost::spirit::qi;
 5
 6 struct timestamp_t
 7 {
 8   void setYear(short val) { year = val; }
 9   unsigned short getYear() { return year; }
10   // Other getters / setters
11
12 private:
13   unsigned short year, month, day,
14            hours, minutes, seconds, fractions;
15 };
16
17 timestamp_t parseTimeStamp(std::string input)
18 {
19   timestamp_t ts;
20
21   qi::int_parser<unsigned short, 10, 4, 4> year_p;
22   qi::int_parser<unsigned short, 10, 2, 2> month_p, day_p, 
23                                       hour_p, min_p, sec_p;
24   qi::rule<std::string::iterator> date_p =
25    year_p [boost::bind(&timestamp_t::setYear, &ts, ::_1)]
26    >> qi::char_('-')
27    >> month_p [boost::bind(&timestamp_t::setMonth, &ts, ::_1)]
28    >> qi::char_('-')
29    >> day_p [boost::bind(&timestamp_t::setDay, &ts, ::_1)];
30
31   qi::rule<std::string::iterator> seconds_p =
32       sec_p [boost::bind(&timestamp_t::setSeconds, &ts, ::_1)]
33         >> -(qi::char_('.')
34         >> qi::ushort_
35         [boost::bind(&timestamp_t::setFractions, &ts, ::_1)]);
36
37   qi::rule<std::string::iterator> time_p =
38    hour_p  [boost::bind(&timestamp_t::setHours, &ts, ::_1)]
39    >> qi::char_(':')
40    >> min_p [boost::bind(&timestamp_t::setMinutes, &ts, ::_1)]
41     >> -(qi::char_(':') >> seconds_p);
42
43   qi::rule<std::string::iterator> timestamp_p = date_p >> -
44                                        (qi::space >> time_p);
45   auto iterator = input.begin();
46   bool success = qi::phrase_parse(iterator, input.end(),
47                                   timestamp_p, qi::space);
48   assert(success);
49
50   return ts;
51 }

timestamp_t类型(第 6 行)表示时间戳,具有每个字段的获取器和设置器。为了简洁起见,我们省略了大多数获取器和设置器。我们定义了与时间戳的各个字段的解析器相关联的操作,使用boost::bind(第 25、27、29、32、35、38、40 行)设置timestamp_t实例的适当属性。

自测问题

对于多项选择题,选择所有适用的选项:

  1. 以下重载/特化中的哪一个会解析到调用foo(1.0, std::string("Hello"))

a. template <typename T, typename U> foo(T, U);

b. foo(double, std::string&);

c. template <> foo<double, std::string>

d. 存在歧义

  1. 元函数必须满足的接口是什么?

a. 必须有一个静态的value字段

b. 它必须有一个名为type的嵌入类型

c. 它必须有一个静态的type字段

d. 它必须有一个名为result的嵌入类型

  1. 以下语句boost::mpl::or_<boost::is_floating_point<T>, boost::is_signed<T>>是做什么的?

a. 检查类型 T 是有符号和浮点类型

b. 生成一个检查(a)的元函数

c. 检查类型 T 是有符号还是浮点类型

d. 生成一个检查(b)的元函数

  1. 我们有一个声明为:template <typename T, typename Enable = void> class Bar的模板,并且以任何方式都不使用Enable参数。如何声明 Bar 的部分特化,只有在 T 是非 POD 类型时才实例化?

a. template <T> class Bar<T, boost::is_non_pod<T>>

b. template <T> class Bar<T, boost::enable_if<is_non_pod<T>>::type>

c. template <T> class Bar<T, boost::mpl::not<boost::is_pod<T>>>

d. template <T> class Bar<T, boost::disable_if<is_pod<T>>::type>

  1. 以下关于 C++ lambda 表达式和 Boost Phoenix actors 的哪一个是正确的?

a. Lambda 表达式是无名的,Phoenix actors 不是

b. Phoenix actors 是多态的,而多态 lambda 表达式仅在 C++14 中可用

c. Phoenix actors 可以部分应用,而 lambda 表达式不能

d. Lambda 表达式可以用作闭包,而 Phoenix actors 不能

总结

本章是我们探索 Boost 库的插曲。有两个关键的主题:更具表现力的代码和更快的代码。我们看到高阶编程如何帮助我们使用函数对象和运算符重载实现更具表现力的语法。我们看到模板元编程技术如何使我们能够编写在编译时执行的代码,并为手头的任务选择最优实现。

我们在一个章节中涵盖了大量的材料,并介绍了一种编程范式,这可能对你们中的一些人来说是新的。我们用不同的功能模式解决了一些问题,并看到了 C++函数对象、模板和运算符重载的强大力量。如果你正在阅读大多数 Boost 库的实现,或者试图编写一个高效、表达力强、可扩展的通用库,那么理解本章的主题将立即有所帮助。

在本章中我们没有涵盖的内容还有很多,也没有在本书中涵盖,包括许多但不限于 Boost Spirit 的基本细节,一个 DSEL 构建工具包,Boost Proto;基于表达式模板的快速正则表达式库,Boost Xpressive;以及更先进的元组库,Boost Fusion。希望本章能够给你足够的起点来进一步探索它们。从下一章开始,我们将转向重点介绍 Boost 中用于日期和时间计算的库,重点关注 Boost 中的系统编程库。

参考资料

第八章:日期和时间库

这是一个简短的章节,向您展示如何使用不同的 Boost 库执行基本的日期和时间计算。大多数实际软件都以某种形式使用日期和时间测量。应用程序计算当前日期和时间,以生成应用程序活动的时间日志。专门的程序根据复杂的调度策略计算作业的时间表,并等待特定的时间点或时间间隔过去。有时,应用程序甚至会监视自己的性能和执行速度,并在需要时采取补救措施或发出通知。

在本章中,我们将介绍使用 Boost 库进行日期和时间计算以及测量代码性能。这些主题分为以下几个部分:

  • 使用 Boost Date Time进行日期和时间计算

  • 使用 Boost Chrono 测量时间

  • 使用 Boost Timer 测量程序性能

使用 Boost Date Time 进行日期和时间计算

日期和时间计算在许多软件应用程序中都很重要,但是 C++03 对于操作日期和执行计算的支持有限。Boost Date Time库提供了一组直观的接口,用于表示日期、时间戳、持续时间和时间间隔。通过允许涉及日期、时间戳、持续时间的简单算术运算,并补充一组有用的日期/时间算法,它可以使用很少的代码进行相当复杂的时间和日历计算。

公历中的日期

公历,也称为基督教历,由教皇格里高利十三世于 1582 年 2 月引入,并在接下来的几个世纪内取代了儒略历在绝大多数西方世界的使用。Date_Time库提供了一组用于表示日期和相关数量的类型:

  • boost::gregorian::date:我们使用这种类型来表示公历中的日期。

  • boost::gregorian::date_duration:除了日期,我们还需要表示日期间的持续时间——以天为单位的两个给定日期之间的时间长度。为此,我们使用boost::gregorian::date_duration类型。它指的是与boost::gregorian::days相同的类型。

  • boost::date_period:使用boost::date_period类型表示日历中从给定日期开始并延续一段特定持续时间的固定日期周期。

创建日期对象

我们可以使用日期的组成部分,即年份、月份和日期,创建boost::gregorian::date类型的对象。此外,还有许多工厂函数可以解析不同格式的日期字符串,以创建date对象。在下面的示例中,我们演示了创建date对象的不同方法:

清单 8.1:使用 boost::gregorian::date

 1 #include <boost/date_time.hpp>
 2 #include <iostream>
 3 #include <cassert>
 4 namespace greg = boost::gregorian;
 5
 6 int main() {
 7   greg::date d0;  // default constructed, is not a date
 8   assert(d0.is_not_a_date());
 9   // Construct dates from parts
10   greg::date d1(1948, greg::Jan, 30);
11   greg::date d2(1968, greg::Apr, 4);
12
13   // Construct dates from string representations
14   greg::date dw1 = greg::from_uk_string("15/10/1948");
15   greg::date dw2 = greg::from_simple_string("1956-10-29");
16   greg::date dw3 = greg::from_undelimited_string("19670605");
17   greg::date dw4 = greg::from_us_string("10-06-1973");
18
19   // Current date
20   greg::date today = greg::day_clock::local_day();
21   greg::date londonToday = greg::day_clock::universal_day();
22
23   // Take dates apart
24   std::cout << today.day_of_week() << " " << today.day() << ", "
25             << today.month() << ", " << today.year() << '\n';
26 }

默认构造的日期表示无效日期(第 7 行);is_not_a_date成员谓词对于这样的日期返回 true(第 8 行)。我们可以从其组成部分构造日期:年、月和日。月份可以使用名为JanFebMarAprMayJunJulAugSepOctNovDecenum值来表示,这些是年份的英文缩写。使用特殊的工厂函数,可以从其他标准表示中构造日期。我们使用boost::gregorian::from_uk_string函数从 DD/MM/YYYY 格式的字符串中构造一个date对象,这是英国的标准格式(第 14 行)。boost::gregorian::from_us_string函数用于从美国使用的 MM/DD/YYYY 格式的字符串中构造一个date(第 17 行)。boost::gregorian::from_simple_string函数用于从 ISO 8601 YYYY-MM-DD 格式的字符串中构造一个date(第 15 行),并且其无分隔形式 YYYYMMDD 可以使用boost::gregorian::from_undelimited_string函数转换为date对象(第 16 行)。

时钟提供了一种在系统上检索当前日期和时间的方法。Boost 为此提供了几个时钟。day_clock类型提供了local_day(第 20 行)和universal_day(第 21 行)函数,它们返回本地和 UTC 时区的当前日期,这两者可能相同,也可能相差一天,这取决于时区和时间。

使用方便的访问器成员函数,如daymonthyearday_of_week,我们可以获取date的部分(第 24-25 行)。

注意

Date_Time库不是一个仅包含头文件的库,为了在本节中运行示例,它们必须链接到libboost_date_time库。在 Unix 上,使用 g++,您可以使用以下命令行来编译和链接涉及 Boost Date Time 的示例:

$ g++ example.cpp -o example -lboost_date_time

有关更多详细信息,请参见第一章介绍 Boost

处理日期持续时间

两个日期之间的时间持续时间由boost::gregorian::date_duration表示。在下面的示例中,我们计算日期之间的时间持续时间,并将持续时间添加到日期或从日期中减去以得到新的日期。

清单 8.2:基本日期算术

 1 #include <boost/date_time.hpp>
 2 #include <iostream>
 3 namespace greg = boost::gregorian;
 4
 5 int main() {
 6   greg::date d1(1948, greg::Jan, 30);
 7   greg::date d2(1968, greg::Apr, 4);
 8
 9   greg::date_duration day_diff = d2 - d1;
10   std::cout << day_diff.days() 
11             << " days between the two dates\n";
12
13   greg::date six_weeks_post_d1 = d1 + greg::weeks(6);
14   std::cout << six_weeks_post_d1 << '\n';
15
16   greg::date day_before_d2 = d2 - greg::days(1);
17   std::cout << day_before_d2 << '\n';
18 }

我们计算持续时间(可以是负数)作为两个日期的差异(第 9 行),并以天为单位打印出来(第 10 行)。date_duration对象在内部以天为单位表示持续时间。我们还可以使用类型boost::gregorian::weeksboost::gregorian::monthsboost::gregorian::years来构造以周、月或年为单位的date_duration对象。请注意,boost::gregorian::daysboost::gregorian::date_duration指的是相同的类型。我们通过将持续时间添加到日期或从日期中减去它们(第 13、16 行)来获得新的日期。

日期周期

以固定日期开始的周期由类型boost::gregorian::date_period表示。在下面的示例中,我们构造了两个日期周期,一个是日历年,一个是美国财政年。我们计算它们的重叠期,然后确定重叠期内每个月的最后一个星期五的日期。

清单 8.3:日期周期和日历计算

 1 #include <boost/date_time.hpp>
 2 #include <iostream>
 3 namespace greg = boost::gregorian;
 4 namespace dt = boost::date_time;
 5
 6 int main() {
 7   greg::date startCal(2015, greg::Jan, 1);
 8   greg::date endCal(2015, greg::Dec, 31);
 9
10   greg::date startFiscal(2014, greg::Oct, 1);
11   greg::date endFiscal(2015, greg::Sep, 30);
12
13   greg::date_period cal(startCal, endCal);
14   greg::date_period fisc(startFiscal, endFiscal);
15
16   std::cout << "Fiscal year begins " << fisc.begin()
17     << " and ends " << fisc.end() << '\n';
18
19   if (cal.intersects(fisc)) {
20     auto overlap = cal.intersection(fisc);
21     greg::month_iterator miter(overlap.begin());
22
23     while (*miter < overlap.end()) {
24       greg::last_day_of_the_week_in_month 
25                    last_weekday(greg::Friday, miter->month());
26       std::cout << last_weekday.get_date(miter->year())
27                 << '\n';
28       ++miter;
29     }
30   }
31 }

我们根据开始日期和结束日期定义日期周期(第 13、14 行)。我们可以使用date_periodintersects成员函数(第 19 行)检查两个周期是否重叠,并使用intersection成员函数(第 20 行)获取重叠期。我们通过在开始日期处创建一个month_iterator(第 21 行),并使用预增量运算符(第 28 行)迭代到结束日期(第 23 行)来遍历一个周期。有不同类型的迭代器,具有不同的迭代周期。我们使用boost::gregorian::month_iterator来迭代周期内连续的月份。month_iterator每次递增时都会将日期提前一个月。您还可以使用其他迭代器,如year_iteratorweek_iteratorday_iterator,它们分别以年、周或天为单位递增迭代器。

对于周期中的每个月,我们想要找到该月的最后一个星期五的日期。Date Time库具有一些有趣的算法类,用于此类日历计算。我们使用boost::gregorian::last_day_of_the_week_in_month算法来执行这样的计算,以确定月份的最后一个星期五的日期。我们构造了一个last_day_of_the_week_in_month对象,构造函数参数是星期几(星期五)和月份(第 24、25 行)。然后我们调用它的get_date成员函数,将特定年份传递给它(第 26 行)。

Posix 时间

Date_Time库还提供了一组类型,用于表示时间点、持续时间和周期。

  • boost::posix_time::ptime:特定的时间点,或者时间点,由类型boost::posix_time::ptime表示。

  • boost::posix_time::time_duration:与日期持续时间一样,两个时间点之间的时间长度称为时间持续时间,并由类型boost::posix_time::time_duration表示。

  • boost::posix_time::time_period:从特定时间点开始的固定间隔,到另一个时间点结束,称为时间段,由类型boost::posix_time::time_period表示。

这些类型及其上的操作一起定义了一个时间系统。Posix Time 使用boost::gregorian::date来表示时间点的日期部分。

构造时间点和持续时间

我们可以从其组成部分,即日期、小时、分钟、秒等创建boost::posix_time::ptime的实例,或者使用解析时间戳字符串的工厂函数。在以下示例中,我们展示了创建ptime对象的不同方式:

清单 8.4:使用 boost::posix_time

 1 #include <boost/date_time.hpp>
 2 #include <iostream>
 3 #include <cassert>
 4 #include <ctime>
 5 namespace greg = boost::gregorian;
 6 namespace pt = boost::posix_time;
 7
 8 int main() {
 9   pt::ptime pt; // default constructed, is not a time
10   assert(pt.is_not_a_date_time());
11
12   // Get current time
13   pt::ptime now1 = pt::second_clock::universal_time();
14   pt::ptime now2 = pt::from_time_t(std::time(0));
15
16   // Construct from strings
17   // Create time points using durations
18   pt::ptime pt1(greg::day_clock::universal_day(),
19           pt::hours(10) + pt::minutes(42)
20           + pt::seconds(20) + pt::microseconds(30));
21   std::cout << pt1 << '\n';
22
23   // Compute durations
24   pt::time_duration dur = now1 - pt1;
25   std::cout << dur << '\n';
26   std::cout << dur.total_microseconds() << '\n';
27
28   pt::ptime pt2(greg::day_clock::universal_day()),
29        pt3 = pt::time_from_string("2015-01-28 10:00:31.83"),
30        pt4 = pt::from_iso_string("20150128T151200");
31
32   std::cout << pt2 << '\n' << to_iso_string(pt3) << '\n'
33             << to_simple_string(pt4) << '\n';
34 }

就像日期对象一样,默认构造的ptime对象(第 9 行)不是一个有效的时间点(第 10 行)。有时钟可以用来推导一天中的当前时间,例如,second_clockmicrosec_clock,它们分别以秒或微秒单位给出时间。在这些时钟上调用local_timeuniversal_time函数(第 13 行)将返回本地和 UTC 时区中的当前日期和时间。

from_time_t工厂函数传递 Unix 时间,即自 Unix 纪元(1970 年 1 月 1 日 00:00:00 UTC)以来经过的秒数,并构造一个表示该时间点的ptime对象(第 14 行)。当传递 0 时,C 库函数time返回 UTC 时区中的当前 Unix 时间。

两个时间点之间的持续时间,可以是负数,是通过计算两个时间点之间的差值来计算的(第 24 行)。它可以被流式传输到输出流中,以默认方式打印持续时间,以小时、分钟、秒和小数秒为单位。使用访问器函数hoursminutessecondsfractional_seconds,我们可以获取持续时间的相关部分。或者我们可以使用访问器total_secondstotal_millisecondstotal_microsecondstotal_nanoseconds将整个持续时间转换为秒或亚秒单位(第 26 行)。

我们可以从一个公历日期和一个类型为boost::posix_time::time_duration的持续时间对象创建一个ptime对象(第 18-20 行)。我们可以在boost::posix_time命名空间中使用 shim 类型hoursminutessecondsmicroseconds等来生成适当单位的boost::posix_time::time_duration类型的持续时间,并使用operator+将它们组合起来。

我们可以仅从一个boost::gregorian::date对象构造一个ptime对象(第 28 行)。这代表了给定日期的午夜时间。我们可以使用工厂函数从不同的字符串表示中创建ptime对象(第 29-30 行)。函数time_from_string用于从“YYYY-MM-DD hh:mm:ss.xxx…”格式的时间戳字符串构造一个ptime实例,在该格式中,日期和时间部分由空格分隔(第 29 行)。函数from_iso_string用于从“YYYYMMDDThhmmss.xxx…”格式的无分隔字符串构造一个ptime实例,其中大写 T 分隔日期和时间部分(第 30 行)。在这两种情况下,分钟、秒和小数秒是可选的,如果未指定,则被视为零。小数秒可以跟在秒后,用小数点分隔。这些格式是与地区相关的。例如,在几个欧洲地区,使用逗号代替小数点。

我们可以将ptime对象流式输出到输出流,比如std::cout(第 32 行)。我们还可以使用转换函数,比如to_simple_stringto_iso_string(第 32-33 行),将ptime实例转换为string。在英文环境中,to_simple_string函数将其转换为"YYYY-MM-DD hh:mm:ss.xxx…"格式。请注意,这是time_from_string预期的相同格式,也是在流式输出ptime时使用的格式。to_iso_string函数将其转换为"YYYYMMDDThhmmss.xxx…"格式,与from_iso_string预期的格式相同。

分辨率

时间系统可以表示的最小持续时间称为其分辨率。时间在特定系统上表示的精度,因此,有效的小数秒数取决于时间系统的分辨率。Posix 时间使用的默认分辨率是微秒(10^(-6)秒),也就是说,它不能表示比微秒更短的持续时间,因此不能区分相隔不到一微秒的两个时间点。以下示例演示了如何获取和解释时间系统的分辨率:

清单 8.5:时间刻度和分辨率

 1 #include <boost/date_time.hpp>
 2 #include <iostream>
 3 namespace pt = boost::posix_time;
 4 namespace dt = boost::date_time;
 5 
 6 int main() {
 7   switch (pt::time_duration::resolution()) {
 8   case dt::time_resolutions::sec:
 9     std::cout << " second\n";
10     break;
11   case dt::time_resolutions::tenth:
12     std::cout << " tenth\n";
13     break;
14   case dt::time_resolutions::hundredth:
15     std::cout << " hundredth\n";
16     break;
17   case dt::time_resolutions::milli:
18     std::cout << " milli\n";
19     break;
20   case dt::time_resolutions::ten_thousandth:
21     std::cout << " ten_thousandth\n";
22     break;
23   case dt::time_resolutions::micro:
24     std::cout << " micro\n";
25     break;
26   case dt::time_resolutions::nano:
27     std::cout << " nano\n";
28     break;
29   default:
30     std::cout << " unknown\n";
31     break;
32   }
33   std::cout << pt::time_duration::num_fractional_digits()
34             << '\n';
35   std::cout << pt::time_duration::ticks_per_second() 
36             << '\n';
37 }

time_duration类的resolution静态函数返回一个枚举常量作为分辨率(第 7 行);我们解释这个enum并打印一个字符串来指示分辨率(第 7-32 行)。

num_fractional_digits静态函数返回小数秒的有效数字位数(第 33 行);在具有微秒分辨率的系统上,这将是 6,在具有纳秒分辨率的系统上,这将是 9。ticks_per_second静态函数将 1 秒转换为系统上最小可表示的时间单位(第 35 行);在具有微秒分辨率的系统上,这将是 10⁶,在具有纳秒分辨率的系统上,这将是 10⁹。

时间段

与日期一样,我们可以使用boost::posix_time::time_period表示固定的时间段。以下是一个简短的示例,演示了如何创建时间段并比较不同的时间段:

清单 8.6:使用时间段

 1 #include <boost/date_time.hpp>
 2 #include <iostream>
 3 #include <cassert>
 4 namespace greg = boost::gregorian;
 5 namespace pt = boost::posix_time;
 6
 7 int main()
 8 {
 9   // Get current time
10   pt::ptime now1 = pt::second_clock::local_time();
11   pt::time_period starts_now(now1, pt::hours(2));
12
13   assert(starts_now.length() == pt::hours(2));
14
15   auto later1 = now1 + pt::hours(1);
16   pt::time_period starts_in_1(later1, pt::hours(3));
17
18   assert(starts_in_1.length() == pt::hours(3));
19
20   auto later2 = now1 + pt::hours(3);
21   pt::time_period starts_in_3(later2, pt::hours(1));
22
23   assert(starts_in_3.length() == pt::hours(1));
24
26   std::cout << "starts_in_1 starts at " << starts_in_1.begin()
27             << " and ends at " << starts_in_1.last() << '\n';
28
29   // comparing time periods
30   // non-overlapping
31   assert(starts_now < starts_in_3);
32   assert(!starts_now.intersects(starts_in_3));
33
34   // overlapping
35   assert(starts_now.intersects(starts_in_1));
36
37   assert(starts_in_1.contains(starts_in_3));
38 }

我们创建了一个名为starts_now的时间段,它从当前时刻开始,持续 2 小时。为此,我们使用了time_period的两个参数构造函数,传递了当前时间戳和 2 小时的持续时间(第 11 行)。使用time_periodlength成员函数,我们验证了该时间段的长度确实为 2 小时(第 13 行)。

我们创建了另外两个时间段:starts_in_1从 1 小时后开始,持续 3 小时(第 16 行),starts_in_3从 3 小时后开始,持续 1 小时(第 20 行)。time_periodbeginlast成员函数返回时间段中的第一个和最后一个时间点(第 26-27 行)。

我们使用关系运算符和称为intersectscontains的两个成员函数来表示三个时间段starts_nowstarts_in_1starts_in_3之间的关系。显然,starts_in_1的第一个小时与starts_now的最后一个小时重叠,因此我们断言starts_nowstarts_in_1相交(第 35 行)。starts_in_1的最后一个小时与整个时间段starts_in_3重合,因此我们断言starts_in_1包含starts_in_3(第 37 行)。但是starts_nowstarts_in_3不重叠;因此,我们断言starts_nowstarts_in_3不相交(第 32 行)。

关系运算符operator<被定义为对于两个时间段tp1tp2,条件tp1 < tp2成立当且仅当tp1.last() < tp2.begin()。同样,operator>被定义为条件tp1 > tp2成立当且仅当tp1.begin() > tp2.last()。这些定义意味着tp1tp2是不相交的。因此,对于不相交的time_period starts_nowstarts_in_3,关系starts_now < starts_in_3成立(第 31 行)。这些关系对于重叠的时间段是没有意义的。

时间迭代器

我们可以使用boost::posix_time::time_iterator来遍历一个时间段,类似于我们使用boost::gregorian::date_iterator的方式。下面的例子展示了这一点:

清单 8.7:遍历一个时间段

 1 #include <boost/date_time.hpp>
 2 #include <iostream>
 3
 4 namespace greg = boost::gregorian;
 5 namespace pt = boost::posix_time;
 6
 7 int main()
 8 {
 9   pt::ptime now = pt::second_clock::local_time();
10   pt::ptime start_of_day(greg::day_clock::local_day());
11
12   for (pt::time_iterator iter(start_of_day, 
13          pt::hours(1)); iter < now; ++iter)
14   {
15     std::cout << *iter << '\n';
16   }
17 }

前面的例子打印了当天每个完成的小时的时间戳。我们实例化了一个time_iterator(第 12 行),将开始迭代的时间点(start_of_day)和迭代器每次增加的持续时间(1 小时)传递给它。我们迭代直到当前时间,通过解引用迭代器获得时间戳(第 15 行)并增加迭代器(第 13 行)。请注意,在表达式iter < now中,我们将迭代器与时间点进行比较,以决定何时停止迭代——这是posix_time::time_iterator的一个特殊属性,与其他迭代器不同。

使用 Chrono 来测量时间

Boost Chrono 是一个用于时间计算的库,与Date Time库的 Posix Time 部分有一些重叠的功能。与 Posix Time 一样,Chrono 也使用时间点和持续时间的概念。Chrono 不处理日期。它比Date Time库更新,实现了 C++标准委员会工作组(WG21)的一份提案中提出的设施。该提案的部分内容成为了 C++11 标准库的一部分,即Chrono库,Boost Chrono 上的许多讨论也适用于 Chrono 标准库(std::chrono)。

持续时间

持续时间表示一段时间间隔。持续时间具有数值大小,并且必须用时间单位表示。boost::chrono::duration模板用于表示任何这样的持续时间,并声明如下:

template <typename Representation, typename Period>
class duration;

Representation类型参数标识用于持续时间大小的基础算术类型。Period类型参数标识滴答周期,即用于测量持续时间的一个时间单位的大小。该周期通常表示为 1 秒的比例或分数,使用一个名为boost::ratio的模板。

因此,如果我们想要以百分之一秒(centiseconds)表示持续时间,我们可以使用int64_t作为基础类型,并且可以使用比例(1/100)来表示滴答周期,因为滴答周期是一百分之一秒。使用boost::ratio,我们可以特化duration来表示百分之一秒的间隔,如下所示:

typedef boost::chrono::duration<int64_t, boost::ratio<1, 100>> 
                                                    centiseconds;
centiseconds cs(1000);  // represents 10 seconds

我们创建了一个名为centisecondstypedef,并将1000作为构造函数参数传递进去,这是持续时间中的百分之一秒的数量。1000百分之一秒相当于(1/100)*1000 秒,也就是 10 秒。

boost::ratio模板用于构造表示有理数的类型,即两个整数的比例。我们通过将我们的有理数的分子和分母作为两个非类型模板参数来特化ratio,按照这个顺序。第二个参数默认为 1;因此,要表示一个整数,比如 100,我们可以简单地写成boost::ratio<100>,而不是boost::ratio<100, 1>。表达式boost::ratio<100>并不代表值 100,而是封装了有理数 100 的类型。

Chrono库已经提供了一组预定义的duration的特化,用于构造以常用时间单位表示的持续时间。这些包括:

  • boost::chrono::hours(滴答周期=boost::ratio<3600>

  • boost::chrono::minutes(滴答周期=boost::ratio<60>

  • boost::chrono::seconds(滴答周期 = boost::ratio<1>

  • boost::chrono::milliseconds(滴答周期 = boost::ratio<1, 1000>

  • boost::chrono::microseconds(滴答周期 = boost::ratio<1, 1000000>

  • boost::chrono::nanoseconds(滴答周期 = boost::ratio<1, 1000000000>

持续时间算术

持续时间可以相加和相减,并且不同单位的持续时间可以组合成其他持续时间。较大单位的持续时间可以隐式转换为较小单位的持续时间。如果使用浮点表示,从较小单位到较大单位的隐式转换是可能的;对于整数表示,这样的转换会导致精度损失。为了处理这个问题,我们必须使用类似于强制转换运算符的函数,进行从较小单位到较大单位的显式转换:

清单 8.8:使用 chrono 持续时间

 1 #include <boost/chrono/chrono.hpp>
 2 #include <boost/chrono/chrono_io.hpp>
 3 #include <iostream>
 4 #include <cstdint>
 5 namespace chrono = boost::chrono;
 6
 7 int main()
 8 {
 9   chrono::duration<int64_t, boost::ratio<1, 100>> csec(10);
10   std::cout << csec.count() << '\n';
11   std::cout << csec << '\n';
12
13   chrono::seconds sec(10);
14   chrono::milliseconds sum = sec + chrono::milliseconds(20);
15   // chrono::seconds sum1 = sec + chrono::milliseconds(20);
16
17   chrono::milliseconds msec = sec;
18
19   // chrono::seconds sec2 = sum;
20   chrono::seconds sec2 = 
21                  chrono::duration_cast<chrono::seconds>(sum);
22 }

这个例子说明了您可以执行的不同操作与持续时间。boost/chrono/chrono.hpp头文件包括了我们需要的大部分 Boost Chrono 设施(第 1 行)。我们首先创建一个 10 厘秒的duration(第 9 行)。count成员函数返回持续时间的滴答计数,即持续时间中所选单位的时间单位数,厘秒(第 10 行)。我们可以直接将持续时间流式传输到输出流(第 11 行),但需要包含额外的头文件boost/chrono/chrono_io.hpp来访问这些操作符(第 2 行)。流式传输csec打印如下:

10 centiseconds

Boost Ratio 根据持续时间使用的时间单位提供适当的 SI 单位前缀,并用于智能打印适当的 SI 前缀。这在 C++11 标准库 Chrono 实现中不可用。

我们使用适当的持续时间特化创建秒和毫秒持续时间,并使用重载的operator+计算它们的和(第 13、14 行)。秒和毫秒持续时间的和是毫秒持续时间。毫秒持续时间隐式转换为秒单位的持续时间会导致精度损失,因为较大类型的表示是整数类型。因此,不支持这种隐式转换(第 15 行)。例如,10 秒+20 毫秒将计算为 10020 毫秒。boost:::chrono::seconds typedef使用带符号整数类型表示,要将 10020 毫秒表示为秒,20 毫秒需要被隐式四舍五入。

我们使用duration_cast函数模板,类似于 C++转换运算符,执行这种转换(第 20-21 行),使意图明确。duration_cast将进行四舍五入。另一方面,秒单位的持续时间总是可以隐式转换为毫秒单位的持续时间,因为没有精度损失(第 17 行)。

注意

Chrono库是一个单独构建的库,也依赖于 Boost System 库。因此,我们必须将本节中的示例链接到libboost_system。在 Unix 上使用 g++,您可以使用以下命令行来编译和链接涉及 Boost Chrono 的示例:

$ g++ example.cpp -o example -lboost_system -lboost_chrono

对于非标准位置安装的 Boost 库,请参阅第一章介绍 Boost

如果我们将持续时间专门化为使用double表示秒,而不是带符号整数,那么情况将会有所不同。以下代码将编译,因为double表示将能够容纳小数部分:

boost::chrono::milliseconds millies(20);
boost::chrono::duration<double> sec(10);

boost::chrono::duration<double> sec2 = sec + millies;
std::cout << sec2 << '\n';

注意

我们在本书中没有详细介绍 Boost Ratio,但本章介绍了处理 Boost Chrono 所需的足够细节。此外,您可以访问比率的部分,并将比率打印为有理数或 SI 前缀,如果有意义的话。以下代码说明了这一点:

#include <boost/ratio.hpp>
typedef boost::ratio<1000> kilo;
typedef boost::ratio<1, 1000> milli;
typedef boost::ratio<22, 7> not_quite_pi;
std::cout << not_quite_pi::num << "/" 
          << not_quite_pi::den << '\n';
std::cout << boost::ratio_string<kilo, char>::prefix() 
          << '\n';
std::cout << boost::ratio_string<milli, char>::prefix() 
          << '\n';

注意我们如何使用ratio_string模板及其前缀成员函数来打印 SI 前缀。代码打印如下:

22/7
kilo
milli

C++11 标准库中的std::ratio模板对应于 Boost Ratio,并被std::chrono使用。标准库中没有ratio_string,因此缺少 SI 前缀打印。

时钟和时间点

时间点是时间的固定点,而不是持续时间。给定一个时间点,我们可以从中添加或减去一个持续时间,以得到另一个时间点。时代是某个时间系统中的参考时间点,可以与持续时间结合,以定义其他时间点。最著名的时代是 Unix 或 POSIX 时代,即 1970 年 1 月 1 日 00:00:00 UTC。

Boost Chrono 提供了几种时钟,用于在不同的上下文中测量时间。时钟具有以下关联成员:

  • 一个名为duration的 typedef,表示使用该时钟可以表示的最小持续时间

  • 一个名为time_point的 typedef,用于表示该时钟的时间点

  • 一个静态成员函数now,返回当前时间点

Boost Chrono 定义了几种时钟,其中一些可能在您的系统上可用,也可能不可用:

  • system_clock类型表示壁钟或系统时间。

  • steady_clock类型表示一个单调时间系统,这意味着如果连续调用now函数,第二次调用将始终返回比第一次调用返回的时间点晚的时间点。这对于system_clock不能保证。只有在定义了BOOST_CHRONO_HAS_STEADY_CLOCK预处理宏时,才可用steady_clock类型。

  • 如果可用,high_resolution_clock类型被定义为steady_clock,否则被定义为system_clock

前面的时钟也可以作为std::chrono的一部分使用。它们使用一个实现定义的时代,并提供了在time_point和 Unix 时间(std::time_t)之间转换的函数。以下示例说明了时钟和时间点的使用方式:

清单 8.9:使用 chrono system_clock

 1 #include <iostream>
 2 #include <boost/chrono.hpp>
 3
 4 namespace chrono = boost::chrono;
 5
 6 int main()
 7 {
 8   typedef chrono::system_clock::period tick_period;
 9   std::cout
10      << boost::ratio_string<tick_period, char>::prefix() 
11      << " seconds\n";
12   chrono::system_clock::time_point epoch;
13   chrono::system_clock::time_point now = 
14                             chrono::system_clock::now();
15
16   std::cout << epoch << '\n';
17   std::cout << chrono::time_point_cast<chrono::hours>(now) 
18             << '\n';
19 }

在这个例子中,我们首先打印与system_clock关联的持续时间的滴答周期。system_clock::periodsystem_clock::duration::period的一个 typedef,表示与system_clock关联的持续时间的滴答周期(第 8 行)。我们将其传递给boost::ratio_string,并使用prefix成员函数打印正确的 SI 前缀(第 9-10 行)。

它构造了两个时间点:一个用于system_clock的默认构造时间点,表示时钟的时代(第 12 行),以及由system_clock提供的now函数返回的当前时间(第 13-14 行)。然后我们打印时代(第 16 行),然后是当前时间(第 17 行)。时间点被打印为自时代以来的时间单位数。请注意,我们使用time_point_cast函数将当前时间转换为自时代以来的小时数。前面的代码在我的系统上打印如下:

nanoseconds
0 nanoseconds since Jan 1, 1970
395219 hours since Jan 1, 1970

Boost Chrono 还提供了以下时钟,这些时钟都不作为 C++标准库 Chrono 的一部分:

  • process_real_cpu_clock类型用于测量程序启动以来的总时间。

  • process_user_cpu_clock类型用于测量程序在用户空间运行的时间。

  • process_system_cpu类型用于测量内核代表程序运行某些代码的时间。

  • thread_clock类型用于测量特定线程调度的总时间。只有在定义了BOOST_CHRONO_HAS_THREAD_CLOCK预处理宏时才可用此时钟。

只有在定义了BOOST_CHRONO_HAS_PROCESS_CLOCKS预处理宏时,才可用处理时钟。这些时钟可以类似于系统时钟使用,但它们的时代是 CPU 时钟的程序启动时,或者线程时钟的线程启动时。

使用 Boost Timer 测量程序性能

作为程序员,我们经常需要测量代码段的性能。虽然有几种出色的性能分析工具可用于此目的,但有时,能够对我们自己的代码进行仪器化既简单又更精确。Boost Timer 库提供了一个易于使用的、可移植的接口,用于通过仪器化代码来测量执行时间并报告它们。它是一个单独编译的库,不是仅头文件,并且在内部使用 Boost Chrono。

cpu_timer

boost::timer::cpu_timer类用于测量代码段的执行时间。在下面的示例中,我们编写一个函数,该函数读取文件的内容并将其包装在unique_ptr中返回(参见第三章内存管理和异常安全)。它还使用cpu_timer计算并打印读取文件所用的时间。

清单 8.10:使用 cpu_timer

 1 #include <fstream>
 2 #include <memory>
 3 #include <boost/timer/timer.hpp>
 4 #include <string>
 5 #include <boost/filesystem.hpp>
 6 using std::ios;
 7
 8 std::unique_ptr<char[]> readFile(const std::string& file_name,
 9                                  std::streampos& size)
10 {
11   std::unique_ptr<char[]> buffer;
12   std::ifstream file(file_name, ios::binary);
13
14   if (file) {
15     size = boost::filesystem::file_size(file_name);
16
17     if (size > 0) {
18       buffer.reset(new char[size]);
19
20       boost::timer::cpu_timer timer;
21       file.read(buffer.get(), size);
22       timer.stop();
23
24       std::cerr << "file size = " << size
25                 << ": time = " << timer.format();
26     }
27   }
28
29   return buffer;
30 }

我们在代码段的开始处创建一个cpu_timer实例(第 20 行),它启动计时器。在代码段结束时,我们在cpu_timer对象上调用stop成员函数(第 22 行),它停止计时器。我们调用format成员函数以获得可读的经过时间表示,并将其打印到标准错误(第 25 行)。使用文件名调用此函数,将以下内容打印到标准输入:

file size = 1697199:  0.111945s wall, 0.000000s user + 0.060000s system = 0.060000s CPU (53.6%)

这表明对fstreamread成员函数的调用(第 21 行)被阻塞了 0.111945 秒。这是挂钟时间,即计时器测量的总经过时间。CPU 在用户模式下花费了 0.000000 秒,在内核模式下花费了 0.060000 秒(即在系统调用中)。请注意,读取完全在内核模式下进行,这是预期的,因为它涉及调用系统调用(例如在 Unix 上的读取)来从磁盘中读取文件的内容。CPU 在执行此代码时花费的经过时间的百分比为 53.6。它是作为在用户模式和内核模式中花费的持续时间之和除以总经过时间计算的,即(0.0 + 0.06)/ 0.111945,约为 0.536。

注意

使用 Boost Timer 的代码必须链接libboost_timerlibboost_system。要在 POSIX 系统上使用 g++构建涉及 Boost Timer 的示例,使用以下命令行:

$ g++ source.cpp -o executable -std=c++11 -lboost_system -lboost_timer

对于安装在非标准位置的 Boost 库,请参阅第一章介绍 Boost

如果我们想要测量打开文件、从文件中读取并关闭文件所花费的累积时间,那么我们可以使用单个计时器来测量多个部分的执行时间,根据需要停止和恢复计时器。

以下代码片段说明了这一点:

12   boost::timer::cpu_timer timer;
13   file.open(file_name, ios::in|ios::binary|ios::ate);
14
15   if (file) {
16     size = file.tellg();
17
18     if (size > 0) {
19       timer.stop();
20       buffer.reset(new char[size]);
21
22       timer.resume();
23       file.seekg(0, ios::beg);
24       file.read(buffer.get(), size);
25     }
26
27     file.close();
28   }
29
30   timer.stop();
31 

在停止的计时器上调用resume成员函数会重新启动计时器,并添加到任何先前的测量中。在前面的代码片段中,我们在分配堆内存之前停止计时器(第 19 行),然后立即恢复计时器(第 22 行)。

还有一个start成员函数,它在cpu_timer构造函数内部调用以开始测量。在停止的计时器上调用start而不是resume会清除任何先前的测量,并有效地重置计时器。您还可以使用is_stopped成员函数检查计时器是否已停止,如果计时器已停止,则返回true,否则返回false

我们可以通过调用cpu_timerelapsed成员函数获取经过的时间(挂钟时间)、在用户模式下花费的 CPU 时间和在内核模式下花费的 CPU 时间(以纳秒为单位):

20       file.seekg(0, ios::beg);
21       boost::timer::cpu_timer timer;
22       file.read(buffer.get(), size);
23       timer.stop();
24
25       boost::timer::cpu_times times = timer.elapsed();
26       std::cout << std::fixed << std::setprecision(8)
27                 << times.wall / 1.0e9 << "s wall, "
28                 << times.user / 1.0e9 << "s user + "
29                 << times.system / 1.0e9 << "s system. "
30                 << (double)100*(timer.user + timer.system) 
31                       / timer.wall << "% CPU\n";

elapsed成员函数返回一个cpu_times类型的对象(第 25 行),其中包含三个字段,分别称为wallusersystem,它们以纳秒(10^(-9)秒)为单位包含适当的持续时间。

自动 CPU 计时器

boost::timer::auto_cpu_timercpu_timer的子类,它会在其封闭作用域结束时自动停止计数器,并将测量的执行时间写入标准输出或用户提供的另一个输出流。您无法停止和恢复它。当您需要测量代码段的执行时间直到作用域结束时,您可以使用auto_cpu_timer,只需使用一行代码,如下面从列表 8.10 调整的片段所示:

17     if (size > 0) {
18       buffer.reset(new char[size]);
19
20       file.seekg(0, ios::beg);
21
22       boost::timer::auto_cpu_timer timer;
23       file.read(buffer.get(), size);
24     }

这将以熟悉的格式将测量的执行时间打印到标准输出:

0.102563s wall, 0.000000s user + 0.040000s system = 0.040000s CPU (39.0%)

要将其打印到不同的输出流,我们需要将流作为构造函数参数传递给timer

要测量读取文件所需的时间,我们只需在调用read之前声明auto_cpu_timer实例(第 22 行)。如果调用 read 不是作用域中的最后一条语句,并且我们不想测量后续内容的执行时间,那么这将不起作用。然后,我们可以使用cpu_timer而不是auto_cpu_timer,或者只将我们感兴趣的语句放在一个嵌套作用域中,并在开始时创建一个auto_cpu_timer实例:

17     if (size > 0) {
18       buffer.reset(new char[size]);
19
20       file.seekg(0, ios::beg);
21
22       {
23         boost::timer::auto_cpu_timer timer(std::cerr);
24         file.read(buffer.get(), size);
25       }
26       // remaining statements in scope
27     }

在上面的例子中,我们创建了一个新的作用域(第 22-25 行),使用auto_cpu_timer来隔离要测量的代码部分。

自测问题

对于多项选择题,选择所有适用的选项:

  1. 以下代码行哪个/哪些是不正确的?假设符号来自boost::chrono命名空间。

a. milliseconds ms = milliseconds(5) + microseconds(10);

b. nanoseconds ns = milliseconds(5) + microseconds(10);

c. microseconds us = milliseconds(5) + microseconds(10);

d. seconds s = minutes(5) + microseconds(10);

  1. boost::chrono::duration<std::intmax_t, boost::ratio<1, 1000000>>代表什么类型?

a. 以整数表示的毫秒持续时间

b. 以整数表示的微秒持续时间

c. 以浮点表示的毫秒持续时间

d. 以整数表示的纳秒持续时间

  1. boost::timer::cpu_timerboost::timer::auto_cpu_timer之间有什么区别?

a. auto_cpu_timer在构造函数中调用startcpu_timer不会

b. auto_cpu_timer 无法停止和恢复

c. auto_cpu_timer在作用域结束时写入输出流,cpu_timer不会

d. 你可以从cpu_timer中提取墙壁时间、用户时间和系统时间,但不能从auto_cpu_timer中提取

总结

本章介绍了用于测量时间和计算日期的库。本章让您快速了解了日期和时间计算的基础知识,而不涉及复杂的日历计算、时区意识和自定义和特定区域设置的格式。Boost 在线文档是这些细节的绝佳来源。

参考

第九章:文件、目录和 IOStreams

为了与操作系统的各种子系统进行交互以利用它们的服务,编写真实世界系统的程序需要。从本章开始,我们将看看各种 Boost 库,这些库提供对操作系统子系统的编程访问。

在本章中,我们将介绍用于执行输入和输出以及与文件系统交互的 Boost 库。我们将在本章的以下部分中介绍这些库:

  • 使用 Boost 文件系统管理文件和目录

  • 使用 Boost IOStreams 进行可扩展 I/O

使用本章涵盖的库和技术,您将能够编写可移植的 C++程序,与文件系统交互,并使用标准接口执行各种 I/O 操作。本章不涵盖网络 I/O,而是专门讨论第十章使用 Boost 进行并发

使用 Boost 文件系统管理文件和目录

使用 Boost 库编写的软件可以在多个操作系统上运行,包括 Linux、Microsoft Windows、Mac OS 和各种其他 BSD 变体。这些操作系统访问文件和目录的路径的方式可能在多种方面有所不同;例如,MS Windows 使用反斜杠作为目录分隔符,而所有 Unix 变体,包括 Linux、BSD 和 Mac,使用正斜杠。非英语操作系统可能使用其他字符作为目录分隔符,有时还支持多个目录分隔符。Boost 文件系统库隐藏了这些特定于平台的特性,并允许您编写更具可移植性的代码。使用 Boost 文件系统库中的函数和类型,您可以编写与操作系统无关的代码,执行应用程序运行所需的文件系统上的常见操作,如复制、重命名和删除文件,遍历目录,创建目录和链接等。

操作路径

文件系统路径使用boost::filesystem::path类型的对象表示。给定boost::filesystem::path类型的对象,我们可以从中获取有用的信息,并从中派生其他path对象。path对象允许我们对真实的文件系统路径进行建模并从中获取信息,但它不一定代表系统中真正存在的路径。

打印路径

让我们看看使用 Boost 文件系统打印进程的当前工作目录的第一个示例:

清单 9.1:使用 Boost 文件系统的第一个示例

 1 #include <boost/filesystem.hpp>
 2 #include <iostream>
 3
 4 namespace fs = boost::filesystem;
 5
 6 int main() {
 7   // Get the current working directory
 8   fs::path cwd = fs::current_path();
 9
10   // Print the path to stdout
11   std::cout << "generic: " << cwd.generic_string() << '\n';
12   std::cout << "native: " << cwd.string() << '\n';
13   std::cout << "quoted: " << cwd << '\n';
14 
15   std::cout << "Components: \n";
16   for (const auto& dir : cwd) {
17     std::cout <<'[' <<dir.string() << ']'; // each part
18   }
19   std::cout << '\n';
20 }

在此示例中,程序通过调用current_path(第 8 行)确定其当前工作目录,这是boost::filesystem命名空间中的一个命名空间级函数。它返回一个表示当前工作目录的boost::filesystem::path类型的对象。boost::filesystem中的大多数函数都是在boost::filesystem::path对象上而不是字符串上工作。

我们通过调用pathgeneric_string成员函数(第 11 行),通过调用string成员函数(第 12 行),以及通过将cwd,路径对象,流式传输到输出流(第 13 行)来打印路径。generic_string成员以通用格式返回路径,该格式由 Boost 文件系统支持,使用正斜杠作为分隔符。string成员函数以本机格式返回路径,这是一个依赖于操作系统的实现定义格式。在 Windows 上,本机格式使用反斜杠作为路径分隔符,而在 UNIX 上,通用格式和本机格式之间没有区别。Boost 文件系统在 Windows 上识别正斜杠和反斜杠作为路径分隔符。

流式传输path对象也会以本机格式写入路径,但还会在路径周围加上双引号。在路径中有嵌入空格的情况下,加上双引号可以方便将结果用作命令的参数。如果路径中有嵌入的双引号字符("),则会用和号(&)对其进行转义。

在 Windows 上,完整路径以宽字符(wchar_t)字符串存储,因此generic_stringstring在执行转换将路径作为std::string返回。根据路径中特定的 Unicode 字符,可能无法将路径有意义地转换为单字节字符字符串。在这种系统上,只能安全地调用generic_wstringwstring成员函数,它们以通用或本机格式返回路径作为std::wstring

我们使用 C++11 中的范围 for 循环迭代路径中的每个目录组件(第 15 行)。如果范围 for 循环不可用,我们应该使用path中的beginend成员函数来迭代路径元素。在我的 Windows 系统上,该程序打印以下内容:

generic: E:/DATA/Packt/Boost/Draft/Book/Chapter07/examples
native:E:\DATA\Packt\Boost\Draft\Book\Chapter07\examples
quoted: "E:\DATA\Packt\Boost\Draft\Book\Chapter07\examples"
Components:
[E:][/][DATA][Packt] [Boost][Draft][Book][Chapter07][examples]

在我的 Ubuntu 系统上,这是我得到的输出:

generic: /home/amukher1/devel/c++/book/ch07
native: /home/amukher1/devel/c++/book/ch07
quoted: "/home/amukher1/devel/c++/book/ch07"
Components:
[/][home][amukher1] [devel][c++][book][ch07]

该程序以通用格式和本机格式打印其当前工作目录。您可以看到在 Ubuntu 上(以及通常在任何 Unix 系统上)两者之间没有区别。

在 Windows 上,路径的第一个组件是驱动器号,通常称为根名称。然后是/(根文件夹)和路径中的每个子目录。在 Unix 上,没有根名称(通常情况下),因此清单以/(根目录)开头,然后是路径中的每个子目录。

类型为pathcwd对象是可流式传输的(第 19 行),将其打印到标准输出会以本机格式带引号打印出来。

注意

使用 Boost Filesystem 编译和链接示例

Boost Filesystem 不是一个仅包含头文件的库。Boost Filesystem 共享库作为 Boost 操作系统包的一部分安装,或者根据第一章中描述的方式从源代码构建,介绍 Boost

在 Linux 上

如果您使用本机包管理器安装 Boost 库,则可以使用以下命令构建您的程序。请注意,库名称采用系统布局。

$ g++ <source>.c -o <executable> -lboost_filesystem -lboost_system

如果您按照第一章中所示的方式从源代码构建 Boost,并将其安装在/opt/boost下,您可以使用以下命令来编译和链接您的源代码:

$ g++ <source>.cpp -c -I/opt/boost/include
$ g++ <source>.o -o <executable> -L/opt/boost/lib -lboost_filesystem-mt -lboost_system-mt -Wl,-rpath,/opt/boost/lib

由于我们使用标记布局构建了库,因此我们链接到适当命名的 Boost Filesystem 和 Boost System 版本。-Wl,-rpath,/opt/boost/lib部分将 Boost 共享库的路径嵌入生成的可执行文件中,以便运行时链接器知道从哪里获取可执行文件运行所需的共享库。

在 Windows 上

在 Windows 上,使用 Visual Studio 2012 或更高版本,您可以启用自动链接,无需显式指定要链接的库。为此,您需要在项目属性对话框中编辑配置属性设置(在 IDE 中使用Alt + F7打开):

1. 在VC++目录下,将<boost-install-path>\include追加到包含目录属性。

2. 在VC++目录下,将<boost-install-path>\lib追加到库目录属性。

3. 在调试下,将环境属性设置为PATH=%PATH%;<boost-install-path>\lib

4. 在C/C++ > 预处理器下,定义以下预处理器符号:

BOOST_ALL_DYN_LINK

BOOST_AUTO_LINK_TAGGED(仅在使用标记布局构建时)

5. 通过从 Visual Studio IDE 中按下F7来构建,并通过从 IDE 中按下Ctrl + F5来运行程序。

构建路径

您可以使用path构造函数之一或以某种方式组合现有路径来构造boost::filesystem::path的实例。字符串和字符串字面值可以隐式转换为path对象。您可以构造相对路径和绝对路径,将相对路径转换为绝对路径,从路径中添加或删除元素,并“规范化”路径,如清单 9.2 所示:

清单 9.2a:构造空路径对象

 1 #define BOOST_FILESYSTEM_NO_DEPRECATED
 2 #include <boost/filesystem.hpp>
 3 #include <iostream>
 4 #include <cassert>
 5 namespace fs = boost::filesystem;
 6 
 7 int main() {
 8   fs::path p1; // empty path
 9   assert(p1.empty());  // does not fire
10   p1 = "/opt/boost";   // assign an absolute path
11   assert(!p1.empty());
12   p1.clear();
13   assert(p1.empty());
14 }

一个默认构造的路径对象表示一个空路径,就像前面的例子所示。你可以将一个路径字符串赋给一个空的path对象(第 10 行),它就不再是空的了(第 11 行)。在路径上调用clear成员函数(第 12 行)后,它再次变为空(第 13 行)。多年来,Boost 文件系统库的一些部分已经被弃用,并被更好的替代品所取代。我们定义宏BOOST_FILESYSTEM_NO_DEPRECATED(第 1 行)以确保这些弃用的成员函数和类型不可访问。

清单 9.2b:构造相对路径

15 void make_relative_paths() {
16   fs::path p2(".."); // relative path
17   p2 /= "..";
18   std::cout << "Relative path: " << p2.string() << '\n';
19
20   std::cout << "Absolute path: "
21      << fs::absolute(p2, "E:\\DATA\\photos").string() << '\n';
22   std::cout << "Absolute path wrt CWD: "
23             << fs::absolute(p2).string() << '\n';
24
25   std::cout << fs::canonical(p2).string() << '\n';
26 }
27

我们使用..(双点)构造了一个相对路径,这是一种在大多数文件系统上引用父目录的常见方式(第 16 行)。然后我们使用operator/=来将额外的..路径元素附加到相对路径(第 17 行)。然后我们以其原生格式打印相对路径(第 18 行),并使用这个相对路径创建绝对路径。

boost::filesystem::absolute函数根据相对路径构造绝对路径。你可以将一个绝对路径传递给它,以便将相对路径附加到构造一个新的绝对路径(第 21 行)。请注意,我们传递了一个 Windows 绝对路径,并确保转义了反斜杠。如果省略absolute的第二个参数,它将使用进程的当前工作目录作为基本路径从相对路径构造绝对路径(第 23 行)。

例如,文件路径/opt/boost/lib/../include可以被规范化为等效形式/opt/boost/include。函数boost::filesystem::canonical从给定路径生成一个规范化的绝对路径(第 25 行),但要求路径存在。否则,它会抛出一个需要处理的异常。它还会读取并遵循路径中的任何符号链接。前面的代码在我的 Windows 系统上打印了以下输出:

Relative path: ..\..
Absolute path: E:\DATA\photos\..\..
Absolute path wrt CWD: E:\DATA\Packt\Boost\Draft\Book\Chapter07\examples\..\..
Canonical: E:/DATA\Packt\Boost\Draft\Book

请注意,规范路径的输出中双点已经被折叠。

清单 9.2c:处理错误

28 void handle_canonical_errors() {
29   fs::path p3 = "E:\\DATA"; // absolute path
30   auto p4 = p3 / "boost" / "boost_1_56";  // append elements
31   std::cout << p4.string() << '\n';
32   std::cout.put('\n');
33
34   boost::system::error_code ec;
35   auto p5 = p4 / ".." / "boost_1_100";  // append elements
36   auto p6 = canonical(p5, ec);
37
38   if (ec.value() == 0) {
39     std::cout << "Normalized: " << p6.string() << '\n';
40   } else {
41     std::cout << "Error (file=" << p5.string()
42           << ") (code=" << ec.value() << "): "
43           << ec.message() << '\n';
44   }
45 }

这个例子说明了当canonical被传递一个不存在的路径时会出错。我们创建了一个路径对象p3,表示 Windows 上的绝对路径E:\DATA(第 29 行)。然后我们通过使用operator/path对象(第 30 行)连续添加路径元素(boostboost_1_56)来创建第二个路径对象p4。这构造了一个等同于E:\DATA\boost\boost_1_56的路径。

接下来,我们将相对路径../boost_1_100附加到p4(第 35 行),这构造了一个等同于E:\DATA\boost\boost_1_56\..\boost_1_100的路径。这个路径在我的系统上不存在,所以当我在这个路径上调用canonical时,它会出错。请注意,我们将boost::system::error_code类型的对象作为canonical的第二个参数传递,以捕获任何错误。我们使用error_codevalue成员函数(第 38 行)来检查返回的非零错误代码。如果发生错误,我们还可以使用message成员函数(第 43 行)检索系统定义的描述性错误消息。或者,我们可以调用canonical的另一个重载,它不接受error_code引用作为参数,而是在路径不存在时抛出异常。抛出异常和不抛出异常的重载是在文件系统库和其他来自 Boost 的系统编程库中常见的模式。

将路径分解为组件

在前一节中,我们看到了如何通过调用parent_path成员函数来获取路径的父目录。实际上,在boost::filesystem::path中有一整套成员函数可以提取路径中的组件。让我们首先看一下路径及其组件。

我们将首先了解 Boost 文件系统术语中关于路径组件的概念,使用来自 UNIX 系统的以下路径:

/opt/boost/include/boost/filesystem/path.hpp

前导/称为根目录。最后一个组件path.hpp称为文件名,即使路径表示的是目录而不是常规文件。剥离了文件名的路径(/opt/boost/include/boost/filesystem)称为父路径。在前导斜杠之后的部分(opt/boost/include/boost/filesystem/path.hpp)称为相对路径

在前面的示例中,.hpp扩展名(包括句点或点),path是文件名的主干。对于具有多个嵌入点的文件名(例如,libboost_filesystem-mt.so.1.56.0),扩展名被认为从最后(最右边)的点开始。

现在考虑以下 Windows 路径:

E:\DATA\boost\include\boost\filesystem\path.hpp

组件E:称为根名称。在E:后面的前导反斜杠称为根目录。根名称与根目录(E:\)的连接称为根路径。以下是一个打印路径的不同组件的简短函数,使用boost::filesystem::path的成员函数:

清单 9.3:将路径拆分为组件

 1 #include <boost/filesystem.hpp>
 2 #include <iostream>
 3 #include <cassert>
 4 namespace fs = boost::filesystem;
 5
 6 void printPathParts(const fs::path& p1)
 7 {
 8 std::cout << "For path: " << p1.string() << '\n';
 9
10   if (p1.is_relative()) {
11     std::cout << "\tPath is relative\n";
12   } else {
13     assert(p1.is_absolute());
14     std::cout << "\tPath is absolute\n";
15   }
16
17   if (p1.has_root_name())
18     std::cout << "Root name: "
19               << p1.root_name().string() << '\n';
20
21   if (p1.has_root_directory())
22     std::cout << "Root directory: "
23               << p1.root_directory().string() << '\n';
24
25   if (p1.has_root_path())
26     std::cout << "Root path: "
27               << p1.root_path().string() << '\n';
28
29   if (p1.has_parent_path())
30     std::cout << "Parent path: "
31               << p1.parent_path().string() << '\n';
32
33   if (p1.has_relative_path())
34     std::cout << "Relative path: "
35               << p1.relative_path().string() << '\n';
36
37   if (p1.has_filename())
38     std::cout << "File name: "
39               << p1.filename().string() << '\n';
40
41   if (p1.has_extension())
42     std::cout << "Extension: "
43               << p1.extension().string() << '\n';
44
45   if (p1.has_stem())
46     std::cout << "Stem: " << p1.stem().string() << '\n';
47
48   std::cout << '\n';
49 }
50
51 int main()
52 {
53   printPathParts ("");                    // no components
54   printPathParts ("E:\\DATA\\books.txt"); // all components
55   printPathParts ("/root/favs.txt");      // no root name
56   printPathParts ("\\DATA\\books.txt");   // Windows, relative
57   printPathParts ("boost");              // no rootdir, no extn
58   printPathParts (".boost");              // no stem, only extn
59   printPathParts ("..");                  // no extension
60   printPathParts (".");                   // no extension
61   printPathParts ("/opt/boost/");         // file name == .
62 }

在前面的示例中,函数printPathParts(第 6 行)打印路径的尽可能多的组件。要访问路径组件,它使用path的相应成员函数。要检查组件是否可用,它使用pathhas_成员函数之一。它还使用pathis_relativeis_absolute成员函数(第 10 行,第 13 行)检查路径是相对路径还是绝对路径。

我们使用不同的相对路径和绝对路径调用printPathParts。结果可能因操作系统而异。例如,在 Windows 上,对has_root_name(第 17 行)的调用对除了 Windows 路径E:\DATA\books.txt(第 54 行)之外的所有路径返回false,这被认为是绝对路径。对此路径调用root_name返回E:。然而,在 UNIX 上,反斜杠不被识别为分隔符,被认为是路径组件的一部分,因此E:\DATA\books.txt将被解释为具有文件名E:\DATA\books.txt的相对路径,主干E:\DATA\books和扩展名.txt。这,再加上在 Windows 上正斜杠被识别为路径分隔符的事实,是绝对不要像我们在这里所做的那样在路径文字中使用反斜杠的一个很好的理由。

注意

为了最大的可移植性,在路径文字中始终使用正斜杠,或者使用重载的operator/operator/=生成路径。

我们还可以比较两个路径,看它们是否相等等效。可以使用重载的operator==来比较两个路径是否相等,只有当两个路径可以分解为相同的组件时才返回true。请注意,这意味着路径/opt/opt/不相等;在前者中,文件名组件是opt,而在后者中,它是.(点)。如果两个路径不相等,但仍然可以等效,如果它们表示相同的底层文件系统条目。例如,/opt/boost/opt/cmake/../boost/虽然不是相等路径,但它们是等效的。要计算等效性,我们可以使用boost::filesystem::equivalent函数,如果两个路径引用文件系统中的相同条目,则返回true

boost::filesystem::path p1("/opt/boost"), p2("/opt/cmake");
if (boost::filesystem::equivalent(p1, p2 / ".." / "boost") {
  std::cout << "The two paths are equivalent\n";
}

boost::filesystem::canonical一样,equivalent函数实际上也检查路径的存在,并且如果任一路径不存在则抛出异常。还有一个不会抛出异常而是设置boost::system::error_code输出参数的重载。

path对象可以被视为路径元素的序列容器,这些元素可以通过path公开的迭代器接口进行迭代。这允许将几个标准算法轻松应用于path对象。要遍历每个路径元素,我们可以使用以下代码片段:

boost::filesystem::path p1("/opt/boost/include/boost/thread.hpp");
for (const auto& pathElem: p1) {
  std::cout <<pathElem.string() <<"  ";
}

这将打印由一对空格分隔的组件:

/ optboost include boost thread.hpp

boost::filesystem::pathbeginend成员函数返回类型为boost::filesystem::path::iterator的随机访问迭代器,您可以以有趣的方式与标准库算法一起使用。例如,要找到路径中的组件数,您可以使用:

size_t count = std::distance(p1.begin(), p1.end());

现在,考虑两个路径:/opt/boost/include/boost/filesystem/path.hpp/opt/boost/include/boost/thread/detail/thread.hpp。我们现在将编写一个函数,计算这两个路径所在的公共子目录:

第 9.4 节:查找公共前缀路径

 1 #include <boost/filesystem.hpp>
 2 #include <iostream>
 3 namespace fs = boost::filesystem;
 4
 5 fs::path commonPrefix(const fs::path& first,
 6                       const fs::path& second) {
 7   auto prefix =
 8     [](const fs::path& p1, const fs::path& p2) {
 9       auto result =
10         std::mismatch(p1.begin(), p1.end(), p2.begin());
11       fs::path ret;
12       std::for_each(p2.begin(), result.second,
13               &ret {
14               ret /= p;
15               });
16       return ret;
17     };
18
19   size_t n1 = std::distance(first.begin(), first.end());
20   size_t n2 = std::distance(second.begin(), second.end());
21 
22   return (n1 < n2) ? prefix(first, second)
23                    : prefix(second, first);
24 }

在这两个路径上调用commonPrefix函数会正确返回/opt/boost/include/boost。为了使该函数正确工作,我们应该传递不包含...组件的路径,一个更完整的实现可以处理这个问题。为了计算前缀,我们首先使用 lambda 表达式定义了一个名为prefix的嵌套函数(第 7-17 行),它执行实际的计算。我们计算了两个路径的元素计数(第 19、20 行),并将较短的路径作为第一个参数,较长的路径作为第二个参数传递给prefix函数(第 22-23 行)。在prefix函数中,我们使用std::mismatch算法在两个路径上计算它们不匹配的第一个组件(第 10 行)。然后我们构造公共前缀作为直到第一个不匹配的路径,并返回它(第 12-15 行)。

遍历目录

Boost Filesystem 提供了两个迭代器类,directory_iteratorrecursive_directory_iterator,使得遍历目录变得相当简单。两者都符合输入迭代器概念,并提供了用于向前遍历的operator++。在这里的第一个例子中,我们看到了directory_iterator的使用:

第 9.5 节:迭代目录

 1 #include <boost/filesystem.hpp>
 2 #include <iostream>
 3 #include <algorithm>
 4 namespace fs = boost::filesystem;
 5
 6 void traverse(const fs::path& dirpath) {
 7   if (!exists(dirpath) || !is_directory(dirpath)) {
 8     return;
 9   }
10
11   fs::directory_iterator dirit(dirpath), end;
12
13   std::for_each(dirit, end, [](const fs::directory_entry& entry) {
14           std::cout <<entry.path().string() << '\n';
15         });
16 }
17
18 int main(int argc, char *argv[1]) {
19   if (argc > 1) {
20     traverse(argv[1]);
21   }
22 }

traverse函数接受一个类型为boost::filesystem::path的参数dirpath,表示要遍历的目录。使用命名空间级别的函数existsis_directory(第 7 行),函数检查dirpath是否实际存在并且是一个目录,然后再继续。

为了执行迭代,我们为路径创建了一个boost::filesystem::directory_iterator的实例dirit,并创建了一个名为end的第二个默认构造的directory_iterator实例(第 11 行)。默认构造的directory_iterator充当了序列结束标记。对类型为directory_iterator的有效迭代器进行解引用会返回一个类型为boost::filesystem::directory_entry的对象。由迭代器范围diritend)表示的序列是目录中的条目列表。为了遍历它们,我们使用熟悉的std::for_each标准算法。我们使用 lambda 来定义对每个条目执行的操作,即简单地将其打印到标准输出(第 13-14 行)。

虽然我们可以围绕boost::directory_iterator编写递归逻辑来递归地遍历目录树,但boost::recursive_directory_iterator提供了一个更简单的替代方法。我们可以在第 9.5 节中用boost::recursive_directory_iterator替换boost::directory_iterator,它仍然可以工作,对目录树进行深度优先遍历。但是recursive_directory_iterator接口提供了额外的功能,比如跳过特定目录的下降和跟踪下降的深度。手写循环更好地利用了这些功能,如下例所示:

第 9.6 节:递归迭代目录

 1 void traverseRecursive(const fs::path& path)
 2 {
 3   if (!exists(path) || !is_directory(path)) {
 4     return;
 5   }
 6
 7   try {
 8     fs::recursive_directory_iterator it(path), end;
 9
10     while (it != end) {
11       printFileProperties(*it, it.level());
12
13       if (!is_symlink(it->path())
14           && is_directory(it->path())
15           && it->path().filename() == "foo") {
16           it.no_push();
17       }
18       boost::system::error_code ec;
19       it.increment(ec);
21       if (ec) {
22         std::cerr << "Skipping entry: "
23                   << ec.message() << '\n';
24       }
25     }
26   } catch (std::exception& e) {
27     std::cout << "Exception caught: " << e.what() << '\n';
28   }
29 }

我们创建了一个recursive_directory_iterator并用一个路径初始化它(第 8 行),就像我们在第 9.5 节中为directory_iterator做的那样。如果路径不存在或程序无法读取,recursive_directory_iterator构造函数可能会抛出异常。为了捕获这种异常,我们将代码放在try-catch块中。

我们使用 while 循环来遍历条目(第 10 行),并通过调用increment成员函数(第 19 行)来推进迭代器。当increment成员函数遇到目录时,它会尝试按深度优先顺序进入该目录。这有时可能会由于系统问题而失败,比如当程序没有足够的权限查看目录时。在这种情况下,我们希望继续到下一个可用的条目,而不是中止迭代。因此,我们不在迭代器上使用operator++,因为当它遇到错误时会抛出异常,处理这种情况会使代码变得更加复杂。increment函数接受一个boost::system::error_code参数,在出现错误时设置error_code并推进迭代器到下一个条目。在这种情况下,我们可以使用error_codemessage成员函数获取与错误相关的系统定义的错误消息。

注意

boost::filesystem::recursive_directory_iterator 的行为

在 Boost 版本 1.56 之前,当operator++increment成员函数遇到错误时,它们只会抛出异常或设置error_code,而不会推进迭代器。这使得编写一个正确的循环以跳过错误变得更加复杂。从 Boost 1.56 开始,这些函数还会将迭代器推进到下一个条目,使循环代码变得简单得多。

我们通过调用一个虚构的函数printFileProperties(第 11 行)来处理每个条目,该函数接受两个参数——解引用recursive_directory_iterator实例的结果,以及通过调用迭代器的level成员函数获得的遍历深度。level函数对于一级目录返回零,并且对于每个额外的下降级别,其返回值递增 1。printFileProperties函数可以利用这一点来缩进子目录中的条目,例如。我们将在下一节中实现printFileProperties函数。

为了给这个例子增加维度,我们决定不进入名为foo的目录。为此,我们检查名为foo的目录(第 13-15 行),并在recursive_directory_iterator上调用no_push成员函数以防止进入该目录(第 16 行)。同样,我们可以随时调用迭代器的pop成员函数来在目录树中上升一级,而不一定要在当前级别完成迭代。

在支持符号链接的系统上,如果recursive_directory_iterator遇到指向目录的符号链接,它不会跟随链接进入目录。如果我们想要覆盖这种行为,我们应该向recursive_directory_iterator构造函数传递boost::filesystem::symlink_option枚举类型的第二个参数。symlink_option枚举提供了none(或no_recurse)(默认值)和recurse两个值,表示应该跟随符号链接进入目录。

查询文件系统条目

Boost Filesystem 提供了一组函数来对文件和目录执行有用的操作。其中大多数是boost::filesystem命名空间中的函数。使用这些函数,我们可以检查文件是否存在、其大小(以字节为单位)、最后修改时间、文件类型、是否为空等等。我们使用这些函数来编写我们在前一节中使用的printFileProperties函数:

清单 9.7:查询文件系统条目

 1 #include <boost/filesystem.hpp>
 2 #include <iostream>
 3 #include <boost/date_time.hpp>
 4 namespace fs = boost::filesystem;
 5 namespace pxtm = boost::posix_time;
 6
 7 void printFileProperties(const fs::directory_entry& entry,
 8                          int indent = 0) {
 9   const fs::path& path= entry.path();
10   fs::file_status stat = entry.symlink_status();
11   std::cout << std::string(2*indent, '');
12
13   try {
14     if (is_symlink(path)) {
15       auto origin = read_symlink(path);
16       std::cout <<" L " << " -  - "
17                 << path.filename().string() << " -> "
18                 << origin.string();
19     } else if (is_regular_file(path)) {
20       std::cout << " F " << " "
21          << file_size(path) << " " << " "
22          << pxtm::from_time_t(last_write_time(path))
23          << " " << path.filename().string();
24     } else if (is_directory(path)) {
25       std::cout << " D " << " – " << " "
26 << pxtm::from_time_t(last_write_time(path))
27 << " " << path.filename().string();
28     } else {
29       switch (stat.type()) {
30       case fs::character_file:
31         std::cout << " C ";
32         break;
33       case fs::block_file:
34         std::cout << " B ";
35         break;
36       case fs::fifo_file:
37         std::cout << " P ";
38         break;
39       case fs::socket_file:
40         std::cout << " S ";
41         break;
42       default:
43         std::cout << " - ";
44         break;
45       }
46       std::cout << pxtm::from_time_t(last_write_time(path))
47                 << " ";
48       std::cout << path.filename().string();
49     }
50     std::cout << '\n';
51   } catch (std::exception& e) {
52     std::cerr << "Exception caught: " <<e.what() << '\n';
53   }
54 }

printFileProperties用于打印给定文件的简短摘要,包括类型、大小、最后修改时间、名称,以及对于符号链接,目标文件。这个函数的第一个参数是directory_entry类型,是对directory_iteratorrecursive_directory_iterator的解引用的结果。我们通过调用directory_entrypath成员函数(第 9 行)获取到directory_entry对象引用的文件的路径。我们通过调用directory_entrysymlink_status成员函数(第 10 行)获取到file_status对象的引用。file_status对象包含有关文件系统条目的其他详细信息,我们在示例中使用它来打印特殊文件的状态。symlink_status函数作用于所有类型的文件,而不仅仅是符号链接,但它返回的是符号链接本身的状态,而不是跟随它到目标的状态。如果你需要每次查询符号链接时都需要目标的状态,使用status成员函数而不是symlink_statusstatussymlink_status成员函数比同名的全局函数更快,因为它们会缓存文件状态,而不是在每次调用时查询文件系统。

在打印适合类型的信息之前,我们确定每个条目的类型。为此,我们使用方便的函数is_symlinkis_regular_fileis_directory(第 14、19、24 行)。在像 Linux 这样的 POSIX 系统上,还有其他类型的文件,如块和字符设备、管道和 Unix 域套接字。为了识别这些文件,我们使用之前获得的file_status对象(第 10 行)。我们调用file_status对象的type成员函数来确定特殊文件的确切类型(第 29 行)。请注意,我们首先检查文件是否是符号链接,然后进行其他测试。这是因为is_regular_fileis_directory对于目标文件的类型也可能返回 true,基于目标文件的类型。

这个函数以以下格式打印每个条目:

file_type  sizetime  name -> target

文件类型由单个字母表示(D:目录,F:普通文件,L:符号链接,C:字符设备,B:块设备,P:管道,S:Unix 域套接字)。大小以字节为单位打印,最后修改时间以长整数形式打印,文件名打印时不包含完整路径。只有对于符号链接,名称后面会附加一个指向目标路径的箭头。当文件大小或最后写入时间不可用时,缺少字段会显示为连字符(-)。对于每个下降级别,条目都会缩进两个额外的空格(第 11 行)。

这是在我的 Linux 系统上运行此函数的示例输出:

查询文件系统条目

你也可以在 Linux 的/dev目录上运行这个程序,看看设备文件是如何列出的。

调用read_symlink函数(第 15 行)来获取符号链接指向的目标文件。调用file_size函数(第 21 行)获取文件的大小(以字节为单位),调用last_write_time函数(第 22、26 和 46 行)获取文件的最后修改时间。last_write_time函数返回文件最后修改的Unix 时间

我们通过调用boost::posix_time::from_time_t函数将这个数字时间戳转换为可打印的日期时间字符串来打印这个时间戳的有意义的表示(参见第七章,“高阶和编译时编程”)。为了构建这个程序,你还必须链接 Boost DateTime 库,如下所示:

$ g++ listing8_7.cpp -o listing8_7 -std=c++11 -lboost_filesystem -lboost_date_time

文件系统中有几个这样的函数,用于查询文件系统中对象的不同类型的信息,例如查找文件的硬链接数。我们可以查询file_status对象(第 10 行)以获取文件权限。请注意,我们不需要在命名空间级别函数中加上命名空间;它们会根据参数的类型正确解析,使用基于参数类型的参数相关查找(Argument Dependent Lookup)。

对文件执行操作

除了查询文件系统条目的信息之外,我们还可以使用 Boost 文件系统库对文件执行操作,如创建目录和链接,复制文件和移动文件等。

创建目录

使用函数boost::filesystem::create_directory很容易创建目录。传递一个路径给它,如果该路径上不存在目录,则会在该路径上创建一个目录;如果目录已经存在,则不会执行任何操作。如果路径存在但不是一个目录,create_directory会抛出一个异常。还有一个非抛出版本,它接受一个boost::system::error_code引用,在错误时设置错误代码。这些函数如果创建了目录则返回true,如果没有则返回false

清单 9.8:创建目录

 1 #include <boost/filesystem.hpp> 
 2 #include <iostream> 
 3 #include <cassert>	 
 4 namespace fs = boost::filesystem; 
 5 
 6 int main() { 
 7   fs::path p1 = "notpresent/dirtest"; 
 8   boost::system::error_code ec; 
 9   if (!is_directory(p1.parent_path()) || exists(p1)) {
10     assert( !create_directory(p1, ec) );
11
12     if (is_directory(p1)) assert(!ec.value());
13     else assert(ec.value());
14   }
15
16   try {
17     if (create_directories(p1)) {
18       assert( !create_directory(p1) );
19     }
20   } catch (std::exception& e) {
21     std::cout << "Exception caught: " << e.what() << '\n';
22   }
23 }

在这个例子中,相对于当前目录在路径notpresent/dirtest上调用create_directory失败(第 10 行),如果当前目录中没有名为notpresent的目录,或者notpresent/dirtest已经存在。这是因为create_directory期望传递的路径的父目录存在,并且不会创建已经存在的路径。如果我们没有传递错误代码参数,这次对create_directory的调用将会抛出一个需要处理的异常。如果notpresent/dirtest已经存在并且是一个目录,那么create_directory会失败,但不会设置错误代码(第 12 行)。

函数boost::filesystem::create_directories创建所需的所有路径组件,类似于 Unix 系统上的mkdir -p。对它的调用(第 17 行)除非存在权限问题或路径已经存在,否则会成功。它创建目录,包括沿路径缺失的任何目录。对create_directorycreate_directories的调用是幂等的;如果目标目录存在,不会返回错误或抛出异常,但函数会返回false,因为没有创建新目录。

创建符号链接

符号链接,有时被称为软链接,是文件系统中的条目,类似于其他文件的别名。它们可以引用文件以及目录,并经常用于为文件和目录提供替代的简化名称和路径。符号链接在UNIX系统上已经存在了相当长的时间,并且自Windows 2000以来在Windows上以某种形式可用。我们可以使用函数boost::filesystem::create_symlink来创建符号链接。对于创建指向目录的符号链接,建议使用函数boost::filesystem::create_directory_symlink以获得更好的可移植性。

清单9.9:创建符号链接

 1 #include <boost/filesystem.hpp>
 2 namespace fs = boost::filesystem; 
 3 
 4 void makeSymLink(const fs::path& target, const fs::path& link) { 
 5   boost::system::error_code ec; 
 6  
 7   if (is_directory(target)) { 
 8     create_directory_symlink(target, link); 
 9   } else {
10     create_symlink(target, link);
11   }
12 }

这显示了一个名为makeSymLink的函数,它创建指向给定路径的符号链接。函数的第一个参数是链接必须别名的目标路径,第二个参数是链接本身的路径。这种参数顺序让人联想到UNIX的ln命令。如果目标是目录,此函数调用create_directory_symlink(第8行),而对于所有其他情况,它调用create_symlink(第10行)。请注意,目标路径在创建符号链接时不需要存在,在这种情况下将创建悬空的符号链接。调用这些函数的效果与在POSIX系统上运行ln -s target link命令相同。在Windows上,当target是目录时,通过运行mklink /D link target命令可以获得相同的效果,当target不是目录时,通过运行mklink link target命令可以获得相同的效果。如果create_directory_symlinkcreate_symlink抛出异常,函数makeSymLink将抛出异常。

复制文件

复制文件是Boost文件系统中的另一个常见任务。boost::filesystem::copy_file函数将常规文件从源复制到目标,并且如果目标处已存在该文件,则会失败。使用适当的覆盖,可以使其覆盖目标处的文件。boost::filesystem::copy_symlink接受源符号链接并在目标处创建第二个符号链接,它别名与源相同的文件。您不能将目录传递给任何一个函数作为目标。还有一个boost::copy_directory函数,似乎并不做其名称所示的事情。它创建目录并将源目录的属性复制到目标目录。因此,我们将推出我们自己的递归目录复制实用程序函数:

第9.10节:递归复制目录

 1 void copyDirectory(const fs::path& src, const fs::path& target) { 
 2   if (!is_directory(src) 
 3     || (exists(target) && !is_directory(target)) 
 4     || !is_directory(absolute(target).parent_path()) 
 5     || commonPrefix(src, target) == src) { 
 6     throw std::runtime_error("Preconditions not satisfied"); 
 7   } 
 8 
 9   boost::system::error_code ec;
10   fs::path effectiveTarget = target;
11   if (exists(target)) {
12     effectiveTarget /= src.filename();
13   }
14   create_directory(effectiveTarget);
15
16   fs::directory_iterator iter(src), end;
17   while (iter != end) {
18     auto status = iter->symlink_status();
19     auto currentTarget = effectiveTarget/
20                               iter->path().filename();
21
22     if (status.type() == fs::regular_file) {
23       copy_file(*iter, currentTarget,
24                     fs::copy_option::overwrite_if_exists);
25     } else if (status.type() == fs::symlink_file) {
26       copy_symlink(*iter, currentTarget);
27     } else if (status.type() == fs::directory_file) {
28       copyDirectory(*iter, effectiveTarget);
29     } // else do nothing
30     ++iter;
31   }
32 }

第9.10节定义了copyDirectory函数,该函数递归地将源目录复制到目标目录。它执行基本验证,并在不满足必要的初始条件时抛出异常(第6行)。如果以下任何条件为真,则违反了必要的前提条件:

  1. 源路径不是目录(第2行)

  2. 目标路径存在,但不是目录(第3行)

  3. 目标路径的父目录不是目录(第4行)

  4. 目标路径是源路径的子目录(第5行)

为了检测违反4,我们重用了第9.4节中定义的commonPrefix函数。如果目标路径已经存在,则在其下创建与源目录同名的子目录以容纳复制的内容(第11-12行,14行)。否则,将创建目标目录并将内容复制到其中。

除此之外,我们使用directory_iterator而不是recursive_directory_iterator(第17行)来递归迭代源目录。我们使用copy_file来复制常规文件,传递copy_option::overwrite_if_exists选项以确保已存在的目标文件被覆盖(第23-24行)。我们使用copy_symlink来复制符号链接(第26行)。每次遇到子目录时,我们递归调用copyDirectory(第28行)。如果从copyDirectory调用的Boost文件系统函数抛出异常,它将终止复制。

移动和删除文件

您可以使用boost::filesystem::rename函数移动或重命名文件和目录,该函数以旧路径和新路径作为参数。两个参数的重载如果失败会抛出异常,而三个参数的重载则设置错误代码:

void rename(const path& old_path, const path& new_path);
void rename(const path& old_path, const path& new_path,
            error_code& ec);

如果new_path不存在,且其父目录存在,则会创建它;否则,重命名调用失败。如果old_path不是目录,则new_path如果存在,也不能是目录。如果old_path是目录,则new_path如果存在,必须是一个空目录,否则函数失败。当一个目录被移动到另一个空目录时,源目录的内容被复制到目标空目录内,然后源目录被删除。重命名符号链接会影响链接本身,而不是它们所指向的文件。

您可以通过调用boost::filesystem::remove并传递文件系统条目的路径来删除文件和空目录。要递归删除一个非空目录,必须调用boost::filesystem::remove_all

bool remove(const path& p);
bool remove(const path& p, error_code& ec);
uintmax_t remove_all(const path& p);
uintmax_t remove_all(const path& p, error_code& ec);

如果路径指定的文件不存在,remove函数返回false。这会删除符号链接而不影响它们所指向的文件。remove_all函数返回它删除的条目总数。在错误情况下,removeremove_all的单参数重载会抛出异常,而双参数重载会设置传递给它的错误代码引用,而不会抛出异常。

路径感知的fstreams

此外,头文件boost/filesystem/fstream.hpp提供了与boost::filesystem::path对象一起工作的标准文件流类的版本。当您编写使用boost::filesystem并且需要读取和写入文件的代码时,这些非常方便。

注意

最近,基于Boost文件系统库的C++技术规范已被ISO批准。这为其包含在未来的C++标准库修订版中铺平了道路。

使用Boost IOStreams进行可扩展I/O

标准库IOStreams设施旨在为各种设备上的各种操作提供一个框架,但它并没有被证明是最容易扩展的框架。Boost IOStreams库通过一个更简单的接口来补充这个框架,以便将I/O功能扩展到新设备,并提供一些非常有用的类来满足在读取和写入数据时的常见需求。

Boost IOStreams的架构

标准库IOStreams框架提供了两个基本抽象,流缓冲区。流为应用程序提供了一个统一的接口,用于在底层设备上读取或写入一系列字符。流缓冲区为实际设备提供了一个更低级别的抽象,这些设备被流所利用和进一步抽象。

Boost IOStreams框架提供了boost::iostreams::streamboost::iostreams::stream_buffer模板,这些是流和流缓冲区抽象的通用实现。这两个模板根据一组进一步的概念实现其功能,这些概念描述如下:

  • 是一个抽象,用于从中读取一系列字符的对象。

  • 是一个抽象,用于向其写入一系列字符。

  • 设备是源、汇,或两者兼有。

  • 输入过滤器修改从源读取的一系列字符,而输出过滤器修改写入到汇之前的一系列字符。

  • 过滤器是输入过滤器或输出过滤器。可以编写一个既可以用作输入过滤器又可以用作输出过滤器的过滤器;这被称为双用过滤器

要在设备上执行I/O,我们将零个或多个过滤器序列与设备关联到boost::iostreams::stream的实例或boost::iostreams::stream_buffer的实例。一系列过滤器称为,一系列过滤器以设备结尾称为完整链

以下图表是输入和输出操作的统一视图,说明了流对象和底层设备之间的I/O路径:

Boost IOStreams的架构

Boost IOStreams 架构

输入从设备中读取,并通过一个可选的过滤器堆栈传递到流缓冲区,从那里可以通过流访问。输出从流通过流缓冲区写入,并通过一堆过滤器传递到设备。如果有的话,过滤器会对从设备读取的数据进行操作,以向流的读取者呈现一个转换后的序列。它们还会对要写入设备的数据进行操作,并在写入之前进行转换。上面的图表用于可视化这些交互,但略有不准确;在代码中,过滤器不能同时作为输入过滤器和输出过滤器。

Boost IOStreams 库配备了几个内置的设备和过滤器类,并且也很容易创建我们自己的设备和过滤器。在接下来的章节中,我们将通过代码示例来说明 Boost IOStreams 库的不同组件的使用。

使用设备

设备提供了一个接口,用于向底层介质读写字符。它抽象了像磁盘、内存或网络连接这样的真实介质。在本书中,我们将专注于使用作为 Boost IOStreams 库一部分提供的许多现成的设备。编写我们自己的设备类的方法超出了本书的范围,但一旦您熟悉了本章内容,您应该很容易从在线文档中学习它们。

文件 I/O 的设备

Boost 定义了许多用于在文件上执行 I/O 的设备,我们首先看的是一个抽象平台特定文件描述符的设备。每个平台都使用一些本机句柄来打开文件,与标准 C++使用fstream表示打开文件的方式不同。例如,这些可以是 POSIX 系统上的整数文件描述符和 Windows 上的 HANDLE。Boost IOStreams 库提供了boost::iostreams::file_descriptor_sourceboost::iostreams::file_descriptor_sinkboost::iostreams::file_descriptor设备,它们将 POSIX 文件描述符和 Windows 文件句柄转换为输入和输出的设备。在下面的示例中,我们使用file_descriptor_source对象使用流接口从 POSIX 系统上的文件中读取连续的行。如果您想要使用流接口来处理使用文件描述符进行文件打开的 I/O,这将非常有用。

清单 9.11:使用 file_descriptor 设备

 1 #include <boost/iostreams/stream.hpp>
 2 #include <boost/iostreams/device/file_descriptor.hpp>
 3 #include <iostream>
 4 #include <string>
 5 #include <cassert>
 6 #include <sys/types.h>
 7 #include <fcntl.h>
 8 namespace io = boost::iostreams;
 9
10 int main(int argc, char *argv[]) {
11   if (argc < 2) {
12     return 0;
13   }
14
15   int fdr = open(argv[1], O_RDONLY);
16   if (fdr >= 0) {
17     io::file_descriptor_source fdDevice(fdr,
18                    io::file_descriptor_flags::close_handle);
19     io::stream<io::file_descriptor_source> in(fdDevice);
20     assert(fdDevice.is_open());
21
22     std::string line;
23     while (std::getline(in, line))
24     std::cout << line << '\n';
25   }
26 }

使用这个程序,我们打开命令行中命名的第一个文件,并从中读取连续的行。我们首先使用 Unix 系统调用open(第 15 行)打开文件,为此我们包括 Unix 头文件sys/types.hfcntl.h(第 6-7 行)。如果文件成功打开(由open返回的文件描述符的正值表示),那么我们创建一个file_descriptor_source的实例,将打开的文件描述符和一个close_handle标志传递给它,以指示在设备被销毁时应适当关闭描述符(第 17-18 行)。

如果我们不希望设备管理描述符的生命周期,那么我们必须传递never_close_handle标志。然后我们创建一个boost::iostreams::stream<file_descriptor_source>的实例(第 19 行),将设备对象传递给它,并使用std::getline函数从中读取连续的行,就像我们使用任何std::istream实例一样(第 23 行)。请注意,我们使用is_open成员函数断言设备已经打开以供读取(第 19 行)。这段代码旨在在 Unix 和类 Unix 系统上编译。在 Windows 上,Visual Studio C 运行时库提供了兼容的接口,因此您也可以通过包括一个额外的头文件io.h来在 Windows 上编译和运行它。

注意

Boost IOStreams 库中的类型和函数分为一组相当独立的头文件,并没有一个单一的头文件包含所有符号。设备头文件位于boost/iostreams/device目录下,过滤器头文件位于boost/iostreams/filter目录下。其余接口位于boost/iostreams目录下。

要构建此程序,我们必须将其与libboost_iostreams库链接。我在我的 Ubuntu 系统上使用以下命令行,使用本机包管理器在默认路径下安装的 Boost 库来构建程序:

$ g++ listing8_11.cpp -o listing8_11 -std=c++11 -lboost_iostreams

我们可能还希望构建我们的程序,以使用我们在第一章中从源代码构建的 Boost 库,介绍 Boost。为此,我在我的 Ubuntu 系统上使用以下命令行来构建此程序,指定包含路径和库路径,以及要链接的libboost_iostreams-mt库:

$ g++listing8_11.cpp -o listing8_11-I /opt/boost/include -std=c++11 -L /opt/boost/lib -lboost_iostreams-mt -Wl,-rpath,/opt/boost/lib

要通过文件描述符写入文件,我们需要使用file_descriptor_sink对象。我们还可以使用file_descriptor对象来同时读取和写入同一设备。还有其他允许写入文件的设备——file_sourcefile_sinkfile设备允许您读取和写入命名文件。mapped_file_sourcemapped_file_sinkmapped_file设备允许您通过内存映射读取和写入文件。

用于读写内存的设备

标准库std::stringstream类系列通常用于将格式化数据读写到内存。如果要从任何给定的连续内存区域(如数组或字节缓冲区)中读取和写入,Boost IOStreams 库中的array设备系列(array_sourcearray_sinkarray)非常方便:

清单 9.12:使用数组设备

 1 #include <boost/iostreams/device/array.hpp>
 2 #include <boost/iostreams/stream.hpp>
 3 #include <boost/iostreams/copy.hpp>
 4 #include <iostream>
 5 #include <vector>
 6 namespace io = boost::iostreams;
 7
 8 int main() {
 9   char out_array[256];
10   io::array_sink sink(out_array, out_array + sizeof(out_array));
11   io::stream<io::array_sink> out(sink);
12   out << "Size of out_array is " << sizeof(out_array)
13       << '\n' << std::ends << std::flush;
14
15   std::vector<char> vchars(out_array,
16                           out_array + strlen(out_array));
17   io::array_source src(vchars.data(),vchars.size());
18   io::stream<io::array_source> in(src);
19
20   io::copy(in, std::cout);
21 }

此示例遵循与清单 9.11 相同的模式,但我们使用了两个设备,一个汇和一个源,而不是一个。在每种情况下,我们都执行以下操作:

  • 我们创建一个适当初始化的设备

  • 我们创建一个流对象并将设备与其关联

  • 在流上执行输入或输出

首先,我们定义了一个array_sink设备,用于写入连续的内存区域。内存区域作为一对指针传递给设备构造函数,指向一个char数组的第一个元素和最后一个元素的下一个位置(第 10 行)。我们将这个设备与流对象out关联(第 11 行),然后使用插入操作符(<<)向流中写入一些内容。请注意,这些内容可以是任何可流化的类型,不仅仅是文本。使用操纵器std::ends(第 13 行),我们确保数组在文本之后有一个终止空字符。使用std::flush操纵器,我们确保这些内容不会保留在设备缓冲区中,而是在调用out_array(第 16 行)上的strlen之前找到它们的方式到汇流设备的后备数组out_array中。

接下来,我们创建一个名为vcharschar向量,用out_array的内容进行初始化(第 15-16 行)。然后,我们定义一个由这个vector支持的array_source设备,向构造函数传递一个指向vchars第一个元素的迭代器和vchars中的字符数(第 17 行)。最后,我们构造一个与该设备关联的输入流(第 18 行),然后使用boost::iostreams::copy函数模板将字符从输入流复制到标准输出(第 20 行)。运行上述代码将通过array_sink设备向out_array写入以下行:

The size of out_array is 256

然后它读取短语中的每个单词,并将其打印到新行的标准输出中。

除了array设备,back_insert_device设备还可以用于适配几个标准容器作为 sink。back_insert_devicearray_sink之间的区别在于,array_sink需要一个固定的内存缓冲区来操作,而back_insert_device可以使用任何具有insert成员函数的标准容器作为其后备存储器。这允许back_insert_device的底层内存区域根据输入的大小而增长。我们使用back_insert_device替换array_sink重写列表 9.12:

列表 9.13:使用 back_insert_device

 1 #include <boost/iostreams/device/array.hpp>
 2 #include <boost/iostreams/device/back_inserter.hpp>
 3 #include <boost/iostreams/stream.hpp>
 4 #include <boost/iostreams/copy.hpp>
 5 #include <iostream>
 6 #include <vector>
 7 namespace io = boost::iostreams;
 8
 9 int main() {
10   typedef std::vector<char> charvec;
11   charvec output;
12   io::back_insert_device<charvec> sink(output);
13   io::stream<io::back_insert_device<charvec>> out(sink);
14   out << "Size of outputis "<< output.size() << std::flush;
15
16   std::vector<char> vchars(output.begin(),
17                            output.begin() + output.size());
18   io::array_source src(vchars.data(),vchars.size());
19   io::stream<io::array_source> in(src);
20
21   io::copy(in, std::cout);
22 }

在这里,我们写入out_vec,它是一个vector<char>(第 11 行),并且使用back_insert_device sink(第 12 行)进行写入。我们将out_vec的大小写入流中,但这可能不会打印在那时已经写入设备的字符总数,因为设备可能会在将输出刷新到向量之前对其进行缓冲。由于我们打算将这些数据复制到另一个向量以供读取(第 16-17 行),我们使用std::flush操纵器确保所有数据都写入out_vec(第 14 行)。

还有其他有趣的设备,比如tee_device适配器,允许将字符序列写入两个不同的设备,类似于 Unix 的tee命令。现在我们将看一下如何编写自己的设备。

使用过滤器

过滤器作用于写入到汇或从源读取的字符流,可以在写入和读取之前对其进行转换,或者仅仅观察流的一些属性。转换可以做各种事情,比如标记关键字,翻译文本,执行正则表达式替换,以及执行压缩或解压缩。观察者过滤器可以计算行数和单词数,或者计算消息摘要等。

常规流和流缓冲区不支持过滤器,我们需要使用过滤流过滤流缓冲区来使用过滤器。过滤流和流缓冲区维护一个过滤器堆栈,源或汇在顶部,最外层的过滤器在底部,称为的数据结构。

现在我们将看一下 Boost IOStreams 库作为一部分提供的几个实用过滤器。编写自己的过滤器超出了本书的范围,但优秀的在线文档详细介绍了这个主题。

基本过滤器

在使用过滤器的第一个示例中,我们使用boost::iostreams::counter过滤器来计算从文件中读取的文本的字符和行数:

列表 9.14:使用计数器过滤器

 1 #include <boost/iostreams/device/file.hpp>
 2 #include <boost/iostreams/filtering_stream.hpp>
 3 #include <boost/iostreams/filter/counter.hpp>
 4 #include <boost/iostreams/copy.hpp>
 5 #include <iostream>
 6 #include <vector>
 7 namespace io = boost::iostreams;
 8
 9 int main(int argc, char *argv[]) {
10   if (argc <= 1) {
11     return 0;
12   }
13
14   io::file_source infile(argv[1]);
15   io::counter counter;
16   io::filtering_istream fis;
17   fis.push(counter);
18   assert(!fis.is_complete());
19   fis.push(infile);
20   assert(fis.is_complete());
21
22   io::copy(fis, std::cout);
23
24   io::counter *ctr = fis.component<io::counter>(0);
25   std::cout << "Chars: " << ctr->characters() << '\n'
26             << "Lines: " << ctr->lines() << '\n';
27 }

我们创建一个boost::iostream::file_source设备来读取命令行中指定的文件的内容(第 14 行)。我们创建一个counter过滤器来计算读取的行数和字符数(第 15 行)。我们创建一个filtering_istream对象(第 16 行),并推送过滤器(第 17 行),然后是设备(第 19 行)。在设备被推送之前,我们可以断言过滤流是不完整的(第 18 行),一旦设备被推送,它就是完整的(第 20 行)。我们将从过滤输入流中读取的内容复制到标准输出(第 22 行),然后访问字符和行数。

要访问计数,我们需要引用过滤流内部的链中的counter过滤器对象。为了做到这一点,我们调用filtering_istreamcomponent成员模板函数,传入我们想要的过滤器的索引和过滤器的类型。这将返回一个指向counter过滤器对象的指针(第 24 行),我们通过调用适当的成员函数(第 25-26 行)检索读取的字符和行数。

在下一个示例中,我们使用boost::iostreams::grep_filter来过滤掉空行。与不修改输入流的计数器过滤器不同,这个过滤器通过删除空行来转换输出流。

列表 9.15:使用 grep_filter

 1 #include <boost/iostreams/device/file.hpp>
 2 #include <boost/iostreams/filtering_stream.hpp>
 3 #include <boost/iostreams/filter/grep.hpp>
 4 #include <boost/iostreams/copy.hpp>
 5 #include <boost/regex.hpp>
 6 #include <iostream>
 7 namespace io = boost::iostreams;
 8
 9 int main(int argc, char *argv[]) {
10   if (argc <= 1) {
11     return 0;
12   }
13
14   io::file_source infile(argv[1]);
15   io::filtering_istream fis;
16   io::grep_filter grep(boost::regex("^\\s*$"),
17       boost::regex_constants::match_default, io::grep::invert);
18   fis.push(grep);
19   fis.push(infile);
20
21   io::copy(fis, std::cout);
22 }

这个例子与列表 9.14 相同,只是我们使用了不同的过滤器boost::iostreams::grep_filter来过滤空行。我们创建了grep_filter对象的一个实例,并向其构造函数传递了三个参数。第一个参数是匹配空行的正则表达式^\s*$(第 16 行)。请注意,反斜杠在代码中被转义了。第二个参数是常量match_default,表示我们使用 Perl 正则表达式语法(第 17 行)。第三个参数boost::iostreams::grep::invert告诉过滤器只允许匹配正则表达式的行被过滤掉(第 17 行)。默认行为是只过滤掉不匹配正则表达式的行。

要在 Unix 上构建此程序,您还必须链接到 Boost Regex 库:

$ g++ listing8_15.cpp -o listing8_15 -std=c++11 -lboost_iostreams-lboost_regex

在没有 Boost 本机包并且 Boost 安装在自定义位置的系统上,使用以下更详细的命令行:

$ g++ listing8_15.cpp -o listing8_15-I /opt/boost/include -std=c++11 -L /opt/boost/lib -lboost_iostreams-mt-lboost_regex-mt -Wl,-rpath,/opt/boost/lib

在 Windows 上,使用 Visual Studio 并启用自动链接到 DLL,您不需要显式指定 Regex 或 IOStream DLL。

压缩和解压过滤器

Boost IOStreams 库配备了三种不同的数据压缩和解压过滤器,分别用于 gzip、zlib 和 bzip2 格式。gzip 和 zlib 格式实现了不同变种的 DEFLATE 算法进行压缩,而 bzip2 格式则使用更节省空间的 Burrows-Wheeler 算法。由于这些是外部库,如果我们使用这些压缩格式,它们必须被构建和链接到我们的可执行文件中。如果您已经按照第一章中概述的详细步骤构建了支持 zlib 和 bzip2 的 Boost 库,那么 zlib 和 bzip2 共享库应该已经与 Boost Iostreams 共享库一起构建了。

在下面的例子中,我们压缩了一个命令行中命名的文件,并将其写入磁盘。然后我们读取它,解压它,并将其写入标准输出。

列表 9.16:使用 gzip 压缩器和解压器

 1 #include <boost/iostreams/device/file.hpp>
 2 #include <boost/iostreams/filtering_stream.hpp>
 3 #include <boost/iostreams/stream.hpp>
 4 #include <boost/iostreams/filter/gzip.hpp>
 5 #include <boost/iostreams/copy.hpp>
 6 #include <iostream>
 7 namespace io = boost::iostreams;
 8
 9 int main(int argc, char *argv[]) {
10   if (argc <= 1) {
11     return 0;
12   }
13   // compress
14   io::file_source infile(argv[1]);
15   io::filtering_istream fis;
16   io::gzip_compressor gzip;
17   fis.push(gzip);
18   fis.push(infile);
19
20   io::file_sink outfile(argv[1] + std::string(".gz"));
21   io::stream<io::file_sink> os(outfile);
22   io::copy(fis, os);
23
24   // decompress
25   io::file_source infile2(argv[1] + std::string(".gz"));
26   fis.reset();
27   io::gzip_decompressor gunzip;
28   fis.push(gunzip);
29   fis.push(infile2);
30   io::copy(fis, std::cout);
31 }

前面的代码首先使用boost::iostreams::gzip_compressor过滤器(第 16 行)在读取文件时解压文件(第 17 行)。然后使用boost::iostreams::copy将这个内容写入一个带有.gz扩展名的文件中,该扩展名附加到原始文件名上(第 20-22 行)。对boost::iostreams::copy的调用还会刷新和关闭传递给它的输出和输入流。因此,在copy返回后立即从文件中读取是安全的。为了读取这个压缩文件,我们使用一个带有boost::iostreams::gzip_decompressorboost::iostreams::file_source设备(第 27-28 行),并将解压后的输出写入标准输出(第 30 行)。我们重用filtering_istream对象来读取原始文件,然后再次用于读取压缩文件。在过滤流上调用reset成员函数会关闭并删除与流相关的过滤器链和设备(第 26 行),因此我们可以关联一个新的过滤器链和设备(第 27-28 行)。

通过向压缩器或解压器过滤器的构造函数提供额外的参数,可以覆盖几个默认值,但基本结构不会改变。通过将头文件从gzip.hpp更改为bzip2.hpp(第 4 行),并在前面的代码中用bzip2_compressorbzip2_decompressor替换gzip_compressorgzip_decompressor,我们可以测试 bzip2 格式的代码;同样适用于 zlib 格式。理想情况下,扩展名应该适当更改(.bz2 用于 bzip2,.zlib 用于 zlib)。在大多数 Unix 系统上,值得测试生成的压缩文件,通过使用 gzip 和 bzip2 工具单独解压缩它们。对于 zlib 存档的命令行工具似乎很少,且标准化程度较低。在我的 Ubuntu 系统上,qpdf程序带有一个名为zlib-flate的原始 zlib 压缩/解压缩实用程序,可以压缩到 zlib 格式并从 zlib 格式解压缩。

构建此程序的步骤与构建清单 9.15 时的步骤相同。即使使用zlib_compressorbzip2_compressor过滤器,只要在链接期间使用选项-Wl,-rpath,/opt/boost/lib,链接器(以及稍后的运行时链接器在执行期间)将自动选择必要的共享库,路径/opt/boost/lib包含 zlib 和 bzip2 的共享库。

组合过滤器

过滤流可以在管道中对字符序列应用多个过滤器。通过在过滤流上使用push方法,我们可以形成以最外层过滤器开始的管道,按所需顺序插入过滤器,并以设备结束。

这意味着对于过滤输出流,您首先推送首先应用的过滤器,然后向前推送每个连续的过滤器,最后是接收器。例如,为了过滤掉一些行并在写入接收器之前进行压缩,推送的顺序将如下所示:

filtering_ostream fos;
fos.push(grep);
fos.push(gzip);
fos.push(sink);

对于过滤输入流,您需要推送过滤器,从最后应用的过滤器开始,然后逆向工作,推送每个前置过滤器,最后是源。例如,为了读取文件,解压缩它,然后执行行计数,推送的顺序将如下所示:

filtering_istream fis;
fis.push(counter);
fis.push(gunzip);
fis.push(source);

管道

原来一点点的操作符重载可以使这个过程更加具有表现力。我们可以使用管道操作符(operator|)以以下替代符号来编写前面的链:

filtering_ostream fos;
fos.push(grep | gzip | sink);

filtering_istream fis;
fis.push(counter | gunzip | source);

前面的片段显然更具表现力,代码行数更少。从左到右,过滤器按照您将它们推入流中的顺序串联在一起,最后是设备。并非所有过滤器都可以以这种方式组合,但来自 Boost IOStreams 库的许多现成的过滤器可以;更明确地说,过滤器必须符合可管道化概念才能以这种方式组合。以下是一个完整的示例程序,该程序读取文件中的文本,删除空行,然后使用 bzip2 进行压缩:

清单 9.17:使用管道过滤器

 1 #include <boost/iostreams/device/file.hpp>
 2 #include <boost/iostreams/filtering_stream.hpp>
 3 #include <boost/iostreams/stream.hpp>
 4 #include <boost/iostreams/filter/bzip2.hpp>
 5 #include <boost/iostreams/filter/grep.hpp>
 6 #include <boost/iostreams/copy.hpp>
 7 #include <boost/regex.hpp>
 8 #include <iostream>
 9 namespace io = boost::iostreams;
10
11 int main(int argc, char *argv[]) {
12   if (argc <= 1) { return 0; }
13
14   io::file_source infile(argv[1]);
15   io::bzip2_compressor bzip2;
16   io::grep_filter grep(boost::regex("^\\s*$"),
17         boost::regex_constants::match_default,
18         io::grep::invert);
19   io::filtering_istream fis;
20   fis.push(bzip2 | grep | infile);
21   io::file_sink outfile(argv[1] + std::string(".bz2"));
22   io::stream<io::file_sink> os(outfile);
23
24   io::copy(fis, os);
25 }

前面的示例将一个用于过滤空行的 grep 过滤器(第 16-18 行)和一个 bzip2 压缩器(第 15 行)与使用管道的文件源设备串联在一起(第 20 行)。代码的其余部分应该与清单 9.15 和 9.16 相似。

使用 tee 分支数据流

在使用具有多个过滤器的过滤器链时,有时捕获两个过滤器之间流动的数据是有用的,特别是用于调试。boost::iostreams::tee_filter是一个输出过滤器,类似于 Unix 的tee命令,它位于两个过滤器之间,并提取两个过滤器之间流动的数据流的副本。基本上,当您想要在处理的不同中间阶段捕获数据时,可以使用tee_filter

使用 tee 分支数据流

您还可以复用两个接收设备来创建一个tee 设备,这样将一些内容写入 tee 设备会将其写入底层设备。boost::iostream::tee_device类模板结合了两个接收器以创建这样的 tee 设备。通过嵌套 tee 设备或管道化 tee 过滤器,我们可以生成几个可以以不同方式处理的并行流。boost::iostreams::tee函数模板可以生成 tee 过滤器和 tee 流。它有两个重载——一个单参数重载,接收一个接收器并生成一个tee_filter,另一个双参数重载,接收两个接收器并返回一个tee_device。以下示例显示了如何使用非常少的代码将文件压缩为三种不同的压缩格式(gzip、zlib 和 bzip2):

清单 9.18:使用 tee 分支输出流

 1 #include <boost/iostreams/device/file.hpp>
 2 #include <boost/iostreams/filtering_stream.hpp>
 3 #include <boost/iostreams/stream.hpp>
 4 #include <boost/iostreams/filter/gzip.hpp>
 5 #include <boost/iostreams/filter/bzip2.hpp>
 6 #include <boost/iostreams/filter/zlib.hpp>
 7 #include <boost/iostreams/copy.hpp>
 8 #include <boost/iostreams/tee.hpp>
 9 namespace io = boost::iostreams;
10
11 int main(int argc, char *argv[]) {
12   if (argc <= 1) { return 0; }
13
14   io::file_source infile(argv[1]);  // input
15   io::stream<io::file_source> ins(infile);
16
17   io::gzip_compressor gzip;
18   io::file_sink gzfile(argv[1] + std::string(".gz"));
19   io::filtering_ostream gzout;     // gz output
20   gzout.push(gzip | gzfile);
21   auto gztee = tee(gzout);
22
23   io::bzip2_compressor bzip2;
24   io::file_sink bz2file(argv[1] + std::string(".bz2"));
25   io::filtering_ostream bz2out;     // bz2 output
26   bz2out.push(bzip2 | bz2file);
27   auto bz2tee = tee(bz2out);
28
29   io::zlib_compressor zlib;
30   io::file_sink zlibfile(argv[1] + std::string(".zlib"));
31
32   io::filtering_ostream zlibout;
33   zlibout.push(gztee | bz2tee | zlib | zlibfile);
34
35   io::copy(ins, zlibout);
36 }

我们为 gzip、bzip2 和 zlib 设置了三个压缩过滤器(第 17、23 和 29 行)。我们需要为每个输出文件创建一个filtering_ostream。我们为 gzip 压缩输出创建了gzout流(第 20 行),为 bzip2 压缩输出创建了bz2out流(第 26 行)。我们在这两个流周围创建了 tee 过滤器(第 21 和 27 行)。最后,我们将 gztee、bz2tee 和 zlib 连接到 zlibfile 接收器前面,并将此链推入 zlibout 的filtering_ostream中,用于 zlib 文件(第 33 行)。从输入流ins复制到输出流zlibout会生成管道中的三个压缩输出文件,如下图所示:

使用 tee 分支数据流

请注意,对 tee 的调用没有命名空间限定,但由于参数相关查找(见第二章,“使用 Boost 实用工具的第一次尝试”),它们得到了正确的解析。

Boost IOStreams 库提供了一个非常丰富的框架,用于编写和使用设备和过滤器。本章仅介绍了此库的基本用法,还有许多过滤器、设备和适配器可以组合成有用的 I/O 模式。

自测问题

对于多项选择题,选择所有适用的选项:

  1. 对于操作路径的canonicalequivalent函数有什么独特之处?

a. 参数不能命名真实路径。

b. 两者都是命名空间级别的函数。

c. 参数必须命名真实路径。

  1. 以下代码片段的问题是什么,假设路径的类型是boost::filesystem::path
if (is_regular_file(path)) { /* … */ }
else if (is_directory(path)) { /* … */ }
else if (is_symlink(path)) { /* … */ }

a. 它必须有静态的value字段。

b. 它必须有一个名为type的嵌入类型。

c. 它必须有静态的type字段。

d. 它必须有一个名为result的嵌入类型。

  1. 考虑到这段代码:
boost::filesystem::path p1("/opt/boost/include/boost/thread.hpp");
size_t n = std::distance(p1.begin(), p1.end());

n 的值是多少?

a. 5,路径中组件的总数。

b. 6,路径中组件的总数。

c. 10,斜杠和组件数量的总和。

d. 4,目录组件的总数。

  1. 您想要读取一个文本文件,使用grep_filter删除所有空行,使用regex_filter替换特定关键词,并计算结果中的字符和行数。您将使用以下哪个管道?

a. file_source | grep_filter| regex_filter | counter

b. grep_filter | regex_filter | counter | file_source

c. counter | regex_filter | grep_filter |file_source

d. file_source | counter | grep_filter | regex_filter

  1. 真或假:tee 过滤器不能与输入流一起使用。

a. 真。

b. 错误。

总结

在本章中,我们介绍了 Boost Filesystem 库,用于读取文件元数据和文件和目录状态,并对它们执行操作。我们还介绍了高级 Boost IOStreams 框架,用于执行具有丰富语义的类型安全 I/O。

处理文件和执行 I/O 操作是基本的系统编程任务,几乎任何有用的软件都需要执行这些任务,而我们在本章中介绍的 Boost 库通过一组可移植的接口简化了这些任务。在下一章中,我们将把注意力转向另一个系统编程主题——并发和多线程。

第十章:使用 Boost 进行并发

线程代表进程内的并发执行流。它们是并发的低级抽象,并由操作系统的系统编程库或系统调用接口公开,例如,POSIX 线程、Win32 线程。在多处理器或多核系统上,操作系统可以调度同一进程的两个线程在两个不同的核上并行运行,从而实现真正的并行

线程是一种流行的机制,用于抽象可能与其他类似任务并行运行的并发任务。如果做得好,线程可以简化程序结构并提高性能。然而,并发和并行性引入了在单线程程序中看不到的复杂性和非确定性行为,做到正确通常是涉及线程时最大的挑战。不同操作系统上本地多线程库或接口的广泛差异使得使用线程编写可移植的并发软件的任务变得更加困难。Boost Thread 库通过提供一个可移植的接口来创建线程和更高级别的抽象来缓解这个问题。Boost Coroutine 库提供了一种创建协作协程或可以退出和恢复的函数的机制,在这些调用之间保留自动对象的状态。协程可以以更简单的方式表达事件驱动逻辑,并在某些情况下避免线程的开销。

本章是对使用 Boost Thread 库的实际介绍,还包括对 Boost Coroutine 库的简要介绍。它分为以下几个部分:

  • 使用 Boost Thread 创建并发任务

  • 并发、信号和同步

  • Boost 协程

即使您从未编写过多线程程序或并发软件,这也是一个很好的起点。我们还将涉及基于 Boost Thread 库的 C++11 标准库中的线程库,并引入额外的改进。

使用 Boost Thread 创建并发任务

考虑一个以不同语言打印问候语的程序。有一个用盎撒克逊语言,如英语、德语、荷兰语、丹麦语等的问候语列表。还有一个用罗曼语言,如意大利语、西班牙语、法语、葡萄牙语等的问候语列表。需要打印来自两种语言组的问候语,我们不希望因为其中一组的问候语而延迟打印另一组的问候语,也就是说,我们希望同时打印来自两个组的问候语。以下是同时打印两组问候语的一种方法:

清单 10.1:交错任务

 1 #include <iostream>
 2 #include <string>
 3 #include <vector>
 4
 5 int main()
 6 {
 7   typedef std::vector<std::string> strvec;
 8
 9   strvec angloSaxon{"Guten Morgen!", "Godmorgen!", 
10                    "Good morning!", "goedemorgen"};
11
12   strvec romance{"Buenos dias!", "Bonjour!", 
13                  "Bom dia!", "Buongiorno!"};
14
15   size_t max1 = angloSaxon.size(), max2 = romance.size();
16   size_t i = 0, j = 0;
17
18   while (i < max1 || j < max2) {
19     if (i < max1)
20       std::cout << angloSaxon[i++] << '\n';
21
22     if (j < max2)
23       std::cout << romance[j++] << '\n';
24   }
25 }

在前面的示例中,我们有两个问候语的向量,并且在每个向量中打印问候语是一个独立的任务。我们通过从每个数组中打印一个问候语来交错这两个任务,因此这两个任务同时进行。从代码中,我们可以看出拉丁语和盎格鲁-撒克逊语的问候语将交替打印,顺序如下所示:

Buenos dias!
Guten Morgen!
Bonjour!
Godmorgen!
Bom dia!
Good morning!
Buongiorno!
goedemorgen

虽然这两个任务是交替运行的,并且在某种意义上是同时进行的,但它们在代码中的区别完全混乱,以至于它们被编码在一个单一的函数中。通过将它们分开成单独的函数并在单独的线程中运行,这些任务可以完全解耦,但可以同时运行。此外,线程可以允许它们并行执行。

使用 Boost Threads

每个运行的进程至少有一个执行线程。传统的“hello world”程序具有一个main函数,也有一个单一线程,通常称为主线程。这样的程序称为单线程。使用 Boost Threads,我们可以创建具有多个执行线程的程序,这些线程并发运行任务。我们可以使用 Boost Threads 重写列表 10.1,以便将单个任务的代码清晰地分解出来,并且在有并行硬件可用时,任务可能并行运行。我们可以这样做:

列表 10.2:作为线程的并发任务

 1 #include <boost/thread.hpp>
 2 #include <string>
 3 #include <vector>
 4 #include <iostream>
 5
 6 typedef std::vector<std::string> strvec;
 7 
 8 void printGreets(const strvec& greets)
 9 {
10   for (const auto& greet : greets) {
11     std::cout << greet << '\n';
12   }
13 }
14
15 int main()
16 {
17   strvec angloSaxon{"Guten Morgen!", "Godmorgen!", 
18                    "Good morning!", "goedemorgen"};
19
20   strvec romance{"Buenos dias!", "Bonjour!", 
21                  "Bom dia!", "Buongiorno!"};
15
16   boost::thread t1(printGreets, romance);
17   printGreets(angloSaxon);
18   t1.join();
19 }

我们定义了一个函数printGreets,它接受一个问候语的向量并打印向量中的所有问候语(第 8-13 行)。这是任务的代码,简化并分解出来。这个函数在两个问候语向量上分别被调用一次。它从main函数中调用一次,该函数在主线程中执行(第 17 行),并且从我们通过实例化boost::thread对象来生成的第二个执行线程中调用一次,传递给它要调用的函数和它的参数(第 16 行)。头文件boost/thread.hpp提供了使用 Boost Threads 所需的类型和函数(第 1 行)。

类型为boost::thread的对象t1包装了一个本地线程,例如pthread_t,Win32 线程HANDLE等。为了简洁起见,我们简单地指“线程t1”来表示底层线程以及包装它的boost::thread对象,除非有必要区分两者。通过传递函数对象(线 16)和传递给函数对象的所有参数来构造对象t1。在构造时,底层本地线程立即通过调用传递的函数和提供的参数开始运行。当此函数返回时,线程终止。这与从main函数调用的printGreets函数(第 17 行)同时发生。

这个程序的一个可能的输出是:

Guten Morgen!
Buenos dias!
Godmorgen!
Bonjour!
Bom dia!
Good morning!
Buongiorno!
goedemorgen

拉丁语问候语按照它们在romance向量中出现的顺序打印,盎格鲁-撒克逊语问候语按照它们在angloSaxon向量中出现的顺序打印。但它们交错的顺序是不可预测的。这种缺乏确定性是并发编程的一个关键特征,也是一些困难的来源。可能更令人不安的是,甚至以下输出也是可能的:

Guten Morgen!
Buenos dGodmorgeias!
n!
Bonjour!
Bom dia! Good morning!
Buongiorno!
goedemorgen

请注意,两个问候语Buenos dias!(西班牙语)和Godmorgen!(荷兰语)是交错的,而Good morning!Bom dia!后的换行之前被打印出来。

我们在t1上调用join成员函数来等待底层线程终止(第 18 行)。由于主线程和线程t1并发运行,任何一个都可以在另一个之前终止。如果main函数先终止,它将终止程序,并且在线程t1中运行的printGreets函数在执行完之前将被终止。通过调用join,主函数确保在t1仍在运行时不会退出。

注意

链接到 Boost 线程库

Boost Thread 不是一个仅包含头文件的库,而是必须从源代码构建的。第一章,介绍 Boost,描述了从源代码构建 Boost 库的细节,它们的名称布局变体和命名约定。

要从列表 10.2 构建一个运行的程序,您需要将编译后的对象与这些库链接起来。要构建前面的示例,您必须链接 Boost 线程和 Boost 系统库。在 Linux 上,您还必须链接libpthread,其中包含 Pthreads 库的实现。

假设源文件是Listing9_2.cpp,以下是在 Linux 上编译和链接源代码以构建二进制文件的 g++命令行:

$ g++ Listing9_2.cpp -o Listing9_2 -lboost_thread -lboost_system –lboost_chrono –pthread 

只有在使用 Boost Chrono 库时才需要链接到libboost_chrono。选项-pthread设置了必要的预处理器和链接器标志,以启用编译多线程应用程序并将其链接到libpthread。如果您没有使用本机包管理器在 Linux 上安装 Boost,或者正在尝试在其他平台上构建,比如 Windows,则请参考第一章中的详细构建说明,介绍 Boost

如果您使用的是 C++11,可以使用标准库线程而不是 Boost Threads。为此,您必须包含标准库头文件thread,并使用std::thread代替boost::thread。Boost Thread 和std::thread不能直接替换彼此,因此可能需要进行一些更改。

移动线程和等待线程

std::thread对象与进程中的一个线程关联并管理。考虑以下代码片段:

 1 void threadFunc() { ... }
 2
 3 boost::thread makeThread(void (*thrFunc)()) {
 4   assert(thrFunc);
 5   boost::thread thr(thrFunc);
 6   // do some work
 7   return thr;
 8 }
 9
10 int main() {
11   auto thr1 = makeThread(threadFunc);
12   // ...
13   thr1.join();
14 }

当创建boost::thread对象thr(第 4 行)时,它与一个新的本机线程(pthread_t,Windows 线程句柄等)相关联,该线程执行thrFunc指向的函数。现在boost::thread是可移动但不可复制的类型。当makeThread函数通过值返回thr(第 7 行)时,底层本机线程句柄的所有权从makeThread中的对象thr移动到main函数中的thr1(第 11 行)。因此,您可以在一个函数中创建一个线程,并将其返回给调用函数,在此过程中转移所有权

最终,我们在main函数内等待线程完成执行,通过调用join(第 13 行)。这确保了在线程thr1终止之前,main函数不会退出。现在完全有可能,在makeThread返回thr时,底层线程已经完成了执行。在这种情况下,thr1.join()(第 13 行)会立即返回。另一方面,当主线程上的控制转移到main函数时,底层线程可能会继续执行,即使在thr1(第 13 行)上调用了join。在这种情况下,thr1.join()将会阻塞,等待线程退出。

有时,我们可能希望一个线程运行完毕并退出,之后就不再关心它了。此外,线程是否终止可能并不重要。想象一下,一个个人财务桌面应用程序具有一个巧妙的股票行情线程,它在窗口的一个角落不断显示一组可配置公司的股票价格。它由主应用程序启动,并继续执行其获取最新股票价格并显示的工作,直到应用程序退出。主线程在退出之前等待此线程没有多大意义。应用程序终止时,股票行情线程也会终止并在其退出时进行清理。我们可以通过在boost::thread对象上调用detach来显式请求线程的此行为,如下面的代码片段所示:

 1 int main() {
 2   boost::thread thr(thrFunc, arg1, ...);
 3   thr.detach();
 4   // ...
 5 }

当我们在boost::thread对象上调用detach时,底层本机线程的所有权被传递给 C++运行时,它将继续执行线程,直到线程终止或程序终止并杀死线程。在调用detach之后,boost::thread对象不再引用有效线程,程序不再能够检查线程的状态或以任何方式与其交互。

只有在boost::thread对象上既没有调用detach也没有调用join时,线程才被认为是可连接的。boost::threadjoinable方法仅在线程可连接时返回true。如果您在不可连接的boost::thread对象上调用detachjoin,则调用将立即返回,没有其他效果。如果我们没有在boost::thread对象上调用join,则在线程超出范围时将调用detach

注意

boost::threadstd::thread之间的区别

必须在std::thread对象上调用joindetach;否则,std::thread的析构函数将调用std::terminate并中止程序。此外,在不可连接的std::thread上调用joindetach将导致抛出std::system_error异常。因此,您在std::thread上调用joindetach中的任何一个,并且只调用一次。这与我们刚刚描述的boost::thread的行为相反。

我们可以通过定义以下预处理器宏使boost::thread模拟std::thread的行为,而且在您编写的任何新代码中模拟std::thread的行为是一个好主意:

BOOST_THREAD_TRHOW_IF_PRECONDITION_NOT_SATISFIED BOOST_THREAD_PROVIDES_THREAD_DESTRUCTOR_CALLS_TERMINATE_IF_JOINABLE

线程 ID

在任何时候,进程中的每个运行线程都有一个唯一的标识符。此标识符由类型boost::thread::id表示,并且可以通过调用get_id方法从boost::thread对象中获取。要获取当前线程的 ID,我们必须使用boost::this_thread::get_id()。可以使用重载的插入运算符(operator<<)将 ID 的字符串表示打印到ostream对象中。

线程 ID 可以使用operator<进行排序,因此它们可以轻松地存储在有序的关联容器(std::set / std::map)中。线程 ID 可以使用operator==进行比较,并且可以存储在无序的关联容器中(std::unordered_set / std::unordered_map)。将线程存储在由其 ID 索引的关联容器中是支持线程查找的有效手段:

清单 10.3:使用线程 ID

 1 #include <boost/thread.hpp>
 2 #include <boost/chrono/duration.hpp>
 3 #include <vector>
 4 #include <map>
 5 #include <iostream>
 6 #include <sstream>
 7 #include <boost/move/move.hpp>
 8
 9 void doStuff(const std::string& name) {
10   std::stringstream sout;
11   sout << "[name=" << name << "]"
12     << "[id=" << boost::this_thread::get_id() << "]"
13     << " doing work\n";
14   std::cout << sout.str();
15   boost::this_thread::sleep_for(boost::chrono::seconds(2));
16 }
17
18 int main() {
19   typedef std::map<boost::thread::id, boost::thread> threadmap;
20   threadmap tmap;
21
22   std::vector<std::string> tnames{ "thread1", "thread2",
23                             "thread3", "thread4", "thread5" };
24   for (auto name : tnames) {
25     boost::thread thr(doStuff, name);
26     tmap[thr.get_id()] = boost::move(thr);
27   }
28
29   for (auto& thrdEntry : tmap) {
30     thrdEntry.second.join();
31     std::cout << thrdEntry.first << " returned\n";
32   }
33 }

在前面的例子中,我们创建了五个线程,每个线程都运行函数doStuff。函数doStuff被分配了一个线程运行的名称;我们将线程命名为thread1thread5,并将它们放在一个由它们的 ID 索引的std::map中(第 26 行)。因为boost::thread是可移动但不可复制的,我们将线程对象移动到地图中。doStuff函数简单地使用boost::this_thread::get_id方法(第 12 行)打印当前线程的 ID 作为一些诊断消息的一部分,然后使用boost::this_thread::sleep_for休眠 2 秒,该方法接受boost::chrono::duration类型的持续时间(参见第八章,“日期和时间库”)。我们还可以使用 Boost Date Time 提供的持续时间类型,即boost::posix_time::time_duration及其子类型,而不是boost::chrono,但是为此我们需要使用boost::this_thread::sleep函数而不是sleep_for

核心和线程

许多现代计算机在单个芯片上有多个 CPU 核心,并且处理器包中可能有多个芯片。要获取计算机上的物理核心数,可以使用静态函数boost::thread::physical_concurrency

现代英特尔 CPU 支持英特尔的超线程技术,该技术通过使用两组寄存器最大限度地利用单个核心,允许在任何给定时间点上在核心上复用两个线程,并降低上下文切换的成本。在支持超线程的具有八个核心的英特尔系统上,可以并行调度的最大线程数为 8x2 = 16。静态函数boost::thread::hardware_concurrency为本地机器返回此数字。

这些数字对于决定程序中的最佳线程数是有用的。但是,如果这些数字在底层系统中不可用,这些函数可能会返回 0。您应该在计划使用它们的每个平台上彻底测试这些函数。

管理共享数据

进程中的所有线程都可以访问相同的全局内存,因此在一个线程中执行的计算结果相对容易与其他线程共享。对共享内存的并发只读操作不需要任何协调,但对共享内存的任何写入都需要与任何读取或写入同步。共享可变数据和其他资源的线程需要机制来仲裁对共享数据的访问并向彼此发出关于事件和状态更改的信号。在本节中,我们探讨了多个线程之间的协调机制。

创建和协调并发任务

考虑一个生成两个文本文件之间差异的程序,类似于 Unix 的 diff 实用程序。您需要读取两个文件,然后应用算法来识别相同部分和已更改部分。对于大多数文本文件,读取两个文件,然后应用适当的算法(基于最长公共子序列问题)都能很好地工作。算法本身超出了本书的范围,与当前讨论无关。

考虑我们需要执行的任务:

  • R1: 读取第一个文件的完整内容

  • R2: 读取第二个文件的完整内容

  • D: 对两个文件的内容应用差异算法

任务 R1 和 R2 可能产生包含文件内容的两个字符数组。任务 D 消耗了 R1 和 R2 产生的内容,并将差异作为另一个字符数组产生。R1 和 R2 之间不需要顺序,我们可以在单独的线程中同时读取两个文件。为简单起见,D 仅在 R1 和 R2 完成后才开始,也就是说,R1 和 R2 必须在 D 之前发生。让我们从编写读取文件的代码开始:

清单 10.4a: 读取文件内容

 1 #include <vector>
 2 #include <string>
 3 #include <fstream>
 4 #include <boost/filesystem.hpp>
 5
 6 std::vector<char> readFromFile(const std::string& filepath)
 7 {
 8   std::ifstream ifs(filepath);
 9   size_t length = boost::filesystem::file_size(filepath);
10   std::vector<char> content(length);
11   ifs.read(content.data(), length);
12
13   return content;
14 }
15
16 std::vector<char> diffContent(const std::vector<char>& c1,
17                               const std::vector<char>& c2) {
18   // stub - returns an empty vector
19   return std::vector<char>();
20 }

给定文件名,函数 readFromFile 读取整个文件的内容并将其返回为 vector<char>。我们将文件内容读入 vector 的基础数组中,为了获取它,我们调用了 C++11 中引入的 data 成员函数(第 11 行)。我们打开文件进行读取(第 8 行),并使用 boost::filesystem::size 函数获取文件的大小(第 9 行)。我们还定义了一个计算两个文件内容差异的方法 diffContent 的存根。

我们如何使用 readFromFile 函数在单独的线程中读取文件并将包含文件内容的向量返回给调用线程?调用线程需要一种等待读取完成的方式,并且然后获取所读取的内容。换句话说,调用线程需要等待异步操作的未来结果。boost::future 模板提供了一种简单的方式来强制执行任务之间的这种顺序。

boost::future 和 boost::promise

boost::future<> 模板用于表示可能在将来发生的计算结果。类型为 boost::future<T> 的对象代表将来可能产生的类型为 T 的对象的代理。粗略地说,boost::future 使调用代码能够等待或阻塞事件的发生——产生某种类型的值的事件。这种机制可以用于信号事件并从一个线程传递值到另一个线程。

值的生产者或事件的来源需要一种与调用线程中的 future 对象通信的方法。为此,使用与调用线程中的 future 对象关联的boost::promise<T>类型的对象来发出事件并发送值。因此,boost::futureboost::promise对象成对工作,以在线程之间传递事件和值。现在我们将看到如何使用 Boost futures 和 promises 来保证两个文件读取操作在两个线程中先于 diff 操作:

列表 10.4b:使用 futures 和 promises 从线程返回值

 1 #define BOOST_THREAD_PROVIDES_FUTURE
 2 #include <boost/thread.hpp>
 3 #include <boost/thread/future.hpp>
 4 // other includes
 5
 6 std::vector<char> diffFiles(const std::string& file1, 
 7                             const std::string& file2) {
 8   // set up the promise-future pair
 9   boost::promise<std::vector<char>> promised_value;
10   boost::future<std::vector<char>> future_result
11                                = promised_value.get_future();
12   // spawn a reader thread for file2
13   boost::thread reader(
14                     [&promised_value, &file2]() {
15                       std::cout << "Reading " << file2 << '\n';
16                       auto content = readFromFile(file2);
17                       promised_value.set_value(content);
18                       std::cout << "Read of " << file2
19                                 << " completed.\n";
20                     });
21
22   std::cout << "Reading " << file1 << '\n';
23   auto content1 = readFromFile(file1);
24   std::cout << "Read of " << file1 << " completed.\n";
25
26   auto content2 = future_result.get(); // this blocks
27   auto diff = diffContent(content1, content2);
28   reader.join();
29   return diff; 
30 }

为了能够使用boost::futureboost::promise,我们需要包括boost/thread/future.hpp(第 3 行)。如果我们没有定义预处理符号BOOST_THREAD_PROVIDES_FUTURE(第 1 行),那么我们需要使用boost::unique_future而不是boost::future。如果我们用boost::unique_future替换boost::future,这个例子将不变,但一般来说,这两种设施的能力有所不同,我们在本书中坚持使用boost::future

diffFiles函数(第 6 和 7 行)接受两个文件名并返回它们的差异。它同步读取第一个文件(第 23 行),使用列表 10.4a 中的readFromFile函数,并创建一个名为reader的线程并发读取第二个文件(第 13 行)。为了在reader线程完成读取并获取读取的内容时得到通知,我们需要设置一个 future-promise 对。由于我们想要从reader线程返回std::vector<char>类型的值,我们定义了一个名为promised_valueboost::promise<std::vector<char>>类型的 promise(第 9 行)。promise 对象的get_future成员返回关联的 future 对象,并用于移动构造future_result(第 10-11 行)。这将promised_valuefuture_result设置为我们要处理的 promise-future 对。

为了读取file2的内容,我们创建了reader线程,传递了一个 lambda(第 14-20 行)。lambda 捕获了promised_value和要读取的文件的名称(第 14 行)。它读取文件的内容并在 promise 对象上调用set_value,传递读取的内容(第 17 行)。然后打印诊断消息并返回。与此同时,调用线程也将另一个文件file1读入缓冲区content1,然后在future_result上调用get(第 26 行)。此调用会阻塞,直到通过调用set_value(第 17 行)设置了关联的 promise。它返回在 promise 中设置的vector<char>,并用于移动构造content2。如果在调用get时 promise 已经设置,它会返回值而不会阻塞调用线程。

现在我们有了计算差异所需的数据,并且通过将缓冲区content1content2传递给diffContent函数(第 27 行)来进行计算。请注意,在返回diff之前,我们在reader线程上调用join(第 28 行)。只有在我们希望确保reader线程在函数返回之前退出时才需要这样做。我们也可以调用detach而不是join来不等待读取线程退出。

等待 future

boost::future<>get成员函数会阻塞调用线程,直到关联的 promise 被设置。它返回 promise 中设置的值。有时,您可能希望阻塞一小段时间,如果 promise 没有设置,则继续进行。为此,您必须使用wait_for成员函数,并使用boost::chrono::duration指定等待的持续时间(参见第八章,“日期和时间库”)。

列表 10.5:等待和超时 future

 1 #define BOOST_THREAD_PROVIDES_FUTURE
 2 #include <boost/thread.hpp>
 3 #include <boost/thread/future.hpp>
 4 #include <boost/chrono.hpp>
 5 #include <ctime>
 6 #include <cassert>
 7 #include <cstdlib>
 8 #include <iostream>
 9 
10 int main() {
11   boost::promise<void> promise;
12   boost::future<void> future = promise.get_future();
13
14   std::cout << "Main thread id=" 
15                       << boost::this_thread::get_id() << '\n';
16   boost::thread thr([&promise]() {
17          srand(time(0));
18          int secs = 10 + rand() % 10;
19          std::cout << "Thread " << boost::this_thread::get_id()
20                   << " sleeping for "
21                   << secs << " seconds\n";
22          boost::this_thread::sleep_for(
23               boost::chrono::seconds(secs));
24          promise.set_value();
25        });
26
27   size_t timeout_count = 0;
28   size_t secs = 2;
29
30   while (future.wait_for(boost::chrono::seconds(secs)) 
31           == boost::future_status::timeout) {
32     std::cout << "Main thread timed out\n";
33     ++timeout_count;
34   }
35   assert(future.is_ready());
36   assert(future.get_state() == boost::future_state::ready);
37
38   std::cout << "Timed out for " << timeout_count * secs 
39             << " seconds \n";
40   thr.join();
41 }

这个例子演示了我们如何在 future 对象上等待固定的持续时间。我们创建了一个 promise-future 对(第 11-12 行),但是boost::future<>boost::promise<>的模板参数是 void。这意味着我们可以纯粹用于信号/等待,但不能在线程之间传输任何数据。

我们创建了一个线程thr(第 16 行),传递一个 lambda,它捕获了 promise 对象。这个线程简单地睡眠在 10 到 19 秒之间的随机持续时间,通过将随机持续时间传递给boost::this_thread::sleep_for(第 22 行),然后退出。持续时间是使用boost::chrono::seconds函数构造的(第 23 行),并传递了使用rand函数计算的随机间隔secs(第 18 行)。我们使用rand是为了简洁起见,尽管 Boost 和 C++11 中提供了更可靠和健壮的设施。要使用rand,我们需要调用srand来种子随机数生成器。在 Windows 上,我们必须在每个调用rand的线程中调用srand,就像我们在这里展示的(第 17 行),而在 POSIX 上,我们应该在每个进程中调用srand,这可以在main的开始处。

在特定持续时间后,线程thr调用 promise 的set_value并返回(第 24 行)。由于 promise 的类型是boost::promise<void>set_value不带任何参数。

在主线程中,我们运行一个循环,每次调用与promise相关联的 future 的wait_for,传递 2 秒的持续时间(第 30 行)。wait_for函数返回枚举类型boost::future_state的值。每次wait_for超时,它返回boost::future_state::timeout。一旦 promise 被设置(第 24 行),wait_for调用返回boost::future_state::ready并且循环中断。boost::futureis_ready成员函数返回true(第 35 行),并且get_state成员函数返回的 future 状态是boost::future_state::ready(第 36 行)。

在线程之间抛出异常

如果传递给boost::thread构造函数的初始函数允许任何异常传播,那么程序将立即通过调用std::terminate中止。如果我们需要从一个线程向另一个线程抛出异常来指示问题,或者传播我们在一个线程中捕获的异常到另一个线程,那么 promise/future 机制也很方便。考虑一下,在清单 10.4a 和 10.4b 中,当文件不存在或不可读时,你将如何处理:

清单 10.6:在线程之间传递异常

 1 #define BOOST_THREAD_PROVIDES_FUTURE
 2 #include <boost/thread.hpp>
 3 #include <boost/thread/future.hpp>
 4 // other includes
 5
 6 std::vector<char> readFromFile(const std::string& filepath)
 7 {
 8   std::ifstream ifs(filepath, std::ios::ate);
 9   if (!ifs) {
10     throw std::runtime_error(filepath + " unreadable");
11   }
12   ... // rest of the code – check Listing 10.4a
13 }
14
15 std::vector<char> diffFiles(const std::string& file1,
16                             const std::string& file2) {
17   // set up the promise-future pair
18   boost::promise<std::vector<char> > promised_value;
19   boost::future<std::vector<char> > future_result
20                                = promised_value.get_future();
21   // spawn a reader thread for file2
22   boost::thread reader(
23                        [&promised_value, &file2]() {
24                          try {
25                            auto content = readFromFile(file2);
26                            promised_value.set_value(content);
27                          } catch (std::exception& e) {
28                            promised_value.set_exception(
29                               boost::copy_exception(e));
30                          }
31                        });
32   ...
33   std::vector<char> diff;
34   try {
35     auto content2 = future_result.get(); // this blocks
36     diff = diffContent(content1, content2);
37   } catch (std::exception& e) {
38     std::cerr << "Exception caught: " << e.what() << '\n';
39   }
40   reader.join();
41   return diff; 
42 }

如果file2是一个不存在或不可读的文件的名称(第 25 行),那么readFromFile函数会抛出一个异常(第 10 行),被reader线程捕获(第 27 行)。reader线程使用set_exception成员函数在 promise 对象中设置异常(第 28-29 行)。请注意,我们使用boost::copy_exception创建异常对象的副本并将其设置在 promise 对象中(第 29 行)。一旦 promise 中设置了异常,对 future 对象的get调用(第 35 行)会抛出该异常,需要捕获和处理(第 38 行)。

shared_future

boost::future对象只能由一个线程等待。它不可复制,但可移动;因此,它的所有权可以从一个线程转移到另一个线程,从一个函数转移到另一个函数,但不能共享。如果我们希望多个线程使用 future 机制等待相同的条件,我们需要使用boost::shared_future。在下面的示例中,我们创建一个发布者线程,在设置带有其线程 ID 的 promise 之前等待固定的持续时间。我们还创建了三个订阅者线程,它们以不同的周期性轮询与 promise 对象关联的boost::shared_future对象,直到它准备就绪,然后从shared_future中检索发布者对象的线程 ID:

清单 10.7:使用 shared_future

 1 #include <string>
 2 #include <vector>
 3 #include <iostream>
 4 #define BOOST_THREAD_PROVIDES_FUTURE
 5 #include <boost/lexical_cast.hpp>
 6 #include <boost/thread.hpp>
 7 #include <boost/thread/future.hpp>
 8 #include <boost/chrono.hpp>
 9
10 int main() {
11   boost::promise<std::string> prom;
12   boost::future<std::string> fut(prom.get_future());
13   boost::shared_future<std::string> shfut(std::move(fut));
14   boost::thread publisher([&prom]() {
15               std::string id =
16                 boost::lexical_cast<std::string>(
17                                boost::this_thread::get_id());
18               std::cout << "Publisher thread " << id 
19                         << " starting.\n";
20               boost::this_thread::sleep_for(
21                                   boost::chrono::seconds(15));
22               prom.set_value(id);
23            });
24   auto thrFunc = [](boost::shared_future<std::string> sf, 
25                     int waitFor) {
26     while (sf.wait_for(boost::chrono::seconds(waitFor))
27         == boost::future_status::timeout) {
28       std::cout << "Subscriber thread " 
29                 << boost::this_thread::get_id()
30                 << " waiting ...\n";
31     }
32
33     std::cout << "\nSubscriber thread " 
34               << boost::this_thread::get_id()
35               << " got " << sf.get() << ".\n";
36   };
37
38   boost::thread subscriber1(thrFunc, shfut, 2);
39   boost::thread subscriber2(thrFunc, shfut, 4);
40   boost::thread subscriber3(thrFunc, shfut, 6);
41
42   publisher.join();
43   subscriber1.join();
44   subscriber2.join();
45   subscriber3.join();
46 }

按照熟悉的模式,我们创建一个 promise(第 11 行)和一个boost::future(第 12 行)。使用 future 对象,我们 move-initialize 一个shared_future对象shfut(第 13 行)。publisher线程捕获 promise(第 14 行),并在设置其 ID 字符串到 promise 之前睡眠 15 秒(第 21 行)。

对于订阅者线程,我们将 lambda 表达式生成的函数对象存储在名为thrFunc的变量中(第 24 行),以便可以多次重用。订阅者线程的初始函数通过值传递一个shared_future参数,并且还有一个waitFor参数,该参数指定以秒为单位轮询shared_future的频率。订阅者在一个循环中调用shared_future上的wait_for,在waitFor秒后超时。一旦 promise 被设置(第 22 行),它就会退出循环,并通过在shared_future上调用get(第 35 行)来检索 promise 中设置的值(发布者线程的 ID)。

三个订阅者线程被创建(第 38-40 行)。请注意,它们初始函数的参数,shared_future对象和以秒为单位的等待时间作为额外参数传递给boost::thread对象的可变构造函数模板。请注意,shared_future是可复制的,同一个shared_future对象shfut被复制到三个订阅者线程中。

std::future 和 std::promise

C++11 标准库提供了std::future<>std::shared_future<>std::promise<>模板,它们的行为几乎与它们的 Boost 库对应物相同。Boost 版本的额外成员函数是实验性的,但是除此之外,它们与标准库对应物相同。例如,我们可以通过在程序文本中替换以下符号来重写 10.5 和 10.7 清单:

  • boost::thread替换为std::thread

  • boost::future替换为std::future

  • boost::promise替换为std::promise

  • boost::shared_promise替换为std::shared_promise

  • boost::chrono替换为std::chrono

此外,我们需要用标准库头文件threadfuturechrono分别替换包含的头文件boost/thread.hppboost/thread/future.hppboost/chrono.hpp

在 10.6 清单中,我们使用boost::promiseset_exception成员函数来实现在线程边界传递异常。这需要一些更改才能与std::promise一起工作。C++11 引入了std::exception_ptr,这是一种具有共享所有权语义的特殊智能指针类型,必须包装异常对象,以便它们可以在函数和线程之间传递(见附录,C++11 语言特性模拟)。std::promiseset_exception成员函数接受一个std::exception_ptr类型的参数,而不是std::exception。以下代码片段显示了如何更改 10.6 清单以使用标准库:

 1 // include other headers
 2 #include <exception>
... // other code
22   boost::thread reader(
23                        [&promised_value, &file2]() {
24                          try {
25                            auto content = readFromFile(file2);
26                            promised_value.set_value(content);
27                          } catch (std::exception& e) {
28                            promised_value.set_exception(
29                                     std::current_exception());
30                          }
31                        });

在这里,我们调用std::current_exception(第 29 行),它返回一个包装在 catch 块中当前活动异常的std::exception_ptr对象。这个exception_ptr被传递给std::promiseset_exception成员函数(第 28 行)。这些类型和函数声明可以从标准库头文件exception(第 2 行)中获得。

我们还可以使用std::make_exception_ptr从异常对象创建一个std::exception_ptr对象,如下面的代码片段所示(第 29 行):

22   boost::thread reader(
23                        [&promised_value, &file2]() {
24                          try {
25                            auto content = readFromFile(file2);
26                            promised_value.set_value(content);
27                          } catch (std::exception& e) {
28                            promised_value.set_exception(
29                                  std::make_exception_ptr(e));
30                          }
31                        });
The exception stored in a std::exception_ptr can be thrown using std::rethrow_exception, as shown here:
01 void throwAgain(std::exception_ptr eptr) {
02   // do stuff
03   std::rethrow_exception(eptr);
04 }

std::packaged_task 和 std::async

虽然线程是强大的构造,但它们提供的完整的通用性和控制是以简单性为代价的。在许多情况下,最好以比创建显式线程运行任务更高的抽象级别进行操作。标准库提供了std::async函数模板和std::packaged_task类模板,为创建并发任务提供了不同的抽象级别,从而使程序员免于在此过程中编写大量样板代码。它们在 Boost 库中有对应物(boost::asyncboost::packaged_task),但在撰写本文时(Boost 版本 1.57),它们的实现不完整,且在早期 C++11 环境中使用起来不太方便。

std::packaged_task

std::packaged_task<>类模板用于创建异步任务。您需要显式创建一个运行任务的线程,或者使用packaged_task中重载的operator()手动调用任务。但您不需要手动设置 promise-future 对,也不需要以任何方式处理 promise。这里是使用std::packaged_task重写的列表 10.6:

列表 10.8:使用 std::packaged_task

 1 #include <future>
 2 #include <thread>
 3 #include <vector>
 4 // other includes
 5
 6 std::vector<char> readFromFile(const std::string& filepath)
 7 {
 8   std::ifstream ifs(filepath, std::ios::ate);
 9   if (!ifs) {
10     throw std::runtime_error(filepath + " unreadable");
11   }
12   ... // rest of the code – check Listing 10.4a
13 }
14
15 std::vector<char> diffFiles(const std::string& file1,
16                             const std::string file2)
17 {
18   typedef std::vector<char> buffer_t;
19   std::packaged_task<buffer_t(const std::string&)>
20             readerTask(readFromFile);
21   auto future = readerTask.get_future();
22
23   try {
24     std::thread thread2(std::move(readerTask), file2);
25     auto content1 = readFromFile(file1);
26     std::cout << "Read from file " << file1 << " completed.\n";
27
28     auto content2 = future.get();
29     thread2.detach();
30     return diffContent(content1, content2);
31   } catch (std::exception& e) {
32     std::cout << "Exception caught: " << e.what() << '\n';
33   }
34
35   return std::vector<char>(); 
36 }

在这个例子中,我们读取两个文件并计算它们的差异。为了读取文件,我们使用readFromFile函数,它返回一个vector<char>中的文件内容,或者如果文件不可读则抛出异常。我们通过阻塞调用readFromFile(第 25 行)读取其中一个文件,并在单独的线程中读取另一个文件。

为了与第一个文件同时读取第二个文件,我们将readFromFile函数包装在名为readerTaskstd::packaged_task中(第 19-20 行),并在单独的线程中运行它。readerTask的具体类型是std::packaged_task<buffer_t(const std::string&)>packaged_task的模板参数是包装的函数类型。在将此任务在单独的线程上启动之前,我们必须首先获取与之关联的 future 对象的引用。我们通过调用packaged_taskget_future成员函数(第 21 行)来获取与 future 对象的引用。接下来,我们创建一个线程并将打包的任务移动到这个线程(第 24 行)。这是必要的,因为packaged_task是可移动的但不可复制的,这就是为什么必须在将packaged_task对象移动之前调用get_future方法的原因。

线程thread2通过调用传递给它的readFromFile函数来读取file2。通过调用与readerTask关联的 future 对象的get成员函数(第 28 行),可以获取readFromFile返回的vector<char>get调用将抛出readFromFile最初抛出的任何异常,比如当命名文件不存在时。

std::async

std::async函数模板从一个函数对象创建一个任务,这个任务可以在一个单独的线程中并发运行。它返回一个std::future对象,可以用来阻塞任务或等待它。它通过标准库头文件future提供。使用std::async,我们不再需要显式创建线程。相反,我们将要执行的函数、要传递的参数以及可选的启动策略传递给std::asyncstd::async根据指定的启动策略,要么在不同的线程中异步运行函数,要么在调用线程中同步运行函数。这里是使用std::async简单重写列表 10.5 的示例:

列表 10.9:使用 std::async 创建并发任务

 1 #include <iostream>
 2 #include <thread>
 3 #include <future>
 4 #include <chrono>
 5 #include <ctime>
 6 #include <cstdlib>
 7
 8 int main()
 9 {
10   int duration = 10 + rand() % 10;
11   srand(time(0));
12   std::cout << "Main thread id="
13             << std::this_thread::get_id() << '\n';
14 
15   std::future<int> future =
16     std::async(std::launch::async,
17        [](int secs) -> int {               
18            std::cout << "Thread " << std::this_thread::get_id()
19                     << " sleeping for "
20                     << secs << " seconds\n";
21            std::this_thread::sleep_for(
22                     std::chrono::seconds(secs));
23            return secs;
24        }, duration);
25   
26   size_t timeout_count = 0, secs = 2;
27 
28   while (future.wait_for(std::chrono::seconds(secs))
29           == std::future_status::timeout) {
30     std::cout << "Main thread timed out\n";
31     ++timeout_count;
32   }
33   std::cout << "Launched task slept for " 
34             << future.get() << '\n';
35   std::cout << "Timed out for " << timeout_count * secs 
36             << " seconds \n";
37 }

虽然packaged_task抽象了 promise,std::async抽象了线程本身,我们不再处理std::thread的对象。相反,我们调用std::async,传递一个启动策略std::launch::async(第 16 行),一个函数对象(第 17 行),以及函数对象所需的任意数量的参数。它返回一个 future 对象,并异步运行传递给它的函数。

thread的构造函数一样,std::async是一个可变参数函数,并传递需要转发给函数对象的所有参数。函数对象使用 lambda 表达式创建,并且除了按参数传递的持续时间休眠外,几乎不做任何事情。duration是 10 到 19 秒之间的随机值,并作为函数对象的唯一参数传递给async调用(第 24 行)。函数对象返回休眠的持续时间(第 23 行)。我们调用 future 对象的wait_for成员函数,以等待短时间直到 future 设置(第 28 行)。我们通过调用其get成员函数从 future 对象中检索任务的返回值(第 34 行)。

启动策略

我们使用启动策略std::launch::async来指示我们希望任务在单独的线程上运行。这将立即在单独的线程中启动任务。使用另一个标准启动策略std::launch::deferred,我们可以在首次调用与关联 future 对象的getwait(非定时等待函数)时懒惰地启动任务。任务将在调用getwait的线程中同步运行。这也意味着,如果使用deferred策略并且没有调用getwait,任务将永远不会启动。

我们无法在列表 10.10 中使用std::launch::deferred。这是因为我们在同一线程中等待 future 准备好(第 28 行)之前调用get(第 34 行)。任务在我们调用get之前永远不会启动,但是除非任务启动并返回一个值,future 永远不会准备好;所以我们会在while循环中永远旋转。

在使用std::async创建任务时,我们也可以省略启动策略:

auto future = std::async([]() {...}, arg1, arg2);

在这种情况下,行为等同于以下调用:

auto future = std::async(std::launch::async|std::launch::deferred,
                          []() {...}, arg1, arg2);

实现可以选择符合std::launch::asyncstd::launch::deferred的行为。此外,只有在运行时库需要支持多线程的情况下,实现才会创建一个新线程并链接到程序。使用默认策略时,当启用多线程时,std::async要么在新线程中启动新任务,要么将它们发布到内部线程池。如果线程池中没有空闲线程或空闲核心,任务将被同步启动。

基于锁的线程同步方法

到目前为止,我们已经看到了如何使用boost::threadstd::thread委托函数在单独的线程上运行。我们看到了使用boost::futureboost::promise在线程之间通信结果和异常,并通过阻塞调用在任务之间施加顺序。有时,您可以将程序分解为可以并发运行的独立任务,产生一个值、一个副作用或两者,然后由程序的另一部分消耗。启动这样的任务并使用 futures 等待它们是一种有效的策略。一旦任务返回,您可以开始下一个消耗第一阶段结果的计算阶段。

然而,通常需要多个线程同时访问和修改相同的数据结构。这些访问需要可靠地排序并且相互隔离,以防止由于不协调的并发访问导致底层数据结构中出现不一致。在本节中,我们将看一下帮助我们解决这些问题的 Boost 库。

数据竞争和原子操作

考虑以下代码片段。我们创建两个线程,每个线程在循环中递增一个共享的整数变量固定次数:

int main() {
  int x = 0;
  const int max = 1000000;

  auto thrFunc = [&x]() {
                          for (int i = 0; i < max; ++i) {
                            ++x;
                          }
                        };

  boost::thread t1(thrFunc);
  boost::thread t2(thrFunc);
  t1.join();
  t2.join();

  std::cout << "Value of x: " << x << '\n';
}

程序结束时x的值是多少?由于每个线程对x递增了一百万次,而且有两个线程,人们可能期望它是2000000。你可以自行验证,递增运算符在x上被调用的次数不少于N*max次,其中N=2是线程数,max是一百万。然而,我看到2000000被打印出来不止一次;每次都是一个较小的数字。这种行为可能会因操作系统和硬件而有所不同,但它是相当常见的。显然,一些递增操作没有生效。

当你意识到操作++x涉及读取x的值,将一个添加到该值,然后将结果写回x时,原因就变得清楚了。假设x的值是V,两个线程对V执行操作++x。两个线程中的每一个都可以将 V 读取为x的值,执行递增操作,然后将 V+1 写回。因此,两个线程分别对x进行一次递增操作后,x的值仍然可能是如果只递增了一次。根据机器架构的不同,对于某些“原始”数据类型,更新变量的值可能需要两个 CPU 指令。并发执行两个这样的操作可能会由于部分写入而将值设置为两者都不想要的值。

像这样交错的操作代表了数据竞争—执行它们的线程被认为在执行操作步骤及其确切顺序上相互竞争,因此结果是不可预测的。

让我们使用符号[r=v1,w=v2]来表示一个线程从变量x读取值 v1 并写回值 v2。请注意,在线程读取变量x的值和写回值之间可能有任意长的持续时间。因此,符号[r=v1,…用于表示已经读取了值 v1,但尚未进行写回,符号…w=v2]表示待定的写回已经发生。现在考虑两个线程分别对x进行一百万次递增操作,如下所示:

数据竞争和原子操作

为简单起见,假设部分写入是不可能发生的。在时间t1,线程 1 和线程 2 都将变量x的值读取为 0。线程 2 递增这个值,并将值写回为 1。线程 2 继续读取和递增x的值 999998 次,直到在时间t999999写回值 999999。之后,线程 1 递增了它在t1读取的值 0,并将值写回为 1。接下来,线程 1 和线程 2 都读取了值 1,线程 1 写回 2,但线程 2 挂起。线程 1 继续进行 999998 次迭代,读取和递增x的值。它在时间t1999999将值 1000000 写入x并退出。线程 2 现在递增了它在t1000001读取的值 1 并写回。对于两百万次递增,x的最终值可能是 2。你可以将迭代次数更改为大于或等于 2 的任意数字,将线程数更改为大于或等于 2 的任意数字,这个结果仍然成立——这是并发的不确定性和非直观方面的一种度量。当我们看到操作++x时,我们直观地认为它是一个不可分割的或原子操作,但实际上并非如此。

原子操作在没有任何可观察的中间状态的情况下运行。这些操作不能交错。原子操作创建的中间状态对其他线程不可见。机器架构提供了执行原子读取-修改-写入操作的特殊指令,操作系统通常提供了使用这些原语的原子类型和操作的库接口。

增量操作++x显然是不可重入的。变量x是一个共享资源,在一个线程的读取、增量和随后的写入x之间,其他线程可以进行任意数量的读取-修改-写入操作——这些操作可以交错进行。对于这样的不可重入操作,我们必须找到使它们线程安全的方法,即通过防止多个线程之间的操作交错,比如++x

互斥排斥和临界区

使++x操作线程安全的一种方法是在临界区中执行它。临界区是一段代码,不能同时被两个不同的线程执行。因此,来自不同线程的两次对x的增量可以交错进行。线程必须遵守这个协议,并且可以使用互斥对象来实现。互斥对象是用于同步并发访问共享资源的原语,比如变量x。我们在这个示例中使用boost::mutex类来实现这一目的,如下例所示:

清单 10.10:使用互斥对象

 1 #include <boost/thread/thread.hpp>
 2 #include <boost/thread/mutex.hpp>
 3 #include <iostream>
 4
 5 int main()
 6 {
 7   int x = 0;
 8   static const int max = 1000000;
 9   boost::mutex mtx;
10
11   auto thrFunc = [&x, &mtx]() {
12     for (int i = 0; i < max; ++i) {
13       mtx.lock();
14       ++x;
15       mtx.unlock();
16     }
17   };
18
19   boost::thread t1(thrFunc);
20   boost::thread t2(thrFunc);
21
22   t1.join();
23   t2.join();
24
25   std::cout << "Value of x: " << x << '\n';
26 }

我们声明了一个boost::mutex类型的互斥对象(第 9 行),在生成线程的初始函数的 lambda 中捕获它(第 11 行),然后在执行增量操作之前通过锁定互斥对象来保护变量x(第 13 行),并在之后解锁它(第 15 行)。对x的增量操作(第 14 行)是临界区。这段代码每次都会打印以下内容:

2000000

这是如何工作的?互斥对象有两种状态:锁定未锁定。第一个调用未锁定互斥对象的lock成员函数的线程会锁定它,并且lock的调用会返回。其他调用已锁定互斥对象的lock的线程会阻塞,这意味着操作系统调度程序不会安排这些线程运行,除非发生某些事件(比如所讨论的互斥对象解锁)。然后持有锁的线程增加x并调用互斥对象的unlock成员函数来释放它持有的锁。此时,阻塞在lock调用中的一个线程会被唤醒,该线程的lock调用返回,并且该线程被安排运行。等待唤醒的线程取决于底层的本地实现。这一过程会一直持续,直到所有线程(在我们的示例中,只有两个)都运行完成。锁确保在任何时刻,只有一个线程独占持有锁,并且可以自由地增加x

我们选择用互斥对象保护的部分是关键的。我们也可以选择保护整个 for 循环,就像下面的代码片段所示:

12     mtx.lock();
13     for (int i = 0; i < max; ++i) {
14       ++x;
15     }
16     mtx.unlock();

x的最终值仍然与 10.10 清单中一样(2000000),但临界区会更大(第 13-15 行)。一个线程会在另一个线程甚至只能增加x一次之前运行完整个循环。通过限制临界区的范围和线程持有锁的时间,多个线程可以取得更加公平的进展。

一个线程也可以选择探测并查看是否可以获取互斥对象的锁,但如果不能则不阻塞。为此,线程必须调用try_lock成员函数而不是lock成员函数。调用try_lock会在互斥对象被锁定时返回true,否则返回false,并且如果互斥对象未被锁定则不会阻塞:

boost::mutex mtx;
if (mtx.try_lock()) {
  std::cout << "Acquired lock\n";
} else {
  std::cout << "Failed to acquire lock\n";
}

一个线程也可以选择在等待获取锁时阻塞指定的持续时间,使用try_lock_for成员函数。如果成功获取锁并且一旦获取锁,try_lock_for的调用会返回true。否则,它会在指定持续时间内阻塞,并且一旦超时而未获取锁则返回 false:

boost::mutex mtx;
if (mtx.try_lock_for(boost::chrono::seconds(5))) { 
  std::cout << "Acquired lock\n";
} else {
  std::cout << "Failed to acquire lock\n";
}

注意

互斥对象应该在尽可能短的时间内持有,覆盖尽可能小的代码段。由于互斥对象串行化了临界区的执行,持有互斥对象的时间越长,等待锁定互斥对象的其他线程的进展就会延迟。

boost::lock_guard

在互斥锁上获取锁并未能释放它是灾难性的,因为任何其他等待互斥锁的线程都将无法取得任何进展。在互斥锁上的裸lock / try_lockunlock调用并不是一个好主意,我们需要一些在异常安全方式下锁定和解锁互斥锁的方法。boost::lock_guard<>模板使用资源获取即初始化RAII)范式在其构造函数和析构函数中锁定和解锁互斥锁:

列表 10.11:使用 boost::lock_guard

 1 #include <boost/thread/thread.hpp>
 2 #include <boost/thread/mutex.hpp>
 3 #include <iostream>
 4
 5 int main()
 6 {
 7   int x = 0;
 8   static const int max = 1000000;
 9   boost::mutex mtx;
10
11   auto thrFunc = [&x, &mtx]() {
12     for (int i = 0; i < max; ++i) {
13       boost::lock_guard<boost::mutex> lg(mtx);
14       ++x;
16     }
17   };
18
19   boost::thread t1(thrFunc);
20   boost::thread t2(thrFunc);
21
22   t1.join();
23   t2.join();
24
25   std::cout << "Value of x: " << x << '\n';
26 }

使用boost::lock_guard对象(第 13 行),我们锁定在锁保护实例化后的代码部分,直到作用域结束。lock_guard在构造函数中获取锁,并在析构函数中释放锁。这确保即使在关键部分出现异常,一旦作用域退出,互斥锁总是被解锁。您将锁的类型作为模板参数传递给lock_guardboost::lock_guard不仅可以与boost::mutex一起使用,还可以与符合BasicLockable概念的任何类型一起使用,即具有可访问的lockunlock成员函数。

我们还可以使用boost::lock_guard来封装已经锁定的互斥锁。为此,我们需要向lock_guard构造函数传递第二个参数,指示它应该假定拥有互斥锁而不尝试锁定它:

 1 boost::mutex mtx;
 2 ...
 3 mtx.lock();  // mutex locked
 4 ...
 5 {
 6   boost::lock_guard<boost::mutex> lk(mtx, boost::adopt_lock);
 7   ...
 8 } // end of scope

boost::lock_guard在其构造函数中锁定底层互斥锁,或者采用已经锁定的互斥锁。释放互斥锁的唯一方法是让lock_guard超出作用域。lock_guard既不可复制也不可移动,因此您不能将它们从一个函数传递到另一个函数,也不能将它们存储在容器中。您不能使用lock_guard等待特定持续时间的互斥锁。

boost::unique_lock

boost::unique_lock<>模板是一种更灵活的替代方案,它仍然使用 RAII 来管理类似互斥锁,但提供了手动锁定和解锁的接口。为了获得这种额外的灵活性,unique_lock必须维护一个额外的数据成员,以跟踪互斥锁是否被线程拥有。我们可以使用unique_lock来管理符合Lockable概念的任何类。如果一个类符合 Lockable 概念,那么它符合 BasicLockable,并且另外定义了一个可访问的try_lock成员函数,就像boost::mutex一样。

我们可以将boost::unique_lock用作boost::lock_guard的替代品,但是如果lock_guard足够用于某个目的,则不应使用unique_lock。当我们想要将手动锁定与异常安全的锁管理混合使用时,unique_lock通常很有用。例如,我们可以重写列表 10.11 以使用unique_lock,如下面的代码片段所示:

 7   int x = 0;
 8   static const int max = 1000000;
 9   boost::mutex mtx;
10
11   auto thrFunc = [&x, &mtx]() {
12     boost::unique_lock<boost::mutex> ul(mtx, boost::defer_lock);
13     assert(!ul.owns_lock());
14
15     for (int i = 0; i < max; ++i) {
16       ul.lock();
17       ++x;
18       assert(ul.owns_lock());
19       assert(ul.mutex() == &mtx);
20
21       ul.unlock();
22     }
23   };

与列表 10.11 不同,我们不会在每次循环迭代中创建一个新的lock_guard对象。相反,我们在循环开始之前创建一个封装互斥锁的单个unique_lock对象(第 12 行)。传递给unique_lock构造函数的boost::defer_lock参数告诉构造函数不要立即锁定互斥锁。在调用unique_locklock成员函数(第 16 行)增加共享变量之前,互斥锁被锁定,并且在操作之后通过调用unique_lockunlock成员函数(第 21 行)解锁。在发生异常时,如果互斥锁被锁定,unique_lock析构函数将解锁互斥锁。

unique_lockowns_lock成员函数在unique_lock拥有互斥锁时返回true,否则返回false(第 13 行和第 18 行)。unique_lockmutex成员函数返回存储的互斥锁的指针(第 19 行),如果unique_lock没有包装有效的互斥锁,则返回nullptr

死锁

互斥锁提供了对共享资源的独占所有权,而许多现实世界的问题涉及多个共享资源。以多人第一人称射击游戏为例。它实时维护和更新两个列表。一个是 A 组射手,他们是带有某种弹药的玩家,另一个是 U 组玩家,他们是手无寸铁的。当玩家用尽弹药时,她会从 A 组移动到 U 组。当她的弹药补充时,她会从 U 组移回 A 组。线程 1 负责将元素从 A 组移动到 U 组,线程 2 负责将元素从 U 组移动到 A 组。

当一个新玩家加入游戏时,她会被添加到 U 组或 A 组,具体取决于她是否有弹药。当玩家在游戏中被杀死时,她会从 U 组或 A 组中被移除。但当弹药用尽或补充时,玩家会在 U 组和 A 组之间移动;因此 U 组和 A 组都需要被编辑。考虑以下代码,其中一个线程负责在弹药用尽时将玩家从 A 组移动到 U 组,另一个线程负责在弹药补充时将玩家从 U 组移回 A 组:

清单 10.12:死锁示例

 1 #include <iostream>
 2 #include <cstdlib>
 3 #include <ctime>
 4 #include <set>
 5 #include <boost/thread.hpp>
 6
 7 struct player {
 8   int id;
 9   // other fields
10   bool operator < (const player& that) const {
11     return id < that.id;
12   }
13 };
14
15 std::set<player> armed, unarmed; // A, U
16 boost::mutex amtx, umtx;
17
18 auto a2u = & {
19         boost::lock_guard<boost::mutex> lka(amtx);
20         auto it = armed.find(player{playerId}); 
21         if (it != armed.end()) {
22           auto plyr = *it;
23           boost::unique_lock<boost::mutex> lku(umtx);
24           unarmed.insert(plyr);
25           lku.unlock();
26           armed.erase(it);
27         }
28       };
29
30 auto u2a = & {
31         boost::lock_guard<boost::mutex> lku(umtx);
32         auto it = unarmed.find(player{playerId});
33         if (it != unarmed.end()) {
34           auto plyr = *it;
35           boost::unique_lock<boost::mutex> lka(amtx);
36           armed.insert(plyr);
37           lka.unlock();
38           unarmed.erase(it);
39         }
40       };
41
42 void onAmmoExhausted(int playerId) { // event callback
43   boost::thread exhausted(a2u, playerId);
44   exhausted.detach();
45 }
46
47 void onAmmoReplenished(int playerId) { // event callback
48   boost::thread replenished(a2u, playerId);
49   replenished.detach();
50 }

每当玩家的弹药用尽时,都会调用onAmmoExhausted(第 42 行)函数,并传递玩家的 ID。这个函数创建一个线程来运行a2u函数(第 18 行),将这个玩家从 A 组(武装)移动到 U 组(非武装)。同样,当玩家的弹药补充时,会调用onAmmoReplenished(第 47 行)函数,然后在一个单独的线程中运行u2a函数,将玩家从 U 组(非武装)移动到 A 组(武装)。

互斥锁amtxumtx控制着对armedunarmed组的访问。要将玩家从 A 组移动到 U 组,函数a2u首先获取amtx的锁(第 19 行),然后在armed中查找玩家(第 20 行)。如果找到了玩家,线程会在umtx上获取锁(第 23 行),将玩家放入unarmed(第 23 行),释放umtx上的锁(第 24 行),并从armed中移除玩家(第 25 行)。

函数u2a本质上具有相同的逻辑,但首先获取umtx的锁,然后是amtx,这导致了一个致命的缺陷。如果一个玩家在大约相同的时间内用尽弹药,另一个玩家补充弹药,两个线程可能会同时运行a2uu2a。也许很少见,但可能发生的是,exhausted线程锁定了amtx(第 19 行),但在它可以锁定umtx(第 23 行)之前,replenished线程锁定了umtx(第 31 行)。现在,exhausted线程等待umtx,而umtxreplenished线程持有,而replenished线程等待amtx,而amtxexhausted线程持有。这两个线程没有任何可能的方式可以从这种状态中继续,它们陷入了死锁。

死锁是指两个或更多个线程竞争共享资源时被阻塞,它们在等待某些资源的同时持有其他资源,以至于任何一个线程都不可能从这种状态中前进。

在我们的例子中,只涉及了两个线程,相对容易调试和修复问题。修复死锁的黄金标准是确保固定的锁获取顺序——任何线程以相同的顺序获取两个给定的锁。通过重写u2a,如下面的代码片段所示,我们可以确保不会发生死锁:

30 auto u2a = & {
31     boost::unique_lock<boost::mutex> 
32       lka(amtx, boost::defer_lock),
33       lku(umtx, boost::defer_lock);
34                                              
35     boost::lock(lka, lku);  // ordered locking
36     auto it = unarmed.find(player{playerId});
37     if (it != unarmed.end()) {
38       auto plyr = *it;
39       armed.insert(plyr);
40       lka.unlock();
41       unarmed.erase(it);
42     }
43   };

在前面的代码中,我们确保u2a在锁定umtx之前先锁定amtx,就像a2u一样。我们本可以手动按照这个顺序获取锁,但相反,我们演示了使用boost::lock来实现这一点。我们创建了unique_lock对象lkalku,并使用defer_lock标志来指示我们暂时不想获取锁。然后我们调用boost::lock,按照我们想要获取它们的顺序传递unique_lockboost::lock确保了这个顺序被遵守。

在这个例子中,使用boost::unique_lock而不是boost::lock_guard有两个原因。首先,我们可以创建unique_lock而不立即锁定互斥锁。其次,我们可以调用unlock提前释放unique_lock(第 40 行),增加锁的粒度,促进并发。

除了固定的锁获取顺序,避免死锁的另一种方法是让线程探测锁(使用try_lock),如果未能获取特定锁,则回溯。这通常会使代码更复杂,但有时可能是必要的。

有许多现实世界的代码示例出现死锁,就像我们例子中的代码一样,可能多年来一直正常工作,但其中潜藏着死锁。有时,在一个系统上运行时命中死锁的概率可能非常低,但当你在另一个系统上运行相同的代码时,可能会立即遇到死锁,这纯粹是因为两个系统上的线程调度差异。

在条件上进行同步

互斥锁通过创建临界区来串行访问共享数据。临界区就像一个带有锁和外部等待区的房间。一个线程获取锁并占据房间,而其他线程在外面等待,等待占有者离开房间,然后按照某种定义好的顺序取代它的位置。有时,线程需要等待条件变为真,比如一些共享数据改变状态。让我们看看生产者-消费者问题,看看线程等待条件的例子。

条件变量和生产者-消费者问题

Unix 命令行实用程序grep使用正则表达式在文件中搜索文本模式。它可以搜索整个文件列表。要在文件中搜索模式,必须读取完整内容并搜索模式。根据要搜索的文件数量,可以使用一个或多个线程并发地将文件内容读入缓冲区。缓冲区可以存储在某种数据结构中,通过文件和偏移量对其进行索引。然后多个线程可以处理这些缓冲区并搜索其中的模式。

我们刚刚描述的是生产者-消费者问题的一个例子,其中一组线程生成一些内容并将其放入数据结构中,第二组线程从数据结构中读取内容,并对其进行计算。如果数据结构为空,消费者必须等待,直到生产者添加一些内容。如果数据填满了数据结构,那么生产者必须等待消费者处理一些数据,并在尝试添加更多内容之前在数据结构中腾出空间。换句话说,消费者等待某些条件得到满足,这些条件是由生产者的行为导致的,反之亦然。

模拟这种条件、等待它们并发出信号的一种方法是使用boost::condition_variable对象。条件变量与程序中可测试的运行时条件或谓词相关联。线程测试条件,如果条件不成立,则线程使用condition_variable对象等待条件成立。导致条件成立的另一个线程发出条件变量的信号,这会唤醒一个或多个等待的线程。条件变量与共享数据固有相关,并表示共享数据的某个条件被满足。为了让等待的线程首先测试共享数据的条件,它必须获取互斥锁。为了让发出信号的线程改变共享数据的状态,它也需要互斥锁。为了让等待的线程醒来并验证变化的结果,它再次需要互斥锁。因此,我们需要使用boost::mutexboost::condition_variable结合使用。

现在,我们将使用条件变量解决固定大小队列的生产者-消费者问题。队列的大小是固定的,这意味着队列中的元素数量是有限的。一个或多个线程生产内容并将其入队(追加到队列)。一个或多个线程出队内容(从队列头部移除内容)并对内容进行计算。我们使用在固定大小的boost::array上实现的循环队列,而不是任何 STL 数据结构,如std::liststd::deque

清单 10.13:使用条件变量实现线程安全的固定大小队列

 1 #include <boost/thread/thread.hpp>
 2 #include <boost/thread/mutex.hpp>
 3 #include <boost/thread/condition_variable.hpp>
 4 #include <boost/array.hpp>
 5
 6 template <typename T, size_t maxsize>
 7 struct CircularQueue
 8 {
 9   CircularQueue () : head_(0), tail_(0) {}
10
11   void pop() {
12     boost::unique_lock<boost::mutex> lock(qlock);
13     if (size() == 0) {
14       canRead.wait(lock, [this] { return size() > 0; });
15     }
16     ++head_;
17     lock.unlock();
18     canWrite.notify_one();
19   }
20
21   T top() {
22     boost::unique_lock<boost::mutex> lock(qlock);
23    if (size() == 0) {
24       canRead.wait(lock, [this] { return size() > 0; });
25     }
26     T ret = data[head_ % maxsize];
27     lock.unlock();
28
29     return ret;
30   }
31
32   void push(T&& obj) {
33     boost::unique_lock<boost::mutex> lock(qlock);
34     if (size() == capacity()) {
35       canWrite.wait(lock, [this] 
36                         { return size() < capacity(); });
37     }
38     data[tail_++ % maxsize] = std::move(obj);
39     lock.unlock();
40     canRead.notify_one();
41   }
42
43   size_t head() const { return head_; }
44   size_t tail() const { return tail_; }
45
46   size_t count() const {
47     boost::unique_lock<boost::mutex> lock(qlock);
48     return (tail_ - head_); 
49   }
50
51 private:
52   boost::array<T, maxsize> data;
53   size_t head_, tail_;
54 
55   size_t capacity() const { return maxsize; }
56   size_t size() const { return (tail_ - head_); };
57
58   mutable boost::mutex qlock;
59   mutable boost::condition_variable canRead;
60   mutable boost::condition_variable canWrite;
61 };
62
63 int main()
64 {
65   CircularQueue<int, 200> ds;
66
67   boost::thread producer([&ds] {
68             for (int i = 0; i < 10000; ++i) {
69               ds.push(std::move(i));
70               std::cout << i << "-->"
71                   << " [" << ds.count() << "]\n";
72             }
73          });
74
75   auto func = [&ds] {
76     for (int i = 0; i < 2500; ++i) {
77       std::cout << "\t\t<--" << ds.top() << "\n";
78       ds.pop();
79     }
80   };
81
82   boost::thread_group consumers;
83   for (int i = 0; i < 4; ++i) {
84     consumers.create_thread(func);
85   }
86 
87   producer.join();
88   consumers.join_all();
89 }

在这个清单中,我们定义了CircularQueue<>模板及其成员函数,包括特别感兴趣的pop(第 11 行)和push(第 32 行)成员函数。调用push会阻塞,直到队列中有空间添加新元素。调用pop会阻塞,直到能够从队列顶部读取并移除一个元素。实用函数top(第 21 行)会阻塞,直到能够从队列顶部读取一个元素,并返回其副本。

为了实现必要的同步,我们定义了互斥锁qlock(第 58 行)和两个条件变量,canRead(第 59 行)和canWrite(第 60 行)。canRead条件变量与一个检查队列中是否有可读元素的谓词相关联。canWrite条件变量与一个检查队列中是否还有空间可以添加新元素的谓词相关联。编辑队列和以任何方式检查队列状态都需要锁定qlock互斥锁。

pop方法首先在qlock(第 12 行)上获取锁,然后检查队列是否为空(第 13 行)。如果队列为空,调用必须阻塞,直到有可读取的项目为止。为此,pop调用canRead条件变量上的wait方法,传递锁lock和一个 lambda 谓词进行测试(第 14 行)。调用wait会解锁lock中的互斥锁并阻塞。如果另一个线程的push方法调用成功并且数据可用,push方法会解锁互斥锁(第 39 行)并通过调用notify_one方法(第 40 行)通知canRead条件变量。这会唤醒在pop方法调用内部的wait调用中阻塞的一个线程。wait调用会原子性地锁定互斥锁,检查谓词(size() > 0)是否为真,如果是,则返回(第 14 行)。如果谓词不为真,则再次解锁互斥锁并返回等待。

pop方法要么从等待中唤醒,并在重新获取互斥锁后验证是否有要读取的元素,要么根本不需要等待,因为已经有要读取的元素。因此,pop继续移除列表头部的元素(第 16 行)。在移除元素后,它会解锁互斥锁(第 17 行)并在canWrite条件上调用notify_one(第 18 行)。如果它从一个满队列中弹出一个元素,并且有线程在push中阻塞,等待队列中的空间,那么调用notify_one会唤醒在push内部的canWrite.wait(...)中阻塞的一个线程(第 35 行),并给它添加一个项目到队列的机会。

push的实现实际上是对称的,并使用了我们描述的pop相同的概念。我们将互斥锁传递给条件变量上的wait方法,用unique_lock包装而不是lock_guard,因为wait方法需要手动访问底层互斥锁进行解锁。通过调用unique_lockmutex成员函数从unique_lock中检索底层互斥锁;lock_guard不提供这样的机制。

为了测试我们的实现,我们创建了一个包含 200 个int类型元素的CircularQueue(第 65 行),一个将 10,000 个元素推入队列的生产者线程(第 67 行),以及四个每个弹出 2,500 个元素的消费者线程(第 82-85 行)。

消费者线程不是单独创建的,而是作为线程组的一部分创建的。线程组是boost::thread_group类型的对象,它提供了一种管理多个线程的简单方法。由于我们想要使用相同的初始函数创建四个消费者线程并将它们全部加入,因此很容易创建一个thread_group对象(第 82 行),使用其create_thread成员函数在循环中创建四个线程(第 84 行),并通过调用join_all方法等待组中的所有线程(第 88 行)。

条件变量细微差别

我们调用notify_one来通知canRead条件变量并唤醒等待读取的一个线程(第 39 行)。相反,我们可以调用notify_all广播事件并唤醒所有等待的线程,它仍然可以工作。但是,我们每次调用push时只向队列中放入一个新元素,因此被唤醒的线程中只有一个会从队列中读取新元素。其他线程会检查队列中的元素数量,发现它为空,然后回到等待状态,导致不必要的上下文切换。

但是,如果我们向队列中添加了大量元素,调用notify_all可能比notify_one更好。调用notify_one只会唤醒一个等待的线程,它会在循环中逐个处理元素(第 63-65 行)。调用notify_all会唤醒所有线程,它们会并发地更快地处理元素。

一个常见的难题是在持有互斥锁时是否调用notify_one/notify_all,就像我们之前的例子中所做的那样,还是在释放锁之后。这两种选择都同样有效,但在性能上可能会有一些差异。如果在持有互斥锁时发出条件变量信号,被唤醒的线程会立即阻塞,等待释放锁。因此,每个线程会有两次额外的上下文切换,这可能会影响性能。因此,如果在发出条件变量信号之前先解锁互斥锁,可能会带来一些性能优势。因此,通常更倾向于在解锁之后发出信号。

读者-写者问题

以图书馆的在线目录为例。图书馆维护一张书籍查找表。为简单起见,让我们假设书籍只能通过标题查找,并且标题是唯一的。代表各种客户端的多个线程同时在图书馆进行查找。图书管理员不时地向目录中添加新书,很少从目录中取走一本书。只有在没有相同标题的书籍或者存在旧版标题时,才能添加新书。

在下面的代码片段中,我们定义了一个表示书目条目的类型,以及代表图书馆目录的LibraryCatalog类的公共接口:

清单 10.14a:图书馆目录类型和接口

 1 struct book_t
 2 {
 3   std::string title;
 4   std::string author;
 5   int edition;
 6 };
 7
 8 class LibraryCatalog
 9 {
10 public:
11   typedef boost::unordered_map<std::string, book_t> map_type;
12   typedef std::vector<book_t> booklist_t;
13
14   boost::optional<book_t> find_book(const std::string& title) 
15                                                       const;
16   booklist_t find_books(const std::vector<std::string>& 
17                                            titles) const;
18   bool add_book(const book_t& book);
19   bool remove_book(const std::string& title);
20 };

成员函数find_book用于查找单个标题,并将其作为book_t对象包装在boost::optional中返回。使用boost::optional,如果找不到标题,我们可以返回一个空值(见第二章,“与 Boost 实用工具的初次接触”)。成员函数find_books查找作为vector传递给它的标题列表,并返回book_t对象的向量。成员函数add_book向目录中添加标题,remove_book从目录中删除标题。

我们希望实现该类以允许多个线程同时查找标题。我们还希望允许图书管理员在读取时并发地添加和删除标题,而不会影响正确性或一致性。

只要目录中的数据不发生变化,多个线程可以同时查找标题,而无需任何同步;因为只读操作不会引入不一致性。但由于目录允许图书管理员添加和删除标题,我们必须确保这些操作不会与读操作交错。在这样制定我们的要求时,我们刚刚陈述了众所周知的并发问题,即读者-写者问题。读者-写者问题规定了以下约束:

  • 任何写线程必须对数据结构进行排他访问

  • 在没有写入线程的情况下,任何读取线程都可以与其他读取线程共享对数据结构的访问。

在上述语句中,读取线程指的是只执行只读操作的线程,比如查找标题,写入线程指的是以某种方式修改数据结构内容的线程,比如添加和删除标题。这有时被称为多读者单写者MRSW)模型,因为它允许多个并发读者或单个独占写者。

虽然boost::mutex允许单个线程获取排他锁,但它不允许多个线程共享锁。我们需要使用boost::shared_mutex来实现这一目的。boost::shared_mutex符合SharedLockable概念,它包含 Lockable 概念,并且另外定义了lock_sharedunlock_shared成员函数,应该由读取线程调用。因为shared_mutex也符合 Lockable,所以可以使用boost::lock_guardboost::unique_lock来对其进行排他访问。现在让我们来看一下LibraryCatalog的实现:

清单 10.14b:图书馆目录实现

 1 #include <vector>
 2 #include <string>
 3 #include <boost/thread.hpp>
 4 #include <boost/optional.hpp>
 5 #include <boost/unordered/unordered_map.hpp>
 6
 7 struct book_t { /* definitions */ };
 8
 9
10 class LibraryCatalog {
11 public:
12   typedef boost::unordered_map<std::string, book_t> map_type;
13   typedef std::vector<book_t> booklist_t;
14
15   boost::optional<book_t> find_book(const std::string& title)
16                                                       const {
17     boost::shared_lock<boost::shared_mutex> rdlock(mtx);
18     auto it = catalog.find(title);
19
20     if (it != catalog.end()) {
21       return it->second;
22     }
23     rdlock.unlock();
24
25     return boost::none;
26   }
27
28   booklist_t find_books(const std::vector<std::string>& titles)
29                                                         const {
30     booklist_t result;
31     for (auto title : titles) {
32       auto book = find_book(title);
33
34       if (book) {
35         result.push_back(book.get());
36       }
37     }
38
39     return result;
40   }
41
42   bool add_book(const book_t& book) {
43     boost::unique_lock<boost::shared_mutex> wrlock(mtx);
44     auto it = catalog.find(book.title);
45
46     if (it == catalog.end()) {
47       catalog[book.title] = book;
48       return true;
49     }
50     else if (it->second.edition < book.edition) {
51       it->second = book;
52       return true;
53     }
54
55     return false;
56   }
57
58   bool remove_book(const std::string& title) {
59     boost::unique_lock<boost::shared_mutex> wrlock(mtx);
60     return catalog.erase(title);
61   }
62
63 private:
64   map_type catalog;
65   mutable boost::shared_mutex mtx;
66 };

方法find_book对目录执行只读操作,因此使用boost::shared_lock模板(第 17 行)获取共享锁。在检索到匹配的书籍后释放锁(第 23 行)。方法find_books是根据find_book实现的,它在传递给它的列表中的每个标题上调用循环中的find_book。这允许更好地在读取线程之间实现整体并发性,但会因为重复锁定和解锁shared_mutex而导致轻微的性能损失。

add_bookremove_book都是可能改变目录中元素数量的变异函数。为了修改目录,这两种方法都需要对目录进行排他性或写入锁定。因此,我们使用unique_lock实例来获取shared_mutex(第 43 行和第 59 行)上的排他锁。

可升级的锁

在清单 10.14b 中add_bookremove_book方法的实现中存在一个明显的问题。这两种方法都是有条件地修改目录,根据首先运行的查找的结果。然而,在这两个操作的开始处无条件地获取了排他锁。可以想象,可能会在循环中调用remove_book,并严重阻碍系统的并发性,因为标题不存在,或者使用已经在目录中的书的版本调用add_book

如果我们获取了共享锁来执行查找,那么在获取排他锁修改目录之前,我们必须释放它。在这种情况下,查找的结果将不再可靠,因为在释放共享锁和获取排他锁之间,其他线程可能已经修改了目录。

这个问题可以通过使用boost::upgrade_lock和一组相关的原语来解决。这在以下add_book的重写中显示:

 1 bool LibraryCatalog::add_book(const book_t& book) {
 2   boost::upgrade_lock<boost::shared_mutex> upglock(mtx);
 3   auto it = catalog.find(book.title);
 4
 5   if (it == catalog.end()) {
 6     boost::upgrade_to_unique_lock<boost::shared_mutex> 
 7                                             ulock(upglock);
 8     catalog[book.title] = book;
 9     return true;
10   } else if (it->second.edition > book.edition) {
11     boost::upgrade_to_unique_lock<boost::shared_mutex> 
12                                             ulock(upglock);
13     it->second = book;
14     return true;
15   }
16
17   return false;
18 }

我们不是从一开始就获取独占锁,而是在执行查找之前获取升级锁(第 2 行),然后只有在需要修改目录时才将其升级为唯一锁(第 6-7 行和第 11-12 行)。要获取升级锁,我们将共享互斥量包装在upgrade_lock<boost::shared_mutex>实例中(第 2 行)。如果互斥量上有独占锁或另一个升级锁在生效,则会阻塞,但否则即使有共享锁也会继续。因此,在任何时间点,互斥量上可以有任意数量的共享锁,最多只能有一个升级锁。因此,获取升级锁不会影响读并发性。一旦执行查找,并确定需要执行写操作,升级锁就会通过将其包装在upgrade_to_unique_lock<boost::shared_mutex>实例中(第 6-7 行和第 11-12 行)来升级为唯一锁。这会阻塞,直到没有剩余的共享锁,然后原子地释放升级所有权并在shared_mutex上获取独占所有权。

注意

获取升级锁表示有可能将其升级为独占锁并执行写入或修改。

共享互斥量的性能

boost::shared_mutexboost::mutex慢,但在已经被读锁定的互斥量上获取额外的读锁要快得多。它非常适合频繁的并发读取,很少需要独占写访问。每当需要频繁写入时,只需使用boost::mutex来提供独占写访问。

大多数 MRSW 问题的解决方案要么偏向读取者,要么偏向写入者。在偏向读取的解决方案中,当共享锁生效时,新的读取线程可以获取共享锁,即使有一个等待获取独占锁的写入者。这导致写入者饥饿,因为写入者只有在没有读取者时才能获取独占锁。在偏向写入的解决方案中,如果有一个写入者线程在等待独占锁,那么即使现有的读取者持有共享锁,新的读取者也会排队。这会影响读取的并发性。Boost 1.57(当前版本)提供了一个完全公平的共享/独占锁实现,既不偏向读取者也不偏向写入者。

标准库原语

C++11 标准库引入了std::mutex和一整套用于锁的 RAII 包装器,包括std::lock_guardstd::unique_lockstd::lock,都在头文件mutex中可用。C++11 标准库还引入了std::condition_variable,可在头文件condition_variable中使用。C++14 标准库引入了std::shared_timed_mutex,对应于boost::shared_mutexstd::shared_lock,都在头文件mutex中可用。它们对应于它们的同名 Boost 对应物,并且具有非常相似的接口。截至 C++14,标准库中没有升级锁设施,也没有方便的boost::thread_group的等效物。

Boost 协程

协程是可以yield或放弃控制权给另一个协程的函数,然后再次获得控制权,从之前放弃控制权的地方继续执行。自动变量的状态在 yield 和恢复之间保持不变。协程可用于复杂的控制流模式,代码既简单又清晰。Boost 协程库提供了两种类型的协程:

  • 非对称协程:非对称协程区分调用者和被调用者协程。使用非对称协程时,被调用者只能向调用者产生输出。它们通常用于从被调用者到调用者的单向数据传输,或者反之亦然。

  • 对称协程:这种协程可以yield给其他协程,不管调用者是谁。它们可以用于生成复杂的协作协程链。

当协程放弃控制时,它被挂起,即它的寄存器被保存,并且它放弃控制给另一个函数。在恢复时,寄存器被恢复,执行继续到挂起点之后。Boost Coroutine 库利用 Boost Context 库来实现这一目的。

堆栈协程无堆栈协程之间有区别。堆栈协程可以从由协程调用的函数中挂起,也就是说,从嵌套的堆栈帧中挂起。对于无堆栈协程,只有顶层例程可以挂起自己。在本章中,我们只关注不对称的堆栈协程。

不对称协程

用于定义不对称协程的核心模板称为boost::coroutines::asymmetric_coroutine<>。它接受一个表示从一个协程传输到另一个协程的值类型参数。如果不需要传输值,可以是void

调用其他协程或向它们产出数据的协程必须有一种方式来引用其他协程。嵌套类型asymmetric_coroutine<T>::push_type表示提供类型为T的数据的协程,而嵌套类型asymmetric_coroutine<T>::pull_type表示消耗类型为T的数据的协程。这两种类型都是可调用类型,具有重载的operator()。使用这些类型,我们现在将编写一个程序,使用协程从元素的向量中读取数据:

清单 10.15:使用不对称协程

 1 #include <iostream>
 2 #include <boost/coroutine/all.hpp>
 3 #include <boost/bind.hpp>
 4 #include <vector>
 5 #include <string>
 6
 7 template <typename T>
 8 using pull_type = typename
 9   boost::coroutines::asymmetric_coroutine<T>::pull_type;
10
11 template <typename T>
12 using push_type = typename
13   boost::coroutines::asymmetric_coroutine<T>::push_type;
14
15 template <typename T>
16 void getNextElem(push_type<T>& sink, 
17                  const std::vector<T>& vec)
18 {
19   for (const auto& elem: vec) {
20     sink(elem);
21   }
22 }
23
24 int main()
25 {
26   std::vector<std::string> vec{"hello", "hi", "hola", 
27                                "servus"};
28   pull_type<std::string> greet_func(
29       boost::bind(getNextElem<std::string>, ::_1, 
30       boost::cref(vec)));
31
32   while (greet_func) {
33     std::cout << greet_func.get() << '\n';
34     greet_func();
35   }
36 }

首先,我们定义了两个别名模板,称为pull_typepush_type,分别指向类型参数 T 的asymmetric_coroutine<T>::pull_typeasymmetric_coroutine<T>::push_type(第 7-9 行和 11-13 行)。

函数getNextElem(第 16 行)旨在用作协程,每次调用时将下一个元素从向量传递给调用者。main函数填充了这个向量(第 26-27 行),然后重复调用getNextElem以获取每个元素。因此,数据从getNextElem传输到mainmain是调用者例程,getNextElem是被调用者例程。

根据协程是向调用者推送数据还是从中拉取数据,它应该具有以下两种签名之一:

  • void (push_type&):协程向调用者推送数据

  • void(pull_type&):协程从调用者拉取数据

传递给协程的pull_typepush_type引用表示调用上下文,并代表通过它向调用者推送数据或从调用者拉取数据的通道。

调用者例程必须使用pull_typepush_type包装函数,具体取决于它是打算从中拉取数据还是向其中推送数据。在我们的情况下,main函数必须在pull_type的实例中包装getNextElem。然而,getNextElem的签名是:

void (push_type&, const std::vector<T>&)

因此,我们必须使用某种机制(如 lambda 或bind)将其调整为符合签名。我们使用boost::bindgetNextElem的第二个参数绑定到向量(第 29-30 行),并将结果的一元函数对象包装在名为greet_funcpull_type实例中。创建pull_type实例会首次调用getNextElem协程。

我们可以在布尔上下文中使用greet_func来检查是否从被调用者那里获得了值,并且我们使用这一点在循环中旋转(第 32 行)。在循环的每次迭代中,我们调用pull_type实例上的get成员函数,以获取getNextElem提供的下一个值(第 33 行)。然后,我们调用pull_type的重载operator(),将控制权交给getNextElem协程(第 34 行)。

另一方面,getNextElem协程不使用传统的返回值将数据发送回调用者。它通过向量进行迭代,并在调用上下文中使用重载的operator()来返回每个元素(第 20 行)。如果调用者必须将数据推送到被调用者,那么调用者将在push_type中包装被调用者,被调用者将传递给调用者的引用包装在pull_type中。在下一章中,我们将看到 Boost Asio 如何使用协程来简化异步事件驱动逻辑。

自测问题

对于多项选择题,选择所有适用的选项:

  1. 如果在boost::thread对象和std::thread对象上不调用joindetach会发生什么?

a. 在boost::thread的基础线程上调用join

b. 对于std::thread,将调用std::terminate,终止程序。

c. 在boost::thread的基础线程上调用detach

d. 在std::thread的基础线程上调用detach

  1. 如果允许异常传播到创建boost::thread对象的初始函数之外会发生什么?

a. 程序将通过std::terminate终止。

b. 这是未定义的行为。

c. 在调用线程上future对象的get调用会抛出异常。

d. 线程终止,但异常不会传播。

  1. 在不持有相关互斥量的情况下,您应该在condition_variable对象上调用notify_onenotify_all吗?

a. 不会,调用会阻塞。

b. 是的,但在某些情况下可能会导致优先级反转。

c. 不会,一些等待的线程可能会错过信号。

d. 是的,甚至可能更快。

  1. 使用boost::unique_lock而不是boost::lock_guard的优势是什么?

a. boost::unique_lock更有效率和轻量级。

b. boost::unique_lock可以或者采用已经获取的锁。

c. boost::lock_guard不能在中间范围内解锁和重新锁定。

d. boost::unique_lock可以推迟获取锁。

  1. 以下哪些关于boost::shared_mutex是正确的?

a. shared_mutexboost::mutex更轻量级和更快。

b. Boost 对shared_mutex的实现没有读者或写者偏向。

c. shared_mutex可以用作可升级的锁。

d. shared_mutex非常适合高写入争用的系统。

摘要

在本章中,我们学习了如何使用 Boost Thread 库和 C++11 标准库来编写线程和任务的并发逻辑。我们学习了如何使用期望和承诺范式来定义并发任务之间的操作顺序,以及标准库中围绕期望和承诺的一些抽象。我们还研究了各种基于锁的线程同步原语,并将它们应用于一些常见的多线程问题。

多线程是一个困难而复杂的主题,本章仅介绍了 Boost 中可用的便携式 API 来编写并发程序。Boost Thread 库和 C++标准库中的并发编程接口是一个不断发展的集合,我们没有涵盖几个功能:C++内存模型和原子操作,Boost Lockfree,线程取消,使用boost::future进行实验性延续等等。设计并发系统和并发数据结构的架构问题是其他相关主题,超出了本书的范围。希望本章介绍的概念和方法能帮助您在这些方向上进一步探索。

参考

第十一章:使用 Boost Asio 进行网络编程

在今天的网络世界中,处理每秒数千个请求的互联网服务器有一个艰巨的任务要完成——保持响应性,并且即使请求量增加也不会减慢。构建可靠的进程,有效地处理网络 I/O 并随着连接数量的增加而扩展,是具有挑战性的,因为它通常需要应用程序员理解底层协议栈并以巧妙的方式利用它。增加挑战的是跨平台的网络编程接口和模型的差异,以及使用低级 API 的固有困难。

Boost Asio(发音为 ay-see-oh)是一个可移植的库,用于使用一致的编程模型执行高效的网络 I/O。重点是执行异步 I/O(因此称为 Asio),其中程序启动 I/O 操作并继续执行其他任务,而不会阻塞等待操作系统返回操作结果。当底层操作系统完成操作时,Asio 库会通知程序并采取适当的操作。Asio 帮助解决的问题以及它使用的一致、可移植接口使其非常有用。但是,交互的异步性质也使其更加复杂和不那么直观。这就是为什么我们将分两部分学习 Asio 的原因:首先理解其交互模型,然后使用它执行网络 I/O:

  • 使用 Asio 进行任务执行

  • 使用 Asio 进行网络编程

Asio 提供了一个工具包,用于执行和管理任意任务,本章的第一部分重点是理解这个工具包。我们在本章的第二部分应用这种理解,当我们具体看一下 Asio 如何帮助编写使用互联网协议(IP)套件的程序与其他程序进行网络通信时。

使用 Asio 进行任务执行

在其核心,Boost Asio 提供了一个任务执行框架,您可以使用它来执行任何类型的操作。您将您的任务创建为函数对象,并将它们发布到 Boost Asio 维护的任务队列中。您可以注册一个或多个线程来选择这些任务(函数对象)并调用它们。线程不断地选择任务,直到任务队列为空,此时线程不会阻塞,而是退出。

IO 服务、队列和处理程序

Asio 的核心是类型boost::asio::io_service。程序使用io_service接口执行网络 I/O 和管理任务。任何想要使用 Asio 库的程序都会创建至少一个io_service实例,有时甚至会创建多个。在本节中,我们将探索io_service的任务管理能力,并将网络 I/O 的讨论推迟到本章的后半部分。

以下是 IO 服务在使用强制性的“hello world”示例:

清单 11.1:Asio Hello World

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 namespace asio = boost::asio;
 4
 5 int main() {
 6   asio::io_service service;
 7
 8   service.post(
 9     [] {
10       std::cout << "Hello, world!" << '\n';
11     });
12
13   std::cout << "Greetings: \n";
14   service.run();
15 }

我们包括方便的头文件boost/asio.hpp,其中包括本章示例中需要的大部分 Asio 库(第 1 行)。Asio 库的所有部分都在命名空间boost::asio下,因此我们为此使用一个更短的别名(第 3 行)。程序本身只是在控制台上打印Hello, world!,但是通过一个任务来实现。

程序首先创建了一个io_service的实例(第 6 行),并使用io_servicepost成员函数将一个函数对象发布到其中。在这种情况下,使用 lambda 表达式定义的函数对象被称为处理程序。对post的调用将处理程序添加到io_service内部的队列中;一些线程(包括发布处理程序的线程)必须分派它们,即,将它们从队列中移除并调用它们。对io_servicerun成员函数的调用(第 14 行)正是这样做的。它循环遍历io_service内部的处理程序,移除并调用每个处理程序。实际上,我们可以在调用run之前向io_service发布更多的处理程序,并且它会调用所有发布的处理程序。如果我们没有调用run,则不会分派任何处理程序。run函数会阻塞,直到队列中的所有处理程序都被分派,并且只有在队列为空时才会返回。单独地,处理程序可以被视为独立的、打包的任务,并且 Boost Asio 提供了一个很好的机制来分派任意任务作为处理程序。请注意,处理程序必须是无参数的函数对象,也就是说,它们不应该带有参数。

注意

默认情况下,Asio 是一个仅包含头文件的库,但使用 Asio 的程序需要至少链接boost_system。在 Linux 上,我们可以使用以下命令行构建这个示例:

$ g++ -g listing11_1.cpp -o listing11_1 -lboost_system -std=c++11

本章中的大多数示例都需要您链接到其他库。您可以使用以下命令行构建本章中的所有示例:

$ g++ -g listing11_25.cpp -o listing11_25 -lboost_system -lboost_coroutine -lboost_date_time -std=c++11

如果您没有从本机包安装 Boost,并且需要在 Windows 上安装,请参考第一章介绍 Boost

运行此程序会打印以下内容:

Greetings: Hello, World!

请注意,在调用run(第 14 行)之前,Greetings:是从主函数(第 13 行)打印出来的。调用run最终会分派队列中的唯一处理程序,打印出Hello, World!。多个线程也可以调用相同的 I/O 对象上的run并发地分派处理程序。我们将在下一节中看到这如何有用。

处理程序状态 - run_one、poll 和 poll_one

虽然run函数会阻塞,直到队列中没有更多的处理程序,但io_service还有其他成员函数,让您以更大的灵活性处理处理程序。但在我们查看这个函数之前,我们需要区分挂起和准备好的处理程序。

我们发布到io_service的处理程序都准备立即运行,并在它们在队列上轮到时立即被调用。一般来说,处理程序与在底层操作系统中运行的后台任务相关联,例如网络 I/O 任务。这样的处理程序只有在关联的任务完成后才会被调用,这就是为什么在这种情况下,它们被称为完成处理程序。这些处理程序被称为挂起,直到关联的任务等待完成,一旦关联的任务完成,它们就被称为准备

run不同,poll成员函数会分派所有准备好的处理程序,但不会等待任何挂起的处理程序准备就绪。因此,如果没有准备好的处理程序,它会立即返回,即使有挂起的处理程序。poll_one成员函数如果有一个准备好的处理程序,会分派一个,但不会阻塞等待挂起的处理程序准备就绪。

run_one成员函数会在非空队列上阻塞,等待处理程序准备就绪。如果在空队列上调用它,它会返回,并且在找到并分派一个准备好的处理程序后立即返回。

发布与分派

post成员函数的调用会将处理程序添加到任务队列并立即返回。稍后对run的调用负责调度处理程序。还有另一个名为dispatch的成员函数,可以用来请求io_service立即调用处理程序。如果在已经调用了runpollrun_onepoll_one的线程中调用了dispatch,那么处理程序将立即被调用。如果没有这样的线程可用,dispatch会将处理程序添加到队列并像post一样立即返回。在以下示例中,我们从main函数和另一个处理程序中调用dispatch

清单 11.2:post 与 dispatch

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 namespace asio = boost::asio;
 4
 5 int main() {
 6   asio::io_service service;
 7   // Hello Handler – dispatch behaves like post
 8   service.dispatch([]() { std::cout << "Hello\n"; });
 9
10   service.post(
11     [&service] { // English Handler
12       std::cout << "Hello, world!\n";
13       service.dispatch([] {  // Spanish Handler, immediate
14                          std::cout << "Hola, mundo!\n";
15                        });
16     });
17   // German Handler
18   service.post([&service] {std::cout << "Hallo, Welt!\n"; });
19   service.run();
20 }

运行此代码会产生以下输出:

Hello
Hello, world!
Hola, mundo!
Hallo, Welt!

dispatch的第一次调用(第 8 行)将处理程序添加到队列中而不调用它,因为io_service上尚未调用run。我们称这个为 Hello 处理程序,因为它打印Hello。然后是两次对post的调用(第 10 行,第 18 行),它们分别添加了两个处理程序。这两个处理程序中的第一个打印Hello, world!(第 12 行),然后调用dispatch(第 13 行)添加另一个打印西班牙问候语Hola, mundo!(第 14 行)的处理程序。这两个处理程序中的第二个打印德国问候语Hallo, Welt(第 18 行)。为了方便起见,让我们称它们为英文、西班牙文和德文处理程序。这在队列中创建了以下条目:

Hello Handler
English Handler
German Handler

现在,当我们在io_service上调用run(第 19 行)时,首先调度 Hello 处理程序并打印Hello。然后是英文处理程序,它打印Hello, World!并在io_service上调用dispatch,传递西班牙处理程序。由于这在已经调用run的线程的上下文中执行,对dispatch的调用会调用西班牙处理程序,打印Hola, mundo!。随后,德国处理程序被调度打印Hallo, Welt!,在run返回之前。

如果英文处理程序调用post而不是dispatch(第 13 行),那么西班牙处理程序将不会立即被调用,而是在德国处理程序之后排队。德国问候语Hallo, Welt!将在西班牙问候语Hola, mundo!之前出现。输出将如下所示:

Hello
Hello, world!
Hallo, Welt!
Hola, mundo!

通过线程池并发执行

io_service对象是线程安全的,多个线程可以同时在其上调用run。如果队列中有多个处理程序,它们可以被这些线程同时处理。实际上,调用run的一组线程在给定的io_service上形成一个线程池。后续的处理程序可以由池中的不同线程处理。哪个线程调度给定的处理程序是不确定的,因此处理程序代码不应该做出任何这样的假设。在以下示例中,我们将一堆处理程序发布到io_service,然后启动四个线程,它们都在其上调用run

清单 11.3:简单线程池

 1 #include <boost/asio.hpp>
 2 #include <boost/thread.hpp>
 3 #include <boost/date_time.hpp>
 4 #include <iostream>
 5 namespace asio = boost::asio;
 6
 7 #define PRINT_ARGS(msg) do {\
 8   boost::lock_guard<boost::mutex> lg(mtx); \
 9   std::cout << '[' << boost::this_thread::get_id() \
10             << "] " << msg << std::endl; \
11 } while (0)
12
13 int main() {
14   asio::io_service service;
15   boost::mutex mtx;
16
17   for (int i = 0; i < 20; ++i) {
18     service.post([i, &mtx]() { 
19                          PRINT_ARGS("Handler[" << i << "]");
20                          boost::this_thread::sleep(
21                               boost::posix_time::seconds(1));
22                        });
23   }
24
25   boost::thread_group pool;
26   for (int i = 0; i < 4; ++i) {
27     pool.create_thread([&service]() { service.run(); });
28   }
29
30   pool.join_all();
31 }

我们在循环中发布了 20 个处理程序(第 18 行)。每个处理程序打印其标识符(第 19 行),然后休眠一秒钟(第 19-20 行)。为了运行处理程序,我们创建了一个包含四个线程的组,每个线程在io_service上调用 run(第 21 行),并等待所有线程完成(第 24 行)。我们定义了宏PRINT_ARGS,它以线程安全的方式将输出写入控制台,并标记当前线程 ID(第 7-10 行)。我们以后也会在其他示例中使用这个宏。

要构建此示例,您还必须链接libboost_threadlibboost_date_time,在 Posix 环境中还必须链接libpthread

$ g++ -g listing9_3.cpp -o listing9_3 -lboost_system -lboost_thread -lboost_date_time -pthread -std=c++11

在我的笔记本电脑上运行此程序的一个特定运行产生了以下输出(有些行被剪掉):

[b5c15b40] Handler[0]
[b6416b40] Handler[1]
[b6c17b40] Handler[2]
[b7418b40] Handler[3]
[b5c15b40] Handler[4]
[b6416b40] Handler[5]
…
[b6c17b40] Handler[13]
[b7418b40] Handler[14]
[b6416b40] Handler[15]
[b5c15b40] Handler[16]
[b6c17b40] Handler[17]
[b7418b40] Handler[18]
[b6416b40] Handler[19]

您可以看到不同的处理程序由不同的线程执行(每个线程 ID 标记不同)。

提示

如果任何处理程序抛出异常,它将传播到执行处理程序的线程上对run函数的调用。

io_service::work

有时,即使没有处理程序要调度,保持线程池启动也是有用的。runrun_one都不会在空队列上阻塞。因此,为了让它们阻塞等待任务,我们必须以某种方式指示有未完成的工作要执行。我们通过创建io_service::work的实例来实现这一点,如下例所示:

11.4 节:使用 io_service::work 保持线程忙碌

 1 #include <boost/asio.hpp>
 2 #include <memory>
 3 #include <boost/thread.hpp>
 4 #include <iostream>
 5 namespace asio = boost::asio;
 6
 7 typedef std::unique_ptr<asio::io_service::work> work_ptr;
 8
 9 #define PRINT_ARGS(msg) do {\ … 
...
14
15 int main() {
16   asio::io_service service;
17   // keep the workers occupied
18   work_ptr work(new asio::io_service::work(service));
19   boost::mutex mtx;
20
21   // set up the worker threads in a thread group
22   boost::thread_group workers;
23   for (int i = 0; i < 3; ++i) {
24     workers.create_thread([&service, &mtx]() {
25                          PRINT_ARGS("Starting worker thread ");
26                          service.run();
27                          PRINT_ARGS("Worker thread done");
28                        });
29   }
30
31   // Post work
32   for (int i = 0; i < 20; ++i) {
33     service.post(
34       [&service, &mtx]() {
35         PRINT_ARGS("Hello, world!");
36         service.post([&mtx]() {
37                            PRINT_ARGS("Hola, mundo!");
38                          });
39       });
40   }
41
42   work.reset(); // destroy work object: signals end of work
43   workers.join_all(); // wait for all worker threads to finish
44 }

在这个例子中,我们创建了一个包装在unique_ptr中的io_service::work对象(第 18 行)。我们通过将io_service对象的引用传递给work构造函数,将其与io_service对象关联起来。请注意,与 11.3 节不同,我们首先创建了工作线程(第 24-27 行),然后发布了处理程序(第 33-39 行)。然而,由于调用run阻塞(第 26 行),工作线程会一直等待处理程序。这是因为我们创建的io_service::work对象指示io_service队列中有未完成的工作。因此,即使所有处理程序都被调度,线程也不会退出。通过在包装work对象的unique_ptr上调用reset,其析构函数被调用,通知io_service所有未完成的工作已完成(第 42 行)。线程中的run调用返回,一旦所有线程都加入,程序就会退出(第 43 行)。我们将work对象包装在unique_ptr中,以便在程序的适当位置以异常安全的方式销毁它。

我们在这里省略了PRINT_ARGS的定义,请参考 11.3 节。

通过 strands 进行序列化和有序执行

线程池允许处理程序并发运行。这意味着访问共享资源的处理程序需要同步访问这些资源。我们在 11.3 和 11.4 节中已经看到了这方面的例子,当我们同步访问全局对象std::cout时。作为在处理程序中编写同步代码的替代方案,我们可以使用strands

提示

将 strand 视为任务队列的子序列,其中没有两个来自同一 strand 的处理程序会同时运行。

队列中的其他处理程序的调度不受 strand 的影响。让我们看一个使用 strands 的例子:

11.5 节:使用 strands

 1 #include <boost/asio.hpp>
 2 #include <boost/thread.hpp>
 3 #include <boost/date_time.hpp>
 4 #include <cstdlib>
 5 #include <iostream>
 6 #include <ctime>
 7 namespace asio = boost::asio;
 8 #define PRINT_ARGS(msg) do {\
...
13
14 int main() {
15   std::srand(std::time(0));
16   asio::io_service service;
17   asio::io_service::strand strand(service);
18   boost::mutex mtx;
19   size_t regular = 0, on_strand = 0;
20 
21  auto workFuncStrand = [&mtx, &on_strand] {
22           ++on_strand;
23           PRINT_ARGS(on_strand << ". Hello, from strand!");
24           boost::this_thread::sleep(
25                       boost::posix_time::seconds(2));
26         };
27
28   auto workFunc = [&mtx, &regular] {
29                   PRINT_ARGS(++regular << ". Hello, world!");
30                   boost::this_thread::sleep(
31                         boost::posix_time::seconds(2));
32                 };
33   // Post work
34   for (int i = 0; i < 15; ++i) {
35     if (rand() % 2 == 0) {
36       service.post(strand.wrap(workFuncStrand));
37     } else {
38       service.post(workFunc);
39     }
40   }
41
42   // set up the worker threads in a thread group
43   boost::thread_group workers;
44   for (int i = 0; i < 3; ++i) {
45     workers.create_thread([&service, &mtx]() {
46                        PRINT_ARGS("Starting worker thread ");
47                       service.run();
48                        PRINT_ARGS("Worker thread done");
49                     });
50   }
51
52   workers.join_all(); // wait for all worker threads to finish
53 }

在这个例子中,我们创建了两个处理程序函数:workFuncStrand(第 21 行)和workFunc(第 28 行)。lambda workFuncStrand捕获一个计数器on_strand,递增它,并打印一个带有计数器值前缀的消息Hello, from strand!。函数workFunc捕获另一个计数器regular,递增它,并打印带有计数器前缀的消息Hello, World!。两者在返回前暂停 2 秒。

要定义和使用 strand,我们首先创建一个与io_service实例关联的io_service::strand对象(第 17 行)。然后,我们通过使用strandwrap成员函数(第 36 行)将所有要成为该 strand 一部分的处理程序发布。或者,我们可以直接使用 strand 的postdispatch成员函数发布处理程序到 strand,如下面的代码片段所示:

33   for (int i = 0; i < 15; ++i) {
34     if (rand() % 2 == 0) {
35       strand.post(workFuncStrand);
37     } else {
...

strand 的wrap成员函数返回一个函数对象,该函数对象调用 strand 上的dispatch来调用原始处理程序。最初,添加到队列中的是这个函数对象,而不是我们的原始处理程序。当得到适当的调度时,这将调用原始处理程序。对于这些包装处理程序的调度顺序没有约束,因此,原始处理程序被调用的实际顺序可能与它们被包装和发布的顺序不同。

另一方面,在线程上直接调用postdispatch可以避免中间处理程序。直接向线程发布也可以保证处理程序将按照发布的顺序进行分发,实现线程中处理程序的确定性排序。stranddispatch成员会阻塞,直到处理程序被分发。post成员只是将其添加到线程并返回。

请注意,workFuncStrand在没有同步的情况下递增on_strand(第 22 行),而workFuncPRINT_ARGS宏(第 29 行)中递增计数器regular,这确保递增发生在临界区内。workFuncStrand处理程序被发布到一个线程中,因此可以保证被序列化;因此不需要显式同步。另一方面,整个函数通过线程串行化,无法同步较小的代码块。在线程上运行的处理程序和其他处理程序之间没有串行化;因此,对全局对象的访问,如std::cout,仍然必须同步。

运行上述代码的示例输出如下:

[b73b6b40] Starting worker thread 
[b73b6b40] 0\. Hello, world from strand!
[b6bb5b40] Starting worker thread 
[b6bb5b40] 1\. Hello, world!
[b63b4b40] Starting worker thread 
[b63b4b40] 2\. Hello, world!
[b73b6b40] 3\. Hello, world from strand!
[b6bb5b40] 5\. Hello, world!
[b63b4b40] 6\. Hello, world!
…
[b6bb5b40] 14\. Hello, world!
[b63b4b40] 4\. Hello, world from strand!
[b63b4b40] 8\. Hello, world from strand!
[b63b4b40] 10\. Hello, world from strand!
[b63b4b40] 13\. Hello, world from strand!
[b6bb5b40] Worker thread done
[b73b6b40] Worker thread done
[b63b4b40] Worker thread done

线程池中有三个不同的线程,并且线程中的处理程序由这三个线程中的两个选择:最初由线程 IDb73b6b40选择,后来由线程 IDb63b4b40选择。这也消除了一个常见的误解,即所有线程中的处理程序都由同一个线程分发,这显然不是这样。

提示

同一线程中的不同处理程序可能由不同的线程分发,但永远不会同时运行。

使用 Asio 进行网络 I/O

我们希望使用 Asio 构建可扩展的网络服务,执行网络 I/O。这些服务接收来自远程机器上运行的客户端的请求,并通过网络向它们发送信息。跨机器边界的进程之间的数据传输,通过网络进行,使用某些网络通信协议。其中最普遍的协议是 IP 或Internet Protocol及其上层的一套协议。Boost Asio 支持 TCP、UDP 和 ICMP,这三种流行的 IP 协议套件中的协议。本书不涵盖 ICMP。

UDP 和 TCP

用户数据报协议或 UDP 用于在 IP 网络上从一个主机向另一个主机传输数据报或消息单元。UDP 是一个基于 IP 的非常基本的协议,它是无状态的,即在多个网络 I/O 操作之间不维护任何上下文。使用 UDP 进行数据传输的可靠性取决于底层网络的可靠性,UDP 传输具有以下注意事项:

  • UDP 数据报可能根本不会被传递

  • 给定的数据报可能会被传递多次

  • 两个数据报可能不会按照从源发送到目的地的顺序被传递

  • UDP 将检测数据报的任何数据损坏,并丢弃这样的消息,没有任何恢复的手段

因此,UDP 被认为是一种不可靠的协议。

如果应用程序需要协议提供更强的保证,我们选择传输控制协议或 TCP。TCP 使用字节流而不是消息进行处理。它在网络通信的两个端点之间使用握手机制建立持久的连接,并在连接的生命周期内维护状态。两个端点之间的所有通信都发生在这样的连接上。以比 UDP 略高的延迟为代价,TCP 提供以下保证:

  • 在给定的连接上,接收应用程序按照发送顺序接收发送方发送的字节流

  • 在传输过程中丢失或损坏的数据可以重新传输,大大提高了交付的可靠性

可以自行处理不可靠性和数据丢失的实时应用通常使用 UDP。此外,许多高级协议都是在 UDP 之上运行的。TCP 更常用,其中正确性关注超过实时性能,例如电子邮件和文件传输协议,HTTP 等。

IP 地址

IP 地址是用于唯一标识连接到 IP 网络的接口的数字标识符。较旧的 IPv4 协议在 4 十亿(2³²)地址的地址空间中使用 32 位 IP 地址。新兴的 IPv6 协议在 3.4 × 10³⁸(2¹²⁸)个唯一地址的地址空间中使用 128 位 IP 地址,这几乎是不可枯竭的。您可以使用类boost::asio::ip::address表示两种类型的 IP 地址,而特定版本的地址可以使用boost::asio::ip::address_v4boost::asio::ip::address_v6表示。

IPv4 地址

熟悉的 IPv4 地址,例如 212.54.84.93,是以点分四进制表示法表示的 32 位无符号整数;四个 8 位无符号整数或八位字节表示地址中的四个字节,从左到右依次是最重要的,以点(句号)分隔。每个八位字节的范围是从 0 到 255。IP 地址通常以网络字节顺序解释,即大端序。

子网

较大的计算机网络通常被划分为称为子网的逻辑部分。子网由一组可以使用广播消息相互通信的节点组成。子网有一个关联的 IP 地址池,具有一个共同的前缀,通常称为路由前缀网络地址。IP 地址字段的剩余部分称为主机部分

给定 IP 地址前缀长度,我们可以使用子网掩码计算前缀。子网的子网掩码是一个 4 字节的位掩码,与子网中的 IP 地址进行按位与运算得到路由前缀。对于具有长度为 N 的路由前缀的子网,子网掩码的最高有效 N 位设置,剩余的 32-N 位未设置。子网掩码通常以点分四进制表示法表示。例如,如果地址 172.31.198.12 具有长度为 16 位的路由前缀,则其子网掩码将为 255.255.0.0,路由前缀将为 172.31.0.0。

一般来说,路由前缀的长度必须明确指定。无类域间路由CIDR)表示法使用点分四进制表示法,并在末尾加上一个斜杠和一个介于 0 和 32 之间的数字,表示前缀长度。因此,10.209.72.221/22 表示具有前缀长度为 22 的 IP 地址。一个旧的分类方案,称为有类方案,将 IPv4 地址空间划分为范围,并为每个范围分配一个(见下表)。属于每个范围的地址被认为是相应类的地址,并且路由前缀的长度是基于类确定的,而不是使用 CIDR 表示法指定。

地址范围 前缀长度 子网掩码 备注
类 A 0.0.0.0 – 127.255.255.255 8 255.0.0.0
类 B 128.0.0.0 – 191.255.255.255 16 255.255.0.0
类 C 192.0.0.0 – 223.255.255.255 24 255.255.255.0
类 D 224.0.0.0 – 239.255.255.255 未指定 未指定 多播
类 E 240.0.0.0 – 255.255.255.255 未指定 未指定 保留

特殊地址

一些 IPv4 地址具有特殊含义。例如,主机部分中所有位设置的 IP 地址被称为子网的广播地址,用于向子网中的所有主机广播消息。例如,网络 172.31.0.0/16 中的广播地址为 172.31.255.255。

监听传入请求的应用程序使用未指定地址 0.0.0.0(INADDR_ANY)来监听所有可用的网络接口,而无需知道系统上的地址。

回环地址 127.0.0.1 通常与一个虚拟网络接口相关联,该接口不与任何硬件相关,并且不需要主机连接到网络。通过回环接口发送的数据立即显示为发送方主机上的接收数据。经常用于在一个盒子内测试网络应用程序,您可以配置额外的回环接口,并将回环地址从 127.0.0.0 到 127.255.255.255 的范围关联起来。

使用 Boost 处理 IPv4 地址

现在让我们看一个构造 IPv4 地址并从中获取有用信息的代码示例,使用类型boost::asio::ip::address_v4

清单 11.6:处理 IPv4 地址

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 #include <cassert>
 4 #include <vector>
 5 namespace asio = boost::asio;
 6 namespace sys = boost::system;
 7 using namespace asio::ip;
 8
 9 void printAddrProperties(const address& addr) {
10   std::cout << "\n\n" << addr << ": ";
11
12   if (addr.is_v4()) {
13     std::cout << "netmask=" << address_v4::netmask(addr.to_v4());
14   } else if (addr.is_v6()) { /* ... */ }
15
16   if (addr.is_unspecified()) { std::cout << "is unspecified, "; }
17   if (addr.is_loopback()) { std::cout << "is loopback, "; }
18   if (addr.is_multicast()) { std::cout << "is multicast, "; }
19 }
20
21 int main() {
22   sys::error_code ec;
23   std::vector<address> addresses;
24   std::vector<const char*> addr_strings{"127.0.0.1", 
25            "10.28.25.62", "137.2.33.19", "223.21.201.30",
26            "232.28.25.62", "140.28.25.62/22"};
27
28   addresses.push_back(address_v4());       // default: 0.0.0.0
29   addresses.push_back(address_v4::any());  // INADDR_ANY
30
31   for (const auto& v4str : addr_strings) {
32     address_v4 addr = address_v4::from_string(v4str, ec);
33     if (!ec) {
34       addresses.push_back(addr);
35     }
36   }
37
38   for (const address& addr1: addresses) {
39     printAddrProperties(addr1);
40   }
41 }

这个例子突出了 IPv4 地址的一些基本操作。我们创建了一个boost::asio::ip::address对象的向量(不仅仅是address_v4),并从它们的字符串表示中构造 IPv4 地址,使用address_v4::from_string静态函数(第 32 行)。我们使用from_string的两个参数重载,它接受地址字符串和一个非 const 引用到error_code对象,如果无法解析地址字符串,则设置该对象。存在一个单参数重载,如果有错误则抛出。请注意,您可以隐式转换或分配address_v4实例到address实例。默认构造的address_v4实例等同于未指定地址 0.0.0.0(第 28 行),也可以由address_v4::any()(第 29 行)返回。

为了打印地址的属性,我们编写了printAddrProperties函数(第 9 行)。我们通过将 IP 地址流式传输到std::cout(第 10 行)来打印 IP 地址。我们使用is_v4is_v6成员函数(第 12、14 行)来检查地址是 IPv4 还是 IPv6 地址,使用address_v4::netmask静态函数(第 13 行)打印 IPv4 地址的网络掩码,并使用适当的成员谓词(第 16-18 行)检查地址是否为未指定地址、回环地址或 IPv4 多播地址(类 D)。请注意,address_v4::from_string函数不识别 CIDR 格式(截至 Boost 版本 1.57),并且网络掩码是基于类别的方案计算的。

在下一节中,我们将在简要概述 IPv6 地址之后,增强printAddrProperties(第 14 行)函数,以打印 IPv6 特定属性。

IPv6 地址

在其最一般的形式中,IPv6 地址被表示为由冒号分隔的八个 2 字节无符号十六进制整数序列。按照惯例,十六进制整数中的数字af以小写字母写入,并且每个 16 位数字中的前导零被省略。以下是以这种表示法的 IPv6 地址的一个例子:

2001:0c2f:003a:01e0:0000:0000:0000:002a

两个或多个零项的序列可以完全折叠。因此,前面的地址可以写成 2001:c2f:3a:1e0::2a。所有前导零已被移除,并且在字节 16 和 63 之间的连续零项已被折叠,留下了冒号对(:😃。如果有多个零项序列,则折叠最长的序列,如果有平局,则折叠最左边的序列。因此,我们可以将 2001:0000:0000:01e0:0000:0000:001a:002a 缩写为 2001::1e0:0:0:1a:2a。请注意,最左边的两个零项序列被折叠,而 32 到 63 位之间的其他零项未被折叠。

在从 IPv4 过渡到 IPv6 的环境中,软件通常同时支持 IPv4 和 IPv6。IPv4 映射的 IPv6 地址用于在 IPv6 和 IPv4 接口之间进行通信。IPv4 地址被映射到具有::ffff:0:0/96 前缀和最后 32 位与 IPv4 地址相同的 IPv6 地址。例如,172.31.201.43 将表示为::ffff:172.31.201.43/96。

地址类、范围和子网

IPv6 地址有三类:

  • 单播地址:这些地址标识单个网络接口

  • 多播地址:这些地址标识一组网络接口,并用于向组中的所有接口发送数据

  • 任播地址:这些地址标识一组网络接口,但发送到任播地址的数据将传递给距离发送者拓扑最近的一个或多个接口,而不是传递给组中的所有接口

在单播和任播地址中,地址的最低有效 64 位表示主机 ID。一般来说,高阶 64 位表示网络前缀。

每个 IPv6 地址也有一个范围,用于标识其有效的网络段:

  • 节点本地地址,包括环回地址,用于节点内通信。

  • 全局地址是可通过网络到达的可路由地址。

  • 链路本地地址会自动分配给每个启用 IPv6 的接口,并且只能在网络内访问,也就是说,路由器不会路由到链路本地地址的流量。即使具有可路由地址,链路本地地址也会分配给接口。链路本地地址的前缀为 fe80::/64。

特殊地址

IPv6 的环回地址类似于 IPv4 中的 127.0.0.1,为::1。在 IPv6 中,未指定地址(全零)写为::(in6addr_any)。IPv6 中没有广播地址,多播地址用于定义接收方接口的组,这超出了本书的范围。

使用 Boost 处理 IPv6 地址

在下面的例子中,我们构造 IPv6 地址,并使用boost::asio::ip::address_v6类查询这些地址的属性:

列表 11.7:处理 IPv6 地址

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 #include <vector>
 4 namespace asio = boost::asio;
 5 namespace sys = boost::system;
 6 using namespace asio::ip;
 7
 8 void printAddr6Properties(const address_v6& addr) {
 9   if (addr.is_v4_mapped()) { std::cout << "is v4-mapped, "; }
10   else {  
11     if (addr.is_link_local()) { std::cout << "is link local";}
12   }  
13 }
14
15 void printAddrProperties(const address& addr) { ... }
16
17 int main() {
18   sys::error_code ec;
19   std::vector<address> addresses;
20   std::vector<const char*> addr_strings{"::1", "::",
21     "fe80::20", "::ffff:223.18.221.9", "2001::1e0:0:0:1a:2a"};
22
23   for (const auto& v6str: addr_strings) {
24     address addr = address_v6::from_string(v6str, ec);
25     if (!ec) { addresses.push_back(addr); }
26   }
27
28   for (const auto& addr : addresses) {
29     printAddrProperties(addr);
30   }
31 }

这个例子通过 IPv6 特定的检查增强了列表 11.6。函数printAddrProperties(第 15 行)与列表 11.6 中的相同,因此不再完整重复。printAddr6Properties函数(第 8 行)检查地址是否为 IPv4 映射的 IPv6 地址(第 9 行),以及它是否为链路本地地址(第 11 行)。其他相关检查已经通过printAddrProperties中的与版本无关的address成员执行(参见列表 11.6)。

我们创建一个boost::asio::ip::address对象的向量(不仅仅是address_v6),并推送由它们的字符串表示构造的 IPv6 地址,使用address_v6::from_string静态函数(第 24 行),它返回address_v6对象,可以隐式转换为address。请注意,我们有环回地址、未指定地址、IPv4 映射地址、常规 IPv6 单播地址和链路本地地址(第 20-21 行)。

端点、套接字和名称解析

应用程序在提供网络服务时绑定到 IP 地址,多个应用程序从 IP 地址开始发起对其他应用程序的出站通信。多个应用程序可以使用不同的端口绑定到同一个 IP 地址。端口是一个无符号的 16 位整数,与 IP 地址和协议(TCP、UDP 等)一起,唯一标识一个通信端点。数据通信发生在两个这样的端点之间。Boost Asio 为 UDP 和 TCP 提供了不同的端点类型,即boost::asio::ip::udp::endpointboost::asio::ip::tcp::endpoint

端口

许多标准和广泛使用的网络服务使用固定的众所周知的端口。端口 0 到 1023 分配给众所周知的系统服务,包括 FTP、SSH、telnet、SMTP、DNS、HTTP 和 HTTPS 等。广泛使用的应用程序可以在 1024 到 49151 之间注册标准端口,由互联网编号分配机构IANA)负责。49151 以上的端口可以被任何应用程序使用,无需注册。通常将众所周知的端口映射到服务的映射通常保存在磁盘文件中,例如在 POSIX 系统上是/etc/services,在 Windows 上是%SYSTEMROOT%\system32\drivers\etc\services

套接字

套接字表示用于网络通信的端点。它表示通信通道的一端,并提供执行所有数据通信的接口。Boost Asio 为 UDP 和 TCP 提供了不同的套接字类型,即boost::asio::ip::udp::socketboost::asio::ip::tcp::socket。套接字始终与相应的本地端点对象相关联。所有现代操作系统上的本机网络编程接口都使用某种伯克利套接字 API 的衍生版本,这是用于执行网络通信的 C API。Boost Asio 库提供了围绕这个核心 API 构建的类型安全抽象。

套接字是I/O 对象的一个例子。在 Asio 中,I/O 对象是用于启动 I/O 操作的对象类。这些操作由底层操作系统的I/O 服务对象分派,该对象是boost::asio::io_service的实例。在本章的前面,我们看到了 I/O 服务对象作为任务管理器的实例。但是它们的主要作用是作为底层操作系统上操作的接口。每个 I/O 对象都是使用关联的 I/O 服务实例构造的。通过这种方式,高级 I/O 操作在 I/O 对象上启动,但是 I/O 对象和 I/O 服务之间的交互保持封装。在接下来的章节中,我们将看到使用 UDP 和 TCP 套接字进行网络通信的示例。

主机名和域名

通过名称而不是数字地址来识别网络中的主机通常更方便。域名系统(DNS)提供了一个分层命名系统,其中网络中的主机通过带有唯一名称的主机名来标识,该名称标识了网络,称为完全限定域名或简称域名。例如,假想的域名elan.taliesyn.org可以映射到 IP 地址 140.82.168.29。在这里,elan将标识特定主机,taliesyn.org将标识主机所属的域。在同一网络中,不同组的计算机可能报告给不同的域,甚至某台计算机可能属于多个域。

名称解析

全球范围内的 DNS 服务器层次结构以及私人网络内的 DNS 服务器维护名称到地址的映射。应用程序询问配置的 DNS 服务器以解析完全限定域名到地址。如果有的话,DNS 服务器将请求解析为 IP 地址,否则将其转发到层次结构更高的另一个 DNS 服务器。如果直到层次结构的根部都没有答案,解析将失败。发起这种名称解析请求的专门程序或库称为解析器。Boost Asio 提供了特定协议的解析器:boost::asio::ip::tcp::resolverboost::asio::ip::udp::resolver用于执行此类名称解析。我们查询主机名上的服务,并获取该服务的一个或多个端点。以下示例显示了如何做到这一点,给定一个主机名,以及可选的服务名或端口:

清单 11.8:查找主机的 IP 地址

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 namespace asio = boost::asio;
 4
 5 int main(int argc, char *argv[]) {
 6   if (argc < 2) {
 7     std::cout << "Usage: " << argv[0] << " host [service]\n";
 8     exit(1);
 9   }
10   const char *host = argv[1];
11   const char *svc = (argc > 2) ? argv[2] : "";
12
13   try {
14     asio::io_service service;
15     asio::ip::tcp::resolver resolver(service);
16     asio::ip::tcp::resolver::query query(host, svc);
17     asio::ip::tcp::resolver::iterator end,
18                             iter = resolver.resolve(query);
19     while (iter != end) {
20       asio::ip::tcp::endpoint endpoint = iter->endpoint();
21       std::cout << "Address: " << endpoint.address()
22                 << ", Port: " << endpoint.port() << '\n';
23       ++iter;
24     }
25   } catch (std::exception& e) {
26     std::cout << e.what() << '\n';
27   }
28 }

您可以通过在命令行上传递主机名和可选的服务名来运行此程序。该程序将这些解析为 IP 地址和端口,并将它们打印到标准输出(第 21-22 行)。程序创建了一个io_service实例(第 14 行),它将成为底层操作系统操作的通道,以及一个boost::asio::ip::tcp::resolver实例(第 15 行),它提供了请求名称解析的接口。我们根据主机名和服务名创建一个名称查找请求,封装在一个query对象中(第 16 行),并调用resolverresolve成员函数,将query对象作为参数传递(第 18 行)。resolve函数返回一个endpoint iterator,指向查询解析的一系列endpoint对象。我们遍历这个序列,打印每个端点的地址和端口号。如果有的话,这将打印 IPv4 和 IPv6 地址。如果我们想要特定于 IP 版本的 IP 地址,我们需要使用query的三参数构造函数,并在第一个参数中指定协议。例如,要仅查找 IPv6 地址,我们可以使用这个:

asio::ip::tcp::resolver::query query(asio::ip::tcp::v6(), 
 host, svc);

在查找失败时,resolve函数会抛出异常,除非我们使用接受非 const 引用error_code的两参数版本,并在错误时设置它。在下面的例子中,我们执行反向查找。给定一个 IP 地址和一个端口,我们查找关联的主机名和服务名:

清单 11.9:查找主机和服务名称

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 namespace asio = boost::asio;
 4
 5 int main(int argc, char *argv[]) {
 6   if (argc < 2) {
 7     std::cout << "Usage: " << argv[0] << " ip [port]\n";
 8     exit(1);
 9   }
10
11   const char *addr = argv[1];
12   unsigned short port = (argc > 2) ? atoi(argv[2]) : 0;
13
14   try {
15     asio::io_service service;
16     asio::ip::tcp::endpoint ep(
17               asio::ip::address::from_string(addr), port);
18     asio::ip::tcp::resolver resolver(service);
19     asio::ip::tcp::resolver::iterator iter = 
20                              resolver.resolve(ep), end;
21     while (iter != end) {
22       std::cout << iter->host_name() << " "
23                 << iter->service_name() << '\n';
24       iter++;
25     }
26   } catch (std::exception& ex) {
27     std::cerr << ex.what() << '\n';
28   }
29 }

我们从命令行传递 IP 地址和端口号给程序,然后使用它们构造endpoint(第 16-17 行)。然后我们将endpoint传递给resolverresolve成员函数(第 19 行),并遍历结果。在这种情况下,迭代器指向boost::asio::ip::tcp::query对象,我们使用适当的成员函数打印每个对象的主机和服务名称(第 22-23 行)。

缓冲区

数据作为字节流通过网络发送或接收。一个连续的字节流可以用一对值来表示:序列的起始地址和序列中的字节数。Boost Asio 提供了两种用于这种序列的抽象,boost::asio::const_bufferboost::asio::mutable_bufferconst_buffer类型表示一个只读序列,通常用作发送数据时的数据源。mutable_buffer表示一个读写序列,当您需要在缓冲区中添加或更新数据时使用,例如当您从远程主机接收数据时:

清单 11.10:使用 const_buffer 和 mutable_buffer

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 #include <cassert>
 4 namespace asio = boost::asio;
 5
 6 int main() {
 7   char buf[10];
 8   asio::mutable_buffer mbuf(buf, sizeof(buf));
 9   asio::const_buffer cbuf(buf, 5);
10
11   std::cout << buffer_size(mbuf) << '\n';
12   std::cout << buffer_size(cbuf) << '\n';
13
14   char *mptr = asio::buffer_cast<char*>(mbuf);
15   const char *cptr = asio::buffer_cast<const char*>(cbuf);
16   assert(mptr == cptr && cptr == buf);
17   
18   size_t offset = 5;
19   asio::mutable_buffer mbuf2 = mbuf + offset;
20   assert(asio::buffer_cast<char*>(mbuf2)
21         - asio::buffer_cast<char*>(mbuf) == offset);
22   assert(buffer_size(mbuf2) == buffer_size(mbuf) - offset);
23 }

在这个例子中,我们展示了如何将 char 数组包装在mutable_bufferconst_buffer中(第 8-9 行)。在构造缓冲区时,您需要指定内存区域的起始地址和区域的字节数。const char数组只能被包装在const_buffer中,而不能被包装在mutable_buffer中。这些缓冲区包装器分配存储空间,不管理任何堆分配的内存,也不执行任何数据复制。

函数boost::asio::buffer_size返回缓冲区的字节长度(第 11-12 行)。这是您在构造缓冲区时传递的长度,它不依赖于缓冲区中的数据。默认初始化的缓冲区长度为零。

函数模板boost::asio::buffer_cast<>用于获取缓冲区的基础字节数组的指针(第 14-15 行)。请注意,如果我们尝试使用buffer_castconst_buffer获取可变数组,将会得到编译错误:

asio::const_buffer cbuf(addr, length);
char *buf = asio::buffer_cast<char*>(cbuf); // fails to compile

最后,您可以使用operator+从另一个缓冲区的偏移量创建一个缓冲区(第 19 行)。结果缓冲区的长度将比原始缓冲区的长度少偏移量的长度(第 22 行)。

向量 I/O 的缓冲区序列

有时,从一系列缓冲区发送数据或将接收到的数据分割到一系列缓冲区中是很方便的。每个序列调用一次网络 I/O 函数会很低效,因为这些调用最终会转换为系统调用,并且每次调用都会有开销。另一种选择是使用可以处理作为参数传递给它的缓冲区序列的网络 I/O 函数。这通常被称为向量 I/O聚集-分散 I/O。Boost Asio 的所有 I/O 函数都处理缓冲区序列,因此必须传递缓冲区序列而不是单个缓冲区。用于 Asio I/O 函数的有效缓冲区序列满足以下条件:

  • 有一个返回双向迭代器的成员函数begin,该迭代器指向mutable_bufferconst_buffer

  • 有一个返回指向序列末尾的迭代器的成员函数end

  • 可复制

要使缓冲区序列有用,它必须是const_buffer序列或mutable_buffer序列。形式上,这些要求总结在ConstBufferSequenceMutableBufferSequence概念中。这是一组稍微简化的条件,但对我们的目的来说已经足够了。我们可以使用标准库容器(如std::vectorstd::list等)以及 Boost 容器来创建这样的序列。然而,由于我们经常只处理单个缓冲区,Boost 提供了boost::asio::buffer函数,可以轻松地将单个缓冲区适配为长度为 1 的缓冲区序列。以下是一个简短的示例,说明了这些想法:

清单 11.11:使用缓冲区

 1 #include <boost/asio.hpp>
 2 #include <vector>
 3 #include <string>
 4 #include <iostream>
 5 #include <cstdlib>
 6 #include <ctime>
 7 namespace asio = boost::asio;
 8
 9 int main() {
10   std::srand(std::time(nullptr));
11
12   std::vector<char> v1(10);
13   char a2[10];
14   std::vector<asio::mutable_buffer> bufseq(2);
15
16   bufseq.push_back(asio::mutable_buffer(v1.data(), 
17                                         v1.capacity()));
18   bufseq.push_back(asio::mutable_buffer(a2, sizeof(a2)));
19
20   for (auto cur = asio::buffers_begin(bufseq),
21        end = asio::buffers_end(bufseq); cur != end; cur++) {
22     *cur = 'a' + rand() % 26;
23   }
24
25   std::cout << "Size: " << asio::buffer_size(bufseq) << '\n';
26
27   std::string s1(v1.begin(), v1.end());
28   std::string s2(a2, a2 + sizeof(a2));
29
30   std::cout << s1 << '\n' << s2 << '\n';
31 }

在这个示例中,我们创建一个vector的两个mutable_buffer的可变缓冲区序列(第 14 行)。这两个可变缓冲区包装了一个charvector(第 16-17 行)和一个char的数组(第 18 行)。使用buffers_begin函数(第 20 行)和buffers_end函数(第 21 行),我们确定了缓冲区序列bufseq所封装的字节的整个范围,并遍历它,将每个字节设置为随机字符(第 22 行)。当这些写入底层的 vector 或数组时,我们使用底层的 vector 或数组构造字符串并打印它们的内容(第 27-28 行)。

同步和异步通信

在接下来的几节中,我们将整合我们迄今为止学到的 IP 地址、端点、套接字、缓冲区和其他 Asio 基础设施的理解,来编写网络客户端和服务器程序。我们的示例使用客户端-服务器模型进行交互,其中服务器程序服务于传入的请求,而客户端程序发起这些请求。这样的客户端被称为主动端点,而这样的服务器被称为被动端点

客户端和服务器可以进行同步通信,即在每个网络 I/O 操作上阻塞,直到请求被底层操作系统处理,然后才继续下一步。或者,它们可以使用异步 I/O,在不等待完成的情况下启动网络 I/O,并在稍后被通知其完成。与同步情况不同,使用异步 I/O 时,程序不会在需要执行 I/O 操作时空闲等待。因此,异步 I/O 在具有更多对等方和更大数据量时具有更好的扩展性。我们将研究通信的同步和异步模型。虽然异步交互的编程模型是事件驱动的且更复杂,但使用 Boost Asio 协程可以使其非常易于管理。在编写 UDP 和 TCP 服务器之前,我们将看一下 Asio 截止时间定时器,以了解如何使用 Asio 编写同步和异步逻辑。

Asio 截止时间定时器

Asio 提供了basic_deadline_timer模板,使用它可以等待特定持续时间的过去或绝对时间点。特化的deadline_timer定义如下:

typedef basic_deadline_timer<boost::posix_time::ptime> 
                                             deadline_timer;

它使用boost::posix_time::ptimeboost::posix_time::time_duration作为时间点和持续时间类型。下面的例子演示了一个应用程序如何使用deadline_timer等待一段时间:

清单 11.12:同步等待

 1 #include <boost/asio.hpp>
 2 #include <boost/date_time.hpp>
 3 #include <iostream>
 4
 5 int main() {
 6   boost::asio::io_service service;
 7   boost::asio::deadline_timer timer(service);
 8
 9   long secs = 5;
10   std::cout << "Waiting for " << secs << " seconds ..." 
11             << std::flush;
12   timer.expires_from_now(boost::posix_time::seconds(secs));
13
14   timer.wait();
15 
16   std::cout << " done\n";
17 }

我们创建了一个io_service对象(第 6 行),它作为底层操作的通道。我们创建了一个与io_service相关联的deadline_timer实例(第 7 行)。我们使用deadline_timerexpires_from_now成员函数指定了一个 5 秒的等待时间(第 12 行)。然后我们调用wait成员函数来阻塞直到时间到期。注意我们不需要在io_service实例上调用run。我们可以使用expires_at成员函数来等待到特定的时间点,就像这样:

using namespace boost::gregorian;
using namespace boost::posix_time;

timer.expires_at(day_clock::local_day(), 
                 hours(16) + minutes(12) + seconds(58));

有时,程序不想阻塞等待定时器触发,或者一般来说,不想阻塞等待它感兴趣的任何未来事件。与此同时,它可以完成其他有价值的工作,因此比起阻塞等待事件,它可以更加响应。我们不想在事件上阻塞,只是想告诉定时器在触发时通知我们,并且同时进行其他工作。为此,我们调用async_wait成员函数,并传递一个完成处理程序。完成处理程序是我们使用async_wait注册的函数对象,一旦定时器到期就会被调用:

清单 11.13:异步等待

 1 #include <boost/asio.hpp>
 2 #include <boost/date_time.hpp>
 3 #include <iostream>
 4
 5 void on_timer_expiry(const boost::system::error_code& ec)
 6 {
 7   if (ec) {
 8     std::cout << "Error occurred while waiting\n";
 9   } else {
10     std::cout << "Timer expired\n";
11   }
12 }
13
14 int main()
15 {
16   boost::asio::io_service service;
17   boost::asio::deadline_timer timer(service);
18
19
20   long secs = 5;
21   timer.expires_from_now(boost::posix_time::seconds(secs));
22
23   std::cout << "Before calling deadline_timer::async_wait\n";
24   timer.async_wait(on_timer_expiry);
25   std::cout << "After calling deadline_timer::async_wait\n";
26
27   service.run();
28 }

与清单 11.12 相比,清单 11.13 有两个关键的变化。我们调用deadline_timerasync_wait成员函数而不是wait,并传递一个指向完成处理程序函数on_timer_expiry的指针。然后在io_service对象上调用run。当我们运行这个程序时,它会打印以下内容:

Before calling deadline_timer::async_wait
After calling deadline_timer::async_wait
Timer expired

调用async_wait不会阻塞(第 24 行),因此前两行消息会快速连续打印出来。随后,调用run(第 27 行)会阻塞直到定时器到期,并且定时器的完成处理程序被调度。除非发生了错误,否则完成处理程序会打印Timer expired。因此,第一和第二条消息出现与第三条消息之间存在时间差,第三条消息是完成处理程序的输出。

使用 Asio 协程的异步逻辑

deadline_timerasync_wait成员函数启动了一个异步操作。这样的函数在启动的操作完成之前就返回了。它注册了一个完成处理程序,并且通过调用这个处理程序来通知程序异步事件的完成。如果我们需要按顺序运行这样的异步操作,控制流就会变得复杂。例如,假设我们想等待 5 秒,打印Hello,然后再等待 10 秒,最后打印world。使用同步的wait,就像下面的代码片段一样简单:

boost::asio::deadline_timer timer;
timer.expires_from_now(boost::posix_time::seconds(5));
timer.wait();
std::cout << "Hello, ";
timer.expires_from_now(boost::posix_time::seconds(10));
timer.wait();
std::cout << "world!\n";

在许多现实场景中,特别是在网络 I/O 中,阻塞同步操作根本不是一个选择。在这种情况下,代码变得更加复杂。使用async_wait作为模型异步操作,下面的例子演示了异步代码的复杂性:

清单 11.14:异步操作

 1 #include <boost/asio.hpp>
 2 #include <boost/bind.hpp>
 3 #include <boost/date_time.hpp>
 4 #include <iostream>
 5
 6 void print_world(const boost::system::error_code& ec) {
 7   std::cout << "world!\n";
 8 }
 9
10 void print_hello(boost::asio::deadline_timer& timer,
11                  const boost::system::error_code& ec) {
12   std::cout << "Hello, " << std::flush;
13
14   timer.expires_from_now(boost::posix_time::seconds(10));
15   timer.async_wait(print_world);
16 }
17
18 int main()
19 {
20   boost::asio::io_service service;
21   boost::asio::deadline_timer timer(service);
22   timer.expires_from_now(boost::posix_time::seconds(5));
23
24   timer.async_wait(boost::bind(print_hello, boost::ref(timer),
25                                            ::_1));
26
27   service.run();
28 }

将相同功能从同步逻辑转换为异步逻辑,代码行数超过两倍,控制流也变得复杂。我们将函数print_hello(第 10 行)注册为第一个 5 秒等待的完成处理程序(第 22、24 行)。print_hello又使用同一个定时器开始了一个 10 秒的等待,并将函数print_world(第 6 行)注册为这个等待的完成处理程序(第 14-15 行)。

请注意,我们使用boost::bind为第一个 5 秒等待生成完成处理程序,将timermain函数传递给print_hello函数。因此,print_hello函数使用相同的计时器。为什么我们需要这样做呢?首先,print_hello需要使用相同的io_service实例来启动 10 秒等待操作和之前的 5 秒等待。timer实例引用了这个io_service实例,并且被两个完成处理程序使用。此外,在print_hello中创建一个本地的deadline_timer实例会有问题,因为print_hello会在计时器响起之前返回,并且本地计时器对象会被销毁,所以它永远不会响起。

示例 11.14 说明了控制流反转的问题,在异步编程模型中是一个重要的复杂性来源。我们不能再将一系列语句串在一起,并假设每个语句只有在前面的操作完成后才会启动一个操作——这对于同步模型是一个安全的假设。相反,我们依赖于io_service的通知来确定运行下一个操作的正确时间。逻辑在函数之间分散,需要更多的努力来管理需要在这些函数之间共享的任何数据。

Asio 使用 Boost Coroutine 库的薄包装简化了异步编程。与 Boost Coroutine 一样,可以使用有栈和无栈协程。在本书中,我们只研究有栈协程。

使用boost::asio::spawn函数模板,我们可以启动任务作为协程。如果一个协程被调度并调用了一个异步函数,那么协程会被暂停。与此同时,io_service会调度其他任务,包括其他协程。一旦异步操作完成,启动它的协程会恢复,并继续下一步。在下面的清单中,我们使用协程重写清单 11.14:

清单 11.15:使用协程进行异步编程

 1 #include <boost/asio.hpp>
 2 #include <boost/asio/spawn.hpp>
 3 #include <boost/bind.hpp>
 4 #include <boost/date_time.hpp>
 5 #include <iostream>
 6
 7 void wait_and_print(boost::asio::yield_context yield,
 8                     boost::asio::io_service& service)
 9 {
10   boost::asio::deadline_timer timer(service);
11
12   timer.expires_from_now(boost::posix_time::seconds(5));
13   timer.async_wait(yield);
14   std::cout << "Hello, " << std::flush;
15 
16   timer.expires_from_now(boost::posix_time::seconds(10));
17   timer.async_wait(yield);
18   std::cout << "world!\n";
19 }
20
21 int main()
22 {
23   boost::asio::io_service service;
24   boost::asio::spawn(service,
25           boost::bind(wait_and_print, ::_1, 
26                                       boost::ref(service)));
27   service.run();
28 }

wait_and_print函数是协程,接受两个参数:一个boost::asio::yield_context类型的对象和一个io_service实例的引用(第 7 行)。yield_context是 Boost Coroutine 的薄包装。我们必须使用boost::asio::spawn来调度一个协程,这样一个协程必须具有void (boost::asio::yield_context)的签名。因此,我们使用boost::bind来使wait_and_print函数与spawn期望的协程签名兼容。我们将第二个参数绑定到io_service实例的引用(第 24-26 行)。

wait_and_print协程在堆栈上创建一个deadline_timer实例,并开始一个 5 秒的异步等待,将其yield_context传递给async_wait函数,而不是完成处理程序。这会暂停wait_and_print协程,只有在等待完成后才会恢复。与此同时,如果有其他任务,可以从io_service队列中处理。等待结束并且wait_and_print恢复后,它打印Hello并开始等待 10 秒。协程再次暂停,只有在 10 秒后才会恢复,然后打印world。协程使异步逻辑与同步逻辑一样简单易读,开销很小。在接下来的章节中,我们将使用协程来编写 TCP 和 UDP 服务器。

UDP

UDP I/O 模型相对简单,客户端和服务器之间的区别模糊不清。对于使用 UDP 的网络 I/O,我们创建一个 UDP 套接字,并使用send_toreceive_from函数将数据报发送到特定的端点。

同步 UDP 客户端和服务器

在本节中,我们编写了一个 UDP 客户端(清单 11.16)和一个同步 UDP 服务器(清单 11.17)。UDP 客户端尝试向给定端点的 UDP 服务器发送一些数据。UDP 服务器阻塞等待从一个或多个 UDP 客户端接收数据。发送数据后,UDP 客户端阻塞等待从服务器接收响应。服务器在接收数据后,在继续处理更多传入消息之前发送一些响应。

清单 11.16:同步 UDP 客户端

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 #include <exception>
 4 namespace asio = boost::asio;
 5
 6 int main(int argc, char *argv[]) {
 7   if (argc < 3) {
 8     std::cerr << "Usage: " << argv[0] << " host port\n";
 9     return 1;
10   }
11
12   asio::io_service service;
13   try {
14     asio::ip::udp::resolver::query query(asio::ip::udp::v4(),
15                                        argv[1], argv[2]);
16     asio::ip::udp::resolver resolver(service);
17     auto iter = resolver.resolve(query);
18     asio::ip::udp::endpoint endpoint = iter->endpoint();
19   
20     asio::ip::udp::socket socket(service, 
21                                  asio::ip::udp::v4());
22     const char *msg = "Hello from client";
23     socket.send_to(asio::buffer(msg, strlen(msg)), endpoint);
24     char buffer[256];
25     size_t recvd = socket.receive_from(asio::buffer(buffer,
26                                  sizeof(buffer)), endpoint);
27     buffer[recvd] = 0;
28     std::cout << "Received " << buffer << " from " 
29        << endpoint.address() << ':' << endpoint.port() << '\n';
30   } catch (std::exception& e) {
31     std::cerr << e.what() << '\n';
32   }
33 }

我们通过命令行向客户端传递服务器主机名和要连接的服务(或端口)。它们会解析为 UDP 的端点(IP 地址和端口号)(第 13-17 行),为 IPv4 创建一个 UDP 套接字(第 18 行),并在其上调用send_to成员函数。我们传递给send_to一个包含要发送的数据和目标端点的const_buffer(第 23 行)。

每个使用 Asio 执行网络 I/O 的程序都使用I/O 服务,它是类型boost::asio::io_service的实例。我们已经看到io_service作为任务管理器的作用。但是 I/O 服务的主要作用是作为底层操作的接口。Asio 程序使用负责启动 I/O 操作的I/O 对象。例如,套接字就是 I/O 对象。

我们调用 UDP 套接字的send_to成员函数,向服务器发送预定义的消息字符串(第 23 行)。请注意,我们将消息数组包装在长度为 1 的缓冲区序列中,该序列使用boost::asio::buffer函数构造,如本章前面所示,在缓冲区部分。一旦send_to完成,客户端在同一套接字上调用recv_from,传递一个可变的缓冲区序列,该序列由可写字符数组使用boost::asio::buffer构造(第 25-26 行)。receive_from的第二个参数是对boost::asio::ip::udp::endpoint对象的非 const 引用。当receive_from返回时,该对象包含发送消息的远程端点的地址和端口号(第 28-29 行)。

调用send_toreceive_from的是阻塞调用。调用send_to不会返回,直到传递给它的缓冲区已经被写入系统中的底层 UDP 缓冲区。将 UDP 缓冲区通过网络发送到服务器可能会在稍后发生。调用receive_from不会返回,直到接收到一些数据为止。

我们可以使用单个 UDP 套接字向多个其他端点发送数据,并且可以在单个套接字上从多个其他端点接收数据。因此,每次调用send_to都将目标端点作为输入。同样,每次调用receive_from都会使用非 const 引用传递一个端点,并在返回时将其设置为发送方的端点。现在我们将使用 Asio 编写相应的 UDP 服务器:

清单 11.17:同步 UDP 服务器

 1 #include <boost/asio.hpp>
 2 #include <exception>
 4 #include <iostream>
 5 namespace asio = boost::asio;
 6
 8 int main() 
 9 {
10   const unsigned short port = 55000;
11   const std::string greet("Hello, world!");
12
13   asio::io_service service;
14   asio::ip::udp::endpoint endpoint(asio::ip::udp::v4(), port);
15   asio::ip::udp::socket socket(service, endpoint);
16   asio::ip::udp::endpoint ep;
17
18   while (true) try {	
19     char msg[256];
20     auto recvd = socket.receive_from(asio::buffer(msg, 
21                                             sizeof(msg)), ep);
22     msg[recvd] = 0;
23     std::cout << "Received: [" << msg << "] from [" 
24               << ep << "]\n";
25
26     socket.send_to(asio::buffer(greet.c_str(), greet.size()),
27                    ep);
27     socket.send_to(asio::buffer(msg, strlen(msg)), ep);
28   } catch (std::exception& e) {
29     std::cout << e.what() << '\n';
30   }
31 }

同步 UDP 服务器在端口 55000 上创建一个boost::asio::ip::udp::endpoint类型的单个 UDP 端点,保持地址未指定(第 14 行)。请注意,我们使用了一个两参数的endpoint构造函数,它将协议和端口作为参数。服务器为此端点创建一个boost::asio::ip::udp::socket类型的单个 UDP 套接字(第 15 行),并在循环中旋转,每次迭代调用套接字上的receive_from,等待直到客户端发送一些数据。数据以一个名为msgchar数组接收,该数组被包装在长度为一的可变缓冲序列中传递给receive_fromreceive_from的调用返回接收到的字节数,用于在msg中添加一个终止空字符,以便它可以像 C 风格的字符串一样使用(第 22 行)。一般来说,UDP 将传入的数据呈现为包含一系列字节的消息,其解释留给应用程序。每当服务器从客户端接收数据时,它会将发送的数据回显,先前由一个固定的问候字符串。它通过在套接字上两次调用send_to成员函数来实现,传递要发送的缓冲区和接收方的端点(第 26-27 行,28 行)。

send_toreceive_from的调用是同步的,只有当数据完全传递给操作系统(send_to)或应用程序完全接收到数据(receive_from)时才会返回。如果许多客户端实例同时向服务器发送消息,服务器仍然只能一次处理一条消息,因此客户端排队等待响应。当然,如果客户端不等待响应,它们可以全部发送消息并退出,但消息仍然会按顺序被服务器接收。

异步 UDP 服务器

UDP 服务器的异步版本可以显著提高服务器的响应性。传统的异步模型可能涉及更复杂的编程模型,但协程可以显著改善情况。

使用完成处理程序链的异步 UDP 服务器

对于异步通信,我们使用socketasync_receive_fromasync_send_to成员函数。这些函数不会等待 I/O 请求被操作系统处理,而是立即返回。它们被传递一个函数对象,当底层操作完成时将被调用。这个函数对象被排队在io_service的任务队列中,在操作系统上的实际操作返回时被调度。

template <typename MutableBufSeq, typename ReadHandler>
deduced async_receive_from(
    const MutableBufSeq& buffers,
    endpoint_type& sender_ep,
 ReadHandler handler);

template <typename ConstBufSeq, typename WriteHandler>
deduced async_send_to(
    const ConstBufSeq& buffers,
    endpoint_type& sender_ep,
 WriteHandler handler);

传递给async_receive_from的读处理程序和传递给async_send_to的写处理程序的签名如下:

void(const boost::system::error_code&, size_t)

处理程序期望传递一个非 const 引用给error_code对象,指示已完成操作的状态和读取或写入的字节数。处理程序可以调用其他异步 I/O 操作并注册其他处理程序。因此,整个 I/O 操作是以一系列处理程序的链条来定义的。现在我们来看一个异步 UDP 服务器的程序:

清单 11.18:异步 UDP 服务器

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 namespace asio = boost::asio;
 4 namespace sys = boost::system;
 5
 6 const size_t MAXBUF = 256;
 7
 8 class UDPAsyncServer {
 9 public:
10   UDPAsyncServer(asio::io_service& service, 
11                  unsigned short port) 
12      : socket(service, 
13           asio::ip::udp::endpoint(asio::ip::udp::v4(), port))
14   {  waitForReceive();  }
15
16   void waitForReceive() {
17     socket.async_receive_from(asio::buffer(buffer, MAXBUF),
18           remote_peer,
19           [this] (const sys::error_code& ec,
20                   size_t sz) {
21             const char *msg = "hello from server";
22             std::cout << "Received: [" << buffer << "] "
23                       << remote_peer << '\n';
24             waitForReceive();
25
26             socket.async_send_to(
27                 asio::buffer(msg, strlen(msg)),
28                 remote_peer,
29                 this {});
31           });
32   }
33
34 private:
35   asio::ip::udp::socket socket;
36   asio::ip::udp::endpoint remote_peer;
37   char buffer[MAXBUF];
38 };
39
40 int main() {
41   asio::io_service service;
42   UDPAsyncServer server(service, 55000);
43   service.run();
44 }

UDP 服务器封装在UDPAsyncServer类中(第 8 行)。要启动服务器,我们首先创建必需的io_service对象(第 42 行),然后创建一个UDPAsyncServer实例(第 43 行),该实例传递了io_service实例和应该使用的端口号。最后,调用io_servicerun成员函数开始处理传入的请求(第 44 行)。那么UDPAsyncServer是如何工作的呢?

UDPAsyncServer的构造函数使用本地端点初始化了 UDP socket成员(第 12-13 行)。然后调用成员函数waitForReceive(第 14 行),该函数又在套接字上调用async_receive_from(第 18 行),开始等待任何传入的消息。我们调用async_receive_from,传递了从buffer成员变量制作的可变缓冲区(第 17 行),对remote_peer成员变量的非 const 引用(第 18 行),以及一个定义接收操作完成处理程序的 lambda 表达式(第 19-31 行)。async_receive_from启动了一个 I/O 操作,将处理程序添加到io_service的任务队列中,然后返回。对io_servicerun调用(第 43 行)会阻塞,直到队列中有 I/O 任务。当 UDP 消息到来时,数据被操作系统接收,并调用处理程序来采取进一步的操作。要理解 UDP 服务器如何无限处理更多消息,我们需要了解处理程序的作用。

接收处理程序在服务器接收到消息时被调用。它打印接收到的消息和远程发送者的详细信息(第 22-23 行),然后发出对waitForReceive的调用,从而重新启动接收操作。然后它发送一条消息hello from server(第 21 行)回到由remote_peer成员变量标识的发送者。它通过调用 UDP socketasync_send_to成员函数来实现这一点,传递消息缓冲区(第 27 行),目标端点(第 28 行),以及另一个以 lambda 形式的处理程序(第 29-32 行),该处理程序什么也不做。

请注意,我们在 lambda 中捕获了this指针,以便能够从周围范围访问成员变量(第 20 行,29 行)。另外,处理程序都没有使用error_code参数进行错误检查,这在现实世界的软件中是必须的。

使用协程的异步 UDP 服务器

处理程序链接将逻辑分散到一组处理程序中,并且在处理程序之间共享状态变得特别复杂。这是为了更好的性能,但我们可以避免这个代价,就像我们在列表 11.15 中使用 Asio 协程处理boost::asio::deadline_timer上的异步等待一样。现在我们将使用 Asio 协程来编写一个异步 UDP 服务器:

列表 11.19:使用 Asio 协程的异步 UDP 服务器

 1 #include <boost/asio.hpp>
 2 #include <boost/asio/spawn.hpp>
 3 #include <boost/bind.hpp>
 4 #include <boost/shared_ptr.hpp>
 5 #include <boost/make_shared.hpp>
 6 #include <iostream>
 7 namespace asio = boost::asio;
 8 namespace sys = boost::system;
 9
10 const size_t MAXBUF = 256;
11 typedef boost::shared_ptr<asio::ip::udp::socket>
12                                   shared_udp_socket;
13
14 void udp_send_to(boost::asio::yield_context yield,
15                  shared_udp_socket socket,
16                  asio::ip::udp::endpoint peer)
17 {
18     const char *msg = "hello from server";
19     socket->async_send_to(asio::buffer(msg, std::strlen(msg)),
20                          peer, yield);
21 }
22
23 void udp_server(boost::asio::yield_context yield,
24                 asio::io_service& service,
25                 unsigned short port)
26 {
27   shared_udp_socket socket =
28       boost::make_shared<asio::ip::udp::socket>(service,
29           asio::ip::udp::endpoint(asio::ip::udp::v4(), port));
30
31   char buffer[MAXBUF];
32   asio::ip::udp::endpoint remote_peer;
33   boost::system::error_code ec;
34
35   while (true) {
36     socket->async_receive_from(asio::buffer(buffer, MAXBUF),
37                 remote_peer, yield[ec]);
38
39     if (!ec) {
40       spawn(socket->get_io_service(), 
41         boost::bind(udp_send_to, ::_1, socket,
42                                  remote_peer));
43     }
44   }
45 }
46
47 int main() {
48   asio::io_service service;
49   spawn(service, boost::bind(udp_server, ::_1,
50                      boost::ref(service), 55000));
51   service.run();                               
52 }

通过使用协程,异步 UDP 服务器的结构与列表 11.18 有了相当大的变化,并且更接近列表 11.17 的同步模型。函数udp_server包含了 UDP 服务器的核心逻辑(第 23 行)。它被设计为协程使用,因此它的一个参数是boost::asio::yield_context类型(第 23 行)。它还接受两个额外的参数:对io_service实例的引用(第 24 行)和 UDP 服务器端口(第 25 行)。

在主函数中,我们创建了一个io_service实例(第 48 行),然后添加一个任务以将udp_server作为协程运行,使用boost::asio::spawn函数模板(第 49-50 行)。我们适当地绑定了udp_server的服务和端口参数。然后我们调用io_service实例上的run来开始处理 I/O 操作。对run的调用会派发udp_server协程(第 51 行)。

udp_server 协程创建一个与未指定的 IPv4 地址(0.0.0.0)和作为参数传递的特定端口相关联的 UDP 套接字(第 27-29 行)。 套接字被包装在 shared_ptr 中,稍后将清楚其原因。 协程堆栈上有额外的变量来保存从客户端接收的数据(第 31 行)并标识客户端端点(第 32 行)。 udp_server 函数然后在循环中旋转,调用套接字上的 async_receive_from,传递接收处理程序的 yield_context(第 36-37 行)。 这会暂停 udp_server 协程的执行,直到 async_receive_from 完成。 与此同时,对 run 的调用会恢复并处理其他任务(如果有)。 一旦调用 async_receive_from 函数完成,udp_server 协程将恢复执行并继续进行其循环的下一次迭代。

对于每个完成的接收操作,udp_server 都会发送一个固定的问候字符串(“来自服务器的问候”)作为对客户端的响应。 发送这个问候的任务也封装在一个协程中,即 udp_send_to(第 14 行),udp_server 协程使用 spawn(第 40 行)将其添加到任务队列中。 我们将 UDP 套接字和标识客户端的端点作为参数传递给这个协程。 请注意,称为 remote_peer 的局部变量被按值传递给 udp_send_to 协程(第 42 行)。 这在 udp_send_to 中被使用,作为 async_send_to 的参数,用于指定响应的接收者(第 19-20 行)。 我们传递副本而不是引用给 remote_peer,因为当发出对 async_send_to 的调用时,另一个对 async_receive_from 的调用可能是活动的,并且可能在 async_send_to 使用之前覆盖 remote_peer 对象。 我们还传递了包装在 shared_ptr 中的套接字。 套接字不可复制,不像端点。 如果套接字对象在 udp_server 函数中的自动存储中,并且在仍有待处理的 udp_send_to 任务时 udp_server 退出,那么 udp_send_to 中的套接字引用将无效,并可能导致崩溃。 出于这个原因,shared_ptr 包装器是正确的选择。

如果您注意到,对 async_receive_from 的处理程序写为 yield[ec](第 37 行)。 yield_context 类具有重载的下标运算符,我们可以使用它来指定对 error_code 类型的变量的可变引用。 当异步操作完成时,作为下标运算符参数传递的变量将设置为错误代码(如果有)。

提示

在编写异步服务器时,更倾向于使用协程而不是处理程序链。 协程使代码更简单,控制流更直观。

性能和并发

我们声称异步通信模式提高了服务器的响应性。 让我们确切地了解哪些因素导致了这种改进。 在列表 11.17 的同步模型中,除非 send_to 函数返回,否则无法发出对 receive_from 的调用。 在列表 11.18 的异步代码中,一旦接收并消耗了消息,就会立即调用 waitForReceive(第 23-25 行),它不会等待 async_send_to 完成。 同样,在列表 11.19 中,它展示了在异步模型中使用协程,协程帮助暂停等待异步 I/O 操作完成的函数,并同时继续处理队列中的其他任务。 这是异步服务器响应性改进的主要来源。

值得注意的是,在列表 11.18 中,所有 I/O 都在单个线程上进行。这意味着在任何给定时间点,我们的程序只处理一个传入的 UDP 消息。这使我们能够重用bufferremote_peer成员变量,而不必担心同步。我们仍然必须确保在再次调用waitForReceive之前打印接收到的缓冲区(第 22-23 行)。如果我们颠倒了顺序,缓冲区可能会在打印之前被新的传入消息覆盖。

考虑一下,如果我们像这样在接收处理程序中调用waitForReceive而不是发送处理程序中:

18     socket.async_receive_from(asio::buffer(buffer, MAXBUF),
19           remote_peer,
20           [this] (const sys::error_code& ec,
21                   size_t sz) {
...            ...
26             socket.async_send_to(
27                 asio::buffer(msg, strlen(msg)),
28                 remote_peer,
29                 this {
31                   waitForReceive();
32                 });
33           });

在这种情况下,接收将仅在发送完成后开始;因此,即使使用异步调用,它也不会比列表 11.17 中的同步示例更好。

在列表 11.18 中,我们在发送内容回来时不需要来自远程对等方的缓冲区,因此我们不需要在发送完成之前保留该缓冲区。这使我们能够在不等待发送完成的情况下开始异步接收(第 24 行)。接收可能会首先完成并覆盖缓冲区,但只要发送操作不使用缓冲区,一切都没问题。在现实世界中,这种情况经常发生,因此让我们看看如何在不延迟接收直到发送之后的情况下解决这个问题。以下是处理程序的修改实现:

  17 void waitForReceive() {
 18   boost::shared_array<char> recvbuf(new char[MAXBUF]);
 19   auto epPtr(boost::make_shared<asio::ip::udp::endpoint>());
 20   socket.async_receive_from(
 21         asio::buffer(recvbuf.get(), MAXBUF),
  22         *epPtr,
 23         [this, recvbuf, epPtr] (const sys::error_code& ec,
  24                 size_t sz) {
 25           waitForReceive();
  26
  27           recvbuf[sz] = 0;
  28           std::ostringstream sout;
  29           sout << '[' << boost::this_thread::get_id()
  30                << "] Received: " << recvbuf.get()
  31                << " from client: " << *epPtr << '\n';
  32           std::cout << sout.str() << '\n';
  33           socket.async_send_to(
 34               asio::buffer(recvbuf.get(), sz),
  35               *epPtr,
 36               this, recvbuf, epPtr {
  38               });
  39        });
  40 }

现在,我们不再依赖于一个共享的成员变量作为缓冲区,而是为每个新消息分配一个接收缓冲区(第 18 行)。这消除了列表 11.18 中buffer成员变量的需要。我们使用boost::shared_array包装器,因为这个缓冲区需要从waitForReceive调用传递到接收处理程序,而且只有在最后一个引用消失时才应该释放它。同样,我们移除了代表远程端点的remote_peer成员变量,并为每个新请求使用了一个shared_ptr包装的端点。

我们将底层数组传递给async_receive_from(第 21 行),并通过在async_receive_from的完成处理程序中捕获其shared_array包装器(第 23 行)来确保它存活足够长的时间。出于同样的原因,我们还捕获端点包装器epPtr。接收处理程序调用waitForReceive(第 25 行),然后打印从客户端接收到的消息,并在当前线程的线程 ID 前加上前缀(考虑未来)。然后它调用async_send_to,传递接收到的缓冲区而不是一些固定的消息(第 34 行)。再一次,我们需要确保缓冲区和远程端点在发送完成之前存活;因此,我们在发送完成处理程序中捕获了缓冲区的shared_array包装器和远程端点的shared_ptr包装器(第 36 行)。

基于协程的异步 UDP 服务器的更改(列表 11.19)也是在同样的基础上进行的。

 1 #include <boost/shared_array.hpp>
...
14 void udp_send_to(boost::asio::yield_context yield,
15               shared_udp_socket socket,
16               asio::ip::udp::endpoint peer,
17               boost::shared_array<char> buffer, size_t size)
18 {
19     const char *msg = "hello from server";
20     socket->async_send_to(asio::buffer(msg, std::strlen(msg)),
21                          peer, yield);
22     socket->async_send_to(asio::buffer(buffer.get(), size),
23                           peer, yield);
24 }
25
26 void udp_server(boost::asio::yield_context yield,
27                 asio::io_service& service,
28                 unsigned short port)
29 {
30   shared_udp_socket socket =
31       boost::make_shared<asio::ip::udp::socket>(service,
32           asio::ip::udp::endpoint(asio::ip::udp::v4(), port));
33
34   asio::ip::udp::endpoint remote_peer;
35   boost::system::error_code ec;
36
38   while (true) {
39     boost::shared_array<char> buffer(new char[MAXBUF]);
40     size_t size = socket->async_receive_from(
41                       asio::buffer(buffer.get(), MAXBUF),
42                       remote_peer, yield[ec]);
43
44     if (!ec) {
45       spawn(socket->get_io_service(), 
46         boost::bind(udp_send_to, ::_1, socket, remote_peer,
47                                  buffer, size));
43     }
44   }
45 }

由于需要将从客户端接收的数据回显回去,udp_send_to协程必须访问它。因此,它将包含接收到的数据的缓冲区和读取的字节数作为参数(第 17 行)。为了确保这些数据不会被后续接收覆盖,我们必须在udp_server循环的每次迭代中为接收数据分配缓冲区(第 39 行)。我们将这个缓冲区,以及async_receive_from返回的读取的字节数(第 40 行),传递给udp_send_to(第 47 行)。通过这些更改,我们的异步 UDP 服务器现在可以在响应对等方之前保持每个传入请求的上下文,而无需延迟处理新请求的需要。

这些更改还使处理程序线程安全,因为实质上,我们删除了处理程序之间的任何共享数据。虽然io_service仍然是共享的,但它是一个线程安全的对象。我们可以很容易地将 UDP 服务器转换为多线程服务器。下面是我们如何做到这一点:

46 int main() {
47   asio::io_service service;
48   UDPAsyncServer server(service, 55000);
49
50   boost::thread_group pool;
51   pool.create_thread([&service] { service.run(); });
52   pool.create_thread([&service] { service.run(); });
53   pool.create_thread([&service] { service.run(); });
54   pool.create_thread([&service] { service.run(); });
55   pool.join_all();
56 }

这将创建四个处理传入 UDP 消息的工作线程。使用协程也可以实现相同的功能。

TCP

在网络 I/O 方面,UDP 的编程模型非常简单——你要么发送消息,要么接收消息,要么两者都做。相比之下,TCP 是一个相当复杂的东西,它的交互模型有一些额外的细节需要理解。

除了可靠性保证外,TCP 还实现了几个巧妙的算法,以确保过于热心的发送方不会用大量数据淹没相对较慢的接收方(流量控制),并且所有发送方都能公平地分享网络带宽(拥塞控制)。TCP 层需要进行相当多的计算来实现这一切,并且需要维护一些状态信息来执行这些计算。为此,TCP 使用端点之间的连接

建立 TCP 连接

TCP 连接由一对 TCP 套接字组成,可能位于不同主机上,通过 IP 网络连接,并带有一些相关的状态数据。相关的连接状态信息在连接的每一端都得到维护。TCP 服务器通常开始监听传入连接,被称为连接的被动端TCP 客户端发起连接到 TCP 服务器的请求,并被称为主动端。一个被称为TCP 三次握手的明确定义的机制用于建立 TCP 连接。类似的机制也存在于协调连接终止。连接也可以被单方面重置或终止,比如在应用程序或主机因各种原因关闭或发生不可恢复的错误的情况下。

客户端和服务器端的调用

要建立 TCP 连接,服务器进程必须在一个端点上监听,并且客户端进程必须主动发起到该端点的连接。服务器执行以下步骤:

  1. 创建一个 TCP 监听套接字。

  2. 为监听传入连接创建一个本地端点,并将 TCP 监听套接字绑定到该端点。

  3. 开始在监听器上监听传入的连接。

  4. 接受任何传入的连接,并打开一个服务器端点(与监听端点不同)来服务该连接。

  5. 在该连接上进行通信。

  6. 处理连接的终止。

  7. 继续监听其他传入的连接。

客户端依次执行以下步骤:

  1. 创建一个 TCP 套接字,并可选地将其绑定到本地端点。

  2. 连接到由 TCP 服务器提供服务的远程端点。

  3. 一旦连接建立,就在该连接上进行通信。

  4. 处理连接的终止。

同步 TCP 客户端和服务器

我们现在将编写一个 TCP 客户端,它连接到指定主机和端口上的 TCP 服务器,向服务器发送一些文本,然后从服务器接收一些消息:

清单 11.20:同步 TCP 客户端

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 namespace asio = boost::asio;
 4
 5 int main(int argc, char* argv[]) {
 6   if (argc < 3) {
 7     std::cerr << "Usage: " << argv[0] << " host port\n";
 8     exit(1);
 9   }
10
11   const char *host = argv[1], *port = argv[2];
12
13   asio::io_service service;
14   asio::ip::tcp::resolver resolver(service);
15   try {
16     asio::ip::tcp::resolver::query query(asio::ip::tcp::v4(),
17                                        host, port);
18     asio::ip::tcp::resolver::iterator end, 
19                        iter = resolver.resolve(query);
20
21     asio::ip::tcp::endpoint server(iter->endpoint());
22     std::cout << "Connecting to " << server << '\n';
23     asio::ip::tcp::socket socket(service, 
24                                  asio::ip::tcp::v4());
25     socket.connect(server);
26     std::string message = "Hello from client";
27     asio::write(socket, asio::buffer(message.c_str(),
28                                    message.size()));
29     socket.shutdown(asio::ip::tcp::socket::shutdown_send);
30 
31     char msg[BUFSIZ];
32     boost::system::error_code ec;
33     size_t sz = asio::read(socket, 
34                          asio::buffer(msg, BUFSIZ), ec);
35     if (!ec || ec == asio::error::eof) {
36       msg[sz] = 0;
37       std::cout << "Received: " << msg << '\n';
38     } else {
39       std::cerr << "Error reading response from server: "
40                 << ec.message() << '\n';
41     }
34   } catch (std::exception& e) {
35     std::cerr << e.what() << '\n';
36   }
37 }

TCP 客户端解析传递给它的主机和端口(或服务名称)(第 16-19 行),并创建一个表示要连接的服务器的端点(第 21 行)。它创建一个 IPv4 套接字(第 23 行),并调用connect成员函数来启动与远程服务器的连接(第 25 行)。connect调用会阻塞,直到建立连接,或者如果连接尝试失败则抛出异常。连接成功后,我们使用boost::asio::write函数将文本Hello from client发送到服务器(第 27-28 行)。我们调用套接字的shutdown成员函数,参数为shutdown_send(第 29 行),关闭与服务器的写通道。这在服务器端显示为 EOF。然后我们使用read函数接收服务器发送的任何消息(第 33-34 行)。boost::asio::writeboost::asio::read都是阻塞调用。对于失败的write调用会抛出异常,例如,如果连接被重置或由于服务器繁忙而发送超时。我们调用read的非抛出重载,在失败时,它会将我们传递给它的错误代码设置为非 const 引用。

函数boost::asio::read尝试读取尽可能多的字节以填充传递的缓冲区,并阻塞,直到所有数据到达或接收到文件结束符。虽然文件结束符被read标记为错误条件,但它可能只是表示服务器已经完成发送数据,我们对接收到的任何数据感兴趣。因此,我们特别使用read的非抛出重载,并在error_code引用中设置错误时,区分文件结束符和其他错误(第 35 行)。出于同样的原因,我们调用shutdown关闭此连接的写通道(第 29 行),以便服务器不等待更多输入。

提示

与 UDP 不同,TCP 是面向流的,并且不定义消息边界。应用程序必须定义自己的机制来识别消息边界。一些策略包括在消息前面加上消息的长度,使用字符序列作为消息结束标记,或者使用固定长度的消息。在本书的示例中,我们使用tcp::socketshutdown成员函数,这会导致接收方读取文件结束符,表示我们已经完成发送消息。这使示例保持简单,但实际上,这不是最灵活的策略。

现在让我们编写 TCP 服务器,它将处理来自此客户端的请求:

清单 11.21:同步 TCP 服务器

 1 #include <boost/asio.hpp>
 2 #include <boost/thread.hpp>
 3 #include <boost/shared_ptr.hpp>
 4 #include <boost/array.hpp>
 5 #include <iostream>
 6 namespace asio = boost::asio;
 7
 8 typedef boost::shared_ptr<asio::ip::tcp::socket> socket_ptr;
 9
10 int main() {
11   const unsigned short port = 56000;
12   asio::io_service service;
13   asio::ip::tcp::endpoint endpoint(asio::ip::tcp::v4(), port);
14   asio::ip::tcp::acceptor acceptor(service, endpoint);
15
16   while (true) {
17     socket_ptr socket(new asio::ip::tcp::socket(service));
18     acceptor.accept(*socket);
19     boost::thread([socket]() {
20       std::cout << "Service request from "
21                 << socket->remote_endpoint() << '\n';
22       boost::array<asio::const_buffer, 2> bufseq;
23       const char *msg = "Hello, world!";
24       const char *msg2 = "What's up?";
25       bufseq[0] = asio::const_buffer(msg, strlen(msg));
26       bufseq[1] = asio::const_buffer(msg2, strlen(msg2));
27 
28       try {
29         boost::system::error_code ec;
30         char recvbuf[BUFSIZ];
31         auto sz = read(*socket, asio::buffer(recvbuf,
32                                             BUFSIZ), ec);
33         if (!ec || ec == asio::error::eof) {
34           recvbuf[sz] = 0;
35           std::cout << "Received: " << recvbuf << " from "
36                     << socket->remote_endpoint() << '\n';
37           write(*socket, bufseq);
38           socket->close();
39         }
40       } catch (std::exception& e) {
41         std::cout << "Error encountered: " << e.what() << '\n';
42       }
43     });
44   }
45 }

TCP 服务器的第一件事是创建一个监听套接字并将其绑定到本地端点。使用 Boost Asio,您可以通过创建asio::ip::tcp::acceptor的实例并将其传递给要绑定的端点来实现这一点(第 14 行)。我们创建一个 IPv4 端点,只指定端口而不指定地址,以便使用未指定地址 0.0.0.0(第 13 行)。我们通过将其传递给acceptor的构造函数将端点绑定到监听器(第 14 行)。然后我们在循环中等待传入的连接(第 16 行)。我们需要一个独立的套接字来作为每个新连接的服务器端点,因此我们创建一个新的套接字(第 17 行)。然后我们在接受器上调用accept成员函数(第 18 行),将新套接字传递给它。accept调用会阻塞,直到建立新连接。当accept返回时,传递给它的套接字表示建立的连接的服务器端点。

我们创建一个新线程来为每个建立的新连接提供服务(第 19 行)。我们使用 lambda(第 19-44 行)生成此线程的初始函数,捕获此连接的shared_ptr包装的服务器端socket(第 19 行)。在线程内部,我们调用read函数来读取客户端发送的数据(第 31-32 行),然后使用write写回数据(第 37 行)。为了展示如何做到这一点,我们从两个字符字符串设置的多缓冲序列中发送数据(第 22-26 行)。此线程中的网络 I/O 在 try 块内完成,以确保没有异常逃逸出线程。请注意,在write返回后我们在 socket 上调用close(第 38 行)。这关闭了服务器端的连接,客户端在接收流中读取到文件结束符。

并发和性能

TCP 服务器独立处理每个连接。但是为每个新连接创建一个新线程的扩展性很差,如果在非常短的时间内有大量连接到达服务器,服务器的资源可能会耗尽。处理这种情况的一种方法是限制线程数量。之前,我们修改了清单 11.18 中的 UDP 服务器示例,使用了线程池并限制了总线程数量。我们可以对清单 11.21 中的 TCP 服务器做同样的事情。以下是如何实现的概述:

12 asio::io_service service;
13 boost::unique_ptr<asio::io_service::work> workptr(
14                                    new dummyWork(service));
15 auto threadFunc = [&service] { service.run(); };
16 
17 boost::thread_group workers;
18 for (int i = 0; i < max_threads; ++i) { //max_threads
19   workers.create_thread(threadFunc);
20 }
21
22 asio::ip::tcp::endpoint ep(asio::ip::tcp::v4(), port);
23 asio::ip::tcp::acceptor acceptor(service, ep);24 while (true) {
25   socket_ptr socket(new asio::ip::tcp::socket(service));
26   acceptor.accept(*socket);
27
28   service.post([socket] { /* do I/O on the connection */ });
29 }
30
31 workers.join_all();
32 workptr.reset(); // we don't reach here

首先,我们创建了一个固定数量线程的线程池(第 15-20 行),并通过向io_service的任务队列发布一个虚拟工作(第 13-14 行)来确保它们不会退出。我们不是为每个新连接创建一个线程,而是将连接的处理程序发布到io_service的任务队列中(第 28 行)。这个处理程序可以与清单 11.21 中每个连接线程的初始函数完全相同。然后线程池中的线程按照自己的时间表分派处理程序。max_threads表示的线程数量可以根据系统中的处理器数量轻松调整。

虽然使用线程池限制了线程数量,但对于服务器的响应性几乎没有改善。在大量新连接涌入时,新连接的处理程序会在队列中形成一个大的积压,这些客户端将被保持等待,而服务器则服务于先前的连接。我们已经通过使用异步 I/O 在 UDP 服务器中解决了类似的问题。在下一节中,我们将使用相同的策略来更好地扩展我们的 TCP 服务器。

异步 TCP 服务器

同步 TCP 服务器效率低下主要是因为套接字上的读写操作会阻塞一段有限的时间,等待操作完成。在此期间,即使有线程池,服务连接的线程也只是空闲地等待 I/O 操作完成,然后才能处理下一个可用连接。

我们可以使用异步 I/O 来消除这些空闲等待。就像我们在异步 UDP 服务器中看到的那样,我们可以使用处理程序链或协程来编写异步 TCP 服务器。虽然处理程序链使代码复杂,因此容易出错,但协程使代码更易读和直观。我们将首先使用协程编写一个异步 TCP 服务器,然后再使用更传统的处理程序链,以便更好地理解这两种方法之间的差异。在第一次阅读时,您可以跳过处理程序链的实现。

使用协程的异步 TCP 服务器

以下是使用协程进行异步 I/O 的 TCP 服务器的完整代码:

清单 11.22:使用协程的异步 TCP 服务器

 1 #include <boost/asio.hpp>
 2 #include <boost/asio/spawn.hpp>
 3 #include <boost/thread.hpp>
 4 #include <boost/shared_ptr.hpp>
 5 #include <boost/make_shared.hpp>
 6 #include <boost/bind.hpp>
 7 #include <boost/array.hpp>
 8 #include <iostream>
 9 #include <cstring>
10
11 namespace asio = boost::asio;
12 typedef boost::shared_ptr<asio::ip::tcp::socket> socketptr;
13
14 void handle_connection(asio::yield_context yield,
15                        socketptr socket)
16 {
17   asio::io_service& service = socket->get_io_service();
18   char msg[BUFSIZ];
19   msg[0] = '\0';
20   boost::system::error_code ec;
21   const char *resp = "Hello from server";
22
23   size_t size = asio::async_read(*socket, 
24                      asio::buffer(msg, BUFSIZ), yield[ec]);
25
26   if (!ec || ec == asio::error::eof) {
27     msg[size] = '\0';
28     boost::array<asio::const_buffer, 2> bufseq;
29     bufseq[0] = asio::const_buffer(resp, ::strlen(resp));
30     bufseq[1] = asio::const_buffer(msg, size);
31
32     asio::async_write(*socket, bufseq, yield[ec]);
33     if (ec) {
34       std::cerr << "Error sending response to client: "
35                 << ec.message() << '\n';
36     }
37   } else {
38     std::cout << ec.message() << '\n';
39   }
40 }
41
42 void accept_connections(asio::yield_context yield,
43                         asio::io_service& service,
44                         unsigned short port)
45 {
46   asio::ip::tcp::endpoint server_endpoint(asio::ip::tcp::v4(),
47                                           port);
48   asio::ip::tcp::acceptor acceptor(service, server_endpoint);
49
50   while (true) {
51     auto socket = 
52         boost::make_shared<asio::ip::tcp::socket>(service);
53     acceptor.async_accept(*socket, yield);
54
55     std::cout << "Handling request from client\n";
56     spawn(service, boost::bind(handle_connection, ::_1, 
57                                socket));
58   }
59 }
60
61 int main() {
62   asio::io_service service;
63   spawn(service, boost::bind(accept_connections, ::_1,
64                              boost::ref(service), 56000));
65   service.run();
66 }

我们使用了两个协程:accept_connections处理传入的连接请求(第 42 行),而handle_connection在每个新连接上执行 I/O(第 14 行)。main函数调用spawn函数模板将accept_connections任务添加到io_service队列中,以作为协程运行(第 63 行)。spawn函数模板可通过头文件boost/asio/spawn.hpp(第 2 行)获得。调用io_servicerun成员函数会调用accept_connections协程,该协程在一个循环中等待新的连接请求(第 65 行)。

accept_connections函数除了强制的yield_context之外,还接受两个参数。这些是对io_service实例的引用,以及用于监听新连接的端口——在main函数在生成此协程时绑定的值(第 63-64 行)。accept_connections函数为未指定的 IPv4 地址和传递的特定端口创建一个端点(第 46-47 行),并为该端点创建一个接受者(第 48 行)。然后,在循环的每次迭代中调用接受者的async_accept成员函数,传递一个 TCP 套接字的引用,并将本地的yield_context作为完成处理程序(第 53 行)。这会暂停accept_connections协程,直到接受到新的连接。一旦接收到新的连接请求,async_accept接受它,将传递给它的套接字引用设置为新连接的服务器端套接字,并恢复accept_connections协程。accept_connections协程将handle_connection协程添加到io_service队列中,用于处理此特定连接的 I/O(第 56-57 行)。在下一次循环迭代中,它再次等待新的传入连接。

handle_connection协程除了yield_context之外,还接受一个包装在shared_ptr中的 TCP 套接字作为参数。accept_connections协程创建此套接字,并将其包装在shared_ptr中传递给handle_connectionhandle_connection函数使用async_read接收客户端发送的任何数据(第 23-24 行)。如果接收成功,它会发送一个响应字符串Hello from server,然后使用长度为 2 的缓冲区序列回显接收到的数据(第 28-30 行)。

没有协程的异步 TCP 服务器

现在我们来看如何编写一个没有协程的异步 TCP 服务器。这涉及处理程序之间更复杂的握手,因此,我们希望将代码拆分成适当的类。我们在两个单独的头文件中定义了两个类。TCPAsyncServer类(清单 11.23)表示监听传入连接的服务器实例。它放在asyncsvr.hpp头文件中。TCPAsyncConnection类(清单 11.25)表示单个连接的处理上下文。它放在asynconn.hpp头文件中。

TCPAsyncServer为每个新的传入连接创建一个新的TCPAsyncConnection实例。TCPAsyncConnection实例从客户端读取传入数据,并向客户端发送消息,直到客户端关闭与服务器的连接。

要启动服务器,您需要创建一个TCPAsyncServer实例,传递io_service实例和端口号,然后调用io_servicerun成员函数来开始处理新连接:

清单 11.23:异步 TCP 服务器(asyncsvr.hpp)

 1 #ifndef ASYNCSVR_HPP
 2 #define ASYNCSVR_HPP
 3 #include <boost/asio.hpp>
 4 #include <boost/shared_ptr.hpp>
 5 #include <boost/make_shared.hpp>
 6 #include <iostream>
 7 #include "asynconn.hpp"
 8
 9 namespace asio = boost::asio;
10 namespace sys = boost::system;
11 typedef boost::shared_ptr<TCPAsyncConnection>
12               TCPAsyncConnectionPtr;
13
14 class TCPAsyncServer {
15 public:
16   TCPAsyncServer(asio::io_service& service, unsigned short p)
17           : acceptor(service,
18                     asio::ip::tcp::endpoint(
19                           asio::ip::tcp::v4(), p)) {
20     waitForConnection();
21   }
22
23   void waitForConnection() {
24     TCPAsyncConnectionPtr connectionPtr = boost::make_shared
25           <TCPAsyncConnection>(acceptor.get_io_service());
26     acceptor.async_accept(connectionPtr->getSocket(),
27           this, connectionPtr {
28             if (ec) {
29               std::cerr << "Failed to accept connection: "
30                         << ec.message() << "\n";
31             } else {
32               connectionPtr->waitForReceive();
33               waitForConnection();
34             }
35           });
36   }
37
38 private:
39   asio::ip::tcp::acceptor acceptor;
40 };
41
42 #endif /* ASYNCSVR_HPP */

TCPAsyncServer类具有一个boost::asio::ip::tcp::acceptor类型的接受者成员变量,用于监听和接受传入连接(第 39 行)。构造函数使用未指定的 IPv4 地址和特定端口初始化接受者(第 17-19 行),然后调用waitForConnection成员函数(第 20 行)。

waitForConnection函数创建了一个新的TCPAsyncConnection实例,将其包装在名为connectionPtrshared_ptr中(第 24-25 行),以处理来自客户端的每个新连接。我们已经包含了我们自己的头文件asynconn.hpp来访问TCPAsyncConnection的定义(第 7 行),我们很快就会看到。然后调用 acceptor 的async_accept成员函数来监听新的传入连接并接受它们(第 26-27 行)。我们传递给async_accept一个对TCPAsyncConnectiontcp::socket对象的非 const 引用,以及一个在每次建立新连接时调用的完成处理程序(第 27-35 行)。这是一个异步调用,会立即返回。但每次建立新连接时,套接字引用都会设置为用于服务该连接的服务器端套接字,并调用完成处理程序。

async_accept的完成处理程序被编写为 lambda,并捕获指向TCPAsyncServer实例的this指针和connectionPtr(第 27 行)。这允许 lambda 在TCPAsyncServer实例和为该特定连接提供服务的TCPAsyncConnection实例上调用成员函数。

提示

lambda 表达式生成一个函数对象,并将捕获的connectionPtr复制到其中的一个成员。由于connectionPtr是一个shared_ptr,在此过程中它的引用计数会增加。async_accept函数将此函数对象推送到io_service的任务处理程序队列中,因此TCPAsyncConnection的底层实例会在waitForConnection返回后继续存在。

在连接建立时,当调用完成处理程序时,它会执行两件事。如果没有错误,它会通过在TCPAsyncConnection对象上调用waitForReceive函数(第 32 行)来启动新连接上的 I/O。然后通过调用TCPAsyncServer对象上的waitForConnection(通过捕获的this指针)来重新等待下一个连接(第 33 行)。如果出现错误,它会打印一条消息(第 29-30 行)。waitForConnection调用是异步的,我们很快就会发现waitForReceive调用也是异步的,因为两者都调用了异步 Asio 函数。处理程序返回后,服务器将继续处理现有连接上的 I/O 或接受新连接:

清单 11.24:运行异步服务器

 1 #include <boost/asio.hpp>
 2 #include <boost/thread.hpp>
 3 #include <boost/shared_ptr.hpp>
 4 #include <iostream>
 5 #include "asyncsvr.hpp"
 6 #define MAXBUF 1024
 7 namespace asio = boost::asio;
 8
 9 int main() {
10   try {
11     asio::io_service service;
12     TCPAsyncServer server(service, 56000);
13     service.run();
14   } catch (std::exception& e) {
15     std::cout << e.what() << '\n';
16   }
17 }

要运行服务器,我们只需用io_service和端口号实例化它(第 12 行),然后在io_service上调用run方法(第 13 行)。我们正在构建的服务器将是线程安全的,因此我们也可以从线程池中的每个线程调用run,以在处理传入连接时引入一些并发。现在我们将看到如何处理每个连接上的 I/O:

清单 11.25:每个连接的 I/O 处理程序类(asynconn.hpp)

 1 #ifndef ASYNCONN_HPP
 2 #define ASYNCONN_HPP
 3
 4 #include <boost/asio.hpp>
 5 #include <boost/thread.hpp>
 6 #include <boost/shared_ptr.hpp>
 7 #include <iostream>
 8 #define MAXBUF 1024
 9
10 namespace asio = boost::asio;
11 namespace sys = boost::system;
12
13 class TCPAsyncConnection
14   : public boost::enable_shared_from_this<TCPAsyncConnection> {
15 public:
16   TCPAsyncConnection(asio::io_service& service) :
17       socket(service) {}
18
19   asio::ip::tcp::socket& getSocket() {
20     return socket;
21   }
22
23   void waitForReceive() {
24     auto thisPtr = shared_from_this();
25     async_read(socket, asio::buffer(buf, sizeof(buf)),
26         thisPtr {
27           if (!ec || ec == asio::error::eof) {
28             thisPtr->startSend();
29             thisPtr->buf[sz] = '\0'; 
30             std::cout << thisPtr->buf << '\n';
31             
32             if (!ec) { thisPtr->waitForReceive(); }
33           } else {
34             std::cerr << "Error receiving data from "
35                     "client: " << ec.message() << "\n";
36           }
37         });
38   }
39
40   void startSend() {
41     const char *msg = "Hello from server";
42     auto thisPtr = shared_from_this();
43     async_write(socket, asio::buffer(msg, strlen(msg)),
44         thisPtr {
45           if (ec) {
46             if (ec == asio::error::eof) {
47                thisPtr->socket.close();
48             }
49             std::cerr << "Failed to send response to "
50                     "client: " << ec.message() << '\n';
51           }
52         });
53   }
54
55 private:
56   asio::ip::tcp::socket socket;
57   char buf[MAXBUF];
58 };
59
60 #endif /* ASYNCONN_HPP */

我们在 11.23 清单中看到了如何创建TCPAsyncConnection的实例,并将其包装在shared_ptr中,以处理每个新连接,并通过调用waitForReceive成员函数来启动 I/O。现在让我们来了解它的实现。TCPAsyncConnection有两个用于在连接上执行异步 I/O 的公共成员:waitForReceive用于执行异步接收(第 23 行),startSend用于执行异步发送(第 40 行)。

waitForReceive函数通过在套接字上调用async_read函数(第 25 行)来启动接收。数据被接收到buf成员中(第 57 行)。此调用的完成处理程序(第 26-37 行)在数据完全接收时被调用。如果没有错误,它调用startSend,它异步地向客户端发送一条消息(第 28 行),然后再次调用waitForReceive,前提是之前的接收没有遇到文件结尾(第 32 行)。因此,只要没有读取错误,服务器就会继续等待在连接上读取更多数据。如果出现错误,它会打印诊断消息(第 34-35 行)。

startSend函数使用async_write函数向客户端发送文本Hello from server。它的处理程序在成功时不执行任何操作,但在失败时打印诊断消息(第 49-50 行)。对于 EOF 写入错误,它关闭套接字(第 47 行)。

TCPAsyncConnection 的生命周期

每个TCPAsyncConnection实例需要在客户端保持连接到服务器的时间内存活。这使得将这个对象的范围绑定到服务器中的任何函数变得困难。这就是我们在shared_ptr中创建TCPAsyncConnection对象的原因,然后在处理程序 lambda 中捕获它。TCPAsyncConnection用于在连接上执行 I/O 的成员函数waitForReceivestartSend都是异步的。因此,它们在返回之前将处理程序推入io_service的任务队列。这些处理程序捕获了TCPAsyncConnectionshared_ptr包装实例,以保持实例在调用之间的存活状态。

为了使处理程序能够从waitForReceivestartSend中访问TCPAsyncConnection对象的shared_ptr包装实例,需要这些TCPAsyncConnection的成员函数能够访问它们被调用的shared_ptr包装实例。我们在第三章中学到的enable shared from this习惯用法,内存管理和异常安全,是为这种目的量身定制的。这就是我们将TCPAsyncConnectionenable_shared_from_this<TCPAsyncConnection>派生的原因。由于这个原因,TCPAsyncConnection继承了shared_from_this成员函数,它返回我们需要的shared_ptr包装实例。这意味着TCPAsyncConnection应该始终动态分配,并用shared_ptr包装,否则会导致未定义的行为。

这就是我们在waitForReceive(第 24 行)和startSend(第 42 行)中都调用shared_from_this的原因,它被各自的处理程序捕获(第 26 行,44 行)。只要waitForReceive成员函数从async_read(第 32 行)的完成处理程序中被调用,TCPAsyncConnection实例就会存活。如果在接收中遇到错误,要么是因为远程端点关闭了连接,要么是因为其他原因,那么这个循环就会中断。包装TCPAsyncConnection对象的shared_ptr不再被任何处理程序捕获,并且在作用域结束时被销毁,关闭连接。

性能和并发性

请注意,TCP 异步服务器的两种实现,使用和不使用协程,都是单线程的。然而,在任何实现中都没有线程安全问题,因此我们也可以使用线程池,每个线程都会在io_service上调用run

控制流的反转

编写异步系统的最大困难在于控制流的反转。要编写同步服务器的代码,我们知道必须按以下顺序调用操作:

  1. 在接收器上调用accept

  2. 在套接字上调用read

  3. 在套接字上调用write

我们知道accept仅在连接建立后才返回,因此可以安全地调用read。此外,read仅在读取所请求的字节数或遇到文件结束后才返回。因此,可以安全地调用write。与异步模型相比,这使得编写代码变得非常容易,但引入了等待,影响了我们处理其他等待连接的能力,同时我们的请求正在被处理。

我们通过异步 I/O 消除了等待,但在使用处理程序链接时失去了模型的简单性。由于我们无法确定地告诉异步 I/O 操作何时完成,因此我们要求io_service在我们的请求完成时运行特定的处理程序。我们仍然知道在之后执行哪个操作,但不再知道何时。因此,我们告诉io_service要运行什么,它使用来自操作系统的适当通知来知道何时运行它们。这种模型的最大挑战是在处理程序之间维护对象状态和管理对象生命周期。

通过允许将异步 I/O 操作的序列写入单个协程来消除这种控制流反转,该协程被挂起而不是等待异步操作完成,并在操作完成时恢复。这允许无等待逻辑,而不会引入处理程序链接的固有复杂性。

提示

在编写异步服务器时,始终优先使用协程而不是处理程序链接。

自测问题

对于多项选择题,选择所有适用的选项:

  1. io_service::dispatchio_service::post之间的区别是什么?

a. dispatch立即返回,而post在返回之前运行处理程序

b. post立即返回,而dispatch如果可以在当前线程上运行处理程序,或者它的行为类似于 post

c. post是线程安全的,而dispatch不是

d. post立即返回,而dispatch运行处理程序

  1. 当处理程序在分派时抛出异常会发生什么?

a. 这是未定义行为

b. 它通过调用std::terminate终止程序

c. 在分派处理程序的io_service上调用 run 将抛出异常。

d. io_service被停止

  1. 未指定地址 0.0.0.0(IPv4)或::/1(IPv6)的作用是什么?

a. 它用于与系统上的本地服务通信

b. 发送到此地址的数据包将被回显到发送方

c. 它用于向网络中的所有连接的主机进行广播

d. 它用于绑定到所有可用接口,而无需知道地址

  1. 以下关于 TCP 的哪些陈述是正确的?

a. TCP 比 UDP 更快

b. TCP 检测数据损坏但不检测数据丢失

c. TCP 比 UDP 更可靠

d. TCP 重新传输丢失或损坏的数据

  1. 当我们说特定函数,例如async_read是异步时,我们是什么意思?

a. 在请求的操作完成之前,该函数会返回

b. 该函数在不同的线程上启动操作,并立即返回

c. 请求的操作被排队等待由同一线程或另一个线程处理

d. 如果可以立即执行操作,则该函数执行该操作,否则返回错误

  1. 我们如何确保在调用异步函数之前创建的对象仍然可以在处理程序中使用?

a. 将对象设为全局。

b. 在处理程序中复制/捕获包装在shared_ptr中的对象。

c. 动态分配对象并将其包装在shared_ptr中。

d. 将对象设为类的成员。

总结

Asio 是一个设计良好的库,可用于编写快速、灵活的网络服务器,利用系统上可用的最佳异步 I/O 机制。它是一个不断发展的库,是提议在未来的 C++标准修订版中添加网络库的技术规范的基础。

在这一章中,我们学习了如何使用 Boost Asio 库作为任务队列管理器,并利用 Asio 的 TCP 和 UDP 接口编写可以在网络上通信的程序。使用 Boost Asio,我们能够突出显示网络编程的一些一般性问题,针对大量并发连接的扩展挑战,以及异步 I/O 的优势和复杂性。特别是,我们看到使用 stackful 协程相对于旧模型的处理程序链,使得编写异步服务器变得轻而易举。虽然我们没有涵盖 stackless 协程、ICMP 协议和串口通信等内容,但本章涵盖的主题应该为您提供了理解这些领域的坚实基础。

参考资料

附录 A:C++11 语言特性模拟

在本节中,我们将回顾一些 C++ 编程中的概念,这些概念在理解本书涵盖的几个主题中具有概念上的重要性。其中许多概念是作为 C++11 的一部分相对较新地引入的。我们将研究:RAII、复制和移动语义、auto、基于范围的 for 循环以及 C++11 异常处理增强。我们将看看如何在预 C++11 编译器下使用 Boost 库的部分来模拟这些特性。

RAII

C++ 程序经常处理系统资源,如内存、文件和套接字句柄、共享内存段、互斥锁等。有明确定义的原语,一些来自 C 标准库,还有更多来自本地系统编程接口,用于请求和释放这些资源。未能保证已获取资源的释放可能会对应用程序的性能和正确性造成严重问题。

C++ 对象 在堆栈上 的析构函数在堆栈展开时会自动调用。展开发生在由于控制达到作用域的末尾而退出作用域,或者通过执行 returngotobreakcontinue。由于抛出异常而导致作用域退出也会发生展开。在任何情况下,都保证调用析构函数。这个保证仅限于堆栈上的 C++ 对象。它不适用于堆上的 C++ 对象,因为它们不与词法作用域相关。此外,它也不适用于前面提到的资源,如内存和文件描述符,它们是平凡旧数据类型(POD 类型)的对象,因此没有析构函数。

考虑以下使用 new[]delete[] 运算符的 C++ 代码:

char *buffer = new char[BUFSIZ];
… …
delete [] buffer;

程序员小心释放了分配的缓冲区。但是,如果另一个程序员随意编写代码,在调用 newdelete 之间的某个地方退出作用域,那么 buffer 就永远不会被释放,您将会泄漏内存。异常也可能在介入的代码中出现,导致相同的结果。这不仅适用于内存,还适用于任何需要手动释放的资源,比如在这种情况下的 delete[]

这是我们可以利用在退出作用域时保证调用析构函数来保证资源的清理。我们可以创建一个包装类,其构造函数获取资源的所有权,其析构函数释放资源。几行代码可以解释这种通常被称为资源获取即初始化RAII的技术。

清单 A.1:RAII 的实际应用

 1 class String
 2 {
 3 public:
 4   String(const char *str = 0)
 5   {  buffer_ = dupstr(str, len_);  }
 6 
 7   ~String() { delete [] buffer_; }
 8
 9 private:
10   char *buffer_;
11   size_t len_;
12 };
13
14 // dupstr returns a copy of s, allocated dynamically.
15 //   Sets len to the length of s.
16 char *dupstr(const char *str, size_t& len) {
17   char *ret = nullptr;
18
19   if (!str) {
20     len = 0;
21     return ret;
22   }
23   len = strlen(str);
24   ret = new char[len + 1];
25   strncpy(ret, str, len + 1);
26
27   return ret;
28 }

String 类封装了一个 C 风格的字符串。我们在构造过程中传递了一个 C 风格的字符串,并且如果它不为空,它会在自由存储器上创建传递的字符串的副本。辅助函数 dupstr 使用 new[] 运算符(第 24 行)在自由存储器上为 String 对象分配内存。如果分配失败,operator new[] 抛出 std::bad_alloc,并且 String 对象永远不会存在。换句话说,资源获取必须成功才能使初始化成功。这是 RAII 的另一个关键方面。

我们在代码中使用 String 类,如下所示:

 {
   String favBand("Led Zeppelin");
 ...   ...
 } // end of scope. favBand.~String() called.

我们创建了一个名为 favBandString 实例,它在内部动态分配了一个字符缓冲区。当 favBand 正常或由于异常而超出范围时,它的析构函数被调用并释放这个缓冲区。您可以将这种技术应用于所有需要手动释放的资源形式,并且它永远不会让资源泄漏。String 类被认为拥有缓冲区资源,即它具有独占所有权语义

复制语义

一个对象在其数据成员中保留状态信息,这些成员本身可以是 POD 类型或类类型。如果你没有为你的类定义一个复制构造函数,那么编译器会隐式为你定义一个。这个隐式定义的复制构造函数依次复制每个成员,调用类类型成员的复制构造函数,并对 POD 类型成员执行位拷贝。赋值运算符也是如此。如果你没有定义自己的赋值运算符,编译器会生成一个,并执行成员逐个赋值,调用类类型成员对象的赋值运算符,并对 POD 类型成员执行位拷贝。

以下示例说明了这一点:

清单 A.2:隐式析构函数、复制构造函数和赋值运算符

 1 #include <iostream>
 2
 3 class Foo {
 4 public:
 5   Foo() {}
 6
 7   Foo(const Foo&) {
 8     std::cout << "Foo(const Foo&)\n";
 9   }
10
11   ~Foo() {
12     std::cout << "~Foo()\n";
13   }
14
15   Foo& operator=(const Foo&) {
16     std::cout << "operator=(const Foo&)\n";
17     return *this;
18   }
19 };
20
21 class Bar {
22 public:
23   Bar() {}
24
25 private:
26   Foo f;
27 };
28
29 int main() {
30   std::cout << "Creating b1\n";
31   Bar b1;
32   std::cout << "Creating b2 as a copy of b1\n";
33   Bar b2(b1);
34
35   std::cout << "Assigning b1 to b2\n";
36   b2 = b1;
37 }

Bar包含类Foo的一个实例作为成员(第 25 行)。类Foo定义了一个析构函数(第 11 行),一个复制构造函数(第 7 行)和一个赋值运算符(第 15 行),每个函数都打印一些消息。类Bar没有定义任何这些特殊函数。我们创建了一个名为b1Bar实例(第 30 行),以及b1的一个副本b2(第 33 行)。然后我们将b1赋值给b2(第 36 行)。当程序运行时,输出如下:

Creating b1
Creating b2 as a copy of b1
Foo(const Foo&)
Assigning b1 to b2
operator=(const Foo&)
~Foo()
~Foo()

通过打印的消息,我们可以追踪从Bar的隐式生成的特殊函数调用Foo的特殊函数。

这对所有情况都有效,除了当你在类中封装指针或非类类型句柄到某些资源时。隐式定义的复制构造函数或赋值运算符将复制指针或句柄,但不会复制底层资源,生成一个浅复制的对象。这很少是需要的,这就是需要用户定义复制构造函数和赋值运算符来定义正确的复制语义的地方。如果这样的复制语义对于类没有意义,复制构造函数和赋值运算符应该被禁用。此外,您还需要使用 RAII 来管理资源的生命周期,因此需要定义一个析构函数,而不是依赖于编译器生成的析构函数。

有一个众所周知的规则叫做三规则,它规范了这个常见的习惯用法。它说如果你需要为一个类定义自己的析构函数,你也应该定义自己的复制构造函数和赋值运算符,或者禁用它们。我们在 A.1 清单中定义的String类就是这样一个候选者,我们将很快添加剩下的三个规范方法。正如我们所指出的,并不是所有的类都需要定义这些函数,只有封装资源的类才需要。事实上,建议使用这些资源的类应该与管理这些资源的类不同。因此,我们应该为每个资源创建一个包装器,使用专门的类型来管理这些资源,比如智能指针(第三章,“内存管理和异常安全性”),boost::ptr_container(第五章,“超出 STL 的有效数据结构”),std::vector等等。使用资源的类应该有包装器而不是原始资源作为成员。这样,使用资源的类就不必再担心管理资源的生命周期,隐式定义的析构函数、复制构造函数和赋值运算符对它的目的就足够了。这就被称为零规则

不抛出交换

感谢零规则,你应该很少需要担心三规则。但是当你确实需要使用三规则时,有一些细枝末节需要注意。让我们首先了解如何在 A.1 清单中为String类定义一个复制操作:

清单 A.1a:复制构造函数

 1 String::String(const String &str) : buffer_(0), len_(0)
 2 {
 3   buffer_ = dupstr(str.buffer_, len_);
 4 }

复制构造函数的实现与清单 A.1 中的构造函数没有区别。赋值运算符需要更多的注意。考虑以下示例中如何对String对象进行赋值:

 1 String band1("Deep Purple");
 2 String band2("Rainbow");
 3 band1 = band2;

在第 3 行,我们将band2赋值给band1。作为此过程的一部分,应释放band1的旧状态,然后用band2的内部状态的副本进行覆盖。问题在于复制band2的内部状态可能会失败,因此在成功复制band2的状态之前,不应销毁band1的旧状态。以下是实现此目的的简洁方法:

清单 A.1b:赋值运算符

 1 String& String::operator=(const String& rhs)
 2 {
 3   String tmp(rhs);   // copy the rhs in a temp variable
 4   swap(tmp);         // swap tmp's state with this' state.
 5   return *this;      // tmp goes out of scope, releases this'
 6                      // old state
 7 }

我们将tmp作为rhs的副本创建(第 3 行),如果此复制失败,它应该抛出异常,赋值操作将失败。被赋值对象this的内部状态不应更改。对swap的调用(第 4 行)仅在复制成功时执行(第 3 行)。对swap的调用交换了thistmp对象的内部状态。因此,this现在包含rhs的副本,而tmp包含this的旧状态。在此函数结束时,tmp超出范围并释放了this的旧状态。

提示

通过考虑特殊情况,可以进一步优化此实现。如果被赋值对象(左侧)已经具有至少与rhs的内容相同大小的存储空间,那么我们可以简单地将rhs的内容复制到被赋值对象中,而无需额外的分配和释放。

这是swap成员函数的实现:

清单 A.1c:nothrow swap

 1 void String::swap(String&rhs) noexcept
 2 {
 3   using std::swap;
 3   swap(buffer_, rhs.buffer_);
 4   swap(len_, rhs.len_);
 5 }

交换原始类型变量(整数、指针等)不应引发任何异常,这一事实我们使用 C++11 关键字noexcept来宣传。我们可以使用throw()代替noexcept,但异常规范在 C++11 中已被弃用,而noexceptthrow()子句更有效。这个swap函数完全是用交换原始数据类型来写的,保证成功并且永远不会使被赋值对象处于不一致的状态。

移动语义和右值引用

复制语义用于创建对象的克隆。有时很有用,但并非总是需要或有意义。考虑封装 TCP 客户端套接字的以下类。TCP 套接字是一个整数,表示 TCP 连接的一个端点,通过它可以向另一个端点发送或接收数据。TCP 套接字类可以有以下接口:

class TCPSocket
{
public:
  TCPSocket(const std::string& host, const std::string& port);
  ~TCPSocket();

  bool is_open();
  vector<char> read(size_t to_read);
  size_t write(vector<char> payload);

private:
  int socket_fd_;

  TCPSocket(const TCPSocket&);
  TCPSocket& operator = (const TCPSocket&);
};

构造函数打开到指定端口上主机的连接并初始化socket_fd_成员变量。析构函数关闭连接。TCP 不定义一种克隆打开套接字的方法(不像具有dup/dup2的文件描述符),因此克隆TCPSocket也没有意义。因此,通过将复制构造函数和复制赋值运算符声明为私有来禁用复制语义。在 C++11 中,这样做的首选方法是将这些成员声明为已删除:

TCPSocket(const TCPSocket&) = delete;
TCPSocket& operator = (const TCPSocket&) = delete;

虽然不可复制,但在一个函数中创建TCPSocket对象然后返回给调用函数是完全合理的。考虑一个创建到某个远程 TCP 服务的连接的工厂函数:

TCPSocket connectToService()
{
  TCPSocket socket(get_service_host(),  // function gets hostname
                   get_service_port()); // function gets port
  return socket;
}

这样的函数将封装关于连接到哪个主机和端口的详细信息,并创建一个要返回给调用者的TCPSocket对象。这实际上根本不需要复制语义,而是需要移动语义,在connectToService函数中创建的TCPSocket对象的内容将被转移到调用点的另一个TCPSocket对象中:

TCPSocket socket = connectToService();

在 C++03 中,如果不启用复制构造函数,将无法编写此代码。我们可以通过曲线救国复制构造函数来提供移动语义,但这种方法存在许多问题:

TCPSocket::TCPSocket(TCPSocket& that) {
  socket_fd_ = that.socket_fd_;
  that.socket_fd_ = -1;
}

请注意,这个“复制”构造函数实际上将其参数的内容移出,这就是为什么参数是非 const 的原因。有了这个定义,我们实际上可以实现connectToService函数,并像之前那样使用它。但是没有什么可以阻止以下情况发生:

 1 void performIO(TCPSocket socket)
 2 {
 3   socket.write(...);
 4   socket.read(...);
 5   // etc.
 6 }
 7
 8 TCPSocket socket = connectToService();
 9 performIO(socket);   // moves TCPSocket into performIO
10 // now socket.socket_fd_ == -1
11 performIO(socket);   // OOPs: not a valid socket

我们通过调用connectToService(第 8 行)获得了名为socketTCPSocket实例,并将此实例传递给performIO(第 9 行)。但用于将socket按值传递给performIO的复制构造函数移出了其内容,当performIO返回时,socket不再封装有效的 TCP 套接字。通过将移动伪装成复制,我们创建了一个令人费解且容易出错的接口;如果您熟悉std::auto_ptr,您以前可能已经见过这种情况。

右值引用

为了更好地支持移动语义,我们必须首先回答一个问题:哪些对象可以被移动?再次考虑TCPSocket示例。在函数connectToService中,表达式TCPSocket(get_service_host(), get_service_port())TCPSocket无名临时对象,其唯一目的是传递到调用者的上下文。没有人可以在创建该语句之后引用此对象。从这样的对象中移出内容是完全合理的。但在以下代码片段中:

TCPSocket socket = connectToService();
performIO(socket);

socket对象中移出内容是危险的,因为在调用上下文中,对象仍然绑定到名称socket,并且可以在进一步的操作中使用。表达式socket被称为左值表达式——具有标识并且其地址可以通过在表达式前加上&-运算符来获取。非左值表达式被称为右值表达式。这些是无名表达式,其地址不能使用&-运算符在表达式上计算。例如,TCPSocket(get_service_host(), get_service_port())是一个右值表达式。

一般来说,从左值表达式中移动内容是危险的,但从右值表达式中移动内容是安全的。因此,以下是危险的:

TCPSocket socket = connectToService();
performIO(socket);

但以下是可以的:

performIO(connectToService());

请注意,表达式connectToService()不是左值表达式,因此符合右值表达式的条件。为了区分左值和右值表达式,C++11 引入了一种新的引用类别,称为右值引用,它可以引用右值表达式但不能引用左值表达式。这些引用使用双和符号的新语法声明,如下所示:

socket&& socketref = TCPSocket(get_service_host(), 
                               get_service_port());

早期被简单称为引用的引用的另一类现在称为左值引用。非 const 左值引用只能引用左值表达式,而 const 左值引用也可以引用右值表达式:

/* ill-formed */
socket& socketref = TCPSocket(get_service_host(), 
                              get_service_port());

/* well-formed */
const socket& socketref = TCPSocket(get_service_host(), 
                                    get_service_port());

右值引用可以是非 const 的,通常是非 const 的:

socket&& socketref = TCPSocket(...);
socketref.read(...);

在上面的代码片段中,表达式socketref本身是一个左值表达式,因为可以使用&-运算符计算其地址。但它绑定到一个右值表达式,并且通过非 const 右值引用引用的对象可以通过它进行修改。

右值引用重载

我们可以根据它们是否接受左值表达式或右值表达式来创建函数的重载。特别是,我们可以重载复制构造函数以接受右值表达式。对于TCPSocket类,我们可以编写以下内容:

TCPSocket(const TCPSocket&) = delete;

TCPSocket(TCPSocket&& rvref) : socket_fd_(-1)
{
  std::swap(socket_fd_, rvref.socket_fd_);
}

虽然左值重载是删除的复制构造函数,但右值重载被称为移动构造函数,因为它被实现为篡夺或“窃取”传递给它的右值表达式的内容。它将源的内容移动到目标,将源(rvref)留在某种未指定的状态中,可以安全地销毁。在这种情况下,这相当于将rvrefsocket_fd_成员设置为-1。

使用移动构造函数的定义,TCPSocket 可以移动,但不能复制。connectToService 的实现将正常工作:

TCPSocket connectToService()
{
  return TCPSocket(get_service_host(),get_service_port());
}

这将把临时对象移回到调用者。但是,对 performIO 的后续调用将是不合法的,因为 socket 是一个左值表达式,而 TCPSocket 仅为其定义了需要右值表达式的移动语义:

TCPSocket socket = connectToService();
performIO(socket);

这是一个好事,因为您不能移动像 socket 这样的对象的内容,而您可能稍后还会使用它。可移动类型的右值表达式可以通过值传递,因此以下内容将是合法的:

performIO(connectToService());

请注意,表达式 connectToService() 是一个右值表达式,因为它未绑定到名称,其地址也无法被获取。

类型可以既可复制又可移动。例如,我们可以为 String 类实现一个移动构造函数,除了它的复制构造函数:

 1 // move-constructor
 2 String::String(String&& source) noexcept
 3       : buffer_(0), len_(0)
 4 {
 5   swap(source); // See listing A.1c
 6 }

nothrow swap 在移动语义的实现中起着核心作用。源对象和目标对象的内容被交换。因此,当源对象在调用范围内超出范围时,它释放其新内容(目标对象的旧状态)。目标对象继续存在,具有其新状态(源对象的原始状态)。移动是基于 nothrow swap 实现的,它只交换原始类型的指针和值,并且保证成功;因此,使用了 noexcept 说明。实际上,移动对象通常需要更少的工作,涉及交换指针和其他数据位,而复制通常需要可能失败的新分配。

移动赋值

就像我们可以通过窃取另一个对象的内容来构造对象一样,我们也可以在两者都构造之后将一个对象的内容移动到另一个对象。为此,我们可以定义一个移动赋值运算符,即复制赋值运算符的右值重载:

 1 // move assignment
 2 String& String::operator=(String&& rhs) noexcept
 3 {
 4   swap(rhs);
 5   return *this;
 6 }

或者,我们可以定义一个通用赋值运算符,适用于左值和右值表达式:

 1 // move assignment
 2 String& String::operator=(String rhs)
 3 {
 4   swap(rhs);
 5   return *this;
 6 }

请注意,通用赋值运算符不能与左值或右值重载共存,否则在重载解析中会存在歧义。

xvalues

当您使用右值表达式调用函数时,如果有可用的右值重载函数,则编译器会将函数调用解析为右值重载函数。但是,如果您使用命名变量调用函数,则会将其解析为左值重载(如果有的话),否则程序将是不合法的。现在,您可能有一个命名变量,可以从中移动,因为您以后不需要使用它:

void performIO(TCPSocket socket);

TCPSocket socket = connectToService();
// do stuff on socket
performIO(socket);  // ill-formed because socket is lvalue

前面的示例将无法编译,因为 performIO 以值传递其唯一参数,而 socket 是一个仅移动类型,但它不是右值表达式。通过使用 std::move,您可以将左值表达式转换为右值表达式,并将其传递给期望右值表达式的函数。std::move 函数模板在标准头文件 utility 中定义。

#include <utility> // for std::moves
performIO(std::move(socket));

std::move(socket) 的调用给我们一个对 socket 的右值引用;它不会导致任何数据从 socket 中移出。当我们将这种右值引用类型的表达式传递给以值传递其参数的函数 performIO 时,在 performIO 函数中创建了一个新的 TCPSocket 对象,对应于其按值参数。它是从 socket 进行移动初始化的,也就是说,它的移动构造函数窃取了 socket 的内容。在调用 performIO 后,变量 socket 失去了其内容,因此不应在后续操作中使用。如果 TCPSocket 的移动构造函数正确实现,那么 socket 应该仍然可以安全地销毁。

表达式 std::move(socket) 共享 socket 的标识,但它可能在传递给函数时被移动。这种表达式称为xvaluesx 代表 expired

提示

xvalues像 lvalues 一样有明确定义的标识,但可以像 rvalues 一样移动。xvalues绑定到函数的 rvalue 引用参数。

如果performIO没有按值接受其参数,而是按 rvalue-reference,那么有一件事会改变:

void performIO(TCPSocket&& socket);
performIO(std::move(socket));

performIO(std::move(socket))的调用仍然是良好的形式,但不会自动移出socket的内容。这是因为我们在这里传递了一个现有对象的引用,而当我们按值传递时,我们创建了一个从socket移动初始化的新对象。在这种情况下,除非performIO函数的实现明确地移出socket的内容,否则在调用performIO之后,它仍将在调用上下文中保持有效。

提示

一般来说,如果您将对象转换为 rvalue 表达式并将其传递给期望 rvalue 引用的函数,您应该假设它已经被移动,并且在调用之后不再使用它。

如果 T 类型的对象是函数内部的本地对象,并且 T 具有可访问的移动或复制构造函数,则可以从该函数中返回该对象的值。如果有移动构造函数,则返回值将被移动初始化,否则将被复制初始化。但是,如果对象不是函数内部的本地对象,则必须具有可访问的复制构造函数才能按值返回。此外,编译器在可能的情况下会优化掉复制和移动。

考虑connectToService的实现以及它的使用方式:

 1 TCPSocket connectToService()
 2 {
 3   return TCPSocket(get_service_host(),get_service_port());
 4 }
 5
 6 TCPSocket socket = connectToService();

在这种情况下,编译器实际上会直接在socket对象的存储空间(第 3 行)中构造临时对象,而connectToService的返回值原本是要移动到的地方(第 6 行)。这样,它会简单地优化掉socket的移动初始化(第 6 行)。即使移动构造函数具有副作用,这种优化也会生效,这意味着这些副作用可能不会因此优化而产生效果。同样,编译器可以优化掉复制初始化,并直接在目标位置构造返回的对象。这被称为返回值优化RVO),自 C++03 以来一直是所有主要编译器的标准,当时它只优化了复制。尽管在 RVO 生效时不会调用复制或移动构造函数,但它们仍然必须被定义和可访问才能使 RVO 生效。

当返回 rvalue 表达式时,RVO 适用,但是即使从函数中返回了命名的本地堆栈对象,编译器有时也可以优化掉复制或移动。这被称为命名返回值优化NRVO)。

返回值优化是复制省略的一个特例,其中编译器优化掉 rvalue 表达式的移动或复制,直接在目标存储中构造它:

std::string reverse(std::string input);

std::string a = "Hello";
std::string b = "World";
reverse(a + b);

在前面的示例中,表达式a + b是一个 rvalue 表达式,它生成了一个std::string类型的临时对象。这个对象不会被复制到函数reverse中,而是省略了复制,并且由表达式a + b生成的对象会直接在reverse的参数的存储空间中构造。

提示

通过值传递和返回类型 T 的对象需要为 T 定义移动或复制语义。如果有移动构造函数,则使用它,否则使用复制构造函数。在可能的情况下,编译器会优化掉复制或移动操作,并直接在调用或被调用函数的目标位置构造对象。

使用 Boost.Move 进行移动模拟

在本节中,我们将看看如何使用 Boost.Move 库相对容易地为自己的传统类实际上实现了大部分移动语义的后期改造。首先,考虑 C++ 11 语法中String类的接口:

 1 class String
 2 {
 3 public:
 4   // Constructor
 5   String(const char *str = 0);
 6
 7   // Destructor
 8   ~String();
 9
10   // Copy constructor
11   String(const String& that);
12
13   // Copy assignment operator
14   String& operator=(const String& rhs);
15
16   // Move constructor
17   String(String&& that);
18
19   // Move assignment
20   String& operator=(String&& rhs);
21   …
22 };

现在让我们看看如何使用 Boost 的工具定义等效的接口:

清单 A.2a:使用 Boost.Move 进行移动模拟

 1 #include <boost/move/move.hpp>
 2 #include <boost/swap.hpp>
 3
 4 class String {
 5 private:
 6   BOOST_COPYABLE_AND_MOVABLE(String);
 7
 8 public:
 9   // Constructor
10   String(const char *str = 0);
11
12   // Destructor
13   ~String();
14
15   // Copy constructor
16   String(const String& that);
17
18   // Copy assignment operator
19   String& operator=(BOOST_COPY_ASSIGN_REF(String) rhs);
20
21   // Move constructor
22   String(BOOST_RV_REF(String) that);
23
24   // Move assignment
25   String& operator=(BOOST_RV_REF(String) rhs);
26 
27   void swap(String& rhs);
28
29 private:
30   char *buffer_;
31   size_t size_;
32 };

关键更改如下:

  • 第 6 行:宏BOOST_COPYABLE_AND_MOVABLE(String)定义了一些内部基础设施,以支持String类型的拷贝和移动语义,并区分String类型的左值和右值。这被声明为私有。

  • 第 19 行:一个拷贝赋值运算符,它接受类型BOOST_COPY_ASSIGN_REF(String)。这是String的包装类型,可以隐式转换为String的左值。

  • 第 22 行和 25 行:接受包装类型BOOST_RV_REF(String)的移动构造函数和移动赋值运算符。String的右值隐式转换为此类型。

  • 请注意,第 16 行的拷贝构造函数不会改变。

在 C++ 03 编译器下,移动语义的模拟是在没有语言或编译器的特殊支持的情况下提供的。使用 C++ 11 编译器,宏自动使用 C++ 11 本机构造来支持移动语义。

实现与 C++ 11 版本基本相同,只是参数类型不同。

清单 A.2b:使用 Boost Move 进行移动模拟

 1 // Copy constructor
 2 String::String(const String& that) : buffer_(0), len_(0)
 3 {
 4   buffer_ = dupstr(that.buffer_, len_);
 5 }
 6 
 7 // Copy assignment operator
 8 String& String::operator=(BOOST_COPY_ASSIGN_REF(String)rhs)
 9 {
10   String tmp(rhs);
11   swap(tmp);        // calls String::swap member
12   return *this;
13 }
14 
15 // Move constructor
16 String::String(BOOST_RV_REF(String) that) : buffer_(0), 
17                                             size_(0) 
18 { 
19   swap(that);      // calls String::swap member 
20 }
21 // Move assignment operator
22 String& String::operator=(BOOST_RV_REF(String)rhs)
23 {
24   swap(rhs);
25   String tmp;
26   rhs.swap(tmp);
27
28   return *this;
29 }
30 
31 void String::swap(String& that)
32 {
33   boost::swap(buffer_, that.buffer_);
34   boost::swap(size_, that.size_);
35 }

如果我们只想使我们的类支持移动语义而不支持拷贝语义,那么我们应该使用宏BOOST_MOVABLE_NOT_COPYABLE代替BOOST_COPYABLE_AND_MOVABLE,并且不应该定义拷贝构造函数和拷贝赋值运算符。

在拷贝/移动赋值运算符中,如果需要,我们可以通过将执行交换/复制的代码放在 if 块内来检查自赋值,如下所示:

if (this != &rhs) {
  …
}

只要拷贝/移动的实现是异常安全的,这不会改变代码的正确性。但是,通过避免对自身进行赋值的进一步操作,可以提高性能。

因此,总之,以下宏帮助我们在 C++ 03 中模拟移动语义:

#include <boost/move/move.hpp>

BOOST_COPYABLE_AND_MOVABLE(classname)
BOOST_MOVABLE_BUT_NOT_COPYABLE(classname)
BOOST_COPY_ASSIGN_REF(classname)
BOOST_RV_REF(classname)

除了移动构造函数和赋值运算符之外,还可以使用BOOST_RV_REF(…)封装类型作为其他成员方法的参数。

如果要从左值移动,自然需要将其转换为“模拟右值”的表达式。您可以使用boost::move来实现这一点,它对应于 C++ 11 中的std::move。以下是使用 Boost 移动模拟在String对象上调用不同的拷贝和移动操作的一些示例:

 1 String getName();                       // return by value
 2 void setName(BOOST_RV_REF(String) str); // rvalue ref overload
 3 void setName(const String&str);        // lvalue ref overload
 4 
 5 String str1("Hello");                 
 6 String str2(str1);                      // copy ctor
 7 str2 = getName();                       // move assignment
 8 String str3(boost::move(str2));         // move ctor
 9 String str4;
10 str4 = boost::move(str1);               // move assignment
11 setName(String("Hello"));               // rvalue ref overload
12 setName(str4);                          // lvalue ref overload
13 setName(boost::move(str4));             // rvalue ref overload

C++11 auto 和 Boost.Auto

考虑如何声明指向字符串向量的迭代器:

std::vector<std::string> names;
std::vector<std::string>::iterator iter = vec.begin();

iter的声明类型很大且笨重,每次显式写出来都很麻烦。鉴于编译器知道右侧初始化表达式的类型,即vec.begin(),这也是多余的。从 C++11 开始,您可以使用auto关键字要求编译器使用初始化表达式的类型来推导已声明变量的类型。因此,前面的繁琐被以下内容替换:

std::vector<std::string> names;
auto iter = vec.begin();

考虑以下语句:

auto var = expr;

当使用参数expr调用以下函数模板时,var的推导类型与推导类型T相同:

template <typename T>
void foo(T);

foo(expr);

类型推导规则

有一些规则需要记住。首先,如果初始化表达式是引用,则在推导类型中引用被剥离:

int x = 5;
int& y = x;
auto z = y;  // deduced type of z is int, not int&

如果要声明左值引用,必须将auto关键字明确加上&,如下所示:

int x = 5;
auto& y = x;     // deduced type of y is int&

如果初始化表达式不可复制,必须以这种方式使被赋值者成为引用。

第二条规则是,初始化表达式的constvolatile限定符在推导类型中被剥离,除非使用auto声明的变量被显式声明为引用:

int constx = 5;
auto y = x;     // deduced type of y is int
auto& z = x;    // deduced type of z is constint

同样,如果要添加constvolatile限定符,必须显式这样做,如下所示:

intconst x = 5;
auto const y = x;    // deduced type of y is constint

常见用法

auto关键字在许多情况下非常方便。它让您摆脱了不得不输入长模板 ID 的困扰,特别是当初始化表达式是函数调用时。以下是一些示例,以说明其优点:

auto strptr = boost::make_shared<std::string>("Hello");
// type of strptr is boost::shared_ptr<std::string>

auto coords(boost::make_tuple(1.0, 2.0, 3.0));
// type of coords is boost::tuple<double, double, double>

请注意通过使用auto实现的类型名称的节省。另外,请注意,在创建名为coordstuple时,我们没有使用赋值语法进行初始化。

Boost.Auto

如果您使用的是 C++11 之前的编译器,可以使用BOOST_AUTOBOOST_AUTO_TPL宏来模拟这种效果。因此,您可以将最后一小节写成如下形式:

#include <boost/typeof/typeof.hpp>

BOOST_AUTO(strptr, boost::make_shared<std::string>("Hello"));
// type of strptr is boost::shared_ptr<std::string>

BOOST_AUTO(coords, boost::make_tuple(1.0, 2.0, 3.0));
// type of coords is boost::tuple<double, double, double>

请注意需要包含的头文件boost/typeof/typeof.hpp以使用该宏。

如果要声明引用类型,可以在变量前加上引导符号(&)。同样,要为变量添加constvolatile限定符,应在变量名之前添加constvolatile限定符。以下是一个示例:

BOOST_AUTO(const& strptr, boost::make_shared<std::string>("Hello"));
// type of strptr is boost::shared_ptr<std::string>

基于范围的 for 循环

基于范围的 for 循环是 C++11 中引入的另一个语法便利。基于范围的 for 循环允许您遍历值的序列,如数组、容器、迭代器范围等,而无需显式指定边界条件。它通过消除了需要指定边界条件来使迭代更不容易出错。

基于范围的 for 循环的一般语法是:

for (range-declaration : sequence-expression) {
 statements;
}

序列表达式标识要遍历的值序列,如数组或容器。范围声明标识一个变量,该变量将在循环的连续迭代中代表序列中的每个元素。基于范围的 for 循环自动识别数组、大括号包围的表达式序列和具有返回前向迭代器的beginend成员函数的容器。要遍历数组中的所有元素,可以这样写:

T arr[N];
...
for (const auto& elem : arr) {
  // do something on each elem
}

您还可以遍历大括号括起来的表达式序列:

for (const auto& elem: {"Aragorn", "Gandalf", "Frodo Baggins"}) {
  // do something on each elem
}

遍历通过beginend成员函数公开前向迭代器的容器中的元素并没有太大不同:

std::vector<T> vector;
...
for (const auto& elem: vector) {
  // do something on each elem
}

范围表达式使用auto声明了一个名为elem的循环变量来推断其类型。基于范围的 for 循环中使用auto的这种方式是惯用的和常见的。要遍历封装在其他类型对象中的序列,基于范围的 for 循环要求两个命名空间级别的方法beginend可用,并且可以通过参数相关查找(见第二章,Boost 实用工具的第一次接触)来解析。基于范围的 for 循环非常适合遍历在遍历期间长度保持不变的序列。

Boost.Foreach

您可以使用BOOST_FOREACH宏来模拟 C++11 基于范围的 for 循环的基本用法:

#include <boost/foreach.hpp>

std::vector<std::string> names;
...
BOOST_FOREACH(std::string& name, names) {
  // process each elem
}

在前面的示例中,我们使用BOOST_FOREACH宏来遍历名为names的字符串向量的元素,使用名为namestring类型的循环变量。使用BOOST_FOREACH,您可以遍历数组、具有返回前向迭代器的成员函数beginend的容器、迭代器对和以空字符结尾的字符数组。请注意,C++11 基于范围的 for 循环不容易支持最后两种类型的序列。另一方面,使用BOOST_FOREACH,您无法使用auto关键字推断循环变量的类型。

C++11 异常处理改进

C++11 引入了捕获和存储异常的能力,可以在稍后传递并重新抛出。这对于在线程之间传播异常特别有用。

存储和重新抛出异常

为了存储异常,使用类型std::exception_ptrstd::exception_ptr是一种具有共享所有权语义的智能指针类型,类似于std::shared_ptr(参见第三章,“内存管理和异常安全性”)。std::exception_ptr的实例是可复制和可移动的,并且可以传递给其他函数,可能跨线程。默认构造的std::exception_ptr是一个空对象,不指向任何异常。复制std::exception_ptr对象会创建两个管理相同底层异常对象的实例。只要包含它的最后一个exception_ptr实例存在,底层异常对象就会继续存在。

函数std::current_exception在 catch 块内调用时,返回执行该 catch 块的活动异常,包装在std::exception_ptr的实例中。在 catch 块外调用时,返回一个空的std::exception_ptr实例。

函数std::rethrow_exception接收一个std::exception_ptr的实例(不能为 null),并抛出std::exception_ptr实例中包含的异常。

清单 A.3:使用 std::exception_ptr

 1 #include <stdexcept>
 2 #include <iostream>
 3 #include <string>
 4 #include <vector>
 5
 6 void do_work()
 7 {
 8   throw std::runtime_error("Exception in do_work");
 9 }
10
11 std::vector<std::exception_ptr> exceptions;
12
13 void do_more_work()
14 {
15   std::exception_ptr eptr;
16
17   try {
18     do_work();
19   } catch (...) {
20     eptr = std::current_exception();
21   }
22
23   std::exception_ptr eptr2(eptr);
24   exceptions.push_back(eptr);
25   exceptions.push_back(eptr2);
26 }
27
28 int main()
29 {
30   do_more_work();
31
32   for (auto& eptr: exceptions) try {
33     std::rethrow_exception(eptr);
34   } catch (std::exception& e) {
35     std::cout << e.what() << '\n';
36   }
37 }

运行上述示例会打印以下内容:

Exception in do_work
Exception in do_work

main函数调用do_more_work(第 30 行),然后调用do_work(第 18 行),后者只是抛出一个runtime_error异常(第 8 行),该异常最终到达do_more_work(第 19 行)中的 catch 块。我们在do_more_work(第 15 行)中声明了一个类型为std::exception_ptr的对象eptr,并在 catch 块内调用std::current_exception,并将结果赋给eptr。稍后,我们创建了eptr的副本(第 23 行),并将两个实例推入全局exception_ptr向量(第 24-25 行)。

main函数中,我们遍历全局向量中的exception_ptr实例,使用std::rethrow_exception(第 33 行)抛出每个异常,并捕获并打印其消息。请注意,在此过程中,我们打印相同异常的消息两次,因为我们有两个包含相同异常的exception_ptr实例。

使用 Boost 存储和重新抛出异常

在 C++11 之前的环境中,可以使用boost::exception_ptr类型来存储异常,并使用boost::rethrow_exception来抛出存储在boost::exception_ptr中的异常。还有boost::current_exception函数,其工作方式类似于std::current_exception。但是在没有底层语言支持的情况下,它需要程序员的帮助才能运行。

为了使boost::current_exception返回当前活动的异常,包装在boost::exception_ptr中,我们必须修改异常,然后才能抛出它,以便使用这种机制进行处理。为此,我们在要抛出的异常上调用boost::enable_current_exception。以下代码片段说明了这一点:

清单 A.4:使用 boost::exception_ptr

 1 #include <boost/exception_ptr.hpp>
 2 #include <iostream>
 3
 4 void do_work()
 5 {
 6   throw boost::enable_current_exception(
 7             std::runtime_error("Exception in do_work"));
 8 }
 9
10 void do_more_work()
11 {
12   boost::exception_ptr eptr;
13 
14   try {
15     do_work();
16   } catch (...) {
17     eptr = boost::current_exception();
18   }
19
20   boost::rethrow_exception(eptr);
21 }
22
23 int main() {
24   try {
25     do_more_work();
26   } catch (std::exception& e) {
27     std::cout << e.what() << '\n';
28   }
29 }

自测问题

  1. 三大法则规定,如果为类定义自己的析构函数,则还应定义:

a. 您自己的复制构造函数

b. 您自己的赋值运算符

c. 两者都是

d. 两者中的任意一个

  1. 假设类String既有复制构造函数又有移动构造函数,以下哪个不会调用移动构造函数:

a. String s1(getName());

b. String s2(s1);

c. String s2(std::move(s1));

d. String s3("Hello");

  1. std::move函数的目的是:

a. 移动其参数的内容

b. 从右值引用创建左值引用

c. 从左值表达式创建 xvalue

d. 交换其参数的内容与另一个对象

  1. 以下哪种情况适用于返回值优化?:

a. return std::string("Hello");

b. string reverse(string);string a, b;reverse(a + b);

c. std::string s("Hello");return s;

d. std::string a, b;return a + b.

参考资料

  • 《Effective Modern C++: 42 Specific Ways to Improve Your Use of C++11 and C++14Scott MeyersO'Reilly Media

  • 《C++之旅》,Bjarne Stroustrup,Addison Wesley Professional

  • 《C++程序设计语言(第 4 版)》,Bjarne Stroustrup,Addison Wesley Professional

posted @ 2024-05-15 15:26  绝不原创的飞龙  阅读(78)  评论(0编辑  收藏  举报