每周技巧 #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:测试夹具、清晰性和数据流
下一节