本周小贴士#120:返回值是不可触碰的

Posted -飞鹤-

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了本周小贴士#120:返回值是不可触碰的相关的知识,希望对你有一定的参考价值。

作为TotW#120最初发于2012年8月16日
由Samuel Benzaquen, (sbenza@google.com)创作
假设你有如下代码片段,它依赖于RAII清理函数,似乎如同预期那样生效:

MyStatus DoSomething() 
  MyStatus status;
  auto log_on_error = RunWhenOutOfScope([&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 MyStatus();接着代码突然中止日志打印错误。

接下来发生了什么呢?

概述

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

返回变量在被拷贝或移动后,会被析构函数隐式访问(参见C++11标准[stmt.return]的第6.6.节3),这就是这种意外访问的发生方式,但是拷贝/移动可能被省略,这就是行为未定义的原因。

此贴士仅适用于在你返回非引用局部变量时,返回任何其他表达式都不会触发此问题。

问题

return语句有2种不同的优化可以修改我们的初始代码片段的行为:NRVO(参见TotW#11)和隐式移动。

前面的代码有效,因为正在发拷贝省略,且return语句实际上并没有做任何事情。变量状态已经在返回地址中构造,并且清理对象在返回语句之后看到了MyStatus对象的这个唯一实例。

在后面的代码中,拷贝省略无法应用,并且返回变量正被移动到返回值中。RunWhenOutOfScope()在移动操作完成后运行,接着它看到了一个来自MyStatus的移动对象。

请注意,前面的代码也不正确,因为它依赖于拷贝省略优化来确保正确性。我们鼓励你依靠拷贝省略来提高性能(参见TotW#24),但是不要依赖它的正确性。毕竟,拷贝省略是一种可选的优化,编译器选项或编译器的实现质量会影响它是否发生。

解决方案

不要在return语句之接触返回变量。注意局部变量的析构函数隐式的操作。

最简单的解决方案是将函数一分为二。一个完成所有处理,一个调用第一个并进行后续处理(即登录错误)。例如:

MyStatus DoSomething() 
  MyStatus status;
  status = DoA();
  if (!status.ok()) return status;
  status = DoB();
  if (!status.ok()) return status;
  status = DoC();
  if (!status.ok()) return status;
  return status;

MyStatus DoSomethingAndLog() 
  MyStatus status = DoSomething();
  if (!status.ok()) LOG(ERROR) << status;
  return status;

如果你只是阅读这些值,你也需要确保禁用优化。这将强制始终制作副本,并且后处理将不会看到移出的值。

MyStatus DoSomething() 
  MyStatus status_no_nrvo;
  // 'status' is a reference so NRVO and all associated optimizations
  // will be disabled.
  // The 'return status;' statements will always copy the object and Logger
  // will always see the correct value.
  MyStatus& status = status_no_nrvo;
  auto log_on_error = RunWhenOutOfScope([&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;

另一个示例

std::string EncodeVarInt(int i) 
  std::string out;
  StringOutputStream string_output(&out);
  CodedOutputStream coded_output(&string_output);
  coded_output.WriteVarint32(i);
  return out;

CodedOutputStream在析构函数中做了一些工作来修剪未使用的尾部字节。如果NRVO没有发生,此函数可以在字符串上留下垃圾字节。

请注意,在这种情况下,你不能强制NRVO发生,禁用它的技巧也无济于事。我们必须在return语句运行前修改返回值。

一个好的解决方案是添加一个块并将函数限制为仅在块完成后返回。像这样:

std::string EncodeVarInt(int i) 
  std::string out;
  
    StringOutputStream string_output(&out);
    CodedOutputStream coded_output(&string_output);
    coded_output.WriteVarint32(i);
  
  // At this point the streams are destroyed and they already flushed.
  // We can safely return 'out'.
  return out;

结论

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

你无法控制NRVO是否发生。编译器版本和选项可以背着你更改此设置。不要依赖它来追求正确性。

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

以上是关于本周小贴士#120:返回值是不可触碰的的主要内容,如果未能解决你的问题,请参考以下文章

本周小贴士#101:返回值,引用和生命周期

本周小贴士#108:避免std::bind

本周小贴士#130:命名空间命名

本周小贴士#93:使用absl::Span

本周小贴士#76:使用absl::status

本周小贴士#126: ‘make_unique‘是新的‘new‘