5.3. unique_ptr

正如其名所示,std::unique_ptr<T> 表示对所指对象的独占所有权。这实际上是处理动态分配内存时最常见(甚至可能是最普遍)的所有权语义。

回顾本章最初的(故意不完整的)示例,无法从源码中确定所指对象的所有权关系。现在用 std::unique_ptr 替代原始指针进行重写:

#include <memory>

class X {
  // ...
};

std::unique_ptr<X> f();

void g(std::unique_ptr<X>&);

void h() {
  // 可以写成 std::unique_ptr<X> 而非 auto
  auto p = f();
  g(p);
} // p 在此处隐式释放其所指向的 X 对象

f()返回的对象负责管理指针所指向的X对象的生命周期,同时g()函数虽然使用了封装的X*指针,但并不会接管所指X对象的生命周期管理。再加上p是一个对象类型,无论g()抛出异常,还是调用f()时忘记使用返回值导致p销毁,程序都能保持异常安全 —— 最终得到的代码比原始版本更简洁也更优雅!

墨菲与马基雅维利

你可能会想:“但我肯定能偷走g()里那个std::unique_ptr托管的指针”,没错 —— 不仅能偷,还很容易偷,unique_ptr提供了不止一种直接访问底层指针的方式。但类型系统的设计初衷是保护程序免于意外,让合理编写的代码顺利运行。它能防范墨菲(意外事故),却防不住马基雅维利(蓄意破坏的代码)。

如果故意写烂代码,最终得到的必然是个烂程序。这难道不是理所当然的吗?

仅通过函数签名配合std::unique_ptr的使用就能讲述完整逻辑。注意,以下示例中的函数刻意保留了未实现状态,以明确我们仅关注其签名:

// ...
// 动态创建一个 X 或 X 的派生类对象,
// 并将其返回,不会导致内存泄漏
unique_ptr<X> factory(args);

// 按值传递 unique_ptr,由于 unique_ptr 不可复制,
// 实际上是通过移动语义传递
unique_ptr<X> borrowing(unique_ptr<X>);

// 按引用传递,允许修改指针所指向的对象。
// 实际上,使用 X* 可能更合适
void possible_mutation(unique_ptr<X>&);

// 按 const 引用传递,用于查看指针指向的对象,
// 但不会修改它。实际上,使用 const X* 更合适
void consult(const unique_ptr<X>&);

// sink() 会“消耗”传入的对象:进来之后就不再传出。
// 虽然可以按值传递,但使用右值引用可能更清晰地表达意图
void sink(unique_ptr<X> &&);

// ...

5.3.1 对象管理

unique_ptr 类型是一种非常出色的工具,如果还未熟悉,应该尽快掌握。以下是一些关于该类型的要点及其在对象指针管理中的应用:

unique_ptr<T> 对象不可复制,其复制构造函数和复制赋值成员函数被标记为删除。这g() 通过引用接收参数 —— g() 与调用方共享指针指向的对象,但并不获取其所有权。也可以将 g() 表达为接收 X* 参数,按照现代惯例,这表示函数使用指针但不拥有:

#include <memory>

class X {
  // ...
};

std::unique_ptr<X> f();

void g(X*);

void h() {
  // 可以写成 std::unique_ptr<X> 而不是 auto
  auto p = f();
  g(p.get());
} // p 在此处隐式释放其所指向的 X 对象

unique_ptr<T> 同时是可移动的 —— 移动后的 unique_ptr<T> 行为类似于空指针,因此该类型的移动操作在语义上实现了所有权的转移。这使得实现需要间接管理资源的各种类型变得更加简单。

考虑以下 solar_system 类的例子,假设了一个 Planet 类型以及一个 create_planet() 实现:

#include "planet.h"
#include <memory>
#include <string>
#include <vector>

std::unique_ptr<Planet>
create_planet(std::string_view name);

class solar_system {
  std::vector<std::unique_ptr<Planet>> planets {
    create_planet("mercury.data"),
    create_planet("venus.data"), // 等
  };
public:
  // solar_system 默认不可复制(因为包含 unique_ptr)
  // solar_system 默认可以移动(move)
  // 不需要手动编写 ~solar_system 析构函数
  // 因为 planets 会隐式地管理其资源(自动释放)
}

如果当初决定用 vector<Planet*> 或 Planet* 来实现 solar_system,则这个类型的内存管理就必须由 solar_system 自己来完成,从而增加了该类型的复杂性。而使用了 vector<unique_ptr<Planet>>,一切就默认都是正确的。根据具体需求,直接使用 vector<Planet> 可能会更好。但为了示例的需要,必须使用指针。

unique_ptr<T> 提供了与 T* 几乎相同的操作,包括 operator*() 和 operator→(),还能通过 == 或 != 比较两个 unique_ptr<T> 是否指向同一个 T 对象。后两个操作可能看起来有些奇怪,这个类型代表了对所指对象的独占所有权 —— 当使用 unique_ptr<T> 的引用时,比较操作就有意义了:

#include <memory>

template <class T>
bool point_to_same(const std::unique_ptr<T> &p0,
const std::unique_ptr<T> &p1) {
  return p0 == p1;
}

template <class T>
bool have_same_value(const std::unique_ptr<T> &p0,
const std::unique_ptr<T> &p1) {
  return p0 && p1 && *p0 == *p1;
}

#include <cassert>

int main() {
  // 两个指向不同对象的指针,但这两个对象具有相同的值
  std::unique_ptr<int> a{ new int { 3 } };
  std::unique_ptr<int> b{ new int { 3 } };
  assert(point_to_same(a, a) && have_same_value(a, a));
  assert(!point_to_same(a, b) && have_same_value(a, b));
}

unique_ptr<T> 不支持指针运算。如果需要进行指针运算(有时确实需要 —— 比如在第13章编写自己的容器时),可以通过其 get() 成员函数获取 unique_ptr<T> 所拥有的原始指针。这在以下场景中特别有用:与C库交互、进行系统调用,或调用那些使用原始指针但不获取其所有权的函数。

哦,这里有个有趣的事 —— 除了本章稍后会讨论的几个情况外,sizeof(unique_ptr<T>)==sizeof(T*)。使用智能指针,而非原始指针,在内存空间上通常不会产生额外的开销。默认情况下,unique_ptr<T> 对象中唯一存储的状态就是一个 T*。

5.3.2 数组管理

unique_ptr的一个优点是它提供了处理数组的特化。考虑以下几点:

void f(int n) {
  // p 指向一个值为 3 的 int 对象
  std::unique_ptr<int> p{ new int{ 3 } };

  // q 指向一个包含 n 个 int 对象的数组,
  // 所有元素初始化为 0
  std::unique_ptr<int[]> q{ new int[n] {} };

  // 示例用法
  std::cout << *p << '\n'; // 输出 3
  for(int i = 0; i != n; ++i) {
    // operator[] supported for unique_ptr<T[]>
    q[i] = i + 1;
  }

  // ...
} // q 的析构函数会对其所指对象调用 delete[]
  // p 的析构函数会对其所指对象调用 delete

这有什么实际用途呢?完全取决于需求。例如:

如果选择后者,需要自行维护数组长度,unique_ptr不提供这个功能。当然,也可以将其封装成自己的抽象类型,比如下面这个fixed_size_array<T>:

#include <cstddef>
#include <memory>

template <class T>
class fixed_size_array {
  std::size_t nelems{};
  std::unique_ptr<T[]> elems {};
public:
  fixed_size_array() = default;
  auto size() const { return nelems; }
  bool empty() const { return size() == 0; }
  fixed_size_array(std::size_t n)
    : nelems { n }, elems{ new T[n] {} } {
  }
  T& operator[](int n) { return elems[n]; }
  const T& operator[](int n) const { return elems[n]; }
  // etc.
}

这是一个朴素的实现方案,将元素数量的信息与资源的隐式所有权结合在一起。不需要手动编写复制操作(除非需要特殊实现!),移动操作和析构函数也无需定义,这些操作都会自动提供合理的行为。

当类型T可简单构造时,这个实现相对高效,但在多数使用场景下(确实)不如vector<T>高效。原因在于:vector的内存管理策略远比该类实现优秀……这个话题稍后再详述。

值得一提的是,与标量类型的情况相同,unique_ptr<T[]>的sizeof值也等于sizeof(T*) —— 这个特性想必大家都会欣赏。

5.3.3 自定义删除器

各位可能会认为:“在我的代码库中,我们不使用 delete 释放对象,因为[在此填入您的理由],所以我不能用 unique_ptr”。确实存在许多无法直接通过 delete 操作符销毁对象的情况:

不论资源释放方式多么特殊,unique_ptr<T> 都支持通过自定义删除器来实现。这个删除器可以是函数对象或普通函数,会在智能指针析构时应用于内部存储的 T* 指针。

实际上,unique_ptr 的完整模板签名如下(展示其支持自定义删除器的设计):

template<class T, class D = std::default_delete<T>>
class unique_ptr {
  // ...
}

这里,default_delete<T>本身本质上是这样的:

template<class T> struct default_delete {
  constexpr default_delete() noexcept = default;
  // ...
  constexpr void operator()(T *p) const { delete p; }
}

由于参数 D 设有默认类型,通常编写代码时可以忽略这个参数。在 unique_ptr<T,D> 的签名中,参数 D 应该是无状态的 —— 不会存储在 unique_ptr 对象内部,而是在需要时实例化,并作为一个接收指针的函数使用,负责完成所指对象的最终清理工作。

假设有下面这个析构函数被声明为私有的类(这是一种常见技巧,用于强制要求对象只能通过动态分配创建,而不能以自动或静态变量的形式存在,这类对象无法隐式销毁):

#include <memory>
class requires_dynamic_alloc {
  ~requires_dynamic_alloc() = default; // 私有
  // ...
  friend struct cleaner;
};

// ...
struct cleaner {
  template <class T>
  void operator()(T *p) const { delete p; }
};

int main() {
  using namespace std;
  // requires_dynamic_alloc r0; // 不行
  //auto p0 = unique_ptr<requires_dynamic_alloc>{
  // new requires_dynamic_alloc
  //}; // 不行,因为默认删除器无法使用 delete
  auto p1 = unique_ptr<requires_dynamic_alloc, cleaner>{
    new requires_dynamic_alloc
  }; // 可以,因为会使用 cleaner::operator() 来释放所指对象
}

通过将清理函数对象声明为友元,requires_dynamic_alloc 类允许该清理器专门访问其 protected 和 private 成员(包括私有析构函数)。

假设可通过一个接口使用对象,该接口对使用端代码隐藏了以下信息:是否是指向资源的唯一所有者;是否与其他对象共享该资源。

进一步假设这种资源共享,是通过侵入式引用计数实现的(许多平台都采用这种方式),此时断开资源连接的方式就是调用其 release() 成员函数。该函数会:记录已断开的连接;如果当前对象是其最后一个使用者,则释放资源。

为简化使用端代码,代码库可提供了一个 release() 独立函数,当指针非空时会调用其成员函数 release()。

这种情况下仍可使用 unique_ptr,但语法稍有不同 —— 需要将函数指针作为构造参数传入(该指针会存储在内部)。因此,这种以函数指针作为删除器的 unique_ptr 特化,会导致轻微的内存开销增加:

#include <memory>

struct releasable {
  void release() {
    // 为了本示例的简洁而做了过度简化
    delete this;
  }
protected:
  ~releasable() = default;
};

class important_resource : public releasable {
  // ...
};

void release(releasable *p) {
  if(p) p->release();
}

int main() {
  using namespace std;
  auto p = unique_ptr<important_resource,
  void(*)(releasable*)>{
    new important_resource, release
  }; // 好的,将使用 release() 来删除所指向的对象
}

如果函数指针带来的内存开销(包括大小和对齐要求)不可接受 —— 正在开发资源受限的平台,或需要处理包含大量 unique_ptr 对象的容器(导致开销呈指数级增长) —— 这里有个解决方案:将删除器函数的运行时调用,转移到类型系统中:

#include <memory>

struct releasable {
  void release() {
    // 为了本示例的简洁而做了过度简化
    delete this;
  }
protected:
  ~releasable() = default;
};

class important_resource : public releasable {
  // ...
};

void release(releasable *p) {
  if(p) p->release();
}

int main() {
  using namespace std;
  auto p = unique_ptr<important_resource,
                      void(*)(releasable*)>{
    new important_resource, release
  }; // 好的,将使用 release() 来删除所指向的对象
  static_assert(sizeof(p) > sizeof(void*));

  auto q = unique_ptr<
    important_resource,
    decltype([](auto p) { release(p); })
  >{
    new important_resource
  };
  static_assert(sizeof(q) == sizeof(void*));
}

指针p的情况:使用函数指针作为删除器,需要存储函数地址。指针q的情况:用假设的lambda类型替代函数指针,实例化时将自动调用目标函数并传递指针参数。

这种方法简单高效,若使用得当还能节省内存空间!

5.3.4 make_unique

自 C++14 起,unique_ptr<T> 配套提供了一个工厂函数,能将参数完美转发给 T 的构造函数,分配并构造 T 对象,同时创建持有该对象的 unique_ptr<T>,最终返回结果对象。这个函数就是 std::make_unique<T>(args...),其简化实现大致如下:

template <class T, class ... Args>
std::unique_ptr<T> make_unique(Args &&... args) {
  return std::unique_ptr<T>{
    new T(std::forward<Args>(args)...);
  }
}

当然,该函数也有创建 T[] 数组的变体。有读者可能会好奇这个函数的意义 —— 事实上它最初并未随 unique_ptr 一起发布(unique_ptr 是 C++11 的类型)。但请看以下(人为设计的)示例:

template <class T>
class pair_with_alloc {
  T *p0, *p1;
public:
  pair_with_alloc(const T &val0, const T &val1)
  : p0{ new T(val0) }, p1{ new T(val1) } {
  }
  ~pair_with_alloc() {
    delete p1; delete p0;
  }
  // 复制和移动操作留给读者自行发挥
}

通过这个示例可以推测,某些情况下使用端代码更倾向于动态分配 T 对象(尽管实践中直接使用对象而非对象指针会让代码更简单)。根据 C++ 对象中子对象按声明顺序构造的规则,p0 会先于 p1 构造:

// ...
  T *p0, *p1; // p0 在 p1 之前声明
public:
  // 下面这些操作的顺序是:
  // - new T(val0) 会在 p0 的构造之前发生
  // - new T(val1) 会在 p1 的构造之前发生
  // - p0 的初始化会在 p1 的初始化之前完成
  pair_with_alloc(const T &val0, const T &val1)
  : p0{ new T(val0) }, p1{ new T(val1) } {
  }
// ...

然而,假设操作顺序是:

new T(val0) → 构造 p0

new T(val1) → 构造 p1

如果 new T(val1) 抛出异常(可能由于内存分配失败或 T 的构造函数失败),会发生什么?有读者可能以为 pair_with_alloc 的析构函数会清理资源,但事实并非如此 —— 只有当构造函数完成时,析构函数才会调用;否则,根本没有需要销毁的对象!

当然,这个问题有解决方案。最佳实践正是使用正在讨论的 unique_ptr<T> 替代原始指针 T*。让我们重写 pair_with_alloc:

#include <memory>

template <class T>
class pair_with_alloc {
  std::unique_ptr<T> p0, p1;
public:
  pair_with_alloc(const T &val0, const T &val1)
    : p0{ new T(val0) }, p1{ new T(val1) } {
  }
  // 析构函数隐式正确
  // 复制和移动操作会隐式生成并正常工作
}

这个版本中,如果操作顺序是:

new T(val0) → 构造 p0

new T(val1) → 构造 p1

当 new T(val1) 抛出异常时:pair_with_alloc 对象不会销毁(因为其构造尚未完成);但 p0 已构造,因此会自动销毁。

代码因此变得更简单、更安全!这与 make_unique<T>() 有什么关系?这里暗藏玄机。仔细看看构造函数中的操作顺序:

// ...
  std::unique_ptr<T> p0, p1; // p0 在 p1 之前声明
public:
  // 以下假设我们将这些操作标识为:
  // A: new T(val0)
  // B: p0 的构造
  // C: new T(val1)
  // D: p1 的构造
  // 我们知道:
  // - A 发生在 B 之前
  // - C 发生在 D 之前
  // - B 发生在 D 之前
  pair_with_alloc(const T &val0, const T &val1)
    : p0{ new T(val0) }, p1{ new T(val1) } {
  }
// ...

根据注释中的规则描述,操作顺序可能存在以下情况:

A→B→C→D(理想的顺序)

A→C→B→D(有风险的顺序)

C→A→B→D(最危险的顺序)

后两种情况下,两个new T(...)会先执行,然后才执行两个unique_ptr<T>的构造。此时若第二个new操作或T的构造函数抛出异常,仍会导致资源泄漏。

这正是make_unique<T>()的价值所在 —— 工厂函数:使用端代码永远不会处于“new操作悬空结果”的状态。只有两种确定状态:

#include <memory>

template <class T>
class pair_with_alloc {
  std::unique_ptr<T> p0, p1;
public:
  pair_with_alloc(const T &val0, const T &val1)
    : p0{ std::make_unique<T>(val0) },
      p1{ std::make_unique<T>(val1) } {
  }
  // 析构函数隐式正确
  // 复制和移动操作会隐式生成并正常工作
};

#include <string>
#include <random>
#include <iostream>

class risky {
  std::mt19937 prng{ std::random_device{}() };
  std::uniform_int_distribution<int> penny{ 0,1 };
public:
  risky() = default;
  risky(const risky &) {
    if(penny(prng)) throw 3; // 50% 的概率抛出异常
  }
  ~risky() {
    std::cout << "~risky()\n";
  }
};

int main() {
  // 即使抛出异常,下面的对象也不会发生内存泄漏
  if(std::string s0, s1; std::cin >> s0 >> s1)
    try {
      pair_with_alloc a{ s0, s1 };
      pair_with_alloc b{ risky{}, risky{} };
    } catch(...) {
      std::cerr << "Something was thrown...\n";
    }
}

make_unique<T>()本质上是一种安全机制,主要作用是避免使用端代码中出现无主资源。好处是能减少代码重复:

unique_ptr<some_type> p0 { new some_type{ args } };
auto p1 = unique_ptr<some_type> { new some_type{ args } };
auto p2 = make_unique<some_type>(args);

显然,p0和p1要求拼写两次指向类型的名称,而p2只写一次,所以p2的写法不仅更安全,也更简洁 —— 这正是现代C++推崇的代码风格。