我经常看到/听到人们说异常应该很少使用,但永远不解释原因.虽然这可能是真的,但理由通常是一种愚蠢:"它被称为例外的原因",对我来说,这似乎是一种不应被一位受人尊敬的程序员/工程师接受的解释.
可以使用异常来解决一系列问题.为什么将它们用于控制流程是不明智的?对它们的使用方式保持格外保守的理念是什么?语义?性能?复杂?美学?惯例?
我之前已经看过一些关于性能的分析,但是在与某些系统相关且与其他系统无关的水平上.
同样,我不一定不同意他们应该在特殊情况下得救,但我想知道共识的理由是什么(如果这样的事情存在的话).
小智 90
主要的摩擦点是语义.许多开发人员滥用异常并抓住每个机会.这个想法是针对某种特殊情况使用例外.例如,错误的用户输入不会被视为异常,因为您希望这种情况发生并为此做好准备.但是如果你试图创建一个文件并且磁盘上没有足够的空间,那么是的,这是一个明确的例外.
另一个问题是异常经常被抛弃和吞噬.开发人员使用这种技术简单地"沉默"程序,让它尽可能长时间运行,直到完全崩溃.这是非常错误的.如果您不处理异常,如果您没有通过释放某些资源做出适当的反应,如果您没有记录异常事件或至少没有通知用户,那么您不会使用异常来表示它们的含义.
直接回答你的问题.应该很少使用例外情况,因为特殊情况很少见,例外情况很昂贵.
很少见,因为您不希望程序在按下每个按钮或每个格式错误的用户输入时崩溃.比方说,数据库可能突然无法访问,磁盘上可能没有足够的空间,你依赖的某些第三方服务是脱机的,这一切都可能发生,但很少,这些都是明显的例外情况.
昂贵,因为抛出异常会中断正常的程序流程.运行时将展开堆栈,直到找到可以处理异常的相应异常处理程序.它还将一直收集调用信息以传递给处理程序将接收的异常对象.这一切都有成本.
这并不是说使用异常(微笑)也不例外.有时,如果您抛出异常而不是通过多层转发返回代码,它可以简化代码结构.作为一个简单的规则,如果您希望经常调用某些方法并在一半时间内发现一些"异常"情况,那么最好找到另一种解决方案.但是,如果你在大多数情况下期望正常的操作流程,而这种"特殊"情况只能在某些罕见情况下出现,那么抛出异常就可以了.
@Comments:如果可以使您的代码更简单,更容易,那么在某些不太常见的情况下绝对可以使用异常.这个选项是开放的,但我认为它在实践中非常罕见.
为什么将它们用于控制流程是不明智的?
因为异常破坏了正常的"控制流".您引发异常并放弃了程序的正常执行,可能会使对象处于不一致状态,而某些打开的资源则不同意.当然,C#有using语句,即使从使用主体抛出异常,也会确保对象被处理掉.但是让我们从语言中抽象出来.假设框架不会为您处理对象.你手动完成.您有一些系统可以请求和释放资源和内存.您在整个系统范围内都有协议,负责在什么情况下释放对象和资源.您有如何处理外部库的规则.如果程序遵循正常的操作流程,它的工作原理很好.但突然在执行过程中你抛出异常.有一半的资源未经同意.还有一半尚未被要求.如果该操作现在是交易性的,那么它就会被破坏.处理资源的规则不起作用,因为那些负责释放资源的代码部分根本不会执行.如果有其他人想要使用这些资源,他们可能会发现它们处于不一致状态并且崩溃,因为他们无法预测这种特殊情况.
比如说,你想要一个方法M()调用方法N()做一些工作并安排一些资源,然后将它返回给M(),它将使用它然后处理它.精细.现在N()出现了问题,它会抛出你在M()中没有想到的异常,所以异常会冒泡到顶部,直到它可能会被某个方法C()抓住,这个方法不知道发生了什么内心深处在N()以及是否以及如何释放一些资源.
抛出异常会创建一种方法,使您的程序进入许多新的不可预测的中间状态,这些状态难以预测,理解和处理.它有点类似于使用GOTO.设计一个可以随机将其执行从一个位置跳到另一个位置的程序是非常困难的.它也很难维护和调试.当程序的复杂性增加时,您将失去对发生的时间和地点的概述,而不是修复它.
小智 61
虽然"在特殊情况下抛出异常"是一个明智的答案,但您实际上可以定义这些情况:当满足前提条件时,但不能满足后置条件.这使您可以编写更严格,更严格和更有用的后置条件,而不会牺牲错误处理; 否则,无需例外,您必须更改后置条件以允许每个可能的错误状态.
关于每个可能用C++编写的类的构造函数,你几乎没有什么可说的,但是有一些东西.其中最主要的是构造对象(即构造函数成功返回)将被破坏. 您无法修改此后置条件,因为该语言假定它为true,并将自动调用析构函数. (从技术上讲,你可以接受未定义行为的可能性,语言不保证任何东西,但这可能在其他地方更好.)
当构造函数无法成功时抛出异常的唯一替代方法是修改类的基本定义("类不变")以允许有效的"null"或僵尸状态,从而允许构造函数通过构造僵尸来"成功" .
这个僵尸修改的一个例子是std :: ifstream,你必须始终检查它的状态才能使用它.例如,因为std :: string没有,所以始终保证您可以在构造后立即使用它.想象一下,如果您必须编写此示例之类的代码,并且如果您忘记检查僵尸状态,您可能会默默地获得不正确的结果或破坏程序的其他部分:
string s = "abc";
if (s.memory_allocation_succeeded()) {
do_something_with(s); // etc.
}
Run Code Online (Sandbox Code Playgroud)
即使命名该方法也是一个很好的例子,说明如何修改类的情境字符串的不变量和接口,既不能预测也不能处理自身.
让我们来解决一个常见的例子:验证用户输入.仅仅因为我们想要允许输入失败并不意味着解析函数需要在后置条件中包含它.它确实意味着我们的处理程序需要检查解析器是否失败.
// 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';
}
Run Code Online (Sandbox Code Playgroud)
不幸的是,这显示了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
Run Code Online (Sandbox Code Playgroud)
不必严格检查前提条件 - 违反一个条件总是表示逻辑失败,并且它们是调用者的责任 - 但如果你检查它们,那么抛出异常是合适的.(在某些情况下,返回垃圾或使程序崩溃更合适;虽然这些操作在其他环境中可能是非常错误的.如何最好地处理未定义的行为是另一个主题.)
特别是,对比stdlib异常层次结构的std :: logic_error和std :: runtime_error分支.前者通常用于违反前提条件,而后者更适合违反后置条件.
Dig*_*oss 40
goto
陈述的许多问题适用于例外.它们经常在多个例程和源文件中跳过大量代码.通过阅读中间源代码,这并不总是显而易见的.(它在Java中.)Eri*_*ler 22
抛出异常在某种程度上类似于goto语句.为流量控制做到这一点,你以不可理解的意大利面条代码结束.更糟糕的是,在某些情况下,您甚至不知道跳转的确切位置(即,如果您没有在给定的上下文中捕获异常).这明显违反了增强可维护性的"最少惊喜"原则.
Ste*_*sop 16
例外情况会使您更难推断您的计划状态.例如,在C++中,你必须做更多的考虑,以确保你的函数是非常安全的,比你不需要的那样.
原因是没有例外,函数调用可以返回,也可以先终止程序.除了异常,函数调用可以返回,也可以终止程序,或者它可以跳转到某个地方的catch块.因此,只需查看前面的代码,就无法再遵循控制流程.你需要知道被调用的函数是否可以抛出.您可能需要知道可以抛出什么以及它被捕获的位置,这取决于您是否关心控制在哪里,或者只关心它离开当前范围.
出于这个原因,人们说"除非情况非常特殊,否则不要使用例外".当你了解它时,"非常特殊"意味着"某些情况已经发生,其中处理错误返回值的好处被成本所抵消".所以,是的,这是一个空洞的陈述,虽然一旦你有一些"非常特殊"的直觉,它就成了一个很好的经验法则.当人们谈论流量控制时,他们意味着在本地推理的能力(不参考catch块)是返回值的好处.
Java比C++有更广泛的"非常特殊"的定义.C++程序员比Java程序员更有可能想要查看函数的返回值,因此在Java中"非常特殊"可能意味着"我无法返回非null对象作为此函数的结果".在C++中,它更可能意味着"我非常怀疑我的来电者能继续".因此,如果Java流无法读取文件,则抛出该流,而C++流(默认情况下)返回指示错误的值.但是,在所有情况下,您都希望强制调用者必须编写哪些代码.所以这确实是一个编码风格的问题:你必须达成共识,你的代码应该是什么样的,以及你要编写多少"错误检查"代码来反对你想做多少"异常安全"推理.
所有语言的广泛共识似乎是最好根据错误的可恢复性来完成(因为不可恢复的错误导致没有代码有异常,但仍需要检查并返回您自己的 - 使用错误返回的代码中的错误).所以人们开始期待"我称此功能抛出异常"意味着" 我无法继续",而不仅仅是" 它无法继续".这不是例外所固有的,它只是一种习惯,但就像任何好的编程实践一样,它是由聪明人提倡的习惯,他们以另一种方式尝试过而不喜欢结果.我也有过多次例外的糟糕经历.所以个人而言,我确实认为"非常特殊",除非有关情况的事情使例外特别具有吸引力.
顺便说一句,除了对代码状态的推理之外,还有性能影响.现在,例外情况通常很便宜,在您有权关心绩效的语言中.它们可能比多个级别的"哦,结果是一个错误,我最好退出自己的错误,然后".在过去的糟糕时期,人们真的担心抛出异常,抓住它并继续下一件事,会使你所做的事情变得如此缓慢以至于无用.因此,在这种情况下,"非常特殊"意味着"情况如此糟糕,以至于可怕的表现不再重要".这已不再是这种情况(尽管紧密循环中的例外情况仍然很明显)并且有希望表明为什么"非常特殊"的定义需要灵活.
Cha*_*via 11
确实没有达成共识.整个问题在某种程度上是主观的,因为抛出异常的"恰当性"通常是由语言本身的标准库中的现有实践所暗示的.与Java标准库相比,C++标准库抛出异常的频率要低得多,Java标准库几乎总是优先考虑异常,即使对于诸如无效用户输入之类的预期错误(例如Scanner.nextInt
)也是如此.我相信,这会显着影响开发者关于什么时候抛出异常的意见.
作为一名C++程序员,我个人更喜欢为非常"特殊"的环境保留异常,例如内存不足,磁盘空间不足,启示时间等等.但我并不坚持认为这是绝对正确的做法的东西.
编辑11/20/2009:
我刚刚阅读了这篇关于提高托管代码性能的MSDN文章,这部分提醒了我这个问题:
抛出异常的性能成本很高.尽管结构化异常处理是处理错误条件的推荐方法,但请确保仅在出现错误情况的特殊情况下使用异常.不要将常规用于常规控制流程.
当然,这仅适用于.NET,它也专门针对那些开发高性能应用程序(如我自己); 所以这显然不是一个普遍的事实.不过,我们中有很多.NET开发人员,所以我觉得值得注意.
编辑:
好的,首先,让我们直言不讳:我无意在表演问题上与任何人争吵.事实上,事实上,我倾向于同意那些认为过早优化是罪的人.但是,我只想说两点:
海报要求传统智慧背后的客观理由,即应谨慎使用例外.我们可以讨论可读性和适当的设计; 但这些都是主观问题,人们准备在任何一方争论.我认为海报意识到了这一点.事实是,使用异常来控制程序流通常是一种低效的处理方式.不,不总是,但经常.这就是为什么谨慎使用例外是合理的建议,就像吃红肉或少喝葡萄酒的好建议一样.
优化没有充分理由和编写高效代码之间存在差异.这样做的必然结果是,写一些强大的东西(如果没有优化)和一些效率低下的东西之间存在差异.有时我认为当人们争论异常处理之类的事情时,他们实际上只是在谈论彼此,因为他们正在讨论根本不同的事情.
为了说明我的观点,请考虑以下C#代码示例.
这是我称之为异常滥用的一个例子.
int value = -1;
string input = GetInput();
bool inputChecksOut = false;
while (!inputChecksOut) {
try {
value = int.Parse(input);
inputChecksOut = true;
} catch (FormatException) {
input = GetInput();
}
}
Run Code Online (Sandbox Code Playgroud)
对我来说,这段代码很荒谬.当然有效.没有人在争论.但它应该是这样的:
int value = -1;
string input = GetInput();
while (!int.TryParse(input, out value)) {
input = GetInput();
}
Run Code Online (Sandbox Code Playgroud)
我认为这种情况实际上很常见.对于很多人来说,它肯定似乎更"可接受",因为它处理文件I/O:
string text = null;
string path = GetInput();
bool inputChecksOut = false;
while (!inputChecksOut) {
try {
using (FileStream fs = new FileStream(path, FileMode.Open)) {
using (StreamReader sr = new StreamReader(fs)) {
text = sr.ReadToEnd();
}
}
inputChecksOut = true;
} catch (FileNotFoundException) {
path = GetInput();
}
}
Run Code Online (Sandbox Code Playgroud)
这似乎足够合理,对吧?我们正在尝试打开一个文件; 如果不存在,我们会抓住该异常并尝试打开另一个文件......这有什么问题?
真的没什么.但请考虑这个替代方案,它不会抛出任何异常:
string text = null;
string path = GetInput();
while (!File.Exists(path)) path = GetInput();
using (FileStream fs = new FileStream(path, FileMode.Open)) {
using (StreamReader sr = new StreamReader(fs)) {
text = sr.ReadToEnd();
}
}
Run Code Online (Sandbox Code Playgroud)
当然,如果这两种方法的表现实际上是相同的,那么这实际上纯粹是一个教义问题.那么,我们来看看吧.对于第一个代码示例,我列出了10000个随机字符串,其中没有一个表示正确的整数,然后在最后添加了一个有效的整数字符串.使用上述两种方法,这些都是我的结果:
使用try
/ catch
block:25.455 秒
使用int.TryParse
:1.637 毫秒
对于第二个例子,我做了基本相同的事情:制作一个10000个随机字符串的列表,其中没有一个是有效路径,然后在最后添加了一个有效路径.这些是结果:
使用try
/ catch
block:29.989 秒
使用File.Exists
:22.820 毫秒
许多人会对此作出回应,他说:"是的,投掷和捕获10,000个异常是非常不现实的;这夸大了结果." 当然可以.抛出一个异常和自己处理错误输入之间的区别对于用户来说并不明显.事实仍然是,在这两种情况下,使用异常的速度比可读的替代方法慢1000到10,000倍 - 如果不是更多的话.
这就是为什么我包括GetNine()
下面方法的例子.它不是无法忍受的缓慢或无法接受的缓慢; 这是因为它比它应该更慢...... 没有充分的理由.
同样,这些只是两个例子.中当然会有时候使用异常的性能损失并不严重这(帕维尔的权利;毕竟,它不依赖于实现).我所说的只是:让我们面对事实,伙计们 - 在上述情况下,投掷和捕捉异常类似于GetNine()
; 这只是一种低效的做事方式,可以很容易地做得更好.
你要求的理由就好像这是每个人都不知道为什么跳上一个潮流的情况之一.但实际上答案很明显,我想你已经知道了.异常处理具有可怕的性能.
好吧,也许这对你的特殊业务场景来说很好,但相对来说,抛出/捕获异常会引入比许多情况下更多的开销.你知道,我知道:大部分时间,如果你使用异常来控制程序流,你只需编写慢速代码.
您不妨问:为什么这段代码不好?
private int GetNine() {
for (int i = 0; i < 10; i++) {
if (i == 9) return i;
}
}
Run Code Online (Sandbox Code Playgroud)
我敢打赌,如果您对此功能进行了分析,您会发现它对您的典型业务应用程序执行速度非常快.这并没有改变这样一个事实,即这是一种非常低效的方式来完成可以做得更好的事情.
这就是人们谈论异常"滥用"时的意思.
我不认为,很少会使用例外情况.但.
并非所有团队和项目都准备好使用例外.使用异常需要高级程序员资格,特殊技术以及缺乏大型遗留的非异常安全代码.如果你有庞大的旧代码库,那么它几乎总是不是异常安全的.我确定你不想重写它.
如果您要广泛使用例外,那么:
另一方面,在具有强大团队的新项目中使用异常可能会使代码更清晰,更易于维护,甚至更快:
并不是很少使用例外.只是他们应该只在特殊情况下抛出.例如,如果用户输入了错误的密码,那也不例外.
原因很简单:异常突然退出函数,并将堆栈向上传播到catch
块.这个过程在计算上非常昂贵:C++构建其异常系统,以便在"普通"函数调用上获得很少的开销,因此当引发异常时,它必须做很多工作才能找到去处.而且,因为每行代码都可能引发异常.如果我们有一些f
常常引发异常的函数,我们现在必须注意每次调用时使用try
/ catch
blocks f
.这是一个非常糟糕的接口/实现耦合.
关于例外的所有经验法则都归结为主观术语.您不应该期望得到何时使用它们以及何时不使用它们的硬性和快速定义."只有在特殊情况下".好的循环定义:例外情况适用于特殊情况.
何时使用异常与"如何知道此代码是一类还是两类?"属于同一个桶.这部分是一个风格问题,部分是偏好.例外是一种工具.它们可以被使用和滥用,找到两者之间的界限是编程艺术和技巧的一部分.
有很多意见和权衡要做.找到与您说话的内容,并遵循它.
相关部分:
几乎总是,使用异常来影响"正常"流是一个坏主意.正如我们在3.1节中已经讨论过的,异常会生成不可见的代码路径.如果这些代码路径仅在错误处理方案中执行,则可以接受.但是,如果我们将异常用于任何其他目的,我们的"正常"代码执行将分为可见和不可见的部分,这使得代码很难阅读,理解和扩展.
我的错误处理方法是有三种基本类型的错误:
assert
).通常,这些情况表明代码中的某些地方已经破坏了,并且您实际上无法相信任何其他内容是正确的 - 可能存在猖獗的内存损坏.你的船正在下沉,下车.换句话说,例外情况是当您遇到可以处理的问题时,但是您无法在您注意到的地方处理.你无法解决的问题应该简单地杀死程序; 你可以立即处理的问题应该简单地处理.
小智 5
我在这里阅读了一些答案.我仍然对所有这些混乱感到惊讶.我强烈不同意所有这些异常== spagetty代码.我的意思是混淆,有些人不喜欢C++异常处理.我不确定我是如何学习C++异常处理的 - 但我在几分钟内理解了它的含义.这是在1996年左右,我使用的是用于OS/2的borland C++编译器.我决定何时使用异常从来没有问题.我通常将易错的do-undo操作包装到C++类中.这种撤消行动包括:
比有功能包装.将系统调用(不属于前一类)包装到C++中.例如,从/向文件读/写.如果某些内容失败,将抛出异常,其中包含有关错误的完整信息.
然后有捕获/重新抛出异常以向故障添加更多信息.
整体C++异常处理导致更清晰的代码.代码量急剧减少.最后,可以使用构造函数来分配错误的资源,并在发生此类故障后仍然保持无损坏的环境.
可以将这些类链接到复杂的类中.一旦某个成员/基础对象的构造函数被执行,就可以依赖于同一对象的所有其他构造函数(之前执行)成功执行.
归档时间: |
|
查看次数: |
9479 次 |
最近记录: |