5.2. 标准智能指针概览

C++ 提供的智能指针种类有限。在探讨标准库提供的选项之前,先来看一个待解决的问题。考虑以下(故意不完整的)程序,各位读者能发现其中的问题吗?

class X {
  // ...
};

X *f();

void g(X *p);

void h() {
  X *p = f();
  g(p);
  delete p;
}

这段代码虽然合法,但绝不符合现代 C++ 的编程规范。它存在诸多潜在问题,以下仅列举部分典型风险:

作为对比,来看两个可能的 f() 实现方案(实际存在更多可能性,但这些已足够说明问题):

X *f() { // 这里是一种可能性
  return new X;
}

X *f() { // 这里是另一种可能性
  static X x;
  return &x;
}

第一种情况下,对返回的指针调用 delete 可能是合理的。但第二种情况下,这样做将导致灾难性后果。然而,函数的签名中没有信息能够明确告知调用方,当前属于哪种情况 —— 甚至可能是完全不同的第三种情形。

作为某种“额外惊喜”:如果调用 f() 时没有使用返回值会发生什么?若 f() 的实现是 return new X; 或类似形式,代码就会发生内存泄漏 —— 这显然不是我们期望的结果。自 C++17 起,你可以通过 [[nodiscard]] 属性标记函数返回类型来缓解这个问题,但这仍需要开发者保持警惕。尽管有时不得不这样做,但通常应尽量避免从函数返回原始指针。

这里还存在其他潜在陷阱,都指向一个共同的核心问题 —— 使用原始指针时,无法直接从源码中解读其语义。更具体地说:

多年来,原始指针无法明确表达所有权关系这一缺陷,已成为 C++ 程序中反复出现的错误根源。

现在考虑另一种情况:

// ...
void f() {
  X *p = new X;
  thread th0{ [p] { /* 使用 *p */ };
  thread th1{ [p] { /* 使用 *p */ };
  th0.detach();
  th1.detach();
}

f() 分配了一个由 p 指向的 X 对象,随后两个线程 th0 和 th1 复制了 p(从而共享 p 指向的 X 对象)。最终,th0 和 th1 分离(detached),所以即使 f() 执行完毕,这两个线程仍会继续运行直至完成。如果不知道 th0 和 th1 的结束顺序,就无法明确应该由哪个线程负责调用 delete 释放 p。这同样是指针所指对象的所有权不明确的问题,但与前一个例子类型不同,因此需要不同智能指针选择的方案。

后续章节将详细探讨这些类型的作用和意义,有助于各位读者在具体场景中做出明智选择。

5.2.1 通过函数签名表达意图

尽管尚未深入探讨标准智能指针的细节,但有必要简要说明 std::unique_ptr 和 std::shared_ptr 的核心语义:

关键区别在于:

以下代码结合 std::unique_ptr 和原始指针,通过类型系统明确所有权语义:

#include <memory>
#include <iostream>

// print_pointee() 与调用者共享一个指针
// 但并不接管指针的所有权
template <class T> void print_pointee(T *p) {
  if (p) std::cout << *p << '\n';
}

std::unique_ptr<T> make_one(const T &arg) {
  return std::make_unique<T>(arg);
}

int main() {
  auto p = make_one(3); // p 是一个 std::unique_ptr<int>
  print_pointee(p.get()); // 调用者和被调用者在此调用期间共享该指针
}

这种设计通过类型签名清晰传达了意图:process 函数无需(也不应)释放传入的指针,而 p 的生命周期由 std::unique_ptr 自动管理。

这里使用 std::unique_ptr 对象来建模所有权 —— make_one() 构造 std::unique_ptr<T> 并将所有权转移给调用者;然后,该调用者保留该对象的所有权,并与其它函数(这里是 print_pointee())共享底层指针,但不会放弃所指对象的所有权。使用但不拥有则通过原始指针来建模。

这个高度简化的场景展示了拥有资源和共享资源之间的区别 —— main() 中的 p 拥有资源,但它与非所有者 print_pointee() 中的 p 共享该资源。这些都是安全且符合 C++ 惯用法的代码。

了解到标准智能指针类型代表所有权后,知道只要存在单一、明确的资源最后使用者,std::unique_ptr 是首选类型;它比 std::shared_ptr 轻量得多(后面会看到),并提供了适当的所有权语义。

当然,也存在 std::unique_ptr 不是理想选择的使用场景。考虑以下简化、非线程安全且不完整的代码片段:

class entity {
  bool taken{ false };
public:
  void take() { taken = true; }
  void release() { taken = false; }
  bool taken() const { return taken; }
  // ...
};

constexpr int N = ...;

// entities 是存放实体对象的地方。我们没有动态分配它们,
// 但如果真的需要动态分配的话,我们会使用 unique_ptr<entity>,
// 因为这些对象将在此处被最后一次使用(即在此作用域中管理其生命周期)。
array<entity,N> entities;

class nothing_left{};

// 这个函数返回一个不拥有所有权的指针(第 6 章将介绍比原始指针更易用的替代方案)
entity * borrow_one() {
  if(auto p = find_if(begin(entities), end(entities), [](auto && e) { return !e.taken(); };
    p != end(entities)) {
    p->take();
    return &(*p); // 非拥有指针
  }
  throw nothing_left{};
}

borrow_one() 与调用代码共享指针,但不共享该指针的所有权 —— 实体对象的提供者仍然全权负责这些对象的生命周期。这既不适合使用 std::unique_ptr(资源的唯一所有者),也不适合使用 std::shared_ptr(资源的共同所有者)。正如将在第6章看到的,除了原始指针外,还有其他方式可以表达非所有权的指针语义。

这里的重点是:函数签名传递了语义信息,选择能准确表达意图的类型至关重要。要做到这一点,必须首先明确自己的设计意图。在接下来的章节中探索如何利用标准智能指针时,请务必牢记这一原则。