每周技巧 #187:`std::unique_ptr` 必须被移动

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #187: std::unique_ptr Must Be Moved

原文最初作为 TotW #187 发布于 2020 年 11 月 5 日。

作者:Andy Soffer

更新于 2020 年 11 月 5 日。

快捷链接:abseil.io/tips/187

如果你在第一章说墙上挂着一个 std::unique_ptr,那么第二章或第三章它绝对必须被移动。如果它不会被移动,就不该挂在那里。~ 向 Anton Chekhov 致歉

std::unique_ptr 用于表达所有权转移。如果你从不把所有权从一个 std::unique_ptr 传给另一个,那么这个抽象很少是必要或合适的。

什么是 std::unique_ptr

std::unique_ptr 是一种指针:当 std::unique_ptr 自身被销毁时,它会自动销毁它所指向的对象。它的存在是为了把所有权(销毁资源的责任)作为类型系统的一部分表达出来,也是 C++11 更有价值的新增内容之一。1 不过,std::unique_ptr 经常被过度使用。一个好的试金石是:如果它从未被 std::move 到另一个 std::unique_ptr,或从另一个 std::unique_ptrstd::move 过来,它很可能不应该是 std::unique_ptr 如果我们不转移所有权,那么几乎总有比使用 std::unique_ptr 更好的方式表达意图。

std::unique_ptr 的成本

在不转移所有权时,有几个理由避免使用 std::unique_ptr

  • std::unique_ptr 表达可转移所有权;如果所有权并未转移,这个信息没有帮助。我们应当尽量使用最准确表达所需语义的类型。
  • std::unique_ptr 可以为空;如果空状态实际上没有使用,就会给读者增加额外认知负担。
  • std::unique_ptr<T> 管理堆分配的 T,这会带来性能影响:既有堆分配本身的成本,也因为数据分散在堆上,更不容易位于 CPU 缓存中。

常见反模式:避免使用 &

经常可以看到下面这样的例子:

1
2
3
4
5
int ComputeValue() {
  auto data = std::make_unique<Data>();
  ModifiesData(data.get());
  return data->GetValue();
}

在这个例子中,data 不需要是 std::unique_ptr,因为所有权从未转移。Data 对象的构造和销毁时机,与在栈上声明一个 Data 对象完全相同。因此,正如技巧 #123 中也讨论过的,更好的选择是:

1
2
3
4
5
int ComputeValue() {
  Data data;
  ModifiesData(&data);
  return data.GetValue();
}

常见反模式:延迟初始化

因为 std::unique_ptr 默认构造时为空,并且可以从 std::make_unique 赋入新值,所以常常被用作延迟初始化机制。在 GoogleTest 中有一种特别常见的模式:测试夹具可以在 SetUp 中初始化对象。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class MyTest : public testing::Test {
 public:
  void SetUp() override {
    thing_ = std::make_unique<Thing>(data_);
  }

 protected:
  Data data_;
  // 在 `SetUp()` 中初始化,所以我们把 `std::unique_ptr`
  // 用作延迟初始化机制。
  std::unique_ptr<Thing> thing_;
};

我们再次看到,thing_ 的所有权从未转移到别处,因此没有必要使用 std::unique_ptr。上面的例子本可以在 MyTest 的默认构造函数中完成所有初始化。关于 SetUp 与构造的区别,见 GoogleTest FAQ

1
2
3
4
5
6
7
8
class MyTest : public testing::Test {
 public:
  MyTest() : thing_(data_) {}

 private:
  Data data_;
  Thing thing_;
};

在这个例子中,data_ 像之前一样默认构造。之后,Thingdata_ 构造。记住,类的构造函数按字段声明顺序初始化字段,因此这种方式与之前以相同顺序初始化对象,但不使用 std::unique_ptr

如果延迟初始化确实重要且无法避免,可以考虑使用 std::optional 及其 emplace() 方法。技巧 #123 对延迟初始化有更深入讨论。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class MyTest : public testing::Test {
 public:
  MyTest() {
    Initialize(&data_);
    thing_.emplace(data_);
  }

 private:
  Data data_;
  std::optional<Thing> thing_;
};

注意事项

既然这是 C++,当然存在即使从未移动也适合使用 std::unique_ptr 的场景。不过这些情况并不常见,处理此类情况的代码应该带有注释说明其中微妙之处。下面是两个例子。

很大且很少使用的对象

如果某个对象只在有时需要,std::optional 是一个不错的默认选择。不过,std::optional 无论对象是否真的构造,都会预留空间。如果这部分空间很重要,那么持有 std::unique_ptr 并只在需要时分配它,可能是合理的。

遗留 API

许多遗留 API 返回指向所拥有数据的原始指针。这些 API 往往早于 std::unique_ptr 加入 C++ 标准库;新代码不应该复制这种模式。不过,即使得到的对象永远不会移动,也应该把这类遗留 API 调用包装在 std::unique_ptr 中,确保内存不会泄漏。

1
2
3
4
5
6
Widget *CreateLegacyWidget() { return new Widget; }

int func() {
  Widget *w = CreateLegacyWidget();
  return w->num_gadgets();
}  // 内存泄漏!

std::unique_ptr 包装对象可以解决这两个问题:

1
2
3
4
int func() {
  std::unique_ptr<Widget> w = absl::WrapUnique(CreateLegacyWidget());
  return w->num_gadgets();
}  // `w` 会被正确销毁。

每周技巧 #186:优先把函数放进未命名命名空间

上一节

每周技巧 #188:小心智能指针函数参数

下一节

  1. std::unique_ptr 名称中的 “unique” 被选来表示这样一个想法:不应有其他 std::unique_ptr 持有相同的非空值。也就是说,在程序执行的任意时刻,所有非空 std::unique_ptr 所持有的地址,在这些 std::unique_ptr 中都是唯一的。 ↩︎