Why are Exceptions said to be so bad for Input Validation?
我知道"例外情况适用于例外情况"[A],但除了一再重复之外,我从未找到这一事实的真正原因。
因为它们停止执行,所以您不希望它们用于纯条件逻辑是有意义的,但是为什么不输入验证呢?
假设您要循环访问一组输入,并捕获每个异常以将它们组合在一起以供用户通知…我不断地看到,这在某种程度上是"错误的",因为用户总是输入不正确的输入,但这一点似乎是基于语义的。
输入不是预期的,因此是例外的。抛出异常允许我准确地定义错误的内容,如StringValueTo龙或IntelValueToRow或InvalidDateValue等。为什么这被认为是错误的?
引发异常的替代方法是返回(并最终收集)错误代码,或者更糟的是返回错误字符串。然后,我要么直接显示这些错误字符串,要么解析错误代码,然后向用户显示相应的错误消息。异常不会被认为是可延展的错误代码吗?为什么要创建一个单独的错误代码和消息表,而这些错误代码和消息可以通过已经内置到我的语言中的异常功能进行通用化?
另外,我还发现了MartinFowler关于如何处理这些事情的这篇文章——通知模式。我不知道我是如何看待这一切的,除了那些不会停止执行的异常。
A:我在任何地方都读过关于例外的东西。
---编辑---
已经提出了许多伟大的观点。我已经评论了大多数和+的优点,但我还没有完全相信。
我并不打算提倡将异常作为解决输入验证的正确方法,但我想找到一个很好的理由,为什么当大多数替代解决方案似乎只是伪装的异常时,实践被视为如此邪恶。
读到这些答案,我发现说"例外情况只适用于例外情况"是非常无用的。这就引出了什么是"例外情况"的整个问题。这是一个主观术语,最好的定义是"正常逻辑流不处理的任何条件"。换句话说,异常条件是您使用异常处理的任何条件。
作为一个定义,我可以接受,我不知道我们会更接近这一点。但你应该知道这就是你所用的定义。
如果你要在某些情况下反对例外,你必须解释如何将条件的宇宙划分为"例外"和"非例外"。
在某些方面,这类似于回答"程序之间的界限在哪里?"答案是,"无论你把开始和结束放在哪里",然后我们可以讨论经验法则和不同的风格来决定放在哪里。没有硬性规定。
输入"bad"的用户不是例外:应该是这样。
异常不应用于正常控制流。
在过去,许多作者都说,例外本身就很昂贵。乔恩·斯基特写了一篇与此相反的博客(并在下面的回答中提到了几次),他说,它们不像报道的那样昂贵(尽管我不主张使用它们的严格循环!)
使用它们的最大原因是"意向声明",即如果您看到异常处理块,您会立即看到异常情况,这些异常情况是在正常流之外处理的。
除上述原因外,还有一个重要原因:
如果只对异常情况使用异常,则可以在调试器中运行调试器设置为"引发异常时停止"。这非常方便,因为您可以在导致问题的确切行上插入调试器。使用此功能每天可为您节省大量时间。
在C中,这是可能的(我衷心地推荐),特别是在他们将胰蛋白酶方法添加到所有数字类之后。一般来说,没有一个标准库需要或使用"坏"的异常处理。当我接近一个尚未写入本标准的C代码库时,我总是将其转换为常规情况下的无异常代码,因为stop-om-throw非常有价值。
在Firebug JavaScript调试器中,您也可以这样做,前提是您的库不会严重使用异常。
当我对Java编程时,这是不可能的,因为很多事情使用异常来处理非例外情况,包括很多标准的Java库。因此,这种省时的功能在Java中是不可用的。我相信这是由于检查异常,但我不会开始抱怨他们是如何邪恶的。
错误和例外——什么,何时何地?
异常旨在报告错误,从而使代码更加健壮。要理解何时使用异常,必须首先了解什么是错误,什么不是错误。
功能是一个工作单元,故障应被视为错误或基于其对功能的影响。在函数f中,只有当它阻止f满足其被调用方的任何前提条件、实现f自身的任何后置条件或重新建立f分担维护责任的任何不变量时,失败才是错误。
有三种错误:
- 阻止函数满足必须调用的另一个函数的前提条件(如参数限制)的条件;
- 一种阻止函数建立其自身后置条件之一的条件(例如,产生有效返回值是后置条件);以及
- 一种防止函数重新建立其负责维护的不变量的条件。这是一种特殊的后置条件,特别适用于成员函数。每个非私有成员函数的一个重要后置条件是它必须重新建立类的不变量。
任何其他条件都不是错误,不应报告为错误。
为什么说异常对输入验证如此不利?我想这是因为对"input"的理解有点含糊不清,即"input"是指函数的输入,或者是字段的值,后者不应该抛出异常,除非它是失败函数的一部分。
我认为区别取决于特定班级的合同,即
对于用于处理用户输入的代码,以及为其编写防御程序(即对其进行消毒),对于无效输入抛出异常是错误的——这是预期的。
对于用于处理已经消毒和验证的输入(可能是由用户发起的)的代码,如果您发现了一些要禁止的输入,则抛出异常是有效的。在这种情况下,调用代码违反了合同,并且指示消毒和/或调用代码中存在错误。
使用异常时,错误处理代码与导致错误的代码分开。这是异常处理的目的——作为一个异常条件,错误不能在本地处理,因此异常被抛出到更高(和未知)的范围。如果不处理,应用程序将在完成任何更难的操作之前退出。
如果在执行简单的逻辑操作(如验证用户输入)时,您曾经抛出异常,那么您所做的事情非常、非常、错误。
The input is Not what was expected and
hence is exceptional.
这句话一点也不适合我。用户界面约束用户输入(例如,使用限制最小/最大值的滑块),现在您可以断言某些条件——不需要进行错误处理。或者,用户可以输入垃圾,您希望发生这种情况,必须处理它。一个或另一个-这里没有任何例外。
Throwing an exception allows me to
define exactly what was wrong like
StringValueTooLong or or
IntegerValueTooLow or InvalidDateValue
or whatever. Why is this considered
wrong?
我认为这是超越-接近邪恶。您可以定义抽象的ErrorProvider接口,或者返回表示错误的复杂对象,而不是简单的代码。关于如何检索错误报告,有许多选项。使用异常是因为方便,所以是错误的。刚写这段话我就觉得不干净。
把抛出异常当作希望。最后一次机会。祈祷验证用户输入不应导致任何这些情况。
是否有可能一些分歧是由于对"用户输入"的含义缺乏共识?实际上,在哪一层编码。
如果您正在编写一个GUI用户界面或Web表单处理程序,那么您很可能会期望输入无效,因为它直接来自于人类的输入手指。
如果您正在对MVC应用程序的模型部分进行编码,您可能已经设计了一些东西,以便控制器为您消毒输入。无效的输入,只要模型确实是一个异常,就可以这样处理。
如果您在协议级别对服务器进行编码,您可能合理地期望客户机检查用户输入。同样,这里的无效输入确实是一个例外。这与100%信任客户机(这确实非常愚蠢)截然不同,但与直接用户输入不同,您预测大多数时间输入都是正常的。这里的线条有些模糊。事情发生的可能性越大,您越不希望使用异常来处理它。
这是一个语言学上的观点。
为什么说异常对输入验证如此不利?结论:- 例外的定义不够清楚,所以有不同的看法。
- 错误的输入被视为正常的事情,而不是例外。
思想?
它可能归结为人们对所创建代码的期望。
- 无法信任客户端
- 必须在服务器端进行验证。更强:每次验证都在服务器端进行。
- 因为验证是在服务器端进行的,所以应该在服务器端进行,而期望的并不是例外,因为它是期望的。
然而,
- 无法信任客户端的输入
- 可以信任客户端的输入验证
- 如果验证是可信的,则可以期望它生成有效的输入
- 现在每个输入都是有效的
- 无效输入现在是意外的,异常
.
异常可能是退出代码的好方法。
要考虑的一点是,您的代码是否处于正确的状态。我不知道什么会使我的代码处于不适当的状态。连接自动关闭,剩余的变量被垃圾收集,有什么问题?
另一个反对对非异常处理的投票!
在.NET中,即使没有抛出异常,JIT编译器在某些情况下也不会执行优化。下面的文章很好地解释了这一点。http://msmvps.com/blogs/peterritchie/archive/2007/06/22/performance-implications-of-try-catch-finally.aspxhttp://msmvps.com/blogs/peterritchie/archive/2007/07/12/performance-implications-of-try-catch-finally-part-two.aspx
当一个异常被抛出时,它会为堆栈跟踪生成一大堆信息,如果您真的"期望"异常,则可能不需要这些信息,就像将字符串转换为int等时的情况一样。
8年后,我在尝试应用CQS模式时遇到了同样的困境。我认为输入验证可以引发异常,但有一个附加的约束。如果任何输入失败,您需要抛出一种类型的异常:validationException、brokenRuleException等。不要抛出一堆不同的类型,因为不可能全部处理它们。这样,您就可以在一个地方得到所有违反规则的列表。创建一个负责进行验证(SRP)的类,如果至少有一个规则被破坏,则抛出异常。这样,你只需一网打尽就可以应付一种情况,而且你知道自己很好。无论调用什么代码,您都可以处理该场景。这会使下游的所有代码更加干净,因为您知道它处于有效状态,或者它不会到达那里。
对我来说,从用户那里获取无效数据并不是你通常所期望的。(如果每个用户第一次向您发送无效数据,我将再次查看您的用户界面。)任何阻止您处理真正意图的数据(无论是用户还是来源于其他地方)都需要中止处理。如果它是用户输入,那么它与从单个数据块抛出argumentNullException有什么不同?如果它是一个类上的字段,表示这是必需的。
当然,您可以先进行验证,然后在每个"命令"上编写相同的样板代码,但我认为这是一个维护噩梦,而不是在顶部的一个位置捕获无效的用户输入,不管如何,这些输入都以相同的方式处理。(代码少!)只有当用户提供的数据无效时,性能才会受到影响,而这不应该经常发生(或者您的用户界面不好)。客户机端的任何和所有规则都必须在服务器上重新编写,所以您只需编写它们一次,执行Ajax调用,并且<500毫秒的延迟将为您节省大量的编码时间(只有1个地方可以放置所有的验证逻辑)。
此外,虽然您可以使用现成的ASP.NET进行一些整洁的验证,但如果您想在其他UI中重新使用验证逻辑,则不能这样做,因为它被烘焙到ASP.NET中。您最好在下面创建一些东西并在上面处理它,而不管使用的是哪个UI。(至少我的2美分。)
异常不应用于输入验证,因为异常不仅应在异常情况下使用(正如已经指出的那样,不正确的用户输入不应),而且它们会创建异常代码(不是很好的意义)。
在大多数语言中,异常的问题是它们改变了程序流的规则,这在一个真正的异常情况下是可以的,在这种情况下,不一定能够计算出有效的流应该是什么,因此只需抛出一个异常,然后退出,不管您知道流应该是什么,您应该创建该流(在列出的案例将向用户发出一条消息,告诉他们需要重新输入一些信息)。
在我每天工作的应用程序中,甚至在用户登录时输入了错误的密码的情况下,异常都被滥用了,根据您的逻辑,这将是异常结果,因为它不是应用程序想要的。然而,当一个过程有两个结果中的一个是正确的或不正确的,我认为我们不能说,不正确,无论多么错误,都是例外。
使用此代码时,我发现的一个主要问题是试图遵循代码的逻辑,而不深入地参与调试器。虽然调试程序很好,但是应该可以在用户输入错误密码时添加逻辑,而不必启动一个。
为真正的异常执行保留异常,而不仅仅是错误的执行。在我强调的情况下,把你的密码弄错也不例外,但不能联系到域名服务器可能是!
当我看到为验证错误抛出异常时,我经常看到抛出异常的方法同时执行了大量验证。例如
1 2 3 4 5 6 7 8 9 10 11 12 | public bool isValidDate(string date) { bool retVal = true; //check for 4 digit year throw new FourDigitYearRequiredException(); retVal = false; //check for leap years throw new NoFeb29InANonLeapYearException(); retVal = false; return retVal; } |
随着规则在数月和数年中不断累积,此代码往往非常脆弱且难以维护。我通常更喜欢将验证分解为返回bools的较小方法。这使得调整规则更容易。
1 2 3 4 5 6 7 8 9 10 11 | public bool isValidDate(string date) { bool retVal = false; retVal = doesDateContainAFourDigitYear(date); retVal = isDateInALeapYear(date); return retVal; } public bool isDateInALeapYear(string date){} public bool doesDateContainAFourDigitYear(string date){} |
如前所述,返回包含错误信息的错误结构/对象是一个好主意。最明显的优点是,您可以收集它们并立即向用户显示所有的错误消息,而不是让它们在验证过程中扮演"恶作剧"的角色。
一般来说,库抛出异常,客户机捕获异常,并用它们做一些明智的事情。对于用户输入,我只是编写验证函数,而不是抛出异常。对于类似的事情,例外似乎太多了。
除了例外,还有一些性能问题,但是在GUI代码中,您通常不必担心这些问题。那么,如果一个验证需要额外的100毫秒来运行呢?用户不会注意到这一点。
在某些方面,这是一个困难的调用——一方面,您可能不希望整个应用程序崩溃,因为用户在邮政编码文本框中输入了一个额外的数字,而您忘记了处理异常。另一方面,一种"及早失败,难失败"的方法可以确保快速发现和修复错误,并保持宝贵的数据库健全。一般来说,我认为大多数框架都建议您不要使用异常处理进行UI错误检查,有些框架(如.NET Windows窗体)提供了很好的方法来进行检查(错误提供程序和验证事件),没有异常。
我同时使用了两种解决方案:对于每个验证函数,我都会传递一个记录,其中包含验证状态(一个错误代码)。在函数的末尾,如果存在验证错误,我会抛出一个异常,这样就不会为每个字段抛出异常,而是只抛出一次。我还利用了引发异常将停止执行的优势,因为我不希望在数据无效时继续执行。
例如
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | procedure Validate(var R:TValidationRecord); begin if Field1 is not valid then begin R.Field1ErrorCode=SomeErrorCode; ErrorFlag := True; end; if Field2 is not valid then begin R.Field2ErrorCode=SomeErrorCode; ErrorFlag := True; end; if Field3 is not valid then begin R.Field3ErrorCode=SomeErrorCode; ErrorFlag := True; end; if ErrorFlag then ThrowException end; |
如果仅依赖布尔值,则使用my函数的开发人员应将此考虑在内,并编写:
1 2 | if not Validate() then DoNotContinue(); |
但他可能忘记了,只调用validate()(我知道他不应该,但他可能会)。
因此,在上面的代码中,我获得了两个优势:1-验证函数中只有一个异常。2-异常,即使未捕获,也会停止执行,并在测试时出现。
我同意米奇的观点,"例外情况不应用于正常控制流"。我只想从我在计算机科学课上的记忆中补充一点,捕获异常是昂贵的。我从来没有真正尝试过做基准测试,但是比较一下例如if/else和try/catch之间的性能是很有趣的。
使用异常的一个问题是倾向于一次只检测一个问题。用户修复并重新提交,结果发现了另一个问题!返回需要解决的问题列表的接口要友好得多(尽管它可以包装在异常中)。