昨天,我与一位同事就首选的错误报告方法进行了激烈的辩论。我们主要讨论了在应用层或模块之间报告错误时使用异常或错误代码。
您使用什么规则来决定是抛出异常还是返回错误代码以进行错误报告?
昨天,我与一位同事就首选的错误报告方法进行了激烈的辩论。我们主要讨论了在应用层或模块之间报告错误时使用异常或错误代码。
您使用什么规则来决定是抛出异常还是返回错误代码以进行错误报告?
在高级别的东西中,例外;在低级的东西中,错误代码。
异常的默认行为是展开堆栈并停止程序,如果我正在编写一个脚本并且我去寻找一个不在字典中的键,这可能是一个错误,我希望程序停止并让我知道这一切。
但是,如果我正在编写一段代码,我必须知道在所有可能的情况下的行为,那么我需要错误代码。否则,我必须知道函数中的每一行可能引发的每个异常,才能知道它会做什么(阅读The Exception That Grounded an Airline以了解这是多么棘手)。编写对每种情况(包括不愉快的情况)做出适当反应的代码既乏味又困难,但这是因为编写无错误代码既乏味又困难,而不是因为您传递了错误代码。
Raymond Chen 和 Joel都提出了一些雄辩的论点,反对对所有事情都使用异常。
我通常更喜欢异常,因为它们有更多的上下文信息,并且可以(在正确使用时)以更清晰的方式将错误传达给程序员。
另一方面,错误代码比异常更轻量级,但更难维护。错误检查可能会无意中被忽略。错误代码更难维护,因为您必须保留包含所有错误代码的目录,然后打开结果以查看引发了什么错误。错误范围在这里可能会有所帮助,因为如果我们唯一感兴趣的是我们是否存在错误,则检查起来更简单(例如,大于或等于 0 的 HRESULT 错误代码是成功的,并且小于零为失败)。它们可能会无意中被忽略,因为没有编程强制开发人员检查错误代码。另一方面,您不能忽略异常。
总而言之,在几乎所有情况下,我更喜欢异常而不是错误代码。
我更喜欢例外,因为
函数的调用者可以忽略错误代码(而且通常是!)。异常至少迫使他们以某种方式处理错误。即使他们处理它的版本是有一个空的 catch 处理程序(叹气)。
错误代码的异常,毫无疑问。您可以从异常中获得许多与使用错误代码相同的好处,但还可以获得更多好处,而没有错误代码的缺点。唯一的例外是它的开销略大。但在当今时代,几乎所有应用程序的开销都可以忽略不计。
以下是一些讨论、比较和对比这两种技术的文章:
有一些很好的链接可以让你进一步阅读。
我永远不会混合这两种模型......当您从堆栈的一个使用错误代码的部分移动到使用异常的更高部分时,从一个模型转换到另一个模型太难了。
例外情况是“任何停止或禁止方法或子例程执行您要求它执行的操作”...不要将有关异常情况或异常情况或系统状态等的消息传回。使用返回值或参考(或输出)参数。
异常允许使用依赖于方法函数的语义来编写(和使用)方法,即可以键入返回员工对象或员工列表的方法来执行此操作,并且您可以通过调用来使用它。
Employee EmpOfMonth = GetEmployeeOfTheMonth();
对于错误代码,所有方法都返回一个错误代码,因此,对于那些需要返回其他内容以供调用代码使用的方法,您必须传递一个引用变量以填充该数据,并测试返回值错误代码,并在每个函数或方法调用上处理它。
Employee EmpOfMonth;
if (getEmployeeOfTheMonth(ref EmpOfMonth) == ERROR)
// code to Handle the error here
如果您的编码使每个方法只做一件简单的事情,那么只要该方法无法实现该方法的预期目标,您就应该抛出异常。以这种方式,异常比错误代码更丰富、更易于使用。您的代码更清晰 - “正常”代码路径的标准流程可以严格用于方法能够完成您想要它做的事情的情况......然后清理代码或处理当发生阻止方法成功完成的不良情况时,可以将“异常”情况与正常代码隔离开来。此外,如果您无法处理发生的异常,并且必须将其通过堆栈传递到 UI,(或者更糟糕的是,从中间层组件到 UI),那么使用异常模型,
你应该同时使用两者。事情是决定何时使用每一个。
在某些情况下,例外是显而易见的选择:
在某些情况下,你不能对错误代码做任何事情,你只需要在调用堆栈的上层处理它,通常只是记录错误,向用户显示一些内容或关闭程序。在这些情况下,错误代码将要求您手动逐级冒泡错误代码,这显然更容易处理异常。关键是这是针对意外和无法处理的情况。
然而,关于情况 1(发生意外和无法处理的事情,您只是不想记录它),异常可能会有所帮助,因为您可能会添加上下文信息。例如,如果我在较低级别的数据帮助程序中遇到 SqlException,我将希望在低级别(我知道导致错误的 SQL 命令)中捕获该错误,以便我可以捕获该信息并重新抛出附加信息. 请注意这里的神奇词:重新投掷,而不是吞下。 异常处理的第一条规则:不要吞下异常。另外,请注意,我的内部捕获不需要记录任何内容,因为外部捕获将具有整个堆栈跟踪并可能会记录它。
在某些情况下,您有一系列命令,如果其中任何一个命令失败,您应该清理/处置资源(*),无论这是不可恢复的情况(应该抛出)还是可恢复的情况(在这种情况下,您可以在本地或在调用者代码中处理,但您不需要异常)。显然,将所有这些命令放在一次尝试中要容易得多,而不是在每个方法之后测试错误代码,然后在 finally 块中进行清理/处置。请注意,如果您希望错误冒泡(这可能是您想要的),您甚至不需要捕获它 - 您只需使用 finally 进行清理/处置- 如果您愿意,您应该只使用 catch/retrow添加上下文信息(参见项目符号 2)。
一个示例是事务块内的一系列 SQL 语句。同样,这也是一个“无法处理”的情况,即使您决定及早发现它(在本地处理而不是冒泡到顶部),它仍然是一个致命的情况,最好的结果是中止一切或至少中止一个大过程的一部分。
(*) 这就像on error goto
我们在旧的 Visual Basic 中使用的那样
在构造函数中,您只能抛出异常。
话虽如此,在所有其他情况下,如果您返回一些信息,调用者可以/应该采取一些行动,使用返回码可能是更好的选择。这包括所有预期的“错误”,因为它们可能应该由直接调用者处理,并且几乎不需要在堆栈中向上冒泡太多级别。
当然,总是可以将预期的错误视为异常,然后立即捕获上一层,也可以在 try catch 中包含每一行代码,并对每个可能的错误采取措施。IMO,这是一个糟糕的设计,不仅因为它更加冗长,而且特别是因为如果不阅读源代码,可能引发的异常并不明显 - 并且可以从任何深层方法引发异常,从而创建不可见的 gotos。它们通过创建多个使代码难以阅读和检查的不可见退出点来破坏代码结构。换句话说,你永远不应该使用异常作为流控制,因为这对于其他人来说很难理解和维护。甚至很难理解所有可能的测试代码流。
再次:为了正确清理/处置,您可以使用 try-finally 而不捕获任何东西。
最流行的关于返回码的批评是“有人可以忽略错误代码,但在同样的意义上,有人也可以吞下异常。糟糕的异常处理在这两种方法中都很容易。但是编写好的基于错误代码的程序仍然容易得多而不是编写一个基于异常的程序。如果一个人出于任何原因决定忽略所有错误(旧的on error resume next
),你可以很容易地用返回码做到这一点,如果没有大量的 try-catchs 样板,你就无法做到这一点。
关于返回码的第二个最受欢迎的批评是“很难冒泡”——但这是因为人们不明白异常是针对不可恢复的情况,而错误码不是。
在异常和错误代码之间做出决定是一个灰色区域。您甚至可能需要从一些可重用的业务方法中获取错误代码,然后您决定将其包装到异常中(可能添加信息)并让它冒泡。但是假设所有错误都应该作为异常抛出是一个设计错误。
把它们加起来:
当我遇到意外情况时,我喜欢使用异常,在这种情况下没什么可做的,通常我们想中止一大块代码甚至整个操作或程序。这就像旧的“on error goto”。
当我预期调用者代码可以/应该采取一些行动时,我喜欢使用返回码。这包括大多数业务方法、API、验证等。
异常和错误代码之间的这种区别是 GO 语言的设计原则之一,它对致命的意外情况使用“恐慌”,而将常规预期情况作为错误返回。
然而,关于 GO,它还允许多个返回值,这对使用返回码有很大帮助,因为您可以同时返回错误和其他内容。在 C#/Java 上,我们可以通过 out 参数、元组或(我最喜欢的)泛型来实现,它们与枚举结合可以为调用者提供清晰的错误代码:
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 它仍然是糟糕的设计。
编辑 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) ...
编辑 2021-01-09:
几天前,我写了这篇博客文章,介绍我们如何(在某些情况下!)使用多个返回而不是异常(就像上面解释的 golang 约定,不应该替换所有异常,但应该让你决定何时使用异常以及何时使用返回码)。在这篇文章的最后,我混合了两个模型——基本上我使用的是ValueTuple 语法(非常简洁和优雅),但仍然使用泛型作为底层结构。基本上我使用隐式转换运算符和类型解构函数ValueTuple
在and之间来回转换CommandResult<TEntity, TError>
。
过去我加入了错误代码阵营(做过太多的 C 编程)。但现在我看到了曙光。
是的,异常对系统来说有点负担。但是它们简化了代码,减少了错误(和 WTF)的数量。
所以使用异常,但要明智地使用它们。他们将成为你的朋友。
作为旁注。我已经学会记录哪种方法可以抛出哪个异常。不幸的是,大多数语言都不需要这样做。但它增加了在正确级别处理正确异常的机会。
在某些情况下,以干净、清晰、正确的方式使用异常可能很麻烦,但绝大多数情况下,异常是显而易见的选择。异常处理相对于错误代码的最大好处是它改变了执行流程,这很重要有两个原因。
发生异常时,应用程序不再遵循其“正常”执行路径。这一点如此重要的第一个原因是,除非代码的作者运行良好并且真正避免做坏事,否则程序将停止并且不会继续做不可预测的事情。如果错误代码没有得到检查,并且没有采取适当的措施来响应错误的错误代码,程序将继续做它正在做的事情,谁知道该操作的结果将是什么。在很多情况下,让程序做“任何事情”最终可能会非常昂贵。考虑一个程序,该程序检索公司销售的各种金融工具的绩效信息,并将该信息传递给经纪人/批发商。如果出现问题并且程序继续运行,它可能会向经纪人和批发商发送错误的绩效数据。我不了解其他人,但我不想成为坐在副总裁办公室解释为什么我的代码导致公司受到 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
}
还有其他一些关于异常的小事也很好。有一堆条件逻辑来跟踪函数中调用的任何方法是否返回了错误代码,并返回更高的错误代码是很多样板。事实上,很多样板都会出错。我对大多数语言的异常系统有更多的信心,而不是“刚从大学毕业” Fred 写的一堆 if-else-if-else 语句,而且我还有很多更好的事情要做用我的时间比代码审查说鼠窝。
我的理由是,如果您正在编写一个真正需要性能的低级驱动程序,那么请使用错误代码。但是,如果您在更高级别的应用程序中使用该代码并且它可以处理一些开销,那么使用一个接口包装该代码,该接口检查这些错误代码并引发异常。
在所有其他情况下,例外可能是要走的路。
我的方法是我们可以同时使用两者,即异常和错误代码。
我习惯于定义几种类型的异常(例如:DataValidationException 或 ProcessInterruptExcepion),并在每个异常内部定义每个问题的更详细描述。
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;
}
通过这种方式,我使用异常来定义类别错误,并使用错误代码来定义有关问题的更详细信息。
我可能坐在这里的栅栏上,但是...
在 Python 中,使用异常是标准做法,我很乐意定义自己的异常。在 C 中,你根本没有例外。
在 C++ 中(至少在 STL 中),异常通常只针对真正的异常错误引发(我自己几乎从未见过它们)。我认为没有理由在我自己的代码中做任何不同的事情。是的,忽略返回值很容易,但 C++ 也不会强迫您捕获异常。我认为你只需要养成这样做的习惯。
我工作的代码库主要是 C++,我们几乎在所有地方都使用错误代码,但是有一个模块会针对任何错误引发异常,包括非常普通的错误,并且使用该模块的所有代码都非常糟糕。但这可能只是因为我们混合了异常和错误代码。始终使用错误代码的代码更容易使用。如果我们的代码始终使用异常,也许它不会那么糟糕。将两者混合似乎效果不佳。
因为我使用 C++,并且有 RAII 来保证它们使用安全,所以我几乎只使用异常。它将错误处理从正常的程序流程中拉出来,并使意图更加清晰。
不过,我确实为特殊情况留下了例外。如果我预计某个错误会发生很多,我会在执行之前检查操作是否成功,或者调用使用错误代码的函数版本(如TryParse()
)
方法签名应该向您传达该方法的作用。类似 long errorCode = getErrorCode(); 可能没问题,但是 long errorCode = fetchRecord(); 令人困惑。
异常是针对特殊情况的——即,当它们不是正常代码流的一部分时。
将异常和错误代码混合使用是非常合理的,其中错误代码代表某事的状态,而不是代码本身运行中的错误(例如检查子进程的返回代码)。
但是当异常情况发生时,我相信异常是最有表现力的模型。
在某些情况下,您可能更喜欢或不得不使用错误代码来代替异常,并且已经充分涵盖了这些情况(除了编译器支持等其他明显的约束)。
但从另一个方向来看,使用异常可以让您为错误处理构建更高级别的抽象,这可以使您的代码更具表现力和自然。我强烈建议阅读 C++ 专家 Andrei Alexandrescu 撰写的这篇出色但被低估的文章,主题是他所谓的“强制执行”:http ://www.ddj.com/cpp/184403864 。虽然这是一篇 C++ 文章,但原则是普遍适用的,而且我已经非常成功地将强制概念转换为 C#。
首先,我同意 Tom 的回答,即对于高级的东西使用异常,而对于低级的东西使用错误代码,只要它不是面向服务的体系结构 (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,而不是引发 NotFoundException。
返回可能被取消引用的无效指针(例如,在 Java 中导致 NullPointerException)是不可接受的。
使用多个不同的数字错误代码(-1,-2)作为同一函数的返回值通常是不好的风格,因为客户端可能会执行“== -1”检查而不是“< 0”。
这里要记住的一件事是随着时间的推移 API 的演变。一个好的 API 允许在不破坏客户端的情况下以多种方式更改和扩展故障行为。例如,如果客户端错误句柄检查了 4 个错误情况,并且您向函数添加了第五个错误值,则客户端处理程序可能不会对此进行测试并中断。如果您提出异常,这通常会使客户更容易迁移到更新版本的库。
要考虑的另一件事是,在团队中工作时,在哪里为所有开发人员做出这样的决定划清界限。例如,“高级事物的例外,低级事物的错误代码”是非常主观的。
在任何情况下,如果可能出现不止一种微不足道的错误,源代码不应该使用数字文字来返回错误代码或处理它(返回 -7,如果 x == -7 ...),但是总是一个命名常量(返回 NO_SUCH_FOO,如果 x == NO_SUCH_FOO)。
如果你在大项目下工作,你不能只使用异常或只使用错误代码。在不同的情况下,您应该使用不同的方法。
例如,您决定只使用例外。但是一旦你决定使用异步事件处理。在这种情况下使用异常进行错误处理是个坏主意。但是在应用程序中到处使用错误代码是乏味的。
所以我认为同时使用异常和错误代码是正常的。
对于大多数应用程序,例外情况更好。例外情况是软件必须与其他设备通信。我工作的领域是工业控制。这里错误代码是首选和预期的。所以我的回答是视情况而定。
我认为这还取决于您是否真的需要结果中的堆栈跟踪等信息。如果是的话,你肯定会选择 Exception ,它为对象提供了很多关于问题的信息。但是,如果您只对结果感兴趣并且不在乎为什么会出现该结果,那么请使用错误代码。
例如,当您在处理文件并遇到 IOException 时,客户端可能有兴趣知道从何处触发、打开文件或解析文件等。所以最好返回 IOException 或其特定的子类。但是,如果您有登录方法并且您想知道它是否成功,那么您只需返回布尔值或显示正确的消息,返回错误代码。在这里,客户端对知道哪个逻辑部分导致了该错误代码不感兴趣。他只知道它的凭据是否无效或帐户锁定等。
我能想到的另一个用例是数据在网络上传输时。您的远程方法可以只返回错误代码而不是异常,以最大限度地减少数据传输。
我的一般规则是:
当您的方法返回数值以外的任何值时,错误代码也不起作用......