我们现在知道,可以调整 Vector<T> 的实现,将其从显式的内存管理模式转变为隐式内存管理,而这样做带来了诸多优势。因此,很自然地会想到对其他容器也做类似的改造。然而,在踏上这样的“旅程”之前,先冷静地分析一下问题也是明智之举。
在第12章中实现了一个基于节点的容器 ForwardList<T>,采用的是显式内存管理。如果尝试将这个容器的实现改为更加隐式的管理方式,会带来什么影响呢?这正是需要探讨的问题。
探索如何使基于节点的容器中的内存管理更加隐式的各种方法时,一种可能的思路是修改 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
}
// ...
嗯,这更新的内容可真不少!这个函数怎么变得这么复杂了呢?看看这些带字母标记的注释:
在 A 行,为要插入的值创建了一个名为 p 的 std::unique_ptr
在 B 行,做了必要的操作,让 p 的后继成为 pos 的后继。这需要小心处理,pos.cur->next 保证是一个 std::unique_ptr
在 C 行,确保 pos.cur 放弃对 pos.cur->next 的责任。这很重要,如果不这样做,替换掉的那个 std::unique_ptr
当 pos.cur->next 断开连接,就来到 D 行,它指向 p 底层的原始指针。这又会导致两个指针对同一个节点拥有责任,所以继续执行 E 行,断开 p 与其底层指针的连接。
F 行则通过返回一个指向原始指针(因此是非拥有)的迭代器,完成了这个函数的工作。
这……真的好复杂。这个函数之所以这么复杂,因为大部分工作都集中在“所有权的转移”上。毕竟,一个 std::unique_ptr<T> 对象代表对一个 T* 的唯一所有权。在链表中,每次插入或删除操作都需要重新安排指针,从而在节点之间转移所有权。我们通过让大多数操作变得复杂,来简化一个偶尔出现的情况(节点的删除)。这……真是令人难过。
智能指针的核心在于通过类型系统来表达含义与责任。虽然简化用户的代码很重要,但这并不是这些类型的主要目的。在 ForwardList<T> 对象中,真正拥有 T 类型对象的是 ForwardList<T> 本身,而 ForwardList<T>::Node<U> 对象(从 ForwardList<T> 的角度来看)本质上只是一种存储机制。
试图改变这种关系虽然可以实现,但随之而来的复杂性说明其中可能存在可疑的设计问题。
在编写一个类时,特别是容器类时,需要对每种类型的职责必须有清晰的定义。我们知道迭代器本质上是不拥有其所指向对象的(尽管在某些使用场景中,也可以设想使用 shared_ptr<T> 来共同拥有指针所指的对象)。
就容器及其底层表示而言,关键在于:如果设计是可管理的,则每种类型的职责必须清晰明确。
好了,让节点对其后继节点负责这条路行不通。那如果让 ForwardList<T> 对象的头指针成员负责列表中的其他节点,会不会好一些呢?
让每个节点对其后继节点负责在语义上不正确。这会导致代码复杂、繁琐且容易出错,而这种设计所带来的某些实现上的简化,往往被其他地方新增的复杂性所抵消。
那么,也许可以尝试让头节点(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 数据成员交互的成员函数都不需要进行修改。
这种“隐式性”是否值得呢?这可能取决于编码方式,我们在异常安全方面确实获得了有价值的隐式保障。将关注点分离是有好处的,而此实现确实让容器摆脱了管理内存的任务(在很大程度上)。那是否“此处”的复杂度降低,值得“彼处”的复杂度增加,这取决于各位的判断了。