C++异常抛出/捕获优化

6

如果你有一些像这样的 C++ 代码:

int f()
{
  try {
    if( do_it() != success ) {
      throw do_it_failure();
    }
  } catch( const std::exception &e ) {
    show_error( e.what() );
  }
}

C++编译器应该能够将throw和catch优化为几乎简单的goto。

然而,从我的经验来看,通过查看反汇编和逐步执行代码,编译器总是跳转到非常混乱的异常处理库中。

他们为什么要这样做?是否有某些语言要求阻止了优化?如果是这样:

int f()
{
  try { throw std::runtime_error("Boo!"); }
  catch ( const std::exception &e ) { std::cout << e.what() << std::endl; }
}

为什么编译器不直接将其重写为:
int f()
{
  std::cout << "Boo!" << std::endl;
}

6
可能是因为在C/C++中,异常并不是为了流程控制而设计的,而是为了真正的异常情况而存在,因此从实现的角度来看,异常被视为有些神圣不可侵犯。 - Amber
在您的最后一个案例中,您假设std::runtime_error()具有没有副作用的构造函数和复制构造函数。尽管这是正确的,但编译器可能不知道这一点。 - Martin York
@Martin:至少复制构造函数可以被编译器自由地优化掉。你不能依赖于复制构造函数的调用。 - sbi
@sbi:非常正确。但是另一方面,你也不能假设他们会是这样。 - Martin York
3个回答

6
我认为已接受的答案如果不是错误的话,就相当缺乏信息,所以即使过了这么多年,我仍然觉得有必要提供一个适当的答案。推测编译器实现者选择不花费精力在任何特定功能上的原因只是...推测。异常只在异常情况下抛出的事实通常不会被视为不优化此类代码的原因。相反,尽管抛出代码没有优化非抛出代码,但异常抛出和处理基础架构仍然被非常小心地优化。此外,那段代码可能感觉很牵强,不值得考虑,但这并不是真的:它可能来自更复杂的代码的内联和优化,将其优化可能导致更简单的代码,从而允许其他优化传递触发,或者包含函数进一步内联。像这样的优化传递,当正确且有效地实现时,总是值得考虑的,无论原始代码看起来多么牵强。否则,即使是像死代码消除这样的基本传递也会被避免,因为“首先不应编写死代码”。这显然不是这种情况。
因此,我不同意被接受的答案。异常应该仅在特殊情况下抛出,这并不是代码未被优化的原因。
原因纯粹是技术性的,并在clang开发邮件列表中解释了:http://lists.llvm.org/pipermail/cfe-dev/2015-March/042035.html 简而言之,语言允许在catch块内调用的代码在任何时候“没有看到”异常对象的情况下重新抛出异常:
void g() { throw; }

因此,请考虑OP代码:
int f()
{
  try { throw std::runtime_error("Boo!"); }
  catch ( const std::exception &e ) { std::cout << e.what() << std::endl; }
}

对于编译器而言,e.what()或两个operator<<的调用可能会重新抛出异常,因此优化掉异常处理代码将破坏程序的语义。确保不会发生这种情况需要“整个程序知识”,如上面的电子邮件中所述。甚至更简单的情况也可以进行优化,例如:
int func() {
  try {
    throw 42;
  }catch(int x) {
    return x;
  }
}

上述代码可以转换为return 42。没有技术上的障碍。

然而,大多数常见编译器不会这样做(godbolt)。从上面链接的电子邮件中,我们可以了解到,Clang开发人员(对于其他编译器,我们无法说些什么)认为这种优化并不值得,可能是因为它只适用于不进行函数调用的catch块。

无论如何,该消息并未说明他们是否会接受补丁来执行此操作。


现在,由于clang已经实现了LTO(链接时优化),使得编译器可以将整个程序作为一个整体来考虑而不仅是作为单个目标文件,因此我认为完全有可能在clang中进行这种优化,并将同一程序/库中发生的每个try-catch优化为goto - JiaHao Xu
@JiaHaoXu:LTO无法查看共享库中预编译函数的内部,例如libc.solibstdc++.soe.what()可能只是一个可见的模板,但是大多数实现中的cout::operator<<最终涉及对非内联函数的潜在调用。(至少可能涉及对低级write系统调用的调用,尽管在使用libstdc++或libc++的Clang中,单独编译的库代码还提供了一些更高级的功能。)除非优化器无法看到的函数都是noexcept,否则这似乎是一个无法解决的问题。 - undefined
没错。事实上,即使是C链接的函数也可能会抛出异常,因为C++异常会通过C ABI传递。所以编译器真的无法知道。 - undefined

5

由于 do_it() 可能会抛出不同的异常,在你抛出 do_it_failure(); 前需要注意。

至于你的第二个例子,编译器可以做到,但它必须被视为一个特殊情况,那么为什么要费力去处理这种病态情况呢?


1
该函数可能会抛出不同的异常。但编译器知道至少有一条执行路径会抛出并立即捕获异常,因此对于该路径,它可以直接跳转到catch块。如果需要的话。 - Zan Lynx

5

为什么要这样做?

C++异常是用于处理特殊情况的,而在特殊情况下性能并不重要。设计C++异常时考虑到了这一点,确保编译器供应商可以在没有抛出异常的常见情况下提供接近最佳的性能,代价是在抛出异常的奇怪情况下性能比可能更差。

从一开始就鼓励用户只在特殊情况下使用异常,并鼓励实现者优化无异常情况(必须在某个地方存储析构函数地址以便在异常出现时调用析构函数),代价是牺牲了异常情况的性能。
虽然实现者当然可以花费资源来优化奇怪的异常情况,但大多数用户不会喜欢那样做,因为总有更重要的事情需要改进。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接