异常或错误代码的约定

昨天,我和一位同事就错误报告的首选方法进行了激烈的讨论。我们主要讨论了异常或错误代码在应用程序层或模块之间报告错误的用法。

您使用什么规则来决定是抛出异常还是返回错误代码以进行错误报告?

66859 次浏览

我通常更喜欢异常,因为它们具有更多的上下文信息,并且能够以更清晰的方式向程序员传达(如果使用得当)错误。

另一方面,错误代码比异常更轻量级,但更难维护。错误检查可能在不经意间被忽略。错误代码更难维护,因为您必须保存包含所有错误代码的目录,然后打开结果以查看抛出了什么错误。在这里,错误范围可能有所帮助,因为如果我们唯一感兴趣的是是否存在错误,那么检查起来就更简单(例如,大于或等于0的 HRESULT T 错误代码是成功的,小于0的错误代码是失败的)。它们可能无意中被忽略,因为没有编程强制要求开发人员检查错误代码。另一方面,您不能忽略异常。

总而言之,在几乎所有情况下,我更喜欢异常而不是错误代码。

我更喜欢例外,因为

  • 它们打断了逻辑的流动
  • 它们受益于提供更多特性/功能的类层次结构
  • 如果使用得当,可以表示大范围的错误(例如,InvalidMethodCallException 也是 LogicException,因为这两种情况都发生在代码中有一个应该在运行时之前检测到的 bug 时) ,以及
  • 它们可以用来增强错误(例如,FileReadException 类定义可以包含检查文件是否存在或被锁定等的代码)

过去我加入了 errorcode 阵营(做了太多 C 编程) ,但现在我看到了曙光。

是的,异常对系统来说是一种负担。但是他们简化了代码,减少了错误(和卧槽)的数量。

因此,使用例外,但使用明智的。他们将是你的朋友。

作为附注。我已经学会记录哪个异常可以通过哪种方法抛出。不幸的是,大多数语言并不要求这样做。但它增加了在正确级别处理正确异常的机会。

方法签名应该向您传达方法的作用 Long errorCode = getErrorCode () ; 也许没问题,但是 Long errorCode = fetchRecord () ; 很让人困惑。

错误代码可以被忽略(而且经常被忽略!)函数的调用者。异常至少会迫使它们以某种方式处理错误。即使他们的版本处理它是有一个空的捕获处理程序(叹气)。

当您的方法返回数值以外的任何值时,错误代码也不起作用..。

对于大多数应用程序来说,异常更好。例外的情况是,软件必须与其他设备通信。我工作的领域是工业控制。这里错误代码是首选和预期的。所以我的回答是,这取决于具体情况。

对于错误代码的异常,毫无疑问。您可以从异常中获得与处理错误代码相同的好处,但是也可以获得更多,而且没有错误代码的缺点。唯一的例外是它的开销稍微多一点; 但是在当今这个时代,对于几乎所有的应用程序来说,这种开销应该被认为是可以忽略不计的。

下面是一些讨论、比较和对比这两种技术的文章:

这里有一些很好的链接,可以让你进一步阅读。

在高层次的东西,异常; 在低层次的东西,错误代码。

异常的默认行为是解除堆栈并停止程序如果我正在编写一个脚本并且我找到了一个字典中没有的关键字这可能是一个错误我希望程序停止并让我知道这一切。

然而,如果我正在写一段代码,我 必须知道的行为在每一个可能的情况下,那么我想要错误代码。否则,我必须知道每一个异常,可以抛出的每一行,我的函数,以了解它将做什么(阅读 禁飞航空公司的例外,以获得一个想法,这是多么棘手)。编写对每种情况(包括不愉快的情况)作出适当反应的代码是乏味和困难的,但这是因为编写无错代码是乏味和困难的,而不是因为传递错误代码。

两个 Raymond Chen 还有 乔尔已经提出了一些雄辩的论点,反对使用异常的一切。

我的理由是,如果您正在编写一个真正需要性能的低级驱动程序,那么可以使用错误代码。但是,如果您在较高级别的应用程序中使用该代码,并且它可以处理一些开销,那么就将该代码包装为一个接口,该接口检查这些错误代码并引发异常。

在所有其他情况下,例外可能是解决问题的方法。

我的方法是,我们可以同时使用两者,即异常和错误代码。

我用于定义几种类型的异常(例如: DataValidationException 或 ProcessInterrupException) ,并在每个异常中定义每个问题的更详细描述。

Java 中的一个简单例子:

public class DataValidationException extends Exception {




private DataValidation error;


/**
*
*/
DataValidationException(DataValidation dataValidation) {
super();
this.error = dataValidation;
}




}


enum DataValidation{


TOO_SMALL(1,"The input is too small"),


TOO_LARGE(2,"The input is too large");




private DataValidation(int code, String input) {
this.input = input;
this.code = code;
}


private String input;


private int code;


}

这样,我使用异常来定义类别错误,和错误代码来定义更详细的信息有关的问题。

我可能在犹豫不决,但是..。

  1. 这取决于语言。
  2. 无论选择哪种模型,都要始终如一地使用它。

在 Python 中,使用异常是标准做法,我很乐意定义自己的异常。C 语言中根本没有例外。

在 C + + 中(至少在 STL 中) ,通常只针对真正异常的错误抛出异常(我自己实际上从来没有看到过)。我认为没有理由在我自己的代码中做任何不同的事情。是的,忽略返回值很容易,但是 C + + 也不强迫您捕捉异常。我认为你只需要养成这样做的习惯。

我工作的代码基础大多是 C + + ,我们几乎在任何地方都使用错误代码,但是有一个模块会对任何错误产生异常,包括非常普通的错误,所有使用该模块的代码都非常糟糕。但这可能只是因为我们混合了异常和错误代码。始终使用错误代码的代码更容易使用。如果我们的代码始终使用异常,也许就不会那么糟糕了。把两者混合起来似乎效果不太好。

例外情况是在 很特别环境中-即,当它们不是正常代码流的一部分时。

将异常和错误代码混合使用是非常合理的,错误代码表示某个事物的状态,而不是代码本身运行中的错误(例如,检查来自子进程的返回代码)。

但是当异常情况发生时,我相信异常是最有表现力的模型。

在某些情况下,您可能更愿意或者必须使用错误代码来代替异常,而且这些已经被充分地覆盖了(除了其他明显的约束,例如编译器支持)。

但是从另一个角度来看,使用 Exceptions 可以为错误处理构建更高级别的抽象,从而使代码更具表现力和自然性。我强烈推荐阅读这篇由 C + + 专家 Andrei Alexandrescu 撰写的优秀但被低估的文章,文章的主题被他称为“增强”: http://www.ddj.com/cpp/184403864。虽然这是一篇 C + + 文章,但是这些原则通常是适用的,而且我已经成功地将增强的概念转换成了 C # 。

我永远不会混合这两个模型... 这是太难转换从一个到另一个,因为你从一个部分的堆栈是使用错误代码,更高的部分是使用异常。

例外情况是“任何阻止或抑制方法或子程序做你要求它做的事情的事情”... ... 不要传递关于不规则或异常情况,或系统状态等的消息。为此使用返回值或 ref (或 out)参数。

例外情况允许使用依赖于方法的函数的语义来编写(和利用)方法,也就是说,一个返回 Employee 对象的方法或者一个员工列表可以被键入来做这件事,你可以通过调用。

Employee EmpOfMonth = GetEmployeeOfTheMonth();

对于错误代码,所有的方法都返回一个错误代码,因此,对于那些需要返回其他东西以供调用代码使用的方法,您必须传递一个引用变量以填充该数据,并测试错误代码的返回值,并在每个函数或方法调用中处理它。

Employee EmpOfMonth;
if (getEmployeeOfTheMonth(ref EmpOfMonth) == ERROR)
// code to Handle the error here

如果您编写的代码使得每个方法只做一件简单的事情,那么当方法不能完成方法期望的目标时,您应该抛出异常。异常比错误代码更丰富,也更容易以这种方式使用。你的代码要干净得多——“正常”代码路径的标准流程可以严格地用于方法能够完成你想要它做的事情的情况... ... 然后,当发生阻碍方法成功完成的坏事情时,清理或处理“异常”情况的代码可以与正常代码隔离开来。另外,如果你不能处理发生异常的地方,并且必须把异常传递到堆栈中的 UI,(或者更糟糕的是,从一个中间层组件传递到一个 UI) ,那么使用异常模型,你不需要编写堆栈中的每一个中间方法来识别异常并将异常传递到堆栈中... ... 异常模型自动为你做这些... ..。有了错误代码,这一块拼图可以变得非常繁重迅速。

因为我使用 C + + ,并且使用 RAII 使它们可以安全使用,所以我几乎完全使用异常。它将错误处理从正常的程序流中抽离出来,并使意图更加清晰。

但我也会在特殊情况下留下例外。如果我预期某个错误会经常发生,我会在执行之前检查操作是否成功,或者调用使用错误代码的函数版本(如 TryParse())

对于所有的错误情况,我更倾向于使用 Exception,除非故障是返回基本数据类型的函数的可预期的无错误结果。例如,在较大的字符串中查找子字符串的索引,如果没有找到,通常返回 -1,而不是引发 NotFoundException。

返回可能被解引用的无效指针(例如,在 Java 中导致 NullPointerException)是不可接受的。

使用多个不同的数字错误代码(- 1,-2)作为同一函数的返回值通常是不好的风格,因为客户端可能执行“ = = -1”检查而不是“ < 0”检查。

这里需要记住的一件事是 API 随着时间的推移而发展。一个好的 API 允许以多种方式更改和扩展失败行为,而不会破坏客户端。例如,如果一个客户端错误句柄检查了4个错误案例,并且您向函数添加了第五个错误值,那么客户端句柄可能不会测试这个错误并中断。如果引发异常,通常会使客户机更容易迁移到库的更新版本。

另一件需要考虑的事情是,当在一个团队中工作时,应该在哪里为所有的开发人员划出一条清晰的界限来做出这样的决定。例如“高层次的例外,低层次的错误代码”是非常主观的。

在任何情况下,如果可能出现多种类型的错误,源代码都不应该使用数字字面值来返回错误代码或处理它(如果 x = = -7,则返回 -7...) ,而应该始终使用命名常量(如果 x = = NO _ SUCH _ FOO,则返回 NO _ SUCH _ FOO)。

如果您在大型项目下工作,则不能只使用异常或错误代码。在不同的情况下,您应该使用不同的方法。

例如,您决定只使用异常。但是一旦您决定使用异步事件处理。在这种情况下,使用异常进行错误处理是不明智的。但是在应用程序的任何地方使用错误代码都是乏味的。

因此,我认为同时使用异常和错误代码是正常的。

可能有一些情况下,以一种干净、清晰、正确的方式使用异常是很麻烦的,但绝大多数时候异常是显而易见的选择。异常处理优于错误代码的最大好处是它改变了执行流程,这一点很重要,原因有二。

当发生异常时,应用程序不再遵循它的“正常”执行路径。这一点之所以如此重要,第一个原因是,除非代码的作者能够很好地、真正地摆脱糟糕的状态,否则程序将会停止运行,而不会继续做不可预测的事情。如果没有检查错误代码,也没有对错误代码采取适当的操作,那么程序将继续执行它正在执行的操作,谁知道这个操作的结果是什么。在很多情况下,让程序做“随便什么”可能会变得非常昂贵。考虑一个程序,该程序检索公司销售的各种金融工具的绩效信息,并将这些信息提供给经纪人/批发商。如果出现问题并且程序继续运行,它可能会向经纪人和批发商发送错误的性能数据。我不知道其他人怎么想,但我不想坐在副总裁办公室里解释为什么我的代码导致公司被罚款7位数。向客户传递错误信息通常比传递看起来“真实”的错误数据更可取,而后一种情况更容易发生,比如使用错误代码等不那么激进的方法。

我喜欢异常和它们破坏正常执行的第二个原因是,它使得将“正常事情正在发生”的逻辑与“某些事情出错了”的逻辑分开变得非常、非常容易。对我来说:

try {
// Normal things are happening logic
catch (// A problem) {
// Something went wrong logic
}

比这个更好:

// Some normal stuff logic
if (errorCode means error) {
// Some stuff went wrong logic
}
// Some normal stuff logic
if (errorCode means error) {
// Some stuff went wrong logic
}
// Some normal stuff logic
if (errorCode means error) {
// Some stuff went wrong logic
}

关于异常还有一些其他的小事情也是很好的。使用一堆条件逻辑来跟踪函数中调用的任何方法是否返回了错误代码,并将该错误代码返回到更高的位置,这是一个繁琐的工作。事实上,很多锅炉板都可能出错。我对大多数语言的异常系统的信心远远超过我对“大学刚毕业”的弗雷德写的一堆“如果还有别的可能”的陈述的信心,而且我有很多更好的事情可以做,而不是对代码进行审查。

我认为这还取决于您是否真正需要结果中的堆栈跟踪等信息。如果是的话,你肯定会选择 Exception,它提供了对象充分的问题信息。但是,如果您只是对结果感兴趣,并且不关心为什么会出现这种结果,那么可以使用错误代码。

例如:。当你处理文件和面临 IOException,客户端可能有兴趣知道这是从哪里触发,在打开文件或解析文件等。所以最好返回 IOException 或其特定的子类。但是,像你有登录方法,你想知道它是否成功,在那里你只需要返回布尔值或显示正确的消息,返回错误代码。在这里,客户端对于知道哪一部分逻辑导致了错误代码不感兴趣。他只知道是否其凭证无效或帐户锁定等。

我能想到的另一个用例是当数据在网络上传输时。远程方法可以只返回错误代码而不返回 Exception,以最小化数据传输。

首先,我同意汤姆的 回答,对于高级别的东西使用异常,对于低级别的东西使用错误代码,只要它不是面向服务的体系结构(SOA)。

在 SOA 中,方法可以跨不同的机器调用,异常可能不会通过网络传递,相反,我们使用成功/失败响应,其结构如下(C #) :

public class ServiceResponse
{
public bool IsSuccess => string.IsNullOrEmpty(this.ErrorMessage);


public string ErrorMessage { get; set; }
}


public class ServiceResponse<TResult> : ServiceResponse
{
public TResult Result { get; set; }
}

像这样使用:

public async Task<ServiceResponse<string>> GetUserName(Guid userId)
{
var response = await this.GetUser(userId);
if (!response.IsSuccess) return new ServiceResponse<string>
{
ErrorMessage = $"Failed to get user."
};
return new ServiceResponse<string>
{
Result = user.Name
};
}

当这些在您的服务响应中一致地使用时,它将创建一个非常好的模式来处理应用程序中的成功/失败。这使得在服务内以及跨服务的异步调用中更容易处理错误。

我的一般原则是:

  • 函数中只能出现一个错误: 使用错误代码(作为函数的参数)
  • 可能会出现一个以上的特定错误: 抛出异常

你应该同时使用它们,问题是什么时候使用它们。

有一个 很少有例外是显而易见的选择:

  1. 在某些情况下,您不能对错误代码进行任何操作,你只是 需要在调用堆栈的上层处理它,通常只是记录错误,显示一些东西给用户或关闭程序。在这些情况下,错误代码将要求您手动逐级显示错误代码,这显然更容易处理异常。关键是,这是为 意想不到的,无法处理的的情况。

  2. 然而,对于情况1(发生了一些意想不到的和无法处理的事情,您只是不想记录它) ,异常可能是有帮助的,因为您可能需要 添加上下文信息。例如,如果我在底层数据助手中得到了一个 SqlException 异常,我想要在底层捕获这个错误(在这里我知道导致错误的 SQL 命令) ,这样我就可以用额外的信息捕获这个信息 然后再扔。请注意这里的魔术词: 重新扔掉,而不是吞下去异常处理的第一条规则: 不接受例外情况 。另外,请注意,我的内部 catch 不需要记录任何内容,因为外部 catch 将拥有整个堆栈跟踪并可能记录它。

  3. 在某些情况下,您有一系列命令,而 如果其中任何一个失败了应该是 清理/处置资源(*) ,无论这是不可恢复的情况(应该抛出)还是可恢复的情况(在这种情况下,您可以在本地或在调用者代码中处理,但不需要异常)。显然,将所有这些命令放在一次尝试中要容易得多,而不是在每个方法之后测试错误代码,并在 finally 块中清除/处理。请注意,如果您希望错误冒泡出现(这可能是您想要的) ,您甚至不需要捕获它-您只需使用 finally 进行清理/处理-如果你想添加上下文信息,你应该只使用 catch/rolw (见项目符号2)。

    一个例子是事务块中的 SQL 语句序列。同样,这也是一个“无法处理”的情况,即使你决定尽早抓住它(在本地治疗,而不是冒泡到顶部) ,它仍然是一个 致命的情况,最好的结果是放弃一切,或者至少放弃过程的一大部分。
    (*)这就像我们在旧版 VisualBasic 中使用的 on error goto

  4. 在构造函数中,只能引发异常。

话虽如此,在所有其他情况下,如果要返回关于哪个 呼叫者可以/应该采取一些行动的某些信息,使用返回代码可能是更好的选择。这包括所有的 预期的“错误”,因为它们可能应该由直接调用方处理,并且几乎不需要在堆栈中冒出太多级别。

当然,总是有可能将预期的错误视为异常,然后立即捕获上一级别的错误,也有可能包含 try catch 中的每一行代码,并对每一个可能的错误采取措施。我的天,这是一个糟糕的设计,不仅因为它更加冗长,而且特别是因为在没有阅读源代码的情况下,可能抛出的异常并不明显——而且异常可以从任何深层次的方法抛出,从而创建 < strong > 看不见的 Gotos 。它们通过创建多个不可见的出口点来破坏代码结构,这些出口点使代码难以阅读和检查。换句话说,您永远不应该使用 例外情况如 < a href = “ https://softwareengineering ering.stackexchange.com/questions/189222/are- 异常-as-control-flow-thinking-a-嚴-antipattern-if-so-why”> flow-control ,因为这将很难让其他人理解和维护。理解所有可能的测试代码流会变得更加困难。
再说一遍: 对于正确的清理/处理,您可以使用 try-finally,但不捕获任何内容

对于返回代码最流行的批评是: “有人可能会忽略错误代码,但在同样的意义上,有人也可能吞下异常。在这两种方法中,糟糕的异常处理都很容易。但是,编写好的基于错误代码的程序仍然比编写基于异常的程序容易得多.如果有人因为某种原因决定忽略所有错误(旧的 on error resume next) ,那么可以很容易地用返回代码做到这一点,如果没有大量的 try-catch 样板文件,则无法做到这一点。

关于返回代码的第二个最流行的批评是“很难冒泡”——但那是因为人们不明白异常是针对不可恢复的情况的,而错误代码则不是。

决定异常和错误代码是一个灰色地带。甚至可能需要从某个可重用的业务方法获取错误代码,然后决定将其封装到异常中(可能会添加信息) ,并让它冒泡出现。但是,假设所有错误都应该作为异常抛出是一个设计错误。

总结一下:

  • 当我遇到意想不到的情况时,我喜欢使用异常,在这种情况下没有什么可做的,通常我们希望中止一个大的代码块,甚至整个操作或程序。这就像古老的“ on error goto”。

  • 我喜欢使用返回代码,当我有预期的情况下,调用者代码可以/应该采取一些行动。这包括大多数业务方法、 API、验证等。

异常和错误代码之间的这种差异是 GO 语言的设计原则之一,GO 语言对致命的意外情况使用“惊慌”,而常规的预期情况作为错误返回。

然而关于 GO,它还允许使用 多个返回值,这对使用返回代码有很大帮助,因为您可以同时返回一个错误和其他内容。在 c #/Java 中,我们可以在没有参数的情况下实现这一点,比如 Tuples,或者(我最喜欢的)泛型(Generics) ,它们与枚举相结合,可以为调用者提供明确的错误代码:

public MethodResult<CreateOrderResultCodeEnum, Order> CreateOrder(CreateOrderOptions options)
{
....
return MethodResult<CreateOrderResultCodeEnum>.CreateError(CreateOrderResultCodeEnum.NO_DELIVERY_AVAILABLE, "There is no delivery service in your area");


...
return MethodResult<CreateOrderResultCodeEnum>.CreateSuccess(CreateOrderResultCodeEnum.SUCCESS, order);
}


var result = CreateOrder(options);
if (result.ResultCode == CreateOrderResultCodeEnum.OUT_OF_STOCK)
// do something
else if (result.ResultCode == CreateOrderResultCodeEnum.SUCCESS)
order = result.Entity; // etc...

如果在方法中添加一个新的可能返回值,我甚至可以检查所有调用者是否在 switch 语句中覆盖了该新值。你真的不能在例外的情况下这么做。当您使用返回代码时,通常会提前知道所有可能的错误,并对其进行测试。除了例外情况,你通常不知道会发生什么。在异常中包装枚举(而不是泛型)是另一种选择(只要清楚每个方法将抛出的异常的类型) ,但是 IMO 仍然是糟糕的设计。

EDIT 2020-10-11 :

由于 C # 7.0(2017年3月)而不是泛型,我更喜欢使用 新的元组语法,它允许多个返回值(因此我们可以使用类 GO 的语法,其中方法返回一个结果或一个错误)。


public enum CreateUserResultCodeEnum
{
[Description("Username not available")]
NOT_AVAILABLE,
}


public (User user, CreateUserResultCodeEnum? error) CreateUser(string userName)
// (try to create user, check if not available...)
if (notAvailable)
return (null, CreateUserResultCodeEnum.NOT_AVAILABLE);
return (user, null);
}


// How to call and deconstruct tuple:
(var user, var error) = CreateUser("john.doe");
if (user != null) ...
if (error == CreateUserResultCodeEnum.NOT_AVAILABLE) ...


// Or returning a single object (named tuple):
var result = CreateUser("john.doe");
if (result.user != null) ...
if (result.error == CreateUserResultCodeEnum.NOT_AVAILABLE) ...

EDIT 2021-01-09 :

几天前关于 我写了这篇博客我们怎样才能(在某些情况下!)使用多个返回而不是异常(就像上面解释的 golang 约定,不应该替换所有的异常,而是应该给你一个武器库来决定何时使用异常和何时使用返回代码)。 在文章的最后,我混合了两个模型-基本上我使用 ValueTuple 语法(非常简洁和优雅) ,但仍然使用 Generics作为底层结构。 基本上,我使用 隐式转换运算符类型解析器类型解析器ValueTupleCommandResult<TEntity, TError>之间来回转换。