【问题标题】:Why exception specifications cannot be useful?为什么异常规范没有用?
【发布时间】:2012-07-05 22:29:43
【问题描述】:

我已经阅读了很多关于(不)在函数签名中使用 throw(X) 的论点,我认为它在 ISO C++ 中指定的方式(并在当前编译器中实现)是相当没用的。但是为什么编译器不能在编译时简单地enforce异常正确性呢?

如果我在其签名中编写包含throw(A,B,C) 的函数/方法定义,编译器在确定给定函数的实现是否异常正确时应该不会有很多问题。这意味着函数体有

  • 除了throw A; throw B; throw C;之外没有throw
  • 没有比throw (A,B,C) 更少限制的函数/方法调用;

,至少在try{}catch() 之外捕获其他抛出的类型。如果在不满足这些要求的情况下编译器引发错误,那么所有函数都应该是“安全的”,并且不需要像 unexpected() 这样的运行时函数。所有这些都将在编译时得到保证。

void fooA() throw (A){
}

void fooAB() throw (A,B){
}

void fooABC() throw (A,B,C){
}


void bar() throw (A){

    throw A();   // ok
    throw B();   // Compiler error

    fooA();      // ok
    fooAB();     // compiler error
    fooABC();    // compiler error

    try{
       throw A();   // ok
       throw B();   // ok
       throw C();   // Compiler error

       fooA();   // ok
       fooAB();  // ok
       fooABC(); // compiler error
    } catch (B){}
}

这将要求所有非 C++ 领域代码默认指定 throw()extern "C" 应默认假定它),或者如果存在一些异常互操作性,则应使用适当的标头(至少对于 C++) throw-也指定了。不这样做可以与在不同的编译单元中使用具有不同函数/方法返回类型的头文件进行比较。虽然它没有产生警告或错误,但它显然是错误的 - 由于抛出的异常是签名的一部分,它们也应该匹配。

如果我们强制执行这样的限制,它会产生三个效果:

  • 它将删除所有那些隐含的 try{}catch 块,否则运行时检查需要这些块,从而提高异常处理性能。
  • “异常使我们的库太大,因此我们将其关闭”的论点;会消失,因为大多数附加代码都存在于每个函数调用中那些不必要的隐式 throw/catch 指令中。如果代码是正确的throw-specified,大部分代码不会被编译器添加。
  • 这会让编程界的大多数人大发雷霆,因为似乎没有人喜欢 例外。现在,由于这些实际上是可用的,我们需要学习如何使用它们。

如果我们对旧代码使用一些兼容性编译器标志,它不会破坏任何东西,但是由于新的异常代码会更快,所以不使用它编写新代码是一个很好的动机。

总结一下我的问题:为什么 ISO C++ 不需要这种强制措施?有什么强有力的理由让它不存在吗?我一直认为异常只是另一个函数的返回值,但它是自动管理的,所以你可以避免编写类似的函数

std::pair<int, bool> str2int(std::string s);
int str2int(std::string s, bool* ok);

加上额外的变量自动销毁和通过堆栈上的多个函数传播,因此您不需要像这样的代码

int doThis(){

    int err=0;

    [...]

    if ((err = doThat())){
        return err;
    }

    [...]

}

;如果你可以要求return的函数只有正确的类型,你为什么不能要求throw呢?

为什么异常说明符不能更好?为什么它们不像我从一开始就描述的那样制作?

PS 我知道例外和模板可能存在一些问题 - 根据这个问题的答案,也许我会问另一个问题 - 现在让我们忘记模板。

编辑(回应@NicolBolas):

编译器可以对异常类 X 进行哪些优化,而对 Y 却无法做到这一点?

比较:

void fooA() throw (A){
}

void fooAB() throw (A,B){
}

void fooABC() throw (A,B,C){
}


void bar() throw (){

    try{
       fooA();
         // if (exception == A) goto A_catch
       fooAB();
         // if (exception == A) goto A_catch
         // if (exception == B) goto B_catch
       fooABC();
         // if (exception == A) goto A_catch
         // if (exception == B) goto B_catch
         // if (exception == C) goto C_catch
    }
    catch (A){  // :A_catch
      [...]
    }
    catch (B){  // :B_catch
      [...]
    }
    catch (C){  // :C_catch
      [...]
    }
}

和:

void fooA(){
}

void fooAB(){
}

void fooABC(){
}


void bar(){

    try{
       fooA();
         // if (exception == A) goto A_catch;
         // if (exception == B) goto B_catch;
         // if (exception == C) goto C_catch;
         // if (exception == other) return exception;
       fooAB();
         // if (exception == A) goto A_catch;
         // if (exception == B) goto B_catch;
         // if (exception == C) goto C_catch;
         // if (exception == other) return exception;
       fooABC();
         // if (exception == A) goto A_catch;
         // if (exception == B) goto B_catch;
         // if (exception == C) goto C_catch;
         // if (exception == other) return exception;
    }
    catch (A){  // :A_catch
      [...]
    }
    catch (B){  // :B_catch
      [...]
    }
    catch (C){  // :C_catch
      [...]
    }
}

在这里,我包含了一些编译器不会生成汇编级别的伪代码。如您所见,知道您可以获得哪些异常可以减少代码量。如果我们在这里有一些额外的变量要销毁,额外的代码会更长。

【问题讨论】:

  • 我们很高兴 C++ 不是 Java 并且没有引入检查异常。
  • 只有愚蠢的游戏开发者会因为性能而关闭核心语言功能。如果没有广泛的语言支持,受检查的异常很烦人(不,Java 级别还不够),而且 C++ 已经足够复杂了。
  • 是的,不惹恼程序员应该是语言设计的标准。
  • @j_kubik 你认为 Java 中的人是做什么的? throws Exception 通常是函数签名的一部分,因为这个特性非常烦人。或者有时他们只是try { ... } catch(Exception) { log_and_forget(); },这更糟。 这实际上是某些 IDE 默认生成的,作为编译器错误的修复程序。真是太糟糕了。程序员在遇到检查异常时会做这些事情,因为至少在 Java 中指定时(这与您在此处的描述非常相似)它们会损害生产力,并且 这些“修复”只会使问题变得更糟 i>.
  • 仅当您假设所有未手动将所有异常从 throw 站点传播到 catch 站点的代码都不是好代码时。如果你这样做,我不可能让我的观点对你有说服力。

标签: c++ templates exception exception-specification


【解决方案1】:

编译器验证的异常作为函数签名的一部分有两个(理论上的)优点:编译器优化和编译时错误检查。

在编译器方面,抛出异常类 X 的函数和类 Y 之间有什么区别?最终……什么都没有。编译器可以对异常类 X 进行哪些优化,而对 Y 却无法做到这一点?除非std::exception 是特殊的(并且X 是从它派生的,而Y 不是),否则它对编译器有什么影响?

最终,编译器在优化方面唯一关心的是函数是否会抛出任何异常。这就是 C++11 标准委员会放弃 throw(...) 转而支持 noexcept 的原因,因为 noexcept 声明该函数不会抛出任何东西。

至于编译时错误检查,Java 清楚地展示了它的工作原理。你正在编写一个函数,foo。你的设计会抛出XY。其他代码使用foo,它们抛出foo 抛出的任何东西。但是异常规范并没有说“无论foo throws”。它必须专门列出XY

现在您返回并更改foo,使其不再抛出X,但现在它抛出Z。突然,整个项目停止编译。您现在必须转到每个抛出 foo 抛出的任何函数的函数,以更改其异常规范以匹配 foo

最终,程序员只是举手并说它会抛出任何异常。当您放弃这样的功能时,实际上是在承认该功能弊大于利。

并不是说它们不能有用。只是它们的实际使用表明它们通常没有用。所以没有意义。

另外,请记住,C++ 的规范声明没有规范意味着将抛出任何东西,而不是什么都不会(就像在 Java 中一样)。使用该语言的最简单方法就是这样:不检查。所以会有很多人不想使用它。

许多人不想打扰的功能有什么好处,即使是那些想打扰的人通常也会因此而苦恼?

【讨论】:

  • "现在你返回并更改 foo,使它不再抛出 X,但现在它抛出 Z。突然,整个项目停止编译。"如果您更改参数或重新排序它们会怎样?或者改变结果类型?函数签名的变化与抛出规范的变化一样多。
  • @j_kubik 它没有那么普遍,因为更改函数的参数只会影响直接调用者。
  • @R.MartinhoFernandes 这可能是真的,但请考虑这种情况:void DisplayAlert(std::string text); 显示来自某些堆栈深度函数的警报。现在这个应用程序的一些用户想要它在某些特定的屏幕部分,所以程序员将它更改为void DisplayAlert(std::string text, int x, int y);。该更改仅影响直接调用者,但有关用户偏好的信息在堆栈中保留了许多级别。现在您必须修改所有这些方法以传递此信息。在两种情况下,您都在更改通过调用堆栈传播的数据:异常规范更改和以上更改。
  • 如果堆栈上层的函数不想知道或关心新的异常,那么直接调用者只需try{}catch
  • 如果我在更改 throws-spec 时可以简单地更改两件事会怎样:投掷者和捕手?如果中间的每一个函数根本不参与这个过程,为什么还要改变它呢?为什么我必须编辑一半的代码库,因为需要修复两行代码?
【解决方案2】:

也就是说函数体有

除了投掷 A 之外没有投掷;扔B;扔 C;;
没有比 throw (A,B,C) 限制性更小的 throw 签名的函数/方法调用;

不要忘记,代码可以在不同的时间在不同的机器上编译,并且只能在运行时通过动态库链接在一起。编译器可能具有被调用函数签名的本地 版本,但它可能与运行时实际使用的版本不匹配。 (我认为如果异常不完全匹配,可以修改链接器以禁止链接,但这可能会带来比它解决的更多的烦恼。)

【讨论】:

  • 嗯,函数签名不匹配已经是一个危险源,所以这个问题可以忽略。我认为这里的真正原因是没有人关心强制异常规范。
  • @MatteoItalia 我也认为 ;)
  • @MatteoItalia 异常规范一直在 C++ 中强制执行。
【解决方案3】:

在某些情况下,抛出规范可能很有用,尤其是在嵌入式系统上,其中的替代方法是完全禁止异常。除此之外,如果唯一允许抛出的函数是那些明确指定的函数,那么在调用不能抛出的函数时可以消除异常处理开销。请注意,即使在使用“元数据”进行异常处理的系统中,异常处理程序必须能够理解堆栈帧这一事实排除了原本可能实现的优化。

抛出规范可能有用的另一种情况是,如果编译器允许catch 语句指定它们应该只捕获没有通过任何非“预期”层冒泡的异常。 C++ 中异常处理概念和借用它的语言的一个主要弱点是,对于由此记录的条件,没有很好的方法来区分发生在被调用例程中的异常与发生在嵌套子例程中的异常,例如直接调用例程的原因没想到。如果catch 语句可以作用于前者而不捕获后者,那将会很有帮助。

【讨论】:

  • “C++ 中的异常处理概念和借用它的语言的一个主要弱点是,对于由此记录的条件,没有很好的方法来区分发生在被调用例程中的异常与由于直接调用的例程没有预料到的原因,在嵌套子例程中发生的异常。如果 catch 语句可以在不捕获后者的情况下作用于前者,那将很有帮助。我一直认为这种异常透明是一件好事。
  • "异常处理程序必须能够理解堆栈帧这一事实排除了原本可能的优化。" 显然,它并不能绝对阻止任何优化,通过定义。异常处理代码只会破坏本应被破坏的本地对象,因此首先有足够的信息来做到这一点。但是,这些信息可能更难找到:精细优化和简单的堆栈展开代码不兼容。除处理代码外的任意优化都与任何优化兼容。这是一个微不足道的属性。
  • @curiousguy:对于叶函数来说确实如此,但对于被调用函数可能抛出调用者不知道的异常的更一般的情况则不然。
  • @supercat 不,它本质上对任何事物都是正确的;请给出具体的计数器。
  • @curiousguy:另外,一个可能被函数抛出的可能性排除的优化示例是通过newmalloc 分配存储空间的代码,然后调用另一个函数,并且然后通过deletefree 释放存储。如果嵌套函数不能抛出,则可以在堆栈上分配存储空间,因为它保证在控制离开外部函数之前被释放。然而,如果嵌套函数要抛出,任何指向该存储的指针都应该保持有效——如果它被分配在堆栈上,这是不会发生的。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2020-11-09
  • 1970-01-01
  • 2014-11-10
  • 2011-12-12
  • 2013-08-30
  • 2020-09-16
  • 2017-06-18
相关资源
最近更新 更多