每周技巧 #116:在参数上保留引用

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #116: Keeping References on Arguments

原文最初作为 TotW #116 发布于 2016 年 5 月 26 日。

作者:Alex Pilkiewicz

更新于 2020 年 6 月 1 日。

“从绘画到图像,从图像到文字,从文字到声音,一种想象中的指针指示、展示、固定、定位、强加一套引用系统,并试图稳定一个唯一空间。”— Michel Foucault, This is Not a Pipe

const 引用与指向 const 的指针

作为函数参数使用时,const 引用相较于指向 const 的指针有几个优点:它们不能为 null,而且很清楚函数没有取得对象所有权。但它们也有一些其他差异,有时会带来问题:它们更隐式(也就是说,调用点没有任何东西显示我们正在取得引用),并且它们可以绑定到临时对象。

类中的悬垂引用风险

以下面这个类为例:

1
2
3
4
5
6
7
8
class Foo {
 public:
  explicit Foo(const std::string& content) : content_(content) {}
  const std::string& content() const { return content_; }

 private:
  const std::string& content_;
};

它看起来很合理。但如果我们用字符串字面量构造一个 Foo 会怎样?

1
2
3
4
void Func() {
  Foo foo("something");
  LOG(INFO) << foo.content();  // BOOM!
}

创建 foo 时,content_ 成员会绑定到一个临时 std::string 对象。这个临时对象由字面量创建,并传给构造函数。临时字符串会在它被创建的那一行结束时离开作用域。现在 foo.content_ 是对一个已经不存在对象的引用。访问它是未定义行为,什么都可能发生:从测试里看起来正常,到生产中严重出错。

一个解决方案:使用指针

在我们的例子中,最简单的解决方案可能是按值传递并存储字符串。但假设我们确实需要引用原始参数,例如它不是字符串,而是某种更有意思的类型。解决方案是按指针传递参数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Foo {
 public:
  // 不要忘记这条注释:
  // 不取得 content 的所有权;content 必须指向一个有效字符串,
  // 且该字符串必须比本对象活得更久。
  explicit Foo(const std::string* content) : content_(content) {}
  const std::string& content() const { return *content_; }

 private:
  const std::string* const content_;  // 不拥有,不能为 null
};

现在下面的代码会直接无法编译:

1
2
3
4
5
std::string GetString();
void Func() {
  Foo foo1(&GetString());  // 错误:获取 std::string 类型临时对象的地址
  Foo foo2(&"something");  // 错误:没有匹配的 Foo 初始化构造函数
}

而在调用点也会很清楚,这个对象可能保留参数地址:

1
2
3
4
void Func2() {
  std::string content = GetString();
  Foo foo(&content);
}

再进一步,少一条注释:存储引用

你可能注意到,我们说了两次指针不能为 null 且不拥有对象:一次在构造函数文档里,一次在实例变量注释里。这有必要吗?考虑下面这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Baz {
 public:
  // 不取得任何所有权,所有指针都必须指向有效对象,
  // 且这些对象必须比被构造对象活得更久。
  explicit Baz(const Arg1* arg1, Arg2* arg2) : arg1_(*arg1), arg2_(*arg2) {}

 private:
  // 现在很清楚:我们不拥有对象,而且引用不能为空。
  const Arg1& arg1_;
  Arg2& arg2_;  // 是的,非 const 引用符合风格要求!
};

引用类型成员的一个缺点是你不能重新赋值,这意味着你的类不会有拷贝赋值运算符(拷贝构造函数仍然可以)。但为了遵守 rule of 3,显式删除它可能是合理的。如果你的类应该可赋值,你就需要非 const 指针,仍然可以指向 const 对象。技巧 #177 会更详细讨论这一点。

如果你想纵深防御,认为某个调用方可能意外传入 null 指针,可以使用 *ABSL_DIE_IF_NULL(arg1) 来触发崩溃。注意,直接解引用 null 指针并不像通常认为的那样保证崩溃;它是未定义行为,不应该依赖。这里可能发生的是:由于引用实现为指针,它只是被复制,真正访问该字段时才会在之后崩溃。

结论

如果参数会被复制,或者只在构造函数中使用、不会在构造出的对象中保留引用,那么把参数按 const 引用传给构造函数仍然没问题。在其他情况下,请考虑按指针传参(指向 const 或非 const 都可以)。还要记住,如果你实际上正在转移对象所有权,就应该用 std::unique_ptr 传递。

最后,这里讨论的内容并不限于构造函数:任何以某种方式保留某个参数别名的函数,无论是把指针放进缓存,还是把参数绑定到分离函数中,都应该按指针接收该参数。

相关阅读

每周技巧 #112:emplace 与 push_back

上一节

每周技巧 #117:拷贝消除和按值传递

下一节