精通-C--17-STL-全-

精通 C++17 STL(全)

原文:zh.annas-archive.org/md5/3cc74f1869cbe375d71d8bca644fbb25

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

C++语言有着悠久的历史,可以追溯到 20 世纪 80 年代。最近,它经历了一次复兴,2011 年和 2014 年引入了重大新特性。在出版时,C++17 标准即将到来。

C++11 实际上将标准库的大小翻了一番,添加了如<tuple><type_traits><regex>等头文件。C++17 再次将库翻了一番,添加了如<optional><any><filesystem>等新特性。那些一直忙于编写代码而不是关注标准化过程程序员可能会觉得标准库已经脱离了他的掌控——因为库中有太多新事物,他可能永远无法掌握整个库,甚至无法区分精华和糟粕。毕竟,谁愿意花一个月的时间阅读关于std::localestd::ratio的技术文档,结果发现它们在日常工作中并不实用?

在这本书中,我将教授 C++17 标准库最重要的特性。为了简洁起见,我省略了一些部分,例如上述的<type_traits>;但我们将会涵盖整个现代 STL(每个标准容器和每个标准算法),以及诸如智能指针、随机数、正则表达式和 C++17 中引入的新<filesystem>库等重要主题。

我将通过示例进行教学。你将学习如何构建自己的迭代器类型;使用std::pmr::memory_resource构建自己的内存分配器;使用std::future构建自己的线程池。

我将教授那些在参考手册中找不到的概念。你将学习单态、多态和泛型算法之间的区别(第一章,经典多态和泛型编程);对于std::stringstd::any来说,被称为“词汇类型”意味着什么(第五章,词汇类型);以及我们可能从 2020 年及以后的 C++标准中期待什么。

我假设你已经对 C++11 的核心语言相当熟悉;例如,你已经理解了如何编写类和函数模板,lvalue 和 rvalue 引用之间的区别等等。

本书涵盖内容

第一章,经典多态和泛型编程,涵盖了经典多态(虚成员函数)和泛型编程(模板)。

第二章,迭代器和范围,解释了迭代器作为指针泛化的概念,以及以迭代器对表示的半开范围的有用性。

第三章,迭代器对算法,探讨了在迭代器对表示的范围上操作的大量标准泛型算法。

第四章,容器动物园,探讨了标准容器类模板的几乎同样广泛的多样性,以及哪些容器适合哪些工作。

第五章,词汇类型,带您了解代数类型,如std::optional,以及与 ABI 兼容的类型擦除类型,如std::function

第六章,智能指针,介绍了智能指针的目的和使用方法。

第七章,并发,涵盖了原子操作、互斥锁、条件变量、线程、未来和承诺。

第八章,分配器,解释了 C++17 的<memory_resource>头文件的新特性。

第九章,I/O 流,探讨了 C++ I/O 模型的发展,从<unistd.h><stdio.h>再到<iostream>

第十章,正则表达式,介绍了 C++中的正则表达式。

第十一章,随机数,带您了解 C++对伪随机数生成的支持。

第十二章,文件系统,涵盖了 C++17 中引入的新<filesystem>库。

您需要这本书什么

由于这本书不是参考手册,你可能需要一本参考手册,例如 cppreference (en.cppreference.com/w/cpp),以便在你身边澄清任何令人困惑的点。手头有一个 C++17 编译器肯定会很有帮助。在出版时,有几种功能相对完整的 C++17 实现,包括 GCC、Clang 和 Microsoft Visual Studio。您可以在本地运行它们,或者通过许多免费的在线编译器服务,如 Wandbox (wandbox.org)、Godbolt (gcc.godbolt.org) 和 Rextester (rextester.com)。

这本书是为谁而写的

这本书是为希望掌握 C++17 STL 并充分利用其组件的开发者而编写的。假设读者具备先前的 C++知识。

规范

在这本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名显示如下:“buffer()函数接受类型为int的参数。”

代码块设置如下:

    try {
      none.get();
    } catch (const std::future_error& ex) {
      assert(ex.code() == std::future_errc::broken_promise);
    }

新术语重要词汇以粗体显示。

警告或重要提示看起来像这样。

小贴士和注意事项看起来像这样。

读者反馈

我们始终欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。要发送一般反馈,请简单地发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书的标题。如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您已经是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从购买中获得最大收益。

下载示例代码

您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的“支持”标签上。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书的名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买本书的地方。

  7. 点击“代码下载”。

文件下载完成后,请确保您使用最新版本的以下软件解压缩或提取文件夹:

  • 适用于 Windows 的 WinRAR / 7-Zip

  • 适用于 Mac 的 Zipeg / iZip / UnRarX

  • 适用于 Linux 的 7-Zip / PeaZip

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Mastering-the-Cpp17-STL。我们还有其他来自我们丰富图书和视频目录的代码包可供选择,网址为github.com/PacktPublishing/。请查看它们!

勘误

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

侵权

在互联网上侵犯版权材料是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。请通过copyright@packtpub.com与我们联系,并提供疑似盗版材料的链接。我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

问题

如果您对本书的任何方面有问题,您可以通过questions@packtpub.com与我们联系,我们将尽力解决问题。

第一章:经典多态与泛型编程

C++标准库有两个截然不同但同样重要的任务。其中之一是提供某些具体数据类型或函数的稳固实现,这些类型或函数在许多不同的程序中都有用,但并未内置于核心语言语法中。这就是为什么标准库包含了std::stringstd::regexstd::filesystem::exists等等。标准库的另一个任务是提供广泛使用的抽象算法(如排序、搜索、反转、排序等)的稳固实现。在本章中,我们将明确说明当我们说某段代码是“抽象的”时,我们指的是什么,并描述标准库用来提供抽象的两种方法:经典多态泛型编程

在本章中,我们将探讨以下主题:

  • 具体的(单态)函数,其行为不可参数化

  • 通过基类、虚拟成员函数和继承实现经典多态

  • 通过概念、要求和模型实现泛型编程

  • 每种方法的实际优缺点

具体的单态函数

什么是区分抽象算法和具体函数的特征?这最好通过例子来说明。让我们编写一个函数,将数组中的每个元素乘以 2:

    class array_of_ints {
      int data[10] = {};
      public:
        int size() const { return 10; }
        int& at(int i) { return data[i]; }
    };

    void double_each_element(array_of_ints& arr)
    {
      for (int i=0; i < arr.size(); ++i) {
        arr.at(i) *= 2;
      }
    }

我们的功能double_each_elementarray_of_int类型的对象一起工作;传递不同类型的对象将不起作用(甚至无法编译)。我们将此类版本的double_each_element称为具体单态函数。我们称它们为具体,因为它们对我们来说不够抽象。想象一下,如果 C++标准库提供了一个仅对一种特定数据类型工作的具体sort例程,那会多么痛苦!

经典的多态函数

我们可以通过经典面向对象OO)编程的技术来提高我们算法的抽象级别,如 Java 和 C#等语言所示。面向对象的方法是决定我们希望哪些行为是可定制的,然后将它们声明为抽象基类的公共虚拟成员函数:

    class container_of_ints {
      public:
      virtual int size() const = 0;
      virtual int& at(int) = 0;
    };

    class array_of_ints : public container_of_ints {
      int data[10] = {};
      public:
        int size() const override { return 10; }
        int& at(int i) override { return data[i]; }
    };

    class list_of_ints : public container_of_ints {
      struct node {
        int data;
        node *next;
      };
      node *head_ = nullptr;
      int size_ = 0;
      public:
       int size() const override { return size_; }
       int& at(int i) override {
        if (i >= size_) throw std::out_of_range("at");
        node *p = head_;
        for (int j=0; j < i; ++j) {
          p = p->next;
        }
        return p->data;
      }
      ~list_of_ints();
    };

    void double_each_element(container_of_ints& arr) 
    {
      for (int i=0; i < arr.size(); ++i) {
        arr.at(i) *= 2;
      } 
    }

    void test()
    {
      array_of_ints arr;
      double_each_element(arr);

      list_of_ints lst;
      double_each_element(lst);
    }

test内部,对double_each_element的两次不同调用可以编译,因为在经典的 OO 术语中,一个array_of_ints 一个container_of_ints(即它继承自container_of_ints并实现了相关的虚拟成员函数),一个list_of_ints 也是 一个container_of_ints。然而,任何给定的container_of_ints对象的行为由其动态类型参数化;也就是说,由与该特定对象关联的函数指针表。

由于我们现在可以通过传递不同动态类型的对象来参数化double_each_element函数的行为,而不必直接编辑其源代码,我们可以说这个函数已经变得多态

然而,这个多态函数只能处理那些是基类container_of_ints的子类的类型。例如,你不能将std::vector<int>传递给这个函数;如果你尝试这样做,你会得到编译错误。经典多态很有用,但它并没有把我们带到完全泛型的地步。

经典(面向对象)多态的一个优点是源代码仍然与编译器生成的机器代码保持一对一的对应关系。在机器代码级别,我们仍然只有一个double_each_element函数,具有一个签名和一个定义良好的入口点。例如,我们可以将double_each_element的地址作为函数指针。

模板泛型编程

在现代 C++中,编写完全泛型算法的典型方式是将算法实现为一个模板。我们仍然将以.size().at()公共成员函数为依据实现函数模板,但我们不再要求参数arr是任何特定类型。因为我们的新函数将是一个模板,我们将告诉编译器“我不关心arr的类型是什么。无论它是什么类型,只要生成一个新的函数(即模板实例化),使其参数类型为该类型。”

    template<class ContainerModel>
    void double_each_element(ContainerModel& arr)
    {
      for (int i=0; i < arr.size(); ++i) {
        arr.at(i) *= 2;
      }
    }

    void test()
    {
      array_of_ints arr;
      double_each_element(arr);

      list_of_ints lst;
      double_each_element(lst);

      std::vector<int> vec = {1, 2, 3};
      double_each_element(vec);
    }

在大多数情况下,如果我们能够用文字精确地描述我们的模板类型参数ContainerModel必须支持的操作,这有助于我们设计更好的程序。这些操作的总和构成了 C++中所谓的概念;在这个例子中,我们可以说概念Container由“有一个名为size的成员函数,它返回容器的大小作为一个int(或与int相当的东西);并且有一个名为at的成员函数,它接受一个int索引(或可以隐式转换为int的东西)并产生对容器中索引元素的非 const 引用。”每当某个类array_of_ints正确地提供概念Container所需的操作,使得array_of_ints可以与double_each_element一起使用时,我们就说具体类array_of_intsContainer概念的模型。这就是为什么我在前面的例子中将模板类型参数命名为ContainerModel

使用Container作为模板类型参数本身的名称更为传统,从现在开始我将这样做;我只是不想一开始就混淆Container概念和特定函数模板的特定模板类型参数之间的区别,这个函数模板恰好希望将其参数设置为模型Container概念的具体类。

当我们使用模板实现一个抽象算法,使得算法的行为可以在编译时通过任何建模适当概念的类型进行参数化时,我们说我们在进行泛型编程。

注意,我们关于 Container 概念的描述并没有提到我们期望包含的元素类型是 int;并且不是巧合的是,我们发现现在我们甚至可以使用我们的通用 double_each_element 函数,即使容器不包含 int

    std::vector<double> vecd = {1.0, 2.0, 3.0};
    double_each_element(vecd);

这种额外的泛型级别是使用 C++ 模板进行泛型编程而不是经典多态的一个大优点。经典多态在稳定的 接口签名(例如,.at(i) 总是返回 int&)后面隐藏了不同类的不同行为,但一旦你开始与变化的签名打交道,经典多态就不再是这项工作的好工具。

泛型编程的另一个优点是,它通过增加内联的机会提供了闪电般的速度。经典多态的例子必须反复查询 container_of_int 对象的虚表以找到其特定虚拟 at 方法的地址,并且通常无法在编译时看到虚拟调度。模板函数 double_each_element<array_of_int> 可以直接调用 array_of_int::at 或甚至完全内联调用。

由于模板泛型编程可以如此轻松地处理复杂的需求,并且在处理类型方面非常灵活——甚至对于像 int 这样的原始类型,在经典多态中失败的情况下——标准库使用模板来处理所有算法及其操作的容器。因此,标准库中算法和容器部分通常被称为 标准模板库STL

对的——技术上,STL 只是 C++ 标准库的一小部分!然而,在这本书中,就像在现实生活中一样,我们有时可能会不小心使用 STL 这个词,而实际上我们指的是标准库,反之亦然。

在我们深入研究 STL 提供的标准泛型算法之前,让我们先看看几个手写的通用算法。这里有一个函数模板 count,它返回容器中元素的总数:

    template<class Container>
    int count(const Container& container)
    {
      int sum = 0;
      for (auto&& elt : container) {
        sum += 1;
      }
      return sum;
    }

这里是 count_if,它返回满足用户提供的 谓词 函数的元素数量:

    template<class Container, class Predicate>
    int count_if(const Container& container, Predicate pred) 
    { 
      int sum = 0;
      for (auto&& elt : container) {
        if (pred(elt)) {
            sum += 1;
        }
      }
      return sum;
    }

这些函数的使用方式如下:

    std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};

    assert(count(v) == 8);

    int number_above =
      count_if(v, [](int e) { return e > 5; });
    int number_below =
      count_if(v, [](int e) { return e < 5; });

    assert(number_above == 2);
    assert(number_below == 5);

在那个小小的表达式pred(elt)中蕴含了如此多的力量!我鼓励你尝试用经典多态重新实现count_if函数,只是为了了解整个系统在哪里崩溃。在现代 C++的语法糖下隐藏着许多不同的签名。例如,在我们的count_if函数中的范围 for 循环语法被编译器转换(或降低)为基于container.begin()container.end()的 for 循环,每个都需要返回一个迭代器,其类型取决于container本身的类型。另一个例子,在泛型编程版本中,我们从未指定——我们从未需要指定——pred是否通过值或引用接收其参数elt。尝试用virtual bool operator()件事!

谈到迭代器:你可能已经注意到,本章中的所有示例函数(无论它们是单态、多态还是泛型)都是用容器来表达的。当我们编写count时,我们计算整个容器中的元素数量。当我们编写count_if时,我们计算整个容器中匹配的元素数量。这证明是一种非常自然的方式来编写,特别是在现代 C++中;如此之多,以至于我们可以期待在 C++20 或 C++23 中看到基于容器的算法(或其近亲,基于范围的算法)的出现。然而,STL 可以追溯到 20 世纪 90 年代和现代 C++之前。因此,STL 的作者假设主要处理容器将会非常昂贵(由于所有那些昂贵的拷贝构造——记住移动语义和移动构造直到 C++11 才出现);因此,他们设计了 STL 主要处理一个更轻量级的概念:迭代器。这将是下一章的主题。

摘要

经典多态和泛型编程都处理了参数化算法行为的本质问题:例如,编写一个与任何任意匹配操作一起工作的搜索函数。

经典的多态通过指定一个具有一组封闭的抽象基类虚成员函数的类,以及编写接受从该基类继承的具体类实例的指针或引用的多态函数来解决该问题。

泛型编程通过指定一个具有一组封闭的要求的概念,并用具体类实例化函数模板来解决这个问题,这些具体类模拟了该概念。

经典多态在处理高级参数化(例如,操作任何签名的函数对象)和类型之间的关系(例如,操作任意容器的元素)方面存在困难。因此,标准模板库大量使用了基于模板的泛型编程,而几乎没有使用经典多态。

当你使用泛型编程时,如果你能记住你类型的概念性要求,或者甚至将它们明确地写下来,这将有所帮助;但截至 C++17,编译器无法直接帮助你检查这些要求。

第二章:迭代器和范围

在上一章中,我们实现了几个在容器上操作的泛型算法,但效率不高。在这一章中,你将学习:

  • C++ 如何以及为什么将指针的概念泛化以创建 迭代器 概念

  • C++ 中 范围 的重要性,以及将半开范围表示为迭代器对的标准方法

  • 如何编写自己的坚如磐石、const-正确的迭代器类型

  • 如何编写在迭代器对上操作的泛型算法

  • 标准迭代器层次结构及其算法重要性

整数索引的问题

在上一章中,我们实现了几个在容器上操作的泛型算法。再次考虑这些算法之一:

    template<typename Container>
    void double_each_element(Container& arr) 
    {
      for (int i=0; i < arr.size(); ++i) {
        arr.at(i) *= 2;
      }
    }

此算法是用较低级别的操作 .size().at() 定义的。这对于容器类型,如 array_of_intsstd::vector,效果相当不错,但对于,比如说,上一章的 list_of_ints 这样的链表,效果就差多了:

    class list_of_ints {
      struct node {
        int data;
        node *next;
      };
      node *head_ = nullptr;
      node *tail_ = nullptr;
      int size_ = 0;
    public:
      int size() const { return size_; }
      int& at(int i) {
        if (i >= size_) throw std::out_of_range("at");
        node *p = head_;
        for (int j=0; j < i; ++j) {
          p = p->next;
        }
        return p->data;
      }
      void push_back(int value) {
        node *new_tail = new node{value, nullptr};
        if (tail_) {
          tail_->next = new_tail;
        } else {
          head_ = new_tail;
        }
        tail_ = new_tail;
        size_ += 1;
      }
      ~list_of_ints() {
        for (node *next, *p = head_; p != nullptr; p = next) {
          next = p->next;
          delete p;
        }
      }
    };

list_of_ints::at() 的实现是 O(n),即列表的长度——列表越长,at() 越慢。特别是,当我们的 count_if 函数遍历列表的每个元素时,它会调用那个 at() 函数 n 次,这使得我们的泛型算法的运行时间为 O(n²)——对于一个本应 O(n) 的简单计数操作!

结果表明,使用 .at() 进行整数索引并不是构建算法城堡的良好基础。我们应该选择一个更接近计算机实际操作数据的原始操作。

超越指针

在没有任何抽象的情况下,一个人通常如何识别数组、链表或树中的元素?最直接的方法是使用指向元素内存地址的 指针。以下是一些指向各种数据结构元素的指针示例:

图片

要遍历一个 数组,我们只需要那个指针;我们可以通过从指向第一个元素的指针开始,简单地递增该指针直到它达到最后一个元素来处理数组中的所有元素。在 C 语言中:

    for (node *p = lst.head_; p != nullptr; p = p->next) {
      if (pred(p->data)) {
        sum += 1;
      }
   }

但是,为了有效地遍历一个 链表,我们需要的不仅仅是原始指针;递增 node* 类型的指针几乎不可能产生指向列表中下一个节点的指针!在这种情况下,我们需要某种类似于指针的东西——特别是,我们应该能够解引用它来检索或修改指向的元素——但与增量这一抽象概念相关联的特殊、容器特定的行为。

在 C++ 中,鉴于我们已经在语言中内置了操作符重载,当我说“将特殊行为与增量概念关联”时,你应该想到“让我们重载 ++ 操作符。”确实,这正是我们将要做的:

    struct list_node {
      int data;
      list_node *next;
    };

    class list_of_ints_iterator {
      list_node *ptr_;

      friend class list_of_ints;
      explicit list_of_ints_iterator(list_node *p) : ptr_(p) {}
    public:
      int& operator*() const { return ptr_->data; }
      list_of_ints_iterator& operator++() { ptr_ = ptr_->next; return *this; }
      list_of_ints_iterator operator++(int) { auto it = *this; ++*this; return it; }
      bool operator==(const list_of_ints_iterator& rhs) const
        { return ptr_ == rhs.ptr_; }
      bool operator!=(const list_of_ints_iterator& rhs) const
        { return ptr_ != rhs.ptr_; }
    };

    class list_of_ints {
      list_node *head_ = nullptr;
      list_node *tail_ = nullptr;
      // ...
    public:
      using iterator = list_of_ints_iterator;
      iterator begin() { return iterator{head_}; }
      iterator end() { return iterator{nullptr}; }
    }; 

    template<class Container, class Predicate>
    int count_if(Container& ctr, Predicate pred)
    {
      int sum = 0;
      for (auto it = ctr.begin(); it != ctr.end(); ++it) {
        if (pred(*it)) {
            sum += 1;
        }
      }
      return sum;
   }

注意,我们还重载了单目*运算符(用于解引用)和==!=运算符;我们的count_if模板要求所有这些操作对循环控制变量it都有效。(好吧,好吧,技术上我们的count_if不需要==操作;但如果你要重载一个比较运算符,你应该也重载另一个。)

常量迭代器

在我们放弃这个列表迭代器例子之前,还有一个问题需要考虑。请注意,我悄悄地改变了我们的count_if函数模板,使其接受Container&而不是const Container&!这是因为我们提供的begin()end()成员函数是非 const 成员函数;而且是因为它们返回的迭代器的operator*返回对列表元素的 non-const 引用。我们希望我们的列表类型(及其迭代器)完全符合 const 的正确性--也就是说,我们希望你能定义并使用const list_of_ints类型的变量,但防止你修改const列表的元素。

标准库通常通过为每个标准容器提供两种不同类型的迭代器来处理这个问题:bag::iteratorbag::const_iterator。非 const 成员函数bag::begin()返回一个iterator,而bag::begin() const成员函数返回一个const_iterator。下划线非常重要!请注意,bag::begin() const并不返回一个普通的const iterator;如果返回的对象是const的,我们就不能对它进行++操作。(这反过来会使遍历const bag变得非常困难!)不,bag::begin() const返回的是一个更微妙的东西:一个非 const 的const_iterator对象,其operator*恰好返回一个const引用到其元素。

可能一个例子会有所帮助。让我们继续为我们的list_of_ints容器实现const_iterator

由于const_iterator类型的代码大部分将与iterator类型的代码完全相同,我们的第一反应可能是剪切和粘贴。但这是 C++!当我这么说“大部分代码将与另一部分代码完全相同”时,你应该在想“让我们将公共部分变成一个模板。”确实,这就是我们将要做的:

    struct list_node {
      int data;
      list_node *next;
    };

    template<bool Const>
    class list_of_ints_iterator {
      friend class list_of_ints;
      friend class list_of_ints_iterator<!Const>;

      using node_pointer = std::conditional_t<Const, const list_node*, list_node*>;
      using reference = std::conditional_t<Const, const int&, int&>;

      node_pointer ptr_;

      explicit list_of_ints_iterator(node_pointer p) : ptr_(p) {}
    public:
      reference operator*() const { return ptr_->data; }
      auto& operator++() { ptr_ = ptr_->next; return *this; }
      auto operator++(int) { auto result = *this; ++*this; return result; }

      // Support comparison between iterator and const_iterator types
      template<bool R>
      bool operator==(const list_of_ints_iterator<R>& rhs) const
        { return ptr_ == rhs.ptr_; }

      template<bool R>
      bool operator!=(const list_of_ints_iterator<R>& rhs) const
        { return ptr_ != rhs.ptr_; }

      // Support implicit conversion of iterator to const_iterator
      // (but not vice versa)
      operator list_of_ints_iterator<true>() const
        { return list_of_ints_iterator<true>{ptr_}; }
    };

    class list_of_ints {
      list_node *head_ = nullptr;
      list_node *tail_ = nullptr;
      // ...
    public:
      using const_iterator = list_of_ints_iterator<true>;
      using iterator = list_of_ints_iterator<false>;

      iterator begin() { return iterator{head_}; }
      iterator end() { return iterator{nullptr}; }
      const_iterator begin() const { return const_iterator{head_}; }
      const_iterator end() const { return const_iterator{nullptr}; }
    };

上述代码实现了对list_of_ints完全符合 const 的迭代器类型。

一对迭代器定义了一个范围

现在我们已经理解了迭代器的根本概念,让我们将其应用于一些实际用途。我们已经看到,如果你有一个由begin()end()返回的迭代器对,你可以使用 for 循环遍历底层容器的所有元素。但更强大的是,你可以使用一对迭代器来遍历容器元素中的任何子范围!比如说,你只想查看向量的前半部分:

    template<class Iterator>
    void double_each_element(Iterator begin, Iterator end) 
    {
      for (auto it = begin; it != end; ++it) {
        *it *= 2;
      } 
    }

    int main() 
    {
      std::vector<int> v {1, 2, 3, 4, 5, 6};
      double_each_element(v.begin(), v.end());
        // double each element in the entire vector
      double_each_element(v.begin(), v.begin()+3);
        // double each element in the first half of the vector
      double_each_element(&v[0], &v[3]);
        // double each element in the first half of the vector
    }

注意,在 main() 的第一个和第二个测试用例中,我们传递了从 v.begin() 派生的迭代器对;也就是说,两个 std::vector::iterator 类型的值。在第三个测试用例中,我们传递了两个 int* 类型的值。由于在这种情况下 int* 满足迭代器类型的所有要求——即它是可增量的、可比较的和可解引用的——我们的代码即使与指针一起使用也能正常工作!这个例子展示了迭代器对模型的灵活性。(然而,一般来说,如果你使用的是像 std::vector 这样的容器,它提供了适当的 iterator 类型,你应该避免与原始指针打交道。请使用从 begin()end() 派生的迭代器。)

我们可以说,一对迭代器隐式地定义了一个数据元素的范围。对于大量令人惊讶的算法,这已经足够了!我们不需要访问 容器 就能执行某些搜索或转换;我们只需要访问正在搜索或转换的特定 范围 的元素。沿着这条思路进一步思考最终将引导我们到 非拥有视图 的概念(这类似于 C++ 引用对单个变量的关系),但视图和范围仍然是更现代的概念,在我们讨论那些事情之前,我们应该先完成 1998 年风格的 STL。

在之前的代码示例中,我们看到了第一个真正的 STL 风格泛型算法的例子。诚然,double_each_element 并不是一个在实现我们可能在其他程序中希望重用的行为方面的非常泛型算法;但这个函数版本的泛型性现在在仅操作 Iterators 对(其中 Iterator 可以是任何实现了增量、比较和解引用能力的类型)方面是完美的。(我们将在本书的下一章中看到这个算法在这个意义上的一个更泛型的版本,当我们讨论 std::transform 时。)

迭代器类别

让我们重新回顾一下我们在之前代码示例中介绍的 countcount_if 函数。

第一章,经典多态和泛型编程。比较这个下一个示例中的函数模板定义与该章节中类似的代码;你会发现除了用一对 Iterators(即隐式定义的范围)替换了 Container& 参数——以及我将第一个函数的名称从 count 改为 distance 之外,它们是相同的。这是因为你可以在标准模板库中几乎以这里描述的完全相同的方式找到这个函数,命名为 std::distance,你可以在 std::count_if 的名称下找到第二个函数:

    template<typename Iterator>
    int distance(Iterator begin, Iterator end) 
    {
      int sum = 0;
      for (auto it = begin; it != end; ++it) {
        sum += 1;
      }
      return sum;
    }

    template<typename Iterator, typename Predicate>
    int count_if(Iterator begin, Iterator end, Predicate pred) 
    {
      int sum = 0;
      for (auto it = begin; it != end; ++it) {
        if (pred(*it)) {
            sum += 1;
        }
      }
      return sum; 
    }

    void test() 
    {
      std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};

      int number_above = count_if(v.begin(), v.end(), [](int e) { return e > 5; });
      int number_below = count_if(v.begin(), v.end(), [](int e) { return e < 5; });

      int total = distance(v.begin(), v.end()); // DUBIOUS 

      assert(number_above == 2);
      assert(number_below == 5);
      assert(total == 8);
    }

但让我们考虑那个例子中标记为DUBIOUS的行。在这里,我们通过反复递增一个迭代器直到它达到另一个迭代器来计算两个迭代器之间的距离。这种方法有多高效?对于某些类型的迭代器——例如,list_of_ints::iterator——我们可能无法做得比这更好。但对于迭代连续数据的vector::iteratorint*,当我们可以在 O(1)时间内通过简单的指针减法完成相同的事情时,使用循环和 O(n)算法就显得有些愚蠢。也就是说,我们希望标准库版本的std::distance包含一个类似于以下的模板特殊化:

    template<typename Iterator>
    int distance(Iterator begin, Iterator end)
    {
      int sum = 0;
      for (auto it = begin; it != end; ++it) {
        sum += 1;
      }
      return sum;
    }

    template<> 
    int distance(int *begin, int *end) 
    {
      return end - begin;
    }

但我们不想让这种专业化只存在于int*std::vector::iterator上。我们希望标准库中的std::distance对所有支持这种特定操作的迭代器类型都有效。也就是说,我们开始有了这样的直觉:存在(至少)两种不同的迭代器:一种是可递增、可比较和可解引用的;然后还有一种是可递增、可比较、可解引用,并且还可以进行减法操作! 事实证明,对于任何可以进行操作i = p - q的迭代器类型,其逆操作q = p + i也是有意义的。支持减法和加法的迭代器被称为随机访问迭代器

因此,标准库中的std::distance应该对随机访问迭代器和其他类型的迭代器都有效。为了使提供这些模板的部分特殊化更容易,标准库引入了迭代器种类层次的概念。例如,支持加法和减法的int*迭代器被称为随机访问迭代器。我们将说它们满足RandomAccessIterator的概念。

比随机访问迭代器稍微弱一些的迭代器可能不支持任意距离的加法和减法,但它们至少支持使用++p--p进行递增和递减。这种性质的迭代器被称为BidirectionalIterator。所有RandomAccessIterator都是BidirectionalIterator,但反之不一定成立。在某种意义上,我们可以将RandomAccessIterator想象成是相对于BidirectionalIterator的子类或子概念;我们可以说BidirectionalIterator是一个更弱的概念,它提出了更少的要求,相对于RandomAccessIterator

一种更弱的概念是那些甚至不支持递减的迭代器。例如,我们的list_of_ints::iterator类型不支持递减,因为我们的链表没有前向指针;一旦你得到了指向列表中某个元素的迭代器,你只能向前移动到后面的元素,而不能向后移动到前面的元素。支持++p但不支持--p的迭代器被称为ForwardIteratorForwardIterator是一个比BidirectionalIterator更弱的概念。

输入和输出迭代器

我们甚至可以想象比 ForwardIterator 更弱的概念!例如,你可以用 ForwardIterator 做的一件有用的事情是复制它,保存这个副本,然后使用它对相同的数据进行两次迭代。操作迭代器(或其副本)根本不会影响底层的数据范围。但我们可以发明一个像下面片段中的迭代器,其中根本没有底层数据,甚至复制迭代器都没有意义:

    class getc_iterator {
      char ch;
    public:
      getc_iterator() : ch(getc(stdin)) {}
      char operator*() const { return ch; }
      auto& operator++() { ch = getc(stdin); return *this; }
      auto operator++(int) { auto result(*this); ++*this; return result; }
      bool operator==(const getc_iterator&) const { return false; }
      bool operator!=(const getc_iterator&) const { return true; }
    };

(实际上,标准库包含一些与这个非常相似的迭代器类型;我们将在第九章iostreams中讨论这种类型,即 std::istream_iterator。)这种迭代器,不可有意义地复制,并且不在任何有意义的意义上指向数据元素,被称为 InputIterator 类型。

反映这种情况也是可能的。考虑以下发明的迭代器类型:

    class putc_iterator {
      struct proxy {
        void operator= (char ch) { putc(ch, stdout); }
      };
    public:
      proxy operator*() const { return proxy{}; }
      auto& operator++() { return *this; }
      auto& operator++(int) { return *this; }
      bool operator==(const putc_iterator&) const { return false; }
      bool operator!=(const putc_iterator&) const { return true; }
    };

    void test()
    {
      putc_iterator it;
      for (char ch : {'h', 'e', 'l', 'l', 'o', '\n'}) {
        *it++ = ch;
      }
    }

(同样,标准库包含一些与这个非常相似的迭代器类型;我们将在第三章迭代器对算法中讨论 std::back_insert_iterator,在第九章iostreams中讨论 std::ostream_iterator。)这种迭代器,不可有意义地复制,可写入但不可读出,被称为 OutputIterator 类型。

C++ 中的每个迭代器类型至少属于以下五个类别之一:

  • InputIterator

  • OutputIterator

  • ForwardIterator

  • BidirectionalIterator,和/或

  • RandomAccessIterator

注意,虽然编译时很容易确定一个特定的迭代器类型是否符合 BidirectionalIteratorRandomAccessIterator 的要求,但仅从它支持的语法操作中,我们无法确定我们是在处理 InputIteratorOutputIterator 还是 ForwardIterator。在我们刚才的例子中,考虑一下:getc_iteratorputc_iteratorlist_of_ints::iterator 支持完全相同的语法操作——使用 *it 解引用,使用 ++it 增量,以及使用 it != it 进行比较。这三个类仅在语义层面上有所不同。那么,标准库如何区分它们呢?

事实上,标准库需要从每个新迭代器的实现者那里得到一点帮助。标准库的算法仅与定义了名为 iterator_category成员类型别名 的迭代器类一起工作。也就是说:

    class getc_iterator {
      char ch;
    public:
      using iterator_category = std::input_iterator_tag;

      // ...
    };

    class putc_iterator {
      struct proxy {
        void operator= (char ch) { putc(ch, stdout); }
      };
    public:
      using iterator_category = std::output_iterator_tag;

      // ...
    };

    template<bool Const>
    class list_of_ints_iterator {
      using node_pointer = std::conditional_t<Const, const list_node*,
       list_node*>;
      node_pointer ptr_;

    public:
      using iterator_category = std::forward_iterator_tag;

      // ...
    };

然后,任何想要根据其模板类型参数的迭代器类别来定制其行为的标准(或者,天哪,非标准)算法都可以通过检查这些类型的 iterator_category 来简单地完成这种定制。

前一段中描述的迭代器类别对应于在 <iterator> 头文件中定义的以下五个标准标记类型:

    struct input_iterator_tag { };
    struct output_iterator_tag { };
    struct forward_iterator_tag : public input_iterator_tag { };
    struct bidirectional_iterator_tag : public forward_iterator_tag { };
    struct random_access_iterator_tag : public bidirectional_iterator_tag
    { };

注意random_access_iterator_tag实际上(在经典面向对象、多态类层次结构的意义上)从bidirectional_iterator_tag派生,依此类推:迭代器种类的概念层次结构反映在iterator_category标签类的类层次结构中。这最终在模板元编程中很有用,当你进行标签分派时;但为了使用标准库的目的,你需要知道的是,如果你想要将一个iterator_category传递给一个函数,一个类型为random_access_iterator_tag的标签将匹配一个期望参数类型为bidirectional_iterator_tag的函数:

    void foo(std::bidirectional_iterator_tag t [[maybe_unused]])
    {
      puts("std::vector's iterators are indeed bidirectional..."); 
    }

    void bar(std::random_access_iterator_tag)
    {
      puts("...and random-access, too!");
    }

    void bar(std::forward_iterator_tag)
    {
      puts("forward_iterator_tag is not as good a match");
    }

    void test()
    {
      using It = std::vector<int>::iterator;
      foo(It::iterator_category{});
      bar(It::iterator_category{});
    }

到目前为止,你可能正在想:“但是关于int*怎么办?我们如何为根本不是类类型的原始标量类型提供一个成员类型定义?标量类型不能有成员类型定义。”好吧,就像软件工程中的大多数问题一样,这个问题可以通过添加一层间接引用来解决。标准算法总是小心地直接引用T::iterator_category,而不是std::iterator_traits<T>::iterator_category。当T是一个指针类型时,类模板std::iterator_traits<T>会相应地专门化。

此外,std::iterator_traits<T>证明是一个方便的地方来挂载其他成员类型定义。如果T本身提供了所有五个(或者如果T是一个指针类型),则它提供了以下五个成员类型定义:iterator_categorydifference_typevalue_typepointerreference

将所有这些综合起来

将本章所学的一切综合起来,我们现在可以编写如下示例代码。在这个例子中,我们正在实现自己的list_of_ints,包括我们自己的迭代器类(包括一个正确的const_iterator版本);并且我们通过提供五个至关重要的成员类型定义,使其能够与标准库一起工作。

    struct list_node {
      int data;
      list_node *next;
    };

    template<bool Const>
    class list_of_ints_iterator {
      friend class list_of_ints;
      friend class list_of_ints_iterator<!Const>;

      using node_pointer = std::conditional_t<Const, const list_node*,
        list_node*>;
      node_pointer ptr_;

      explicit list_of_ints_iterator(node_pointer p) : ptr_(p) {}
    public:
      // Member typedefs required by std::iterator_traits
      using difference_type = std::ptrdiff_t;
      using value_type = int;
      using pointer = std::conditional_t<Const, const int*, int*>;
      using reference = std::conditional_t<Const, const int&, int&>;
      using iterator_category = std::forward_iterator_tag;

      reference operator*() const { return ptr_->data; }
      auto& operator++() { ptr_ = ptr_->next; return *this; }
      auto operator++(int) { auto result = *this; ++*this; return result; }

      // Support comparison between iterator and const_iterator types
      template<bool R>
      bool operator==(const list_of_ints_iterator<R>& rhs) const
        { return ptr_ == rhs.ptr_; }

      template<bool R>
      bool operator!=(const list_of_ints_iterator<R>& rhs) const
        { return ptr_ != rhs.ptr_; }

      // Support implicit conversion of iterator to const_iterator
      // (but not vice versa)
      operator list_of_ints_iterator<true>() const { return 
        list_of_ints_iterator<true>{ptr_}; }
    };

    class list_of_ints {
      list_node *head_ = nullptr;
      list_node *tail_ = nullptr;
      int size_ = 0;
    public:
      using const_iterator = list_of_ints_iterator<true>;
      using iterator = list_of_ints_iterator<false>;

      // Begin and end member functions
      iterator begin() { return iterator{head_}; }
      iterator end() { return iterator{nullptr}; }
      const_iterator begin() const { return const_iterator{head_}; }
      const_iterator end() const { return const_iterator{nullptr}; }

      // Other member operations
      int size() const { return size_; }
      void push_back(int value) {
        list_node *new_tail = new list_node{value, nullptr};
        if (tail_) {
          tail_->next = new_tail;
        } else {
          head_ = new_tail;
        }
        tail_ = new_tail;
        size_ += 1;
      }
      ~list_of_ints() {
        for (list_node *next, *p = head_; p != nullptr; p = next) {
          next = p->next;
          delete p;
        }
      }
    };

然后,为了表明我们理解标准库如何实现泛型算法,我们将按照 C++17 标准库的实现方式,精确地实现函数模板distancecount_if

注意在distance中使用 C++17 的新if constexpr语法。在这本书中,我们不会过多地讨论 C++17 的核心语言特性,但可以说,你可以使用if constexpr来消除与 C++14 相比需要编写的许多尴尬的样板代码。

    template<typename Iterator>
    auto distance(Iterator begin, Iterator end)
    {
      using Traits = std::iterator_traits<Iterator>;
      if constexpr (std::is_base_of_v<std::random_access_iterator_tag,
        typename Traits::iterator_category>) {
          return (end - begin);
        } else {
         auto result = typename Traits::difference_type{};
         for (auto it = begin; it != end; ++it) {
           ++result;
         }
         return result;
      }
    }

    template<typename Iterator, typename Predicate>
    auto count_if(Iterator begin, Iterator end, Predicate pred) 
    {
      using Traits = std::iterator_traits<Iterator>;
      auto sum = typename Traits::difference_type{};
      for (auto it = begin; it != end; ++it) {
        if (pred(*it)) {
          ++sum;
        }
      }
      return sum;
    }

    void test()
    {
       list_of_ints lst;
       lst.push_back(1);
       lst.push_back(2);
       lst.push_back(3);
       int s = count_if(lst.begin(), lst.end(), [](int i){
          return i >= 2;
       });
       assert(s == 2);
       int d = distance(lst.begin(), lst.end());
       assert(d == 3);
    }

在下一章中,我们将停止从头实现我们自己的许多函数模板,而是开始遍历标准模板库提供的函数模板。但在我们离开对迭代器的深入讨论之前,还有一件事我想谈谈。

已弃用的 std::iterator

你可能会想:“我实现的每个迭代器类都需要提供相同的五个成员类型定义。这有很多样板代码——很多我希望能抽象出来的打字工作。”难道没有一种方法可以消除所有这些样板代码吗?

好吧,在 C++98 以及直到 C++17,标准库包含了一个辅助类模板来做到这一点。它的名字是 std::iterator,它接受五个模板类型参数,这些参数对应于 std::iterator_traits 所需的五个成员类型定义。其中三个参数有“合理的默认值”,这意味着最简单的用例得到了很好的覆盖:

    namespace std {
      template<
        class Category,
        class T,
        class Distance = std::ptrdiff_t,
        class Pointer = T*,
        class Reference = T&
      > struct iterator {
        using iterator_category = Category;
        using value_type = T;
        using difference_type = Distance;
        using pointer = Pointer;
        using reference = Reference;
      };
    }

    class list_of_ints_iterator :
      public std::iterator<std::forward_iterator_tag, int>
    {
       // ...
    };

然而,对于 std::iterator 来说,现实生活并没有那么简单;std::iterator 在 C++17 被废弃,原因我们即将讨论。

正如我们在 常量迭代器 部分所看到的,常量正确性要求我们为每个“非常量迭代器”类型提供相应的常量迭代器类型。因此,按照那个例子,我们最终得到的代码是这样的:

    template<
      bool Const,
      class Base = std::iterator<
        std::forward_iterator_tag,
        int,
        std::ptrdiff_t,
        std::conditional_t<Const, const int*, int*>,
        std::conditional_t<Const, const int&, int&>
      >
    >
    class list_of_ints_iterator : public Base
    {
      using typename Base::reference; // Awkward!

      using node_pointer = std::conditional_t<Const, const list_node*,
        list_node*>;
      node_pointer ptr_;

    public:
      reference operator*() const { return ptr_->data; }
      // ...
    };

上述代码与没有使用 std::iterator 的版本相比,在可读性和可写性上并没有任何优势;此外,按照预期的方式使用 std::iterator 会使我们的代码复杂化,引入了 公有继承,也就是说,看起来非常像经典面向对象类层次结构。一个初学者可能会被诱惑在编写像这样的函数时使用那个类层次结构:

    template<typename... Ts, typename Predicate>
    int count_if(const std::iterator<Ts...>& begin,
                 const std::iterator<Ts...>& end,
                 Predicate pred);

这看起来表面上类似于我们来自 第一章 的“多态编程”示例,经典多态和泛型编程,一个通过接受基类引用类型的参数来实现不同行为的函数。但在 std::iterator 的情况下,这种相似性纯粹是偶然的,并且具有误导性;从 std::iterator 继承并不会给我们一个多态类层次结构,并且从我们的函数中引用那个“基类”永远不是正确的事情!

因此,C++17 标准废弃了 std::iterator,目的是在 2020 年或之后的某个标准中完全删除它。你不应该在编写的代码中使用 std::iterator

然而,如果你在你的代码库中使用 Boost,你可能想查看 std::iterator 的 Boost 等价物,它被拼写为 boost::iterator_facade。与 std::iterator 不同,boost::iterator_facade 基类为一些令人烦恼的成员函数提供了默认功能,例如 operator++(int)operator!=,否则这些功能将是繁琐的样板代码。要使用 iterator_facade,只需从它继承并定义一些原始成员函数,例如 dereferenceincrementequal。(由于我们的列表迭代器是 ForwardIterator,这就足够了。对于 BidirectionalIterator,你还需要提供一个 decrement 成员函数,依此类推。)

由于这些原始成员函数是 private 的,我们通过声明 friend class boost::iterator_core_access; 授予 Boost 对它们的访问权限:

    #include <boost/iterator/iterator_facade.hpp>

    template<bool Const>
    class list_of_ints_iterator : public boost::iterator_facade<
      list_of_ints_iterator<Const>,
      std::conditional_t<Const, const int, int>,
      std::forward_iterator_tag
    >
    {
      friend class boost::iterator_core_access;
      friend class list_of_ints;
      friend class list_of_ints_iterator<!Const>;

      using node_pointer = std::conditional_t<Const, const list_node*,
        list_node*>;
      node_pointer ptr_;

      explicit list_of_ints_iterator(node_pointer p) : ptr_(p) {} 

      auto& dereference() const { return ptr_->data; }
      void increment() { ptr_ = ptr_->next; }

      // Support comparison between iterator and const_iterator types
      template<bool R>
      bool equal(const list_of_ints_iterator<R>& rhs) const {
        return ptr_ == rhs.ptr_;}

    public:
      // Support implicit conversion of iterator to const_iterator
      // (but not vice versa)
      operator list_of_ints_iterator<true>() const { return
        list_of_ints_iterator<true>{ptr_}; }
    };

注意到boost::iterator_facade的第一个模板类型参数始终是你正在编写的类的定义:这是奇特重复的模板模式,我们将在第六章“智能指针”中再次看到。

使用boost::iterator_facade的此列表迭代器代码比上一节中的相同代码要短得多;节省主要来自于不必重复关系运算符。因为我们的列表迭代器是一个ForwardIterator,所以我们只有两个关系运算符;但如果它是一个RandomAccessIterator,那么iterator_facade将生成基于单个原始成员函数distance_to的运算符 -<><=>= 的默认实现。

摘要

在本章中,我们了解到遍历是你可以对数据结构做的最基本的事情之一。然而,仅使用原始指针不足以遍历复杂结构:对原始指针应用++操作通常不会以预期的“移动到下一个项目”的方式进行。

C++标准模板库提供了迭代器的概念,它是原始指针的泛化。两个迭代器定义了一个范围的数据。这个范围可能是容器内容的一部分;或者它可能根本不依赖于任何内存,就像我们在getc_iteratorputc_iterator中看到的那样。迭代器类型的某些属性编码在其迭代器类别中--输入、输出、前向、双向或随机访问--以利于可以使用更快算法的某些迭代器类别的函数模板。

如果你正在定义自己的容器类型,你还需要定义自己的迭代器类型--包括 const 和非 const 版本。模板是做这件事的便捷方式。在实现自己的迭代器类型时,避免使用已弃用的std::iterator,但可以考虑使用boost::iterator_facade

第三章:迭代器对算法

现在你已经了解了迭代器类型——既包括标准提供的也包括用户定义的——现在是时候看看你可以用迭代器做什么了。

本章你将学习:

  • “半开范围”的概念,这确定了两个迭代器如何定义一个范围

  • 如何将每个标准算法分类为“只读”、“只写”、“转换”或“排列”;以及作为“单范围”、“双范围”或“一又一半范围”

  • 一些标准算法,如mergemake_heap,仅仅是构建更高层次实体(如stable_sortpriority_queue)所必需的构建块。

  • 如何根据除operator<之外的比较器对范围进行排序

  • 如何使用erase-remove 习语操作排序数组

关于头文件的说明

本章讨论的大多数函数模板都定义在标准头文件<algorithm>中。另一方面,特殊的迭代器类型通常定义在<iterator>中。如果你想知道如何找到特定的实体,我强烈建议你咨询在线参考资料,如cppreference.com,以获得权威答案;不要只是猜测!

只读范围算法

在前面的章节中,我们构建了一个我们称之为distance的算法,另一个称为count_if。这两个算法都出现在标准库中。

std::count_if(a,b,p)返回满足谓词函数p的元素数量,即在ab之间,使得p(e)true的元素数量e

注意,每当说到“在ab之间”,我们都是在谈论包括*a但不包括*b的范围——数学家称之为“半开范围”,并用不对称的符号[a,b)表示。为什么我们不能包括*b呢?首先,如果b是某个向量的end(),那么它根本不指向该向量的任何元素!所以一般来说,解引用范围的终点是一件危险的事情。其次,使用半开范围方便地允许我们表示范围;例如,“从xx”的范围是一个包含零数据元素的空范围。

在 C++中,半开范围与在 C 中一样自然。几十年来,我们一直在编写从下界(包含)到上界(不包含)的范围的 for 循环;这个习语如此常见,以至于偏离这个习语通常表明存在错误:

    constexpr int N = 10;
    int a[N];

    // A correct for-loop.
    for (int i=0; i < N; ++i) {
      // ...
    }

    // One variety of "smelly" for-loop.
    for (int i=0; i <= N; ++i) {
      // ... 
    }

    // A correct invocation of a standard algorithm.
    std::count_if(std::begin(a), std::end(a), [](int){ return true; });

    // A "smelly" invocation.
    std::count_if(std::begin(a), std::end(a) - 1, [](int){ return true; });

    // A "trivial" invocation: counting a range of length zero.
    std::count_if(std::begin(a), std::begin(a), [](int){ return true; });

std::distance(a,b)返回ab之间的元素数量——也就是说,你需要将++应用于a多少次才能到达b。你可以将这个函数视为在效果上等同于std::count_if(a,b,[](auto&&){return true;})

正如我们在 第二章,迭代器和范围 中所看到的,如果相关的迭代器是随机访问迭代器,这个数字可以快速计算为 (b - a),因此标准 std::distance 会这样做。请注意,(b - a) 可能是一个负数,如果你以“错误”的顺序给出了参数!

    int a[] {1, 2, 3, 4, 5};
    std::list<int> lst {1, 2, 3, 4, 5};
    std::forward_list<int> flst {1, 2, 3, 4, 5};

    assert(std::distance(std::begin(a), std::end(a)) == 5);
    assert(std::distance(std::begin(lst), std::end(lst)) == 5);
    assert(std::distance(std::begin(lst), std::end(lst)) == 5);

    assert(std::distance(std::end(a), std::begin(a)) == -5);

当迭代器是随机访问迭代器时,std::distance 实际上只是进行减法操作;因此,传递“错误顺序”的参数是明确支持并由 C++ 标准认可的。然而,如果相关的迭代器仅仅是双向迭代器(例如 std::list<int>::iterator——见 第四章,容器动物园),则不支持“错误顺序”的迭代器。你可能期望对于所有迭代器类型,std::distance(b,a) == -std::distance(a,b) 应该成立;但考虑一下,std::distance 算法本身如何知道你给出的迭代器是否“错误顺序”呢?它唯一能做的事情(在没有 operator- 的情况下)是不断递增 a——可能超过容器的末尾,进入空间——在徒劳的希望中,它最终会到达 b

    // The following line gives an "incorrect" answer!
    // assert(std::distance(std::end(lst), std::begin(lst)) == 1);
    // And this one just segfaults!
    // std::distance(std::end(flst), std::begin(flst));

请参考 第四章 中 std::liststd::forward_list 的图示,容器动物园,以理解这个代码示例的奇怪行为。

std::count(a,b,v) 返回 ab 之间等于 v 的元素数量——也就是说,对于 e == v 为真的元素 e 的数量。你可以将这个函数视为在效果上等同于 std::count_if(a,b,&v{return e == v;}),实际上两种版本应该给出相同的汇编代码。如果 C++ 在 1998 年就有 lambda 表达式,他们可能就不会将 std::count 算法放入标准库中。

注意到 std::count(a,b,v) 必然会遍历 ab 之间的 所有 元素。它无法利用你可能对范围内数据排列的任何特殊信息。例如,假设我想计算 std::set<int>42 的实例?我可以以下两种方式之一编写代码:

    std::set<int> s { 1, 2, 3, 10, 42, 99 };
    bool present;

    // O(n): compare each element with 42
    present = std::count(s.begin(), s.end(), 42);

    // O(log n): ask the container to look up 42 itself
    present = s.count(42);

原始算法 std::count 在性能上不如第二种方法,后者只是简单地向 set 本身请求答案。这把整个集合的 O(n) 遍历转换成了 O(log n) 的树查找。同样,std::unordered_set 提供了一个大致为 O(1) 的 count 方法。

关于这些容器,更多内容请参阅第四章 《容器动物园》;目前这里的关键点是,有时你的数据中存在重要的结构,可以通过选择合适的工具来利用。尽管我在指出标准算法似乎“神奇地”做了正确的事情(例如 std::distance 委派给 (b - a)),但你不应想象这种“魔法”比它所做的那样更远。标准算法只知道它们被告知的内容,也就是说,只关于你传递给它们的 迭代器类型 的属性。它们永远不会根据 底层数据元素 之间的关系改变其行为。安排你的代码以利用底层数据中的关系(例如,“这些数据是有序的”,“这个范围跨越整个容器”)是作为程序员的你工作的一部分。

这里有一些类似于 std::countstd::count_if 的算法。

std::find(a,b,v)std::find_if(a,b,p) 的功能与 std::count(a,b,v)std::count_if(a,b,p) 分别相似,区别在于,find 变体不是遍历整个范围并返回匹配元素的 计数,而是只循环到找到第一个匹配项,然后返回指向匹配数据元素的迭代器。还有一个变体 find_if_not,它与 find_if 类似,但谓词的感测被否定;如果我们在 C++ 的早期历史中得到了 lambdas,这个变体可能就不需要存在了:

    template<class InputIterator, class UnaryPredicate>
    InputIterator find_if(InputIterator first, InputIterator last,
      UnaryPredicate p) 
    {
      for (; first != last; ++first) {
        if (p(*first)) {
          return first;
        }
      }
      return last;
    }

    template<class It, class U>
    It find_if_not(It first, It last, U p) {
      return std::find_if(first, last, &{ return !p(e); }); 
    }

    template<class It, class T>
    It find(It first, It last, T value) {
      return std::find_if(first, last, &
        { return e == value; }); 
    }

注意,因为 find 在找到第一个匹配项时立即返回,所以它平均来说比 count 算法(无论什么情况都会扫描整个范围)要快。这种“立即返回”的行为通常被称为“短路”。

std::all_of(a,b,p)std::any_of(a,b,p)std::none_of(a,b,p) 根据提供的谓词函数 p 在范围中的元素中为真的频率返回 truefalse。它们都可以建立在 find 算法之上,从而免费获得短路行为:

    template<class It, class UnaryPredicate>
    bool all_of(It first, It last, UnaryPredicate p)
    {
      return std::find_if_not(first, last, p) == last;
    }

    template <class It, class U>
    bool any_of(It first, It last, U p)
    {
      return std::find_if(first, last, p) != last;
    }

    template <class It, class U>
    bool none_of(It first, It last, U p)
    {
      return std::find_if(first, last, p) == last;
    }

我还应该提一下一个与 find 相关的算法:find_first_of。它实现了在序列中查找固定集合中目标元素首次出现的操作——也就是说,就像 C 标准库中的 strcspn,但适用于任何类型,而不仅仅是 char。抽象地说,find_first_of 接受两个概念参数:要搜索的范围和目标元素集合。由于这是 STL,它们都作为范围传递,也就是说,迭代器对。因此,对这个算法的调用看起来像 find_first_of(haystack, haystack, needle, needle):并排的两个迭代器对。这可能会让人困惑——当算法接受多个类似参数时要小心!

    template <class It, class FwdIt>
    It find_first_of(It first, It last, FwdIt targetfirst,
      FwdIt targetlast)
    {
      return std::find_if(first, last, & {
        return std::any_of(targetfirst, targetlast, & {
          return e == t;
        });
      });
    }

    template <class It, class FwdIt, class BinaryPredicate>
    It find_first_of(It first, It last, FwdIt targetfirst,
      FwdIt targetlast, BinaryPredicate p)
    {
      return std::find_if(first, last, & {
        return std::any_of(targetfirst, targetlast, & {
          return p(e, t);
        });
      });
    }

注意,“稻草堆”迭代器预期是任何旧的InputIterator类型,但“针”迭代器必须至少是ForwardIterator。回想一下第二章,“迭代器和范围”,ForwardIterator类型的一个重要特点是它们可以被有意义地复制,使得相同的范围可以被多次遍历。这正是find_first_of所需要的!它对“稻草堆”范围中的每个字符进行一次遍历;因此,“针”必须是可重遍历的——顺便说一下,还必须是有限大小的!相反,没有特别要求“稻草堆”必须是有限的;它可能从可能无界的输入流中提取其元素:

    std::istream_iterator<char> ii(std::cin);
    std::istream_iterator<char> iend{};
    std::string s = "hello";

    // Chomp characters from std::cin until finding an 'h', 'e', 'l', or 'o'.
    std::find_first_of(ii, iend, s.begin(), s.end());

谈到多个相似参数,让我们通过这两个来结束对简单只读算法的探讨:std::equalstd::mismatch

std::equal(a,b,c,d)接受两个迭代器对:范围a,b)和范围[c,d)。如果两个范围元素逐个相等,则返回true,否则返回false

std::mismatch(a,b,c,d)有点像find:它会告诉你确切哪一对元素破坏了匹配:

    template<class T> constexpr bool is_random_access_iterator_v =
      std::is_base_of_v<std::random_access_iterator_tag, typename 
      std::iterator_traits<T>::iterator_category>;

    template<class It1, class It2, class B>
    auto mismatch(It1 first1, It1 last1, It2 first2, It2 last2, B p)
    {
      while (first1 != last1 && first2 != last2 && p(*first1, *first2)) {
        ++first1;
        ++first2;
      }
      return std::make_pair(first1, first2);
    }

    template<class It1, class It2>
    auto mismatch(It1 first1, It1 last1, It2 first2, It2 last2)
    {
      return std::mismatch(first1, last1, first2, last2, std::equal_to<>{});
    }

    template<class It1, class It2, class B>
    bool equal(It1 first1, It1 last1, It2 first2, It2 last2, B p)
    {
      if constexpr (is_random_access_iterator_v<It1> &&
        is_random_access_iterator_v<It2>) {
        // Ranges of different lengths can never be equal.
        if ((last2 - first2) != (last1 - first1)) {
          return false;
        }
      }
      return std::mismatch(first1, last1, first2, last2, p) ==
        std::make_pair(last1, last2);
    }

    template<class It1, class It2>
    bool equal(It1 first1, It1 last1, It2 first2, It2 last2)
    {
      return std::equal(first1, last1, first2, last2, std::equal_to<>{});
    }

注意到使用了std::equal_to<>{}作为谓词对象;在这本书中,我们不会深入探讨内置谓词,所以请假设std::equal_to<>{}是一个行为类似于[{ return a == b; }的对象,但涉及更多的完美转发

最后,再次注意!C++17 标准库中的许多双范围算法也有被称为半范围算法的变体形式。例如,除了std::mismatch(a,b,c,d)之外,你还会发现std::mismatch(a,b,c)——第二个范围的“结束”点简单地假设为c + std::distance(a, b)。如果c实际上指向一个容器,其中c + std::distance(a, b)将是“超出范围”,那么,运气不佳!

因为“运气不佳”永远不是对技术问题的真正伟大回答,C++17 标准为许多在 C++14 中存在的半范围算法添加了安全的双范围变体。

使用 std::copy 移动数据

我们刚刚看到了几个双范围算法。<algorithm>头文件充满了双范围算法及其兄弟半范围算法。这种算法可能有多简单?

一个合理的回答可能是:“将每个数据元素从第一个范围复制到第二个范围。”实际上,STL 提供了这个算法,名为std::copy

    template<class InIt, class OutIt>
    OutIt copy(InIt first1, InIt last1, OutIt destination)
    {
      while (first1 != last1) {
        *destination = *first1;
        ++first1;
        ++destination;
      }
      return destination;
    }

注意,这是一个半范围算法。标准库实际上没有提供std::copy的双范围版本;假设如果你实际上正在尝试写入缓冲区,那么你一定已经检查了它的大小,所以在循环中检查“我们是否到达了缓冲区的末尾”将是既冗余又低效的。

现在,我可以几乎听到你在惊叹:“天哪!这正是导致我们有了 strcpysprintfgets 的那种粗糙逻辑!这是对缓冲区溢出的邀请!”好吧,如果你这样惊叹,那么你对 gets 的不良行为判断是正确的——实际上,gets 函数已经被正式从 C++17 标准库中移除。你对 sprintf 的看法也是正确的——任何需要该功能的人最好使用经过范围检查的版本 snprintf,在这个上下文中,它类似于一个“双范围算法”。但关于 strcpy,我不同意。对于 gets,确定输出缓冲区的正确大小是不可能的;对于 sprintf,是困难的;但对于 strcpy,是微不足道的*:你只需测量输入缓冲区的 strlen,这就是你的答案。同样,对于 std::copy,"输入元素消耗" 和 "输出元素产生" 之间的关系是一对一,因此输出缓冲区的大小并不构成技术挑战。

注意,我们称之为 destination 的参数是一个输出迭代器。这意味着我们可以使用 std::copy,不仅可以在内存中移动数据,甚至可以将数据提供给任意的“接收”函数。例如:

    class putc_iterator : public boost::iterator_facade<
      putc_iterator, // T
      const putc_iterator, // value_type
      std::output_iterator_tag
      >
    {
      friend class boost::iterator_core_access;

       auto& dereference() const { return *this; }
       void increment() {}
       bool equal(const putc_iterator&) const { return false; }
       public:
       // This iterator is its own proxy object!
       void operator= (char ch) const { putc(ch, stdout); }
    };

    void test()
    {
      std::string s = "hello";
      std::copy(s.begin(), s.end(), putc_iterator{});
    }

你可能会发现将这个版本的 putc_iterator 与 第二章 中提到的版本进行比较是有益的;这个版本使用了在 第二章 的末尾介绍的 boost::iterator_facade,并且还使用了一个常见的技巧来返回 *this 而不是一个新的代理对象。

现在,我们可以利用 destination 的灵活性来解决我们对缓冲区溢出的担忧!假设我们不是写入一个固定大小的数组,而是写入一个可调整大小的 std::vector(参见 第四章 的“容器动物园”)。那么,“写入一个元素”对应于“在向量上推入一个元素”。因此,我们可以编写一个非常类似于 putc_iterator 的输出迭代器,它将使用 push_back 而不是 putc,然后我们就有了一种防止溢出的填充向量的方法。实际上,标准库在 <iterator> 头文件中就提供了这样的输出迭代器:

    namespace std {
      template<class Container>
      class back_insert_iterator {
        using CtrValueType = typename Container::value_type;
        Container *c;
      public:
        using iterator_category = output_iterator_tag;
        using difference_type = void;
        using value_type = void;
        using pointer = void;
        using reference = void;

        explicit back_insert_iterator(Container& ctr) : c(&ctr) {}

        auto& operator*() { return *this; }
        auto& operator++() { return *this; }
        auto& operator++(int) { return *this; }

        auto& operator= (const CtrValueType& v) {
            c->push_back(v);
            return *this;
        }
        auto& operator= (CtrValueType&& v) {
            c->push_back(std::move(v));
            return *this;
        }
      };

      template<class Container>
      auto back_inserter(Container& c)
      {
         return back_insert_iterator<Container>(c);
      }
    }

    void test()
    {
      std::string s = "hello";
      std::vector<char> dest;
      std::copy(s.begin(), s.end(), std::back_inserter(dest));
      assert(dest.size() == 5);
    }

函数调用 std::back_inserter(dest) 简单地返回一个 back_insert_iterator 对象。在 C++17 中,我们可以依赖模板类型推导来构造函数,并将该函数体的内容简单地写为 return std::back_insert_iterator(dest);或者完全省略该函数,直接在我们的代码中写 std::back_insert_iterator(dest)--在 C++14 代码中则必须使用 std::back_inserter(dest) 来“应付”。然而,为什么我们要输入那么多额外的代码?名称 back_inserter 被故意选择为易于记忆,因为它是我们预期最常使用的。尽管 C++17 允许我们用 std::pair 替代 std::make_pair,用 std::tuple 替代 std::make_tuple,但在 C++17 中用繁琐的 std::back_insert_iterator 替代 std::back_inserter 是愚蠢的。即使在 C++17 中,你也应该首选 std::back_inserter(dest)

主题变奏 - std::move 和 std::move_iterator

如你所猜,或者你可能已经在前面的实现中注意到,std::copy 算法通过从输入范围复制元素到输出工作。截至 C++11,你可能会想:如果我们不是 复制 元素,而是使用移动语义将它们从输入 移动 到输出会怎样?

STL 为此问题提供了两种不同的方法。第一种方法是最直接的:有一个 std::move 算法(定义在 <algorithm> 头文件中),其定义如下:

    template<class InIt, class OutIt>
    OutIt move(InIt first1, InIt last1, OutIt destination)
    {
      while (first1 != last1) {
        *destination = std::move(*first1);
        ++first1;
        ++destination;
      }
      return destination;
    }

它与 std::copy 算法完全相同,只是在输入元素上添加了一个 std::move 操作(小心--这个内部 std::move,带有一个 参数,定义在 <utility> 头文件中,与定义在 <algorithm> 中的外部三个参数的 std::move 完全不同!它们共享一个名称是不幸的。讽刺的是,其他少数 STL 函数也遭受了类似的情况,比如 std::remove;参见 从排序数组中删除 部分,以及 第十二章,文件系统)。

另一种方法是我们之前看到的 back_inserter 的变体。而不是更换核心 算法,我们可以继续使用 std::copy 但以不同的方式参数化。假设我们传递了一个新的迭代器类型,它(就像 back_inserter 一样)围绕我们的原始对象并改变其行为?特别是,我们需要一个输入迭代器,其 operator* 返回一个右值。我们可以做到这一点!

    template<class It>
    class move_iterator {
      using OriginalRefType = typename std::iterator_traits<It>::reference;
      It iter;
      public:
       using iterator_category = typename
         std::iterator_traits<It>::iterator_category;
       using difference_type = typename
         std::iterator_traits<It>::difference_type;
       using value_type = typename std::iterator_traits<It>::value_type;
       using pointer = It;
       using reference = std::conditional_t<
         std::is_reference_v<OriginalRefType>,
         std::remove_reference_t<OriginalRefType>&&,
         OriginalRefType
         >;

       move_iterator() = default;
       explicit move_iterator(It it) : iter(std::move(it)) {}

       // Allow constructing or assigning from any kind of move-iterator.
       // These templates also serve as our own type's copy constructor
       // and assignment operator, respectively.
       template<class U>
       move_iterator(const move_iterator<U>& m) : iter(m.base()) {}
       template<class U>
       auto& operator=(const move_iterator<U>& m)
         { iter = m.base(); return *this; }

       It base() const { return iter; }

       reference operator*() { return static_cast<reference>(*iter); }
       It operator->() { return iter; }
       decltype(auto) operator[](difference_type n) const 
         { return *std::move(iter[n]); }

      auto& operator++() { ++iter; return *this; }
      auto& operator++(int)
        { auto result = *this; ++*this; return result; }
      auto& operator--() { --iter; return *this; }
      auto& operator--(int)
        { auto result = *this; --*this; return result; } 

      auto& operator+=(difference_type n) const
        { iter += n; return *this; }
      auto& operator-=(difference_type n) const
        { iter -= n; return *this; }
    };

    // I've omitted the definitions of non-member operators
    // == != < <= > >= + - ; can you fill them in?

    template<class InputIterator>
    auto make_move_iterator(InputIterator& c) 
    {
      return move_iterator(c);
    }

对于这段代码的密集性,我表示歉意;请相信你可以安全地跳过细节。对于那些喜欢这类东西的人来说,你可能注意到我们提供了一个从 move_iterator<U> 到模板构造函数,它恰好也充当了我们的复制构造函数(当 UIt 类型相同时);我们还提供了许多成员函数(例如 operator[]operator--),它们的主体对于许多可能的 It 类型(例如,当 It 是一个前向迭代器时--见第二章,迭代器和范围)将产生错误,但这是可以的,因为它们的主体只有在用户实际在编译时尝试调用这些函数时才会实例化(如果用户实际上尝试对 move_iterator<list_of_ints::iterator> 进行 -- 操作,那么当然会产生编译时错误)。

就像 back_inserter 一样,请注意,STL 为那些没有构造函数模板类型推导的预 C++17 编译器提供了一个辅助函数 make_move_iterator。在这种情况下,就像 make_pairmake_tuple 一样,"辅助" 名称比实际类名更丑陋,所以我建议你在代码中使用 C++17 的特性;如果你不需要,为什么要多打五个字符并实例化一个额外的函数模板呢?

现在我们有两种不同的方式将数据从一个容器或范围移动到另一个:std::move 算法和 std::move_iterator 适配器类。以下是这两种习惯用法的示例:

    std::vector<std::string> input = {"hello", "world"};
    std::vector<std::string> output(2);

    // First approach: use the std::move algorithm
    std::move(input.begin(), input.end(), output.begin());

    // Second approach: use move_iterator
    std::copy(
      std::move_iterator(input.begin()),
      std::move_iterator(input.end()),
      output.begin()
    );

第一种方法,使用 std::move,如果你只是移动数据,显然要干净得多。那么,为什么标准库要提供这种“更混乱”的方法 move_iterator 呢?为了回答这个问题,我们不得不探索另一个与 std::copy 基本相关的算法。

使用 std::transform 进行复杂复制

你可能已经注意到了,当我们之前展示了 std::copy 的实现时,两个迭代器类型参数的 value_type 并没有限制必须相同。这是一个特性,而不是错误!这意味着我们可以编写依赖于隐式转换的代码,并且它将正确地执行:

    std::vector<const char *> input = {"hello", "world"};
    std::vector<std::string> output(2);

    std::copy(input.begin(), input.end(), output.begin());

    assert(output[0] == "hello");
    assert(output[1] == "world");

看起来很简单,对吧?仔细看看!在我们对 std::copy 的实例化中,有一个调用隐式构造函数,它将 const char **input.begin() 的类型)转换为 std::string*output.begin() 的类型)。所以,我们又一次看到了一个示例,即通用代码通过简单地提供某些迭代器类型,就能执行令人惊讶的复杂操作。

但有时你希望在复制操作期间应用一个复杂的转换函数--比隐式转换更复杂的函数。标准库已经为你准备好了!

    template<class InIt, class OutIt, class Unary>
    OutIt transform(InIt first1, InIt last1, OutIt destination, Unary op)
    {
      while (first1 != last1) {
        *destination = op(*first1);
        ++first1;
        ++destination;
      }
      return destination;
    }

    void test() 
    {
      std::vector<std::string> input = {"hello", "world"};
      std::vector<std::string> output(2);

      std::transform(
        input.begin(),
        input.end(),
        output.begin(),
        [](std::string s) {
          // It works for transforming in-place, too!
          std::transform(s.begin(), s.end(), s.begin(), ::toupper);
          return s;
        }
      );

      assert(input[0] == "hello");
      assert(output[0] == "HELLO");
    }

有时候,你需要使用一个接受 两个 参数的函数来进行转换。库已经为你准备好了:

    template<class InIt1, class InIt2, class OutIt, class Binary>
    OutIt transform(InIt1 first1, InIt1 last1, InIt2 first2, OutIt destination,
      Binary op)
    {
      while (first1 != last1) {
        *destination = op(*first1, *first2);
        ++first1;
        ++first2;
        ++destination;
      }
      return destination;
    }

这个版本的 std::transform 可以幽默地描述为一种一又二分之一的范围算法!

(关于三个参数的函数?四个参数的函数?不幸的是,std::transform 没有完全可变参数版本;可变模板直到 C++11 才被引入到 C++ 中。你可以尝试实现一个可变参数版本,看看会遇到什么问题——它们是可克服的,但绝对不是微不足道的。)

std::transform 的存在为我们提供了将数据元素从一个地方移动到另一个地方的第三种方法:

    std::vector<std::string> input = {"hello", "world"};
    std::vector<std::string> output(2);

    // Third approach: use std::transform
    std::transform(
      input.begin(),
      input.end(),
      output.begin(),
      std::move<std::string&>
    );

我当然不推荐这种方法!它的最大和最明显的红旗是它包含了 std::move 模板的显式特化。每当你在模板名称后看到显式特化——那些模板名称后的尖括号——这几乎可以肯定是非常微妙和脆弱的代码。高级读者可能会喜欢弄清楚编译器如何推断出我指的是两个 std::move 中的哪一个;记住,一个在 <utility> 中,一个在 <algorithm> 中。

只写范围算法

我们在本章开始时查看了一些算法,例如 std::find,这些算法遍历一个范围,按顺序读取其元素而不进行修改。你可能会惊讶地发现逆操作也是有意义的:存在一组标准算法,它们遍历一个范围 修改 每个元素而不读取它!

std::fill(a,b,v) 做的正如其名所暗示的那样:将给定范围 [a,b) 的每个元素填充为提供的值 v 的副本。

std::iota(a,b,v) 稍微更有趣:它将给定范围的元素填充为 ++v 的副本。也就是说,std::iota(a,b,42) 将将 a[0] 设置为 42,a[1] 设置为 43,a[2] 设置为 44,以此类推,直到 b。这个算法有趣的名字来源于 APL 编程语言,其中名为 ι(希腊字母 iota)的函数执行了这个操作。这个算法的另一个有趣之处在于,出于某种原因,它的定义可以在标准 <numeric> 头文件中找到,而不是在 <algorithm> 中。它就是这样一种怪异的算法。

std::generate(a,b,g) 更有趣:它将给定范围的元素填充为 g() 的连续结果,无论它是什么:

    template<class FwdIt, class T>
    void fill(FwdIt first, FwdIt last, T value) {
      while (first != last) {
        *first = value;
         ++first;
      }
    }

    template<class FwdIt, class T>
    void iota(FwdIt first, FwdIt last, T value) {
      while (first != last) {
        *first = value;
        ++value;
        ++first;
      }
    }

    template<class FwdIt, class G>
    void generate(FwdIt first, FwdIt last, G generator) {
      while (first != last) {
        *first = generator();
        ++first;
      }
    }

这里是使用这些标准算法填充具有不同内容的字符串向量的示例。测试你的理解:你是否理解为什么每个调用会产生这样的输出?我选择的 std::iota 的例子特别有趣(但在现实世界的代码中不太可能有用):

    std::vector<std::string> v(4);

    std::fill(v.begin(), v.end(), "hello");
    assert(v[0] == "hello");
    assert(v[1] == "hello");
    assert(v[2] == "hello");
    assert(v[3] == "hello");

    std::iota(v.begin(), v.end(), "hello");
    assert(v[0] == "hello");
    assert(v[1] == "ello");
    assert(v[2] == "llo");
    assert(v[3] == "lo");

    std::generate(v.begin(), v.end(), [i=0]() mutable {
      return ++i % 2 ? "hello" : "world";
    });
    assert(v[0] == "hello");
    assert(v[1] == "world");
    assert(v[2] == "hello");
    assert(v[3] == "world");

影响对象生命周期的算法

<memory> 头文件提供了一组名为 std::uninitialized_copystd::uninitialized_default_constructstd::destroy(完整列表,请参考在线参考资料,如 cppreference.com)的晦涩算法。考虑以下使用显式析构函数调用销毁范围元素的算法:

    template<class T>
    void destroy_at(T *p)
    {
      p->~T();
    }

    template<class FwdIt>
    void destroy(FwdIt first, FwdIt last)
    {
      for ( ; first != last; ++first) {
        std::destroy_at(std::addressof(*first));
      }
    }

注意,std::addressof(x)是一个方便的小辅助函数,它返回其参数的地址;它与&x完全相同,只是在x是某些类类型并且残酷地重载了自己的operator&的罕见情况下除外。

考虑这个使用显式 placement-new 语法“复制构造”到范围元素中的算法(注意,如果在复制过程中抛出异常,它会干净利落地清理)。这个算法显然不应该用于任何已经存在元素的任何范围;所以以下例子看起来非常牵强:

    template<class It, class FwdIt>
    FwdIt uninitialized_copy(It first, It last, FwdIt out)
    {
      using T = typename std::iterator_traits<FwdIt>::value_type;
      FwdIt old_out = out;
      try {
        while (first != last) {
          ::new (static_cast<void*>(std::addressof(*out))) T(*first);
          ++first;
          ++out;
        }
        return out;
      } catch (...) {
        std::destroy(old_out, out);
        throw;
      }
    }

    void test()
    { 
      alignas(std::string) char b[5 * sizeof (std::string)];  
      std::string *sb = reinterpret_cast<std::string *>(b);

      std::vector<const char *> vec = {"quick", "brown", "fox"};

      // Construct three std::strings.
      auto end = std::uninitialized_copy(vec.begin(), vec.end(), sb);

      assert(end == sb + 3);

      // Destroy three std::strings.
      std::destroy(sb, end);
    }

我们将在第四章中了解更多关于这些算法应该如何使用的信息,容器动物园,当我们讨论std::vector时。

我们的第一个排列算法:std::sort

到目前为止,我们讨论的所有算法都是简单地按顺序遍历它们给定的范围,线性地从第一个元素到下一个元素。我们下一系列的算法不会这样表现。相反,它将给定范围中元素的值打乱,使得相同的值仍然出现,但顺序不同。这种操作的数学名称是排列。

最简单的排列算法要描述的是std::sort(a,b)。它做的是名字暗示的事情:对给定的范围进行排序,使得最小的元素出现在前面,最大的元素出现在后面。为了确定哪些元素是“最小的”,std::sort(a,b)使用operator<

如果你想有不同的顺序,你可以尝试重载operator<以在不同的条件下返回true--但可能你应该使用算法的三参数版本,std::sort(a,b,cmp)。第三个参数应该是一个比较器;也就是说,一个函数、仿函数或 lambda,当其第一个参数“小于”第二个参数时返回true。例如:

    std::vector<int> v = {3, 1, 4, 1, 5, 9};
    std::sort(v.begin(), v.end(), [](auto&& a, auto&& b) {
      return a % 7 < b % 7;
    });
    assert((v == std::vector{1, 1, 9, 3, 4, 5}));

注意,我在这个例子中仔细选择了我的 lambda,以便以确定的方式对数组进行排序。如果我用函数(a % 6 < b % 6)代替,那么可能会有两种可能的输出:要么是{1, 1, 3, 9, 4, 5},要么是{1, 1, 9, 3, 4, 5}。标准的sort算法对于在给定比较函数下恰好相等的元素的相对位置没有任何保证!

为了解决这个问题(如果它确实是一个问题),你应该将你的std::sort使用替换为std::stable_sort。后者可能稍微慢一点,但它将保证在相等元素的情况下保留原始顺序--也就是说,在这种情况下,我们将得到{1, 1, 3, 9, 4, 5},因为在原始(未排序)向量中,元素3在元素9之前。

使用 sortstable_sort 还可能发生更糟糕的事情——如果我选择了比较函数 (a % 6 < b) 会怎样?那么我就会有一些元素对 x, y,其中 x < y 同时 y < x!(原始向量中的一个这样的元素对是 59。)在这种情况下,没有什么可以拯救我们;我们传递了一个“比较函数”,而这个函数根本就不是比较函数!这与传递空指针给 std::sort 的先决条件相违背。在排序数组时,确保你是基于一个有意义的比较函数进行排序!

交换、反转和划分

STL 除了 std::sort 之外还包含大量排列算法。许多这些算法可以被视为“构建块”,它们仅实现了整体排序算法的一小部分。

std::swap(a,b) 是最基本的构建块;它只是接受它的两个参数并将它们“交换”——也就是说,它交换它们的值。这是通过给定类型的移动构造函数和移动赋值运算符实现的。swap 在标准算法中实际上有点特殊,因为它是一个如此原始的操作,而且几乎总是有比执行 temp = a; a = b; b = temp; 等效操作更快的方式来交换两个任意对象。对于标准库类型(如 std::vector)的常用惯例是类型本身实现一个 swap 成员函数(如 a.swap(b)),然后在类型的同一命名空间中添加 swap 函数的重载——也就是说,如果我们正在实现 my::obj,我们会在命名空间 my 中添加重载,这样对于该特定类型的 swap(a,b),将调用 a.swap(b) 而不是执行三个移动操作。以下是一个例子:

    namespace my {
      class obj {
        int v;
      public:
        obj(int value) : v(value) {}

        void swap(obj& other) {
          using std::swap;
          swap(this->v, other.v);
        }
      };

      void swap(obj& a, obj& b) {
        a.swap(b);
      }
    } // namespace my

    void test()
    {
      int i1 = 1, i2 = 2;
      std::vector<int> v1 = {1}, v2 = {2};
      my::obj m1 = 1, m2 = 2;
      using std::swap;
      swap(i1, i2); // calls std::swap<int>(int&, int&)
      swap(v1, v2); // calls std::swap(vector&, vector&)
      swap(m1, m2); // calls my::swap(obj&, obj&)
    }

现在我们有了 swap 和双向迭代器,我们可以构建 std::reverse(a,b),这是一个排列算法,它通过交换第一个元素与最后一个元素、第二个元素与倒数第二个元素,依此类推,简单地反转元素范围的顺序。std::reverse 的一个常见应用是反转字符串中较大的块顺序——例如,反转句子中单词的顺序:

    void reverse_words_in_place(std::string& s)
    {
      // First, reverse the whole string.
      std::reverse(s.begin(), s.end());

      // Next, un-reverse each individual word.
      for (auto it = s.begin(); true; ++it) {
        auto next = std::find(it, s.end(), ' ');
        // Reverse the order of letters in this word.
        std::reverse(it, next);
        if (next == s.end()) {
          break;
        }
        it = next;
      }
    }

    void test()
    {
      std::string s = "the quick brown fox jumps over the lazy dog";
      reverse_words_in_place(s);
      assert(s == "dog lazy the over jumps fox brown quick the");
    }

std::reverse 的实现进行一点小的调整,我们得到了排序的另一个构建块,即 std::partition。与 std::reverse 从两端遍历范围无条件地交换每一对元素不同,std::partition 只有在元素相对于某个谓词函数“顺序错误”时才交换它们。在以下示例中,我们将所有 偶数 元素划分到范围的起始位置,所有 奇数 元素划分到范围的末尾。如果我们使用 std::partition 来构建 Quicksort 排序程序,我们将把小于枢轴元素的元素划分到范围的起始位置,把大于枢轴元素的元素划分到范围的末尾:

    template<class BidirIt>
    void reverse(BidirIt first, BidirIt last)
    {
      while (first != last) {
        --last;
        if (first == last) break;
        using std::swap;
        swap(*first, *last);
        ++first;
      }
    }

    template<class BidirIt, class Unary>
    auto partition(BidirIt first, BidirIt last, Unary p)
    {
      while (first != last && p(*first)) {
        ++first;
      }

      while (first != last) {
        do {
          --last;
        } while (last != first && !p(*last));
        if (first == last) break;
        using std::swap;
        swap(*first, *last);
        do {
          ++first;
        } while (first != last && p(*first));
      }
      return first;
    }

    void test()  
    {
      std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6, 5};
      auto it = std::partition(v.begin(), v.end(), [](int x) {
        return x % 2 == 0;
      });
      assert(it == v.begin() + 3);
      assert((v == std::vector{6, 2, 4, 1, 5, 9, 1, 3, 5}));
    }

你可能会注意到前面代码的一个有趣之处:reversepartition的代码几乎完全相同!唯一的区别是partition包含一个令人不快的 do-while 循环,而reverse只有简单的递增或递减。

你可能也注意到partition中的第一个 do-while 循环与我们之前看到的标准算法等价;即std::find_if_not。第二个 do-while 循环类似于std::find_if... 但它需要向后运行,而不是向前!不幸的是,我们没有std::rfind_if这样的算法。但是——正如你可能已经猜到的——标准库不会让我们陷入困境。

我们需要一个在std::find_if的目的上表现得像迭代器,但迭代“反向”的东西。标准库以std::reverse_iterator<FwdIt>适配器的形式提供了这个确切的东西。我们不会展示它的代码;如果你需要复习如何实现它,请回顾第二章,迭代器和范围。简而言之,std::reverse_iterator<FwdIt>对象就像一个FwdIt对象一样包装和表现,除了当你递增包装器时,它会递减被包装的对象,反之亦然。因此,我们可以用reverse_iterator来写partition,如下所示:

    // Shorthands for "reversing" and "unreversing".
    template<class It>
    auto rev(It it) {
      return std::reverse_iterator(it);
    };

    template<class InnerIt>
    auto unrev(std::reverse_iterator<InnerIt> it) {
      return it.base();
    }

    template<class BidirIt, class Unary>
    auto partition(BidirIt first, BidirIt last, Unary p)
    {
      first = std::find_if_not(first, last, p);

      while (first != last) {
        last = unrev(std::find_if(rev(last), rev(first), p));
        if (first == last) break;
        using std::swap;
        swap(*first++, *--last);
        first = std::find_if_not(first, last, p);
      }
      return first;
    }

当然,有时在保持每个分区中元素相对顺序不变的情况下对范围进行分区是有用的。在这些情况下,可以使用std::stable_partition(a,b,p)(但请参阅关于stable_partition的警告部分:它可能会使用operator new分配内存)。

有一些非排列算法也处理分区:

std::is_partitioned(a,b,p)如果给定的范围已经通过谓词p分区(即满足p的所有元素都在前面,而不满足p的所有元素都在后面),则返回true

std::partition_point(a,b,p)使用二分查找来找到已经分区范围内不满足p的第一个元素。

std::partition_copy(a,b,ot,of,p)将范围[a,b)中的每个元素复制到输出迭代器之一:对于满足p(e)的元素,*ot++ = e;对于不满足p(e)的元素,*of++ = e

顺便说一下,如果你只想得到一个输出序列或另一个,那么你可以分别使用std::copy_if(a,b,ot,p)std::remove_copy_if(a,b,of,p)

旋转和排列

记得我们来自 交换、反转和分区 的代码,用来反转句子中单词的顺序吗?当“句子”只包含两个单词时,还有另一种看待反转的方法:你可以将其视为底层范围中元素的 循环旋转std::rotate(a,mid,b) 将范围 [a,b) 的元素旋转,使得原本由 mid 指向的元素现在位于 a(并返回一个指向原本位于 a 的元素的迭代器):

    template<class FwdIt>
    FwdIt rotate(FwdIt a, FwdIt mid, FwdIt b)
    {
      auto result = a + (b - mid);

      // First, reverse the whole range.
      std::reverse(a, b);

      // Next, un-reverse each individual segment.
      std::reverse(a, result);
      std::reverse(result, b);

      return result;
    }

    void test()
    {
      std::vector<int> v = {1, 2, 3, 4, 5, 6};
      auto five = std::find(v.begin(), v.end(), 5);
      auto one = std::rotate(v.begin(), five, v.end());
      assert((v == std::vector{5, 6, 1, 2, 3, 4}));
      assert(*one == 1);
    }

另一个杂项但有时有用的排列算法是 std::next_permutation(a,b)。在循环中调用此函数将遍历所有 n 个元素的排列,这可能在你尝试暴力解决旅行商问题(小规模实例)时很有用:

    std::vector<int> p = {10, 20, 30};
    std::vector<std::vector<int>> results;

    // Collect the permutations of these three elements.
    for (int i=0; i < 6; ++i) {
      results.push_back(p);
      std::next_permutation(p.begin(), p.end());
    }

    assert((results == std::vector<std::vector<int>>{
      {10, 20, 30},
      {10, 30, 20},
      {20, 10, 30},
      {20, 30, 10},
      {30, 10, 20},
      {30, 20, 10},
    }));

注意,next_permutation 使用“小于”关系来确定一个排列在字典序上“小于”另一个排列;例如,{20, 10, 30} 在字典序上“小于” {20, 30, 10},因为 10 小于 30。因此,next_permutation 也有一个基于比较器的版本:std::next_permutation(a,b,cmp)。还有 std::prev_permutation(a,b)std::prev_permutation(a,b,cmp),它们在字典序上“向下”计数而不是“向上”。

顺便说一句,要按这种方式在字典序上比较两个序列,你可以使用来自 只读范围算法 部分的 std::mismatch,或者你可以直接使用标准提供的 std::lexicographical_compare(a,b,c,d)

堆和堆排序

std::make_heap(a,b)(或其基于比较器的版本,std::make_heap(a,b,cmp))接受一个未排序的元素范围,并将它们重新排列成一个满足最大堆属性的顺序:具有最大堆属性的数组中,索引 i 的每个元素将至少与索引 2i+1 和 2i+2 的元素之一相等。这意味着所有元素中的最大值将位于索引 0。这表明,最大元素将位于索引 0。

std::push_heap(a,b)(或其基于比较器的版本)假设范围 [a,b-1) 已经是一个最大堆。它将当前位于 b[-1] 的元素“冒泡”起来,通过与堆中的父元素交换,直到整个范围 [a,b) 的最大堆属性得到恢复。请注意,make_heap 可以通过简单地循环调用 std::push_heap(a,++b) 来实现。

std::pop_heap(a,b)(或其基于比较器的版本)假设范围 [a,b) 已经是一个最大堆。它将 a[0]b[-1] 交换,使得最大元素现在位于范围的 尾部 而不是 前端;然后它与堆中的一个子元素交换,依此类推,“冒泡”下来直到最大堆属性得到恢复。在调用 pop_heap(a,b) 之后,最大元素将位于 b[-1],范围 [a, b-1) 将具有最大堆属性。

std::sort_heap(a,b)(或其基于比较器的版本)接受一个具有最大堆属性的范围,并通过重复调用std::pop_heap(a, b--)将其排列成排序顺序。

使用这些构建块,我们可以实现经典的“堆排序”算法。标准库中的std::sort函数可能合理地实现如下(但在实践中通常实现为混合算法,例如“introsort”):

    template<class RandomIt>
    void push_heap(RandomIt a, RandomIt b)
    {
      auto child = ((b-1) - a);
      while (child != 0) {
        auto parent = (child - 1) / 2;
        if (a[child] < a[parent]) {
          return; // max-heap property has been restored
        }
        std::iter_swap(a+child, a+parent);
        child = parent;
      }
    }

    template<class RandomIt>
    void pop_heap(RandomIt a, RandomIt b)
    {
      using DistanceT = decltype(b - a);

      std::iter_swap(a, b-1);

      DistanceT parent = 0;
      DistanceT new_heap_size = ((b-1) - a);

      while (true) {
        auto leftchild = 2 * parent + 1;
        auto rightchild = 2 * parent + 2;
        if (leftchild >= new_heap_size) {
          return;
        }
        auto biggerchild = leftchild;
        if (rightchild < new_heap_size && a[leftchild] < a[rightchild]) {
          biggerchild = rightchild;
        }
        if (a[biggerchild] < a[parent]) {
          return; // max-heap property has been restored
        }
        std::iter_swap(a+parent, a+biggerchild);
        parent = biggerchild;
      }
    }

    template<class RandomIt>
    void make_heap(RandomIt a, RandomIt b)
    {
      for (auto it = a; it != b; ) {
        push_heap(a, ++it);
      }
    }

    template<class RandomIt>
    void sort_heap(RandomIt a, RandomIt b)
    {
      for (auto it = b; it != a; --it) {
        pop_heap(a, it);
      }
    }

    template<class RandomIt>
    void sort(RandomIt a, RandomIt b)
    {
      make_heap(a, b);
      sort_heap(a, b);
    }

我们将在第四章“容器动物园”中看到push_heappop_heap的另一个应用,当我们讨论std::priority_queue时。

合并和归并排序

既然我们谈论到了排序算法,让我们以不同的方式编写sort

std::inplace_merge(a,mid,b)接受一个已经通过std::sort(a,mid)std::sort(mid,b)排序的范围a,b),并将两个子范围合并成一个排序的范围。我们可以使用这个构建块来实现经典的归并排序算法:

    template<class RandomIt>
    void sort(RandomIt a, RandomIt b)
    {
      auto n = std::distance(a, b);
      if (n >= 2) {
        auto mid = a + n/2;
        std::sort(a, mid);
        std::sort(mid, b);
        std::inplace_merge(a, mid, b);
      }
    }

然而,请注意!名称inplace_merge似乎暗示合并是在“原地”发生的,无需任何额外的缓冲空间;但实际上并非如此。实际上,inplace_merge函数会为其自身分配一个缓冲区,通常是通过调用operator new。如果你在一个堆分配有问题的环境中编程,那么你应该避免使用inplace_merge

可能会在堆上分配临时缓冲区的其他标准算法是std::stable_sortstd::stable_partition

std::merge(a,b,c,d,o)是非分配合并算法;它接受两个迭代器对,代表范围[a,b)[c,d),并将它们合并到由o定义的输出范围中。

使用std::lower_bound在有序数组中进行搜索和插入

一旦数据范围被排序,就可以使用二分搜索在该数据内进行搜索,而不是使用较慢的线性搜索。实现二分搜索的标准算法称为std::lower_bound(a,b,v)

    template<class FwdIt, class T, class C>
    FwdIt lower_bound(FwdIt first, FwdIt last, const T& value, C lessthan)
    {
      using DiffT = typename std::iterator_traits<FwdIt>::difference_type;
      FwdIt it;
      DiffT count = std::distance(first, last);

      while (count > 0) {
        DiffT step = count / 2;
        it = first;
        std::advance(it, step);
        if (lessthan(*it, value)) {
          ++it;
          first = it;
          count -= step + 1;
        } else {
          count = step;
        }
      }
      return first;
    }

    template<class FwdIt, class T>
    FwdIt lower_bound(FwdIt first, FwdIt last, const T& value) 
    {
      return std::lower_bound(first, last, value, std::less<>{});
    }

此函数返回一个指向范围中第一个不小于给定值v的元素的迭代器。如果范围中已经存在该值的实例,则返回的迭代器将指向它(实际上,它将指向范围中的第一个这样的值)。如果没有该值的实例,则返回的迭代器将指向v应该放置的位置。

我们可以使用lower_bound的返回值作为vector::insert的输入,以便在保持其排序顺序的同时将v插入到排序向量的正确位置:

    std::vector<int> vec = {3, 7};
    for (int value : {1, 5, 9}) {
      // Find the appropriate insertion point...
      auto it = std::lower_bound(vec.begin(), vec.end(), value);
      // ...and insert our value there.
      vec.insert(it, value);
    }
    // The vector has remained sorted.
    assert((vec == std::vector{1, 3, 5, 7, 9}));

类似的函数 std::upper_bound(a,b,v) 返回一个指向范围中第一个大于给定值 v 的元素的迭代器。如果 v 不在给定的范围内,那么 std::lower_boundstd::upper_bound 将返回相同的值。但如果 v 存在于范围内,那么 lower_bound 将返回一个指向范围中 v 的第一个实例的迭代器,而 upper_bound 将返回一个指向范围中 v 的最后一个实例之后一个位置的迭代器。换句话说,使用这两个函数一起将给出一个包含仅 v 值实例的半开范围 [lower, upper)

    std::vector<int> vec = {2, 3, 3, 3, 4};
    auto lower = std::lower_bound(vec.begin(), vec.end(), 3);

    // First approach:
    // upper_bound's interface is identical to lower_bound's.
    auto upper = std::upper_bound(vec.begin(), vec.end(), 3);

    // Second approach:
    // We don't need to binary-search the whole array the second time.
    auto upper2 = std::upper_bound(lower, vec.end(), 3);
    assert(upper2 == upper);

    // Third approach:
    // Linear scan from the lower bound might well be faster
    // than binary search if our total range is really big.
    auto upper3 = std::find_if(lower, vec.end(), [ {
      return v != 3;
    });
    assert(upper3 == upper);

    // No matter which approach we take, this is what we end up with.
    assert(*lower >= 3);
    assert(*upper > 3);
    assert(std::all_of(lower, upper, [](int v) { return v == 3; }));

这处理了在有序数组中搜索和插入值的问题。但删除怎么办?

使用 std::remove_if 从有序数组中删除

在我们到目前为止关于标准泛型算法的所有讨论中,我们还没有涵盖如何从范围中删除元素的问题。这是因为“范围”的概念本质上是只读的:我们可能改变给定范围中元素的 ,但我们永远不能使用标准算法来缩短或延长 范围本身。在 使用 std::copy 推送数据 这一部分中,当我们使用 std::copy 向名为 dest 的向量“插入”时,并不是 std::copy 算法在进行插入;而是 std::back_insert_iterator 对象本身持有对底层容器的引用,并且能够将元素插入到容器中。std::copy 并没有将 dest.begin()dest.end() 作为参数;相反,它使用了特殊的对象 std::back_inserter(dest)

那么,我们如何从范围中删除项目呢?嗯,我们不能。我们所能做的就是从 容器 中删除项目;而 STL 的算法并不处理容器。因此,我们应该寻找一种重新排列范围值的方法,使得“删除”的项目最终会出现在可预测的位置,这样我们就可以快速地从底层容器中删除它们(使用除 STL 算法之外的其他方法)。

我们已经看到了一种可能的方法:

    std::vector<int> vec = {1, 3, 3, 4, 6, 8};

    // Partition our vector so that all the non-3s are at the front
    // and all the 3s are at the end.
    auto first_3 = std::stable_partition(
      vec.begin(), vec.end(), [](int v){ return v != 3; }
    );

    assert((vec == std::vector{1, 4, 6, 8, 3, 3}));

    // Now erase the "tail" of our vector.
    vec.erase(first_3, vec.end());

    assert((vec == std::vector{1, 4, 6, 8}));

但这比实际需要的要浪费得多(注意,stable_partition 是那些在堆上分配临时缓冲区的不多算法之一!)。我们想要的算法实际上要简单得多:

    template<class FwdIt, class T>
    FwdIt remove(FwdIt first, FwdIt last, const T& value) 
    {
      auto out = std::find(first, last, value);
      if (out != last) {
        auto in = out;
        while (++in != last) {
          if (*in == value) {
             // don't bother with this item
          } else {
             *out++ = std::move(*in);
          }
        }
      }
      return out;
    }

    void test()
    {
      std::vector<int> vec = {1, 3, 3, 4, 6, 8};

      // Partition our vector so that all the non-3s are at the front.
      auto new_end = std::remove(
        vec.begin(), vec.end(), 3
      );

      // std::remove_if doesn't preserve the "removed" elements.
      assert((vec == std::vector{1, 4, 6, 8, 6, 8}));

      // Now erase the "tail" of our vector.
      vec.erase(new_end, vec.end());

      assert((vec == std::vector{1, 4, 6, 8}));

      // Or, do both steps together in a single line.
      // This is the "erase-remove idiom":
      vec.erase(
        std::remove(vec.begin(), vec.end(), 3),
        vec.end()
      );

      // But if the array is very long, and we know it's sorted,
      // then perhaps it would be better to binary-search for
      // the elements to erase.
      // Here the "shifting-down" is still happening, but it's
      // happening inside vector::erase instead of inside std::remove.
      auto first = std::lower_bound(vec.begin(), vec.end(), 3);
      auto last = std::upper_bound(first, vec.end(), 3);
      vec.erase(first, last);
    }

std::remove(a,b,v) 从范围 [a,b) 中删除所有等于 v 的值。请注意,范围不必是有序的--但 remove 将通过“向下移动”非删除元素来填补范围中的空隙,从而保留原有的顺序。如果 remove 从范围中删除了 k 个元素,那么当 remove 函数返回时,范围末尾将有 k 个元素的值处于已移动状态,remove 的返回值将是一个指向第一个这种已移动元素的迭代器。

std::remove_if(a,b,p) 会移除所有满足给定谓词的元素;也就是说,它会移除所有使得 p(e) 为真的元素 e。就像 remove 一样,remove_if 会将元素向下移动以填充范围,并返回一个指向第一个“已移动”元素的迭代器。

从序列容器中删除项的常用惯用方法是所谓的 erase-remove 惯用方法,因为它涉及到将返回值直接传递到容器自己的 .erase() 成员函数。

另一个与 erase-remove 惯用方法一起工作的标准库算法是 std::unique(a,b),它接受一个范围,并对每一组连续的等效项,移除除了第一个之外的所有项。像 std::remove 一样,输入范围不需要排序;算法将保留最初存在的任何排序:

    std::vector<int> vec = {1, 2, 2, 3, 3, 3, 1, 3, 3};

    vec.erase(
      std::unique(vec.begin(), vec.end()),
      vec.end()
    );

    assert((vec == std::vector{1, 2, 3, 1, 3}));

最后,请注意,我们通常可以比 std::remove 做得更好,要么通过使用我们底层容器的 erase 成员函数(例如,我们将在下一章中看到 std::list::erase 可以比在 std::list 上的 erase-remove 惯用方法快得多)--即使我们从不需要排序顺序的向量中删除,我们通常也会更倾向于以下这样的泛型算法 unstable_remove,该算法已被提议用于未来的标准化,但在撰写本文时尚未被纳入 STL:

    namespace my {
      template<class BidirIt, class T>
      BidirIt unstable_remove(BidirIt first, BidirIt last, const T& value)
      {
        while (true) {
          // Find the first instance of "value"...
          first = std::find(first, last, value);
          // ...and the last instance of "not value"...
          do {
            if (first == last) {
              return last;
            }
            --last;
          } while (*last == value);
          // ...and move the latter over top of the former.
          *first = std::move(*last);
          // Rinse and repeat.
          ++first;
        }
      }
    } // namespace my

    void test()
    {
      std::vector<int> vec = {4, 1, 3, 6, 3, 8};

      vec.erase(
        my::unstable_remove(vec.begin(), vec.end(), 3),
        vec.end()
      );

      assert((vec == std::vector{4, 1, 8, 6}));
    }

在下一章中,我们将探讨 容器--STL 对“所有这些元素到底存储在哪里?”这一问题的回答。

摘要

标准模板库为几乎每个需求都提供了一个泛型算法。如果你在进行算法操作,首先检查 STL!

STL 算法处理由一对迭代器定义的半开区间。在处理任何一元半区间算法时都要小心。

处理比较和排序的 STL 算法默认使用 operator<,但你始终可以传递一个两个参数的“比较器”。如果你想在整个数据范围上执行非平凡操作,请记住 STL 可能直接支持它(std::movestd::transform)或通过特殊迭代器类型间接支持(std::back_inserterstd::istream_iterator)。

你应该知道“排列”是什么,以及标准排列算法(swapreverserotatepartitionsort)是如何相互实现的。只有三个 STL 算法(stable_sortstable_partitioninplace_merge)可能会默默地从堆中分配内存;如果你负担不起堆分配,请像躲避瘟疫一样避开这三个算法。

使用 erase-remove 惯用方法来维护序列容器的排序顺序,即使你在删除项时也是如此。如果你不关心排序顺序,可以使用类似 my::unstable_remove 的方法。对于支持 .erase() 的容器,请使用 .erase()

第四章:容器动物园

在前两章中,我们介绍了 迭代器范围 的概念(第二章,迭代器和范围)以及操作由这些迭代器定义的数据元素范围的庞大标准 泛型算法 库(第三章,迭代器对算法)。在本章中,我们将探讨这些数据元素本身是如何分配和存储的。也就是说,现在我们已经了解了如何迭代,问题变得紧迫:我们是在迭代 什么

在标准模板库中,对这个问题的回答通常是:我们正在迭代一个 容器 中包含的元素的一些子范围。容器简单地是一个 C++ 类(或类模板),根据其本质,包含(或拥有)一组同质的数据元素,并通过泛型算法公开该范围以进行迭代。

本章我们将涵盖的主题是:

  • 一个对象 拥有 另一个对象的概念(这是 容器范围 之间的本质区别)

  • 序列容器(arrayvectorlistforward_list

  • 迭代器无效化和引用无效化的陷阱

  • 容器适配器(stackqueuepriority_queue

  • 关联容器(setmap 和相关容器)

  • 当提供 比较器哈希函数等价比较器分配器 作为额外的模板类型参数是合适的时候

拥有权的概念

当我们说对象 A 拥有 对象 B 时,我们的意思是对象 A 管理对象 B 的生命周期--即 A 控制对象 B 的构造、复制、移动和销毁。对象 A 的用户可以(并且应该)“忘记”管理 B(例如,通过显式调用 delete Bfclose(B) 等)。

对象 A “拥有”对象 B 的最简单方式是让 B 成为 A 的成员变量。例如:

    struct owning_A {
      B b_;
    };

    struct non_owning_A {
      B& b_;
    };

    void test()
    {
      B b;

      // a1 takes ownership of [a copy of] b.
      owning_A a1 { b };

      // a2 merely holds a reference to b;
      // a2 doesn't own b.
      non_owning_A a2 { b };
    }

另一种方式是让 A 持有对 B 的指针,并在 ~A()(以及必要时在 A 的复制和移动操作中)中编写适当的代码来清理与该指针关联的资源:

    struct owning_A {
      B *b_;

      explicit owning_A(B *b) : b_(b) {}

      owning_A(owning_A&& other) : b_(other.b_) {
        other.b_ = nullptr;
      }

      owning_A& operator= (owning_A&& other) {
        delete b_;
        b_ = other.b_;
        other.b_ = nullptr;
        return *this;
      }

      ~owning_A() {
        delete b_;
      }
    };

    struct non_owning_A {
      B *b_;
    };

    void test()
    {
      B *b = new B;

      // a1 takes ownership of *b.
      owning_A a1 { b };

      // a2 merely holds a pointer to *b;
      // a2 doesn't own *b.
      non_owning_A a2 { b };
    }

拥有权的概念与 C++ 特有的口号 资源分配是初始化 紧密相关,这个口号通常缩写为 RAII。(这个繁琐的缩写本应更像是“资源释放是销毁”,但那个缩写已被占用。)

标准的 容器类 的目标是提供对特定的一组数据对象 B 的访问,同时确保这些对象的 所有权 总是清晰的——也就是说,容器总是拥有其数据元素的所有权。(相反,迭代器 或定义 范围 的迭代器对,永远不会拥有其数据元素;我们在 第三章,迭代器对算法 中看到,标准的基于迭代器的算法,如 std::remove_if,实际上从未真正释放任何元素,而是简单地以各种方式重新排列元素值。)

在本章的剩余部分,我们将探讨各种标准容器类。

最简单的容器:std::array<T, N>

最简单的标准容器类是 std::array<T, N>,它的行为就像内置的(“C 风格”)数组。std::array 的第一个模板参数指示数组元素的类型,第二个模板参数指示数组中的元素数量。这是标准库中非常少数几个模板参数是整数值而不是类型名称的地方。

图片

正常的 C 风格数组,作为核心语言的一部分(并且是追溯到 1970 年代的那一部分!),不提供任何会以线性时间运行的内置操作。C 风格数组允许你使用 operator[] 来索引它们,并比较它们的地址,因为这些操作可以在常数时间内完成;但如果你想要将一个 C 风格数组的全部内容赋值给另一个,或者比较两个数组的全部内容,你会发现你不能直接这样做。你必须使用我们在 第三章,迭代器对算法 中讨论的一些标准算法,例如 std::copystd::equal(函数模板 std::swap 已经是一个“算法”,确实 可以用于 C 风格数组。如果它不起作用,那就太遗憾了):

    std::string c_style[4] = {
      "the", "quick", "brown", "fox"
    };
    assert(c_style[2] == "brown");
    assert(std::size(c_style) == 4);
    assert(std::distance(std::begin(c_style), std::end(c_style)) == 4);

    // Copying via operator= isn't supported.
    std::string other[4]; 
    std::copy(std::begin(c_style), std::end(c_style), std::begin(other));

    // Swapping IS supported... in linear time, of course.
    using std::swap;
    swap(c_style, other);

    // Comparison isn't supported; you have to use a standard algorithm.
    // Worse, operator== does the "wrong" thing: address comparison!
    assert(c_style != other);
    assert(std::equal(
      c_style, c_style + 4,
      other, other + 4 
    ));
    assert(!std::lexicographical_compare(
      c_style, c_style + 4,
      other, other + 4
   ));

std::array 的行为就像 C 风格数组,但提供了更多的语法糖。它提供了 .begin().end() 成员函数;并且它重载了 =, ==, 和 < 运算符以执行自然的事情。所有这些操作仍然需要与数组大小成线性时间的开销,因为它们必须遍历数组,逐个复制(或交换或比较)每个单独的元素。

对于 std::array 有一点抱怨,你会在这些标准容器类中看到,那就是当你使用花括号内的初始化列表构造一个 std::array 时,你实际上需要写 两组 花括号。这是为 std::array<T, N> 类型的“外部对象”写的一组,以及为 T[N] 类型的“内部数据成员”写的一组。一开始这有点烦人,但一旦你使用了几次,双花括号语法会很快成为第二本能:

    std::array<std::string, 4> arr = {{
      "the", "quick", "brown", "fox"
    }};
    assert(arr[2] == "brown");

    // .begin(), .end(), and .size() are all provided.
    assert(arr.size() == 4);
    assert(std::distance(arr.begin(), arr.end()) == 4);

    // Copying via operator= is supported... in linear time.
    std::array<std::string, 4> other;
    other = arr;

    // Swapping is also supported... in linear time.
    using std::swap;
    swap(arr, other);

    // operator== does the natural thing: value comparison!
    assert(&arr != &other); // The arrays have different addresses... 
    assert(arr == other); // ...but still compare lexicographically equal.
    assert(arr >= other); // Relational operators are also supported.

std::array 的另一个好处是你可以从函数中返回一个,这是你不能用 C 风格数组做到的:

    // You can't return a C-style array from a function.
    // auto cross_product(const int (&a)[3], const int (&b)[3]) -> int[3];

    // But you can return a std::array.
    auto cross_product(const std::array<int, 3>& a,
     const std::array<int, 3>& b) -> std::array<int, 3>
    {
      return {{
        a[1] * b[2] - a[2] * b[1],
        a[2] * b[0] - a[0] * b[2],
        a[0] * b[1] - a[1] * b[0],
      }};
    }

因为 std::array 有拷贝构造函数和拷贝赋值运算符,你还可以将它们存储在容器中:例如,std::vector<std::array<int, 3>> 是可以的,而 std::vector<int[3]> 则不行。

然而,如果你发现自己经常从函数中返回数组或将数组存储在容器中,你应该考虑“数组”是否真的是你目的的正确抽象。将那个数组封装成某种类类型可能更合适吗?

在我们的 cross_product 示例中,将我们的“三个整数的数组”封装成一个类类型是一个非常极好的主意。这不仅允许我们命名成员(xyz),而且我们可以更容易地初始化 Vec3 类类型的对象(不需要第二对花括号!)并且也许最重要的是,为了我们未来的理智,我们可以避免定义比较运算符,如 operator<,这些运算符实际上对我们数学领域没有意义。使用 std::array,我们必须处理这样一个事实,即数组 {1, 2, 3} 与数组 {1, 3, -9} 相比是“小于”的——但当我们定义自己的 class Vec3 时,我们可以简单地省略对 operator< 的任何提及,从而确保没有人会意外地在数学环境中误用它:

    struct Vec3 {
      int x, y, z;
      Vec3(int x, int y, int z) : x(x), y(y), z(z) {}
    };

    bool operator==(const Vec3& a, const Vec3& b) {
      return std::tie(a.x, a.y, a.z) ==
         std::tie(b.x, b.y, b.z);
    }

    bool operator!=(const Vec3& a, const Vec3& b) {
      return !(a == b);
    }

    // Operators < <= > >= don't make sense for Vec3

    Vec3 cross_product(const Vec3& a, const Vec3& b) {
      return {
        a.y * b.z - a.z * b.y,
        a.z * b.x - a.x * b.z,
        a.x * b.y - a.y * b.x,
      };
    }

std::array 在其内部持有其元素。因此,sizeof (std::array<int, 100>) 等于 sizeof (int[100]),等于 100 * sizeof (int)。不要犯试图将巨大的数组作为局部变量放在栈上的错误!

    void dont_do_this()
    {
      // This variable takes up 4 megabytes of stack space ---
      // enough to blow your stack and cause a segmentation fault!
      int arr[1'000'000];
    }

    void dont_do_this_either()
    {
      // Changing it into a C++ std::array doesn't fix the problem.
      std::array<int, 1'000'000> arr;
    }

与“巨大的数组”打交道是我们列表中下一个容器的工作:std::vector

工作马:std::vector<T>

std::vector 表示一个数据元素的字节连续数组,但分配在堆上而不是栈上。这比 std::array 有两个改进:首先,它允许我们创建一个非常大的数组而不会耗尽栈空间。其次,它允许我们动态地调整底层数组的大小——与 std::array<int, 3> 不同,其中数组的大小是类型不可变的一部分,一个 std::vector<int> 没有固有的大小。向量的 .size() 方法实际上提供了关于向量当前状态的 useful 信息。

std::vector 有另一个显著的属性:其 容量。向量的容量始终不小于其大小,表示向量当前 可以 持有的元素数量,在它需要重新分配其底层数组之前:

图片

除了可调整大小之外,vector 的行为与 array 类似。像数组一样,向量是可复制的(在线性时间内复制所有数据元素)并且可比较的(std::vector<T>::operator< 将通过委托给 T::operator< 报告操作数的字典序)。

一般而言,std::vector是整个标准库中最常用的容器。每次你需要存储“大量”元素(或“我不确定我有多少元素”)时,你的第一个想法应该是使用vector。为什么?因为vector提供了可调整大小容器的所有灵活性,同时具有连续数组的简单性和效率。

连续数组是最有效的数据结构(在典型硬件上),因为它们提供了良好的局部性,也称为缓存友好性。当你按顺序从.begin().end()遍历向量时,你也在按顺序遍历内存,这意味着计算机的硬件可以非常准确地预测你将要查看的下一块内存。将此与链表进行比较,其中从.begin().end()的遍历可能涉及在整个地址空间中跟随指针,并且按无序的方式访问内存位置。在链表中,几乎每个你访问的地址都与前一个地址无关,因此它们都不会在 CPU 的缓存中。在向量(或数组)中,情况正好相反:你访问的每个地址都将与前一个地址通过简单的线性关系相关联,CPU 将能够在你需要时准备好所有这些值。

即使你的数据比简单的值列表“更有结构”,你通常也可以使用vector来存储它。我们将在本章末尾看到如何使用vector来模拟栈或优先队列。

调整std::vector的大小

std::vector有一系列成员函数,用于添加和删除元素。这些成员函数在std::array中不存在,因为std::array是不可调整大小的;但它们在其他我们将要讨论的容器中大多数都存在。因此,现在熟悉它们是个好主意。

让我们从针对vector本身的两个基本操作开始:.resize().reserve()

vec.reserve(c)更新向量的容量——它“预留”了足够的空间来存储多达c个元素(总计)。如果c <= vec.capacity(),则不会发生任何操作;但如果c > vec.capacity(),则向量将不得不重新分配其基本数组。重新分配遵循以下等效算法:

    template<typename T>
    inline void destroy_n_elements(T *p, size_t n)
    {
      for (size_t i = 0; i < n; ++i) {
        p[i].~T();
      }
    }

    template<typename T>
    class vector {
      T *ptr_ = nullptr;
      size_t size_ = 0;
      size_t capacity_ = 0;

      public:
      // ...

      void reserve(size_t c) {
        if (capacity_ >= c) {
          // do nothing
          return;
        }

        // For now, we'll ignore the problem of
        // "What if malloc fails?"
        T *new_ptr = (T *)malloc(c * sizeof (T));

        for (size_t i=0; i < size_; ++i) {
          if constexpr (std::is_nothrow_move_constructible_v<T>) {
            // If the elements can be moved without risking
            // an exception, then we'll move the elements.
            ::new (&new_ptr[i]) T(std::move(ptr_[i]));
          } else {
            // If moving the elements might throw an exception,
            // then moving isn't safe. Make a copy of the elements
            // until we're sure that we've succeeded; then destroy
            // the old elements.
            try {
              ::new (&new_ptr[i]) T(ptr_[i]);
            } catch (...) {
              destroy_n_elements(new_ptr, i);
              free(new_ptr);
              throw;
            }
          }
        }
        // Having successfully moved or copied the elements,
        // destroy the old array and point ptr_ at the new one.
        destroy_n_elements(ptr_, size_);
        free(ptr_);
        ptr_ = new_ptr;
        capacity_ = c;
      }

      ~vector() {
        destroy_n_elements(ptr_, size_);
        free(ptr_);
      }
    };

如果你按顺序阅读这本书,你可能会认出这个.reserve()函数中的关键 for 循环与第三章中std::uninitialized_copy(a,b,c)的实现非常相似,迭代器对算法。实际上,如果你在一个非分配器感知的容器上实现.reserve()(见第八章,分配器),你可能重用那个标准算法:

    // If the elements can be moved without risking
    // an exception, then we'll move the elements.
    std::conditional_t<
      std::is_nothrow_move_constructible_v<T>,
      std::move_iterator<T*>,
      T*
      > first(ptr_);

    try {
      // Move or copy the elements via a standard algorithm.
      std::uninitialized_copy(first, first + size_, new_ptr);
    } catch (...) {
      free(new_ptr);
      throw;
    }

    // Having successfully moved or copied the elements,
    // destroy the old array and point ptr_ at the new one.
    std::destroy(ptr_, ptr_ + size_);
    free(ptr_);
    ptr_ = new_ptr;
    capacity_ = c;

vec.resize(s)改变向量的大小——它从向量末尾切掉元素(在这个过程中调用它们的析构函数),或者向向量中添加额外的元素(默认构造它们),直到向量的大小等于s。如果s > vec.capacity(),则向量将不得不像在.reserve()情况下一样重新分配其底层数组。

你可能已经注意到,当一个向量重新分配其底层数组时,元素地址会发生变化:重新分配前vec[0]的地址与重新分配后vec[0]的地址不同。任何指向向量旧元素的指针都变成了“悬垂指针”。由于std::vector::iterator本质上也是一个指针,因此任何指向向量旧元素的迭代器也变得无效。这种现象被称为迭代器失效,它是 C++代码中 bug 的主要来源。当你同时处理迭代器和调整向量大小时要小心!

这里有一些经典的迭代器失效案例:

    std::vector<int> v = {3, 1, 4};

    auto iter = v.begin();
    v.reserve(6); // iter is invalidated!

    // This might look like a way to produce the result
    // {3, 1, 4, 3, 1, 4}; but if the first insertion
    // triggers reallocation, then the next insertion
    // will be reading garbage from a dangling iterator!
    v = std::vector{3, 1, 4};
    std::copy(
      v.begin(),
      v.end(),
      std::back_inserter(v)
    );

这里还有一个案例,这在许多其他编程语言中也很常见,即在迭代容器的同时删除元素会产生微妙的 bug:

    auto end = v.end();
    for (auto it = v.begin(); it != end; ++it) {
      if (*it == 4) {
        v.erase(it); // WRONG!
      }
    }

    // Asking the vector for its .end() each time
    // through the loop does fix the bug...
    for (auto it = v.begin(); it != v.end(); ) {
      if (*it == 4) {
        it = v.erase(it);
      } else {
        ++it;
      }
    }

    // ...But it's much more efficient to use the
    // erase-remove idiom.
    v.erase(
      std::remove_if(v.begin(), v.end(), [](auto&& elt) {
        return elt == 4;
      }),
      v.end()
    );

在 std::vector 中插入和删除

vec.push_back(t)向向量的末尾添加一个项目。没有对应的.push_front()成员函数,因为正如本节开头所示,没有一种有效的方法可以将任何东西推送到向量的前端

vec.emplace_back(args...)是一个完美前向变长函数模板,其行为就像.push_back(t)一样,除了它不是在向量的末尾放置t的副本,而是放置一个T对象,就像通过T(args...)构造一样。

push_backemplace_back都有所谓的“摊销常数时间”性能。要了解这意味着什么,考虑如果你连续调用v.emplace_back()一百次会发生什么。每次调用,向量都需要稍微变大;因此它重新分配其底层数组并将所有v.size()个元素从旧数组移动到新数组。很快,你花费在复制旧数据上的时间就会比实际“推回”新数据的时间多!幸运的是,std::vector足够聪明,可以避免这个陷阱。每当像v.emplace_back()这样的操作导致重新分配时,向量不会只为capacity() + 1个元素在新数组中留出空间;它将为k * capacity()个元素留出空间(其中k对于 libc++和 libstdc++是 2,对于 Visual Studio 大约是 1.5)。因此,尽管随着向量的增长重新分配变得越来越昂贵,但每次push_back的重新分配次数越来越少——因此单个push_back的成本在平均上是常数。这个技巧被称为几何扩展

vec.insert(it, t)将一个项目插入到向量的中间,位置由迭代器it指示。如果it == vec.end(),则这相当于push_back;如果it == vec.begin(),则这是一个简化的push_front版本。请注意,如果你不在向量的末尾插入,那么在插入点之后的底层数组中的所有元素都将被移位以腾出空间;这可能会很昂贵。

.insert()有几个不同的重载。一般来说,这些都不会对你有用,但你可能想了解它们,以便解释当你错误地提供.insert()的参数时出现的神秘错误消息(或神秘的运行时错误);如果重载解析最终选择的是你期望之外的这些中的一个:

    std::vector<int> v = {1, 2};
    std::vector<int> w = {5, 6};

    // Insert a single element.
    v.insert(v.begin() + 1, 3);
    assert((v == std::vector{1, 3, 2}));

    // Insert n copies of a single element.
    v.insert(v.end() - 1, 3, 4);
    assert((v == std::vector{1, 3, 4, 4, 4, 2}));

    // Insert a whole range of elements.
    v.insert(v.begin() + 3, w.begin(), w.end());
    assert((v == std::vector{1, 3, 4, 5, 6, 4, 4, 2}));

    // Insert a braced list of elements.
    v.insert(v.begin(), {7, 8});
    assert((v == std::vector{7, 8, 1, 3, 4, 5, 6, 4, 4, 2}));

vec.emplace(it, args...)insert的关系类似于emplace_backpush_back的关系:它是 C++03 函数的一个完美转发版本。当可能时,优先使用emplaceemplace_back而不是insertpush_back

vec.erase(it)从向量的中间位置删除单个项目,位置由迭代器it指示。还有一个两个迭代器的版本,vec.erase(it, it),用于删除一系列连续的项目。请注意,这个两个迭代器的版本与我们在上一章中使用的erase-remove 习语是相同的。

要从向量中删除最后一个元素,你可以使用vec.erase(vec.end()-1)vec.erase(vec.end()-1, vec.end());但由于这是一个常见的操作,标准库提供了一个同义词,形式为vec.pop_back()。你可以仅使用std::vectorpush_back()pop_back()方法来实现一个动态增长的

向量的陷阱

std::vector模板有一个特殊情况:std::vector<bool>。由于bool数据类型只有两个可能的值,八个bool的值可以打包到一个字节中。std::vector<bool>使用这种优化,这意味着它使用的堆分配内存比预期的少八倍。

图片

这种打包的缺点是vector<bool>::operator[]的返回类型不能是bool&,因为向量没有在任何地方存储实际的bool对象。因此,operator[]返回一个定制的类类型,std::vector<bool>::reference,它可以转换为bool,但它本身不是bool(这种类型的类型通常被称为“代理类型”或“代理引用”)。

operator[] const的结果类型“官方上”是bool,但在实践中,一些库(特别是 libc++)为operator[] const返回一个代理类型。这意味着使用vector<bool>的代码不仅微妙,有时甚至不可移植;如果你可以的话,我建议避免使用vector<bool>

    std::vector<bool> vb = {true, false, true, false};

    // vector<bool>::reference has one public member function:
    vb[3].flip();
    assert(vb[3] == true);

    // The following line won't compile!
    // bool& oops = vb[0];

    auto ref = vb[0];
    assert((!std::is_same_v<decltype(ref), bool>));
    assert(sizeof vb[0] > sizeof (bool));

    if (sizeof std::as_const(vb)[0] == sizeof (bool)) {
      puts("Your library vendor is libstdc++ or Visual Studio");
    } else {
      puts("Your library vendor is libc++");
    }

非 noexcept 移动构造函数的陷阱

回想一下在 Resizing a std::vector 部分中 vector::resize() 的实现。当向量调整大小时,它会重新分配其底层数组并将元素移动到新数组中——除非元素类型不是“nothrow move-constructible”,在这种情况下它会复制其元素!这意味着,除非你特意指定你的移动构造函数是 noexcept,否则调整你自己的类类型向量的大小将会是不必要的“最优化”。

考虑以下类定义:

    struct Bad {
      int x = 0;
      Bad() = default;
      Bad(const Bad&) { puts("copy Bad"); }
      Bad(Bad&&) { puts("move Bad"); }
    };

    struct Good {
      int x = 0;
      Good() = default;
      Good(const Good&) { puts("copy Good"); }
      Good(Good&&) noexcept { puts("move Good"); }
    };

    class ImplicitlyGood {
      std::string x;
      Good y;
    };

    class ImplicitlyBad {
      std::string x;
      Bad y;
    };

我们可以使用以下测试框架等单独测试这些类的行为。运行 test() 将打印 "copy Bad--move Good--copy Bad--move Good。"多么恰当的咒语!

    template<class T>
    void test_resizing()
    {
      std::vector<T> vec(1);
      // Force a reallocation on the vector.
      vec.resize(vec.capacity() + 1);
    }

    void test()
    {
      test_resizing<Good>();
      test_resizing<Bad>();
      test_resizing<ImplicitlyGood>();
      test_resizing<ImplicitlyBad>();
    }

这是一个微妙且晦涩的观点,但它可能会对你在实践中 C++ 代码的效率产生重大影响。一个很好的经验法则是:无论何时声明你自己的移动构造函数或交换函数,确保你声明它为 noexcept

快速混合型:std::deque

std::vector 类似,std::deque 提供了连续数组的接口——它是随机访问的,并且其元素以连续块的形式存储,以便于缓存友好。但与 vector 不同,其元素只是“块状”连续的。一个 deque 由任意数量的“块”组成,每个块包含固定数量的元素。在容器的两端插入更多元素的成本较低;在中间插入元素的成本仍然较高。在内存中它看起来像这样:

图片

std::deque<T> 展示了与 std::vector<T> 相同的所有成员函数,包括重载的 operator[]。除了向量的 push_backpop_back 方法外,deque 还暴露了一个高效的 push_frontpop_front

注意,当你反复向向量中 push_back 时,你最终会触发底层数组的重新分配,并使所有迭代器以及容器内元素的指针和引用无效。在 deque 中,迭代器失效仍然会发生,但除非你在 deque 的中间插入或删除元素(在这种情况下,deque 的一个端点或另一个端点将不得不向外移动以腾出空间,或者向内移动以填补空隙),否则单个元素永远不会改变它们的地址:

    std::vector<int> vec = {1, 2, 3, 4};
    std::deque<int> deq = {1, 2, 3, 4};
    int *vec_p = &vec[2];
    int *deq_p = &deq[2];
    for (int i=0; i < 1000; ++i) {
      vec.push_back(i);
      deq.push_back(i);
    }
    assert(vec_p != &vec[2]);
    assert(deq_p == &deq[2]);

std::deque<T> 的另一个优点是,没有为 std::deque<bool> 进行特殊化;无论 T 是什么,容器都提供了一个统一的公共接口。

std::deque<T> 的缺点在于其迭代器的递增和解引用操作要显著昂贵,因为它们必须导航到以下图中所示的指针数组。这是一个相当大的缺点,以至于坚持使用 vector 是有意义的,除非你恰好需要在容器的两端快速插入和删除。

特定的技能集:std::list

容器 std::list<T> 在内存中代表一个链表。示意图如下:

图片

注意,列表中的每个节点都包含对其“下一个”和“上一个”节点的指针,因此这是一个双向链表。双向链表的优点是它的迭代器可以向前和向后遍历列表--也就是说,std::list<T>::iterator 是一个双向迭代器(但它不是随机访问;到达列表的第 n 个元素仍然需要 O(n) 的时间)。

std::list 支持与 std::vector 相同的许多操作,但除了那些需要随机访问的操作(例如 operator[])。由于从列表的前端进行推入和弹出不需要昂贵的移动操作,因此它可以添加用于从列表前端推入和弹出的成员函数。

通常,std::list 的性能远低于 std::vectorstd::deque 这样的连续数据结构,因为跟踪“随机”分配的地址比跟踪内存连续块中的指针要困难得多。因此,你应该将 std::list 视为一个通常不推荐的容器;你应该只在绝对需要它做得比 vector 更好的事情时才从工具箱中取出它。

std::list 有哪些特殊技能?

首先,对于列表没有迭代器失效lst.push_back(v)lst.push_front(v) 总是常数时间操作,并且永远不需要“调整大小”或“移动”任何数据。

其次,在 vector 上成本高昂或在行外存储(“临时空间”)中需要存储的许多修改操作,对于链表来说变得便宜。以下是一些例子:

lst.splice(it, otherlst) “拼接”整个 otherlstlst 中,就像通过重复调用 lst.insert(it++, other_elt);但是“插入”的节点实际上是偷自右侧的 otherlst。整个拼接操作只需几个指针交换即可完成。在此操作之后,otherlst.size() == 0

lst.merge(otherlst) 类似地仅使用指针交换将 otherlst 清空到 lst 中,但具有“合并排序列表”的效果。例如:

    std::list<int> a = {3, 6};
    std::list<int> b = {1, 2, 3, 5, 6};

    a.merge(b);
    assert(b.empty());
    assert((a == std::list{1, 2, 3, 3, 5, 6, 6}));

与涉及比较的 STL 操作一样,有一个接受比较器的版本:lst.merge(otherlst, less)

另一个只能通过指针交换来完成的操作是在原地反转列表:lst.reverse() 切换所有“下一个”和“上一个”链接,使得列表的头部现在是尾部,反之亦然。

注意,所有这些操作都会原地修改列表,并且通常返回 void

另一种在链表上(但在连续容器上不是)成本低廉的操作是删除元素。回想一下第三章,迭代器对算法,STL 提供了 std::remove_ifstd::unique 等算法,用于与连续容器一起使用;这些算法将“已删除”的元素洗牌到容器的末尾,以便可以在单个 erase() 中取下。对于 std::list,洗牌元素比简单地就地删除它们更昂贵。因此,std::list 提供了以下成员函数,不幸的是,它们的名称与非删除 STL 算法相似:

  • lst.remove(v) 移除并删除所有等于 v 的元素。

  • lst.remove_if(p) 移除并删除所有满足一元谓词 p(e) 的元素 e

  • lst.unique() 移除并删除每个“连续相等元素序列”中除了第一个元素之外的所有元素。像往常一样,与涉及比较的 STL 操作一样,有一个接受比较器的版本:lst.unique(eq)p(e1, e2) 成立时移除并删除 e2

  • lst.sort() 在原地排序列表。这特别有用,因为排列算法 std::sort(ctr.begin(), ctr.end()) 不能在非随机访问的 std::list::iterator 上工作。

很奇怪,lst.sort() 只能对整个容器进行排序,而不是像 std::sort 那样接受一个子范围。但如果你只想对 lst 的子范围进行排序,你可以通过——跟我一起说——仅仅几个指针交换来实现!

    std::list<int> lst = {3, 1, 4, 1, 5, 9, 2, 6, 5};
    auto begin = std::next(lst.begin(), 2);
    auto end = std::next(lst.end(), -2);

    // Sort just the range begin, end)
    std::list<int> sub; 
    sub.splice(sub.begin(), lst, begin, end);
    sub.sort();
    lst.splice(end, sub);
    assert(sub.empty());

    assert((lst == std::list{3, 1, 1, 2, 4, 5, 9, 6, 5}));

使用 std::forward_list 进行简化

标准容器 std::forward_list<T>std::list 类似,但功能较少——无法获取其大小,无法向后迭代。在内存中它看起来与 std::list<T> 类似,但节点更小:

![尽管如此,std::forward_list 保留了 std::list 几乎所有的“特殊技能”。它无法执行的操作只有 splice(因为这涉及到在给定迭代器之前插入)和 push_back(因为这涉及到在常数时间内找到列表的末尾)。forward_list_after 版本替换了这些缺失的成员函数:+ flst.erase_after(it) 删除给定位置之后的元素+ flst.insert_after(it, v) 在给定位置之后插入一个新元素+ flst.splice_after(it, otherflst) 在给定位置之后插入 otherflst 的元素与 std::list 一样,除非你需要它特定的技能集,否则应尽量避免使用 forward_list。# 使用 std::stack 和 std::queue 进行抽象我们现在已经看到了三个不同的标准容器,它们具有 push_back()pop_back() 成员函数(尽管我们没有提到,但 back() 用于获取容器最后一个元素的引用)。如果我们想要实现一个栈数据结构,我们需要这些操作。标准库提供了一个方便的方式来抽象栈的概念,容器被称为(还能是什么?)std::stack。然而,与迄今为止我们所看到的容器不同,std::stack 需要一个额外的模板参数。std::stack<T, Ctr> 表示一个类型为 T 的元素栈,其底层存储由容器类型 Ctr 的一个实例管理。例如,stack<T, vector<T>> 使用向量来管理其元素;stack<T, list<T>> 使用列表;等等。模板参数 Ctr 的默认值实际上是 std::deque<T>;你可能还记得,deque 比向量占用更多内存,但它的好处是永远不需要重新分配其底层数组或移动插入后的元素。要与 std::stack<T, Ctr> 交互,你必须限制自己只使用 push 操作(对应于底层容器上的 push_back),pop 操作(对应于 pop_back),top 操作(对应于 back),以及一些其他访问器,如 sizeempty:```cpp std::stack stk; stk.push(3); stk.push(1); stk.push(4); assert(stk.top() == 4); stk.pop(); assert(stk.top() == 1); stk.pop(); assert(stk.top() == 3);````std::stack的一个奇特特性是它支持比较运算符!=<<=>>=;并且这些运算符通过比较底层容器(使用底层容器类型定义的任何语义)来工作。由于底层容器类型通常通过字典序比较,结果是比较两个栈时是“从下往上”进行字典序比较的。```cpp std::stack<int> a, b; a.push(3); a.push(1); a.push(4); b.push(2); b.push(7); assert(a != b); assert(a.top() < b.top()); // that is, 4 < 7 assert(a > b); // because 3 > 2```如果你只使用 !=,或者依赖于 operator<std::setstd::map 生成一致排序,那么这没问题;但当你第一次看到它时,这确实会让人感到惊讶!标准库还提供了一个“队列”的抽象。std::queue<T, Ctr>提供了push_backpop_front方法(对应于底层容器上的push_backpop_front),以及一些其他访问器,如 frontbacksizeempty。知道容器必须尽可能高效地支持这些基本操作,你应该能够猜出 Ctr的 *默认* 值。是的,它是std::deque,这是一个低开销的双端队列。注意,如果你从头开始使用 std::deque实现队列,你可以选择是在双端队列的前端入队并在后端出队,或者是在后端入队并在前端出队。标准的std::queue<T, std::deque>特意选择在后端入队并在前端出队,如果你考虑现实世界中的“队列”,这很容易记住。当你排队在售票窗口或午餐队伍中时,你会在队伍的后面加入,当你到达前面时才会被服务——永远不会反过来!选择技术术语(如queuefrontback)的艺术是很有用的,这些术语的技术含义是它们现实世界对应物的准确反映。# 有用的适配器:std::priority_queue<T>在第三章,“迭代器对算法”中,我们介绍了“堆”算法系列:make_heappush_heappop_heap。您可以使用这些算法给一系列元素赋予最大堆属性。如果您将最大堆属性作为数据的不变性来维护,您将得到一个通常称为**优先队列**的数据结构。在数据结构教科书中,优先队列通常被描绘为一种**二叉树**,但正如我们在第三章,“迭代器对算法”中看到的,最大堆属性并没有要求显式地使用基于指针的树结构。标准容器 std::priority_queue<T, Ctr, Cmp>表示一个优先队列,内部表示为一个Ctr的实例,其中Ctr的元素始终按照最大堆顺序排列(由比较器类型Cmp 的实例确定)。在这种情况下,Ctr的默认值是std::vector。记住,vector 是最有效的容器;std::stackstd::queue选择deque作为它们的默认值,唯一的原因是它们不想在插入元素后移动元素。但是,对于优先队列,元素始终在移动,随着其他元素的插入或删除,在最大堆中上下移动。因此,使用deque作为底层容器没有特别的优点;因此,标准库遵循了我一直在重复的相同规则——除非有特定的原因需要其他东西,否则使用std::vectorCmp的默认值是标准库类型std::less,它表示 operator<。换句话说,std::priority_queue容器默认使用与第三章,“迭代器对算法”中的std::push_heapstd::pop_heap 算法相同的比较器。std::priority_queue<T, Ctr>暴露的成员函数是pushpoptop。从概念上讲,底层容器前面的项目位于堆的“顶部”。要记住的一件事是,在最大堆中,“顶部”的项目是**最大的**项目——想象一下这些项目像玩山丘之王,最大的项目获胜并最终位于堆的顶部。+ pq.push(v)将新项目插入到优先队列中,就像在底层容器上执行std::push_heap()一样+ pq.top()返回对优先队列顶部元素的引用,就像在底层容器上调用ctr.front()一样+ pq.pop()从优先队列中移除最大元素并更新堆,就像在底层容器上执行std::pop_heap()一样要获得**最小堆**而不是最大堆,只需简单地反转提供给priority_queue 模板的比较器的意义:```cpp std::priority_queue<int> pq1; std::priority_queue<int, std::vector<int>, std::greater<>> pq2; for (int v : {3, 1, 4, 1, 5, 9}) { pq1.push(v); pq2.push(v); } assert(pq1.top() == 9); // max-heap by default assert(pq2.top() == 1); // min-heap by choice```# 树:std::setstd::map<K, V>类模板 std::set为任何实现operator<T 提供了“唯一集合”的接口。与涉及比较的 STL 操作一样,有一个接受比较器的版本:std::set<T, Cmp>使用Cmp(a,b)而不是(a < b)来排序数据元素。一个std::set在概念上是一个二叉搜索树,类似于 Java 的TreeSet。在所有流行的实现中,它特别是一个 *红黑树*,这是一种特定的自平衡二叉搜索树:即使你不断地从树中插入和删除项目,它也不会变得 *太不平衡*,这意味着 insertfind` 在平均情况下总是以 O(log n) 的时间复杂度运行。注意其内存布局中涉及的指针数量:图片

由于二叉搜索树的定义是元素按排序顺序(从小到大)存储,因此 std::set 提供成员函数 push_frontpush_back 没有意义。相反,为了向集合中添加元素 v,你使用 s.insert(v);要删除元素,则使用 s.erase(v)s.erase(it)

    std::set<int> s;
    for (int i : {3, 1, 4, 1, 5}) {
      s.insert(i);
    }

    // A set's items are stored sorted and deduplicated.
    assert((s == std::set{1, 3, 4, 5}));

    auto it = s.begin();
    assert(*it == 1);
    s.erase(4);
    s.erase(it); // erase *it, which is 1

    assert((s == std::set{3, 5}));

s.insert(v) 的返回值很有趣。当我们向一个向量 insert 时,只有两种可能的结果:要么值成功添加到向量中(并返回一个指向新插入元素的迭代器),要么插入失败并抛出异常。当我们向一个集合 insert 时,还有一种可能的结果:可能由于集合中已经存在 v 的副本而没有发生插入!这并不是一个值得异常控制流的“失败”,但仍然是一些调用者可能想要了解的事情。因此,s.insert(v) 总是返回一个 pair 的返回值:ret.first 是数据结构中 v 的副本的常规迭代器(无论它是否刚刚插入),而 ret.second 如果指向的 v 是刚刚插入的则为 true,如果指向的 v 最初就在集合中则为 false

    std::set<int> s;
    auto [it1, b1] = s.insert(1);
    assert(*it1 == 1 && b1 == true);

    auto [it2, b2] = s.insert(2);
    assert(*it2 == 2 && b2 == true);

    auto [it3, b3] = s.insert(1); // again
    assert(*it3 == 1 && b3 == false);

前一个片段中使用的方括号变量定义正在使用 C++17 结构化绑定

正如之前的示例所示,集合的元素是有序存储的——不仅概念上,而且在可见性上,*s.begin() 将是集合中的最小元素,而 *std::prev(s.end()) 将是最大的元素。使用标准算法或范围 for 循环遍历集合将按升序(记住,“升序”的含义由你的比较器选择决定——类模板 setCmp 参数)给出集合的元素。

set 的基于树的结构意味着一些标准算法,如 std::findstd::lower_bound (第三章,迭代器对算法) 仍然可以工作,但效率低下——算法的迭代器将在树的丘陵上花费大量时间上下爬升,而如果我们能够访问树结构本身,我们可以直接从树的根开始下降,并快速找到给定元素的位置。因此,std::set 提供了可以作为低效算法替代的成员函数:

  • 对于 std::find(s.begin(), s.end(), v),使用 s.find(v)

  • 对于 std::lower_bound(s.begin(), s.end(), v),使用 s.lower_bound(v)

  • 对于 std::upper_bound(s.begin(), s.end(), v),使用 s.upper_bound(v)

  • 对于 std::count(s.begin(), s.end(), v),使用 s.count(v)

  • 对于 std::equal_range(s.begin(), s.end(), v),使用 s.equal_range(v)

注意,s.count(v) 只会返回 0 或 1,因为集合的元素是去重的。这使得 s.count(v) 成为一个方便的同义词,用于集合成员操作——Python 会称之为 v in s 或 Java 会称之为 s.contains(v)

std::map<K, V> 就像 std::set<K>,只不过每个键 K 都可以有一个与之关联的值 V;这使得数据结构类似于 Java 的 TreeMap 或 Python 的 dict。始终如一,如果你需要与自然 K::operator< 不同的键排序顺序,那么有 std::map<K, V, Cmp>。虽然你不会经常将 std::map 视为“只是一个围绕 std::set 对对的薄包装”,但在内存中它确实是这样:

图片

std::map 支持使用 operator[] 进行索引,但有一个令人惊讶的转折。当你用 vec[42] 对大小为零的向量进行索引时,你会得到未定义的行为。当你用 m[42] 对大小为零的 映射 进行索引时,映射会友好地插入键值对 {42, {}} 到它自己中,并返回该对第二个元素的引用!

这种古怪的行为实际上对编写易于阅读的代码很有帮助:

    std::map<std::string, std::string> m;
    m["hello"] = "world";
    m["quick"] = "brown";
    m["hello"] = "dolly";
    assert(m.size() == 2);

但如果你不注意,可能会导致混淆:

    assert(m["literally"] == "");
    assert(m.size() == 3);

你会注意到,对于映射没有 operator[] const,因为 operator[] 总是保留将新的键值对插入到 *this 中的可能性。如果你有一个常量映射——或者你真的现在不想插入的映射——那么查询它的非突变方式是使用 m.find(k)。避免 operator[] 的另一个原因是如果你的映射的值类型 V 不是默认可构造的,在这种情况下,operator[] 简单地无法编译。在这种情况下(实话实说:在任何情况下)你应该使用 m.insert(kv)m.emplace(k, v) 来插入新的键值对,而不是为了再次赋值而默认构造一个值。以下是一个例子:

    // Confusingly, "value_type" refers to a whole key-value pair.
    // The types K and V are called "key_type" and "mapped_type",
    // respectively.
    using Pair = decltype(m)::value_type;

    if (m.find("hello") == m.end()) {
      m.insert(Pair{"hello", "dolly"});

      // ...or equivalently...
      m.emplace("hello", "dolly");
    }

在 C++11 之后的世界里,人们普遍认为基于指针树的 std::mapstd::set 由于对缓存不友好,应该默认避免使用,而应该首选使用 std::unordered_mapstd::unordered_set

关于透明比较器的说明

在最后一个代码示例中,我写的是 m.find("hello")。请注意,"hello" 是类型为 const char[6] 的值,而 decltype(m)::key_typestd::string,并且(因为我们没有指定任何特殊的东西)decltype(m)::key_comparestd::less<std::string>。这意味着当我们调用 m.find("hello") 时,我们调用的是一个第一个参数类型为 std::string 的函数--因此我们隐式地构造了 std::string("hello") 来作为 find 的参数。一般来说,m.find 的参数将被隐式转换为 decltype(m)::key_type,这可能会是一个昂贵的转换。

如果我们的 operator< 行为正常,我们可以通过将 m 的比较器更改为具有 异构 operator() 的某个类,并定义成员类型定义 is_transparent 来避免这种开销,如下所示:

    struct MagicLess {
      using is_transparent = std::true_type;

      template<class T, class U>
      bool operator()(T&& t, U&& u) const {
        return std::forward<T>(t) < std::forward<U>(u);
      }
    };

    void test()
    {
      std::map<std::string, std::string, MagicLess> m;

      // The STL provides std::less<> as a synonym for MagicLess.
      std::map<std::string, std::string, std::less<>> m2;

      // Now 'find' no longer constructs a std::string!
      auto it = m2.find("hello");
    }

这里的“魔法”全部发生在库对 std::map 的实现中;find 成员函数特别检查成员 is_transparent 并相应地改变其行为。成员函数 countlower_boundupper_boundequal_range 也都改变了它们的行为。但奇怪的是,成员函数 erase 并没有!这可能是由于区分有意为之的 m.erase(v) 和有意为之的 m.erase(it) 对于重载解析来说太难了。无论如何,如果你想在删除时进行异构比较,你可以分两步实现:

    auto [begin, end] = m.equal_range("hello");
    m.erase(begin, end);

奇异物:std::multiset<T>std::multimap<K, V>

在 STL 术语中,“set” 是一个有序且去重的元素集合。因此,一个“multiset”自然是一个有序且非去重的元素集合!它的内存布局与 std::set 的布局完全相同;只是它的不变量不同。注意以下图中 std::multiset 允许两个值为 42 的元素:

std::multiset<T, Cmp> 的行为与 std::set<T, Cmp> 类似,不同之处在于它可以存储重复元素。对于 std::multimap<K, V, Cmp> 也是如此:

    std::multimap<std::string, std::string> mm;
    mm.emplace("hello", "world");
    mm.emplace("quick", "brown");
    mm.emplace("hello", "dolly");
    assert(mm.size() == 3);

    // Key-value pairs are stored in sorted order.
    // Pairs with identical keys are guaranteed to be
    // stored in the order in which they were inserted.
    auto it = mm.begin();
    using Pair = decltype(mm)::value_type;
    assert(*(it++) == Pair("hello", "world"));
    assert(*(it++) == Pair("hello", "dolly"));
    assert(*(it++) == Pair("quick", "brown"));

在 multiset 或 multimap 中,mm.find(v) 返回一个指向 某个v 匹配的元素(或键值对)的迭代器(或键值对),不一定是迭代顺序中的第一个。mm.erase(v) 删除所有键等于 v 的元素(或键值对)。而 mm[v] 不存在。例如:

    std::multimap<std::string, std::string> mm = {
      {"hello", "world"},
      {"quick", "brown"},
      {"hello", "dolly"},
    };
    assert(mm.count("hello") == 2);
    mm.erase("hello");
    assert(mm.count("hello") == 0);

不移动元素而移动元素

回想一下,使用 std::list,我们能够通过使用 std::list 的“特定技能”将列表拼接在一起,从一个列表移动元素到另一个列表,等等。从 C++17 开始,基于树的容器也获得了类似的技能!

合并两个集合或映射(或多重集合或多重映射)的语法与合并排序的 std::list 的语法具有欺骗性的相似性:

    std::map<std::string, std::string> m = {
      {"hello", "world"},
      {"quick", "brown"},
    };
    std::map<std::string, std::string> otherm = {
      {"hello", "dolly"},
      {"sad", "clown"},
    };

    // This should look familiar!
    m.merge(otherm);

    assert((otherm == decltype(m){
      {"hello", "dolly"},
    }));

    assert((m == decltype(m){
      {"hello", "world"},
      {"quick", "brown"},
      {"sad", "clown"},
    }));

然而,请注意,当存在重复项时会发生什么!重复的元素不会被传输;它们被留在右侧映射中!如果你来自像 Python 这样的语言,这与你预期的正好相反,在 Python 中 d.update(otherd) 将右侧字典中的所有映射插入到左侧字典中,覆盖任何已经存在的内容。

C++ 中 d.update(otherd) 的等价操作是 m.insert(otherm.begin(), otherm.end())。唯一有意义的用例是,如果你知道你不想覆盖重复项,并且你接受丢弃 otherm 的旧值(例如,如果它是一个即将超出作用域的临时变量)。

在基于树的容器之间传输元素的另一种方法是使用成员函数 extractinsert 来传输单个元素:

    std::map<std::string, std::string> m = {
      {"hello", "world"},
      {"quick", "brown"},
    };
    std::map<std::string, std::string> otherm = {
      {"hello", "dolly"},
      {"sad", "clown"},
    };

    using Pair = decltype(m)::value_type;

    // Insertion may succeed...
    auto nh1 = otherm.extract("sad");
    assert(nh1.key() == "sad" && nh1.mapped() == "clown");
    auto [it2, inserted2, nh2] = m.insert(std::move(nh1));
    assert(*it2 == Pair("sad", "clown") && inserted2 == true && nh2.empty());

    // ...or be blocked by an existing element.
    auto nh3 = otherm.extract("hello");
    assert(nh3.key() == "hello" && nh3.mapped() == "dolly");
    auto [it4, inserted4, nh4] = m.insert(std::move(nh3));
    assert(*it4 == Pair("hello", "world") && inserted4 == false && !nh4.empty());

    // Overwriting an existing element is a pain.
    m.insert_or_assign(nh4.key(), nh4.mapped());

    // It is often easiest just to delete the element that's
    // blocking our desired insertion.
    m.erase(it4);
    m.insert(std::move(nh4));

extract 返回的对象类型被称为“节点句柄”——本质上是指向数据结构内部的指针。你可以使用访问器方法 nh.key()nh.mapped() 来操作 std::map(或 std::set 元素中的单个数据项的 nh.value())中的条目。因此,你可以提取、操作并重新插入一个键,而无需复制或移动其实际数据!在下面的代码示例中,“操作”包括对 std::transform 的调用:

    std::map<std::string, std::string> m = {
      {"hello", "world"},
      {"quick", "brown"},
    };
    assert(m.begin()->first == "hello");
    assert(std::next(m.begin())->first == "quick");

    // Upper-case the {"quick", "brown"} mapping, with
    // absolutely no memory allocations anywhere.
    auto nh = m.extract("quick");
    std::transform(nh.key().begin(), nh.key().end(), nh.key().begin(), ::toupper);
    m.insert(std::move(nh));

    assert(m.begin()->first == "QUICK");
    assert(std::next(m.begin())->first == "hello");

如您所见,此功能的接口不如 lst.splice(it, otherlst) 整洁;接口的微妙之处是它直到 C++17 才被纳入标准库的原因之一。不过,有一个巧妙的点需要注意:假设你从一个集合中 extract 一个节点,然后在将其插入到目标集合之前抛出一个异常。这个孤立的节点会发生什么——它会泄漏吗?事实证明,库的设计者考虑到了这种可能性;如果一个节点句柄的析构函数在节点句柄被插入到其新家之前被调用,析构函数将正确清理与节点关联的内存。因此,仅 extract(没有 insert)的行为就像 erase

散列:std::unordered_set<T>std::unordered_map<K, V>

std::unordered_set 类模板表示一个链表散列表——也就是说,一个固定大小的“桶”数组,每个桶包含一个数据元素的单链表。当新数据元素被添加到容器中时,每个元素都被放置在与其值“散列”相关联的链表中。这与 Java 的 HashSet 几乎完全相同。在内存中它看起来像这样:

关于哈希表的文献非常丰富,std::unordered_set 并不代表当前技术的最前沿;但因为它消除了某些指针追踪,所以通常比基于树的 std::set 表现得更好。

为了消除其余的指针,你需要用一种称为“开放寻址”的技术来替换链表,但这超出了本书的范围;但如果 std::unordered_set 对于你的用例来说太慢,那么查找它是有价值的。

std::unordered_set 是为了替代 std::set 而设计的,因此它提供了我们已见过的相同接口:inserterase,以及使用 beginend 进行迭代。然而,与 std::set 不同,std::unordered_set 中的元素不是按顺序存储的(它是无序的,明白吗?)并且它只提供前向迭代器,而不是 std::set 提供的双向迭代器。(查看前面的插图——有“下一个”指针但没有“上一个”指针,所以在 std::unordered_set 中反向迭代是不可能的。)

std::unordered_map<K, V> 相对于 std::unordered_set<T>,就像 std::map<K, V> 相对于 std::set<T>。也就是说,在内存中看起来完全一样,只是它存储的是键值对而不是仅仅是键:

图片

类似于 setmap,它们可以接受一个可选的比较器参数,unordered_setunordered_map 也可以接受一些可选参数。这两个可选参数是 Hash(默认为 std::hash<K>)和 KeyEqual(默认为 std::equal_to<K>,也就是说,operator==)。传递不同的哈希函数或不同的键比较函数会导致哈希表使用这些函数而不是默认值。如果你正在与某些不支持值语义或 operator== 的旧式 C++ 类类型进行交互,这可能是有用的:

    class Widget {
    public:
      virtual bool IsEqualTo(Widget const *b) const;
      virtual int GetHashValue() const;
    };

    struct myhash {
      size_t operator()(const Widget *w) const {
        return w->GetHashValue();
      }
    };

    struct myequal {
      bool operator()(const Widget *a, const Widget *b) const {
        return a->IsEqualTo(b);
      }
    };

    std::unordered_set<Widget *, myhash, myequal> s;

负载因子和桶列表

类似于 Java 的 HashSetstd::unordered_set 揭示了其桶的所有管理细节。你可能永远不需要与这些管理函数交互!

  • s.bucket_count() 返回数组中当前桶的数量。

  • s.bucket(v) 返回你将在其中找到元素 v 的桶的索引 i

    如果在这个 unordered_set 中存在元素 v

  • s.bucket_size(i) 返回第 i 个桶中的元素数量。注意,总是 s.count(v) <= s.bucket_size(s.bucket(v))

  • s.load_factor() 返回 s.size() / s.bucket_count() 作为 float 值。

  • s.rehash(n) 将桶数组的尺寸精确增加到 n

你可能已经注意到 load_factor 似乎有些不合适;s.size() / s.bucket_count() 有什么重要之处,以至于它有自己的成员函数?嗯,这是 unordered_set 随着元素数量的增长而扩展的机制。每个 unordered_set 对象 s 都有一个值 s.max_load_factor(),它精确地表示 s.load_factor() 允许达到的大小。如果一个插入操作会将 s.load_factor() 推过顶点,那么 s 将重新分配其桶数组,并重新散列其元素,以保持 s.load_factor() 小于 s.max_load_factor()

s.max_load_factor() 默认值为 1.0。您可以通过使用单参数重载 s.max_load_factor(k) 将其设置为不同的值 k。然而,这基本上从未必要,也不是一个好主意。

一个有意义的行政操作是 s.reserve(k)。类似于 vec.reserve(k) 对于向量,这个 reserve 成员函数意味着“我计划进行插入操作,这将使这个容器的大小达到 k 附近。请现在就为这些 k 个元素预分配足够的空间。”在 vector 的情况下,这意味着分配一个包含 k 个元素的数组。在 unordered_set 的情况下,这意味着分配一个包含 k / max_load_factor() 个指针的桶数组,这样即使插入 k 个元素(预期会有一定数量的冲突),负载因子仍然只会是 max_load_factor()

内存从哪里来?

在整个本章中,我实际上一直在对你撒谎!本章中描述的每个容器——除了 std::array 之外——都多了一个可选的模板类型参数。这个参数被称为分配器,它表示“reallocating the underlying array”或“allocating a new node on the linked list”等操作所需的内存来源。std::array不需要分配器,因为它在其内部持有所有内存;但每个其他容器类型都需要知道从哪里获取其分配。

这个模板参数的默认值是标准库类型 std::allocator<T>,这对于大多数用户来说肯定足够好了。我们将在第八章分配器中更多地讨论分配器。

摘要

在本章中,我们学习了以下内容:容器管理一组元素的所有权。STL 容器始终是类模板,参数化元素类型,有时也参数化其他相关参数。除了 std::array<T, N> 之外,每个容器都可以通过一个分配器类型进行参数化,以指定其分配和释放内存的方式。使用比较的容器可以由一个比较器类型进行参数化。考虑使用透明比较器类型,如 std::less<> 而不是同质比较器。

当使用 std::vector 时,请注意重新分配和地址无效化。当使用大多数容器类型时,请注意迭代器无效化。

标准库的哲学是支持没有自然低效操作(例如 vector::push_front);并支持任何自然高效的操作(例如 list::splice)。如果你能想到某个特定操作的效率实现,那么很可能是 STL 已经以某个名称实现了它;你只需要弄清楚它的拼写。

如果不确定,请使用 std::vector。只有在你需要特定容器类型的功能时才使用其他容器类型。具体来说,除非你需要它们的特殊功能(维护排序顺序;提取、合并和拼接),否则请避免使用基于指针的容器(setmaplist)。

在线参考资料,如 cppreference.com,是解决这些问题的最佳资源。

第五章:词汇类型

在过去十年中,人们越来越认识到,标准语言或标准库的一个重要角色是提供词汇类型。一个“词汇”类型是一个声称为处理其领域提供一个单一通用语言的类型,一个共同的语言。

注意,甚至在 C++存在之前,C 编程语言就已经在某个领域的词汇方面做出了相当不错的尝试,为整数数学(int)、浮点数学(double)、以 Unix 纪元表示的时间点(time_t)和字节计数(size_t)提供了标准类型或类型别名。

在本章中,我们将学习:

  • C++中词汇类型的演变历史,从std::stringstd::any

  • 代数数据类型、乘积类型和求和类型的定义

  • 如何操作元组和访问变体

  • std::optional<T>作为“可能有T”或“尚未有T”的作用

  • std::any作为“无限”的代数数据类型等价物

  • 如何实现类型擦除,它在std::anystd::function中的使用以及其固有的限制

  • std::function的一些陷阱以及修复它们的第三方库

std::string的故事

考虑字符字符串的领域;例如,短语hello world。在 C 中,处理字符串的通用语言是char *

    char *greet(const char *name) {
      char buffer[100];
      snprintf(buffer, 100, "hello %s", name);
      return strdup(buffer);
    }

    void test() {
      const char *who = "world";
      char *hw = greet(who);
      assert(strcmp(hw, "hello world") == 0);
      free(hw);
    }

这在一段时间内是可行的,但是处理原始的char *对于语言的用户以及第三方库和例程的创建者来说存在一些问题。一方面,C 语言如此古老,以至于一开始就没有发明出const,这意味着某些旧的例程会期望它们的字符串为char *,而某些较新的则期望const char *。另一方面,char *没有携带一个长度;因此,一些函数期望一个指针和一个长度,而一些函数只期望指针,并且根本无法处理值'\0'嵌入的字节。

char *谜团中缺失的最重要部分是生命周期管理所有权(如第四章,容器动物园开头所述)。当一个 C 函数想要从其调用者那里接收一个字符串时,它接受char *,并且通常将字符的所有权管理留给调用者。但是,如果它想要返回一个字符串呢?那么它必须返回char *并希望调用者记得释放它(strdupasprintf),或者从调用者那里接收一个缓冲区并希望它足够大以容纳输出(sprintfsnprintfstrcat)。在 C(以及在预标准的 C++)中管理字符串所有权的困难如此之大,以至于出现了大量的“字符串库”来解决这个问题:Qt 的QString、glib 的GString等等。

1998 年,C++ 以一个奇迹般的方式进入了这个混乱:一个 标准 字符串类!新的 std::string 以自然的方式封装了字符串的字节和长度;它可以正确处理嵌入的空字节;它支持以前复杂的操作,如 hello + world,通过静默地分配所需的精确内存量;而且由于 RAII,它永远不会泄漏内存或引起关于谁拥有底层字节的混淆。最好的是,它从 char * 有隐式转换:

    std::string greet(const std::string& name) {
      return "hello " + name;
    }

    void test() {
      std::string who = "world";
      assert(greet(who) == "hello world");
    }

现在,C++ 函数处理字符串(如前述代码中的 greet())可以接受 std::string 参数并返回 std::string 结果。甚至更好,因为字符串类型是 标准化的,几年后你可能会相当有信心,当你选择一些第三方库将其集成到你的代码库中时,它的任何接受字符串(文件名、错误消息等)的函数都会使用 std::string。通过共享 std::string通用语言,每个人都可以更有效地进行沟通。

使用 reference_wrapper 标记引用类型

在 C++03 中引入的另一个词汇类型是 std::reference_wrapper<T>。它有一个简单的实现:

    namespace std {
      template<typename T>
      class reference_wrapper {
        T *m_ptr;
        public:
        reference_wrapper(T& t) noexcept : m_ptr(&t) {}

        operator T& () const noexcept { return *m_ptr; }
        T& get() const noexcept { return *m_ptr; }
      };

      template<typename T>
      reference_wrapper<T> ref(T& t);
    } // namespace std

std::reference_wrapper 的用途与 std::stringint 等词汇类型略有不同;它专门作为将我们希望在其上下文中作为引用行为的“标记”值的方式:

     int result = 0;
     auto task = [](int& r) {
       r = 42;
     };

     // Trying to use a native reference wouldn't compile.
     //std::thread t(task, result);

     // Correctly pass result "by reference" to the new thread.
     std::thread t(task, std::ref(result));

std::thread 的构造函数编写了特定的特殊情况来处理 reference_wrapper 参数,通过“退化”为原生引用来处理。相同的特殊情况适用于标准库函数 make_pairmake_tuplebindinvoke 以及基于 invoke 的所有内容(如 std::applystd::function::operator()std::async)。

C++11 和代数类型

随着 C++11 的形成,越来越多的人认识到另一个适合词汇化的领域是所谓的 代数数据类型。代数类型在函数式编程范式中自然出现。基本思想是考虑类型的域——即该类型所有可能值的集合。为了使事情简单,你可能想要考虑 C++ 的 enum 类型,因为很容易谈论 enum 类型的对象在某个时刻可能具有的不同值的数量:

    enum class Color {
      RED = 1,
      BLACK = 2,
    };

    enum class Size {
      SMALL = 1,
      MEDIUM = 2,
      LARGE = 3,
    };

给定类型 ColorSize,你能创建一个实例可能具有 2 × 3 = 6 个值的类型吗?是的;这种类型代表 ColorSize 的“每个都只有一个”,被称为 积类型,因为其可能值的集合是其元素可能值集合的 笛卡尔积

那么一个实例可能具有 2 + 3 = 5 个不同值的类型呢?也是;这种类型表示“要么是 ColorSize,但不会同时两者都是”,这被称为 求和类型。(令人困惑的是,数学家并不使用 笛卡尔和 这个术语来表示这个概念。)

在像 Haskell 这样的函数式编程语言中,这两个练习的拼写如下:

    data SixType = ColorandSizeOf Color Size;
    data FiveType = ColorOf Color | SizeOf Size;

在 C++ 中,它们的拼写如下:

    using sixtype = std::pair<Color, Size>;
    using fivetype = std::variant<Color, Size>;

类模板 std::pair<A, B> 表示一个有序元素对:一个类型为 A 的元素,后面跟着一个类型为 B 的元素。它与一个包含两个元素的普通 struct 非常相似,只是你不需要自己编写 struct 定义:

    template<class A, class B>
    struct pair {
      A first;
      B second;
    };

注意到 std::pair<A, A>std::array<A, 2> 之间只有细微的表面差异。我们可能会说 pairarray 的一个 异构 版本(除了 pair 只能持有两个元素的限制)。

使用 std::tuple

C++11 引入了一个完整的异构数组;它被称为 std::tuple<Ts...>。仅包含两种元素类型的元组——例如,tuple<int, double>——与 pair<int, double> 没有区别。但元组可以持有比一对元素更多的内容;通过 C++11 可变参数模板的魔力,它们可以持有三元组、四元组、五元组等,因此具有通用的名称 tuple。例如,tuple<int, int, char, std::string> 与一个成员分别为 int、另一个 int、一个 char 和最后的 std::stringstruct 相似。

因为元组的第一个元素与第二个元素类型不同,我们不能使用“正常”的 operator[](size_t) 通过可能随运行时变化的索引来访问元素。相反,我们必须在 编译时 告诉编译器我们打算访问元组的哪个元素,这样编译器才能确定要给表达式赋予什么类型。C++ 在编译时提供信息的方法是通过模板参数强制将其纳入类型系统,这就是我们这样做的原因。当我们想访问元组 t 的第一个元素时,我们调用 std::get<0>(t)。要访问第二个元素,我们调用 std::get<1>(t),依此类推。

这就成为了处理 std::tuple 的模式——在具有访问和操作它们的 成员函数 的同构容器类型中,异构代数类型倾向于有 自由函数模板 用于访问和操作它们。

然而,一般来说,你不会对元组进行很多操作。它们的主要用途,除了模板元编程之外,是在需要单个值的上下文中以经济的方式暂时将多个值绑定在一起。例如,你可能还记得从第四章[part0052.html#1HIT80-2fdac365b8984feebddfbb9250eaf20d]中“最简单的容器”部分中的示例中了解到的std::tie。这是一种将任意数量的值绑定到单个单元的便宜方法,该单元可以用operator<进行字典序比较。字典序比较的“感觉”取决于你绑定值的顺序:

    using Author = std::pair<std::string, std::string>;
    std::vector<Author> authors = {
      {"Fyodor", "Dostoevsky"},
      {"Sylvia", "Plath"},
      {"Vladimir", "Nabokov"},
      {"Douglas", "Hofstadter"},
    };

    // Sort by first name then last name.
    std::sort(
      authors.begin(), authors.end(),
      [](auto&& a, auto&& b) {
        return std::tie(a.first, a.second) < std::tie(b.first, b.second);
      }
    );
    assert(authors[0] == Author("Douglas", "Hofstadter"));

    // Sort by last name then first name.
    std::sort(
      authors.begin(), authors.end(),
      [](auto&& a, auto&& b) {
        return std::tie(a.second, a.first) < std::tie(b.second, b.first);
      }
    );
    assert(authors[0] == Author("Fyodor", "Dostoevsky"));

std::tie之所以如此便宜,是因为它实际上创建了一个对其参数内存位置的引用元组,而不是复制其参数的值。这导致了std::tie的第二种常见用途:模拟像 Python 这样的语言中发现的“多重赋值”:

    std::string s;
    int i;

    // Assign both s and i at once.
    std::tie(s, i) = std::make_tuple("hello", 42);

注意,前述注释中的“一次”短语与并发(见第七章[part0108.html#36VSO0-2fdac365b8984feebddfbb9250eaf20d],并发)或副作用执行的顺序无关;我的意思是,两个值可以在单个赋值语句中赋值,而不是占用两行或多行。

如前例所示,std::make_tuple(a, b, c...)可以用来创建一个包含的元组;也就是说,make_tuple确实会构造其参数值的副本,而不仅仅是获取它们的地址。

最后,在 C++17 中,我们可以使用构造函数模板参数推导来简单地编写std::tuple(a, b, c...);但除非你确切知道你想要它的行为,否则最好避免使用这个特性。模板参数推导与std::make_tuple的不同之处仅在于它将保留std::reference_wrapper参数而不是将它们退化到原生 C++引用:

    auto [i, j, k] = std::tuple{1, 2, 3};

    // make_tuple decays reference_wrapper...
    auto t1 = std::make_tuple(i, std::ref(j), k);
    static_assert(std::is_same_v< decltype(t1),
      std::tuple<int, int&, int>
    >);

    // ...whereas the deduced constructor does not.
    auto t2 = std::tuple(i, std::ref(j), k);
    static_assert(std::is_same_v< decltype(t2),
      std::tuple<int, std::reference_wrapper<int>, int>
    >);

操作元组值

大多数这些函数和模板仅在模板元编程的上下文中有用;你不太可能每天都会使用它们:

  • std::get<I>(t): 获取对t的第I个元素的引用。

  • std::tuple_size_v<decltype(t)>: 表示给定元组的大小。因为这是元组类型的编译时常量属性,所以它被表示为一个以该类型为参数的变量模板。如果你更愿意使用更自然的语法,你可以以以下两种方式之一编写辅助函数:

        template<class T>
        constexpr size_t tuple_size(T&&)
        {
          return std::tuple_size_v<std::remove_reference_t<T>>;
        }

        template<class... Ts>
        constexpr size_t simpler_tuple_size(const std::tuple<Ts...>&)
        {
          return sizeof...(Ts);
        }
  • std::tuple_element_t<I, decltype(t)>: 表示给定元组类型的第I个元素的类型。同样,标准库以一种比核心语言更不优雅的方式公开了这项信息。通常,要找到元组的第I个元素的类型,你只需编写decltype(std::get<I>(t))

  • std::tuple_cat(t1, t2, t3...): 将所有给定的元组从头到尾连接起来。

  • std::forward_as_tuple(a, b, c...): 创建一个引用元组,就像std::tie;但与std::tie要求左值引用不同,std::forward_as_tuple将接受任何类型的引用作为输入,并将它们完美地转发到元组中,以便稍后可以通过std::get<I>(t)...提取它们:

        template<typename F>
        void run_zeroarg(const F& f);

        template<typename F, typename... Args>
        void run_multiarg(const F& f, Args&&... args)
        {
          auto fwd_args =
            std::forward_as_tuple(std::forward<Args>(args)...);
          auto lambda = [&f, fwd_args]() {
            std::apply(f, fwd_args);
          };
          run_zeroarg(f);
        }

关于命名类的说明

正如我们在第四章“容器动物园”中看到的那样,当我们比较std::array<double, 3>struct Vec3时,使用 STL 类模板可以缩短你的开发时间,并通过重用经过良好测试的 STL 组件来消除错误来源;但它也可能使你的代码可读性降低或给你的类型赋予过多的功能。在我们的第四章“容器动物园”的例子中,std::array<double, 3>对于Vec3来说是一个糟糕的选择,因为它暴露了一个不想要的operator<

在你的接口和 API 中使用任何代数类型(tuplepairoptionalvariant)可能是错误的。你会发现,如果你为自己的“特定领域词汇”类型编写命名类,你的代码将更容易阅读、理解和维护,即使它们最终只是代数类型的薄包装——尤其是如果它们最终只是代数类型的薄包装。

使用 std::variant 表达备选方案

std::tuple<A,B,C>是一个积类型std::variant<A,B,C>是一个和类型。一个变体可以同时持有ABC中的一个——但一次不能同时持有(或少于)一个。这个概念的另一个名字是有区别的联合,因为变体在很多方面都像原生 C++的union;但与原生union不同,变体总是能够告诉你它的哪个元素,ABC,在任何给定时间点是“活跃”的。这些元素的官方名称是“备选方案”,因为一次只能有一个是活跃的:

    std::variant<int, double> v1;

    v1 = 1; // activate the "int" member
    assert(v1.index() == 0);
    assert(std::get<0>(v1) == 1);

    v1 = 3.14; // activate the "double" member
    assert(v1.index() == 1);
    assert(std::get<1>(v1) == 3.14);
    assert(std::get<double>(v1) == 3.14);

    assert(std::holds_alternative<int>(v1) == false);
    assert(std::holds_alternative<double>(v1) == true);

    assert(std::get_if<int>(&v1) == nullptr);
    assert(*std::get_if<double>(&v1) == 3.14);

tuple一样,你可以使用std::get<I>(v)获取variant的特定元素。如果你的变体对象的备选方案都是不同的(除非你在进行深度元编程,这应该是最常见的用例),你可以使用std::get<T>(v)与类型以及索引一起使用——例如,查看前面的代码示例,其中std::get<0>(v1)std::get<int>(v1)可以互换使用,因为变体v1中的零索引备选方案是int类型。然而,与tuple不同,变体上的std::get允许失败!如果你在v1当前持有int类型值时调用std::get<double>(v1),那么你会得到一个std::bad_variant_access类型的异常。std::get_ifstd::get的“非抛出”版本。正如前面的示例所示,如果指定的备选方案是活跃的,get_if返回指向该备选方案的指针,否则返回空指针。因此,以下代码片段都是等效的:

    // Worst...
    try {
      std::cout << std::get<int>(v1) << std::endl;
    } catch (const std::bad_variant_access&) {}

    // Still bad...
    if (v1.index() == 0) {
      std::cout << std::get<int>(v1) << std::endl; 
    }

    // Slightly better... 
    if (std::holds_alternative<int>(v1)) {
      std::cout << std::get<int>(v1) << std::endl;
    } 

    // ...Best.
    if (int *p = std::get_if<int>(&v1)) {
      std::cout << *p << std::endl; 
    }

访问变体

在前面的例子中,我们展示了当有一个变量 std::variant<int, double> v 时,调用 std::get<double>(v) 会给我们当前的值,前提是变体当前持有 double,但如果变体持有 int,则会抛出异常。这可能会让你觉得有些奇怪——因为 int 可以转换为 double,为什么它不能直接给我们转换后的值呢?

如果我们想要这种行为,我们不能从 std::get 获取。我们必须以这种方式重新表达我们的需求:“我有一个变体。如果它当前持有 double,称为 d,那么我想获取 double(d)。如果它持有 int i,那么我想获取 double(i)。”也就是说,我们有一个行为列表在心中,我们想要在当前由我们的变体 v 持有的任何替代方案上调用这其中的一个行为。标准库通过可能有些晦涩的名字 std::visit 来表达这个算法:

    struct Visitor {
      double operator()(double d) { return d; }
      double operator()(int i) { return double(i); }
      double operator()(const std::string&) { return -1; }
    };

    using Var = std::variant<int, double, std::string>;

    void show(Var v)
    {
      std::cout << std::visit(Visitor{}, v) << std::endl;
    }

    void test() 
    {
      show(3.14);
      show(1);
      show("hello world");
    }

一般而言,当我们 visit 一个变体时,我们心中所想的全部行为在本质上都是相似的。因为我们是用 C++ 编写的,它具有函数和运算符的重载,我们可以一般地使用完全相同的语法来表达我们的相似行为。如果我们可以用相同的语法来表达它们,我们就可以将它们封装到一个模板函数中,或者——最常见的情况——一个 C++14 泛型 lambda,如下所示:

    std::visit([](const auto& alt) {
      if constexpr (std::is_same_v<decltype(alt), const std::string&>) {
        std::cout << double(-1) << std::endl;
      } else {
        std::cout << double(alt) << std::endl;
      }
    }, v);

注意到使用了 C++17 的 if constexpr 来处理与其他情况根本不同的一种情况。是否更喜欢使用这种明确的 decltype 切换,或者创建一个辅助类,例如前面代码示例中的 Visitor,并依赖重载解析来选择每个可能替代的 operator() 的正确重载,这更多是一个个人喜好问题。

std::visit 也有一个可变参数版本,它接受两个、三个甚至更多的 variant 对象,这些对象可以是相同类型或不同类型。这个版本的 std::visit 可以用来实现一种“多重分派”,如下面的代码所示。然而,除非你正在进行真正的密集型元编程,否则你几乎肯定不需要这个版本的 std::visit

    struct MultiVisitor {
      template<class T, class U, class V>
      void operator()(T, U, V) const { puts("wrong"); }

      void operator()(char, int, double) const { puts("right!"); }
    };

    void test()
    {
      std::variant<int, double, char> v1 = 'x';
      std::variant<char, int, double> v2 = 1;
      std::variant<double, char, int> v3 = 3.14;
      std::visit(MultiVisitor{}, v1, v2, v3); // prints "right!"
    }

那么 make_variant 呢?以及关于值语义的注意事项

由于你可以使用 std::make_tuple 创建一个元组对象,或者使用 make_pair 创建一个对,你可能会合理地问:“make_variant 呢?”实际上,并没有这样的函数。它不存在的主要原因在于,虽然 tuplepair 是积类型,但 variant 是和类型。要创建一个元组,你必须始终提供其所有 n 个元素的值,因此元素类型总是可以推断出来的。对于 variant,你只需要提供其一个值——假设为类型 A——但编译器在不知道类型 BC 的身份的情况下,无法创建一个 variant<A,B,C> 对象。因此,提供 my::make_variant<A,B,C>(a) 这样的函数是没有意义的,因为实际的类构造函数可以更简洁地写成:std::variant<A,B,C>(a)

我们已经提到了make_pairmake_tuple存在的次要原因:它们自动将特殊词汇类型std::reference_wrapper<T>衰减为T&,因此std::make_pair(std::ref(a), std::cref(b))创建了一个类型为std::pair<A&, const B&>的对象。具有“引用对”或“引用元组”类型的对象表现得非常奇怪:你可以使用通常的语义比较和复制它们,但当你将值赋给这种类型的对象时,而不是“重新绑定”引用元素(以便它们引用右侧的对象),赋值运算符实际上“通过”赋值,改变所引用对象的值。正如我们在“使用std::tuple”部分的代码示例中所看到的,这种故意的奇怪性允许我们使用std::tie作为一种“多重赋值”语句。

所以,我们可能期望或希望看到标准库中有一个make_variant函数的另一个原因可能是它的引用衰减能力。然而,这仅仅是因为一个简单的原因——标准禁止创建元素为引用类型的变体!我们将在本章后面看到,std::optionalstd::any同样被禁止持有引用类型。(然而,std::variant<std::reference_wrapper<T>, ...>是完全合法的。)这种禁止的原因是库的设计者还没有就引用的变体应该意味着什么达成共识。或者,更确切地说,一个引用的元组应该意味着什么!我们今天在语言中拥有引用元组的原因仅仅是因为std::tie在 2011 年看起来是一个非常好的主意。到了 2017 年,没有人特别渴望通过引入变体、可选或引用的“任何”来增加混淆。

我们已经确定了std::variant<A,B,C>始终恰好持有类型ABC的一个值——不多也不少。嗯,这实际上并不完全正确。在非常罕见的情况下,有可能构造一个没有任何值的变体。唯一使这种情况发生的方法是使用类型A的值来构造变体,然后以这种方式给它分配一个类型B的值,即A被成功销毁,但构造函数B抛出异常,而B实际上从未被放置。当这种情况发生时,变体对象进入一个被称为“无值异常”的状态:

    struct A {
      A() { throw "ha ha!"; }
    };
    struct B {
      operator int () { throw "ha ha!"; }
    };
    struct C {
      C() = default;
      C& operator=(C&&) = default;
      C(C&&) { throw "ha ha!"; }
    };

    void test()
    {
      std::variant<int, A, C> v1 = 42;

      try {
        v1.emplace<A>();
      } catch (const char *haha) {}
      assert(v1.valueless_by_exception());

      try {
        v1.emplace<int>(B());
      } catch (const char *haha) {}
      assert(v1.valueless_by_exception());
    }

这种情况永远不会发生在你身上,除非你正在编写构造函数或转换运算符会抛出异常的代码。此外,通过使用operator=而不是emplace,你可以避免在除了你有抛出异常的移动构造函数之外的所有情况下出现无值的变体:

    v1 = 42;

    // Constructing the right-hand side of this assignment
    // will throw; yet the variant is unaffected.
    try { v1 = A(); } catch (...) {}
    assert(std::get<int>(v1) == 42);

    // In this case as well.
    try { v1 = B(); } catch (...) {}
    assert(std::get<int>(v1) == 42);

    // But a throwing move-constructor can still foul it up.
    try { v1 = C(); } catch (...) {}
    assert(v1.valueless_by_exception());

从第四章“容器动物园”中关于std::vector的讨论中回忆起来,你的类型的移动构造函数应该始终标记为noexcept;因此,如果你虔诚地遵循这条建议,你将能够完全避免处理valueless_by_exception

无论如何,当一个变体处于这种状态时,它的index()方法返回size_t(-1)(一个也称为std::variant_npos的常量)并且任何尝试std::visit它的操作都将抛出一个类型为std::bad_variant_access的异常。

使用std::optional延迟初始化

你可能已经在想,std::variant的一个潜在用途可能是表示“也许我有一个对象,也许我没有。”例如,我们可以使用标准的标签类型std::monostate来表示“也许我没有”的状态:

    std::map<std::string, int> g_limits = {
      { "memory", 655360 }
    };

    std::variant<std::monostate, int>
    get_resource_limit(const std::string& key)
    {
      if (auto it = g_limits.find(key); it != g_limits.end()) {
        return it->second;
      }
      return std::monostate{};
    }

    void test()
    {
      auto limit = get_resource_limit("memory");
      if (std::holds_alternative<int>(limit)) {
        use( std::get<int>(limit) );
      } else {
        use( some_default );
      }
    }

你会很高兴地知道,这不是实现该目标的最佳方式!标准库提供了专门用于处理“也许我有一个对象,也许我没有”这一概念的vocabulary type std::optional<T>

    std::optional<int>
    get_resource_limit(const std::string& key)
    {
      if (auto it = g_limits.find(key); it != g_limits.end()) {
        return it->second;
      }
      return std::nullopt;
    }

    void test()
    {
      auto limit = get_resource_limit("memory");
      if (limit.has_value()) {
        use( *limit );
      } else {
        use( some_default );
      }
    }

在代数数据类型的逻辑中,std::optional<T>是一个和类型:它具有与T一样多的可能值,再加上一个。这个额外值被称为“null”,“empty”或“disengaged”状态,并在源代码中由特殊常量std::nullopt表示。

不要将std::nullopt与同名的std::nullptr混淆!它们除了都是模糊的 null-like 之外,没有共同之处。

与具有混乱的免费(非成员)函数的std::tuplestd::variant不同,std::optional<T>类充满了方便的成员函数。o.has_value()为真,如果可选对象o当前持有类型为T的值。通常将“有值”状态称为“参与”状态;包含值的可选对象是“参与”的,而处于空状态的可选对象是“分离”的。

如果比较运算符==, !=, <, <=, >, 和 >=对于T是有效的,那么它们都会为optional<T>重载。要比较两个可选对象,或者将一个可选对象与类型为T的值进行比较,你需要记住的是,在分离状态下,可选对象与T的任何实际值比较时都“小于”。

bool(o)o.has_value()的同义词,而!o!o.has_value()的同义词。我个人建议你始终使用has_value,因为它们在运行时成本上没有区别;唯一的区别在于代码的可读性。如果你确实使用了简化的转换到bool的形式,请注意,对于std::optional<bool>o == false!o意味着非常不同的事情!

o.value()返回一个指向o包含的值的引用。如果o当前处于分离状态,则o.value()会抛出一个类型为std::bad_optional_access的异常。

使用重载的单目运算符 operator**o 返回 o 包含的值的引用,而不检查是否已连接。如果 o 当前未连接,并且你调用 *o,则这是未定义的行为,就像你调用 *p 在空指针上一样。你可以通过注意 C++ 标准库喜欢使用标点符号来表示其最有效、最少检查理智的操作来记住这种行为。例如,std::vector::operator[] 的边界检查比 std::vector::at() 少。因此,按照同样的逻辑,std::optional::operator* 的边界检查比 std::optional::value() 少。

o.value_or(x) 返回 o 包含的值的副本,或者如果 o 未连接,则返回将 x 转换为类型 T 的副本。我们可以使用 value_or 将前面的代码示例重写为一行简单且易于阅读的代码:

    std::optional<int> get_resource_limit(const std::string&);

    void test() {
      auto limit = get_resource_limit("memory");
      use( limit.value_or(some_default) );
    }

前面的例子已经展示了如何使用 std::optional<T> 作为处理“可能是一个 T”在飞行中的方式(作为函数返回类型或参数类型)。另一种常见且有用的使用 std::optional<T> 的方式是作为处理“尚未是一个 T”在静止中的方式,作为类数据成员。例如,假设我们有一些类型 L,它不是默认可构造的,例如由 lambda 表达式产生的闭包类型:

    auto make_lambda(int arg) {
      return arg { return x + arg; };
    }
    using L = decltype(make_lambda(0));

    static_assert(!std::is_default_constructible_v<L>);
    static_assert(!std::is_move_assignable_v<L>);

然后,具有该类型成员的类也将无法默认构造:

    class ProblematicAdder {
      L fn_;
    };

    static_assert(!std::is_default_constructible_v<ProblematicAdder>);

但是,通过向我们的类提供一个类型为 std::optional<L> 的成员,我们允许它在需要默认构造性的上下文中使用:

    class Adder {
      std::optional<L> fn_;
      public:
      void setup(int first_arg) {
        fn_.emplace(make_lambda(first_arg));
      }
      int call(int second_arg) {
        // this will throw unless setup() was called first
        return fn_.value()(second_arg);
      }
    };

    static_assert(std::is_default_constructible_v<Adder>);

    void test() {
      Adder adder;
      adder.setup(4);
      int result = adder.call(5);
      assert(result == 9); 
    }

没有使用 std::optional,要实现这种行为是非常困难的。你可以使用 placement-new 语法或使用 union 来做,但本质上你必须至少重新实现 optional 的一半。使用 std::optional 会更好!

注意,如果出于某种原因我们想要得到未定义的行为而不是从 call() 抛出异常的可能性,我们只需将 fn_.value() 替换为 *fn_

std::optional 真的是 C++17 新特性中最大的胜利之一,通过熟悉它,你将受益匪浅。

optional,可以将其描述为一种有限的单类型 variant,我们现在转向另一个极端:无限代数数据类型的等价物。

重访变体

variant 数据类型擅长表示简单的选择,但截至 C++17,它并不特别适合表示如 JSON 列表之类的递归数据类型。也就是说,以下 C++17 代码将无法编译:

    using JSONValue = std::variant<
      std::nullptr_t,
      bool,
      double,
      std::string,
      std::vector<JSONValue>,
      std::map<std::string, JSONValue>
    >;

有几种可能的解决方案。最稳健和正确的方法是继续使用 C++11 的 Boost 库 boost::variant,该库通过标记类型 boost::recursive_variant_ 特定地支持递归变体类型:

    using JSONValue = boost::variant<
      std::nullptr_t,
      bool,
      double,
      std::string,
      std::vector<boost::recursive_variant_>,
      std::map<std::string, boost::recursive_variant_>
    >;

你也可以通过引入一个新的类类型 JSONValue 来解决这个问题,该类型要么 包含 要素,要么 递归类型的 std::variant

注意,在下面的例子中,我选择了 HAS-A 而不是 IS-A;从非多态的标准库类型继承几乎总是一个非常糟糕的想法。

由于 C++接受对类类型的转发引用,这将编译:

    struct JSONValue {
      std::variant<
        std::nullptr_t,
        bool,
        double,
        std::string,
        std::vector<JSONValue>,
        std::map<std::string, JSONValue>
      > value_;
    };

最后的可能性是切换到标准库中的一个比variant更强大的代数类型。

使用 std::any 的无限备选方案

用亨利·福特的话来说,类型为std::variant<A, B, C>的对象可以存储一个值

任何类型--只要它是ABC。但是,假设我们想要存储一个真正任何类型的值?也许我们的程序将在运行时加载插件,这些插件可能包含无法预测的新类型。我们无法在variant中指定这些类型。或者,也许我们处于前一节详细描述的“递归数据类型”情况。

对于这些情况,C++17 标准库提供了一个代数数据类型的“无穷大”版本:类型std::any。这是一种容器(见第四章,容器动物园),用于存储任何类型的单个对象。容器可能是空的,也可能包含一个对象。您可以对any对象执行以下基本操作:

  • 询问它当前是否持有对象

  • 向其中放入一个新的对象(销毁旧对象,无论它是什么)

  • 询问所持有对象的类型

  • 通过正确命名其类型来检索所持有的对象

在代码中,这三个操作的前三个看起来像这样:

    std::any a; // construct an empty container

    assert(!a.has_value());

    a = std::string("hello");
    assert(a.has_value());
    assert(a.type() == typeid(std::string));

    a = 42;
    assert(a.has_value());
    assert(a.type() == typeid(int));

第四种操作稍微有些复杂。它被称作std::any_cast,并且,就像std::get对变体一样,它有两种风味:一种类似于std::get的风味,在失败时抛出std::bad_any_cast异常,以及一种类似于std::get_if的风味,在失败时返回一个空指针:

    if (std::string *p = std::any_cast<std::string>(&a)) {
      use(*p);
    } else {
      // go fish!
    }

    try {
      std::string& s = std::any_cast<std::string&>(a);
      use(s);
    } catch (const std::bad_any_cast&) {
      // go fish!
    }

注意,在两种情况下,您都必须命名您想要从any对象中检索的类型。如果您类型错误,那么您将得到一个异常或空指针。没有办法说“给我一个所持有的对象,无论它的类型是什么”,因为那样这个表达式的类型又是什么呢?

回想一下,当我们在前一节遇到与std::variant类似的问题时,我们通过使用std::visit将一些泛型代码访问到所持有的备选方案上解决了它。不幸的是,对于any没有等效的std::visit。原因是简单且无法克服的:分离编译。假设在一个源文件a.cc中,我有:

    template<class T> struct Widget {};

    std::any get_widget() {
      return std::make_any<Widget<int>>();
    }

在另一个源文件b.cc中(可能编译成不同的插件,.dll或共享对象文件)我有:

    template<class T> struct Widget {};

    template<class T> int size(Widget<T>& w) {
      return sizeof w;
    }

    void test()
    {
      std::any a = get_widget();
      int sz = hypothetical_any_visit([](auto&& w){
        return size(w);
      }, a);
      assert(sz == sizeof(Widget<int>));
    }

当编译 b.cc 时,编译器如何知道它需要输出 size(Widget<int>&) 的模板实例化,而不是,比如说,size(Widget<double>&)?当有人将 a.cc 改为返回 make_any(Widget<char>&) 时,编译器应该如何知道它需要使用新的 size(Widget<char>&) 实例重新编译 b.cc,而 size(Widget<int>&) 的实例不再需要——除非我们预计要链接到一个确实需要该实例化的 c.cc!基本上,编译器无法确定在可以定义为包含任何类型并触发任何代码生成的容器上,可能需要什么样的代码生成。

因此,为了提取 any 中包含值的任何函数,你必须事先知道该包含值的类型可能是什么。(如果你猜错了——去钓鱼吧!)

std::any 与多态类类型

std::any 处于 std::variant<A, B, C> 的编译时多态和具有多态继承层次结构和 dynamic_cast 的运行时多态之间。你可能想知道 std::any 是否与 dynamic_cast 的机制有任何交互。答案是“没有,它没有”——也没有任何标准方法来获得这种行为。std::any 是百分之百的静态类型安全:没有方法可以突破它并获得“指向数据的指针”(例如,void *),除非你知道数据的确切静态类型:

    struct Animal {
      virtual ~Animal() = default;
    };

    struct Cat : Animal {};

    void test()
    {
      std::any a = Cat{};

      // The held object is a "Cat"...
      assert(a.type() == typeid(Cat));
      assert(std::any_cast<Cat>(&a) != nullptr);

      // Asking for a base "Animal" will not work.
      assert(a.type() != typeid(Animal));
      assert(std::any_cast<Animal>(&a) == nullptr);

      // Asking for void* certainly will not work!
      assert(std::any_cast<void>(&a) == nullptr);
    }

简而言之,类型擦除

让我们简要地看看标准库如何实现 std::any。其核心思想被称为“类型擦除”,我们实现它的方法是通过识别我们想要支持的所有类型 T 的显著或相关操作,然后“擦除”任何特定类型 T 可能支持的任何其他独特操作。

对于 std::any,其显著的操作如下:

  • 通过移动构造包含对象

  • 通过移动构造包含对象

  • 获取包含对象的 typeid

构造和销毁也是必需的,但这两个操作与包含对象本身的生存期管理有关,而不是“你可以用它做什么”,所以至少在这个情况下,我们不需要考虑它们。

因此,我们发明了一个支持仅这三种操作的多态类类型(称之为 AnyBase),这些操作作为可重写的 virtual 方法,然后每次程序员实际上将特定类型 T 的对象存储到 any 中时,我们创建一个新的派生类(称之为 AnyImpl<T>):

    class any;

    struct AnyBase {
      virtual const std::type_info& type() = 0;
      virtual void copy_to(any&) = 0;
      virtual void move_to(any&) = 0;
      virtual ~AnyBase() = default;
    };

    template<typename T>
    struct AnyImpl : AnyBase {
      T t_;
      const std::type_info& type() {
        return typeid(T);
      }
      void copy_to(any& rhs) override {
        rhs.emplace<T>(t_);
      }
      void move_to(any& rhs) override {
        rhs.emplace<T>(std::move(t_));
      }
      // the destructor doesn't need anything
      // special in this case
    };

使用这些辅助类,实现 std::any 的代码变得相当简单,尤其是当我们使用智能指针(见第六章,智能指针)来管理 AnyImpl<T> 对象的生存期时:

    class any {
      std::unique_ptr<AnyBase> p_ = nullptr;
      public:
      template<typename T, typename... Args>
      std::decay_t<T>& emplace(Args&&... args) {
        p_ = std::make_unique<AnyImpl<T>>(std::forward<Args>(args)...);
      }

      bool has_value() const noexcept {
        return (p_ != nullptr);
      }

      void reset() noexcept {
        p_ = nullptr;
      }

      const std::type_info& type() const {
        return p_ ? p_->type() : typeid(void);
      }

      any(const any& rhs) {
        *this = rhs;
      }

      any& operator=(const any& rhs) {
        if (rhs.has_value()) {
          rhs.p_->copy_to(*this);
        }
        return *this;
      }
    };

前面的代码示例省略了移动赋值的实现。它可以像复制赋值一样完成,或者可以通过简单地交换指针来完成。标准库实际上在可能的情况下更喜欢交换指针,因为这保证是 noexcept;你可能会看到 std::any 不交换指针的唯一原因可能是它使用“小对象优化”来避免为非常小的、不可抛出移动构造的类型 T 进行堆分配。截至本文撰写时,libstdc++(GCC 使用的库)将使用小对象优化,并避免为大小最多为 8 字节类型的堆分配;libc++(Clang 使用的库)将使用小对象优化,适用于大小最多为 24 字节类型的类型。

与第四章中讨论的标准容器不同,《容器动物园》,std::any 不接受分配器参数,也不允许你自定义或配置其堆内存的来源。如果你在实时或内存受限的系统上使用 C++,其中不允许堆分配,那么你不应该使用 std::any。考虑一个替代方案,例如 Tiemo Jung 的 tj::inplace_any<Size, Alignment>。如果所有其他方法都失败了,你现在已经看到了如何自己实现它!

std::any 和可复制性

注意到我们的 AnyImpl<T>::copy_to 定义要求 T 可复制构造。这对于标准的 std::any 也是正确的;没有方法可以将移动唯一类型存储到 std::any 对象中。绕过这个问题的方法是使用一种“适配器”包装器,其目的是使其移动唯一对象符合可复制构造的语法要求,同时避免任何实际的复制:

    using Ptr = std::unique_ptr<int>;

    template<class T>
    struct Shim {
      T get() { return std::move(*t_); }

      template<class... Args>
      Shim(Args&&... args) : t_(std::in_place,
        std::forward<Args>(args)...) {}

      Shim(Shim&&) = default;
      Shim& operator=(Shim&&) = default;
      Shim(const Shim&) { throw "oops"; }
      Shim& operator=(const Shim&) { throw "oops"; }
      private:
      std::optional<T> t_;
    };

    void test()
    {
      Ptr p = std::make_unique<int>(42);

      // Ptr cannot be stored in std::any because it is move-only.
      // std::any a = std::move(p);

      // But Shim<Ptr> can be!
      std::any a = Shim<Ptr>(std::move(p));
      assert(a.type() == typeid(Shim<Ptr>));

      // Moving a Shim<Ptr> is okay...
      std::any b = std::move(a);

      try {
        // ...but copying a Shim<Ptr> will throw.
        std::any c = b;
      } catch (...) {}

      // Get the move-only Ptr back out of the Shim<Ptr>.
      Ptr r = std::any_cast<Shim<Ptr>&>(b).get();
      assert(*r == 42);
    }

注意前一个代码示例中 std::optional<T> 的使用;这保护了我们的假复制构造函数免受 T 可能不可默认构造的可能性。

再次提到类型擦除:std::function

我们观察到对于 std::any,显著的操作如下:

  • 构建包含对象的副本

  • 通过移动构造函数构建包含对象的副本

  • 获取包含对象的 typeid

假设我们要添加一个到这组显著的操作中?让我们说我们的集合是:

  • 构建包含对象的副本

  • 通过移动构造函数构建包含对象的副本

  • 获取包含对象的 typeid

  • 使用特定的固定参数类型序列 A... 调用包含的对象,并将结果转换为某种特定的固定类型 R

这组操作的类型擦除对应于标准库类型 std::function<R(A...)>

    int my_abs(int x) { return x < 0 ? -x : x; }
    long unusual(long x, int y = 3) { return x + y; }

    void test()
    {
      std::function<int(int)> f; // construct an empty container
      assert(!f);

      f = my_abs; // store a function in the container
      assert(f(-42) == 42);

      f = [](long x) { return unusual(x); }; // or a lambda!
      assert(f(-42) == -39);
    }

如果包含的对象具有状态,则复制 std::function 总是会复制包含的对象。当然,如果包含的对象是函数指针,你不会观察到任何差异;但如果你尝试使用用户定义类类型的对象或具有状态的 lambda 表达式,你可以看到复制发生:

    f = i=0 mutable { return ++i; };
    assert(f(-42) == 1); 
    assert(f(-42) == 2);

    auto g = f;
    assert(f(-42) == 3);
    assert(f(-42) == 4);
    assert(g(-42) == 3);
    assert(g(-42) == 4);

就像 std::any 一样,std::function<R(A...) 允许你检索包含对象的 typeid,或者如果你静态地知道(或可以猜测)其类型,可以检索指向该对象的指针:

  • f.target_type() 等同于 a.type()

  • f.target<T>() 等同于 std::any_cast<T*>(&a)

    if (f.target_type() == typeid(int(*)(int))) {
      int (*p)(int) = *f.target<int (*)(int)>();
      use(p);
    } else {
      // go fish!
    }

话虽如此,我在现实生活中从未见过这些方法的实际用例。通常,如果你必须询问 std::function 的包含类型,那么你已经做错了什么。

std::function 最重要的用例是作为跨越模块边界的“行为”传递的词汇类型,在这种情况下使用模板是不可能的——例如,当你需要将回调传递给外部库中的函数时,或者当你编写需要从其调用者接收回调的库时:

    // templated_for_each is a template and must be visible at the
    // point where it is called.
    template<class F>
    void templated_for_each(std::vector<int>& v, F f) {
      for (int& i : v) {
        f(i);
      }
    }

    // type_erased_for_each has a stable ABI and a fixed address.
    // It can be called with only its declaration in scope.
    extern void type_erased_for_each(std::vector<int>&,
      std::function<void(int)>);

我们在本章开始时讨论了 std::string,这是在函数之间传递字符串的标准词汇类型;现在,随着本章的结束,我们正在讨论 std::function,这是在函数之间传递 函数 的标准词汇类型!

std::function, 可复制性和分配

就像 std::any 一样,std::function 要求存储在其内的任何对象都必须是可复制的。如果你使用了很多捕获 std::future<T>std::unique_ptr<T> 或其他只能移动的类型(move-only types)的 lambda 表达式,这可能会带来问题:这样的 lambda 类型本身也将是只能移动的。解决这个问题的一种方法在本章的 std::any and copyability 部分已经演示过:我们可以引入一个在语法上可复制的适配器(shim),但如果你尝试复制它,它会抛出一个异常。

当与 std::function 和 lambda 捕获一起工作时,可能更倾向于通过 shared_ptr 捕获只能移动的 lambda 捕获。我们将在下一章介绍 shared_ptr

    auto capture = [](auto& p) {
      using T = std::decay_t<decltype(p)>;
      return std::make_shared<T>(std::move(p));
    };

   std::promise<int> p;

   std::function<void()> f = [sp = capture(p)]() {
     sp->set_value(42);
   };

就像 std::any 一样,std::function 不接受分配器参数,也不允许你自定义或配置其堆内存的来源。如果你在实时或内存受限的系统上使用 C++,其中不允许堆分配,那么你不应该使用 std::function。考虑使用如 Carl Cook 的 sg14::inplace_function<R(A...), Size, Alignment> 这样的替代方案。

概述

类似于 std::stringstd::function 这样的词汇类型(vocabulary types)允许我们共享一个 通用语言 来处理常见的编程概念。在 C++17 中,我们有一套丰富的词汇类型来处理 代数数据类型std::pairstd::tuple(积类型),std::optionalstd::variant(和类型),以及 std::any(和类型的终极形式——它可以存储几乎任何东西)。然而,不要沉迷于使用 std::tuplestd::variant 作为每个函数的返回类型!命名类类型仍然是保持代码可读性最有效的方法。

使用 std::optional 来表示可能缺少的值,或者表示数据成员的“尚未存在”状态。

使用 std::get_if<T>(&v) 来查询 variant 的类型;使用 std::any_cast<T>(&a) 来查询 any 的类型。请记住,您提供的类型必须与目标类型完全匹配;如果不匹配,您将得到 nullptr

请注意,make_tuplemake_pair 不仅构造 tuplepair 对象;它们还将 reference_wrapper 对象解引用为原生引用。使用 std::tiestd::forward_as_tuple 来创建引用的元组。std::tie 特别适用于多重赋值和编写比较运算符。std::forward_as_tuple 对于元编程很有用。

请注意,std::variant 总是有可能处于“异常无值”状态;但要知道,除非您编写具有抛出移动构造函数的类,否则您不必担心这种情况。另外:不要编写具有抛出移动构造函数的类!

请注意,类型擦除的类型 std::anystd::function 隐式地使用了堆。第三方库提供了这些类型的非标准 inplace_ 版本。请注意,std::anystd::function 要求其包含的类型必须是可复制的。如果出现这种情况,请使用 "通过 shared_ptr 捕获" 来处理。

第六章:智能指针

C++凭借其性能——编写良好的 C++代码比其他任何东西都要快——几乎可以说是定义性的,因为 C++给了程序员几乎完全控制最终由编译器生成的代码。

低级、高性能代码的一个经典特性是使用原始指针Foo*)。然而,原始指针伴随着许多陷阱,例如内存泄漏和悬垂指针。C++11 库的“智能指针”类型可以帮助你在几乎不花费任何代价的情况下避免这些陷阱。

在本章中,我们将学习以下内容:

  • “智能指针”的定义以及如何编写你自己的

  • std::unique_ptr<T>在防止所有类型的资源泄漏(不仅仅是内存泄漏)中的有用性

  • std::shared_ptr<T>的实现方式及其对内存使用的含义

  • 奇怪重复模板模式(Curiously Recurring Template Pattern)的意义和用途

智能指针的起源

原始指针在 C 语言中有许多用途:

  • 作为对调用方拥有的对象的一种便宜的非复制视图

  • 作为被调用方修改调用方拥有的对象的一种方式

  • 作为指针/长度对的一半,用于数组

  • 作为可选参数(一个有效的指针空指针)

  • 作为管理堆内存的一种方式

在 C++中,我们有原生引用(const Foo&Foo&)来处理前两点;此外,移动语义使得调用方在大多数情况下通过值传递复杂数据给被调用方变得非常便宜,从而完全避免了别名问题。在 C++17 中,我们可以使用std::string_view来解决一些第一点和第三点的问题。我们已经在第五章,“词汇类型”中看到,传递一个optional<T>——或者可能使用一个optional<reference_wrapper<T>>来变得复杂——足以处理第四点。

本章将关注第五点。

在 C 语言中,堆分配带来了一系列问题,并且所有这些问题(以及更多!)在 2011 年之前的 C++中都得到了应用。然而,从 C++11 开始,几乎所有这些问题都消失了。让我们列举一下:

  • 内存泄漏:你可能在堆上分配了一块内存或一个对象,但意外地忘记编写释放它的代码。

  • 内存泄漏:你可能已经编写了那段代码,但由于早期返回或抛出异常,代码从未运行,内存仍然未被释放!

  • 使用后释放:你复制了一个指向堆上对象的指针,然后通过原始指针释放了该对象。复制指针的持有者没有意识到他们的指针已经不再有效。

  • 通过指针算术导致的堆损坏:你在堆上以地址 A 分配了一个数组。拥有一个指向数组的原始指针会诱使你进行指针算术,最终你意外地释放了指向地址 A+k 的指针。当 k=0(正如墨菲定律确保的那样,在测试中)时没有问题;当 k=1 时,你会损坏你的堆并导致崩溃。

前两个问题由于堆分配在语义上允许失败--malloc 可以返回空,operator new 可以抛出 std::bad_alloc--而加剧,这意味着如果你正在编写预 C++11 代码来分配内存,你可能会编写大量的清理代码来处理分配失败。(在 C++中,无论你是否意识到,你都在“编写”这些代码,因为由于异常处理而产生的控制流路径是存在的,即使你没有有意识地思考它们。)所有这些的结果是,在 C++中管理堆分配的内存是 困难的

除非你使用智能指针!

智能指针永远不会忘记

“智能指针”类型的概念(不要与“花哨指针”类型混淆,我们将在第八章(part0129.html#3R0OI0-2fdac365b8984feebddfbb9250eaf20d)分配器中介绍)是它是一个类--通常是类模板--它在语法上表现得就像一个指针,但其特殊成员函数(构造、析构和复制/移动)有额外的簿记来确保某些不变性。例如,我们可能确保以下内容:

  • 指针的析构函数也会释放其指向的对象--有助于解决内存泄漏问题

  • 可能指针不能被复制--有助于解决使用后释放

  • 或者指针 可以被复制,但它知道存在多少个副本,并且只有在最后一个指向它的指针被销毁后才会释放指向的对象

  • 或者指针可以被复制,你可以释放指向的对象,但如果你这样做,指向它的所有其他指针都会神奇地变为空

  • 或者指针没有内置的 operator+--有助于

    解决由于指针算术引起的损坏

  • 或者你可能允许以算术方式调整指针的值,但算术“指向哪个对象”的管理与“要释放哪个对象”的标识是分开的

标准智能指针类型是 std::unique_ptr<T>, std::shared_ptr<T>, 以及(虽然不是一个真正的指针类型,但我们将它们放在一起)std::weak_ptr<T>。在本章中,我们将介绍这三种类型,以及一种可能对你有用的非标准智能指针,它可能在未来的 C++标准中成为标准智能指针类型!

使用 std::unique_ptr 自动管理内存

智能指针类型的根本属性很简单:它应该支持 operator*,并且它应该重载特殊成员函数以保持其类的不变性,无论这些是什么。

std::unique_ptr<T> 支持与 T* 相同的接口,但具有类不变性,即一旦构造了一个指向给定堆分配对象的 unique_ptr,当调用析构函数 unique_ptr 时,该对象 将会 被释放。让我们编写一些支持该 T* 接口的代码:

    template<typename T>
    class unique_ptr {
      T *m_ptr = nullptr;
    public:
      constexpr unique_ptr() noexcept = default;
      constexpr unique_ptr(T *p) noexcept : m_ptr(p) {}

      T *get() const noexcept { return m_ptr; }
      operator bool() const noexcept { return bool(get()); }
      T& operator*() const noexcept { return *get(); }
      T* operator->() const noexcept { return get(); }

如果我们在这里停止——只提供了从 T* 构造指针对象的方法以及再次获取指针的方法——我们将得到本章末尾讨论的 observer_ptr<T>。但我们会继续前进。我们将添加 releasereset 方法:

      void reset(T *p = nullptr) noexcept {
        T *old_p = std::exchange(m_ptr, p);
        delete old_p;
      }

      T *release() noexcept {
        return std::exchange(m_ptr, nullptr);
      }

p.release() 就像 p.get(),但除了返回原始原始指针的副本外,它还会将 p 的内容置为空(当然,不会释放原始指针,因为我们的调用者可能想要拥有它)。

p.reset(q) 确实 释放了 p 的当前内容,然后将原始指针 q 放在其位置。

注意,我们已经用标准算法 std::exchange 实现了这两个成员函数,我们在 第三章 “迭代器对算法” 中没有介绍过。它有点像是返回值的 std::swap:传入一个新值,得到旧值。

最后,使用这两个基本操作,我们可以实现 std::unique_ptr<T> 的特殊成员函数,以保持我们的不变性——再次强调,这是:一旦原始指针被 unique_ptr 对象获取,只要 unique_ptr 对象具有相同的值,它就会保持有效,当不再是这样时——当 unique_ptr 调整以指向其他地方或被销毁时——原始指针将被正确释放。以下是特殊成员函数:

      unique_ptr(unique_ptr&& rhs) noexcept {
        this->reset(rhs.release());
      }

      unique_ptr& operator=(unique_ptr&& rhs) noexcept {
        reset(rhs.release());
        return *this;
      }

      ~unique_ptr() {
        reset();
      }
    };

在内存中,我们的 std::unique_ptr<T> 将看起来像这样:

我们还需要一个额外的辅助函数,以确保我们永远不用手动接触原始指针:

    template<typename T, typename... Args>
    unique_ptr<T> make_unique(Args&&... args)
    {
      return unique_ptr<T>(new T(std::forward<Args>(args)...));
    }

在我们的工具箱中有 unique_ptr 后,我们可以替换旧式代码,如下所示:

    struct Widget {
      virtual ~Widget();
    };
    struct WidgetImpl : Widget {
      WidgetImpl(int size);
    };
    struct WidgetHolder {
      void take_ownership_of(Widget *) noexcept;
    };
    void use(WidgetHolder&);

    void test() {
      Widget *w = new WidgetImpl(30);
      WidgetHolder *wh;
      try {
        wh = new WidgetHolder();
      } catch (...) {
        delete w;
        throw;
      }
      wh->take_ownership_of(w);
      try {
        use(*wh);
      } catch (...) {
        delete wh;
        throw;
      }
      delete wh;
    }

它可以用现代 C++17 代码替换,如下所示:

    void test() {
      auto w = std::make_unique<WidgetImpl>(30);
      auto wh = std::make_unique<WidgetHolder>();
      wh->take_ownership_of(w.release());
      use(*wh);
    }

注意,unique_ptr<T>RAII 的另一个应用——在这种情况下,非常直接。尽管“有趣”的操作(底层原始指针的释放)仍然发生在销毁(unique_ptr)期间,但你想要充分利用 unique_ptr 的唯一方法是在你 分配 资源时,也 初始化 一个 unique_ptr 来管理它。上一节中展示的 std::make_unique<T>() 函数(以及 C++14 中引入标准库)是现代 C++ 中安全内存管理的关键。

虽然 可能 在不使用 make_unique 的情况下使用 unique_ptr,但你不应这样做:

    std::unique_ptr<Widget> bad(new WidgetImpl(30));
    bad.reset(new WidgetImpl(40));

    std::unique_ptr<Widget> good = std::make_unique<WidgetImpl>(30);
    good = std::make_unique<WidgetImpl>(40);

为什么 C++ 没有 finally 关键字

再次考虑上一节“现代”代码示例中的这段代码:

    try {
      use(*wh);
    } catch (...) {
      delete wh;
      throw;
    }
    delete wh;

在其他语言,如 Java 和 Python 中,这些语义可能更紧凑地使用 finally 关键字来表示:

    try {
      use(*wh);
    } finally {
      delete wh;
    }

C++没有finally关键字,也没有迹象表明它将进入这个语言。这仅仅是由于 C++与其他语言之间的哲学差异:C++的哲学是,如果你关心强制某些不变性——例如“指针必须在块的末尾释放,无论我们如何到达那里”——那么你不应该编写显式代码,因为这样总有可能写错,然后你会遇到错误。

如果你有一些不变性想要强制执行,那么强制执行它的正确地方是在类型系统中,使用构造函数、析构函数和其他特殊成员函数——RAII 的工具。然后,你可以确保任何可能的使用你的新类型都保留了其不变性——例如“当不再由该类型对象持有时,应释放底层指针”——当你编写业务逻辑时,你不需要编写任何显式的内容;代码看起来简单,而且总是——可证明地——具有正确的行为。

所以,如果你发现自己编写的代码看起来像前面的例子,或者如果你发现自己希望能直接写finally,那就停下来想想:“我应该为这个使用unique_ptr吗?”或者“我应该为这个编写一个 RAII 类类型吗?”

自定义删除回调

说到自定义 RAII 类型,你可能想知道是否可以使用std::unique_ptr与自定义的删除回调一起使用:例如,你可能会想将底层指针传递给free()而不是delete。是的,你可以!

std::unique_ptr<T,D>有一个第二个模板类型参数:一个删除回调类型。参数D默认为std::default_delete<T>,它只是调用operator delete,但你可以传递任何你想要的类型——通常是具有重载operator()的用户定义类类型:

    struct fcloser {
      void operator()(FILE *fp) const {
        fclose(fp);
      }

      static auto open(const char *name, const char *mode) {
        return std::unique_ptr<FILE, fcloser>(fopen(name, mode));
      }
    };

    void use(FILE *);

    void test() {
      auto f = fcloser::open("test.txt", "r");
      use(f.get());
      // f will be closed even if use() throws
    }

顺便提一下,注意std::unique_ptr的析构函数被精心编写,以确保它永远不会用空指针调用你的回调。这在前面的例子中是绝对关键的,因为fclose(NULL)是一个特殊情况,意味着“关闭当前进程中的所有打开的文件句柄”——这绝对不是你想要的!

注意到std::make_unique<T>()只接受一个模板类型参数;没有std::make_unique<T,D>()。但避免用手直接触摸原始指针的规则仍然是一个好规则;这就是为什么我们前面的例子将fopenunique_ptr构造封装在一个小的可重用辅助函数fcloser::open中,而不是将fopen的调用内联到test的作用域中。

你的自定义删除器的空间将在std::unique_ptr<T,D>对象本身的作用域中分配,这意味着如果D有任何成员数据,sizeof(unique_ptr<T,D>)可能大于sizeof(unique_ptr<T>)

图片

使用 std::unique_ptr<T[]>管理数组

另一个delete p不是释放原始指针适当方式的情况是,如果p是指向数组第一个元素的指针;在这种情况下,应该使用delete [] p。幸运的是,从 C++14 开始,存在std::unique_ptr<T[]>,在这种情况下它会做正确的事情(由于std::default_delete<T[]>也存在并且做正确的事情,即调用operator delete[])。

对于数组类型,确实存在std::make_unique的重载,但请注意——它为其参数赋予了不同的含义!std::make_unique<T[]>(n)本质上调用new T[n](),其中末尾的括号表示它将初始化所有元素;也就是说,它将为原始类型清零。在极少数情况下,如果你不希望这种行为,你必须自己调用new,并将返回值包裹在std::unique_ptr<T[]>中,最好使用我们在上一节示例中使用的辅助函数(在那里我们使用了fcloser::open)。

使用 std::shared_ptr进行引用计数

完全解决了内存泄漏的问题后,我们现在着手解决使用后释放(use-after-free)错误的问题。这里需要解决的基本问题是关于给定资源或内存块的所有权不明确——或者说更确切地说是共享所有权。这个内存块可能在不同时间被多个人查看,可能来自不同的数据结构或来自不同的线程,我们想要确保所有这些利益相关者都参与决定何时释放它。底层内存块的所有权应该是共享的

为了实现这一点,标准提供了std::shared_ptr<T>。它的接口看起来与std::unique_ptr<T>非常相似;所有差异都隐藏在底层,在特殊成员函数的实现中。

std::shared_ptr<T>提供了一种内存管理方法,通常被称为引用计数。每个由shared_ptr管理的对象都会记录系统中对其的引用数量——也就是说,有多少利益相关者现在关心它——一旦这个数字降到零,对象就知道是时候清理自己了。当然,实际上并不是“对象”在清理自己;知道如何计数引用和清理事物的实体是一个小的包装器,或称为“控制块”,每次你将对象的所有权转移到shared_ptr时,都会在堆上创建它。控制块由库无形地处理,但如果我们查看其在内存中的布局,它可能看起来像这样:

图片

正如 unique_ptrmake_unique 一样,标准库为 shared_ptr 提供了 make_shared,这样你永远不需要用手去触碰原始指针。使用 std::make_shared<T>(args) 分配共享对象的另一个优点是,将所有权转移到 shared_ptr 需要为控制块分配额外的内存。当你调用 make_shared 时,库被允许分配一个足够大的内存块,足以容纳控制块和你的 T 对象,在一个分配中完成。(这在前面的图中通过 control_block_implSuper 的矩形物理位置得到了说明。)

复制 shared_ptr 会增加其相关控制块的使用计数;销毁 shared_ptr 会减少其使用计数。将 shared_ptr 的值赋给另一个值会减少旧值的使用计数(如果有的话),并增加新值的使用计数。以下是一些玩转 shared_ptr 的例子:

    std::shared_ptr<X> pa, pb, pc;

    pa = std::make_shared<X>();
    // use-count always starts at 1

    pb = pa;
    // make a copy of the pointer; use-count is now 2

    pc = std::move(pa);
    assert(pa == nullptr);
    // moving the pointer keeps the use-count at 2

    pb = nullptr;
    // decrement the use-count back to 1
    assert(pc.use_count() == 1);

下面的图示说明了 shared_ptr 的一个有趣且偶尔有用的特性:两个 shared_ptr 实例可以引用同一个控制块,但指向由该控制块管理的不同内存块:

图片

在前面的图中使用的构造函数,它也用于 get_second() 函数中,通常被称为 shared_ptr 的“别名构造函数”。它接受任何类型的现有非空 shared_ptr 对象,其控制块将由新构造的对象共享。在下面的代码示例中,直到“访问 Super::second”的消息之后,才会打印出“销毁 Super”的消息:

    struct Super {
      int first, second;
      Super(int a, int b) : first(a), second(b) {}
      ~Super() { puts("destroying Super"); }
    };

    auto get_second() {
      auto p = std::make_shared<Super>(4, 2);
      return std::shared_ptr<int>(p, &p->second);
    }

    void test() {
      std::shared_ptr<int> q = get_second();
      puts("accessing Super::second");
      assert(*q == 2);
    }

正如你所见,一旦所有权被转移到 shared_ptr 系统中,记住如何释放管理资源的责任就完全落在控制块上。你的代码不需要处理 shared_ptr<T>,仅仅因为底层管理对象恰好是类型 T

不要双重管理!

虽然 shared_ptr<T> 有潜力从你的指针代码中消除讨厌的双重释放错误,但遗憾的是,对于缺乏经验的程序员来说,仅仅通过过度使用接受原始指针参数的构造函数,就使用 shared_ptr 编写相同的错误是非常常见的。以下是一个例子:

    std::shared_ptr<X> pa, pb, pc;

    pa = std::make_shared<X>();
      // use-count always starts at 1

    pb = pa;
      // make a copy of the pointer; use-count is now 2

    pc = std::shared_ptr<X>(pb.get()); // WRONG!
      // give the same pointer to shared_ptr again,
      // which tells shared_ptr to manage it -- twice!
    assert(pb.use_count() == 2);
    assert(pc.use_count() == 1);

    pc = nullptr;
      // pc's use-count drops to zero and shared_ptr
      // calls "delete" on the X object

    *pb; // accessing the freed object yields undefined behavior

记住,你的目标应该是永远不要用手去触碰原始指针!这段代码出错的地方就是它第一次调用 pb.get()shared_ptr 中获取原始指针的时候。

在这里调用别名构造函数是正确的,pc = std::shared_ptr<X>(pb, pb.get()),但这会产生与简单赋值pc = pb相同的效果。因此,我们可以提出另一条通用规则:如果你在代码中必须显式使用shared_ptr这个词,那么你正在做一些非同寻常的事情——也许还有危险。在代码中命名shared_ptr,你仍然可以分配和管理堆对象(使用std::make_shared),并通过创建和销毁指针副本来操作托管对象的使用计数(使用auto声明变量,按需使用)。这条规则肯定不适用的情况是,当你有时需要声明一个类型为shared_ptr<T>的类数据成员时;你通常不能不写出类型的名称来做到这一点!

使用weak_ptr持有可空句柄

你可能已经注意到了之前的图中,控制块中标记为“弱计数”的未解释数据成员。现在是时候解释它是什么了。

有时——这种情况很少见,但有时——我们使用shared_ptr来管理共享对象的所有权,并且我们希望保留一个对象的指针,而不实际上表达对该对象的所有权。当然,我们可以使用原始指针、引用或observer_ptr<T>来表示“非拥有引用”,但危险在于,实际拥有引用对象的拥有者可能会决定释放它,然后当我们尝试取消引用我们的非拥有指针时,我们会访问一个已释放的对象并得到未定义的行为。以下代码示例中的DangerousWatcher说明了这种危险行为:

    struct DangerousWatcher {
      int *m_ptr = nullptr;

      void watch(const std::shared_ptr<int>& p) {
        m_ptr = p.get();
      }
      int current_value() const {
        // By now, *m_ptr might have been deallocated!
        return *m_ptr;
      }
    };

我们也可以使用shared_ptr来表示“引用”的概念,但当然这会给我们一个拥有引用,使我们更像是一个Participant而不是一个Watcher

    struct NotReallyAWatcher {
      std::shared_ptr<int> m_ptr;

      void watch(const std::shared_ptr<int>& p) {
        m_ptr = p;
      }
      int current_value() const {
        // Now *m_ptr cannot ever be deallocated; our
        // mere existence is keeping *m_ptr alive!
        return *m_ptr;
      }
    };

我们真正想要的是一个非拥有引用,但它仍然对管理内存的shared_ptr系统有所了解,并且能够查询控制块以确定引用的对象是否仍然存在。但当我们发现对象存在并尝试访问它时,它可能已经被其他线程释放了!因此,我们需要的基本操作是“如果存在,原子地获取引用对象的拥有引用(shared_ptr),否则指示失败。”也就是说,我们不想一个非拥有引用;我们想要的是一个可以在将来某个日期兑换为拥有引用的票据

标准库在名称std::weak_ptr<T>下提供了这个“shared_ptr的票据”。(它被称为“弱”以区别于shared_ptr的“强”拥有引用。)以下是如何使用weak_ptr解决我们的Watcher问题的示例:

    struct CorrectWatcher {
      std::weak_ptr<int> m_ptr;

      void watch(const std::shared_ptr<int>& p) {
        m_ptr = std::weak_ptr<int>(p);
      }
      int current_value() const {
        // Now we can safely ask whether *m_ptr has been
        // deallocated or not.
        if (auto p = m_ptr.lock()) {
            return *p;
        } else {
          throw "It has no value; it's been deallocated!";
        }
      }
    };

你需要知道的关于 weak_ptr 的唯一两个操作是,你可以从 shared_ptr<T> 构造一个 weak_ptr<T>(通过调用构造函数,如 watch() 函数中所示),并且你可以通过调用 wptr.lock() 尝试从 weak_ptr<T> 构造一个 shared_ptr<T>。如果 weak_ptr 已过期,你会得到一个空的 shared_ptr

此外,还有一个成员函数 wptr.expired(),它可以告诉你相关的 weak_ptr 是否已经过期;但请注意,它基本上是无用的,因为即使它现在返回 false,它也可能在几微秒后返回 true

下面的图表通过从 q 创建 weak_ptr 并然后将其 shared_ptr 置为空来继续前一个图表开始的故事:

复制一个 weak_ptr 会增加与引用对象的控制块关联的弱引用计数,而销毁一个 weak_ptr 会减少弱引用计数。当使用计数达到零时,系统知道可以安全地重新分配受控对象;但控制块本身将不会重新分配,直到弱引用计数达到零,这时我们知道没有更多的 weak_ptr 对象指向这个控制块:

你可能已经注意到 shared_ptr 在其 Deleter 上使用了与我们在第五章[第 26I9K0-2fdac365b8984feebddfbb9250eaf20d]节中提到的 std::anystd::function 上下文中的相同技巧——它使用了 类型擦除。而且,就像 std::anystd::function 一样,std::shared_ptr 提供了一个“去钓鱼”函数——std::get_deleter<Deleter>(p)——来检索原始的删除对象。这个信息在你的工作中将完全无用;我提到它只是为了强调类型擦除在现代 C++ 中的重要性。甚至 shared_ptr,其表面目的与擦除类型无关,也在其功能的一个小角落中依赖于类型擦除。

使用 std::enable_shared_from_this 来谈论自己

我们还应该讨论 shared_ptr 生态系统中的最后一个部分。我们已经提到了通过创建多个控制块来“双重管理”指针的危险。因此,我们可能需要一个方法来询问,给定一个堆分配对象的指针,现在究竟是谁在管理它。

这个特性的最常见用例是在面向对象编程中,其中方法 A::foo() 想要调用某个外部函数 bar(),而 bar() 需要一个指向 A 对象的指针。如果我们不担心生命周期管理,这会很简单;A::foo() 会简单地调用 bar(this)。但假设我们的 A 正在被 shared_ptr 管理,假设 bar() 很可能将 this 指针的副本存储在其内部——也许我们正在注册一个稍后调用的回调,或者也许我们正在创建一个将在 A::foo() 完成并返回调用者时并发运行的线程。因此,我们需要某种方法在 bar() 运行期间保持 A 的存活。

显然,bar() 应该接受一个类型为 std::shared_ptr<A> 的参数;这将保持我们的 A 对象存活。但在 A::foo() 中,我们从哪里获得那个 shared_ptr 呢?我们可以给 A 一个类型为 std::shared_ptr<A> 的成员变量,但这样 A 就会保持自己的存活——它永远不会死亡!这显然不是我们想要的!

一个初步的解决方案是,A 应该保留一个指向自己的类型为 std::weak_ptr<A> 的成员变量,并在调用 bar 时使用 bar(this->m_wptr.lock())。这确实有一些语法开销,而且不清楚指针 m_wptr 应该如何初始化。因此,C++ 将这个想法直接构建到了标准库中!

    template<class T>
    class enable_shared_from_this {
      weak_ptr<T> m_weak;
    public:
      enable_shared_from_this(const enable_shared_from_this&) {}
      enable_shared_from_this& operator=(const enable_shared_from_this&) {}
      shared_ptr<T> shared_from_this() const {
        return shared_ptr<T>(m_weak);
      }
    };

std::enable_shared_from_this<A> 类持有我们的类型为 std::weak_ptr<A> 的成员变量,并以 x.shared_from_this() 的名称公开“获取指向自己的 shared_ptr”操作。在前面的代码中,有几个有趣的细节需要注意:首先,如果你尝试在一个当前没有被 shared_ptr 系统管理的对象上调用 x.shared_from_this(),你会得到一个类型为 std::bad_weak_ptr 的异常。其次,注意空的拷贝构造函数和拷贝赋值运算符。在这种情况下,空的大括号不是=default 相同!如果我们使用 =default 来使拷贝操作默认化,它们将执行成员-wise 拷贝。每次你复制一个受管理的对象时,新对象都会收到原始对象的 m_weak 的一个副本;这在这里根本不是我们想要的。C++ 对象的 enable_shared_from_this 部分的“身份”与其内存位置相关联,因此它(并且不应该)遵循我们通常努力追求的拷贝和值语义规则。

最后一个问题是要回答:成员 m_weak(记住它是一个 私有 成员;我们使用 m_weak 这个名字纯粹是为了说明)最初是如何初始化的?答案是 shared_ptr 的构造函数包含一些代码来检测 T 是否公开继承自 enable_shared_from_this<T>,如果是的话,将通过一些隐藏的后门设置其 m_weak 成员。请注意,继承必须是 公开明确的,因为就 C++ 的规则而言,shared_ptr 的构造函数只是一个用户定义的函数;它不能打开你的类来找到其私有基类,或者在不同副本之间进行歧义消除。

这些限制意味着你应该只公开继承自 enable_shared_from_this;一旦一个类继承自 enable_shared_from_this,你应该只公开继承自 ;为了保持简单,你可能只应该在继承层次结构的叶子节点处继承 enable_shared_from_this。当然,如果你一开始就没有构建深层继承层次结构,遵循这些规则将会相对容易!

让我们把关于 enable_shared_from_this 的所有知识都集中在一个示例中:

    struct Widget : std::enable_shared_from_this<Widget> {
      template<class F>
      void call_on_me(const F& f) {
        f(this->shared_from_this());
      }
    };

    void test() {
      auto sa = std::make_shared<Widget>();

      assert(sa.use_count() == 1);
      sa->call_on_me([](auto sb) {
        assert(sb.use_count() == 2);
      });

      Widget w;
      try {
        w.call_on_me([](auto) {});
      } catch (const std::bad_weak_ptr&) {
        puts("Caught!");
      }
    }

Curiously Recurring Template Pattern

你可能已经注意到了,但尤其是在看到前面的代码示例之后,应该很明显,每次你继承自 enable_shared_from_this 时,你的类 的名称总是出现在其基类模板参数列表中!这种“X 继承自 A<X>”的模式被称为 Curiously Recurring Template Pattern,或简称 CRTP。当基类的一些方面依赖于其派生类时,这种情况很常见。例如,在我们的情况下,派生类的名称被纳入 shared_from_this 方法的返回类型中。

CRTP 另一个常见的应用场景是,当派生类的一些 行为 被纳入基类提供的行为中时。例如,使用 CRTP,我们可以编写一个基类模板,为任何实现 operator+= 和复制构造的派生类提供一个返回值的 operator+。注意所需的 static_castaddable<Derived>Derived,这样我们调用的是 Derived 的复制构造函数,而不是基类 addable<Derived> 的复制构造函数:

    template<class Derived>
    class addable {
    public:
      auto operator+(const Derived& rhs) const {
        Derived lhs = static_cast<const Derived&>(*this);
        lhs += rhs;
        return lhs;
      }
    };

事实上,这几乎就是 Boost 运算符库中 boost::addable 提供的服务;只不过 boost::addable 使用所谓的“Barton-Nackman 技巧”使其 operator+ 成为一个非成员的友元函数,而不是成员函数:

    template<class Derived>
    class addable {
    public:
      friend auto operator+(Derived lhs, const Derived& rhs) {
        lhs += rhs;
        return lhs;
      }
    };

即使你从未在你的代码库中使用 enable_shared_from_this,你也应该了解 Curiously Recurring Template Pattern,并且能够在需要将派生类行为“注入”到基类方法时从你的工具箱中取出它。

最后的警告

shared_ptrweak_ptrenable_shared_from_this 的迷你生态系统是现代 C++ 中最酷的部分之一;它可以为你的代码提供垃圾回收语言的安全性,同时保留 C++ 始终具有的速度和确定性销毁特性。然而,请注意不要滥用 shared_ptr!大多数 C++ 代码根本不应该使用 shared_ptr,因为你不应该共享堆分配对象的拥有权。你的首选应该始终是避免堆分配(通过使用值语义);其次,你应该确保每个堆分配的对象都有一个唯一的所有者(通过使用 std::unique_ptr<T>);只有在两者都真的不可能的情况下,才考虑使用共享拥有权和 std::shared_ptr<T>

使用 observer_ptr<T> 表示非特殊化

我们现在已经看到了两种或三种不同的智能指针类型(这取决于你是否将 weak_ptr 作为独立的指针类型,或者更像是 shared_ptr 的入场券)。这些类型中的每一个都携带一些关于生命周期管理的有用源级信息。例如,仅从这两个 C++ 函数的函数签名中,我们能说些什么关于它们的语义?

    void remusnoc(std::unique_ptr<Widget> p);

    std::unique_ptr<Widget> recudorp();

我们看到 remusnoc 通过值接收一个 unique_ptr,这意味着控制对象的拥有权被转移到了 remusnoc。当我们调用这个函数时,我们必须拥有一个 Widget 的唯一拥有权,并且在调用这个函数之后,我们将无法再访问那个 Widget。我们不知道 remusnoc 是否会销毁 Widget、保留它,或者将其附加到其他对象或线程上;但它明确不再是我们的关注点。remusnoc 函数是 widgets 的消费者。

更微妙的是,我们还可以说,当我们调用 remusnoc 时,我们必须拥有一个使用 new 分配的 Widget 的唯一拥有权,并且可以安全地 delete 它!

反之:当我调用 recudorp 时,我知道我们接收到的任何 Widget 都将唯一归我所有。它不是指向其他人 Widget 的引用;它也不是指向某些静态数据的指针。它明确是一个由我独自拥有的堆分配的 Widget。即使我处理返回值的第一件事是在它上面调用 .release() 并将原始指针放入某个“前现代”结构中,我也可以确信这样做是安全的,因为我肯定是返回值的唯一所有者。

我们能说些什么关于这个 C++ 函数的语义?

    void suougibma(Widget *p);

这是不明确的。也许这个函数将接管传递的指针的所有权;也许它不会。我们可以从suougibma的文档中(我们希望如此)或从我们代码库中的某些风格约定(例如,“一个原始指针永远不会表示所有权”,这是一个合理的约定)中得知,但我们不能仅从签名中得知。另一种表达这种区别的方法是说,unique_ptr<T>是表示所有权转移的词汇类型,而T*根本不是任何事物的词汇类型;它是 C++中无意义词汇或罗夏墨迹的等价物,因为在任何两个人之间,对它的含义都不一定有共识。

因此,如果你在代码库中发现自己传递了大量的非拥有指针,你可能需要一个词汇类型来表示非拥有指针的概念。(你的第一步应该是尽可能传递引用而不是指针,但假设你已经用尽了这条路。)这样的词汇类型确实存在,尽管它目前还没有包含在 C++标准库中:由于沃尔特·布朗,它被称为“世界上最愚蠢的智能指针”,它仅仅是一个围绕原始非拥有指针的类形状包装器:

    template<typename T>
    class observer_ptr {
      T *m_ptr = nullptr;
      public:
      constexpr observer_ptr() noexcept = default;
      constexpr observer_ptr(T *p) noexcept : m_ptr(p) {}

      T *get() const noexcept { return m_ptr; }
      operator bool() const noexcept { return bool(get()); }
      T& operator*() const noexcept { return *get(); }
      T* operator->() const noexcept { return get(); }
    };

    void revresbo(observer_ptr<Widget> p);

在我们的工具箱中有observer_ptr后,变得非常清楚,revresbo仅仅观察其参数;它绝对没有接管它的所有权。实际上,我们可以假设它甚至没有保留传入指针的副本,因为该指针的有效性将取决于受控对象的生命周期,而revresbo明确表示它对该对象的生命周期没有任何利益。如果它想要对受控对象的生命周期有利益,它将通过从其调用者那里请求unique_ptrshared_ptr来明确请求那个利益。通过请求observer_ptrrevresbo“退出”了整个所有权辩论。

正如我说的,observer_ptr不是 C++17 标准的一部分。阻止它进入标准的主要反对意见之一是其糟糕的名称(因为它与“观察者模式”没有任何关系)。还有许多知识渊博的人会说,T*应该是“非拥有指针”的词汇类型,并且所有使用T*进行所有权转移的旧代码都应该重写或至少用诸如owner<T*>之类的结构重新注释。这是目前 C++核心指南编辑,包括 C++发明者 Bjarne Stroustrup 推荐的方法。尽管如此,有一点是确定的:永远不要使用原始指针进行所有权转移!

摘要

在本章中,我们了解了一些关于智能指针的知识。

std::unique_ptr<T>是表示所有权的词汇类型,也是表示所有权转移的词汇类型;优先考虑它而不是原始的T*。考虑在所有权明确不是被转移的情况下,或者原始T*可能对读者来说模糊不清的情况下使用observer_ptr

std::shared_ptr<T> 是处理共享所有权的优秀(且标准)工具,其中许多不同的实体都是单个受控对象生命周期的利益相关者。std::weak_ptr<T> 是一个“shared_ptr的入场券”;它提供.lock()而不是operator*。如果你的类需要获取自身shared_ptr的能力,则从std::enable_shared_from_this<T>继承。请记住公开继承,并且一般来说,仅在继承图的最底层进行继承。并且不要在绝对不要求共享所有权的情况下过度使用这些功能!

千万不要手动触摸原始指针:使用make_uniquemake_shared来创建堆分配的对象,并一次性管理它们。并且每当你需要将派生类行为“注入”到由你的基类提供的函数中时,记得使用“奇特重复的模板模式”。

在下一章中,我们将讨论另一种类型的“共享”:在多线程编程中出现的共享。

第七章:并发

在上一章中,我们讨论了std::shared_ptr<T>如何实现引用计数内存管理,以便对象的生存期可以由可能彼此不了解的利益相关者共同控制——例如,利益相关者可能生活在不同的线程中。在 C++11 之前,这会立即成为一个障碍:如果一个利益相关者在减少引用计数的同时,另一个线程中的利益相关者正在减少引用计数的进程中,那么我们不是有数据竞争和因此是未定义的行为吗?

在 C++11 之前,答案通常是“是的。”(事实上,C++11 之前的 C++没有“线程”的标准概念,所以另一个合理的答案可能是这个问题本身就不相关。)然而,自 2011 年以来,C++有一个标准内存模型,它考虑了诸如“线程”和“线程安全”等概念,因此这个问题是有意义的,答案是明确地“不!”对std::shared_ptr的引用计数的访问保证不会相互竞争;在本章中,我们将向您展示如何使用标准库提供的工具实现类似的线程安全构造。

在本章中,我们将涵盖以下主题:

  • volatile Tstd::atomic<T>之间的区别

  • std::mutexstd::lock_guard<M>std::unique_lock<M>

  • std::recursive_mutexstd::shared_mutex

  • std::condition_variablestd::condition_variable_any

  • std::promise<T>std::future<T>

  • std::threadstd::async

  • std::async的危险以及如何构建线程池来替代它

不稳定的问题

如果你过去十年一直生活在山洞里——或者如果你来自旧式的 C 语言——你可能会问:“volatile关键字有什么问题?当我想要确保某些访问真正触达内存时,我会确保它是volatile的。”

volatile的官方语义是,volatile 访问将严格根据抽象机的规则进行评估,这意味着,或多或少,编译器不允许重新排序它们或将多个访问组合成一个。例如,编译器不能假设在这两次加载之间x的值保持不变;它必须生成执行两个加载的机器代码,一个在存储到y之前,一个在之后:

    volatile int& x = memory_mapped_register_x();
    volatile bool& y = memory_mapped_register_y();
    int stack;

    stack = x; // load
    y = true; // store 
    stack += x; // load

如果x不是volatile,那么编译器完全有权像这样重新排序代码:

    stack = 2*x; // load
    y = true; // store

如果x不是volatile,编译器可以这样做(因为写入bool变量y不可能影响int变量x的值)。然而,由于xvolatile,这种重新排序优化是不允许的。

x is a view onto some hardware buffer, and the store to memory location y is the signal for the hardware to load the next four bytes of data into the x register. It might help to view the situation as an operator overloading, but in hardware. And if "operator overloading, but in hardware" sounds crazy to you, then you probably have zero reason to use volatile in your programs!

那就是volatile的作用。但为什么我们不能使用volatile来使我们的程序线程安全呢?本质上,volatile的问题在于它太老了。自从我们从 C 语言分离出来,C++就包含了这个关键字,而它在 1989 年的原始标准中就已经存在了。当时,对多线程的关注非常少,编译器也更简单,这意味着一些可能存在问题的优化还没有被想到。到了 1990 年代末和 2000 年代初,当 C++缺乏线程感知的内存模型开始成为一个真正的问题时,已经太晚了,无法让volatile完成线程安全内存访问所需的全部工作,因为每个供应商都已经实现了volatile并详细记录了它所做的一切。在那个时刻改变规则将会破坏很多人的代码——而且会被破坏的代码将是低级硬件接口代码,这种代码你真的不希望出现错误。

这里有一些我们需要确保的保证类型,以便获得线程安全的内存访问:

    // Global variables:
    int64_t x = 0;
    bool y = false;

    void thread_A() {
      x = 0x42'00000042;
      y = true;
    }

    void thread_B() {
      if (x) {
        assert(x == 0x42'00000042);
      }
    }

    void thread_C() {
      if (y) {
        assert(x == 0x42'00000042);
      }
    }

假设thread_Athread_Bthread_C都在不同的线程中并发运行。这段代码可能会出错吗?嗯,thread_B正在检查x始终保持正好是零或0x42'00000042。然而,在 32 位计算机上,可能无法做出这样的保证;编译器可能不得不将thread_A中的赋值实现为两个赋值:“将x的高半部分设置为 42;将x的低半部分设置为 42。”如果thread_B中的测试恰好(错误地)在正确的时间运行,它最终可能会看到x0x42'00000000。将x声明为volatile并不能解决这个问题;事实上,什么都不能,因为我们的 32 位硬件根本不支持这个操作!如果编译器能够检测到我们正在尝试进行原子 64 位赋值,并且如果它知道我们的目标是不可实现的,就会给出编译时错误。换句话说,volatile访问并不保证是原子的。在实践中,它们通常是原子的——非volatile访问也是如此,但它们并不保证是原子的,有时你必须下降到机器代码级别才能确定你是否得到了预期的代码。我们希望有一种方法可以保证访问将是原子的(或者如果这是不可能的,我们希望编译器报错)。

现在考虑thread_C。它在检查,如果y的值是可见的为真,那么x的值必须已经设置为它的最终值。换句话说,它正在检查对x的写入“发生在”对y的写入之前。从thread_A的角度来看,这绝对是正确的,至少如果xy都是易失性的,因为我们已经看到编译器不允许重新排序易失性访问。然而,从thread_C的角度来看,这并不一定正确!如果thread_C在不同的物理 CPU 上运行,有自己的数据缓存,那么它可能会在不同的时间意识到xy的更新值,这取决于它何时刷新各自的缓存行。我们希望有一种方式来说明,当编译器从y加载时,它必须确保其整个缓存是最新的--它永远不会读取一个“过时”的x值。然而,在某些处理器架构上,这需要特殊的指令或额外的内存屏障逻辑。编译器不会为“旧式”的易失性访问生成这些指令,因为当volatile被发明时,线程并不是一个关注点;并且编译器不能被强制生成这些指令,因为这会不必要地减慢速度,甚至可能破坏所有使用旧式volatile进行旧式意义访问的现有底层代码。因此,我们面临的问题是,尽管从它们自己的线程的角度来看,易失性访问是按顺序发生的,但它们可能从另一个线程的角度来看以不同的顺序出现。换句话说,易失性访问不能保证是顺序一致的。我们希望有一种方式来保证访问将与其他访问顺序一致。

解决我们两个问题的方案在 2011 年被添加到 C++中。这个方案是std::atomic

使用std::atomic<T>进行线程安全的访问

在 C++11 及以后版本中,<atomic>头文件包含了类模板std::atomic<T>的定义。你可以从两种不同的方式来考虑std::atomic:你可以将其视为一个类模板,就像std::vector一样,它重载了操作符,这些操作符恰好实现了线程安全的操作;或者你可以将其视为一个神奇的内建类型家族,其名称恰好包含尖括号。后一种思考方式实际上非常有用,因为它正确地表明std::atomic部分是内建到编译器中的,因此编译器通常会为原子操作生成最优代码。后一种还表明了atomicvector的不同之处:在std::vector<T>中,T可以是几乎任何你想要的东西。在std::atomic<T>中,T可以是任何你想要的东西,但在实践中,使用不属于一小组原子友好类型的任何T都是不明智的。关于这个话题的更多内容,稍后讨论。

原子友好的类型是整数类型(至少,那些不超过机器寄存器大小的类型)和指针类型。一般来说,在常见的平台上,你会发现对这些类型的 std::atomic 对象的操作会正好满足你的需求:

    // Global variables:
    std::atomic<int64_t> x = 0;
    std::atomic<bool> y = false;

    void thread_A() {
      x = 0x42'00000042; // atomic!
      y = true; // atomic!
    }

    void thread_B() {
      if (x) {
        // The assignment to x happens atomically.
        assert(x == 0x42'00000042);
      }
    }

    void thread_C() {
      if (y) {
        // The assignment to x "happens before" the
        // assignment to y, even from another thread's
        // point of view.
        assert(x == 0x42'00000042);
      }
    }

std::atomic<T> 重载了赋值运算符以执行原子、线程安全的赋值;同样地,它的 ++--+=-= 运算符;对于整数类型,还包括 &=|=^= 运算符。

重要的是要记住 std::atomic<T> 类型(在概念上生活在内存“那里”)和类型 T 的短暂值(在概念上生活在“这里”,手头附近;例如,在 CPU 寄存器中)之间的区别。因此,例如,std::atomic<int> 没有复制赋值运算符:

    std::atomic<int> a, b;
    a = b; // DOES NOT COMPILE!

没有复制赋值运算符(也没有移动赋值运算符),因为这没有明确的含义:程序员的意思是计算机应该将 b 的值加载到一个寄存器中,然后将该寄存器的值存储到 a 中吗?这听起来像是两个不同的原子操作,而不是一个操作!或者程序员可能意味着计算机应该在单个原子操作中将 b 的值复制到 a 中;但这涉及到在单个原子操作中触及两个不同的内存位置,而这超出了大多数计算机硬件的能力。因此,C++ 要求你明确写出你的意图:从对象 b 到寄存器(在 C++ 中由非原子栈变量表示)的单个原子加载,然后是到对象 a 的单个原子存储:

    int shortlived = b; // atomic load
    a = shortlived; // atomic store

std::atomic<T> 提供了 .load().store(v) 成员函数,以方便那些喜欢在每一步都看到他们所做事情的程序员。使用它们是可选的:

    int shortlived = b.load(); // atomic load
    a.store(shortlived); // atomic store

事实上,通过使用这些成员函数,你可以将赋值操作写在一行代码中,例如 b.store(a.load());但我强烈建议你不要这样做。在一行代码中写上两个函数调用并不意味着它们会在时间上“更接近”,当然也不意味着它们会“原子性地”发生(正如我们刚才看到的,在大多数硬件上这是不可能的),但将两个函数调用写在一行代码中可能会让你误以为这些调用是“同时”发生的。

当你一次只做一件事时,处理线程代码已经足够困难了。如果你开始变得聪明,同时做几件事,在一行代码中,错误的可能性会急剧增加。坚持每行源代码一个原子操作;你会发现这会澄清你的思考过程,并且意外地使你的代码更容易阅读。

原子性地执行复杂操作

你可能已经注意到,操作符 *=, /=, %=, <<=, 和 >>= 在上一节中省略了重载操作符的列表。这些操作符被 std::atomic<int> 和所有其他整型原子类型删除,因为它们被认为在任何实际硬件上难以高效实现。然而,即使在 std::atomic<int> 中包含的操作中,大多数也需要一个稍微昂贵的实现技巧。

假设我们的硬件没有“原子乘法”指令,但我们仍然想实现 operator*=。我们该如何做?诀窍是使用一种原始的原子操作,称为“比较并交换”,或者在 C++ 中称为“比较交换”。

    std::atomic<int> a = 6;

    a *= 9; // This isn't allowed.

    // But this is:

    int expected, desired;
    do {
      expected = a.load();
      desired = expected * 9;
    } while (!a.compare_exchange_weak(expected, desired));

    // At the end of this loop, a's value will
    // have been "atomically" multiplied by 9.

a.compare_exchange_weak(expected, desired) 的含义是处理器应该查看 a;并且 如果 它的当前值是 expected,则将其值设置为 desired;否则不设置。如果 a 被设置为 desired,则函数调用返回 true,否则返回 false

但它还有另一个功能。注意,每次通过前面的循环时,我们都会将 a 的值加载到 expected 中;但比较交换函数也会加载 a 的值以便与 expected 进行比较。当我们第二次通过循环时,我们更希望不要再次加载 a;我们更希望直接将 expected 设置为比较交换函数看到的值。幸运的是,a.compare_exchange_weak(expected, desired) 预见了我们的这个愿望,并且如果它将返回 false,则会预先更新 expected 为它看到的值。也就是说,每次我们使用 compare_exchange_weak 时,我们必须提供一个可修改的 expected 值,因为函数是通过引用来获取它的。

因此,我们实际上应该这样编写我们的示例:

    int expected = a.load();
    while (!a.compare_exchange_weak(expected, expected * 9)) {
      // continue looping
    }

desired 变量实际上并不是必需的,除非它有助于澄清代码。

std::atomic 的一个不为人知的秘密是,大多数的复合赋值操作实际上都是通过类似于这样的比较交换循环来实现的。在 RISC 处理器上,这几乎是始终如此。在 x86 处理器上,这种情况只在你想要使用操作符的返回值时才会发生,例如 x = (a += b)

当原子变量 a 不是被其他线程频繁修改时,进行比较交换循环是没有害处的。但当 a 被频繁修改——当它高度 竞争 时——我们可能会看到循环多次尝试才能成功。在绝对病态的情况下,我们甚至可能会看到循环线程的饥饿;它可能只是无限循环,直到竞争减弱。然而,请注意,每次我们的比较交换返回 false 并再次循环时,都是因为内存中 a 的值已经改变;这意味着其他线程必须已经取得了一点点进展。比较交换循环本身永远不会导致程序进入一个没有人取得进展的状态(技术上称为“活锁”)。

前一段文字可能听起来比它应该的更可怕。通常没有必要担心这种病态行为,因为它只在高度竞争的情况下才会出现,即使那样也不会真正造成任何严重问题。你应该从这个部分学到的真正要点是如何使用比较交换循环在 atomic<T> 对象上实现复杂的、非内置的“原子”操作。只需记住 a.compare_exchange_weak(expected, desired) 参数的顺序,通过记住它对 a 做了什么:“如果 a 有预期的值,就给它想要的值。”

大原子操作

编译器会识别并生成针对 std::atomic<T> 的最佳代码,当 T 是整数类型(包括 bool)或 T 是指针类型,如 void * 时。但如果 T 是更大的类型,例如 int[100] 呢?在这种情况下,编译器通常会调用 C++ 运行时库中的一个例程,该例程将在一个 互斥锁 下执行赋值操作。(我们稍后会讨论互斥锁。)由于赋值操作是在一个不知道如何复制任意用户定义类型的库中执行的,C++17 标准将 std::atomic<T> 限制为只能与那些可以 简单复制 的类型一起工作,也就是说,它们可以使用 memcpy 安全地复制。因此,如果你想要 std::atomic<std::string>,那很遗憾——你必须自己编写它。

使用大(简单可复制)类型与 std::atomic 时的另一个问题是,相关的 C++ 运行时例程通常位于 C++ 标准库的其他地方。在某些平台上,你可能需要在链接器命令行中添加 -latomic。但这只在你实际上使用大类型与 std::atomic 一起使用时才是问题。因为你实际上不应该这样做,所以通常没有必要担心。

现在我们来看看如何编写那个原子字符串类!

与 std::mutex 交替使用

假设我们想要编写一个类类型,其行为基本上类似于如果存在std::atomic<std::string>会有的行为。也就是说,我们希望它支持原子、线程安全的加载和存储,这样如果两个线程正在并发访问std::string,则任何一个都不会观察到它处于“半分配”状态,就像我们在上一节“volatile的问题”中的代码示例中观察到的“半分配”int64_t一样。

编写此类最佳方式是使用一个名为 std::mutex 的标准库类型。在技术领域,“mutex”这个名字非常常见,以至于如今它基本上就是其自身的代名词,但最初它的名字来源于“mutual exclusion”(互斥)。这是因为互斥锁充当了一种确保一次只允许一个线程进入特定代码段(或一组代码段)的方式——也就是说,确保“线程 A 正在执行此代码”和“线程 B 正在执行此代码”是互斥的可能性。

在这样一个关键段的开始,为了表明我们不希望被任何其他线程打扰,我们会在相关的互斥锁上加锁。当我们离开关键段时,我们释放锁。库会负责确保没有两个线程可以在同一时间对同一个互斥锁持有锁。具体来说,这意味着如果线程 B 在线程 A 已经持有锁的情况下进入,线程 B 必须等待直到线程 A 离开关键段并释放锁。只要线程 A 持有锁,线程 B 的进度就会被阻塞;因此,这种现象被称为等待阻塞,可以互换使用。

“在互斥锁上加锁”通常简称为“锁定互斥锁”,“释放锁”简称为“解锁互斥锁”。

有时(尽管很少见)测试一个互斥锁是否当前被锁定是有用的。为此目的,std::mutex不仅公开了成员函数.lock().unlock(),还公开了成员函数.try_lock(),如果它能够获取互斥锁的锁(在这种情况下,互斥锁将被锁定),则返回true,如果互斥锁已经被某个线程锁定,则返回false

在某些语言中,例如 Java,每个对象都携带自己的互斥锁;这就是 Java 实现其synchronized块的方式。在 C++中,互斥锁是其自己的对象类型;当你想要使用互斥锁来控制代码段时,你需要考虑互斥锁对象的生存期语义。你可以在哪里放置互斥锁,以便只有一个对希望使用它的每个人可见的互斥锁对象?有时,如果只有一个需要保护的关键段,你可以在函数作用域的静态变量中放置互斥锁:

    void log(const char *message)
    {
      static std::mutex m;
      m.lock(); // avoid interleaving messages on stdout
      puts(message);
      m.unlock();
    }

这里的 static 关键字非常重要!如果我们省略了它,那么 m 就会是一个普通的栈变量,每个进入 log 的线程都会收到 m 的一个独特副本。这不会帮助我们实现目标,因为库仅仅确保一次不会有两个线程锁定同一个互斥量对象。如果每个线程都在锁定和解锁它自己的独特互斥量对象,那么库就没有什么可做的;没有任何互斥量正在被竞争

如果我们想要确保两个不同的函数彼此互斥,即在任何给定时间只允许一个线程进入 log1log2,我们必须将互斥量对象放在两个关键部分都能看到的地方:

    static std::mutex m;

    void log1(const char *message) {
      m.lock();
      printf("LOG1: %s\n", message);
      m.unlock();
    }

    void log2(const char *message) {
      m.lock();
      printf("LOG2: %s\n", message);
      m.unlock();
    }

通常,如果你发现自己需要这样做,你应该尝试通过创建一个类类型并使互斥量对象成为该类的成员变量来消除全局变量,如下所示:

    struct Logger {
      std::mutex m_mtx;

      void log1(const char *message) {
        m_mtx.lock();
        printf("LOG1: %s\n", message);
        m_mtx.unlock();
      }

      void log2(const char *message) {
        m_mtx.lock();
        printf("LOG2: %s\n", message);
        m_mtx.unlock();
      }
    };

现在由一个 Logger 打印的消息可能会与另一个 Logger 打印的消息交织在一起,但同时对同一个 Logger 对象的并发访问将会锁定在同一个 m_mtx 上,这意味着它们将互相阻塞并很好地轮流进入关键函数 log1log2,一次一个。

“正确地获取锁”

回想一下 第六章,智能指针,C 和“旧式”C++ 编写的程序的一个主要问题是存在指针错误--内存泄漏、双重释放和堆损坏--以及我们从“新式”C++ 程序中消除这些错误的方法是通过使用 RAII 类型,如 std::unique_ptr<T>。使用原始互斥量的多线程编程具有与使用原始指针进行堆编程的故障模式类似:

  • 锁泄漏:你可能会锁定特定的互斥量,并意外忘记编写释放它的代码。

  • 锁泄漏:你可能已经编写了那段代码,但由于早期返回或抛出异常,代码从未运行,互斥量仍然被锁定!

  • 锁外使用:因为原始互斥量只是另一个变量,它与它“保护”的变量在物理上是分离的。你可能会在没有先获取锁的情况下意外访问这些变量之一。

  • 死锁:假设线程 A 锁定了互斥量 1,而线程 B 锁定了互斥量 2。然后,线程 A 尝试获取互斥量 2 的锁(并阻塞);而在线程 A 仍然阻塞的同时,线程 B 尝试获取互斥量 1 的锁(并阻塞)。现在两个线程都处于阻塞状态,并且将永远不会再次取得进展。

这并不是并发陷阱的详尽列表;例如,我们已经在与 std::atomic<T> 相关的上下文中简要提到了“活锁”。对于并发错误及其避免方法的彻底处理,请参阅关于多线程或并发编程的书籍。

C++ 标准库有一些工具可以帮助我们从多线程程序中消除这些错误。与内存管理的情况不同,在这种情况下,标准库的解决方案并不能保证 100%修复你的问题--多线程编程比单线程编程要复杂得多,实际上,如果你能避免的话,一个好的经验法则是不要做。但是,如果你必须进行并发编程,标准库可以在一定程度上帮助你。

正如 第六章 中所提到的 智能指针,我们可以通过谨慎使用 RAII 来消除与“锁泄漏”相关的错误。你可能已经注意到,我一直在一致地使用“在互斥锁上获取锁”这个短语,而不是“锁定互斥锁”;现在我们将看到为什么。在短语“锁定互斥锁”中,“锁定”是一个动词;这种说法与 C++ 代码 mtx.lock() 完全对应。但在短语“在互斥锁上获取锁”中,“锁定”是一个名词。让我们发明一个类型,将“锁定”的概念具体化;也就是说,将其变成一个名词(一个 RAII 类)而不是动词(一个非 RAII 类的方法):

    template<typename M>
    class unique_lock {
      M *m_mtx = nullptr;
      bool m_locked = false;
    public:
      constexpr unique_lock() noexcept = default;
      constexpr unique_lock(M *p) noexcept : m_mtx(p) {}

      M *mutex() const noexcept { return m_mtx; }
      bool owns_lock() const noexcept { return m_locked; }

      void lock() { m_mtx->lock(); m_locked = true; }
      void unlock() { m_mtx->unlock(); m_locked = false; }

      unique_lock(unique_lock&& rhs) noexcept {
        m_mtx = std::exchange(rhs.m_mtx, nullptr);
        m_locked = std::exchange(rhs.m_locked, false);
      }

      unique_lock& operator=(unique_lock&& rhs) {
        if (m_locked) {
            unlock();
        }
        m_mtx = std::exchange(rhs.m_mtx, nullptr);
        m_locked = std::exchange(rhs.m_locked, false);
        return *this;
      }

      ~unique_lock() {
        if (m_locked) {
            unlock();
        }
      }
    };

如其名所示,std::unique_lock<M> 是一个“唯一所有权”RAII 类,在精神上类似于 std::unique_ptr<T>。如果你坚持使用名词 unique_ptr 而不是动词 newdelete,你就永远不会忘记释放指针;同样,如果你坚持使用名词 unique_lock 而不是动词 lockunlock,你就永远不会忘记释放互斥锁。

std::unique_lock<M> 确实公开了成员函数 .lock().unlock(),但通常你不需要使用这些。如果需要在代码块中间获取或释放锁,远离 unique_lock 对象的自然销毁点,它们可能是有用的。我们将在下一节中看到一个接受已锁定 unique_lock 作为参数的函数,该函数在功能的一部分中解锁并重新锁定。

注意,因为 unique_lock 是可移动的,它必须有一个“null”或“empty”状态,就像 unique_ptr 一样。在大多数情况下,你不需要移动你的锁;你只需在某个作用域的开始无条件地获取锁,并在作用域的末尾无条件地释放它。对于这种用例,有 std::lock_guard<M>lock_guardunique_lock 很相似,但它不可移动,也没有 .lock().unlock() 成员函数。因此,它不需要携带 m_locked 成员,并且它的析构函数可以在没有任何额外测试的情况下无条件地解锁它所保护的互斥锁。

在这两种情况(unique_locklock_guard)中,类模板是根据被锁定的互斥锁的类型进行参数化的。(我们将在下一分钟查看更多种类的互斥锁,但几乎总是,你将想要使用std::mutex。)C++17 有一个新的语言特性叫做类模板参数推导,在大多数情况下,它允许你省略模板参数:例如,简单地写std::unique_lock而不是std::unique_lock<std::mutex>。这是我个人会推荐依赖类模板参数推导的极少数情况之一,因为写出参数类型std::mutex对读者来说真的增加了很少的信息。

让我们看看std::lock_guard的一些示例,包括带有和不带有类模板参数推导的情况:

    struct Lockbox {
      std::mutex m_mtx;
      int m_value = 0;

      void locked_increment() {
        std::lock_guard<std::mutex> lk(m_mtx);
        m_value += 1;
      }

      void locked_decrement() {
        std::lock_guard lk(m_mtx); // C++17 only
        m_value -= 1;
      }
    };

在我们能够看到std::unique_lock的类似实际示例之前,我们首先需要解释为什么最初要使用std::unique_lock

总是将与受控数据关联的互斥锁

考虑以下线程安全的StreamingAverage类的草图。这里有一个 bug;你能找到它吗?

    class StreamingAverage {
      double m_sum = 0;
      int m_count = 0;
      double m_last_average = 0;
      std::mutex m_mtx;
    public:
      // Called from the single producer thread
      void add_value(double x) {
        std::lock_guard lk(m_mtx);
        m_sum += x;
        m_count += 1; // A
      }

      // Called from the single consumer thread
      double get_current_average() {
        std::lock_guard lk(m_mtx);
        m_last_average = m_sum / m_count; // B
        return m_last_average;
      }

      // Called from the single consumer thread
      double get_last_average() const {
        return m_last_average; // C
      }

      // Called from the single consumer thread
      double get_current_count() const {
        return m_count; // D
      }
    };

bug 是行A,在生产者线程中写入this->m_count,与行D在消费者线程中读取this->m_count发生竞争。行A在写入之前正确地锁定this->m_mtx,但行D未能采取类似的锁定,这意味着它将愉快地闯入并尝试读取m_count,即使行A正在写入它。

BC表面上看起来很相似,这可能是 bug 最初悄悄进入的原因。行C不需要加锁;为什么行D必须加锁呢?好吧,行C只被消费者线程调用,而这个线程与在行B上写入m_last_average的线程是同一个。由于行BC只由单个消费者线程执行,它们不能同时执行--至少在程序的其他部分遵守注释的情况下是这样!(让我们假设代码注释是正确的。在实践中,这通常很遗憾地不正确,但为了这个例子,让我们假设它是正确的。)

我们在这里有一个混淆的配方:当接触m_summ_count时需要锁定m_mtx,但当接触m_last_average时则不需要。如果这个类变得更加复杂,它甚至可能涉及多个互斥锁(尽管在那个阶段,它显然违反了单一职责原则,并且可能从重构为更小的组件中受益)。因此,在处理互斥锁时,一个非常好的实践是将互斥锁放置在与它“保护”的变量最紧密的关系中。一种方法是通过仔细命名来实现这一点:

    class StreamingAverage {
      double m_sum = 0;
      int m_count = 0;
      double m_last_average = 0;
      std::mutex m_sum_count_mtx;

      // ...
    };

一个更好的方法是使用嵌套结构定义:

    class StreamingAverage {
      struct {
        double sum = 0;
        int count = 0;
        std::mutex mtx;
      } m_guarded_sc;
      double m_last_average = 0;

      // ...
    };

上述希望是,当程序员被迫编写this->m_guarded_sc.sum时,它会提醒他确保他已经获取了this->m_guarded_sc.mtx的锁。我们可以使用 GNU 的“匿名结构成员”扩展来避免在我们的代码中重复输入m_guarded_sc;但这样会违背这种方法的目的,即确保每个访问数据的地方都必须使用“guarded”这个词,提醒程序员在this->m_guarded_sc.mtx上获取那个锁。

一种更加牢不可破但相对不灵活的方法是将互斥锁放在一个类中,该类仅在互斥锁被锁定时允许访问其私有成员,通过返回一个 RAII 句柄。返回句柄的类看起来大致如下:

    template<class Data>
    class Guarded {
      std::mutex m_mtx;
      Data m_data;

      class Handle {
        std::unique_lock<std::mutex> m_lk;
        Data *m_ptr;
      public:
        Handle(std::unique_lock<std::mutex> lk, Data *p) :
          m_lk(std::move(lk)), m_ptr(p) {}
        auto operator->() const { return m_ptr; }
      };
    public:
      Handle lock() {
        std::unique_lock lk(m_mtx);
        return Handle{std::move(lk), &m_data};
      }
    };

我们的StreamingAverage类可以这样使用它:

    class StreamingAverage {
      struct Guts {
        double m_sum = 0;
        int m_count = 0;
      };
      Guarded<Guts> m_sc;
      double m_last_average = 0;

      // ...

      double get_current_average() {
        auto h = m_sc.lock();
        m_last_average = h->m_sum / h->m_count;
        return m_last_average;
      }
    };
impossible for any member function of StreamingAverage to access m_sum without owning a lock on m_mtx; access to the guarded m_sum is possible only via the RAII Handle type.

这种模式包含在 Facebook 的 Folly 库中,名为folly::Synchronized<T>,Ansel Sermersheim 和 Barbara Geller 的“libGuarded”模板库中还有更多基于它的变体。

注意到在Handle类中使用了std::unique_lock<std::mutex>!我们在这里使用unique_lock而不是lock_guard,因为我们希望有传递这个锁、从函数返回它等功能,因此它需要是可移动的。这就是你会在工具箱里找到unique_lock的主要原因。

请注意,这种模式并不能解决所有与锁相关的错误——它只解决了最简单的“忘记锁定互斥锁”的情况——并且可能会鼓励导致更多其他类型并发错误的编程模式。例如,考虑以下对StreamingAverage::get_current_average的重新编写:

    double get_sum() {
      return m_sc.lock()->m_sum; 
    }

    int get_count() {
      return m_sc.lock()->m_count;
    }

    double get_current_average() {
      return get_sum() / get_count();
    }

由于有两个m_sc.lock()调用,m_sum的读取和m_count的读取之间存在一个间隙。如果生产线程在这个间隙期间调用add_value,我们将计算出一个错误的平均值(比实际低一个1 / m_count的因子)。如果我们尝试通过在整个计算周围获取锁来“修复”这个错误,我们会发现自己陷入了死锁:

    double get_sum() {
      return m_sc.lock()->m_sum; // LOCK 2
    }

    int get_count() {
      return m_sc.lock()->m_count;
    }

    double get_current_average() {
      auto h = m_sc.lock(); // LOCK 1
      return get_sum() / get_count();
    }

标记为LOCK 1的行会导致互斥锁被锁定;然后,在标记为LOCK 2的行上,我们尝试再次锁定互斥锁。关于互斥锁的一般规则是,如果你试图锁定一个已经锁定的互斥锁,你必须阻塞并等待它解锁。所以我们的线程会阻塞并等待互斥锁解锁——但这是不可能发生的,因为锁是由我们自己的线程持有的!

这个问题(自死锁)通常应该通过仔细的编程来解决——也就是说,你应该尽量避免获取你已经持有的锁!但如果以这种方式获取锁不可避免地成为你设计的一部分,那么标准库会支持你,所以让我们来谈谈recursive_mutex

特殊用途的互斥锁类型

回想一下,std::lock_guard<M>std::unique_lock<M>是根据互斥锁类型参数化的。到目前为止,我们只看到了std::mutex。然而,标准库确实包含了一些其他互斥锁类型,在特殊情况下可能很有用。

std::recursive_mutex 类似于 std::mutex,但它会记住 哪个 线程已经锁定了它。如果该特定线程尝试再次锁定它,递归互斥锁将仅增加内部引用计数“我已被锁定的次数”。如果其他线程尝试锁定递归互斥锁,该线程将阻塞,直到原始线程已适当地解锁互斥锁。

std::timed_mutex 类似于 std::mutex,但它能够感知时间的流逝。它不仅具有常用的 .try_lock() 成员函数,还有 .try_lock_for().try_lock_until() 成员函数,这些函数与标准 <chrono> 库交互。下面是 try_lock_for 的一个示例:

    std::timed_mutex m;
    std::atomic<bool> ready = false;

    std::thread thread_b([&]() {
      std::lock_guard lk(m);
      puts("Thread B got the lock.");
      ready = true;
      std::this_thread::sleep_for(100ms);
    });

    while (!ready) {
      puts("Thread A is waiting for thread B to launch.");
      std::this_thread::sleep_for(10ms);
    }

    while (!m.try_lock_for(10ms)) {
      puts("Thread A spent 10ms trying to get the lock and failed.");
    }

    puts("Thread A finally got the lock!");
    m.unlock();

下面是 try_lock_until 的一个示例:

    std::timed_mutex m1, m2;
    std::atomic<bool> ready = false;

    std::thread thread_b([&]() {
      std::unique_lock lk1(m1);
      std::unique_lock lk2(m2);
      puts("Thread B got the locks.");
      ready = true;
      std::this_thread::sleep_for(50ms);
      lk1.unlock();
      std::this_thread::sleep_for(50ms);
    });

    while (!ready) {
      std::this_thread::sleep_for(10ms); 
    }

    auto start_time = std::chrono::system_clock::now();
    auto deadline = start_time + 100ms;

    bool got_m1 = m1.try_lock_until(deadline);
    auto elapsed_m1 = std::chrono::system_clock::now() - start_time;

    bool got_m2 = m2.try_lock_until(deadline);
    auto elapsed_m2 = std::chrono::system_clock::now() - start_time;

    if (got_m1) {
      printf("Thread A got the first lock after %dms.\n",
      count_ms(elapsed_m1));
      m1.unlock();
    }
    if (got_m2) {
      printf("Thread A got the second lock after %dms.\n",
      count_ms(elapsed_m2));
      m2.unlock();
    }  

顺便提一下,这里使用的 count_ms 函数只是一个提取了一些 <chrono> 常用模板代码的小型 lambda 表达式:

    auto count_ms = [](auto&& d) -> int {
      using namespace std::chrono;
      return duration_cast<milliseconds>(d).count();
    };

在上述两个示例中,请注意我们使用 std::atomic<bool> 来同步线程 AB 的方式。我们只需将原子变量初始化为 false,然后循环直到它变为 true。轮询循环的主体是调用 std::this_thread::sleep_for,这足以向编译器暗示原子变量的值可能会改变。务必注意永远不要编写不包含睡眠的轮询循环,因为在这种情况下,编译器有权将所有连续的 ready 加载合并为单个加载和一个(必然是无限期的)循环。

std::recursive_timed_mutex 就像是将 recursive_mutextimed_mutex 合并在一起;它提供了 recursive_mutex 的“计数”语义,加上 timed_mutextry_lock_fortry_lock_until 方法。

std::shared_mutex 的命名可能不太恰当。它实现的行为在大多数并发教科书中被称为 读写锁(也称为 rwlockreaders-writer lock)。读写锁或 shared_mutex 的定义特征是它可以以两种不同的方式“锁定”。你可以通过调用 sm.lock() 来获取一个普通的排他性(“写入”)锁,或者你可以通过调用 sm.lock_shared() 来获取一个非排他性(“读取”)锁。许多不同的线程可以同时获取读取锁;但如果 任何人 正在读取,那么 任何人 都不能写入;如果 任何人 正在写入,那么 任何人 都不能进行其他操作(既不能读取也不能写入)。这些恰好是定义 C++ 内存模型中“竞态条件”的基本规则:如果有两个线程同时从同一个对象读取,这是可以的,只要没有线程同时写入。std::shared_mutex 增加的是安全性:它确保如果有人 确实 尝试写入(至少如果他们表现得很好,首先在 std::shared_mutex 上获取写入锁),他们将阻塞,直到所有读取者都已退出并且安全写入。

std::unique_lock<std::shared_mutex> 是对应于 std::shared_mutex 上的独占(“写”)锁的名词。正如你所期望的,标准库也提供了 std::shared_lock<std::shared_mutex> 来具体化 std::shared_mutex 上的非独占(“读”)锁的概念。

升级读写锁

假设你有一个 shared_mutex 的读锁(也就是说,你有一个 std::shared_lock<std::shared_mutex> lk,使得 lk.owns_lock()),并且你想要获取写锁。你能“升级”你的锁吗?

不可以。考虑一下,如果线程 AB 都持有读锁,并且同时尝试升级到写锁而不先释放它们的读锁会发生什么。它们两个都无法获取写锁,因此它们会相互死锁。

有第三方库试图解决这个问题,例如 boost::thread::upgrade_lock,它与 boost::thread::shared_mutex 一起工作;但它们超出了本书的范围。标准的解决方案是,如果你持有读锁并想要写锁,你必须释放你的读锁,然后和其他人一样排队等待写锁:

    template<class M> 
    std::unique_lock<M> upgrade(std::shared_lock<M> lk)
    {
      lk.unlock();
      // Some other writer might sneak in here.
      return std::unique_lock<M>(*lk.mutex());
    }

降级读写锁

假设你有一个 shared_mutex 的独占写锁,并且你想要获取读锁。你能“降级”你的锁吗?

原则上答案是肯定的,应该可以降级写锁到读锁;但在标准 C++17 中,答案是不了,你不能直接这样做。与升级的情况一样,你可以使用 boost::thread::shared_mutex。标准的解决方案是,如果你持有写锁并想要读锁,你必须释放你的写锁,然后和其他人一样排队等待读锁:

    template<class M>
    std::shared_lock<M> downgrade(std::unique_lock<M> lk)
    {
      lk.unlock();
      // Some other writer might sneak in here.
      return std::shared_lock<M>(*lk.mutex()); 
    }

如这些示例所示,C++17 的 std::shared_mutex 目前有些半成品。如果你的架构设计需要读写锁,我强烈建议使用类似 boost::thread::shared_mutex 的东西,它“电池组”齐全。

你可能已经注意到,由于在持有读锁的同时可能会有新的读者加入,但不会有新的写者,因此一个潜在的写者线程可能会因为连续的潜在读者流而“饿死”,除非实现者特意提供强有力的“无饿死”保证。boost::thread::shared_mutex 提供了这样的保证(至少,它避免了饿死,如果底层操作系统的调度器这样做的话)。std::shared_mutex 的标准措辞没有提供这样的保证,尽管任何在实践中允许饿死的实现都会被认为是非常差的。实际上,你会发现你的标准库供应商对 shared_mutex 的实现非常接近 Boost 的实现,除了缺少升级/降级功能。

等待条件

在标题为“专用互斥锁类型”的部分,我们在一个单独的线程中启动了一个任务,然后需要在继续之前等待某些初始化完成。在那个情况下,我们使用了一个围绕std::atomic<bool>的轮询循环。但还有更好的等待方式!

我们 50 毫秒的轮询循环的问题在于它从未在睡眠中花费正确的时间。有时我们的线程会醒来,但它等待的条件还没有满足,所以它会再次入睡——这意味着我们第一次没有睡够。有时我们的线程会醒来,看到它等待的条件在过去 50 毫秒中的某个时刻已经满足,但我们不知道具体是多久以前——这意味着我们平均超睡大约 25 毫秒。无论发生什么,我们恰好睡够正确时间的几率几乎为零。

因此,如果我们不想浪费时间,正确的做法是避免轮询循环。标准库提供了一种等待恰好正确时间的方法;它被称为std::condition_variable

给定一个类型为std::condition_variable的变量cv,我们的线程可以通过调用cv.wait(lk)来“等待”cv;这将使我们的线程进入睡眠状态。调用cv.notify_one()cv.notify_all()会唤醒一个或所有当前正在等待cv的线程。然而,这不是唤醒那些线程的唯一方式!可能来自外部的中断(例如 POSIX 信号)可能会在没有任何人调用notify_one的情况下将你的线程唤醒。这种现象被称为虚假唤醒。防止虚假唤醒的常用方法是醒来时检查你的条件。例如,如果你正在等待某个输入到达缓冲区b,那么当你醒来时,你应该检查b.empty(),如果它是空的,就回去等待。

根据定义,其他线程将会把数据放入b中;因此,当你读取b.empty()时,你最好在某种类型的互斥锁下进行。这意味着当你醒来时,你首先会锁定那个互斥锁,当你再次入睡时,你最后会释放对那个互斥锁的锁定。(实际上,你需要原子性地在入睡操作中释放那个互斥锁,这样就没有人可以在你成功入睡之前溜进来,修改b并调用cv.notify_one()。)这个逻辑链引导我们理解为什么cv.wait(lk)需要那个参数lk——它是一个std::unique_lock<std::mutex>,在入睡时会释放,在醒来时会重新获得!

这里是一个等待某些条件满足的例子。首先是一个简单的但低效的轮询循环,针对一个std::atomic变量:

    std::atomic<bool> ready = false;

    std::thread thread_b([&]() {
      prep_work();
      ready = true;
      main_work();
    });

    // Wait for thread B to be ready.
    while (!ready) {
      std::this_thread::sleep_for(10ms); 
    }
    // Now thread B has completed its prep work.

现在是更受欢迎且更高效的condition_variable实现:

    bool ready = false; // not atomic!
    std::mutex ready_mutex;
    std::condition_variable cv;

    std::thread thread_b([&]() {
      prep_work();
      {
        std::lock_guard lk(ready_mutex);
        ready = true;
      }
      cv.notify_one();
      main_work();
    });

    // Wait for thread B to be ready.
    {
      std::unique_lock lk(ready_mutex);
      while (!ready) {
        cv.wait(lk);
      }
    }
    // Now thread B has completed its prep work.

如果我们正在等待从受读写锁保护的结构中读取(即,一个std::shared_mutex),那么我们不想传递一个std::unique_lock<std::mutex>;我们希望传递一个std::shared_lock<std::shared_mutex>。如果我们提前计划并定义我们的条件变量为std::condition_variable_any类型而不是std::condition_variable类型,我们可以这样做(但遗憾的是,只有在这种情况下才能做到)。实际上,std::condition_variable_anystd::condition_variable之间可能没有任何性能差异,这意味着你应该根据你的程序需求来选择它们,或者,如果两者都可以满足需求,那么根据代码的清晰度来选择。一般来说,这意味着节省四个字符并使用std::condition_variable。然而,请注意,由于std::shared_lock提供的隔离抽象层,在读写锁下等待cv的实际代码几乎与在普通互斥锁下等待cv的代码相同。以下是读写锁版本的代码:

    bool ready = false;
    std::shared_mutex ready_rwlock;
    std::condition_variable_any cv;
    std::thread thread_b([&]() {
      prep_work();
      {
        std::lock_guard lk(ready_rwlock);
        ready = true;
      }
      cv.notify_one();
      main_work();
    });

    // Wait for thread B to be ready.
    {
      std::shared_lock lk(ready_rwlock);
      while (!ready) {
        cv.wait(lk);
      }
    }
    // Now thread B has completed its prep work.

这是一段完全正确且尽可能高效的代码。然而,手动操作互斥锁和条件变量几乎和直接操作原始互斥锁或原始指针一样危险。我们可以做得更好!更好的解决方案是我们下一节的主题。

关于未来的承诺

如果你之前没有遇到过并发编程主题,那么最后几个部分可能越来越具有挑战性。互斥锁相对容易理解,因为它们模拟了日常生活中熟悉的概念:通过加锁来获取对某些资源的独占访问。读写锁(shared_mutex)也不难理解。然而,我们随后在神秘性方面迈出了重大的一步,条件变量很难掌握,部分原因在于它们似乎模拟的不是名词(如“挂锁”),而是一种介词动词短语:“直到……但也要……唤醒。”它们晦涩的名字也没有多大帮助。

现在我们继续我们的并发编程之旅,探讨一个即使你已修过并行编程本科课程可能也感到陌生的主题:承诺未来

在 C++11 中,std::promise<T>std::future<T>类型总是成对出现。来自 Go 语言的人可能会把承诺-未来对看作是一种通道,即如果一个线程将一个值(类型为T)推入这对的“承诺”一侧,那么这个值最终会在“未来”一侧出现(那时通常在另一个线程中)。然而,承诺-未来对也像不稳定的时间隧道:一旦你将一个值推过这个隧道,它就会立即坍塌。

我们可以说,promise-future 对就像一个有方向的、可携带的、一次性的虫洞。它是有方向的,因为您只能将数据推入“promise”端,并通过“future”端检索数据。它是可携带的,因为如果您拥有虫洞的一端,您可以移动该端,甚至在不同线程之间移动它;您不会破坏两端之间的隧道。而且它是一次性的,因为一旦您将一块数据推入“promise”端,您就不能再推入更多。

对于这对的另一个隐喻是由它们的名称所提出的:std::future<T>实际上不是类型T的值,但在某种意义上它是一个未来的值——它将在未来的某个时刻为您提供访问T的权限,但“尚未”。(以这种方式,它也类似于线程安全的optional<T>。)同时,std::promise<T>对象就像一个未履行的承诺,或者一个 I-O-U。承诺对象的所有者承诺在某个时刻将类型T的值放入其中;如果他从未放入值,那么他就“违背了他的承诺”。

通常来说,您首先创建一个std::promise<T>,其中T是您计划通过它发送的数据类型;然后通过调用p.get_future()创建虫洞的“future”端。当您准备好履行承诺时,您调用p.set_value(v)。同时,在另一个线程中,当您准备好检索值时,您调用f.get()。如果线程在承诺得到履行之前调用f.get(),那么该线程将阻塞,直到承诺得到履行且值准备好检索。另一方面,当持有承诺的线程调用p.set_value(v)时,如果没有人在等待,那也无所谓;set_value只需将值v记录在内存中,以便任何人通过f.get()请求时都能准备好并等待。

让我们看看promisefuture的实际应用!

    std::promise<int> p1, p2;
    std::future<int> f1 = p1.get_future();
    std::future<int> f2 = p2.get_future();

      // If the promise is satisfied first,
      // then f.get() will not block.
    p1.set_value(42);
    assert(f1.get() == 42);

      // If f.get() is called first, then it
      // will block until set_value() is called
      // from some other thread.
    std::thread t([&](){
      std::this_thread::sleep_for(100ms);
      p2.set_value(43);
    });
    auto start_time = std::chrono::system_clock::now();
    assert(f2.get() == 43);
    auto elapsed = std::chrono::system_clock::now() - start_time;
    printf("f2.get() took %dms.\n", count_ms(elapsed));
    t.join();

(有关count_ms的定义,请参阅上一节,专用互斥类型。)

关于标准库的std::promise的一个不错的细节是,它为void类型有一个特化。std::future<void>的想法一开始可能看起来有点愚蠢——如果唯一可以推入虫洞的数据类型是没有值的类型,那么虫洞有什么用?但事实上future<void>非常有用,无论我们是否关心接收到的,而是关心是否收到了信号。例如,我们可以使用std::future<void>来实现我们“等待线程 B 启动”代码的第三个版本:

    std::promise<void> ready_p;
    std::future<void> ready_f = ready_p.get_future();

    std::thread thread_b([&]() {
      prep_work();
      ready_p.set_value();
      main_work();
    });

      // Wait for thread B to be ready.
    ready_f.wait();
      // Now thread B has completed its prep work.

将此版本与标题为“等待条件”的章节中的代码示例进行比较。这个版本要干净得多!实际上没有冗余,没有任何样板代码。"信号 B 的就绪"和"等待 B 的就绪"操作都只需要一行代码。因此,从语法清洁度的角度来看,这绝对是单对线程之间进行信号的最佳方式。至于从单个线程向一组线程发送信号的第四种方式,请参阅本章标题为“识别单个线程和当前线程”的小节。

尽管 std::future 有其代价。这个代价是动态内存分配。你看,promisefuture 都需要访问一个共享存储位置,这样当你将 42 存储在 promise 一侧时,你将能够从 future 一侧取出它。(这个共享存储位置还包含了线程间同步所需的互斥锁和条件变量。互斥锁和条件变量并没有从我们的代码中消失;它们只是向下移动了一层抽象层,这样我们就不必担心它们了。)因此,promisefuture 都充当了这种共享状态的“句柄”;但它们都是可移动类型,所以它们都不能作为成员持有共享状态。它们需要在堆上分配共享状态,并持有其指针;由于共享状态不应该在两个句柄都被销毁之前释放,我们谈论的是通过类似 shared_ptr(见第六章 Chapter 6,智能指针)的东西进行共享所有权。从图示上看,promisefuture 看起来是这样的:

图片

此图中的共享状态将使用 operator new 分配,除非你使用特殊的“分配器感知”版本的构造函数 std::promise。要使用你选择的分配器与 std::promisestd::future 一起使用,你应该编写以下代码:

    MyAllocator myalloc{};
    std::promise<int> p(std::allocator_arg, myalloc);
    std::future<int> f = p.get_future();

std::allocator_arg<memory> 头文件中定义。有关 MyAllocator 的详细信息,请参阅第八章 Chapter 8,分配器

将任务打包以供稍后使用

关于前面图表的另一个需要注意的事项是,共享状态不仅仅包含一个optional<T>;实际上它包含一个variant<T, exception_ptr>(关于variantoptional,请参阅第五章,词汇类型)。这意味着你不仅可以将类型为T的数据通过虫洞传递;你还可以传递异常。这特别方便且对称,因为它允许std::future<T>表示调用具有签名T()的函数的所有可能结果。也许它会返回一个T;也许它会抛出异常;当然,也许它根本不会返回。同样,调用f.get()可能会返回一个T;或者抛出异常;或者(如果持有承诺的线程无限循环)可能根本不会返回。为了通过虫洞传递异常,你会使用p.set_exception(ex)方法,其中exstd::exception_ptr类型的对象,这种对象可能来自 catch 处理程序中的std::current_exception()

让我们拿一个签名为T()的函数,并将其封装在类型为std::future<T>的未来中:

    template<class T>
    class simple_packaged_task {
      std::function<T()> m_func;
      std::promise<T> m_promise;
    public:
      template<class F>
      simple_packaged_task(const F& f) : m_func(f) {}

      auto get_future() { return m_promise.get_future(); }

      void operator()() {
        try {
          T result = m_func();
          m_promise.set_value(result);
        } catch (...) {
          m_promise.set_exception(std::current_exception());
        }
      }
    };

这个类在表面上类似于标准库类型std::packaged_task<R(A...)>;区别在于标准库类型接受参数,并使用额外的间接层来确保它可以持有甚至只移动的函数类型。回到第五章,词汇类型,我们向你展示了std::function不能持有只移动函数类型的一些解决方案;幸运的是,当处理std::packaged_task时,这些解决方案是不必要的。另一方面,你可能一生中都不需要处理std::packaged_task。它主要作为一个例子,展示了如何将承诺、未来和函数组合成用户友好的类类型,同时具有非常简单的接口。考虑一下:上面的simple_packaged_task类在std::function中使用类型擦除,然后有一个std::promise成员,该成员通过std::shared_ptr实现,它执行引用计数;那个引用计数的指针指向的共享状态包含一个互斥锁和一个条件变量。这相当多的想法和技术被压缩在一个非常小的体积中!然而,simple_packaged_task的接口确实很简单:用某种函数或 lambda 表达式构造它,然后调用pt.get_future()以获取一个你可以调用f.get()的未来;同时调用pt()(可能来自其他线程)以实际执行存储的函数并将结果通过虫洞传递到f.get()

如果存储函数抛出异常,那么packaged_task将捕获该异常(在持有承诺的线程中)并将其推入虫洞。然后,无论何时其他线程调用f.get()(或者它可能已经调用了,并且现在正阻塞在f.get()中),f.get()都会将异常抛出到持有未来的线程。换句话说,通过使用承诺和未来,我们实际上可以在线程之间“传送”异常。这种传送的确切机制std::exception_ptr,不幸的是超出了本书的范围。如果你在一个使用大量异常的代码库中进行库编程,那么熟悉std::exception_ptr绝对是值得的。

未来的未来

std::shared_mutex一样,标准库自己的std::future版本只是半成品。一个更完整、更有用的future版本可能出现在 C++20 中,并且有许多第三方库结合了即将推出的版本的最佳特性。其中最好的库包括boost::future和 Facebook 的folly::Future

std::future的主要问题是它需要在多步计算中的每一步之后“降落”到线程中。考虑这种对std::future的病态使用:

    template<class T>
    auto pf() {
      std::promise<T> p;
      std::future<T> f = p.get_future();
      return std::make_pair(std::move(p), std::move(f));
    }

    void test() {
      auto [p1, f1] = pf<Connection>();
      auto [p2, f2] = pf<Data>();
      auto [p3, f3] = pf<Data>();

      auto t1 = std::thread([p1 = std::move(p1)]() mutable {
        Connection conn = slowly_open_connection();
        p1.set_value(conn);
        // DANGER: what if slowly_open_connection throws?
      });
      auto t2 = std::thread([p2 = std::move(p2)]() mutable {
        Data data = slowly_get_data_from_disk();
        p2.set_value(data);
      });
      auto t3 = std::thread(
      [p3 = std::move(p3), f1 = std::move(f1)]() mutable {
        Data data = slowly_get_data_from_connection(f1.get());
        p3.set_value(data);
      });
      bool success = (f2.get() == f3.get());

      assert(success);
    }

注意标记为DANGER的行:三个线程体中每个都存在相同的错误,即它们在抛出异常时未能捕获并调用.set_exception()。解决方案是一个try...catch块,就像我们在前一个部分中使用的simple_packaged_task一样;但由于每次都要写出来会变得很繁琐,标准库提供了一个整洁的包装函数std::async(),它负责创建承诺-未来对并创建一个新的线程。使用std::async(),我们有了这样更干净看起来更好的代码:

    void test() {
      auto f1 = std::async(slowly_open_connection);
      auto f2 = std::async(slowly_get_data_from_disk);
      auto f3 = std::async([f1 = std::move(f1)]() mutable {
        return slowly_get_data_from_connection(f1.get());
        // No more danger.
      });
      bool success = (f2.get() == f3.get());

      assert(success);
    }

然而,这段代码在美学上更干净,但它对你的代码库的性能和健壮性同样糟糕。这是糟糕的代码!

每次你在代码中看到.get()时,你应该想,“多么浪费上下文切换啊!”每次你看到线程被创建(无论是显式还是通过async),你应该想,“操作系统可能耗尽内核线程,我的程序可能从std::thread的构造函数开始抛出意外异常的可能性有多大!”我们宁愿不写前面的任何一种代码,而是写一些可能看起来熟悉 JavaScript 程序员的代码:

    void test() {
      auto f1 = my::async(slowly_open_connection);
      auto f2 = my::async(slowly_get_data_from_disk);
      auto f3 = f1.then([](Connection conn) {
        return slowly_get_data_from_connection(conn);
      });
      bool success = f2.get() == f3.get();

      assert(success);
    }

在这里,除了在最后什么也不做只是等待最终答案时调用 .get() 之外,没有其他调用;并且产生的线程数量少了一个。相反,在 f1 完成任务之前,我们将其附加到一个“延续”上,这样当 f1 完成时,持有承诺的线程可以立即过渡到继续任务(如果 f1 的原始任务抛出异常,我们根本不会进入这个延续。库应该提供一个对称的方法,f1.on_error(continuation),来处理异常代码路径)。

类似的东西在 Boost 中已经可用;Facebook 的 Folly 库包含一个特别健壮且功能齐全的实现,甚至比 Boost 的还要好。在我们等待 C++20 改善这种情况的同时,我的建议是,如果你能承担将其集成到你的构建系统中的认知开销,就使用 Folly。std::future 的单一优势在于它是标准的;你几乎可以在任何平台上使用它,而无需担心下载、包含路径或许可条款。

说到线程...

在整个这一章中,我们一直在使用“线程”这个词,而没有明确地定义我们所说的“线程”是什么;你可能已经注意到,我们许多多线程代码示例都使用了 std::thread 类类型和 std::this_thread 命名空间,而没有太多解释。我们一直专注于如何在不同执行线程之间同步行为,但到目前为止,我们一直忽略了在执行!

换个说法:当执行到达表达式 mtx.lock(),其中 mtx 是一个已锁定的互斥量时,std::mutex 的语义表明当前执行线程应该阻塞并等待。当这个线程阻塞时,发生了什么?我们的 C++ 程序仍然“负责”正在发生的事情,但显然这个特定的 C++ 代码不再执行;那么在执行?答案是:另一个线程。我们通过使用标准库类 std::thread 来指定其他线程的存在以及我们希望它们执行的操作,该类定义在 <thread> 头文件中。

要创建一个新的执行线程,只需构造一个 std::thread 类型的对象,并将单个参数传递给构造函数:一个 lambda 或函数,它告诉你你希望在新的线程中运行的代码。技术上,你可以传递多个参数;所有第一个参数之后的参数都将经过 reference_wrapper 衰减(如第五章[26I9K0-2fdac365b8984feebddfbb9250eaf20d]中所述,词汇类型),然后作为它的函数参数传递。从 C++11 开始,lambdas 使得 thread 构造函数的额外参数变得不必要,甚至可能出错;我建议避免使用它们。)

新线程将立即开始运行;如果你想让它“启动时暂停”,你必须自己使用“等待条件”部分中展示的同步技巧之一或“识别单个线程和当前线程”中展示的替代技巧来构建该功能。

新线程将执行它所提供的代码,当它到达你提供的 lambda 表达式或函数的末尾时,它将“变为可连接”。这个想法与 std::future 在“变为就绪”时发生的情况非常相似:线程已经完成了其计算,并准备好将计算结果传递给你。就像 std::future<void> 一样,该计算的结果是“无值的”;但计算 已经完成 的这一事实仍然非常有价值——不是字面意义上的!

std::future<void> 不同,不允许在未获取无值结果的情况下销毁 std::thread 对象。默认情况下,如果你不处理任何新线程的结果就销毁它,析构函数将调用 std::terminate,也就是说,它会直接杀死你的程序。避免这种命运的方法是通过调用成员函数 t.join() 来向线程表明你看到了并承认了它的完成——“干得好,线程,做得好!”——或者,如果你不期望线程完成(例如,如果它是一个运行无限循环的后台线程)或者不关心它的结果(例如,如果它代表一些短暂的“点火并忘记”任务),你可以将其发送到后台——“走开,线程,我不想再听到你的消息!”——通过 t.detach()

下面是一些使用 std::thread 的完整示例:

    using namespace std::literals; // for "ms"

    std::thread a([](){
      puts("Thread A says hello ~0ms");
      std::this_thread::sleep_for(10ms);
      puts("Thread A says goodbye ~10ms");
    });

    std::thread b([](){
      puts("Thread B says hello ~0ms");
      std::this_thread::sleep_for(20ms);
      puts("Thread B says goodbye ~20ms");
    });

    puts("The main thread says hello ~0ms");
    a.join(); // waits for thread A
    b.detach(); // doesn't wait for thread B
    puts("The main thread says goodbye ~10ms");

识别单个线程和当前线程

类型为 std::thread 的对象,就像本章中描述的每一种其他类型一样,不支持 operator==。你不能直接询问“这两个线程对象是否相同?”这也意味着你不能将 std::thread 对象用作关联容器(如 std::mapstd::unordered_map)中的键。然而,你可以通过一个称为 thread-ids 的特性间接地询问相等性。

成员函数 t.get_id() 返回一个唯一的标识符,类型为 std::thread::id,尽管它技术上是一个类类型,但它的行为非常类似于整数类型。你可以使用 <== 操作符比较线程标识符;并且你可以将线程标识符用作关联容器的键。线程标识符对象的一个宝贵特性是它们可以被 复制,而 std::thread 对象本身只能移动。记住,每个 std::thread 对象代表一个实际的执行线程;如果你可以复制 thread 对象,你就是在“复制”执行线程,这没有太多意义——而且肯定会引发一些有趣的错误!

std::thread::id 的第三个有价值的特性是,可以获取当前线程的线程 ID,甚至主线程的线程 ID。在某个线程内部,没有方法可以说“请给我管理这个线程的 std::thread 对象。”(这会是一个类似于第六章中 std::enable_shared_from_this<T> 的技巧;但正如我们所看到的,这样的技巧需要来自创建管理资源的库部分的支持——在这个例子中,将是 std::thread 的构造函数。)主线程,即 main 开始执行的那个线程,根本就没有对应的 std::thread 对象。但仍然有一个线程 ID!

最后,线程 ID 可以通过某种实现定义的方式转换为字符串表示,这保证了其唯一性——也就是说,to_string(id1) == to_string(id2) 当且仅当 id1 == id2。不幸的是,这个字符串表示只通过流操作符公开(见第九章,Iostreams);如果你想使用 to_string(id1) 语法,你需要编写一个简单的包装函数:

    std::string to_string(std::thread::id id)
    {
      std::ostringstream o;
      o << id;
      return o.str();
    }

你可以通过调用免费函数 std::this_thread::get_id() 来获取当前线程的线程 ID(包括主线程,如果它恰好是当前线程的话)。仔细看看语法!std::thread 是一个类的名字,但 std::this_thread 是一个命名空间的名字。在这个命名空间中存在一些免费函数(与任何 C++类实例无关),它们操作当前线程。get_id() 是其中之一。它的名字被选择为让人联想到 std::thread::get_id(),但实际上它是一个完全不同的函数:thread::get_id() 是一个成员函数,而 this_thread::get_id() 是一个免费函数。

使用两个线程 ID,你可以找出,例如,现有的线程列表中哪个代表了你的当前线程:

    std::mutex ready;
    std::unique_lock lk(ready);
    std::vector<std::thread> threads;

    auto task = [&](){
        // Block here until the main thread is ready.
      (void)std::lock_guard(ready);
        // Now go. Find my thread-id in the vector.
      auto my_id = std::this_thread::get_id();
      auto iter = std::find_if(
        threads.begin(), threads.end(),
        = {
          return t.get_id() == my_id;
         }
      );
      printf("Thread %s %s in the list.\n",
        to_string(my_id).c_str(),
        iter != threads.end() ? "is" : "is not");
    };

    std::vector<std::thread> others;
    for (int i = 0; i < 10; ++i) {
      std::thread t(task);
      if (i % 2) {
        threads.push_back(std::move(t));
      } else {
        others.push_back(std::move(t));
      }
    }

      // Let all the threads run.
    ready.unlock();

      // Join all the threads.
    for (std::thread& t : threads) t.join();
    for (std::thread& t : others) t.join();

你永远不能做的是反过来;你不能从给定的 std::thread::id 重建对应的 std::thread 对象。因为如果你能这样做,你的程序中就会有代表那个执行线程的两个不同的对象:原始的 std::thread 无论它在何处,以及你刚刚从其线程 ID 重建的那个。你永远不能有两个 std::thread 对象控制同一个线程。

std::this_thread 命名空间中的另外两个免费函数是 std::this_thread::sleep_for(duration),你在这章中已经看到我广泛地使用了它,以及 std::this_thread::yield(),这基本上等同于 sleep_for(0ms):它告诉运行时现在切换到另一个线程是个好主意,但并不表示当前线程有任何特定的时间延迟

线程耗尽和 std::async

在本章的 未来的未来 节中,我们介绍了 std::async,它是一个围绕线程构造函数的简单包装器,结果被捕获到 std::future 中。它的实现看起来大致如下:

    template<class F>
    auto async(F&& func) {
      using ResultType = std::invoke_result_t<std::decay_t<F>>;
      using PromiseType = std::promise<ResultType>;
      using FutureType = std::future<ResultType>;

      PromiseType promise;
      FutureType future = promise.get_future();
      auto t = std::thread([
        func = std::forward<F>(func),
        promise = std::move(promise)
      ]() mutable {
        try {
          ResultType result = func();
           promise.set_value(result);
        } catch (...) {
          promise.set_exception(std::current_exception());
        }
      });
      // This special behavior is not implementable
      // outside of the library, but async does do it.
      // future.on_destruction([t = std::move(t)]() {
      //  t.join();
      // });
      return future;
    }

注意被注释掉的行,它们指示了从 std::async 返回的 std::future 的特殊行为 "在销毁时"。这是标准库中 std::async 实现的一个奇怪且尴尬的行为,也是避免或在自己的代码中重新实现 std::async 的好理由:std::async 返回的未来具有调用其底层线程的 .join() 的析构函数!这意味着它们的析构函数可能会阻塞,而且任务肯定不会像你自然期望的那样“在后台执行”。如果你调用 std::async 而不将返回的未来分配给变量,返回值将立即被销毁,这从讽刺的角度来看意味着只包含对 std::async 调用的行实际上会同步执行指定的函数:

    template<class F>
    void fire_and_forget_wrong(const F& f) {
      // WRONG! Runs f in another thread, but blocks anyway.
      std::async(f);
    }

    template<class F>
    void fire_and_forget_better(const F& f) {
      // BETTER! Launches f in another thread without blocking.
      std::thread(f).detach();
    }

这种限制的原由似乎是出于对如果 std::async 以通常的方式启动后台线程,可能会导致人们过度使用 std::async 并可能引入悬垂引用错误的担忧,就像这个例子中那样:

    int test() {
      int i = 0;
      auto future = std::async([&]() {
        i += 1;
      });
      // suppose we do not call f.wait() here
      return i;
    }

如果我们没有等待这个未来的结果,函数 test() 可能会在新线程有机会运行之前就返回给调用者;然后,当新线程最终运行并尝试增加 i 时,它会访问一个不再存在的栈变量。因此,为了避免人们编写这样的有缺陷的代码,标准委员会决定 std::async 应该返回具有特殊、“魔法”析构函数的未来,这些析构函数会自动连接它们的线程。

无论如何,过度使用 std::async 也有其他问题。最大的原因是,在所有流行的操作系统上,std::thread 代表一个 内核线程——一个调度受操作系统内核控制的线程。因为操作系统只有有限的资源来跟踪这些线程,所以任何进程可用的线程数量相当有限:通常只有几万。如果你将 std::async 作为你的线程管理器,每次有另一个可能从并发中受益的任务时都创建一个新的 std::thread,你很快就会发现自己没有足够的内核线程可用。当这种情况发生时,std::thread 的构造函数将开始抛出 std::system_error 类型的异常,通常带有文本 Resource temporarily unavailable

构建自己的线程池

如果你每次有新任务时都使用std::async来启动一个线程,你可能会耗尽内核为你进程提供的线程数量。运行任务的一种更好的方式是使用线程池——一小部分“工作线程”,它们唯一的任务是运行程序员提供的任务。如果有比工作者更多的任务,额外的任务将被放置在工作队列中。每当一个工作者完成一个任务时,它会检查工作队列中是否有新任务。

这是一个众所周知的思想,但截至 C++17 标准库还没有采用。然而,你可以结合本章中展示的思想来创建自己的生产级线程池。我将在这里介绍一个简单的例子;从性能的角度来看,它不是“生产级”的,但它确实是正确线程安全的,并且在其所有功能上都是正确的。在浏览结束时将讨论一些性能调整。

我们将从成员数据开始。请注意,我们使用的是规则,即所有由互斥锁控制的数据都应该位于单个视觉命名空间下;在这种情况下,一个嵌套的结构定义。我们还将使用std::packaged_task<void()>作为我们的移动函数类型;如果你的代码库已经有一个移动函数类型,你可能想使用那个类型。如果你还没有移动函数类型,考虑采用 Folly 的folly::Function或 Denis Blank 的fu2::unique_function

    class ThreadPool {
      using UniqueFunction = std::packaged_task<void()>;
      struct {
        std::mutex mtx;
        std::queue<UniqueFunction> work_queue;
        bool aborting = false;
      } m_state;
      std::vector<std::thread> m_workers;
      std::condition_variable m_cv;

work_queue变量将保存传入的任务。成员变量m_state.aborting将在所有工作者停止工作并“回家休息”时设置为truem_workers保存工作线程本身;而m_state.mtxm_cv只是用于同步。(当没有工作要做时,工作者将花费大部分时间处于睡眠状态。当有新任务进来并且我们需要唤醒一些工作者时,我们将通知m_cv。)

ThreadPool的构造函数会启动工作线程并填充m_workers向量。每个工作线程将运行成员函数this->worker_loop(),我们将在下一分钟看到:

    public:
      ThreadPool(int size) {
        for (int i=0; i < size; ++i) {
          m_workers.emplace_back([this]() { worker_loop(); });
        }
      }

如承诺的那样,析构函数将m_state.aborting设置为true,然后等待所有工作线程注意到这个变化并终止。请注意,当我们接触m_state.aborting时,它只在m_state.mtx的锁下;我们正在遵循良好的卫生习惯,以避免错误!

      ~ThreadPool() {
        if (std::lock_guard lk(m_state.mtx); true) {
          m_state.aborting = true;
        }
        m_cv.notify_all();
        for (std::thread& t : m_workers) {
          t.join();
        }
      }

现在我们来看看如何将任务入队到工作队列中。(我们还没有看到工作者如何获取任务;我们将在worker_loop成员函数中看到这一点。)这非常简单;我们只需要确保只在互斥锁下访问m_state,并且一旦我们入队了任务,我们就调用m_cv.notify_one(),这样某个工作者就会醒来处理任务:

      void enqueue_task(UniqueFunction task) {
        if (std::lock_guard lk(m_state.mtx); true) {
          m_state.work_queue.push(std::move(task));
        }
        m_cv.notify_one();
      }

最后,这里是工作循环。这是每个工作者运行的成员函数:

    private:
      void worker_loop() {
        while (true) {
          std::unique_lock lk(m_state.mtx);
          while (m_state.work_queue.empty() && !m_state.aborting) {
            m_cv.wait(lk);
          }
          if (m_state.aborting) break;
          // Pop the next task, while still under the lock.
          assert(!m_state.work_queue.empty());
          UniqueFunction task = std::move(m_state.work_queue.front());
          m_state.work_queue.pop();

          lk.unlock();
          // Actually run the task. This might take a while.
          task();
          // When we're done with this task, go get another.
        }
      }

注意 m_cv.wait(lk) 附近的必然循环,并注意我们只在互斥锁下卫生地访问 m_state。此外,注意当我们实际调用 task 来执行任务时,我们首先释放互斥锁;这确保了我们不会在用户任务执行期间长时间持有锁。如果我们 确实 要长时间持有锁,那么就没有其他工作线程能够进入并获取其下一个任务--我们实际上会减少池的并发性。此外,如果我们要在 task 期间持有锁,并且如果 task 本身尝试在这个池中排队一个新任务(这需要自己获取锁),那么 task 将发生死锁,整个程序都会冻结。这是更一般规则的一个特殊情况,即永远不要在持有互斥锁的同时调用用户提供的回调:这通常会导致死锁。

最后,让我们通过实现 async 的安全版本来完善我们的 ThreadPool 类。我们的版本将允许对任何无参数可调用的 f 调用 tp.async(f),就像 std::async 一样,我们将通过返回一个 std::future 来获取 f 的结果,一旦它准备好。与 std::async 返回的 future 不同,我们的 future 将是安全的:如果调用者最终决定他不想等待结果,任务将保持排队并最终执行,结果将被简单地忽略:

    public:
      template<class F>
      auto async(F&& func) {
        using ResultType = std::invoke_result_t<std::decay_t<F>>;

        std::packaged_task<ResultType()> pt(std::forward<F>(func));
        std::future<ResultType> future = pt.get_future();

        UniqueFunction task(
           [pt = std::move(pt)]() mutable { pt(); }
        );

        enqueue_task(std::move(task));

        // Give the user a future for retrieving the result.
        return future;
      }
    }; // class ThreadPool

我们可以使用我们的 ThreadPool 类来编写如下函数,该函数创建了 60,000 个任务:

    void test() {
      std::atomic<int> sum(0);
      ThreadPool tp(4);
      std::vector<std::future<int>> futures;
      for (int i=0; i < 60000; ++i) {
        auto f = tp.async([i, &sum](){
          sum += i;
          return i;
        });
        futures.push_back(std::move(f));
      }
      assert(futures[42].get() == 42);
      assert(903 <= sum && sum <= 1799970000);
    }

我们可以尝试用 std::async 做同样的事情,但当我们尝试创建 60,000 个内核线程时,我们可能会遇到线程耗尽的问题。前面的例子只使用了四个内核线程,正如 ThreadPool 构造函数的参数所示。

当你运行这段代码时,你会在标准输出中看到至少从 0 到 42 的数字,以某种顺序打印出来。我们知道 42 必须被打印出来,因为函数肯定在退出前等待 futures[42] 准备就绪,并且所有前面的数字都必须打印出来,因为它们的任务在任务编号 42 之前被放入工作队列。数字 43 到 59,999 可能会打印出来,也可能不会,这取决于调度器;因为一旦任务 42 完成,我们就退出 test 并因此销毁线程池。正如我们所见,线程池的析构函数会通知所有工作线程在完成当前任务后停止工作并回家。因此,我们可能会看到更多数字被打印出来,然后所有工作线程都会回家,剩余的任务将被简单地丢弃。

当然,如果您希望 ThreadPool 的析构函数阻塞,直到所有入队任务都完成,您可以通过更改析构函数的代码来实现这一点。然而,通常当您销毁线程池时,是因为您的程序(如 Web 服务器)正在退出,这是由于您收到了用户按下 Ctrl + C 等信号。在这种情况下,您可能希望尽快退出,而不是尝试清空队列。我个人更愿意添加一个成员函数 tp.wait_for_all_enqueued_tasks(),这样线程池的使用者就可以决定他们是要阻塞还是直接放弃所有任务。

提高我们的线程池性能

我们 ThreadPool 的最大性能瓶颈在于每个工作线程都在争夺同一个互斥锁,this->m_state.mtx。它们之所以争夺这个互斥锁,是因为这是保护 this->m_state.work_queue 的互斥锁,每个工作线程都需要接触这个队列以找出其下一个工作。因此,减少竞争并加快我们程序的一种方法就是找到一种将工作分配给工作线程的方法,而不涉及单一的中心工作队列。

最简单的解决方案是为每个工作线程提供自己的“待办事项列表”;也就是说,用整个 std::vector<std::queue<Task>> 替换我们单一的 std::queue<Task>,为每个工作线程提供一个条目。当然,我们还需要一个 std::vector<std::mutex>,以便为每个工作队列有一个互斥锁。enqueue_task 函数以轮询方式将任务分配给工作队列(使用 std::atomic<int> 计数器的原子递增来处理同时入队的情况)。

您还可以为每个入队线程使用一个 thread_local 计数器,如果您有幸在一个支持 C++11 的 thread_local 关键字的平台上工作。在 x86-64 POSIX 平台上,访问 thread_local 变量的速度大约与访问普通全局变量的速度相同;设置线程局部变量的所有复杂性都在幕后发生,并且仅在您创建新线程时才会出现。然而,由于这种复杂性确实存在并且需要运行时支持,许多平台尚未支持 thread_local 存储类指定符。(在那些支持的平台中,thread_local int xstatic int x 基本上是同一回事,只是当您的代码通过名称访问 x 时,x 的实际内存地址将根据 std::this_thread::get_id() 而变化。原则上,在幕后有一个由线程 ID 索引的整个 x 数组,由 C++ 运行时在创建和销毁线程时填充。)

我们 ThreadPool 的下一个显著的性能改进将是“工作窃取”:现在每个工作线程都有自己的待办事项列表,可能会偶然或恶意地发生一个工作线程变得过度劳累,而所有其他工作线程都闲置。在这种情况下,我们希望空闲的工作线程扫描忙碌工作线程的队列,并在可能的情况下“窃取”任务。这再次在工作线程之间引入了锁竞争,但只有在任务分配不均已经产生了效率低下——我们希望通过工作窃取来纠正这种效率低下。

实现单独的工作队列和工作窃取作为练习留给读者;但我希望你在看到基本的 ThreadPool 实现如此简单之后,不会对修改它以包含这些额外功能感到过于畏惧。

当然,也存在专业编写的线程池类。例如,Boost.Asio 包含了一个,而且 Asio 正在朝着可能在 C++20 中成为标准库的方向发展。使用 Boost.Asio,我们的 ThreadPool 类将看起来像这样:

    class ThreadPool {
      boost::thread_group m_workers;
      boost::asio::io_service m_io;
      boost::asio::io_service::work m_work;
    public:
      ThreadPool(int size) : m_work(m_io) {
        for (int i=0; i < size; ++i) {
          m_workers.create_thread([&](){ m_io.run(); });
        }
      }

      template<class F>
      void enqueue_task(F&& func) {
        m_io.post(std::forward<F>(func));
      }

      ~ThreadPool() {
        m_io.stop();
        m_workers.join_all();
      }
    };

Boost.Asio 的解释当然超出了本书的范围。

任何时候你使用线程池,都要小心确保你入队的任务不会在由同一线程池中的其他任务控制的条件下无限期地阻塞。一个经典的例子是任务 A 等待一个条件变量,期望稍后某个任务 B 会通知这个条件变量。如果你创建一个大小为 4 的 ThreadPool 并将四个任务 A 的副本和四个任务 B 的副本入队,你会发现任务 B 永远不会运行——你池中的四个工作线程都被四个任务 A 的副本占用,它们都在等待一个永远不会到来的信号!“处理”这种情况相当于编写自己的用户空间线程库;如果你不想涉足这一领域,那么唯一的明智答案是确保这种情况根本不会发生。

摘要

多线程是一个困难和微妙的话题,有许多只有在事后才会明显的问题。在本章中,我们学习了:

volatile 虽然对于直接处理硬件很有用,但不足以保证线程安全。对于标量 T(大小不超过机器寄存器),std::atomic<T> 是访问共享数据而不发生竞争和不使用锁的正确方式。最重要的原始原子操作是比较并交换,在 C++ 中表示为 compare_exchange_weak

为了强制线程轮流访问共享的非原子数据,我们使用 std::mutex。始终通过 RAII 类如 std::unique_lock<M> 锁定互斥量。记住,尽管 C++17 类模板参数推导允许我们从这些模板的名称中省略 <M>,但这只是一个语法便利;它们仍然是模板类。

总是清楚地指出程序中每个互斥量控制的数据。一个很好的方法是使用嵌套的结构定义。

std::condition_variable 允许我们“等待直到”某个条件得到满足。如果条件只能满足一次,例如线程变为“就绪”状态,那么你可能想使用承诺-未来对而不是条件变量。如果条件可以反复满足,考虑是否可以将你的问题重新表述为 工作队列 模式。

std::thread 实现了执行线程的概念。当前线程不能直接作为一个 std::thread 对象来操作,但在 std::this_thread 命名空间中有一组有限的操作作为免费函数可用。其中最重要的操作是 sleep_forget_id。每个 std::thread 在被销毁之前都必须始终加入或分离。分离对于你永远不会需要干净关闭的后台线程是有用的。

标准函数 std::async 接收一个用于在其他线程上执行的功能或 lambda 表达式,并在函数执行完毕时返回一个 std::future 对象。虽然 std::async 本身存在致命缺陷(析构函数中的 join;内核线程耗尽)因此不应在生产代码中使用,但通过 future 处理并发的总体思路是好的。最好使用支持 .then 方法的承诺和 future 的实现。Folly 的实现是最好的。

多线程是一个困难和微妙的话题,其中许多陷阱只有在事后才会变得明显

第八章:分配器

我们在前面的章节中看到,C++对动态内存分配有着爱恨交加的关系。

一方面,从堆中进行动态内存分配是一种“代码异味”;追逐指针可能会损害程序的性能,堆可能会意外耗尽(导致std::bad_alloc类型的异常),手动内存管理是如此微妙地困难,以至于 C++11 引入了多种不同的“智能指针”类型来管理复杂性(参见第六章,智能指针)。2011 年之后的 C++连续版本也添加了大量的非分配代数数据类型,如tupleoptionalvariant(参见第五章,词汇类型),这些类型可以在不接触堆的情况下表达所有权或包含关系。

另一方面,新的智能指针类型确实有效地管理了内存管理的复杂性;在现代 C++中,你可以安全地分配和释放内存,而无需使用原始的newdelete,也无需担心内存泄漏。并且堆分配在许多新的 C++特性(anyfunctionpromise)的“幕后”使用,就像它继续被许多旧特性(stable_partitionvector)使用一样。

因此,这里存在冲突:如果我们被告知好的 C++代码应避免堆分配,我们如何使用这些伟大的新特性(以及旧特性)呢,这些特性依赖于堆分配?

在大多数情况下,你应该偏向于使用 C++提供的特性。如果你想有一个可调整大小的元素向量,你应该使用默认的std::vector,除非你在你的情况下测量了使用它的实际性能问题。但也存在一类程序员——在非常受限的环境中工作,如飞行软件——他们必须避免接触堆,原因很简单:“堆”在他们的平台上不存在!在这些嵌入式环境中,整个程序的整个占用空间必须在编译时确定。有些这样的程序简单地避免任何类似于堆分配的算法——如果你从未动态分配任何类型的资源,你永远不会遇到意外的资源耗尽!其他这样的程序虽然使用类似于堆分配的算法,但要求在他们的程序中显式表示“堆”(比如说,通过一个非常大的char数组以及用于“保留”和“返回”该数组连续块的功能)。

如果这类程序无法使用 C++提供的功能,如std::vectorstd::any,那将极其不幸。因此,自从 1998 年的原始标准以来,标准库就提供了一种称为“分配器感知”的功能。当一个类型或算法是“分配器感知”的,它为程序员提供了一种指定类型或算法应该如何保留和返回动态内存的方法。这个“如何”被具体化为一个称为“分配器”的对象。

在本章中,我们将学习:

  • “分配器”和“内存资源”的定义

  • 如何创建自己的内存资源,该资源从静态缓冲区中分配

  • 如何使自己的容器“分配器感知”

  • 命名空间std::pmr中的标准内存资源类型及其令人惊讶的陷阱

  • C++11 分配器模型中的许多奇怪特性纯粹是为了支持scoped_allocator_adaptor

  • 什么使一个类型成为“花哨指针”类型,以及这种类型可能在何处有用

分配器是内存资源的句柄

在阅读本章时,你必须牢记两个基本概念之间的区别,我将称它们为“内存资源”和“分配器”。一个“内存资源”(一个受标准自身术语启发的名字——你可能更愿意称它为“堆”)是一个长期存在的对象,可以在请求时分配内存块(通常是通过从内存资源本身拥有的一个大内存块中切割出来)。内存资源具有经典的面向对象语义(参见第一章,经典多态和泛型编程):你创建一个内存资源一次,永远不会移动或复制它,内存资源的相等性通常由对象身份定义。另一方面,一个“分配器”是一个指向内存资源的短暂句柄。分配器具有指针语义:你可以复制它们,移动它们,并且通常可以随意操作它们,分配器的相等性通常由它们是否指向相同的内存资源来定义。我们可以说分配器“指向”特定的内存资源,我们也可以说分配器“由”那个内存资源“支持”;这两个术语可以互换使用。

当我在本章中谈论“内存资源”和“分配器”时,我将谈论前面的概念。标准库还有一些名为memory_resourceallocator的类型;每当我谈论这些类型时,我会小心地使用打字机文本。这不应该太令人困惑。情况与第二章,“迭代器和范围”相似,在那里我们谈论了“迭代器”以及std::iterator。当然,那更容易,因为我只提到std::iterator是为了告诉你永远不要使用它;它在良好的 C++代码中没有任何位置。在本章中,我们将了解到std::pmr::memory_resource在特定的 C++程序中确实有它的位置!

尽管我描述了分配器为一个“指向”内存资源的句柄,但你应该注意到,有时所涉及的内存资源是一个全局单例——这类单例的一个例子是全局堆,其访问器是全局的operator newoperator delete。就像一个“捕获”全局变量的 lambda 实际上并没有捕获任何东西一样,由全局堆支持的分配器实际上不需要任何状态。事实上,std::allocator<T>就是这样一种无状态的分配器类型——但我们在这里跑题了!

复习 - 接口与概念

从第一章,“经典多态与泛型编程”中回忆起,C++提供了两种主要不兼容的处理多态的方法。静态、编译时多态被称为泛型编程;它依赖于将多态接口表达为一个概念,具有许多可能的模型,与接口交互的代码以模板的形式表达。动态、运行时多态被称为经典多态;它依赖于将多态接口表达为一个基类,具有许多可能的派生类,与接口交互的代码以对虚函数的调用形式表达。

在本章中,我们将第一次(也是最后一次)真正接近泛型编程。除非你能够同时记住两个想法,否则无法理解 C++的分配器:一方面是定义接口的概念 Allocator,另一方面是某些特定的模型,例如std::allocator,它实现了符合Allocator概念的行为。

为了进一步复杂化问题,Allocator概念实际上是一个模板化的概念家族!更准确地说,我们应该谈论概念家族Allocator<T>;例如,Allocator<int>将是定义“分配int对象的分配器”的概念,而Allocator<char>将是“分配char对象的分配器”,等等。例如,具体的类std::allocator<int>是概念Allocator<int>的一个模型,但它不是Allocator<char>的模型。

每个类型T的分配器(每个Allocator<T>)都必须提供一个名为allocate的成员函数,以便a.allocate(n)返回足够内存的指针,用于存储类型为Tn个对象的数组。(该指针将来自支持分配器实例的内存资源。)没有指定allocate成员函数应该是静态的还是非静态的,也没有指定它应该恰好接受一个参数(n)或者可能接受一些具有默认值的附加参数。因此,以下两种类类型在这一点上都是Allocator<int>的可接受模型:

    struct int_allocator_2014 {
      int *allocate(size_t n, const void *hint = nullptr);
    };

    struct int_allocator_2017 {
      int *allocate(size_t n);
    };

类别名为int_allocator_2017显然是建模Allocator<int>更简单方法,但int_allocator_2014也是一个正确的模型,因为在两种情况下,表达式a.allocate(n)都将被编译器接受;这就是我们在谈论泛型编程时所要求的一切。

相比之下,当我们进行经典多态时,我们为基类的每个方法指定一个固定的签名,并且不允许派生类偏离该签名:

    struct classical_base {
      virtual int *allocate(size_t n) = 0;
    };

    struct classical_derived : public classical_base {
      int *allocate(size_t n) override;
    };

派生类classical_derived不允许在allocate方法的签名上添加任何额外的参数;不允许更改返回类型;不允许使方法static。与泛型编程相比,接口在经典多态中更加“锁定”。

由于“锁定”的经典接口自然比开放的抽象接口更容易描述,因此我们将从 C++17 的全新、经典多态的memory_resource开始我们的分配器库之旅。

使用memory_resource定义堆

回想一下,在资源受限的平台,我们可能不允许使用“堆”(例如通过newdelete),因为平台的运行时可能不支持动态内存分配。但我们可以创建自己的小堆——不是“堆”,而是“一个堆”——并通过编写几个函数allocatedeallocate来模拟动态内存分配的效果,这些函数保留了一个大静态分配的char数组的一部分,类似于这样:

    static char big_buffer[10000];
    static size_t index = 0;

    void *allocate(size_t bytes) {
      if (bytes > sizeof big_buffer - index) {
        throw std::bad_alloc();
      }
      index += bytes;
      return &big_buffer[index - bytes];
    }

    void deallocate(void *p, size_t bytes) {
      // drop it on the floor
    }

为了使代码尽可能简单,我将deallocate设为无操作。这个小堆允许调用者分配最多 10,000 字节的内存,然后从此开始抛出bad_alloc异常。

通过在代码上投入更多,我们可以允许调用者无限次地分配和释放内存,只要分配的内存总量不超过 10,000 字节,并且只要调用者始终遵循“最后分配的先释放”的协议:

    void deallocate(void *p, size_t bytes) {
      if ((char*)p + bytes == &big_buffer[index]) {
        // aha! we can roll back our index!
        index -= bytes;
      } else {
        // drop it on the floor
      }
    }

这里突出的点是,我们的堆有一些状态(在这种情况下,big_bufferindex),以及一些操作这个状态的函数。我们已经看到了deallocate的两种不同可能的实现——还有其他可能性,有额外的共享状态,不会那么“漏斗”——然而,接口,allocatedeallocate函数签名的本身,却保持不变。这表明我们可以将我们的状态和访问函数包装到一个 C++对象中;广泛的实现可能性加上我们函数签名的恒定性表明,我们可以使用一些经典的多态。

C++17 分配器模型正是如此。标准库提供了一个经典多态的基类定义,std::pmr::memory_resource,然后我们实现我们自己的小堆作为派生类。(在实践中,我们可能会使用标准库提供的派生类之一,但在讨论这些之前,让我们完成我们的小例子。)基类std::pmr::memory_resource在标准头文件<memory_resource>中定义:

    class memory_resource {
      virtual void *do_allocate(size_t bytes, size_t align) = 0;
      virtual void do_deallocate(void *p, size_t bytes, size_t align) = 0;
      virtual bool do_is_equal(const memory_resource& rhs) const = 0;
    public:
      void *allocate(size_t bytes, size_t align) {
        return do_allocate(bytes, align);
      }
      void deallocate(void *p, size_t bytes, size_t align) {
        return do_deallocate(p, bytes, align);
      }
      bool is_equal(const memory_resource& rhs) const {
        return do_is_equal(rhs);
      }
    };

注意到类public接口和virtual实现之间的奇特间接层。通常当我们进行经典的多态时,我们只有一组既是public又是virtual的方法;但在这个例子中,我们有一个public非虚拟接口,它调用到私有的虚拟方法。这种将接口从实现中分离出来的做法带来了一些微妙的好处——例如,它防止任何子类使用“直接调用虚拟方法非虚拟”的语法来调用this->SomeBaseClass::allocate()——但老实说,它对我们来说的主要好处是,当我们定义一个派生类时,我们根本不需要使用public关键字。因为我们只指定了实现,而不是接口,所以我们写的所有代码都可以是private的。这就是我们的微不足道的漏斗堆:

    class example_resource : public std::pmr::memory_resource {
      alignas(std::max_align_t) char big_buffer[10000];
      size_t index = 0;
      void *do_allocate(size_t bytes, size_t align) override {
        if (align > alignof(std::max_align_t) ||
            (-index % align) > sizeof big_buffer - index ||
            bytes > sizeof big_buffer - index - (-index % align))
        {
            throw std::bad_alloc();
        }
        index += (-index % align) + bytes;
        return &big_buffer[index - bytes];
      }
      void do_deallocate(void *, size_t, size_t) override {
        // drop it on the floor
      }
      bool do_is_equal(const memory_resource& rhs) const override {
        return this == &rhs;
      }
    };

注意到标准库的std::pmr::memory_resource::allocate不仅接受字节数,还接受对齐方式。我们需要确保从do_allocate返回的任何指针都适当地对齐;例如,如果我们的调用者计划在我们的提供的内存中存储int,他可能会要求四字节对齐。

关于我们的派生类example_resource的最后一点要注意的是,它代表了由我们的“堆”实际控制的资源;也就是说,它实际上包含、拥有和管理从其中分配内存的big_buffer。对于任何给定的big_buffer,在我们的程序中将有且只有一个example_resource对象来操作该缓冲区。正如我们之前所说的:example_resource类型的对象是“内存资源”,因此它们打算被复制或移动;它们是经典面向对象的,而不是值语义的。

标准库提供了几种内存资源类型,它们都源自 std::pmr::memory_resource。让我们看看其中的一些。

使用标准内存资源

标准库中的内存资源有两种类型。其中一些是实际类类型,你可以创建其实例;还有一些是“匿名”类类型,只能通过单例函数访问。通常,你可以通过思考两个对象是否可能“不同”,或者类型本质上是否是单例来预测它们是哪一种。

<memory_resource> 头文件中最简单的内存资源是通过 std::pmr::null_memory_resource() 访问的“匿名”单例。这个函数的定义可能类似于以下内容:

    class UNKNOWN : public std::pmr::memory_resource {
      void *do_allocate(size_t, size_t) override {
        throw std::bad_alloc();
      }
      void do_deallocate(void *, size_t, size_t) override {}
      bool do_is_equal(const memory_resource& rhs) const override {
        return this == &rhs;
      }
    };

    std::pmr::memory_resource *null_memory_resource() noexcept {
      static UNKNOWN singleton;
      return &singleton;
    }

注意,该函数返回单例实例的指针。通常,std::pmr::memory_resource 对象将通过指针进行操作,因为memory_resource 对象本身无法移动。

null_memory_resource 似乎相当无用;它所做的只是在你尝试从中分配时抛出异常。然而,当你开始使用我们稍后将看到的更复杂的内存资源时,它可能很有用。

下一个最复杂的内存资源是通过 std::pmr::new_delete_resource() 访问的单例;它使用 ::operator new::operator delete 来分配和释放内存。

现在我们来谈谈命名类类型。这些是在单个程序中拥有多个相同类型资源是有意义的资源。例如,有 class std::pmr::monotonic_buffer_resource。这种内存资源基本上与之前我们的 example_resource 相同,除了两点不同:它不是将其大缓冲区作为成员数据(std::array 风格)持有,而是只持有从别处分配的大缓冲区的指针(std::vector 风格)。当其第一个大缓冲区耗尽时,它不会立即开始抛出 bad_alloc 异常,而是会尝试分配第二个大缓冲区,并从这个缓冲区中分配块,直到它全部用完;此时,它将分配第三个大缓冲区……以此类推,直到最终它甚至无法再分配任何大缓冲区。与我们的 example_resource 一样,直到资源对象本身被销毁,所有已释放的内存都不会被释放。有一个有用的出口:如果你调用 a.release() 方法,monotonic_buffer_resource 将释放它当前持有的所有缓冲区,有点像在向量上调用 clear()

当你构造一个 std::pmr::monotonic_buffer_resource 类型的资源时,你需要告诉它两件事:它的第一个大缓冲区在哪里?当该缓冲区耗尽时,它应该向谁请求另一个缓冲区?第一个问题的答案是提供一个 void*, size_t 的参数对,它描述了第一个大缓冲区(可选 nullptr);第二个问题的答案是提供一个指向此资源“上游”资源的 std::pmr::memory_resource*。对于“上游”资源,一个合理的传递方式是 std::pmr::new_delete_resource(),以便使用 ::operator new 分配新的缓冲区。或者,另一个合理的传递方式是 std::pmr::null_memory_resource(),以便对特定资源的内存使用设置一个硬限制。以下是一个后者的示例:

    alignas(16) char big_buffer[10000];

    std::pmr::monotonic_buffer_resource a(
      big_buffer, sizeof big_buffer,
      std::pmr::null_memory_resource()
    );

    void *p1 = a.allocate(100);
    assert(p1 == big_buffer + 0);

    void *p2 = a.allocate(100, 16); // alignment
    assert(p1 == big_buffer + 112);

    // Now clear everything allocated so far and start over.
    a.release();
    void *p3 = a.allocate(100);
    assert(p3 == big_buffer + 0);

    // When the buffer is exhausted, a will go upstream
    // to look for more buffers... and not find any.
    try {
      a.allocate(9901);
    } catch (const std::bad_alloc&) {
      puts("The null_memory_resource did its job!");
    }

如果你忘记了特定的 monotonic_buffer_resource 正在使用哪个上游资源,你可以通过调用 a.upstream_resource() 来找出;该方法返回一个指向提供给构造函数的上游资源的指针。

从资源池中分配

C++17 标准库提供的最后一种内存资源被称为“池资源”。池资源不仅仅管理一个大的缓冲区,例如 example_resource;甚至不是一个单调递增的缓冲区链,例如 monotonic_buffer_resource。相反,它管理各种大小的“块”。给定大小的所有块都存储在“池”中,因此我们可以谈论“大小为 4 的块池”、“大小为 16 的块池”等等。当一个请求到来,需要分配大小为 k 的资源时,池资源会在大小为 k 的块池中查找,取出一个并返回。如果大小为 k 的池为空,那么池资源将尝试从其上游资源中分配更多的块。此外,如果一个请求到来,需要分配的块大小如此之大,以至于我们甚至没有该大小的块池,那么池资源允许直接将请求传递给其上游资源。

池资源有两种类型:同步异步,也就是说,线程安全和线程不安全。如果你将同时从两个不同的线程访问池,那么你应该使用 std::pmr::synchronized_pool_resource,如果你肯定永远不会这样做,并且想要原始速度,那么你应该使用 std::pmr::unsynchronized_pool_resource。(顺便说一下,std::pmr::monotonic_buffer_resource 总是线程不安全的;而 new_delete_resource() 实际上是线程安全的,因为它只是调用 newdelete。)

当你构建一个类型为 std::pmr::synchronized_pool_resource 的资源时,你需要告诉它三件事情:它应该在池中保留哪些块大小;当它从上游资源获取更多块时,应该将多少块组合成一个“块组”;以及它的上游资源是谁。不幸的是,标准接口在这里留下了很多遗憾——如此之多,以至于坦白地说,我建议如果你真正关心这些参数,你应该实现自己的派生 memory_resource,而不要触及标准库的版本。表达这些选项的语法也相当复杂:

    std::pmr::pool_options options;
    options.max_blocks_per_chunk = 100;
    options.largest_required_pool_block = 256;

    std::pmr::synchronized_pool_resource a(
      options,
      std::pmr::new_delete_resource()
    );

注意,无法指定确切的块大小;这留给供应商对 synchronized_pool_resource 的实现。如果你很幸运,它可能会选择适合你用例的合理块大小;但个人来说,我不会依赖这个假设。注意,也无法为不同的块大小使用不同的上游资源,也没有为当调用者请求异常大小的分配时使用的“后备”资源使用不同的上游资源。

简而言之,在可预见的未来,我会避开内置的 pool_resource 派生类。但从 memory_resource 派生自己的类的根本思想是稳固的。如果你担心内存分配和管理你自己的小堆,我建议将 memory_resource 纳入你的代码库。

现在,到目前为止,我们一直在谈论各种分配策略,这些策略由不同的 memory_resource 派生类“体现”。我们仍然需要看看如何将 memory_resource 集成到标准模板库的算法和容器中。为此,我们必须从 memory_resource 的经典多态世界过渡到 C++03 STL 的值语义世界。

标准分配器的 500 顶帽子

标准分配器模型在 2011 年看起来很神奇。我们将看到,仅使用一种 C++ 类型,我们就可以完成以下所有任务:

  • 指定用于分配内存的内存资源。

  • 在每个分配的指针上注解一些将随指针一起传递的元数据

    在其整个生命周期内,一直到释放时间。

  • 将一个容器对象与特定的内存资源关联起来,并确保

    这种关联是“粘性的”——这个容器对象将始终使用给定的

    为其分配使用堆。

  • 将一个容器 与特定的内存资源关联起来,这意味着

    容器可以使用值语义高效地移动,而无需

    忘记如何释放其内容。

  • 在上述两种互斥行为之间进行选择。

  • 在多级结构的所有级别上指定分配内存的策略

    容器,例如向量中的向量。

  • 重新定义“构造”容器内容的意义,以便

    例如,vector<int>::resize 可以被定义为对新元素进行默认初始化,而不是零初始化。

这对于任何单个类类型来说都是一个疯狂的帽子数量——这是对单一责任原则的严重违反。尽管如此,这正是标准分配器模型所做的事情;所以让我们尝试解释所有这些特性。

记住,“标准分配器”只是任何满足某些类型 T 的概念 Allocator<T> 的类类型。标准库提供了三种标准分配器类型:std::allocator<T>std::pmr::polymorphic_allocator<T>std::scoped_allocator_adaptor<A...>

让我们先看看 std::allocator<T>

    template<class T>
    struct allocator {
      using value_type = T;

      T *allocate(size_t n) {
        return static_cast<T *>(::operator new(n * sizeof (T)));
      }
      void deallocate(T *p, size_t) {
        ::operator delete(static_cast<void *>(p));
      }

      // NOTE 1
      template<class U>
      explicit allocator(const allocator<U>&) noexcept {}

      // NOTE 2
      allocator() = default;
      allocator(const allocator&) = default;
    };

std::allocator<T>allocatedeallocate 成员函数,这些函数是 Allocator<T> 概念所要求的。记住,我们现在处于基于概念泛型编程的世界!经典的多态 memory_resource同样有名为 allocatedeallocate 的成员函数,但它们总是返回 void*,而不是 T*。(此外,memory_resource::allocate() 接受两个参数——bytesalign——而 allocator<T>::allocate() 只接受一个参数。第一个原因是 allocator<T> 产生于对对齐重要性的主流理解之前;记住,sizeof 操作符是从 20 世纪 80 年代的 C 语言继承而来的,但 alignof 操作符只出现在 C++11 中。第二个原因是,在 std::allocator<T> 的上下文中,我们知道正在分配的对象类型是 T,因此请求的对齐必须是 alignof(T)std::allocator<T> 不使用这个信息,因为它早于 alignof;但原则上它可以,这就是为什么 Allocator<T> 概念只要求 a.allocate(n) 的签名,而不是 a.allocate(n, align) 的原因。)

标记为 NOTE 1 的构造函数很重要;每个分配器都需要一个模板构造函数,其模式与此类似。标记为 NOTE 2 的后续构造函数并不重要;我们之所以在代码中明确写出它们,仅仅是因为如果我们没有写出它们,由于存在用户定义的构造函数(即 NOTE 1 构造函数),它们将被隐式删除。

任何标准分配器的想法是,我们可以将其作为任何标准容器(第四章,容器动物园)的最后一个模板类型参数插入,然后容器将在需要为任何原因分配内存时使用该分配器而不是其通常的机制。让我们看一个例子:

    template<class T>
    struct helloworld {
      using value_type = T;

      T *allocate(size_t n) {
        printf("hello world %zu\n", n);
        return static_cast<T *>(::operator new(n * sizeof (T)));
      }
      void deallocate(T *p, size_t) {
        ::operator delete(static_cast<void *>(p));
      }
    };

    void test() {
      std::vector<int, helloworld<int>> v;
      v.push_back(42); // prints "hello world 1"
      v.push_back(42); // prints "hello world 2"
      v.push_back(42); // prints "hello world 4"
    }

在这里,我们的类 helloworld<int> 模拟 Allocator<int>;但我们省略了模板构造函数。如果我们只处理 vector,这是可以的,因为 vector 只会为其元素类型分配数组。然而,如果我们改变测试用例以使用 list 代替,看看会发生什么:

    void test() {
      std::list<int, helloworld<int>> v;
      v.push_back(42);
    }

在 libc++ 下,这段代码会输出几十行错误信息,归结为基本抱怨:“没有已知从 helloworld<int> 转换到 helloworld<std::__1::__list_node<int, void *>> 的转换。”回想一下 第四章 中的图,“容器动物园”,std::list<T> 存储其元素在比 T 本身更大的节点中。因此,std::list<T> 不打算尝试分配任何 T 对象;它想要分配 __list_node 类型的对象。为了为 __list_node 对象分配内存,它需要一个模拟 Allocator<__list_node> 概念的分配器,而不是 Allocator<int>

在内部,std::list<int> 的构造函数尝试将我们的 helloworld<int> “重新绑定”为分配 __list_node 对象而不是 int 对象。这是通过一个 特性类-- 一个我们在 第二章,“迭代器和范围”中首次遇到的 C++ 习语来实现的:

    using AllocOfInt = helloworld<int>;

    using AllocOfChar =
      std::allocator_traits<AllocOfInt>::rebind_alloc<char>;

    // Now alloc_of_char is helloworld<char>

标准类模板 std::allocator_traits<A> 将关于分配器类型 A 的许多信息封装在一个地方,因此很容易访问。例如,std::allocator_traits<A>::value_type 是一个别名,表示由 A 分配的内存的类型 T;而 std::allocator_traits<A>::pointer 是对应指针类型的别名(通常是 T*)。

嵌套别名模板 std::allocator_traits<A>::rebind_alloc<U> 是一种将分配器从一种类型 T 转换为另一种类型 U 的方法。这种类型特性使用元编程来打开类型 A 并查看:首先,A 是否有一个嵌套模板别名 A::rebind<U>::other(这种情况很少见),其次,类型 A 是否可以表示为 Foo<Bar,Baz...> 的形式(其中 Baz... 是一些类型列表,可能是一个空列表)。如果 A 可以以这种方式表示,那么 std::allocator_traits<A>::rebind_alloc<U> 将是 Foo<U,Baz...> 的同义词。从哲学上讲,这是完全任意的;但在实践中,它适用于你将看到的每个分配器类型。特别是,它适用于 helloworld<int>--这也解释了为什么我们不需要在我们的 helloworld 类中提供嵌套别名 rebind<U>::other。通过提供合理的默认行为,std::allocator_traits 模板为我们节省了一些样板代码。这就是 std::allocator_traits 存在的原因。

你可能会想知道为什么 std::allocator_traits<Foo<Bar,Baz...>>::value_type 不默认为 Bar。坦白说,我也不知道。这似乎是一个显而易见的事情;但标准库没有这样做。因此,你必须为每个你编写的分配器类型(记住我们现在在谈论模拟 Allocator<T> 的类,而不是从 memory_resource 派生的类)提供一个嵌套 typedef value_type,它是一个 T 的别名。

然而,一旦你为 value_type 定义了嵌套类型别名,你就可以依赖 std::allocator_traits 来推断其嵌套类型别名 pointer(即 T*)、const_pointer(即 const T*)、void_pointer(即 void*)等的正确定义。如果你跟随了之前关于 rebind_alloc 的讨论,你可能会猜测将指针类型如 T* 转换为 void* 与将分配器类型 Foo<T> 转换为 Foo<void> 一样困难或容易;你是对的!这些指针相关类型别名的值都是通过第二个标准特性类 std::pointer_traits<P> 计算得出的:

    using PtrToInt = int*;

    using PtrToChar =
      std::pointer_traits<PtrToInt>::rebind<char>;

    // Now PtrToChar is char*

    using PtrToConstVoid =
      std::pointer_traits<PtrToInt>::rebind<const void>;

    // Now PtrToConstVoid is const void*

当我们讨论 Allocator<T> 的下一个职责时,这个特性类变得非常重要,即“为每个分配的指针添加一些将在其整个生命周期中携带的元数据。”

与花哨指针一起携带元数据

考虑以下内存资源的高级设计,这应该会让你非常想起 std::pmr::monotonic_buffer_resource

  • 维护一个我们从系统获取的内存块的列表。对于每个块,还存储从块开始分配的字节数的 index;并存储从该特定块中已释放的字节数的计数 freed

  • 当有人调用 allocate(n) 时,如果可能,增加我们任何一个块的 index 以适当的字节数,或者在绝对必要时从上游资源获取一个新的块。

  • 当有人调用 deallocate(p, n) 时,找出 p 来自我们的哪个块,并增加其 freed += n。如果 freed == index,则整个块为空,因此将 freed = index = 0

将上述描述转换为代码相当直接。唯一的问题在于:在 deallocate(p, n) 中,我们如何确定 p 来自我们的哪个块?

如果我们只是在“指针”本身中记录块的标识符,这将很容易:

    template<class T>
    class ChunkyPtr {
      T *m_ptr = nullptr;
      Chunk *m_chunk = nullptr;
    public:
      explicit ChunkyPtr(T *p, Chunk *ch) :
      m_ptr(p), m_chunk(ch) {}

      T& operator *() const {
        return *m_ptr;
      }
      explicit operator T *() const {
        return m_ptr;
      }
      // ... and so on ...

      // ... plus this extra accessor:
      auto chunk() const {
        return m_chunk;
      }
    };

然后在我们的 deallocate(p, n) 函数中,我们只需查看 p.chunk()。但要使这生效,我们需要更改 allocate(n)deallocate(p, n) 函数的签名,使 deallocate 接受 ChunkyPtr<T> 而不是 T*,并且 allocate 返回 ChunkyPtr<T> 而不是 T*

幸运的是,C++ 标准库为我们提供了一种方法来做这件事!我们只需要定义自己的类型来模拟 Allocator<T>,并给它一个成员类型别名 pointer,其值为 ChunkyPtr<T>

    template<class T>
    struct ChunkyAllocator {
      using value_type = T;
      using pointer = ChunkyPtr<T>;

      ChunkyAllocator(ChunkyMemoryResource *mr) :
        m_resource(mr) {}

      template<class U>
      ChunkyAllocator(const ChunkyAllocator<U>& rhs) :
        m_resource(rhs.m_resource) {}

      pointer allocate(size_t n) {
        return m_resource->allocate(
          n * sizeof(T), alignof(T));
      } 
      void deallocate(pointer p, size_t n) {
        m_resource->deallocate(
          p, n * sizeof(T), alignof(T));
      }
    private:
      ChunkyMemoryResource *m_resource;

      template<class U>
      friend struct ChunkyAllocator;
    };

特性类 std::allocator_traitsstd::pointer_traits 将会负责推断其他类型别名--例如 void_pointer,它通过 pointer_traits::rebind 的魔法最终会成为 ChunkyPtr<void> 的别名。

我在这里省略了 allocatedeallocate 函数的实现,因为它们将依赖于 ChunkyMemoryResource 的接口。我们可能会像这样实现 ChunkyMemoryResource

    class Chunk {
      char buffer[10000];
      int index = 0;
      int freed = 0;
    public:
      bool can_allocate(size_t bytes) {
        return (sizeof buffer - index) >= bytes;
      }
      auto allocate(size_t bytes) {
        index += bytes;
        void *p = &buffer[index - bytes];
        return ChunkyPtr<void>(p, this);
      }
      void deallocate(void *, size_t bytes) {
        freed += bytes;
        if (freed == index) {
            index = freed = 0;
        }
      }
    };

    class ChunkyMemoryResource {
      std::list<Chunk> m_chunks;
    public:
      ChunkyPtr<void> allocate(size_t bytes, size_t align) {
        assert(align <= alignof(std::max_align_t));
        bytes += -bytes % alignof(std::max_align_t);
        assert(bytes <= 10000);

        for (auto&& ch : m_chunks) {
          if (ch.can_allocate(bytes)) {
            return ch.allocate(bytes);
          }
        }
        return m_chunks.emplace_back().allocate(bytes);
      }
      void deallocate(ChunkyPtr<void> p, size_t bytes, size_t) {
        bytes += -bytes % alignof(std::max_align_t);
        p.chunk()->deallocate(static_cast<void*>(p), bytes);
      }
    };

现在我们可以使用我们的ChunkyMemoryResource为像这样的标准分配器感知容器分配内存:

    ChunkyMemoryResource mr;
    std::vector<int, ChunkyAllocator<int>> v{&mr};
    v.push_back(42);
    // All the memory for v's underlying array
    // is coming from blocks owned by "mr".

现在,我选择这个例子是为了让它看起来非常简单直接;并且我省略了ChunkyPtr<T>类型本身的许多细节。如果你尝试自己复制这段代码,你会发现你需要为ChunkyPtr提供许多重载运算符,例如==, !=, <, ++, --, 和-;你还需要为ChunkyPtr<void>提供一个特化,该特化省略了重载的operator*。大部分的细节与我们在第二章,“迭代器和范围”,当我们实现自己的迭代器类型时所覆盖的内容相同。实际上,每个“花哨指针”类型都必须能够作为随机访问迭代器使用——这意味着你必须提供第二章,“迭代器和范围”末尾列出的五个嵌套 typedef:iterator_category, difference_type, value_type, pointer, 和reference

最后,如果你想使用某些容器,例如std::liststd::map,你需要实现一个名为pointer_to(r)的静态成员函数:

    static ChunkyPtr<T> pointer_to(T &r) noexcept {
      return ChunkyPtr<T>(&r, nullptr);
    }

这是因为——正如你可能从第四章,“容器动物园”中回忆起来——一些容器,例如std::list,将它们的数据存储在节点中,这些节点的prevnext指针需要能够指向任意一个已分配的节点或者指向包含在std::list对象本身成员数据中的节点。有两种明显的方法可以实现这一点:要么每个next指针都必须存储在一个带有花哨指针和原始指针(可能是一个std::variant,如第五章,“词汇类型”中描述的)的标记联合体中,要么我们必须找到一种方法将原始指针编码为花哨指针。标准库选择了后者。所以,每当您编写一个花哨指针类型时,它不仅必须完成分配器要求的所有事情,而且它必须满足随机访问迭代器的需求,而且它还必须也有一种表示程序地址空间中任何任意指针的方法——至少如果您想使用您的分配器与基于节点的容器,如std::list

即使跳过了所有这些障碍,你也会发现(截至出版时间),libc++和 libstdc++都无法处理比std::vector更复杂的任何容器中的花哨指针。它们只支持足够的操作与单个花哨指针类型一起工作——boost::interprocess::offset_ptr<T>,它不携带元数据。而且标准仍在不断发展;std::pmr::memory_resource是在 C++17 中新引入的,截至本文撰写时,它还没有被 libc++和 libstdc++实现。

你可能也注意到了缺少任何使用花哨指针的内存资源的标准基类。幸运的是,这很容易自己编写:

    namespace my {

      template<class VoidPtr>
      class fancy_memory_resource {
      public:
        VoidPtr allocate(size_t bytes,
          size_t align = alignof(std::max_align_t)) {
          return do_allocate(bytes, align);
        }
        void deallocate(VoidPtr p, size_t bytes,
          size_t align = alignof(std::max_align_t)) {
          return do_deallocate(p, bytes, align);
        }
        bool is_equal(const fancy_memory_resource& rhs) const noexcept {
          return do_is_equal(rhs);
        }
        virtual ~fancy_memory_resource() = default;
      private:
        virtual VoidPtr do_allocate(size_t bytes, size_t align) = 0;
        virtual void do_deallocate(VoidPtr p, size_t bytes,
          size_t align) = 0;
        virtual bool do_is_equal(const fancy_memory_resource& rhs)
          const noexcept = 0;
      };

      using memory_resource = fancy_memory_resource<void*>;

    } // namespace my

标准库不提供使用花哨指针的分配器;每个库提供的分配器类型都使用原始指针。

将容器固定到单个内存资源上

标准分配器模型戴上的下一个帽子——由std::allocator_traits控制的下一个特性——是能够将特定的容器对象与特定的堆关联起来。我们之前用三个项目符号描述了这一特性:

  • 将容器对象与特定的内存资源关联起来,并确保

    这种关联是“粘性的”——这个容器对象将始终使用给定的

    使用堆进行分配

  • 将容器与特定的内存资源关联起来,意味着

    容器可以使用值语义有效地移动,而无需

    忘记如何释放其内容

  • 在上述两种互斥行为之间进行选择。

让我们看看一个例子,使用std::pmr::monotonic_buffer_resource作为我们的资源,但使用手写的类类型作为我们的分配器类型。(只是为了让你放心,你确实没有错过任何东西:实际上,我们仍然没有涵盖任何标准库提供的分配器类型——除了std::allocator<T>,这是一个平凡的无状态分配器,它是newdelete管理的全局堆的句柄。)

    template<class T>
    struct WidgetAlloc {
      std::pmr::memory_resource *mr;

      using value_type = T;

      WidgetAlloc(std::pmr::memory_resource *mr) : mr(mr) {}

      template<class U>
      WidgetAlloc(const WidgetAlloc<U>& rhs) : mr(rhs.mr) {}

      T *allocate(size_t n) {
        return (T *)mr->allocate(n * sizeof(T), alignof(T));
      }
      void deallocate(void *p, size_t n) {
        mr->deallocate(p, n * sizeof(T), alignof(T));
      }
    };

    class Widget {
      char buffer[10000];
      std::pmr::monotonic_buffer_resource mr {buffer, sizeof buffer};
      std::vector<int, WidgetAlloc<int>> v {&mr};
      std::list<int, WidgetAlloc<int>> lst {&mr};
    public:
      static void swap_elems(Widget& a, Widget& b) {
        std::swap(a.v, b.v);
      }
    };

在这里,我们的Widget是一个经典的面向对象类类型;我们期望它在整个生命周期中存在于特定的内存地址。然后,为了减少堆碎片或提高缓存局部性,我们在每个Widget对象内部放置了一个大缓冲区,并使Widget使用该缓冲区作为其数据成员vlst的后备存储。

现在看看Widget::swap_elems(a, b)函数。它交换了Widget aWidget bv数据成员。你可能还记得第四章,“容器动物园”,其中std::vector不过是一个指向动态分配数组的指针,因此通常库可以通过简单地交换它们的底层指针来交换两个std::vector实例,而不需要移动任何底层数据——使得向量交换成为 O(1)操作而不是 O(n)操作。

此外,vector足够智能,知道如果它交换指针,它还需要交换分配器——这样关于如何释放的信息就会随着最终需要释放的指针一起传递。

但在这种情况下,如果库只是交换了指针和分配器,那将是一场灾难!我们会有一个向量 a.v,其底层数组现在“属于”b.mr,反之亦然。如果我们销毁 Widget b,那么下次我们访问 a.v 的元素时,我们将访问已释放的内存。而且更进一步,即使我们以后再也不访问 a.v,当 a.v 的析构函数尝试调用早已死亡的 b.mrdeallocate 方法时,我们的程序很可能会崩溃!

幸运的是,标准库救了我们于水火。一个分配器感知容器的一个责任是在复制赋值、移动赋值和交换时适当地传播其分配器。由于历史原因,这由 allocator_traits 类模板中的大量 typedef 处理,但为了正确使用分配器传播,你只需要知道几件事情:

  • 分配器是否传播自身,或者是否坚定地粘附在特定的容器上,是分配器类型的一个属性。如果你想使一个分配器“粘性”而另一个分配器传播,你必须使它们成为不同的类型。

  • 当一个分配器“粘性”时,它会粘附在特定的(经典、面向对象的)

    容器对象。在非粘性分配器类型下原本是 O(1) 指针交换的操作可能会变成 O(n),因为“采用”来自某个其他分配器内存空间中的元素到我们自己的内存空间中,需要在我们自己的内存空间中为它们分配空间。

  • 粘性有明确的用例(正如我们刚刚用 Widget 展示的那样),并且

    非粘性的影响可能是灾难性的(再次,参见 Widget)。因此,std::allocator_traits 默认假设分配器类型是粘性的,除非它能判断出分配器类型是空的,因此绝对是无状态的。对于空的分配器类型,默认实际上是粘性。

  • 作为程序员,你基本上总是想要默认状态:无状态的分配器可以传播,而状态性的分配器可能在需要粘性的 Widget 类似场景之外没有太多用途。

使用标准分配器类型

让我们谈谈标准库提供的分配器类型。

std::allocator<T> 是默认的分配器类型;它是每个标准容器模板类型参数的默认值。所以例如,当你代码中写 std::vector<T> 时,这实际上是 std::vector<T, std::allocator<T>> 的完全相同类型。正如我们本章前面提到的,std::allocator<T> 是一个无状态的空类型;它是 newdelete 管理的全局堆的“句柄”。由于 std::allocator 是一个无状态类型,allocator_traits 假定(正确地)它应该是非粘性的。这意味着操作如 std::vector<T>::swapstd::vector<T>::operator= 保证是非常高效的指针交换操作——因为任何 std::vector<T, std::allocator<T>> 类型的对象总是知道如何释放由任何其他 std::vector<T, std::allocator<T>> 分配的内存。

std::pmr::polymorphic_allocator<T> 是 C++17 中新增的一个类型。它是一个有状态的、非空类型;它有一个数据成员,是一个指向 std::pmr::memory_resource 的指针。(实际上,它与本章早期示例中的 WidgetAlloc 几乎相同!)两个不同的 std::pmr::polymorphic_allocator<T> 实例不一定可以互换,因为它们的指针可能指向完全不同的 memory_resource;这意味着 std::vector<T, std::pmr::polymorphic_allocator<T>> 类型的对象不一定知道如何释放由其他 std::vector<T, std::pmr::polymorphic_allocator<T>> 分配的内存。这反过来意味着 std::pmr::polymorphic_allocator<T> 是一个“粘性”分配器类型;这意味着操作如 std::vector<T, std::pmr::polymorphic_allocator<T>>::operator= 可能会导致大量的复制。

顺便说一下,反复写出 std::vector<T, std::pmr::polymorphic_allocator<T>> 这个类型名称相当繁琐。幸运的是,标准库实现者得出了相同的认识,因此标准库在 std::pmr 命名空间中提供了类型别名:

    namespace std::pmr {

      template<class T>
      using vector = std::vector<T,
        polymorphic_allocator<T>>;

      template<class K, class V, class Cmp = std::less<K>>
      using map = std::map<K, V, Cmp,
        polymorphic_allocator<typename std::map<K, V>::value_type>>;

      // ...

    } // namespace std::pmr

设置默认内存资源

标准库中的 polymorphic_allocator 与我们的示例 WidgetAlloc 之间最大的区别是 polymorphic_allocator 可以默认构造。默认构造性是一个分配器的有吸引力的特性;这意味着我们可以写出这两行中的第二行而不是第一行:

    std::pmr::vector<int> v2({1, 2, 3}, std::pmr::new_delete_resource());
        // Specifying a specific memory resource

    std::pmr::vector<int> v1 = {1, 2, 3};
        // Using the default memory resource

另一方面,当你看到第二行时,你可能会想,“底层数组实际上是在哪里被分配的?”毕竟,指定分配器的关键点是我们想知道我们的字节是从哪里来的!这就是为什么构建标准polymorphic_allocator正常方式是传递一个指向memory_resource的指针——实际上,这个习惯用法预计会非常常见,以至于从std::pmr::memory_resource*std::pmr::polymorphic_allocator的转换是一个隐式转换。但是polymorphic_allocator也有一个默认的无参数构造函数。当你默认构造一个polymorphic_allocator时,你得到一个指向“默认内存资源”的句柄,默认情况下是new_delete_resource()。然而,你可以改变这个!默认内存资源指针存储在一个全局原子(线程安全)变量中,可以使用库函数std::pmr::get_default_resource()(返回指针)和std::pmr::set_default_resource()(将新值赋给指针并返回旧值)来操作。

如果你完全想避免通过newdelete进行堆分配,那么在程序开始时调用std::pmr::set_default_resource(std::pmr::null_memory_resource())可能是有意义的。当然,你无法阻止程序的其他部分变得混乱并自行调用set_default_resource;并且由于相同的全局变量被程序中的每个线程共享,如果在程序执行期间尝试修改默认资源,你可能会遇到一些非常奇怪的行为。例如,无法说“只为我的当前线程设置默认资源”。此外,调用get_default_resource()(例如从polymorphic_allocator的默认构造函数中)执行原子访问,这通常会比如果可以避免原子访问而稍微慢一些。因此,你最好的行动方案是避免polymorphic_allocator的默认构造函数;始终明确你正在尝试使用哪种内存资源。为了绝对的安全,你可能考虑简单地使用上述WidgetAlloc而不是polymorphic_allocator;由于WidgetAlloc没有默认构造函数,它根本不可能被误用。

使容器具有分配器意识

在覆盖了内存资源(堆)和分配器(堆的句柄)之后,现在让我们转向三脚架的第三条腿:容器类。在每一个具有分配器意识的容器内部,至少必须发生以下四件事情:

  • 容器实例必须将分配器实例作为成员数据存储。(因此,容器必须将分配器的类型作为模板参数;否则,它不知道为该成员变量预留多少空间。)

  • 容器必须提供接受分配器参数的构造函数。

  • 容器实际上必须使用其分配器来分配和释放内存;任何使用newdelete的操作都必须被禁止。

  • 容器的移动构造函数、移动赋值运算符和swap函数都必须根据其allocator_traits传播分配器。

这里有一个非常简单的感知分配器的容器——一个只包含一个对象的容器,在堆上分配。这类似于第六章中智能指针的分配器感知版本std::unique_ptr<T>

    template<class T, class A = std::allocator<T>>
    class uniqueish {
      using Traits = std::allocator_traits<A>;
      using FancyPtr = typename Traits::pointer;

      A m_allocator;
      FancyPtr m_ptr = nullptr;

    public:
      using allocator_type = A;

      uniqueish(A a = {}) : m_allocator(a) {
        this->emplace();
      }

      ~uniqueish() {
        clear();
      }

      T& value() { return *m_ptr; }
      const T& value() const { return *m_ptr; }

      template<class... Args>
      void emplace(Args&&... args) {
        clear();
        m_ptr = Traits::allocate(m_allocator, 1);
        try {
          T *raw_ptr = static_cast<T *>(m_ptr);
          Traits::construct(m_allocator, raw_ptr,
              std::forward<Args>(args)...
          );
        } catch (...) {
          Traits::deallocate(m_allocator, m_ptr, 1);
          throw;
        }
      }

      void clear() noexcept {
        if (m_ptr) {
          T *raw_ptr = static_cast<T *>(m_ptr);
          Traits::destroy(m_allocator, raw_ptr);
          Traits::deallocate(m_allocator, m_ptr, 1);
          m_ptr = nullptr;
        }
      }
    };

注意,unique_ptr使用T*的地方,我们当前的代码使用allocator_traits<A>::pointer;而make_unique使用newdelete的地方,我们当前的代码使用allocator_traits<A>::allocate/constructallocator_traits<A>::destroy/deallocate的一击两式。我们已经讨论了allocatedeallocate的目的——它们处理从适当的内存资源获取内存。但是,这些内存块只是原始的字节;为了将一块内存转换成一个可用的对象,我们必须在那个地址构造一个T的实例。我们可以使用“placement new”语法来达到这个目的;但我们将看到在下一节中为什么使用constructdestroy是重要的。

最后,在我们继续之前,请注意uniqueish析构函数在尝试释放分配之前会检查是否存在分配。这很重要,因为它给我们一个代表“空对象”的uniqueish值——一个可以在不分配任何内存的情况下构造的值,并且是我们类型的一个合适的“移动后”表示。

现在我们来实现我们类型的移动操作。我们希望确保在从uniqueish<T>对象中移动之后,移动后的对象是“空的”。此外,如果左侧对象和右侧对象使用相同的分配器,或者如果分配器类型是“非粘性的”,那么我们希望根本不调用T的移动构造函数——我们希望将分配的指针的所有权从右侧对象转移到左侧对象:

    uniqueish(uniqueish&& rhs) : m_allocator(rhs.m_allocator) 
    {
      m_ptr = std::exchange(rhs.m_ptr, nullptr);
    }

    uniqueish& operator=(uniqueish&& rhs)
    {
      constexpr bool pocma =
        Traits::propagate_on_container_move_assignment::value;
      if constexpr (pocma) {
        // We can adopt the new allocator, since
        // our allocator type is not "sticky".
        this->clear(); // using the old allocator
        this->m_allocator = rhs.m_allocator;
        this->m_ptr = std::exchange(rhs.m_ptr, nullptr);
      } else if (m_allocator() == rhs.m_allocator()) {
        // Our allocator is "stuck" to this container;
        // but since it's equivalent to rhs's allocator,
        // we can still adopt rhs's memory.
        this->clear();
        this->m_ptr = std::exchange(rhs.m_ptr, nullptr);
      } else {
        // We must not propagate this new allocator
        // and thus cannot adopt its memory.
        if (rhs.m_ptr) {
          this->emplace(std::move(rhs.value()));
          rhs.clear();
        } else {
          this->clear();
        }
      }
      return *this;
    }

移动构造函数就像它曾经一样简单。唯一的细微差别是我们必须记住将我们的m_allocator构造为右侧对象分配器的副本。

我们可以使用std::move来移动分配器而不是复制它,但我觉得在这个例子中这样做不值得。记住,分配器只是一个指向实际内存资源的薄“句柄”,并且许多分配器类型,如std::allocator<T>,实际上都是空的。复制分配器类型应该总是相对便宜的。尽管如此,在这里使用std::move并不会造成伤害。

另一方面,移动 赋值运算符 非常复杂!我们首先需要做的是检查我们的分配器类型是否是“粘性”的。非粘性通过 propagate_on_container_move_assignment::value 的真值表示,我们将其缩写为 "pocma"。(实际上,标准说 propagate_on_container_move_assignment 应该是 std::true_type 类型;GNU 的 libstdc++ 会严格遵循这一要求。所以当定义自己的分配器类型时要小心。)如果分配器类型是非粘性的,那么我们移动赋值的最高效做法是销毁我们的当前值(如果有的话)——确保使用我们的旧 m_allocator——然后采用右手对象的指针及其分配器。因为我们同时采用指针和分配器,我们可以确信将来我们会知道如何释放那个指针。

另一方面,如果我们的分配器类型 “粘性”的,那么我们就不能采用右手对象的分配器。如果我们的当前(“卡住”)的分配器实例恰好等于右手对象的分配器实例,那么我们无论如何都可以采用右手对象的指针;我们已经知道如何处理由这个特定分配器实例分配的指针。

最后,如果我们不能采用右手对象的分配器实例,并且我们的当前分配器实例不等于右手对象的,那么我们就不能采用右手对象的指针——因为将来某个时候我们得释放那个指针,而唯一释放那个指针的方法是使用右手对象的分配器实例,但我们不允许采用右手对象的分配器实例,因为我们的实例是“卡住”的。在这种情况下,我们实际上必须使用自己的分配器实例分配一个全新的指针,然后通过调用 T 的移动构造函数将数据从 rhs.value() 复制到我们的值。这种情况是唯一一个我们实际上调用 T 的移动构造函数的情况!

复制赋值在传播右手分配器实例的逻辑上遵循类似的逻辑,除了它查看特性 propagate_on_container_copy_assignment,或称为 "pocca"。

交换特别有趣,因为它的最终情况(当分配器类型是“粘性”且分配器实例不相等时)需要额外的分配:

    void swap(uniqueish& rhs) noexcept {
      constexpr bool pocs =
        Traits::propagate_on_container_swap::value;
      using std::swap;
      if constexpr (pocs) {
        // We can swap allocators, since
        // our allocator type is not "sticky".
        swap(this->m_allocator, rhs.m_allocator);
        swap(this->m_ptr, rhs.m_ptr);
      } else if (m_allocator == rhs.m_allocator) {
        // Our allocator is "stuck" to this container;
        // but since it's equivalent to rhs's allocator,
        // we can still adopt rhs's memory and vice versa.
        swap(this->m_ptr, rhs.m_ptr);
      } else {
        // Neither side can adopt the other's memory, and
        // so one side or the other must allocate.
        auto temp = std::move(*this);
        *this = std::move(rhs); // might throw
        rhs = std::move(temp); // might throw
      }
    }

在标记为“可能抛出异常”的两行中,我们正在调用移动赋值运算符,在这种情况下可能会调用emplace,这将要求分配器分配内存。如果底层内存资源已经耗尽,那么Traits::allocate(m_allocator, 1)可能会抛出异常--然后我们就会遇到麻烦,原因有两个。首先,我们已经开始移动状态并释放旧内存,我们可能发现无法“回滚”到一个合理的状态。其次,更重要的是,swap是那些非常基础和原始的函数之一,标准库没有为其失败提供任何处理--例如,std::swap算法(第三章,迭代器对算法)被声明为noexcept,这意味着它必须成功;它不允许抛出异常。

因此,如果在我们的noexcept交换函数中发生分配失败,我们将在调用栈中看到bad_alloc异常逐层上升,直到它达到我们的noexcept交换函数声明;此时,C++运行时会停止回滚并调用std::terminate,除非程序员通过std::set_terminate更改其行为,否则这将导致我们的程序崩溃并终止。

C++17 标准在规范标准容器类型交换过程中应该发生的事情方面比这更进一步。首先,标准不是说明swap过程中的分配失败将导致调用std::terminate,而是简单地说明swap过程中的分配失败将导致未定义行为。其次,标准并没有将这种未定义行为限制在分配失败上!根据 C++17 标准,仅仅对任何分配器不平等比较的标准库容器实例调用swap将导致未定义行为,无论是否遇到分配失败!

事实上,libc++利用这个优化机会为所有标准容器swap函数生成代码,其大致形式如下:

    void swap(uniqueish& rhs) noexcept {
      constexpr bool pocs =
        Traits::propagate_on_container_swap::value;
      using std::swap;
      if constexpr (pocs) {
        swap(this->m_allocator, rhs.m_allocator);
      }
      // Don't even check that we know how to free
      // the adopted pointer; just assume that we can.
      swap(this->m_ptr, rhs.m_ptr);
    }

注意,如果你使用这个代码(如 libc++所做)来交换具有不等价分配器的两个容器,你最终会在指针和它们的分配器之间出现不匹配,然后你的程序可能会崩溃--或者更糟--在你下次尝试使用不匹配的分配器释放这些指针时。在处理 C++17 的“便利”类型,如std::pmr::vector时,记住这个陷阱至关重要!

    char buffer[100];
    auto mr = std::pmr::monotonic_buffer_resource(buffer, 100);

    std::pmr::vector<int> a {1,2,3};
    std::pmr::vector<int> b({4,5,6}, &mr);

    std::swap(a, b);
      // UNDEFINED BEHAVIOR

    a.reserve(a.capacity() + 1);
      // this line will undoubtedly crash, as
      // it tries to delete[] a stack pointer

如果你的代码设计允许不同内存资源支持的容器之间相互交换,那么你必须避免使用std::swap,而应使用这个安全的惯用语:

    auto temp = std::move(a); // OK
    a = std::move(b); // OK
    b = std::move(temp); // OK

当我说“避免std::swap”时,我的意思是“避免 STL 中的任何排列算法”,包括像std::reversestd::sort这样的算法。这将是一项相当大的工作,我不建议尝试这样做!

如果你的代码设计允许不同内存资源支持的容器之间可以互换,那么实际上,你可能真的需要重新考虑你的设计。如果你能够修复它,使得你只能交换共享相同内存资源的容器,或者如果你可以完全避免有状态的和/或粘性的分配器,那么你就永远不需要考虑这个特定的陷阱。

通过 scoped_allocator_adaptor 向下传播

在前面的章节中,我们介绍了std::allocator_traits<A>::construct(a, ptr, args...),并将其描述为比 placement-new语法::new ((void*)ptr) T(args...)更可取的替代方案。现在我们将看到为什么某个特定分配器的作者可能希望给它不同的语义。

改变我们自己的分配器类型construct的语义的一个可能明显的方法是,对于原始类型,使其以默认方式初始化,而不是零初始化。代码看起来会是这样:

    template<class T>
    struct my_allocator : std::allocator<T> 
    {
      my_allocator() = default;

      template<class U>
      my_allocator(const my_allocator<U>&) {}

      template<class... Args>
      void construct(T *p, Args&&... args) {
        if (sizeof...(Args) == 0) {
          ::new ((void*)p) T;
        } else {
          ::new ((void*)p) T(std::forward<Args>(args)...);
        }
      }
    };

现在,你可以使用std::vector<int, my_allocator<int>>作为一个“类似向量”的类型,满足std::vector<int>的所有常用不变性,除了当你通过v.resize(n)v.emplace_back()隐式创建新元素时,新元素是未初始化的,就像栈变量一样,而不是被零初始化。

在某种意义上,我们在这里设计的是一个“适配器”,它覆盖在std::allocator<T>之上,并以一种有趣的方式修改其行为。如果我们能够以同样的方式修改或“适配”任何任意的分配器那就更好了;为了做到这一点,我们只需将我们的template<class T>更改为template<class A>,并在旧代码继承自std::allocator<T>的地方从A继承。当然,我们新的适配器的模板参数列表不再以T开头,因此我们不得不自己实现rebind;这条路径很快就会进入深层次的元编程,所以我就不展开说明了。

然而,我们还可以用另一种有用的方法来调整我们自己的分配器类型的construct方法。考虑以下代码示例,它创建了一个int类型的向量向量:

    std::vector<std::vector<int>> vv;
    vv.emplace_back();
    vv.emplace_back();
    vv[0].push_back(1);
    vv[1].push_back(2);
    vv[1].push_back(3);

假设我们想要将这个容器“粘”到我们自己设计的内存资源上,比如我们最喜欢的WidgetAlloc。我们不得不写一些重复性的代码,如下所示:

    char buffer[10000];
    std::pmr::monotonic_buffer_resource mr {buffer, sizeof buffer};

    using InnerAlloc = WidgetAlloc<int>;
    using InnerVector = std::vector<int, InnerAlloc>;
    using OuterAlloc = WidgetAlloc<InnerVector>;

    std::vector<InnerVector, OuterAlloc> vv(&mr);
    vv.emplace_back(&mr);
    vv.emplace_back(&mr);
    vv[0].push_back(1);
    vv[1].push_back(2);
    vv[1].push_back(3);

注意分配器对象的初始化器&mr在两个级别上的重复。需要重复&mr使得在泛型上下文中使用我们的向量vv变得困难;例如,我们无法轻易将其传递给一个函数模板以填充数据,因为每次被调用者想要emplace_back一个新的int向量时,它都需要知道只有调用者知道的地址&mr。我们想要做的是封装并具体化“每次你构造向量向量的元素时,你都需要将&mr附加到参数列表的末尾”的概念。而标准库已经为我们提供了解决方案!

自从 C++11 以来,标准库在名为<scoped_allocator>的头文件中提供了一个名为scoped_allocator_adaptor<A>的类模板。就像我们的默认初始化“适配器”一样,scoped_allocator_adaptor<A>继承自A,从而继承了A的所有行为;然后它重写了construct方法以执行不同的操作。具体来说,它试图弄清楚它当前正在构建的T对象是否“使用分配器”,如果是的话,它将把自己作为额外的参数传递给T的构造函数。

要决定类型T是否“使用分配器”,scoped_allocator_adaptor<A>::construct会委托给类型特性std::uses_allocator_v<T,A>,除非你已对其进行特化(你很可能不应该这样做),否则当且仅当A可以隐式转换为T::allocator_type时,它将为真。如果T没有allocator_type,那么库将假设T不关心分配器,除了pairtuple的特殊情况(它们都有针对特定于成员的分配器传播的构造函数的重载)以及promise的特殊情况(即使它没有提供引用该分配器对象的方法,它也可以使用分配器分配其共享状态;我们说promise的分配器支持比我们在第五章,词汇类型中看到的类型擦除示例更彻底地“类型擦除”)。

由于历史原因,分配器感知类型的构造函数可以遵循两种不同的模式,而scoped_allocator_adaptor足够智能,可以知道它们两个。较旧且简单的类型(即除了tuplepromise之外的所有类型)通常具有形式为T(args..., A)的构造函数,其中分配器A位于末尾。对于tuplepromise,标准库引入了一种新的模式:T(std::allocator_arg, A, args...),其中分配器A位于开头,但前面有一个特殊的标记值std::allocator_arg,其唯一目的是指示参数列表中的下一个参数代表一个分配器,类似于标记std::nullopt的唯一目的是指示optional没有值(参见第五章,词汇类型)。就像标准禁止创建类型std::optional<std::nullopt_t>一样,如果你尝试创建std::tuple<std::allocator_arg_t>,你也会发现自己陷入麻烦。

使用scoped_allocator_adaptor,我们可以以前一种稍微不那么繁琐的方式重写我们之前繁琐的例子:

    char buffer[10000];
    std::pmr::monotonic_buffer_resource mr {buffer, sizeof buffer};

    using InnerAlloc = WidgetAlloc<int>;
    using InnerVector = std::vector<int, InnerAlloc>;
    using OuterAlloc = std::scoped_allocator_adaptor<WidgetAlloc<InnerVector>>;

    std::vector<InnerVector, OuterAlloc> vv(&mr);
    vv.emplace_back();
    vv.emplace_back();
    vv[0].push_back(1);
    vv[1].push_back(2);
    vv[1].push_back(3);

注意到分配器类型变得更加繁琐,但重要的是 emplace_back&mr 参数已经消失了;我们现在可以在期望能够以自然方式推送元素的环境中使用 vv,而无需记住到处添加 &mr。在我们的情况下,因为我们使用的是我们的 WidgetAlloc,它不是默认可构造的,所以忘记 &mr 的症状是一系列编译时错误。但你可能还记得,在本章前面的部分中,std::pmr::polymorphic_allocator<T> 会愉快地允许你默认构造它,这可能会产生灾难性的后果;所以如果你计划使用 polymorphic_allocator,那么查看 scoped_allocator_adaptor 也可能是明智的,只是为了限制你可能忘记指定分配策略的地方数量。

传播不同的分配器

在我介绍 scoped_allocator_adaptor<A> 时,遗漏了一个更复杂的点。模板参数列表不仅限于只有一个分配器类型参数!实际上,你可以创建一个具有多个分配器类型参数的 scoped-allocator 类型,如下所示:

    using InnerAlloc = WidgetAlloc<int>;
    using InnerVector = std::vector<int, InnerAlloc>;

    using MiddleAlloc = std::scoped_allocator_adaptor<
      WidgetAlloc<InnerVector>,
      WidgetAlloc<int>
    >;
    using MiddleVector = std::vector<InnerVector, MiddleAlloc>;

    using OuterAlloc = std::scoped_allocator_adaptor<
      WidgetAlloc<MiddleVector>,
      WidgetAlloc<InnerVector>,
      WidgetAlloc<int>
    >;
    using OuterVector = std::vector<MiddleVector, OuterAlloc>;

在设置这些 typedef 之后,我们继续设置三个不同的内存资源,并构造一个能够记住所有三个内存资源的 scoped_allocator_adaptor 实例(因为它包含三个不同的 WidgetAlloc 实例,每个“级别”一个):

    char bi[1000];
    std::pmr::monotonic_buffer_resource mri {bi, sizeof bi};
    char bm[1000];
    std::pmr::monotonic_buffer_resource mrm {bm, sizeof bm};
    char bo[1000];
    std::pmr::monotonic_buffer_resource mro {bo, sizeof bo};

    OuterAlloc saa(&mro, &mrm, &mri);

最后,我们可以构造一个 OuterVector 的实例,传入我们的 scoped_allocator_adaptor 参数;这就全部完成了!我们精心设计的分配器类型中隐藏的 construct 方法会负责将 &bm&bi 参数传递给需要其中一个的任何构造函数:

    OuterVector vvv(saa);

    vvv.emplace_back();
      // This allocation comes from buffer "bo".

    vvv[0].emplace_back();
      // This allocation comes from buffer "bm".

    vvv[0][0].emplace_back(42);
      // This allocation comes from buffer "bi".

如你所见,一个深度嵌套的 scoped_allocator_adaptor 并不是为胆小的人准备的;而且它们只有在沿途创建了很多“辅助” typedef 的情况下才能使用,就像我们在本例中所做的那样。

关于 std::scoped_allocator_adaptor<A...> 的最后一点说明:如果容器的嵌套深度超过了模板参数列表中分配器类型的数量,那么 scoped_allocator_adaptor 将会像其参数列表中的最后一个分配器类型无限重复一样行事。例如:

    using InnerAlloc = WidgetAlloc<int>;
    using InnerVector = std::vector<int, InnerAlloc>;

    using MiddleAlloc = std::scoped_allocator_adaptor<
      WidgetAlloc<InnerVector>
    >;
    using MiddleVector = std::vector<InnerVector, MiddleAlloc>;

    using TooShortAlloc = std::scoped_allocator_adaptor<
      WidgetAlloc<MiddleVector>,
      WidgetAlloc<InnerVector>
    >;
    using OuterVector = std::vector<MiddleVector, TooShortAlloc>;

    TooShortAlloc tsa(&mro, WidgetAlloc<InnerVector>(&mri));
    OuterVector tsv(tsa);

    tsv.emplace_back();
      // This allocation comes from buffer "bo".

    tsv[0].emplace_back();
      // This allocation comes from buffer "bi".

    tsv[0][0].emplace_back(42);
      // This allocation AGAIN comes from buffer "bi"!

实际上,我们在第一个 scoped_allocator_adaptor 示例中就依赖了这种行为,即涉及 vv 的那个示例,尽管当时我没有提到。现在你知道了这一点,你可能想回去研究那个示例,看看“无限重复”的行为在哪里被使用,如果你想要为 int 的内部数组使用不同于外部 InnerVector 数组的内存资源,你应该如何修改那段代码。

摘要

分配器是 C++ 中一个基本深奥的话题,主要由于历史原因。几个不同的接口,具有不同的晦涩用途,层层叠叠;所有这些都涉及强烈的元编程;并且许多这些特性(即使是相对较旧的 C++11 特性,如花哨的指针)的供应商支持仍然不足。

C++17 提供了标准库类型 std::pmr::memory_resource 来阐明现有 内存资源(即 )和 allocators(即 堆的句柄)之间的区别。内存资源提供 allocatedeallocate 方法;分配器提供这些方法以及 constructdestroy 方法。

如果你实现了自己的分配器类型 A,它必须是一个模板;它的第一个模板参数应该是它期望分配的类型 T。你的分配器类型 A 还必须有一个模板构造函数来支持从 A<U>A<T> 的“重新绑定”。就像任何其他类型的指针一样,分配器类型必须支持 ==!= 操作符。

堆的 deallocate 方法允许要求附加到传入指针的额外元数据。C++ 通过 花哨的指针 来处理这一点。C++17 的 std::pmr::memory_resource 不支持花哨的指针,但实现自己的并不困难。

花哨指针类型必须满足所有随机访问迭代器的需求,并且必须是可空的,并且必须可转换为普通原始指针。如果你想使用你的花哨指针类型与基于节点的容器,如 std::list,你必须给它一个静态的 pointer_to 成员函数。

C++17 区分了“粘性”和“非粘性”分配器类型。无状态分配器类型,如 std::allocator<T>,是非粘性的;有状态分配器类型,如 std::pmr::polymorphic_allocator<T>,默认是粘性的。创建一个非默认粘性的自定义分配器类型需要设置三个成员类型别名,通常称为“POCCA”、“POCMA”和“POCS”。粘性分配器类型,如 std::pmr::polymorphic_allocator<T>,主要用于——或许仅用于——经典面向对象的情况,其中容器对象被固定在特定的内存地址上。面向值的编程(涉及大量移动和交换操作)需要无状态分配器类型,或者程序中的每个人都要使用相同的堆和单个粘性但实际上无状态的分配器类型。

scoped_allocator_adaptor<A...> 可以帮助简化使用自定义分配器或内存资源的深层嵌套容器的使用。几乎任何使用非默认分配器类型的深层嵌套容器都需要大量的辅助类型别名来保持可读性。

交换两个具有不同粘性分配器的容器:在理论上这会引发未定义的行为,在实践中会破坏内存并导致段错误。不要这样做!

第九章:Iostreams

到目前为止,我们只在标准库的几个地方看到了经典的泛型。我们刚刚在 第八章 的 分配器 中看到了经典的泛型 std::pmr::memory_resource;泛型在类型擦除类型 std::anystd::function 中被“幕后”使用,这在 第五章 的 词汇类型 中有详细说明。然而,总的来说,标准库没有使用经典的泛型。

然而,标准库中的两个地方大量使用了经典的泛型。一个是标准的异常层次结构--为了方便,标准库抛出的所有异常都是 std::exception 的子类。(我们在这本书中不介绍异常层次结构。)另一个是标准 <iostream> 头文件的内容,我们将在本章中介绍。然而,在我们到达那里之前,我们还有很多背景知识要介绍!

在本章中,我们将涵盖以下主题:

  • 输出分为缓冲和格式化;输入分为缓冲、词法分析和解析

  • POSIX API 用于无格式文件 I/O

  • <stdio.h> 中的 "C" API,它增加了缓冲和格式化功能

  • 经典的 <iostream> API 的优缺点

  • locale-dependent 格式的危险,以及可以帮助避免这些危险的新的 C++17 功能

  • 将数字数据转换为字符串以及从字符串转换回数字的多种方法

C++ 中 I/O 的问题

衡量一种编程语言易用性的一个常见指标是所谓的 TTHW--"time to hello world"。许多流行的编程语言具有非常低的 TTHW:在许多脚本语言中,如 Python 和 Perl,"hello world" 程序实际上只有一行:print "hello world"

C++ 和其祖先 C 都是系统编程语言,这意味着它们的主要关注点是“力量”:对机器的控制、速度,以及在 C++ 的情况下,利用泛型算法的能力。这不是适合小型 "hello world" 程序的关注点组合。

C 中的标准 "hello world" 程序如下:

    #include <stdio.h>

    int main()
    {
      puts("hello world");
    }

在 C++ 中,情况如下:

    #include <iostream>

    int main()
    {
      std::cout << "hello world" << std::endl;
    }

标准的 C++ 源代码并不比标准的 C 源代码长多少,但它有更多的“参数”或“旋钮”可以调整--即使是新手用户也必须了解这些旋钮,即使他们不知道如何调整它们。例如,在 C 中,我们调用名为 puts 的函数(非正式地,一个“动词”),在 C++ 中,我们向名为 std::cout 的对象应用一个 运算符(因此,非正式地,我们既有“动词”又有“间接宾语”)。在 C++ 的例子中,我们还必须学习一个特殊的行结束(换行)字符的名称--std::endl--这是 C 的 puts 函数没有向我们隐藏的细节。

有时,这种复杂性会让 C++的新手望而却步,尤其是如果他们在学校学习 C++,也许他们根本不确定是否想学习它。然而,这是一个不幸的误解!你看,前面的“C”源代码(使用puts)也是完全有效的 C++,使用<stdio.h>头文件中的功能也没有什么问题。实际上,在本章中,我们将在介绍<iostream>功能之前解释<stdio.h>的功能。然而,我们将看到 C++14 和 C++17 引入了一些鲜为人知的新特性--在<string><utility>等头文件中--这些特性有助于一些常见的 I/O 任务。

关于头文件命名的说明:我一直在使用<stdio.h>这个名字来指代包含“C 风格”I/O 功能的头文件。自从 C++03 以来,就有一个类似的标准头文件名为<cstdio><stdio.h><cstdio>之间的唯一区别在于,在<stdio.h>中,所有的功能都保证在全局命名空间中(例如,::printf)并且可能或可能不在std命名空间中(例如,std::printf);而在<cstdio>中,它们保证在std(例如,std::printf)中,但并不一定在全局命名空间中(例如,::printf)。实际上,两者之间没有任何区别,因为所有的主要供应商都将功能放在这两个命名空间中,无论你包含哪个头文件。我的建议只是选择一种风格并坚持下去。如果你的代码库使用了大量的 POSIX 头文件,如<unistd.h>,它只具有.h的名称;那么,坚持使用标准“C 风格”头文件的.h名称可能从美学上更可取。

缓冲区与格式化

如果你记住,在“输出”一些数据时(以及相应地,在“输入”一些数据时)至少有两件根本不同的事情在进行,这将有助于你理解“C 风格”I/O 和“iostream 风格”I/O。为了给它们起个名字,让我们称它们为格式化缓冲区

  • 格式化是将一些强类型数据值从

    程序--整数、字符串、浮点数、用户定义的类类型--并将它们转换或序列化为“文本”。例如,当数字 42 以"42"(或"+42""0x002A")的形式打印出来时,这就是格式化。通常,一个格式化库将拥有自己的“迷你语言”来描述你希望每个值如何格式化。

  • 缓冲是将一串原始字节从程序发送到某个输出设备(在输出时),或者从某个输入设备收集数据并将其作为一串原始字节提供给程序的任务(在输入时)。与缓冲相关的库部分可能执行诸如“一次收集 4096 字节数据,然后刷新”的操作;或者它可能关注数据去向:文件系统中的文件、网络套接字,或者内存中的字节数组?

现在,我故意说“格式化”阶段的输出是“文本”,“缓冲”阶段的输入是“一串字节”。在合理的操作系统上,“文本”和“字节”是同一件事。然而,如果你使用的是那些奇怪的操作系统,其中换行符被编码为两个字节,或者文本文件的预期编码不是 UTF-8,那么在这些阶段之一或两个中,甚至更下游(例如在操作系统系统调用中将数据写入文件时),必须进行一些额外的处理。我们不会过多地讨论这类事情,因为我的希望是,你不会在实际生产中使用这种类型的操作系统(或区域设置)。在生产中,你应该始终使用 UTF-8 进行字符编码,使用 UTC 作为时区,以及使用"C.UTF-8"作为你的区域设置。因此,就我们的目的而言,我们可以假设“格式化”和“缓冲”是管道中我们需要关注的唯一部分。

当我们进行输入时,首先进行“缓冲”,从输入设备读取一些未格式化的字节;然后进行“格式化”,将字节转换为强类型的数据值。输入阶段的“格式化”可以进一步细分为词法分析(确定流中单个数据项的长度)和解析(从这些字节中确定项的实际值)。我们将在第十章“正则表达式”中更多地讨论词法分析。

使用 POSIX API

在我们讨论文件 I/O 时,最重要的是记住,C 和 C++中所有与 I/O 相关的都是建立在 POSIX 标准之上的。POSIX 是一个非常底层的规范,几乎与 Linux 系统调用处于同一级别,它与 C 和 C++的 I/O 标准有相当大的重叠;如果你不理解 POSIX 层的主旨,你将很难理解后续的概念。

请记住,从技术上来说,以下内容都不是标准的 C++!它实际上是符合非 C++标准有效C++:POSIX 标准。在实践中,这意味着它将在除了 Windows 以外的任何操作系统上工作,甚至可能通过Windows 子系统用于 LinuxWSL)在现代 Windows 系统上工作。无论如何,所有标准 API(包括<stdio.h><iostream>)都是建立在上述模型之上的。

定义以下大部分内容的非标准头文件是<unistd.h><fcntl.h>

在 POSIX 中,术语文件指的是磁盘上的实际文件(或者至少在某种文件系统中;如果偶尔用“磁盘”这个词来指代文件系统,请原谅我)。多个程序可以通过操作系统资源(称为文件描述符)并发地读取或写入同一文件。在 C 或 C++程序中,您永远不会看到文件描述符对象本身;您看到的是文件描述符的句柄(或指针)。这些句柄(或指针)不是以指针类型呈现,而是以小的整数形式呈现--实际上是int类型的值。(POSIX 背后的委员会并不像平均的 C++程序员那样痴迷于类型安全!)

要创建一个新的文件描述符并获取其整数句柄,您使用open函数;例如,int fd = open("myfile.txt", O_RDONLY)。第二个参数是一个掩码,可能包含以下任何一个位标志,或运算在一起:

  • 必需:一个且仅有一个“访问模式”。可能的“访问模式”是O_RDONLY(只读)、O_WRONLY(只写)和O_RDWR(读写)。

  • 可选:一些“打开时标志”,描述了在文件打开时系统要执行的操作。例如,O_CREAT表示“如果指定的文件不存在,请为我创建它”(与返回失败相反);您甚至可以添加O_EXCL,这意味着“...如果指定的文件确实已经存在,那么确实返回失败。”另一个重要的打开时标志是O_TRUNC,表示“截断--清除、清空、重置--在打开文件后的文件。”

  • 可选:一些“操作模式”,描述了 I/O 应该如何进行

    通过这个文件描述符完成。这里重要的是O_APPEND

O_APPEND表示“追加模式”。当一个文件处于“追加模式”时,您可以像往常一样在其中进行搜索(您将在下一节看到),但每次您向文件写入时,您的写入都会隐式地先进行到文件末尾的搜索(这意味着在写入后,光标将位于文件末尾,即使您刚刚从不同的位置读取)。以追加模式打开文件描述符对于您使用它进行日志记录非常有用,尤其是如果您使用它从不同的线程进行日志记录。一些标准实用程序,如logrotate,在程序正确以“追加模式”打开其日志文件时工作得最好。简而言之,追加模式非常广泛地有用,我们将在每个高级 API 中一次又一次地看到它。

现在来解释“光标”和“定位”。每个 POSIX 文件描述符都有一些相关数据——基本上是其“成员变量”。这些相关数据之一是描述符的当前操作模式;另一个是描述符的当前文件位置指示器,以下简称“光标”。就像文本编辑器中的光标一样,这个光标指向底层文件中下一次读取或写入将发生的位置。在描述符上使用readwrite会移动其光标。而且,如前一段所述,在“追加模式”下对文件描述符使用write将重置光标到文件的末尾。请注意,每个文件描述符只有一个光标!如果你使用O_RDWR打开文件描述符,你不会得到一个读取光标和一个写入光标;你只会得到一个单一的、通过读取和写入都会移动的光标。

  • read(fd, buffer, count): 这将从底层文件读取原始字节并将它们存储在给定的缓冲区中——最多count字节,或者直到遇到某种临时或永久错误(例如,如果我们需要在网络连接上等待更多数据,或者如果在读取过程中卸载了底层文件系统)。它返回读取的字节数;并且记住,它将光标向前移动。

  • write(fd, buffer, count): 这将从给定的缓冲区将原始字节写入底层文件——最多count字节,或者直到遇到某种临时或永久错误。它返回写入的字节数;并且记住,它将光标向前移动。(并且在写入任何数据之前,如果文件描述符处于追加模式,它将定位到文件的末尾。)

  • lseek(fd, offset, SEEK_SET): 这将(即移动光标)定位到文件开头的给定偏移量,并返回该偏移量(如果操作失败,例如超出文件末尾,则返回-1)。

  • lseek(fd, offset, SEEK_CUR): 这将定位到相对于当前光标的给定偏移量。这种相对移动通常并不重要,但lseek(fd, 0, SEEK_CUR)的特殊情况非常重要,因为这是找出光标当前位置的方法!

  • lseek(fd, offset, SEEK_END): 这将定位到文件末尾的给定偏移量。同样,当offset为零时,这个版本最为有用。

顺便说一下,没有方法可以“复制构造”一个 POSIX 文件描述符,以便你可以获得指向同一文件的第二个光标。如果你想有两个光标,你需要将文件打开两次。令人困惑的是,确实有一个名为dup的 POSIX 函数,它接受一个整数文件描述符句柄并返回一个不同的整数,该整数可以用作第二个句柄来访问相同的描述符;这是一种原始的引用计数。

当你完成文件描述符的使用后,你可以调用close(fd)来释放你的句柄;如果这是对描述符的最后一个句柄(也就是说,在此期间没有人调用dup),那么文件描述符本身将被操作系统回收——也就是说,底层的文件将被“关闭”。

将所有这些放在一起,我们可以使用 POSIX API 编写一个简单的程序来打开、读取、写入、定位和关闭文件描述符:

    #include <cassert>
    #include <string>
    #include <unistd.h>
    #include <fcntl.h>

    int main()
    {
      int fdw = open("myfile.txt", O_WRONLY | O_CREAT | O_TRUNC);
      int fdr = open("myfile.txt", O_RDONLY);
      if (fdw == -1 || fdr == -1)
        return EXIT_FAILURE;

      write(fdw, "hello world", 11);
      lseek(fdw, 6, SEEK_SET);
      write(fdw, "neighbor", 8);

      std::string buffer(14, '\0');
      int b = read(fdr, buffer.data(), 14);
      assert(b == 14);
      assert(buffer == "hello neighbor");
      close(fdr);
      close(fdw);
    }

注意,POSIX API 不关心任何与格式化相关的事情。它只关心确保我们可以将原始字节从磁盘上的文件中读入和读出;也就是说,关于缓冲阶段的一半——“数据去向”的一半。POSIX 不关心“缓冲输出”;当你调用write时,你的数据将被写入。也就是说,它可能仍然坐在操作系统级别、磁盘控制器级别或硬件中的缓冲区中,但对你程序来说,数据已经在路上了。任何进一步的输出延迟都不在你的控制范围内,也不是你的错。这反过来意味着,如果你需要使用 POSIX API 高效地写入大量数据,你的程序必须负责将数据写入缓冲区,然后一次性将整个缓冲区发送到write。单个 4096 字节的write将比 4,096 个单字节write快得多!

或者,你不必编写自己的缓冲区管理代码,可以提升一个抽象级别,使用 C API。

使用标准 C API

这个描述必然和我们在前面讨论 POSIX 时一样简短且不完整。要完整描述<stdio.h>中的功能,你必须查阅其他资料,例如cppreference.com或你本地的man页面。

在“C 风格”API 中,POSIX 的文件描述符被赋予了一个新名字:对应文件描述符的东西被称为FILE,对应整数文件描述符句柄的东西(自然地)被称为FILE*。尽管如此,就像在 POSIX API 中一样,你永远不会自己构造FILE的实例。

要创建一个新的FILE对象并获取其指针,你使用fopen函数;例如,FILE *fp = fopen("myfile.txt", "r")。第二个参数是一个字符串(即指向以空字符终止的字符数组的指针——通常,你只需使用一个字符串字面量,就像我这里所做的那样),它必须是以下之一:

  • "r":这相当于 POSIX 的O_RDONLY。以读取方式打开。如果文件不存在,则失败(即返回nullptr)。

  • "w":这相当于 POSIX 的O_WRONLY | O_CREAT | O_TRUNC。以写入方式打开。如果文件不存在,则创建文件。无论如何,在继续之前将文件清空。

  • "r+":这相当于 POSIX 的O_RDWR | O_CREAT。以读取和写入方式打开。如果文件不存在,则创建一个空文件。

  • "w+":这相当于 POSIX 的O_RDWR | O_CREAT | O_TRUNC

    以读写方式打开。如果文件不存在,则创建文件。无论如何,在继续之前将文件清空。

  • "a":这等价于 POSIX 的 O_WRONLY | O_CREAT | O_APPEND。以写入方式打开。如果文件不存在,则创建一个空文件。进入追加模式。

  • "a+":这等价于 POSIX 的 O_RDWR | O_CREAT | O_APPEND。以读写方式打开。如果文件不存在,则创建文件。无论如何,在继续之前将文件清空。

注意,前面的字符串有一些模式——带有 '+' 的字符串始终映射到 O_RDWR,带有 'w' 的字符串始终映射到 O_TRUNC,带有 'a' 的字符串始终映射到 O_APPEND;然而,没有完美的规则可以描述从 fopen 模式字符串到 POSIX open 标志的映射。

一些平台支持在模式字符串中附加额外的字符;例如,在 POSIX 平台上,常见的扩展是添加 'x' 表示 O_EXCL;在 GNU 平台上,添加 'e' 表示 O_CLOEXEC;在 Windows 上,可以通过添加大写 'N' 获得类似的行为。

任何平台上都可以附加到模式字符串上的一个字符(即,C++标准保证它在任何地方都可用)是 'b',表示“二进制”。这仅在 Windows 上才有意义,如果你没有指定这个字符,库会自动将你输出的每个 '\n' 字节转换为 Windows 行结束符序列,'\r', '\n'。如果你在 Windows 上运行时确实需要这种转换,一个有用的约定是在模式字符串中添加 't'。所有供应商的库都会识别并忽略这个字符;它仅仅作为对人类读者的指示,表明你确实打算以“文本”模式打开文件,而不是不小心遗漏了预期的 'b'

当你完成文件的使用时,你必须调用 fclose(fp),这相当于在底层文件描述符句柄上调用 close(fd)

为了处理 C 风格 FILE 指针的簿记,你可能想使用来自第五章,“词汇类型”的 RAII 智能指针。你可以这样写一个“唯一的 FILE 指针”:

    struct fcloser {
      void operator()(FILE *fp) const {
        fclose(fp);
      }

      static auto open(const char *name, const char *mode) {
        return std::unique_ptr<FILE, fcloser>(fopen(name, mode));
      }
    };

    void test() {
      auto f = fcloser::open("test.txt", "w");
      fprintf(f.get(), "hello world\n");
        // f will be closed automatically
    }

此外,记住,如果你想具有引用计数,“最后一个人离开房间关灯”的语义,你总是可以将 unique_ptr 移动到 shared_ptr

    auto f = fcloser::open("test.txt", "w");
    std::shared_ptr<FILE> g1 = std::move(f);
      // now f is empty and g1's use-count is 1
    if (true) {
      std::shared_ptr<FILE> g2 = g1;
        // g1's use-count is now 2
      fprintf(g2.get(), "hello ");
        // g1's use-count drops back to 1
    }
    fprintf(g1.get(), "world\n");
      // g1's use-count drops to 0; the file is closed

标准 C API 中的缓冲区

标准 C API 提供了一组看起来与 POSIX 函数相似的函数,

但在前面加上字母 f

fread(buffer, 1, count, fp) 方法从底层文件读取原始字节并将它们存储在给定的缓冲区中——最多 count 个字节,或者直到遇到某种永久错误(例如,如果在读取过程中有人卸载了底层文件系统)。它返回读取的字节数并前进光标。

那个调用中的字面量 1 并非错误!技术上,函数签名是 fread(buffer, k, count, fp)。它读取最多 k * count 个字节,或者直到遇到永久性错误并返回读取的字节数除以 k。然而,在你的代码中,k 应始终是字面量 1;使用其他任何值都是错误,至少有两个原因。首先,因为返回值总是除以 k,如果 k 不是 1,你将丢失信息。例如,如果 k 是 8,返回值 3 表示“在 24 到 31 个字节之间”读取并存储到缓冲区中,但 buffer[3] 可能现在包含一个部分写入的值——也就是说,垃圾数据——而你无法检测到这一点。其次,由于库内部会乘以 k * count,传递除 1 以外的任何 k 都会存在溢出和计算错误缓冲区长度的风险。没有流行的实现会检查乘法是否溢出;这如果不是为了性能原因,至少也是出于其他原因。如果每个程序员都知道永远不要为 k 传递除 1 以外的任何值,那么在昂贵的除法操作上花费 CPU 时间是没有意义的!

fwrite(buffer, 1, count, fp) 方法将给定缓冲区中的原始字节写入底层文件——最多 count 个字节,或者直到遇到某种永久性错误。它返回写入的字节数,并前进光标。(并且*在写入任何数据之前,如果文件描述符处于追加模式,它将移动到文件的末尾。)

fseek(fp, offset, SEEK_SET) 方法将光标(即移动光标)移动到文件开头的给定偏移量;fseek(fp, offset, SEEK_CUR) 将光标移动到相对于当前光标的给定偏移量;而 fseek(fp, offset, SEEK_END) 将光标移动到相对于文件末尾的给定偏移量。与 POSIX 的 lseek 不同,标准的 C 版本 fseek 并不返回当前光标的位置;它仅在成功时返回 0 或在失败时返回 -1

ftell(fp) 方法返回当前光标的位置;也就是说,它与底层 POSIX 调用 lseek(fd, 0, SEEK_CUR) 等效。

说到底层的 POSIX 调用:如果你在一个 POSIX 平台上,并且需要使用 POSIX 文件描述符执行与标准 C FILE * 相关的非可移植操作,你总是可以通过调用 fileno(fp) 来检索文件描述符。因此,例如,我们可以将 ftell 表达如下:

    long ftell(FILE *fp)
    {
      int fd = fileno(fp);
      return lseek(fd, 0, SEEK_CUR);
    }

使用freadfwrite是可能的,但并不是使用 C API 的最常见方式。许多程序更愿意以字符或字节为单位处理输入和输出,而不是处理大量数据。原始的“Unix 哲学”倾向于小型简单的命令行工具,这些工具读取并转换字节流;这些小型面向流的程序被称为“过滤器”,当与 Unix shell 的管道链接在一起时,它们表现得尤为出色。例如,这里有一个小程序,它使用<stdio.h> API 打开一个文件,并计算该文件中的字节数、空格分隔的“单词”数和行数:

    struct LWC {
      int lines, words, chars;
    };

    LWC word_count(FILE *fp)
    {
      LWC r {};
      bool in_space = true;
      while (true) {
        int ch = getc(fp);
        if (ch == EOF) break;
        r.lines += (ch == '\n');
        r.words += (in_space && !isspace(ch));
        r.chars += 1;
        in_space = isspace(ch);
      }
      return r;
    }

    int main(int argc, const char **argv)
    {
      FILE *fp = (argc < 2) ? stdin : fopen(argv[1], "r");
      auto [lines, words, chars] = word_count(fp);
      printf("%8d %7d %7d\n", lines, words, chars);
    }

(你认出它了吗?这是命令行工具wc。)

这个程序引入了两个新想法(除了标准保证在程序退出时所有FILE对象都隐式关闭,这样我们就可以省略fclose的记录并在这个例子中节省几行之外)。第一个是标准流的概念。在 C 和 C++中有三个标准流:stdinstdoutstderr。在我们的单词计数程序中,我们遵循规则,如果命令行用户没有明确告诉我们要读取的任何文件名,我们将从stdin,即标准输入流读取,这通常等同于控制台(或终端或键盘——也就是说,是坐在那里打字的人)。操作系统和命令行 shell 中的各种机制可以用来重定向标准输入流到其他输入;这些机制(如在 shell 提示符下键入wc <myfile.txt)远远超出了本书的范围。关于这三个标准流的主要事情是要记住,它们可以通过名称自动供您使用,而无需fopen它们;并且关闭它们中的任何一个都是错误的。

在我们的单词计数程序中引入的第二个新想法是getc函数。getc(fp)函数从给定的FILE *读取一个字节,并返回它所读取的字节。如果发生错误,或者(更可能的是)如果遇到文件末尾,它将返回一个特殊值,称为EOFEOF的数值通常是-1,但关于它的保证是,它与任何可能的有效字节都完全不同。因此,getc(fp)不将其返回值作为char返回;它将其作为int返回,这足以存储任何可能的char,并且,此外,足以存储与这些char值不同的值EOF(如果char在您的平台上是带符号的类型——在许多平台上都是这样——那么getc将在返回之前将其读取的char转换为unsigned char;这确保了如果输入文件中出现0xFF字节,它将被返回为255,这是一个与表示EOF-1不同的整数值)。

现在,让我们来看看fread/fwriteread/write之间的关键区别。

回想一下,POSIX API 并不会对输入或输出字节进行任何额外的缓冲;

当你调用 read 时,你实际上是在调用操作系统来检索

下一个输入字节的块。如果 getc(fp) 被实现为 fread(&ch, 1, 1, fp),并且 fread(buf, 1, count, fp) 被实现为 read(fileno(fp), buf, count),那么我们的单词计数程序将非常低效——读取一个百万字节的文件将导致一百万个系统调用!所以,当 C 库将文件描述符句柄包装在 FILE 对象中时,它还添加了一个额外的功能:缓冲

FILE 流可以是“无缓冲”的(这意味着每个 fread 确实对应于 read,每个 fwrite 对应于 write);“完全缓冲”的,也称为“块缓冲”(这意味着写入将累积到一个私有缓冲区中,只有当它填满时才会发送到底层的文件描述符,同样,读取也将从私有缓冲区中提供,只有当它为空时才会从底层的文件描述符中重新填充);或者“行缓冲”的(这意味着有一个与之前情况相同的私有缓冲区,但写入 '\n' 会导致刷新,即使缓冲区还没有满)。当程序启动并打开其标准流时,stdinstdout 将是行缓冲的,而 stderr 将是无缓冲的。你通过 fopen 打开的任何文件通常将是完全缓冲的,尽管操作系统也可能对此有所说法;例如,如果你打开的“文件”实际上是一个终端设备,它可能默认就是行缓冲的。

在非常罕见的情况下,如果你需要控制 FILE 流的缓冲模式,你可以通过标准的 setvbuf 函数来实现。你还可以使用 setvbuf 来提供自己的缓冲区,如下面的示例所示:

FILE *fp = fopen("myfile.txt", "w");
    int fd = fileno(fp);
    char buffer[150];
    setvbuf(fp, buffer, _IOFBF, 150);
      // setvbuf returns 0 on success, or EOF on failure.

    std::string AAAA(160, 'A');
    int bytes_written = fwrite(AAAA.data(), 1, 160, fp);
      // This fills the buffer with 150 bytes, flushes it,
      // and writes 10 more bytes into the buffer.

    assert(bytes_written == 160);
    assert(lseek(fd, 0, SEEK_CUR) == 150);
    assert(ftell(fp) == 160);

注意到示例的最后一条中 ftell(fp)lseek(fd, 0, SEEK_CUR) 之间的差异。在 FILE 的缓冲区中还有 10 个字节未被读取;因此 FILE 报告说你的光标目前位于偏移量 160,但实际上,底层的 POSIX 文件描述符的光标仍然位于偏移量 150,并且将保持在那里,直到 FILE 的缓冲区填满并第二次刷新——此时底层的 POSIX 文件描述符的光标将跳转到偏移量 300。这感觉有些不自然,但实际上这正是我们想要的!我们想要的是通过以大块写入底层文件描述符所带来的效率。(注意,在现实中 150 个字节并不算“大”。如果你根本不使用 setvbuf,典型的默认文件缓冲区大小可能更像是 4096 字节。)

在某些平台上,调用ftell可能会导致缓冲区作为副作用被刷新,因为这使得库的记账更容易;库不喜欢被抓住说谎。(调用fseek也可能是导致刷新的原因。)然而,在其他平台上,ftell甚至fseek并不总是刷新缓冲区。为了确保你的FILE流缓冲区确实已经刷新到底层文件,请使用fflush。让我们继续上一个示例,如下所示:

    // Flush the FILE's buffer by force.
    fflush(fp);
    // Now, fd and fp agree about the state of the file.
    assert(lseek(fd, 0, SEEK_CUR) == 160);

将所有这些放在一起,我们可以像这样重写我们在使用 POSIX API部分中的简单程序,使用<stdio.h> API 来打开、读取、写入、定位、刷新和关闭文件流:

    #include <cassert>
    #include <cstdio>
    #include <string>

    int main()
    {
      FILE *fpw = fopen("myfile.txt", "w");
      FILE *fpr = fopen("myfile.txt", "r");
      if (fpw == nullptr || fpr == nullptr)
        return EXIT_FAILURE;

      fwrite("hello world", 1, 11, fpw);
      fseek(fpw, 6, SEEK_SET);
      fwrite("neighbor", 1, 8, fpw);
      fflush(fpw);

      std::string buffer(14, '\0');
      int b = fread(buffer.data(), 1, 14, fpr);
      assert(b == 14 && buffer == "hello neighbor");
      fclose(fpr);
      fclose(fpw);
    }

这就结束了我们对标准<stdio.h> API 的缓冲能力的探索;现在,我们将继续考虑<stdio.h>如何处理格式化

使用 printf 和 snprintf 进行格式化

在格式化阶段,我们开始于我们想要打印出的高级数据值;例如,我们可能想要打印芝加哥钢琴调音师的数量,我们的程序计算为 225。打印出三字节字符串 "225"很容易;我们在前面的章节中已经解决了这个问题。格式化的任务是将数字 225(一个int,比如说)转换成那个三字节字符串"225"

当打印数字时,我们有许多可能的问题:数字应该以 10 进制、16 进制、8 进制、2 进制或其他进制打印吗?如果数字是负数,我们可能需要在前面加上-;如果是正数,我们应该在前面加上+吗?我们应该使用千位分隔符吗?如果是这样,我们应该使用逗号、句号还是空格?关于小数点呢?一旦我们谈论到浮点数,我们应该打印小数点后多少位数字?或者我们应该使用科学记数法,如果是这样,我们应该保留多少有效数字?

然后,还有一些问题甚至涉及到非数值输入。打印的值是否应该在一个固定宽度的列中对齐,如果是的话,应该左对齐、右对齐,还是以某种其他巧妙的方式进行对齐?(我们该使用什么字符来填充未占用的列呢?)如果值不适合给定的列宽,应该左截断、右截断,还是直接超出列的边界?

类似地,当读取格式化输入(即解析)时,我们必须回答许多关于数字的相同问题:我们是否期望千位分隔符?科学记数法?前导+符号?我们期望什么数字进制?甚至对于非数字:我们是否期望前导空白?如果我们正在读取类型为“字符串”的值,除了EOF之外,什么表示值的结束?

标准 C API 提供了一系列以printf结尾的格式化函数,以及一系列以scanf结尾的匹配解析函数。这个系列中每个函数的共同点是它接受一个变长参数列表(使用 C 风格的变长参数,而不是 C++变长模板),并且在变长参数之前有一个单一的“格式字符串”,为要格式化的每个参数回答许多上述问题(但不是所有问题),并为整体消息提供一个“形状”,库将在这个形状中插入格式化的参数:

    int tuners = 225;
    const char *where = "Chicago";
    printf("There are %d piano tuners in %s.\n", tuners, where);

此外还有fprintf(fp, "format", args...),用于打印到任何任意流(不一定是stdout);snprintf(buf, n, "format", args...)用于写入缓冲区,我们将在稍后讨论这个问题;还有一组vprintfvfprintfvsnprintf函数,这些函数在构建自己的 printf-like 函数时很有用。正如你可能在本章中学到的,C 风格格式字符串的完整处理超出了本书的范围。然而,C 风格的“格式字符串语言”即使在不是直接从 C 派生的语言中也被广泛使用;例如,在 Python 2 中,你可以这样写:

    tuners = 225
    where = "Chicago"
    print "There are %d piano tuners in %s." % (tuners, where)

然而,C 和 Python 中发生的事情之间有重大差异!

最大的区别在于 Python 是动态类型的,所以如果你写"%s tuners" % (tuners),它仍然能够正确执行。使用 C 风格的变长参数列表时,tuners的原始类型会丢失;如果你使用"%s"格式说明符(它期望一个const char *类型的参数)并传递一个int类型的参数,你最多会得到友好的编译器警告,最坏的情况下会有未定义的行为。也就是说,当你使用<stdio.h>格式化函数时,格式字符串承担双重职责:它不仅编码了如何格式化每个数据值,还编码了每个数据值的类型——如果你弄错了其中一个类型,比如当你想用"%s"时实际上应该用"%d",那么你的程序就会出现错误。幸运的是,如今所有主要的编译器都可以检测并诊断这种不匹配,只要你的格式字符串是直接传递给printf或带有(非标准的)format属性的函数,就像我们将在接下来的代码示例中看到的那样。不幸的是,当你处理平台相关类型的typedef时,这些诊断可能不可靠;例如,一些 64 位编译器不会诊断尝试使用"%llu"格式说明符格式化size_t值的情况,尽管正确的可移植格式说明符应该是"%zu"

另一个区别是,在 C 中,printf 实际上是直接写入标准输出流 stdout;数据的格式化与输出字节的缓冲是交织在一起的。在 Python 中,"There are %d piano tuners in %s." % (tuners, where) 结构实际上是一个 str 类型(字符串)的 表达式;所有的格式化都直接在那里完成,生成一个包含正确字节的单个字符串值,在我们决定将字符串打印到 stdout 之前。

要使用 <stdio.h> API 生成格式化的字符串,我们将使用 snprintf

    char buf[13];
    int needed = snprintf(
      buf, sizeof buf,
      "There are %d piano tuners in %s", tuners, where
    );
    assert(needed == 37);
    assert(std::string_view(buf) == "There are 22");

注意到 snprintf 总是会以空字符终止其缓冲区,即使这意味着不能将整个消息写入其中;它返回它 想要 写入的消息的 strlen。格式化任意长消息的常见方法是首先用 nullptr 调用 snprintf,以了解消息的最终大小;然后再次调用它,这次使用相应大小的缓冲区:

    template<class... Args>
    std::string format(const char *fmt, const Args&... args)
    {
      int needed = snprintf(nullptr, 0, fmt, args...);
      std::string s(needed + 1, '\0');
      snprintf(s.data(), s.size(), fmt, args...);
      s.pop_back(); // remove the written '\0'
      return s;
    }

    void test()  
    {
      std::string s = format("There are %d piano tuners in %s", tuners, where);
      assert(s == "There are 225 piano tuners in Chicago"); 
    } 

之前 format 的实现使用了一个变长参数函数模板,这往往会产生大量类似的代码副本。一个更有效的实现(从编译时间和代码膨胀的角度来看)是使用一个单一的非模板函数,具有 C 风格的变长参数列表,并使用 vsnprintf 进行格式化。遗憾的是,关于 va_listvsnprintf 的进一步讨论远远超出了本书的范围。

    std::string better_format(const char *fmt, ...)
    {
      va_list ap;
      va_start(ap, fmt);
      int needed = vsnprintf(nullptr, 0, fmt, ap);
      va_end(ap);
      std::string s(needed + 1, '\0');
      va_start(ap, fmt);
      vsnprintf(s.data(), s.size(), fmt, ap);
      va_end(ap);
      s.pop_back(); // remove the written '\0'
      return s;
    }

我们将把对 scanf 格式字符串的讨论推迟到本章的 食谱 部分。关于 scanf 的完整介绍,请参考 cppreference.com 或关于 C 标准库的书籍。

在了解了 <stdio.h> 环境中缓冲和格式化(至少是输出格式化)是如何工作的之后,我们现在转向标准 C++ <iostream> API。

经典的 iostreams 层次结构

<stdio.h> API 至少有三个问题。首先,格式化功能远非类型安全。其次,缓冲功能尴尬地分为 "将缓冲写入文件流"(FILE *fprintf)和 "将缓冲写入字符缓冲区"(snprintf)。(好吧,技术上,GNU C 库提供了 fopencookie 来构建 FILE *,它可以缓冲到任何你想要的地方;但这相当不为人知,并且非常非标准。)第三,没有简单的方法来扩展用户定义类的格式化功能;我甚至不能 printf 一个 std::string,更不用说 my::Widget 了!

在 1980 年代中期 C++ 被开发时,设计者感到需要一个类型安全、可组合和可扩展的 I/O 库。因此诞生了被称为 "iostreams" 或简单地称为 "C++ streams"(不要与刚刚讨论过的 <stdio.h> streams 混淆)的功能。自 1980 年代中期以来,iostreams 的基本架构没有改变,这使得它与标准库中的其他任何东西都大不相同,可能唯一的例外是(无意中)std::exception 层次结构。

C++ 的 iostreams 库由两个主要部分组成:streams,它们关注格式化,以及 streambufs,它们关注缓冲。大多数 C++ 程序员永远不会与 streambufs 交互;只有与 streams 交互。然而,让我们快速解释一下什么是 streambuf

streambuf 在 C API 中非常类似于 FILE。它告诉程序输入(以原始字节的形式)应该来自哪里,输出应该去哪里。它还维护一个字节缓冲区以减少到达这些目的地(如 POSIX 的 readwrite 函数)的往返次数。为了允许具有相同接口的不同类型的 streambuf -- 嗯,记得我在本章中提到的承诺,我们会看到经典的多态吗?我们终于到达那里了!

std::streambuf(实际上是对 std::basic_streambuf<char, char_traits<char>> 的别称,但让我们不要使事情变得更复杂)是一个继承层次结构的基类,其派生类是 std::filebufstd::stringbufstreambuf 接口提供的虚拟方法太多,无法一一列出,但包括 sb.setbuf(buf, n)(对应于 setvbuf(fp, buf, _IO_FBF, n)),sb.overflow()(对应于 fflush(fp)),以及 sb.seekpos(offset, whence)(对应于 fseek(fp, offset, whence))。当我说对应时,当然是指对应于 std::filebuf。这些方法在调用 std::stringbuf 时具有实现定义的(在实践中,不可移植的)行为。

任何由 streambuf 派生的类都必须支持一些原始操作来与其缓冲区交互(用于放入和取出字节)。这些原始操作不是供普通程序员使用的;它们是为封装此 streambuf 并提供更友好的程序员接口的 stream 对象使用的。

C++ 的 stream 封装了一个 streambuf 并限制了你可以对其执行的操作集。例如,请注意 streambuf 并没有“访问模式”的概念:你可以像取出字节(“读取”)一样容易地将其放入字节(“写入”)。然而,当我们用 std::ostream 封装那个 streambuf 时,ostream 对象只暴露了一个 write 方法;在 ostream 上没有 read 方法。

以下图表展示了 C++17 中流和流缓冲区的类层次结构,如标准 <iostream><fstream> 和/或 <sstream> 头文件中定义的那样。streambufistreamostreamiostream 基类是“抽象的”:虽然它们没有纯虚方法,但只包含从 ios 继承的 streambuf* 成员变量。为了防止你意外构造这些“抽象的”类型的实例,标准库定义了它们的构造函数为 protected。相反,名称中包含 stringstreamfstream 的类实际上分别包含 stringbuffilebuf 的实例,它们的构造函数初始化继承的 streambuf* 成员变量以指向这些实例。在本章的后续部分,在 解决粘性操纵器问题 节中,我们将看到如何构造一个 ostream 对象,其 streambuf* 成员变量指向一个不属于 *this 的流缓冲区实例:

流类公开了一系列方法,这些方法大致对应于我们之前两次看到过的函数。特别是,fstream 类封装了 filebuf,并且它们一起的行为与 C API 中的 FILE 非常相似:filebuf 有一个“游标”,你可以使用 fstreamseekp 方法来操作它。(seekp 的名称是从 ostream 类继承的。在 ifstream 上,该方法的名称是 seekg:“g”代表“获取”,“p”代表“放置。”在完整的 fstream 上,你可以使用 seekgseekp;在这种情况下,它们是同义词。一如既往,记住,即使 iostreams API 在这种情况下有两个不同的 名称,但只有一个游标!)

fstream 构造函数接受一个由 std::ios_base::inoutapp(表示“追加模式”)、truncatebinary 标志值组成的位掩码;然而,正如我们在 fopen 中所看到的,这些标志如何转换为 POSIX open 标志之间只有很少的韵律和逻辑关系:

  • in: 这与 fopen("r") 或 POSIX 的 O_RDONLY 等效。

  • out: 这与 fopen("w") 或 POSIX 的 O_WRONLY | O_CREAT | O_TRUNC 等效。(注意,即使没有传递 truncout 单独也意味着 O_TRUNC!)

  • in|out: 这与 fopen("r+") 或 POSIX 的 O_RDWR | O_CREAT 等效。

  • in|out|trunc: 这与 fopen("w+") 或 POSIX 的 O_RDWR | O_CREAT | O_TRUNC 等效。(注意,在这种情况下,iostreams 语法比 fopen 语法更有意义。)

  • out|app: 这与 fopen("a") 或 POSIX 的 O_WRONLY | O_CREAT | O_APPEND 等效。

  • in|out|app: 这与 fopen("a+") 或 POSIX 的 O_RDWR | O_CREAT | O_APPEND 等效。

binary 添加到位掩码中就像在 fopen 中添加 "b" 一样。添加 ate 告诉流从文件的末尾开始搜索,即使文件不是以 O_APPEND 模式打开。

传递一个不支持的标志集,例如 app|trunc,仍然会构建流对象,但将其置于“失败”状态,我们将在后面讨论。一般来说,你应该设计自己类的构造函数,以便通过异常来指示失败。这个规则在这里被打破,部分原因是因为这个类层次结构是在大约四十年前设计的,部分原因是因为我们无论如何都需要一个“失败”机制,以处理相对可能的情况,即无法打开指定的文件(例如,如果它不存在)。

将所有内容整合起来,我们可以用 <fstream> API 重写我们的简单程序,如下所示,使用该 API 打开、读取、写入、定位、刷新和关闭文件流:

    #include <cassert>
    #include <fstream>
    #include <string>

    int main()
    {
      std::fstream fsw("myfile.txt", std::ios_base::out);
      std::fstream fsr("myfile.txt", std::ios_base::in);
      if (fsw.fail() || fsr.fail())
        return EXIT_FAILURE;

      fsw.write("hello world", 11);
      fsw.seekp(6, std::ios_base::beg);
      fsw.write("neighbor", 8);
      fsw.flush();

      std::string buffer(14, '\0');
      fsr.read(buffer.data(), 14);
      assert(fsr.gcount() == 14 && buffer == "hello neighbor");
    }

前一个例子中有一个奇怪的地方,那就是 fsr.read(buffer.data(), 14)

并不返回读取了多少字节的任何指示!相反,它将读取的字节数存储在一个成员变量中,你必须通过访问器 fsr.gcount() 函数自己检索这个计数。而且,write 方法甚至不允许你找出写入了多少字节。这看起来可能像是一个问题;但,一般来说,如果一个流在读取或写入时遇到错误,由于从底层文件描述符实际读取或写入的字节数不确定,以及应用程序程序和硬件之间的几个缓冲层,错误往往是“不可恢复”的。当遇到读取或写入错误时,我们基本上必须放弃了解那个流的状态——除了在输入遇到“文件末尾”的特殊情况之外。如果我们打算读取 100 个字节,但意外地遇到了“文件末尾”,那么询问“我们成功读取了多少字节?”是有意义的。然而,如果我们打算 写入 100 个字节,但收到了网络错误或磁盘错误,那么询问“我们成功写入了多少字节?”就没有那么有意义了。我们根本无法判断我们的“写入”字节是否成功到达了目的地。

如果我们请求读取 100 个字节,但在遇到文件末尾之前只读取了 99 个(或更少),那么不仅 fs.gcount() 会报告一个小于 100 的数字,而且流对象的状态上还会设置 eof 指示器。你可以使用访问器函数 fs.good()(是否一切顺利?),fs.bad()(底层流是否遇到了不可恢复的错误?),fs.eof()(最后的输入操作是否遇到了文件末尾?),和 fs.fail()(最后的操作是否由于任何原因而“失败”?)来询问任何流的状态。请注意,fs.good() 并不是 fs.bad() 的逆;一个流可能处于某种状态,例如 eof,即 !good() && !bad()

我们现在已经看到了使用 fstream 流进行缓冲输入和输出的最简单、最原始的方法。然而,如果你像这样使用 C++ 流,你不妨直接使用 FILE *,甚至 POSIX API。C++ 流的“新且(有争议地)改进”之处在于它们处理 格式化 的方式。

流和操纵符

回想一下,使用 printf 时,原始参数类型会丢失,因此格式化字符串必须承担双重任务,不仅编码每个数据值的 格式化 方式,还要编码每个数据值的 类型。当我们使用 iostreams 时,这种缺点就消失了。使用 iostreams 的格式化看起来像这样:

    int tuners = 225;
    const char *where = "Chicago";
    std::cout << "There are " << tuners << " piano tuners in " << where << "\n";

在这里,std::cout 是一个全局变量,类型为 ostream,对应于 stdout 或 POSIX 文件描述符 1。还有 std::cerr,对应于无缓冲的 stderr 或 POSIX 文件描述符 2;std::clog,再次对应于文件描述符 2,但这次是完全缓冲的;以及 std::cin,是一个全局变量,类型为 istream,对应于 stdin 或 POSIX 文件描述符 0。

标准的 ostream 类,再次强调,实际上是 basic_ostream<char, char_traits<char>>,但让我们忽略这一点)有大量的非成员重载的 operator<<。例如,这里是最简单的重载 operator<<

    namespace std {
      ostream& operator<< (ostream& os, const string& s)
      {
        os.write(s.data(), s.size());
        return os;
      }
    } // namespace std

由于这个函数返回了对它接收到的相同 os 的引用,我们可以将 << 操作符链接在一起,就像前面的例子所示。这允许我们格式化复杂的信息。

不幸的是,我们简单的 operator<<(ostream&, const string&) 并不足以满足在 Formatting with printf and snprintf 部分描述的各种格式化关注点。假设我们想要在一个宽度为 7 的列中打印左对齐的字符串;我们该如何做呢?operator<< 语法不允许我们传递任何额外的“格式化选项”参数,这意味着我们无法进行复杂的格式化,除非格式化选项被携带在 << 的左侧(ostream 对象本身)或右侧(要格式化的对象)。标准库使用了这两种方法的混合。一般来说,1980 年代和 1990 年代首次出现的功能将它们的格式化选项携带在 ostream 对象本身中;而后来添加的任何内容——由于不能在不破坏二进制兼容性的情况下向 ostream 添加新的成员变量——不得不通过调整 << 操作符的右侧来凑合。以列内的对齐为例,这是 std::stringoperator<< 的一个稍微更完整的版本:

    void pad(std::ostream& os, size_t from, size_t to)
    {
      char ch = os.fill();
      for (auto i = from; i < to; ++i) {
        os.write(&ch, 1);
      }
    }

    std::ostream& operator<< (std::ostream& os, const std::string& s)
    {
      auto column_width = os.width();
      auto padding = os.flags() & std::ios_base::adjustfield;

      if (padding == std::ios_base::right) {
        pad(os, s.size(), column_width);
      }
      os.write(s.data(), s.size());
      if (padding == std::ios_base::left) {
        pad(os, s.size(), column_width);
      }
      os.width(0); // reset "column width" to 0
      return os;
    }

在这里,os.width()os.flags()os.fill() 都是 std::ostream 类的内置成员。

std::ostream 类。还有 os.precision() 用于浮点数,

os.flags() 可以指示某些数值类型的十进制、十六进制或八进制输出。您可以通过调用 os.width(n) 在流上设置“列宽”状态;然而,如果我们不得不在每次输出操作之前编写 std::cout.width(10)std::cout.setfill('.') 等来设置,那将会非常痛苦(而且愚蠢!)所以,iostreams 库提供了一些标准 流操作符,可以用来获取这些成员函数的效果,但方式更加“流畅”。这些操作符通常定义在标准头文件 <iomanip> 中,而不是 <iostream> 本身。例如,这里有一个设置流列宽的操作符:

    struct WidthSetter { int n; };

    auto& operator<< (std::ostream& os, WidthSetter w)
    {
      os.width(w.n);
      return os;
    }

    auto setw(int n) { return WidthSetter{n}; }

此外,这里还有两个更多的标准操作符,其中一个现在应该非常熟悉您。std::endl 操作符将换行符流到输出流,然后刷新它:

    using Manip = std::ostream& (*)(std::ostream&);

    auto& operator<< (std::ostream& os, Manip f) {
      return f(os);
    }

    std::ostream& flush(std::ostream& os) {
      return os.flush();
    }

    std::ostream& endl(std::ostream& os) {
      return os << '\n' << flush; 
    }

一旦我们有了 std::setw;它的朋友 std::leftstd::rightstd::hexstd::decstd::octstd::setfillstd::precision;以及所有其余的——我说一旦我们有了所有这些操作符——我们就可以编写出看起来几乎自然的 iostreams 代码,尽管非常冗长。比较这些 <stdio.h><iostream> 片段:

    printf("%-10s.%6x\n", where, tuners);
      // "Chicago . e1"

    std::cout << std::setw(8) << std::left << where << "."
              << std::setw(4) << std::right << std::hex
              << tuners << "\n";
      // "Chicago . e1"

请记住,每次我们使用这些操作符之一时,我们都在命令式地影响流对象的状态;这种影响可能持续的时间比当前输出语句更长。例如,我们前面的片段可能继续如下:

    printf("%d\n", 42); // "42"

    std::cout << 42 << "\n"; // "2a" -- oops!

之前示例中的 std::hex 操作符将此流的模式设置为“数字的十六进制输出”,并且没有任何东西将其设置回“默认”的十进制模式。因此,现在我们无意中使程序后面的所有内容都打印为十六进制!这是 iostreams 库(以及状态式、命令式编程的一般缺点)的一个主要缺点。

流和包装

std::ios_base 提供的参数(leftrighthexwidthprecision 等)是一个封闭集——一个在 1980 年代中期定义的集,自那时以来基本上没有改变。由于每个操作符都修改流状态中的一个参数,因此操作符集本质上也是封闭的。影响特定数据值格式的现代方法是将其包装在 包装器 中。例如,假设我们已经编写了一个通用的算法,用于在数据文件中引用值:

    template<class InputIt, class OutputIt>
    OutputIt do_quote(InputIt begin, InputIt end,
      OutputIt dest)
    {
      *dest++ = '"';
      while (begin != end) {
        auto ch = *begin++;
        if (ch == '"') {
            *dest++ = '\\';
        }
        *dest++ = ch;
      }
      *dest++ = '"';
      return dest;
    }

(此算法不是标准库的一部分。)有了这个算法,我们可以轻松地构造一个包装类,其中包装类的 operator<< 将调用以下算法:

    struct quoted {
      std::string_view m_view;
      quoted(const char *s) : m_view(s) {}
      quoted(const std::string& s) : m_view(s) {}
    };

    std::ostream& operator<< (std::ostream& os, const quoted& q)
    {
      do_quote(
        q.m_view.begin(),
        q.m_view.end(),
        std::ostreambuf_iterator<char>(os)
      );
      return os;
    }

std::ostreambuf_iterator<char> 类型是标准库的一部分;它来自 <iterator> 头文件。我们将在本章后面看到它的朋友 istream_iterator。)然后,有了包装类,我们就能编写出非常合理的代码来将引号内的值打印到输出流中:

    std::cout << quoted("I said \"hello\".");

我们刚刚发明的包装器与标准库 <iomanip> 头文件中找到的 std::quoted 包装函数有故意的相似之处。主要区别在于 std::quoted 不使用基于迭代器的算法来生成其输出;它在一个局部的 std::string 变量中构建整个输出,然后使用 os << str 一次性将其打印出来。这意味着 std::quoted非分配器感知的(见第八章,分配器),因此不适合禁止堆分配的环境。虽然在这个案例中细节可能处理不当,

使用包装函数或类调整数据值格式的根本思想是好的。你可以在像 Boost.Format 这样的库中看到它被推向极致,其中以下语法是合法的:

    std::cout << boost::format("There are %d piano tuners in %s.") % tuners % where
              << std::endl;

更倾向于使用描述自包含格式化操作的包装器,而不是那些"粘性"地改变流状态的操纵器。在前面的代码中,我们看到了一个放置不当的 std::hex 如何给所有"下游"的人带来诅咒。现在,我们将探讨两种解决该问题的方法——以及随之出现的新问题!

解决粘性操作器问题

我们可以通过保存和恢复 std::hex 的状态来解决我们的"粘性 std::hex"问题

在每个复杂的输出操作周围使用 ostream,或者每次我们想要输出某些内容时创建一个新的 ostream。前者的一个例子如下:

    void test() {
      std::ios old_state(nullptr);
      old_state.copyfmt(std::cout);
        std::cout << std::hex << 225; // "e1"
      std::cout.copyfmt(old_state);

      std::cout << 42; // "42"
    }

后者的一个例子如下:

    void test() {
      std::ostream os(std::cout.rdbuf());
      os << std::hex << 225; // "e1"

      std::cout << 42; // "42"
    }

注意 iostreams 库如何将"streambuf"的概念与"stream"的概念分开;在前面的例子中,我们很容易通过提取其流 buf:std::cout.rdbuf())来从流中剥离所有与格式化相关的字段,然后在同一流 buf 上叠加一个新的流(带有自己的与格式化相关的字段)。

然而,iostreams 格式化还有一个主要的缺点。我们意图中的每一条消息都会在相应的 operator<< 被达到时"急切"地输出——或者,如果你愿意,每一条意图中的消息只在其相应的 operator<< 被达到时"懒惰"地计算——因此我们有以下这段代码:

    void test() {
      try {
        std::cout << "There are "
                  << computation_that_may_throw()
                  << "piano tuners here.\n";
      } catch (...) {
        std::cout << "An exception was thrown";
      }
    }

我们将看到前面那段代码的输出为 There are An exception was thrown

此外,iostreams 格式化对国际化("i18n")来说非常令人不悦,因为整体消息的"形状"从未出现在源代码中。我们不是有一个代表完整思想的单个字符串字面量 "There are %d piano tuners here.\n",它可以由人类翻译并存储在外部翻译消息文件中;我们有两个句子片段:"There are " 和 "piano tuners here.\n",它们都不能单独翻译。

由于所有这些原因,我强烈建议你不要尝试将 iostreams 作为你代码库的 基础。使用 <stdio.h> 或第三方库如 fmt 进行格式化是更好的选择。Boost.Format 也是一个选择,尽管与另外两种选择相比,它往往具有非常长的编译时间和较差的运行时性能。如果你发现自己一周内不止一次或两次输入 <<std::hexos.rdbuf(),那么你可能在做错事。

然而,iostreams 库仍然有一些可用甚至有用的功能!让我们看看其中之一。

使用 ostringstream 进行格式化

到目前为止,我们主要讨论了 fstream,它大致对应于 C API 中的 fprintfvfprintf 格式化函数。还有一个 stringstream,它对应于 snprintfvsnprintf

ostringstream 就像 ostream,公开了所有常见的 operator<< 功能;然而,它背后是由 stringbuf 支持的,它不是写入文件描述符,而是写入可调整大小的字符缓冲区——在实践中,就是 std::string!你可以使用 oss.str() 方法来获取这个字符串的副本供自己使用。这导致了以下习惯用法,例如,“stringifying”任何类型 T 的对象:

    template<class T>
    std::string to_string(T&& t)
    {
      std::ostringstream oss;
      oss << std::forward<T>(t);
      return oss.str();
    }

在 C++17 中,你甚至可以考虑 to_string 的多参数版本:

    template<class... Ts>
    std::string to_string(Ts&&... ts)
    {
      std::ostringstream oss;
      (oss << ... << std::forward<Ts>(ts));
      return oss.str();
    }

使用这个版本,调用 to_string(a, " ", b)to_string(std::hex, 42) 将具有适当的语义。

关于区域设置的一则笔记

无论何时使用 printfostream 进行字符串格式化(或字符串解析),都应小心一个潜在的陷阱。这个陷阱就是 区域设置。区域设置的全面处理超出了本书的范围;然而,简而言之,区域设置 是“用户环境的一个子集,它依赖于语言和文化惯例。”区域信息通过操作系统以编程方式公开,允许单个程序根据当前用户的偏好区域调整其行为,例如,控制“á”是否被视为字母字符,一周是否从星期日开始或星期一开始,日期是否打印为“23-01-2017”或“01-23-2017”,以及浮点数是否打印为“1234.56”或“1.234,56”。现在,21 世纪的程序员可能会看到所有这些例子,然后说,“这太疯狂了!我的意思是,这些都不是由一个 标准 指定的?这种情况似乎不可避免地会导致微妙的和痛苦的错误!”你的看法是正确的!

    std::setlocale(LC_ALL, "C.UTF-8");
    std::locale::global(std::locale("C.UTF-8"));

    auto json = to_string('[', 3.14, ']');
    assert(json == "[3.14]"); // Success!

    std::setlocale(LC_ALL, "en_DK.UTF-8");
    std::locale::global(std::locale("en_DK.UTF-8"));

    json = to_string('[', 3.14, ']');
    assert(json == "[3,14]"); // Silent, abject failure!

通过将 全局区域设置 改为 "en_DK.UTF-8",我们已经使得我们的 JSON 打印不再工作。不幸的用户如果尝试在任何不是 "C.UTF-8" 的区域设置中运行网络服务器或数据库,那可就糟了!

除了区域特定编程的正确性成本外,我们还必须应对性能成本。请注意,“当前区域”是一个 全局变量,这意味着每次访问它都必须由原子访问或——更糟糕的是——全局互斥锁来保护。此外,每次调用 snprintfoperator<<(ostream&, double) 都必须访问当前区域。这是一个巨大的性能成本,在特定场景中,这实际上可能是多线程代码的性能瓶颈。

作为应用程序程序员,对于具有一定复杂度的应用程序,你应该养成在 main() 的第一行写入 std::locale::global(std::locale("C")) 的习惯。(如果你只写 setlocale(LC_ALL, "C"),就像在 C 程序中那样,你会使 <stdio.h> 正确工作,但不会影响 <iostream> 使用的区域设置。换句话说,设置 C++ 库的“全局区域设置”也会修改 C 库的“全局区域设置”,但反之则不然。)

如果你甚至不信任你的用户使用 UTF-8,可能更喜欢 "C.UTF-8" 而不是仅仅 "C";然而,请注意,"C.UTF-8" 的名称自 2015 年左右以来才出现,可能在较旧的系统上不可用。实际上,除了 "C" 以外的任何区域设置的可用性取决于用户。在这方面,区域设置类似于时区:世界上只有一个区域设置和一个时区是 保证 可在任何平台上使用的,而且不是巧合的是,它就是你应该始终使用的那个。

作为第三方库的程序员,你有两条可能的路径。较容易的路径是假设你的库将只会在将全局区域设置设置为 "C" 的应用程序中使用,因此你不需要担心区域设置;尽情地使用 snprintfoperator<< 吧。(然而,请注意,这并不能解决与区域感知编程相关的性能问题。那个全局互斥锁仍然存在,占用宝贵的周期。)较难的路径——之所以困难,是因为它要求对细微的指南进行负责任的遵守——是避免使用所有区域感知的格式化函数。这条路径直到 C++17 才真正可行,得益于一些最新的库功能,我们现在将转向这些功能。

将数字转换为字符串

考虑以下声明:

    std::ostringstream oss;
    std::string str;
    char buffer[100];
    int intvalue = 42;
    float floatvalue = 3.14;
    std::to_chars_result r;

要将 intvalue 整数转换为数字字符串,C++17 给我们提供了以下选项:

    snprintf(buffer, sizeof buffer, "%d", intvalue);
      // available in <stdio.h>
      // locale-independent (%d is unaffected by locales)
      // non-allocating
      // bases 8, 10, 16 only

    oss << intvalue;
    str = oss.str();
      // available in <sstream>
      // locale-problematic (thousands separator may be inserted)
      // allocating; allocator-aware
      // bases 8, 10, 16 only

    str = std::to_string(intvalue);
      // available since C++11 in <string>
      // locale-independent (equivalent to %d)
      // allocating; NOT allocator-aware
      // base 10 only

    r = std::to_chars(buffer, std::end(buffer), intvalue, 10);
    *r.ptr = '\0';
      // available since C++17 in <charconv>
      // locale-independent by design
      // non-allocating
      // bases 2 through 36

所有四种替代方案都有其优点。std::to_string 的主要优点是它方便地组合成高层代码中的更大消息:

    std::string response =
      "Content-Length: " + std::to_string(body.size()) + "\r\n" +
      "\r\n" +
      body;

std::to_chars 的主要优点是它是区域无关的,并且它可以在底层代码中轻松组合:

    char *write_string(char *p, char *end, const char *from)  
    {
      while (p != end && *from != '\0') *p++ = *from++;
      return p;
    }

    char *write_response_headers(char *p, char *end, std::string body)
    {
      p = write_string(p, end, "Content-Length: ");
      p = std::to_chars(p, end, body.size(), 10).ptr;
      p = write_string(p, end, "\r\n\r\n");
      return p;
    }

std::to_chars 的主要缺点是它是 C++17 的一个非常新的特性;截至本文撰写时,<charconv> 头文件尚未出现在任何主要标准库的实现中。

要将浮点数floatvalue转换为数字字符串,C++17 为我们提供了以下选项:

    snprintf(buffer, sizeof buffer, "%.6e", floatvalue);
    snprintf(buffer, sizeof buffer, "%.6f", floatvalue);
    snprintf(buffer, sizeof buffer, "%.6g", floatvalue);
      // available in <stdio.h>
      // locale-problematic (decimal point)
      // non-allocating

    oss << floatvalue;
    str = oss.str();
      // available in <sstream>
      // locale-problematic (decimal point)
      // allocating; allocator-aware

    str = std::to_string(floatvalue);
      // available since C++11 in <string>
      // locale-problematic (equivalent to %f)
      // allocating; NOT allocator-aware
      // no way to adjust the formatting

    r = std::to_chars(buffer, std::end(buffer), floatvalue,
                      std::chars_format::scientific, 6);
    r = std::to_chars(buffer, std::end(buffer), floatvalue,
                      std::chars_format::fixed, 6);
    r = std::to_chars(buffer, std::end(buffer), floatvalue,
                      std::chars_format::general, 6);
    *r.ptr = '\0';
      // available since C++17 in <charconv>
      // locale-independent by design
      // non-allocating

注意,在打印浮点数时,除了std::to_string之外的所有方法都提供了调整格式的可能性;除了std::to_chars之外的所有方法都是区域感知的,因此在可移植代码中可能存在问题。所有这些方法都适用于doublelong double数据类型,以及float。在任何情况下,整数格式化相同的相应优点和缺点都适用。

将字符串转换为数字

格式化数字以供输出的反向问题是解析用户输入中的数字。解析本质上比格式化更微妙和困难,因为我们必须考虑到错误的可能性。每个数字都可以合理地转换为一个数字字符串,但并非每个字符串(甚至每个数字字符串!)都可以合理地转换为一个数字。因此,任何声称可以解析数字的函数都必须有一种处理不表示有效数字的字符串的方法。

考虑以下声明:

    std::istringstream iss;
    std::string str = "42";
    char buffer[] = "42";
    int intvalue;
    float floatvalue;
    int rc;
    char *endptr;
    size_t endidx;
    std::from_chars_result r;

要将bufferstr中的字符串转换为intvalue整数,C++17 为我们提供了以下选项:

    intvalue = strtol(buffer, &endptr, 10);
      // saturates on overflow
      // sets global "errno" on most errors
      // sets endptr==buffer when input cannot be parsed
      // available in <stdlib.h>
      // locale-problematic, in theory
      // non-allocating
      // bases 0 and 2 through 36
      // always skips leading whitespace
      // skips leading 0x for base 16
      // recognizes upper and lower case

    rc = sscanf(buffer, "%d", &intvalue);
      // fails to detect overflow
      // returns 0 (instead of 1) when input cannot be parsed
      // available in <stdio.h>
      // locale-problematic (equivalent to strtol)
      // non-allocating
      // bases 0, 8, 10, 16 only
      // always skips leading whitespace
      // skips leading 0x for base 16
      // recognizes upper and lower case

    intvalue = std::stoi(str, &endidx, 10);
      // throws on overflow or error
      // available since C++11 in <string>
      // locale-problematic (equivalent to strtol)
      // NOT allocator-aware
      // bases 0 and 2 through 36
      // always skips leading whitespace
      // skips leading 0x for base 16
      // recognizes upper and lower case

    iss.str("42");
    iss >> intvalue;
      // saturates on overflow
      // sets iss.fail() on any error
      // available in <sstream>
      // locale-problematic
      // allocating; allocator-aware
      // bases 8, 10, 16 only
      // skips leading 0x for base 16
      // skips whitespace by default

    r = std::from_chars(buffer, buffer + 2, intvalue, 10);
      // sets r.ec != 0 on any error
      // available since C++17 in <charconv>
      // locale-independent by design
      // non-allocating
      // bases 2 through 36
      // always skips leading whitespace
      // recognizes lower case only

这里提供的解析方法比上一节中提供的格式化方法要多;这是因为 C 标准库本身就提供了三种不同的方法:atoi,这是最老的方法,也是唯一一个在无效输入上行为实际上未定义的方法,因此在生产代码中应避免使用;strtol,是atoi的标准替代品,它通过全局变量errno来传达溢出错误,这可能不适合线程或高性能代码);以及sscanf,这是一个与snprintf同族的函数。

std::stoiatoi在一次性解析用户输入时的一个非常好的替代品,但在高性能工作中则是一个很糟糕的选择。它能够很好地检测错误--std::stoi("2147483648")会抛出std::out_of_range,而std::stoi("abc")会抛出std::invalid_argument。(尽管std::stoi("42abc")会无声地返回 42,但std::stoi("42abc", &endidx)调用会将endidx设置为 2 而不是 5,这表明可能存在问题。) std::stoi的主要缺点是它只适用于精确的std::string类型--没有为string_view提供std::stoi的重载,也没有为std::pmr::string提供重载,当然也没有为const char *提供重载!

std::from_chars是最新且性能最优的解析整数选项。其主要优势在于,与其他任何竞争者不同,from_chars不需要其输入缓冲区以空字符终止--它通过一对begin, end指针来指示要解析的字符范围,并且永远不会读取超过end。它仍然存在一些不幸的限制--例如,它不能被教会不跳过空白字符,也不能被教会解析大写十六进制输入。测试r.ec是否出现错误的惯用方法在第十二章的开头部分展示,文件系统

strtolsscanfstoi函数表明它们识别“基数 0”。这是库中的特殊案例语法,其中传递基数0(或者在sscanf的情况下,格式说明符为"%i")告诉库将输入解析为 C 整数字面量:0123将被解析为十进制 83 的八进制表示,0x123将被解析为 291 的十六进制表示,019将被解析为整数 1 的八进制表示,字符9未被解析,因为它不是有效的八进制数字。对于计算机程序来说,“基数 0”永远不会是适当的行为,而from_chars明智地将它扔进了垃圾桶,那里才是它应该去的地方。

将字符串转换为浮点数floatvalue,C++17 提供了以下几种选择:

    floatvalue = strtof(buffer, &endptr);
      // saturates on overflow
      // sets global "errno" on most errors
      // sets endptr==buffer when input cannot be parsed
      // available in <stdlib.h>
      // locale-problematic
      // non-allocating
      // base 10 or 16, auto-detected
      // always skips leading whitespace

    rc = sscanf(buffer, "%f", &floatvalue);
      // fails to detect overflow
      // returns 0 (instead of 1) when input cannot be parsed
      // available in <stdio.h>
      // locale-problematic (equivalent to strtof)
      // non-allocating
      // base 10 or 16, auto-detected
      // always skips leading whitespace

    floatvalue = std::stof(str, &endidx);
      // throws on overflow or error
      // available since C++11 in <string>
      // locale-problematic (equivalent to strtol)
      // NOT allocator-aware
      // base 10 or 16, auto-detected
      // always skips leading whitespace

    iss.str("3.14");
    iss >> floatvalue;
      // saturates on overflow
      // sets iss.fail() on any error
      // available in <sstream>
      // locale-problematic
      // allocating; allocator-aware
      // base 10 or 16, auto-detected
      // skips whitespace by default
      // non-portable behavior on trailing text

    r = std::from_chars(buffer, buffer + 2, floatvalue,
                        std::chars_format::general);
      // sets r.ec != 0 on any error
      // available since C++17 in <charconv>
      // locale-independent by design
      // non-allocating
      // base 10 or 16, auto-detected
      // always skips leading whitespace

所有这些解析器--甚至包括std::from_chars--都接受输入字符串"Infinity""Nan"(不区分大小写),并且也接受“十六进制浮点数”输入,例如,"0x1.c"将被解析为十进制数 1.75。除了std::from_chars之外,所有这些解析器都是区域感知的,因此在可移植代码中存在问题。在整数解析中遇到区域问题的理论性很大,但在实际应用中,广泛使用.不是小数分隔符的区域意味着,很容易遇到std::stofstd::stod不按预期工作的案例:

    std::setlocale(LC_ALL, "C.UTF-8");
    assert(std::stod("3.14") == 3.14); // Success!
    std::setlocale(LC_ALL, "en_DK.UTF-8");
    assert(std::stod("3.14") == 3.00); // Silent, abject failure!

顺便提一下,与istringstream相关的“在尾部文本上的不可移植行为”。不同的库供应商对流输入的处理方式不同,并不总是清楚哪一种应该被认为是“正确的”:

    double d = 17;
    std::istringstream iss("42abc");
    iss >> d;
    if (iss.good() && d == 42) {
      puts("Your library vendor is libstdc++");
    } else if (iss.fail() && d == 0) {
      puts("Your library vendor is libc++");
    }

由于这些可移植性问题--这是流输入在一般情况下微妙复杂性的症状--我建议您避免使用istringstream进行输入解析,尽管ostringstream有时可能是输出格式化的最佳选择。

另一个很好的经验法则是将输入的 验证(或 词法分析)与输入的 解析 分开。如果你可以在事先验证某个字符串包含所有数字,或者与有效浮点数的正则表达式语法匹配,那么你只需要选择一个可以检测溢出和/或尾随文本的解析方法;例如,std::stofstd::from_chars。有关使用正则表达式进行词法分析输入的更多信息,请参阅第十章,正则表达式

逐行或逐词读取

从标准输入逐行读取是一个非常常见的简单脚本任务,大多数脚本语言都将其简化为一行代码。例如,在 Python 中:

    for line in sys.stdin:
    # preserves trailing newlines
    process(line)

在 Perl 中:

    while (<>) {
      # preserves trailing newlines
      process($_);
    }

在 C++ 中,这项任务几乎同样简单。请注意,与其它语言的惯用方法不同,C++ 的 std::getline 函数从它读取的每一行中移除了尾随的换行符(如果有的话):

    std::string line;  
    while (std::getline(std::cin, line)) {
      // automatically chomps trailing newlines
      process(line);
    }

在这些情况下,整个输入一次不会全部存储在内存中;我们确实是以一种有效的方式通过程序“流式传输”这些行。(并且 std::getline 函数是分配器感知的;如果我们绝对需要避免堆分配,我们可以将 std::string line 交换为 std::pmr::string。)process 函数可以取每一行,并使用正则表达式(见第十章,正则表达式)进行验证并将行分割成字段以进行进一步解析。

isspace to separate words correctly, of course):
    template<class T>
    struct streamer {
      std::istream& m_in;
      explicit streamer(std::istream& in) : m_in(in) {}
      auto begin() const
        { return std::istream_iterator<T>(m_in); }
      auto end() const
        { return std::istream_iterator<T>{}; }
    };

    int main()
    {
      for (auto word : streamer<std::string>(std::cin)) {
        process(word);
      }
    }

std::istream_iterator<T> 是一个标准库类型,定义在 <iterator> 头文件中,它封装了一个指向 istream 的指针。迭代器的 operator++ 从 istream 中读取类型为 T 的值,就像通过 operator>> 一样,并且这个值由迭代器的 operator* 返回。将所有这些放在一起,这允许我们通过依赖于 std::istream::operator>>(std::string&) 读取单个空白分隔的单词的事实,从 std::cin 中读取一系列由空白分隔的单词。

我们可以重用我们的 streamer 类模板来从 std::cin 中读取一系列整数并对每个整数进行处理:

    // Double every int the user gives us
    for (auto value : streamer<int>(std::cin)) {
      printf("%d\n", 2*value);
    }

虽然 C++ 的 I/O 功能确实非常复杂,正如其根源在 1980 年代的系统编程语言所应具备的那样,但我们从这些最后的几个例子中看到,仍然可以在抽象层下面隐藏这种复杂性,并最终得到几乎与 Python 一样简单的代码。

摘要

数据输出可以大致分为 格式化缓冲。数据输入可以同样大致分为 缓冲解析;尽管,如果你可以在前面加入一个 词法分析 步骤,解析步骤会更容易。(我们将在下一章中更多地讨论词法分析!)

经典的 iostreams API 是建立在 <stdio.h> 之上的,而 <stdio.h> 又是建立在 POSIX 文件描述符 API 之上的。如果不了解其下层的级别,就无法理解更高层的级别。特别是,fopen 模式字符串和 fstream 构造函数标志的混乱只能通过参考将它们映射到实际底层 POSIX open 标志的查找表来理解。

POSIX API 仅关注将数据块在文件描述符之间移动;它并不在直观意义上“缓冲”数据。<stdio.h> API 在 POSIX 之上添加了一层缓冲;C 的 FILE 可以是完全缓冲、行缓冲或无缓冲。此外,<stdio.h> 提供了性能良好的(但考虑地区设置的)格式化例程,其中最重要的是 fprintfsnprintfsscanf

<iostream> API 将“streambuf”(它标识原始字节的来源或目的地及其缓冲模式)与“stream”(它持有与格式化相关的状态)分开。不同类型的流(输入或输出?文件或字符串?)形成一个经典的泛型层次结构,具有复杂且有时不直观的继承关系。在生产代码中避免使用 <iostream> 是更好的选择,因为它与 <stdio.h> 或 POSIX 接口相比既慢又透明。无论如何,要小心地区依赖的格式化例程。

对于一次性快速任务,建议通过 std::stoi 解析数字,它会检测错误并抛出异常,并通过 std::to_stringsnprintf 进行格式化。对于高性能情况,如果可以找到支持这些来自 <charconv> 头文件的新函数的库实现,则使用 std::from_chars 进行解析和 std::to_chars 进行格式化是更好的选择。

第十章:正则表达式

在上一章中,我们学习了 C++ 中的格式化输入和输出。我们了解到,只要确保你处于 C 位置,格式化输出就有很好的解决方案,但尽管有众多输入解析方法,即使是解析字符串中的 int 这样的简单任务也可能相当困难。(回想一下,在两种最保险的方法中,std::stoi(x) 需要将 x 转换为堆分配的 std::string,而冗长的 std::from_chars(x.begin(), x.end(), &value, 10) 在 C++17 的供应商采用方面落后于其他部分。)解析数字中最棘手的部分是确定如何处理输入中 不是 数字的部分!

如果可以将解析任务分解为两个子任务,解析会变得更容易:首先,确定输入中对应于一个“输入项”的确切字节数(这被称为 词法分析);其次,解析该项的值,如果该项的值超出范围或无意义,则进行一些错误恢复。如果我们将这种方法应用于整数输入,词法分析 对应于找到输入中最长的初始数字序列,而 解析 对应于计算该序列的十进制数值。

正则表达式(或 regexes)是许多编程语言提供的一种工具,用于解决词法分析问题,不仅适用于数字序列,还适用于任意复杂的输入格式。自 2011 年以来,正则表达式一直是 C++ 标准库的一部分,位于 <regex> 头文件中。在本章中,我们将向您展示如何使用正则表达式简化一些常见的解析任务。

请记住,正则表达式对于你日常工作中遇到的 大多数 解析任务来说可能是过度杀鸡用牛刀。它们可能很慢、体积庞大,并且不可避免地需要堆分配(即,正则表达式数据类型不是如 第八章 中描述的 分配器感知)。正则表达式真正发光的地方是对于即使手写的解析代码也会很慢的复杂任务;以及对于极其简单的任务,正则表达式的可读性和健壮性超过了它们的性能成本。简而言之,正则表达式支持使 C++ 向日常可使用脚本语言(如 Python 和 Perl)迈进了一步。

在本章中,我们将学习:

  • “修改后的 ECMAScript”,C++ 正则表达式使用的方言

  • 如何使用正则表达式匹配、搜索甚至替换子串

  • 悬挂迭代器的进一步危险

  • 避免的正则表达式功能

正则表达式是什么?

正则表达式是一种记录识别字符串字节或字符是否属于(或不属于)某种“语言”规则的方法。在这个语境中,“语言”可以是“所有数字序列的集合”到“所有有效 C++标记序列的集合”的任何东西。本质上,“语言”只是将所有字符串的世界划分为两个集合——匹配语言规则的字符串集合,以及不匹配的字符串集合。

一些类型的语言遵循足够简单的规则,以至于可以通过一个“有限状态机”来识别,这是一个完全没有记忆的计算机程序——只是一个程序计数器和扫描输入的单个指针。数字序列的语言当然属于可以通过有限状态机识别的语言类别。我们称这些语言为“正则语言”。

还存在非正则语言。一个非常常见的非正则语言是“有效的算术表达式”,或者简化其本质,就是“正确匹配的括号”。任何能够区分正确匹配的字符串(((())))与不正确匹配的字符串(((()))(((()))))的程序,本质上必须能够“计数”——区分四个括号的情况与三个或五个括号的情况。这种计数方式不能没有可修改的变量或下推栈;因此,括号匹配不是正则语言。

结果表明,对于任何正则语言,都存在一种简单直接的方法来编写识别它的有限状态机的表示,这当然也是语言规则的表示。我们称这种表示为“正则表达式”,或“regex”。正则表达式的标准符号是在 20 世纪 50 年代开发的,并在 20 世纪 70 年代末的 Unix 程序(如grepsed)中得到确立——这些程序至今仍非常值得学习,但当然超出了本书的范围。

C++标准库提供了几种不同的正则表达式语法“风味”,但默认风味(以及你应该始终使用的风味)是从 ECMAScript 标准(更广为人知的 JavaScript 语言)全面借鉴的,只是在方括号结构附近进行了少量修改。我在本章末尾包含了一个关于 ECMAScript 正则表达式语法的入门介绍;但如果你曾经使用过grep,你将能够轻松地跟随本章的其余部分,而无需查阅该部分。

关于反斜杠转义说明

在本章中,我们将频繁地提到包含字面反斜杠的字符串和正则表达式。正如你所知,要在 C++ 中写入包含字面反斜杠的字符串,你必须用另一个反斜杠来 转义 反斜杠:因此 "\n" 表示一个换行符,但 "\\n" 表示由“反斜杠”和“n”组成的两个字符字符串。这类事情通常很容易跟踪,但在这个章节中,我们不得不特别小心。正则表达式完全作为库特性实现;所以当你写 std::regex("\n") 时,正则表达式库会看到一个只包含单个空白字符的“正则表达式”,如果你写 std::regex("\\n"),库会看到一个以反斜杠开头的两个字符字符串,库会 解释 它为一个表示“换行”的两个字符转义序列。如果你想将 字面 反斜杠-n 的概念传达给正则表达式库,你必须让正则表达式库看到三个字符字符串 \\\\n,这意味着在 C++ 源代码中写入五个字符字符串 "\\\\n"

你可能在前一段落注意到了我将在本章中使用的解决方案。当我提到一个 C++ 字符串字面量 或字符串值时,我会用双引号将其括起来,就像这样:"cat","a.b"。当我提到一个 正则表达式,就像你在电子邮件或文本编辑器中输入的那样,或者将其传递给库进行评估时,我将不使用引号来表示:cata\.b。只需记住,当你看到未加引号的字符串时,那是一个字符序列的字面表示,如果你想要将其放入 C++ 字符串字面量中,你需要将所有的反斜杠都加倍,因此:a\.b 将在源代码中以 std::regex("a\\.b") 的形式出现。

我听到一些人在问:那么 原始字符串字面量 呢?原始字符串字面量是 C++11 中的一个特性,它允许你通过使用 R 和一些括号来“转义”整个字符串来写出 a\.b 这样的字符序列,就像这样--R"(a\.b)"--而不是转义字符串中的每个反斜杠。如果你的字符串本身包含括号,那么你可以在第一个括号之前和最后一个括号之后写任何任意字符串,就像这样:R"fancy(a\.b)fancy"。这样的原始字符串字面量可以包含任何字符--反斜杠、引号,甚至是换行符--只要它不包含连续的序列 )fancy"(如果你认为它可能包含这个序列,那么你只需选择一个新的任意字符串,例如 )supercalifragilisticexpialidocious")。

C++原始字符串字面量的语法,其前缀为R,让人联想到 Python 中原始字符串字面量的语法(其前缀为r)。在 Python 中,r"a\.b"同样表示字面量字符串a\.b;在代码中,用如r"abc"这样的字符串表示正则表达式是既常见又符合习惯的,即使它们不包含任何特殊字符。但请注意r"a\.b"R"(a\.b)"之间至关重要的区别——C++版本有一个额外的括号组!并且括号是正则表达式语法中的重要特殊字符。C++字符串字面量"(cat)"R"(cat)"与白天和黑夜一样不同——前者表示五个字符的正则表达式(cat),而后者表示三个字符的字符串cat。如果你不小心写了R"(cat)"而本意是"(cat)"(或者等价地,R"((cat))"),你的程序将会有一个非常微妙的错误。甚至更糟糕的是,R"a*(b*)a*"是一个具有惊人含义的有效正则表达式!因此,我建议你在使用原始字符串字面量表示正则表达式时要非常小心;通常,双重所有反斜杠比只担心双重最外层的括号更安全、更清晰。

原始字符串字面量适用于其他语言所说的“heredocs”:

    void print_help() {
      puts(R"(The regex special characters are:
      \ - escaping
      | - separating alternatives
      . - match any character
      [] - character class or set
      () - capturing parentheses, or lookahead
      ?*+ - "zero or one", "zero or more", "one or more"
      {} - "exactly N" or "M to N" repetitions
      ^$ - beginning and end of a "line"
      \b - word boundary
      \d \s \w - digit, space, and word
      (?=foo) (?!foo) - lookahead; negative lookahead
    )");

也就是说,原始字符串字面量是 C++中唯一可以不进行任何转义就编码换行符的字符串字面量。这对于向用户打印长消息或可能用于 HTTP 头等用途非常有用;但是原始字符串与括号的行为使得它们在使用正则表达式时稍微有些危险——我不会在这本书中使用它们。

将正则表达式实体化为std::regex对象

要在 C++中使用正则表达式,你不能直接使用如"c[a-z]*t"这样的字符串。相反,你必须使用这个字符串来构建一个正则表达式对象,类型为std::regex,然后将regex对象作为参数之一传递给匹配函数,例如std::regex_matchstd::regex_searchstd::regex_replace。每个std::regex类型的对象都编码了给定表达式的完整有限状态机,构建这个有限状态机需要大量的计算和内存分配;因此,如果我们需要将大量的输入文本与相同的正则表达式进行匹配,那么库提供一种只需支付一次这种昂贵构建的方法是非常方便的。另一方面,这也意味着std::regex对象构建相对较慢,复制成本较高;在紧缩的内循环中构建正则表达式是降低程序性能的好方法:

    std::regex rx("(left|right) ([0-9]+)");
    // Construct the regex object "rx" outside the loop.
    std::string line;
    while (std::getline(std::cin, line)) {
      // Inside the loop, use the same "rx" over and over.
      if (std::regex_match(line, rx)) {
        process_command(line);
      } else {
        puts("Unrecognized command.");
      }
    }

请记住,这个 regex 对象具有值语义;当我们“匹配”一个输入字符串与正则表达式时,我们并没有修改 regex 对象本身。正则表达式没有记忆它匹配过什么。因此,当我们想要从正则表达式匹配操作中提取信息——例如,“命令是否说要向左或向右移动?我们看到了什么数字?”——我们将不得不引入一个新的实体,我们可以对其进行修改。

regex 对象提供了以下方法:

std::regex(str, flags) 通过将给定的 str 转换(或“编译”)成有限状态机来构建一个新的 std::regex 对象。可以通过位掩码参数 flags 指定影响编译过程本身的选项:

  • std::regex::icase:将所有字母字符视为不区分大小写

  • std::regex::nosubs:将所有括号组视为非捕获组

  • std::regex::multiline:使非消耗性断言 ^(和 $)在输入中的 "\n" 字符之后(和之前)立即匹配,而不是仅在输入的开始(和结束)处匹配

你可以将其他几个选项按位或到标志中;但其他选项要么将正则表达式语法“风味”从 ECMAScript 转向文档较少且测试较少的风味(basicextendedawkgrepegrep),引入区域设置依赖性(collate),或者根本不执行任何操作(optimize)。因此,你应该在生产代码中避免使用所有这些选项。

注意,尽管将字符串转换为 regex 对象的过程通常被称为“编译正则表达式”,但它仍然是一个动态过程,在调用 regex 构造函数时发生,而不是在编译你的 C++ 程序期间。如果你在正则表达式中犯了语法错误,它将在运行时被捕获,而不是在编译时——regex 构造函数将抛出一个类型为 std::regex_error 的异常,它是 std::runtime_error 的子类。健壮的代码还应该准备好 regex 构造函数抛出 std::bad_alloc;回想一下,std::regex 不是分配器感知的。

rx.mark_count() 返回正则表达式中的括号捕获组的数量。这个方法的名字来源于短语“标记子表达式”,这是“捕获组”的一个较老的别名。

rx.flags() 返回最初传递给构造函数的位掩码。

匹配和搜索

要询问给定的输入字符串 haystack 是否符合给定的正则表达式 rneedle,你可以使用 std::regex_match(haystack, rneedle)。正则表达式始终放在最后,这与 JavaScript 的语法 haystack.match(rneedle) 和 Perl 的 haystack =~ rneedle 相似,尽管它与 Python 的 re.match(rneedle, haystack) 相反。如果正则表达式匹配整个输入字符串,则 regex_match 函数返回 true,否则返回 false

    std::regex rx("(left|right) ([0-9]+)");
    std::string line;
    while (std::getline(std::cin, line)) {
      if (std::regex_match(line, rx)) {
        process_command(line);
      } else {
        printf("Unrecognized command '%s'.\n",
          line.c_str());
      }
    }

regex_search 函数在正则表达式与输入字符串的任何部分匹配时返回 true。本质上,它只是在提供的正则表达式两边加上 .*,然后运行 regex_match 算法;但实现通常可以比重新编译整个新的正则表达式更快地执行 regex_search

要在字符缓冲区的一部分(例如,当你从网络连接或文件中批量拉取数据时)进行匹配,你可以将迭代器对传递给 regex_matchregex_search,这与我们在 第三章 中看到的非常相似,The Iterator-Pair Algorithms。在下面的例子中,范围 [p, end) 之外的字节永远不会被考虑,并且 "string" p 不需要以空字符终止:

    void parse(const char *p, const char *end)
    {
      static std::regex rx("(left|right) ([0-9]+)");
      if (std::regex_match(p, end, rx)) {
        process_command(p, end);
      } else {
        printf("Unrecognized command '%.*s'.\n",
          int(end - p), p);
      }
    }

此接口与我们之前在 第九章 中看到的 std::from_chars 类似,Iostreams

从匹配中提取子匹配

要使用正则表达式进行输入的 lexing 阶段,你需要一种方法来提取匹配每个捕获组的输入子字符串。在 C++ 中,你通过创建一个类型为 std::smatchmatch 对象 来这样做。不,这不是一个打字错误!match 对象类型的名称确实是 smatch,代表 std::string match;还有一个 cmatch 用于 const char * 匹配。smatchcmatch 之间的区别是它们内部存储的 迭代器类型smatch 存储 string::const_iterator,而 cmatch 存储 const char *

在构建了一个空的 std::smatch 对象后,你将通过引用将其作为 regex_matchregex_search 的中间参数传递。这些函数将 "填充" smatch 对象,包含有关匹配的子字符串的信息,如果 正则表达式匹配实际上成功了。如果匹配失败,那么 smatch 对象将变为(或保持)空。

这里是一个使用 std::smatch 从我们的 "robot command" 中提取匹配方向和整数距离的子字符串的例子:

    std::pair<std::string, std::string>
    parse_command(const std::string& line)
    {
      static std::regex rx("(left|right) ([0-9]+)");
      std::smatch m;
      if (std::regex_match(line, m, rx)) {
        return { m[1], m[2] };
      } else {
        throw "Unrecognized command!";
      }
    }

    void test() {
      auto [dir, dist] = parse_command("right 4");
      assert(dir == "right" && dist == "4");
    }

注意,我们使用一个 static 正则表达式对象来避免每次函数进入时都构造("编译")一个新的正则表达式对象。以下代码使用 const char *std::cmatch 仅用于比较:

    std::pair<std::string, std::string>
    parse_command(const char *p, const char *end)
    {
      static std::regex rx("(left|right) ([0-9]+)");
      std::cmatch m;
      if (std::regex_match(p, end, m, rx)) {
        return { m[1], m[2] };
      } else {
        throw "Unrecognized command!";
      }
    }

    void test() {
      char buf[] = "left 20";
      auto [dir, dist] = parse_command(buf, buf + 7);
      assert(dir == "left" && dist == "20");
    }

在这两种情况下,在带有 return 的行上都会发生一些有趣的事情。在成功将输入字符串与我们的正则表达式匹配后,我们可以查询匹配对象 m 来找出输入字符串中哪些部分对应于正则表达式中的各个捕获组。在我们的例子中,第一个捕获组 ((left|right)) 对应于 m[1],第二个组 (([0-9]+)) 对应于 m[2],依此类推。如果你尝试引用正则表达式中不存在的组,例如我们的例子中的 m[3],你将得到一个空字符串;访问匹配对象永远不会抛出异常。

m[0] 是一个特殊情况:它指的是整个匹配序列。如果匹配是由 std::regex_match 填充的,这将始终是整个输入字符串;如果匹配是由 std::regex_search 填充的,那么这将只是与正则表达式匹配的字符串部分。

此外,还有两个命名组:m.prefix()m.suffix()。这些指的是不是匹配部分的序列——分别在匹配子串之前和之后。如果匹配成功,则 m.prefix() + m[0] + m.suffix() 表示整个输入字符串。

所有这些“组”对象都不是由 std::string 对象表示的——那会太昂贵了——而是由轻量级的 std::sub_match<It> 类型对象表示(其中 Itstd::string::const_iteratorconst char *,如前所述)。每个 sub_match 对象都可以隐式转换为 std::string,并且其行为在很大程度上类似于 std::string_view:你可以比较子匹配与字符串字面量,询问它们的长度,甚至可以使用 operator<< 将它们输出到 C++ 流中,而无需将它们转换为 std::string。这种轻量级效率的缺点是,每次我们处理指向可能不属于我们的容器的迭代器时都会遇到的同样缺点:我们面临 悬垂迭代器 的风险:

    static std::regex rx("(left|right) ([0-9]+)");
    std::string line = "left 20";
    std::smatch m;
    std::regex_match(line, m, rx);
      // m[1] now holds iterators into line
    line = "hello world";
      // reallocate line's underlying buffer
    std::string oops = m[1];
      // this invokes undefined behavior because
      // of iterator invalidation
const char * to std::string) might cause iterator-invalidation bugs in harmless-looking code. Consider the following:
    static std::regex rx("(left|right) ([0-9]+)");
    std::smatch m;
    std::regex_match("left 20", m, rx);
      // m[1] would hold iterators into a temporary
      // string, so they would ALREADY be invalid.
      // Fortunately this overload is deleted.

幸运的是,标准库预见到这种潜伏的恐怖,并通过提供特殊案例重载 regex_match(std::string&&, std::smatch&, const std::regex&) 来避免它,该重载是 显式删除的(使用与删除不想要的特殊成员函数相同的 =delete 语法)。这确保了前面的看似无辜的代码将无法编译,而不是成为迭代器无效化错误的来源。尽管如此,迭代器无效化错误仍然可能发生,就像前面的例子中那样;为了防止这些错误,你应该将 smatch 对象视为极其临时的,有点像捕获整个世界的 [&] lambda。一旦 smatch 对象被填充,在提取你关心的 smatch 部分之前,不要触摸环境中的任何其他内容!

总结来说,smatchcmatch 对象提供了以下方法:

  • m.ready(): 如果 m 自构造以来已被填充,则为真。

  • m.empty(): 如果 m 代表一个失败的匹配(即,如果它是最近由失败的 regex_matchregex_search 填充的),则为真;如果 m 代表一个成功的匹配,则为假。

  • m.prefix()m[0]m.suffix():代表输入字符串中未匹配的前缀、匹配和未匹配后缀部分的 sub_match 对象。(如果 m 代表一个失败的匹配,那么这些都没有意义。)

  • m[k]: 代表输入字符串中由第 k 个捕获组匹配的部分的 sub_match 对象。m.str(k)m[k].str() 的便捷简写。

  • m.size(): 如果 m 表示一个失败的匹配,则为零;否则,比表示 m 的正则表达式中捕获组的数量多一个。请注意,m.size() 总是与 operator[] 一致;有意义的子匹配对象的范围始终是 m[0]m[m.size()-1]

  • m.begin()m.end():使能够对匹配对象进行范围 for 循环语法的迭代器。

一个 sub_match 对象提供了以下方法:

  • sm.first: 匹配输入子字符串开头的迭代器。

  • sm.second: 匹配输入子字符串末尾的迭代器。

  • sm.matched: 如果 sm 参与了成功的匹配,则为真;如果 sm 是一个可选分支的一部分,该分支被绕过,则为假。例如,如果正则表达式是 (a)|(b) 并且输入是 "a",则会有 m[1].matched && !m[2].matched;而如果输入是 "b",则会有 m[2].matched && !m[1].matched

  • sm.str(): 匹配的输入子字符串,提取并转换为 std::string

  • sm.length(): 匹配输入子字符串的长度(second - first)。相当于 sm.str().length(),但速度更快。

  • sm == "foo": 与 std::stringconst char * 或单个 char 进行比较。相当于 sm.str() == "foo",但速度更快。不幸的是,C++17 标准库没有提供任何重载的 operator== 操作符,用于接受 std::string_view

虽然你可能在实际代码中永远不会用到这个,但有可能创建一个存储到容器中迭代器(除了 std::stringchar 缓冲区)的匹配或子匹配对象。例如,这里是我们相同的函数,但将正则表达式与 std::list<char> 匹配——愚蠢,但它有效!

    template<class Iter>
    std::pair<std::string, std::string>
    parse_command(Iter begin, Iter end) 
    {
      static std::regex rx("(left|right) ([0-9]+)");
      std::match_results<Iter> m;
      if (std::regex_match(begin, end, m, rx)) {
        return { m.str(1), m.str(2) };
      } else {
        throw "Unrecognized command!";
      }
    }

    void test() {
      char buf[] = "left 20";
      std::list<char> lst(buf, buf + 7);
      auto [dir, dist] = parse_command(lst.begin(), lst.end());
      assert(dir == "left" && dist == "20");
    }

将子匹配转换为数据值

只为了完成解析的闭环,这里有一个例子,说明我们如何从子匹配中解析字符串和整数值,以实际移动我们的机器人:

    int main()
    {
      std::regex rx("(left|right) ([0-9]+)");
      int pos = 0;
      std::string line;
      while (std::getline(std::cin, line)) {
        try {
          std::smatch m;
          if (!std::regex_match(line, m, rx)) {
              throw std::runtime_error("Failed to lex");
          }
          int how_far = std::stoi(m.str(2));
          int direction = (m[1] == "left") ? -1 : 1;
          pos += how_far * direction;
          printf("Robot is now at %d.\n", pos);
        } catch (const std::exception& e) {
          puts(e.what());
          printf("Robot is still at %d.\n", pos);
        }
      }
    }

任何未识别或无效的字符串输入将通过我们自定义的 "Failed to lex" 异常或由 std::stoi() 抛出的 std::out_of_range 异常来诊断。如果我们修改 pos 之前添加一个整数溢出的检查,我们将有一个坚不可摧的输入解析器。

如果我们想要处理负整数和大小写不敏感的方向,以下修改将有效:

    int main()
    {
      std::regex rx("((left)|right) (-?[0-9]+)", std::regex::icase);
      int pos = 0;
      std::string line;
      while (std::getline(std::cin, line)) {
        try {
          std::smatch m;
          if (!std::regex_match(line, m, rx)) {
            throw std::runtime_error("Failed to lex");
          }
          int how_far = std::stoi(m.str(3));
          int direction = m[2].matched ? -1 : 1;
          pos += how_far * direction;
          printf("Robot is now at %d.\n", pos);
        } catch (const std::exception& e) {
          puts(e.what());
          printf("Robot is still at %d.\n", pos);
        }
      }
    }

遍历多个匹配项

考虑正则表达式 (?!\d)\w+,它匹配单个 C++ 标识符。我们已经知道如何使用 std::regex_match 来判断输入字符串是否是 C++ 标识符,以及如何使用 std::regex_search 来找到给定输入行中的第一个 C++ 标识符。但如果我们想要找到给定输入行中的所有 C++ 标识符呢?

这里的基本思想是在循环中调用 std::regex_search。然而,由于非消耗性的“向后看”锚点,如 ^\b,这会变得复杂。要从头开始正确实现 std::regex_search 的循环,我们必须保留这些锚点的状态。std::regex_search(以及 std::regex_match)通过提供自己的标志来支持这种用例——这些标志决定了这个特定匹配操作的有限状态机的 起始状态。对我们来说,唯一重要的标志是 std::regex::match_prev_avail,它告诉库迭代器 begin(表示输入的开始)实际上不在输入的“开始”处(即它可能不匹配 ^),并且如果你想要知道输入的上一字符用于 \b,检查 begin[-1] 是安全的:

    auto get_all_matches(
      const char *begin, const char *end,
      const std::regex& rx,
      bool be_correct)
    {
      auto flags = be_correct ?
      std::regex_constants::match_prev_avail :
      std::regex_constants::match_default;
      std::vector<std::string> result;
      std::cmatch m;
      std::regex_search(begin, end, m, rx);
      while (!m.empty()) {
        result.push_back(m[0]);
        begin = m[0].second;
        std::regex_search(begin, end, m, rx, flags);
      }
      return result;
    }

    void test() {
      char buf[] = "baby";
      std::regex rx("\\bb.");
        // get the first 2 letters of each word starting with "b"
      auto v = get_all_matches(buf, buf+4, rx, false);
      assert(v.size() == 2);
        // oops, "by" is considered to start on a word boundary! 

      v = get_all_matches(buf, buf+4, rx, true);
      assert(v.size() == 1);
        // "by" is correctly seen as part of the word "baby"
    }

在前面的示例中,当 !be_correct 时,每次 regex_search 调用都是独立处理的,所以从单词 "by" 的第一个字母搜索 \bb. 和从单词 "baby" 的第三个字母搜索 \bb. 之间没有区别。但是当我们把 match_prev_avail 传递给 regex_search 的后续调用时,它会实际退后一步——看看 "by" 前面的字母是否是一个“单词”字母。由于前面的 "a" 是一个单词字母,第二个 regex_search 正确地拒绝将 "by" 作为匹配项。

在循环中使用 regex_search 很简单... 除非给定的正则表达式可能会匹配一个空字符串!如果正则表达式返回一个成功的匹配 m,其中 m[0].length() == 0,那么我们就会有一个无限循环。所以我们的 get_all_matches() 的内部循环实际上应该看起来更像是这样:

    while (!m.empty()) {
      result.push_back(m[0]);
      begin = m[0].second;
      if (begin == end) break;
      if (m[0].length() == 0) ++begin;
      if (begin == end) break;
      std::regex_search(begin, end, m, rx, flags);
    }

标准库提供了一个名为 std::regex_iterator 的“便利”类型,它将封装前面代码片段的逻辑;使用 regex_iterator 可能会节省你一些与零长度匹配相关的微妙错误。遗憾的是,它不会节省你的任何打字,而且它略微增加了悬挂迭代器陷阱的可能性。regex_iteratormatch_results 一样,在底层迭代器类型上进行了模板化,所以如果你正在匹配 std::string 输入,你想要 std::sregex_iterator,如果你正在匹配 const char * 输入,你想要 std::cregex_iterator。以下是将前面的示例重新编码为 sregex_iterator 的代码:

    auto get_all_matches(
      const char *begin, const char *end,
      const std::regex& rx)
    {
      std::vector<std::string> result;
      using It = std::cregex_iterator;
      for (It it(begin, end, rx); it != It{}; ++it) {
        auto m = *it;
        result.push_back(m[0]);
      }
      return result;
    }

考虑一下这个笨拙的 for 循环如何从辅助类中受益

来自 第九章末尾的示例 的 streamer<T>Iostreams

你也可以手动遍历每个匹配中的子匹配,或者使用一个“便利”库类型。手动的话,看起来可能像这样:

    auto get_tokens(const char *begin, const char *end,
      const std::regex& rx)
    {
      std::vector<std::string> result;
      using It = std::cregex_iterator;
      std::optional<std::csub_match> opt_suffix;
      for (It it(begin, end, rx); it != It{}; ++it) {
        auto m = *it;
        std::csub_match nonmatching_part = m.prefix();
        result.push_back(nonmatching_part);
        std::csub_match matching_part = m[0];
        result.push_back(matching_part);
        opt_suffix = m.suffix();
      }
      if (opt_suffix.has_value()) {
        result.push_back(opt_suffix.value());
      }
      return result;
    }

回想一下,regex_iterator 只是 regex_search 的包装,所以在这种情况下,m.prefix() 保证包含整个非匹配部分,一直回溯到上一个匹配的末尾。通过交替推送非匹配前缀和匹配项,并以非匹配后缀的特殊情况结束,我们将输入字符串分割成一个交替出现 "单词" 和 "单词分隔符" 的向量。如果要保存的只是 "单词" 或 "分隔符",或者甚至要保存 m[1] 而不是 m[0],则很容易修改此代码;或者甚至保存 m[1] 而不是 m[0],等等。

库类型 std::sregex_token_iterator 非常直接地封装了所有这些逻辑,尽管如果你不熟悉前面的手动代码,其构造函数接口相当复杂。sregex_token_iterator 的构造函数接受一个输入迭代器对、一个正则表达式,然后是一个 子匹配索引的向量,其中索引 -1 是一个特殊情况,表示 "前缀(以及后缀)。"

    auto get_tokens(const char *begin, const char *end,
      const std::regex& rx)
    {
      std::vector<std::string> result;
      using TokIt = std::cregex_token_iterator;
      for (TokIt it(begin, end, rx, {-1, 0}); it != TokIt{}; ++it) {
        std::csub_match some_part = *it;
        result.push_back(some_part);
      }
      return result;
    }

如果我们将数组 {-1, 0} 改为仅 {0},那么我们的结果向量将只包含

仅匹配 rx 的输入字符串的片段。如果我们将其更改为 {1, 2, 3},我们的

循环将只看到每个 rx 匹配 m 中的那些子匹配(m[1]m[2]m[3])。回想一下,由于 | 操作符,子匹配可以被跳过,使得 m[k].matched 为假。regex_token_iterator 不会跳过这些匹配。例如:

    std::string input = "abc123...456...";
    std::vector<std::ssub_match> v;
    std::regex rx("([0-9]+)|([a-z]+)");
    using TokIt = std::sregex_token_iterator;
    std::copy(
      TokIt(input.begin(), input.end(), rx, {1, 2}),
      TokIt(),
      std::back_inserter(v)
    );
    assert(!v[0].matched); assert(v[1] == "abc");
    assert(v[2] == "123"); assert(!v[3].matched);
    assert(v[4] == "456"); assert(!v[5].matched);

regex_token_iterator 最吸引人的用途可能是将字符串在空白边界处分割成 "单词"。不幸的是,它并不比老式方法(如 istream_iterator<string> (见第九章 [part0144.html#49AH00-2fdac365b8984feebddfbb9250eaf20d],Iostreams)或 strtok_r)更容易使用——或者更容易调试。

使用正则表达式进行字符串替换

如果你来自 Perl,或者你经常使用命令行工具 sed,你可能会主要将正则表达式视为修改字符串的一种方式——例如,"删除所有匹配此正则表达式的子串",或者"将所有此单词的实例替换为另一个单词"。C++ 标准库确实提供了一种名为 std::regex_replace 的正则表达式替换功能。它是基于 JavaScript 的 String.prototype.replace 方法,这意味着它自带了一种独特的格式化迷你语言。

std::regex_replace(str, rx, "replacement") 返回一个由 std::string 构造的字符串,该字符串通过在 str 中搜索每个匹配正则表达式 rx 的子串,并将每个这样的子串替换为字面字符串 "replacement"。例如:

    std::string s = "apples and bananas";
    std::string t = std::regex_replace(s, std::regex("a"), "e");
    assert(t == "epples end benenes");
    std::string u = std::regex_replace(s, std::regex("[ae]"), "u");
    assert(u == "upplus und bununus");

然而,如果 "replacement" 包含任何 '$' 字符,会发生特殊的事情!

  • "$&" 被替换为整个匹配子串,m[0]。libstdc++ 和 libc++ 都支持 "$0" 作为 "$&" 的非标准同义词。

  • "$1"被替换为第一个子匹配m[1]"$2"被替换为m[2];以此类推,直到"$99"。无法引用第 100 个子匹配。"$100"表示"m[10]"后面跟着一个字面字符'0'。要表示"m[1]"后面跟着一个字面字符'0',请写"$010"

  • "$"(这是一个反引号)被替换为m.prefix()`。

  • "$'"(这是一个单引号)被替换为m.suffix()

  • "$$"被替换为一个字面美元符号。

注意,"$""\('"`远非对称,因为`m.prefix()`始终指向最后一个匹配的末尾和当前匹配的开始之间的字符串部分,而`m.suffix()`始终指向当前匹配的末尾和字符串末尾之间的字符串部分!你永远不会在实际代码中使用`"\)""$'"

这里是一个使用regex_replace从代码片段中删除所有std::实例或将它们全部更改为my::的示例:

    auto s = "std::sort(std::begin(v), std::end(v))";
    auto t = std::regex_replace(s, std::regex("\\bstd::(\\w+)"), "$1");
    assert(t == "sort(begin(v), end(v))");
    auto u = std::regex_replace(s, std::regex("\\bstd::(\\w+)"), "my::$1");
    assert(u == "my::sort(my::begin(v), my::end(v))");

JavaScript 的String.prototype.replace允许你传入一个任意函数而不是带美元符号的格式字符串。C++的regex_replace目前还不支持任意函数,但可以轻松编写自己的版本来实现这一点:

    template<class F>
    std::string regex_replace(std::string_view haystack,
      const std::regex& rx, const F& f)
    {
      std::string result;
      const char *begin = haystack.data();
      const char *end = begin + haystack.size();
      std::cmatch m, lastm;
      if (!std::regex_search(begin, end, m, rx)) {
        return std::string(haystack);
      }
      do {
        lastm = m;
        result.append(m.prefix());
        result.append(f(m));
        begin = m[0].second;
        begin += (begin != end && m[0].length() == 0);
        if (begin == end) break;
      } while (std::regex_search(begin, end, m, rx,
        std::regex_constants::match_prev_avail));
      result.append(lastm.suffix());
      return result;
    }

    void test()
    {
      auto s = "std::sort(std::begin(v), std::end(v))";
      auto t = regex_replace(s, std::regex("\\bstd::(\\w+)"),
        [](auto&& m) {
          std::string result = m[1].str();
          std::transform(m[1].first, m[1].second,
          begin(result), ::toupper);
          return result;
        });
      assert(t == "SORT(BEGIN(v), END(v))");
    }

使用这个改进的regex_replace,你可以轻松执行复杂的操作,例如“将每个标识符从snake_case转换为CamelCase”。

这就结束了我们对 C++ <regex>头文件中提供的功能的快速浏览。本章的其余部分是对 ECMAScript 方言的正则表达式符号的详细介绍。我希望它对之前没有使用过正则表达式的读者有所帮助,并且对那些已经使用过正则表达式的人来说,它将作为一个复习和参考。

ECMAScript 正则表达式语法的入门指南

在 ECMAScript 方言中读取和编写正则表达式的规则很简单。正则表达式只是一系列字符(例如a[bc].d*e),并且你应该从左到右读取它。大多数字符仅代表自身,因此cat是一个有效的正则表达式,仅匹配字面字符串"cat"。唯一不表示自身的字符——也是构建表示比"cat"更有趣的语言的正则表达式的唯一方式——是以下标点符号:

    ^ $ \ . * + ? ( ) [ ] { } |

\——如果你使用正则表达式来描述涉及标点符号的字符串集合,你可以使用反斜杠来转义这些特殊字符。例如,\$42\.00是一个正则表达式,表示只包含字符串"$42.00"的单例语言。也许有些令人困惑的是,反斜杠还被用来将一些普通字符转换为特殊字符!n是一个表示字母n的正则表达式,但\n是一个表示换行符的正则表达式。d是一个表示字母d的正则表达式,但\d是一个等同于[0-9]的正则表达式。

C++的正则表达式语法所识别的反斜杠字符的完整列表是:

  • \1\2、... \10、... 用于后向引用(应避免使用)

  • \b用于单词边界和\B用于(?!\b)

  • \d 用于 [[:digit:]]\D 用于 [^[:digit:]]

  • \s 用于 [[:space:]]\S 用于 [^[:space:]]

  • \w 用于 [0-9A-Za-z_]\W 用于 [⁰-9A-Za-z_]

  • \cX 用于各种“控制字符”(应避免使用)

  • \xXX 用于十六进制,具有通常的含义

  • \u00XX 用于 Unicode,具有通常的含义

  • \0\f\n\r\t\v 具有它们通常的含义

.——这个特殊字符表示“正好一个字符”,几乎没有其他要求。例如,a.c 是一个有效的正则表达式,并匹配如 "aac""a!c""a\0c" 这样的输入。然而,. 永远不会匹配换行符或回车符;并且由于 C++ 正则表达式在字节级别工作,而不是在 Unicode 级别,. 会匹配任何单个字节(除了 '\\n''\\r'),但即使它们偶然组成一个有效的 UTF-8 代码点,也不会匹配多个字节的序列。

[]——一个包含在方括号内的字符组表示“正好是这个集合中的一个”,因此 c[aou]t 是一个有效的正则表达式,并匹配字符串 "cat""cot""cut"。你可以使用方括号语法来“转义”大多数字符;例如,[$][.][*][+][?][(][)][[][{][}][|] 是一个单成员语言的正则表达式,其唯一成员是字符串 "$.*+?()[{}|"。然而,你不能使用方括号来转义 ]\^

[^]——一个以 ^ 开头并包含在方括号内的字符组表示“正好一个,不是这个集合中的”,因此 c[^aou]t 将匹配 "cbt""c^t" 但不会匹配 "cat"。ECMAScript 方言不特别处理 [][^] 的平凡情况;[] 表示“来自空集的正好一个字符”(也就是说,它永远不会匹配任何内容),而 [^] 表示“不是来自空集的正好一个字符”(也就是说,它匹配任何单个字符——就像 . 但更好,因为它会匹配换行符和回车符)。

[] 语法对一些字符有特殊处理:如果 - 出现在方括号内,除了作为第一个或最后一个字符外,它表示一个“范围”,其左右邻居为范围。因此,ro[s-v]e 是一个正则表达式,用于匹配语言成员为四个字符串:"rose""rote""roue""rove"。一些常用范围——与 <ctype.h> 头文件中暴露的范围相同——使用方括号内的 [:foo:] 语法内置:[[:digit:]][0-9] 相同,[[:upper:][:lower:]][[:alpha:]] 相同,即 [A-Za-z],等等。

还有一些内置语法看起来像 [[.x.]][[=x=]];它们处理与区域设置相关的比较,你永远不会需要使用它们。只需知道,如果你需要在方括号字符类中包含字符 [,最好使用反斜杠转义:foo[=([;]foo[(\[=;] 匹配字符串 "foo=""foo(""foo[""foo;",但 foo[([=;] 是一个无效的正则表达式,在尝试从它构造 std::regex 对象时会在运行时抛出异常。

+--一个表达式或单个字符后面紧跟 + 可以匹配前面的表达式或字符任意正次数。例如,正则表达式 ba+ 匹配字符串 "ba""baa""baaa" 等等。

*--一个表达式或单个字符后面紧跟 * 可以匹配前面的表达式或字符任意次数,包括零次。所以正则表达式 ba* 匹配字符串 "ba""baa""baaa",甚至单独的 "b"

?--一个表达式或单个字符后面紧跟 ? 可以匹配前面的表达式或字符正好零次或一次。例如,正则表达式 coo?t 只匹配 "cot""coot"

{n}--一个表达式或单个字符后面紧跟一个花括号中的整数,会精确匹配前面的表达式或字符指定次数。例如,b(an){2}a 是一个匹配 "banana" 的正则表达式;b(an){3}a 是一个匹配 "bananana" 的正则表达式。

{m,n}--当花括号结构由两个用逗号分隔的整数 mn 组成时,该结构匹配前面的表达式或字符从 mn 次数(包括)。所以 b(an){2,3}a 是一个只匹配字符串 "banana""bananana" 的正则表达式。

{m,}--留空 n 实际上使其无限;所以 x{42,} 表示“匹配 x 42 次或更多”,相当于 x{42}x*。ECMAScript 语法不允许留空 m

|--两个正则表达式可以用 | 连接起来,表示“或”的概念。例如,cat|dog 是一个只匹配字符串 "cat""dog" 的正则表达式;而 (tor|shark)nado 匹配 "tornado""sharknado"。在正则表达式中,| 运算符的优先级非常低,就像它在 C++ 表达式中的优先级一样。

()--括号的作用就像在数学中一样,用于括住一个子表达式,将其紧密绑定并作为一个单元处理。例如,ba* 表示“字符 b,然后是零个或多个 a 的实例;但 (ba)* 表示“零个或多个 ba 的实例。”所以前者匹配 "b""ba""baa" 等等;但带括号的那个版本匹配 """ba""baba" 等等。

括号也有第二个用途--它们不仅用于 分组,还用于 捕获 匹配的部分以进行进一步处理。正则表达式中的每个开括号 ( 都会在结果 std::smatch 对象中生成另一个子匹配。

如果你想要将某些子表达式紧密地组合在一起而不生成子匹配,你可以使用语法 (?:foo) 的非捕获组:

    std::string s = "abcde";
    std::smatch m;
    std::regex_match(s, m, std::regex("(a|b)*(.*)e"));
    assert(m.size() == 3 && m[2] == "cd");
    std::regex_match(s, m, std::regex("(?:a|b)*(.*)e"));
    assert(m.size() == 2 && m[1] == "cd");

非捕获性可能在某些隐藏的上下文中很有用;但通常,如果你只是使用常规捕获 () 并忽略你不在乎的子匹配,而不是在你的代码库中散布 (?:) 以尝试压制所有未使用的子匹配,这将使读者更清楚。未使用的子匹配在性能上非常便宜。

非消耗性结构

(?=foo) 匹配输入中的模式 foo,然后“回滚”以使输入实际上没有消耗。这被称为“向前查看”。所以例如 c(?=a)(?=a)(?=a)at 匹配 "cat";而 (?=.*[A-Za-z])(?=.*[0-9]).* 匹配包含至少一个字母字符和至少一个数字的任何字符串。

(?!foo) 是一个“负向前查看”;它向前查看以匹配输入中的 foo,但如果 foo 被接受,则拒绝匹配,如果 foo 被拒绝,则接受匹配。所以,例如,(?!\d)\w+ 匹配任何 C++ 标识符或关键字--也就是说,任何不以数字开头的字母数字字符序列。请注意,第一个字符必须不匹配 \d 但不被 (?!\d) 结构消耗;它仍然必须被 \w 接受。类似外观的正则表达式 [⁰-9]\w+ 会“错误地”接受像 "#xyzzy" 这样的字符串,这些字符串不是有效的标识符。

(?=)(?!) 不仅是非消耗性的,而且是非捕获性的,就像 (?:) 一样。但是,写 (?=(foo)) 来捕获“向前查看”的部分的全部或部分是完全可行的。

^$--一个单独的、不在任何方括号内的撇号 ^ 仅匹配要匹配的字符串的开始;而 $ 仅匹配字符串的末尾。这在 std::regex_search 的上下文中非常有用,可以“锚定”正则表达式到输入字符串的开始或结束。在 std::regex::multiline 正则表达式中,^$ 分别作为“向后查看”和“向前查看”断言:

    std::string s = "ab\ncd";
    std::regex rx("^ab$[^]^cd$", std::regex::multiline);

    assert(std::regex_match(s, rx));

将所有这些放在一起,我们可能会写出正则表达式 foo[a-z_]+(\d|$) 来匹配“字母 foo 后跟一个或多个其他字母和/或下划线;然后跟一个数字或行尾。”

如果你需要深入了解正则表达式语法,请参阅 cppreference.com。如果还不够--C++ 从 ECMAScript 风格的正则表达式复制来的最好之处在于,任何关于 JavaScript 正则表达式的教程也适用于 C++!你甚至可以在浏览器控制台中测试正则表达式。C++ 正则表达式和 JavaScript 正则表达式之间的唯一区别是,C++ 支持字符类如 [[:digit:]][[.x.]][[=x=]] 的双方括号语法,而 JavaScript 不支持。JavaScript 将这些正则表达式视为与 [\[:digit:]][\[.x\]][\[=x\]] 分别等价。

隐藏的 ECMAScript 功能和陷阱

在本章的早期,我提到了一些 std::regex 的特性,你最好避免使用,例如 std::regex::collatestd::regex::optimize 以及改变方言远离 ECMAScript 的标志。ECMAScript 正则表达式语法本身也包含一些晦涩且应避免的特性。

一个反斜杠后跟一个或多个数字(除了 \0)会创建一个回溯引用。回溯引用 \1 匹配“与我第一个捕获组匹配的相同字符序列”;例如,正则表达式 (cat|dog)\1 会匹配字符串 "catcat""dogdog",但不会匹配 "catdog",而 (a*)(b*)c\2\1 会匹配 "aabbbcbbbaa",但不会匹配 "aabbbcbbba"。回溯引用可以具有微妙而奇怪的语义,特别是当与 (?=foo) 这样的非消耗性构造结合使用时,我建议在可能的情况下避免使用它们。

如果你遇到回溯引用的问题,首先检查的是你的反斜杠转义。记住,std::regex("\1") 是匹配 ASCII 控制字符编号 1 的正则表达式。你本想输入的是 std::regex("\\1")

使用回溯引用将你带出了正则语言的世界,进入了更广泛的上下文相关语言的世界,这意味着库必须放弃其基于有限状态机的高效匹配算法,转而使用更强大但昂贵且缓慢的“回溯”算法。这似乎是避免回溯引用的另一个很好的理由,除非它们绝对必要。

然而,截至 2017 年,大多数供应商实际上并不会根据正则表达式中的回溯引用的存在来切换算法;他们会在 ECMAScript 正则表达式方言中基于回溯引用的可能性使用较慢的回溯算法。然后,因为没有任何供应商愿意为没有回溯引用的方言 std::regex::awkstd::regex::extended 实现整个第二个算法,他们最终甚至为这些方言使用回溯算法!同样,大多数供应商将 regex_match(s, rx) 实现为 regex_match(s, m, rx),然后丢弃昂贵的计算结果 m,而不是使用可能更快的 regex_match(s, rx) 算法。这样的优化可能在未来的 10 年内出现在某个库中,但我不会为此而等待。

另一个鲜为人知的特性是,*+? 量词默认都是贪婪的,这意味着例如 (a*) 会尽可能多地匹配 a 字符。你可以通过在量词后附加一个额外的 ? 来将贪婪量词转换为非贪婪的;例如 (a*?) 会匹配尽可能少的 a 字符。除非你使用捕获组,否则这不会产生任何区别。以下是一个例子:

    std::string s = "abcde";
    std::smatch m;
    std::regex_match(s, m, std::regex(".*([bcd].*)e"));
    assert(m[1] == "d");
    std::regex_match(s, m, std::regex(".*?([bcd].*)e"));
    assert(m[1] == "bcd");

在第一种情况下,.* 贪婪地匹配 abc,只留下 d 由捕获组进行匹配。在第二种情况下,.*? 非贪婪地只匹配 a,留下 bcd 给捕获组。实际上,.*? 更愿意匹配空字符串;但是,如果没有整体匹配被拒绝,它就不能这样做。

注意,非贪婪性的语法并不遵循“正常”的运算符组合规则。根据我们对 C++ 运算符语法的了解,我们预计 a+* 应该意味着 (a+)*(它确实如此),而 a+? 应该意味着 (a+)?(但它并不这样)。因此,如果你在正则表达式中看到连续的标点符号字符,要小心——它可能意味着与你的直觉告诉你的不同!

摘要

正则表达式(regexes)是在解析之前从输入字符串中提取片段的好方法。C++ 中的默认正则表达式方言与 JavaScript 相同。利用这一点。

在可能的情况下,避免使用原始字符串字面量,因为额外的括号可能会造成混淆。在正则表达式中,尽可能限制转义反斜杠的数量,通过使用方括号来转义特殊字符。

std::regex rx 基本上是不可变的,代表一个有限状态机。std::smatch m 是可变的,并包含关于草堆字符串中特定匹配的信息。子匹配 m[0] 代表整个匹配的子字符串;m[k] 代表第 k 个捕获组。

std::regex_match(s, m, rx) 将针针对整个草堆字符串进行匹配;std::regex_search(s, m, rx) 在草堆中寻找针。记住,草堆在前,针在后,就像在 JavaScript 和 Perl 中一样。

std::regex_iterator, std::regex_token_iterator, 和 std::regex_replace 是在 regex_search 基础上构建的相对不便的“便利”函数。在使用这些包装器之前,先熟悉 regex_search

警惕悬挂迭代器错误!永远不要修改或销毁一个仍被 regex_iterator 引用的 regex;永远不要修改或销毁一个仍被 smatch 引用的 string

第十一章:随机数

在上一章中,你学习了正则表达式,这是一个自 C++11 以来一直是 C++标准库的一部分的功能,但许多程序员仍然不太了解。你看到正则表达式在 C++光谱的两端都很有用——在需要对复杂输入格式进行坚如磐石解析的复杂程序中,以及在需要可读性和开发速度的简单脚本中。

另一个位于这两个类别中的库特性是随机数生成。许多脚本程序需要一点随机性,但几十年来,C++程序员一直被告知经典的 libc rand() 函数已经过时。在光谱的另一端,rand() 对于密码学和复杂的数值模拟来说都是极其不合适的。然而,C++11 <random> 库却成功地实现了这三个目标。

在本章中,我们将涵盖以下主题:

  • 真正随机数序列与伪随机数序列之间的区别

  • 随机比特生成器与产生数据值的分布之间的区别

  • 为随机数生成器设置种子的三种策略

  • 几种标准库生成器和分布,以及它们的用例

  • 如何在 C++17 中洗牌一副牌

随机数与伪随机数

在计算机编程的语境中谈论随机数时,我们必须小心地区分真正随机的数,这些数来自物理上非确定性的来源,以及伪随机数,这些数来自一个算法,该算法以确定性的方式产生一系列“看起来随机”的数。这样的算法被称为伪随机数生成器PRNG)。每个 PRNG 在概念上都以相同的方式工作——它有一些内部状态,并且有一些方式让用户请求下一个输出。每次我们请求下一个输出时,PRNG 都会根据某种确定性的算法打乱其内部状态,并返回该状态的一部分。以下是一个例子:

    template<class T>
    class SimplePRNG {
      uint32_t state = 1;
    public:
      static constexpr T min() { return 0; }
      static constexpr T max() { return 0x7FFF; }

      T operator()() {
        state = state * 1103515245 + 12345;
        return (state >> 16) & 0x7FFF;
      }
    };

这个 SimplePRNG 类实现了一个线性同余生成器,这可能与你的标准库中 rand() 的实现非常相似。请注意,SimplePRNG::operator() 产生 [0, 32767] 15 位范围内的整数,但其内部 state 有 32 位范围。这种模式在现实世界的 PRNG 中也是成立的。

例如,标准的梅森旋转算法几乎保持 20 千字节的状态!保持如此多的内部状态意味着有很多位可以混淆,并且每次生成时只有 PRNG 内部状态的一小部分泄露出来。这使得人类(或计算机)在只有少量先前输出的情况下难以预测 PRNG 的下一个输出。预测其输出的难度使我们称这个为伪随机数生成器。如果其输出充满了明显的模式和易于预测,我们可能会称其为非随机数生成器!

尽管具有伪随机的特性,PRNG 的行为始终是完美的确定性;它严格遵循其编码的算法。如果我们运行一个使用 PRNG 的程序并连续运行几次,我们期望每次都能得到完全相同的伪随机数序列。它的严格确定性使我们称这个为-随机数生成器。

假随机数生成器的一个方面是,两个运行相同算法但初始状态有微小差异的生成器会迅速放大这些差异,发散彼此,并产生看起来完全不同的输出序列——就像两滴水被放在你手背上的不同位置,会向完全不同的方向流去。这意味着如果我们想在每次运行程序时得到不同的伪随机数序列,我们只需确保我们为我们的 PRNG 使用不同的初始状态。设置 PRNG 的初始状态被称为播种PRNG。

我们至少有三种为 PRNG 播种的策略:

  • 使用从外部提供的种子——来自调用者或最终用户。这对于需要可重复性的任何事物都最合适,例如蒙特卡洛模拟或任何需要进行单元测试的事物。

  • 使用可预测但可变的种子,例如当前时间戳。在 C++11 之前,这是最常见的策略,因为 C 标准库提供了一个便携且方便的time函数,但它不提供任何真正的随机位源。基于像time这样可预测的东西进行播种不适合任何与安全相关的事物。从 C++11 开始,你不应该再使用这种策略。

  • 使用从某些特定平台来源直接获得的真正随机种子。

真正随机的位是通过操作系统基于各种随机事件收集的;一个经典的方法是对于每个系统调用,收集硬件周期计数器的低阶位,并将它们通过 XOR 操作合并到操作系统的熵池中。内核内部的伪随机数生成器(PRNG)会定期用熵池中的位重新初始化;该 PRNG 的输出序列被暴露给应用程序开发者。在 Linux 上,原始的熵池作为/dev/random暴露,PRNG 的输出序列作为/dev/urandom暴露。幸运的是,你永远不需要直接处理这些设备;C++标准库已经为你解决了这个问题。请继续阅读。

rand()的问题

传统的 C 语言生成随机数的方法是调用rand()函数。这个rand()函数仍然是 C++的一部分,它不接受任何参数,并在[0, RAND_MAX]范围内产生一个单一、均匀分布的整数。内部状态可以通过调用库函数void srand(unsigned int seed_value)初始化

自 1980 年代以来,生成[0, x)范围内的随机数的经典代码没有变化,如下所示:

    #include <stdlib.h>

    int randint0(int x) {
      return rand() % x;
    }

然而,这段代码有几个问题。第一个也是最明显的问题是它没有以相等的可能性生成所有的x输出。假设为了论证,rand()返回一个在[0, 32767]范围内的均匀分布值,那么randint0(10)将比返回89更频繁地返回[0, 7]范围内的每个值,频率是 1/3276。

第二个问题是rand()访问全局状态;在 C++程序中的每个线程都共享同一个随机数生成器。这不是线程安全的问题--rand()自 C++11 以来被保证是线程安全的。然而,这是一个性能问题(因为每次调用rand()都必须获取全局互斥锁),这也是一个可重复性问题(因为如果你从多个线程并发使用rand(),不同的程序运行可能会得到不同的结果)。

rand()函数的第三个问题,也是与其全局状态相关的问题,是任何程序中的函数都可以通过调用rand()来修改该状态。这使得在单元测试驱动的环境中使用rand()变得实际上是不可能的。考虑以下代码片段:

    int heads(int n) {
      DEBUG_LOG("heads");
      int result = 0;
      for (int i = 0; i < n; ++i) {
        result += (rand() % 2);
      }
      return result;
    }

    void test_heads() {
      srand(17); // nail down the seed
      int result = heads(42);
      assert(result == 27);
    }

显然,单元测试 test_heads 将在开始并行化单元测试时立即中断(因为来自其他线程对 rand() 的调用将干扰这个测试的微妙工作)。然而,更微妙的是,它也可能因为有人更改了 DEBUG_LOG 的实现,添加或删除对 rand() 的调用而中断!这种 遥远的神秘作用 是任何依赖于全局变量的架构的问题。我们在第八章 分配器中看到了类似的危险。在每种情况下,我强烈推荐的治疗方法都是相同的--不要使用全局变量。不要使用全局状态

因此,C 库有两个问题--它没有提供生成真正均匀分布的伪随机数的方法,并且它从根本上依赖于全局变量。让我们看看 C++ 标准库的 <random> 头文件是如何解决这两个问题的。

使用 <random> 解决问题

<random> 头文件提供了两个核心概念--生成器分布。一个 生成器(一个模拟 UniformRandomBitGenerator 概念的类)将 PRNG 的内部状态封装到一个 C++ 对象中,并提供了一个以函数调用操作符 operator()(void) 形式的下一个输出成员函数。一个 分布(一个模拟 RandomNumberDistribution 的类)是你可以在生成器的输出上放置的一种过滤器,这样你得到的不是像从 rand() 得到的均匀分布的随机位,而是根据指定的数学分布实际数据值分布,并限制在特定范围内,如 rand() % n,但更数学上合适且具有更大的灵活性。

<random> 头文件包含总共七种 生成器 类型以及二十种 分布 类型。其中大部分是模板,需要很多参数。这些生成器中大多数比实际应用更有历史意义,而大多数分布只对数学家感兴趣。因此,在本章中,我们将专注于几个标准的生成器和分布,每个都展示了标准库的一些有趣之处。

处理生成器

对于任何 生成器 对象 g,你可以对其执行以下操作:

  • g(): 这会打乱生成器的内部状态并产生下一个输出。

  • g.min(): 这告诉你 g() 的最小可能输出(通常是 0)。

  • g.max(): 这告诉你 g() 的最大可能输出。也就是说,g() 的可能输出范围是从 g.min()g.max(),包括两端。

    可能的输出范围是 g.min()g.max(),包括 g.max()

  • g.discard(n): 这实际上是对 g() 进行 n 次调用并丢弃这些结果。

    结果。在一个好的库实现中,你将支付打乱生成器内部状态 n 次的费用,但节省与从状态计算下一个输出相关的任何成本。

使用 std::random_device 的真正随机位

std::random_device 是一个 生成器。它的接口极其简单;它甚至不是一个类模板,而是一个普通的类。一旦你使用其默认构造函数构造了一个 std::random_device 的实例,你就可以使用其重载的调用操作符来获取类型为 unsigned int 的值,这些值在闭区间 [rd.min(), rd.max()] 内均匀分布。

一个需要注意的地方是,std::random_device 并不完全符合 UniformRandomBitGenerator 的概念。最重要的是,它既不可复制也不可移动。在实践中,这并不是一个大问题,因为你通常不会长时间保留一个 真正 随机的生成器。相反,你会使用一个短暂的 std::random_device 实例来为某种类型的长期伪随机生成器生成一个 种子,如下所示:

    std::random_device rd;
    unsigned int seed = rd();
    assert(rd.min() <= seed && seed <= rd.max());

现在我们来看看你唯一需要了解的伪随机生成器。

使用 std::mt19937 的伪随机位

你唯一需要了解的伪随机生成器被称为 梅森旋转器 算法。这个算法自 1997 年以来就为人所知,在任何编程语言中的高质量实现都很容易找到。从技术上讲,梅森旋转器算法定义了一个相关 PRNG 的整个家族--它是 C++模板的算法等价物--但这个家族中最常用的成员被称为 MT19937。这一串数字可能看起来像时间戳,但并非如此;它是旋转器内部状态的大小(以位为单位)。因为梅森旋转器的下一个输出函数完美地打乱了其状态,它最终会达到(除了一个之外)所有可能的状态,然后再回到开始--MT19937 生成器的 周期 是 2¹⁹⁹³⁷-1。与此相比,我们本章开头的 SimplePRNG 只有一个 32 位的内部状态和一个周期为 2³¹。(我们的 SimplePRNG 生成器有 2³² 种可能的内部状态,但在它再次循环之前,只有一半的状态被达到。例如,state=3 从初始的 state=1 是无法到达的。)

理论已经足够。让我们看看梅森旋转器在实际中的应用!对应于梅森旋转器 算法模板 的 C++类模板是 std::mersenne_twister_engine<...>,但你不会直接使用它;你将使用便利的 typedef std::mt19937,如下所示:

    std::mt19937 g;
    assert(g.min() == 0 && g.max() == 4294967295);

    assert(g() == 3499211612);
    assert(g() == 581869302);
    assert(g() == 3890346734);

std::mt19937 的默认构造函数将其内部状态设置为众所周知的标准值。这确保了从默认构造的 mt19937 对象获得的输出序列在所有平台上都是相同的--与 rand() 相比,rand() 在不同平台上往往给出不同的输出序列。

要获得不同的输出序列,你需要向std::mt19937的构造函数提供一个种子。 在 C++17 中有两种方法--繁琐的方法和简单的方法。 繁琐的方法是构建一个真正的 19937 位种子,并将其通过一个种子序列复制到std::mt19937对象中,如下所示:

    std::random_device rd;

    uint32_t numbers[624];
    std::generate(numbers, std::end(numbers), std::ref(rd));
      // Generate initial state.

    SeedSeq sseq(numbers, std::end(numbers));
      // Copy our state into a heap-allocated "seed sequence".

    std::mt19937 g(sseq);
      // Initialize a mt19937 generator with our state.

在这里,SeedSeq类型可以是std::seed_seq(一个被美化的std::vector;它使用堆分配)或者一个正确编写的“种子序列”类,如下所示:

    template<class It>
    struct SeedSeq {
      It begin_;
      It end_;
    public:
      SeedSeq(It begin, It end) : begin_(begin), end_(end) {}

      template<class It2>
      void generate(It2 b, It2 e) {
        assert((e - b) <= (end_ - begin_));
        std::copy(begin_, begin_ + (e - b), b);
      }
    };

当然,仅仅为了构建一个单一的 PRNG 对象就需要写这么多代码! (我告诉过你,这是繁琐的方法。) 简单的方法,也是你将在实践中看到的方法,是将 MT19937 用单个真正的32 位整数进行初始化,如下所示:

    std::random_device rd;

    std::mt19937 g(rd());
      // 32 bits of randomness ought to be enough for anyone!
      // ...Right?

警惕!32 比 19937 小得多!这种简单的初始化方法只能产生 40 亿种不同的输出序列,永远;这意味着如果你用随机种子反复运行你的程序,你可以在运行了几十万次之后看到一些重复。 (这是著名的生日悖论的应用。) 然而,如果你认为这种可预测性很重要,你可能还应该知道,梅森旋转器不是密码学安全的。 这意味着即使你用真正的 19937 位种子序列初始化它,恶意攻击者也可以逆向工程你的原始种子中的所有 19937 位,并在只看到输出序列的几百项之后,完美准确地预测后续的每个输出。 如果你需要一个密码学安全的伪随机数生成器CSPRNG),你应该使用类似 AES-CTR 或 ISAAC 的东西,这两种东西都不是 C++标准库提供的。 你仍然应该将你的 CSPRNG 实现包装在一个模拟UniformRandomBitGenerator的类中,这样它就可以与标准算法一起使用,我们将在本章末尾讨论这一点。

使用适配器过滤生成器输出

我们提到,生成器的原始输出通常需要通过单个分布进行过滤,以便将生成器的原始比特转换为可用的数据值。有趣的是,也可以将生成器的输出通过一个生成器适配器发送,该适配器可以以各种可能有用的方式重新格式化原始比特。标准库提供了三种适配器--std::discard_block_enginestd::shuffle_order_enginestd::independent_bits_engine。这些适配器类型的工作方式与我们在第四章“容器动物园”中讨论的容器适配器(如std::stack)类似--它们提供一定的接口,但将大部分实现细节委托给其他某个类。

std::discard_block_engine<Gen, p, r>的一个实例保留了一个类型为Gen底层生成器,并将所有操作委托给该底层生成器,除了discard_block_engine::operator()将只返回底层生成器每p个输出中的前r个。例如,考虑以下示例:

    std::vector<uint32_t> raw(10), filtered(10);

    std::discard_block_engine<std::mt19937, 3, 2> g2;
    std::mt19937 g1 = g2.base();

    std::generate(raw.begin(), raw.end(), g1);
    std::generate(filtered.begin(), filtered.end(), g2);

    assert(raw[0] == filtered[0]);
    assert(raw[1] == filtered[1]);
      // raw[2] doesn't appear in filtered[]
    assert(raw[3] == filtered[2]);
    assert(raw[4] == filtered[3]);
      // raw[5] doesn't appear in filtered[]

注意,可以通过g2.base()检索底层生成器的引用。在上面的示例中,g1被初始化为g2.base()的一个副本;这解释了为什么调用g1()不会影响g2的状态,反之亦然。

std::shuffle_order_engine<Gen, k>的一个实例保留其底层生成器最后k个输出的缓冲区,以及一个额外的整数Y。每次调用

shuffle_order_engine::operator()Y = buffer[Y % k]设置为buffer[Y] = base()()。(从Y计算缓冲区索引的公式实际上比简单的模运算更复杂,但它基本上有相同的效果。)值得注意的是,std::shuffle_order_engine并不使用std::uniform_int_distributionY映射到0, k)范围。这不会影响生成器输出的随机性——如果底层生成器已经是伪随机的话,稍微打乱其输出并不会使它们变得更加或更少随机,无论我们使用什么算法来进行打乱。因此,shuffle_order_engine使用的算法是专门挑选的,因为它具有历史兴趣——它是唐纳德·克努特在《计算机程序设计艺术》中描述的经典算法的一个构建块:

    using knuth_b = std::shuffle_order_engine<
      std::linear_congruential_engine<
        uint_fast32_t, 16807, 0, 2147483647
      >,
      256
    >;

std::independent_bits_engine<Gen, w, T>的一个实例除了其底层生成器类型为Gen之外,不保留任何状态。independent_bits_engine::operator()函数调用base()()足够多次以计算至少w个随机位;然后,它通过一个比实际应用更有历史意义的算法精确地拼接这些位,并将它们作为类型为T的无符号整数提供。 (如果T不是无符号整数类型,或者T的位数少于w位,则是一个错误。)

以下是一个independent_bits_engine从多个base()()调用中拼接位的示例:

    std::independent_bits_engine<std::mt19937, 40, uint64_t> g2;
    std::mt19937 g1 = g2.base();

    assert(g1() == 0xd09'1bb5c); // Take "1bb5c"...
    assert(g1() == 0x22a'e9ef6); // and "e9ef6"...
    assert(g2() == 0x1bb5c'e9ef6); // Paste and serve!

以下是一个使用independent_bits_enginemt19937的输出中移除所有但最低有效位(创建一个翻转生成器)的示例,然后,将这个生成器的 32 个输出拼接起来,以重建一个 32 位生成器:

    using coinflipper = std::independent_bits_engine<
      std::mt19937, 1, uint8_t>;

    coinflipper onecoin;
    std::array<int, 64> results;
    std::generate(results.begin(), results.end(), onecoin);
    assert((results == std::array<int, 64>{{
      0,0,0,1, 0,1,1,1, 0,1,1,1, 0,0,1,0,
      1,0,1,0, 1,1,1,1, 0,0,0,1, 0,1,0,1,
      1,0,0,1, 1,1,1,0, 0,0,1,0, 1,0,1,0,
      1,0,0,1, 0,0,0,0, 0,1,0,0, 1,1,0,0,
    }}));

    std::independent_bits_engine<coinflipper, 32, uint32_t> manycoins;
    assert(manycoins() == 0x1772af15);
    assert(manycoins() == 0x9e2a904c);

注意,independent_bits_engine对其底层生成器的位不执行任何复杂的操作;特别是,它假设其底层生成器没有偏差。如果WeightedCoin生成器倾向于偶数。你将看到这种偏差也会在independent_bits_engine<WeightedCoin, w, T>的输出中体现出来。

尽管我们花费了数页的篇幅来讨论这些生成器,但请记住,在你的代码中没有任何理由使用这些神秘的类!如果你需要一个伪随机数生成器,请使用 std::mt19937;如果你需要一个加密安全的伪随机数生成器,请使用类似 AES-CTR 或 ISAAC 的东西;如果你需要相对较少的真正随机位来为你的伪随机数生成器设置种子,请使用 std::random_device。这些是你将在实践中唯一使用的生成器。

处理分布

现在我们已经看到了如何按需生成随机位,让我们看看如何将这些随机位转换为匹配特定 分布 的数值。这个两步过程--生成原始位,然后将它们格式化为数据值--与我们前面在 [第九章 中介绍的缓冲和解析的两步过程非常相似,即 Iostreams。首先,获取原始位和字节,然后执行某种操作将这些位和字节转换为类型化的数据值。

对于任何分布对象 dist,你可以对其执行以下操作:

  • dist(g): 这将根据适当的数学分布产生下一个输出。这可能需要多次调用 g(),或者根本不需要,这取决于 dist 对象的内部状态。

  • dist.reset(): 这将清除 dist 对象的内部状态(如果有的话)。你永远不会需要使用这个成员函数。

  • dist.min()dist.max(): 这些告诉你 dist(g) 对于任何随机位生成器 g 的最小和最大可能输出。通常,这些值要么是显而易见的,要么是没有意义的;例如,std::normal_distribution<float>().max()INFINITY

让我们看看几个分布类型在实际中的应用。

使用 uniform_int_distribution 投掷骰子

std::uniform_int_distribution 方法是标准库中最简单的分布类型。它执行的操作与我们本章前面尝试用 randint0 执行的操作相同--将一个随机无符号整数映射到给定的范围中--但它没有任何偏差。uniform_int_distribution 的最简单实现看起来可能像这样:

    template<class Int>
    class uniform_int_distribution {
      using UInt = std::make_unsigned_t<Int>;
      UInt m_min, m_max;
    public:
      uniform_int_distribution(Int a, Int b) :
        m_min(a), m_max(b) {}

      template<class Gen>
      Int operator()(Gen& g) {
        UInt range = (m_max - m_min);
        assert(g.max() - g.min() >= range);
        while (true) {
          UInt r = g() - g.min();
          if (r <= range) {
            return Int(m_min + r);
          }
        }
      }
    };

实际的标准库实现必须做一些事情来消除那个 assert。通常,他们会使用类似 independent_bits_engine 的东西来一次生成正好 ceil(log2(range)) 个随机位,从而最小化 while 循环需要运行的次数。

如前例所示,uniform_int_distribution 是无状态的(尽管这并不是 技术上 保证的),因此最常见的使用方式是在每次生成数字时创建一个新的分布对象。因此,我们可以像这样实现我们的 randint0 函数:

    int randint0(int x) {
      static std::mt19937 g;
      return std::uniform_int_distribution<int>(0, x-1)(g);
    }

现在可能是时候指出 <random> 库的一些奇怪之处了。一般来说,每次你向这些函数或构造函数提供一个 整数数值范围 时,它被视为一个 闭区间。这与 C 和 C++ 中范围通常的工作方式形成鲜明对比;我们甚至在 第三章,迭代器对算法 中看到,偏离 半开区间 规则通常是代码有问题的标志。然而,在 C++ 随机数库的情况下,有一条新的规则--闭区间 规则。为什么?

好吧,半开区间的关键优势是它可以轻松地表示一个 空区间。另一方面,半开区间不能表示一个 完全满的区间,也就是说,一个覆盖整个域的区间。(我们在 第四章,容器动物园 的实现中看到了这个问题。)假设我们想要表达在整个 long long 范围上均匀分布的概念。我们不能将其表示为半开区间 [LLONG_MIN, LLONG_MAX+1),因为 LLONG_MAX+1 会溢出。然而,我们可以将其表示为闭区间 [LLONG_MIN, LLONG_MAX]--因此,这就是 <random> 库的函数和类(如 uniform_int_distribution)所做的事情。《uniform_int_distribution(0,6)方法是在[0,6]七个数范围内的分布,而uniform_int_distribution(42,42)是一个完全有效的分布,总是返回42`。

另一方面,std::uniform_real_distribution<double>(a, b) 确实 在一个半开区间上操作!std::uniform_real_distribution<double>(0, 1) 方法产生类型为 double 的值,在 0, 1) 范围内均匀分布。在浮点数域中,没有溢出问题--[0, INFINITY) 的半开区间实际上是可以表示的,尽管当然,在无限范围内不存在 均匀分布。浮点数也使得很难区分半开区间和闭区间;例如,std::uniform_real_distribution<float>(0, 1)(g) 可以合法地返回 float(1.0),只要它生成的随机实数足够接近 1,以至于每 2²⁵ 个结果中大约有一个会被四舍五入。 (在出版时,libc++ 的行为如上所述。GNU 的 libstdc++ 应用了一个补丁,使得接近 1 的实数向下而不是向上舍入,因此略低于 1.0 的浮点数出现的频率略高于随机预测。)

使用 normal_distribution 生成种群

实值分布最有用的例子可能是正态分布,也称为钟形曲线。在现实世界中,正态分布无处不在,尤其是在一个群体中物理特征的分布中。例如,成年人类身高的直方图往往会呈现出正态分布——许多个体围绕着平均身高聚集,其他人则向两边延伸。反过来,这意味着你可能想要使用正态分布来为游戏中的模拟个体分配身高、体重等。

std::normal_distribution<double>(m, sd) 方法构建了一个具有均值(m)和标准差(sd)的 normal_distribution<double> 实例。(如果你没有提供这些参数,这些参数默认为 m=0sd=1,所以要注意拼写错误!)以下是一个使用 normal_distribution 创建 10,000 个正态分布样本的“人口”,然后通过数学方法验证其分布的示例:

    double mean = 161.8;
    double stddev = 6.8;
    std::normal_distribution<double> dist(mean, stddev);

      // Initialize our generator.
    std::mt19937 g(std::random_device{}());

      // Fill a vector with 10,000 samples.
    std::vector<double> v;
    for (int i=0; i < 10000; ++i) {
      v.push_back( dist(g) );
    }
    std::sort(v.begin(), v.end());

      // Compare expectations with reality.
    auto square = [ { return x*x; };
    double mean_of_values = std::accumulate(
      v.begin(), v.end(), 0.0) / v.size();
    double mean_of_squares = std::inner_product(
      v.begin(), v.end(), v.begin(), 0.0) / v.size();
    double actual_stddev =
      std::sqrt(mean_of_squares - square(mean_of_values));
    printf("Expected mean and stddev: %g, %g\n", mean, stddev);
    printf("Actual mean and stddev: %g, %g\n",
           mean_of_values, actual_stddev);

与本章中(或将要看到的)的其他分布不同,std::normal_distribution 是有状态的。虽然为每个生成的值构造一个新的 std::normal_distribution 实例是可以的,但如果你这样做,实际上会减半你程序的效率。这是因为生成正态分布值的最流行算法每次产生两个独立值;std::normal_distribution 不能一次给你两个值,所以它会将其中一个值保留在成员变量中,以便下次请求时提供给你。可以使用 dist.reset() 成员函数清除这个保存的状态,尽管你永远不会想这样做。

使用 discrete_distribution 进行加权选择

std::discrete_distribution<int>(wbegin, wend) 方法在 [0, wend - wbegin) 的半开区间上构建一个离散的或加权的分布。以下示例可以最容易地解释这一点:

    template<class Values, class Weights, class Gen>
    auto weighted_choice(const Values& v, const Weights& w, Gen& g)
    {
      auto dist = std::discrete_distribution<int>(
        std::begin(w), std::end(w));
      int index = dist(g);
      return v[index];
    }

    void test() {
      auto g = std::mt19937(std::random_device{}());
      std::vector<std::string> choices =
        { "quick", "brown", "fox" };
      std::vector<int> weights = { 1, 7, 2 };
      std::string word = weighted_choice(choices, weights, g);
        // 7/10 of the time, we expect word=="brown".
    }

std::discrete_distribution<int> 方法会将其传入的权重在自己的私有成员变量 std::vector<double> 中创建一个内部副本(并且,像 <random> 中的常规操作一样,它不是分配器感知的)。你可以通过调用 dist.probabilities() 来获取这个向量的副本,如下所示:

    int w[] = { 1, 0, 2, 1 };
    std::discrete_distribution<int> dist(w, w+4);
    std::vector<double> v = dist.probabilities();
    assert((v == std::vector{ 0.25, 0.0, 0.50, 0.25 }));

你可能不想直接在自己的代码中使用 discrete_distribution;最好的办法是将它的使用封装在类似前面的 weighted_choice 函数中。然而,如果你需要避免堆分配或浮点运算,使用一个更简单的不分配函数可能更有利,如下所示:

    template<class Values, class Gen>
    auto weighted_choice(
      const Values& v, const std::vector<int>& w,
      Gen& g)
    {
      int sum = std::accumulate(w.begin(), w.end(), 0);
      int cutoff = std::uniform_int_distribution<int>(0, sum - 1)(g);
      auto vi = v.begin();
      auto wi = w.begin();
      while (cutoff > *wi) {
        cutoff -= *wi++;
        ++vi;
      }
      return *vi;
    }

然而,discrete_distribution 的默认库实现之所以将其所有数学运算作为浮点数进行,是因为它为你节省了担心整数溢出的麻烦。如果 sum 超出了 int 的范围,前面的代码将会有不良行为。

使用 std::shuffle 洗牌

让我们通过查看std::shuffle(a,b,g)来结束这一章,这是唯一一个接受随机数生成器作为输入的标准算法。根据第三章的定义,它是一个排列算法--它接受一个元素范围 [a,b) 并对其进行洗牌,保留其值但不保留其位置。

std::shuffle(a,b,g)方法是在 C++11 中引入的,用于取代旧的std::random_shuffle(a,b)算法。那个旧的算法“随机”地洗牌 [a,b) 范围,但没有指定随机性的来源;在实践中,这意味着它将使用全局 C 库的rand(),并带来所有相关问题。一旦 C++11 通过<random>引入了关于随机数生成器的标准化方法,就到了摆脱基于旧rand()random_shuffle的时候了;并且,截至 C++17,std::random_shuffle(a,b)不再是 C++标准库的一部分。

这是我们可以如何使用 C++11 的std::shuffle来洗牌一副扑克牌的方法:

    std::vector<int> deck(52);
    std::iota(deck.begin(), deck.end(), 1);
      // deck now contains ints from 1 to 52.

    std::mt19937 g(std::random_device{}());
    std::shuffle(deck.begin(), deck.end(), g);
      // The deck is now randomly shuffled.

回想一下,<random>中的每个生成器都是完全指定的,例如,使用固定值初始化的std::mt19937实例将在每个平台上产生完全相同的输出。对于像uniform_real_distribution这样的分布,以及shuffle算法,情况并非如此。从 libc++切换到 libstdc++,或者只是升级编译器,可能会导致你的std::shuffle行为发生变化。

9 different shuffles--out of the 8 × 1067 ways, you can shuffle a deck of cards by hand! If you were shuffling cards for a real casino game, you'd certainly want to use the "tedious" method of seeding, described earlier in this chapter, or--simpler, if performance isn't a concern--just use std::random_device directly:
    std::random_device rd;
    std::shuffle(deck.begin(), deck.end(), rd);
    // The deck is now TRULY randomly shuffled.

无论你使用什么生成器和初始化方法,你都可以直接将其插入到std::shuffle中。这是标准库对随机数生成可组合方法的好处。

摘要

标准库提供了两个与随机数相关的概念--生成器分布。生成器是有状态的,必须进行初始化,并通过operator()(void)产生无符号整数输出(原始比特)。两种重要的生成器类型是std::random_device,它产生真正的随机比特,以及std::mt19937,它产生伪随机比特。

分布通常是无状态的,并通过operator()(Gen&)产生数值数据。对于大多数程序员来说,最重要的分布类型将是std::uniform_int_distribution<int>(a,b),它产生闭区间 [a,b] 内的整数。标准库还提供了其他分布,例如std::uniform_real_distributionstd::normal_distributionstd::discrete_distribution,以及许多对数学家和统计学家有用的神秘分布。

使用随机性的唯一标准算法是std::shuffle,它取代了旧式的std::random_shuffle。不要在新代码中使用random_shuffle

注意,std::mt19937在所有平台上具有完全相同的行为,但任何分布类型,以及std::shuffle,情况并非如此。

第十二章:文件系统

C++17 最大的新特性之一是其 <filesystem> 库。这个库,就像现代 C++ 的许多其他主要特性一样,起源于 Boost 项目。2015 年,它成为了一个标准技术规范以收集反馈,最终,根据这些反馈进行了一些修改后,被合并到 C++17 标准中。

在本章中,你将学习以下内容:

  • <filesystem> 如何返回动态类型错误而不抛出异常,以及你如何也能做到

  • 路径 的格式,以及 POSIX 和 Windows 在这个问题上的根本不兼容立场

  • 如何使用可移植的 C++17 来获取文件状态和遍历目录

  • 如何创建、复制、重命名和删除文件和目录

  • 如何获取文件系统的空闲空间

关于命名空间的一些说明

标准的 C++17 文件系统功能都包含在一个单独的头文件中,即 <filesystem>,并且该头文件中的所有内容都放置在其自己的命名空间中:namespace std::filesystem。这遵循了 C++11 的 <chrono> 头文件及其 namespace std::chrono 所设定的先例。(本书省略了对 <chrono> 的全面介绍。它与 std::threadstd::timed_mutex 的交互在 第七章,并发 中简要介绍。)

这种命名空间策略意味着当你使用 <filesystem> 功能时,你将使用诸如 std::filesystem::directory_iteratorstd::filesystem::temp_directory_path() 这样的标识符。这些完全限定名称相当难以处理!但是,使用 using 声明将整个命名空间拉入当前上下文可能是一种过度行为,尤其是如果你需要在文件作用域中这样做。在过去十年中,我们都被告知永远不要写 using namespace std,而且无论标准库的命名空间嵌套有多深,这条建议都不会改变。考虑以下代码:

    using namespace std::filesystem;

    void foo(path p)
    {
      remove(p); // What function is this?
    }

对于日常用途来说,更好的解决方案是在文件作用域(在.cc文件中)或命名空间作用域(在.h文件中)定义一个命名空间别名。命名空间别名允许你通过一个新名称来引用现有的命名空间,如下面的示例所示:

    namespace fs = std::filesystem;

    void foo(fs::path p)
    {
      fs::remove(p); // Much clearer!
    }

在本章的剩余部分,我将使用命名空间别名 fs 来引用 namespace std::filesystem。当我提到 fs::path 时,我的意思是 std::filesystem::path。当我提到 fs::remove 时,我的意思是 std::filesystem::remove

在某个全局位置定义一个命名空间别名 fs 也有另一个实用的好处。截至出版时,在所有主要的库供应商中,只有 Microsoft Visual Studio 声称已经实现了 C++17 <filesystem> 头文件。然而,<filesystem> 提供的功能与 <experimental/filesystem> 中由 libstdc++ 和 libc++ 提供的功能以及 Boost 中的 <boost/filesystem.hpp> 非常相似。因此,如果你始终通过自定义命名空间别名,如 fs,来引用这些功能,你将能够通过更改该别名的目标来从一家供应商的实现切换到另一家——只需一行更改,而不是在整个代码库上进行大量且容易出错的搜索和替换操作。这可以在以下示例中看到:

    #if USE_CXX17
     #include <filesystem>
     namespace fs = std::filesystem;
    #elif USE_FILESYSTEM_TS
     #include <experimental/filesystem>
     namespace fs = std::experimental::filesystem;
    #elif USE_BOOST
     #include <boost/filesystem.hpp>
     namespace fs = boost::filesystem;
    #endif

关于错误报告的非常长的笔记

C++ 与错误报告有着爱恨交加的关系。在这里,“错误报告”指的是“当你无法完成所要求的事情时,应该怎么做”。在 C++ 中,报告这类“失望”的经典、典型且至今仍被视为最佳实践的方法是抛出一个异常。我们在前面的章节中看到,有时抛出异常是唯一合理的做法,因为没有其他方式可以返回调用者。例如,如果你的任务是构建一个对象,而构建失败,你无法返回;当构造函数失败时,唯一相同的行动就是抛出异常。然而,我们也已经看到(在第九章 Chapter 9,Iostreams),C++ 自身的 <iostream> 库并没有采取这种理智的行动!如果一个 std::fstream 对象的构建失败(因为无法打开指定的文件),你会得到一个异常;你将得到一个完全构建的 fstream 对象,其中 f.fail() && !f.is_open()

我们在第九章Chapter 9,Iostreams 中给出的理由是 fstream 的“不良”行为是“相对较高的文件无法打开的可能性”。每次文件无法打开时都抛出异常,这让人不舒服地接近使用异常进行控制流,这是我们被正确教导要避免的。因此,而不是强迫程序员在所有地方都编写 trycatch 块,库返回操作似乎已成功完成,但允许用户检查(使用正常的 if,而不是 catch)操作是否真的成功了。

也就是说,我们可以避免编写以下繁琐的代码:

    try {
      f.open("hello.txt");
      // Opening succeeded.
    } catch (const std::ios_base::failure&) {
      // Opening failed.
    }

相反,我们可以简单地写这个:

    f.open("hello.txt");
    if (f.is_open()) {
      // Opening succeeded.
    } else {
      // Opening failed.
    }

当操作的结果可以用一个重型的对象(如 fstream)描述,该对象具有自然的 失败 状态,或者在设计阶段可以添加这样的 失败 状态时,iostreams 方法工作得相当好。然而,它也有一些缺点,如果没有涉及重型的类型,则根本不能使用。我们在 第九章 的末尾看到了这种情况,iostreams,当时我们讨论了从字符串中解析整数的方法。如果我们不期望失败,或者不介意“使用异常进行控制流”的性能损失,那么我们使用 std::stoi

    // Exception-throwing approach.
    try {
      int i = std::stoi(s);
      // Parsing succeeded.
    } catch (...) {
      // Parsing failed.
    }

如果我们需要 C++03 的可移植性,我们使用 strtol,它通过线程局部全局变量 errno 报告错误,如以下代码所示:

    char *endptr = nullptr;
    errno = 0;
    long i = strtol(s, &endptr, 10);
    if (endptr != s && !errno) {
      // Parsing succeeded.
    } else {
      // Parsing failed.
    }

而在 bleeding-edge C++17 风格中,我们使用 std::from_chars,它返回一个包含字符串结束指针和表示成功或失败的强枚举类型 std::errc 的轻量级结构体,如下所示:

    int i = 0;
    auto [ptr, ec] = std::from_chars(s, end(s), i);
    if (ec != std::errc{}) {
      // Parsing succeeded.
    } else {
      // Parsing failed.
    }

<filesystem> 库在错误报告方面的容量大约与 std::from_chars 相同。几乎你可以在文件系统中进行的任何操作都可能因为系统上运行的其他进程的操作而失败;因此,每次失败时抛出异常(类似于 std::stoi)似乎与使用异常进行控制流非常接近。但是,将“错误结果”如 ec 在整个代码库中传递也可能很繁琐,并且(不是字面意义上的)容易出错。因此,标准库决定既要吃蛋糕又要吃蛋糕,为 <filesystem> 头文件中的几乎每个函数都提供了 两个接口

例如,以下是在磁盘上确定文件大小的两个 <filesystem> 函数:

    uintmax_t file_size(const fs::path& p);

    uintmax_t file_size(const fs::path& p,
       std::error_code& ec) noexcept;

前面的两个函数都接受一个 fs::path(我们将在本章后面进一步讨论),并返回一个 uintmax_t,表示以字节为单位命名的文件的大小。但如果文件不存在,或者文件存在,但当前用户账户没有查询其大小的权限呢?那么,第一个重载将简单地 抛出一个异常,类型为 fs::filesystem_error,指示出了什么问题。但第二个重载永远不会抛出(实际上,它被标记为 noexcept)。相反,它接受一个类型为 std::error_code 的输出参数,库将填充一个指示出错的指示(如果没有出错,则清除)。

比较一下 fs::file_sizestd::from_chars 的签名,你可能会注意到 from_chars 处理的是 std::errc,而 file_size 处理的是 std::error_code。这两个类型虽然相关,但并不相同!要理解这种差异——以及非抛出 <filesystem> API 的整个设计——我们不得不快速浏览一下 C++11 标准库的另一个部分。

使用 <system_error>

std::from_charsfs::file_size 的错误报告机制之间的区别在于它们固有的复杂性。from_chars 可以以两种方式失败——要么给定的字符串根本没有任何初始数字字符串,要么有太多的数字,以至于读取它们会导致溢出。在前一种情况下,报告错误的一个经典(但效率低下且通常危险的)方法是将 errno 设置为 EINVAL(并返回一些无用的值,如 0)。在后一种情况下,一个经典的方法是将 errno 设置为 ERANGE(并返回一些无用的值)。这大致上是(但远不如前者)strtol 所采取的方法。

突出的要点是,使用 from_chars 时,可能出错的两种情况是完全可以由 POSIX <errno.h> 提供的单个错误代码集来描述的。因此,为了将 1980 年代的 strtol 带入 21 世纪,我们只需要修复使其直接将错误代码返回给调用者,而不是通过线程局部 errno 间接返回。这就是标准库所做的一切。经典的 POSIX <errno.h> 值仍然通过 <cerrno> 作为宏提供,但自 C++11 以来,它们也通过 <system_error> 中的强类型枚举提供,如下面的代码所示:

 namespace std {
   enum class errc {
     // implicitly, "0" means "no error"
     operation_not_permitted = EPERM,
     no_such_file_or_directory = ENOENT,
     no_such_process = ESRCH,
     // ...
     value_too_large = EOVERFLOW
   };
 } // namespace std

std::from_chars 通过返回一个包含类型为 enum std::errc 的成员变量的结构体(struct from_chars_result)来报告错误,该成员变量可以是 0 表示 无错误,或者两个可能的错误指示值之一。

那么,关于 fs::file_size 呢?file_size 可能遇到的一组错误要多得多——实际上,当你想到存在的操作系统的数量,以及每个操作系统支持的不同文件系统的数量,以及某些文件系统(如 NFS)分布在各种类型的 网络 上时,可能出现的错误集合看起来就像一个 开集。可能可以将它们全部归结为七十八个标准的 sys::errc 枚举器(每个 POSIX errno 值一个,除了 EDQUOTEMULTIHOPESTALE),但这会丢失很多信息。实际上,至少缺失的 POSIX 枚举器之一(ESTALE)是 fs::file_size 的一个合法失败模式!当然,你的底层文件系统可能想要报告它自己的特定于文件系统的错误;例如,虽然有一个标准的 POSIX 错误代码用于 名称过长,但没有 POSIX 错误代码用于 名称包含不允许的字符(原因将在本章下一主要部分中看到)。文件系统可能想要报告那个错误,而不用担心 fs::file_size 会将其压缩到某种固定的枚举类型中。

这里基本的问题是,fs::file_size报告的错误可能并不都来自同一个,因此,它们不能由一个固定的类型(例如,std::errc)来表示。C++异常处理优雅地解决了这个问题;程序的不同级别抛出不同类型的异常是正常且自然的。如果程序最低级别抛出myfs::DisallowedCharacterInName,则最高级别可以捕获它——无论是通过名称、基类还是通过...。如果我们遵循在程序中抛出的所有内容都应该从std::exception派生的通用规则,那么任何catch块都将能够使用e.what(),这样至少用户可以得到一些模糊上可读的问题指示,无论问题是什么。

标准库将多个错误域的概念具体化为基类std::error_category,如下面的代码所示:

 namespace std {

 class error_category {
 public:
   virtual const char *name() const noexcept = 0;
   virtual std::string message(int err) const = 0;

   // other virtual methods not shown

   bool operator==(const std::error_category& rhs) const {
     return this == &rhs;
   }
 };

 } // namespace std

error_category的行为与第八章中提到的memory_resource非常相似,分配器;它定义了一个经典的多态接口,某些类型的库预期会从它派生。我们看到了,一些memory_resource的子类是全球单例,而另一些则不是。对于error_category每个子类必须是一个全局单例,否则它将无法工作。

为了使内存资源有用,库为我们提供了容器(参见第四章,容器动物园)。在最基本层面上,一个容器是一个表示某些已分配内存的指针,以及一个指向内存资源的句柄,该资源知道如何释放该指针。(回想一下,指向内存资源的句柄被称为分配器。)

为了使error_category子类有用,库为我们提供了std::error_code。在最基本层面上(在这个例子中,这是唯一的层面),一个error_code是一个表示错误枚举的int值,以及一个指向error_category的句柄,该句柄知道如何解释该枚举。它看起来是这样的:

    namespace std {

    class error_code {
      const std::error_category *m_cat;
      int m_err;
    public:
      const auto& category() const { return m_cat; }
      int value() const { return m_err; }
      std::string message() const { return m_cat->message(m_err); }
      explicit operator bool() const { return m_err != 0; }

      // other convenience methods not shown
    };

    } // namespace std

因此,要创建一个挑剔的文件系统库子系统,我们可以编写以下代码:

    namespace FinickyFS {

    enum class Error : int {
      success = 0,
      forbidden_character = 1,
      forbidden_word = 2,
      too_many_characters = 3,
    };

    struct ErrorCategory : std::error_category
    {
      const char *name() const noexcept override {
        return "finicky filesystem";
      }

      std::string message(int err) const override {
        switch (err) {
          case 0: return "Success";
          case 1: return "Invalid filename";
          case 2: return "Bad word in filename";
          case 3: return "Filename too long";
        }
        throw Unreachable();
      }

      static ErrorCategory& instance() {
        static ErrorCategory instance;
        return instance;
      }
    };

    std::error_code make_error_code(Error err) noexcept
   {
      return std::error_code(int(err), ErrorCategory::instance());
    }

    } // namespace FinickyFS

上述代码定义了一个新的错误域,即FinickyFS::Error域,通过FinickyFS::ErrorCategory::instance()实例化。这允许我们通过如make_error_code(FinickyFS::Error::forbidden_word)这样的表达式创建std::error_code类型的对象。

注意,依赖参数的查找ADL)将无需我们的任何帮助就找到make_error_code的正确重载。make_error_codeswap一样,是一个定制点:只需在枚举的命名空间中定义一个具有该名称的函数,它就会工作而无需任何额外的工作。

    // An error fits comfortably in a statically typed
    // and value-semantic std::error_code object...
    std::error_code ec =    
      make_error_code(FinickyFS::Error::forbidden_word);

    // ...Yet its "what-string" remains just as
    // accessible as if it were a dynamically typed
    // exception!
    assert(ec.message() == "Bad word in filename");

现在我们有了一种方法,可以通过将它们包装在简单可复制的 std::error_code 对象中来无损地传递 FinickyFS::Error 代码——并在最高级别获取原始错误。当我那样说的时候,它听起来几乎像是魔法——就像没有异常的异常处理!但正如我们刚才看到的,实现起来非常简单。

错误代码和错误条件

注意到 FinickyFS::Error 不能隐式转换为 std::error_code;在最后一个例子中,我们使用了 make_error_code(FinickyFS::Error::forbidden_word) 语法来构造我们的初始 error_code 对象。如果我们告诉 <system_error> 启用从 FinickyFS::Errorstd::error_code 的隐式转换,那么 FinickyFS::Error 对程序员来说会更加方便,如下所示:

    namespace std {
    template<>
    struct is_error_code_enum<::FinickyFS::Error> : true_type {};
    } // namespace std

在重新打开 std 命名空间时要小心——记住,当你这样做的时候,你必须处于任何其他命名空间之外!否则,你将创建一个嵌套的命名空间,例如 FinickyFS::std 命名空间。在这种情况下,如果你出错,编译器会在你尝试特化不存在的 FinickyFS::std::is_error_code_enum 时友好地报错。只要你在特化模板时只重新打开 std 命名空间(并且只要你不搞砸模板特化语法),你就不必太担心任何 静默 失败。

一旦你为你的枚举类型特化了 std::is_error_code_enum,库就会处理其余部分,如下代码所示:

    class error_code {
      // ...
      template<
        class E,
        class = enable_if_t<is_error_code_enum_v<E>>
      >
      error_code(E err) noexcept {
        *this = make_error_code(err);
      }
    };

之前代码中看到的隐式转换使得方便的语法变得可能,例如通过 == 进行直接比较,但由于每个 std::error_code 对象都携带其域,比较是强类型的。error_code 对象的值相等不仅取决于它们的 整数 ,还取决于它们相关错误类别单例的 地址

    std::error_code ec = FinickyFS::Error::forbidden_character;

      // Comparisons are strongly typed.
    assert(ec == FinickyFS::Error::forbidden_character);
    assert(ec != std::io_errc::stream);

特化 is_error_code_enum<X> 对于你经常将 X 赋值给 std::error_code 类型的变量,或者从返回 std::error_code 的函数中返回 X 来说是有帮助的。换句话说,如果你的类型 X 真正代表 错误的来源——方程的抛出方,那么关于捕获方呢?假设你注意到你已经编写了这个函数,以及几个类似的函数:

    bool is_malformed_name(std::error_code ec) {
      return (
        ec == FinickyFS::Error::forbidden_character ||
        ec == FinickyFS::Error::forbidden_word ||
        ec == std::errc::illegal_byte_sequence);
    }

前面的函数定义了一个在整个错误代码宇宙上的 一元 谓词;就我们的 FinickyFS 库而言,它对任何与名称格式错误概念相关的错误代码返回 true。我们只需将此函数直接放入我们的库中作为 FinickyFS::is_malformed_name()——实际上,这正是我个人的推荐做法——但标准库还提供了另一种可能的方法。你可以定义一个 error_condition 而不是 error_code,如下所示:

    namespace FinickyFS {

    enum class Condition : int {
      success = 0,
      malformed_name = 1,
    };

    struct ConditionCategory : std::error_category {
      const char *name() const noexcept override {
        return "finicky filesystem";
      }
      std::string message(int cond) const override {
        switch (cond) {
          case 0: return "Success";
          case 1: return "Malformed name";
        }
        throw Unreachable();
      }
      bool equivalent(const std::error_code& ec, int cond) const  
      noexcept override {
        switch (cond) {
          case 0: return !ec;
          case 1: return is_malformed_name(ec);
        }
        throw Unreachable();
      }
      static ConditionCategory& instance() {
        static ConditionCategory instance;
        return instance;
      }
    };
    std::error_condition make_error_condition(Condition cond) noexcept  
    {
      return std::error_condition(int(cond),  
      ConditionCategory::instance());
    }

    } // namespace FinickyFS

    namespace std {
    template<>
    struct is_error_condition_enum<::FinickyFS::Condition> : true_type  
    {};
    } // namespace std

一旦完成这些,你可以通过编写比较 (ec == FinickyFS::Condition::malformed_name) 来获得调用 FinickyFS::is_malformed_name(ec) 的效果,如下所示:

    std::error_code ec = FinickyFS::Error::forbidden_word;

      // RHS is implicitly converted to error_code
    assert(ec == FinickyFS::Error::forbidden_word);

      // RHS is implicitly converted to error_condition
    assert(ec == FinickyFS::Condition::malformed_name);

然而,因为我们没有提供一个函数 make_error_code(FinickyFS::Condition),将无法轻松构造一个包含这些条件的 std::error_code 对象。这是合适的;条件枚举是在捕获侧进行测试的,而不是在抛出侧转换为 error_code

标准库提供了两种代码枚举类型(std::future_errcstd::io_errc),以及一种条件枚举类型(std::errc)。没错——POSIX 错误枚举 std::errc 实际上枚举的是 条件,而不是 代码!这意味着如果你试图将 POSIX 错误代码塞入一个 std::error_code 对象中,你做错了;它们是 条件,这意味着它们是在捕获侧 测试 的,而不是用于抛出的。遗憾的是,标准库至少在两个方面犯了错误。首先,正如我们所看到的,std::from_chars 会抛出一个 std::errc 类型的值(这很不方便;抛出一个 std::error_code 会更一致)。其次,存在一个函数 std::make_error_code(std::errc),这会弄乱语义空间,而实际上只需要存在 std::make_error_condition(std::errc)(它确实存在)。

使用 std::system_error 抛出错误

到目前为止,我们考虑了 std::error_code,这是 C++ 异常处理的一个巧妙的非抛出替代方案。但有时,你需要在不同级别的系统中混合非抛出和抛出库。标准库为你解决了问题——至少是问题的一半。std::system_error 是从 std::runtime_error 派生出的具体异常类型,它有足够的空间存储单个 error_code。因此,如果你正在编写一个基于抛出而不是 error_code 的库 API,并且从系统较低级别收到表示失败的 error_code,将那个 error_code 包装在 system_error 对象中并向上抛出是完全合适的。

    // The lower level is error_code-based.
    uintmax_t file_size(const fs::path& p,
        std::error_code& ec) noexcept;

    // My level is throw-based.
    uintmax_t file_size(const fs::path& p)
    {
      std::error_code ec;
      uintmax_t size = file_size(p, ec);
      if (ec) {
        throw std::system_error(ec);
      }
      return size;
    }

在相反的情况下——当你编写了非抛出异常的库 API,但你调用较低级别的可能抛出异常的代码时——标准库基本上没有提供帮助。但你可以自己轻松地编写一个 error_code 解包器:

    // The lower level is throw-based.
    uintmax_t file_size(const fs::path& p);

    // My level is error_code-based.
    uintmax_t file_size(const fs::path& p,
        std::error_code& ec) noexcept
    {
      uintmax_t size = -1;
      try {
        size = file_size(p);
      } catch (...) {
        ec = current_exception_to_error_code();
      }
      return size;
    }
current_exception_to_error_code(), which is a non-standard function you can write yourself. I recommend something along these lines:
 namespace detail {

 enum Error : int {
    success = 0,
    bad_alloc_thrown = 1,
    unknown_exception_thrown = 2,
 };
 struct ErrorCategory : std::error_category {
    const char *name() const noexcept override;
    std::string message(int err) const override;
    static ErrorCategory& instance();
 };
 std::error_code make_error_code(Error err) noexcept {
    return std::error_code(int(err), ErrorCategory::instance());
 }

 } // namespace detail

 std::error_code current_exception_to_error_code()
 {
    try {
        throw;
    } catch (const std::system_error& e) {
        // also catches std::ios_base::failure
        // and fs::filesystem_error
        return e.code();
    } catch (const std::future_error& e) {
        // catches the oddball
        return e.code();
    } catch (const std::bad_alloc&) {
        // bad_alloc is often of special interest
        return detail::bad_alloc_thrown;
    } catch (...) {
        return detail::unknown_exception_thrown;
    }
 }

这就结束了我们对 <system_error> 中混乱世界的离题讨论。我们现在回到你正在进行的常规 <filesystem>

文件系统和路径

在 第九章 Iostreams 中,我们讨论了 POSIX 的文件描述符概念。

表示数据源或汇,可以通过 read 和/或 write 来定位;通常,但不总是,它对应于磁盘上的一个文件。(回想一下,文件描述符号 1 指的是 stdout,它通常连接到人类的屏幕。文件描述符还可以指网络套接字、例如 /dev/random 的设备等。)

此外,POSIX 文件描述符、<stdio.h><iostream> 都与磁盘上文件(或任何地方)的 内容 有关,具体来说,是与构成文件 内容 的字节序列有关。在 文件系统 意义上的文件有许多显著的属性,这些属性并未通过文件读取和写入 API 暴露出来。我们不能使用第九章的 API,即 Iostreams,来确定文件的所有权或其最后修改日期;也不能确定给定目录中的文件数量。《`的目的就是允许我们的 C++程序以可移植、跨平台的方式与这些 文件系统 属性交互。

让我们再次开始。什么是文件系统?文件系统是通过 路径文件目录条目 的抽象映射。如果你能以宽容的心态看待,也许一个图表会帮到你:

图片

在前面图的最上方,我们有一个相对抽象的“名称”世界。我们有一个从那些名称(如 speech.txt)到 POSIX 称为 inode 的具体结构的映射。术语“inode”在 C++标准中并未使用——它使用通用术语“文件”——但当我需要精确时,我会尝试使用 inode 这个术语。每个 inode 包含一组完整的属性,描述磁盘上的单个文件:其所有者、其最后修改日期、其 类型 等。最重要的是,inode 还确切地说明了文件的大小,并提供了一个指向其实际内容的指针(类似于std::vectorstd::list持有指向其内容的指针)。inode 和块在磁盘上的确切表示取决于你运行的是哪种类型的文件系统;一些常见文件系统的名称包括 ext4(在 Linux 上常见)、HFS+(在 OS X 上)和 NTFS(在 Windows 上)。

注意到图中的一些块包含的数据只是将 名称 映射到 inode 编号 的表格映射。这让我们回到了起点!一个 目录 只是一个具有特定 类型 的 inode,其内容是名称到 inode 编号的表格映射。每个文件系统都有一个特殊的、众所周知的 inode,称为其 根目录

假设我们图中的 inode 标签为"2"的是根目录。那么我们可以明确地通过一系列从根目录到该文件的名称路径来识别包含"现在是时候..."的文件。例如,/My Documents/speech.txt就是这样一条路径:从根目录开始,My Documents映射到 inode 42,这是一个包含speech.txt映射到 inode 17 的目录,它是一个包含磁盘上内容为"现在是时候..."的普通文件。我们使用斜杠将这些单个名称组合成一个路径,并在前面加上一个斜杠以表示我们从根目录开始。(在 Windows 中,每个分区或驱动器都有一个独立的根目录。因此,我们可能写成c:/My Documents/speech.txt来表示我们从驱动器 C 的根目录开始。)

或者,"/alices-speech.txt" 是从根目录直接指向 inode 17 的路径。我们说这两个路径("/My Documents/speech.txt" 和 "/alices-speech.txt")都是对同一底层 inode 的硬链接,也就是说,对同一底层文件的链接。某些文件系统(例如许多 USB 闪存驱动器使用的 FAT 文件系统)不支持对同一文件有多个硬链接。当支持多个硬链接时,文件系统必须计算每个 inode 的引用次数,以便知道何时可以安全地删除和释放 inode--这个过程与我们在第六章中看到的shared_ptr引用计数过程完全类似,即智能指针

当我们要求库函数(如openfopen)"打开文件"时,这就是它在文件系统内部深处所经历的过程。它接受你给出的文件名并将其视为路径--在斜杠处将其拆分,并进入文件系统的目录结构,直到最终到达你请求的文件的 inode(或者直到它遇到死胡同)。请注意,一旦我们到达 inode,就不再有意义地问"这个文件的名称是什么?",因为它至少有与它的硬链接一样多的名称。

在 C++ 中表示路径

在第九章的Iostreams中,每个期望参数为"文件名"(即路径)的函数都乐于接受这个路径作为一个简单的 const char *。但在<filesystem>库中,我们将因为 Windows 而使这个情况复杂化。

所有 POSIX 文件系统都将名称(如speech.txt)存储为简单的原始字节字符串。POSIX 中的唯一规则是,你的名称不能包含'\0',并且你的名称不能包含'/'(因为这是我们将要分割的字符)。在 POSIX 中,"\xC1.h"是一个完全有效的文件名,尽管它不是有效的 UTF-8,不是有效的 ASCII,并且当你ls .时它在屏幕上的显示完全取决于你的当前区域设置和代码页。毕竟,它只是一个由三个字节组成的字符串,其中没有一个字节是'/'

另一方面,Windows 的本地文件 API,例如CreateFileW,总是以 UTF-16 存储名称。这意味着,根据定义,Windows 中的路径始终是有效的 Unicode 字符串。这是 POSIX 和 NTFS 之间的一大哲学差异!让我再慢一点说一遍:在 POSIX 中,文件名是字节字符串。在 Windows 中,文件名是Unicode 字符字符串

如果你遵循第九章中的一般原则第九章,即世界上所有东西都应该使用 UTF-8 编码,那么 POSIX 和 Windows 之间的差异将是可管理的——也许甚至可以忽略不计。但如果你需要在某个系统上调试具有奇怪名称的文件的问题,请记住:在 POSIX 中,文件名是字节字符串。在 Windows 中,文件名是字符字符串。

由于 Windows API 期望 UTF-16 字符串(std::u16string)和 POSIX API 期望字节字符串(std::string),这两种表示方法对于跨平台库来说都不是完全合适的。因此,<filesystem>发明了一个新的类型:fs::path。(回想一下,在本章中我们使用的是我们的命名空间别名。实际上,那就是std::filesystem::path。)fs::path看起来像这样:

    class path {
    public:
      using value_type = std::conditional_t<
        IsWindows, wchar_t, char
      >;
      using string_type = std::basic_string<value_type>;

      const auto& native() const { return m_path; }
      operator string_type() const { return m_path; }
      auto c_str() const { return m_path.c_str(); }

      // many constructors and accessors omitted
    private:
      string_type m_path;
    };

注意,在 Windows 中fs::path::value_typewchar_t,尽管 C++11 的 UTF-16 字符类型char16_t可能更合适。这仅仅是库历史根源在 Boost 中的体现,Boost 的历史可以追溯到 C++11 之前。在本章中,每当提到wchar_t时,你可以假设我们在谈论 UTF-16,反之亦然。

要编写可移植的代码,请注意你使用的任何将fs::path转换为字符串的函数的返回类型。例如,请注意path.c_str()的返回类型不是 const char *——它是 const value_type *

    fs::path p("/foo/bar");

    const fs::path::value_type *a = p.c_str();
      // Portable, for whatever that's worth.

    const char *b = p.c_str();
      // OK on POSIX; compilation error on Windows.

    std::string s = p.u8string();
    const char *c = s.c_str();
      // OK on both POSIX and Windows.
      // Performs 16-to-8 conversion on Windows.

上述示例,情况c,保证可以编译,但在两个平台上的行为不同:在 POSIX 平台上,它会给你想要的原始字节字符串,而在 Windows 上,它会昂贵地将path.native()从 UTF-16 转换为 UTF-8(这正是你要求的——但如果你找到了避免请求的方法,你的程序可能会更快)。

fs::path 有一个模板构造函数,可以从几乎任何参数构建一个 path。参数可以是任何字符类型的序列(charwchar_tchar16_tchar32_t),并且该序列可以表示为指向空终止字符串的指针、空终止字符串的 迭代器basic_stringbasic_string_view 或迭代器对。像往常一样,我提到这种大量的重载不是因为你想要使用它们中的任何一个,而是让你知道如何避免它们。

标准还提供了一个自由函数 fs::u8path("path"),它是 fs::path("path") 的同义词,但可能作为提醒,你传递的字符串应该是 UTF-8 编码的。我建议忽略 u8path

这一切可能听起来比实际情况要可怕。请记住,如果你坚持使用 ASCII 文件名,你就不必担心编码问题;如果你记得避免使用“本地”访问器方法,path.native()path.c_str(),以及避免隐式转换为 fs::path::string_type,那么你就不必过于担心可移植性。

路径操作

x (except path itself) represents the return value of the member function path.x():
    assert(root_path == root_name / root_directory);
    assert(path == root_name / root_directory / relative_path);
    assert(path == root_path / relative_path);

    assert(path == parent_path / filename);
    assert(filename == stem + extension);

    assert(is_absolute == !is_relative);
    if (IsWindows) {
      assert(is_relative == (root_name.empty() ||  
    root_directory.empty()));
    } else {
      assert(is_relative == (root_name.empty() &&  
    root_directory.empty()));
    }

例如,给定路径 p = "c:/foo/hello.txt",我们有 p.root_name() == "c:"p.root_directory() == "/"p.relative_path() == "foo/hello.txt"p.stem() == "hello",和 p.extension() == ".txt"。至少,在 Windows 上是这样的!请注意,在 Windows 上,绝对路径需要根名称和根目录("c:foo/hello.txt""/foo/hello.txt" 都不是绝对路径),而在 POSIX 中,由于不存在根名称,绝对路径只需要根目录("/foo/hello.txt" 是绝对路径,而 "c:foo/hello.txt" 是以奇怪目录名称 "c:foo" 开头的相对路径)。

operator/ to concatenate paths. fs::path supports both operator/ and operator/= for this purpose, and they do almost exactly what you'd expect--concatenate two pieces of a path with a slash in between them. If you want to concatenate pieces of a path without adding that slash, use operator+=. Unfortunately, the C++17 standard library is missing operator+ for paths, but it's easy to add as a free function, as follows:
    static fs::path operator+(fs::path a, const fs::path& b)
    {
      a += b;
      return a;
    }

路径还支持在令人困惑的成员函数名 path.concat("foo")(不带斜杠)和 path.append("foo")(带斜杠) 下进行带斜杠和不带斜杠的连接。请注意,这与你的预期正好相反!因此,我强烈建议永远不要使用命名成员函数;始终使用运算符(可能包括你在前面代码中描述的自定义定义的 operator+)。

关于 fs::path 的最后一个可能令人困惑的问题是,它提供了 beginend 方法,就像 std::string 一样。但与 std::string 不同,迭代的单位不是单个字符——迭代的单位是 名称!这在以下示例中可以看到:

    fs::path p = "/foo/bar/baz.txt";
    std::vector<fs::path> v(p.begin(), p.end());
    assert((v == std::vector<fs::path>{
      "/", "foo", "bar", "baz.txt"
    }));

在实际代码中,你永远不会有一个迭代绝对 fs::path 的理由。在 p.relative_path().parent_path() 上迭代——其中每个迭代的元素都保证是目录名称——在特殊情况下可能有一些价值。

使用 directory_entry 检查文件状态

警告!directory_entry 是 C++17 <filesystem> 库中最前沿的部分。我即将描述的内容既不是由 Boost 实现的,也不是由 <experimental/filesystem> 实现的。

从文件的 inode 中检索文件元数据是通过查询类型为fs::directory_entry的对象来完成的。如果你熟悉 POSIX 方法来检索元数据,想象一下fs::directory_entry包含一个type fs::path类型的成员和一个type std::optional<struct stat>类型的成员。调用entry.refresh()基本上等同于调用 POSIX 函数stat();调用任何accessor方法,例如entry.file_size(),如果可选成员仍然未连接,则会隐式调用stat();如果可选成员已经连接,则可能只是使用上次查询时缓存的值。仅仅构造一个fs::directory_entry实例并不会查询文件系统;库会在你提出具体问题之前保持等待。提出具体问题,例如entry.file_size(),可能会使库查询文件系统,或者(如果可选成员已经连接)它可能只是使用上次查询时缓存的值。

    fs::path p = "/tmp/foo/bar.txt";
    fs::directory_entry entry(p);
      // Here, we still have not touched the filesystem.

    while (!entry.exists()) {
       std::cout << entry.path() << " does not exist yet\n";
       std::this_thread::sleep_for(100ms);
       entry.refresh();
         // Without refresh(), this would loop forever.
    }
      // If the file is deleted right now, the following
      // line might print stale cached values, or it
      // might try to refresh the cache and throw.
    std::cout << entry.path() << " has size "
          << entry.file_size() << "\n";

实现相同目标的一种较老的方法是使用fs::status("path")fs::symlink_status("path")来检索fs::file_status类的实例,然后通过诸如status.type() == fs::file_type::directory之类的繁琐操作从file_status对象中提取信息。我建议你不要尝试使用fs::file_status;最好使用entry.is_directory()等。对于喜欢自虐的人,你仍然可以直接从directory_entry中检索fs::file_status实例:entry.status()等同于fs::status(entry.path()),而entry.symlink_status()等同于fs::symlink_status(entry.path()),这反过来又是一个稍微快一点的等效操作。

fs::status(entry.is_symlink() ? fs::read_symlink(entry.path()) : entry.path()).

顺便提一下,自由函数fs::equivalent(p, q)可以告诉你两个路径是否都硬链接到同一个 inode;而entry.hard_link_count()可以告诉你这个特定 inode 的总硬链接数。(确定那些硬链接的名称的唯一方法是在整个文件系统中进行遍历;即使如此,你的当前用户账户可能没有权限对这些路径进行stat操作。)

使用 directory_iterator 遍历目录

fs::directory_iterator正是其名字所暗示的。这种类型的对象允许你逐个条目地遍历单个目录的内容:

    fs::path p = fs::current_path();
      // List the current directory.
    for (fs::directory_entry entry : fs::directory_iterator(p)) {
      std::cout << entry.path().string() << ": "
      << entry.file_size() << " bytes\n";
    }

顺便提一下,注意前面代码中 entry.path().string() 的使用。这是必需的,因为 operator<< 在路径对象上表现得非常奇怪——它总是输出为如果你已经写下了 std::quoted(path.string())。如果你想输出路径本身,没有任何额外的引号,你总是必须在输出之前将其转换为 std::string。(同样,std::cin >> path 也不能用来从用户那里获取路径,但这不是很讨厌,因为你根本不应该使用 operator>>。有关从用户那里解析输入的更多信息,请参阅第九章 Chapters 9,Iostreams,和第十章 Chapter 10,Regular Expressions)。)

递归目录遍历

要以 Python 的 os.walk() 风格递归地遍历整个目录树,你可以使用以下基于先前代码片段的递归函数:

    template<class F>
    void walk_down(const fs::path& p, const F& callback)
    {
      for (auto entry : fs::directory_iterator(p)) {
        if (entry.is_directory()) {
          walk_down(entry.path(), callback);
        } else {
          callback(entry);
        }
      }
    }

或者,你可以简单地使用一个 fs::recursive_directory_iterator

    template<class F>
    void walk_down(const fs::path& p, const F& callback)
    {
      for (auto entry : fs::recursive_directory_iterator(p)) {
        callback(entry);
      }
    }

fs::recursive_directory_iterator 的构造函数可以接受一个额外的 fs::directory_options 类型的参数,它修改了递归的确切性质。例如,你可以传递 fs::directory_options::follow_directory_symlink 来跟随符号链接,尽管如果恶意用户创建了一个指向其自身父目录的符号链接,这可能会是一个导致无限循环的好方法。

修改文件系统

<filesystem> 头文件的大多数功能都涉及检查文件系统,而不是修改它。但其中隐藏着一些宝贵的功能。许多这些函数似乎是为了使经典的 POSIX 命令行工具的效果在可移植的 C++ 中可用而设计的:

  • fs::copy_file(old_path, new_path):将位于 old_path 的文件复制到一个新的文件(即新的 inode)中,类似于 cp -n。如果 new_path 已经存在,则出错。

  • fs::copy_file(old_path, new_path, fs::copy_options::overwrite_existing): 将 old_path 复制到 new_path。如果可能,覆盖 new_path。如果 new_path 已存在且不是常规文件,或者它与 old_path 相同,则出错。

  • fs::copy_file(old_path, new_path, fs::copy_options::update_existing): 将 old_path 复制到 new_path。只有当 new_path 比位于 old_path 的文件旧时,才覆盖 new_path

  • fs::copy(old_path, new_path, fs::copy_options::recursive | fs::copy_options::copy_symlinks): 以 cp -R 的方式将整个目录从 old_path 复制到 new_path

  • fs::create_directory(new_path): 以 mkdir 的方式创建一个目录。

  • fs::create_directories(new_path): 以 mkdir -p 的方式创建一个目录。

  • fs::create_directory(new_path, old_path)(注意参数的顺序反转!):创建一个目录,但其属性来自 old_path 目录。

  • fs::create_symlink(old_path, new_path): 从 new_path 创建指向 old_path 的符号链接。

  • fs::remove(path): 以 rm 的方式删除文件或空目录。

  • fs::remove_all(path): 以 rm -r 的方式删除文件或目录。

  • fs::rename(old_path, new_path): 通过 mv 重命名文件或目录。

  • fs::resize_file(path, new_size): 通过零扩展或截断常规文件。

报告磁盘使用情况

说到经典的命令行工具,我们可能还想要对文件系统做的一件事是询问它有多满。这是命令行工具 df -h 或 POSIX 库函数 statvfs 的领域。在 C++17 中,我们可以使用 fs::space("path") 来实现,它返回一个类型为 fs::space_info 的结构体:

 struct space_info {
    uintmax_t capacity;
    uintmax_t free;
    uintmax_t available;
 };

每个这些字段都是以字节为单位的,我们应该有 available <= free <= capacityavailablefree 之间的区别与用户限制有关:在一些文件系统中,一部分空闲空间可能被保留给 root 用户,而在其他系统中,可能会有每个用户账户的磁盘配额。

摘要

使用命名空间别名以节省输入,并允许使用替代实现

库命名空间,例如 Boost。

std::error_code 提供了一种非常巧妙的方式来向上传递整数错误代码,而不需要异常处理;如果你在一个不赞成异常处理的领域中工作,请考虑使用它。(在这种情况下,这可能是你从这个特定章节中能得到的唯一东西!<filesystem> 库提供了抛出异常和非抛出异常的 API;然而,两个 API 都使用堆分配(并且可能抛出 fs::path 作为词汇类型。使用非抛出 API 的唯一原因是在某些情况下消除“使用异常进行控制流”的情况。)

std::error_condition 只提供了“捕获”错误代码的语法糖;像瘟疫一样避免使用它。

一个 path 由一个 root_name、一个 root_directory 和一个 relative_path 组成;最后一个是由斜杠分隔的 names。对于 POSIX,一个 name 是一串原始字节;对于 Windows,一个 name 是一串 Unicode 字符。fs::path 类型试图为每个平台使用适当的字符串类型。为了避免可移植性问题,请注意 path.c_str() 和隐式转换为 fs::path::string_type

目录存储从 namesinodes 的映射(C++ 标准将其称为“files”)。在 C++ 中,你可以通过 fs::directory_iterator 循环来检索 fs::directory_entry 对象;fs::directory_entry 上的方法允许你查询相应的 inode。重置 inode 就像调用 entry.refresh() 一样简单。

<filesystem> 提供了一系列用于创建、复制、重命名、删除和调整文件和目录大小的免费函数,以及一个用于获取文件系统总容量的最后函数。

本章讨论的大部分内容(至少是 <filesystem> 部分)都是 C++17 的前沿技术,截至出版时,任何编译器供应商都没有实现。使用这些新功能时请谨慎。

posted @ 2025-10-26 08:52  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报