每周技巧 #120:返回值不可触碰

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #120: Return Values are Untouchable

原文最初作为 TotW #120 发布于 2012 年 8 月 16 日。

作者:Samuel Benzaquen

假设你有下面这段代码,它看起来按预期工作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
absl::Status DoSomething() {
  absl::Status status;
  absl::Cleanup log_on_error = [&status] {
    if (!status.ok()) LOG(ERROR) << status;
  };
  status = DoA();
  if (!status.ok()) return status;
  status = DoB();
  if (!status.ok()) return status;
  status = DoC();
  if (!status.ok()) return status;
  return status;
}

一次重构把最后一行从 return status; 改成 return absl::OkStatus();,然后代码突然不再记录错误了。

发生了什么?

总结

永远不要在 return 语句运行之后访问(读取或写入)返回变量。除非你非常小心地正确处理,否则行为是未指定的。

返回变量会在被复制或移动之后,由析构函数隐式访问([stmt.return]),这就是这种意外访问发生的方式;但复制/移动可能被消除,这也是行为未指定的原因。

本技巧只适用于你返回一个非引用局部变量的情况。返回任何其他表达式都不会触发这个问题。

问题

有两种不同的 return 语句优化可能修改这段代码的行为:具名返回值优化(NRVO)和隐式移动。

之前的代码能工作,是因为执行了 NRVO,return 语句实际上没有做任何工作。变量 status 已经构造在返回地址中,cleanup 对象在 return 语句之后看到的是这个唯一的 Status 对象实例。

之后的代码中,没有执行 NRVO,返回变量被移动到返回值中。cleanup 对象在移动操作完成后运行,看到的是一个被 move 过的 Status

注意,之前的代码也并不正确,因为它依赖 NRVO 来保证正确性。我们鼓励你依赖 NRVO 获得性能(见 TotW #24),但不要依赖它保证正确性。毕竟,NRVO 是一个可选优化,编译器选项或编译器实现质量都可能影响它是否发生。

解决方案

不要在 return 语句之后触碰返回变量。要小心局部变量的析构函数隐式这样做。

最简单的解决方案是把函数拆成两个:一个做所有处理,另一个调用第一个并执行后处理(也就是错误时记录日志)。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
absl::Status DoSomething() {
  absl::Status status;
  status = DoA();
  if (!status.ok()) return status;
  status = DoB();
  if (!status.ok()) return status;
  status = DoC();
  if (!status.ok()) return status;
  return status;
}

absl::Status DoSomethingAndLog() {
  absl::Status status = DoSomething();
  if (!status.ok()) LOG(ERROR) << status;
  return status;
}

如果你只是读取这个值,也可以确保禁用这些优化。这样会强制始终创建副本,后处理不会看到被 move 过的值。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
absl::Status DoSomething() {
  absl::Status status_no_nrvo;
  // 'status' 是引用,因此 NRVO 和所有相关优化
  // 都会被禁用。
  // 'return status;' 语句总会复制对象,Logger
  // 也总会看到正确的值。
  absl::Status& status = status_no_nrvo;
  absl::Cleanup log_on_error = [&status] {
    if (!status.ok()) LOG(ERROR) << status;
  };
  status = DoA();
  if (!status.ok()) return status;
  status = DoB();
  if (!status.ok()) return status;
  status = DoC();
  if (!status.ok()) return status;
  return status;
}

另一个真实示例

1
2
3
4
5
6
7
std::string EncodeVarInt(int i) {
  std::string out;
  proto2::io::StringOutputStream string_output(&out);
  proto2::io::CodedOutputStream coded_output(&string_output);
  coded_output.WriteVarint32(i);
  return out;
}

CodedOutputStream 会在析构函数中做一些工作,用来裁剪未使用的尾部字节。如果没有发生 NRVO,这个函数可能会在字符串中留下垃圾字节。

注意,在这种情况下你无法强制 NRVO 发生,而禁用它的技巧也帮不上忙。我们必须在 return 语句运行之前修改返回值。

一个好的解决方案是添加一个代码块,并限制函数只能在该代码块结束后返回。像这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
std::string EncodeVarInt(int i) {
  std::string out;
  {
    proto2::io::StringOutputStream string_output(&out);
    proto2::io::CodedOutputStream coded_output(&string_output);
    coded_output.WriteVarint32(i);
  }
  // 此时 streams 已经被销毁,并且已经 flush。
  // 我们可以安全返回 'out'。
  return out;
}

结论

不要保留对正在被返回变量的引用。

你无法控制 NRVO 是否发生。编译器版本和选项可能在你脚下改变这一点。不要依赖它保证正确性。

你无法控制返回局部变量是否会触发隐式移动。你使用的类型未来可能会更新为支持移动操作。此外,未来语言标准会在更多情况下应用隐式移动,所以你不能因为它现在没有发生,就假设它未来也不会发生。

每周技巧 #119:using 声明和命名空间别名

上一节

每周技巧 #122:测试夹具、清晰性和数据流

下一节