为什么应该保守地使用例外?
可能重复:
为什么异常处理不好?
我经常看到/听到有人说异常应该很少使用,但永远不能解释为什么。 虽然这可能是正确的,但基本原理通常是一种滑稽的说法:“这被称为例外,这对我来说似乎是一种应该永远不会被可敬的程序员/工程师接受的解释。
有一系列问题可以使用例外来解决。 为什么将它们用于控制流是不明智的? 在如何使用它们方面非常保守,背后的哲学是什么? 语义? 性能? 复杂? 美学? 惯例?
我之前看到过一些关于绩效的分析,但是它的水平与某些系统相关,与其他系统无关。
再次,我不一定不同意他们应该在特殊情况下得到挽救,但我想知道共识的基本原理是什么(如果存在这样的事情)。
摩擦的主要点是语义。 许多开发人员滥用异常并在每个机会上抛出异常。 这个想法是在有些特殊情况下使用异常。 例如,错误的用户输入不会被视为异常,因为您预期这会发生并为此做好准备。 但是,如果您尝试创建文件并且磁盘空间不足,那么是的,这是一个明确的例外。
另外一个问题是例外情况经常被抛出和吞噬。 开发人员使用这种技术来简单地“沉默”程序,并尽可能长时间地运行,直到完全崩溃。 这是非常错误的。 如果你不处理异常,如果你没有通过释放一些资源来适当地作出反应,如果你没有记录异常发生或者至少不通知用户,那么你并没有为他们的意思使用异常。
直接回答你的问题。 例外情况很少使用,因为例外情况很少,例外情况很贵。
很少见,因为你不希望你的程序在每次按下按钮或每个格式不正确的用户输入时崩溃。 比方说,数据库可能突然无法访问,磁盘空间可能不足,您依赖的某些第三方服务处于脱机状态,这一切都可能发生,但很少会出现这种情况。
昂贵,因为抛出异常会中断正常的程序流程。 运行时将展开堆栈,直到找到可处理异常的适当异常处理程序。 它还会一直收集呼叫信息以传递给处理程序将收到的异常对象。 这都有成本。
这并不是说使用例外(微笑)不会有例外。 有时如果抛出异常而不是通过多层传递返回码,它可以简化代码结构。 作为一个简单的规则,如果你期望经常调用某种方法并在一半时间内发现一些“特殊”情况,那么最好找到另一种解决方案。 但是,如果您期望大多数时候正常的操作流程,而这种“特殊”情况只能在极少数情况下出现,那么抛出异常就可以了。
@Comments:如果这可以让你的代码更简单,更容易,异常可以用在一些不那么特殊的情况下。 这个选项是开放的,但我认为它在实践中相当罕见。
为什么将它们用于控制流是不明智的?
因为例外会中断正常的“控制流”。 您引发异常并放弃程序的正常执行,可能会使对象处于不一致状态,并且某些打开的资源不一致。 当然,C#具有使用语句,它将确保即使从使用主体抛出异常也会丢弃该对象。 但让我们从语言中抽象出当下。 假设框架不会为你处理对象。 你手动做。 你有一些如何请求和释放资源和内存的系统。 您在全系统范围内达成协议,负责在什么情况下释放对象和资源。 你有规则如何处理外部库。 如果程序遵循正常的操作流程,它会很好用。 但是在执行过程中突然出现异常。 有一半的资源没有兑现。 一半还没有被要求。 如果该操作现在是交易性的,那么它就会被破坏。 您的资源处理规则将不起作用,因为那些负责释放资源的代码部分不会执行。 如果任何人想要使用这些资源,他们可能会发现它们处于不一致的状态并崩溃,因为它们无法预测这种特殊情况。
说,你想要一个方法M()调用方法N()做一些工作,并安排一些资源,然后返回给M(),它将使用它,然后处置它。 精细。 现在N()中出现了一些问题,并且它引发了一个你在M()中没有预料到的异常,所以异常会冒泡到顶部,直到它被C()所捕获,这将不知道内部发生了什么在N()中是否以及如何释放一些资源。
通过抛出异常,您创建了一种方法将您的程序带入许多难以预测,理解和处理的新的不可预测的中间状态。 它有点类似于使用GOTO。 设计一个可以从一个位置随机跳转到另一个位置的程序是非常困难的。 它也很难维护和调试它。 当程序变得越来越复杂时,你就会忽略什么时候发生什么以及发生什么事情来修复它。
虽然“在异常情况下抛出异常”是glib的答案,但实际上你可以定义这些情况是什么: 当前提条件满足时,但后置条件不能得到满足 。 这可以让您在不牺牲错误处理的情况下编写更严格,更紧密且更有用的后置条件; 否则,无一例外地,您必须更改后置条件以允许每种可能的错误状态。
构造函数
对于每个可能用C ++编写的类,每个构造函数都有很少的说法,但有一些东西。 其中最主要的是构造对象(即构造函数返回的构造对象)将被破坏。 你不能修改这个后置条件,因为语言假定它是真的,并且会自动调用析构函数。 (从技术上讲,你可以接受未定义行为的可能性,对于这种行为,语言对任何事情都不作任何保证,但这可能在其他地方更好。
当构造函数不能成功时抛出异常的唯一方法是修改该类的基本定义(“类不变量”)以允许有效的“null”或僵尸状态,从而允许构造函数通过构建僵尸来“成功” 。
僵尸的例子
僵尸修改的一个例子是std :: ifstream,在使用它之前你必须经常检查它的状态。 因为std :: string没有,所以你总是保证你可以在施工后立即使用它。 想象一下,如果你必须编写这样的代码,并且如果你忘记检查僵尸状态,你可能会默默得到不正确的结果或损坏程序的其他部分:
string s = "abc";
if (s.memory_allocation_succeeded()) {
do_something_with(s); // etc.
}
即使命名该方法,也是一个很好的例子,说明如何修改类的不变量和接口以适应情境字符串既不能预测也不能自己处理。
验证输入示例
我们来举个常见的例子:验证用户输入。 仅仅因为我们想要允许失败的输入并不意味着解析函数需要在其后置条件中包含它。 这意味着我们的处理程序需要检查解析器是否失败。
// boost::lexical_cast<int>() is the parsing function here
void show_square() {
using namespace std;
assert(cin); // precondition for show_square()
cout << "Enter a number: ";
string line;
if (!getline(cin, line)) { // EOF on cin
// error handling omitted, that EOF will not be reached is considered
// part of the precondition for this function for the sake of example
//
// note: the below Python version throws an EOFError from raw_input
// in this case, and handling this situation is the only difference
// between the two
}
int n;
try {
n = boost::lexical_cast<int>(line);
// lexical_cast returns an int
// if line == "abc", it obviously cannot meet that postcondition
}
catch (boost::bad_lexical_cast&) {
cout << "I can't do that, Dave.n";
return;
}
cout << n * n << 'n';
}
不幸的是,这显示了两个C ++范围如何要求你打破RAII / SBRM的例子。 Python中的一个例子没有这个问题,并且显示了我希望C ++的东西 - try-else:
# int() is the parsing "function" here
def show_square():
line = raw_input("Enter a number: ") # same precondition as above
# however, here raw_input will throw an exception instead of us
# using assert
try:
n = int(line)
except ValueError:
print "I can't do that, Dave."
else:
print n * n
前提条件
先决条件并不一定要检查 - 违反一个总是表示逻辑失败,并且它们是调用者的责任 - 但是如果您确实检查它们,则抛出异常是适当的。 (在某些情况下,返回垃圾或使程序崩溃更为合适;但在其他情况下,这些操作可能是非常错误的。如何最好地处理未定义的行为是另一个话题。)
特别是,对比stdlib异常层次结构的std :: logic_error和std :: runtime_error分支。 前者通常用于违反先决条件,而后者更适合违反后置条件。
内核调用(或其他系统API调用)来管理内核(系统)信号接口
goto
语句的许多问题都适用于异常。 他们经常在多个例程和源文件中跳过潜在的大量代码。 阅读中间源代码并不总是显而易见的。 (这是在Java中。) 跳过的代码可能写入或可能没有写入,但有可能出现异常退出。 如果最初是这样写的,那么可能就没有考虑到这一点。 认为:内存泄漏,文件描述符泄漏,套接字泄漏,谁知道?
维护处理异常跳转的代码很难。