有人告诉我,异常仅应在例外情况下使用。我怎么知道我的案子是否特殊?


99

我在这里的特定情况是用户可以将字符串传递到应用程序中,应用程序将对其进行解析并将其分配给结构化对象。有时,用户可能输入了无效的内容。例如,他们的输入可能描述一个人,但他们可能说他们的年龄是“苹果”。在这种情况下,正确的行为是回滚事务并告诉用户发生了错误,他们将不得不再次尝试。可能需要报告我们在输入中发现的每个错误,而不仅仅是第一个。

在这种情况下,我认为我们应该抛出异常。他不同意,说:“例外应该是例外:应该预期用户可能会输入无效数据,所以这不是例外情况”,我真的不知道该如何辩驳,因为按照单词的定义,他似乎是正确的。

但是,据我了解,这就是为什么首先发明例外的原因。过去,您必须检查结果以查看是否发生错误。如果检查失败,可能会在您不注意的情况下发生不良情况。

无一例外,堆栈的每个级别都需要检查调用的方法的结果,如果程序员忘记检入这些级别之一,则代码可能会意外继续并保存无效数据(例如)。这种方式似乎更容易出错。

无论如何,请随时纠正我在这里所说的任何内容。我的主要问题是,如果有人说例外应该是例外,我怎么知道我的情况是否是例外?


3
可能重复吗?什么时候抛出异常。尽管它在那里关闭,但我认为它适合这里。这仍然是一种哲学,一些人和社区倾向于将异常视为一种流控制。
thorstenmüller13年

8
当用户哑巴时,他们将提供无效的输入。当用户很聪明时,他们通过提供无效的输入进行游戏。因此,无效的用户输入也不例外。
mouviciel 2013年

7
另外,不要将异常(这是Java和.NET中的一种非常特殊的机制)与错误(这是更通用的术语)混淆了。错误处理比抛出异常要多得多。该讨论涉及异常错误之间的细微差别。
埃里克·金

4
“ Exceptional”!=“很少发生”
ConditionRacer

3
我发现Eric Lippert的Vexing例外是不错的建议。
Brian

Answers:


87

发明了异常,以使错误处理更容易,代码混乱更少。如果它们使错误处理更容易且代码混乱更少,则应使用它们。这种“仅在特殊情况下的例外情况”业务源于将异常处理视为不可接受的性能损失的时间。在绝大多数代码中已经不再是这种情况了,但是人们仍然不顾规则背后的原因而大声疾呼。

特别是在Java(这可能是有史以来最喜欢异常的语言)中,当您简化代码时使用异常不会感到不好 实际上,Java自己的Integer类没有办法检查字符串是否为有效整数,而又不会抛出NumberFormatException

此外,尽管您不能依靠UI验证,但请记住,如果您的UI设计正确,例如使用微调器输入短数值,那么将其非真实值输入后端的确是特殊情况。


10
轻扫一下,在那里。实际上,在我设计的真实应用程序中,性能上的影响确实有所不同,因此我必须对其进行更改,以免某些解析操作抛出异常。
罗伯特·哈维

17
我并不是说仍然没有出现性能下降是正当理由的情况,但是这些情况是例外(双关语意)而不是规则。
Karl Bielefeldt

11
@RobertHarvey Java中的技巧是抛出预制的异常对象,而不是throw new ...。或者,抛出fillInStackTrace()被覆盖的自定义异常。然后,您不应该注意到任何性能下降,更不用说点击率了
Ingo 2013年

3
+1:完全是我要回答的问题。在简化代码时使用它。异常可以提供更清晰的代码,您无需费心检查调用堆栈中每个级别的返回值。(不过,就像其他所有内容一样,如果使用错误的方法,它可能会使您的代码变得一团糟。)
Leo

4
@Brendan假设发生某些异常,并且错误处理代码在调用堆栈中位于4个级别以下。如果使用错误代码,则处理程序上方的所有4个函数都需要将错误代码的类型作为其返回值,并且您必须if (foo() == ERROR) { return ERROR; } else { // continue }在每个级别执行一个链。如果抛出未经检查的异常,则不会有嘈杂且多余的“ if error return error”。另外,如果将函数作为参数传递,则使用错误代码可能会将函数签名更改为不兼容的类型,即使可能不会发生错误也是如此。
Doval 2014年

72

什么时候应该引发异常?在代码方面,我认为以下解释非常有帮助:

一个例外是,当成员未能完成其名称所指示的应执行的任务时。(Jeffry Richter,通过C#进行CLR)

为什么有帮助?它建议,这取决于上下文何时将某些内容作为异常来处理。在方法调用的级别上,上下文由(a)名称,(b)方法的签名和(b)使用或预期使用该方法的客户端代码给出。

要回答您的问题,您应该看一下处理用户输入的代码。它可能看起来像这样:

public void Save(PersonData personData) {  }

方法名称是否暗示已完成一些验证?否。在这种情况下,无效的PersonData应该引发异常。

假设该类具有另一个如下所示的方法:

public ValidationResult Validate(PersonData personData) {  }

方法名称是否暗示已完成一些验证?是。在这种情况下,无效的PersonData不应引发异常。

综上所述,两种方法都建议客户端代码应如下所示:

ValidationResult validationResult = personRegister.Validate(personData);
if (validationResult.IsValid())
{
    personRegister.Save(personData)
}
else
{
    // Throw an exception? To answer this look at the context!
    // That is: (a) Method name, (b) signature and
    // (c) where this method is (expected) to be used.
}

当不清楚某个方法是否应该引发异常时,则可能是由于方法名称或签名选择不当所致。也许班级的设计不清楚。有时,您需要修改代码设计,以明确了解是否应引发异常。


就在昨天,我制作了一个struct名为“ ValidationResult”的代码,并按照您的描述来组织代码。
paul 2013年

4
它无助于回答您的问题,但我只是想指出,您隐式或有意遵循了Command-query分离原则(en.wikipedia.org/wiki/Command-query_separation)。;-)
Theo Lenndorff

好主意!缺点之一:在您的示例中,验证实际上执行了两次:一次验证期间Validate(如果无效,则返回False)和一次验证期间(如果无效,则Save抛出记录明确的特定异常)。当然,可以将验证结果缓存在对象内部,但这会增加额外的复杂性,因为验证结果需要在更改时失效。
Heinzi 2013年

@Heinzi,我同意。可以对其进行重构,以便Validate()Save()方法内部调用,并且ValidationResult可以使用中的特定详细信息为异常构造适当的消息。
菲尔

3
这比我认为的公认答案要好。当呼叫无法执行应执行的操作时抛出。
安迪

31

例外应该是例外的:预期用户可能输入了无效数据,所以这不是例外情况

关于该论点:

  • 预计文件可能不存在,所以这不是例外情况。
  • 预计到服务器的连接可能会丢失,所以这不是例外情况
  • 预计配置文件可能会出现乱码,这不是例外情况
  • 预计您的请求有时可能会失败,所以这不是例外情况

您必须预期会捕获到的任何异常,因为您决定捕获它。因此,按照这种逻辑,您绝对不应抛出任何实际计划要捕获的异常。

因此,我认为“例外应该是例外”是一个可怕的经验法则。

您应该做什么取决于语言。对于何时应该引发异常,不同的语言有不同的约定。例如,Python会引发所有事件的异常,而在Python中,我也会效仿。另一方面,C ++引发的异常相对较少,因此我也照做。您可以像对待Python一样对待C ++或Java,并为所有内容抛出异常,但是您在使用该语言时期望使用的语言却不一致。

我更喜欢Python的方法,但是我认为将其他语言加入其中并不是一个好主意。


1
@gnat,我知道。我的观点是,即使不是您的惯用语言,您也应遵循该语言的约定(在本例中为Java)。
2013年

6
+1 "exceptions should be exceptional" is a terrible rule of thumb.说得好!这就是人们不考虑而重复的事情之一。
Andres F.

2
“预期”不是由主观论据或约定定义的,而是由API /函数的约定定义的(可能是明确的,但通常只是隐含的)。不同的功能/ API /子系统可能会有不同的期望,例如,对于某些更高级别的功能,不存在的文件可能会处理(可能会通过GUI向用户报告),对于其他更低级别的功能则是可能不会(因此应该抛出异常)。这个答案似乎错过了这一要点
。...– mikera

1
@mikera,是的,函数应该(仅)抛出其合同中定义的异常。但这不是问题。问题是您如何决定该合同应该是什么。我认为,“例外应该是例外”的经验法则对做出该决定没有帮助。
Winston Ewert

1
@supercat,我认为这并不重要,最终变得更加普遍。我认为关键问题是有一个合理的默认值。如果我没有明确处理错误情况,我的代码会假装什么也没有发生,还是得到有用的错误消息?
温斯顿·埃韦特

30

考虑异常时,我总是想到访问数据库服务器或Web API之类的东西。您希望服务器/ Web API可以正常工作,但在特殊情况下可能无法(服务器已关闭)。Web请求通常可能很快,但是在特殊情况下(高负载)可能会超时。这是您无法控制的。

用户的输入数据在您的控制范围内,因为您可以检查他们发送的内容并按照自己喜欢的方式进行处理。在您的情况下,我会在尝试保存用户输入之前对其进行验证。而且我倾向于同意应该期望用户提供无效数据,并且您的应用应该通过验证输入并提供用户友好的错误消息来解决此问题。

就是说,我确实在大多数域模型设置器中使用异常,因为绝对没有机会输入无效数据。但是,这是最后一道防线,我倾向于使用丰富的验证规则来构建输入表单,因此几乎没有机会触发该域模型异常。因此,当二传手期待某件事,而又得到另一件事时,这是一种例外情况,在通常情况下不应该发生。

编辑(其他要考虑的):

在将用户提供的数据发送到数据库时,您预先知道应该和不应该输入表中的内容。这意味着可以根据某些预期格式验证数据。这是您可以控制的。您无法控制的是服务器在查询过程中出现故障。因此,您知道查询是可以的,并且数据已被过滤/验证,您尝试查询后仍然失败,这是一种特殊情况。

与网络请求类似,在尝试发送请求之前,您不知道请求是否超时或无法连接。因此,这也保证了尝试/捕获方法,因为在发送请求后,您不能询问服务器它是否会在几毫秒后工作。


8
但为什么?为什么在处理更多预期的问题时例外没有那么有用?
2013年

6
@Pinetree,在打开文件之前检查文件是否存在是一个坏主意。该文件在检查和打开之间可能不再存在,该文件没有允许您打开它的权限,并且检查是否存在然后打开文件将需要两个昂贵的系统调用。最好先打开文件,然后再处理失败。
2013年

10
据我所知,几乎所有可能的故障都可以通过从故障中恢复来更好地处理,而不是先尝试检查是否成功。是否使用异常或其他指示失败是一个单独的问题。我更喜欢例外,因为我不会偶然忽略它们。
2013年

11
我不同意您的假设,因为预期会有无效的用户数据,因此不能认为该数据是例外的。如果我编写了一个解析器,并且有人提供了无法解析的数据,那是一个例外。我无法继续解析。异常的处理方式完全是另一个问题。
ConditionRacer

4
FileNotFoundException如果输入错误(例如,文件名不存在),则File.ReadAllBytes将抛出。那是威胁该错误的唯一有效方法,在不导致返回错误代码的情况下还能做什么?
2013年

16

参考

实用程序员:

我们认为,异常应该很少用作程序正常流程的一部分;应该为意外事件保留异常。假定未捕获的异常将终止您的程序,并问自己:“如果删除所有异常处理程序,该代码是否还会运行?” 如果答案为“否”,则可能在非例外情​​况下使用例外。

他们继续研究打开文件进行读取的示例,但该文件不存在-应该引发异常吗?

如果文件应该在那儿,那么就可以保证例外。[...]另一方面,如果您不知道该文件是否应该存在,那么,如果找不到该文件就不会显得异常,因此返回错误是合适的。

后来,他们讨论了为什么选择这种方法:

[A] n异常表示立即的,非本地的控制权转移-这是一种级联goto。使用异常作为其正常处理的一部分的程序会遭受经典意大利面条代码的所有可读性和可维护性问题。这些程序破坏了封装:例程和它们的调用者通过异常处理更加紧密地耦合在一起。

关于你的情况

您的问题可以归结为“验证错误是否会引发异常?” 答案是,这取决于验证发生的位置。

如果所讨论的方法在代码的一部分内(假设已验证输入数据),则无效的输入数据应引发异常;如果将代码设计为使得此方法将接收用户输入的确切输入,则应预期会出现无效数据,并且不应引发异常。


11

这里有很多哲学上的要求,但是一般来说,特殊情况就是没有用户干预就无法或不希望处理的那些情况(除了清理,错误报告等)。换句话说,它们是不可恢复的条件。

如果您为程序提供了一个文件路径,目的是要以某种方式处理该文件,而该路径指定的文件不存在,那是一个特殊情况。除了向用户报告并允许他们指定其他文件路径外,您无法在代码中对此做任何事情。


1
+1,非常接近我要说的内容。我要说的是范围,与用户无关。一个很好的例子是两个.Net函数int.Parse和int.TryParse之间的区别,前者别无选择,只能在输入错误时抛出异常,而后者则永远不要抛出异常
jmoreno 2013年

1
@jmoreno:不好意思,当代码可以对不可解析的条件执行某些操作时,可以使用TryParse,而在无法解析的条件下可以使用Parse。
罗伯特·哈维

7

您应该考虑两个问题:

  1. 您讨论一个关注点-称呼它是Assigner因为该关注点是将输入分配给结构化对象-并表达了其输入有效的约束

  2. 一个实现良好的用户界面有一个额外的关注:用户输入和建设性的反馈意见对错误的验证(我们称之为部分Validator

Assigner组件的角度来看,抛出异常是完全合理的,因为您已表达了已违反的约束。

用户体验的角度来看,用户不应首先直接与之交谈Assigner。他们应该谈论它通过Validator

现在,在情况下Validator,无效的用户输入不是一个例外情况,这确实是您更感兴趣的情况。因此,这里的例外情况是不合适的,这也是您要识别所有错误而不是识别错误的地方抢先。

您会注意到我没有提到如何实现这些担忧。看来您在谈论,Assigner而您的同事在谈论合并Validator+Assigner。一旦你意识到有两个独立的(或可分离)的担忧,至少你可以理智地讨论。


为了解决Renan的评论,我假设您已经确定了两个独立的问题,很明显在每种情况下都应考虑将哪些情况视为例外。

实际上,如果尚不清楚是否应该将某些事情视为例外,那么我认为您可能还没有完成解决方案中独立问题的识别。

我想这直接回答了

...我怎么知道我的案子是否特殊?

不断简化直到显而易见。当您掌握一堆简单的概念时,就可以清楚地将它们重新组合成代码,类,库或其他内容。


-1是的,有两个问题,但这不能回答“我怎么知道我的案子是否特殊?”的问题。
RMalke

关键是同一情况在一种情况下可能是例外,而在另一种情况下可能不会例外。确定您实际上是在谈论哪个上下文(而不是将两者都混为一谈)在这里回答了这个问题。
没用

...实际上,也许不是-我已在回答中解决了您的观点。
无用的

4

其他人的回答很好,但这仍然是我的简短回答。例外情况是环境中的某些问题出了错,您无法控制并且代码根本无法前进。在这种情况下,您还必须告知用户出了什么问题,为什么不能继续执行以及解决方案是什么。


3

我从来都不是忠告的忠实拥护者,只有在特殊情况下才应该抛出异常,部分原因是它什么也没说(就像说您应该只吃可食用的食物),还因为是非常主观的,通常不清楚什么是例外情况,什么不是例外情况。

但是,提供此建议的理由很充分:抛出和捕获异常的速度很慢,并且如果您在Visual Studio的调试器中运行代码,并且该代码设置为在引发异常时通知您,则最终可能被数十种垃圾邮件淹没如果不是很久,那么数百条消息就不存在了。

因此,一般而言,如果:

  • 您的代码没有错误,并且
  • 它所依赖的服务都是可用的,并且
  • 您的用户正在按照计划使用的方式使用您的程序(即使他们提供的某些输入无效)

那么您的代码就永远不会抛出异常,即使是后来被捕获的异常也是如此。要捕获无效数据,可以在UI级别或代码(例如Int32.TryParse()表示层)中使用验证器。

对于其他任何事情,您都应该坚持以下原则:异常意味着您的方法无法执行其名称所说明的操作。通常,使用返回码指示失败不是一个好主意(除非您的方法名称明确表明这样做了,例如TryParse()),原因有两个。首先,对错误代码的默认响应是忽略错误条件并继续执行;其次,您可能很容易以使用返回码的某些方法和使用异常的其他方法结束,而忘记了哪个方法。我什至看过代码库,其中同一接口的两个不同的可互换实现在这里采用不同的方法。


2

异常应表示条件,即使调用方法可能也无法准备立即调用的代码。例如,考虑正在从文件中读取某些数据的代码,可以合法地假定任何有效文件都将以有效记录结尾,并且不需要从部分记录中提取任何信息。

如果读取数据例程不使用异常,而只是报告读取是否成功,则调用代码将看起来像:

temp = dataSource.readInteger();
if (temp == null) return null;
field1 = (int)temp;
temp = dataSource.readInteger();
if (temp == null) return null;
field2 = (int)temp;
temp = dataSource.readString();
if (temp == null) return null;
field3 = temp;

等。为每项有用的工作花费三行代码。相反,如果if readInteger在遇到文件结尾时将引发异常,并且如果调用方可以简单地传递异常,则代码将变为:

field1 = dataSource.readInteger();
field2 = dataSource.readInteger();
field3 = dataSource.readString();

看起来更加简单和整洁,并且更加注重正常工作的情况。需要注意的是在直接调用方的情况下被期待来处理的条件,它返回一个错误代码,通常会比一个会抛出异常更有帮助的方法。例如,要合计文件中的所有整数:

do
{
  temp = dataSource.tryReadInteger();
  if (temp == null) break;
  total += (int)temp;
} while(true);

try
{
  do
  {
    total += (int)dataSource.readInteger();
  }
  while(true);
}
catch endOfDataSourceException ex
{ // Don't do anything, since this is an expected condition (eventually)
}

要求整数的代码预期这些调用之一将失败。让代码使用一个无限循环,直到发生这种情况,这要比使用一种通过返回值指示失败的方法要优雅得多。

由于类通常不知道客户会期望或不期望的条件,因此提供两种版本的方法可能会有所帮助,这两种方法可能会以某些调用者期望的方式失败,而另一些调用者不会。这样做将使这两种类型的调用方都能干净地使用这些方法。还要注意,如果情况出现,甚至调用者可能没想到的话,即使是“ try”方法也应该抛出异常。例如,tryReadInteger如果遇到干净的文件结束条件,则不应引发异常(如果调用者不希望这样,则调用者将使用readInteger)。另一方面,如果无法读取数据,则可能会引发异常,因为例如已拔出包含数据的记忆棒。尽管应该始终将此类事件视为可能,但立即调用代码不太可能准备做任何有用的响应。当然,不应以与文件结束条件相同的方式报告该错误。


2

编写软件中最重要的事情是使其具有可读性。所有其他考虑因素都是次要的,包括使其高效并使其正确。如果可读,其余的可以在维护中处理,如果不可读,那么最好扔掉它。因此,增强可读性时应抛出异常。

当您编写某种算法时,只需考虑将来将要阅读该算法的人。当您来到可能存在潜在问题的地方时,问问自己读者是否想看看您现在如何解决该问题,还是读者宁愿只继续学习算法?

我喜欢想到巧克力蛋糕的食谱。当它告诉您添加鸡蛋时,它可以选择:它可以假设您已经鸡蛋,然后继续食谱,或者可以开始解释如果没有鸡蛋,如何获得鸡蛋。它可以使整本书充满狩猎野鸡的技巧,所有这些都可以帮助您烤蛋糕。很好,但是大多数人都不想阅读该食谱。大多数人宁愿只是假设有鸡蛋,然后继续做菜。这是作者在编写食谱时需要做出的判断。

关于什么是好的例外以及应该立即处理哪些问题,没有任何保证的规则,因为这需要您阅读读者的思想。您将要做的最好的就是经验法则,“例外仅在特殊情况下”是一个很好的规则。通常,当读者阅读您的方法时,他们正在寻找该方法将在99%的时间内完成的工作,而他们宁愿不要将这些混乱的情况弄得一团糟,例如处理用户输入非法输入和其他几乎从未发生过的事情。他们想直接看到您的软件的正常流程,一个接一个的指令仿佛从未发生过问题。


2

可能需要报告我们在输入中发现的每个错误,而不仅仅是第一个。

这就是为什么您不能在此处引发异常的原因。异常立即中断验证过程。因此,有很多解决方法可以完成此任务。

一个不好的例子:

Dog使用异常的类的验证方法:

void validate(Set<DogValidationException> previousExceptions) {
    if (!DOG_NAME_PATTERN.matcher(this.name).matches()) {
        DogValidationException disallowedName = new DogValidationException(Problem.DISALLOWED_DOG_NAME);
        if (!previousExceptions.contains(disallowedName)){
            throw disallowedName;
        }
    }
    if (this.legs < 4) {
        DogValidationException invalidDog = new DogValidationException(Problem.LITERALLY_INVALID_DOG);
        if (!previousExceptions.contains(invalidDog)){
            throw invalidDog;
        }
    }
    // etc.
}

怎么称呼:

Set<DogValidationException> exceptions = new HashSet<DogValidationException>();
boolean retry;
do {
    retry = false;
    try {
        dog.validate(exceptions);
    } catch (DogValidationException e) {
        exceptions.add(e);
        retry = true;
    }
} while (retry);

if(exceptions.isEmpty()) {
    dogDAO.beginTransaction();
    dogDAO.save(dog);
    dogDAO.commitAndCloseTransaction();
} else {
    // notify user to fix the problems
}

这里的问题是,要获取所有错误,验证过程将需要跳过已发现的异常。上面的方法可能有效,但这显然是对异常的滥用。要求您进行的那种验证应接触数据库之前进行。因此,无需回滚任何东西。并且,验证的结果很可能是验证错误(尽管希望为零)。

更好的方法是:

方法调用:

Set<Problem> validationResults = dog.validate();
if(validationResults.isEmpty()) {
    dogDAO.beginTransaction();
    dogDAO.save(dog);
    dogDAO.commitAndCloseTransaction();
} else {
    // notify user to fix the problems
}

验证方法:

Set<Problem> validate() {
    Set<Problem> result = new HashSet<Problem>();
    if(!DOG_NAME_PATTERN.matcher(this.name).matches()) {
        result.add(Problem.DISALLOWED_DOG_NAME);
    }
    if(this.legs < 4) {
        result.add(Problem.LITERALLY_INVALID_DOG);
    }
    // etc.
    return result;
}

为什么?原因很多,其他答复中也指出了大多数原因。简而言之:他人阅读和理解要简单得多。其次,您是否要向用户显示堆栈跟踪信息以解释他设置dog错误的原因?

如果在第二个示例的提交过程中,即使您的验证程序以零个问题验证了,仍然出现错误dog那么抛出异常是正确的事情。像:没有数据库连接,或者与此同时有人修改了数据库条目。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.