现代-C---编程秘籍-全-
现代 C++ 编程秘籍(全)
原文:
zh.annas-archive.org/md5/648d349bcb1ebb48a0fa638761ced261译者:飞龙
前言
C++ 是最受欢迎和广泛使用的编程语言之一,这种状态已经持续了三十年。C++ 的设计重点在于性能、效率和灵活性,它结合了面向对象、命令式、泛型和函数式编程等范式。C++ 由国际标准化组织(ISO)标准化,并在过去十五年里经历了巨大的变化。随着 C++11 标准的发布,这门语言进入了一个新的时代,通常被称为现代 C++。类型推断、移动语义、lambda 表达式、智能指针、统一初始化、变长模板和许多其他最近引入的特性,已经改变了我们在 C++ 中编写代码的方式,以至于它几乎看起来像一门全新的编程语言。这种变化随着 C++20 的发布得到了进一步的推进,C++20 包含了许多对语言的改进,如模块、概念和协程,以及标准库的改进,如范围、文本格式化和日历。现在,随着 C++23 和即将到来的 C++26 中引入的更多变化,这门语言正在进一步发展。
本书涵盖了 C++11、C++14、C++17、C++20 和 C++23 中包含的许多新特性。本书以食谱的形式组织,每个食谱都涵盖一个特定的语言或库特性,或者是一个开发者经常遇到的问题及其使用现代 C++ 的典型解决方案。通过超过 150 个食谱,你将学会掌握核心语言特性和标准库,包括字符串、容器、算法、迭代器、流、正则表达式、线程、文件系统、原子操作、实用工具和范围。
这本书的第三版花费了几个月的时间来编写,在这段时间里,C++23 标准的工作已经完成。然而,在撰写这篇前言的时候,该标准尚未获得批准,并将在今年(2024 年)发布。
第二版和第三版中新增或更新的超过 30 个食谱涵盖了 C++20 的特性,包括模块、概念、协程、范围、线程和同步机制、文本格式化、日历和时区、即时函数、三向比较运算符以及新的 std::span 类。本第三版中新增或更新的近 20 个食谱涵盖了 C++23 的特性,包括 std::expected 类、std::mdspan 类、stacktrace 库、span 缓冲区、多维索引运算符以及文本格式库的扩展。
书中的所有食谱都包含代码示例,展示了如何使用某个功能或如何解决问题。这些代码示例是用 Visual Studio 2022 编写的,但也已使用 Clang 和 GCC 编译。由于各种语言和库功能已逐渐添加到所有这些编译器中,建议您使用每个编译器的最新版本,以确保所有新功能都得到支持。
在撰写这篇前言时,最新的版本是 GCC 14.0、Clang 18.0 和 VC++ 2022 版本 14.37(来自 Visual Studio 2019 版本 17.7)。尽管所有这些编译器都是 C++17 完整的,但它们对 C++23 的支持各不相同。请参阅en.cppreference.com/w/cpp/compiler_support以检查您的编译器对 C++23 特性的支持。
本书面向对象
本书旨在面向所有 C++开发者,无论其经验水平如何。典型的读者是希望掌握语言并成为多产的现代 C++开发者的入门级或中级 C++开发者。经验丰富的 C++开发者将发现这是一本很好的参考资料,其中包含许多 C++11、C++14、C++17、C++20 和 C++23 语言和库特性,这些特性可能在某些时候很有用。本书包含超过 150 个食谱,涵盖了从简单到中级甚至高级的内容。然而,它们都需要先前的 C++知识,包括函数、类、模板、命名空间、宏等。因此,如果你不熟悉这门语言,建议你首先阅读一本入门书籍,以便熟悉核心方面,然后继续阅读本书。
本书涵盖内容
第一章,学习现代核心语言特性,介绍了现代核心语言特性,包括类型推断、统一初始化、作用域枚举、基于范围的 for 循环、结构化绑定、类模板参数推导等。
第二章,处理数字和字符串,讨论了如何在数字和字符串之间进行转换、生成伪随机数、处理正则表达式以及各种类型的字符串,以及如何使用 C++20 文本格式化库来格式化文本。
第三章,探索函数,深入探讨了默认和删除函数、可变模板、lambda 表达式和高级函数。
第四章,预处理和编译,从如何执行条件编译、编译时断言、代码生成以及使用属性提示编译器等方面,探讨了编译的各个方面。
第五章,标准库容器、算法和迭代器,介绍了几个标准容器、许多算法,并教你如何编写自己的随机访问迭代器。
第六章,通用工具:深入探讨了 chrono 库,包括 C++20 的日历和时间区域支持;any、optional、variant 以及 span 和 mdspan 类型;以及类型特性。
第七章,处理文件和流:解释了如何读取和写入流中的数据,使用 I/O 操作符控制流,并探讨了 filesystem 库。
第八章,利用线程和并发:教你如何使用线程、互斥锁、锁、条件变量、承诺、未来、原子类型,以及 C++20 的闩锁、栅栏和信号量。
第九章,健壮性和性能:专注于异常、常量正确性、类型转换、智能指针和移动语义。
第十章,实现模式和惯用语:涵盖了各种有用的模式和惯用语,例如 pimpl 惯用语、非虚拟接口惯用语、奇特重复的模板模式以及混入。
第十一章,探索测试框架:使用三个最广泛使用的测试框架,Boost.Test、Google Test 和 Catch2,为你提供入门。
第十二章,C++20 核心特性:介绍了 C++20 标准中最重要的新增功能——模块、概念、协程和范围,包括 C++23 的更新。
本版新增内容
本节提供了一列新的或更新的食谱,以及变化的简要描述。
第一章,学习现代核心语言特性:
- 
使用范围枚举:更新了 C++23 的 std::to_underlying和std::is_scoped_enum
- 
使用基于范围的 for 循环迭代范围:更新了 C++23 的 init语句
- 
使用下标操作符访问集合中的元素: (新) C++23 的多维下标操作符 
第二章,处理数字和字符串:
- 
理解各种数值类型: (新) 解释了 C++ 数值类型 
- 
理解各种字符和字符串类型: (新) 解释了 C++ 字符类型 
- 
将 Unicode 字符打印到输出控制台: (新) 讨论了与 UNICODE 一起工作并将内容打印到控制台 
- 
创建字符串辅助库:更新了 C++20 的 starts_with()、ends_with()和contains()
- 
使用 std::format 和 std::print 格式化和打印文本:更新了 C++23 的 std::print()和std::println()
- 
使用 std::format 与用户定义类型:更新了 C++23 的 std::formattable和更好的示例
第三章,探索函数:
- 
与标准算法一起使用 lambda 表达式:更新了 C++23 的函数调用操作符属性 
- 
编写递归 lambda 表达式:更新了 C++14 的递归泛型 lambda 表达式 
- 
编写函数模板: (新) 编写函数模板的教程 
第四章,预处理器和编译:
- 
条件编译源代码:更新了 C++23 的 #warning、#elifdef和#elifndef
- 
使用 static_assert 进行编译时断言检查:更新了 C++26 的用户生成消息 
- 
使用属性向编译器提供元数据: 更新了 C++23 的 lambda、 [[assume]]和重复属性上的属性
第五章,标准库容器、算法和迭代器:
- 
使用 vector 作为默认容器: 更新了 C++23 的范围感知成员函数 
- 
选择合适的标准容器 (新) 标准容器的比较 
第六章,通用工具:
- 
使用日历: 更新了与 C++20 兼容的示例 
- 
在时区之间转换时间: 更新了与 C++20 兼容的示例 
- 
链式计算,可能或可能不产生值: (新) 讨论了 C++23 的 std::optional单子操作
- 
使用 std::expected 返回值或错误: (新) 讨论了 C++23 的 std::expected类型
- 
使用 std::mdspan 对对象序列的多维视图: (新) 探索了 C++23 的 std::mdspan类型
- 
使用 source_location 提供日志细节: (新) 探索了 C++20 的 std::source_location类型
- 
使用堆栈跟踪库打印调用堆栈: (新) 介绍了 C++23 的堆栈跟踪库 
第七章,处理文件和流:
- 在固定大小的外部缓冲区上使用流: (新) 探索了 C++23 的 span 缓冲区
第八章,利用线程和并发:
- 
使用线程同步机制: 更新了与 C++20 兼容的示例 
- 
同步输出流: (新) 讨论了 C++20 的同步流 
第九章,健壮性和性能:
- 
创建编译时常量表达式: 更新了 C++23 的静态 constexpr变量
- 
在常量评估上下文中优化代码: (新) 解释了 C++23 的 consteval
- 
在常量表达式中使用虚函数调用: (新) 讨论了 C++20 的 constexpr虚函数
第十章,实现模式和惯用语:
- 
使用混入向类添加功能: (新) 解释了混入模式 
- 
使用类型擦除泛型处理无关类型: (新) 讨论了类型擦除惯用语 
第十一章,探索测试框架:
- 
开始使用 Catch2: 更新了 Catch2 版本 3.4.0 的安装说明 
- 
使用 Catch2 进行断言: 更新了 Catch2 版本 3.4.0 的示例 
第十二章,C++20 核心特性:
- 
探索缩写函数模板: (新) 检查了 C++20 的缩写函数模板 
- 
探索标准范围适配器: (新) 讨论了 C++20 和 C++23 的范围适配器 
- 
将范围转换为容器: (新) 解释了如何使用 C++23 的 std::ranges::to()将范围转换为标准容器
- 
使用约束算法: (新) 探索了直接与范围一起工作的通用算法 
- 
创建异步计算的协程任务: 更新了符合标准的示例(以及使用 libcoro 库的替代方案) 
- 
为值序列创建协程生成器类型: 更新了符合标准的示例(以及使用 libcoro 库的替代方案) 
- 
使用 std::generator 类型递归生成值:(新)解释了 C++23 的 std::generator
为了充分利用本书
书中展示的代码可以从github.com/PacktPublishing/Modern-Cpp-Programming-Cookbook-Third-Edition下载,尽管我鼓励你尝试自己编写所有示例。为了编译它们,你需要在 Windows 上安装 VC++ 2022 17.7,在 Linux 和 Mac 上安装 GCC 14.0 或 Clang 18.0。如果你没有编译器的最新版本,或者想尝试另一个编译器,你可以使用在线可用的编译器。
虽然你可以使用各种在线平台,但我推荐 Wandbox,可在wandbox.org/找到,以及 Compiler Explorer,可在godbolt.org/找到。
下载示例代码文件
本书代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Modern-Cpp-Programming-Cookbook-Third-Edition。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781835080542。
使用的约定
本书使用了多种文本约定。
CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“几何模块定义在一个名为geometry.ixx/.cppm的文件中,尽管任何文件名都会得到相同的结果。”
代码块设置如下:
static std::map<
  std::string,
  std::function<std::unique_ptr<Image>()>> mapping
{
  { "bmp", []() {return std::make_unique<BitmapImage>(); } },
  { "png", []() {return std::make_unique<PngImage>(); } },
  { "jpg", []() {return std::make_unique<JpgImage>(); } }
}; 
当我们希望将您的注意力引向代码块中的特定部分时,相关的行或项目会被突出显示:
static std::map<
  std::string,
  **std::function<std::unique_ptr<Image>()>> mapping**
{
  { "bmp", []() {return std::make_unique<BitmapImage>(); } },
  { "png", []() {return std::make_unique<PngImage>(); } },
  { "jpg", []() {return std::make_unique<JpgImage>(); } }
}; 
任何命令行输入或输出都应如下编写:
running thread 140296854550272
running thread 140296846157568
running thread 140296837764864 
粗体:表示新术语、重要单词或你在屏幕上看到的单词,例如在菜单或对话框中,也以这种方式出现在文本中。例如:“从管理面板中选择系统信息。”
警告或重要注意事项看起来像这样。
小贴士和技巧看起来像这样。
联系我们
我们欢迎读者的反馈。
一般反馈:请发送电子邮件至feedback@packtpub.com,并在邮件主题中提及本书的标题。如果你对本书的任何方面有疑问,请通过电子邮件联系我们questions@packtpub.com。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在此书中发现错误,我们将不胜感激,如果您能向我们报告,请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《现代 C++编程食谱,第三版》,我们非常乐意听取您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走?
您的电子书购买是否与您选择的设备不兼容?
不要担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不会就此结束,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取好处:
- 扫描下面的二维码或访问以下链接

packt.link/free-ebook/9781835080542
- 
提交您的购买证明 
- 
就这些!我们将直接将您的免费 PDF 和其他好处发送到您的电子邮件。 
第一章:学习现代核心语言特性
C++语言在过去几十年中经历了重大变革,随着 C++11 的发布以及随后更新的版本:C++14、C++17、C++20 和 C++23,这些新标准引入了新的概念,简化并扩展了现有的语法和语义,并彻底改变了我们编写代码的方式。与之前所知相比,C++11 看起来和感觉上像是一种全新的语言,使用这些新标准编写的代码被称为现代 C++代码。本入门章节将涉及一些引入的语言特性,从 C++11 开始,这些特性有助于你处理许多编码常规。然而,语言的核心内容远远超出了本章所讨论的主题,书中其他章节还讨论了许多其他特性。
本章包含的食谱如下:
- 
尽可能使用 auto
- 
创建类型别名和别名模板 
- 
理解统一初始化 
- 
理解非静态成员初始化的各种形式 
- 
控制和查询对象对齐 
- 
使用范围枚举 
- 
使用 override和final关键字为虚方法
- 
使用基于范围的 for 循环遍历范围 
- 
为自定义类型启用基于范围的 for 循环 
- 
使用显式构造函数和转换运算符来避免隐式转换 
- 
使用无名命名空间而不是静态全局变量 
- 
使用内联命名空间进行符号版本控制 
- 
使用结构化绑定来处理多返回值 
- 
使用类模板参数推导简化代码 
- 
使用下标运算符访问集合中的元素 
让我们从学习自动类型推导开始。
尽可能使用auto
自动类型推导是现代 C++ 中最重要且最广泛使用的特性之一。新的 C++ 标准使得在多种上下文中使用 auto 作为类型的占位符成为可能,让编译器推导出实际类型。在 C++11 中,auto 可以用来声明局部变量以及具有尾随返回类型的函数的返回类型。在 C++14 中,auto 可以用来声明没有指定尾随类型的函数返回类型以及 lambda 表达式中的参数声明。在 C++17 中,它可以用来声明结构化绑定,这在章节末尾有讨论。在 C++20 中,它可以用来简化函数模板语法,所谓的缩写函数模板。在 C++23 中,它可以用来执行对 prvalue 复制的显式转换。未来的标准版本可能会将 auto 的使用扩展到更多的情况。C++11 和 C++14 中引入的 auto 的使用有几个重要的好处,所有这些都会在 它是如何工作的... 部分讨论。开发者应该意识到它们,并尽可能使用 auto。安德烈·亚历山德鲁斯库(Andrei Alexandrescu)提出了一个实际术语,并由 Herb Sutter 推广——几乎总是 auto (AAA) (herbsutter.com/2013/08/12/gotw-94-solution-aaa-style-almost-always-auto/)。
如何做到...
在以下情况下考虑使用 auto 作为实际类型的占位符:
- 
使用 auto name = expression形式声明局部变量,当你不想承诺一个特定类型时:auto i = 42; // int auto d = 42.5; // double auto s = "text"; // char const * auto v = { 1, 2, 3 }; // std::initializer_list<int>
- 
使用 auto name = type-id { expression }形式声明局部变量,当你需要承诺一个特定类型时:auto b = new char[10]{ 0 }; // char* auto s1 = std::string {"text"}; // std::string auto v1 = std::vector<int> { 1, 2, 3 }; // std::vector<int> auto p = std::make_shared<int>(42); // std::shared_ptr<int>
- 
使用 auto name = lambda-expression形式声明命名 lambda 函数,除非 lambda 需要传递或返回给函数:auto upper = [](char const c) {return toupper(c); };
- 
声明 lambda 参数和返回值: auto add = [](auto const a, auto const b) {return a + b;};
- 
在不想承诺一个特定类型时声明函数返回类型: template <typename F, typename T> auto apply(F&& f, T value) { return f(value); }
它是如何工作的...
auto 说明符基本上是一个实际类型的占位符。当使用 auto 时,编译器从以下实例推导出实际类型:
- 
从初始化变量的表达式类型,当使用 auto声明变量时。
- 
从函数的尾随返回类型或返回表达式类型,当 auto用作函数返回类型的占位符时。
在某些情况下,有必要承诺一个特定的类型。例如,在第一个例子中,编译器推断出s的类型为char const *。如果意图是使用std::string,则必须显式指定类型。同样,v的类型被推断为std::initializer_list<int>,因为它绑定到auto而不是特定类型;在这种情况下,规则说明推断的类型是std::initializer_list<T>,其中T在我们的例子中是int。然而,意图可能是拥有一个std::vector<int>。在这种情况下,必须在赋值右侧显式指定类型。
使用auto指定符而不是实际类型有一些重要的好处;以下是一些可能最重要的好处:
- 
无法留下未初始化的变量。这是开发者在声明变量并指定实际类型时常见的错误。然而,使用 auto是不可能的,因为它需要初始化变量以推断类型。使用定义的值初始化变量很重要,因为未初始化的变量会导致未定义的行为。
- 
使用 auto确保你始终使用预期的类型,并且不会发生隐式转换。考虑以下示例,其中我们检索局部变量的向量大小。在第一种情况下,变量的类型是int,尽管size()方法返回size_t。这意味着将从size_t到int发生隐式转换。然而,使用auto来推断类型将得出正确的类型——即size_t:auto v = std::vector<int>{ 1, 2, 3 }; // implicit conversion, possible loss of data int size1 = v.size(); // OK auto size2 = v.size(); // ill-formed (warning in gcc, error in clang & VC++) auto size3 = int{ v.size() };
- 
使用 auto可以促进良好的面向对象实践,例如优先选择接口而非实现。这在面向对象编程(OOP)中非常重要,因为它提供了在不同实现之间进行更改的灵活性,代码的模块化,以及更好的可测试性,因为模拟对象很容易。指定的类型数量越少,代码越通用,对未来变化的开放性也越大,这是面向对象编程的基本原则。
- 
它意味着(通常)更少的输入和更少的对实际类型(我们实际上并不关心的类型)的关注。我们经常遇到的情况是,即使我们明确指定了类型,我们实际上并不关心它。一个非常常见的例子是与迭代器相关,但还有更多。当你想要遍历一个范围时,你并不关心迭代器的实际类型。你只对迭代器本身感兴趣;因此使用 auto可以节省输入(可能很长的)名称的时间,并帮助你专注于实际的代码而不是类型名称。在下面的例子中,在第一个for循环中,我们明确使用了迭代器的类型。这需要输入很多文本;长语句实际上可能使代码更难以阅读,而且你还需要知道类型名称,而你实际上并不关心。带有auto指示符的第二个循环看起来更简单,可以节省你输入和关注实际类型的时间:std::map<int, std::string> m; for (std::map<int, std::string>::const_iterator it = m.cbegin(); it != m.cend(); ++it) { /*...*/ } for (auto it = m.cbegin(); it != m.cend(); ++it) { /*...*/ }
- 
使用 auto声明变量提供了一种一致的编码风格,类型始终位于右侧。如果你动态分配对象,你需要在赋值语句的左右两侧都写上类型,例如,int* p = new int(42)。使用auto,类型仅在右侧指定一次。
然而,在使用 auto 时也有一些需要注意的问题:
- 
auto指示符仅是类型的占位符,而不是const/volatile和引用指定符。如果你需要一个const/volatile和/或引用类型,那么你需要明确指定它们。在下面的例子中,foo的get()成员函数返回int的引用;当变量x从返回值初始化时,编译器推断出的类型是int,而不是int&。因此,对x的任何更改都不会传播到foo.x_。为了做到这一点,我们应该使用auto&:class foo { int x; public: foo(int const value = 0) :x{ value } {} int& get() { return x; } }; foo f(42); auto x = f.get(); x = 100; std::cout << f.get() << '\n'; // prints 42
- 
对于不可移动的类型,无法使用 auto。auto ai = std::atomic<int>(42); // error
- 
对于多词类型,如 long long、long double或struct foo,无法使用auto。然而,在第一种情况下,可能的解决方案是使用字面量或类型别名;此外,在 Clang 和 GCC(但不是 MSVC)中,可以将类型名称放在括号中,例如(long long){ 42 }。至于第二种情况,以那种形式使用struct/class只在 C++ 中支持与 C 的兼容性,并且应该避免使用:auto l1 = long long{ 42 }; // error using llong = long long; auto l2 = llong{ 42 }; // OK auto l3 = 42LL; // OK auto l4 = (long long){ 42 }; // OK with gcc/clang
- 
如果你使用了 auto指示符但仍然需要知道类型,你可以在大多数 IDE 中通过将光标放在变量上来实现,例如。然而,如果你离开 IDE,那就不再可能了,唯一知道实际类型的方法是自己从初始化表达式中推断出来,这可能意味着在代码中搜索函数返回类型。
auto可以用来指定函数的返回类型。在 C++11 中,这需要在函数声明中有一个尾随返回类型。在 C++14 中,这已经放宽,编译器会从return表达式推断返回值的类型。如果有多个返回值,它们应该具有相同的类型:
// C++11
auto func1(int const i) -> int
{ return 2*i; }
// C++14
auto func2(int const i)
{ return 2*i; } 
如前所述,auto不会保留const/volatile和引用限定符。这导致auto作为函数返回类型占位符时出现问题。为了解释这一点,让我们考虑前面的例子foo.get()。这次,我们有一个名为proxy_get()的包装函数,它接受一个foo的引用,调用get(),并返回get()返回的值,该值是一个int&。然而,编译器将推断proxy_get()的返回类型为int,而不是int&。
尝试将那个值赋给一个int&会失败并报错:
class foo
{
  int x_;
public:
  foo(int const x = 0) :x_{ x } {}
  int& get() { return x_; }
};
auto proxy_get(foo& f) { return f.get(); }
auto f = foo{ 42 };
auto& x = proxy_get(f); // cannot convert from 'int' to 'int &' 
为了解决这个问题,我们需要实际返回auto&。然而,模板和完美前向传递返回类型时不知道它是值还是引用存在一个问题。C++14 中解决这个问题的方法是decltype(auto),它将正确推断类型:
decltype(auto) proxy_get(foo& f) 
{ return f.get(); }
auto f = foo{ 42 };
decltype(auto) x = proxy_get(f); 
decltype说明符用于检查实体或表达式的声明类型。它主要用于在声明类型比较繁琐或根本无法使用标准符号声明时。这类例子包括声明 lambda 类型以及依赖于模板参数的类型。
auto可以使用的最后一个重要情况是与 lambda 一起。截至 C++14,lambda 返回类型和 lambda 参数类型都可以是auto。这样的 lambda 被称为泛型 lambda,因为 lambda 定义的闭包类型有一个模板调用操作符。以下是一个泛型 lambda 的示例,它接受两个auto参数,并返回将operator+应用于实际类型的操作结果:
auto ladd = [] (auto const a, auto const b) { return a + b; }; 
编译器生成的函数对象具有以下形式,其中调用操作符是一个函数模板:
struct
{
  template<typename T, typename U>
 auto operator () (T const a, U const b) const { return a+b; }
} L; 
这个 lambda 可以用于添加任何定义了operator+的操作数的值,如下面的代码片段所示:
auto i = ladd(40, 2);            // 42
auto s = ladd("forty"s, "two"s); // "fortytwo"s 
在这个例子中,我们使用了ladd lambda 函数来添加两个整数并将它们连接到std::string对象(使用 C++14 的用户定义字面量operator ""s)。
参见
- 
创建类型别名和别名模板,了解类型别名的使用 
- 
理解统一初始化,了解花括号初始化是如何工作的 
创建类型别名和别名模板
在 C++中,可以创建同义词,这些同义词可以用来代替类型名称。这是通过创建 typedef 声明来实现的。这在几种情况下很有用,例如为类型创建更短或更有意义的名称,或者为函数指针创建名称。然而,typedef 声明不能与模板一起使用来创建模板类型别名。例如,std::vector<T>不是一个类型(std::vector<int>是一个类型),而是一系列类型,当类型占位符T被实际类型替换时可以创建。
在 C++11 中,类型别名是已声明类型的名称,别名模板是已声明模板的名称。这两种类型的别名都是通过新的 using 语法引入的。
如何实现...
- 
创建具有形式 using identifier = type-id的类型别名,如下面的示例所示:using byte = unsigned char; using byte_ptr = unsigned char *; using array_t = int[10]; using fn = void(byte, double); void func(byte b, double d) { /*...*/ } byte b{42}; byte_ptr pb = new byte[10] {0}; array_t a{0,1,2,3,4,5,6,7,8,9}; fn* f = func;
- 
创建具有形式 template<template-params-list> identifier = type-id的别名模板,如下面的示例所示:template <class T> class custom_allocator { /* ... */ }; template <typename T> using vec_t = std::vector<T, custom_allocator<T>>; vec_t<int> vi; vec_t<std::string> vs;
为了保持一致性和可读性,你应该做以下事情:
- 
在创建别名时不要混合使用 typedef 和 using 声明 
- 
在创建函数指针类型的名称时,优先使用 using 语法 
它是如何工作的...
typedef 声明引入了一个类型的同义词(换句话说,别名)。它不会引入另一个类型(如class、struct、union或enum声明)。使用 typedef 声明引入的类型名称遵循与标识符名称相同的隐藏规则。它们也可以重新声明,但只能引用相同的类型(因此,你可以在翻译单元中拥有有效的多个 typedef 声明,引入相同的类型名称同义词,只要它是相同类型的同义词)。以下是一些典型的 typedef 声明示例:
typedef unsigned char   byte;
typedef unsigned char * byte_ptr;
typedef int array_t[10];
typedef void(*fn)(byte, double);
template<typename T>
class foo {
  typedef T value_type;
};
typedef std::vector<int> vint_t;
typedef int INTEGER;
INTEGER x = 10;
typedef int INTEGER; // redeclaration of same type
INTEGER y = 20; 
类型别名声明与 typedef 声明等价。它可以出现在块作用域、类作用域或命名空间作用域中。根据 C++11 标准(第 9.2.4 段,文档版本 N4917):
可以通过别名声明来引入 typedef 名称。使用 using 关键字之后的标识符成为 typedef 名称,标识符之后的可选属性说明符序列属于该 typedef 名称。它具有与使用 typedef 说明符引入相同的语义。特别是,它不会定义一个新的类型,并且它不应出现在类型标识符中。
然而,当创建数组类型和函数指针类型的别名时,别名声明在可读性和对实际别名的清晰度方面更为出色。在如何实现...部分的示例中,很容易理解array_t是 10 个整数的数组类型的名称,而fn是接受两个类型为byte和double的参数并返回void的函数类型的名称。这也与声明std::function对象的语法一致(例如,std::function<void(byte, double)> f)。
以下事项非常重要:
- 
别名模板不能部分或显式地特化。 
- 
别名模板在推导模板参数时永远不会通过模板参数推导进行推导。 
- 
当特化别名模板时产生的类型不允许直接或间接地使用其自身的类型。 
新语法的驱动目的是定义别名模板。这些模板在特化时,等价于将别名模板的模板参数替换为类型-id 中的模板参数的结果。
参见
- 使用类模板参数推导简化代码,学习如何在不显式指定模板参数的情况下使用类模板
理解统一初始化
大括号初始化是 C++11 中初始化数据的统一方法。因此,它也被称为 统一初始化。这可能是 C++11 中开发者应该理解和使用的最重要的特性之一。它消除了初始化基本类型、聚合和非聚合类型、数组和标准容器之间的区别。
准备工作
要继续这个配方,你需要熟悉直接初始化,即从一组显式的构造函数参数初始化对象,以及复制初始化,即从一个对象初始化另一个对象。以下是对这两种初始化的简单示例:
std::string s1("test");   // direct initialization
std::string s2 = "test";  // copy initialization 
在这些考虑的基础上,让我们探索如何执行统一初始化。
如何做...
要统一初始化对象,无论其类型如何,都使用大括号初始化形式 {},它可以用于直接初始化和复制初始化。当与大括号初始化一起使用时,这些被称为直接列表和复制列表初始化:
T object {other};   // direct-list-initialization
T object = {other}; // copy-list-initialization 
统一初始化的例子如下:
- 
标准容器: std::vector<int> v { 1, 2, 3 }; std::map<int, std::string> m { {1, "one"}, { 2, "two" }};
- 
动态分配的数组: int* arr2 = new int[3]{ 1, 2, 3 };
- 
数组: int arr1[3] { 1, 2, 3 };
- 
内置类型: int i { 42 }; double d { 1.2 };
- 
用户定义的类型: class foo { int a_; double b_; public: foo():a_(0), b_(0) {} foo(int a, double b = 0.0):a_(a), b_(b) {} }; foo f1{}; foo f2{ 42, 1.2 }; foo f3{ 42 };
- 
用户定义的 纯旧数据 (POD)类型: struct bar { int a_; double b_;}; bar b{ 42, 1.2 };
它是如何工作的...
在 C++11 之前,对象根据其类型需要不同类型的初始化:
- 
基本类型可以使用赋值进行初始化: int a = 42; double b = 1.2;
- 
如果类对象有一个转换构造函数(在 C++11 之前,只有一个参数的构造函数被称为 转换构造函数),它们也可以使用单个值的赋值进行初始化: class foo { int a_; public: foo(int a):a_(a) {} }; foo f1 = 42;
- 
非聚合类可以在提供参数时使用括号(函数形式)进行初始化,而在执行默认初始化(调用默认构造函数)时则无需任何括号。在下一个例子中, foo是在 如何做... 部分定义的结构:foo f1; // default initialization foo f2(42, 1.2); foo f3(42); foo f4(); // function declaration
- 
聚合和 POD 类型可以使用大括号初始化。在以下示例中, bar是在 如何做... 部分定义的结构:bar b = {42, 1.2}; int a[] = {1, 2, 3, 4, 5};
纯数据(POD)类型是一种既是平凡(具有编译器提供的或显式默认的特殊成员,并占用连续的内存区域)又具有标准布局(不包含与 C 语言不兼容的语言功能,如虚函数,并且所有成员具有相同的访问控制)的类型。POD 类型的概念在 C++20 中已被弃用,以支持平凡和标准布局类型。
除了不同的数据初始化方法外,还有一些限制。例如,初始化标准容器(除了复制构造之外)的唯一方法是在其中声明一个对象,然后向其中插入元素;std::vector 是一个例外,因为它可以从可以预先使用聚合初始化的数组中分配值。然而,另一方面,动态分配的聚合不能直接初始化。
“如何做...” 部分中的所有示例都使用直接初始化,但也可以使用花括号初始化进行复制初始化。这两种形式,直接和复制初始化,在大多数情况下可能是等效的,但复制初始化的限制较少,因为它在其隐式转换序列中不考虑显式构造函数,而必须直接从初始化器生成对象,而直接初始化则期望从初始化器到构造函数参数的隐式转换。动态分配的数组只能使用直接初始化。
在前面示例中显示的类中,foo 是唯一一个既有默认构造函数又有参数构造函数的类。要使用默认构造函数进行默认初始化,我们需要使用空花括号——即 {}。要使用参数构造函数,我们需要在花括号 {} 中提供所有参数的值。与非聚合类型不同,其中默认初始化意味着调用默认构造函数,对于聚合类型,默认初始化意味着使用零进行初始化。
如前所述,标准容器(如向量 map)的初始化也是可能的,因为所有标准容器在 C++11 中都有一个额外的构造函数,它接受类型为 std::initializer_list<T> 的参数。这基本上是一个轻量级代理,覆盖了类型 T const 的元素数组。然后这些构造函数从初始化列表中的值初始化内部数据。
使用 std::initializer_list 进行初始化的方式如下:
- 
编译器解析初始化列表中元素的类型(所有元素必须具有相同的类型)。 
- 
编译器创建一个包含初始化列表中元素的数组。 
- 
编译器创建一个 std::initializer_list<T>对象来包装之前创建的数组。
- 
std::initializer_list<T>对象作为参数传递给构造函数。
初始化器列表始终优先于使用花括号初始化的其他构造函数。如果此类存在此类构造函数,则在执行花括号初始化时将被调用:
class foo
{
  int a_;
  int b_;
public:
  foo() :a_(0), b_(0) {}
  foo(int a, int b = 0) :a_(a), b_(b) {}
  foo(std::initializer_list<int> l) {}
};
foo f{ 1, 2 }; // calls constructor with initializer_list<int> 
优先级规则适用于任何函数,而不仅仅是构造函数。在以下示例中,存在同一函数的两个重载。使用初始化器列表调用函数将解析为具有std::initializer_list的重载:
void func(int const a, int const b, int const c)
{
  std::cout << a << b << c << '\n';
}
void func(std::initializer_list<int> const list)
{
  for (auto const & e : list)
    std::cout << e << '\n';
}
func({ 1,2,3 }); // calls second overload 
然而,这可能导致错误。以std::vector类型为例。在向量的构造函数中,有一个只有一个参数的构造函数,表示要分配的初始元素数量,还有一个参数为std::initializer_list的构造函数。如果目的是创建具有预分配大小的向量,使用花括号初始化将不起作用,因为具有std::initializer_list的构造函数将是最佳重载以被调用:
std::vector<int> v {5}; 
前面的代码并没有创建一个包含五个元素的向量,而是一个包含一个值为5的元素的向量。要实际创建一个包含五个元素的向量,必须使用括号形式进行初始化:
std::vector<int> v (5); 
另一点需要注意的是,花括号初始化不允许缩窄转换。根据 C++标准(参考标准第 9.4.5 段,文档版本 N4917),缩窄转换是一种隐式转换:
从浮点类型到整数类型。
从
long double到double或float,或从double到float,除非源是常量表达式,并且转换后的实际值在可以表示的值范围内(即使不能精确表示)。从整数类型或无范围枚举类型到浮点类型,除非源是常量表达式,并且转换后的实际值可以适合目标类型,并且在转换回原始类型时将产生原始值。
从整数类型或无范围枚举类型到无法表示原始类型所有值的整数类型,除非源是常量表达式,并且转换后的实际值可以适合目标类型,并且在转换回原始类型时将产生原始值。
以下声明会触发编译器错误,因为它们需要缩窄转换:
int i{ 1.2 };           // error
double d = 47 / 13;
float f1{ d };          // error, only warning in gcc 
为了修复此错误,必须进行显式转换:
int i{ static_cast<int>(1.2) };
double d = 47 / 13;
float f1{ static_cast<float>(d) }; 
花括号初始化列表不是一个表达式,也没有类型。因此,不能在花括号初始化列表上使用decltype,模板类型推导也不能推导出与花括号初始化列表匹配的类型。
让我们再考虑一个例子:
float f2{47/13};        // OK, f2=3 
尽管如此,前面的声明是正确的,因为存在从int到float的隐式转换。表达式47/13首先被评估为整数值3,然后将其赋值给类型为float的变量f2。
还有更多...
以下示例展示了直接列表初始化和复制列表初始化的几个例子。在 C++11 中,所有这些表达式的推导类型是 std::initializer_list<int>:
auto a = {42};   // std::initializer_list<int>
auto b {42};     // std::initializer_list<int>
auto c = {4, 2}; // std::initializer_list<int>
auto d {4, 2};   // std::initializer_list<int> 
C++17 改变了列表初始化的规则,区分了直接列表初始化和复制列表初始化。类型推导的新规则如下:
- 
对于复制列表初始化,如果列表中的所有元素都具有相同的类型,则自动推导将推导出 std::initializer_list<T>;否则是不合法的。
- 
对于直接列表初始化,如果列表只有一个元素,则自动推导将推导出 T;如果有多个元素,则是不合法的。
根据这些新规则,前面的例子将如下改变(推导类型在注释中提及):
auto a = {42};   // std::initializer_list<int>
auto b {42};     // int
auto c = {4, 2}; // std::initializer_list<int>
auto d {4, 2};   // error, too many 
在这种情况下,a 和 c 被推导为 std::initializer_list<int>,b 被推导为 int,而 d 使用直接初始化并且花括号初始化列表中有多个值,这会触发编译器错误。
参见
- 
尽可能使用 auto,以了解 C++ 中自动类型推导的工作原理 
- 
理解非静态成员的多种初始化形式,以了解如何最佳地执行类成员的初始化 
理解非静态成员的多种初始化形式
构造函数是执行非静态类成员初始化的地方。许多开发者更喜欢在构造函数体中使用赋值。除了实际需要的那几个例外情况,非静态成员的初始化应该在构造函数的初始化列表中完成,或者从 C++11 开始,当它们在类中声明时,可以使用默认成员初始化。在 C++11 之前,类的常量和非常量非静态数据成员必须在构造函数中初始化。在类中声明初始化仅适用于静态常量。正如我们将在这里看到的,这种限制在 C++11 被移除,允许在类声明中初始化非静态成员。这种初始化被称为 默认成员初始化,将在以下章节中解释。
这个配方将探讨非静态成员初始化应该如何进行。为每个成员使用适当的初始化方法不仅会使代码更高效,而且会使代码组织得更好,更易于阅读。
如何做到这一点...
要初始化类的非静态成员,你应该:
- 
使用默认成员初始化为常量,包括静态和非静态(参见以下代码中的 [1]和[2])。
- 
使用默认成员初始化为具有多个构造函数的类成员提供默认值(参见以下代码中的 [3]和[4])。
- 
使用构造函数初始化列表来初始化没有默认值但依赖于构造函数参数的成员(参见以下代码中的 [5]和[6])。
- 
当其他选项不可用时,请在构造函数中使用赋值操作(例如,使用指针 this初始化数据成员,检查构造函数参数值,以及在用这些值或两个非静态数据成员的自我引用初始化成员之前抛出异常)。
以下示例显示了这些初始化形式:
struct Control
{
  const int DefaultHeight = 14;                                // [1]
const int DefaultWidth  = 80;                                // [2]
  std::string text;
  TextVerticalAlignment valign = TextVerticalAlignment::Middle;   // [3]
  TextHorizontalAlignment halign = TextHorizontalAlignment::Left; // [4]
Control(std::string const & t) : text(t)      // [5]
  {}
  Control(std::string const & t,
    TextVerticalAlignment const va,
    TextHorizontalAlignment const ha):
    text(t), valign(va), halign(ha)             // [6]
  {}
}; 
它是如何工作的...
非静态数据成员应该在构造函数的初始化列表中进行初始化,如下面的示例所示:
struct Point
{
  double x, y;
  Point(double const x = 0.0, double const y = 0.0) : x(x), y(y)  {}
}; 
然而,许多开发者并不使用初始化列表,而是偏好构造函数体中的赋值操作,甚至混合使用赋值和初始化列表。这可能由几个原因造成——对于具有许多成员的大类,构造函数中的赋值可能比长初始化列表更容易阅读,也许分散在多行中,或者可能是因为这些开发者熟悉没有初始化列表的其他编程语言。
重要的一点是,非静态数据成员的初始化顺序是它们在类定义中声明的顺序,而不是在构造函数初始化列表中初始化的顺序。相反,非静态数据成员的销毁顺序是构造顺序的反向。
在构造函数中使用赋值操作不是高效的,因为这可能会创建后来被丢弃的临时对象。如果不在初始化列表中初始化,非静态成员将通过它们的默认构造函数进行初始化,然后在构造函数体中分配值时,将调用赋值运算符。如果默认构造函数分配了资源(如内存或文件),并且需要在赋值运算符中重新分配和释放,这可能会导致低效的工作。这在下述代码片段中得到了体现:
struct foo
{
  foo()
  { std::cout << "default constructor\n"; }
  foo(std::string const & text)
  { std::cout << "constructor '" << text << "\n"; }
  foo(foo const & other)
  { std::cout << "copy constructor\n"; }
  foo(foo&& other)
  { std::cout << "move constructor\n"; };
  foo& operator=(foo const & other)
  { std::cout << "assignment\n"; return *this; }
  foo& operator=(foo&& other)
  { std::cout << "move assignment\n"; return *this;}
  ~foo()
  { std::cout << "destructor\n"; }
};
struct bar
{
  foo f;
  bar(foo const & value)
  {
    f = value;
  }
};
foo f;
bar b(f); 
上述代码产生以下输出,显示了数据成员 f 首先通过默认初始化,然后被分配了一个新值:
default constructor
default constructor
assignment
destructor
destructor 
如果你想跟踪哪个对象被创建和销毁,你可以稍微修改上面的 foo 类,并打印每个特殊成员函数的 this 指针的值。你可以将此作为后续练习来完成。
将构造函数体中的赋值操作更改为初始化列表,将替换对默认构造函数和赋值运算符的调用,改为调用拷贝构造函数:
bar(foo const & value) : f(value) { } 
添加上述代码行会产生以下输出:
default constructor
copy constructor
destructor
destructor 
由于这些原因,至少对于除内置类型(如 bool、char、int、float、double 或指针)之外的类型,你应该优先选择构造函数的初始化列表。然而,为了保持初始化风格的一致性,在可能的情况下,你应该始终优先选择构造函数的初始化列表。存在一些情况下使用初始化列表是不可能的;以下是一些情况(但列表可以扩展到其他情况):
- 
如果一个成员必须使用指向包含它的对象的指针或引用进行初始化,在初始化列表中使用 this指针可能会在某些编译器上触发警告,表明它应该在对象构造之前使用。
- 
如果你有两个数据成员必须相互包含对方的引用。 
- 
如果你想在初始化非静态数据成员之前测试输入参数并抛出异常。 
从 C++11 开始,非静态数据成员可以在类中声明时进行初始化。这被称为 默认成员初始化,因为它表示使用默认值进行初始化。默认成员初始化旨在用于常量和那些不基于构造函数参数初始化的成员(换句话说,成员的值不依赖于对象的构造方式):
enum class TextFlow { LeftToRight, RightToLeft };
struct Control
{
  const int DefaultHeight = 20;
  const int DefaultWidth = 100;
  TextFlow textFlow = TextFlow::LeftToRight;
  std::string text;
  Control(std::string const & t) : text(t)
  {}
}; 
在前面的例子中,DefaultHeight 和 DefaultWidth 都是常量;因此,它们的值不依赖于对象的构造方式,所以它们在声明时进行初始化。textFlow 对象是一个非常量、非静态数据成员,其值也不依赖于对象的初始化方式(它可以通过另一个成员函数进行更改);因此,它在声明时也使用默认成员初始化进行初始化。相反,text 也是一个非常量、非静态数据成员,但它的初始值依赖于对象的构造方式。
因此,它使用传递给构造函数的参数值在构造函数的初始化列表中进行初始化。
如果一个数据成员同时使用默认成员初始化和构造函数初始化列表进行初始化,后者具有优先级,并且默认值会被丢弃。为了说明这一点,让我们再次考虑前面提到的 foo 类和下面的 bar 类,它使用了 foo 类:
struct bar
{
  foo f{"default value"};
  bar() : f{"constructor initializer"}
  {
  }
};
bar b; 
在这种情况下,输出如下不同:
constructor 'constructor initializer'
destructor 
不同行为的原因是默认初始化列表中的值被丢弃,对象不会被初始化两次。
参见
- 理解统一初始化,了解花括号初始化是如何工作的
控制和查询对象对齐
C++11 提供了指定和查询类型对齐要求的标准方法(这之前只能通过编译器特定的方法实现)。控制对齐对于提高不同处理器的性能和允许使用一些仅在特定对齐上工作的指令非常重要。
例如,Intel 流式单指令多数据扩展(SSE)和 Intel SSE2,它们是一组处理器指令,当要对多个数据对象应用相同的操作时,可以大大提高性能,需要数据对齐 16 字节。相反,对于 Intel 高级向量扩展(或 Intel AVX),它将大多数整数处理器指令扩展到 256 位,强烈建议使用 32 字节对齐。本食谱探讨了用于控制对齐要求的 alignas 指定符和用于检索类型对齐要求的 alignof 操作符。
准备工作
您应该熟悉数据对齐是什么以及编译器如何执行默认数据对齐。然而,有关后者的基本信息在 它是如何工作的... 部分提供。
如何做到这一点...
- 
要控制类型(在类级别或数据成员级别)或对象的对齐,请使用 alignas指定符:struct alignas(4) foo { char a; char b; }; struct bar { alignas(2) char a; alignas(8) int b; }; alignas(8) int a; alignas(256) long b[4];
- 
要查询类型的对齐,请使用 alignof操作符:auto align = alignof(foo);
它是如何工作的...
处理器不是逐字节访问内存,而是以 2 的幂次方(2、4、8、16、32 等)的更大块来访问。因此,编译器在内存中对齐数据以便处理器可以轻松访问是很重要的。如果数据未对齐,编译器必须做额外的工作来访问数据;它必须读取多个数据块,移位并丢弃不必要的字节,然后组合剩余的部分。
C++ 编译器根据数据类型的大小来对变量进行对齐。标准仅指定了 char、signed char、unsigned char、char8_t(在 C++20 中引入)和 std::byte(在 C++17 中引入)的大小,这些大小必须是 1。它还要求 short 的大小至少为 16 位,long 的大小至少为 32 位,long long 的大小至少为 64 位。它还要求 1 == sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long)。因此,大多数类型的大小是编译器特定的,并且可能取决于平台。通常,这些大小是 bool 和 char 为 1 字节,short 为 2 字节,int、long 和 float 为 4 字节,double 和 long long 为 8 字节,等等。当涉及到结构体或联合体时,对齐必须与最大成员的大小相匹配,以避免性能问题。为了举例说明,让我们考虑以下数据结构:
struct foo1 // size = 1, alignment = 1
{              // foo1:    +-+
char a;      // members: |a|
};
struct foo2 // size = 2, alignment = 1
{              // foo2:    +-+-+
char a;      // members  |a|b|
char b;
};
struct foo3 // size = 8, alignment = 4
{              // foo3:    +----+----+
char a;      // members: |a...|bbbb|
int  b;      // . represents a byte of padding
}; 
foo1 和 foo2 的大小不同,但它们的对齐方式相同——即 1——因为所有数据成员都是 char 类型,其大小为 1 字节。在结构 foo3 中,第二个成员是一个整数,其大小为 4。因此,该结构的成员对齐是在地址为 4 的倍数的地方进行的。为了实现这一点,编译器引入了填充字节。
结构 foo3 实际上被转换成以下形式:
struct foo3_
{
  char a;        // 1 byte
char _pad0[3]; // 3 bytes padding to put b on a 4-byte boundary
int  b;        // 4 bytes
}; 
类似地,以下结构的大小为 32 字节,对齐为 8;这是因为最大的成员是一个大小为 8 的double。然而,这个结构需要在几个地方进行填充,以确保所有成员都可以在地址为 8 的倍数的位置访问:
struct foo4 // size = 24, alignment = 8
{               // foo4:    +--------+--------+--------+--------+
int a;        // members: |aaaab...|cccc....|dddddddd|e.......|
char b;       // . represents a byte of padding
float c;
  double d;
  bool e;
}; 
编译器创建的等效结构如下:
struct foo4_
{
  int a;         // 4 bytes
char b;        // 1 byte
char _pad0[3]; // 3 bytes padding to put c on a 8-byte boundary
float c;       // 4 bytes
char _pad1[4]; // 4 bytes padding to put d on a 8-byte boundary
double d;      // 8 bytes
bool e;        // 1 byte
char _pad2[7]; // 7 bytes padding to make sizeof struct multiple of 8
}; 
在 C++11 中,指定对象或类型的对齐是通过使用alignas指定符来完成的。这可以是一个表达式(一个求值为0或对齐有效值的整型常量表达式)、一个类型标识符或参数包。alignas指定符可以应用于变量或类的数据成员的声明,这些成员不表示位字段,或者可以应用于类、联合或枚举的声明。
在声明中使用的所有alignas指定符中,应用于类型或对象的alignas指定将对齐要求等于所有alignas指定符中最大的、大于零的表达式。
使用alignas指定符时有一些限制:
- 
只有 2 的幂(1、2、4、8、16、32 等等)是有效的对齐方式。任何其他值都是非法的,程序被认为是无效的;这并不一定必须产生错误,因为编译器可以选择忽略该指定。 
- 
0 的对齐始终被忽略。 
- 
如果声明中最大的 alignas值小于没有任何alignas指定符的自然对齐,则程序也被认为是无效的。
在下面的示例中,alignas指定符已被应用于类声明。如果没有alignas指定符的自然对齐将是 1,但使用alignas(4)后变为 4:
struct alignas(4) foo
{
  char a;
  char b;
}; 
换句话说,编译器将前面的类转换为以下形式:
struct foo
{
  char a;
  char b;
  char _pad0[2];
}; 
alignas指定符可以同时应用于类声明和成员数据声明。在这种情况下,最严格的(即,最大的)值获胜。在下面的示例中,成员a的自然大小为 1,需要 2 的对齐;成员b的自然大小为 4,需要 8 的对齐,因此最严格的对齐将是 8。整个类的对齐要求是 4,这比最严格要求的对齐更弱(即,更小),因此它将被忽略,尽管编译器将生成一个警告:
struct alignas(4) foo
{
  alignas(2) char a;
  alignas(8) int  b;
}; 
结果是一个看起来像这样的结构:
struct foo
{
  char a;
  char _pad0[7];
  int b;
  char _pad1[4];
}; 
alignas指定符也可以应用于变量。在下面的示例中,整数变量a必须放置在内存的 8 的倍数位置。下一个变量,即 4 个长整型的数组,必须放置在内存的 256 的倍数位置。因此,编译器将在两个变量之间引入多达 244 字节的填充(取决于内存中的位置,在地址为 8 的倍数的位置,变量a被放置):
alignas(8)   int a;
alignas(256) long b[4];
printf("%p\n", &a); // eg. 0000006C0D9EF908
printf("%p\n", &b); // eg. 0000006C0D9EFA00 
通过查看地址,我们可以看到 a 的地址确实是 8 的倍数,而 b 的地址是 256(十六进制 100)的倍数。
要查询类型的对齐,我们使用 alignof 操作符。与 sizeof 不同,此操作符只能应用于类型,不能应用于变量或类数据成员。它可以应用于完整类型、数组类型或引用类型。对于数组,返回的值是元素类型的对齐方式;对于引用,返回的值是引用类型的对齐方式。以下是一些示例:
| 表达式 | 评估 | 
|---|---|
| alignof(char) | 1,因为 char的自然对齐方式为 1 | 
| alignof(int) | 4,因为 int的自然对齐方式为 4 | 
| alignof(int*) | 32 位系统上的对齐方式为 4,64 位系统上的对齐方式为 8,这是指针的对齐方式 | 
| alignof(int[4]) | 4,因为元素类型的自然对齐方式为 4 | 
| alignof(foo&) | 8,因为类 foo的指定对齐方式为 8,这是一个引用类型(如前一个示例所示) | 
表 1.1:alignof 表达式的示例及其评估值
如果你想强制对数据类型进行对齐(考虑前面提到的限制),alignas 指示符非常有用,以便可以有效地访问和复制该类型的变量。这意味着优化 CPU 读取和写入,并避免缓存行不必要的无效化。
在某些类别中的应用中,性能至关重要,例如游戏或交易应用,这可以非常重要。相反,alignof 操作符尝试指定类型的最低对齐要求。
参见
- 创建类型别名和别名模板,以了解类型别名
使用范围枚举
枚举是 C++ 中的一个基本类型,它定义了一组值,这些值始终具有一个整型基础类型。它们的命名值,这些值是常量,被称为枚举符。使用关键字 enum 声明的枚举称为 无范围枚举,而使用 enum class 或 enum struct 声明的枚举称为 范围枚举。后者是在 C++11 中引入的,旨在解决无范围枚举的几个问题,这些问题在本食谱中进行了说明。
如何做到这一点...
当处理枚举时,你应该:
- 
更倾向于使用范围枚举而不是无范围枚举 
- 
使用 enum class或enum struct声明范围枚举:enum class Status { Unknown, Created, Connected }; Status s = Status::Created;
enum class 和 enum struct 声明是等效的,在这份食谱和本书的其余部分,我们将使用 enum class。
由于范围枚举是受限命名空间,C++20 标准允许我们使用 using 指令将它们关联起来。你可以这样做:
- 
使用 using指令在局部作用域中引入范围枚举标识符,如下所示:int main() { using Status::Unknown; Status s = Unknown; }
- 
使用 using指令在局部作用域中引入范围枚举的所有标识符,如下所示:struct foo { enum class Status { Unknown, Created, Connected }; using enum Status; }; foo::Status s = foo::Created; // instead of // foo::Status::Created
- 
使用 using enum指令在switch语句中引入枚举标识符,以简化你的代码:void process(Status const s) { switch (s) { using enum Status; case Unknown: /*…*/ break; case Created: /*...*/ break; case Connected: /*...*/ break; } }
在使用旧式 API(这些 API 接受整数作为参数)的上下文中,有时需要将范围枚举转换为它的基础类型。在 C++23 中,你可以通过使用std::to_underlying()实用函数将范围枚举转换为它的基础类型:
void old_api(unsigned flag);
enum class user_rights : unsigned
{
    None, Read = 1, Write = 2, Delete = 4
};
old_api(std::to_underlying(user_rights::Read)); 
它是如何工作的...
无范围枚举存在一些问题,这些问题会给开发者带来麻烦:
- 
它们将枚举符导出到周围作用域(这就是为什么它们被称为无范围枚举),这有两个缺点: - 
如果同一命名空间中的两个枚举具有相同名称的枚举符,可能会导致名称冲突 
- 
使用完全限定名称使用枚举符是不可能的: enum Status {Unknown, Created, Connected}; enum Codes {OK, Failure, Unknown}; // error auto status = Status::Created; // error
 
- 
- 
在 C++11 之前,它们不能指定基础类型,基础类型必须是整型。除非枚举值无法适应有符号或无符号整数,否则此类型不得大于 int。因此,枚举的前向声明是不可能的。原因在于枚举的大小是未知的。这是因为直到枚举符的值被定义,基础类型才未知,以便编译器选择适当的整型类型。这在 C++11 中得到了修复。
- 
枚举符的值隐式转换为 int。这意味着你可以故意或意外地将具有特定意义的枚举和整数(可能甚至与枚举的意义无关)混合,编译器将无法警告你:enum Codes { OK, Failure }; void include_offset(int pixels) {/*...*/} include_offset(Failure);
范围枚举基本上是强类型枚举,其行为与无范围枚举不同:
- 
它们不会将枚举符导出到周围作用域。前面显示的两个枚举将变为以下内容,不再生成名称冲突,并使得完全限定枚举符的名称成为可能: enum class Status { Unknown, Created, Connected }; enum class Codes { OK, Failure, Unknown }; // OK Codes code = Codes::Unknown; // OK
- 
你可以指定基础类型。无范围枚举的基础类型的相同规则也适用于范围枚举,除了用户可以显式指定基础类型。这也解决了关于前向声明的問題,因为基础类型可以在定义可用之前就已知: enum class Codes : unsigned int; void print_code(Codes const code) {} enum class Codes : unsigned int { OK = 0, Failure = 1, Unknown = 0xFFFF0000U };
- 
范围枚举的值不再隐式转换为 int。将enum class的值赋给整数变量将触发编译器错误,除非指定了显式转换:Codes c1 = Codes::OK; // OK int c2 = Codes::Failure; // error int c3 = static_cast<int>(Codes::Failure); // OK
然而,范围枚举有一个缺点:它们是受限命名空间。它们不会导出外部作用域中的标识符,这在某些情况下可能不方便,例如,当你编写一个switch语句并且需要为每个情况标签重复枚举名称时,如下面的示例所示:
std::string_view to_string(Status const s)
{
  switch (s)
  {
    case Status::Unknown:   return "Unknown";
    case Status::Created:   return "Created";
    case Status::Connected: return "Connected";
  }
} 
在 C++20 中,可以通过使用具有范围枚举名称的using指令来简化这一点。前面的代码可以简化如下:
std::string_view to_string(Status const s)
{
  switch (s)
  {
    using enum Status;
    case Unknown:   return "Unknown";
    case Created:   return "Created";
    case Connected: return "Connected";
  }
} 
此 using 指令的效果是,所有枚举标识符都引入到局部作用域中,使得可以使用未限定形式引用它们。也可以使用具有限定标识符名称的 using 指令仅将特定的枚举标识符引入局部作用域,例如 using Status::Connected。
C++23 标准版本添加了一些用于处理作用域枚举的实用函数。其中第一个是 std::to_underlying(),可在 <utility> 头文件中找到。它的作用是将枚举转换为它的底层类型。
它的目的是与不使用作用域枚举的 API(无论是遗留的还是新的)一起工作。让我们看看以下函数 old_api() 的例子,它接受一个整数参数,将其解释为控制用户权限的系统标志:
void old_api(unsigned flag)
{
    if ((flag & 0x01) == 0x01) { /* can read */ }
    if ((flag & 0x02) == 0x02) { /* can write */ }
    if ((flag & 0x04) == 0x04) { /* can delete */ }
} 
此函数可以按以下方式调用:
old_api(1); // read only
old_api(3); // read & write 
相反,系统的较新部分为用户权限定义了以下作用域枚举:
enum class user_rights : unsigned
{
    None,
    Read = 1,
    Write = 2,
    Delete = 4
}; 
然而,使用来自 user_rights 的枚举调用 old_api() 函数是不可能的,必须使用 static_cast:
old_api(static_cast<int>(user_rights::Read)); // read only
old_api(static_cast<int>(user_rights::Read) | 
        static_cast<int>(user_rights::Write)); // read & write 
为了避免这些静态转换,C++23 提供了函数 std::to_underlying(),可以使用如下方式:
old_api(std::to_underlying(user_rights::Read));
old_api(std::to_underlying(user_rights::Read) | 
        std::to_underlying(user_rights::Write)); 
C++23 中引入的其他实用工具是一个名为 is_scoped_enum<T> 的类型特质,可在 <type_traits> 头文件中找到。它包含一个名为 value 的成员常量,如果模板类型参数 T 是作用域枚举类型,则等于 true,否则为 false。还有一个辅助变量模板,is_scoped_enum_v<T>。
此类型特质的目的是确定枚举是否具有作用域,以便根据枚举的类型应用不同的行为。以下是一个简单的示例:
enum A {};
enum class B {};
int main()
{
   std::cout << std::is_scoped_enum_v<A> << '\n';
   std::cout << std::is_scoped_enum_v<B> << '\n';
} 
第一行将打印 0,因为 A 是无作用域枚举,而第二行将打印 1,因为 B 是作用域枚举。
参见
- 第九章,创建编译时常量表达式,了解如何处理编译时常量
使用 override 和 final 为虚方法
与其他类似的编程语言不同,C++ 没有用于声明接口(基本上是只有纯虚方法的类)的特定语法,并且还有一些与如何声明虚方法相关的缺陷。在 C++ 中,虚方法是通过 virtual 关键字引入的。然而,对于派生类中的重写声明,virtual 关键字是可选的,这可能导致在处理大型类或层次结构时产生混淆。您可能需要在整个层次结构中导航到基类,以确定一个函数是否是虚的。相反,有时确保虚函数或派生类不能再被重写或进一步派生是有用的。在这个菜谱中,我们将看到如何使用 C++11 特殊标识符 override 和 final 来声明虚函数或类。
准备就绪
您应该熟悉 C++中的继承和多态,以及抽象类、纯指定符、虚拟和覆盖方法等概念。
如何做到...
为了确保在基类和派生类中正确声明虚拟方法,同时确保提高可读性,请执行以下操作:
- 
在派生类中声明虚拟函数时,旨在使用 virtual关键字,这些虚拟函数应该覆盖基类中的虚拟函数。
- 
在虚拟函数的声明或定义的声明部分之后始终使用 override特殊标识符:class Base { virtual void foo() = 0; virtual void bar() {} virtual void foobar() = 0; }; void Base::foobar() {} class Derived1 : public Base { virtual void foo() override = 0; virtual void bar() override {} virtual void foobar() override {} }; class Derived2 : public Derived1 { virtual void foo() override {} };
声明符是函数类型的一部分,不包括返回类型。
为了确保函数不能进一步覆盖或类不能再派生,使用final特殊标识符,如下所示:
- 
在虚拟函数声明或定义的声明部分之后,以防止在派生类中进一步覆盖: class Derived2 : public Derived1 { virtual void foo() final {} };
- 
在类声明的类名之后,以防止进一步派生该类: class Derived4 final : public Derived1 { virtual void foo() override {} };
它是如何工作的...
override的工作方式非常简单;在虚拟函数的声明或定义中,它确保函数实际上覆盖了基类函数;否则,编译器将触发错误。
应该注意,override和final特殊标识符都是仅在成员函数声明或定义中有意义的特殊标识符。它们不是保留关键字,并且仍然可以在程序的其他地方作为用户定义的标识符使用。
使用override特殊标识符有助于编译器检测虚拟方法没有覆盖另一个方法的情况,如下面的示例所示:
class Base
{
public:
  virtual void foo() {}
  virtual void bar() {}
};
class Derived1 : public Base
{
public:
  void foo() override {}
  // for readability use the virtual keyword
virtual void bar(char const c) override {}
  // error, no Base::bar(char const)
}; 
如果没有override指定符的存在,Derived1类的虚拟bar(char const)方法将不会是一个覆盖方法,而是一个从Base类重载的bar()方法。
另一个特殊标识符final用于成员函数的声明或定义中,以指示该函数是虚拟的,并且在派生类中不能被覆盖。如果派生类尝试覆盖虚拟函数,编译器将触发错误:
class Derived2 : public Derived1
{
  virtual void foo() final {}
};
class Derived3 : public Derived2
{
  virtual void foo() override {} // error
}; 
final指定符也可以在类声明中使用,以指示它不能被派生:
class Derived4 final : public Derived1
{
  virtual void foo() override {}
};
class Derived5 : public Derived4 // error
{
}; 
由于override和final在定义的上下文中具有这种特殊含义,并且实际上不是保留关键字,因此您仍然可以在 C++代码的任何其他地方使用它们。这确保了在 C++11 之前编写的现有代码不会因为使用这些名称作为标识符而中断:
class foo
{
  int final = 0;
  void override() {}
}; 
尽管之前给出的建议建议在重写虚拟方法的声明中使用virtual和override,但virtual关键字是可选的,可以省略以缩短声明。存在override指定符应该足以向读者表明该方法虚拟。这更多的是个人偏好的问题,不会影响语义。
参见
- 第十章,使用 curiously recurring template pattern 实现静态多态,了解 CRTP 模式如何帮助在编译时实现多态
使用基于范围的 for 循环遍历范围
许多编程语言支持一种名为for each的for循环变体——即,重复一组语句遍历集合中的元素。C++直到 C++11 之前都没有对这种功能提供核心语言支持。最接近的功能是标准库中的通用算法std::for_each,它将一个函数应用于范围中的所有元素。C++11 引入了对for each的语言支持,实际上称为基于范围的 for 循环。新的 C++17 标准为原始语言特性提供了几个改进。
准备工作
在 C++11 中,基于范围的 for 循环具有以下通用语法:
for ( range_declaration : range_expression ) loop_statement 
在 C++20 中,初始化语句(必须以分号结束)可以在范围声明之前存在。因此,一般形式变为以下内容:
for(init-statement range-declaration : range-expression)
loop-statement 
为了说明使用基于范围的 for 循环的各种方式,我们将使用以下函数,它们返回元素序列:
std::vector<int> getRates()
{
  return std::vector<int> {1, 1, 2, 3, 5, 8, 13};
}
std::multimap<int, bool> getRates2()
{
  return std::multimap<int, bool> {
    { 1, true },
    { 1, true },
    { 2, false },
    { 3, true },
    { 5, true },
    { 8, false },
    { 13, true }
  };
} 
在下一节中,我们将探讨我们可以使用基于范围的 for 循环的各种方式。
如何做到这一点...
基于范围的 for 循环可以用各种方式使用:
- 
通过为序列的元素指定特定类型: auto rates = getRates(); for (int rate : rates) std::cout << rate << '\n'; for (int& rate : rates) rate *= 2;
- 
通过不指定类型,让编译器推断它: for (auto&& rate : getRates()) std::cout << rate << '\n'; for (auto & rate : rates) rate *= 2; for (auto const & rate : rates) std::cout << rate << '\n';
- 
通过在 C++17 中使用结构化绑定和分解声明: for (auto&& [rate, flag] : getRates2()) std::cout << rate << '\n';
它是如何工作的...
在如何做到这一点...部分之前显示的基于范围的 for 循环的表达式基本上是语法糖,因为编译器将其转换为其他内容。在 C++17 之前,编译器生成的代码通常是以下内容:
{
  auto && __range = range_expression;
  for (auto __begin = begin_expr, __end = end_expr;
  __begin != __end; ++__begin) {
    range_declaration = *__begin;
    loop_statement
  }
} 
begin_expr和end_expr在这个代码中的含义取决于范围类型:
- 
对于 C 样式的数组: __range和__range + __bound(其中__bound是数组中元素的数量)。
- 
对于具有 begin和end成员的类类型(无论其类型和可访问性):__range.begin()和__range.end()。
- 
对于其他情况,它是 begin(__range)和end(__range),这些是通过参数依赖查找确定的。
需要注意的是,如果一个类包含任何名为begin或end的成员(函数、数据成员或枚举器),无论其类型和可访问性如何,它们将被用于begin_expr和end_expr。因此,这种类类型不能用于基于范围的 for 循环。
在 C++17 中,编译器生成的代码略有不同:
{
  auto && __range = range_expression;
  auto __begin = begin_expr;
  auto __end = end_expr;
  for (; __begin != __end; ++__begin) {
    range_declaration = *__begin;
    loop_statement
  }
} 
新标准已经取消了 begin 表达式和 end 表达式必须是相同类型的约束。end 表达式不需要是一个实际的迭代器,但它必须能够与迭代器进行比较。这个好处是范围可以通过谓词来界定。相反,end 表达式只计算一次,而不是每次循环迭代时都计算,这可能会提高性能。
如前所述,在 C++20 中,范围声明之前可以有一个初始化语句。这导致编译器为基于范围的 for 循环生成的代码具有以下形式:
{
  init-statement
  auto && __range = range_expression;
  auto __begin = begin_expr;
  auto __end = end_expr;
  for (; __begin != __end; ++__begin) {
    range_declaration = *__begin;
    loop_statement
  }
} 
初始化语句可以是一个空语句、表达式语句、简单声明,或者从 C++23 开始,是一个别名声明。以下是一个示例:
for (auto rates = getRates(); int rate : rates)
{
   std::cout << rate << '\n';
} 
在 C++23 之前,这有助于避免范围表达式中的临时变量引起的未定义行为。range-expression 返回的临时变量的生命周期被扩展到循环结束。然而,如果它们将在 range-expression 结束时被销毁,则不会扩展 range-expression 内部临时变量的生命周期。
我们将通过以下代码片段来解释这一点:
struct item
{
   std::vector<int> getRates()
 {
      return std::vector<int> {1, 1, 2, 3, 5, 8, 13};
   }
};
item make_item()
{
   return item{};
}
// undefined behavior, until C++23
for (int rate : make_item().getRates())
{
   std::cout << rate << '\n';
} 
由于 make_item() 通过值返回,我们在 range-expression 中有一个临时变量。这引入了未定义的行为,可以通过以下初始化语句避免:
for (auto item = make_item(); int rate : item.getRates())
{
   std::cout << rate << '\n';
} 
在 C++23 中,这个问题不再出现,因为该版本的规范还扩展了 range-expression 中所有临时变量的生命周期,直到循环结束。
参见
- 
为自定义类型启用基于范围的 for 循环,了解如何使用户定义的类型能够与基于范围的 for 循环一起使用 
- 
第十二章,使用范围库遍历集合,了解 C++20 范围库的基本知识 
- 
第十二章,创建自己的范围视图,了解如何通过用户定义的范围适配器扩展 C++20 范围库的功能 
为自定义类型启用基于范围的 for 循环
正如我们在前面的配方中看到的,基于范围的 for 循环,在其他编程语言中称为 for each,允许您遍历范围中的元素,提供了一种比标准 for 循环更简化的语法,并在许多情况下使代码更易于阅读。然而,基于范围的 for 循环并不与任何表示范围的类型直接工作,而是需要存在 begin() 和 end() 函数(对于非数组类型),无论是作为成员函数还是自由函数。在本配方中,我们将学习如何使自定义类型能够在基于范围的 for 循环中使用。
准备工作
如果您需要了解基于范围的 for 循环如何工作,以及编译器为这种循环生成的代码,建议在继续阅读本部分之前先阅读 使用基于范围的 for 循环遍历范围 的配方。
为了展示我们如何为表示序列的自定义类型启用基于范围的 for 循环,我们将使用以下简单数组的实现:
template <typename T, size_t const Size>
class dummy_array
{
  T data[Size] = {};
public:
  T const & GetAt(size_t const index) const
 {
    if (index < Size) return data[index];
    throw std::out_of_range("index out of range");
  }
  void SetAt(size_t const index, T const & value)
 {
    if (index < Size) data[index] = value;
    else throw std::out_of_range("index out of range");
  }
  size_t GetSize() const { return Size; }
}; 
本食谱的目的是使编写如下代码成为可能:
dummy_array<int, 3> arr;
arr.SetAt(0, 1);
arr.SetAt(1, 2);
arr.SetAt(2, 3);
for(auto&& e : arr)
{
  std::cout << e << '\n';
} 
实现所有这些所需步骤的详细描述将在以下章节中介绍。
如何实现...
要使自定义类型能够用于基于范围的 for 循环,你需要做以下事情:
- 
为该类型创建可变和常量迭代器,这些迭代器必须实现以下运算符: - 
operator++(前缀和后缀版本)用于递增迭代器
- 
operator*用于解引用迭代器并访问迭代器所指向的实际元素
- 
operator!=用于比较迭代器与另一个迭代器以进行不等性比较
 
- 
- 
为该类型提供免费的 begin()和end()函数。
给定前面的简单范围示例,我们需要提供以下内容:
- 
以下是一个迭代器类的最小实现: template <typename T, typename C, size_t const Size> class dummy_array_iterator_type { public: dummy_array_iterator_type(C& collection, size_t const index) : index(index), collection(collection) { } bool operator!= (dummy_array_iterator_type const & other) const { return index != other.index; } T const & operator* () const { return collection.GetAt(index); } dummy_array_iterator_type& operator++() { ++index; return *this; } dummy_array_iterator_type operator++(int) { auto temp = *this; ++*this; return temp; } private: size_t index; C& collection; };
- 
可变和常量迭代器的别名模板: template <typename T, size_t const Size> using dummy_array_iterator = dummy_array_iterator_type< T, dummy_array<T, Size>, Size>; template <typename T, size_t const Size> using dummy_array_const_iterator = dummy_array_iterator_type< T, dummy_array<T, Size> const, Size>;
- 
提供免费的 begin()和end()函数,这些函数返回相应的开始和结束迭代器,并为这两个别名模板提供重载:template <typename T, size_t const Size> inline dummy_array_iterator<T, Size> begin( dummy_array<T, Size>& collection) { return dummy_array_iterator<T, Size>(collection, 0); } template <typename T, size_t const Size> inline dummy_array_iterator<T, Size> end( dummy_array<T, Size>& collection) { return dummy_array_iterator<T, Size>( collection, collection.GetSize()); } template <typename T, size_t const Size> inline dummy_array_const_iterator<T, Size> begin( dummy_array<T, Size> const & collection) { return dummy_array_const_iterator<T, Size>( collection, 0); } template <typename T, size_t const Size> inline dummy_array_const_iterator<T, Size> end( dummy_array<T, Size> const & collection) { return dummy_array_const_iterator<T, Size>( collection, collection.GetSize()); }
如何工作...
在此实现可用的情况下,前面展示的基于范围的 for 循环将按预期编译和执行。在执行参数依赖查找时,编译器将识别我们编写的两个 begin() 和 end() 函数(它们接受对 dummy_array 的引用),因此,它生成的代码是有效的。
在前面的例子中,我们定义了一个迭代器类模板和两个别名模板,分别称为 dummy_array_iterator 和 dummy_array_const_iterator。begin() 和 end() 函数都有这两种迭代器类型的两个重载。
这是有必要的,这样我们考虑的容器就可以在基于范围的 for 循环中与常量和非常量实例一起使用:
template <typename T, const size_t Size>
void print_dummy_array(dummy_array<T, Size> const & arr)
{
  for (auto && e : arr)
  {
    std::cout << e << '\n';
  }
} 
为了使简单范围类能够使用基于范围的 for 循环,我们考虑的一个可能的替代方案是提供成员函数 begin() 和 end()。一般来说,这只有在你可以拥有并修改源代码的情况下才有意义。相反,本食谱中展示的解决方案适用于所有情况,并且应该优先于其他替代方案。
参见
- 
创建类型别名和别名模板,了解类型别名的知识 
- 
第十二章,使用 ranges 库遍历集合,了解 C++20 ranges 库的基本知识 
使用显式构造函数和转换运算符来避免隐式转换
在 C++11 之前,只有一个参数的构造函数被认为是转换构造函数(因为它接受另一个类型的值并从中创建一个新的类实例)。从 C++11 开始,每个没有explicit指定符的构造函数都被认为是转换构造函数。这很重要,因为这样的构造函数定义了从其参数类型或类型到类类型的隐式转换。类还可以定义将类类型转换为另一个指定类型的转换运算符。所有这些在某些情况下都是有用的,但在其他情况下可能会造成问题。在这个食谱中,我们将学习如何使用显式构造函数和转换运算符。
准备工作
对于这个食谱,你需要熟悉构造函数和转换运算符的转换。在这个食谱中,你将学习如何编写显式构造函数和转换运算符以避免隐式转换到或从某个类型。显式构造函数和转换运算符(称为用户定义的转换函数)的使用使得编译器能够产生错误——在某些情况下,这些错误是编码错误——并允许开发者快速发现这些错误并修复它们。
如何操作...
要声明显式构造函数和显式转换运算符(无论它们是函数还是函数模板),在声明中使用explicit指定符。
以下示例显示了显式构造函数和显式转换运算符:
struct handle_t
{
  explicit handle_t(int const h) : handle(h) {}
  explicit operator bool() const { return handle != 0; };
private:
  int handle;
}; 
它是如何工作的...
要理解显式构造函数的必要性以及它们是如何工作的,我们首先将查看转换构造函数。以下名为foo的类有三个构造函数:一个不带参数的默认构造函数、一个接受int的构造函数和一个接受两个参数(一个int和一个double)的构造函数。它们除了打印一条消息外不做任何事情。截至 C++11,这些都被认为是转换构造函数。该类还有一个转换运算符,它将foo类型的值转换为bool:
struct foo
{
  foo()
  { std::cout << "foo" << '\n'; }
  foo(int const a)
  { std::cout << "foo(a)" << '\n'; }
  foo(int const a, double const b)
  { std::cout << "foo(a, b)" << '\n'; }
  operator bool() const { return true; }
}; 
基于此,以下对象的定义是可能的(注意,注释代表控制台的输出):
foo f1;              // foo()
foo f2 {};           // foo()
foo f3(1);           // foo(a)
foo f4 = 1;          // foo(a)
foo f5 { 1 };        // foo(a)
foo f6 = { 1 };      // foo(a)
foo f7(1, 2.0);      // foo(a, b)
foo f8 { 1, 2.0 };   // foo(a, b)
foo f9 = { 1, 2.0 }; // foo(a, b) 
变量f1和f2调用默认构造函数。f3、f4、f5和f6调用接受int的构造函数。请注意,所有这些对象的定义都是等效的,尽管它们看起来不同(f3使用函数形式初始化,f4和f6是复制初始化,而f5直接使用花括号初始化列表初始化)。同样,f7、f8和f9调用具有两个参数的构造函数。
在这种情况下,f5和f6将print foo(l),而f8和f9将生成编译器错误(尽管编译器可能有选项忽略一些警告,例如 GCC 的-Wno-narrowing),因为初始化列表中的所有元素都应该为整数。
可能需要注意,如果foo定义了一个接受std::initializer_list的构造函数,那么所有使用{}的初始化都将解析为该构造函数:
foo(std::initializer_list<int> l)
{ std::cout << "foo(l)" << '\n'; } 
这些可能看起来都是正确的,但隐式转换构造函数允许出现隐式转换可能不是我们想要的情况。首先,让我们看看一些正确的例子:
void bar(foo const f)
{
}
bar({});             // foo()
bar(1);              // foo(a)
bar({ 1, 2.0 });     // foo(a, b) 
foo类到bool的转换运算符也使我们能够在期望布尔值的地方使用foo对象。以下是一个例子:
bool flag = f1;                // OK, expect bool conversion
if(f2) { /* do something */ }  // OK, expect bool conversion
std::cout << f3 + f4 << '\n';  // wrong, expect foo addition
if(f5 == f6) { /* do more */ } // wrong, expect comparing foos 
前两个例子是foo被期望用作布尔值的例子。然而,最后两个,一个用于加法和一个用于测试相等性,可能是不正确的,因为我们最可能期望添加foo对象并测试foo对象是否相等,而不是它们隐式转换成的布尔值。
可能一个更现实的例子来理解可能出现问题的场景是考虑一个字符串缓冲区实现。这将是一个包含字符内部缓冲区的类。
本类提供了几个转换构造函数:一个默认构造函数,一个接受一个表示预分配缓冲区大小的size_t参数的构造函数,以及一个接受char指针的构造函数,该指针应用于分配和初始化内部缓冲区。简而言之,我们用于本例的字符串缓冲区实现看起来如下:
class string_buffer
{
public:
  string_buffer() {}
  string_buffer(size_t const size) { data.resize(size); }
  string_buffer(char const * const ptr) : data(ptr) {}
  size_t size() const { return data.size(); }
  operator bool() const { return !data.empty(); }
  operator char const * () const { return data.c_str(); }
private:
   std::string data;
}; 
根据这个定义,我们可以构造以下对象:
std::shared_ptr<char> str;
string_buffer b1;            // calls string_buffer()
string_buffer b2(20);        // calls string_buffer(size_t const)
string_buffer b3(str.get()); // calls string_buffer(char const*) 
对象b1使用默认构造函数创建,因此具有空缓冲区;b2使用单参数构造函数进行初始化,其中参数的值表示内部缓冲区的字符大小;b3使用现有的缓冲区进行初始化,该缓冲区用于定义内部缓冲区的大小并将值复制到内部缓冲区。然而,相同的定义也允许以下对象定义:
enum ItemSizes {DefaultHeight, Large, MaxSize};
string_buffer b4 = 'a';
string_buffer b5 = MaxSize; 
在这种情况下,b4使用一个char进行初始化。由于存在到size_t的隐式转换,将调用单参数的构造函数。这里的意图不一定清楚;也许它应该是"a"而不是'a',在这种情况下,将调用第三个构造函数。
然而,b5很可能是错误,因为MaxSize是一个表示ItemSizes的枚举器,应该与字符串缓冲区大小无关。这些错误情况在编译器中没有任何标记。未限定的枚举到int的隐式转换是倾向于使用限定的枚举(使用enum class声明的)的一个很好的论据,因为它们没有这种隐式转换。如果ItemSizes是一个限定的枚举,那么这里描述的情况就不会出现。
当在构造函数的声明中使用 explicit 指定时,该构造函数成为显式构造函数,不再允许对 class 类型的对象进行隐式构造。为了说明这一点,我们将稍微修改 string_buffer 类以声明所有构造函数为 explicit:
class string_buffer
{
public:
  explicit string_buffer() {}
  explicit string_buffer(size_t const size) { data.resize(size); }
  explicit string_buffer(char const * const ptr) :data(ptr) {}
  size_t size() const { return data.size(); }
  explicit operator bool() const { return !data.empty(); }
  explicit operator char const * () const { return data.c_str(); }
private:
   std::string data;
}; 
这里的变化很小,但之前示例中 b4 和 b5 的定义不再有效且是错误的。这是因为重载解析期间不再可用从 char 或 int 到 size_t 的隐式转换来确定应该调用哪个构造函数。结果是 b4 和 b5 都会出现编译错误。请注意,即使构造函数是显式的,b1、b2 和 b3 仍然是有效的定义。
在这种情况下,解决问题的唯一方法是从 char 或 int 显式转换为 string_buffer:
string_buffer b4 = string_buffer('a');
string_buffer b5 = static_cast<string_buffer>(MaxSize);
string_buffer b6 = string_buffer{ "a" }; 
使用显式构造函数,编译器能够立即标记出错误情况,开发者可以相应地做出反应,要么使用正确的值修复初始化,要么提供显式转换。
这仅在用复制初始化进行初始化时才成立,而不是在使用函数式或通用初始化时。
以下定义仍然可能(但错误)使用显式构造函数:
string_buffer b7{ 'a' };
string_buffer b8('a'); 
与构造函数类似,转换运算符可以被声明为显式(如前所述)。在这种情况下,从对象类型到转换运算符指定的类型的隐式转换不再可能,需要显式转换。考虑到 b1 和 b2,它们是我们之前定义的 string_buffer 对象,以下使用显式 operator bool 转换将不再可能:
std::cout << b4 + b5 << '\n'; // error
if(b4 == b5) {}               // error 
相反,它们需要显式转换为 bool:
std::cout << static_cast<bool>(b4) + static_cast<bool>(b5);
if(static_cast<bool>(b4) == static_cast<bool>(b5)) {} 
两个 bool 值相加没有太多意义。前面的示例仅用于说明为了使语句编译,需要显式转换。当没有显式静态转换时,编译器发出的错误可以帮助你确定表达式本身是错误的,可能原本意图是其他内容。
参见
- 理解统一初始化,以了解花括号初始化是如何工作的
使用无名命名空间而不是静态全局变量
程序越大,当你的程序链接到多个翻译单元时遇到名称冲突的可能性就越大。在源文件中声明的函数或变量,目的是在翻译单元内部局部使用,可能与另一个翻译单元中声明的其他类似函数或变量冲突。
这是因为所有未声明为静态的符号都具有外部链接,并且它们的名称必须在整个程序中是唯一的。C 语言解决这个问题的典型方法是将这些符号声明为静态,将它们的链接从外部更改为内部,因此使它们成为翻译单元的本地符号。另一种选择是在名称前加上它们所属的模块或库的名称。在本菜谱中,我们将探讨 C++解决这个问题的方法。
准备工作
在这个菜谱中,我们将讨论诸如全局函数和静态函数、变量、命名空间和翻译单元等概念。我们期望你已经对这些概念有基本的了解。除此之外,你还必须理解内部链接和外部链接之间的区别;这对于本菜谱至关重要。
如何实现...
当你处于需要将全局符号声明为静态以避免链接问题的情境时,你应该优先使用无名的命名空间:
- 
在你的源文件中声明一个无名的命名空间。 
- 
将全局函数或变量的定义放在无名的命名空间中,而不将其声明为 static。
以下示例展示了在两个不同的翻译单元中调用名为 print() 的两个函数;每个函数都在一个无名的命名空间中定义:
// file1.cpp
namespace
{
  void print(std::string const & message)
 {
    std::cout << "[file1] " << message << '\n';
  }
}
void file1_run()
{
  print("run");
}
// file2.cpp
namespace
{
  void print(std::string const & message)
 {
    std::cout << "[file2] " << message << '\n';
  }
}
void file2_run()
{
  print("run");
} 
它是如何工作的...
当一个函数在翻译单元中声明时,它具有外部链接。这意味着来自两个不同翻译单元的两个具有相同名称的函数将生成链接错误,因为不可能有两个具有相同名称的符号。在 C 语言中解决这个问题,有时在 C++中也是如此,是将函数或变量声明为静态,并将它的链接从外部更改为内部。在这种情况下,它的名称不再导出至翻译单元之外,从而避免了链接问题。
在 C++中,正确的解决方案是使用无名的命名空间。当你定义一个类似于前面展示的命名空间时,编译器将其转换成以下形式:
// file1.cpp
namespace _unique_name_ {}
using namespace _unique_name_;
namespace _unique_name_
{
  void print(std::string message)
 {
    std::cout << "[file1] " << message << '\n';
  }
}
void file1_run()
{
  print("run");
} 
首先,它声明了一个具有唯一名称的命名空间(名称是什么以及它是如何生成该名称的是编译器实现细节,不应成为关注点)。在这个时候,命名空间是空的,这一行的目的是基本建立命名空间。其次,一个 using 指令将 _unique_name_ 命名空间中的所有内容引入当前命名空间。第三,具有编译器生成的名称的命名空间被定义为它原始源代码中的样子(当它没有名称时)。
通过在无名的命名空间中定义翻译单元本地的 print() 函数,它们只有本地可见性,但它们的链接外部性不再产生链接错误,因为它们现在具有外部唯一名称。
无名命名空间在涉及模板的某些更不明显的情况下也有效。在 C++11 之前,模板的非类型参数不能具有内部链接的名称,因此使用静态变量是不可能的。相反,无名命名空间中的符号具有外部链接,可以用作模板参数。尽管模板非类型参数的这种链接限制在 C++11 中被取消,但在最新的 VC++编译器版本中仍然存在。以下示例展示了这个问题:
template <int const& Size>
class test {};
static int Size1 = 10;
namespace
{
  int Size2 = 10;
}
test<Size1> t1;
test<Size2> t2; 
t1 variable produces a compiler error because the non-type argument expression, Size1, has internal linkage. Conversely, the declaration of the t2 variable is correct because Size2 has an external linkage. (Note that compiling this snippet with Clang and GCC does not produce an error.)
参见
- 使用内联命名空间进行符号版本化,了解如何使用内联命名空间和条件编译来对源代码进行版本控制
使用内联命名空间进行符号版本化
C++11 标准引入了一种新的命名空间类型,称为内联命名空间,它基本上是一种机制,使得嵌套命名空间中的声明看起来和表现得像它们是周围命名空间的一部分。内联命名空间使用命名空间声明中的inline关键字来声明(无名命名空间也可以内联)。这是一个有助于库版本化的特性,在本食谱中,我们将学习如何使用内联命名空间进行符号版本化。通过本食谱,你将学习如何使用内联命名空间和条件编译来对源代码进行版本控制。
准备工作
在本食谱中,我们将讨论命名空间和嵌套命名空间、模板和模板特化,以及使用预处理器宏进行条件编译。为了继续本食谱,对这些概念的了解是必要的。
如何做到这一点...
为了提供库的多个版本并让用户决定使用哪个版本,请执行以下操作:
- 
在命名空间内定义库的内容。 
- 
在内部内联命名空间内定义库的每个版本或其部分。 
- 
使用预处理器宏和 #if指令来启用库的特定版本。
以下示例展示了一个库有两个版本,客户端可以使用:
namespace modernlib
{
  #ifndef LIB_VERSION_2
inline namespace version_1
  {
    template<typename T>
 int test(T value) { return 1; }
  }
  #endif
#ifdef LIB_VERSION_2
inline namespace version_2
  {
    template<typename T>
 int test(T value) { return 2; }
  }
  #endif
} 
它是如何工作的...
内联命名空间的一个成员被视为周围命名空间的一个成员。这样的成员可以是部分特化的、显式实例化的或显式特化的。这是一个传递属性,这意味着如果命名空间A包含一个内联命名空间B,而B又包含一个内联命名空间C,那么C的成员将作为B和A的成员出现,而B的成员将作为A的成员出现。
为了更好地理解内联命名空间为什么有用,让我们考虑一个案例,即开发一个随着时间的推移从第一个版本到第二个版本(以及更进一步的)演变的库。这个库在其名为modernlib的命名空间下定义了所有其类型和函数。在第一个版本中,这个库可能看起来像这样:
namespace modernlib
{
  template<typename T>
 int test(T value) { return 1; }
} 
库的客户端可以执行以下调用并返回值1:
auto x = modernlib::test(42); 
然而,客户端可能会决定如下特化模板函数 test():
struct foo { int a; };
namespace modernlib
{
  template<>
  int test(foo value) { return value.a; }
}
auto y = modernlib::test(foo{ 42 }); 
在这种情况下,y 的值不再是 1,而是 42,因为调用了用户特定的函数。
到目前为止,一切正常,但作为库的开发者,你决定创建库的第二个版本,同时仍然提供第一个和第二个版本,并让用户通过宏来控制使用哪个版本。在这个第二个版本中,你提供了一个新的 test() 函数实现,它不再返回 1,而是返回 2。
为了能够提供第一个和第二个实现,你将它们放在名为 version_1 和 version_2 的嵌套命名空间中,并使用预处理器宏条件编译库:
namespace modernlib
{
  namespace version_1
  {
    template<typename T>
 int test(T value) { return 1; }
  }
  #ifndef LIB_VERSION_2
using namespace version_1;
  #endif
namespace version_2
  {
    template<typename T>
 int test(T value) { return 2; }
  }
  #ifdef LIB_VERSION_2
using namespace version_2;
  #endif
} 
突然之间,客户端代码崩溃了,无论它使用库的第一个版本还是第二个版本。这是因为测试函数现在位于嵌套命名空间内部,而 foo 的特化是在 modernlib 命名空间中完成的,而实际上它应该在 modernlib::version_1 或 modernlib::version_2 中完成。这是因为模板的特化必须在声明模板的同一命名空间中完成。
在这种情况下,客户端需要更改代码,如下所示:
#define LIB_VERSION_2
#include "modernlib.h"
struct foo { int a; };
namespace modernlib
{
  namespace version_2
  {
    template<>
    int test(foo value) { return value.a; }
  }
} 
这是一个问题,因为库泄露了实现细节,客户端需要了解这些细节才能进行模板特化。这些内部细节在 如何做... 部分的示例中以内联命名空间的方式被隐藏起来。根据对 modernlib 库的定义,具有在 modernlib 命名空间中特化 test() 函数的客户端代码不再崩溃,因为 version_1::test() 或 version_2::test()(取决于客户端实际使用的版本)在模板特化时表现得像它是封装的 modernlib 命名空间的一部分。现在,实现细节对客户端是隐藏的,客户端只能看到周围的命名空间 modernlib。
然而,你应该记住,命名空间 std 是为标准保留的,永远不应该内联。此外,如果一个命名空间在其第一次定义时不是内联的,那么它也不应该内联定义。
参见
- 
使用未命名的命名空间而不是静态全局变量,探索匿名命名空间并了解它们如何帮助 
- 
第四章,条件编译源代码,了解执行条件编译的各种选项 
使用结构化绑定来处理多返回值
从函数中返回多个值是非常常见的,但在 C++ 中没有第一类解决方案可以使其以简单的方式实现。开发者必须在通过函数的引用参数返回多个值、定义一个包含多个值的结构或返回 std::pair 或 std::tuple 之间进行选择。前两种使用命名变量,这给了它们一个优势,即它们可以清楚地指示返回值的含义,但缺点是它们必须被显式定义。std::pair 的成员称为 first 和 second,而 std::tuple 有未命名的成员,只能通过函数调用检索,但可以使用 std::tie() 复制到命名变量。这些解决方案都不是理想的。
C++17 将 std::tie() 的语义使用扩展为第一类核心语言特性,该特性允许将元组的值解包到命名变量中。这个特性被称为 结构化绑定。
准备工作
对于这个菜谱,你应该熟悉标准实用类型 std::pair 和 std::tuple 以及实用函数 std::tie()。
如何做到...
要使用支持 C++17 的编译器从函数中返回多个值,你应该做以下操作:
- 
使用 std::tuple作为返回类型:std::tuple<int, std::string, double> find() { return {1, "marius", 1234.5}; }
- 
使用结构化绑定将元组的值解包到命名对象中: auto [id, name, score] = find();
- 
使用结构绑定将返回的值绑定到 if语句或switch语句内部的变量:if (auto [id, name, score] = find(); score > 1000) { std::cout << name << '\n'; }
它是如何工作的...
结构化绑定(有时被称为 分解声明)是一种语言特性,它的工作方式与 std::tie() 类似,除了我们不需要为每个需要使用 std::tie() 显式解包的值定义命名变量。使用结构绑定,我们使用 auto 说明符在单个定义中定义所有命名变量,以便编译器可以推断每个变量的正确类型。
为了举例说明,让我们考虑将项目插入到 std::map 的情况。insert 方法返回一个 std::pair,包含插入元素或阻止插入的元素的迭代器,以及一个布尔值,指示插入是否成功。以下代码非常明确,使用 second 或 first->second 使得代码更难阅读,因为你需要不断弄清楚它们代表什么:
std::map<int, std::string> m;
auto result = m.insert({ 1, "one" });
std::cout << "inserted = " << result.second << '\n'
          << "value = " << result.first->second << '\n'; 
之前的代码可以通过使用 std::tie 来提高可读性,它将元组解包成单个对象(并且与 std::pair 一起工作,因为 std::tuple 从 std::pair 有转换赋值):
std::map<int, std::string> m;
std::map<int, std::string>::iterator it;
bool inserted;
std::tie(it, inserted) = m.insert({ 1, "one" });
std::cout << "inserted = " << inserted << '\n'
          << "value = " << it->second << '\n';
std::tie(it, inserted) = m.insert({ 1, "two" });
std::cout << "inserted = " << inserted << '\n'
          << "value = " << it->second << '\n'; 
代码不一定更简单,因为它需要提前定义对偶解包到的对象。同样,元组包含的元素越多,你需要定义的对象就越多,但使用命名对象可以使代码更容易阅读。
C++17 结构化绑定将解包元组元素到命名对象提升为语言特性的级别;不需要使用 std::tie(),并且对象在声明时被初始化:
std::map<int, std::string> m;
{
  auto [it, inserted] = m.insert({ 1, "one" });
  std::cout << "inserted = " << inserted << '\n'
            << "value = " << it->second << '\n';
}
{
  auto [it, inserted] = m.insert({ 1, "two" });
  std::cout << "inserted = " << inserted << '\n'
            << "value = " << it->second << '\n';
} 
在前面的例子中使用多个块是必要的,因为变量不能在同一个块中重新声明,而结构化绑定意味着使用 auto 指示符进行声明。因此,如果您需要多次调用,如前面的示例所示,并使用结构化绑定,您必须使用不同的变量名或多个块。另一个选择是避免使用结构化绑定并使用 std::tie(),因为它可以用相同的变量多次调用,因此您只需声明一次。
在 C++17 中,也可以分别以 if(init; condition) 和 switch(init; condition) 的形式在 if 和 switch 语句中声明变量。这可以与结构化绑定结合,以生成更简单的代码。让我们来看一个示例:
if(auto [it, inserted] = m.insert({ 1, "two" }); inserted)
{ std::cout << it->second << '\n'; } 
it and inserted, defined in the scope of the if statement in the initialization part. Then, the condition of the if statement is evaluated from the value of the inserted variable.
还有更多...
尽管我们专注于将名称绑定到元组的元素上,但结构化绑定可以在更广泛的范围内使用,因为它们还支持绑定到数组元素或类的数据成员。如果您想绑定到数组的元素上,您必须为每个数组元素提供一个名称;否则,声明是不合法的。以下是一个绑定到数组元素的示例:
int arr[] = { 1,2 };
auto [a, b] = arr;
auto& [x, y] = arr;
arr[0] += 10;
arr[1] += 10;
std::cout << arr[0] << ' ' << arr[1] << '\n'; // 11 12
std::cout << a << ' ' << b << '\n';           // 1 2
std::cout << x << ' ' << y << '\n';           // 11 12 
在这个例子中,arr 是一个包含两个元素的数组。我们首先将 a 和 b 绑定到其元素上,然后将 x 和 y 引用绑定到其元素上。对数组元素所做的更改通过变量 a 和 b 是不可见的,但通过 x 和 y 引用是可见的,如注释中打印到控制台这些值的示例所示。这是因为当我们进行第一次绑定时,会创建数组的副本,a 和 b 被绑定到副本的元素上。
正如我们之前提到的,也可以绑定到类的数据成员上。以下有一些限制:
- 
绑定仅适用于类的非静态成员。 
- 
类不能有匿名联合成员。 
- 
标识符的数量必须与类的非静态成员的数量匹配。 
标识符的绑定按照数据成员声明的顺序进行,这可以包括位域。以下是一个示例:
struct foo
{
   int         id;
   std::string name;
};
foo f{ 42, "john" };
auto [i, n] = f;
auto& [ri, rn] = f;
f.id = 43;
std::cout << f.id << ' ' << f.name << '\n';   // 43 john
std::cout << i <<'''' << n <<''\'';           // 42 john
std::cout << ri <<'''' << rn <<''\'';         // 43 john 
再次,对 foo 对象的更改对变量 i 和 n 是不可见的,但对 ri 和 rn 是可见的。这是因为结构绑定中的每个标识符都成为指向类数据成员(就像数组一样,它指向数组的元素)的 lvalue 的名称。然而,标识符的引用类型是对应的数据成员(或数组元素)。
新的 C++20 标准引入了一系列对结构化绑定的改进,包括以下内容:
- 
在结构绑定声明中包含 static或thread_local存储类指定符的可能性。
- 
使用 [[maybe_unused]]属性声明结构化绑定。一些编译器,如 Clang 和 GCC,已经支持此功能。
- 
在 lambda 中捕获结构绑定标识符的可能性。所有标识符,包括绑定到位字段的标识符,都可以按值捕获。相反,除了绑定到位字段的标识符之外的所有标识符也可以按引用捕获。 
这些更改使我们能够编写以下内容:
foo f{ 42,"john" };
auto [i, n] = f;
auto l1 = [i] {std::cout << i; };
auto l2 = [=] {std::cout << i; };
auto l3 = [&i] {std::cout << i; };
auto l4 = [&] {std::cout << i; }; 
这些示例展示了在 C++20 中结构化绑定可以以各种方式在 lambda 中捕获的各种方法。
有时,我们需要绑定我们不使用的变量。在 C++26 中,将可以使用下划线(_)而不是名称来忽略一个变量。尽管在撰写本文时没有任何编译器支持此功能,但该功能已被包含在 C++26 中。
foo f{ 42,"john" };
auto [_, n] = f; 
在这里,_ 是一个占位符,用于绑定到 foo 对象的 id 成员。它用于表示此值在此上下文中未使用且将被忽略。
使用 _ 占位符不仅限于结构化绑定。它可以用作非静态类成员、结构化绑定和 lambda 捕获的标识符。您可以使用下划线重新定义同一作用域中已存在的声明,因此可以忽略多个变量。然而,如果变量名为 _ 在重新声明之后使用,则程序被认为是格式不正确的。
参见
- 
尽可能使用 auto,了解 C++中自动类型推导的工作原理 
- 
第三章,使用标准算法中的 lambda,了解 lambda 如何与标准库通用算法一起使用 
- 
第四章,使用属性向编译器提供元数据,了解如何使用标准属性向编译器提供提示 
使用类模板参数推导简化代码
模板在 C++中无处不在,但总是需要指定模板参数可能会很烦人。有些情况下,编译器实际上可以从上下文中推断模板参数。此功能在 C++17 中可用,称为类模板参数推导,它使编译器能够从初始化器的类型推断缺失的模板参数。在本食谱中,我们将学习如何利用此功能。
如何做到...
在 C++17 中,您可以在以下情况下省略指定模板参数,让编译器推断它们:
- 
当您声明一个变量或变量模板并对其进行初始化时: std::pair p{ 42, "demo" }; // deduces std::pair<int, char const*> std::vector v{ 1, 2 }; // deduces std::vector<int> std::less l; // deduces std::less<void>
- 
当您使用 new 表达式创建对象时: template <class T> struct foo { foo(T v) :data(v) {} private: T data; }; auto f = new foo(42);
- 
当您执行函数式类型转换表达式时: std::mutex mx; // deduces std::lock_guard<std::mutex> auto lock = std::lock_guard(mx); std::vector<int> v; // deduces std::back_insert_iterator<std::vector<int>> std::fill_n(std::back_insert_iterator(v), 5, 42);
它是如何工作的...
在 C++17 之前,您必须在初始化变量时指定所有模板参数,因为所有这些都必须已知才能实例化类模板,例如以下示例:
std::pair<int, char const*> p{ 42, "demo" };
std::vector<int>            v{ 1, 2 };
foo<int>                    f{ 42 }; 
使用函数模板,例如std::make_pair(),可以避免显式指定模板参数的问题,它受益于函数模板参数推导,并允许我们编写如下代码:
auto p = std::make_pair(42, "demo"); 
在这里展示的foo类模板的情况下,我们可以编写以下make_foo()函数模板来启用相同的行为:
template <typename T>
constexpr foo<T> make_foo(T&& value)
{
   return foo{ value };
}
auto f = make_foo(42); 
在 C++17 中,在如何工作...部分列出的情况下,这不再必要。以下是一个示例声明:
std::pair p{ 42, "demo" }; 
在这个上下文中,std::pair不是一个类型,但它作为一个类型占位符,激活了类模板参数推导。当编译器在声明带有初始化或函数式转换的变量时遇到它,它将构建一个推导指南集。这些推导指南是假设类类型的虚构构造函数。
作为用户,你可以通过用户定义的推导规则来补充这个集合。这个集合用于执行模板参数推导和重载解析。
在std::pair的情况下,编译器将构建一个包含以下虚构函数模板的推导指南集(但不仅限于此):
template <class T1, class T2>
std::pair<T1, T2> F();
template <class T1, class T2>
std::pair<T1, T2> F(T1 const& x, T2 const& y);
template <class T1, class T2, class U1, class U2>
std::pair<T1, T2> F(U1&& x, U2&& y); 
这些由编译器生成的推导指南是从类模板的构造函数中创建的,如果没有提供,则创建一个假设默认构造函数的推导指南。此外,在所有情况下,都会创建一个假设复制构造函数的推导指南。
用户定义的推导指南是具有尾随返回类型且不带auto关键字的函数签名(因为它们代表没有返回值的假设构造函数)。它们必须在应用于该类模板的命名空间中定义。
为了理解它是如何工作的,让我们考虑与std::pair对象相同的示例:
std::pair p{ 42, "demo" }; 
编译器推导出的类型是std::pair<int, char const*>。如果我们想指示编译器推导出std::string而不是char const*,那么我们需要几个用户定义的推导规则,如下所示:
namespace std {
   template <class T>
   pair(T&&, char const*)->pair<T, std::string>;
   template <class T>
   pair(char const*, T&&)->pair<std::string, T>;
   pair(char const*, char const*)->pair<std::string, std::string>;
} 
这些将使我们能够执行以下声明,其中字符串"demo"的类型始终推导为std::string:
std::pair  p1{ 42, "demo" };    // std::pair<int, std::string>
std::pair  p2{ "demo", 42 };    // std::pair<std::string, int>
std::pair  p3{ "42", "demo" };  // std::pair<std::string, std::string> 
如此示例所示,推导指南不必是函数模板。
重要的一点是,如果存在模板参数列表,则不会发生类模板参数推导,无论指定了多少个参数。以下是一些示例:
std::pair<>    p1 { 42, "demo" };
std::pair<int> p2 { 42, "demo" }; 
由于这两个声明都指定了模板参数列表,它们是无效的,并产生编译器错误。
有一些已知的情况,其中类模板参数推导不起作用:
- 
聚合模板,其中你可以编写用户定义的推导指南来规避这个问题。 template<class T> struct Point3D { T x; T y; T z; }; Point3D p{1, 2, 2}; // error, requires Point3D<int>
- 
类型别名,如下面的示例所示(对于 GCC,在编译时使用 -std=c++20实际上可以工作):template <typename T> using my_vector = std::vector<T>; std::vector v{1,2,3}; // OK my_vector mv{1,2,3}; // error
- 
继承构造函数,因为推导指南(无论是隐式还是用户定义的)在继承构造函数时不会被继承: template <typename T> struct box { box(T&& t) : content(std::forward<T>(t)) {} virtual void unwrap() { std::cout << "unwrapping " << content << '\n'; } T content; }; template <typename T> struct magic_box : public box<T> { using box<T>::box; virtual void unwrap() override { std::cout << "unwrapping " << box<T>::content << '\n'; } }; int main() { box b(42); // OK b.unwrap(); magic_box m(21); // error, requires magic_box<int> m.unwrap(); }
这种限制在 C++23 中被移除,因为在继承构造函数时,推导指南也会被继承。
参见
- 理解统一初始化,以了解花括号初始化是如何工作的
使用下标运算符访问集合中的元素
访问数组元素是 C++以及任何支持数组的编程语言的基本功能。语法在许多编程语言中也是相同的。在 C++中,用于此目的的下标运算符[]可以被重载以提供对类中数据的访问。通常,这是对容器进行建模的类的情况。在本食谱中,我们将看到如何利用此运算符以及 C++23 带来了哪些变化。
如何做到…
为了提供对容器中元素的随机访问,以下是如何重载下标运算符:
- 
对于一维容器,你可以使用一个参数重载下标运算符,无论标准版本如何: template <typename T> struct some_buffer { some_buffer(size_t const size):data(size) {} size_t size() const { return data.size(); } T const& operator[](size_t const index) const { if(index >= data.size()) std::runtime_error("invalid index"); return data[index]; } T & operator[](size_t const index) { if (index >= data.size()) std::runtime_error("invalid index"); return data[index]; } private: std::vector<T> data; };
- 
对于多维容器,在 C++23 中,你可以使用多个参数重载下标运算符: template <typename T, size_t ROWS, size_t COLS> struct matrix { T& operator[](size_t const row, size_t const col) { if(row >= ROWS || col >= COLS) throw std::runtime_error("invalid index"); return data[row * COLS + col]; } T const & operator[](size_t const row, size_t const col) const { if (row >= ROWS || col >= COLS) throw std::runtime_error("invalid index"); return data[row * COLS + col]; } private: std::array<T, ROWS* COLS> data; };
它是如何工作的…
下标运算符用于访问数组中的元素。然而,它也可以作为类中通常建模容器(或一般集合)的成员函数重载,以访问其元素。标准容器如std::vector、std::set和std::map为此目的提供了下标运算符的重载。因此,你可以编写如下代码:
std::vector<int> v {1, 2, 3};
v[2] = v[1] + v[0]; 
在上一节中,我们看到了下标运算符可以如何重载。通常有两种重载方式,一种是常量重载,另一种是可变重载。常量重载返回一个指向常量对象的引用,而可变重载返回一个引用。
下标运算符的主要问题是,在 C++23 之前,它只能有一个参数。因此,它不能用来提供对多维容器元素的访问。因此,开发者通常求助于使用调用运算符来达到这个目的。以下是一个示例片段:
template <typename T, size_t ROWS, size_t COLS>
struct matrix
{
   T& operator()(size_t const row, size_t const col)
 {
      if(row >= ROWS || col >= COLS)
         throw std::runtime_error("invalid index");
      return data[row * COLS + col];
   }
   T const & operator()(size_t const row, size_t const col) const
 {
      if (row >= ROWS || col >= COLS)
         throw std::runtime_error("invalid index");
      return data[row * COLS + col];
   }
private:
   std::array<T, ROWS* COLS> data;
};
matrix<int, 2, 3> m;
m(0, 0) = 1; 
为了帮助解决这个问题,并允许更一致的方法,C++11 使得可以使用下标运算符的语法[{expr1, expr2, …}]。下面展示了一个利用此语法的matrix类的修改实现:
template <typename T, size_t ROWS, size_t COLS>
struct matrix
{
   T& operator[](std::initializer_list<size_t> index)
   {
      size_t row = *index.begin();
      size_t col = *(index.begin() + 1);
      if (row >= ROWS || col >= COLS)
         throw std::runtime_error("invalid index");
      return data[row * COLS + col];
   }
   T const & operator[](std::initializer_list<size_t> index) const
   {
      size_t row = *index.begin();
      size_t col = *(index.begin() + 1);
      if (row >= ROWS || col >= COLS)
         throw std::runtime_error("invalid index");
      return data[row * COLS + col];
   }
private:
   std::array<T, ROWS* COLS> data;
};
matrix<int, 2, 3> m;
m[{0, 0}] = 1; 
然而,语法相当繁琐,在实践中可能很少使用。因此,C++23 标准使得使用多个参数重载下标运算符成为可能。这里展示了一个修改后的matrix类:
template <typename T, size_t ROWS, size_t COLS>
struct matrix
{
   T& operator[](size_t const row, size_t const col)
   {
      if(row >= ROWS || col >= COLS)
         throw std::runtime_error("invalid index");
      return data[row * COLS + col];
   }
   T const & operator[](size_t const row, size_t const col) const
   {
      if (row >= ROWS || col >= COLS)
         throw std::runtime_error("invalid index");
      return data[row * COLS + col];
   }
private:
   std::array<T, ROWS* COLS> data;
};
matrix<int, 2, 3> m;
m[0, 0] = 1; 
这使得调用语法与访问一维容器保持一致。std::mdspan 使用它来提供元素访问。这是一个新的 C++23 类,它表示对连续序列(如数组)的非拥有视图,但它将序列重新解释为多维数组。
之前显示的 matrix 类实际上可以用数组上的 mdspan 视图替换,如下面的代码片段所示:
int data[2*3] = {};
auto m = std::mdspan<int, std::extents<2, 3>> (data);
m[0, 0] = 1; 
参见
- 
第五章,编写自己的随机访问迭代器,了解您如何编写用于访问容器元素的迭代器 
- 
第六章,使用 std::mdspan 对对象序列进行多维视图,了解 std::mdspan类的更多信息
在 Discord 上了解更多
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
discord.gg/7xRaTCeEhx

第二章:处理数字和字符串
数字和字符串是任何编程语言的基本类型;所有其他类型都是基于或由这些类型构成的。开发者经常面临诸如在数字和字符串之间转换、解析和格式化字符串、生成随机数等任务。本章专注于提供使用现代 C++ 语言和库功能解决这些常见任务的有用配方。
本章包含的配方如下:
- 
理解各种数值类型 
- 
数值类型的限制和其他属性 
- 
在数值类型和字符串类型之间进行转换 
- 
理解各种字符和字符串类型 
- 
将 Unicode 字符打印到输出控制台 
- 
生成伪随机数 
- 
正确初始化伪随机数生成器 
- 
创建熟成的用户定义文字 
- 
创建原始用户定义文字 
- 
使用原始字符串文字避免转义字符 
- 
创建字符串辅助库 
- 
使用正则表达式验证字符串的格式 
- 
使用正则表达式解析字符串内容 
- 
使用正则表达式替换字符串内容 
- 
使用 std::string_view而不是常量字符串引用
- 
使用 std::format和std::print格式化和打印文本
- 
使用 std::format与用户定义类型
让我们从查看 C++ 编程语言中存在的不同数值类型开始这一章。
理解各种数值类型
C++ 编程语言定义了大量的算术类型;这些类型是可以在其上执行算术运算(加法、减法、乘法、除法、取模)的类型。这个类别包括字符、整数和浮点类型。许多这些类型是从 C 编程语言继承的,而有些是在标准最近版本中添加到 C++ 中的。算术类型的一个典型问题是,与许多其他编程语言不同,它们中的大多数没有固定的大小。大小随目标平台而变化,标准只保证最小值。在本配方中,我们将了解各种整数和浮点类型。
如何做到这一点...
根据需要表示的值类型,使用可用的数值类型之一:
- 
要表示一个整数值(当范围不是特别重要时),使用 int类型。这是默认的基本(有符号)整数类型,通常大小为 32 位,但不保证。你可以用它表示诸如人的年龄、日期中的日、月、年、电影或书籍的评分、集合中的项目数量以及无数其他事物:int age = 42; int attendance = 96321;
- 
当你需要对可能值的范围或内存表示施加限制时,请使用有符号性( signed/unsigned)和大小(short/long/long long)修饰符。例如,你可能想使用无符号整数来表示不能有负值的值。应避免混合有符号和无符号整数。另一方面,你可能想优化存储某些值的内存使用,例如表示日期的值,在这种情况下,你可以使用至少 16 位的short int。如果你需要表示大值,例如文件的大小,你可以使用unsigned long long int,它保证至少有 64 位:unsigned int length = 32; short year = 2023; // same as short int unsigned long long filesize = 3'758'096'384;
- 
要表示 std::array索引(不能是负数)、集合(如标准容器)中的元素数量或sizeof运算符的结果,请使用std::size_t。这是一个至少 16 位的无符号整数类型。标准容器定义了一个成员类型别名size_type用于大小和容器的索引,这个类型通常与std::size_t同义:std::size_t items = arr.size();
- 
要存储指针算术运算的结果,或表示类似于 C 语言的数组索引(可以是负数),请使用 std::ptrdiff_t。C++标准容器定义了一个成员类型别名difference_type来存储迭代器之间的差异,这通常被定义为std::ptrdiff_t的同义词。
- 
当你需要存储一个需要保证范围的值时,请使用 std::int8_t、std::int16_t、std::int32_t或std::int64_t类型之一。尽管这些是可选的,但它们在所有现代架构上都是定义好的。
- 
当你需要存储非负值或对保证范围内的值执行位操作时,请使用 std::uint8_t、std::uint16_t、std::uint32_t或std::uint64_t类型之一。
- 
当你需要存储一个需要保证范围且同时希望优化访问速度的值时,请使用 std::int_fast8_t、std::int_fast16_t、std::int_fast32_t或std::int_fast64_t类型(或它们的无符号对应类型)。这些类型在所有目标架构上都是可用的。
- 
当你需要存储一个需要保证范围且同时希望优化内存使用的值时,请使用 std::int_least8_t、std::int_least16_t、std::int_least32_t或std::int_least64_t(或它们的无符号对应类型)。这些类型在所有目标架构上都是可用的。
- 
要表示实数,请使用类型 double。这是默认的浮点类型,大小为 64 位。名称表明这是一个双精度类型,与单精度类型(使用 32 位)相对,后者通过float类型实现。还有一个扩展精度类型,称为long double。标准没有指定其实际精度,但要求它至少与double类型相同。在某些编译器中,这可以是四倍精度(使用 128 位),尽管一些编译器,如 VC++,将其视为等于double:double price = 4.99; float temperature = 36.5; long double pi = 3.14159265358979323846264338327950288419716939937510L;
它是如何工作的…
C++ 语言有一个基本整型 int,以及可以应用于它的几个修饰符,用于表示符号和大小。类型 int 是一个有符号类型,因此 int 和 signed int 是同一类型。使用 int 实际上是可选的,当使用修饰符时。因此,以下类型是等效的:
| 类型 | 等效类型 | 
|---|---|
| signed | int | 
| unsigned | unsigned int | 
| short | short int | 
| signed short | short int | 
| signed short int | short int | 
| long | long int | 
| long long | long long int | 
| unsigned short | unsigned short int | 
| unsigned long | unsigned long int | 
| unsigned long long | unsigned long long int | 
表 2.1:整型等效
此表未列出所有可能的组合,仅列出几个示例。类型修饰符的顺序未指定;因此,任何顺序都是允许的。以下表列出了表示相同类型的几个类型:
| 类型 | 等效类型 | 
|---|---|
| long long unsigned int | unsigned long long int | 
| long unsigned long int | |
| int long long unsigned | |
| unsigned long long int | |
| int long unsigned long | 
表 2.2:带有修饰符的整型等效
尽管顺序未定义,但通常的做法是先使用符号修饰符,然后是大小修饰符,最后是 int 类型。因此,前表中左侧列的类型规范形式是 unsigned long long int。
无论整型的符号或大小如何,都可能发生称为 溢出 或 下溢 的过程。溢出发生在尝试存储一个大于该数据类型最大值的值时。下溢发生在相反的情况下,即尝试存储一个小于该数据类型最小值的值时。
让我们考虑 short 类型的例子。这是一个可以存储 -32,768 到 32,767 范围内值的有符号整型。如果我们想存储 32,768 会发生什么?由于这个值大于最大值,会发生溢出。十进制的 32,767 在二进制中表示为 01111111 11111111,下一个值是 10000000 00000000,在 16 位表示中是十进制的 -32,768。以下表显示了溢出和下溢的情况:
| 存储的值 | 存储的值 | 
|---|---|
| -32771 | 32765 | 
| -32770 | 32766 | 
| -32769 | 32767 | 
| -32768 | -32768 | 
| … | … | 
| 32767 | 32767 | 
| 32768 | -32768 | 
| 32769 | -32767 | 
| 32770 | -32765 | 
表 2.3:短整型值溢出和下溢的示例
在以下图像中以不同的形式展示了相同的示例,您可能会觉得更容易理解:

图 2.1:短整型值溢出和下溢的示例
如果我们考虑的是 unsigned short 类型而不是 short,同样的问题会发生,尽管它们可能更容易理解。unsigned short 的范围是 0 到 65,535。尝试存储 65,536 将导致存储的值为 0。同样,尝试存储 65,537 将导致存储的值为 1。这是存储值与数据类型可以存储的值的数量之间的模运算的结果。对于 unsigned short,这是 2¹⁶ 或 62,536。对于下溢,结果以类似的方式发生。-1 变为 65,535,-2 变为 65,534,依此类推。这与将负值加到 65,536 然后执行模 65,536 运算相同。溢出和下溢在以下表中显示:
| 要存储的值 | 存储的值 | 
|---|---|
| -2 | 65534 | 
| -1 | 65535 | 
| 0 | 0 | 
| … | … | 
| 65535 | 65535 | 
| 65536 | 0 | 
| 65537 | 1 | 
表 2.4:无符号短整型溢出和下溢的示例
同样,下一个图像展示了相同的价值:

图 2.2:无符号短整型溢出和下溢的示例
C++中整型类型的一个重大问题是它们的大小没有明确规定。唯一明确定义的大小是 char 类型(及其 signed 和 unsigned 修饰符),它必须是 1。对于其余部分,以下关系适用:
1 == sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long) 
实际上,在大多数平台上,short 是 16 位的,int 和 long 都是 32 位的,而 long long 是 64 位的。然而,有些平台 long 和 long long 都是 64 位的,或者 int 是 16 位的。为了克服这种异质性,C++11 标准引入了一系列固定宽度的整型类型。这些类型在 <cstdint> 头文件中定义,并分为两类:
- 
一类可选的类型,可能在某些平台上不可用。这些类型具有它们名称指定的确切位数: - 
int8_t和uint8_t是 8 位的
- 
int16_t和uint16_t是 16 位的
- 
int32_t和uint32_t是 32 位的
- 
int64_t和uint64_t是 64 位的
- 
此外,还有 intptr_t和uintptr_t,它们的大小足以存储指向 void 的指针
 
- 
- 
一类强制类型,因此可在所有平台上使用。这些类型又分为两类: - 
一种针对快速访问进行优化的类型;这些被称为 int_fastX_t和uint_fastX_t,其中X是 8、16、32 或 64,表示位数。这些类型提供了在特定架构上访问速度最快且宽度至少为X的整型类型。
- 
一种优化内存消耗的;这些被称为 int_leastX_t和uint_leastX_t,其中X是 8、16、32 或 64,表示位数。这些类型提供了在特定架构上表示的最小整数类型,但宽度至少为X。
 
- 
在实践中,大多数编译器将 8 位类型(int8_t、uint8_t、int_least8_t、uint_least8_t、int_fast8_t 和 uint_fast8_t)视为与 signed char 和 unsigned char 相同。这意味着在不同的系统上,使用它们的程序可能与其他固定宽度整数类型的程序表现不同。以下是一个示例,以演示这一点:
std::int8_t x = 42;
std::cout << x << '\n'; // [1] prints *
std::int16_t y = 42;
std::cout << y << '\n'; // [2] prints 42 
x 和 y 都是固定宽度整数类型,并且都被分配了值 42。然而,当将它们的值打印到控制台时,x 将被打印为 * 而不是 42。但请注意,这并不是一个保证,因为行为是系统相关的。
因此,您可能想要避免使用 8 位固定宽度整数类型,并优先考虑 int16_t/uint16_t 或快速/最小变体之一。
在编写数值字面量时,您可以使用单引号(')作为数字分隔符。这使得阅读大数字变得更容易,也许可以直观地比较它们。它可以用于十进制、十六进制、八进制和二进制数字,如下面的代码片段所示:
auto a = 4'234'871'523ll;        // 4234871523
auto b = 0xBAAD'F00D;            // 3131961357
auto c = 00'12'34;               // 668
auto d = 0b1011'01011'0001'1001; // 46361 
在确定数值时,数字分隔符被忽略,因此它们的位置无关紧要。这意味着您可以在没有产生错误的情况下以没有实际意义的格式编写数字:
auto e = 1'2'3'4'5; 
参见
- 理解各种字符和字符串类型,了解不同的字符和字符串类型
数值类型的限制和其他属性
有时,有必要知道并使用数值类型(如 char、int 或 double)可以表示的最小和最大值。许多开发者使用标准 C 宏来完成此操作,例如 CHAR_MIN/CHAR_MAX、INT_MIN/INT_MAX 和 DBL_MIN/DBL_MAX。C++ 提供了一个名为 numeric_limits 的类模板,它为每个数值类型提供了特化,这使得您可以查询类型的最大和最小值。然而,numeric_limits 不仅限于该功能,还提供了用于类型属性查询的附加常量,例如类型是否为有符号,它需要多少位来表示其值,是否可以表示浮点类型的无穷大,以及其他许多内容。在 C++11 之前,numeric_limits<T> 的使用受到限制,因为它不能用于需要常量的地方(例如数组的大小和 switch 语句)。因此,开发者更喜欢在其代码中使用 C 宏。在 C++11 中,这种情况不再存在,因为 numeric_limits<T> 的所有静态成员现在都是 constexpr,这意味着它们可以在需要常量表达式的任何地方使用。
准备工作
numeric_limits<T>类模板在<limits>头文件中的std命名空间中可用。
如何做到这一点...
使用std::numeric_limits<T>查询数值类型T的各种属性:
- 
使用 min()和max()静态方法获取一个类型的最小和最大有限数值。以下是如何使用这些方法的示例:// example 1 template<typename T, typename Iter> T minimum(Iter const start, Iter const end) // finds the // minimum value // in a range { T minval = std::numeric_limits<T>::max(); for (auto i = start; i < end; ++i) { if (*i < minval) minval = *i; } return minval; } // example 2 int range[std::numeric_limits<char>::max() + 1] = { 0 }; // example 3 switch(get_value()) { case std::numeric_limits<int>::min(): // do something break; }
- 
使用其他静态方法和静态常量来检索数值类型的其他属性。在以下示例中, bits变量是一个std::bitset对象,它包含表示变量n(它是一个整数)所表示数值所需的位序列:auto n = 42; std::bitset<std::numeric_limits<decltype(n)>::digits> bits { static_cast<unsigned long long>(n) };
在 C++11 中,std::numeric_limits<T>的使用没有限制;因此,在您的现代 C++代码中,最好使用它而不是 C 宏。
它是如何工作的...
std::numeric_limits<T>类模板允许开发者查询数值类型的属性。实际值通过特化提供,标准库为所有内置数值类型(char、short、int、long、float、double等)提供了特化。此外,第三方可能为其他类型提供额外的实现。一个例子是一个实现bigint类型和decimal类型并为此类提供numeric_limits特化的数值库(例如numeric_limits<bigint>和numeric_limits<decimal>)。
<limits>头文件中提供了以下数值类型的特化。请注意,char16_t和char32_t的特化是 C++11 中新增的;其他特化之前就已经可用。除了前面列出的特化之外,库还包括这些数值类型每个cv-qualified版本的特化,并且它们与无修饰特化相同。例如,考虑类型int;有四个实际特化(它们是相同的):numeric_limits<int>、numeric_limits<const int>、numeric_limits<volatile int>和numeric_limits<const volatile int>。您可以在en.cppreference.com/w/cpp/types/numeric_limits找到特化的完整列表。
如前所述,在 C++11 中,std::numeric_limits的所有静态成员都是constexpr,这意味着它们可以在需要常量表达式的所有地方使用。这相对于 C 宏有几个主要优点:
- 
它们更容易记住,因为你只需要知道类型的名称,而这通常是你应该知道的,而不是无数宏的名称。 
- 
它们支持 C 语言中不可用的类型,例如 char16_t和char32_t。
- 
它是唯一可能的解决方案,用于不知道类型的模板。 
- 
min和max只是它提供的各种类型属性中的两种;因此,其实际用途超出了显示的数值限制。作为旁注,因此,这个类可能应该被命名为 numeric_properties,而不是numeric_limits。
以下函数模板 print_type_properties() 打印出类型的最小和最大有限值,以及其他信息:
template <typename T>
void print_type_properties()
{
  std::cout
    << "min="
    << std::numeric_limits<T>::min()        << '\n'
    << "max="
    << std::numeric_limits<T>::max()        << '\n'
    << "bits="
    << std::numeric_limits<T>::digits       << '\n'
    << "decdigits="
    << std::numeric_limits<T>::digits10     << '\n'
    << "integral="
    << std::numeric_limits<T>::is_integer   << '\n'
    << "signed="
    << std::numeric_limits<T>::is_signed    << '\n'
    << "exact="
    << std::numeric_limits<T>::is_exact     << '\n'
    << "infinity="
    << std::numeric_limits<T>::has_infinity << '\n';
} 
如果我们为 unsigned short、int 和 double 调用 print_type_properties() 函数,我们将得到以下输出:
| unsigned short | int | double | 
|---|
|
min=0
max=65535
bits=16
decdigits=4
integral=1
signed=0
exact=1
infinity=0 
|
min=-2147483648
max=2147483647
bits=31
decdigits=9
integral=1
signed=1
exact=1
infinity=0 
|
min=2.22507e-308
max=1.79769e+308
bits=53
decdigits=15
integral=0
signed=1
exact=0
infinity=1 
|
表 2.5:print_type_properties() 对于 unsigned short、int 和 double 的输出
请注意,digits 和 digits10 常量之间存在差异:
- 
digits代表整数类型(如果存在符号位则不包括)和填充位(如果有)的位数,以及浮点类型尾数的位数。
- 
digits10是一个类型可以表示而不改变的小数位数。为了更好地理解这一点,让我们考虑unsigned short的例子。这是一个 16 位的整数类型。它可以表示从 0 到 65,536 的数字。它可以表示最多五位小数的数字,即从 10,000 到 65,536,但它不能表示所有五位小数的数字,因为从 65,537 到 99,999 的数字需要更多的位。因此,它在不需要更多位的情况下可以表示的最大数字有四位小数(从 1,000 到 9,999)。这就是digits10所指示的值。对于整数类型,它与常量digits有直接关系;对于一个整数类型T,digits10的值是std::numeric_limits<T>::digits * std::log10(2)。
值得注意的是,标准库中那些是算术类型别名的类型(例如 std::size_t)也可以使用 std::numeric_limits 进行检查。另一方面,其他不是算术类型的标准类型,例如 std::complex<T> 或 std::nullptr_t,并没有 std::numeric_limits 特化。
相关内容
- 在数字和字符串类型之间转换,了解如何将数字和字符串之间进行转换
在数字和字符串类型之间转换
数字和字符串类型之间的转换是一种普遍的操作。在 C++11 之前,对将数字转换为字符串以及反向转换的支持很少,因此开发者主要不得不求助于不安全的类型函数,并且他们通常编写自己的实用函数以避免重复编写相同的代码。随着 C++11 的推出,标准库提供了在数字和字符串之间进行转换的实用函数。在本配方中,你将学习如何使用现代 C++标准函数在数字和字符串之间以及相反方向进行转换。
准备工作
本配方中提到的所有实用函数都包含在 <string> 头文件中。
如何做到这一点...
当您需要在不同数字和字符串之间进行转换时,请使用以下标准转换函数:
- 
要将整数或浮点类型转换为字符串类型,请使用 std::to_string()或std::to_wstring(),如下面的代码片段所示:auto si = std::to_string(42); // si="42" auto sl = std::to_string(42L); // sl="42" auto su = std::to_string(42u); // su="42" auto sd = std::to_wstring(42.0); // sd=L"42.000000" auto sld = std::to_wstring(42.0L); // sld=L"42.000000"
- 
要将字符串类型转换为整型类型,请使用 std::stoi()、std::stol()、std::stoll()、std::stoul()或std::stoull(),如下面的代码片段所示:auto i1 = std::stoi("42"); // i1 = 42 auto i2 = std::stoi("101010"L, nullptr, 2); // i2 = 42 auto i3 = std::stoi("052", nullptr, 8); // i3 = 42 auto i4 = std::stoi("0x2A"L, nullptr, 16); // i4 = 42
- 
要将字符串类型转换为浮点类型,请使用 std::stof()、std::stod()或std::stold(),如下面的代码片段所示:// d1 = 123.45000000000000 auto d1 = std::stod("123.45"); // d2 = 123.45000000000000 auto d2 = std::stod("1.2345e+2"); // d3 = 123.44999980926514 auto d3 = std::stod("0xF.6E6666p3");
它是如何工作的...
int of these two functions:
std::string to_string(int value);
std::wstring to_wstring(int value); 
除了int之外,这两个函数还为long、long long、unsigned int、unsigned long、unsigned long long、float、double和long double提供了重载。
当涉及到相反的转换时,有一整套具有ston(字符串到数字)格式的函数名称,其中n代表i(integer)、l(long)、ll(long long)、ul(unsigned long)或ull(unsigned long long)。以下列表显示了stoi函数及其两个重载——一个接受std::string作为第一个参数,另一个接受std::wstring作为第一个参数。此外,还有类似的函数称为stol、stoll、stoul、stoull、stof、stod和stold:
int stoi(const std::string& str, std::size_t* pos = 0, int base = 10);
int stoi(const std::wstring& str, std::size_t* pos = 0, int base = 10); 
字符串到整型函数的工作方式是丢弃非空白字符之前的所有空白字符,然后尽可能多地取字符以形成一个有符号或无符号数(根据情况而定),然后将该数转换为请求的整型(stoi()将返回整数,stoul()将返回unsigned long,等等)。在所有以下示例中,结果都是整数42,除了最后一个示例,其结果是-42:
auto i1 = std::stoi("42");             // i1 = 42
auto i2 = std::stoi("   42");          // i2 = 42
auto i3 = std::stoi("   42fortytwo");  // i3 = 42
auto i4 = std::stoi("+42");            // i4 = 42
auto i5 = std::stoi("-42");            // i5 = -42 
一个有效的整数值可能由以下部分组成:
- 
符号,加号( +)或减号(-)(可选)
- 
前缀 0表示八进制基数(可选)
- 
前缀 0x或0X表示十六进制基数(可选)
- 
一系列数字 
可选的前缀0(表示八进制)仅在指定的基数为8或0时应用。同样,可选的前缀0x或0X(表示十六进制)仅在指定的基数为16或0时应用。
将字符串转换为整数的函数有三个参数:
- 
输入字符串。 
- 
一个指针,当它不为空时,将接收已处理的字符数。这可能包括被丢弃的所有前导空白字符、符号和基数前缀,因此不应与整数值的位数混淆。 
- 
表示基数的数字;默认情况下,这是 10。
输入字符串中的有效数字取决于基。对于基 2,唯一的有效数字是 0 和 1;对于基 5,它们是 01234。对于基 11,有效数字是 0-9 和字符 A 和 a。这继续到基 36,它有有效字符 0-9、A-Z 和 a-z。
以下是将具有各种基数数字的字符串转换为十进制整数的额外示例。在所有情况下,结果要么是 42,要么是 -42:
auto i6 = std::stoi("052", nullptr, 8);      // i6 = 42
auto i7 = std::stoi("052", nullptr, 0);      // i7 = 42
auto i8 = std::stoi("0x2A", nullptr, 16);    // i8 = 42
auto i9 = std::stoi("0x2A", nullptr, 0);     // i9 = 42
auto i10 = std::stoi("101010", nullptr, 2); // i10 = 42
auto i11 = std::stoi("22", nullptr, 20);    // i11 = 42
auto i12 = std::stoi("-22", nullptr, 20);  // i12 = -42
auto pos = size_t{ 0 };
auto i13 = std::stoi("42", &pos);      // i13 = 42,  pos = 2
auto i14 = std::stoi("-42", &pos);     // i14 = -42, pos = 3
auto i15 = std::stoi("  +42dec", &pos);// i15 = 42,  pos = 5 
需要注意的一个重要事项是,如果转换失败,这些转换函数会抛出异常。可以抛出的有两个异常:
- 
如果转换无法执行,将抛出 std::invalid_argument:try { auto i16 = std::stoi(""); } catch (std::exception const & e) { // prints "invalid stoi argument" std::cout << e.what() << '\n'; }
- 
如果转换的值超出了结果类型的范围(或者如果底层函数将 errno设置为ERANGE),将抛出std::out_of_range:try { // OK auto i17 = std::stoll("12345678901234"); // throws std::out_of_range auto i18 = std::stoi("12345678901234"); } catch (std::exception const & e) { // prints "stoi argument out of range" std::cout << e.what() << '\n'; }
另一组将字符串转换为浮点型的函数与它们非常相似,只是它们没有用于数值基的参数。有效的浮点值在输入字符串中可能有不同的表示形式:
- 
十进制浮点表达式(可选的符号,一串十进制数字,可选的点,可选的 e或E,后跟可选符号的指数)
- 
二进制浮点表达式(可选的符号, 0x或0X前缀,一串十六进制数字,可选的点,可选的p或P,后跟可选符号的指数)
- 
无穷大表达式(可选的符号后跟不区分大小写的 INF或INFINITY)
- 
一个非数字表达式(可选的符号后跟不区分大小写的 NAN和可能的其他字母数字字符)
除了这些格式之外,当前安装的 C 区域设置支持的额外格式也可能被支持。
以下是将字符串转换为双精度浮点数的各种示例:
auto d1 = std::stod("123.45");         // d1 =  123.45000000000000
auto d2 = std::stod("+123.45");        // d2 =  123.45000000000000
auto d3 = std::stod("-123.45");        // d3 = -123.45000000000000
auto d4 = std::stod("  123.45");       // d4 =  123.45000000000000
auto d5 = std::stod("  -123.45abc");   // d5 = -123.45000000000000
auto d6 = std::stod("1.2345e+2");      // d6 =  123.45000000000000
auto d7 = std::stod("0xF.6E6666p3");   // d7 =  123.44999980926514
auto d8 = std::stod("INF");            // d8 = inf
auto d9 = std::stod("-infinity");      // d9 = -inf
auto d10 = std::stod("NAN");           // d10 = nan
auto d11 = std::stod("-nanabc");       // d11 = -nan 
在前面以 0xF.6E6666p3 的形式看到的浮点基 2 科学记数法不是本食谱的主题。然而,为了清晰理解,这里提供了一个简短的描述,但建议您查阅额外的参考资料以获取详细信息(例如 en.cppreference.com/w/cpp/language/floating_literal)。基 2 科学记数法中的浮点常数由几个部分组成:
- 
十六进制前缀 0x。
- 
一个整数部分;在这个例子中,它是 F,在十进制中是 15。
- 
一个分数部分,在这个例子中是 6E6666,或者以二进制形式表示为011011100110011001100110。要将它转换为十进制,我们需要加上二的反幂:1/4 + 1/8 + 1/32 + 1/64 + 1/128 + ...。
- 
一个后缀,表示 2 的幂;在这个例子中, p3表示 2 的 3 次幂。
十进制等值的值是通过将有效数字(由整数部分和分数部分组成)与基的指数幂相乘得到的。
对于给定的十六进制基数 2 浮点字面量,有效数字是 15.4312499...(请注意,第七位之后的数字没有显示),基数是 2,指数是 3。因此,结果是 15.4212499... * 8,即 123.44999980926514。
参见
- 数值类型的限制和其他属性,了解最小值和最大值,以及数值类型的其他属性
理解各种字符和字符串类型
在之前的菜谱中,我们探讨了各种整型和浮点类型。另一类类型,字符类型,通常是误解和混淆的来源。截至 C++20,C++ 语言中有五种字符数据类型:char、wchar_t、char8_t、char16_t 和 char32_t。在本菜谱中,我们将探讨这些类型之间的差异以及它们的使用方式。
如何做到这一点...
按照以下方式使用可用的字符类型:
- 
用于存储 ASCII 字符、拉丁字符集(在 ISO-8859 标准中定义)或 UTF-8 编码字符的单个字节的 char类型:char c = 'C'; const char* s = "C++"; std::cout << c << s << '\n';
- 
使用 Windows API 存储和操作 UTF-16LE 编码字符的 wchar_t类型:wchar_t c = L'Ʃ'; const wchar_t* s = L"δῆμος"; std::wcout << c << s << '\n';
- 
用于存储 UTF-8 编码码点的单个字节的 char8_t类型:char8_t c = u8'A'; const char8_t* s = u8"Æthelflæd";
- 
用于存储 UTF-16 编码字符的 char16_t类型:char16_t c = u'Æ'; const char16_t* s = u"Æthelflæd";
- 
用于存储 UTF-32 编码字符的 char32_t类型:char32_t c = U''; const char32_t* s = U"";
它是如何工作的...
C++语言早期用于存储字符的内建数据类型是 char 类型。这是一个 8 位数据类型,与 signed char 和 unsigned char 都不同。它不是这两个数据类型中的任何一个的 typedef。您可以使用 std::is_same 类型特性来测试这一点:
std::cout << std::is_same_v<char, signed char> << '\n';   // prints 0
std::cout << std::is_same_v<char, unsigned char> << '\n'; // prints 0 
这两行都会打印 0。这意味着您可以对这些三种数据类型中的所有这些进行函数重载,如下面的代码片段所示:
void f(char) {}
void f(signed char) {}
void f(unsigned char) {} 
标准没有指定 char 是有符号类型还是无符号类型。因此,其符号性取决于编译器或目标平台。在 x86 和 x64 系统上,char 类型是有符号类型,而在 ARM 上是无符号类型。
char 数据类型可以用来存储 ASCII 字符集和其他 8 位拉丁字符集,如 Latin-1、Latin-2、Latin/Cyrillic、Latin Nordic 等。它也可以用来存储多字节字符集的单个字节,最常用的是 Unicode 集的 UTF-8 编码。
为了处理固定宽度的多字节字符集,wchar_t 类型在 C++98 中被引入。这也是一个独特的数据类型(不是某些整型类型的 typedef)。其大小没有指定,因此也各不相同:在 Windows 上是 2 字节,在 Unix 系统上通常是 4 字节。
这意味着在编写可移植代码时不应使用 wchar_t。wchar_t 类型主要在 Windows 上使用,它被用于存储 Unicode 字符集的 UTF-16LE 编码的 16 位字符。这是 Windows 操作系统的本地字符集。
在标准的较新版本中,引入了三种新的字符数据类型。在 C++11 中,char32_t 和 char16_t 被添加来表示 32 位和 16 位宽字符。它们旨在表示 UTF-32 和 UTF-16 编码的 Unicode 字符。尽管它们是各自独特的类型,但它们与 uint_least32_t 和 uint_least16_t 分别具有相同的大小、符号和对齐。在 C++20 中,添加了 char8_t 数据类型。这是旨在存储 UTF-8 代码单元(它们是 8 位的)。char8_t 类型是一个独特的 8 位类型,它具有与 unsigned char 相同的大小、符号和对齐。
我们可以将所有这些信息总结在以下表格中:
| 类型 | C++ 标准 | 尺寸(字节) | 符号 | 
|---|---|---|---|
| char | 所有版本 | 1 | 未指定 | 
| wchar_t | C++98 | 未指定(通常是 2 或 4) | 未指定 | 
| char8_t | C++20 | 1 | 无符号 | 
| char16_t | C++11 | 2 | 无符号 | 
| char32_t | C++11 | 4 | 无符号 | 
表 2.6:C++ 字符类型的尺寸和符号总结
char 和 char8_t 类型的字符串被称为 窄字符串,而 wchar_t、char16_t 和 char32_t 类型的字符串被称为 宽字符串。C++ 标准提供了一个用于存储和操作字符序列的容器。这是一个类模板,它定义了几个类型别名以简化使用,如下表所示:
| 类型 | 定义 | C++ 标准 | 
|---|---|---|
| std::string | std::basic_string<char> | C++98 | 
| std::wstring | std::basic_string<wchar_t> | C++98 | 
| std::u8string | std::basic_string<char8_t> | C++20 | 
| std::u16string | std::basic_string<char16_t> | C++11 | 
| std::u32string | std::basic_string<char32_t> | C++11 | 
表 2.7:std::basic_string 的各种类型别名
与其他标准容器一样,std::basic_string 提供了多种成员函数来构建、访问元素、迭代、搜索或对包含的字符序列执行各种操作。特别需要提及的是 basic_string 中数据是如何存储的。在 C++11 中,它保证是连续的,就像数组一样。
另一方面,它如何处理字符串终止可能有点令人困惑。让我们通过一个例子来解释它:
std::string s = "demo"; 
存储在 basic_string 对象中的元素是字符 'd' ','e' ','m' ' 和 'o' '。这是如果你遍历对象(例如,for (auto c : s))时得到的结果。size() 成员将返回 4。然而,c_str() 和 data() 成员函数都将返回一个空终止符。这意味着 s[s.size()] 保证是 0。
字符串通常以文字形式在源代码中提供。不同的字符类型有不同的前缀,如下表所示:
| 文字 | C++ 标准 | 字符类型 | 字符串类型 | 
|---|---|---|---|
| 无 | 所有版本 | char | const char* | 
| L | C++98 | wchar_t | const wchar_t* | 
| u8 | C++11 | char(直到 C++20)char8_t(自 C++20) | const char*(直到 C++20)const char8_t*(自 C++20) | 
| u | C++11 | char16_t | const char16_t* | 
| U | C++11 | char32_t | const char32_t* | 
表 2.8:不同字符和字符串类型的前缀
auto is used didactically to explain the deduction rules):
auto c1 = 'a';     // char
auto c2 = L'b';    // wchar_t
auto c3 = u8'c';   // char until C++20, char8_t in C++20
auto c4 = u'd';    // char16_t
auto c5 = U'e';    // char32_t
auto sa1 = "a";    // const char*
auto sa2 = L"a";   // const wchar_t*
auto sa3 = u8"a";  // const char* until C++20
// const char8_t* in C++20
auto sa4 = u"a";   // const char16_t*
auto sa5 = U"a";   // const char32_t* 
在第一部分,由于使用了单引号,变量 c1 到 c5 的字符类型根据字面量前缀(在注释的右侧提到推断出的类型)。在第二部分,由于使用了双引号,变量 sa1 到 sa5 的类型被推断为字符串类型,同样地,这也取决于字面量前缀。
对于 "a" 的推断类型不是 std::string,而是 const char*。如果您想使用任何 basic_string 类型别名,例如 std::string,您必须显式定义类型(不要使用 auto)或使用在 std::string_literals 命名空间中可用的标准用户定义字面量后缀。这将在下一个代码片段中展示:
using namespace std::string_literals;
auto s1 = "a"s;    // std::string
auto s2 = L"a"s;   // std::wstring
auto s3 = u8"a"s;  // std::u8string
auto s4 = u"a"s;   // std::u16string
auto s5 = U"a"s;   // std::u32string 
为了避免混淆,以下表格解释了各种指针类型的含义:
| 类型 | 含义 | 
|---|---|
| char* | 一个指向可变字符的可变指针。指针和指向的字符都可以被修改。 | 
| const char* | 一个可变指针指向一个常量字符。指针可以被修改,但指向的位置的内容不能被修改。 | 
| char * const | 一个指向可变字符的常量指针。指针不能被修改,但指向的位置的内容可以被修改。 | 
| const char * const | 一个指向常量字符的常量指针。指针和指向的位置的内容都不能被修改。 | 
| char[] | 字符数组。 | 
表 2.9:各种指针类型的含义
您必须已经注意到,在前面的表格中,前缀 u8 在不同的标准中有不同的行为:
- 
自 C++11 引入以来,直到 C++20,它定义了一个 char字面量。
- 
自 C++20 以来,当它通过引入 char8_t被重新定义时,它定义了一个char8_t字面量。
这个 C++20 的变化是一个破坏性变化。它被优先考虑,而不是引入另一个可能使事情更加复杂的字面量前缀。
字符或字符串字面量可以包含代码点值而不是实际字符。这些必须使用 \u(用于 4 个十六进制数字的代码点)或 \U(用于 8 个十六进制数字的代码点)进行转义。以下是一个示例:
std::u16string hb = u"Harald Bluetooth \u16BC\u16d2"; // 
std::u32string eh = U"Egyptian hieroglyphs \U00013000 \U000131B2"; //  
在 C++23 中,可以使用 Unicode 而不是代码点值。这是通过使用 \N{xxx} 转义序列来完成的,其中 xxx 是 Unicode 分配的名称。因此,上述代码片段在 C++23 中也可以写成以下形式:
std::u16string hb = u"Harald Bluetooth \N{Runic Letter Long-Branch-Hagall H}\N{Runic Letter Berkanan Beorc Bjarkan B}";
std::u32string eh = U"Egyptian hieroglyphs \N{EGYPTIAN HIEROGLYPH A001} \N{EGYPTIAN HIEROGLYPH M003A}"; 
此外,在 C++23 中,可以使用具有任意数量十六进制数字的代码点值。在先前的示例中,包含埃及象形文字的字符串包含代码点 13000,它有 5 个十六进制数字。然而,由于 \U 转义序列需要 8 个十六进制数字,我们不得不包含三个前导零 (\U00013000)。在 C++23 中,这不再必要,但需要语法 \u{n…}(小写 u),其中 n… 是任意数量的十六进制数字。因此,此字符串也可以在 C++23 中按以下方式编写:
std::u32string eh = U"Egyptian hieroglyphs \u{13000} \u{131B2}"; //  
以多种方式将字符和字符串打印到控制台:
- 
使用 std::cout和std::wcout全局对象
- 
使用 printf函数族
- 
在 C++23 中使用 std::print函数族
- 
使用第三方文本处理库,例如广泛使用的 fmt 库(它是 C++20 和 C++23 中包含的 std::format和std::print标准工具的来源)
std::cout 和 std::wcout 全局对象可用于将 char/const char*/std::string 值和,分别,wchar_t/const wchar_t*/std::wstring 值打印到标准输出控制台。打印 ASCII 字符不会引起问题,但处理其他字符集和编码,如 UTF-8,则更为复杂,因为没有标准支持,不同的平台需要不同的解决方案。你可以在下一配方 将 Unicode 字符打印到输出控制台 中了解更多关于这个主题的信息。
参见
- 
理解各种数值类型,了解可用的整数和浮点类型 
- 
使用 std::format 和 std::print 格式化和打印文本,了解如何使用现代工具格式化和打印文本 
将 Unicode 字符打印到输出控制台
在之前的配方中,理解各种字符和字符串类型,我们探讨了用于存储字符和字符串字符的各种数据类型。这种类型的多重性是必要的,因为随着时间的推移,已经开发出了多种字符集。
最广泛使用的字符集是 ASCII 和 Unicode。尽管对前者的支持自语言创建以来一直存在于所有编译器和目标平台上,但后者在 Windows 和 Unix/Linux 系统上的支持以不同的速度和形式发展。在本配方中,我们将探讨如何以不同的编码将文本打印到标准输出控制台。
如何做到这一点…
要将文本写入标准输出控制台,你可以使用以下方法:
- 
对于 ASCII 字符的写入,使用 std::cout:std::cout << "C++\n";
- 
对于在 Linux 上写入 UTF-8 编码的 Unicode 字符,也使用 std::cout:std::cout << "Erling Håland\n"; std::cout << "Thomas Müller\n"; std::cout << "Στέφανος Τσιτσιπάς\n"; std::string monkeys = ""; std::cout << monkeys << '\n';对于使用 char8_t数据类型存储的 UTF-8 字符串,你仍然可以使用std::cout,但必须将底层类型重新解释为字符数组:std::cout << reinterpret_cast<const char*>(u8"Στέφανος Τσιτσιπάς\n");
- 
在 Windows 系统上写入 UTF-8 编码的 Unicode 字符时,使用 char8_t字符,在 C++20 中相应地使用std::u8string字符串。在早期版本中,您可以使用char和std::string。在写入标准输出之前,请确保调用 Windows APISetConsoleOutputCP(CP_UTF8):SetConsoleOutputCP(CP_UTF8); std::cout << reinterpret_cast<const char*>(u8"Erling Håland\n"); std::cout << reinterpret_cast<const char*>(u8"Thomas Müller\n"); std::cout << reinterpret_cast<const char*>(u8"Στέφανος Τσιτσιπάς\n"); std::cout << reinterpret_cast<const char*>(u8"\n");

图 2.3:前一个代码片段的输出
- 
在 Windows 系统上写入 UTF-16 编码的 Unicode 字符时,使用 wchar_t字符和std::wstring字符串。在写入标准输出之前,请确保调用_setmode(_fileno(stdout), _O_U16TEXT):auto mode = _setmode(_fileno(stdout), _O_U16TEXT); std::wcout << L"Erling Håland\n"; std::wcout << L"Thomas Müller\n"; std::wcout << L"Στέφανος Τσιτσιπάς\n"; _setmode(_fileno(stdout), mode);

图 2.4:前一个代码片段的输出
它是如何工作的...
ASCII 编码在过去半个世纪中一直是最常见的字符编码格式。它包含 128 个字符,包括英语的大写和小写字母、10 个十进制数字和符号。该集合的前 32 个字符是不可打印的,被称为控制字符。C++语言完全支持 ASCII 字符集。您可以使用std::cout将 ASCII 字符打印到标准输出。
由于 ASCII 编码仅包括英语字母表中的字母,因此已经尝试了各种方法来支持其他语言和字母表。一种方法是代码页的概念。ASCII 编码只需要 7 位来编码 128 个字符。因此,可以使用 8 位数据类型来编码额外的 128 个字符。这意味着索引 128-255 中的字符可以映射到其他语言或字母表。这种映射称为代码页。有各种各样的代码页,如 IBM 代码页、DOS 代码页、Windows 代码页等。您可以在en.wikipedia.org/wiki/Code_page上了解更多信息。以下表格列出了几个 Windows 代码页:
| Code page | Name | Languages supported | 
|---|---|---|
| 1250 | Windows Central Europe | Czech, Polish, Slovak, Hungarian, Slovene, Serbo-Croatian, Romanian, Albanian | 
| 1251 | Windows Cyrillic | Russian, Belarusian, Ukrainian, Bulgarian, Macedonian, Serbian | 
| 1252 | Windows Western | Spanish, Portuguese, French, German, Danish, Norwegian, Swedish, Finnish, Icelandic, Faroese, etc. | 
| 1253 | Windows Greek | Greek | 
| 1254 | Windows Turkish | Turkish | 
| 1255 | Windows Hebrew | Hebrew | 
| 1256 | Windows Arabic | Arabic, Persian, Urdu, English, French | 
| 1257 | Windows Baltic | Estonian, Latvian, Lithuanian | 
| 1258 | Windows Vietnamese | Vietnamese | 
表 2.10:Windows 代码页的子集列表
为了理解它是如何工作的,让我们用一个代码片段来举例。索引 224 或 0xE0(十六进制)在不同的代码页中映射到不同的字符,如下表所示:
| 1250 | 1251 | 1252 | 1253 | 1254 | 1255 | 1256 | 1257 | 1258 | 
|---|---|---|---|---|---|---|---|---|
| ŕ | а | à | ΰ | à |  | à | ą | à | 
表 2.11:几个 Windows 代码页中索引为 224 的字符
在编码术语中,将字符映射到的数值称为码点(或码点)。在我们的例子中,224 是一个码点,而a、à或ą是不同代码页中映射到这个码点的特定字符。
在 Windows 中,你可以通过调用SetConsoleOutputCP() API 来激活与运行进程关联的控制台的一个代码页。以下是一个示例片段,其中我们打印了从 1250 到 1258(前面列出的那些)所有代码页中映射到 224 码点的字符:
char c = 224;
for (int codepage = 1250; codepage <= 1258; codepage++)
{
   SetConsoleOutputCP(codepage);
   std::cout << c << ' ';
} 
运行此程序的结果显示在下一张图片中。你可以看到,打印的字符是按照表 2.9中预期的字符。

图 2.5:使用不同代码页打印码点 224
虽然代码页提供了一种简单的方法来在不同的脚本之间切换,但它并不是一个可以支持包含数百或数千个字符或象形文字的语言或书写系统(如中文或埃及象形文字)的解决方案。为此,开发了另一个标准,称为Unicode。这个编码标准旨在表示世界上大多数的书写脚本,无论是现在的还是过去的,以及其他符号,如近年来在短信中变得极为流行的表情符号。目前,Unicode 标准定义了大约 150,000 个字符。
Unicode 字符可以存储在几种编码中,最流行的是 UTF-8 和 UTF-16。还有 UTF-32 和 GB18030;后者不是 Unicode 规范的一部分,但在中国使用,并完全实现了 Unicode。
UTF-8 是一种可变长度的字符编码标准,与 ASCII 兼容。UTF-8 使用 1、2、3 或 4 个字节来编码所有可表示的码点。一个码点使用得越多,其编码使用的字节就越少。ASCII 编码的 128 个码点由一个字节表示。因此,UTF-8 完全与 ASCII 向后兼容。所有其他的 Unicode 码点都使用多个字节进行编码:范围在 128-2047 的码点使用 2 个字节,范围在 2048-65535 的码点使用 3 个字节,范围在 65536-1114111 的码点使用 4 个字节。编码中的第一个字节称为引导字节,它提供了关于用于编码码点的字节数的信息。正因为如此,UTF-8 是一个非常高效的编码系统,并且是万维网的首选选择,几乎所有的网页都使用这种编码。
UTF-16 也是一种可变长度的字符编码,可以编码所有的 Unicode 码点。为此,它使用一个或两个 16 位码单元,这使得它与 ASCII 不兼容。UTF-16 是 Windows 操作系统以及 Java 和 JavaScript 编程语言使用的编码。
UTF-32 是一种不太常见的编码系统。它是一种固定长度的编码,每个代码点使用 32 位。由于所有 Unicode 代码点最多需要 21 位,所以前 11 位总是 0。这使得它空间效率低下,这是其主要缺点。其主要优点是,在序列中查找第 N 个代码点所需的时间是常数,而 UTF-8 和 UTF-16 这样的可变长度编码则需要线性时间。
编译器通常假定源文件使用 UTF-8 编码。GCC、Clang 和 MSVC 都是如此。
Linux 发行版对 UTF-8 有原生支持。这意味着将字符串字面量写入输出控制台,如"Στέφανος Τσιτσιπάς",将产生预期的结果,因为终端支持 UTF-8:
std::cout << "Στέφανος Τσιτσιπάς"; 
另一方面,直接写入宽字符串,如L"Στέφανος Τσιτσιπάς",不会按预期工作。要得到预期的结果,您需要设置一个区域对象。默认的 C 区域不知道如何将宽字符转换为 UTF-8。为了实现这一点,您需要使用能够做到这一点的区域。您有两个选择:
- 
初始化一个与环境配置匹配的区域对象,通常应该是一个支持 UTF-8 的区域: std::locale utf8(""); std::wcout.imbue(utf8); std::wcout << L"Στέφανος Τσιτσιπάς\n";
- 
使用特定的区域初始化一个区域对象,例如英语(美国): std::locale utf8("en_US.UTF-8"); std::wcout.imbue(utf8); std::wcout << L"Στέφανος Τσιτσιπάς\n";
区域在第七章中详细讨论,使用区域化设置进行流操作。
在 Windows 系统上,情况不同。Windows 命令提示符(cmd.exe)不支持 UTF-8。尽管 Windows 10 添加了对名为“使用 Unicode UTF-8 进行全球语言支持”的 beta 功能的支持,但这在区域设置中隐藏得很深,并且目前报告说这可能会阻止某些应用程序正确运行。要将 UTF-8 内容写入命令提示符,您必须首先通过调用SetConsoleOutputCP()并将CP_UTF8作为参数(或其数值 65001)传递来设置正确的代码页:
SetConsoleOutputCP(CP_UTF8);
std::cout << reinterpret_cast<const char*>(u8"Erling Håland\n");
std::cout << reinterpret_cast<const char*>(u8"Thomas Müller\n");
std::cout << reinterpret_cast<const char*>(u8"Στέφανος Τσιτσιπάς\n");
std::u8string monkeys = u8"\n";
std::cout << reinterpret_cast<const char*>(monkeys.c_str()); 
要写入 UTF-16,您需要调用_setmode()(来自<io.h>)来设置文件的翻译模式(在这种情况下,是标准输出控制台)为 UTF-16。为此,您必须传递_O_U16TEXT参数。该函数返回之前的翻译模式,您可以在写入所需内容后使用它来恢复翻译模式。
传递_O_TEXT设置文本模式(在此模式下,输入时的 CR-LF 组合被转换为单个 LF,而输出时的 LF 字符被转换为 CR-LF):
auto mode = _setmode(_fileno(stdout), _O_U16TEXT);
std::wcout << L"Erling Håland\n";
std::wcout << L"Thomas Müller\n";
std::wcout << L"Στέφανος Τσιτσιπάς\n";
_setmode(_fileno(stdout), mode); 
然而,为了使这可行,也很重要的是命令提示符应用程序使用的是 True Type 字体,例如 Lucinda Console 或 Consolas,而不是仅支持 ASCII 的 Raster 字体。
从 Windows 10 开始,Windows 提供了一种新的终端应用程序。这被称为Windows Terminal,它内置了对 UTF-8 的支持。这意味着以下代码无需先调用SetConsoleOutputCP()即可打印出预期的结果:
std::cout << reinterpret_cast<const char*>(u8"Erling Håland\n"); 
与其他编程语言不同,C++对 Unicode 的支持并不是一个强项。本食谱提供了在控制台应用程序中处理 Unicode 的基础。然而,在实践中,事情可能会变得更加复杂,需要额外的支持。为了进一步了解这个主题,建议您查阅额外的资料,其中许多资料可在网上找到。
参见
- 
理解各种字符和字符串类型,了解 C++中可用的字符和字符串类型 
- 
使用 std::format 和 std::print 格式化和打印文本,了解如何使用现代工具格式化和打印文本 
- 
第七章,使用本地化设置进行流操作,了解区域设置以及如何控制输入/输出流的行为 
生成伪随机数
生成随机数对于各种应用程序都是必要的,从游戏到密码学,从抽样到预测。然而,“随机数”这个术语实际上并不准确,因为通过数学公式生成数字是确定性的,并不产生真正的随机数,而是产生看起来随机的数字,称为“伪随机数”。真正的随机性只能通过基于物理过程的硬件设备实现,即使这样也可能受到挑战,因为我们甚至可能认为整个宇宙实际上是确定性的。
现代 C++提供了通过伪随机数库生成伪随机数的支持,该库包含数字生成器和分布。理论上,它也可以生成真正的随机数,但在实践中,这些可能实际上只是伪随机数。
准备工作
在本食谱中,我们将讨论生成伪随机数的标准支持。理解随机数和伪随机数之间的区别是关键。真正的随机数是无法比随机机会预测得更好的数字,并且通过基于硬件的随机数生成器生成。伪随机数是通过生成具有近似真正随机数特性的序列的算法生成的数字。
此外,熟悉各种统计分布也是一个加分项。然而,了解均匀分布是强制性的,因为库中的所有引擎生成的数字都是均匀分布的。不深入细节,我们只提一下,均匀分布是一种概率分布,它关注的是等可能性发生的事件(在特定范围内)。
如何实现...
要在您的应用程序中生成伪随机数,您应执行以下步骤:
- 
包含头文件 <random>:#include <random>
- 
使用 std::random_device生成器为一个伪随机引擎设置种子:std::random_device rd{};
- 
使用可用的一个引擎生成数字,并用随机种子初始化它: auto mtgen = std::mt19937{ rd() };
- 
使用可用的分布之一将引擎的输出转换为所需的统计分布之一: auto ud = std::uniform_int_distribution<>{ 1, 6 };
- 
生成伪随机数: for(auto i = 0; i < 20; ++i) auto number = ud(mtgen);
它是如何工作的...
伪随机数库包含两种类型的组件:
- 
Engines,这些是随机数生成器;这些可以生成具有均匀分布的伪随机数,或者如果可用,实际随机数。 
- 
Distributions,这些将引擎的输出转换为统计分布。 
所有引擎(除了 random_device)都产生在均匀分布中的整数数,并且所有引擎都实现了以下方法:
- 
min():这是一个静态方法,返回生成器可以生成的最小值。
- 
max():这是一个静态方法,返回生成器可以生成的最大值。
- 
seed():这使用起始值初始化算法(除了random_device,它不能被初始化)。
- 
operator():这生成一个在min()和max()之间均匀分布的新数字。
- 
discard():生成并丢弃指定数量的伪随机数。
以下引擎可用:
- 
linear_congruential_engine:这是一个线性同余发生器,使用以下公式生成数字:x(i) = (A * x(i – 1) + C) mod M 
- 
mersenne_twister_engine:这是一个梅森旋转发生器,它在 W * (N – 1) * R 位上保持一个值。每次需要生成一个数字时,它提取 W 位。当所有位都已被使用时,它通过移位和混合位来扭曲大值,以便它有一个新的位集可以从中提取。
- 
subtract_with_carry_engine:这是一个实现基于以下公式的 subtract with carry 算法的生成器:x(i) = (x(i – R) – x(i – S) – cy(i – 1)) mod M 在前面的公式中,cy定义为: ![]() 
此外,库还提供了引擎适配器,这些适配器也是包装另一个引擎并基于基本引擎的输出生成数字的引擎。引擎适配器实现了前面提到的基本引擎的相同方法。以下可用的引擎适配器有:
- 
discard_block_engine:一个生成器,从基本引擎生成的每个 P 个数字块中,只保留 R 个数字,丢弃其余的数字。
- 
independent_bits_engine:一个生成器,它产生与基本引擎不同数量的位数的数字。
- 
shuffle_order_engine:一个生成器,它保持由基本引擎生成的 K 个数字的随机顺序表,并从该表中返回数字,用基本引擎生成的数字替换它们。
选择伪随机数生成器应根据您应用程序的具体要求进行。线性同余引擎中等快速,但其内部状态存储需求非常小。减法进位引擎非常快,包括在那些没有高级算术指令集处理器的机器上。然而,它需要更大的内部状态存储,并且生成的数字序列具有较少的期望特征。梅森旋转器是这些引擎中最慢的,具有最长的存储持续时间,但产生最长的非重复伪数字序列。
所有这些引擎和引擎适配器都产生伪随机数。然而,库还提供了一个称为 random_device 的另一个引擎,它应该产生非确定性数字,但这不是一个实际约束,因为可能没有可用的物理随机熵源。因此,random_device 的实现实际上可能基于伪随机引擎。random_device 类不能像其他引擎那样进行播种,并且有一个名为 entropy() 的附加方法,它返回随机设备的熵,对于确定性生成器为 0,对于非确定性生成器为非零。
然而,这不是确定设备是否真正确定性的可靠方法。例如,GNU libstdc++ 和 LLVM libc++ 实现了一个非确定性设备,但熵返回 0。另一方面,VC++ 和 boost.random 分别返回 32 和 10 作为熵。
所有这些生成器都产生均匀分布的整数。然而,这只是在大多数应用中需要随机数的大多数可能统计分布之一。为了能够产生其他分布中的数字(无论是整数还是实数),库提供了几个称为 分布 的类。
这些将根据其实施的统计分布转换引擎的输出。以下分布可用:
| 类型 | 类名 | 数字 | 统计分布 | 
|---|---|---|---|
| 均匀分布 | uniform_int_distribution | 整数 | 均匀分布 | 
| uniform_real_distribution | 实数 | 均匀 | |
| 伯努利 | bernoulli_distribution | 布尔 | 伯努利 | 
| binomial_distribution | 整数 | 二项式 | |
| negative_binomial_distribution | 整数 | 负二项式 | |
| geometric_distribution | 整数 | 几何 | |
| 泊松 | poisson_distribution | 整数 | 泊松 | 
| exponential_distribution | 实数 | 指数 | |
| gamma_distribution | 实数 | 伽马 | |
| weibull_distribution | 实数 | Weibull | |
| extreme_value_distribution | 实数 | 极值 | |
| 正态 | normal_distribution | 实数 | 标准正态(高斯) | 
| lognormal_distribution | 实数 | 对数正态 | |
| chi_squared_distribution | 实数 | 卡方 | |
| cauchy_distribution | 实数 | 柯西 | |
| fisher_f_distribution | 实数 | 费舍尔 F 分布 | |
| student_t_distribution | 实数 | 学生 t 分布 | |
| 抽样 | discrete_distribution | 整数 | 离散 | 
| piecewise_constant_distribution | 实数 | 在常数子区间上分布的值 | |
| piecewise_linear_distribution | 实数 | 在定义的子区间上分布的值 | 
表 2.12:来自
如前所述,库提供的每个引擎都有其优缺点。当适当地初始化时,梅森旋转器,尽管速度最慢且内部状态最大,可以产生最长的非重复数字序列。在以下示例中,我们将使用std::mt19937,这是一个具有 19,937 位内部状态的 32 位梅森旋转器。还有一个 64 位的梅森旋转器,std::mt19937_64。std::mt19937和std::mt19937_64都是std::mersenne_twister_engine的别名。
生成随机数的最简单方法如下:
auto mtgen = std::mt19937 {};
for (auto i = 0; i < 10; ++i)
  std::cout << mtgen() << '\n'; 
在这个例子中,mtgen是梅森旋转器的std::mt19937。要生成数字,你只需要使用调用操作符,它会推进内部状态并返回下一个伪随机数。然而,这段代码有缺陷,因为引擎没有被初始化。结果,它总是产生相同的数字序列,这在大多数情况下可能不是你想要的。
初始化引擎的方法有很多种。其中一种方法,与 C 语言的random库类似,是使用当前时间。在现代 C++中,它应该看起来像这样:
auto seed = std::chrono::high_resolution_clock::now()
            .time_since_epoch()
            .count();
auto mtgen = std::mt19937{ static_cast<unsigned int>(seed) }; 
在这个例子中,seed是一个表示从时钟的纪元到当前时刻的滴答数的数字。然后使用这个数字来初始化引擎。这种方法的问题在于,那个seed的值实际上是确定的,在某些类别的应用中,它可能容易受到攻击。一个更可靠的方法是用实际的随机数来初始化生成器。
std::random_device类是一个应该返回真随机数的引擎,尽管实现可能实际上基于伪随机数生成器:
std::random_device rd;
auto mtgen = std::mt19937 {rd()}; 
所有引擎产生的数字都遵循均匀分布。要将结果转换为另一个统计分布,我们必须使用一个分布类。为了展示生成的数字是如何根据所选分布分布的,我们将使用以下函数。这个函数生成指定数量的伪随机数,并计算它们在映射中的重复次数。然后使用映射中的值来生成一个条形图,显示每个数字出现的频率:
void generate_and_print(std::function<int(void)> gen,
 int const iterations = 10000)
{
  // map to store the numbers and their repetition
auto data = std::map<int, int>{};
  // generate random numbers
for (auto n = 0; n < iterations; ++n)
    ++data[gen()];
  // find the element with the most repetitions
auto max = std::max_element(
             std::begin(data), std::end(data),
             [](auto kvp1, auto kvp2) {
    return kvp1.second < kvp2.second; });
  // print the bars
for (auto i = max->second / 200; i > 0; --i)
  {
    for (auto kvp : data)
    {
      std::cout
        << std::fixed << std::setprecision(1) << std::setw(3)
        << (kvp.second / 200 >= i ? (char)219 : ' ');
    }
    std::cout << '\n';
  }
  // print the numbers
for (auto kvp : data)
  {
    std::cout
      << std::fixed << std::setprecision(1) << std::setw(3)
      << kvp.first;
  }
  std::cout << '\n';
} 
以下代码使用std::mt19937引擎在范围[1, 6]内生成随机数;这基本上是你掷骰子时得到的结果:
std::random_device rd{};
auto mtgen = std::mt19937{ rd() };
auto ud = std::uniform_int_distribution<>{ 1, 6 };
generate_and_print([&mtgen, &ud]() {return ud(mtgen); }); 
程序的输出如下:

图 2.6:范围[1,6]的均匀分布
在下一个和最后一个示例中,我们将分布更改为均值为5和标准差为2的正态分布。这种分布产生实数;因此,为了使用之前的generate_and_print()函数,必须将数字四舍五入为整数:
std::random_device rd{};
auto mtgen = std::mt19937{ rd() };
auto nd = std::normal_distribution<>{ 5, 2 };
generate_and_print(
  [&mtgen, &nd]() {
    return static_cast<int>(std::round(nd(mtgen))); }); 
以下将是前面代码的输出:

图 2.7:均值为 5 和标准方差为 2 的正态分布
在这里,我们可以看到,基于图形表示,分布已从均匀分布变为均值为 5 的正态分布。
参见
- 正确初始化伪随机数生成器,了解如何正确初始化随机数生成器
正确初始化伪随机数生成器
在之前的配方中,我们探讨了伪随机数库及其组件,以及如何使用它来生成不同统计分布的数字。在那个配方中,被忽视的一个重要因素是伪随机数生成器的正确初始化。
通过仔细分析(这超出了本配方或本书的目的),可以证明梅森旋转器引擎倾向于重复产生某些值并省略其他值,从而生成不是均匀分布的数字,而是二项式或泊松分布的数字。在本配方中,您将学习如何初始化生成器以生成具有真正均匀分布的伪随机数。
准备工作
您应该阅读之前的配方,生成伪随机数,以了解伪随机数库提供的概述。
如何操作...
为了正确初始化伪随机数生成器以生成均匀分布的伪随机数序列,执行以下步骤:
- 
使用 std::random_device生成随机数作为种子值:std::random_device rd;
- 
为引擎的所有内部位生成随机数据: std::array<int, std::mt19937::state_size> seed_data {}; std::generate(std::begin(seed_data), std::end(seed_data), std::ref(rd));
- 
从之前生成的伪随机数据创建一个 std::seed_seq对象:std::seed_seq seq(std::begin(seed_data), std::end(seed_data));
- 
创建一个引擎对象并初始化表示引擎内部状态的位;例如, mt19937有 19,937 位的内部状态:auto eng = std::mt19937{ seq };
- 
根据应用程序的要求使用适当的分布: auto dist = std::uniform_real_distribution<>{ 0, 1 };
它是如何工作的...
在之前配方中展示的所有示例中,我们使用了std::mt19937引擎来生成伪随机数。尽管梅森旋转器比其他引擎慢,但它可以产生最长的非重复数字序列,并具有最佳的频谱特性。然而,按照之前配方中所示的方式初始化引擎将不会产生这种效果。问题是mt19937的内部状态有 624 个 32 位整数,而在之前配方的示例中,我们只初始化了其中之一。
在使用伪随机数库时,请记住以下经验法则(如信息框所示)。
为了产生最佳结果,在生成数字之前,必须正确初始化引擎的整个内部状态。
伪随机数库提供了一个用于此特定目的的类,称为 std::seed_seq。这是一个可以以任意数量的 32 位整数进行初始化的生成器,并在 32 位空间内均匀地产生所需数量的整数。
在 如何做... 部分的先前代码中,我们定义了一个名为 seed_data 的数组,其中包含与 mt19937 生成器内部状态相等的 32 位整数——即 624 个整数。然后,我们使用 std::random_device 生成的随机数初始化该数组。该数组后来被用来初始化 std::seed_seq,而 std::seed_seq 又被用来初始化 mt19937 生成器。
参见
- 生成伪随机数,以便熟悉标准数值库生成伪随机数的能力
创建烹饪用户定义字面量
字面量是内置类型(数值、布尔、字符、字符字符串和指针)的常量,在程序中不能被更改。语言定义了一系列前缀和后缀来指定字面量(前缀/后缀实际上是字面量的一部分)。C++11 允许我们通过定义称为字面量运算符的函数来创建用户定义的字面量,这些运算符引入了用于指定字面量的后缀。这些运算符仅与数值字符和字符字符串类型一起使用。
这为在未来的版本中定义标准字面量和允许开发者创建自己的字面量打开了可能性。在本食谱中,我们将学习如何创建自己的烹饪字面量。
准备工作
用户定义的字面量可以有两种形式:原始和烹饪。原始字面量不会被编译器处理,而烹饪字面量则是被编译器处理的值(例如,处理字符字符串中的转义序列或识别字面量中的数值,如整数 2898 来自字面量 0xBAD)。原始字面量仅适用于整数和浮点类型,而烹饪字面量也适用于字符和字符字符串字面量。
如何做...
要创建烹饪用户定义字面量,你应该遵循以下步骤:
- 
在单独的命名空间中定义你的字面量以避免名称冲突。 
- 
总是用下划线( _)作为用户定义后缀的前缀。
- 
为烹饪字面量定义以下形式之一的字面量运算符(使用 char8_t的形式仅从 C++20 开始可用)。注意,在以下列表中,T不是一个类型模板参数,而只是运算符返回类型的占位符:T operator "" _suffix(unsigned long long int); T operator "" _suffix(long double); T operator "" _suffix(char); T operator "" _suffix(wchar_t); T operator "" _suffix(char8_t); // since C++20 T operator "" _suffix(char16_t); T operator "" _suffix(char32_t); T operator "" _suffix(char const *, std::size_t); T operator "" _suffix(wchar_t const *, std::size_t); T operator "" _suffix(char8_t const *, std::size_t); // C++20 T operator "" _suffix(char16_t const *, std::size_t); T operator "" _suffix(char32_t const *, std::size_t);以下示例创建了一个用于指定千字节的用户定义字面量: namespace compunits { constexpr size_t operator "" _KB(unsigned long long const size) { return static_cast<size_t>(size * 1024); } } auto size{ 4_KB }; // size_t size = 4096; using byte = unsigned char; auto buffer = std::array<byte, 1_KB>{};
它是如何工作的...
当编译器遇到一个具有用户定义后缀_X(它总是以下划线开头,因为不带前导下划线的后缀是为标准库保留的)的用户定义字面量时,它将执行无限定名称查找以识别名为operator "" _X的函数。如果找到了,则根据字面量和字面量运算符的类型调用它。否则,编译器将产生错误。
在“如何做...”部分的示例中,字面量运算符被调用为operator "" _KB,其参数类型为unsigned long long int。这是字面量运算符可以处理的唯一整数类型。同样,对于浮点用户定义字面量,参数类型必须是long double,因为对于数值类型,字面量运算符必须能够处理可能的最大值。此字面量运算符返回一个constexpr值,因此它可以在需要编译时值的上下文中使用,例如指定数组的大小,如前例所示。
当编译器识别出一个用户定义字面量并需要调用适当的用户定义字面量运算符时,它将根据以下规则从重载集中选择重载:
- 
对于整数字面量:它按以下顺序调用:接受 unsigned long long的运算符,接受const char*的原始字面量运算符,或字面量运算符模板。
- 
对于浮点字面量:它按以下顺序调用:接受 long double的运算符,接受const char*的原始字面量运算符,或字面量运算符模板。
- 
对于字符字面量:它根据字符类型( char、wchar_t、char16_t和char32_t)调用适当的运算符。
- 
对于字符串字面量:它调用适当的运算符,根据字符串类型,该运算符接受一个指向字符字符串的指针和大小。 
在以下示例中,我们正在定义一个单位和数量的系统。我们希望使用千克、件、升和其他类型的单位。这在可以处理订单的系统中有用,您需要指定每件商品的量和单位。
在units命名空间中定义了以下内容:
- 
一个作用域枚举,用于可能的单位类型(如千克、米、升和件): enum class unit { kilogram, liter, meter, piece, };
- 
一个类模板,用于指定特定单位的数量(例如 3.5 千克或 42 件): template <unit U> class quantity { const double amount; public: constexpr explicit quantity(double const a) : amount(a) {} explicit operator double() const { return amount; } };
- 
为了能够对 quantity类模板进行加法和减法操作,定义了operator+和operator-函数:template <unit U> constexpr quantity<U> operator+(quantity<U> const &q1, quantity<U> const &q2) { return quantity<U>(static_cast<double>(q1) + static_cast<double>(q2)); } template <unit U> constexpr quantity<U> operator-(quantity<U> const &q1, quantity<U> const &q2) { return quantity<U>(static_cast<double>(q1) – static_cast<double>(q2)); }
- 
创建 quantity字面量的字面量运算符,定义在名为unit_literals的内部命名空间中。这样做是为了避免与其他命名空间中的字面量发生名称冲突。如果确实发生了这样的冲突,开发者可以使用适当的命名空间在需要定义字面量的作用域中选择它们: namespace unit_literals { constexpr quantity<unit::kilogram> operator "" _kg( long double const amount) { return quantity<unit::kilogram> { static_cast<double>(amount) }; } constexpr quantity<unit::kilogram> operator "" _kg( unsigned long long const amount) { return quantity<unit::kilogram> { static_cast<double>(amount) }; } constexpr quantity<unit::liter> operator "" _l( long double const amount) { return quantity<unit::liter> { static_cast<double>(amount) }; } constexpr quantity<unit::meter> operator "" _m( long double const amount) { return quantity<unit::meter> { static_cast<double>(amount) }; } constexpr quantity<unit::piece> operator "" _pcs( unsigned long long const amount) { return quantity<unit::piece> { static_cast<double>(amount) }; } }
仔细观察可以发现,之前定义的字面量运算符并不相同:
- 
_kg定义了整数和浮点字面量;这使得我们能够创建整数和浮点值,例如1_kg和1.0_kg。
- 
_l和_m仅适用于浮点字面量;这意味着我们只能使用浮点数定义这些单位的量值字面量,例如4.5_l和10.0_m。
- 
_pcs仅适用于整数字面量;这意味着我们只能定义整数数量的件量,例如42_pcs。
有这些字面量运算符可用,我们可以操作各种量。以下示例显示了有效和无效操作:
using namespace units;
using namespace unit_literals;
auto q1{ 1_kg };    // OK
auto q2{ 4.5_kg };  // OK
auto q3{ q1 + q2 }; // OK
auto q4{ q2 - q1 }; // OK
// error, cannot add meters and pieces
auto q5{ 1.0_m + 1_pcs };
// error, cannot have an integer number of liters
auto q6{ 1_l };
// error, can only have an integer number of pieces
auto q7{ 2.0_pcs} 
q1 是 1 千克的量;这是一个整数值。由于存在重载的 operator "" _kg(unsigned long long const),字面量可以从整数 1 正确创建。同样,q2 是 4.5 千克的量;这是一个实数值。由于存在重载的 operator "" _kg(long double),字面量可以从双精度浮点值 4.5 创建。
另一方面,q6 是 1 升的量。由于没有重载的 operator "" _l(unsigned long long),字面量无法创建。它需要一个接受 unsigned long long 的重载,但这样的重载不存在。同样,q7 是 2.0 件,但件字面量只能从整数值创建,因此这会生成另一个编译器错误。
更多内容...
尽管用户定义的字面量从 C++11 开始可用,但标准字面量运算符仅在 C++14 中可用。后续标准用户定义的字面量已添加到标准的下一个版本中。以下是一个这些标准字面量运算符的列表:
- 
用于定义 std::basic_string字面量的operator""s和在 C++17 中用于定义std::basic_string_view字面量的operator""sv:using namespace std::string_literals; auto s1{ "text"s }; // std::string auto s2{ L"text"s }; // std::wstring auto s3{ u"text"s }; // std::u16string auto s4{ U"text"s }; // std::u32string using namespace std::string_view_literals; auto s5{ "text"sv }; // std::string_view
- 
用于创建 std::chrono::duration值的operator""h、operator""min、operator""s、operator""ms、operator""us和operator""ns:using namespace std::chrono_literals; // std::chrono::duration<long long> auto timer {2h + 42min + 15s};
- 
用于创建 std::chrono::year字面量的operator""y和用于创建表示月份一天的std::chrono::day字面量的operator""d,两者都添加到 C++20:using namespace std::chrono_literals; auto year { 2020y }; // std::chrono::year auto day { 15d }; // std::chrono::day
- 
用于创建 std::complex<float>、std::complex<double>和std::complex<long double>值的operator""if、operator""i和operator""il:using namespace std::complex_literals; auto c{ 12.0 + 4.5i }; // std::complex<double>
标准用户定义的字面量存在于多个命名空间中。例如,字符串的 ""s 和 ""sv 字面量定义在命名空间 std::literals::string_literals 中。
然而,literals 和 string_literals 都是内联命名空间。因此,你可以使用 using namespace std::literals、using namespace std::string_literals 或 using namespace std::literals::string_literals 来访问字面量。在之前的示例中,第二种形式被首选。
另请参阅
- 
使用原始字符串字面量来避免转义字符,了解如何定义不需要转义特殊字符的字符串字面量 
- 
创建原始用户定义字面量,了解如何提供对输入序列的定制解释,以便改变编译器的正常行为 
- 
第一章,使用内联命名空间进行符号版本控制,了解如何使用内联命名空间和条件编译来对源代码进行版本控制 
创建原始用户定义字面量
在前面的配方中,我们探讨了 C++11 允许库实现者和开发者创建用户定义字面量以及 C++14 标准中可用的用户定义字面量。然而,用户定义字面量有两种形式:一种是被烹饪的形式,其中字面量值在提供给字面量操作符之前由编译器处理;另一种是原始形式,其中字面量在提供给字面量操作符之前不会被编译器处理。后者仅适用于整型和浮点型。原始字面量对于改变编译器的正常行为很有用。例如,一个如 3.1415926 的序列被编译器解释为浮点值,但使用原始用户定义字面量,它可以被解释为用户定义的十进制值。在本配方中,我们将探讨创建原始用户定义字面量。
准备工作
在继续本配方之前,强烈建议您先完成前面的配方,创建烹饪用户定义字面量,因为这里不会重复用户定义字面量的通用细节。
为了说明如何创建原始用户定义字面量,我们将定义二进制字面量。这些二进制字面量可以是 8 位、16 位和 32 位(无符号)类型。这些类型将被称为 byte8、byte16 和 byte32,我们将创建的字面量将被称为 _b8、_b16 和 _b32。
如何做到这一点...
要创建原始用户定义字面量,您应该遵循以下步骤:
- 
在单独的命名空间中定义您的字面量以避免名称冲突。 
- 
总是以下划线( _)作为用户定义后缀的前缀。
- 
定义以下形式的字面量操作符或字面量操作符模板: T operator "" _suffix(const char*); template<char...> T operator "" _suffix();
以下示例展示了 8 位、16 位和 32 位二进制字面量的可能实现:
namespace binary
{
  using byte8  = unsigned char;
  using byte16 = unsigned short;
  using byte32 = unsigned int;
  namespace binary_literals
  {
    namespace binary_literals_internals
    {
      template <typename CharT, char... bits>
      struct binary_struct;
      template <typename CharT, char... bits>
      struct binary_struct<CharT, '0', bits...>
      {
        static constexpr CharT value{
          binary_struct<CharT, bits...>::value };
      };
      template <typename CharT, char... bits>
      struct binary_struct<CharT, '1', bits...>
      {
        static constexpr CharT value{
          static_cast<CharT>(1 << sizeof...(bits)) |
          binary_struct<CharT, bits...>::value };
      };
      template <typename CharT>
      struct binary_struct<CharT>
      {
        static constexpr CharT value{ 0 };
      };
    }
    template<char... bits>
    constexpr byte8 operator""_b8()
    {
      static_assert(
        sizeof...(bits) <= 8,
        "binary literal b8 must be up to 8 digits long");
      return binary_literals_internals::
                binary_struct<byte8, bits...>::value;
    }
    template<char... bits>
    constexpr byte16 operator""_b16()
    {
      static_assert(
        sizeof...(bits) <= 16,
        "binary literal b16 must be up to 16 digits long");
      return binary_literals_internals::
                binary_struct<byte16, bits...>::value;
    }
    template<char... bits>
    constexpr byte32 operator""_b32()
    {
      static_assert(
        sizeof...(bits) <= 32,
        "binary literal b32 must be up to 32 digits long");
      return binary_literals_internals::
                binary_struct<byte32, bits...>::value;
    }
  }
} 
它是如何工作的...
首先,我们在名为 binary 的命名空间内定义一切,并首先介绍几个类型别名:byte8、byte16 和 byte32。这些代表 8 位、16 位和 32 位的整型,正如其名称所暗示的。
上一个章节中的实现使我们能够定义形式为 1010_b8(十进制值为 10 的 byte8 值)或 000010101100_b16(十进制值为 2130496 的 byte16 值)的二进制字面量。然而,我们想确保我们不会超过每种类型的数字数量。换句话说,如 111100001_b8 这样的值应该是非法的,并且编译器应该产生错误。
字面量运算符模板定义在一个嵌套的命名空间中,称为 binary_literal_internals。这是一种良好的实践,以避免与其他命名空间中的其他字面量运算符发生名称冲突。如果发生类似情况,您可以选择在适当的范围内使用适当的命名空间(例如,一个函数或块中的一个命名空间和另一个函数或块中的另一个命名空间)。
三个字面量运算符模板非常相似。唯一不同的是它们的名称(_b8、_16 和 _b32)、返回类型(byte8、byte16 和 byte32)以及在静态断言中的条件,该断言检查数字的位数。
我们将在后续的菜谱中探讨变长模板和模板递归的细节;然而,为了更好地理解,以下是这种特定实现的工作原理:bits 是一个模板参数包,它不是一个单一值,而是模板可以实例化的所有值。例如,如果我们考虑字面量 1010_b8,那么字面量运算符模板将被实例化为 operator"" _b8<'1', '0', '1', '0'>()。在计算二进制值之前,我们检查字面量的位数。对于 _b8,位数不得超过八个(包括任何尾随零)。同样,对于 _b16 应该是 16 位,对于 _b32 应该是 32 位。为此,我们使用 sizeof... 操作符,它返回参数包中的元素数量(在这种情况下,bits)。
如果数字位数正确,我们可以继续展开参数包,并递归地计算二进制字面量表示的十进制值。这是通过一个额外的类模板及其特化来完成的。这些模板定义在另一个嵌套的命名空间中,称为 binary_literals_internals。这也是一种良好的实践,因为它隐藏(除非通过适当的限定符)了从客户端隐藏实现细节(除非显式使用 using namespace 指令使它们对当前命名空间可用)。
尽管这看起来像是递归,但这并不是真正的运行时递归。这是因为,在编译器展开并从模板生成代码之后,我们最终得到的是对具有不同参数数量的重载函数的调用。这在上面的菜谱“编写具有可变数量参数的函数模板”的 第三章 中有解释。
binary_struct 类模板有一个模板类型 CharT 用于函数的返回类型(我们需要这个,因为我们的字面量运算符模板应该返回 byte8、byte16 或 byte32),以及一个参数包:
template <typename CharT, char... bits>
struct binary_struct; 
该类模板有几种特化可用,带有参数包分解(你可以在第三章的编写具有可变参数数量的函数模板食谱中了解更多)。当包的第一个数字是'0'时,计算值保持不变,我们继续展开包的其余部分。如果包的第一个数字是'1',则新值是 1,左移包剩余部分的位数,或者包剩余部分的值:
template <typename CharT, char... bits>
struct binary_struct<CharT, '0', bits...>
{
  static constexpr CharT value{ binary_struct<CharT, bits...>::value };
};
template <typename CharT, char... bits>
struct binary_struct<CharT, '1', bits...>
{
  static constexpr CharT value{
    static_cast<CharT>(1 << sizeof...(bits)) |
    binary_struct<CharT, bits...>::value };
}; 
最后一个特化处理了包为空的情况;在这种情况下,我们返回 0:
template <typename CharT>
struct binary_struct<CharT>
{
  static constexpr CharT value{ 0 };
}; 
在定义了这些辅助类之后,我们可以按照预期实现byte8、byte16和byte32二进制字面量。请注意,我们需要将binary_literals命名空间的内容引入当前命名空间,以便使用字面量操作符模板:
using namespace binary;
using namespace binary_literals;
auto b1 = 1010_b8;
auto b2 = 101010101010_b16;
auto b3 = 101010101010101010101010_b32; 
以下定义会触发编译器错误:
// binary literal b8 must be up to 8 digits long
auto b4 = 0011111111_b8;
// binary literal b16 must be up to 16 digits long
auto b5 = 001111111111111111_b16;
// binary literal b32 must be up to 32 digits long
auto b6 = 0011111111111111111111111111111111_b32; 
原因是static_assert中的条件未满足。在字面量操作符之前的字符序列长度大于预期,在所有情况下都是如此。
相关内容
- 
使用原始字符串字面量来避免转义字符,了解如何定义不需要转义特殊字符的字符串字面量 
- 
创建烹饪用户定义字面量,了解如何创建用户定义类型的字面量 
- 
第三章,编写具有可变参数数量的函数模板,了解变长模板如何使我们能够编写可以接受任意数量参数的函数 
- 
第一章,创建类型别名和别名模板,了解类型别名 
使用原始字符串字面量来避免转义字符
字符串可能包含特殊字符,例如不可打印字符(换行符、水平制表符和垂直制表符等)、字符串和字符分隔符(双引号和单引号),或者任意的八进制、十六进制或 Unicode 值。这些特殊字符通过以反斜杠开头的转义序列引入,后跟字符(例如 ' 和 ")、其指定的字母(例如 n 表示换行,t 表示水平制表符),或其值(例如八进制 050、十六进制 XF7 或 Unicode U16F0)。因此,反斜杠字符本身必须通过另一个反斜杠字符进行转义。这导致了一些更复杂的字面量字符串,这些字符串可能难以阅读。
为了避免转义字符,C++11 引入了原始字符串字面量,它不处理转义序列。在本食谱中,你将学习如何使用原始字符串字面量的各种形式。
准备工作
在本食谱中,以及本书的其余部分,我将使用s后缀来定义basic_string字面量。这在本章的创建烹饪用户定义字面量食谱中已有介绍。
如何做到...
为了避免转义字符,可以使用以下形式之一定义字符串字面量:
- 
R"( literal )"作为默认形式:auto filename {R"(C:\Users\Marius\Documents\)"s}; auto pattern {R"((\w+)=(\d+)$)"s}; auto sqlselect { R"(SELECT * FROM Books WHERE Publisher='Packtpub' ORDER BY PubDate DESC)"s};
- 
R"delimiter( literal )delimiter",其中delimiter是任何不包括括号、反斜杠和空格的字符序列,而literal是任何字符序列,限制是不能包含关闭序列)delimiter"。以下是一个以!!作为分隔符的示例:auto text{ R"!!(This text contains both "( and )".)!!"s }; std::cout << text << '\n';
它是如何工作的...
当使用字符串文本文本时,不会处理转义,实际字符串内容将写入分隔符之间(换句话说,所见即所得)。以下示例显示了看起来相同的原始文本文本;然而,第二个仍然包含转义字符。由于在字符串文本文本的情况下不会处理这些转义字符,它们将以原样打印到输出中:
auto filename1 {R"(C:\Users\Marius\Documents\)"s};
auto filename2 {R"(C:\\Users\\Marius\\Documents\\)"s};
// prints C:\Users\Marius\Documents\
std::cout << filename1 << '\n';
// prints C:\\Users\\Marius\\Documents\\
std::cout << filename2 << '\n'; 
如果文本必须包含 )" 序列,则必须使用不同的分隔符,在 R"delimiter( literal )delimiter" 形式中。根据标准,分隔符中可能包含的字符可以如下所示:
基本源字符集的任何成员,除了:空格、左括号(右括号),反斜杠 \,以及代表水平制表符、垂直制表符、换页符和换行符的控制字符。
原始字符串文本文本可以由 L、u8、u 和 U 之一前缀,分别表示宽字符串、UTF-8、UTF-16 或 UTF-32 字符串文本文本。以下是一些此类字符串文本文本的示例:
auto t1{ LR"(text)"  };  // const wchar_t*
auto t2{ u8R"(text)" };  // const char* until C++20
 // const char8_t* in C++20
auto t3{ uR"(text)"  };  // const char16_t*
auto t4{ UR"(text)"  };  // const char32_t*
auto t5{ LR"(text)"s  }; // std::wstring
auto t6{ u8R"(text)"s }; // std::string until C++20
 // std::u8string in C++20
auto t7{ uR"(text)"s  }; // std::u16string
auto t8{ UR"(text)"s  }; // std::u32string 
注意,字符串末尾存在后缀 ""s 会使编译器推断类型为各种字符串类,而不是字符数组。
参见
- 
创建用户定义的文本文本,以了解如何创建用户定义类型的文本文本 
- 
了解各种字符和字符串类型,以了解更多关于字符和字符串类型、文本文本前缀以及 C++20 中对 u8前缀的更改
创建字符串辅助库
标准库中的字符串类型是一般用途的实现,缺乏许多有用的方法,例如更改大小写、修剪、分割等,这些可能满足不同开发者的需求。存在提供丰富字符串功能的第三方库。然而,在本配方中,我们将查看实现几个简单但有用的方法,这些方法你可能在实践中经常需要。目的是了解字符串方法和标准通用算法如何用于字符串操作,同时也是为了有一个可重用的代码库,可以在你的应用程序中使用。
在本配方中,我们将实现一个小型字符串实用程序库,它将提供以下功能的函数:
- 
将字符串转换为小写或大写 
- 
反转字符串 
- 
从字符串的开始和/或末尾修剪空白字符 
- 
从字符串的开始和/或末尾删除特定的字符集 
- 
从字符串中删除任何位置的字符出现 
- 
使用特定分隔符对字符串进行标记化 
在开始实现之前,让我们看看一些先决条件。
准备工作
我们将要实现的字符串库应该与所有标准字符串类型一起工作——也就是说,std::string、std::wstring、std::u16string 和 std::u32string。
为了避免指定像 std::basic_string<CharT, std::char_traits<CharT>, std::allocator<CharT>> 这样长的名称,我们将使用以下别名模板来表示字符串和字符串流:
template <typename CharT>
using tstring =
  std::basic_string<CharT, std::char_traits<CharT>,
                    std::allocator<CharT>>;
template <typename CharT>
using tstringstream =
  std::basic_stringstream<CharT, std::char_traits<CharT>,
                          std::allocator<CharT>>; 
要实现这些字符串辅助函数,我们需要包含 <string> 头文件以使用字符串,以及 <algorithm> 头文件以使用我们将使用的通用标准算法。
在本食谱的所有示例中,我们将使用 C++14 的标准用户定义字面量运算符,对于字符串,我们需要显式使用 std::string_literals 命名空间。
如何做...
- 
要将字符串转换为小写或大写,请使用通用算法 std::transform()将tolower()或toupper()函数应用于字符串中的字符:template<typename CharT> inline tstring<CharT> to_upper(tstring<CharT> text) { std::transform(std::begin(text), std::end(text), std::begin(text), toupper); return text; } template<typename CharT> inline tstring<CharT> to_lower(tstring<CharT> text) { std::transform(std::begin(text), std::end(text), std::begin(text), tolower); return text; }
- 
要反转字符串,请使用通用算法 std::reverse():template<typename CharT> inline tstring<CharT> reverse(tstring<CharT> text) { std::reverse(std::begin(text), std::end(text)); return text; }
- 
要在字符串的开始、结束或两者之间修剪,请使用 std::basic_string方法find_first_not_of()和find_last_not_of():template<typename CharT> inline tstring<CharT> trim(tstring<CharT> const & text) { auto first{ text.find_first_not_of(' ') }; auto last{ text.find_last_not_of(' ') }; return text.substr(first, (last - first + 1)); } template<typename CharT> inline tstring<CharT> trimleft(tstring<CharT> const & text) { auto first{ text.find_first_not_of(' ') }; return text.substr(first, text.size() - first); } template<typename CharT> inline tstring<CharT> trimright(tstring<CharT> const & text) { auto last{ text.find_last_not_of(' ') }; return text.substr(0, last + 1); }
- 
要从字符串中修剪给定集合中的字符,请使用 std::basic_string方法的重载find_first_not_of()和find_last_not_of(),这些方法接受一个字符串参数,该参数定义了要查找的字符集:template<typename CharT> inline tstring<CharT> trim(tstring<CharT> const & text, tstring<CharT> const & chars) { auto first{ text.find_first_not_of(chars) }; auto last{ text.find_last_not_of(chars) }; return text.substr(first, (last - first + 1)); } template<typename CharT> inline tstring<CharT> trimleft(tstring<CharT> const & text, tstring<CharT> const & chars) { auto first{ text.find_first_not_of(chars) }; return text.substr(first, text.size() - first); } template<typename CharT> inline tstring<CharT> trimright(tstring<CharT> const &text, tstring<CharT> const &chars) { auto last{ text.find_last_not_of(chars) }; return text.substr(0, last + 1); }
- 
要从字符串中删除字符,请使用 std::remove_if()和std::basic_string::erase():template<typename CharT> inline tstring<CharT> remove(tstring<CharT> text, CharT const ch) { auto start = std::remove_if( std::begin(text), std::end(text), = {return c == ch; }); text.erase(start, std::end(text)); return text; }
- 
要根据指定的分隔符拆分字符串,请使用 std::getline()从初始化为字符串内容的std::basic_stringstream中读取。从流中提取的标记被推入字符串的向量中:template<typename CharT> inline std::vector<tstring<CharT>> split (tstring<CharT> text, CharT const delimiter) { auto sstr = tstringstream<CharT>{ text }; auto tokens = std::vector<tstring<CharT>>{}; auto token = tstring<CharT>{}; while (std::getline(sstr, token, delimiter)) { if (!token.empty()) tokens.push_back(token); } return tokens; }
它是如何工作的...
要实现库中的实用函数,我们有两种选择:
- 
函数将修改通过引用传递的字符串 
- 
函数将不会修改原始字符串,而是返回一个新的字符串 
第二种选择的优势在于它保留了原始字符串,这在许多情况下可能很有用。否则,在这些情况下,你首先必须复制字符串并修改副本。本食谱中提供的实现采用了第二种方法。
在 如何做... 部分中,我们首先实现的函数是 to_upper() 和 to_lower()。这些函数将字符串的内容更改为大写或小写。实现这一点最简单的方法是使用 std::transform() 标准算法。这是一个通用算法,它将一个函数应用于范围(由开始和结束迭代器定义)中的每个元素,并将结果存储在另一个范围中,其中只需指定开始迭代器。输出范围可以是输入范围,这正是我们用来转换字符串的方法。应用的功能是 toupper() 或 tolower():
auto ut{ string_library::to_upper("this is not UPPERCASE"s) };
// ut = "THIS IS NOT UPPERCASE"
auto lt{ string_library::to_lower("THIS IS NOT lowercase"s) };
// lt = "this is not lowercase" 
我们考虑的下一个函数是reverse(),正如其名称所暗示的,它反转字符串的内容。为此,我们使用了std::reverse()标准算法。这个通用算法反转由开始和结束迭代器定义的范围中的元素:
auto rt{string_library::reverse("cookbook"s)}; // rt = "koobkooc" 
当涉及到修剪时,字符串可以在开头、结尾或两边进行修剪。因此,我们实现了三个不同的函数:trim()用于修剪两端,trimleft()用于修剪字符串的开头,trimright()用于修剪字符串的结尾。函数的第一个版本只修剪空格。为了找到正确的修剪部分,我们使用了std::basic_string的find_first_not_of()和find_last_not_of()方法。这些方法返回字符串中不是指定字符的第一个和最后一个字符。随后,对std::basic_string的substr()方法的调用返回一个新的字符串。substr()方法接受字符串中的索引和要复制到新字符串中的元素数量:
auto text1{"   this is an example   "s};
auto t1{ string_library::trim(text1) };
// t1 = "this is an example"
auto t2{ string_library::trimleft(text1) };
// t2 = "this is an example   "
auto t3{ string_library::trimright(text1) };
// t3 = "   this is an example" 
有时,从字符串中删除其他字符和空格可能很有用。为了做到这一点,我们为修剪函数提供了重载,这些重载指定了要删除的字符集。该集合也指定为一个字符串。实现与之前的一个非常相似,因为find_first_not_of()和find_last_not_of()都有接受包含要排除搜索的字符的字符串的重载:
auto chars1{" !%\n\r"s};
auto text3{"!!  this % needs a lot\rof trimming  !\n"s};
auto t7{ string_library::trim(text3, chars1) };
// t7 = "this % needs a lot\rof trimming"
auto t8{ string_library::trimleft(text3, chars1) };
// t8 = "this % needs a lot\rof trimming  !\n"
auto t9{ string_library::trimright(text3, chars1) };
// t9 = "!!  this % needs a lot\rof trimming" 
如果需要从字符串的任何部分删除字符,修剪方法就无济于事,因为它们只处理字符串开头和结尾的连续字符序列。然而,为了实现这一点,我们实现了一个简单的remove()方法。这个方法使用了std::remove_if()标准算法。
std::remove()和std::remove_if()的工作方式可能一开始并不直观。它们通过重新排列范围的内容(使用移动赋值)从由第一个和最后一个迭代器定义的范围中删除满足条件的元素。需要删除的元素被放置在范围的末尾,函数返回一个指向表示已删除元素的范围内第一个元素的迭代器。这个迭代器基本上定义了修改后的范围的新末尾。如果没有元素被删除,则返回的迭代器是原始范围的末尾迭代器。然后使用此返回迭代器的值调用std::basic_string::erase()方法,该方法实际上删除由两个迭代器定义的字符串的内容。在我们的情况下,这两个迭代器是std::remove_if()返回的迭代器和字符串的末尾:
auto text4{"must remove all * from text**"s};
auto t10{ string_library::remove(text4, '*') };
// t10 = "must remove all  from text"
auto t11{ string_library::remove(text4, '!') };
// t11 = "must remove all * from text**" 
我们最后实现的 split() 方法,根据指定的分隔符将字符串的内容分割。有各种实现这种功能的方法。在我们的实现中,我们使用了 std::getline()。这个函数从输入流中读取字符,直到找到指定的分隔符,并将字符放入字符串中。
在开始从输入缓冲区读取之前,它会在输出字符串上调用 erase() 来清除其内容。在循环中调用此方法会产生放置在向量中的标记。在我们的实现中,从结果集中跳过了空标记:
auto text5{"this text will be split   "s};
auto tokens1{ string_library::split(text5, ' ') };
// tokens1 = {"this", "text", "will", "be", "split"}
auto tokens2{ string_library::split(""s, ' ') };
// tokens2 = {} 
这里展示了两个文本分割的示例。在第一个示例中,text5 变量的文本被分割成单词,如前所述,空标记被忽略。在第二个示例中,分割空字符串会产生一个空的 token 向量。
更多内容…
在标准库的最近版本中,为 std::basic_string 类模板添加了几个辅助方法,以帮助用户避免定义一些广泛使用的函数。这些方法在以下表中列出:
| 函数 | C++版本 | 描述 | 
|---|---|---|
| starts_with | C++20 | 检查字符串是否以指定的前缀开头 | 
| ends_with | C++20 | 检查字符串是否以指定的后缀结尾 | 
| contains | C++23 | 检查字符串是否包含指定的子字符串 | 
表 2.13:广泛使用的字符串操作的新基本 _string 成员函数
这些成员函数的使用在以下代码片段中得到了示例:
std::string text = "The Lord of the Rings";
if(text.starts_with("The")) {}
if(text.ends_with("Rings")) {}
if(text.contains("Lord")) {} 
相关内容
- 
创建用户定义的 cooked 文本字面量,以了解如何创建用户定义类型的字面量 
- 
第一章,创建类型别名和别名模板,了解类型别名的知识 
使用正则表达式验证字符串的格式
正则表达式是一种用于在文本中进行模式匹配和替换的语言。C++11 通过 <regex> 头文件中提供的一组类、算法和迭代器,在标准库中提供了对正则表达式的支持。在本食谱中,我们将学习如何使用正则表达式来验证字符串是否与某个模式匹配(例如验证电子邮件或 IP 地址格式)。
准备工作
在本食谱中,我们将在必要时解释我们所使用的正则表达式的细节。然而,您至少应该对正则表达式有一些基本了解,以便使用 C++标准库中的正则表达式。正则表达式语法和标准的描述超出了本书的目的;如果您不熟悉正则表达式,建议在继续阅读本食谱和其他专注于正则表达式的食谱之前,了解更多关于正则表达式的信息。学习、构建和调试正则表达式的良好在线资源可以在regexr.com和regex101.com找到。
如何操作...
为了验证一个字符串是否与正则表达式匹配,执行以下步骤:
- 
包含 <regex>和<string>头文件以及std::string_literals命名空间,用于字符串的标准用户定义文字(自 C++14 开始添加):#include <regex> #include <string> using namespace std::string_literals;
- 
使用原始字符串文字来指定正则表达式,以避免转义反斜杠(这可能会频繁发生)。以下正则表达式验证大多数电子邮件格式: auto pattern {R"(^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$)"s};
- 
创建一个 std::regex/std::wregex对象(取决于所使用的字符集)来封装正则表达式:auto rx = std::regex{pattern};
- 
要忽略大小写或指定其他解析选项,请使用具有额外正则表达式标志参数的重载构造函数: auto rx = std::regex{pattern, std::regex_constants::icase};
- 
使用 std::regex_match()将正则表达式与整个字符串匹配:auto valid = std::regex_match("marius@domain.com"s, rx);
它是如何工作的...
考虑到验证电子邮件地址格式的难题,尽管这看起来可能是一个简单的问题,但在实践中,很难找到一个简单的正则表达式来涵盖所有有效的电子邮件格式可能的情况。在这个菜谱中,我们不会尝试找到那个终极的正则表达式,而是应用一个足够大多数情况的正则表达式。我们将用于此目的的正则表达式如下:
^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$ 
以下表格解释了正则表达式的结构:
| 部分 | 描述 | 
|---|---|
| ^ | 字符串的开始 | 
| [A-Z0-9._%+-]+ | 至少一个字符是 uppercase letter A-Z,digit0-9,或者是.,%,+, 或-,这代表电子邮件地址的本地部分 | 
| @ | 字符 @ | 
| [A-Z0-9.-]+ | 至少一个字符是 uppercase letter A-Z,digit0-9,或者是符号.或-,这代表域名部分的主机名 | 
| \. | 分隔域名主机名和标签的点 | 
| [A-Z]{2,} | 域名的 DNS 标签,可以包含 2 到 63 个字符 | 
| ` | 部分 | 
| --- | --- | 
| ^ | 字符串的开始 | 
| [A-Z0-9._%+-]+ | 至少一个字符是 uppercase letter A-Z,digit0-9,或者是.,%,+, 或-,这代表电子邮件地址的本地部分 | 
| @ | 字符 @ | 
| [A-Z0-9.-]+ | 至少一个字符是 uppercase letter A-Z,digit0-9,或者是符号.或-,这代表域名部分的主机名 | 
| \. | 分隔域名主机名和标签的点 | 
| [A-Z]{2,} | 域名的 DNS 标签,可以包含 2 到 63 个字符 | 
| 字符串的结束 | 
表 2.14:之前定义的正则表达式的结构
请记住,在实践中,域名由一个主机名后跟一个点分隔的 DNS 标签列表组成。例如,localhost,gmail.com 和 yahoo.co.uk。我们正在使用的这个正则表达式不匹配没有 DNS 标签的域名,如 localhost(例如,root@localhost 是一个有效的电子邮件地址)。域名也可以是一个括号中指定的 IP 地址,如 [192.168.100.11](如 john.doe@[192.168.100.11])。包含此类域名的电子邮件地址将不会匹配之前定义的正则表达式。尽管这些相当罕见的格式不会匹配,但正则表达式可以涵盖大多数电子邮件格式。
本章示例中的正则表达式仅用于教学目的,并不打算在生产代码中直接使用。如前所述,此示例并不涵盖所有可能的电子邮件格式。
我们首先包含了必要的头文件——即 <regex> 用于正则表达式和 <string> 用于字符串。下面的 is_valid_email() 函数(基本上包含 How to do it... 部分的示例),接受一个表示电子邮件地址的字符串,并返回一个布尔值,指示电子邮件是否有有效的格式。
我们首先构造一个 std::regex 对象来封装由原始字符串字面量指示的正则表达式。使用原始字符串字面量是有帮助的,因为它避免了转义反斜杠,这在正则表达式中也被用作转义字符。然后函数调用 std::regex_match(),传递输入文本和正则表达式:
bool is_valid_email_format(std::string const & email)
{
  auto pattern {R"(^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$)"s};
  auto rx = std::regex{ pattern };
  return std::regex_match(email, rx);
} 
std::regex_match() 方法尝试将正则表达式与整个字符串进行匹配。如果成功,它返回 true;否则,它返回 false:
auto ltest = [](std::string const & email)
{
  std::cout << std::setw(30) << std::left
            << email << " : "
            << (is_valid_email_format(email) ?
                "valid format" : "invalid format")
            << '\n';
};
ltest("JOHN.DOE@DOMAIN.COM"s);         // valid format
ltest("JOHNDOE@DOMAIL.CO.UK"s);        // valid format
ltest("JOHNDOE@DOMAIL.INFO"s);         // valid format
ltest("J.O.H.N_D.O.E@DOMAIN.INFO"s);   // valid format
ltest("ROOT@LOCALHOST"s);              // invalid format
ltest("john.doe@domain.com"s);         // invalid format 
在这个简单的测试中,唯一不匹配正则表达式的电子邮件是 ROOT@LOCALHOST 和 john.doe@domain.com。第一个包含没有点前缀的 DNS 标签的域名,这种情况在正则表达式中没有涵盖。第二个只包含小写字母,而在正则表达式中,本地部分和域名有效的字符集是 uppercase 字母,A 到 Z。
我们可以不通过添加额外的有效字符(如 [A-Za-z0-9._%+-])来复杂化正则表达式,而是指定匹配可以忽略大小写。这可以通过向 std::basic_regex 类的构造函数添加一个额外的参数来实现。为此目的而定义的可用常量位于 regex_constants 命名空间中。
以下对 is_valid_email_format() 的微小更改将使其忽略大小写,并允许电子邮件地址包含大小写字母都能正确匹配正则表达式:
bool is_valid_email_format(std::string const & email)
{
  auto rx = std::regex{
    R"(^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$)"s,
    std::regex_constants::icase};
  return std::regex_match(email, rx);
} 
这个 is_valid_email_format() 函数相当简单,如果将正则表达式作为参数提供,并附带要匹配的文本,它可以用于匹配任何内容。然而,如果能通过一个函数处理不仅多字节字符串(std::string),而且宽字符串(std::wstring),那就更好了。这可以通过创建一个函数模板来实现,其中字符类型作为模板参数提供:
template <typename CharT>
using tstring = std::basic_string<CharT, std::char_traits<CharT>,
                                  std::allocator<CharT>>;
template <typename CharT>
bool is_valid_format(tstring<CharT> const & pattern,
                     tstring<CharT> const & text)
{
  auto rx = std::basic_regex<CharT>{ pattern, std::regex_constants::icase };
  return std::regex_match(text, rx);
} 
我们首先为 std::basic_string 创建一个别名模板,以简化其使用。新的 is_valid_format() 函数是一个与我们的 is_valid_email() 实现非常相似的函数模板。然而,我们现在使用 std::basic_regex<CharT> 而不是 std::regex 的 typedef,后者是 std::basic_regex<char>,模式作为第一个参数提供。我们现在实现了一个名为 is_valid_email_format_w() 的新函数,用于宽字符串,它依赖于这个函数模板。然而,这个函数模板可以被重用于实现其他验证,例如,检查车牌号是否有特定的格式:
bool is_valid_email_format_w(std::wstring const & text)
{
  return is_valid_format(
    LR"(^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$)"s,
    text);
}
auto ltest2 = [](auto const & email)
{
  std::wcout << std::setw(30) << std::left
     << email << L" : "
     << (is_valid_email_format_w(email) ? L"valid" : L"invalid")
     << '\n';
};
ltest2(L"JOHN.DOE@DOMAIN.COM"s);       // valid
ltest2(L"JOHNDOE@DOMAIL.CO.UK"s);      // valid
ltest2(L"JOHNDOE@DOMAIL.INFO"s);       // valid
ltest2(L"J.O.H.N_D.O.E@DOMAIN.INFO"s); // valid
ltest2(L"ROOT@LOCALHOST"s);            // invalid
ltest2(L"john.doe@domain.com"s);       // valid 
在这里展示的所有示例中,唯一不符合预期的是 ROOT@LOCALHOST。
std::regex_match() 方法实际上有几个重载版本,其中一些版本有一个参数是 std::match_results 对象的引用,用于存储匹配结果。如果没有匹配,则 std::match_results 为空,其大小为 0。否则,如果存在匹配,则 std::match_results 对象不为空,其大小为 1,加上匹配的子表达式数量。
以下版本的函数使用了提到的重载,并将匹配的子表达式返回在一个 std::smatch 对象中。请注意,正则表达式已更改,因为定义了三个捕获组——一个用于本地部分,一个用于域名的主机部分,一个用于 DNS 标签。如果匹配成功,则 std::smatch 对象将包含四个子匹配对象:第一个(索引 0)匹配整个字符串,第二个(索引 1)匹配第一个捕获组(本地部分),第三个(索引 2)匹配第二个捕获组(主机名),第四个(索引 3)匹配第三个也是最后一个捕获组(DNS 标签)。结果以元组形式返回,其中第一个项目实际上表示成功或失败:
std::tuple<bool, std::string, std::string, std::string>
is_valid_email_format_with_result(std::string const & email)
{
  auto rx = std::regex{
    R"(^([A-Z0-9._%+-]+)@([A-Z0-9.-]+)\.([A-Z]{2,})$)"s,
    std::regex_constants::icase };
  auto result = std::smatch{};
  auto success = std::regex_match(email, result, rx);
  return std::make_tuple(
    success,
    success ? result[1].str() : ""s,
    success ? result[2].str() : ""s,
    success ? result[3].str() : ""s);
} 
在前面的代码之后,我们使用 C++17 结构化绑定将元组的内容解包到命名变量中:
auto ltest3 = [](std::string const & email)
{
  auto [valid, localpart, hostname, dnslabel] =
    is_valid_email_format_with_result(email);
  std::cout << std::setw(30) << std::left
     << email << " : "
     << std::setw(10) << (valid ? "valid" : "invalid")
     << "local=" << localpart
     << ";domain=" << hostname
     << ";dns=" << dnslabel
     << '\n';
};
ltest3("JOHN.DOE@DOMAIN.COM"s);
ltest3("JOHNDOE@DOMAIL.CO.UK"s);
ltest3("JOHNDOE@DOMAIL.INFO"s);
ltest3("J.O.H.N_D.O.E@DOMAIN.INFO"s);
ltest3("ROOT@LOCALHOST"s);
ltest3("john.doe@domain.com"s); 
程序的输出将如下所示:

图 2.8:测试输出
还有更多...
正则表达式有多种版本,C++标准库支持其中的六种:ECMAScript、基本 POSIX、扩展 POSIX、awk、grep 和 egrep(带有 -E 选项的 grep)。默认使用的语法是 ECMAScript,若要使用其他语法,必须在定义正则表达式时显式指定语法。您可以在en.cppreference.com/w/cpp/regex/syntax_option_type了解更多支持的语法选项。除了指定语法外,还可以指定解析选项,例如忽略大小写进行匹配。
标准库提供的类和算法比我们之前看到的要多。库中可用的主要类如下(所有这些都是类模板,并且为了方便,为不同的字符类型提供了 typedefs):
- 
类模板 std::basic_regex定义了正则表达式对象:typedef basic_regex<char> regex; typedef basic_regex<wchar_t> wregex;
- 
类模板 std::sub_match表示匹配捕获组的字符序列;这个类实际上是std::pair的派生类,其first和second成员表示匹配序列中第一个和最后一个字符的迭代器。如果没有匹配序列,则两个迭代器相等:typedef sub_match<const char *> csub_match; typedef sub_match<const wchar_t *> wcsub_match; typedef sub_match<string::const_iterator> ssub_match; typedef sub_match<wstring::const_iterator> wssub_match;
- 
类模板 std::match_results是匹配结果的集合;第一个元素始终是目标中的完整匹配,而其他元素是子表达式的匹配:typedef match_results<const char *> cmatch; typedef match_results<const wchar_t *> wcmatch; typedef match_results<string::const_iterator> smatch; typedef match_results<wstring::const_iterator> wsmatch;
正则表达式标准库中可用的算法如下:
- 
std::regex_match(): 这尝试将一个正则表达式(由一个std::basic_regex实例表示)与整个字符串进行匹配。
- 
std::regex_search(): 这尝试将一个正则表达式(由一个std::basic_regex实例表示)与字符串的一部分(包括整个字符串)进行匹配。
- 
std::regex_replace(): 这根据指定的格式替换正则表达式中的匹配项。
正则表达式标准库中可用的迭代器如下:
- 
std::regex_iterator: 一个常量前向迭代器,用于遍历字符串中模式的匹配项。它有一个指向std::basic_regex的指针,该指针必须存在于迭代器被销毁之前。在创建和递增时,迭代器调用std::regex_search()并存储算法返回的std::match_results对象的副本。
- 
std::regex_token_iterator: 一个常量前向迭代器,用于遍历字符串中每个正则表达式匹配项的子匹配。内部,它使用std::regex_iterator来遍历子匹配。由于它存储了一个指向std::basic_regex实例的指针,因此正则表达式对象必须存在于迭代器被销毁之前。
应该指出的是,标准正则表达式库的性能比其他实现(如 Boost.Regex)较差,并且不支持 Unicode。此外,可以争论说 API 本身使用起来比较繁琐。然而,使用标准库的好处是避免了额外的依赖。
参见
- 
使用正则表达式解析字符串内容,学习如何在文本中执行多个模式的匹配 
- 
使用正则表达式替换字符串内容,以了解如何使用正则表达式执行文本替换 
- 
第一章,使用结构化绑定处理多返回值,学习如何将变量绑定到初始化表达式中的子对象或元素 
使用正则表达式解析字符串内容
在前面的菜谱中,我们探讨了如何使用std::regex_match()来验证字符串内容是否与特定格式匹配。该库提供了一个名为std::regex_search()的另一个算法,它将正则表达式与字符串的任何部分(而不是整个字符串)进行匹配,正如regex_match()所做的那样。然而,这个函数不允许我们在输入字符串中搜索正则表达式的所有出现。为此,我们需要使用库中可用的一个迭代器类。
在这个菜谱中,你将学习如何使用正则表达式解析字符串的内容。为此,我们将考虑解析包含名称-值对的文本文件的问题。每个这样的对都在不同的行上定义,格式为 name = value,但以 # 开头的行代表注释,必须忽略。以下是一个示例:
#remove # to uncomment a line
timeout=120
server = 127.0.0.1
#retrycount=3 
在查看实现细节之前,让我们考虑一些先决条件。
准备工作
关于 C++11 中正则表达式支持的详细信息,请参阅本章前面提到的 使用正则表达式验证字符串格式 菜谱。进行此菜谱需要基本了解正则表达式。
在以下示例中,text 是一个定义为以下内容的变量:
auto text {
  R"(
    #remove # to uncomment a line
    timeout=120
    server = 127.0.0.1
    #retrycount=3
  )"s}; 
这唯一的目的是为了简化我们的代码片段,尽管在现实世界的例子中,你可能会从文件或其他来源读取文本。
如何做到这一点...
为了通过字符串搜索正则表达式的出现,你应该这样做:
- 
包含头文件 <regex>和<string>以及命名空间std::string_literals以支持字符串的标准用户定义字面量(自 C++14 开始添加):#include <regex> #include <string> using namespace std::string_literals;
- 
使用原始字符串字面量(或对于 std::wregex使用原始宽字符串字面量)来指定正则表达式,以避免转义反斜杠(这可能会频繁发生)。以下正则表达式验证了之前提出的文件格式:auto pattern {R"(^(?!#)(\w+)\s*=\s*([\w\d]+[\w\d._,\-:]*)$)"s};
- 
创建一个 std::regex/std::wregex对象(根据使用的字符集而定)来封装正则表达式:auto rx = std::regex{pattern};
- 
要在给定的文本中搜索正则表达式的第一个出现,请使用通用算法 std::regex_search()(示例 1):auto match = std::smatch{}; if (std::regex_search(text, match, rx)) { std::cout << match[1] << '=' << match[2] << '\n'; }
- 
要在给定的文本中找到所有正则表达式的出现,请使用迭代器 std::regex_iterator(示例 2):auto end = std::sregex_iterator{}; for (auto it=std::sregex_iterator{ std::begin(text), std::end(text), rx }; it != end; ++it) { std::cout << '\'' << (*it)[1] << "'='" << (*it)[2] << '\'' << '\n'; }
- 
要迭代通过一个匹配的所有子表达式,请使用迭代器 std::regex_token_iterator(示例 3):auto end = std::sregex_token_iterator{}; for (auto it = std::sregex_token_iterator{ std::begin(text), std::end(text), rx }; it != end; ++it) { std::cout << *it << '\n'; }
它是如何工作的...
一个可以解析前面显示的输入文件的简单正则表达式可能看起来像这样:
^(?!#)(\w+)\s*=\s*([\w\d]+[\w\d._,\-:]*)$ 
这个正则表达式旨在忽略所有以 # 开头的行;对于不以 # 开头的行,匹配一个名称后跟等号,然后是一个值,该值可以由字母数字字符和几个其他字符(下划线、点、逗号等)组成。这个正则表达式的确切含义如下所述:
| 部分 | 描述 | 
|---|---|
| ^ | 行首 | 
| (?!#) | 一个负向前瞻,确保不可能匹配 #字符 | 
| (\w)+ | 表示至少一个单词字符的标识符的捕获组 | 
| \s* | 任意空白字符 | 
| = | 等号 | 
| \s* | 任意空白字符 | 
| ([\w\d]+[\w\d._,\-:]*) | 表示以字母数字字符开头的值,但也可以包含点、逗号、反斜杠、连字符、冒号或下划线等字符的捕获组 | 
| ` | 部分 | 
| --- | --- | 
| ^ | 行首 | 
| (?!#) | 一个负向前瞻,确保不可能匹配 #字符 | 
| (\w)+ | 表示至少一个单词字符的标识符的捕获组 | 
| \s* | 任意空白字符 | 
| = | 等号 | 
| \s* | 任意空白字符 | 
| ([\w\d]+[\w\d._,\-:]*) | 表示以字母数字字符开头的值,但也可以包含点、逗号、反斜杠、连字符、冒号或下划线等字符的捕获组 | 
| 行尾 | 
表 2.15:分解正则表达式
我们可以使用 std::regex_search() 在输入文本的任何位置进行搜索。此算法有几个重载版本,但通常它们的工作方式相同。您必须指定要处理的字符范围、一个输出 std::match_results 对象,该对象将包含匹配结果,以及一个表示正则表达式和匹配标志(定义搜索方式)的 std::basic_regex 对象。如果找到匹配项,函数返回 true,否则返回 false。
在上一节的第一例(见第四个列表项)中,match 是 std::smatch 的一个实例,它是 std::match_results 的一个 typedef,模板类型为 string::const_iterator。如果找到匹配项,此对象将包含所有匹配子表达式的匹配信息序列。索引 0 的子匹配始终是整个匹配。
索引 1 的子匹配是第一个匹配的子表达式,索引 2 的子匹配是第二个匹配的子表达式,依此类推。由于我们的正则表达式中有两个捕获组(即子表达式),在成功的情况下 std::match_results 将有三个子匹配。代表名称的标识符位于索引 1,等号后面的值位于索引 2。因此,此代码只打印以下内容:

图 2.9:第一个示例的输出
std::regex_search() 算法无法遍历文本中所有可能的匹配项。为了做到这一点,我们需要使用一个迭代器。std::regex_iterator 就是为了这个目的而设计的。它不仅允许遍历所有匹配项,还可以访问匹配项的所有子匹配。
迭代器实际上在构造和每次递增时都会调用 std::regex_search(),并且会记住调用结果中的 std::match_results。默认构造函数创建了一个表示序列末尾的迭代器,可以用来测试在遍历匹配时何时应该停止循环。
在上一节的第二个示例(见第五个列表项)中,我们首先创建了一个序列末尾的迭代器,然后开始遍历所有可能的匹配项。在构造时,它将调用 std::regex_match(),如果找到匹配项,我们可以通过当前迭代器访问其结果。这将一直持续到没有找到匹配项(序列的末尾)。此代码将打印以下输出:

图 2.10:第二个示例的输出
std::regex_iterator 的一个替代方案是 std::regex_token_iterator。它的工作方式与 std::regex_iterator 类似,实际上,它内部包含这样一个迭代器,但它使我们能够访问匹配中的特定子表达式。这在本节中的第三个示例(见第六个列表项)中有所展示。我们首先创建一个序列结束迭代器,然后遍历匹配直到达到序列结束。在构造函数中,我们没有指定通过迭代器访问的子表达式的索引;因此,使用默认值 0。这意味着这个程序将打印出所有匹配项:

图 2.11:第三个示例的输出
如果我们只想访问第一个子表达式(在我们的例子中意味着名称),我们只需在标记迭代器的构造函数中指定子表达式的索引,如下所示:
auto end = std::sregex_token_iterator{};
for (auto it = std::sregex_token_iterator{ std::begin(text),
               std::end(text), rx, 1 };
     it != end; ++it)
{
  std::cout << *it << '\n';
} 
这次,我们得到的输出只包含名称。如下图所示:

图 2.12:仅包含名称的输出
关于标记迭代器的一个有趣之处在于,如果子表达式的索引为 -1,它可以返回字符串的不匹配部分,在这种情况下,它返回一个 std::match_results 对象,该对象对应于最后一个匹配项和序列结束之间的字符序列:
auto end = std::sregex_token_iterator{};
for (auto it = std::sregex_token_iterator{ std::begin(text),
               std::end(text), rx, -1 };
     it != end; ++it)
{
  std::cout << *it << '\n';
} 
这个程序将输出以下内容:

图 2.13:包括空行的输出
请注意,输出中的空行对应于空标记。
参见
- 
使用正则表达式验证字符串格式,以熟悉 C++ 库对正则表达式工作的支持 
- 
使用正则表达式替换字符串内容,以学习如何在文本中执行多个模式匹配 
使用正则表达式替换字符串内容
在前两个示例中,我们探讨了如何在字符串或字符串的一部分上匹配正则表达式,并遍历匹配和子匹配。正则表达式库还支持基于正则表达式的文本替换。在本例中,我们将学习如何使用 std::regex_replace() 来执行此类文本转换。
准备工作
关于 C++11 中正则表达式支持的详细信息,请参阅本章早些时候的 使用正则表达式验证字符串格式 示例。
如何操作...
为了使用正则表达式执行文本转换,你应该执行以下操作:
- 
包含 <regex>和<string>以及std::string_literals命名空间,用于 C++14 标准的字符串用户定义文字:#include <regex> #include <string> using namespace std::string_literals;
- 
使用 std::regex_replace()算法,并将替换字符串作为第三个参数。考虑以下示例。将所有由a、b或c组成的恰好三个字符的单词替换为三个连字符:auto text{"abc aa bca ca bbbb"s}; auto rx = std::regex{ R"(\b[a|b|c]{3}\b)"s }; auto newtext = std::regex_replace(text, rx, "---"s);
- 
使用带有 $前缀的匹配标识符的std::regex_replace()算法的第三个参数。例如,将“姓氏,名字”格式的名字替换为“名字 姓氏”格式,如下所示:auto text{ "bancila, marius"s }; auto rx = std::regex{ R"((\w+),\s*(\w+))"s }; auto newtext = std::regex_replace(text, rx, "$2 $1"s);
它是如何工作的...
std::regex_replace()算法有几个具有不同类型参数的重载,但参数的意义如下:
- 
执行替换的输入字符串 
- 
一个封装用于识别要替换的字符串部分的正则表达式的 std::basic_regex对象
- 
用于替换的字符串格式 
- 
可选的匹配标志 
返回值取决于使用的重载,可以是字符串或作为参数提供的输出迭代器的副本。用于替换的字符串格式可以是简单的字符串或带有$前缀的匹配标识符:
- 
$&表示整个匹配。
- 
$1、$2、$3等表示第一个、第二个和第三个子匹配等。
- 
$`表示字符串中第一个匹配之前的部分。
- 
$'表示字符串中最后一个匹配之后的部分。
在“如何做...”部分展示的第一个示例中,初始文本包含由恰好三个a、b和c字符组成的两个单词:abc和bca。正则表达式表示在单词边界之间的恰好三个字符的表达式。这意味着像bbbb这样的子文本不会与表达式匹配。替换的结果是字符串文本将变为--- aa --- ca bbbb。
可以为std::regex_replace()算法指定额外的匹配标志。默认情况下,匹配标志是std::regex_constants::match_default,这基本上指定了 ECMAScript 作为构建正则表达式所使用的语法。如果我们想,例如,只替换第一次出现的内容,那么我们可以指定std::regex_constants::format_first_only。在下面的示例中,结果是替换后的字符串为--- aa bca ca bbbb,因为替换在找到第一个匹配后停止:
auto text{ "abc aa bca ca bbbb"s };
auto rx = std::regex{ R"(\b[a|b|c]{3}\b)"s };
auto newtext = std::regex_replace(text, rx, "---"s,
                 std::regex_constants::format_first_only); 
然而,替换字符串可以包含特殊指示符,用于整个匹配、特定的子匹配或未匹配的部分,如前所述。在“如何做...”部分展示的第二个示例中,正则表达式识别一个至少包含一个字符的单词,后面跟一个逗号和可能的空白字符,然后是另一个至少包含一个字符的单词。第一个单词应该是姓氏,而第二个单词应该是名字。替换字符串采用$2 $1格式。这是一条用于将匹配的表达式(在这个例子中是整个原始字符串)替换为另一个字符串的指令,该字符串由第二个子匹配组成,后面跟一个空格,然后是第一个子匹配。
在这种情况下,整个字符串都是匹配的。在下面的例子中,字符串内部将有多个匹配,并且它们都将被替换为指定的字符串。在这个例子中,我们正在替换以元音字母开头的单词(当然,这并不包括以元音音素开头的单词)前的不定冠词 a,将其替换为不定冠词 an:
auto text{"this is a example with a error"s};
auto rx = std::regex{R"(\ba ((a|e|i|u|o)\w+))"s};
auto newtext = std::regex_replace(text, rx, "an $1"); 
正则表达式将字母 a 识别为一个单独的单词(\b 表示单词边界,所以 \ba 表示一个只有一个字母的单词,a),后面跟着一个空格和一个至少有两个字符且以元音字母开头的单词。当识别到这样的匹配时,它将被替换为一个由固定字符串 an 后跟一个空格和匹配的第一个子表达式组成的字符串,即单词本身。在这个例子中,newtext 字符串将是 this is an example with an error。
除了子表达式的标识符($1、$2 等等)之外,还有整个匹配的标识符($&)、第一个匹配之前字符串的部分($),以及最后一个匹配之后字符串的部分($')。在最后一个例子中,我们将日期的格式从 dd.mm.yyyy 更改为 yyyy.mm.dd,同时也显示了匹配的部分:
auto text{"today is 1.06.2023!!"s};
auto rx = std::regex{R"((\d{1,2})(\.|-|/)(\d{1,2})(\.|-|/)(\d{4}))"s};
// today is 2023.06.1!!
auto newtext1 = std::regex_replace(text, rx, R"($5$4$3$2$1)");
// today is [today is ][1.06.2023][!!]!!
auto newtext2 = std::regex_replace(text, rx, R"([$`][$&][$'])"); 
正则表达式匹配一个一位或两位数字后跟一个点、连字符或斜杠;然后是另一个一位或两位数字;然后是一个点、连字符或斜杠;最后是一个四位数。请记住,这只是一个例子,并且有更好的表达式可以用来解析日期。
对于 newtext1,替换字符串是 $5$4$3$2$1;这意味着年份,然后是第二个分隔符,然后是月份,第一个分隔符,最后是日期。因此,对于输入字符串 today is 1.06.2023!,结果是 today is 2023.06.1!!。
对于 newtext2,替换字符串是 [$`][$&][$'];这意味着第一个匹配之前的部分,然后是整个匹配,最后是最后一个匹配之后的部分都放在方括号中。然而,结果并不是你一开始可能期望的 [!!][1.06.2023][today is ],而是 today is [today is ][1.06.2023][!!]!!。这是因为被替换的是匹配的表达式,在这种情况下,只有日期 (1.06.2023)。这个子字符串被替换为另一个由初始字符串的所有部分组成的字符串。
参见
- 
使用正则表达式验证字符串的格式,以便熟悉 C++库对正则表达式工作的支持 
- 
使用正则表达式解析字符串内容,以学习如何在文本中执行多个模式的匹配 
使用 std::string_view 而不是常量字符串引用
当处理字符串时,会不断创建临时对象,即使你可能并没有真正意识到这一点。很多时候,这些临时对象都是无关紧要的,它们仅仅是为了将数据从一个地方复制到另一个地方(例如,从一个函数到其调用者)而服务的。这代表了一个性能问题,因为它们需要内存分配和数据复制,这些都应该尽量避免。为此,C++17 标准提供了一个新的字符串类模板,称为 std::basic_string_view,它表示对字符串的非拥有常量引用(即,字符序列)。在本食谱中,你将学习何时以及如何使用这个类。
准备工作
string_view 类在 string_view 头文件中的 std 命名空间中可用。
如何做到这一点...
你应该使用 std::string_view 来向函数传递参数(或从函数返回值),而不是 std::string const &,除非你的代码需要调用其他接受 std::string 参数的函数(在这种情况下,需要进行转换):
std::string_view get_filename(std::string_view str)
{
  auto const pos1 {str.find_last_of('')};
  auto const pos2 {str.find_last_of('.')};
  return str.substr(pos1 + 1, pos2 - pos1 - 1);
}
char const file1[] {R"(c:\test\example1.doc)"};
auto name1 = get_filename(file1);
std::string file2 {R"(c:\test\example2)"};
auto name2 = get_filename(file2);
auto name3 = get_filename(std::string_view{file1, 16}); 
它是如何工作的...
在我们查看新的字符串类型是如何工作之前,让我们考虑以下一个函数的例子,该函数旨在提取不带扩展名的文件名。这基本上是在 C++17 之前你会如何编写该函数:
std::string get_filename(std::string const & str)
{
  auto const pos1 {str.find_last_of('\\')};
  auto const pos2 {str.find_last_of('.')};
  return str.substr(pos1 + 1, pos2 - pos1 - 1);
}
auto name1 = get_filename(R"(c:\test\example1.doc)"); // example1
auto name2 = get_filename(R"(c:\test\example2)");     // example2
if(get_filename(R"(c:\test\_sample_.tmp)").front() == '_') {} 
注意,在这个例子中,文件分隔符是 \(反斜杠),就像在 Windows 中一样。对于基于 Linux 的系统,它必须更改为 /(斜杠)。
get_filename() 函数相对简单。它接受一个对 std::string 的常量引用,并识别由最后一个文件分隔符和最后一个点界定的子串,这基本上代表了一个不带扩展名(以及不带文件夹名称)的文件名。
然而,这个代码的问题在于,它根据编译器的优化程度创建了一个、两个,甚至可能更多的临时对象。函数参数是一个常量 std::string 引用,但函数是用字符串字面量调用的,这意味着 std::string 需要从字面量构造。这些临时对象需要分配和复制数据,这既耗时又消耗资源。在最后一个例子中,我们只想检查文件名的第一个字符是否是下划线,但我们为此创建了至少两个临时字符串对象。
std::basic_string_view 类模板旨在解决这个问题。这个类模板与 std::basic_string 非常相似,两者几乎有相同的接口。这是因为 std::basic_string_view 的目的是在不进行进一步代码更改的情况下替代对 std::basic_string 的常量引用。就像 std::basic_string 一样,它为所有标准字符类型都有特殊化:
typedef basic_string_view<char>     string_view;
typedef basic_string_view<wchar_t>  wstring_view;
typedef basic_string_view<char16_t> u16string_view;
typedef basic_string_view<char32_t> u32string_view; 
std::basic_string_view 类模板定义了对一个连续字符序列的引用。正如其名所示,它代表了一个视图,不能用来修改字符序列的引用。一个 std::basic_string_view 对象具有相对较小的尺寸,因为它只需要一个指向序列中第一个字符的指针和长度。它可以从一个 std::basic_string 对象构建,也可以从一个指针和长度构建,或者从一个以空字符终止的字符序列(在这种情况下,它将需要遍历字符串以找到长度)。因此,std::basic_string_view 类模板也可以用作多种字符串类型的通用接口(只要数据只需要被读取)。另一方面,从 std::basic_string_view 转换到 std::basic_string 是不可能的。
你必须显式地从 std::basic_string_view 构造一个 std::basic_string 对象,如下例所示:
std::string_view sv{ "demo" };
std::string s{ sv }; 
将 std::basic_string_view 传递给函数并返回 std::basic_string_view 仍然会创建这种类型的临时对象,但这些是在栈上的小尺寸对象(在 64 位平台上,一个指针和大小可能为 16 字节);因此,它们应该比分配堆空间和复制数据产生更少的性能成本。
注意,所有主要编译器都提供了 std::basic_string 的实现,这包括一个小字符串优化。尽管实现细节不同,但它们通常依赖于具有一定数量的字符的静态分配缓冲区(对于 VC++和 GCC 5 或更新的版本为 16),这不需要堆操作,只有当字符串的大小超过这个数字时才需要堆操作。
除了与 std::basic_string 中可用的方法相同的方法外,std::basic_string_view 还有两个更多:
- 
remove_prefix(): 通过增加起始位置N个字符和减少长度N个字符来缩小视图
- 
remove_suffix(): 通过减少长度来缩小视图,长度减少N个字符
在以下示例中,这两个成员函数用于从 std::string_view 中修剪空格,包括开头和结尾。函数的实现首先查找第一个不是空格的元素,然后查找最后一个不是空格的元素。然后,它从末尾移除最后一个非空格字符之后的所有内容,并从开头移除直到第一个非空格字符的所有内容。函数返回修剪两端的新视图:
std::string_view trim_view(std::string_view str)
{
  auto const pos1{ str.find_first_not_of(" ") };
  auto const pos2{ str.find_last_not_of(" ") };
  str.remove_suffix(str.length() - pos2 - 1);
  str.remove_prefix(pos1);
  return str;
}
auto sv1{ trim_view("sample") };
auto sv2{ trim_view("  sample") };
auto sv3{ trim_view("sample  ") };
auto sv4{ trim_view("  sample  ") };
std::string s1{ sv1 };
std::string s2{ sv2 };
std::string s3{ sv3 };
std::string s4{ sv4 }; 
当使用 std::basic_string_view 时,你必须注意两件事:你不能更改视图所引用的底层数据,你必须管理数据的生命周期,因为视图是一个非拥有引用。
参见
- 创建字符串辅助库,以了解如何创建有用的文本实用工具,这些实用工具在标准库中不可直接使用
使用 std::format 和 std::print 格式化和打印文本
C++语言有两种格式化文本的方式:printf函数族和 I/O 流库。printf函数是从 C 继承而来的,提供了格式文本和参数的分离。流库提供了安全性和可扩展性,通常比printf函数更推荐,但通常速度较慢。C++20 标准提出了一个新的输出格式化库替代方案,其形式类似于printf,但更安全、更可扩展,旨在补充现有的流库。在本食谱中,我们将学习如何使用新的功能,而不是使用printf函数或流库。
准备工作
新的格式化库在<format>头文件中可用。你必须包含此头文件,以下示例才能正常工作。
如何做...
std::format()函数根据提供的格式字符串格式化其参数。你可以如下使用它:
- 
在格式字符串中为每个参数提供空替换字段,表示为 {}:auto text = std::format("{} is {}", "John", 42);
- 
在替换字段内指定参数列表中每个参数的 0 基于索引,例如 {0}、{1}等。参数的顺序不重要,但索引必须是有效的:auto text = std::format("{0} is {1}", "John", 42);
- 
使用冒号( :)之后替换字段中提供的格式说明符来控制输出文本。对于基本和字符串类型,这是一个标准格式说明符。对于 chrono 类型,这是一个 chrono 格式说明符:auto text = std::format("{0} hex is {0:08X}", 42); auto now = std::chrono::system_clock::now(); auto date = std::format("Today is {:%Y-%m-%d}", now); std::cout << date << '\n';
你也可以使用std::format_to()或std::format_to_n()通过迭代器以输出格式写入参数,如下所示:
- 
使用 std::format_n()和std::back_inserter()辅助函数将内容写入缓冲区,例如std::string或std::vector<char>:std::vector<char> buf; std::format_to(std::back_inserter(buf), "{} is {}", "John", 42);
- 
使用 std::formatted_size()检索存储参数格式化表示所需的字符数:auto size = std::formatted_size("{} is {}", "John", 42); std::vector<char> buf(size); std::format_to(buf.data(), "{} is {}", "John", 42);
- 
要限制写入输出缓冲区的字符数,你可以使用 std::format_to_n(),它与std::format_to()类似,但最多写入n个字符:char buf[100]; auto result = std::format_to_n(buf, sizeof(buf), "{} is {}", "John", 42);
在 C++23 中,你可以使用新<print>头文件中的以下函数直接将格式化文本写入文件流,例如标准输出控制台:
- 
std::print,用于根据格式字符串写入参数:std::print("The answer is {}", 42);
- 
std::println,用于根据格式字符串后跟一个换行符('\n')写入参数:std::println("The answer is {}", 42); std::FILE* stream = std::fopen("demo.txt", "w"); if (stream) { std::println(stream, "The answer is {}", 42); std::fclose(stream); }
它是如何工作的...
std::format()函数有多个重载。你可以指定格式字符串为字符串视图或宽字符串视图,函数返回std::string或std::wstring。你也可以指定第一个参数为一个std::locale,它用于特定区域设置的格式化。函数重载都是变参函数模板,这意味着你可以在格式之后指定任意数量的参数。
格式字符串由普通字符、替换字段和转义序列组成。转义序列是 {{ 和 }},在输出中它们被替换为 { 和 }。替换字段在花括号 {} 内提供。它可以包含一个非负数,表示要格式化的参数的 0 基于索引,后跟一个冒号(:),然后是一个格式规范。如果格式规范无效,则抛出 std::format_error 类型的异常。
类似地,std::format_to() 有多个重载,就像 std::format() 一样。这两个函数的区别在于 std::format_to() 总是接受输出缓冲区的迭代器作为第一个参数,并返回输出范围的末尾之后的迭代器(而不是 std::format() 所做的字符串)。另一方面,std::format_to_n() 比 std::format_to() 多一个参数。它的第二个参数是一个表示要写入缓冲区的最大字符数的数字。
以下列表显示了这三个函数模板最简单重载的签名:
template<class... Args>
std::string format(std::string_view fmt, const Args&... args);
template<class OutputIt, class... Args>
OutputIt format_to(OutputIt out,
                   std::string_view fmt, const Args&... args);
template<class OutputIt, class... Args>
std::format_to_n_result<OutputIt>
format_to_n(OutputIt out, std::iter_difference_t<OutputIt> n,
            std::string_view fmt, const Args&... args); 
当你提供格式字符串时,你可以提供参数标识符(它们的 0 基于索引)或省略它们。然而,同时使用两者是不合法的。如果省略了替换字段中的索引,则按提供的顺序处理参数,并且替换字段的数量不得大于提供的参数数量。如果提供了索引,它们必须对格式字符串有效。
当使用格式规范时:
- 
对于基本类型和字符串类型,它被认为是标准格式规范。 
- 
对于 chrono 类型,它被认为是 chrono 格式规范。 
- 
对于用户定义的类型,它由用户定义的 std::formatter类的特化来定义,该特化针对所需类型。
标准格式规范基于 Python 中的格式规范,具有以下语法:
fill-and-align(optional) sign(optional) #(optional) 0(optional) width(optional) precision(optional) L(optional) type(optional) 
这些语法部分在此简要描述。
fill-and-align 是一个可选的填充字符,后跟一个对齐选项:
- 
<: 强制字段与可用空间左对齐。
- 
>: 强制字段与可用空间右对齐。
- 
^: 强制字段与可用空间居中对齐。为此,它将在左侧插入 n/2 个字符,在右侧插入 n/2 个字符:auto t1 = std::format("{:5}", 42); // " 42" auto t2 = std::format("{:5}", 'x'); // "x " auto t3 = std::format("{:*<5}", 'x'); // "x****" auto t4 = std::format("{:*>5}", 'x'); // "****x" auto t5 = std::format("{:*⁵}", 'x'); // "**x**" auto t6 = std::format("{:5}", true); // "true "
sign, #, 和 0 仅在数字(整数或浮点数)使用时有效。符号可以是以下之一:
- 
+: 指定对于负数和正数都必须使用符号
- 
-: 指定仅对负数使用符号(这是隐式行为)
- 
一个空格:指定对于负数必须使用符号,并且对于非负数必须使用前导空格: auto t7 = std::format("{0:},{0:+},{0:-},{0: }", 42); // "42,+42,42, 42" auto t8 = std::format("{0:},{0:+},{0:-},{0: }", -42); // "-42,-42,-42,-42"
# 符号会导致使用交替形式。这可以是以下之一:
- 
对于整型,当指定二进制、八进制或十六进制表示时,交替形式会在输出前添加前缀 0b、0或0x。
- 
对于浮点类型,交替形式会导致格式化值中始终存在小数点字符,即使后面没有数字。此外,当使用 g或G时,输出中不会移除尾随零。
数字 0 指定应该输出前导零到字段宽度,除非浮点类型的值为无穷大或 NaN。当与对齐选项一起出现时,指定符 0 被忽略:
auto t9  = std::format("{:+05d}", 42); // "+0042"
auto t10 = std::format("{:#05x}", 42); // "0x02a"
auto t11 = std::format("{:<05}", -42); // "-42  " 
width 指定最小字段宽度,可以是正的十进制数或嵌套替换字段。precision 字段表示浮点类型的精度或对于字符串类型,将使用多少个字符。它用一个点(.)后跟一个非负十进制数或嵌套替换字段来指定。
使用大写 L 指定区域特定的格式化,这将导致使用区域特定的格式。此选项仅适用于算术类型。
可选的 type 决定了数据在输出中的表示方式。以下表格显示了可用的字符串表示类型:
| 类型 | 表示类型 | 描述 | 
|---|---|---|
| 字符串 | 无、 s | 将字符串复制到输出。 | 
| 整型 | b | 带前缀 0b 的二进制格式。 | 
| B | 带前缀 0B 的二进制格式。 | |
| C | 字符格式。将值作为字符类型复制到输出。 | |
| 无或 d | 十进制格式。 | |
| O | 带前缀 0 的八进制格式(除非值为 0)。 | |
| x | 带前缀 0x 的十六进制格式。 | |
| X | 带前缀 0X 的十六进制格式。 | |
| char和wchar_t | 无或 c | 将字符复制到输出。 | 
| b、B、c、d、o、x、X | 整数表示类型。 | |
| bool | 无或 s | 将 true 或 false 作为文本表示(或其区域特定的形式)复制到输出。 | 
| b、B、c、d、o、x、X | 整数表示类型。 | |
| 浮点数 | a | 十六进制表示。相当于调用 std::to_chars(first, last, value, std::chars_format::hex, precision)或std::to_chars(first, last, value, std::chars_format::hex),具体取决于是否指定了精度。 | 
| A | 与 a相同,除了它使用大写字母表示大于 9 的数字,并使用 P 来表示指数。 | |
| e | 科学表示。产生输出,就像调用 std::to_chars(first, last, value, std::chars_format::scientific, precision)。 | |
| E | 与 e类似,除了它使用E来表示指数。 | |
| f、F | 固定表示。产生输出,就像通过调用 std::to_chars(first, last, value, std::chars_format::fixed, precision)。当未指定精度时,默认为 6。 | |
| g | 通用浮点表示。输出结果类似于调用 std::to_chars(first, last, value, std::chars_format::general, precision)。当未指定精度时,默认为 6。 | |
| G | 与 g相同,但使用E来表示指数。 | |
| 指针 | 无或 p | 指针表示。输出结果类似于调用 std::to_chars(first, last, reinterpret_cast<std::uintptr_t>(value), 16)并在输出前加上前缀0x。这仅在std::uintptr_t被定义时可用;否则,输出是未定义的。 | 
表 2.16:可用的表示类型列表
时间格式规范具有以下形式:
fill-and-align(optional) width(optional) precision(optional) chrono-spec(optional) 
fill-and-align、width 和 precision 字段与之前描述的标准格式规范中的意义相同。精度仅在 std::chrono::duration 类型且表示类型为浮点类型时有效。在其他情况下使用它将抛出 std::format_error 异常。
时间规范可以是空的,在这种情况下,参数将被格式化为如果将其流式传输到 std::stringstream 并复制结果字符串。或者,它可以由一系列转换规范和普通字符组成。以下表格中展示了其中一些格式规范:
| 转换规范 | 描述 | 
|---|---|
| %% | 写入一个字面的 %字符。 | 
| %n | 写入一个换行符。 | 
| %t | 写入一个水平制表符。 | 
| %Y | 将年份以十进制数字形式写入。如果结果小于四位数字,则使用 0左侧填充至四位数字。 | 
| %m | 将月份以十进制数字形式写入(一月为 01)。如果结果是单个数字,则前面会加上0。 | 
| %d | 将月份中的日期以十进制数字形式写入。如果结果是单个数字,则前面会加上 0。 | 
| %w | 写入星期几的十进制数字( 0-6),其中星期天为0。 | 
| %D | 等同于 %m/%d/%y。 | 
| %F | 等同于 %Y-%m-%d。 | 
| %H | 将小时(24 小时制)以十进制数字形式写入。如果结果是单个数字,则前面会加上 0。 | 
| %I | 将小时(12 小时制)以十进制数字形式写入。如果结果是单个数字,则前面会加上 0。 | 
| %M | 将分钟以十进制数字形式写入。如果结果是单个数字,则前面会加上 0。 | 
| %S | 将秒以十进制数字形式写入。如果秒数小于 10,则结果前面会加上 0。 | 
| %R | 等同于 %H:%M。 | 
| %T | 等同于 %H:%M:%S。 | 
| %X | 写入区域设置的时间表示。 | 
表 2.17:最常见的 chrono 规范列表
完整的 chrono 库格式规范列表可以在 en.cppreference.com/w/cpp/chrono/system_clock/formatter 查询。
由于将格式化文本写入控制台或文件流需要两个操作(将文本格式化为字符串或字符向量,然后将该缓冲区写入输出流),C++23 标准引入了一些新函数来简化此过程。
新的std::print和std::println函数非常相似。唯一的区别是std::println在格式化文本后附加一个\n字符(换行符)。这两个函数各有两个重载:
- 
第一个参数是 std::FILE*,表示输出文件流
- 
没有这样的参数,并且隐式地使用 C 输出流 stdout 
因此,以下两个对std::println的调用是等效的:
std::println("The answer is {}", 42);
std::println(stdout, "The answer is {}", 42); 
此外,以下两个对std::print和std::println的调用在标准输出流上具有相同的结果:
std::println("The answer is {}", 42);
std::print("The answer is {}\n", 42); 
格式字符串的指定与std::format相同,之前已经介绍过。
参见
- 
使用用户定义类型与 std::format 结合,学习如何为用户定义类型创建自定义格式化特化 
- 
在数字和字符串类型之间转换,学习如何在不同数字和字符串之间进行转换 
使用用户定义类型与 std::format 结合
C++20 格式化库是使用类似printf的函数或 I/O 流库的现代替代品,它实际上是对它们的补充。尽管标准为基本类型(如整数和浮点类型、bool、字符类型、字符串和 chrono 类型)提供了默认格式化,但用户可以为用户定义类型创建自定义特化。在本食谱中,我们将学习如何做到这一点。
准备工作
您应该阅读之前的食谱,使用 std::format 和 std::print 格式化和打印文本,以便熟悉格式化库。
在我们将要展示的示例中,我们将使用以下类:
struct employee
{
   int         id;
   std::string firstName;
   std::string lastName;
}; 
在下一节中,我们将介绍实现使用std::format()对用户定义类型进行文本格式化的必要步骤。
如何做到...
要启用使用新格式化库对用户定义类型进行格式化,必须执行以下操作:
- 
在 std命名空间中定义std::formatter<T, CharT>类的特化。
- 
实现一个 parse()方法来解析格式字符串中对应当前参数的部分。如果类继承自另一个格式化器,则可以省略此方法。
- 
实现一个 format()方法来格式化参数,并通过format_context写入输出。
对于这里列出的employee类,可以实现一个格式化器,将employee格式化为[42] John Doe的形式(即[id] firstName lastName),具体实现如下:
template <>
struct std::formatter<employee>
{
   constexpr auto parse(format_parse_context& ctx)
 {
      return ctx.begin();
   }
   auto format(employee const & e, format_context& ctx) const 
 {
      return std::format_to(ctx.out(),
                            "[{}] {} {}",
                            e.id, e.firstName, e.lastName);
   }
}; 
它是如何工作的...
格式化库使用std::formatter<T, CharT>类模板来定义给定类型的格式化规则。内置类型、字符串类型和 chrono 类型由库提供格式化器。这些是std::formatter<T, CharT>类模板的特殊化实现。
这个类有两个方法:
- 
parse()函数接受一个类型为std::basic_format_parse_context<CharT>的单个参数,并解析由解析上下文提供的类型T的格式说明。解析的结果应存储在类的成员字段中。如果解析成功,此函数应返回类型为std::basic_format_parse_context<CharT>::iterator的值,它表示格式说明符的结束。如果解析失败,函数应抛出类型为std::format_error的异常,以提供有关错误的详细信息。
- 
format()函数接受两个参数,第一个是要格式化的类型T的对象,第二个是格式化上下文对象,类型为std::basic_format_context<OutputIt, CharT>。此函数应根据所需的说明符(可能是隐式的或解析格式说明的结果)将输出写入ctx.out()。函数必须返回类型为std::basic_format_context<OutputIt, CharT>::iterator的值,表示输出的结束。
在前一个部分中展示的实现中,parse()函数除了返回表示格式说明符开始的迭代器之外,不做任何事情。格式化总是通过在方括号内打印员工标识符,然后是名字和姓氏来完成的,例如[42] John Doe。尝试使用格式说明符会导致编译时错误:
employee e{ 42, "John", "Doe" };
auto s1 = std::format("{}", e);   // [42] John Doe
auto s2 = std::format("{:L}", e); // error 
如果你希望你的用户定义类型支持格式说明符,那么你必须正确实现parse()方法。为了展示如何实现这一点,我们将支持employee类中定义的几个说明符,如下表所示:
| 说明符 | 描述 | 示例 | 
|---|---|---|
| L | 字典顺序 | [42] Doe, John | 
| l | 小写 | [42] john doe | 
| u | 大写 | [42] JOHN DOE | 
表 2.18:用户定义的employee类支持的说明符
当使用L说明符时,employee将以方括号内的标识符开始格式化,然后是姓氏,一个逗号,然后是名字,例如[42] Doe, John。这些说明符的组合也是可能的。例如,{:Ll}将产生[42] doe, john,而{:uL}将产生[42] DOE, JOHN。
实现定义要求的std::formatter类模板的employee类的特殊化可能如下所示:
template<>
struct std::formatter<employee>
{
   constexpr auto parse(std::format_parse_context& ctx)
 {
      auto iter = begin(ctx);
      while(iter != ctx.end() && *iter != '}')
      {
         switch (*iter)
         {
         case 'L': lexicographic_order = true; break;
         case 'u': uppercase = true; break;
         case 'l': lowercase = true; break;
         }
         ++iter;
      }
      return iter;
   }
   auto format(employee const& e, std::format_context& ctx) const
 {
      if (lexicographic_order)
         return std::format_to(ctx.out(), 
                               "[{}] {}, {}", 
                               e.id, 
                               text_format(e.lastName), 
                               text_format(e.firstName));
      return std::format_to(ctx.out(), 
                            "[{}] {} {}", 
                            e.id, 
                            text_format(e.firstName),
                            text_format(e.lastName));
   }
private:
   bool lexicographic_order = false;
   bool uppercase = false;
   bool lowercase = false;
   constexpr std::string text_format(std::string text) const
 {
      if(lowercase)
         std::transform(text.begin(), text.end(), text.begin(),                         ::tolower);
      else if(uppercase)
         std::transform(text.begin(), text.end(), text.begin(),                         ::toupper);
      return text;
   }
}; 
parse() 函数接收包含格式字符串的解析上下文。begin() 迭代器指向格式分隔符(:)之后的格式字符串的第一个元素。下一个表格提供了一个示例:
| 格式 | begin() 迭代器 | 范围 | 
|---|---|---|
| "{}" | 等同于 end() | 空的 | 
| "{0}" | 等同于 end() | 空的 | 
| "{0:L}" | 指向 'L' | L} | 
| "{:L}" | 指向 'L' | L} | 
| "{:Lul}" | 指向 'L' | Lul} | 
表 2.19:解析上下文内容的示例
定义了这些之后,前面的示例代码(使用 {:L} 格式参数)将可以工作。此外,可以使用 L、u 和 l 指定符的各种组合,如下所示:
auto s1 = std::format("{}", e);     // [42] John Doe
auto s2 = std::format("{:L}", e);   // [42] Doe, John
auto s3 = std::format("{:u}", e);   // [42] JOHN DOE
auto s4 = std::format("{:lL}", e);  // [42] doe, john
// uppercase ignored when lowercase also specified
auto s5 = std::format("{:ulL}", e); // [42] doe, john 
同一个参数的多次使用也是可能的,如下面的代码片段所示:
auto s6 = std::format("{0} = {0:L}", e);
// [42] John Doe = [42] Doe, John 
然而,使用其他格式指定符(例如 A)将不会工作;指定符将被简单地忽略,并使用默认格式化:
auto s7 = std::format("{:A}", e);   // [42] John Doe 
如果你不需要解析格式指定符以支持各种选项,你可以完全省略 parse() 方法。但是,为了这样做,你的 std::formatter 特化必须从另一个 std::formatter 类派生。一个实现示例如下:
template<>
struct std::formatter<employee> : std::formatter<char const*>
{
   auto format(employee const& e, std::format_context& ctx) const
 {
      return std::format_to(ctx.out(), "[{}] {} {}",
                            e.id, e.firstName, e.lastName);
   }
}; 
这个 employee 类的特化与前面在 如何做... 部分中显示的第一个实现等效。
还有更多...
C++23 标准引入了一个新的概念,称为 std::formattable(也在 <format> 头文件中),它指定一个类型是可格式化的。这意味着对于类型 T,有 std::format 的特化,并且它定义了 parse() 和 format() 成员函数,如本食谱中所述。
参考内容
- 使用 std::format 格式化文本,以获得对新的 C++20 文本格式化库的介绍
在 Discord 上了解更多
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
discord.gg/7xRaTCeEhx

第三章:探索函数
函数是编程中的基本概念;无论我们讨论什么主题,最终都会谈到函数。试图在一个章节中涵盖关于函数的所有内容不仅困难,而且不太合理。作为语言的基本元素,函数出现在本书的每一道食谱中。然而,这一章涵盖了与函数和可调用对象相关的现代语言特性,重点关注 lambda 表达式、来自函数式语言的概念,如高阶函数和函数模板。
本章包含的食谱如下:
- 
默认化和删除函数 
- 
使用标准算法与 lambda 表达式 
- 
使用泛型和模板 lambda 
- 
编写递归 lambda 
- 
编写函数模板 
- 
编写具有可变参数数量的函数模板 
- 
使用折叠表达式简化变长函数模板 
- 
实现高阶函数 map和fold
- 
将函数组合成高阶函数 
- 
统一调用任何可调用对象 
我们将从这个章节开始,学习一个使我们可以更容易地提供特殊类成员函数或防止任何函数(成员或非成员)被调用的特性。
默认化和删除函数
在 C++中,类有特殊的成员(构造函数、析构函数和赋值运算符),这些成员可能由编译器默认实现,或者由开发者提供。然而,可以默认实现的规则有点复杂,可能会导致问题。另一方面,开发者有时希望防止对象以特定方式被复制、移动或构造。
这可以通过使用这些特殊成员实现不同的技巧来实现。C++11 标准通过允许函数被删除或默认,简化了其中许多规则,我们将在下一节中看到这些规则。
入门
对于这个食谱,你需要熟悉以下概念:
- 
特殊成员函数(默认构造函数、析构函数、拷贝构造函数、移动构造函数、拷贝赋值运算符和移动赋值运算符) 
- 
可拷贝的概念(一个类具有拷贝构造函数和拷贝赋值运算符,使得创建副本成为可能) 
- 
可移动的概念(一个类具有移动构造函数和移动赋值运算符,使得移动对象成为可能) 
考虑到这一点,让我们学习如何定义默认和删除的特殊函数。
如何做到这一点...
使用以下语法来指定函数应该如何处理:
- 
要将函数默认化,请使用 =default而不是函数体。只有编译器可以提供默认实现的特殊类成员函数才能被默认化:struct foo { foo() = default; };
- 
要删除一个函数,请使用 =delete而不是函数体。任何函数,包括非成员函数,都可以被删除:struct foo { foo(foo const &) = delete; }; void func(int) = delete;
使用默认化和删除函数来实现各种设计目标,例如以下示例:
- 
要实现一个不可拷贝且隐式不可移动的类,请将拷贝构造函数和拷贝赋值运算符声明为已删除: class foo_not_copyable { public: foo_not_copyable() = default; foo_not_copyable(foo_not_copyable const &) = delete; foo_not_copyable& operator=(foo_not_copyable const&) = delete; };
- 
要实现一个不可拷贝但可移动的类,请将拷贝操作声明为已删除,并显式实现移动操作(以及提供所需的任何其他构造函数): class data_wrapper { Data* data; public: data_wrapper(Data* d = nullptr) : data(d) {} ~data_wrapper() { delete data; } data_wrapper(data_wrapper const&) = delete; data_wrapper& operator=(data_wrapper const &) = delete; data_wrapper(data_wrapper&& other) :data(std::move(other.data)) { other.data = nullptr; } data_wrapper& operator=(data_wrapper&& other) { if (data != other.data)) { delete data; data = std::move(other.data); other.data = nullptr; } return *this; } };
- 
要确保函数仅由特定类型的对象调用,并且可能防止类型提升,请为该函数提供已删除的重载(以下示例中的自由函数也可以应用于任何类的成员函数): template <typename T> void run(T val) = delete; void run(long val) {} // can only be called with long integers
它是如何工作的...
一个类有多个可以实现的特殊成员,默认情况下可以由编译器实现。这些是默认构造函数、拷贝构造函数、移动构造函数、拷贝赋值、移动赋值和析构函数(关于移动语义的讨论,请参阅第九章“健壮性和性能”中的实现移动语义配方)。如果您不实现它们,则编译器会根据以下规则生成它们。然而,如果您显式提供了一个或多个这些特殊方法,则编译器将不会根据以下规则生成其他方法:
- 
如果存在用户定义的构造函数,则默认不生成默认构造函数。 
- 
如果存在用户定义的虚析构函数,则不生成默认析构函数。 
- 
如果存在用户定义的移动构造函数或移动赋值运算符,则默认不生成拷贝构造函数和拷贝赋值运算符。 
- 
如果存在用户定义的拷贝构造函数、移动构造函数、拷贝赋值运算符、移动赋值运算符或析构函数,则默认不生成移动构造函数和移动赋值运算符。 
- 
如果存在用户定义的拷贝构造函数或析构函数,则默认生成拷贝赋值运算符。 
- 
如果存在用户定义的拷贝赋值运算符或析构函数,则默认生成拷贝构造函数。 
注意,前面列表中的最后两条规则已被弃用,并且可能不再被您的编译器支持。
有时,开发者需要提供这些特殊成员的空实现或隐藏它们,以防止类的实例以特定方式构造。一个典型的例子是一个不应该可拷贝的类。这种情况下,经典的模式是提供一个默认构造函数并隐藏拷贝构造函数和拷贝赋值运算符。虽然这可行,但显式定义的默认构造函数确保该类不再被视为平凡类型,因此是一个纯旧数据(POD)类型。现代的替代方法是使用已删除的函数,如前节所示。
当编译器在函数的定义中遇到=default时,它将提供默认实现。前面提到的特殊成员函数的规则仍然适用。如果函数是内联的,那么只有在类体外部声明函数时才能使用=default:
class foo
{
public:
  foo() = default;
  inline foo& operator=(foo const &);
};
inline foo& foo::operator=(foo const &) = default; 
默认实现有几个好处,包括以下内容:
- 
可能比显式实现更高效。 
- 
非默认实现,即使它们是空的,也被认为是非平凡的,这影响了类型的语义,使得类型变得非平凡(因此,非 POD)。 
- 
帮助用户不编写显式的默认实现。例如,如果存在用户定义的移动构造函数,那么编译器不会默认提供拷贝构造函数和拷贝赋值运算符。然而,你仍然可以显式地默认它们,并要求编译器提供它们,这样你就不必手动做了。 
当编译器在函数的定义中遇到=delete时,它将阻止函数的调用。然而,函数在重载解析期间仍然被考虑,只有当删除的函数是最好的匹配时,编译器才会生成错误。例如,通过为之前定义的run()函数的重载提供,只有使用长整数的调用是可能的。使用任何其他类型(包括自动提升到long的int)的参数的调用将确定删除的重载被认为是最佳匹配,因此编译器将生成错误:
run(42);  // error, matches a deleted overload
run(42L); // OK, long integer arguments are allowed 
注意,之前声明的函数不能被删除,因为=delete定义必须是翻译单元中的第一个声明:
void forward_declared_function();
// ...
void forward_declared_function() = delete; // error 
对于类特殊成员函数的规则(也称为“五规则”)是,如果你明确定义了任何拷贝构造函数、移动构造函数、拷贝赋值运算符、移动赋值运算符或析构函数,那么你必须要么明确定义,要么默认所有这些。
用户定义的析构函数、拷贝构造函数和拷贝赋值运算符是必要的,因为在各种情况下对象都是从副本中构建的(例如将参数传递给函数)。如果它们没有被用户定义,编译器会提供它们,但它们的默认实现可能是不正确的。如果一个类管理资源,那么默认实现执行的是浅拷贝,这意味着它复制了资源句柄的值(例如指向对象的指针)而不是资源本身。在这种情况下,用户定义的实现必须执行深拷贝,即复制资源而不是其句柄。在这种情况下,移动构造函数和移动赋值运算符的存在是可取的,因为它们代表了性能的提升。缺少这两个运算符不是错误,但是一个被错过的优化机会。
一方面与五规则相对立,另一方面与之相补充的是所谓的零规则。该规则指出,除非类处理资源所有权,否则它不应有自定义析构函数、拷贝和移动构造函数,以及相应的拷贝和移动赋值运算符。
在设计类时,你应该遵循以下指南:
- 
管理资源的类应该只负责处理该资源的所有权。这样的类必须遵循五规则,并实现自定义析构函数、拷贝/移动构造函数和拷贝/移动赋值运算符。 
- 
不管理资源的类不应该有自定义析构函数、拷贝/移动构造函数和拷贝/移动赋值运算符(因此遵循零规则)。 
参见
- 统一调用任何可调用对象,了解如何使用std::invoke()以提供的参数调用任何可调用对象
使用 lambda 表达式与标准算法
C++最现代的特性之一是 lambda 表达式,也称为 lambda 函数或简称为 lambdas。Lambda 表达式使我们能够定义匿名函数对象,这些对象可以捕获作用域内的变量,并作为参数调用或传递给函数。它们避免了定义命名函数或函数对象的必要性。Lambda 表达式在许多用途中都很有用,在这个菜谱中,我们将学习如何使用它们与标准算法一起。
准备工作
在这个菜谱中,我们将讨论接受一个函数或谓词作为参数的标准算法,该函数或谓词应用于它迭代的元素。你需要了解一元和二元函数是什么,以及谓词和比较函数是什么。你还应该熟悉函数对象,因为 lambda 表达式是函数对象的语法糖。
如何做...
你应该优先使用 lambda 表达式将回调传递给标准算法,而不是函数或函数对象:
- 
如果你只需要在调用处定义匿名 lambda 表达式,就使用它: auto numbers = std::vector<int>{ 0, 2, -3, 5, -1, 6, 8, -4, 9 }; auto positives = std::count_if( std::begin(numbers), std::end(numbers), [](int const n) {return n > 0; });
- 
如果你需要在多个地方调用 lambda,定义一个命名的 lambda,即分配给变量的 lambda(通常使用 auto指定器指定类型):auto ispositive = [](int const n) {return n > 0; }; auto positives = std::count_if( std::begin(numbers), std::end(numbers), ispositive);
- 
如果你需要 lambda 表达式仅在参数类型方面有所不同(自 C++14 起可用),请使用泛型 lambda 表达式: auto positives = std::count_if( std::begin(numbers), std::end(numbers), [](auto const n) {return n > 0; });
它是如何工作的...
第二点中显示的非泛型 lambda 表达式接受一个常量整数,如果它大于0则返回true,否则返回false。编译器定义了一个无名的函数对象,具有 lambda 表达式的签名,该签名具有调用操作符:
struct __lambda_name__
{
  bool operator()(int const n) const { return n > 0; }
}; 
编译器定义未命名函数对象的方式取决于我们定义的可以捕获变量的 lambda 表达式的方式,使用mutable指定符或异常指定符,或者有尾随返回类型。前面展示的__lambda_name__函数对象实际上是编译器生成的简化版本,因为它还定义了一个默认的拷贝构造函数、默认析构函数和一个删除的赋值运算符。
必须清楚了解 lambda 表达式实际上是一个类。为了调用它,编译器需要实例化类的对象。从 lambda 表达式实例化的对象被称为lambda 闭包。
在以下示例中,我们想要计算一个范围中大于或等于 5 且小于或等于 10 的元素数量。在这种情况下,lambda 表达式将看起来像这样:
auto numbers = std::vector<int>{ 0, 2, -3, 5, -1, 6, 8, -4, 9 };
auto minimum { 5 };
auto maximum { 10 };
auto inrange = std::count_if(
    std::begin(numbers), std::end(numbers),
    minimum, maximum {
      return minimum <= n && n <= maximum;}); 
这个 lambda 通过拷贝(即值)捕获了两个变量,minimum和maximum。编译器创建的未命名函数对象看起来非常像我们之前定义的。使用前面提到的默认和删除的特殊成员,类看起来像这样:
class __lambda_name_2__
{
  int minimum_;
  int maximum_;
public:
  explicit __lambda_name_2__(int const minimum, int const maximum) :
    minimum_( minimum), maximum_( maximum)
  {}
  __lambda_name_2__(const __lambda_name_2__&) = default;
  __lambda_name_2__(__lambda_name_2__&&) = default;
  __lambda_name_2__& operator=(const __lambda_name_2__&)
    = delete;
  ~__lambda_name_2__() = default;
  bool operator() (int const n) const
 {
    return minimum_ <= n && n <= maximum_;
  }
}; 
Lambda 表达式可以通过拷贝(或值)或通过引用捕获变量,并且这两种组合的不同组合是可能的。然而,无法多次捕获一个变量,并且捕获列表的开头只能有&或=。
Lambda 表达式可以访问以下类型的变量:从封装作用域捕获的变量、lambda 参数、在其体内局部声明的变量、当 lambda 在类内部声明且指针被 lambda 捕获时的类数据成员,以及任何具有静态存储期的变量,如全局变量。
Lambda 只能捕获封装函数作用域中的变量。它不能捕获具有静态存储期的变量(即,在命名空间作用域中声明的变量或使用static或external指定符声明的变量)。
以下表格展示了 lambda 捕获语义的各种组合:
| Lambda | 描述 | 
|---|---|
| [](){} | 不捕获任何内容。 | 
| [&](){} | 通过引用捕获所有内容。 | 
| [=](){} | 通过拷贝捕获所有内容。在 C++20 中,隐式捕获指针 this已被弃用。 | 
| [&x](){} | 仅通过引用捕获 x。 | 
| [x](){} | 仅通过拷贝捕获 x。 | 
| [&x...](){} | 通过引用捕获 pack 扩展 x。 | 
| [x...](){} | 通过拷贝捕获 pack 扩展 x。 | 
| [&, x](){} | 通过引用捕获所有内容,除了通过拷贝捕获的 x。 | 
| [=, &x](){} | 通过拷贝捕获所有内容,除了通过引用捕获的 x。 | 
| [&, this](){} | 通过引用捕获所有内容,除了通过拷贝捕获的指针 this(this总是通过拷贝捕获)。 | 
| [x, x](){} | 错误; x被捕获两次。 | 
| [&, &x](){} | 错误;所有内容都是通过引用捕获的,我们不能再指定再次通过引用捕获 x。 | 
| [=, =x](){} | 错误;所有内容都是通过复制捕获的,我们不能再指定再次通过复制捕获 x。 | 
| [&this](){} | 错误;指针 this总是通过复制捕获。 | 
| [&, =](){} | 错误;不能同时通过复制和引用捕获所有内容。 | 
| [x=expr](){} | x是 lambda 的闭包中的数据成员,由表达式expr初始化。 | 
| [&x=expr](){} | x是 lambda 的闭包中的引用数据成员,由表达式expr初始化。 | 
表 3.1:带有解释的 lambda 捕获示例
截至 C++17,lambda 表达式的一般形式如下所示:
capture-list mutable constexpr exception attr -> ret
{ body } 
此语法中显示的所有部分实际上都是可选的,除了捕获列表,它可以空着,主体也可以空着。如果不需要参数,实际上可以省略参数列表。不需要指定返回类型,因为编译器可以从返回表达式的类型中推断它。mutable 说明符(它告诉编译器 lambda 实际上可以修改通过复制捕获的变量,这与通过值捕获不同,因为更改仅在 lambda 内部观察到),constexpr 说明符(它告诉编译器生成 constexpr 调用操作符),以及异常说明符和属性都是可选的。
最简单的 lambda 表达式是 []{},尽管它通常写作 []()。
在前面的表格中后两个示例是泛化 lambda 捕获的形式。这些是在 C++14 中引入的,以便我们可以捕获具有移动语义的变量,但它们也可以用于在 lambda 中定义新的任意对象。以下示例显示了如何通过泛化 lambda 捕获以 move 的方式捕获变量:
auto ptr = std::make_unique<int>(42);
auto l = [lptr = std::move(ptr)](){return ++*lptr;}; 
在类方法中编写的 lambda,如果需要捕获类数据成员,可以通过几种方式做到:
- 
使用形式 [x=expr]捕获单个数据成员:struct foo { int id; std::string name; auto run() { return [i=id, n=name] { std::cout << i << ' ' << n << '\n'; }; } };
- 
使用形式 [=]捕获整个对象(请注意,通过[=]隐式捕获指针this在 C++20 中已被弃用):struct foo { int id; std::string name; auto run() { return [=] { std::cout << id << ' ' << name << '\n'; }; } };
- 
通过捕获 this指针捕获整个对象。如果需要调用类的其他方法,这是必要的。这可以捕获为[this]当指针通过值捕获时,或者[*this]当对象本身通过值捕获时。如果对象在捕获发生之后但在 lambda 调用之前可能超出作用域,这可能会产生重大差异:struct foo { int id; std::string name; auto run() { return[this]{ std::cout << id << ' ' << name << '\n'; }; } }; auto l = foo{ 42, "john" }.run(); l(); // does not print 42 john
在此情况下,正确的捕获应该是 [*this],以便对象通过值复制。在这种情况下,调用 lambda 将打印 42 john,即使临时变量已经超出作用域。
C++20 标准引入了对捕获指针 this 的几个更改:
- 
当使用 [=]时,它会弃用隐式捕获this。这将导致编译器发出弃用警告。
- 
当你想要使用 [=, this]显式捕获所有内容时,它引入了通过值捕获this指针。你仍然只能使用[this]捕获指针this。
有一些情况下,lambda 表达式仅在它们的参数方面有所不同。在这种情况下,lambda 可以以泛型方式编写,就像模板一样,但使用 auto 指定类型参数(不涉及模板语法)。这将在下一道菜谱中解决,正如即将到来的 参见 部分所注明的。
在 C++23 之前,属性可以指定在可选的异常指定符和可选的尾随返回类型之间的 lambda 表达式中。这些属性将应用于类型,而不是函数调用操作符。然而,如 [[nodiscard]] 或 [[noreturn]] 这样的属性仅在函数上才有意义,而不是类型。
因此,从 C++23 开始,这个限制已经改变,属性也可以被指定:
- 
在 lambda 引入符及其可选捕获之后,或者 
- 
在模板参数列表及其可选的 requires 子句之后。 
在 lambda 声明中的任何这些部分声明的属性应用于函数调用操作符,而不是类型。
让我们考察以下示例:
auto linc = [](int a) [[deprecated]] { return a+1; };
linc(42); 
[[deprecated]] 属性应用于 lambda 的类型,在编译代码片段时不会产生警告。在 C++23 中,我们可以写出以下代码:
auto linc = [][[nodiscard,deprecated]](int a) { return a+1; };
linc(42); 
通过这个变化,[[nodiscard]] 和 [[deprecated]] 属性都应用于 lambda 类型的函数调用操作符。这导致发出两个警告:一个是指示正在使用弃用的函数,另一个是指示返回类型被忽略。
参见
- 
使用泛型和模板 lambda,了解如何为 lambda 参数使用 auto并如何在 C++20 中定义模板 lambda
- 
编写递归 lambda,了解我们可以用来使 lambda 递归调用的技术 
- 
第四章,使用属性向编译器提供元数据,了解可用的标准属性以及如何使用它们 
使用泛型和模板 lambda
在前面的食谱中,我们看到了如何编写 lambda 表达式以及如何与标准算法一起使用它们。在 C++ 中,lambda 表达式基本上是无名函数对象的语法糖,这些对象是实现了 call 操作符的类。然而,就像任何其他函数一样,这可以通过模板进行泛型实现。C++14 利用这一点并引入了不需要为它们的参数指定实际类型的泛型 lambda,而是使用 auto 指示符。尽管没有使用这个名字,但泛型 lambda 实际上就是 lambda 模板。当我们需要使用相同的 lambda 但具有不同类型的参数时,它们非常有用。此外,C++20 标准更进一步,支持显式定义模板 lambda。这有助于一些泛型 lambda 令人繁琐的场景。
入门
建议你在继续阅读本食谱之前,先阅读前面的食谱,使用 lambda 与标准算法,以便熟悉 C++ 中 lambda 的基础知识。
如何做到这一点...
自 C++14 以来,我们可以编写泛型 lambda:
- 
通过使用 auto指示符而不是实际类型作为 lambda 表达式参数
- 
当我们需要使用多个 lambda 表达式,而这些 lambda 表达式仅通过它们的参数类型不同时 
以下示例展示了如何使用 std::accumulate() 算法使用泛型 lambda,首先使用整数向量,然后使用字符串向量:
auto numbers =
  std::vector<int>{0, 2, -3, 5, -1, 6, 8, -4, 9};
using namespace std::string_literals;
auto texts =
  std::vector<std::string>{"hello"s, " "s, "world"s, "!"s};
auto lsum = [](auto const s, auto const n) {return s + n;};
auto sum = std::accumulate(
  std::begin(numbers), std::end(numbers), 0, lsum);
  // sum = 22
auto text = std::accumulate(
  std::begin(texts), std::end(texts), ""s, lsum);
  // sum = "hello world!"s 
自 C++20 以来,我们可以编写模板 lambda:
- 
通过在捕获子句之后使用尖括号中的模板参数列表(例如 <template T>)
- 
当你想要: - 
仅对某些类型(如容器或满足概念的类型)限制泛型 lambda 的使用。 
- 
确保泛型 lambda 的两个或多个参数实际上具有相同的类型。 
- 
获取泛型参数的类型,例如,我们可以创建其实例,调用静态方法或使用其迭代器类型。 
- 
在泛型 lambda 中执行完美转发。 
 
- 
以下示例展示了一个只能使用 std::vector 调用的模板 lambda:
std::vector<int> vi { 1, 1, 2, 3, 5, 8 };
auto tl = []<typename T>(std::vector<T> const& vec)
{
   std::cout << std::size(vec) << '\n';
};
tl(vi); // OK, prints 6
tl(42); // error 
它是如何工作的...
在上一节的第一例中,我们定义了一个命名的 lambda 表达式——即,将它的闭包分配给变量的 lambda 表达式。然后,这个变量被传递给 std::accumulate() 函数作为参数。
这个通用算法接受开始和结束迭代器,这些迭代器定义了一个范围,一个要累加的初始值,以及一个函数,该函数将范围中的每个值累加到总和中。这个函数接受一个表示当前累加值的第一个参数和一个表示要累加到总和中当前值的第二个参数,并返回新的累加值。请注意,我没有使用术语 add,因为这不仅可以用于加法,还可以用于计算乘积、连接或其他聚合值的操作。
这个示例中的两次std::accumulate()调用几乎相同;只是参数的类型不同:
- 
在第一次调用中,我们传递了整数范围(来自 vector<int>)的迭代器、0 作为初始和,以及一个将两个整数相加并返回它们的和的 lambda。这会产生范围内所有整数的和;对于这个示例,它是22。
- 
在第二次调用中,我们传递了字符串范围(来自 vector<string>)的迭代器、一个空字符串作为初始值,以及一个通过将两个字符串相加并返回结果来连接两个字符串的 lambda。这会产生一个包含范围内所有字符串的字符串,一个接一个地放在一起;对于这个示例,结果是hello world!。
虽然泛型 lambda 可以在它们被调用的地方匿名定义,但这实际上并没有什么意义,因为泛型 lambda(基本上,如我们之前提到的,是一个 lambda 表达式模板)的主要目的就是为了重用,正如在如何做...部分中的示例所示。
在定义这个 lambda 表达式时,当与多个std::accumulate()调用一起使用时,我们不是为 lambda 参数指定具体类型(如int或std::string),而是使用了auto指定符,让编译器推断类型。
当遇到一个参数类型具有auto指定符的 lambda 表达式时,编译器会生成一个具有调用操作符模板的无名函数对象。对于这个示例中的泛型 lambda 表达式,函数对象看起来是这样的:
struct __lambda_name__
{
  template<typename T1, typename T2>
 auto operator()(T1 const s, T2 const n) const { return s + n; }
  __lambda_name__(const __lambda_name__&) = default;
  __lambda_name__(__lambda_name__&&) = default;
  __lambda_name__& operator=(const __lambda_name__&) = delete;
  ~__lambda_name__() = default;
}; 
调用操作符是一个模板,它为 lambda 中每个使用auto指定的参数有一个类型参数。调用操作符的返回类型也是auto,这意味着编译器将从返回值的类型中推断它。这个操作符模板将使用编译器在泛型 lambda 使用的上下文中识别的实际类型进行实例化。
C++20 的模板 lambda 是对 C++14 泛型 lambda 的改进,使得某些场景更容易实现。一个典型的例子是上一节中的第二个示例,其中 lambda 的使用被限制为std::vector类型的参数。另一个例子是当你想要确保 lambda 的两个参数具有相同的类型。在 C++20 之前,这很难做到,但有了模板 lambda,这非常简单,如下面的示例所示:
auto tl = []<typename T>(T x, T y)
{
  std::cout << x << ' ' << y << '\n';
};
tl(10, 20);   // OK
tl(10, "20"); // error 
模板 lambda 的另一个场景是当你需要知道参数的类型,以便你可以创建该类型的实例或调用它的静态成员时。使用泛型 lambda,解决方案如下:
struct foo
{
   static void f() { std::cout << "foo\n"; }
};
auto tl = [](auto x)
{
  using T = std::decay_t<decltype(x)>;
  T other;
  T::f();
};
tl(foo{}); 
这个解决方案需要使用std::decay_t和decltype。decltype是一个类型指定符,它返回指定表达式的类型,主要用于编写模板。另一方面,std::decay是来自<type_traits>的一个实用工具,它执行与通过值传递函数参数相同的类型转换。
然而,在 C++20 中,相同的 lambda 可以这样编写:
auto tl = []<typename T>(T x)
{
  T other;
  T::f();
}; 
当我们需要在泛型 lambda 中进行完美转发时,也会出现类似的情况,这需要使用 decltype 来确定参数的类型:
template <typename ...T>
void foo(T&& ... args)
{ /* ... */ }
auto tl = [](auto&& ...args)
{
  return foo(std::forward<decltype(args)>(args)...);
};
tl(1, 42.99, "lambda"); 
使用模板 lambda,我们可以以更简单的方式重写如下:
auto tl = []<typename ...T>(T && ...args)
{
  return foo(std::forward<T>(args)...);
}; 
如这些示例所示,模板 lambda 是对泛型 lambda 的改进,使得处理本食谱中提到的场景更加容易。
参见
- 
使用 lambda 与标准算法,以探索 lambda 表达式的基础知识以及如何利用它们与标准算法。 
- 
第一章,尽可能使用 auto,以了解 C++ 中自动类型推导的工作原理 
编写递归 lambda
Lambda 本质上是无名的函数对象,这意味着应该可以递归地调用它们。确实,它们可以递归地调用;然而,执行此操作的机制并不明显,因为它需要将 lambda 分配给函数包装器并通过引用捕获包装器。尽管可以争论递归 lambda 并没有真正意义,并且函数可能是一个更好的设计选择,但在本食谱中,我们将探讨如何编写递归 lambda。
准备工作
为了演示如何编写递归 lambda,我们将考虑斐波那契函数的著名示例。这通常在 C++ 中递归实现,如下所示:
constexpr int fib(int const n)
{
  return n <= 2 ? 1 : fib(n - 1) + fib(n - 2);
} 
以此实现作为起点,让我们看看我们如何使用递归 lambda 重写它。
如何做到这一点...
在 C++11 中,为了编写递归 lambda 函数,您必须执行以下操作:
- 
在函数作用域中定义 lambda。 
- 
将 lambda 分配给 std::function包装器。
- 
在 lambda 中通过引用捕获 std::function对象,以便递归地调用它。
在 C++14 中,可以使用泛型 lambda 简化上述模式:
- 
在函数作用域中定义 lambda。 
- 
使用 auto占位符声明第一个参数;这用于将 lambda 表达式作为参数传递给自己。
- 
通过传递 lambda 本身作为第一个参数来调用 lambda 表达式。 
在 C++23 中,此模式可以进一步简化如下:
- 
在函数作用域中定义 lambda。 
- 
声明第一个参数 this const auto&& self; 这是为了启用一个新的 C++23 特性,称为 推导 this 或 显式对象参数。您可以通过self参数递归调用 lambda 表达式。
- 
通过调用它并传递显式参数(如果有)来调用 lambda 表达式,并让编译器推导第一个参数。 
以下是一些递归 lambda 的示例:
- 
在从定义它的作用域调用的函数的作用域中返回的递归 Fibonacci lambda 表达式: void sample() { std::function<int(int const)> lfib = &lfib { return n <= 2 ? 1 : lfib(n - 1) + lfib(n - 2); }; auto f10 = lfib(10); }
- 
由函数返回的递归 Fibonacci lambda 表达式,可以从任何作用域调用: std::function<int(int const)> fib_create() { std::function<int(int const)> f = [](int const n) { std::function<int(int const)> lfib = &lfib { return n <= 2 ? 1 : lfib(n - 1) + lfib(n - 2); }; return lfib(n); }; return f; } void sample() { auto lfib = fib_create(); auto f10 = lfib(10); }
- 
作为类成员的 lambda 表达式,该类被递归调用: struct fibonacci { std::function<int(int const)> lfib = this { return n <= 2 ? 1 : lfib(n - 1) + lfib(n - 2); }; }; fibonacci f; f.lfib(10);
- 
一个递归的 Fibonacci 泛型 lambda 表达式——C++14 对第一个要点中例子的替代方案: void sample() { auto lfib = [](auto f, int const n) { if (n < 2) return 1; else return f(f, n - 1) + f(f, n - 2); }; lfib(lfib, 10); }
- 
一个递归的 Fibonacci lambda 表达式,利用了 C++23 中的显式对象参数(或推导此)功能,这是上述方法的进一步简化替代方案: void sample() { auto lfib = [](this const auto& self, int n) -> int { return n <= 2 ? 1 : self(n - 1) + self(n - 2); }; lfib(5); }
它是如何工作的...
当在 C++11 中编写递归 lambda 时,你需要考虑的第一件事是 lambda 表达式是一个函数对象,并且为了从 lambda 的主体中递归调用它,lambda 必须捕获其闭包(即 lambda 的实例化)。换句话说,lambda 必须捕获自身,这有几个含义:
- 
首先,lambda 必须有一个名称;无名的 lambda 不能被捕获以便再次调用。 
- 
其次,lambda 只能在函数作用域内定义。这是因为 lambda 只能捕获函数作用域中的变量;它不能捕获任何具有静态存储期的变量。在命名空间作用域中定义的对象或具有静态或外部指定符的对象具有静态存储期。如果 lambda 在命名空间作用域中定义,其闭包将具有静态存储期,因此 lambda 不会捕获它。 
- 
第三个含义是 lambda 闭包的类型不能保持未指定;也就是说,不能使用 auto指定符声明。使用auto类型指定符声明的变量不能出现在其自己的初始化器中。这是因为当处理初始化器时,变量的类型是未知的。因此,你必须指定 lambda 闭包的类型。我们可以通过使用通用函数包装器std::function来实现这一点。
- 
最后但同样重要的是,lambda 闭包必须通过引用捕获。如果我们通过复制(或值)捕获,那么将创建函数包装器的副本,但在捕获时包装器未初始化。我们最终得到一个无法调用的对象。即使编译器不会对通过值捕获提出抱怨,当闭包被调用时,会抛出 std::bad_function_call。
在“如何做……”部分的第一个例子中,递归 lambda 表达式定义在另一个名为sample()的函数内部。lambda 表达式的签名和主体与在介绍部分定义的常规递归函数fib()的签名和主体相同。lambda 闭包被分配给一个名为lfib的函数包装器,然后 lambda 通过引用捕获它,并从其主体中递归调用。由于闭包是通过引用捕获的,因此它将在 lambda 主体需要调用时初始化。
在第二个例子中,我们定义了一个返回 lambda 表达式闭包的函数,该 lambda 表达式反过来定义并调用一个递归 lambda,该递归 lambda 使用它被依次调用的参数。这是一个在需要从函数返回递归 lambda 时必须实现的模式。这是必要的,因为 lambda 闭包必须在递归 lambda 被调用时仍然可用。fib_create()方法返回一个函数包装器,当被调用时,创建一个捕获自身的递归 lambda。外部的f lambda 没有捕获任何东西,特别是通过引用;因此,我们不会遇到悬垂引用的问题。然而,当被调用时,它创建了一个嵌套 lambda 的闭包,这是我们真正想要调用的 lambda,并返回将递归lfib lambda 应用于其参数的结果。
在 C++14 中编写递归 lambda 更简单,如如何做…部分的第四个例子所示。不是捕获 lambda 的闭包,而是将其作为参数传递(通常是第一个)。为此,使用auto占位符声明了一个参数。让我们回顾一下实现,以便讨论它:
auto lfib = [](auto f, int const n)
{
   if (n < 2) return 1;
   else return f(f, n - 1) + f(f, n - 2);
};
lfib(lfib, 10); 
lambda 表达式是一个具有函数调用操作符的函数对象。一个泛型 lambda 是一个具有模板函数调用操作符的函数对象。编译器为前面的代码片段生成类似于以下代码的代码:
class __lambda_name_3
{
public:
   template<class T1>
 inline int operator()(T1 f, const int n) const
 {
      if (n < 2) {
         return 1;
      }
      else {
         return f(f, n - 1) + f(f, n - 2);
      }
   }
   template<>
   inline int operator()<__lambda_name_3> (__lambda_name_3 f, 
 const int n) const
 {
      if (n < 2) {
         return 1;
      }
      else {
         return f.operator()(__lambda_name_3(f), n - 1) + 
                f.operator()(__lambda_name_3(f), n - 2);
      }
   }
};
__lambda_name_3 lfib = __lambda_name_3{};
lfib.operator()(__lambda_name_3(lfib), 10); 
函数调用操作符是一个模板函数。它的第一个参数具有类型模板参数的类型。对于这个主要模板,提供了对类类型的完整显式特化。这使得可以调用 lambda,将自身作为参数传递,从而避免捕获std::function对象,这在 C++11 中是必须做的。
如果你的编译器支持 C++23,那么在显式对象参数功能(也称为推导 this)的帮助下,可以进一步简化这一点。这个功能是为了使编译器能够从函数内部确定它被调用的表达式是一个左值还是右值,或者它是否是cv-或ref-限定,以及表达式的类型。这个功能使得以下场景成为可能:
- 
通过基于重载的cv-和ref-限定符(例如,没有限定符和具有 const限定符的相同函数,这是最常见的情况)避免代码重复。
- 
通过使用简单的继承来简化奇特重复模板模式(CRTP),从而从模式中去除重复。 
- 
简化编写递归 lambda。 
对于如何做…部分给出的例子,编译器能够推断出第一个参数self的类型,这使得不需要显式传递 lambda 闭包作为参数。
注意,在 C++23 的例子中,我们使用尾随返回类型语法定义了一个 lambda 表达式:
[](this auto const & self, int n) -> int 
没有这个,你会得到如下编译器错误:
error: function 'operator()<(lambda)>' with deduced return type cannot be used before it is defined 
通过对函数实现进行微小更改,如以下所示,不再需要尾随返回类型,并且推导这个特性再次工作:
auto lfib = [](this auto const& self, int n)
{
   if (n <= 2) return 1;
   return self(n - 1) + self(n - 2);
}; 
参见
- 
使用通用和模板 lambda,学习如何在 C++20 中使用 auto作为 lambda 参数以及如何定义模板 lambda
- 
第九章,使用怪异重复模板模式进行静态多态,了解 CRTP 是什么以及它是如何工作的 
编写函数模板
通用代码是避免编写重复代码的关键。在 C++中,这是通过模板实现的。类、函数和变量都可以进行模板化。尽管模板通常被视为复杂且繁琐,但它们能够创建通用库,例如标准库,并帮助我们编写更少且更好的代码。
模板是 C++语言的一等公民,可能需要整本书来详细说明。实际上,这本书中的多个菜谱都处理了模板的各个方面。在本菜谱中,我们将讨论编写函数模板的基础。
如何做到这一点...
要创建函数模板,请执行以下操作:
- 
要创建一个函数模板,在函数声明前加上 template关键字,后跟尖括号中的模板参数列表:template <typename T> T minimum(T a, T b) { return a <= b ? a : b; } minimum(3, 4); minimum(3.99, 4.01);
- 
要专门化一个函数模板,在函数签名中留空模板参数列表,并用实际类型或值替换模板参数: template <> const char* minimum(const char* a, const char* b) { return std::strcmp(a, b) <= 1 ? a : b; } minimum("abc", "acxyz");
- 
要重载函数模板,提供另一个定义,这可以是模板或非模板: template <typename T> std::basic_string<T> minimum(std::basic_string<T> a, std::basic_string<T> b) // [1] { return a.length() <= b.length() ? a : b; } std::string minimum(std::string a, std::string b) // [2] { return a.length() <= b.length() ? a : b; } minimum(std::string("def"), std::string("acxyz")); // calls [2] minimum(std::wstring(L"def"), std::wstring(L"acxyz")); // calls [1]
- 
要确保特定的函数模板或函数模板的专门化不能被调用(从重载集中删除),请将其声明为 deleted:template <typename T> T* minimum(T* a, T* b) = delete; int a = 3; int b = 4; minimum(&a, &b); // error
它是如何工作的...
至少乍一看,函数模板与其他函数只有细微的差别。它们使用模板语法引入,可以用类型、值甚至其他模板进行参数化。然而,由于模板只是创建实际代码的蓝图,函数模板基本上是一个定义函数族的蓝图。模板仅在源代码中存在,直到它们被使用。
编译器根据其使用情况实例化模板。这个过程称为模板实例化。编译器通过替换模板参数来完成此操作。例如,在前面展示的minimum<T>函数模板的情况下,当我们以minimum<int>(1, 2)的方式调用它时,编译器将int类型替换为T参数。存在两种实例化的形式:
- 
隐式实例化发生在编译器根据代码中使用的模板生成代码时。例如,如果您的代码中通过 int和double值调用minimum<T>函数,那么将生成两个重载(一个带有整数参数,另一个带有double参数)。这被称为隐式实例化,如下面的代码片段所示:minimum<int>(1, 2); // explicit int template argument minimum(3.99, 4.50); // deduced double template argument
- 
显式实例化发生在您作为用户请求编译器从模板生成代码,即使该实例化在代码中没有使用时。这种用法的一个例子是在创建库(二进制)文件时,因为未实例化的模板(它们只是蓝图)不会被放入对象文件中。以下是一个为 char类型的minimum<T>函数显式实例化的示例。请注意,如果显式实例化没有在模板所在的同一命名空间中定义,则必须在显式实例化定义中使用完全限定的名称:template char minimum(char a, char b);
如前所述,模板可以有不同的参数类型。这些参数位于 template 关键字之后的角度括号中,可以是以下类型:
- 
类型模板参数,其中参数是类型的占位符。这是前一个章节中看到的所有示例的情况。 
- 
非类型模板参数,其中参数是结构化类型的值。整数类型、浮点类型(自 C++20 起)、指针类型、枚举类型和左值引用类型都是结构化类型。在下面的示例中, T是一个类型模板参数,而S是一个非类型模板参数:template <typename T, std::size_t S> std::array<T, S> make_array() { return std::array<T, S>{}; }
在 C++17 中,可以使用 auto 关键字声明非类型模板参数:
template <typename T, auto S>
std::array<T, S> make_array()
{
   return std::array<T, S>{};
} 
- 
模板模板参数,其中参数的类型是另一个类型。在下面的示例中, trimin函数模板有两个模板参数,一个类型模板参数T和一个模板模板参数M:template <typename T> struct Minimum { T operator()(T a, T b) { return a <= b ? a : b; } }; template <typename T, template <typename> class M> T trimin(T a, T b, T c) { return M<T>{}(a, M<T>{}(b, c)); } trimin<int, Minimum>(5, 2, 7);
虽然模板允许我们为许多类型(或更一般地说,模板参数)编写一个实现,但为不同类型提供修改后的实现通常是有用的,或者可能是必要的。为某些模板参数提供替代实现的过程称为特化。正在特化的模板称为主模板。有两种可能的形式:
- 
部分特化是指只为某些模板参数提供不同的实现。 
- 
完全特化是指为模板参数的整个集合提供不同的实现。 
函数模板仅支持完全特化。部分特化仅适用于类模板。在如何做到这一点…部分提供了一个完全特化的例子,当时我们为const char*类型特化了minimum<T>函数模板。我们决定不是基于两个参数的字典顺序比较,而是根据它们的长度来决定哪个“更小”。请记住,这只是一个为了理解特化而给出的例子。
函数模板可以像任何其他函数一样重载。请注意,当有多个重载可用,包括模板和非模板时,编译器将优先选择非模板重载。前面已经提供了一个例子。让我们再次看看,只包含函数的声明:
template <typename T>
std::basic_string<T> minimum(std::basic_string<T> a, std::basic_string<T> b);
std::string minimum(std::string a, std::string b);
minimum(std::string("def"), std::string("acxyz"));
minimum(std::wstring(L"def"), std::wstring(L"acxyz")); 
对minimum函数的第一个调用接受std::string参数,因此将调用非模板重载。第二个调用接受std::wstring参数,由于函数模板是唯一匹配的重载,因此将调用其std::wstring实例化。
在调用函数模板时指定模板参数并不总是必要的。以下两个调用是相同的:
minimum(1, 2);
minimum<int>(1, 2); 
在许多情况下,编译器可以从函数的调用中推导出模板参数。在这个例子中,由于两个函数参数都是整数,它可以推断出模板参数应该是int类型。因此,明确指定这一点是不必要的。然而,也存在编译器无法推导类型的情况。在这些情况下,您必须明确提供它们。下面将给出一个例子:
minimum(1, 2u); // error, ambiguous template parameter T 
两个参数是一个int和一个unsigned int。因此,编译器不知道T类型应该推断为int还是unsigned int。为了解决这种歧义,您必须明确提供模板参数:
minimum<unsigned>(1, 2u); // OK 
在推导模板参数时,编译器会在模板参数和用于调用函数的参数之间进行比较。为了使比较成功并让编译器成功推导出所有参数,这些参数必须具有某种结构。然而,对这个过程的详细探讨超出了本菜谱的范围。您可以查阅其他资源,包括我的书籍《使用 C++的模板元编程》,其中在第四章详细讨论了这一点,包括函数模板和类模板。
如介绍中所述,模板是一个广泛的主题,无法在一道菜谱中涵盖。我们将在整本书中学习更多关于模板的内容,包括在接下来的两个菜谱中,我们将讨论具有可变数量参数的函数模板。
参见
- 
编写具有可变数量参数的函数模板,以了解如何编写接受可变数量参数的函数 
- 
第一章,使用类模板参数推导简化代码,以了解模板参数推导对类模板的工作方式。 
编写具有可变数量参数的函数模板
有时编写具有可变数量参数的函数或具有可变数量成员的类是有用的。典型的例子包括像printf这样的函数,它接受一个格式和可变数量的参数,或者像tuple这样的类。在 C++11 之前,前者只能通过使用可变宏(它只能编写不安全的函数)来实现,而后者根本不可能。C++11 引入了可变模板,这些是具有可变数量参数的模板,使得可以编写具有可变数量参数的类型安全函数模板,以及具有可变数量成员的类模板。在这个菜谱中,我们将探讨编写函数模板。
准备工作
具有可变数量参数的函数被称为可变参数函数。具有可变数量参数的函数模板被称为可变参数函数模板。了解 C++可变参数宏(va_start、va_end、va_arg、va_copy和va_list)对于学习如何编写可变参数函数模板不是必需的,但它是一个很好的起点。
我们已经在之前的菜谱中使用了可变模板,但这个将提供详细的解释。
如何做到这一点...
为了编写可变参数模板函数,你必须执行以下步骤:
- 
定义一个具有固定数量参数的重载,如果可变参数模板函数的语义需要结束编译时递归(参见以下代码中的[1])。 
- 
定义一个模板参数包,它是一个可以存储任意数量参数的模板参数,包括零;这些参数可以是类型、非类型或模板(参见[2])。 
- 
定义一个函数参数包以存储任意数量的函数参数,包括零;模板参数包的大小和相应的函数参数包的大小相同。这个大小可以用 sizeof...运算符确定(参见[3]),并参考如何工作...部分的结尾以获取有关此运算符的信息)。
- 
扩展参数包以便用提供的实际参数替换它(参见[4])。 
以下示例,它说明了所有前面的点,是一个使用operator+添加可变数量参数的可变参数函数模板:
template <typename T>                 // [1] overload with fixed
T add(T value) //     number of arguments
{
  return value;
}
template <typename T, typename... Ts> // [2] typename... Ts
T add(T head, Ts... rest) // [3] Ts... rest
{
  return head + add(rest...);         // [4] rest...
} 
如何工作...
乍一看,前面的实现看起来像是递归,因为 add() 函数调用了自身,从某种意义上说确实是,但它是一种编译时递归,不会产生任何运行时递归和开销。实际上,编译器根据变长函数模板的使用生成具有不同参数数量的几个函数,因此只涉及函数重载,而不是递归。然而,实现时似乎参数是以递归方式处理的,有一个结束条件。
在前面的代码中,我们可以识别出以下关键部分:
- 
Typename... Ts是一个模板参数包,表示可变数量的模板类型参数。
- 
Ts... rest是一个函数参数包,表示可变数量的函数参数。
- 
rest...是函数参数包的展开。
省略号的位置在语法上并不重要。typename... Ts、typename ... Ts 和 typename ...Ts 都是等效的。
在 add(T head, Ts... rest) 参数中,head 是参数列表中的第一个元素,而 ...rest 是包含列表中其余参数的包(这可以是零个或多个)。在函数体中,rest... 是函数参数包的展开。这意味着编译器将参数包及其元素按顺序替换。在 add() 函数中,我们基本上将第一个参数添加到剩余参数的总和中,这给人一种递归处理的印象。这种递归在只剩下一个参数时结束,此时调用第一个 add() 重载(单个参数)并返回其参数的值。
这种函数模板 add() 的实现使我们能够编写如下所示的代码:
auto s1 = add(1, 2, 3, 4, 5); // s1 = 15
auto s2 = add("hello"s, " "s, "world"s, "!"s); // s2 = "hello world!" 
当编译器遇到 add(1, 2, 3, 4, 5) 时,它会生成以下函数(注意 arg1、arg2 等不是编译器实际生成的名称),这表明这个过程实际上只是一系列对重载函数的调用,而不是递归:
int add(int head, int arg1, int arg2, int arg3, int arg4)
{return head + add(arg1, arg2, arg3, arg4);}
int add(int head, int arg1, int arg2, int arg3)
{return head + add(arg1, arg2, arg3);}
int add(int head, int arg1, int arg2)
{return head + add(arg1, arg2);}
int add(int head, int arg1)
{return head + add(arg1);}
int add(int value)
{return value;} 
使用 GCC 和 Clang,你可以使用 __PRETTY_FUNCTION__ 宏来打印函数的名称和签名。
通过添加 std::cout << __PRETTY_FUNCTION__ << std::endl,当使用 GCC 或 Clang 时,在两个我们编写的函数的开始处,运行代码时会得到以下结果:
- 
使用 GCC: T add(T, Ts ...) [with T = int; Ts = {int, int, int, int}] T add(T, Ts ...) [with T = int; Ts = {int, int, int}] T add(T, Ts ...) [with T = int; Ts = {int, int}] T add(T, Ts ...) [with T = int; Ts = {int}] T add(T) [with T = int]
- 
使用 Clang: T add(T, Ts...) [T = int, Ts = <int, int, int, int>] T add(T, Ts...) [T = int, Ts = <int, int, int>] T add(T, Ts...) [T = int, Ts = <int, int>] T add(T, Ts...) [T = int, Ts = <int>] T add(T) [T = int]
由于这是一个函数模板,它可以与支持 operator+ 操作符的任何类型一起使用。另一个例子,add("hello"s, " "s, "world"s, "!"s) 产生 hello world! 字符串。然而,std::basic_string 类型对 operator+ 有不同的重载,包括一个可以将字符串连接到字符的重载,因此我们应该能够编写以下代码:
auto s3 = add("hello"s, ' ', "world"s, '!'); // s3 = "hello world!" 
然而,这将生成编译器错误,如下所示(注意我实际上用字符串 hello world! 替换了 std::basic_string<char, std::char_traits<char>, std::allocator<char> > 以简化问题):
In instantiation of 'T add(T, Ts ...) [with T = char; Ts = {string, char}]':
16:29:   required from 'T add(T, Ts ...) [with T = string; Ts = {char, string, char}]'
22:46:   required from here
16:29: error: cannot convert 'string' to 'char' in return
 In function 'T add(T, Ts ...) [with T = char; Ts = {string, char}]':
17:1: warning: control reaches end of non-void function [-Wreturn-type] 
发生的情况是编译器生成了这里显示的代码,其中返回类型与第一个参数的类型相同。然而,第一个参数要么是std::string要么是char(为了简单起见,std::basic_string<char, std::char_traits<char>, std::allocator<char> >被替换为string)。在第一个参数的类型是char的情况下,返回值head+add (...)的类型,它是一个std::string,与函数返回类型不匹配,并且没有到它的隐式转换:
string add(string head, char arg1, string arg2, char arg3)
{return head + add(arg1, arg2, arg3);}
char add(char head, string arg1, char arg2)
{return head + add(arg1, arg2);}
string add(string head, char arg1)
{return head + add(arg1);}
char add(char value)
{return value;} 
我们可以通过修改变长函数模板,使其返回类型为auto而不是T来解决这个问题。在这种情况下,返回类型总是从返回表达式中推断出来的,在我们的例子中,在所有情况下都将是std::string:
template <typename T, typename... Ts>
auto add(T head, Ts... rest)
{
  return head + add(rest...);
} 
应进一步说明参数包可以出现在花括号初始化中,并且可以使用sizeof...运算符确定其大小。此外,变长函数模板不一定意味着编译时递归,正如我们在本食谱中所展示的。所有这些都在以下示例中展示:
template<typename... T>
auto make_even_tuple(T... a)
{
  static_assert(sizeof...(a) % 2 == 0,
                "expected an even number of arguments");
  std::tuple<T...> t { a... };
  return t;
}
auto t1 = make_even_tuple(1, 2, 3, 4); // OK
// error: expected an even number of arguments
auto t2 = make_even_tuple(1, 2, 3); 
sizeof...(a) to make sure that we have an even number of arguments and assert by generating a compiler error otherwise. The sizeof... operator can be used with both template parameter packs and function parameter packs. sizeof...(a) and sizeof...(T) would produce the same value. Then, we create and return a tuple. 
模板参数包T被展开(使用T...)为std::tuple类模板的类型参数,函数参数包a被展开(使用a...)为元组成员的值,使用花括号初始化。
参见
- 
使用折叠表达式简化变长函数模板,了解如何在创建具有可变数量参数的函数模板时编写更简单、更清晰的代码 
- 
第二章,创建原始用户定义字面量,了解如何提供对输入序列的定制解释,以便改变编译器的正常行为 
使用折叠表达式简化变长函数模板
在本章中,我们已经多次讨论了折叠;这是一个将二元函数应用于值范围以产生单个值的操作。我们在讨论变长函数模板时看到了这一点,我们还将再次在高阶函数中看到它。结果证明,在变长函数模板中参数包的展开基本上是一个折叠操作的情况有很多。为了简化编写这样的变长函数模板,C++17 引入了折叠表达式,它将参数包的展开折叠到二元运算符上。在本食谱中,我们将学习如何使用折叠表达式来简化编写变长函数模板。
准备工作
本食谱中的示例基于我们在上一食谱中编写的变长函数模板add (),即编写具有可变数量参数的函数模板。该实现是一个左折叠操作。为了简单起见,我们将再次展示该函数:
template <typename T>
T add(T value)
{
  return value;
}
template <typename T, typename... Ts>
T add(T head, Ts... rest)
{
  return head + add(rest...);
} 
在下一节中,我们将学习如何简化这种特定的实现,以及其他使用折叠表达式的示例。
如何操作...
要在二元运算符上折叠参数包,可以使用以下形式之一:
- 
使用一元形式 (... op pack)的左折叠:template <typename... Ts> auto add(Ts... args) { return (... + args); }
- 
使用二元形式 (init op ... op pack)的左折叠:template <typename... Ts> auto add_to_one(Ts... args) { return (1 + ... + args); }
- 
使用一元形式 (pack op ...)的右折叠:template <typename... Ts> auto add(Ts... args) { return (args + ...); }
- 
使用二元形式 (pack op ... op init)的右折叠:template <typename... Ts> auto add_to_one(Ts... args) { return (args + ... + 1); }这里显示的括号是折叠表达式的一部分,不能省略。 
它是如何工作的...
当编译器遇到折叠表达式时,它会将其展开为以下表达式之一:
| 表达式 | 展开 | 
|---|---|
| (... op pack) | ((pack$1 op pack$2) op ...) op pack$n | 
| (init op ... op pack) | (((init op pack$1) op pack$2) op ...) op pack$n | 
| (pack op ...) | pack$1 op (... op (pack$n-1 op pack$n)) | 
| (pack op ... op init) | pack$1 op (... op (pack$n-1 op (pack$n op init))) | 
表 3.2:折叠表达式的可能形式
当使用二元形式时,省略号左右两边的运算符必须相同,并且初始化值不能包含未展开的参数包。
支持以下二元运算符与折叠表达式一起使用:
| + | - | * | / | % | ^ | & | | | = | < | > | << | 
|---|---|---|---|---|---|---|---|---|---|---|---|
| >> | += | -= | *= | /= | %= | ^= | &= | |= | <<= | >>= | == | 
| != | <= | >= | && | || | , | .* | ->*. | 
表 3.3:与折叠表达式一起支持的二元运算符
当使用一元形式时,只有 *、+、&、|、&&、|| 和 ,(逗号)这样的运算符可以与空参数包一起使用。在这种情况下,空包的值如下:
| 运算符 | 空包值 | 
|---|---|
| + | 0 | 
| * | 1 | 
| & | -1 | 
| | | 0 | 
| && | true | 
| || | false | 
| , | void() | 
表 3.4:可以使用空参数包的运算符
现在我们有了之前实现的功能模板(让我们考虑左折叠版本),我们可以编写以下代码:
auto sum = add(1, 2, 3, 4, 5);         // sum = 15
auto sum1 = add_to_one(1, 2, 3, 4, 5); // sum = 16 
考虑 add(1, 2, 3, 4, 5) 调用,它将产生以下函数:
int add(int arg1, int arg2, int arg3, int arg4, int arg5)
{
  return ((((arg1 + arg2) + arg3) + arg4) + arg5);
} 
值得注意的是,由于现代编译器在优化方面的积极方式,这个函数可以被内联,最终我们可能会得到一个如 auto sum = 1 + 2 + 3 + 4 + 5 的表达式。
还有更多...
折叠表达式与支持的所有二元运算符的重载一起工作,但不与任意二元函数一起工作。可以通过提供一个将包含值和该包装器类型的重载运算符的包装器类型来实现一个解决方案:
template <typename T>
struct wrapper
{
  T const & value;
};
template <typename T>
constexpr auto operator<(wrapper<T> const & lhs, wrapper<T> const & rhs)
{
  return wrapper<T> {lhs.value < rhs.value ? lhs.value : rhs.value};
} 
在前面的代码中,wrapper 是一个简单的类模板,它持有类型 T 的值的常量引用。为此类模板提供了一个重载的 operator<;这个重载不返回布尔值来指示第一个参数小于第二个参数,而是实际上返回一个 wrapper 类型的实例来保存两个参数中的最小值。这里显示的变长函数模板 min() 使用这个重载的 operator< 来折叠展开为 wrapper 类模板实例的参数包:
template <typename... Ts>
constexpr auto min(Ts&&... args)
{
  return (wrapper<Ts>{args} < ...).value;
}
auto m = min(3, 1, 2); // m = 1 
此 min() 函数被编译器扩展为类似以下内容:
template<>
inline constexpr int min<int, int, int>(int && __args0,
                                        int && __args1,
                                        int && __args2)
{
  return
operator<(wrapper_min<int>{__args0},
      operator<(wrapper_min<int>{__args1},
                wrapper_min<int>{__args2})).value;
} 
我们在这里可以看到的是对二进制 operator < 的级联调用,返回 Wrapper<int> 值。没有这个,使用折叠表达式实现的 min() 函数的实现将是不可能的。以下实现不起作用:
template <typename... Ts>
constexpr auto minimum(Ts&&... args)
{
  return (args < ...);
} 
根据调用 min(3, 1, 2),编译器将将其转换为以下类似的内容:
template<>
inline constexpr bool minimum<int, int, int>(int && __args0,
                                             int && __args1,
                                             int && __args2)
{
  return __args0 < (static_cast<int>(__args1 < __args2));
} 
结果是一个返回布尔值的函数,而不是实际整数值,这是提供的参数之间的最小值。
参见
- 实现高阶函数 map 和 fold,了解函数式编程中的高阶函数以及如何实现广泛使用的 map和fold(或reduce)函数
实现高阶函数 map 和 fold
在本书前面的食谱中,我们在几个示例中使用了通用算法 std::transform() 和 std::accumulate(),例如用于实现字符串实用工具以创建字符串的大写或小写副本,或用于计算范围值的总和。
这些基本上是高阶函数 map 和 fold 的实现。高阶函数是一种接受一个或多个其他函数作为参数并将它们应用于范围(列表、向量、映射、树等)的函数,从而产生一个新的范围或一个值。在本食谱中,我们将学习如何实现 map 和 fold 函数,以便它们能够与 C++ 标准容器一起工作。
准备工作
map 是一种高阶函数,它将函数应用于范围中的元素并返回一个新范围,顺序相同。
fold 是一种高阶函数,它将组合函数应用于范围中的元素以产生单个结果。由于处理顺序可能很重要,通常有两个版本的此函数。一个是 fold_left,它从左到右处理元素,而另一个是 fold_right,它从右到左组合元素。
大多数关于函数 map 的描述表明它应用于列表,但这是一个通用术语,可以指代不同的顺序类型,如列表、向量、数组,以及字典(即映射)、队列等。因此,我更喜欢在描述这些高阶函数时使用术语范围。
例如,映射操作可以将字符串范围转换为表示每个字符串长度的整数范围。然后,折叠操作可以将这些长度相加,以确定所有字符串的总长度。
如何做到这一点...
要实现 map 函数,你应该:
- 
在支持迭代和元素赋值的容器上使用 std::transform,例如std::vector或std::list:template <typename F, typename R> R mapf(F&& func, R range) { std::transform( std::begin(range), std::end(range), std::begin(range), std::forward<F>(func)); return range; }
- 
对于不支持对元素进行赋值的容器,例如 std::map和std::queue,使用其他方法,如显式迭代和插入:template<typename F, typename T, typename U> std::map<T, U> mapf(F&& func, std::map<T, U> const & m) { std::map<T, U> r; for (auto const kvp : m) r.insert(func(kvp)); return r; } template<typename F, typename T> std::queue<T> mapf(F&& func, std::queue<T> q) { std::queue<T> r; while (!q.empty()) { r.push(func(q.front())); q.pop(); } return r; }
要实现 fold 函数,你应该:
- 
在支持迭代的容器上使用 std::accumulate():template <typename F, typename R, typename T> constexpr T fold_left(F&& func, R&& range, T init) { return std::accumulate( std::begin(range), std::end(range), std::move(init), std::forward<F>(func)); } template <typename F, typename R, typename T> constexpr T fold_right(F&& func, R&& range, T init) { return std::accumulate( std::rbegin(range), std::rend(range), std::move(init), std::forward<F>(func)); }
- 
对于不支持迭代的容器,例如 std::queue,使用其他方法显式处理:template <typename F, typename T> constexpr T fold_left(F&& func, std::queue<T> q, T init) { while (!q.empty()) { init = func(init, q.front()); q.pop(); } return init; }
它是如何工作的...
在前面的示例中,我们以函数式的方式实现了 map 高阶函数,没有副作用。这意味着它保留了原始范围并返回一个新的范围。函数的参数是应用函数和范围。为了避免与 std::map 容器混淆,我们称此函数为 mapf。mapf 有几个重载,如前所述:
- 
第一个重载适用于支持迭代和对其元素进行赋值的容器;这包括 std::vector、std::list、std::array,以及 C 类型的数组。函数接受一个函数的右值引用和一个定义了std::begin()和std::end()的范围。范围按值传递,以便修改局部副本不会影响原始范围。范围通过使用标准算法std::transform()对每个元素应用给定函数进行转换;然后返回转换后的范围。
- 
第二个重载专门针对 std::map,它不支持直接对其元素进行赋值(std::pair<T, U>)。因此,此重载创建一个新的映射,然后使用基于范围的for循环遍历其元素,并将将输入函数应用于原始映射的每个元素的结果插入到新映射中。
- 
第三个重载专门针对 std::queue,它是一个不支持迭代的容器。可以争辩说队列不是映射的典型结构,但为了演示不同的可能实现,我们正在考虑它。为了遍历队列的元素,队列必须被修改——你需要从前面弹出元素直到列表为空。这就是第三个重载所做的事情——它处理输入队列(按值传递)的每个元素,并将给定函数应用于剩余队列的前端元素的结果推送到队列的前端。
现在我们已经实现了这些重载,我们可以将它们应用到许多容器中,如下面的示例所示:
- 
保留向量中的绝对值。在这个例子中,向量包含正负值。应用映射后,结果是只包含正值的新的向量: auto vnums = std::vector<int>{0, 2, -3, 5, -1, 6, 8, -4, 9}; auto r =mapf([](int const i) { return std::abs(i); }, vnums); // r = {0, 2, 3, 5, 1, 6, 8, 4, 9}
- 
平方列表中的数值。在这个例子中,列表包含整数。应用映射后,结果是包含初始值平方的列表: auto lnums = std::list<int>{1, 2, 3, 4, 5}; auto l = mapf([](int const i) { return i*i; }, lnums); // l = {1, 4, 9, 16, 25}
- 
浮点数的四舍五入。对于这个例子,我们需要使用 std::round();然而,它对所有浮点类型都有重载,这使得编译器无法选择正确的类型。因此,我们要么编写一个 lambda,该 lambda 接受特定浮点类型的参数并返回应用于该值的std::round()的值,要么创建一个函数对象模板,该模板包装std::round()并仅允许浮点类型调用其调用操作符。这种技术在下例中使用:template<class T = double> struct fround { typename std::enable_if_t<std::is_floating_point_v<T>, T> operator()(const T& value) const { return std::round(value); } }; auto amounts = std::array<double, 5> {10.42, 2.50, 100.0, 23.75, 12.99}; auto a = mapf(fround<>(), amounts); // a = {10.0, 3.0, 100.0, 24.0, 13.0}
- 
将单词映射的字符串键转换为大写(其中键是单词,值是文本中的出现次数)。请注意,创建字符串的大写副本本身就是一个映射操作。因此,在这个例子中,我们使用 mapf将toupper()应用于表示键的字符串元素,以生成大写副本:auto words = std::map<std::string, int>{ {"one", 1}, {"two", 2}, {"three", 3} }; auto m = mapf( [](std::pair<std::string, int> const kvp) { return std::make_pair( funclib::mapf(toupper, kvp.first), kvp.second); }, words); // m = {{"ONE", 1}, {"TWO", 2}, {"THREE", 3}}
- 
标准化优先级队列中的值;最初,这些值从 1 到 100,但我们希望将它们标准化为两个值,1=高,2=正常。所有初始优先级值在 30 及以下的都获得高优先级;其余的获得正常优先级: auto priorities = std::queue<int>(); priorities.push(10); priorities.push(20); priorities.push(30); priorities.push(40); priorities.push(50); auto p = mapf( [](int const i) { return i > 30 ? 2 : 1; }, priorities); // p = {1, 1, 1, 2, 2}
要实现fold,我们实际上必须考虑两种可能的折叠方式——即从左到右和从右到左。因此,我们提供了两个函数,称为fold_left(用于左折叠)和fold_right(用于右折叠)。前一个章节中展示的实现非常相似:它们都接受一个函数、一个范围和一个初始值,并调用std::accumulate()将范围的值折叠成一个单一值。然而,fold_left使用直接迭代器,而fold_right使用反向迭代器遍历和处理范围。第二个重载是一个针对类型std::queue的特殊化,因为std::queue没有迭代器。
基于这些折叠实现,我们可以实现以下示例:
- 
添加整数向量的值。在这种情况下,左折叠和右折叠将产生相同的结果。在以下示例中,我们传递一个 lambda,该 lambda 接受一个总和和一个数字,并返回一个新的总和,或者传递标准库中的函数对象 std::plus<>,该对象将operator+应用于相同类型的两个操作数(基本上类似于 lambda 的闭包):auto vnums = std::vector<int>{0, 2, -3, 5, -1, 6, 8, -4, 9}; auto s1 = fold_left( [](const int s, const int n) {return s + n; }, vnums, 0); // s1 = 22 auto s2 = fold_left( std::plus<>(), vnums, 0); // s2 = 22 auto s3 = fold_right( [](const int s, const int n) {return s + n; }, vnums, 0); // s3 = 22 auto s4 = fold_right( std::plus<>(), vnums, 0); // s4 = 22
- 
将向量中的字符串连接成一个单一字符串: auto texts = std::vector<std::string>{"hello"s, " "s, "world"s, "!"s}; auto txt1 = fold_left( [](std::string const & s, std::string const & n) { return s + n;}, texts, ""s); // txt1 = "hello world!" auto txt2 = fold_right( [](std::string const & s, std::string const & n) { return s + n; }, texts, ""s); // txt2 = "!world hello"
- 
将字符数组连接成一个字符串: char chars[] = {'c','i','v','i','c'}; auto str1 = fold_left(std::plus<>(), chars, ""s); // str1 = "civic" Auto str2 = fold_right(std::plus<>(), chars, ""s); // str2 = "civic"
- 
根据已计算的词频统计文本中的单词数量,这些词频存储在 map<string, int>中:auto words = std::map<std::string, int>{ {"one", 1}, {"two", 2}, {"three", 3} }; auto count = fold_left( [](int const s, std::pair<std::string, int> const kvp) { return s + kvp.second; }, words, 0); // count = 6
更多...
这些函数可以被管道化——也就是说,它们可以用另一个函数的结果调用一个函数。以下示例通过将 std::abs() 函数应用于其元素,将一系列整数映射到一系列正整数。然后将结果映射到另一个平方数的范围。这些数通过在范围上应用左折叠而相加:
auto vnums = std::vector<int>{ 0, 2, -3, 5, -1, 6, 8, -4, 9 };
auto s = fold_left(
  std::plus<>(),
  mapf(
    [](int const i) {return i*I; },
    mapf(
      [](int const i) {return std::abs(i); },
      vnums)),
  0); // s = 236 
作为练习,我们可以将 fold 函数实现为一个变长函数模板,就像之前看到的那样。执行实际折叠的函数作为参数提供:
template <typename F, typename T1, typename T2>
auto fold_left(F&&f, T1 arg1, T2 arg2)
{
  return f(arg1, arg2);
}
template <typename F, typename T, typename... Ts>
auto fold_left(F&& f, T head, Ts… rest)
{
  return f(head, fold_left(std::forward<F>(f), rest...));
} 
当我们将它与我们在 编写带有可变数量参数的函数模板 菜谱中编写的 add() 函数模板进行比较时,我们可以注意到几个差异:
- 
第一个参数是一个函数,在递归调用 fold_left时会被完美转发。
- 
末尾的情况是一个需要两个参数的函数,因为我们使用的折叠函数是二元的(接受两个参数)。 
- 
我们编写的两个函数的返回类型被声明为 auto,因为它们必须匹配提供的二元函数f的返回类型,而f的返回类型在我们调用fold_left之前是未知的。
fold_left() 函数可以使用如下方式:
auto s1 = fold_left(std::plus<>(), 1, 2, 3, 4, 5);
// s1 = 15
auto s2 = fold_left(std::plus<>(), "hello"s, ' ', "world"s, '!');
// s2 = "hello world!"
auto s3 = fold_left(std::plus<>(), 1); // error, too few arguments 
注意到最后一次调用会产生编译器错误,因为变长函数模板 fold_left() 至少需要传入两个参数才能调用提供的二元函数。
参见
- 
第二章,创建字符串辅助库,了解如何创建有用的文本实用工具,这些工具在标准库中并不直接可用 
- 
编写带有可变数量参数的函数模板,了解变长模板如何使我们能够编写可以接受任意数量参数的函数 
- 
将函数组合成高阶函数,学习从一个或多个其他函数创建新函数的函数式编程技术 
将函数组合成高阶函数
在前面的菜谱中,我们实现了两个高阶函数,map 和 fold,并看到了它们的各种用法示例。在菜谱的结尾,我们看到了它们如何通过几个原始数据的转换来生成最终值。管道化是一种组合形式,这意味着从两个或更多给定的函数中创建一个新的函数。在提到的例子中,我们实际上并没有组合函数;我们只是用一个函数的结果调用另一个函数,但在这个菜谱中,我们将学习如何将函数实际组合成一个新的函数。为了简单起见,我们只考虑一元函数(只接受一个参数的函数)。
准备工作
在你继续之前,建议你阅读之前的菜谱,实现高阶函数 map 和 fold。这并不是理解这个菜谱的强制要求,但我们将参考在那里实现的 map 和 fold 函数。
如何实现...
要将一元函数组合成高阶函数,你应该这样做:
- 
要组合两个函数,提供一个函数,该函数接受两个函数 f和g作为参数,并返回一个新的函数(一个 lambda),该函数返回f(g(x)),其中x是组合函数的参数:template <typename F, typename G> auto compose(F&& f, G&& g) { return = { return f(g(x)); }; } auto v = compose( [](int const n) {return std::to_string(n); }, [](int const n) {return n * n; })(-3); // v = "9"
- 
要组合可变数量的函数,提供之前描述的函数的可变模板重载: template <typename F, typename... R> auto compose(F&& f, R&&... r) { return = { return f(compose(r...)(x)); }; } auto n = compose( [](int const n) {return std::to_string(n); }, [](int const n) {return n * n; }, [](int const n) {return n + n; }, [](int const n) {return std::abs(n); })(-3); // n = "36"
它是如何工作的...
将两个一元函数组合成一个新的函数相对简单。创建一个模板函数,我们在前面的例子中将其称为compose(),它有两个参数——f和g——代表函数,并返回一个接受一个参数x的函数,并返回f(g(x))。重要的是,g函数返回的值的类型与f函数的参数类型相同。组合函数返回的值是一个闭包——也就是说,它是 lambda 的一个实例化。
在实践中,能够组合不仅仅是两个函数是非常有用的。这可以通过编写compose()函数的可变模板版本来实现。可变模板在编写具有可变数量参数的函数模板配方中有更详细的解释。
可变模板通过展开参数包来暗示编译时递归。这个实现与compose()的第一个版本非常相似,除了以下几点:
- 
它接受可变数量的函数作为参数。 
- 
返回的闭包递归地调用 compose()与展开的参数包,递归在只剩两个函数时结束,在这种情况下调用之前实现的重载。
即使代码看起来像是在发生递归,这并不是真正的递归。这可以称为编译时递归,但每次展开都会调用另一个具有相同名称但参数数量不同的方法,这并不代表递归。
现在我们已经实现了这些可变模板重载,我们可以重写之前配方中的最后一个例子,实现高阶函数 map 和 fold。参考以下片段:
auto s = compose(
  [](std::vector<int> const & v) {
    return fold_left(std::plus<>(), v, 0); },
  [](std::vector<int> const & v) {
    return mapf([](int const i) {return i + i; }, v); },
  [](std::vector<int> const & v) {
    return mapf([](int const i) {return std::abs(i); }, v); })(vnums); 
有一个初始整数向量,我们通过将每个元素应用std::abs()映射到一个只包含正值的新的向量。然后将结果映射到一个新的向量,通过将每个元素的值加倍。最后,将结果向量中的值通过将它们加到初始值0上折叠在一起。
还有更多...
组合通常用点(.)或星号(*)表示,例如f . g或f * g。实际上,我们可以在 C++中通过重载operator*(尝试重载操作符点几乎没有意义)做类似的事情。与compose()函数类似,operator*应该与任何数量的参数一起工作;因此,我们将有两个重载,就像在compose()的情况下一样:
- 
第一个重载接受两个参数并调用 compose()来返回一个新的函数。
- 
第二个重载是一个变长模板函数,它再次通过展开参数包来调用 operator*。
基于这些考虑,我们可以如下实现operator*:
template <typename F, typename G>
auto operator*(F&& f, G&& g)
{
  return compose(std::forward<F>(f), std::forward<G>(g));
}
template <typename F, typename... R>
auto operator*(F&& f, R&&... r)
{
  return operator*(std::forward<F>(f), r...);
} 
现在我们可以通过应用operator*而不是更冗长的compose()调用来简化函数的实际组合:
auto n =
  ([](int const n) {return std::to_string(n); } *
   [](int const n) {return n * n; } *
   [](int const n) {return n + n; } *
   [](int const n) {return std::abs(n); })(-3); // n = "36"
auto c =
  [](std::vector<int> const & v) {
    return fold_left(std::plus<>(), v, 0); } *
  [](std::vector<int> const & v) {
    return mapf([](int const i) {return i + i; }, v); } *
  [](std::vector<int> const & v) {
    return mapf([](int const i) {return std::abs(i); }, v); };
auto vnums = std::vector<int>{ 2, -3, 5 };
auto s = c(vnums); // s = 20 
虽然乍一看可能不太直观,但函数是按相反的顺序应用的,而不是文本中显示的顺序。例如,在第一个例子中,参数的绝对值被保留。然后,结果被加倍,然后该操作的结果再乘以自身。最后,结果被转换为字符串。对于提供的参数-3,最终结果是字符串"36"。
参见
- 编写一个带有可变数量参数的函数模板,以了解变长模板如何使我们能够编写可以接受任意数量参数的函数
统一调用任何可调用对象
开发者,尤其是那些实现库的开发者,有时需要以统一的方式调用可调用对象。这可能是一个函数、一个函数指针、一个成员函数指针或一个函数对象。此类情况的例子包括std::bind、std::function、std::mem_fn和std::thread::thread。C++17 定义了一个标准函数std::invoke(),它可以调用任何可调用对象并传递提供的参数。这并不是要取代对函数或函数对象的直接调用,但在模板元编程中实现各种库函数时非常有用。
准备工作
对于这个配方,你应该熟悉如何定义和使用函数指针。
为了说明std::invoke()如何在不同的上下文中使用,我们将使用以下函数和类:
int add(int const a, int const b)
{
  return a + b;
}
struct foo
{
  int x = 0;
  void increment_by(int const n) { x += n; }
}; 
在下一节中,我们将探讨std::invoke()函数的可能用例。
如何实现...
std::invoke()函数是一个变长函数模板,它接受可调用对象作为第一个参数,以及一个可变数量的参数列表,这些参数被传递给调用。std::invoke()可以用来调用以下内容:
- 
自由函数: auto a1 = std::invoke(add, 1, 2); // a1 = 3
- 
通过函数指针实现的自由函数: auto a2 = std::invoke(&add, 1, 2); // a2 = 3 int(*fadd)(int const, int const) = &add; auto a3 = std::invoke(fadd, 1, 2); // a3 = 3
- 
通过成员函数指针实现的成员函数: foo f; std::invoke(&foo::increment_by, f, 10);
- 
数据成员: foo f; auto x1 = std::invoke(&foo::x, f); // x1 = 0
- 
函数对象: foo f; auto x3 = std::invoke(std::plus<>(), std::invoke(&foo::x, f), 3); // x3 = 3
- 
Lambda 表达式: auto l = [](auto a, auto b) {return a + b; }; auto a = std::invoke(l, 1, 2); // a = 3
在实践中,std:invoke()应该在模板元编程中用于调用具有任意数量参数的函数。为了说明这种情况,我们将展示std::apply()函数的可能实现,以及 C++17 标准库的一部分,它通过将元组的成员解包到函数的参数中调用函数:
namespace details
{
  template <class F, class T, std::size_t... I>
  auto apply(F&& f, T&& t, std::index_sequence<I...>)
 {
    return std::invoke(
      std::forward<F>(f),
      std::get<I>(std::forward<T>(t))...);
  }
}
template <class F, class T>
auto apply(F&& f, T&& t)
{
  return details::apply(
    std::forward<F>(f),
    std::forward<T>(t),
    std::make_index_sequence<
      std::tuple_size_v<std::decay_t<T>>> {}); 
} 
它是如何工作的...
在我们了解std::invoke()如何工作之前,让我们快速看一下如何调用不同的可调用对象。给定一个函数,显然,调用它的通用方式是直接传递必要的参数。然而,我们也可以使用函数指针来调用函数。函数指针的问题在于定义指针类型可能会很繁琐。使用auto可以简化事情(如下面的代码所示),但在实践中,你通常需要首先定义函数指针的类型,然后定义一个对象并用正确的函数地址初始化它。以下是一些示例:
// direct call
auto a1 = add(1, 2);    // a1 = 3
// call through function pointer
int(*fadd)(int const, int const) = &add;
auto a2 = fadd(1, 2);   // a2 = 3
auto fadd2 = &add;
auto a3 = fadd2(1, 2);  // a3 = 3 
当你需要通过类的实例调用类函数时,通过函数指针调用会变得更为繁琐。定义成员函数指针和调用它的语法并不简单:
foo f;
f.increment_by(3);
auto x1 = f.x;    // x1 = 3
void(foo::*finc)(int const) = &foo::increment_by;
(f.*finc)(3);
auto x2 = f.x;    // x2 = 6
auto finc2 = &foo::increment_by;
(f.*finc2)(3);
auto x3 = f.x;    // x3 = 9 
无论这种调用看起来多么繁琐,实际的问题是编写能够以统一方式调用这些类型可调用对象的库组件(函数或类)。这正是从标准函数,如std::invoke()中实际受益的地方。
std::invoke()的实现细节很复杂,但可以用简单的话来解释它的工作方式。假设调用形式为invoke(f, arg1, arg2, ..., argN),那么考虑以下:
- 
如果 f是指向T类成员函数的指针,那么调用等同于以下两种情况之一:- 
如果 arg1是T的实例,则为(arg1.*f)(arg2, ..., argN)
- 
如果 arg1是reference_wrapper的特化,则为(arg1.get().*f)(arg2, ..., argN)
- 
((*arg1).*f)(arg2, ..., argN),如果它不是其他情况
 
- 
- 
如果 f是指向T类数据成员的指针,并且有一个单独的参数——换句话说,调用形式为invoke(f, arg1)——那么调用等同于以下两种情况之一:- 
如果 arg1是T的实例,则为arg1.*f
- 
如果 arg1是reference_wrapper的特化,则为arg1.get().*f
- 
(*arg1).*f,如果它不是其他情况
 
- 
- 
如果 f是一个函数对象,那么调用等同于f(arg1, arg2, ..., argN)
标准库还提供了一系列相关的类型特性:一方面是std::is_invocable和std::is_nothrow_invocable,另一方面是std::is_invocable_r和std::is_nothrow_invocable_r。第一组确定一个函数是否可以用提供的参数调用,而第二组确定它是否可以用提供的参数调用并产生可以隐式转换为指定类型的结果。这些类型特性的nothrow版本验证调用可以在不抛出任何异常的情况下完成。
截至 C++20,std::invoke函数是constexpr,这意味着它可以在编译时调用可调用对象。
在 C++23 中,已添加了一个类似的实用工具 std::invoke_r。它有一个额外的模板参数(第一个),它是一个表示返回值类型的类型模板参数(除非它是 void),或者是一个可以将返回值隐式转换为的类型。
参见
- 编写一个带有可变数量参数的函数模板,以了解变长模板如何使我们能够编写可以接受任意数量参数的函数
在 Discord 上了解更多
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
discord.gg/7xRaTCeEhx

第四章:预处理和编译
在 C++中,编译是将源代码转换为机器代码并组织成对象文件的过程,这些对象文件随后被链接在一起以生成可执行文件。编译器实际上一次只处理一个文件(称为翻译单元),该文件由预处理程序(处理预处理指令的编译器部分)从单个源文件及其包含的所有头文件生成。然而,这只是一个简化的编译代码时发生的事情的描述。本章讨论与预处理和编译相关的话题,重点关注执行条件编译的各种方法,同时也涉及其他现代主题,例如使用属性提供实现定义的语言扩展。
本章包含的食谱如下:
- 
条件编译您的源代码 
- 
使用间接模式进行预处理程序字符串化和连接 
- 
使用 static_assert执行编译时断言检查
- 
使用 enable_if条件编译类和函数
- 
使用 constexpr if在编译时选择分支
- 
使用属性向编译器提供元数据 
我们将在本章开始时讨论的食谱解决的是开发者面临的一个非常普遍的问题,即根据各种条件仅编译代码库的一部分。
条件编译您的源代码
条件编译是一种简单的机制,它使开发者能够维护单个代码库,但只考虑代码的一部分进行编译以生成不同的可执行文件,通常是为了在不同的平台或硬件上运行,或者依赖于不同的库或库版本。常见的例子包括根据编译器、平台(x86、x64、ARM 等)、配置(调试或发布)或任何用户定义的特定条件使用或忽略代码。在本食谱中,我们将探讨条件编译是如何工作的。
准备工作
条件编译是一种广泛用于许多目的的技术。在本食谱中,我们将查看几个示例并解释它们是如何工作的。这种技术并不局限于这些示例。对于本食谱的范围,我们只考虑三个主要的编译器:GCC、Clang 和 VC++。
如何做到这一点...
要条件编译代码的部分,请使用#if、#ifdef和#ifndef指令(以及#elif、#else和#endif指令)。条件编译的一般形式如下:
#if condition1
  text1
#elif condition2
  text2
#elif condition3
  text3
#else
  text4
#endif 
因为这里的条件通常意味着使用defined identifier或defined (identifier)语法检查宏是否已定义,因此也可以使用以下形式:
#ifdef identifier1
  text1
#elifdef identifier2
  text2
#endif
#ifndef identifier1
  text1
#elifndef identifier2
  text2
#endif 
#elifdef和#elifndef指令是在 C++23 中引入的。
要为条件编译定义宏,您可以使用以下两种方法之一:
- 
在您的源代码中的 #define指令:#define VERBOSE_PRINTS #define VERBOSITY_LEVEL 5
- 
每个编译器特有的编译器命令行选项。以下是最广泛使用的编译器的示例: - 
对于 Visual C++,使用 /Dname或/Dname=value(其中/Dname等同于/Dname=1),例如,cl /DVERBOSITY_LEVEL=5。
- 
对于 GCC 和 Clang,使用 -D name或-D name=value(其中-D name等同于-D name=1),例如,gcc -D VERBOSITY_LEVEL=5。
 
- 
以下是一些典型的条件编译示例:
- 
头文件保护以避免重复定义(由于在同一翻译单元中多次包含相同的头文件): #ifndef UNIQUE_NAME #define UNIQUE_NAME class widget { }; #endif
- 
针对跨平台应用的编译器特定代码。以下是一个向控制台打印带有编译器名称的消息的示例: void show_compiler() { #if defined _MSC_VER std::cout << "Visual C++\n"; #elif defined __clang__ std::cout << "Clang\n"; #elif defined __GNUG__ std::cout << "GCC\n"; #else std::cout << "Unknown compiler\n"; #endif }
- 
针对多个架构的特定代码,例如,为多个编译器和架构条件编译代码: void show_architecture() { #if defined _MSC_VER #if defined _M_X64 std::cout << "AMD64\n"; #elif defined _M_IX86 std::cout << "INTEL x86\n"; #elif defined _M_ARM std::cout << "ARM\n"; #else std::cout << "unknown\n"; #endif #elif defined __clang__ || __GNUG__ #if defined __amd64__ std::cout << "AMD64\n"; #elif defined __i386__ std::cout << "INTEL x86\n"; #elif defined __arm__ std::cout << "ARM\n"; #else std::cout << "unknown\n"; #endif #else #error Unknown compiler #endif }
- 
针对特定配置的代码,例如,为调试和发布构建条件编译代码: void show_configuration() { #ifdef _DEBUG std::cout << "debug\n"; #else std::cout << "release\n"; #endif }
- 
要检查语言或库功能是否可用,请使用预定义的宏 __cpp_xxx(例如__cpp_constexpr、__cpp_constinit或__cpp_modules)用于语言功能,以及__cpp_lib_xxx(例如__cpp_lib_concepts、__cpp_lib_expected或__cpp_lib_jthread)用于库功能。库功能宏是在 C++20 中引入的,并在<version>头文件中可用:#ifdef __cpp_consteval #define CONSTEVAL consteval #else #define CONSTEVAL constexpr #endif CONSTEVAL int twice(int const n) { return n + n; } int main() { twice(42); }
- 
要检查头文件或源文件是否可用于包含,请使用 __has_include指令,该指令在 C++17 中可用。以下示例检查<optional>头文件是否存在:#if __has_include(<optional>) #include <optional> template<class T> using optional_t = std::optional<T>; #elif #include "myoptional.h" template<class T> using optional_t = my::optional<T>; #endif
- 
要检查属性是否受支持(以及从哪个版本开始),请使用 __has_cpp_attribute指令,该指令在 C++20 中可用:#if defined(__has_cpp_attribute) #if __has_cpp_attribute(deprecated) #define DEPRECATED(msg) [[deprecated(msg)]] #endif #endif DEPRECATED("This function is deprecated.") void func() {}
它是如何工作的...
在讨论编译之前,我们首先应该明确一个我们经常会遇到的术语:翻译单元。在 C++中,这是编译的基本单位。它是将源文件(一个.cpp文件)的内容和所有直接或间接包含的头文件的全部图(不包括由条件预处理语句排除的文本)组合起来的结果,正如本食谱中所述。
当您使用预处理指令#if、#ifndef、#ifdef、#elif、#else和#endif时,编译器将选择最多一个分支,其主体将被包含在翻译单元中进行编译。这些指令的主体可以是任何文本,包括其他预处理指令。以下规则适用:
- 
#if、#ifdef和#ifndef必须与#endif匹配。
- 
#if指令可以有多个#elif指令,但只能有一个#else,它也必须是#endif之前的最后一个。
- 
#if、#ifdef、#ifndef、#elif、#else和#endif可以嵌套。
- 
#if指令需要一个常量表达式,而#ifdef和#ifndef需要一个标识符。
- 
defined运算符可用于预处理器常量表达式,但仅限于#if和#elif指令。
- 
如果 identifier被定义,则defined(identifier)被认为是true;否则,被认为是false。
- 
被定义为空文本的标识符被认为是已定义的。 
- 
#ifdef identifier等同于#if defined(identifier)。
- 
#ifndef identifier等同于#if !defined(identifier)。
- 
defined(identifier)和defined identifier是等效的。
头文件保护是条件编译中最常见的形式之一。这种技术用于防止头文件的内容在同一个翻译单元中被多次包含(尽管每次都会扫描头文件以检测应该包含的内容)。因此,头文件中的代码以示例中所示的方式进行了保护,以防止多次包含。考虑到给定的示例,如果 UNIQUE_NAME 宏(这是上一节中的通用名称)未定义,则 #if 指令之后的代码,直到 #endif,将被包含在翻译单元中并编译。当这种情况发生时,使用 #define 指令定义 UNIQUE_NAME 宏。下次将头文件包含在(相同的)翻译单元中时,UNIQUE_NAME 宏已被定义,因此 #if 指令体中的代码不会被包含在翻译单元中,从而避免了重复。
注意,宏的名称在整个应用程序中必须是唯一的;否则,只有使用该宏的第一个头文件中的代码将被编译。使用相同名称的其他头文件中的代码将被忽略。通常,宏的名称基于定义它的头文件名称。
条件编译的另一个重要例子是跨平台代码,它需要考虑不同的编译器和架构,通常是 Intel x86、AMD64 或 ARM。然而,编译器为可能的平台定义了自己的宏。如何做... 部分的示例展示了如何为多个编译器和架构条件编译代码。
注意,在上述示例中,我们只考虑了几个架构。在实际应用中,存在多个宏可以用来识别相同的架构。在使用这些类型的宏之前,请确保您已经阅读了每个编译器的文档。
特定配置的代码也使用宏和条件编译来处理。例如,GCC 和 Clang 编译器在调试配置(当使用 -g 标志时)中不定义任何特殊的宏。Visual C++ 为调试配置定义了 _DEBUG,这在上一节的 如何做... 部分中已展示。对于其他编译器,您必须显式定义一个宏来识别此类调试配置。
功能测试是条件编译的重要用例,特别是在为多个平台(Windows、Linux 等)和编译器版本(C++11、C++14、C++17 等)提供支持的库中。库实现者通常需要检查特定语言功能或语言属性是否可用。这可以通过一组预定义的宏来实现,包括以下内容:
- 
__cplusplus: 表示正在使用的 C++ 标准版本。它扩展为以下值之一:199711L用于 C++11 之前的版本,201103L用于 C++11,201402L用于 C++14,201703L用于 C++17,以及202002L用于 C++20。在撰写本书时,C++23 的值尚未定义。
- 
__cpp_xxx宏,用于确定语言功能是否受支持。例如包括__cpp_concepts、__cpp_consteval、__cpp_modules等。
- 
__cpp_lib_xxx宏,用于确定库功能是否受支持。例如包括__cpp_lib_any、__cpp_lib_optional、__cpp_lib_constexpr_string等。这些宏定义在 C++20 中引入的<version>头文件中。
随着 C++ 中新功能的添加,__cpp_xxx 语言功能宏和 __cpp_lib_xxx 库功能宏正在通过新宏进行扩展。宏的完整列表太长,无法在此处展示,但可以在 en.cppreference.com/w/cpp/feature_test 查询。
除了这些宏之外,还有两个指令,__has_include 和 __has_cpp_attribute,可以在 #if/#elif 表达式中使用,以确定头文件或源文件是否存在,或者编译器是否支持某个属性。所有这些宏和指令都是确定特定功能是否存在的有用工具。它们使我们能够编写跨平台和编译器版本的代码。
更多内容...
有时,在执行条件编译时,你可能希望显示一个警告或完全停止编译。这可以通过两个诊断宏来实现:
- 
#error向控制台显示消息并停止程序的编译。
- 
#warning自 C++23 起可用,向控制台显示消息而不停止程序的编译。
以下代码片段展示了使用这些指令的示例:
#ifdef _WIN64
#error "64-bit not supported"
#endif
#if __cplusplus < 201703L
#warning "Consider upgrading to a C++17 compiler"
#endif 
虽然仅从 C++23 开始提供 #warning,但许多编译器提供对该指令的支持作为扩展。
参见
- 使用间接模式进行预处理器字符串化和连接,了解如何将标识符转换为字符串并在预处理期间连接标识符
使用间接模式进行预处理器字符串化和连接
C++ 预处理器提供了两个操作符,用于将标识符转换为字符串并将标识符连接在一起。第一个操作符,操作符 #,被称为 字符串化操作符,而第二个操作符,操作符 ##,被称为 标记粘贴、合并或连接操作符。尽管它们的使用仅限于某些特定情况,但理解它们的工作原理是很重要的。
准备工作
对于这个配方,你需要知道如何使用预处理指令 #define 来定义宏。
如何做到这一点...
要使用预处理操作符 # 从标识符创建字符串,请使用以下模式:
- 
定义一个辅助宏,它接受一个参数,该参数展开为 #,后跟参数:#define MAKE_STR2(x) #x
- 
定义你想要使用的宏,它接受一个参数,该参数展开为辅助宏: #define MAKE_STR(x) MAKE_STR2(x)
要使用预处理操作符 ## 将标识符连接在一起,请使用以下模式:
- 
定义一个辅助宏,它有一个或多个参数,这些参数使用标记粘贴操作符 ##来连接参数:#define MERGE2(x, y) x##y
- 
使用辅助宏定义你想要使用的宏: #define MERGE(x, y) MERGE2(x, y)
它是如何工作的...
要理解这些是如何工作的,让我们考虑之前定义的 MAKE_STR 和 MAKE_STR2 宏。当与任何文本一起使用时,它们将生成包含该文本的字符串。以下示例展示了这两个宏如何被用来定义包含文本 "sample" 的字符串:
std::string s1 { MAKE_STR(sample) };  // s1 = "sample"
std::string s2 { MAKE_STR2(sample) }; // s2 = "sample" 
另一方面,当宏作为参数传递时,结果会有所不同。在以下示例中,NUMBER 是一个展开为整数的宏,42。当它作为 MAKE_STR 的参数使用时,确实生成了字符串 "42";然而,当它作为 MAKE_STR2 的参数使用时,生成了字符串 "NUMBER":
#define NUMBER 42
std::string s3 { MAKE_STR(NUMBER) };    // s3 = "42"
std::string s4 { MAKE_STR2(NUMBER) };   // s4 = "NUMBER" 
C++ 标准定义了以下规则,用于函数式宏中的参数替换(来自 C++ 标准文档编号 N4917 的第 15.6.2 段):
在识别了函数式宏调用的参数之后,就会进行参数替换。替换列表中的参数,除非它前面有一个 # 或 ## 预处理令牌,或者后面有一个 ## 预处理令牌(见下文),否则在展开其中包含的所有宏之后,会被相应的参数替换。在替换之前,每个参数的预处理令牌会被完全宏替换,就像它们构成了预处理文件的其余部分一样;没有其他预处理令牌可用。
这意味着在将宏参数替换到宏体之前,会先展开这些参数,除了当操作符 # 或 ## 位于宏体中的参数之前或之后的情况。因此,以下情况会发生:
- 
对于 MAKE_STR2(NUMBER),替换列表中的NUMBER参数前面有一个#,因此,在将参数替换到宏体之前不会展开;因此,在替换之后,我们得到#NUMBER,它变成了"NUMBER"。
- 
对于 MAKE_STR(NUMBER),替换列表是MAKE_STR2(NUMBER),它没有#或##;因此,NUMBER参数在替换之前被替换为其相应的参数,42。结果是MAKE_STR2(42),然后再次扫描,并在展开后变为"42"。
相同的处理规则适用于使用标记粘贴运算符的宏。因此,为了确保您的字符串化和连接宏适用于所有情况,始终应用本食谱中描述的间接模式。
标记粘贴运算符通常用于考虑重复代码的宏中,以避免反复明确地编写相同的内容。以下简单的示例展示了标记粘贴运算符的实际应用;给定一组类,我们希望提供创建每个类实例的工厂方法:
#define DECL_MAKE(x)    DECL_MAKE2(x)
#define DECL_MAKE2(x)   x* make##_##x() { return new x(); }
struct bar {};
struct foo {};
DECL_MAKE(foo)
DECL_MAKE(bar)
auto f = make_foo(); // f is a foo*
auto b = make_bar(); // b is a bar* 
熟悉 Windows 平台的人可能已经使用过 _T(或 _TEXT)宏来声明字符串字面量,这些字符串字面量可以是转换为 Unicode 或 ANSI 字符串(单字节和多字节字符字符串):
auto text{ _T("sample") }; // text is either "sample" or L"sample" 
Windows SDK 定义 _T 宏如下。注意,当 _UNICODE 被定义时,标记粘贴运算符被定义为将 L 前缀和实际传递给宏的字符串连接起来:
#ifdef _UNICODE
#define __T(x)   L ## x
#else
#define __T(x)   x
#endif
#define _T(x)    __T(x)
#define _TEXT(x) __T(x) 
乍一看,似乎没有必要有一个宏调用另一个宏,但这种间接级别对于使 # 和 ## 运算符与其他宏一起工作至关重要,正如我们在本食谱中看到的。
参见
- 有条件地编译源代码,了解如何根据各种条件编译代码的某些部分
使用 static_assert 执行编译时断言检查
在 C++ 中,可以执行运行时和编译时断言检查,以确保代码中的特定条件为真。运行时断言的缺点是它们在程序运行时较晚被验证,并且只有当控制流到达它们时才会验证。当条件依赖于运行时数据时没有替代方案;然而,当这种情况不成立时,应优先考虑编译时断言检查。使用编译时断言,编译器能够在开发早期通过错误通知您特定条件尚未满足。然而,这些只能在条件可以在编译时评估的情况下使用。在 C++11 中,编译时断言使用 static_assert 执行。
准备工作
静态断言检查最常见的用途是与模板元编程一起使用,其中它们可以用来验证模板类型的前置条件是否得到满足(示例可以包括类型是否是 POD 类型、可复制构造、引用类型等)。另一个典型示例是确保类型(或对象)具有预期的尺寸。
如何操作...
使用 static_assert 声明来确保不同作用域中的条件得到满足:
- 
命名空间:在这个例子中,我们验证类 item的大小始终为 16:struct alignas(8) item { int id; bool active; double value; }; static_assert(sizeof(item) == 16, "size of item must be 16 bytes");
- 
类:在这个例子中,我们验证 pod_wrapper只能用于 POD 类型:template <typename T> class pod_wrapper { static_assert(std::is_standard_layout_v<T>, "POD type expected!"); T value; }; struct point { int x; int y; }; pod_wrapper<int> w1; // OK pod_wrapper<point> w2; // OK pod_wrapper<std::string> w3; // error: POD type expected
- 
块(函数):在这个例子中,我们验证一个函数模板只接受整型类型的参数: template<typename T> auto mul(T const a, T const b) { static_assert(std::is_integral_v<T>, "Integral type expected"); return a * b; } auto v1 = mul(1, 2); // OK auto v2 = mul(12.0, 42.5); // error: Integral type expected
它是如何工作的...
static_assert 实际上是一个声明,但它不会引入新的名称。这些声明具有以下形式:
static_assert(condition, message); 
条件必须在编译时可转换为布尔值,并且消息必须是一个字符串字面量。从 C++17 开始,消息是可选的。
当 static_assert 声明中的条件评估为 true 时,不会发生任何事情。当条件评估为 false 时,编译器生成包含指定消息(如果有)的错误。
消息参数必须是一个字符串字面量。然而,从 C++26 开始,它可以是产生字符序列的任意常量表达式。这有助于为用户提供更好的诊断信息。例如,假设会有一个 constexpr std::format() 函数,可以编写以下内容:
static_assert(
   sizeof(item) == 16,
   std::format("size of item must be 16 bytes but got {}", sizeof(item))); 
参见
- 
使用 enable_if 条件编译类和函数,了解 SFINAE 以及如何使用它来为模板指定类型约束 
- 
第十二章,使用概念指定模板参数的要求,了解 C++20 概念的基本原理以及如何使用它们来指定模板类型的约束 
- 
在编译时使用 constexpr if 选择分支,了解如何仅使用 constexpr if 语句编译代码的一部分 
使用 enable_if 条件编译类和函数
模板元编程是 C++ 的一个强大功能,它使我们能够编写通用的类和函数,它们可以与任何类型一起工作。这有时是一个问题,因为语言没有定义任何机制来指定可以替换模板参数的类型约束。然而,我们仍然可以通过元编程技巧和利用一个称为 替换失败不是错误 的规则来实现这一点,也称为 SFINAE。该规则确定当在替换模板参数时,如果显式指定的或推导出的类型替换失败,则编译器是否从重载集中丢弃特定化,而不是生成错误。本食谱将专注于实现模板的类型约束。
准备工作
开发者多年来一直使用与 SFINAE 结合的类模板 enable_if 来对模板类型实施约束。enable_if 模板系列已成为 C++11 标准的一部分,并如下实现:
template<bool Test, class T = void>
struct enable_if
{};
template<class T>
struct enable_if<true, T>
{
  typedef T type;
}; 
要使用 std::enable_if,你必须包含 <type_traits> 头文件。
如何做到这一点...
std::enable_if 可以在多个作用域中使用以实现不同的目的;考虑以下示例:
- 
在类模板参数上启用类模板,仅对满足指定条件的类型: template <typename T, typename = typename std::enable_if_t<std::is_standard_layout_v<T>, T>> class pod_wrapper { T value; }; struct point { int x; int y; }; struct foo { virtual int f() const { return 42; } }; pod_wrapper<int> w1; // OK pod_wrapper<point> w2; // OK pod_wrapper<std::string> w3; // OK with Clang and GCC // error with MSVC // too few template arguments pod_wrapper<foo> w4; // error
- 
在函数模板参数、函数参数或函数返回类型上启用函数模板,仅对满足指定条件的类型: template<typename T, typename = typename std::enable_if_t<std::is_integral_v<T>, T>> auto mul(T const a, T const b) { return a * b; } auto v1 = mul(1, 2); // OK auto v2 = mul(1.0, 2.0); // error: no matching overloaded function found
为了简化我们使用std::enable_if时最终编写的杂乱代码,我们可以利用别名模板并定义两个别名,分别称为EnableIf和DisableIf:
template <typename Test, typename T = void>
using EnableIf = typename std::enable_if_t<Test::value, T>;
template <typename Test, typename T = void>
using DisableIf = typename std::enable_if_t<!Test::value, T>; 
基于这些别名模板,以下定义与前面的定义等效:
template <typename T, typename = EnableIf<std::is_standard_layout<T>>>
class pod_wrapper
{
  T value;
};
template<typename T, typename = EnableIf<std::is_integral<T>>>
auto mul(T const a, T const b)
{
  return a * b;
} 
它是如何工作的...
std::enable_if之所以有效,是因为编译器在执行重载解析时应用了 SFINAE 规则。在我们能够解释std::enable_if是如何工作的之前,我们应该快速了解一下 SFINAE 是什么。
当编译器遇到函数调用时,它需要构建一组可能的重载并基于函数调用的参数选择最佳匹配。在构建这个重载集时,编译器也会评估函数模板,并必须对模板参数中指定的或推导出的类型进行替换。根据 SFINAE(Substitution Failure Is Not An Error),当替换失败时,编译器不应产生错误,而应仅从重载集中移除函数模板并继续。
标准指定了一个类型和表达式错误列表,这些也是 SFINAE 错误。这包括尝试创建void数组或大小为零的数组,尝试创建对void的引用,尝试创建具有void类型参数的函数类型,以及尝试在模板参数表达式中或在函数声明中使用的表达式中执行无效转换。有关异常的完整列表,请参阅 C++标准或其他资源。
让我们考虑一个名为func()的函数的两个重载。第一个重载是一个只有一个T::value_type类型参数的函数模板;这意味着它只能用具有名为value_type的内部类型的类型实例化。第二个重载是一个只有一个int类型参数的函数:
template <typename T>
void func(typename T::value_type const a)
{ std::cout << "func<>" << '\n'; }
void func(int const a)
{ std::cout << "func" << '\n'; }
template <typename T>
struct some_type
{
  using value_type = T;
}; 
如果编译器遇到func(42)这样的调用,它必须找到一个可以接受int参数的重载。当它构建重载集并用提供的模板参数替换模板参数时,结果void func(int::value_type const)是无效的,因为int没有value_type成员。由于 SFINAE,编译器不会发出错误并停止,而只是忽略该重载并继续。然后它找到void func(int const),这将是最合适(也是唯一)的匹配,它将调用。
如果编译器遇到func<some_type<int>>(42)这样的调用,它将构建一个包含void func(some_type<int>::value_type const)和void func(int const)的重载集,在这种情况下,最佳匹配是第一个重载;这次没有涉及 SFINAE。
另一方面,如果编译器遇到 func("string"s) 这样的调用,它再次依赖于 SFINAE 来忽略函数模板,因为 std::basic_string 也没有 value_type 成员。然而,这次重载集合中不包含任何与字符串参数匹配的项;因此,程序是无效的,编译器发出错误并停止。
enable_if<bool, T> 类模板没有任何成员,但它的部分特化 enable_if<true, T> 有一个内部类型称为 type,它是 T 的同义词。当将 enable_if 的第一个参数作为编译时表达式评估为 true 时,内部成员 type 是可用的;否则,它不可用。
考虑到 如何做到... 部分的 mul() 函数的最后定义,当编译器遇到 mul(1, 2) 这样的调用时,它试图用 int 替换模板参数 T;由于 int 是一个整型,std::is_integral<T> 评估为 true,因此,定义了一个内部类型 type 的 enable_if 特化被实例化。结果,别名模板 EnableIf 成为此类型的同义词,即 void(来自表达式 typename T = void)。结果是,可以带有提供的参数调用的函数模板 int mul<int, void>(int a, int b)。
另一方面,当编译器遇到 mul(1.0, 2.0) 这样的调用时,它试图用 double 替换模板参数 T。然而,这并不是一个整型;因此,std::enable_if 中的条件评估为 false,类模板没有定义内部成员 type。这导致替换错误,但根据 SFINAE,编译器不会发出错误,而是继续进行。然而,由于没有找到其他重载,将没有可以调用的 mul() 函数。因此,程序被认为是无效的,编译器停止并报错。
类模板 pod_wrapper 遇到类似的情况。它有两个模板类型参数:第一个是被包装的实际 POD 类型,而第二个是 enable_if 和 is_standard_layout 替换的结果。如果类型是 POD 类型(如 pod_wrapper<int>),则 enable_if 的内部成员 type 存在,并替换第二个模板类型参数。然而,如果内部成员 type 不是一个 POD 类型(如 pod_wrapper<std::string>),则内部成员 type 未定义,替换失败,产生如 模板参数太少 这样的错误。
还有更多...
static_assert 和 std::enable_if 可以用来实现相同的目标。实际上,在前面的配方中,使用 static_assert 进行编译时断言检查,我们定义了相同的类模板 pod_wrapper 和函数模板 mul()。对于这些示例,static_assert 似乎是一个更好的解决方案,因为编译器会发出更好的错误信息(前提是在 static_assert 声明中指定了相关的消息)。然而,这两个函数的工作方式相当不同,并不打算作为替代品。
static_assert 不依赖于 SFINAE,并且在重载解析完成后应用。失败的断言会导致编译器错误。另一方面,std::enable_if 用于从重载集中移除候选者,并且不会触发编译器错误(假设标准中指定的 SFINAE 异常没有发生)。SFINAE 后可能发生的实际错误是一个空的重载集,这会使程序无效。这是因为特定的函数调用无法执行。
要了解 static_assert 和 std::enable_if 与 SFINAE 之间的区别,让我们考虑一个我们想要有两个函数重载的情况:一个用于整型类型的参数,另一个用于除整型类型之外的所有类型的参数。使用 static_assert,我们可以编写以下内容(注意,第二个重载上的虚拟第二个类型参数是必要的,以便定义两个不同的重载;否则,我们只会有两个相同函数的定义):
template <typename T>
auto compute(T const a, T const b)
{
  static_assert(std::is_integral_v<T>, "An integral type expected");
  return a + b;
}
template <typename T, typename = void>
auto compute(T const a, T const b)
{
  static_assert(!std::is_integral_v<T>, "A non-integral type expected");
  return a * b;
}
auto v1 = compute(1, 2);
// error: ambiguous call to overloaded function
auto v2 = compute(1.0, 2.0);
// error: ambiguous call to overloaded function 
无论我们如何尝试调用此函数,最终都会出错,因为编译器找到了两个可能调用的重载。这是因为 static_assert 仅在重载解析完成后才被考虑,在这种情况下,构建了一个包含两个可能候选者的集合。
解决这个问题的方法是 std::enable_if 和 SFINAE。我们通过之前定义的别名模板 EnableIf 和 DisableIf 在模板参数上使用 std::enable_if(尽管我们仍然在第二个重载上使用虚拟模板参数以引入两个不同的定义)。以下示例显示了重载的重新编写。第一个重载仅对整型类型启用,而第二个对整型类型禁用:
template <typename T, typename = EnableIf<std::is_integral<T>>>
auto compute(T const a, T const b)
{
  return a * b;
}
template <typename T, typename = DisableIf<std::is_integral<T>>,
          typename = void>
auto compute(T const a, T const b)
{
  return a + b;
}
auto v1 = compute(1, 2);     // OK; v1 = 2
auto v2 = compute(1.0, 2.0); // OK; v2 = 3.0 
在 SFINAE 作用下,当编译器为 compute(1, 2) 或 compute(1.0, 2.0) 构建重载集时,它将简单地丢弃产生替换失败的过载,并继续进行,在每种情况下,我们最终都会得到一个只包含单个候选者的重载集。
参见
- 
使用 static_assert进行编译时断言检查,了解如何定义在编译时验证的断言
- 
第一章,创建类型别名和别名模板,了解类型别名 
使用 constexpr if 在编译时选择分支
在之前的菜谱中,我们看到了如何使用 static_assert 和 std::enable_if 对类型和函数施加限制,以及这两个是如何不同的。当我们使用 SFINAE 和 std::enable_if 来定义函数重载或编写变长模板函数时,模板元编程可能会变得复杂和杂乱。C++17 的一个新特性旨在简化此类代码;它被称为 constexpr if,它定义了一个在编译时评估条件的 if 语句,从而使得编译器选择翻译单元中某个分支或另一个分支的主体。constexpr if 的典型用法是简化变长模板和基于 std::enable_if 的代码。
准备工作
在这个菜谱中,我们将参考并简化在前两个菜谱中编写的代码。在继续这个菜谱之前,你应该花点时间回顾一下我们在之前的菜谱中编写的代码,如下所示:
- 
来自 使用 enable_if 条件编译类和函数 菜谱的整型和非整型的 compute()重载。
- 
来自 第二章,处理数字和字符串 的 创建原始用户定义字面量 菜谱的用户定义的 8 位、16 位和 32 位二进制字面量。 
这些实现有几个问题:
- 
它们很难阅读。有很多关注模板声明,而函数的主体却非常简单,例如。然而,最大的问题是它需要开发者更加注意,因为它充满了复杂的声明,如 typename = std::enable_if<std::is_integral<T>::value, T>::type。
- 
代码太多。第一个示例的最终目的是拥有一个对不同的类型表现不同的泛型函数,但我们不得不为该函数编写两个重载;此外,为了区分这两个重载,我们不得不使用一个额外的、未使用的模板参数。在第二个示例中,目的是从字符 '0'和'1'构建一个整数值,但我们不得不编写一个类模板和三个特化来实现这一点。
- 
它需要高级模板元编程技能,而这对于做这样简单的事情是不必要的。 
constexpr if 的语法与常规 if 语句非常相似,需要在条件之前使用 constexpr 关键字。一般形式如下:
if constexpr (init-statement condition) statement-true
else statement-false 
注意,在这个形式中,init-statement 是可选的。
在以下部分,我们将探讨使用 constexpr if 进行条件编译的几个用例。
如何做...
使用 constexpr if 语句来完成以下操作:
- 
为了避免使用 std::enable_if并依赖于 SFINAE 对函数模板类型施加限制以及条件编译代码:template <typename T> auto value_of(T value) { if constexpr (std::is_pointer_v<T>) return *value; else return value; }
- 
为了简化编写变长模板并实现元编程编译时递归: namespace binary { using byte8 = unsigned char; namespace binary_literals { namespace binary_literals_internals { template <typename CharT, char d, char... bits> constexpr CharT binary_eval() { if constexpr(sizeof...(bits) == 0) return static_cast<CharT>(d-'0'); else if constexpr(d == '0') return binary_eval<CharT, bits...>(); else if constexpr(d == '1') return static_cast<CharT>( (1 << sizeof...(bits)) | binary_eval<CharT, bits...>()); } } template<char... bits> constexpr byte8 operator""_b8() { static_assert( sizeof...(bits) <= 8, "binary literal b8 must be up to 8 digits long"); return binary_literals_internals:: binary_eval<byte8, bits...>(); } } }
它是如何工作的...
constexpr if 的工作方式相对简单:if 语句中的条件必须是一个编译时表达式,该表达式可以评估或转换为布尔值。如果条件为 true,则选择 if 语句的主体,这意味着它最终会进入编译单元进行编译。如果条件为 false,则评估(如果已定义)else 分支。丢弃的 constexpr if 分支中的返回语句不会对函数返回类型推导做出贡献。
在 How to do it... 部分的第一个示例中,value_of() 函数模板有一个干净的签名。其主体也非常简单;如果用于模板参数的类型是指针类型,编译器将选择第一个分支(即 return *value;)进行代码生成并丢弃 else 分支。对于非指针类型,因为条件评估为 false,编译器将选择 else 分支(即 return value;)进行代码生成并丢弃其余部分。此函数可以使用如下方式:
auto v1 = value_of(42);
auto p = std::make_unique<int>(42);
auto v2 = value_of(p.get()); 
然而,没有 constexpr if 的帮助,我们只能使用 std::enable_if 来实现这一点。以下是一个更杂乱的替代实现:
template <typename T,
          typename = typename std::enable_if_t<std::is_pointer_v<T>, T>>
auto value_of(T value)
{
  return *value;
}
template <typename T,
          typename = typename std::enable_if_t<!std::is_pointer_v<T>, T>>
T value_of(T value)
{
  return value;
} 
如您所见,constexpr if 变体不仅更短,而且更具表达性,更容易阅读和理解。
在 How to do it... 部分的第二个示例中,内部的 binary_eval() 辅助函数是一个没有任何参数的变长模板函数;它只有模板参数。该函数评估第一个参数,然后以递归方式处理剩余的参数(但请记住,这并不是运行时递归)。当只剩下一个字符并且剩余的包的大小为 0 时,我们返回由字符表示的十进制值('0' 为 0,'1' 为 1)。如果当前第一个元素是 '0',我们通过评估剩余的参数包来确定值,这涉及到递归调用。如果当前第一个元素是 '1',我们通过将 1 左移由剩余包的大小或确定的值指定的位数来返回值。我们通过评估剩余的参数包来完成这项工作,这又涉及到递归调用。
参见
- 使用 enable_if条件编译类和函数,了解 SFINAE 以及如何使用它来为模板指定类型约束
使用属性向编译器提供元数据
C++在提供数据类型反射或内省功能以及定义语言扩展的标准机制方面一直存在很大缺陷。正因为如此,编译器为这个目的定义了自己的特定扩展。例如,VC++的__declspec()指定符和 GCC 的__attribute__((...))。然而,C++11 引入了属性的概念,这使得编译器能够以标准方式或甚至嵌入特定领域的语言来实现扩展。新的 C++标准定义了所有编译器都应该实现的几个属性,这将是本菜谱的主题。
如何操作...
使用标准属性为编译器提供有关各种设计目标的提示,例如在以下场景中,但不仅限于此:
- 
为了确保函数的返回值不能被忽略,使用 [[nodiscard]]属性声明函数。在 C++20 中,你可以指定一个字符串字面量,形式为[[nodiscard(text)]],来解释为什么结果不应该被丢弃:[[nodiscard]] int get_value1() { return 42; } get_value1(); // warning: ignoring return value of function // declared with 'nodiscard' attribute get_value1();
- 
或者,你可以使用 [[nodiscard]]属性声明用作函数返回类型的枚举和类;在这种情况下,任何返回此类类型的函数的返回值都不能被忽略:enum class[[nodiscard]] ReturnCodes{ OK, NoData, Error }; ReturnCodes get_value2() { return ReturnCodes::OK; } struct[[nodiscard]] Item{}; Item get_value3() { return Item{}; } // warning: ignoring return value of function // declared with 'nodiscard' attribute get_value2(); get_value3();
- 
为了确保被认为已过时的函数或类型的用法被编译器标记为警告,使用 [[deprecated]]属性声明它们:[[deprecated("Use func2()")]] void func() { } // warning: 'func' is deprecated : Use func2() func(); class [[deprecated]] foo { }; // warning: 'foo' is deprecated foo f;
- 
为了确保编译器不对未使用的变量发出警告,使用 [[maybe_unused]]属性:double run([[maybe_unused]] int a, double b) { return 2 * b; } [[maybe_unused]] auto i = get_value1();
- 
为了确保 switch语句中的有意跳过的情况标签不会被编译器标记为警告,使用[[fallthrough]]属性:void option1() {} void option2() {} int alternative = get_value1(); switch (alternative) { case 1: option1(); [[fallthrough]]; // this is intentional case 2: option2(); }
- 
为了帮助编译器优化执行路径,使用 C++20 的 [[likely]]和[[unlikely]]属性:void execute_command(char cmd) { switch(cmd) { [[likely]] case 'a': /* add */ break; [[unlikely]] case 'd': /* delete */ break; case 'p': /* print */ break; default: /* do something else */ break; } }
- 
为了帮助编译器根据用户提供的假设优化代码,使用 C++23 的 [[assume]]属性:void process(int* data, size_t len) { [[assume(len > 0)]]; for(size_t i = 0; i < len; ++i) { // do something with data[i] } }
它是如何工作的...
属性是 C++的一个非常灵活的特性;它们几乎可以在任何地方使用,但实际使用是针对每个特定属性具体定义的。它们可以用在类型、函数、变量、名称、代码块或整个翻译单元上。
属性指定在双方括号之间(例如,[[attr1]]),并且在声明中可以指定多个属性(例如,[[attr1, attr2, attr3]])。
属性可以有参数,例如[[mode(greedy)]],并且可以是完全限定的,例如[[sys::hidden]]或[[using sys: visibility(hidden), debug]]。
属性可以出现在它们所应用的实体名称之前或之后,或者两者都出现,在这种情况下它们会被组合。以下是一些示例,说明了这一点:
// attr1 applies to a, attr2 applies to b
int a [[attr1]], b [[attr2]];
// attr1 applies to a and b
int [[attr1]] a, b;
// attr1 applies to a and b, attr2 applies to a
int [[attr1]] a [[attr2]], b; 
属性不能出现在命名空间声明中,但可以作为单行声明出现在命名空间中的任何位置。在这种情况下,是否应用于后续声明、命名空间或翻译单元取决于每个属性:
namespace test
{
  [[debug]];
} 
标准确实定义了所有编译器都必须实现的几个属性,使用它们可以帮助你编写更好的代码。我们在上一节给出的示例中看到了一些。这些属性已在标准的不同版本中定义:
- 
在 C++11 中: - 
[[noreturn]]属性表示函数不会返回。
- 
[[carries_dependency]]属性表示在发布-消费std::memory_order中的依赖链在函数中传播进出,这允许编译器跳过不必要的内存栅栏指令。
 
- 
- 
在 C++14 中: - [[deprecated]]和- [[deprecated("reason")]]属性表示使用这些属性声明的实体被认为是过时的,不应使用。这些属性可以与类、非静态数据成员、typedefs、函数、枚举和模板特化一起使用。- "reason"字符串是一个可选参数。
 
- 
在 C++17 中: - 
[[fallthrough]]属性表示在switch语句中的标签之间的穿透是故意的。该属性必须单独一行,紧接在case标签之前。
- 
[[nodiscard]]属性表示函数的返回值不能被忽略。
- 
[[maybe_unused]]属性表示实体可能未使用,但编译器不应发出有关该问题的警告。此属性可以应用于变量、类、非静态数据成员、枚举、枚举符和 typedefs。
 
- 
- 
在 C++20 中: - 
[[nodiscard(text)]]属性是 C++17 的[[nodiscard]]属性的扩展,并提供描述结果不应被丢弃原因的文本。
- 
[[likely]]和[[unlikely]]属性为编译器提供提示,表明执行路径更有可能或不太可能执行,因此允许它相应地进行优化。它们可以应用于语句(但不能是声明)和标签,但只能使用其中一个,因为它们是互斥的。
- 
[[no_unique_address]]属性可以应用于非静态数据成员(排除位域),并告知编译器该成员不必具有唯一的地址。当应用于具有空类型的成员时,编译器可以将其优化为不占用空间,例如,当它是一个空基类时。另一方面,如果成员的类型不为空,编译器可能会重用任何后续的填充来存储其他数据成员。
 
- 
- 
在 C++23 中: - [[assume(expr)]]表示一个表达式将始终评估为- true。它的目的是让编译器执行代码优化,而不是记录函数的先决条件。然而,表达式永远不会被评估。具有未定义行为或抛出异常的表达式将被评估为- false。不成立的假设会导致未定义行为;因此,假设应该谨慎使用。另一方面,编译器可能根本不会使用假设。
 
在现代 C++ 编程的书籍和教程中,属性通常被忽略或简略提及,这其中的原因可能是因为开发者实际上无法编写属性,因为这个语言特性是为编译器实现而设计的。然而,对于某些编译器来说,可能可以定义用户提供的属性;GCC 就是这样一种编译器,它支持插件,这些插件可以为编译器添加额外功能,也可以用来定义新的属性。
参见
- 第九章,使用 noexcept 处理不抛出异常的函数,了解如何通知编译器一个函数不应该抛出异常
在 Discord 上了解更多
加入我们的 Discord 社区空间,与作者和其他读者进行讨论:
discord.gg/7xRaTCeEhx

第五章:标准库容器、算法和迭代器
C++标准库随着 C++11/14/17/20 的演变而发生了很大的变化,现在有 C++23。然而,其核心仍然有三个主要支柱:容器、算法和迭代器。它们都是作为泛型类型和通用函数模板实现的。在本章中,我们将探讨它们如何一起使用以实现各种目标。
在本章中,我们将介绍以下食谱:
- 
使用 vector作为默认容器
- 
使用 bitset处理固定大小的位序列
- 
使用 vector<bool>处理可变大小的位序列
- 
使用位操作实用工具 
- 
在范围内查找元素 
- 
对范围进行排序 
- 
初始化一个范围 
- 
在范围内使用集合操作: 
- 
使用迭代器向容器中插入新元素 
- 
编写自己的随机访问迭代器 
- 
使用非成员函数访问容器 
- 
选择正确的标准容器 
我们将从这个章节开始,探讨 C++事实上的默认容器std::vector的功能。
使用向量作为默认容器
标准库提供了各种类型的容器,用于存储对象的集合;库包括序列容器(如vector、array和list)、有序和无序关联容器(如set和map),以及不存储数据但提供对序列容器进行适配的接口的容器适配器(如stack和queue)。所有这些都被实现为类模板,这意味着它们可以与任何类型一起使用(只要它满足容器的要求)。一般来说,你应该始终使用最适合特定问题的容器,这不仅提供了良好的性能,包括插入、删除、访问元素和内存使用方面的速度,而且使代码易于阅读和维护。然而,默认选择应该是vector。在这个食谱中,我们将看到为什么在许多情况下vector应该是容器的首选选择,以及vector最常见的操作是什么。
准备工作
对于这个食谱,你必须熟悉数组,包括静态和动态分配的。这里提供了一些示例:
double d[3];           // a statically allocated array of 3 doubles
int* arr = new int[5]; // a dynamically allocated array of 5 ints 
vector类模板在<vector>头文件中的std命名空间中可用。
如何做到...
要初始化std::vector类模板,你可以使用以下任何一种方法,但你并不局限于仅使用这些:
- 
从初始化列表初始化: std::vector<int> v1 { 1, 2, 3, 4, 5 };
- 
从数组初始化: int arr[] = { 1, 2, 3, 4, 5 }; std::vector<int> v21(arr, arr + 5); // v21 = { 1, 2, 3, 4, 5 } std::vector<int> v22(arr+1, arr+4); // v22 = { 2, 3, 4 }
- 
从另一个容器初始化: std::list<int> l{ 1, 2, 3, 4, 5 }; std::vector<int> v3(l.begin(), l.end()); //{ 1, 2, 3, 4, 5 }
- 
从计数和值初始化: std::vector<int> v4(5, 1); // {1, 1, 1, 1, 1}
要修改std::vector的内容,你可以使用以下任何一种方法(如上所述,你并不局限于仅使用这些):
- 
使用 push_back()在向量的末尾添加一个元素:std::vector<int> v1{ 1, 2, 3, 4, 5 }; v1.push_back(6); // v1 = { 1, 2, 3, 4, 5, 6 }
- 
使用 pop_back()从向量的末尾移除一个元素:v1.pop_back(); // v1 = { 1, 2, 3, 4, 5 }
- 
使用 insert()在向量的任何位置插入:int arr[] = { 1, 2, 3, 4, 5 }; std::vector<int> v21; v21.insert(v21.begin(), arr, arr + 5); // v21 = { 1, 2, 3, 4, 5 } std::vector<int> v22; v22.insert(v22.begin(), arr, arr + 3); // v22 = { 1, 2, 3 }
- 
通过在向量的末尾创建元素来添加一个元素,使用 emplace_back():struct foo { int a; double b; std::string c; foo(int a, double b, std::string const & c) : a(a), b(b), c(c) {} }; std::vector<foo> v3; v3.emplace_back(1, 1.0, "one"s); // v3 = { foo{1, 1.0, "one"} }
- 
通过在向量中的任何位置创建元素来插入一个元素,使用 emplace():v3.emplace(v3.begin(), 2, 2.0, "two"s); // v3 = { foo{2, 2.0, "two"}, foo{1, 1.0, "one"} }
要修改向量的整个内容,你可以使用以下任何一种方法,尽管你并不局限于这些:
- 
使用 operator=从另一个向量赋值;这替换了容器的内容:std::vector<int> v1{ 1, 2, 3, 4, 5 }; std::vector<int> v2{ 10, 20, 30 }; v2 = v1; // v2 = { 1, 2, 3, 4, 5 }
- 
使用 assign()方法从由开始和结束迭代器定义的另一个序列赋值;这替换了容器的内容:int arr[] = { 1, 2, 3, 4, 5 }; std::vector<int> v31; v31.assign(arr, arr + 5); // v31 = { 1, 2, 3, 4, 5 } std::vector<int> v32; v32.assign(arr + 1, arr + 4); // v32 = { 2, 3, 4 }
- 
使用 swap()方法交换两个向量的内容:std::vector<int> v4{ 1, 2, 3, 4, 5 }; std::vector<int> v5{ 10, 20, 30 }; v4.swap(v5); // v4 = { 10, 20, 30 }, v5 = { 1, 2, 3, 4, 5 }
- 
使用 clear()方法删除所有元素:std::vector<int> v6{ 1, 2, 3, 4, 5 }; v6.clear(); // v6 = { }
- 
使用 erase()方法删除一个或多个元素(这需要迭代器或定义从向量中要删除的元素范围的迭代器对):std::vector<int> v7{ 1, 2, 3, 4, 5 }; v7.erase(v7.begin() + 2, v7.begin() + 4); // v7 = { 1, 2, 5 }
- 
使用 std::remove_if()函数和erase()方法删除一个或多个满足谓词的元素:std::vector<int> v8{ 1, 2, 3, 4, 5 }; auto iterToNext = v8.erase( std::remove_if(v8.begin(), v8.end(), [](const int n) {return n % 2 == 0; }), v8.end()); // v8 = { 1, 3, 5 }
- 
使用在 C++20 中引入的 std::erase_if()函数删除一个或多个满足谓词的元素,类似的std::erase()函数也存在:std::vector<int> v9{ 1, 2, 3, 4, 5 }; auto erasedCount = std::erase_if(v9, [](const int n) { return n % 2 == 0; }); // v9 = { 1, 3, 5 }
要获取向量中第一个元素的地址,通常是为了将向量的内容传递给类似 C 的 API,可以使用以下任何一种方法:
- 
使用 data()方法,它返回指向第一个元素的指针,提供对存储向量元素的底层连续内存序列的直接访问;这仅从 C++11 开始可用:void process(int const * const arr, size_t const size) { /* do something */ } std::vector<int> v{ 1, 2, 3, 4, 5 }; process(v.data(), v.size());
- 
获取第一个元素的地址: process(&v[0], v.size());
- 
获取 front()方法引用的元素的地址(在空向量上调用此方法是不确定的行为):process(&v.front(), v.size());
- 
获取从 begin()返回的迭代器指向的元素的地址:process(&*v.begin(), v.size());
要修改向量的内容,在 C++23 中,你也可以使用以下范围感知的成员函数:
要用给定范围的元素副本替换向量的元素,使用 assign_range():
std::list<int>   l{ 1, 2, 3, 4, 5 };
std::vector<int> v;
v.assign_range(l); // v = {1, 2, 3, 4, 5} 
要将范围元素的副本追加到向量的末尾(在末尾迭代器之前),使用 append_range():
std::list<int>   l{ 3, 4, 5 };
std::vector<int> v{ 1, 2 };
v.append_range(l);  // v = {1, 2, 3, 4, 5} 
要在向量的给定迭代器之前插入范围元素的副本,使用 insert_range():
std::list<int>   l{ 2, 3, 4 };
std::vector<int> v{ 1, 5 };
v.insert_range(v.begin() + 1, l); // v = {1, 2, 3, 4, 5} 
它是如何工作的...
std::vector 类被设计成与数组最相似且可互操作的 C++ 容器。向量是一个可变大小的元素序列,保证在内存中连续存储,这使得向量的内容可以轻松传递给接受数组元素指针和通常大小参数的类似 C 的函数:
使用向量而不是数组的许多好处包括:
- 
开发者不需要直接进行内存管理,因为容器内部完成这项工作,分配内存,重新分配它,并释放它。 注意,向量是用来存储对象实例的。如果你需要存储指针,不要存储原始指针,而是智能指针。否则,你需要处理指向对象的生存期管理。 
- 
向量大小修改的可能性。 
- 
两个向量的简单赋值或连接。 
- 
直接比较两个向量。 
vector 类是一个非常高效的容器,其所有实现都提供了许多优化,这些优化大多数开发者都无法用数组完成。对元素进行随机访问以及在向量末尾插入和删除操作是一个常数 O(1) 操作(前提是无需重新分配),而其他任何位置的插入和删除操作是一个线性 O(n) 操作。
与其他标准容器相比,向量具有各种优势:
- 
它与数组和 C 类型的 API 兼容。如果一个函数接受一个数组作为参数,其他容器的内容(除了 std::array)需要在传递给函数作为参数之前被复制到一个vector中。
- 
它对所有容器的元素访问速度最快(但与 std::array相同)。
- 
它没有为存储元素而设置的每个元素的内存开销。这是因为元素存储在连续的空间中,就像数组一样。因此, vector的内存占用很小,与其他容器不同,例如list,它需要指向其他元素的额外指针,或者关联容器,它需要哈希值。
std::vector 在语义上与数组非常相似,但具有可变大小。向量的大小可以增加和减少。有两个属性定义了向量的大小:
- 
容量 是向量在不进行额外内存分配的情况下可以容纳的元素数量;这由 capacity()方法表示。
- 
大小 是向量中实际元素的数量;这由 size()方法表示。
大小始终小于或等于容量。当大小等于容量且需要添加新元素时,必须修改容量,以便向量有更多元素的空间。在这种情况下,向量分配一个新的内存块,并将之前的内容移动到新位置,然后再释放之前分配的内存。尽管这听起来很耗时——确实如此——实现通过每次改变时将其加倍来指数级地增加容量。因此,平均而言,每个元素只需要移动一次(这是因为向量中的所有元素都在增加容量的过程中被移动,但之后可以添加相同数量的元素而无需进行更多移动,前提是在向量末尾进行插入操作)。
如果您事先知道将要插入向量中的元素数量,可以先调用 reserve() 方法将容量增加到至少指定的数量(如果指定的大小小于当前容量,则此方法不执行任何操作),然后才插入元素。
另一方面,如果您需要释放额外的预留内存,可以使用 shrink_to_fit() 方法请求这样做,但这是否释放任何内存是实现决策,而不是一个强制性的方法。自 C++11 以来,这种非绑定方法的替代方法是使用一个临时的空向量进行交换:
std::vector<int> v{ 1, 2, 3, 4, 5 };
std::vector<int>().swap(v); // v.size = 0, v.capacity = 0 
调用 clear() 方法只会从向量中移除所有元素,但不会释放任何内存。
应该注意的是,vector 类实现了针对其他类型容器的特定操作:
- 
栈:使用 push_back()和emplace_back()在末尾添加元素,使用pop_back()从末尾移除元素。请注意,pop_back()不会返回已移除的最后一个元素。如果你需要显式访问该元素,例如在移除元素之前使用back()方法。
- 
列表:使用 insert()和emplace()在序列中间添加元素,使用erase()从序列的任何位置移除元素。
对于 C++ 容器,一个很好的经验法则是除非你有充分的理由使用其他容器,否则默认使用 std::vector。
参见
- 
使用位集处理固定大小的位序列,了解处理固定大小位序列的标准容器 
- 
使用 vector<bool>处理可变大小的位序列,了解std::vector对bool类型的特化,旨在处理可变大小的位序列
使用位集处理固定大小的位序列
开发者使用位标志进行操作并不罕见。这可能是因为他们与操作系统 API(通常用 C 语言编写)一起工作,这些 API 以位标志的形式接受各种类型的参数(如选项或样式),或者因为他们与执行类似操作的库一起工作,或者仅仅是因为某些类型的问题自然可以用位标志来解决。
我们可以考虑使用位和位操作的其他替代方案,例如定义每个选项/标志都有一个元素的数组,或者定义一个具有成员和函数的结构来模拟位标志,但这些通常更复杂;并且在需要将表示位标志的数值传递给函数的情况下,你仍然需要将数组或结构转换为位序列。因此,C++ 标准提供了一个名为 std::bitset 的容器,用于固定大小的位序列。
准备工作
对于这个菜谱,你必须熟悉位操作(AND、OR、XOR、NOT 和移位 - 将数字的二进制表示中的每个数字向左或向右移动)。如果你需要了解更多关于这些的信息,en.wikipedia.org/wiki/Bitwise_operation 是一个很好的起点。
bitset 类在 <bitset> 头文件中的 std 命名空间中可用。位集表示一个在编译时定义大小的固定大小的位序列。为了方便起见,在这个菜谱中,大多数示例都将使用 8 位的位集。
如何做到这一点...
要构造一个 std::bitset 对象,请使用以下可用的构造函数之一:
- 
所有位都设置为 0的空位集:std::bitset<8> b1; // [0,0,0,0,0,0,0,0]
- 
从数值值创建位集: std::bitset<8> b2{ 10 }; // [0,0,0,0,1,0,1,0]
- 
从由 '0'和'1'组成的字符串创建位集:std::bitset<8> b3{ "1010"s }; // [0,0,0,0,1,0,1,0]
- 
从包含任意两个字符(代表 '0'和'1')的字符串中创建位集;在这种情况下,我们必须指定哪个字符代表0(第四个参数,'o')以及哪个字符代表1(第五个参数,'x'):std::bitset<8> b4 { "ooooxoxo"s, 0, std::string::npos, 'o', 'x' }; // [0,0,0,0,1,0,1,0]
要测试集合中的单个位或整个集合的特定值,请使用任何可用的方法:
- 
使用 count()获取设置为1的位的数量:std::bitset<8> bs{ 10 }; std::cout << "has " << bs.count() << " 1s" << '\n';
- 
使用 any()检查是否至少有一个位设置为1:if (bs.any()) std::cout << "has some 1s" << '\n';
- 
使用 all()检查是否所有位都设置为1:if (bs.all()) std::cout << "has only 1s" << '\n';
- 
使用 none()检查是否所有位都设置为0:if (bs.none()) std::cout << "has no 1s" << '\n';
- 
使用 test()函数来检查单个位的值(该位的位位置是函数的唯一参数):if (!bs.test(0)) std::cout << "even" << '\n';
- 
使用 operator[]来访问和测试单个位:if(!bs[0]) std::cout << "even" << '\n';
要修改位集的内容,可以使用以下任何一种方法:
- 
成员运算符 |=,&=,^=, 和~分别执行二进制操作 OR、AND、XOR 和 NOT。或者,可以使用非成员运算符|,&, 和^:std::bitset<8> b1{ 42 }; // [0,0,1,0,1,0,1,0] std::bitset<8> b2{ 11 }; // [0,0,0,0,1,0,1,1] auto b3 = b1 | b2; // [0,0,1,0,1,0,1,1] auto b4 = b1 & b2; // [0,0,0,0,1,0,1,0] auto b5 = b1 ^ b2; // [0,0,1,0,0,0,0,1] auto b6 = ~b1; // [1,1,0,1,0,1,0,1]
- 
成员运算符 <<=,<<,>>=, 和>>用于执行位移操作:auto b7 = b1 << 2; // [1,0,1,0,1,0,0,0] auto b8 = b1 >> 2; // [0,0,0,0,1,0,1,0]
- 
使用 flip()切换整个集合或单个位从0到1或从1到0:b1.flip(); // [1,1,0,1,0,1,0,1] b1.flip(0); // [1,1,0,1,0,1,0,0]
- 
使用 set()将整个集合或单个位更改为true或指定的值:b1.set(0, true); // [1,1,0,1,0,1,0,1] b1.set(0, false); // [1,1,0,1,0,1,0,0]
- 
使用 reset()将整个集合或单个位更改为false:b1.reset(2); // [1,1,0,1,0,0,0,0]
要将位集转换为数值或字符串值,请使用以下方法:
- 
使用 to_ulong()和to_ullong()将其转换为unsigned long或unsigned long long。如果值无法表示在输出类型中,这些操作会抛出std::overflow_error异常。请参考以下示例:std::bitset<8> bs{ 42 }; auto n1 = bs.to_ulong(); // n1 = 42UL auto n2 = bs.to_ullong(); // n2 = 42ULL
- 
使用 to_string()将其转换为std::basic_string。默认情况下,结果是包含'0'和'1'的字符串,但您可以指定这两个值的不同字符:auto s1 = bs.to_string(); // s1 = "00101010" auto s2 = bs.to_string('o', 'x'); // s2 = "ooxoxoxo"
它是如何工作的...
如果你曾经使用过 C 或类似 C 的 API,那么你很可能编写过或至少见过操作位以定义样式、选项或其他类型值的代码。这通常涉及以下操作:
- 
定义位标志;这些可以是枚举、类中的静态常量,或者使用 C 风格的 #define引入的宏。通常,有一个表示无值(样式、选项等)的标志。由于这些是位标志,它们的值是 2 的幂。
- 
向集合中添加或删除标志(即数值)。添加位标志使用位或运算符( value |= FLAG),删除位标志使用位与运算符,并使用取反的标志(value &= ~FLAG)。
- 
测试是否将标志添加到集合中( value & FLAG == FLAG)。
- 
使用标志作为参数调用函数。 
以下是一个简单示例,展示了定义具有左、右、上或下边框的控件边框样式的标志,包括这些边框的组合,以及没有边框的情况:
#define BORDER_NONE   0x00
#define BORDER_LEFT   0x01
#define BORDER_TOP    0x02
#define BORDER_RIGHT  0x04
#define BORDER_BOTTOM 0x08
void apply_style(unsigned int const style)
{
  if (style & BORDER_BOTTOM) { /* do something */ }
}
// initialize with no flags
unsigned int style = BORDER_NONE;
// set a flag
style = BORDER_BOTTOM;
// add more flags
style |= BORDER_LEFT | BORDER_RIGHT | BORDER_TOP;
// remove some flags
style &= ~BORDER_LEFT;
style &= ~BORDER_RIGHT;
// test if a flag is set
if ((style & BORDER_BOTTOM) == BORDER_BOTTOM) {}
// pass the flags as argument to a function
apply_style(style); 
标准的std::bitset类旨在作为 C++中类似 C 风格的位集工作方式的替代。它使我们能够编写更健壮和更安全的代码,因为它通过成员函数抽象了位操作,尽管我们仍然需要识别集合中每个位所代表的含义:
- 
使用 set()和reset()方法添加和移除标志,这些方法将位的位置值设置为1或0(或true和false);或者,我们可以使用索引运算符达到相同的目的。
- 
使用 test()方法检查位是否被设置。
- 
从整数或字符串的转换是通过构造函数完成的,而将值转换为整数或字符串则是通过成员函数完成的,这样就可以在期望整数的地方使用位集的值(例如函数的参数)。 
注意,从字符序列(无论是std::basic_string、const char*(或任何其他字符类型)还是 C++26 中的std::basic_string_view)构建bitset的构造函数可能会抛出异常:如果任何字符不是指定的零或一,则抛出std::invalid_argument异常;如果序列的起始偏移量超出了序列的末尾,则抛出std::out_of_range异常。
除了这些操作之外,bitset类还有执行位操作、移位、测试和其他在上一节中展示的方法的附加方法。
概念上,std::bitset是数值的表示,它允许你访问和修改单个位。然而,内部,一个bitset有一个整数值的数组,它在这个数组上执行位操作。bitset的大小不限于数值类型的大小;它可以是一切,除了它是一个编译时常量。
上一个章节中控制边框样式的示例可以用以下方式使用std::bitset编写:
struct border_flags
{
  static const int left = 0;
  static const int top = 1;
  static const int right = 2;
  static const int bottom = 3;
};
// initialize with no flags
std::bitset<4> style;
// set a flag
style.set(border_flags::bottom);
// set more flags
style
  .set(border_flags::left)
  .set(border_flags::top)
  .set(border_flags::right);
// remove some flags
style[border_flags::left] = 0;
style.reset(border_flags::right);
// test if a flag is set
if (style.test(border_flags::bottom)) {}
// pass the flags as argument to a function
apply_style(style.to_ulong()); 
请记住,这只是一个可能的实现。例如,border_flags类本可以是一个枚举类型。然而,使用范围枚举将需要显式转换为int。不同的解决方案可能有优点和缺点。你可以将其作为练习来编写一个替代方案。
还有更多...
可以从一个整数创建一个 bitset,并可以使用to_ulong()或to_ullong()方法将其值转换为整数。然而,如果bitset的大小大于这些数值类型的大小,并且请求的数值类型大小之外的任何位被设置为1,则这些方法会抛出std::overflow_error异常。这是因为该值无法在unsigned long或unsigned long long上表示。为了提取所有位,我们需要执行以下操作:
- 
清除 unsigned long或unsigned long long大小之外的位。
- 
将值转换为 unsigned long或unsigned long long。
- 
使用 unsigned long或unsigned long long中的位数量来移位bitset。
- 
继续这样做,直到所有位都被检索。 
这些是这样实现的:
template <size_t N>
std::vector<unsigned long> bitset_to_vectorulong(std::bitset<N> bs)
{
  auto result = std::vector<unsigned long> {};
  auto const size = 8 * sizeof(unsigned long);
  auto const mask = std::bitset<N>{ static_cast<unsigned long>(-1)};
  auto totalbits = 0;
  while (totalbits < N)
  {
    auto value = (bs & mask).to_ulong();
    result.push_back(value);
    bs >>= size;
    totalbits += size;
  }
  return result;
} 
为了举例说明,让我们考虑以下bitset:
std::bitset<128> bs =
    (std::bitset<128>(0xFEDC) << 96) |
    (std::bitset<128>(0xBA98) << 64) |
    (std::bitset<128>(0x7654) << 32) |
    std::bitset<128>(0x3210);
std::cout << bs << '\n'; 
如果我们打印其内容,我们得到以下结果:
00000000000000001111111011011100000000000000000010111010100110000000000000000000011101100101010000000000000000000011001000010000 
然而,当我们使用biset_to_vectorulong()将此集合转换为unsigned long值序列并打印其十六进制表示时,我们得到以下结果:
auto result = bitset_to_vectorulong(bs);
for (auto const v : result)
  std::cout << std::hex << v << '\n'; 
3210
7654
ba98
fedc 
对于在编译时无法知道bitset大小的案例,替代方案是std::vector<bool>,我们将在下一个食谱中介绍。
参见
- 
使用 vector<bool>处理可变大小的位序列,以了解std::vector对bool类型的特化,该类型用于处理可变大小的位序列
- 
使用位操作实用工具,以探索来自数值库的 C++20 位操作函数集 
使用vector<bool>处理可变大小的位序列
在之前的食谱中,我们探讨了使用std::bitset处理固定大小的位序列。有时,std::bitset并不是一个好的选择,因为您在编译时不知道位的数量,仅仅定义一个足够大的位集合并不是一个好主意。这是因为您可能会遇到实际上并不足够大的情况。这个标准的替代方案是使用std::vector<bool>容器,这是一个针对std::vector的空间和速度优化的特化,因为实现实际上并不存储布尔值,而是为每个元素存储单独的位。
然而,由于这个原因,std::vector<bool>不符合标准容器或顺序容器的需求,std::vector<bool>::iterator也不符合前向迭代器的需求。因此,这个特化不能用于期望使用向量的泛型代码中。另一方面,作为一个向量,它具有与std::bitset不同的接口,不能被视为数字的二进制表示。没有直接的方法可以从数字或字符串构造std::vector<bool>,也不能将其转换为数字或字符串。
准备工作...
本食谱假设您熟悉std::vector和std::bitset。如果您没有阅读之前的食谱,即使用 vector 作为默认容器和使用 bitset 处理固定大小的位序列,您应该在继续之前阅读它们。
vector<bool>类在<vector>头文件中的std命名空间中可用。
如何操作...
要操作std::vector<bool>,使用与操作std::vector<T>相同的方法,如下面的示例所示:
- 
创建一个空向量: std::vector<bool> bv; // []
- 
向向量中添加位: bv.push_back(true); // [1] bv.push_back(true); // [1, 1] bv.push_back(false); // [1, 1, 0] bv.push_back(false); // [1, 1, 0, 0] bv.push_back(true); // [1, 1, 0, 0, 1]
- 
设置单个位的值: bv[3] = true; // [1, 1, 0, 1, 1]
- 
使用泛型算法: auto count_of_ones = std::count(bv.cbegin(), bv.cend(), true);
- 
从向量中移除位: bv.erase(bv.begin() + 2); // [1, 1, 1, 1]
它是如何工作的...
std::vector<bool>不是一个标准向量,因为它设计用来通过为每个元素存储一个位而不是布尔值来提供空间优化。因此,其元素不是连续存储的,不能替换为布尔值数组。由于这个原因:
- 
索引操作符不能返回对特定元素的引用,因为元素不是单独存储的: std::vector<bool> bv; bv.resize(10); auto& bit = bv[0]; // error
- 
由于前面提到的原因,解引用迭代器不能产生对 bool的引用:auto& bit = *bv.begin(); // error
- 
没有保证在同一个时间点,不同线程可以独立地操作单个位。 
- 
向量不能与需要前向迭代器的算法一起使用,例如 std::search()。
- 
如果这样的代码需要本列表中提到的任何操作,则不能在期望 std::vector<T>的泛型代码中使用向量。
std::vector<bool>的替代方案是std::deque<bool>,这是一个满足所有容器和迭代器要求的标准容器(双端队列),并且可以与所有标准算法一起使用。然而,这不会像std::vector<bool>那样提供空间优化。
更多内容...
std::vector<bool>的接口与std::bitset非常不同。如果你想以类似的方式编写代码,你可以在std::vector<bool>上创建一个包装器,使其看起来像std::bitset,在可能的情况下。
以下实现提供了类似于std::bitset中可用的成员:
class bitvector
{
  std::vector<bool> bv;
public:
  bitvector(std::vector<bool> const & bv) : bv(bv) {}
  bool operator[](size_t const i) { return bv[i]; }
  inline bool any() const {
    for (auto b : bv) if (b) return true;
    return false;
  }
  inline bool all() const {
    for (auto b : bv) if (!b) return false;
    return true;
  }
  inline bool none() const { return !any(); }
  inline size_t count() const {
    return std::count(bv.cbegin(), bv.cend(), true);
  }
  inline size_t size() const { return bv.size(); }
  inline bitvector & add(bool const value) {
    bv.push_back(value);
    return *this;
  }
  inline bitvector & remove(size_t const index) {
    if (index >= bv.size())
      throw std::out_of_range("Index out of range");
    bv.erase(bv.begin() + index);
    return *this;
  }
  inline bitvector & set(bool const value = true) {
    for (size_t i = 0; i < bv.size(); ++i)
      bv[i] = value;
    return *this;
  }
  inline bitvector& set(size_t const index, bool const value = true) {
    if (index >= bv.size())
      throw std::out_of_range("Index out of range");
    bv[index] = value;
    return *this;
  }
  inline bitvector & reset() {
    for (size_t i = 0; i < bv.size(); ++i) bv[i] = false;
    return *this;
  }
  inline bitvector & reset(size_t const index) {
    if (index >= bv.size())
      throw std::out_of_range("Index out of range");
    bv[index] = false;
    return *this;
  }
  inline bitvector & flip() {
    bv.flip();
    return *this;
  }
  std::vector<bool>& data() { return bv; }
}; 
这只是一个基本的实现,如果你想要使用这样的包装器,你应该添加额外的功能,例如位逻辑操作、移位,也许是从和到流的读写,等等。然而,使用前面的代码,我们可以写出以下示例:
bitvector bv;
bv.add(true).add(true).add(false); // [1, 1, 0]
bv.add(false);                     // [1, 1, 0, 0]
bv.add(true);                      // [1, 1, 0, 0, 1]
if (bv.any()) std::cout << "has some 1s" << '\n';
if (bv.all()) std::cout << "has only 1s" << '\n';
if (bv.none()) std::cout << "has no 1s" << '\n';
std::cout << "has " << bv.count() << " 1s" << '\n';
bv.set(2, true);                   // [1, 1, 1, 0, 1]
bv.set();                          // [1, 1, 1, 1, 1]
bv.reset(0);                       // [0, 1, 1, 1, 1]
bv.reset();                        // [0, 0, 0, 0, 0]
bv.flip();                         // [1, 1, 1, 1, 1] 
这些示例与使用std::bitset的示例非常相似。这个bitvector类具有与std::bitset兼容的 API,但适用于处理可变大小的位序列。
参见
- 
使用向量作为默认容器,学习如何使用 std::vector标准容器
- 
使用 bitset 处理固定大小的位序列,了解处理固定大小位序列的标准容器 
- 
使用位操作工具,探索来自数值库的 C++20 位操作工具函数集 
使用位操作工具
在前面的菜谱中,我们看到了如何使用std::bitset和std::vector<bool>来处理固定和可变长度的位序列。然而,在某些情况下,我们需要操作或处理无符号整数值的单独或多个位。这包括计数或旋转位等操作。C++20 标准在数值库中提供了一套位操作工具函数。在本菜谱中,我们将学习它们是什么以及如何使用这些工具。
准备工作
本配方中讨论的函数模板都在新的 C++20 头文件 <bit> 中的 std 命名空间中可用。
如何做到这一点…
使用以下函数模板来操作无符号整型类型的位:
- 
如果需要执行循环移位,请使用 std::rotl<T>()进行左旋转和std::rotr<T>()进行右旋转:unsigned char n = 0b00111100; auto vl1 = std::rotl(n, 0); // 0b00111100 auto vl2 = std::rotl(n, 1); // 0b01111000 auto vl3 = std::rotl(n, 3); // 0b11100001 auto vl4 = std::rotl(n, 9); // 0b01111000 auto vl5 = std::rotl(n, -2);// 0b00001111 auto vr1 = std::rotr(n, 0); // 0b00111100 auto vr2 = std::rotr(n, 1); // 0b00011110 auto vr3 = std::rotr(n, 3); // 0b10000111 auto vr4 = std::rotr(n, 9); // 0b00011110 auto vr5 = std::rotr(n, -2); // 0b11110000
- 
如果需要计算连续 0位的数量(即,直到找到一个1),请使用std::countl_zero<T>()从左到右计算(即,从最高有效位开始)和std::countr_zero<T>()从右到左计算(即,从最低有效位开始):std::cout << std::countl_zero(0b00000000u) << '\n'; // 8 std::cout << std::countl_zero(0b11111111u) << '\n'; // 0 std::cout << std::countl_zero(0b00111010u) << '\n'; // 2 std::cout << std::countr_zero(0b00000000u) << '\n'; // 8 std::cout << std::countr_zero(0b11111111u) << '\n'; // 0 std::cout << std::countr_zero(0b00111010u) << '\n'; // 1
- 
如果需要计算连续 1位的数量(即,直到找到一个0),请使用std::countl_one<T>()从左到右计算(即,从最高有效位开始)和std::countr_one<T>()从右到左计算(即,从最低有效位开始):std::cout << std::countl_one(0b00000000u) << '\n'; // 0 std::cout << std::countl_one(0b11111111u) << '\n'; // 8 std::cout << std::countl_one(0b11000101u) << '\n'; // 2 std::cout << std::countr_one(0b00000000u) << '\n'; // 0 std::cout << std::countr_one(0b11111111u) << '\n'; // 8 std::cout << std::countr_one(0b11000101u) << '\n'; // 1
- 
如果需要计算 1位的数量,请使用std::popcount<T>()。0位的数量是表示该值所使用的数字位数(这可以通过std::numeric_limits<T>::digits来确定),减去1位的数量:std::cout << std::popcount(0b00000000u) << '\n'; // 0 std::cout << std::popcount(0b11111111u) << '\n'; // 8 std::cout << std::popcount(0b10000001u) << '\n'; // 2
- 
如果需要检查一个数字是否是 2 的幂,请使用 std::has_single_bit<T>():std::cout << std::boolalpha << std::has_single_bit(0u) << '\n'; // false std::cout << std::boolalpha << std::has_single_bit(1u) << '\n'; // true std::cout << std::boolalpha << std::has_single_bit(2u) << '\n'; // true std::cout << std::boolalpha << std::has_single_bit(3u) << '\n'; // false std::cout << std::boolalpha << std::has_single_bit(4u) << '\n'; // true
- 
如果需要找到大于或等于给定数字的最小 2 的幂,请使用 std::bit_ceil<T>()。另一方面,如果需要找到小于或等于给定数字的最大 2 的幂,请使用std::bit_floor<T>():std::cout << std::bit_ceil(0u) << '\n'; // 0 std::cout << std::bit_ceil(3u) << '\n'; // 4 std::cout << std::bit_ceil(4u) << '\n'; // 4 std::cout << std::bit_ceil(31u) << '\n'; // 32 std::cout << std::bit_ceil(42u) << '\n'; // 64 std::cout << std::bit_floor(0u) << '\n'; // 0 std::cout << std::bit_floor(3u) << '\n'; // 2 std::cout << std::bit_floor(4u) << '\n'; // 4 std::cout << std::bit_floor(31u) << '\n'; // 16 std::cout << std::bit_floor(42u) << '\n'; // 32
- 
如果需要确定表示一个数字所需的最小位数,请使用 std::bit_width<T>():std::cout << std::bit_width(0u) << '\n'; // 0 std::cout << std::bit_width(2u) << '\n'; // 2 std::cout << std::bit_width(15u) << '\n'; // 4 std::cout << std::bit_width(16u) << '\n'; // 5 std::cout << std::bit_width(1000u) << '\n'; // 10
- 
如果需要将类型 F的对象表示重新解释为类型T的表示,请使用std::bit_cast<T, F>():const double pi = 3.1415927; const uint64_t bits = std::bit_cast<uint64_t>(pi); const double pi2 = std::bit_cast<double>(bits); std::cout << std::fixed << pi << '\n' // 3.1415923 << std::hex << bits << '\n' // 400921fb5a7ed197 << std::fixed << pi2 << '\n'; // 3.1415923
它是如何工作的…
上一节中提到的所有函数模板,除了 std::bit_cast<T, F>(),仅适用于无符号整型类型。这包括类型 unsigned char、unsigned short、unsigned int、unsigned long 和 unsigned long long,以及固定宽度的无符号整型类型(例如 uint8_t、uint64_t、uint_least8_t、uintmax_t 等)。这些函数很简单,不需要详细说明。
与其他函数不同的函数是 std::bit_cast<T, F>()。在这里,F 是重新解释的类型,而 T 是我们解释到的类型。此函数模板不需要 T 和 F 是无符号整型类型,但它们都必须是简单可复制的。此外,sizeof(T) 必须与 sizeof(F) 相同。
该函数的规范没有提及结果中填充位的值。另一方面,如果结果值不对应于类型 T 的有效值,则行为是未定义的。
std::bit_cast<T, F>()可以是constexpr,如果T、F以及它们所有子对象类型的类型不是联合类型、指针类型、成员指针类型或带 volatile 资格的类型,并且没有非静态数据成员为引用类型。
参见
- 
使用位集处理固定大小的位序列,了解处理固定大小位序列的标准容器 
- 
使用 vector<bool>处理可变大小的位序列,了解std::vector对bool类型的特化,旨在处理可变大小的位序列
在一个范围内查找元素
在任何应用程序中,我们最常进行的操作之一是搜索数据。因此,标准库提供许多通用算法来搜索标准容器,或者任何可以表示范围并由开始和结束迭代器定义的东西,并不足为奇。在本食谱中,我们将了解这些标准算法是什么以及如何使用它们。
准备工作
在本食谱的所有示例中,我们将使用std::vector,但所有算法都与由开始和结束迭代器定义的范围一起工作,这些迭代器是输入迭代器或前向迭代器,具体取决于算法(有关各种迭代器的更多信息,请参阅本章后面的编写自己的随机访问迭代器食谱)。所有这些算法都在<algorithm>头文件中的std命名空间中可用。
如何做到这一点...
以下是可以用于在范围内查找元素的算法列表:
- 
使用 std::find()来查找一个范围内存在的值;此算法返回一个指向第一个等于该值的元素的迭代器:std::vector<int> v{ 13, 1, 5, 3, 2, 8, 1 }; auto it = std::find(v.cbegin(), v.cend(), 3); if (it != v.cend()) std::cout << *it << '\n'; // prints 3
- 
使用 std::find_if()来查找一个范围内符合一元谓词准则的值;此算法返回一个指向第一个谓词返回true的元素的迭代器:std::vector<int> v{ 13, 1, 5, 3, 2, 8, 1 }; auto it = std::find_if(v.cbegin(), v.cend(), [](int const n) {return n > 10; }); if (it != v.cend()) std::cout << *it << '\n'; // prints 13
- 
使用 std::find_if_not()来查找一个范围内不符合一元谓词准则的值;此算法返回一个指向第一个谓词返回false的元素的迭代器:std::vector<int> v{ 13, 1, 5, 3, 2, 8, 1 }; auto it = std::find_if_not(v.cbegin(), v.cend(), [](int const n) {return n % 2 == 1; }); if (it != v.cend()) std::cout << *it << '\n'; // prints 2
- 
使用 std::find_first_of()在另一个范围内搜索来自一个范围的任何值的出现;此算法返回一个指向找到的第一个元素的迭代器:std::vector<int> v{ 13, 1, 5, 3, 2, 8, 1 }; std::vector<int> p{ 5, 7, 11 }; auto it = std::find_first_of(v.cbegin(), v.cend(), p.cbegin(), p.cend()); if (it != v.cend()) std::cout << "found " << *it << " at index " << std::distance(v.cbegin(), it) << '\n'; // found 5 at index 2
- 
使用 std::find_end()来查找一个范围内元素子范围的最后出现;此算法返回一个指向最后一个子范围中第一个元素的迭代器:std::vector<int> v1{ 1, 1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 1 }; std::vector<int> v2{ 1, 0, 1 }; auto it = std::find_end(v1.cbegin(), v1.cend(), v2.cbegin(), v2.cend()); if (it != v1.cend()) std::cout << "found at index " << std::distance(v1.cbegin(), it) << '\n'; // found at index 8
- 
要在一个范围内找到最小和最大元素,使用 std::min_element()来获取最小值,std::max_element()来获取最大值,以及std::minmax_element()来同时获取最小和最大值:std::vector<int> v{ 1, 5, -2, 9, 6 }; auto minit = std::min_element(v.begin(), v.end()); std::cout << "min=" << *minit << '\n'; // min=-2 auto maxit = std::max_element(v.begin(), v.end()); std::cout << "max=" << *maxit << '\n'; // max=9 auto minmaxit = std::minmax_element(v.begin(), v.end()); std::cout << "min=" << *minmaxit.first << '\n'; // min=-2 std::cout << "max=" << *minmaxit.second << '\n'; // max=9
- 
使用 std::search()来搜索一个范围内子范围的第一出现;此算法返回一个指向子范围在范围内第一个元素的迭代器:auto text = "The quick brown fox jumps over the lazy dog"s; auto word = "over"s; auto it = std::search(text.cbegin(), text.cend(), word.cbegin(), word.cend()); if (it != text.cend()) std::cout << "found " << word << " at index " << std::distance(text.cbegin(), it) << '\n';
- 
使用 std::search()与一个 searcher,这是一个实现搜索算法并满足某些预定义标准的类。此std::search()重载是在 C++17 中引入的,并且可用的标准搜索器实现了 Boyer-Moore 和 Boyer-Moore-Horspool 字符串搜索算法:auto text = "The quick brown fox jumps over the lazy dog"s; auto word = "over"s; auto it = std::search( text.cbegin(), text.cend(), std::make_boyer_moore_searcher(word.cbegin(), word.cend())); if (it != text.cend()) std::cout << "found " << word << " at index " << std::distance(text.cbegin(), it) << '\n';
- 
使用 std::search_n()在一个范围内搜索 N 个连续出现的值;此算法返回找到的序列在范围内的第一个元素的迭代器:std::vector<int> v{ 1, 1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 1 }; auto it = std::search_n(v.cbegin(), v.cend(), 2, 0); if (it != v.cend()) std::cout << "found at index " << std::distance(v.cbegin(), it) << '\n';
- 
使用 std::adjacent_find()来查找一个范围内相等或满足二元谓词的两个相邻元素;此算法返回找到的第一个元素的迭代器:std::vector<int> v{ 1, 1, 2, 3, 5, 8, 13 }; auto it = std::adjacent_find(v.cbegin(), v.cend()); if (it != v.cend()) std::cout << "found at index " << std::distance(v.cbegin(), it) << '\n'; auto it = std::adjacent_find( v.cbegin(), v.cend(), [](int const a, int const b) { return IsPrime(a) && IsPrime(b); }); if (it != v.cend()) std::cout << "found at index " << std::distance(v.cbegin(), it) << '\n';
- 
使用 std::binary_search()来查找一个元素是否存在于有序范围内;此算法返回一个布尔值以指示是否找到了该值:std::vector<int> v{ 1, 1, 2, 3, 5, 8, 13 }; auto success = std::binary_search(v.cbegin(), v.cend(), 8); if (success) std::cout << "found" << '\n';
- 
使用 std::lower_bound()来查找一个范围内不小于指定值的第一个元素;此算法返回元素的迭代器:std::vector<int> v{ 1, 1, 2, 3, 5, 8, 13 }; auto it = std::lower_bound(v.cbegin(), v.cend(), 1); if (it != v.cend()) std::cout << "lower bound at " << std::distance(v.cbegin(), it) << '\n';
- 
使用 std::upper_bound()来查找一个范围内大于指定值的第一个元素;此算法返回元素的迭代器:std::vector<int> v{ 1, 1, 2, 3, 5, 8, 13 }; auto it = std::upper_bound(v.cbegin(), v.cend(), 1); if (it != v.cend()) std::cout << "upper bound at " << std::distance(v.cbegin(), it) << '\n';
- 
使用 std::equal_range()在一个值等于指定值的范围内查找子范围。此算法返回一个迭代器对,定义子范围的第一个和超出末尾的迭代器;这两个迭代器等同于由std::lower_bound()和std::upper_bound()返回的迭代器:std::vector<int> v{ 1, 1, 2, 3, 5, 8, 13 }; auto bounds = std::equal_range(v.cbegin(), v.cend(), 1); std::cout << "range between indexes " << std::distance(v.cbegin(), bounds.first) << " and " << std::distance(v.cbegin(), bounds.second) << '\n';
它是如何工作的...
这些算法的工作方式非常相似:它们都接受定义可搜索范围的迭代器以及每个算法依赖的附加参数。除了返回布尔值的 std::binary_search() 和返回迭代器对的 std::equal_range() 之外,它们都返回搜索到的元素或子范围的迭代器。这些迭代器必须与范围的末尾迭代器(即超出最后一个元素)进行比较,以检查搜索是否成功。如果搜索没有找到元素或子范围,则返回的值是末尾迭代器。
所有这些算法都有多个重载,但在 如何做... 部分中,我们只查看了一个特定的重载来展示算法如何使用。要查看所有重载的完整参考,你应该查看其他来源,例如 en.cppreference.com/w/cpp/algorithm。
在所有前面的例子中,我们使用了常量迭代器,但所有这些算法都可以与可变迭代器和反向迭代器一起工作。因为它们接受迭代器作为输入参数,所以它们可以与标准容器、数组或任何具有迭代器的序列一起工作。
关于 std::binary_search() 算法的特别说明是必要的:定义搜索范围的迭代器参数至少应满足前向迭代器的需求。无论提供的迭代器类型如何,比较次数总是与范围大小的对数成正比。然而,如果迭代器是随机访问的,则迭代器增加的次数也是对数的,或者不是随机访问的,在这种情况下,它是线性的,并且与范围的大小成比例。
除了 std::find_if_not() 之外,所有这些算法在 C++11 之前都是可用的。然而,它们的一些重载在新标准中已被引入。一个例子是 std::search(),它在 C++17 中引入了几个重载。其中之一具有以下形式:
template<class ForwardIterator, class Searcher>
ForwardIterator search(ForwardIterator first, ForwardIterator last,
 const Searcher& searcher ); 
此重载通过搜索由搜索函数对象定义的模式来查找其发生,标准为此提供了几个实现:
- 
default_searcher基本上委托搜索到标准的std::search()算法。
- 
boyer_moore_searcher实现了 Boyer-Moore 字符串搜索算法。
- 
boyer_moore_horspool_algorithm实现了 Boyer-Moore-Horspool 字符串搜索算法。
许多标准容器都有一个成员函数 find() 用于在容器中查找元素。当此类方法可用且满足您的需求时,应优先于通用算法,因为这些成员函数是根据每个容器的特定性进行优化的。
参见
- 
使用向量作为默认容器,了解如何使用 std::vector标准容器
- 
初始化一个范围,探索填充范围值的标准算法 
- 
在范围内使用集合操作,了解用于执行排序范围并集、交集或差集的标准算法 
- 
对范围进行排序,了解排序范围的通用算法 
对范围进行排序
在前面的菜谱中,我们研究了在范围内搜索的通用标准算法。我们经常需要执行的其他常见操作之一是对范围进行排序,因为许多例程,包括一些搜索算法,都需要排序的范围。标准库提供了几个用于排序范围的通用算法,在本菜谱中,我们将了解这些算法是什么以及如何使用它们。
准备工作
排序通用算法与由起始和结束迭代器定义的范围一起工作,因此可以排序标准容器、数组或任何具有随机迭代器的序列。然而,本菜谱中的所有示例都将使用 std::vector。
如何做...
以下是一系列用于搜索范围的通用标准算法:
- 
使用 std::sort()对范围进行排序:std::vector<int> v{3, 13, 5, 8, 1, 2, 1}; std::sort(v.begin(), v.end()); // v = {1, 1, 2, 3, 5, 8, 13} std::sort(v.begin(), v.end(), std::greater<>()); // v = {13, 8, 5, 3, 2, 1, 1}
- 
使用 std::stable_sort()对范围进行排序,但保持相等元素的顺序:struct Task { int priority; std::string name; }; bool operator<(Task const & lhs, Task const & rhs) { return lhs.priority < rhs.priority; } bool operator>(Task const & lhs, Task const & rhs) { return lhs.priority > rhs.priority; } std::vector<Task> v{ { 10, "Task 1"s }, { 40, "Task 2"s }, { 25, "Task 3"s }, { 10, "Task 4"s }, { 80, "Task 5"s }, { 10, "Task 6"s }, }; std::stable_sort(v.begin(), v.end()); // {{ 10, "Task 1" },{ 10, "Task 4" },{ 10, "Task 6" }, // { 25, "Task 3" },{ 40, "Task 2" },{ 80, "Task 5" }} std::stable_sort(v.begin(), v.end(), std::greater<>()); // {{ 80, "Task 5" },{ 40, "Task 2" },{ 25, "Task 3" }, // { 10, "Task 1" },{ 10, "Task 4" },{ 10, "Task 6" }}
- 
使用 std::partial_sort()对范围的一部分进行排序(并保留其余部分的不确定顺序):std::vector<int> v{ 3, 13, 5, 8, 1, 2, 1 }; std::partial_sort(v.begin(), v.begin() + 4, v.end()); // v = {1, 1, 2, 3, ?, ?, ?} std::partial_sort(v.begin(), v.begin() + 4, v.end(), std::greater<>()); // v = {13, 8, 5, 3, ?, ?, ?}
- 
使用 std::partial_sort_copy()通过将排序的元素复制到第二个范围来对范围的一部分进行排序,同时保留原始范围不变:std::vector<int> v{ 3, 13, 5, 8, 1, 2, 1 }; std::vector<int> vc(v.size()); std::partial_sort_copy(v.begin(), v.end(), vc.begin(), vc.end()); // v = {3, 13, 5, 8, 1, 2, 1} // vc = {1, 1, 2, 3, 5, 8, 13} std::partial_sort_copy(v.begin(), v.end(), vc.begin(), vc.end(), std::greater<>()); // vc = {13, 8, 5, 3, 2, 1, 1}
- 
使用 std::nth_element()对范围进行排序,以便第 N 个元素是如果范围完全排序时将位于该位置的元素,并且它之前的所有元素都更小,它之后的所有元素都更大,没有任何保证它们也是有序的:std::vector<int> v{ 3, 13, 5, 8, 1, 2, 1 }; std::nth_element(v.begin(), v.begin() + 3, v.end()); // v = {1, 1, 2, 3, 5, 8, 13} std::nth_element(v.begin(), v.begin() + 3, v.end(), std::greater<>()); // v = {13, 8, 5, 3, 2, 1, 1}
- 
使用 std::is_sorted()来检查一个范围是否已排序:std::vector<int> v { 1, 1, 2, 3, 5, 8, 13 }; auto sorted = std::is_sorted(v.cbegin(), v.cend()); sorted = std::is_sorted(v.cbegin(), v.cend(), std::greater<>());
- 
使用 std::is_sorted_until()从范围的开头找到一个已排序的子范围:std::vector<int> v{ 3, 13, 5, 8, 1, 2, 1 }; auto it = std::is_sorted_until(v.cbegin(), v.cend()); auto length = std::distance(v.cbegin(), it);
它是如何工作的...
所有的先前通用算法都接受随机迭代器作为参数来定义要排序的范围。其中一些还接受一个输出范围。它们都有重载:一个需要比较函数来排序元素,另一个不需要,并使用 operator< 来比较元素。
这些算法以以下方式工作:
- 
std::sort()修改输入范围,使其元素根据默认或指定的比较函数排序;实际的排序算法是实现细节。
- 
std::stable_sort()与std::sort()类似,但它保证保留相等元素的原始顺序。
- 
std::partial_sort()接收三个迭代器参数,表示范围中的第一个、中间和最后一个元素,其中中间可以是任何元素,而不仅仅是自然中间位置的元素。结果是部分排序的范围,使得从原始范围中找到的第一个到中间的最小元素(即 [first, last),位于 [first, middle) 子范围中,其余元素在 [middle, last) 子范围中以不确定的顺序排列。
- 
std::partial_sort_copy()并不是std::partial_sort()的变体,尽管名称可能暗示,而是std::sort()的变体。它通过将元素复制到输出范围而不改变它来对范围进行排序。算法的参数是输入和输出范围的第一个和最后一个迭代器。如果输出范围的大小 M 大于或等于输入范围的大小 N,则输入范围完全排序并复制到输出范围;输出范围的前 N 个元素被覆盖,最后 M – N 个元素保持不变。如果输出范围小于输入范围,则仅将输入范围的前 M 个排序元素复制到输出范围(在这种情况下,输出范围被完全覆盖)。
- 
std::nth_element()基本上是选择算法的实现,该算法是寻找范围中第 N 个最小元素的算法。此算法接受三个迭代器参数,分别表示第一个、第 N 个和最后一个元素,并部分排序范围,以便在排序后,第 N 个元素是如果整个范围都已排序,它将位于该位置的元素。在修改后的范围内,第 N 个之前的所有 N – 1 个元素都小于它,而第 N 个之后的元素都大于它。然而,对这些其他元素的顺序没有保证。
- 
std::is_sorted()检查指定的范围是否根据指定的或默认的比较函数排序,并返回一个布尔值以指示这一点。
- 
std::is_sorted_until()查找指定范围内的有序子范围,从开始处开始,使用提供的比较函数或默认的operator<。返回的值是一个迭代器,表示有序子范围的 upper bound,也是最后一个有序元素的下一个迭代器。
一些标准容器 std::list 和 std::forward_list 提供了一个成员函数 sort(),该函数针对这些容器进行了优化。应优先使用这些成员函数,而不是通用标准算法 std::sort()。
相关内容
- 
将向量作为默认容器使用,了解如何使用 std::vector标准容器。
- 
初始化一个范围 以探索用值填充范围的标准算法 
- 
在范围内使用集合操作,了解执行有序范围并集、交集或差集的标准算法 
- 
在范围内查找元素,了解搜索值序列的标准算法 
初始化一个范围
在之前的菜谱中,我们探讨了在范围内搜索和排序的一般标准算法。算法库提供了许多其他通用算法,其中一些旨在用值填充范围。在本菜谱中,你将了解这些算法是什么以及如何使用它们。
准备工作
本菜谱中的所有示例都使用 std::vector。然而,像所有通用算法一样,我们将在本菜谱中看到的算法使用迭代器来定义范围的界限,因此可以使用任何标准容器、数组或具有定义了前向迭代器的自定义类型来表示序列。
除了在 <numeric> 头文件中可用的 std::iota() 之外,所有其他算法都可在 <algorithm> 头文件中找到。
如何做...
要将值分配给范围,可以使用以下任何标准算法:
- 
std::fill()将值分配给范围的所有元素;范围由一个第一个和最后一个前向迭代器定义:std::vector<int> v(5); std::fill(v.begin(), v.end(), 42); // v = {42, 42, 42, 42, 42}
- 
std::fill_n()用于将值赋给一个范围内的若干元素;该范围由一个第一个前向迭代器和计数器定义,该计数器指示应该为多少个元素分配指定的值:std::vector<int> v(10); std::fill_n(v.begin(), 5, 42); // v = {42, 42, 42, 42, 42, 0, 0, 0, 0, 0}
- 
std::generate()用于将函数返回的值赋给一个范围内的元素;该范围由一个第一个和最后一个前向迭代器定义,并且对于范围中的每个元素,函数都会被调用一次:std::random_device rd{}; std::mt19937 mt{ rd() }; std::uniform_int_distribution<> ud{1, 10}; std::vector<int> v(5); std::generate(v.begin(), v.end(), [&ud, &mt] {return ud(mt); });
- 
std::generate_n()用于将函数返回的值赋给一个范围内的若干元素;该范围由一个第一个前向迭代器和计数器定义,该计数器指示应该从调用的函数中为多少个元素赋值,该函数对于每个元素调用一次:std::vector<int> v(5); auto i = 1; std::generate_n(v.begin(), v.size(), [&i] { return i*i++; }); // v = {1, 4, 9, 16, 25}
- 
std::iota()用于将顺序递增的值赋给一个范围内的元素;该范围由一个第一个和最后一个前向迭代器定义,并且使用从初始指定值开始的operator++前缀来递增值:std::vector<int> v(5); std::iota(v.begin(), v.end(), 1); // v = {1, 2, 3, 4, 5}
它是如何工作的...
std::fill() 和 std::fill_n() 在工作方式上相似,但在指定范围的方式上有所不同:前者通过第一个和最后一个迭代器,后者通过第一个迭代器和计数器。第二个算法返回一个迭代器,表示如果计数器大于零,则为已分配的最后一个元素之后的一个迭代器,否则为范围的第一个元素的迭代器。
std::generate() 和 std::generate_n() 也非常相似,只是在指定范围的方式上有所不同。第一个接受两个迭代器,定义范围的上下界,而第二个接受第一个元素的迭代器和计数器。像 std::fill_n() 一样,std::generate_n() 也返回一个迭代器,表示如果计数器大于零,则为已分配的最后一个元素之后的一个迭代器,否则为范围的第一个元素的迭代器。这些算法对范围中的每个元素调用指定的函数,并将返回的值赋给该元素。生成函数不接受任何参数,因此无法将参数的值传递给函数。这是因为它被设计为一个用于初始化范围元素的函数。如果您需要使用元素的值来生成新值,应使用 std::transform()。
std::iota() 的名字来源于 APL 编程语言中的 ι (iota) 函数,尽管它最初是 STL 的一部分,但它仅在 C++11 标准库中包含。此函数接受一个范围的前向迭代器和最后一个迭代器,以及一个分配给范围第一个元素的初始值。然后使用范围中其余元素的 operator++ 前缀来生成顺序递增的值。
STL 代表 Standard Template Library。它是由 Alexander Stepanov 设计的软件库,最初是为 C++ 设计的,在 C++ 语言标准化之前。后来它被用来模拟 C++ 标准库,提供容器、迭代器、算法和函数。它不应与 C++ 标准库混淆,因为这两个是不同的实体。
更多内容...
我们在这个配方中看到的示例使用了整数,这样它们就更容易理解。然而,我们也可以提供一个现实生活中的例子,以帮助您更好地理解这些算法如何在更复杂的场景中使用。
让我们考虑一个函数,给定两种颜色生成一系列中间点,表示渐变。一个颜色对象有三个值,分别对应红色、绿色和蓝色通道。我们可以将其建模如下:
struct color
{
   unsigned char red   = 0;
   unsigned char blue  = 0;
   unsigned char green = 0;
}; 
我们将编写一个函数,该函数接受起始颜色和结束颜色,以及要生成的点的数量,并返回一个 color 对象的向量。内部使用 std::generate_n() 来生成值:
std::vector<color> make_gradient(color const& c1, color const& c2, size_t points)
{
   std::vector<color> colors(points);
   auto rstep = static_cast<double>(c2.red - c1.red) / points;
   auto gstep = static_cast<double>(c2.green - c1.green) / points;
   auto bstep = static_cast<double>(c2.blue - c1.blue) / points;
   auto r = c1.red;
   auto g = c1.green;
   auto b = c1.blue;
   std::generate_n(colors.begin(), 
                   points, 
                   [&r, &g, &b, rstep, gstep, bstep] {
      color c {
         static_cast<unsigned char>(r),
         static_cast<unsigned char>(g),
         static_cast<unsigned char>(b) 
      };
      r += rstep;
      g += gstep;
      b += bstep;
      return c;
   });
   return colors;
} 
我们可以这样使用这个函数:
color white { 255, 255, 255 };
color black { 0, 0, 0 };
std::vector<color> grayscale = make_gradient(white, black, 256);
std::for_each(
   grayscale.begin(), grayscale.end(),
   [](color const& c) {
      std::cout << 
         static_cast<int>(c.red) << ", "
         << static_cast<int>(c.green) << ", "
         << static_cast<int>(c.blue) << '\n';
   }); 
虽然运行此代码片段的输出有 256 行(每行一个点),但我们可以展示其中的一部分:
255, 255, 255
254, 254, 254
253, 253, 253
…
1, 1, 1
0, 0, 0 
参见
- 
对范围进行排序,了解标准算法对范围的排序方法 
- 
在范围内使用集合操作,了解用于执行排序范围并集、交集或差集的标准算法 
- 
在范围内查找元素,了解标准算法在值序列中的搜索方法 
- 
第二章,生成伪随机数,了解在 C++ 中生成伪随机数的正确方法 
- 
第二章,初始化伪随机数生成器的内部状态的所有位,了解如何正确初始化随机数生成器 
在范围内使用集合操作
标准库提供了几个集合操作算法,使我们能够对排序范围执行并集、交集或差集操作。在本配方中,我们将了解这些算法是什么以及它们是如何工作的。
准备工作
集合操作的算法与迭代器一起工作,这意味着它们可以用于标准容器、数组或任何具有输入迭代器的自定义类型表示的序列。本配方中的所有示例都将使用 std::vector。
在下一节的全部示例中,我们将使用以下范围:
std::vector<int> v1{ 1, 2, 3, 4, 4, 5 };
std::vector<int> v2{ 2, 3, 3, 4, 6, 8 };
std::vector<int> v3; 
在下一节中,我们将探讨标准算法在集合操作中的应用。
如何实现...
使用以下通用算法进行集合操作:
- 
使用 std::set_union()来计算两个范围合并到第三个范围中:std::set_union(v1.cbegin(), v1.cend(), v2.cbegin(), v2.cend(), std::back_inserter(v3)); // v3 = {1, 2, 3, 3, 4, 4, 5, 6, 8}
- 
使用 std::merge()将两个范围的内容合并到第三个范围中;这与std::set_union()类似,但不同之处在于它将输入范围的全部内容复制到输出范围中,而不仅仅是它们的并集:std::merge(v1.cbegin(), v1.cend(), v2.cbegin(), v2.cend(), std::back_inserter(v3)); // v3 = {1, 2, 2, 3, 3, 3, 4, 4, 4, 5, 6, 8}
- 
使用 std::set_intersection()来计算两个范围交集到第三个范围中:std::set_intersection(v1.cbegin(), v1.cend(), v2.cbegin(), v2.cend(), std::back_inserter(v3)); // v3 = {2, 3, 4}
- 
std::set_difference()用于将两个范围的计算差值到第三个范围中;输出范围将包含来自第一个范围,但不在第二个范围中的元素:std::set_difference(v1.cbegin(), v1.cend(), v2.cbegin(), v2.cend(), std::back_inserter(v3)); // v3 = {1, 4, 5}
- 
std::set_symmetric_difference()用于将两个范围的双向差值计算到第三个范围中;输出范围将包含在任何输入范围中出现的元素,但只在一个范围中:std::set_symmetric_difference(v1.cbegin(), v1.cend(), v2.cbegin(), v2.cend(), std::back_inserter(v3)); // v3 = {1, 3, 4, 5, 6, 8}
- 
std::includes()用于检查一个范围是否是另一个范围(即,其所有元素也存在于另一个范围)的子集:std::vector<int> v1{ 1, 2, 3, 4, 4, 5 }; std::vector<int> v2{ 2, 3, 3, 4, 6, 8 }; std::vector<int> v3{ 1, 2, 4 }; std::vector<int> v4{ }; auto i1 = std::includes(v1.cbegin(), v1.cend(), v2.cbegin(), v2.cend()); // i1 = false auto i2 = std::includes(v1.cbegin(), v1.cend(), v3.cbegin(), v3.cend()); // i2 = true auto i3 = std::includes(v1.cbegin(), v1.cend(), v4.cbegin(), v4.cend()); // i3 = true
它是如何工作的...
所有从两个输入范围生成新范围的集合操作都具有相同的接口并以类似的方式工作:
- 
它们接受两个输入范围,每个范围由一个第一个和最后一个输入迭代器定义。 
- 
它们接受一个指向输出范围的输出迭代器,其中元素将被插入。 
- 
它有一个重载,接受一个额外的参数,表示一个比较二元函数对象,该对象必须返回 true如果第一个参数小于第二个。当未指定比较函数对象时,使用operator<。
- 
它们返回一个指向构造输出范围结束之后的迭代器。 
- 
输入范围必须使用 operator<或提供的比较函数进行排序,具体取决于使用的重载。
- 
输出范围必须与两个输入范围中的任何一个都不重叠。 
我们将通过使用我们之前也使用过的 Task 类型 POD 向量来使用额外的示例演示它们的工作方式:
struct Task
{
  int         priority;
  std::string name;
};
bool operator<(Task const & lhs, Task const & rhs) {
  return lhs.priority < rhs.priority;
}
bool operator>(Task const & lhs, Task const & rhs) {
  return lhs.priority > rhs.priority;
}
std::vector<Task> v1{
  { 10, "Task 1.1"s },
  { 20, "Task 1.2"s },
  { 20, "Task 1.3"s },
  { 20, "Task 1.4"s },
  { 30, "Task 1.5"s },
  { 50, "Task 1.6"s },
};
std::vector<Task> v2{
  { 20, "Task 2.1"s },
  { 30, "Task 2.2"s },
  { 30, "Task 2.3"s },
  { 30, "Task 2.4"s },
  { 40, "Task 2.5"s },
  { 50, "Task 2.6"s },
}; 
每个算法产生输出范围的具体方式在此描述:
- 
std::set_union()将一个或两个输入范围中存在的所有元素复制到输出范围中,生成一个新排序的范围。如果一个元素在第一个范围中找到 M 次,在第二个范围中找到 N 次,那么第一个范围中的所有 M 个元素将按其现有顺序复制到输出范围中,然后如果 N > M,则将第二个范围中的 N – M 个元素复制到输出范围中,否则复制0个元素:std::vector<Task> v3; std::set_union(v1.cbegin(), v1.cend(), v2.cbegin(), v2.cend(), std::back_inserter(v3)); // v3 = {{10, "Task 1.1"},{20, "Task 1.2"},{20, "Task 1.3"}, // {20, "Task 1.4"},{30, "Task 1.5"},{30, "Task 2.3"}, // {30, "Task 2.4"},{40, "Task 2.5"},{50, "Task 1.6"}}
- 
std::merge()将两个输入范围的所有元素复制到输出范围中,生成一个根据比较函数排序的新范围:std::vector<Task> v4; std::merge(v1.cbegin(), v1.cend(), v2.cbegin(), v2.cend(), std::back_inserter(v4)); // v4 = {{10, "Task 1.1"},{20, "Task 1.2"},{20, "Task 1.3"}, // {20, "Task 1.4"},{20, "Task 2.1"},{30, "Task 1.5"}, // {30, "Task 2.2"},{30, "Task 2.3"},{30, "Task 2.4"}, // {40, "Task 2.5"},{50, "Task 1.6"},{50, "Task 2.6"}}
- 
std::set_intersection()将两个输入范围中找到的所有元素复制到输出范围中,生成一个根据比较函数排序的新范围:std::vector<Task> v5; std::set_intersection(v1.cbegin(), v1.cend(), v2.cbegin(), v2.cend(), std::back_inserter(v5)); // v5 = {{20, "Task 1.2"},{30, "Task 1.5"},{50, "Task 1.6"}}
- 
std::set_difference()将第一个输入范围中未在第二个输入范围中找到的所有元素复制到输出范围中。对于在两个范围中都找到的等效元素,以下规则适用:如果一个元素在第一个范围中找到 M 次,在第二个范围中找到 N 次,并且如果 M > N,则它被复制 M – N 次;否则,它不被复制:std::vector<Task> v6; std::set_difference(v1.cbegin(), v1.cend(), v2.cbegin(), v2.cend(), std::back_inserter(v6)); // v6 = {{10, "Task 1.1"},{20, "Task 1.3"},{20, "Task 1.4"}}
- 
std::set_symmetric_difference()将两个输入范围中找到的所有元素复制到输出范围,但不在两个范围中都存在。如果一个元素在第一个范围中找到 M 次,在第二个范围中找到 N 次,那么如果 M > N,则从第一个范围复制最后 M – N 个这些元素到输出范围;否则,将从第二个范围复制最后 N – M 个这些元素到输出范围:std::vector<Task> v7; std::set_symmetric_difference(v1.cbegin(), v1.cend(), v2.cbegin(), v2.cend(), std::back_inserter(v7)); // v7 = {{10, "Task 1.1"},{20, "Task 1.3"},{20, "Task 1.4"} // {30, "Task 2.3"},{30, "Task 2.4"},{40, "Task 2.5"}}
另一方面,std::includes() 不会产生输出范围;它只检查第二个范围是否包含在第一个范围中。它返回一个布尔值,如果第二个范围是空的或其所有元素都包含在第一个范围中,则为 true,否则为 false。它还有两个重载,其中一个指定了一个比较二元函数对象。
参见
- 
使用向量作为默认容器,了解如何使用 std::vector标准容器
- 
排序范围 以了解排序范围的标准化算法 
- 
使用迭代器和迭代器适配器在容器中插入新元素,了解如何使用迭代器和迭代器适配器向范围添加元素 
- 
在范围内查找元素,了解搜索值序列的标准化算法 
使用迭代器将新元素插入到容器中
当你与容器一起工作时,通常很有用,可以在开始、末尾或中间某个位置插入新元素。有一些算法,例如我们在上一个食谱中看到的,在范围上使用集合操作,需要迭代器来插入,但如果你简单地传递一个迭代器,例如 begin() 返回的迭代器,它将不会插入而是覆盖容器的元素。此外,使用 end() 返回的迭代器无法在末尾插入。为了执行此类操作,标准库提供了一套迭代器和迭代器适配器,这些适配器可以支持这些场景。
准备工作
本食谱中讨论的迭代器和适配器可在 <iterator> 头文件中的 std 命名空间中找到。如果你包含了如 <algorithm> 这样的头文件,你就不必显式地包含 <iterator>。
如何做...
使用以下迭代器适配器将新元素插入到容器中:
- 
std::back_inserter()用于向具有push_back()方法的容器中插入元素到末尾:std::vector<int> v{ 1,2,3,4,5 }; std::fill_n(std::back_inserter(v), 3, 0); // v={1,2,3,4,5,0,0,0}
- 
std::front_inserter()用于向具有push_front()方法的容器中插入元素到开始位置:std::list<int> l{ 1,2,3,4,5 }; std::fill_n(std::front_inserter(l), 3, 0); // l={0,0,0,1,2,3,4,5}
- 
std::inserter()用于向具有insert()方法的容器中的任何位置插入元素:std::vector<int> v{ 1,2,3,4,5 }; std::fill_n(std::inserter(v, v.begin()), 3, 0); // v={0,0,0,1,2,3,4,5} std::list<int> l{ 1,2,3,4,5 }; auto it = l.begin(); std::advance(it, 3); std::fill_n(std::inserter(l, it), 3, 0); // l={1,2,3,0,0,0,4,5}
它是如何工作的...
std::back_inserter()、std::front_inserter()和std::inserter()都是辅助函数,用于创建std::back_insert_iterator、std::front_insert_iterator和std::insert_iterator类型的迭代器适配器。这些迭代器都是输出迭代器,它们将元素追加到、预置于或插入到它们所构建的容器中。增加和取消引用这些迭代器不会做任何事情。然而,在赋值时,这些迭代器会调用容器中的以下方法:
- 
std::back_inserter_iterator调用push_back()
- 
std::front_inserter_iterator调用push_front()
- 
std::insert_iterator调用insert()
以下是对std::back_insert_iterator的过度简化的实现:
template<class C>
class back_insert_iterator {
public:
  typedef back_insert_iterator<C> T;
  typedef typename C::value_type V;
  explicit back_insert_iterator( C& c ) :container( &c ) { }
  T& operator=( const V& val ) {
    container->push_back( val );
    return *this;
  }
  T& operator*() { return *this; }
  T& operator++() { return *this; }
  T& operator++( int ) { return *this; }
protected:
  C* container;
}; 
由于赋值运算符的工作方式,这些迭代器只能与某些标准容器一起使用:
- 
std::back_insert_iterator可以与std::vector、std::list、std::deque和std::basic_string一起使用。
- 
std::front_insert_iterator可以与std::list、std::forward_list和std:deque一起使用。
- 
std::insert_iterator可以与所有标准容器一起使用。
以下示例展示了如何在std::vector的开头插入三个值为0的元素:
std::vector<int> v{ 1,2,3,4,5 };
std::fill_n(std::inserter(v, v.begin()), 3, 0);
// v={0,0,0,1,2,3,4,5} 
std::inserter()适配器接受两个参数:容器和应该插入元素的迭代器。在容器上调用insert()时,std::insert_iterator会增加迭代器,因此再次赋值时,它可以插入新元素到下一个位置。请看以下代码片段:
T& operator=(const V& v)
{
  iter = container->insert(iter, v);
  ++iter;
  return (*this);
} 
std::inserter_iterator adapter. You can see that it first calls the insert() member function of the container and then increments the returned iterator. Because all the standard containers have a method called insert() with this signature, this adapter can be used with all these containers.
更多内容...
这些迭代器适配器旨在与算法或函数一起使用,这些算法或函数将多个元素插入到范围中。当然,它们也可以用来插入单个元素,但这并不是一个好的做法,因为在这种情况下简单地调用push_back()、push_front()或insert()要简单直观得多。考虑以下代码片段:
std::vector<int> v{ 1,2,3,4,5 };
*std::back_inserter(v) = 6; // v = {1,2,3,4,5,6}
std::back_insert_iterator<std::vector<int>> it(v);
*it = 7;                    // v = {1,2,3,4,5,6,7} 
这里显示的示例,其中使用适配器迭代器插入单个元素,应该避免。它们没有任何好处;它们只会使代码变得混乱。
参见
- 在范围内使用集合操作,了解用于执行排序范围并集、交集或差集的标准算法
编写你自己的随机访问迭代器
在第一章中,我们看到了如何通过实现迭代器以及提供begin()和end()函数来返回自定义范围的第一个和最后一个元素之后的迭代器,从而启用自定义类型的基于范围的 for 循环。你可能已经注意到,在那个菜谱中提供的最小迭代器实现并不满足标准迭代器的需求。这是因为它不能被复制构造或赋值,也不能被增加。在这个菜谱中,我们将在此基础上构建示例,并展示如何创建一个满足所有要求的随机访问迭代器。
准备工作
对于这个菜谱,你应该了解标准定义的迭代器类型以及它们之间的区别。关于它们要求的良好概述可在www.cplusplus.com/reference/iterator/找到。
为了说明如何编写随机访问迭代器,我们将考虑在 第一章 的 学习现代核心语言特性 菜谱中使用的 dummy_array 类的一个变体,即 启用自定义类型的基于范围的 for 循环。这是一个非常简单的数组概念,除了作为演示迭代器的代码库外,没有实际价值:
template <typename Type, size_t const SIZE>
class dummy_array
{
  Type data[SIZE] = {};
public:
  Type& operator[](size_t const index)
  {
    if (index < SIZE) return data[index];
    throw std::out_of_range("index out of range");
  }
  Type const & operator[](size_t const index) const
  {
    if (index < SIZE) return data[index];
    throw std::out_of_range("index out of range");
  }
  size_t size() const { return SIZE; }
}; 
下一个部分中显示的所有代码,包括迭代器类、typedefs 和 begin() 以及 end() 函数,都将成为本类的一部分。
此外,在这个菜谱中,我们将查看一个使用以下名为 Tag 的类的示例:
struct Tag
{
   int id;
   std::string name;
   Tag(int const id = 0, std::string const& name = ""s) :
      id(id), name(name)
   {}
}; 
如何做到这一点...
为了为上一节中所示的 dummy_array 类提供可变和常量随机访问迭代器,请向类中添加以下成员:
- 
一个迭代器类模板,它以元素类型和数组大小为参数。该类必须具有以下公共 typedefs,以定义标准同义词: template <typename T, size_t const Size> class dummy_array_iterator { public: using self_type = dummy_array_iterator; using value_type = T; using reference = T&; using pointer = T* ; using iterator_category = std::random_access_iterator_tag; using difference_type = ptrdiff_t; };
- 
迭代器类的私有成员——指向数组数据的指针和数组中的当前索引: private: pointer ptr = nullptr; size_t index = 0;
- 
迭代器类的一个私有方法,用于检查两个迭代器实例是否指向相同的数组数据: private: bool compatible(self_type const & other) const { return ptr == other.ptr; }
- 
迭代器类的一个显式构造函数: public: explicit dummy_array_iterator(pointer ptr, size_t const index) : ptr(ptr), index(index) { }
- 
满足所有迭代器的通用要求——可复制构造函数、可复制赋值、可析构、前缀和后缀可增量。在此实现中,后增量运算符是用前增量运算符实现的,以避免代码重复: dummy_array_iterator(dummy_array_iterator const & o) = default; dummy_array_iterator& operator=(dummy_array_iterator const & o) = default; ~dummy_array_iterator() = default; self_type & operator++ () { if (index >= Size) throw std::out_of_range("Iterator cannot be incremented past the end of range."); ++index; return *this; } self_type operator++ (int) { self_type tmp = *this; ++*this; return tmp; }
- 
满足输入迭代器要求的迭代器类成员——测试相等/不等,可解引用为 rvalues:bool operator== (self_type const & other) const { assert(compatible(other)); return index == other.index; } bool operator!= (self_type const & other) const { return !(*this == other); } reference operator* () const { if (ptr == nullptr) throw std::bad_function_call(); return *(ptr + index); } reference operator-> () const { if (ptr == nullptr) throw std::bad_function_call(); return *(ptr + index); }
- 
满足正向迭代器要求的迭代器类成员——默认构造函数: dummy_array_iterator() = default;
- 
满足双向迭代器要求的迭代器类成员——可减量: self_type & operator--() { if (index <= 0) throw std::out_of_range("Iterator cannot be decremented past the end of range."); --index; return *this; } self_type operator--(int) { self_type tmp = *this; --*this; return tmp; }
- 
满足随机访问迭代器要求的迭代器类成员——算术加法和减法,与其他迭代器的不等比较,复合赋值,以及偏移量可解引用: self_type operator+(difference_type offset) const { self_type tmp = *this; return tmp += offset; } self_type operator-(difference_type offset) const { self_type tmp = *this; return tmp -= offset; } difference_type operator-(self_type const & other) const { assert(compatible(other)); return (index - other.index); } bool operator<(self_type const & other) const { assert(compatible(other)); return index < other.index; } bool operator>(self_type const & other) const { return other < *this; } bool operator<=(self_type const & other) const { return !(other < *this); } bool operator>=(self_type const & other) const { return !(*this < other); } self_type & operator+=(difference_type const offset) { if (index + offset < 0 || index + offset > Size) throw std::out_of_range("Iterator cannot be incremented past the end of range."); index += offset; return *this; } self_type & operator-=(difference_type const offset) { return *this += -offset; } value_type & operator[](difference_type const offset) { return (*(*this + offset)); } value_type const & operator[](difference_type const offset) const { return (*(*this + offset)); }
- 
向 dummy_array类添加 typedefs 以获得可变和常量迭代器同义词:public: using iterator = dummy_array_iterator<Type, SIZE>; using constant_iterator = dummy_array_iterator<Type const, SIZE>;
- 
将公共的 begin()和end()函数添加到dummy_array类中,以返回数组中的第一个和最后一个元素之后的迭代器:iterator begin() { return iterator(data, 0); } iterator end() { return iterator(data, SIZE); } constant_iterator begin() const { return constant_iterator(data, 0); } constant_iterator end() const { return constant_iterator(data, SIZE); }
它是如何工作的...
标准库定义了五种迭代器类别:
- 
输入迭代器:这是最简单的类别,仅保证单次遍历顺序算法的有效性。在增加后,之前的副本可能变得无效。 
- 
输出迭代器:这些基本上是输入迭代器,可以用来写入指向的元素。 
- 
正向迭代器:这些可以读取(和写入)指向的元素中的数据。它们满足输入迭代器的需求,并且,此外,必须是默认可构造的,并且必须支持多遍场景而不使之前的副本无效。 
- 
双向迭代器:这些是正向迭代器,除此之外,它们还支持递减操作,因此可以双向移动。 
- 
随机访问迭代器:这些支持在常数时间内访问容器中的任何元素。它们实现了双向迭代器的所有要求,并且,此外,支持算术运算 +和-,复合赋值+=和-=,与其他迭代器的比较操作<、<=、>、>=以及偏移量解引用运算符。
同时实现输出迭代器要求的正向、双向和随机访问迭代器被称为可变迭代器。
在上一节中,我们看到了如何实现随机访问迭代器,通过逐步讲解每个迭代器类别的要求(因为每个迭代器类别都包括前一个类别的要求并添加新的要求)。迭代器类模板对常量和可变迭代器都是通用的,我们为它定义了两个同义词,称为iterator和constant_iterator。
在实现内部迭代器类模板之后,我们还定义了begin()和end()成员函数,分别返回数组的第一个元素和最后一个元素之后的迭代器。这些方法有重载,可以根据dummy_array类实例是否可变返回可变或常量迭代器。
使用dummy_array类及其迭代器的这种实现,我们可以编写以下代码:
// defining and initializing an array of integers
dummy_array<int, 3> a;
a[0] = 10;
a[1] = 20;
a[2] = 30;
// modifying the elements of the array
std::transform(a.begin(), a.end(), a.begin(),
               [](int const e) {return e * 2; });
// iterating through and printing the values of the array
for (auto&& e : a) std::cout << e << '\n'; 
20
40
60 
auto lp = [](dummy_array<int, 3> const & ca)
{
  for (auto const & e : ca)
    std::cout << e << '\n';
};
lp(a); 
20
40
60 
// defining and initializing an array of smart pointers
dummy_array<std::unique_ptr<Tag>, 3> ta;
ta[0] = std::make_unique<Tag>(1, "Tag 1");
ta[1] = std::make_unique<Tag>(2, "Tag 2");
ta[2] = std::make_unique<Tag>(3, "Tag 3");
// iterating through and printing the pointed values
for (auto it = ta.begin(); it != ta.end(); ++it)
  std::cout << it->id << " " << it->name << '\n'; 
1 Tag 1
2 Tag 2
3 Tag 3 
更多示例,请查看本书附带的源代码。
更多内容...
除了begin()和end()之外,容器可能还有其他方法,例如cbegin()/cend()(用于常量迭代器)、rbegin()/rend()(用于可变反向迭代器)和crbegin()/ crend()(用于常量反向迭代器)。实现这一点留作你的练习。
另一方面,在现代 C++中,返回第一个和最后一个迭代器的这些函数不必是成员函数,但可以作为非成员函数提供。事实上,这正是下一道菜谱的主题,使用非成员函数访问容器。
参见
- 
第一章,为自定义类型启用基于范围的 for 循环,学习如何对集合中的每个元素执行一个或多个语句 
- 
第一章,创建类型别名和别名模板,了解类型别名的知识 
使用非成员函数访问容器
标准容器提供了begin()和end()成员函数,用于检索容器的第一个和最后一个元素之后的迭代器。实际上,有四组这样的函数。除了begin()/end()之外,容器还提供了cbegin()/cend()来返回常量迭代器,rbegin()/rend()来返回可变反向迭代器,以及crbegin()/crend()来返回常量反向迭代器。在 C++11/C++14 中,所有这些都有非成员等效函数,它们与标准容器、数组以及任何专门化它们的自定义类型一起工作。在 C++17 中,还添加了更多的非成员函数:std::data(),它返回指向包含容器元素的内存块的指针;std::size(),它返回容器或数组的大小;以及std::empty(),它返回给定的容器是否为空。这些非成员函数旨在用于通用代码,但可以在代码的任何地方使用。此外,在 C++20 中,引入了std::ssize()非成员函数,以返回容器或数组的大小作为有符号整数。
准备工作
在这个菜谱中,我们将使用我们在之前的菜谱中实现的dummy_array类及其迭代器,即编写自己的随机访问迭代器,作为一个例子。你应该在继续阅读这个菜谱之前先阅读那个菜谱。
非成员begin()/end()函数以及其他变体,以及非成员data()、size()和empty()函数,都包含在<iterator>头文件中的std命名空间中,该头文件在以下任何头文件中隐式包含:<array>、<deque>、<forward_list>、<list>、<map>、<regex>、<set>、<string>、<unordered_map>、<unordered_set>和<vector>。
在这个菜谱中,我们将参考std::begin()/std::end()函数,但所讨论的一切也适用于其他函数:std::cbegin()/std::cend()、std::rbegin()/std::rend()和std::crbegin()/std::crend()。
如何操作...
使用非成员std::begin()/std::end()函数以及其他变体,以及std::data()、std::size()和std::empty(),与以下内容一起使用:
- 
标准容器: std::vector<int> v1{ 1, 2, 3, 4, 5 }; auto sv1 = std::size(v1); // sv1 = 5 auto ev1 = std::empty(v1); // ev1 = false auto dv1 = std::data(v1); // dv1 = v1.data() for (auto i = std::begin(v1); i != std::end(v1); ++i) std::cout << *i << '\n'; std::vector<int> v2; std::copy(std::cbegin(v1), std::cend(v1), std::back_inserter(v2));
- 
数组: int a[5] = { 1, 2, 3, 4, 5 }; auto pos = std::find_if(std::crbegin(a), std::crend(a), [](int const n) {return n % 2 == 0; }); auto sa = std::size(a); // sa = 5 auto ea = std::empty(a); // ea = false auto da = std::data(a); // da = a
- 
提供相应成员函数的自定义类型;即 begin()/end()、data()、empty()或size():dummy_array<std::string, 5> sa; dummy_array<int, 5> sb; sa[0] = "1"s; sa[1] = "2"s; sa[2] = "3"s; sa[3] = "4"s; sa[4] = "5"s; std::transform( std::begin(sa), std::end(sa), std::begin(sb), [](std::string const & s) {return std::stoi(s); }); // sb = [1, 2, 3, 4, 5] auto sa_size = std::size(sa); // sa_size = 5
- 
未知容器类型的通用代码: template <typename F, typename C> void process(F&& f, C const & c) { std::for_each(std::begin(c), std::end(c), std::forward<F>(f)); } auto l = [](auto const e) {std::cout << e << '\n'; }; process(l, v1); // std::vector<int> process(l, a); // int[5] process(l, sa); // dummy_array<std::string, 5>
它是如何工作的...
这些非成员函数在标准的不同版本中被引入,但它们都在 C++17 中被修改为返回constexpr auto:
- 
C++11 中的 std::begin()和std::end()
- 
C++14 中的 std::cbegin()/std::cend()、std::rbegin()/std::rend()和std::crbegin()/std::crend()
- 
C++17 中的 std::data()、std::size()和std::empty()
- 
C++20 中的 std::ssize()
begin()/end()函数族为容器类和数组提供了重载,它们所做的一切如下:
- 
返回调用容器对应成员函数的结果 
- 
对于数组,返回数组的第一个或最后一个元素之后的指针。 
std::begin()/std::end()的实际典型实现如下:
template<class C>
constexpr auto inline begin(C& c) -> decltype(c.begin())
{
  return c.begin();
}
template<class C>
constexpr auto inline end(C& c) -> decltype(c.end())
{
  return c.end();
}
template<class T, std::size_t N>
constexpr T* inline begin(T (&array)[N])
{
  return array;
}
template<class T, std::size_t N>
constexpr T* inline begin(T (&array)[N])
{
  return array+N;
} 
可以为没有对应begin()/end()成员但仍然可以迭代的容器提供自定义特化。标准库实际上为std::initializer_list和std::valarray提供了这样的特化。
特化必须在原始类或函数模板定义的同一命名空间中定义。因此,如果您想特化任何std::begin()/std::end()对,您必须在std命名空间中这样做。
C++17 中引入的其他用于容器访问的非成员函数也有几个重载:
- 
std::data()有几个重载;对于一个类C,它返回c.data(),对于数组,它返回array,对于std::initializer_list<T>,它返回il.begin():template <class C> constexpr auto data(C& c) -> decltype(c.data()) { return c.data(); } template <class C> constexpr auto data(const C& c) -> decltype(c.data()) { return c.data(); } template <class T, std::size_t N> constexpr T* data(T (&array)[N]) noexcept { return array; } template <class E> constexpr const E* data(std::initializer_list<E> il) noexcept { return il.begin(); }
- 
std::size()有两个重载;对于一个类C,它返回c.size(),对于数组,它返回大小N:template <class C> constexpr auto size(const C& c) -> decltype(c.size()) { return c.size(); } template <class T, std::size_t N> constexpr std::size_t size(const T (&array)[N]) noexcept { return N; }
- 
std::empty()有几个重载;对于一个类C,它返回c.empty(),对于数组,它返回false,对于std::initializer_list<T>,它返回il.size() == 0:template <class C> constexpr auto empty(const C& c) -> decltype(c.empty()) { return c.empty(); } template <class T, std::size_t N> constexpr bool empty(const T (&array)[N]) noexcept { return false; } template <class E> constexpr bool empty(std::initializer_list<E> il) noexcept { return il.size() == 0; }
在 C++20 中,std::ssize()非成员函数被添加为std::size()的配套函数,以返回给定容器或数组中的元素数量作为一个有符号整数。std::size()返回一个无符号整数,但在某些情况下可能需要一个有符号值。例如,C++20 类std::span表示对连续对象序列的视图,它有一个返回有符号整数的size()成员函数,而标准库容器中的size()成员函数返回一个无符号整数。
std::span的size()函数返回有符号整数的原因是,值-1 被用来表示在编译时未知大小的类型的哨兵。执行混合有符号和无符号算术可能导致难以找到错误的代码。std::ssize()有两个重载:对于一个类C,它返回将c.size()静态转换为有符号整数(通常是std::ptrdiff_t),对于数组,它返回N,即元素的数量。请查看以下代码片段:
template <class C>
constexpr auto ssize(const C& c)
    -> std::common_type_t<std::ptrdiff_t,
                          std::make_signed_t<decltype(c.size())>>
{
    using R = std::common_type_t<std::ptrdiff_t,
                      std::make_signed_t<decltype(c.size())>>;
    return static_cast<R>(c.size());
}
template <class T, std::ptrdiff_t N>
constexpr std::ptrdiff_t ssize(const T (&array)[N]) noexcept
{
    return N;
} 
前面的代码片段展示了为容器和数组中的std::ssize()函数可能的实现。
还有更多...
这些非成员函数主要适用于模板代码,其中容器是未知的,可以是标准容器、数组或自定义类型。使用这些函数的非成员版本使我们能够编写更简单、更少的代码,这些代码可以与所有这些类型的容器一起工作。
然而,这些函数的使用并不应该仅限于通用代码。尽管这更多的是个人偏好的问题,但养成一致性的好习惯,在代码的任何地方都使用它们,可能是一个好习惯。所有这些方法都有轻量级的实现,编译器很可能会内联它们,这意味着使用相应的成员函数将不会有任何开销。
参见
- 编写自己的随机访问迭代器,以了解编写自定义随机访问迭代器需要做什么
选择正确的标准容器
标准库包含各种容器,以满足多种和不同的需求。有顺序容器(其中元素按一定位置排列)、容器适配器(为顺序容器提供不同的接口)、关联容器(其中顺序由与元素关联的键给出)、无序关联容器(其中元素不遵循某种顺序)。为特定任务选择正确的容器并不总是直截了当。本食谱将提供指导方针,以帮助您决定为哪种目的使用哪种容器。
如何做到这一点...
要决定应该使用哪个标准容器,请考虑以下指导方针:
- 
使用 std::vector作为默认容器,当没有其他特定要求时。
- 
当序列的长度在编译时已知且固定时,请使用 std::array。
- 
如果你经常需要在序列的开始和结束处添加或删除元素,请使用 std::deque。
- 
如果你经常需要在序列的中间添加或删除元素(除了开始和结束之外的其他地方),并且需要序列的双向遍历,请使用 std::list。
- 
如果你经常需要在序列的任何位置添加或删除元素,但你只需要按一个方向遍历序列,请使用 std::forward_list。
- 
如果你需要一个具有后进先出(LIFO)语义的序列,请使用 std::stack。
- 
如果你需要一个具有先进先出(FIFO)语义的序列,请使用 std::queue。
- 
如果你需要一个具有先进先出(FIFO)语义的序列,但其中元素按严格的弱序排列(最大的元素——最高优先级的元素排在第一位),请使用 std::priority_queue。
- 
如果你需要存储键值对,元素的顺序不重要但键必须是唯一的,请使用 std::unordered_map。
- 
如果你需要存储具有唯一键的键值对,但元素的顺序由它们的键给出,请使用 std::map。
- 
如果你需要存储键值对,键可以重复,且元素的顺序不重要,请使用 std::unordered_multimap。
- 
如果你需要存储键值对,键可以重复,且元素按它们的键的顺序存储,请使用 std::multimap。
- 
如果你需要存储唯一值但它们的顺序不重要,请使用 std::unordered_set。
- 
如果你需要存储唯一值但元素的顺序很重要(最低的元素首先存储),请使用 std::set。
- 
如果你想要存储非唯一值,尽管它们的顺序不重要,但你想拥有集合的搜索功能,请使用 std::unordered_multiset。
- 
如果你想要存储非唯一值,但元素的顺序很重要,具有最低键的元素首先存储,并且你想要集合的搜索功能,请使用 std::multiset。
它是如何工作的...
容器是存储其他对象的对象,内部管理存储对象的内存。它们通过标准接口提供对元素和其他功能的访问。标准库中有四种容器类别:
- 
序列容器按照一定顺序存储元素,但这种顺序不依赖于元素的值。序列容器通常实现为数组(元素在内存中连续存储)或链表(元素存储在指向其他元素的节点中)。标准的序列容器包括 std::array、std::vector、std::list、std::forward_list和std::deque。
- 
容器适配器定义了对序列容器的适配接口。这些是 std::stack、std::queue和std::priority_queue。
- 
关联容器根据与每个元素关联的键存储元素。尽管它们支持插入和删除,但这不能在特定位置发生,而是取决于键。它们为搜索元素提供良好的性能,所有容器都可能的二分搜索具有对数复杂度。标准的关联容器包括 std::map、std::set、std::multimap和std::multiset。
- 
无序关联容器存储无序的元素。这些容器使用哈希表实现,这使得搜索元素成为常数时间操作。与关联容器不同,无序的容器不支持二分搜索。必须为存储在无序关联容器中的元素类型实现哈希函数。标准的容器包括 std::unordered_map、std::unordered_multimap、std::unordered_set和std::unordered_multiset。
std::vector 容器可能是最常用的一个,正如这本书中的代码片段也显示的那样。向量将其元素顺序存储在连续的内存中。向量可以增长和缩小。尽管元素可以插入到序列中的任何位置,但最有效的操作是在序列的末尾插入和删除(使用 push_back() 和 pop_back()):
std::vector<int> v{ 1, 1, 2, 3, 5, 8 };
v.push_back(13); // insert at the end 
这里是向量的概念表示,在插入元素到其末尾之前和之后:

图 5.1:在向量的末尾插入一个元素
在序列的除两端以外的任何位置(使用insert()和erase())插入或删除元素性能较低,因为必须在内存中移动插入/删除位置之后的所有元素。如果插入操作会确定向量的容量(在分配的内存中可以存储的元素数量)超过,则必须进行重新分配。在这种情况下,分配一个新的更大的连续内存序列,并将所有存储的元素以及新添加的元素复制到这个新缓冲区中,然后删除旧的内存块:
std::vector<int> v{ 1, 1, 2, 3, 5, 8 };
v.insert(v.begin() + 3, 13); // insert in the middle 
下一个图显示了在插入新元素之前和之后向向量中间插入元素的概念表示:

图 5.2:在向量中间插入元素
如果序列的开头也频繁发生插入或删除操作,更好的选择是使用std::deque容器。这允许在两端快速插入和删除(使用push_front()/pop_front()和push_back()/pop_back())。在两端进行的删除操作不会使指向其余元素的指针或引用失效。然而,与std::vector不同,std::deque并不在内存中连续存储其元素,而是在一系列固定长度的数组中,这需要额外的管理。尽管索引元素涉及两级指针解引用,与std::vector仅涉及一级相比,扩展deque比扩展vector更快,因为它不需要重新分配所有内存并复制现有元素:
std::deque<int> d{ 1,2,3,5,8 };
d.push_front(1); // insert at the beginning
d.push_back(13); // insert at the end 
std::vector和std::deque在序列中间的插入操作(中间指的是除了两端以外的任何位置)性能都不好。一个提供中间插入常数时间操作的容器是std::list。它实现为一个双链表,这意味着元素不是存储在连续的内存中。std::list的使用场景并不多。一个典型的情况是在需要频繁在中间进行插入和删除操作,并且这些操作比列表迭代还要多的情况下。你也可以在需要经常拆分和连接一个或多个序列时使用std::list。
如果你还需要在插入或删除操作后保持列表元素的迭代器和引用的有效性,那么std::list是一个不错的选择:
std::list<int> l{ 1, 1, 2, 3, 5, 8 };
auto it = std::find(l.begin(), l.end(), 3);
l.insert(it, 13); 
下一个图显示了(双链)列表的概念表示以及在其中插入新元素的过程:

图 5.3:在列表中间插入元素
如果你想要存储由键标识的值,关联容器是合适的解决方案。使用std::map和std::unordered_map都可以存储键值对。这两个容器有显著的不同:
- 
std::map按照键的顺序存储键值对(使用比较函数,默认为std::less),而std::unordered_map,正如其名所示,不保留任何顺序。
- 
std::map使用自平衡 二叉搜索树(BST)如红黑树实现,而std::unordered_map使用哈希表实现。由于哈希表需要更多的维护数据,因此std::unordered_map比存储相同数量元素时的std::map使用更多的内存。
- 
std::map为搜索操作提供对数复杂度,O(log(n)),并且对于插入和删除操作,相同加上平衡操作,而std::unordered_map在平均情况下为插入提供常数时间,O(1),尽管在最坏情况下,所有搜索、插入和删除操作的性能降低到线性复杂度,O(n)。
基于这些差异,我们可以确定每个这些容器的典型用例:
- 
当推荐使用 std::map时:- 
您需要按顺序存储容器中的元素,以便可以按其定义的顺序访问 
- 
您需要元素的后续或前驱 
- 
您需要使用 <、<=、>或>=操作符进行字典序比较映射
- 
您想使用 binary_search()、lower_bound()或upper_bound()等算法
 
- 
- 
当推荐使用 std::unordered_map时:- 
您不需要按照特定顺序存储唯一对象 
- 
您执行大量的插入/删除和搜索操作 
- 
您需要访问单个元素,并且不需要迭代整个序列 
 
- 
为了使用 std::unordered_map,必须为存储元素的类型定义一个哈希函数(可以是 std::hash<T> 的特化或不同的实现)。这是必要的,因为在 std::unordered_map 中,元素存储在桶中。元素存储的桶取决于键的哈希值。一个好的哈希函数可以防止冲突,使所有操作都能以常数时间 – O(1) 完成。另一方面,如果哈希函数设计不当,可能会导致冲突,降低搜索和插入/删除操作的复杂度到线性 – O(n)。
当您想要存储唯一对象但没有与每个对象关联的键时,正确的标准容器是 std::set 和 std::unordered_set。集合与映射非常相似,除了对象本身也是键。这两个容器,std::set 和 std::unordered_set,与我们在 std::map 和 std::unordered_map 中看到的不同之处相同:
- 
在 std::set中对象是有序的,而在std::unordered_set中是无序的。
- 
std::set使用红黑树实现,而std::unordered_set使用哈希表实现。
- 
std::set为搜索操作提供对数复杂度,O(log(n)),并且对于插入和删除操作,相同加上平衡操作,而std::unordered_set在平均情况下为插入提供常数时间,O(1),尽管在最坏情况下,所有搜索、插入和删除操作的性能降低到线性复杂度,O(n)。
考虑这些差异以及与std::map/std::unordered_map容器的相似性,我们可以为std::set识别出与std::map相同的使用场景,为std::unordered_set识别出与std::unordered_map相同的使用场景。此外,为了使用std::unordered_set,必须为存储对象的类型定义一个哈希函数。
当你需要存储与一个键关联的多个值时,你可以使用std::multimap和std::unordered_multimap。这两个容器与std::map和std::unordered_map有相同的考虑因素。我们可以这样说,std::multimap对应于std::map,而std::unordered_multimap对应于std::unordered_map。同样,std::multiset和std::unordered_multiset可以用来在集合中存储重复项。
考虑所有各种标准容器类型及其基于特性的典型用途,我们可以使用以下图表来选择最合适的容器。以下图表是我根据 Mikael Persson 创建的图表并分享在 StackOverflow 上的版本制作的:(stackoverflow.com/a/22671607/648078)。

图 5.4:选择正确标准容器的流程图
尽管这个配方旨在作为选择合适标准容器的指南,但它并不涵盖所有容器和所有可能的考虑因素。当性能是关键时,最佳选择可能并非典型的选择。在这种情况下,你应该尝试使用不同的选择进行不同的实现,对它们进行基准测试,并根据你的测量结果决定解决方案。
参考以下内容
- 
使用向量作为默认容器,了解如何使用 std::vector类
- 
使用 std::vector<bool>处理可变长度的位序列,了解如何使用这个针对布尔类型的std::vector特化来操作位序列
在 Discord 上了解更多信息
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
discord.gg/7xRaTCeEhx

第六章:通用工具
标准库包含许多通用工具和库,这些工具和库超出了上一章中讨论的容器、算法和迭代器。本章重点介绍三个领域:用于处理日期、时间、日历和时区的 chrono 库;类型特性,它提供有关其他类型的元信息;以及标准库中新版本中的实用类型,包括 C++17 中的 std::any、std::optional 和 std::variant,C++20 中的 std::span 和 std::source_location,以及 C++23 中的 std::mdspan 和 std::expected。
本章包含的食谱如下:
- 
使用 chrono::duration表达时间间隔
- 
与日历一起工作 
- 
在时区之间转换时间 
- 
使用标准时钟测量函数执行时间 
- 
为自定义类型生成哈希值 
- 
使用 std::any存储任何值
- 
使用 std::optional存储可选值
- 
连接可能或可能不产生值的计算 
- 
使用 std::variant作为类型安全的联合体
- 
访问 std::variant
- 
使用 std::expected返回值或错误
- 
使用 std::span处理对象的连续序列
- 
使用 std::mdspan处理对象序列的多维视图
- 
注册一个在程序正常退出时调用的函数 
- 
使用类型特性查询类型的属性 
- 
编写自己的类型特性 
- 
使用 std::conditional在类型之间进行选择
- 
使用 source_location提供日志细节
- 
使用 stacktrace库打印调用序列
本章的第一部分重点介绍 chrono 库,它提供了时间和日期工具。
使用 chrono::duration 表达时间间隔
无论编程语言如何,处理时间和日期都是一项常见操作。C++11 提供了一个灵活的日期和时间库作为标准库的一部分,使我们能够定义时间点和时间间隔。这个名为 chrono 的库是一个通用工具库,旨在与不同系统上可能不同的计时器和时钟一起工作,因此是精度中立的。该库在 <chrono> 头文件中的 std::chrono 命名空间中可用,并定义和实现了以下组件:
- 
持续时间,表示时间间隔 
- 
时间点,表示自时钟纪元以来的时间长度 
- 
时钟,定义了一个纪元(即时间的开始)和一个滴答 
在本食谱中,我们将学习如何处理持续时间。
准备工作
本食谱并非 duration 类的完整参考。建议您咨询其他资源以获取相关信息(库参考文档可在 en.cppreference.com/w/cpp/chrono 获取)。
在 chrono 库中,时间间隔由 std::chrono::duration 类表示。
如何做到这一点...
要处理时间间隔,请使用以下方法:
- 
std::chrono::duration的小时、分钟、秒、毫秒、微秒和纳秒类型别名:std::chrono::hours half_day(12); std::chrono::minutes half_hour(30); std::chrono::seconds half_minute(30); std::chrono::milliseconds half_second(500); std::chrono::microseconds half_millisecond(500); std::chrono::nanoseconds half_microsecond(500);
- 
使用 C++14 中可用的标准用户定义字面量运算符,在 std::chrono_literals命名空间中创建小时、分钟、秒、毫秒、微秒和纳秒的持续时间:using namespace std::chrono_literals; auto half_day = 12h; auto half_hour = 30min; auto half_minute = 30s; auto half_second = 500ms; auto half_millisecond = 500us; auto half_microsecond = 500ns;
- 
使用从低精度持续时间到高精度持续时间的直接转换: std::chrono::hours half_day_in_h(12); std::chrono::minutes half_day_in_min(half_day_in_h); std::cout << half_day_in_h.count() << "h" << '\n'; //12h std::cout << half_day_in_min.count() << "min" << '\n';//720min
- 
使用 std::chrono::duration_cast将高精度持续时间转换为低精度持续时间:using namespace std::chrono_literals; auto total_seconds = 12345s; auto hours = std::chrono::duration_cast<std::chrono::hours>(total_seconds); auto minutes = std::chrono::duration_cast<std::chrono::minutes>(total_seconds % 1h); auto seconds = std::chrono::duration_cast<std::chrono::seconds>(total_seconds % 1min); std::cout << hours.count() << ':' << minutes.count() << ':' << seconds.count() << '\n'; // 3:25:45
- 
在 C++17 中,当需要四舍五入时,使用 std::chrono命名空间中可用的floor()、round()和ceil()转换函数(不要与<cmath>头文件中的std::floor()、std::round()和std::ceil()函数混淆):using namespace std::chrono_literals; auto total_seconds = 12345s; auto m1 = std::chrono::floor<std::chrono::minutes>(total_seconds); // 205 min auto m2 = std::chrono::round<std::chrono::minutes>(total_seconds); // 206 min auto m3 = std::chrono::ceil<std::chrono::minutes>(total_seconds); // 206 min auto sa = std::chrono::abs(total_seconds);
- 
使用算术运算、复合赋值和比较运算来修改和比较时间间隔: using namespace std::chrono_literals; auto d1 = 1h + 23min + 45s; // d1 = 5025s auto d2 = 3h + 12min + 50s; // d2 = 11570s if (d1 < d2) { /* do something */ }
它是如何工作的...
std::chrono::duration 类定义了时间单位上的多个刻度(两个时间点之间的增量)。默认单位是秒,对于表示其他单位,如分钟或毫秒,我们需要使用一个比率。对于大于秒的单位,比率大于一,例如 ratio<60> 用于分钟。对于小于秒的单位,比率小于一,例如 ratio<1, 1000> 用于毫秒。刻度的数量可以通过 count() 成员函数检索。
标准库为纳秒、微秒、毫秒、秒、分钟和小时的持续时间定义了几个类型别名,我们在上一节的第一例中使用了这些别名。以下代码显示了这些持续时间在 chrono 命名空间中的定义:
namespace std {
  namespace chrono {
    typedef duration<long long, ratio<1, 1000000000>> nanoseconds;
    typedef duration<long long, ratio<1, 1000000>> microseconds;
    typedef duration<long long, ratio<1, 1000>> milliseconds;
    typedef duration<long long> seconds;
    typedef duration<int, ratio<60> > minutes;
    typedef duration<int, ratio<3600> > hours;
  }
} 
然而,有了这种灵活的定义,我们可以表示像 1.2 六分之一的分钟(这意味着 12 秒)这样的时间间隔,其中 1.2 是持续时间的刻度数,ratio<10>(如 60/6)是时间单位:
std::chrono::duration<double, std::ratio<10>> d(1.2); // 12 sec 
在 C++14 中,std::chrono_literals 命名空间中添加了几个标准用户定义字面量运算符。这使得定义持续时间变得更容易,但您必须将命名空间包含在您想要使用字面量运算符的作用域中。
您应该只将用户定义字面量运算符的作用域包含在您想要使用它们的作用域中,而不是在更大的作用域中,以避免与其他库和命名空间中具有相同名称的运算符冲突。
所有算术运算都适用于 duration 类。可以添加和减去持续时间,将它们乘以或除以一个值,或者应用 modulo 操作。然而,需要注意的是,当两个不同时间单位的持续时间相加或相减时,结果是这两个时间单位最大公约数的持续时间。这意味着如果您将表示秒的持续时间和表示分钟的持续时间相加,结果是表示秒的持续时间。
从具有较不精确时间单位的持续时间到具有更精确时间单位的持续时间的转换是隐式进行的。另一方面,从更精确的时间单位到较不精确的时间单位的转换需要一个显式的转换。这可以通过非成员函数 std::chrono::duration_cast() 来完成。在 如何做... 部分中,您看到了一个确定以秒为单位的给定持续时间的小时、分钟和秒数的示例。
C++17 添加了几个更多的非成员转换函数,它们执行带有舍入的持续时间转换:floor() 用于向下舍入,ceil() 用于向上舍入,round() 用于四舍五入到最接近的。此外,C++17 还添加了一个名为 abs() 的非成员函数,用于保留持续时间的绝对值。
更多内容...
在 C++20 之前,chrono 是一个通用库,它缺乏许多有用的功能,例如使用年、月和日部分表达日期,处理时区和日历,以及其他功能。C++20 标准添加了对日历和时区的支持,我们将在下面的食谱中看到。如果您使用不支持这些 C++20 新增功能的编译器,那么第三方库可以实现这些功能,其中一个推荐的是 Howard Hinnant 的 date 库,可在 MIT 许可证下在 github.com/HowardHinnant/date 找到。这个库是 C++20 chrono 新增功能的基础。
参见
- 
使用标准时钟测量函数执行时间,了解您如何确定函数的执行时间 
- 
使用日历,发现 C++20 对 chrono库在处理日期和日历方面的新增功能
- 
在时区之间转换时间,了解如何在 C++20 中转换不同时区的时间点 
使用日历
C++11 中可用的 chrono 库提供了对时钟、时间点和持续时间的支持,但并没有使表达时间和日期变得容易,尤其是在日历和时区方面。新的 C++20 标准通过扩展现有的 chrono 库来纠正这一点,包括:
- 
更多时钟,例如 UTC 时钟、国际原子时时钟、GPS 时钟、文件时间时钟以及表示本地时间的伪时钟。 
- 
白天时间,表示从午夜开始经过的小时、分钟和秒。 
- 
日历,它使我们能够使用年、月和日部分来表达日期。 
- 
时区,它使我们能够根据时区表达时间点,并使在不同时区之间转换时间成为可能。 
- 
对从流中解析 chrono 对象的 I/O 支持。 
在本食谱中,我们将学习如何使用日历对象。
准备工作
所有新的 chrono 功能都可在 <chrono> 头文件中的相同 std::chrono 和 std::chrono_literals 命名空间中找到。
如何做…
您可以使用 C++20 的 chrono 日历功能来:
- 
使用 year_month_day类型的实例表示格里高利日历日期。使用标准用户定义的文法、常量和重载的运算符/来构造这样的对象:// format: year / month /day year_month_day d1 = 2024y / 1 / 15; year_month_day d2 = 2024y / January / 15; // format: day / month / year year_month_day d3 = 15d / 1 / 2024; year_month_day d4 = 15d / January / 2024 // format: month / day / year year_month_day d5 = 1 / 15d / 2024; year_month_day d6 = January / 15 / 2024;
- 
使用 year_month_weekday类型的实例表示特定年份和月份的第 n 个工作日:// format: year / month / weekday year_month_weekday d1 = 2024y / January / Monday[1]; // format: weekday / month / year year_month_weekday d2 = Monday[1] / January / 2024; // format: month / weekday / year year_month_weekday d3 = January / Monday[1] / 2024;
- 
确定当前日期,并从中计算其他日期,例如明天和昨天的日期: auto today = floor<days>(std::chrono::system_clock::now()); auto tomorrow = today + days{ 1 }; auto yesterday = today - days{ 1 };
- 
确定特定月份和年份的第一天和最后一天: year_month_day today = floor<days>(std::chrono::system_clock::now()); year_month_day first_day_this_month = today.year() / today.month() / 1; year_month_day last_day_this_month = today.year() / today.month() / last; // std::chrono::last year_month_day last_day_feb_2024 = 2024y / February / last; year_month_day_last ymdl {today.year(), month_day_last{ month{ 2 } }}; year_month_day last_day_feb { ymdl };
- 
计算两个日期之间的天数: inline int number_of_days(std::chrono::sys_days const& first, std::chrono::sys_days const& last) { return (last - first).count(); } auto days = number_of_days(2024y / April / 1, 2024y / December / 25);
- 
检查一个日期是否有效: auto day = 2024y / January / 33; auto is_valid = day.ok();
- 
使用 hh_mm_ss<Duration>类模板以小时、分钟和秒表示一天中的时间,其中Duration决定了分割时间间隔的精度。在下一个示例中,std::chrono::seconds定义了 1 秒的分割精度:chrono::hh_mm_ss<chrono::seconds> td(13h+12min+11s); std::cout << td << '\n'; // 13:12:11
- 
创建包含日期和时间部分的时点: auto tp = chrono::sys_days{ 2024y / April / 1 } + 12h + 30min + 45s; std::cout << tp << '\n'; // 2024-04-01 12:30:45
- 
确定当前一天的时间并使用各种精度表示它: auto tp = std::chrono::system_clock::now(); auto dp = floor<days>(tp); chrono::hh_mm_ss<chrono::milliseconds> time1 { chrono::duration_cast<chrono::milliseconds>(tp - dp) }; std::cout << time1 << '\n'; // 13:12:11.625 chrono::hh_mm_ss<chrono::minutes> time2 { chrono::duration_cast<chrono::minutes>(tp - dp) }; std::cout << time2 << '\n'; // 13:12
它是如何工作的…
在这里示例中看到的 year_month_day 和 year_month_weekday 类型只是添加到 chrono 库以支持日历的许多新类型中的一部分。以下表格列出了 std::chrono 命名空间中的所有这些类型以及它们所表示的内容:
| 类型 | 表示 | 
|---|---|
| day | 一个月中的某一天 | 
| month | 年份中的月份 | 
| year | 格里高利日历中的年份 | 
| weekday | 格里高利日历中的星期几 | 
| weekday_indexed | 一个月中的第 n 个工作日,其中 n 在范围 [1, 5] 内(1 是月份的第一天,5 是月份的第 5 天——如果存在的话) | 
| weekday_last | 一个月中的最后一个工作日 | 
| month_day | 特定月份的特定一天 | 
| month_day_last | 特定月份的最后一天 | 
| month_weekday | 特定月份的第 n 个工作日 | 
| month_weekday_last | 特定月份的最后一个工作日 | 
| year_month | 特定年份的特定月份 | 
| year_month_day | 特定年份、月份和日期 | 
| year_month_day_last | 特定年份和月份的最后一天 | 
| year_month_weekday | 特定年份和月份的第 n 个工作日 | 
| year_month_weekday_last | 特定年份和月份的最后一个工作日 | 
表 6.1:C++20 用于处理日期的 chrono 类型
表格中列出的所有类型都具有:
- 
默认构造函数,该构造函数将成员字段初始化为未初始化状态 
- 
成员函数用于访问实体的各个部分 
- 
一个名为 ok()的成员函数,用于检查存储的值是否有效
- 
非成员比较运算符,用于比较该类型值的比较 
- 
一个重载的 operator<<运算符,用于将类型的值输出到流中
- 
一个重载的函数模板 from_stream(),它根据提供的格式从流中解析值
- 
为文本格式化库的 std::formatter<T, CharT>类模板进行特化
此外,这些类型的许多操作符被重载,以便我们能够轻松创建格里高利日历日期。当创建日期(包含年、月和日)时,您可以选择三种不同的格式:
- 
年/月/日(在中国、日本、韩国、加拿大等国家使用,但还有其他国家,有时与月/日/年格式一起使用) 
- 
月/日/年(在美国使用) 
- 
月/日/年(在世界上大多数地区使用) 
在这些情况下,日可以是:
- 
实际的月份中的某一天(值从 1 到 31) 
- 
std::chrono::last,表示月份的最后一天
- 
weekday[n],表示月份的第 n 个工作日(其中 n 可以取 1 到 5 的值)
- 
weekday[std::chrono::last],表示月份的最后一天
为了区分表示日期、月份和年份的整数,库提供了两个用户定义的文法:""y 用于构造 std::chrono::year 类型的文法,""d 用于构造 std::chrono::day 类型的文法。
此外,还有一些表示以下内容的常量:
- 
std::chrono::month,命名为January、February一直到December。
- 
std::chrono::weekday,命名为Sunday、Monday、Tuesday、Wednesday、Thursday、Friday或Saturday。
您可以使用所有这些来构造日期,例如 2025y/April/1、25d/December/2025 或 Sunday[last]/May/2025。
year_month_day 类型提供了到 std::chrono::sys_days 的隐式转换。这种类型是一个精度为一天的 std::chrono::time_point。有一个伴随类型称为 std::chrono::sys_seconds,它是一个精度为一秒的 time_point。可以使用 std::chrono::time_point_cast() 或 std::chrono::floor() 来执行 time_point 和 sys_days / sys_seconds 之间的显式转换。
为了表示一天中的某个时刻,我们可以使用 std::chrono::hh_mm_ss 类型。这个类表示自午夜以来经过的时间,分解为小时、分钟、秒和毫秒。这个类型主要用作格式化工具。
此外,还有一些实用函数用于在 12 小时/24 小时格式之间转换。这些函数包括:
- 
is_am()和is_pm()函数用于检查以 24 小时格式表示的时间(作为std::chrono::hours值提供)是上午(中午之前)还是下午(午夜之前):std::cout << is_am(0h) << '\n'; // true std::cout << is_am(1h) << '\n'; // true std::cout << is_am(12h) << '\n'; // false std::cout << is_pm(0h) << '\n'; // false std::cout << is_pm(12h) << '\n'; // true std::cout << is_pm(23h) << '\n'; // true std::cout << is_pm(24h) << '\n'; // false
- 
make12()和make24()函数返回 24 小时格式的 12 小时等效时间,反之亦然。它们都接受输入时间作为std::chrono::hours值,但make24()有一个额外的参数,一个布尔值,表示时间是否为下午:for (auto h : { 0h, 1h, 12h, 23h, 24h }) { std::cout << make12(h).count() << '\n'; // prints 12, 1, 12, 11, 12 } for (auto [h, pm] : { std::pair<hours, bool>{ 0h, false}, std::pair<hours, bool>{ 1h, false}, std::pair<hours, bool>{ 1h, true}, std::pair<hours, bool>{12h, false}, std::pair<hours, bool>{12h, true}, }) { std::cout << make24(h, pm).count() << '\n'; // prints 0, 1, 13, 0, 12 }
如您从这些示例中看到的,这四个函数仅适用于小时值,因为只有时间点的小时部分决定了其格式为 12 小时或 24 小时,或者是否为上午或下午时间。
在本书第二版出版时,chrono 的变化尚未完成。hh_mm_ss类型被称为time_of_day,而make12()/make_24()函数是其成员。这一版反映了这些变化并利用了标准化的 API。
更多内容…
这里描述的日期和时间功能都是基于std::chrono::system_clock。自 C++20 起,此时钟被定义为测量 Unix 时间,即自 1970 年 1 月 1 日 00:00:00 UTC 以来的时间。这意味着隐含的时间区域是 UTC。然而,在大多数情况下,你可能对特定时区的地方时间感兴趣。为了帮助解决这个问题,chrono库增加了对时区的支持,这是我们将在下一个菜谱中学习的。
相关内容
- 
使用 chrono::duration 表示时间间隔,以熟悉 C++11 chrono库的基本原理,并处理持续时间、时间点和时间点
- 
在不同时区之间转换时间,了解如何在 C++20 中转换不同时区之间的时间点 
在不同时区之间转换时间
在上一个菜谱中,我们讨论了 C++20 对处理日历以及使用year_month_day类型和其他来自chrono库的类型在格里高利历中表示日期的支持。
我们还看到了如何使用hh_mm_ss类型表示一天中的时间。然而,在所有这些示例中,我们使用系统时钟处理时间点,该时钟测量 Unix 时间,因此默认使用 UTC 作为时区。然而,我们通常对本地时间感兴趣,有时对其他时区的时间感兴趣。这是通过添加到chrono库以支持时区的功能实现的。在本菜谱中,你将了解 chrono 时区最重要的功能。
准备工作
在继续本菜谱之前,如果你还没有阅读,建议你阅读上一个菜谱,处理日历。
如何操作…
你可以使用 C++20 的chrono库执行以下操作:
- 
使用 std::chrono::current_zone()从时区数据库中检索本地时区。
- 
使用 std::chrono::locate_zone()通过其名称从时区数据库中检索特定时区。
- 
使用 std::chrono::zoned_time类模板在特定时区中表示时间点。
- 
检索并显示当前本地时间: auto time = zoned_time{ current_zone(), system_clock::now() }; std::cout << time << '\n'; // 2024-01-16 22:10:30.9274320 EET
- 
检索并显示另一个时区的当前时间。在以下示例中,我们使用意大利的时间: auto time = zoned_time{ locate_zone("Europe/Rome"), system_clock::now() }; std::cout << time << '\n'; // 2024-01-16 21:10:30.9291091 CET
- 
使用适当的区域设置格式显示当前本地时间。在这个例子中,当前时间是罗马尼亚时间,使用的区域设置是为罗马尼亚设计的: auto time = zoned_time{ current_zone(), system_clock::now() }; std::cout << std::format(std::locale{"ro_RO"}, "%c", time) << '\n'; // 16.01.2024 22:12:57
- 
在特定时区中表示一个时间点并显示它。在以下示例中,这是纽约的时间: auto time = local_days{ 2024y / June / 1 } + 12h + 30min + 45s + 256ms; auto ny_time = zoned_time<std::chrono::milliseconds>{ locate_zone("America/New_York"), time}; std::cout << ny_time << '\n'; // 2024-06-01 12:30:45.256 EDT
- 
将特定时区的时间点转换为另一个时区的时间点。在以下示例中,我们将纽约的时间转换为洛杉矶的时间: auto la_time = zoned_time<std::chrono::milliseconds>( locate_zone("America/Los_Angeles"), ny_time); std::cout << la_time << '\n'; // 2024-06-01 09:30:45.256 PDT
工作原理…
系统维护 IANA 时间区域(TZ)数据库的副本(可在www.iana.org/time-zones在线找到)。作为用户,您不能创建或修改数据库,但可以使用 std::chrono::tzdb() 或 std::chrono::get_tzdb_list() 等函数检索其只读副本。时间区域的信息存储在 std::chrono::time_zone 对象中。这个类的实例不能直接创建;它们仅在库初始化时间区域数据库时创建。然而,可以使用两个函数获得对这些实例的常量访问:
- 
std::chrono::current_zone()获取表示本地时间区域的time_zone对象。
- 
std::chrono::locate_zone()获取表示指定时间区域的time_zone对象。
时间区域名称的示例包括 Europe/Berlin、Asia/Dubai 和 America/Los_Angeles。当位置名称包含多个单词时,空格被下划线(_)替换,例如在先前的示例中,洛杉矶被写作 Los_Angeles。
所有来自 IANA TZ 数据库的时间区域列表可以在en.wikipedia.org/wiki/List_of_tz_database_time_zones找到。
C++20 的 chrono 库中有两套类型来表示时间点:
- 
sys_days和sys_seconds(具有天和秒的精度)表示系统时间区域中的时间点,该时间区域是 UTC。这些是std::chrono::sys_time的类型别名,而std::chrono::sys_time又是std::chrono::time_point的别名,它使用std::chrono::system_clock。
- 
local_days和local_seconds(也具有天和秒的精度)表示相对于尚未指定的时间区域的时间点。这些是std::chrono::local_time的类型别名,而std::chrono::local_time又是使用std::chrono::local_t伪时钟的std::chrono::time_point的类型别名。这个时钟的唯一目的是指示尚未指定的时间区域。
std::chrono::zoned_time 类模板表示时间区域与时间点的配对。它可以由 sys_time、local_time 或另一个 zoned_time 对象创建。这里展示了所有这些情况的示例:
auto zst = zoned_time<std::chrono::seconds>(
  current_zone(),
  sys_days{ 2024y / May / 10 } +14h + 20min + 30s);
std::cout << zst << '\n'; // 2024-05-10 17:20:30 EEST (or GMT+3)
auto zlt = zoned_time<std::chrono::seconds>(
  current_zone(),
  local_days{ 2024y / May / 10 } +14h + 20min + 30s);
std::cout << zlt << '\n'; // 2024-05-10 14:20:30 EEST (or GMT+3)
auto zpt = zoned_time<std::chrono::seconds>(
  locate_zone("Europe/Paris"),
  zlt);
std::cout << zpt << '\n'; //2024-05-10 13:20:30 CEST (or GMT+2) 
在此示例代码中,注释中的时间基于罗马尼亚时间区域。请注意,在第一个示例中,时间使用 sys_days 表示,它使用 UTC 时间区域。由于罗马尼亚时间在 2024 年 5 月 10 日(因为夏令时)是 UTC+3,所以本地时间是 17:20:30。在第二个示例中,时间使用 local_days 指定,它是与时间区域无关的。因此,当与当前时间区域配对时,实际时间是 14:20:30。在第三个和最后一个示例中,将本地罗马尼亚时间转换为巴黎时间,巴黎时间是 13:20:30(因为那天巴黎的时间是 UTC+2)。
参见
- 
使用 chrono::duration 表达时间间隔,以熟悉 C++11 chrono库的基本知识,并处理持续时间、时间点和点
- 
使用日历,以发现 C++20 对 chrono库中用于处理日期和日历的添加
使用标准时钟测量函数执行时间
在前面的菜谱中,我们看到了如何使用 chrono 标准库处理时间间隔。然而,我们还需要处理时间点。chrono 库提供了一个这样的组件,表示自时钟纪元以来的时间长度(即,时钟定义的时间的起点)。在这个菜谱中,我们将学习如何使用 chrono 库和时间点来测量函数的执行时间。
准备工作
这个菜谱与前面的一个菜谱紧密相关,使用 chrono::duration 表达时间间隔。如果你之前没有完成那个菜谱,你应该在继续这个菜谱之前先完成它。
对于这个菜谱中的示例,我们将考虑以下函数,它什么也不做,只是暂停当前线程的执行给定的时间间隔:
void func(int const interval = 1000)
{
  std::this_thread::sleep_for(std::chrono::milliseconds(interval));
} 
不言而喻,这个函数仅用于测试目的,并没有做任何有价值的事情。在实际应用中,你将使用这里提供的计数工具来测试你自己的函数。
如何做到这一点...
要测量函数的执行时间,你必须执行以下步骤:
- 
使用标准时钟获取当前时间点: auto start = std::chrono::high_resolution_clock::now();
- 
调用你想要测量的函数: func();
- 
再次获取当前时间点;两个时间点之间的差值是函数的执行时间: auto diff = std::chrono::high_resolution_clock::now() - start;
- 
将差异(以纳秒表示)转换为你感兴趣的分辨率: std::cout << std::chrono::duration<double, std::milli>(diff).count() << "ms" << '\n'; std::cout << std::chrono::duration<double, std::nano>(diff).count() << "ns" << '\n';
要在可重用组件中实现此模式,请执行以下步骤:
- 
创建一个由分辨率和时钟参数化的类模板。 
- 
创建一个静态变长函数模板,它接受一个函数及其参数。 
- 
实现之前显示的模式,使用其参数调用函数。 
- 
返回一个持续时间,而不是滴答数。 
这在以下代码片段中得到了体现:
template <typename Time = std::chrono::microseconds,
          typename Clock = std::chrono::high_resolution_clock>
struct perf_timer
{
  template <typename F, typename... Args>
  static Time duration(F&& f, Args... args)
  {
    auto start = Clock::now();
    std::invoke(std::forward<F>(f), std::forward<Args>(args)...);
    auto end = Clock::now();
    return std::chrono::duration_cast<Time>(end - start);
  }
}; 
它是如何工作的...
时钟是一个定义了两件事的组件:
- 
一个称为 纪元 的时间起点;关于纪元没有约束,但典型的实现使用 1970 年 1 月 1 日。 
- 
一个 滴答率,它定义了两个时间点之间的增量(例如毫秒或纳秒)。 
时间点是从时钟纪元以来的时间长度。有几个特别重要的时间点:
- 
当前时间,由时钟的静态成员 now()返回。
- 
纪元,或时间的起点;这是由特定时钟的 time_point的默认构造函数创建的时间点。
- 
时钟可以表示的最小时间,由 time_point的静态成员min()返回。
- 
时钟可以表示的最大时间,由 time_point的静态成员max()返回。
标准定义了几个时钟:
- 
system_clock: 这使用当前系统的实时时钟来表示时间点。
- 
high_resolution_clock: 这代表一个使用当前系统最短可能的滴答周期的时钟。
- 
steady_clock: 这表示一个永远不会调整的时钟。这意味着,与其它时钟不同,随着时间的推移,两个时间点之间的差异总是正的。
- 
utc_clock: 这是一个用于协调世界时(UTC)的 C++20 时钟。
- 
tai_clock: 这是一个用于国际原子时(TAI)的 C++20 时钟。
- 
gps_clock: 这是一个用于 GPS 时间的 C++20 时钟。
- 
file_clock: 这是一个用于表示文件时间的 C++20 时钟。
以下示例打印了列表中前三个时钟(C++11 中可用的)的精度,无论它们是否稳定(或单调):
template <typename T>
void print_clock()
{
  std::cout << "precision: "
            << (1000000.0 * double(T::period::num)) / 
               (T::period::den)
            << '\n';
  std::cout << "steady: " << T::is_steady << '\n';
}
print_clock<std::chrono::system_clock>();
print_clock<std::chrono::high_resolution_clock>();
print_clock<std::chrono::steady_clock>(); 
可能的输出如下:
precision: 0.1
steady: 0
precision: 0.001
steady: 1
precision: 0.001
steady: 1 
这意味着system_clock的分辨率为 0.1 微秒,并且不是一个单调时钟。另一方面,其它两个时钟high_resolution_clock和steady_clock都具有 1 纳秒的分辨率,并且是单调时钟。
在测量函数的执行时间时,时钟的稳定性很重要,因为如果在函数运行时调整时钟,结果将不会给出实际的执行时间,甚至可能得到负值。您应该依赖一个稳定的时钟来测量函数执行时间。典型的选择是high_resolution_clock,这正是我们在如何做...部分示例中使用的时钟。
当我们测量执行时间时,需要在调用之前和调用返回之后检索当前时间。为此,我们使用时钟的now()静态方法。结果是time_point;当我们从两个时间点中减去时,结果是duration,由时钟的持续时间定义。
为了创建一个可重用的组件,可以用来测量任何函数的执行时间,我们定义了一个名为perf_timer的类模板。这个类模板以我们感兴趣的分辨率(默认为微秒)和想要使用的时钟(默认为high_resolution_clock)作为参数。该类模板有一个名为duration()的单个静态成员——一个变参函数模板——它接受一个要执行的功能及其可变数量的参数。实现相对简单:我们检索当前时间,使用std::invoke调用函数(以便它处理调用任何可调用对象的不同机制),然后再次检索当前时间。返回值是一个duration(具有定义的分辨率)。以下代码片段展示了这个示例:
auto t = perf_timer<>::duration(func, 1500);
std::cout << std::chrono::duration<double, std::milli>(t).count()
          << "ms" << '\n';
std::cout << std::chrono::duration<double, std::nano>(t).count()
          << "ns" << '\n'; 
重要的是要注意,我们不是从duration()函数返回滴答数,而是返回实际的duration值。原因是,通过返回滴答数,我们失去了分辨率,也不知道它们实际上代表什么。仅在需要实际滴答数时调用count()更好。这里举例说明:
auto t1 = perf_timer<std::chrono::nanoseconds>::duration(func, 150);
auto t2 = perf_timer<std::chrono::microseconds>::duration(func, 150);
auto t3 = perf_timer<std::chrono::milliseconds>::duration(func, 150);
std::cout
  << std::chrono::duration<double, std::micro>(t1 + t2 + t3).count()
  << "us" << '\n'; 
在本例中,我们使用三种不同的分辨率(纳秒、微秒和毫秒)来测量三个不同函数的执行情况。t1、t2和t3的值代表持续时间。这使得它们可以轻松相加,并将结果转换为微秒。
参见
- 
使用 chrono::duration 表达时间间隔,以便熟悉 C++11 chrono库的基本知识以及如何处理持续时间、时间点和点
- 
第三章,统一调用任何可调用对象,学习如何使用 std::invoke()调用函数和任何可调用对象
为自定义类型生成哈希值
标准库提供了几个无序关联容器:std::unordered_set、std::unordered_multiset、std::unordered_map和std::unordered_map。这些容器不按特定顺序存储它们的元素;相反,它们被分组在桶中。一个元素所属的桶取决于该元素的哈希值。这些标准容器默认使用std::hash类模板来计算哈希值。所有基本类型和一些库类型的特化都是可用的。然而,对于自定义类型,您必须自己特化类模板。本食谱将向您展示如何做到这一点,并解释如何计算一个好的哈希值。一个好的哈希值可以快速计算,并且在值域内均匀分布,因此最小化重复值(冲突)存在的可能性。
准备工作
在本食谱的示例中,我们将使用以下类:
struct Item
{
  int         id;
  std::string name;
  double      value;
  Item(int const id, std::string const & name, double const value)
    :id(id), name(name), value(value)
  {}
  bool operator==(Item const & other) const
  {
    return id == other.id && name == other.name &&
           value == other.value;
  }
}; 
本食谱涵盖了标准库中的哈希功能。您应该熟悉哈希和哈希函数的概念。
如何操作...
为了使用自定义类型与无序关联容器一起使用,你必须执行以下步骤:
- 
为您的自定义类型特化 std::hash类模板;特化必须在std命名空间中完成。
- 
定义参数和结果类型的同义词。 
- 
实现调用操作符,使其接受对您的类型的常量引用并返回一个哈希值。 
为了计算一个好的哈希值,您应该做以下事情:
- 
从一个初始值开始,这个值应该是一个素数(例如,17)。 
- 
对于每个用于确定两个类的实例是否相等的字段,根据以下公式调整哈希值: hashValue = hashValue * prime + hashFunc(field);
- 
您可以使用相同的素数对所有字段使用前面的公式,但建议使用一个不同于初始值的值(例如,31)。 
- 
使用 std::hash的特化来确定类数据成员的哈希值。
根据这里描述的步骤,Item类的std::hash特化看起来如下:
namespace std
{
  template<>
  struct hash<Item>
  {
    typedef Item argument_type;
    typedef size_t result_type;
    result_type operator()(argument_type const & item) const
 {
      result_type hashValue = 17;
      hashValue = 31 * hashValue + std::hash<int>{}(item.id);
      hashValue = 31 * hashValue + std::hash<std::string>{}(item.name);
      hashValue = 31 * hashValue + std::hash<double>{}(item.value);
      return hashValue;
    }
  };
} 
这个专业使您能够使用Item类与无序关联容器,例如std::unordered_set一起使用。这里提供了一个示例:
std::unordered_set<Item> set2
{
  { 1, "one"s, 1.0 },
  { 2, "two"s, 2.0 },
  { 3, "three"s, 3.0 },
}; 
它是如何工作的...
类模板std::hash是一个函数对象模板,其调用操作符定义了一个具有以下属性的哈希函数:
- 
接受模板参数类型的参数并返回一个 size_t值。
- 
不会抛出任何异常。 
- 
对于相等的两个参数,它返回相同的哈希值。 
- 
对于不相等的两个参数,返回相同值的概率非常小(应接近 1.0/std::numeric_limits<size_t>::max())。
标准为所有基本类型提供了特化,例如bool、char、int、long、float、double(以及所有可能的unsigned和long变体),以及指针类型,但也包括库类型,如basic_string和basic_string_view类型、unique_ptr和shared_ptr、bitset和vector<bool>、optional和variant(在 C++17 中),以及几种其他类型。然而,对于自定义类型,您必须提供自己的特化。这个特化必须在std命名空间中(因为类模板hash是在这个命名空间中定义的),并且必须满足前面列举的要求。
标准没有指定如何计算哈希值。只要它为相等的对象返回相同的值,并且对于不相等的对象有非常小的概率返回相同的值,您可以使用任何想要的函数。本食谱中描述的算法在 Joshua Bloch 所著的《Effective Java,第二版》一书中提出。
在计算哈希值时,仅考虑参与确定两个类的实例是否相等的字段(换句话说,用于operator==的字段)。但是,您必须使用与operator==一起使用的所有这些字段。在我们的例子中,Item类的所有三个字段都用于确定两个对象的相等性;因此,我们必须使用它们来计算哈希。初始哈希值不应为零,在我们的例子中,我们选择了素数 17。
重要的是,这些值不应为零;否则,产生哈希值为零的初始字段(即处理顺序中的第一个)将不会改变哈希值(由于x * 0 + 0 = 0,哈希值保持为零)。对于用于计算哈希的每个字段,我们通过将其前一个值乘以一个素数并加上当前字段的哈希值来改变当前哈希值。为此,我们使用类模板std::hash的特化。
使用素数 31 对于性能优化是有利的,因为 31 * x 可以被编译器替换为 (x << 5) - x,这更快。同样,你也可以使用 127,因为 127 * x 等于 (x << 7) - x 或 8191,因为 8191 * x 等于 (x << 13) - x。
如果你的自定义类型包含一个数组,并且用于确定两个对象的相等性,因此需要用于计算哈希,那么将数组视为其元素是类的数据成员。换句话说,将前面描述的相同算法应用于数组的所有元素。
参见
- 第二章,数值类型的限制和其他属性,了解数值类型的最大值和最小值,以及其他数值类型的属性
使用 std::any 存储任何值
C++ 没有像其他语言(如 C# 或 Java)那样的层次类型系统,因此它不能像 .NET 和 Java 中的 Object 类型或 JavaScript 中的原生类型那样在单个变量中存储多个类型的值。长期以来,开发者一直使用 void* 来实现这个目的,但这只能帮助我们存储指向任何东西的指针,并且不是类型安全的。根据最终目标,替代方案可以包括模板或重载函数。然而,C++17 引入了一个标准类型安全的容器,称为 std::any,它可以存储任何类型的单个值。
准备工作
std::any 是基于 boost::any 设计的,并在 <any> 头文件中可用。如果你熟悉 boost::any 并已在代码中使用它,你可以无缝地将它迁移到 std::any。
如何做...
使用以下操作来处理 std::any:
- 
要存储值,请使用构造函数或将它们直接赋值给 std::any变量:std::any value(42); // integer 42 value = 42.0; // double 42.0 value = "42"s; // std::string "42"
- 
要读取值,请使用非成员函数 std::any_cast():std::any value = 42.0; try { auto d = std::any_cast<double>(value); std::cout << d << '\n'; // prints 42 } catch (std::bad_any_cast const & e) { std::cout << e.what() << '\n'; }
- 
要检查存储值的类型,请使用 type()成员函数:inline bool is_integer(std::any const & a) { return a.type() == typeid(int); }
- 
要检查容器是否存储了值,请使用 has_value()成员函数:auto ltest = [](std::any const & a) { if (a.has_value()) std::cout << "has value" << '\n'; else std::cout << "no value" << '\n'; }; std::any value; ltest(value); // no value value = 42; ltest(value); // has value
- 
要修改存储的值,请使用 emplace()、reset()或swap()成员函数:std::any value = 42; ltest(value); // has value value.reset(); ltest(value); // no value
它是如何工作的...
std::any 是一个类型安全的容器,可以存储任何类型(或者更确切地说,其退化类型是可复制的)的值。在容器中存储值非常简单——你可以使用可用的构造函数之一(默认构造函数创建一个不存储任何值的容器)或者赋值运算符。然而,直接读取值是不可能的,你需要使用非成员函数 std::any_cast(),该函数将存储的值转换为指定的类型。如果存储的值与你要转换的类型不同,该函数会抛出 std::bad_any_cast 异常。在隐式可转换的类型之间进行转换,例如 int 和 long,也是不可能的。std::bad_any_cast 是从 std::bad_cast 派生的;因此,你可以捕获这两种异常类型中的任何一种。
可以使用 type() 成员函数检查存储值的类型,它返回一个 type_info 常量引用。如果容器为空,此函数返回 typeid(void)。要检查容器是否存储了值,可以使用成员函数 has_value(),如果容器中有值则返回 true,如果容器为空则返回 false。
以下示例展示了如何检查容器是否有任何值,如何检查存储值的类型,以及如何从容器中读取值:
void log(std::any const & value)
{
  if (value.has_value())
  {
    auto const & tv = value.type();
    if (tv == typeid(int))
    {
      std::cout << std::any_cast<int>(value) << '\n';
    }
    else if (tv == typeid(std::string))
    {
      std::cout << std::any_cast<std::string>(value) << '\n';
    }
    else if (tv == typeid(
      std::chrono::time_point<std::chrono::system_clock>))
    {
      auto t = std::any_cast<std::chrono::time_point<
        std::chrono::system_clock>>(value);
      auto now = std::chrono::system_clock::to_time_t(t);
      std::cout << std::put_time(std::localtime(&now), "%F %T")
                << '\n';
    }
    else
    {
      std::cout << "unexpected value type" << '\n';
    }
  }
  else
  {
    std::cout << "(empty)" << '\n';
  }
}
log(std::any{});                       // (empty)
log(42);                               // 42
log("42"s);                            // 42
log(42.0);                             // unexpected value type
log(std::chrono::system_clock::now()); // 2016-10-30 22:42:57 
如果你想存储任何类型的多个值,可以使用标准容器,如 std::vector 来持有 std::any 类型的值。以下是一个示例:
std::vector<std::any> values;
values.push_back(std::any{});
values.push_back(42);
values.push_back("42"s);
values.push_back(42.0);
values.push_back(std::chrono::system_clock::now());
for (auto const & v : values)
  log(v); 
values contains elements of the std::any type, which, in turn, contains an int, std::string, double, and std::chrono::time_point value.
参见
- 
使用 std::optional存储可选值,了解 C++17 类模板std::optional,它管理可能存在或可能不存在的值
- 
使用 std::variant作为类型安全的联合体,了解如何使用 C++17 的std::variant类来表示类型安全的联合体
使用 std::optional 存储可选值
有时,如果某个特定值不可用,能够存储一个值或一个空指针是有用的。这种情况的一个典型例子是函数的返回值,该函数可能无法生成返回值,但这种失败不是错误。例如,考虑一个通过指定键从字典中查找并返回值的函数。找不到值是一个可能的情况,因此该函数要么返回一个布尔值(或如果需要更多错误代码,则返回整数值),并有一个引用参数来保存返回值,要么返回一个指针(原始指针或智能指针)。在 C++17 中,std::optional 是这些解决方案的更好替代。类模板 std::optional 是一个用于存储可能存在或不存在值的模板容器。在本食谱中,我们将了解如何使用此容器及其典型用例。
准备工作
类模板 std::optional<T> 是基于 boost::optional 设计的,并在 <optional> 头文件中提供。如果你熟悉 boost::optional 并已在代码中使用它,你可以无缝地将它迁移到 std::optional。
在以下代码片段中,我们将参考以下 foo 类:
struct foo
{
  int    a;
  double b;
}; 
如何做到这一点...
使用以下操作来处理 std::optional:
- 
要存储一个值,使用构造函数或将值直接赋给 std::optional对象:std::optional<int> v1; // v1 is empty std::optional<int> v2(42); // v2 contains 42 v1 = 42; // v1 contains 42 std::optional<int> v3 = v2; // v3 contains 42
- 
要读取存储的值,使用 operator*或operator->:std::optional<int> v1{ 42 }; std::cout << *v1 << '\n'; // 42 std::optional<foo> v2{ foo{ 42, 10.5 } }; std::cout << v2->a << ", " << v2->b << '\n'; // 42, 10.5
- 
或者,使用成员函数 value()和value_or()来读取存储的值:std::optional<std::string> v1{ "text"s }; std::cout << v1.value() << '\n'; // text std::optional<std::string> v2; std::cout << v2.value_or("default"s) << '\n'; // default
- 
要检查容器是否存储了值,可以使用转换运算符到 bool或成员函数has_value():std::optional<int> v1{ 42 }; if (v1) std::cout << *v1 << '\n'; std::optional<foo> v2{ foo{ 42, 10.5 } }; if (v2.has_value()) std::cout << v2->a << ", " << v2->b << '\n';
- 
要修改存储的值,使用成员函数 emplace()、reset()或swap():std::optional<int> v{ 42 }; // v contains 42 v.reset(); // v is empty
使用 std::optional 来模拟以下任何一种情况:
- 
可能无法生成值的函数的返回值: template <typename K, typename V> std::optional<V> find(K const key, std::map<K, V> const & m) { auto pos = m.find(key); if (pos != m.end()) return pos->second; return {}; } std::map<int, std::string> m{ { 1, "one"s },{ 2, "two"s },{ 3, "three"s } }; auto value = find(2, m); if (value) std::cout << *value << '\n'; // two value = find(4, m); if (value) std::cout << *value << '\n';
- 
可选的函数参数: std::string extract(std::string const & text, std::optional<int> start, std::optional<int> end) { auto s = start.value_or(0); auto e = end.value_or(text.length()); return text.substr(s, e - s); } auto v1 = extract("sample"s, {}, {}); std::cout << v1 << '\n'; // sample auto v2 = extract("sample"s, 1, {}); std::cout << v2 << '\n'; // ample auto v3 = extract("sample"s, 1, 4); std::cout << v3 << '\n'; // amp
- 
可选的类数据成员: struct book { std::string title; std::optional<std::string> subtitle; std::vector<std::string> authors; std::string publisher; std::string isbn; std::optional<int> pages; std::optional<int> year; };
它是如何工作的...
类模板 std::optional 是一个表示可选值容器的类模板。如果容器包含值,则该值作为 optional 对象的一部分存储;不涉及堆分配和指针。std::optional 类模板的概念性实现如下:
template <typename T>
class optional
{
  bool _initialized;
  std::aligned_storage_t<sizeof(t), alignof(T)> _storage;
}; 
std::aligned_storage_t 别名模板允许我们创建未初始化的内存块,这些内存块可以存储特定类型的对象。类模板 std::optional 如果是默认构造的,或者是从另一个空的 std::optional 对象或从 std::nullopt_t 值复制构造或复制赋值而来,则不包含值。这样的值是 std::nullopt,一个用于表示具有未初始化状态的 std::optional 对象的 constexpr 值。这是一个辅助类型,实现为一个空类,用于指示具有未初始化状态的 std::optional 对象。
optional 类型(在其他编程语言中称为 nullable)的典型用途是从可能失败的功能返回。这种情况的可能解决方案包括以下几种:
- 
返回一个 std::pair<T, bool>,其中T是返回值的类型;对的数据是布尔标志,指示第一个元素的有效性。
- 
返回一个 bool,接受一个额外的类型为T&的参数,并且仅在函数成功时将值赋给此参数。
- 
返回原始指针或智能指针类型,并使用 nullptr来指示失败。
类模板 std::optional 是一种更好的方法,因为一方面,它不涉及函数的输出参数(这在 C 和 C++ 之外不是返回值的规范形式),也不需要处理指针,另一方面,它更好地封装了 std::pair<T, bool> 的细节。
然而,可选对象也可以用于类的数据成员,并且编译器能够优化内存布局以实现高效的存储。
类模板 std::optional 不能用于返回多态类型。例如,如果你编写了一个需要从类型层次结构返回不同类型的工厂方法,你不能依赖于 std::optional,需要返回一个指针,最好是 std::unique_ptr 或 std::shared_ptr(取决于是否需要共享对象的所有权)。
当你使用 std::optional 将可选参数传递给函数时,你需要理解它可能会产生复制,如果涉及到大型对象,这可能会成为性能问题。让我们考虑以下具有对 std::optional 参数的常量引用的函数的例子:
struct bar { /* details */ };
void process(std::optional<bar> const & arg)
{
  /* do something with arg */
}
std::optional<bar> b1{ bar{} };
bar b2{};
process(b1); // no copy
process(b2); // copy construction 
第一次调用process()不涉及任何额外的对象构造,因为我们传递了一个std::optional<bar>对象。然而,第二次调用将涉及bar对象的复制构造,因为b2是一个bar,需要被复制到一个std::optional<bar>中;即使bar实现了移动语义,也会进行复制。如果bar是一个小对象,这不应该引起太大的关注,但对于大对象,这可能会成为一个性能问题。避免这种情况的解决方案取决于上下文,可能包括创建一个接受bar常量引用的第二个重载,或者完全避免使用std::optional。
还有更多…
虽然std::optional使得从可能失败的函数中返回值变得更加容易,但将多个此类函数链式连接起来会产生冗长或至少过于重复的代码。为了简化这种情况,在 C++23 中,std::optional有多个额外的成员(transform()、and_then()和or_else()),被称为单子操作。我们将在下一道菜谱中了解它们。
参见
- 
使用 std::any 存储任何值,了解如何使用 C++17 类 std::any,它代表任何类型的单值类型安全容器
- 
使用 std::variant 作为类型安全的联合体,了解如何使用 C++17 类 std::variant来表示类型安全的联合体
- 
将可能或可能不产生值的计算链式连接起来,以了解新的 C++23 单子操作 std::optional如何简化多个返回std::optional的函数依次调用的场景
将可能或可能不产生值的计算链式连接起来
在之前的菜谱中,我们看到了如何使用std::optional类来存储可能存在或不存在的数据。它的用例包括函数的可选参数和可能无法产生结果的函数的返回值。当需要将多个此类函数链式连接起来时,代码可能会变得冗长且啰嗦。因此,C++23 标准为std::optional类添加了几个新方法。它们被称为单子操作。这些方法包括transform()、and_then()和or_else()。在本菜谱中,我们将了解它们有什么用。
简而言之,在函数式编程中,一个单子是一个封装在其包装值之上的一些功能的容器。例如,C++中的std::optional就是一个这样的例子。另一方面,一个单子操作是一个从域 D 到 D 自身的函数。例如,恒等函数(返回其参数的函数)就是一个单子操作。新添加的函数transform()、and_then()和or_else()是单子操作,因为它们接受一个std::optional并返回一个std::optional。
准备工作
在以下章节中,我们将参考此处所示的定义:
struct booking
{
   int                        id;
   int                        nights;
   double                     rate;
   std::string                description;
   std::vector<std::string>   extras;
};
std::optional<booking> make_booking(std::string_view description, 
 int nights, double rate);
std::optional<booking> add_rental(std::optional<booking> b);
std::optional<booking> add_insurance(std::optional<booking> b);
double calculate_price(std::optional<booking> b);
double apply_discount(std::optional<double> p); 
如何做到这一点…
根据您的使用情况,您可以使用以下单子操作:
- 
如果你有一个 可选值,并想应用一个函数f并返回该调用的值,那么请使用transform():auto b = make_booking("Hotel California", 3, 300); auto p = b.transform(calculate_price);
- 
如果你有一个 可选值,并想应用一个返回可选值的函数f,然后返回该调用的值,那么请使用and_then():auto b = make_booking("Hotel California", 3, 300); b = b.and_then(add_insurance); auto p = b.transform(calculate_price);
- 
如果你有一个可能为空的 可选值,在这种情况下,你想调用一个函数来处理这种情况(例如记录日志或抛出异常),并返回另一个可选(一个替代值或一个空的可选),那么请使用or_else():auto b = make_booking("Hotel California", 3, 300) .or_else([]() -> std::optional<booking> { std::cout << "creating the booking failed!\n"; return std::nullopt; });
下面的片段展示了更大的例子:
auto p =
    make_booking("Hotel California", 3, 300)
   .and_then(add_rental)
   .and_then(add_insurance)
   .or_else([]() -> std::optional<booking> {
      std::cout << "creating the booking failed!\n";  
      return std::nullopt; })
   .transform(calculate_price)
   .transform(apply_discount)
   .or_else([]() -> std::optional<double> {
      std::cout << "computing price failed!\n"; return -1; }); 
它是如何工作的...
and_then()和transform()成员函数非常相似。它们实际上具有相同数量的重载,具有相同的签名。它们接受一个参数,该参数是一个函数或可调用对象,并且它们都返回一个可选。如果可选不包含值,那么and_then()和transform()都返回一个空的可选。
否则,如果可选确实包含一个值,那么它将使用存储的值调用该函数或可调用对象。这里就是它们的不同之处:
- 
传递给 and_then()的函数/可调用对象必须返回一个std::optional类型的值。这将是由and_then()返回的值。
- 
传递给 transform()的函数/可调用对象可以返回任何非引用类型的返回类型。然而,它在返回之前将自身包裹在一个std::optional中。
为了更好地说明这一点,让我们再次考虑以下函数:
double calculate_price(std::optional<booking> b); 
之前,我们已经看到了这个片段:
auto b = make_booking("Hotel California", 3, 300);
auto p = b.transform(calculate_price); 
在这里,p具有std::optional<double>类型。这是因为calculate_price()返回一个double,因此transform()将返回一个std::optional<double>。让我们将calculate_price()的签名更改为返回std::optional<double>:
std::optional<double> calculate_price(std::optional<booking> b); 
变量p现在将具有std::optional<std::optional<double>>的类型。
第三种单子函数or_else()是and_then()/transform()的对立面:如果可选对象包含一个值,它将不做任何操作并返回该可选。否则,它将调用其单个参数,即一个不带任何参数的函数或可调用对象,并从这次调用返回值。函数/可调用对象的返回类型必须是std::optional<T>。
or_else()函数通常用于处理预期值缺失时的错误情况。提供的函数可能向日志中添加条目、抛出异常或执行其他操作。除非这个可调用对象抛出异常,否则它必须返回一个值。这可以是一个空的可选,或者是一个包含默认值或替代缺失值的可选。
还有更多...
std::optional 的一个最重要的用例是从可能产生也可能不产生值的函数中返回值。然而,当值缺失时,我们可能需要知道失败的原因。使用可选类型时,这并不直接可行,除非存储的类型是一个值和错误的复合体,或者如果我们在函数中使用了额外的参数来检索错误。因此,C++23 标准为这些用例提供了 std::optional 的替代方案,即 std::expected 类型。
参见
- 使用 std::expected返回值或错误,以了解这种 C++23 类型如何使我们能够从函数中返回值或错误代码
将 std::variant 作为类型安全的联合使用
在 C++ 中,联合类型是一种特殊类类型,在任何时刻,它都持有其数据成员中的一个值。与常规类不同,联合不能有基类,也不能被派生,并且不能包含虚拟函数(这本来就没有意义)。联合主要用于定义相同数据的不同表示。然而,联合仅适用于 纯旧数据 (POD) 类型。如果一个联合包含非 POD 类型的值,那么这些成员需要使用带位置的 new 进行显式构造和显式销毁,这很麻烦且容易出错。在 C++17 中,类型安全的联合以标准库类模板 std::variant 的形式提供。在本食谱中,您将学习如何使用它来建模替代值。
准备工作
std::variant 类型实现了一个类型安全的 区分联合。尽管详细讨论这些内容超出了本食谱的范围,但我们将在这里简要介绍它们。熟悉区分联合将帮助我们更好地理解 variant 的设计和其工作方式。
区分联合也称为 标记联合 或 不相交联合。区分联合是一种能够存储一组类型中的一个值并提供对该值类型安全访问的数据类型。在 C++ 中,这通常如下实现:
enum VARTAG {VT_int, VT_double, VT_pint, TP_pdouble /* more */ };
struct variant_t
{
  VARTAG tag;
  union Value 
  {
    int     i;
    int*    pi;
    double  d;
    double* pd;
    /* more */
  } value;
}; 
对于 Windows 程序员来说,一个众所周知的区分联合是用于 组件对象模型 (COM) 编程的 VARIANT 结构。
类模板 std::variant 是基于 boost::variant 设计的,并在 <variant> 头文件中可用。如果您熟悉 boost::variant 并已在代码中使用它,您可以通过少量努力将代码迁移到使用标准的 variant 类模板。
如何做到这一点...
使用以下操作来处理 std::variant:
- 
要修改存储的值,请使用成员函数 emplace()或swap():struct foo { int value; explicit foo(int const i) : value(i) {} }; std::variant<int, std::string, foo> v = 42; // holds int v.emplace<foo>(42); // holds foo
- 
要读取存储的值,请使用非成员函数 std::get或std::get_if:std::variant<int, double, std::string> v = 42; auto i1 = std::get<int>(v); auto i2 = std::get<0>(v); try { auto f = std::get<double>(v); } catch (std::bad_variant_access const & e) { std::cout << e.what() << '\n'; // Unexpected index }
- 
要存储一个值,请使用构造函数或将值直接赋给 variant对象:std::variant<int, double, std::string> v; v = 42; // v contains int 42 v = 42.0; // v contains double 42.0 v = "42"; // v contains string "42"
- 
要检查存储的替代项,请使用成员函数 index():std::variant<int, double, std::string> v = 42; static_assert(std::variant_size_v<decltype(v)> == 3); std::cout << "index = " << v.index() << '\n'; v = 42.0; std::cout << "index = " << v.index() << '\n'; v = "42"; std::cout << "index = " << v.index() << '\n';
- 
要检查变体是否持有替代方案,请使用非成员函数 std::holds_alternative():std::variant<int, double, std::string> v = 42; std::cout << "int? " << std::boolalpha << std::holds_alternative<int>(v) << '\n'; // int? true v = "42"; std::cout << "int? " << std::boolalpha << std::holds_alternative<int>(v) << '\n'; // int? false
- 
要定义一个第一个替代方案不是默认可构造的变体,请使用 std::monostate作为第一个替代方案(在这个例子中,foo是我们之前使用的相同类):std::variant<std::monostate, foo, int> v; v = 42; // v contains int 42 std::cout << std::get<int>(v) << '\n'; v = foo{ 42 }; // v contains foo{42} std::cout << std::get<foo>(v).value << '\n';
- 
要处理变体存储的值并根据替代方案的类型执行某些操作,请使用 std::visit():std::variant<int, double, std::string> v = 42; std::visit( [](auto&& arg) {std::cout << arg << '\n'; }, v);
它是如何工作的...
std::variant是一个类模板,它模拟了一个类型安全的联合,在任何给定时间持有其可能的替代方案之一。然而,在某些罕见的情况下,变体对象可能不存储任何值。std::variant有一个名为valueless_by_exception()的成员函数,如果变体不持有值,则返回true,这只有在初始化期间发生异常的情况下才可能,因此函数的名称。
std::variant对象的大小与其最大的替代方案一样大。变体不存储额外的数据。变体存储的值是在对象的内存表示内部分配的。
变体可以持有相同类型的多个替代方案,并且还可以同时持有不同常量和易失性资格的版本。在这种情况下,您不能分配多个类型的值,而应使用emplace()成员函数,如下面的代码片段所示:
std::variant<int, int, double> v = 33.0;
v = 42;                               // error
v.emplace<1>(42);                     // OK
std::cout << std::get<1>(v) << '\n';  // prints 42
std::holds_alternative<int>(v);       // error 
之前提到的std::holds_alternative()函数,它检查变体是否持有替代类型T,在此情况下不能使用。您应该避免定义持有相同类型多个替代方案的变体。
另一方面,变体不能持有类型void的替代方案,或者数组和引用类型的替代方案。此外,第一个替代方案必须始终是默认可构造的。这是因为,就像区分联合一样,变体使用其第一个替代方案的值进行默认初始化。如果第一个替代方案类型不是默认可构造的,那么变体必须使用std::monostate作为第一个替代方案。这是一个空类型,旨在使变体默认可构造。
可以在编译时查询variant的大小(即它定义的替代方案的数量)以及通过其零基索引指定的替代方案类型。另一方面,您可以使用成员函数index()在运行时查询当前持有的替代方案的索引。
更多...
操作变体内容的一种典型方式是通过访问。这基本上是基于变体持有的替代方案执行一个动作。由于这是一个较大的主题,它将在下一个菜谱中单独讨论。
参见
- 
使用 std::any存储任何值,了解如何使用 C++17 类std::any,它代表任何类型的单值类型安全容器
- 
使用 std::optional 存储可选值,了解 C++17 类模板 std::optional,它管理可能存在或不存在的一个值
- 
访问 std::variant,了解如何执行类型匹配并根据变体替代的类型执行不同的操作 
访问 std::variant
std::variant 是一个新标准容器,它基于 boost.variant 库添加到 C++17 中。变体是一个类型安全的联合体,它持有其替代类型之一的值。尽管在之前的食谱中,我们已经看到了各种变体的操作,但我们使用的变体相当简单,主要是 POD 类型,这并不是 std::variant 被创建的实际目的。变体旨在用于持有类似非多态和非 POD 类型的替代项。在这个食谱中,我们将看到一个更实际的变体使用示例,并学习如何访问变体。
准备工作
对于这个食谱,你应该熟悉 std::variant 类型。建议你首先阅读之前的食谱,使用 std::variant 作为类型安全的联合体。
为了解释如何进行变体访问,我们将考虑一个用于表示媒体 DVD 的变体。假设我们想要模拟一个商店或图书馆,其中包含可能包含音乐、电影或软件的 DVD。然而,这些选项不是作为具有公共数据和虚拟函数的层次结构来建模,而是作为可能具有类似属性(如标题)的非相关类型。为了简单起见,我们将考虑以下属性:
- 
对于电影:标题和长度(以分钟为单位) 
- 
对于一个专辑:标题、艺术家姓名以及曲目列表(每首曲目都有一个标题和以秒为单位的长度) 
- 
对于软件:标题和制造商 
以下代码展示了这些类型的简单实现,没有包含任何函数,因为这与访问包含这些类型变体的变体无关:
enum class Genre { Drama, Action, SF, Comedy };
struct Movie
{
  std::string title;
  std::chrono::minutes length;
  std::vector<Genre> genre;
};
struct Track
{
  std::string title;
  std::chrono::seconds length;
};
struct Music
{
  std::string title;
  std::string artist;
  std::vector<Track> tracks;
};
struct Software
{
  std::string title;
  std::string vendor;
};
using dvd = std::variant<Movie, Music, Software>;
std::vector<dvd> dvds
{
  Movie{ "The Matrix"s, 2h + 16min,{ Genre::Action, Genre::SF } },
  Music{ "The Wall"s, "Pink Floyd"s,
       { { "Mother"s, 5min + 32s },
         { "Another Brick in the Wall"s, 9min + 8s } } },
  Software{ "Windows"s, "Microsoft"s },
}; 
另一方面,我们将使用以下函数将文本转换为大写:
template <typename CharT>
using tstring = std::basic_string<CharT, std::char_traits<CharT>, 
                                         std::allocator<CharT>>;
template<typename CharT>
inline tstring<CharT> to_upper(tstring<CharT> text)
{
   std::transform(std::begin(text), std::end(text), 
                  std::begin(text), toupper);
   return text;
} 
定义好这些之后,让我们开始探讨如何执行访问变体。
如何做到这一点...
要访问一个变体,你必须为变体的可能替代提供一个或多个动作。有几种类型的访问者,用于不同的目的:
- 
一个不返回任何内容但具有副作用的无返回值访问者。以下示例将每张 DVD 的标题打印到控制台: for (auto const & d : dvds) { std::visit([](auto&& arg) { std::cout << arg.title << '\n'; }, d); }
- 
返回值的访问者;值应该与当前变体的任何替代类型相同,或者本身可以是一个变体。在以下示例中,我们访问一个变体并返回一个具有相同类型的新的变体,其 title属性从任何替代类型转换为大写字母:for (auto const & d : dvds) { dvd result = std::visit( [](auto&& arg) -> dvd { auto cpy { arg }; cpy.title = to_upper(cpy.title); return cpy; }, d); std::visit( [](auto&& arg) { std::cout << arg.title << '\n'; }, result); }
- 
通过提供具有为变体的每种替代类型重载的调用操作符的函数对象来实现类型匹配的访问者(这可以是空返回值或返回值的访问者): struct visitor_functor { void operator()(Movie const & arg) const { std::cout << "Movie" << '\n'; std::cout << " Title: " << arg.title << '\n'; std::cout << " Length: " << arg.length.count() << "min" << '\n'; } void operator()(Music const & arg) const { std::cout << "Music" << '\n'; std::cout << " Title: " << arg.title << '\n'; std::cout << " Artist: " << arg.artist << '\n'; for (auto const & t : arg.tracks) std::cout << " Track: " << t.title << ", " << t.length.count() << "sec" << '\n'; } void operator()(Software const & arg) const { std::cout << "Software" << '\n'; std::cout << " Title: " << arg.title << '\n'; std::cout << " Vendor: " << arg.vendor << '\n'; } }; for (auto const & d : dvds) { std::visit(visitor_functor(), d); }
- 
通过提供执行基于替代类型动作的 lambda 表达式来实现类型匹配的访问者: for (auto const & d : dvds) { std::visit([](auto&& arg) { using T = std::decay_t<decltype(arg)>; if constexpr (std::is_same_v<T, Movie>) { std::cout << "Movie" << '\n'; std::cout << " Title: " << arg.title << '\n'; std::cout << " Length: " << arg.length.count() << "min" << '\n'; } else if constexpr (std::is_same_v<T, Music>) { std::cout << "Music" << '\n'; std::cout << " Title: " << arg.title << '\n'; std::cout << " Artist: " << arg.artist << '\n'; for (auto const & t : arg.tracks) std::cout << " Track: " << t.title << ", " << t.length.count() << "sec" << '\n'; } else if constexpr (std::is_same_v<T, Software>) { std::cout << "Software" << '\n'; std::cout << " Title: " << arg.title << '\n'; std::cout << " Vendor: " << arg.vendor << '\n'; } }, d); }
它是如何工作的...
访问者是一个可调用对象(一个函数、一个 lambda 表达式或一个函数对象),它接受来自变体的所有可能的替代项。通过使用访问者和一个或多个变体对象调用 std::visit() 来进行访问。变体不必是相同的类型,但访问者必须能够接受所有被调用的变体的所有可能的替代项。在先前的示例中,我们访问了一个单个的变体对象,但访问多个变体并不意味着比将它们作为参数传递给 std::visit() 更多的事情。
当访问一个变体时,可调用对象会使用当前存储在变体中的值来调用。如果访问者不接受变体中存储的类型作为参数,则程序是不规范的。如果访问者是一个函数对象,那么它必须为变体的所有可能的替代类型重载其调用操作符。如果访问者是一个 lambda 表达式,它应该是一个泛型 lambda,这基本上是一个具有调用操作符模板的函数对象,由编译器根据实际调用的类型实例化。
在上一节中展示了两种方法类型的访问者示例。第一个示例中的函数对象很简单,不需要额外的解释。另一方面,泛型 lambda 表达式使用 constexpr if 来根据编译时参数的类型选择特定的 if 分支。结果是编译器将创建一个具有操作符调用模板和包含 constexpr if 语句的体的函数对象;当它实例化该函数模板时,它将为变体的每种可能的替代类型生成一个重载,并且在每个这些重载中,它将只选择与调用操作符参数类型匹配的 constexpr if 分支。结果是概念上等同于 visitor_functor 类的实现。
参见
- 
使用 std::any存储任何值,了解如何使用 C++17 类std::any,它代表一个类型安全的容器,用于存储任何类型的单个值
- 
使用 std::optional存储可选值,了解关于 C++17 类模板std::optional的信息,该模板管理可能存在或不存在的一个值
- 
使用 std::variant作为类型安全的联合,了解如何使用 C++17std::variant类来表示类型安全的联合
使用 std::expected 返回值或错误
我们经常需要编写一个函数,该函数既返回一些数据,又返回成功或失败的指示(对于最简单的情况,可以是 bool,对于更复杂的情况,可以是错误代码)。通常,这可以通过返回状态码并使用通过引用传递的参数来返回数据来解决,或者在实际数据返回失败的情况下抛出异常。近年来,std::optional 和 std::variant 的可用性为解决这个问题提供了新的解决方案。然而,C++23 标准通过 std::expected 类型提供了一种新的方法,这是一种两种先前提及类型的组合。这种类型存在于其他编程语言中,如 Rust 中的 Result 和 Haskell 中的 Either。在本食谱中,我们将学习如何使用这个新的 std::expected 类。
准备工作
在本食谱中展示的示例中,我们将使用在此处定义的数据类型:
enum class Status
{
   Success, InvalidFormat, InvalidLength, FilterError,
};
enum class Filter
{
   Pixelize, Sepia, Blur
};
using Image = std::vector<char>; 
如何操作…
您可以使用来自新 <expected> 头文件的 std::expected<T, E> 类型,如下面的示例所示:
- 
当从函数返回数据时,返回 std::unexpected<E>以指示错误,或者在一切执行成功时返回数据(T类型的值):bool IsValidFormat(Image const& img) { return true; } bool IsValidLength(Image const& img) { return true; } bool Transform(Image& img, Filter const filter) { switch(filter) { case Filter::Pixelize: img.push_back('P'); std::cout << "Applying pixelize\n"; break; case Filter::Sepia: img.push_back('S'); std::cout << "Applying sepia\n"; break; case Filter::Blur: img.push_back('B'); std::cout << "Applying blur\n"; break; } return true; } std::expected<Image, Status> ApplyFilter(Image img, Filter const filter) { if (!IsValidFormat(img)) return std::unexpected<Status> {Status::InvalidFormat}; if (!IsValidLength(img)) return std::unexpected<Status> {Status::InvalidLength}; if (!Transform(img, filter)) return std::unexpected<Status> {Status::FilterError}; return img; } std::expected<Image, Status> FlipHorizontally(Image img) { return Image{img.rbegin(), img.rend()}; }
- 
当检查返回 std::expected<T, E>的函数的结果时,使用bool操作符(或has_value()方法)来检查对象是否包含预期的值,并使用value()和error()方法分别返回预期的值或意外的错误:void ShowImage(Image const& img) { std::cout << "[img]:"; for(auto const & e : img) std::cout << e; std::cout << '\n'; } void ShowError(Status const status) { std::cout << "Error code: " << static_cast<int>(status) << '\n'; } int main() { Image img{'I','M','G'}; auto result = ApplyFilter(img, Filter::Sepia); if (result) { ShowImage(result.value()); } else { ShowError(result.error()); } }
- 
您可以使用返回 std::expected值的函数通过单调操作and_then()、or_else()、transform()和transform_error()组成操作链:int main() { Image img{'I','M','G'}; ApplyFilter(img, Filter::Sepia) .and_then([](Image result){ return ApplyFilter(result, Filter::Pixelize); }) .and_then([](Image result){ return ApplyFilter(result, Filter::Blur); }) .and_then([](Image result){ ShowImage(result); return std::expected<Image, Status>{result}; }) .or_else([](Status status){ ShowError(status); return std::expected<Image, Status>{std::unexpect, status}; }); }
它是如何工作的…
std::expected<T, E> 类模板可在新的 C++23 头文件 <expected> 中找到。这个类是 std::variant 和 std::optional 类型(C++17 中引入)的混合体,但旨在从函数返回数据或意外值。由于它要么持有预期类型 T 的值,要么持有意外类型(错误)E 的值,因此它具有判别联合的逻辑结构。然而,它的接口与 std::optional 类非常相似,因为它具有以下成员:
| 函数 | 描述 | 
|---|---|
| has_value() | 返回一个布尔值,指示对象是否包含预期的值。 | 
| operator bool | 与 has_value()相同。提供用于在if语句中更简单使用的功能(if(result)相对于if(result.has_value()))。 | 
| value() | 返回预期的值,除非对象包含意外的值。在这种情况下,它抛出一个包含意外值的 std::bad_expected_access<E>异常。 | 
| value_or() | 与 value()类似,但如果对象中存储了意外的值,它不会抛出异常,而是返回提供的替代值。 | 
| error() | 返回意外的值。如果对象包含期望值,则行为未定义。 | 
| operator->和operator* | 访问期望值。如果对象包含意外的值,则行为未定义。 | 
表 6.2:std::expected 最重要成员的列表
虽然之前提到 std::expected 类型是两个 T(期望)和 E(错误)类型的区分联合,但这并不完全正确。它实际持有的类型要么是 T,要么是 std::unexpected<E>。后者是一个辅助类,用于持有类型为 E 的对象。对 T 和 E 可用类型的有些限制:
- 
T可以是void或可销毁的类型(可以调用析构函数的类型)。不能替换T的类型是数组和引用类型。如果类型T是void类型,则value_or()方法不可用。
- 
E必须是可销毁的类型。数组、引用类型以及const和volatile标记的类型不能替换E。
有时你想要对一个值应用多个操作。在我们的例子中,这可能是对图像连续应用不同的过滤器。但这也可能是其他事情,比如调整图像大小、更改格式/类型、在不同方向上翻转等。每个这些操作都可能返回一个 std::expected 值。在这种情况下,我们可以编写如下代码:
int main()
{
   Image img{'I','M','G'};
   auto result = ApplyFilter(img, Filter::Sepia);
   result = ApplyFilter(result.value(), Filter::Pixelize);
   result = ApplyFilter(result.value(), Filter::Blur);
   result = FlipHorizontally(result.value());
   if (result)
   {
      ShowImage(result.value());
   }
   else
   {
      ShowError(result.error());
   }
} 
如果没有发生错误,则运行此程序的结果如下:
Applying sepia
Applying pixelize
Applying blur
[img]:BPSGMI 
然而,如果在 ApplyFilter() 函数中发生错误,后续调用中调用 value() 方法将导致 std::bad_expected_access 异常。实际上,我们必须在每次操作后检查结果。这可以使用单一操作来改进。
由于 std::expected 类型与 std::optional 类型非常相似,C++23 中为 std::optional 提供的单一操作也适用于 std::expected。以下是一些操作:
| 函数 | 描述 | 
|---|---|
| and_then() | 如果 std::expected对象包含期望值(类型为T),则对它应用给定的函数并返回结果。否则,返回std::expected值。 | 
| or_else() | 如果 std::expected对象包含意外的值(类型为E),则对意外的值应用给定的函数并返回结果。否则,返回std::expected值。 | 
| transform() | 这与 and_then()类似,但返回的值也被封装在一个std::expected值中。 | 
| transform_error() | 这与 or_else()类似,但返回的值也被封装在一个std::expected值中。 | 
表 6.3:std::expected 的单一操作
我们可以使用单一操作重写上一个代码片段,如下所示:
int main()
{
   Image img{'I','M','G'};
   ApplyFilter(img, Filter::Sepia)                
      .and_then([](Image result){
          return ApplyFilter(result, Filter::Pixelize);
      })
      .and_then([](Image result){
          return ApplyFilter(result, Filter::Blur);
      })
      .and_then(FlipHorizontally)       
      .and_then([](Image result){
          ShowImage(result);
          return std::expected<Image, Status>{result};
      })
      .or_else([](Status status){
          ShowError(status);
          return std::expected<Image, Status>{std::unexpect, status};
      });
} 
如果没有发生错误,则输出将是我们已经看到的那个。然而,如果发生错误,比如说在应用棕褐色滤镜时,输出将变为以下内容:
Applying sepia
Error code: 3 
此示例仅展示了可用的两种单子操作,and_then() 和 or_else()。其他两种,transform() 和 transform_or(),功能相似,但它们旨在将(正如其名称所暗示的)预期值或意外值转换为另一个值。在以下代码片段(对上一个代码片段的修改)中,我们为预期值和意外值都链式调用了转换操作,在任一情况下都返回一个字符串:
int main()
{
   Image img{'I','M','G'};
   auto obj = ApplyFilter(img, Filter::Sepia)                
      .and_then([](Image result){
          return ApplyFilter(result, Filter::Pixelize);
      })
      .and_then([](Image result){
          return ApplyFilter(result, Filter::Blur);
      })
      .and_then(FlipHorizontally)       
      .and_then([](Image result){
          ShowImage(result);
          return std::expected<Image, Status>{result};
      })
      .or_else([](Status status){
          ShowError(status);
          return std::expected<Image, Status>{std::unexpect, status};
      })       
      .transform([](Image result){
          std::stringstream s;
          s << std::quoted(std::string(result.begin(), 
                                       result.end()));
          return s.str();
      })
      .transform_error([](Status status){
          return status == Status::Success ? "success" : "fail";
      });
    if(obj)
       std::cout << obj.value() << '\n';
    else
       std::cout << obj.error() << '\n';
} 
如果程序执行过程中没有发生错误,则将打印以下输出:
Applying sepia
Applying pixelize
Applying blur
[img]:BPSGMI
"BPSGMI" 
然而,如果在执行过程中发生错误,例如在应用棕褐色滤镜时,输出将变为以下内容:
Applying sepia
Error code: 3
fail 
在上面的 or_else() 函数中,你会注意到 std::unexpected 的使用。这是一个辅助类,它作为 std::expected 构造函数的标签,以指示意外值的构造。因此,参数被完美转发到 E 类型(意外类型)的构造函数中。has_value() 方法将返回 false 对于新创建的 std::expected 值,表示它包含一个意外值。
参见
- 
使用 std::optional存储可选值,了解 C++17 类模板std::optional,它管理可能存在或不存在的数据值
- 
使用 std::variant作为类型安全的联合体,了解如何使用 C++17std::variant类来表示类型安全的联合体
使用 std::span 对象的连续序列
在 C++17 中,std::string_view 类型被添加到标准库中。这是一个表示对字符连续序列视图的对象。视图通常使用指向序列第一个元素的指针和长度来实现。字符串是任何编程语言中最常用的数据类型之一。它们有一个非拥有视图,不分配内存,避免复制,并且比 std::string 执行某些操作更快,这是一个重要的好处。然而,字符串只是一个具有特定于文本操作的字符特殊向量。因此,有一个类型,它是对连续序列对象的视图,无论它们的类型如何,这是有意义的。这就是 C++20 中的 std::span 类模板所代表的。我们可以这样说,std::span 对于 std::vector 和数组类型来说,就像 std::string_view 对于 std::string 一样。
准备工作
std::span 类模板在头文件 <span> 中可用。
如何做到这一点…
使用 std::span<T> 而不是指针和大小对,就像在 C 类接口中通常所做的那样。换句话说,替换如下函数:
void func(int* buffer, size_t length) { /* ... */ } 
如此:
void func(std::span<int> buffer) { /* ... */ } 
当使用 std::span 时,你可以做以下操作:
- 
通过指定跨度中的元素数量来创建具有编译时长度(称为静态范围)的跨度: int arr[] = {1, 1, 2, 3, 5, 8, 13}; std::span<int, 7> s {arr};
- 
通过不指定跨度中的元素数量来创建具有运行时长度(称为 动态范围)的跨度: int arr[] = {1, 1, 2, 3, 5, 8, 13}; std::span<int> s {arr};
- 
您可以使用跨度在基于范围的 for 循环中: void func(std::span<int> buffer) { for(auto const e : buffer) std::cout << e << ' '; std::cout << '\n'; }
- 
您可以使用 front()、back()、data()方法和operator[]来访问跨度的元素:int arr[] = {1, 1, 2, 3, 5, 8, 13}; std::span<int, 7> s {arr}; std::cout << s.front() << " == " << s[0] << '\n'; // prints 1 == 1 std::cout << s.back() << " == " << s[s.size() - 1] << '\n'; // prints 13 == 13 std::cout << *s.data() << '\n'; // prints 1
- 
您可以使用 first()、last()和subspan()方法从跨度中获取子跨度:std::span<int> first_3 = s.first(3); func(first_3); // 1 1 2. std::span<int> last_3 = s.last(3); func(last_3); // 5 8 13 std::span<int> mid_3 = s.subspan(2, 3); func(mid_3); // 2 3 5
它是如何工作的...
std::span 类模板不是一个对象的容器,而是一个轻量级包装器,它定义了一个连续对象序列的视图。最初,跨度被称为 array_view,有人认为这是一个更好的名称,因为它清楚地表明该类型是序列的非拥有视图,并且它与 string_view 的名称保持一致。然而,该类型是在标准库中以 span 的名称采用的。
尽管标准没有指定实现细节,跨度通常通过存储指向序列第一个元素的指针和一个长度来实现,该长度表示视图中的元素数量。因此,跨度可以用来定义对(但不限于)std::vector、std::array、T[] 或 T* 的非拥有视图。然而,它不能与列表或关联容器(例如,std::list、std::map 或 std::set)一起使用,因为这些不是连续元素序列的容器。
跨度可以具有编译时大小或运行时大小。当跨度的元素数量在编译时指定时,我们有一个具有静态范围(编译时大小)的跨度。如果元素数量未指定但在运行时确定,我们有一个动态范围。
std::span 类具有简单的接口,主要由以下成员组成:
| 成员函数 | 描述 | 
|---|---|
| begin(),end(),cbegin(),cend() | 可变和常量迭代器,分别指向序列的第一个元素和最后一个元素之后的元素。 | 
| rbegin(),rend(),cbegin(),crend() | 可变和常量反向迭代器,分别指向序列的开始和结束。 | 
| front(),back() | 访问序列的第一个和最后一个元素。 | 
| data() | 返回指向序列元素开头的指针。 | 
| operator[] | 通过其索引访问序列中的元素。 | 
| size() | 获取序列中的元素数量。 | 
| size_bytes() | 获取序列的字节数。 | 
| empty() | 检查序列是否为空。 | 
| first() | 获取序列中前 N 个元素的子跨度。 | 
| last() | 获取序列中最后 N 个元素的子跨度。 | 
| subspan() | 从指定的偏移量开始获取具有 N 个元素的子跨度。如果未指定计数 N,则返回从偏移量到序列末尾的所有元素的跨度。 | 
表 6.4:std::span 最重要成员函数列表
Span 不适用于使用一对迭代器(指向范围的开始和结束)的通用算法(如 sort、copy、find_if 等),也不适用于标准容器的替代品。其主要目的是构建比传递指针和大小到函数的 C 类接口更好的接口。用户可能会传递错误的大小值,这可能导致访问序列之外的内存。Span 提供了安全和边界检查。它也是将常量引用作为函数参数传递给 std::vector<T> (std::vector<T> const &) 的良好替代品。Span 不拥有其元素,足够小,可以按值传递(你不应该通过引用或常量引用传递 Span)。
与不支持更改序列中元素值的 std::string_view 不同,std::span 定义了一个可变视图,并支持修改其元素。为此,函数如 front()、back() 和 operator[] 返回一个引用。
参见
- 
第二章,使用 std::string_view而不是常量字符串引用,了解如何使用std::string_view在处理字符串时在某些场景中提高性能
- 
使用 std::mdspan对对象序列的多维视图进行操作,了解 C++23 的多维序列 span 类
使用 std::mdspan 对对象序列的多维视图
在之前的菜谱中,使用 std::span 对连续对象序列进行操作,我们学习了 C++20 类 std::span,它表示一个连续元素序列的视图(一个非拥有包装器)。这与 C++17 类 std::string_view 类似,它执行相同的操作,但针对字符序列。这两个都是一维序列的视图。然而,有时我们需要处理多维序列。这些可以通过多种方式实现,例如 C 类数组 (int[2][3][4])、指针的指针 (int** 或 int***)、数组数组(或向量向量,如 vector<vector<vector<int>>>)。另一种方法是使用一维对象序列,但定义操作将其呈现为逻辑上的多维序列。这正是 C++23 std::mdspan 类所做的:它表示一个作为多维序列呈现的连续对象序列的非拥有视图。我们可以这样说,std::mdspan 是 std::span 类的多维视图扩展。
准备工作
在本菜谱中,我们将参考以下二维矩阵(其大小在编译时已知)的简单实现:
template <typename T, std::size_t ROWS, std::size_t COLS>
struct matrix
{
   T& 
#ifdef __cpp_multidimensional_subscript
operator[] // C++23
#else
operator() // previously
#endif
   (std::size_t const r, std::size_t const c)
   {
      if (r >= ROWS || c >= COLS)
         throw std::runtime_error("Invalid index");
      return data[r * COLS + c];
   }
   T const & 
#ifdef __cpp_multidimensional_subscript
operator[] // C++23
#else
operator() // previously
#endif
   (std::size_t const r, std::size_t const c) const
   {
      if (r >= ROWS || c >= COLS)
         throw std::runtime_error("Invalid index");
      return data[r * COLS + c];
   }
   std::size_t size() const { return data.size(); }
   std::size_t empty() const { return data.empty(); }
   template <std::size_t dimension>
   std::size_t extent() const
 {
      static_assert(dimension <= 1, 
                    "The matrix only has two dimensions.");
      if constexpr (dimension == 0) return ROWS;
      else if constexpr(dimension == 1) return COLS;
   }
private:
   std::array<T, ROWS* COLS> data;
}; 
在 C++23 中,你应该优先使用 operator[] 而不是 operator() 来访问多维数据结构的元素。
如何实现...
更倾向于使用 std::mdspan 而不是多维 C 样式的数组、指针的指针或向量-向量/数组-数组实现。换句话说,替换如下函数:
void f(int data[2][3]) { /* … */ }
void g(int** data, size_t row, size_t cols) { /* … */ }
void h(std::vector<std::vector<int>> dat, size_t row, size_t cols)
{ /* … */ } 
使用以下方法:
void f(std::mdspan<int,std::extents<size_t, 2, 3>> data) 
{ /* … */ } 
当与 std::mdspan 一起工作时,你可以做以下操作:
- 
通过指定跨度每个维度的元素数量来创建具有编译时长度(称为静态范围)的 mdspan:int* data = get_data(); std::mdspan<int, std::extents<size_t, 2, 3>> m(data);
- 
通过不在编译时指定跨度维度中元素的数量,而是在运行时提供它来创建具有运行时长度(称为动态范围)的 mdspan:int* data = get_data(); std::mdspan<int, std::extents<size_t, 2, std::dynamic_extent>> mv{v.data(), 3};或者 int* data = get_data(); std::mdspan<int, std::extents<size_t, std::dynamic_extent, std::dynamic_extent>> m(data, 2, 3);或者 int* data = get_data(); std::mdspan m(data, 2, 3);
- 
要控制 mdspan的多维索引到底层(连续)数据序列的一维索引的映射,请使用布局策略,这是第三个模板参数:std::mdspan<int, std::extents<size_t, 2, 3>, std::layout_right> mv{ data };或者 std::mdspan<int, std::extents<size_t, 2, 3>, std::layout_left> mv{ data };或者 std::mdspan<int, std::extents<size_t, 2, 3>, std::layout_stride> mv{ data };
它是如何工作的……
如其名所示,mdspan 是一个多维跨度。这是一个非拥有视图,它将一维值序列投影为逻辑的多维结构。这是我们之前在 准备就绪 部分看到的内容,在那里我们定义了一个名为 matrix 的类,它表示一个二维矩阵。它定义的操作(如 C++23 中的 operator() 和/或 operator[])是特定于 2D 数据结构的。然而,在内部,数据以连续序列的形式排列,在我们的实现中是一个 std::array。我们可以如下使用这个类:
matrix<int, 2, 3> m;
for (std::size_t r = 0; r < m.extent<0>(); r++)
{
   for (std::size_t c = 0; c < m.extent<1>(); c++)
   {
      m[r, c] = r * m.extent<1>() + c + 1; // m[r,c] in C++23
// m(r, c) previously
   }
} 
这个 for-in-for 循环将矩阵元素的值设置为以下:
1 2 3
4 5 6 
在 C++23 中,我们可以简单地用 std::mdspan 类替换整个类:
std::array<int, 6> arr;
std::mdspan m{arr.data(), std::extents{2, 3}};
for (std::size_t r = 0; r < m.extent(0); r++)
{
   for (std::size_t c = 0; c < m.extent(1); c++)
   {
      m[r, c] = r * m.extent(1) + c + 1;
   }
} 
这里唯一改变的是 extent() 方法的使用,它之前是 matrix 类的一个函数模板成员。然而,这只是一个细节。实际上,我们可以将 matrix 定义为一个别名模板,如下所示:
template <typename T, std::size_t ROWS, std::size_t COLS>
using matrix = std::mdspan<T, std::extents<std::size_t, ROWS, COLS>>;
std::array<int, 6> arr;
matrix<int, 2, 3> ma {arr.data()}; 
在这个例子中,mdspan 是二维的,但它可以定义在任何数量的维度上。mdspan 类型的接口包括以下成员:
| 名称 | 描述 | 
|---|---|
| operator[] | 提供对底层数据的访问。 | 
| size() | 返回元素的数量。 | 
| empty() | 指示元素数量是否为零。 | 
| stride() | 返回指定维度的步长。除非明确自定义,否则默认为 1。 | 
| extents() | 返回指定维度的尺寸(范围)。 | 
表 6.5:mdspan 的一些成员函数列表
如果你查看 std::mdspan 类的定义,你会看到以下内容:
template<class T,
         class Extents,
         class LayoutPolicy = std::layout_right,
         class AccessorPolicy = std::default_accessor<T>>
class mdspan; 
前两个模板参数是元素类型和每个维度的范围(大小)。我们在前面的例子中看到了这些。然而,最后两个是定制点:
- 
布局策略控制 mdspan的多维索引如何映射到一维底层数据的偏移量。有几种选项可供选择:layout_right(默认)表示最右边的索引提供对底层内存的步长为 1 的访问(这是 C/C++风格);layout_left表示最左边的索引提供对底层内存的步长为 1 的访问(这是 Fortran 和 Matlab 风格);以及layout_stride,它泛化了前两种,并允许在每个范围上自定义步长。拥有布局策略的原因是与其他语言互操作以及在不改变算法循环结构的情况下更改算法的数据访问模式。
- 
访问策略定义了底层序列如何存储其元素以及如何使用布局策略的偏移量来获取存储元素的引用。这些主要用于第三方库。对于 std::mdspan实现访问策略的可能性不大,正如定义std::vector的分配器一样不太可能。
让我们举例说明布局策略,以了解它们是如何工作的。默认的是std::layout_right。我们可以考虑这个例子,它明确指定了策略:
std::vector v {1,2,3,4,5,6,7,8,9};
std::mdspan<int, 
            std::extents<size_t, 2, 3>,
            std::layout_right> mv{v.data()}; 
这里定义的二维矩阵具有以下内容:
1 2 3
4 5 6 
然而,如果我们将布局策略更改为std::layout_left,那么内容也会更改为以下:
1 3 5
2 4 6 
defines a stride equivalent to the std::layout_right, for the 2x3 matrix we have seen so far:
std::mdspan<int, 
            std::extents<size_t, 
                         std::dynamic_extent, 
                         std::dynamic_extent>, 
            std::layout_stride> 
mv{ v.data(), { std::dextents<size_t,2>{2, 3}, 
                std::array<std::size_t, 2>{3, 1}}}; 
然而,不同的步长提供不同的结果。以下表格中显示了几个示例:
| 步长 | 矩阵 | 
|---|---|
| 1 1 11 1 1 | |
| 1 2 31 2 3 | |
| 1 1 12 2 2 | |
| 1 2 32 3 4 | |
| 1 2 33 4 5 | |
| 1 3 52 4 6 | |
| 1 4 73 6 9 | 
表 6.6:自定义步长和结果视图的内容示例
让我们讨论最后一个例子,它可能更为通用。第一个范围步长代表行的偏移增量。第一个元素在底层序列中的索引为 0。因此,步长为 2,如本例所示,表示从索引 0、2、4 等读取行。第二个范围步长代表列的偏移增量。第一个元素对应行的索引。在这个例子中,第一行的索引为 0,因此列的步长为 3 意味着第一行的元素将从索引 0、3 和 6 读取。第二行从索引 2 开始。因此,第二行的元素将从索引 2、5 和 8 读取。这是前表中显示的最后一个例子。
还有更多...
mdspan的原始提案包括一个名为submdspan()的免费函数。此函数创建一个mdspan的切片,或者说,是mdspan子集的视图。为了使mdspan能够包含在 C++23 中,此函数被移除并移至 C++26。在撰写本书时,它已经包含在 C++26 中,尽管还没有编译器支持它。
参见
- 使用std::span处理连续对象序列,了解如何使用对连续元素序列的非拥有视图
注册在程序正常退出时被调用的函数
程序在退出时通常需要清理代码以释放资源,向日志中写入内容,或者执行其他结束操作。标准库提供了两个实用函数,使我们能够注册在程序正常终止时被调用的函数,无论是通过从main()返回还是通过调用std::exit()或std::quick_exit()。这对于需要在程序终止前执行操作而无需用户显式调用结束函数的库特别有用。在本食谱中,你将学习如何安装退出处理程序以及它们是如何工作的。
准备工作
本食谱中讨论的所有函数,exit()、quick_exit()、atexit()和at_quick_exit(),都可在<cstdlib>头文件中std命名空间中找到。
如何操作...
要注册在程序终止时被调用的函数,你应该使用以下方法:
- 
使用 std::atexit()注册在从main()返回或调用std::exit()时被调用的函数:void exit_handler_1() { std::cout << "exit handler 1" << '\n'; } void exit_handler_2() { std::cout << "exit handler 2" << '\n'; } std::atexit(exit_handler_1); std::atexit(exit_handler_2); std::atexit([]() {std::cout << "exit handler 3" << '\n'; });
- 
使用 std::at_quick_exit()注册在调用std::quick_exit()时被调用的函数:void quick_exit_handler_1() { std::cout << "quick exit handler 1" << '\n'; } void quick_exit_handler_2() { std::cout << "quick exit handler 2" << '\n'; } std::at_quick_exit(quick_exit_handler_1); std::at_quick_exit(quick_exit_handler_2); std::at_quick_exit([]() { std::cout << "quick exit handler 3" << '\n'; });
它是如何工作的...
不论使用何种方法注册的退出处理程序,只有在程序正常或快速终止时才会被调用。如果以异常方式终止,通过调用std::terminate()或std::abort(),则它们都不会被调用。如果任何处理程序通过异常退出,则调用std::terminate()。退出处理程序不得有任何参数,并且必须返回void。一旦注册,退出处理程序就不能取消注册。
程序可以安装多个处理程序。标准保证每种方法至少可以注册 32 个处理程序,尽管实际实现可以支持任何更高的数字。std::atexit()和std::at_quick_exit()都是线程安全的,因此可以从不同的线程同时调用,而不会产生竞争条件。
如果注册了多个处理程序,则它们将按照注册的相反顺序被调用。以下表格显示了注册了退出处理程序的程序(如前节所示)在通过std::exit()调用和std::quick_exit()调用终止时的输出:
| std::exit(0); | std::quick_exit(0); | 
|---|
|
exit handler 3
exit handler 2
exit handler 1 
|
quick exit handler 3
quick exit handler 2
quick exit handler 1 
|
表 6.7:当由于调用 exit()和 quick_exit()而退出时,前一个代码片段的输出
另一方面,在程序正常终止时,具有局部存储期的对象的析构、具有静态存储期的对象的析构以及调用已注册的退出处理程序是并发执行的。然而,可以保证在静态对象的构造之前注册的退出处理程序将在该静态对象析构之后调用,而在静态对象构造之后注册的退出处理程序将在该静态对象析构之前调用。
为了更好地说明这一点,让我们考虑以下类:
struct static_foo
{
  ~static_foo() { std::cout << "static foo destroyed!" << '\n'; }
  static static_foo* instance()
 {
    static static_foo obj;
    return &obj;
  }
}; 
在这个上下文中,我们将引用以下代码片段:
std::atexit(exit_handler_1);
static_foo::instance();
std::atexit(exit_handler_2);
std::atexit([]() {std::cout << "exit handler 3" << '\n'; });
std::exit(42); 
exit_handler_1 is registered before the creation of the static object static_foo. On the other hand, exit_handler_2 and the lambda expression are both registered, in that order, after the static object was constructed. As a result, the order of calls at normal termination is as follows:
- 
Lambda 表达式 
- 
exit_handler_2
- 
static_foo的析构函数
- 
exit_handler_1
上一程序的输出如下所示:
exit handler 3
exit handler 2
static foo destroyed!
exit handler 1 
当使用std::at_quick_exit()时,在正常程序终止的情况下,不会调用已注册的函数。如果需要在那种情况下调用函数,您必须使用std::atexit()来注册它。
参见
- 第三章,使用标准算法与 lambda 表达式,以探索 lambda 表达式的基础知识以及如何利用它们与标准算法
使用类型特性查询类型的属性
模板元编程是语言的一个强大功能,它使我们能够编写和重用适用于所有类型的通用代码。在实践中,通常有必要使通用代码对不同的类型工作方式不同,或者根本不工作,无论是出于意图还是为了语义正确性、性能或其他原因。例如,您可能希望通用算法对 POD 和非 POD 类型有不同的实现,或者函数模板仅对整数类型进行实例化。C++11 提供了一套类型特性来帮助解决这个问题。
类型特性基本上是元类型,它们提供了关于其他类型的信息。类型特性库包含了一个用于查询类型属性(例如检查一个类型是否是整数类型或两个类型是否相同)的特性和类型转换(例如移除const和volatile限定符或向类型添加指针)的长列表。我们已经在本书的几个配方中使用了类型特性;然而,在这个配方中,我们将探讨类型特性是什么以及它们是如何工作的。
准备工作
在 C++11 中引入的所有类型特性都在<type_traits>头文件中的std命名空间中可用。
类型特性可以在许多元编程上下文中使用,并且在这本书中,我们已经看到它们在各种情况下被使用。在这个配方中,我们将总结一些这些用例,并了解类型特性是如何工作的。
在这个配方中,我们将讨论完全和部分模板特化。对这些概念的了解将帮助您更好地理解类型特性的工作方式。
如何做...
以下列表显示了使用类型特性实现各种设计目标的各种情况:
- 
使用 enable_if来定义函数模板可以实例化的类型的先决条件:template <typename T, typename = typename std::enable_if_t< std::is_arithmetic_v<T> > > T multiply(T const t1, T const t2) { return t1 * t2; } auto v1 = multiply(42.0, 1.5); // OK auto v2 = multiply("42"s, "1.5"s); // error
- 
使用 static_assert来确保满足不变性:template <typename T> struct pod_wrapper { static_assert(std::is_standard_layout_v<T> && std::is_trivial_v<T>, "Type is not a POD!"); T value; }; pod_wrapper<int> i{ 42 }; // OK pod_wrapper<std::string> s{ "42"s }; // error
- 
使用 std::conditional在类型之间进行选择:template <typename T> struct const_wrapper { typedef typename std::conditional_t< std::is_const_v<T>, T, typename std::add_const_t<T>> const_type; }; static_assert( std::is_const_v<const_wrapper<int>::const_type>); static_assert( std::is_const_v<const_wrapper<int const>::const_type>);
- 
使用 constexpr if来使编译器能够根据模板实例化的类型生成不同的代码:template <typename T> auto process(T arg) { if constexpr (std::is_same_v<T, bool>) return !arg; else if constexpr (std::is_integral_v<T>) return -arg; else if constexpr (std::is_floating_point_v<T>) return std::abs(arg); else return arg; } auto v1 = process(false); // v1 = true auto v2 = process(42); // v2 = -42 auto v3 = process(-42.0); // v3 = 42.0 auto v4 = process("42"s); // v4 = "42"
它是如何工作的...
类型特性是提供关于类型或可以用来修改类型的元信息的类。实际上有两种类型的类型特性:
- 
提供有关类型、其属性或其关系信息(如 is_integer、is_arithmetic、is_array、is_enum、is_class、is_const、is_trivial、is_standard_layout、is_constructible、is_same等)的特性。这些特性提供了一个名为value的const bool成员。
- 
修改类型属性的特性(如 add_const、remove_const、add_pointer、remove_pointer、make_signed、make_unsigned等)。这些特性提供了一个名为type的成员 typedef,它表示转换后的类型。
这两类类型已经在 如何做... 部分中展示过;示例在其他菜谱中已经详细讨论和解释。为了方便,这里提供了一个简短的总结:
- 
在第一个示例中,函数模板 multiply()只允许用算术类型(即整数或浮点数)实例化;当用不同类型的类型实例化时,enable_if不会定义一个名为type的 typedef 成员,这将产生编译错误。
- 
在第二个示例中, pod_wrapper是一个类模板,它应该只使用 POD 类型实例化。如果使用非 POD 类型(它既不是平凡的也不是标准布局),static_assert声明将产生编译错误。
- 
在第三个示例中, const_wrapper是一个类模板,它提供了一个名为const_type的 typedef 成员,它表示一个常量合格类型。
- 
在这个示例中,我们使用了 std::conditional在编译时选择两种类型:如果类型参数T已经是一个 const 类型,那么我们只选择T。否则,我们使用add_const类型特性用const说明符修饰类型。
- 
在第四个示例中, process()是一个包含一系列if constexpr分支的函数模板。根据在编译时通过各种类型特性(如is_same、is_integer、is_floating_point)查询到的类型类别,编译器只会选择一个分支放入生成的代码中,其余的将被丢弃。因此,像process(42)这样的调用将产生以下函数模板的实例化:int process(int arg) { return -arg; }
类型特性是通过提供一个类模板及其部分或完全特化来实现的。以下是一些类型特性的概念实现示例:
- 
is_void()方法指示一个类型是否为void;这使用了完全特化:template <typename T> struct is_void { static const bool value = false; }; template <> struct is_void<void> { static const bool value = true; };
- 
is_pointer()方法指示一个类型是否是指向对象的指针或指向函数的指针;这使用了部分特化:template <typename T> struct is_pointer { static const bool value = false; }; template <typename T> struct is_pointer<T*> { static const bool value = true; };
- 
enable_if()类型特质仅在非类型模板参数是一个评估为true的表达式时,为其类型模板参数定义一个类型别名:template<bool B, typename T = void> struct enable_if {}; template<typename T> struct enable_if<true, T> { using type = T; };
由于查询属性(如std::is_integer<int>::value)的特质或修改类型属性的特质(如std::enable_if<true, T>::type)中使用的bool成员value太冗长(且长),C++14 和 C++17 标准引入了一些辅助工具以简化使用:
- 
形式为 std::trait_v<T>的变量模板是std::trait<T>::value的别名。一个例子是std::is_integer_v<T>,其定义如下:template <typename T> inline constexpr bool is_integral_v = is_integral<T>::value;
- 
std::trait_t<T>形式的别名模板是std::trait<T>::type的别名。一个例子是std::enable_if_t<B, T>,其定义如下:template <bool B, typename T = void> using enable_if_t = typename enable_if<B,T>::type;
注意,在 C++20 中,POD 类型的概念已被弃用。这还包括std::is_pod类型特质的弃用。POD 类型是一种既是平凡的(具有编译器提供的或显式默认的特殊成员,并占用连续的内存区域)又具有标准布局(不包含与 C 语言不兼容的语言特性,如虚函数,并且所有成员具有相同的访问控制)的类型。因此,从 C++20 开始,更精细的平凡和标准布局类型概念更受欢迎。这也意味着您不应再使用std::is_pod,而应使用std::is_trivial和std::is_standard_layout。
更多...
类型特质不仅限于标准库提供的。使用类似的技术,您可以定义自己的类型特质以实现各种目标。在下一道菜谱编写自己的类型特质中,我们将学习如何定义和使用自己的类型特质。
参见
- 
第四章,使用 constexpr if 在编译时选择分支,了解如何仅使用 constexpr if语句编译代码的一部分
- 
第四章,使用 enable_if 条件编译类和函数,了解 SFINAE 以及如何使用它为模板指定类型约束 
- 
第四章,使用 static_assert 进行编译时断言检查,了解如何定义在编译时验证的断言 
- 
编写自己的类型特质,了解如何定义自己的类型特质 
- 
使用 std::conditional在类型之间进行选择,了解如何在编译时布尔表达式中执行类型的编译时选择
编写自己的类型特质
在前面的菜谱中,我们学习了类型特质是什么,标准提供了哪些特质,以及它们如何用于各种目的。在本菜谱中,我们将更进一步,看看如何定义我们自己的自定义特质。
准备工作
在这个菜谱中,我们将学习如何解决以下问题:我们有一些支持序列化的类。不深入细节,假设其中一些提供了一种“纯”序列化到字符串的方式(无论这意味着什么),而其他一些基于指定的编码进行序列化。最终目标是创建一个单一、统一的 API 来序列化任何这些类型的对象。为此,我们将考虑以下两个类:提供简单序列化的 foo 类,以及提供带编码序列化的 bar 类。
让我们看看代码:
struct foo
{
  std::string serialize()
 {
    return "plain"s;
  }
};
struct bar
{
  std::string serialize_with_encoding()
 {
    return "encoded"s;
  }
}; 
建议你在继续阅读本菜谱之前,首先阅读前面的 使用类型特性查询类型的属性 菜谱。
如何实现...
实现以下类和函数模板:
- 
一个名为 is_serializable_with_encoding的类模板,其中包含一个设置为false的static const bool变量:template <typename T> struct is_serializable_with_encoding { static const bool value = false; };
- 
is_serializable_with_encoding模板对类bar的完全特化,其中static const bool变量设置为true:template <> struct is_serializable_with_encoding<bar> { static const bool value = true; };
- 
一个名为 serializer的类模板,其中包含一个名为serialize的静态模板方法,它接受一个模板类型T的参数,并调用该对象的serialize():template <bool b> struct serializer { template <typename T> static auto serialize(T& v) { return v.serialize(); } };
- 
一个名为 true的完全特化类模板,其serialize()静态方法为参数调用serialize_with_encoding():template <> struct serializer<true> { template <typename T> static auto serialize(T& v) { return v.serialize_with_encoding(); } };
- 
一个名为 serialize()的函数模板,它使用之前定义的serializer类模板和is_serializable_with_encoding类型特性,来选择应该调用实际的哪种序列化方法(纯或带编码):template <typename T> auto serialize(T& v) { return serializer<is_serializable_with_encoding<T>::value>:: serialize(v); }
工作原理...
is_serializable_with_encoding 是一个类型特性,用于检查类型 T 是否可以使用(指定的)编码进行序列化。它提供了一个类型 bool 的静态成员,名为 value,如果 T 支持使用编码进行序列化,则其值等于 true,否则为 false。它被实现为一个具有单个类型模板参数 T 的类模板;这个类模板对支持编码序列化的类型进行了完全特化——在这个特定例子中,对类 bar 进行了特化:
std::cout << std::boolalpha;
std::cout <<
  is_serializable_with_encoding<foo>::value << '\n';        // false
std::cout <<
  is_serializable_with_encoding<bar>::value << '\n';        // true
std::cout <<
  is_serializable_with_encoding<int>::value << '\n';        // false
std::cout <<
  is_serializable_with_encoding<std::string>::value << '\n';// false
std::cout << std::boolalpha; 
serialize() 方法是一个函数模板,它代表了一个支持两种序列化方式的对象的通用 API。它接受一个类型模板参数 T 的单个参数,并使用辅助类模板 serializer 来调用其参数的 serialize() 或 serialize_with_encoding() 方法。
serializer类型是一个只有一个非类型模板参数(类型为bool)的类模板。这个类模板包含一个名为serialize()的静态函数模板。这个函数模板接受一个类型模板参数T的单个参数,对参数调用serialize(),并返回该调用返回的值。serializer类模板对其非类型模板参数的值true有一个完全特化。在这个特化中,函数模板serialize()具有未更改的签名,但调用serialize_with_encoding()而不是serialize()。
在serialize()函数模板中使用is_serializable_with_encoding类型特性来完成使用泛型或完全特化的类模板之间的选择。类型特性中的静态成员value用作serializer的非类型模板参数的参数。
在定义了所有这些之后,我们可以编写以下代码:
foo f;
bar b;
std::cout << serialize(f) << '\n'; // plain
std::cout << serialize(b) << '\n'; // encoded 
serialize() with a foo argument will return the string *plain*, while calling serialize() with a bar argument will return the string *encoded*.
参见
- 
使用类型特性查询类型的属性,探索一种 C++元编程技术,允许我们检查和转换类型的属性 
- 
使用 std::conditional 在类型之间进行选择,了解如何在编译时基于编译时布尔表达式执行类型的编译时选择 
使用 std::conditional 在类型之间进行选择
在前面的配方中,我们查看了一些类型支持库的功能,特别是类型特性。相关主题在其他部分的本章中有所讨论,例如在第四章,预处理和编译中使用std::enable_if来隐藏函数重载,以及在本章讨论访问变体时使用的std::decay来移除const和volatile限定符。另一个值得更深入讨论的类型转换功能是std::conditional,它允许我们根据编译时布尔表达式在编译时选择两种类型。在本配方中,您将通过几个示例了解它是如何工作的以及如何使用它。
准备工作
建议您首先阅读本章前面提到的使用类型特性查询类型的属性配方。
如何做...
以下是一些示例,展示了如何使用在<type_traits>头文件中可用的std::conditional(以及std::conditional_t),在编译时选择两种类型:
- 
在类型别名或 typedef 中,根据平台选择 32 位和 64 位整数类型(在 32 位平台上指针大小为 4 字节,在 64 位平台上为 8 字节): using long_type = std::conditional_t< sizeof(void*) <= 4, long, long long>; auto n = long_type{ 42 };
- 
在别名模板中,根据用户指定(作为一个非类型模板参数)选择 8 位、16 位、32 位或 64 位整数类型: template <int size> using number_type = typename std::conditional_t< size<=1, std::int8_t, typename std::conditional_t< size<=2, std::int16_t, typename std::conditional_t< size<=4, std::int32_t, std::int64_t > > >; auto n = number_type<2>{ 42 }; static_assert(sizeof(number_type<1>) == 1); static_assert(sizeof(number_type<2>) == 2); static_assert(sizeof(number_type<3>) == 4); static_assert(sizeof(number_type<4>) == 4); static_assert(sizeof(number_type<5>) == 8); static_assert(sizeof(number_type<6>) == 8); static_assert(sizeof(number_type<7>) == 8); static_assert(sizeof(number_type<8>) == 8); static_assert(sizeof(number_type<9>) == 8);
- 
在类型模板参数中,根据类型模板参数是整数类型还是实数均匀分布类型来选择,具体取决于类型模板参数是否为整数类型: template <typename T, typename D = std::conditional_t< std::is_integral_v<T>, std::uniform_int_distribution<T>, std::uniform_real_distribution<T>>, typename = typename std::enable_if_t< std::is_arithmetic_v<T>>> std::vector<T> GenerateRandom(T const min, T const max, size_t const size) { std::vector<T> v(size); std::random_device rd{}; std::mt19937 mt{ rd() }; D dist{ min, max }; std::generate(std::begin(v), std::end(v), [&dist, &mt] {return dist(mt); }); return v; } auto v1 = GenerateRandom(1, 10, 10); // integers auto v2 = GenerateRandom(1.0, 10.0, 10); // doubles
它是如何工作的...
std::conditional是一个类模板,它定义了一个名为type的成员,该成员可以是它的两个类型模板参数之一。这个选择是基于作为非类型模板参数的编译时常量布尔表达式提供的。它的实现如下所示:
template<bool Test, class T1, class T2>
struct conditional
{
  typedef T2 type;
};
template<class T1, class T2>
struct conditional<true, T1, T2>
{
  typedef T1 type;
}; 
让我们总结一下上一节中的例子:
- 
在第一个例子中,如果平台是 32 位的,那么指针类型的大小是 4 字节,因此编译时表达式 sizeof(void*) <= 4是true;因此,std::conditional将其成员类型定义为long。如果平台是 64 位的,那么条件评估为false,因为指针类型的大小是 8 字节;因此,成员类型被定义为long long。
- 
在第二个例子中,也遇到了类似的情况,其中多次使用 std::conditional来模拟一系列if...else语句以选择合适的数据类型。
- 
在第三个例子中,我们使用了别名模板 std::conditional_t来简化函数模板GenerateRandom的声明。在这里,std::conditional用于定义表示统计分布的类型模板参数的默认值。根据第一个类型模板参数T是整数类型还是浮点类型,默认分布类型将在std::uniform_int_distribution<T>和std::uniform_real_distribution<T>之间选择。通过使用带有第三个模板参数的std::enable_if来禁用其他类型的使用,正如我们在其他菜谱中已经看到的那样。
为了帮助简化std::conditional的使用,C++14 提供了一个名为std::conditional_t的别名模板,我们在这里已经看到过,其定义如下:
template<bool Test, class T1, class T2>
using conditional_t = typename conditional_t<Test,T1,T2>; 
使用这个辅助类(以及许多其他类似的标准库中的类)是可选的,但有助于编写更简洁的代码。
参见
- 
使用类型特性查询类型的属性,探索一种 C++元编程技术,该技术允许我们检查和转换类型的属性 
- 
编写自己的类型特性,学习如何定义自己的类型特性 
- 
第四章,使用 enable_if 条件编译类和函数,学习 SFINAE 及其如何用于指定模板的类型约束 
使用 source_location 提供日志细节
调试是软件开发的一个基本部分。无论它多么简单或复杂,没有程序会从第一次尝试就按预期工作。因此,开发者会花费大量时间调试他们的代码,使用从调试器到打印到控制台或文本文件的多种工具和技术。有时,我们希望在日志中提供有关消息来源的详细信息,包括文件、行号和可能的功能名。尽管这可以通过一些标准宏实现,但在 C++20 中,一个新的实用类型 std::source_location 允许我们以现代方式完成它。在本食谱中,我们将学习如何实现。
如何做…
要记录包括文件名、行号和函数名的信息,请执行以下操作:
- 
定义一个带有所有需要提供的信息(如消息、严重性等)参数的日志函数。 
- 
添加一个类型为 std::source_location的额外参数(您必须包含<source_location>头文件),默认值为std::source_location::current()。
- 
使用成员函数 file_name()、line()、column()和function_name()来检索调用源的信息。
这里展示了一个这样的日志函数的例子:
void log(std::string_view message, 
         std::source_location const location = std::source_location::current())
{
   std::cout   << location.file_name() << '('
               << location.line() << ':'
               << location.column() << ") '"
               << location.function_name() << "': "
               << message << '\n';
} 
它是如何工作的…
在 C++20 之前,如源文件、行和函数名之类的日志信息只能通过几个宏来实现:
- 
__FILE__,它展开为当前文件的名称
- 
__LINE__,它展开为源文件行号
此外,所有支持的编译器都包括非标准宏,如 __func__ / __FUNCTION__,它们提供当前函数的名称。
使用这些宏,可以编写以下日志函数:
void log(std::string_view message, 
         std::string_view file, 
 int line, 
         std::string_view function)
{
   std::cout << file << '('
             << line << ") '"
             << function << "': "
             << message << '\n';
} 
然而,必须从函数执行的上下文中使用这些宏,如下面的代码片段所示:
int main()
{
   log("This is a log entry!", __FILE__, __LINE__, __FUNCTION__);
} 
运行此函数的结果在控制台上看起来如下:
[...]\source.cpp(23) 'main': This is a log entry! 
C++20 的 std::source_line 由于以下几个原因是一个更好的替代方案:
- 
您不再需要依赖于宏。 
- 
它包括关于列的信息,而不仅仅是行。 
- 
它可以用在日志函数签名中,简化调用过程。 
在 如何做… 部分定义的 log() 函数可以这样调用:
int main()
{
   log("This is a log entry!");
} 
这将产生以下输出:
[...]\source.cpp(23:4) 'int __cdecl main(void)': This is a log entry! 
尽管存在默认构造函数,但它使用默认值初始化数据。要获取正确的值,必须调用静态成员函数 current()。此函数的工作方式如下:
- 
当在函数调用中直接调用时,它使用调用位置的信息初始化数据。 
- 
当用作默认成员初始化器时,它使用初始化数据成员的构造函数聚合初始化的数据位置信息初始化数据。 
- 
当在默认参数(如这里所示的示例)中使用时,它使用调用点的位置初始化数据。 
- 
在其他上下文中使用时,行为是未定义的。 
必须注意,预处理器指令 #line 会改变源代码的行号和文件名。这会影响宏 __FILE__ 和 __LINE__ 返回的值。std::source_location 也以相同的方式受到 #line 指令的影响。
参见
- 使用堆栈跟踪库打印调用栈,了解如何遍历或打印当前堆栈跟踪的内容
使用堆栈跟踪库打印调用序列
在前面的菜谱中,我们看到了如何使用 C++20 std::source_location 为日志记录、测试和调试目的提供源位置信息。另一种调试机制由断言表示,但它们并不总是足够,因为我们经常需要知道导致执行点的调用序列。这被称为堆栈跟踪。C++23 标准包含一个新的诊断实用工具库。这允许我们打印堆栈跟踪。在本菜谱中,您将学习如何使用这些诊断实用工具。
如何做到这一点...
您可以使用 C++23 堆栈跟踪库来:
- 
打印堆栈跟踪的整个内容: std::cout << std::stacktrace::current() << '\n';
- 
遍历堆栈跟踪中的每一帧并打印它: for (auto const & frame : std::stacktrace::current()) { std::cout << frame << '\n'; }
- 
遍历堆栈跟踪中的每一帧并检索其信息: for (auto const& frame : std::stacktrace::current()) { std::cout << frame.source_file() << "("<< frame.source_line() << ")" << ": " << frame.description() << '\n'; }
它是如何工作的...
新的诊断实用工具包含在一个名为 <stacktrace> 的单独头文件中。此头文件包含以下两个类:
- 
std::basic_stacktrace,这是一个表示堆栈跟踪条目序列的类模板。定义了一个类型别名std::stacktrace,作为std::basic_stacktrace<std::allocator<std::stacktrace_entry>>。
- 
std::stacktrace_entry,它表示堆栈跟踪中的一个评估。
在讨论调用序列时,有两个术语需要正确理解:调用栈和堆栈跟踪。调用栈是用于存储运行程序中活动帧(调用)信息的数结构。堆栈跟踪是在某个时间点对调用栈的快照。
虽然 std::basic_stacktrace 是一个容器,但它不是由用户实例化并填充堆栈条目的。堆栈跟踪序列中没有用于添加或删除元素的成员函数;然而,有用于元素访问的成员函数(at() 和 operator[])以及检查大小(capacity()、size() 和 max_size())。为了获取调用栈的快照,您必须调用静态成员函数 current():
std::stacktrace trace = std::stacktrace::current(); 
当前跟踪可以以多种方式打印:
- 
使用重载的 operator<<操作符到一个输出流:std::cout << std::stacktrace::current() << '\n';
- 
使用成员函数 to_string()将其转换为std::string:std::cout << std::to_string(std::stacktrace::current()) << '\n';
- 
使用格式化函数,如 std::format()。请注意,不允许使用任何格式化说明符:auto str = std::format("{}\n", std::stacktrace::current()); std::cout << str;
以下代码片段展示了如何将堆栈跟踪打印到标准输出:
int plus_one(int n)
{
   std::cout << std::stacktrace::current() << '\n';
   return n + 1;
}
int double_n_plus_one(int n)
{
   return plus_one(2 * n);
}
int main()
{
   std::cout << double_n_plus_one(42) << '\n';
} 
运行此程序的结果会根据编译器和目标系统而有所不同,但以下是一个可能的输出示例:
0> [...]\main.cpp(24): chapter06!plus_one+0x4F
1> [...]\main.cpp(37): chapter06!double_n_plus_one+0xE
2> [...]\main.cpp(61): chapter06!main+0x5F
3> D:\a\_work\1\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl(78): chapter06!invoke_main+0x33
4> D:\a\_work\1\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl(288): chapter06!__scrt_common_main_seh+0x157
5> D:\a\_work\1\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl(331): chapter06!__scrt_common_main+0xD
6> D:\a\_work\1\s\src\vctools\crt\vcstartup\src\startup\exe_main.cpp(17): chapter06!mainCRTStartup+0x8
7> KERNEL32+0x17D59
8> ntdll!RtlInitializeExceptionChain+0x6B
9> ntdll!RtlClearBits+0xBF 
对于如上所示的跟踪条目,我们可以识别出三个部分:源文件、行号和评估的描述。这些内容如下所示:
[...]\main.cpp(24): chapter06!main+0x5F
-------------- --   -------------------
source         line description 
这些部分可以独立获取,使用 std::stacktrace_entry 的成员函数 source_file()、source_line() 和 description()。可以从 stacktrace 容器迭代堆栈跟踪条目的序列,或者使用成员函数 at() 和 operator[] 访问。
参见
- 使用 source_location提供日志详细信息,了解如何使用 C++20 的source_location类来显示有关源文件、行和函数名称的信息
在 Discord 上了解更多
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:
discord.gg/7xRaTCeEhx

第七章:处理文件和流
C++标准库最重要的部分之一是基于输入/输出(I/O)流库,它使开发者能够处理文件、内存流或其他类型的 I/O 设备。本章的第一部分提供了对一些常见流操作(如读取和写入数据、区域设置和操作流的输入和输出)的解决方案。本章的第二部分探讨了 C++17 的filesystem库,它使开发者能够对文件系统及其对象(如文件和目录)执行操作。
本章涵盖的食谱如下:
- 
从/向二进制文件读取和写入原始数据 
- 
从/向二进制文件读取和写入对象 
- 
在固定大小的外部缓冲区上使用流 
- 
使用区域设置流 
- 
使用 I/O 操作符控制流的输出 
- 
使用货币 I/O 操作符 
- 
使用时间 I/O 操作符 
- 
处理文件系统路径 
- 
创建、复制和删除文件和目录 
- 
从文件中删除内容 
- 
检查现有文件或目录的属性 
- 
列出目录内容 
- 
查找文件 
我们将以几个关于如何将数据序列化和反序列化到/从文件的食谱开始本章。
从/向二进制文件读取和写入原始数据
你处理的一些数据程序必须以各种方式持久化到磁盘文件中,包括将数据存储在数据库或平面文件中,无论是文本还是二进制数据。这个食谱和下一个食谱都专注于从和向二进制文件持久化和加载原始数据和对象。
在此上下文中,原始数据是指无结构数据,在本食谱中,我们将考虑写入和读取缓冲区的内容(即连续的内存序列),这可以是数组、std::vector或std::array。
准备工作
对于这个食谱,你应该熟悉标准流 I/O 库,尽管提供了一些解释,以帮助理解这个食谱,你应该也熟悉二进制文件和文本文件之间的区别。
在本食谱中,我们将使用ofstream和ifstream类,这些类在<fstream>头文件中的std命名空间中可用。
如何做到这一点...
要将缓冲区(在我们的例子中,是一个std::vector)的内容写入二进制文件,你应该执行以下步骤:
- 
通过创建 std::ofstream类的实例以二进制模式打开文件流:std::ofstream ofile("sample.bin", std::ios::binary);
- 
在向文件写入数据之前,请确保文件实际上已打开: if(ofile.is_open()) { // streamed file operations }
- 
通过提供字符数组的指针和要写入的字符数,将数据写入文件。在以下示例中,我们写入本地向量的内容;然而,通常,这些数据来自不同的上下文: std::vector<unsigned char> output {0,1,2,3,4,5,6,7,8,9}; ofile.write(reinterpret_cast<char*>(output.data()), output.size());
- 
可选地,你可以通过调用 flush()方法将流输出缓冲区的内容刷新到实际的磁盘文件中。这确定了流中的未提交更改将与外部目标同步,在这种情况下,是一个磁盘文件。
- 
通过调用 close()关闭流。这反过来又调用flush(),在大多数情况下使前面的步骤变得不必要:ofile.close();
为了将整个二进制文件内容读取到缓冲区中,应执行以下步骤:
- 
通过创建 std::ifstream类的实例来打开文件流以以二进制模式读取文件。文件路径可以是绝对路径,也可以是相对于当前工作目录的路径(而不是可执行文件的路径)。在此示例中,路径是相对的:std::ifstream ifile("sample.bin", std::ios::binary);
- 
在从文件中读取数据之前,确保文件实际上已经打开: if(ifile.is_open()) { // streamed file operations }
- 
通过将输入位置指示器定位到文件末尾,读取其值,然后将指示器移到开始位置来确定文件的长度: ifile.seekg(0, std::ios_base::end); auto length = ifile.tellg(); ifile.seekg(0, std::ios_base::beg);
- 
分配内存以读取文件的内容: std::vector<unsigned char> input; input.resize(static_cast<size_t>(length));
- 
通过提供接收数据的字符数组指针和要读取的字符数来将文件内容读取到分配的缓冲区中: ifile.read(reinterpret_cast<char*>(input.data()), length);
- 
检查读取操作是否成功完成: auto success = !ifile.fail() && length == ifile.gcount();
- 
最后,关闭文件流: ifile.close();
它是如何工作的...
标准基于流的 I/O 库提供了各种类,这些类实现了高级输入、输出或输入和输出文件流、字符串流和字符数组操作、控制这些流行为的操纵器,以及几个预定义的流对象(cin/wcin、cout/wcout、cerr/wcerr和clog/wclog)。
这些流被实现为类模板,并且对于文件,库提供了几个(不可复制的)类:
- 
basic_filebuf实现了原始文件的 I/O 操作,其语义与 C 的FILE流类似。
- 
basic_ifstream实现了由basic_istream流接口定义的高级文件流输入操作,内部使用basic_filebuf对象。
- 
basic_ofstream实现了由basic_ostream流接口定义的高级文件流输出操作,内部使用basic_filebuf对象。
- 
basic_fstream实现了由basic_iostream流接口定义的高级文件流输入和输出操作,内部使用basic_filebuf对象。
这些类在以下类图中表示,以更好地理解它们之间的关系:

图 7.1:流类图
注意,此图还包含几个设计用于与基于字符串的流一起工作的类。然而,这里将不会讨论这些流。
在<fstream>头文件中,std命名空间中定义了之前提到的类模板的几个typedef。ofstream和ifstream对象是前面示例中使用类型同义词:
typedef basic_ifstream<char>    ifstream;
typedef basic_ifstream<wchar_t> wifstream;
typedef basic_ofstream<char>    ofstream;
typedef basic_ofstream<wchar_t> wofstream;
typedef basic_fstream<char>     fstream;
typedef basic_fstream<wchar_t>  wfstream; 
在上一节中,您看到了我们如何将原始数据写入文件流并从中读取。现在,我们将更详细地介绍这个过程。
要将数据写入文件,我们实例化了一个类型为 std::ofstream 的对象。在构造函数中,我们传递了要打开的文件名和流的打开模式,我们指定了 std::ios::binary 来表示二进制模式。以这种方式打开文件会丢弃之前的文件内容。如果您想向现有文件追加内容,也应该使用标志 std::ios::app(即 std::ios::app | std::ios::binary)。这个构造函数内部会在其底层的原始文件对象(即 basic_filebuf 对象)上调用 open()。如果此操作失败,则会设置失败位。为了检查流是否已成功关联到文件设备,我们使用了 is_open()(这内部调用底层 basic_filebuf 的同名方法)。向文件流写入数据是通过 write() 方法完成的,该方法接受要写入的字符串字符的指针和要写入的字符数。由于此方法操作的是字符字符串,如果数据是其他类型,例如我们例子中的 unsigned char,则需要使用 reinterpret_cast。在失败的情况下,写入操作不会设置失败位,但它可能会抛出 std::ios_base::failure 异常。然而,数据不是直接写入文件设备,而是存储在 basic_filebuf 对象中。要将数据写入文件,需要刷新缓冲区,这通过调用 flush() 来完成。正如前一个示例所示,在关闭文件流时,这会自动完成。
要从文件中读取数据,我们实例化了一个类型为 std::ifstream 的对象。在构造函数中,我们传递了用于打开文件的相同参数,即文件名和打开模式(即 std::ios::binary)。构造函数内部会在底层的 std::basic_filebuf 对象上调用 open()。为了检查流是否已成功关联到文件设备,我们使用了 is_open()(这内部调用底层 basic_filebuf 的同名方法)。在这个例子中,我们将整个文件内容读取到一个内存缓冲区中,特别是 std::vector。在我们能够读取数据之前,我们必须知道文件的大小,以便分配一个足够大的缓冲区来存储这些数据。为此,我们使用了 seekg() 将输入位置指示器移动到文件末尾。
然后,我们调用tellg()来返回当前位置,在这种情况下,它表示文件的大小(以字节为单位),然后我们将输入位置指示器移动到文件的开始,以便能够从开始读取。为了避免调用seekg()来移动位置指示器到末尾,可以直接将位置指示器移动到末尾来打开文件。这可以通过在构造函数(或open()方法)中使用std::ios::ate打开标志来实现。在为文件内容分配足够的内存后,我们使用read()方法将数据从文件复制到内存中。这需要一个指向接收从流中读取的数据的字符串的指针和要读取的字符数。由于流在字符上操作,如果缓冲区包含其他类型的数据,例如我们例子中的unsigned char,则需要使用reinterpret_cast表达式。
如果发生错误,此操作会抛出std::basic_ios::failure异常。为了确定从流中成功读取的字符数,我们可以使用gcount()方法。在完成读取操作后,我们关闭文件流。
作为此处描述的seekg()/tellg()方法用于确定打开的文件大小的替代方案,可以使用文件系统库中的std::filesystem::file_size()函数。这只需要一个路径;不需要打开文件。它还可以确定目录的大小,但这是由实现定义的。这个函数在章节后面的检查现有文件或目录属性菜谱中介绍。
这些例子中显示的操作是写入和从文件流中读取数据的最低要求操作。然而,执行适当的检查以验证操作的成功并捕获可能发生的任何异常是很重要的。
重要的是要注意表示要写入或读取的字符数的参数值。在迄今为止看到的例子中,我们使用了unsigned char类型的缓冲区。unsigned char的大小是 1,与char相同。因此,字符计数是缓冲区中元素的数量。然而,如果缓冲区包含int类型的元素,例如,情况就会改变。int通常是 32 位的,这意味着,如果重新解释为char,它相当于 4 个字符。这意味着当我们写入大小大于 1 的任何内容时,我们需要将元素的数量乘以元素的大小,如下面的代码片段所示:
std::vector<int> numbers{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
std::ofstream ofile("sample.bin", std::ios::binary);
if (ofile.is_open())
{
   ofile.write(reinterpret_cast<char*>(numbers.data()), 
               numbers.size() * sizeof(int));
   ofile.close();
} 
类似地,当我们读取时,我们需要考虑从文件中读取的元素的大小,这将在下面的例子中说明:
std::vector<int> input;
std::ifstream ifile("sample.bin", std::ios::binary);
if (ifile.is_open())
{
   ifile.seekg(0, std::ios_base::end);
   auto length = ifile.tellg();
   ifile.seekg(0, std::ios_base::beg);
   input.resize(static_cast<size_t>(length) / sizeof(int));
   ifile.read(reinterpret_cast<char*>(input.data()), length);
   assert(!ifile.fail() && length == ifile.gcount());
   ifile.close();
} 
在本菜谱中迄今为止讨论的示例代码可以重新组织成两个通用函数,用于将数据写入和从文件中读取:
bool write_data(char const * const filename,
 char const * const data,
 size_t const size)
{
  auto success = false;
  std::ofstream ofile(filename, std::ios::binary);
  if(ofile.is_open())
  {
    try
    {
      ofile.write(data, size);
      success = true;
    }
    catch(std::ios_base::failure &)
    {
      // handle the error
    }
    ofile.close();
  }
  return success;
}
size_t read_data(char const * const filename,
                 std::function<char*(size_t const)> allocator)
{
  size_t readbytes = 0;
  std::ifstream ifile(filename, std::ios::ate | std::ios::binary);
  if(ifile.is_open())
  {
    auto length = static_cast<size_t>(ifile.tellg());
    ifile.seekg(0, std::ios_base::beg);
    auto buffer = allocator(length);
    try
    {
      ifile.read(buffer, length);
      readbytes = static_cast<size_t>(ifile.gcount());
    }
    catch (std::ios_base::failure &)
    {
      // handle the error
    }
    ifile.close();
  }
  return readbytes;
} 
write_data()是一个函数,它接受文件名、字符数组的指针以及该数组的长度作为参数,并将字符写入指定的文件。read_data()是一个函数,它接受文件名和一个分配缓冲区的函数,该函数读取文件的整个内容到由分配函数返回的缓冲区。以下是如何使用这些函数的示例:
std::vector<int> output {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
std::vector<int> input;
if(write_data("sample.bin",
              reinterpret_cast<char*>(output.data()),
              output.size() * sizeof(int)))
{
  auto lalloc = &input 
  {
    input.resize(length) / sizeof(int);
    return reinterpret_cast<char*>(input.data());
 };
  if(read_data("sample.bin", lalloc) > 0)
  {
    std::cout << (output == input ? "equal": "not equal")
              << '\n';
  }
} 
或者,我们可以使用动态分配的缓冲区,而不是std::vector;在整体示例中,为此所需更改很小:
std::vector<int> output {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
std::unique_ptr<int[]> input = nullptr;
size_t readb = 0;
if(write_data("sample.bin",
              reinterpret_cast<char*>(output.data()),
              output.size() * sizeof(int)))
{
  if((readb = read_data(
     "sample.bin",
     &input {
       input.reset(new int[length / sizeof(int)]);
       return reinterpret_cast<char*>(input.get()); })) > 0)
  {
    auto cmp = memcmp(output.data(), input.get(), output.size());
    std::cout << (cmp == 0 ? "equal": "not equal") << '\n';
  }
} 
然而,这个替代方案只是为了说明read_data()可以与不同类型的输入缓冲区一起使用。建议尽可能避免显式动态分配内存。
更多...
如本食谱所示,从文件读取数据到内存的方式只是几种方法之一。以下是从文件流读取数据的可能替代方案列表:
- 
直接使用 std::istreambuf_iterator迭代器初始化std::vector(类似地,这也可以与std::string一起使用):std::vector<unsigned char> input; std::ifstream ifile("sample.bin", std::ios::binary); if(ifile.is_open()) { input = std::vector<unsigned char>( std::istreambuf_iterator<char>(ifile), std::istreambuf_iterator<char>()); ifile.close(); }
- 
将 std::vector的内容从std::istreambuf_iterator迭代器赋值:std::vector<unsigned char> input; std::ifstream ifile("sample.bin", std::ios::binary); if(ifile.is_open()) { ifile.seekg(0, std::ios_base::end); auto length = ifile.tellg(); ifile.seekg(0, std::ios_base::beg); input.reserve(static_cast<size_t>(length)); input.assign( std::istreambuf_iterator<char>(ifile), std::istreambuf_iterator<char>()); ifile.close(); }
- 
使用 std::istreambuf_iterator迭代器和std::back_inserter适配器将文件流的内容复制到向量中:std::vector<unsigned char> input; std::ifstream ifile("sample.bin", std::ios::binary); if(ifile.is_open()) { ifile.seekg(0, std::ios_base::end); auto length = ifile.tellg(); ifile.seekg(0, std::ios_base::beg); input.reserve(static_cast<size_t>(length)); std::copy(std::istreambuf_iterator<char>(ifile), std::istreambuf_iterator<char>(), std::back_inserter(input)); ifile.close(); }
然而,与这些替代方案相比,如何做...部分中描述的方法是最快的,尽管从面向对象的角度来看,这些替代方案可能看起来更有吸引力。本食谱的范围不包括比较这些替代方案的性能,但你可以将其作为练习尝试。
参见
- 
从/到二进制文件读取和写入对象,了解如何将对象序列化和反序列化到和从二进制文件中 
- 
使用 I/O 操作符控制流输出,了解使用称为操作符的辅助函数,这些函数通过 <<和>>流操作符控制输入和输出流
从/到二进制文件读取和写入对象
在前面的食谱中,我们学习了如何将原始数据(即无结构数据)写入和读取到文件中。然而,很多时候,我们必须持久化和加载对象。如前所述的食谱所示的方式仅适用于 POD 类型。对于其他任何类型,我们必须明确决定实际写入或读取的内容,因为写入或读取指针(包括指向虚拟表的指针)和任何类型的元数据不仅是不相关的,而且在语义上也是错误的。这些操作通常被称为序列化和反序列化。在本食谱中,我们将学习如何将 POD 和非 POD 类型序列化和反序列化到和从二进制文件中。
准备工作
对于本食谱中的示例,我们将使用foo和foopod类,如下所示:
class foo
{
  int         i;
  char        c;
  std::string s;
public:
  foo(int const i = 0, char const c = 0, std::string const & s = {}):
    i(i), c(c), s(s)
  {}
  foo(foo const &) = default;
  foo& operator=(foo const &) = default;
  bool operator==(foo const & rhv) const
  {
    return i == rhv.i &&
           c == rhv.c &&
           s == rhv.s;
  }
  bool operator!=(foo const & rhv) const
  {
    return !(*this == rhv);
  }
};
struct foopod
{
  bool a;
  char b;
  int  c[2];
};
bool operator==(foopod const & f1, foopod const & f2)
{
  return f1.a == f2.a && f1.b == f2.b &&
         f1.c[0] == f2.c[0] && f1.c[1] == f2.c[1];
} 
建议你在继续之前首先阅读前一个示例,从/到二进制文件读取和写入原始数据。你还应该了解 POD(既是平凡的又具有标准布局的类型)和非 POD 类型是什么,以及如何重载运算符。你可以在 第六章,通用工具 的 使用类型特性查询类型属性 的示例中查找有关 POD 类型的更多详细信息。
如何做...
要序列化/反序列化不包含指针的 POD 类型,使用 ofstream::write() 和 ifstream::read(),如前一个示例所示:
- 
使用 ofstream和write()方法将对象序列化到二进制文件中:std::vector<foopod> output { {true, '1', {1, 2}}, {true, '2', {3, 4}}, {false, '3', {4, 5}} }; std::ofstream ofile("sample.bin", std::ios::binary); if(ofile.is_open()) { for(auto const & value : output) { ofile.write(reinterpret_cast<const char*>(&value), sizeof(value)); } ofile.close(); }
- 
使用 ifstream和read()方法从二进制文件中反序列化对象:std::vector<foopod> input; std::ifstream ifile("sample.bin", std::ios::binary); if(ifile.is_open()) { while(true) { foopod value; ifile.read(reinterpret_cast<char*>(&value), sizeof(value)); if(ifile.fail() || ifile.eof()) break; input.push_back(value); } ifile.close(); }
要序列化非 POD 类型(或包含指针的 POD 类型),必须显式地将数据成员的值写入文件;要反序列化,必须显式地从文件中读取到数据成员,且顺序相同。为了演示这一点,我们将考虑我们之前定义的 foo 类:
- 
向此类添加一个名为 write()的成员函数以序列化该类的对象。该方法接受一个指向ofstream的引用,并返回一个bool值,指示操作是否成功:bool write(std::ofstream& ofile) const { ofile.write(reinterpret_cast<const char*>(&i), sizeof(i)); ofile.write(&c, sizeof(c)); auto size = static_cast<int>(s.size()); ofile.write(reinterpret_cast<char*>(&size), sizeof(size)); ofile.write(s.data(), s.size()); return !ofile.fail(); }
- 
向此类添加一个名为 read()的成员函数以反序列化该类的对象。此方法接受一个指向ifstream的引用,并返回一个bool值,指示操作是否成功:bool read(std::ifstream& ifile) { ifile.read(reinterpret_cast<char*>(&i), sizeof(i)); ifile.read(&c, sizeof(c)); auto size {0}; ifile.read(reinterpret_cast<char*>(&size), sizeof(size)); s.resize(size); ifile.read(reinterpret_cast<char*>(&s.front()), size); return !ifile.fail(); }
之前演示的 write() 和 read() 成员函数的替代方法是重载 operator<< 和 operator>>。为此,你应该执行以下步骤:
- 
向要序列化/反序列化的非成员 operator<<和operator>>添加friend声明到类中(在这种情况下,是foo类):friend std::ofstream& operator<<(std::ofstream& ofile, foo const& f); friend std::ifstream& operator>>(std::ifstream& ifile, foo& f);
- 
为你的类重载 operator<<:std::ofstream& operator<<(std::ofstream& ofile, foo const& f) { ofile.write(reinterpret_cast<const char*>(&f.i), sizeof(f.i)); ofile.write(&f.c, sizeof(f.c)); auto size = static_cast<int>(f.s.size()); ofile.write(reinterpret_cast<char*>(&size), sizeof(size)); ofile.write(f.s.data(), f.s.size()); return ofile; }
- 
为你的类重载 operator>>:std::ifstream& operator>>(std::ifstream& ifile, foo& f) { ifile.read(reinterpret_cast<char*>(&f.i), sizeof(f.i)); ifile.read(&f.c, sizeof(f.c)); auto size {0}; ifile.read(reinterpret_cast<char*>(&size), sizeof(size)); f.s.resize(size); ifile.read(reinterpret_cast<char*>(&f.s.front()), size); return ifile; }
它是如何工作的...
无论我们是否序列化整个对象(对于 POD 类型)还是其部分,我们都使用之前讨论过的相同流类:ofstream 用于输出文件流,ifstream 用于输入文件流。关于使用这些标准类写入和读取数据的详细信息已在那个示例中讨论,此处不再重复。
当将对象序列化和反序列化到文件时,你应该避免将指针的值写入文件。此外,你不得从文件中读取指针值,因为这些代表内存地址,在进程间没有意义,甚至在同一进程中的某些时刻也是无意义的。相反,你应该写入指针引用的数据,并将数据读入由指针引用的对象中。
这是一个一般原则,在实践中,你可能会遇到源中可能存在多个指向同一对象的指针的情况;在这种情况下,你可能只想写一个副本,并且以相应的方式处理读取。
如果你想要序列化的对象是 POD 类型,你可以像我们在讨论原始数据时做的那样进行。在本食谱的示例中,我们序列化了foopod类型的对象序列。当我们反序列化时,我们通过循环从文件流中读取,直到读取到文件末尾或发生失败。在这种情况下,我们的读取方式可能看起来不太直观,但以不同的方式做可能会导致读取的最后一个值的重复:
- 
读取是在一个无限循环中进行的。 
- 
在循环中执行读取操作。 
- 
执行对失败或文件末尾的检查,如果发生其中之一,则退出无限循环。 
- 
该值被添加到输入序列,并且循环继续。 
如果使用带有退出条件的循环来读取文件,即检查文件末尾的位,即while(!ifile.eof()),最后一个值将被添加到输入序列两次。这是因为读取最后一个值时,尚未遇到文件末尾(因为那是一个超出文件最后一个字节的标记)。文件末尾的标记只有在下一次读取尝试时才会达到,因此设置了流的eofbit。然而,输入变量仍然保留最后一个值,因为它没有被任何东西覆盖,并且这个值第二次被添加到输入向量中。
如果你想要序列化和反序列化的对象是非 POD 类型,将它们作为原始数据写入/读取是不可能的。例如,这样的对象可能有一个虚表。将虚表写入文件不会引起问题,即使它没有任何值;然而,从文件中读取,因此覆盖对象的虚表,将对对象和程序产生灾难性的影响。
在序列化/反序列化非 POD 类型时,有各种替代方案,其中一些在上一节中已经讨论过。所有这些都提供了明确的写入和读取或重载标准<<和>>运算符的方法。第二种方法的优势在于它允许在泛型代码中使用你的类,其中对象使用这些运算符写入和读取到流文件。
当你计划序列化和反序列化你的对象时,考虑从一开始就对数据进行版本控制,以避免随着时间的推移数据结构发生变化时出现问题。如何进行版本控制超出了本食谱的范围。
参见
- 
从/到二进制文件读取和写入原始数据,以了解如何将非结构化数据写入二进制文件 
- 
使用 I/O 操作符控制流的输出,以了解使用称为操作符的辅助函数的使用,这些函数使用 <<和>>流运算符来控制输入和输出流
在固定大小的外部缓冲区上使用流
<strstream>头文件从其开始就是标准 I/O 库的一部分。它包含提供对存储在数组中字符序列的流操作的类。然而,这个头文件在 C++98 时就已被弃用,尽管它仍然可用,因为没有提供替代品。C++20 标准引入了std::span类,它是对对象序列的非拥有视图。在 C++23 中,一个新的头文件<spanstream>被添加作为<strstream>的替代。这个头文件包含提供对外部提供的内存缓冲区进行流操作的类。在这个菜谱中,我们将学习如何使用 I/O span 流解析或写入文本。
如何做到这一点…
如下使用新的 C++23 span 流:
- 
要从外部数组解析文本,使用 std::ispanstream:char text[] = "1 1 2 3 5 8"; std::ispanstream is{ std::span<char>{text} }; int value; while (is >> value) { std::cout << value << '\n'; }
- 
要将文本写入外部数组,使用 std::ospanstream:char text[15]{}; int numbers[]{ 1, 1, 2, 3, 5, 8 }; std::ospanstream os{ std::span<char>{text} }; for (int n : numbers) { os << n << ' '; }
- 
要同时读取和写入同一个外部数组,使用 std::spanstream:char text[] = "1 1 2 3 5 8 "; std::vector<int> numbers; std::spanstream ss{ std::span<char>{text} }; int value; while (ss >> value) { numbers.push_back(value); } ss.clear(); ss.seekp(0); std::for_each(numbers.rbegin(), numbers.rend(), &ss { ss << n << ' '; }); std::cout << text << '\n'; // prints 8 5 3 2 1 1
它是如何工作的…
可以使用外部分配的缓冲区进行流输入/输出操作。然而,<strstream>头文件及其strstream、istrstream、ostrstream和strstreambuf类在 C++98 中被弃用,且没有提供替代品。弃用它们的原因包括安全性,因为strstreambuf不执行边界检查,以及由于其在调整底层缓冲区大小方面的限制而导致的灵活性不足。《std::stringstream》是唯一推荐的替代方案。
在 C++23 中,在新的<spanstream>头文件中提供了一组类似的类:basic_spanstream、basic_ispanstream、basic_ospanstream和basic_spanbuf。这些类允许对外部分配的固定大小缓冲区执行流操作。这些类不提供对缓冲区所有权或重新分配的支持。对于此类场景,应使用std::stringstream。
std::basic_spanbuf控制对字符序列的输入和输出。其关联的序列(输入的源,输出的汇)是一个外部分配的固定大小缓冲区,可以从或作为std::span提供初始化。这被std::basic_ispanstream、std::basic_ospanstream和std::basic_spanstream所包装,它们提供了由std::basic_istream、std::basic_ostream和std::basic_stream类定义的输入/输出操作的高级接口。
让我们再举一个例子来观察这个问题。假设我们有一个包含由逗号分隔的键值对的字符串。我们想要读取这些对并将它们放入一个映射中。我们可以在 C++23 中编写以下代码:
char const text[] = "severity=1,code=42,message=generic error";
std::unordered_map<std::string, std::string> m;
std::string key, val;
std::ispanstream is(text);
while (std::getline(is, key, '=') >> std::ws)
{
   if(std::getline(is, val, ','))
      m[key] = val;
}
for (auto const & [k, v] : m)
{
   std::cout << k << " : " << v << '\n';
} 
std::getline() 函数允许我们从输入流中读取字符,直到遇到其结束或指定的分隔符。使用它,我们首先使用 = 和 , 分隔符拆分文本。直到 = 的字符序列表示键,而 = 之后直到下一个逗号或结束的所有内容是值。std::ws 是一个 I/O 操作符,它从输入流中丢弃空白字符。简单来说,我们读取直到找到等号;直到那里是键的所有文本。然后,我们读取直到找到逗号(或达到末尾);直到那里是值。我们这样做,只要我们继续遇到等号,就在循环中。
从固定大小的缓冲区中读取并不困难,但写入需要更多的检查,因为写入不能超出缓冲区的界限,在这种情况下,写入操作将失败。让我们通过一个例子来更好地理解这一点:
char text[3]{};
std::ospanstream os{ std::span<char>{text} };
os << "42";
auto pos = os.tellp();
os << "44";
if (!os.good())
{
   os.clear();
   os.seekp(pos);
}
// text is {'4','2','4'}
// prints (examples): 424╠╠╠╠╠... or 424MƂ etc.
std::cout << text << '\n';
os << '\0';
// text is {'4','2','\0'}
// prints: 42
std::cout << text << '\n'; 
外部数组有 3 个字节。我们写入文本 42,这个操作成功。然后,我们尝试写入文本 44。然而,这需要外部缓冲区有 4 个字节,但它只有 3 个。因此,在写入字符 4 后,操作失败。此时,文本缓冲区的内容是 '``4','2','4',并且没有空终止字符。如果我们将其打印到控制台,在 424 之后,将出现一些基于内存中找到的内容的乱码,直到第一个 0。
要检查写入操作是否失败,我们使用 good() 成员函数。如果它返回 false,那么我们需要清除错误标志。我们还将流输出位置指示器设置回尝试读取之前的位置(可以使用 tellp() 成员函数检索)。此时,如果我们向输出缓冲区写入一个 0,其内容将是 '4','2','\0',因此将其打印到控制台将显示文本 42。
如果你想要同时读取和写入同一个缓冲区,你可以使用 std::spanstream 类,它提供了输入和输出流操作。一个例子在 如何做… 部分中已经展示。
参见
- 第六章,使用 std::span 对象处理对象的连续序列,学习如何使用非拥有视图来处理元素的连续序列
使用流本地化设置
写入或从流中读取的方式可能取决于语言和区域设置。例如,写入和解析数字、时间值或货币值,或比较(整理)字符串。C++ I/O 库通过 locales 和 facets 提供了一般用途的机制来处理国际化特性。在本食谱中,你将学习如何使用 locales 来控制输入/输出流的行为。
准备工作
本食谱中的所有示例都使用 std::cout 预定义的控制台流对象。然而,这同样适用于所有 I/O 流对象。此外,在这些食谱示例中,我们将使用以下对象和 lambda 函数:
auto now = std::chrono::system_clock::now();
auto stime = std::chrono::system_clock::to_time_t(now);
auto ltime = std::localtime(&stime);
std::vector<std::string> names
  {"John", "adele", "Øivind", "François", "Robert", "Åke"};
auto sort_and_print = [](std::vector<std::string> v,
                         std::locale const & loc)
{
  std::sort(v.begin(), v.end(), loc);
  for (auto const & s : v) std::cout << s << ' ';
  std::cout << '\n';
}; 
名称 Øivind 和 Åke 包含丹麦/挪威特有的字符 Ø 和 Å。在丹麦/挪威字母表中,这些是字母表的最后两个字母(按此顺序)。这些被用来举例说明使用区域设置的效果。
此配方中使用的区域设置名称(en_US.utf8、de_DE.utf8等)是 UNIX 系统上使用的名称。以下表格列出了 Windows 系统上的等效名称:
| UNIX | Windows | 
|---|---|
| en_US.utf8 | English_US.1252 | 
| en_GB.utf8 | English_UK.1252 | 
| de_DE.utf8 | German_Germany.1252 | 
| no_NO.utf8 | Norwegian_Norway.1252 | 
表 7.1:此配方中使用的 UNIX 和 Windows 区域设置名称列表
如何操作...
要控制流的本地化设置,必须执行以下操作:
- 
使用 std::locale类来表示本地化设置。有各种构造区域对象的方法,包括以下方法:- 
默认构造是使用全局区域设置(默认情况下,程序启动时的 C区域设置)
- 
从本地名称,如 C、POSIX、en_US.utf8等,如果操作系统支持
- 
从另一个区域设置,除了指定的特性 
- 
从另一个区域设置,除了从另一个指定的区域设置复制的指定类别的所有特性: // default construct auto loc_def = std::locale {}; // from a name auto loc_us = std::locale {"en_US.utf8"}; // from another locale except for a facet auto loc1 = std::locale {loc_def, new std::collate<wchar_t>}; // from another local, except the facet in a category auto loc2 = std::locale {loc_def, loc_us, std::locale::collate};
 
- 
- 
要获取默认的 C区域设置的副本,请使用std::locale::classic()静态方法:auto loc = std::locale::classic();
- 
要更改每次默认构造区域设置时复制的默认区域设置,请使用 std::locale::global()静态方法:std::locale::global(std::locale("en_US.utf8"));
- 
使用 imbue()方法更改 I/O 流的当前区域设置:std::cout.imbue(std::locale("en_US.utf8"));
下面的列表显示了使用各种区域设置的示例:
- 
使用特定的区域设置,通过其名称指示。在这个例子中,区域设置是为德语: auto loc = std::locale("de_DE.utf8"); std::cout.imbue(loc); std::cout << 1000.50 << '\n'; // 1.000,5 std::cout << std::showbase << std::put_money(1050) << '\n'; // 10,50 € std::cout << std::put_time(ltime, "%c") << '\n'; // So 04 Dez 2016 17:54:06 JST sort_and_print(names, loc); // adele Åke François John Øivind Robert
- 
使用与用户设置相对应的区域设置(如系统中所定义)。这通过从一个空字符串构造一个 std::locale对象来完成:auto loc = std::locale(""); std::cout.imbue(loc); std::cout << 1000.50 << '\n'; // 1,000.5 std::cout << std::showbase << std::put_money(1050) << '\n'; // $10.50 std::cout << std::put_time(ltime, "%c") << '\n'; // Sun 04 Dec 2016 05:54:06 PM JST sort_and_print(names, loc); // adele Åke François John Øivind Robert
- 
设置和使用全局区域设置: std::locale::global(std::locale("no_NO.utf8")); // set global auto loc = std::locale{}; // use global std::cout.imbue(loc); std::cout << 1000.50 << '\n'; // 1 000,5 std::cout << std::showbase << std::put_money(1050) << '\n'; // 10,50 kr std::cout << std::put_time(ltime, "%c") << '\n'; // sön 4 dec 2016 18:02:29 sort_and_print(names, loc); // adele François John Robert Øivind Åke
- 
使用默认的 C区域设置:auto loc = std::locale::classic(); std::cout.imbue(loc); std::cout << 1000.50 << '\n'; // 1000.5 std::cout << std::showbase << std::put_money(1050) << '\n'; // 1050 std::cout << std::put_time(ltime, "%c") << '\n'; // Sun Dec 4 17:55:14 2016 sort_and_print(names, loc); // François John Robert adele Åke Øivind
它是如何工作的...
区域对象实际上并不存储本地化设置。一个 区域 是一个异构的特件容器。一个 特件 是一个定义本地化和国际化设置的对象。标准定义了一个每个区域必须包含的特件列表。此外,区域还可以包含任何其他用户定义的特件。以下是一个所有标准定义的特件的列表:
| std::collate<char> | std::collate<wchar_t> | 
|---|---|
| std::ctype<char> | std::ctype<wchar_t> | 
| std::codecvt<char,char,mbstate_t>``std::codecvt<char16_t,char,mbstate_t> | std::codecvt<char32_t,char,mbstate_t>``std::codecvt<wchar_t,char,mbstate_t> | 
| std::moneypunct<char>``std::moneypunct<char,true> | std::moneypunct<wchar_t>``std::moneypunct<wchar_t,true> | 
| std::money_get<char> | std::money_get<wchar_t> | 
| std::money_put<char> | std::money_put<wchar_t> | 
| std::numpunct<char> | std::numpunct<wchar_t> | 
| std::num_get<char> | std::num_get<wchar_t> | 
| std::num_put<char> | std::num_put<wchar_t> | 
| std::time_get<char> | std::time_get<wchar_t> | 
| std::time_put<char> | std::time_put<wchar_t> | 
| std::messages<char> | std::messages<wchar_t> | 
表 7.2:标准特性的列表
讨论这个列表中的所有这些特性超出了本食谱的范围。然而,我们将提到 std::money_get 是一个封装从字符流解析货币值规则的特性,而 std::money_put 是一个封装将货币值格式化为字符串的规则的特性。以类似的方式,std::time_get 封装了解析日期和时间的规则,而 std::time_put 封装了格式化日期和时间的规则。这些将是下一两个食谱的主题。
区域设置是一个不可变对象,包含不可变的特性对象。区域设置作为引用计数的数组实现,该数组由引用计数的特性指针组成。数组通过 std::locale::id 进行索引,并且所有特性都必须从基类 std::locale::facet 派生,并且必须有一个公共静态成员,其类型为 std::locale::id,称为 id。
只能使用重载构造函数之一或使用 combine() 方法来创建区域设置对象,正如其名称所暗示的,combine() 方法将当前区域设置与一个新的编译时可识别的特性组合,并返回一个新的区域设置对象。下一个示例显示了使用美国英语区域设置,但带有来自挪威区域设置的数值标点设置的情况:
std::locale loc = std::locale("English_US.1252")
                  .combine<std::numpunct<char>>(
                     std::locale("Norwegian_Norway.1252"));
std::cout.imbue(loc);
std::cout << "en_US locale with no_NO numpunct: " << 42.99 << '\n';
// en_US locale with no_NO numpunct: 42,99 
另一方面,可以使用 std::has_facet() 函数模板来确定区域设置是否包含特定的特性,或者使用 std::use_facet() 函数模板获取特定区域设置实现的特性的引用。
在前面的示例中,我们对字符串向量进行了排序,并将区域设置对象作为 std::sort() 通用算法的第三个参数传递。这个第三个参数应该是一个比较函数对象。传递区域设置对象之所以有效,是因为 std::locale 有一个 operator(),它使用其排序特性按字典顺序比较两个字符串。这实际上是 std::locale 直接提供的唯一本地化功能;然而,这实际上调用的是排序特性的 compare() 方法,该方法根据特性的规则执行字符串比较。
每个程序在启动时都会创建一个全局区域设置。这个全局区域设置的内容会被复制到每个默认构造的区域设置中。可以使用静态方法 std::locale::global() 来替换全局区域设置。默认情况下,全局区域设置是 C 区域设置,它与具有相同名称的 ANSI C 区域设置等效。这个区域设置是为了处理简单的英文文本而创建的,并且在 C++ 中是默认的区域设置,它提供了与 C 的兼容性。可以通过静态方法 std::locale::classic() 获取对这个区域设置的引用。
默认情况下,所有流都使用经典区域设置来写入或解析文本。然而,可以使用流的 imbue() 方法来更改流使用的区域设置。这是 std::ios_base 类的一个成员,它是所有 I/O 流的基础。相应的成员方法是 getloc() 方法,它返回当前流区域设置的副本。
在前面的示例中,我们更改了 std::cout 流对象的区域设置。在实际应用中,你可能希望为与标准 C 流关联的所有流对象设置相同的区域设置:cin、cout、cerr 和 clog(或 wcin、wcout、wcerr 和 wclog)。
当你想使用特定的区域设置(例如本食谱中所示的德语或挪威语)时,你必须确保它们在你的系统上可用。在 Windows 上,这通常不会成问题,但在 Linux 系统上,它们可能未安装。在这种情况下,尝试实例化一个 std::locale 对象,例如使用 std::locale("de_DE.utf8"),会导致抛出一个 std::runtime_error 异常。要在你的系统上安装区域设置,请查阅其文档以找到你必须执行的必要步骤。
参见
- 
使用 I/O 操作符控制流输出,了解如何使用称为操作符的辅助函数来控制输入和输出流,这些操作符使用 <<和>>流运算符
- 
使用货币 I/O 操作符,了解如何使用标准操作符来写入和读取货币值 
- 
使用时间 I/O 操作符,了解如何使用标准操作符来写入和读取日期和时间值 
使用 I/O 操作符控制流输出
除了基于流的 I/O 库之外,标准库还提供了一系列称为操作符的辅助函数,这些函数使用 operator<< 和 operator>> 控制输入和输出流。在本食谱中,我们将查看一些这些操作符,并通过一些示例来展示它们的使用,这些示例将格式化控制台输出。我们将在接下来的食谱中继续介绍更多操作符。
准备工作
I/O 操作符在 std 命名空间中的头文件 <ios>、<istream>、<ostream> 和 <iomanip> 中可用。在本食谱中,我们只将讨论 <ios> 和 <iomanip> 中的某些操作符。
如何操作...
以下操作符可用于控制流的输出或输入:
- 
boolalpha和noboolalpha用于启用和禁用布尔值的文本表示:std::cout << std::boolalpha << true << '\n'; // true std::cout << false << '\n'; // false std::cout << std::noboolalpha << false << '\n'; // 0
- 
left、right和internal影响填充字符的对齐;left和right影响所有文本,但internal仅影响整数、浮点数和货币输出:std::cout << std::right << std::setw(10) << "right\n"; std::cout << std::setw(10) << "text\n"; std::cout << std::left << std::setw(10) << "left\n";
- 
fixed、scientific、hexfloat和defaultfloat改变了用于浮点类型的格式化(对于输入和输出流)。后两者自 C++11 起才可用:std::cout << std::fixed << 0.25 << '\n'; // 0.250000 std::cout << std::scientific << 0.25 << '\n'; // 2.500000e-01 std::cout << std::hexfloat << 0.25 << '\n'; // 0x1p-2 std::cout << std::defaultfloat << 0.25 << '\n'; // 0.25
- 
dec、hex和oct控制整数类型(在输入和输出流中)使用的基数:std::cout << std::oct << 42 << '\n'; // 52 std::cout << std::hex << 42 << '\n'; // 2a std::cout << std::dec << 42 << '\n'; // 42
- 
setw改变下一个输入或输出字段的宽度。默认宽度为 0。
- 
setfill改变输出流的填充字符;这是用于填充下一个字段直到达到指定宽度的字符。默认填充字符是空白字符:std::cout << std::right << std::setfill('.') << std::setw(10) << "right" << '\n'; // .....right
- 
setprecision改变输入和输出流中浮点类型的十进制精度(即生成的数字位数)。默认精度为 6:std::cout << std::fixed << std::setprecision(2) << 12.345 << '\n'; // 12.35
它是如何工作的...
所有的先前列出的 I/O 操纵符(除了 setw,它只针对下一个输出字段)都会影响流。此外,所有连续的写入或读取操作都会使用最后指定的格式,直到再次使用另一个操纵符。
其中一些操纵符是无参数调用的。例如,boolalpha/noboolalpha 或 dec/hex/oct。这些操纵符是接受单个参数(即字符串的引用)并返回同一流引用的函数:
std::ios_base& hex(std::ios_base& str); 
如 std::cout << std::hex 这样的表达式是可能的,因为 basic_ostream::operator<< 和 basic_istream::operator>> 都有特殊的重载,可以接受对这些函数的指针。
其他操纵符,包括这里未提及的一些,都是通过参数调用的。这些操纵符是接受一个或多个参数并返回一个未指定类型的对象的函数:
template<class CharT>
/*unspecified*/ setfill(CharT c); 
为了更好地展示这些操纵符的使用,我们将考虑两个示例,这些示例将格式化输出到控制台。
在第一个例子中,我们将列出满足以下要求的书籍目录:
- 
章节编号右对齐,并使用罗马数字表示。 
- 
章节标题左对齐,直到页码的剩余空间用点填充。 
- 
章节的页码右对齐。 
对于这个例子,我们将使用以下类和辅助函数:
struct Chapter
{
  int Number;
  std::string Title;
  int Page;
};
struct BookPart
{
  std::string Title;
  std::vector<Chapter> Chapters;
};
struct Book
{
  std::string Title;
  std::vector<BookPart> Parts;
};
std::string to_roman(unsigned int value)
{
  struct roman_t { unsigned int value; char const* numeral; };
  const static roman_t rarr[13] =
  {
    {1000, "M"}, {900, "CM"}, {500, "D"}, {400, "CD"},
    { 100, "C"}, { 90, "XC"}, { 50, "L"}, { 40, "XL"},
    {  10, "X"}, {  9, "IX"}, {  5, "V"}, {  4, "IV"},
    {   1, "I"}
  };
  std::string result;
  for (auto const & number : rarr)
  {
    while (value >= number.value)
    {
      result += number.numeral;
      value -= number.value;
    }
  }
  return result;
} 
Book as its argument and prints its content to the console according to the specified requirements. For this purpose, we use the following:
- 
std::left和std::right指定文本对齐方式
- 
std::setw指定每个输出字段的宽度
- 
std::fill指定填充字符(章节编号为空白字符,章节标题为点)
print_toc() 函数的实现如下:
void print_toc(Book const & book)
{
  std::cout << book.Title << '\n';
  for(auto const & part : book.Parts)
  {
    std::cout << std::left << std::setw(15) << std::setfill(' ')
              << part.Title << '\n';
    std::cout << std::left << std::setw(15) << std::setfill('-')
              << '-' << '\n';
    for(auto const & chapter : part.Chapters)
    {
      std::cout << std::right << std::setw(4) << std::setfill(' ')
                << to_roman(chapter.Number) << ' ';
      std::cout << std::left << std::setw(35) << std::setfill('.')
                << chapter.Title;
      std::cout << std::right << std::setw(3) << std::setfill('.')
                << chapter.Page << '\n';
    }
  }
} 
以下示例使用此方法与描述书籍《指环王》目录的 Book 对象:
auto book = Book
{
  "THE FELLOWSHIP OF THE RING"s,
  {
    {
      "BOOK ONE"s,
      {
        {1, "A Long-expected Party"s, 21},
        {2, "The Shadow of the Past"s, 42},
        {3, "Three Is Company"s, 65},
        {4, "A Short Cut to Mushrooms"s, 86},
        {5, "A Conspiracy Unmasked"s, 98},
        {6, "The Old Forest"s, 109},
        {7, "In the House of Tom Bombadil"s, 123},
        {8, "Fog on the Barrow-downs"s, 135},
        {9, "At the Sign of The Prancing Pony"s, 149},
        {10, "Strider"s, 163},
        {11, "A Knife in the Dark"s, 176},
        {12, "Flight to the Ford"s, 197},
      },
    },
    {
      "BOOK TWO"s,
      {
        {1, "Many Meetings"s, 219},
        {2, "The Council of Elrond"s, 239},
        {3, "The Ring Goes South"s, 272},
        {4, "A Journey in the Dark"s, 295},
        {5, "The Bridge of Khazad-dum"s, 321},
        {6, "Lothlorien"s, 333},
        {7, "The Mirror of Galadriel"s, 353},
        {8, "Farewell to Lorien"s, 367},
        {9, "The Great River"s, 380},
        {10, "The Breaking of the Fellowship"s, 390},
      },
    },
  }
};
print_toc(book); 
在这种情况下,输出如下:
THE FELLOWSHIP OF THE RING
BOOK ONE
---------------
   I A Long-expected Party...............21
  II The Shadow of the Past..............42
 III Three Is Company....................65
  IV A Short Cut to Mushrooms............86
   V A Conspiracy Unmasked...............98
  VI The Old Forest.....................109
 VII In the House of Tom Bombadil.......123
VIII Fog on the Barrow-downs............135
  IX At the Sign of The Prancing Pony...149
   X Strider............................163
  XI A Knife in the Dark................176
 XII Flight to the Ford.................197
BOOK TWO
---------------
   I Many Meetings......................219
  II The Council of Elrond..............239
 III The Ring Goes South................272
  IV A Journey in the Dark..............295
   V The Bridge of Khazad-dum...........321
  VI Lothlorien.........................333
 VII The Mirror of Galadriel............353
VIII Farewell to Lorien.................367
  IX The Great River....................380
   X The Breaking of the Fellowship.....390 
对于第二个例子,我们的目标是输出一个列表,列出按收入排名的世界最大公司。该表格将包含公司名称、行业、收入(以十亿美元为单位)、收入增长/下降、收入增长率、员工人数和起源国家。对于这个例子,我们将使用以下类:
struct Company
{
  std::string Name;
  std::string Industry;
  double      Revenue;
  bool        RevenueIncrease;
  double      Growth;
  int         Employees;
  std::string Country;
}; 
以下代码片段中的 print_companies() 函数使用了几个额外的操纵符,这些操纵符在先前的例子中已经展示过:
- 
std::boolalpha将布尔值显示为true和false而不是1和0。
- 
std::fixed表示固定浮点表示,然后std::defaultfloat恢复到默认浮点表示。
- 
std::setprecision指定在输出中显示的小数位数。与std::fixed一起使用,这用于表示具有小数位的Growth字段。
print_companies() 函数的实现如下所示:
void print_companies(std::vector<Company> const & companies)
{
  for(auto const & company : companies)
  {
    std::cout << std::left << std::setw(26) << std::setfill(' ')
              << company.Name;
    std::cout << std::left << std::setw(18) << std::setfill(' ')
              << company.Industry;
    std::cout << std::left << std::setw(5) << std::setfill(' ')
              << company.Revenue;
    std::cout << std::left << std::setw(5) << std::setfill(' ')
              << std::boolalpha << company.RevenueIncrease
              << std::noboolalpha;
    std::cout << std::right << std::setw(5) << std::setfill(' ')
              << std::fixed << std::setprecision(1) << company.Growth
              << std::defaultfloat << std::setprecision(6) << ' ';
    std::cout << std::right << std::setw(8) << std::setfill(' ')
              << company.Employees << ' ';
    std::cout << std::left << std::setw(2) << std::setfill(' ')
              << company.Country
              << '\n';
  }
} 
以下是一个调用此方法的示例。此处数据的来源是维基百科(en.wikipedia.org/wiki/List_of_largest_companies_by_revenue,截至 2016 年):
std::vector<Company> companies
{
  {"Walmart"s, "Retail"s, 482, false, 0.71,
    2300000, "US"s},
  {"State Grid"s, "Electric utility"s, 330, false, 2.91,
    927839, "China"s},
  {"Saudi Aramco"s, "Oil and gas"s, 311, true, 40.11,
    65266, "SA"s},
  {"China National Petroleum"s, "Oil and gas"s, 299,
    false, 30.21, 1589508, "China"s},
  {"Sinopec Group"s, "Oil and gas"s, 294, false, 34.11,
    810538, "China"s},
};
print_companies(companies); 
在这种情况下,输出具有基于表格的格式,如下所示:
Walmart                   Retail            482  false  0.7  2300000 US
State Grid                Electric utility  330  false  2.9   927839 China
Saudi Aramco              Oil and gas       311  true  40.1    65266 SA
China National Petroleum  Oil and gas       299  false 30.2  1589508 China
Sinopec Group             Oil and gas       294  false 34.1   810538 China 
作为练习,你可以尝试添加表头或甚至网格线来 precede 这些行,以更好地表格化数据。
参见
- 
从/向二进制文件读取和写入原始数据,以学习如何将非结构化数据写入和读取到二进制文件 
- 
使用货币 I/O 操作符,以学习如何使用标准操作符来写入和读取货币值 
- 
使用时间 I/O 操作符,以学习如何使用标准操作符来写入和读取日期和时间值 
使用货币 I/O 操作符
在上一个配方中,我们查看了一些可以用于控制输入和输出流的操作符。我们讨论的操作符与数值和文本值相关。在本配方中,我们将探讨如何使用标准操作符来写入和读取货币值。
准备工作
现在,你应该熟悉区域设置以及如何为流设置它们。这个主题在 使用流的本地区域设置 配方中讨论过。建议你在继续之前阅读该配方。
本配方中讨论的操作符在 std 命名空间中,在 <iomanip> 头文件中可用。
如何做...
要将货币值写入输出流,你应该执行以下操作:
- 
设置所需的区域设置以控制货币格式: std::cout.imbue(std::locale("en_GB.utf8"));
- 
使用 long double或std::basic_string值作为金额:long double mon = 12345.67; std::string smon = "12345.67";
- 
使用 std::put_money操作符并带一个参数(货币值)来显示使用货币符号(如果有的话)的值:std::cout << std::showbase << std::put_money(mon) << '\n'; // £123.46 std::cout << std::showbase << std::put_money(smon) << '\n'; // £123.46
- 
使用 std::put_money并带两个参数(货币值和设置为true的布尔标志)来指示使用国际货币字符串:std::cout << std::showbase << std::put_money(mon, true) << '\n'; // GBP 123.46 std::cout << std::showbase << std::put_money(smon, true) << '\n'; // GBP 123.46
从输入流中读取货币值,你应该执行以下操作:
- 
设置所需的区域设置以控制货币格式: std::istringstream stext("$123.45 567.89 USD"); stext.imbue(std::locale("en_US.utf8"));
- 
使用 long double或std::basic_string值从输入流中读取金额:long double v1 = 0; std::string v2;
- 
如果输入流中可能使用货币符号,请使用 std::get_money()并带一个参数(要写入货币值的变量):stext >> std::get_money(v1) >> std::get_money(v2); // v1 = 12345, v2 = "56789"
- 
使用 std::get_money()并带两个参数(要写入货币值的变量和设置为true的布尔标志)来指示存在国际货币字符串:std::istringstream stext("123.45 567.89"); stext.imbue(std::locale("en_US.utf8")); long double v1 = 0; std::string v2; stext >> std::get_money(v1, true) >> std::get_money(v2, true); // v1 = 12345, v2 = "56789"
它是如何工作的...
put_money()和get_money()操作符非常相似。它们都是函数模板,接受一个表示要写入输出流的货币值或从输入流读取的货币值变量的参数,以及一个可选的参数,用于指示是否使用国际货币字符串。默认选项是货币符号,如果可用。put_money()使用std::money_put()面设置来输出货币值,而get_money()使用std::money_get()面来解析货币值。这两个操作符函数模板都返回一个未指定类型的对象。这些函数不抛出异常:
template <class MoneyT>
/*unspecified*/ put_money(const MoneyT& mon, bool intl = false);
template <class MoneyT>
/*unspecified*/ get_money(MoneyT& mon, bool intl = false); 
这两个操作符函数都需要货币值是long double或std::basic_string。
然而,需要注意的是,货币值以使用中的区域设置的货币最小面额的整数形式存储。以美元为例,$100.00 存储为10000.0,而 1 分(即$0.01)存储为1.0。
当将货币值写入输出流时,如果你想显示货币符号或国际货币字符串,则必须使用std::showbase操作符。这通常用于表示数字基的前缀(例如,十六进制的0x);然而,对于货币值,它用于指示是否应显示货币符号/字符串。以下代码片段提供了一个示例:
std::cout << std::put_money(12345.67) << '\n';
// prints 123.46
std::cout << std::showbase << std::put_money(12345.67) << '\n';
// prints £123.46 
123.46, while the second line will print the same numerical value but preceded by the currency symbol.
相关内容
- 
使用 I/O 操作符控制流输出,了解使用辅助函数,即操作符,它们通过 <<和>>流操作符来控制输入和输出流。
- 
使用时间 I/O 操作符,了解如何使用标准操作符写入和读取日期和时间值 
使用时间 I/O 操作符
与我们在上一个菜谱中讨论的货币 I/O 操作符类似,C++11 标准提供了控制时间值写入和读取流中的操作符,其中时间值以std::tm对象的形式表示,该对象包含日历日期和时间。在本菜谱中,你将学习如何使用这些时间操作符。
准备中
时间 I/O 操作符使用的时间值以std::tm值表示。你应该熟悉这个结构,它来自<ctime>头文件。
你还应该熟悉区域设置以及如何为流设置它们。这个主题在使用流区域设置菜谱中讨论过。建议你在继续之前阅读那个菜谱。
本菜谱中讨论的操作符在std命名空间中,在<iomanip>头文件中可用。
如何实现...
要将时间值写入输出流,你应该执行以下步骤:
- 
获取与给定时间对应的日历日期和时间值。有多种方法可以实现这一点。以下展示了如何将当前时间转换为以日历日期和时间表示的本地时间的几个示例: auto now = std::chrono::system_clock::now(); auto stime = std::chrono::system_clock::to_time_t(now); auto ltime = std::localtime(&stime); auto ttime = std::time(nullptr); auto ltime = std::localtime(&ttime);
- 
使用 std::put_time()提供一个指向表示日历日期和时间的std::tm对象的指针,以及一个指向以空字符结尾的字符字符串的指针,表示格式。C++11 标准提供了一长串可用的格式;此列表可以在en.cppreference.com/w/cpp/io/manip/put_time中查阅。
- 
要根据特定区域的设置写入标准日期和时间字符串,首先通过调用 imbue()设置流的区域,然后使用std::put_time()操纵符:std::cout.imbue(std::locale("en_GB.utf8")); std::cout << std::put_time(ltime, "%c") << '\n'; // Sun 04 Dec 2016 05:26:47 JST
以下列表显示了一些受支持的时间格式示例:
- 
ISO 8601 日期格式 "%F"或"%Y-%m-%d":std::cout << std::put_time(ltime, "%F") << '\n'; // 2016-12-04
- 
ISO 8601 时间格式 "%T":std::cout << std::put_time(ltime, "%T") << '\n'; // 05:26:47
- 
ISO 8601 UTC 格式组合日期和时间 "%FT%T%z":std::cout << std::put_time(ltime, "%FT%T%z") << '\n'; // 2016-12-04T05:26:47+0900
- 
ISO 8601 周格式 "%Y-W%V":std::cout << std::put_time(ltime, "%Y-W%V") << '\n'; // 2016-W48
- 
ISO 8601 带周数日期格式 "%Y-W%V-%u":std::cout << std::put_time(ltime, "%Y-W%V-%u") << '\n'; // 2016-W48-7
- 
ISO 8601 序数日期格式 "%Y-%j":std::cout << std::put_time(ltime, "%Y-%j") << '\n'; // 2016-339
要从输入流中读取时间值,应执行以下步骤:
- 
声明一个 std::tm类型的对象来保存从流中读取的时间值:auto time = std::tm {};
- 
使用 std::get_time()提供一个指向std::tm对象的指针,该对象将保存时间值,以及一个指向以空字符结尾的字符字符串的指针,该字符串表示格式。可能的格式列表可以在en.cppreference.com/w/cpp/io/manip/get_time中查阅。以下示例解析了 ISO 8601 组合日期和时间值:std::istringstream stext("2016-12-04T05:26:47+0900"); stext >> std::get_time(&time, "%Y-%m-%dT%H:%M:%S"); if (!stext.fail()) { /* do something */ }
- 
要根据特定区域的设置读取标准日期和时间字符串,首先通过调用 imbue()设置流的区域,然后使用std::get_time()操纵符:std::istringstream stext("Sun 04 Dec 2016 05:35:30 JST"); stext.imbue(std::locale("en_GB.utf8")); stext >> std::get_time(&time, "%c"); if (stext.fail()) { /* do something else */ }
它是如何工作的...
时间值操纵符put_time()和get_time()非常相似:它们都是具有两个参数的函数模板。第一个参数是指向表示日历日期和时间的std::tm对象的指针,该对象包含要写入流或从流中读取的值。第二个参数是指向表示时间文本格式的以空字符结尾的字符字符串的指针。put_time()使用std::time_put()方面来输出日期和时间值,而get_time()使用std::time_get()方面来解析日期和时间值。这两个操纵符函数模板都返回一个未指定类型的对象。这些函数不抛出异常:
template<class CharT>
/*unspecified*/ put_time(const std::tm* tmb, const CharT* fmt);
template<class CharT>
/*unspecified*/ get_time(std::tm* tmb, const CharT* fmt); 
使用put_time()将日期和时间值写入输出流的结果字符串与调用std::strftime()或std::wcsftime()的结果相同。
标准定义了一系列可用的转换说明符,它们构成了格式字符串。这些说明符以 % 开头,在某些情况下,后面跟着 E 或 0。其中一些也是等效的;例如,%F 等同于 %Y-%m-%d(这是 ISO 8601 日期格式),而 %T 等同于 %H:%M:%S(这是 ISO 8601 时间格式)。本配方中提到的示例仅涉及少数转换说明符,指的是 ISO 8601 日期和时间格式。对于转换说明符的完整列表,请参阅 C++ 标准,或遵循之前提到的链接。
重要的是要注意,put_time() 支持的所有转换说明符并不一定都由 get_time() 支持。例如,z(UTC 偏移量,ISO 8601 格式)和 Z(时区名称或缩写)说明符只能与 put_time() 一起使用。以下代码片段展示了这一点:
std::istringstream stext("2016-12-04T05:26:47+0900");
auto time = std::tm {};
stext >> std::get_time(&time, "%Y-%m-%dT%H:%M:%S%z"); // fails
stext >> std::get_time(&time, "%Y-%m-%dT%H:%M:%S");   // OK 
由某些转换说明符表示的文本是区域相关的。所有以 E 或 0 开头的说明符都是区域相关的。要为流设置特定的区域,请使用 imbue() 方法,如 如何做... 部分中的示例所示。
在前面的示例中使用的 std::localtime() 函数在成功时返回一个指向静态内部 std::tm 对象的指针(否则返回 nullptr)。你不应该尝试释放这个指针!
参见
- 
使用 I/O 操作符控制流输出,了解如何使用称为操作符的辅助函数来控制输入和输出流,这些操作符使用 <<和>>流运算符。
- 
使用货币 I/O 操作符,了解如何使用标准操作符来写入和读取货币值 
与文件系统路径一起工作
C++17 标准的一个重要补充是 filesystem 库,它使我们能够处理分层文件系统(如 Windows 或 POSIX 文件系统)中的路径、文件和目录。这个标准库是基于 boost.filesystem 库开发的。在接下来的几个配方中,我们将探讨该库的这些功能,这些功能使我们能够执行文件和目录操作,例如创建、移动或删除它们,以及查询属性和搜索。然而,首先查看这个库如何处理路径是很重要的。
准备工作
对于这个配方,我们将考虑大多数使用 Windows 路径的示例。在配套代码中,所有示例都有 Windows 和 POSIX 的替代方案。
filesystem 库位于 std::filesystem 命名空间中,在 <filesystem> 头文件中。为了简化代码,我们将在所有示例中使用以下命名空间别名:
namespace fs = std::filesystem; 
文件系统组件(文件、目录、硬链接或软链接)的路径由 path 类表示。
如何做...
以下是对路径最常见的操作列表:
- 
使用构造函数、赋值运算符或 assign()方法创建路径:// Windows auto path = fs::path{"C:\\Users\\Marius\\Documents"}; // POSIX auto path = fs::path{ "/home/marius/docs" };
- 
使用成员 operator /=、非成员operator /或append()方法通过包含目录分隔符将元素附加到路径:path /= "Book"; path = path / "Modern" / "Cpp"; path.append("Programming"); // Windows: C:\Users\Marius\Documents\Book\Modern\Cpp\Programming // POSIX: /home/marius/docs/Book/Modern/Cpp/Programming
- 
使用成员 operator +=、非成员operator +或concat()方法将元素连接到路径,而不包括目录分隔符:auto path = fs::path{ "C:\\Users\\Marius\\Documents" }; path += "\\Book"; path.concat("\\Modern"); // path = C:\Users\Marius\Documents\Book\Modern
- 
使用成员函数(如 root_name()、root_dir()、filename()、stem()、extension()等)将路径的元素分解为其部分,例如根、根目录、父路径、文件名、扩展名等(所有这些都在以下示例中展示):auto path = fs::path{"C:\\Users\\Marius\\Documents\\sample.file.txt"}; std::cout << "root: " << path.root_name() << '\n' << "root dir: " << path.root_directory() << '\n' << "root path: " << path.root_path() << '\n' << "rel path: " << path.relative_path() << '\n' << "parent path: " << path.parent_path() << '\n' << "filename: " << path.filename() << '\n' << "stem: " << path.stem() << '\n' << "extension: " << path.extension() << '\n';
- 
使用成员函数(如 has_root_name()、has_root_directory()、has_filename()、has_stem()和has_extension())查询路径部分是否可用(所有这些都在以下示例中展示):auto path = fs::path{"C:\\Users\\Marius\\Documents\\sample.file.txt"}; std::cout << "has root: " << path.has_root_name() << '\n' << "has root dir: " << path.has_root_directory() << '\n' << "has root path: " << path.has_root_path() << '\n' << "has rel path: " << path.has_relative_path() << '\n' << "has parent path: " << path.has_parent_path() << '\n' << "has filename: " << path.has_filename() << '\n' << "has stem: " << path.has_stem() << '\n' << "has extension: " << path.has_extension() << '\n';
- 
检查路径是相对的还是绝对的: auto path2 = fs::path{ "marius\\temp" }; std::cout << "absolute: " << path1.is_absolute() << '\n' << "absolute: " << path2.is_absolute() << '\n';
- 
使用 replace_filename()和remove_filename()修改路径的各个部分,如文件名,以及使用replace_extension()修改扩展名:auto path = fs::path{"C:\\Users\\Marius\\Documents\\sample.file.txt"}; path.replace_filename("output"); path.replace_extension(".log"); // path = C:\Users\Marius\Documents\output.log path.remove_filename(); // path = C:\Users\Marius\Documents
- 
将目录分隔符转换为系统首选的分隔符: // Windows auto path = fs::path{"Users/Marius/Documents"}; path.make_preferred(); // path = Users\Marius\Documents // POSIX auto path = fs::path{ "\\home\\marius\\docs" }; path.make_preferred(); // path = /home/marius/docs
它是如何工作的...
std::filesystem::path类模拟文件系统组件的路径。然而,它只处理语法,并不验证路径表示的组件(如文件或目录)的存在性。
该库定义了一个可移植的通用路径语法,可以适应各种文件系统,如 POSIX 或 Windows,包括 Microsoft Windows 通用命名约定(UNC)格式。两者在几个关键方面有所不同:
- 
POSIX 系统有一个单一的树,没有根名称,一个名为 /的单个根目录,以及一个单个当前目录。此外,它们使用/作为目录分隔符。路径表示为以 UTF-8 编码的char的空终止字符串。
- 
Windows 系统有多个树,每个树都有一个根名称(如 C:),一个根目录(如\),以及一个当前目录(如C:\Windows\System32)。路径表示为以 UTF-16 编码的宽字符的空终止字符串。
你不应该在不同系统之间混合路径格式。尽管 Windows 可以处理 POSIX 路径,但反之则不然。请使用每个系统特定的路径格式。此外,你可以使用filesystem::path功能,例如operator /=和append()函数,以及preferred_separator静态成员来以可移植的方式构建路径。
根据定义在filesystem库中的pathname具有以下语法:
- 
可选的根名称( C:或//localhost)
- 
可选的根目录 
- 
零个或多个文件名(可能指文件、目录、硬链接或符号链接)或目录分隔符 
有两个特殊的文件名被识别:单个点(.),它代表当前目录,和双点(..),它代表父目录。目录分隔符可以重复,在这种情况下,它被视为单个分隔符(换句话说,/home////docs与/home/marius/docs相同)。一个没有冗余当前目录名(.)、没有冗余父目录名(..)和没有冗余目录分隔符的路径被认为是正常形式。
上一节中介绍的路径操作是最常见的路径操作。然而,它们的实现定义了额外的查询和修改方法、迭代器、非成员比较运算符等等。
以下示例遍历路径的部分并将它们打印到控制台:
auto path =
  fs::path{ "C:\\Users\\Marius\\Documents\\sample.file.txt" };
for (auto const & part : path)
{
  std::cout << part << '\n';
} 
以下列表表示其结果:
C:
Users
Marius
Documents
sample.file.txt 
在本例中,sample.file.txt是文件名。这基本上是从最后一个目录分隔符到路径末尾的部分。这就是成员函数filename()对于给定路径会返回的内容。该文件的扩展名是.txt,这是由extension()成员函数返回的字符串。要获取不带扩展名的文件名,可以使用另一个名为stem()的成员函数。在这里,该方法返回的字符串是sample.file。对于所有这些方法,以及所有其他分解方法,都有一个具有相同名称和前缀has_的相应查询方法,例如has_filename()、has_stem()和has_extension()。所有这些方法都返回一个bool值,以指示路径是否有相应的部分。
参见
- 
创建、复制和删除文件和目录,以了解如何独立于使用的文件系统执行这些基本操作 
- 
检查现有文件或目录的属性,以了解如何查询文件和目录的属性,例如类型、权限、文件时间等 
创建、复制和删除文件和目录
文件操作,如复制、移动和删除,或目录操作,如创建、重命名和删除,都由filesystem库支持。文件和目录使用路径(可以是绝对路径、规范路径或相对路径)来标识,这在之前的菜谱中已经介绍过。在本菜谱中,我们将查看之前提到的操作的标准化函数以及它们的工作方式。
准备工作
在继续之前,你应该阅读使用文件系统路径菜谱。该菜谱的简介说明也适用于此处。然而,本菜谱中的所有示例都是平台无关的。
对于以下所有示例,我们将使用以下变量,并假设在 Windows 上的当前路径是C:\Users\Marius\Documents,而在 POSIX 系统上是/home/marius/docs:
auto err = std::error_code{};
auto basepath = fs::current_path();
auto path = basepath / "temp";
auto filepath = path / "sample.txt"; 
我们还将假设在当前路径的temp子目录中存在一个名为sample.txt的文件(例如C:\Users\Marius\Documents\temp\sample.txt或/home/marius/docs/temp/sample.txt)。
如何操作...
使用以下库函数执行目录操作:
- 
要创建一个新的目录,使用 create_directory()。如果目录已存在,此方法不会执行任何操作;然而,它不会递归地创建目录:auto success = fs::create_directory(path, err);
- 
要递归地创建新目录,使用 create_directories():auto temp = path / "tmp1" / "tmp2" / "tmp3"; auto success = fs::create_directories(temp, err);
- 
要移动现有的目录,使用 rename():auto temp = path / "tmp1" / "tmp2" / "tmp3"; auto newtemp = path / "tmp1" / "tmp3"; fs::rename(temp, newtemp, err); if (err) std::cout << err.message() << '\n';
- 
要重命名现有的目录,也使用 rename():auto temp = path / "tmp1" / "tmp3"; auto newtemp = path / "tmp1" / "tmp4"; fs::rename(temp, newtemp, err); if (err) std::cout << err.message() << '\n';
- 
要复制现有的目录,使用 copy()。要递归地复制目录的全部内容,使用copy_options::recursive标志:fs::copy(path, basepath / "temp2", fs::copy_options::recursive, err); if (err) std::cout << err.message() << '\n';
- 
要创建指向目录的符号链接,使用 create_directory_symlink():auto linkdir = basepath / "templink"; fs::create_directory_symlink(path, linkdir, err); if (err) std::cout << err.message() << '\n';
- 
要删除空目录,使用 remove():auto temp = path / "tmp1" / "tmp4"; auto success = fs::remove(temp, err);
- 
要递归地删除目录的全部内容及其自身,使用 remove_all():auto success = fs::remove_all(path, err) != static_cast<std::uintmax_t>(-1);
- 
要更改目录或文件的权限,使用 permissions(),指定来自perms枚举的权限选项。除非您从perm_options枚举中指定操作类型(替换、添加或删除),否则默认操作是用指定的权限替换所有现有权限:// replace permissions with specified ones fs::permissions(temp, fs::perms::owner_all | fs::perms::group_all, err); if (err) std::cout << err.message() << '\n'; // remove specified permissions fs::permissions(temp, fs::perms::group_exec, fs::perm_options::remove, err); if (err) std::cout << err.message() << '\n';
使用以下库函数执行文件操作:
- 
要复制文件,使用 copy()或copy_file()。下一节将解释这两个函数之间的区别:auto success = fs::copy_file(filepath, path / "sample.bak", err); if (!success) std::cout << err.message() << '\n'; fs::copy(filepath, path / "sample.cpy", err); if (err) std::cout << err.message() << '\n';
- 
要重命名文件,使用 rename():auto newpath = path / "sample.log"; fs::rename(filepath, newpath, err); if (err) std::cout << err.message() << '\n';
- 
要移动文件,使用 rename():auto newpath = path / "sample.log"; fs::rename(newpath, path / "tmp1" / "sample.log", err); if (err) std::cout << err.message() << '\n';
- 
要创建指向文件的符号链接,使用 create_symlink():auto linkpath = path / "sample.txt.link"; fs::create_symlink(filepath, linkpath, err); if (err) std::cout << err.message() << '\n';
- 
要删除文件,使用 remove():auto success = fs::remove(path / "sample.cpy", err); if (!success) std::cout << err.message() << '\n';
工作原理...
本食谱中提到的所有函数,以及此处未讨论的其他类似函数,都有多个重载,可以归纳为两类:
- 
重载函数,其最后一个参数是 std::error_code的引用:这些重载函数不会抛出异常(它们使用noexcept指定)。相反,如果发生操作系统错误,它们将error_code对象的值设置为操作系统错误代码。如果没有发生此类错误,则调用error_code对象的clear()方法来重置任何可能之前设置的代码。
- 
不接受 std::error_code类型最后一个参数的重载函数:如果发生错误,这些重载函数会抛出异常。如果发生操作系统错误,它们会抛出std::filesystem::filesystem_error异常。另一方面,如果内存分配失败,这些函数会抛出std::bad_alloc异常。
上一节中的所有示例都使用了不抛出异常的重载,而是在发生错误时设置一个代码。一些函数返回一个 bool 来指示成功或失败。您可以通过检查方法 value() 返回的错误代码的值是否不同于 0,或者通过使用转换 operator bool 来检查 error_code 对象是否包含错误代码,该转换在相同情况下返回 true,否则返回 false。要获取错误代码的解释性字符串,请使用 message() 方法。
一些 filesystem 库函数对文件和目录都适用。rename()、remove() 和 copy() 就是这种情况。这些函数的工作细节可能很复杂,尤其是在 copy() 的情况下,并且超出了本食谱的范围。如果您需要执行这里未涵盖的任何其他操作,请参阅参考文档。
在复制文件时,可以使用两个函数:copy() 和 copy_file()。它们具有等效的重载,具有相同的签名,并且显然以相同的方式工作。然而,有一个重要的区别(除了 copy() 也可以用于目录之外):copy_file() 会跟随符号链接。
为了避免这样做,而是复制实际的符号链接,您必须使用 copy_symlink() 或带有 copy_options::copy_symlinks 标志的 copy()。copy() 和 copy_file() 函数都有一个重载,它接受 std::filesystem::copy_options 类型的参数,该参数定义了操作应该如何执行。copy_options 是一个具有以下定义的局部 enum:
enum class copy_options
{
  none               = 0,
  skip_existing      = 1,
  overwrite_existing = 2,
  update_existing    = 4,
  recursive          = 8,
  copy_symlinks      = 16,
  skip_symlinks      = 32,
  directories_only   = 64,
  create_symlinks    = 128,
  create_hard_links  = 256
}; 
以下表格定义了这些标志如何影响使用 copy() 或 copy_file() 进行的复制操作。该表格来自 N4917 版本 C++ 标准的 31.12.8.3 段落:
| 选项组控制现有目标文件对 copy_file函数的影响 | 
|---|
| none | 
| skip_existing | 
| overwrite_existing | 
| update_existing | 
| 选项组控制子目录对 copy函数的影响 | 
| none | 
| recursive | 
| 选项组控制符号链接对 copy函数的影响 | 
| none | 
| copy_symlinks | 
| skip_symlinks | 
| 选项组控制复制函数效果以选择复制形式 | 
| none | 
| directories_only | 
| create_symlinks | 
| create_hard_links | 
表 7.3:copy_operation 标志如何影响复制操作
另一个应该提到的方面与符号链接有关:create_directory_symlink()创建指向目录的符号链接,而create_symlink()创建指向文件或目录的符号链接。在 POSIX 系统上,当涉及到目录时,这两个是相同的。在其他系统(如 Windows)上,目录的符号链接与文件的符号链接创建方式不同。因此,建议您为目录使用create_directory_symlink(),以便编写适用于所有系统的正确代码。
当您对文件和目录执行操作,例如本食谱中描述的操作,并使用可能抛出异常的重载时,请确保您在调用上使用try-catch。无论使用哪种重载类型,您都应该检查操作的成功,并在失败的情况下采取适当的行动。
如果您需要更改文件或目录的权限,可以使用permissions()函数。它有几个重载,允许您指定一系列权限选项。这些在std::filesystem::perms枚举中定义。如果您不指定特定的更改操作,则执行现有权限的完全替换。但是,您可以使用从std::filesystem::perm_options枚举中可用的选项来指定添加或删除权限。除了replace、add和remove之外,还有一个第四个选项,nofollow。这适用于符号链接,因此权限更改是在符号链接本身上进行的,而不是在它解析到的文件上。
参见
- 
处理文件系统路径,了解 C++17 标准对文件系统路径的支持 
- 
从文件中删除内容,探索删除文件内容部分的可能方法 
- 
检查现有文件或目录的属性,了解如何查询文件和目录的属性,如类型、权限、文件时间等 
从文件中删除内容
filesystem库直接提供了复制、重命名、移动或删除文件等操作。然而,当涉及到从文件中删除内容时,您必须执行显式操作。
无论您需要为文本文件还是二进制文件执行此操作,您都可以实现以下模式:
- 
创建一个临时文件。 
- 
仅从原始文件复制您想要的内容到临时文件。 
- 
删除原始文件。 
- 
将临时文件重命名/移动到原始文件的名字/位置。 
在本食谱中,我们将学习如何为文本文件实现此模式。
准备工作
为了本食谱的目的,我们将考虑从文本文件中删除空行或以分号(;)开头的行。对于这个示例,我们将有一个初始文件,称为sample.dat,其中包含莎士比亚戏剧的名称,但也包含空行和以分号开头的行。以下是该文件的部分列表(从开头):
;Shakespeare's plays, listed by genre
;TRAGEDIES
Troilus and Cressida
Coriolanus
Titus Andronicus
Romeo and Juliet
Timon of Athens
Julius Caesar 
下一个部分列出的代码示例使用以下变量:
auto path = fs::current_path();
auto filepath = path / "sample.dat";
auto temppath = path / "sample.tmp";
auto err = std::error_code{}; 
在下一节中,我们将学习如何将此模式放入代码。
如何做...
执行以下操作以从文件中删除内容:
- 
打开文件进行读取: std::ifstream in(filepath); if (!in.is_open()) { std::cout << "File could not be opened!" << '\n'; return; }
- 
打开另一个临时文件进行写入;如果文件已存在,则截断其内容: std::ofstream out(temppath, std::ios::trunc); if (!out.is_open()) { std::cout << "Temporary file could not be created!" << '\n'; return; }
- 
逐行读取输入文件,并将选定的内容复制到输出文件: auto line = std::string{}; while (std::getline(in, line)) { if (!line.empty() && line.at(0) != ';') { out << line << 'n'; } }
- 
关闭输入和输出文件: in.close(); out.close();
- 
删除原始文件: auto success = fs::remove(filepath, err); if(!success || err) { std::cout << err.message() << '\n'; return; }
- 
将临时文件重命名/移动到原始文件的名字/位置: fs::rename(temppath, filepath, err); if (err) { std::cout << err.message() << '\n'; }
它是如何工作的...
这里描述的模式也适用于二进制文件;然而,为了保持简洁,我们只讨论了与文本文件相关的示例。在这个示例中,临时文件与原始文件位于同一目录下。或者,它也可以位于一个单独的目录中,例如用户临时目录。要获取临时目录的路径,您可以使用std::filesystem::temp_directory_path()。在 Windows 系统上,此函数返回与GetTempPath()相同的目录。在 POSIX 系统上,它返回在环境变量TMPDIR、TMP、TEMP或TEMPDIR中指定的路径,如果没有这些变量,则返回路径/tmp。
从原始文件复制到临时文件的内容因情况而异,取决于需要复制的内容。在前面的示例中,我们复制了整个行,除非它们是空的或以分号(;)开头。
为了这个目的,我们使用std::getline()逐行读取原始文件的内容,直到没有更多的行可以读取。在复制所有必要的内容后,应该关闭文件以便它们可以被移动或删除。
要完成操作,有三个选项:
- 
如果它们位于同一目录中,则删除原始文件并将临时文件重命名为与原始文件相同的名称;如果它们位于不同的目录中,则将临时文件移动到原始文件的位置。这是本食谱采用的方法。为此,我们使用了 remove()函数来删除原始文件,并使用rename()将临时文件重命名为原始文件名。
- 
将临时文件的内容复制到原始文件(为此,您可以使用 copy()或copy_file()函数)然后删除临时文件(使用remove()进行此操作)。
- 
重命名原始文件(例如,更改扩展名或名称),然后使用原始文件名重命名/移动临时文件。 
如果你采用这里提到的第一种方法,那么你必须确保后来替换原始文件的临时文件具有与原始文件相同的文件权限;否则,根据你解决方案的上下文,可能会导致问题。
参见
- 创建、复制和删除文件和目录,以了解如何独立于使用的文件系统执行这些基本操作
检查现有文件或目录的属性
filesystem 库提供了函数和类型,使开发者能够检查文件系统对象的存在,例如文件或目录,其属性,例如类型(文件、目录、符号链接等),最后写入时间、权限等。在本配方中,我们将探讨这些类型和函数是什么以及如何使用它们。
准备工作
对于以下代码示例,我们将使用命名空间别名 fs 来表示 std::filesystem 命名空间。filesystem 库在具有相同名称的标题中可用,即 <filesystem>。此外,我们将使用这里显示的变量,path 表示文件的路径,err 用于从文件系统 API 接收潜在的操作系统错误代码:
auto path = fs::current_path() / "main.cpp";
auto err = std::error_code{}; 
此外,这里显示的函数 to_time_t 将在本配方中引用:
 template <typename TP>
  std::time_t to_time_t(TP tp)
 {
     using namespace std::chrono;
     auto sctp = time_point_cast<system_clock::duration>(
       tp - TP::clock::now() + system_clock::now());
     return system_clock::to_time_t(sctp);
  } 
在继续此配方之前,你应该阅读 使用文件系统路径 配方。
如何做...
使用以下库函数来检索关于文件系统对象的信息:
- 
要检查路径是否指向一个现有的文件系统对象,请使用 exists():auto exists = fs::exists(path, err); std::cout << "file exists: " << std::boolalpha << exists << '\n';
- 
要检查两个不同的路径是否指向相同的文件系统对象,请使用 equivalent():auto same = fs::equivalent(path, fs::current_path() / "." / "main.cpp", err); std::cout << "equivalent: " << same << '\n';
- 
要检索文件的字节数,请使用 file_size()。这不需要打开文件,因此应该优先于打开文件然后使用seekg()/tellg()函数的方法:auto size = fs::file_size(path, err); std::cout << "file size: " << size << '\n';
- 
要检索文件系统对象的硬链接数量,请使用 hard_link_count():auto links = fs::hard_link_count(path, err); if(links != static_cast<uintmax_t>(-1)) std::cout << "hard links: " << links << '\n'; else std::cout << "hard links: error" << '\n';
- 
要检索或设置文件系统对象的最后修改时间,请使用 last_write_time():auto lwt = fs::last_write_time(path, err); auto time = to_time_t(lwt); auto localtime = std::localtime(&time); std::cout << "last write time: " << std::put_time(localtime, "%c") << '\n';
- 
要检索文件属性,例如类型和权限(类似于 POSIX stat函数返回的值),请使用status()函数。此函数会跟随符号链接。要检索不跟随符号链接的符号链接的文件属性,请使用symlink_status():auto print_perm = [](fs::perms p) { std::cout << ((p & fs::perms::owner_read) != fs::perms::none ? "r" : "-") << ((p & fs::perms::owner_write) != fs::perms::none ? "w" : "-") << ((p & fs::perms::owner_exec) != fs::perms::none ? "x" : "-") << ((p & fs::perms::group_read) != fs::perms::none ? "r" : "-") << ((p & fs::perms::group_write) != fs::perms::none ? "w" : "-") << ((p & fs::perms::group_exec) != fs::perms::none ? "x" : "-") << ((p & fs::perms::others_read) != fs::perms::none ? "r" : "-") << ((p & fs::perms::others_write) != fs::perms::none ? "w" : "-") << ((p & fs::perms::others_exec) != fs::perms::none ? "x" : "-") << '\n'; }; auto status = fs::status(path, err); std::cout << "type: " << static_cast<int>(status.type()) << '\n'; std::cout << "permissions: "; print_perm(status.permissions());
- 
要检查路径是否指向特定类型的文件系统对象,例如文件、目录、符号链接等,请使用 is_regular_file()、is_directory()、is_symlink()等函数:std::cout << "regular file? " << fs::is_regular_file(path, err) << '\n'; std::cout << "directory? " << fs::is_directory(path, err) << '\n'; std::cout << "char file? " << fs::is_character_file(path, err) << '\n'; std::cout << "symlink? " << fs::is_symlink(path, err) << '\n';
- 
要检查文件或目录是否为空,请使用 is_empty()函数:bool empty = fs::is_empty(path, err); if (!err) { std::cout << std::boolalpha << "is_empty(): " << empty << '\n'; }
它是如何工作的...
这些函数(用于检索关于文件系统文件和目录的信息)通常简单直接。然而,需要考虑一些因素:
- 
检查文件系统对象是否存在可以使用 exists(),通过传递路径或使用status()函数之前检索的std::filesystem::file_status对象。
- 
equivalent()函数确定两个文件系统对象的状态是否相同,状态是通过status()函数获取的。如果两个路径都不存在,或者两个路径都存在但都不是文件、目录或符号链接,则该函数返回错误。指向同一文件对象的硬链接是等效的。符号链接及其目标也是等效的。
- 
file_size()函数只能用来确定指向常规文件的常规文件和符号链接的大小。对于其他类型的文件对象,例如目录,此函数将失败。此函数返回文件的字节数,或者在发生错误时返回-1。如果你想确定一个文件是否为空,你可以使用is_empty()函数。这适用于所有类型的文件系统对象,包括目录。
- 
last_write_time()函数有两个重载集:一个用于检索文件系统对象的最后修改时间,另一个用于设置最后修改时间。时间由一个std::filesystem::file_time_type对象表示,这基本上是std::chrono::time_point的类型别名。以下示例将文件的最后写入时间更改为比之前值早 30 分钟:using namespace std::chrono_literals; auto lwt = fs::last_write_time(path, err); fs::last_write_time(path, lwt - 30min);
- 
status()函数确定文件系统对象类型和权限。如果文件是符号链接,返回的信息是关于符号链接的目标。要检索关于符号链接本身的信息,必须使用symlink_status()函数。这些函数返回一个std::filesystem::file_status对象。此类有一个 type()成员函数用于检索文件类型,以及一个permissions()成员函数用于检索文件权限。文件类型是通过std::filesystem::file_type枚举定义的。文件权限是通过std::filesystem::perms枚举定义的。此枚举的所有枚举值并不都代表权限;其中一些代表控制位,例如add_perms,表示应该添加权限,或者remove_perms,表示应该移除权限。permissions()函数可以用来修改文件或目录的权限。以下示例向文件的所有者和用户组添加所有权限:fs::permissions( path, fs::perms::add_perms | fs::perms::owner_all | fs::perms::group_all, err);
- 
要确定文件系统对象(如文件、目录或符号链接)的类型,有两种选择:检索文件状态然后检查 type属性,或者使用可用的文件系统函数之一,例如is_regular_file()、is_symlink()或is_directory()。以下检查路径是否指向常规文件的示例是等效的:auto s = fs::status(path, err); auto isfile = s.type() == std::filesystem::file_type::regular; auto isfile = fs::is_regular_file(path, err);
本配方中讨论的所有函数都有一个在发生错误时抛出异常的重载版本,以及一个不抛出异常但通过函数参数返回错误代码的重载版本。本配方中的所有示例都使用了这种方法。有关这些重载版本的更多信息,请参阅创建、复制和删除文件和目录配方。虽然本配方中的代码片段没有显示(为了简单起见),但检查这些函数返回的error_code值非常重要。它所持有的实际意义取决于返回它的调用以及它所属的值类别(例如系统、I/O 流或通用)。然而,值0在所有值类别中都被认为是表示成功。因此,您可以按以下方式检查成功:
auto lwt = fs::last_write_time(path, err);
if (!err) // success
{
   auto time = to_time_t(lwt);
   auto localtime = std::localtime(&time);
   std::cout << "last write time: "
             << std::put_time(localtime, "%c") << '\n';
} 
如果您使用不返回错误代码但抛出异常的重载版本,则需要捕获该可能的异常。以下是一个示例:
try
{
   auto exists = fs::exists(path);
   std::cout << "file exists: " << std::boolalpha << exists << '\n';
}
catch (std::filesystem::filesystem_error const& ex)
{
   std::cerr << ex.what() << '\n';
} 
参见
- 
使用文件系统路径,了解 C++17 标准对文件系统路径的支持 
- 
创建、复制和删除文件和目录,了解如何独立于使用的文件系统执行这些基本操作 
- 
列举目录内容,了解如何遍历目录的文件和子目录 
列举目录内容
到目前为止,在本章中,我们已经探讨了filesystem库提供的许多功能,例如处理路径、对文件和目录执行操作(创建、移动、重命名、删除等)、查询或修改属性。当与文件系统一起工作时,另一个有用的功能是遍历目录的内容。filesystem库提供了两个目录迭代器,一个称为directory_iterator,它遍历目录的内容,另一个称为recursive_directory_iterator,它递归遍历目录及其子目录的内容。在本配方中,我们将学习如何使用它们。
准备工作
对于本配方,我们将考虑具有以下结构的目录:
test/
├──data/
│ ├──input.dat
│ └──output.dat
├──file_1.txt
├──file_2.txt
└──file_3.log 
在以下代码片段中,我们将引用以下函数:
void print_line(std::string_view prefix, 
                std::filesystem::path const& path)
{
   std::cout << prefix << path << '\n';
} 
在本配方中,我们将使用文件系统路径并检查文件系统对象属性。因此,建议您首先阅读使用文件系统路径和检查现有文件或目录属性配方。
如何做到这一点...
使用以下模式来枚举目录的内容:
- 
要迭代目录的内容而不递归访问其子目录,请使用 directory_iterator:void visit_directory(fs::path const & dir) { if (fs::exists(dir) && fs::is_directory(dir)) { for (auto const & entry : fs::directory_iterator(dir)) { auto filename = entry.path().filename(); if (fs::is_directory(entry.status())) print_line("[+]", filename); else if (fs::is_symlink(entry.status())) print_line("[>]", filename); else if (fs::is_regular_file(entry.status())) print_line(" ", filename); else print_line("[?]", filename); } } }
- 
要迭代目录的所有内容,包括其子目录,当处理条目的顺序不重要时,请使用 recursive_directory_iterator:void visit_directory_rec(fs::path const & dir) { if (fs::exists(dir) && fs::is_directory(dir)) { for (auto const & entry : fs::recursive_directory_iterator(dir)) { auto filename = entry.path().filename(); if (fs::is_directory(entry.status())) print_line("[+]", filename); else if (fs::is_symlink(entry.status())) print_line("[>]",filename); else if (fs::is_regular_file(entry.status())) print_line(" ",filename); else print_line("[?]",filename); } } }
- 
以结构化的方式遍历目录的所有内容,包括其子目录,例如像遍历树一样,可以使用与第一个示例中类似的功能,该功能使用 directory_iterator遍历目录的内容。然而,相反,为每个子目录递归地调用它:void visit_directory_rec_ordered( fs::path const & dir, bool const recursive = false, unsigned int const level = 0) { if (fs::exists(dir) && fs::is_directory(dir)) { auto lead = std::string(level*3, ' '); for (auto const & entry : fs::directory_iterator(dir)) { auto filename = entry.path().filename(); if (fs::is_directory(entry.status())) { print_line(lead + "[+]", filename); if(recursive) visit_directory_rec_ordered(entry, recursive, level+1); } else if (fs::is_symlink(entry.status())) print_line(lead + "[>]", filename); else if (fs::is_regular_file(entry.status())) print_line(lead + " ", filename); else print_line(lead + "[?]", filename); } } }
它是如何工作的...
directory_iterator和recursive_directory_iterator都是输入迭代器,它们遍历目录的条目。区别在于第一个不递归访问子目录,而第二个,正如其名称所暗示的,确实会递归访问。它们都有类似的行为:
- 
迭代顺序是不确定的。 
- 
每个目录条目只访问一次。 
- 
特殊路径点( .)和点-点(..)被跳过。
- 
默认构造的迭代器是结束迭代器,并且两个结束迭代器始终相等。 
- 
当迭代到最后的目录条目之后,它将等于结束迭代器。 
- 
标准没有指定在迭代器创建之后向迭代目录中添加或从迭代目录中删除目录条目会发生什么。 
- 
标准为 directory_iterator和recursive_directory_iterator定义了非成员函数begin()和end(),这使得我们可以在前面的示例中使用的基于范围的for循环中使用这些迭代器。
两个迭代器都有重载的构造函数。recursive_directory_iterator构造函数的一些重载接受std::filesystem::directory_options类型的参数,该参数指定了迭代的附加选项:
- 
none:这是默认值,不指定任何内容。
- 
follow_directory_symlink:这指定了迭代应该跟随符号链接而不是提供链接本身。
- 
skip_permission_denied:这指定了应该忽略并跳过可能触发访问拒绝错误的目录。
两个目录迭代器指向的元素都是directory_entry类型。path()成员函数返回由该对象表示的文件系统对象的路径。可以通过成员函数status()和symlink_status()获取文件系统对象的状态,对于符号链接。
前面的示例遵循一个常见的模式:
- 
验证要迭代的路径实际上存在。 
- 
使用基于范围的 for循环来迭代目录的所有条目。
- 
根据迭代的方式,使用 filesystem库中可用的两个目录迭代器之一。
- 
根据要求处理每个条目。 
在我们的示例中,我们只是简单地将目录条目的名称打印到控制台。重要的是要注意,正如我们之前指定的那样,目录的内容是按未指定的顺序迭代的。如果你想要以结构化的方式处理内容,例如显示缩进的子目录及其条目(对于这个特定情况)或树(在其他类型的应用程序中),那么使用recursive_directory_iterator是不合适的。相反,你应该在从迭代中递归调用的函数中使用directory_iterator,对于每个子目录,就像上一节中的最后一个示例所示。
考虑到本配方开头提供的目录结构(相对于当前路径),当我们使用递归迭代器时,会得到以下输出,如下所示:
visit_directory_rec(fs::current_path() / "test"); 
[+]data
   input.dat
   output.dat
   file_1.txt
   file_2.txt
   file_3.log 
另一方面,当使用第三个示例中的递归函数时,如下所示,输出将按子级顺序显示,正如预期的那样:
visit_directory_rec_ordered(fs::current_path() / "test", true); 
[+]data
      input.dat
      output.dat
   file_1.txt
   file_2.txt
   file_3.log 
请记住,visit_directory_rec()函数是一个非递归函数,它使用recursive_directory_iterator,而visit_directory_rec_ordered()函数是一个递归函数,它使用directory_iterator。这个例子应该有助于你理解这两个迭代器之间的区别。
更多内容...
在之前的配方中,检查现有文件或目录的属性,我们讨论了诸如file_size()函数等内容,该函数返回文件的字节数。然而,如果指定的路径是目录,则此函数会失败。为了确定目录的大小,我们需要递归地遍历目录的内容,检索常规文件或符号链接的大小,并将它们相加。
考虑以下函数来举例说明这种情况:
std::uintmax_t dir_size(fs::path const & path)
{
   if (fs::exists(path) && fs::is_directory(path))
   {
      auto size = static_cast<uintmax_t>(0);
      for (auto const & entry :
           fs::recursive_directory_iterator(path))
      {
         if (fs::is_regular_file(entry.status()) ||
            fs::is_symlink(entry.status()))
         {
            auto err = std::error_code{};
            auto filesize = fs::file_size(entry, err);
            if (!err)
               size += filesize;
         }
      }
      return size;
   }
   return static_cast<uintmax_t>(-1);
} 
之前的dir_size()函数返回目录中所有文件的大小(递归),或者在出现错误(路径不存在或不代表目录)的情况下,以uintmax_t返回-1。
参见
- 
检查现有文件或目录的属性,了解如何查询文件和目录的属性,例如类型、权限、文件时间等 
- 
查找文件,了解如何根据文件名、扩展名或其他属性搜索文件 
查找文件
在之前的配方中,我们学习了如何使用directory_iterator和recursive_directory_iterator来枚举目录的内容。显示目录内容,就像我们在之前的配方中所做的那样,这只是需要这样做的一种场景。另一个主要场景是在目录中搜索特定条目,例如具有特定名称、扩展名等的文件。在这个配方中,我们将演示如何使用目录迭代器和之前展示的迭代模式来查找符合给定标准的文件。
准备工作
您应该阅读之前的配方,列出目录内容,以了解有关目录迭代器的详细信息。在本配方中,我们也将使用之前配方中展示的相同的测试目录结构。
如何操作...
要查找符合特定标准的文件,请使用以下模式:
- 
使用 recursive_directory_iterator遍历目录的所有条目及其子目录。
- 
考虑常规文件(以及您可能需要处理的任何其他类型的文件)。 
- 
使用函数对象(例如 lambda 表达式)来过滤仅匹配您条件的文件。 
- 
将选定的条目添加到容器中(例如向量)。 
此模式在下面的find_files()函数中得到了体现:
std::vector<fs::path> find_files(
    fs::path const & dir,
    std::function<bool(fs::path const&)> filter)
{
  auto result = std::vector<fs::path>{};
  if (fs::exists(dir))
  {
    for (auto const & entry :
      fs::recursive_directory_iterator(
        dir,
        fs::directory_options::follow_directory_symlink))
    {
      if (fs::is_regular_file(entry) &&
          filter(entry))
      {
        result.push_back(entry);
      }
    }
  }
  return result;
} 
工作原理...
当我们想要在目录中查找文件时,目录的结构以及其条目(包括子目录)的访问顺序可能并不重要。因此,我们可以使用recursive_directory_iterator遍历条目。
find_files()函数接受两个参数:一个路径和一个用于选择应返回的条目的函数包装器。返回类型是filesystem::path的向量,但也可以是filesystem::directory_entry的向量。在此示例中使用的递归目录迭代器不跟随符号链接,返回链接本身而不是目标。这种行为可以通过使用具有filesystem::directory_options类型参数的构造函数重载并传递follow_directory_symlink来改变。
在前面的示例中,我们只考虑常规文件并忽略其他类型的文件系统对象。谓词应用于目录条目,如果它返回true,则条目被添加到结果中。
以下示例使用find_files()函数查找测试目录中以file_为前缀的所有文件:
auto results = find_files(
          fs::current_path() / "test",
          [](fs::path const & p) {
  auto filename = p.wstring();
  return filename.find(L"file_") != std::wstring::npos;
});
for (auto const & path : results)
{
  std::cout << path << '\n';
} 
执行此程序后的输出,相对于当前路径,如下所示:
test\file_1.txt
test\file_2.txt
test\file_3.log 
第二个示例展示了如何查找具有特定扩展名的文件,在这种情况下,扩展名为.dat:
auto results = find_files(
       fs::current_path() / "test",
       [](fs::path const & p) {
         return p.extension() == L".dat";});
for (auto const & path : results)
{
  std::cout << path << '\n';
} 
输出,同样相对于当前路径,如下所示:
test\data\input.dat
test\data\output.dat 
这两个示例非常相似。唯一不同的是 lambda 函数中的代码,它检查作为参数接收的路径。
参考以下内容
- 
检查现有文件或目录的属性,了解如何查询文件和目录的属性,例如类型、权限、文件时间等 
- 
列出目录内容,了解如何遍历目录的文件和子目录 
在 Discord 上了解更多信息
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
discord.gg/7xRaTCeEhx

第八章:利用线程和并发
大多数计算机都包含多个处理器或至少多个核心,利用这种计算能力对于许多应用类别至关重要。不幸的是,许多开发者仍然持有顺序代码执行的心态,即使不依赖彼此的操作也可以并发执行。本章介绍了标准库对线程、异步任务和相关组件的支持,以及一些最后的实际示例。
大多数现代处理器(除了那些针对不需要强大计算能力的应用类型,如物联网应用)都有两个、四个或更多核心,这使您能够并发执行多个执行线程。应用程序必须明确编写以利用现有的多个处理单元;您可以通过同时在多个线程上执行函数来编写此类应用程序。自 C++11 以来,标准库提供了与线程、共享数据同步、线程通信和异步任务一起工作的支持。在本章中,我们将探讨与线程和任务相关的重要主题。
本章包括以下食谱:
- 
与线程一起工作 
- 
使用互斥锁和锁同步对共享数据的访问 
- 
寻找递归互斥锁的替代方案 
- 
处理线程函数的异常 
- 
在线程之间发送通知 
- 
使用承诺和未来从线程返回值 
- 
异步执行函数 
- 
使用原子类型 
- 
使用线程实现并行 map和fold
- 
使用任务实现并行 map和fold
- 
使用标准并行算法实现并行 map和fold
- 
使用可连接线程和取消机制 
- 
使用闩锁、屏障和信号量同步线程 
- 
从多个线程同步写入输出流 
本章的第一部分,我们将探讨库中内置支持的多种线程对象和机制,例如线程、锁定对象、条件变量、异常处理等。
与线程一起工作
线程是由调度程序(如操作系统)独立管理的指令序列。线程可以是软件或硬件。软件线程是由操作系统管理的执行线程。它们通常通过时间切片在单个处理单元上运行。这是一种机制,其中每个线程在操作系统调度另一个软件线程在相同处理单元上运行之前,在处理单元上获得一个执行时间槽(在毫秒范围内)。硬件线程是在物理层面的执行线程。它们基本上是一个 CPU 或 CPU 核心。它们可以在具有多处理器或多核的系统上同时运行,即并行运行。许多软件线程可以同时在硬件线程上运行,通常通过使用时间切片。C++库提供了与软件线程一起工作的支持。在本食谱中,你将学习如何创建和操作线程。
准备工作
执行线程由thread类表示,该类在<thread>头文件中的std命名空间中可用。相同的头文件中还有其他线程实用工具,但位于std::this_thread命名空间中。
在以下示例中,使用了print_time()函数。此函数将本地时间打印到控制台。其实现如下:
inline void print_time()
{
  auto now = std::chrono::system_clock::now();
  auto stime = std::chrono::system_clock::to_time_t(now);
  auto ltime = std::localtime(&stime);
  std::cout << std::put_time(ltime, "%c") << '\n';
} 
在下一节中,我们将看到如何使用线程执行常见操作。
如何做到这一点...
使用以下解决方案来管理线程:
- 
要创建一个不启动新线程执行的 std::thread对象,请使用其默认构造函数:std::thread t;
- 
通过构造一个 std::thread对象并将函数作为参数传递,在另一个线程上启动函数的执行:void func1() { std::cout << "thread func without params" << '\n'; } std::thread t1(func1); std::thread t2([]() { std::cout << "thread func without params" << '\n'; });
- 
通过构造一个 std::thread对象,并将函数作为构造函数的参数传递,然后传递其参数,在另一个线程上启动具有参数的函数的执行:void func2(int const i, double const d, std::string const s) { std::cout << i << ", " << d << ", " << s << '\n'; } std::thread t(func2, 42, 42.0, "42");
- 
要等待线程完成其执行,请使用 thread对象的join()方法:t.join();
- 
要允许线程独立于当前 thread对象继续执行,请使用detach()方法。这意味着线程将继续执行,直到完成,而不会被std::thread对象管理,该对象将不再拥有任何线程:t.detach();
- 
要将引用传递给函数线程,请将它们包装在 std::ref或std::cref(如果引用是常量)中:void func3(int & i) { i *= 2; } int n = 42; std::thread t(func3, std::ref(n)); t.join(); std::cout << n << '\n'; // 84
- 
要使线程的执行停止指定的时间长度,请使用 std::this_thread::sleep_for()函数:void func4() { using namespace std::chrono; print_time(); std::this_thread::sleep_for(2s); print_time(); } std::thread t(func4); t.join();
- 
要使线程的执行停止到指定的时间点,请使用 std::this_thread::sleep_until()函数:void func5() { using namespace std::chrono; print_time(); std::this_thread::sleep_until( std::chrono::system_clock::now() + 2s); print_time(); } std::thread t(func5); t.join();
- 
要挂起当前线程的执行并提供其他线程执行的机会,请使用 std::this_thread::yield():void func6(std::chrono::seconds timeout) { auto now = std::chrono::system_clock::now(); auto then = now + timeout; do { std::this_thread::yield(); } while (std::chrono::system_clock::now() < then); } std::thread t(func6, std::chrono::seconds(2)); t.join(); print_time();
它是如何工作的...
表示单个执行线程的std::thread类有几个构造函数:
- 
一个默认构造函数,它只创建线程对象,但不启动新线程的执行。 
- 
一个移动构造函数,它创建一个新的线程对象来表示之前由构造函数所创建的对象所表示的线程执行。在新对象构造完成后,另一个对象就不再与执行线程相关联。 
- 
一个带有可变数量参数的构造函数:第一个是一个表示顶级线程函数的函数,其余的是要传递给线程函数的参数。参数需要通过值传递给线程函数。如果线程函数通过引用或常量引用接收参数,它们必须被包装在 std::ref或std::cref对象中。这些是辅助函数模板,它们生成std::reference_wrapper类型的对象,该对象将引用包装在可复制和可赋值的对象中。
在这个例子中,线程函数无法返回值。函数实际上具有除void之外的返回类型并不违法,但它会忽略函数直接返回的任何值。如果它必须返回一个值,可以使用共享变量或函数参数来实现。在本书后面的使用承诺和未来从线程返回值配方中,我们将看到线程函数如何使用承诺将值返回给另一个线程。
如果函数因异常而终止,则无法在启动线程的上下文中使用try...catch语句捕获异常。所有异常都必须在执行线程中被捕获,但它们可以通过std::exception_ptr对象在线程之间传输。我们将在稍后的配方中讨论这个主题,称为处理线程函数中的异常。
在线程开始执行后,它既是可连接的也是可分离的。连接线程意味着阻塞当前线程的执行,直到连接的线程结束其执行。分离线程意味着将线程对象与其所代表的线程执行解耦,允许当前线程和分离的线程同时执行。分离的线程有时被称为后台线程或守护线程。当程序终止(通过从主函数返回)时,仍在运行的分离线程不会被等待。这意味着那些线程的堆栈不会被回滚。因此,堆栈上对象的析构函数不会被调用,这可能导致资源泄漏或资源损坏(文件、共享内存等)。
使用join()方法来连接线程,使用detach()方法来分离线程。一旦调用这两个方法之一,该线程就被认为是不可连接的,线程对象可以安全地被销毁。当线程被分离时,它可能需要访问的共享数据必须在其整个执行过程中可用。
当你断开线程时,你不能再将其连接。尝试这样做将导致运行时错误。你可以通过使用joinable()成员函数来检查线程是否可以被连接来防止这种情况。
如果线程对象超出作用域并被销毁,但既没有调用join()也没有调用detach(),则将调用std::terminate()。
每个线程都有一个可以检索的标识符。对于当前线程,调用std::this_thread::get_id()函数。对于由thread对象表示的另一个执行线程,调用其get_id()方法。
在std::this_thread命名空间中提供了几个额外的实用函数:
- 
yield()方法向调度器暗示激活另一个线程。这在实现忙等待例程时很有用,如上一节中的最后一个示例。然而,实际行为是特定于实现的。实际上,对这个函数的调用可能对线程的执行没有影响。
- 
sleep_for()方法阻塞当前线程的执行,至少达到指定的持续时间(由于调度,线程被置于睡眠状态的实际时间可能比请求的持续时间更长)。
- 
sleep_until()方法阻塞当前线程的执行,直到至少达到指定的时刻(由于调度,实际睡眠时间可能比请求的时间更长)。
std::thread类需要显式调用join()方法来等待线程完成。这可能导致编程错误(如上所述)。C++20 标准提供了一个新的线程类,称为std::jthread,它解决了这个不便之处。这将是本章后面“使用可连接线程和取消机制”食谱的主题。
参见
- 
使用互斥锁和锁同步对共享数据的访问,了解可用于同步线程对共享数据访问的机制以及它们是如何工作的 
- 
寻找递归互斥锁的替代方案,了解为什么应该避免递归互斥锁,以及如何将使用递归互斥锁的线程安全类型转换为使用非递归互斥锁的线程安全类型 
- 
处理线程函数中的异常,了解如何在主线程或连接线程中处理从工作线程抛出的异常 
- 
在线程之间发送通知,了解如何使用条件变量在生产者和消费者线程之间发送通知 
- 
使用承诺和未来从线程返回值,了解如何使用 std::promise对象从线程返回一个值或异常
使用互斥锁和锁同步对共享数据的访问
线程允许你同时执行多个函数,但通常这些函数需要访问共享资源。对共享资源的访问必须进行同步,以确保一次只有一个线程可以读取或写入共享资源。在本例中,我们将看到 C++标准定义了哪些机制来同步对共享数据的线程访问,以及它们是如何工作的。
准备工作
本例中讨论的mutex和lock类在<mutex>头文件中的std命名空间中可用,而<shared_mutex>用于 C++14 的共享互斥锁和锁。
如何操作...
使用以下模式来同步对单个共享资源的访问:
- 
在适当的作用域(类或全局作用域)中定义一个 mutex:std::mutex g_mutex;
- 
在每个线程访问共享资源之前,先获取这个 mutex的lock:void thread_func() { using namespace std::chrono_literals; { std::lock_guard<std::mutex> lock(g_mutex); std::cout << "running thread " << std::this_thread::get_id() << '\n'; } std::this_thread::yield(); std::this_thread::sleep_for(2s); { std::lock_guard<std::mutex> lock(g_mutex); std::cout << "done in thread " << std::this_thread::get_id() << '\n'; } }
使用以下模式来同步对多个共享资源的访问,以避免死锁:
- 
在适当的作用域(全局或类作用域)中为每个共享资源定义一个互斥锁(mutex): template <typename T> struct container { std::mutex mutex; std::vector<T> data; };
- 
使用死锁避免算法通过 std::lock()同时锁定互斥锁:template <typename T> void move_between(container<T> & c1, container<T> & c2, T const value) { std::lock(c1.mutex, c2.mutex); // continued at 3. }
- 
在锁定它们之后,将每个互斥锁的所有权采用到 std::lock_guard类中,以确保在函数(或作用域)结束时安全释放:// continued from 2. std::lock_guard<std::mutex> l1(c1.mutex, std::adopt_lock); std::lock_guard<std::mutex> l2(c2.mutex, std::adopt_lock); c1.data.erase( std::remove(c1.data.begin(), c1.data.end(), value), c1.data.end()); c2.data.push_back(value);
它是如何工作的...
互斥锁(互斥)是一种同步原语,它允许我们从多个线程中保护对共享资源的同步访问。C++标准库提供了几种实现:
- 
std::mutex是最常用的互斥锁类型;它在上面的代码片段中进行了说明。它提供了获取和释放互斥锁的方法。lock()尝试获取互斥锁,如果不可用则阻塞,try_lock()尝试获取互斥锁,如果不可用则不阻塞并返回,unlock()释放互斥锁。
- 
std::timed_mutex与std::mutex类似,但提供了两种使用超时获取互斥锁的方法:try_lock_for()尝试获取互斥锁,如果在指定的时间内互斥锁不可用,则返回它,try_lock_until()尝试获取互斥锁,如果在指定的时间点之前互斥锁不可用,则返回它。
- 
std::recursive_mutex与std::mutex类似,但互斥锁可以从同一线程多次获取而不会被阻塞。
- 
std::recursive_timed_mutex是递归互斥锁和定时互斥锁的组合。
- 
std::shared_timed_mutex自 C++14 起使用,适用于在多个读者可以同时访问同一资源而不引起数据竞争的场景,同时只允许一个写者这样做。它实现了两种访问级别的锁定 – 共享(多个线程可以共享同一互斥锁的所有权)和独占(只有一个线程可以拥有互斥锁) – 并提供了超时功能。
- 
std::shared_mutex自 C++17 起与shared_timed_mutex类似,但没有超时功能。
第一个锁定可用互斥量的线程将拥有它并继续执行。所有从任何线程尝试锁定互斥量的后续尝试都会失败,包括已经拥有互斥量的线程,并且lock()方法会阻塞线程,直到通过调用unlock()释放互斥量。如果一个线程需要能够多次锁定互斥量而不被阻塞,从而避免死锁,则应使用recursive_mutex类模板。
使用互斥量保护对共享资源的访问的典型用法包括锁定互斥量,使用共享资源,然后解锁互斥量:
g_mutex.lock();
// use the shared resource such as std::cout
std::cout << "accessing shared resource" << '\n';
g_mutex.unlock(); 
然而,这种使用互斥量的方法容易出错。这是因为每个对lock()的调用都必须与所有执行路径上的unlock()调用配对;也就是说,无论是正常返回路径还是异常返回路径。为了安全地获取和释放互斥量,无论函数的执行方式如何,C++标准定义了几个锁定类:
- 
std::lock_guard是之前看到的锁定机制;它代表了一种以 RAII 方式实现的互斥量包装器。它试图在构造时获取互斥量,并在销毁时释放它。这在 C++11 中可用。以下是对lock_guard的典型实现:template <class M> class lock_guard { public: typedef M mutex_type; explicit lock_guard(M& Mtx) : mtx(Mtx) { mtx.lock(); } lock_guard(M& Mtx, std::adopt_lock_t) : mtx(Mtx) { } ~lock_guard() noexcept { mtx.unlock(); } lock_guard(const lock_guard&) = delete; lock_guard& operator=(const lock_guard&) = delete; private: M& mtx; };
- 
std::unique_lock是一个互斥量所有权包装器,它提供了对延迟锁定、时间锁定、递归锁定、所有权转移以及与条件变量一起使用支持。这在 C++11 中可用。
- 
std::shared_lock是一个互斥量共享所有权包装器,它提供了对延迟锁定、时间锁定和所有权转移的支持。这在 C++14 中可用。
- 
std::scoped_lock是多个互斥量的包装器,以 RAII 方式实现。在构造时,它试图以避免死锁的方式获取互斥量的所有权,就像它正在使用std::lock()一样,并在销毁时以获取它们的相反顺序释放互斥量。这在 C++17 中可用。
RAII,即资源获取即初始化,是一种在包括 C++在内的某些编程语言中使用的编程技术,它简化了资源管理,确保程序正确性,并减少代码大小。这种技术将资源的生命周期绑定到对象上。资源的分配(也称为获取),是在对象的创建过程中(在构造函数中)完成的,而资源的释放(解除分配)是在对象被销毁时(在析构函数中)完成的。这确保了资源不会泄漏,前提是绑定到对象本身不会泄漏。有关 RAII 的更多信息,请参阅en.cppreference.com/w/cpp/language/raii。
在 如何做... 部分的第一个例子中,我们使用了 std::mutex 和 std::lock_guard 来保护对 std::cout 流对象的访问,该对象在程序的所有线程之间共享。以下示例展示了 thread_func() 函数如何在多个线程上并发执行:
std::vector<std::thread> threads;
for (int i = 0; i < 5; ++i)
  threads.emplace_back(thread_func);
for (auto & t : threads)
  t.join(); 
此程序的可能的输出如下:
running thread 140296854550272
running thread 140296846157568
running thread 140296837764864
running thread 140296829372160
running thread 140296820979456
done in thread 140296854550272
done in thread 140296846157568
done in thread 140296837764864
done in thread 140296820979456
done in thread 140296829372160 
当一个线程需要获取多个互斥锁以保护多个共享资源时,逐个获取它们可能会导致死锁。让我们考虑以下示例(其中 container 是在 如何做... 部分中显示的类):
template <typename T>
void move_between(container<T> & c1, container<T> & c2, T const value)
{
  std::lock_guard<std::mutex> l1(c1.mutex);
  std::lock_guard<std::mutex> l2(c2.mutex);
  c1.data.erase(
    std::remove(c1.data.begin(), c1.data.end(), value), 
    c1.data.end());
  c2.data.push_back(value);
}
container<int> c1;
c1.data.push_back(1);
c1.data.push_back(2);
c1.data.push_back(3);
container<int> c2;
c2.data.push_back(4);
c2.data.push_back(5);
c2.data.push_back(6);
std::thread t1(move_between<int>, std::ref(c1), std::ref(c2), 3);
std::thread t2(move_between<int>, std::ref(c2), std::ref(c1), 6);
t1.join();
t2.join(); 
在这个例子中,container 类持有可能被不同线程同时访问的数据;因此,它需要通过获取互斥锁来保护。move_between() 函数是一个线程安全函数,它从一个容器中删除一个元素并将其添加到第二个容器中。为此,它按顺序获取两个容器的互斥锁,然后从第一个容器中删除元素并将其添加到第二个容器的末尾。
然而,此函数容易发生死锁,因为在获取锁的过程中可能会触发竞争条件。假设我们有一个场景,其中两个不同的线程执行此函数,但具有不同的参数:
- 
第一个线程以 c1和c2的顺序开始执行。
- 
第一个线程在获取 c1容器的锁后暂停。第二个线程以c2和c1的顺序开始执行。
- 
第二个线程在获取 c2容器的锁后暂停。
- 
第一个线程继续执行并尝试获取 c2的互斥锁,但互斥锁不可用。因此,发生死锁(这可以通过在获取第一个互斥锁后让线程短暂休眠来模拟)。
为了避免可能出现的此类死锁,互斥锁应按死锁避免方式获取,标准库提供了一个名为 std::lock() 的实用函数来执行此操作。move_between() 函数需要通过以下代码替换两个锁(如 如何做... 部分所示)来更改:
std::lock(c1.mutex, c2.mutex);
std::lock_guard<std::mutex> l1(c1.mutex, std::adopt_lock);
std::lock_guard<std::mutex> l2(c2.mutex, std::adopt_lock); 
互斥锁的所有权必须仍然转移到锁保护对象,以便在函数执行结束后(或根据情况,当特定作用域结束时)正确释放。
在 C++17 中,一个新的互斥锁包装器 std::scoped_lock 可用,可用于简化代码,例如前一个示例中的代码。这种类型的锁可以以无死锁的方式获取多个互斥锁的所有权。当作用域锁被销毁时,这些互斥锁被释放。前面的代码等同于以下单行代码:
std::scoped_lock lock(c1.mutex, c2.mutex); 
scoped_lock 类提供了一种简化的机制,用于在作用域块期间拥有一个或多个互斥锁,并有助于编写简单且更健壮的代码。
参见
- 
与线程一起工作,了解 C++中 std::thread类以及线程的基本操作
- 
使用可连接线程和取消机制,了解 C++20 的 std::jthread类,该类管理一个执行线程,并在其销毁时自动连接,以及改进的停止线程执行机制
- 
寻找递归互斥锁的替代方案,了解为什么应该避免使用递归互斥锁,以及如何将使用递归互斥锁的线程安全类型转换为使用非递归互斥锁的线程安全类型 
寻找递归互斥锁的替代方案
标准库提供了几种互斥锁类型,用于保护对共享资源的访问。std::recursive_mutex和std::recursive_timed_mutex是两种实现,允许您在同一个线程中使用多次锁定。递归互斥锁的一个典型用途是保护递归函数对共享资源的访问。std::recursive_mutex类可以从线程中多次锁定,无论是通过调用lock()还是try_lock()。当一个线程锁定一个可用的递归互斥锁时,它获得所有权;因此,来自同一线程的连续锁定尝试不会阻塞线程的执行,从而创建死锁。然而,递归互斥锁仅在执行了相同数量的unlock()调用后才会释放。递归互斥锁可能比非递归互斥锁具有更大的开销。因此,在可能的情况下,应避免使用它们。本菜谱展示了将使用递归互斥锁的线程安全类型转换为使用非递归互斥锁的线程安全类型的使用案例。
准备工作
您需要熟悉标准库中可用的各种互斥锁和锁。我建议您阅读之前的菜谱,使用互斥锁和锁同步对共享数据的访问,以了解它们的大致情况。
对于这个菜谱,我们将考虑以下类:
class foo_rec
{
  std::recursive_mutex m;
  int data;
public:
  foo_rec(int const d = 0) : data(d) {}
  void update(int const d)
 {
    std::lock_guard<std::recursive_mutex> lock(m);
    data = d;
  }
  int update_with_return(int const d)
 {
    std::lock_guard<std::recursive_mutex> lock(m);
    auto temp = data;
    update(d);
    return temp;
  }
}; 
本菜谱的目的是将foo_rec类进行转换,以便我们可以避免使用std::recursive_mutex。
如何操作...
要将前面的实现转换为使用非递归互斥锁的线程安全类型,请执行以下操作:
- 
将 std::recursive_mutex替换为std::mutex:class foo { std::mutex m; int data; // continued at 2. };
- 
定义私有非线程安全的版本,用于在线程安全的公共方法或辅助函数中使用: void internal_update(int const d) { data = d; } // continued at 3.
- 
将公共方法重写为使用新定义的非线程安全私有方法: public: foo(int const d = 0) : data(d) {} void update(int const d) { std::lock_guard<std::mutex> lock(m); internal_update(d); } int update_with_return(int const d) { std::lock_guard<std::mutex> lock(m); auto temp = data; internal_update(d); return temp; }
它是如何工作的...
我们刚才讨论的foo_rec类使用递归互斥锁来保护对共享数据的访问;在这种情况下,它是一个整数成员变量,从两个线程安全的公共函数中访问:
- 
update()在私有变量中设置新值。
- 
update_and_return()在私有变量中设置新值,并将旧值返回给调用函数。此函数调用update()来设置新值。
foo_rec的实现可能旨在避免代码重复,但这种方法实际上是一个可以改进的设计错误,如如何做…部分所示。我们不是重用公共线程安全函数,而是可以提供私有非线程安全函数,然后可以从公共接口调用这些函数。
同样的解决方案可以应用于其他类似问题:定义一个非线程安全的代码版本,然后提供可能轻量级的线程安全包装器。
参见
- 
与线程一起工作,了解 std::thread类以及 C++中与线程一起工作的基本操作
- 
使用互斥锁和锁同步对共享数据的访问,以了解同步线程对共享数据访问的可用机制及其工作原理 
处理线程函数中的异常
在第一个配方中,我们介绍了线程支持库,并展示了如何使用线程进行一些基本操作。在那个配方中,我们简要讨论了线程函数中的异常处理,并提到在启动线程的上下文中无法使用try…catch语句捕获异常。另一方面,异常可以在std::exception_ptr包装器内线程之间传输。在这个配方中,我们将了解如何处理线程函数中的异常。
准备工作
你现在已经熟悉了在先前的配方与线程一起工作中讨论的线程操作。exception_ptr类在std命名空间中可用,该命名空间在<exception>头文件中;mutex(我们之前更详细地讨论过)也在同一个命名空间中,但在<mutex>头文件中。
如何做…
为了正确处理在工作线程中从主线程或其连接的线程抛出的异常,请执行以下操作(假设可以从多个线程抛出多个异常):
- 
使用全局容器来保存 std::exception_ptr的实例:std::vector<std::exception_ptr> g_exceptions;
- 
使用全局 mutex来同步对共享容器的访问:std::mutex g_mutex;
- 
使用 try…catch块来处理在顶级线程函数中执行的代码。使用std::current_exception()捕获当前异常,并将其副本或引用包装到一个std::exception_ptr指针中,然后将该指针添加到共享异常容器中:void func1() { throw std::runtime_error("exception 1"); } void func2() { throw std::runtime_error("exception 2"); } void thread_func1() { try { func1(); } catch (...) { std::lock_guard<std::mutex> lock(g_mutex); g_exceptions.push_back(std::current_exception()); } } void thread_func2() { try { func2(); } catch (...) { std::lock_guard<std::mutex> lock(g_mutex); g_exceptions.push_back(std::current_exception()); } }
- 
在启动线程之前,从主线程中清除容器: g_exceptions.clear();
- 
在主线程中,所有线程执行完成后,检查捕获的异常并适当地处理每个异常: std::thread t1(thread_func1); std::thread t2(thread_func2); t1.join(); t2.join(); for (auto const & e : g_exceptions) { try { if(e) std::rethrow_exception(e); } catch(std::exception const & ex) { std::cout << ex.what() << '\n'; } }
它是如何工作的…
对于前一个示例中的示例,我们假设多个线程可以抛出异常,因此需要一个容器来保存它们。如果一次只有一个线程抛出异常,那么你不需要共享容器和互斥锁来同步对其的访问。你可以使用一个全局的std::exception_ptr类型的单个对象来保存线程之间传输的异常。
std::current_exception()是一个函数,通常用于catch子句中,用于捕获当前异常并创建一个std::exception_ptr实例。这样做是为了保留原始异常的副本或引用(取决于实现),只要有一个std::exception_ptr指针引用它,该异常就保持有效。如果在这个函数被调用时没有正在处理的异常,那么它创建一个空的std::exception_ptr。
std::exception_ptr指针是使用std::current_exception()捕获的异常的包装器。如果默认构造,它不包含任何异常;在这种情况下,它是一个空指针。如果两个对象都是空的或指向相同的异常对象,则这两个类型的对象相等。《std::exception_ptr对象可以被传递到其他线程,在那里它们可以在try...catch`块中被重新抛出和捕获。
std::rethrow_exception()是一个函数,它接受std::exception_ptr作为参数,并抛出由其参数引用的异常对象。
std::current_exception()、std::rethrow_exception()和std::exception_ptr都是 C++11 中可用的。
在上一节的示例中,每个线程函数使用整个代码执行的try...catch语句,以确保没有异常可能未捕获地离开函数。当处理异常时,会获取全局mutex对象的锁,并将包含当前异常的std::exception_ptr对象添加到共享容器中。使用这种方法,线程函数会在第一个异常处停止;然而,在其他情况下,你可能需要执行多个操作,即使前一个操作抛出了异常。在这种情况下,你将有多重try...catch语句,并且可能只将一些异常传输到线程外。
在主线程中,所有线程执行完毕后,容器被迭代,每个非空异常都会被重新抛出,并通过一个try...catch块捕获并适当处理。
参见
- 
与线程一起工作,了解 std::thread类以及 C++中处理线程的基本操作
- 
使用互斥锁和锁同步对共享数据的访问,以了解可用于同步线程对共享数据访问的机制以及它们是如何工作的 
在线程之间发送通知
互斥锁是同步原语,可以用来保护对共享数据的访问。然而,标准库提供了一个名为条件变量的同步原语,它允许一个线程向其他线程发出信号,表明某个条件已经发生。等待条件变量的线程或线程将被阻塞,直到条件变量被信号或直到超时或虚假唤醒发生。在这个菜谱中,我们将看到如何使用条件变量在产生数据的线程和消费数据的线程之间发送通知。
准备中
对于这个食谱,你需要熟悉线程、互斥锁和锁。条件变量在 <condition_variable> 头文件中的 std 命名空间中可用。
如何实现...
使用以下模式来同步线程,并在条件变量上实现通知:
- 
定义一个条件变量(在适当的作用域内): std::condition_variable cv;
- 
定义一个线程用于锁定互斥锁的互斥锁。第二个互斥锁应用于同步不同线程对标准控制台访问: std::mutex data_mutex; // data mutex std::mutex io_mutex; // I/O mutex
- 
定义线程之间使用的共享数据: int data = 0;
- 
在生产线程中,在修改数据之前锁定互斥锁: std::thread producer([&](){ // simulate long running operation { using namespace std::chrono_literals; std::this_thread::sleep_for(2s); } // produce { std::unique_lock lock(data_mutex); data = 42; } // print message { std::lock_guard l(io_mutex); std::cout << "produced " << data << '\n'; } // continued at 5. });
- 
在生产线程中,通过调用 notify_one()或notify_all()来信号条件变量(在用于保护共享数据的互斥锁解锁之后进行):// continued from 4. cv.notify_one();
- 
在消费线程中,获取数据互斥锁上的唯一锁并使用它来等待条件变量。请注意,可能会发生虚假唤醒,这是我们将在 How it works… 部分详细讨论的主题: std::thread consumer([&](){ // wait for notification { std::unique_lock lock(data_mutex); cv.wait(lock); } // continued at 7. });
- 
在消费线程中,在条件被通知后使用共享数据: // continued from 6. { std::lock_guard lock(io_mutex); std::cout << "consumed " << data << '\n'; }
它是如何工作的...
上述示例表示两个共享公共数据(在这种情况下,是一个整型变量)的线程。一个线程在经过长时间的计算(用睡眠来模拟)后产生数据,而另一个线程只在数据被产生后消费它。为了做到这一点,它们使用了一个同步机制,该机制使用互斥锁和一个条件变量来阻塞消费线程,直到生产线程发出通知,表明数据已可用。在这个通信通道中的关键是消费线程等待的条件变量,直到生产线程通知它。两个线程几乎同时开始。生产线程开始一个长时间的计算,这个计算应该为消费线程产生数据。同时,消费线程实际上不能继续执行,直到数据可用;它必须保持阻塞,直到它被通知数据已产生。一旦通知,它就可以继续执行。整个机制的工作方式如下:
- 
至少必须有一个线程在等待条件变量被通知。 
- 
至少必须有一个线程在信号条件变量。 
- 
等待的线程必须首先在互斥锁上获取一个锁( std::unique_lock<std::mutex>)并将其传递给条件变量的wait()、wait_for()或wait_until()方法。所有等待方法原子性地释放互斥锁并阻塞线程,直到条件变量被信号。此时,线程被解除阻塞,互斥锁再次原子性地获取(这意味着涉及的操作被视为一个整体,并且在执行这些操作时线程不能被中断)。
- 
信号条件变量的线程可以使用 notify_one()来这样做,其中只有一个阻塞的线程被解除阻塞,或者使用notify_all(),其中所有等待条件变量的阻塞线程都被解除阻塞。
在多处理器系统中,条件变量无法完全预测。因此,可能会发生虚假唤醒,即使没有人信号条件变量,线程也会被解锁。因此,在线程被解除阻塞后,有必要检查条件是否为真。然而,虚假唤醒可能会多次发生,因此有必要在循环中检查条件变量。您可以在en.wikipedia.org/wiki/Spurious_wakeup上了解更多关于虚假唤醒的信息。
C++标准提供了两个条件变量的实现:
- 
在此配方中使用的 std::condition_variable定义了一个与std::unique_lock关联的条件变量。
- 
std::condition_variable_any代表一个更通用的实现,它可以与满足基本锁要求的任何锁一起工作(实现了lock()和unlock()方法)。此实现的可能用途是提供可中断的等待,如安东尼·威廉姆斯在《C++并发实战》(2012 年)中所述:
自定义锁操作将按预期锁定相关互斥锁,并在接收到中断信号时执行必要的任务,即通知此条件变量。
条件变量的所有等待方法都有两个重载:
- 
第一个重载接受 std::unique_lock<std::mutex>(基于类型;即持续时间或时间点),并导致线程在条件变量被信号之前保持阻塞。此重载原子地释放互斥锁并阻塞当前线程,然后将其添加到等待条件变量的线程列表中。当条件通过notify_one()或notify_all()被通知,发生虚假唤醒或超时(取决于函数重载)时,线程被解除阻塞。当这种情况发生时,互斥锁再次被原子地获取。
- 
第二个重载除了其他重载的参数外还接受一个谓词。这个谓词可以在等待条件变为 true时避免虚假唤醒。此重载等同于以下内容:while(!pred()) wait(lock);
producer thread:
std::mutex g_lockprint;
std::mutex g_lockqueue;
std::condition_variable g_queuecheck;
std::queue<int> g_buffer;
bool g_done;
void producer(
 int const id, 
  std::mt19937& generator,
  std::uniform_int_distribution<int>& dsleep,
  std::uniform_int_distribution<int>& dcode)
{
  for (int i = 0; i < 5; ++i)
  {
    // simulate work
    std::this_thread::sleep_for(
      std::chrono::seconds(dsleep(generator)));
    // generate data
    {
      std::unique_lock<std::mutex> locker(g_lockqueue);
      int value = id * 100 + dcode(generator);
      g_buffer.push(value);
      {
        std::unique_lock<std::mutex> locker(g_lockprint);
        std::cout << "[produced(" << id << ")]: " << value << '\n';
      }
    }
    // notify consumers 
    g_queuecheck.notify_one();
  }
} 
另一方面,消费者线程的实现如下所示:
void consumer()
{
  // loop until end is signaled
while (!g_done)
  {
    std::unique_lock<std::mutex> locker(g_lockqueue);
    g_queuecheck.wait_for(
      locker, 
      std::chrono::seconds(1),
      [&]() {return !g_buffer.empty(); });
    // if there are values in the queue process them
while (!g_done && !g_buffer.empty())
    {
      std::unique_lock<std::mutex> locker(g_lockprint);
      std::cout << "[consumed]: " << g_buffer.front() << '\n';
      g_buffer.pop();
    }
  }
} 
消费者线程执行以下操作:
- 
循环直到接收到生产数据过程结束的信号。 
- 
在与条件变量关联的 mutex对象上获取一个唯一的锁。
- 
使用 wait_for()重载,该重载接受一个谓词,检查唤醒时缓冲区是否为空(以避免虚假唤醒)。此方法使用 1 秒的超时,并在超时发生后返回,即使条件已被信号。
- 
在通过条件变量发出信号后,消耗队列中的所有数据。 
为了测试这一点,我们可以启动几个生产线程和一个消费线程。生产线程生成随机数据,因此共享伪随机数生成器和分布。所有这些都在下面的代码示例中展示:
auto seed_data = std::array<int, std::mt19937::state_size> {};
std::random_device rd {};
std::generate(std::begin(seed_data), std::end(seed_data),
              std::ref(rd));
std::seed_seq seq(std::begin(seed_data), std::end(seed_data));
auto generator = std::mt19937{ seq };
auto dsleep = std::uniform_int_distribution<>{ 1, 5 };
auto dcode = std::uniform_int_distribution<>{ 1, 99 };
std::cout << "start producing and consuming..." << '\n';
std::thread consumerthread(consumer);
std::vector<std::thread> threads;
for (int i = 0; i < 5; ++i)
{
  threads.emplace_back(producer, 
                       i + 1, 
                       std::ref(generator),
                       std::ref(dsleep),
                       std::ref(dcode));
}
// work for the workers to finish
for (auto& t : threads)
  t.join();
// notify the logger to finish and wait for it
g_done = true;
consumerthread.join();
std::cout << "done producing and consuming" << '\n'; 
该程序的可能的输出如下(实际输出会因每次执行而不同):
start producing and consuming...
[produced(5)]: 550
[consumed]: 550
[produced(5)]: 529
[consumed]: 529
[produced(5)]: 537
[consumed]: 537
[produced(1)]: 122
[produced(2)]: 224
[produced(3)]: 326
[produced(4)]: 458
[consumed]: 122
[consumed]: 224
[consumed]: 326
[consumed]: 458
...
done producing and consuming 
该标准还提供了一个名为notify_all_at_thread_exit()的辅助函数,它提供了一种方式,允许一个线程通过一个condition_variable对象通知其他线程它已经完全完成执行,包括销毁所有thread_local对象。此函数有两个参数:一个与条件变量关联的condition_variable和一个std::unique_lock<std::mutex>(它接受所有权)。此函数的典型用例是运行一个分离的线程,在完成前调用此函数。
参见
- 
与线程一起工作,了解 std::thread类以及 C++中与线程一起工作的基本操作
- 
使用互斥锁和锁同步对共享数据的访问,了解可用于同步线程对共享数据访问的机制以及它们是如何工作的 
使用承诺和未来从线程返回值
在本章的第一个菜谱中,我们讨论了如何与线程一起工作。你还了解到,线程函数不能返回值,并且线程应该使用其他方法,例如共享数据,来做到这一点;然而,为此需要同步。与主线程或另一个线程通信返回值或异常的另一种选择是使用std::promise。本菜谱将解释这个机制是如何工作的。
准备工作
本菜谱中使用的promise和future类在<future>头文件中的std命名空间中可用。
如何做...
要通过承诺和未来从一个线程向另一个线程通信一个值,这样做:
- 
通过参数将承诺提供给线程函数;例如: void produce_value(std::promise<int>& p) { // simulate long running operation { using namespace std::chrono_literals; std::this_thread::sleep_for(2s); } // continued at 2. }
- 
在承诺上调用 set_value()来设置结果表示一个值或调用set_exception()来设置结果表示一个异常:// continued from 1. p.set_value(42);
- 
通过参数将承诺关联的未来提供给其他线程函数;例如: void consume_value(std::future<int>& f) { // continued at 4. }
- 
在 future对象上调用get()来获取设置到承诺中的结果:// continued from 3. auto value = f.get();
- 
在调用线程中,使用承诺的 get_future()方法来获取与承诺关联的future:std::promise<int> p; std::thread t1(produce_value, std::ref(p)); std::future<int> f = p.get_future(); std::thread t2(consume_value, std::ref(f)); t1.join(); t2.join();
它是如何工作的...
承诺-未来对基本上是一个通信通道,它允许一个线程通过共享状态与另一个线程通信一个值或异常。promise是一个异步的结果提供者,它有一个关联的future,代表异步返回对象。为了建立这个通道,你必须首先创建一个承诺。这反过来又创建了一个可以稍后通过承诺关联的未来读取的共享状态。
要将结果设置到承诺中,你可以使用以下任何一种方法:
- 
使用 set_value()或set_value_at_thread_exit()方法来设置返回值;后一个函数将值存储在共享状态中,但只有在线程退出时才通过关联的 future 使其可用。
- 
使用 set_exception()或set_exception_at_thread_exit()方法来设置一个异常作为返回值。异常被封装在一个std::exception_ptr对象中。后一个函数将异常存储在共享状态中,但只有在线程退出时才使其可用。
要检索与promise关联的future对象,请使用get_future()方法。要从future值中获取值,请使用get()方法。这将阻塞调用线程,直到共享状态中的值可用。Future 类有几种方法可以阻塞线程,直到共享状态的结果可用:
- 
wait()仅在结果可用时返回。
- 
wait_for()在结果可用或指定的超时时间到期时返回。
- 
wait_until()在结果可用或指定的时刻到达时返回。
如果将异常设置到promise值,则在future对象上调用get()方法将抛出此异常。上一节中的示例已被重写如下,以抛出异常而不是设置结果:
void produce_value(std::promise<int>& p)
{
  // simulate long running operation
  {
    using namespace std::chrono_literals;
    std::this_thread::sleep_for(2s);
  }
  try
  {
    throw std::runtime_error("an error has occurred!");
  }
  catch(...)
  {
    p.set_exception(std::current_exception());
  }
}
void consume_value(std::future<int>& f)
{
  std::lock_guard<std::mutex> lock(g_mutex);
  try
  {
    std::cout << f.get() << '\n';
  }
  catch(std::exception const & e)
  {
    std::cout << e.what() << '\n';
  } 
} 
你可以在这里看到,在consume_value()函数中,对get()的调用被放在一个try...catch块中。如果捕获到异常——在这个特定的实现中,确实是这样——其消息将被打印到控制台。
更多内容...
以这种方式建立 promise-future 通道是一个相当明确的操作,可以通过使用std::async()函数来避免;这是一个高级实用工具,它异步运行一个函数,创建一个内部 promise 和一个共享状态,并返回与共享状态关联的 future。我们将在下一个配方中看到std::async()是如何工作的,异步执行函数。
参见
- 
使用线程,了解 std::thread类以及如何在 C++中操作线程的基本操作
- 
处理线程函数中的异常,了解如何从主线程或连接的线程中处理工作线程抛出的异常 
异步执行函数
线程使我们能够同时运行多个函数;这有助于我们利用多处理器或多核系统中的硬件设施。然而,线程需要显式、低级别的操作。线程的替代方案是任务,它们是在特定线程中运行的作业单元。C++标准没有提供完整的任务库,但它允许开发者在不同线程上异步执行函数,并通过 promise-future 通道返回结果,如前一个配方中所示。在这个配方中,我们将看到如何使用std::async()和std::future来完成这项工作。
准备工作
在本食谱的示例中,我们将使用以下函数:
void do_something()
{
  // simulate long running operation
  {
    using namespace std::chrono_literals;
    std::this_thread::sleep_for(2s);
  } 
  std::lock_guard<std::mutex> lock(g_mutex);
  std::cout << "operation 1 done" << '\n'; 
}
void do_something_else()
{
  // simulate long running operation
  {
    using namespace std::chrono_literals;
    std::this_thread::sleep_for(1s);
  } 
  std::lock_guard<std::mutex> lock(g_mutex);
  std::cout << "operation 2 done" << '\n'; 
}
int compute_something()
{
  // simulate long running operation
  {
    using namespace std::chrono_literals;
    std::this_thread::sleep_for(2s);
  } 
  return 42;
}
int compute_something_else()
{
  // simulate long running operation
  {
    using namespace std::chrono_literals;
    std::this_thread::sleep_for(1s);
  }
  return 24;
} 
在本食谱中,我们将使用 futures;因此,建议您阅读之前的食谱以快速了解它们的工作原理。async() 和 future 都在 <future> 头文件中的 std 命名空间中可用。
如何做...
当当前线程继续执行而不期望结果时,在另一个线程上异步执行函数,直到当前线程需要异步函数的结果,请执行以下操作:
- 
使用 std::async()启动一个新线程来执行指定的函数。这将创建一个异步提供者,并返回与其关联的future对象。为了确保函数将异步运行,请使用std::launch::async策略作为std::async()函数的第一个参数:auto f = std::async(std::launch::async, do_something);
- 
继续执行当前线程: do_something_else();
- 
当你需要确保异步操作完成时,请在 std::async()返回的future对象上调用wait()方法:f.wait();当当前线程继续执行,直到当前线程需要异步函数的结果时,在工作者线程上异步执行函数,请执行以下操作: 
- 
使用 std::async()启动一个新线程来执行指定的函数,创建一个异步提供者,并返回与其关联的future对象。为了确保函数确实异步运行,请使用函数的第一个参数的std::launch::async策略:auto f = std::async(std::launch::async, compute_something);
- 
继续执行当前线程: auto value = compute_something_else();
- 
当你需要异步执行函数的结果时,请在 std::async()返回的future对象上调用get()方法:value += f.get();
它是如何工作的...
std::async() 是一个变长参数模板函数,有两个重载:一个指定了作为第一个参数的启动策略,另一个则没有。std::async() 的其他参数是要执行的函数及其参数(如果有)。启动策略由一个名为 std::launch 的范围枚举定义,该枚举在 <future> 头文件中可用:
enum class launch : /* unspecified */ 
{
  async = /* unspecified */,
  deferred = /* unspecified */,
  /* implementation-defined */
}; 
可用的两个启动策略指定如下:
- 
使用 async,将启动一个新线程来异步执行任务。
- 
使用 deferred,任务将在第一次请求其结果时在调用线程上执行。
当同时指定两个标志(std::launch::async | std::launch::deferred)时,是否在新的线程上异步运行任务或在当前线程上同步运行是实现决策。这是不指定启动策略的其他 std::async() 重载的行为。这种行为是不确定的。
不要使用非确定性的 std::async() 重载来异步运行任务。为此,始终使用需要启动策略的重载,并且始终只使用 std::launch::async。
std::async() 的两种重载都返回一个 future 对象,该对象引用 std::async() 内部创建的共享状态,用于它建立的承诺-未来通道。当你需要异步操作的结果时,请在 future 上调用 get() 方法。这将阻塞当前线程,直到结果值或异常可用。如果 future 不携带任何值或你实际上不感兴趣该值,但想确保异步操作将在某个时刻完成,请使用 wait() 方法;它将阻塞当前线程,直到通过 future 可用共享状态。
future 类还有两个等待方法:wait_for() 指定一个持续时间,在此之后调用结束并返回,即使共享状态尚未通过 future 可用,而 wait_until() 指定一个时间点,在此之后调用返回,即使共享状态尚未可用。这些方法可以用来创建轮询例程并向用户显示状态消息,如下例所示:
auto f = std::async(std::launch::async, do_something);
while(true)
{
  std::cout << "waiting...\n";
  using namespace std::chrono_literals;
  auto status = f.wait_for(500ms);
  if(status == std::future_status::ready) 
    break;
}
std::cout << "done!\n"; 
运行此程序的结果如下:
waiting...
waiting...
waiting...
operation 1 done
done! 
相关内容
- 使用承诺和未来从线程返回值,了解如何使用 std::promise对象从线程返回一个值或异常
使用原子类型
线程支持库提供了管理线程和同步对共享数据访问(使用互斥锁和锁,以及从 C++20 开始使用闩锁、屏障和信号量)的功能。标准库提供了对数据互补、低级原子操作的支持,这些操作是不可分割的操作,可以在不同线程上并发执行,而不会产生竞态条件,也不需要使用锁。它提供支持包括原子类型、原子操作和内存同步排序。在本菜谱中,我们将看到如何使用这些类型和函数。
准备工作
所有原子类型和操作都在 <atomic> 头文件中定义的 std 命名空间中。
如何操作...
以下是一系列使用原子类型的典型操作:
- 
使用 std::atomic类模板创建支持原子操作(如加载、存储或执行算术或位运算)的原子对象:std::atomic<int> counter {0}; std::vector<std::thread> threads; for(int i = 0; i < 10; ++i) { threads.emplace_back([&counter](){ for(int j = 0; j < 10; ++j) ++counter; }); } for(auto & t : threads) t.join(); std::cout << counter << '\n'; // prints 100
- 
在 C++20 中,使用 std::atomic_ref类模板将原子操作应用于引用的对象,该对象可以是整数类型、浮点类型或用户定义类型的引用或指针:void do_count(int& c) { std::atomic_ref<int> counter{ c }; std::vector<std::thread> threads; for (int i = 0; i < 10; ++i) { threads.emplace_back([&counter]() { for (int j = 0; j < 10; ++j) ++counter; }); } for (auto& t : threads) t.join(); } int main() { int c = 0; do_count(c); std::cout << c << '\n'; // prints 100 }
- 
使用 std::atomic_flag类来表示原子布尔类型:std::atomic_flag lock = ATOMIC_FLAG_INIT; int counter = 0; std::vector<std::thread> threads; for(int i = 0; i < 10; ++i) { threads.emplace_back([&](){ while(lock.test_and_set(std::memory_order_acquire)); ++counter; lock.clear(std::memory_order_release); }); } for(auto & t : threads) t.join(); std::cout << counter << '\n'; // prints 10
- 
使用原子类型的成员 – load()、store()和exchange()– 或非成员函数 –atomic_load()/atomic_load_explicit()、atomic_store()/atomic_store_explicit()、和atomic_exchange()/atomic_exchange_explicit()– 以原子方式读取、设置或交换原子对象的值。
- 
使用其成员函数 fetch_add()和fetch_sub()或非成员函数atomic_fetch_add()/atomic_fetch_add_explicit()和atomic_fetch_sub()/atomic_fetch_sub_explicit()来原子性地向原子对象添加或减去一个值,并返回操作前的值:std::atomic<int> sum {0}; std::vector<int> numbers = generate_random(); size_t size = numbers.size(); std::vector<std::thread> threads; for(int i = 0; i < 10; ++i) { threads.emplace_back(&sum, &numbers { for(size_t j = start; j < end; ++j) { std::atomic_fetch_add_explicit( &sum, numbers[j], std::memory_order_acquire); // same as // sum.fetch_add(numbers[i], std::memory_order_acquire); }}, i*(size/10), (i+1)*(size/10)); } for(auto & t : threads) t.join();
- 
使用其成员函数 fetch_and()、fetch_or()和fetch_xor()或非成员函数atomic_fetch_and()/atomic_fetch_and_explicit()、atomic_fetch_or()/atomic_fetch_or_explicit()、和atomic_fetch_xor()/atomic_fetch_xor_explicit()来执行分别对应 AND、OR 和 XOR 原子操作,并返回操作前原子对象的价值。
- 
使用 std::atomic_flag的成员函数test_and_set()和clear()或非成员函数atomic_flag_test_and_set()/atomic_flag_test_and_set_explicit()和atomic_flag_clear()/atomic_flag_clear_explicit()来设置或重置一个原子标志。此外,在 C++20 中,你可以使用成员函数test()和非成员函数atomic_flag_test()/atomic_flag_test_explicit()来原子性地返回标志的值。
- 
在 C++20 中,使用成员函数 wait()、notify_one()和notify_all()以及非成员函数atomic_wait()/atomic_wait_explicit()、atomic_notify_one()和atomic_notify_all()来执行线程同步,这些函数对std::atomic、std::atomic_ref和std::atomic_flag都可用。这些函数提供了一个比轮询更有效的等待原子对象值改变的机制。
它是如何工作的...
std::atomic 是一个类模板,定义了(包括其特化)一个原子类型。当一个线程写入对象而另一个线程读取数据时,原子类型对象的行为是明确定义的,无需使用锁来保护访问。原子变量的操作被视为单一、不可中断的操作。如果两个线程都想写入同一个原子变量,则第一个获得它的线程将写入,而另一个将等待原子写入完成后再写入。这是一个确定性行为,不需要额外的锁定。
std::atomic 类提供了几个特化:
- 
对 bool的完全特化,有一个名为atomic_bool的类型别名。
- 
所有整型类型都实现了完全特化,包括类型别名(typedefs),例如 atomic_bool(对应std::atomic<bool>)、atomic_int(对应std::atomic<int>)、atomic_long(对应std::atomic<long>)、atomic_char(对应std::atomic<char>)、atomic_size_t(对应std::atomic<std::size_t>)以及许多其他类型。
- 
指针类型的部分特化。 
- 
在 C++20 中,对浮点类型 float、double和long double实现了完全特化。
- 
在 C++20 中,对 std::shared_ptr<U>的std::atomic<std::shared_ptr<U>>和对std::weak_ptr<U>的std::atomic<std::weak_ptr<U>>实现了部分特化。
atomic 类模板具有各种成员函数,执行原子操作,例如以下:
- 
load()用于原子地加载并返回对象的值。
- 
store()用于原子地将非原子值存储在对象中;此函数不返回任何内容。
- 
exchange()用于原子地将非原子值存储在对象中并返回之前的值。
- 
operator=,其效果与store(arg)相同。
- 
fetch_add()用于原子地将非原子参数添加到原子值中,并返回之前存储的值。
- 
fetch_sub()用于原子地从原子值中减去非原子参数并返回之前存储的值。
- 
fetch_and(),fetch_or(), 和fetch_xor()用于原子地在参数和原子值之间执行位与、或或异或操作;将新值存储在原子对象中;并返回之前的值。
- 
在 operator++和operator--前缀和后缀中添加,以原子地增加和减少原子对象的值 1。这些操作相当于使用fetch_add()或fetch_sub()。
- 
operator +=,-=,&=,|=, 和ˆ=用于在参数和原子值之间添加、减去或执行位与、或或异或操作,并将新值存储在原子对象中。这些操作相当于使用fetch_add()、fetch_sub()、fetch_and()、fetch_or()和fetch_xor()。
假设你有一个原子变量,例如 std::atomic<int> a;以下不是原子操作:
a = a + 42; 
这涉及一系列操作,其中一些是原子的:
- 
原子地加载原子对象的值 
- 
将 42 添加到加载的值(这不是原子操作) 
- 
原子地将结果存储在原子对象 a中
另一方面,以下使用成员运算符 += 的操作是原子的:
a += 42; 
此操作与以下任一操作具有相同的效果:
a.fetch_add(42);               // using member function
std::atomic_fetch_add(&a, 42); // using non-member function 
尽管 std::atomic 为 bool 类型提供了完全特化,称为 std::atomic<bool>,但标准还定义了另一种原子类型,称为 std::atomic_flag,它保证是无锁的。然而,此原子类型与 std::atomic<bool> 非常不同,并且它只有以下成员函数:
- 
test_and_set()原子地将值设置为true并返回之前的值。
- 
clear()原子地将值设置为false。
- 
在 C++20 中,有 test(),它原子地返回标志的值。
在 C++20 之前,初始化 std::atomic_flag 为确定值的唯一方法是使用 ATOMIC_FLAG_INIT 宏。此宏将原子标志初始化为清除(false)值:
std::atomic_flag lock = ATOMIC_FLAG_INIT; 
在 C++20 中,此宏已被弃用,因为 std::atomic_flag 的默认构造函数将其初始化为清除状态。
之前提到的所有成员函数,无论是 std::atomic 还是 std::atomic_flag,都有非成员等效函数,这些函数以 atomic_ 或 atomic_flag_ 为前缀,具体取决于它们引用的类型。例如,std::atomic::fetch_add() 的等效函数是 std::atomic_fetch_add(),这些非成员函数的第一个参数始终是指向 std::atomic 对象的指针。内部,非成员函数在提供的 std::atomic 参数上调用等效的成员函数。同样,std::atomic_flag::test_and_set() 的等效函数是 std::atomic_flag_test_and_set(),其第一个参数是指向 std::atomic_flag 对象的指针。
所有这些 std::atomic 和 std::atomic_flag 的成员函数都有两套重载;其中一套有一个额外的参数表示内存顺序。同样,所有非成员函数——例如 std::atomic_load()、std::atomic_fetch_add() 和 std::atomic_flag_test_and_set()——都有一个带有后缀 _explicit 的伴随函数——std::atomic_load_explicit()、std::atomic_fetch_add_explicit() 和 std::atomic_flag_test_and_set_explicit();这些函数有一个额外的参数表示内存顺序。
内存顺序指定了非原子内存访问如何围绕原子操作进行排序。默认情况下,所有原子类型和操作的内存顺序是 顺序一致性。
在 std::memory_order 枚举中定义了额外的排序类型,可以将它们作为 std::atomic 和 std::atomic_flag 的成员函数或带有后缀 _explicit() 的非成员函数的参数传递。
顺序一致性 是一种一致性模型,它要求在多处理器系统中,所有指令都必须按某种顺序执行,并且所有写操作都必须立即在整个系统中可见。这个模型最初由 Leslie Lamport 在 70 年代提出,描述如下:
“任何执行的任何结果都等同于如果所有处理器的操作都按某种顺序执行,并且每个处理器的操作都按其程序指定的顺序出现在这个序列中。”
以下表格描述了各种类型的内存排序函数,这些内容摘自 C++参考网站(en.cppreference.com/w/cpp/atomic/memory_order)。每个这些函数如何工作的细节超出了本书的范围,可以在标准的 C++参考中查找(参见前面的链接):
| 模型 | 说明 | 
|---|---|
| memory_order_relaxed | 这是一个非同步操作。没有同步或排序约束;仅要求此操作具有原子性。 | 
| memory_order_consume | 使用此内存顺序的加载操作在受影响的内存位置执行消耗操作;当前线程中依赖于当前加载值的任何读取或写入操作都不能在此加载操作之前重排。在其他线程中对释放相同原子变量的数据依赖变量进行的写入在当前线程中是可见的。在大多数平台上,这仅影响编译器优化。 | 
| memory_order_acquire | 使用此内存顺序的加载操作在受影响的内存位置执行获取操作;当前线程中的任何读取或写入操作都不能在此加载之前重排。在其他线程中释放相同原子变量的所有写入在当前线程中都是可见的。 | 
| memory_order_release | 使用此内存顺序的存储操作执行释放操作;当前线程中的任何读取或写入操作都不能在此存储之后重排。当前线程中的所有写入在其他线程中获取相同原子变量的线程中都是可见的,并且对原子变量的依赖写入在其他线程中消耗相同原子变量的线程中变为可见。 | 
| memory_order_acq_rel | 使用此内存顺序的读取-修改-写入操作既是获取操作也是释放操作。当前线程中的任何内存读取或写入都不能在此存储之前或之后重排。在其他线程中释放相同原子变量的所有写入在修改之前都是可见的,并且修改在其他线程中获取相同原子变量的线程中可见。 | 
| memory_order_seq_cst | 任何具有此内存顺序的操作既是获取操作也是释放操作;存在一个单一的全序,其中所有线程以相同的顺序观察到所有修改。 | 
表 8.1:描述原子操作内存访问顺序的 std::memory_order 成员
在 如何做... 部分的第一个示例展示了几个线程反复通过并发增加来修改一个共享资源——一个计数器。这个示例可以通过实现一个具有如 increment() 和 decrement() 方法来表示原子计数器的类进一步优化,这些方法用于修改计数器的值,以及 get() 方法,用于检索其当前值:
template <typename T, 
          typename I = 
            typename std::enable_if<std::is_integral_v<T>>::type>
class atomic_counter
{
  std::atomic<T> counter {0};
public:
  T increment()
 {
    return counter.fetch_add(1);
  }
  T decrement()
 {
    return counter.fetch_sub(1);
  }
  T get()
 {
    return counter.load();
  }
}; 
使用这个类模板,第一个示例可以按照以下形式重写,结果相同:
atomic_counter<int> counter;
std::vector<std::thread> threads;
for(int i = 0; i < 10; ++i)
{
  threads.emplace_back([&counter](){
    for(int j = 0; j < 10; ++j)
      counter.increment();
  });
}
for(auto & t : threads) t.join();
std::cout << counter.get() << '\n'; // prints 100 
如果需要在引用上执行原子操作,不能使用 std::atomic。然而,在 C++20 中,可以使用新的 std::atomic_ref 类型。这是一个类模板,它将原子操作应用于它引用的对象。此对象必须比 std::atomic_ref 对象存在时间更长,并且只要存在任何引用此对象的 std::atomic_ref 实例,此对象就只能通过 std::atomic_ref 实例访问。
std::atomic_ref 类型有以下特化:
- 
主要模板可以用任何可以简单复制的类型 T实例化,包括bool。
- 
所有指针类型的部分特化。 
- 
整数类型(字符类型、有符号和无符号整数类型,以及 <cstdint>头文件中 typedef 所需的所有其他整数类型)的特化。
- 
浮点类型 float、double和long double的特化。
当使用std::atomic_ref时,你必须记住:
- 
通过 std::atomic_ref引用的对象的任何子对象访问都不是线程安全的。
- 
可以通过一个 const std::atomic_ref对象来修改引用的值。
此外,在 C++20 中,有一些新的成员函数和非成员函数提供了高效的线程同步机制:
- 
成员函数 wait()以及非成员函数atomic_wait()/atomic_wait_explicit()和atomic_flag_wait()/atomic_flag_wait_explicit()执行原子等待操作,阻塞线程直到被通知并且原子值发生变化。其行为类似于反复比较提供的参数与load()返回的值,如果相等,则阻塞直到由notify_one()或notify_all()通知,或者线程被意外解除阻塞。如果比较的值不相等,则函数返回而不阻塞。
- 
成员函数 notify_one()以及非成员函数atomic_notify_one()和atomic_flag_notify_one()原子性地通知,至少有一个线程在原子等待操作中被阻塞。如果没有这样的线程被阻塞,该函数不执行任何操作。
- 
成员函数 notify_all()以及非成员函数atomic_notify_all()和atomic_flag_notify_all()解除所有在原子等待操作中被阻塞的线程的阻塞,或者如果没有这样的线程,则不执行任何操作。
最后,应该提到的是,所有来自标准原子操作库的原子对象——std::atomic、std::atomic_ref和std::atomic_flag——都是无数据竞争的。
参见
- 
与线程协同工作,了解 std::thread类以及如何在 C++中处理线程的基本操作
- 
使用互斥锁和锁同步对共享数据的访问,以了解同步线程对共享数据访问的可用机制及其工作原理 
- 
异步执行函数,了解如何使用 std::future类和std::async()函数在不同的线程上异步执行函数并将结果返回
使用线程实现并行映射和折叠
在第三章,探索函数中,我们讨论了两个高阶函数:map,它通过转换范围或产生一个新的范围来将函数应用于范围中的元素,以及fold(也称为reduce),它将范围中的元素组合成一个单一值。我们所做的各种实现都是顺序的。然而,在并发、线程和异步任务的环境中,我们可以利用硬件来运行这些函数的并行版本,以加快大范围或转换和聚合耗时时的执行速度。在本配方中,我们将看到实现map和fold使用线程的可能解决方案。
准备工作
您需要熟悉map和fold函数的概念。建议您阅读第三章,探索函数中的实现高阶函数 map 和 fold配方。在本配方中,我们将使用与线程一起工作配方中展示的各种线程功能。
为了测量这些函数的执行时间并将其与顺序替代方案进行比较,我们将使用我们在第六章,通用工具中的使用标准时钟测量函数执行时间配方中引入的perf_timer类模板。
算法的并行版本可能会加快执行时间,但这并不一定在所有情况下都成立。线程的上下文切换和对共享数据的同步访问可能会引入显著的开销。对于某些实现和特定数据集,这种开销可能会使并行版本的实际执行时间比顺序版本更长。
为了确定需要拆分工作所需的线程数,我们将使用以下函数:
unsigned get_no_of_threads()
{
  return std::thread::hardware_concurrency();
} 
在下一节中,我们将探讨map和fold函数并行版本的第一种可能实现。
如何做...
要实现map函数的并行版本,请执行以下操作:
- 
定义一个函数模板,它接受范围的 begin和end迭代器以及应用于所有元素的功能:template <typename Iter, typename F> void parallel_map(Iter begin, Iter end, F&& f) { }
- 
检查范围的大小。如果元素数量小于预定义的阈值(对于此实现,阈值为 10,000),则以顺序方式执行映射: auto size = std::distance(begin, end); if(size <= 10000) std::transform(begin, end, begin, std::forward<F>(f));
- 
对于较大的范围,可以将工作分配到多个线程,并让每个线程映射范围的一部分。这些部分不应重叠,以避免同步访问共享数据的需求: else { auto no_of_threads = get_no_of_threads(); auto part = size / no_of_threads; auto last = begin; // continued at 4\. and 5. }
- 
启动线程,并在每个线程上运行映射的顺序版本: std::vector<std::thread> threads; for(unsigned i = 0; i < no_of_threads; ++i) { if(i == no_of_threads - 1) last = end; else std::advance(last, part); threads.emplace_back( [=,&f]{std::transform(begin, last, begin, std::forward<F>(f));}); begin = last; }
- 
等待所有线程完成执行: for(auto & t : threads) t.join();
将前面的步骤组合起来,得到以下实现:
template <typename Iter, typename F>
void parallel_map(Iter begin, Iter end, F&& f)
{
  auto size = std::distance(begin, end);
  if(size <= 10000)
    std::transform(begin, end, begin, std::forward<F>(f)); 
  else
  {
    auto no_of_threads = get_no_of_threads();
    auto part = size / no_of_threads;
    auto last = begin;
    std::vector<std::thread> threads;
    for(unsigned i = 0; i < no_of_threads; ++i)
    {
      if(i == no_of_threads - 1) last = end;
      else std::advance(last, part);
      threads.emplace_back(
        [=,&f]{std::transform(begin, last, 
                              begin, std::forward<F>(f));});
      begin = last;
    }
    for(auto & t : threads) t.join();
  }
} 
要实现左fold函数的并行版本,请执行以下操作:
- 
定义一个函数模板,它接受一个范围的 begin和end迭代器、一个初始值以及应用于范围元素的二进制函数:template <typename Iter, typename R, typename F> auto parallel_fold(Iter begin, Iter end, R init, F&& op) { }
- 
检查范围的大小。如果元素数量小于预定义的阈值(对于此实现,为 10,000),则以顺序方式执行折叠: auto size = std::distance(begin, end); if(size <= 10000) return std::accumulate(begin, end, init, std::forward<F>(op));
- 
对于较大的范围,将工作拆分为多个线程,并让每个线程处理范围的一部分。这些部分不应重叠,以避免共享数据的线程同步。结果可以通过传递给线程函数的引用返回,以避免数据同步: else { auto no_of_threads = get_no_of_threads(); auto part = size / no_of_threads; auto last = begin; // continued with 4\. and 5. }
- 
启动线程,并在每个线程上执行顺序版本的折叠: std::vector<std::thread> threads; std::vector<R> values(no_of_threads); for(unsigned i = 0; i < no_of_threads; ++i) { if(i == no_of_threads - 1) last = end; else std::advance(last, part); threads.emplace_back( =,&op{ result = std::accumulate(begin, last, R{}, std::forward<F>(op));}, std::ref(values[i])); begin = last; }
- 
等待所有线程执行完毕并将部分结果合并到最终结果中: for(auto & t : threads) t.join(); return std::accumulate(std::begin(values), std::end(values), init, std::forward<F>(op));
我们刚才组合的步骤导致以下实现:
template <typename Iter, typename R, typename F>
auto parallel_fold(Iter begin, Iter end, R init, F&& op)
{
  auto size = std::distance(begin, end);
  if(size <= 10000)
    return std::accumulate(begin, end, init, std::forward<F>(op));
  else
  {
    auto no_of_threads = get_no_of_threads();
    auto part = size / no_of_threads;
    auto last = begin;
    std::vector<std::thread> threads;
    std::vector<R> values(no_of_threads);
    for(unsigned i = 0; i < no_of_threads; ++i)
    {
      if(i == no_of_threads - 1) last = end;
      else std::advance(last, part);
      threads.emplace_back(
        =,&op{
          result = std::accumulate(begin, last, R{}, 
                                   std::forward<F>(op));},
        std::ref(values[i]));
      begin = last;
    }
    for(auto & t : threads) t.join();
    return std::accumulate(std::begin(values), std::end(values), 
                           init, std::forward<F>(op));
  }
} 
它是如何工作的...
map和fold的这些并行实现有几个方面是相似的:
- 
如果范围中的元素数量小于 10,000,它们都会回退到顺序版本。 
- 
它们都启动相同数量的线程。这些线程是通过使用静态函数 std::thread::hardware_concurrency()确定的,该函数返回实现支持的并发线程数。然而,这个值更多的是一个提示,而不是一个准确值,应该考虑到这一点。
- 
没有使用共享数据以避免访问同步。尽管所有线程都在处理同一范围的元素,但它们都处理不重叠的范围部分。 
- 
这两个函数都实现为函数模板,它们接受一个开始迭代器和结束迭代器来定义要处理的范围。为了将范围拆分为多个部分,由不同的线程独立处理,请在范围中间使用额外的迭代器。为此,我们使用 std::advance()来增加迭代器的特定位置数。这对于向量或数组来说效果很好,但对于列表等容器来说效率非常低。因此,此实现仅适用于具有随机访问迭代器的范围。
map和fold的顺序版本可以用std::transform()和std::accumulate()简单地实现。实际上,为了验证并行算法的正确性并检查它们是否提供了任何执行速度提升,我们可以将它们与这些通用算法的执行进行比较。
为了进行测试,我们将使用map和fold在一个大小从 10,000 到 5,000 万元素的向量上。首先将范围映射(即转换),即每个元素的值翻倍,然后将结果折叠成一个单一值,通过将范围的所有元素相加。为了简单起见,范围中的每个元素都等于其基于 1 的索引(第一个元素是 1,第二个元素是 2,依此类推)。以下示例在大小不同的向量上运行了map和fold的顺序和并行版本,并以表格格式打印了执行时间:
作为练习,你可以改变元素的数量以及线程的数量,并观察并行版本与顺序版本的性能对比。
std::vector<int> sizes
{
  10000, 100000, 500000, 
  1000000, 2000000, 5000000, 
  10000000, 25000000, 50000000
};
std::cout
  << std::right << std::setw(8) << std::setfill(' ') << "size"
  << std::right << std::setw(8) << "s map"
  << std::right << std::setw(8) << "p map"
  << std::right << std::setw(8) << "s fold"
  << std::right << std::setw(8) << "p fold"
  << '\n';
for (auto const size : sizes)
{
  std::vector<int> v(size);
  std::iota(std::begin(v), std::end(v), 1);
  auto v1 = v;
  auto s1 = 0LL;
  auto tsm = perf_timer<>::duration([&] {
    std::transform(std::begin(v1), std::end(v1), std::begin(v1), 
                   [](int const i) {return i + i; }); });
  auto tsf = perf_timer<>::duration([&] {
    s1 = std::accumulate(std::begin(v1), std::end(v1), 0LL,
                         std::plus<>()); });
  auto v2 = v;
  auto s2 = 0LL;
  auto tpm = perf_timer<>::duration([&] {
    parallel_map(std::begin(v2), std::end(v2), 
                 [](int const i) {return i + i; }); });
  auto tpf = perf_timer<>::duration([&] {
    s2 = parallel_fold(std::begin(v2), std::end(v2), 0LL,
                       std::plus<>()); });
  assert(v1 == v2);
  assert(s1 == s2);
  std::cout
    << std::right << std::setw(8) << std::setfill(' ') << size
    << std::right << std::setw(8) 
    << std::chrono::duration<double, std::micro>(tsm).count()
    << std::right << std::setw(8) 
    << std::chrono::duration<double, std::micro>(tpm).count()
    << std::right << std::setw(8) 
    << std::chrono::duration<double, std::micro>(tsf).count()
    << std::right << std::setw(8) 
    << std::chrono::duration<double, std::micro>(tpf).count()
    << '\n';
} 
该程序的可能的输出如下图表所示(在运行 Windows 64 位操作系统、Intel Core i7 处理器和 4 个物理核心、8 个逻辑核心的机器上执行)。特别是fold实现,并行版本的性能优于顺序版本。但这仅在向量的长度超过一定大小时才成立。在下面的表中,我们可以看到,对于最多一百万个元素,顺序版本仍然更快。当向量中有两百万个或更多元素时,并行版本执行得更快。请注意,实际时间可能会因运行而异,即使在同一台机器上,它们也可能在不同机器上非常不同:
 size   s map   p map  s fold  p fold
   10000      11      10       7      10
  100000     108    1573      72     710
  500000     547    2006     361     862
 1000000    1146    1163     749     862
 2000000    2503    1527    1677    1289
 5000000    5937    3000    4203    2314
10000000   11959    6269    8269    3868
25000000   29872   13823   20961    9156
50000000   60049   27457   41374   19075 
为了更好地可视化这些结果,我们可以将并行版本的加速以柱状图的形式表示。在下面的图表中,蓝色柱状图表示并行map实现的加速,而橙色柱状图显示并行fold实现的加速。正值表示并行版本更快;负值表示顺序版本更快:

图 8.1:对于各种处理元素,map(蓝色)和 fold(橙色)并行实现的加速
这个图表使得更容易看出,只有当元素数量超过某个特定阈值(在我的基准测试中约为两百万)时,并行实现才比顺序版本更快。
参见
- 
第三章,实现高阶函数 map 和 fold,了解函数式编程中的高阶函数,并了解如何实现广泛使用的 map和fold(或 reduce)函数
- 
使用任务实现并行 map 和 fold,了解如何使用异步函数实现函数式编程中的 map和fold函数
- 
使用标准并行算法实现并行 map 和 fold,了解如何使用 C++17 中的并行算法实现函数式编程中的 map和fold函数
- 
使用线程,了解 std::thread类以及 C++中处理线程的基本操作
使用任务实现并行 map 和 fold
任务是执行并发计算的高级替代方案。std::async()使我们能够异步执行函数,无需处理低级线程细节。在本食谱中,我们将执行与之前食谱中相同的任务,即实现map和fold函数的并行版本,但我们将使用任务,并观察它与线程版本的比较。
准备工作
本菜谱中提出的解决方案在许多方面与之前菜谱中使用的线程的解决方案相似,即 使用线程实现并行 map 和 fold。在继续当前菜谱之前,请确保阅读那个菜谱。
如何做到这一点...
要实现 map 函数的并行版本,请按照以下步骤操作:
- 
定义一个函数模板,它接受一个范围的起始和结束迭代器以及应用于所有元素的功能: template <typename Iter, typename F> void parallel_map(Iter begin, Iter end, F&& f) { }
- 
检查范围的大小。对于元素数量小于预定义阈值(对于此实现,阈值为 10,000)的情况,以顺序方式执行映射: auto size = std::distance(begin, end); if(size <= 10000) std::transform(begin, end, begin, std::forward<F>(f));
- 
对于较大的范围,将工作分成多个任务,并让每个任务映射范围的一部分。这些部分不应重叠,以避免同步对共享数据的线程访问: else { auto no_of_tasks = get_no_of_threads(); auto part = size / no_of_tasks; auto last = begin; // continued at 4\. and 5. }
- 
启动异步函数,并对每个函数执行顺序版本的映射: std::vector<std::future<void>> tasks; for(unsigned i = 0; i < no_of_tasks; ++i) { if(i == no_of_tasks - 1) last = end; else std::advance(last, part); tasks.emplace_back(std::async( std::launch::async, [=,&f]{std::transform(begin, last, begin, std::forward<F>(f));})); begin = last; }
- 
等待所有异步函数执行完成: for(auto & t : tasks) t.wait();
将这些步骤组合起来,可以得到以下实现:
template <typename Iter, typename F>
void parallel_map(Iter begin, Iter end, F&& f)
{
  auto size = std::distance(begin, end);
  if(size <= 10000)
    std::transform(begin, end, begin, std::forward<F>(f)); 
  else
  {
    auto no_of_tasks = get_no_of_threads();
    auto part = size / no_of_tasks;
    auto last = begin;
    std::vector<std::future<void>> tasks;
    for(unsigned i = 0; i < no_of_tasks; ++i)
    {
      if(i == no_of_tasks - 1) last = end;
      else std::advance(last, part);
      tasks.emplace_back(std::async(
        std::launch::async, 
          [=,&f]{std::transform(begin, last, begin, 
                                std::forward<F>(f));}));
      begin = last;
    }
    for(auto & t : tasks) t.wait();
  }
} 
要实现左 fold 函数的并行版本,请按照以下步骤操作:
- 
定义一个函数模板,它接受一个范围的起始和结束迭代器、一个初始值以及一个应用于范围元素的二元函数: template <typename Iter, typename R, typename F> auto parallel_fold(Iter begin, Iter end, R init, F&& op) { }
- 
检查范围的大小。对于元素数量小于预定义阈值(对于此实现,阈值为 10,000)的情况,以顺序方式执行折叠: auto size = std::distance(begin, end); if(size <= 10000) return std::accumulate(begin, end, init, std::forward<F>(op));
- 
对于较大的范围,将工作分成多个任务,并让每个任务折叠范围的一部分。这些部分不应重叠,以避免同步对共享数据的线程访问。结果可以通过传递给异步函数的引用返回,以避免同步: else { auto no_of_tasks = get_no_of_threads(); auto part = size / no_of_tasks; auto last = begin; // continued at 4\. and 5. }
- 
启动异步函数,并对每个函数执行顺序版本的折叠: std::vector<std::future<R>> tasks; for(unsigned i = 0; i < no_of_tasks; ++i) { if(i == no_of_tasks - 1) last = end; else std::advance(last, part); tasks.emplace_back( std::async( std::launch::async, [=,&op]{return std::accumulate( begin, last, R{}, std::forward<F>(op));})); begin = last; }
- 
等待所有异步函数执行完成,并将部分结果折叠成最终结果: std::vector<R> values; for(auto & t : tasks) values.push_back(t.get()); return std::accumulate(std::begin(values), std::end(values), init, std::forward<F>(op));
将这些步骤组合起来,可以得到以下实现:
template <typename Iter, typename R, typename F>
auto parallel_fold(Iter begin, Iter end, R init, F&& op)
{
  auto size = std::distance(begin, end);
  if(size <= 10000)
    return std::accumulate(begin, end, init, std::forward<F>(op));
  else
  {
    auto no_of_tasks = get_no_of_threads();
    auto part = size / no_of_tasks;
    auto last = begin;
    std::vector<std::future<R>> tasks;
    for(unsigned i = 0; i < no_of_tasks; ++i)
    {
      if(i == no_of_tasks - 1) last = end;
      else std::advance(last, part);
      tasks.emplace_back(
        std::async(
          std::launch::async,
          [=,&op]{return std::accumulate(
                            begin, last, R{}, 
                            std::forward<F>(op));}));
      begin = last;
    }
    std::vector<R> values;
    for(auto & t : tasks)
      values.push_back(t.get());
    return std::accumulate(std::begin(values), std::end(values), 
                           init, std::forward<F>(op));
  }
} 
它是如何工作的...
提出的实现仅略不同于之前的菜谱。线程被替换为异步函数,从 std::async() 开始,并通过返回的 std::future 提供结果。并发启动的异步函数数量等于实现可以支持的线程数量。这个值由静态方法 std::thread::hardware_concurrency() 返回,但这个值只是一个提示,不应被视为非常可靠。
采用这种方法的两个主要原因:
- 
看看一个为线程并行执行而实现的函数如何修改以使用异步函数,从而避免线程的底层细节。 
- 
运行与支持的线程数量相等的异步函数可能每个线程运行一个函数;这可能会为并行函数提供最快的执行时间,因为上下文切换和等待时间的开销最小。 
我们可以使用与之前配方相同的方法测试新的map和fold实现的性能:
std::vector<int> sizes
{
  10000, 100000, 500000,
  1000000, 2000000, 5000000,
  10000000, 25000000, 50000000
};
std::cout
  << std::right << std::setw(8) << std::setfill(' ') << "size"
  << std::right << std::setw(8) << "s map"
  << std::right << std::setw(8) << "p map"
  << std::right << std::setw(8) << "s fold"
  << std::right << std::setw(8) << "p fold"
  << '\n';
for(auto const size : sizes)
{
  std::vector<int> v(size);
  std::iota(std::begin(v), std::end(v), 1);
  auto v1 = v;
  auto s1 = 0LL;
  auto tsm = perf_timer<>::duration([&] {
    std::transform(std::begin(v1), std::end(v1), std::begin(v1), 
                   [](int const i) {return i + i; }); });
  auto tsf = perf_timer<>::duration([&] {
    s1 = std::accumulate(std::begin(v1), std::end(v1), 0LL,
                         std::plus<>()); });
auto v2 = v;
auto s2 = 0LL;
auto tpm = perf_timer<>::duration([&] {
  parallel_map(std::begin(v2), std::end(v2), 
               [](int const i) {return i + i; }); });
auto tpf = perf_timer<>::duration([&] {
  s2 = parallel_fold(std::begin(v2), std::end(v2), 0LL, 
                       std::plus<>()); });
assert(v1 == v2);
assert(s1 == s2);
std::cout
  << std::right << std::setw(8) << std::setfill(' ') << size
  << std::right << std::setw(8) 
  << std::chrono::duration<double, std::micro>(tsm).count()
  << std::right << std::setw(8) 
  << std::chrono::duration<double, std::micro>(tpm).count()
  << std::right << std::setw(8) 
  << std::chrono::duration<double, std::micro>(tsf).count()
  << std::right << std::setw(8) 
  << std::chrono::duration<double, std::micro>(tpf).count()
  << '\n';
} 
前一个程序的可能输出,可能因执行而略有不同,因机器而大不相同,如下所示:
 size   s map   p map  s fold  p fold
   10000      11      11      11      11
  100000     117     260     113      94
  500000     576     303     571     201
 1000000    1180     573    1165     283
 2000000    2371     911    2330     519
 5000000    5942    2144    5841    1886
10000000   11954    4999   11643    2871
25000000   30525   11737   29053    9048
50000000   59665   22216   58689   12942 
与线程解决方案的说明类似,以下图表显示了并行map和fold实现的加速。
负值表示顺序版本更快:

图 8.2:使用异步函数的并行实现(在蓝色中)和折叠(在橙色中)相对于顺序实现的加速
如果我们将此与使用线程的并行版本的结果进行比较,我们会发现这些执行时间更快,并且速度提升显著,尤其是在fold函数上。以下图表显示了任务实现相对于线程实现的加速:

图 8.3:使用异步函数的并行实现相对于使用线程的并行实现(在蓝色中)和折叠(在橙色中)的速度提升
还有更多...
之前显示的实现只是我们可以采取的并行化map和fold函数的可能方法之一。一个可能的替代方案使用以下策略:
- 
将要处理的范围分成两个相等的部分。 
- 
递归异步调用并行函数以处理范围的第一个部分。 
- 
递归同步调用并行函数以处理范围的第二部分。 
- 
在完成同步递归调用后,等待异步递归调用结束再完成执行。 
这种分而治之算法可能会创建很多任务。根据范围的大小,异步调用的数量可能会远远超过线程的数量,在这种情况下,会有很多等待时间,这会影响整体执行时间。
map和fold函数可以使用以下分而治之算法实现:
template <typename Iter, typename F>
void parallel_map(Iter begin, Iter end, F f)
{ 
  auto size = std::distance(begin, end);
  if(size <= 10000)
  {
    std::transform(begin, end, begin, std::forward<F>(f)); 
  }
  else
  {
    auto middle = begin;
    std::advance(middle, size / 2);
    auto result = std::async(
      std::launch::deferred, 
      parallel_map<Iter, F>, 
      begin, middle, std::forward<F>(f));
    parallel_map(middle, end, std::forward<F>(f));
    result.wait();
  }
}
template <typename Iter, typename R, typename F>
auto parallel_fold(Iter begin, Iter end, R init, F op)
{
  auto size = std::distance(begin, end);
  if(size <= 10000)
    return std::accumulate(begin, end, init, std::forward<F>(op));
  else
  {
    auto middle = begin;
    std::advance(middle, size / 2);
    auto result1 = std::async(
      std::launch::async, 
      parallel_reduce<Iter, R, F>, 
      begin, middle, R{}, std::forward<F>(op));
    auto result2 = parallel_fold(middle, end, init, 
                                 std::forward<F>(op));
    return result1.get() + result2;
  }
} 
此实现的执行时间列于此处,与之前实现的执行时间并列:
 size   s map p1 map  p2 map  s fold p1 fold p2 fold
   10000      11     11      10       7      10      10
  100000     111    275     120      72      96     426
  500000     551    230     596     365     210    1802
 1000000    1142    381    1209     753     303    2378
 2000000    2411    981    2488    1679     503    4190
 5000000    5962   2191    6237    4177    1969    7974
10000000   11961   4517   12581    8384    2966   15174 
当我们比较这些执行时间时,我们可以看到这个版本(在前面的输出中由p2表示)对于map和fold都与顺序版本相似,并且比之前显示的第一个并行版本(由p1表示)要差得多。
参见
- 
使用线程实现并行 map 和 fold,查看如何使用原始线程实现函数式编程中的 map和fold函数
- 
使用标准并行算法实现并行 map 和 fold,了解如何使用 C++17 的并行算法实现函数式编程中的 map和fold函数
- 
异步执行函数,了解如何使用 std::future类和std::async()函数在不同的线程上异步执行函数并将结果返回
使用标准并行算法实现并行 map 和 fold
在前两个食谱中,我们使用线程和任务实现了map和fold函数的并行版本(在标准库中分别称为std::transform()和std::accumulate())。然而,这些实现需要手动处理并行化细节,例如将数据分割成并行处理的数据块,创建线程或任务,同步它们的执行,以及合并结果。
在 C++17 中,许多标准泛型算法已被并行化。实际上,同一个算法可以按顺序或并行执行,这取决于提供的执行策略。在本食谱中,我们将学习如何使用标准算法并行实现map和fold。
准备工作
在继续此食谱之前,建议你阅读前两个,以确保你理解了各种并行实现之间的差异。
如何做到这一点...
要使用具有并行执行的标准化算法,你应该做以下事情:
- 
寻找一个适合并行化的算法。并非每个算法在并行时都会运行得更快。确保你正确地识别了程序中可以通过并行化改进的部分。为此使用分析器,并且通常查看具有O(n)或更差复杂性的操作。 
- 
包含头文件 <execution>以使用执行策略。
- 
将并行执行策略( std::execution::par)作为重载算法的第一个参数。
使用std::transform()的并行重载实现的 map 函数的并行实现如下:
template <typename Iter, typename F>
void parallel_map(Iter begin, Iter end, F&& f)
{
   std::transform(std::execution::par,
                  begin, end,
                  begin,
                  std::forward<F>(f));
} 
使用std::reduce()的并行重载实现的 fold 函数的并行实现如下:
template <typename Iter, typename R, typename F>
auto parallel_fold(Iter begin, Iter end, R init, F&& op)
{
   return std::reduce(std::execution::par,
                      begin, end,
                      init,
                      std::forward<F>(op));
} 
它是如何工作的...
在 C++17 中,69 个标准泛型算法被重载以支持并行执行。这些重载将执行策略作为第一个参数。从头文件<execution>中可用的执行策略如下:
| 策略 | 自 | 描述 | 全局对象 | 
|---|---|---|---|
| std::execution::sequenced_policy | C++17 | 表示算法可能不会并行执行。 | std::execution::seq | 
| std::execution::parallel_policy | C++17 | 表示算法的执行可能被并行化。 | std::execution::par | 
| std::execution::parallel_unsequenced_policy | C++17 | 表示算法的执行可能被并行化和向量化。 | std::execution::par_unseq | 
| std::execution::unsequenced_policy | C++20 | 表示算法的执行可能被向量化。 | std::execution::unseq | 
表 8.2:来自 <execution> 头文件的执行策略
向量化是将算法转换为一次处理一组值(向量)而不是一次处理单个值的过程。现代处理器通过SIMD(单指令,多数据)单元在硬件级别提供这种功能。
除了现有的已重载的算法外,还增加了七个新算法:
| 算法 | 描述 | 
|---|---|
| std::for_each_n | 根据指定的执行策略,将给定的函数应用于指定范围的前N个元素。 | 
| std::exclusive_scan | 计算元素范围的局部和(使用 std::plus<>或二元操作),但排除第i个元素的第i个和。如果二元操作是结合的,则结果与使用std::partial_sum()相同。 | 
| std::inclusive_scan | 计算元素范围的局部和(使用 std::plus<>或二元操作),但包括第i个元素在第i个和中。 | 
| std::transform_exclusive_scan | 将一元函数应用于范围的每个元素,然后计算结果范围的排除扫描。 | 
| std::transform_inclusive_scan | 将一元函数应用于范围中的每个元素,然后计算结果范围的包含扫描。 | 
| std::reduce | std::accumulate()的无序版本。 | 
| std::transform_reduce | 将函数应用于范围的元素,然后无序地累积结果范围的元素(即减少)。 | 
表 8.2:来自 <algorithm> 和 <numeric> 头文件的新算法
在前面的示例中,我们使用std::transform()和std::reduce()与执行策略一起使用——在我们的情况下,std::execution::par。算法std::reduce()类似于std::accumulate(),但它以无序的方式处理元素。std::accumulate()没有指定执行策略的重载,因此它只能顺序执行。
需要注意的是,尽管一个算法支持并行化,但这并不意味着它的运行速度会比顺序版本更快。执行速度取决于实际硬件、数据集和算法的特定特性。实际上,这些算法在并行化时可能永远不会,或者几乎不会比顺序执行更快。因此,例如,微软对一些排列、复制或移动元素的算法的实现并没有执行并行化,而是在所有情况下都回退到顺序执行。这些算法包括 copy()、copy_n()、fill()、fill_n()、move()、reverse()、reverse_copy()、rotate()、rotate_copy() 和 swap_ranges()。此外,标准并不保证特定的执行;指定策略实际上是一个执行策略的请求,但没有隐含的保证。
另一方面,标准库允许并行算法分配内存。当无法这样做时,算法会抛出 std::bad_alloc。然而,微软的实现有所不同,它不是抛出异常,而是回退到算法的顺序版本。
另一个必须了解的重要方面是,标准算法与不同类型的迭代器一起工作。一些需要前向迭代器,一些需要输入迭代器。然而,所有允许指定执行策略的重载都限制了算法与前向迭代器的使用。
看一下下面的表格:

图 8.4:顺序和并行实现 map 和 reduce 函数的执行时间比较
在这里,你可以看到 map 和 reduce 函数顺序和并行实现的执行时间比较。突出显示的是本食谱中实现的函数版本。这些时间可能会因执行而略有不同。这些值是通过在具有四核英特尔至强 CPU 的机器上使用 Visual C++ 2019 16.4.x 编译的 64 位发布版本获得的。尽管对于这些数据集,并行版本的性能优于顺序版本,但实际上哪个版本更好取决于数据集的大小。这就是为什么在通过并行化工作来优化时,分析至关重要。
还有更多...
在这个例子中,我们看到了 map 和 fold(也称为 reduce)的单独实现。然而,在 C++17 中,有一个名为 std::transform_reduce() 的标准算法,它将这两个操作组合成一个单独的函数调用。这个算法有顺序执行的过载,以及基于策略的并行化和向量化执行。因此,我们可以利用这个算法来代替我们在前三个食谱中手动实现的实现。
以下是用以计算范围中所有元素双倍之和的算法的顺序和并行版本:
std::vector<int> v(size);
std::iota(std::begin(v), std::end(v), 1);
// sequential
auto sums = std::transform_reduce(
    std::begin(v), std::end(v), 
    0LL,
    std::plus<>(),
    [](int const i) {return i + i; } );
// parallel
auto sump = std::transform_reduce(
    std::execution::par,
    std::begin(v), std::end(v),
    0LL,
    std::plus<>(),
    [](int const i) {return i + i; }); 
如果我们将以下表格中最后两列显示的这两个调用的执行时间与单独调用 map 和 reduce 的总时间进行比较,正如其他实现中所示,您会发现 std::transform_reduce(),尤其是并行版本,在大多数情况下执行得更好:

图 8.5:transform/reduce 模式的执行时间比较,突出显示 C++17 中 std::transform_reduce() 标准算法的时间
参见
- 
第三章,实现高阶函数 map 和 fold,了解函数式编程中的高阶函数,并了解如何实现广泛使用的 map和fold(或 reduce)函数
- 
使用线程实现并行 map 和 fold,了解如何使用原始线程实现函数式编程中的 map和fold函数
- 
使用任务实现并行 map 和 fold,了解如何使用异步函数实现函数式编程中的 map和fold函数
使用可连接线程和取消机制
C++11 类 std::thread 代表一个执行线程,并允许多个函数并发执行。然而,它有一个主要的不便之处:您必须显式调用 join() 方法等待线程完成执行。这可能导致问题,因为如果 std::thread 对象在仍然可连接时被销毁,则会调用 std::terminate()。C++20 提供了一个改进的线程类,称为 std::jthread(来自 joinable thread),如果对象销毁时线程仍然可连接,则会自动调用 join()。此外,此类型支持通过 std::stop_source/std::stop_token 进行取消,其析构函数也会在连接之前请求线程停止。在本菜谱中,您将学习如何使用这些新的 C++20 类型。
准备工作
在继续之前,您应该阅读本章的第一个菜谱,与线程一起工作,以确保您熟悉 std::thread。要使用 std::jthread,您需要包含相同的 <thread> 头文件。对于 std::stop_source 和 std::stop_token,您需要包含头文件 <stop_token>。
如何做到...
使用可连接线程和协作取消机制的经典场景如下:
- 
如果您想在对象超出作用域时自动连接线程对象,请使用 std::jthread而不是std::thread。您仍然可以使用std::thread所有的方法,例如使用join()显式连接:void thread_func(int i) { while(i-- > 0) { std::cout << i << '\n'; } } int main() { std::jthread t(thread_func, 10); }
- 
如果您需要能够取消线程的执行,您应该做以下事情: - 
确保线程函数的第一个参数是 std::stop_token对象。
- 
在线程函数中,定期使用 std::stop_token对象的stop_requested()方法检查是否请求停止,并在收到信号时停止。
- 
使用 std::jthread在单独的线程上执行函数。
- 
从调用线程中,使用 std::jthread对象的request_stop()方法请求线程函数停止并返回:void thread_func(std::stop_token st, int& i) { while(!st.stop_requested() && i < 100) { using namespace std::chrono_literals; std::this_thread::sleep_for(200ms); i++; } } int main() { int a = 0; std::jthread t(thread_func, std::ref(a)); using namespace std::chrono_literals; std::this_thread::sleep_for(1s); t.request_stop(); std::cout << a << '\n'; // prints 4 }
 
- 
- 
如果你需要取消多个线程的工作,你可以这样做: - 
所有线程函数都必须将 std::stop_token对象作为第一个参数。
- 
所有线程函数都应该定期检查是否请求了停止,通过调用 std::stop_token的stop_requested()方法,如果请求了停止,则终止执行。
- 
使用 std::jthread在不同的线程上执行函数。
- 
在调用线程中,创建一个 std::stop_source对象。
- 
通过调用 std::stop_source对象的get_token()方法获取std::stop_token对象,并在创建std::jthread对象时将其作为第一个参数传递给线程函数。
- 
当你想要停止线程函数的执行时,调用 std::stop_source对象的request_stop()方法。void thread_func(std::stop_token st, int& i) { while(!st.stop_requested() && i < 100) { using namespace std::chrono_literals; std::this_thread::sleep_for(200ms); i++; } } int main() { int a = 0; int b = 10; std::stop_source st; std::jthread t1(thread_func, st.get_token(), std::ref(a)); std::jthread t2(thread_func, st.get_token(), std::ref(b)); using namespace std::chrono_literals; std::this_thread::sleep_for(1s); st.request_stop(); std::cout << a << ' ' << b << '\n'; // prints 4 // and 14 }
 
- 
- 
如果你需要在停止源请求取消时执行一段代码,你可以使用由 std::stop_token对象创建的std::stop_callback,它发出停止请求,并在请求停止时(通过与std::stop_token关联的std::stop_source对象)调用回调函数:void thread_func(std::stop_token st, int& i) { while(!st.stop_requested() && i < 100) { using namespace std::chrono_literals; std::this_thread::sleep_for(200ms); i++; } } int main() { int a = 0; std::stop_source src; std::stop_token token = src.get_token(); std::stop_callback cb(token, []{std::cout << "the end\n";}); std::jthread t(thread_func, token, std::ref(a)); using namespace std::chrono_literals; std::this_thread::sleep_for(1s); src.request_stop(); std::cout << a << '\n'; // prints "the end" and 4 }
它是如何工作的...
std::jthread 与 std::thread 非常相似。实际上,它是试图修复 C++11 中线程所缺失的功能。它的公共接口与 std::thread 非常相似。std::thread 所有的方法在 std::jthread 中也都存在。然而,它在以下关键方面有所不同:
- 
在内部,它至少在逻辑上维护一个共享的停止状态,这使得可以请求线程函数停止执行。 
- 
它有几种处理协作取消的方法: get_stop_source(),它返回与线程共享停止状态关联的std::stop_source对象,get_stop_token(),它返回与线程共享停止状态关联的std::stop_token,以及request_stop(),它通过共享停止状态请求取消线程函数的执行。
- 
其析构函数的行为,当线程可连接时,会调用 request_stop()然后调用join(),首先发出停止执行请求,然后等待线程完成执行。
你可以像创建 std::thread 对象一样创建 std::jthread 对象。然而,传递给 std::jthread 的可调用函数可以有一个类型为 std::stop_token 的第一个参数。当你想要能够协作取消线程的执行时,这是必要的。
典型场景包括图形用户界面,其中用户交互可能会取消正在进行的操作,但可以设想许多其他情况。这样的函数线程调用如下:
- 
如果为 std::jthread构造时提供的线程函数的第一个参数是std::stop_token,则将其转发到可调用函数。
- 
如果在存在参数的情况下,可调用函数的第一个参数不是 std::stop_token对象,则将std::jthread对象内部共享的停止状态关联的std::stop_token对象传递给函数。此令牌通过调用get_stop_token()获得。
线程函数必须定期检查std::stop_token对象的状态。stop_requested()方法检查是否请求了停止。停止请求来自std::stop_source对象。
如果多个停止令牌与同一个停止源相关联,则停止请求对所有停止令牌都是可见的。如果请求停止,则无法撤销,并且后续的停止请求没有意义。要请求停止,应调用request_stop()方法。你可以通过调用stop_possible()方法来检查std::stop_source是否与停止状态相关联,并且可以请求停止。
如果你需要在请求停止源停止时调用回调函数,则可以使用std::stop_callback类。这会将std::stop_token对象与回调函数关联起来。当停止令牌的停止源被请求停止时,将调用回调。回调函数的调用方式如下:
- 
在调用 request_stop()的同一线程中。
- 
在构建 std::stop_callback对象之前,如果已经请求停止。
你可以为同一个停止令牌创建任意数量的std::stop_callback对象。然而,回调函数被调用的顺序是不确定的。唯一的保证是,如果停止是在std::stop_callback对象创建之后请求的,它们将同步执行。
还需要注意的是,如果任何回调函数通过异常返回,则将调用std::terminate()。
参见
- 
与线程协同工作,了解 std::thread类以及如何在 C++中处理线程的基本操作
- 
线程间发送通知,了解如何使用条件变量在生产者和消费者线程之间发送通知 
使用 latches、barriers 和 semaphores 同步线程
C++11 的线程支持库包括互斥锁和条件变量,这些变量使得线程同步到共享资源成为可能。互斥锁允许多个进程中的一个线程执行,而其他想要访问共享资源的线程将被挂起。在某些情况下,互斥锁的使用可能会很昂贵。因此,C++20 标准引入了几个新的、更简单的同步机制:latches、barriers 和 semaphores。尽管它们不提供新的用例,但它们的使用更简单,并且可能由于内部依赖于无锁机制而具有更高的性能。
准备工作
新的 C++20 同步机制定义在新头文件中。你必须包含 <latch> 以使用 std::latch、<barrier> 或 std::barrier,以及 <semaphore> 以使用 std::counting_semaphore 和 std::binary_semaphore。
本食谱中的代码片段将使用以下两个函数:
void process(std::vector<int> const& data) noexcept
{
   for (auto const e : data)
      std::cout << e << ' ';  
   std::cout << '\n';
}
int create(int const i, int const factor) noexcept
{
   return i * factor;
} 
如何实现...
按以下方式使用 C++20 同步机制:
- 
当你需要线程等待直到一个由其他线程减少的计数器达到零时,请使用 std::latch。闩锁必须使用非零计数初始化,并且多个线程可以减少它,而其他线程等待计数达到零。当这种情况发生时,所有等待的线程都会被唤醒,并且闩锁不能再使用。如果闩锁计数没有减少到零(没有足够的线程减少它),等待的线程将永远阻塞。在下面的示例中,四个线程正在创建数据(存储在整数向量中),主线程通过使用std::latch(每个线程在其工作完成后减少)来等待它们的完成:int const jobs = 4; std::latch work_done(jobs); std::vector<int> data(jobs); std::vector<std::jthread> threads; for(int i = 1; i <= jobs; ++i) { threads.push_back(std::jthread([&data, i, &work_done]{ using namespace std::chrono_literals; std::this_thread::sleep_for(1s); // simulate work data[i-1] = create(i, 1); // create data work_done.count_down(); // decrement counter })); } work_done.wait(); // wait for all jobs to finish process(data); // process data from all jobs
- 
当你需要执行并行任务之间的循环同步时,请使用 std::barrier。你使用一个计数和一个可选的完成函数来构造一个屏障。线程到达屏障,减少内部计数,并阻塞。当计数达到零时,调用完成函数,所有阻塞的线程被唤醒,并开始新的周期。在下面的示例中,四个线程正在创建数据,并将它们存储在一个整数向量中。当所有线程完成一个周期后,主线程通过一个完成函数处理数据。每个线程在完成一个周期后都会阻塞,直到通过使用std::barrier对象被唤醒,该对象也存储了完成函数。这个过程重复 10 次:int const jobs = 4; std::vector<int> data(jobs); int cycle = 1; std::stop_source st; // completion function auto on_completion = [&data, &cycle, &st]() noexcept { process(data); // process data from all jobs cycle++; if (cycle == 10) // stop after ten cycles st.request_stop(); }; std::barrier work_done(jobs, on_completion); std::vector<std::jthread> threads; for (int i = 1; i <= jobs; ++i) { threads.push_back(std::jthread( &data, &cycle, &work_done { while (!st.stop_requested()) { using namespace std::chrono_literals; // simulate work std::this_thread::sleep_for(200ms); data[i-1] = create(i, cycle); // create data work_done.arrive_and_wait(); // decrement counter } }, st.get_token(), i)); } for (auto& t : threads) t.join();
- 
当你想限制 N 个线程(在 binary_semaphore的情况下是一个线程)访问共享资源,或者你想在不同线程之间传递通知时,请使用std::counting_semaphore<N>或std::binary_semaphore。在下面的示例中,四个线程正在创建数据,并将数据添加到整数向量的末尾。为了避免竞争条件,使用了一个binary_semaphore对象来限制对向量的访问只能由单个线程进行:int const jobs = 4; std::vector<int> data; std::binary_semaphore sem(1); std::vector<std::jthread> threads; for (int i = 1; i <= jobs; ++i) { threads.push_back(std::jthread([&data, i, &sem] { for (int k = 1; k < 5; ++k) { // simulate work using namespace std::chrono_literals; std::this_thread::sleep_for(200ms); // create data int value = create(i, k); // acquire the semaphore sem.acquire(); // write to the shared resource data.push_back(value); // release the semaphore sem.release(); } })); } for (auto& t : threads) t.join(); process(data); // process data from all jobs
它是如何工作的...
std::latch 类实现了一个可以用来同步线程的计数器。它是一个无竞争的类,工作方式如下:
- 
计数器在创建闩锁时初始化,并且只能减少。 
- 
一个线程可以减少闩锁的值,并且可以多次这样做。 
- 
一个线程可以通过等待直到闩锁计数器达到零来阻塞。 
- 
当计数器达到零时,闩锁永久地被信号,并且所有在闩锁上阻塞的线程都会被唤醒。 
std::latch 类有以下方法:
| 方法 | 描述 | 
|---|---|
| count_down() | 通过原子操作减少内部计数器 N(默认为 1)而不阻塞调用者。此操作是原子性的。N 必须是一个正数,且不超过内部计数器的值;否则,行为是未定义的。 | 
| try_wait() | 表示内部计数器是否达到零,如果是,则返回 true。尽管计数器已经达到零,但函数可能仍然返回false的概率非常低。 | 
| wait() | 阻塞调用线程,直到内部计数器达到零。如果内部计数器已经为零,函数将立即返回而不阻塞。 | 
| arrive_and_wait() | 此函数相当于调用 count_down(),然后调用wait()。它将内部计数器减少 N(默认为1)并阻塞调用线程,直到内部计数器达到零。 | 
表 8.3:描述原子操作内存访问顺序的 std::memory_order 成员
在上一节的第一例中,我们有一个名为 work_done 的 std::latch,它初始化为执行工作的线程(或作业)数量。每个线程生成数据,然后将其写入共享资源,即整数向量。尽管这是共享的,但由于每个线程写入不同的位置,因此不存在竞态条件;因此,不需要同步机制。完成工作后,每个线程都会减少 latch 的计数器。主线程会等待直到 latch 的计数器达到零,之后它会处理来自线程的数据。
由于 std::latch 的内部计数器不能增加或重置,因此这种同步机制只能使用一次。一个类似但可重复使用的同步机制是 std::barrier。屏障允许线程阻塞,直到操作完成,这对于管理多个线程执行的重叠任务很有用。
屏障的工作方式如下:
- 
一个屏障包含一个计数器,它在创建时初始化,并且可以被到达屏障的线程减少。当计数器达到零时,它将重置为其初始值,屏障可以再次使用。 
- 
屏障还包含一个完成函数,当计数器达到零时被调用。如果使用默认的完成函数,它将在调用 arrive_and_wait()或arrive_and_drop()时作为调用的一部分被调用。否则,完成函数将在参与完成阶段的某个线程上被调用。
- 
一个屏障从开始到重置的过程称为 完成阶段。这从所谓的 同步点 开始,以 完成步骤 结束。 
- 
在屏障构建后到达同步点的第一个 N 个线程被称为 参与线程集。只有这些线程在每个后续周期中被允许到达屏障。 
- 
到达同步点的线程可以通过调用 arrive_and_wait()来决定参与完成阶段。然而,线程也可以通过调用arrive_and_drop()来从参与集中移除自己。在这种情况下,另一个线程必须取代它在参与集中的位置。
- 
当参与集中的所有线程都到达同步点时,执行完成阶段。这个过程有三个步骤:首先,调用完成函数。其次,唤醒所有阻塞的线程。最后,重置屏障计数并开始新周期。 
std::barrier 类有以下方法:
| 方法 | 描述 | 
|---|---|
| arrive() | 到达屏障的同步点并按值 n 减少预期的计数。如果 n 的值大于预期的计数,或者等于或小于零,则行为未定义。该函数以原子方式执行。 | 
| wait() | 在同步点阻塞,直到执行完成步骤。 | 
| arrive_and_wait() | 到达屏障的同步点并阻塞。调用此函数的线程必须属于参与集;否则,行为未定义。此函数仅在完成阶段结束后才返回。 | 
| arrive_and_drop() | 到达屏障的同步点并从参与集中移除线程。函数是否阻塞直到完成阶段结束是一个实现细节。调用此函数的线程必须属于参与集;否则,行为未定义。 | 
表 8.4:std::barrier 类的成员函数
How to do it... section. In this example, a std::barrier is created and initialized with a counter, which represents the number of threads, and a completion function. This function processes the data produced by all the threads, then increments a loop counter, and requests threads to stop after 10 loops. This basically means that the barrier will perform 10 cycles before the threads will finish their work. Each thread loops until a stop is requested, and, in each iteration, they produce some data, written to the shared vector of integers. At the end of the loop, each thread arrives at the barrier synchronization point, decrements the counter, and waits for it to reach zero and the completion function to execute. This is done with a call to the arrive_and_wait() method of the std::barrier class.
C++20 中线程支持库中可用的最后一种同步机制由信号量表示。信号量包含一个内部计数器,可以被多个线程同时增加和减少。当计数器达到零时,进一步尝试减少它将阻塞线程,直到另一个线程增加计数器。
有两个信号量类:std::counting_semaphore<N> 和 std::binary_semaphore。后者实际上是 std::counting_semaphore<1> 的别名。
counting_semaphore 允许 N 个线程访问共享资源,与只允许一个线程的互斥锁不同。binary_semaphore 在这个方面与互斥锁相似,因为只有一个线程可以访问共享资源。另一方面,互斥锁绑定到线程:锁定互斥锁的线程必须解锁它。然而,对于信号量来说并非如此。信号量可以被未获取它的线程释放,并且获取了信号量的线程也不必释放它。
std::counting_semaphore 类有以下方法:
| 方法 | 描述 | 
|---|---|
| acquire() | 如果内部计数器大于 0,则将其减 1。否则,它将阻塞,直到计数器大于 0。 | 
| try_acquire() | 如果计数器大于 0,则尝试将其减少 1。如果成功,返回 true,否则返回false。此方法不会阻塞。 | 
| try_acquire_for() | 如果计数器大于 0,则尝试将其减少 1。否则,它将阻塞,直到计数器大于 0 或发生指定的超时。如果成功减少计数器,函数返回 true。 | 
| try_acquire_until() | 如果计数器大于 0,则尝试将其减少 1。否则,它将阻塞,直到计数器大于 0 或经过指定的时间点。如果成功减少计数器,函数返回 true。 | 
| release() | 通过指定的值(默认为 1)增加内部计数器。任何被阻塞等待计数器大于 0 的线程将被唤醒。 | 
表 8.5:std::counting_semaphore 类的成员函数
这里列出的方法对计数器进行的所有增加和减少操作都是原子执行的。
如何做...部分的最后一个示例展示了如何使用binary_semaphore。多个线程(在这个例子中是四个)在循环中产生工作并写入共享资源。与前面的示例不同,它们只是简单地将数据添加到整数向量的末尾。因此,必须在线程之间同步对向量的访问,这就是使用二进制信号量的地方。在每次循环中,线程函数创建一个新的值(这可能需要一些时间)。然后,这个值被追加到向量的末尾。然而,线程必须调用信号量的acquire()方法以确保它是唯一可以继续执行并访问共享资源的线程。在写操作完成后,线程调用信号量的release()方法以增加内部计数器并允许另一个线程访问共享资源。
信号量可用于多种用途:阻止对共享资源的访问(类似于互斥锁)、在线程之间发出或传递通知(类似于条件变量),或实现屏障,通常比类似的机制具有更好的性能。
参见
- 
与线程一起工作,了解 std::thread类以及 C++中处理线程的基本操作
- 
使用互斥锁和锁同步对共享数据的访问,以了解可用于同步线程对共享数据访问的机制以及它们的工作原理 
- 
在线程之间发送通知,了解如何使用条件变量在生产者和消费者线程之间发送通知 
同步来自多个线程的输出流写入
std::cout 是 std::ostream 类的全局对象。它用于将文本写入标准输出控制台。尽管写入它是保证线程安全的,但这仅适用于 operator<< 的单个调用。多个此类顺序调用可能会被中断并在稍后恢复,这使得必须使用同步机制来避免损坏的结果。这适用于所有多个线程操作同一输出流的场景。为了简化这种情况,C++20 引入了 std::basic_osyncstream 以提供同步写入同一输出流的线程的机制。在本食谱中,你将学习如何使用这个新工具。
如何做到这一点...
要同步多个线程对输出流的写入访问,请执行以下操作:
- 
包含 <syncstream>头文件。
- 
定义一个 std::osyncstream类型的变量来包装共享输出流,例如std::cout。
- 
仅使用包装变量写入输出流。 
下面的代码片段展示了这种模式的示例:
std::vector<std::jthread> threads;
for (int i = 1; i <= 10; ++i)
{
   threads.push_back(
      std::jthread([](const int id)
         {
            std::osyncstream scout{ std::cout };
            scout << "thread " << id << " running\n";
         }, i));
} 
它是如何工作的...
默认情况下,标准 C++ 流对象 std::cin/std::wcin、std::cout/std::wcout、std::cerr/std::wcerr 和 std::clog/std::wclog 与其各自的 C 流 stdin、stdout 和 stderr 同步(除非调用 std::ios_base::sync_with_stdio() 禁用了这种同步)。这意味着对 C++ 流对象进行的任何操作都会立即应用于相应的 C 流。此外,访问这些流是保证线程安全的。这意味着对 operator << 或 >> 的调用是原子的;另一个线程无法访问流,直到调用完成。然而,多个调用可能会被中断,如下面的示例所示:
std::vector<std::jthread> threads;
for (int i = 1; i <= 10; ++i)
{
   threads.push_back(
      std::jthread([](const int id)
         {
            std::cout << "thread " << id << " running\n";
         }, i));
} 
输出在不同的执行中会有所不同,但看起来如下所示:
thread thread thread 6 running
thread 2 running
1 running
thread 3 running
5 running
thread 4thread 7 running
thread 10 running
thread 9 running
 running
thread 8 running 
在线程函数中,对 operator << 有三种不同的调用方式。尽管每个调用都是原子性的,但线程在调用之间可能会被挂起,以便其他线程有机会执行。这就是为什么我们看到的输出具有之前显示的形状。
这可以通过几种方式解决。可以使用同步机制,例如互斥锁。然而,在这种情况下,一个更简单的解决方案是使用一个局部的 std::stringstream 对象来构建要在控制台上显示的文本,并对 operator<< 进行单次调用,如下所示:
std::vector<std::jthread> threads;
for (int i = 1; i <= 10; ++i)
{
   threads.push_back(
      std::jthread([](const int id)
         {
            std::stringstream ss;
            ss << "thread " << id << " running\n";
            std::cout << ss.str();
         }, i));
} 
这些更改后,输出具有预期的形式:
thread 1 running
thread 2 running
thread 3 running
thread 4 running
thread 5 running
thread 6 running
thread 7 running
thread 8 running
thread 9 running
thread 10 running 
在 C++20 中,你可以使用std::osyncstream/std::wosyncstream对象来包装一个输出流以同步访问,如如何实现…部分所示。osyncstream类保证如果所有来自不同线程的写操作都通过这个类的实例进行,则不会有数据竞争。std::basic_osyncstream类包装了一个std::basic_syncbuf的实例,它反过来包装了一个输出缓冲区,但也包含一个单独的内部缓冲区。这个类在内部缓冲区中累积输出,并在对象被销毁或显式调用emit()成员函数时将其传输到包装的缓冲区。
同步流包装器可以用来同步访问任何输出流,而不仅仅是std::ostream/std::wostream(std::cout/std::wcout的类型)。例如,它可以用来同步访问字符串流,如下面的代码片段所示:
int main()
{
   std::ostringstream str{ };
   {
      std::osyncstream syncstr{ str };
      syncstr << "sync stream demo";
      std::cout << "A:" << str.str() << '\n'; // [1]
   }
   std::cout << "B:" << str.str() << '\n';    // [2]
} 
在这个示例中,我们定义了一个名为str的std::ostringstream对象。在内部块中,这个对象被std::osyncstream对象包装,然后我们通过这个包装器将文本"sync stream demo"写入字符串流。在[1]行标记的行上,我们打印字符串流的内容到控制台。然而,流的缓冲区内容为空,因为同步流尚未被销毁,也没有发生对emit()的调用。当同步流超出作用域时,其内部缓冲区的内容传输到包装流。因此,在[2]行标记的行上,str字符串流包含文本"sync stream demo"。这导致程序输出如下:
A:
B:sync stream demo 
我们可以进一步阐述这个示例,以展示emit()成员函数如何影响流的操作。让我们考虑以下代码片段:
int main()
{
   std::ostringstream str{ };
   {
      std::osyncstream syncstr{ str };
      syncstr << "sync stream demo";
      std::cout << "A:" << str.str() << '\n'; // [1]
      syncstr.emit();
      std::cout << "B:" << str.str() << '\n'; // [2]
      syncstr << "demo part 2";
      std::cout << "C:" << str.str() << '\n'; // [3]
   }
   std::cout << "D:" << str.str() << '\n';    // [4]
} 
这个第二个示例的第一部分是相同的。在[1]行,字符串缓冲区的内容为空。然而,在调用emit()之后,同步流将内部缓冲区的内容传输到包装的输出流。因此,在[2]行,字符串缓冲区包含文本"sync stream demo"。新的文本"demo part 2"通过同步流写入字符串流,但在[3]行标记执行之前,这些文本并未传输到字符串流;因此,此时字符串流的内容没有改变。当内部块结束时超出作用域,同步流内部缓冲区的新内容再次传输到包装的字符串流,此时字符串流将包含文本"sync stream demodemo part 2"。因此,这个第二个示例的输出如下:
A:
B:sync stream demo
C:sync stream demo
D:sync stream demodemo part 2 
std::basic_syncstream 类有一个名为 get_wrapped() 的成员函数,它返回指向包装流缓冲区的指针。这可以用来构造 std::basic_syncstream 类的新实例,以便你可以通过 std::basic_osyncstream 的不同实例将内容序列到相同的输出流。下面的代码片段演示了它是如何工作的:
int main()
{
   std::ostringstream str{ };
   {
      std::osyncstream syncstr{ str };
      syncstr << "sync stream demo";
      std::cout << "A:" << str.str() << '\n';    // [1]
      {
         std::osyncstream syncstr2{ syncstr.get_wrapped() };
         syncstr2 << "demo part 3";
         std::cout << "B:" << str.str() << '\n'; // [2]
      }
      std::cout << "C:" << str.str() << '\n';    // [3]
   }
   std::cout << "D:" << str.str() << '\n';       // [4]
} 
再次,示例的第一部分没有改变。然而,这里我们有一个第二个内部块,其中使用 syncstr 的 get_wrapped() 成员函数返回的流缓冲区指针构造了第二个 std::osyncstream 实例。在标记为 [2] 的行,两个 std::osyncstream 实例都尚未被销毁;因此,str 字符串流的内容仍然是空的。第一个要销毁的同步流是 syncstr2,在第二个内部块的末尾。因此,在标记为 [3] 的行,字符串流的内容将是 "demo part 3"。然后,第一个同步流对象 syncstr 在第一个内部块的末尾超出作用域,将文本 "sync stream demo" 添加到字符串流中。运行此程序的输出如下:
A:
B:
C:demo part 3
D:demo part 3sync stream demo 
尽管在所有这些示例中我们都定义了命名变量,但你也可以使用临时同步流向输出流写入,如下所示:
threads.push_back(
   std::jthread([](const int id)
      {
         std::osyncstream{ std::cout } << "thread " << id 
                                       << " running\n";
      }, i)); 
参见
- 
与线程一起工作,了解 std::thread类以及 C++ 中处理线程的基本操作
- 
使用可连接的线程和取消机制,了解 C++20 的 std::jthread类,该类管理执行线程并在其销毁时自动连接,以及改进的停止线程执行机制
在 Discord 上了解更多
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
discord.gg/7xRaTCeEhx

第九章:健壮性和性能
当选择以性能和灵活性为主要目标的面向对象编程语言时,C++ 常常是首选。现代 C++ 提供了语言和库功能,例如右值引用、移动语义和智能指针。
当与良好的异常处理实践、常量正确性、类型安全转换、资源分配和释放相结合时,C++ 使开发者能够编写更好、更健壮、更高效的代码。本章的食谱涵盖了所有这些基本主题。
本章包括以下食谱:
- 
使用异常进行错误处理 
- 
为不抛出异常的函数使用 noexcept
- 
确保程序常量正确性 
- 
创建编译时常量表达式 
- 
创建即时函数 
- 
在常量评估上下文中优化代码 
- 
在常量表达式中使用虚函数调用 
- 
执行正确的类型转换 
- 
实现移动语义 
- 
使用 unique_ptr独特拥有内存资源
- 
使用 shared_ptr共享内存资源
- 
使用 <=>运算符进行一致比较
- 
安全地比较有符号和无符号整数 
我们将从这个章节开始,介绍一些处理异常的食谱。
使用异常进行错误处理
异常是程序运行时可能出现的异常情况的一种响应。它们使控制流转移到程序的另一部分。与返回错误代码相比,异常是一种更简单、更健壮的错误处理机制,后者可能会极大地复杂化和杂乱代码。在本食谱中,我们将探讨与抛出和处理异常相关的关键方面。
准备工作
本食谱要求您具备抛出异常(使用 throw 语句)和捕获异常(使用 try...catch 块)的机制的基本知识。本食谱侧重于异常周围的良好实践,而不是 C++ 语言中异常机制的细节。
如何操作...
使用以下实践来处理异常:
- 
通过值抛出异常: void throwing_func() { throw std::runtime_error("timed out"); } void another_throwing_func() { throw std::system_error( std::make_error_code(std::errc::timed_out)); }
- 
通过引用捕获异常,或者在大多数情况下,通过常量引用捕获: try { throwing_func(); // throws std::runtime_error } catch (std::exception const & e) { std::cout << e.what() << '\n'; }
- 
在捕获类层次结构中的多个异常时,从最派生类到层次结构的基类按顺序排列 catch语句:auto exprint = [](std::exception const & e) { std::cout << e.what() << '\n'; }; try { another_throwing_func(); // throws std::system_error // 1st catch statements catches it } catch (std::system_error const & e) { exprint(e); } catch (std::runtime_error const & e) { exprint(e); } catch (std::exception const & e) { exprint(e); }
- 
使用 catch(...)捕获所有异常,无论它们的类型如何:try { throwing_func(); } catch (std::exception const & e) { std::cout << e.what() << '\n'; } catch (...) { std::cout << "unknown exception" << '\n'; }
- 
使用 throw;重新抛出当前异常。这可以用于为多个异常创建单个异常处理函数。
- 
当您想隐藏异常的原始位置时,抛出异常对象(例如, throw e;):void handle_exception() { try { throw; // throw current exception } catch (const std::logic_error & e) { /* ... */ } catch (const std::runtime_error & e) { /* ... */ } catch (const std::exception & e) { /* ... */ } } try { throwing_func(); } catch (...) { handle_exception(); }
它是如何工作的...
大多数函数必须指示其执行的成败。这可以通过不同的方式实现。以下是一些可能性:
- 
返回一个错误代码(对于成功有一个特殊值)以指示失败的具体原因: int f1(int& result) { if (...) return 1; // do something if (...) return 2; // do something more result = 42; return 0; } enum class error_codes {success, error_1, error_2}; error_codes f2(int& result) { if (...) return error_codes::error_1; // do something if (...) return error_codes::error_2; // do something more result = 42; return error_codes::success; }
- 
这种变体是只返回布尔值来仅指示成功或失败: bool g(int& result) { if (...) return false; // do something if (...) return false; // do something more result = 42; return true; }
- 
另一个替代方案是返回无效对象、空指针或空的 std::optional<T>对象:std::optional<int> h() { if (...) return {}; // do something if (...) return {}; // do something more return 42; }
在任何情况下,都应该检查函数的返回值。这可能导致复杂、杂乱、难以阅读和维护的现实代码。此外,检查函数返回值的过程始终执行,无论函数是成功还是失败。另一方面,只有当函数失败时才会抛出并处理异常,这应该比成功的执行更少发生。这实际上可能导致比返回并测试错误代码的代码更快。
异常和错误代码不是互斥的。异常应该仅用于在异常情况下转移控制流,而不是用于控制程序中的数据流。
类构造函数是特殊的函数,它们不返回任何值。它们应该构建一个对象,但在失败的情况下,它们将无法通过返回值来指示这一点。异常应该是一个构造函数用来指示失败机制的机制。与资源获取即初始化(RAII)惯用法一起,这确保了在所有情况下资源的安全获取和释放。另一方面,异常不允许离开析构函数。当这种情况发生时,程序会通过调用std::terminate()异常终止。这是在发生另一个异常时调用析构函数进行栈回溯的情况。当发生异常时,栈从抛出异常的点回溯到处理异常的块。这个过程涉及到所有这些栈帧中所有局部对象的销毁。
如果在此过程中正在销毁的对象的析构函数抛出异常,则应开始另一个栈回溯过程,这将与已经进行的过程冲突。因此,程序会异常终止。
处理构造函数和析构函数中的异常的规则如下:
- 
使用异常来指示构造函数中发生的错误。 
- 
不要在析构函数中抛出或让异常离开。 
可以抛出任何类型的异常。然而,在大多数情况下,你应该抛出临时对象,并通过常量引用捕获异常。捕获(常量)引用的原因是避免异常类型的切片。让我们考虑以下代码片段:
class simple_error : public std::exception
{
public:
  virtual const char* what() const noexcept override
 {
    return "simple exception";
  }
};
try
{
   throw simple_error{};
}
catch (std::exception e)
{
   std::cout << e.what() << '\n'; // prints "Unknown exception"
} 
我们抛出一个simple_error对象,但通过值捕获一个std::exception对象。这是simple_error的基类型。发生切片过程,派生类型信息丢失,只保留对象的std::exception部分。因此,打印的消息是未知异常,而不是预期的简单异常。使用引用可以避免对象切片。
以下是一些关于抛出异常的指南:
- 
建议抛出标准异常或从 std::exception或其他标准异常派生的自定义异常。这样做的原因是标准库提供了旨在作为表示异常首选方案的异常类。你应该使用已经可用的那些,当这些不够用时,基于标准异常构建自己的异常。这样做的主要好处是一致性,并帮助用户通过基类std::exception捕获异常。
- 
避免抛出内置类型的异常,如整数。这样做的原因是数字对用户来说信息量很小,用户必须知道它代表什么,而一个对象可以提供上下文信息。例如, throw 13;对用户来说没有任何说明,但throw access_denied_exception{};仅从类名本身就携带了大量的隐含信息,借助数据成员,它还可以携带关于异常情况的有用或必要信息。
- 
当使用提供自己异常层次结构的库或框架时,优先抛出该层次结构中的异常或从它派生的自定义异常,至少在代码中与它紧密相关的部分。这样做的主要原因是为了保持利用库 API 的代码的一致性。 
还有更多...
如前所述,当你需要创建自己的异常类型时,应从可用的标准异常之一派生,除非你正在使用具有自己异常层次结构的库或框架。C++标准定义了几个需要考虑此类目的的异常类别:
- 
std::logic_error表示指示程序逻辑错误的异常,例如无效的参数和范围之外的索引。有各种从标准派生的类,如std::invalid_argument、std::out_of_range和std::length_error。
- 
std::runtime_error表示指示超出程序范围或由于各种因素(包括外部因素)无法预测的错误。C++标准还提供了从std::runtime_error派生的几个类,包括std::overflow_error、std::underflow_error、std::system_error和 C++20 中的std::format_error。
- 
以 bad_为前缀的异常,例如std::bad_alloc、std::bad_cast和std::bad_function_call,表示程序中的各种错误,如内存分配失败、动态类型转换失败或函数调用失败。
所有这些异常的基类是 std::exception。它有一个非抛出(non-throwing)的虚方法 what(),该方法返回一个指向字符数组的指针,该数组表示错误的描述。
当您需要从标准异常派生自定义异常时,使用适当的类别,例如逻辑错误或运行时错误。如果这些类别都不合适,则可以直接从std::exception派生。以下是从标准异常派生时可以使用的可能解决方案列表:
- 
如果您需要从 std::exception派生,则重写虚拟方法what()以提供错误描述:class simple_error : public std::exception { public: virtual const char* what() const noexcept override { return "simple exception"; } };
- 
如果您从 std::logic_error或std::runtime_error派生,并且只需要提供一个不依赖于运行时数据的静态描述,则将描述文本传递给基类构造函数:class another_logic_error : public std::logic_error { public: another_logic_error(): std::logic_error("simple logic exception") {} };
- 
如果您从 std::logic_error或std::runtime_error派生,但描述消息依赖于运行时数据,则提供一个带有参数的构造函数并使用它们来构建描述消息。您可以将描述消息传递给基类构造函数或从重写的what()方法返回它:class advanced_error : public std::runtime_error { int error_code; std::string make_message(int const e) { std::stringstream ss; ss << "error with code " << e; return ss.str(); } public: advanced_error(int const e) : std::runtime_error(make_message(e).c_str()),error_code(e) { } int error() const noexcept { return error_code; } };
要查看标准异常类的完整列表,您可以访问en.cppreference.com/w/cpp/error/exception页面。
相关内容
- 
第八章,处理线程函数抛出的异常,了解如何处理从主线程或它所加入的线程抛出的工作线程中的异常 
- 
使用 noexcept指定不抛出异常的函数,以了解如何通知编译器一个函数不应该抛出异常
使用noexcept指定不抛出异常的函数
异常规范是一种语言特性,可以启用性能改进,但另一方面,如果使用不当,可能会导致程序异常终止。C++03 中的异常规范,允许您指示函数可以抛出哪些类型的异常,已在 C++11 中弃用,并在 C++17 中删除。它被 C++11 的noexcept指定符所取代。此外,使用throw()指定符来指示函数抛出,而不指示可以抛出的异常类型,已在 C++17 中弃用,并在 C++20 中完全删除。noexcept指定符仅允许您指示函数不抛出异常(与旧的throw指定符相反,旧的throw指定符可以指示函数可以抛出的类型列表)。本食谱提供了有关 C++中现代异常规范的信息,以及何时使用它们的指南。
如何实现...
使用以下构造来指定或查询异常规范:
- 
在函数声明中使用 noexcept来指示该函数不会抛出任何异常:void func_no_throw() noexcept { }
- 
在函数声明中使用 noexcept(expr),例如模板元编程,来指示函数可能抛出或可能不抛出异常,这取决于评估为bool的条件:template <typename T> T generic_func_1() noexcept(std::is_nothrow_constructible_v<T>) { return T{}; }
- 
在编译时使用 noexcept运算符来检查表达式是否声明为不抛出任何异常:template <typename T> T generic_func_2() noexcept(noexcept(T{})) { return T{}; } template <typename F, typename A> auto func(F&& f, A&& arg) noexcept { static_assert(noexcept(f(arg)), "F is throwing!"); return f(arg); } std::cout << noexcept(generic_func_2<int>) << '\n';
它是如何工作的...
截至 C++17,异常指定是函数类型的一部分,但不是函数签名的一部分;它可以作为任何函数声明的部分出现。因为异常指定不是函数签名的一部分,所以两个函数签名不能仅在异常指定上有所不同。
在 C++17 之前,异常指定不是函数类型的一部分,只能作为 lambda 声明或顶层函数声明的部分出现;它们甚至不能出现在 typedef 或类型别名声明中。关于异常指定的进一步讨论仅限于 C++17 标准。
抛出异常的过程可以通过几种方式来指定:
- 
如果没有异常指定,则函数可能抛出异常。 
- 
noexcept(false)等同于没有异常指定。
- 
noexcept(true)和noexcept表示一个函数不会抛出任何异常。
- 
throw()等同于noexcept(true),但在 C++17 中被弃用,并在 C++20 中完全删除。
使用异常指定必须谨慎进行,因为如果一个异常(无论是直接抛出还是从被调用的另一个函数中抛出)使一个标记为非抛出的函数结束,程序将立即以调用 std::terminate() 的方式异常终止。
不抛出异常的函数指针可以隐式转换为可能抛出异常的函数指针,但反之则不行。另一方面,如果一个虚函数具有非抛出异常指定,这表明所有重写声明的所有重写都必须保留此指定,除非重写的函数被声明为已删除。
在编译时,可以使用操作符 noexcept 来检查一个函数是否声明为非抛出。此操作符接受一个表达式,如果表达式被声明为非抛出或 false,则返回 true。它不会评估它检查的表达式。
noexcept 操作符,连同 noexcept 指定符一起,在模板元编程中特别有用,用于指示一个函数对于某些类型是否可能抛出异常。它还与 static_assert 声明一起使用,以检查表达式是否违反了函数的非抛出保证,如 如何做... 部分的示例所示。
以下代码提供了更多关于 noexcept 操作符如何工作的示例:
int double_it(int const i) noexcept
{
  return i + i;
}
int half_it(int const i)
{
  throw std::runtime_error("not implemented!");
}
struct foo
{
  foo() {}
};
std::cout << std::boolalpha
  << noexcept(func_no_throw()) <<  '\n' // true
  << noexcept(generic_func_1<int>()) <<  '\n' // true
  << noexcept(generic_func_1<std::string>()) <<  '\n'// true
  << noexcept(generic_func_2<int>()) << '\n' // true
  << noexcept(generic_func_2<std::string>()) <<  '\n'// true
  << noexcept(generic_func_2<foo>()) <<  '\n' // false
  << noexcept(double_it(42)) <<  '\n' // true
  << noexcept(half_it(42)) <<  '\n' // false
  << noexcept(func(double_it, 42)) <<  '\n' // true
  << noexcept(func(half_it, 42)) << '\n';            // true 
重要的是要注意,noexcept 指定符不提供编译时对异常的检查。它只代表用户通知编译器一个函数不期望抛出异常的一种方式。编译器可以使用这一点来启用某些优化。例如,std::vector 如果其移动构造函数是 noexcept,则会移动元素,否则会复制它们。
更多内容...
如前所述,使用 noexcept 指示符声明的函数由于异常而退出会导致程序异常终止。因此,应谨慎使用 noexcept 指示符。它的存在可以启用代码优化,这有助于提高性能同时保持强异常保证。一个例子是库容器。
C++语言提供了几个异常保证级别:
- 
第一级,无异常保证,不提供任何保证。如果发生异常,没有任何指示表明程序是否处于有效状态。资源可能会泄漏,内存可能会损坏,对象的不变性可能会被破坏。 
- 
基本异常保证是保证的最简单级别,它确保在抛出异常后,对象处于一致和可用的状态,没有资源泄漏发生,且不变性得到保留。 
- 
强异常保证指定操作要么成功完成,要么以抛出异常的方式完成,该异常使程序处于操作开始之前的状态。这确保了提交或回滚语义。 
- 
无抛出异常保证实际上是其中最强烈的,它指定操作保证不会抛出任何异常并成功完成。 
许多标准容器为其一些操作提供了强异常保证。例如,vector 的 push_back() 方法。可以通过使用移动构造函数或移动赋值运算符而不是向量元素类型的复制构造函数或复制赋值运算符来优化此方法。然而,为了保持其强异常保证,这只能在移动构造函数或赋值运算符不抛出异常的情况下进行。如果任一抛出异常,则必须使用复制构造函数或赋值运算符。
如果其类型参数的移动构造函数带有 noexcept 标记,std::move_if_noexcept() 实用函数会这样做。能够表明移动构造函数或移动赋值运算符不会抛出异常可能是使用 noexcept 的最重要的场景之一。
考虑以下异常指定规则:
- 
如果一个函数可能抛出异常,则不要使用任何异常指定符。 
- 
仅标记那些保证不会抛出异常的函数。 
- 
仅标记那些可能基于条件抛出异常的带有 noexcept(expression)的函数。
这些规则很重要,因为,如前所述,从 noexcept 函数抛出异常将立即通过调用 std::terminate() 终止程序。
参见
- 使用异常进行错误处理,以探索在 C++ 语言中使用异常的最佳实践
确保程序的正确性保持恒定。
虽然没有正式的定义,但常量正确性意味着不应该被修改的对象(是不可变的)保持不变。作为开发者,您可以通过使用 const 关键字来声明参数、变量和成员函数来强制执行这一点。在本食谱中,我们将探讨常量正确性的好处以及如何实现它。
如何做到...
为了确保程序具有常量正确性,您应该始终将以下内容声明为常量:
- 
函数参数不应该在函数内部被修改: struct session {}; session connect(std::string const & uri, int const timeout = 2000) { /* do something */ return session { /* ... */ }; }
- 
不变的类数据成员: class user_settings { public: int const min_update_interval = 15; /* other members */ };
- 
从外部看,不修改对象状态的类成员函数: class user_settings { bool show_online; public: bool can_show_online() const {return show_online;} /* other members */ };
- 
在其整个生命周期中值不改变的函数局部变量: user_settings get_user_settings() { return user_settings {}; } void update() { user_settings const us = get_user_settings(); if(us.can_show_online()) { /* do something */ } /* do more */ }
- 
应该绑定到临时(一个右值)以扩展临时寿命到(常量)引用寿命的引用: std::string greetings() { return "Hello, World!"; } const std::string & s = greetings(); // must use const std::cout << s << std::endl;
它是如何工作的...
将对象和成员函数声明为常量具有几个重要的好处:
- 
您防止了对象意外和故意的更改,这在某些情况下可能导致程序行为不正确。 
- 
您使编译器能够执行更好的优化。 
- 
您为其他用户记录代码的语义。 
常量正确性不是一个个人风格的问题,而是一个应该指导 C++开发的核心理念。
不幸的是,常量正确性的重要性在书籍、C++ 社区和工作环境中尚未得到,并且仍然没有得到足够的强调。但经验法则是,所有不应该改变的内容都应该声明为常量。这应该始终如此,而不仅仅是在开发的后期阶段,当您可能需要清理和重构代码时。
当您将参数或变量声明为常量时,您可以将 const 关键字放在类型之前(const T c)或之后(T const c)。这两种方式是等效的,但无论您使用哪种风格,对声明的读取必须从右侧开始。const T c 读取为 c 是一个常量的 T,而 T const c 读取为 c 是一个常量 T。当涉及到指针时,这会变得稍微复杂一些。以下表格展示了各种指针声明及其含义:
| 表达式 | 描述 | 
|---|---|
| T* p | p是一个指向非常量T的非常量指针。 | 
| const T* p | p是一个指向常量T的非常量指针。 | 
| T const * p | p是一个指向常量T的非常量指针(与前面的点相同)。 | 
| const T * const p | p是一个指向常量T的常量指针。 | 
| T const * const p | p是一个指向常量T的常量指针(与前面的点相同)。 | 
| T** p | p是一个指向非常量指针的非常量指针,该指针指向非常量T。 | 
| const T** p | p是一个指向非常量指针的非常量指针,该指针指向常量T。 | 
| T const ** p | 与 const T** p相同。 | 
| const T* const * p | p是一个指向常量指针的非常量指针,该指针是一个常量T。 | 
| T const * const * p | 与 const T* const * p相同。 | 
表 9.1:指针声明及其含义示例
将 const 关键字放在类型之后更自然,因为它与语法解释的方向一致,即从右到左。因此,本书中的所有示例都使用这种风格。
当涉及到引用时,情况类似:const T & c 和 T const & c 是等价的,这意味着 c 是指向常量 T 的引用。然而,T const & const c,这意味着 c 是指向常量 T 的常量引用,是没有意义的,因为引用——变量的别名——在隐式上是常量的,它们不能被修改来表示指向另一个变量的别名。
一个指向非常量对象的非常量指针,即 T*,可以隐式转换为指向常量对象的非常量指针,T const *。然而,T** 不能隐式转换为 T const **(这与 const T** 相同)。这是因为这可能导致通过指向非常量对象的指针修改常量对象,如下面的示例所示:
int const c = 42;
int* x;
int const ** p = &x; // this is an actual error
*p = &c;
*x = 0;              // this modifies c 
如果一个对象是常量,则只能调用其类的常量函数。然而,将成员函数声明为常量并不意味着该函数只能对常量对象进行调用;它也可能意味着该函数不会修改对象的状态,从外部看。这是一个关键方面,但通常被误解。一个类有一个内部状态,它可以通过其公共接口向其客户端公开。
然而,并非所有内部状态都可能被公开,从公共接口可见的内容可能没有在内部状态中的直接表示。(如果你对订单行进行建模,并在内部表示中具有项目数量和项目销售价格字段,那么你可能有一个公开的方法,通过乘以数量和价格来公开订单行金额。)因此,从其公共接口可见的对象状态是一个逻辑状态。将方法定义为常量是一个确保函数不改变逻辑状态的声明。然而,编译器阻止你使用此类方法修改数据成员。为了避免这个问题,应该从常量方法中修改的数据成员应声明为 mutable。
在下面的示例中,computation 是一个具有 compute() 方法的类,它执行长时间运行的计算操作。因为它不影响对象的逻辑状态,所以这个函数被声明为常量。然而,为了避免对相同输入再次计算结果,计算出的值被存储在缓存中。为了能够在常量函数中修改缓存,它被声明为 mutable:
class computation
{
  double compute_value(double const input) const
 {
    /* long running operation */
return input + 42;
  }
  mutable std::map<double, double> cache;
public:
  double compute(double const input) const
 {
    auto it = cache.find(input);
    if(it != cache.end()) return it->second;
    auto result = compute_value(input);
    cache[input] = result;
    return result;
  }
}; 
以下类表示了类似的情况,它实现了一个线程安全的容器。对共享内部数据的访问通过mutex进行保护。该类提供了添加和删除值的方法,以及如contains()这样的方法,指示项目是否存在于容器中。因为这个成员函数不打算修改对象的逻辑状态,所以它被声明为常量。但是,访问共享内部状态必须通过互斥锁进行保护。为了锁定和解锁互斥锁,必须将修改对象状态的 mutable 操作和互斥锁都声明为mutable:
template <typename T>
class container
{
  std::vector<T>     data;
  mutable std::mutex mt;
public:
  void add(T const & value)
 {
    std::lock_guard<std::mutex> lock(mt);
    data.push_back(value);
  }
  bool contains(T const & value) const
 {
    std::lock_guard<std::mutex> lock(mt);
    return std::find(std::begin(data), std::end(data), value)
           != std::end(data);
  }
}; 
mutable指定符允许我们修改使用它的类成员,即使包含的对象被声明为const。这是std::mutex类型的mt成员的情况,即使在声明为const的contains()方法中也会被修改。
有时,一个方法或运算符会被重载以同时具有常量和非常量版本。这种情况通常出现在下标运算符或提供直接访问内部状态的方法中。这样做的原因是,该方法应该对常量和非常量对象都可用。尽管行为应该不同:对于非常量对象,该方法应允许客户端修改它提供访问的数据,但对于常量对象,则不应修改。因此,非常量下标运算符返回对非常量对象的引用,而常量下标运算符返回对常量对象的引用:
class contact {};
class addressbook
{
  std::vector<contact> contacts;
public:
  contact& operator[](size_t const index);
  contact const & operator[](size_t const index) const;
}; 
应注意,如果成员函数是常量,即使对象是常量,该成员函数返回的数据可能不是常量。
const的一个重要用途是定义对临时对象的引用,如如何做…部分最后一条所述。临时对象是一个右值,非const左值引用不能绑定到右值。然而,通过将左值引用变为const,这是可能的。这会使临时对象的生存期延长到常量引用的生存期。但是,这仅适用于基于堆栈的引用,不适用于对象成员的引用。
还有更多...
可以使用const_cast转换来移除对象的const限定符,但只有在你知道该对象没有被声明为常量时才应使用它。你可以在执行正确的类型转换菜谱中了解更多关于此内容。
参见
- 
创建编译时常量表达式,了解 constexpr指定符以及如何定义可以在编译时评估的变量和函数
- 
创建即时函数,了解 C++20 的 consteval指定符,它用于定义保证在编译时评估的函数
- 
执行正确的类型转换,了解在 C++ 语言中执行正确转换的最佳实践 
创建编译时常量表达式
在编译时评估表达式的可能性提高了运行时执行效率,因为要运行的代码更少,编译器可以执行额外的优化。编译时常量不仅可以是文本(如数字或字符串),还可以是函数执行的结果。如果函数的所有输入值(无论它们是参数、局部变量还是全局变量)在编译时都是已知的,编译器可以执行该函数,并在编译时提供结果。这就是 C++11 中引入的泛型常量表达式所实现的功能,它在 C++14 中得到了放宽,甚至在 C++20 中进一步放宽。关键字 constexpr(代表 常量表达式)可以用来声明编译时常量对象和函数。我们已经在前面章节的几个例子中看到了这一点。现在,是时候学习它实际上是如何工作的了。
准备工作
C++14 和 C++20 中对泛型常量表达式的处理方式已经放宽,但这给 C++11 引入了一些破坏性变化。例如,在 C++11 中,constexpr 函数隐式地是 const 的,但在 C++14 中就不再是这种情况了。在本食谱中,我们将讨论 C++20 中定义的泛型常量表达式。
如何操作...
当你想使用 constexpr 关键字时:
- 
定义可以在编译时评估的非成员函数: constexpr unsigned int factorial(unsigned int const n) { return n > 1 ? n * factorial(n-1) : 1; }
- 
定义可以在编译时执行以初始化 constexpr对象和在此期间调用的成员函数的构造函数:class point3d { double const x_; double const y_; double const z_; public: constexpr point3d(double const x = 0, double const y = 0, double const z = 0) :x_{x}, y_{y}, z_{z} {} constexpr double get_x() const {return x_;} constexpr double get_y() const {return y_;} constexpr double get_z() const {return z_;} };
- 
定义可以在编译时评估其值的变量: constexpr unsigned int size = factorial(6); char buffer[size] {0}; constexpr point3d p {0, 1, 2}; constexpr auto x = p.get_x();
它是如何工作的...
const 关键字用于在运行时声明变量为常量;这意味着一旦初始化,它们就不能更改。然而,评估常量表达式可能仍然意味着运行时计算。constexpr 关键字用于声明在编译时为常量的变量或可以在编译时执行的功能。constexpr 函数和对象可以替代宏和硬编码的文本,而不会产生任何性能损失。
将函数声明为 constexpr 并不意味着它总是会在编译时评估。它仅允许在编译时评估的表达式中使用该函数。这仅发生在函数的所有输入值都可以在编译时评估的情况下。然而,该函数也可能在运行时被调用。以下代码显示了同一函数的两个调用,首先是编译时,然后是运行时:
constexpr unsigned int size = factorial(6);
// compile time evaluation
int n;
std::cin >> n;
auto result = factorial(n);
// runtime evaluation 
关于 constexpr 可以使用的地方有一些限制。这些限制随着时间的推移而演变,C++14 和 C++20 中有所变化。为了保持列表的合理性,这里只显示了在 C++20 中需要满足的要求:
- 
一个 constexpr变量必须满足以下要求:- 
它的类型是一个字面量类型。 
- 
它在声明时初始化。 
- 
用于初始化变量的表达式是一个常量表达式。 
- 
它必须有常量析构。这意味着它不能是类类型或类类型的数组;否则,类类型必须有一个 constexpr析构函数。
 
- 
- 
一个 constexpr函数必须满足以下要求:- 
它不是一个协程。 
- 
返回类型以及所有参数的类型都是字面量类型。 
- 
至少有一组参数,对于该函数的调用会产生一个常量表达式。 
- 
函数体不得包含 goto语句、标签(除了在switch中的case和default之外),以及非字面量类型或具有静态或线程存储持续时间的局部变量。这个列表点中提到的限制在 C++23 中被移除。
 
- 
- 
一个 constexpr构造函数必须满足以下要求,除了之前对函数的要求之外:- 
该类没有虚拟基类。 
- 
所有初始化非静态数据成员的构造函数,包括基类,也必须是 constexpr。
 
- 
- 
自 C++20 起,一个 constexpr析构函数必须满足以下要求,除了之前对函数的要求之外:- 
该类没有虚拟基类。 
- 
所有销毁非静态数据成员的析构函数,包括基类,也必须是 constexpr。
 
- 
这里提到的所有constexpr构造函数和析构函数的限制在 C++23 中都被移除了。
对于标准不同版本的要求的完整列表,你应该阅读在en.cppreference.com/w/cpp/language/constexpr可用的在线文档。
一个constexpr函数不是隐式const(截至 C++14),所以如果你希望函数不改变对象的逻辑状态,你需要显式使用const说明符。然而,一个constexpr函数是隐式inline的。另一方面,一个声明为constexpr的对象是隐式const的。以下两个声明是等价的:
constexpr const unsigned int size = factorial(6);
constexpr unsigned int size = factorial(6); 
在某些情况下,你可能需要在声明中使用constexpr和const,因为它们会引用声明中的不同部分。在以下示例中,p是一个指向常量整数的constexpr指针:
static constexpr int c = 42;
constexpr int const * p = &c; 
如果且仅如果一个引用变量别名一个具有静态存储持续时间或函数的对象,那么引用变量也可以是constexpr。以下是一个示例:
static constexpr int const & r = c; 
在这个示例中,r是一个constexpr引用,它定义了一个对在前面代码片段中定义的编译时常量变量c的别名。
尽管你可以定义静态的constexpr变量,但在constexpr函数中这样做直到 C++23 之前是不可能的。以下是一个这样的示例:
constexpr char symbol_table(int const n)
{
  static constexpr char symbols[] = "!@#$%^&*"; // error until C++23
return symbols[n % 8];
}
int main()
{
    constexpr char s = symbol_table(42);
    std::cout << s << '\n';
} 
声明symbols变量将生成编译器错误,在 C++23 之前。解决这个问题的一个可能方法是定义变量在constexpr函数之外,如下所示:
static constexpr char symbols[] = "!@#$%^&*"; // OK
constexpr char symbol_table(int const n)
{
  return symbols[n % 8];
} 
在 C++23 中,这个问题得到了解决,它放宽了几个constexpr限制,使得解决方案变得不再必要。
在constexpr函数中还应提及的一个方面与异常有关。自 C++20 以来,允许在constexpr函数中使用 try-catch 块(在此版本之前无法使用)。然而,不允许从常量表达式中抛出异常。尽管你可以在constexpr函数中有一个抛出语句,但其行为如下:
- 
当在运行时执行时,它将表现得好像没有被声明为 constexpr。
- 
当在编译时执行时,如果执行路径遇到抛出语句,则编译器会发出错误。 
这在以下代码片段中得到了体现:
constexpr int factorial2(int const n)
{
   if(n <= 0) throw std::invalid_argument("n must be positive");
   return n > 1 ? n * factorial2(n - 1) : 1;
}
int main()
{
   try
   {
      int a = factorial2(5);
      int b = factorial2(-5);
   }
   catch (std::exception const& ex)
   {
      std::cout << ex.what() << std::endl;
   }         
   constexpr int c = factorial2(5);
   constexpr int d = factorial2(-5); // error
} 
在此代码片段中:
- 
对 factorial2()的前两次调用是在运行时执行的。第一次调用成功并返回60。第二次调用由于参数为负而抛出std::invalid_argument异常。
- 
第三次调用是在编译时评估的,因为变量 c被声明为constexpr,并且所有函数的输入在编译时也是已知的。调用成功,函数评估结果为60。
- 
第四次调用也是在编译时评估的,但由于参数为负,应该执行抛出异常的路径。然而,在常量表达式中不允许这样做,因此编译器会发出错误。 
还有更多...
在 C++20 中,语言中添加了一个新的指定符。这个指定符被称为constinit,用于确保具有静态或线程存储持续时间的变量具有静态初始化。在 C++中,变量的初始化可以是静态的或动态的。静态初始化可以是零初始化(当对象的初始值设置为零时)或常量初始化(当初始值设置为编译时表达式时)。以下代码片段显示了零和常量初始化的示例:
struct foo
{
  int a;
  int b;
};
struct bar
{
  int   value;
  int*  ptr;
  constexpr bar() :value{ 0 }, ptr{ nullptr } {}
};
std::string text {};  // zero-initialized to unspecified value
double arr[10];       // zero-initialized to ten 0.0
int* ptr;             // zero-initialized to nullptr
foo f = foo();        // zero-initialized to a=0, b=0
foo const fc{ 1, 2 }; // const-initialized at runtime
constexpr bar b;      // const-initialized at compile-time 
具有静态存储的变量可以具有静态或动态初始化。在后一种情况下,可能出现难以发现的错误。想象两个在不同的翻译单元中初始化的静态对象。
当一个对象的初始化依赖于另一个对象时,它们的初始化顺序很重要。这是因为依赖于对象的那个对象必须首先初始化。然而,翻译单元初始化的顺序是不确定的,因此无法保证这些对象的初始化顺序。然而,具有静态存储持续时间的变量如果具有静态初始化,则是在编译时初始化的。这意味着当执行翻译单元的动态初始化时,可以安全地使用这些对象。
这正是新指定符constinit的目的。它确保具有静态或线程局部存储的变量具有静态初始化,因此其初始化是在编译时执行的:
int f() { return 42; }
constexpr int g(bool const c) { return c ? 0 : f(); }
constinit int c = g(true);  // OK
constinit int d = g(false); /* error: variable does not have
                                      a constant initializer */ 
它还可以用于非初始化声明中,以指示具有线程存储持续时间的变量已经初始化,如下面的示例所示:
extern thread_local constinit int data;
int get_data() { return data; } 
你不能在同一个声明中使用超过一个的constexpr、constinit和consteval指定符。
参见
- 
创建立即函数,了解 C++20 的 consteval指定符,该指定符用于定义保证在编译时评估的函数
- 
确保程序恒定正确性,以探索恒定正确性的好处以及如何实现它 
创建立即函数
constexpr函数允许在编译时评估函数,前提是它们的所有输入(如果有的话)也必须在编译时可用。然而,这并不保证,constexpr函数也可能在运行时执行,正如我们在之前的配方中看到的,创建编译时常量表达式。在 C++20 中,引入了函数的新类别:立即函数。这些函数保证始终在编译时进行评估;否则,它们会产生错误。立即函数可以作为宏的替代品,并且可能在语言未来的反射和元类开发中很重要。
如何实现…
当你想使用consteval关键字时:
- 
定义必须在编译时评估的非成员函数或函数模板: consteval unsigned int factorial(unsigned int const n) { return n > 1 ? n * factorial(n-1) : 1; }
- 
定义必须在编译时执行的构造函数,以初始化 constexpr对象和仅应在编译时调用的成员函数:class point3d { double x_; double y_; double z_; public: consteval point3d(double const x = 0, double const y = 0, double const z = 0) :x_{x}, y_{y}, z_{z} {} consteval double get_x() const {return x_;} consteval double get_y() const {return y_;} consteval double get_z() const {return z_;} };
它是如何工作的…
consteval指定符是在 C++20 中引入的。它只能应用于函数和函数模板,并将它们定义为立即函数。这意味着任何函数调用都必须在编译时进行评估,因此产生一个编译时常量表达式。如果函数不能在编译时进行评估,则程序是不良形式,编译器会发出错误。
以下规则适用于立即函数:
- 
析构函数、分配和释放函数不能是立即函数。 
- 
如果函数的任何声明包含 consteval指定符,则该函数的所有声明也必须包含它。
- 
consteval指定符不能与constexpr或constinit一起使用。
- 
立即函数是一个内联的 constexpr函数。因此,立即函数和函数模板必须满足适用于constexpr函数的要求。
这里是如何使用上一节中定义的factorial()函数和point3d类的示例:
constexpr unsigned int f = factorial(6);
std::cout << f << '\n';
constexpr point3d p {0, 1, 2};
std::cout << p.get_x() << ' ' << p.get_y() << ' ' << p.get_z() << '\n'; 
然而,以下示例会产生编译器错误,因为即时函数factorial()和point3d的构造函数无法在编译时评估:
unsigned int n;
std::cin >> n;
const unsigned int f2 = factorial(n); // error
double x = 0, y = 1, z = 2;
constexpr point3d p2 {x, y, z};       // error 
如果即时函数不是在常量表达式中,则无法获取其地址:
using pfact = unsigned int(unsigned int);
pfact* pf = factorial;
constexpr unsigned int f3 = pf(42);   // error
consteval auto addr_factorial()
{
  return &factorial;
}
consteval unsigned int invoke_factorial(unsigned int const n)
{
  return addr_factorial()(n);
}
constexpr auto ptr = addr_factorial();
// ERROR: cannot take the pointer of an immediate function
constexpr unsigned int f2 = invoke_factorial(5);
// OK 
因为即时函数在运行时不可见,所以不会为它们生成符号,调试器也无法显示它们。
参见
- 
确保程序常量正确性,探索常量正确性的好处以及如何实现它 
- 
创建编译时常量表达式,了解 constexpr指定符以及如何定义可以在编译时评估的变量和函数
在常量评估上下文中优化代码
在前两个菜谱中,我们学习了关于常量表达式函数,它允许函数在所有输入在编译时都可用的情况下在编译时进行评估,以及C++20 中的即时函数,它们保证始终在编译时评估(否则将产生错误)。constexpr函数的一个重要方面是常量评估上下文;这些是在编译时评估所有表达式和函数的代码路径。常量评估上下文对于更有效地优化代码非常有用。另一方面,从constexpr函数中调用即时函数仅在 C++23 中可行。在本菜谱中,我们将学习如何利用常量评估上下文。
如何实现…
要确定函数上下文是否为常量评估,以便提供编译时实现,请使用以下方法:
- 
在 C++20 中, std::is_constant_evaluated()库函数,在<type_traits>头文件中可用,使用常规的if语句:constexpr double power(double base, int exponent) { if(std::is_constant_evaluated()) { double result = 1.0; if (exponent == 0) { return result; } else if (exponent > 0) { for (int i = 0; i < exponent; i++) { result *= base; } } else { exponent = -exponent; for (int i = 0; i < exponent; i++) { result *= base; } result = 1.0 / result; } return result; } else { return std::pow(base, exponent); } } int main() { constexpr double a = power(10, 5); // compile-time eval std::cout << a << '\n'; double b = power(10, 5); // runtime eval std::cout << b << '\n'; }
- 
在 C++23 中, if consteval语句,它是if(std::is_constant_evaluated())语句的简化(具有额外的优点):constexpr double power(double base, int exponent) { if consteval { double result = 1.0; if (exponent == 0) { return result; } else if (exponent > 0) { for (int i = 0; i < exponent; i++) { result *= base; } } else { exponent = -exponent; for (int i = 0; i < exponent; i++) { result *= base; } result = 1.0 / result; } return result; } else { return std::pow(base, exponent); } }
它是如何工作的…
C++20 标准提供了一个名为std::is_constant_evaluated()的库函数(在<type_traits>头文件中),它可以检测其调用是否发生在constexpr函数中的常量评估上下文中。在这种情况下,它返回true;否则,返回false。
此函数使用常规的if语句,如前一小节中提供的示例,其中我们计算了数字的幂。从这个实现中可以得出的关键要点如下:
- 
在常量评估上下文中,我们使用了一个可以在编译时由编译器执行的算法来优化代码。 
- 
在非常量评估上下文(即运行时)中,我们调用 std::pow()函数来计算幂。
然而,这个函数和常量评估上下文有一些“陷阱”,你必须注意:
- 
函数的参数在编译时已知,并不意味着上下文是常量评估的。在以下代码片段中, constexpr函数power()的第一次调用是在常量评估上下文中,但第二次调用不是,尽管所有参数在编译时已知,并且函数被声明为constexpr:constexpr double a = power(10, 5); // [1] compile-time eval double b = power(10, 5); // [2] runtime eval
- 
如果与 constexprif 语句一起使用,std::is_constant_evaluated()函数始终评估为true(例如 GCC 和 Clang 编译器会为此细微的错误提供警告):constexpr double power(double base, int exponent) { if constexpr (std::is_constant_evaluated()) { } }
以下是一个报告错误的示例:
prog.cc: In function 'constexpr double power(double, int)':
prog.cc:10:45: warning: 'std::is_constant_evaluated' always evaluates to true in 'if constexpr' [-Wtautological-compare]
   10 |     if constexpr (std::is_constant_evaluated())
      |                   ~~~~~~~~~~~~~~~~~~~~~~~~~~^~ 
C++23 标准提供了对 std::is_constant_evaluated() 函数的更好替代方案,即 consteval if 语句。这有几个优点:
- 
不需要包含头文件 
- 
避免对使用正确形式的 if语句产生混淆
- 
允许在常量评估上下文中调用立即函数 
在 C++23 中,幂函数的实现变为以下形式:
constexpr double power(double base, int exponent)
{
   if consteval
   {
      /* ... */
   }
   else
   {
       return std::pow(base, exponent);
   }
} 
consteval if 语句始终需要花括号。否定形式也是可能的,无论是使用 ! 还是 not 关键字。在以下代码片段中,每一对语句都是等价的:
if !consteval {/*statement*/}          // [1] equivalent to [2]
if consteval {} else {/*statement*/}   // [2]
if not consteval {/*statement1*/}      // [3] equivalent to [4]
else {/*statement2*/}              
if consteval {/*statement2*/}          // [4]
else {/*statement1*/} 
consteval if 语句对于允许在 constexpr 函数中从常量评估上下文中立即调用函数也很重要。以下是一个 C++20 的示例:
consteval int plus_one(int const i) 
{ 
   return i + 1; 
}
consteval int plus_two(int i)
{
   return plus_one(i) + 1;
}
constexpr int plus_two_alt(int const i)
{
   if (std::is_constant_evaluated())
   {
      return plus_one(i) + 1;
   } 
   else
   {
      return i + 2;
   }
} 
在这里,函数 plus_one() 是一个立即函数,可以从 plus_two() 函数(也是一个立即函数)中调用。然而,从 plus_two_alt() 函数中调用它是不可能的,因为它不是一个常量表达式,尽管这是一个 constexpr 函数,并且调用 plus_one() 函数的上下文是常量评估的。
这个问题通过 C++23 的 consteval if 语句得到解决。这使得从常量评估上下文中调用立即函数成为可能,如下面的示例所示:
constexpr int plus_two_alt(int const i)
{
   if consteval
   {
      return plus_one(i) + 1;
   } 
   else
   {
      return i + 2;
   }
} 
随着 consteval if 语句的可用性,std::is_constant_evaluated() 函数变得过时。实际上,它可以使用 consteval if 语句如下实现:
constexpr bool is_constant_evaluated() noexcept
{
   if consteval {
      return true;
   } else {
      return false;
   }
} 
当使用 C++23 编译器时,你应该始终优先选择 consteval if 语句,而不是过时的 std::is_constant_evaluated() 函数。
参见
- 
创建编译时常量表达式,了解 constexpr指示符以及如何定义可以在编译时评估的变量和函数
- 
创建立即函数,了解 C++20 的 consteval指示符,它用于定义保证在编译时评估的函数
在常量表达式中使用虚函数调用
作为一种多范式编程语言,C++ 包括对面向对象编程的支持。多态性是面向对象编程的核心原则之一,在 C++ 中有两种形式:编译时多态性,通过函数和运算符重载实现,以及运行时多态性,通过虚函数实现。虚函数允许派生类覆盖基类中的函数实现。然而,在 C++20 中,虚函数被允许在常量表达式中使用,这意味着它们可以在编译时调用。在本食谱中,你将了解这是如何工作的。
准备工作
在本食谱中,我们将使用以下结构来表示文档的维度以及随后的示例中的信封维度:
struct dimension
{
   double width;
   double height;
}; 
如何实现...
你可以通过以下方式将运行时多态性移动到编译时:
- 
将你想要移动到编译时调用的虚函数声明为 constexpr。
- 
将层次结构的基类的析构函数声明为 constexpr。
- 
将重写的虚函数声明为 constexpr。
- 
在常量表达式中调用 constexpr虚函数。
以下是一个示例片段:
struct document_type
{
   constexpr virtual ~document_type() {};
   constexpr virtual dimension size() const = 0;
};
struct document_a5 : document_type
{
   constexpr dimension size() const override { return { 148.5, 210 }; }
};
struct envelope_type
{
   constexpr virtual ~envelope_type() {}
   constexpr virtual dimension size() const = 0;
   constexpr virtual dimension max_enclosure_size() const = 0;
};
struct envelop_commercial_8 : envelope_type
{
   constexpr dimension size() const override { return { 219, 92 }; }
   constexpr dimension max_enclosure_size() const override 
 { return { 213, 86 }; }
};
constexpr bool document_fits_envelope(document_type const& d, 
                                      envelope_type const& e)
{
   return e.max_enclosure_size().width >= d.size().width;
}
int main()
{
   constexpr envelop_commercial_8 e1;
   constexpr document_a5          d1;
   static_assert(document_fits_envelope(d1, e1));
} 
它是如何工作的...
在 C++20 之前,虚函数不能是 constexpr。然而,用于常量表达式的对象的动态类型必须在编译时已知。因此,将虚函数设置为 constexpr 的限制在 C++20 中已被取消。
有 constexpr 虚函数的优势在于可以将一些计算从运行时移动到编译时。尽管这不会影响实践中许多用例,但在上一节中已经给出一个示例。让我们进一步阐述,以便更好地理解。
我们有一系列各种文档纸张大小。例如,包括 A3、A4、A5、legal、letter 和 half-letter。它们有不同的尺寸。例如,A5 是 148.5 毫米 x 210 毫米,而 letter 是 215.9 毫米 x 279.4 毫米。
另一方面,我们有不同类型和大小的信封。例如,我们有一个 92 毫米 x 219 毫米的信封,最大封装尺寸为 86 毫米 x 213 毫米。我们想要编写一个函数,以确定某种类型的折叠纸张是否可以放入信封中。由于尺寸是标准的,它们在编译时是已知的。这意味着我们可以在编译时而不是运行时执行此检查。
为了这个目的,在 如何实现... 部分中,我们已经看到:
- 
一个文档的层次结构,基类称为 document_type。它有两个成员:一个虚析构函数和一个名为size()的虚函数,该函数返回纸张的大小。这两个函数也都是constexpr。
- 
一个信封的层次结构,基类称为 envelope_type。它有三个成员:一个虚析构函数,一个名为size()的虚函数,它返回信封的大小,以及一个名为max_enclosure_size()的虚函数,它返回可以放入信封中的(折叠)纸张的最大尺寸。所有这些都是constexpr。
- 
一个名为 document_fits_envelope()的免费函数通过比较两个宽度的尺寸来确定给定的文档类型是否适合特定的信封类型。这也是一个constexpr函数。
因为所有提到的函数都是constexpr,所以如果被调用的对象也是constexpr,则可以在常量表达式中调用document_fits_envelope()函数,例如在static_assert中。在本书附带的代码文件中,你可以找到一个关于各种纸张和信封尺寸的详细示例。
你应该记住:
- 
你甚至可以将一个重写的虚拟函数声明为 constexpr,即使它在基类中被重写的函数没有被定义为constexpr。
- 
反过来也是可能的,派生类中重写的虚拟函数可以不是 constexpr,尽管在基类中该函数被定义为constexpr。
- 
如果存在多级层次结构,并且一个虚拟函数定义了一些 constexpr重写和一些非constexpr重写,那么用于确定虚拟函数是否为constexpr的最终重写器是针对被调用的对象。
参见
- 第一章,使用 override 和 final 对虚拟方法和类进行操作,学习如何在虚拟方法和类上使用 override 和 final 说明符
正确执行类型转换
通常情况下,数据必须从一种类型转换为另一种类型。一些转换在编译时是必要的(例如double到int);其他转换在运行时是必要的(例如向上转换和向下转换层次结构中的类指针)。该语言支持与 C 转换风格的兼容性,无论是(type)expression还是type(expression)形式。然而,这种类型的转换破坏了 C++的类型安全性。
因此,该语言也提供了几种转换:static_cast、dynamic_cast、const_cast和reinterpret_cast。它们用于更好地指示意图并编写更安全的代码。在本菜谱中,我们将探讨如何使用这些转换。
如何做到这一点...
使用以下转换来执行类型转换:
- 
使用 static_cast来执行非多态类型的类型转换,包括将整数转换为枚举、从浮点数转换为整数值,或从指针类型转换为另一个指针类型,例如从基类到派生类(向下转换)或从派生类到基类(向上转换),但没有任何运行时检查:enum options {one = 1, two, three}; int value = 1; options op = static_cast<options>(value); int x = 42, y = 13; double d = static_cast<double>(x) / y; int n = static_cast<int>(d);
- 
使用 dynamic_cast来执行从基类到派生类或相反的指针或引用的多态类型的类型转换。这些检查在运行时执行,可能需要启用 运行时类型信息(RTTI):struct base { virtual void run() {} virtual ~base() {} }; struct derived : public base { }; derived d; base b; base* pb = dynamic_cast<base*>(&d); // OK derived* pd = dynamic_cast<derived*>(&b); // fail try { base& rb = dynamic_cast<base&>(d); // OK derived& rd = dynamic_cast<derived&>(b); // fail } catch (std::bad_cast const & e) { std::cout << e.what() << '\n'; }
- 
使用 const_cast来执行具有不同const和volatile说明符的类型之间的转换,例如从未声明为const的对象中移除const:void old_api(char* str, unsigned int size) { // do something without changing the string } std::string str{"sample"}; old_api(const_cast<char*>(str.c_str()), static_cast<unsigned int>(str.size()));
- 
使用 reinterpret_cast来执行位重新解释,例如在整数和指针类型之间、从指针类型到整数或从指针类型到任何其他指针类型的转换,而不涉及任何运行时检查:class widget { public: typedef size_t data_type; void set_data(data_type d) { data = d; } data_type get_data() const { return data; } private: data_type data; }; widget w; user_data* ud = new user_data(); // write w.set_data(reinterpret_cast<widget::data_type>(ud)); // read user_data* ud2 = reinterpret_cast<user_data*>(w.get_data());
它是如何工作的...
显式类型转换,有时被称为 C 风格转换 或 静态转换,是 C++ 与 C 语言兼容性的遗产,并允许你执行各种转换,包括以下内容:
- 
在算术类型之间 
- 
在指针类型之间 
- 
在整型和指针类型之间 
- 
在具有不同 const或volatile修饰符的已修饰和未修饰类型之间
这种类型的转换在处理多态类型或在模板中使用时效果不佳。正因为如此,C++ 提供了我们在前面的示例中看到的四种转换。使用这些转换可以带来几个重要的好处:
- 
它们更好地表达了用户的意图,无论是对于编译器还是阅读代码的其他人。 
- 
它们使得在不同类型之间的转换更加安全(除了 reinterpret_cast)。
- 
它们可以在源代码中轻松搜索。 
static_cast 并不是显式类型转换或静态转换的直接等价物,尽管名称可能暗示这一点。这种转换在编译时执行,可以用来执行隐式转换、隐式转换的反向转换以及从类层次结构中的指针到类型的转换。它不能用来触发不相关指针类型之间的转换。因此,在以下示例中,使用 static_cast 从 int* 转换到 double* 会产生编译器错误:
int* pi = new int{ 42 };
double* pd = static_cast<double*>(pi);   // compiler error 
然而,从 base* 转换到 derived*(其中 base 和 derived 是在 如何做... 部分中显示的类)不会产生编译器错误,但在尝试使用新获得的指针时会产生运行时错误:
base b;
derived* pd = static_cast<derived*>(&b); // compilers OK, runtime error
base* pb1 = static_cast<base*>(pd);      // OK 
另一方面,static_cast 不能用来移除 const 和 volatile 修饰符。以下代码片段说明了这一点:
int const c = 42;
int* pc = static_cast<int*>(&c);         // compiler error 
使用 dynamic_cast 可以在继承层次结构中安全地进行向上、向下或侧向的类型转换。这种转换在运行时执行,并要求启用 RTTI。正因为如此,它会产生运行时开销。动态转换只能用于指针和引用。
当使用 dynamic_cast 将表达式转换为指针类型且操作失败时,结果是一个空指针。当它用于将表达式转换为引用类型且操作失败时,会抛出 std::bad_cast 异常。因此,总是将 dynamic_cast 转换到引用类型的操作放在 try...catch 块中。
RTTI 是一种在运行时暴露对象数据类型信息的机制。这仅适用于多态类型(至少有一个虚方法,包括虚析构函数,所有基类都应该有)。RTTI 通常是一个可选的编译器功能(或者可能根本不支持),这意味着使用此功能可能需要使用编译器开关。
虽然动态转换是在运行时执行的,但如果尝试在非多态类型之间进行转换,将会得到编译器错误:
struct struct1 {};
struct struct2 {};
struct1 s1;
struct2* ps2 = dynamic_cast<struct2*>(&s1); // compiler error 
reinterpret_cast 更像是一个编译器指令。它不会转换为任何 CPU 指令;它只是指示编译器将表达式的二进制表示解释为另一种指定的类型。这是一种类型不安全的转换,应该谨慎使用。它可以用于在整数类型和指针、指针类型和函数指针类型之间转换表达式。因为没有任何检查,reinterpret_cast 可以成功用于在无关类型之间转换表达式,例如从 int* 转换到 double*,这将产生未定义的行为:
int* pi = new int{ 42 };
double* pd = reinterpret_cast<double*>(pi); 
reinterpret_cast 的典型用途是在使用操作系统或供应商特定 API 的代码中在类型之间转换表达式。许多 API 以指针或整数类型的形式存储用户数据。因此,如果您需要将用户定义类型的地址传递给此类 API,则需要将无关指针类型或指针类型值转换为整数类型值。在上一节中提供了一个类似的例子,其中 widget 是一个类,它在数据成员中存储用户定义的数据,并提供了访问它的方法:set_data() 和 get_data()。如果您需要在 widget 中存储对象的指针,那么请使用 reinterpret_cast,如下面的例子所示。
const_cast 在某种程度上与 reinterpret_cast 相似,因为它是一个编译器指令,并不转换为 CPU 指令。它用于去除 const 或 volatile 修饰符,这是其他三种转换所不能做的操作。
const_cast 应仅用于在对象未声明为 const 或 volatile 时去除 const 或 volatile 修饰符。否则,将会引入未定义的行为,如下面的例子所示:
int const a = 42;
int const * p = &a;
int* q = const_cast<int*>(p);
*q = 0; // undefined behavior 
在这个例子中,变量 p 指向一个对象(变量 a),该对象被声明为常量。通过移除 const 修饰符,尝试修改指向的对象会引入未定义的行为。
还有更多...
当使用形式为(type)expression的显式类型转换时,请注意它将选择以下列表中满足特定转换要求的第一个选择:
- 
const_cast<type>(expression)
- 
static_cast<type>(expression)
- 
static_cast<type>(expression) + const_cast<type>(expression)
- 
reinterpret_cast<type>(expression)
- 
reinterpret_cast<type>(expression) + const_cast<type>(expression)
此外,与特定的 C++转换不同,静态转换可以用于在未完整类类型之间进行转换。如果type和expression都是指向未完整类型的指针,则未指定是选择static_cast还是reinterpret_cast。
参见
- 确保程序常量正确性,以探索常量正确性的好处以及如何实现它
实现移动语义
移动语义是推动现代 C++性能提升的关键特性。它们允许移动资源,而不是复制,或者更一般地说,复制代价高昂的对象。然而,它要求类实现移动构造函数和移动赋值运算符。在某些情况下,编译器提供了这些,但在实践中,通常您必须显式地编写它们。在本配方中,我们将看到如何实现移动构造函数和移动赋值运算符。
准备工作
预期您具备关于右值引用和特殊类函数(构造函数、赋值运算符和析构函数)的基本知识。我们将演示如何使用以下Buffer类来实现移动构造函数和赋值运算符:
class Buffer
{
  unsigned char* ptr;
  size_t length;
public:
  Buffer(): ptr(nullptr), length(0)
  {}
  explicit Buffer(size_t const size):
    ptr(new unsigned char[size] {0}), length(size)
  {}
  ~Buffer()
  {
    delete[] ptr;
  }
  Buffer(Buffer const& other):
    ptr(new unsigned char[other.length]),
    length(other.length)
  {
    std::copy(other.ptr, other.ptr + other.length, ptr);
  }
  Buffer& operator=(Buffer const& other)
  {
    if (this != &other)
    {
      delete[] ptr;
      ptr = new unsigned char[other.length];
      length = other.length;
      std::copy(other.ptr, other.ptr + other.length, ptr);
    }
    return *this;
  }
  size_t size() const { return length;}
  unsigned char* data() const { return ptr; }
}; 
让我们继续到下一节,在那里您将学习如何修改这个类以利用移动语义。
如何做到这一点...
要为类实现移动构造函数,请执行以下操作:
- 
编写一个接受类类型右值引用的构造函数: Buffer(Buffer&& other) { }
- 
将所有数据成员从右值引用赋值到当前对象。这可以在构造函数体中完成,如下所示,或者在初始化列表中完成,这是首选方式: ptr = other.ptr; length = other.length;
- 
可选地,将数据成员从右值引用赋值为默认值(以确保被移动的对象处于可析构状态): other.ptr = nullptr; other.length = 0;
将Buffer类的移动构造函数整合如下:
Buffer(Buffer&& other)
{
  ptr = other.ptr;
  length = other.length;
  other.ptr = nullptr;
  other.length = 0;
} 
要为类实现移动赋值运算符,请执行以下操作:
- 
编写一个接受类类型右值引用并返回其引用的赋值运算符: Buffer& operator=(Buffer&& other) { }
- 
检查右值引用是否指向与 this相同的对象,如果它们不同,则执行步骤 3 到步骤 5:if (this != &other) { }
- 
处理当前对象的所有资源(如内存、句柄等): delete[] ptr;
- 
将所有数据成员从右值引用赋值到当前对象: ptr = other.ptr; length = other.length;
- 
将右值引用的数据成员赋值为默认值: other.ptr = nullptr; other.length = 0;
- 
不论是否执行了步骤 3 到步骤 5,都返回当前对象的引用: return *this;
将所有内容放在一起,Buffer类的移动赋值运算符看起来像这样:
Buffer& operator=(Buffer&& other)
{
  if (this != &other)
  {
    delete[] ptr;
    ptr = other.ptr;
    length = other.length;
    other.ptr = nullptr;
    other.length = 0;
  }
  return *this;
} 
它是如何工作的...
移动构造函数和移动赋值运算符由编译器默认提供,除非已经存在用户定义的复制构造函数、移动构造函数、复制赋值运算符、移动赋值运算符或析构函数。当由编译器提供时,它们以成员方式执行移动。移动构造函数递归地调用类数据成员的移动构造函数;同样,移动赋值运算符递归地调用类数据成员的移动赋值运算符。
在这种情况下,移动代表了对太大而无法复制(如字符串或容器)或不应被复制(如unique_ptr智能指针)的对象的性能优势。并非所有类都应该实现复制和移动语义。一些类应该只可移动,而另一些类则应该可复制和可移动。另一方面,一个类可复制但不移动并没有太多意义,尽管技术上可以实现。
并非所有类型都从移动语义中受益。对于内置类型(如bool、int或double)、数组或 PODs,移动实际上是一个复制操作。另一方面,移动语义在 rvalue 的上下文中提供了性能优势,即临时对象。rvalue 是一个没有名字的对象;它在表达式的评估期间临时存在,并在下一个分号处被销毁:
T a;
T b = a;
T c = a + b; 
在前面的例子中,a、b和c是 lvalue;它们是有名字的对象,可以在其生命周期的任何时刻通过这个名字来引用该对象。另一方面,当你评估表达式a+b时,编译器创建了一个临时对象(在这种情况下,被分配给c),然后在遇到分号时被销毁。这些临时对象被称为 rvalue,因为它们通常出现在赋值表达式的右侧。在 C++11 中,我们可以通过&&这样的 rvalue 引用来引用这些对象。
移动语义在 rvalue 的上下文中非常重要。这是因为它们允许你在临时对象被销毁后获取其资源所有权,而客户端在移动操作完成后无法再使用它。另一方面,lvalue 不能被移动;它们只能被复制。这是因为它们可以在移动操作之后被访问,客户端期望对象处于相同的状态。例如,在前面的例子中,表达式b = a将a赋值给b。
在此操作完成后,lvalue 类型的对象a仍然可以被客户端使用,并且应该处于与之前相同的状态。另一方面,a+b的结果是临时的,其数据可以安全地移动到c。
移动构造函数与复制构造函数不同,因为它接受对类类型T的右值引用T(T&&),而复制构造函数接受左值引用T(T const&)。同样,移动赋值也接受右值引用,即T& operator=(T&&),而复制赋值运算符接受左值引用,即T& operator=(T const &)。即使两者都返回对T&类的引用,这也是正确的。编译器根据参数的类型(右值或左值)选择合适的构造函数或赋值运算符。
当存在移动构造函数/赋值运算符时,右值会被自动移动。左值也可以被移动,但这需要显式地将其转换为右值引用。这可以通过使用std::move()函数来完成,它基本上执行了一个static_cast<T&&>操作:
std::vector<Buffer> c;
c.push_back(Buffer(100));  // move
Buffer b(200);
c.push_back(b);            // copy
c.push_back(std::move(b)); // move 
对象移动后,它必须保持在一个有效状态。然而,没有关于这个状态应该是什么的要求。为了保持一致性,你应该将所有成员字段设置为它们的默认值(数值类型为0,指针为nullptr,布尔值为false等)。
以下示例展示了Buffer对象可以以不同的方式被构造和赋值:
Buffer b1;                // default constructor
Buffer b2(100);           // explicit constructor
Buffer b3(b2);            // copy constructor
b1 = b3;                  // assignment operator
Buffer b4(std::move(b1)); // move constructor
b3 = std::move(b4);       // move assignment 
每一行注释中提到的构造函数或赋值运算符都涉及到了对象b1、b2、b3和b4的创建或赋值。
还有更多...
如Buffer示例所示,实现移动构造函数和移动赋值运算符都涉及到编写类似的代码(移动构造函数的整个代码也存在于移动赋值运算符中)。实际上,可以通过在移动构造函数中调用移动赋值运算符(或者,作为替代方案,将赋值代码分解成一个私有函数,该函数由移动构造函数和移动赋值运算符共同调用)来避免这种情况:
Buffer(Buffer&& other) : ptr(nullptr), length(0)
{
  *this = std::move(other);
} 
在这个例子中有两个需要注意的点:
- 
在构造函数的初始化列表中进行成员初始化是必要的,因为这些成员可能会在以后的移动赋值运算符中使用(例如,本例中的 ptr成员)。
- 
将 other显式地转换为右值引用。如果没有这个显式转换,将会调用复制赋值运算符。这是因为即使将右值作为参数传递给这个构造函数,当它被赋予一个名称时,它绑定到一个左值上。因此,other实际上是一个左值,必须转换为右值引用才能调用移动赋值运算符。
参见
- 第三章,默认和删除函数,了解在特殊成员函数上使用default指定符以及如何使用delete指定符定义已删除的函数
使用unique_ptr来唯一拥有内存资源
手动处理堆内存分配和释放(使用 new 和 delete)是 C++ 中最具争议的特性之一。所有分配都必须与正确的范围内的相应删除操作正确配对。如果内存分配在函数中完成,并且需要在函数返回之前释放,例如,那么这必须在所有返回路径上发生,包括函数由于异常而返回的不正常情况。C++11 特性,如右值和移动语义,使得更好的智能指针(因为一些,如 auto_ptr,在 C++11 之前就已存在)的开发成为可能;这些指针可以管理内存资源,并在智能指针被销毁时自动释放。在这个菜谱中,我们将查看 std::unique_ptr,这是一个拥有并管理在堆上分配的另一个对象或对象数组的智能指针,并在智能指针超出作用域时执行销毁操作。
准备工作
在以下示例中,我们将使用以下类:
class foo
{
  int a;
  double b;
  std::string c;
public:
  foo(int const a = 0, double const b = 0, 
 std::string const & c = "") :a(a), b(b), c(c)
  {}
  void print() const
 {
    std::cout << '(' << a << ',' << b << ',' << std::quoted(c) << ')'
              << '\n';
  }
}; 
对于这个菜谱,你需要熟悉移动语义和 std::move() 转换函数。unique_ptr 类在 <memory> 头文件中的 std 命名空间中可用。
如何做到这一点...
以下是在使用 std::unique_ptr 时需要了解的一些典型操作列表:
- 
使用可用的重载构造函数创建一个通过指针管理对象或对象数组的 std::unique_ptr。默认构造函数创建一个不管理任何对象的指针:std::unique_ptr<int> pnull; std::unique_ptr<int> pi(new int(42)); std::unique_ptr<int[]> pa(new int[3]{ 1,2,3 }); std::unique_ptr<foo> pf(new foo(42, 42.0, "42"));
- 
或者,使用 C++14 中可用的 std::make_unique()函数模板创建std::unique_ptr对象:std::unique_ptr<int> pi = std::make_unique<int>(42); std::unique_ptr<int[]> pa = std::make_unique<int[]>(3); std::unique_ptr<foo> pf = std::make_unique<foo>(42, 42.0, "42");
- 
使用 C++20 中可用的 std::make_unique_for_overwrite()函数模板,创建指向默认初始化的对象或对象数组的std::unique_ptr。这些对象应稍后用确定的值覆盖:std::unique_ptr<int> pi = std::make_unique_for_overwrite<int>(); std::unique_ptr<foo[]> pa = std::make_unique_for_overwrite<foo[]>();
- 
如果默认的 delete操作符不适用于销毁托管对象或数组,请使用重载构造函数,该构造函数接受自定义删除器:struct foo_deleter { void operator()(foo* pf) const { std::cout << "deleting foo..." << '\n'; delete pf; } }; std::unique_ptr<foo, foo_deleter> pf( new foo(42, 42.0, "42"), foo_deleter());
- 
使用 std::move()将对象的所有权从一个std::unique_ptr转移到另一个:auto pi = std::make_unique<int>(42); auto qi = std::move(pi); assert(pi.get() == nullptr); assert(qi.get() != nullptr);
- 
要访问托管对象的原始指针,如果你想保留对象的所有权,请使用 get();如果你想释放所有权,请使用release():void func(int* ptr) { if (ptr != nullptr) std::cout << *ptr << '\n'; else std::cout << "null" << '\n'; } std::unique_ptr<int> pi; func(pi.get()); // prints null pi = std::make_unique<int>(42); func(pi.get()); // prints 42
- 
使用 operator*和operator->解引用托管对象的指针:auto pi = std::make_unique<int>(42); *pi = 21; auto pf1 = std::make_unique<foo>(); pf1->print(); // prints (0,0,"") auto pf2 = std::make_unique<foo>(42, 42.0, "42"); pf2->print(); // prints (42,42,"42")
- 
如果 std::unique_ptr管理一个对象数组,可以使用operator[]访问数组的单个元素:std::unique_ptr<int[]> pa = std::make_unique<int[]>(3); for (int i = 0; i < 3; ++i) pa[i] = i + 1;
- 
要检查 std::unique_ptr是否可以管理一个对象,请使用显式操作符bool或检查get() != nullptr(这是操作符bool所做的):std::unique_ptr<int> pi(new int(42)); if (pi) std::cout << "not null" << '\n';
- 
std::unique_ptr对象可以存储在容器中。由make_unique()返回的对象可以直接存储。如果想要将管理对象的所有权放弃给容器中的std::unique_ptr对象,可以将一个左值对象通过std::move()静态转换为右值对象:std::vector<std::unique_ptr<foo>> data; for (int i = 0; i < 5; i++) data.push_back( std::make_unique<foo>(i, i, std::to_string(i))); auto pf = std::make_unique<foo>(42, 42.0, "42"); data.push_back(std::move(pf));
它是如何工作的...
std::unique_ptr是一个智能指针,它通过原始指针管理在堆上分配的对象或数组。当智能指针超出作用域、被赋予新的指针使用operator=或使用release()方法放弃所有权时,它会执行适当的销毁操作。默认情况下,使用delete运算符来销毁管理对象。然而,用户在构造智能指针时可以提供自定义的销毁器。这个销毁器必须是一个函数对象,要么是一个函数对象的左值引用,要么是一个函数,并且这个可调用对象必须接受一个类型为unique_ptr<T, Deleter>::pointer的单个参数。
C++14 添加了std::make_unique()实用函数模板来创建std::unique_ptr。它在某些特定情况下避免了内存泄漏,但也有一些限制:
- 
它只能用来分配数组;你不能用它来初始化它们,这是 std::unique_ptr构造函数所能做到的。以下两段示例代码是等价的: // allocate and initialize an array std::unique_ptr<int[]> pa(new int[3]{ 1,2,3 }); // allocate and then initialize an array std::unique_ptr<int[]> pa = std::make_unique<int[]>(3); for (int i = 0; i < 3; ++i) pa[i] = i + 1;
- 
它不能用来创建具有用户定义销毁器的 std::unique_ptr对象。
正如我们刚才提到的,make_unique()的巨大优势是它帮助我们避免在某些抛出异常的上下文中发生内存泄漏。如果分配失败或它创建的对象的构造函数抛出任何异常,make_unique()本身可能会抛出std::bad_alloc。让我们考虑以下示例:
void some_function(std::unique_ptr<foo> p)
{ /* do something */ }
some_function(std::unique_ptr<foo>(new foo()));
some_function(std::make_unique<foo>()); 
无论foo的分配和构造过程中发生什么,都不会有内存泄漏,无论你使用make_unique()还是std::unique_ptr的构造函数。然而,代码的略微不同版本会导致这种情况发生变化:
void some_other_function(std::unique_ptr<foo> p, int const v)
{
}
int function_that_throws()
{
  throw std::runtime_error("not implemented");
}
// possible memory leak
some_other_function(std::unique_ptr<foo>(new foo),
                    function_that_throws());
// no possible memory leak
some_other_function(std::make_unique<foo>(),
                    function_that_throws()); 
在这个例子中,some_other_function() 有一个额外的参数:一个整数值。传递给这个函数的整数参数是另一个函数的返回值。如果这个函数调用抛出异常,使用 std::unique_ptr 构造函数创建智能指针可能会导致内存泄漏。这是因为,在调用 some_other_function() 时,编译器可能会首先调用 foo,然后是 function_that_throws(),最后是 std::unique_ptr 的构造函数。如果 function_that_throws() 抛出错误,那么分配的 foo 将会泄漏。如果调用顺序是 function_that_throws() 然后是 new foo() 和 unique_ptr 的构造函数,则不会发生内存泄漏;这是因为栈在 foo 对象分配之前就开始回溯。然而,通过使用 make_unique() 函数,可以避免这种情况。这是因为,唯一调用的函数是 make_unique() 和 function_that_throws()。如果首先调用 function_that_throws(),则 foo 对象根本不会分配。如果首先调用 make_unique(),则 foo 对象将被构造,并且其所有权将传递给 std::unique_ptr。如果稍后调用 function_that_throws() 抛出异常,那么当栈回溯时,std::unique_ptr 将被销毁,并且 foo 对象将从智能指针的析构函数中销毁。C++17 通过要求在开始下一个参数之前必须完全评估任何参数来解决这个问题。
在 C++20 中,添加了一个名为 std::make_unique_for_overwrite() 的新函数。这与 make_unique() 类似,但它的默认值初始化对象或对象数组。此函数可用于泛型代码,其中不知道类型模板参数是否是平凡的复制的。此函数表达了创建指向可能未初始化的对象的指针的意图,以便稍后可以覆盖它。
常量 std::unique_ptr 对象不能将管理对象或数组的所有权转移到另一个 std::unique_ptr 对象。另一方面,可以通过 get() 或 release() 获取管理对象的原始指针。第一种方法仅返回底层指针,但后者也释放了管理对象的所有权,因此得名。在调用 release() 之后,std::unique_ptr 对象将变为空,调用 get() 将返回 nullptr。
管理 Derived 类对象的 std::unique_ptr 可以隐式转换为管理 Base 类对象的 std::unique_ptr,如果 Derived 从 Base 继承。这种隐式转换只有在 Base 有虚拟析构函数(所有基类都应该有)的情况下才是安全的;否则,将执行未定义的行为:
struct Base
{
  virtual ~Base()
  {
    std::cout << "~Base()" << '\n';
  }
};
struct Derived : public Base
{
  virtual ~Derived()
  {
    std::cout << "~Derived()" << '\n';
  }
};
std::unique_ptr<Derived> pd = std::make_unique<Derived>();
std::unique_ptr<Base> pb = std::move(pd); 
运行此代码片段的输出如下:
~Derived()
~Base() 
std::unique_ptr 可以存储在容器中,例如 std::vector。因为任何时刻只有一个 std::unique_ptr 对象可以拥有被管理的对象,所以智能指针不能被复制到容器中;它必须被移动。这可以通过 std::move() 实现,它执行了一个 static_cast 到右值引用类型。这允许将管理对象的所有权转移到容器中创建的 std::unique_ptr 对象。
参见
- 使用 shared_ptr共享内存资源,了解std::shared_ptr类,它表示一个智能指针,它共享堆上分配的对象或对象数组的所有权
使用 shared_ptr 共享内存资源
当对象或数组需要共享时,无法使用 std::unique_ptr 来管理动态分配的对象或数组。这是因为 std::unique_ptr 保留其唯一所有权。C++ 标准提供了一个名为 std::shared_ptr 的另一个智能指针;它在许多方面与 std::unique_ptr 类似,但不同之处在于它可以与其他 std::shared_ptr 对象共享对象或数组的所有权。在本配方中,我们将了解 std::shared_ptr 的工作原理以及它与 std::unique_ptr 的区别。我们还将查看 std::weak_ptr,它是一个非资源拥有智能指针,它持有由 std::shared_ptr 管理的对象的引用。
准备工作
确保你已经阅读了之前的配方,使用 unique_ptr 唯一拥有内存资源,以熟悉 unique_ptr 和 make_unique() 的工作方式。我们将使用本配方中定义的 foo、foo_deleter、Base 和 Derived 类,并对其进行多次引用。
shared_ptr 和 weak_ptr 类以及 make_shared() 函数模板都包含在 <memory> 头文件中的 std 命名空间中。
为了简单和可读性,我们不会在本配方中使用完全限定的名称 std::unique_ptr、std::shared_ptr 和 std::weak_ptr,而是使用 unique_ptr、shared_ptr 和 weak_ptr。
如何做...
以下是在使用 shared_ptr 和 weak_ptr 时需要了解的典型操作的列表:
- 
使用可用的重载构造函数之一来创建一个通过指针管理对象的 shared_ptr。默认构造函数创建一个空的shared_ptr,它不管理任何对象:std::shared_ptr<int> pnull1; std::shared_ptr<int> pnull2(nullptr); std::shared_ptr<int> pi1(new int(42)); std::shared_ptr<int> pi2 = pi1; std::shared_ptr<foo> pf1(new foo()); std::shared_ptr<foo> pf2(new foo(42, 42.0, "42"));
- 
或者,使用自 C++11 起可用的 std::make_shared()函数模板来创建shared_ptr对象:std::shared_ptr<int> pi = std::make_shared<int>(42); std::shared_ptr<foo> pf1 = std::make_shared<foo>(); std::shared_ptr<foo> pf2 = std::make_shared<foo>(42, 42.0, "42");
- 
使用 C++20 中可用的 std::make_shared_for_overwrite()函数模板来创建指向默认初始化的对象或对象数组的shared_ptr。这些对象应稍后用确定的值覆盖:std::shared_ptr<int> pi = std::make_shared_for_overwrite<int>(); std::shared_ptr<foo[]> pa = std::make_shared_for_overwrite<foo[]>(3);
- 
如果默认的删除操作不适用于销毁被管理的对象,请使用重载的构造函数,它接受一个自定义删除器: std::shared_ptr<foo> pf1(new foo(42, 42.0, "42"), foo_deleter()); std::shared_ptr<foo> pf2( new foo(42, 42.0, "42"), [](foo* p) { std::cout << "deleting foo from lambda..." << '\n'; delete p;});
- 
在管理对象的数组时,始终指定一个删除器。删除器可以是 std::default_delete的数组部分特化,或者任何接受模板类型指针的函数:std::shared_ptr<int> pa1( new int[3]{ 1, 2, 3 }, std::default_delete<int[]>()); std::shared_ptr<int> pa2( new int[3]{ 1, 2, 3 }, [](auto p) {delete[] p; });
- 
要访问管理对象的原始指针,请使用 get()函数:void func(int* ptr) { if (ptr != nullptr) std::cout << *ptr << '\n'; else std::cout << "null" << '\n'; } std::shared_ptr<int> pi; func(pi.get()); pi = std::make_shared<int>(42); func(pi.get());
- 
使用 operator*和operator->解引用管理对象的指针:std::shared_ptr<int> pi = std::make_shared<int>(42); *pi = 21; std::shared_ptr<foo> pf = std::make_shared<foo>(42, 42.0, "42"); pf->print();
- 
如果 shared_ptr管理对象的数组,可以使用operator[]访问数组的各个元素。这仅在 C++17 中可用:std::shared_ptr<int[]> pa1( new int[3]{ 1, 2, 3 }, std::default_delete<int[]>()); for (int i = 0; i < 3; ++i) pa1[i] *= 2;
- 
要检查 shared_ptr是否可以管理一个对象,请使用显式操作符bool或检查get() != nullptr(这是操作符bool所做的):std::shared_ptr<int> pnull; if (pnull) std::cout << "not null" << '\n'; std::shared_ptr<int> pi(new int(42)); if (pi) std::cout << "not null" << '\n';
- 
shared_ptr对象可以存储在容器中,例如std::vector:std::vector<std::shared_ptr<foo>> data; for (int i = 0; i < 5; i++) data.push_back( std::make_shared<foo>(i, i, std::to_string(i))); auto pf = std::make_shared<foo>(42, 42.0, "42"); data.push_back(std::move(pf)); assert(!pf);
- 
使用 weak_ptr维护对共享对象的非拥有引用,稍后可以通过从weak_ptr对象构造的shared_ptr访问:auto sp1 = std::make_shared<int>(42); assert(sp1.use_count() == 1); std::weak_ptr<int> wpi = sp1; assert(sp1.use_count() == 1); auto sp2 = wpi.lock(); // sp2 type is std::shared_ptr<int> assert(sp1.use_count() == 2); assert(sp2.use_count() == 2); sp1.reset(); assert(sp1.use_count() == 0); assert(sp2.use_count() == 1);
- 
当你需要为已由另一个 shared_ptr对象管理的实例创建shared_ptr对象时,请将std::enable_shared_from_this类模板用作类型的基类:struct Apprentice; struct Master : std::enable_shared_from_this<Master> { ~Master() { std::cout << "~Master" << '\n'; } void take_apprentice(std::shared_ptr<Apprentice> a); private: std::shared_ptr<Apprentice> apprentice; }; struct Apprentice { ~Apprentice() { std::cout << "~Apprentice" << '\n'; } void take_master(std::weak_ptr<Master> m); private: std::weak_ptr<Master> master; }; void Master::take_apprentice(std::shared_ptr<Apprentice> a) { apprentice = a; apprentice->take_master(shared_from_this()); } void Apprentice::take_master(std::weak_ptr<Master> m) { master = m; } auto m = std::make_shared<Master>(); auto a = std::make_shared<Apprentice>(); m->take_apprentice(a);
它是如何工作的...
在许多方面,shared_ptr 与 unique_ptr 非常相似;然而,它服务于不同的目的:共享对象或数组的所有权。两个或多个 shared_ptr 智能指针可以管理同一个动态分配的对象或数组,当最后一个智能指针超出作用域、使用 operator= 分配新指针或使用 reset() 方法重置时,该对象或数组将被自动销毁。默认情况下,对象使用 operator delete 销毁;然而,用户可以向构造函数提供一个自定义删除器,这是使用 std::make_shared() 所不可能做到的。如果 shared_ptr 用于管理对象的数组,必须提供一个自定义删除器。在这种情况下,你可以使用 std::default_delete<T[]>,它是 std::default_delete 类模板的部分特化,使用 operator delete[] 来删除动态分配的数组。
与自 C++14 才可用的 std::make_unique() 不同,std::make_shared()(自 C++11 起可用)应用于创建智能指针,除非你需要提供一个自定义删除器。主要原因与 make_unique() 相同:避免在某些上下文中抛出异常时潜在的内存泄漏。有关更多信息,请阅读前一道菜谱中提供的 std::make_unique() 的解释。
在 C++20 中,添加了一个新的函数,称为 std::make_shared_for_overwrite()。这个函数与 make_shared() 类似,但默认初始化对象或对象数组。这个函数可以在未知类型模板参数是否是平凡可复制的泛型代码中使用。这个函数表达了创建一个可能未初始化的对象的指针的意图,以便稍后可以覆盖它。
此外,与 unique_ptr 的情况类似,一个管理 Derived 类对象的 shared_ptr 可以隐式转换为管理 Base 类对象的 shared_ptr。这只有在 Derived 类从 Base 类派生时才可能。这种隐式转换只有在 Base 类有一个虚析构函数(正如所有基类在应该通过基类的指针或引用多态删除对象时应该有的那样)时才是安全的;否则,将执行未定义的行为。在 C++17 中,添加了几个新的非成员函数:std::static_pointer_cast()、std::dynamic_pointer_cast()、std::const_pointer_cast() 和 std::reinterpret_pointer_cast()。这些函数将 static_cast、dynamic_cast、const_cast 和 reinterpret_cast 应用于存储的指针,并返回一个新的指向指定类型的 shared_ptr。
在以下示例中,Base 和 Derived 是我们在上一个示例中使用的相同类:
std::shared_ptr<Derived> pd = std::make_shared<Derived>();
std::shared_ptr<Base> pb = pd;
std::static_pointer_cast<Derived>(pb)->print(); 
有时你需要一个智能指针来管理共享对象,但又不希望它对共享所有权做出贡献。假设你模拟一个树结构,其中节点对其子节点有引用,并且它们由 shared_ptr 对象表示。另一方面,假设一个节点需要保持对其父节点的引用。如果这个引用也是 shared_ptr,那么它将创建循环引用,并且没有任何对象会被自动销毁。
weak_ptr 是一种智能指针,用于打破这种循环依赖。它持有对由 shared_ptr 管理的对象或数组的非拥有引用。可以从 shared_ptr 对象创建 weak_ptr。为了访问管理的对象,你需要获取一个临时的 shared_ptr 对象。为此,我们需要使用 lock() 方法。此方法原子性地检查所引用的对象是否仍然存在,如果对象不再存在,则返回一个空的 shared_ptr,如果对象仍然存在,则返回一个拥有该对象的 shared_ptr。由于 weak_ptr 是一个非拥有智能指针,因此所引用的对象可以在 weak_ptr 超出作用域之前或当所有拥有 shared_ptr 对象被销毁、重置或分配给其他指针时被销毁。可以使用 expired() 方法来检查所引用的对象是否已被销毁或仍然可用。
在如何做...部分,前面的示例模拟了一个师徒关系。有一个Master类和一个Apprentice类。Master类有一个对Apprentice类的引用和一个名为take_apprentice()的方法来设置Apprentice对象。Apprentice类有一个对Master类的引用和一个名为take_master()的方法来设置Master对象。为了避免循环依赖,这些引用中的一个必须由一个weak_ptr表示。在提出的示例中,Master类有一个shared_ptr来拥有Apprentice对象,而Apprentice类有一个weak_ptr来跟踪对Master对象的引用。然而,这个示例稍微复杂一些,因为在这里,Apprentice::take_master()方法是从Master::take_apprentice()中调用的,并且需要一个weak_ptr<Master>。为了在Master类内部调用它,我们必须能够在Master类中使用this指针创建一个shared_ptr<Master>。在安全的方式中做到这一点的唯一方法是使用std::enable_shared_from_this。
std::enable_shared_from_this是一个类模板,必须用作所有需要为当前对象(this指针)创建shared_ptr的类的基类,当此对象已被另一个shared_ptr管理时。它的类型模板参数必须是派生自它的类,就像在好奇的递归模板模式中一样。它有两个方法:shared_from_this(),它返回一个shared_ptr,共享this对象的拥有权,和weak_from_this(),它返回一个weak_ptr,共享对this对象的非拥有引用。后者方法仅在 C++17 中可用。这些方法只能在由现有shared_ptr管理的对象上调用;否则,它们会抛出std::bad_weak_ptr异常,自 C++17 起。在 C++17 之前,行为是未定义的。
不使用std::enable_shared_from_this并直接创建shared_ptr<T>(this)会导致有多个shared_ptr对象独立管理同一个对象,彼此之间不知道。当这种情况发生时,对象最终会被不同的shared_ptr对象多次销毁。
参见
- 使用unique_ptr来唯一拥有内存资源,学习std::unique_ptr类,它代表一个智能指针,它拥有并管理在堆上分配的另一个对象或对象数组
与操作符<=>的一致比较
C++语言定义了六个关系运算符来执行比较:==、!=、<、<=、>和>=。尽管!=可以用==来实现,而<=、>=和>可以用<来实现,但如果你想让用户定义的类型支持相等比较,你仍然必须实现==和!=;如果你想让它支持排序,你必须实现<、<=、>和>=。
这意味着如果你的类型——让我们称它为 T——的对象要可比较,那么需要 6 个函数;如果它们要与另一个类型 U 可比较,则需要 12 个函数;如果还要使 U 类型的值与你的 T 类型可比较,则需要 18 个函数,依此类推。新的 C++20 标准通过引入一个新的比较操作符,称为三向比较,将这个数字减少到 1 或 2,或者这些数字的倍数(取决于与其他类型的比较),这个新的比较操作符用符号 <=> 表示,因此它通常被称为 飞船操作符。这个新操作符帮助我们编写更少的代码,更好地描述关系的强度,并避免手动实现比较操作符时可能出现的性能问题。
准备工作
在定义或实现三向比较操作符时,必须包含头文件 <compare>。这个新的 C++20 头文件是标准通用工具库的一部分,它提供了用于实现比较的类、函数和概念。
如何做到这一点…
要在 C++20 中最优地实现比较,请执行以下操作:
- 
如果你只想让你的类型支持相等比较(包括 ==和!=),则只需实现==操作符并返回一个bool。你可以默认实现,以便编译器执行逐成员比较:class foo { int value; public: foo(int const v):value(v){} bool operator==(foo const&) const = default; };
- 
如果你希望你的类型同时支持相等和排序,并且默认的成员比较就足够了,那么只需定义 <=>操作符,返回auto,并默认其实现:class foo { int value; public: foo(int const v) :value(v) {} auto operator<=>(foo const&) const = default; };
- 
如果你希望你的类型同时支持相等和排序,并且需要执行自定义比较,那么实现 ==操作符(用于相等)和<=>操作符(用于排序):class foo { int value; public: foo(int const v) :value(v) {} bool operator==(foo const& other) const { return value == other.value; } auto operator<=>(foo const& other) const { return value <=> other.value; } };
在实现三向比较操作符时,请遵循以下指南:
- 
仅实现三向比较操作符,但在比较值时始终使用双向比较操作符 <、<=、>和>=。
- 
即使你想要比较操作符的第一个操作数是除你的类之外的其他类型,也要将三向比较操作符实现为成员函数。 
- 
仅当你在两个参数上想要隐式转换时,才将三向比较操作符实现为非成员函数(这意味着比较两个对象,它们都不是你的类)。 
它是如何工作的…
新的三向比较操作符类似于 memcmp()/strcmp() C 函数和 std::string::compare() 方法。这些函数接受两个参数,并返回一个整数值,如果第一个小于第二个,则返回小于零的值;如果它们相等,则返回零;如果第一个参数大于第二个参数,则返回大于零的值。三向比较操作符不返回整数,而是返回比较类别类型的值。
这可以是以下之一:
- 
std::strong_ordering表示支持所有六个关系运算符的三向比较的结果,不允许不可比较的值(这意味着a < b、a == b和a > b至少有一个必须为真),并且意味着可替换性。这是一个属性,如果a == b并且f是一个只读取比较显著状态(通过参数的公共常量成员访问)的函数,那么f(a) == f(b)。
- 
std::weak_ordering支持所有六个关系运算符,不支持不可比较的值(这意味着a < b、a == b和a > b都可能不为真),但也不意味着可替换性。一个典型的定义弱排序的类型是无大小写敏感的字符串类型。
- 
std::partial_ordering支持所有六个关系运算符,但不意味着可替换性,并且其值可能不可比较(例如,浮点数NaN不能与任何其他值进行比较)。
std::strong_ordering 类型是所有这些类别类型中最强的。它不能隐式转换为任何其他类别,但它可以隐式转换为 std::weak_ordering 和 std::partial_ordering。std::weak_ordering 也可以隐式转换为 std::partial_ordering。我们已在以下表格中总结了所有这些属性:
| 类别 | 运算符 | 可替换性 | 可比较值 | 隐式转换 | 
|---|---|---|---|---|
| std::strong_ordering | ==,!=,<,<=,>,>= | 是 | 是 |  | 
| std::weak_ordering | ==,!=,<,<=,>,>= | 否 | 是 |  | 
| std::partial_ordering | ==,!=,<,<=,>,>= | 否 | 否 | 
表 9.2:类别类型属性
这些比较类别具有隐式可比较于文字零(但不能与零的整数变量)的值。它们的值列在以下表格中:
| 类别 | 数值 | 非数值 | 
|---|---|---|
| -1 | 0 | 1 | 
| strong_ordering | 小于 | 等价 | 
| weak_ordering | 小于 | 等价 | 
| partial_ordering | 小于 | 等价 | 
表 9.3:隐式可比较于文字零的比较类别值
为了更好地理解其工作原理,让我们看看以下示例:
class cost_unit_t
{
  // data members
public:
  std::strong_ordering operator<=>(cost_unit_t const & other) const noexcept = default;
};
class project_t : public cost_unit_t
{
  int         id;
  int         type;
  std::string name;
public:
  bool operator==(project_t const& other) const noexcept
  {
    return (cost_unit_t&)(*this) == (cost_unit_t&)other &&
           name == other.name &&
           type == other.type &&
           id == other.id;
  }
  std::strong_ordering operator<=>(project_t const & other) const noexcept
  {
    // compare the base class members
if (auto cmp = (cost_unit_t&)(*this) <=> (cost_unit_t&)other;
        cmp != 0)
      return cmp;
    // compare this class members in custom order
if (auto cmp = name.compare(other.name); cmp != 0)
      return cmp < 0 ? std::strong_ordering::less :
                       std::strong_ordering::greater;
    if (auto cmp = type <=> other.type; cmp != 0)
      return cmp;
    return id <=> other.id;
  }
}; 
在这里,cost_unit_t是一个基类,它包含一些(未指定的)数据成员并定义了<=>运算符,尽管它是由编译器默认实现的。这意味着编译器还将提供==和!=运算符,而不仅仅是<、<=、>和>=。这个类通过project_t派生,它包含几个数据字段:项目的标识符、类型和名称。然而,对于这种类型,我们不能默认实现运算符的实现,因为我们不想逐字段比较成员,而是按照自定义的顺序:首先是名称,然后是类型,最后是标识符。在这种情况下,我们实现了==运算符,它返回bool并测试成员字段是否相等,以及<=>运算符,它返回std::strong_ordering并使用其自身的<=>运算符来比较两个参数的值。
employee_t that models employees in a company. An employee can have a manager, and an employee who is a manager has people that they manage. Conceptually, such a type could look as follows:
struct employee_t
{
  bool is_managed_by(employee_t const&) const { /* ... */ }
  bool is_manager_of(employee_t const&) const { /* ... */ }
  bool is_same(employee_t const&) const { /* ... */ }
  bool operator==(employee_t const & other) const
  {
    return is_same(other);
  }
  std::partial_ordering operator<=>(employee_t const& other) const noexcept
  {
    if (is_same(other))
      return std::partial_ordering::equivalent;
    if (is_managed_by(other))
      return std::partial_ordering::less;
    if (is_manager_of(other))
      return std::partial_ordering::greater;
    return std::partial_ordering::unordered;
  }
}; 
is_same()、is_manager_of()和is_managed_by()方法返回两个员工之间的关系。然而,可能存在没有关系的员工;例如,来自不同团队的员工,或者没有经理-下属结构的团队。在这里,我们可以实现相等和排序。然而,由于我们不能比较所有员工,<=>运算符必须返回std::partial_ordering值。如果值代表相同的员工,则返回值是partial_ordering::equivalent;如果当前员工由提供的员工管理,则返回partial_ordering::less;如果当前员工是提供的员工的管理者,则返回partial_ordering::greater;在其他所有情况下返回partial_ordering::unorder。
让我们再看一个例子来理解三向比较运算符是如何工作的。在以下示例中,ipv4类模拟了一个 IP 版本 4 地址。它支持与其他ipv4类型的对象以及unsigned long值的比较(因为有一个to_unlong()方法,它将 IP 地址转换为 32 位无符号整数值):
struct ipv4
{
  explicit ipv4(unsigned char const a=0, unsigned char const b=0,
 unsigned char const c=0, unsigned char const d=0) noexcept :
    data{ a,b,c,d }
  {}
  unsigned long to_ulong() const noexcept
 {
    return
      (static_cast<unsigned long>(data[0]) << 24) |
      (static_cast<unsigned long>(data[1]) << 16) |
      (static_cast<unsigned long>(data[2]) << 8) |
      static_cast<unsigned long>(data[3]);
  }
  auto operator<=>(ipv4 const&) const noexcept = default;
  bool operator==(unsigned long const other) const noexcept
  {
    return to_ulong() == other;
  }
  std::strong_ordering
  operator<=>(unsigned long const other) const noexcept
  {
    return to_ulong() <=> other;
  }
private:
  std::array<unsigned char, 4> data;
}; 
在这个例子中,我们重载了<=>运算符并允许它默认实现。但我们还明确实现了operator==和operator<=>的重载,这些运算符用于比较ipv4对象与unsigned long值。因为这些运算符,我们可以写出以下任何一种形式:
ipv4 ip(127, 0, 0, 1);
if(ip == 0x7F000001) {}
if(ip != 0x7F000001) {}
if(0x7F000001 == ip) {}
if(0x7F000001 != ip) {}
if(ip < 0x7F000001)  {}
if(0x7F000001 < ip)  {} 
这里有两个需要注意的地方:第一个是,尽管我们只重载了==运算符,我们也可以使用!=运算符;第二个是,尽管我们重载了==运算符和<=>运算符来比较ipv4值与unsigned long值,我们也可以比较unsigned long值与ipv4值。这是因为编译器执行对称重载解析。这意味着对于表达式a@b(其中@是一个双向关系运算符),它执行a@b、a<=>b和b<=>a的名称查找。以下表格显示了所有可能的关系运算符转换:
| a == b | b == a | |
|---|---|---|
| a != b | !(a == b) | !(b == a) | 
| a <=> b | 0 <=> (b <=> a) | |
| a < b | (a <=> b) < 0 | 0 > (b <=> a) | 
| a <= b | (a <=> b) <= 0 | 0 >= (b <=> a) | 
| a > b | (a <=> b) > 0 | 0 < (b <=> a) | 
| a >= b | (a <=> b) >= 0 | 0 <= (b <=> a) | 
表 9.4:关系运算符的可能转换
这大大减少了你必须显式提供的重载数量,以支持不同形式的比较。三向比较运算符可以实施为成员函数或非成员函数。通常,你应该优先选择成员实现。
只有在你想要两个参数都进行隐式转换时才应使用非成员形式。以下是一个示例:
struct A { int i; };
struct B
{
  B(A a) : i(a.i) { }
  int i;
};
inline auto
operator<=>(B const& lhs, B const& rhs) noexcept
{
  return lhs.i <=> rhs.i;
}
assert(A{ 2 } > A{ 1 }); 
虽然<=>运算符为类型B定义,因为它是一个非成员运算符,并且由于A可以隐式转换为B,我们可以对A类型的对象执行比较操作。
参见
- 
第一章,使用类模板参数推导简化代码,学习如何在不显式指定模板参数的情况下使用类模板 
- 
确保程序常量正确性,以探索常量正确性的好处以及如何实现它 
安全地比较有符号和无符号整数
C++语言具有多种整型:short、int、long和long long,以及它们的无符号对应类型unsigned short、unsigned int、unsigned long和unsigned long long。在 C++11 中,引入了固定宽度的整型,例如int32_t和uint32_t,以及许多类似的类型。除此之外,还有char、signed char、unsigned char、wchar_t、char8_t、char16_t和char32_t这些类型,尽管它们不是为了存储数字而是为了存储字符。此外,用于存储true或false值的bool类型也是一个整型。这些类型值的比较是一个常见的操作,但比较有符号和无符号值是容易出错的。如果没有一些特定的编译器开关来将这些操作标记为警告或错误,你就可以执行这些操作并得到意外的结果。例如,比较-1 < 42u(比较有符号的-1 和无符号的 42)将返回false。C++20 标准提供了一套用于执行有符号和无符号值安全比较的函数,我们将在本食谱中学习这些函数。
如何做…
要执行确保负有符号整数始终比较小于无符号整数的无符号和有符号整数的安全比较,请使用<utility>头文件中的以下比较函数之一:
| 函数 | 对应的比较运算符 | 
|---|---|
| std::cmp_equal | == | 
| std::cmp_not_equal | != | 
| std::cmp_less | < | 
| std::cmp_less_equal | <= | 
| std::cmp_greater | > | 
| std::cmp_greater_equal | >= | 
表 9.5:新的 C++20 比较函数及其对应的比较运算符
以下是一个示例:
int a = -1;
unsigned int b = 42;
if (std::cmp_less(a, b)) // a is less than b so this returns true
{
   std::cout << "-1 < 42\n";
}
else
{
   std::cout << "-1 >= 42\n";
} 
它是如何工作的…
比较两个有符号或两个无符号值很简单,但比较一个有符号和一个无符号整数则容易出错。当发生此类比较时,有符号值会被转换为无符号。例如,整数-1 变为 4294967295。这是因为有符号数在内存中的存储方式如下:
- 
最高有效位表示符号:正数为 0,负数为 1。 
- 
负值通过取正数的位反并加 1 来存储。 
这种表示法被称为二进制补码。例如,假设是一个 8 位有符号表示,值 1 存储为0000001,但值-1 存储为11111111。这是因为正数的 7 个最低有效位是0000001,取反后是1111110。通过加 1,我们得到1111111。与符号位一起,这构成了11111111。对于 32 位有符号整数,值-1 存储为11111111'11111111'11111111'11111111。
signed -1 and unsigned 42 will print *-1 >= 42* because the actual comparison occurs between unsigned 4294967295 and unsigned 42.
int a = -1;
unsigned int b = 42;
if(a < b)
{
   std::cout << "-1 < 42\n";
}
else
{
   std::cout << "-1 >= 42\n";
} 
这适用于所有六个相等(==,!=)和不相等(<,<=,>,>=)运算符。为了得到正确的结果,我们需要检查有符号值是否为负。之前显示的if语句的正确条件如下:
if(a < 0 || static_cast<unsigned int>(a) < b) 
为了简化此类表达式的编写,C++20 标准引入了表 9.5 中列出的六个函数,应在比较有符号和无符号整数时用作相应运算符的替代。
if(std::cmp_less(a, b))
{
   std::cout << "-1 < 42\n";
}
else
{
   std::cout << "-1 >= 42\n";
} 
下面的代码片段展示了std::cmp_less()函数的一个可能的实现:
template<class T, class U>
constexpr bool cmp_less(T t, U u) noexcept
{
    if constexpr (std::is_signed_v<T> == std::is_signed_v<U>)
 return t < u;
    else if constexpr (std::is_signed_v<T>)
        return t < 0 || std::make_unsigned_t<T>(t) < u;
    else
return u >= 0 && t < std::make_unsigned_t<U>(u);
} 
这所做的是以下内容:
- 
如果两个参数都是有符号的,它使用内置的 <比较运算符来比较它们。
- 
如果第一个参数是有符号的,第二个是无符号的,那么它检查第一个是否是本地的(负值总是小于正值)或者使用内置运算符 <将第一个参数转换为无符号并与第二个参数进行比较。
- 
如果第一个参数是无符号的,第二个可以是有符号或无符号的。第一个参数只能小于第二个,如果第二个是正数,并且第一个参数小于将其转换为无符号的第二个参数。 
当你使用这些函数时,请记住它们只适用于:
- 
short,int,long,long long及其无符号对应类型
- 
固定宽度整数类型,如 int32_t,int_least32_t,int_fast32_t及其无符号对应类型
- 
扩展整数类型(这些是编译器特定的类型,如 __int64或__int128及其大多数编译器支持的无符号对应类型)
下面的代码片段提供了一个使用扩展类型(在这种情况下是 Microsoft 特定的)和标准固定宽度整数类型的示例。
__int64 a = -1;
unsigned __int64 b = 42;
if (std::cmp_less(a, b))  // OK
{ }
int32_t  a = -1;
uint32_t b = 42;
if (std::cmp_less(a, b))  // OK
{ } 
然而,你不能用它们来比较枚举,std::byte,char,char8_t,char16_t,char32_t,wchar_t和bool。在这种情况下,你会得到编译器错误:
if (std::cmp_equal(true, 1)) // error
{ } 
参见
- 
第二章,理解各种数值类型,了解可用的整数和浮点类型 
- 
执行正确的类型转换,了解在 C++ 中执行类型转换的正确方法 
在 Discord 上了解更多
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
discord.gg/7xRaTCeEhx

第十章:实现模式和惯用语
设计模式是一般可重用的解决方案,可以应用于软件开发中出现的常见问题。惯用法是模式、算法或结构代码的一种或多种编程语言的方式。关于设计模式已经编写了大量的书籍。本章的目的不是重复它们,而是展示如何实现几个有用的模式和惯用法,重点关注可读性、性能和健壮性,从现代 C++的角度出发。
本章节包含的食谱如下:
- 
避免在工厂模式中重复使用 if-else语句
- 
实现 pimpl 惯用法 
- 
实现命名参数习语 
- 
使用非虚拟接口惯用语分离接口和实现 
- 
使用律师-客户习语处理友谊 
- 
奇异重复模板模式下的静态多态 
- 
使用混入(mixins)向类添加功能 
- 
使用类型擦除惯用语泛型处理无关类型 
- 
实现线程安全的单例 
本章的第一个菜谱介绍了一种避免重复if-else语句的简单机制。让我们来探究这个机制是如何工作的。
避免在工厂模式中重复使用 if-else 语句
通常情况下,我们会陷入编写重复的if...else语句(或等效的switch语句),这些语句执行类似的事情,通常变化很小,而且常常是通过复制粘贴并做些小改动来完成的。随着可选条件的数量增加,代码既难以阅读也难以维护。重复的if...else语句可以用各种技术来替换,例如多态。在这个菜谱中,我们将看到如何使用函数映射来避免在工厂模式(工厂是一个用于创建其他对象的函数或对象)中使用if...else语句。
准备就绪
在这个菜谱中,我们将考虑以下问题:构建一个能够处理各种格式图像文件的系统,例如位图、PNG、JPG 等等。显然,这些细节超出了本菜谱的范围;我们关注的部分是创建处理各种图像格式的对象。为此,我们将考虑以下类的层次结构:
class Image {};
class BitmapImage : public Image {};
class PngImage    : public Image {};
class JpgImage    : public Image {}; 
另一方面,我们将定义一个用于工厂类的接口,该接口可以创建上述类的实例,以及使用if...else语句的典型实现:
struct IImageFactory
{
  virtual std::unique_ptr<Image> Create(std::string_view type) = 0;
};
struct ImageFactory : public IImageFactory
{
  std::unique_ptr<Image> 
 Create(std::string_view type) override
 {
    if (type == "bmp")
      return std::make_unique<BitmapImage>();
    else if (type == "png")
      return std::make_unique<PngImage>();
    else if (type == "jpg")
      return std::make_unique<JpgImage>();
    return nullptr;
  }
}; 
本食谱的目标是查看如何重构此实现以避免重复的if...else语句。
如何做到这一点...
执行以下步骤以重构前面展示的工厂,避免使用if...else语句:
- 
实现工厂接口: struct ImageFactory : public IImageFactory { std::unique_ptr<Image> Create(std::string_view type) override { // continued with 2\. and 3. } };
- 
定义一个映射,其中键是要创建的对象类型,值是创建对象的函数: static std::map< std::string, std::function<std::unique_ptr<Image>()>> mapping { { "bmp", []() {return std::make_unique<BitmapImage>(); } }, { "png", []() {return std::make_unique<PngImage>(); } }, { "jpg", []() {return std::make_unique<JpgImage>(); } } };
- 
要创建一个对象,请在映射中查找对象类型,如果找到,则使用关联的函数来创建该类型的新实例: auto it = mapping.find(type.data()); if (it != mapping.end()) return it->second(); return nullptr;
它是如何工作的...
第一实现中的重复if...else语句非常相似——它们检查type参数的值并创建适当的Image类的实例。如果检查的参数是整型(例如枚举类型),if...else语句的序列也可以写成switch语句的形式。这段代码可以这样使用:
auto factory = ImageFactory{};
auto image = factory.Create("png"); 
无论实现是使用if...else语句还是switch,重构以避免重复检查相对简单。在重构的代码中,我们使用了一个键类型为std::string的映射,表示类型,即图像格式的名称。值是一个std::function<std::unique_ptr<Image>()>。这是一个用于无参数且返回std::unique_ptr<Image>(派生类的unique_ptr隐式转换为基类的unique_ptr)的函数包装器。
现在我们有了创建对象的函数映射,工厂的实际实现就简单多了;在映射中检查要创建的对象的类型,如果存在,则使用映射中关联的值作为创建对象的实际函数,如果映射中不存在该对象类型,则返回nullptr。
这种重构对客户端代码来说是透明的,因为客户端使用工厂的方式没有变化。另一方面,这种方法确实需要更多的内存来处理静态映射,对于某些应用程序类别,如物联网(IoT),这可能是一个重要的方面。这里提供的示例相对简单,因为目的是演示这个概念。在实际代码中,可能需要以不同的方式创建对象,例如使用不同数量的参数和不同类型的参数。然而,这并不特定于重构的实现,使用if...else/switch语句的解决方案也需要考虑这一点。因此,在实践中,使用if...else语句解决问题的解决方案也应该适用于映射。
还有更多...
在先前的实现中,映射是一个属于虚拟函数的局部静态变量,但它也可以是类的成员,甚至是一个全局变量。在下面的实现中,映射被定义为类的静态成员。对象不是基于格式名称创建的,而是基于类型信息创建的,这是由typeid运算符返回的:
struct IImageFactoryByType
{
  virtual std::unique_ptr<Image> Create(
    std::type_info const & type) = 0;
};
struct ImageFactoryByType : public IImageFactoryByType
{
  std::unique_ptr<Image> Create(std::type_info const & type) 
 override
 {
    auto it = mapping.find(&type);
    if (it != mapping.end())
      return it->second();
    return nullptr;
  }
private:
  static std::map<
    std::type_info const *,
    std::function<std::unique_ptr<Image>()>> mapping;
};
std::map<
  std::type_info const *,
  std::function<std::unique_ptr<Image>()>> ImageFactoryByType::mapping
{
  {&typeid(BitmapImage),[](){
      return std::make_unique<BitmapImage>();}},
  {&typeid(PngImage),   [](){
      return std::make_unique<PngImage>();}},
  {&typeid(JpgImage),   [](){
      return std::make_unique<JpgImage>();}}
}; 
在这种情况下,客户端代码略有不同,因为我们不是传递一个表示要创建的类型名称,例如 PNG,而是传递typeid运算符返回的值,例如typeid(PngImage):
auto factory = ImageFactoryByType{};
auto movie = factory.Create(typeid(PngImage)); 
这种替代方案可以说是更健壮的,因为映射键不是字符串,这可能会更容易出错。本食谱提出了一种模式作为解决常见问题的方案,而不是实际的实现。正如大多数模式的情况一样,它们有不同的实现方式,取决于你选择最适合每个上下文的那一种。
参见
- 
实现 pimpl 习语,学习一种能够将实现细节与接口分离的技术 
- 
第九章,使用 unique_ptr 唯一拥有内存资源,了解 std::unique_ptr类,它代表一个智能指针,它拥有并管理在堆上分配的另一个对象或对象数组
实现 pimpl 习语
pimpl代表指向实现(也称为查理猫习语或编译器防火墙习语)是一种不透明的指针技术,它能够将实现细节与接口分离。这种技术的优点是它允许在不修改接口的情况下更改实现,因此避免了需要重新编译使用该接口的代码。这使得使用 pimpl 习语的库在实现细节更改时,其 ABIs 与旧版本向后兼容。在本食谱中,我们将看到如何使用现代 C++特性实现 pimpl 习语。
ABI这个术语代表应用程序二进制接口,指的是两个二进制模块之间的接口。通常,其中一个模块是库或操作系统,另一个是用户执行的程序。
准备工作
读者应熟悉智能指针和std::string_view,这两者都在本书的前几章中讨论过。
为了以实际的方式演示 pimpl 习语,我们将考虑以下类,然后我们将根据 pimpl 模式对其进行重构:
class control
{
  std::string text;
  int width = 0;
  int height = 0;
  bool visible = true;
  void draw()
 {
    std::cout 
      << "control " << '\n'
      << " visible: " << std::boolalpha << visible << 
         std::noboolalpha << '\n'
      << " size: " << width << ", " << height << '\n'
      << " text: " << text << '\n';
  }
public:
  void set_text(std::string_view t)
 {
    text = t.data();
    draw();
  }
  void resize(int const w, int const h)
 {
    width = w;
    height = h;
    draw();
  }
  void show() 
 { 
    visible = true; 
    draw();
  }
  void hide() 
 { 
    visible = false; 
    draw();
  }
}; 
这个类表示具有文本、大小和可见性等属性的控件。每次这些属性发生变化时,控件都会重新绘制。在这个模拟实现中,绘制意味着将属性的值打印到控制台。
如何做...
按照以下步骤实现 pimpl 习语,以下以重构前面展示的control类为例:
- 
将所有私有成员,包括数据和函数,放入一个单独的类中。我们将这个类称为pimpl 类,而原始类称为公共类。 
- 
在公共类的头文件中,对 pimpl 类进行前置声明: // in control.h class control_pimpl;
- 
在公共类定义中,使用 unique_ptr声明对 pimpl 类的指针。这应该是类的唯一私有数据成员:class control { std::unique_ptr<control_pimpl, void(*)(control_pimpl*)> pimpl; public: control(); void set_text(std::string_view text); void resize(int const w, int const h); void show(); void hide(); };
- 
将 pimpl 类定义放在公共类的源文件中。pimpl 类反映了公共类的公共接口: // in control.cpp class control_pimpl { std::string text; int width = 0; int height = 0; bool visible = true; void draw() { std::cout << "control " << '\n' << " visible: " << std::boolalpha << visible << std::noboolalpha << '\n' << " size: " << width << ", " << height << '\n' << " text: " << text << '\n'; } public: void set_text(std::string_view t) { text = t.data(); draw(); } void resize(int const w, int const h) { width = w; height = h; draw(); } void show() { visible = true; draw(); } void hide() { visible = false; draw(); } };
- 
pimpl 类在公共类的构造函数中被实例化: control::control() : pimpl(new control_pimpl(), [](control_pimpl* pimpl) {delete pimpl; }) {}
- 
公共类成员函数调用 pimpl 类的相应成员函数: void control::set_text(std::string_view text) { pimpl->set_text(text); } void control::resize(int const w, int const h) { pimpl->resize(w, h); } void control::show() { pimpl->show(); } void control::hide() { pimpl->hide(); }
它是如何工作的...
pimpl 习语允许隐藏类内部实现,从而为库或模块的客户端提供以下好处:
- 
对于其客户端可见的类,提供一个干净的接口。 
- 
内部实现的变化不会影响公共接口,这使库的新版本(当公共接口保持不变时)具有二进制向后兼容性。 
- 
当内部实现发生变化时,使用这种习语的类的客户端无需重新编译。这导致构建时间更短。 
- 
头文件不需要包含私有实现中使用的类型和函数的头文件。这同样导致构建时间更短。 
提到的上述好处并非免费获得;也存在一些需要提到的缺点:
- 
需要编写和维护的代码更多。 
- 
代码的可读性可能较低,因为存在一定程度的间接引用,并且所有实现细节都需要在其他文件中查找。在本例中,pimpl 类定义在公共类的源文件中提供,但在实践中,它可能位于单独的文件中。 
- 
由于从公共类到 pimpl 类的间接引用级别,存在轻微的运行时开销,但在实践中,这很少是显著的。 
- 
这种方法不适用于私有和受保护的成员,因为这些成员必须对派生类可用。 
- 
这种方法不适用于私有虚拟函数,这些函数必须出现在类中,要么是因为它们覆盖了基类的函数,要么是因为它们必须对派生类中的覆盖可用。 
作为经验法则,在实现 pimpl 习语时,始终将所有私有成员数据和函数(除了虚拟函数外)放在 pimpl 类中,并将受保护的成员数据和函数以及所有私有虚拟函数留在公共类中。
在本例中,control_pimpl类基本上与原始的control类相同。在实践中,当类更大,具有虚拟函数和受保护的成员以及函数和数据时,pimpl 类不是类未进行 pimpl 化时的完整等价物。此外,在实践中,pimpl 类可能需要一个指向公共类的指针,以便调用未移动到 pimpl 类的成员。
关于重构后的control类的实现,control_pimpl对象的指针由unique_ptr管理。在声明此指针时,我们使用了自定义的删除器:
std::unique_ptr<control_pimpl, void(*)(control_pimpl*)> pimpl; 
原因在于control类在control_pimpl类型仍然不完整(即在头文件中)的地方被编译器隐式定义了析构函数。这会导致unique_ptr出错,因为unique_ptr不能删除一个不完整类型。这个问题可以通过两种方式解决:
- 
为 control类提供一个用户定义的析构函数,该析构函数在control_pimpl类的完整定义可用后显式实现(即使声明为default)。
- 
为 unique_ptr提供一个自定义的删除器,就像在这个例子中所做的那样。
还有更多...
原始的control类既可复制也可移动:
control c;
c.resize(100, 20);
c.set_text("sample");
c.hide();
control c2 = c;             // copy
c2.show();
control c3 = std::move(c2); // move
c3.hide(); 
重新设计的control类仅可移动,不可复制。以下代码展示了实现既可复制也可移动的control类的示例:
class control_copyable
{
  std::unique_ptr<control_pimpl, void(*)(control_pimpl*)> pimpl;
public:
  control_copyable();
  control_copyable(control_copyable && op) noexcept;
  control_copyable& operator=(control_copyable && op) noexcept;
  control_copyable(const control_copyable& op);
  control_copyable& operator=(const control_copyable& op);
  void set_text(std::string_view text);
  void resize(int const w, int const h);
  void show();
  void hide();
};
control_copyable::control_copyable() :
  pimpl(new control_pimpl(),
        [](control_pimpl* pimpl) {delete pimpl; })
{}
control_copyable::control_copyable(control_copyable &&) 
   noexcept = default;
control_copyable& control_copyable::operator=(control_copyable &&) 
   noexcept = default;
control_copyable::control_copyable(const control_copyable& op)
   : pimpl(new control_pimpl(*op.pimpl),
           [](control_pimpl* pimpl) {delete pimpl; })
{}
control_copyable& control_copyable::operator=(
   const control_copyable& op) 
{
  if (this != &op) 
  {
    pimpl = std::unique_ptr<control_pimpl,void(*)(control_pimpl*)>(
               new control_pimpl(*op.pimpl),
               [](control_pimpl* pimpl) {delete pimpl; });
  }
  return *this;
}
// the other member functions 
control_copyable类既可复制也可移动,但为了使其如此,我们提供了复制构造函数和复制赋值运算符,以及移动构造函数和移动赋值运算符。后两者可以省略,但前两者被显式实现,以便从被复制的对象中创建一个新的control_pimpl对象。
参见
- 第九章,使用unique_ptr唯一拥有内存资源,了解std::unique_ptr类,它表示一个智能指针,它拥有并管理在堆上分配的另一个对象或对象数组
实现命名参数惯例
C++只支持位置参数,这意味着参数是根据参数的位置传递给函数的。其他语言也支持命名参数——即在调用时指定参数名称并调用参数。这对于具有默认值的参数特别有用。一个函数可能有具有默认值的参数,尽管它们总是出现在所有非默认参数之后。
然而,如果您只想为一些默认参数提供值,没有提供在函数参数列表中位于它们之前的参数的参数,就无法做到这一点。
一种称为命名参数惯例的技术提供了一种模拟命名参数并帮助解决这个问题的方法。我们将在本食谱中探讨这项技术。
准备工作
为了说明命名参数惯例,我们将使用以下代码片段中的control类:
class control
{
  int id_;
  std::string text_;
  int width_;
  int height_;
  bool visible_;
public:
  control(
    int const id,
    std::string_view text = "",
    int const width = 0,
    int const height = 0,
    bool const visible = false):
      id_(id), text_(text), 
      width_(width), height_(height), 
      visible_(visible)
  {}
}; 
control类表示一个视觉控件,如按钮或输入,具有数值标识符、文本、大小和可见性等属性。这些属性被提供给构造函数,除了 ID 之外,所有其他属性都有默认值。实际上,此类会有更多属性,如文本画笔、背景画笔、边框样式、字体大小、字体家族等。
如何做到这一点...
要为函数实现命名参数惯例(通常具有许多默认参数),请执行以下操作:
- 
创建一个类来封装函数的参数: class control_properties { int id_; std::string text_; int width_ = 0; int height_ = 0; bool visible_ = false; };
- 
需要访问这些属性的类或函数可以声明为 friend以避免编写 getter:friend class control;
- 
原始函数的每个没有默认值的定位参数都应该成为没有默认值的定位参数,在类的构造函数中: public: control_properties(int const id) :id_(id) {}
- 
对于原始函数的每个具有默认值的定位参数,应该有一个具有相同名称的函数,该函数在内部设置值并返回对类的引用: public: control_properties& text(std::string_view t) { text_ = t.data(); return *this; } control_properties& width(int const w) { width_ = w; return *this; } control_properties& height(int const h) { height_ = h; return *this; } control_properties& visible(bool const v) { visible_ = v; return *this; }
- 
原始函数应该被修改,或者提供一个重载,以接受来自新类的新参数,从该类中读取属性值: control(control_properties const & cp): id_(cp.id_), text_(cp.text_), width_(cp.width_), height_(cp.height_), visible_(cp.visible_) {}
如果我们将所有这些放在一起,结果如下:
class control;
class control_properties
{
  int id_;
  std::string text_;
  int width_ = 0;
  int height_ = 0;
  bool visible_ = false;
  friend class control;
public:
  control_properties(int const id) :id_(id)
  {}
  control_properties& text(std::string_view t) 
 { text_ = t.data(); return *this; }
  control_properties& width(int const w) 
 { width_ = w; return *this; }
  control_properties& height(int const h) 
 { height_ = h; return *this; }
  control_properties& visible(bool const v) 
 { visible_ = v; return *this; }
};
class control
{
  int         id_;
  std::string text_;
  int         width_;
  int         height_;
  bool        visible_;
public:
  control(control_properties const & cp):
    id_(cp.id_), 
    text_(cp.text_),
    width_(cp.width_), 
    height_(cp.height_),
    visible_(cp.visible_)
  {}
}; 
它是如何工作的...
初始的control类有一个带有许多参数的构造函数。在实际代码中,你可以找到类似这样的例子,其中参数的数量要高得多。一个可能的解决方案,通常在实践中找到,是将常见的布尔类型属性分组在位标志中,可以作为一个单独的整型参数一起传递(一个例子可以是控制器的边框样式,它定义了边框应该可见的位置:顶部、底部、左侧、右侧,或这些四个位置的任意组合)。使用初始实现创建control对象的方式如下:
control c(1044, "sample", 100, 20, true); 
命名参数习语的优势在于,它允许你使用名称指定你想要的参数值,顺序不限,这比固定的定位顺序更加直观。
虽然没有单一的策略来实现习语,但本食谱中的示例相当典型。control类的属性,作为构造函数中的参数提供,已被放入一个单独的类中,称为control_properties,该类将control类声明为友元类,以允许它访问其私有数据成员而不提供 getter。这有一个副作用,即限制了control_properties在control类之外的用途。control类构造函数的非可选参数也是control_properties构造函数的非可选参数。对于所有其他具有默认值的参数,control_properties类定义了一个具有相关名称的函数,该函数简单地设置数据成员为提供的参数,然后返回对control_properties的引用。这使得客户端可以以任何顺序链式调用这些函数。
控制类构造函数已被替换为一个新的构造函数,它只有一个参数,即对control_properties对象的常量引用,其数据成员被复制到control对象的数据成员中。
以这种方式实现命名参数习语的control对象创建,如下代码片段所示:
control c(control_properties(1044)
          .visible(true)
          .height(20)
          .width(100)); 
参见
- 
使用非虚接口惯用法来分离接口和实现,探索一个通过使(公共)接口非虚和虚函数私有来促进接口和实现关注点分离的惯用法 
- 
使用律师-客户惯用法处理友谊,了解一个简单的机制来限制朋友对类中指定、私有成员的访问 
使用非虚接口惯用法来分离接口和实现
虚函数通过允许派生类修改从基类继承的实现,为类提供了特殊化的点。当一个派生类对象通过基类指针或引用来处理时,对重写的虚函数的调用最终会调用派生类中的重写实现。另一方面,定制是实现细节,良好的设计将接口与实现分离。
Herb Sutter 在 C/C++ Users Journal 关于虚函数的文章中提出的 非虚接口惯用法,通过使(公共)接口非虚和虚函数私有,促进了接口和实现的关注点分离。
公共虚接口阻止类在其接口上强制执行前条件和后条件。期望基类实例的用户不能保证公共虚方法会提供预期的行为,因为它可以在派生类中被重写。这个惯用法有助于强制执行接口的承诺合同。
准备工作
读者应该熟悉与虚函数相关的方面,例如定义和重写虚函数、抽象类和纯指定符。
如何做到这一点...
实现这个惯用法需要遵循几个简单的设计准则,这些准则由 Herb Sutter 在 C/C++ Users Journal,19(9),2001 年 9 月提出:
- 
将(公共)接口设为非虚。 
- 
将虚函数设为私有。 
- 
只有当基类实现必须从派生类中调用时,才将虚函数设为保护。 
- 
将基类析构函数设为公共和虚的或保护和非虚的。 
以下是一个简单的控件层次结构的示例,遵循所有这四个准则:
class control
{
private:
  virtual void paint() = 0;
protected:
  virtual void erase_background() 
 {
    std::cout << "erasing control background..." << '\n';
  }
public:
  void draw()
 {
    erase_background();
    paint();
  }
  virtual ~control() {}
};
class button : public control
{
private:
  virtual void paint() override
 {
    std::cout << "painting button..." << '\n';
  }
protected:
  virtual void erase_background() override
 {
    control::erase_background();
    std::cout << "erasing button background..." << '\n';
  }
};
class checkbox : public button
{
private:
  virtual void paint() override
 {
    std::cout << "painting checkbox..." << '\n';
  }
protected:
  virtual void erase_background() override
 {
    button::erase_background();
    std::cout << "erasing checkbox background..." << '\n';
  }
}; 
它是如何工作的...
NVI 惯用法使用 模板方法 设计模式,允许派生类定制基类功能(即算法)的部分(即步骤)。这是通过将整体算法拆分为更小的部分来实现的,每个部分都由一个虚函数实现。基类可以提供或不需要默认实现,派生类可以覆盖它们,同时保持算法的整体结构和意义。
NVI 习语的核心理念是虚拟函数不应该公开;它们应该是私有或受保护的,以防基类实现可以从派生类中调用。类的接口,其客户端可以访问的公共部分,应该仅由非虚拟函数组成。这提供了几个优点:
- 
它将接口与不再暴露给客户端的实现细节分离。 
- 
它使得在不改变公共接口且不需要修改客户端代码的情况下更改实现细节成为可能,因此使基类更加健壮。 
- 
它允许一个类对其接口拥有完全控制权。如果公共接口包含虚拟方法,派生类可以改变承诺的功能,因此,类不能确保其前置条件和后置条件。当没有虚拟方法(除了析构函数)可供其客户端访问时,类可以在其接口上强制执行前置条件和后置条件。 
对于这个习语,需要特别提及类的析构函数。通常强调基类析构函数应该是虚拟的,这样对象就可以通过基类指针或引用进行多态删除。当析构函数不是虚拟的时,进行多态删除对象会导致未定义的行为。然而,并非所有基类都旨在进行多态删除。对于这些特定情况,基类析构函数不应该虚拟,但也应该不是公共的,而是受保护的。
上一节中的例子定义了一个表示视觉控件的类层次结构:
- 
control是基类,但存在派生类,如button和checkbox,它们是按钮类型,因此从这个类派生出来。
- 
control类定义的唯一功能是绘制控件。draw()方法是非虚拟的,但它调用了两个虚拟方法,erase_background()和paint(),以实现绘制控件的两个阶段。
- 
erase_background()是一个受保护的虚拟方法,因为派生类需要在它们自己的实现中调用它。
- 
paint()是一个私有的纯虚拟方法。派生类必须实现它,但不应该调用基类实现。
- 
控件类的析构函数是公共的且虚拟的,因为预期对象将通过多态删除。 
下面展示了使用这些类的示例。这些类的实例由基类智能指针管理:
std::vector<std::unique_ptr<control>> controls;
controls.emplace_back(std::make_unique<button>());
controls.emplace_back(std::make_unique<checkbox>());
for (auto& c : controls)
  c->draw(); 
该程序的输出如下:
erasing control background...
erasing button background...
painting button...
erasing control background...
erasing button background...
erasing checkbox background...
painting checkbox...
destroying button...
destroying control...
destroying checkbox...
destroying button...
destroying control... 
NVI 习语在公共函数调用实际实现的非公共虚拟函数时引入了一层间接性。在先前的例子中,draw() 方法调用了几个其他函数,但在许多情况下,可能只需要一个调用:
class control
{
protected:
  virtual void initialize_impl()
 {
    std::cout << "initializing control..." << '\n';
  }
public:
  void initialize()
 {
    initialize_impl();
  }
};
class button : public control
{
protected:
  virtual void initialize_impl()
 {
    control::initialize_impl();
    std::cout << "initializing button..." << '\n';
  }
}; 
在这个例子中,类control有一个额外的名为initialize()的方法(为了保持简单,没有显示类的前置内容),它调用一个单独的非公共虚拟方法initialize_impl(),该方法在每个派生类中实现不同。这不会产生太多开销——如果有的话——因为像这样的简单函数很可能被编译器内联。
参见
- 第一章,使用 override 和 final 指定虚拟方法,了解如何指定一个虚拟函数覆盖另一个虚拟函数,以及如何指定在派生类中不能覆盖虚拟函数
使用律师-客户习语处理友元关系
使用友元声明授予函数和类对类非公共部分的访问权限通常被视为设计不佳的标志,因为友元关系破坏了封装性,并使类和函数之间产生了联系。无论友元是类还是函数,它们都可以访问类的所有私有成员,尽管它们可能只需要访问其中的一部分。
律师-客户习语提供了一个简单的机制,以限制友元对类中仅指定的私有成员的访问。
准备工作
为了演示如何实现这个习语,我们将考虑以下类:Client,它具有一些私有成员数据和函数(在这里,公共接口并不重要),以及Friend,它应该只访问私有细节的一部分,例如data1和action1(),但它可以访问一切:
class Client
{
  int data_1;
  int data_2;
  void action1() {}
  void action2() {}
  friend class Friend;
public:
  // public interface
};
class Friend
{
public:
  void access_client_data(Client& c)
 {
    c.action1();
    c.action2();
    auto d1 = c.data_1;
    auto d2 = c.data_1;
  }
}; 
要理解这个习语,你必须熟悉 C++语言中如何声明友元关系以及它是如何工作的。
如何做...
采取以下步骤以限制友元对您需要访问的类中私有成员的访问:
- 
在 Client类中,该类将其所有私有成员对友元类提供访问权限,将友元关系声明给一个中间类,称为Attorney类:class Client { int data_1; int data_2; void action1() {} void action2() {} friend class Attorney; public: // public interface };
- 
创建一个只包含私有(内联)函数的类,这些函数访问客户端的私有成员。这个中间类允许实际的友元访问其私有成员: class Attorney { static inline void run_action1(Client& c) { c.action1(); } static inline int get_data1(Client& c) { return c.data_1; } friend class Friend; };
- 
在 Friend类中,通过Attorney类间接访问Client类的私有成员:class Friend { public: void access_client_data(Client& c) { Attorney::run_action1(c); auto d1 = Attorney::get_data1(c); } };
它是如何工作的...
律师-客户惯用法通过引入中间人律师来限制对客户端私有成员的访问。客户端类不是直接向使用其内部状态的人提供友元关系,而是向律师提供友元关系,律师反过来提供对客户端受限的私有数据或函数的访问。它是通过定义私有静态函数来实现的。通常,这些也是内联函数,这避免了律师类引入的间接级别导致的任何运行时开销。客户端的友元通过实际使用律师的私有成员来访问其私有成员。这种惯用法被称为律师-客户,因为它与律师-客户关系的方式相似,律师知道客户的所有秘密,但只向其他方透露其中的一部分。
在实践中,如果不同的友元类或函数必须访问客户端类的不同私有成员,可能需要为客户端类创建多个律师。
另一方面,友元关系是不可继承的,这意味着一个与类B为友元的类或函数不会与从B派生出的类D为友元。然而,D中重写的虚拟函数仍然可以通过指向或引用B的指针或引用从友元类以多态方式访问。以下是一个示例,其中从F调用run()方法会打印出base和derived:
class B
{
  virtual void execute() { std::cout << "base" << '\n'; }
  friend class BAttorney;
};
class D : public B
{
  virtual void execute() override 
 { std::cout << "derived" << '\n'; }
};
class BAttorney
{
  static inline void execute(B& b)
 {
    b.execute();
  }
  friend class F;
};
class F
{
public:
  void run()
 {
    B b;
    BAttorney::execute(b); // prints 'base'
    D d;
    BAttorney::execute(d); // prints 'derived'
  }
};
F;
f.run(); 
使用设计模式总会有权衡,这个也不例外。在某些情况下,使用此模式可能会导致在开发、测试和维护方面产生过多的开销。然而,对于某些类型的应用程序,例如可扩展框架,该模式可能非常有价值。
参见
- 实现 pimpl 惯用法,学习一种能够将实现细节与接口分离的技术
使用奇特重复的模板模式实现静态多态
多态为我们提供了具有相同接口的多种形式的能力。虚拟函数允许派生类覆盖基类中的实现。它们是多态形式中最常见的元素,称为运行时多态,因为从类层次结构中调用特定虚拟函数的决定是在运行时发生的。它也称为后期绑定,因为函数调用与函数调用的绑定是在程序执行期间较晚发生的。与此相反的是称为早期绑定、静态多态或编译时多态,因为它在编译时通过函数和运算符重载发生。
另一方面,一种称为奇特重复的模板模式(或CRTP)的技术允许通过从基类模板派生类来在编译时模拟基于虚函数的运行时多态。这种技术在某些库中得到了广泛的应用,包括微软的活动模板库(ATL)和Windows 模板库(WTL)。在这个配方中,我们将探索 CRTP,了解如何实现它以及它是如何工作的。
准备工作
为了演示 CRTP 的工作原理,我们将回顾我们在使用非虚拟接口习惯用法分离接口和实现配方中实现的控制类层次结构的示例。我们将定义一组具有诸如绘制控件等功能的控制类,在我们的示例中,这是一个分为两个阶段进行的操作:擦除背景然后绘制控件。为了简单起见,在我们的实现中,这些将只打印文本到控制台的操作。
如何做...
为了实现奇特重复的模板模式以实现静态多态,请执行以下操作:
- 
提供一个类模板,它将代表其他应在编译时进行多态处理的类的基类。多态函数从此类调用: template <class T> class control { public: void draw() { static_cast<T*>(this)->erase_background(); static_cast<T*>(this)->paint(); } };
- 
派生类使用类模板作为它们的基类;派生类也是基类的模板参数。派生类实现了从基类调用的函数: class button : public control<button> { public: void erase_background() { std::cout << "erasing button background..." << '\n'; } void paint() { std::cout << "painting button..." << '\n'; } }; class checkbox : public control<checkbox> { public: void erase_background() { std::cout << "erasing checkbox background..." << '\n'; } void paint() { std::cout << "painting checkbox..." << '\n'; } };
- 
函数模板可以通过基类模板的指针或引用来多态地处理派生类: template <class T> void draw_control(control<T>& c) { c.draw(); } button b; draw_control(b); checkbox c; draw_control(c);
它是如何工作的...
虚函数可能会引起性能问题,尤其是在它们很小并且在循环中多次调用时。现代硬件使得这些情况中的大多数变得相当无关紧要,但仍然有一些应用类别,性能至关重要,任何性能提升都很重要。奇特重复的模板模式允许使用元编程在编译时模拟虚函数调用,这最终转化为函数重载。
这种模式乍一看可能相当奇怪,但它完全合法。想法是从一个基类派生一个类,该基类是一个模板类,然后传递派生类本身作为基类的类型模板参数。基类随后调用派生类函数。在我们的示例中,control<button>::draw()在button类对编译器已知之前声明。然而,control类是一个类模板,这意味着它仅在编译器遇到使用它的代码时实例化。在那个时刻,在这个例子中,button类已经定义并且对编译器已知,因此可以调用button::erase_background()和button::paint()。
要调用派生类的函数,我们首先需要获得派生类的指针。这通过static_cast转换完成,如static_cast<T*>(this)->erase_background()所示。如果需要多次这样做,可以通过提供一个执行此操作的私有函数来简化代码:
template <class T>
class control
{
  T* derived() { return static_cast<T*>(this); }
public:
  void draw()
 {
    derived()->erase_background();
    derived()->paint();
  }
}; 
在使用 CRTP 时,有一些陷阱你必须注意:
- 
所有从基类模板调用的派生类中的函数都必须是公共的;否则,基类特化必须声明为派生类的友元: class button : public control<button> { private: friend class control<button>; void erase_background() { std::cout << "erasing button background..." << '\n'; } void paint() { std::cout << "painting button..." << '\n'; } };
- 
在同质容器,例如 vector或list中,无法存储 CRTP 类型的对象,因为每个基类都是一个独特的类型(例如control<button>和control<checkbox>)。如果这确实是必要的,那么可以使用一种变通方法来实现它。这将在下一节中进行讨论和示例。
- 
当使用这种技术时,程序的大小可能会增加,因为模板的实例化方式。 
还有更多...
当需要将实现 CRTP 类型的对象同质地存储在容器中时,必须使用一个额外的惯用用法。基类模板本身必须从另一个具有纯虚拟函数(以及虚拟公共析构函数)的类派生。为了在control类上说明这一点,需要以下更改:
class controlbase
{
public:
  virtual void draw() = 0;
  virtual ~controlbase() {}
};
template <class T>
class control : public controlbase
{
public:
  virtual void draw() override
 {
    static_cast<T*>(this)->erase_background();
    static_cast<T*>(this)->paint();
  }
}; 
不需要对派生类,如button和checkbox,进行任何更改。然后,我们可以在容器中存储抽象类的指针,例如std::vector,如下所示:
void draw_controls(std::vector<std::unique_ptr<controlbase>>& v)
{
  for (auto & c : v)
  {
    c->draw();
  }
}
std::vector<std::unique_ptr<controlbase>> v;
v.emplace_back(std::make_unique<button>());
v.emplace_back(std::make_unique<checkbox>());
draw_controls(v); 
参见
- 
实现 pimpl 惯用用法,学习一种使实现细节与接口分离的技术 
- 
使用非虚拟接口惯用用法来分离接口和实现,以探索一种惯用用法,通过使(公共)接口非虚拟和虚拟函数私有,来促进接口和实现的关注点分离 
向混入类添加功能
在前面的菜谱中,我们了解了一个称为“好奇地反复出现的模板模式”或简称 CRTP 的模式,以及它是如何被用来向类添加共同功能的。这并不是它的唯一用途;其他用例包括限制类型的实例化次数和实现组合模式。与这个模式相关,还有一个称为混合的模式。混合是设计用来向其他现有类添加功能的小类。你可能可以找到关于这个模式的文章,声称它是使用 CRTP 实现的。这是不正确的。确实,CRTP 和混合是相似的模式,两者都用于向类添加功能,但它们的结构并不相同。在 CRTP 中,基类向从它派生的类添加功能。混合类向它派生的类添加功能。因此,从某种意义上说,它是一个颠倒的 CRTP。在这个菜谱中,你将学习如何使用混合向类添加共同功能。为此,我们将检查绘制控件(如按钮和复选框)的相同示例。这将允许与 CRTP 进行良好的比较,这将帮助你更好地理解两者之间的差异(和相似之处)。
如何做到这一点…
要实现混合模式以向现有类添加共同功能,请按照以下步骤操作(在以下示例中,所涉及的共同功能是绘制控件的背景和内容):
- 
考虑(可能无关的)表现出共同功能(的)类: class button { public: void erase_background() { std::cout << "erasing button background..." << '\n'; } void paint() { std::cout << "painting button..." << '\n'; } }; class checkbox { public: void erase_background() { std::cout << "erasing checkbox background..." << '\n'; } void paint() { std::cout << "painting checkbox..." << '\n'; } };
- 
创建一个从其类型模板参数派生的类模板。这个混合类定义了一些新的功能,这些功能是通过基类中现有的功能实现的: template <typename T> class control : public T { public: void draw() { T::erase_background(); T::paint(); } };
- 
实例化和使用混合类的对象以利用添加的功能: control<button> b; b.draw(); control<checkbox> c; c.draw();
它是如何工作的…
混合是一个允许我们向现有类添加新功能的概念。在许多编程语言中,这种模式有不同的实现方式。在 C++中,混合是一个小的类,它向现有的类添加功能(而不需要对现有类进行任何修改)。为此,你需要:
- 
将混合类做成模板。在我们的例子中,这是 control类。如果只有一个类型需要扩展,那么不需要使用模板,因为没有代码重复。然而,在实践中,这通常是为了向多个类似类添加共同功能。
- 
从其类型模板参数派生它,该参数应该实例化为要扩展的类型。通过重用类型模板参数类的功能来实现添加的功能。在我们的例子中,新的功能是 draw(),它使用了T::erase_background()和T::paint()。
由于混入类是一个模板,它不能被多态处理。例如,也许您想要一个能够绘制按钮、复选框以及其他可绘制控件的函数。这个函数可以看起来如下:
void draw_all(std::vector<???*> const & controls)
{
   for (auto& c : controls)
   {
      c->draw();
   }
} 
但在这个片段中???代表什么?我们需要一个非模板基类才能使其以多态方式工作。这样的基类可以看起来如下:
class control_base
{
public:
   virtual ~control_base() {}
   virtual void draw() = 0;
}; 
混入类(control)还需要从该基类(control_base)派生,并且draw()函数成为一个被重写的虚函数:
template <typename T>
class control : public control_base, public T 
{
public:
   void draw() override
 {
      T::erase_background();
      T::paint();
   }
}; 
这允许我们以多态方式处理控件对象,如下面的示例所示:
void draw_all(std::vector<control_base*> const & controls)
{
   for (auto& c : controls)
   {
      c->draw();
   }
}
int main()
{
   std::vector<control_base*> controls;
   control<button> b;
   control<checkbox> c;
   draw_all({&b, &c});
} 
如您从本食谱和上一个食谱中可以看到,混入和 CRTP 都用于添加功能到类的相同目的。此外,它们看起来很相似,尽管实际的模式结构是不同的。
参见
- 使用好奇地重复出现的模板模式进行静态多态,要了解 CRTP,它允许通过从基类模板派生类来在编译时模拟运行时多态
使用类型擦除惯用语泛型处理无关类型
多态(特别是 C++中的运行时多态)允许我们以通用方式处理类的层次结构。然而,有些情况下我们想要做的是相同的,但与不继承自公共基类的类。这可能发生在我们不拥有代码或由于各种原因无法更改代码以创建层次结构时。这个过程是利用具有某些特定成员(函数或变量)的不相关类型来完成给定任务(并且只使用那些公共成员)的过程,称为鸭子类型。解决这个问题的一个简单方法是为我们想要以通用方式处理的每个类构建一个包装类层次结构。这有缺点,因为有很多样板代码,并且每次需要以相同方式处理新类时,都必须创建一个新的包装器。这种方法的替代方法是称为类型擦除的惯用语。这个术语指的是擦除了有关具体类型的信息,允许以通用方式处理不同甚至不相关的类型。在本食谱中,我们将学习这个惯用语是如何工作的。
准备工作
为了展示类型擦除惯用语,我们将使用以下两个类,分别代表按钮和复选框控件:
class button
{
public:
   void erase_background()
 {
      std::cout << "erasing button background..." << '\n';
   }
   void paint()
 {
      std::cout << "painting button..." << '\n';
   }
};
class checkbox
{
public:
   void erase_background()
 {
      std::cout << "erasing checkbox background..." << '\n';
   }
   void paint()
 {
      std::cout << "painting checkbox..." << '\n';
   }
}; 
这些是我们之前在各种形式中看到过的相同类。它们都有erase_background()和paint()成员函数,但没有一个共同的基类;因此,它们不是属于允许我们以多态方式处理它们的层次结构的一部分。
如何做到这一点...
要实现类型擦除惯用语,您需要遵循以下步骤:
- 
定义一个将提供擦除类型信息机制的类。对于本食谱中展示的与控件相关的示例,我们将简单地称其为 control:struct control { };
- 
创建一个内部类( control类的内部类),该类定义了需要通用处理的类型所共有的接口。这个接口被称为概念;因此,我们将称这个类为control_concept:struct control_concept { virtual ~control_concept() = default; virtual void draw() = 0; };
- 
创建另一个内部类( control类的内部类),它从概念类派生。然而,这将是一个类模板,其类型模板参数代表一个需要通用处理的类型。在我们的例子中,它将被替换为button和checkbox。这种实现称为 模型,因此我们将这个类模板称为control_model:template <typename T> struct control_model : public control_concept { control_model(T & unit) : t(unit) {} void draw() override { t.erase_background(); t.paint(); } private: T& t; };
- 
向 control类添加一个数据成员,表示指向该概念实例的指针。在这个菜谱中,我们将使用智能指针来完成这个目的:private: std::shared_ptr<control_concept> ctrl;
- 
定义 control类的构造函数。这必须是一个函数模板,并且它必须将概念指针设置为模型的一个实例:template <typename T> control(T&& obj) : ctrl(std::make_shared<control_model<T>>(std::forward<T>(obj))) { }
- 
定义 control类客户端能够调用的公共接口。在我们的示例中,这是一个用于绘制控制的函数。我们将称之为draw()(尽管它不必与概念中的虚拟方法同名):void draw() { ctrl->draw(); }
struct control
{
   template <typename T>
   control(T&& obj) : 
      ctrl(std::make_shared<control_model<T>>(std::forward<T>(obj)))
   {
   }
   void draw()
 {
      ctrl->draw();
   }
   struct control_concept
   {
      virtual ~control_concept() = default;
      virtual void draw() = 0;
   };
   template <typename T>
   struct control_model : public control_concept
   {
      control_model(T& unit) : t(unit) {}
      void draw() override
 {
         t.erase_background();
         t.paint();
      }
   private:
      T& t;
   };
private:
   std::shared_ptr<control_concept> ctrl;
}; 
我们可以使用这个包装类来多态地处理按钮和复选框(以及类似的其它类),例如在以下代码片段中:
void draw(std::vector<control>& controls)
{
   for (auto& c : controls)
   {
      c.draw();
   }
}
int main()
{
   checkbox cb;
   button btn;
   std::vector<control> v{control(cb), control(btn)};
   draw(v);
} 
它是如何工作的…
最基本的类型擦除形式(也许可以说是最终形式)是使用void指针。尽管这为在 C 语言中实现该惯用表达式提供了机制,但在 C++中应避免使用,因为它不保证类型安全。它需要从指向类型的指针转换为指向void的指针,然后再反过来,这很容易出错,如下面的示例所示:
void draw_button(void* ptr)
{
   button* b = static_cast<button*>(ptr);
   if (b)
   {
      b->erase_background();
      b->paint();
   }
}
int main()
{
   button btn;
   draw_button(&btn);
   checkbox cb;
   draw_button(&cb); // runtime error
} 
draw_button() is a function that knows how to draw a button. But we can pass a pointer to anything – there will be no compile-time error or warning. However, the program will likely crash at runtime.
在 C++中,解决这个问题的方法是定义一个处理单个类的包装器层次结构。为此,我们可以从一个定义包装器类接口的基类开始。在我们的情况下,我们感兴趣的是绘制一个控件,因此唯一的虚方法是名为draw()的方法。
我们将把这个类称为control_concept。其定义如下:
struct control_concept
{
   virtual ~control_concept() = default;
   virtual void draw() = 0;
}; 
下一步是针对可以绘制的每种控制类型推导出相应的实现(使用两个 erase_background() 和 paint() 函数)。button 和 checkbox 的包装器如下:
struct button_wrapper : control_concept
{
   button_wrapper(button& b):btn(b)
   {}
   void draw() override
 {
      btn.erase_background();
      btn.paint();
   }
private:
   button& btn;
};
struct checkbox_wrapper : control_concept
{
   checkbox_wrapper(checkbox& cb) :cbox(cb)
   {}
   void draw() override
 {
      cbox.erase_background();
      cbox.paint();
   }
private:
   checkbox& cbox;
}; 
有这样的包装器层次结构,我们可以编写一个函数,通过使用指向control_concept(包装器层次结构的基类)的指针,以多态方式绘制控件:
void draw(std::vector<control_concept*> const & controls)
{
   for (auto& c : controls)
      c->draw();
}
int main()
{
   checkbox cb;
   button btn;
   checkbox_wrapper cbw(cb);
   button_wrapper btnw(btn);
   std::vector<control_concept*> v{ &cbw, &btnw };
   draw(v);
} 
虽然这样做是可行的,button_wrapper 和 control_wrapper 几乎完全相同。因此,它们是模板化的良好候选者。下面展示了一个封装了这两个类中看到的功能的类模板:
template <typename T>
struct control_wrapper : control_concept
{
   control_wrapper(T& b) : ctrl(b)
   {}
   void draw() override
 {
      ctrl.erase_background();
      ctrl.paint();
   }
private:
   T& ctrl;
}; 
客户端代码只需进行微小修改:将button_wrapper和checkbox_wrapper替换为control_wrapper<button>和control_wrapper<checkbox>,如下所示片段:
int main()
{
   checkbox cb;
   button btn;
   control_wrapper<checkbox> cbw(cb);
   control_wrapper<button> btnw(btn);
   std::vector<control_concept*> v{ &cbw, &btnw };
   draw(v);
} 
我们也可以将处理这些控制类型的draw()自由函数移动到control类内部。得到的实现如下:
struct control_collection
{
   template <typename T>
   void add_control(T&& obj) 
 {      
      ctrls.push_back(
         std::make_shared<control_model<T>>(std::forward<T>(obj)));
   }
   void draw()
 {
      for (auto& c : ctrls)
      {
         c->draw();
      }
   }
   struct control_concept
   {
      virtual ~control_concept() = default;
      virtual void draw() = 0;
   };
   template <typename T>
   struct control_model : public control_concept
   {
      control_model(T& unit) : t(unit) {}
      void draw() override
 {
         t.erase_background();
         t.paint();
      }
   private:
      T& t;
   };
private:
   std::vector<std::shared_ptr<control_concept>> ctrls;
}; 
这需要对客户端代码进行一些小的修改(见 如何操作… 部分),它将类似于以下代码片段:
int main()
{
   checkbox cb;
   button btn;
   control_collection cc;
   cc.add_control(cb);
   cc.add_control(btn);
   cc.draw();
} 
尽管我们在本食谱中看到了一个简单的例子,但这个习语在现实世界的场景中也被使用,包括 C++标准库,其中它被用于实现:
- 
std::function,这是一个多态函数包装器,允许我们存储、复制和调用可调用项:函数、函数对象、成员函数指针、成员数据指针、lambda 表达式和绑定表达式。
- 
std::any,这是一个表示任何可复制构造类型值的容器类型。
参见
- 
静态多态与古怪重复出现的模板模式,了解 CRTP,它允许通过从使用派生类参数化的基类模板派生类来在编译时模拟运行时多态 
- 
通过混入(mixins)向类添加功能, 了解如何在不更改现有类的情况下向其添加通用功能 
- 
第六章,使用 std::any 存储任何值,学习如何使用 C++17 的 std::any类,它代表了一个任何类型单值的类型安全容器
实现线程安全的单例
单例模式可能是最广为人知的设计模式之一。它限制了类中单个对象的实例化,这在某些情况下是必要的,尽管很多时候单例的使用更像是一种可以避免的反模式,可以通过其他设计选择来替代。
由于单例意味着一个类的单个实例对整个程序都是可用的,因此这种独特的实例可能可以从不同的线程中访问。因此,当你实现单例时,你也应该使其线程安全。
在 C++11 之前,做这件事并不容易,双重检查锁定技术是典型的解决方案。然而,Scott Meyers 和 Andrei Alexandrescu 在一篇名为《C++与双重检查锁定之危险》的论文中表明,使用这种模式并不能保证在可移植的 C++中实现线程安全的单例。幸运的是,这种情况在 C++11 中得到了改变,这个配方展示了如何在现代 C++中编写线程安全的单例。
准备就绪
对于这个食谱,你需要了解静态存储持续时间、内部链接以及删除和默认函数是如何工作的。如果你还没有阅读过,并且不熟悉该模式,你应该首先阅读之前的食谱 使用奇特重复模板模式的静态多态性,因为我们将在本食谱中稍后使用它。
如何做到这一点...
要实现线程安全的单例,你应该做以下事情:
- 
定义 Singleton类:class Singleton { };
- 
将默认构造函数设置为私有: private: Singleton() = default;
- 
将复制构造函数和复制赋值运算符分别设置为 public和delete:public: Singleton(Singleton const &) = delete; Singleton& operator=(Singleton const&) = delete;
- 
创建并返回单个实例的函数应该是静态的,并且应该返回对类类型的引用。它应该声明一个类类型的静态对象,并返回对其的引用: public: static Singleton& instance() { static Singleton single; return single; }
它是如何工作的...
由于单例对象不应该由用户直接创建,所有构造函数要么是私有的,要么是公共的并且deleted。默认构造函数是私有的且未被删除,因为类代码中必须实际创建类的实例。在这个实现中,有一个名为instance()的静态函数,它返回类的单个实例。
尽管大多数实现返回一个指针,但实际上返回一个引用更有意义,因为在这个函数返回 null 指针(没有对象)的情况下是没有情况的。
instance()方法的实现可能看起来很简单且不是线程安全的,尤其是如果你熟悉双重检查锁定模式(DCLP)。在 C++11 中,这实际上不再是必要的,因为对象具有静态存储持续时间初始化的关键细节。初始化只发生一次,即使多个线程同时尝试初始化相同的静态对象也是如此。DCLP 的责任已经从用户转移到编译器,尽管编译器可能使用另一种技术来保证结果。
来自 C++标准文档版本 N4917 的第 8.8.3 段落的以下引文定义了静态对象初始化的规则(高亮显示的部分与并发初始化相关):
块变量的动态初始化具有静态存储持续时间(6.7.5.2)或线程存储持续时间(6.7.5.3)是在控制首次通过其声明时执行的;这样的变量在其初始化完成后被认为是初始化过的。如果初始化通过抛出异常退出,则初始化未完成,因此它将在下一次控制进入声明时再次尝试。如果在变量初始化的同时控制并发进入声明,则并发执行应等待初始化完成。
[注 2:符合规范的实现不能在初始化器的执行过程中引入任何死锁。死锁可能仍然由程序逻辑引起;实现只需避免由于自己的同步操作引起的死锁。—结束注]
如果在变量初始化过程中控制递归地重新进入声明,则行为是未定义的。
静态局部对象具有静态存储持续时间,但它仅在首次使用时(在第一次调用 instance() 方法时)实例化。程序退出时对象将被释放。作为旁注,返回指针而不是引用的唯一可能优势是在程序退出之前某个时刻删除此单个实例,然后可能重新创建它。这再次没有太多意义,因为它与类单例、全局实例的概念相冲突,该实例可以从程序的任何地方访问。
更多内容...
在较大的代码库中,可能存在需要多个单例类型的情况。为了避免多次编写相同的模式,可以以通用方式实现它。为此,我们需要使用本章前面看到的 好奇重复模板模式(或 CRTP)。实际的单例作为类模板实现。instance() 方法创建并返回一个类型为模板参数的对象,这将是一个派生类:
template <class T>
class SingletonBase
{
protected:
  SingletonBase() {}
public:
  SingletonBase(SingletonBase const &) = delete;
  SingletonBase& operator=(SingletonBase const&) = delete;
  static T& instance()
 {
    static T single;
    return single;
  }
};
class Single : public SingletonBase<Single>
{
  Single() {}
  friend class SingletonBase<Single>;
public:
  void demo() { std::cout << "demo" << '\n'; }
}; 
上一个部分中的 Singleton 类已变为 SingletonBase 类模板。默认构造函数不再是私有的,而是受保护的,因为它必须可以从派生类访问。在这个例子中,需要实例化单个对象的类被称为 Single。它的构造函数必须是私有的,但默认构造函数也必须对基类模板可用;因此,SingletonBase<Single> 是 Single 类的朋友。
参见
- 
使用好奇重复模板模式实现静态多态性,了解 CRTP,它允许通过从使用派生类参数化的基类模板派生类来在编译时模拟运行时多态 
- 
第三章,已弃用和已删除的函数,了解在特殊成员函数上使用默认指定符以及如何使用 delete 指定符定义已删除的函数 
在 Discord 上了解更多
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
discord.gg/7xRaTCeEhx

第十一章:探索测试框架
测试代码是软件开发的重要部分。尽管 C++ 标准中没有对测试的支持,但存在大量用于单元测试 C++ 代码的框架。本章的目的是让您开始使用几个现代且广泛使用的测试框架,这些框架使您能够编写可移植的测试代码。本章将涵盖的框架是 Boost.Test、Google Test 和 Catch2。
本章包括以下食谱:
- 
开始使用 Boost.Test 
- 
使用 Boost.Test 编写和调用测试 
- 
使用 Boost.Test 进行断言 
- 
使用 Boost.Test 的测试夹具 
- 
使用 Boost.Test 控制输出 
- 
开始使用 Google Test 
- 
使用 Google Test 编写和调用测试 
- 
使用 Google Test 进行断言 
- 
使用 Google Test 的测试夹具 
- 
使用 Google Test 控制输出 
- 
开始使用 Catch2 
- 
使用 Catch2 编写和调用测试 
- 
使用 Catch2 进行断言 
- 
使用 Catch2 控制输出 
这三个框架被选择是因为它们广泛的使用、丰富的功能、易于编写和执行测试、可扩展性和可定制性。以下表格展示了这三个库功能的简要比较:
| 特性 | Boost.Test | Google Test | Catch2 (v3) | 
|---|---|---|---|
| 容易安装 | 是 | 是 | 是 | 
| 仅头文件 | 是 | 否 | 否 | 
| 编译库 | 是 | 是 | 是 | 
| 容易编写测试 | 是 | 是 | 是 | 
| 自动测试注册 | 是 | 是 | 是 | 
| 支持测试套件 | 是 | 是 | 否(间接通过标签) | 
| 支持夹具 | 是(设置/清理) | 是(设置/清理) | 是(多种方式) | 
| 丰富的断言集 | 是 | 是 | 是 | 
| 非致命断言 | 是 | 是 | 是 | 
| 多种输出格式 | 是(包括 HRF、XML) | 是(包括 HRF、XML) | 是(包括 HRF、XML) | 
| 测试执行过滤 | 是 | 是 | 是 | 
| 许可证 | Boost | Apache 2.0 | Boost | 
表 11.1:Boost.Test、Google Test 和 Catch2 功能比较
所有这些功能将在每个框架的详细讨论中介绍。本章具有对称结构,有 4 个 5 个食谱专门针对每个测试框架。首先考虑的框架是 Boost.Test。
开始使用 Boost.Test
Boost.Test 是最古老且最受欢迎的 C++ 测试框架之一。它提供了一套易于使用的 API,用于编写测试并将它们组织成测试用例和测试套件。它对断言、异常处理、夹具和其他测试框架所需的重要功能提供了良好的支持。
在接下来的几个食谱中,我们将探索它最重要的功能,这些功能使您能够编写单元测试。在这个食谱中,我们将看到如何安装框架并创建一个简单的测试项目。
准备工作
Boost.Test 框架有一个基于宏的 API。虽然你只需要使用提供的宏来编写测试,但如果想很好地使用该框架,建议你了解宏。
如何实现...
为了设置你的环境以使用 Boost.Test,请执行以下操作:
- 
从 www.boost.org/下载最新的 Boost 库版本。
- 
解压存档的内容。 
- 
使用提供的工具和脚本构建库,以便使用静态库或共享库变体。如果你计划使用库的头文件版本,这一步是不必要的。 
在 Linux 系统上,也可以使用包管理工具安装库。例如,在 Ubuntu 上,你可以使用 app-get 安装包含 Boost.Test 库的 libboost-test-dev 包,如下所示:
sudo apt-get install libboost-test-dev 
建议你查阅库的在线文档,了解在不同系统上的安装步骤。
要使用 Boost.Test 库的头文件版本创建你的第一个测试程序,请执行以下操作:
- 
创建一个新的、空的 C++ 项目。 
- 
根据你使用的开发环境进行必要的设置,以便将 Boost 的 main文件夹对项目可用,以便包含头文件。
- 
向项目中添加一个新的源文件,内容如下: #define BOOST_TEST_MODULE My first test module #include <boost/test/included/unit_test.hpp> BOOST_AUTO_TEST_CASE(first_test_function) { int a = 42; BOOST_TEST(a > 0); }
- 
如果你想要链接到共享库版本,那么还需要定义 BOOST_TEST_DYN_LINK宏。
- 
构建并运行项目。 
它是如何工作的...
Boost.Test 库可以与其它 Boost 库一起从 www.boost.org/ 下载。在这本书的这一版中,我使用了 1.83 版本,但讨论的这些功能可能适用于许多未来的版本。Test 库有三个变体:
- 
单个头文件:这使你能够在不构建库的情况下编写测试程序;你只需要包含一个头文件。它的限制是,你只能为模块有一个翻译单元;然而,你仍然可以将模块分割成多个头文件,以便将不同的测试套件分开到不同的文件中。 
- 
静态库:这使你能够将模块分割到不同的翻译单元中,但库需要首先作为一个静态库来构建。 
- 
共享库:这提供了与静态库相同的场景。然而,它有一个优点,即对于具有许多测试模块的程序,这个库只需链接一次,而不是每个模块都链接一次,从而减小了二进制文件的大小。但是,在这种情况下,共享库必须在运行时可用。 
为了简单起见,我们将在这本书中使用单个头文件变体。在静态库和共享库变体的情况下,你需要构建库。下载的存档包含构建库的脚本。然而,具体的步骤取决于平台和编译器;它们将不会在此处介绍,但可以在网上找到。
为了使用这个库,你需要理解几个术语和概念:
- 
测试模块是一个执行测试的程序。有两种类型的模块:单文件(当你使用单头文件变体时)和多文件(当你使用静态或共享变体时)。 
- 
测试断言是测试模块检查的条件。 
- 
测试用例是一组一个或多个测试断言,它由测试模块独立执行和监控,以便如果它失败或泄漏未捕获的异常,其他测试的执行将不会停止。 
- 
测试套件是一组一个或多个测试用例或测试套件。 
- 
测试单元是一个测试用例或测试套件。 
- 
测试树是测试单元的分层结构。在这个结构中,测试用例是叶子节点,测试套件是非叶子节点。 
- 
测试执行器是一个组件,给定一个测试树,执行必要的初始化、测试执行和结果报告。 
- 
测试报告是测试执行器从执行测试产生的报告。 
- 
测试日志是记录测试模块执行期间发生的所有事件的记录。 
- 
测试设置是负责初始化框架、构建测试树和单个测试用例设置的测试模块的一部分。 
- 
测试清理是负责清理操作的测试模块的一部分。 
- 
测试夹具是一对设置和清理操作,用于多个测试单元以避免重复代码。 
定义了这些概念之后,就可以解释前面列出的示例代码:
- 
#define BOOST_TEST_MODULE My first test module定义了一个模块初始化的占位符并为主测试套件设置了一个名称。这必须在包含任何库头文件之前定义。
- 
#include <boost/test/included/unit_test.hpp>包含单头文件库,该库包含所有其他必要的头文件。
- 
BOOST_AUTO_TEST_CASE(first_test_function)声明一个无参数的测试用例(first_test_function)并将其自动注册为包含在测试树中,作为封装测试套件的一部分。在这个例子中,测试套件是由BOOST_TEST_MODULE定义的主测试套件。
- 
BOOST_TEST(true);执行一个测试断言。
执行此测试模块的输出如下:
Running 1 test case...
*** No errors detected 
还有更多...
如果你不想库生成main()函数但想自己编写,那么在包含任何库头文件之前,你需要定义几个额外的宏 - BOOST_TEST_NO_MAIN和BOOST_TEST_ALTERNATIVE_INIT_API。然后,在你提供的main()函数中,通过提供默认的初始化函数init_unit_test()作为参数,调用默认的测试执行器unit_test_main(),如下代码片段所示:
#define BOOST_TEST_MODULE My first test module
#define BOOST_TEST_NO_MAIN
#define BOOST_TEST_ALTERNATIVE_INIT_API
#include <boost/test/included/unit_test.hpp>
BOOST_AUTO_TEST_CASE(first_test_function)
{
  int a = 42;
  BOOST_TEST(a > 0);
}
int main(int argc, char* argv[])
{
  return boost::unit_test::unit_test_main(init_unit_test, argc, argv);
} 
还可以自定义测试运行器的初始化函数。在这种情况下,必须删除 BOOST_TEST_MODULE 宏的定义,并编写一个不接受任何参数并返回 bool 值的初始化函数:
#define BOOST_TEST_NO_MAIN
#define BOOST_TEST_ALTERNATIVE_INIT_API
#include <boost/test/included/unit_test.hpp>
#include <iostream>
BOOST_AUTO_TEST_CASE(first_test_function)
{
  int a = 42;
  BOOST_TEST(a > 0);
}
bool custom_init_unit_test()
{
  std::cout << "test runner custom init\n";
  return true;
}
int main(int argc, char* argv[])
{
  return boost::unit_test::unit_test_main(
    custom_init_unit_test, argc, argv);
} 
可以自定义初始化函数,而不必自己编写 main() 函数。在这种情况下,不应定义 BOOST_TEST_NO_MAIN 宏,并且初始化函数应命名为 init_unit_test()。
参见
- 使用 Boost.Test 编写和调用测试,以了解如何使用 Boost.Test 库的单头版本创建测试套件和测试用例,以及如何运行测试
使用 Boost.Test 编写和调用测试
库提供了自动和手动两种方式来注册测试用例和测试套件,以便测试运行器执行。自动注册是最简单的方式,因为它允许你仅通过声明测试单元来构建测试树。在本食谱中,我们将了解如何使用库的单头版本创建测试套件和测试用例,以及如何运行测试。
准备工作
为了说明测试套件和测试用例的创建,我们将使用以下类,它代表一个三维点。此实现包含访问点属性的方法、比较运算符、流输出运算符以及修改点位置的方法:
class point3d
{
  int x_;
  int y_;
  int z_;
public:
  point3d(int const x = 0, 
          int const y = 0, 
          int const z = 0):x_(x), y_(y), z_(z) {}
  int x() const { return x_; }
  point3d& x(int const x) { x_ = x; return *this; }
  int y() const { return y_; }
  point3d& y(int const y) { y_ = y; return *this; }
  int z() const { return z_; }
  point3d& z(int const z) { z_ = z; return *this; }
  bool operator==(point3d const & pt) const
  {
    return x_ == pt.x_ && y_ == pt.y_ && z_ == pt.z_;
  }
  bool operator!=(point3d const & pt) const
  {
    return !(*this == pt);
  }
  bool operator<(point3d const & pt) const
  {
    return x_ < pt.x_ || y_ < pt.y_ || z_ < pt.z_;
  }
  friend std::ostream& operator<<(std::ostream& stream, 
                                  point3d const & pt)
  {
    stream << "(" << pt.x_ << "," << pt.y_ << "," << pt.z_ << ")";
    return stream;
  }
  void offset(int const offsetx, int const offsety, int const offsetz)
 {
    x_ += offsetx;
    y_ += offsety;
    z_ += offsetz;
  }
  static point3d origin() { return point3d{}; }
}; 
在继续之前,请注意,本食谱中的测试用例故意包含错误测试,以便它们产生失败。
如何操作...
使用以下宏来创建测试单元:
- 
要创建测试套件,使用 BOOST_AUTO_TEST_SUITE(name)和BOOST_AUTO_TEST_SUITE_END():BOOST_AUTO_TEST_SUITE(test_construction) // test cases BOOST_AUTO_TEST_SUITE_END()
- 
要创建测试用例,使用 BOOST_AUTO_TEST_CASE(name)。测试用例定义在BOOST_AUTO_TEST_SUITE(name)和BOOST_AUTO_TEST_SUITE_END()之间,如下面的代码片段所示:BOOST_AUTO_TEST_CASE(test_constructor) { auto p = point3d{ 1,2,3 }; BOOST_TEST(p.x() == 1); BOOST_TEST(p.y() == 2); BOOST_TEST(p.z() == 4); // will fail } BOOST_AUTO_TEST_CASE(test_origin) { auto p = point3d::origin(); BOOST_TEST(p.x() == 0); BOOST_TEST(p.y() == 0); BOOST_TEST(p.z() == 0); }
- 
要创建嵌套测试套件,在另一个测试套件内部定义一个测试套件: BOOST_AUTO_TEST_SUITE(test_operations) BOOST_AUTO_TEST_SUITE(test_methods) BOOST_AUTO_TEST_CASE(test_offset) { auto p = point3d{ 1,2,3 }; p.offset(1, 1, 1); BOOST_TEST(p.x() == 2); BOOST_TEST(p.y() == 3); BOOST_TEST(p.z() == 3); // will fail } BOOST_AUTO_TEST_SUITE_END() BOOST_AUTO_TEST_SUITE_END()
- 
要向测试单元添加装饰器,向测试单元的宏添加一个额外的参数。装饰器可以包括描述、标签、先决条件、依赖项、固定装置等。请参考以下代码片段,它说明了这一点: BOOST_AUTO_TEST_SUITE(test_operations) BOOST_AUTO_TEST_SUITE(test_operators) BOOST_AUTO_TEST_CASE( test_equal, *boost::unit_test::description("test operator==") *boost::unit_test::label("opeq")) { auto p1 = point3d{ 1,2,3 }; auto p2 = point3d{ 1,2,3 }; auto p3 = point3d{ 3,2,1 }; BOOST_TEST(p1 == p2); BOOST_TEST(p1 == p3); // will fail } BOOST_AUTO_TEST_CASE( test_not_equal, *boost::unit_test::description("test operator!=") *boost::unit_test::label("opeq") *boost::unit_test::depends_on( "test_operations/test_operators/test_equal")) { auto p1 = point3d{ 1,2,3 }; auto p2 = point3d{ 3,2,1 }; BOOST_TEST(p1 != p2); } BOOST_AUTO_TEST_CASE(test_less) { auto p1 = point3d{ 1,2,3 }; auto p2 = point3d{ 1,2,3 }; auto p3 = point3d{ 3,2,1 }; BOOST_TEST(!(p1 < p2)); BOOST_TEST(p1 < p3); } BOOST_AUTO_TEST_SUITE_END() BOOST_AUTO_TEST_SUITE_END()
要执行测试,执行以下操作(请注意,命令行是针对 Windows 的,但应该很容易替换为针对 Linux 或 macOS 的命令行):
- 
要执行整个测试树,不带任何参数运行程序(测试模块): chapter11bt_02.exe Running 6 test cases... f:/chapter11bt_02/main.cpp(12): error: in "test_construction/test_ constructor": check p.z() == 4 has failed [3 != 4] f:/chapter11bt_02/main.cpp(35): error: in "test_operations/test_ methods/test_offset": check p.z() == 3 has failed [4 != 3] f:/chapter11bt_02/main.cpp(55): error: in "test_operations/test_ operators/test_equal": check p1 == p3 has failed [(1,2,3) != (3,2,1)] *** 3 failures are detected in the test module "Testing point 3d"
- 
要执行单个测试套件,使用参数 run_test运行程序,指定测试套件的路径:chapter11bt_02.exe --run_test=test_construction Running 2 test cases... f:/chapter11bt_02/main.cpp(12): error: in "test_construction/test_ constructor": check p.z() == 4 has failed [3 != 4] *** 1 failure is detected in the test module "Testing point 3d"
- 
要执行单个测试用例,使用参数 run_test运行程序,指定测试用例的路径:chapter11bt_02.exe --run_test=test_construction/test_origin Running 1 test case... *** No errors detected
- 
要执行在相同标签下定义的多个测试套件和测试用例,使用参数 run_test运行程序,指定以@为前缀的标签名称:chapter11bt_02.exe --run_test=@opeq Running 2 test cases... f:/chapter11bt_02/main.cpp(56): error: in "test_operations/test_ operators/test_equal": check p1 == p3 has failed [(1,2,3) != (3,2,1)] *** 1 failure is detected in the test module "Testing point 3d"
它是如何工作的...
测试树是一系列测试用例和测试套件的层次结构,还包括了固定装置和额外的依赖项。测试套件可以包含一个或多个测试用例以及其他嵌套的测试套件。在相同文件或不同文件中,测试套件可以多次停止和重新启动,类似于命名空间。测试套件的自动注册使用宏BOOST_AUTO_TEST_SUITE,它需要一个名称,以及BOOST_AUTO_TEST_SUITE_END。测试用例的自动注册使用BOOST_AUTO_TEST_CASE。测试单元(无论是用例还是套件)成为最近测试套件的成员。在文件作用域级别定义的测试单元成为主测试套件的成员——由BOOST_TEST_MODULE声明创建的隐式测试套件。
测试套件和测试用例都可以用一系列属性装饰,这些属性会影响测试模块执行期间如何处理测试单元。目前支持的装饰器如下:
- 
depends_on: 这表示当前测试单元与指定测试单元之间的依赖关系。
- 
description: 这提供了测试单元的语义描述。
- 
enabled/disabled: 这些将测试单元的默认运行状态设置为true或false。
- 
enable_if<bool>: 这根据编译时表达式的评估结果,将测试单元的默认运行状态设置为true或false。
- 
expected_failures: 这表示测试单元的预期失败情况。
- 
fixture: 这指定了一对函数(启动和清理),在执行测试单元之前和之后调用。
- 
label: 使用这个,你可以将测试单元与一个标签关联起来。相同的标签可以用于多个测试单元,并且一个测试单元可以有多个标签。
- 
precondition: 这将一个谓词与测试单元关联起来,在运行时用于确定测试单元的运行状态。
- 
timeout: 指定单元测试的超时时间,以墙钟时间为准。如果测试持续时间超过指定的超时时间,则测试失败。
- 
tolerance: 这个装饰器指定了装饰测试单元中 FTP 浮点类型的默认比较容差。
如果测试用例的执行导致未处理的异常,框架将捕获该异常并以失败状态终止测试用例的执行。然而,框架提供了几个宏来测试特定的代码是否引发或未引发异常。有关更多信息,请参阅下一道菜谱,使用 Boost.Test 进行断言。
组成模块测试树的测试单元可以完全或部分执行。在两种情况下,要执行测试单元,请执行(二进制)程序,该程序代表测试模块。要仅执行某些测试单元,请使用--run_test命令行选项(或--t如果您想使用更短的名字)。此选项允许您过滤测试单元并指定路径或标签。路径是一系列测试套件和/或测试用例名称的序列,例如test_construction或test_operations/test_methods/test_offset。标签是与label装饰器定义的名称,并在run_test参数前加@。此参数是可重复的,这意味着您可以在其上指定多个过滤器。
参见
- 
开始使用 Boost.Test,了解如何安装 Boost.Test 框架以及如何创建一个简单的测试项目 
- 
使用 Boost.Test 进行断言,探索 Boost.Test 库中的丰富断言宏集 
使用 Boost.Test 进行断言
测试用例包含一个或多个测试。Boost.Test 库提供了一系列以宏形式存在的 API 来编写测试。在前面的配方中,您已经了解了一些关于BOOST_TEST宏的内容,这是库中最重要且最广泛使用的宏。在本配方中,我们将更详细地讨论如何使用BOOST_TEST宏。
准备就绪
您现在应该熟悉编写测试套件和测试用例,这是我们前面讨论的主题。
如何做到这一点...
以下列表显示了执行测试的一些最常用 API:
- 
BOOST_TEST以其纯形式用于大多数测试:int a = 2, b = 4; BOOST_TEST(a == b); BOOST_TEST(4.201 == 4.200); std::string s1{ "sample" }; std::string s2{ "text" }; BOOST_TEST(s1 == s2, "not equal");
- 
BOOST_TEST,与tolerance()操作符一起使用,用于指示浮点数比较的容差:BOOST_TEST(4.201 == 4.200, boost::test_tools::tolerance(0.001));
- 
BOOST_TEST,与per_element()操作符一起使用,用于执行容器(即使是不同类型)的元素级比较:std::vector<int> v{ 1,2,3 }; std::list<short> l{ 1,2,3 }; BOOST_TEST(v == l, boost::test_tools::per_element());
- 
BOOST_TEST,与三元运算符和逻辑||或&&的复合语句一起使用时,需要额外的括号:BOOST_TEST((a > 0 ? true : false)); BOOST_TEST((a > 2 && b < 5));
- 
BOOST_ERROR用于无条件失败测试并在报告中生成消息。这相当于BOOST_TEST(false, message):BOOST_ERROR("this test will fail");
- 
BOOST_TEST_WARN用于在测试失败时在报告中生成警告,而不会增加遇到的错误数量并停止测试用例的执行:BOOST_TEST_WARN(a == 4, "something is not right");
- 
BOOST_TEST_REQUIRE用于确保测试用例的先决条件得到满足;否则,将停止测试用例的执行:BOOST_TEST_REQUIRE(a == 4, "this is critical");
- 
BOOST_FAIL用于无条件停止测试用例的执行,增加遇到的错误数量,并在报告中生成消息。这相当于BOOST_TEST_REQUIRE(false, message):BOOST_FAIL("must be implemented");
- 
BOOST_IS_DEFINED用于检查在运行时是否定义了特定的预处理器符号。它与BOOST_TEST一起用于执行验证和记录:BOOST_TEST(BOOST_IS_DEFINED(UNICODE));
它是如何工作的...
该库定义了各种宏和操作符,用于执行测试断言。最常用的是 BOOST_TEST。此宏简单地评估一个表达式;如果失败,它会增加错误计数但继续执行测试用例。实际上它有三个变体:
- 
BOOST_TEST_CHECK与BOOST_TEST相同,用于执行检查,如前文所述。
- 
BOOST_TEST_WARN用于旨在提供信息的断言,而不会增加错误计数并停止测试用例的执行。
- 
BOOST_TEST_REQUIRE的目的是确保测试用例继续执行所需的先决条件得到满足。如果失败,此宏会增加错误计数并停止测试用例的执行。
测试宏的一般形式是 BOOST_TEST(statement)。此宏提供了丰富和灵活的报告功能。默认情况下,它不仅显示语句,还显示操作数的值,以便快速识别失败的原因。
然而,用户可以提供替代的失败描述;在这种情况下,消息将记录在测试报告中:
BOOST_TEST(a == b);
// error: in "regular_tests": check a == b has failed [2 != 4]
BOOST_TEST(a == b, "not equal");
// error: in "regular_tests": not equal 
此宏还允许您通过特殊支持来控制比较过程:
- 
第一个是浮点数比较,可以定义容差来测试相等性。 
- 
其次,它支持使用多种方法对容器进行比较:默认比较(使用重载的运算符 ==)、逐元素比较和字典序比较(使用字典序)。逐元素比较允许按容器的前向迭代器顺序比较不同类型的容器(如 vector 和 list),同时考虑容器的尺寸(这意味着它首先测试尺寸,只有当它们相等时,才会继续比较元素)。
- 
最后,它支持操作数的位比较。如果失败,框架会报告比较失败的位索引。 
BOOST_TEST 宏确实有一些限制。它不能与使用逗号分隔的复合语句一起使用,因为此类语句会被预处理器或三元运算符拦截和处理,以及使用逻辑运算符 || 和 && 的复合语句。后者的解决方案是使用另一对括号,如 BOOST_TEST((statement))。
有几个宏可用于测试在表达式评估过程中是否抛出了特定异常。在以下列表中,<level> 可以是 CHECK、WARN 或 REQUIRE:
- 
BOOST_<level>_NO_THROW(expr)检查expr表达式是否抛出异常。在expr的评估过程中抛出的任何异常都会被此断言捕获,并且不会传播到测试主体。如果发生任何异常,断言将失败。
- 
BOOST_<level>_THROW(expr, exception_type)检查是否从expr表达式引发了exception_type类型的异常。如果表达式expr没有引发任何异常,则断言失败。除了exception_type类型之外的异常不会被此断言捕获,并且可以传播到测试主体。测试用例中的未捕获异常会被执行监视器捕获,但它们会导致测试用例失败。
- 
BOOST_<level>_EXCEPTION(expr, exception_type, predicate)检查是否从expr表达式引发了exception_type类型的异常。如果是这样,它将表达式传递给谓词以进行进一步检查。如果没有引发异常或引发了不同于exception_type类型的异常,则断言的行为类似于BOOST_<level>_THROW。
本菜谱仅讨论了测试中最常见的 API 及其典型用法。然而,库提供了许多其他 API。有关进一步参考,请查阅在线文档。对于版本 1.83,请参阅 www.boost.org/doc/libs/1_83_0/libs/test/doc/html/index.html。
参见
- 使用 Boost.Test 编写和调用测试,以了解如何使用 Boost.Test 库的单头版本创建测试套件和测试用例,以及如何运行测试
在 Boost.Test 中使用夹具
测试模块越大,测试用例越相似,就越有可能有需要相同设置、清理和可能相同数据的测试用例。包含这些的组件称为测试夹具或测试上下文。夹具对于建立运行测试的良好定义的环境非常重要,以便结果可重复。示例可以包括在执行测试之前将一组特定文件复制到某个位置,并在测试之后删除它们,或者从特定的数据源加载数据。
Boost.Test 为测试用例、测试套件或模块(全局)提供了定义测试夹具的几种方法。在本菜谱中,我们将探讨夹具的工作原理。
准备工作
本菜谱中的示例使用以下类和函数来指定测试单元夹具:
struct global_fixture
{
   global_fixture()  { BOOST_TEST_MESSAGE("global setup"); }
   ~global_fixture() { BOOST_TEST_MESSAGE("global cleanup"); }
   int g{ 1 };
};
struct standard_fixture
{
  standard_fixture()  {BOOST_TEST_MESSAGE("setup");}
  ~standard_fixture() {BOOST_TEST_MESSAGE("cleanup");}
  int n {42};
};
struct extended_fixture
{
  std::string name;
  int         data;
  extended_fixture(std::string const & n = "") : name(n), data(0) 
  {
    BOOST_TEST_MESSAGE("setup "+ name);
  }
  ~extended_fixture()
  {
    BOOST_TEST_MESSAGE("cleanup "+ name);
  }
};
void fixture_setup()
{
  BOOST_TEST_MESSAGE("fixture setup");
}
void fixture_cleanup()
{
  BOOST_TEST_MESSAGE("fixture cleanup");
} 
前两个是类,其构造函数表示设置函数,析构函数表示清理函数。在示例末尾有一对函数,fixture_setup() 和 fixture_cleanup(),它们表示测试的设置和清理函数。
如何操作...
使用以下方法定义一个或多个测试单元的测试夹具:
- 
要为特定的测试用例定义夹具,请使用 BOOST_FIXTURE_TEST_CASE宏:BOOST_FIXTURE_TEST_CASE(test_case, extended_fixture) { data++; BOOST_TEST(data == 1); }
- 
要为测试套件中的所有测试用例定义一个夹具,请使用 BOOST_FIXTURE_TEST_SUITE:BOOST_FIXTURE_TEST_SUITE(suite1, extended_fixture) BOOST_AUTO_TEST_CASE(case1) { BOOST_TEST(data == 0); } BOOST_AUTO_TEST_CASE(case2) { data++; BOOST_TEST(data == 1); } BOOST_AUTO_TEST_SUITE_END()
- 
要为测试套件中的所有测试单元定义固定装置(除了一个或多个测试单元),请使用 BOOST_FIXTURE_TEST_SUITE。您可以使用BOOST_FIXTURE_TEST_CASE覆盖特定测试用例,对于嵌套测试套件使用BOOST_FIXTURE_TEST_SUITE:BOOST_FIXTURE_TEST_SUITE(suite2, extended_fixture) BOOST_AUTO_TEST_CASE(case1) { BOOST_TEST(data == 0); } BOOST_FIXTURE_TEST_CASE(case2, standard_fixture) { BOOST_TEST(n == 42); } BOOST_AUTO_TEST_SUITE_END()
- 
要为测试用例或测试套件定义多个固定装置,请使用带有 BOOST_AUTO_TEST_SUITE和BOOST_AUTO_TEST_CASE宏的boost::unit_test::fixture:BOOST_AUTO_TEST_CASE(test_case_multifix, * boost::unit_test::fixture<extended_fixture>(std::string("fix1")) * boost::unit_test::fixture<extended_fixture>(std::string("fix2")) * boost::unit_test::fixture<standard_fixture>()) { BOOST_TEST(true); }
- 
在固定装置的情况下,要使用自由函数作为设置和拆卸操作,请使用 boost::unit_test::fixture:BOOST_AUTO_TEST_CASE(test_case_funcfix, * boost::unit_test::fixture(&fixture_setup, &fixture_cleanup)) { BOOST_TEST(true); }
- 
要为模块定义固定装置,请使用 BOOST_GLOBAL_FIXTURE:BOOST_GLOBAL_FIXTURE(global_fixture);
它是如何工作的...
该库支持几种固定装置模型:
- 
类模型,其中构造函数充当设置函数,析构函数充当清理函数。扩展模型允许构造函数有一个参数。在前面的例子中, standard_fixture实现了第一种模型,而extended_fixture实现了第二种模型。
- 
一对自由函数:一个定义设置,另一个是可选的,实现清理代码。在前面的例子中,我们在讨论 fixture_setup()和fixture_cleanup()时遇到了这些。
将作为类实现的固定装置也可以有数据成员,并且这些成员对测试单元可用。如果为测试套件定义了固定装置,则它对该测试套件下所有分组测试单元隐式可用。然而,可能存在这种情况,即包含在这样一个测试套件中的测试单元可以重新定义固定装置。在这种情况下,定义在最近作用域中的固定装置是对测试单元可用的。
可以为测试单元定义多个固定装置。然而,这是通过boost::unit_test::fixture()装饰器完成的,而不是通过宏。在这种情况下,测试套件和测试用例是通过BOOST_TEST_SUITE/BOOST_AUTO_TEST_SUITE和BOOST_TEST_CASE/BOOST_AUTO_TEST_CASE宏定义的。多个fixture()装饰器可以通过operator *组合在一起,如前所述。此装饰器的目的是定义在测试单元执行前后要调用的设置和拆卸函数。它有几种形式,可以是成对的函数,也可以是类,其中构造函数和析构函数充当设置/拆卸函数。使用包含成员数据的类作为固定装置装饰器的缺点或可能是误导性的一部分是,这些成员将不可用于测试单元。
每当执行测试用例时,都会为每个测试用例构造一个新的固定装置对象,并在测试用例结束时销毁该对象。
固定状态不会在不同测试用例之间共享。因此,构造函数和析构函数会为每个测试用例调用一次。你必须确保这些特殊函数不包含仅应在每个模块中执行一次的代码。如果是这种情况,你应该为整个模块设置一个全局固定点。
全局固定点使用通用测试类模型(具有默认构造函数的模型);你可以定义任意数量的全局固定点(如果需要,允许你按类别组织设置和清理)。全局固定点使用 BOOST_GLOBAL_FIXTURE 宏定义,并且必须在测试文件作用域内定义(不在任何测试单元内部)。它们的作用是定义设置和清理函数,由类的构造函数和析构函数表示。如果类还定义了其他成员,例如数据,这些成员在测试单元中不可用:
BOOST_GLOBAL_FIXTURE(global_fixture);
BOOST_AUTO_TEST_CASE(test_case_globals)
{
   BOOST_TEST(g == 1); // error, g not accessible
BOOST_TEST(true);
} 
相关内容
- 使用 Boost.Test 编写和调用测试,了解如何使用 Boost.Test 库的单头版本创建测试套件和测试用例,以及如何运行测试
使用 Boost.Test 控制输出
框架为我们提供了自定义测试日志和测试报告中显示内容的能力,然后格式化结果。目前,支持两种格式:一种是人可读格式(或 HRF)和 XML(测试日志还有 JUNIT 格式)。然而,你可以创建并添加自己的格式。
人可读格式是指任何可以由人类自然读取的数据编码形式。用于此目的的文本,无论是以 ASCII 还是 Unicode 编码,都用于此目的。
输出中显示的配置可以在运行时通过命令行开关进行,也可以在编译时通过各种 API 进行。在测试执行期间,框架会收集日志中的所有事件。最后,它生成一个报告,该报告以不同级别的详细程度表示执行摘要。在失败的情况下,报告包含有关位置和原因的详细信息,包括实际值和预期值。这有助于开发者快速识别错误。在本菜谱中,我们将了解如何控制日志和报告中写入的内容以及格式;我们使用运行时的命令行选项来完成此操作。
准备工作
在本菜谱中展示的示例中,我们将使用以下测试模块:
#define BOOST_TEST_MODULE Controlling output
#include <boost/test/included/unit_test.hpp>
BOOST_AUTO_TEST_CASE(test_case)
{
  BOOST_TEST(true);
}
BOOST_AUTO_TEST_SUITE(test_suite)
BOOST_AUTO_TEST_CASE(test_case)
{
  int a = 42;
  BOOST_TEST(a == 0);
}
BOOST_AUTO_TEST_SUITE_END() 
下一个部分将展示如何通过命令行选项控制测试日志和测试报告的输出。
如何操作...
要控制测试日志的输出,请执行以下操作:
- 
使用 --log_format=<format>或-f <format>命令行选项来指定日志格式。可能的格式有HRF(默认值)、XML和JUNIT。
- 
使用 --log_level=<level>或-l <level>命令行选项来指定日志级别。可能的日志级别包括error(HRF 和 XML 的默认值)、warning、all和success(JUNIT 的默认值)。
- 
使用 --log_sink=<stream or file name>或-k <stream or file name>命令行选项来指定框架应写入测试日志的位置。可能的选项是stdout(HRF 和 XML 的默认值)、stderr或任意文件名(JUNIT 的默认值)。
要控制测试报告的输出,请执行以下操作:
- 
使用 --report_format=<format>或-m <format>命令行选项来指定报告格式。可能的格式是HRF(默认值)和XML。
- 
使用 --report_level=<format>或-r <format>命令行选项来指定报告级别。可能的格式是confirm(默认值)、no(无报告)、short和detailed。
- 
使用 --report_sink=<stream or file name>或-e <stream or file name>命令行选项来指定框架应写入报告日志的位置。可能的选项是stderr(默认值)、stdout或任意文件名。
它是如何工作的...
当您从控制台/终端运行测试模块时,您将看到测试日志和测试报告,测试报告紧随测试日志之后。对于前面显示的测试模块,默认输出如下。前三条线代表测试日志,而最后一条线代表测试报告:
Running 2 test cases...
f:/chapter11bt_05/main.cpp(14): error: in "test_suite/test_case": 
check a == 0 has failed [42 != 0]
*** 1 failure is detected in the test module "Controlling output" 
测试日志和测试报告的内容可以以多种格式提供。默认是 HRF;然而,框架也支持 XML,对于测试日志,支持 JUNIT 格式。这是一个为自动化工具设计的格式,例如持续构建或集成工具。除了这些选项之外,您可以通过实现自己的从boost::unit_test::unit_test_log_formatter派生的类来自定义测试日志的格式。
以下示例展示了如何使用 XML 格式化测试日志(第一个示例)和测试报告(第二个示例)(每个都加粗):
chapter11bt_05.exe -f XML
**<TestLog><Error file="f:/chapter11bt_05/main.cpp"** 
**line="14"><![CDATA[check a == 0 has failed [42 != 0]]]>**
**</Error></TestLog>**
*** 1 failure is detected in the test module "Controlling output"
chapter11bt_05.exe -m XML
Running 2 test cases...
f:/chapter11bt_05/main.cpp(14): error: in "test_suite/test_case": 
check a == 0 has failed [42 != 0]
**<TestResult><TestSuite name="Controlling output" result="failed"** 
**assertions_passed="1" assertions_failed="1" warnings_failed="0"** 
**expected_failures="0" test_cases_passed="1"** 
**test_cases_passed_with_warnings="0" test_cases_failed="1"** 
**test_cases_skipped="0" test_cases_aborted="0"></TestSuite>**
</TestResult> 
日志或报告级别表示输出的详细程度。以下表格显示了日志详细程度的可能值,按从低到高的顺序排列。表中的较高级别包括所有高于它的级别的消息:
| 级别 | 报告的消息 | 
|---|---|
| nothing | 没有日志记录。 | 
| fatal_error | 系统或用户致命错误以及所有在 REQUIRE级别描述失败的断言的消息(例如BOOST_TEST_REQUIRE和BOOST_REQUIRE_)。 | 
| system_error | 系统非致命错误。 | 
| cpp_exception | 未捕获的 C++异常。 | 
| error | CHECK级别失败的断言(BOOST_TEST和BOOST_CHECK_)。 | 
| warning | WARN级别失败的断言(BOOST_TEST_WARN和BOOST_WARN_)。 | 
| message | 由 BOOST_TEST_MESSAGE生成的消息。 | 
| test_suite | 每个测试单元的开始和结束状态的通知。 | 
| all/success | 所有消息,包括通过断言。 | 
表 11.2:日志详细程度的可能值
测试报告的可用格式在以下表格中描述:
| 级别 | 描述 | 
|---|---|
| no | 不生成报告。 | 
| confirm | 通过测试:*** 未检测到错误。跳过测试:*** <name>测试套件被跳过;请参阅标准输出以获取详细信息。中止测试:***<name>测试套件被中止;请参阅标准输出以获取详细信息。无失败断言的失败测试:*** 在<name>测试套件中检测到错误;请参阅标准输出以获取详细信息。失败测试:*** 在<name>测试套件中检测到 N 个失败。预期失败的失败测试:*** 在<name>测试套件中检测到 N 个失败(预期 M 个失败) | 
| detailed | 结果以分层方式报告(每个测试单元作为父测试单元的一部分进行报告),但只显示相关信息。没有失败断言的测试用例不会在报告中产生条目。测试用例/套件 <name>已通过/被跳过/被中止/失败,有 N 个断言中的 M 个通过/N 个断言中的 M 个失败/N 个警告中的 M 个失败/X 个预期的失败 | 
| short | 与 detailed类似,但只向主测试套件报告信息。 | 
表 11.3:测试报告的可用格式
标准输出流 (stdout) 是测试日志的默认写入位置,标准错误流 (stderr) 是测试报告的默认位置。然而,测试日志和测试报告都可以重定向到另一个流或文件。
除了这些选项之外,还可以使用 --report_memory_leaks_to=<文件名> 命令行选项指定一个单独的文件来报告内存泄漏。如果此选项不存在且检测到内存泄漏,它们将被报告到标准错误流。
更多...
除了本配方中讨论的选项之外,框架还提供了额外的编译时 API 来控制输出。有关这些 API 的全面描述以及本配方中描述的功能,请查看框架文档www.boost.org/doc/libs/1_83_0/libs/test/doc/html/index.html。
参见
- 
使用 Boost.Test 编写和调用测试,以了解如何使用 Boost.Test 库的单头版本创建测试套件和测试用例,以及如何运行测试 
- 
使用 Boost.Test 进行断言,以探索 Boost.Test 库中丰富的断言宏集 
开始使用 Google Test
Google Test 是 C++ 中最广泛使用的测试框架之一。Chromium 项目和 LLVM 编译器是使用它进行单元测试的项目之一。Google Test 允许开发者使用多个编译器在多个平台上编写单元测试。Google Test 是一个便携、轻量级的框架,它提供了一个简单而全面的 API,用于使用断言编写测试;在这里,测试被分组为测试套件,测试套件被分组为测试程序。
框架提供了有用的功能,例如重复执行测试多次,并在第一次失败时中断测试以调用调试器。其断言在启用或禁用异常的情况下都能正常工作。下一道菜将涵盖框架的最重要功能。这道菜将向您展示如何安装框架并设置您的第一个测试项目。
准备工作
Google Test 框架,就像 Boost.Test 一样,有一个基于宏的 API。尽管您只需要使用提供的宏来编写测试,但为了更好地使用框架,建议您了解宏。
如何操作...
为了设置您的环境以使用 Google Test,请执行以下操作:
- 
从 github.com/google/googletest克隆或下载 Git 仓库。
- 
如果您选择下载,下载完成后,请解压存档内容。 
- 
使用提供的构建脚本来构建框架。 
要使用 Google Test 创建您的第一个测试程序,请执行以下操作:
- 
创建一个新的空 C++ 项目。 
- 
根据您使用的开发环境进行必要的设置,以便将框架的头文件目录(称为 include)提供给项目以便包含头文件。
- 
将项目链接到 gtest共享库。
- 
向项目中添加一个包含以下内容的源文件: #include <gtest/gtest.h> TEST(FirstTestSuite, FirstTest) { int a = 42; ASSERT_TRUE(a > 0); } int main(int argc, char **argv) { testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); }
- 
构建并运行项目。 
它是如何工作的...
Google Test 框架提供了一套简单易用的宏,用于创建测试和编写断言。与 Boost.Test 等其他测试框架相比,测试的结构也得到了简化。测试被分组为测试套件,测试套件被分组为测试程序。
提及与术语相关的一些方面是很重要的。传统上,Google Test 并未使用 测试套件 这一术语。在 Google Test 中,测试用例基本上是一个测试套件,与 Boost.Test 中的测试套件相当。另一方面,测试函数相当于一个测试用例。由于这导致了混淆,Google Test 已经遵循了由 国际软件测试资格认证委员会(ISTQB)使用的通用术语,即测试用例和测试套件,并开始在其代码和文档中替换这些术语。在这本书中,我们将使用这些术语。
该框架提供了一套丰富的断言,包括致命和非致命断言,对异常处理提供了极大的支持,并且能够自定义测试执行方式和输出生成方式。然而,与 Boost.Test 库不同,Google Test 中的测试套件不能包含其他测试套件,只能包含测试函数。
框架的文档可在 GitHub 项目的页面上找到。对于本书的这一版,我使用了 Google Test 框架版本 1.14,但这里展示的代码与框架的先前版本兼容,并预期与框架的未来版本也兼容。如何做… 部分中展示的示例代码包含以下部分:
- 
#include <gtest/gtest.h>包含框架的主要头文件。
- 
TEST(FirstTestSuite, FirstTest)声明一个名为FirstTest的测试,作为名为FirstTestSuite的测试套件的一部分。这些名称必须是有效的 C++标识符,但不允许包含下划线。测试函数的实际名称是通过将测试套件的名称和测试名称连接起来,并在其中添加一个下划线来组成的。在我们的例子中,名称是FirstTestSuite_FirstTest。来自不同测试套件的测试可能具有相同的单个名称。测试函数没有参数,并返回void。可以将多个测试组合到同一个测试套件中。
- 
ASSERT_TRUE(a > 0);是一个断言宏,当条件评估为false时会产生致命错误,并从当前函数返回。框架定义了许多其他断言宏,我们将在 使用 Google Test 进行断言 菜单中看到。
- 
testing::InitGoogleTest(&argc, argv);初始化框架,必须在调用RUN_ALL_TESTS()之前执行。
- 
return RUN_ALL_TESTS();自动检测并调用使用TEST()或TEST_F()宏定义的所有测试。宏返回的值用作main()函数的返回值。这很重要,因为自动化测试服务根据main()函数返回的值来确定测试程序的结果,而不是打印到stdout或stderr流的输出。RUN_ALL_TESTS()宏只能调用一次;多次调用不支持,因为它与框架的一些高级功能冲突。
执行此测试程序将提供以下结果:
[==========] Running 1 test from 1 test suite.
[----------] Global test environment set-up.
[----------] 1 test from FirstTestCase
[ RUN      ] FirstTestCase.FirstTestFunction
[       OK ] FirstTestCase.FirstTestFunction (1 ms)
[----------] 1 test from FirstTestCase (1 ms total)
[----------] Global test environment tear-down
[==========] 1 test from 1 test suite ran. (2 ms total)
[  PASSED  ] 1 test. 
对于许多测试程序,main() 函数的内容与 如何做… 部分中显示的示例相同。为了避免编写这样的 main() 函数,框架提供了一个基本实现,您可以通过将程序与 gtest_main 共享库链接来使用它。
还有更多...
Google Test 框架也可以与其他测试框架一起使用。你可以使用其他测试框架,如 Boost.Test 或 CppUnit,来编写测试,并使用 Google Test 断言宏。为此,使用 --gtest_throw_on_failure 参数从代码或命令行设置 throw_on_failure 标志。或者,使用 GTEST_THROW_ON_FAILURE 环境变量并初始化框架,如下面的代码片段所示:
#include "gtest/gtest.h"
int main(int argc, char** argv)
{
  testing::GTEST_FLAG(throw_on_failure) = true;
  testing::InitGoogleTest(&argc, argv);
} 
当你启用 throw_on_failure 选项时,失败的断言将打印错误消息并抛出异常,该异常将被宿主测试框架捕获并视为失败。如果未启用异常,则失败的 Google Test 断言将告诉你的程序以非零代码退出,这同样会被宿主测试框架视为失败。
参见
- 
使用 Google Test 编写和调用测试,以了解如何使用 Google Test 库创建测试和测试套件,以及如何运行测试 
- 
使用 Google Test 进行断言,以探索 Google Test 库中的各种断言宏 
使用 Google Test 编写和调用测试
在之前的菜谱中,我们瞥见了使用 Google Test 框架编写简单测试需要什么。多个测试可以组合成一个测试套件,一个或多个测试套件可以组合成一个测试程序。在这个菜谱中,我们将看到如何创建和运行测试。
准备工作
对于这个菜谱中的示例代码,我们将使用在 使用 Boost.Test 编写和调用测试 菜谱中讨论的 point3d 类。
如何做到...
使用以下宏来创建测试:
- 
TEST(TestSuiteName, TestName)定义了一个名为TestName的测试,作为名为TestSuiteName的测试套件的一部分:TEST(TestConstruction, TestConstructor) { auto p = point3d{ 1,2,3 }; ASSERT_EQ(p.x(), 1); ASSERT_EQ(p.x(), 2); ASSERT_EQ(p.x(), 3); } TEST(TestConstruction, TestOrigin) { auto p = point3d::origin(); ASSERT_EQ(p.x(), 0); ASSERT_EQ(p.x(), 0); ASSERT_EQ(p.x(), 0); }
- 
TEST_F(TestSuiteWithFixture, TestName)定义了一个名为TestName的测试,作为使用TestSuiteWithFixture固定装置的测试套件的一部分。你可以在 使用 Google Test 的测试固定装置 菜谱中找到关于它是如何工作的详细信息。
要执行测试,请执行以下操作:
- 
使用 RUN_ALL_TESTS()宏来运行测试程序中定义的所有测试。这必须在框架初始化后从main()函数中只调用一次。
- 
使用 --gtest_filter=<filter>命令行选项来过滤要运行的测试。
- 
使用 --gtest_repeat=<count>命令行选项来重复执行所选测试指定的次数。
- 
使用 --gtest_break_on_failure命令行选项,当第一个测试失败时,将调试器附加到测试程序进行调试。
它是如何工作的...
可用于定义测试的宏有几个(作为测试用例的一部分)。最常见的是 TEST 和 TEST_F。后者与 fixtures 一起使用,将在 使用 Google Test 的测试 fixtures 菜单中详细讨论。用于定义测试的其他宏包括 TYPED_TEST 用于编写类型测试和 TYPED_TEST_P 用于编写类型参数化测试。然而,这些是更高级的主题,超出了本书的范围。TEST 和 TEST_F 宏接受两个参数:第一个是测试套件名称,第二个是测试名称。这两个参数形成测试的完整名称,并且它们必须是有效的 C++ 标识符;它们不应该包含下划线。不同的测试套件可以包含具有相同名称的测试(因为完整名称仍然是唯一的)。这两个宏都会自动将测试注册到框架中;因此,用户不需要显式输入来完成此操作。
测试可以失败或成功。如果断言失败或发生未捕获的异常,则测试失败。除了这两种情况外,测试总是成功的。
要调用测试,请调用 RUN_ALL_TESTS()。然而,你只能在测试程序中调用一次,并且只能在调用 testing::InitGoogleTest() 初始化框架之后进行。此宏会运行测试程序中的所有测试。然而,你可能只想运行一些测试。你可以通过设置名为 GTEST_FILTER 的环境变量并使用适当的过滤器,或者通过使用 --gtest_filter 标志将过滤器作为命令行参数来做到这一点。如果这两个中的任何一个存在,框架只会运行名称与过滤器完全匹配的测试。过滤器可以包含通配符:* 匹配任何字符串,? 符号匹配任何字符。使用连字符(-)引入负模式(应该省略的内容)。以下是一些过滤器的示例:
| 过滤器 | 描述 | 
|---|---|
| --gtest_filter=* | 运行所有测试 | 
| --gtest_filter=TestConstruction.* | 运行名为 TestConstruction的测试套件中的所有测试 | 
| --gtest_filter=TestOperations.*-TestOperations.TestLess | 运行名为 TestOperations的测试套件中的所有测试,除了名为TestLess的测试 | 
| --gtest_filter=*Operations*:*Construction* | 运行所有名称中包含 Operations或Construction的测试 | 
| --gtest_filter=Test? | 运行所有名称有 5 个字符且以 Test开头的测试,例如TestA、Test0或Test_。 | 
| --gtest_filter=Test?? | 运行所有名称有 6 个字符且以 Test开头的测试,例如TestAB、Test00或Test_Z。 | 
表 11.4:过滤器示例
以下列表是使用命令行参数 --gtest_filter=TestConstruction.*-TestConstruction.TestConstructor 调用包含前面显示的测试的测试程序时的输出:
Note: Google Test filter = TestConstruction.*-TestConstruction.TestConstructor
[==========] Running 1 test from 1 test suite.
[----------] Global test environment set-up.
[----------] 1 test from TestConstruction
[ RUN      ] TestConstruction.TestOrigin
[       OK ] TestConstruction.TestOrigin (0 ms)
[----------] 1 test from TestConstruction (0 ms total)
[----------] Global test environment tear-down
[==========] 1 test from 1 test suite ran. (2 ms total)
[  PASSED  ] 1 test. 
你可以通过在测试名称前加上 DISABLED_ 或在具有相同标识符的测试套件名称前加上前缀来禁用一些测试。在这种情况下,测试套件中的所有测试都将被禁用。以下是一个示例:
TEST(TestConstruction, DISABLED_TestConversionConstructor) 
{ /* ... */ }
TEST(DISABLED_TestComparisons, TestEquality) 
{ /* ... */ }
TEST(DISABLED_TestComparisons, TestInequality)
{ /* ... */ } 
这些测试都不会被执行。然而,你将在输出中收到一份报告,说明你有多个禁用的测试。
请记住,此功能仅用于临时禁用测试。当你需要执行一些会导致测试失败的代码更改,而你又没有时间立即修复它们时,这很有用。因此,应谨慎使用此功能。
参见
- 
Google Test 入门,了解如何安装 Google Test 框架以及如何创建一个简单的测试项目 
- 
使用 Google Test 进行断言,探索 Google Test 库中的各种断言宏 
- 
使用 Google Test 的测试夹具,了解如何在使用 Google Test 库时定义测试夹具 
使用 Google Test 进行断言
Google Test 框架提供了一套丰富的致命和非致命断言宏,它们类似于函数调用,用于验证测试代码。当这些断言失败时,框架会显示源文件、行号以及相关的错误信息(包括自定义错误信息),以帮助开发者快速识别失败的代码。我们已看到一些使用 ASSERT_TRUE 宏的简单示例;在本食谱中,我们将探讨其他可用的宏。
如何操作...
使用以下宏来验证测试代码:
- 
使用 ASSERT_TRUE(condition)或EXPECT_TRUE(condition)来检查条件是否为true,以及使用ASSERT_FALSE(condition)或EXPECT_FALSE(condition)来检查条件是否为false,以下代码展示了这一用法:EXPECT_TRUE(2 + 2 == 2 * 2); EXPECT_FALSE(1 == 2); ASSERT_TRUE(2 + 2 == 2 * 2); ASSERT_FALSE(1 == 2);
- 
使用 ASSERT_XX(val1, val2)或EXPECT_XX(val1, val2)来比较两个值,其中XX是以下之一:EQ(val1 == val2)、NE(val1 != val2)、LT(val1 < val2)、LE(val1 <= val2)、GT(val1 > val2)或GE(val1 >= val2)。以下代码展示了这一用法:auto a = 42, b = 10; EXPECT_EQ(a, 42); EXPECT_NE(a, b); EXPECT_LT(b, a); EXPECT_LE(b, 11); EXPECT_GT(a, b); EXPECT_GE(b, 10);
- 
使用 ASSERT_STRXX(str1, str2)或EXPECT_STRXX(str1, str2)来比较两个以 null 结尾的字符串,其中XX是以下之一:EQ(字符串内容相同)、NE(字符串内容不同)、CASEEQ(忽略大小写时字符串内容相同)和CASENE(忽略大小写时字符串内容不同)。以下代码片段展示了这一用法:auto str = "sample"; EXPECT_STREQ(str, "sample"); EXPECT_STRNE(str, "simple"); ASSERT_STRCASEEQ(str, "SAMPLE"); ASSERT_STRCASENE(str, "SIMPLE");
- 
使用 ASSERT_FLOAT_EQ(val1, val2)或EXPECT_FLOAT_EQ(val1, val2)来检查两个float值是否几乎相等,以及使用ASSERT_DOUBLE_EQ(val1, val2)或EXPECT_DOUBLE_EQ(val1, val2)来检查两个double值是否几乎相等;它们之间的差异不应超过 4 ULP(最后一位单位)。使用ASSERT_NEAR(val1, val2, abserr)来检查两个值之间的差异是否不大于指定的绝对值:EXPECT_FLOAT_EQ(1.9999999f, 1.9999998f); ASSERT_FLOAT_EQ(1.9999999f, 1.9999998f);
- 
使用 ASSERT_THROW(statement, exception_type)或EXPECT_THROW(statement, exception_type)来检查语句是否抛出指定类型的异常,使用ASSERT_ANY_THROW(statement)或EXPECT_ANY_THROW(statement)来检查语句是否抛出任何类型的异常,以及使用ASSERT_NO_THROW(statement)或EXPECT_NO_THROW(statement)来检查语句是否抛出任何异常:void function_that_throws() { throw std::runtime_error("error"); } void function_no_throw() { } TEST(TestAssertions, Exceptions) { EXPECT_THROW(function_that_throws(), std::runtime_error); EXPECT_ANY_THROW(function_that_throws()); EXPECT_NO_THROW(function_no_throw()); ASSERT_THROW(function_that_throws(), std::runtime_error); ASSERT_ANY_THROW(function_that_throws()); ASSERT_NO_THROW(function_no_throw()); }
- 
使用 ASSERT_PRED1(pred, val)或EXPECT_PRED1(pred, val)来检查pred(val)是否返回true,使用ASSERT_PRED2(pred, val1, val2)或EXPECT_PRED2(pred, val1, val2)来检查pred(val1, val2)是否返回true,依此类推;用于n-元谓词函数或函数对象:bool is_positive(int const val) { return val != 0; } bool is_double(int const val1, int const val2) { return val2 + val2 == val1; } TEST(TestAssertions, Predicates) { EXPECT_PRED1(is_positive, 42); EXPECT_PRED2(is_double, 42, 21); ASSERT_PRED1(is_positive, 42); ASSERT_PRED2(is_double, 42, 21); }
- 
使用 ASSERT_HRESULT_SUCCEEDED(expr)或EXPECT_HRESULT_SUCCEEDED(expr)来检查expr是否是成功的HRESULT,以及使用ASSERT_HRESULT_FAILED(expr)或EXPECT_HRESULT_FAILED(expr)来检查expr是否是失败的HRESULT。这些断言旨在在 Windows 上使用。
- 
使用 FAIL()生成致命错误,使用ADD_FAILURE()或ADD_FAILURE_AT(filename, line)生成非致命错误:ADD_FAILURE(); ADD_FAILURE_AT(__FILE__, __LINE__);
它是如何工作的……
所有这些断言都有两种版本:
- 
ASSERT_*:这会生成致命错误,阻止当前测试函数的进一步执行。
- 
EXPECT_*:这会生成非致命错误,这意味着即使断言失败,测试函数的执行也会继续。
如果不满足条件不是严重错误,或者您希望测试函数继续执行以获取尽可能多的错误信息,请使用EXPECT_*断言。在其他情况下,请使用测试断言的ASSERT_*版本。
您可以在框架的在线文档中找到这里展示的断言的详细信息,该文档可在 GitHub 上找到:github.com/google/googletest;这是项目所在的位置。然而,关于浮点数比较有一个特别的注意事项。由于舍入误差(分数部分不能表示为二的反幂的有限和),浮点数值不会完全匹配。因此,比较应该在相对误差范围内进行。宏ASSERT_EQ/EXPECT_EQ不适用于比较浮点数,框架提供了一套其他的断言。ASSERT_FLOAT_EQ/ASSERT_DOUBLE_EQ和EXPECT_FLOAT_EQ/EXPECT_DOUBLE_EQ使用默认误差为 4 ULP 进行比较。
ULP 是浮点数之间间隔的度量单位,即如果它是 1,则表示最不显著数字的值。有关更多信息,请阅读 Bruce Dawson 撰写的比较浮点数,2012 年版文章:randomascii.wordpress.com/2012/02/25/comparing-floating-point-numbers-2012-edition/。
参见
- 使用 Google Test 编写和调用测试,了解如何使用 Google Test 库创建测试和测试套件,以及如何运行测试
使用 Google Test 的测试用例
框架提供了支持,将测试用例作为可重用组件用于测试套件中的所有测试。它还提供了支持,用于设置测试将运行的全球环境。在本食谱中,您将找到逐步说明如何定义和使用测试用例,以及如何设置测试环境。
准备工作
您现在应该熟悉使用 Google Test 框架编写和调用测试,这是在本章前面提到的主题,特别是在 使用 Google Test 编写和调用测试 食谱中。
如何操作...
要创建和使用测试用例,请执行以下操作:
- 
创建一个从 testing::Test类派生的类:class TestFixture : public testing::Test { };
- 
使用构造函数来初始化测试用例,并使用析构函数来清理它: protected: TestFixture() { std::cout << "constructing fixture\n"; data.resize(10); std::iota(std::begin(data), std::end(data), 1); } ~TestFixture() { std::cout << "destroying fixture\n"; }
- 
或者,您也可以重写虚拟方法 SetUp()和TearDown()以达到相同的目的。
- 
向类中添加成员数据和函数,以便它们对测试可用: protected: std::vector<int> data;
- 
使用 TEST_F宏定义使用测试用例的测试,并将测试用例类名指定为测试套件名称:TEST_F(TestFixture, TestData) { ASSERT_EQ(data.size(), 10); ASSERT_EQ(data[0], 1); ASSERT_EQ(data[data.size()-1], data.size()); }
要自定义运行测试的环境设置,请执行以下操作:
- 
创建一个从 testing::Environment派生的类:class TestEnvironment : public testing::Environment { };
- 
重写虚拟方法 SetUp()和TearDown()以执行设置和清理操作:public: virtual void SetUp() override { std::cout << "environment setup\n"; } virtual void TearDown() override { std::cout << "environment cleanup\n"; } int n{ 42 };
- 
在调用 RUN_ALL_TESTS()之前,通过调用testing::AddGlobalTestEnvironment()来注册环境:int main(int argc, char **argv) { testing::InitGoogleTest(&argc, argv); testing::AddGlobalTestEnvironment(new TestEnvironment{}); return RUN_ALL_TESTS(); }
它是如何工作的...
文本测试用例允许用户在多个测试之间共享数据配置。测试用例对象在测试之间不共享。对于与文本函数关联的每个测试,都会创建不同的测试用例对象。框架为来自测试用例的每个测试执行以下操作:
- 
创建一个新的测试用例对象。 
- 
调用其 SetUp()虚拟方法。
- 
运行测试。 
- 
调用测试用例的 TearDown()虚拟方法。
- 
销毁测试用例对象。 
您可以通过两种方式设置和清理测试用例对象:使用构造函数和析构函数,或者使用 SetUp() 和 TearDown() 虚拟方法。在大多数情况下,前者是首选的方法。虚拟方法的使用适用于几种情况:
- 
当清理操作抛出异常时,因为不允许异常离开析构函数。 
- 
如果在清理过程中需要使用断言宏,并且使用了 --gtest_throw_on_failure标志,该标志用于确定在发生失败时抛出的宏。
- 
如果需要调用虚拟方法(这些方法可能在派生类中被重写),因为虚拟调用不应从构造函数或析构函数中调用。 
使用测试用例的测试必须使用 TEST_F 宏(其中 _F 代表测试用例)。尝试使用 TEST 宏声明它们将生成编译器错误。
运行测试的环境也可以进行定制。机制类似于测试夹具:您从基类 testing::Environment 派生,并重写 SetUp() 和 TearDown() 虚拟函数。这些派生环境类的实例必须通过调用 testing::AddGlobalTestEnvironment() 在框架中进行注册;然而,这必须在运行测试之前完成。您可以注册任意多个实例,在这种情况下,SetUp() 方法将按注册顺序调用对象,而 TearDown() 方法将按相反的顺序调用。您必须将动态实例化的对象传递给此函数。框架将接管对象,并在程序终止前删除它们;因此,请不要自行删除。
环境对象对测试不可用,也不打算为测试提供数据。它们的目的在于为运行测试定制全局环境。
参见
- 使用 Google Test 编写和调用测试,了解如何使用 Google Test 库创建测试和测试套件,以及如何运行测试
使用 Google Test 控制输出
默认情况下,Google Test 程序的输出流向标准流,以可读的格式打印。框架提供了几个选项来自定义输出,包括以基于 JUNIT 的格式将 XML 打印到磁盘文件。本菜谱将探讨可用于控制输出的选项。
准备工作
为了本菜谱的目的,让我们考虑以下测试程序:
#include <gtest/gtest.h>
TEST(Sample, Test)
{
  auto a = 42;
  ASSERT_EQ(a, 0);
}
int main(int argc, char **argv)
{
  testing::InitGoogleTest(&argc, argv);
  return RUN_ALL_TESTS();
} 
其输出如下:
[==========] Running 1 test from 1 test suite.
[----------] Global test environment set-up.
[----------] 1 test from Sample
[ RUN      ] Sample.Test
f:\chapter11gt_05\main.cpp(6): error: Expected equality of these values:
  a
    Which is: 42
  0
[  FAILED  ] Sample.Test (1 ms)
[----------] 1 test from Sample (1 ms total)
[----------] Global test environment tear-down
[==========] 1 test from 1 test suite ran. (3 ms total)
[  PASSED  ] 0 tests.
[  FAILED  ] 1 test, listed below:
[  FAILED  ] Sample.Test
 1 FAILED TEST 
我们将使用这个简单的测试程序来演示我们可以用来控制程序输出的各种选项,这些选项在以下部分中进行了示例。
如何做...
要控制测试程序的输出,您可以:
- 
使用 --gtest_output命令行选项或带有xml:filepath字符串的GTEST_OUTPUT环境变量来指定要写入 XML 报告的文件位置:chapter11gt_05.exe --gtest_output=xml:report.xml <?xml version="1.0" encoding="UTF-8"?> <testsuites tests="1" failures="1" disabled="0" errors="0" time="0.007" timestamp="2020-05-18T19:00:17" name="AllTests"> <testsuite name="Sample" tests="1" failures="1" disabled="0" errors="0" time="0.002" timestamp="2020-05-18T19:00:17"> <testcase name="Test" status="run" result="completed" time="0" timestamp="2020-05-18T19:00:17" classname="Sample"> <failure message="f:\chapter11gt_05\main.cpp:6
Expected equality of these values:
 a
 Which is: 42
 0
" type=""><![CDATA[f:\chapter11gt_05\main.cpp:6 Expected equality of these values: a Which is: 42 0 ]]></failure> </testcase> </testsuite> </testsuites>
- 
使用 --gtest_color命令行选项或GTEST_COLOR环境变量,并指定auto、yes或no以指示报告是否应使用颜色打印到终端:chapter11gt_05.exe --gtest_color=no
- 
使用 --gtest_print_time命令行选项或带有值0的GTEST_PRINT_TIME环境变量来抑制打印每个测试执行所需的时间:chapter11gt_05.exe --gtest_print_time=0 [==========] Running 1 test from 1 test suite. [----------] Global test environment set-up. [----------] 1 test from Sample [ RUN ] Sample.Test f:\chapter11gt_05\main.cpp(6): error: Expected equality of these values: a Which is: 42 0 [ FAILED ] Sample.Test [----------] Global test environment tear-down [==========] 1 test from 1 test suite ran. [ PASSED ] 0 tests. [ FAILED ] 1 test, listed below: [ FAILED ] Sample.Test 1 FAILED TEST
它是如何工作的...
以 XML 格式生成报告不会影响打印到终端的易读报告。输出路径可以指示文件、目录(在这种情况下,将创建一个以可执行文件命名的文件 - 如果之前运行已存在,则通过在后面添加数字创建一个新名称的文件),或者无,在这种情况下,报告将写入当前目录中名为 test_detail.xml 的文件。
XML 报告格式基于 JUNITReport Ant 任务,并包含以下主要元素:
- 
<testsuites>:这是根元素,对应整个测试程序。
- 
<testsuite>:这对应于一个测试套件。
- 
<testcase>:这对应于一个测试函数,因为 Google Test 函数在其他框架中相当于测试用例。
默认情况下,框架会报告每个测试执行所需的时间。可以使用 --gtest_print_time 命令行选项或 GTEST_PRINT_TIME 环境变量来抑制此功能,如前所述。
参见
- 
使用 Google Test 编写和调用测试,查看如何使用 Google Test 库创建测试和测试套件,以及如何运行测试 
- 
使用 Google Test 的测试夹具,学习如何在使用 Google Test 库时定义测试夹具 
开始使用 Catch2
Catch2 是一个用于 C++ 和 Objective-C 的多范式测试框架。Catch2 的名字沿袭自 Catch,这是框架的第一个版本,代表 C++ Automated Test Cases in Headers。它允许开发者使用传统的测试函数分组在测试用例中的风格或带有 given-when-then 部分的 行为驱动开发(BDD) 风格来编写测试。测试是自动注册的,并且框架提供了几个断言宏;在这些宏中,使用得最多的是两个:一个是致命的(即,REQUIRE)和一个非致命的(即,CHECK)。它们对左右两边的表达式进行分解,并在失败时记录。与第一个版本不同,Catch2 不再支持 C++03。Catch2 的当前版本是 v3,与 Catch2 v2 相比有一些重大变化,例如,库不再是单头库,而是作为一个常规库(需要编译)工作,并需要一个 C++14 编译器。
在本章剩余的食谱中,我们将学习如何使用 Catch2 版本 3 编写单元测试。
准备工作
Catch2 测试框架有一个基于宏的 API。虽然你只需要使用提供的宏来编写测试,但如果想更好地使用该框架,建议对宏有一个良好的理解。
如何做...
为了设置你的环境以使用 Catch2 测试框架,请执行以下操作:
- 
从 github.com/catchorg/Catch2克隆或下载 Git 仓库。
- 
下载仓库后,解压缩存档内容。 
要使用 Catch 2 的 v3 版本,你有两种选择:
- 
在你的测试项目中使用合并(混合)的库和源文件。这些文件被称为 catch_amalgamated.hpp和catch_amalgamated.cpp。它们位于 Catch2 库的extras文件夹中,如果你想的话,可以将它们复制到你的测试项目中。这样做的好处是,你不必处理 CMake 脚本,但代价是增加了构建时间。
- 
使用 CMake 将 Catch2 添加为你的项目的静态库。 
要使用 Catch2 和其合并文件创建您的第一个测试程序,请执行以下操作:
- 
创建一个新的空 C++ 项目。 
- 
将 Catch2 库的 extras文件夹中的catch_amalgamated.hpp和catch_amalgamated.cpp文件复制到您的测试项目中。
- 
将 catch_amalgamated.cpp源文件添加到您的项目中,与其他源文件(包含测试)一起编译。
- 
向项目中添加一个新源文件,内容如下: #include "catch_amalgamated.hpp" TEST_CASE("first_test_case", "[learn][catch]") { SECTION("first_test_function") { auto i{ 42 }; REQUIRE(i == 42); } }
- 
构建并运行项目。 
要使用 CMake 集成创建您的第一个 Catch2 测试程序,请执行以下操作:
- 
打开控制台/命令提示符,并将目录更改为克隆/解压缩的 Catch2 文件的位置。 
- 
使用以下命令构建库。在 Unix 系统上运行: cmake -Bbuild -H. -DBUILD_TESTING=OFF sudo cmake --build build/ --target instal在 Windows 系统上,从具有管理员权限的命令提示符中执行以下命令: cmake -Bbuild -H. -DBUILD_TESTING=OFF cmake --build build/ --target instal
- 
为 C++ 测试项目创建一个新的文件夹(称为 Test)。
- 
向此文件夹添加一个新源文件(称为 main.cpp),内容如下:#include <catch2/catch_test_macros.hpp> TEST_CASE("first_test_case", "[learn][catch]") { SECTION("first_test_function") { auto i{ 42 }; REQUIRE(i == 42); } }
- 
在 Test文件夹中添加一个新的CMakeLists.txtCMake 文件,内容如下:find_package(Catch2 3 REQUIRED) add_executable(Test main.cpp) target_link_libraries(Test PRIVATE Catch2::Catch2WithMain)
- 
运行 cmake.exe以生成/构建您的项目。
使用 CMake 设置项目有多种方式。在这个菜谱中,我提供了一个最小示例,它有效,您也可以在 GitHub 仓库的源文件中找到它。熟悉 CMake 的读者可能会找到比这里提供的方法更好的方法。您可以从在线资源中了解更多关于 CMake 的信息。
它是如何工作的...
Catch2 允许开发者将测试用例编写为自注册函数;它甚至可以提供 main() 函数的默认实现,这样您就可以专注于测试代码并编写更少的设置代码。测试用例被划分为单独运行的章节。该框架不遵循 setup-test-teardown 架构的风格。相反,测试用例部分(或者更确切地说,最内层的部分,因为部分可以嵌套)是执行的单位,以及它们的封装部分。这使得固定装置变得不再需要,因为数据和设置以及拆卸代码可以在多个级别上重用。
测试用例和部分使用字符串标识,而不是标识符(如大多数测试框架中那样)。测试用例也可以被标记,以便可以根据标记执行或列出测试。测试结果以文本可读格式打印;然而,它们也可以导出为 XML 格式,使用 Catch2 特定的模式或 JUNIT ANT 模式,以便轻松集成到持续交付系统中。测试的执行可以参数化,在失败时中断(在 Windows 和 macOS 上),这样您就可以附加调试器并检查程序。
该框架易于安装和使用。正如在 如何做… 部分中看到的那样,有两种替代方案:
- 
使用合并的文件 catch_amalgamated.hpp和catch_amalgamated_cpp。这些是所有头文件和源文件的合并。使用它们的优点是,你不必担心构建 Catch2 库。你只需要将这些文件复制到你的目标位置(通常在项目内部),在你的包含测试的文件中包含catch_amalgamated.hpp头文件,并与其他源文件一起构建catch_amalgamated.cpp。使用这种方法的不利之处是增加了构建时间。
- 
将 Catch2 作为静态库使用。这要求你在使用之前构建库。你可以明确地将头文件和 lib文件添加到你的项目中,或者你可以使用 CMake 来完成这个任务。这种方法的优势是减少了构建时间。
上一节中展示的示例代码有以下部分:
- 
#include "catch_amalgamated.hpp"包含了库的合并头文件,这是一个所有库头文件的合并。另一方面,如果你使用的是构建版本,你只需要包含你需要的特定头文件,例如<catch2/catch_test_macros.hpp>。你可以包含<cathc2/catch_all.hpp>,但这将包含所有库头文件,这并不建议。一般来说,你应该只包含你需要的头文件。
- 
TEST_CASE("first_test_case", "[learn][catch]")定义了一个名为first_test_case的测试用例,它有两个关联的标签:learn和catch。标签用于选择运行或仅列出测试用例。多个测试用例可以带有相同的标签。
- 
SECTION("first_test_function")定义了一个部分,即一个测试函数,称为first_test_function,作为外部测试用例的一部分。
- 
REQUIRE(i == 42);是一个断言,告诉测试如果条件不满足则测试失败。
运行此程序的结果如下:
=========================================================
All tests passed (1 assertion in 1 test cases) 
还有更多...
如前所述,该框架使我们能够使用带有 给-当-然后 部分的 BDD 风格编写测试。这是通过使用几个别名实现的:SCENARIO 对应于 TEST_CASE 和 GIVE、WHEN、AND_WHEN、THEN 和 AND_THEN 对应于 SECTION。使用这种风格,我们可以重写前面展示的测试,如下所示:
SCENARIO("first_scenario", "[learn][catch]")
{
  GIVEN("an integer")
  {
    auto i = 0;
    WHEN("assigned a value")
    {
      i = 42;
      THEN("the value can be read back")
      {
        REQUIRE(i == 42);
      }
    }
  }
} 
当程序成功执行时,它将打印以下输出:
=========================================================
All tests passed (1 assertion in 1 test cases) 
然而,在失败的情况下(假设我们得到了错误的条件:i == 0),失败的表达式以及左右两侧的值将在输出中打印出来,如下面的代码片段所示:
---------------------------------------------------------------
f:\chapter11ca_01\main.cpp(11)
...............................................................
f:\chapter11ca_01\main.cpp(13): FAILED:
  REQUIRE( i == 0 )
with expansion:
  42 == 0
===============================================================
test cases: 1 | 1 failed
assertions: 1 | 1 failed 
这里展示的输出,以及在下述食谱中的其他代码片段,已经从实际的控制台输出中略微裁剪或压缩,以便更容易地在本书的页面中列出。
参见
- 
使用 Catch2 编写和调用测试,以了解如何使用 Catch2 库创建测试,无论是基于测试用例的传统风格还是基于场景的 BDD 风格,以及如何运行测试 
- 
使用 Catch2 进行断言,以探索 Catch2 库中的各种断言宏 
使用 Catch2 编写和调用测试
Catch2 框架允许你使用传统的测试用例和测试函数风格或带有场景和 given-when-then 部分的 BDD 风格来编写测试。测试被定义为测试用例的独立部分,可以嵌套到你想要的深度。无论你更喜欢哪种风格,测试都只使用两个基本宏来定义。这个配方将展示这些宏是什么以及它们是如何工作的。
如何操作...
要使用传统的测试用例和测试函数风格编写测试,请这样做:
- 
使用 TEST_CASE宏定义一个带有名称(作为字符串)的测试用例,可选地,一个与其关联的标签列表:TEST_CASE("test construction", "[create]") { // define sections here }
- 
使用 SECTION宏在测试用例内部定义一个测试函数,名称作为字符串:TEST_CASE("test construction", "[create]") { SECTION("test constructor") { auto p = point3d{ 1,2,3 }; REQUIRE(p.x() == 1); REQUIRE(p.y() == 2); REQUIRE(p.z() == 4); } }
- 
如果你想重用设置和清理代码或以分层结构组织测试,请定义嵌套部分: TEST_CASE("test operations", "[modify]") { SECTION("test methods") { SECTION("test offset") { auto p = point3d{ 1,2,3 }; p.offset(1, 1, 1); REQUIRE(p.x() == 2); REQUIRE(p.y() == 3); REQUIRE(p.z() == 3); } } }
要使用 BDD 风格编写测试,请这样做:
- 
使用 SCENARIO宏定义场景,指定其名称:SCENARIO("modify existing object") { // define sections here }
- 
在场景内部使用 GIVEN、WHEN和THEN宏定义嵌套部分,为每个部分指定一个名称:SCENARIO("modify existing object") { GIVEN("a default constructed point") { auto p = point3d{}; REQUIRE(p.x() == 0); REQUIRE(p.y() == 0); REQUIRE(p.z() == 0); WHEN("increased with 1 unit on all dimensions") { p.offset(1, 1, 1); THEN("all coordinates are equal to 1") { REQUIRE(p.x() == 1); REQUIRE(p.y() == 1); REQUIRE(p.z() == 1); } } } }
要执行测试,请执行以下操作:
- 
要执行程序中的所有测试(除了隐藏的测试),运行测试程序而不带任何命令行参数(如下述代码中描述的)。 
- 
要执行特定的一组测试用例,提供一个过滤器作为命令行参数。这可以包含测试用例名称、通配符、标签名称和标签表达式: chapter11ca_02.exe "test construction" test construction test constructor ------------------------------------------------- f:\chapter11ca_02\main.cpp(7) ................................................. f:\chapter11ca_02\main.cpp(12): FAILED: REQUIRE( p.z() == 4 ) with expansion: 3 == 4 ================================================= test cases: 1 | 1 failed assertions: 6 | 5 passed | 1 failed
- 
要执行特定的部分(或一系列部分),使用带有部分名称的命令行参数 --section或-c(可以多次使用以执行多个部分):chapter11ca_02.exe "test construction" --section "test origin" Filters: test construction ================================================== All tests passed (3 assertions in 1 test case)
- 
要指定测试用例应运行的顺序,使用命令行参数 --order并选择以下值之一:decl(声明顺序),lex(按名称的字典顺序),或rand(使用std::random_shuffle()确定的随机顺序)。以下是一个示例:chapter11ca_02.exe --order lex
它是如何工作的...
测试用例会自动注册,不需要开发人员做任何额外工作来设置测试程序,除了定义测试用例和测试函数。测试函数定义为测试用例的部分(使用 SECTION 宏),并且可以嵌套。
节的嵌套深度没有限制。测试用例和测试函数(从现在起将被称为节),形成一个树状结构,测试用例位于根节点,最内层的节作为叶子。当测试程序运行时,执行的是叶子节。每个叶子节都是独立于其他叶子节执行的。然而,执行路径从根测试用例开始,向下继续,直到最内层的节。路径上遇到的所有代码在每次运行时都会完全执行。这意味着当多个节共享公共代码(来自父节或测试用例)时,相同的代码为每个节执行一次,执行之间不共享任何数据。这在一方面消除了对特殊夹具方法的需求。另一方面,它为每个节(路径上遇到的所有内容)提供了多个夹具,这是许多测试框架所缺少的功能。
编写测试用例的 BDD 风格由相同的两个宏提供支持,即TEST_CASE和SECTION,以及测试节的能力。实际上,宏SCENARIO是对TEST_CASE的重定义,而GIVEN、WHEN、AND_WHEN、THEN和AND_THEN是对SECTION的重定义:
#define SCENARIO( ... ) TEST_CASE( "Scenario: " __VA_ARGS__ )
#define GIVEN(desc)     INTERNAL_CATCH_DYNAMIC_SECTION("    Given: " << desc)
#define AND_GIVEN(desc) INTERNAL_CATCH_DYNAMIC_SECTION("And given: " << desc)
#define WHEN(desc)      INTERNAL_CATCH_DYNAMIC_SECTION("     When: " << desc)
#define AND_WHEN(desc)  INTERNAL_CATCH_DYNAMIC_SECTION(" And when: " << desc)
#define THEN(desc)      INTERNAL_CATCH_DYNAMIC_SECTION("     Then: " << desc)
#define AND_THEN(desc)  INTERNAL_CATCH_DYNAMIC_SECTION("      And: " << desc) 
当你执行测试程序时,所有定义的测试都会运行。然而,这排除了隐藏的测试,这些测试要么使用以./开头的名称指定,要么使用以点开头的标签指定。也可以通过提供命令行参数[.]或[hide]来强制运行隐藏的测试。
可以对要执行的测试用例进行过滤。这可以通过名称或标签来完成。以下表格显示了其中的一些可能选项:
| 参数 | 描述 | 
|---|---|
| "测试构建" | 被称为 test construction的测试用例 | 
| test* | 所有以 test开头的测试用例 | 
| ~"测试构建" | 除了被称为 test construction的测试用例之外的所有测试用例 | 
| ~*equal* | 所有不包含单词 equal的测试用例 | 
| a* ~ab* abc | 所有以 a开头的测试,除了以ab开头的,除了abc(包含在内) | 
| [修改] | 所有带有标签 [修改]的测试用例 | 
| [修改],[比较][操作] | 所有带有标签 [修改]或同时带有[比较]和[操作]的测试用例 | 
| -#sourcefile | 来自 sourcefile.cpp文件的全部测试 | 
表 11.5:将要执行的测试用例的过滤器示例
通过指定命令行参数 --section 或 -c 中的一个或多个部分名称,也可以执行特定的测试函数。但是,此选项不支持通配符。如果你指定要运行的部分,请注意,将从根测试用例到所选部分的整个测试路径都将执行。此外,如果你首先没有指定测试用例或一组测试用例,则将执行所有测试用例,尽管只有它们中匹配的部分。
参见
- 
开始使用 Catch2,学习如何安装 Catch2 框架以及如何创建一个简单的测试项目 
- 
使用 Catch2 断言,以探索 Catch2 库中的各种断言宏 
使用 Catch2 断言
与其他测试框架不同,Catch2 不提供大量断言宏。它有两个主要的宏:REQUIRE,在失败时产生致命错误,停止测试用例的执行,和 CHECK,在失败时产生非致命错误,继续测试用例的执行。还定义了几个附加的宏;在本食谱中,我们将看到如何使用它们。
准备工作
你现在应该熟悉使用 Catch2 编写测试用例和测试函数,这是我们之前在 使用 Catch2 编写和调用测试 这一食谱中讨论的主题。
如何做...
以下列表包含使用 Catch2 框架进行断言的可用选项:
- 
使用 CHECK(expr)来检查expr是否评估为true,在失败时继续执行,并使用REQUIRE(expr)来确保expr评估为true,在失败时停止测试的执行:int a = 42; CHECK(a == 42); REQUIRE(a == 42);
- 
使用 CHECK_FALSE(expr)和REQUIRE_FALSE(expr)来确保expr评估为false,并在失败时产生非致命或致命错误:int a = 42; CHECK_FALSE(a > 100); REQUIRE_FALSE(a > 100);
- 
使用浮点数匹配器 WithinAbs、WithinRel和WithinUPL来比较浮点数(这比过时的Approx类更受欢迎):double a = 42.5; CHECK_THAT(42.0, Catch::Matchers::WithinAbs(a, 0.5)); REQUIRE_THAT(42.0, Catch::Matchers::WithinAbs(a, 0.5)); CHECK_THAT(42.0, Catch::Matchers::WithinRel(a, 0.02)); REQUIRE_THAT(42.0, Catch::Matchers::WithinRel(a, 0.02));
- 
使用 CHECK_NOTHROW(expr)/REQUIRE_NOTHROW(expr)来验证expr不抛出任何错误,CHECK_THROWS(expr)/REQUIRE_THROWS(expr)来验证expr抛出任何类型的错误,CHECK_THROWS_AS(expr, exctype)/REQUIRE_THROWS_AS(expr, exctype)来验证expr抛出类型为exctype的异常,或者CHECK_THROWS_WITH(expression, string or string matcher)/REQUIRE_THROWS_WITH(expression, string or string matcher)来验证expr抛出的异常描述与指定的字符串匹配:void function_that_throws() { throw std::runtime_error("error"); } void function_no_throw() { } SECTION("expressions") { CHECK_NOTHROW(function_no_throw()); REQUIRE_NOTHROW(function_no_throw()); CHECK_THROWS(function_that_throws()); REQUIRE_THROWS(function_that_throws()); CHECK_THROWS_AS(function_that_throws(), std::runtime_error); REQUIRE_THROWS_AS(function_that_throws(), std::runtime_error); CHECK_THROWS_WITH(function_that_throws(), "error"); REQUIRE_THROWS_WITH(function_that_throws(), Catch::Matchers::ContainsSubstring("error")); }
- 
使用 CHECK_THAT(value, matcher expression)/REQUIRE_THAT(expr, matcher expression)来检查给定的匹配器表达式是否对指定的值评估为true:std::string text = "this is an example"; CHECK_THAT( text, Catch::Matchers::ContainsSubstring("EXAMPLE", Catch::CaseSensitive::No)); REQUIRE_THAT( text, Catch::Matchers::StartsWith("this") && Catch::Matchers::ContainsSubstring("an"));
- 
使用 FAIL(message)来报告message并使测试用例失败,WARN(message)来记录消息而不停止测试用例的执行,以及INFO(message)来将消息记录到缓冲区,并且只在下一个会失败的断言中报告它。
它是如何工作的...
REQUIRE/CATCH 宏系列将表达式分解为其左右两侧的项,并在失败时报告失败的位置(源文件和行)、表达式以及左右两侧的值:
f:\chapter11ca_03\main.cpp(19): FAILED:
  REQUIRE( a == 1 )
with expansion:
  42 == 1 
然而,这些宏不支持使用逻辑运算符(如 && 和 ||)组成的复杂表达式。以下示例是错误的:
REQUIRE(a < 10 || a %2 == 0);   // error 
解决这个问题的方法是创建一个变量来保存表达式评估的结果,并在断言宏中使用它。然而,在这种情况下,打印表达式元素展开的能力丢失了:
auto expr = a < 10 || a % 2 == 0;
REQUIRE(expr); 
另一个选择是使用另一组括号。然而,这也阻止了分解工作:
REQUIRE((a < 10 || a %2 == 0)); // OK 
两套断言,即 CHECK_THAT/REQUIRE_THAT 和 CHECK_THROWS_WITH/REQUIRE_THROWS_WITH,与匹配器一起工作。匹配器是可扩展和可组合的组件,用于执行值匹配。框架提供了几个匹配器,包括:
- 
字符串: StartsWith、EndsWith、ContainsSubstring、Equals和Matches
- 
std::vector:Contains、VectorContains、Equals、UnorderedEquals和Approx
- 
浮点值: WithinAbs、WithinULP、WithinRel和IsNaN
- 
类似于范围类型(从版本 3.0.1 开始包含): IsEmpty、SizeIs、Contains、AllMatch、AnyMatch、NoneMatch、AllTrue、AnyTrue、NoneTrue、RangeEquals、UnorderedRangeEquals
- 
异常: Message和MessageMatches
Contains() 和 VectorContains() 之间的区别在于 Contains() 在另一个向量中搜索一个向量,而 VectorContains() 在向量内部搜索单个元素。
如前所述,有几个匹配器针对浮点数。这些匹配器包括:
- 
WithinAbs():创建一个接受小于或等于目标数且具有指定边缘(0 到 1 之间的数字表示的百分比)的浮点数的匹配器:REQUIRE_THAT(42.0, WithinAbs(42.5, 0.5));
- 
WithinRel():创建一个接受近似等于目标值且具有给定容忍度的浮点数的匹配器:REQUIRE_THAT(42.0, WithinRel(42.4, 0.01));
- 
WithinULP():创建一个接受目标值不超过给定 ULP 的浮点数的匹配器:REQUIRE_THAT(42.0, WithinRel(target, 4));
这些匹配器也可以组合在一起,如下所示:
REQUIRE_THAT(a,
  Catch::Matchers::WithinRel(42.0, 0.001) ||
  Catch::Matchers::WithinAbs(42.0, 0.000001)); 
一个过时的比较浮点数的方法由名为 Approx 的类表示,位于 Catch 命名空间中。这个类通过值重载了相等/不等和比较运算符,通过这些值可以构造一个 double 值。两个值可以相差的边缘或被认为是相等的边缘可以指定为给定值的百分比。这可以通过成员函数 epsilon() 来设置。值必须在 0 和 1 之间(例如,0.05 的值是 5%)。epsilon 的默认值设置为 std::numeric_limits<float>::epsilon()*100。
您可以创建自己的匹配器,无论是为了扩展现有框架的功能还是为了与您自己的类型一起工作。创建自定义匹配器有两种方式:旧版 v2 方式和新版 v3 方式。
要以旧方式创建自定义匹配器,有两个必要条件:
- 
从 Catch::MatcherBase<T>派生出的匹配器类,其中T是正在比较的类型。必须重写两个虚拟函数:match(),它接受一个要匹配的值并返回一个布尔值,指示匹配是否成功,以及describe(),它不接受任何参数但返回一个描述匹配器的字符串。
- 
从测试代码中调用的构建函数。 
以下示例定义了一个匹配器,用于 point3d 类,这是我们在本章中看到过的,以检查给定的三维点是否位于三维空间中的一条直线上:
class OnTheLine : public Catch::Matchers::MatcherBase<point3d>
{
  point3d const p1;
  point3d const p2;
public:
  OnTheLine(point3d const & p1, point3d const & p2):
    p1(p1), p2(p2)
  {}
  virtual bool match(point3d const & p) const override
 {
    auto rx = p2.x() - p1.x() != 0 ? 
             (p.x() - p1.x()) / (p2.x() - p1.x()) : 0;
    auto ry = p2.y() - p1.y() != 0 ? 
             (p.y() - p1.y()) / (p2.y() - p1.y()) : 0;
    auto rz = p2.z() - p1.z() != 0 ? 
             (p.z() - p1.z()) / (p2.z() - p1.z()) : 0;
    return 
      Catch::Approx(rx).epsilon(0.01) == ry &&
      Catch::Approx(ry).epsilon(0.01) == rz;
  }
protected:
  virtual std::string describe() const
 {
    std::ostringstream ss;
    ss << "on the line between " << p1 << " and " << p2;
    return ss.str();
  }
};
inline OnTheLine IsOnTheLine(point3d const & p1, point3d const & p2)
{
  return OnTheLine {p1, p2};
} 
要以新方式创建自定义匹配器,您需要以下内容:
- 
从 Catch::Matchers::MatcherGenericBase派生出的匹配器类。这个类必须实现两个方法:bool match(…) const,它执行匹配,以及重写虚拟函数string describe() const,它不接受任何参数但返回一个描述匹配器的字符串。尽管这些与旧式风格中使用的函数非常相似,但有一个关键区别:match()函数对其参数的传递方式没有要求。这意味着它可以按值传递或通过可变引用传递参数。此外,它还可以是一个函数模板。优点是它使得编写更复杂的匹配器成为可能,例如可以比较类似范围的类型。
- 
从测试代码中调用的构建函数。 
以新风格编写的比较 point3d 值的相同匹配器如下所示:
class OnTheLine : public Catch::Matchers::MatcherGenericBase
{
   point3d const p1;
   point3d const p2;
public:
   OnTheLine(point3d const& p1, point3d const& p2) :
      p1(p1), p2(p2)
   {
   }
   bool match(point3d const& p) const
 {
      auto rx = p2.x() - p1.x() != 0 ? 
                (p.x() - p1.x()) / (p2.x() - p1.x()) : 0;
      auto ry = p2.y() - p1.y() != 0 ? 
                (p.y() - p1.y()) / (p2.y() - p1.y()) : 0;
      auto rz = p2.z() - p1.z() != 0 ? 
                (p.z() - p1.z()) / (p2.z() - p1.z()) : 0;
      return
         Catch::Approx(rx).epsilon(0.01) == ry &&
         Catch::Approx(ry).epsilon(0.01) == rz;
   }
protected:
   std::string describe() const override
 {
#ifdef __cpp_lib_format
return std::format("on the line between ({},{},{}) and ({},{},{})", p1.x(), p1.y(), p1.z(), p2.x(), p2.y(), p2.z());
#else
      std::ostringstream ss;
      ss << "on the line between " << p1 << " and " << p2;
      return ss.str();
#endif
   }
}; 
以下测试用例包含了一个如何使用此自定义匹配器的示例:
TEST_CASE("matchers")
{
  SECTION("point origin")
  {
    point3d p { 2,2,2 };
    REQUIRE_THAT(p, IsOnTheLine(point3d{ 0,0,0 }, point3d{ 3,3,3 }));
  }
} 
此测试确保点 {2,2,2} 位于由点 {0,0,0} 和 {3,3,3} 定义的直线上,使用了之前实现的 IsOnTheLine() 自定义匹配器。
参见
- 使用 Catch2 编写和调用测试,了解如何使用 Catch2 库创建测试,无论是使用基于测试用例的传统风格还是基于场景的 BDD 风格,以及如何运行测试
使用 Catch2 控制输出
与本书中讨论的其他测试框架一样,Catch2 以人类可读的格式将测试程序执行的结果报告到 stdout 标准流。支持额外的选项,例如使用 XML 格式报告或写入文件。在本食谱中,我们将查看使用 Catch2 控制输出的主要选项。
准备工作
为了说明测试程序执行输出可能如何修改,请使用以下测试用例:
TEST_CASE("case1")
{
  SECTION("function1")
  {
    REQUIRE(true);
  }
}
TEST_CASE("case2")
{
  SECTION("function2")
  {
    REQUIRE(false);
  }
} 
运行这两个测试用例的输出如下:
----------------------------------------------------------
case2
  function2
----------------------------------------------------------
f:\chapter11ca_04\main.cpp(14)
..........................................................
f:\chapter11ca_04\main.cpp(16): FAILED:
  REQUIRE( false )
==========================================================
test cases: 2 | 1 passed | 1 failed
assertions: 2 | 1 passed | 1 failed 
在下一节中,我们将探讨控制 Catch2 测试程序输出的各种选项。
如何操作...
要控制使用 Catch2 时测试程序的输出,你可以:
- 
使用命令行参数 -r或--reporter <reporter>来指定用于格式化和结构化结果的报告器。框架提供的默认选项是console、compact、xml和junit:chapter11ca_04.exe -r junit <?xml version="1.0" encoding="UTF-8"?> <testsuites> <testsuite name="chapter11ca_04.exe" errors="0" failures="1" tests="2" hostname="tbd" time="0.002039" timestamp="2020-05-02T21:17:04Z"> <testcase classname="case1" name="function1" time="0.00016"/> <testcase classname="case2" name="function2" time="0.00024"> <failure message="false" type="REQUIRE"> at f:\chapter11ca_04\main.cpp(16) </failure> </testcase> <system-out/> <system-err/> </testsuite> </testsuites>
- 
使用命令行参数 -s或--success来显示成功测试用例的结果:chapter11ca_04.exe -s -------------------------------------------------- case1 function1 -------------------------------------------------- f:\chapter11ca_04\main.cpp(6) .................................................. f:\chapter11ca_04\main.cpp(8): PASSED: REQUIRE( true ) -------------------------------------------------- case2 function2 -------------------------------------------------- f:\chapter11ca_04\main.cpp(14) .................................................. f:\chapter11ca_04\main.cpp(16): FAILED: REQUIRE( false ) ================================================== test cases: 2 | 1 passed | 1 failed assertions: 2 | 1 passed | 1 failed
- 
使用命令行参数 -o或--out <filename>将所有输出发送到文件而不是标准流:chapter11ca_04.exe -o test_report.log
- 
使用命令行参数 -d或--durations <yes/no>来显示每个测试用例执行所需的时间:chapter11ca_04.exe -d yes 0.000 s: scenario1 0.000 s: case1 -------------------------------------------------- case2 scenario2 -------------------------------------------------- f:\chapter11ca_04\main.cpp(14) .................................................. f:\chapter11ca_04\main.cpp(16): FAILED: REQUIRE( false ) 0.003 s: scenario2 0.000 s: case2 0.000 s: case2 ================================================== test cases: 2 | 1 passed | 1 failed assertions: 2 | 1 passed | 1 failed
它是如何工作的...
除了默认用于报告测试程序执行结果的易读格式外,Catch2 框架还支持两种 XML 格式:
- 
一种特定的 Catch2 XML 格式(通过 -r xml指定)
- 
一种类似于 JUNIT 的 XML 格式,遵循 JUNIT ANT 任务的结构(通过 -r junit指定)
前者报告器在单元测试执行和结果可用时流式传输 XML 内容,可以用作 XSLT 转换的输入以生成实例的 HTML 报告。后者报告器需要在打印报告之前收集程序的所有执行数据。JUNIT XML 格式对于被第三方工具(如持续集成服务器)消费很有用。
在独立头文件中提供了几个额外的报告器。它们需要包含在测试程序的源代码中(所有额外报告器的名称格式为 catch_reporter_*.hpp)。这些额外的可用报告器包括:
- 
TeamCity 报告器(通过 -r teamcity指定),它将 TeamCity 服务消息写入标准输出流。它仅适用于与 TeamCity 集成。它是一个流式报告器;数据在可用时即被写入。
- 
Automake 报告器(通过 -r automake指定),它通过make check写入automake所期望的元标签。
- 
Test Anything Protocol(或简称 TAP)报告器(通过 -r tap指定)。
- 
SonarQube 报告器(通过 -r sonarqube指定),它使用 SonarQube 通用测试数据 XML 格式进行写入。
以下示例展示了如何包含 TeamCity 头文件以使用 TeamCity 报告器生成报告:
#include <catch2/catch_test_macros.hpp>
#include <catch2/reporters/catch_reporter_teamcity.hpp> 
测试报告的默认目标是标准流 stdout(即使明确写入 stderr 的数据最终也会被重定向到 stdout)。然而,输出也可能被写入到文件中。这些格式化选项可以组合使用。请看以下命令:
chapter11ca_04.exe -r junit -o test_report.xml 
此命令指定报告应使用 JUNIT XML 格式,并保存到名为 test_report.xml 的文件中。
参见
- 
开始使用 Catch2,了解如何安装 Catch2 框架以及如何创建一个简单的测试项目 
- 
使用 Catch2 编写和调用测试,了解如何使用 Catch2 库创建测试,无论是基于测试用例的传统风格还是基于场景的 BDD 风格,以及如何运行测试 
在 Discord 上了解更多
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
discord.gg/7xRaTCeEhx

第十二章:C++ 20 核心特性
新的 C++20 标准是 C++ 语言发展中的一个重大步骤。C++20 为语言和标准库带来了许多新特性。其中一些已经在之前的章节中讨论过,例如文本格式化库、chrono 库的日历扩展、线程支持库的更改等等。然而,对语言影响最大的特性是模块、概念、协程和新的 ranges 库。这些特性的规范非常长,这使得在本书中详细讨论它们变得困难。因此,在这一章中,我们将探讨这些特性的最重要的方面和用例。本章旨在帮助你开始使用这些特性。
本章包括以下菜谱:
- 
与模块一起工作 
- 
理解模块分区 
- 
使用概念指定模板参数的要求 
- 
使用表达式和子句 
- 
探索缩写函数模板 
- 
使用 ranges 库遍历集合 
- 
探索标准范围适配器 
- 
将范围转换为容器 
- 
创建自己的范围视图 
- 
使用约束算法 
- 
创建用于异步计算的协程任务类型 
- 
创建用于值序列的协程生成器类型 
- 
使用 std::generator 类型生成值序列 
让我们从学习模块开始这一章,模块是数十年来对 C++ 语言影响最大的变革。
与模块一起工作
模块是 C++20 标准中最重要的一项变革之一。它们代表了 C++ 语言以及我们编写和消费代码方式的根本性变革。模块通过单独编译的源文件提供,这些源文件与使用它们的翻译单元分开编译。
模块提供了多个优势,尤其是在与头文件使用相比时:
- 
它们只导入一次,导入的顺序并不重要。 
- 
它们不需要在不同源文件中分割接口和实现,尽管这仍然是可能的。 
- 
模块有可能减少编译时间,在某些情况下甚至可以显著减少。从模块导出的实体在二进制文件中描述,编译器可以比传统的预编译头更快地处理这些文件。 
- 
此外,此文件可能被用于构建与其他语言 C++ 代码的集成和互操作性。 
在这个菜谱中,你将学习如何开始使用模块。
准备工作
在撰写本文时,主要的编译器(VC++、Clang 和 GCC)为模块提供不同级别的支持。构建系统,如 CMake,在模块的采用方面落后(尽管这可能在不久的将来发生变化)。由于不同的编译器有不同的方式和不同的编译器选项来支持模块,本书将不会提供如何构建这些示例的详细信息。您被邀请查阅特定编译器的在线文档。
伴随这本书的源代码包括使用 Visual Studio 2019 16.8 和 Visual Studio 2022 17.x 的 MSVC 编译器(cl.exe)构建本食谱和下一个食谱中展示的源代码的脚本。
存在几种类型的模块文件:模块接口单元、模块接口分区和模块实现分区。在本食谱中,我们将仅提及第一种;其他两种,我们将在下一个食谱中学习。
如何做到这一点…
当你模块化你的代码时,你可以做以下几件事情:
- 
使用 import指令和模块名称导入模块。标准库在std模块中可用,但仅从 C++23 开始。这允许我们在 C++23 中编写以下内容:import std; int main() { std::println("Hello, world!"); } std.core module from Visual C++, which contains most of the functionality of the standard library, including the streams library:import std.core; int main() { std::cout << "Hello, World!\n"; }
- 
通过创建一个模块接口单元(MIU)来导出模块,该单元可以包含函数、类型、常量和甚至宏。它们的声明必须以关键字 export开头。模块接口单元文件必须以.ixx扩展名结尾,适用于 VC++。Clang 接受不同的扩展名,包括.cpp、.cppm,甚至.ixx。以下示例导出了一个名为point的类模板,一个名为distance()的函数,该函数计算两点之间的距离,以及一个用户定义的文法操作符_ip,它可以从字符串创建point类型的对象,形式为"0,0"或"12,-3":// --- geometry.ixx/.cppm --- export module geometry; #ifdef __cpp_lib_modules import std; #else import std.core; #endif export template <class T, typename = typename std::enable_if_t<std::is_arithmetic_v<T>, T>> struct point { T x; T y; }; export using int_point = point<int>; export constexpr int_point int_point_zero{ 0,0 }; export template <class T> double distance(point<T> const& p1, point<T> const& p2) { return std::sqrt((p2.x - p1.x) * (p2.x - p1.x) + (p2.y - p1.y) * (p2.y - p1.y)); } namespace geometry_literals { export int_point operator ""_ip(const char* ptr, std::size_t size) { int x = 0, y = 0; if(ptr) { while (*ptr != ',' && *ptr != ' ') x = x * 10 + (*ptr++ - '0'); while (*ptr == ',' || *ptr == ' ') ptr++; while (*ptr != 0) y = y * 10 + (*ptr++ - '0'); } return { x, y }; } } // --- main.cpp --- #ifdef __cpp_lib_modules import std; #else import std.core; #endif import geometry; int main() { int_point p{ 3, 4 }; std::cout << distance(int_point_zero, p) << '\n'; { using namespace geometry_literals; std::cout << distance("0,0"_ip, "30,40"_ip) << '\n'; } }
- 
使用 import指令也可以导入头文件的内容。这里展示的例子使用了与前面示例中相同的类型和函数:// --- geometry.h --- #pragma once #include <cmath> template <class T, typename = typename std::enable_if_t<std::is_arithmetic_v<T>, T>> struct point { T x; T y; }; using int_point = point<int>; constexpr int_point int_point_zero{ 0,0 }; template <class T> double distance(point<T> const& p1, point<T> const& p2) { return std::sqrt((p2.x – p1.x) * (p2.x – p1.x) + (p2.y – p1.y) * (p2.y – p1.y)); } namespace geometry_literals { int_point operator ""_ip(const char* ptr, std::size_t) { int x = 0, y = 0; if(ptr) { while (*ptr != ',' && *ptr != ' ') x = x * 10 + (*ptr++ - '0'); while (*ptr == ',' || *ptr == ' ') ptr++; while (*ptr != 0) y = y * 10 + (*ptr++ - '0'); } return { x, y }; } } // --- main.cpp --- #ifdef __cpp_lib_modules import std; #else import std.core; #endif import "geometry.h"; int main() { int_point p{ 3, 4 }; std::cout << distance(int_point_zero, p) << '\n'; { using namespace geometry_literals; std::cout << distance("0,0"_ip, "30,40"_ip) << '\n'; } }
它是如何工作的...
模块单元由几个部分组成,包括必需的或可选的部分:
- 
全局模块片段,通过 module;语句引入。这部分是可选的,如果存在,可能只包含预处理器指令。这里添加的任何内容都被称为全局模块,它是所有全局模块片段和所有非模块的翻译单元的集合。
- 
模块声明,这是一个必需的语句,形式为 exportmodule name;。
- 
模块序言,这是可选的,可能只包含导入声明。 
- 
模块范围,即单元的内容,从模块声明开始,延伸到模块单元的末尾。 
以下图表显示了一个包含所有上述部分的模块单元。在左侧,我们有模块的源代码,在右侧,解释了模块的各个部分:

图 12.1:一个模块(左侧)的示例,每个部分都被突出显示并解释(右侧)
一个模块可以导出任何实体,例如函数、类和常量。每个导出都必须以 export 关键字开头。这个关键字始终是第一个关键字,位于 class/struct、template 或 using 等其他关键字之前。前一个章节中展示的 geometry 模块中已经提供了几个示例:
- 
一个名为 point的 E 类模板,表示二维空间中的一个点
- 
一个名为 int_point的point<int>类型别名
- 
一个名为 int_point_zero的编译时常量
- 
一个函数模板 distance(),用于计算两点之间的距离
- 
一个用户自定义字面量 _ip,可以从"3,4"等字符串创建int_point对象
使用模块而不是头文件的翻译单元不需要进行任何其他更改,除了将 #include 预处理器指令替换为 import 指令。此外,也可以使用相同的 import 指令将头文件导入为模块,如前一个示例所示。
模块和命名空间之间没有关系。这两个是独立的概念。模块 geometry 在 geometry_literals 命名空间中导出了用户自定义字面量 ""_ip,而模块中的其他所有导出都在全局命名空间中可用。
模块名称和单元文件名称之间也没有关系。几何模块是在一个名为 geometry.ixx/.cppm 的文件中定义的,尽管任何文件名都会产生相同的结果。建议您遵循一致的命名方案,并将模块名称用于模块文件名。另一方面,模块单元使用的扩展名因编译器而异,尽管这可能是当模块支持成熟时可能会发生变化的事情。
在 C++23 之前,标准库尚未模块化。然而,编译器已经将其作为模块提供。Clang 编译器为每个头文件提供不同的模块。另一方面,Visual C++ 编译器为标准库提供了以下模块:
- 
std.regex: 头文件<regex>的内容
- 
std.filesystem: 头文件<filesystem>的内容
- 
std.memory: 头文件<memory>的内容
- 
std.threading: 头文件<atomic>,<condition_variable>,<future>,<mutex>,<shared_mutex>, 和<thread>的内容
- 
std.core: C++ 标准库的其余部分
如您从这些模块名称中可以看到,例如 std.core 或 std.regex,模块的名称可以是由一系列标识符通过点(.)连接而成的序列。点号除了帮助将名称分割成表示逻辑层次结构的部分(如 company.project.module)之外,没有其他意义。使用点号在某种程度上可以提供比使用下划线(如 std_core 或 std_regex)更好的可读性,尽管下划线也是合法的,就像任何可能形成标识符的东西一样。
另一方面,C++23 标准提供了两个标准化命名的模块:
- 
std,它将 C++ 标准头文件(如<vector>、<string>、<algorithm>等)和 C 包装器头文件(如<cstdio>)中的所有内容导入到std命名空间。如果您使用std限定所有内容且不想污染全局命名空间,则应使用此模块。
- 
std.compat模块导入了std所有的内容,以及 C 包装器头文件的全球命名空间对应项。例如,如果std从<cstdio>中导入了std::fopen和std::fclose(以及其他所有内容),那么std.compat将导入::fopen和::fclose。如果您想更容易地迁移代码而不必使用std命名空间限定名称(如使用fopen而不是std::fopen,使用size_t而不是std::size_t等),则应使用此模块。
作为程序员,您熟悉任何编程语言的典型入门程序,称为 “Hello, world!”,该程序简单地打印此文本到控制台。在 C++ 中,此程序的规范形式曾经如下所示:
#include <iostream>
int main()
{
   std::cout << "Hello, world!\n";
} 
在 C++23 中,随着标准化模块和新文本格式化库打印功能的支持,此程序可以如下所示:
import std;
int main()
{
   std::println("Hello, world!");
} 
您可以使用 __cpp_lib_modules 功能宏来检查标准模块 std 和 std.compat 是否可用。
相关内容
- 理解模块分区,以了解接口和实现分区
理解模块分区
模块源代码可能会变得很大且难以维护。此外,一个模块可能由逻辑上独立的几个部分组成。为了帮助处理这些情况,模块支持从称为 分区 的部分进行组合。一个作为分区且导出实体的模块单元称为 模块接口分区。
然而,也可能存在不导出任何内容的内部分区。这样的分区单元称为 模块实现分区。在本食谱中,您将学习如何与接口和实现分区一起工作。
准备工作
在继续阅读本食谱之前,您应该阅读之前的食谱 使用模块。您将需要我们在那里讨论的模块基础知识以及我们将在此食谱中继续使用的代码示例。
在以下示例中,我们将使用std模块,它仅在 C++23 中可用。对于之前的版本,在 VC++或其他编译器支持的特定模块中使用std.core。
如何做到...
你可以将一个模块拆分为几个分区,如下所示:
- 
每个分区单元必须以 export module modulename:partitionname;形式的声明开始。只有全局模块片段可以在此声明之前:// --- geometry-core.ixx/.cppm --- export module geometry:core; import std; export template <class T, typename = typename std::enable_if_t<std::is_arithmetic_v<T>, T>> struct point { T x; T y; }; export using int_point = point<int>; export constexpr int_point int_point_zero{ 0,0 }; export template <class T> double distance(point<T> const& p1, point<T> const& p2) { return std::sqrt((p2.x - p1.x) * (p2.x - p1.x) + (p2.y - p1.y) * (p2.y - p1.y)); } // --- geometry-literals.ixx/.cppm --- export module geometry:literals; import :core; namespace geometry_literals { export int_point operator ""_ip(const char* ptr, std::size_t) { int x = 0, y = 0; if(ptr) { while (*ptr != ',' && *ptr != ' ') x = x * 10 + (*ptr++ - '0'); while (*ptr == ',' || *ptr == ' ') ptr++; while (*ptr != 0) y = y * 10 + (*ptr++ - '0'); } return { x, y }; } }
- 
在主要模块接口单元中,使用 export import :partitionname形式的声明导入并导出分区,如下例所示:// --- geometry.ixx/.cppm --- export module geometry; export import :core; export import :literals;
- 
导入由多个分区组成的模块的代码只看到作为一个整体的模块,如果它是从一个单个模块单元构建的: // --- main.cpp --- import std; import geometry; int main() { int_point p{ 3, 4 }; std::cout << distance(int_point_zero, p) << '\n'; { using namespace geometry_literals; std::cout << distance("0,0"_ip, "30,40"_ip) << '\n'; } }
- 
可以创建不导出任何内容但包含可用于同一模块的代码的内部分区。这样的分区必须以 module modulename:partitionname;形式的声明开始(不使用export关键字)。不同的编译器可能还需要为包含内部分区的文件使用不同的扩展名。对于 VC++,扩展名必须是.cpp:// --- geometry-details.cpp -- module geometry:details; import std; std::pair<int, int> split(const char* ptr) { int x = 0, y = 0; if(ptr) { while (*ptr != ',' && *ptr != ' ') x = x * 10 + (*ptr++ - '0'); while (*ptr == ',' || *ptr == ' ') ptr++; while (*ptr != 0) y = y * 10 + (*ptr++ - '0'); } return { x, y }; } // --- geometry-literals.ixx/.cppm --- export module geometry:literals; import :core; import :details; namespace geometry_literals { export int_point operator ""_ip(const char* ptr, std::size_t) { auto [x, y] = split(ptr); return {x, y}; } }
它是如何工作的...
之前展示的代码是之前菜谱中介绍的模块示例的后续。geometry模块已被拆分为两个不同的分区,分别称为core和literals。
然而,当你声明分区时,你必须使用modulename:partitionname这种形式的名称,例如在geometry:core和geometry:literals中。在其他地方导入分区时,这并不是必需的。这可以在主要分区单元geometry.ixx和模块接口分区geometry-literals.ixx中看到。以下是一些清晰度更高的片段:
// --- geometry-literals.ixx/.cppm ---
export module geometry:literals;
// import the core partition
import :core;
// --- geometry.ixx/.cppm ---
export module geometry;
// import the core partition and then export it
export import :core;
// import the literals partition and then export it
export import :literals; 
尽管模块分区是独立的文件,但它们对于使用模块的翻译单元来说并不是作为单独的模块或子模块可用的。它们作为一个单一、聚合的模块一起导出。如果你比较main.cpp文件中的源代码和之前菜谱中的代码,你将看不到任何区别。
与模块接口单元一样,没有命名包含分区的文件的规则。然而,编译器可能需要不同的扩展名或支持某些特定的命名方案。例如,VC++使用<module-name>-<partition-name>.ixx方案,这简化了构建命令。
分区,就像模块一样,可能包含未从模块导出的代码。一个分区可能完全不包含导出项,在这种情况下,它仅是一个内部分区。这样的分区被称为模块实现分区。它是在模块声明中不使用export关键字定义的。
一个内部分区的例子是前面展示的 geometry:details 分区。它提供了一个名为 split() 的辅助函数,用于从字符串中解析由逗号分隔的两个整数。然后,这个分区被导入到 geometry:literals 分区中,其中 split() 函数被用来实现用户定义的文法 _ip。
更多...
分区是模块的划分。然而,它们不是子模块。它们在模块之外没有逻辑存在。C++ 语言中没有子模块的概念。在这个菜谱中展示的代码使用分区可以稍微不同地使用模块来编写:
// --- geometry-core.ixx ---
export module geometry.core;
import std;
export template <class T,
   typename = typename std::enable_if_t<std::is_arithmetic_v<T>, T>>
struct point
{
   T x;
   T y;
};
export using int_point = point<int>;
export constexpr int_point int_point_zero{ 0,0 };
export template <class T>
double distance(point<T> const& p1, point<T> const& p2)
{
   return std::sqrt(
      (p2.x - p1.x) * (p2.x - p1.x) +
      (p2.y - p1.y) * (p2.y - p1.y));
}
// --- geometry-literals.ixx ---
export module geometry.literals;
import geometry.core;
namespace geometry_literals
{
   export int_point operator ""_ip(const char* ptr, std::size_t)
   {
      int x = 0, y = 0;
      if(ptr)
      {
while (*ptr != ',' && *ptr != ' ')
            x = x * 10 + (*ptr++ - '0');
         while (*ptr == ',' || *ptr == ' ') ptr++;
         while (*ptr != 0)
            y = y * 10 + (*ptr++ - '0');
      }
      return { x, y };
   }
}
// --- geometry.ixx ---
export module geometry;
export import geometry.core;
export import geometry.literals; 
在这个示例中,我们有三个模块:geometry.core、geometry.literals 和 geometry。在这里,geometry 导入并重新导出了前两个模块的全部内容。正因为如此,main.cpp 中的代码不需要更改。
仅通过导入 geometry 模块,我们就可以访问 geometry.core 和 geometry.literals 模块的内容。
然而,如果我们不再定义 geometry 模块,那么我们需要显式地导入这两个模块,如下面的代码片段所示:
import std;
import geometry.core;
import geometry.literals;
int main()
{
   int_point p{ 3, 4 };
   std::cout << distance(int_point_zero, p) << '\n';
   {
      using namespace geometry_literals;
      std::cout << distance("0,0"_ip, "30,40"_ip) << '\n';
   }
} 
在使用分区或多个模块来组件化源代码之间进行选择应取决于你项目的特定性。如果你使用多个较小的模块,你提供了更好的导入粒度。如果你正在开发一个大型的库,这可能很重要,因为用户应该只导入他们使用的东西(而不是当他们只需要一些功能时导入一个非常大的模块)。
参见
- 与模块一起工作,以探索 C++20 模块的基础知识
使用概念指定模板参数的要求
模板元编程是 C++ 语言的重要组成部分,它赋予了开发通用库(包括标准库)的能力。然而,模板元编程并非易事。相反,没有丰富的经验,复杂任务可能会变得繁琐且难以正确实现。事实上,由 Bjarne Stroustrup 和 Herb Sutter 创建的 C++ Core Guidelines 初始项目有一个规则叫做 仅在真正需要时使用模板元编程,该规则的理由是:
模板元编程很难正确实现,会减慢编译速度,并且通常很难维护。
模板元编程的一个重要方面是针对类型模板参数的约束指定,以便对可以实例化的模板类型施加限制。C++20 概念库旨在解决这个问题。概念是一组命名的约束,约束是对模板参数的要求。这些用于选择合适的函数重载和模板特化。
在这个菜谱中,我们将看到如何使用 C++20 概念来指定模板参数的要求。
准备工作
在我们开始学习概念之前,让我们考虑以下类模板,称为NumericalValue,它应该包含整型或浮点型的值。这个 C++11 实现使用了std::enable_if来指定T模板参数的要求:
template <typename T>,
          typename = typename std::enable_if_t<std::is_arithmetic_v<T>, T>>
struct NumericalValue
{
  T value;
};
template <typename T>
NumericalValue<T> wrap(T value) { return { value }; }
template <typename T>
T unwrap(NumericalValue<T> t) { return t.value; }
auto nv = wrap(42);
std::cout << nv.value << '\n';   // prints 42
auto v = unwrap(nv);
std::cout << v << '\n';          // prints 42
using namespace std::string_literals;
auto ns = wrap("42"s);           // error 
此代码片段将是本食谱中展示的示例的基础。
如何做...
您可以如下指定模板参数的要求:
- 
使用以下形式的 concept关键字创建一个概念:template <class T> concept Numerical = std::is_arithmetic_v<T>;
- 
或者,您可以使用标准定义的概念之一,这些概念在头文件 <concepts>(或标准库的其他头文件)中可用:template <class T> concept Numerical = std::integral<T> || std::floating_point<T>;
- 
在函数模板、类模板或变量模板中使用概念名称而不是 class或typename关键字:template <Numerical T> struct NumericalValue { T value; }; template <Numerical T> NumericalValue<T> wrap(T value) { return { value }; } template <Numerical T> T unwrap(NumericalValue<T> t) { return t.value; }
- 
使用语法不变的方式实例化类模板和调用函数模板: auto nv = wrap(42); std::cout << nv.value << '\n'; // prints 42 auto v = unwrap(nv); std::cout << v << '\n'; // prints 42 using namespace std::string_literals; auto ns = wrap("42"s); // error
它是如何工作的...
概念是一组一个或多个约束,总是在命名空间范围内定义。概念的定义类似于变量模板。以下代码片段展示了如何使用概念作为变量模板:
template <class T>
concept Real = std::is_floating_point_v<T>;
template<Real T>
constexpr T pi = T(3.1415926535897932385L);
std::cout << pi<double> << '\n';
std::cout << pi<int>    << '\n'; // error 
概念本身不能被约束,也不能递归地引用自己。在前面展示的例子中,Numerical和Real概念由单个原子约束组成。然而,可以从多个约束创建概念。使用&&逻辑运算符从两个约束创建的约束称为结合,而使用||逻辑运算符从两个约束创建的约束称为析取。
在如何做...部分定义的Numerical概念使用了std::is_arithmetic_v类型特性。然而,我们可以有两个概念,Real和Integral,如下所示:
template <class T>
concept Integral = std::is_integral_v<T>;
template <class T>
concept Real = std::is_floating_point_v<T>; 
从这两个类中,我们可以使用||逻辑运算符组合出Numerical概念,结果是析取:
template <class T>
concept Numerical = Integral<T> || Real<T>; 
从语义上看,这两个版本的Numerical概念没有区别,尽管它们是以不同的方式定义的。
为了理解结合,让我们看另一个例子。考虑两个基类,IComparableToInt和IConvertibleToInt,它们应该被支持比较或转换为int的类继承。这些可以定义如下:
struct IComparableToInt
{
  virtual bool CompareTo(int const o) = 0;
};
struct IConvertibleToInt
{
  virtual int ConvertTo() = 0;
}; 
一些类可以实现这两个概念,而另一些类只实现其中一个或另一个。这里的SmartNumericalValue<T>类实现了这两个概念,而DullNumericalValue<T>只实现了IConvertibleToInt类:
template <typename T>
struct SmartNumericalValue : public IComparableToint, IConvertibleToInt
{
  T value;
  SmartNumericalValue(T v) :value(v) {}
  bool CompareTo(int const o) override
 { return static_cast<int>(value) == o; }
  int ConvertTo() override
 { return static_cast<int>(value); }
};
template <typename T>
struct DullNumericalValue : public IConvertibleToInt
{
  T value;
  DullNumericalValue(T v) :value(v) {}
  int ConvertTo() override
 { return static_cast<int>(value); }
}; 
我们想要编写一个函数模板,它只接受既是可比较的又可转换为int类型的参数。这里展示的IComparableAndConvertible概念是IntComparable和IntConvertible概念的结合。它们可以如下实现:
template <class T>
concept IntComparable = std::is_base_of_v<IComparableToInt, T>;
template <class T>
concept IntConvertible = std::is_base_of_v<IConvertibleToInt, T>;
template <class T>
concept IntComparableAndConvertible = IntComparable<T> && IntConvertible<T>;
template <IntComparableAndConvertible T>
void print(T o)
{
  std::cout << o.value << '\n';
}
int main()
{
   auto snv = SmartNumericalValue<double>{ 42.0 };
   print(snv);                      // prints 42
auto dnv = DullNumericalValue<short>{ 42 };
   print(dnv);                      // error
} 
合取和析取是从左到右评估的,并且是短路执行的。这意味着对于合取,只有当左边的约束满足时,才会评估右边的约束;对于析取,只有当左边的约束不满足时,才会评估右边的约束。
第三类约束是原子约束。这些约束由一个表达式E以及从E的类型参数到约束实体的模板参数之间的映射组成,称为参数映射。原子约束是在约束规范化过程中形成的,这是一个将约束表达式转换成一系列原子约束的合取和析取的过程。通过将参数映射和模板参数代入表达式E来检查原子约束。结果必须是一个有效的bool类型的 prvalue 常量表达式;否则,约束不满足。
标准库定义了一系列概念,可以用来定义对模板参数的编译时要求。尽管这些概念大多数都同时施加了语法和语义要求,但编译器通常只能确保前者。当语义要求不满足时,程序被认为是无效的,编译器不需要提供任何关于问题的诊断。标准概念可以在几个地方找到:
- 
在概念库中,在 <concepts>头文件和std命名空间中。这包括核心语言概念(如same_as、integral、floating_point、copy_constructible和move_constructible),比较概念(如equality_comparable和totally_ordered),对象概念(如copyable、moveable和regular),以及可调用概念(如invocable和predicate)。
- 
在算法库中,在 <iterator>头文件和std命名空间中。这包括算法要求(如sortable、permutable和mergeable)和间接可调用概念(如indirect_unary_predicate和indirect_binary_predicate)。
- 
在范围库中,在 <ranges>头文件和std::ranges命名空间中。这包括针对范围的概念,如range、view、input_range、output_range、forward_range和random_access_range。
更多内容...
在这个配方中定义的概念使用了已经可用的类型特性。然而,有许多情况下对模板参数的要求无法用这种方式描述。因此,可以使用requires表达式来定义概念,这是一个bool类型的 prvalue 表达式,描述了模板参数的要求。这将是下一个配方的主题。
参见
- 使用requires表达式和子句,了解就地约束
使用requires表达式和子句
在上一个配方中,我们介绍了概念和约束的主题,通过几个仅基于现有类型特征的示例来了解它们。此外,我们还使用了更简洁的语法来指定概念,在模板声明中使用概念名称代替typename或class关键字。然而,借助requires 表达式可以定义更复杂的概念。这些是描述某些模板参数约束的bool类型的 prvalue。
在本配方中,我们将学习如何编写 requires 表达式以及指定模板参数约束的另一种方法。
准备工作
在本配方中展示的代码片段中,将使用上一个配方中定义的类模板NumericalValue<T>和函数模板wrap()。
如何做...
要指定模板参数的要求,可以使用requires关键字引入的 requires 表达式,如下所示:
- 
使用一个简单的表达式,编译器会验证其正确性。在下面的代码片段中,必须为 T模板参数重载运算符+:template <typename T> concept Addable = requires (T a, T b) {a + b;}; template <Addable T> T add(T a, T b) { return a + b; } add(1, 2); // OK, integers add("1"s, "2"s); // OK, std::string user-defined literals NumericalValue<int> a{1}; NumericalValue<int> b{2}; add(a, b); // error: no matching function for call to 'add' // 'NumericalValue<int>' does not satisfy 'Addable'
- 
使用一个简单的表达式来要求存在特定的函数。在以下代码片段中,必须存在一个名为 wrap()的函数,该函数使用T模板参数的重载参数:template <typename T> concept Wrapable = requires(T x) { wrap(x); }; template <Wrapable T> void do_wrap(T x) { [[maybe_unused]] auto v = wrap(x); } do_wrap(42); // OK, can wrap an int do_wrap(42.0); // OK, can wrap a double do_wrap("42"s); // error, cannot wrap a std::string
- 
使用类型要求,通过 typename关键字后跟类型名称(可选地带有限定符)来指定要求,例如成员名称、类模板特化或别名模板替换。在以下代码片段中,T模板参数必须有两个内部类型称为value_type和iterator。此外,还必须提供两个函数begin()和end(),它们接受T参数:template <typename T> concept Container = requires(T x) { typename T::value_type; typename T::iterator; begin(x); end(x); }; template <Container T> void pass_container(T const & c) { for(auto const & x : c) std::cout << x << '\n'; } std::vector<int> v { 1, 2, 3}; std::array<int, 3> a {1, 2, 3}; int arr[] {1,2,3}; pass_container(v); // OK pass_container(a); // OK pass_container(arr); // error: 'int [3]' does not satisfy // 'Container'
- 
使用复合要求来指定表达式的需求以及表达式的评估结果。在以下示例中,必须存在一个名为 wrap()的函数,它可以接受T模板参数类型的参数,并且调用该函数的结果必须是NumericalValue<T>类型:template <typename T> concept NumericalWrapable = requires(T x) { {wrap(x)} -> std::same_as<NumericalValue<T>>; }; template <NumericalWrapable T> void do_wrap_numerical(T x) { [[maybe_unused]] auto v = wrap(x); } template <typename T> class any_wrapper { public: T value; }; any_wrapper<std::string> wrap(std::string s) { return any_wrapper<std::string>{s}; } // OK, wrap(int) returns NumericalValue<int> do_wrap_numerical(42); // error, wrap(string) returns any_wrapper<string> do_wrap_numerical("42"s);
模板参数的约束也可以使用涉及requires关键字的语法来指定。这些称为requires 子句,可以如下使用:
- 
在模板参数列表之后使用 requires 子句: template <typename T> requires Addable<T> T add(T a, T b) { return a + b; }
- 
或者,在函数声明符的最后一个元素之后使用 requires 子句: template <typename T> T add(T a, T b) requires Addable<T> { return a + b; }
- 
将 requires 子句与 requires 表达式结合,而不是使用命名概念。在这种情况下, requires关键字出现了两次,如下面的代码片段所示:template <typename T> T add(T a, T b) requires requires (T a, T b) {a + b;} { return a + b; }
它是如何工作的...
新的requires关键字具有多个用途。一方面,它用于引入一个指定模板参数约束的 requires 子句。另一方面,它用于定义一个bool类型的 prvalue 的 requires 表达式,用于定义模板参数的约束。
如果你不太熟悉 C++值类别(lvalue、rvalue、prvalue、xvalue、glvalue),建议你检查en.cppreference.com/w/cpp/language/value_category。术语prvalue,意为纯右值,指定了一个不是 xvalue(即将过期的值)的右值。prvalue 的例子包括字面量、返回类型不是引用类型的函数调用、枚举或this指针。
在requires子句中,requires关键字必须后跟一个类型为bool的常量表达式。该表达式必须是原始表达式(例如std::is_arithmetic_v<T>或std::integral<T>),括号内的表达式,或者任何这样的表达式的序列,这些表达式通过&&或||运算符连接。
一个requires表达式的形式为requires (parameters-list) { requirements }。参数列表是可选的,并且可以完全省略(包括括号)。指定的需求可以引用:
- 
范围内的模板参数 
- 
在 parameters-list中引入的局部参数
- 
任何从封装上下文中可见的其他声明 
requires表达式的需求序列可以包含以下类型的需求:
- 
简单需求:这些是不以 requires关键字开头的任意表达式。编译器只检查其语言正确性。
- 
类型需求:这些是以关键字 typename开头后跟一个类型名称的表达式,它必须是有效的。这使编译器能够验证是否存在某个嵌套名称,或者是否存在类模板特化或别名模板替换。
- 
复合需求:它们的形式为 {expression} noexcept -> type-constraint。noexcept关键字是可选的,在这种情况下,表达式不得是可能抛出异常的。使用->引入的返回类型需求也是可选的。然而,如果它存在,那么decltype(expression)必须满足type-constraint施加的约束。
- 
嵌套需求:这些是更复杂的表达式,它们指定了定义为 requires 表达式的约束,而这些表达式本身又可以是一个嵌套需求。以关键字 requires开头的需求被视为嵌套需求。
在它们被评估之前,每个名称概念和每个requires表达式的主体都会被替换,直到获得一系列原子约束的合取和析取。这个过程被称为规范化。规范化及其分析的实际细节超出了本书的范围。
参见
- 使用概念指定模板参数的需求,以探索 C++20 概念的基础
探索缩写函数模板
在第三章中,我们学习了函数模板以及 lambda 表达式,包括泛型和模板 lambda。泛型 lambda 是一个使用auto指定符为其参数之一指定的 lambda 表达式。结果是具有模板调用操作符的函数对象。同样,定义具有更好参数类型控制优势的 lambda 模板也会产生相同的结果。在 C++20 中,这种为参数类型使用auto指定符的想法被推广到所有函数。
这引入了定义函数模板的简化语法,以这种方式定义的函数被称为简化函数模板。我们将在这个菜谱中看到如何使用它们。
如何做到…
你可以在 C++20 中定义以下类别的简化函数模板:
- 
使用 auto指定符定义参数的非约束简化函数模板:auto sum(auto a, auto b) { return a + b; } auto a = sum(40, 2); // 42 auto b = sum(42.0, 2); // 44.0
- 
使用在 auto指定符之前指定的概念来约束函数模板参数的约束简化函数模板:auto sum(std::integral auto a, std::integral auto b) { return a + b; } auto a = sum(40, 2); // 42 auto b = sum(42.0, 2); // error
- 
使用上述语法但带有参数包的约束简化变长函数模板: auto sum(std::integral auto ... args) { return (args + ...); } auto a = sum(10, 30, 2); // 42
- 
使用上述语法但带有 lambda 表达式的约束简化 lambda 表达式: int main() { auto lsum = [](std::integral auto a, std::integral auto b) { return a + b; }; auto a = lsum(40, 2); // 42 auto b = lsum(42.0, 2); // error }
- 
简化函数模板的特殊化可以像使用常规模板语法定义的函数模板一样定义: auto sum(auto a, auto b) { return a + b; } template <> auto sum(char const* a, char const* b) { return std::string(a) + std::string(b); } auto a = sum(40, 2); // 42 auto b = sum("40", "2"); // "402"
它是如何工作的…
模板语法被认为相当繁琐。简化函数模板旨在简化编写某些类别的函数模板。它们通过使用auto指定符作为参数类型的占位符来实现这一点,而不是典型的模板语法。以下两个定义是等价的:
auto sum(auto a, auto b)
{
   return a + b;
}
template <typename T, typename U>
auto sum(T a, U b)
{
   return a + b;
} 
如果意图是定义具有相同类型参数的函数模板,那么这种简化函数模板的形式就不够了。这些简化函数模板被称为非约束,因为函数的参数上没有放置任何约束。
可以使用概念的帮助来定义这些约束,如下所示:
auto sum(std::integral auto a, std::integral auto b)
{
   return a + b;
} 
约束简化函数模板的类别被称为约束。上面的函数等同于以下常规函数模板:
template <typename T>
T sum(T a, T b);
template<>
int sum(int a, int b)
{
   return a + b;
} 
由于简化函数模板是一个函数模板,因此它可以像使用标准模板语法声明的任何函数一样进行特殊化:
template <>
auto sum(char const* a, char const* b)
{
   return std::string(a) + std::string(b);
} 
约束简化函数模板也可以是变长参数;也就是说,它们具有可变数量的参数。它们没有特别之处,除了我们在第五章中学到的那些。此外,该语法还可以用来定义 lambda 模板。这些示例在上一节中已经给出。
参见
- 
第三章,使用泛型 lambda,了解泛型 lambda 和 lambda 模板的使用 
- 
第三章,编写函数模板,以探索编写函数模板的语法 
- 
第三章,编写具有可变数量参数的函数模板,以了解您如何编写接受可变数量参数的函数 
- 
使用概念指定模板参数的要求,以了解如何使用函数模板和概念约束参数 
使用 ranges 库遍历集合
C++ 标准库提供了三个重要的支柱——容器、迭代器和算法——使我们能够处理集合。因为这些算法是通用的,并且设计为与迭代器一起工作,迭代器定义了范围,它们通常需要编写显式且有时复杂的代码来实现简单任务。C++20 ranges 库已被设计用来解决这个问题,通过提供处理元素范围组件。这些组件包括范围适配器(或视图)和与范围一起工作的约束算法,而不是迭代器。在本食谱中,我们将查看一些这些视图和算法,并了解它们如何简化编码。
准备工作
在以下代码片段中,我们将参考一个名为 is_prime() 的函数,它接受一个整数并返回一个布尔值,指示该数字是否为质数。这里展示了一个简单的实现:
bool is_prime(int const number)
{
  if (number != 2)
  {
    if (number < 2 || number % 2 == 0) return false;
    auto root = std::sqrt(number);
    for (int i = 3; i <= root; i += 2)
      if (number % i == 0) return false;
  }
  return true;
} 
对于一个高效的算法(这超出了本食谱的范围),我推荐 Miller–Rabin 质数测试。
ranges 库可在新的 <ranges> 头文件中找到,位于 std::ranges 命名空间中。为了简单起见,本食谱中将使用以下命名空间别名:
namespace rv = std::ranges::views;
namespace rg = std::ranges; 
我们将在下一节探索 ranges 库的各种用法。
如何做到...
ranges 库可用于通过以下操作迭代范围:
- 
使用 iota_view/views::iota视图生成连续整数的序列。以下代码片段打印出从 1 到 9 的所有整数:for (auto i : rv::iota(1, 10)) std::cout << i << ' ';
- 
使用 filter_view/views::filter过滤范围中的元素,仅保留满足谓词的元素。这里的第一段代码打印出从 1 到 99 的所有质数。然而,第二段代码保留并打印出从整数向量中所有的质数:// prints 2 3 5 7 11 13 ... 79 83 89 97 for (auto i : rv::iota(1, 100) | rv::filter(is_prime)) std::cout << i << ' '; // prints 2 3 5 13 std::vector<int> nums{ 1, 1, 2, 3, 5, 8, 13, 21 }; for (auto i : nums | rv::filter(is_prime)) std::cout << i << ' ';
- 
使用 transform_view/views::transform通过对每个元素应用一元函数来转换范围中的元素。以下代码片段打印出从 1 到 99 的所有质数的后继数:// prints 3 4 6 8 12 14 ... 80 84 90 98 for (auto i : rv::iota(1, 100) | rv::filter(is_prime) | rv::transform([](int const n) {return n + 1; })) std::cout << i << ' ';
- 
使用 take_view/views::take仅保留视图中的前 N 个元素。以下代码片段仅打印出从 1 和 99 的前 10 个质数:// prints 2 3 5 7 11 13 17 19 23 29 for (auto i : rv::iota(1, 100) | rv::filter(is_prime) | rv::take(10)) std::cout << i << ' ';
- 
使用 reverse_view/views::reverse以逆序迭代范围。这里的第一个代码片段打印出从 99 到 1 的前 10 个质数(降序),而第二个代码片段打印出从 1 到 99 的最后 10 个质数(升序):// prints 97 89 83 79 73 71 67 61 59 53 for (auto i : rv::iota(1, 100) | rv::reverse | rv::filter(is_prime) | rv::take(10)) std::cout << i << ' '; // prints 53 59 61 67 71 73 79 83 89 97 for (auto i : rv::iota(1, 100) | rv::reverse | rv::filter(is_prime) | rv::take(10) | rv::reverse) std::cout << i << ' ';
- 
使用 drop_view/views::drop跳过范围中的前 N 个元素。以下代码片段按升序打印 1 到 99 之间的素数,但跳过了序列中的前 10 个和最后 10 个素数:// prints 31 37 41 43 47 for (auto i : rv::iota(1, 100) | rv::filter(is_prime) | rv::drop(10) | rv::reverse | rv::drop(10) | rv::reverse) std::cout << i << ' ';
范围库还可以用于使用范围而不是迭代器调用算法。大多数算法都有为此目的的重载。以下是一些示例:
- 
确定范围的最大元素: std::vector<int> v{ 5, 2, 7, 1, 4, 2, 9, 5 }; auto m = rg::max(v); // 5
- 
对范围进行排序: rg::sort(v); // 1 2 2 4 5 5 7 9
- 
复制范围。以下代码片段将范围中的元素复制到标准输出流: rg::copy(v, std::ostream_iterator<int>(std::cout, " "));
- 
反转范围中的元素: rg::reverse(v);
- 
计算一个范围内的元素(验证谓词): auto primes = rg::count_if(v, is_prime);
它是如何工作的...
C++20 范围库提供了处理元素范围的各种组件。这包括:
- 
范围概念,如 range和view。
- 
范围访问函数,如 begin()、end()、size()、empty()和data()。
- 
范围工厂,用于创建元素序列,如 empty_view、single_view和iota_view。
- 
范围适配器或视图,它从范围创建一个延迟评估的视图,例如 filter_view、transform_view、take_view和drop_view。
范围被定义为可以由迭代器和结束哨兵迭代的一系列元素。范围类型取决于定义范围的迭代器的功能。以下概念定义了范围类型:
| 概念 | 迭代器类型 | 能力 | 
|---|---|---|
| input_range | input_iterator | 至少可以迭代一次以进行读取。 | 
| output_range | output_iterator | 可以迭代以进行写入。 | 
| forward_range | forward_iterator | 可以多次迭代。 | 
| bidirectional_range | bidirectional_iterator | 也可以以相反的顺序迭代。 | 
| random_access_range | random_access_iterator | 元素可以在常数时间内随机访问。 | 
| contiguous_range | contiguous_iterator | 元素在内存中连续存储。 | 
表 12.1:定义范围类型的概念列表
因为 forward_iterator 满足 input_iterator 的要求,bidirectional_iterator 满足 forward_iterator 的要求,依此类推(从上表中的顶部到底部),所以范围也是如此。forward_range 满足 input_range 的要求,bidirectional_range 满足 forward_range 的要求,依此类推。除了上表中列出的范围概念之外,还有其他范围概念。其中一个值得提的是 sized_range,它要求范围必须在常数时间内知道其大小。
标准容器满足不同范围概念的要求。其中最重要的列在以下表格中:
| 输入范围 | 前向范围 | 双向范围 | 随机访问范围 | 连续范围 | |
|---|---|---|---|---|---|
| forward_list | ✓ | ✓ | |||
| list | ✓ | ✓ | ✓ | ||
| dequeue | ✓ | ✓ | ✓ | ✓ | |
| array | ✓ | ✓ | ✓ | ✓ | ✓ | 
| vector | ✓ | ✓ | ✓ | ✓ | ✓ | 
| set | ✓ | ✓ | ✓ | ||
| map | ✓ | ✓ | ✓ | ||
| multiset | ✓ | ✓ | ✓ | ||
| multimap | ✓ | ✓ | ✓ | ||
| unordered_set | ✓ | ✓ | |||
| unordered_map | ✓ | ✓ | |||
| unordered_multiset | ✓ | ✓ | |||
| unordered_multimap | ✓ | ✓ | 
表 12.2:标准容器列表及其满足的要求
范围库的一个核心概念是范围适配器,也称为视图。视图是元素范围的非拥有包装器,复制、移动或赋值元素需要常数时间。视图是范围的组合适配。然而,这些适配是延迟发生的,仅在视图迭代时发生。
在上一节中,我们看到了使用各种视图的示例:过滤、转换、取、丢弃和反转。库中共有 16 个视图可用。所有视图都位于std::ranges命名空间中,名称如filter_view、transform_view、take_view、drop_view和reverse_view。然而,为了使用简便,这些视图可以用views::filter、views::take、views::reverse等形式的表达式使用。请注意,这些表达式的类型和值是不指定的,是编译器实现细节。
要了解视图是如何工作的,让我们看看以下示例:
std::vector<int> nums{ 1, 1, 2, 3, 5, 8, 13, 21 };
auto v = nums | rv::filter(is_prime) | rv::take(3) | rv::reverse;
for (auto i : v) std::cout << i << ' '; // prints 5 3 2 
for statement. The views are said to be lazy. The pipe operator (|) is overloaded to simplify the composition of views.
视图的组合等同于以下:
auto v = rv::reverse(rv::take(rv::filter(nums, is_prime), 3)); 
通常,以下规则适用:
- 
如果适配器 A只接受一个参数,一个范围R,那么A(R)和R|A是等价的。
- 
如果适配器 A接受多个参数,一个范围R和args...,那么以下三个是等价的:A(R, args...)、A(args...)(R)和R|A(args...)。
除了范围和范围适配器(或视图)之外,C++20 中通用算法的重载也可用,位于相同的std::ranges命名空间中。这些重载被称为约束算法。范围可以提供一个单一参数(如本食谱中的示例所示)或一个迭代器-哨兵对。此外,对于这些重载,返回类型已更改,以提供在算法执行期间计算出的附加信息。
更多内容...
标准范围库是基于由 Eric Niebler 创建的range-v3库设计的,可在 GitHub 上找到github.com/ericniebler/range-v3。这个库提供了一组更大的范围适配器(视图),以及提供突变操作的行动(如排序、删除、洗牌等)。从 range-v3 库到 C++20 范围库的过渡可以非常平滑。实际上,本食谱中提供的所有示例都适用于这两个库。你只需要包含适当的头文件并使用 range-v3 特定的命名空间:
#include "range/v3/view.hpp"
#include "range/v3/algorithm/sort.hpp"
#include "range/v3/algorithm/copy.hpp"
#include "range/v3/algorithm/reverse.hpp"
#include "range/v3/algorithm/count_if.hpp"
#include "range/v3/algorithm/max.hpp"
namespace rv = ranges::views;
namespace rg = ranges; 
使用这些替换,如何做到这一点… 部分的所有代码片段将继续使用符合 C++17 的编译器工作。
参见
- 
创建自己的范围视图,以了解如何通过用户定义的范围适配器扩展范围库的功能 
- 
使用概念指定模板参数的要求,以探索 C++20 概念的基础 
探索标准范围适配器
在上一个食谱中,我们探讨了范围库如何帮助我们简化使用集合(范围)时的各种任务,例如枚举、过滤、转换和反转。我们借助范围适配器做到了这一点。然而,我们只查看了一小部分适配器。标准库中还有更多适配器,其中一些包含在 C++20 中,其他包含在 C++23 中。在本食谱中,我们将探索标准库中的所有适配器。
准备工作
在本食谱中显示的代码片段中,我们将使用以下命名空间别名:
namespace rv = std::ranges::views;
namespace rg = std::ranges; 
此外,为了编译下面的代码片段,您需要包含 <ranges> 和 <algorithm> 头文件(用于范围库)。
如何做到这一点…
在 C++20 中,以下适配器可供使用:
- 
ranges::filter_view/views::filter表示底层序列的视图,但不包含不满足指定谓词的元素:std::vector<int> numbers{ 1, 1, 2, 3, 5, 8, 13 }; auto primes = numbers | rv::filter(is_prime); rg::copy(primes, std::ostream_iterator<int>{ std::cout, " " });2 3 5 13
- 
ranges::transform_view/views::transform表示在将指定函数应用于范围中的每个元素之后,底层序列的视图:std::vector<int> numbers{ 1, 1, 2, 3, 5, 8, 13 }; auto letters = numbers | rv::transform([](int i) { return static_cast<char>('A' + i); }); rg::copy(letters, std::ostream_iterator<char>{ std::cout, " " });B B C D F I N
- 
ranges::take_view/views::take表示从序列的开始处开始,包含指定数量的元素的底层序列视图:std::vector<int> numbers{ 1, 1, 2, 3, 5, 8, 13 }; auto some_numbers = numbers | rv::take(3); rg::copy(some_numbers, std::ostream_iterator<int>{ std::cout, " " });1 1 2
- 
ranges::take_while_view/views::take_while表示从开始处开始,包含所有满足给定谓词的连续元素的底层序列视图:std::vector<int> numbers{ 1, 1, 2, 3, 5, 8, 13 }; auto some_numbers = numbers | rv::take_while([](int i) { return i < 3; });}); rg::copy(some_numbers, std::ostream_iterator<int>{ std::cout, " " });1 1 2
- 
ranges::drop_view/views::drop表示跳过指定数量的元素之后的底层序列视图:std::vector<int> numbers{ 1, 1, 2, 3, 5, 8, 13 }; auto some_numbers = numbers | rv::drop(3); rg::copy(some_numbers, std::ostream_iterator<int>{ std::cout, " " });3 5 8 13
- 
ranges::drop_while_view/views::drop_while表示跳过所有满足给定谓词的连续元素(从开始处)之后的底层序列视图:std::vector<int> numbers{ 1, 1, 2, 3, 5, 8, 13 }; auto some_numbers = numbers | rv::drop_while([](int i) { return i < 3; }); rg::copy(some_numbers, std::ostream_iterator<int>{ std::cout, " " });3 5 8 13
- 
ranges::join_view/views::join展平范围视图;它表示由序列序列的所有元素组成的视图:std::vector<std::vector<int>> numbers{ {1, 1}, {2, 3}, {5, 8}, {13} }; auto joined_numbers = numbers | rv::join; rg::copy(joined_numbers, std::ostream_iterator<int>{ std::cout, " " });1 1 2 3 5 8 13
- 
ranges::split_view/views::split表示通过使用指定分隔符拆分视图获得的子范围视图。分隔符不是结果子范围的一部分:std::string text{ "Hello, world!" }; auto words = text | rv::split(' '); for (auto const word : words) { std::cout << std::quoted(std::string_view(word)) << ' '; }"Hello," "world!"
- 
ranges::lazy_split_view/views::lazy_split与split类似,但它以 延迟模式 运行,这意味着它不会在迭代下一个结果元素之前查找下一个分隔符。它与常量范围一起工作,这些范围不受split_view支持:std::string text{ "Hello, world!" }; auto words = text | rv::lazy_split(' '); for (auto const word : words) { std::cout << std::quoted(std::ranges::to<std::string>(word)) << ' '; }"Hello," "world!"
- 
ranges::reverse_view/views::reverse表示以逆序呈现元素的底层序列视图:std::vector<int> numbers{ 1, 1, 2, 3, 5, 8, 13 }; auto reversed_numbers = numbers | rv::reverse; rg::copy(reversed_numbers, std::ostream_iterator<int>{ std::cout, " " });13 8 5 3 2 1 1
- 
ranges::elements_view/views::elements表示对元组值底层序列的第 N 个元素的视图:std::vector<std::tuple<int, std::string_view>> numbers{ {1, "one"}, {1, "one"}, {2, "two"}, {3, "three"}, {5, "five"}, {8, "eight"}, {13, "thirteen"} }; auto some_numbers = numbers | rv::elements<0>; rg::copy(some_numbers, std::ostream_iterator<int>{ std::cout, " " });1 1 2 3 5 8 13auto some_names = numbers | rv::elements<1>; rg::copy(some_names, std::ostream_iterator<std::string_view>{ std::cout, " " });one one two three five eight thirteen
- 
ranges::keys_view/views::keys是ranges::elements_view<R, 0>的别名,以及views::elements<0>类型的对象:
ranges::values_view / views::values 是 ranges::elements_view<R, 1> 的别名,以及 views::elements<1> 类型的对象:
std::vector<std::pair<int, std::string_view>> numbers{
   {1, "one"},
   {1, "one"},
   {2, "two"},
   {3, "three"},
   {5, "five"},
   {8, "eight"},
   {13, "thirteen"} };
auto some_numbers = numbers | rv::keys;
rg::copy(some_numbers, std::ostream_iterator<int>{ std::cout, " " }); 
1 1 2 3 5 8 13 
auto some_names = numbers | rv::values;
rg::copy(some_names, 
         std::ostream_iterator<std::string_view>{ std::cout, " " }); 
one one two three five eight thirteen 
在 C++23 中,以下适配器被添加到标准库中:
- 
ranges::enumerate_view/views::enumerate表示对元组的视图,其中第一个元素是底层序列元素的零基于索引,第二个元素是对底层元素的引用:std::vector<std::string> words{ "one", "two", "three", "four", "five" }; auto enumerated_words = words | rv::enumerate; for (auto const [index, word] : enumerated_words) { std::println("{} : {}", index, word); }0 : one 1 : two 2 : three 3 : four 4 : five
- 
ranges::zip_view/views::zip表示由两个或更多底层视图创建的元组视图,其中第 N 个元组是由每个底层视图的第 N 个元素创建的:std::vector<int> numbers{ 1, 1, 2, 3, 5, 8, 13 }; std::vector<std::string> words{ "one", "two", "three", "four", "five" }; auto zipped = rv::zip(numbers, words); for (auto const [number, word] : zipped) { std::println("{} : {}", number, word); }1 : one 1 : two 2 : three 3 : four 5 : five
- 
ranges::zip_transform_view/views::zip_transform表示通过将给定的函数应用于两个或更多视图来生成的元素的视图。结果视图的第 N 个元素是由所有指定底层视图的第 N 个元素生成的:std::vector<int> numbers{ 1, 1, 2, 3, 5, 8, 13 }; std::vector<std::string> words{ "one", "two", "three", "four", "five" }; auto zipped = rv::zip_transform( [](int number, std::string word) { return std::to_string(number) + " : " + word; }, numbers, words); std::ranges::for_each(zipped, [](auto e) {std::println("{}", e); });1 : one 1 : two 2 : three 3 : four 5 : five
- 
ranges::adjacent_view/views::adjacent表示从底层视图中的 N 个元素的元组视图;每个元组是底层视图中的一个窗口,第 i 个元组包含索引在范围[i, i + N - 1]内的元素:std::vector<int> numbers{ 1, 1, 2, 3, 5, 8, 13 }; auto adjacent_numbers = numbers | rv::adjacent<3>; std::ranges::for_each( adjacent_numbers, [](auto t) { auto [a, b, c] = t; std::println("{},{},{}", a, b, c); });1,1,2 1,2,3 2,3,5 3,5,8 5,8,13
- 
ranges::adjacent_transform_view/views::adjacent_transform表示通过将指定函数应用于底层视图的 N 个相邻元素来生成的元素的视图;结果视图的第 i 个元素是通过将函数应用于底层范围中具有索引在范围[i, i + N - 1]内的元素生成的:std::vector<int> numbers{ 1, 1, 2, 3, 5, 8, 13 }; auto adjacent_numbers = numbers | rv::adjacent_transform<3>( [](int a, int b, int c) {return a * b * c; }); std::ranges::for_each(adjacent_numbers, [](auto e) {std::print("{} ", e); });2 6 30 120 520
- 
ranges::join_with_view/views::join_with与join_view类似,因为它将范围视图展平为单个视图;然而,它接受一个分隔符,该分隔符插入到底层范围元素之间:std::vector<std::vector<int>> numbers{ {1, 1, 2}, {3, 5, 8}, {13} }; auto joined_numbers = numbers | rv::join_with(0); rg::copy(joined_numbers, std::ostream_iterator<int>{ std::cout, " " });1 1 2 0 3 5 8 0 13
- 
ranges::slide_view/views::slide是一个类似于ranges::adjacent_view/views::adjacent的范围适配器,除了从底层序列中指定的窗口大小是在运行时指定的:std::vector<int> numbers{ 1, 1, 2, 3, 5, 8, 13 }; auto slide_numbers = numbers | rv::slide(3); std::ranges::for_each( slide_numbers, [](auto r) { rg::copy(r, std::ostream_iterator<int>{ std::cout, " " }); std::println(""); });1 1 2 1 2 3 2 3 5 3 5 8 5 8 13
- 
ranges::chunk_view/views::chunk表示底层视图的 N 个元素的子视图。最后一个块可能少于 N 个元素(如果底层视图的大小不是 N 的倍数):std::vector<int> numbers{ 1, 1, 2, 3, 5, 8, 13 }; auto chunk_numbers = numbers | rv::chunk(3); std::ranges::for_each( chunk_numbers, [](auto r) { rg::copy(r, std::ostream_iterator<int>{ std::cout, " " }); std::println(""); });1 1 2 3 5 8 13
- 
ranges::chunk_by_view/views::chunk_by表示由底层视图的子视图组成的视图,每次当提供给两个相邻元素的二进制谓词返回false时,都会分割底层视图:std::vector<int> numbers{ 1, 1, 2, 3, 5, 8, 13 }; auto chunk_numbers = numbers | rv::chunk_by([](int a, int b) {return a * b % 2 == 1; }); std::ranges::for_each( chunk_numbers, [](auto r) { rg::copy(r, std::ostream_iterator<int>{ std::cout, " " }); std::println(""); });1 1 2 3 5 8 13
- 
ranges::stride_view/views::stride是从底层视图中的某些元素组成的视图,从第一个元素开始,每次前进 N 个元素:std::vector<int> numbers{ 1, 1, 2, 3, 5, 8, 13 }; auto stride_numbers = numbers | rv::stride(3); rg::copy(stride_numbers, std::ostream_iterator<int>{ std::cout, " " });1 3 13
- 
ranges::cartesian_product_view/views::cartesian_product表示通过计算 1 个或多个底层视图的笛卡尔积来生成的元组视图:std::vector<int> numbers{ 1, 2 }; std::vector<std::string> words{ "one", "two", "three" }; auto product = rv::cartesian_product(numbers, words); rg::for_each( product, [](auto t) { auto [number, word] = t; std::println("{} : {}", number, word); });1 : one 1 : two 1 : three 2 : one 2 : two 2 : three
它是如何工作的…
我们在上一道菜谱中看到了范围适配器是如何工作的。在本节中,我们将仅查看一些你应该注意的适配器的细节和差异。
首先,让我们考虑adjacent_view和slide_view。它们在某种程度上是相似的,因为它们接受一个视图并产生这个底层视图的子视图的另一个视图。这些子视图被称为窗口,并具有指定的尺寸N。
第一个窗口从第一个元素开始,第二个从第二个元素开始,等等。然而,它们在两个显著方面有所不同:
- 
adjacent_view和slide_view的窗口大小N在编译时指定,对于adjacent_view,在运行时指定slide_view。
- 
由 adjacent_view表示的视图的元素是元组,而由slide_view表示的视图的元素是其他视图。
以下图表展示了这两个适配器的比较:

图 12.2:相邻视图<3>(R)和滑动视图(R, 3)的比较
当窗口大小为 2 时,你可以使用views::pairwise和views::pairwise_transform,它们分别是类型adjacent<2>和adjacent_transform<2>的对象。
接下来要查看的适配器对是split_view和lazy_split_view。它们都做同样的事情:根据给定的分隔符将视图分割成子范围,这个分隔符可以是单个元素或元素的视图。这两个适配器都不在结果子范围中包含分隔符。然而,这两个在关键方面有所不同:lazy_split_view适配器,正如其名称所暗示的,是惰性的,这意味着它不会在迭代下一个结果元素的下一个分隔符之前向前查看,而split_view会这样做。此外,split_view支持forward_range类型或更高类型的范围,但不能分割一个常量范围,而lazy_split_view支持input_range类型或更高类型的范围,并且可以分割一个常量范围。
提出的问题是什么来用以及何时使用?通常,你应该优先选择split_view,因为它比lazy_split_view(具有更高效的迭代器递增和比较)更高效。然而,如果你需要分割一个常量范围,那么split_view就不是一个选项,你应该使用lazy_split_view。
有两个适配器,join_view(在 C++20 中)和join_with_view(在 C++23 中),它们执行连接操作,将范围的范围转换为单个(扁平化)范围。它们之间的区别在于后者,join_with_view,在两个连续的底层范围之间的元素之间插入一个分隔符。
关于标准范围适配器的更多详细信息,您可以查阅在en.cppreference.com/w/cpp/ranges提供的在线文档。
参见
- 
使用 ranges 库迭代集合,了解 C++ ranges 库的基本原理 
- 
使用约束算法,了解与范围一起工作的标准泛型算法 
将范围转换为容器
将各种范围适配器应用于范围(如容器)的结果是一个复杂类型,难以类型化或记忆。通常,我们会使用auto指定符来指示链式适配器结果的类型,就像我们在前面的示例中看到的那样。范围是惰性的,这意味着它们只在迭代时才会被评估,并产生结果。然而,我们经常需要将一个或多个范围适配器应用于容器(如向量或映射)的结果存储起来。在 C++23 之前,这需要显式编码。但是,C++23 提供了一个范围转换函数,称为std::ranges::to,这使得这项任务变得简单。它还允许在不同容器之间进行转换。在这个示例中,我们将学习如何使用它。
准备工作
在以下代码片段中使用的is_prime()函数在探索标准范围适配器的示例中已展示,此处不再列出。
如何操作…
你可以使用std::ranges::to范围转换函数将范围转换为容器,如下所示:
- 
将范围转换为 std::vector:std::vector<int> numbers{ 1, 1, 2, 3, 5, 8, 13 }; std::vector<int> primes = numbers | std::views::filter(is_prime) | std::ranges::to<std::vector>(); std::ranges::copy(primes, std::ostream_iterator<int>(std::cout, " ")); std::println(""); std::string text{ "server=demo123;db=optimus" }; auto parts = text | std::views::lazy_split(';') | std::ranges::to<std::vector<std::string>>(); std::ranges::copy(parts, std::ostream_iterator<std::string>(std::cout, " ")); std::println("");
- 
将范围转换为映射类型,例如 std::unordered_multimap:std::vector<int> numbers{ 1, 1, 2, 3, 5, 8, 13 }; std::vector<std::string> words{"one", "two", "three", "four"}; auto zipped = std::views::zip(numbers, words) | std::ranges::to< std::unordered_multimap<int, std::string>>(); for (auto const [number, word] : zipped) { std::println("{} = {}", number, word); }
- 
将范围转换为 std::string:std::string text{ "server=demo123;db=optimus" }; std::string text2 = text | std::views::stride(3) | std::ranges::to<std::string>(); std::println("{}", text2);
它是如何工作的…
std::ranges::to范围转换函数从 C++23(功能测试宏__cpp_lib_ranges_to_container可以用来测试是否支持)的<ranges>头文件中可用。
尽管前面的示例展示了如何从一个范围转换为另一个范围,但std::ranges::to也可以用于在不同类型的容器之间进行转换,例如从向量到列表,或者从映射到对向量:
std::vector<int> numbers{ 1, 1, 2, 3, 5, 8, 13 };
std::list<int> list = numbers | std::ranges::to<std::list>(); 
std::list and not std::list<int>. However, there are scenarios where this is not possible, and you will get compiler errors unless you explicitly provide the full type. Such an example is shown next:
std::map<int, std::string> m{ {1, "one"}, {2, "two"}, {3, "three"} };
std::vector<std::pair<int, std::string>> words = 
   m | rg::to<std::vector<std::pair<int, std::string>>>(); 
当你使用管道(|)语法时,括号是强制性的;否则,你将得到编译错误(这些错误难以阅读):
std::vector<int> v {1, 1, 2, 3, 5, 8};
auto r = v | std::ranges::to<std::vector>;   // error 
正确的语法如下:
auto r = v | std::ranges::to<std::vector>();  // OK 
相关内容
- 
探索标准范围适配器,了解 C++20 和 C++23 中可用的范围适配器 
- 
使用约束算法,了解与范围一起工作的标准泛型算法 
创建自己的范围视图
C++20 的范围库简化了元素范围的处理。库中定义的 16 个范围适配器(视图)提供了有用的操作,如前一个示例中所示。然而,你可以创建自己的视图,这些视图可以与标准视图一起使用。在这个示例中,你将学习如何做到这一点。我们将创建一个名为trim的视图,它接受一个范围和一个一元谓词,返回一个没有满足谓词的前后元素的新范围。
准备工作
在这个示例中,我们将使用与上一个示例中相同的命名空间别名,其中rg是std::ranges的别名,rv是std::ranges::views的别名。
如何操作...
要创建一个视图,请执行以下操作:
- 
创建一个名为 trim_view的类模板,它从std::ranges::view_interface派生:template<rg::input_range R, typename P> requires rg::view<R> class trim_view : public rg::view_interface<trim_view<R, P>> { };
- 
定义类的内部状态,这至少应包括一个起始和结束迭代器以及视图所适配的可视化范围。对于这个适配器,我们还需要一个谓词,以及一个布尔变量来标记迭代器是否已被评估: private: R base_ {}; P pred_; mutable rg::iterator_t<R> begin_ {std::begin(base_)}; mutable rg::iterator_t<R> end_ {std::end(base_)}; mutable bool evaluated_ = false; void ensure_evaluated() const { if(!evaluated_) { while(begin_ != std::end(base_) && pred_(*begin_)) {begin_ = std::next(begin_);} while(end_ != begin_ && pred_(*std::prev(end_))) {end_ = std::prev(end_);} evaluated_ = true; } }
- 
定义一个默认构造函数(可以省略)和一个带有所需参数的 constexpr构造函数。第一个参数始终是范围。对于这个视图,其他参数是一个谓词:public: trim_view() = default; constexpr trim_view(R base, P pred) : base_(std::move(base)) , pred_(std::move(pred)) , begin_(std::begin(base_)) , end_(std::end(base_)) {}
- 
提供对内部数据的访问器,例如基本范围和谓词: constexpr R base() const & {return base_;} constexpr R base() && {return std::move(base_);} constexpr P const & pred() const { return pred_; }
- 
提供函数以检索起始和结束迭代器。为了确保视图是惰性的,这些迭代器应该仅在第一次使用时进行评估: constexpr auto begin() const { ensure_evaluated(); return begin_; } constexpr auto end() const { ensure_evaluated(); return end_ ; }
- 
提供其他有用的成员,例如一个函数,用于返回范围的大小: constexpr auto size() requires rg::sized_range<R> { return std::distance(begin_, end_); } constexpr auto size() const requires rg::sized_range<const R> { return std::distance(begin_, end_); }
将所有这些放在一起,视图看起来如下:
template<rg::input_range R, typename P> requires rg::view<R>
class trim_view : public rg::view_interface<trim_view<R, P>>
{
private:
  R base_ {};
  P pred_;
  mutable rg::iterator_t<R> begin_ {std::begin(base_)};
  mutable rg::iterator_t<R> end_   {std::end(base_)};
  mutable bool evaluated_ = false;
private:
  void ensure_evaluated() const
 {
    if(!evaluated_)
    {
      while(begin_ != std::end(base_) && pred_(*begin_))
      {begin_ = std::next(begin_);}
      while(end_ != begin_ && pred_(*std::prev(end_)))
      {end_ = std::prev(end_);}
      evaluated_ = true;
    }
  }
public:
  trim_view() = default;
  constexpr trim_view(R base, P pred)
    : base_(std::move(base))
    , pred_(std::move(pred))
    , begin_(std::begin(base_))
    , end_(std::end(base_))
  {}
  constexpr R base() const &       {return base_;}
  constexpr R base() &&            {return std::move(base_);}
  constexpr P const & pred() const { return pred_; }
  constexpr auto begin() const
 { ensure_evaluated(); return begin_; }
  constexpr auto end() const
 { ensure_evaluated(); return end_ ; }
  constexpr auto size() requires rg::sized_range<R>
 { return std::distance(begin_, end_); }
  constexpr auto size() const requires rg::sized_range<const R>
 { return std::distance(begin_, end_); }
}; 
为了简化用户定义视图与标准视图的组合性,以下也应执行:
- 
为 trim_view_range_adaptor_closure类创建用户定义的推导指南,用于类模板参数推导:template<class R, typename P> trim_view(R&& base, P pred) -> trim_view<rg::views::all_t<R>, P>;
- 
创建可以实例化 trim_view适配器的函数对象,并使用适当的参数。这些对象可以放在一个单独的命名空间中,因为它们代表实现细节:namespace details { template <typename P> struct trim_view_range_adaptor_closure { P pred_; constexpr trim_view_range_adaptor_closure(P pred) : pred_(pred) {} template <rg::viewable_range R> constexpr auto operator()(R && r) const { return trim_view(std::forward<R>(r), pred_); } }; struct trim_view_range_adaptor { template<rg::viewable_range R, typename P> constexpr auto operator () (R && r, P pred) { return trim_view( std::forward<R>(r), pred ) ; } template <typename P> constexpr auto operator () (P pred) { return trim_view_range_adaptor_closure(pred); } }; }
- 
重载之前定义的 trim_view_range_adaptor_closure类的管道操作符:namespace details { template <rg::viewable_range R, typename P> constexpr auto operator | ( R&& r, trim_view_range_adaptor_closure<P> const & a) { return a(std::forward<R>(r)) ; } }
- 
创建一个 trim_view_range_adaptor类型的对象,该对象可以用来创建trim_view实例。这可以在名为views的命名空间中完成,以与范围库的命名空间相似:namespace views { inline static details::trim_view_range_adaptor trim; }
它是如何工作的...
我们在这里定义的 trim_view 类模板是从 std::ranges::view_interface 类模板派生的。这是范围库中的一个辅助类,用于定义视图,使用怪异重复模板模式(CRTP)。trim_view 类有两个模板参数:范围类型,它必须满足 std::ranges::input_range 概念,以及谓词类型。
trim_view 类内部存储基本范围和谓词。此外,它需要一个起始和结束(哨兵)迭代器。这些迭代器必须指向不满足修剪谓词的范围中的第一个元素和最后一个元素之后的元素。然而,因为视图是一个惰性对象,这些迭代器在需要迭代范围之前不应被解析。以下图显示了这些迭代器在整数范围中的位置,当视图必须从范围的开始和结束处修剪奇数时 {1,1,2,3,5,6,4,7,7,9}:

图 12.3:在迭代开始之前(上方)和开始时(下方)范围、起始和结束迭代器的视觉概念表示
我们可以使用 trim_view 类来编写以下代码片段:
auto is_odd = [](int const n){return n%2 == 1;};
std::vector<int> n { 1,1,2,3,5,6,4,7,7,9 };
auto v = trim_view(n, is_odd);
rg::copy(v, std::ostream_iterator<int>(std::cout, " "));
// prints 2 3 5 6 4
for(auto i : rv::reverse(trim_view(n, is_odd)))
  std::cout << i << ' ';
// prints 4 6 5 3 2 
通过使用在 details 命名空间中声明的函数对象,使用 trim_view 类以及与其他视图的组合被简化,这些函数对象代表实现细节。然而,这些以及重载的管道运算符(|)使得可以将前面的代码重写如下:
auto v = n | views::trim(is_odd);
rg::copy(v, std::ostream_iterator<int>(std::cout, " "));
for(auto i : n | views::trim(is_odd) | rv::reverse)
  std::cout << i << ' '; 
应该提到的是,range-v3 库确实包含一个名为 trim 的范围视图,但它尚未移植到 C++20 范围库。这可能在标准的未来版本中发生。
参见
- 
使用范围库迭代集合,了解 C++ 范围库的基础 
- 
使用概念指定模板参数的要求,以探索 C++20 概念的基础 
- 
*第十章**,使用怪异重复模板模式进行静态多态,了解 CRTP 的工作原理 
使用约束算法
C++ 标准库具有超过 100 个通用算法(其中大多数在 <algorithm> 头文件中,一些在 <numeric> 头文件中)。我们在 第五章 中看到了其中的一些算法,在多个菜谱中,我们学习了如何在范围内搜索元素、排序范围、初始化范围等。算法的通用性源于它们使用迭代器(一个元素序列的开始和结束迭代器——一个范围)的事实,但这也有一个缺点,即需要更多的显式代码,这些代码需要一次又一次地重复。为了简化这些算法的使用,C++20 标准在 std::ranges 命名空间中提供了匹配的算法,这些算法与范围一起工作(但也为迭代器提供了重载)。这些范围库中的算法被称为 约束算法,并在 <algorithm> 头文件中可用。尽管在这里不可能查看所有这些算法,但在这个菜谱中,我们将看到如何使用其中的一些来初始化、排序和查找范围内的元素。
如何做到这一点...
你可以对范围执行各种操作,包括初始化、查找和排序,如下所示:
- 
使用 std::ranges::fill()将一个值赋给一个范围内的所有元素:std::vector<int> v(5); std::ranges::fill(v, 42); // v = {42, 42, 42, 42, 42}
- 
使用 std::ranges::fill_n()将一个值赋给一个范围内的指定数量的元素。要分配的第一个元素由一个输出迭代器指示:std::vector<int> v(10); std::ranges::fill_n(v.begin(), 5, 42); // v = {42, 42, 42, 42, 42, 0, 0, 0, 0, 0}
- 
使用 std::ranges::generate_n()将给定函数连续调用返回的值赋给一个范围中的多个元素。第一个元素由一个迭代器指示:std::vector<int> v(5); auto i = 1; std::ranges::generate_n(v.begin(), v.size(), [&i] { return I * i++; }); // v = {1, 4, 9, 16, 25}
- 
使用 std::ranges::iota()将递增的值赋给一个范围内的元素。值使用前缀operator++从一个初始指定的值开始递增:std::vector<int> v(5); std::ranges::iota(v, 1); // v = {1, 2, 3, 4, 5}
- 
使用 std::ranges::find()在一个范围内查找一个值;此算法返回一个指向第一个等于所提供值的元素的迭代器,如果存在这样的值,或者一个等于范围末尾的迭代器:std::vector<int> v{ 1, 1, 2, 3, 5, 8, 13 }; auto it = std::ranges::find(v, 3); if (it != v.cend()) std::cout << *it << '\n';
- 
使用 std::ranges::find_if()在一个范围内找到一个满足由一元谓词定义的标准的值。算法返回指向范围内第一个使谓词返回true的元素的迭代器,如果不存在这样的元素,则返回指向范围末尾的迭代器:std::vector<int> v{ 1, 1, 2, 3, 5, 8, 13 }; auto it = std::ranges::find_if(v, [](int const n) { return n > 10; }); if (it != v.cend()) std::cout << *it << '\n';
- 
使用 std::ranges::find_first_of()在另一个范围中搜索来自一个范围的任何值的出现;算法返回指向第一个找到的元素的迭代器(在搜索范围内),或者等于范围末尾的迭代器,否则:std::vector<int> v{ 1, 1, 2, 3, 5, 8, 13 }; std::vector<int> p{ 5, 7, 11 }; auto it = std::ranges::find_first_of(v, p); if (it != v.cend()) std::cout << "found " << *it << " at index " << std::ranges::distance(v.cbegin(), it) << '\n';
- 
使用 std::ranges::sort()对范围进行排序。你可以提供一个应用于元素的比较函数。这可以包括std::ranges::greater、std::ranges::less以及来自<functional>头文件的其它函数对象,对应于<、<=、>、>=、==和!=操作符:std::vector<int> v{ 3, 13, 5, 8, 1, 2, 1 }; std::ranges::sort(v); // v = {1, 1, 2, 3, 5, 8, 13} std::ranges::sort(v, std::ranges::greater()); // v = {13, 8, 5, 3, 2, 1 ,1}
- 
使用 std::ranges::is_sorted()检查一个范围是否已排序:std::vector<int> v{ 1, 1, 2, 3, 5, 8, 13 }; auto sorted = std::ranges::is_sorted(v); sorted = std::ranges::is_sorted(v, std::ranges::greater());
- 
使用 std::ranges::is_sorted_until()从范围的开始找到一个已排序的子范围:std::vector<int> v{ 3, 13, 5, 8, 1, 2, 1 }; auto it = std::ranges::is_sorted_until(v); auto length = std::ranges::distance(v.cbegin(), it); // length = 2
它是如何工作的…
除了一个之外,所有约束算法都位于 <algorithm> 头文件中。例外的是 std::ranges::iota(),它在 <numeric> 头文件中找到。在 如何做… 部分列出的算法只是可用约束算法的一小部分。它们被称为约束算法,因为它们的参数中定义了要求,这些要求是通过概念和约束来帮助实现的。以下是之前使用过的 std::ranges::find() 之一重载的定义:
template <ranges::input_range R, class T, class Proj = std::identity>
requires std::indirect_binary_predicate<
    ranges::equal_to,
    std::projected<ranges::iterator_t<R>, Proj>,
    const T*>
constexpr ranges::borrowed_iterator_t<R>
    find( R&& r, const T& value, Proj proj = {} ); 
std::ranges::find() algorithm, also presented in the previous section, invoked with a beginning and ending iterator:
std::vector<int> v{ 1, 1, 2, 3, 5, 8, 13 };
auto it = std::ranges::find(v.begin(), v.end(), 3);
if (it != v.cend()) std::cout << *it << '\n'; 
另一方面,有一些算法,如之前看到的 std::ranges::fill_n() 和 std::ranges::generate_n(),只有一个重载,它只接受一个从范围开始处的迭代器。
传统算法和约束算法之间的另一个区别是,后者没有指定执行策略的重载,而前者有。
约束算法相对于传统算法有以下几个优点:
- 
因为不需要检索范围的开始和结束迭代器,所以需要编写的代码更少。 
- 
它们是受约束的,使用概念和约束,这有助于在误用时提供更好的错误信息。 
- 
它们可以与由范围库定义的 ranges/views 一起使用。 
- 
其中一些有重载,允许你指定一个应用于元素的投影,然后在这个投影上应用指定的谓词。 
让我们先看看约束算法如何与范围交互。为此,我们考虑以下示例:
std::vector<int> v{ 3, 13, 5, 8, 1, 2, 1 };
auto range =
   v |
   std::views::filter([](int const n) {return n % 2 == 1; }) |
   std::views::transform([](int const n) {return n * n; }) |
   std::views::take(4);
std::ranges::for_each(range, 
                      [](int const n) {std::cout << n << ' '; });
std::cout << '\n';
auto it = std::ranges::find_if(range, 
                               [](int const n) {return n > 10; });
if (it != range.end())
   std::cout << *it << '\n'; 
在这个例子中,我们有一个整数向量。从这个向量中,我们过滤出偶数,将剩余的元素通过它们的平方进行转换,最后保留四个结果数字。结果是范围。它的类型太复杂,难以记住或编写;因此,我们使用 auto 说明符,让编译器推断它。
对于那些想知道实际类型是什么(在这个先前的例子中),它是std::ranges::take_view<std::ranges::transform_view<std::ranges::filter_view<std::ranges::ref_view<std::vector<int>>, lambda [](int n)->bool>, lambda [](int n)->int>>。
我们希望将结果值打印到控制台并找到第一个大于 10 的值(如果存在)。为此,我们使用std::ranges::for_each()和std::ranges::find_if(),传递range对象,而无需直接处理迭代器。
在列表中之前提到的最后一个优点是能够指定一个投影。投影是一个可调用对象(例如,一个函数对象,或成员的引用)。这个投影应用于范围中的元素。在投影的结果上,再应用另一个谓词。
要理解这是如何工作的,让我们考虑一个包含 ID、名称和价格的产品的列表。从这个列表中,我们希望找到具有特定价格的产品并打印其名称。列表定义如下:
struct Product
{
   int         id;
   std::string name;
   double      price;
};
std::vector<Product> products
{
   {1, "pen", 15.50},
   {2, "pencil", 9.99},
   {3, "rubber", 5.0},
   {4, "ruler", 5.50},
   {5, "notebook", 12.50}
}; 
使用传统算法,我们需要使用std::find_if()并传递一个 lambda 函数,该函数执行每个元素的检查:
auto pos = std::find_if(
   products.begin(), products.end(),
   [](Product const& p) { return p.price == 12.5; });
if (pos != products.end())
   std::cout << pos->name << '\n'; 
使用约束算法,我们可以使用std::ranges::find()的一个重载,它接受一个范围、一个值和一个投影,如下面的代码片段所示:
auto it = std::ranges::find(products, 12.50, &Product::price);
if (it != products.end())
   std::cout << it->name << '\n'; 
另一个类似的例子是按产品名称(升序)对范围进行字母排序:
std::ranges::sort(products, std::ranges::less(), &Product::name);
std::ranges::for_each(products, [](Product const& p) {
      std::cout << std::format("{} = {}\n", p.name, p.price); }); 
希望这些示例表明,在一般情况下,有充分的理由选择新的 C++20 约束算法而不是传统算法。然而,请注意,当您想要指定执行策略(例如,并行化或矢量化算法的执行)时,不能使用约束算法,因为这些重载不可用。
参见
- 
第五章,在范围中查找元素,了解搜索值序列的常规算法 
- 
第五章,排序范围,了解排序范围的常规算法 
- 
第五章,初始化范围,探索填充范围值的常规算法 
- 
使用概念指定模板参数的要求,探索 C++20 概念的基础 
为异步计算创建协程任务类型
C++20 标准的一个主要组成部分是协程。简单来说,协程是可以暂停和恢复的函数。协程是编写异步代码的替代方案。它们有助于简化异步 I/O 代码、延迟计算或事件驱动应用程序。当协程被暂停时,执行返回到调用者,并将恢复协程所需的数据存储在栈之外。因此,C++20 协程被称为 无栈。不幸的是,C++20 标准没有定义实际的协程类型,只提供了一个构建它们的框架。这使得在没有依赖第三方组件的情况下使用协程编写异步代码变得困难。
在本食谱中,您将学习如何编写表示异步计算的协程任务类型,该任务在任务被等待时开始执行。
准备工作
定义协程框架的几个标准库类型和函数可在 <coroutine> 头文件中找到,在 std 命名空间中。然而,您需要使用最低编译器版本来支持协程:MSVC 19.28(从 Visual Studio 2019 16.8)、Clang 17 或 GCC 10。
本食谱的目标是创建一个任务类型,使我们能够编写异步函数,如下所示:
task<int> get_answer()
{
  co_return 42;
}
task<> print_answer()
{
  auto t = co_await get_answer();
  std::cout << "the answer is " << t << '\n';
}
template <typename T>
void execute(T&& t)
{
  while (!t.is_ready()) t.resume();
};
int main()
{
  auto t = get_answer();
  execute(t);
  std::cout << "the answer is " << t.value() << '\n';
  execute(print_answer());
} 
如何操作...
要创建一个支持返回无值(task<>)、值(task<T>)或引用(task<T&>)的协程的任务类型,您应该执行以下操作:
- 
创建一个名为 promise_base的类,其内容如下:namespace details { struct promise_base { auto initial_suspend() noexcept { return std::suspend_always{}; } auto final_suspend() noexcept { return std::suspend_always{}; } void unhandled_exception() { std::terminate(); } }; }
- 
创建一个名为 promise的类模板,从promise_base派生,添加get_return_object()和return_value()方法,并持有协程返回的值:template <typename T> struct task; namespace details { template <typename T> struct promise final : public promise_base { task<T> get_return_object() noexcept; template<typename V, typename = std::enable_if_t< std::is_convertible_v<V&&, T>>> void return_value(V&& value) noexcept(std::is_nothrow_constructible_v<T, V&&>) { value_ = value; } T get_value() const noexcept { return value_; } private: T value_; }; }
- 
为 void类型特化promise类模板,并为get_return_object()和return_void()方法提供实现:namespace details { template <> struct promise<void> final : public promise_base { task<void> get_return_object() noexcept; void return_void() noexcept {} }; }
- 
为 T&特化promise类模板。为get_return_object()和return_value()提供实现,并存储协程返回的引用的指针:namespace details { template <typename T> struct promise<T&> final : public promise_base { task<T&> get_return_object() noexcept; void return_value(T& value) noexcept { value_ = std::addressof(value); } T& get_value() const noexcept { return *value_; } private: T* value_ = nullptr; }; }
- 
创建一个名为 task的类模板,其内容如下所示。此类型必须有一个名为promise_type的内部类型,并持有执行协程的句柄。task_awaiter和类成员在此列出:template <typename T = void> struct task { using promise_type = details::promise<T>; // task_awaiter // members private: std::coroutine_handle<promise_type> handle_ = nullptr; };
- 
创建一个名为 task_awaiter的可等待类,实现await_ready()、await_suspend()和await_resume()方法:struct task_awaiter { task_awaiter(std::coroutine_handle<promise_type> coroutine) noexcept : handle_(coroutine) {} bool await_ready() const noexcept { return !handle_ || handle_.done(); } void await_suspend( std::coroutine_handle<> continuation) noexcept { handle_.resume(); } decltype(auto) await_resume() { if (!handle_) throw std::runtime_error{ "broken promise" }; return handle_.promise().get_value(); } friend struct task<T>; private: std::coroutine_handle<promise_type> handle_; };
- 
提供类成员,包括转换构造函数、移动构造函数和移动赋值运算符、析构函数、 co_await运算符、检查协程是否完成的方法、恢复挂起协程的方法以及获取协程返回值的方法:explicit task(std::coroutine_handle<promise_type> handle) : handle_(handle) { } ~task() { if (handle_) handle_.destroy(); } task(task&& t) noexcept : handle_(t.handle_) { t.handle_ = nullptr; } task& operator=(task&& other) noexcept { if (std::addressof(other) != this) { if (handle_) handle_.destroy(); handle_ = other.handle_; other.handle_ = nullptr; } return *this; } task(task const &) = delete; task& operator=(task const &) = delete; T value() const noexcept { return handle_.promise().get_value(); } void resume() noexcept { handle_.resume(); } bool is_ready() const noexcept { return !handle_ || handle_.done(); } auto operator co_await() const& noexcept { return task_awaiter{ handle_ }; }
- 
实现 promise原始模板的get_return_object()成员及其特化。这必须在task类的定义之后完成:namespace details { template <typename T> task<T> promise<T>::get_return_object() noexcept { return task<T>{ std::coroutine_handle<promise<T>>::from_promise(*this)}; } task<void> promise<void>::get_return_object() noexcept { return task<void>{ std::coroutine_handle<promise<void>>::from_promise(*this)}; } template <typename T> task<T&> promise<T&>::get_return_object() noexcept { return task<T&>{ std::coroutine_handle<promise<T&>>::from_promise( *this)}; } }
它是如何工作的...
函数是执行一个或多个语句的代码块。你可以将它们赋给变量、将它们作为参数传递、获取它们的地址,当然,也可以调用它们。这些特性使它们成为 C++ 语言中的第一类公民。函数有时被称为 子程序。另一方面,协程是支持两个额外操作(挂起和恢复执行)的函数。
在 C++20 中,如果一个函数使用了以下任何一个,则该函数是一个协程:
- 
co_await操作符,它会在恢复执行之前挂起执行
- 
co_return关键字,用于完成执行并可选地返回一个值
- 
co_yield关键字,用于挂起执行并返回一个值
然而,并非每个函数都可以是协程。以下不能是协程:
- 
构造函数和析构函数 
- 
Constexpr 函数 
- 
具有可变数量参数的函数 
- 
返回 auto或概念类型的函数
- 
main()函数
协程由以下三个部分组成:
- 
一个 承诺对象,在协程内部操作,用于传递协程的返回值或异常。 
- 
一个 协程句柄,在协程外部操作,用于恢复执行或销毁协程帧。 
- 
协程帧,通常在堆上分配,包含承诺对象、通过值复制的协程参数、局部变量、生命周期超过当前挂起点的临时变量,以及挂起点的表示,以便可以进行恢复和销毁。 
承诺对象可以是任何实现了以下接口的类型,这是编译器所期望的:
| 默认构造函数 | 承诺必须是可默认构造的 | 
|---|---|
| initial_suspend() | 指示是否在初始挂起点发生挂起。 | 
| final_suspend() | 指示是否在最后一个挂起点发生挂起。 | 
| unhandled_exception() | 当异常从协程块中传播出来时调用。 | 
| get_return_object() | 函数的返回值。 | 
| return_value(v) | 启用 co_return v语句。它的返回类型必须是void。 | 
| return_void() | 启用 co_return语句。它的返回类型必须是void。 | 
| yield_value(v) | 启用 co_yield v语句。 | 
表 12.3:由承诺实现的接口成员
我们在这里实现的 promise 类型的 initial_suspend() 和 final_suspend() 的实现返回 std::suspend_always 的一个实例。这是标准定义的两个平凡的可等待类型之一,另一个是 std::suspend_never。它们的实现如下:
struct suspend_always
{
  constexpr bool await_ready() noexcept { return false; }
  constexpr void await_suspend(coroutine_handle<>) noexcept {}
  constexpr void await_resume() noexcept {}
};
struct suspend_never
{
  constexpr bool await_ready() noexcept { return true; }
  constexpr void await_suspend(coroutine_handle<>) noexcept {}
  constexpr void await_resume() noexcept {}
}; 
这些类型实现了 可等待 概念,它使得可以使用 co_await 操作符。这个概念需要三个函数。这些可以是自由函数或类成员函数。它们在以下表中列出:
| await_ready() | 指示结果是否就绪。如果返回值是 false(或可转换为false的值),则调用await_suspend()。 | 
|---|---|
| await_suspend() | 安排协程恢复或销毁。 | 
| await_resume() | 为整个 co_await e表达式提供结果。 | 
表 12.4:可等待概念所需的函数
我们在本食谱中构建的 task<T> 类型有几个成员:
- 
一个显式构造函数,它接受 std::coroutine_handle<T>类型的参数,表示对协程的非拥有句柄。
- 
析构函数用于销毁协程帧。 
- 
一个移动构造函数和移动赋值运算符。 
- 
删除了复制构造函数和复制赋值运算符,使得类只能移动。 
- 
返回一个实现可等待概念的 task_awaiter值的co_await操作符。
- 
is_ready(),一个返回布尔值的方法,指示协程值是否就绪。
- 
resume(),一个用于恢复协程执行的方法。
- 
value(),一个返回承诺对象所持有值的方法。
- 
一个内部承诺类型称为 promise_type(此名称是强制性的)。
如果在协程执行过程中发生异常,并且这个异常没有在协程中被处理,那么将调用承诺的 unhandled_exception() 方法。在这个简单的实现中,这种情况没有被处理,程序会通过调用 std::terminate() 而异常终止。在下面的示例中,我们将看到一种可等待的实现,它可以处理异常。
让我们以以下协程为例,看看编译器是如何处理它的:
task<> print_answer()
{
  auto t = co_await get_answer();
  std::cout << "the answer is " << t << '\n';
} 
由于我们在本食谱中构建的所有机制,编译器将此代码转换为以下内容(此片段是伪代码):
task<> print_answer()
{
  __frame* context;
  task<>::task_awaiter t = operator co_await(get_answer());
  if(!t.await_ready())
  {
    coroutine_handle<> resume_co =
      coroutine_handle<>::from_address(context);
    y.await_suspend(resume_co);
    __suspend_resume_point_1:
  }
  auto value = t.await_resume();
  std::cout << "the answer is " << value << '\n';
} 
如前所述,main() 函数是那些不能作为协程的函数之一。因此,在 main() 中无法使用 co_await 操作符。这意味着在 main() 中等待协程完成必须以不同的方式完成。
这是通过一个名为 execute() 的函数模板来处理的,该模板运行以下循环:
while (!t.is_ready()) t.resume(); 
这个循环确保协程在每个挂起点之后恢复,直到其最终完成。
还有更多...
C++20 标准没有提供任何协程类型,自己编写是一个繁琐的任务。幸运的是,第三方库可以提供这些抽象。这样一个库是 libcoro,这是一个开源的实验性库,提供了一组通用原语,以利用 C++20 标准中描述的协程。该库可在 github.com/jbaldwin/libcoro 获取。它提供的组件之一是 task<T> 协程类型,类似于我们在本食谱中构建的类型。使用 coro::task<T> 类型,我们可以将我们的示例重写如下:
#include <iostream>
#include <coro/task.hpp>
#include <coro/sync_wait.hpp>
coro::task<int> get_answer()
{
  co_return 42;
}
coro::task<> print_answer()
{
  auto t = co_await get_answer();
  std::cout << "the answer is " << t << '\n';
}
coro::task<> demo()
{
  auto t = co_await get_answer();
  std::cout << "the answer is " << t << '\n';
  co_await print_answer();
}
int main()
{
   coro::sync_wait(demo());
} 
如你所见,代码与我们在这道菜谱的第一部分所写的非常相似。变化很小。通过使用此 libcoro 库或其他类似的库,你不需要关心实现协程类型的细节,而是专注于它们的使用。
在本书的第二版中使用的另一个库是 cppcoro,可在 github.com/lewissbaker/cppcoro 获取。然而,cppcoro 库已经多年未维护。尽管它仍然可在 GitHub 上找到,但它依赖于协程技术规范的实验性实现。例如,当使用 MSVC 时,这需要使用现在已过时的 /await 编译器标志。你应该只将此库作为编写协程原语(如我们将在下一道菜谱中看到的)的灵感来源。
参见
- 创建一个用于值序列的协程生成器类型,了解如何启用使用 co_yield从协程返回多个值
创建一个用于值序列的协程生成器类型
在之前的菜谱中,我们看到了如何创建一个协程任务,它能够实现异步计算。我们使用了 co_await 操作符来挂起执行直到恢复,并使用 co_return 关键字来完成执行并返回一个值。然而,另一个关键字 co_yield 也将一个函数定义为协程。它挂起协程的执行并返回一个值。它使协程能够在每次恢复时返回多个值。为了支持此功能,需要另一种类型的协程。这种类型被称为 生成器。从概念上讲,它就像一个流,以惰性方式(在迭代时)产生类型 T 的值序列。在这道菜谱中,我们将看到我们如何实现一个简单的生成器。
准备工作
这道菜谱的目标是创建一个生成器协程类型,使我们能够编写如下代码:
generator<int> iota(int start = 0, int step = 1) noexcept
{
  auto value = start;
  for (int i = 0;; ++i)
  {
    co_yield value;
    value += step;
  }
}
generator<std::optional<int>> iota_n(
  int start = 0, int step = 1,
  int n = std::numeric_limits<int>::max()) noexcept
{
  auto value = start;
  for (int i = 0; i < n; ++i)
  {
    co_yield value;
    value += step;
  }
}
generator<int> fibonacci() noexcept
{
  int a = 0, b = 1;
  while (true)
  {
    co_yield b;
    auto tmp = a;
    a = b;
    b += tmp;
  }
}
int main()
{
  for (auto i : iota())
  {
    std::cout << i << ' ';
    if (i >= 10) break;
  }
  for (auto i : iota_n(0, 1, 10))
  {
    if (!i.has_value()) break;
    std::cout << i.value() << ' ';
  }
  int c = 1;
  for (auto i : fibonacci())
  {
    std::cout << i << ' ';
    if (++c > 10) break;
  }
} 
建议你在继续进行这道菜谱之前,先遵循之前的菜谱,创建一个用于异步计算的协程任务类型。
如何做到这一点...
要创建一个支持同步惰性生成值序列的生成器协程类型,你应该做以下事情:
- 
创建一个名为 generator的类模板,其内容如下(每个部分的细节将在以下要点中介绍):template <typename T> struct generator { // struct promise_type // struct iterator // member functions // iterators private: std::coroutine_handle<promise_type> handle_ = nullptr; };
- 
创建一个名为 promise_type的内部类(名称是强制性的),其内容如下:struct promise_type { T const* value_; std::exception_ptr eptr_; auto get_return_object() { return generator{ *this }; } auto initial_suspend() noexcept { return std::suspend_always{}; } auto final_suspend() noexcept { return std::suspend_always{}; } void unhandled_exception() noexcept { eptr_ = std::current_exception(); } void rethrow_if_exception() { if (eptr_) { std::rethrow_exception(eptr_); } } auto yield_value(T const& v) { value_ = std::addressof(v); return std::suspend_always{}; } void return_void() {} template <typename U> U&& await_transform(U&& v) { return std::forward<U>(v); } };
- 
创建一个名为 iterator的内部类,其内容如下:struct iterator { using iterator_category = std::input_iterator_tag; using difference_type = ptrdiff_t; using value_type = T; using reference = T const&; using pointer = T const*; std::coroutine_handle<promise_type> handle_ = nullptr; iterator() = default; iterator(nullptr_t) : handle_(nullptr) {} iterator(std::coroutine_handle<promise_type> arg) : handle_(arg) {} iterator& operator++() { handle_.resume(); if (handle_.done()) { std::exchange(handle_, {}).promise() .rethrow_if_exception(); } return *this; } void operator++(int) { ++*this; } bool operator==(iterator const& _Right) const { return handle_ == _Right.handle_; } bool operator!=(iterator const& _Right) const { return !(*this == _Right); } reference operator*() const { return *handle_.promise().value_; } pointer operator->() const { return std::addressof(handle_.promise().value_); } };
- 
提供默认构造函数、从 promise_type对象显式构造函数、移动构造函数和移动赋值运算符,以及析构函数。删除复制构造函数和复制赋值运算符,以便类型只能移动:explicit generator(promise_type& p) : handle_( std::coroutine_handle<promise_type>::from_promise(p)) {} generator() = default; generator(generator const&) = delete; generator& operator=(generator const&) = delete; generator(generator&& other) : handle_(other.handle_) { other.handle_ = nullptr; } generator& operator=(generator&& other) { if (this != std::addressof(other)) { handle_ = other.handle_; other.handle_ = nullptr; } return *this; } ~generator() { if (handle_) { handle_.destroy(); } }
- 
提供函数 begin()和end()以启用对生成器序列的迭代:iterator begin() { if (handle_) { handle_.resume(); if (handle_.done()) { handle_.promise().rethrow_if_exception(); return { nullptr }; } } return { handle_ }; } iterator end() { return { nullptr }; }
它是如何工作的...
本菜谱中实现的承诺类型与先前的菜谱中的类似,尽管有一些差异:
- 
它被实现为一个内部类型,因此名称是 promise_type,因为协程框架要求协程类型有一个名为此的内部承诺类型。
- 
它支持处理未捕获的异常。在先前的菜谱中,这种情况没有被处理,并且 unhandled_exception()调用std::terminate()以异常终止进程。然而,这个实现会重试当前异常的指针并将其存储在std::exception_ptr对象中。这个异常在遍历生成的序列时被重新抛出(无论是调用begin()还是递增迭代器)。
- 
函数 return_value()和return_void()不存在,但被yield_value()替换,当co_yield expr表达式解析时调用。
生成器类也与之前菜谱中的任务类有一些相似之处:
- 
它是默认可构造的 
- 
它可以从一个承诺对象构造 
- 
它不是可复制构造的并且可复制 
- 
它是可移动构造的并且可移动 
- 
它的析构函数销毁协程帧 
这个类没有重载 co_await 操作符,因为在生成器上等待没有意义;相反,它提供了 begin() 和 end() 函数,这些函数返回迭代器对象,使得可以遍历值的序列。这个生成器被称为懒生成器,因为它不会在协程被恢复(无论是通过调用 begin() 还是递增迭代器)之前产生新值。协程是创建为挂起的,并且它的第一次执行只有在调用 begin() 函数时才开始。执行会继续,直到第一个 co_yield 语句或直到协程完成执行。同样,递增迭代器将恢复协程的执行,它将继续,直到下一个 co_yield 语句或直到其完成。
以下示例显示了一个生成多个整数值的协程。它不是通过使用循环,而是通过重复 co_yield 语句来实现的:
generator<int> get_values() noexcept
{
  co_yield 1;
  co_yield 2;
  co_yield 3;
}
int main()
{
  for (auto i : get_values())
  {
    std::cout << i << ' ';
  }
} 
重要的是要注意,协程只能使用 co_yield 关键字并同步产生值。在这个特定实现中,协程内部不支持使用 co_await 操作符。要能够通过使用 co_await 操作符挂起执行,需要不同的实现。
更多...
在先前的菜谱中提到的 libcoro 库有一个 generator<T> 类型,可以用它来代替我们在这里创建的类型。实际上,通过将我们的 generator<T> 替换为 coro::generator<T>,之前显示的代码片段将继续按预期工作。
参见
- 创建异步计算的协程任务类型,介绍 C++20 协程
使用 std::generator 类型生成值序列
C++20 标准对标准库进行了两项主要更新:范围库和协程。然而,关于后者,支持非常有限。C++20 标准仅定义了构建协程的框架。因此,像 libcoro 这样的库被创建出来,以提供实际的协程,例如我们在前两个菜谱中看到的 task 和 generator。C++23 标准引入了第一个标准协程,称为 std::generator。这汇集了范围和协程,因为 std::generator 是一个表示同步协程生成器的视图。这是我们在上一个菜谱中明确构建的标准实现,为值序列创建协程生成器类型。让我们看看它是如何工作的。
在撰写本文时,只有 GCC 14 支持此标准协程。
如何做到这一点…
要以惰性方式生成元素序列,编写一个协程:
- 
使用 std::generator<T>作为返回类型。
- 
使用 co_yield语句返回一个值。
std::generator<int> iota(int start = 0, int step = 1) noexcept
{
  auto value = start;
  for (int i = 0;; ++i)
  {
    co_yield value;
    value += step;
  }
}
int main()
{
  for (auto i : iota())
  {
    std::cout << i << ' ';
    if (i >= 10) break;
  }
} 
它是如何工作的…
新的 std::generator 类模板在其自己的头文件中可用,称为 <generator>。它从 std::ranges::view_interface 派生;因此,它是一个协程(可中断函数)评估产生的元素的视图。该类定义如下:
template<class Ref, class V = void, class Allocator = void>
class generator
 : public ranges::view_interface<generator<Ref, V, Allocator>>; 
每次协程被恢复并评估 co_yield 语句时,都会生成序列的新元素。以下是一个包含一系列 co_yield 语句的示例(不是一个循环)。总共,这个协程生成了三个元素。然而,如果只评估一次 get_values() 协程,它只会生成一个元素。我们称这为惰性评估:
std::generator<int> get_values() noexcept
{
  co_yield 1;
  co_yield 2;
  co_yield 3;
} 
std::generator 类型是一个同步生成器;协程只能使用 co_yield 语句来返回值。在协程内部无法使用 co_await 操作符。为此需要另一种类型的生成器,但目前尚无此类生成器。
使用 std::generator 类型生成值序列的另一个示例如下,它生成斐波那契数列。这是我们在上一个菜谱中看到的相同示例。唯一的变化是我们将 generator<int>(我们编写的)替换为 std::generator<int>,这是 C++23 标准中可用的:
std::generator<int> fibonacci() noexcept
{
  int a = 0, b = 1;
  while (true)
  {
    co_yield b;
    auto tmp = a;
    a = b;
    b += tmp;
  }
}
int main()
{
  int c = 1;
  for (auto i : fibonacci())
  {
    std::cout << i << ' ';
    if (++c > 10) break;
  }
} 
参见
- 
使用范围库迭代集合,了解 C++ 范围库的基本知识 
- 
为异步计算创建协程任务类型,介绍 C++20 协程 
- 
为值序列创建协程生成器类型,了解如何启用从协程返回多个值的 co_yield的使用
在 Discord 上了解更多
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
discord.gg/7xRaTCeEhx


 
                     
                    
                 
                    
                

 
                
            
         
         浙公网安备 33010602011771号
浙公网安备 33010602011771号