C--20-STL-秘籍-全-

C++20 STL 秘籍(全)

原文:zh.annas-archive.org/md5/9883ce163288b3e176dc1a4642162c90

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

关于本书

C++20 STL 烹饪书》提供了食谱,帮助你充分利用 C++ STL(标准模板库),包括 C++20 中引入的新特性。

C++ 是一种丰富而强大的语言。建立在 C 的基础上,通过类型安全、泛型编程和面向对象编程的语法扩展,C++ 实质上是一种低级语言。STL 提供了一组高级类、函数和算法,使你的编程工作更轻松、更有效,且更不容易出错。

我经常说 C++ 是五种语言拼凑在一起的一种。正式规范包括 1) 整个 C 语言,2) C 的神秘而强大的 宏预处理器,3) 功能丰富的 类/对象 模型,4) 一种称为 模板泛型编程 模型,最后,建立在 C++ 类和模板之上,5) STL

前置知识

本书假设你已具备 C++ 的基本理解,包括语法、结构、数据类型、类和对象、模板以及 STL。

本书中的食谱和示例假设你理解了需要 #include 某些头文件以使用库函数的需要。食谱通常不会列出所有必要的头文件,而是更专注于手头的技巧。鼓励你下载示例代码,其中包含所有必要的 #include 指令和其他前置内容。

你可以从 GitHub 下载示例代码:github.com/PacktPublishing/CPP-20-STL-Cookbook

这些假设意味着当你看到这样的代码片段时:

cout << "hello, world\n";

你应该已经知道,你需要将此代码放入 main() 函数中,你需要 #include <iostream> 头文件,而 coutstd:: 命名空间中的一个对象:

#include <iostream>
int main() {
    std::cout << "hello, world\n";
}

STL 的力量来源于模板(简要入门)

模板 是 C++ 进行 泛型编程 的方式,代码独立于类型同时保持类型安全。C++ 模板允许你使用标记作为类型和类的占位符,如下所示:

template<typename T>
T add_em_up(T& lhs, T& rhs) {
    return lhs + rhs;
}

模板可用于类和/或函数。在这个模板函数中,T 代表一个 泛型类型,这使得此代码可以在任何兼容的类或类型的上下文中使用:

int a{ 72 };  // see braced initialization below
int b{ 47 };
cout << add_em_up<int>(a, b) << "\n";

这将使用 int 类型调用模板函数。相同的代码可以用于任何支持 + 操作符的类型或类。

当编译器看到 模板调用,例如 add_em_up<int>(a, b),它会创建一个 特化。这就是使代码类型安全的原因。当你用 int 类型调用 add_em_up() 时,特化将类似于以下这样:

int add_em_up(int& lhs, int& rhs) {
    return lhs + rhs;
}

特化将模板替换为 T 占位符的所有实例,在这个例子中,是 int。每次用不同类型调用模板时,编译器都会为模板创建一个单独的特化。

STL 容器,如 vectorstackmap,以及它们的 迭代器 和其他支持函数和算法,都是使用模板构建的,这样它们可以在保持类型安全的同时通用。这就是 STL 如此灵活的原因。模板是 STL 中的 T

本书使用 C++20 标准

C++ 语言由国际标准化组织(ISO)大约每三年标准化一次。当前的标准称为 C++20(在此之前是 C++17、C++14 和 C++11)。C++20 于 2020 年 9 月获得批准。

C++20 为语言和 STL 添加了许多重要特性。新的特性如 格式模块范围等将对使用 STL 的方式产生重大影响。

同时也有一些便利的更改。例如,如果你想从 vector 中删除所有匹配的元素,你可能一直使用这样的 erase-remove 习语

auto it = std::remove(vec1.begin(), vec1.end(), value);
vec1.erase(it, vec1.end());

从 C++20 开始,你可以使用新的 std::erase 函数,并在一个简单的、优化的函数调用中完成所有这些操作:

std::erase(vec1, value);

C++20 有许多改进,既有细微之处,也有实质性的改进。在本书中,我们将涵盖其中很多内容,特别是与 STL 相关的部分。

带括号的初始化

你可能会注意到,本书中的配方经常使用 带括号的初始化 而不是更熟悉的 复制初始化

std::string name{ "Jimi Hendrix" };  // braced initialization
std::string name = "Jimi Hendrix";   // copy initialization

= 运算符既是赋值运算符也是复制运算符。它既常见又熟悉,而且它有效,所以我们一直都在使用它。

= 运算符的缺点是它也是一个复制构造函数,这通常意味着隐式类型转换。这既低效又可能导致意外的类型转换,这可能会很难调试。

带括号的初始化使用列表初始化运算符 {}(自 C++11 引入)来避免这些副作用。养成这样的习惯是好的,你会在本书中看到很多。

值得注意的是,T{} 的特殊情况保证为零初始化。

int x;      // uninitialized            bad  :(
int x = 0;  // zero (copy constructed)  good :)
int x{};    // zero (zero-initialized)  best :D

空括号的零初始化为初始化新变量提供了一个有用的快捷方式。

隐藏 std:: 命名空间

在本书的大多数情况下,练习将隐藏 std:: 命名空间。这主要是出于页面空间和可读性考虑。我们都知道大多数 STL 标识符都在 std:: 命名空间中。我通常会使用某种形式的 using 声明来避免在示例中重复前缀。例如,当使用 cout 时,你可以假设我已经包含了这样的 using 声明:

using std::cout;    // cout is now sans prefix
cout << "Hello, Jimi!\n"; 

我通常不会显示配方列表中的 using 声明。这使我们能够专注于示例的目的。

在你的代码中导入整个 std:: 命名空间是一种不好的做法。你应该避免使用这样的 using namespace 声明:

using namespace std;    // bad. don't do that. 
cout << "Hello, Jimi!\n"; 

std::命名空间包含成千上万的标识符,没有很好的理由在你的命名空间中用它们来造成混乱。冲突的可能性不是微不足道的,而且可能很难追踪。当你想要使用不带std::前缀的名称时,首选的方法是像上面那样一次导入一个名称。

为了进一步避免命名空间冲突,我经常为将要重用的类使用一个单独的命名空间。我倾向于使用namespace bw作为我的个人命名空间。你也可以使用对你来说有效的方法。

使用using声明类型别名

本书使用using指令而不是typedef来声明类型别名。

STL 类和类型有时可能会很冗长。例如,一个模板迭代器类可能看起来像这样:

std::vector<std::pair<int,std::string>>::iterator

长类型名不仅难以输入,而且容易出错。

一种常见的技巧是使用typedef来缩短长类型名:

typedef std::vector<std::pair<int,std::string>>::iterator vecit_t

这为我们的笨拙迭代器类型声明了一个别名。typedef是从 C 继承的,其语法反映了这一点。

从 C+11 开始,可以使用using关键字来创建类型别名:

using vecit_t = std::vector<std::pair<int,std::string>>::iterator;

在大多数情况下,using别名等同于typedef。最显著的区别是using别名可能是模板化的:

template<typename T>
using v = std::vector<T>;
v<int> x{};

由于这些原因,为了清晰起见,本书更倾向于使用using指令来声明类型别名。

简化的函数模板

从 C++20 开始,可以指定没有模板头的简化的函数模板。例如:

void printc(const auto& c) {
    for (auto i : c) {
        std::cout << i << '\n';
    }
}

参数列表中的auto类型就像一个匿名模板typename,它等同于:

template<typename C>
void printc(const C& c) {
    for (auto i : c) {
        std::cout << i << '\n';
    }
}

虽然 C++20 中才引入,但简化的函数模板已经被主要的编译器支持了一段时间。本书将在许多示例中使用简化的函数模板。

C++20 的format()函数

直到 C++20,我们可以在使用传统的printf()或 STL 的cout进行文本格式化之间进行选择。两者都有严重的缺陷,但我们使用它们是因为它们有效。从 C++20 开始,format()函数提供了受 Python 3 格式化程序启发的文本格式化。

本课程大量使用了新的 STL format()函数。请参阅第一章新 C++20 特性,以获取更全面的描述。

使用 STL 解决实际问题

本书中的食谱使用 STL 为实际问题提供实际解决方案。它们被设计为仅依赖于 STL 和 C++标准库,不使用任何外部库。这应该使你能够轻松地进行实验和学习,而不会受到安装和配置第三方代码的干扰。

现在,让我们用 STL(标准模板库)来享受一些乐趣吧。快乐学习!

本书面向的对象

本书是为希望从 C++20 标准模板库中获得更多内容的中级到高级C++程序员而编写的。为了充分利用本书,需要具备基本的编码知识和 C++概念。

本书涵盖的内容

第一章, 新 C++20 特性, 介绍了 C++20 中的新 STL 特性。目的是让您熟悉这些新语言特性,以便您可以在 STL 中使用它们。

第二章, 通用 STL 特性, 讨论了最近 C++版本中添加的现代 STL 特性。

第三章, STL 容器, 讨论了 STL 的全面容器库。

第四章, 兼容迭代器, 展示了如何使用和创建与 STL 兼容的迭代器。

第五章, Lambda 表达式, 讨论了与 STL 函数和算法一起使用 Lambda 的方法。

第六章, STL 算法, 提供了使用和创建与 STL 兼容的算法的食谱。

第七章, 字符串、流和格式化, 描述了 STL 的字符串和格式化类。

第八章, 实用类, 讨论了 STL 的日期和时间、智能指针、optionals 等实用类。

第九章, 并发与并行性, 描述了对并发性的支持,包括线程、async、原子类型等。

第十章, 使用文件系统, 讨论了std::filesystem类以及如何利用 C++20 带来的最新进展来使用它们。

第十一章, 更多想法, 提供了一些额外的解决方案,包括 trie 类、字符串分割等。这提供了如何将 STL 应用于实际问题的先进示例。

本书中的食谱使用 GCC 编译器

除非另有说明,本书中的大多数食谱都是使用 GCC 编译器,版本 11.2,截至本书撰写时的最新稳定版本开发的。

当我写这篇文章时,C++20 仍然很新,并且任何可用的编译器都没有完全实现。在三个主要编译器中,GCC(GNU)、MSVC(Microsoft)和Clang(Apple)中,MSVC 编译器在实现新标准方面进展最快。偶尔,我们可能会遇到在 MSVC 或其他编译器上实现但在 GCC 上未实现的功能,在这种情况下,我会注明我使用了哪个编译器。如果一个功能在任何可用的编译器上尚未实现,我会解释我无法对其进行测试。

我强烈建议您安装 GCC 以跟随本书中的食谱。GCC 在 GNU 通用公共许可证(GPL)下免费提供。获取 GCC 最新版本的最简单方法是安装Debian Linux(也是 GPL),并使用apttesting仓库。

如果您正在使用本书的数字版,我们建议您自己输入代码或从 GitHub 仓库(下一节中的链接)下载代码。这将避免从电子书复制粘贴格式化代码时产生的错误。

下载示例代码文件

您可以从 GitHub(https://github.com/PacktPublishing/CPP-20-STL-Cookbook)下载本书的示例代码文件。在更新和勘误的情况下,代码将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包可供选择,请访问github.com/PacktPublishing/。查看它们!

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“insert()方法接受一个initializer_list并调用私有函数_insert():”

代码块设置如下:

int main() {
    Frac f{ 5, 3 };
    cout << format("Frac: {}\n", f);
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

for(uint64_t i{ 2 }; i < n / 2; ++i) {
    if(n % i == 0) return false;
}

任何命令行输入或输出都按以下方式编写:

$ ./producer-consumer
Got 0 from the queue
Got 1 from the queue
Got 2 from the queue
finished!

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”

小贴士或重要注意事项

显示如下。

部分

在本书中,您将找到一些经常出现的标题(如何做…它是如何工作的…更多内容…另请参阅…)。

为了清楚地说明如何完成食谱,请按照以下方式使用这些部分:

如何做…

本节包含遵循食谱所需的步骤。

它是如何工作的…

本节通常包含对前节发生事件的详细解释。

更多内容…

本节包含有关食谱的附加信息,以便您对食谱有更深入的了解。

另请参阅…

本节提供了对其他有用信息的链接,以帮助您了解食谱。

联系我们

欢迎读者反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com给我们发邮件。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上遇到任何形式的我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且对撰写或参与书籍感兴趣,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《C++20 STL 烹饪秘籍》,我们非常乐意听取您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都非常重要,并将帮助我们确保我们提供高质量的内容。

前言

第一章:第一章: 新的 C++20 特性

本章主要集中介绍 C++20 为 STL 添加的一些更具吸引力的特性。其中一些特性您可以立即使用。其他特性可能需要等待您喜欢的编译器实现。但从长远来看,我预计您会想了解这些特性中的大多数。

C++20 标准新增了很多内容,远远超出了我们在这里所能涵盖的范围。以下是一些我认为将产生长期影响的特性。

在本章中,我们将介绍以下食谱:

  • 使用新的format库格式化文本

  • 使用constexpr编译时向量和字符串

  • 安全比较不同类型的整数

  • 使用“飞船”运算符<=>进行三路比较

  • 使用<version>头文件轻松找到特性测试宏

  • 使用概念和约束创建更安全的模板

  • 使用模块避免重新编译模板库

  • 使用范围创建容器视图

本章旨在使您熟悉 C++20 中的这些新特性,以便您可以在自己的项目中使用它们,并在遇到它们时理解它们。

技术要求

本章的代码文件可以在 GitHub 上找到:github.com/PacktPublishing/CPP-20-STL-Cookbook/tree/main/chap01

使用新的格式化库格式化文本

到目前为止,如果您想格式化文本,可以使用传统的printf函数或 STL 的iostream库。两者都有其优点和缺点。

基于printf的函数是从 C 继承而来的,并且经过 50 多年的证明,它们既高效、灵活又方便。格式化语法看起来可能有点晦涩,但一旦习惯了,就足够简单。

printf("Hello, %s\n", c_string);

printf的主要弱点是其缺乏类型安全。常见的printf()函数(及其相关函数)使用 C 的可变参数模型将参数传递给格式化器。当它起作用时,效果很好,但当参数类型与其对应的格式说明符不匹配时,可能会引起严重问题。现代编译器尽可能多地执行类型检查,但该模型本身有缺陷,保护作用有限。

STL 的iostream库以牺牲可读性和运行时性能为代价,带来了类型安全。iostream的语法不寻常,但熟悉。它重载了位左移运算符<<),允许一系列对象、操作数和格式化操作符,从而生成格式化输出。

cout << "Hello, " << str << endl;

iostream的弱点在于其复杂度,无论是语法还是实现。构建格式化字符串可能既冗长又晦涩。许多格式化操作符在使用后必须重置,否则会创建级联的格式化错误,这可能导致难以调试。该库本身庞大而复杂,导致代码比其printf等效版本大得多且运行速度慢。

这种令人沮丧的情况让 C++程序员别无选择,只能在这两个有缺陷的系统之间做出选择,直到现在。

如何做到这一点...

新的 format 库位于 <format> 头文件中。截至本文写作时,format 仅在 MSVC(微软)编译器中实现。到你阅读本文时,它应该可以在更多系统上使用。否则,你可以从 fmt.devj.bw.org/fmt)作为第三方库使用其参考实现。

format 库是基于 Python 3 中的 str.format() 方法构建的。格式化字符串与 Python 中的格式化字符串基本相同,并且在大多数情况下可以互换。让我们看看一些简单的例子:

  • 在其最简单形式中,format() 函数接受一个 string_view 格式字符串和一个 可变参数包 的参数。它返回一个 string。其函数签名看起来像这样:

    template<typename... Args>
    string format(string_view fmt, const Args&... args);
    
  • format() 函数返回几乎任何类型或值的 string 表示形式。例如:

    string who{ "everyone" };
    int ival{ 42 };
    double pi{ std::numbers::pi };
    format("Hello, {}!\n ", who);   // Hello, everyone!
    format("Integer: {}\n ", ival); // Integer: 42
    format("π: {}\n", pi);          // π: 3.141592653589793
    

格式化字符串 使用花括号 {} 作为占位符。如果没有 格式说明符,花括号实际上是一个类型安全的 占位符,它将任何兼容类型的值转换为合理的字符串表示形式。

  • 你可以在格式化字符串中包含多个占位符,如下所示:

    format("Hello {} {}", ival, who);  // Hello 42 
                                       // everyone
    
  • 你可以指定替换值的顺序。这可能对国际化很有用:

    format("Hello {1} {0}", ival, who); // Hello everyone 42
    format("Hola {0} {1}", ival, who);  // Hola 42 everyone
    
  • 你可以左对齐(<)、右对齐(>)或居中对齐(^)值,可以带或不带填充字符:

    format("{:.<10}", ival);  // 42........
    format("{:.>10}", ival);  // ........42
    format("{:.¹⁰}", ival);  // ....42....
    
  • 你可以设置值的十进制精度:

    format("π: {:.5}", pi);  // π: 3.1416
    
  • 以及更多更多。

这是一个丰富且完整的格式化规范,它提供了 iostream 的类型安全,以及 printf 的性能和简单性,实现了两者的最佳结合。

它是如何工作的……

format 库尚未包含 print() 函数,该函数计划在 C++23 中实现。format() 函数本身返回一个 string 对象。因此,如果你想打印字符串,你需要使用 iostreamcstdio。 (悲伤的表情。)

你可以使用 iostream 打印字符串:

cout << format("Hello, {}", who) << "\n";

或者你也可以使用 cstdio

puts(format("Hello, {}", who).c_str());

这两种方法都不理想,但编写一个简单的 print() 函数并不困难。我们可以通过这个过程了解 format 库的一些内部工作原理。

这里是使用 format 库实现的 print() 函数的一个简单示例:

#include <format>
#include <string_view>
#include <cstdio>
template<typename... Args>
void print(const string_view fmt_str, Args&&... args) {
    auto fmt_args{ make_format_args(args...) };
    string outstr{ vformat(fmt_str, fmt_args) };
    fputs(outstr.c_str(), stdout);
} 

这使用与 format() 函数相同的参数。第一个参数是格式字符串的 string_view 对象。随后是一个参数的可变参数包。

make_format_args() 函数接受参数包并返回一个包含 类型擦除值 的对象,这些值适合格式化。然后,该对象被传递给 vformat(),它返回一个适合打印的 string。我们使用 fputs() 将值打印到控制台,因为它比 cout 效率要高得多。

我们现在可以使用这个 print() 函数代替 cout << format() 组合:

print("Hello, {}!\n", who);
print("π: {}\n", pi);
print("Hello {1} {0}\n", ival, who);
print("{:.¹⁰}\n", ival);
print("{:.5}\n", pi);

输出:

Hello, everyone!
π: 3.141592653589793
Hello everyone 42
....42....
3.1416

当您最终获得一个支持print()的 C++23 编译器时,您应该能够简单地用using std::print;替换上面的print()模板函数定义,并且所有的print()调用都应该继续工作。

还有更多……

能够格式化字符串和原始数据很有用,但为了让format库完全功能,它需要自定义以与您自己的类一起工作。

例如,这里有一个简单的struct结构,包含两个成员:一个分子和一个分母。我们希望它以分数的形式打印出来:

struct Frac {
    long n;
    long d;
};
int main() {
    Frac f{ 5, 3 };
    print("Frac: {}\n", f);    
}

当我编译这段代码时,会出现一系列错误,效果类似于“没有用户定义的转换操作符……”。不错。那么,让我们来修复它!

format系统遇到一个需要进行转换的对象时,它会寻找与相应类型对应的formatter对象的特化。标准特化包括字符串和数字等常见对象。

为我们的Frac类型创建一个特化相当简单:

template<>
struct std::formatter<Frac>
{
    template<typename ParseContext>
    constexpr auto parse(ParseContext& ctx) {
        return ctx.begin();
    }
    template<typename FormatContext>
    auto format(const Frac& f, FormatContext& ctx) {
        return format_to(ctx.out(), "{0:d}/{1:d}", 
            f.n, f.d);
    }
};

这个formatter特化是一个包含两个短模板函数的类:

  • parse()函数解析冒号(或如果没有冒号,则在开括号之后)之后的格式字符串,直到但不包括闭括号。(换句话说,指定对象类型的部分。)它接受一个ParseContext对象并返回一个迭代器。对于我们的目的,我们只需返回begin()迭代器,因为我们不需要为我们的类型添加任何新语法。您很少需要在这里放置其他内容。

  • format()函数接受一个Frac对象和一个FormatContext对象。它返回一个结束迭代器format_to()函数使这变得简单。它接受一个迭代器、一个格式字符串和一个参数包。在这种情况下,参数包是我们Frac类的两个属性,即分子和分母。

在这里,我们只需要提供一个简单的格式字符串"{0}/{1}"以及分子和分母的值。(01表示参数的位置。它们不是必需的,但将来可能会有用。)

现在我们为Frac创建了一个特化,我们可以将我们的对象传递给print()以获得可读的结果:

int main() {
    Frac f{ 5, 3 };
    print("Frac: {}\n", f);    
}

输出:

Frac: 5/3

C++20 的format库通过提供一个既高效又方便的类型安全文本格式化库来解决了一个长期存在的问题。

使用constexpr编译时向量和字符串

C++20 允许在多个新的上下文中使用constexpr。这提供了改进的效率,因为这些事情可以在编译时而不是运行时进行评估。

如何做到这一点……

该规范包括在constexpr上下文中使用stringvector对象的能力。重要的是要注意,这些对象本身可能不是constexpr声明的,但它们可以在编译时上下文中使用:

constexpr auto use_string() {
    string str{"string"};
    return str.size();
}

您还可以在constexpr上下文中使用算法:

constexpr auto use_vector() {
    vector<int> vec{ 1, 2, 3, 4, 5};
    return accumulate(begin(vec), end(vec), 0);
}

accumulate算法的结果在编译时和constexpr上下文中都是可用的。

它是如何工作的……

constexpr 修饰符声明了一个可能在编译时评估的变量或函数。在 C++20 之前,这仅限于使用字面值初始化的对象,或者是在有限约束内的函数。C++17 允许有某种程度的扩展使用,而 C++20 进一步扩展了它。

截至 C++20,STL 的 stringvector 类现在有了 constexpr 修饰的构造函数和析构函数,这使得它们可以在编译时调用。这也意味着为 stringvector 对象分配的内存 必须在编译时释放

例如,这个返回vectorconstexpr函数将无错误编译:

constexpr auto use_vector() {
    vector<int> vec{ 1, 2, 3, 4, 5};
    return vec;
}

但如果在运行时环境中尝试使用该结果,你将得到一个关于在常量评估期间分配的内存的错误:

int main() {
    constexpr auto vec = use_vector();
    return vec[0];
}

这是因为vector对象是在编译期间分配和释放的。因此,该对象在运行时不再可用。

另一方面,你可以在运行时使用vector对象的某些constexpr修饰的方法,例如size()

int main() {
    constexpr auto value = use_vector().size();
    return value;
}

因为size()方法是constexpr修饰的,表达式可以在编译时评估。

安全地比较不同类型的整数

比较不同类型的整数可能不会总是产生预期的结果。例如:

int x{ -3 };
unsigned y{ 7 };
if(x < y) puts("true");
else puts("false");

你可能期望这段代码打印 true,这是可以理解的。-3 通常小于 7。但它会打印 false

问题在于x是有符号的,而y是无符号的。标准化的行为是将有符号类型转换为无符号类型进行比较。这似乎有些反直觉,不是吗?确实,你不能可靠地将无符号值转换为相同大小的有符号值,因为有符号整数使用二进制补码表示(它使用最高位作为符号)。对于相同大小的整数,最大有符号值是无符号值的一半。使用这个例子,如果你的整数是 32 位,-3(有符号)变为FFFF FFFD(十六进制),或 4,294,967,293(无符号十进制),这并不是小于 7。

一些编译器在尝试比较有符号和无符号整数值时可能会发出警告,但大多数不会。

C++20 标准在 <utility> 头文件中包含了一组整数安全的比较函数。

如何做到这一点...

新的整数比较函数可以在 <utility> 头文件中找到。它们各自接受两个参数,对应于运算符的左右两侧。

#include <utility>
int main() {
    int x{ -3 };
    unsigned y{ 7 };
    if(cmp_less(x, y)) puts("true");
    else puts("false");
}

cmp_less() 函数给出了我们期望的结果。-3 小于 7,程序现在打印 true

<utility> 头文件提供了完整的整数比较函数。假设我们的 xy 的值,我们得到以下比较:

cmp_equal(x, y)          // x == y is false
cmp_not_equal(x, y)      // x != y is true
cmp_less(x, y)           // x < y is true
cmp_less_equal(x, y)     // x <= y is true
cmp_greater(x, y)        // x > y is false
cmp_greater_equal(x, y)  // x >= y is false

它是如何工作的...

这里是 C++20 标准中cmp_less()函数的示例实现,以给你一个更完整的关于其工作方式的了解:

template< class T, class U >
constexpr bool cmp_less( T t, U u ) noexcept
{
    using UT = make_unsigned_t<T>;
    using UU = make_unsigned_t<U>;
    if constexpr (is_signed_v<T> == is_signed_v<U>)
        return t < u;
    else if constexpr (is_signed_v<T>)
        return t < 0 ? true : UT(t) < u;
    else
        return u < 0 ? false : t < UU(u);
}

UTUU 别名被声明为 make_unsigned_t,这是一个在 C++17 中引入的有用的辅助类型。这允许安全地将有符号类型转换为无符号类型。

函数首先测试两个参数是否都是有符号或无符号。如果是这样,它返回一个简单的比较。

然后它测试任一边是否为有符号。如果该有符号值小于零,它可以不执行比较就返回 truefalse。否则,它将有符号值转换为无符号并返回比较结果。

类似的逻辑应用于每个其他比较函数。

使用“飞船”运算符 <=> 进行三向比较

三向比较运算符 (<=>),通常称为飞船运算符,因为从侧面看它像一只飞碟,是 C++20 中的新特性。你可能想知道,现有的六个比较运算符有什么问题?根本没问题,你将继续使用它们。飞船的目的在于为对象提供一个统一的比较运算符。

常用的双向比较运算符根据比较结果返回两种状态之一,truefalse。例如:

const int a = 7;
const int b = 42;
static_assert(a < b);

a < b 表达式使用小于比较运算符 (<) 来测试 a 是否小于 b。如果条件满足,比较运算符返回 true,如果不满足,则返回 false。在这种情况下,它返回 true,因为 7 小于 42。

三向比较的工作方式不同。它返回三种状态之一。如果操作数相等,飞船运算符将返回等于 0 的值;如果左操作数小于右操作数,则返回负值;如果左操作数大于右操作数,则返回正值

const int a = 7;
const int b = 42;
static_assert((a <=> b) < 0);

返回值不是一个整数。它是一个来自 <compare> 头文件的比较对象,与 0 进行比较。

如果操作数具有整型,运算符返回 <compare> 库中的 strong_ordering 对象。

strong_ordering::equal    // operands are equal
strong_ordering::less     // lhs is less than rhs
strong_ordering::greater  // lhs is greater than rhs

如果操作数具有浮点类型,运算符返回一个 partial_ordering 对象:

partial_ordering::equivalent  // operands are equivelant
partial_ordering::less        // lhs is less than rhs
partial_ordering::greater     // lhs is greater than rhs
partial_ordering::unordered   // if an operand is unordered

这些对象被设计成使用传统的比较运算符(例如,(a <=> b) < 0)与字面量零 (0) 进行比较。这使得三向比较的结果比传统比较更精确。

如果所有这些都显得有些复杂,那没关系。对于大多数应用,你永远不会直接使用飞船运算符。它的真正力量在于它作为对象统一比较运算符的应用。让我们深入探讨一下。

如何做到这一点...

让我们看看一个简单的类,它封装了一个整数并提供比较运算符:

struct Num {
    int a;
    constexpr bool operator==(const Num& rhs) const 
        { return a == rhs.a; }
    constexpr bool operator!=(const Num& rhs) const
        { return !(a == rhs.a); }
    constexpr bool operator<(const Num& rhs) const
        { return a < rhs.a; }
    constexpr bool operator>(const Num& rhs) const
        { return rhs.a < a; }
    constexpr bool operator<=(const Num& rhs) const
        { return !(rhs.a < a); }
    constexpr bool operator>=(const Num& rhs) const
        { return !(a < rhs.a); }
};

看到这样的比较运算符重载列表并不罕见。实际上,它应该与非成员友元更复杂,这些友元与运算符两边的对象一起工作。

使用新的飞船运算符,所有这些都可以通过一个重载来完成:

#include <compare>
struct Num {
    int a;
    constexpr Num(int a) : a{a} {}
    auto operator<=>(const Num&) const = default;
};

注意,我们需要包含 <compare> 头文件以支持三路运算符的返回类型。现在我们可以声明一些变量并通过比较来测试它们:

constexpr Num a{ 7 };
constexpr Num b{ 7 };
constexpr Num c{ 42 };
int main() {
    static_assert(a < c);
    static_assert(c > a);
    static_assert(a == b);
    static_assert(a <= b);
    static_assert(a <= c);
    static_assert(c >= a);
    static_assert(a != c);
    puts("done.");
}

编译器将自动优先选择 <=> 运算符进行每个比较。

因为默认的 <=> 运算符已经是 constexpr 安全的,所以我们不需要在我们的成员函数中声明它为 constexpr

它是如何工作的…

operator<=> 重载利用了 C++20 的新概念,重写表达式。在重载解析过程中,编译器根据一组规则重写表达式。例如,如果我们写 a < b,编译器将重写它为 (a <=> b < 0),以便与我们的成员运算符一起工作。编译器将重写 <=> 运算符的每个相关比较表达式,其中我们没有包含更具体的运算符。

事实上,我们不再需要一个非成员函数来处理与左侧兼容类型的比较。编译器将 合成 一个与成员运算符一起工作的表达式。例如,如果我们写 42 > a,编译器将合成一个反转运算符的表达式 (a <=> 42 < 0),以便与我们的成员运算符一起工作。

注意

<=> 运算符的优先级高于其他比较运算符,因此它总是首先评估。所有比较运算符都是从左到右评估的。

还有更多…

默认运算符可以与各种类一起正常工作,包括具有多个不同类型数值成员的类:

struct Nums {
  int i;
  char c;
  float f;
  double d;
  auto operator<=>(const Nums&) const = default;
};

但如果你有一个更复杂的数据类型呢?这里有一个简单的分数类的例子:

struct Frac {
    long n;
    long d;
    constexpr Frac(int a, int b) : n{a}, d{b} {}
    constexpr double dbl() const {
        return static_cast<double>(n) / 
          static_cast<double>(d);
    }
    constexpr auto operator<=>(const Frac& rhs) const {
        return dbl() <=> rhs.dbl();
    };
    constexpr auto operator==(const Frac& rhs) const {
        return dbl() <=> rhs.dbl() == 0;
    };
};

在这种情况下,我们需要定义 operator<=> 重载,因为我们的数据成员不是独立的标量值。这仍然相当简单,并且效果很好。

注意,我们还需要一个 operator== 重载。这是因为表达式重写规则不会重写带有自定义 operator<=> 重载的 ==!=。你只需要定义 operator==。编译器将根据需要重写 != 表达式。

现在,我们可以定义一些对象:

constexpr Frac a(10,15);  // compares equal with 2/3
constexpr Frac b(2,3);
constexpr Frac c(5,3);

我们可以用正常的比较运算符来测试它们,正如预期的那样:

int main() {
    static_assert(a < c);
    static_assert(c > a);
    static_assert(a == b);
    static_assert(a <= b);
    static_assert(a <= c);
    static_assert(c >= a);
    static_assert(a != c);
}

空间船运算符的力量在于其简化类中比较重载的能力。与独立重载每个运算符相比,它提高了简单性和效率。

使用 <version> 头文件轻松找到特性测试宏

C++ 在添加新功能的同时,一直提供了一些形式的特性测试宏。从 C++20 开始,这个过程被标准化,所有 库特性 测试宏都已添加到 <version> 头文件中。这将使测试代码中的新功能变得更加容易。

这是一个有用的特性,并且使用起来非常简单。

如何做到这一点…

所有功能测试宏都以前缀 __cpp_ 开头。库功能以 __cpp_lib_ 开头。语言功能测试宏通常由编译器定义。库功能测试宏在新 <version> 头文件中定义。你可以像使用任何其他预处理器宏一样使用它们:

#include <version>
#ifdef __cpp_lib_three_way_comparison
#   include <compare>
#else
#   error Spaceship has not yet landed
#endif

在某些情况下,你可以使用 __has_include 预处理器运算符(自 C++17 引入)来测试包含文件的存在。

#if __has_include(<compare>)
#   include <compare>
#else
#   error Spaceship has not yet landed
#endif

你可以使用 __has_include 来测试任何头文件的存在。因为它是一个预处理器指令,所以它不需要自己的头文件来工作。

它是如何工作的…

通常,你可以通过使用 #ifdef#if defined 测试非零值来使用功能测试宏。每个功能测试宏都有一个非零值,对应于它被标准委员会接受的那一年和一个月。例如,__cpp_lib_three_way_comparison 宏的值为 201907。这意味着它在 2019 年 7 月被接受。

#include <version>
#ifdef __cpp_lib_three_way_comparison
    cout << "value is " << __cpp_lib_three_way_comparison 
        << "\n"
#endif

输出:

$ ./working
value is 201907

宏的值在某些不常见的情况下可能很有用,在这些情况下,功能已更改,而你依赖于这些更改。对于大多数目的,你可以安全地忽略值,只需使用 #ifdef 测试非零值即可。

几个网站维护了一个功能测试宏的完整列表。我倾向于使用 cppreference (j.bw.org/cppfeature),但还有其他网站。

使用概念和约束创建更安全的模板

模板非常适合编写与不同类型一起工作的代码。例如,这个函数将适用于任何数值类型:

template <typename T>
T arg42(const T & arg) {
    return arg + 42;
}

但当你尝试用非数值类型调用它时会发生什么呢?

const char * n = "7";
cout << "result is " << arg42(n) << "\n";

输出:

Result is ion

这个程序编译和运行没有错误,但结果不可预测。实际上,这个调用是危险的,它很容易崩溃或成为漏洞。我更希望编译器生成错误信息,这样我就可以修复代码。

现在,有了概念,我可以这样写:

template <typename T>
requires Numeric<T>
T arg42(const T & arg) {
    return arg + 42;
}

requires 关键字是 C++20 中的新特性。它将约束应用于模板。Numeric 是一个只接受整数和浮点类型的 概念 的名称。现在,当我用非数值参数编译这段代码时,我得到了一个合理的编译器错误:

error: 'arg42': no matching overloaded function found
error: 'arg42': the associated constraints are not satisfied

这样的错误信息比大多数编译器错误更有用。

让我们更详细地看看如何在代码中使用概念和约束。

如何做到这一点…

概念只是一个命名的约束。上面的 Numeric 概念看起来像这样:

#include <concepts>
template <typename T>
concept Numeric = integral<T> || floating_point<T>;

这个 概念 需要一个满足 std::integralstd::floating_point 预定义概念的类型 T。这些概念包含在 <concepts> 头文件中。

概念和约束可用于类模板、函数模板或变量模板。我们已经看到了一个约束函数模板的例子,现在这里有一个简单的约束类模板示例:

template<typename T>
requires Numeric<T>
struct Num {
    T n;
    Num(T n) : n{n} {}
};

这里还有一个简单的变量模板示例:

template<typename T>
requires floating_point<T>
T pi{3.1415926535897932385L};

你可以在任何模板上使用概念和约束。让我们考虑一些进一步的例子。为了简单起见,我们将使用函数模板。

  • 约束可以使用概念或 类型特性 来评估类型的特征。你可以使用 <type_traits> 头文件中找到的任何类型特性,只要它返回一个 bool

例如:

template<typename T>
requires is_integral<T>::value  // value is bool
constexpr double avg(vector<T> const& vec) {
    double sum{ accumulate(vec.begin(), vec.end(), 
      0.0)
    };
    return sum / vec.size();
}
  • requires 关键字是 C++20 中新增的。它为模板参数引入了一个约束。在这个例子中,约束表达式测试模板参数是否满足类型特性 is_integral

  • 你可以使用 <type_traits> 头文件中找到的预定义特性,或者你可以定义自己的,就像定义一个模板变量一样。用于约束的变量必须返回 constexpr bool。例如:

    template<typename T>
    constexpr bool is_gt_byte{ sizeof(T) > 1 };
    

这定义了一个名为 is_gt_byte 的类型特性。这个特性使用 sizeof 运算符来测试类型 T 是否大于 1 字节。

  • 一个 概念 简单地是一个命名的约束集合。例如:

    template<typename T>
    concept Numeric = is_gt_byte<T> &&
        (integral<T> || floating_point<T>);
    

这定义了一个名为 Numeric 的概念。它使用我们的 is_gt_byte 约束,以及 <concepts> 头文件中的 floating_pointintegral 概念。我们可以用它来约束模板,使其只接受大于 1 字节大小的数值类型。

template<Numeric T>
T arg42(const T & arg) {
    return arg + 42;
}

你会注意到,我在模板声明中应用了约束,而不是在 requires 表达式的单独一行上。有几种方法可以应用一个概念。让我们看看这是如何工作的。

它是如何工作的…

你可以通过几种不同的方式应用一个概念或约束:

  • 你可以使用 requires 关键字应用一个概念或约束:

    template<typename T>
    requires Numeric<T>
    T arg42(const T & arg) {
        return arg + 42;
    }
    
  • 你可以在模板声明中应用一个概念:

    template<Numeric T>
    T arg42(const T & arg) {
        return arg + 42;
    }
    
  • 你可以在函数签名中使用 requires 关键字:

    template<typename T>
    T arg42(const T & arg) requires Numeric<T> {
        return arg + 42;
    }
    
  • 或者,你可以在函数模板的参数列表中使用一个概念以缩写形式:

    auto arg42(Numeric auto & arg) {
        return arg + 42;
    }
    

对于许多目的,选择这些策略之一可能只是风格问题。而且,在某些情况下,一个可能比另一个更好。

还有更多…

标准使用术语 合取析取原子 来描述可以用来构造约束的表达式类型。让我们定义这些术语。

你可以使用 &&|| 运算符组合概念和约束。这些组合分别称为 合取析取。你可以把它们看作逻辑的 ANDOR

通过使用 && 运算符和两个约束形成了一个 约束合取

Template <typename T>
concept Integral_s = Integral<T> && is_signed<T>::value;

逻辑与(&&)运算符仅在两侧都满足时才成立。它的计算顺序是从左到右。逻辑与的运算数是短路操作,也就是说,如果左侧的约束不满足,则不会评估右侧。

通过使用 || 运算符和两个约束形成了一个 约束析取

Template <typename T>
concept Numeric = integral<T> || floating_point<T>;

如果||运算符的任一侧被满足,则析取成立。它是从左到右评估的。合取的运算符是短路,也就是说,如果左侧约束成立,则不会评估右侧。

原子约束是一个返回bool类型、不能进一步分解的表达式。换句话说,它不是合取或析取。

template<typename T>
concept is_gt_byte = sizeof(T) > 1;

你还可以在原子约束中使用逻辑!)运算符。

template<typename T>
concept is_byte = !is_gt_byte<T>;

如预期的那样,!运算符将右侧的bool表达式的值取反。

当然,我们可以将这些表达式类型组合成更大的表达式。在以下示例中,我们可以看到每种约束表达式都有示例。

template<typename T>
concept Numeric = is_gt_byte<T> && 
    (integral<T> || floating_point<T>);

让我们分解一下。子表达式(integral<T> floating_point<T>)是一个析取。子表达式is_gt_byte<T> ()是一个合取。而每个子表达式integral<T>floating_point<T>is_gt_byte<T>都是原子的。

这些区别主要是为了描述目的。虽然了解细节是好的,但在编写代码时,可以安全地将它们视为简单的逻辑||&&!运算符。

概念和约束是 C++标准的受欢迎的补充,我期待在未来的项目中使用它们。

避免使用模块重新编译模板库

头文件自从 C 语言一开始就存在了。最初,它们主要用于文本替换宏和在不同翻译单元之间链接外部符号。随着模板的引入,C++利用头文件来携带实际代码。因为模板需要为特化的变化重新编译,所以我们已经很多年都在头文件中携带它们。随着 STL 在多年来的持续增长,这些头文件也相应地增长。这种状况已经变得难以管理,并且不再适合未来的扩展。

头文件通常包含比模板多得多的内容。它们通常包含配置宏和其他用于系统目的但不对应用户的符号。随着头文件数量的增加,符号冲突的机会也增加了。考虑到宏的丰富性,这是一个更大的问题,因为宏不受命名空间限制,也不受任何形式的安全类型的影响。

C++20 通过模块解决了这个问题。

如何做到这一点...

你可能习惯于创建如下所示的头文件:

#ifndef BW_MATH
#define BW_MATH
namespace bw {
    template<typename T>
    T add(T lhs, T rhs) {
        return lhs + rhs;
    }
}
#endif // BW_MATH

这个最小化示例说明了模块解决的一些问题。BW_MATH符号被用作包含保护器。它的唯一目的是防止头文件被多次包含,但它的符号在整个翻译单元中都被携带。当你将这个头文件包含到源文件中时,它可能看起来像这样:

#include "bw-math.h"
#include <format>
#include <string>
#include <iostream>

现在 BW_MATH 符号对包含的每个其他头文件以及由其他头文件包含的每个头文件都可用。这有很多机会发生冲突。而且记住,编译器无法检查这些冲突。它们是宏。这意味着在编译器有机会看到它们之前,它们就被预处理器翻译了。

现在我们来到了头文件的实际重点,即模板函数:

template<typename T>
T add(T lhs, T rhs) {
    return lhs + rhs;
}

因为它是一个模板,每次使用 add() 函数时,编译器都必须创建一个单独的特化。这意味着模板函数必须在每次调用时都进行解析和特化。这就是为什么模板放在头文件中的原因;源代码必须在编译时可用。随着 STL 的增长和演变,以及其许多大型模板类和函数,这成为一个显著的扩展性问题。

模块 解决了这些问题以及更多。

作为模块,bw-math.h 变为 bw-math.ixx(在 MSVC 命名约定中)并且看起来是这样的:

export module bw_math;
export template<typename T>
T add(T lhs, T rhs) {
    return lhs + rhs;
}

注意,唯一导出的符号是模块的名称 bw_math 和函数的名称 add()。这保持了命名空间整洁。

使用它时更干净。当我们将其用于 module-test.cpp 时,它看起来像这样:

import bw_math;
import std.core;
int main() {
    double f = add(1.23, 4.56);
    int i = add(7, 42);
    string s = add<string>("one ", "two");
    cout << 
        "double: " << f << "\n" <<
        "int: " << i << "\n" <<
        "string: " << s << "\n";
}

import 声明用于我们可能使用 #include 预处理器指令的地方。这些导入模块的符号表以进行链接。

我们示例的输出看起来像这样:

$ ./module-test
double: 5.79
int: 49
string: one two

模块版本的工作方式与在头文件中完全一样,只是更干净、更高效。

注意

编译的模块包括一个单独的 元数据文件(在 MSVC 命名约定中为 *module-name*.ifc`),它描述了模块接口。这允许模块支持模板。元数据包括足够的信息,使编译器能够创建模板特化。

它是如何工作的…

importexport 声明是 模块 实现的核心。让我们再次看看 bw-math.ixx 模块:

export module bw_math;
export template<typename T>
T add(T lhs, T rhs) {
    return lhs + rhs;
}

注意两个 export 声明。第一个使用 export module bw_math 导出模块本身,这声明了翻译单元为模块。每个模块文件顶部必须有一个模块声明,并且在任何其他语句之前。第二个 export 使函数名称 add() 可用于 模块消费者

如果你的模块需要 #include 指令或其他全局片段,你将需要首先使用如下简单的模块声明来声明你的模块:

module;
#define SOME_MACRO 42
#include <stdlib.h>
export module bw_math;
...

文件顶部单独一行上的 module; 声明引入了一个 全局模块片段。全局模块片段中只能出现预处理器指令。这必须立即后接一个标准模块声明(export module bw_math;)以及模块内容的其余部分。让我们更仔细地看看它是如何工作的:

  • export 声明使符号对 模块消费者 可见,即导入模块的代码。符号默认为私有。

    export int a{7};  // visible to consumer
    int b{42};        // not visible
    
  • 你可以导出一个块,如下所示:

    export {
        int a() { return 7; };     // visible 
        int b() { return 42; };    // also visible
    }
    
  • 你可以导出一个命名空间:

    export namespace bw {  // all of the bw namespace is visible
        template<typename T>
        T add(T lhs, T rhs) {  // visible as bw::add()
            return lhs + rhs;
        }
    }
    
  • 或者,你可以从命名空间中导出单个符号:

    namespace bw {  // all of the bw namespace is visible
        export template<typename T>
        T add(T lhs, T rhs) {  // visible as bw::add()
            return lhs + rhs;
        }
    }
    
  • 一个 import 声明将模块导入到 消费者 中:

    import bw_math;
    int main() {
        double f = bw::add(1.23, 4.56);
        int i = bw::add(7, 42);
        string s = bw::add<string>("one ", "two");
    }
    
  • 你甚至可以导入一个模块并将其导出给消费者以传递:

    export module bw_math;
    export import std.core;
    

export 关键字必须位于 import 关键字之前。

std.core 模块现在可供消费者使用:

import bw_math;
using std::cout, std::string, std::format;
int main() {
    double f = bw::add(1.23, 4.56);
    int i = bw::add(7, 42);
    string s = bw::add<string>("one ", "two");
    cout << 
        format("double {} \n", f) <<
        format("int {} \n", i) <<
        format("string {} \n", s);
}

正如你所见,模块是相对于头文件的一个简单、直接的选择。我知道我们中的许多人都在期待模块的广泛可用性。我认为这将大大减少我们对头文件的依赖。

注意

在撰写本文时,模块的唯一完整实现是在 MSVC 的 预览发布 中。模块文件扩展名(.ixx)可能因其他编译器而异。此外,合并的 std.core 模块是 MSVC 在此版本中实现 STL 作为模块的一部分。其他编译器可能不会使用此约定。当完全符合的实现发布时,一些细节可能会发生变化。

在示例文件中,我包含了基于 formatprint() 函数的模块版本。这适用于当前 MSVC 的预览发布版。一旦其他系统支持足够的模块规范,可能需要一些小的修改才能在其他系统上运行。

使用范围创建容器中的视图

新的 ranges 库是 C++20 中更重要的添加之一。它为过滤和处理容器提供了一种新的范式。范围提供了干净、直观的构建块,以实现更有效和可读的代码。

让我们先定义一些术语:

  • 一个 begin()end() 迭代器是一个范围。这包括大多数 STL 容器。

  • 一个 视图 是一个转换另一个底层范围的范围。视图是惰性的,意味着它们只在迭代时操作。视图从底层范围返回数据,并不拥有任何数据。视图以 O(1) 常数时间操作。

  • 一个 | 操作符。

    注意

    <ranges> 库使用 std::rangesstd::ranges::view 命名空间。认识到这很繁琐,标准包括了一个对 std::ranges::view 的别名,简单地称为 std::view。我仍然觉得这很繁琐。对于这个配方,我将使用以下别名,以节省空间,因为我认为它更优雅:

    namespace ranges = std::ranges;  // 省去手指的麻烦!

    namespace views = std::ranges::views;  

    这适用于本配方中的所有代码。

如何实现...

rangesviews 类位于 <ranges> 头文件中。让我们看看如何使用它们:

  • 一个 视图 应用到一个 范围 上,如下所示:

    const vector<int> nums{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
    auto result = ranges::take_view(nums, 5);
    for (auto v: result) cout << v << " ";
    

输出:

1 2 3 4 5 

ranges::take_view(range, n) 是一个返回前 n 个元素的视图。

你也可以使用 take_view()视图适配器 版本:

auto result = nums | views::take(5);
for (auto v: result) cout << v << " ";

输出:

1 2 3 4 5 

视图适配器 位于 std::ranges::views 命名空间中。一个 视图适配器| 操作符的左侧获取 范围操作数,就像 iostreams 使用 << 操作符的 iostreams 一样。| 操作数从左到右评估。

  • 因为视图适配器是 可迭代的,它也符合 范围 的资格。这使得它们可以串联使用,如下所示:

    const vector<int> nums{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
    auto result = nums | views::take(5) | 
       views::reverse;
    

输出:

5 4 3 2 1
  • filter() 视图使用谓词函数:

    auto result = nums | 
        views::filter([](int i){ return 0 == i % 2; });
    

输出:

2 4 6 8 10
  • transform() 视图使用转换函数:

    auto result = nums | 
        views::transform([](int i){ return i * i; });
    

输出:

1 4 9 16 25 36 49 64 81 100
  • 当然,这些视图和适配器适用于任何类型的范围:

    cosnt vector<string>
    words{ "one", "two", "three", "four", "five" };
    auto result = words | views::reverse;
    

输出:

five four three two one
  • ranges 库还包括一些 范围生成器iota 生成器将生成一个递增的值序列:

    auto rnums = views::iota(1, 10);
    

输出:

1 2 3 4 5 6 7 8 9

iota(value, bound) 函数生成一个从 value 开始,在 bound 之前结束的序列。如果省略 bound,则序列是无限的:

auto rnums = views::iota(1) | views::take(200);

输出:

1 2 3 4 5 6 7 8 9 10 11 12 […] 196 197 198 199 200

范围视图视图适配器 非常灵活且有用。让我们更深入地了解它们,以便更好地理解。

它是如何工作的…

为了满足 范围 的基本要求,一个对象必须至少有两个迭代器,begin()end(),其中 end() 迭代器是一个哨兵,用于确定范围的终点。大多数 STL 容器都符合 范围 的资格,包括 stringvectorarraymap 等,但容器适配器(如 stackqueue)除外,它们没有 beginend 迭代器。

视图 是一个操作范围并返回修改后范围的对象。视图是惰性操作的,不包含自己的数据。它不是保留底层数据的副本,而是根据需要简单地返回指向底层元素的迭代器。让我们看看这个代码片段:

vector<int> vi { 0, 1, 2, 3, 4, 5 };
ranges::take_view tv{vi, 2};
for(int i : tv) {
    cout << i << " ";
}
cout << "\n";

输出:

0 1

在这个例子中,take_view 对象接受两个参数,一个 范围(在这种情况下,一个 vector<int> 对象),和一个 计数。结果是包含 vector 中前 count 个对象的 视图。在评估时间,在 for 循环迭代期间,take_view 对象只需返回指向 vector 对象元素的迭代器,按需返回。在这个过程中,vector 对象不会被修改。

ranges 命名空间中的许多视图都有 views 命名空间中的相应的 范围适配器。这些适配器可以用作 按位或 (|) 操作符,就像管道一样,如下所示:

vector<int> vi { 0, 1, 2, 3, 4, 5 };
auto tview = vi | views::take(2);
for(int i : tview) {
    cout << i << " ";
}
cout << "\n";

输出:

0 1

如预期,| 操作符从左到右评估。因为范围适配器的结果是另一个范围,所以这些适配器表达式可以链式使用:

vector<int> vi { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
auto tview = vi | views::reverse | views::take(5);
for(int i : tview) {
    cout << i << " ";
}
cout << "\n";

输出:

9 8 7 6 5

该库包括一个用于与 谓词 一起使用的 filter 视图,用于定义简单的过滤器:

vector<int> vi { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
auto even = [](long i) { return 0 == i % 2; };
auto tview = vi | views::filter(even);

输出:

0 2 4 6 8

还包括一个用于与 转换函数 一起使用的 transform 视图:

vector<int> vi { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
auto even = [](int i) { return 0 == i % 2; };
auto x2 = [](auto i) { return i * 2; };
auto tview = vi | views::filter(even) | views::transform(x2);

输出:

0 4 8 12 16

该库中有许多有用的视图和视图适配器。请查看您喜欢的参考网站,或 (j.bw.org/ranges) 以获取完整列表。

还有更多…

从 C++20 开始,<algorithm> 头文件中的大多数算法都包括用于与 ranges 一起使用的版本。这些版本仍然在 <algorithm> 头文件中,但在 std::ranges 命名空间中。这使它们与旧算法区分开来。

这意味着,你不需要调用一个算法并传递两个迭代器:

sort(v.begin(), v.end());

你现在可以用一个范围来调用它,就像这样:

ranges::sort(v);

这确实更方便,但它实际上是如何帮助我们的呢?

考虑这样一个情况,你想要对一个向量的部分进行排序,你可以像这样使用旧的方法:

sort(v.begin() + 5, v.end());

这将按顺序对向量中第一个 5 个元素之后的元素进行排序。使用 ranges 版本,你可以使用一个视图来跳过前 5 个元素:

ranges::sort(views::drop(v, 5));

你甚至可以将视图组合起来:

ranges::sort(views::drop(views::reverse(v), 5));

事实上,你甚至可以使用范围适配器作为 ranges::sort 的参数:

ranges::sort(v | views::reverse | views::drop(5));

与此相反,如果你想要使用传统的 sort 算法和向量迭代器来完成这个任务,它看起来可能像这样:

sort(v.rbegin() + 5, v.rend());

虽然这确实更短,并且并不难理解,但我发现范围适配器版本更加直观。

你可以在 cppreference 网站上找到一个完整的算法列表,这些算法被限制只能与范围一起工作(https://j.bw.org/algoranges)。

在这个菜谱中,我们仅仅只是触及了 RangesViews 的表面。这个特性是许多不同团队超过十年工作的结晶,我预计它将从根本上改变我们在 STL 中使用容器的方式。

第二章:第二章:通用 STL 功能

本章是 STL 功能和技术的综合。这些大多是过去几年中引入的新功能,可能尚未得到广泛应用。这些有用的技术将提高你代码的简洁性和可读性。

在本章中,我们将介绍以下食谱:

  • 使用新的span类使你的 C 数组更安全

  • 使用结构化绑定返回多个值

  • ifswitch语句中初始化变量

  • 使用模板参数推导以简化并清晰

  • 使用if constexpr简化编译时决策

技术要求

你可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/CPP-20-STL-Cookbook/tree/main/chap02

使用新的span类使你的 C 数组更安全

C++20 新增的std::span类是一个简单的包装器,它创建了对连续对象序列的视图。span不拥有自己的数据,它引用底层结构中的数据。将其视为 C 数组的string_view。底层结构可能是一个C 数组、一个vector或 STL 的array

如何做到这一点...

你可以从任何兼容的连续存储结构创建span。最常见的情况将涉及 C 数组。例如,如果你尝试直接将 C 数组传递给一个函数,数组将被降级为指针,函数没有简单的方法知道数组的大小:

void parray(int * a);  // loses size information

如果你使用span参数定义你的函数,你可以传递一个 C 数组,它将被提升为span。以下是一个模板函数,它接受一个span并打印出元素数量和字节数:

template<typename T>
void pspan(span<T> s) {
    cout << format("number of elements: {}\n", s.size());
    cout << format("size of span: {}\n", s.size_bytes());
    for(auto e : s) cout << format("{} ", e);
    cout << "\n";
}

你可以将 C 数组传递给这个函数,它将自动提升为span

int main() {
    int carray[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
    pspan<int>(carray);
}

输出:

number of elements: 10
number of bytes: 40
1 2 3 4 5 6 7 8 9 10 

span的目的在于封装原始数据,以提供一定程度的保护和实用性,同时最小化开销。

它是如何工作的...

span类本身不拥有任何数据。数据属于底层数据结构。span本质上是对底层数据的视图。它还提供了一些有用的成员函数。

定义在<span>头文件中,span类看起来像这样:

template<typename T, size_t Extent = std::dynamic_extent>
class span {
    T * data;
    size_t count;
public:
    ... 
};

Extent参数是一个constexpr size_t类型的常量,它在编译时计算。它要么是底层数据中的元素数量,要么是std::dynamic_extent常量,表示大小是可变的。这允许span使用底层结构,如vector,其大小可能不是固定的。

所有成员函数都是constexprconst修饰的。成员函数包括:

重要提示

span类只是一个简单的包装器,不执行边界检查。所以,如果你尝试在包含n个元素的span中访问第n+1 个元素,结果是未定义的,这在技术上意味着“不好。不要这样做。”

使用结构化绑定返回多个值

结构化绑定使将结构值解包到单独的变量变得容易,从而提高了代码的可读性。

使用结构化绑定,你可以直接将成员值赋给变量,如下所示:

things_pair<int,int> { 47, 9 };
auto [this, that] = things_pair;
cout << format("{} {}\n", this, that);

输出:

47 9

如何做到这一点...

  • 结构化绑定pairtuplearraystruct一起工作。从 C++20 开始,这包括位域。此示例使用 C 数组:

    int nums[] { 1, 2, 3, 4, 5 };
    auto [ a, b, c, d, e ] = nums;
    cout << format("{} {} {} {} {}\n", a, b, c, d, e);
    

输出:

1 2 3 4 5

因为结构化绑定使用自动类型推导,它的类型必须是auto。个别变量的名称位于方括号内,[ a, b, c, d, e ]

在这个例子中,int C 数组nums包含五个值。这五个值使用结构化绑定分配给变量(abcde)。

  • 这也适用于 STL array对象:

    array<int,5> nums { 1, 2, 3, 4, 5 };
    auto [ a, b, c, d, e ] = nums;
    cout << format("{} {} {} {} {}\n", a, b, c, d, e);
    

输出:

1 2 3 4 5
  • 或者,你可以用它与tuple一起使用:

    tuple<int, double, string> nums{ 1, 2.7, "three" };
    auto [ a, b, c ] = nums;
    cout << format("{} {} {}\n", a, b, c);
    

输出:

1 2.7 three
  • 当你与struct一起使用时,它将按照定义的顺序获取变量:

    struct Things { int i{}; double d{}; string s{}; };
    Things nums{ 1, 2.7, "three" };
    auto [ a, b, c ] = nums;
    cout << format("{} {} {}\n", a, b, c);
    

输出:

1 2.7 three
  • 你可以使用引用与结构化绑定一起使用,这允许你修改绑定容器中的值,同时避免数据重复:

    array<int,5> nums { 1, 2, 3, 4, 5 };
    auto& [ a, b, c, d, e ] = nums;
    cout << format("{} {}\n", nums[2], c);
    c = 47;
    cout << format("{} {}\n", nums[2], c);
    

输出:

3 3
47 47

因为变量作为引用绑定,你可以将值赋给c,这将改变数组中的值(nums[2])。

  • 你可以声明数组为const以防止值被更改:

    const array<int,5> nums { 1, 2, 3, 4, 5 };
    auto& [ a, b, c, d, e ] = nums;
    c = 47;    // this is now an error 
    

或者,你可以声明绑定为const以获得相同的效果,同时允许在其他地方更改数组,并且仍然避免复制数据:

array<int,5> nums { 1, 2, 3, 4, 5 };
const auto& [ a, b, c, d, e ] = nums;
c = 47;    // this is also an error 

它是如何工作的...

结构化绑定使用自动类型推导将结构解包到你的变量中。它独立确定每个值的类型,并为每个变量分配相应的类型。

  • 因为结构化绑定使用自动类型推导,你不能为绑定指定类型。你必须使用auto。如果你尝试为绑定使用类型,你应该会得到一个合理的错误信息:

    array<int,5> nums { 1, 2, 3, 4, 5 };
    int [ a, b, c, d, e ] = nums;
    

输出:

error: structured binding declaration cannot have type 'int'
note: type must be cv-qualified 'auto' or reference to cv-qualified 'auto'

上面是当我尝试使用int与结构化绑定声明时 GCC 产生的错误。

  • 使用结构化绑定作为函数的返回类型很常见:

    struct div_result {
        long quo;
        long rem;
    };
    div_result int_div(const long & num, const long & denom) {
        struct div_result r{};
        r.quo = num / denom;
        r.rem = num % denom;
        return r;
    }
    int main() {
        auto [quo, rem] = int_div(47, 5);
        cout << format("quotient: {}, remainder {}\n",
          quo, rem);
    }
    

输出:

quotient: 9, remainder 2
  • 因为map容器类为每个元素返回一个对,所以使用结构化绑定检索键/值对很方便:

    map<string, uint64_t> inhabitants {
        { "humans",   7000000000 },
        { "pokemon", 17863376 },
        { "klingons",   24246291 },
        { "cats",    1086881528 }
    };
    // I like commas
    string make_commas(const uint64_t num) {
        string s{ std::to_string(num) };
        for(int l = s.length() - 3; l > 0; l -= 3) {
            s.insert(l, ",");
        }
        return s;
    }
    int main() {
        for(const auto & [creature, pop] : inhabitants) {
            cout << format("there are {} {}\n", 
                make_commas(pop), creature);
        }
    }
    

输出:

there are 1,086,881,528 cats
there are 7,000,000,000 humans
there are 24,246,291 klingons
there are 17,863,376 pokemon

使用结构化绑定解包结构可以使你的代码更清晰且易于维护。

在 if 和 switch 语句中初始化变量

从 C++17 开始,ifswitch现在有了初始化语法,就像 C99 以来的for循环一样。这允许你限制条件内使用的变量的作用域。

如何做到这一点...

你可能习惯于这样的代码:

const string artist{ "Jimi Hendrix" };
size_t pos{ artist.find("Jimi") };
if(pos != string::npos) {
    cout << "found\n";
} else {
    cout << "not found\n";
}

这使得变量pos暴露在条件语句的作用域之外,需要在这里进行管理,或者它可能与其他尝试使用相同符号的尝试发生冲突。

现在,你可以在if条件中放置初始化表达式:

if(size_t pos{ artist.find("Jimi") }; pos != string::npos) {
    cout << "found\n";
} else {
    cout << "not found\n";
}

现在pos变量的作用域被限制在条件的作用域内。这保持了你的命名空间干净且易于管理。

它是如何工作的...

初始化器表达式可以用于 ifswitch 语句。以下是每个的示例。

  • 使用 if 语句的初始化器表达式:

    if(auto var{ init_value }; condition) {
        // var is visible 
    } else {
        // var is visible 
    } 
    // var is NOT visible 
    

在初始化器表达式中定义的变量在整个 if 语句的作用域内可见,包括 else 子句。一旦控制流离开 if 语句的作用域,该变量将不再可见,并且将调用任何相关的析构函数。

  • 使用 switch 语句的初始化器表达式:

    switch(auto var{ init_value }; var) {
    case 1: ...
    case 2: ...
    case 3: ...
    ...
    Default: ...
    }
    // var is NOT visible 
    

在初始化器表达式中定义的变量在整个 switch 语句的作用域内可见,包括所有的 case 子句和可选的 default 子句。一旦控制流离开 switch 语句的作用域,该变量将不再可见,并且将调用任何相关的析构函数。

还有更多...

一个有趣的用例是限制锁定互斥锁的 lock_guard 的作用域。使用初始化器表达式可以使这变得简单:

if (lock_guard<mutex> lg{ my_mutex }; condition) { 
    // interesting things happen here 
}

lock_guard 在其构造函数中锁定互斥锁,并在析构函数中解锁。现在,当 lock_guard 超出 if 语句的作用域时,它将被自动销毁。在过去,你可能需要删除它或将整个 if 语句包围在一个额外的花括号块中。

另一个用例可能是使用使用输出参数的遗留接口,例如 SQLite 中的这个:

if(
    sqlite3_stmt** stmt, 
    auto rc = sqlite3_prepare_v2(db, sql, -1, &_stmt,
        nullptr);
    !rc) {
          // do SQL things
} else {  // handle the error 
    // use the error code 
    return 0;
}

在这里,我可以将语句句柄和错误代码局部化到 if 语句的作用域内。否则,我需要全局管理这些对象。

使用初始化器表达式将有助于使你的代码紧凑且无杂乱,更紧凑,更容易阅读。重构和管理你的代码也将变得更加容易。

使用模板参数推导以简化并清晰

模板参数推导发生在模板函数的参数类型或类模板构造函数(从 C++17 开始)的类型足够清晰,以至于编译器无需使用模板参数就能理解时。这个特性有一些规则,但主要是直观的。

如何实现...

通常,当你使用与模板明显兼容的参数的模板时,模板参数推导会自动发生。让我们考虑一些例子。

  • 在函数模板中,参数推导通常看起来像这样:

    template<typename T>
    const char * f(const T a) {
        return typeid(T).name();
    }
    int main() {
        cout << format("T is {}\n", f(47));
        cout << format("T is {}\n", f(47L));
        cout << format("T is {}\n", f(47.0));
        cout << format("T is {}\n", f("47"));
        cout << format("T is {}\n", f("47"s));
    }
    

输出:

T is int
T is long
T is double
T is char const *
T is class std::basic_string<char...

因为类型很容易识别,所以在函数调用中不需要指定模板参数,例如 f<int>(47)。编译器可以从参数中推导出 <int> 类型。

注意

上述输出显示了大多数编译器将使用缩写的有意义类型名称,例如 inticonst char *PKc 等。

  • 这同样适用于多个模板参数:

    template<typename T1, typename T2>
    string f(const T1 a, const T2 b) {
        return format("{} {}", typeid(T1).name(), 
            typeid(T2).name());
    }
    int main() {
        cout << format("T1 T2: {}\n", f(47, 47L));
        cout << format("T1 T2: {}\n", f(47L, 47.0));
        cout << format("T1 T2: {}\n", f(47.0, "47"));
    }
    

输出:

T1 T2: int long
T1 T2: long double
T1 T2: double char const *

在这里,编译器正在推断 T1T2 的类型。

  • 注意,类型必须与模板兼容。例如,你不能从字面量中取引用:

    template<typename T>
    const char * f(const T& a) {
        return typeid(T).name();
    }
    int main() {
        int x{47};
        f(47);  // this will not compile 
        f(x);   // but this will 
    }
    
  • 从 C++17 开始,你也可以使用模板参数推导与类。所以现在这将工作:

    pair p(47, 47.0);     // deduces to pair<int, double>
    tuple t(9, 17, 2.5);  // deduces to tuple<int, int, double>
    

这消除了对 std::make_pair()std::make_tuple() 的需要,因为你现在可以直接初始化这些类,而无需显式模板参数。std::make_* 辅助函数将保持可用,以保持向后兼容性。

它是如何工作的…

让我们定义一个类,这样我们就可以看到它是如何工作的:

template<typename T1, typename T2, typename T3>
class Thing {
    T1 v1{};
    T2 v2{};
    T3 v3{};
public:
    explicit Thing(T1 p1, T2 p2, T3 p3)
    : v1{p1}, v2{p2}, v3{p3} {}
    string print() {
        return format("{}, {}, {}\n",
            typeid(v1).name(),
            typeid(v2).name(),
            typeid(v3).name()
        );
    }
};

这是一个具有三种类型和三个相应数据成员的模板类。它有一个 print() 函数,该函数返回包含三个类型名称的格式化字符串。

没有模板参数推导的情况下,我必须这样实例化这个类型的对象:

Things<int, double, string> thing1{1, 47.0, "three" }

现在我可以这样做:

Things thing1{1, 47.0, "three" }

这既简单又不易出错。

当我在 thing1 对象上调用 print() 函数时,我得到这个结果:

cout << thing1.print();

输出:

int, double, char const *

当然,你的编译器可能会报告类似的内容。

在 C++17 之前,模板参数推导不适用于类,因此你需要一个辅助函数,它可能看起来像这样:

template<typename T1, typename T2, typename T3>
Things<T1, T2, T3> make_things(T1 p1, T2 p2, T3 p3) {
    return Things<T1, T2, T3>(p1, p2, p3);
}
...
auto thing1(make_things(1, 47.0, "three"));
cout << thing1.print();

输出:

int, double, char const *

STL 包含了一些这些辅助函数,例如 make_pair()make_tuple() 等。这些函数现在已过时,但为了与旧代码保持兼容性,将会继续维护。

还有更多…

考虑具有参数包的构造函数的情况:

template <typename T>
class Sum {
    T v{};
public:
    template <typename... Ts>
    Sum(Ts&& ... values) : v{ (values + ...) } {}
    const T& value() const { return v; }
};

注意构造函数中的 折叠表达式 (values + ...)。这是一个 C++17 特性,它将运算符应用于参数包的所有成员。在这种情况下,它将 v 初始化为参数包的总和。

这个类的构造函数接受任意数量的参数,其中每个参数可能属于不同的类。例如,我可以这样调用它:

Sum s1 { 1u, 2.0, 3, 4.0f };  // unsigned, double, int, 
                              // float
Sum s2 { "abc"s, "def" };     // std::sring, c-string

当然,这无法编译。模板参数推导未能为所有这些不同的参数找到一个共同类型。我们得到一个类似以下错误信息的消息:

cannot deduce template arguments for 'Sum'

我们可以通过一个 模板推导指南 来解决这个问题。推导指南是一种辅助模式,用于帮助编译器处理复杂的推导。这是我们的构造函数的指南:

template <typename... Ts>
Sum(Ts&& ... ts) -> Sum<std::common_type_t<Ts...>>;

这告诉编译器使用 std::common_type_t 特性,它试图为包中的所有参数找到一个共同类型。现在我们的参数推导工作正常,我们可以看到它选择了哪些类型:

Sum s1 { 1u, 2.0, 3, 4.0f };  // unsigned, double, int, 
                              // float
Sum s2 { "abc"s, "def" };     // std::sring, c-string
auto v1 = s1.value();
auto v2 = s2.value();
cout << format("s1 is {} {}, s2 is {} {}",
        typeid(v1).name(), v1, typeid(v2).name(), v2);

输出:

s1 is double 10, s2 is class std::string abcdef

使用 if constexpr 简化编译时决策

在需要根据编译时条件执行代码的地方使用 if constexpr(条件)语句。*条件* 可以是任何类型为boolconstexpr` 表达式。

如何去做…

考虑这样一个情况,你有一个需要根据模板参数类型执行不同操作的模板函数。

template<typename T>
auto value_of(const T v) {
    if constexpr (std::is_pointer_v<T>) {
        return *v;  // dereference the pointer
    } else {
        return v;   // return the value
    }
}
int main() {
    int x{47};
    int* y{&x};
    cout << format("value is {}\n", value_of(x));  // value
    cout << format("value is {}\n", value_of(y));  
                                                // pointer
    return 0;
}

输出:

value is 47
value is 47

模板参数 T 的类型在编译时是可用的。constexpr if 语句允许代码轻松区分指针和值。

它是如何工作的…

constexpr if语句的工作方式与普通的if语句类似,除了它在编译时进行评估。运行时代码将不会包含来自constexpr if语句的任何分支语句。考虑我们上面的分支语句:

if constexpr (std::is_pointer_v<T>) {
    return *v;  // dereference the pointer
} else {
        return v;   // return the value
    }

条件is_pointer_v<T>测试一个模板参数,该参数在运行时不可用。constexpr关键字告诉编译器这个if语句需要在编译时评估,而模板参数<T>是可用的。

这应该会使许多元编程场景变得更加简单。if constexpr语句在 C++17 及以后的版本中可用。

第三章:第三章:STL 容器

在本章中,我们将关注 STL 中的容器类。简而言之,容器 是一个包含其他对象集合或 元素 的对象。STL 提供了一套完整的容器类型,这些类型构成了 STL 本身的基础。

STL 容器类型的快速概述

STL 提供了一套全面的容器类型,包括 顺序容器关联容器容器适配器。以下是简要概述:

顺序容器

顺序容器提供了一个接口,其中元素按顺序排列。虽然您可能按顺序使用元素,但其中一些容器使用连续存储,而另一些则不使用。STL 包括以下顺序容器:

  • array 是一个固定大小的序列,在连续存储中持有特定数量的元素。一旦分配,它就不能改变大小。这是最简单且最快的连续存储容器。

  • vector 类似于可以收缩和扩展的数组。其元素连续存储,因此改变大小可能涉及分配内存和移动数据的开销。vector 可以保留额外的空间以减轻这种成本。从 vector末尾 之外的位置插入或删除元素将触发元素的重新排列,以保持连续存储。

  • list 是一种双向链表结构,允许在常数 (O(1)) 时间内插入和删除元素。遍历列表发生在线性 O(n) 时间。有一个单链表变体,称为 forward_list,它只能向前迭代。forward_list 使用更少的空间,并且比双向链表 list 更有效率,但缺乏一些功能。

  • deque(通常发音为 deck)允许随机访问其元素,类似于 vector,但不保证连续存储。

关联容器

关联容器将键与每个元素关联。元素通过其键而不是在容器中的位置进行引用。STL 关联容器包括以下容器:

  • set 是一个关联容器,其中每个元素也是它自己的键。元素按某种二叉树排序。set 中的元素是不可变的,不能修改,但可以插入和删除。set 中的元素是 唯一的,不允许重复。set 按照其排序运算符的顺序迭代。

  • multisetset 类似,具有非唯一的键,允许重复。

  • unordered_set 类似于不按顺序迭代的 set,元素不按任何特定顺序排序,而是根据它们的哈希值组织以实现快速访问。

  • unordered_multiset 类似于具有非唯一键的 unordered_set,允许重复。

  • map 是一个关联容器,用于键值对,其中每个 都映射到一个特定的 (或 有效负载)。键和值的类型可能不同。键是唯一的,但值不是。映射根据其排序运算符按键的顺序迭代。

  • multimap 类似于 map,但键不是唯一的,允许重复键。

  • unordered_map 类似于不按顺序迭代的映射。

  • unordered_multimap 类似于 unordered_map,但键不是唯一的,允许重复。

容器适配器

容器适配器是一个封装底层容器的类。容器类提供一组特定的成员函数来访问底层容器元素。STL 提供以下容器适配器:

  • stack 提供了 vectordequelist。如果没有指定底层容器,默认是 deque

  • queue 提供了 dequelist。如果没有指定底层容器,默认是 deque

  • priority_queue 根据严格的弱排序将最大值元素放在顶部。它以对数时间插入和提取为代价,提供对最大值元素的常数时间查找。底层容器可能是 vectordeque。如果没有指定底层容器,默认是 vector

在本章中,我们将介绍以下食谱:

  • 使用统一的删除函数从容器中删除项目

  • 在常数时间内从无序向量中删除项目

  • 直接且安全地访问向量元素

  • 保持向量元素排序

  • 高效地将元素插入到映射中

  • 高效修改映射项的键

  • 使用自定义键的 unordered_map

  • 使用集合对用户输入进行排序和过滤

  • 使用 deque 实现一个简单的 RPN 计算器

  • 使用映射实现单词频率计数器

  • 使用向量数组查找长句子

  • 使用多映射的待办事项列表

技术要求

你可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/CPP-20-STL-Cookbook/tree/main/chap03

使用统一的删除函数从容器中删除项目

在 C++20 之前,erase-remove 习语 通常用于高效地从 STL 容器中删除元素。这有点繁琐,但并不算太大的负担。通常使用如下这样的函数来完成这项任务:

template<typename Tc, typename Tv>
void remove_value(Tc & c, const Tv v) {
    auto remove_it = std::remove(c.begin(), c.end(), v);
    c.erase(remove_it, c.end());
}

std::remove() 函数来自 <algorithms> 头文件。std::remove() 搜索指定的值,并通过从容器末尾向前移动元素来删除它。它不会改变容器的大小。它返回一个指向移动范围末尾之后的迭代器。然后我们调用容器的 erase() 函数来删除剩余的元素。

这个两步过程现在通过新的统一删除函数简化为一步:

std::erase(c, 5);   // same as remove_value() function

这个函数调用与上面我们编写的 remove_value() 函数做的是同样的事情。

也有一个使用谓词函数的版本。例如,从数值容器中移除所有偶数编号的值:

std::erase_if(c, [](auto x) { return x % 2 == 0; });

让我们更详细地看看统一擦除函数。

如何做到这一点...

统一擦除函数有两种形式。第一种形式,称为 erase(),接受两个参数,一个容器和一个值:

erase(container, value); 

容器可以是任何顺序容器(vectorlistforward_listdeque),但不能是 array,因为 array 不能改变大小。

第二种形式,称为 erase_if(),接受一个容器和一个谓词函数:

erase_if(container, predicate); 

这种形式与任何可以与 erase() 一起工作的容器以及关联容器 setmap 及其多键和无序变体一起工作。

函数 erase()erase_if() 作为非成员函数定义在相应容器的头文件中。不需要包含另一个头文件。

让我们看看一些例子:

  • 首先,让我们定义一个简单的函数来打印顺序容器的尺寸和元素:

    void printc(auto & r) {
        cout << format("size({}) ", r.size());
        for( auto & e : r ) cout << format("{} ", e);
        cout << "\n";
    }
    

printc() 函数使用 C++20 的 format() 函数来格式化字符串以供 cout 使用。

  • 这是一个包含 10 个整数元素的 vector,使用我们的 printc() 函数打印:

    vector v{ 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    printc(v);
    

输出:

size: 10: 0 1 2 3 4 5 6 7 8 9

我们可以看到向量有 10 个元素。现在我们可以使用 erase() 来移除所有具有值 5 的元素:

erase(v, 5);
printc(v);

输出:

size: 9: 0 1 2 3 4 6 7 8 9

std::erase() 函数的 vector 版本定义在 <vector> 头文件中。在 erase() 调用之后,值为 5 的元素已被移除,并且向量中有 9 个元素。

  • 这与 list 容器一样有效:

    list l{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    printc(l);
    erase(l, 5);
    printc(l);
    

输出:

size: 10: 0 1 2 3 4 5 6 7 8 9
size: 9: 0 1 2 3 4 6 7 8 9

std::erase() 函数的 list 版本定义在 <list> 头文件中。在 erase() 调用之后,具有值 5 的元素已被移除,并且列表中有 9 个元素。

  • 我们可以使用 erase_if() 通过简单的谓词函数来移除所有偶数编号的元素:

    vector v{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    printc(v);
    erase_if(v, [](auto x) { return x % 2 == 0; });
    printc(v);
    

输出:

size: 10: 0 1 2 3 4 5 6 7 8 9
size: 5: 1 3 5 7 9
  • erase_if() 函数也可以与关联容器(如 map)一起使用:

    void print_assoc(auto& r) {
        cout << format("size: {}: ", r.size());
        for( auto& [k, v] : r ) cout << format("{}:{} ",
            k, v);
        cout << "\n";
    }
    int main() {
        map<int, string> m{ {1, "uno"}, {2, "dos"},
            {3, "tres"}, {4, "quatro"}, {5, "cinco"} };
        print_assoc(m);
        erase_if(m, 
            [](auto& p) { auto& [k, v] = p;
            return k % 2 == 0; }
        );
        print_assoc(m);
    }
    

输出:

size: 5: 1:uno 2:dos 3:tres 4:quatro 5:cinco
size: 3: 1:uno 3:tres 5:cinco

因为 map 的每个元素都作为 pair 返回,所以我们需要一个不同的函数来打印它们。print_assoc() 函数在 for 循环中使用 结构化绑定 来解包 pair 元素。我们还在 erase_if() 的谓词函数中使用结构化绑定来隔离键以过滤偶数编号的元素。

它是如何工作的...

erase()erase_if() 函数只是执行 erase-remove 习语 的包装器,一步完成。它们执行与函数相同的操作,如下所示:

template<typename Tc, typename Tv>
void remove_value(Tc & c, const Tv v) {
    auto remove_it = std::remove(c.begin(), c.end(), v);
    c.erase(remove_it, c.end());
}

如果我们考虑一个简单的 int 类型的 vector,称为 vec,具有以下值:

vector vec{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };

我们可以将 vec 可视化为一个包含 int 值的单行表:

图 3.1 – begin() 和 end() 迭代器

图 3.1 – begin() 和 end() 迭代器

begin() 迭代器指向第一个元素,而 end() 迭代器指向最后一个元素之后。这种配置是所有 STL 顺序容器的标准配置。

当我们调用 remove(c.begin(), c.end(), 5) 时,算法从 begin() 迭代器开始搜索匹配的元素。对于它找到的每个匹配元素,它将下一个元素移入其位置。它继续搜索和移动,直到达到 end() 迭代器。结果是容器,其中所有剩余的元素都在开头,没有删除的元素,并且保持原始顺序。end() 迭代器保持不变,剩余的元素是 未定义 的。我们可以这样可视化操作:

![图 3.2 – 移除一个元素图 3.2 – 移除一个元素

图 3.2 – 移除一个元素

remove() 函数返回一个迭代器 (remove_it),它指向移除元素后第一个元素。end() 迭代器保持 remove() 操作前的状态。为了进一步说明,如果我们使用 remove_if() 移除所有偶数元素,我们的结果将如下所示:

![图 3.3 – 移除偶数元素后的结果

![图 3.3 – 移除偶数元素后的结果

图 3.3 – 移除偶数元素后的结果

在这种情况下,剩下的只有五个奇数元素,然后是五个 未定义 值的元素。

然后调用容器的 erase() 函数来擦除剩余的元素:

c.erase(remove_it, c.end());

容器的 erase() 函数使用 remove_itend() 迭代器调用,以删除所有未定义的元素。

erase()erase_if() 函数调用 remove() 函数和容器的 erase() 函数,以便在一步中执行 erase-remove 习语

在常数时间内从无序向量中删除项目

使用统一的擦除函数(或 erase-remove 习语)从向量中间删除项目需要 O(n)(线性)时间。这是因为必须将向量末尾的元素移动以关闭被删除项目留下的空隙。如果向量中项目的顺序不重要,我们可以优化此过程以在 O(1)(常数)时间内完成。下面是如何做到这一点。

如何做到这一点…

这个方法利用了从向量末尾移除元素既快又简单的事实。

  • 让我们首先定义一个函数来打印向量:

    void printc(auto & r) {
        cout << format("size({}) ", r.size());
        for( auto & e : r ) cout << format("{} ", e);
        cout << '\n';
    }
    
  • 在我们的 main() 函数中,我们定义一个 int 类型的向量并使用 printc() 打印它:

    int main() {
        vector v{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
        printc(v);
    }
    

输出:

size(10) 0 1 2 3 4 5 6 7 8 9
  • 现在我们将编写一个函数来从向量中删除一个元素:

    template<typename T>
    void quick_delete(T& v, size_t idx) {
        if (idx < v.size()) {
            v[idx] = move(v.back());
            v.pop_back();
        }
    }
    

quick_delete() 函数接受两个参数,一个向量 v 和一个索引 idx。我们首先检查确保我们的索引在边界内。然后我们调用 <algorithms> 头文件中的 move() 函数,将向量的最后一个元素移动到我们的索引位置。最后,调用 v.pop_back() 函数从后面缩短向量。

  • 让我们还包括一个 quick_delete() 的版本,用于使用迭代器而不是索引。

    template<typename T>
    void quick_delete(T& v, typename T::iterator it) {
        if (it < v.end()) {
            *it = move(v.back());
            v.pop_back();
        }
    }
    

这个版本的 quick_delete() 从迭代器而不是索引开始操作。否则,它的工作方式与索引版本相同。

  • 现在我们可以从我们的main()函数中调用它:

    int main() {
        vector v{ 12, 196, 47, 38, 19 };
        printc(v);
        auto it = std::ranges::find(v, 47);
        quick_delete(v, it);
        printc(v);
        quick_delete(v, 1);
        printc(v);
    }
    

输出将看起来像这样:

size(5) 12 196 47 38 19
size(4) 12 196 19 38
size(3) 12 38 19

quick_delete()的第一个调用使用std::ranges::find()算法的迭代器。这将从向量中删除值47。注意向量末尾的值(19)取而代之。quick_delete()的第二次调用使用索引(1)从向量中删除第二个元素(196)。同样,向量末尾的值取而代之。

它是如何工作的…

quick_delete()函数使用一个简单的技巧来快速有效地从向量中删除元素。向量末尾的元素被移动(不是复制)到要删除的元素的位置。在这个过程中,被删除的元素被丢弃。然后,pop_back()函数从末尾缩短向量一个元素。

这利用了删除向量末尾元素特别便宜的事实。pop_back()函数以常数复杂度运行,因为它只需要更改end()迭代器。

此图显示了quick_delete()操作前后向量的状态:

![图 3.4 – quick_delete()前后]

![img/B18267_03_04.jpg]

图 3.4 – quick_delete()前后

quick_remove()操作简单地将向量末尾的元素移动到迭代器(it)的位置,然后通过缩短向量来减少一个元素。使用std::move()而不是赋值来移动元素是很重要的。移动操作比复制赋值快得多,尤其是对于大对象。

如果你不需要有序元素,这是一个非常高效的技巧。它发生在常数时间(O(1)),并且不触及任何其他元素。

直接且安全地访问向量元素

vector是 STL 中最广泛使用的容器之一,原因很好。它使用起来与array一样方便,但功能更强大,更灵活。通常的做法是使用[]运算符以这种方式访问向量中的元素:

vector v{ 19, 71, 47, 192, 4004 };
auto & i = v[2];

vector类还提供了一个用于相同目的的成员函数:

auto & i = v.at(2);

结果相同,但有一个重要的区别。at()函数会进行边界检查,而[]运算符则不会。这是故意的,因为它允许[]运算符与原始 C 数组保持兼容。让我们更详细地考察一下这一点。

如何做到这一点…

访问向量中具有索引的元素有两种方式。at()成员函数会进行边界检查,而[]运算符则不会。

  • 这里有一个简单的main()函数,它初始化一个向量并访问一个元素:

    int main() {
        vector v{ 19, 71, 47, 192, 4004 };
        auto & i = v[2];
        cout << format("element is {}\n", i);
    }
    

输出:

element is 47

在这里,我使用了[]运算符直接访问向量的第三个元素。与 C++中大多数顺序对象一样,索引从0开始,所以第三个元素是数字2

  • 向量有五个元素,编号从04。如果尝试访问编号为5的元素,这将超出向量的边界:

    vector v{ 19, 71, 47, 192, 4004 };
    auto & i = v[5];
    cout << format("element is {}\n", i);
    element is 0
    

这个结果极具误导性。这是一个常见的错误,因为人类倾向于从 1 开始计数,而不是从 0 开始。但是,无法保证向量的末尾之后的元素有任何特定的值。

  • 更糟糕的是,[] 操作符会静默地允许你向向量的末尾之后的位置 写入

    vector v{ 19, 71, 47, 192, 4004 };
    v[5] = 2001;
    auto & i = v[5];
    cout << format("element is {}\n", i);
    element is 2001
    

我现在已经写入了我无法控制的内存,编译器 静默地 允许了它,没有任何错误信息或崩溃。但不要被骗——这是一段极其危险的代码,将会 在未来的某个时刻引起问题。越界内存访问是安全漏洞的主要原因之一。

  • 解决方案是在可能的情况下使用 at() 成员函数,而不是使用 [] 操作符:

    vector v{ 19, 71, 47, 192, 4004 };
    auto & i = v.at(5);
    cout << format("element is {}\n", i);
    

现在我们得到了一个运行时异常:

terminate called after throwing an instance of 'std::out_of_range'
  what():  vector::_M_range_check: __n (which is 5) >= this->size() (which is 5)
Aborted

代码编译没有错误,但 at() 函数会检查容器的边界,并在你尝试访问这些边界之外的内存时抛出一个 运行时异常。这是使用 GCC 编译器编译的代码的异常信息。在不同的环境中,信息可能会有所不同。

它是如何工作的…

[] 操作符和 at() 成员函数执行相同的工作;它们根据索引位置提供对容器元素的直接访问。[] 操作符不进行边界检查,因此在某些密集迭代的程序中可能会稍微快一点。

话虽如此,at() 函数 应该是你的默认选择。虽然边界检查可能会占用几个 CPU 周期,但这是一种低成本的保险。对于大多数应用程序来说,这种好处远远值得这种成本。

虽然 vector 类通常用作直接访问容器,但 arraydeque 容器也支持 [] 操作符和 at() 成员函数。这些注意事项同样适用。

还有更多…

在某些应用中,你可能不希望当遇到越界条件时,你的应用程序只是 崩溃。在这种情况下,你可以 捕获 这个异常,如下所示:

int main() {
    vector v{ 19, 71, 47, 192, 4004 };
    try {
        v.at(5) = 2001;
    } catch (const std::out_of_range & e) {
        std::cout <<
            format("Ouch!\n{}\n", e.what());
    }
    cout << format("end element is {}\n", v.back());
}

输出:

Ouch!
vector::_M_range_check: __n (which is 5) >= this->size() (which is 5)
end element is 4004

try 块捕获了 catch 子句中指定的异常,在这种情况下,异常是 std::out_of_rangee.what() 函数返回一个包含 STL 库错误信息的 C 字符串。每个库都会有不同的信息。

请记住,这也适用于 arraydeque 容器。

保持向量元素排序

vector 是一个顺序容器,它按照元素插入的顺序保持元素。它不会对元素进行排序,也不会以任何方式改变它们的顺序。其他容器,如 setmap,会保持元素排序,但这些容器不是随机访问的,可能没有你需要的功能。然而,你可以保持你的向量排序。这只需要一点管理。

如何做到这一点…

这个菜谱的想法是创建一个简单的函数,insert_sorted(),将元素插入到向量中的正确位置以保持向量排序。

  • 为了方便,我们将从一个字符串向量的 type alias 开始:

    using Vstr = std::vector<std::string>;
    

我喜欢在这里使用类型别名,因为向量的具体细节并不那么重要,重要的是它的应用。

  • 然后,我们可以定义几个辅助函数:

    // print a vector
    void printv(const auto& v) {
        for(const auto& e : v) {
            cout << format("{} ", e);
        }
        cout << "\n";
    }
    // is it sorted? 
    void psorted(const Vstr& v) {
        if(std::ranges::is_sorted(v)) cout<< "sorted: ";
        else cout << "unsorted: ";
        printv(v);
    }
    

printv() 函数很简单;它在一行上打印向量的元素。

psorted() 函数使用 is_sorted() 算法的 ranges 版本来告诉我们向量是否已排序。然后它调用 printv() 来打印向量。

  • 现在,我们可以在 main() 函数中初始化一个 Vstr 向量:

    int main() {
        Vstr v{ 
            "Miles",
            "Hendrix",
            "Beatles",
            "Zappa",
            "Shostakovich"
        };
        psorted(v);
    }
    

输出:

unsorted: Miles Hendrix Beatles Zappa Shostakovich

在这一点上,我们有一个包含一些有趣音乐家名字的 Vstr 向量,没有任何特定的顺序。

  • 让我们使用 sort() 算法的 ranges 版本来排序我们的向量。

    std::ranges::sort(v);
    psorted(v);
    

输出:

sorted: Beatles Hendrix Miles Shostakovich Zappa
  • 在这一点上,我们希望能够在向量中插入项目,使它们已经按顺序排列。insert_sorted() 函数为我们做到了这一点:

    void insert_sorted(Vstr& v, const string& s) {
        const auto pos{ std::ranges::lower_bound(v, s) };
        v.insert(pos, s);
    }
    

insert_sorted() 函数使用 lower_bound() 算法的 ranges 版本来获取 insert() 函数的迭代器,以保持向量排序。

  • 现在,我们可以使用 insert_sorted() 函数将更多音乐家插入到向量中:

    insert_sorted(v, "Ella");
    insert_sorted(v, "Stones");
    

输出:

sorted: Beatles Ella Hendrix Miles Shostakovich Stones Zappa

它是如何工作的…

insert_sorted() 函数用于在保持其顺序的同时将元素插入到有序向量中:

void insert_sorted(Vstr& v, const string& s) {
    const auto pos{ std::ranges::lower_bound(v, s) };
    v.insert(pos, s);
}

lower_bound() 算法找到第一个不小于参数的元素。然后我们使用 lower_bound() 返回的迭代器在正确的位置插入一个元素。

在这种情况下,我们使用 lower_bound() 的 ranges 版本,但两种版本都可以工作。

更多内容…

使用模板可以使 insert_sorted() 函数更加通用。这个版本将支持其他容器类型,例如 setdequelist

template<typename C, typename E>
void insert_sorted(C& c, const E& e) {
    const auto pos{ std::ranges::lower_bound(c, e) };
    c.insert(pos, e);
}

请记住,std::sort() 算法(及其衍生算法)需要一个支持随机访问的容器。并非所有 STL 容器都满足此要求。值得注意的是,std::list 不满足。

高效地将元素插入到映射中

map 类是一个关联容器,它包含 键值对,其中键必须在容器内是唯一的。

有多种方法可以填充映射容器。考虑一个如下定义的 map

map<string, string> m;

您可以使用 [] 操作符添加一个元素:

m["Miles"] = "Trumpet"

您可以使用 insert() 成员函数:

m.insert(pair<string,string>("Hendrix", "Guitar"));

或者,您也可以使用 emplace() 成员函数:

m.emplace("Krupa", "Drums");

我倾向于使用 emplace() 函数。自 C++11 引入以来,emplace() 使用 完美转发emplace(就地创建)容器中的新元素。参数直接转发到元素构造函数。这既快又高效,且易于编码。

虽然这确实比其他选项有所改进,但 emplace() 的问题在于即使不需要也会构造一个对象。这涉及到调用构造函数、分配内存以及移动数据,然后丢弃这个临时对象。

为了解决这个问题,C++17 提供了新的try_emplace()函数,它只有在需要时才构造值对象。这对于大型对象或许多插入操作尤为重要。

注意

映射的每个元素都是一个键值对。在键值对结构中,元素被命名为firstsecond,但它们在映射中的目的是键和值。我倾向于将值对象视为有效载荷,因为这通常是映射的目的。为了搜索现有的键,try_emplace()函数必须构造键对象;这是不可避免的。但除非需要将其插入映射中,否则它不需要构造有效载荷对象。

如何实现…

新的try_emplace()函数避免了在不需要时构造有效载荷对象的开销。这在键冲突的情况下特别有用,尤其是对于大型有效载荷。让我们看看:

  • 首先,我们创建一个有效载荷类。为了演示目的,这个类有一个简单的std::string有效载荷,并在构造时显示一条消息:

    struct BigThing {
        string v_;
        BigThing(const char * v) : v_(v) {
            cout << format("BigThing constructed {}\n", v_);
        }
    };
    using Mymap = map<string, BigThing>;
    

这个BigThing类只有一个成员函数,即一个在对象构造时显示消息的构造函数。我们将使用这个来跟踪BigThing对象被构造的频率。当然,在实际应用中,这个类会更大,并使用更多资源。

每个映射元素将包含一对对象,一个用于键的std::string和一个用于有效载荷的BigThing对象。Mymap只是一个便利别名。这允许我们关注函数而不是形式。

  • 我们还将创建一个printm()函数来打印映射的内容:

    void printm(Mymap& m) {
        for(auto& [k, v] : m) {
            cout << format("[{}:{}] ", k, v.v_);
        }
        cout << "\n";
    }
    

这使用了 C++20 的format()函数来打印映射,这样我们可以在插入元素时跟踪它们。

  • 在我们的main()函数中,我们创建映射对象并插入一些元素:

    int main() {
        Mymap m;
        m.emplace("Miles", "Trumpet");
        m.emplace("Hendrix", "Guitar");
        m.emplace("Krupa", "Drums");
        m.emplace("Zappa", "Guitar");
        m.emplace("Liszt", "Piano");
        printm(m);
    } 
    

输出:

BigThing constructed Trumpet
BigThing constructed Guitar
BigThing constructed Drums
BigThing constructed Guitar
BigThing constructed Piano
[Hendrix:Guitar] [Krupa:Drums] [Liszt:Piano] [Miles:Trumpet] [Zappa:Guitar]

我们的输出显示了每个有效载荷对象的构造,然后是printm()函数调用的输出。

  • 我使用了emplace()函数将元素添加到映射中,并且每个有效载荷元素只构造了一次。我们可以使用try_emplace()函数,结果将相同:

    Mymap m;
    m.try_emplace("Miles", "Trumpet");
    m.try_emplace("Hendrix", "Guitar");
    m.try_emplace("Krupa", "Drums");
    m.try_emplace("Zappa", "Guitar");
    m.try_emplace("Liszt", "Piano");
    printm(m);
    

输出:

BigThing constructed Trumpet
BigThing constructed Guitar
BigThing constructed Drums
BigThing constructed Guitar
BigThing constructed Piano
[Hendrix:Guitar] [Krupa:Drums] [Liszt:Piano] [Miles:Trumpet] [Zappa:Guitar]
  • 当我们尝试插入具有重复键的新元素时,emplace()try_emplace()之间的区别就显现出来了:

    cout << "emplace(Hendrix)\n";
    m.emplace("Hendrix", "Singer");
    cout << "try_emplace(Zappa)\n";
    m.try_emplace("Zappa", "Composer"); 
    printm(m); 
    

输出:

emplace(Hendrix)
BigThing constructed Singer
try_emplace(Zappa)
[Hendrix:Guitar] [Krupa:Drums] [Liszt:Piano] [Miles:Trumpet] [Zappa:Guitar] 

emplace()函数尝试添加一个具有重复键("Hendrix")的元素。它失败了,但仍然构造了有效载荷对象("Singer")。try_emplace()函数也尝试添加一个具有重复键("Zappa")的元素。它失败了,但没有构造有效载荷对象。

这个例子演示了emplace()try_emplace()之间的区别。

它是如何工作的…

try_emplace()函数签名与emplace()类似,因此应该很容易对旧代码进行改造。以下是try_emplace()函数签名:

pair<iterator, bool> try_emplace( const Key& k, Args&&... args );

乍一看,这与emplace()签名不同:

pair<iterator,bool> emplace( Args&&... args );

区别在于try_emplace()使用一个单独的参数用于参数,这使得它可以独立构建。功能上,如果你使用模板参数推导try_emplace()可以作为一个直接替换:

m.emplace("Miles", "Trumpet");
m.try_emplace("Miles", "Trumpet");

try_emplace()的返回值与emplace()相同,是一个表示迭代器和布尔值的对:

const char * key{"Zappa"};
const char * payload{"Composer"};
if(auto [it, success] = m.try_emplace(key, payload);
        !success) {
    cout << "update\n";
    it->second = payload;
}
printm(m);

输出:

update
BigThing constructed Composer
[Hendrix:Guitar] [Krupa:Drums] [Liszt:Piano] [Miles:Trumpet] [Zappa:Composer]

在这里,我使用了结构化绑定auto [it, success] =)和if 初始化语句来测试返回值并条件性地更新有效载荷。请注意,它仍然只构建一次有效载荷对象。

值得注意的是,try_emplace()函数也可以与unordered_map一起使用。我们更改别名,除了无序之外,一切正常:

using Mymap = unordered_map<string, BigThing>; 

try_emplace()的优势在于它仅在准备好将其存储在映射中时才构建有效载荷对象。在实践中,这应该在运行时节省大量资源。你应该始终优先考虑try_emplace()而不是emplace()

高效修改映射项的键

map是一个关联容器,存储键值对。容器按键排序。键必须是唯一的,并且它们是const限定的,因此不能更改。

例如,如果我填充一个map并尝试更改键,我将在编译时得到一个错误:

map<int, string> mymap {
    {1, "foo"}, {2, "bar"}, {3, "baz"}
};
auto it = mymap.begin(); 
it->first = 47;

输出:

error: assignment of read-only member ...
    5 |     it->first = 47;
      |     ~~~~~~~~~~^~~~

如果你需要重新排序映射容器,你可以通过使用extract()方法交换键来实现。

新增于 C++17,extract()map类及其派生类的一个成员函数。它允许从序列中提取映射的元素,而不接触有效载荷。一旦提取,键就不再const限定,可以修改。

让我们看看一个例子。

如何做到这一点...

在这个例子中,我们将定义一个表示比赛中参赛者的映射。在比赛过程中,某个时刻顺序发生变化,我们需要修改映射的键。

  • 我们将首先为map类型定义一个别名:

    using Racermap = map<unsigned int, string>;
    

这允许我们在整个代码中一致地使用类型。

  • 我们将编写一个打印映射的函数:

    void printm(const Racermap &m)
    {
        cout << "Rank:\n";
        for (const auto& [rank, racer] : m) {
            cout << format("{}:{}\n", rank, racer);
        }
    }
    

我们可以在任何时候将映射传递给这个函数,以打印出我们参赛者的当前排名。

  • 在我们的main()函数中,我们定义一个map,其中包含我们参赛者的初始状态:

    int main() {
        Racermap racers {
            {1, "Mario"}, {2, "Luigi"}, {3, "Bowser"},
            {4, "Peach"}, {5, "Donkey Kong Jr"}
        };
        printm(racers);
        node_swap(racers, 3, 5);
        printm(racers);
    }
    

键是一个int,表示参赛者的排名。值是一个包含参赛者名字的string

然后我们调用printm()来打印当前的排名。node_swap()的调用将交换两个参赛者的键,然后我们再次打印。

  • 在某个时刻,一名参赛者落后了,另一名参赛者抓住机会提升排名。node_swap()函数将交换两名参赛者的排名:

    template<typename M, typename K>
    bool node_swap(M & m, K k1, K k2) {
        auto node1{ m.extract(k1) };
        auto node2{ m.extract(k2) };
        if(node1.empty() || node2.empty()) {
            return false;
        }
        swap(node1.key(), node2.key());
        m.insert(move(node1));
        m.insert(move(node2));
        return true;
    }
    

这个函数使用map.extract()方法从映射中提取指定的元素。这些提取的元素被称为节点

节点 是从 C++17 开始的一个新概念。这允许从一个映射类型结构中提取一个元素,而不需要触及该元素本身。节点被解链,并返回一个 节点句柄。一旦提取,节点句柄通过节点的 key() 函数提供了对键的 可写 访问。然后我们可以交换键并将它们重新插入映射中,而无需复制或操作有效负载。

  • 当我们运行此代码时,我们会得到在节点交换前后的映射打印输出:

输出:

Rank:
1:Mario
2:Luigi
3:Bowser
4:Peach
5:Donkey Kong Jr
Rank:
1:Mario
2:Luigi
3:Donkey Kong Jr
4:Peach
5:Bowser

这一切都是由 extract() 方法和新 node_handle 类实现的。让我们更仔细地看看它是如何工作的。

它是如何工作的...

这种技术使用了新的 extract() 函数,它返回一个 node_handle 对象。正如其名所示,node_handle 是对 节点 的引用,节点由一个关联元素及其相关结构组成。提取函数在保持节点位置的同时 解关联 节点,并返回一个 node_handle 对象。这相当于在不触及数据本身的情况下从关联容器中移除节点。node_handle 允许你访问解关联的节点。

node_handle 有一个成员函数 key(),它返回对节点键的 可写 引用。这允许你在键与容器解关联时更改键。

还有更多...

在使用 extract()node_handle 时,有几个要点需要注意:

  • 如果找不到键,extract() 函数返回一个 空的 节点句柄。你可以使用 empty() 函数测试节点句柄是否为空:

    auto node{ mapthing.extract(key) };
    if(node.empty()) {
        // node handle is empty
    }
    
  • exract() 函数有两个重载:

    node_type extract(const key_type& x);
    node_type extract(const_iterator position);
    

我们使用了第一种形式,通过传递一个键。你也可以使用一个迭代器,这通常不需要查找。

  • 请记住,你不能从字面量创建引用,所以像 extract(1) 这样的调用通常会因段错误而崩溃。

  • 当插入到 map 中时,键必须保持唯一。

例如,如果我尝试将一个键更改为映射中已经存在的值:

auto node_x{ racers.extract(racers.begin()) };
node_x.key() = 5;  // 5 is Donkey Kong Jr
auto status = racers.insert(move(node_x));
if(!status.inserted) {
    cout << format("insert failed, dup key: {}",
        status.position->second);
    exit(1);
}

插入失败,我们得到了错误信息:

insert failed, dup key: Donkey Kong Jr

在这个例子中,我将 begin() 迭代器传递给了 extract()。然后我给键分配了一个已经使用的值(5,Donkey Kong Jr)。插入失败,并且 status.inserted 的结果是 false。status.position 是找到的键的迭代器。在 if() 块中,我使用了 format() 来打印找到的键的值。

使用带自定义键的 unordered_map

对于有序的 map,键的类型必须是可排序的,这意味着它至少必须支持小于 < 比较运算符。假设你想使用一个自定义类型且不可排序的关联容器。例如,一个向量 (0, 1) 不比 (1, 0) 小或大,它只是指向不同的方向。在这种情况下,你仍然可以使用 unordered_map 类型。让我们看看如何做到这一点。

如何操作...

对于这个配方,我们将创建一个使用 x/y 坐标作为键的 unordered_map 对象。为此,我们需要一些支持函数。

  • 首先,我们将定义一个坐标的结构:

    struct Coord {
        int x{};
        int y{};
    };
    

这是一个简单的结构,包含两个成员,xy,用于坐标。

  • 我们的映射将使用 Coord 结构作为键,并使用 int 作为值:

    using Coordmap = unordered_map<Coord, int>;
    

我们使用 using 别名来方便地使用我们的映射。

  • 要使用 Coord 结构作为键,我们需要几个重载。这些是用于 unordered_map 的必需项。首先,我们将定义一个相等比较运算符:

    bool operator==(const Coord& lhs, const Coord& rhs) {
        return lhs.x == rhs.x && lhs.y == rhs.y;
    }
    

这是一个简单的函数,它比较 x 成员之间的值,以及 y 成员之间的值。

  • 我们还需要一个 std::hash 类的特殊化。这使得使用键检索映射元素成为可能:

    namespace std {
        template<>
        struct hash<Coord> {
            size_t operator()(const Coord& c) const {
                return static_cast<size_t>(c.x)
                     + static_cast<size_t>(c.y);
            }
        };
    }
    

这为 std::unordered_map 类使用的默认 hash 类提供了一个特殊化。它必须在 std 命名空间中。

  • 我们还将编写一个打印函数来打印 Coordmap 对象:

    void print_Coordmap(const Coordmap& m) {
        for (const auto& [key, value] : m) {
            cout << format("{{ ({}, {}): {} }} ",
                key.x, key.y, value);
        }
        cout << '\n';
    }
    

这使用 C++20 的 format() 函数来打印 x/y 键和值。注意使用双大括号 {{}} 来打印单大括号。

  • 现在我们已经定义了所有支持函数,我们可以编写 main() 函数。

    int main() {
        Coordmap m {
            { {0, 0}, 1 },
            { {0, 1}, 2 },
            { {2, 1}, 3 } 
        };
        print_Coordmap(m);
    }
    

输出:

{ (2, 1): 3 } { (0, 1): 2 } { (0, 0): 1 }

到目前为止,我们已经定义了一个 Coordmap 对象,它接受 Coord 对象作为键并将它们映射到任意值。

  • 我们还可以根据 Coord 键访问单个成员:

    Coord k{ 0, 1 };
    cout << format("{{ ({}, {}): {} }}\n", k.x, k.y, m.at(k));
    

输出:

{ (0, 1): 2 }

在这里,我们定义了一个名为 kCoord 对象,并使用它与 at() 函数从 unordered_map 中检索值。

它是如何工作的…

unordered_map 类依赖于一个哈希类来从键中查找元素。我们通常以这种方式实例化一个对象:

std::unordered_map<key_type, value_type> my_map;

这里不明显的是,因为我们没有定义一个,它正在使用一个 默认哈希类unordered_map 类的完整模板类型定义如下所示:

template<
    class Key,
    class T,
    class Hash = std::hash<Key>,
    class KeyEqual = std::equal_to<Key>,
    class Allocator = std::allocator< std::pair<const Key, 
      T> >
> class unordered_map;

模板为 HashKeyEqualAllocator 提供了默认值,所以我们通常不在我们的定义中包含它们。在我们的例子中,我们为默认的 std::hash 类提供了一个特殊化。

STL 包含了 std::hash 的大多数标准类型的特殊化,如 stringint 等。为了与我们的类一起工作,它需要一个特殊化。

我们可以将一个函数传递给模板参数,如下所示:

std::unordered_map<coord, value_type, my_hash_type> my_map;

这当然可以工作。在我看来,特殊化更通用。

使用集合对用户输入进行排序和过滤

set 容器是一个关联容器,其中每个元素都是一个 单个值,用作键。set 中的元素按顺序维护,不允许重复键。

set 容器通常被误解,它确实比 vectormap 等更通用的容器有更少和更具体的用途。set 的一个常见用途是从值集中过滤重复项。

如何做到这一点…

在这个配方中,我们将从 标准输入 读取单词并过滤掉重复项。

  • 我们将首先定义一个 istream 迭代器的别名。我们将使用它从命令行获取输入。

    using input_it = istream_iterator<string>;
    
  • main() 函数中,我们将为我们的单词定义一个 set

    int main() {
        set<string> words;
    

set 被定义为 string 元素的集合。

  • 我们定义了一对迭代器,用于与 inserter() 函数一起使用:

    input_it it{ cin };
    input_it end{};
    

end 迭代器使用其默认构造函数进行初始化。这被称为 流结束 迭代器。当我们的输入结束时,这个迭代器将与 cin 迭代器相等。

  • inserter() 函数用于将元素插入到 set 容器中:

    copy(it, end, inserter(words, words.end()));
    

我们使用 std::copy() 来方便地从输入流中复制单词。

  • 现在我们可以打印出我们的 set 来查看结果:

    for(const string & w : words) {
        cout << format("{} ", w);
    }
    cout << '\n';
    
  • 我们可以通过将一些单词管道到其输入来运行程序:

    $ echo "a a a b c this that this foo foo foo" | ./set-words
    a b c foo that this
    

set 已经消除了重复项,并保留了一个排序后的单词列表。

它是如何工作的…

set 容器是这个菜谱的核心。它只包含唯一的元素。当你插入一个重复项时,该插入操作将失败。因此,你最终会得到一个包含每个唯一元素的排序列表。

但这并不是这个菜谱的唯一有趣部分。

istream_iterator 是一个输入迭代器,它从流中读取对象。我们这样实例化了输入迭代器:

istream_iterator<string> it{ cin };

现在我们有一个来自 cin 流的 string 类型的输入迭代器。每次我们解引用这个迭代器时,它将返回输入流中的一个单词。

我们还实例化了另一个 istream_iterator

istream_iterator<string> end{};

这调用默认构造函数,它给我们一个特殊的 流结束 迭代器。当输入迭代器到达流的末尾时,它将等于 流结束 迭代器。这对于结束循环很有用,例如 copy() 算法创建的循环。

copy() 算法接受三个迭代器,即要复制的范围的开始和结束迭代器,以及一个目标迭代器:

copy(it, end, inserter(words, words.end()));

inserter() 函数接受一个容器和一个插入点的迭代器,并返回一个适合容器及其元素的 insert_iterator

这种 copy()inserter() 的组合使得从流中复制元素到 set 容器变得容易。

一个简单的基于 deque 的逆波兰表达式(RPN)计算器

一个 逆波兰表达式(RPN)计算器是一个基于栈的计算器,它使用后缀表示法,其中运算符位于操作数之后。它在打印计算器中很常见,尤其是在 HP 12C 上,这是有史以来最受欢迎的电子计算器。

在熟悉其操作模式后,许多人更喜欢使用逆波兰表达式(RPN)计算器。(自从它们在 20 世纪 80 年代初首次推出以来,我一直使用 HP 12C 和 16C。)例如,使用传统的代数符号,要加 1 和 2,你会输入 1 + 2。使用逆波兰表达式,你会输入 1 2 +。运算符位于操作数之后。

使用代数计算器时,你需要按下=键来表示你想要一个结果。而在逆波兰表达式(RPN)计算器中,这并不是必需的,因为操作符会立即处理,起到双重作用。另一方面,逆波兰表达式计算器通常需要按下Enter键来将操作数推入栈中。

我们可以轻松地使用基于栈的数据结构实现逆波兰表达式计算器。例如,考虑一个具有四个位置的逆波兰表达式计算器:

图 3.5 – 逆波兰表达式加法操作

图 3.5 – 逆波兰表达式加法操作

每个操作数在输入时都会被推入栈中。当输入操作符时,操作数会被弹出,进行操作,并将结果推回栈中。然后结果可以用于下一个操作。例如,考虑(3+2)×3的情况:

图 3.6 – 逆波兰表达式栈操作

图 3.6 – 逆波兰表达式栈操作

逆波兰表达式的一个优点是你可以将操作数留在栈上以供未来的计算,从而减少对单独内存寄存器的需求。考虑(9×6)+(2×3)的情况:

图 3.7 – 逆波兰表达式多栈操作

图 3.7 – 逆波兰表达式多栈操作

注意,我们首先执行括号内的操作,然后对中间结果执行最终操作。一开始这可能看起来更复杂,但一旦习惯了,就会觉得非常有道理。

现在,让我们使用 STL 的deque容器构建一个简单的逆波兰表达式计算器。

如何做到这一点…

对于这个实现,我们将使用deque容器作为我们的栈。为什么不使用stack容器呢?stack类是一个容器适配器,它使用另一个容器(通常是deque)来存储。对于我们的目的,stack并没有比deque提供任何实质性的优势。而且deque允许我们像纸带计算器一样迭代和显示逆波兰表达式栈。

  • 我们将把我们的逆波兰表达式计算器封装在一个类中。在这里使用类有几个优点。封装提供了安全性可重用性可扩展性清晰的接口。我们将把这个类命名为RPN

    class RPN {
        deque<double> deq_{};
        constexpr static double zero_{0.0};
        constexpr static double inf_ 
            { std::numeric_limits<double>::infinity() };
    ...  // public and private members go here
    };
    

数据存储deque,命名为deq_,位于类的私有区域,以保护它。这是我们存储逆波兰表达式栈的地方。

zero_常量在整个类中被使用,既作为返回值也作为比较操作数。inf_常量用于除以零错误。这些常量被声明为constexpr static,因此它们不会在每个实例中占用空间。

我喜欢用尾随下划线来命名私有数据成员,以提醒自己它们是私有的。

  • 我们不需要显式的构造函数或析构函数,因为deque类管理自己的资源。因此,我们的公共接口只包含三个函数:

    public:
        // process an operand/operator
        double op(const string & s) {
            if(is_numeric(s)) {
                double v{stod(s, nullptr)};
                deq_.push_front(v);
                return v;
            }
            else return optor(s);
        }
        // empty the stack
        void clear() {
            deq_.clear();
        }
        // print the stack
        string get_stack_string() const {
            string s{};
            for(auto v : deq_) {
                s += format("{} ", v);
            }
            return s;
        }
    

double op()函数是RPN类的主要入口点。它接受一个包含数字或操作符的string。如果是数字,则将其转换为double并压入栈中。如果是操作符,我们调用optor()来执行操作。这是类的主要逻辑。

void clear()函数简单地调用dequeclear()来清空栈。

最后,string get_stack_string()函数返回栈的内容,以string形式。

  • private部分,我们有支持接口工作的辅助工具。pop_get2()函数从栈中弹出两个操作数,并将它们作为一对返回。我们将其用作操作数的操作符:

        pair<double, double> pop_get2() {
            if(deq_.size() < 2) return {zero_, zero_};
            double v1{deq_.front()};
            deq_.pop_front();
            double v2{deq_.front()};
            deq_.pop_front();
            return {v2, v1};
        }
    
  • is_numeric()函数检查字符串是否完全是数字。我们还允许小数点.字符。

        bool is_numeric(const string& s) {
            for(const char c : s) {
                if(c != '.' && !std::isdigit(c)) return 
                  false;
            }
            return true;
        }
    
  • optor()函数执行操作符。我们使用map容器将操作符映射到相应的 lambda 函数。

    double optor(const string& op) {
        map<string, double (*)(double, double)> opmap {
            {"+", [](double l, double r){ return l + r; }},
            {"-", [](double l, double r){ return l - r; }},
            {"*", [](double l, double r){ return l * r; }},
            {"/", [](double l, double r){ return l / r; }},
            {"^", [](double l, double r)
                { return pow(l, r); }},
            {"%", [](double l, double r)
                { return fmod(l, r); }}
        };
        if(opmap.find(op) == m.end()) return zero_;
        auto [l, r] = pop_get2();
        // don’t divide by zero
        if(op == "/" && r == zero_) deq_.push_front(inf_);
        else deq_.push_front(opmap.at(op)(l, r));
        return deq_.front();
    }
    

带有 lambda 函数的map容器创建了一个快速且简单的跳转表。

我们在map中使用find()函数来测试我们是否有有效的操作符。

在除以零的测试之后,对map进行解引用,并调用操作符。

操作的结果被推入栈中并返回。

  • 这些都是RPN类的函数成员。现在我们可以在main()函数中使用它:

    int main() {
        RPN rpn;
        for(string o{}; cin >> o; ) {
            rpn.op(o);
            auto stack_str{rpn.get_stack_string()};
            cout << format("{}: {}\n", o, stack_str);
        }
    }
    

我们将通过从命令行将字符串管道输入程序来测试这一点。我们使用for循环从cin流中获取每个单词,并将其传递给rpn.op()。我喜欢这里的for循环,因为它很容易包含o变量的作用域。然后我们使用get_stack_string()函数在每条命令行项之后打印栈。

  • 我们可以通过输入这样的表达式来运行程序:

    $ echo "9 6 * 2 3 * +" | ./rpn
    9: 9
    6: 6 9
    *: 54
    2: 2 54
    3: 3 2 54
    *: 6 54
    +: 60
    

这看起来像很多编码,但实际上相当简单。有了注释,RPN类只有不到 70 行代码。完整的rpn.cpp源代码在 GitHub 仓库中。

它是如何工作的...

RPN类通过首先确定每个输入块的性质来操作。如果是数字,我们将其推入栈中。如果是操作符,我们从栈顶弹出两个操作数,应用操作,并将结果推回栈中。如果我们不识别输入,我们只需忽略它。

deque类是一个双端队列。要将其用作栈,我们选择一个端点,并从该端点同时进行压栈和出栈操作。我选择了 deque 的front端,但也可以从back端工作。我们只需要从同一端进行所有操作。

如果我们确定输入是数字,我们将其转换为double并使用push_front()将其推入 deque 的前端。

    if(is_numeric(s)) {
        double v{stod(s, nullptr)};
        deq_.push_front(v);
        return v;
    }

当我们需要使用栈中的值时,我们从 deque 的前端弹出它们。我们使用front()获取值,然后使用pop_front()将其从栈中弹出。

    pair<double, double> pop_get2() {
        if(deq_.size() < 2) return {zero_, zero_};
        double v1{deq_.front()};
        deq_.pop_front();
        double v2{deq_.front()};
        deq_.pop_front();
        return {v2, v1};
    }

使用map为我们操作符,使得检查操作符是否有效以及执行操作变得容易。

    map<string, double (*)(double, double)> opmap {
        {"+", [](double l, double r){ return l + r; }},
        {"-", [](double l, double r){ return l - r; }},
        {"*", [](double l, double r){ return l * r; }},
        {"/", [](double l, double r){ return l / r; }},
        {"^", [](double l, double r){ return pow(l, r); }},
        {"%", [](double l, double r){ return fmod(l, r); }}
    };

我们可以通过使用 find() 函数来测试操作符的有效性:

    if(opmap.find(op) == opmap.end()) return zero_;

我们可以通过使用 at() 函数解引用 map 来调用操作符:

    opmap.at(op)(l, r)

我们在一次语句中调用操作符 lambda 并将结果推送到 deque 中:

    deq_.push_front(opmap.at(op)(l, r));

还有更多…

在这个菜谱中,我们使用 cin 流将操作传递给 RPN 计算器。用 STL 容器做这件事也同样简单。

int main() {
    RPN rpn;
    vector<string> opv{ "9", "6", "*", "2", "3", "*", "+" 
      };
    for(auto o : opv) {
        rpn.op(o);
        auto stack_str{rpn.get_stack_string()};
        cout << format("{}: {}\n", o, stack_str);
    }
}

输出:

9: 9
6: 6 9
*: 54
2: 2 54
3: 3 2 54
*: 6 54
+: 60

通过将 RPN 计算器放入一个具有干净接口的类中,我们创建了一个灵活的工具,可以在许多不同的环境中使用。

使用 map 的单词频率计数器

这个菜谱使用 map 容器的唯一键属性来计算文本流中重复单词的数量。

STL 的 map 容器是一个 关联 容器。它由组织成 键值对 的元素组成。键用于查找,并且必须是唯一的。

在这个菜谱中,我们将利用 STL map 容器的唯一键要求来计算文本文件中每个单词的出现次数。

如何做到这一点…

这个任务有几个部分我们可以单独解决:

  1. 我们需要从文件中获取文本。我们将使用 cin 流来完成这项任务。

  2. 我们需要将单词与非单词内容(如标点符号)分开。我们将使用 regex(正则表达式)库来完成这项任务。

  3. 我们需要计算每个单词的频率。这是本菜谱的主要目标。我们将使用 STL 的 map 容器来完成这项任务。

  4. 最后,我们需要按频率和字母顺序对结果进行排序。为此,我们将使用 STL 的 sort 算法和 vector 容器。

即使有所有这些任务,生成的代码相对较短,大约有 70 行,包括头文件在内。让我们开始吧:

  • 我们将开始使用一些别名以方便起见:

    namespace ranges = std::ranges;
    namespace regex_constants = std::regex_constants;
    

对于 std:: 空间内的命名空间,我喜欢创建更短的别名,但仍然让我知道我正在使用特定命名空间中的标记。特别是对于经常重用现有算法名称的 ranges 命名空间。

  • 我们将正则表达式存储在一个常量中。我不喜欢让全局命名空间变得杂乱,因为这可能导致冲突。我倾向于为这类事情使用基于我首字母的命名空间:

    namespace bw {
        constexpr const char * re{"(\\w+)"};
    }
    

使用 bw::re 获取它很容易,这告诉我它确切是什么。

  • main() 的顶部,我们定义我们的数据结构:

    int main() {
        map<string, int> wordmap{};    
        vector<pair<string, int>> wordvec{};
        regex word_re(bw::re);
        size_t total_words{};
    

我们的主要 map 被称为 wordmap。我们有一个名为 wordvecvector,我们将使用它作为排序容器。最后,我们的 regex 类,word_re

  • for 循环是大部分工作发生的地方。我们从 cin 流中读取文本,应用正则表达式,并将单词存储在 map 中:

    for(string s{}; cin >> s; ) {
        auto words_begin{
            sregex_iterator(s.begin(), s.end(), word_re) };
        auto words_end{ sregex_iterator() };
        for(auto r_it{words_begin}; r_it != words_end; 
          ++r_it) {
            smatch match{ *r_it };
            auto word_str{match.str()};
            ranges::transform(word_str, word_str.begin(),
                [](unsigned char c){ return tolower(c); });
            auto [map_it, result] =
                wordmap.try_emplace(word_str, 0);
            auto & [w, count] = *map_it;
            ++total_words;
            ++count;
        }
    }
    

我喜欢使用 for 循环,因为它允许我将 s 变量的作用域包含在内。

我们首先为 regex 结果定义迭代器。这允许我们在只有标点符号的情况下区分多个单词。for(r_it...) 循环从 cin 字符串中返回单个单词。

smatch类型是regex字符串匹配类的特化。它给我们提供了下一个单词从我们的regex

然后,我们使用transform算法将单词转换为小写——这样我们就可以不计较大小写来计数单词。(例如,“The”和“the”是同一个单词。)

接下来,我们使用try_emplace()将单词添加到 map 中。如果它已经存在,它不会被替换。

最后,我们使用++count来增加map中单词的计数。

  • 现在,我们在map中有单词及其频率计数。但它们是按字母顺序排列的,我们希望它们按频率降序排列。为此,我们将它们放入一个向量中并排序:

        auto unique_words = wordmap.size();
        wordvec.reserve(unique_words);
        ranges::move(wordmap, back_inserter(wordvec));
        ranges::sort(wordvec, [](const auto& a, const 
          auto& b) { 
            if(a.second != b.second)
                return (a.second > b.second);
            return (a.first < b.first);
        });
        cout << format("unique word count: {}\n", 
          total_words);
        cout << format("unique word count: {}\n", 
          unique_words);
    

wordvec是一个包含单词和频率计数的对向量。我们使用ranges::move()算法填充vector,然后使用ranges::sort()算法对vector进行排序。注意,谓词 lambda 函数首先按计数(降序)排序,然后按单词(升序)排序。

  • 最后,我们打印出结果:

        for(int limit{20}; auto& [w, count] : wordvec) {
            cout << format("{}: {}\n", count, w);
            if(--limit == 0) break;
        }
    }
    

我设置了一个限制,只打印前 20 个条目。你可以取消注释if(--limit == 0) break;行来打印整个列表。

  • 在示例文件中,我包含了一个包含埃德加·爱伦·坡的《乌鸦》副本的文本文件。这首诗是公有领域的。我们可以用它来测试程序:

    $ ./word-count < the-raven.txt
    total word count: 1098
    unique word count: 439
    56: the
    38: and
    32: i
    24: my
    21: of
    17: that
    17: this
    15: a
    14: door
    11: chamber
    11: is
    11: nevermore
    10: bird
    10: on
    10: raven
    9: me
    8: at
    8: from
    8: in
    8: lenore
    

这首诗总共有 1,098 个单词,其中 439 个是独特的。

它是如何工作的……

菜单的核心是使用map对象来计数重复的单词。但还有其他部分值得考虑。

我们使用cin流从标准输入读取文本。默认情况下,cin在读取到string对象时会跳过空白字符。通过将一个字符串对象放在>>操作符的右侧(cin >> s),我们可以得到由空白字符分隔的文本块。这对于许多目的来说已经足够定义一个单词一次,但我们需要语言学上的单词。为此,我们将使用正则表达式。

regex类提供了正则表达式语法的选择,默认为ECMA语法。在 ECMA 语法中,正则表达式"(\w+)""([A-Za-z0-9_]+)"的快捷方式。这将选择包含这些字符的单词。

正则表达式是一种语言。要了解更多关于正则表达式的信息,我推荐 Jeffrey Friedl 的书籍《精通正则表达式》。

当我们从regex引擎获取每个单词时,我们使用 map 对象的try_emplace()方法有条件地将单词添加到我们的wordmap中。如果单词不在 map 中,我们将其添加,计数为0。如果单词已经在 map 中,计数保持不变。我们在循环的后面增加计数,所以它总是正确的。

在将文件中的所有唯一单词填充到 map 之后,我们使用 ranges::move() 算法将其转移到 vector 中。move() 算法使这种转移快速高效。然后我们可以使用 ranges::sort()vector 中对其进行排序。排序的 谓词 lambda 函数 包括对 pair 两边的比较,所以我们最终得到一个按单词计数(降序)和单词排序的结果。

使用 vector of vectors 查找长句子

对于作家来说,确保他们使用不同长度的句子,或者确保他们的句子没有太长,这可能很有用。让我们构建一个工具来评估文本文件的句子长度。

在使用 STL 时,选择合适的容器是关键。如果你需要有序的容器,通常最好使用关联容器,如 mapmultimap。然而,在这种情况下,由于我们需要自定义排序,对 vector 进行排序更容易。

vector 通常是最灵活的 STL 容器。当另一个容器类型似乎合适,但缺少一个重要功能时,vector 常常是一个有效的解决方案。在这种情况下,由于我们需要自定义排序,vector 工作得很好。

这个配方使用了一个 vector of vectors。内部的 vector 存储句子的单词,外部的 vector 存储内部的 vectors。正如你将看到的,这提供了很多灵活性,同时保留了所有相关的数据。

如何实现...

这个程序需要读取单词,找到句子的结尾,存储和排序句子,然后打印结果。

  • 我们首先编写一个简单的函数来告诉我们何时到达句子的结尾:

    bool is_eos(const string_view & str) {
        constexpr const char * end_punct{ ".!?" };
        for(auto c : str) {
            if(strchr(end_punct, c) != nullptr) return 
              true;
        }
        return false;
    }
    

is_eos() 函数使用 string_view,因为它效率高,我们不需要更多。然后我们使用 strchr() 库函数来检查单词是否包含句号、感叹号或问号等句子结尾标点符号。这些是英语中结束句子的三种可能字符。

  • main() 函数中,我们首先定义了 vector of vectors

    vector<vector<string>> vv_sentences{vector<string>{}};
    

这定义了一个名为 vv_sentencesvector,其元素类型为 vector<string>vv_sentences 对象初始化为一个空的 vector,用于第一个句子。

这创建了一个包含其他 vectorvector。内部的 vector 将各自包含一个单词句子。

  • 现在我们可以处理单词流了:

    for(string s{}; cin >> s; ) {
        vv_sentences.back().emplace_back(s);
        if(is_eos(s)) {
          vv_sentences.emplace_back(vector<string>{});
        }
    }
    

for 循环每次从输入流中返回一个单词。vv_sentences 对象上的 back() 方法用于访问当前的单词 vector,当前单词使用 emplace_back() 添加。然后我们调用 is_eos() 来查看这是否是句子的结尾。如果是,我们在 vv_sentences 中添加一个新的空 vector 以开始下一个句子。

  • 由于我们总是在 vv_sentences 的末尾添加一个新的空 vector,在每个句号后,我们通常会得到一个空的句子 vector。在这里我们检查这一点,并在必要时删除它:

        // delete back if empty
        if(vv_sentences.back().empty()) 
            vv_sentences.pop_back();
    
  • 现在我们可以按句子的长度对 vv_sentences 向量进行排序:

        sort(vv_sentences, [](const auto& l, 
            const auto& r) {
                return l.size() > r.size();
            });
    

这就是为什么 vector 对于这个项目来说如此方便。使用 ranges::sort() 算法和简单的 谓词 按大小降序排序非常快速和简单。

  • 现在我们可以打印我们的结果:

        constexpr int WLIMIT{10};
        for(auto& v : vv_sentences) {
            size_t size = v.size();
            size_t limit{WLIMIT};
            cout << format("{}: ", size);
            for(auto& s : v) {
                cout << format("{} ", s);
                if(--limit == 0) {
                    if(size > WLIMIT) cout << "...";
                    break;
                }
            }
            cout << '\n';
        }
        cout << '\n';
    }
    

外部循环和内部循环对应于外部和内部向量。我们只需遍历向量,并使用 format("{}: ", size) 打印内部向量的大小,然后使用 format("{} ", s) 打印每个单词。我们不想打印完整的非常长的句子,所以我们定义了一个 10 个单词的限制,并在有更多单词时打印省略号。

  • 输出看起来像这样,使用这个菜谱的前几段作为输入:

    $ ./sentences < sentences.txt
    27: It can be useful for a writer to make sure ...
    19: Whenever another container type seems appropriate, but is missing one ...
    18: If you need something ordered, it's often best to use ...
    17: The inner vector stores the words of a sentence, and ...
    16: In this case, however, since we need a descending sort, ...
    16: In this case, where we need our output sorted in ...
    15: As you'll see, this affords a lot of flexibility while ...
    12: Let's build a tool that evaluates a text file for ...
    11: The vector is generally the most flexible of the STL ...
    9: Choosing the appropriate container key when using the STL.
    7: This recipe uses a vector of vectors.
    

它是如何工作的...

使用 C 标准库中的 strchr() 函数查找标点符号很简单。记住,C 及其标准库都包含在 C++ 语言的定义中。在适当的地方使用它是没有理由不做的。

bool is_eos(const string_view & str) {
    constexpr const char * end_punct{ ".!?" };
    for(auto c : str) {
        if(strchr(end_punct, c) != nullptr) return true;
    }
    return false;
}

如果单词中间有标点符号,此函数将无法正确分隔句子。这可能在某些形式的诗歌或格式不良的文本文件中发生。我见过使用 std::string 迭代器和正则表达式这样做,但就我们的目的而言,这是快速且简单的方法。

我们使用 cin 逐词读取文本文件:

for(string s{}; cin >> s; ) {
    ...
}

这避免了将大文件一次性读入内存的开销。vector 已经很大了,包含了文件中的所有单词。没有必要也在内存中保留整个文本文件。在极少数情况下,如果文件太大,就需要找到另一种策略或使用数据库。

向量数组 可能一开始看起来很复杂,但它并不比使用两个单独的向量更复杂。

vector<vector<string>> vv_sentences{vector<string>{}};

这声明了一个 外部 vector,其 内部 元素类型为 vector<string>外部 向量命名为 vv_sentences内部 向量是匿名的;它们不需要名称。此定义使用一个元素初始化 vv_sentences 对象,即一个空的 vector<string> 对象。

当前内部向量始终可用,作为 vv_sentences.back()

vv_sentences.back().emplace_back(s);

当我们完成一个内部向量后,我们只需创建一个新的:

vv_sentences.emplace_back(vector<string>{});

这创建了一个新的匿名 vector<string> 对象,并将其 放置vv_sentences 对象的末尾。

使用 multimap 的待办事项列表

有序任务列表(或 待办事项列表)是一种常见的计算应用。正式地说,它是一系列与优先级相关的任务,按逆数值顺序排序。

你可能会想使用 priority_queue 来做这件事,因为正如其名所示,它已经按优先级(逆数值)顺序排序。priority_queue 的缺点是它没有迭代器,因此在不向队列中推入和弹出项目的情况下操作它很困难。

对于这个食谱,我们将使用一个multimap来存储有序列表。这个multimap 关联容器按顺序保存项目,并且可以使用反向迭代器以正确的排序顺序访问。

如何做到这一点...

这是一个简短且简单的食谱,它初始化一个multimap并按反向顺序打印它。

  • 我们从一个multimap的类型别名开始:

    using todomap = multimap<int, string>;
    

我们的todomap是一个具有int键和string负载的multimap

  • 我们有一个用于在反向顺序中打印todomap的小工具函数:

    void rprint(todomap& todo) {
        for(auto it = todo.rbegin(); it != todo.rend(); 
          ++it) {
            cout << format("{}: {}\n", it->first, 
              it->second);
        }
        cout << '\n';
    }
    

这使用反向迭代器来打印todomap

  • main()函数简短而甜蜜:

    int main()
    {
        todomap todo {
            {1, "wash dishes"},
            {0, "watch teevee"},
            {2, "do homework"},
            {0, "read comics"}
        };
        rprint(todo);
    }
    

我们用任务初始化todomap。注意,任务没有特定的顺序,但它们在键中确实有优先级。rprint()函数将按优先级顺序打印它们。

  • 输出看起来像这样:

    $ ./todo
    2: do homework
    1: wash dishes
    0: read comics
    0: watch teevee
    

待办事项列表按优先级顺序打印出来,正如我们所需要的。

它是如何工作的...

这是一个简短且简单的食谱。它使用multimap容器来保存优先级列表中的项目。

唯一的技巧在于rprint()函数:

void rprint(todomap& todo) {
    for(auto it = todo.rbegin(); it != todo.rend(); ++it) {
        cout << format("{}: {}\n", it->first, it->second);
    }
    cout << '\n';
}

注意反向迭代器,rbegin()rend()。无法更改multimap的排序顺序,但它确实提供了反向迭代器。这使得multimap的行为正好符合我们为优先级列表所需的方式。

第四章:第四章:兼容迭代器

迭代器是 STL 中的基本概念。迭代器使用与 C 指针相同的语义实现,使用相同的增量、减量和解引用运算符。指针习语对大多数 C/C++ 程序员来说都很熟悉,它允许 算法std::sortstd::transform 在原始内存缓冲区和 STL 容器上工作。

迭代器是基本概念

STL 使用迭代器来导航其容器类的元素。大多数容器包括 begin()end() 迭代器。这些通常被实现为返回迭代器对象的成员函数。begin() 迭代器指向容器中的初始元素,而 end() 迭代器指向最终元素之后:

图 4.1 – begin() 和 end() 迭代器

图 4.1 – begin() 和 end() 迭代器

end() 迭代器可以作为不定长度容器的 哨兵。我们将在本章中看到一些示例。

大多数 STL 容器定义了自己的特定 迭代器类型。例如,对于一个 int 类型的 vector

std::vector<int> v;

迭代器类型将被定义为:

std::vector<int>::iterator v_it;

你可以看到这很容易失控。如果我们有一个 vectorvectorstring

std::vector<std::vector<int, std::string>> v;

它的迭代器类型将是:

std::vector<std::vector<int, std::string>>::iterator v_it;

幸运的是,C++11 给我们带来了自动类型推导和 auto 类型。通过使用 auto,我们很少需要使用完整的迭代器类型定义。例如,如果我们需要在 for 循环中使用迭代器,我们可以使用 auto 类型:

for(auto v_it = v.begin(); v_it != v.end(); ++v_it) {
    cout << *v_it << '\n';
}

注意使用解引用运算符 * 从迭代器访问元素。这与您用于解引用指针的语法相同:

const int a[]{ 1, 2, 3, 4, 5 };
size_t count{ sizeof(a) / sizeof(int) };
for(const int* p = a; count > 0; ++p, --count) {
    cout << *p << '\n';
}

这也意味着你可以使用基于范围的 for 循环与原始数组:

const int a[]{ 1, 2, 3, 4, 5 };
for(auto e : a) {
    cout << e << '\n';
}

或者使用 STL 容器:

std::vector<int> v{ 1, 2, 3, 4, 5 };
for(auto e : v) {
    cout << e << '\n';
}

基于范围的 for 循环只是带有迭代器的 for 循环的简写:

{
    auto begin_it{ std::begin(container) };
    auto end_it{ std::end(container) };
    for ( ; begin_it != end_it; ++begin_it) {
        auto e{ *begin_it };
        cout << e << '\n';
    } 
}

因为迭代器使用与原始指针相同的语法,基于范围的 for 循环与任何容器都可以同样工作。

注意到基于范围的 for 循环调用 std::begin()std::end(),而不是直接调用成员函数 begin()end()std:: 函数调用成员函数以获取迭代器。那么,为什么不直接调用成员函数呢?std:: 非成员函数被设计为也可以与原始数组一起工作。这就是为什么 for 循环可以与数组一起工作:

const int arr[]{ 1, 2, 3, 4, 5 };
for(auto e : arr) {
    cout << format("{} ", e);
}

输出:

1 2 3 4 5

对于大多数用途,我倾向于更喜欢成员函数 begin()end(),因为它们更明确。其他人更喜欢 std:: 非成员函数,因为它们更通用。六或七八个;我建议你选择一种风格并坚持下去。

迭代器类别

在 C++20 之前,迭代器根据其能力被分为几个类别:

这些类别是分层的,其中更强大的迭代器继承了较不强大的迭代器的功能。换句话说,输入迭代器可以读取和递增一次。正向迭代器具有输入迭代器的功能加上可以多次递增。双向迭代器具有这些功能加上可以递减。依此类推。

输出迭代器可以写入和递增一次。如果其他迭代器也可以写入,则被认为是可变迭代器

迭代器概念

概念约束是 C++20 中引入的。概念只是一个命名约束,它限制了模板函数或类的参数类型,并帮助编译器选择合适的特化。

从 C++20 开始,STL 以概念而不是类别来定义迭代器。这些概念都在std::命名空间中。

图片

您可以使用这些概念来约束模板的参数:

template<typename T>
requires std::random_access_iterator<typename T::iterator>
void printc(const T & c) {
        for(auto e : c) {
        cout << format("{} ", e);
    }
    cout << '\n';
    cout << format("element 0: {}\n", c[0]);
}

此函数需要一个random_access_iterator。如果我用list(它不是一个随机访问容器)调用它,编译器会给我一个错误:

int main()
{
    list<int> c{ 1, 2, 3, 4, 5 };
    printc(c);       
}

list迭代器类型不支持random_access_iterator概念。因此,编译器会给我一个错误:

error: no matching function for call to 'printc(std::__cxx11::list<int>&)'
   27 |     printc(c);
      |     ~~~~~~^~~
note: candidate: 'template<class T>  requires  random_access_iterator<typename T::iterator> void printc(const T&)'
   16 | void printc(const T & c) {
      |      ^~~~~~
note:   template argument deduction/substitution failed:
note: constraints not satisfied

这是 GCC 的错误输出。您的错误可能看起来不同。

如果我用vector(它是一个随机访问容器)调用它:

int main()
{
    vector<int> c{ 1, 2, 3, 4, 5 };
    printc(c);       
}

现在它编译并运行没有错误:

$ ./working
1 2 3 4 5
element 0: 1

尽管有不同类型的迭代器用于不同类型的能力(和概念),但复杂性是为了支持易用性。

在介绍了迭代器之后,我们现在继续本章的以下食谱:

  • 创建一个可迭代的范围

  • 使您的迭代器与 STL 迭代器特性兼容

  • 使用迭代器适配器填充 STL 容器

  • 将生成器作为迭代器创建

  • 使用反向迭代器适配器进行反向迭代

  • 使用哨兵迭代未知长度的对象

  • 构建一个 zip 迭代器适配器

  • 创建一个随机访问迭代器

技术要求

您可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/CPP-20-STL-Cookbook/tree/main/chap04

创建一个可迭代的范围

这个食谱描述了一个简单的类,它生成一个可迭代的范围,适用于与基于范围的for循环一起使用。想法是创建一个序列生成器,它从起始值迭代到结束值。

要完成这个任务,我们需要一个迭代器类,以及对象接口类。

如何做到这一点...

这个食谱有两个主要部分,主接口Seqiterator类。

  • 首先,我们将定义Seq类。它只需要实现begin()end()成员函数:

    template<typename T>
    class Seq {
        T start_{};
        T end_{};
    public:
        Seq(T start, T end) : start_{start}, end_{end} {}
        iterator<T> begin() const {
            return iterator{start_};
        }
        iterator<T> end() const { return iterator{end_}; }
    };
    

构造函数设置了 start_end_ 变量。这些变量分别用于构建 begin()end() 迭代器。成员函数 begin()end() 返回 iterator 对象。

  • 迭代器类通常定义在容器类的公共部分中。这被称为 成员类嵌套类。我们将它插入到 Seq 构造函数之后:

    public:
        Seq(T start, T end) : start_{ start }, end_{ end } {}
        class iterator {
            T value_{};
        public:
            explicit iterator(T position = 0)
                : value_{position} {}
            T operator*() const { return value_; }
            iterator& operator++() {
                ++value_;
                return *this;
            }
            bool operator!=(const iterator& other) const {
                return value_ != other.value_;
            }
        };
    

通常将迭代器类命名为 iterator。这允许它被引用为 Seq<类型>::iterator

iterator 构造函数被标记为 explicit 以避免隐式转换。

value_ 变量由迭代器维护。这用于从指针解引用返回值。

支持基于范围的 for 循环的最小要求是一个解引用运算符 *、一个前缀增量运算符 ++ 和一个不等比较运算符 !=

  • 现在我们可以编写一个 main() 函数来测试我们的序列生成器:

    int main()
    {
        Seq<int> r{ 100, 110 };
        for (auto v : r) {
            cout << format("{} ", v);
        }
        cout << '\n';
    }
    

这会构建一个 Seq 对象并打印出其序列。

输出看起来像这样:

$ ./seq
100 101 102 103 104 105 106 107 108 109

它是如何工作的…

这个菜谱的目的是制作一个与基于范围的 for 循环一起工作的序列生成器。让我们首先考虑基于范围的 for 循环的等效代码:

{
    auto begin_it{ std::begin(container) };
    auto end_it{ std::end(container) };
    for ( ; begin_it != end_it; ++begin_it) {
        auto v{ *begin_it };
        cout << v << '\n';
    } 
}

从这段等效代码中,我们可以推导出对象与 for 循环一起工作的要求:

  • begin()end() 迭代器

  • 对不等比较运算符 != 的迭代器支持

  • 对前缀增量运算符 ++ 的迭代器支持

  • 对解引用运算符 * 的迭代器支持

我们的主要 Seq 类接口只有三个公共成员函数:构造函数,以及 begin()end() 迭代器:

Seq(T start, T end) : start_{ start }, end_{ end } {}
iterator begin() const { return iterator{start_}; }
iterator end() const { return iterator{end_}; }

Seq::iterator 类的实现携带实际的负载:

class iterator {
    T value_{};

这是一种常见的配置,因为有效负载仅通过迭代器访问。

我们只实现了所需的三个运算符:

    T operator*() const { return value_; }
    iterator& operator++() {
        ++value_;
        return *this;
    }
    bool operator!=(const iterator& other) const {
        return value_ != other.value_;
    }

这是我们支持基于范围的 for 循环所需的所有内容:

Seq<int> r{ 100, 110 };
for (auto v : r) {
    cout << format("{} ", v);
}

还有更多…

将迭代器定义为容器的成员类是传统做法,但不是必需的。这允许 iterator 类型从属于容器类型:

Seq<int>::iterator it = r.begin();

由于 auto 类型,C++11 之后这并不那么重要,但它仍然被认为是最佳实践。

使你的迭代器与 STL 迭代器特性兼容

许多 STL 算法要求迭代器符合某些特性。不幸的是,这些要求在编译器、系统和 C++ 版本之间不一致。

为了我们的目的,我们将使用来自 创建可迭代范围 菜谱的类来说明这个问题。如果你在继续之前先阅读那个菜谱,可能会更容易理解。

main() 中,如果添加对 minmax_element() 算法的调用:

Seq<int> r{ 100, 110 };
auto [min_it, max_it] = minmax_element(r.begin(), r.end());
cout << format("{} - {}\n", *min_it, *max_it);

它无法编译。错误信息模糊、晦涩,并且是级联的,但如果你仔细观察,你会发现我们的迭代器不符合与该算法兼容的要求。

好的,让我们来修复这个问题。

如何做到这一点…

我们需要对我们迭代器做一些简单的添加,使其与算法兼容。我们的迭代器需要满足前向迭代器的最低要求,所以让我们从这里开始:

  • 我们几乎有所有必要的运算符来支持前向迭代器。我们唯一缺少的是相等比较运算符==。我们可以很容易地通过operator==()重载来添加这个运算符:

    bool operator==(const iterator& other) const {
        return value_ == other.value_;
    }
    

有趣的是,这使得代码在某些系统上编译和运行,但在Clang上则不行,我们得到错误信息:

No type named 'value_type' in 'std::iterator_traits<Seq<int>::iterator>'

这告诉我我们需要在迭代器中设置特性。

  • iterator_traits类在iterator类中寻找一组类型定义(实现为using别名):

    public:
        using iterator_concept  = std::forward_iterator_tag;
        using iterator_category = 
          std::forward_iterator_tag;
        using value_type        = std::remove_cv_t<T>;
        using difference_type   = std::ptrdiff_t;
        using pointer           = const T*;
        using reference         = const T&;
    

我倾向于将这些放在iterator类的public:部分的顶部,这样它们就很容易看到了。

现在我们有一个完全符合规范的前向迭代器类,代码在所有我有的编译器上都能运行。

它是如何工作的……

using语句是特性,可以用来定义迭代器可以执行的能力。让我们看看它们中的每一个:

using iterator_concept  = std::forward_iterator_tag;
using iterator_category = std::forward_iterator_tag;

前两个是类别概念,两者都设置为forward_iterator_tag。这个值表示迭代器符合前向迭代器规范。

一些代码不会查看这些值,而是寻找单个设置和能力:

using value_type        = std::remove_cv_t<T>;
using difference_type   = std::ptrdiff_t;
using pointer           = const T*;
using reference         = const T&;

value_type别名设置为std::remove_cv_t<T>,这是值的类型,任何const限定符都被移除。

difference_type别名设置为std::ptrdiff_t,这是一个用于指针差异的特殊类型。

指针引用别名分别设置为指针和引用的const限定版本。

定义这些类型别名是大多数迭代器的基本要求。

还有更多……

值得注意的是,定义这些特性允许我们使用概念受限的模板与我们的迭代器一起使用。例如:

template<typename T>
requires std::forward_iterator<typename T::iterator>
void printc(const T & c) {
    for(auto v : c) {
        cout << format("{} ", v);
    }
    cout << '\n';
}

这个打印我们序列的函数受forward_iterator概念的约束。如果我们的类没有限定,它就不会编译。

我们也可以使用算法的ranges::版本:

auto [min_it, max_it] = ranges::minmax_element(r);

这使得使用我们的迭代器更加方便。

我们可以使用静态断言来测试forward_range兼容性:

static_assert(ranges::forward_range<Seq<int>>);

使用迭代器适配器填充 STL 容器

迭代器本质上是一种抽象。它有一个特定的接口,并且以特定的方式使用。但除此之外,它只是代码,它可以用于其他目的。一个迭代器适配器是一个看起来像迭代器但做其他事情的类。

STL 附带了一系列迭代器适配器。通常与algorithm库一起使用,它们非常有用。STL 迭代器适配器通常分为三类:

  • 插入迭代器,或插入器,用于将元素插入到容器中。

  • 流迭代器从流中读取并写入。

  • 反向迭代器反转迭代器的方向。

如何做到这一点……

在这个菜谱中,我们将查看一些 STL 迭代器适配器的示例:

  • 我们将从打印容器内容的一个简单函数开始:

    void printc(const auto & v, const string_view s = "") {
        if(s.size()) cout << format("{}: ", s);
        for(auto e : v) cout << format("{} ", e);
        cout << '\n';
    }
    

printc() 函数使我们能够轻松查看算法的结果。它包括一个可选的 string_view 参数用于描述。

  • 在我们的 main() 函数中,我们将定义几个 deque 容器。我们使用 deque 容器是因为我们可以在两端插入元素:

    int main() {
        deque<int> d1{ 1, 2, 3, 4, 5 };
        deque<int> d2(d1.size());
        copy(d1.begin(), d1.end(), d2.begin());
        printc(d1);
        printc(d2, "d2 after copy"); 
    }
    

输出:

1 2 3 4 5
d2 after copy: 1 2 3 4 5

我们定义了包含五个 int 值的 deque 容器 d1,以及有相同元素数量的空间用于 d2copy() 算法不会分配空间,所以 d2 必须有足够的空间来存放元素。

copy() 算法接受三个迭代器:开始结束 迭代器指示要复制的元素的范围,以及目标范围的开头迭代器。它不会检查迭代器以确保它们是有效的。(在没有在 vector 中分配空间的情况下尝试此操作,你会得到一个 segmentation fault 错误。)

我们在两个容器上调用 printc() 来显示结果。

  • copy() 算法并不总是方便用于此。有时你想要在容器的末尾复制并添加元素。有一个算法为每个元素调用 push_back() 会很好。这就是迭代器适配器有用的地方。让我们在 main() 函数的末尾添加一些代码:

    copy(d1.begin(), d1.end(), back_inserter(d2));
    printc(d2, "d2 after back_inserter");
    

输出:

d2 after back_inserter: 1 2 3 4 5 1 2 3 4 5

back_inserter() 是一个 插入迭代器适配器,它为分配给它的每个项目调用 push_back()。你可以在期望输出迭代器的任何地方使用它。

  • 此外,还有一个 front_inserter() 适配器,当你想在容器的开头插入元素时使用:

    deque<int> d3{ 47, 73, 114, 138, 54 };
    copy(d3.begin(), d3.end(), front_inserter(d2));
    printc(d2, "d2 after front_inserter");
    

输出:

d2 after front_inserter: 54 138 114 73 47 1 2 3 4 5 1 2 3 4 5

front_inserter() 适配器使用容器的 push_front() 方法在前面插入元素。注意,目标中的元素是反转的,因为每个元素都是插入在之前元素之前。

  • 如果我们想在中间插入,我们可以使用 inserter() 适配器:

    auto it2{ d2.begin() + 2};
    copy(d1.begin(), d1.end(), inserter(d2, it2));
    printc(d2, "d2 after middle insert");
    

输出:

d2 after middle insert: 54 138 1 2 3 4 5 114 73 47 ...

inserter() 适配器接受一个用于插入起点的迭代器。

  • 流迭代器 对于从和向 iostream 对象读写数据非常方便,这是 ostream_iterator()

    cout << "ostream_iterator: ";
    copy(d1.begin(), d1.end(), ostream_iterator<int>(cout));
    cout << '\n';
    

输出:

ostream_iterator: 12345
  • 这里是 istream_iterator()

    vector<string> vs{};
    copy(istream_iterator<string>(cin), 
        istream_iterator<string>(),
        back_inserter(vs));
    printc(vs, "vs2");
    

输出:

$ ./working < five-words.txt
vs2: this is not a haiku

如果没有传递流,istream_iterator() 适配器默认会返回一个结束迭代器。

  • 反向适配器 通常包含在大多数容器中,作为函数成员 rbegin()rend()

    for(auto it = d1.rbegin(); it != d1.rend(); ++it) {
        cout << format("{} ", *it);
    }
    cout << '\n';
    

输出:

5 4 3 2 1

它是如何工作的…

迭代器适配器通过包装现有的容器来工作。当你调用一个适配器,比如 back_inserter() 与一个容器对象一起时:

copy(d1.begin(), d1.end(), back_inserter(d2));

适配器返回一个模仿迭代器的对象,在这种情况下是一个 std::back_insert_iterator 对象,每次将值分配给迭代器时,它都会在容器对象上调用 push_back() 方法。这允许适配器在执行其有用任务的同时替代迭代器。

istream_adapter()也需要一个哨兵。哨兵表示不确定长度迭代器的结束。当你从流中读取时,直到遇到结束,你都不知道流中有多少个对象。当流遇到结束时,哨兵将与迭代器相等,表示流的结束。当istream_adapter()不带参数调用时,它将创建一个哨兵:

auto it = istream_adapter<string>(cin);
auto it_end = istream_adapter<string>();  // creates sentinel

这允许你测试流的结束,就像测试任何容器一样:

for(auto it = istream_iterator<string>(cin);
        it != istream_iterator<string>();
        ++it) {
    cout << format("{} ", *it);
}
cout << '\n';

输出:

$ ./working < five-words.txt
this is not a haiku

将生成器作为迭代器创建

生成器是一个生成其自己的值序列的迭代器。它不使用容器。它即时创建值,按需一次返回一个。C++生成器独立存在;它不需要包装在另一个对象周围。

在这个菜谱中,我们将构建一个用于生成斐波那契序列的生成器。这是一个序列,其中每个数字都是序列中前两个数字的和,从 0 和 1 开始:

图 4.2 – 斐波那契序列的定义

图 4.2 – 斐波那契序列的定义

斐波那契序列的前十个值(不计零)是:1, 1, 2, 3, 5, 8, 13, 21, 34, 55。这是自然界中发现的黄金比例的近似值。

如何实现...

斐波那契序列通常使用递归循环创建。生成器中的递归可能很困难且资源密集,所以我们只是保存序列中的前两个值并将它们相加。这更有效。

  • 首先,让我们定义一个打印序列的函数:

    void printc(const auto & v, const string_view s = "") {
        if(s.size()) cout << format("{}: ", s);
        for(auto e : v) cout << format("{} ", e);
        cout << '\n';
    }
    

我们之前已经使用过这个printc()函数。它打印一个可迭代的范围,如果提供了描述字符串,还会打印描述字符串。

  • 我们的这个类从类型别名和一些对象变量开始,所有这些都在private部分。

    class fib_generator {
        using fib_t = unsigned long;
        fib_t stop_{};
        fib_t count_ { 0 };
        fib_t a_ { 0 };
        fib_t b_ { 1 };
    

stop_变量将稍后用作哨兵。它被设置为要生成的值的数量。count_用于跟踪我们已经生成了多少个值。a_b_是序列中的前两个值,用于计算下一个值。

  • 仍然在private部分,我们有一个用于计算斐波那契序列中下一个值的简单函数。

        constexpr void do_fib() {
            const fib_t old_b = b_;
            b_ += a_;
            a_  = old_b;
        }
    
  • 现在在public部分,我们有一个带有默认值的简单构造函数:

    public:
        explicit fib_generator(fib_t stop = 0) : stop_{ stop } {}
    

这个构造函数在没有参数的情况下使用,用于创建哨兵。stop参数初始化stop_变量,表示要生成的值的数量。

  • 其余的公共函数是期望的前向迭代器的运算符重载:

        fib_t operator*() const { return b_; }
        constexpr fib_generator& operator++() {
            do_fib();
            ++count_;
            return *this;
        }
        fib_generator operator++(int) {
            auto temp{ *this };
            ++*this;
            return temp; 
        }
        bool operator!=(const fib_generator &o) const {
            return count_ != o.count_; 
        }
        bool operator==(const fib_generator&o) const { 
            return count_ == o.count_; 
        }
        const fib_generator& begin() const { return *this; }
        const fib_generator end() const { 
            auto sentinel = fib_generator();
            sentinel.count_ = stop_;
            return sentinel;
        }
        fib_t size() { return stop_; }
    };
    

此外,还有一个简单的size()函数,如果你需要为复制操作初始化目标容器,这可能很有用。

  • 现在我们可以通过简单的调用printc()来在我们的主函数中使用生成器:

    int main() {
        printc(fib_generator(10));
    }
    

这创建了一个匿名fib_generator对象,用于传递给printc()函数。

  • 我们用前 10 个斐波那契数字得到这个输出,不包括零:

    1 1 2 3 5 8 13 21 34 55
    

它是如何工作的...

fib_generator类作为一个前向迭代器运行,因为它提供了所有必要的接口函数:

fib_generator {
public:
    fib_t operator*() const;
    constexpr fib_generator& operator++();
    fib_generator operator++(int);
    bool operator!=(const fib_generator &o) const;
    bool operator==(const fib_generator&o) const;
    const fib_generator& begin() const;
    const fib_generator end() const;
};

就基于范围的for循环而言,这是一个迭代器,因为它看起来像迭代器。

值是在do_fib()函数中计算的:

constexpr void do_fib() {
    const fib_t old_b = b_;
    b_ += a_;
    a_  = old_b;
}

这只是简单地添加b_ += a_,将结果存储在b_中,并将旧的b_存储在a_中,为下一次迭代做准备。

解引用运算符*返回b_的值,这是序列中的下一个值:

fib_t operator*() const { return b_; }

end()函数创建一个对象,其中count_变量等于stop_变量,创建一个哨兵

const fib_generator end() const { 
    auto sentinel = fib_generator();
    sentinel.count_ = stop_;
    return sentinel;
}

现在相等比较运算符可以轻松检测序列的结束:

bool operator==(const fib_generator&o) const { 
    return count_ == o.count_; 
}

还有更多...

如果我们想让我们的生成器与algorithm库一起工作,我们需要提供traits别名。这些别名位于public部分的顶部:

public:
    using iterator_concept  = std::forward_iterator_tag;
    using iterator_category = std::forward_iterator_tag;
    using value_type        = std::remove_cv_t<fib_t>;
    using difference_type   = std::ptrdiff_t;
    using pointer           = const fib_t*;
    using reference         = const fib_t&;

现在,我们可以使用我们的生成器与算法一起工作:

fib_generator fib(10);
auto x = ranges::views::transform(fib, 
    [](unsigned long x){ return x * x; });
printc(x, "squared:");

这使用ranges::views版本的transform()算法来平方每个值。结果对象可以在任何可以使用迭代器的地方使用。我们从printc()调用中获取这个输出:

squared:: 1 1 4 9 25 64 169 441 1156 3025

使用反向迭代器适配器向后迭代

反向迭代器适配器是一个反转迭代器类方向的抽象。它需要一个双向迭代器。

如何做到这一点...

STL 中的大多数双向容器都包含一个反向迭代器适配器。其他容器,如原始的 C 数组,则没有。让我们看看一些例子:

  • 让我们从本章中使用的printc()函数开始:

    void printc(const auto & c, const string_view s = "") {
        if(s.size()) cout << format("{}: ", s);
        for(auto e : c) cout << format("{} ", e);
        cout << '\n';
    }
    

这使用基于范围的for循环来打印容器中的元素。

  • 基于范围的for循环甚至可以与没有迭代器类的原始 C 数组一起工作。因此,我们的printc()函数已经可以与 C 数组一起使用:

    int main() {
        int array[]{ 1, 2, 3, 4, 5 };
        printc(array, "c-array");
    }
    

我们得到这个输出:

c-array: 1 2 3 4 5
  • 我们可以使用begin()end()迭代器适配器为 C 数组创建正常的正向迭代器:

    auto it = std::begin(array);
    auto end_it = std::end(array);
    while (it != end_it) {
        cout << format("{} ", *it++);
    }
    

for循环的输出:

1 2 3 4 5
  • 或者我们可以使用rbegin()rend()反向迭代器适配器来为 C 数组创建反向迭代器:

    auto it = std::rbegin(array);
    auto end_it = std::rend(array);
    while (it != end_it) {
        cout << format("{} ", *it++);
    }
    

现在我们的输出是反转的:

5 4 3 2 1
  • 我们甚至可以创建一个修改版的printc(),使其反向打印:

    void printr(const auto & c, const string_view s = "") {
        if(s.size()) cout << format("{}: ", s);
        auto rbegin = std::rbegin(c);
        auto rend = std::rend(c);
        for(auto it = rbegin; it != rend; ++it) {
            cout << format("{} ", *it);
        }
        cout << '\n';
    }
    

当我们用 C 数组调用它时:

printr(array, "rev c-array");

我们得到这个输出:

rev c-array: 5 4 3 2 1
  • 当然,这也适用于任何双向 STL 容器:

    vector<int> v{ 1, 2, 3, 4, 5 };
    printc(v, "vector");
    printr(v, "rev vector");
    

输出:

vector: 1 2 3 4 5
rev vector: 5 4 3 2 1

它是如何工作的...

一个普通的迭代器类有一个指向第一个元素的begin()迭代器,以及一个指向最后一个元素之后的end()迭代器:

Figure 4.3 – Forward iterator

img/B18267_04_03.jpg

图 4.3 – 前向迭代器

您通过使用++运算符递增begin()迭代器来迭代容器,直到它达到end()迭代器的值。

反向迭代器适配器会拦截迭代器接口并将其反转,使得begin()迭代器指向最后一个元素,而end()迭代器指向第一个元素之前。++--运算符也被反转:

Figure 4.4 – Reverse iterator adapter

img/B18267_04_04.jpg

图 4.4 – 反向迭代器适配器

在反向迭代器中,++ 运算符递减,而 -- 运算符递增。

值得注意的是,大多数双向 STL 容器已经包含了反向迭代器适配器,可以通过成员函数 rbegin()rend() 访问:

vector<int> v;
it = v.rbegin();
it_end = v.rend();

这些迭代器将反向操作,适用于许多用途。

使用哨兵遍历未知长度的对象

一些对象没有特定的长度。要知道它们的长度,你需要遍历它们的所有元素。例如,在本章的其它地方,我们看到了一个没有特定长度的生成器。一个更常见的例子是C 字符串

C 字符串是一个以 null '\0' 值终止的字符的原始 C 数组。

![图 4.5 – 带有 null 终止符的 C 字符串]

![图 B18267_04_05.jpg]

图 4.5 – 带有 null 终止符的 C 字符串

我们经常使用 C 字符串,即使我们没有意识到这一点。C/C++ 中的任何字面量字符串都是一个 C 字符串:

std::string s = "string";

这里,STL 字符串 s 使用字面量字符串初始化。字面量字符串是一个 C 字符串。如果我们查看单个字符的十六进制表示,我们会看到 null 终止符:

for (char c : "string") {
    std::cout << format("{:02x} ", c);
}

“string”这个词有六个字母。我们循环的输出显示了数组中的七个元素:

73 74 72 69 6e 67 00

第七个元素是 null 终止符。

循环看到的是字符的原始 C 数组,有七个值。它是字符串的事实是一个对循环不可见的抽象。如果我们想让循环将其视为字符串,我们需要一个迭代器和一个哨兵

一个哨兵是一个表示不确定长度迭代器末尾的对象。当迭代器遇到数据末尾时,哨兵将与迭代器比较相等。

要了解它是如何工作的,让我们为 C 字符串构建一个迭代器!

如何做到这一点...

要使用哨兵与 C 字符串一起使用,我们需要构建一个自定义迭代器。它不需要很复杂,只需要用于基于范围的 for 循环的基本要素。

  • 我们将从一个方便的定义开始:

    using sentinel_t = const char;
    constexpr sentinel_t nullchar = '\0';
    

sentinel_tusing 别名为 const char。我们将在我们的类中使用这个哨兵。

我们还定义了用于 null 字符终止符的常量 nullchar

  • 现在,我们可以定义我们的迭代器类型:

    class cstr_it {
        const char *s{};
    public:
        explicit cstr_it(const char *str) : s{str} {}
        char operator*() const { return *s; }
        cstr_it& operator++() {
            ++s;
            return *this;
        }
        bool operator!=(sentinel_t) const {
            return s != nullptr && *s != nullchar;
        }
        cstr_it begin() const { return *this; }
        sentinel_t end() const { return nullchar; }
    };
    

这很简单。这是基于范围的 for 循环所必需的最小内容。注意 end() 函数返回一个 nullchar,而 operator!=() 重载与 nullchar 进行比较。这就是我们需要的哨兵。

  • 现在,我们可以定义一个函数,使用哨兵打印我们的 C 字符串:

    void print_cstr(const char * s) {
        cout << format("{}: ", s);
        for (char c : cstr_it(s)) {
            std::cout << format("{:02x} ", c);
        }
        std::cout << '\n';
    }
    

在这个函数中,我们首先打印字符串。然后我们使用 format() 函数打印每个单独的字符作为十六进制值。

  • 现在,我们可以在 main() 函数中调用 print_cstr()

    int main() {
        const char carray[]{"array"};
        print_cstr(carray);
        const char * cstr{"c-string"};
        print_cstr(cstr);
    }
    

输出看起来像这样:

array: 61 72 72 61 79
c-string: 63 2d 73 74 72 69 6e 67

注意,这里没有多余的字符和空终止符。这是因为我们的哨兵告诉 for 循环在看到 nullchar 时停止。

它是如何工作的...

迭代器类的哨兵部分非常简单。我们可以通过在 end() 函数中返回它来轻松地使用空终止符作为哨兵值:

sentinel_t end() const { return nullchar; }

然后不等比较运算符可以用来测试它:

bool operator!=(sentinel_t) const {
    return s != nullptr && *s != nullchar;
}

注意,参数只是一个类型 (sentinel_t)。参数类型对于函数签名是必要的,但我们不需要值。所有必要的只是将当前迭代器与哨兵进行比较。

这种技术应该在你有一个没有预定比较终点的类型或类时非常有用。

构建 zip 迭代器适配器

许多脚本语言包括一个用于将两个序列 zip 在一起的函数。典型的 zip 操作将接受两个输入序列,并为每个输入中的每个位置返回一对值:

考虑两个序列的情况 – 它们可以是容器、迭代器或初始化列表:

![图 4.6 – 要 zip 的容器图片 B18267_04_06.jpg

图 4.6 – 要 zip 的容器

我们想要 zip 它们在一起,以创建一个新的序列,包含来自前两个序列的元素对:

![图 4.7 – Zip 操作图片 B18267_04_07.jpg

图 4.7 – Zip 操作

在这个菜谱中,我们将使用迭代器适配器来完成这个任务。

如何做到这一点...

在这个菜谱中,我们将构建一个 zip 迭代器适配器,它接受两个相同类型的容器,并将值压缩到 std::pair 对象中:

  • 在我们的 main() 函数中,我们想要用两个向量调用我们的适配器:

    int main()
    {
        vector<std::string> vec_a {"Bob", "John", "Joni"};
        vector<std::string> vec_b {"Dylan", "Williams", 
            "Mitchell"};
        cout << "zipped: ";
        for(auto [a, b] : zip_iterator(vec_a, vec_b)) {
            cout << format("[{}, {}] ", a, b);
        }
        cout << '\n';
    }
    

这允许我们使用 zip_iterator 替代单个 vector 迭代器。

我们期望得到这样的输出:

zipped: [Bob, Dylan] [John, Williams] [Joni, Mitchell]
  • 我们的迭代器适配器在一个名为 zip_iterator 的类中。我们将从一些类型别名开始,以方便起见:

    template<typename T>
    class zip_iterator {
        using val_t = typename T::value_type;
        using ret_t = std::pair<val_t, val_t>;
        using it_t = typename T::iterator;
    

这些允许我们方便地定义对象和函数。

  • 我们在迭代器中不存储任何数据。我们只存储目标容器的 begin()end() 迭代器的副本:

    it_t ita_{};
    it_t itb_{};
    // for begin() and end() objects
    it_t ita_begin_{};
    it_t itb_begin_{};
    it_t ita_end_{};
    it_t itb_end_{};
    

ita_itb_ 是目标容器的迭代器。其他四个迭代器用于为 zip_iterator 适配器生成 begin()end() 迭代器。

  • 我们还有一个私有构造函数:

    // private constructor for begin() and end() objects
    zip_iterator(it_t ita, it_t itb) : ita_{ita}, itb_{itb} {}
    

这用于稍后构建特定于 begin()end() 迭代器的适配器对象。

  • public 部分,我们首先从迭代器 traits 类型定义开始:

    public:
        using iterator_concept  = 
          std::forward_iterator_tag;
        using iterator_category = 
          std::forward_iterator_tag;
        using value_type        = std::pair<val_t, val_t>;
        using difference_type   = long int;
        using pointer           = const val_t*;
        using reference         = const val_t&;
    
  • 构造函数设置所有私有迭代器变量:

    zip_iterator(T& a, T& b) : 
        ita_{a.begin()},
        itb_{b.begin()},
        ita_begin_{ita_},
        itb_begin_{itb_},
        ita_end_{a.end()},
        itb_end_{b.end()}
    {}
    
  • 我们定义了最小操作符重载以与前向迭代器一起工作:

    zip_iterator& operator++() {
        ++ita_;
        ++itb_;
        return *this;
    }
    bool operator==(const zip_iterator& o) const {
        return ita_ == o.ita_ || itb_ == o.itb_;
    }
    bool operator!=(const zip_iterator& o) const {
        return !operator==(o);
    }
    ret_t operator*() const {
        return { *ita_, *itb_ };
    }
    
  • 最后,begin()end() 函数返回相应的迭代器:

    zip_iterator begin() const
        { return zip_iterator(ita_begin_, itb_begin_); }
    zip_iterator end() const
        { return zip_iterator(ita_end_, itb_end_); }
    

这些通过存储的迭代器和私有构造函数变得简单。

  • 现在让我们扩展我们的 main() 函数以进行测试:

    int main()
    {
        vector<std::string> vec_a {"Bob", "John", "Joni"};
        vector<std::string> vec_b {"Dylan", "Williams", 
            "Mitchell"};
        cout << "vec_a: ";
        for(auto e : vec_a) cout << format("{} ", e);
        cout << '\n';
        cout << "vec_b: ";
        for(auto e : vec_b) cout << format("{} ", e);
        cout << '\n';
        cout << "zipped: ";
        for(auto [a, b] : zip_iterator(vec_a, vec_b)) {
            cout << format("[{}, {}] ", a, b);
        }
        cout << '\n';
    }
    
  • 这给我们想要的输出:

    vec_a: Bob John Joni
    vec_b: Dylan Williams Mitchell
    zipped: [Bob, Dylan] [John, Williams] [Joni, Mitchell]
    

它是如何工作的...

zipped iterator adapter 是一个例子,说明了迭代器抽象可以有多灵活。我们可以取两个容器的迭代器,并将它们用于一个聚合迭代器中。让我们看看它是如何工作的。

zip_iterator 类的主构造函数接受两个容器对象。为了讨论的目的,我们将把这些对象称为 目标 对象。

zip_iterator(T& a, T& b) : 
    ita_{a.begin()},
    itb_{b.begin()},
    ita_begin_{ita_},
    itb_begin_{itb_},
    ita_end_{a.end()},
    itb_end_{b.end()}
{}

构造函数从目标 begin() 迭代器初始化 ita_itb_ 变量。这些将用于导航目标对象。目标 begin()end() 迭代器也保存起来以供以后使用。

这些变量在 private 部分定义:

it_t ita_{};
it_t itb_{};
// for begin() and end() objects
it_t ita_begin_{};
it_t itb_begin_{};
it_t ita_end_{};
it_t itb_end_{};

it_t 类型被定义为目标迭代器类的类型:

using val_t = typename T::value_type;
using ret_t = std::pair<val_t, val_t>;
using it_t = typename T::iterator;

其他别名类型是 val_t 用于目标值的类型,以及 ret_t 用于返回 pair。这些类型定义在类中用于方便。

begin()end() 函数使用一个只初始化 ita_itb_ 值的私有构造函数:

zip_iterator begin() const
  { return zip_iterator(ita_begin_, itb_begin_); }
zip_iterator end() const
  { return zip_iterator(ita_end_, itb_end_); }

private 构造函数看起来是这样的:

// private constructor for begin() and end() objects
zip_iterator(it_t ita, it_t itb) : ita_{ita}, itb_{itb} {}

这是一个接受 it_t 迭代器作为参数的构造函数。它只初始化 ita_itb_,以便它们可以在比较运算符重载中使用。

类的其余部分就像一个正常的迭代器一样工作,但它操作的是目标类的迭代器:

zip_iterator& operator++() {
    ++ita_;
    ++itb_;
    return *this;
}
bool operator==(const zip_iterator& o) const {
    return ita_ == o.ita_ || itb_ == o.itb_;
}
bool operator!=(const zip_iterator& o) const {
    return !operator==(o);
}

解引用运算符返回一个 std::pair 对象(ret_tstd::pair<val_t, val_t> 的别名)。这是从迭代器检索值的接口。

ret_t operator*() const {
    return { *ita_, *itb_ };
}

还有更多...

zip_iterator 适配器可以用来轻松地将对象压缩到 map 中:

map<string, string> name_map{};
for(auto [a, b] : zip_iterator(vec_a, vec_b)) {
    name_map.try_emplace(a, b);
}
cout << "name_map: ";
for(auto [a, b] : name_map) {
    cout << format("[{}, {}] ", a, b);
}
cout << '\n';

如果我们将此代码添加到 main() 中,我们得到以下输出:

name_map: [Bob, Dylan] [John, Williams] [Joni, Mitchell]

创建一个随机访问迭代器

这个配方是一个完整的连续/随机访问迭代器的例子。这是容器中最完整类型的迭代器。随机访问迭代器包括所有其他类型容器迭代器的所有功能,以及它的随机访问能力。

虽然我认为在本章中包含一个完整的迭代器很重要,但这个例子有超过 700 行代码,比本书中的其他例子要大得多。在这里,我将介绍代码的必要组件。请参阅完整的源代码github.com/PacktPublishing/CPP-20-STL-Cookbook/blob/main/chap04/container-iterator.cpp

如何做到这一点...

我们需要一个容器来存储我们的迭代器。我们将使用一个简单的数组来完成这项工作,并将其称为 Containeriterator 类嵌套在 Container 类中。

所有这些设计都是为了与 STL 容器接口保持一致。

  • Container 被定义为 template 类。它的 private 部分只有两个元素:

    template<typename T>
    class Container {
        std::unique_ptr<T[]> c_{};
        size_t n_elements_{};
    

我们使用 unique_pointer 来管理数据。我们让 smart pointer 管理自己的内存。这减轻了对 ~Container() 析构函数的需求。n_elements_ 变量保持我们容器的大小。

  • 在公共部分,我们有我们的构造函数:

    Container(initializer_list<T> l) : n_elements_{l.size()} {
        c_ = std::make_unique<T[]>(n_elements_);
        size_t index{0};
        for(T e : l) {
            c_[index++] = e;
        }
    }
    

第一个构造函数使用 initializer_list 传递容器中的元素。我们调用 make_unique 来分配空间,并通过基于范围的 for 循环填充容器。

  • 我们还有一个构造函数,它分配空间但不填充元素:

    Container(size_t sz) : n_elements_{sz} {
        c_ = std::make_unique<T[]>(n_elements_);
    }
    

make_unique() 函数为元素构造空对象。

  • size() 函数返回元素的数量:

    size_t size() const {
        return n_elements_;
    }
    
  • operator[]() 函数返回一个索引元素:

    const T& operator[](const size_t index) const {
        return c_[index];
    }
    
  • at() 函数返回一个带边界检查的索引元素:

    T& at(const size_t index) const {
        if(index > n_elements_ - 1) {
            throw std::out_of_range(
                "Container::at(): index out of range"
            );
        }
        return c_[index];
    }
    

这与 STL 使用一致。at() 函数是首选方法。

  • begin()end() 函数调用迭代器构造函数,并传递容器数据的地址。

    iterator begin() const { return iterator(c_.get()); }
    iterator end() const { 
        return iterator(c_.get() + n_elements_); 
    }
    

unique_ptr::get() 函数从智能指针返回地址。

  • iterator 类作为 public 成员嵌套在 Container 类中。

    class iterator {
        T* ptr_;
    

迭代器类有一个私有成员,一个指针,它在 Container 类的 begin()end() 方法中被初始化。

  • 迭代器构造函数接受容器数据的指针。

    iterator(T* ptr = nullptr) : ptr_{ptr} {}
    

我们提供默认值,因为标准要求有默认构造函数。

运算符重载

这个迭代器为以下运算符提供了运算符重载:++后缀 ++--后缀 --[]默认比较 <=> (C++20)==*->+非成员 +数值 -对象 -+=-=。我们在这里将介绍一些显著的重载。请参阅源代码以获取所有内容。

  • C++20 默认比较运算符 <=> 提供了完整比较运算符集的功能,除了等式 == 运算符:

    const auto operator<=>(const iterator& o) const {
        return ptr_ <=> o.ptr_;
    }
    

这是一个 C++20 特性,因此它需要一个符合标准的编译器和库。

  • 有两个 + 运算符重载。这些支持 it + nn + it 操作。

    iterator operator+(const size_t n) const {
        return iterator(ptr_ + n);
    }
    // non-member operator (n + it)
    friend const iterator operator+(
            const size_t n, const iterator& o) {
        return iterator(o.ptr_ + n);
    }
    

friend 声明是一个特殊情况。当在模板类成员函数中使用时,它等同于一个非成员函数。这允许在类上下文中定义一个非成员函数。

  • - 运算符也有两个重载。我们需要支持一个数值操作数和一个迭代器操作数。

    const iterator operator-(const size_t n) {
        return iterator(ptr_ - n);
    }
    const size_t operator-(const iterator& o) {
        return ptr_ - o.ptr_;
    }
    

这允许进行 it – nit – it 操作。不需要非成员函数,因为 n – it 不是一个有效的操作。

验证代码

C++20 规范 §23.3.4.13 要求对有效的随机访问迭代器有一组特定的操作和结果。我在源代码中包含了一个 unit_tests() 函数来验证这些要求。

main() 函数创建一个 Container 对象并执行一些简单的验证函数。

  • 首先,我们创建一个包含十个值的 Container<string> 对象 x

    Container<string> x{"one", "two", "three", "four", "five", 
        "six", "seven", "eight", "nine", "ten" };
    cout << format("Container x size: {}\n", x.size());
    

输出给出元素的数量:

Container x size: 10
  • 我们使用基于范围的 for 循环显示容器的元素:

    puts("Container x:");
    for(auto e : x) {
        cout << format("{} ", e);
    }
    cout << '\n';
    

输出:

Container x:
one two three four five six seven eight nine ten
  • 接下来,我们测试几个直接访问方法:

    puts("direct access elements:");
    cout << format("element at(5): {}\n", x.at(5));
    cout << format("element [5]: {}\n", x[5]);
    cout << format("element begin + 5: {}\n",
        *(x.begin() + 5));
    cout << format("element 5 + begin: {}\n",
        *(5 + x.begin()));
    cout << format("element begin += 5: {}\n",
        *(x.begin() += 5));
    

输出:

direct access elements:
element at(5): six
element [5]: six
element begin + 5: six
element 5 + begin: six
element begin += 5: six
  • 我们使用 ranges::views 管道和 views::reverse 测试容器:

    puts("views pipe reverse:");
    auto result = x | views::reverse;
    for(auto v : result) cout << format("{} ", v);
    cout << '\n';
    

输出:

views pipe reverse:
ten nine eight seven six five four three two one
  • 最后,我们创建一个包含 10 个未初始化元素的 Container 对象 y

    Container<string> y(x.size());
    cout << format("Container y size: {}\n", y.size());
    for(auto e : y) {
        cout << format("[{}] ", e);
    }
    cout << '\n';
    

输出:

Container y size: 10
[] [] [] [] [] [] [] [] [] []

它是如何工作的…

虽然代码量很大,但这个迭代器并不比一个更小的迭代器复杂。大部分代码都在运算符重载中,这些重载通常是每行一到两行代码。

容器本身由一个智能指针管理。由于它是一个平铺数组,不需要扩展或压缩,这一点得到了简化。

当然,STL 提供了平铺的std::array类,以及其他更复杂的数据结构。然而,你可能觉得揭示一个完整迭代器类的工作原理是有价值的。

第五章:第五章: Lambda 表达式

C++11 标准引入了 lambda 表达式(有时称为 lambda 函数,或简称 lambda)。这个特性允许在表达式的上下文中使用匿名函数。Lambda 可以在函数调用、容器、变量和其他表达式上下文中使用。这可能听起来无害,但它非常有用。

让我们从 lambda 表达式的简要回顾开始。

Lambda 表达式

Lambda 实质上是一个字面量表达式作为匿名函数:

auto la = []{ return "Hello\n"; };

变量 la 现在可以像函数一样使用:

cout << la();

它可以被传递给另一个函数:

f(la);

它可以被传递给另一个 lambda:

const auto la = []{ return "Hello\n"; };
const auto lb = [](auto a){ return a(); };
cout << lb(la);

输出:

Hello

或者它可以匿名传递(作为字面量):

const auto lb = [](auto a){ return a(); };
cout << lb([]{ return "Hello\n"; });

闭包

术语 闭包 通常应用于任何匿名函数。严格来说,闭包是一个允许在自身词法作用域之外使用符号的函数。

你可能已经注意到了 lambda 定义中的方括号:

auto la = []{ return "Hello\n"; };

方括号用于指定 捕获 列表。捕获是在 lambda 体作用域内可访问的外部变量。如果我没有将外部变量列为捕获,我将得到编译错误:

const char * greeting{ "Hello\n" };
const auto la = []{ return greeting; };
cout << la();

当我尝试使用 GCC 编译这个程序时,我得到了以下错误:

In lambda function:
error: 'greeting' is not captured

这是因为 lambda 的主体有其自己的词法作用域,而 greeting 变量在该作用域之外。

我可以在捕获中指定 greeting 变量。这允许变量进入 lambda 的作用域:

const char * greeting{ "Hello\n" };
const auto la = [greeting]{ return greeting; };
cout << la();

现在它按预期编译并运行:

$ ./working
Hello

这种在其自身作用域之外捕获变量的能力使得 lambda 成为一个 闭包。人们以不同的方式使用这个术语,这没关系,只要我们能互相理解。然而,了解这个术语的含义是很好的。

Lambda 表达式使我们能够编写良好、干净的泛型代码。它们允许使用 函数式编程 模式,其中我们可以将 lambda 作为函数参数传递给算法,甚至传递给其他 lambda。

在本章中,我们将介绍使用 lambda 与 STL 的方法,以下是一些食谱:

  • 使用 lambda 进行作用域可重用代码

  • 使用 lambda 作为算法库中的谓词

  • 使用 std::function 作为多态包装器

  • 使用递归连接 lambda

  • 使用逻辑合取结合谓词

  • 使用相同的输入调用多个 lambda

  • 使用映射 lambda 作为跳转表

技术要求

你可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/CPP-20-STL-Cookbook/tree/main/chap05

使用 lambda 进行作用域可重用代码

Lambda 表达式可以被定义并存储以供以后使用。它们可以作为参数传递,存储在数据结构中,并在不同的上下文中使用不同的参数进行调用。它们与函数一样灵活,但具有数据的移动性。

如何做到这一点...

让我们从一个小程序开始,我们将使用它来测试 lambda 表达式的各种配置:

  • 我们首先定义一个 main() 函数,并使用它来实验 lambda:

    int main() {
        ... // code goes here
    }
    
  • main() 函数内部,我们将声明几个 lambda。lambda 的基本定义需要一个对齐的方括号和花括号中的代码块:

    auto one = [](){ return "one"; };
    auto two = []{ return "two"; };
    

注意,第一个示例 one 在方括号后包含括号,而第二个示例 two 则没有。空参数括号通常包含在内,但并非总是必需的。返回类型由编译器推断。

  • 我可以用 cout 调用这些函数,或者用 format,或者在任何接受 C-字符串的上下文中:

    cout << one() << '\n';
    cout << format("{}\n", two());
    
  • 在许多情况下,编译器可以从 自动类型推导 中确定返回类型。否则,您可以使用 -> 运算符指定返回类型:

    auto one = []() -> const char * { return "one"; };
    auto two = []() -> auto { return "two"; };
    

Lambdas 使用 尾随返回类型 语法。这由 -> 运算符后跟类型指定组成。如果没有指定返回类型,则被认为是 auto。如果您使用尾随返回类型,则必须包含参数括号

  • 让我们定义一个 lambda 来打印出其他 lambda 的值:

    auto p = [](auto v) { cout << v() << '\n'; };
    

p() lambda 期望一个 lambda(或函数)作为其参数 v,并在其函数体中调用它。

auto 类型参数使这个 lambda 成为 缩写模板。在 C++20 之前,这是模板化 lambda 的唯一方法。从 C++20 开始,您可以在捕获括号之后指定模板参数(无需 template 关键字)。这与模板参数等价:

auto p = []<template T>(T v) { cout << v() << '\n'; };

简化的 auto 版本更简单且更常见。它适用于大多数目的。

  • 现在,我们可以在函数调用中传递一个匿名 lambda:

    p([]{ return "lambda call lambda"; });
    

输出如下:

lambda call lambda
  • 如果我们需要向匿名 lambda 传递参数,我们可以在 lambda 表达式之后放置括号:

    << [](auto l, auto r){ return l + r; }(47, 73)
        << '\n';
    

函数参数 4773 被传递到函数体后面的括号中的匿名 lambda。

  • 您可以通过将它们作为 捕获 包含在方括号中来访问 lambda 外部的变量:

    int num{1};
    p([num]{ return num; });
    
  • 或者您可以通过引用捕获它们:

    int num{0};
    auto inc = [&num]{ num++; };
    for (size_t i{0}; i < 5; ++i) {
        inc();
    }
    cout << num << '\n';
    

输出如下:

5

这允许您修改捕获的变量。

  • 您还可以定义一个局部捕获变量以保持其状态:

    auto counter = [n = 0]() mutable { return ++n; };
    for (size_t i{0}; i < 5; ++i) {
        cout << format("{}, ", counter());
    }
    cout << '\n';
    

输出:

1, 2, 3, 4, 5,

mutable 指定符允许 lambda 修改其捕获。lambda 默认为 const-qualified。

与尾随返回类型一样,任何 指定符 都需要参数括号。

  • lambda 支持两种类型的 默认捕获

    int a = 47;
    int b = 73;
    auto l1 = []{ return a + b; };
    

如果我尝试编译此代码,我会得到一个包含以下错误的信息:

note: the lambda has no capture-default

一种默认捕获类型由等号表示:

auto l1 = [=]{ return a + b; };

这将捕获 lambda 范围内的所有符号。等号执行 复制捕获。它将捕获对象的副本,就像使用赋值运算符复制一样。

另一个默认捕获使用 & 符号进行 引用捕获

auto l1 = [&]{ return a + b; };

这是一个默认捕获,通过引用捕获。

默认情况下,捕获只使用符号在它们被引用时,所以它们并不像看起来那么混乱。话虽如此,我建议尽可能使用显式捕获,因为它们通常可以提高可读性。

它是如何工作的…

lambda 表达式的语法如下:

![图 5.1 – lambda 表达式的语法img/B18267_05_01.jpg

图 5.1 – lambda 表达式的语法

lambda 表达式的唯一必需部分是捕获列表和主体,主体可以是空的:

[]{}

这是最小的 lambda 表达式。它不捕获任何内容也不做任何事情。

让我们考虑每个部分。

捕获列表

捕获列表指定了我们捕获的内容,如果有的话。它不能被省略,但它可以是空的。我们可以在 lambda 的作用域内使用 [=] 来捕获所有变量 通过复制[&] 来捕获所有变量 通过引用

您可以通过在括号中列出它们来捕获单个变量:

[a, b]{ return a + b; }

指定的捕获默认为复制。您可以使用引用运算符来通过引用捕获:

[&a, &b]{ return a + b; }

当您通过引用捕获时,您可以修改引用的变量。

注意

您不能直接捕获对象成员。您可以通过列出它们来捕获单个变量 this*this 以解引用类成员。

参数

与函数一样,参数在括号中指定:

[](int a, int b){ return a + b };

如果没有参数、指定符或尾随返回类型,则括号是可选的。指定符或尾随返回类型使括号成为必需:

[]() -> int { return 47 + 73 };

mutable 修饰符(可选)

lambda 表达式默认为 const-qualified,除非您指定 mutable 修饰符。这允许它在 const 上下文中使用,但也意味着它不能修改任何通过复制捕获的变量。例如:

[a]{ return ++a; };

这将无法编译,并显示如下错误信息:

In lambda function:
error: increment of read-only variable 'a'

使用 mutable 修饰符后,lambda 就不再具有 const-qualified,捕获的变量可以被更改:

[a]() mutable { return ++a; };

constexpr 指定符(可选)

您可以使用 constexpr 显式指定您希望 lambda 被视为 常量表达式。这意味着它可以在编译时评估。如果 lambda 满足要求,即使没有指定符,它也可以被视为 constexpr

异常属性(可选)

您可以使用 noexcept 指定符来声明您的 lambda 不会抛出任何异常。

尾随返回类型(可选)

默认情况下,lambda 返回类型是从 return 语句推断的,就像它是 auto 返回类型一样。您可以使用 -> 运算符可选地指定尾随返回类型:

[](int a, int b) -> long { return a + b; };

如果您使用任何可选指定符或尾随返回类型,则参数括号是必需的。

注意

一些编译器,包括 GCC,允许省略空参数括号,即使存在指定符或尾随返回类型。这是不正确的。根据规范,参数、指定符和尾随返回类型都是lambda-declarator的一部分,并且当包含任何部分时都需要括号。这可能在 C++的未来的版本中发生变化。

使用算法库中的 lambda 作为谓词

algorithm库中的某些函数需要使用谓词函数。谓词是一个函数(或仿函数或 lambda),它测试一个条件并返回布尔true/false响应。

如何实现...

对于这个配方,我们将通过使用不同类型的谓词来实验count_if()算法:

  • 首先,让我们创建一个用作谓词的函数。谓词接受一定数量的参数并返回一个boolcount_if()的谓词接受一个参数:

    bool is_div4(int i) {
        return i % 4 == 0;
    }
    

这个谓词检查一个int值是否可以被 4 整除。

  • main()函数中,我们将定义一个int值的向量,并使用它通过count_if()测试我们的谓词函数:

    int main() {
        const vector<int> v{ 1, 7, 4, 9, 4, 8, 12, 10, 20 };
        int count = count_if(v.begin(), v.end(), is_div4);
        cout << format("numbers divisible by 4: {}\n", 
          count);
    }
    

输出如下:

numbers divisible by 4: 5

(可被 5 整除的数字有:4,4,8,12 和 20。)

count_if()算法使用谓词函数来确定要计数的序列中的哪些元素。它将每个元素作为参数调用谓词,并且只有当谓词返回true时才计数元素。

在这种情况下,我们使用了一个函数作为谓词。

  • 我们也可以使用仿函数作为谓词:

    struct is_div4 {
        bool operator()(int i) {
            return i % 4 == 0;
        }
    };
    

这里的唯一变化是我们需要使用类的一个实例作为谓词:

int count = count_if(v.begin(), v.end(), is_div4());

仿函数的优势在于它可以携带上下文并访问类和实例变量。这是在 C++11 引入 lambda 表达式之前使用谓词的常见方式。

  • 使用 lambda 表达式,我们拥有了两种世界的最佳之处:函数的简洁性和仿函数的强大功能。我们可以将 lambda 用作变量:

    auto is_div4 = [](int i){ return i % 4 == 0; };
    int count = count_if(v.begin(), v.end(), is_div4);
    

或者我们可以使用匿名 lambda:

int count = count_if(v.begin(), v.end(), 
    [](int i){ return i % 4 == 0; });
  • 我们可以利用 lambda 捕获,通过将 lambda 包装在函数中来利用它,并使用该函数上下文产生具有不同参数的相同 lambda:

    auto is_div_by(int divisor) {
        return divisor{ return i % divisor == 0; };
    }
    

这个函数返回一个带有捕获上下文中除数的谓词 lambda。

我们然后可以使用该谓词与count_if()一起使用:

for( int i : { 3, 4, 5 } ) {
    auto pred = is_div_by(i);
    int count = count_if(v.begin(), v.end(), pred);
    cout << format("numbers divisible by {}: {}\n", i,
      count);
}

每次调用is_div_by()都会返回一个带有从i的不同除数的谓词。现在我们得到以下输出:

numbers divisible by 3: 2
numbers divisible by 4: 5
numbers divisible by 5: 2

它是如何工作的...

函数指针的类型表示为一个指针后跟函数调用()运算符:

void (*)()

你可以声明一个函数指针并用现有函数的名称初始化它:

void (*fp)() = func;

一旦声明,函数指针可以被解引用并像函数本身一样使用:

func();  // do the func thing

lambda 表达式与函数指针具有相同的类型:

void (*fp)() = []{ cout << "foo\n"; };

这意味着无论你在哪里使用具有特定签名的函数指针,你也可以使用具有相同签名的 lambda 表达式。这允许函数指针、仿函数和 lambda 表达式可以互换使用:

bool (*fp)(int) = is_div4;
bool (*fp)(int) = [](int i){ return i % 4 == 0; };

由于这种可互换性,像 count_if() 这样的算法接受一个函数、仿函数或 lambda,其中它期望一个具有特定函数签名的谓词。

这适用于任何使用谓词的算法。

使用 std::function 作为多态包装器

类模板 std::function 是函数的一个薄薄的多态包装器。它可以存储、复制和调用任何函数、lambda 表达式或其他函数对象。在您希望存储函数或 lambda 引用的地方,它可能很有用。使用 std::function 允许您在同一个容器中存储具有不同签名的函数和 lambda,并保持 lambda 捕获的上下文。

如何做到这一点…

这个配方使用 std::function 类将 lambda 的不同特化存储在 vector 中:

  • 这个配方包含在 main() 函数中,我们首先声明三个不同类型的容器:

    int main() {
        deque<int> d;
        list<int> l;
        vector<int> v;
    

这些容器,dequelistvector,将被模板 lambda 引用。

  • 我们将声明一个简单的 print_c lambda 函数来打印容器:

    auto print_c = [](auto& c) {
        for(auto i : c) cout << format("{} ", i);
        cout << '\n';
    };
    
  • 现在我们声明一个返回匿名 lambda 的 lambda:

    auto push_c = [](auto& container) {
        return &container {
            container.push_back(value);
        };
    };
    

push_c lambda 接收一个容器的引用,该容器被匿名 lambda 所捕获。匿名 lambda 调用捕获容器的 push_back() 成员函数。push_c 的返回值是匿名 lambda。

  • 现在我们声明一个 std::function 元素的 vector,并用三个 push_c() 实例填充它:

    const vector<std::function<void(int)>> 
        consumers { push_c(d), push_c(l), push_c(v) };
    

初始化列表中的每个元素都是对 push_c lambda 的函数调用。push_c 返回匿名 lambda 的一个实例,该实例通过 function 包装器存储在 vector 中。push_c lambda 使用三个容器 dlv 被调用。容器作为捕获传递给匿名 lambda。

  • 现在我们遍历 consumers 向量,并对每个 lambda 元素调用 10 次,将整数 0–9 分别填充到每个容器中:

    for(auto &consume : consumers) {
        for (int i{0}; i < 10; ++i) {
            consume(i);
        }
    }
    
  • 现在我们的三个容器,dequelistvector,都应该填充了整数。让我们将它们打印出来:

    print_c(d);
    print_c(l);
    print_c(v);
    

我们应该得到的结果是:

0 1 2 3 4 5 6 7 8 9
0 1 2 3 4 5 6 7 8 9
0 1 2 3 4 5 6 7 8 9

它是如何工作的…

Lambda 经常与间接引用一起使用,这个配方是这种用法的一个很好的例子。例如,push_c lambda 返回一个匿名 lambda:

auto push_c = [](auto& container) {
    return &container {
        container.push_back(value);
    };
};

这个匿名 lambda 是存储在 vector 中的那个:

const vector<std::function<void(int)>> 
    consumers { push_c(d), push_c(l), push_c(v) };

这是 consumers 容器的定义。它初始化了三个元素,其中每个元素都是通过调用 push_c 来初始化的,它返回一个匿名 lambda。存储在向量中的是匿名 lambda,而不是 push_c lambda。

vector 定义使用 std::function 类作为元素的类型。function 构造函数接受任何可调用对象并将其引用存储为 function 目标:

template< class F >
function( F&& f );

当其函数调用 () 操作符被调用时,function 对象会使用预期的参数调用目标函数:

for(auto &c : consumers) {
    for (int i{0}; i < 10; ++i) {
        c(i);
    }
}

这会调用存储在consumers容器中的每个匿名 lambda 10 次,从而填充dlv容器。

还有更多...

std::function类的本质使其在许多方面都很有用。你可以把它想象成一个多态函数容器。它可以存储一个独立的函数:

void hello() {
    cout << "hello\n";
}
int main() {
    function<void(void)> h = hello;
    h();
}

它可以存储一个成员函数,使用std::bind来绑定函数参数:

struct hello {
    void greeting() const { cout << "Hello Bob\n"; }
};
int main() {
    hello bob{};
    const function<void(void)> h = 
        std::bind(&hello::greeting, &bob);
    h();
}

或者它可以存储任何可执行对象:

struct hello {
    void operator()() const { cout << "Hello Bob\n"; }
};
int main() {
    const function<void(void)> h = hello();
    h();
}

输出如下:

Hello Bob

使用递归连接 lambda

你可以将 lambda 堆叠起来,使得一个的输出是下一个的输入,使用一个简单的递归函数。这创建了一种简单的方法来构建一个函数在另一个函数之上。

如何实现...

这是一个简短且简单的配方,使用一个递归函数来完成大部分工作:

  • 我们首先定义连接函数concat()

    template <typename T, typename ...Ts>
    auto concat(T t, Ts ...ts) {
        if constexpr (sizeof...(ts) > 0) {
            return & {
    return t(concat(ts...)(parameters...)); 
            };
        } else  {
            return t;
        }
    }
    

这个函数返回一个匿名 lambda,它反过来再次调用函数,直到参数包耗尽。

  • main()函数中,我们创建了一些 lambda 并使用它们调用concat()函数:

    int main() {
        auto twice = [](auto i) { return i * 2; };
        auto thrice = [](auto i) { return i * 3; };
        auto combined = concat(thrice, twice, 
          std::plus<int>{});
        std::cout << format("{}\n", combined(2, 3));
    }
    

concat()函数使用三个参数被调用:两个 lambda 和std::plus()函数。

当递归展开时,函数从右到左被调用,从plus()开始。plus()函数接受两个参数并返回总和。从plus()返回的值传递给twice(),然后将其返回值传递给thrice()。然后使用format()将结果打印到控制台:

30

它是如何工作的...

concat()函数很简单,但由于返回 lambda 的递归间接引用可能令人困惑:

template <typename T, typename ...Ts>
auto concat(T t, Ts ...ts) {
    if constexpr (sizeof...(ts) > 0) {
        return & {
            return t(concat(ts...)(parameters...)); 
        };
    } else  {
        return t;
    }
}

concat()函数使用参数包被调用。使用省略号,sizeof...运算符返回参数包中的元素数量。这用于测试递归的结束。

concat()函数返回一个 lambda。这个 lambda 递归地调用concat()函数。因为concat()的第一个参数不是参数包的一部分,所以每次递归调用都会剥去包的第一个元素。

外部的return语句返回 lambda。内部的return来自 lambda。lambda 调用传递给concat()的函数并返回其值。

随意拆解并研究它。这个技术很有价值。

使用逻辑合取连接谓词

这个例子将 lambda 包装在一个函数中,以创建用于算法谓词的自定义合取。

如何实现...

copy_if()算法需要一个接受一个参数的谓词。在这个配方中,我们将从三个其他 lambda 中创建一个谓词 lambda:

  • 首先,我们将编写combine()函数。这个函数返回一个用于与copy_if()算法一起使用的 lambda 表达式:

    template <typename F, typename A, typename B>
    auto combine(F binary_func, A a, B b) {
        return = {
            return binary_func(a(param), b(param));
        };
    }
    

combine()函数接受三个函数参数——一个二元合取和两个谓词——并返回一个调用合取与两个谓词的 lambda。

  • main()函数中,我们创建用于与combine()一起使用的 lambda 表达式:

    int main() {
        auto begins_with = [](const string &s){
            return s.find("a") == 0;
        };
        auto ends_with = [](const string &s){
            return s.rfind("b") == s.length() - 1;
        };
        auto bool_and = [](const auto& l, const auto& r){
            return l && r;
        };
    

begins_withends_with lambda 是简单的过滤器谓词,分别用于查找以 'a' 开头和以 'b' 结尾的字符串。bool_and lambda 是合取。

  • 现在我们可以使用 combine() 调用 copy_if 算法:

    std::copy_if(istream_iterator<string>{cin}, {},
                 ostream_iterator<string>{cout, " "},
                 combine(bool_and, begins_with, 
    ends_with));
    cout << '\n';
    

combine() 函数返回一个 lambda,该 lambda 通过合取将两个谓词结合起来。

输出看起来如下:

$ echo aabb bbaa foo bar abazb | ./conjunction
aabb abazb

它是如何工作的…

std::copy_if() 算法需要一个接受一个参数的谓词函数,但我们的合取需要两个参数,每个参数都需要一个参数。我们通过返回一个特定于该上下文的 lambda 的函数来解决这个问题:

template <typename F, typename A, typename B>
auto combine(F binary_func, A a, B b) {
    return = {
        return binary_func(a(param), b(param));
    };
}

combine() 函数从一个函数参数创建一个 lambda,每个参数都是一个函数。返回的 lambda 接受谓词函数所需的单个参数。现在我们可以使用 combine() 函数调用 copy_if()

std::copy_if(istream_iterator<string>{cin}, {},
             ostream_iterator<string>{cout, " "},
             combine(bool_and, begins_with, ends_with));

这将组合 lambda 传递给算法,以便它可以在该上下文中操作。

使用相同的输入调用多个 lambda

你可以通过将 lambda 包装在函数中来轻松创建具有不同捕获值的 lambda 的多个实例。这允许你使用相同的输入调用 lambda 的不同版本。

如何做到这一点…

这是一个简单的例子,展示了如何使用不同类型的括号包装一个值:

  • 我们首先创建包装函数 braces()

    auto braces (const char a, const char b) {
        return a, b {
            cout << format("{}{}{} ", a, v, b);
        };
    }
    

braces() 函数包装一个返回三个值字符串的 lambda,其中第一个和最后一个值是传递给 lambda 作为捕获的字符,中间的值作为参数传递。

  • main() 函数中,我们使用 braces() 创建四个 lambda,使用四组不同的括号:

    auto a = braces('(', ')');
    auto b = braces('[', ']');
    auto c = braces('{', '}');
    auto d = braces('|', '|');
    
  • 现在我们可以从简单的 for() 循环中调用我们的 lambda:

    for( int i : { 1, 2, 3, 4, 5 } ) {
        for( auto x : { a, b, c, d } ) x(i);
        cout << '\n';
    }
    

这是一个嵌套的 for() 循环。外层循环简单地从 1 计数到 5,将整数传递给内层循环。内层循环调用带有括号的 lambda。

两个循环都使用一个 初始化列表 作为基于范围的 for() 循环中的容器。这是一种方便的技术,用于遍历一组小的值。

  • 我们程序的输出如下:

    (1) [1] {1} |1|
    (2) [2] {2} |2|
    (3) [3] {3} |3|
    (4) [4] {4} |4|
    (5) [5] {5} |5|
    

输出显示了每个整数,以及每个括号组合。

它是如何工作的…

这是一个如何使用 lambda 包装器的简单例子。braces() 函数使用传递给它的括号构建一个 lambda:

auto braces (const char a, const char b) {
    return a, b {
        cout << format("{}{}{} ", a, v, b);
    };
}

通过将 braces() 函数的参数传递给 lambda,它可以返回一个具有该上下文的 lambda。因此,主函数中的每个赋值都携带这些参数:

auto a = braces('(', ')');
auto b = braces('[', ']');
auto c = braces('{', '}');
auto d = braces('|', '|');

当这些 lambda 用数字调用时,它们将返回一个包含相应括号中该数字的字符串。

使用映射 lambda 作为跳转表

当你想从用户或其他输入中选择一个动作时,跳转表是一个有用的模式。跳转表通常在 if/elseswitch 结构中实现。在这个菜谱中,我们将使用 STL map 和匿名 lambda 仅构建一个简洁的跳转表。

如何做到这一点…

map和 lambda 构建简单的跳转表很容易。map提供了简单的索引导航,lambda 可以作为负载存储。下面是如何做到这一点:

  • 首先,我们将创建一个简单的prompt()函数来从控制台获取输入:

    const char prompt(const char * p) {
        std::string r;
        cout << format("{} > ", p);
        std::getline(cin, r, '\n');
        if(r.size() < 1) return '\0';
        if(r.size() > 1) {
            cout << "Response too long\n";
            return '\0';
        }
        return toupper(r[0]);
    }
    

C 字符串参数用作提示。调用std::getline()从用户那里获取输入。响应存储在r中,检查长度,然后如果长度为单个字符,则将其转换为大写并返回。

  • main()函数中,我们声明并初始化一个 lambda 的map

    using jumpfunc = void(*)();
    map<const char, jumpfunc> jumpmap {
        { 'A', []{ cout << "func A\n"; } },
        { 'B', []{ cout << "func B\n"; } },
        { 'C', []{ cout << "func C\n"; } },
        { 'D', []{ cout << "func D\n"; } },
        { 'X', []{ cout << "Bye!\n"; } }
    };
    

map容器加载了用于跳转表的匿名 lambda。这些 lambda 可以轻松调用其他函数或执行简单任务。

using别名是为了方便。我们使用函数指针类型void(*)()作为 lambda 的负载。如果你更喜欢,你可以使用std::function(),如果你需要更多的灵活性或者觉得它更易读。它的开销非常小:

using jumpfunc = std::function<void()>;
  • 现在我们可以提示用户输入并从map中选择一个动作:

    char select{};
    while(select != 'X') {
        if((select = prompt("select A/B/C/D/X"))) {
            auto it = jumpmap.find(select);
            if(it != jumpmap.end()) it->second();
            else cout << "Invalid response\n";
        }
    }
    

这是我们如何使用基于map的跳转表。我们循环直到选择'X'以退出。我们使用提示字符串调用prompt(),在map对象上调用find(),然后调用 lambda 的it->second()

它是如何工作的…

map容器是一个出色的跳转表。它简洁且易于导航:

using jumpfunc = void(*)();
map<const char, jumpfunc> jumpmap {
    { 'A', []{ cout << "func A\n"; } },
    { 'B', []{ cout << "func B\n"; } },
    { 'C', []{ cout << "func C\n"; } },
    { 'D', []{ cout << "func D\n"; } },
    { 'X', []{ cout << "Bye!\n"; } }
};

匿名 lambda 存储在map容器中作为负载。键是来自动作菜单的字符响应。

你可以在一个动作中测试键的有效性并选择一个 lambda:

auto it = jumpmap.find(select);
if(it != jumpmap.end()) it->second();
else cout << "Invalid response\n";

这是一个简单、优雅的解决方案,否则我们可能会使用尴尬的分支代码。

第六章:第六章:STL 算法

STL 的许多功能都体现在容器接口的标准化上。如果一个容器具有特定的功能,那么该功能的接口很可能在所有容器类型中都是标准化的。这种标准化使得一个库成为可能,该库中的 算法 可以无缝地在具有公共接口的容器和序列上运行。

例如,如果我们想计算 vector 中所有 int 元素的总和,我们可以使用循环:

vector<int> x { 1, 2, 3, 4, 5 };
long sum{};
for( int i : x ) sum += i;                     // sum is 15

或者我们可以使用一个算法:

vector<int> x { 1, 2, 3, 4, 5 };
auto sum = accumulate(x.begin(), x.end(), 0);  // sum is 15

此语法同样适用于其他容器:

deque<int> x { 1, 2, 3, 4, 5 };
auto sum = accumulate(x.begin(), x.end(), 0);  // sum is 15

算法版本不一定更短,但它更容易阅读和维护。而且算法通常比等效循环更高效。

从 C++20 开始,ranges 库提供了一套操作于 rangesviews 的替代算法。本书将适当地演示这些替代方案。有关 ranges 和 views 的更多信息,请参阅本书第一章 Chaper 1 中的配方 使用 ranges 创建容器视图New C++20 Features

大多数算法都在 algorithm 头文件中。一些数值算法,特别是 accumulate(),在 numeric 头文件中,一些与内存相关的算法在 memory 头文件中。

我们将在以下配方中介绍 STL 算法:

  • 从一个迭代器复制到另一个迭代器

  • 将容器元素连接成一个字符串

  • 使用 std::sort 对容器进行排序

  • 使用 std::transform 修改容器

  • 在容器中查找项目

  • 使用 std::clamp 限制容器的值在一个范围内

  • 使用 std::sample 的示例数据集

  • 生成数据序列的排列

  • 合并排序后的容器

技术要求

您可以在 GitHub 上找到本章的代码文件:github.com/PacktPublishing/CPP-20-STL-Cookbook/tree/main/chap06

从一个迭代器复制到另一个迭代器

复制算法 通常用于在容器之间复制数据,但实际上,它们与迭代器一起工作,这要灵活得多。

如何做到这一点...

在本配方中,我们将通过实验 std::copystd::copy_n 来深入了解它们的工作原理:

  • 让我们从打印容器的函数开始:

    void printc(auto& c, string_view s = "") {
        if(s.size()) cout << format("{}: ", s);
        for(auto e : c) cout << format("[{}] ", e);
        cout << '\n';
    }
    
  • main() 中,我们定义一个 vector 并使用 printc() 打印它:

    int main() {
        vector<string> v1
            { "alpha", "beta", "gamma", "delta", 
              "epsilon" };
        printc(v1);
    }
    

我们得到以下输出:

v1: [alpha] [beta] [gamma] [delta] [epsilon]
  • 现在,让我们创建第二个 vector,它有足够的空间来复制第一个 vector

    vector<string> v2(v1.size());
    
  • 我们可以使用 std::copy() 算法将 v1 复制到 v2

    std::copy(v1.begin(), v1.end(), v2.begin());
    printc(v2);
    

std::copy() 算法接受两个迭代器作为复制源的范围,以及一个迭代器作为目标。在这种情况下,我们给它 v1begin()end() 迭代器来复制整个 vectorv2begin() 迭代器作为复制的目标。

我们现在的输出是:

v1: [alpha] [beta] [gamma] [delta] [epsilon]
v2: [alpha] [beta] [gamma] [delta] [epsilon]
  • copy() 算法不会为目的地分配空间。因此,v2 必须已经为复制分配了空间。或者,您可以使用 back_inserter() 迭代器适配器在 vector 的末尾插入元素:

    vector<string> v2{};
    std::copy(v1.begin(), v1.end(), back_inserter(v2))
    
  • 我们还可以使用 ranges::copy() 算法来复制整个范围。容器对象作为范围,因此我们可以使用 v1 作为源。我们仍然使用迭代器作为目标:

    vector<string> v2(v1.size());
    ranges::copy(v1, v2.begin());
    

这也可以与 back_inserter() 一起使用:

vector<string> v2{};
ranges::copy(v1, back_inserter(v2));

输出:

v2: [alpha] [beta] [gamma] [delta] [epsilon]
  • 您可以使用 copy_n() 复制一定数量的元素:

    vector<string> v3{};
    std::copy_n(v1.begin(), 3, back_inserter(v3));
    printc(v3, "v3");
    

在第二个参数中,copy_n() 算法是复制元素数量的计数。输出如下:

v3: [alpha] [beta] [gamma]
  • 此外,还有一个使用布尔谓词函数来决定哪些元素需要复制的 copy_if() 算法:

    vector<string> v4{};
    std::copy_if(v1.begin(), v1.end(), back_inserter(v4), 
        [](string& s){ return s.size() > 4; });
    printc(v4, "v4");
    

还有一个 ranges 版本的 copy_if()

vector<string> v4{};
ranges::copy_if(v1, back_inserter(v4), 
    [](string& s){ return s.size() > 4; });
printc(v4, "v4");

输出仅包括长度超过 4 个字符的字符串:

v4: [alpha] [gamma] [delta] [epsilon]

注意,值 beta 被排除了。

  • 您可以使用这些算法中的任何一个将数据复制到或从任何序列中,包括流迭代器:

    ostream_iterator<string> out_it(cout, " ");
    ranges::copy(v1, out_it)
    cout << '\n';
    

输出:

alpha beta gamma delta epsilon

它是如何工作的…

std::copy() 算法非常简单。等效函数可能看起来像这样:

template<typename Input_it, typename Output_it>
Output_it bw_copy(Input_it begin_it, Input_it end_it, 
                  Output_it dest_it) {
    while (begin_it != end_it) {
        *dest_it++ = *begin_it++;
    }
    return dest_it;
}

copy() 函数使用目标迭代器的赋值运算符从输入迭代器复制到输出迭代器,直到达到输入范围的末尾。

此外,还有一个名为 std::move() 的算法版本,它移动元素而不是复制它们:

std::move(v1.begin(), v1.end(), v2.begin());
printc(v1, "after move: v1");
printc(v2, "after move: v2");

这执行的是移动赋值而不是复制赋值。移动操作后,v1 中的元素将为空,而原本在 v1 中的元素现在在 v2 中。输出如下所示:

after move1: v1: [] [] [] [] []
after move1: v2: [alpha] [beta] [gamma] [delta] [epsilon]

还有一个 ranges 版本的 move() 算法执行相同的操作:

ranges::move(v1, v2.begin());

这些算法的强大之处在于它们的简单性。通过让迭代器管理数据,这些简单、优雅的函数允许您无缝地在支持所需迭代器的任何 STL 容器之间复制或移动。

将容器元素连接成一个字符串

有时,库中没有算法来完成手头的任务。我们可以使用迭代器,使用与 algorithms 库相同的技巧,轻松编写一个。

例如,我们经常需要将容器中的元素,带分隔符,连接成一个字符串。一个常见的解决方案是使用简单的 for() 循环:

for(auto v : c) cout << v << ', ';

这个解决方案的缺点是它留下了一个尾随分隔符:

vector<string> greek{ "alpha", "beta", "gamma", 
                      "delta", "epsilon" };
for(auto v : greek) cout << v << ", ";
cout << '\n';

输出:

alpha, beta, gamma, delta, epsilon,

这在测试环境中可能没问题,但在任何生产系统中,尾随逗号都是不可接受的。

ranges::views 库有一个 join() 函数,但它不提供分隔符:

auto greek_view = views::join(greek);

views::join() 函数返回一个 ranges::view 对象。这需要额外的步骤来显示或将其转换为字符串。我们可以使用 for() 循环遍历视图:

for(const char c : greek_view) cout << c;
cout << '\n';

输出如下所示:

alphabetagammadeltaepsilon

所有的内容都在那里,但我们需要一个合适的分隔符来使其对我们的目的有用。

由于 algorithms 库中没有适合我们需求的函数,我们将编写一个。

如何做到这一点…

对于这个菜谱,我们将取容器中的元素,并用分隔符将它们连接成一个字符串:

  • 在我们的 main() 函数中,我们声明了一个字符串向量:

    int main() {
        vector<string> greek{ "alpha", "beta", "gamma",
            "delta", "epsilon" };
        ...
    }
    
  • 现在,让我们编写一个简单的 join() 函数,该函数使用 ostream 对象将元素与分隔符连接起来:

    namespace bw {
        template<typename I>
        ostream& join(I it, I end_it, ostream& o, 
                      string_view sep = "") {
            if(it != end_it) o << *it++;
            while(it != end_it) o << sep << *it++;
            return o;
        }
    }
    

我已经将这个函数放在了自己的 bw 命名空间中,以避免名称冲突。

我们可以用 cout 来调用它:

bw::join(greek.begin(), greek.end(), cout, ", ") << '\n';

因为它返回 ostream 对象,所以我们可以跟在它后面使用 << 向流中添加一个 换行符

输出:

alpha, beta, gamma, delta, epsilon
  • 我们经常想要一个 string,而不是直接写入 cout。我们可以为此函数重载一个返回 string 对象的版本:

    template<typename I>
    string join(I it, I end_it, string_view sep = "") {
        ostringstream ostr;
        join(it, end_it, ostr, sep);
        return ostr.str();
    }
    

这也放在了 bw 命名空间中。这个函数创建了一个 ostringstream 对象,并将其传递给 bw::join()ostream 版本。它从 ostringstream 对象的 str() 方法返回一个 string 对象。

我们可以像这样使用它:

string s = bw::join(greek.begin(), greek.end(), ", ");
cout << s << '\n';

输出:

alpha, beta, gamma, delta, epsilon
  • 让我们添加一个最终的重载,使其更容易使用:

    string join(const auto& c, string_view sep = "") {
        return join(begin(c), end(c), sep);
    }
    

这个版本只接受一个容器和一个分隔符,这应该可以很好地满足大多数用例:

string s = bw::join(greek, ", ");
cout << s << '\n';

输出:

alpha, beta, gamma, delta, epsilon

它是如何工作的…

这个菜谱中的大部分工作都是由迭代器和 ostream 对象完成的:

namespace bw {
    template<typename I>
    ostream& join(I it, I end_it, ostream& o, 
                  string_view sep = "") {
        if(it != end_it) o << *it++;
        while(it != end_it) o << sep << *it++;
        return o;
    }
}

分隔符放在第一个元素之后,在连续元素之间,并在最终元素之前停止。这意味着我们可以在每个元素 之前 添加一个分隔符,跳过第一个,或者在每个元素 之后 添加,跳过最后一个。如果我们在 while() 循环之前测试并跳过第一个元素,逻辑会更简单。我们就在 while() 循环之前这样做:

if(it != end_it) o << *it++;

一旦我们处理掉了第一个元素,我们就可以在剩余的每个元素前简单地添加一个分隔符:

while(it != end_it) o << sep << *it++;

我们返回 ostream 对象作为便利。这使用户能够轻松地向流中添加换行符或其他对象:

bw::join(greek.begin(), greek.end(), cout, ", ") << '\n';

输出:

alpha, beta, gamma, delta, epsilon

还有更多…

与库中的任何算法一样,join() 函数可以与任何支持 forward iterators 的容器一起工作。例如,这里有一个来自 numbers 库的 double 常量 list

namespace num = std::numbers;
list<double> constants { num::pi, num::e, num::sqrt2 };
cout << bw::join(constants, ", ") << '\n';

输出:

3.14159, 2.71828, 1.41421

它甚至可以与 ranges::view 对象一起工作,就像在这个菜谱中之前定义的 greek_view

cout << bw::join(greek_view, ":") << '\n';

输出:

a:l:p:h:a:b:e:t:a:g:a:m:m:a:d:e:l:t:a:e:p:s:i:l:o:n

使用 std::sort 对容器进行排序

如何有效地对可比较元素进行排序的问题本质上已经解决。对于大多数应用来说,没有必要重新发明轮子。STL 通过 std::sort() 算法提供了一个出色的排序解决方案。虽然标准没有指定排序算法,但它确实指定了当应用于 n 个元素的范围内时,最坏情况下的复杂度为 O(n log n)。

仅在几十年前,快速排序 算法被认为是对大多数用途的良好折衷方案,并且通常比其他类似算法更快。今天,我们有 混合 算法,这些算法会根据情况选择不同的方法,通常在运行时切换算法。大多数当前的 C++ 库使用一种混合方法,结合了 introsort插入排序std::sort() 在大多数常见情况下提供了卓越的性能。

如何做到这一点...

在这个菜谱中,我们将检查 std::sort() 算法。sort() 算法与任何具有随机访问迭代器的容器一起工作。这里,我们将使用 intvector

  • 我们将从测试容器是否排序的函数开始:

    void check_sorted(auto &c) {
        if(!is_sorted(c.begin(), c.end())) cout << "un";
        cout << "sorted: ";
    }
    

这使用了 std::is_sorted() 算法,并根据结果打印 "sorted:""unsorted:"

  • 我们需要一个函数来打印我们的 vector

    void printc(const auto &c) {
        check_sorted(c);
        for(auto& e : c) cout << e << ' ';
        cout << '\n';
    }
    

这个函数调用 check_sorted() 来显示在值之前容器的状态。

  • 现在我们可以定义并打印 main() 函数中的 intvector

    int main() {
        vector<int> v{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
        printc(v);
        …
    }
    

输出看起来像这样:

sorted: 1 2 3 4 5 6 7 8 9 10
  • 为了测试 std::sort() 算法,我们需要一个未排序的向量。这是一个简单的函数来随机化我们的容器:

    void randomize(auto& c) {
        static std::random_device rd;
        static std::default_random_engine rng(rd());
        std::shuffle(c.begin(), c.end(), rng);
    }
    

std::random_device 类使用你的系统硬件 源。大多数现代系统都有一个,否则库将模拟它。std::default_random_engine() 函数从熵源生成随机数。这被 std::shuffle() 用于随机化容器。

我们现在可以用我们的容器调用 randomize() 并打印结果:

randomize(v);
printc(v);

输出:

unsorted: 6 3 4 8 10 1 2 5 9 7

当然,你的输出会不同,因为它被随机化了。事实上,每次我运行它时,我都会得到不同的结果:

for(int i{3}; i; --i) {
    randomize(v);
    printc(v);
}

输出:

unsorted: 3 1 8 5 10 2 7 9 6 4
unsorted: 7 6 5 1 3 9 10 2 4 8
unsorted: 4 2 3 10 1 9 5 6 8 7
  • 要对向量进行排序,我们只需调用 std::sort()

    std::sort(v.begin(), v.end());
    printc(v);
    

输出:

sorted: 1 2 3 4 5 6 7 8 9 10

默认情况下,sort() 算法使用 < 操作符对指定迭代器范围的元素进行排序。

  • partial_sort() 算法将排序容器的一部分:

    cout << "partial_sort:\n";
    randomize(v);
    auto middle{ v.begin() + (v.size() / 2) };
    std::partial_sort(v.begin(), middle, v.end());
    printc(v);
    

partial_sort() 接受三个迭代器:开始、中间和结束。它将容器排序,使得中间之前的元素是有序的。中间之后的元素不保证保持原始顺序。以下是输出:

unsorted: 1 2 3 4 5 10 7 6 8 9

注意到前五个元素是有序的,其余的不是。

  • partition() 算法 不会 对任何东西进行排序。它重新排列容器,使某些元素出现在容器的前面:

    coutrandomize(v);
    printc(v);
    partition(v.begin(), v.end(), [](int i)
        { return i > 5; });
    printc(v);
    

第三个参数是一个 谓词 lambda,它确定哪些元素将被移动到前面。

输出:

unsorted: 4 6 8 1 9 5 2 7 3 10
unsorted: 10 6 8 7 9 5 2 1 3 4

注意值 >5 被移动到容器的前面。

  • sort() 算法支持一个可选的比较函数,可用于非标准比较。例如,给定一个名为 things 的类:

    struct things {
        string s_;
        int i_;
        string str() const {
            return format("({}, {})", s_, i_);
        }
    };
    

我们可以创建一个 vectorthings

vector<things> vthings{ {"button", 40},
    {"hamburger", 20}, {"blog", 1000},
    {"page", 100}, {"science", 60} };

我们需要一个函数来打印它们:

void print_things(const auto& c) {
    for (auto& v : c) cout << v.str() << ' ';
    cout << '\n';
}
  • 现在我们可以排序并打印 thingsvector

    std::sort(vthings.begin(), vthings.end(), 
            [](const things &lhs, const things &rhs) {
        return lhs.i_ < rhs.i_;
    });
    print_things(vthings);
    

输出:

(hamburger, 20) (button, 40) (science, 60) (page, 100) (blog, 1000)

注意比较函数按 i_ 成员排序,所以结果是按 i_ 排序的。我们也可以按 s_ 成员排序:

std::sort(vthings.begin(), vthings.end(), 
        [](const things &lhs, const things &rhs) {
    return lhs.s_ < rhs.s_;
});
print_things(vthings);

现在我们得到这个输出:

(blog, 1000) (button, 40) (hamburger, 20) (page, 100) (science, 60)

它是如何工作的...

sort() 函数通过将排序算法应用于由两个迭代器指示的元素范围(范围的开始和结束)来工作。

默认情况下,这些算法使用 < 操作符来比较元素。可选地,它们可能使用 比较函数,通常作为 lambda 提供的:

std::sort(vthings.begin(), vthings.end(), 
        [](const things& lhs, const things& rhs) {
    return lhs.i_ < rhs.i_;
});

比较函数接受两个参数并返回一个 bool。它的签名等同于以下:

bool cmp(const Type1& a, const Type2& b);

sort() 函数使用 std::swap() 来移动元素。这在计算周期和内存使用上都很高效,因为它避免了为读取和写入正在排序的对象分配空间的需求。这也是为什么 partial_sort()partition() 函数不能保证未排序元素的顺序。

使用 std::transform 修改容器

std::transform() 函数非常强大且灵活。它是库中更常用的一些算法之一,它将一个 函数lambda 应用到容器中的每个元素上,同时将结果存储在另一个容器中,而原始容器保持不变。

由于其强大的功能,使用起来出奇地简单。

如何实现它...

在这个菜谱中,我们将探讨 std::transform() 函数的一些应用:

  • 我们从一个简单的打印容器内容的函数开始:

    void printc(auto& c, string_view s = "") {
        if(s.size()) cout << format("{}: ", s);
        for(auto e : c) cout << format("{} ", e);
        cout << '\n';
    }
    

我们将使用它来查看转换的结果。

  • main() 函数中,让我们声明几个向量:

    int main() {
        vector<int> v1{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
        vector<int> v2;
        printc(v1, "v1");
        ...
    }
    

这会打印出 v1 的内容:

v1: 1 2 3 4 5 6 7 8 9 10
  • 现在,我们可以使用 transform() 函数将每个值的平方插入到 v2 中:

    cout << "squares:\n";
    transform(v1.begin(), v1.end(), back_inserter(v2),
        [](int x){ return x * x; });
    printc(v2, "v2");
    

transform() 函数接受四个参数。前两个参数是源范围的 begin()end() 迭代器。第三个参数是目标范围的 begin() 迭代器。在这种情况下,我们使用 back_inserter() 算法将结果插入到 v2 中。第四个参数是转换函数。在这种情况下,我们使用一个简单的 lambda 来平方值。

输出:

squares:
v2: 1 4 9 16 25 36 49 64 81 100
  • 当然,我们可以用 transform() 函数处理任何类型。以下是一个将 string 对象的 vector 转换为小写的示例。首先,我们需要一个函数来返回字符串的小写值:

    string str_lower(const string& s) {
        string outstr{};
        for(const char& c : s) {
            outstr += tolower(c);
        }
        return outstr;
    }
    

现在,我们可以使用 str_lower() 函数与 transform 一起使用:

vector<string> vstr1{ "Mercury", "Venus", "Earth",
    "Mars", "Jupiter", "Saturn", "Uranus", "Neptune",
    "Pluto" };
vector<string> vstr2;
printc(vstr1, "vstr1");
cout << "str_lower:\n";
transform(vstr1.begin(), vstr1.end(),
    back_inserter(vstr2), 
    [](string& x){ return str_lower(x); });
printc(vstr2, "vstr2");

这会对 vstr1 中的每个元素调用 str_lower() 并将结果插入到 vstr2 中。结果是:

vstr: Mercury Venus Earth Mars Jupiter Saturn Uranus Neptune Pluto
str_lower:
vstr: mercury venus earth mars jupiter saturn uranus neptune pluto

(是的,对我来说,冥王星始终是行星。)

  • 还有一个 ranges 版本的 transform

    cout << "ranges squares:\n";
    auto view1 = views::transform(v1, [](int x){ 
        return x * x; });
    printc(view1, "view1");
    

ranges 版本具有更简洁的语法,并返回一个 view 对象,而不是填充另一个容器。

它是如何工作的...

std::transform() 函数的工作方式非常类似于 std::copy(),它增加了用户提供的函数。输入范围内的每个元素都会传递给该函数,函数的返回值会被复制分配给目标迭代器。这使得 transform() 成为一个独特且强大的算法。

值得注意的是,transform() 函数并不能保证元素会按顺序处理。如果你需要确保转换的顺序,你应该使用 for 循环代替:

v2.clear();    // reset vector v2 to empty state
for(auto e : v1) v2.push_back(e * e);
printc(v2, "v2");

输出:

v2: 1 4 9 16 25 36 49 64 81 100

在容器中查找项目

algorithm 库包含了一组用于在容器中查找元素的函数。std::find() 函数及其衍生函数会顺序遍历容器,并返回一个指向第一个匹配元素的迭代器,如果没有匹配则返回 end() 元素。

如何实现...

find() 算法与满足 ForwardInput 迭代器资格的任何容器一起工作。对于这个配方,我们将使用 vector 容器。find() 算法在容器中顺序搜索第一个匹配元素。在这个配方中,我们将通过几个示例来讲解:

  • 我们首先在 main() 函数中声明一个 int 类型的 vector

    int main() {
        const vector<int> v{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
        ...
    }
    
  • 现在,让我们搜索值为 7 的元素:

    auto it1 = find(v.begin(), v.end(), 7);
    if(it1 != v.end()) cout << format("found: {}\n", *it1);
    else cout << "not found\n";
    

find() 算法接受三个参数:begin()end() 迭代器,以及要搜索的值。它返回指向它找到的第一个元素的迭代器,或者在搜索未成功找到匹配项时返回 end() 迭代器。

输出:

found: 7
  • 我们也可以搜索比标量更复杂的东西。该对象需要支持相等比较运算符 ==。这里有一个简单的结构体,它重载了 operator==()

    struct City {
        string name{};
        unsigned pop{};
        bool operator==(const City& o) const {
            return name == o.name;
        }
        string str() const {
            return format("[{}, {}]", name, pop);
        }
    };
    

注意,operator=() 重载只比较 name 成员。

我还包含了一个 str() 函数,它返回 City 元素的字符串表示形式。

  • 现在,我们可以声明一个 City 元素的 vector

    const vector<City> c{
        { "London", 9425622 },
        { "Berlin", 3566791 },
        { "Tokyo",  37435191 },
        { "Cairo",  20485965 }
    };
    
  • 我们可以像搜索 int 类型的 vector 一样搜索 City 类型的 vector

    auto it2 = find(c.begin(), c.end(), City{"Berlin"});
    if(it2 != c.end()) cout << format("found: {}\n", 
        it2->str());
    else cout << "not found\n";
    

输出:

found: [Berlin, 3566791]
  • 如果我们想搜索 pop 成员而不是 name,我们可以使用带有谓词的 find_if() 函数:

    auto it3 = find_if(begin(c), end(c),
        [](const City& item)
            { return item.pop > 20000000; });
    if(it3 != c.end()) cout << format("found: {}\n",
        it3->str());
    else cout << "not found\n";
    

谓词测试 pop 成员,所以我们得到这个输出:

found: [Tokyo, 37435191]
  • 注意,find_if() 的结果只返回满足谓词的第一个元素,即使 vector 中有两个元素的 pop 值大于 20,000,000。

find()find_if() 函数只返回一个迭代器。ranges 库提供了 ranges::views::filter(),这是一个 视图适配器,它将给我们所有匹配的迭代器,而不会干扰我们的 vector

auto vw1 = ranges::views::filter(c, 
    [](const City& c){ return c.pop > 20000000; });
for(const City& e : vw1) cout << format("{}\n", e.str());

这给我们带来了输出中的所有匹配元素:

[Tokyo, 37435191]
[Cairo, 20485965]

它是如何工作的……

find()find_if() 函数顺序遍历容器,检查每个元素,直到找到匹配项。如果找到匹配项,它返回指向该匹配项的迭代器。如果在没有找到匹配项的情况下达到 end() 迭代器,它返回 end() 迭代器以指示没有找到匹配项。

find() 函数接受三个参数,即 begin()end() 迭代器,以及搜索值。其签名如下:

template<class InputIt, class T>
constexpr InputIt find(InputIt, InputIt, const T&)

find_if() 函数使用谓词而不是值:

template<class InputIt, class UnaryPredicate>
constexpr InputIt find_if(InputIt, InputIt, UnaryPredicate)

还有更多……

两个 find() 函数都是顺序搜索,并在找到第一个匹配项时返回。如果你想找到更多匹配的元素,你可以使用 ranges 库中的 filter() 函数:

template<ranges::viewable_range R, class Pred>
constexpr ranges::view auto ranges::views::filter(R&&, Pred&&);

filter() 函数返回一个 视图,这是一个非破坏性的容器窗口,只包含过滤后的元素。然后我们可以像使用任何其他容器一样使用这个视图:

auto vw1 = std::ranges::views::filter(c,
    [](const City& c){ return c.pop > 20000000; });
for(const City& e : vw1) cout << format("{}\n", e.str());

输出:

[Tokyo, 37435191]
[Cairo, 20485965]

使用 std::clamp 限制容器中的值范围

随着 C++17 的引入,std::clamp() 函数可以用来限制数值标量的范围,使其在最小值和最大值之间。该函数尽可能优化使用 移动语义,以实现最大速度和效率。

如何做到这一点...

我们可以通过在循环中使用 clamp() 或使用 transform() 算法来通过它约束容器中的值。让我们看看一些例子。

  • 我们将从一个简单的函数开始,用于打印容器中的值:

    void printc(auto& c, string_view s = "") {
        if(s.size()) cout << format("{}: ", s);
        for(auto e : c) cout << format("{:>5} ", e);
        cout << '\n';
    }
    

注意到 格式字符串 "{:>5} "。这会将每个值右对齐到 5 个空格,以实现表格视图。

  • main() 函数中,我们将定义一个 初始化列表 以用于我们的容器。这允许我们多次使用相同的值:

    int main() {
        auto il = { 0, -12, 2001, 4, 5, -14, 100, 200, 
          30000 };
        ...
    }
    

这是一个很好的值范围,可以与 clamp() 一起使用。

  • 让我们也定义一些常数作为我们的限制:

    constexpr int ilow{0};
    constexpr int ihigh{500};
    

我们将在对 clamp() 的调用中使用这些值。

  • 现在,我们可以在 main() 函数中定义一个容器。我们将使用 intvector

    vector<int> voi{ il };
    cout << "vector voi before:\n";
    printc(voi);
    

使用我们的初始化列表中的值,输出如下:

vector voi before:
    0   -12  2001     4     5   -14   100   200 30000
  • 现在,我们可以使用带有 clamp()for 循环来限制值在 0 和 500 之间:

    cout << "vector voi after:\n";
    for(auto& e : voi) e = clamp(e, ilow, ihigh);
    printc(voi);
    

这个函数将 clamp() 应用到容器中的每个值,分别使用 0 和 500 作为低和高限制。现在,输出如下:

vector voi before:
    0   -12  2001     4     5   -14   100   200 30000
vector voi after:
    0     0   500     4     5     0   100   200   500

clamp() 操作之后,负值变为 0,大于 500 的值变为 500。

  • 我们可以使用 transform() 算法,在 lambda 中使用 clamp() 来做同样的事情。这次我们将使用一个 list 容器:

    cout << "list loi before:\n";
    list<int> loi{ il };
    printc(loi);
    transform(loi.begin(), loi.end(), loi.begin(), 
        ={ return clamp(e, ilow, ihigh); });
    cout << "list loi after:\n";
    printc(loi);
    

输出与带有 for 循环的版本相同:

list loi before:
    0   -12  2001     4     5   -14   100   200 30000
list loi after:
    0     0   500     4     5     0   100   200   500

它是如何工作的...

clamp() 算法是一个简单的函数,看起来像这样:

template<class T>
constexpr const T& clamp( const T& v, const T& lo,
        const T& hi ) {
    return less(v, lo) ? lo : less(hi, v) ? hi : v;
}

如果 v 的值小于 lo,则返回 lo。如果 hi 小于 v,则返回 hi。该函数快速且高效。

在我们的例子中,我们使用 for 循环将 clamp() 应用到容器中:

for(auto& v : voi) v = clamp(v, ilow, ihigh);

我们还使用 lambda 在 transform() 算法中与 clamp() 一起使用:

transform(loi.begin(), loi.end(), loi.begin(),
    ={ return clamp(v, ilow, ihigh); });

在我的实验中,两个版本都给出了相同的结果,并且都生成了类似 GCC 编译器的代码。编译大小(带有 for 循环的版本更小,正如预期的那样)和性能差异可以忽略不计。

通常,我更喜欢 for 循环,但 transform() 版本在其他应用中可能更灵活。

使用 std::sample 的样本数据集

std::sample() 算法从一系列值中随机抽取 样本,并将样本填充到目标容器中。这对于分析较大的数据集很有用,其中随机样本被认为是整个集合的代表。

样本集允许我们近似大量数据的特征,而无需分析整个集合。这以准确性为代价提供了效率,在很多情况下是一个公平的交易。

如何做到这一点...

在这个菜谱中,我们将使用一个包含 200,000 个随机整数的数组,具有 标准正态分布。我们将采样几百个值来创建每个值的频率直方图。

  • 我们将从返回一个从double舍入的int的简单函数开始。标准库缺少这样的函数,我们稍后需要它:

    int iround(const double& d) {
        return static_cast<int>(std::round(d));
    }
    

标准库提供了几个版本的std::round(),包括一个返回long int的版本。但我们需要一个int,这是一个简单的解决方案,它避免了编译器关于缩窄转换的警告,同时隐藏了难看的static_cast

  • main()函数中,我们将开始一些有用的常量:

    int main() {
        constexpr size_t n_data{ 200000 };
        constexpr size_t n_samples{ 500 };
        constexpr int mean{ 0 };
        constexpr size_t dev{ 3 };
        ...
    }
    

我们有n_datan_samples的值,分别用于数据容器和样本容器的尺寸。我们还有meandev的值,这是随机值正态分布的均值标准差参数。

  • 我们现在设置我们的随机数生成器分布对象。这些用于初始化源数据集:

    std::random_device rd;
    std::mt19937 rng(rd());
    std::normal_distribution<> dist{ mean, dev };
    

random_device对象提供了对硬件随机数生成器的访问。mt19937类是Mersenne Twister随机数算法的一个实现,这是一个在大多数系统上表现良好的高质量算法,适用于我们使用的数据集大小。normal_distribution类提供了一个围绕均值的随机数分布,并提供了标准差

  • 现在我们用一个n_data数量的随机int值填充一个数组:

    array<int, n_data> v{};
    for(auto& e : v) e = iround(dist(rng));
    

array容器的大小是固定的,所以模板参数包括一个size_t值,表示要分配的元素数量。我们使用for()循环来填充数组。

rng对象是硬件随机数生成器。这个对象被传递给我们的normal_distribution对象dist(),然后传递给我们的整数舍入函数iround()

  • 到目前为止,我们有一个包含 200,000 个数据点的数组。这有很多要分析,所以我们将使用sample()算法来抽取 500 个值的样本:

    array<int, n_samples> samples{};
    sample(data.begin(), data.end(), samples.begin(), 
        n_samples, rng);
    

我们定义另一个array对象来存储样本。这个数组的大小是n_samples。然后我们使用sample()算法用n_samples个随机数据点填充数组。

  • 我们创建一个直方图来分析样本。map结构非常适合这个用途,因为我们可以轻松地将每个值的频率映射出来:

    std::map<int, size_t> hist{};
    for (const int i : samples) ++hist[i];
    

for()循环从samples容器中取每个值,并将其用作map中的键。增量表达式++hist[i]计算样本集中每个值的出现次数。

  • 我们使用 C++20 的format()函数打印出直方图:

    constexpr size_t scale{ 3 };
    cout << format("{:>3} {:>5} {:<}/{}\n", 
        "n", "count", "graph", scale);
    for (const auto& [value, count] : hist) {
        cout << format("{:>3} ({:>3}) {}\n", 
            value, count, string(count / scale, '*'));
    }
    

类似于{:>3}format()指定符为一定数量的字符留出空间。尖括号指定了对齐方式,是右对齐还是左对齐。

string(count, char)构造函数创建一个string,其中包含重复指定次数的字符,在这种情况下,n个星号字符*,其中ncount/scale,即直方图中一个值的频率除以scale常量。

输出看起来像这样:

$ ./sample
  n count graph/3
 -9 (  2)
 -7 (  5) *
 -6 (  9) ***
 -5 ( 22) *******
 -4 ( 24) ********
 -3 ( 46) ***************
 -2 ( 54) ******************
 -1 ( 59) *******************
  0 ( 73) ************************
  1 ( 66) **********************
  2 ( 44) **************
  3 ( 34) ***********
  4 ( 26) ********
  5 ( 18) ******
  6 (  9) ***
  7 (  5) *
  8 (  3) *
  9 (  1)

这是一个很好的直方图图形表示。第一个数字是值,第二个数字是该值的频率,星号是频率的视觉表示,其中每个星号代表样本集中scale(3)次出现。

每次运行代码时,你的输出都会不同。

它是如何工作的…

std::sample()函数从源容器中的随机位置选择特定数量的元素,并将它们复制到目标容器中。

sample()的签名如下所示:

OutIter sample(SourceIter, SourceIter, OutIter, 
    SampleSize, RandNumGen&&);

前两个参数是容器中完整数据集的begin()end()迭代器。第三个参数是样本目的地的迭代器。第四个参数是样本大小,最后一个参数是随机数生成函数。

sample()算法使用均匀分布,因此每个数据点被抽样的机会相同。

生成数据序列的排列组合

排列组合有许多用途,包括测试、统计学、研究等。next_permutation()算法通过重新排列容器以生成下一个字典序排列组合。

如何做到这一点…

对于这个食谱,我们将打印出一组三个字符串的排列组合:

  • 我们首先创建一个用于打印容器内容的简短函数:

    void printc(const auto& c, string_view s = "") {
        if(s.size()) cout << format("{}: ", s);
        for(auto e : c) cout << format("{} ", e);
        cout << '\n';
    }
    

我们将使用这个简单的函数来打印我们的数据集和排列组合。

  • main()函数中,我们声明一个string对象的vector并使用sort()算法对其进行排序。

    int main() {
        vector<string> vs{ "dog", "cat", "velociraptor" };
        sort(vs.begin(), vs.end());
        ...
    }
    

next_permutation()函数需要一个有序容器。

  • 现在,我们可以使用next_permutation()do循环中列出排列组合:

    do {
        printc(vs);
    } while (next_permutation(vs.begin(), vs.end()));
    

next_permutation()函数会修改容器,如果还有另一个排列组合则返回true,如果没有则返回false

输出列出了我们三只宠物的六种排列组合:

cat dog velociraptor
cat velociraptor dog
dog cat velociraptor
dog velociraptor cat
velociraptor cat dog
velociraptor dog cat

它是如何工作的…

std::next_permutation()算法生成一组值的字典序排列组合,即基于字典顺序的排列组合。输入必须是有序的,因为算法按字典序遍历排列组合。所以,如果你从一个像 3, 2, 1 这样的集合开始,它将立即终止,因为这是这三个元素的最后一种字典序排列。

例如:

vector<string> vs{ "velociraptor", "dog", "cat" };
do {
    printc(vs);
} while (next_permutation(vs.begin(), vs.end()));

这给出了以下输出:

velociraptor dog cat

虽然术语字典序暗示了字母顺序,但实现使用标准比较运算符,因此它适用于任何可排序的值。

同样,如果集合中的值重复,它们只按字典序计数。这里有一个包含两个重复序列的五个值的vector

vector<int> vi{ 1, 2, 3, 4, 5, 1, 2, 3, 4, 5 };
sort(vi.begin(), vi.end());
printc(vi, "vi sorted");
long count{};
do {
    ++count;
} while (next_permutation(vi.begin(), vi.end()));
cout << format("number of permutations: {}\n", count);

输出:

Vi sorted: 1 1 2 2 3 3 4 4 5 5
number of permutations: 113400

这些值的排列组合共有 113,400 种。请注意,这不是10!(3,628,800),因为有些值是重复的。由于3,33,3排序相同,它们不是不同的字典序排列组合。

换句话说,如果我列出这个短集合的排列组合:

vector<int> vi2{ 1, 3, 1 };
sort(vi2.begin(), vi2.end());
do {
    printc(vi2);
} while (next_permutation(vi2.begin(), vi2.end()));

由于有重复的值,我们只得到三个排列,而不是 3!(9):

1 1 3
1 3 1
3 1 1

合并排序容器

std::merge() 算法接受两个排序序列,并创建一个第三合并和排序的序列。这种技术通常作为 归并排序 的一部分,允许将大量数据分解成块,分别排序,然后合并到一个排序的目标中。

如何做到这一点...

对于这个菜谱,我们将取两个排序的 vector 容器,并使用 std::merge() 将它们合并到第三个 vector 中。

  • 我们将从打印容器内容的一个简单函数开始:

    void printc(const auto& c, string_view s = "") {
        if(s.size()) cout << format("{}: ", s);
        for(auto e : c) cout << format("{} ", e);
        cout << '\n';
    }
    

我们将使用这个结果来打印源和目标序列。

  • main() 函数中,我们将声明我们的源向量,以及目标向量,并将它们打印出来:

    int main() {
        vector<string> vs1{ "dog", "cat", 
          "velociraptor" };
        vector<string> vs2{ "kirk", "sulu", "spock" };
        vector<string> dest{};
        printc(vs1, "vs1");
        printc(vs2, "vs2");
        ...
    }
    

输出结果如下:

vs1: dog cat velociraptor
vs2: kirk sulu spock
  • 现在我们可以对向量进行排序并再次打印它们:

    sort(vs1.begin(), vs1.end());
    sort(vs2.begin(), vs2.end());
    printc(vs1, "vs1 sorted");
    printc(vs2, "vs2 sorted");
    

输出结果:

vs1 sorted: cat dog velociraptor
vs2 sorted: kirk spock sulu
  • 现在我们已经对源容器进行了排序,我们可以将它们合并以得到最终的合并结果:

    merge(vs1.begin(), vs1.end(), vs2.begin(), vs2.end(), 
        back_inserter(dest));
    printc(dest, "dest");
    

输出结果:

dest: cat dog kirk spock sulu velociraptor

这个输出表示将两个源合并到一个排序向量中。

它是如何工作的...

merge() 算法从两个源中获取 begin()end() 迭代器,并为目标提供一个输出迭代器:

OutputIt merge(InputIt1, InputIt1, InputIt2, InputIt2, OutputIt)

它接受两个输入范围,执行其合并/排序操作,并将结果序列发送到输出迭代器。

第七章:第七章:字符串、流和格式化

STL 的 string 类是存储、操作和显示基于字符数据的一个强大、功能齐全的工具。它具有您在高级脚本语言中会发现的大部分便利性,同时仍然像您期望的那样快速敏捷。

string 类基于 basic_string,这是一个连续容器类,可以用任何字符类型实例化。其类签名如下:

template<
    typename CharT,
    typename Traits = std::char_traits<CharT>,
    typename Allocator = std::allocator<CharT>
> class basic_string;

TraitsAllocator 模板参数通常保留为默认值。

basic_string 的底层存储是一个连续的 CharT 序列,可以通过 data() 成员函数访问:

const std::basic_string<char> s{"hello"};
const char * sdata = s.data();
for(size_t i{0}; i < s.size(); ++i) {
    cout << sdata[i] << ' ';
}
cout << '\n';

输出:

h e l l o

data() 成员函数返回一个指向字符底层数组的 CharT*。自 C++11 以来,data() 返回的数组是空终止的,这使得 data() 等同于 c_str()

basic_string 类包含了许多在其他连续存储类中可以找到的方法,包括 insert()erase()push_back()pop_back() 以及其他方法。这些方法在 CharT 的底层数组上操作。

std::stringstd::basic_string<char> 的类型别名:

using std::string = std::basic_string<char>;

对于大多数用途,您将使用 std::string

字符串格式化

字符串格式化一直是 STL 的弱点。直到最近,我们只能在不完美的选择之间做出选择,要么是笨拙的 STL iostreams,要么是过时的遗产 printf()。从 C++20 和 format 库开始,STL 字符串格式化终于成熟起来。新的 format 库紧密基于 Python 的 str.format() 方法,快速灵活,提供了 iostreamsprintf() 的许多优点,以及良好的内存管理和类型安全。

更多关于 format 库的信息,请参阅 第一章 中的 使用新的格式化库格式化文本 菜谱,新 C++20 功能

虽然我们不再需要使用 iostreams 进行字符串格式化,但它仍然在其他用途中非常有用,包括文件和流 I/O 以及一些类型转换。

在本章中,我们将涵盖以下主题以及更多内容:

  • string_view 用作轻量级字符串对象

  • 连接字符串

  • 转换字符串

  • 使用 C++20 的 format 库格式化文本

  • 从字符串中删除空白字符

  • 从用户输入读取字符串

  • 在文件中计算单词数

  • 从文件输入初始化复杂结构

  • 使用 char_traits 自定义字符串类

  • 使用正则表达式解析字符串

技术要求

您可以在 GitHub 上找到本章的代码文件,地址为 github.com/PacktPublishing/CPP-20-STL-Cookbook/tree/main/chap07

string_view 用作轻量级字符串对象

string_view 类为 string 类提供了一个轻量级的替代方案。它不是维护自己的数据存储,而是对 C 字符串的 视图 进行操作。这使得 string_view 比起 std::string 更小、更高效。在需要字符串对象但不需要 std::string 的更多内存和计算密集型功能的情况下,它非常有用。

如何做到这一点…

string_view 类看起来与 STL 的 string 类非常相似,但工作方式略有不同。让我们考虑一些例子:

  • 这里是一个从 C 字符串(char 数组)初始化的 STL string

    char text[]{ "hello" };
    string greeting{ text };
    text[0] = 'J';
    cout << text << ' ' << greeting << '\n';
    

输出:

Jello hello

注意,当我们修改数组时,string 并没有改变。这是因为 string 构造函数创建了底层数据的副本。

  • 当我们用 string_view 做同样的事情时,我们得到不同的结果:

    char text[]{ "hello" };
    string_view greeting{ text };
    text[0] = 'J';
    cout << text << ' ' << greeting << '\n';
    

输出:

Jello Jello

string_view 构造函数创建底层数据的 视图。它不会创建自己的副本。这导致显著的效率,但也允许副作用。

  • 由于 string_view 不会复制底层数据,源数据必须在 string_view 对象持续的时间内保持作用域。因此,这行不通:

    string_view sv() {
        const char text[]{ "hello" };  // temporary storage
        string_view greeting{ text };
        return greeting;
    }
    int main() {
        string_view greeting = sv();  // data out of scope
        cout << greeting << '\n';  // output undefined
    }
    

由于底层数据在 sv() 函数返回后超出作用域,所以在使用它的时候,main() 中的 greeting 对象就不再有效了。

  • string_view 类具有适合底层数据的构造函数。这包括字符数组(const char*)、连续 范围(包括 std::string)和其他 string_view 对象。此示例使用 范围 构造函数:

    string str{ "hello" };
    string_view greeting{ str };
    cout << greeting << '\n';
    

输出:

hello
  • 此外,还有一个 string_view 文字操作符 sv,它在 std::literals 命名空间中定义:

    using namespace std::literals;
    cout << "hello"sv.substr(1, 4) << '\n';
    

这构建了一个 constexpr string_view 对象,并调用其方法 substr() 来获取从索引 1 开始的 4 个值。

输出:

ello

它是如何工作的…

string_view 类实际上是一个连续字符序列的 迭代器适配器。其实现通常有两个成员:一个 const CharT * 和一个 size_t。它通过在源数据周围包装一个 contiguous_iterator 来工作。

这意味着你可以像 std::string 一样用于许多目的,但有一些重要的区别:

  • 复制构造函数不会复制数据。这意味着当你复制一个 string_view 时,每个副本都操作相同的底层数据:

    char text[]{ "hello" };
    string_view sv1{ text };
    string_view sv2{ sv1 };
    string_view sv3{ sv2 };
    string_view sv4{ sv3 };
    cout << format("{} {} {} {}\n", sv1, sv2, sv3, sv4);
    text[0] = 'J';
    cout << format("{} {} {} {}\n", sv1, sv2, sv3, sv4);
    

输出:

hello hello hello hello
Jello Jello Jello Jello
  • 请记住,当你将一个 string_view 传递给一个函数时,它使用复制构造函数:

    void f(string_view sv) {
        if(sv.size()) {
            char* x = (char*)sv.data();  // dangerous
            x[0] = 'J';  // modifies the source
        }
        cout << format("f(sv): {} {}\n", (void*)sv.data(),      sv);
    }
    int main() {
        char text[]{ "hello" };
        string_view sv1{ text };
        cout << format("sv1: {} {}\n", (void*)sv1.data(),       sv1);
        f(sv1);
        cout << format("sv1: {} {}\n", (void*)sv1.data(),       sv1);
    }
    

输出:

sv1: 0x7ffd80fa7b2a hello
f(sv): 0x7ffd80fa7b2a Jello
sv1: 0x7ffd80fa7b2a Jello

注意,底层数据的地址(由 data() 成员函数返回)对于所有 string_view 实例都是相同的。这是因为复制构造函数不会复制底层数据。尽管 string_view 成员指针是 const-修饰的,但仍然可以取消 const 修饰符,尽管这 不推荐 因为它可能会引起意外的副作用。但值得注意的是,数据永远不会被复制。

  • string_view类缺少直接操作底层字符串的方法。例如append()operator+()push_back()pop_back()replace()resize()等,这些在string中支持的方法在string_view中不支持。

如果你需要使用+运算符连接字符串,你需要一个std::string。例如,这不能与string_view一起使用:

sv1 = sv2 + sv3 + sv4; // does not work

你需要使用string

string str1{ text };
string str2{ str1 };
string str3{ str2 };
string str4{ str3 };
str1 = str2 + str3 + str4; // works
cout << str1 << '\n';

输出:

JelloJelloJello

连接字符串

在 C++中连接字符串有几种方法。在这个菜谱中,我们将查看三种最常见的方法:string类的operator+()运算符、string类的append()函数和ostringstream类的operator<<()运算符。C++20 新引入的format()函数。每个都有其优点、缺点和使用场景。

如何做到这一点...

在这个菜谱中,我们将检查连接字符串的方法。然后我们将进行一些基准测试并考虑不同的使用场景。

  • 我们将从一个std::string对象开始:

    string a{ "a" };
    string b{ "b" };
    

string对象是由字面量 C 字符串构造的。

C 字符串构造函数复制字面量字符串,并使用本地副本作为string对象的底层数据。

  • 现在,让我们构造一个新的空字符串对象,并使用分隔符和换行符将ab连接起来:

    string x{};
    x += a + ", " + b + "\n";
    cout << x;
    

在这里,我们使用了string对象的+=+运算符来连接ab字符串,以及字面量字符串", ""\n"。结果字符串将元素连接在一起:

a, b
  • 我们可以使用string对象的append()成员函数:

    string x{};
    x.append(a);
    x.append(", ");
    x.append(b);
    x.append("\n");
    cout << x;
    

这给我们带来了相同的结果:

a, b
  • 或者,我们可以构造一个ostringstream对象,它使用流接口:

    ostringstream x{};
    x << a << ", " << b << "\n";
    cout << x.str();
    

我们得到相同的结果:

a, b
  • 我们还可以使用 C++20 的format()函数:

    string x{};
    x = format("{}, {}\n", a, b);
    cout << x;
    

再次,我们得到相同的结果:

a, b

它是如何工作的...

string对象有两种不同的方法来连接字符串,即+运算符和append()成员函数。

append()成员函数将数据添加到string对象数据的末尾。它必须分配和管理内存以完成此操作。

+运算符使用operator+()重载来构造一个新的string对象,该对象包含旧数据和新的数据,并返回新对象。

ostringstream对象的工作方式类似于ostream,但存储其输出以用作字符串。

C++20 的format()函数使用格式字符串和可变参数,并返回一个新构造的string对象。

还有更多...

你如何决定哪种连接策略适合你的代码?我们可以从一些基准测试开始。

基准测试

我使用 GCC 11 在 Debian Linux 上执行了这些测试:

  • 首先,我们将使用<chrono>库创建一个timer函数:

    using std::chrono::high_resolution_clock;
    using std::chrono::duration;
    void timer(string(*f)()) {
        auto t1 = high_resolution_clock::now();
        string s{ f() };
        auto t2 = high_resolution_clock::now();
        duration<double, std::milli> ms = t2 - t1;
        cout << s;
        cout << format("duration: {} ms\n", ms.count());
    }
    

timer函数调用传递给它的函数,标记函数调用前后的时间。然后它使用cout显示持续时间。

  • 现在,我们创建一个使用append()成员函数连接字符串的函数:

    string append_string() {
        cout << "append_string\n";
        string a{ "a" };
        string b{ "b" };
        long n{0};
        while(++n) {
            string x{};
            x.append(a);
            x.append(", ");
            x.append(b);
            x.append("\n");
            if(n >= 10000000) return x;
        }
        return "error\n";
    }
    

为了基准测试的目的,这个函数重复进行了 1000 万次的连接操作。我们从main()函数中调用这个函数并使用timer()

int main() {
    timer(append_string);
}

我们得到以下输出:

append_string
a, b
duration: 425.361643 ms

因此,在这个系统上,我们的连接操作进行了 1000 万次迭代,大约耗时 425 毫秒。

  • 现在,让我们用+运算符重载创建相同的函数:

    string concat_string() {
        cout << "concat_string\n";
        string a{ "a" };
        string b{ "b" };
        long n{0};
        while(++n) {
            string x{};
            x += a + ", " + b + "\n";
            if(n >= 10000000) return x;
        }
        return "error\n";
    }
    

我们的基准输出:

concat_string
a, b
duration: 659.957702 ms

这个版本进行了 1000 万次迭代,大约耗时 660 毫秒。

  • 现在,让我们用ostringstream来试一试:

    string concat_ostringstream() {
        cout << "ostringstream\n";
        string a { "a" };
        string b { "b" };
        long n{0};
        while(++n) {
            ostringstream x{};
            x << a << ", " << b << "\n";
            if(n >= 10000000) return x.str();
        }
        return "error\n";
    }
    

我们的基准输出:

ostringstream
a, b
duration: 3462.020587 ms

这个版本进行了 1000 万次迭代,大约耗时 3.5 秒。

  • 这里是format()版本的示例:

    string concat_format() {
        cout << "append_format\n";
        string a{ "a" };
        string b{ "b" };
        long n{0};
        while(++n) {
            string x{};
            x = format("{}, {}\n", a, b);
            if(n >= 10000000) return x;
        }
        return "error\n";
    }
    

我们的基准输出:

append_format
a, b
duration: 782.800547 ms

format()版本进行了 1000 万次迭代,大约耗时 783 毫秒。

  • 结果总结:

连接性能比较

连接性能比较

性能差异的原因是什么?

从这些基准测试中我们可以看出,ostringstream版本比基于string的版本慢很多倍。

append()方法比+运算符略快。它需要分配内存但不构造新对象。由于重复,可能存在一些优化。

+运算符重载可能调用append()方法。额外的函数调用可能会使其比append()方法逐渐慢。

format()版本创建了一个新的string对象,但没有iostream系统的开销。

ostringstream<<运算符重载为每个操作创建一个新的ostream对象。考虑到流对象的复杂性以及管理流状态,这使得它比基于string的任何版本都要慢得多。

为什么我会选择其中一个而不是另一个?

个人的偏好将涉及一些度量。运算符重载(+<<)可能是方便的。性能可能对你来说是一个问题,也可能不是。

ostringstream类相对于string方法有一个独特的优势:它为每种不同类型专门化了<<运算符,因此能够在可能存在不同类型调用相同代码的情况下操作。

format()函数提供了相同类型安全和定制选项,并且比ostringstream类快得多。

string对象的+运算符重载速度快,使用方便,易于阅读,但比append()方法逐渐慢。

append()版本最快,但需要为每个项目调用一个单独的函数。

对于我的目的,我更喜欢format()函数或string对象的+运算符,在大多数情况下。如果每个速度的比特都很重要,我会使用append()。如果需要ostringstream的独特功能和性能不是问题,我会使用它。

转换字符串

std::string类是一个连续容器,类似于vectorarray。它支持contiguous_iterator概念和所有相应的算法。

string类是basic_string的一个特化,其类型为char。这意味着容器的元素是char类型。其他特化也可用,但string是最常见的。

因为它本质上是一个连续的char元素容器,所以string可以使用transform()算法,或者任何使用contiguous_iterator概念的技巧。

如何做到这一点…

根据应用的不同,有多种方式进行转换。本食谱将探讨其中的一些。

  • 我们将从几个谓词函数开始。谓词函数接受一个转换元素并返回一个相关元素。例如,这里有一个简单的谓词,它返回一个大写字母:

    char char_upper(const char& c) {
        return static_cast<char>(std::toupper(c));
    }
    

这个函数是std::toupper()的包装器。因为toupper()函数返回一个int,而string元素是char类型,所以我们不能直接在转换中使用toupper()函数。

这里是相应的char_lower()函数:

char char_lower(const char& c) {
    return static_cast<char>(std::tolower(c));
}
  • rot13()函数是一个用于演示目的的有趣转换谓词。它是一个简单的替换密码,不适用于加密,但常用于混淆

    char rot13(const char& x) {
        auto rot13a = [](char x, char a)->char { 
            return a + (x - a + 13) % 26; 
        };
        if (x >= 'A' && x <= 'Z') return rot13a(x, 'A');
        if (x >= 'a' && x <= 'z') return rot13a(x, 'a');
        return x;
    }
    
  • 我们可以使用这些谓词与transform()算法一起使用:

    main() {
        string s{ "hello jimi\n" };
        cout << s;
        std::transform(s.begin(), s.end(), s.begin(), 
          char_upper);
        cout << s;
        ...
    

transform()函数对s的每个元素调用char_upper(),将结果放回s中,并将所有字符转换为大写:

输出:

hello jimi
HELLO JIMI
  • 除了transform(),我们还可以使用一个简单的带有谓词函数的for循环:

    for(auto& c : s) c = rot13(c);
    cout << s;
    

从我们的大写字符串对象开始,结果是:

URYYB WVZV
  • rot13密码的有趣之处在于它可以自己解密。因为 ASCII 字母表中有 26 个字母,旋转 13 次然后再旋转 13 次会得到原始字符串。让我们将字符串转换为小写并再次进行rot13转换以恢复我们的字符串:

    for(auto& c : s) c = rot13(char_lower(c));
    cout << s;
    

输出:

hello jimi

由于它们的接口统一,谓词函数可以作为彼此的参数进行链式调用。我们也可以使用char_lower(rot13(c))得到相同的结果。

  • 如果你的需求对于简单的字符转换过于复杂,你可以像使用任何连续容器一样使用string迭代器。以下是一个简单的函数,它通过将第一个字符和每个跟在空格后面的字符大写,将小写字符串转换为标题大小写

    string& title_case(string& s) {
        auto begin = s.begin();
        auto end = s.end();
        *begin++ = char_upper(*begin);  // first element
        bool space_flag{ false };
        for(auto it{ begin }; it != end; ++it) {
            if(*it == ' ') {
                space_flag = true;
            } else {
                if(space_flag) *it = char_upper(*it);
                space_flag = false;
            }
        }
        return s;
    }
    

因为它返回转换后字符串的引用,我们可以像这样用cout调用它:

cout << title_case(s);

输出:

Hello Jimi

它是如何工作的…

std::basic_string类及其特化(包括string),都由完全符合contiguous_iterator的迭代器支持。这意味着任何适用于任何连续容器的技巧也适用于string

注意

这些转换不会与string_view对象一起工作,因为底层数据是const修饰的。

使用 C++20 的格式库格式化文本

C++20 引入了新的 format() 函数,该函数返回其参数的格式化字符串表示。format() 使用 Python 风格的格式化字符串,具有简洁的语法、类型安全和优秀的性能。

format() 函数接受一个格式字符串和一个模板,即 参数包 作为其参数:

template< class... Args >
string format(const string_view fmt, Args&&... args );

格式字符串使用花括号 {} 作为格式化参数的占位符:

const int a{47};
format("a is {}\n", a);

输出:

a is 47

它也使用花括号作为格式说明符,例如:

format("Hex: {:x} Octal: {:o} Decimal {:d} \n", a, a, a);

输出:

Hex: 2f Octal: 57 Decimal 47

这个配方将向您展示如何使用 format() 函数来实现一些常见的字符串格式化解决方案。

注意

这章是在 Windows 10 上使用 Microsoft Visual C++ 编译器的预览版开发的。在撰写本文时,这是唯一完全支持 C++20 <format> 库的编译器。最终实现可能在某些细节上有所不同。

如何做到这一点…

让我们考虑一些使用 format() 函数的常见格式化解决方案:

  • 我们将从一些需要格式化的变量开始:

    const int inta{ 47 };
    const char * human{ "earthlings" };
    const string_view alien{ "vulcans" };
    const double df_pi{ pi };
    

pi 常量在 <numbers> 头文件和 std::numbers 命名空间中。

  • 我们可以使用 cout 来显示变量:

    cout << "inta is " << inta << '\n'
        << "hello, " << human << '\n'
        << "All " << alien << " are welcome here\n"
        << "π is " << df_pi << '\n';
    

我们得到以下输出:

a is 47
hello, earthlings
All vulcans are welcome here
π is 3.14159
  • 现在,让我们用 format() 来查看这些内容,从 C-string 的 human 开始:

    cout << format("Hello {}\n", human);
    

这是 format() 函数的最简单形式。格式字符串有一个占位符 {} 和一个相应的变量 human。输出如下:

Hello earthlings
  • format() 函数返回一个字符串,我们使用 cout << 来显示这个字符串。

format() 库的原版提案包括一个 print() 函数,它使用与 format() 相同的参数。这将允许我们一步打印我们的格式化字符串:

print("Hello {}\n", cstr);

不幸的是,print() 没有被纳入 C++20 标准,尽管它预计将在 C++23 中被包含。

我们可以使用一个简单的函数,通过 vformat() 来提供相同的功能:

template<typename... Args>
constexpr void print(const string_view str_fmt, 
                     Args&&... args) {
    fputs(std::vformat(str_fmt, 
          std::make_format_args(args...)).c_str(), 
          stdout);
}

这个简单的单行函数为我们提供了一个可用的 print() 函数。我们可以用它来代替 cout << format() 组合:

print("Hello {}\n", human);

输出:

Hello earthlings

在示例文件的 include 目录中可以找到这个函数的更完整版本。

  • 格式字符串还提供了位置选项:

    print("Hello {} we are {}\n", human, alien);
    

输出:

Hello earthlings we are vulcans

我们可以通过在格式字符串中使用位置选项来改变参数的顺序:

print("Hello {1} we are {0}\n", human, alien);

现在,我们得到以下输出:

Hello vulcans we are earthlings

注意,参数保持不变。只有花括号中的位置值发生了变化。位置索引是从零开始的,就像 [] 操作符一样。

这个特性对于国际化很有用,因为不同的语言在句子中不同词性的顺序不同。

  • 数字有许多格式化选项:

    print("π is {}\n", df_pi);
    

输出:

π is 3.141592653589793

我们可以指定精度的位数:

print("π is {:.5}\n", df_pi);

输出:

π is 3.1416

冒号字符 : 用于分隔位置索引和格式化参数:

print("inta is {1:}, π is {0:.5}\n", df_pi, inta);

输出:

inta is 47, π is 3.1416
  • 如果我们想让一个值占据一定数量的空间,我们可以指定字符数,如下所示:

    print("inta is [{:10}]\n", inta);
    

输出:

inta is [        47]

我们可以将其左对齐或右对齐:

print("inta is [{:<10}]\n", inta);
print("inta is [{:>10}]\n", inta);

输出:

inta is [47        ]
inta is [        47]

默认情况下,它用空格字符填充,但我们可以更改它:

print("inta is [{:*<10}]\n", inta);
print("inta is [{:0>10}]\n", inta);

输出:

inta is [47********]
inta is [0000000047]

我们还可以居中一个值:

print("inta is [{:¹⁰}]\n", inta);
print("inta is [{:_¹⁰}]\n", inta);

输出:

inta is [    47    ]
inta is [____47____]
  • 我们可以将整数格式化为十六进制、八进制或默认的十进制表示:

    print("{:>8}: [{:04x}]\n", "Hex", inta);
    print("{:>8}: [{:4o}]\n", "Octal", inta);
    print("{:>8}: [{:4d}]\n", "Decimal", inta);
    

输出:

     Hex: [002f]
   Octal: [  57]
 Decimal: [  47]

注意,我使用了右对齐来对齐标签。

使用大写 X 表示大写十六进制:

print("{:>8}: [{:04X}]\n", "Hex", inta);

输出:

     Hex: [002F]

小贴士

默认情况下,Windows 使用不常见的字符编码。最新版本可能默认为 UTF-16 或 UTF-8 BOM。较旧版本可能默认为 "代码页" 1252,它是 ISO 8859-1 ASCII 标准的超集。没有 Windows 系统默认使用更常见的 UTF-8(无 BOM)。

默认情况下,Windows 不会显示标准的 UTF-8 π 字符。为了使 Windows 与 UTF-8 编码(以及世界上的其他部分)兼容,使用编译器开关 /utf-8 并在命令行上执行 chcp 65001 命令进行测试。现在,你可以拥有你的 π 并享用它。

它是如何工作的…

<format> 库使用模板 参数包 将参数传递给格式化器。这允许单独检查参数的类和类型。库函数 make_format_args() 接收一个参数包并返回一个 format_args 对象,该对象提供了一个要格式化的 类型擦除 参数列表。

我们可以在 print() 函数中看到这一点:

template<typename... Args>
constexpr void print(const string_view str_fmt, Args&&... args) {
    fputs(vformat(str_fmt, 
      make_format_args(args...)).c_str(), 
          stdout);
}

make_format_args() 函数接收一个参数包并返回一个 format_args 对象。vformat() 函数接收一个格式字符串和 format_args 对象,并返回一个 std::string。我们使用 c_str() 方法获取用于 fputs() 的 C 字符串。

还有更多…

对于自定义类,通常的做法是重载 ostream<< 操作符。例如,给定一个包含分数值的 Frac 类:

template<typename T>
struct Frac {
    T n;
    T d;
};
...
Frac<long> n{ 3, 5 };
cout << "Frac: " << n << '\n';

我们希望将对象打印成分数形式,例如 3/5。因此,我们会编写一个简单的 operator<< 特化如下:

template <typename T>
std::ostream& operator<<(std::ostream& os, const Frac<T>& f) {
    os << f.n << '/' << f.d;
    return os;
}

现在的输出是:

Frac: 3/5

为了为我们自定义的类提供 format() 支持,我们需要创建一个 formatter 对象特化,如下所示:

template <typename T>
struct std::formatter<Frac<T>> : std::formatter<unsigned> {
    template <typename Context>
    auto format(const Frac<T>& f, Context& ctx) const {
        return format_to(ctx.out(), "{}/{}", f.n, f.d);
    }
};

std::formatter 类的特化重载了其 format() 方法。为了简单起见,我们继承自 formatter<unsigned> 特化。format() 方法使用一个 Context 对象调用,该对象提供了格式化字符串的输出上下文。对于返回值,我们使用 format_to() 函数与 ctx.out、一个普通格式字符串和参数。

现在,我们可以使用 print() 函数和 Frac 类:

print("Frac: {}\n", n);

格式化器现在识别我们的类并提供了我们期望的输出:

Frac: 3/5

从字符串中修剪空白

用户输入通常会在字符串的一端或两端包含多余的空白。这可能会引起问题,因此我们通常需要删除它。在这个菜谱中,我们将使用 string 类的 find_first_not_of()find_last_not_of() 方法来修剪字符串的端部空白。

如何做到这一点…

string 类包含用于查找是否包含在字符列表中的元素的方法。我们将使用这些方法来修剪 string

  • 我们首先使用来自一个假设的多指用户的输入来定义 string

    int main() {
        string s{" \t  ten-thumbed input   \t   \n \t "};
        cout << format("[{}]\n", s);
        ...
    

我们的内容前后有一些额外的制表符 \t 和换行符 \n 字符。我们用括号包围它来显示空白:

[       ten-thumbed input
      ]
  • 这里有一个 trimstr() 函数,用于从 string 的两端删除所有空白字符:

    string trimstr(const string& s) {
        constexpr const char * whitespace{ " \t\r\n\v\f" };
        if(s.empty()) return s;
        const auto first{ s.find_first_not_of(whitespace) };
        if(first == string::npos) return {};
        const auto last{ s.find_last_not_of(whitespace) };
        return s.substr(first, (last - first + 1));
    }
    

我们定义了我们的一组空白字符为 空格制表符回车换行符垂直制表符换页符。其中一些比其他更常见,但这是规范集合。

此函数使用 string 类的 find_first_not_of()find_last_not_of() 方法来查找第一个/最后一个不是集合成员的元素。

  • 现在,我们可以调用该函数来去除所有那些不请自来的空白字符:

    cout << format("[{}]\n", trimstr(s));
    

输出:

[ten-thumbed input]

它是如何工作的…

string 类的各个 find...() 成员函数返回一个 size_t 类型的位置:

size_t find_first_not_of( const CharT* s, size_type pos = 0 );
size_t find_last_not_of( const CharT* s, size_type pos = 0 );

返回值是第一个匹配字符(不在 s 字符列表中)的零基于位置,或者如果没有找到,则返回特殊值,string::nposnpos 是一个静态成员常量,表示一个无效的位置。

我们测试 (first == string::npos),如果没有匹配,则返回空字符串 {}。否则,我们使用 firstlast 位置与 s.substr() 方法一起返回没有空白的字符串。

从用户输入读取字符串

STL 使用 std::cin 对象从标准输入流提供基于字符的输入。cin 对象是一个全局 单例,它将输入从控制台作为 istream 输入流读取。

默认情况下,cin 一次读取 一个单词,直到达到流的末尾:

string word{};
cout << "Enter words: ";
while(cin >> word) {
    cout << format("[{}] ", word);
}
cout << '\n';

输出:

$ ./working
Enter words: big light in sky
[big] [light] [in] [sky]

这有限的使用价值,并且可能会导致一些人将 cin 视为功能最小化。

虽然 cin 确实有其怪癖,但它可以轻松地被整理成提供面向行的输入。

如何做到这一点…

要从 cin 获取基本的面向行的功能,需要了解两个重要的行为。一个是能够一次获取一行,而不是一次一个单词。另一个是在错误条件下重置流的能力。让我们详细看看这些:

  • 首先,我们需要提示用户输入。这里有一个简单的 prompt 函数:

    bool prompt(const string_view s, const string_view s2 = "") {
        if(s2.size()) cout << format("{} ({}): ", s, s2);
        else cout << format("{}: ", s);
        cout.flush();
        return true;
    }
    

cout.flush() 函数调用确保输出立即显示。有时,当输出不包含换行符时,输出流可能不会自动刷新。

  • cin 类有一个 getline() 方法,可以从输入流获取一行文本并将其放入 C-string 数组中:

    constexpr size_t MAXLINE{1024 * 10};
    char s[MAXLINE]{};
    const char * p1{ "Words here" };
    prompt(p1);
    cin.getline(s, MAXLINE, '\n');
    cout << s << '\n';
    

输出:

Words here: big light in sky![](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp20-stl-cb/img/1.png)
big light in sky

cin.getline() 方法接受三个参数:

getline(char* s, size_t count, char delim );

第一个参数是目标 C-string 数组。第二个是数组的大小。第三个是行结束的分隔符。

函数将不会在数组中放置超过 count-1 个字符,为 空字符 终止符留出空间。

分隔符默认为换行符 '\n'

  • STL 还提供了一个独立的 getline() 函数,它可以与 STL string 对象一起使用:

    string line{};
    const char * p1a{ "More words here" };
    prompt(p1a, "p1a");
    getline(cin, line, '\n');
    cout << line << '\n';
    

输出:

$ ./working
More words here (p1a): slated to appear in east![](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp20-stl-cb/img/1.png)
slated to appear in east

独立的 std::getline() 函数接受三个参数:

getline(basic_istream&& in, string& str, char delim );

第一个参数是输出流,第二个参数是 string 对象的引用,第三个是行结束符。

如果未指定,分隔符默认为换行符 '\n'

我发现独立的 getline() 比使用 cin.getline() 方法更方便。

  • 我们可以使用 cin 从输入流中获取特定类型。为了做到这一点,我们必须能够处理错误条件。

cin 遇到错误时,它会将流设置为错误条件并停止接受输入。为了在错误后重试输入,我们必须重置流的状态。这里有一个在错误后重置输入流的函数:

void clearistream() {
    string s{};
    cin.clear();
    getline(cin, s);
}

cin.clear() 函数重置输入流的错误标志,但留下缓冲区中的文本。然后我们通过读取一行并丢弃它来清除缓冲区。

  • 我们可以通过使用 cin 和数值类型变量来接受数值输入:

    double a{};
    double b{};
    const char * p2{ "Please enter two numbers" };
    for(prompt(p2); !(cin >> a >> b); prompt(p2)) {
        cout << "not numeric\n";
        clearistream();
    }
    cout << format("You entered {} and {}\n", a, b);
    

输出:

$ ./working
Please enter two numbers: a b![](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp20-stl-cb/img/1.png)
not numeric
Please enter two numbers: 47 73![](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp20-stl-cb/img/1.png)
You entered 47 and 73

cin >> a >> b 表达式从控制台接受输入,并尝试将前两个单词转换为与 ab (double) 兼容的类型。如果失败,我们调用 clearistream() 并再次尝试。

  • 我们可以使用 getline() 的分隔符参数来获取逗号分隔的输入:

    line.clear();
    prompt(p3);
    while(line.empty()) getline(cin, line);
    stringstream ss(line);
    while(getline(ss, word, ',')) {
        if(word.empty()) continue;
        cout << format("word: [{}]\n", trimstr(word));
    }
    

输出:

$ ./working
Comma-separated words: this, that, other
word: [this]
word: [that]
word: [other]

因为这段代码在数字代码之后运行,并且因为 cin 输入混乱,缓冲区中可能仍然存在一个行结束符。while(line.empty()) 循环将可选地吃掉任何空行。

我们使用 stringstream 对象来处理单词,因此我们不必使用 cin 来做。这允许我们使用 getline() 获取一行,而无需等待文件结束状态。

然后,我们在 stringstream 对象上调用 getline() 来解析出由逗号分隔的单词。这给我们单词,但带有前导空白。我们使用本章中 从字符串中删除空白 食谱中的 trimstr() 函数来删除空白。

它是如何工作的…

std::cin 对象比它看起来更有用,但使用它可能是一个挑战。它倾向于在流中留下行结束符,并且在错误的情况下,它可能会忽略输入。

解决方案是使用 getline(),并在必要时将行放入 stringstream 中以便方便解析。

在文件中计数单词

默认情况下,basic_istream 类一次读取一个单词。我们可以利用这个特性来使用 istream_iterator 来计数单词。

如何做到这一点…

这是一个简单的使用 istream_iterator 来计数单词的食谱:

  • 我们将从使用 istream_iterator 对象来计数单词的简单函数开始:

    size_t wordcount(auto& is) {
        using it_t = istream_iterator<string>;
        return distance(it_t{is}, it_t{});
    }
    

distance() 函数接受两个迭代器并返回它们之间的步骤数。using 语句为具有 string 特化的 istream_iterator 类创建了一个别名 it_t。然后我们使用一个初始化为输入流 it_t{is} 的迭代器和另一个使用默认构造函数的迭代器调用 distance(),后者给出了流结束的哨兵。

  • 我们在 main() 函数中调用 wordcount()

    int main() {
        const char * fn{ "the-raven.txt" };
        std::ifstream infile{fn, std::ios_base::in};
        size_t wc{ wordcount(infile) };
        cout << format("There are {} words in the 
          file.\n", wc);
    }
    

这调用 wordcount() 并打印文件中的单词数。当我用埃德加·爱伦·坡的 The Raven 的文本调用它时,我们得到以下输出:

There are 1068 words in the file.

它是如何工作的…

由于 basic_istream 默认按单词输入,文件中的步骤数将是单词数。distance() 函数将测量两个迭代器之间的步骤数,因此使用起始迭代器和兼容对象的哨兵调用它将计算文件中的单词数。

从文件输入初始化复杂结构

输入流 的一项优点是它能够从文本文件中解析不同类型的数据并将它们转换为相应的基本类型。这里有一个简单的技术,使用输入流将数据导入结构体的容器中。

如何做到这一点…

在这个菜谱中,我们将从一个数据文件中导入其不同的字段到 struct 对象的 vector 中。数据文件表示城市及其人口和地图坐标:

  • 这是 cities.txt,我们将要读取的数据文件:

    Las Vegas
    661903 36.1699 -115.1398
    New York City
    8850000 40.7128 -74.0060
    Berlin
    3571000 52.5200 13.4050
    Mexico City
    21900000 19.4326 -99.1332
    Sydney
    5312000 -33.8688 151.2093
    

城市名称独占一行。第二行是人口,后面跟着经度和纬度。这种模式为五个城市中的每一个重复。

  • 我们将在一个常量中定义我们的文件名,这样我们就可以稍后打开它:

    constexpr const char * fn{ "cities.txt" };
    
  • 这是一个用于存储数据的 City 结构体:

    struct City {
        string name;
        unsigned long population;
        double latitude;
        double longitude;
    };
    
  • 我们希望读取文件并将 City 对象的 vector 填充:

    vector<City> cities;
    
  • 这就是输入流使这变得简单的地方。我们可以简单地像这样为我们的 City 类特化 operator>>

    std::istream& operator>>(std::istream& in, City& c) {
        in >> std::ws;
        std::getline(in, c.name);
        in >> c.population >> c.latitude >> c.longitude;
        return in;
    }
    

std::ws 输入操纵符会从输入流中丢弃前导空白字符。

我们使用 getline() 读取城市名称,因为它可能是一个或多个单词。

这利用了 >> 操作符为 populationunsigned long)、latitudelongitude(都是 double)元素填充正确的类型。

  • 现在,我们可以打开文件并使用 >> 操作符直接将文件读取到 City 对象的 vector 中:

    ifstream infile(fn, std::ios_base::in);
    if(!infile.is_open()) {
        cout << format("failed to open file {}\n", fn);
        return 1;
    }
    for(City c{}; infile >> c;) cities.emplace_back(c);
    
  • 我们可以使用 format() 显示这个向量:

    for (const auto& [name, pop, lat, lon] : cities) {
        cout << format("{:.<15} pop {:<10} coords {}, {}\n", 
            name, make_commas(pop), lat, lon);
    }
    

输出:

$ ./initialize_container < cities.txt
Las Vegas...... pop 661,903    coords 36.1699, -115.1398
New York City.. pop 8,850,000  coords 40.7128, -74.006
Berlin......... pop 3,571,000  coords 52.52, 13.405
Mexico City.... pop 21,900,000 coords 19.4326, -99.1332
Sydney......... pop 5,312,000  coords -33.8688, 151.2093
  • make_commas() 函数也用于 使用结构化绑定返回多个值 菜谱中的 第二章通用 STL 功能。它接受一个数值并返回一个 string 对象,其中添加了逗号以提高可读性:

    string make_commas(const unsigned long num) {
        string s{ std::to_string(num) };
        for(int l = s.length() - 3; l > 0; l -= 3) {
            s.insert(l, ",");
        }
        return s;
    }
    

它是如何工作的…

这个菜谱的核心是 istream 类的 operator>> 重载:

std::istream& operator>>(std::istream& in, City& c) {
    in >> std::ws;
    std::getline(in, c.name);
    in >> c.population >> c.latitude >> c.longitude;
    return in;
}

通过在函数头中指定我们的 City 类,每当一个 City 对象出现在输入流 >> 操作符的右侧时,这个函数就会被调用:

City c{};
infile >> c;

这允许我们精确指定输入流如何将数据读入 City 对象。

更多...

当你在 Windows 系统上运行此代码时,你会注意到第一行的第一个单词被破坏。这是因为 Windows 总是在任何 UTF-8 文件的开头包含一个 字节顺序标记BOM)。所以,当你读取 Windows 上的文件时,BOM 将包含在你读取的第一个对象中。BOM 是过时的,但在写作的时候,没有方法可以阻止 Windows 使用它。

解决方案是调用一个函数来检查文件的前三个字节是否为 BOM。UTF-8 的 BOM 是 EF BB BF。以下是一个搜索并跳过 UTF-8 BOM 的函数:

// skip BOM for UTF-8 on Windows
void skip_bom(auto& fs) {
    const unsigned char boms[]{ 0xef, 0xbb, 0xbf };
    bool have_bom{ true };
    for(const auto& c : boms) {
        if((unsigned char)fs.get() != c) have_bom = false; 
    }
    if(!have_bom) fs.seekg(0);
    return;
}

这个函数读取文件的前三个字节并检查它们是否为 UTF-8 BOM 签名。如果三个字节中的任何一个不匹配,它将输入流重置为文件的开头。如果文件没有 BOM,则不会造成任何损害。

你只需在开始读取文件之前调用此函数:

int main() {
    ...
    ifstream infile(fn, std::ios_base::in);
    if(!infile.is_open()) {
        cout << format("failed to open file {}\n", fn);
        return 1;
    }
    skip_bom(infile);
    for(City c{}; infile >> c;) cities.emplace_back(c);
    ...
}

这将确保 BOM 不会包含在文件的第一行字符串中。

注意

因为 cin 输入流不可定位,所以 skip_bom() 函数在 cin 流上不会工作。它只能与可定位的文本文件一起工作。

使用 char_traits 自定义字符串类

string 类是 basic_string 类的别名,其签名为:

class basic_string<char, std::char_traits<char>>;

第一个模板参数提供了字符类型。第二个模板参数提供了一个字符 traits 类,它为指定的字符类型提供基本的字符和字符串操作。我们通常使用默认的 char_traits<char> 类。

我们可以通过提供我们自己的自定义字符 traits 类来修改字符串的行为。

如何实现...

在这个菜谱中,我们将创建一个用于 basic_string字符 traits 类,该类在比较时将忽略大小写:

  • 首先,我们需要一个函数将字符转换为通用的大小写。这里我们将使用小写,但这是一个任意的选择。大写也可以工作:

    constexpr char char_lower(const char& c) {
        if(c >= 'A' && c <= 'Z') return c + ('a' - 'A');
        else return c;
    }
    

这个函数必须是 constexpr(对于 C++20 及以后的版本),所以现有的 std::tolower() 函数在这里不会工作。幸运的是,这是一个简单问题的简单解决方案。

  • 我们的 traits 类称为 ci_traitsci 代表不区分大小写)。它继承自 std::char_traits<char>

    class ci_traits : public std::char_traits<char> {
    public:
        ...
    };
    

继承允许我们仅覆盖我们需要的函数。

  • 比较函数分别称为 lt()(小于)和 eq()(等于):

    static constexpr bool lt(char_type a, char_type b) noexcept {
        return char_lower(a) < char_lower(b);
    }
    static constexpr bool eq(char_type a, char_type b) noexcept {
        return char_lower(a) == char_lower(b);
    }
    

注意到我们比较的是字符的小写版本。

  • 还有一个 compare() 函数,它比较两个 C-字符串。它返回 +1 表示大于,-1 表示小于,0 表示等于。我们可以使用 spaceship <=> 运算符来完成这个操作:

    static constexpr int compare(const char_type* s1, 
            const char_type* s2, size_t count) {
        for(size_t i{0}; i < count; ++i) {
            auto diff{ char_lower(s1[i]) <=> 
              char_lower(s2[i]) };
            if(diff > 0) return 1;
            if(diff < 0) return -1;
            }
        return 0;
    }
    
  • 最后,我们需要实现一个 find() 函数。它返回找到的第一个字符实例的指针,如果没有找到则返回 nullptr

    static constexpr const char_type* find(const char_type* p, 
            size_t count, const char_type& ch) {
        const char_type find_c{ char_lower(ch) };
        for(size_t i{0}; i < count; ++i) {
            if(find_c == char_lower(p[i])) return p + i;
        }
        return nullptr;
    }
    
  • 现在我们有了 ci_traits 类,我们可以为我们的 string 类定义一个别名:

    using ci_string = std::basic_string<char, ci_traits>;
    
  • 在我们的 main() 函数中,我们定义了一个 string 和一个 ci_string

    int main() {
        string s{"Foo Bar Baz"};
        ci_string ci_s{"Foo Bar Baz"};
        ...
    
  • 我们想使用 cout 打印它们,但这不会工作:

    cout << "string: " << s << '\n';
    cout << "ci_string: " << ci_s << '\n';
    

首先,我们需要为 operator<< 重载一个操作符:

std::ostream& operator<<(std::ostream& os, 
        const ci_string& str) {
    return os << str.c_str();
}

现在,我们得到以下输出:

string: Foo Bar Baz
ci_string: Foo Bar Baz
  • 让我们比较两个不同大小写的 ci_string 对象:

    ci_string compare1{"CoMpArE StRiNg"};
    ci_string compare2{"compare string"};
    if (compare1 == compare2) {
        cout << format("Match! {} == {}\n", compare1, 
          compare2);
    } else {
        cout << format("no match {} != {}\n", compare1, 
          compare2);
    }
    

输出:

Match! CoMpArE StRiNg == compare string

比较按预期工作。

  • ci_s 对象上使用 find() 函数,我们搜索小写的 b 并找到一个大写的 B

    size_t found = ci_s.find('b');
    cout << format("found: pos {} char {}\n", found, ci_s[found]);
    

输出:

found: pos 4 char B

注意

注意,format() 函数不需要特化。这已经在 fmt.dev 参考实现中进行了测试。即使在特化的情况下,它也没有在 MSVC 的预览版 format() 中工作。希望这将在未来的版本中得到修复。

它是如何工作的…

这个配方通过在 string 类的模板特化中用我们自己的 ci_traits 类替换 std::char_traits 类来实现。basic_string 类使用特性类为其基本字符特定功能,如比较和搜索。当我们用我们自己的类替换它时,我们可以改变这些基本行为。

还有更多…

我们还可以重写 assign()copy() 成员函数来创建一个存储小写字符的类:

class lc_traits : public std::char_traits<char> {
public:
    static constexpr void assign( char_type& r, const
      char_type& a )
            noexcept {
        r = char_lower(a);
    }
    static constexpr char_type* assign( char_type* p,
            std::size_t count, char_type a ) {
        for(size_t i{}; i < count; ++i) p[i] = 
          char_lower(a);
        return p;
    }
    static constexpr char_type* copy(char_type* dest, 
            const char_type* src, size_t count) {
        for(size_t i{0}; i < count; ++i) {
            dest[i] = char_lower(src[i]);
        }
        return dest;
    }
};

现在,我们可以创建一个 lc_string 别名,并且对象存储小写字符:

using lc_string = std::basic_string<char, lc_traits>;
...
lc_string lc_s{"Foo Bar Baz"};
cout << "lc_string: " << lc_s << '\n';

输出:

lc_string: foo bar baz

注意

这些技术在 GCC 和 Clang 上按预期工作,但在 MSVC 的预览版上不起作用。我预计这将在未来的版本中得到修复。

使用正则表达式解析字符串

正则表达式(通常缩写为 regex)常用于文本流中的词法分析和模式匹配。它们在 Unix 文本处理工具中很常见,如 grepawksed,并且是 Perl 语言的一个组成部分。在语法中存在一些常见的变体。1992 年批准了一个 POSIX 标准,而其他常见的变体包括 PerlECMAScript(JavaScript)方言。C++ 的 regex 库默认使用 ECMAScript 方言。

regex 库首次在 C++11 中引入到 STL 中。它对于在文本文件中查找模式非常有用。

要了解更多关于正则表达式语法和用法的信息,我推荐阅读 Jeffrey Friedl 的书籍,Mastering Regular Expressions

如何做到这一点…

对于这个配方,我们将从 HTML 文件中提取超链接。超链接在 HTML 中的编码如下:

<a href="http://example.com/file.html">Text goes here</a>

我们将使用一个 regex 对象来提取链接和文本,作为两个单独的字符串。

  • 我们的示例文件名为 the-end.html。它来自我的网站 (bw.org/end/),并包含在 GitHub 仓库中:

    const char * fn{ "the-end.html" };
    
  • 现在,我们定义我们的 regex 对象,并使用正则表达式字符串:

    const std::regex 
        link_re{ "<a href=\"([^\"]*)\"[^<]*>([^<]*)</a>" };
    

正则表达式一开始可能看起来很吓人,但实际上相当简单。

这被解析如下:

  1. 匹配整个字符串。

  2. 找到子串 <a href=".

  3. 将直到下一个 " 的所有内容存储为子匹配 1

  4. 跳过 > 字符。

  5. 将直到字符串 </a> 的所有内容存储为子匹配 2

  • 现在,我们将整个文件读入一个字符串中:

    string in{};
    std::ifstream infile(fn, std::ios_base::in);
    for(string line{}; getline(infile, line);) in += line;
    

这将打开 HTML 文件,逐行读取它,并将每一行追加到 string 对象 in 中。

  • 为了提取链接字符串,我们设置一个 sregex_token_iterator 对象来遍历文件并提取每个匹配的元素:

    std::sregex_token_iterator it{ in.begin(), in.end(),
        link_re, {1, 2} };
    

12 对应于正则表达式中的子匹配。

  • 我们有一个相应的函数来使用迭代器遍历结果:

    template<typename It>
    void get_links(It it) {
        for(It end_it{}; it != end_it; ) {
            const string link{ *it++ };
            if(it == end_it) break;
            const string desc{ *it++ };
            cout << format("{:.<24} {}\n", desc, link);
        }
    }
    

我们用 regex 迭代器调用该函数:

get_links(it);

我们用描述和链接得到这个结果:

Bill Weinman............ https://bw.org/
courses................. https://bw.org/courses/
music................... https://bw.org/music/
books................... https://packt.com/
back to the internet.... https://duckduckgo.com/

它是如何工作的…

STL 的 regex 引擎作为一个 生成器 运行,每次评估并产生一个结果。我们使用 sregex_iteratorsregex_token_iterator 设置迭代器。虽然 sregex_token_iterator 支持子匹配,但 sregex_iterator 不支持。

我们正则表达式中的括号作为 子匹配,分别编号为 12

const regex link_re{ "<a href=\"([^\"]*)\"[^<]*>([^<]*)</a>" };

这里展示了 regex 匹配的每一部分:

![图 7.1 – 带有子匹配的正则表达式img/B18267_07_01.jpg

图 7.1 – 带有子匹配的正则表达式

这允许我们匹配一个字符串,并使用该字符串的某些部分作为我们的结果:

sregex_token_iterator it{ in.begin(), in.end(), link_re, {1, 2} };

子匹配是编号的,从 1 开始。子匹配 0 是一个特殊值,代表整个匹配。

一旦我们有了迭代器,我们就像使用任何其他迭代器一样使用它:

for(It end_it{}; it != end_it; ) {
    const string link{ *it++ };
    if(it == end_it) break;
    const string desc{ *it++ };
    cout << format("{:.<24} {}\n", desc, link);
}

这只是简单地通过 regex 迭代器遍历我们的结果,从而给出格式化的输出:

Bill Weinman............ https://bw.org/
courses................. https://bw.org/courses/
music................... https://bw.org/music/
books................... https://packt.com/
back to the internet.... https://duckduckgo.com/

第八章:第八章:实用类

C++标准库包含了一系列为特定任务设计的实用类。其中一些是常见的,您可能已经在本书的其他食谱中看到了这些类。

本章涵盖了广泛的功能,包括时间测量、泛型类型、智能指针等,以下是一些食谱:

  • 使用std::optional管理可选值

  • 使用std::any进行类型安全

  • 使用std::variant存储不同类型

  • 使用std::chrono进行时间事件

  • 使用折叠表达式处理可变参数元组

  • 使用std::unique_ptr管理分配的内存

  • 使用std::shared_ptr共享对象

  • 使用弱指针与共享对象一起使用

  • 共享托管对象成员

  • 比较随机数生成器

  • 比较随机数分布生成器

技术要求

本章的代码文件可以在 GitHub 上找到,链接为github.com/PacktPublishing/CPP-20-STL-Cookbook/tree/main/chap08

使用std::optional管理可选值

随着 C++17 的引入,std::optional类包含一个可选值

考虑这种情况,您有一个可能返回或不返回值的函数——例如,一个检查数字是否为素数但如果有第一个因子则返回它的函数。这个函数应该返回一个值或一个bool状态。我们可以创建一个struct来携带值和状态:

struct factor_t {
    bool is_prime;
    long factor;
};
factor_t factor(long n) {
    factor_t r{};
    for(long i = 2; i <= n / 2; ++i) {
        if (n % i == 0) {
            r.is_prime = false;
            r.factor = i;
            return r;
        }
    }
    r.is_prime = true;
    return r;
}

这是一个笨拙的解决方案,但它有效,而且并不罕见。

使用optional类可以使它变得更加简单:

optional<long> factor(long n) {
    for (long i = 2; i <= n / 2; ++i) {
        if (n % i == 0) return {i};
    }
    return {};
}

使用optional,我们可以返回一个值或非值。

我们可以这样调用它:

long a{ 42 };
long b{ 73 };
auto x = factor(a);
auto y = factor(b);
if(x) cout << format("lowest factor of {} is {}\n", a, *x);
else cout << format("{} is prime\n", a);
if(y) cout << format("lowest factor of {} is {}\n", b, *y);
else cout << format("{} is prime\n", b);

我们的输出是:

lowest factor of 42 is 2
73 is prime

optional类允许我们轻松返回可选值并轻松测试值。

如何做到这一点…

在本食谱中,我们将查看一些如何使用optional类的示例:

  • optional类相当简单。我们使用标准模板符号构造一个可选值:

    optional<int> a{ 42 };
    cout << *a << '\n';
    

我们使用*指针解引用操作符访问optional的值。

输出:

42
  • 我们使用optionalbool操作符测试它是否有值:

如果a没有值被构造:

optional<int> a{};

输出将反映else条件:

no value
  • 我们可以通过声明一个类型别名来进一步简化:

    using oint = std::optional<int>;
    oint a{ 42 };
    oint b{ 73 };
    
  • 如果我们想在oint对象上操作,并且结果也是oint对象,我们可以提供操作符重载:

    oint operator+(const oint& a, const oint& b) {
        if(a && b) return *a + *b;
        else return {};
    }
    oint operator+(const oint& a, const int b) {
        if(a) return *a + b;
        else return {};
    }
    

现在,我们可以直接操作oint对象:

auto sum{ a + b };
if(sum) {
    cout << format("{} + {} = {}\n", *a, *b, *sum);
} else {
    cout << "NAN\n";
}

输出:

42 + 73 = 115
  • 假设我们使用默认构造函数声明b

    oint b{};
    

现在,我们得到else分支的输出:

NAN

它是如何工作的…

std::optional类是为了简洁而设计的。它为许多常见函数提供了操作符重载。它还包括用于进一步灵活性的成员函数。

optional类提供了一个operator bool重载,用于确定对象是否有值:

optional<int> n{ 42 };
if(n) ... // has a value

或者,您可以使用has_value()成员函数:

if(n.has_value()) ... // has a value

要访问值,您可以使用operator*重载:

x = *n;  // * retruns the value

或者,您可以使用value()成员函数:

x = n.value();  // * retruns the value

reset()成员函数销毁值并重置optional对象的状态:

n.reset();      // no longer has a value

还有更多…

optional类通过value()方法提供异常支持:

b.reset();
try {
    cout << b.value() << '\n';
} catch(const std::bad_optional_access& e) {
    cout << format("b.value(): {}\n", e.what());
}

输出:

b.value(): bad optional access

重要提示

只有value()方法会抛出异常。对于无效值,*运算符的行为是未定义的。

使用std::any进行类型安全

C++17 引入的std::any类提供了一个类型安全的容器,用于存储任何类型的单个对象。

例如,这是一个默认构造的any对象:

any x{};

此对象没有值。我们可以使用has_value()方法来测试这一点:

if(x.has_value()) cout << "have value\n";
else cout << "no value\n";

输出:

no value

我们使用赋值运算符给any对象赋值:

x = 42;

现在,any对象有一个值和一个类型:

if(x.has_value()) {
    cout << format("x has type: {}\n", x.type().name());
    cout << format("x has value: {}\n", any_cast<int>(x));
} else {
    cout << "no value\n";
}

输出:

x has type: i
x has value: 42

type()方法返回一个type_info对象。type_info::name()方法返回一个 C 字符串中类型的实现定义名称。在这种情况下,对于 GCC,i表示int

我们使用any_cast<type>()非成员函数来转换值以供使用。

我们可以用不同类型的不同值重新赋值any对象:

x = "abc"s;
cout << format("x is type {} with value {}\n", 
    x.type().name(), any_cast<string>(x))

输出:

x is type NSt7__cxx1112basic_string... with value abc

我已经将 GCC 中的长类型名缩写了,但你应该明白这个意思。曾经包含int的同一个any对象现在包含了一个 STL string对象。

any类的主要用途在于创建多态函数。让我们在这个菜谱中看看如何做到这一点:

如何做到这一点…

在这个菜谱中,我们将使用any类构建一个多态函数。多态函数是指可以接受不同类型参数的对象:

  • 我们的多态函数接受一个any对象并打印其类型和值:

    void p_any(const any& a) {
        if (!a.has_value()) {
            cout << "None.\n";
        } else if (a.type() == typeid(int)) {
            cout << format("int: {}\n", any_cast<int>(a));
        } else if (a.type() == typeid(string)) {
            cout << format("string: \"{}\"\n", 
                any_cast<const string&>(a));
        } else if (a.type() == typeid(list<int>)) {
            cout << "list<int>: ";
            for(auto& i : any_cast<const list<int>&>(a)) 
                cout << format("{} ", i);
            cout << '\n';
        } else {
            cout << format("something else: {}\n", 
                a.type().name());
        }
    }
    

p_any()函数首先检查对象是否有值。然后它将type()方法与各种类型进行比较,并为每种类型采取适当的行动。

any类之前,我们不得不为这个函数编写四个不同的特殊化版本,而且我们仍然无法轻松处理默认情况。

  • 我们像这样从main()函数中调用此函数:

    p_any({});
    p_any(47);
    p_any("abc"s);
    p_any(any(list{ 1, 2, 3 }));
    p_any(any(vector{ 1, 2, 3 }));
    

输出:

None.
int: 47
string: "abc"
list<int>: 1 2 3
something else: St6vectorIiSaIiEE

我们的多态函数以最少的代码处理各种类型。

它是如何工作的…

std::any的拷贝构造函数和赋值运算符使用直接初始化来创建目标对象的非const拷贝作为包含对象。包含对象的类型作为typeid对象单独存储。

一旦初始化,any对象具有以下方法:

  • emplace()替换包含的对象,在原地构造新对象。

  • reset()销毁包含的对象。

  • has_value()如果存在包含对象则返回true

  • type()返回一个typeid对象,表示包含对象的类型。

  • operator=()通过拷贝移动操作替换包含的对象。

any类还支持以下非成员函数:

  • any_cast<T>(),一个模板函数,提供了对包含对象的类型安全访问。

请记住,any_cast<T>()函数返回包含对象的副本。您可以使用any_cast<T&>()来返回引用。

  • std::swap()专门化了std::swap算法。

如果您尝试使用错误类型转换any对象,它将抛出bad_any_cast异常:

try {
    cout << any_cast<int>(x) << '\n';
} catch(std::bad_any_cast& e) {
    cout << format("any: {}\n", e.what());
}

输出:

any: bad any_cast

使用 std::variant 存储不同类型

C++17 中引入的std::variant类可以一次持有不同的值,每个值必须适应相同的分配内存空间。它在用于单个上下文中的替代类型持有方面很有用。

与原始联合结构的区别

variant类是一个标记联合。它与原始的union结构不同,因为在任何给定时间只能有一个类型有效。

从 C 继承而来的原始union类型是一种结构,其中相同的数可以以不同的类型访问。例如:

union ipv4 {
    struct {
        uint8_t a; uint8_t b; uint8_t c; uint8_t d;
    } quad;
    uint32_t int32;
} addr;
addr.int32 = 0x2A05A8C0;
cout << format("ip addr dotted quad: {}.{}.{}.{}\n", 
    addr.quad.a, addr.quad.b, addr.quad.c, addr.quad.d);
cout << format("ip addr int32 (LE): {:08X}\n", addr.int32);

输出:

ip addr dotted quad: 192.168.5.42
ip addr int32 (LE): 2A05A8C0

在这个例子中,union有两个成员,类型为structuint32_t,其中struct有四个uint8_t成员。这为我们提供了对相同的 32 位内存空间的两种不同视角。我们可以将相同的ipv4地址视为 32 位无符号整数(小端LE)或四个 8 位无符号整数,使用常见的点分十进制表示法。这提供了一种在系统级别有用的位操作多态性。

variant的行为并不像那样。variant类是一个标记联合,其中每个数据都带有其类型的标记。如果我们存储一个值为uint32_t,我们只能将其作为uint32_t访问。这使得variant类型安全,但不是union的替代品。

如何做到这一点...

在这个菜谱中,我们展示了使用std::variant与各种物种的家庭宠物的小目录。

  • 我们将从包含Animal的简单类开始:

    class Animal {
        string_view _name{};
        string_view _sound{};
        Animal();
    public:
        Animal(string_view n, string_view s) 
            : _name{ n }, _sound{ s } {}
        void speak() const {
            cout << format("{} says {}\n", _name, _sound);
        }
        void sound(string_view s) {
            _sound = s;
        }
    };
    

动物的名称和动物发出的声音通过构造函数传入。

  • 单个物种类从Animal继承:

    class Cat : public Animal {
    public:
        Cat(string_view n) : Animal(n, "meow") {}
    };
    class Dog : public Animal {
    public:
        Dog(string_view n) : Animal(n, "arf!") {}
    };
    class Wookie : public Animal {
    public:
        Wookie(string_view n) : Animal(n, "grrraarrgghh!") {}
    };
    

这些类中的每一个都通过调用父构造函数来为其特定物种设置声音。

  • 现在,我们可以在别名中定义我们的variant类型:

    using v_animal = std::variant<Cat, Dog, Wookie>;
    

这个variant可以持有任何类型,CatDogWookie

  • main()中,我们使用我们的v_animal别名作为类型创建一个list

    int main() {
        list<v_animal> pets{ 
            Cat{"Hobbes"}, Dog{"Fido"}, Cat{"Max"}, 
            Wookie{"Chewie"}
        };
        ...
    

列表中的每个元素都是variant定义中包含的类型。

  • variant类提供了几种不同的方式来访问元素。首先,我们将查看visit()函数。

visit()调用包含在variant中的对象的函数对象。首先,让我们定义一个接受我们任何宠物的函数对象:

struct animal_speaks {
    void operator()(const Dog& d) const { d.speak(); }
    void operator()(const Cat& c) const { c.speak(); }
    void operator()(const Wookie& w) const { 
      w.speak(); }
};

这是一个简单的函数对象类,为每个Animal子类提供了重载。我们用visit()调用它,每个我们的list元素:

for (const v_animal& a : pets) {
    visit(animal_speaks{}, a);
}

我们得到这个输出:

Hobbes says meow
Fido says arf!
Max says meow
Chewie says grrraarrgghh!
  • variant类还提供了一个index()方法:

    for(const v_animal &a : pets) {
        auto idx{ a.index() };
        if(idx == 0) get<Cat>(a).speak();
        if(idx == 1) get<Dog>(a).speak();
        if(idx == 2) get<Wookie>(a).speak();
    }
    

输出:

Hobbes says meow
Fido says arf!
Max says meow
Chewie says grrraarrgghh!

每个variant对象都是基于模板参数中声明的类型顺序进行索引的。我们的v_animal类型是用std::variant<Cat, Dog, Wookie>定义的,这些类型按顺序索引为0 – 2

  • get_if<T>() 函数测试给定元素与一个类型是否匹配:

    for (const v_animal& a : pets) {
        if(const auto c{ get_if<Cat>(&a) }; c) {
            c->speak();
        } else if(const auto d{ get_if<Dog>(&a) }; d) {
            d->speak();
        } else if(const auto w{ get_if<Wookie>(&a) }; w) {
            w->speak();
        }
    }
    

输出结果:

Hobbes says meow
Fido says arf!
Max says meow
Chewie says grrraarrgghh!

get_if<T>() 函数在元素类型匹配 T 时返回一个指针;否则,返回 nullptr

  • 最后,holds_alternative<T>() 函数返回 truefalse。我们可以使用这个函数来测试一个类型与一个元素是否匹配,而不返回该元素:

    size_t n_cats{}, n_dogs{}, n_wookies{};
    for(const v_animal& a : pets) {
        if(holds_alternative<Cat>(a)) ++n_cats;
        if(holds_alternative<Dog>(a)) ++n_dogs;
        if(holds_alternative<Wookie>(a)) ++n_wookies;
    }
    cout << format("there are {} cat(s), "
                   "{} dog(s), "
                   "and {} wookie(s)\n",
                   n_cats, n_dogs, n_wookies);
    

输出结果:

there are 2 cat(s), 1 dog(s), and 1 wookie(s)

它是如何工作的…

std::variant 类是一个单对象容器。variant<X, Y, Z> 的实例必须恰好包含一个 XYZ 类型的对象。它同时包含其当前对象的值和类型。

index() 方法告诉我们当前对象的类型:

if(v.index() == 0) // if variant is type X

holds_alternative<T>() 非成员函数如果 T 是当前对象的类型,则返回 true

if(holds_alternative<X>(v))  // if current variant obj is type X

我们可以使用 get() 非成员函数来检索当前对象:

auto o{ get<X>(v) };  // current variant obj must be type X

我们可以使用 get_if() 非成员函数将类型测试和检索结合起来:

auto* p{ get_if<X>(v) };  // nullptr if current obj not type X

visit() 非成员函数使用当前 variant 对象作为其单个参数调用一个可调用对象:

visit(f, v);  // calls f(v) with current variant obj

visit() 函数是检索对象而不测试其类型的唯一方法。结合一个可以处理每种类型的函数对象,这可以非常灵活:

struct animal_speaks {
    void operator()(const Dog& d) const { d.speak(); }
    void operator()(const Cat& c) const { c.speak(); }
    void operator()(const Wookie& v) const { v.speak(); }
};
main() {
    for (const v_animal& a : pets) {
        visit(animal_speaks{}, a);
    } 
}

输出结果:

Hobbes says meow
Fido says arf!
Max says meow
Chewie says grrraarrgghh!

使用 std::chrono 计时事件

std::chrono 库提供了测量和报告时间和间隔的工具。

许多这些类和函数是在 C++11 中引入的。C++20 有显著的变化和更新,但在撰写本文时,许多这些更新在我测试的系统上尚未实现。

使用 chrono 库,本食谱探讨了计时事件的技巧。

如何做到这一点…

system_clock 类用于报告当前日期和时间。steady_clockhigh_resolution_clock 类用于计时事件。让我们看看这些时钟之间的区别:

  • 由于这些名称可能很长且难以处理,我们将在整个过程中使用一些类型别名:

    using std::chrono::system_clock;
    using std::chrono::steady_clock;
    using std::chrono::high_resolution_clock;
    using std::chrono::duration;
    using seconds = duration<double>;
    using milliseconds = duration<double, std::milli>;
    using microseconds = duration<double, std::micro>;
    using fps24 = duration<unsigned long, std::ratio<1, 24>>;
    

duration 类表示两个时间点之间的间隔。这些别名方便使用不同的间隔。

  • 我们可以使用 system_clock 类来获取当前时间和日期:

    auto t = system_clock::now();
    cout << format("system_clock::now is {:%F %T}\n", t);
    

system_clock::now() 函数返回一个 time_point 对象。<chrono> 库为 time_point 提供了一个 format() 特化,它使用 strftime() 格式说明符。

输出结果为:

system_clock::now is 2022-02-05 13:52:15

<iomanip> 头文件包括 put_time(),它类似于 strftime() 用于 ostream

std::time_t now_t = system_clock::to_time_t(t);
cout << "system_clock::now is " 
     << std::put_time(std::localtime(&now_t), "%F %T") 
     << '\n';

put_time() 接受一个指向 C 风格 time_t* 值的指针。system_clock::to_time_ttime_point 对象转换为 time_t

这与我们的 format() 示例输出相同:

system_clock::now is 2022-02-05 13:52:15
  • 我们也可以使用 system_clock 来计时一个事件。首先,我们需要一个可以计时的东西。这里有一个计算素数的函数:

    constexpr uint64_t MAX_PRIME{ 0x1FFFF }
    uint64_t count_primes() {
        constexpr auto is_prime = [](const uint64_t n) {
            for(uint64_t i{ 2 }; i < n / 2; ++i) {
                if(n % i == 0) return false;
            }
            return true;
        };
        uint64_t count{ 0 };
        uint64_t start{ 2 };
        uint64_t end{ MAX_PRIME };
        for(uint64_t i{ start }; i <= end ; ++i) {
            if(is_prime(i)) ++count;
       }
       return count;
    }
    

此函数计算 2 到 0x1FFFF(131,071)之间的素数,这在大多数现代系统上应该需要几秒钟。

  • 现在,我们编写一个 timer 函数来计时我们的 count_primes()

    seconds timer(uint64_t(*f)()) {
        auto t1{ system_clock::now() };
        uint64_t count{ f() };
        auto t2{ system_clock::now() };
        seconds secs{ t2 - t1 };
        cout << format("there are {} primes in range\n", 
          count);
        return secs;
    }
    

此函数接受一个函数 f 并返回 duration<double>。我们使用 system_clock::now() 标记 f() 调用之前和之后的时间。我们取两个时间之间的差值,并以 duration 对象的形式返回它。

  • 我们可以从 main() 中调用我们的 timer(),如下所示:

    int main() {
        auto secs{ timer(count_primes) };
        cout << format("time elapsed: {:.3f} seconds\n", 
            secs.count());
        ...
    

这将 count_primes() 函数传递给 timer() 并将 duration 对象存储在 secs 中。

输出:

there are 12252 primes in range
time elapsed: 3.573 seconds

duration 对象上的 count() 方法返回指定单位内的持续时间 – 在这种情况下,double,表示持续时间的

这是在运行 Debian 和 GCC 的虚拟机上运行的。确切时间会因不同系统而异。

  • system_clock 类旨在提供当前的 系统时钟 时间。虽然其分辨率可能支持计时目的,但它不保证是 单调的。换句话说,它可能不会始终提供一致的 滴答(计时间隔)。

chrono 库在 steady_clock 中提供了一个更合适的时钟。它具有与 system_clock 相同的接口,但提供了更可靠的滴答,适用于计时目的:

seconds timer(uint64_t(*f)()) {
    auto t1{ steady_clock::now() };
    uint64_t count{ f() };
    auto t2{ steady_clock::now() };
    seconds secs{ t2 - t1 };
    cout << format("there are {} primes in range\n", 
      count);
    return secs;
}

steady_clock 是为了提供可靠一致的单调滴答而设计的,适用于计时事件。它使用相对时间参考,因此对于系统时钟时间来说没有用。虽然 system_clock 从固定的时间点(1970 年 1 月 1 日,00:00 UTC)开始测量,但 steady_clock 使用相对时间。

另一个选项是 high_resolution_clock,它提供了给定系统上可用的最短滴答周期,但不是在不同实现中一致实现的。它可能是 system_clocksteady_clock 的别名,并且可能或可能不是单调的。high_resolution_clock 不建议用于通用用途。

  • 我们的 timer() 函数返回 seconds,它是 duration<double> 的别名:

    using seconds = duration<double>;
    

持续时间类接受一个可选的第二模板参数,一个 std::ratio 类:

template<class Rep, class Period = std::ratio<1>>
class duration;

<chrono> 头文件提供了许多十进制比率的便利类型,包括 millimicro

using milliseconds = duration<double, std::milli>;
using microseconds = duration<double, std::micro>;

如果我们需要其他东西,我们可以提供自己的:

using fps24 = duration<unsigned long, std::ratio<1, 24>>;

fps24 表示以每秒 24 帧的标准拍摄的电影帧数。该比率是秒的 1/24。

这使我们能够轻松地在不同的持续时间范围内进行转换:

cout << format("time elapsed: {:.3f} sec\n", secs.count());
cout << format("time elapsed: {:.3f} ms\n", 
    milliseconds(secs).count());
cout << format("time elapsed: {:.3e} μs\n", 
    microseconds(secs).count());
cout << format("time elapsed: {} frames at 24 fps\n", 
    floor<fps24>(secs).count());

输出:

time elapsed: 3.573 sec
time elapsed: 3573.077 ms
time elapsed: 3.573e+06 μs
time elapsed: 85 frames at 24 fps

由于 fps24 别名使用 unsigned long 而不是 double,需要进行类型转换。floor 函数通过丢弃小数部分来实现这一点。在此上下文中,round()ceil() 函数也是可用的。

  • 为了方便,chrono 库为标准的 duration 比率提供了 format() 特殊化:

    cout << format("time elapsed: {:.3}\n", secs);
    cout << format("time elapsed: {:.3}\n", milliseconds(secs));
    cout << format("time elapsed: {:.3}\n", microseconds(secs));
    

输出:

time elapsed: 3.573s
time elapsed: 3573.077ms
time elapsed: 3573076.564μs

这些结果将因不同的实现而异。

它是如何工作的...

chrono 库有两个主要部分,时钟 类和 duration 类。

时钟类

时钟类包括:

  • system_clock – 提供系统时钟时间。

  • steady_clock – 提供了保证单调的持续时间测量滴答。

  • high_resolution_clock——提供最短的可用滴答周期。在某些系统上可能是system_clocksteady_clock的别名。

我们使用system_clock来显示当前时间和日期。我们使用steady_clock来测量间隔。

每个时钟类都有一个now()方法,它返回time_point,表示时钟的当前值。now()是一个静态成员函数,因此不需要实例化对象就可以调用:

auto t1{ steady_clock::now() };

std::duration

duration类用于存储时间间隔——即两个time_point对象之间的差异。它通常使用time_point对象的减法运算符(-)构造。

duration<double> secs{ t2 - t1 };

time_point减法运算符同时是duration的构造函数:

template<class C, class D1, class D2>
constexpr duration<D1,D2>
operator-( const time_point<C,D1>& pt_lhs,
    const time_point<C,D2>& pt_rhs );

duration类有用于类型表示的模板参数和一个ratio对象:

template<class Rep, class Period = std::ratio<1>>
class duration;

Period模板参数默认为 1:1 的ratio,即秒。

该库提供了ratio别名(如micromilli),用于从atto(1/1,000,000,000,000,000,000)到exa(1,000,000,000,000,000,000/1)的 10 的幂。这允许我们创建标准持续时间,就像我们在示例中所做的那样:

using milliseconds = duration<double, std::milli>;
using microseconds = duration<double, std::micro>;

count()方法给我们的是Rep类型的持续时间:

constexpr Rep count() const;

这使我们能够轻松访问持续时间以进行显示或其他目的:

cout << format("duration: {}\n", secs.count());

使用折叠表达式处理可变元组

std::tuple类本质上是一个更复杂、不太方便的structtuple的接口很繁琐,尽管类模板参数推导结构化绑定使其变得稍微容易一些。

我倾向于在大多数应用中使用struct而不是tuple,有一个显著的例外:tuple的一个真正优势是它可以在可变上下文中与折叠表达式一起使用。

折叠表达式

设计目的是为了使扩展可变参数包更容易,折叠表达式是 C++17 的一个新特性。在折叠表达式之前,扩展参数包需要一个递归函数:

template<typename T>
void f(T final) {
    cout << final << '\n';
}
template<typename T, typename... Args>
void f(T first, Args... args) {
    cout << first;
    f(args...);
}
int main() {
    f("hello", ' ', 47, ' ', "world");
}

输出:

hello 47 world

使用折叠表达式,这要简单得多:

template<typename... Args>
void f(Args... args) {
    (cout << ... << args);
    cout << '\n';
}

输出:

hello 47 world

有四种类型的折叠表达式:

  • 一元右折叠:(args op ...)

  • 一元左折叠:(... op args)

  • 二元右折叠:(args op ... op init)

  • 二元左折叠:(init op ... op args)

上面示例中的表达式是一个二元左折叠

(cout << ... << args);

这将展开为:

cout << "hello" << ' ' << 47 << ' ' << "world";

折叠表达式在许多用途中都非常方便。让我们看看我们如何使用它们与元组一起。

如何做到这一点...

在这个菜谱中,我们将创建一个模板函数,它对一个具有不同数量和类型的元素元组进行操作:

  • 这个菜谱的核心是一个函数,它接受一个未知大小和类型的元组,并使用format()打印每个元素:

    template<typename... T>
    constexpr void print_t(const tuple<T...>& tup) {
        auto lpt =
            [&tup] <size_t... I> 
              (std::index_sequence<I...>)
                constexpr {
                (..., ( cout <<
                    format((I? ", {}" : "{}"), 
                      get<I>(tup))
                ));
                cout << '\n';
            };
        lpt(std::make_index_sequence<sizeof...(T)>());
    }
    

这个函数的核心在于 lambda 表达式。它使用index_sequence对象生成一个索引值的参数包。然后我们使用折叠表达式调用每个索引值的get<I>。模板 lambda 需要 C++20。

你可以用一个单独的函数来代替 lambda 表达式,但我喜欢将其保持在单个作用域内。

  • 我们现在可以从main()函数中用各种元组来调用这个函数:

    int main() {
        tuple lables{ "ID", "Name", "Scale" };
        tuple employee{ 123456, "John Doe", 3.7 };
        tuple nums{ 1, 7, "forty-two", 47, 73L, -111.11 };
    
        print_t(lables);
        print_t(employee);
        print_t(nums);
    }
    

输出:

ID, Name, Scale
123456, John Doe, 3.7
1, 7, forty-two, 47, 73, -111.11

它是如何工作的…

tuple的挑战在于其限制性的接口。你可以使用std::tie()、结构化绑定或std::get<>函数来检索元素。如果你不知道tuple中元素的数量和类型,这些技术都没有用。

我们通过使用index_sequence类来克服这个限制。index_sequenceinteger_sequence的一个特化,它提供了一个size_t元素的参数包,我们可以用它来索引我们的tuple。我们通过调用make_index_sequence来设置 lambda 中的参数包,以调用我们的 lambda 函数:

lpt(std::make_index_sequence<sizeof...(T)>());

模板 lambda 是用get()函数的size_t索引参数包构建的:

[&tup] <size_t... I> (std::index_sequence<I...>) constexpr {
   ...
};

get()函数将索引值作为模板参数。我们使用一个一元左折叠表达式来调用get<I>()

(..., ( cout << format("{} ", std::get<I>(tup))));

折叠表达式将函数参数包中的每个元素取出来,并应用逗号运算符。逗号运算符的右侧有一个format()函数,它打印元组中的每个元素。

这使得推断元组中的元素数量成为可能,使其在可变参数上下文中可用。请注意,与模板函数一般一样,编译器将为tuple参数的每个组合生成此函数的单独特化。

还有更多…

我们可以用这种技术做其他任务。例如,这里有一个函数,它返回未知大小的tuple中所有int值的总和:

template<typename... T>
constexpr int sum_t(const tuple<T...>& tup) {
    int accum{};
    auto lpt =
        [&tup, &accum] <size_t... I> 
          (std::index_sequence<I...>) 
        constexpr {
            (..., ( 
                accum += get<I>(tup)
            ));
        };
    lpt(std::make_index_sequence<sizeof...(T)>());
    return accum;
}

我们可以用不同数量的int值的tuple对象来调用这个函数:

tuple ti1{ 1, 2, 3, 4, 5 };
tuple ti2{ 9, 10, 11, 12, 13, 14, 15 };
tuple ti3{ 47, 73, 42 };
auto sum1{ sum_t(ti1) };
auto sum2{ sum_t(ti2) };
auto sum3{ sum_t(ti3) };
cout << format("sum of ti1: {}\n", sum1);
cout << format("sum of ti2: {}\n", sum2);
cout << format("sum of ti3: {}\n", sum3);

输出:

sum of ti1: 15
sum of ti2: 84
sum of ti3: 162

使用 std::unique_ptr 管理分配的内存

智能指针是管理分配的堆内存的绝佳工具。

堆内存是由 C 函数malloc()free()在最低级别管理的。malloc()从堆中分配一块内存,而free()将其返回到堆中。这些函数不执行初始化,也不调用构造函数或析构函数。如果你没有通过调用free()将分配的内存返回到堆中,其行为是未定义的,通常会导致内存泄漏和安全漏洞。

C++提供了newdelete运算符来分配和释放堆内存,代替malloc()free()newdelete运算符调用对象构造函数和析构函数,但仍然不管理内存。如果你使用new分配内存,而没有使用delete释放它,你将导致内存泄漏。

C++14 引入的智能指针符合资源获取即初始化RAII)习惯用法。这意味着当为对象分配内存时,会调用该对象的构造函数。当调用对象的析构函数时,内存会自动返回到堆中。

例如,当我们使用make_unique()创建一个新的智能指针时:

{   // beginning of scope
    auto p = make_unique<Thing>(); // memory alloc’d,
                                   // ctor called
    process_thing(p);   // p is unique_ptr<Thing>
}   // end of scope, dtor called, memory freed

make_unique()Thing 对象分配内存,调用 Thing 的默认构造函数,构造一个 unique_ptr<Thing> 对象,并返回该 unique_ptr。当 p 超出作用域时,调用 Thing 的析构函数,并将内存自动返回到堆中。

除了内存管理外,智能指针的工作方式与原始指针非常相似:

auto x = *p;  // *p derefs the pointer, returns Thing object
auto y = p->thname; // p-> derefs the pointer, returns member

unique_ptr 是一种智能指针,它只允许指针存在一个实例。它可以被移动,但不能被复制。让我们更详细地看看如何使用 unique_ptr

如何做到...

在这个菜谱中,我们通过一个在构造函数和析构函数被调用时打印的演示类来检查 std::unique_ptr

  • 首先,我们将创建一个简单的演示类:

    struct Thing {
        string_view thname{ "unk" };
        Thing() {
            cout << format("default ctor: {}\n", thname);
        }
        Thing(const string_view& n) : thname(n) {
            cout << format("param ctor: {}\n", thname);
        }
        ~Thing() {
            cout << format("dtor: {}\n", thname);
        }
    };
    

这个类有一个默认构造函数、一个参数化构造函数和一个析构函数。每个都有简单的打印语句来告诉我们调用了什么。

  • 当我们仅构造一个 unique_ptr 时,它不会分配内存或构造一个托管对象:

    int main() {
        unique_ptr<Thing> p1;
        cout << "end of main()\n";
    }
    

输出:

end of main()
  • 当我们使用 new 运算符时,它会分配内存并构造一个 Thing 对象:

    int main() {
        unique_ptr<Thing> p1{ new Thing };
        cout << "end of main()\n";
    }
    

输出:

default ctor: unk
end of main()
dtor: unk

new 运算符通过调用默认构造函数来构造 Thing 对象。当智能指针达到其作用域的末尾时,unique_ptr<Thing> 析构函数调用 Thing 析构函数。

Thing 的默认构造函数没有初始化 thname 字符串,保留其默认值,"unk"

  • 我们可以使用 make_unique() 来得到相同的结果:

    int main() {
        auto p1 = make_unique<Thing>();
        cout << "end of main()\n";
    }
    

输出:

default ctor: unk
end of main()
dtor: unk

make_unique() 辅助函数负责内存分配并返回一个 unique_ptr 对象。这是构造 unique_ptr 的推荐方法。

  • 你传递给 make_unique() 的任何参数都用于构造目标对象:

    int main() {
        auto p1 = make_unique<Thing>("Thing 1") };
        cout << "end of main()\n";
    }
    

输出:

param ctor: Thing 1
end of main()
dtor: Thing 1

参数化构造函数将值赋给 thname,因此我们的 Thing 对象现在是 "Thing 1"

  • 让我们编写一个接受 unique_ptr<Thing> 参数的函数:

    void process_thing(unique_ptr<Thing> p) {
        if(p) cout << format("processing: {}\n", 
          p->thname);
        else cout << "invalid pointer\n";
    }
    

如果我们尝试将 unique_ptr 传递给这个函数,我们会得到编译器错误:

process_thing(p1);

编译器错误:

error: use of deleted function...

这是因为函数调用试图复制 unique_ptr 对象,但 unique_ptr 的复制构造函数被 删除 以防止复制。解决方案是让函数接受一个 const& 引用:

void process_thing(const unique_ptr<Thing>& p) {
    if(p) cout << format("processing: {}\n", 
      p->thname);
    else cout << "invalid pointer\n";
}

输出:

param ctor: Thing 1
processing: Thing 1
end of main()
dtor: Thing 1
  • 我们可以用临时对象调用 process_thing(),该临时对象在函数作用域结束时立即被销毁:

    int main() {
        auto p1{ make_unique<Thing>("Thing 1") };
        process_thing(p1);
        process_thing(make_unique<Thing>("Thing 2"));
        cout << "end of main()\n";
    }
    

输出:

param ctor: Thing 1
processing: Thing 1
param ctor: Thing 2
processing: Thing 2
dtor: Thing 2
end of main()
dtor: Thing 1

它是如何工作的...

一个 智能指针 简单来说是一个对象,它提供了一个指针接口,同时拥有和管理另一个对象资源。

unique_ptr 类通过其删除的复制构造函数和复制赋值运算符而与众不同,这防止了智能指针被复制。

你不能复制一个 unique_ptr

auto p2 = p1;

编译器错误:

error: use of deleted function...

但你可以移动一个 unique_ptr

auto p2 = std::move(p1);
process_thing(p1);
process_thing(p2);

移动后,p1 无效,而 p2"Thing 1"

输出:

invalid pointer
processing: Thing 1
end of main()
dtor: Thing 1

unique_ptr 接口有一个重置指针的方法:

p1.reset();  // pointer is now invalid
process_thing(p1);

输出:

dtor: Thing 1
invalid pointer

reset() 方法也可以用来用相同类型的另一个对象替换托管对象:

p1.reset(new Thing("Thing 3"));
process_thing(p1);

输出:

param ctor: Thing 3
dtor: Thing 1
processing: Thing 3

与 std::shared_ptr 共享对象

std::shared_ptr类是一个智能指针,它拥有其管理对象并维护一个引用计数来跟踪副本。这个配方探讨了使用shared_ptr来管理内存的同时共享指针副本。

注意

关于智能指针的更多详细信息,请参阅本章前面的使用 std::unique_ptr 管理分配的内存配方介绍。

如何做…

在这个配方中,我们通过一个演示类来检查std::shared_ptr,该类在其构造函数和析构函数被调用时打印信息:

  • 首先,我们创建一个简单的演示类:

    struct Thing {
        string_view thname{ "unk" };
        Thing() {
            cout << format("default ctor: {}\n", thname);
        }
        Thing(const string_view& n) : thname(n) {
            cout << format("param ctor: {}\n", thname);
        }
        ~Thing() {
            cout << format("dtor: {}\n", thname);
        }
    };
    

这个类有一个默认构造函数、一个带参数的构造函数和一个析构函数。每个都有简单的打印语句来告诉我们调用了什么。

  • shared_ptr类的工作方式与其它智能指针非常相似,它可以使用new运算符或其辅助函数make_shared()来构造:

    int main() {
        shared_ptr<Thing> p1{ new Thing("Thing 1") };
        auto p2 = make_shared<Thing>("Thing 2");
        cout << "end of main()\n";
    }
    

输出:

param ctor: Thing 1
param ctor: Thing 2
end of main()
dtor: Thing 2
dtor: Thing 1

建议使用make_shared()函数,因为它管理构造过程,并且更不容易出错。

与其他智能指针一样,当指针超出作用域时,管理对象被销毁,其内存返回到堆中。

  • 这里有一个函数来检查shared_ptr对象的引用计数:

    void check_thing_ptr(const shared_ptr<Thing>& p) {
        if(p) cout << format("{} use count: {}\n", 
            p->thname, p.use_count());
        else cout << "invalid pointer\n";
    }
    

thnameThing类的一个成员,因此我们通过指针使用p->成员解引用运算符来访问它。use_count()函数是shared_ptr类的一个成员,因此我们使用p.对象成员运算符来访问它。

让我们用我们的指针来调用这个函数:

check_thing_ptr(p1);
check_thing_ptr(p2);

输出:

Thing 1 use count: 1
Thing 2 use count: 1
  • 当我们复制我们的指针时,引用计数会增加,但不会构造新的对象:

    cout << "make 4 copies of p1:\n";
    auto pa = p1;
    auto pb = p1;
    auto pc = p1;
    auto pd = p1;
    check_thing_ptr(p1);
    

输出:

make 4 copies of p1:
Thing 1 use count: 5
  • 当我们检查其他任何副本时,我们得到相同的结果:

    check_thing_ptr(pa);
    check_thing_ptr(pb);
    check_thing_ptr(pc);
    check_thing_ptr(pd);
    

输出:

Thing 1 use count: 5
Thing 1 use count: 5
Thing 1 use count: 5
Thing 1 use count: 5

每个指针报告相同的引用计数。

  • 当副本超出作用域时,它们被销毁,引用计数减少:

    {   // new scope
        cout << "make 4 copies of p1:\n";
        auto pa = p1;
        auto pb = p1;
        auto pc = p1;
        auto pd = p1;
        check_thing_ptr(p1);
    }   // end of scope
    check_thing_ptr(p1);
    

输出:

make 4 copies of p1:
Thing 1 use count: 5
Thing 1 use count: 1
  • 销毁一个副本会减少引用计数,但不会销毁管理对象。对象在最后一个副本超出作用域且引用计数达到零时被销毁:

    {    
        cout << "make 4 copies of p1:\n";
        auto pa = p1;
        auto pb = p1;
        auto pc = p1;
        auto pd = p1;
        check_thing_ptr(p1);
        pb.reset();
        p1.reset();
        check_thing_ptr(pd);
    }   // end of scope
    

输出:

make 4 copies of p1:
Thing 1 use count: 5
Thing 1 use count: 3
dtor: Thing 1

销毁pb(副本)和p1(原始),留下三个指针副本(pabcpd),因此管理对象仍然存在。

剩下的三个指针副本在它们被创建的作用域结束时被销毁。然后对象被销毁,其内存返回到堆中。

它是如何工作的…

shared_ptr类以其对同一管理对象的多个指针的管理而区别于其他智能指针。

shared_ptr对象的复制构造函数和复制赋值运算符增加引用计数。析构函数减少引用计数,直到它达到零,然后销毁管理对象,并将其内存返回到堆中。

shared_ptr 类管理托管对象和一个堆分配的 控制块。控制块包含使用计数器以及其他维护对象。控制块与托管对象一起被管理和共享。这允许原始 shared_ptr 对象将其控制权转让给其副本,以便最后一个剩余的 shared_ptr 可以管理对象及其内存。

使用共享对象与弱指针

严格来说,std::weak_ptr 不是一个智能指针。相反,它是一个 观察者,与 shared_ptr 协作工作。weak_ptr 对象本身不持有指针。

有一些情况下,shared_ptr 对象可能会创建悬垂指针或竞态条件,这可能导致内存泄漏或其他问题。解决方案是使用 weak_ptr 对象与 shared_ptr 一起使用。

如何做到这一点…

在这个菜谱中,我们通过一个演示类来检查 std::weak_ptrstd::shared_ptr 的使用,该类在其构造函数和析构函数被调用时打印信息。

  • 我们从之前用来演示 shared_ptrunique_ptr 的相同类开始:

    struct Thing {
        string_view thname{ "unk" };
        Thing() {
            cout << format("default ctor: {}\n", thname);
        }
        Thing(const string_view& n) : thname(n) {
            cout << format("param ctor: {}\n", thname);
        }
        ~Thing() {
            cout << format("dtor: {}\n", thname);
        }
    };
    

这个类有一个默认构造函数、一个参数化构造函数和一个析构函数。每个都有简单的打印语句来告诉我们调用了什么。

  • 我们还需要一个函数来检查一个 weak_ptr 对象:

    void get_weak_thing(const weak_ptr<Thing>& p) {
        if(auto sp = p.lock()) cout << 
            format("{}: count {}\n", sp->thname, 
              p.use_count());
        else cout << "no shared object\n";
    }
    

weak_ptr 本身不作为指针操作;它需要使用 shared_ptrlock() 函数返回一个 shared_ptr 对象,然后可以使用它来访问托管对象。

  • 因为 weak_ptr 需要一个关联的 shared_ptr,所以我们将在 main() 中创建一个 shared_ptr<Thing> 对象。当我们创建一个没有分配 shared_ptrweak_ptr 对象时,expired 标志最初被设置为:

    int main() {
        auto thing1 = make_shared<Thing>("Thing 1");
        weak_ptr<Thing> wp1;
        cout << format("expired: {}\n", wp1.expired());
        get_weak_thing(wp1);
    }
    

输出:

param ctor: Thing 1
expired: true
no shared object

make_shared() 函数分配内存并构造一个 Thing 对象。

weak_ptr<Thing> 声明构建一个没有分配 shared_ptrweak_ptr 对象。因此,当我们检查 expired 标志时,它是 true,表示没有关联的 shared_ptr

get_weak_thing() 函数无法获取锁,因为没有可用的 shared_ptr

  • 当我们将 shared_ptr 分配给 weak_ptr 时,我们可以使用 weak_ptr 来访问托管对象:

    wp1 = thing1;
    get_weak_thing(wp1);
    

输出:

Thing 1: count 2

get_weak_thing() 函数现在能够获取锁并访问托管对象。lock() 方法返回一个 shared_ptr,而 use_count() 反映了现在有一个额外的 shared_ptr 正在管理 Thing 对象。

新的 shared_ptrget_weak_thing() 范围结束时被销毁。

  • weak_ptr 类有一个构造函数,它接受一个 shared_ptr 以进行一步构造:

    weak_ptr<Thing> wp2(thing1);
    get_weak_thing(wp2);
    

输出:

Thing 1: count 2

use_count() 再次变为 2。记住,之前的 shared_ptr 在其封装的 get_weak_thing() 范围结束时已被销毁。

  • 当我们重置 shared_ptr 时,其关联的 weak_ptr 对象已过期:

    thing1.reset();
    get_weak_thing(wp1);
    get_weak_thing(wp2);
    

输出:

dtor: Thing 1
no shared object
no shared object

在调用reset()之后,使用计数达到零,托管对象被销毁并且内存被释放。

它是如何工作的...

weak_ptr对象是一个观察者,它持有对shared_ptr对象的非拥有引用。weak_ptr观察shared_ptr,以便知道托管对象何时可用,何时不可用。这允许在不知道托管对象是否活跃的情况下使用shared_ptr

weak_ptr类有一个use_count()函数,它返回shared_ptr的使用计数,或者如果托管对象已被删除,则返回0

long use_count() const noexcept;

weak_ptr还有一个expired()函数,可以报告托管对象是否已被删除:

bool expired() const noexcept;

lock()函数是访问共享指针的首选方式。它会检查expired()以确定托管对象是否可用。如果是,它返回一个新的shared_ptr,该shared_ptr与托管对象共享所有权。否则,它返回一个空的shared_ptr。它将这些操作作为一个原子操作来完成:

std::shared_ptr<T> lock() const noexcept;

还有更多...

weak_ptr的一个重要用例是在存在shared_ptr对象循环引用的可能性时。例如,考虑两个相互链接的类(可能在层次结构中)的情况:

struct circB;
struct circA {
    shared_ptr<circB> p;
    ~circA() { cout << "dtor A\n"; }
};
struct circB {
    shared_ptr<circA> p;
    ~circB() { cout << "dtor B\n"; }
};

我们在析构函数中添加了打印语句,这样我们就可以看到对象何时被销毁。我们现在可以创建两个对象,它们使用shared_ptr相互指向:

int main() {
    auto a{ make_shared<circA>() };
    auto b{ make_shared<circB>() };
    a->p = b;
    b->p = a;
    cout << "end of main()\n";
}

当我们运行这个程序时,请注意析构函数永远不会被调用:

end of main()

因为对象维护指向彼此的共享指针,使用计数永远不会达到零,托管对象也永远不会被销毁。

我们可以通过将其中一个类改为使用weak_ptr来解决这个问题:

struct circB {
    weak_ptr<circA> p;
    ~circB() { cout << "dtor B\n"; }
};

main()中的代码保持不变,我们得到以下输出:

end of main()
dtor A
dtor B

通过将一个shared_ptr更改为weak_ptr,我们解决了循环引用的问题,并且对象现在在它们的范围结束时被正确销毁。

共享托管对象的成员

std::shared_ptr类提供了一个别名构造函数来共享由另一个无关指针管理的指针:

shared_ptr( shared_ptr<Y>&& ref, element_type* ptr ) noexcept;

这返回了一个别名shared_ptr对象,它使用ref的资源,但返回ptr的指针。use_countref共享。析构器与ref共享。但get()返回ptr。这允许我们在不共享整个对象的情况下共享托管对象的成员,并且在我们使用成员时不会删除整个对象。

如何做到这一点...

在这个菜谱中,我们创建了一个托管对象并共享该对象的成员:

  • 我们从一个托管对象的类开始:

    struct animal {
        string name{};
        string sound{};
        animal(const string& n, const string& a)
                : name{n}, sound{a} {
            cout << format("ctor: {}\n", name);
        }
        ~animal() {
            cout << format("dtor: {}\n", name);
        }
    };
    

这个类有两个成员,用于animal对象的namesoundstring类型。我们还在构造函数和析构函数中添加了打印语句。

  • 现在,我们需要一个函数来创建一个动物,但只共享它的名称和声音:

    auto make_animal(const string& n, const string& s) {
        auto ap = make_shared<animal>(n, s);
        auto np = shared_ptr<string>(ap, &ap->name);
        auto sp = shared_ptr<string>(ap, &ap->sound);
        return tuple(np, sp);
    }
    

此函数创建带有 animal 对象的 shared_ptr,该对象使用名称和声音构造。然后我们为名称和声音创建别名 shared_ptr 对象。当我们返回 namesound 指针时,animal 指针超出作用域。它没有被删除,因为别名指针保持了使用计数不会达到零。

  • 在我们的 main() 函数中,我们调用 make_animal() 并检查结果:

    int main() {
        auto [name, sound] =
            make_animal("Velociraptor", "Grrrr!");
        cout << format("The {} says {}\n", *name, *sound);
        cout << format("Use count: name {}, sound {}\n", 
            name.use_count(), sound.use_count()); 
    }
    

输出:

ctor: Velociraptor
The Velociraptor says Grrrr!
Use count: name 2, sound 2
dtor: Velociraptor

我们可以看到,每个别名指针都显示了一个 use_count2。当 make_animal() 函数创建别名指针时,它们各自增加了 animal 指针的使用计数。当函数结束时,animal 指针超出作用域,其使用计数保持在 2,这反映在别名指针上。别名指针在 main() 函数的末尾超出作用域,这允许 animal 指针被销毁。

它是如何工作的…

别名共享指针看起来有点抽象,但实际上比看起来简单。

共享指针使用一个 控制块 来管理其资源。一个控制块与一个托管对象相关联,并由共享该对象的指针共享。控制块通常包含:

  • 指向托管对象的指针

  • 删除器

  • 分配器

  • 拥有托管对象的 shared_ptr 对象的数量(这是 使用计数

  • 指向托管对象的 weak_ptr 对象的数量

在别名共享指针的情况下,控制块包括指向 别名对象 的指针。其他一切保持不变。

别名共享指针参与使用计数,就像非别名共享指针一样,防止托管对象在计数达到零之前被销毁。删除器没有改变,因此它销毁托管对象。

重要提示

可以使用任何指针来构造别名共享指针。通常,该指针指向别名对象内的成员。如果别名指针不指向托管对象的一个元素,您将需要单独管理其构造和销毁。

比较随机数生成器

random 库提供了一系列随机数生成器,每个生成器都有不同的策略和属性。在本例中,我们通过创建输出直方图来比较这些不同选项的功能。

如何操作…

在本例中,我们比较了 C++ random 库提供的不同随机数生成器:

  • 我们从一些常量开始,为随机数生成器提供统一的参数:

    constexpr size_t n_samples{ 1000 };
    constexpr size_t n_partitions{ 10 };
    constexpr size_t n_max{ 50 };
    

n_samples 是要检查的样本数量,n_partitions 是显示样本的分区数量,n_max 是直方图中条形图的最大尺寸(这会因为四舍五入而略有变化)。

这些数字提供了对引擎之间差异的合理展示。增加 样本分区 的比率往往会使曲线平滑,并模糊引擎之间的差异。

  • 这是收集随机数样本并显示直方图的函数:

    template <typename RNG>
    void histogram(const string_view& rng_name) {
        auto p_ratio = (double)RNG::max() / n_partitions;
        RNG rng{};  // construct the engine object
        // collect the samples
        vector<size_t> v(n_partitions);
        for(size_t i{}; i < n_samples; ++i) {
            ++v[rng() / p_ratio];
        }
        // display the histogram
        auto max_el = std::max_element(v.begin(), 
          v.end());
        auto v_ratio = *max_el / n_max;
        if(v_ratio < 1) v_ratio = 1;
        cout << format("engine: {}\n", rng_name);
        for(size_t i{}; i < n_partitions; ++i) {
            cout << format("{:02}:{:*<{}}\n",
                i + 1, ' ', v[i] / v_ratio);
        }
        cout << '\n';
    }
    

简而言之,这个函数将收集到的样本直方图存储在vector中。然后,它在控制台上以一系列星号的形式显示直方图。

  • 我们像这样从main()中调用histogram()

    int main() {
        histogram<std::random_device>("random_device");
        histogram<std::default_random_engine>
            ("default_random_engine");
        histogram<std::minstd_rand0>("minstd_rand0");
        histogram<std::minstd_rand>("minstd_rand");
        histogram<std::mt19937>("mt19937");
        histogram<std::mt19937_64>("mt19937_64");
        histogram<std::ranlux24_base>("ranlux24_base");
        histogram<std::ranlux48_base>("ranlux48_base");
        histogram<std::ranlux24>("ranlux24");
        histogram<std::ranlux48>("ranlux48");
        histogram<std::knuth_b>("knuth_b");
    }
    

输出:

图 8.1 – 来自前两个随机数发生器的输出截图

图 8.1 – 来自前两个随机数发生器的输出截图

这张截图显示了前两个随机数发生器的直方图。你的输出可能会有所不同。

如果我们将n_samples的值提高到 100,000,你会发现引擎之间的方差变得更加难以辨别:

图 8.2 – 包含 100,000 个样本的输出截图

图 8.2 – 包含 100,000 个样本的输出截图

它是如何工作的...

每个随机数发生器都有一个返回序列中下一个随机数的函数接口:

result_type operator()();

函数返回一个随机值,均匀分布在min()max()值之间。所有随机数发生器都有这个共同的接口。

histogram()函数利用这种均匀性,通过在模板中使用随机数发生器的类:

template <typename RNG>

RNG随机数生成器的常见缩写。库文档将这些类称为引擎,在我们的目的中与 RNG 同义。)

我们使用 RNG 类实例化一个对象,并在vector中创建一个直方图:

RNG rng{};
vector<size_t> v(n_partitions);
for(size_t i{}; i < n_samples; ++i) {
    ++v[rng() / p_ratio];
}

这使我们能够轻松地使用这种技术比较各种随机数引擎的结果。

还有更多...

库中的每个随机数发生器都有不同的方法和特性。当你多次运行直方图时,你会注意到大多数引擎每次运行时都有相同的分布。这是因为它们是确定性的 – 即每次都生成相同的数字序列。std::random_device在大多数系统中是非确定性的。如果你需要更多的变化,你可以用它来初始化其他引擎之一。通常也用当前日期和时间来初始化 RNG。

std::default_random_engine是大多数情况下的合适选择。

比较随机数分布生成器

C++标准库提供了一系列随机数分布生成器,每个生成器都有其自身的特性。在这个菜谱中,我们通过创建它们输出的直方图来比较不同的选项。

如何做到这一点...

与随机数发生器一样,分布生成器有一些共同的接口元素。与随机数发生器不同,分布生成器有各种属性可以设置。我们可以创建一个模板函数来打印各种分布的直方图,但各种分布生成器的初始化差异很大:

  • 我们从一些常数开始:

    constexpr size_t n_samples{ 10 * 1000 };
    constexpr size_t n_max{ 50 };
    

n_samples 常数是每个直方图要生成的样本数量 – 在这种情况下,10,000。

n_max 常数在生成我们的直方图时用作除数。

  • 我们的直方图函数接受一个分布生成器作为参数,并打印出该分布算法的直方图:

    void dist_histogram(auto distro,
            const string_view& dist_name) {
        std::default_random_engine rng{};
        map<long, size_t> m;
        // create the histogram map
        for(size_t i{}; i < n_samples; ++i) 
            ++m[(long)distro(rng)];
        // print the histogram
        auto max_elm_it = max_element(m.begin(), m.end(),
            [](const auto& a, const auto& b)
            { return a.second < b.second; }
            );
        size_t max_elm = max_elm_it->second;
        size_t max_div = std::max(max_elm / n_max,
            size_t(1));
        cout << format("{}:\n", dist_name);
        for (const auto [randval, count] : m) {
            if (count < max_elm / n_max) continue;
            cout << format("{:3}:{:*<{}}\n",
                randval, ' ', count / max_div);
        }
    }
    

dist_histogram() 函数使用 map 来存储直方图。然后,它在控制台上以一系列星号的形式显示直方图。

  • 我们像这样从 main() 中调用 dist_histogram()

    int main() {
        dist_histogram(std::uniform_int_distribution<int>
            {0, 9}, uniform_int_distribution");
        dist_histogram(std::normal_distribution<double>
            {0.0, 2.0}, "normal_distribution");
    ...
    

调用 dist_histogram() 函数比随机数生成器要复杂。每个随机分布类都有其算法的不同参数集。

对于完整列表,请参阅 GitHub 存档中的 distribution.cpp 文件。

输出:

![图 8.3 – 随机分布直方图的截图图片 B18267_08_03.jpg

图 8.3 – 随机分布直方图的截图

每个分布算法都会产生非常不同的输出。您可能需要为每个随机分布生成器尝试不同的选项。

它是如何工作的…

每个分布生成器都有一个返回随机分布中下一个值的函数对象:

result_type operator()( Generator& g );

函数对象接受一个随机数生成器 (RNG) 对象作为参数:

std::default_random_engine rng{};
map<long, size_t> m;
for (size_t i{}; i < n_samples; ++i) ++m[(long)distro(rng)];

对于我们的目的,我们使用 std::default_random_engine 作为我们的随机数生成器 (RNG)。

与 RNG 直方图一样,这是一个有用的工具,可以可视化 random 库中可用的各种随机分布算法。您可能需要尝试每个算法可用的各种参数。

第九章:第九章:并发与并行

并发和并行是指能够在独立的执行线程中运行代码的能力。

更具体地说,并发是指能够在后台运行线程的能力,而并行是指能够在处理器的不同核心上同时运行线程的能力。运行时库以及宿主操作系统将为给定硬件环境中的给定线程选择并发或并行执行模型。

在现代多任务操作系统中,main()函数已经代表了一个执行线程。当启动一个新线程时,它被称为由现有线程孵化的。一组线程可能被称为蜂群

在 C++标准库中,std::thread类提供了线程执行的基本单元。其他类基于thread提供互斥锁和其他并发模式。根据系统架构,执行线程可能在一个处理器上并发运行,或者在多个核心上并行运行。

在本章中,我们将介绍以下配方中的这些工具和更多内容:

  • 休眠特定的时间长度

  • 使用std::thread实现并发

  • 使用std::async实现并发

  • 使用执行策略并行运行 STL 算法

  • 使用互斥锁和锁安全地共享数据

  • 使用std::atomic共享标志和值

  • 使用std::call_once初始化线程

  • 使用std::condition_variable解决生产者-消费者问题

  • 实现多个生产者和消费者

技术要求

您可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/CPP-20-STL-Cookbook/tree/main/chap09

休眠特定的时间长度

<thread>头文件提供了两个用于使线程休眠的功能,sleep_for()sleep_until()。这两个函数都在std::this_thread命名空间中。

这个配方探讨了这些函数的使用,因为我们将在本章后面使用它们。

如何做到这一点...

让我们看看如何使用sleep_for()sleep_until()函数:

  • 与休眠相关的函数位于std::this_thread命名空间中。因为它只有几个符号,我们将继续为std::this_threadstd::chrono_literals发出using指令:

    using namespace std::this_thread;
    using namespace std::chrono_literals;
    

chrono_literals命名空间有表示持续时间的符号,例如1s表示一秒,或100ms表示 100 毫秒。

  • main()中,我们将使用steady_clock::now()标记一个时间点,这样我们就可以计时我们的测试:

    int main() {
        auto t1 = steady_clock::now();
        cout << "sleep for 1.3 seconds\n";
        sleep_for(1s + 300ms);
        cout << "sleep for 2 seconds\n";
        sleep_until(steady_clock::now() + 2s);
        duration<double> dur1 = steady_clock::now() - t1;
        cout << format("total duration: {:.5}s\n", 
          dur1.count());
    }
    

sleep_for()函数接受一个duration对象来指定休眠的时间长度。参数(1s + 300ms)使用了chrono_literal运算符来返回一个表示 1.3 秒的duration对象。

sleep_until()函数接受一个time_point对象来指定从休眠中恢复的具体时间。在这种情况下,使用了chrono_literal运算符来修改由steady_clock::now()返回的time_point对象。

这是我们的输出:

sleep for 1.3 seconds
sleep for 2 seconds
total duration: 3.3005s

它是如何工作的…

sleep_for(duration)sleep_until(time_point) 函数将暂停当前线程的执行,直到指定的 duration 完成,或者直到 time_point 到达。

如果支持,sleep_for() 函数将使用 steady_clock 实现。否则,持续时间可能会受到时间调整的影响。这两个函数可能会因为调度或资源延迟而阻塞更长的时间。

还有更多…

一些系统支持 POSIX 函数 sleep(),该函数暂停执行指定的时间数秒:

unsigned int sleep(unsigned int seconds);

sleep() 函数是 POSIX 标准的一部分,而不是 C++ 标准的一部分。

使用 std::thread 进行并发

线程 是并发的单位。main() 函数可以被认为是 主要执行线程。在操作系统的上下文中,主线程与其他进程拥有的线程并发运行。

std::thread 类是 STL 中并发的根基。所有其他并发特性都是建立在 thread 类的基础之上。

在这个菜谱中,我们将检查 std::thread 的基础知识以及 join()detach() 如何确定其执行上下文。

如何做到这一点…

在这个菜谱中,我们创建了一些 std::thread 对象,并实验了它们的执行选项。

  • 我们从一个用于休眠线程的方便函数开始,以毫秒为单位:

    void sleepms(const unsigned ms) {
        using std::chrono::milliseconds;
        std::this_thread::sleep_for(milliseconds(ms));
    }
    

sleep_for() 函数接受一个 duration 对象,并阻塞当前线程的执行,持续指定的时间。这个 sleepms() 函数作为一个方便的包装器,接受一个表示休眠毫秒数的 unsigned 值。

  • 现在,我们需要一个用于我们的线程的函数。这个函数根据一个整数参数休眠可变数量的毫秒:

    void fthread(const int n) {
        cout << format("This is t{}\n", n);
    
        for(size_t i{}; i < 5; ++i) {
            sleepms(100 * n);
            cout << format("t{}: {}\n", n, i + 1);
        }
        cout << format("Finishing t{}\n", n);
    }
    

fthread() 调用 sleepms() 五次,每次休眠 100 * n 毫秒。

  • 我们可以在 main() 中使用 std::thread 在单独的线程中运行这个操作:

    int main() {
        thread t1(fthread, 1);
        cout << "end of main()\n";
    }
    

它可以编译,但当我们运行它时得到这个错误:

terminate called without an active exception
Aborted

(你的错误信息可能会有所不同。这是在 Debian 上使用 GCC 时的错误信息。)

问题在于操作系统不知道当线程对象超出作用域时该如何处理。我们必须指定调用者是否等待线程,或者线程是否分离并独立运行。

  • 我们使用 join() 方法来表示调用者将等待线程完成:

    int main() {
        thread t1(fthread, 1);
        t1.join();
        cout << "end of main()\n";
    }
    

输出:

This is t1
t1: 1
t1: 2
t1: 3
t1: 4
t1: 5
Finishing t1
end of main()

现在,main() 等待线程完成。

  • 如果我们调用 detach() 而不是 join(),那么 main() 不会等待,程序在线程运行之前就结束了:

    thread t1(fthread, 1);
    t1.detach();
    

输出:

end of main()
  • 当线程分离时,我们需要给它运行的时间:

    thread t1(fthread, 1);
    t1.detach();
    cout << "main() sleep 2 sec\n";
    sleepms(2000);
    

输出:

main() sleep 2 sec
This is t1
t1: 1
t1: 2
t1: 3
t1: 4
t1: 5
Finishing t1
end of main()
  • 让我们启动并分离第二个线程,看看会发生什么:

    int main() {
        thread t1(fthread, 1);
        thread t2(fthread, 2);
        t1.detach();
        t2.detach();
        cout << "main() sleep 2 sec\n";
        sleepms(2000);
        cout << "end of main()\n";
    }
    

输出:

main() sleep 2 sec
This is t1
This is t2
t1: 1
t2: 1
t1: 2
t1: 3
t2: 2
t1: 4
t1: 5
Finishing t1
t2: 3
t2: 4
t2: 5
Finishing t2
end of main()

因为我们的 fthread() 函数使用其参数作为 sleepms() 的乘数,所以第二个线程比第一个线程运行得慢一些。我们可以在输出中看到计时器的交错。

  • 如果我们用 join() 而不是 detach() 来做这件事,我们会得到类似的结果:

    int main() {
        thread t1(fthread, 1);
        thread t2(fthread, 2);
        t1.join();
        t2.join();
        cout << "end of main()\n";
    }
    

输出:

This is t1
This is t2
t1: 1
t2: 1
t1: 2
t1: 3
t2: 2
t1: 4
t1: 5
Finishing t1
t2: 3
t2: 4
t2: 5
Finishing t2
end of main()

因为 join() 等待线程完成,所以我们不再需要在 main() 中的 sleepms() 2 秒来等待线程完成。

它是如何工作的...

std::thread 对象代表一个执行线程。对象与线程之间存在一对一的关系。一个 thread 对象代表一个线程,一个线程由一个 thread 对象表示。thread 对象不能被复制或赋值,但它可以被移动。

thread 构造函数看起来像这样:

explicit thread( Function&& f, Args&&… args );

线程使用函数指针和零个或多个参数进行构造。函数会立即使用提供的参数调用:

thread t1(fthread, 1);

这创建了对象 t1 并立即调用函数 fthread(int),将字面值 1 作为参数。

在创建线程后,我们必须在线程上使用 join()detach()

t1.join();

join() 方法会阻塞调用线程的执行,直到 t1 线程完成:

t1.detach();

detach() 方法允许调用线程独立于 t1 线程继续执行。

更多内容...

C++20 提供了 std::jthread,它会在其作用域结束时自动连接调用者:

int main() {
    std::jthread t1(fthread, 1);
    cout "< "end of main("\n";
}

输出:

end of main()
This is t1
t1: 1
t1: 2
t1: 3
t1: 4
t1: 5
Finishing t1

这允许 t1 线程独立执行,并在其作用域结束时自动连接到 main() 线程。

使用 std::async 进行并发

std::async() 异步运行目标函数并返回一个 std::future 对象来携带目标函数的返回值。这样,async() 的操作方式与 std::thread 类似,但允许返回值。

让我们通过几个示例来考虑 std::async() 的使用。

如何实现...

在其最简单的形式中,std::async() 函数执行的任务与 std::thread 类似,无需调用 join()detach(),同时还可以通过 std::future 对象允许返回值。

在这个菜谱中,我们将使用一个函数来计算一个范围内的素数数量。我们将使用 chrono::steady_clock 来计时每个线程的执行。

  • 我们将开始使用一些便利别名:

    using launch = std::launch;
    using secs = std::chrono::duration<double>;
    

std::launch 有启动策略常量,用于与 async() 调用一起使用。secs 别名是一个 duration 类,用于计时我们的素数计算。

  • 我们的目标函数计算一个范围内的素数。这本质上是一种通过消耗一些时钟周期来理解执行策略的方法:

    struct prime_time {
        secs dur{};
        uint64_t count{};
    };
    prime_time count_primes(const uint64_t& max) {
        prime_time ret{};
        constexpr auto isprime = [](const uint64_t& n) {
            for(uint64_t i{ 2 }; i < n / 2; ++i) {
                if(n % i == 0) return false;
            }
            return true;
        };
        uint64_t start{ 2 };
        uint64_t end{ max };
        auto t1 = steady_clock::now();
        for(uint64_t i{ start }; i <= end ; ++i) {
            if(isprime(i)) ++ret.count;
        }
        ret.dur = steady_clock::now() - t1;
        return ret;
    }
    

prime_time 结构用于返回值,包含持续时间和计数的元素。这允许我们计时循环本身。isprime lambda 函数在值是素数时返回 true。我们使用 steady_clock 来计算计算素数的循环的持续时间。

  • main() 中,我们调用我们的函数并报告其计时:

    int main() {
        constexpr uint64_t MAX_PRIME{ 0x1FFFF };
        auto pt = count_primes(MAX_PRIME);
        cout << format("primes: {} {:.3}\n", pt.count, 
          pt.dur);
    }
    

输出:

primes: 12252 1.88008s
  • 现在,我们可以使用 std::async() 异步运行 count_primes()

    int main() {
        constexpr uint64_t MAX_PRIME{ 0x1FFFF };
        auto primes1 = async(count_primes, MAX_PRIME);
        auto pt = primes1.get();
        cout << format("primes: {} {:.3}\n", pt.count, 
          pt.dur);
    }
    

这里,我们使用 async() 函数和 count_primes 函数以及 MAX_PRIME 参数进行调用。这将在后台运行 count_primes()

async()返回一个std::future对象,该对象携带异步操作的返回值。future对象的get()方法会阻塞,直到异步函数完成,然后返回函数的返回对象。

这与没有使用async()时几乎相同的计时:

primes: 12252 1.97245s
  • async()函数可以可选地将其第一个参数作为执行策略标志:

    auto primes1 = async(launch::async, count_primes, MAX_PRIME);
    

选项是asyncdeferred。这些标志位于std::launch命名空间中。

async标志启用异步操作,而deferred标志启用延迟评估。这些标志是位映射的,并且可以使用位或|运算符组合。

默认情况下,两个位都被设置,就像指定了async | deferred一样。

  • 我们可以使用async()同时运行我们函数的几个实例:

    int main() {
        constexpr uint64_t MAX_PRIME{ 0x1FFFF };
        list<std::future<prime_time>> swarm;
        cout << "start parallel primes\n";
        auto t1{ steady_clock::now() };
        for(size_t i{}; i < 15; ++i) {
            swarm.emplace_back(
                async(launch::async, count_primes, 
                  MAX_PRIME)
            );
        }
        for(auto& f : swarm) {
            static size_t i{};
            auto pt = f.get();
            cout << format("primes({:02}): {} {:.5}\n",
                ++i, pt.count, pt.dur);
        }
        secs dur_total{ steady_clock::now() - t1 };
        cout << format("total duration: {:.5}s\n", 
            dur_total.count());
    }
    

我们知道async返回一个future对象。因此,我们可以通过将future对象存储在容器中来运行 15 个线程。以下是在 6 核 i7 上运行 Windows 的输出:

start parallel primes
primes(01): 12252 4.1696s
primes(02): 12252 3.7754s
primes(03): 12252 3.78089s
primes(04): 12252 3.72149s
primes(05): 12252 3.72006s
primes(06): 12252 4.1306s
primes(07): 12252 4.26015s
primes(08): 12252 3.77283s
primes(09): 12252 3.77176s
primes(10): 12252 3.72038s
primes(11): 12252 3.72416s
primes(12): 12252 4.18738s
primes(13): 12252 4.07128s
primes(14): 12252 2.1967s
primes(15): 12252 2.22414s
total duration: 5.9461s

即使 6 核 i7 无法在单独的核心上运行所有进程,它仍然在 6 秒内完成了 15 个实例。

它看起来在前 13 个线程大约用了 4 秒完成,然后又花了 2 秒来完成最后 2 个线程。它似乎利用了 Intel 的 Hyper-Threading 技术,在某些情况下允许一个核心下运行 2 个线程。

当我们在 12 核 Xeon 上运行相同的代码时,我们得到这个结果:

start parallel primes
primes(01): 12252 0.96221s
primes(02): 12252 0.97346s
primes(03): 12252 0.92189s
primes(04): 12252 0.97499s
primes(05): 12252 0.98135s
primes(06): 12252 0.93426s
primes(07): 12252 0.90294s
primes(08): 12252 0.96307s
primes(09): 12252 0.95015s
primes(10): 12252 0.94255s
primes(11): 12252 0.94971s
primes(12): 12252 0.95639s
primes(13): 12252 0.95938s
primes(14): 12252 0.92115s
primes(15): 12252 0.94122s
total duration: 0.98166s

12 核 Xeon 在不到一秒内就完成了所有 15 个进程。

它是如何工作的...

理解std::async的关键在于其使用std::promisestd::future

promise类允许一个thread存储一个对象,该对象可能稍后由一个future对象异步检索。

例如,假设我们有一个这样的函数:

void f() {
    cout << "this is f()\n";
}

我们可以像这样使用std::thread来运行它:

int main() {
    std::thread t1(f);
    t1.join();
    cout << "end of main()\n";
}

对于没有返回值的简单函数来说,这没问题。当我们想要从f()函数中返回一个值时,我们可以使用promisefuture

我们在main()线程中设置了 promise 和 future 对象:

int main() {
    std::promise<int> value_promise;
    std::future<int> value_future = 
      value_promise.get_future();
    std::thread t1(f, std::move(value_promise));
    t1.detach();
    cout << format("value is {}\n", value_future.get());
    cout << "end of main()\n";
}

我们将promise对象传递给我们的函数:

void f(std::promise<int> value) {
    cout << "this is f()\n";
    value.set_value(47);
}

注意,promise对象不能被复制,因此我们需要使用std::move将其传递给函数。

promise对象充当了future对象的一个桥梁,允许我们在值可用时检索它。

std::async()只是一个辅助函数,用于简化promisefuture对象的创建。使用async(),我们可以这样做:

int f() {
    cout << "this is f()\n";
    return 47;
}
int main() {
    auto value_future = std::async(f);
    cout << format("value is {}\n", value_future.get());
    cout << "end of main()\n";
}

这就是async()函数的价值。对于许多用途来说,它使得使用promisefuture变得更加容易。

使用执行策略并行运行 STL 算法

从 C++17 开始,许多标准 STL 算法可以以并行执行的方式运行。这个特性允许算法将其工作分割成子任务,以便在多个核心上同时运行。这些算法接受一个执行策略对象,该对象指定了应用于算法的并行类型。这个特性需要硬件支持。

如何做…

执行策略在 <execution> 头文件和 std::execution 命名空间中定义。在本例中,我们将使用 std::transform() 算法测试可用的策略:

  • 为了计时,我们将使用带有 std::milli 比率的 duration 对象,这样我们就可以以毫秒为单位进行测量:

    using dur_t = duration<double, std::milli>;
    
  • 为了演示目的,我们将从一个包含 1000 万个随机值的 int vector 开始:

    int main() {
        std::vector<unsigned> v(10 * 1000 * 1000);
        std::random_device rng;
        for(auto &i : v) i = rng() % 0xFFFF;
        ...
    
  • 现在,我们应用一个简单的转换:

    auto mul2 = [](int n){ return n * 2; };
    auto t1 = steady_clock::now();
    std::transform(v.begin(), v.end(), v.begin(), mul2);
    dur_t dur1 = steady_clock::now() - t1;
    cout << format("no policy: {:.3}ms\n", dur1.count());
    

mul2 lambda 简单地将一个值乘以 2。transform() 算法将 mul2 应用到向量的每个成员上。

此转换没有指定执行策略。

输出:

no policy: 4.71ms
  • 我们可以在算法的第一个参数中指定执行策略:

    std::transform(execution::seq,
        v.begin(), v.end(), v.begin(), mul2);
    

seq 策略意味着算法不应并行化。这与没有执行策略相同。

输出:

execution::seq: 4.91ms

注意到持续时间与没有策略时大致相同。它永远不会完全精确,因为每次运行时都会变化。

  • execution::par 策略允许算法并行化其工作负载:

    std::transform(execution::par,
        v.begin(), v.end(), v.begin(), mul2);
    

输出:

execution::par: 3.22ms

注意到算法在并行执行策略下运行得更快。

  • execution::par_unseq 策略允许无序并行执行工作负载:

    std::transform(execution::par_unseq,
        v.begin(), v.end(), v.begin(), mul2);
    

输出:

execution::par_unseq: 2.93ms

这里,我们注意到使用此策略性能又有提升。

execution::par_unseq 策略对算法的要求更严格。算法不得执行需要并发或顺序操作的操作。

它是如何工作的…

执行策略接口没有指定算法工作负载是如何并行化的。它旨在与各种硬件和处理器在不同负载和环境下一起工作。它可能完全在库中实现,也可能依赖于编译器或硬件支持。

并行化将在那些做超过 O(n) 工作的算法上显示出最大的改进。例如,sort() 显示出显著的改进。这是一个没有并行化的 sort()

auto t0 = steady_clock::now();
std::sort(v.begin(), v.end());
dur_t dur0 = steady_clock::now() - t0;
cout << format("sort: {:.3}ms\n", dur0.count());

输出:

sort: 751ms

使用 execution::par,我们看到显著的性能提升:

std::sort(execution::par, v.begin(), v.end());

输出:

sort: 163ms

使用 execution::par_unseq 的改进效果更好:

std::sort(execution::par_unseq, v.begin(), v.end());

输出:

sort: 152ms

在使用并行算法时进行大量测试是个好主意。如果你的算法或谓词不适合并行化,你可能会得到最小的性能提升或意外的副作用。

注意

在撰写本文时,执行策略在 GCC 中支持不佳,LLVM/Clang 还不支持。本食谱在运行 Windows 10 的 6 核 i7 和 Visual C++ 预览版上进行了测试。

使用互斥锁和锁安全地共享数据

术语 互斥锁 指的是对共享资源的互斥访问。互斥锁通常用于避免由于多个执行线程尝试访问相同数据而导致的数据损坏和竞态条件。互斥锁通常会使用 来限制一次只允许一个线程访问。

STL 在 <mutex> 头文件中提供了 互斥锁 类。

如何做…

在这个菜谱中,我们将使用一个简单的 Animal 类来实验对 mutex锁定解锁

  • 我们首先创建一个 mutex 对象:

    std::mutex animal_mutex;
    

mutex 在全局范围内声明,因此它对所有相关对象都是可访问的。

  • 我们的 Animal 类有一个名字和一个朋友列表:

    class Animal {
        using friend_t = list<Animal>;
        string_view s_name{ "unk" };
        friend_t l_friends{};
    public:
        Animal() = delete;
        Animal(const string_view n) : s_name{n} {}
        ...
    }
    

添加和删除朋友将是我们 mutex 的一个有用的测试用例。

  • 等于运算符是我们唯一需要的运算符:

    bool operator==(const Animal& o) const {
        return s_name.data() == o.s_name.data();
    }
    

s_name 成员是一个 string_view 对象,因此我们可以测试其数据存储的地址是否相等。

  • is_friend() 方法测试另一个 Animal 是否在 l_friends 列表中:

    bool is_friend(const Animal& o) const {
        for(const auto& a : l_friends) {
            if(a == o) return true;
        }
        return false;
    }
    
  • find_friend() 方法返回一个 optional,如果找到则包含对 Animal 的迭代器:

    optional<friend_t::iterator>
    find_friend(const Animal& o) noexcept {
        for(auto it{l_friends.begin()};
                it != l_friends.end(); ++it) {
            if(*it == o) return it;
        }
        return {};
    }
    
  • print() 方法打印 s_name 以及 l_friends 列表中每个 Animal 对象的名字:

    void print() const noexcept {
        auto n_animals{ l_friends.size() };
        cout << format("Animal: {}, friends: ", s_name);
        if(!n_animals) cout << "none";
        else {
            for(auto n : l_friends) {
                cout << n.s_name;
                if(--n_animals) cout << ", ";
            }
        }
        cout << '\n';
    }
    
  • add_friend() 方法将一个 Animal 对象添加到 l_friends 列表中:

    bool add_friend(Animal& o) noexcept {
        cout << format("add_friend {} -> {}\n", s_name, 
          o.s_name);
        if(*this == o) return false;
        std::lock_guard<std::mutex> l(animal_mutex);
        if(!is_friend(o)) l_friends.emplace_back(o);
        if(!o.is_friend(*this))
          o.l_friends.emplace_back(*this);
        return true;
    }
    
  • delete_friend() 方法从 l_friends 列表中删除一个 Animal 对象:

    bool delete_friend(Animal& o) noexcept {
        cout << format("delete_friend {} -> {}\n",
            s_name, o.s_name);
        if(*this == o) return false;
        if(auto it = find_friend(o)) 
          l_friends.erase(it.value());
        if(auto it = o.find_friend(*this))
            o.l_friends.erase(it.value());
        return true;
    }
    
  • main() 函数中,我们创建了一些 Animal 对象:

    int main() {
        auto cat1 = std::make_unique<Animal>("Felix");
        auto tiger1 = std::make_unique<Animal>("Hobbes");
        auto dog1 = std::make_unique<Animal>("Astro");
        auto rabbit1 = std::make_unique<Animal>("Bugs");
        ...
    
  • 我们使用 async() 在我们的对象上调用 add_friends(),以在单独的线程中运行它们:

    auto a1 = std::async([&]{ cat1->add_friend(*tiger1); });
    auto a2 = std::async([&]{ cat1->add_friend(*rabbit1); });
    auto a3 = std::async([&]{ rabbit1->add_friend(*dog1); });
    auto a4 = std::async([&]{ rabbit1->add_friend(*cat1); });
    a1.wait();
    a2.wait();
    a3.wait();
    a4.wait();
    

我们调用 wait() 以允许线程在继续之前完成。

  • 我们调用 print() 来查看我们的 Animals 和它们的关系:

    auto p1 = std::async([&]{ cat1->print(); });
    auto p2 = std::async([&]{ tiger1->print(); });
    auto p3 = std::async([&]{ dog1->print(); });
    auto p4 = std::async([&]{ rabbit1->print(); });
    p1.wait();
    p2.wait();
    p3.wait();
    p4.wait();
    
  • 最后,我们调用 delete_friend() 来删除我们的一种关系:

    auto a5 = std::async([&]{ cat1->delete_friend(*rabbit1); });
    a5.wait();
    auto p5 = std::async([&]{ cat1->print(); });
    auto p6 = std::async([&]{ rabbit1->print(); });
    
  • 在这个阶段,我们的输出看起来是这样的:

    add_friend Bugs -> Felix
    add_friend Felix -> Hobbes
    add_friend Felix -> Bugs
    add_friend Bugs -> Astro
    Animal: Felix, friends: Bugs, Hobbes
    Animal: Hobbes, friends: Animal: Bugs, friends: FelixAnimal: Astro, friends: Felix
    , Astro
    Bugs
    delete_friend Felix -> Bugs
    Animal: Felix, friends: Hobbes
    Animal: Bugs, friends: Astro
    

这个输出有些混乱。每次运行时它都会有所不同。有时可能没问题,但不要被它迷惑。我们需要添加一些 mutex 锁来控制对数据的访问。

  • 使用 mutex 的一种方法是通过其 lock()unlock() 方法。让我们将它们添加到 add_friend() 函数中:

    bool add_friend(Animal& o) noexcept {
        cout << format("add_friend {} -> {}\n", s_name, o.s_name);
        if(*this == o) return false;
        animal_mutex.lock();
        if(!is_friend(o)) l_friends.emplace_back(o);
        if(!o.is_friend(*this)) o.l_friends.emplace_back(*this);
        animal_mutex.unlock();
        return true;
    }
    

lock() 方法尝试获取 mutex 的锁。如果 mutex 已经被锁定,它将等待(阻塞执行)直到 mutex 被解锁。

  • 我们还需要给 delete_friend() 添加一个锁:

    bool delete_friend(Animal& o) noexcept {
        cout << format("delete_friend {} -> {}\n",
            s_name, o.s_name);
        if(*this == o) return false;
        animal_mutex.lock();
        if(auto it = find_friend(o)) 
          l_friends.erase(it.value());
        if(auto it = o.find_friend(*this))
            o.l_friends.erase(it.value());
        animal_mutex.unlock();
        return true;
    }
    
  • 现在,我们需要给 print() 添加一个锁,以确保在打印时数据不会被更改:

    void print() const noexcept {
        animal_mutex.lock();
        auto n_animals{ l_friends.size() };
        cout << format("Animal: {}, friends: ", s_name);
        if(!n_animals) cout << "none";
        else {
            for(auto n : l_friends) {
                cout << n.s_name;
                if(--n_animals) cout << ", ";
            }
        }
        cout << '\n';
        animal_mutex.unlock();
    }
    

现在,我们的输出是有意义的:

add_friend Bugs -> Felix
add_friend Bugs -> Astro
add_friend Felix -> Hobbes
add_friend Felix -> Bugs
Animal: Felix, friends: Bugs, Hobbes
Animal: Hobbes, friends: Felix
Animal: Astro, friends: Bugs
Animal: Bugs, friends: Felix, Astro
delete_friend Felix -> Bugs
Animal: Felix, friends: Hobbes
Animal: Bugs, friends: Astro

由于异步操作,您的输出行可能顺序不同。

  • lock()unlock() 方法很少直接调用。std::lock_guard 类通过带有 lock_guardadd_friend() 方法管理锁:

    bool add_friend(Animal& o) noexcept {
        cout << format("add_friend {} -> {}\n", s_name, o.s_name);
        if(*this == o) return false;
        std::lock_guard<std::mutex> l(animal_mutex);
        if(!is_friend(o)) l_friends.emplace_back(o);
        if(!o.is_friend(*this)) 
          o.l_friends.emplace_back(*this);
        return true;
    }
    

lock_guard 对象被创建并保持锁定状态,直到它被销毁。像 lock() 方法一样,lock_guard 也会阻塞,直到获得锁。

  • 让我们将 lock_guard 应用于 delete_friend()print() 方法。

下面是 delete_friend() 的代码:

bool delete_friend(Animal& o) noexcept {
    cout << format("delete_friend {} -> {}\n",
        s_name, o.s_name);
    if(*this == o) return false;
    std::lock_guard<std::mutex> l(animal_mutex);
    if(auto it = find_friend(o)) 
      l_friends.erase(it.value());
    if(auto it = o.find_friend(*this))
        o.l_friends.erase(it.value());
    return true;
}

下面是 print() 的代码:

void print() const noexcept {
    std::lock_guard<std::mutex> l(animal_mutex);
    auto n_animals{ l_friends.size() };
    cout << format("Animal: {}, friends: ", s_name);
    if(!n_animals) cout << "none";
    else {
        for(auto n : l_friends) {
            cout << n.s_name;
            if(--n_animals) cout << ", ";
        }
    }
    cout << '\n';
}

我们的输出保持一致:

add_friend Felix -> Hobbes
add_friend Bugs -> Astro
add_friend Felix -> Bugs
add_friend Bugs -> Felix
Animal: Felix, friends: Bugs, Hobbes
Animal: Astro, friends: Bugs
Animal: Hobbes, friends: Felix
Animal: Bugs, friends: Astro, Felix
delete_friend Felix -> Bugs
Animal: Felix, friends: Hobbes
Animal: Bugs, friends: Astro

和之前一样,由于异步操作,您的输出行可能顺序不同。

它是如何工作的...

重要的是要理解 mutex 并不会锁定数据;它会阻塞执行。正如这个菜谱所示,当 mutex 应用在对象方法中时,它可以用来强制对数据的互斥访问。

当一个线程使用lock()lock_guard锁定mutex时,该线程被称为拥有mutex。任何尝试锁定相同mutex的其他线程都将被阻塞,直到它被所有者解锁。

当任何线程拥有mutex对象时,不能销毁该对象。同样,拥有mutex的线程也不能被销毁。符合 RAII 规范的包装器,如lock_guard,将有助于确保这种情况不会发生。

还有更多...

虽然std::mutex提供了适用于许多目的的独占互斥锁,但 STL 确实提供了一些其他选择:

  • shared_mutex允许多个线程同时拥有互斥锁。

  • recursive_mutex允许一个线程在单个互斥锁上堆叠多个锁。

  • timed_mutex为互斥锁块提供超时。shared_mutexrecursive_mutex也有可用的定时版本。

使用 std::atomic 共享标志和值

std::atomic类封装了一个单个对象,并保证它是原子的。写入原子对象受内存顺序策略控制,并且可以同时发生读取。它通常用于在不同线程之间同步访问。

std::atomic从其模板类型定义了一个原子类型。类型必须是平凡的。如果一个类型占用连续的内存,没有用户定义的构造函数,并且没有虚拟成员函数,则该类型是平凡的。所有原始类型都是平凡的。

虽然可以构造平凡的类型,但std::atomic通常与简单的原始类型一起使用,例如boolintlongfloatdouble

如何做到这一点...

这个配方使用一个简单的函数,该函数遍历一个计数器来演示共享原子对象。我们将生成一群这样的循环作为共享原子值的线程:

  • 原子对象通常放置在全局命名空间中。它们必须对所有需要共享其值的线程都是可访问的:

    std::atomic<bool> ready{};
    std::atomic<uint64_t> g_count{};
    std::atomic_flag winner{};
    

ready对象是一个bool类型,当所有线程都准备好开始计数时,将其设置为true

g_count对象是一个全局计数器。每个线程都会增加它。

winner对象是一个特殊的atomic_flag类型。它用于指示哪个线程先完成。

  • 我们使用一些常量来控制线程的数量和每个线程的循环次数:

    constexpr int max_count{1000 * 1000};
    constexpr int max_threads{100};
    

我将其设置为运行 100 个线程,并在每个线程中计数 1,000,000 次迭代。

  • 为每个线程生成countem()函数。它循环max_count次,并在循环的每次迭代中增加g_count。这就是我们使用原子值的地方:

    void countem (int id) {
        while(!ready) std::this_thread::yield();
        for(int i{}; i < max_count; ++i) ++g_count;
        if(!winner.test_and_set()) {
            std::cout << format("thread {:02} won!\n", 
              id);
        }
    };
    

使用ready原子值来同步线程。每个线程将调用yield(),直到ready值设置为trueyield()函数将执行权交给了其他线程。

for循环的每次迭代都会增加g_count原子值。最终值应该等于max_count * max_threads

循环完成后,使用winner对象的test_and_set()方法报告获胜的线程。test_and_set()atomic_flag类的一个方法。它设置标志并返回设置之前的bool值。

  • 我们之前已经使用过make_commas()函数。它显示带有千位分隔符的数字:

    string make_commas(const uint64_t& num) {
        string s{ std::to_string(num) };
        for(long l = s.length() - 3; l > 0; l -= 3) {
            s.insert(l, ",");
        }
        return s;
    }
    
  • main()函数创建线程并报告结果:

    int main() {
        vector<std::thread> swarm;
        cout << format("spawn {} threads\n", max_threads);
        for(int i{}; i < max_threads; ++i) {
            swarm.emplace_back(countem, i);
        }
        ready = true;
        for(auto& t : swarm) t.join();
        cout << format("global count: {}\n",
            make_commas(g_count));
        return 0;
    }
    

这里,我们创建一个vector<std::thread>对象来保存线程。

for循环中,我们使用emplace_back()vector中创建每个thread

一旦线程被创建,我们设置ready标志,以便线程可以开始它们的循环。

输出:

spawn 100 threads
thread 67 won!
global count: 100,000,000

每次运行它时,都会有一个不同的线程获胜。

它是如何工作的…

std::atomic类封装了一个对象,以在多个线程之间同步访问。

封装的对象必须是一个平凡类型,这意味着它占用连续的内存,没有用户定义的构造函数,并且没有虚成员函数。所有原始类型都是平凡的。

使用一个简单的结构体和atomic

struct Trivial {
    int a;
    int b;
};
std::atomic<Trivial> triv1;

虽然这种用法是可能的,但并不实用。任何超出设置和检索复合值的行为都会失去原子性的好处,最终需要使用一个互斥锁。原子类最适合标量值。

特殊化

atomic类有几个特殊化,用于不同的目的:

  • std::atomic<U*>特殊化包括对原子指针算术操作的支持,包括fetch_add()用于加法和fetch_sub()用于减法。

  • floatdoublelong doublestd::atomic包括对原子浮点算术操作的支持,包括fetch_add()用于加法和fetch_sub()用于减法。

  • std::atomic提供了对额外的原子操作的支持,包括fetch_add()fetch_sub()fetch_and()fetch_or()fetch_xor()

标准别名

STL 为所有标准标量整型类型提供了类型别名。这意味着我们可以在代码中使用这些声明:

std::atomic<bool> ready{};
std::atomic<uint64_t> g_count{};

我们可以使用:

std::atomic_bool ready{};
std::atomic_uint64_t g_count{};

有 46 个标准别名,每个对应一个标准整型类型:

无锁变体

大多数现代架构都提供了执行原子操作的原子 CPU 指令std::atomic应该在硬件支持的情况下使用原子指令。某些原子类型可能在某些硬件上不受支持。std::atomic可能使用一个互斥锁来确保这些特殊化的线程安全操作,导致线程在等待其他线程完成操作时阻塞。使用硬件支持的特殊化被称为无锁,因为它们不需要互斥锁。

is_lock_free()方法检查一个特殊化是否是无锁的:

cout << format("is g_count lock-free? {}\n", 
    g_count.is_lock_free());

输出:

is g_count lock-free? true

这个结果对于大多数现代架构将是true

有几种保证的 std::atomic 无锁变体可用。这些特化保证了每个目的都使用最有效的硬件原子操作:

  • std::atomic_signed_lock_free 是一个对有符号整型最有效无锁特化的别名。

  • std::atomic_unsigned_lock_free 是对无符号整型最有效无锁特化的别名。

  • std::atomic_flag 类提供了一个无锁原子布尔类型。

    重要提示

    当前 Windows 系统不支持 64 位硬件整数,即使在 64 位系统上也是如此。在我实验室的这些系统上测试此代码时,将 std::atomic<uint64_t> 替换为 std::atomic_unsigned_lock_free 导致性能提高了3 倍。在 64 位 Linux 和 Mac 系统上性能没有变化。

还有更多...

当多个线程同时读写变量时,一个线程可能观察到的变化顺序与它们写入的顺序不同。std::memory_order 指定了原子操作周围的内存访问的排序方式。

std::atomic 提供了访问和更改其管理值的方法。与相关运算符不同,这些访问方法提供了指定 memory_order 的参数。例如:

g_count.fetch_add(1, std::memory_order_seq_cst);

在这种情况下,memory_order_seq_cst 指定顺序一致排序。因此,这个 fetch_add() 调用将以顺序一致排序将 1 添加到 g_count 的值。

可能的 memory_order 常量有:

  • memory_order_relaxed:这是一个非同步操作。不施加任何同步或排序约束;仅保证操作的原子性。

  • memory_order_consume:这是一个消费操作。当前线程依赖于值的访问不能在此加载之前进行重排序。这仅影响编译器优化。

  • memory_order_acquire:这是一个获取操作。访问不能在此加载之前进行重排序。

  • memory_order_release:这是一个存储操作。当前线程在此存储之后不能对访问进行重排序。

  • memory_order_acq_rel:这是获取释放的结合。当前线程在此存储之前或之后不能对访问进行重排序。

  • memory_order_seq_cst:这是顺序一致排序,根据上下文可以是获取释放。一个加载执行获取,一个存储执行释放,一个读写/修改同时执行两者。所有线程以相同的顺序观察到所有修改。

如果没有指定 memory_order,则 memory_order_seq_cst 是默认值。

使用 std::call_once 初始化线程

你可能需要在多个线程中运行相同的代码,但只需初始化一次该代码。

一种解决方案是在运行线程之前调用初始化代码。这种方法可以工作,但有一些缺点。通过分离初始化,它可能在不需要时被调用,或者在需要时被遗漏。

std::call_once 函数提供了一个更健壮的解决方案。call_once<mutex> 头文件中。

如何实现...

在这个配方中,我们使用一个打印函数来进行初始化,这样我们可以清楚地看到它何时被调用:

  • 我们将使用一个常量来指定要生成的线程数:

    constexpr size_t max_threads{ 25 };
    

我们还需要一个 std::once_flag 来同步 std::call_once 函数:

std::once_flag init_flag;
  • 我们的初始化函数简单地打印一个字符串,让我们知道它已被调用:

    void do_init(size_t id) {
        cout << format("do_init ({}): ", id);
    }
    
  • 我们的工人函数 do_print() 使用 std::call_once 调用初始化函数然后打印其自己的 id

    void do_print(size_t id) {
        std::call_once(init_flag, do_init, id);
        cout << format("{} ", id);
    }
    
  • main() 中,我们使用 list 容器来管理 thread 对象:

    int main() {
        list<thread> spawn;
        for (size_t id{}; id < max_threads; ++id) {
            spawn.emplace_back(do_print, id);
        }
        for (auto& t : spawn) t.join();
        cout << '\n';
    }
    

我们的结果显示初始化首先发生,并且只发生一次:

do_init (8): 12 0 2 1 9 6 13 10 11 5 16 3 4 17 7 15 8 14 18 19 20 21 22 23 24 

注意,并不总是第一个生成的线程(0)最终调用初始化函数,但它总是第一个被调用。如果你反复运行它,你会看到线程 0 经常得到初始化,但并不总是。在一个核心较少的系统上,你会看到线程 0 在初始化中出现的频率更高。

它是如何工作的...

std::call_once 是一个模板函数,它接受一个标志、一个 可调用对象(函数或函数对象)以及参数包:

template<class Callable, class... Args>
void call_once(once_flag& flag, Callable&& f, Args&&... args);

可调用对象 f 只被调用一次。即使 call_once 从多个线程并发调用,f 也只会被调用一次。

这需要一个 std::once_flag 对象来进行协调。once_flag 构造函数将其状态设置为指示可调用函数尚未被调用。

call_once 调用可调用对象时,对同一 once_flag 的任何其他调用都会被阻塞,直到可调用对象返回。在可调用对象返回后,once_flag 被设置,并且任何后续对 call_once 的调用都不会调用 f

使用 std::condition_variable 解决生产者-消费者问题

简单版本的 生产者-消费者问题 是指有一个进程 生产 数据,另一个进程 消费 数据,使用一个 缓冲区 或容器来存储数据。这需要生产者和消费者之间的协调来管理缓冲区并防止不希望出现的副作用。

如何实现...

在这个配方中,我们考虑了一个简单的解决方案来处理生产者-消费者问题,使用 std::condition_variable 来协调进程:

  • 为了方便起见,我们开始进行一些命名空间和别名声明:

    using namespace std::chrono_literals;
    namespace this_thread = std::this_thread;
    using guard_t = std::lock_guard<std::mutex>;
    using lock_t = std::unique_lock<std::mutex>;
    

lock_guardunique_lock 别名使得使用这些类型时更不容易出错。

  • 我们使用了一些常量:

    constexpr size_t num_items{ 10 };
    constexpr auto delay_time{ 200ms };
    

将这些内容放在一个地方可以使其更安全,也更容易对不同值进行实验。

  • 我们使用这些全局变量来协调数据存储:

    std::deque<size_t> q{};
    std::mutex mtx{};
    std::condition_variable cond{};
    bool finished{};
    

我们使用 deque 来存储数据,作为一个 先进先出FIFO)队列。

mutexcondition_variable 一起使用来协调数据从生产者到消费者的移动。

finished 标志表示没有更多数据。

  • 生产者线程将使用这个函数:

    void producer() {
        for(size_t i{}; i < num_items; ++i) {
            this_thread::sleep_for(delay_time);
            guard_t x{ mtx };
            q.push_back(i);
            cond.notify_all();
        }
        guard_t x{ mtx };
        finished = true;
        cond.notify_all();
    }
    

producer() 函数循环 num_items 次迭代,每次循环都将一个数字推入 deque

我们包括一个 sleep_for() 调用来模拟每次产生值时的延迟。

conditional_variable 需要一个 mutex 锁来操作。我们使用 lock_guard(通过 guard_t 别名)来获取锁,然后将值推入 deque,然后在 conditional_variable 上调用 notify_all()。这告诉消费者线程有新的值可用。

当循环完成时,我们设置 finished 标志并通知消费者线程生产者已完成。

  • 消费者线程等待从生产者那里获取每个值,将其显示在控制台上,然后等待 finished 标志:

    void consumer() {
        while(!finished) {
            lock_t lck{ mtx };
            cond.wait(lck, [] { return !q.empty() || 
              finished; });
            while(!q.empty()) {
                cout << format("Got {} from the queue\n",
                    q.front());
                q.pop_front();
            }
        }
    }
    

wait() 方法等待被生产者通知。它使用 lambda 作为谓词,继续等待直到 deque 不为空或 finished 标志被设置。

当我们获取一个值时,我们显示它,然后从 deque 中弹出它。

  • 我们在 main() 中使用简单的 thread 对象运行它:

    int main() {
        thread t1{ producer };
        thread t2{ consumer };
        t1.join();
        t2.join();
        cout << "finished!\n";
    }
    

输出:

Got 0 from the queue
Got 1 from the queue
Got 2 from the queue
Got 3 from the queue
Got 4 from the queue
Got 5 from the queue
Got 6 from the queue
Got 7 from the queue
Got 8 from the queue
Got 9 from the queue
finished!

注意到每行之间有 200 毫秒的延迟。这告诉我们生产者-消费者协调工作如预期。

它是如何工作的…

生产者-消费者问题需要在缓冲区或容器中写入和读取之间进行协调。在这个例子中,我们的容器是一个 deque<size_t>

std::deque<size_t> q{};

condition_variable 类可以在修改共享变量时阻塞一个线程或多个线程。然后它可以通知其他线程值已可用。

condition_variable 需要一个 mutex 来执行锁定:

std::lock_guard x{ mtx };
q.push_back(i);
cond.notify_all();

std::lock_guard 获取一个锁,这样我们就可以将一个值推入我们的 deque

condition_variable 上的 wait() 方法用于阻塞当前线程,直到它收到通知:

void wait( std::unique_lock<std::mutex>& lock );
void wait( std::unique_lock<std::mutex>& lock,
    Pred stop_waiting );

wait() 的谓词形式等同于:

while (!stop_waiting()) {
    wait(lock);
}

谓词形式用于防止在等待特定条件时产生虚假唤醒。我们在示例中使用 lambda:

cond.wait(lck, []{ return !q.empty() || finished; });

这防止了消费者在 deque 有数据或 finished 标志被设置之前醒来。

condition_variable 类有两个通知方法:

  • notify_one() 解锁一个等待的线程

  • notify_all() 解锁所有等待的线程

我们在示例中使用了 notify_all()。因为只有一个消费者线程,所以任何通知方法都会产生相同的效果。

注意

注意,unique_lock 是支持 condition_variable 对象上的 wait() 方法的 唯一 锁形式。

实现多个生产者和消费者

生产者-消费者问题 实际上是一组问题。如果缓冲区是有界的或无界的,或者有多个生产者、多个消费者或两者都有,解决方案将不同。

让我们考虑一个具有多个生产者、多个消费者和有界(有限容量)缓冲区的案例。这是一个常见的情况。

如何做到这一点…

在这个菜谱中,我们将查看一个具有多个生产者和消费者以及 有界缓冲区 的案例,使用我们在本章中介绍的各种技术:

  • 我们将使用一些常量来提高便利性和可靠性:

    constexpr auto delay_time is a duration object, used with sleep_for().
    
  • consumer_wait 是一个 duration 对象,与 consumer 条件变量一起使用。

  • queue_limt 是缓冲区限制 - deque 中的最大项目数。

  • num_items 是每个 producer 产生的最大项目数。

  • num_producers 是生成的生产者数量。

  • num_producers 是生成的消费者数量。

  • 现在,我们需要一些对象来控制这个过程:

    deque<string> qs is a deque of string that holds the produced objects.
    
    • q_mutex 控制对 deque 的访问。* cv_producer 是一个条件变量,用于协调生产者。* cv_consumer 是一个条件变量,用于协调消费者。* 当所有生产者线程完成后,production_complete 被设置为 true。* producer() 线程运行此函数:
    void producer(const size_t id) {
        for(size_t i{}; i < num_items; ++i) {
            this_thread::sleep_for(delay_time * id);
            unique_lock<mutex> lock(q_mutex);
            cv_producer.wait(lock,
                [&]{ return qs.size() < queue_limit; });
            qs.push_back(format("pid {}, qs {}, 
              item {:02}\n", id, qs.size(), i + 1));
            cv_consumer.notify_all();
        }
    }
    

传递的值 id 是一个顺序号,用于识别生产者。

主要的 for 循环重复 num_item 次。使用 sleep_for() 函数来模拟生产一个项目所需的一些工作。

然后,我们从 q_mutex 获取 unique_lock 并在 cv_producer 上调用 wait(),使用一个 lambda 检查 deque 的大小与 queue_limit 常量的比较。如果 deque 达到最大大小,生产者将等待消费者线程减少 deque 的大小。这代表了生产者的 有界缓冲区 限制。

一旦条件满足,我们将一个 item 推送到 deque。该项目是一个格式化的字符串,包含生产者的 idqs 的大小以及来自循环控制变量的项目编号 (i + 1)。

最后,我们通过在 cv_consumer 条件变量上使用 notify_all() 来通知消费者有新数据可用。

  • consumer() 线程运行此函数:

    void consumer(const size_t id) {
        while(!production_complete) {
            unique_lock<mutex> lock(q_mutex);
            cv_consumer.wait_for(lock, consumer_wait,
                [&]{ return !qs.empty(); });
            if(!qs.empty()){
                cout << format("cid {}: {}", id, 
                  qs.front());
                qs.pop_front();
            }
            cv_producer.notify_all();
        }
    }
    

传递的 id 值是一个顺序号,用于识别消费者。

主要的 while() 循环将继续,直到 production_complete 被设置。

我们从 q_mutex 获取 unique_lock 并在 cv_consumer 条件变量上调用 wait_for(),带有超时和一个 lambda,该 lambda 检查 deque 是否为空。我们需要超时,因为有可能生产者线程已经完成,而一些消费者线程仍在运行,导致 deque 为空。

一旦我们有一个非空的 deque,我们就可以打印 (消费) 一个 item 并将其从 deque 中弹出。

  • main() 中,我们使用 async() 来生成 producerconsumer 线程。async() 符合 RAII 模式,所以如果可能,我通常会优先选择 async() 而不是 threadasync() 返回一个 future 对象,因此我们将保留 future<void> 对象的列表以进行进程管理:

    int main() {
        list<future<void>> producers;
        list<future<void>> consumers;
        for(size_t i{}; i < num_producers; ++i) {
            producers.emplace_back(async(producer, i));
        }
        for(size_t i{}; i < num_consumers; ++i) {
            consumers.emplace_back(async(consumer, i));
        }
        ...
    

我们使用 for 循环来创建 producerconsumer 线程。

  • 最后,我们使用 future 对象的 list 来确定我们的 producerconsumer 线程何时完成:

    for(auto& f : producers) f.wait();
    production_complete = true;
    cout << "producers done.\n";
    for(auto& f : consumers) f.wait();
    cout << "consumers done.\n";
    

我们遍历 producers 容器,调用 wait() 允许 producer 线程完成。然后,我们可以设置 production_complete 标志。我们同样遍历 consumers 容器,调用 wait() 允许 consumer 线程完成。我们可以在这里执行任何最终的解析或完成过程。

  • 输出内容较长,无法全部展示:

    cid 0: pid 0, qs  0, item 01
    cid 0: pid 0, qs  1, item 02
    cid 0: pid 0, qs  2, item 03
    cid 0: pid 0, qs  3, item 04
    cid 0: pid 0, qs  4, item 05
    ...
    cid 4: pid 2, qs  0, item 12
    cid 4: pid 2, qs  0, item 13
    cid 3: pid 2, qs  0, item 14
    cid 0: pid 2, qs  0, item 15
    producers done.
    consumers done.
    

它是如何工作的…

这个方法的精髓在于使用两个 condition_variable 对象来异步控制 producerconsumer 线程:

condition_variable cv_producer{};
condition_variable cv_consumer{};

producer() 函数中,cv_producer 对象获取一个 unique_lock,等待 deque 可用,并在有项目被生产时通知 cv_consumer 对象:

void producer(const size_t id) {
    for(size_t i{}; i < num_items; ++i) {
        this_thread::sleep_for(delay_time * id);
        unique_lock<mutex> lock(q_mutex);
        cv_producer.wait(lock,
            [&]{ return qs.size() < queue_limit; });
        qs.push_back(format("pid {}, qs  {}, item {:02}\n",
            id, qs.size(), i + 1));
        cv_consumer.notify_all();
    }
}

相反,在 consumer() 函数中,cv_consumer 对象获取一个 unique_lock,等待 deque 中有项目,并在有项目被消费时通知 cv_producer 对象:

void consumer(const size_t id) {
    while(!production_complete) {
        unique_lock<mutex> lock(q_mutex);
        cv_consumer.wait_for(lock, consumer_wait,
            [&]{ return !qs.empty(); });
        if(!qs.empty()) {
            cout << format("cid {}: {}", id, qs.front());
            qs.pop_front();
        }
        cv_producer.notify_all();
    }
}

这些互补的锁、等待和通知构成了多个生产者和消费者之间协调的平衡。

第十章:第十章:使用文件系统

STL filesystem库的目的是在各个平台上标准化文件系统操作。filesystem库旨在标准化操作,弥合 POSIX/Unix、Windows 和其他文件系统之间的不规则性。

filesystem库是从相应的Boost库中采纳的,并在 C++17 中纳入了 STL。在撰写本文时,一些系统上其实现仍存在空白,但本章中的菜谱已在 Linux、Windows 和 macOS 文件系统上进行了测试,并分别使用最新的 GCC、MSVC 和 Clang 编译器编译。

该库使用<filesystem>头文件,并且std::filesystem命名空间通常被别名为fs

namespace fs = std::filesystem;

fs::path类是filesystem库的核心。它为不同的环境提供了标准化的文件名和目录路径表示。一个path对象可以表示一个文件、一个目录,甚至是一个,即使是一个不存在或不可能的对象。

在下面的菜谱中,我们将介绍使用filesystem库处理文件和目录的工具:

  • path类特化std::formatter

  • 使用path的操纵函数

  • 列出目录中的文件

  • 使用grep实用工具搜索目录和文件

  • 使用regexdirectory_iterator重命名文件

  • 创建磁盘使用计数器

技术要求

您可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/CPP-20-STL-Cookbook/tree/main/chap10

为 path 类特化 std::formatter

filesystem库中,path类被用来表示文件或目录路径。在符合 POSIX 的系统上,例如 macOS 和 Linux,path对象使用char类型来表示文件名。在 Windows 上,path使用wchar_t。在 Windows 上,coutformat()将不会显示wchar_t字符的原始字符串。这意味着没有简单的方法来编写既使用filesystem库又在 POSIX 和 Windows 之间可移植的代码。

我们可以使用预处理器指令为 Windows 编写特定版本的代码。这可能是一些代码库的合理解决方案,但对于这本书来说,它会变得混乱,并且不符合简单、可移植、可重用的菜谱的目的。

精美的解决方案是编写一个 C++20 的formatter特化,用于path类。这允许我们简单地、可移植地显示path对象。

如何做到这一点...

在这个菜谱中,我们编写了一个formatter特化,用于与fs::path类一起使用:

  • 为了方便,我们首先定义一个命名空间别名。所有的filesystem名称都在std::filesystem命名空间中:

    namespace fs = std::filesystem;
    
  • 我们为path类提供的formatter特化简单而简洁:

    template<>
    struct std::formatter<fs::path>: std::formatter<std::string> {
        template<typename FormatContext>
        auto format(const fs::path& p, FormatContext& ctx) {
            return format_to(ctx.out(), "{}", p.string());
        }
    };
    

在这里,我们正在为fs::path类型特殊化formatter,使用其string()方法获取可打印的表示形式。我们无法使用c_str()方法,因为它在 Windows 上的wchar_t字符上不起作用。

本书中的第一章,“新 C++20 特性”,对formatter特殊化的解释更为完整。

  • main()函数中,我们使用命令行传递一个文件名或路径:

    int main(const int argc, const char** argv) {
        if(argc != 2) {
            fs::path fn{ argv[0] };
            cout << format("usage: {} <path>\n", 
              fn.filename());
            return 0;
        }
        fs::path dir{ argv[1] };
        if(!fs::exists(dir)) {
            cout << format("path: {} does not exist\n", 
              dir);
            return 1;
        }
        cout << format("path: {}\n", dir);
        cout << format("filename: {}\n", dir.filename());
        cout << format("cannonical: {}\n", 
          fs::canonical(dir));
    }
    

argcargv参数是标准的命令行参数。

argv[0]始终是可执行文件的完整目录路径和文件名。如果我们没有正确的参数数量,我们将显示argv[0]中的文件名部分作为我们的用法消息的一部分。

我们在这个例子中使用了一些filesystem函数:

  • fs::exists()函数检查目录或文件是否存在。

  • dir是一个path对象。我们现在可以直接将其传递给format(),使用我们的特殊化来显示路径的字符串表示形式。

  • filename()方法返回一个新的path对象,我们将其直接传递给format(),使用我们的特殊化。

  • fs::cannonical()函数接受一个path对象,并返回一个新的path对象,其中包含规范绝对目录路径。我们直接将此path对象传递给format(),并显示从cannonical()返回的目录路径。

输出:

$ ./formatter ./formatter.cpp
path: ./formatter.cpp
filename: formatter.cpp
cannonical: /home/billw/working/chap10/formatter.cpp

它是如何工作的…

fs::path类在filesystem库中用于表示目录路径和文件名。通过提供formatter特殊化,我们可以轻松地在各个平台上一致地显示path对象。

path类提供了一些有用的方法。我们可以遍历路径以查看其组成部分:

fs::path p{ "~/include/bwprint.h" };
cout << format("{}\n", p);
for(auto& x : p) cout << format("[{}] ", x);
cout << '\n';

输出:

~/include/bwprint.h
[~] [include] [bwprint.h]

迭代器为路径的每个元素返回一个path对象。

我们也可以获取路径的不同部分:

fs::path p{ "~/include/bwprint.h" };
cout << format("{}\n", p);
cout << format("{}\n", p.stem());
cout << format("{}\n", p.extension());
cout << format("{}\n", p.filename());
cout << format("{}\n", p.parent_path());

输出:

~/include/bwprint.h
bwprint
.h
bwprint.h
~/include

我们将在本章中继续使用这个formatter特殊化来处理path类。

使用路径操作函数

filesystem库包括用于操作path对象内容的函数。在本例中,我们将考虑这些工具中的几个。

如何做到这一点…

在这个菜谱中,我们检查了一些操作path对象内容的函数:

  • 我们从namespace指令和我们的formatter特殊化开始。我们在本章的每个菜谱中都这样做:

    namespace fs = std::filesystem;
    template<>
    struct std::formatter<fs::path>: std::formatter<std::string> {
        template<typename FormatContext>
        auto format(const fs::path& p, FormatContext& ctx) {
            return format_to(ctx.out(), "{}", p.string());
        }
    };
    
  • 我们可以使用current_path()函数获取当前工作目录,该函数返回一个path对象:

    cout << format("current_path: {}\n", fs::current_path());
    

输出:

current_path: /home/billw/chap10
  • absolute()函数从相对路径返回绝对路径:

    cout << format("absolute(p): {}\n", fs::absolute(p));
    

输出:

absolute(p): /home/billw/chap10/testdir/foo.txt

absolute()也会取消符号链接的引用。

  • +=运算符将字符串连接到path字符串的末尾:

    cout << format("concatenate: {}\n",
        fs::path{ "testdir" } += "foo.txt");
    

输出:

concatenate: testdirfoo.txt
  • /=运算符将字符串附加到path字符串的末尾并返回一个新的path对象:

    cout << format("append: {}\n",
        fs::path{ "testdir" } /= "foo.txt");
    

输出:

append: testdir/foo.txt
  • canonical()函数返回完整的规范目录路径:

    cout << format("canonical: {}\n",
        fs::canonical(fs::path{ "." } /= "testdir"));
    

输出:

canonical: /home/billw/chap10/testdir
  • equivalent()函数测试两个相对路径是否解析到相同的文件系统实体:

    cout << format("equivalent: {}\n", 
        fs::equivalent("testdir/foo.txt", 
            "testdir/../testdir/foo.txt"));
    

输出:

equivalent: true
  • filesystem 库包含了用于异常处理的 filesystem_error 类:

    try {
        fs::path p{ fp };
        cout << format("p: {}\n", p);
        ...
        cout << format("equivalent: {}\n", 
            fs::equivalent("testdir/foo.txt", 
                "testdir/../testdir/foo.txt"));
    } catch (const fs::filesystem_error& e) {
        cout << format("{}\n", e.what());
        cout << format("path1: {}\n", e.path1());
        cout << format("path2: {}\n", e.path2());
    }
    

filesystem_error 类包含了显示错误信息和获取涉及错误路径的方法。

如果我们在 equivalent() 调用中引入错误,我们可以看到 filesystem_error 类的结果:

cout << format("equivalent: {}\n", 
    fs::equivalent("testdir/foo.txt/x", 
        "testdir/../testdir/foo.txt/y"));

输出:

filesystem error: cannot check file equivalence: No such file or directory [testdir/foo.txt/x] [testdir/../testdir/foo.txt/y]
path1: testdir/foo.txt/x
path2: testdir/../testdir/foo.txt/y

这是 Debian 上使用 GCC 的输出。

filesystem_error 类通过其 path1()path2() 方法提供了额外的详细信息。这些方法返回 path 对象。

  • 你也可以使用 std::error_code 与一些 filesystem 函数一起使用:

    fs::path p{ fp };
    std::error_code e;
    cout << format("canonical: {}\n", 
        fs::canonical(p /= "foo", e));
    cout << format("error: {}\n", e.message());
    

输出:

canonical:
error: Not a directory
  • 尽管 Windows 使用一个非常不同的文件系统,但此代码仍然按预期工作,使用 Windows 文件命名约定:

    p: testdir/foo.txt
    current_path: C:\Users\billw\chap10
    absolute(p): C:\Users\billw\chap10\testdir\foo.txt
    concatenate: testdirfoo.txt
    append: testdir\foo.txt
    canonical: C:\Users\billw\chap10\testdir
    equivalent: true
    

它是如何工作的...

大多数这些函数接受一个 path 对象、一个可选的 std::error_code 对象,并返回一个 path 对象:

path absolute(const path& p);
path absolute(const path& p, std::error_code& ec);

equivalent() 函数接受两个 path 对象并返回一个 bool

bool equivalent( const path& p1, const path& p2 );
bool equivalent( const path& p1, const path& p2,
    std::error_code& ec );

path 类有用于连接和追加的运算符。这两个运算符都是破坏性的。它们修改运算符左侧的 path

p1 += source; // concatenate
p1 /= source; // append

对于右侧,这些运算符可以接受一个 path 对象、一个 string、一个 string_view、一个 C 字符串或一对迭代器。

连接运算符将运算符右侧的字符串添加到 p1 path 字符串的末尾。

追加运算符添加一个分隔符(例如,/\),然后是运算符右侧的字符串到 p1 path 字符串的末尾。

列出目录中的文件

filesystem 库提供了一个包含给定 path 的目录相关信息的 directory_entry 类。我们可以使用它来创建有用的目录列表。

如何做到这一点...

在这个菜谱中,我们使用 directory_entry 类中的信息创建一个目录列表实用工具:

  • 我们从命名空间别名和用于显示 path 对象的 formatter 特化开始:

    namespace fs = std::filesystem;
    template<>
    struct std::formatter<fs::path>: std::formatter<std::string> {
        template<typename FormatContext>
        auto format(const fs::path& p, FormatContext& ctx) {
            return format_to(ctx.out(), "{}", p.string());
        }
    };
    
  • directory_iterator 类使得列出目录变得容易:

    int main() {
        constexpr const char* fn{ "." };
        const fs::path fp{fn};
        for(const auto& de : fs::directory_iterator{fp}) {
            cout << format("{} ", de.path().filename());
        }
        cout << '\n';
    }
    

输出:

chrono Makefile include chrono.cpp working formatter testdir formatter.cpp working.cpp
  • 我们可以添加命令行选项来使它工作,就像 Unix ls

    int main(const int argc, const char** argv) {
        fs::path fp{ argc > 1 ? argv[1] : "." };
        if(!fs::exists(fp)) {
            const auto cmdname { 
              fs::path{argv[0]}.filename() };
            cout << format("{}: {} does not exist\n",
                cmdname, fp);
            return 1;
        }
        if(is_directory(fp)) {
            for(const auto& de : 
              fs::directory_iterator{fp}) {
                cout << format("{} ", 
                  de.path().filename());
            }
        } else {
            cout << format("{} ", fp.filename());
        }
        cout << '\n';
    }
    

如果有命令行参数,我们使用它来创建一个 path 对象。否则,我们使用 "." 表示当前目录。

我们使用 if_exists() 检查路径是否存在。如果不存在,我们打印错误消息并退出。错误消息包括 argv[0] 中的 cmdname

接下来,我们检查 is_directory()。如果我们有一个目录,我们通过 directory_iterator 对每个条目进行循环。directory_iterator 遍历 directory_entry 对象。de.path().filename() 从每个 directory_entry 对象中获取 pathfilename

输出:

$ ./working
chrono Makefile include chrono.cpp working formatter testdir formatter.cpp working.cpp
$ ./working working.cpp
working.cpp
$ ./working foo.bar
working: foo.bar does not exist
  • 如果我们希望输出排序,我们可以将我们的 directory_entry 对象存储在可排序的容器中。

让我们为 fs::directory_entry 创建一个别名。我们会经常使用它。这个别名放在文件的顶部:

using de = fs::directory_entry;

main() 函数的顶部,我们声明了一个 de 对象的 vector

vector<de> entries{};

is_directory() 块内部,我们加载 vector,对其进行排序,然后显示它:

if(is_directory(fp)) {
    for(const auto& de : fs::directory_iterator{fp}) {
        entries.emplace_back(de);
    }
    std::sort(entries.begin(), entries.end());
    for(const auto& e : entries) {
        cout << format("{} ", e.path().filename());
    }
} else { ...

现在输出已经排序:

Makefile chrono chrono.cpp formatter formatter.cpp include testdir working working.cpp

注意到 Makefile 是首先排序的,看起来顺序不对。这是因为大写字母在 ASCII 排序中排在小写字母之前。

  • 如果我们想要不区分大小写的排序,我们需要一个忽略大小写的比较函数。首先,我们需要一个函数来返回小写的 string

    string strlower(string s) {
        auto char_lower = [](const char& c) -> char {
            if(c >= 'A' && c <= 'Z') return c + ('a' - 'A');
            else return c;
        };
        std::transform(s.begin(), s.end(), s.begin(),
            char_lower);
        return s;
    }
    

现在我们需要一个函数来比较两个 directory_entry 对象,使用 strlower()

bool dircmp_lc(const de& lhs, const de& rhs) {
    const auto lhstr{ lhs.path().string() };
    const auto rhstr{ rhs.path().string() };
    return strlower(lhstr) < strlower(rhstr);
}

现在,我们可以在排序中使用 dircmp_lc()

std::sort(entries.begin(), entries.end(), dircmp_lc);

我们现在忽略大小写排序的输出:

chrono chrono.cpp formatter formatter.cpp include Makefile testdir working working.cpp
  • 到目前为止,我们有一个简单的目录列表工具。

filesystem 库中还有更多可用的信息。让我们创建一个 print_dir() 函数来收集更多信息,并以 Unix ls 的样式格式化显示:

void print_dir(const de& dir) {
    using fs::perms;
    const auto fpath{ dir.path() };
    const auto fstat{ dir.symlink_status() };
    const auto fperm{ fstat.permissions() };
    const uintmax_t fsize{ 
        is_regular_file(fstat) ? file_size(fpath) : 0 };
    const auto fn{ fpath.filename() };
    string suffix{};
    if(is_directory(fstat)) suffix = "/";
    else if((fperm & perms::owner_exec) != perms::none) {
        suffix = "*";
    }
    cout << format("{}{}\n", fn, suffix);
}

print_dir() 函数接受一个 directory_entry 参数。然后我们从 directory_entry 对象中检索一些有用的对象:

  • dir.path() 返回一个 path 对象。

  • dir.symlink_status() 返回一个 file_status 对象,不跟随符号链接。

  • fstat.permissions() 返回一个 perms 对象。

  • fsize 是文件的大小,fn 是文件名 string。我们将在使用它们时更详细地查看这些。

Unix ls 使用文件名之后的尾随字符来指示目录或可执行文件。我们用 is_directory() 测试 fstat 对象以查看文件是否是目录,并将尾随的 / 添加到文件名。同样,我们可以用 fperm 对象测试文件是否可执行。

sort() 之后,我们在 for 循环中调用 main() 中的 print_dir()

std::sort(entries.begin(), entries.end(), dircmp_lc);
for(const auto& e : entries) {
    print_dir(e);
}

我们现在的输出看起来像这样:

chrono*
chrono.cpp
formatter*
formatter.cpp
include*
Makefile
testdir/
working*
working.cpp
  • 注意到 include* 条目。实际上这是一个符号链接。让我们通过跟随链接来正确地标记目标路径:

    string suffix{};
    if(is_symlink(fstat)) {
        suffix = " -> ";
        suffix += fs::read_symlink(fpath).string();
    }
    else if(is_directory(fstat)) suffix = "/";
    else if((fperm & perms::owner_exec) != perms::none) suffix = "*";
    

read_symlink() 函数返回一个 path 对象。我们取返回的 path 对象的 string() 表示形式,并将其添加到这个输出的后缀:

chrono*
chrono.cpp
formatter*
formatter.cpp
include -> /Users/billw/include
Makefile
testdir/
working*
working.cpp
  • Unix ls 命令还包括一个字符序列来指示文件的权限位。它看起来像这样:drwxr-xr-x

第一个字符表示文件的类型,例如:d 表示目录,l 表示符号链接,- 表示常规文件。

type_char() 函数返回适当的字符:

char type_char(const fs::file_status& fstat) {
         if(is_symlink(fstat))        return 'l';
    else if(is_directory(fstat))      return 'd';
    else if(is_character_file(fstat)) return 'c';
    else if(is_block_file(fstat))     return 'b';
    else if(is_fifo(fstat))           return 'p';
    else if(is_socket(fstat))         return 's';
    else if(is_other(fstat))          return 'o';
    else if(is_regular_file(fstat))   return '-';
    return '?';
}

字符串的其余部分分为三个三元组。每个三元组包括读取、写入和执行权限位的位,形式为 rwx。如果位未设置,则其字符被替换为 -。有三组权限位,分别对应所有者、组和其它。

string rwx(const fs::perms& p) {
    using fs::perms;
    auto bit2char = &p {
        return (p & bit) == perms::none ? '-' : c;
    };
    return { bit2char(perms::owner_read,   'r'),
             bit2char(perms::owner_write,  'w'),
             bit2char(perms::owner_exec,   'x'),
             bit2char(perms::group_read,   'r'),
             bit2char(perms::group_write,  'w'),
             bit2char(perms::group_exec,   'x'),
             bit2char(perms::others_read,  'r'),
             bit2char(perms::others_write, 'w'),
             bit2char(perms::others_exec,  'x') };
}

perms 对象代表 POSIX 权限位图,但并不一定以位的形式实现。每个条目都必须与 perms::none 值进行比较。我们的 lambda 函数满足这一要求。

我们将这个定义添加到 print_dir() 函数的顶部:

const auto permstr{ type_char(fstat) + rwx(fperm) };

我们更新我们的 format() 字符串:

cout << format("{} {}{}\n", permstr, fn, suffix);

我们得到以下输出:

-rwxr-xr-x chrono*
-rw-r--r-- chrono.cpp
-rwxr-xr-x formatter*
-rw-r--r-- formatter.cpp
lrwxr-xr-x include -> /Users/billw/include
-rw-r--r-- Makefile
drwxr-xr-x testdir/
-rwxr-xr-x working*
-rw-r--r-- working.cpp
  • 现在,让我们添加一个大小字符串。fsize值来自file_size()函数,它返回一个std::uintmax_t类型。这代表目标系统上的最大自然整数。uintmax_t并不总是与size_t相同,并且并不总是容易转换。值得注意的是,在 Windows 上uintmax_t是 32 位,而size_t是 64 位:

    string size_string(const uintmax_t fsize) {
        constexpr const uintmax_t kilo{ 1024 };
        constexpr const uintmax_t mega{ kilo * kilo };
        constexpr const uintmax_t giga{ mega * kilo };
        string s;
        if(fsize >= giga ) return
            format("{}{}", (fsize + giga / 2) / giga, 'G');
        else if (fsize >= mega) return
            format("{}{}", (fsize + mega / 2) / mega, 'M');
        else if (fsize >= kilo) return
            format("{}{}", (fsize + kilo / 2) / kilo, 'K');
        else return format("{}B", fsize);
    }
    

我选择在这个函数中使用 1,024 作为 1K,因为这看起来是 Linux 和 BSD Unix 的默认设置。在生产环境中,这可以是一个命令行选项。

我们在main()中更新我们的format()字符串:

cout << format("{} {:>6} {}{}\n",
    permstr, size_string(fsize), fn, suffix);

现在,我们得到这个输出:

-rwxr-xr-x   284K chrono*
-rw-r--r--     2K chrono.cpp
-rwxr-xr-x   178K formatter*
-rw-r--r--   906B formatter.cpp
lrwxr-xr-x     0B include -> /Users/billw/include
-rw-r--r--   642B Makefile
drwxr-xr-x     0B testdir/
-rwxr-xr-x   197K working*
-rw-r--r--     5K working.cpp

注意

这个实用程序是为 POSIX 系统设计的,例如 Linux 和 macOS。它在 Windows 系统上也能工作,但 Windows 的权限系统与 POSIX 系统不同。在 Windows 上,权限位总是完全设置的。

它是如何工作的...

filesystem库通过其directory_entry和相关类携带丰富的信息。我们在本菜谱中使用的的主要类包括:

  • path类表示一个文件系统路径,根据目标系统的规则。一个path对象可以从一个字符串或另一个路径构造而成。它不需要表示一个现有路径,甚至不是一个可能的路径。路径字符串被解析成组件部分,包括根名称、根目录以及一系列可选的文件名和目录分隔符。

  • directory_entry类携带一个path对象作为成员,并且也可能存储额外的属性,包括硬链接计数、状态、符号链接、文件大小和最后写入时间。

  • file_status类携带有关文件类型和权限的信息。perms对象可能是file_status的一个成员,表示文件的权限结构。

有两个函数可以从file_status检索perms对象。status()函数和symlink_status()函数都返回一个perms对象。区别在于它们处理符号链接的方式。status()函数会跟随符号链接并返回目标文件的permssymlink_status()将返回符号链接本身的perms

更多...

我原本打算在目录列表中包含每个文件的最后写入时间。

directory_entry类有一个成员函数last_write_time(),它返回一个表示文件最后一次写入时间戳的file_time_type对象。

不幸的是,在写作的时候,可用的实现缺乏一种可移植的方式来将file_time_type对象转换为标准的chrono::sys_time,适用于与coutformat()一起使用。

目前,这里有一个与 GCC 兼容的解决方案:

string time_string(const fs::directory_entry& dir) {
    using std::chrono::file_clock;
    auto file_time{ dir.last_write_time() };
    return format("{:%F %T}", 
        file_clock::to_sys(dir.last_write_time()));
}

建议用户代码使用std::chrono::clock_cast而不是file::clock::to_sys来在时钟之间转换时间点。不幸的是,目前可用的实现中没有任何一个为这个目的工作的std::chrono::clock_cast特化。

使用这个time_string()函数,我们可以在print_dir()中添加:

const string timestr{ time_string(dir) };

然后,我们可以更改 format() 字符串:

cout << format("{} {:>6} {} {}{}\n",
    permstr, sizestr, timestr, fn, suffix);

我们得到以下输出:

-rwxr-xr-x   248K 2022-03-09 09:39:49 chrono*
-rw-r--r--     2K 2022-03-09 09:33:56 chrono.cpp
-rwxr-xr-x   178K 2022-03-09 09:39:49 formatter*
-rw-r--r--   906B 2022-03-09 09:33:56 formatter.cpp
lrwxrwxrwx     0B 2022-02-04 11:39:53 include -> /home/billw/include
-rw-r--r--   642B 2022-03-09 14:08:37 Makefile
drwxr-xr-x     0B 2022-03-09 10:38:39 testdir/
-rwxr-xr-x   197K 2022-03-12 17:13:46 working*
-rw-r--r--     5K 2022-03-12 17:13:40 working.cpp

这在 Debian 系统上使用 GCC-11 是可行的。不要期望它在任何其他系统上无需修改就能工作。

使用 grep 工具搜索目录和文件

为了演示遍历和搜索目录结构,我们创建了一个类似于 Unix grep 的简单工具。这个工具使用 recursive_directory_iterator 来遍历嵌套目录,并搜索与正则表达式匹配的文件。

如何做到这一点...

在这个菜谱中,我们编写了一个简单的 grep 工具,它遍历目录以搜索使用正则表达式的文件:

  • 我们从一些便利的别名开始:

    namespace fs = std::filesystem;
    using de = fs::directory_entry;
    using rdit = fs::recursive_directory_iterator;
    using match_v = vector<std::pair<size_t, std::string>>;
    

match_v 是正则表达式匹配结果的一个 vector

  • 我们继续使用 formatter 特化来处理 path 对象:

    template<>
    struct std::formatter<fs::path>: std::formatter<std::string> {
        template<typename FormatContext>
        auto format(const fs::path& p, FormatContext& ctx) {
            return format_to(ctx.out(), "{}", p.string());
        }
    };
    
  • 我们有一个简单的函数用于从文件中获取正则表达式匹配:

    match_v matches(const fs::path& fpath, const regex& re) {
        match_v matches{};
        std::ifstream instrm(fpath.string(),
            std::ios_base::in);
        string s;
        for(size_t lineno{1}; getline(instrm, s); ++lineno) {
            if(std::regex_search(s.begin(), s.end(), re)) {
                matches.emplace_back(lineno, move(s));
            }
        }
        return matches;
    }
    

在这个函数中,我们使用 ifstream 打开文件,使用 getline() 从文件中读取行,并使用 regex_search() 匹配正则表达式。结果收集在 vector 中并返回。

  • 我们现在可以从 main() 中调用这个函数:

    int main() {
        constexpr const char * fn{ "working.cpp" };
        constexpr const char * pattern{ "path" };
        fs::path fpath{ fn };
        regex re{ pattern };
        auto regmatches{ matches(fpath, re) };
        for(const auto& [lineno, line] : regmatches) {
            cout << format("{}: {}\n", lineno, line);
        }
        cout << format("found {} matches\n", regmatches.size());
    }
    

在这个例子中,我们使用常量来表示文件名和正则表达式模式。我们创建 pathregex 对象,调用 matches() 函数,并打印结果。

我们输出带有行号和匹配行的字符串:

25: struct std::formatter<fs::path>: std::formatter<std::string> {
27:     auto format(const fs::path& p, FormatContext& ctx) {
32: match_v matches(const fs::path& fpath, const regex& re) {
34:     std::ifstream instrm(fpath.string(), std::ios_base::in);
62:     constexpr const char * pattern{ "path" };
64:     fs::path fpath{ fn };
66:     auto regmatches{ matches(fpath, re) };
  • 我们的工具需要接受命令行参数作为 regex 模式和文件名。它应该能够遍历目录或接受文件名列表(这可能是命令行通配符扩展的结果)。这需要在 main() 函数中添加一些逻辑。

首先,我们需要一个额外的辅助函数:

size_t pmatches(const regex& re, const fs::path& epath,
        const fs::path& search_path) {
    fs::path target{epath};
    auto regmatches{ matches(epath, re) };
    auto matchcount{ regmatches.size() };
    if(!matchcount) return 0;
    if(!(search_path == epath)) {
        target = 
          epath.lexically_relative(search_path);
    }
    for (const auto& [lineno, line] : regmatches) {
        cout << format("{} {}: {}\n", target, lineno, 
          line);
    }
    return regmatches.size();
}

这个函数调用我们的 matches() 函数并打印结果。它接受一个 regex 对象和两个 path 对象。epath 是目录搜索的结果,而 search_path 是搜索的目录本身。我们将在 main() 中设置这些。

  • main() 中,我们使用 argcargv 命令行参数,并声明了一些变量:

    int main(const int argc, const char** argv) {
        const char * arg_pat{};
        regex re{};
        fs::path search_path{};
        size_t matchcount{};
        ...
    

这里声明的变量有:

  • arg_pat 是用于命令行中的正则表达式模式。

  • reregex 对象。

  • search_path 是命令行搜索路径参数。

  • matchcount 用于计算匹配的行数。

  • 继续在 main() 中,如果没有参数,则打印一个简短的用法字符串:

    if(argc < 2) {
        auto cmdname{ fs::path(argv[0]).filename() };
        cout << format("usage: {} pattern [path/file]\n", 
            cmdname);
        return 1;
    }
    

argv[1] 总是命令行中的调用命令。cmdname 使用 filename() 方法返回一个只包含调用命令路径文件名的 path

  • 接下来,我们解析正则表达式。我们使用 try-catch 块来捕获 regex 解析器可能产生的任何错误:

    arg_pat = argv[1];
    try {
        re = regex(arg_pat, std::regex_constants::icase);
    } catch(const std::regex_error& e) {
        cout << format("{}: {}\n", e.what(), arg_pat);
        return 1;
    }
    

我们使用 icase 标志来告诉 regex 解析器忽略大小写。

  • 如果 argc == 2,我们只有一个参数,我们将其视为正则表达式模式,并使用当前目录作为搜索路径:

    if(argc == 2) {
        search_path = ".";
            for (const auto& entry : rdit{ search_path }) {
            const auto epath{ entry.path() };
            matchcount += pmatches(re, epath, 
              search_path);
        }
    }
    

rditrecursive_directory_iterator类的别名,它从起始路径遍历目录树,为遇到的每个文件返回一个directory_entry对象。然后我们创建一个path对象并调用pmatches()来遍历文件并打印任何正则表达式匹配。

  • main()的这个点上,我们知道argc>=2。现在,我们处理命令行上有一个或多个文件路径的情况:

    int count{ argc - 2 };
    while(count-- > 0) {
        fs::path p{ argv[count + 2] };
        if(!exists(p)) {
            cout << format("not found: {}\n", p);
            continue;
        }
        if(is_directory(p)) {
            for (const auto& entry : rdit{ p }) {
                const auto epath{ entry.path() };
                matchcount += pmatches(re, epath, p);
            }
        } else {
            matchcount += pmatches(re, p, p);
        }
    }
    

while循环处理命令行上的搜索模式之后的一个或多个参数。它检查每个文件名以确保它存在。然后,如果它是一个目录,它使用rdit别名(recursive_directory_iterator类)来遍历目录并调用pmatches()来打印文件中的任何模式匹配。

如果是单个文件,它会在该文件上调用pmatches()

  • 我们可以用一个搜索模式作为参数运行我们的grep克隆:

    $ ./bwgrep using
    dir.cpp 12: using std::format;
    dir.cpp 13: using std::cout;
    dir.cpp 14: using std::string;
    ...
    formatter.cpp 10: using std::cout;
    formatter.cpp 11: using std::string;
    formatter.cpp 13: using namespace std::filesystem;
    found 33 matches
    

我们可以用第二个参数作为搜索目录来运行它:

$ ./bwgrep using ..
chap04/iterator-adapters.cpp 12: using std::format;
chap04/iterator-adapters.cpp 13: using std::cout;
chap04/iterator-adapters.cpp 14: using std::cin;
...
chap01/hello-version.cpp 24: using std::print;
chap01/chrono.cpp 8: using namespace std::chrono_literals;
chap01/working.cpp 15: using std::cout;
chap01/working.cpp 34:     using std::vector;
found 529 matches

注意,它遍历目录树来查找子目录中的文件。

或者我们可以用一个单个文件参数来运行它:

$ ./bwgrep using bwgrep.cpp
bwgrep.cpp 13: using std::format;
bwgrep.cpp 14: using std::cout;
bwgrep.cpp 15: using std::string;
...
bwgrep.cpp 22: using rdit = fs::recursive_directory_iterator;
bwgrep.cpp 23: using match_v = vector<std::pair<size_t, std::string>>;
found 9 matches

它是如何工作的…

虽然这个实用程序的主要任务是正则表达式匹配,但我们专注于递归处理文件目录的技术。

recursive_directory_iterator对象与directory_iterator可互换,除了recursive_directory_iterator递归地遍历每个子目录的所有条目。

参见…

更多关于正则表达式的信息,请参阅第七章中的配方使用正则表达式解析字符串字符串、流和格式化

使用正则表达式和目录迭代器重命名文件

这是一个简单的实用程序,使用正则表达式重命名文件。它使用directory_iterator在目录中查找文件,并使用fs::rename()来重命名它们。

如何做到这一点…

在这个配方中,我们创建了一个使用正则表达式的文件重命名实用程序:

  • 我们首先定义一些便利别名:

    namespace fs = std::filesystem;
    using dit = fs::directory_iterator;
    using pat_v = vector<std::pair<regex, string>>;
    

pat_v别名是一个用于正则表达式的vector

  • 我们还继续使用path对象的formatter特化:

    template<>
    struct std::formatter<fs::path>: std::formatter<std::string> {
        template<typename FormatContext>
        auto format(const fs::path& p, FormatContext& ctx) {
            return format_to(ctx.out(), "{}", p.string());
        }
    };
    
  • 我们有一个函数用于将正则表达式替换应用到文件名字符串:

    string replace_str(string s, const pat_v& replacements) {
        for(const auto& [pattern, repl] : replacements) {
            s = regex_replace(s, pattern, repl);
        }
        return s;
    }
    

注意,我们遍历一个包含模式/替换对的vector,依次应用正则表达式。这允许我们堆叠我们的替换。

  • main()中,我们首先检查命令行参数:

    int main(const int argc, const char** argv) {
        pat_v patterns{};
        if(argc < 3 || argc % 2 != 1) {
            fs::path cmdname{ fs::path{argv[0]}.filename() };
            cout << format(
                "usage: {} [regex replacement] ...\n", 
                cmdname);
            return 1;
        }
    

命令行接受一个或多个字符串对。每个字符串对包括一个正则表达式(正则表达式)后跟一个替换。

  • 现在我们用regexstring对象填充vector

    for(int i{ 1 }; i < argc; i += 2) {
        patterns.emplace_back(argv[i], argv[i + 1]);
    }
    

pair构造函数在原地构造regexstring对象,从命令行传递的 C-字符串。这些通过emplace_back()方法添加到vector中。

  • 我们使用directory_iterator对象在当前目录中搜索:

    for(const auto& entry : dit{fs::current_path()}) {
        fs::path fpath{ entry.path() };
        string rname{
            replace_str(fpath.filename().string(), 
    patterns) };
        if(fpath.filename().string() != rname) {
            fs::path rpath{ fpath };
            rpath.replace_filename(rname);
            if(exists(rpath)) {
                cout << "Error: cannot rename - destination file exists.\n";
            } else {
                fs::rename(fpath, rpath);
                cout << format(
                    "{} -> {}\n", 
                    fpath.filename(), 
                    rpath.filename());
            }
        }
    }
    

在这个 for 循环中,我们调用 replace_str() 来获取替换后的文件名,然后检查新名称不是目录中文件的重复项。我们在 path 对象上使用 replace_filename() 方法来创建具有新文件名的 path,并使用 fs::rename() 来重命名文件。

  • 为了测试这个实用程序,我创建了一个包含一些文件的目录以进行重命名:

    $ ls
    bwfoo.txt bwgrep.cpp chrono.cpp dir.cpp formatter.cpp path-ops.cpp working.cpp
    
  • 我们可以做一些简单的事情,比如将 .cpp 改为 .Cpp

    $ ../rerename .cpp .Cpp
    dir.cpp -> dir.Cpp
    path-ops.cpp -> path-ops.Cpp
    bwgrep.cpp -> bwgrep.Cpp
    working.cpp -> working.Cpp
    formatter.cpp -> formatter.Cpp
    

让我们再次更改它们:

$ ../rerename .Cpp .cpp
formatter.Cpp -> formatter.cpp
bwgrep.Cpp -> bwgrep.cpp
dir.Cpp -> dir.cpp
working.Cpp -> working.cpp
path-ops.Cpp -> path-ops.cpp
  • 使用标准的正则表达式语法,我可以将 "bw" 添加到每个文件名的开头:

    $ ../rerename '^' bw
    bwgrep.cpp -> bwbwgrep.cpp
    chrono.cpp -> bwchrono.cpp
    formatter.cpp -> bwformatter.cpp
    bwfoo.txt -> bwbwfoo.txt
    working.cpp -> bwworking.cpp
    

注意,它甚至重命名了那些已经以 "bw" 开头的文件。让我们让它不要这样做。首先,我们恢复文件名:

$ ../rerename '^bw' ''
bwbwgrep.cpp -> bwgrep.cpp
bwworking.cpp -> working.cpp
bwformatter.cpp -> formatter.cpp
bwchrono.cpp -> chrono.cpp
bwbwfoo.txt -> bwfoo.txt

现在我们使用一个检查文件名是否以 "bw" 开头的正则表达式:

$ ../rerename '^(?!bw)' bw
chrono.cpp -> bwchrono.cpp
formatter.cpp -> bwformatter.cpp
working.cpp -> bwworking.cpp

因为我们使用正则表达式/替换字符串的 vector,所以我们可以堆叠多个替换:

$ ../rerename foo bar '\.cpp$' '.xpp' grep grok
bwgrep.cpp -> bwgrok.xpp
bwworking.cpp -> bwworking.xpp
bwformatter.cpp -> bwformatter.xpp
bwchrono.cpp -> bwchrono.xpp
bwfoo.txt -> bwbar.txt

它是如何工作的…

此食谱中的 filesystem 部分使用 directory_iterator 返回当前目录中每个文件的 directory_entry 对象:

for(const auto& entry : dit{fs::current_path()}) {
    fs::path fpath{ entry.path() };
    ...
}

然后,我们从 directory_entry 对象构造一个 path 对象来处理文件。

我们在 path 对象上使用 replace_filename() 方法来创建重命名操作的目标:

fs::path rpath{ fpath };
rpath.replace_filename(rname);

在这里,我们创建一个副本并更改其名称,这样我们就有两个用于重命名操作的版本:

fs::rename(fpath, rpath);

在正则表达式的这一侧,我们使用 regex_replace(),它使用正则表达式语法在字符串中执行替换:

s = regex_replace(s, pattern, repl);

正则表达式语法非常强大。它甚至允许替换包括搜索字符串的部分:

$ ../rerename '(bw)(.*\.)(.*)$' '$3$2$1'
bwgrep.cpp -> cppgrep.bw
bwfoo.txt -> txtfoo.bw

通过在搜索模式中使用括号,我可以轻松地重新排列文件名的一部分。

参见…

更多关于正则表达式的信息,请参阅 第七章 中的食谱 使用正则表达式解析字符串字符串、流和格式化

创建磁盘使用计数器

这是一个简单的实用程序,它计算目录及其子目录中每个文件的总大小。它可以在 POSIX/Unix 和 Windows 文件系统上运行。

如何做到这一点…

这个食谱是一个实用程序,用于报告目录及其子目录中每个文件的大小,以及总大小。我们将重用本章其他地方使用的一些函数:

  • 我们从一些便利别名开始:

    namespace fs = std::filesystem;
    using dit = fs::directory_iterator;
    using de = fs::directory_entry;
    
  • 我们还使用了我们的 format 特化 fs::path 对象:

    template<>
    struct std::formatter<fs::path>: std::formatter<std::string> {
        template<typename FormatContext>
        auto format(const fs::path& p, FormatContext& ctx) {
            return format_to(ctx.out(), "{}", p.string());
        }
    };
    
  • 为了报告目录的大小,我们将使用这个 make_commas() 函数:

    string make_commas(const uintmax_t& num) {
        string s{ std::to_string(num) };
        for(long l = s.length() - 3; l > 0; l -= 3) {
            s.insert(l, ",");
        }
        return s;
    }
    

我们之前已经使用过这个。它在每个第三个字符之前插入一个逗号。

  • 为了对目录进行排序,我们需要一个将字符串转换为小写的函数:

    string strlower(string s) {
        auto char_lower = [](const char& c) -> char {
            if(c >= 'A' && c <= 'Z') return c + ('a' – 
               'A');
            else return c;
        };
        std::transform(s.begin(), s.end(), s.begin(), 
          char_lower);
        return s;
    }
    
  • 我们需要一个比较谓词来按 path 名称的小写对 directory_entry 对象进行排序:

    bool dircmp_lc(const de& lhs, const de& rhs) {
        const auto lhstr{ lhs.path().string() };
        const auto rhstr{ rhs.path().string() };
        return strlower(lhstr) < strlower(rhstr);
    }
    
  • size_string() 返回用于报告文件大小的缩写值,单位为千兆字节、兆字节、千字节或字节:

    string size_string(const uintmax_t fsize) {
        constexpr const uintmax_t kilo{ 1024 };
        constexpr const uintmax_t mega{ kilo * kilo };
        constexpr const uintmax_t giga{ mega * kilo };
        if(fsize >= giga ) return format("{}{}",
            (fsize + giga / 2) / giga, 'G');
        else if (fsize >= mega) return format("{}{}",
            (fsize + mega / 2) / mega, 'M');
        else if (fsize >= kilo) return format("{}{}",
            (fsize + kilo / 2) / kilo, 'K');
        else return format("{}B", fsize);
    }
    
  • entry_size() 返回文件的大小,如果是目录,则返回目录的递归大小:

    uintmax_t entry_size(const fs::path& p) {
        if(fs::is_regular_file(p)) return 
           fs::file_size(p);
        uintmax_t accum{};
        if(fs::is_directory(p) && ! fs::is_symlink(p)) {
            for(auto& e : dit{ p }) {
                accum += entry_size(e.path());
            }
        }
        return accum;
    }
    
  • main()中,我们开始声明并测试是否有有效的目录要搜索:

    int main(const int argc, const char** argv) {
        auto dir{ argc > 1 ? 
            fs::path(argv[1]) : fs::current_path() };
        vector<de> entries{};
        uintmax_t accum{};
        if (!exists(dir)) {
            cout << format("path {} does not exist\n", 
              dir);
            return 1;
        }
        if(!is_directory(dir)) {
            cout << format("{} is not a directory\n", 
              dir);
            return 1;
        }
        cout << format("{}:\n", absolute(dir));
    

对于我们的目录路径dir,如果我们有一个参数,我们使用argv[1];否则,我们使用current_path()表示当前目录。然后我们为我们的使用计数器设置一个环境:

  • directory_entry对象的vector用于对响应进行排序。

  • accum用于累计我们最终的总大小值。

  • 在检查目录之前,我们确保dir存在并且是一个目录。

  • 接下来,一个简单的循环来填充vector。一旦填充完成,我们使用我们的dircmp_lc()函数作为比较谓词对entries进行排序:

    for (const auto& e : dit{ dir }) {
        entries.emplace_back(e.path());
    }
    std::sort(entries.begin(), entries.end(), dircmp_lc);
    
  • 现在一切都已经设置好了,我们可以从排序的directory_entry对象的vector中累计结果:

    for (const auto& e : entries) {
        fs::path p{ e };
        uintmax_t esize{ entry_size(p) };
        string dir_flag{};
        accum += esize;
        if(is_directory(p) && !is_symlink(p)) dir_flag = 
           " ![](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp20-stl-cb/img/6.png)";
        cout << format("{:>5} {}{}\n",
            size_string(esize), p.filename(), dir_flag);
    }
    cout << format("{:->25}\n", "");
    cout << format("total bytes: {} ({})\n",
        make_commas(accum), size_string(accum));
    

entry_size()的调用返回由directory_entry对象表示的文件或目录的大小。

如果当前条目是一个目录(而不是一个符号链接),我们添加一个符号来表示它是一个目录。我选择了一个倒三角形。你可以在这里使用任何东西。

循环完成后,我们以字节为单位显示累计的大小,并用逗号分隔,以及来自size_string()的缩写表示法。

我们输出:

/home/billw/working/cpp-stl-wkbk/chap10:
 327K bwgrep
   3K bwgrep.cpp
 199K dir
   4K dir.cpp
 176K formatter
 905B formatter.cpp
   0B include
   1K Makefile
 181K path-ops
   1K path-ops.cpp
 327K rerename
   2K rerename.cpp
11K testdir ![](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp20-stl-cb/img/6.png)
11K testdir-backup ![](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp20-stl-cb/img/6.png)
 203K working
   3K working.cpp
-------------------------
total bytes: 1,484,398 (1M)

它是如何工作的…

fs::file_size()函数返回一个uintmax_t值,它表示文件的大小,作为给定平台上的最大自然无符号整数。虽然这通常在大多数 64 位系统上是一个 64 位整数,但有一个值得注意的例外是 Windows,它使用 32 位整数。这意味着虽然在某些系统上size_t可能适用于此值,但在 Windows 上它无法编译,因为它可能尝试将 64 位值提升为 32 位值。

entry_size()函数接受一个path对象并返回一个uintmax_t值:

uintmax_t entry_size(const fs::path& p) {
    if(fs::is_regular_file(p)) return fs::file_size(p);
    uintmax_t accum{};
    if(fs::is_directory(p) && !fs::is_symlink(p)) {
        for(auto& e : dit{ p }) {
            accum += entry_size(e.path());
        }
    }
    return accum;
}

函数检查是否为常规文件,并返回文件的大小。否则,它检查是否为既不是符号链接的目录。我们只想获取目录中文件的大小,所以不想跟随符号链接。(符号链接也可能导致引用循环,导致失控状态。)

如果我们找到一个目录,我们将遍历它,为遇到的每个文件调用entry_size()。这是一个递归循环,所以我们最终得到目录的大小。

第十一章:第十一章:一些更多想法

我们在这本书中学到了一些有用的技术,包括可选值、容器、迭代器、算法、智能指针等。我们看到了这些概念的应用示例,并有机会进行实验并将它们应用于一些小型项目。现在让我们将这些技术应用于一些更多实际的想法。

在本章中,我们将介绍以下食谱:

  • 为搜索建议创建一个 trie 类

  • 计算两个向量的误差和

  • 构建自己的算法:split

  • 利用现有算法:gather

  • 移除连续空白

  • 将数字转换为文字

技术要求

你可以在 GitHub 上找到本章的代码文件 github.com/PacktPublishing/CPP-20-STL-Cookbook/tree/main/chap11

为搜索建议创建一个 trie 类

trie,有时称为 前缀树,是一种搜索树类型,常用于预测文本和其他搜索应用。trie 是一种递归结构,旨在进行深度优先搜索,其中每个 节点 既是键又是另一个 trie。

一个常见的用例是 字符串 trie,其中每个节点是句子中的一个字符串。例如:

图 11.1 – 字符串 trie

图 11.1 – 字符串 trie

我们通常从 trie 的 头部 开始搜索,寻找以特定单词开头的句子。在这个例子中,当我搜索 all 时,我得到三个节点:youthealong。如果搜索 love,我得到 meis

字符串 trie 通常用于创建搜索建议。在这里,我们将使用 std::map 来实现字符串 trie 的结构。

如何做…

在这个食谱中,我们创建了一个递归 trie 类,它在一个 std::map 容器中存储节点。这是一个针对小内存 trie 的简单解决方案。这是一个相当大的类,所以我们只在这里展示重要的部分。

对于完整的类,请参阅源代码 github.com/PacktPublishing/CPP-20-STL-Cookbook/blob/main/chap11/trie.cpp

  • 我们有一个便利别名:

    using ilcstr = initializer_list<const char *>;
    

我们在搜索 trie 时使用 ilcstr

  • 我们将把这个类放在一个私有命名空间中,以避免冲突:

    namespace bw {
        using std::map;
        using std::deque;
        using std::initializer_list;
    

在这个命名空间中,我们为了方便起见有一些 using 语句。

  • 这个类本身被称为 trie。它有三个数据成员:

    class trie {
        using get_t = deque<deque<string>>;
        using nodes_t = map<string, trie>;
        using result_t = std::optional<const trie*>;
        nodes_t nodes{};
        mutable get_t result_dq{};
        mutable deque<string> prefix_dq{};
    

trie 类有几个局部类型别名:

  • get_t 是一个 dequedequestring,用于字符串结果。

  • nodes_t 是一个以 string 为键的 trie 类的映射。

  • result_t 是一个指向 trie 的指针的 optional,用于返回搜索结果。一个空的 trie 是一个有效的结果,所以我们使用 optional 值。

nodes 对象用于存储节点的递归 map,其中 trie 上的每个节点都是另一个 trie

  • 公共接口经常调用私有接口中的实用函数。例如,insert() 方法接受一个 initializer_list 对象并调用私有函数 _insert()

    void insert(const ilcstr& il) {
        _insert(il.begin(), il.end());
    }
    

私有的 _insert() 函数执行插入元素的工作:

template <typename It>
void _insert(It it, It end_it) {
    if(it == end_it) return;
    nodes[*it]._insert(++it, end_it);
}

这便于进行必要的递归函数调用以导航 trie。注意,引用 map 中未出现的关键字会创建一个具有该键的空元素。因此,在 nodes 元素上调用 _insert() 的行,如果该元素不存在,将创建一个空的 trie 对象。

  • get() 方法返回一个 get_t 对象,它是 dequedequestring 的别称。这允许我们返回多组结果:

    get_t& get() const {
        result_dq.clear();
        deque<string> dq{};
        _get(dq, result_dq);
        return result_dq;
    }
    

get() 方法调用私有的 _get() 函数,该函数递归遍历 trie

void _get(deque<string>& dq, get_t& r_dq) const {
    if(empty()) {
        r_dq.emplace_back(dq);
        dq.clear();
    }
    for(const auto& p : nodes) {
        dq.emplace_back(p.first);
        p.second._get(dq, r_dq);
    }
}
  • find_prefix() 函数返回一个包含所有与部分字符串匹配的 deque

    deque<string>& find_prefix(const char * s) const {
        _find_prefix(s, prefix_dq);
        return prefix_dq;
    }
    

公共接口调用私有函数 _find_prefix()

void _find_prefix(const string& s, auto& pre_dq) const {
    if(empty()) return;
    for(const auto& [k, v] : nodes) {
        if(k.starts_with(s)) {
            pre_dq.emplace_back(k);
            v._find_prefix(k, pre_dq);
        }
    }
}

私有的 _find_prefix() 函数递归遍历 trie,将前缀与每个键的开头进行比较。starts_with() 方法是 C++20 中的新功能。在较旧的 STL 中,您可以使用 find() 方法并检查返回值是否为 0

if(k.find(s) == 0) {
    ...
  • search() 函数返回一个 optional<const trie*>,别名为 result_t。它有两个重载版本:

    result_t search(const ilcstr& il) const {
        return _search(il.begin(), il.end());
    }
    result_t search(const string& s) const {
        const ilcstr il{s.c_str()};
        return _search(il.begin(), il.end());
    }
    

这些方法将迭代器传递给私有成员函数 _search(),该函数执行搜索工作:

template <typename It>
result_t _search(It it, It end_it) const {
    if(it == end_it) return {this};
    auto found_it = nodes.find(*it);
    if(found_it == nodes.end()) return {};
    return found_it->second._search(++it, end_it);
}

_search() 函数递归搜索,直到找到匹配项,然后返回 result_t 对象中的一个节点。如果没有找到匹配项,它返回非值 optional

  • 我们还有两个重载版本的 print_trie_prefix() 函数。此函数从用作搜索键的前缀打印 trie 的内容。一个版本使用 string 作为前缀,另一个使用 C-字符串的 initializer_list

    void print_trie_prefix(const bw::trie& t,
            const string& prefix) {
        auto& trie_strings = t.get();
        cout << format("results for \"{}...\":\n", prefix);
        for(auto& dq : trie_strings) {
            cout << format("{} ", prefix);
            for(const auto& s : dq) cout << format("{} ", s);
            cout << '\n';
        }
    }
    void print_trie_prefix(const bw::trie& t,
            const ilcstr & prefix) {
        string sprefix{};
        for(const auto& s : prefix) sprefix += 
            format("{} ", s);
        print_trie_prefix(t, sprefix);
    }
    

这些函数调用 get() 成员函数从 trie 中检索结果。

  • 现在,我们可以在 main() 函数中测试 trie 类。首先,我们声明一个 trie 并插入一些句子:

    int main() {
        bw::trie ts;
        ts.insert({ "all", "along", "the", "watchtower" });
        ts.insert({ "all", "you", "need", "is", "love" });
        ts.insert({ "all", "shook", "up" });
        ts.insert({ "all", "the", "best" });
        ts.insert({ "all", "the", "gold", "in",
            "california" });
        ts.insert({ "at", "last" });
        ts.insert({ "love", "the", "one", "you're",        "with" });
        ts.insert({ "love", "me", "do" });
        ts.insert({ "love", "is", "the", "answer" });
        ts.insert({ "loving", "you" });
        ts.insert({ "long", "tall", "sally" });
        ...
    

insert() 调用传递一个包含句子中所有字符串的 initializer_list。句子中的每个字符串都插入到 trie 的层次结构中。

  • 现在我们可以搜索 trie。这里是一个简单的搜索,搜索单个字符串 "love"

    const auto prefix = {"love"};
    if (auto st = ts.search(prefix); st.have_result) {
        print_trie_prefix(*st.t, prefix);
    }
    cout << '\n';
    

这将 ts.search() 与一个包含一个 C-字符串的 initializer_list(称为 prefix)一起调用。然后,将结果以及 prefix 传递给 print_trie_prefix() 函数。

输出是:

results for "love...":
love is the answer
love me do
love the one you're with
  • 这里是一个搜索两个字符串前缀的例子:

    const auto prefix = {"all", "the"};
    if (auto st = ts.search(prefix); st.have_result) {
        print_trie_prefix(*st.t, prefix);
    }
    cout << '\n';
    

输出:

results for "all the ...":
all the  best
all the  gold in california
  • 这里是一个使用 find_prefix() 函数搜索部分前缀的例子:

    const char * prefix{ "lo" };
    auto prefix_dq = ts.find_prefix(prefix);
    for(const auto& s : prefix_dq) {
        cout << format("match: {} -> {}\n", prefix, s);
        if (auto st = ts.search(s); st.have_result) {
            print_trie_prefix(*st.t, s);
        }
    }
    cout << '\n';
    

输出:

match: lo -> long
results for "long...":
long tall sally
match: lo -> love
results for "love...":
love is the answer
love me do
love the one you're with
match: lo -> loving
results for "loving...":
loving you

find_prefix() 搜索返回了几个结果,我们将每个结果传递给其自己的搜索,从而为每个结果产生了多个结果。

它是如何工作的…

trie 类的数据存储在递归的 map 容器中。map 中的每个节点都包含另一个 trie 对象,该对象反过来又有一个自己的 map 节点。

using nodes_t = map<string, trie>

_insert() 函数接受 beginend 迭代器,并使用它们递归地在新节点上调用 _insert()

template <typename It>
void _insert(It it, It end_it) {
    if(it == end_it) return;
    nodes[*it]._insert(++it, end_it);
}

同样,_search() 函数递归地在其找到的节点上调用 _search()

template <typename It>
result_t _search(It it, It end_it) const {
    if(it == end_it) return {this};
    auto found_it = nodes.find(*it);
    if(found_it == nodes.end()) return {};
    return found_it->second._search(++it, end_it);
}

使用 std::map 的这种递归方法使我们能够简洁且高效地实现 trie 类。

计算两个向量的误差和

给定两个仅量化或分辨率不同的相似向量,我们可以使用 inner_product() 算法来计算一个 误差和,定义为:

图 11.2 – 错误和定义

图 11.2 – 错误和定义

图 11.2 – 错误和定义

其中 e 是误差和,即两个向量中一系列点之间差值的平方和。

我们可以使用来自 <numeric> 头文件的 inner_product() 算法来计算两个向量之间的误差和。

如何做到这一点...

在这个菜谱中,我们定义了两个向量,每个向量都有一个 正弦波。一个 vector 的值类型为 double,另一个的值类型为 int。这给了我们量化不同的向量,因为 int 类型不能表示分数值。然后我们使用 inner_product() 来计算两个向量之间的误差和:

  • 在我们的 main() 函数中,我们定义了我们的向量和方便的 index 变量:

    int main() {
        constexpr size_t vlen{ 100 };
        vector<double> ds(vlen);
        vector<int> is(vlen);
        size_t index{};
        ...
    

dsdouble 正弦波的 vectorisint 正弦波的 vector。每个 vector 有 100 个元素来存储正弦波。index 变量用于初始化 vector 对象。

  • 我们使用循环和 lambda 在 double 正弦波的 vector 中生成正弦波:

    auto sin_gen = [&index]{
      return 5.0 * sin(index++ * 2 * pi / 100);
    };
    for(auto& v : ds) v = sin_gen();
    

Lambda 捕获了对 index 变量的引用,以便它可以递增。

pi 常量来自 std::numbers 库。

  • 现在我们有一个 double 正弦波,我们可以用它来推导出 int 版本:

    index = 0;
    for(auto& v : is) {
        v = static_cast<int>(round(ds.at(index++)));
    }
    

这将 ds 中的每个点取整,将其转换为 int,并在 is 容器中更新其位置。

  • 我们使用简单的循环显示我们的正弦波:

    for(const auto& v : ds) cout << format("{:-5.2f} ", v);
    cout << "\n\n";
    for(const auto& v : is) cout << format("{:-3d} ", v);
    cout << "\n\n";
    

我们的输出是两个容器中的正弦波数据点:

0.00  0.31  0.63  0.94  1.24  1.55  1.84  2.13  2.41
0.00  0.31  0.63  0.94  1.24  1.55  1.84  2.13  2.41
2.68  2.94  3.19  3.42  3.64  3.85  4.05  4.22  4.38
4.52  4.65  4.76  4.84  4.91  4.96  4.99  5.00  4.99
4.96  4.91  4.84  4.76  4.65  4.52  4.38  4.22  4.05
3.85  3.64  3.42  3.19  2.94  2.68  2.41  2.13  1.84
1.55  1.24  0.94  0.63  0.31  0.00 -0.31 -0.63 -0.94 -1.24 -1.55 -1.84 -2.13 -2.41 -2.68 -2.94 -3.19 -3.42 -3.64 -3.85 -4.05 -4.22 -4.38 -4.52 -4.65 -4.76 -4.84 -4.91 -4.96 -4.99 -5.00 -4.99 -4.96 -4.91 -4.84 -4.76 -4.65 -4.52 -4.38 -4.22 -4.05 -3.85 -3.64 -3.42 -3.19 -2.94 -2.68 -2.41 -2.13 -1.84 -1.55 -1.24 -0.94 -0.63 -0.31
0   0   1   1   1   2   2   2   2   3   3   3   3   4   4 4   4   4   5   5   5   5   5   5   5   5   5   5   5   5 5   5   5   4   4   4   4   4   3   3   3   3   2   2   2 2   1   1   1   0   0   0   -1   -1   -1   -2   -2   -2 -2   -3   -3   -3   -3   -4   -4   -4   -4   -4   -5   -5 -5   -5   -5   -5   -5   -5   -5   -5   -5   -5   -5   -5 -5   -4   -4   -4   -4   -4   -3   -3   -3   -3   -2   -2 -2   -2   -1   -1   -1   0
  • 现在我们使用 inner_product() 来计算错误和:

    double errsum = inner_product(ds.begin(), ds.end(), 
        is.begin(), 0.0, std::plus<double>(),
        [](double a, double b){ return pow(a - b, 2); });
    cout << format("error sum: {:.3f}\n\n", errsum);
    

Lambda 表达式返回公式中的 (ai – bi)² 部分。std::plus() 算法执行求和操作。

输出:

error sum: 7.304

它是如何工作的...

inner_product() 算法在第一个输入范围内计算乘积的和。其签名是:

T inner_product(InputIt1 first1, InputIt1 last1,
    InputIt2 first2, T init, BinaryOperator1 op1,
    BinaryOperator2 op2)

函数接受两个二元运算符函数,op1op2。第一个 op1 用于 求和,第二个 op2 用于 乘积。我们使用 std::plus() 作为求和运算符,并使用 lambda 作为乘积运算符。

init 参数可以用作起始值或偏差。我们传递给它字面值,0.0

返回值是乘积的累积和。

还有更多...

我们可以通过将 inner_product() 放入循环来计算累积的错误和:

cout << "accumulated error:\n";
for (auto it{ds.begin()}; it != ds.end(); ++it) {
    double accumsum = inner_product(ds.begin(), it, 
        is.begin(), 0.0, std::plus<double>(),
        [](double a, double b){ return pow(a - b, 2); });
    cout << format("{:-5.2f} ", accumsum);
}
cout << '\n';

输出:

accumulated error:
0.00  0.00  0.10  0.24  0.24  0.30  0.51  0.53  0.55  0.72  0.82  0.82  0.86  1.04  1.16  1.19  1.19  1.24  1.38  1.61  1.73  1.79  1.82  1.82  1.83  1.83  1.83  1.83  1.83  1.84  1.86  1.92  2.04  2.27  2.42  2.46  2.47  2.49  2.61  2.79  2.83  2.83  2.93  3.10  3.12  3.14  3.35  3.41  3.41  3.55  3.65  3.65  3.75  3.89  3.89  3.95  4.16  4.19  4.20  4.37  4.47  4.48  4.51  4.69  4.82  4.84  4.84  4.89  5.03  5.26  5.38  5.44  5.47  5.48  5.48  5.48  5.48  5.48  5.48  5.49  5.51  5.57  5.70  5.92  6.07  6.12  6.12  6.14  6.27  6.45  6.48  6.48  6.59  6.75  6.77  6.80  7.00  7.06  7.07  7.21

这可能在某些统计应用中很有用。

构建你自己的算法:split

STL 有一个丰富的 algorithm 库。然而,有时你可能发现它缺少你需要的东西。一个常见的需求是 split 函数。

一个 split 函数在字符分隔符上拆分字符串。例如,这是一个来自标准 Debian 安装的 Unix /etc/passwd 文件:

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync

每个字段由冒号 : 字符分隔,字段包括:

  1. 登录名

  2. 可选加密密码

  3. 用户 ID

  4. 组 ID

  5. 用户名或注释

  6. 主目录

  7. 可选命令解释器

这是一个基于 POSIX 的操作系统中的标准文件,还有其他类似文件。大多数脚本语言都包含一个用于在分隔符上拆分字符串的内置函数。在 C++ 中有简单的方法来做这件事,尽管 std::string 只是 STL 中的另一个容器,一个在分隔符上拆分容器的通用算法可以作为工具箱中的一个有用的补充。所以,让我们构建一个。

如何实现...

在这个菜谱中,我们构建了一个通用算法,该算法在分隔符上拆分容器并将结果放入目标容器中。

  • 我们算法位于 bw 命名空间中,以避免与 std 冲突:

    namespace bw {
        template<typename It, typename Oc, typename V,
            typename Pred>
        It split(It it, It end_it, Oc& dest,
                const V& sep, Pred& f) {
            using SliceContainer = typename 
              Oc::value_type;
            while(it != end_it) {
                SliceContainer dest_elm{};
                auto slice{ it };
                while(slice != end_it) {
                    if(f(*slice, sep)) break;
                    dest_elm.push_back(*slice++);
                }
                dest.push_back(dest_elm);
                if(slice == end_it) return end_it;
                it = ++slice;
            }
            return it;
        }
    };
    

split() 算法在容器中搜索分隔符,并将分离的片段收集到一个新的输出容器中,其中每个片段都是输出容器内的容器。

我们希望 split() 算法尽可能通用,就像 algorithm 库中的那些一样。这意味着所有参数都是模板化的,代码将能够与各种参数类型一起工作。

首先,让我们看看模板参数:

  • It 是源容器的输入迭代器类型。

  • Oc 是输出容器类型。这是一个容器容器。

  • V 是分隔符类型。

  • Pred 用于谓词函数。

我们的输出类型是一个容器容器。它需要容纳切片容器。它可以是 vector<string>,其中字符串值是切片,或者 vector<vector<int>>,其中内层的 vector<int> 包含切片。这意味着我们需要从输出容器类型派生出内部容器的类型。我们通过函数体内的 using 声明来实现这一点。

using SliceContainer = typename Oc::value_type;

这也是为什么我们不能使用输出迭代器作为输出参数的原因。根据定义,输出迭代器无法确定其内容的类型,其 value_type 被设置为 void

我们使用 SliceContainer 定义一个临时容器,该容器通过语句添加到输出容器中:

dest.push_back(dest_elm);
  • 谓词是一个二元运算符,它比较输入元素与分隔符。我们在 bw 命名空间中包含了一个默认的相等运算符:

    constexpr auto eq = [](const auto& el, const auto& sep) {
        return el == sep;
    };
    
  • 我们还包括一个使用默认 eq 操作符的 split() 特殊化:

    template<typename It, typename Oc, typename V>
    It split(It it, const It end_it, Oc& dest, const V& sep) {
        return split(it, end_it, dest, sep, eq);
    }
    
  • 因为拆分 string 对象是这个算法的常见用例,所以我们包括一个用于该特定目的的辅助函数:

    template<typename Cin, typename Cout, typename V>
    Cout& strsplit(const Cin& str, Cout& dest, const V& sep) {
        split(str.begin(), str.end(), dest, sep, eq);
        return dest;
    }
    
  • 我们通过 main() 函数测试我们的拆分算法,从一个 string 对象开始:

    int main() {
        constexpr char strsep{ ':' };
        const string str
            { "sync:x:4:65534:sync:/bin:/bin/sync" };
        vector<string> dest_vs{};
        bw::split(str.begin(), str.end(), dest_vs, strsep, 
            bw::eq);
        for(const auto& e : dest_vs) cout <<
            format("[{}] ", e);
        cout << '\n';
    }
    

我们使用 /etc/passwd 文件中的字符串来测试我们的算法,得到以下结果:

[sync] [x] [4] [65534] [sync] [/bin] [/bin/sync]
  • 使用我们的 strsplit() 辅助函数甚至更简单:

    vector<string> dest_vs2{};
    bw::strsplit(str, dest_vs2, strsep);
    for(const auto& e : dest_vs2) cout << format("[{}] ", e);
    cout << '\n';
    

输出:

[sync] [x] [4] [65534] [sync] [/bin] [/bin/sync]

这将使解析 /etc/passwd 文件变得容易。

  • 当然,我们可以使用相同的算法来处理任何容器:

    constexpr int intsep{ -1 };
    vector<int> vi{ 1, 2, 3, 4, intsep, 5, 6, 7, 8, intsep,
        9, 10, 11, 12 };
    vector<vector<int>> dest_vi{};
    bw::split(vi.begin(), vi.end(), dest_vi, intsep);
    for(const auto& v : dest_vi) {
        string s;
        for(const auto& e : v) s += format("{}", e);
        cout << format("[{}] ", s);
    }
    cout << '\n';
    

输出:

[1234] [5678] [9101112]

它是如何工作的…

分割算法本身相对简单。这个菜谱中的魔法在于使用模板使其尽可能通用。

using 声明中定义的派生类型允许我们创建一个用于输出容器的容器:

using SliceContainer = typename Oc::value_type;

这给我们一个 SliceContainer 类型,我们可以用它来创建切片的容器:

SliceContainer dest_elm{};

这是一个临时容器,它被添加到输出容器中的每个切片:

dest.push_back(dest_elm);

利用现有算法:聚集

gather() 是利用现有算法的算法示例。

gather() 算法接受一对容器迭代器,并将满足谓词的元素移动到序列中的枢轴位置,返回一个包含满足谓词的元素的迭代器对。

例如,我们可以使用 gather 算法将所有偶数排序到 vector 的中点:

vector<int> vint{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
gather(vint.begin(), vint.end(), mid(vint), is_even);
for(const auto& el : vint) cout << el;

我们输出的是:

1302468579

注意,所有的偶数都位于输出的中间。

在这个菜谱中,我们将使用标准 STL 算法实现一个 gather 算法。

如何做…

我们的 gather 算法使用 std::stable_partition() 算法将元素移动到枢轴迭代器之前,然后再移动到枢轴之后。

  • 我们将算法放在 bw 命名空间中,以避免冲突。

    namespace bw {
    using std::stable_partition;
    using std::pair;
    using std::not_fn;
    template <typename It, typename Pred>
    pair<It, It> gather(It first, It last, It pivot,
            Pred pred) {
        return {stable_partition(first, pivot, not_fn(pred)),
                stable_partition(pivot, last, pred)};
    }
    };
    

gather() 算法返回一个迭代器对,这是从两次调用 stable_partition() 返回的。

  • 我们还包含了一些辅助的 lambda 表达式:

    constexpr auto midit = [](auto& v) {
        return v.begin() + (v.end() - v.begin()) / 2;
    };
    constexpr auto is_even = [](auto i) {
        return i % 2 == 0;
    };
    constexpr auto is_even_char = [](auto c) {
        if(c >= '0' && c <= '9') return (c - '0') % 2 == 0;
        else return false;
    };
    

这三个 lambda 表达式如下:

  • midit 返回一个在容器中点位置的迭代器,用作枢轴点。

  • is_even 如果值是偶数,则返回布尔值 true,用作谓词。

  • is_even_char 如果值是介于 '0''9' 之间的字符且为偶数,则返回布尔值 true,用作谓词。

  • 我们从 main() 函数中调用 gather(),传递一个 int 类型的向量,如下所示:

    int main() {
        vector<int> vint{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
        auto gathered_even = bw::gather(vint.begin(),
            vint.end(), bw::midit(vint), bw::is_even);
        for(const auto& el : vint) cout << el;
        cout << '\n';
    }
    

我们的输出显示偶数已经被聚集在中间:

1302468579

gather() 函数返回一个包含仅偶数值的迭代器对:

auto& [it1, it2] = gathered_even;
for(auto it{ it1 }; it < it2; ++it) cout << *it;
cout << '\n';

输出:

02468
  • 我们可以将枢轴点设置为 begin()end() 迭代器:

    bw::gather(vint.begin(), vint.end(), vint.begin(), 
        bw::is_even);
    for(const auto& el : vint) cout << el;
    cout << '\n';
    bw::gather(vint.begin(), vint.end(), vint.end(),
        bw::is_even);
    for(const auto& el : vint) cout << el;
    cout << '\n';
    

输出:

0246813579
1357902468
  • 因为 gather() 是基于迭代器的,所以我们可以用它与任何容器一起使用。这里是一个字符数字的字符串:

    string jenny{ "867-5309" };
    bw::gather(jenny.begin(), jenny.end(), jenny.end(),
        bw::is_even_char);
    for(const auto& el : jenny) cout << el;
    cout << '\n';
    

这将所有偶数位移动到字符串的末尾:

输出:

7-539860

它是如何工作的…

gather() 函数使用 std::stable_partition() 算法将匹配谓词的元素移动到枢轴点。

gather() 函数调用了两次 stable_partition(),一次是使用谓词,另一次是使用否定谓词:

template <typename It, typename Pred>
pair<It, It> gather(It first, It last, It pivot, Pred pred) {
    return { stable_partition(first, pivot, not_fn(pred)),
             stable_partition(pivot, last, pred) };
}

从两个 stable_partition() 调用返回的迭代器在 pair 中返回。

删除连续空白

当从用户接收输入时,字符串中经常会有过多的连续空白字符。这个菜谱提供了一个用于删除连续空格的函数,即使它包括制表符或其他空白字符。

如何做…

这个函数利用 std::unique() 算法从字符串中移除连续的空白字符。

  • bw 命名空间中,我们从一个检测空白的函数开始:

    template<typename T>
    bool isws(const T& c) {
        constexpr const T whitespace[]{ " \t\r\n\v\f" };
        for(const T& wsc : whitespace) {
            if(c == wsc) return true;
        }    
        return false;
    }
    

这个模板化的 isws() 函数应该可以与任何字符类型一起工作。

  • delws() 函数使用 std::unique() 来擦除字符串中的连续空白字符:

    string delws(const string& s) {
        string outstr{s};
        auto its = unique(outstr.begin(), outstr.end(),
            [](const auto &a, const auto &b) {
                return isws(a) && isws(b);
            });
        outstr.erase(its, outstr.end());
        outstr.shrink_to_fit();
        return outstr;
    }
    

delws() 函数会复制输入的字符串,移除连续的空白字符,并返回新的字符串。

  • 我们在 main() 中使用一个 string 来调用它:

    int main() {
        const string s{ "big     bad    \t   wolf" };
        const string s2{ bw::delws(s) };
        cout << format("[{}]\n", s);
        cout << format("[{}]\n", s2);
        return 0;
    }
    

输出:

[big     bad           wolf]
[big bad wolf]

它是如何工作的...

这个函数使用 std::unique() 算法和比较 lambda 表达式来在一个 string 对象中找到连续的空白字符。

比较 lambda 调用我们自己的 isws() 函数来确定我们是否找到了连续的空白字符:

auto its = unique(outstr.begin(), outstr.end(),
    [](const auto &a, const auto &b) {
        return isws(a) && isws(b);
    });

我们可以使用标准库中的 isspace() 函数,但它是一个依赖于从 intchar 的类型缩窄转换的标准 C 函数。这可能在某些现代 C++ 编译器上引发警告,并且不能保证在没有显式转换的情况下工作。我们的 isws() 函数使用模板类型,应该可以在任何系统上工作,并且与任何 std::string 的特化一起工作。

将数字转换为文字

在我的职业生涯中,我使用过很多编程语言。在学习一门新语言时,我喜欢有一个项目来工作,这样我可以接触到语言的细微差别。numwords 类是我最喜欢的这种用途的练习之一。多年来,我曾在数十种语言中编写过它,包括几次在 C 和 C++ 中。

numwords 是一个将数字转换为文字的类。它在应用中的样子如下:

int main() {
    bw::numword nw{};
    uint64_t n;
    nw = 3; bw::print("n is {}, {}\n", nw.getnum(), nw);
    nw = 47; bw::print("n is {}, {}\n", nw.getnum(), nw);
    n = 100073; bw::print("n is {}, {}\n", n, 
      bw::numword{n});
    n = 1000000001; bw::print("n is {}, {}\n", n, 
      bw::numword{n});
    n = 123000000000; bw::print("n is {}, {}\n", n, 
      bw::numword{n});
    n = 1474142398007; bw::print("n is {}, {}\n", n, 
      nw.words(n));
    n = 999999999999999999; bw::print("n is {}, {}\n", n, 
      nw.words(n));
    n = 1000000000000000000; bw::print("n is {}, {}\n", n, 
        nw.words(n));
}

输出:

n is 3, three
n is 47, forty-seven
n is 100073, one hundred thousand seventy-three
n is 1000000001, one billion one
n is 123000000000, one hundred twenty-three billion
n is 1474142398007, one trillion four hundred seventy-four billion one hundred forty-two million three hundred ninety-eight thousand seven
n is 999999999999999999, nine hundred ninety-nine quadrillion nine hundred ninety-nine trillion nine hundred ninety-nine billion nine hundred ninety-nine million nine hundred ninety-nine thousand nine hundred ninety-nine
n is 1000000000000000000, error

如何做到这一点...

这个菜谱最初是作为一个创建生产就绪代码的练习而出现的。因此,它分布在三个不同的文件中:

  • numword.hnumwords 类的头文件/接口文件。

  • numword.cppnumwords 类的实现文件。

  • numword-test.cpp 是用于测试 numword 类的应用程序文件。

类本身大约有 180 行代码,所以我们只概述一下亮点。你可以在 github.com/PacktPublishing/CPP-20-STL-Cookbook/tree/main/chap11/numword 找到完整的源代码。

  • numword.h 文件中,我们将类放在 bw 命名空间中,并开始使用一些 using 语句:

    namespace bw {
        using std::string;
        using std::string_view;
        using numnum = uint64_t; 
        using bufstr = std::unique_ptr<string>;
    

我们在代码中使用了 stringstring_view 对象。

uint64_t 是我们的主要整数类型,因为它可以存储非常大的数字。由于类名为 numword,我喜欢 numnum 作为整数类型。

_bufstr 是主要输出缓冲区。它是一个 string,被 unique_ptr 包装,这处理了内存管理以实现自动 RAII 兼容。

  • 我们还有一些用于各种目的的常量:

    constexpr numnum maxnum = 999'999'999'999'999'999;
    constexpr int zero_i{ 0 };
    constexpr int five_i{ 5 };
    constexpr numnum zero{ 0 };
    constexpr numnum ten{ 10 };
    constexpr numnum twenty{ 20 };
    constexpr numnum hundred{ 100 };
    constexpr numnum thousand{ 1000 };
    

maxnum 常数翻译为 "九百九十九兆九百九十九万亿九百九十九亿九百九十九千万九百九十九",对于大多数用途来说应该足够了。

其余的 numnum 常数用于避免代码中的字面量。

  • 主要数据结构是 constexpr 数组,包含 string_view 对象,代表输出中使用的单词。string_view 类对于这些常量来说非常完美,因为它提供了最小开销的封装:

    constexpr string_view errnum{ "error" };
    constexpr string_view _singles[] {
        "zero", "one", "two", "three", "four", "five", 
        "six", "seven", "eight", "nine"
    };
    constexpr string_view _teens[] {
        "ten", "eleven", "twelve", "thirteen", "fourteen", 
        "fifteen", "sixteen", "seventeen", "eighteen", 
        "nineteen"
    };
    constexpr string_view _tens[] {
        errnum, errnum, "twenty", "thirty", "forty", 
        "fifty", "sixty", "seventy", "eighty", "ninety",
    };
    constexpr string_view _hundred_string = "hundred";
    constexpr string_view _powers[] {
        errnum, "thousand", "million", "billion", 
        "trillion", "quadrillion"
    };
    

单词被分组到各个部分,这对于将数字转换为单词很有用。许多语言使用类似的分解,因此这种结构应该很容易翻译到那些语言。

  • numword 类有几个私有成员:

    class numword {
        bufstr _buf{ std::make_unique<string>(string{}) };
        numnum _num{};
        bool _hyphen_flag{ false };
    
    • _buf 是输出字符串缓冲区。其内存由 unique_ptr 管理。

    • _num 保存当前的数值。

    • _hyphen_flag 在翻译过程中用于在单词之间插入连字符,而不是空格字符。

  • 这些私有方法用于操作输出缓冲区。

    void clearbuf();
    size_t bufsize();
    void appendbuf(const string& s);
    void appendbuf(const string_view& s);
    void appendbuf(const char c);
    void appendspace();
    

此外,还有一个用于计算 numnum 类型中 xy 的私有方法 pow_i()

numnum pow_i(const numnum n, const numnum p);

pow_i() 用于区分数字值的部分以进行单词输出。

  • 公共接口包括构造函数和调用 words() 方法的各种方式,该方法是翻译 numnum 到单词字符串的工作:

    numword(const numnum& num = 0) : _num(num) {}
    numword(const numword& nw) : _num(nw.getnum()) {}
    const char * version() const { return _version; }
    void setnum(const numnum& num) { _num = num; }
    numnum getnum() const { return _num; }
    numnum operator= (const numnum& num);
    const string& words();
    const string& words(const numnum& num);
    const string& operator() (const numnum& num) {
        return words(num); };
    
  • 在实现文件 numword.cpp 中,大部分工作是在 words() 成员函数中处理的:

    const string& numword::words( const numnum& num ) {
        numnum n{ num };
        clearbuf();
        if(n > maxnum) {
            appendbuf(errnum);
            return *_buf;
        }
        if (n == 0) {
            appendbuf(_singles[n]);
            return *_buf;
        }
        // powers of 1000
        if (n >= thousand) {
            for(int i{ five_i }; i > zero_i; --i) {
                numnum power{ pow_i(thousand, i) };
                numnum _n{ ( n - ( n % power ) ) / power };
                if (_n) {
                    int index = i;
                    numword _nw{ _n };
                    appendbuf(_nw.words());
                    appendbuf(_powers[index]);
                    n -= _n * power;
                }
            }
        }
        // hundreds
        if (n >= hundred && n < thousand) {
            numnum _n{ ( n - ( n % hundred ) ) / hundred };
            numword _nw{ _n };
            appendbuf(_nw.words());
            appendbuf(_hundred_string);
            n -= _n * hundred;
        }
        // tens
        if (n >= twenty && n < hundred) {
            numnum _n{ ( n - ( n % ten ) ) / ten };
            appendbuf(_tens[_n]);
            n -= _n * ten;
            _hyphen_flag = true;
        }
        // teens
        if (n >= ten && n < twenty) {
            appendbuf(_teens[n - ten]);
            n = zero;
        }
        // singles
        if (n > zero && n < ten) {
            appendbuf(_singles[n]);
        }
        return *_buf;
    }
    

函数的每个部分都递归地使用十的幂的模数剥离数字的一部分,并在千位的情况下追加来自 string_view 常量数组的字符串。

  • appendbuf() 有三个重载。一个添加一个 string

    void numword::appendbuf(const string& s) {
        appendspace();
        _buf->append(s);
    }
    

另一个添加一个 string_view

void numword::appendbuf(const string_view& s) {
    appendspace();
    _buf->append(s.data());
}

第三个添加单个字符:

void numword::appendbuf(const char c) {
    _buf->append(1, c);
}

appendspace() 方法根据上下文添加空格字符或连字符:

void numword::appendspace() {
    if(bufsize()) {
        appendbuf( _hyphen_flag ? _hyphen : _space);
        _hyphen_flag = false;
    }
}
  • numword-test.cpp 文件是 bw::numword 的测试环境。它包括一个 formatter 特化:

    template<>
    struct std::formatter<bw::numword>: std::formatter<unsigned> {
        template<typename FormatContext>
        auto format(const bw::numword& nw, 
          FormatContext& ctx) {
            bw::numword _nw{nw};
            return format_to(ctx.out(), "{}", 
              _nw.words());
        }
    };
    

这允许我们直接将 bw::numword 对象传递给 format()

  • 此外,还有一个 print() 函数,它将 formatter 输出直接发送到 stdout,完全绕过 coutiostream 库:

    namespace bw {
        template<typename... Args> constexpr void print(
                const std::string_view str_fmt, Args&&... 
                  args) {
            fputs(std::vformat(str_fmt, 
                std::make_format_args(args...)).c_str(), 
                stdout);
        }
    };
    

这允许我们使用 print("{}\n", nw) 而不是通过 cout 管道 format()。这样的函数将被包含在 C++23 标准。现在可以像这样简单地包含它。

  • main() 中,我们声明一个 bw::numword 对象和一个 uint64_t 用于测试:

    int main() {
        bw::numword nw{};
        uint64_t n{};
        bw::print("n is {}, {}\n", nw.getnum(), nw);
        ...
    

numword 对象初始化为零,这使得我们的 print() 语句产生以下输出:

n is 0, zero
  • 我们测试了调用 numword 的各种方式:

    nw = 3; bw::print("n is {}, {}\n", nw.getnum(), nw);
    nw = 47; bw::print("n is {}, {}\n", nw.getnum(), nw);
    ...
    n = 100073; bw::print("n is {}, {}\n", n, bw::numword{n});
    n = 1000000001; bw::print("n is {}, {}\n", n, bw::numword{n});
    ...
    n = 474142398123; bw::print("n is {}, {}\n", n, nw(n));
    n = 1474142398007; bw::print("n is {}, {}\n", n, nw(n));
    ...
    n = 999999999999999999; bw::print("n is {}, {}\n", n, nw(n));
    n = 1000000000000000000; bw::print("n is {}, {}\n", n, nw(n));
    

输出:

n is 3, three
n is 47, forty-seven
...
n is 100073, one hundred thousand seventy-three
n is 1000000001, one billion one
...
n is 474142398123, four hundred seventy-four billion one hundred forty-two million three hundred ninety-eight thousand one hundred twenty-three
n is 1474142398007, one trillion four hundred seventy-four billion one hundred forty-two million three hundred ninety-eight thousand seven
...
n is 999999999999999999, nine hundred ninety-nine quadrillion nine hundred ninety-nine trillion nine hundred ninety-nine billion nine hundred ninety-nine million nine hundred ninety-nine thousand nine hundred ninety-nine
n is 1000000000000000000, error

它是如何工作的…

这个类在很大程度上是由数据结构驱动的。通过将 string_view 对象组织成数组,我们可以轻松地将标量值转换为相应的单词:

appendbuf(_tens[_n]);  // e.g., _tens[5] = "fifty"

其余部分主要是数学:

numnum power{ pow_i(thousand, i) };
numnum _n{ ( n - ( n % power ) ) / power };
if (_n) {
    int index = i;
    numword _nw{ _n };
    appendbuf(_nw.words());
    appendbuf(_powers[index]);
    n -= _n * power;
}

还有更多…

我还有一个使用 numwords 类来用文字报时的实用工具。它的输出看起来像这样:

$ ./saytime
three past five

在测试模式下,它给出以下输出:

$ ./saytime test
00:00 midnight
00:01 one past midnight
11:00 eleven o'clock
12:00 noon
13:00 one o'clock
12:29 twenty-nine past noon
12:30 half past noon
12:31 twenty-nine til one
12:15 quarter past noon
12:30 half past noon
12:45 quarter til one
11:59 one til noon
23:15 quarter past eleven
23:59 one til midnight
12:59 one til one
13:59 one til two
01:60 OOR
24:00 OOR

我将其实现留给读者作为练习。

Image87652

Packt.com

订阅我们的在线数字图书馆,全面访问超过 7000 本书籍和视频,以及领先的行业工具,帮助你规划个人发展并推进你的职业生涯。更多信息,请访问我们的网站。

第十二章:为什么订阅?

  • 通过来自 4000 多名行业专业人士的实用电子书和视频,节省学习时间,增加编码时间

  • 通过为你量身定制的技能计划提高你的学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于快速访问关键信息

  • 复制粘贴、打印和收藏内容

你知道吗,Packt 为每本书都提供了电子书版本,包括 PDF 和 ePub 文件。你可以在packt.com升级到电子书版本,作为印刷书客户,你有权获得电子书副本的折扣。如需了解更多详情,请联系我们customercare@packtpub.com

www.packt.com,你还可以阅读一系列免费的技术文章,注册各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

你可能还会喜欢以下书籍

如果你喜欢这本书,你可能还会对 Packt 的其他书籍感兴趣:

Mastering Adobe Photoshop Elements

9781800208117 封面点击访问

高效编程的艺术

Fedor G. Pikus

ISBN: 9781800208117

  • 探索如何有效地使用程序中的硬件计算资源

  • 理解内存顺序和内存屏障之间的关系

  • 熟悉不同数据结构和组织方式的性能影响

  • 评估并发访问内存的性能影响以及如何最小化它

  • 了解何时使用以及何时不使用无锁编程技术

  • 研究不同的方法来提高编译器优化的有效性

  • 为并发数据结构和高性能数据结构设计 API 以避免低效

Mastering Adobe Photoshop Elements

9781838554590 封面点击访问

使用 C++进行软件架构

Adrian Ostrowski, Piotr Gaczkowski

ISBN: 9781838554590

  • 理解如何应用软件架构的原则

  • 应用设计模式和最佳实践以满足你的架构目标

  • 使用最新的 C++特性编写优雅、安全且高效的代码

  • 构建易于维护和部署的应用程序

  • 探索不同的架构方法,并学习根据你的需求应用它们

  • 使用应用程序容器简化开发和运维

  • 探索解决软件开发中常见问题的各种技术

Packt 正在寻找像你这样的作者

如果你有兴趣成为 Packt 的作者,请访问authors.packtpub.com并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解分享给全球科技社区。你可以提交一个一般性申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。

分享你的想法

现在你已经完成了《C++ 20 STL 烹饪秘籍》,我们非常想听听你的想法!如果你在亚马逊购买了这本书,请点击此处直接跳转到该书的亚马逊评论页面并分享你的反馈或在该购买网站上留下评论。

你的评论对我们和科技社区都非常重要,它将帮助我们确保我们提供的是高质量的内容。

你可能还会喜欢的其他书籍

posted @ 2025-10-02 09:33  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报