我的问题是大多数开发人员更喜欢错误处理、异常或错误返回代码。请具体说明语言(或语系)以及为什么您更喜欢其中一种。
我是出于好奇而问这个的。就我个人而言,我更喜欢错误返回代码,因为它们的爆炸性较小,并且如果用户不想这样做,也不会强制用户代码支付异常性能损失。
更新:感谢所有答案!我必须说,尽管我不喜欢带有异常的代码流的不可预测性。关于返回码(和他们的哥哥句柄)的答案确实给代码增加了很多噪音。
我的问题是大多数开发人员更喜欢错误处理、异常或错误返回代码。请具体说明语言(或语系)以及为什么您更喜欢其中一种。
我是出于好奇而问这个的。就我个人而言,我更喜欢错误返回代码,因为它们的爆炸性较小,并且如果用户不想这样做,也不会强制用户代码支付异常性能损失。
更新:感谢所有答案!我必须说,尽管我不喜欢带有异常的代码流的不可预测性。关于返回码(和他们的哥哥句柄)的答案确实给代码增加了很多噪音。
C++ 基于 RAII。
如果您有可能失败、返回或抛出的代码(即大多数普通代码),那么您应该将指针包装在智能指针中(假设您有充分的理由不在堆栈上创建对象)。
它们很冗长,并且倾向于发展成以下内容:
if(doSomething())
{
if(doSomethingElse())
{
if(doSomethingElseAgain())
{
// etc.
}
else
{
// react to failure of doSomethingElseAgain
}
}
else
{
// react to failure of doSomethingElse
}
}
else
{
// react to failure of doSomething
}
最后,您的代码是标识指令的集合(我在生产代码中看到了这种代码)。
这段代码可以翻译成:
try
{
doSomething() ;
doSomethingElse() ;
doSomethingElseAgain() ;
}
catch(const SomethingException & e)
{
// react to failure of doSomething
}
catch(const SomethingElseException & e)
{
// react to failure of doSomethingElse
}
catch(const SomethingElseAgainException & e)
{
// react to failure of doSomethingElseAgain
}
它干净地将代码和错误处理分开,这可能是一件好事。
如果不是来自某个编译器的一些晦涩的警告(参见 "phjr" 的评论),它们很容易被忽略。
对于上述示例,假设有人忘记处理其可能的错误(发生这种情况......)。该错误在“返回”时会被忽略,并且可能会在以后爆炸(即 NULL 指针)。同样的问题不会发生异常。
错误不会被忽略。有时候,你希望它不爆炸,但是……所以你必须谨慎选择。
假设我们有以下功能:
如果其调用函数之一失败,则 doTryToDoSomethingWithAllThisMess 的返回类型是什么?
运算符不能返回错误代码。C++ 构造函数也不能。
以上观点的推论。如果我想写:
CMyType o = add(a, multiply(b, c)) ;
我不能,因为返回值已被使用(有时,它无法更改)。所以返回值成为第一个参数,作为参考发送......或者不是。
您可以为每种异常发送不同的类。资源异常(即内存不足)应该是轻量级的,但其他任何事情都可以根据需要变得很重(我喜欢 Java Exception 给我整个堆栈)。
然后可以对每个捕获进行专门化。
通常,您不应隐藏错误。如果您不重新抛出,至少将错误记录在文件中,打开消息框,无论如何......
异常的问题是过度使用它们会产生充满尝试/捕获的代码。但问题出在其他地方:谁使用 STL 容器尝试/捕获他/她的代码?尽管如此,这些容器仍然可以发送异常。
当然,在 C++ 中,永远不要让异常退出析构函数。
确保在它们使您的线程跪下或在您的 Windows 消息循环中传播之前抓住它们。
所以我想解决方案是在不应该发生的事情时抛出。当某些事情可能发生时,然后使用返回码或参数使用户能够对其做出反应。
所以,唯一的问题是“什么是不应该发生的事情?”
这取决于您的功能合同。如果函数接受一个指针,但指定指针必须为非NULL,那么当用户发送一个NULL指针时抛出异常是可以的(问题是,在C++中,函数作者什么时候不使用引用代替的指针,但是...)
有时,您的问题是您不希望出现错误。使用异常或错误返回代码很酷,但是……您想了解它。
在我的工作中,我们使用一种“断言”。无论调试/发布编译选项如何,它都会根据配置文件的值:
在开发和测试中,这使用户能够在检测到问题时准确地查明问题,而不是之后(当某些代码关心返回值时,或者在 catch 内)。
很容易添加到遗留代码中。例如:
void doSomething(CMyObject * p, int iRandomData)
{
// etc.
}
导致一种类似于以下的代码:
void doSomething(CMyObject * p, int iRandomData)
{
if(iRandomData < 32)
{
MY_RAISE_ERROR("Hey, iRandomData " << iRandomData << " is lesser than 32. Aborting processing") ;
return ;
}
if(p == NULL)
{
MY_RAISE_ERROR("Hey, p is NULL !\niRandomData is equal to " << iRandomData << ". Will throw.") ;
throw std::some_exception() ;
}
if(! p.is Ok())
{
MY_RAISE_ERROR("Hey, p is NOT Ok!\np is equal to " << p->toString() << ". Will try to continue anyway") ;
}
// etc.
}
(我有类似的宏,仅在调试时有效)。
请注意,在生产中,配置文件不存在,因此客户端永远不会看到此宏的结果...但是在需要时很容易激活它。
当您使用返回码进行编码时,您正在为失败做好准备,并希望您的测试堡垒足够安全。
当您使用异常编码时,您知道您的代码可能会失败,并且通常将 counterfire catch 放在代码中选定的战略位置。但通常,您的代码更多的是关于“它必须做什么”而不是“我担心会发生什么”。
但是当你编写代码时,你必须使用你可以使用的最好的工具,有时,它是“永远不要隐藏错误,并尽快显示它”。我上面所说的宏遵循这个哲学。
我实际上两者都用。
如果它是已知的、可能的错误,我会使用返回码。如果这是我知道可以并且将会发生的场景,那么就会有一个代码被发回。
例外仅用于我不期望的事情。
根据Framework Design Guidelines: Conventions, Idioms, and Patterns for Reusable .NET Libraries中标题为“异常”的第 7 章,给出了许多理由说明为什么在 C# 等 OO 框架中必须使用异常而不是返回值。
也许这是最令人信服的原因(第 179 页):
“异常与面向对象的语言很好地集成。面向对象的语言倾向于对成员签名施加约束,这些约束不是由非 OO 语言中的函数施加的。例如,在构造函数、运算符重载和属性的情况下,开发人员在返回值上没有选择。因此,无法标准化面向对象框架的基于返回值的错误报告。错误报告方法,例如异常,超出方法签名的范围是唯一的选择。 ”
我的偏好(在 C++ 和 Python 中)是使用异常。语言提供的工具使其成为一个明确定义的过程,可以引发、捕获和(如有必要)重新抛出异常,从而使模型易于查看和使用。从概念上讲,它比返回代码更简洁,因为可以通过名称定义特定异常,并附带附加信息。使用返回码,您仅限于错误值(除非您想定义 ReturnStatus 对象或其他东西)。
除非您正在编写的代码是时间关键的,否则与展开堆栈相关的开销并不足以担心。
只有在您没有预料到的事情发生时才应返回异常。
从历史上看,另一个例外点是返回码本质上是专有的,有时可以从 C 函数返回 0 表示成功,有时返回 -1,或者其中任何一个表示失败,1 表示成功。即使枚举它们,枚举也可能是模棱两可的。
异常还可以提供更多信息,并明确说明“出了点问题,这是什么,堆栈跟踪和上下文的一些支持信息”
话虽这么说,一个很好枚举的返回码对于一组已知的结果很有用,一个简单的“函数的这里有 n 个结果,它只是以这种方式运行”
默认情况下始终使用异常,但考虑提供额外的 tester-doer 选项(TryX)! 对我来说,答案很明确。当上下文指示 Try 或 Tester-Doer 模式(即 cpu 密集型或公共 api)时,我将另外将这些方法提供给异常抛出版本。我认为避免异常的一揽子规则是错误的、不受支持的,并且可能会导致比他们声称要防止的任何性能问题更多的错误费用。
不,微软没有说不使用异常(常见的误解)。
它说,如果您正在设计 API ,请提供方法来帮助该 API 的用户在需要时避免抛出异常(Try 和 Tester-Doer 模式)
❌ 如果可能,请勿将异常用于正常的控制流程。
除了系统故障和具有潜在竞争条件的操作外,框架设计者应该设计 API,以便用户可以编写不会引发异常的代码。例如,您可以提供一种在调用成员之前检查先决条件的方法,以便用户可以编写不会引发异常的代码。
这里推断的是,非测试执行者/非尝试实现应该在失败时抛出异常,然后用户可以将其更改为您的测试执行者之一或尝试性能方法。成功的坑是为了安全而维护的,用户选择使用更危险但更高效的方法。
微软确实说过不要使用返回码 TWICE,这里:
❌ 不要返回错误代码。
异常是在框架中报告错误的主要方式。
✔️ 务必通过抛出异常来报告执行失败。
在这里:
❌ 不要使用错误代码,因为担心异常可能会对性能产生负面影响。
为了提高性能,可以使用 Tester-Doer Pattern 或 Try-Parse Pattern,这将在接下来的两节中介绍。
如果您不使用异常,则可能违反了从非测试/非尝试实现返回返回码或布尔值的其他规则。同样,TryParse 不会取代 Parse。它是除了 Parse 之外提供的
主要原因:返回代码几乎每次都未能通过“成功坑”测试。
关于性能:
相对于根本不抛出异常而言,异常可能在计算上是昂贵的,但出于某种原因,它们被称为异常。速度比较总是设法假设 100% 的异常率,这绝不应该是这种情况。即使一个异常慢了 100 倍,如果它只发生 1% 的时间,这又有什么关系呢?
上下文就是一切。例如,避免唯一键违规的 Tester-Doer 或 Try 选项可能平均浪费更多的时间和资源(在很少发生冲突时检查是否存在),而不是仅仅假设成功进入并捕获罕见的违规。
除非我们在讨论图形应用程序的浮点运算或类似的东西,否则与开发人员时间相比,CPU 周期很便宜。
从时间的角度来看,成本也有同样的论据。相对于数据库查询或 Web 服务调用或文件加载,正常的应用程序时间将使异常时间相形见绌。2006 年的例外情况接近亚微秒
我敢于在 .net 中工作的任何人设置调试器以中断所有异常并仅禁用我的代码,看看已经发生了多少你甚至不知道的异常。
Jon Skeet 说“[Exceptions are] 不够慢,因此值得在正常使用中避免它们”。链接的响应还包含 Jon 关于该主题的两篇文章。他的概括性主题是异常很好,如果您将它们视为性能问题,则可能存在更大的设计问题。
在 Java 中,我使用(按以下顺序):
按合同设计(确保在尝试任何可能失败的事情之前满足先决条件)。这可以捕获大多数东西,我为此返回一个错误代码。
在处理工作时返回错误代码(并在需要时执行回滚)。
例外,但这些仅用于意想不到的事情。
我不喜欢返回码,因为它们会导致以下模式在您的代码中迅速蔓延
CRetType obReturn = CODE_SUCCESS;
obReturn = CallMyFunctionWhichReturnsCodes();
if (obReturn == CODE_BLOW_UP)
{
// bail out
goto FunctionExit;
}
很快,一个由 4 个函数调用组成的方法调用膨胀到 12 行错误处理。其中一些永远不会发生。If 和 switch 案例比比皆是。
如果您使用得当,异常会更清晰......发出异常事件的信号......之后执行路径将无法继续。它们通常比错误代码更具描述性和信息性。
如果您在方法调用后有多个状态,应该以不同方式处理(并且不是例外情况),请使用错误代码或输出参数。虽然 Personaly 我发现这很罕见..
我已经对“性能损失”的反驳进行了一些研究。在 C++ / COM 世界中更多的是,但在较新的语言中,我认为差异并不大。在任何情况下,当发生故障时,性能问题都会被放到次要位置:)
不久前,我写了一篇关于此的博客文章。
抛出异常的性能开销不应在您的决定中发挥任何作用。毕竟,如果你做对了,例外就是例外。
我从The Pragmatic Programmer那里得到的一个很好的建议是“你的程序应该能够在不使用异常的情况下执行其所有主要功能”。
我有一套简单的规则:
1) 对您希望直接呼叫者做出反应的事情使用返回码。
2) 对范围更广的错误使用异常,并且可以合理地期望由调用者之上的许多级别来处理,这样错误的意识就不必渗透到许多层,从而使代码更加复杂。
在 Java 中,我只使用过未经检查的异常,检查过的异常最终只是另一种形式的返回代码,根据我的经验,方法调用可能“返回”的二元性通常更多的是阻碍而不是帮助。
对于任何体面的编译器或运行时环境,异常不会招致重大损失。它或多或少有点像跳转到异常处理程序的 GOTO 语句。此外,让运行时环境(如 JVM)捕获异常有助于更轻松地隔离和修复错误。任何一天,我都会在 Java 中使用 NullPointerException 来处理 C 中的段错误。
我在异常和非异常情况下都在 python 中使用异常。
能够使用异常来指示“无法执行请求”通常很好,而不是返回错误值。这意味着您/总是/知道返回值是正确的类型,而不是任意的 None 或 NotFoundSingleton 什么的。这是一个很好的例子,说明我更喜欢使用异常处理程序而不是返回值的条件。
try:
dataobj = datastore.fetch(obj_id)
except LookupError:
# could not find object, create it.
dataobj = datastore.create(....)
副作用是,当运行 datastore.fetch(obj_id) 时,您无需检查其返回值是否为 None,您会立即免费获得该错误。这与“你的程序应该能够在不使用异常的情况下执行其所有主要功能”的论点背道而驰。
这是另一个“异常”有用的异常示例,以便编写代码来处理不受竞争条件影响的文件系统。
# wrong way:
if os.path.exists(directory_to_remove):
# race condition is here.
os.path.rmdir(directory_to_remove)
# right way:
try:
os.path.rmdir(directory_to_remove)
except OSError:
# directory didn't exist, good.
pass
一个系统调用而不是两个,没有竞争条件。这是一个糟糕的例子,因为很明显,在比目录不存在更多的情况下,这将失败并出现 OSError,但对于许多严格控制的情况来说,它是一个“足够好”的解决方案。
我相信返回码会增加代码噪音。例如,由于返回码,我一直讨厌 COM/ATL 代码的外观。必须对每一行代码进行 HRESULT 检查。我认为错误返回代码是 COM 架构师做出的错误决定之一。这使得对代码进行逻辑分组变得困难,因此代码审查变得困难。
当每行都明确检查返回码时,我不确定性能比较。
异常不适用于错误处理,IMO。例外就是这样;您没有预料到的异常事件。我说谨慎使用。
错误代码可以,但从方法返回 404 或 200 是不好的,IMO。改用枚举(.Net),这使得代码更易读,更容易被其他开发人员使用。此外,您不必维护一个包含数字和描述的表格。
还; try-catch-finally 模式在我的书中是一个反模式。try-finally 可以很好,try-catch 也可以很好,但是 try-catch-finally 永远不会很好。try-finally 通常可以用“使用”语句(IDispose 模式)代替,这是更好的 IMO。并且 Try-catch 在您实际捕获您能够处理的异常的地方是好的,或者如果您这样做:
try{
db.UpdateAll(somevalue);
}
catch (Exception ex) {
logger.Exception(ex, "UpdateAll method failed");
throw;
}
因此,只要您让异常继续冒泡就可以了。另一个例子是这样的:
try{
dbHasBeenUpdated = db.UpdateAll(somevalue); // true/false
}
catch (ConnectionException ex) {
logger.Exception(ex, "Connection failed");
dbHasBeenUpdated = false;
}
在这里,我实际上处理了异常;当更新方法失败时,我在 try-catch 之外所做的是另一回事,但我认为我的观点已经提出。:)
那么为什么 try-catch-finally 是一种反模式呢?原因如下:
try{
db.UpdateAll(somevalue);
}
catch (Exception ex) {
logger.Exception(ex, "UpdateAll method failed");
throw;
}
finally {
db.Close();
}
如果 db 对象已经关闭,会发生什么?一个新的异常被抛出,它必须被处理!这个更好:
try{
using(IDatabase db = DatabaseFactory.CreateDatabase()) {
db.UpdateAll(somevalue);
}
}
catch (Exception ex) {
logger.Exception(ex, "UpdateAll method failed");
throw;
}
或者,如果 db 对象没有实现 IDisposable,请执行以下操作:
try{
try {
IDatabase db = DatabaseFactory.CreateDatabase();
db.UpdateAll(somevalue);
}
finally{
db.Close();
}
}
catch (DatabaseAlreadyClosedException dbClosedEx) {
logger.Exception(dbClosedEx, "Database connection was closed already.");
}
catch (Exception ex) {
logger.Exception(ex, "UpdateAll method failed");
throw;
}
无论如何,那是我的 2 美分!:)
我更喜欢将异常用于错误处理并将返回值(或参数)作为函数的正常结果。这提供了一个简单且一致的错误处理方案,如果正确完成,它会使代码看起来更清晰。
最大的区别之一是异常迫使您处理错误,而错误返回代码可以不检查。
如果大量使用错误返回代码,也会导致非常丑陋的代码,其中包含大量类似于此形式的 if 测试:
if(function(call) != ERROR_CODE) {
do_right_thing();
}
else {
handle_error();
}
就我个人而言,我更喜欢对调用代码应该或必须处理的错误使用异常,并且只将错误代码用于“预期的失败”,其中返回的东西实际上是有效且可能的。
有很多理由更喜欢异常而不是返回码:
我通常更喜欢返回码,因为它们让调用者决定失败是否异常。
这种方法在 Elixir 语言中很典型。
# I care whether this succeeds. If it doesn't return :ok, raise an exception.
:ok = File.write(path, content)
# I don't care whether this succeeds. Don't check the return value.
File.write(path, content)
# This had better not succeed - the path should be read-only to me.
# If I get anything other than this error, raise an exception.
{:error, :erofs} = File.write(path, content)
# I want this to succeed but I can handle its failure
case File.write(path, content) do
:ok => handle_success()
error => handle_error(error)
end
人们提到返回码会导致你有很多嵌套的if
语句,但这可以用更好的语法来处理。在 Elixir 中,该with
语句让我们可以轻松地将一系列快乐路径返回值与任何失败区分开来。
with {:ok, content} <- get_content(),
:ok <- File.write(path, content) do
IO.puts "everything worked, happy path code goes here"
else
# Here we can use a single catch-all failure clause
# or match every kind of failure individually
# or match subsets of them however we like
_some_error => IO.puts "one of those steps failed"
_other_error => IO.puts "one of those steps failed"
end
Elixir 仍然具有引发异常的函数。回到我的第一个示例,如果无法写入文件,我可以执行其中任何一项来引发异常。
# Raises a generic MatchError because the return value isn't :ok
:ok = File.write(path, content)
# Raises a File.Error with a descriptive error message - eg, saying
# that the file is read-only
File.write!(path, content)
如果我作为调用者知道我想在写入失败时引发错误,我可以选择调用File.write!
而不是File.write
. 或者我可以选择以File.write
不同的方式调用和处理每个可能的失败原因。
当然,rescue
如果我们愿意,总是有可能出现异常。但与处理信息丰富的返回值相比,这对我来说似乎很尴尬。如果我知道函数调用可能会失败甚至应该失败,那么它的失败并不是例外情况。
我只使用异常,没有返回码。我在这里谈论Java。
我遵循的一般规则是,如果我调用了一个方法doFoo()
,那么如果它没有“做 foo”,那么就发生了一些异常情况,应该抛出一个异常。
我担心异常的一件事是抛出异常会破坏代码流。例如,如果你这样做
void foo()
{
MyPointer* p = NULL;
try{
p = new PointedStuff();
//I'm a module user and I'm doing stuff that might throw or not
}
catch(...)
{
//should I delete the pointer?
}
}
或者更糟糕的是,如果我删除了一些我不应该删除的东西,但在我完成其余的清理工作之前就被扔掉了。投掷给可怜的用户恕我直言。
我在异常与返回代码参数中的一般规则:
我没有发现返回码比异常更难看。除了例外,你有try{} catch() {} finally {}
where 和你有的返回码if(){}
。由于帖子中给出的原因,我曾经担心例外;你不知道指针是否需要清除,你有什么。但我认为在返回码方面你也有同样的问题。除非您知道有关函数/方法的一些详细信息,否则您不知道参数的状态。
无论如何,如果可能,您必须处理错误。您可以很容易地让异常传播到顶层,就像忽略返回码并让程序出现段错误一样。
我确实喜欢为结果返回一个值(枚举?)并为异常情况返回一个异常的想法。
对于像 Java 这样的语言,我会选择 Exception,因为如果不处理异常,编译器会给出编译时错误。这会强制调用函数处理/抛出异常。
对于Python,我比较矛盾。没有编译器,因此调用者可能没有处理导致运行时异常的函数抛出的异常。如果您使用返回码,如果处理不当,您可能会出现意外行为,如果您使用异常,您可能会遇到运行时异常。