检查与未检查与异常……相反信念的最佳实践


10

系统正确传达和处理异常有很多要求。还有多种语言可供选择以实现这一概念。

例外要求(无特定顺序):

  1. 文档:一种语言应具有文档API可能引发的异常的含义。理想情况下,此文档介质应可在机器上使用,以允许编译器和IDE为程序员提供支持。

  2. 传输异常情况:显然,这允许功能传达阻止被调用功能执行预期动作的情况。我认为这类情况分为三大类:

    2.1代码中的错误导致某些数据无效。

    2.2配置或其他外部资源问题。

    2.3本质上不可靠的资源(网络,文件系统,数据库,最终用户等)。这些有点极端,因为它们不可靠的本性应该让我们期待它们的零星失败。在这种情况下,是否将这些情况视为例外?

  3. 为代码提供足够的信息来处理它:异常应向被调用者提供足够的信息,以便被调用者可以做出反应并可能处理这种情况。这些信息也应该足够,以便在记录该异常时将为程序员提供足够的上下文,以识别和隔离有问题的语句并提供解决方案。

  4. 向程序员提供有关其代码执行状态的当前状态的信心:软件系统的异常处理能力应足够强,以提供所需的防护措施,同时又不影响程序员,因此他可以专注于执行以下任务:手。

为涵盖这些内容,以下方法已以多种语言实现:

  1. 受检查的异常 提供了记录异常的好方法,并且从理论上讲,如果正确实施,应该可以确保一切都很好。但是,这样做的代价是,许多人认为通过吞下异常或将它们作为未检查的异常重新抛出来绕过它更具生产力。如果使用了不适当的检查异常,则几乎失去了它的全部用处。同样,检查异常使创建时间稳定的API变得困难。在特定域内实现通用系统将带来特殊情况的负担,这些特殊情况将变得难以使用单独检查的异常来维护。

  2. 未检查的异常 -比检查的异常通用得多,它们无法正确记录给定实现的可能异常情况。他们完全依赖临时文档。这就造成了一种情况,即介质的不可靠特性被提供可靠性外观的API所掩盖。同样,当抛出这些异常时,它们会在抽象层中向上移动,从而失去其含义。由于它们的文献记录不充分,因此程序员无法专门针对它们,并且经常需要投放比必要范围更广的网络,以确保辅助系统(如果发生故障)不会导致整个系统崩溃。这使我们又回到了吞咽问题检查提供的异常。

  3. 多状态返回类型 在这里,它依赖于不相交的集合,元组或其他类似的概念来返回预期结果或代表异常的对象。这里没有堆栈展开,没有代码切入,一切正常执行,但是必须在继续之前验证返回值是否存在错误。我还没有真正使用它,因此无法从经验中发表评论,我承认它解决了绕过正常流程的异常的一些问题,但是仍然会遇到与已检查的异常几乎相同的问题,既麻烦又不断地“面对您”。

所以问题是:

您在这方面的经验是什么?根据您的看法,您是为语言提供良好的异常处理系统的最佳人选?


编辑:写完这个问题后几分钟,我遇到了这个帖子,怪异的!


2
“它与被检查的异常一样会遭受同样的麻烦,并且令人厌烦并不断出现在您的脸上”:并非如此:有了适当的语言支持,您只需要编写“成功之路”,底层的语言机制就可以处理传播问题错误。
Giorgio 2014年

“一种语言应该有手段来记录API可能引发的异常。” -weeeel。在C ++中,“我们”了解到这实际上不起作用。所有你真正有效地做的是状态的API是否可以抛出任何异常。(这确实缩短了一个长话,但我认为,noexcept以C ++来看故事也可以为C#和Java中的EH带来非常好的见解。)
Martin Ba

Answers:


10

在C ++的早期,我们发现如果没有某种通用编程,强类型语言将非常笨拙。我们还发现,检查异常和通用编程不能很好地协同工作,而检查异常实际上已被放弃。

多集返回类型很棒,但是不能替代异常。毫无例外,该代码充满了错误检查噪声。

与检查的异常有关的另一个问题是,由低级函数引发的异常更改会强制所有调用者及其调用者中的级联更改,以此类推。防止这种情况的唯一方法是让每个级别的代码都捕获由较低级别抛出的任何异常并将它们包装在新的异常中。同样,您最终会得到非常嘈杂的代码。


2
泛型确实有助于解决一整类错误,这主要是由于该语言对OO范例的支持受到限制。不过,替代方案似乎是要么要么执行大多数代码来进行错误检查,要么运行以防止任何错误。您要么经常遇到特殊情况,要么生活在梦想中的蓬松白色兔子中,当您在中间放下一只大灰狼时,它们变得丑陋!
Newtopian 2011年

3
+1为级联问题。任何使变更变得困难的系统/体系结构都只会导致猴子补丁和混乱的系统,无论作者认为它们的设计多么出色。
Matthieu M.

2
@Newtopian:模板在严格的面向对象上做不到的事情,例如为通用容器提供静态类型安全。
David Thornley

2
我希望看到一个带有“检查的异常”概念的异常系统,但是它与Java的体系非常不同。Check-ness不应是异常类型的属性,而应该是抛出站点,捕获站点和异常实例;如果一个方法被宣告为抛出一个检查异常,则应具有两个效果:(1)该函数应通过在返回时做一些特殊的事情(例如,设置进位标志等)来处理“抛出”检查异常。确切的平台)将需要为其准备调用代码。
2013年

7
“毫无例外,代码中充满了错误检查噪声。”:我对此不确定:在Haskell中,您可以使用monad来解决所有错误检查噪声。由“多状态返回类型”引入的噪声更多地是编程语言的限制,而不是解决方案本身的限制。
Giorgio

9

长期以来,OO语言中,使用异常一直是传达错误的实际标准。但是函数式编程语言提供了采用其他方法的可能性,例如使用monad(我从未使用过),或者使用更轻量的“面向铁路的编程”,如Scott Wlaschin所述。

它实际上是多状态结果类型的变体。

  • 函数返回成功或错误。它不能同时返回两者(就像元组一样)。
  • 简要记录了所有可能的错误(至少在F#中,结果类型为可区分的并集)。
  • 如果结果是成功还是失败,调用者将无法使用结果。

结果类型可以这样声明

type Result<'TSuccess,'TFail> =
| Success of 'TSuccess
| Fail of 'TFail

因此,返回此类型的函数的结果将是a SuccessFailtype。不能两者兼有。

在面向命令式的编程语言中,这种样式可能会在调用者站点上需要大量代码。但是函数式编程允许您构造绑定函数或运算符以将多个函数绑定在一起,因此错误检查不会占用一半的代码。举个例子:

// Create an updateUser function that takes an id, and new state
// as input, and updates an existing user.
let updateUser id input =
    validateInput input
    >>= loadUser id
    >>= updateUser input
    >>= saveUser id
    >>= notifyAboutUserUpdated

updateUser函数依次调用这些函数中的每一个,并且每个函数都可能失败。如果它们全部成功,则返回最后一个调用函数的结果。如果其中一个功能失败,则该功能的结果将是整个updateUser功能的结果。这全部由自定义>> =运算符处理。

在上面的示例中,错误类型可能是

type UserValidationErrorType =
| InvalidEmail of string
| MissingFirstName of string
... etc

type DbErrorType =
| RecordNotFound of int
| ConcurrencyError of int

type UpdateUserErrorType =
| InvalidInput of UserValidationErrorType
| DbError of DbErrorType

如果的调用者updateUser未明确处理函数中所有可能的错误,则编译器将发出警告。因此,您已记录了所有内容。

在Haskell中,有一种do符号可以使代码更加清晰。


2
很好的答案和参考(面向铁路的编程),+ 1。您可能需要提及Haskell的do符号,这使生成的代码更加清晰。
Giorgio

1
@Giorgio-我现在做了,但是我没有和Haskell一起工作,只有F#,所以我不能为此写很多东西。但是您可以根据需要添加答案。
皮特2014年

谢谢,我写了一个小例子,但是由于它不够小,无法添加到您的答案中,所以我写了一个完整的答案(带有一些额外的背景信息)。
Giorgio 2014年

2
Railway Oriented Programming正是一元的行为。
丹妮丝

5

我认为Pete的回答非常好,我想补充一些考虑和一个例子。关于异常使用和返回特殊错误值的非常有趣的讨论,可以在Robert Harper的《标准ML编程》(第29.3节,第243页,第244页)末尾找到。

问题是要实现一个f返回某些类型值的局部函数t。一种解决方案是使功能具有类型

f : ... -> t

并在没有可能的结果时引发异常。第二种解决方案是使用类型实现函数

f : ... -> t option

SOME v在成功和NONE失败中获得回报。

这是本书的文字,我本人做了一些改动以使文字更笼统(本书指的是特定示例)。修改后的文本以斜体字书写。

两种解决方案之间的权衡是什么?

  1. 基于选项类型的解决方案在函数类型中明确指出f了发生故障的可能性。这迫使程序员使用对调用结果的案例分析来明确测试失败。类型检查器将确保不能t optiont期望使用a的地方使用 。基于异常的解决方案未明确指示其类型失败。但是,程序员仍然被迫处理故障,否则将在运行时而不是编译时引发未捕获的异常错误。
  2. 基于选项类型的解决方案需要对每个调用的结果进行明确的案例分析。如果“大多数”结果成功,则检查是多余的,因此成本过高。基于异常的解决方案没有这种开销:它偏向于返回a的“正常”情况t,而不是根本不返回结果的“失败”情况。在失败与成功相比很少的情况下,异常的实现可确保处理程序的使用比显式案例分析更为有效。

[切]通常,如果效率至高无上,那么当失败很少时,我们倾向于选择例外,而如果失败相对普遍,我们倾向于选择选择。另一方面,如果静态检查是最重要的,则使用选项是有利的,因为类型检查器将强制要求程序员检查故障,而不是仅在运行时出现错误。

就异常和选项返回类型之间的选择而言。

关于以返回类型表示错误会导致错误检查遍及整个代码的想法:不必是这种情况。这是Haskell中的一个小例子,说明了这一点。

假设我们要解析两个数字,然后将第一个除以第二个。因此,解析每个数字或除(除以零)时可能会出错。因此,我们必须在每个步骤之后检查错误。

import Text.Read

parseInt :: String -> Maybe Int
parseInt s = readMaybe s :: Maybe Int

safeDiv :: Int -> Int -> Maybe Int
safeDiv n d = if d /= 0 then Just (n `div` d) else Nothing

toString :: Maybe Int -> String
toString (Just i) = show i
toString Nothing  = "error"

main = do
         -- Get two lines from the terminal.
         nStr <- getLine
         dStr <- getLine

         -- Parse each string and divide.
         let r = do n <- parseInt nStr
                    d <- parseInt dStr
                    safeDiv n d

         -- Print the result.
         putStrLn $ toString r

解析和除法在该let ...块中执行。请注意,通过使用Maybemonad和do表示法,仅指定成功路径:Maybemonad 的语义隐式传播错误值(Nothing)。程序员没有任何开销。


2
我认为在这种情况下,如果您想打印某种有用的错误消息,则该Either类型将更为合适。你到Nothing这里怎么办?您只收到消息“错误”。对调试不是很有帮助。
萨拉

1

我已经成为Checked Exceptions的忠实拥护者,我想分享我何时使用它们的一般规则。

我得出的结论是,我的代码基本上必须处理两种类型的错误。在执行代码之前存在可测试的错误,在执行代码之前存在不可测试的错误。在NullPointerException中执行代码之前可以测试的错误的简单示例。

//... bad code below.  the runnable variable
// tries to call the run() method before the variable
// is instantiated.  Running the code below will cause
// a NullPointerException.
Runnable runnable = null;
runnable.run();

一个简单的测试可以避免这样的错误,例如...

Runnable runnable = null;
...
if (runnable != null)
{   runnable.run(); }

在计算中,有些时候您可以在执行代码之前运行1个或多个测试,以确保您的安全,并且仍然会感到意外。例如,您可以测试文件系统,以确保在将数据写入驱动器之前硬盘上有足够的磁盘空间。在多处理操作系统中(例如今天使用的操作系统),您的进程可以测试磁盘空间,并且文件系统将返回一个值,表明有足够的空间,然后上下文切换到另一个进程可以写入可用于操作的剩余字节系统。当操作系统上下文切换回正在运行的进程(将内容写入磁盘)时,仅由于文件系统上没有足够的磁盘空间,才会发生异常。

我认为上述情况是Checked Exception的完美案例。这是代码中的异常,即使您的代码可以完美编写,它也会迫使您处理一些不好的事情。如果您选择做“吞下异常”之类的坏事情,那么您就是不好的程序员。顺便说一下,我发现吞下该异常是合理的情况,但是请在代码中留下有关吞下该异常的原因的注释。不能怪异常处理机制。我经常开玩笑说,我希望我的心脏起搏器使用具有“检查异常”的语言编写。

有时很难确定代码是否可测试。例如,如果您正在编写解释器,并且由于某些语法原因而导致代码执行失败,则抛出SyntaxException,那么SyntaxException应该是Checked Exception还是(在Java中)RuntimeException?我会回答,如果解释器在执行代码之前检查了代码的语法,则Exception应该是RuntimeException。如果解释器仅运行代码“ hot”并仅遇到语法错误,我想说该异常应该是Checked Exception。

我会承认我并不总是很高兴必须捕获或引发Checked Exception,因为有时我不确定该怎么做。Checked Exception是一种强制程序员注意可能发生的潜在问题的方法。我用Java编程的原因之一是因为它具有Checked Exceptions。


1
我宁愿我的心脏起搏器使用的语言完全没有异常,并且所有代码行都通过返回代码来处理错误。引发异常时,您说的是“一切都出错了”,继续处理的唯一安全方法是停止并重新启动。如此容易以无效状态结束的程序并不是关键软件所需要的(并且Java明确禁止在EULA中将其用于关键软件)
gbjbaanb 2015年

使用异常而不检查它们与使用返回码并且最后不检查它们,都将产生相同的心脏骤停。
Newtopian

-1

我目前正处在一个基于OOP的大型项目/ API的中间,并且使用了这种异常布局。但这实际上取决于您要对异常处理等进行深入研究。

ExpectedException
-AuthorisedException
-EmptySetException
-NoRemainingException
-NoRowsException
-NotFoundException
-ValidationException

UnexpectedException
-ConnectivityException
-EnvironmentException
-ProgrammerException
-SQLException

   $valid_types = array('mysql', 'oracle', 'sqlite');
       if (!in_array($type, $valid_types)) {
           throw new ecProgrammerException(
        'The database type specified, %1$s, is invalid. Must be one of: %2$s.',
    $type,
    join(', ', $valid_types)
    );
}

11
如果可以预料到例外,那实际上不是例外。“ NoRowsException”?听起来像控制流传给我,因此很少使用异常。
2011年

1
@qes:每当函数无法计算值时都会引发异常,例如double Math.sqrt(double v)或User findUser(long id)。这使调用者可以在方便的地方自由地捕获和处理错误,而不必在每次调用后进行检查。
凯文·克莱恩

1
预期=控制流=异常的反模式。异常不应用于控制流。如果期望为特定的输入产生错误,则只将其作为返回值的一部分传递。所以我们有NANNULL
Eonil

1
@Eonil ...或Option <T>
Maarten Bodewes 2014年
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.