13.5. 实现ForwardList

我们现在知道,可以调整 Vector<T> 的实现,将其从显式的内存管理模式转变为隐式内存管理,而这样做带来了诸多优势。因此,很自然地会想到对其他容器也做类似的改造。然而,在踏上这样的“旅程”之前,先冷静地分析一下问题也是明智之举。

在第12章中实现了一个基于节点的容器 ForwardList<T>,采用的是显式内存管理。如果尝试将这个容器的实现改为更加隐式的管理方式,会带来什么影响呢?这正是需要探讨的问题。

13.5.1 尝试 —— 让每个节点负责其后继节点

探索如何使基于节点的容器中的内存管理更加隐式的各种方法时,一种可能的思路是修改 ForwardList<T>::Node 的定义,使原来的 next 指针成员从 Node* 类型变为 std::unique_ptr<Node>。

简要来说,将得到如下结构:

template <class T>
class ForwardList {
public:
  // ...
private:
  struct Node {
    value_type value;
    std::unique_ptr<Node> next; // <--
    Node(const_reference value) : value{ value } {
    }
    Node(value_type&& value) : value{ std::move(value) }{
    }
  };

  Node* head{};

  size_type nelems{};
// ...

乍一看,这似乎是一种改进,可以将 ForwardList<T> 的析构函数简化为以下形式:

// ...
~ForwardList() {
  delete head; // <-- lots of work starts here!
}
// ...

这种简化实际上引发了一种“多米诺效应”:由于每个节点的 next 成员现在成为其后继节点的所有者(除了链表头部 head 本身),因此只要销毁第一个节点,就会级联销毁它的后继节点,依此类推。

这种表面上的简化掩盖了一个棘手的问题:当调用 delete head; 时,可能会导致栈溢出(stack overflow)。原因在于,原本使用的是一个逐个释放每个节点的循环,而现在则变成了一个本质上是递归调用的过程。所以栈空间的使用从一个固定大小,变成了与链表中节点数量成正比的规模。这无疑是一个令人不快的变化!

有读者可能会想:“嗯,反正我只打算用这个 ForwardList<T> 来处理一些小列表,所以我不太担心。”如果这样想的,也许应该继续探讨一下这种实现方式在 ForwardList<T> 类中的其他影响。

其中一个影响是:迭代器的实现变得复杂了。我们不希望一个遍历节点的迭代器,成为它所指向节点的唯一拥有者。那样的话,在遍历链表的过程中节点就会销毁,这显然是破坏性的行为。因此,对于 ForwardList<T>::Node<U>(其中 U 是 T 或 const T),应该使用原始指针类型(如 T*)来保存值,而不是 std::unique_ptr。所以像 operator++() 这样的操作,就需要从每个节点的 std::unique_ptr 成员中获取其底层原始指针:

// ...
template <class U> class Iterator {
public:
  // ...

private:
  Node* cur{};

public:
  // ...
  Iterator& operator++() {
  cur = cur->next.get(); // <--
  return *this;
}
// ...

这确实带来了一点复杂度的增加,但并非难以应对。

在第12章中,将 ForwardList<T> 的大多数构造函数都统一到了一个更通用的构造函数中,该构造函数接受一对某种类型 It 的前向迭代器作为参数。这个构造函数现在会部分变得更为复杂,在链接节点时需要考虑到每个节点内部使用了智能指针。但在发生异常时的清理工作却变得更加简单:只需删除头节点,然后让上述的“多米诺效应”自动完成后续的资源释放即可:

// ...
template <std::forward_iterator It>
ForwardList(It b, It e) {
  try {
    if (b == e) return;
    head = new Node{ *b };

    auto q = head;
    ++nelems;

    for (++b; b != e; ++b) {
      q->next = std::make_unique<Node>(*b); // <--
      q = q->next.get(); // <--
      ++nelems;
    }
  } catch (...) {
    delete head; // <--
    throw;
  }
}
// ...

ForwardList<T> 的大多数成员函数将保持不变。不过,像 push_front() 这样的函数则需要做一些轻微的调整:

// ...
void push_front(const_reference val) {
  auto p = new Node{ val };
  p->next = std::unique_ptr<Node>{ head }; // <--
  head = p;
  ++nelems;
}

void push_front(T&& val) {
  auto p = new Node{ std::move(val) };
  p->next = std::unique_ptr<Node>{ head }; // <--
  head = p;
  ++nelems;
}
// ...

如上所示,需要区分操作 head 成员的代码和其他节点的代码,head 是一个 std::unique_ptr<Node>,而插入新节点时需要正确转移所有权。类似地,所有修改链表结构的成员函数都需要做出相应的调整,包括插入和删除操作。

一个更有趣、也更具启发性的成员函数是 insert_after(),用于在列表中某个给定迭代器之后插入一个元素。我们来详细看看这个函数的实现:

// ...
iterator
  insert_after(iterator pos, const_reference value) {
  auto p = std::make_unique<Node>(value); // <-- A
  p->next.reset(pos.cur->next.get()); // <-- B
  pos.cur->next.release(); // <-- C
  pos.cur->next.reset(p.get()); // <-- D
  p.release(); // <-- E
  ++nelems;
  return { pos.cur->next.get() }; // <-- F
}
// ...

嗯,这更新的内容可真不少!这个函数怎么变得这么复杂了呢?看看这些带字母标记的注释:

这……真的好复杂。这个函数之所以这么复杂,因为大部分工作都集中在“所有权的转移”上。毕竟,一个 std::unique_ptr<T> 对象代表对一个 T* 的唯一所有权。在链表中,每次插入或删除操作都需要重新安排指针,从而在节点之间转移所有权。我们通过让大多数操作变得复杂,来简化一个偶尔出现的情况(节点的删除)。这……真是令人难过。

关于含义与责任语义

智能指针的核心在于通过类型系统来表达含义与责任。虽然简化用户的代码很重要,但这并不是这些类型的主要目的。在 ForwardList<T> 对象中,真正拥有 T 类型对象的是 ForwardList<T> 本身,而 ForwardList<T>::Node<U> 对象(从 ForwardList<T> 的角度来看)本质上只是一种存储机制。

试图改变这种关系虽然可以实现,但随之而来的复杂性说明其中可能存在可疑的设计问题。

在编写一个类时,特别是容器类时,需要对每种类型的职责必须有清晰的定义。我们知道迭代器本质上是不拥有其所指向对象的(尽管在某些使用场景中,也可以设想使用 shared_ptr<T> 来共同拥有指针所指的对象)。

就容器及其底层表示而言,关键在于:如果设计是可管理的,则每种类型的职责必须清晰明确。

好了,让节点对其后继节点负责这条路行不通。那如果让 ForwardList<T> 对象的头指针成员负责列表中的其他节点,会不会好一些呢?

13.5.2 尝试:让头指针负责其他节点

让每个节点对其后继节点负责在语义上不正确。这会导致代码复杂、繁琐且容易出错,而这种设计所带来的某些实现上的简化,往往被其他地方新增的复杂性所抵消。

那么,也许可以尝试让头节点(head)成为一个带有自定义删除器(custom deleter)的 std::unique_ptr<Node> 对象,由它负责删除整个链表?

这个想法值得一试。总的来说,现在可以得到如下结构:

template <class T>
class ForwardList {
  // ...
  struct Node {
    value_type value;
    Node* next = nullptr;
    Node(const_reference value) : value{ value } {
    }
    Node(value_type&& value) : value{ std::move(value) }{
    }
  };

  struct deleter { // <--
    void operator()(Node* p) const {
      while (p) {
        Node* q = p->next;
        delete p;
        p = q;
      }
    }
  };

  std::unique_ptr<Node, deleter> head;
// ...

现在有一个 ForwardList<T> 类型,当该类型的对象销毁时,会隐式地确保列表中的节点也被析构。整个列表仍然是由原始指针构建的,节点不负责内存管理,这与之前的实现相比可能是一个改进。

通过这种实现,可以获得一个使用默认行为的 ForwardList<T> 析构函数,这是一件好事。而在 clear() 方法中会带来一点复杂度的增加,需要区分头节点的智能指针和其底层的原始指针:

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

由于头节点 head 不再是一个 Node*,而使用了非拥有(non-owning)资源的迭代器,所以迭代器接口需要进行一定程度的调整:

// ...
iterator begin() { return { head.get() }; } // <--
const_iterator begin() const {
  return { head.get() }; // <--
}
// ...

用于接收一对迭代器参数的 ForwardList<T> 构造函数,也是大多数其他构造函数最终调用的目标函数,需要进行一些小的修改:

// ...
template <std::forward_iterator It>
ForwardList(It b, It e) {
  if(b == e) return;

  head.reset(new Node{ *b }); // <--
  auto q = head.get(); // <--

  ++nelems;

  for(++b; b != e; ++b) {
    q->next = new Node{ *b };
    q = q->next;
    ++nelems;
  }
}
// ...

从异常处理的角度来看,该成员函数的实现确实变得更加简单了。因为在构建过程中,如果某个 T 类型对象的构造函数抛出异常,先前创建的节点将被自动销毁,这一行为为隐式保证。

与之前版本一样,push_front() 成员函数也需要进行一些调整,因其与 head 数据成员有交互:

// ...
void push_front(const_reference val) {
  auto p = new Node{ val };
  p->next = head.get(); // <--
  head.release(); // <--
  head.reset(p); // <--
  ++nelems;
}

void push_front(T&& val) {
  auto p = new Node{ std::move(val) };
  p->next = head.get(); // <--
  head.release(); // <--
  head.reset(p); // <--
  ++nelems;
}
// ...

好消息是,所有不与 head 数据成员交互的成员函数都不需要进行修改。

这种“隐式性”是否值得呢?这可能取决于编码方式,我们在异常安全方面确实获得了有价值的隐式保障。将关注点分离是有好处的,而此实现确实让容器摆脱了管理内存的任务(在很大程度上)。那是否“此处”的复杂度降低,值得“彼处”的复杂度增加,这取决于各位的判断了。