12.3. 自定义forward_list的替代方案

编写基于节点的容器(例如 std::list、std::unordered_map、std::map 等)是一项有趣的练习。本章中,这种有趣之处未必会立刻显现出来。这类容器类的有趣特点将在第13章和第14章中更加明显。不过,我们仍会在此处编写一个基本的简化版本,以便在接下来的章节中更清晰地展示我们容器类型的并行演进过程。

forward_list 是对精简性的一种练习。我们希望该类型体积小巧,并能高效地完成其职责。某些 forward_list 在内存中仅占用一个指针的大小(指向序列中第一个节点的指针)。在实现中,为了使 size() 成员函数具有常数时间复杂度的保证,将额外付出一个整数(元素数量)的空间代价。

12.3.1 基于节点的容器的表示方式选择

我们的实现中,ForwardList<T> 将会包含节点,每个节点保存一个由值(类型为 T)和一个指向序列中下一个节点的指针组成的配对。最后一个节点的下一个节点指针,将是一个空指针(null pointer)。

因此, ForwardList<T> 对象的表示方式将是一个指向节点的指针(Node*),以及一个无符号整数(用于表示列表中元素的数量)。我们的实现将会非常简单,并只展示一小部分成员函数。只要保证添加的函数都能高效实现,就可以根据需要自由扩展该类的功能。

12.3.2 ForwardList<T>的实现

正如我们对 Vector<T> 所做的那样,将逐步展示最初的(简单的)ForwardList<T> 实现。第一步是通过符合标准库的类型别名(type aliases)来定义类,并选择内部表示形式,这与我们通常处理容器的方式一致:

#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() 表达为判断 head 是否为 null 指针,而不是通过 size() 是否为零来判断,因为在某些(合理的)forward_list 实现中,size() 是可计算的,而不是存储的,这会使 size() 成为一个具有线性复杂度的操作,而不是常数时间的操作。实际上,提供一个常数时间复杂度的 size() 成员函数是一个好主意,其更符合大多数用户的预期:

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

链表上的迭代器不能是原始指针,其所存储的元素在内存中并不是连续存放的。我们需要一个类,其实例可以遍历列表中的元素,并且能够考虑到元素的 const 性质(或缺乏 const 性质)。

我们(私有的)ForwardList<T>::Iterator 类将是一个以某种类型 U 为模板的类,其中(实际上)当用于 ForwardList<T>::iterator 时,U 是 T;而当用于 ForwardList<T>::const_iterator 时,U 是 const T。

C++ 中的标准迭代器预期会提供5个类型别名:

迭代器是一个描述我们如何遍历一系列值的对象,ForwardList<T>::Iterator 也不例外。迭代器所暴露的关键操作包括:

我们将 ForwardList<T> 声明为友元类(friend),该类负责节点的组织工作,而这种操作在拥有对 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;
    }

    // 自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() 成员函数,但其实可以通过单独编写析构函数来节省一小部分处理时间(析构函数中不需要重新初始化 elems):

  // ...
  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;(这会递归地调用 head→next 的析构函数,依此类推),然后设置 nelems = 0;。

如果是我的话,不会这么做:从原则上讲,ForwardList<T> 对象应该负责组织这些节点,而这项责任不应该交给众多的 Node 对象自己去处理。此外,还有一个小的技术问题:调用 delete head; 会导致 delete head→next; 被调用,而这又会调用 delete head→next→next;,依此类推。如果链表足够长,这将非常容易导致栈溢出,用循环来实现则完全可以避免这个问题。

这里有一个简单的经验:当每个类只承担一个责任时,生活会更加轻松。这其实是一个早已被广泛认可的设计原则,称为“单一职责原则”。这个原则是著名的面向对象设计原则 SOLID 中的“S”。让容器来管理基于节点的结构组织,让节点只负责存储值即可。

至于构造函数,我们只为这个类实现一小部分常用的构造方式:

事实上,最后一个构造函数可以看作是前面某些构造函数的泛化形式,而只有默认构造函数和移动构造函数在单独编写时才会有优势(如果不把工作委托给一个通用构造函数,理论上可以更高效地计算序列的大小;因此,如果你的代码库中性能差异显著,可以自由选择实现方式):

  // ...
  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()。

对于前向链表(forward list),我们不会实现 back() 或 push_back() 这样的成员函数,根据表示方式选择,这样做将没有高效的实现方式(唯一合理的方法是遍历整个链表以找到最后一个节点,这会导致线性复杂度的算法):

  // ...
  // 前置条件: 非空)
  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());
  }

  // 自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> 来说,目前的实现还有很大提升空间……当然,这种改进是以增加一些复杂性为代价的。不过,我们已经准备好迎接这一挑战了,不是吗?