C++ 内存管理:12 使用显式内存管理编写泛型容器

自我们开始探索 C++内存管理机制与技术的奇妙之旅以来,已经走过了相当长的路程。 从第 4 章到第 7 章 ,我们构建了一个有趣的工具箱,既可在此基础上继续发展,又能灵活调整以解决未来可能遇到的新问题。 这个工具箱目前包含以下内容:

  • 对象隐式管理其资源的技术
  • 行为类似指针但通过类型系统编码指针目标责任关系的类型
  • 我们可以接管程序内存分配机制行为的多种方式

我们尚未涉及的内存管理一个重要方面是容器如何管理内存。 这实际上是个非常有趣的话题,我们将从三个不同角度, 分三个不同章节来探讨这个问题。

第一个角度是如何在容器中显式且高效地处理内存管理。 这正是本章要讨论的内容。 在某些应用领域,习惯上会自行实现(或维护)容器而非使用标准库提供的容器。 这可能有多种原因:例如贵公司可能有高度专业化的需求。 或许贵公司过去对标准库容器的性能不满意——可能是因为当时的实现效率低于预期——因此开发了自己的替代容器。 经过多年基于自研容器的编码后,转回使用标准库容器可能显得代价过高。

第二个角度相对简短些,探讨如何在容器中隐式且高效地处理内存,这部分内容将在第 13 章进行讲解,届时我们将重新审视并简化本章中看到的实现方案。

第三个角度更为复杂精妙,探讨如何通过分配器在容器中处理内存,这将构成第 14 章的主要内容。

在本章中,我们将编写一个(简易版的)std::vector<T> 仿制品,命名为 Vector<T>。 我们将借此机会讨论异常安全性(这是编写泛型代码时需要特别注意的重要问题)。 随后我们会发现,到目前为止的实现效率非常低下,因为 std::vector<T> 将比我们的 Vector<T> 替代方案高效得多(至少对某些类型而言)。 基于这个认识,我们将用更好的内存管理方案重新审视设计,在多个方面实现重要改进,并讨论一些能(也将)让我们的工作更轻松的重要底层标准内存管理工具。

我们还将编写一个自制的 std::forward_list<T> 仿制品,命名为 ForwardList<T>,因为基于节点的容器存在一些特定问题和考量,这是类似向量的类型无法让我们深入讨论的。 本章将编写一个"基础版"单向链表,我们会在第 13 章简要回顾它,然后在第 14 章进行更详细的讨论.

这意味着读完本章后,你将能够: 完成以下操作

  • 使用基础内存管理技术编写正确且异常安全的容器
  • 理解与 const 或引用数据成员相关的问题
  • 使用标准提供的底层内存管理算法

更广泛地说,你将明白为何 std::vector<T> 如此高效,以及为何在资源管理领域很难超越这种类型。 你还会了解到基于节点的容器(如 std::forward_list<T>)所面临的挑战,不过后续章节会对此进行更深入的探讨。 这并不意味着你不应该编写自己的容器(针对特定用例,我们通常能比通用解决方案做得更好),而是意味着你将更清楚地知道为何(以及何时)要这样做,以及需要投入多少精力来实现它。

全面性或代表性

本书通常不追求详尽无遗的表述或实现(毕竟像书这样的实体对象存在篇幅限制!),本章也不例外...远非如此! 若要完整实现受标准库启发的两种容器类型的所有成员函数,本书的篇幅将变得极其庞大——而您的标准库实现所涵盖的边界情况(以及提供的精妙优化)远比本书所能呈现的要多得多。 因此,我们将尝试展示一组核心成员函数供您在此基础上构建,而非逐一编写所有函数。

20年工作经验,承接微信小程序,App,网站,网站后端开发。有意向私聊我哈。微信:akluse

编写自己的 vector<T> 替代方案

假设某天你起床后宣布:"嘿,我要在 std::vector 最擅长的领域击败它",然后信心满满地开始编码。 这里有些忠告:

  • 这个看似简单的任务实际上困难得惊人:首先,std::vector 本身就是件艺术品,更何况你钟爱的标准库作者们都是技艺超群的大师。
  • 你可能仍认为自己能做到,所以尝试也无妨,但务必用两种元素类型测试你的想法:一种是极易构造的类型(例如 int 或 double),另一种则不然(例如 std::string),并比较结果。 对许多人而言,前者会带来卓越性能,而后者可能导致……悲剧。
  • 这种差异的原因在于,像 std::vector 这样的容器在内存管理上极其高效(我知道,在本书中读到这个结论肯定令人震惊!)。 实际上,它比自行实现的替代方案要优秀得多,除非你投入大量时间和精力,并且(极有可能)针对特定使用场景进行优化,使自制版本能更精准地适配需求。

您的标准库供应商确实投入了这样的时间和精力,而且正是为了您的利益,因此学习如何最优地使用 std::vector 最终可能会比尝试编写自己的等效容器带来更好的结果。 当然,最终使用哪种容器取决于您,而且您通常可以针对特定情况编写代码, 其性能优于标准容器提供的通用解决方案。

关于我们如何编写容器的总体说明

在本章及后续章节中,我们将编写(并使用)容器,因此需要简要说明以达成共识。 首先,我们将在容器中使用与标准容器相匹配的类型别名,这有助于更流畅地集成到其他标准库工具(如标准算法)中。 其次,我们将尽量为标准库中的成员函数使用相同的公共名称(例如,我们将使用 empty() 作为判断容器是否为空的谓词,与标准库现有实践保持一致,尽管有人可能认为 is_empty() 更为可取)。 最后,我们将采用渐进式优化方法:初始版本会更简单但效率较低,请耐心等待,亲爱的读者:我们正沿着自己的道路通往开悟!

连续元素容器的表示形式选择

通俗地说,std::vector 表示一个可按需动态增长的数组。 与普通数组类似,std::vector<T> 是由类型为 T 的元素构成的内存连续序列。 我们将自制版本命名为 Vector<T> 以使其与 std::vector<T> 形成明显区分.

要实现一个性能合理的实现,第一个关键思路是区分大小与容量 。 如果不这样做,而是决定让大小和容量相同,我们的 Vector<T> 实现在概念上将始终处于满状态,每次插入单个元素都需要扩容——这意味着要分配更多内存、将元素从旧存储复制到新存储、释放旧存储等操作。 可以说这种实现方式用"痛苦"来形容都显得是严重轻描淡写了 。

向量类类型的内部表示主要有两种方法。 一种是跟踪三个指针:

  • 一个指向已分配存储空间的起始位置
  • 一个指向元素末尾
  • 一种方式是标记已分配存储空间的末端(注意这里我们指的是半开区间,包含起始位置但不包含结束位置)

简化图示如下所示:

template <class T>
   class Vector {
      T *elems;
      T *end_elems;
      T *end_storage;
      // ...

另一种方式是保留指向分配存储空间起始位置的指针以及两个整数(分别表示容器的大小和容量)。 这种情况下的简化图示如下:

template <class T>
   class Vector {
      T *elems;
      std:size_t nelems; // number of elements
      std::size_t cap; // capacity
      // ...

这些表示方式在功能上是等效的,都能让我们编写出正确的容器,但它们各自带来了不同的权衡取舍。 例如,维护三个指针可以快速计算 end() 迭代器,但会导致 size() 和 capacity() 需要进行指针减法运算;而维护一个指针和两个整数则能让 size() 和 capacity() 快速计算,但需要通过指针与整数的加法运算来获取 end() 迭代器。

就大小而言,三指针表示法使得 sizeof(Vector<T>) 等于 3*sizeof(void*),因此在 8 字节对齐的 64 位平台上可能为 24 字节。 根据所使用的整数类型,指针和两个整数的大小可能相同,也可能略有差异。 例如,在 64 位机器上选择 32 位整数表示大小和容量,将导致 16 字节的表示形式和 8 字节对齐。 这些细节可能在资源受限的系统上产生影响,但正如您可能已经推断的那样,像 Vector<T> 这样的结构主要内存消耗成本来自于为 T 对象分配的内存。

由于对内存占用的考量、对哪些成员函数会被更频繁调用的预估等因素,不同的实现会做出不同的表示选择。 我们也需要做出选择;在本书中,我们将采用"一个指针加两个整数"的方案,但请记住这只是众多合理选项之一(你甚至可以尝试用其他表示方式来实现后续内容,看看会带来怎样的结果!)。

Vector<T> 的实现

我们将逐步讲解最初(基础版)的 Vector<T> 实现,逐步构建对其工作原理的理解,并说明为何我们认为这个实现确实比较基础。 我们的初始步骤大部分已经涵盖,包括通过符合标准库规范的类型别名来定义抽象,以及选择内部表示方式:

#include <cstddef>
#include <algorithm>
#include <utility>
#include <initializer_list>
#include <iterator>
#include <type_traits>
template <class T>
   class Vector {
   public:
      using value_type = T;
      using size_type = std::size_t;
      using pointer = T*;
      using const_pointer = const T*;
      using reference = T&;
      using const_reference = const T&;
   private:
      pointer elems{};
      size_type nelems{},
                cap{};
      // ...

您会注意到这个实现选择对 Vector<T> 的三个数据成员使用非静态数据成员初始化器,将它们初始化为默认值(整数为 0,指针为空),这在我们实现中是合适的,因为它表示一个空容器,这对于默认的 Vector<T> 来说似乎是一个合理状态.

接下来是一些简单但基础的成员函数:

   // ...
   public:
      size_type size() const { return nelems; }
      size_type capacity() const { return cap; }
      bool empty() const { return size() == 0; }
   private:
      bool full() const { return size() == capacity(); }
   // ...

注意 empty() 和 full() 的实现。 有些人倾向于在实现成员函数时直接访问数据成员(这里使用 nelems 和 cap 而非 size() 和 capacity()),但建议复用更基础的成员函数来实现这些"合成"函数。 这样能让你的代码对实现变更更不敏感,而且 C++编译器非常擅长函数内联优化,特别是当这些函数是非虚.

此刻,我们可能设计出的最有用的成员集合就是迭代器类型和类的数据成员,这将帮助我们使用标准算法来清晰高效地实现其余的成员函数。

迭代器

C++容器通常会将迭代器作为接口的一部分公开,我们的实现也不例外。 我们将为 const 和非 const 迭代器类型定义类型别名,这样若需要实现边界检查迭代器等替代方案时会更加简便,同时实现 const 和非 const 版本的 begin() 与 end() 成员函数:

      // ...
   public:
      using iterator = pointer;
      using const_iterator = const_pointer;
      iterator begin() { return elems; }
      const_iterator begin() const { return elems; }
      iterator end() { return begin() + size(); }
      const_iterator end() const {
         return begin() + size();
      }
      // for users' convenience
      const_iterator cend() const { return end(); }
      const_iterator cbegin() const { return begin(); }
      // ...

你可能会抱怨为 const 和非 const 版本编写 begin() 和 end() 时出现的语法重复,虽然它们在语法上相似但语义不同。 如果你手头有 C++23 编译器,可以通过方便的"推导 this"特性来简化:

      // alternative approach (requires C++23)
      template <class S>
         auto begin(this S && self) { return self.elems; }
      template <class S>
         auto end(this S && self) {
            return self.begin() + self.size();
         }

这是表达这些函数稍微复杂的方式,但它让我们能通过转发引用的类型推导系统,将 begin() 和 end() 的两个版本合并为一个。

构造函数和其他特殊成员函数

现在我们来讨论构造函数。 首先介绍的是默认构造函数和参数化构造函数,后者接收元素数量和初始值作为参数,例如 Vector<char>(3,'a') 会生成一个包含三个值为 'a' 的元素的容器。 需要注意的是,本例中 default 修饰的默认构造函数(是的,我知道)是隐式 constexpr 的,因为所有非 static 成员初始化器都能在 constexpr 上下文中解析:

      // ...
      Vector() = default;
      Vector(size_type n, const_reference init)
         : elems{ new value_type[n] },
           nelems{ n }, cap{ n } {
         try {
            std::fill(begin(), end(), init);
         } catch(...) {
            delete [] elems;
            throw;
         }
      }
      // ...

请注意这个构造函数中的异常处理代码,它会反复出现。 我们正在编写一个通用容器,因此使用了某种类型 T,而我们对其一无所知。 当调用 std::fill() 时(该函数将 init 参数的值赋给序列中的每个 T 对象),我们正在将一个 T 值赋给 T 对象,但我们不知道这个赋值运算符是否会抛出异常。

我们的职责是管理 elems,这是一个动态分配的 T 类型数组。因此,当某个赋值操作符抛出异常时,我们必须确保在 Vector<T> 构造函数失败前销毁并释放该数组;否则将导致内存泄漏,更严重的是,数组中已构造的对象将无法完成析构。 其中 catch(...) 代码块表示"捕获所有异常",而实际上并不清楚具体捕获了什么异常;throw; 表达式则表示"重新抛出捕获到的异常"。 事实上,我们并不想在此类情况下处理异常(我们对执行上下文缺乏足够认知:这是控制台应用程序? 图形界面程序? 嵌入式系统? 还有别的问题吗?);我们只是想确保构造 Vector<T> 对象失败时不会泄漏资源,并让用户代码明确知道构造函数未能满足其后置条件的原因(未能构造出有效对象)。

拷贝构造函数将遵循类似的模式,不同之处在于它不是用单个值的副本来填充序列,而是将值从源序列(other)复制到目标序列(*this 或 elems,取决于你的理解方式)。 移动构造函数则完全不同:

      // ...
      Vector(const Vector &other)
         : elems{ new value_type[other.size()] },
           nelems{ other.size() }, cap{ other.size() } {
         try {
            std::copy(other.begin(), other.end(), begin());
         } catch(...) {
            delete [] elems;
            throw;
         }
      }
      // ...
      Vector(Vector &&other) noexcept
         : elems{ std::exchange(other.elems, nullptr) },
           nelems{ std::exchange(other.nelems, 0) },
           cap{ std::exchange(other.cap, 0) } {
      }
      // ...

如你所见,对于这种类型而言,拷贝构造函数是个代价高昂的庞然大物:需要为 other.size() 个对象分配内存(对于非平凡构造对象,还会伴随同样多次的类型 T 默认构造函数调用),然后进行 other.size() 次赋值操作,还要处理可能抛出的异常。

移动构造函数则简单得多:它是一个常数时间、noexcept 的函数。 严格来说,大多数类并不需要移动操作(毕竟 C++在没有移动操作的年代也运行良好),但当你能利用它们时,最好还是加以利用。 速度提升可能非常显著,而且执行速度会变得更加可预测。

 

关于值与显著特性

如果你仔细阅读拷贝构造函数的代码,可能会注意到 *this 并没有复制 other.capacity(),而是决定让 cap 成为 other.size() 的副本。 在这种情况下的确应该这样做:容器的 size() 是所谓的显著属性 ,而 capacity() 更多是该对象生命周期的副产品,反映了其随时间增长的历史痕迹。 我们希望的是,在复制对象后,原始对象和副本在使用 operator== 进行比较时相等,当然,capacity() 不应影响该函数:通常认为两个数组相等,当它们具有相同数量的元素,并且每个元素与另一个容器中对应元素的值相同时。 复制容量在实践中可行,但对于大多数使用场景来说会显得浪费。

我(为了方便)添加了一个接受 initializer_list<T> 参数的构造函数, 以允许用类型为 Vector<T> 的对象的一系列 T 类型值进行初始化。 析构函数的功能应当不言自明:

      // ...
      Vector(std::initializer_list<T> src)
         : elems{ new value_type[src.size()] },
           nelems {src.size() }, cap{ src.size() } {
         try {
            std::copy(src.begin(), src.end(), begin());
         } catch(...) {
            delete [] elems;
            throw;
         }
      }
      // ...
      ~Vector() {
         delete [] elems;
      }

实现从源对象(此处:other)到目标对象(*this)的拷贝赋值运算符可能很复杂,如果以不规范的方式实现,因为它涉及清理代码(用于 *this 赋值前的内容)、复制源对象的状态,并确保我们正确处理自赋值以及复制源对象状态时可能抛出的异常。

幸运的是,Scott Meyers(以及无数后来者再次提出)发现了一个巧妙技巧:他注意到拷贝赋值操作可以通过拷贝构造函数(对象复制的核心)、析构函数(清理发生的地方)和 swap() 成员函数的组合来实现:只需将参数拷贝到一个匿名对象中(以最小化其生命周期),然后交换这个无名临时对象与 *this 的状态,最终使 *this 成为 other 的副本。 这个编程惯用法几乎总是奏效,这也解释了它为何如此成功!

移动赋值操作可以按照与拷贝赋值相同的思路实现,但在赋值运算符的实现中用移动构造函数替代拷贝构造函数即可。

      // ...
      void swap(Vector &other) noexcept {
         using std::swap;
         swap(elems, other.elems);
         swap(nelems, other.nelems);
         swap(cap, other.cap);
      }
      Vector& operator=(const Vector &other) {
         Vector{ other }.swap(*this);
         return *this;
      }
      Vector& operator=(Vector &&other) {
         Vector{ std::move(other) }.swap(*this);
         return *this;
      }
      // ...

向量式类的基本服务

我们已经实现了处理 Vector<T> 对象内部表示的特殊成员函数,但要编写一个便捷的动态数组类型还有更多工作。 例如,允许访问 first() 首元素或 back() 末元素的成员函数,以及通过方括号访问数组中特定索引元素的函数,这些都是应有的功能:


      // ...
      reference operator[](size_type n) {
         return elems[n];
      }
      const_reference operator[](size_type n) const {
         return elems[n];
      }
      // precondition: !empty()
      reference front() { return (*this)[0]; }
      const_reference front() const { return (*this)[0]; }
      reference back() { return (*this)[size() - 1]; }
      const_reference back() const {
         return (*this)[size() - 1];
      }
      // ...

正如预期的那样,对空的 Vector<T> 调用 front() 或 back() 属于未定义行为(当然你也可以让这些函数抛出异常,但这样所有程序都要为少数可能在所谓的 debug 模式下行为异常的程序买单)。 同样,这六个成员函数可以通过 C++23 的"推导 this"特性缩减为三个:

      // alternative approach, (requires C++23)
      // ...
      template <class S>
         decltype(auto) operator[](this S && self,
                                   size_type n) {
            return self.elems[n];
         }
      // precondition: !empty()
      template <class S>
         decltype(auto) front(this S &&self) {
            return self[0];
         }
      template <class S>
         decltype(auto) back(this S &&self) {
            return self[self.size()-1];
         }
      // ...

有些人会希望在 const 和非常量版本中都添加一个 at() 成员函数,其行为类似于 operator[],但在尝试访问底层数组越界时会抛出异常。 如果您愿意 ,可以自由地这样做。

比较两个 Vector<T> 对象的等价性或非等价性相对简单,因为我们已经为我们的类型实现了迭代器:

      // ...
      bool operator==(const Vector &other) const {
         return size() == other.size() &&
                std::equal(begin(), end(), other.begin());
      }
      // can be omitted since C++20 (synthesized by
      // the compiler through operator==())
      bool operator!=(const Vector &other) const {
         return !(*this == other);
      }
      // ...

终于,你可能会说,我们来到了这本讨论内存管理的书中最令人感兴趣的部分:如何向容器添加元素,以及底层内存是如何管理的。 虽然不逐一探讨客户端代码可用于向 Vector<T> 对象添加元素的每种机制,但至少我们会研究 push_back() 和 emplace_back() 这两个成员函数:

  • 在此版本中,将包含两个 push_back() 成员函数:一个接收 const T& 作为参数,另一个则接收 T&&。 接收 const T& 参数的函数会在容器末尾复制该参数,而接收 T&& 参数的函数则会将其移动至该位置。
  • 成员函数 emplace_back() 将接受一个可变参数包,然后完美转发这些参数到 T 类型对象的构造函数,该对象将被放置在 容器的末尾。
  • 为了方便使用,emplace_back() 会返回对新构造对象的引用,以防用户代码需要立即使用它。 而 push_back() 不会这样做,因为它是在用户代码已经 可以访问的完整构造对象上调用。

在这三个函数中,我们首先检查容器是否已满,若已满则调用私有成员 grow() 函数。 该 grow() 函数需要分配比容器当前容量更多的内存,当然这一操作可能会失败。 请注意,如果 grow() 抛出异常,则新对象的添加操作从未发生,容器保持原状。 还需注意 grow() 考虑了 capacity() 值为 0 的情况,此时将选择任意默认容量 。

一旦 grow() 执行成功,我们就会在容器存储的最后一个对象之后添加新元素。 请注意,该值是通过赋值添加的,这意味着赋值操作左侧需要一个对象,即 grow() 不仅增加了存储空间,还(很可能)用类型 T 的默认对象对其进行了初始化。 因此我们可以推断,在这种 Vector<T> 的实现中,类型 T 需要公开一个默认构造函数:

      // ...
      void push_back(const_reference val) {
         if(full())
            grow();
         elems[size()] = val;
         ++nelems;
      }
      void push_back(T &&val) {
         if(full())
            grow();
         elems[size()] = std::move(val);
         ++nelems;
      }
   template <class ... Args>
      reference emplace_back(Args &&...args) {
         if (full())
            grow();
         elems[size()] =
            value_type(std::forward<Args>(args)...);
         ++nelems;
         return back();
      }
   private:
      void grow() {
         resize(capacity()? capacity() * 2 : 16);
      }
   // ...

请注意, 在 push_back() 和 emplace_back() 中的插入代码,在两种情况下都会执行以下操作:

elems[size()] = // the object to insert
++nelems;

您可能会想把元素数量的递增操作与实际插入表达式合并为一个操作, 如下所示:

elems[nelems++] = // the object to insert

不要那样做。"为什么要阻止我?" 你可能会问。 因为这会导致异常不安全的代码! 原因是后缀版本的 operator++() 具有极高(非常高!)的优先级, 远高于赋值操作。 这意味着在组合表达式中,nelems++ 会非常早地执行(这可能被忽视,因为该表达式返回的是 nelems 的旧值),而赋值操作随后进行,但赋值可能会抛出异常:我们正在将某种类型 T 的对象赋值给同类型的另一个对象,而我们不知道 T::operator=(const T&) 是否会抛出异常。 当然,如果确实抛出异常,赋值操作将不会发生,容器的末尾也不会添加任何对象;但元素数量却已被递增,从而导致一个不一致的 Vector<T> 对象。

这里有一个通用技巧:在确认可以安全操作之前,不要修改你的对象。 尽量先执行可能抛出异常的操作,然后再执行会改变对象状态的操作。 这样你会更安心,同时对象损坏的风险也能得到一定程度的缓解。

我们的 grow() 成员函数通过调用 resize() 来完成工作,将容器容量翻倍(如果原容量为 0,则选择一个默认容量)。 那么 resize() 是如何工作的? 在我们的实现中,其流程是:分配足够内存以满足新容量需求,将对象从旧内存块复制或移动到新内存块,然后用新内存块替换旧内存块,并更新容量值。

我们如何判断应该移动还是复制对象? 由于移动操作可能会破坏原始对象,因此只有当 T::operator=(T&&) 被显式声明为 noexcept 时才会执行移动操作。 我们使用 std::is_nothrow_move_assignable<T> 特性来确认这一点(如果不满足条件,则选择复制对象——这是更安全的做法,能保持原始对象完好无损):

  // ...
  public:
     void resize(size_type new_cap) {
        if (new_cap <= capacity()) return;
        auto p = new T[new_cap];
        if constexpr(std::is_nothrow_move_assignable_v<T>){
           std::move(begin(), end(), p);
        } else try {
           std::copy(begin(), end(), p);
        } catch (...) {
           delete[] p;
           throw;
        }
        delete[] elems;
        elems = p;
        cap = new_cap;
     }
     // ...

好了。 我同意这段代码并不简单,但也不是无法克服的。 记住这只是我们的初稿,对于大多数类型来说,它的速度会远远慢于 std::vector<T>

关于这个容器最后需要讨论的是如何 insert() 插入元素以及如何 erase() 删除元素。 在标准库中的工业级容器里, 有大量函数可以执行这两项操作,因此我们仅各选一种进行说明:在容器指定位置插入一系列值,以及从容器中删除指定位置的元素。

我们的 insert() 成员函数将是一个模板,它接收名为 first 和 last 的一对源迭代器,以及一个名为 const_iterator 的 pos,表示 Vector<T> 对象内的某个位置。 将其设为 template 意味着我们可以使用来自任何容器的迭代器对作为要插入值的来源,这确实是一个很有用的特性。

在函数内部,我们将使用一个名为 pos_ 的非 const 等效参数来替代 pos,这只是因为我们正在编写一个简化且不完整的容器,其中许多本应作用于 const_iterator 对象的成员函数都未被实现。

为了执行插入操作,我们将计算 remaining(容器中可用的剩余空间,以对象数量表示)和 n(要插入的对象数量)。 如果剩余可用空间不足,我们将通过 resize() 成员函数分配更多空间。 当然,调用 resize() 很可能会导致 pos_ 失效(它原本指向旧的内存块,而 resize() 完成后该内存块将被新块取代),因此我们会在调整大小前先计算容器中的相对 index,并在调整大小后重新计算新内存块中相当于 pos_ 的位置。

插入过程中一个有趣的转折是,我们需要先将从 pos_ 到 end() 的对象复制(或移动,但这里我们保持简单)到 ending 位置,即 end()+n 处,然后再在 pos_ 位置插入 n 个对象。但为了避免在复制过程中覆盖需要复制的对象,这个复制操作必须逆向进行(从最后一个到第一个)。std::copy_backward() 算法的表达方式是:第三个参数表示复制操作的目标终止位置,而非起始位置。

只有这时我们才会复制由 first 和 last 确定的序列到位置 pos_,更新 Vector<T> 对象中的元素数量,并返回标准要求的内容(指向第一个插入元素的迭代器,或在 pos 当 first==last 时,表示它们确定了一个空序列):


   template <class It>
   iterator insert(const_iterator pos, It first, It last) {
      iterator pos_ = const_cast<iterator>(pos);
      // deliberate usage of unsigned integrals
      const std::size_t remaining = capacity() - size();
      const std::size_t n = std::distance(first, last);
      if (remaining < n) {
         auto index = std::distance(begin(), pos_);
         resize(capacity() + n - remaining);
         pos_ = std::next(begin(), index);
      }
      std::copy_backward(pos_, end(), end() + n);
      std::copy(first, last, pos_);
      nelems += n;
      return pos_;
   }

我们的 erase() 成员函数将接收一个名为 const_iterator 的参数 pos,表示要从 Vector<T> 对象中删除元素的位置。 我们再次采用技巧,在函数内部使用名为 const 的迭代器 pos_。 删除 end() 是空操作(理应如此);否则,我们会执行从 next(pos_) 到 end() 的线性拷贝,覆盖从 pos_ 开始的位置,实际上将该位置之后的每个元素替换为其直接后继元素 。

最后,我们将最后一个元素替换为某个默认值,这一操作看似不必要但实际上很重要,因为末尾的 T 对象可能持有需要释放的资源。 例如,在使用 Vector<Res> 对象的程序中,当 Res 是会在析构时释放资源的 RAII 类型时,不替换"刚好超出末尾"的对象可能导致相关资源仅在 Vector 对象销毁时才会被关闭,而这一时刻可能比客户端代码预期的要晚得多。

随后我们更新 Vector<T> 对象中的元素数量。 再次说明,此实现意味着我们要求 T 必须公开默认构造函数,这并非本质要求(我们将在本章后续部分解除这一限制):

   iterator erase(const_iterator pos) {
      iterator pos_ = const_cast<iterator>(pos);
      if (pos_ == end()) return pos_;
      std::copy(std::next(pos_), end(), pos_);
      *std::prev(end()) = {};
      --nelems;
      return pos_;
   }

我知道你一定在想我们如何能做得更好,但我们很快就会回到这个问题上。 在此期间,我们将看看如何实现一个简单的基于节点的容器(一个自制的 std::forward_list<T> 类似类型)。

 

编写你自己的 forward_list<T> 替代实现

编写基于节点的容器如 std::liststd::unordered_mapstd::map 等是项有趣的练习,但在本章中,这种趣味性并不会立即"显现"出来。 这类容器的精妙之处将在第 13 章和第 14 章中更为明显,不过我们仍会在此实现一个基础简化版本,以便在后续章节中更清晰地展示各类容器的并行演进过程。

前向链表是一种追求精简的设计实践。 我们希望该类型保持小巧且能高效完成其功能。 某些前向链表在内存中仅占用单个指针的空间(指向序列中首节点的指针);而在我们的实现中,为了确保 size() 成员函数具有常数时间复杂度,我们将额外付出一个整型(元素数量)的空间代价。

基于节点的容器的表示选择

在我们的实现中,ForwardList<T> 将持有若干节点,每个节点包含由一个值(类型为 T)和指向序列中下一节点的指针组成的二元组。 末节点的 next 指针将为空值。

因此,ForwardList<T> 对象的表示形式将是一个 Node* 和一个无符号整型(表示链表中的元素数量)。 我们的实现将非常简单,仅展示一小部分成员函数。 你可以根据需要自由扩展它,只要确保这些函数能够高效实现。

ForwardList<T> 的实现

正如我们对 Vector<T> 所做的那样,我们将逐步讲解初始(基础版)ForwardList<T> 的实现。 第一步通常是定义符合标准库规范的抽象类型别名,并选择内部表示方式, 这是容器的常规做法:

#include <cstddef>
#include <algorithm>
#include <utility>
#include <iterator>
#include <initializer_list>
#include <concepts>
template <class T>
class ForwardList {
public:
   using value_type = T;
   using size_type = std::size_t;
   using pointer = T*;
   using const_pointer = const T*;
   using reference = T&;
   using const_reference = const T&;
   // ...

如前所述,ForwardList<T>::Node 对象将保存一个值和一个指向序列中下一个节点的指针。 初始状态下,下一个节点始终是空指针;列表负责组织节点,而节点本身则负责管理其中存储的值:

   // ...
private:
   struct Node {
      value_type value;
      Node *next = nullptr;
      Node(const_reference value) : value { value } {
      }
      Node(value_type &&value)
         : value { std::move(value) } {
      }
   };
   Node *head {};
   size_type nelems {};
   // ...

默认情况下,ForwardList<T> 对象的状态等同于空列表(head 为 null 指针且不含任何元素)。 对于大多数容器而言这是合理的默认状态, 因为在实际应用中,当用户请求默认构造函数时,通常期望获得的就是一个空容器。

编写 size() 和 empty() 这两个成员函数都很简单。 我将 empty() 实现为检查头节点是否为空,而非判断 size() 是否为零,因为在某些(合理的)单向链表实现中,大小是计算得出的而非存储的,这会导致 size() 成为线性时间复杂度操作而非常数时间复杂度。 实际上,提供一个常数时间复杂度的 size() 成员函数是个好主意,因为它符合大多数 用户的预期:

   // ...
public:
   size_type size() const { return nelems; }
   bool empty() const { return !head; }
   // ...

链表的迭代器不能是原始指针,因为其存储的元素在内存中并非连续存放。 我们需要一个类的实例能够遍历链表元素,并且能够考虑元素的 const 属性(或其缺失情况)。

我们的(私有)ForwardList::Iterator 类将作为某种类型的模板,U,其中(实际应用中)U 对于 T 而言是 ForwardList::iterator,而对于 const T 则是 ForwardList::const_iterator.

C++中的标准迭代器需要提供五种别名类型:

  • value_type:表示被指向值的类型
  • reference:表示对被指向值引用的类型
  • pointer:表示指向被指向值的指针类型
  • difference_type:表示该类型两个迭代器之间距离的类型( 有符号整型 )
  • iterator_category:截至 C++20 共有六种分类,它们通过描述迭代器能实现的操作来指导代码生成。 在我们的案例中,由于我们将提供 ++ 但不提供 --,因此我们将迭代器描述为属于 forward_iterator_category.

迭代器是一种描述如何遍历值序列的对象,ForwardList<T>::Iterator 也不例外。 迭代器提供的关键操作主要包括 operator++()(在序列中前进一个位置)、operator!=()(比较两个迭代器以判断是否到达序列末尾),以及 operator*() 和 operator->()(访问指向的元素或其服务)。 注意我们将 ForwardList<T> 设为友元类,因为该类负责节点的组织工作——当您能完全访问私有数据成员(如 cur)时,这项工作会更容易完成。:

   // ...
private:
   template <class U> class Iterator {
   public:
      using value_type =
         typename ForwardList<T>::value_type;
      using pointer = typename ForwardList<T>::pointer;
      using reference = typename ForwardList<T>::reference;
      using difference_type = std::ptrdiff_t;
      using iterator_category =
         std::forward_iterator_tag;
      friend class ForwardList<T>;
   private:
      Node *cur {};
   public:
      Iterator() = default;
      Iterator(Node *p) : cur { p } {
      }
      Iterator& operator++() {
         cur = cur->next;
         return *this;
      }
      Iterator operator++(int) {
         auto temp = *this;
         operator++();
         return temp;
      }
      bool operator==(const Iterator &other) const {
         return cur == other.cur;
      }
      // not needed since C++20
      bool operator!=(const Iterator &other) const {
         return !(*this == other);
      }
      U& operator*() { return cur->value; }
      const U& operator*() const { return cur->value; }
      U* operator->() { return cur->value; }
      const U* operator->() const { return cur->value; }
   };
public:
   using iterator = Iterator<T>;
   using const_iterator = Iterator<const T>;
   // ...

前述实现方案基于元素类型 U 使用模板,其中 Iterator<U> 可遍历该类型元素。 我们使用 U 而非 T,因为 T 是 ForwardList<T> 对象中的值类型。 在 ForwardList<T> 中,我们通过 iterator 和 const_iterator 分别为类型 Iterator<T> 和 Iterator<const T> 创建了别名。 我们本可以采用两种独立类型来实现,但模板方案显得更为简洁。

成员函数集合 begin() 和 end() 本质上很简单;begin() 返回指向链表头部的迭代器,而 end() 返回的概念上位于末尾之后的节点是一个空指针,这正是我们的 Iterator<U> 默认构造函数所提供的内容:

   // ...
   iterator begin() { return { head }; }
   const_iterator begin() const { return { head }; }
   const_iterator cbegin() const { return begin(); }
   iterator end() { return {}; }
   const_iterator end() const { return {}; }
   const_iterator cend() const { return end(); }
   // ...

我们有时需要 clear() 一个 ForwardList<T> 对象,这将导致我们销毁该容器的内容。 在这个实现中,为了简单起见,我让析构函数调用了 clear() 成员函数,但我们可以通过单独编写析构函数来节省少量处理时间(不需要在析构函数中重新初始化 nelems):

   // ...
   void clear() noexcept {
      for(auto p = head; p; ) {
         auto q = p->next;
         delete p;
         p = q;
      }
      nelems = 0;
   }
   ~ForwardList() {
      clear();
   }
   // ...

一个看似诱人的做法是编写一个 Node 析构函数,对其 next 数据成员执行 delete 操作;如果这样做,clear() 函数就只需写成 delete head;(这会调用 delete head->next 并以此类推递归执行),然后加上 nelems=0;。 但换作是我不会这么做:原则上,ForwardList 对象应当负责组织 ForwardList 中的节点,而不该将这个职责分散给众多 Node 对象本身。 这里存在一个小技术问题:调用 delete head; 会触发 delete head->next; 的执行,从技术上讲这又会继续调用 delete 作用于 head->next->next;,以此类推。 如果链表足够长,这将导致非常实际的栈溢出风险,而使用循环则可以完全避免这个问题。

这里有一个简单的经验:当每个类只承担单一职责时,生活会变得更轻松。 这一理念长期以来被称为"单一职责原则"。 该原则正是面向对象编程著名 SOLID 原则中的'S'。 让容器负责处理基于节点的容器中的节点组织, 而让节点专注于存储值。

就构造函数而言,我们将为这个类实现一个小型集合:

  • 一个模拟空列表的默认构造函数
  • 一个接受 std::initializer_list<T> 作为参数的构造函数
  • 一个复制构造函数,按顺序复制源链表中的每个节点
  • 一个移动构造函数
  • 一个序列构造函数,它接受某种类型的两个对象 It,该类型满足 std::input_iterator 概念(本质上:允许你至少对序列进行一次遍历并消费元素,这正是我们需要完成工作的全部要求)

事实上,最后一个构造函数可以被视为其他一些构造函数的泛化版本,只有默认构造函数和移动构造函数真正需要单独编写(从技术上讲,如果我们不将工作委托给通用构造函数,可以更高效地计算序列大小,因此如果在你的代码库中这会产生差异,请随时这样做):

   // ...
   ForwardList() = default;
   template <std::input_iterator It>
      ForwardList(It b, It e) {
         if(b == e) return;
         try {
            head = new Node{ *b };
            auto q = head;
            ++nelems;
            for(++b; b != e; ++b) {
               q->next = new Node{ *b };
               q = q->next;
               ++nelems;
            }
         } catch (...) {
            clear();
            throw;
         }
      }
   ForwardList(const ForwardList& other)
      : ForwardList(other.begin(), other.end()) {
   }
   ForwardList(std::initializer_list<T> other)
      : ForwardList(other.begin(), other.end()) {
   }
   ForwardList(ForwardList&& other) noexcept
      : head{ std::exchange(other.head, nullptr) },
        nelems{ std::exchange(other.nelems, 0) } {
   }
   // ...

不出所料,赋值操作可以通过我们在处理 Vector<T> 类型时应用的安全赋值惯用法来表达:

   // ...
   void swap(ForwardList& other) noexcept {
      using std::swap;
      swap(head, other.head);
      swap(nelems, other.nelems);
   }
   ForwardList& operator=(const ForwardList& other) {
      ForwardList{ other }.swap(*this);
      return *this;
   }
   ForwardList& operator=(ForwardList&& other) {
      ForwardList{ std::move(other) }.swap(*this);
      return *this;
   }
   // ...

剩余操作中有些可以合理地认为是琐碎的,例如 front()operator==() 和 push_front()。 正如您对单向链表可以合理预期的那样,我们既不会实现 back() 也不会实现 push_back() 成员函数,因为按照我们选择的表示方式,没有高效的方法来实现这些功能(唯一合理的算法需要遍历整个结构以找到最后一个节点,这将导致线性复杂度的算法):

   // ...
   // precondition: !empty()
   reference front() { return head->value; }
   const_reference front() const { return head->value; }
   bool operator==(const ForwardList &other) const {
      return size() == other.size() &&
             std::equal(begin(), end(), other.begin());
   }
   // can be omitted since C++20
   bool operator!=(const ForwardList &other) const {
      return !(*this == other);
   }
   void push_front(const_reference val) {
      auto p = new Node{ val };
      p->next = head;
      head = p;
      ++nelems;
   }
   void push_front(T&& val) {
      auto p = new Node{ std::move(val) };
      p->next = head;
      head = p;
      ++nelems;
   }
   // ...

作为向容器中插入值的示例,请考虑以下 insert_after() 成员函数,它会在 pos 指针所指向的节点后插入一个值为 value 的节点。 利用这个函数,我们可以轻松构建更复杂的功能,例如在列表中某个位置后插入一系列值( 试试看!):

   // ...
   iterator insert_after
      (iterator pos, const_reference value) {
      auto p = new Node{ value };
      p->next = pos.cur->next;
      pos.cur->next = p;
      ++nelems;
      return { p };
   }
   // ...

为容器提供添加元素的功能确实非常实用,同样重要的还有从容器中移除元素的选项。 例如,请看以下 erase_after() 成员函数的实现:

   // ...
   iterator erase_after(iterator pos) {
      if (pos == end() || std::next(pos) == end())
         return end();
      auto p = pos.cur->next->next;
      delete pos.cur->next;
      pos.cur->next = p;
      return { p->next };
   }
};

这个类的实现应该就到此为止了。 在本章剩余部分,ForwardList<T> 几乎没有改进空间,但我们将在第 13 章重新讨论这个类,并在第 14 章进行更深入的探讨。.

然而对于 Vector<T>,我们可以在现有基础上做得更好得多...代价是增加一些复杂性。 但我们已经准备好了, 不是吗?

更优的内存管理

因此,这位谦逊的作者认为我们虽好但简单的 Vector<T> 类型无法与 std::vector<T> 相提并论。 这看似是个大胆的断言:毕竟我们似乎完成了所需功能,甚至还使用了算法而非原始循环;我们捕获了异常, 因为希望实现异常安全,但仅限于清理资源...我们究竟哪里做错了?

如果你在 Vector<int> 对象和 std::vector<int> 对象之间进行对比基准测试,实际上你可能不会注意到两者测试数据的明显差异。 例如,尝试向这两个容器各添加一百万个 int 对象(通过 push_back() 方法),你会发现我们的容器表现相当不错。 很棒! 现在,将其改为 Vector<std::string> 与 std::vector<std::string> 的对比测试,你可能会有点沮丧,因为正如人们所说 ,我们"被远远甩在了后面"。

关于小对象优化的说明

当您添加不太短的字符串时(建议至少 25 个字符),效果会更明显。因为对于"短"字符串(这里的"短"没有明确定义),大多数标准库会执行所谓的小字符串优化 SSO),这是小对象优化 SOO)的一种特例。 通过这种优化,当对象需要存储的数据足够小时,实现会直接使用对象的"控制块"(实际上是数据成员)存储空间作为原始存储,完全避免动态内存分配。 因此,"小"字符串不需要分配内存, 在实际应用中速度极快。

但为什么呢?

两个测试中的元素类型提供了线索:int 是平凡可构造类型,而 std::string 则不是。 这一线索表明 std::vector 可能比我们调用的构造函数更少,本质上比 Vector 在处理内存及其所含对象的方式上更为高效。

问题出在哪里? 让我们通过查看 Vector<T> 的一个构造函数来理解当前实现的问题。 除了默认构造函数(在我们的实现中已默认)和移动构造函数外,任何构造函数都可以说明问题,因此我们选择接收元素数量和初始值作为参数的那个。 请特别注意高亮显示的代码:

   // ...
   Vector(size_type n, const_reference init)
      : elems{ new value_type[n] }, nelems{ n }, cap{ n } {
      try {
         std::fill(begin(), end(), init);
      } catch(...) {
         delete [] elems;
         throw;
      }
   }
   // ...

构建 elems 数据成员时,会分配一块足以容纳 n 个 T 类型对象的内存空间,并为这 n 个元素逐一调用默认构造函数。 显然,若 T 是平凡可构造类型,这些默认构造函数不会造成太大问题;但如果 T 并非平凡可构造类型 ,这种做法就值得商榷了。

尽管如此,您可能仍会辩称这些对象需要被构造,但往前看就会发现 std::fill() 会用 init 的副本替换所有这些默认的 T 对象,这表明最初的对象默认构造本质上是在浪费时间(我们从未使用过这些对象!)。 这正是 std::vector<T> 比我们做得更好的地方:它避免了冗余操作,只执行必要的操作。

现在我们将尝试探索如何在实际性能上更接近 std::vector<T> 所达到的水平 。

 

一个更高效的 Vector<T>

实现更高效 Vector<T> 的关键在于区分分配与构造——这是本书多次讨论的概念,同时还需要在受控环境中以恰当方式对类型系统进行"欺骗"。 没错,本书前几章那些"邪恶"内容现在要派上用场了。

我们不会在本文中重写完整的 Vector<T>,而是通过分析精选的成员函数来演示关键实现(完整实现可参见本章开头提到的 GitHub 仓库)。

我们可以尝试手动完成这项工作,利用我们已经熟悉的语言工具,比如 std::malloc() 来分配原始内存块,再通过定位 new 在该内存块中构造对象。 采用同样接收元素数量和初始值作为参数的构造函数,我们将得到以下代码:

   // ...
   Vector(size_type n, const_reference init)
      // A
      : elems{ static_cast<pointer>(
           std::malloc(n * sizeof(value_type)
        ) }, nelems{ n }, cap{ n } {
      // B
      auto p = begin(); // note: we know p is a T*
      try {
      // C
         for(; p != end(); ++p)
            new(static_cast<void*>(p)) value_type{ init };
      } catch(...) {
      // D
         for(auto q = begin(); q != p; ++q)
            q->~value_type();
         std::free(elems);
         throw;
      }
   }
   // ...

现在这真是...令人不快。 请注意标有 A 到 D 的函数部分:

  • 在 A 中,我们面临第一个谎言:虽然分配了能存储 n 个 T 类型对象的内存空间,但仅限于原始内存分配(此时不会调用任何 T 对象的构造函数),却出于自身目的保留了指向该内存块的 T* 指针。 我们的实现在内部需要意识到,elems 指针的类型在此阶段是不正确的。
  • 在 B 中,我们调用 begin() 时知道 iterator 与实现中的 T* 是等同的。 如果我们的实现使用类而非原始指针来模拟迭代器,就需要在此处获取指向原始存储的基础指针, 才能实现函数的剩余部分。
  • 在 C 中,我们在已分配的内存块中就地构造 T 对象。 由于该内存区域原本不存在需要替换的对象,我们使用 placement new 来构造这些对象,并利用我们欺骗类型系统的事实(即虽然分配的是原始内存但仍使用 T*)来执行从一个对象到另一个对象所需的指针运算。
  • 在 D 中,我们处理 T 对象构造函数可能抛出的异常。 由于我们是唯一知道其中存在 T 对象的人,正如我们是唯一确切知道首个构造失败对象位置的人,我们需要手动销毁这些对象,然后释放(此时已成为原始内存的)内存块并重新抛出异常。 额外说明,此实现甚至不符合标准;我们应按构造顺序的逆序销毁对象,而本例并未做到这点。

顺便一提,这个例子清晰地展示了为何不能从析构函数抛出异常:如果在 D 期间抛出异常,我们无法合理地期望恢复(至少在不付出高昂代价的情况下)。

亲爱的读者,此刻您可能觉得这种方式复杂得离谱,对于非专业人士来说错误率太高,几乎不可能用这种方法编写出完整的容器。 确实,这类复杂性会渗透到大量成员函数中,使得质量控制远比您期望的更加困难。

但请别急,还有希望! 正如您所料,库供应商面临着与我们相同的挑战(甚至更多!),因此标准库提供了底层设施,只要掌握了本书目前所讲的内容, 就能相对轻松地处理自制容器中的原始内存。

使用标准库底层设施

标准库头文件 <memory> 是内存管理领域的宝库。 我们已经讨论过该头文件中定义的标准智能指针(回顾请见第 5 章 ),但如果深入探究,你会发现一些专门用于操作原始内存的算法。

以接收元素数量和初始值作为参数的 Vector<T> 构造函数为例,我们从一个相当简单的实现(分配 T 对象数组并通过 std::fill() 调用替换它们)发展到了明显更复杂的实现。 原始版本虽然简单但效率低下(我们构造了不需要的对象只是为了替换它们);改进版本效率更高(只做最少的工作)但需要更高的技能来编写和维护。

现在我们将研究这些设施对我们实现分配成员函数的影响。 我们首先要关注的是构造函数,因为它们提供了一个很好的切入点。

对构造函数的影响

在实践中,当你想要编写一个显式管理内存的自定义容器时,最好使用 <memory> 中的底层设施。 请看以下示例:

   // ...
   Vector(size_type n, const_reference init)
      : elems{ static_cast<pointer>(
           std:malloc(n * sizeof(value_type))
        ) }, nelems{ n }, cap{ n } {
      try {
         std::uninitialized_fill(begin(), end(), init);
      } catch(...) {
         std::free(elems);
         throw;
      }
   }
   // ...

这比我们完全自己编写的版本好多了,不是吗? 这个版本的两大亮点如下:

  • 我们分配了一个大小合适的原始内存块,而不是 T 对象数组,从而避免了初始版本中所有不必要的默认构造函数。
  • 我们替换了对 std::fill() 的调用(位于 <algorithm> 中),该函数使用 T::operator=(const T&) 并假设赋值左侧存在现有对象,改为调用 std::uninitialized_fill(),后者假设它正在遍历原始内存并通过 placement new.

该算法(及其同类算法)的美妙之处在于它是异常安全的。 如果由 std::uninitialized_fill() 调用的某个构造函数抛出异常,那么在异常离开函数之前,异常发生前已成功创建的对象将会被销毁(按照与构造顺序相反的合理顺序)。

这实际上就是我们之前(笨拙地)手动编写的逻辑。 除了现在需要分配和释放原始内存外,其余代码与最初简单版本非常相似。 这应该会让你感觉好多了... 也确实应该如此。

类似的方法也可以应用于其他构造函数。 以拷贝构造函数为例:

   // ...
   Vector(const Vector& other)
      : elems{ static_cast<pointer>(
           std::malloc(n * sizeof(value_type))
        ) },
        nelems{ other.size() }, cap{ other.size() } {
      try {
         std::uninitialized_copy(
            other.begin(), other.end(), begin()
         );
      } catch (...) {
         std::free(elems);
         throw;
      }
   }
   // ...

如您所见,采用适当算法时,直接操作原始内存的快速实现与较慢的原始版本非常相似。

关键在于理解 API 的边界。 以 std::uninitialized_copy() 函数为例,它接收三个参数:源序列的起始和结束位置(该序列假定包含对象),以及目标序列的起始位置(该序列假定已正确对齐且由原始内存构成,不含对象)。 若函数因满足后置条件并在目标序列中构造对象而完成执行,则目标序列将包含这些对象。 反之,若函数未能满足后置条件,则目标序列中不存在任何对象——因为无论函数构造了什么,它都会将其析构。

类似的操作也可应用于其他构造函数,但需注意默认构造函数和移动构造函数的实现方式截然不同,因此需要区别对待。

 

对析构函数的影响

这个 Vector<T> 实现中的析构函数很有意思:当对象到达生命周期的终点时,我们不能简单地对其 elems 数据成员调用 delete[],因为它最初并非由 new[] 分配,而是由一系列 T 对象组成,后面可能还跟着一串原始字节。我们不想对任意字节序列调用 T::~T(),因为这可能会对程序造成严重破坏并导致 UB.

唯一知道容器中有多少对象的实体是 Vector<T> 对象本身,这意味着它需要 destroy() 剩余的对象,然后才能 free() 释放(此时已不包含任何对象)剩余的内存块。 对一系列 std::destroy() 算法应用于 T 对象时,会调用 T::~T() 析构函数,将对象序列转化为原始内存:

   // ...
   ~Vector() {
      std::destroy(begin(), end());
      std::free(elems);
   }
   // ...

这些底层内存管理算法确实有助于厘清我们所编写代码的意图,正如您所见。

对逐元素插入函数的影响

类似情况也出现在成员函数 push_back() 和 emplace_back() 中——过去我们通过赋值替换数组末尾的现有对象;现在由于该位置已无对象(这正是我们努力的意义所在——避免无谓构造对象),需要在数组末尾直接构造对象。

显然我们可以使用定位 new 来实现,但标准库提供了道德等价物 std::construct_at()。 这使得我们的意图在源代码中更为清晰:

   // ...
   void push_back(const_reference val) {
      if (full())
         grow();
      std::construct_at(end(), val);
      ++nelems;
   }
   void push_back(T&& val) {
      if (full())
         grow();
      std::construct_at(end(), std::move(val));
      ++nelems;
   }
   template <class ... Args>
      reference emplace_back(Args &&...args) {
         if (full())
            grow();
         std::construct_at(
            end(), std::forward<Args>(args)...
         );
         ++nelems;
         return back();
      }

 

对增长函数的影响

我们最初实现的 grow() 函数 调用了 resize() 方法作用于 Vector<T>,但 resize() 的本意是用对象初始化存储空间。 要实现 不初始化对象而扩展分配存储空间的功能,我们需要使用另一个成员函数, 即 reserve().

关于 RESIZE()与 RESERVE()的区别

简而言之,resize() 可能会向容器中添加对象,因此它可能同时修改 size() 和 capacity()。 另一方面,reserve() 不会向容器添加任何对象,仅可能增加容器使用的存储空间;换句话说,reserve() 可以改变 capacity() 但不会改变 size().

遵循 std::vector<T> 的范例,我们的 Vector<T> 类将同时提供 resize() 和 reserve() 方法。 以下是一个针对我们部分对象、部分原始内存容器新特性调整的 resize() 版本,以及适用于 reserve() 的 Vector<T> 实现。 我们将分别讨论 reserve() 和 resize()

   // ...
private:
   void grow() {
      reserve(capacity()? capacity() * 2 : 16);
   }
public:
   void reserve(size_type new_cap) {
      if(new_cap <= capacity()) return;
      auto p = static_cast<pointer>(
         std::malloc(new_cap * sizeof(T))
      );
      if constexpr(std::is_nothrow_move_assignable_v<T>) {
         std::uninitialized_move(begin(), end(), p);
      } else try {
         std::uninitialized_copy(begin(), end(), p);
      } catch (...) {
         std::free(p);
         throw;
      }
      std::destroy(begin(), end());
      std::free(elems);
      elems = p;
      cap = new_cap;
   }
   // ...

reserve() 成员函数首先确保请求的新容量高于现有容量(否则无需任何操作)。 若满足条件,它将分配新的内存块,并通过在 Vector<T> 对象的新内存中移动或复制现有元素(如果移动 T 对象可能抛出异常则会进行复制:亲爱的读者们,将移动操作标记为 noexcept 是值得的!)来完成这一过程,该过程使用在原始内存中构造对象的算法。

随后,T 对象在 elems 中被销毁(即使它们已被移出:仍需要完成析构),我们确保 cap 被更新且 elems 指向新的存储块。 当然,size() 不会改变,因为没有新对象被添加到容器中。

该过程与 resize() 类似(如下所示),不同之处在于从 size() 索引开始的内存块位置会被初始化为默认的 T 值,而非保持原始内存状态。 因此,size() 会被更新,这导致其语义与调用 reserve() 后获得的语义不同。:

   // ...
   void resize(size_type new_cap) {
      if(new_cap <= capacity()) return;
      auto p = static_cast<pointer>(
         std::malloc(new_cap * sizeof(T))
      );
      if constexpr(std::is_nothrow_move_assignable_v<T>) {
         std::uninitialized_move(begin(), end(), p);
      } else try {
         std::uninitialized_copy(begin(), end(), p);
      } catch (...) {
         std::free(p);
         throw;
      }
      std::uninitialized_fill(
         p + size(), p + capacity(), value_type{}
      );
      std::destroy(begin(), end());
      std::free(elems);
      elems = p;
      nelems = new_cap;
      cap = new_cap;
   }
   // ...

我们正在实施的这种更复杂的结构显然会影响我们 insert() 或 erase() 元素的方式。

对元素插入和删除功能的影响

正如预期的那样, 诸如 insert() 和 erase() 这样的成员函数需要更新,以考虑我们对内部组织结构的修改,这些修改涉及 Vector<T> 对象。 只要每个函数的语义从一开始就清晰明了,这种更新就不会太痛苦(实际上,所需的改动可能非常小),但这确实需要谨慎处理。

例如,以 insert(pos,first,last) 为例,我们正在从图 12.1:

 

图 12.1 - 朴素 Vector<T> 插入模型示例

在此处,于位置 pos 插入一个 [first,last) 序列意味着:首先以逆序方式将 [pos,end()) 区间内的元素复制到 pos + n 位置,然后用 [first,last) 区间的元素覆盖 [pos,pos+n) 区间的元素,这一过程对应图 12.2 中描述的更复杂模型:

 

图 12.2 - Vector<T> 当前插入模型示例

其核心思想是需要在位置 pos 处插入 [first,last) 区间,这意味着 [pos,pos+n) 区间内的元素需要被复制(或移动)到右侧。 这将需要 在原始内存中(前图中的灰色区域) 构造若干对象,并通过复制(或 移动)赋值替换其他对象。

此处需要考虑四个步骤:

  • 需要从 [begin(),end()) 序列复制或移动多少元素到容器末端的原始内存块,以及这些结果对象应该在该内存块的什么位置进行构造。
  • 如果要从 [first,last) 序列中插入元素到原始内存(可能没有),应该有多少个元素? 如果有这样的对象,它们将被插入到 end().
  • 如果要从 [pos,end()) 序列中复制或移动元素来替换容器中的现有对象(可能没有元素),那么应该有多少个元素? 在这种情况下,目标范围的末尾将是 end()
  • 最后,[first,last) 序列中剩余需要插入的元素将被复制到容器中,从 pos 位置开始.

一种可能的实现方式如下:

  // ...
   template <class It>
   iterator insert(const_iterator pos, It first, It last) {
      iterator pos_ = const_cast<iterator>(pos);
      const auto remaining = capacity() - size();
      const auto n = std::distance(first, last);
      // we use cmp_less() here as remaining is an unsigned
      // integral but n is a signed integral
      if (std::cmp_less(remaining, n)) {
         auto index = std::distance(begin(), pos_);
         reserve(capacity() + n - remaining);
         pos_ = std::next(begin(), index);
      }
      // objects to displace (move or copy) from the
      // [begin(),end()) sequence into raw memory
      const auto nb_to_uninit_displace =
         std::min<std::ptrdiff_t>(n, end() - pos_);
      auto where_to_uninit_displace =
         end() + n - nb_to_uninit_displace;
      if constexpr(std::is_nothrow_move_constructible_v<T>)
         std::uninitialized_move(
            end() - nb_to_uninit_displace, end(),
            where_to_uninit_displace
         );
      else
         std::uninitialized_copy(
            end() - nb_to_uninit_displace, end(),
            where_to_uninit_displace
         );
      // objects from [first,last) to insert into raw
      // memory (note: there might be none)
      const auto nb_to_uninit_insert =
         std::max<std::ptrdiff_t>(
            0, n - nb_to_uninit_displace
         );
      auto where_to_uninit_insert = end();
      std::uninitialized_copy(
         last - nb_to_uninit_insert, last,
         where_to_uninit_insert
      );
      // objects to displace (copy or move) from the
      // [pos,end()) sequence into that space (note:
      // there might be none)
      const auto nb_to_backward_displace =
         std::max<std::ptrdiff_t>(
            0, end() - pos_ - nb_to_uninit_displace
         );
       // note : end of destination
      auto where_to_backward_displace = end();
      if constexpr (std::is_nothrow_move_assignable_v<T>)
         std::move_backward(
            pos_, pos_ + nb_to_backward_displace,
            where_to_backward_displace
         );
      else
         std::copy_backward(
            pos_, pos_ + nb_to_backward_displace,
            where_to_backward_displace
         );
      // objects to copy from [first,last) to pos
      std::copy(
         first, first + n - nb_to_uninit_insert, pos
      );
      nelems += n;
      return pos_;
   }

请确保不要移动 [first,last) 范围内的元素: 这可能会破坏源区间的数据,对用户极不友好!

至于我们最初以较为简单方式编写的 erase() 成员函数,我们需要做出的关键调整在于处理被移除元素的方式:您可能记得,在简单版本中,我们在容器末尾为被擦除的 T 元素分配了默认值,并指出这增加了类型 T 需要默认构造函数的可疑要求。 在这个版本中,我们将直接销毁该对象,结束其生命周期并将其底层存储空间恢复为原始内存:

   iterator erase(const_iterator pos) {
      iterator pos_ = const_cast<iterator>(pos);
      if (pos_ == end()) return pos_;
      std::copy(std::next(pos_), end(), pos_);
      std::destroy_at(std::prev(end()));
      --nelems;
      return pos_;
   }

希望本文能让亲爱的读者更清楚地了解如何编写一个更专业的自制 std::vector 类容器,并更加感激您喜爱的标准库提供商背后那些工程师们的精湛技艺。 要知道,他们所做的这一切乃至更多,都是为了能让您的程序保持如此出色的高效性!

 

常量或引用成员与 std::launder()

在结束本章之前, 我们需要对几种特殊容器稍作说明:一类是存储 const 类型对象的容器,另一类是元素类型包含 const 成员或引用成员的容器。

看看这个看似无害的程序:

// ...
int main() {
   Vector<const int> v;
   for(int n : { 2, 3, 5, 7, 11 })
      v.push_back(n);
}

按照我们当前的实现,这段代码将无法通过编译,因为我们的实现调用了许多底层函数(std::free()std::destroy_at()std::construct_at() 等),这些函数需要接收指向非 const 类型的指针作为参数。

如果我们要支持这样的程序,就意味着必须在实现中的某些地方"抛弃"const 特性。 例如,替换下面这行代码

      std::free(elems); // illegal if elems points to const

与此:

      using type = std::remove_const_t<value_type>*;
      std::free(const_cast<type>(elems));

同样地,如果您的容器需要支持在已有对象的位置进行插入操作,像 std::copy() 或 std::copy_backward() 这类算法所执行的赋值操作将无法作用于 const 对象或包含 const 数据成员的对象。 您可以通过用析构后接构造的方式替代赋值操作来实现这一功能,但如果在必须被替换的对象析构后构造失败,您的代码将降低异常安全性。

当然,抛弃 const 属性会将我们带入危险地带,因为我们已经濒临未定义行为的恐怖领域。 标准库的实现者自然可以为所欲为,毕竟他们与编译器实现者关系密切,但我们这些凡夫俗子可没有这种特权,因此必须小心行事。

类似的情况也出现在具有引用类型数据成员的复合对象上:你无法创建引用容器,因为引用不是对象,但你完全可以创建包含引用类型数据成员的对象容器。 问题在于,当一个具有引用数据成员的对象被替换时,如何理解其行为。

让我们用一个比 Vector<T> 更简单的例子来说明这种情况。 假设我们有以下类,用于持有对某个类型为 T 的对象的引用:

#include <type_traits>
template <class T>
struct X {
   static_assert(std::is_trivially_destructible_v<T>);
   T &r;
public:
   X(T &r) : r{ r } {
   }
   T& value() { return r; }
   const T & value() const { return r; }
};
// ...

就目前而言,这个类足够简单,似乎很容易理解。 现在,假设我们有以下客户端代码:


// ...
#include <iostream>
#include <new>
int main() {
   int n = 3;
   X<int> h{ n };
   h.value()++;
   std::cout << n << '\n'; // 4
   std::cout << h.value() << '\n'; // 4
   int m = -3;
   // h = X<int>{ m }; // nope
   X<int> *p = new (static_cast<void*>(&h)) X<int>{ m };
   std::cout << p->value() << '\n'; // -3
   // UB (-3? 4? something else?)
   std::cout << h.value() << '\n';
   std::cout << std::launder(&h)->value() << '\n'; // -3
}

通过赋值替换一个 X<int> 对象是不正确的,因为具有引用数据成员会删除您的赋值运算符,至少默认情况下如此(默认含义会产生歧义:引用应该重新绑定到其他对象,还是应该对所指对象进行赋值?)。

解决此问题的一种方法是销毁原始对象并在其位置构造一个新对象。 在我们的示例中,由于我们通过 static_assert 确保了 T 是可平凡析构的,因此我们直接在原对象位置构造了新对象(从而结束了前一个对象的生命周期)。 随后所有位都被正确地映射到新对象... 只是编译器可能不会遵循我们的推理逻辑。

实际上,编译器会尽其所能追踪对象的生命周期,但我们人为制造了一个原 X<int> 对象从未被显式销毁的场景。 因此,编译器可能仍认为这个原始的 X<int> 对象依然存在,但实际上通过非常规手段,该内存地址上的原始对象数据已被新对象覆盖。 由于我们(坦白说)在特定内存地址上显式构造对象时耍了些花招——这个地址恰好已被另一个对象占用—— 这就导致内存数据与编译器基于源代码的理解之间可能出现偏差。

通过 p 访问 value() 必然会得到 -3,因为很明显 p 指向一个 X<int> 对象,该对象持有对 m 的引用,而此时 m 的值为 -3。 通过 h 访问 value() 是未定义行为(生成的代码会返回比特位表示的值还是编译器认为该代码所表达的值?)。

这种看似邪恶的情况——编译器理解的代码逻辑可能与实际位模式不符——会出现在具有 const 数据成员的对象、具有引用数据成员的对象,以及某些以怪异方式构建的 union 类型中。但这些正是我们用于底层对象操作的工具,它们潜藏在 std::optional<T>std::vector<T> 等实现之下。 说到底,使用这些怪异类型是我们的选择,但这也是生活的一部分。

当比特位与编译器所能理解的内容不完全一致时,我们就需要用到 std::launder()。 请谨慎使用它:这是一个优化屏障,相当于告诉编译器"直接看比特位就好,在查看这个指针指向的对象时,忘记你所知道的源代码信息"。 当然,这是个非常危险的工具,使用时需要格外小心,但有时它正是我们所需要的。

概述

呼,这一章可真长啊! 我们首先实现了一个简单的 vector 式容器,然后是一个简单的 forward_list 式容器,接着又回头审视了 vector 式容器(我们将在接下来两章中再次讨论 forward_list 式容器),以展示更精细的内存控制如何能带来更高效的容器实现。

本章中的实现是"手动"完成的,即我们亲自进行了内存管理。 这涉及编写大量代码,我们将在第 13 章重新考虑这个问题。在第 14 章中,我们将研究分配器如何与容器交互,并借此机会重新审视我们的 forward_list 式容器,因为随着我们在 C++内存管理领域的持续探索,将有许多有趣的方面值得研究。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

akluse

失业老程序员求打赏,求买包子钱

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值