3.3. C++中的内存所有权表示

C++ 语言的发展历史中,在表达内存所有权的方式上不断演进。相同的语法结构有时赋予了不同的、约定俗成的语义。这种演进部分是由语言新增的特性所推动的(如果没有共享指针,就很难谈论共享内存所有权)。另一方面,C++11 及之后版本添加的大多数内存管理工具并非新想法或新概念。“共享指针”的概念早已存在。语言层面的支持使得实现起来更容易(标准库中提供了共享指针,使得大多数自定义实现变得不必要),但共享指针在 C++11 将其纳入标准之前就已经在 C++ 中长期使用了。更重要的变化是 C++ 社区认知的演进,以及共同实践和惯用法的出现。正是在这个意义上,作为一组与不同语法特征相关联的通用约定和语义,可以将这套内存管理实践视为 C++ 语言的一种设计模式。现在,介绍一下表达不同类型内存所有权的各种方式。

3.3.1 非所有权

从最常见的一种内存所有权开始。大多数代码既不分配也不释放内存,既不构造也不析构对象。只是对之前由其他代码创建、将来也会由其他代码删除的对象进行操作。那么,如何表达一个函数将对某个对象进行操作,但不会尝试删除它,也不会(相反地)将该对象的生命周期延长到函数执行完毕之后这一概念呢?

这非常简单,每一位 C++ 开发者都曾多次这样做过:

// Example 01
void Transmogrify(Widget* w) { // 我不会删除 w
...
}

void MustTransmogrify(Widget& w) { // 我也不会删除
...
}

在一个编写良好的程序中,带有原始指针参数的函数表明,不以方式参与对应对象的所有权;引用的情况也是如此。同样,一个包含成员函数指针的类,该指针引用了一个对象,但期望由其他人来拥有该对象并管理其生命周期。下一个示例中的 WidgetProcessor 类的析构函数,并没有删除该类所指向的对象 —— 这明确表明我们拒绝拥有该对象:

// Example 02
class WidgetProcessor {
public:
  WidgetProcessor(Widget* w) : w_(w) {}
  WidgetProcessor() {} // 绝不删除 w_!!
  ...
private:
  Widget* w_; // 我不拥有 w_ 的所有权
};

应通过使用原始指针或引用来授予对对象的非拥有式访问,即使在拥有所有智能指针的 C++14 中,原始指针仍然有其用武之地。不仅如此,在大部分代码中,大多数指针都将是原始指针 —— 所有非拥有的指针(将在下一节看到的,C++17 和 C++20 将推进得更远)。

此时,有读者会指出,前面推荐的用于授予非拥有式访问的示例,看起来与之前展示的不良实践示例完全一样。其中区别在于上下文 —— 在一个设计良好的程序中,只有非拥有式访问才通过原始指针和引用授予。实际的所有权总是通过其他方式表达。当遇到原始指针时,很清楚该函数或类不会以方式干扰对象的所有权。当将到处都是原始指针的旧遗留代码转换为现代实践时,这会产生一些混淆。为了清晰起见,建议一次转换一部分代码,并在遵循现代指南和不遵循现代指南的代码之间进行明确过渡。

这里需要讨论的另一个问题是,使用指针还是引用。从语法上讲,引用本质上是一个不为空且不能未初始化的指针。因此,传递给函数的指针都可能为空,因此必须进行检查;而不能接受空指针的函数则必须改用引用。这是一个很好的约定,也可广泛使用,但尚未广泛到视为一种公认的设计模式。也许正是认识到,C++ 核心指南库提供了一种表达非空指针的替代方案 —— not_null<T*>。但这并不是语言本身的一部分,可以在标准 C++ 中实现,并且无需语言扩展。

3.3.2 独占所有权

第二种最常见的所有权类型是独占所有权 —— 代码创建一个对象,并将在之后自行删除。删除任务不会委托给其他人,也不允许延长该对象的生命周期。这种内存所有权类型非常普遍,我们经常在不假思索的情况下就完成了:

void Work() {
  Widget w;
  Transmogrify(w);
  Draw(w);
}

所有局部(栈上)变量都表达了唯一的内存所有权!在此上下文中,所有权并不代表其他人不会修改该对象。它仅仅意味着,当对象的创建者 —— 例子中是 DoWork() 函数 —— 决定删除它时,删除操作会成功,并且该对象确实会被删除。

这是 C++ 中最古老的对象构造方式,至今仍是最佳选择。如果栈变量能满足需求,请使用它。C++11 提供了另一种表达独占所有权的方式,它主要用在对象无法在栈上创建而必须在堆上分配的情况下。堆分配通常发生在所有权共享或转移时 —— 毕竟,栈上分配的对象会在其作用域结束时删除无法避免。如果需要让对象存活更长时间,就必须在其他地方分配。另一个在堆上创建对象的原因是,对象的大小或类型在编译时可能未知。这种情况通常发生在对象具有多态性时 —— 创建了一个派生类对象,但使用的是基类指针。无论出于何种原因不能在栈上分配对象,都可以使用 std::unique_ptr 来表达对这些对象的独占所有权:

// Example 03
class FancyWidget : public Widget { ... };
std::unique_ptr<Widget> w(new FancyWidget);

即使栈分配的对象看似足够,有时也可能因技术原因而必须在堆上构造对象:栈空间相当有限,通常在 2MB 到 10MB 之间。这是单个线程中所有栈分配的总空间,当超出此限制,程序就会崩溃。足够大的对象可能会耗尽栈空间,或将其消耗到接近极限,从而影响后续的分配。这类对象必须在堆上创建,并由栈上分配的 unique_ptr 或其他资源拥有型对象来管理。

如果对象的创建方式比简单的 operator new 更复杂,而我们需要一个工厂函数时,该怎么办?这正是接下来要讨论的所有权类型。

3.3.3 独占所有权的转移

前面的例子中,创建了一个新对象并立即绑定到一个 std::unique_ptr 独占指针上,从而保证了独占所有权。当对象由工厂创建时,使用端代码看起来完全一样:

std::unique_ptr<Widget> w(WidgetFactory());

但工厂函数应该返回什么?当然可以返回一个原始指针 Widget*,new 操作符就是这么做的。但这为错误使用 WidgetFactory 打开了大门 —— 例如,可能没有将返回的原始指针捕获到一个独占指针中,而是将其传递给了像 Transmogrify 这样的函数,而该函数接受原始指针是因为它不处理所有权问题。现在,没有人拥有这个 widget,最终导致内存泄漏。理想情况下,WidgetFactory 的编写方式应该强制调用者接管返回对象的所有权。

这里需要的是所有权的转移 —— WidgetFactory 当然是它所构造对象的独占所有者,但在某个时刻,需要将该所有权移交给一个新的、同样是独占的所有者。实现的代码非常简单:

// Example 04
std::unique_ptr<Widget> WidgetFactory() {
  Widget* new_w = new Widget;
  ...
  return std::unique_ptr<Widget>(new_w);
}
std::unique_ptr<Widget> w(WidgetFactory());

这完全按照我们期望的方式工作,但这是为什么呢?unique_ptr 不是提供独占所有权吗?答案是,确实提供独占所有权,但它同时也是一个可移动的对象(具有移动构造函数)。将一个 unique_ptr 的内容移动到另一个 unique_ptr 中,会转移所指向对象的所有权;原始指针将处于“已移动”状态(其析构不会删除对象)。

这种惯用法有什么好处呢?可清晰地表达并在编译时强制要求:工厂期望调用者接管对象的独占(或共享)所有权。例如,以下这段代码会导致新创建的 widget 没有所有者,因此无法通过编译:

void Transmogrify(Widget* w);
Transmogrify(WidgetFactory());

那么,在正确地获取了所有权之后,如何对 widget 调用 Transmogrify() 呢?这仍然是通过原始指针完成的:

std::unique_ptr<Widget> w(WidgetFactory());
Transmogrify(w.get());
Transmogrify(&*w); // 同上,如果 w 不为空,效果相同

但是栈变量呢?在变量销毁之前,独占所有权能否转移?这会稍微复杂一些 —— 对象的内存是在栈上分配的,并且即将销毁,需要进行一定程度的复制。具体复制多少取决于该对象是否可移动。通常情况下,移动会将所有权从“被移动”的对象转移到“目标”对象。这可以用于返回值,但更常用于将参数传递给那些需要独占所有权的函数。这样的函数必须声明为通过右值引用 T&& 来接收参数:

// Example 05
void Consume(Widget&& w) {
  auto my_w = std::move(w);
  ...
}

Widget w, w1;
Consume(std::move(w)); // 将 w 的所有权转移,之后不应再使用 w

// w 现在处于已移动状态
Consume(w1); // 编译失败 - 必须显式同意移动(应使用 std::move(w1))

调用者必须通过将参数包裹在 std::move 中来显式地放弃所有权。这是这种惯用法的一个优点;如果没有 std::move,一个转移所有权的调用看起来会和普通的调用完全一样。

3.3.4 共享所有权

最后一种需要讨论的所有权类型是共享所有权,即多个实体平等地拥有同一个对象。

首先,共享所有权经常误用或过度使用。回顾之前的例子,一个函数传递了一个并不需要拥有的对象的共享指针。人们很容易倾向于让引用计数来处理对象的所有权,从而不必担心删除问题。然而,这通常是一个设计不佳的迹象。在大多数系统中,资源的所有权明确,应该反映在资源管理的设计中。

“不必担心删除”这种顾虑仍然成立;显式删除对象的情况应该很少见,但自动删除并不需要共享所有权,只需要有明确表达的单一所有权即可(比如使用独占指针、数据成员或容器,都能很好地实现自动删除)。

话虽如此,共享所有权确实有其明确的适用场景。共享所有权最常见的有效应用是在底层的数据结构内部,例如链表、树等。同一数据结构的多个节点可能拥有一个数据元素,可以是前指向它的迭代器,也可能是数据结构成员函数内部操作整个结构或部分结构(例如:重新平衡一棵树)的一些临时变量。在设计良好的系统中,整个数据结构的所有权明确。但每个节点或数据元素的所有权可能是真正意义上的共享 —— 即所有者与其他所有者地位相等,没有哪个有特权或为主。

在 C++ 中,共享所有权的概念通过共享指针 std::shared_ptr 来表达:

// Example 06
struct ListNode {
  T data;
  std::shared_ptr<ListNode> next, prev;
};

class ListIterator {
  ...
  std::shared_ptr<ListNode> node_;
};

class List {
  ...
  std::shared_ptr<ListNode> head_;
};

这种设计的优点在于,即使列表元素已从列表中断开链接,只要还能通过迭代器访问它,该元素就会继续存活。这并不是 std::list 的实现方式,std::list 也不提供这样的保证(删除 std::list 对象会使所有迭代器失效)。

使用共享指针构成的双向链表会导致列表中任意两个连续的节点相互拥有对方,并且即使删除列表头,这些节点也不会删除,从而导致所拥有的对象发生泄漏。因此,实际的设计很可能会对 next 或 prev 中的一个使用 std::weak_ptr 来打破这种循环引用。

抛开这些复杂性不谈,对于某些需要迭代器在列表删除或某些元素擦除后,仍能拥有其所引用数据的应用来说,这种设计可能是有效的。一个例子是线程安全的列表,很难保证一个线程在另一个线程仍持有指向该元素的迭代器时不会将其删除。这个特定应用还需要原子共享指针,这在 C++20 中才可用(或者可以使用 C++11 自行实现)。

现在,函数以共享指针作为参数的情况如何呢?在一个遵循良好内存所有权实践的程序中,这样的函数向调用者传达了获取比函数调用本身更长的部分所有权 —— 将会创建一个共享指针的副本。并发上下文中,这也可能表明该函数需要保护对象,使其在执行期间至少不会让另一个线程删除。

必须牢记共享所有权的几个缺点。最广为人知的是共享指针的祸根,即循环依赖。如果两个带有共享指针的对象相互指向对方,整个这对对象将无限期地保持使用状态。C++ 提供了 std::weak_ptr 作为解决方案,它是共享指针的一个对应物,提供了一个指向可能已被删除对象的安全指针。如果前述相互指向的对象对中使用一个共享指针和一个弱指针,就打破了循环依赖。

循环依赖问题确实存在,但它更常出现在使用共享所有权,来掩盖资源所有权不明确这一更大问题的设计中。然而,共享所有权还有其他缺点。共享指针的性能总是低于原始指针。另一方面,独占指针可以和原始指针一样高效(事实上,std::unique_ptr 就是如此)。当共享指针首次创建时,必须为引用计数进行内存分配。

在 C++11 中,std::make_shared 可用于将对象本身的分配和引用计数器的分配合并,但对象创建时就打算共享(通常,对象工厂返回独占指针,其中一些稍后转换为共享指针)。复制或删除共享指针也必须递增或递减引用计数器。共享指针在线程安全的数据结构中往往很吸引人,在这些结构的底层,所有权的概念确实可能是模糊的,同一对象可能同时多次访问。然而,设计一个在上下文中线程安全的共享指针着实不易,并且会带来额外的运行时开销。

目前为止,我们主要局限于使用指针作为拥有对象(及其内存和其他资源)的手段。非所有权同样通过原始指针、引用或简单的非拥有指针来表达。然而,这并不是拥有资源的唯一方式(确实提到过,最常见的独占所有权形式是栈变量)。我们现在将来了解,如何直接使用资源拥有型对象来表达所有权和非所有权。