我应该捕获并包装通用异常吗?

16

以下的代码可以被认为是一个好的编程实践吗?如果不能,为什么?

try
{
    // code that can cause various exceptions...
}
catch (Exception e)
{
    throw new MyCustomException("Custom error message", e);
}

在我看来没问题 - 有时候你并不关心是IO异常还是超时或其他什么,你只想捕获任何错误并抛出一个通用的“出错了”错误并包装实际的异常 - 就像你所做的那样。 - Charleh
纯洁主义者可能会谴责你——但我认为这取决于具体情况。例如,你是在开发一个将被其他代码使用的库,还是在讨论一个应用程序?如果不这样做,用户可能会看到“应用程序已停止工作”的消息,而不是一个明智的“无法连接到服务器”的消息,用户对此可以采取一些措施。 - JohnL
如果你要这样做,请非常小心,不要这样写:catch (Exception e) { new MyCustomException("Custom error message", e); } 哎呀! - Eric Lippert
14个回答

34
短答案:除非有必要,否则不要这样做。相反,在您可以处理它们的地方捕获特定异常,并允许所有其他异常上升到堆栈。
TL;DR答案:这取决于您正在编写什么、将调用代码的是什么以及为什么感觉需要引入自定义异常类型。
我认为最重要的问题是:“如果我捕获,我的调用者会得到什么好处?”
- 不要隐藏调用者所需的信息 - 不要让您的调用者跳过比必要更多的步骤
想象一下,您正在处理底层数据源;也许您正在进行一些编码或反序列化操作。我们的系统非常模块化,因此您对您的低级数据源是什么或者您的代码如何被调用没有太多了解。也许您的库在一个服务器上部署,监听消息队列并写入数据到磁盘上。也许它在桌面上运行,数据来自网络并且在屏幕上显示。
可能会发生许多各种各样的异常(DbException、IOException、RemoteException等),而您没有用于处理它们的有用手段。
在这种情况下,在C#中通常应让异常上升。您的调用者知道该怎么办:桌面客户端可以提醒用户检查网络连接,服务可以写入日志并允许新消息排队。
如果您将异常包装在自己的MyAwesomeLibraryException中,则会增加调用者的工作量。现在,您的调用者需要:
- 阅读并理解您的文档 - 在他们想要捕获的地方引入对您程序集的依赖关系 - 编写额外的代码来查询您的自定义异常 - 重新抛出他们不关心的异常。
确保这额外的努力值得他们的时间。不要毫无理由地这样做!
如果您自定义异常类型的主要理由是为了提供用户友好的错误消息,则应当警惕在捕获异常时过于模糊。我已经无数次因为过度热情的catch语句而隐藏了问题的真正原因,无论是作为用户还是作为工程师。
try
{
  GetDataFromNetwork("htt[://www.foo.com"); // FormatException?

  GetDataFromNetwork(uriArray[0]); // ArrayIndexOutOfBounds?

  GetDataFromNetwork(null); // ArgumentNull?
}
catch(Exception e)
{
  throw new WeClearlyKnowBetterException(
    "Hey, there's something wrong with your network!", e);
}

或者,另一个例子是:
try
{
  ImportDataFromDisk("C:\ThisFileDoesNotExist.bar"); // FileNotFound?

  ImportDataFromDisk("C:\BobsPrivateFiles\Foo.bar"); // UnauthorizedAccess?

  ImportDataFromDisk("C:\NotInYourFormat.baz"); // InvalidOperation?

  ImportDataFromDisk("C:\EncryptedWithWrongKey.bar"); // CryptographicException?
}
catch(Exception e)
{
  throw new NotHelpfulException(
    "Couldn't load data!", e); // So how do I *fix* it?
}

现在我们的调用者必须解开我们的自定义异常,以便告诉用户实际出了什么问题。在这些情况下,我们要求调用者做额外的工作,却没有任何好处。引入包装所有异常的自定义异常类型本质上并不是一个好主意。一般来说,我会:
  1. 捕获我能够捕获到的最具体的异常
  2. 在我能够处理它的地方
  3. 否则,我只是让异常向上传递
  4. 请记住,隐藏发生了什么错误的细节通常没有用

这并不意味着你永远不应该这样做!

  1. 有时Exception是您可以捕获的最具体的异常,因为您想以相同的方式处理所有异常-例如:Log(e); Environment.FailFast();
  2. 有时,您在抛出异常的地方就有处理异常的上下文-例如:您刚刚尝试连接到网络资源并且您想重试
  3. 有时,您的调用者的性质意味着您不能允许异常向上传递-例如:您正在编写日志记录代码,不希望日志记录失败“替换”您尝试记录的原始异常!
  4. 有时,在抛出异常的地方可以给调用者提供有用的额外信息,而在调用堆栈中更高的位置不可用-例如:在我上面的第二个示例中,我们可以catch(InvalidOperationException e)并包括我们正在处理的文件的路径。
  5. 偶尔,发生了什么并不像发生了哪里那样重要。可能有用将FooModuleExceptionBarModuleException区分开来,而不管实际问题是什么-例如:如果有一些异步操作可能会妨碍您对堆栈跟踪进行有用的询问。
  6. 虽然这是一个C#问题,但值得注意的是,在其他一些语言(特别是Java)中,您可能被迫进行包装,因为已检查的异常构成方法的合同-例如:如果您正在实现一个接口,并且该接口未指定该方法可能引发IOException

这都是相当一般的东西。更具体地说,针对您的情况:您为什么觉得需要自定义异常类型?如果我们知道了这一点,或许可以给您提供更好的量身定制的建议。


2
感谢您详细的回答。假设应用程序的一部分想执行操作X,而所有调用代码都只关心此操作是否成功。它不关心确切的错误是什么。例如,我想从文件c:\TEST.TXT中加载文本,如果无法加载(无论出于什么原因),则使用默认的硬编码文本。因此,如果无法加载文件,则我不关心确切的原因(例如,文件不存在,权限被拒绝,文件内容是二进制而不是ASCII...),我只关心操作是否成功。那么在这种情况下使用OP代码是否可以? - matori82
如果你真的不在乎,那就随便吧。但是要小心了,因为一旦有错误发生(比如导致空引用的任何代码错误),由于你的catch语句,这些bug将无法被找到。如果这个应用程序不仅限于你的机器,你可能至少想要记录异常信息的位置,以便在操作失败而你期望成功时能找出原因。 - Iain Galloway
2
知道如何处理异常通常需要知道 它发生的位置;封装提供了这些信息。 还需要知道抛出异常是否创建了除原始异常之外的任何新问题。 封装有时也可以提供这一点。 如果方法的契约指定在某种情况下抛出某种特定类型的异常,则调用者可能会期望,但由于某些意外原因而调用一个抛出相同类型异常的方法,如果未能将异常封装... - supercat
可能会导致调用者错误地认为异常是由调用者预期的原因引起的。 - supercat

7
有一篇Eric Lippert的博客文章"令人烦恼的异常", 这里有一些普遍的指导方针来回答你的问题。以下是“总结”部分的摘录:
  • 不要捕获致命的异常; 无论如何,你都不能对它们做任何事情,尝试捕获会使情况更糟。
  • 修复代码,使其永远不会触发一个愚蠢的异常-一个“索引超出范围”的异常在生产代码中永远不应该发生。
  • 尽可能避免出现令人烦恼的异常,通过调用那些在非异常情况下抛出的“Try”版本的令人烦恼方法来实现。 如果无法避免调用令人烦恼的方法,请捕获其令人烦恼的异常。
  • 始终处理指示意外外部条件的异常; 通常,并不值得或实际去预测每一个可能的失败。只需尝试操作并准备好处理异常。

2
仅供搜索参考 - 该博客的URL已不再有效 - 但在此处:https://ericlippert.com/2008/09/10/vexing-exceptions/(它可能有点老,但我认为它仍然是将不同类型的异常进行分类并讨论应用程序如何处理它们的最佳方式之一)。 - DotNetSparky
1
@DotNetSparky 谢谢。我已经更新了链接。 - FreeAsInBeer

5
一般来说,你不应该那样做:这可能掩盖真正的异常,这可能表明你的代码存在编程问题。例如,如果try/catch内部的代码有一行错误导致数组索引超出范围错误,你的代码也会捕获它,并为其抛出自定义异常。现在自定义异常毫无意义,因为它报告了一个编码问题,所以在你的代码之外捕获它的任何人都无法对其做出任何有意义的处理。
另一方面,如果try/catch内部的代码抛出你预期的异常,捕获并将它们包装在自定义异常中是一个好主意。例如,如果你的代码从某个专门为你组件私有的文件读取数据,而读取操作导致I/O异常,则捕获该异常并报告自定义异常是一个好主意,因为它能帮助你隐藏来自调用者的文件操作。

6
他将原始异常作为内部异常传递给自定义异常,因此这样做实际上是可以的。 - Robert Levy
3
@dasblinkenlight,我不同意你的观点-自定义异常比这个catch块捕获到的异常更高级。如果你正在初始化应用程序,那么可能会出现类似于ConfigurationException("无法初始化交易员模块", e)的异常。高级别客户端(例如某些服务)将处理配置错误,而不是处理低级别的IndexOutOfRangeExceptionFileNotFoundException(以及基本的Exception)。 - Sergey Berezovskiy
1
@dasblinkenlight 但从客户端问题的角度来看,问题是相同的——交易模块无法初始化——这是初始化/配置错误(我可能会选择ModuleInitializationException)——您应该调查日志以找到问题的源头——是编程错误还是缺少文件。这就是问题的调查部分,这就是为什么原始异常被分配给内部异常的原因。考虑一下你的胃——当它抛出异常时——只是MyStomachHurtsException,而不是具体的东西 :) - Sergey Berezovskiy
2
@SergeyBerezovskiy “包装一切”方法的根本问题在于不清楚谁需要调查异常。如果我只包装有意义的异常,我的客户仍然会向我寻求帮助处理 IndexOutOfRangeException 异常,但他们可能能够自行调查 MyCustomException + FileNotFound 异常,而不必打扰我。在有大量客户的情况下,这对我和我的客户都是一个巨大的时间节省者。 - Sergey Kalinichenko
1
@dasblinkenlight:我认为相反的观点更有道理:虽然应该尽可能在 API 层边界处执行盲目异常包装,但是调用在某个特定上下文中调用提供的委托并包装由其抛出的任何异常的方法将使得可以区分发生在委托内部和其他地方的异常。由于它无法知道委托可能抛出哪些异常,因此也无法知道哪些异常是重要的。 - supercat
显示剩余4条评论

5

完全没问题。你不必分别捕获每个异常类型。如果你想用特定的方式处理,你 可以 捕获特定类型的异常。如果你想以相同的方式处理所有异常-捕获基础的Exception并按照你的方式处理。否则,你将在每个捕获块中有重复的代码。


1
那么像OutOfMemoryException这样的关键系统异常呢?它们也会在这个catch块中被捕获并包装成自定义异常,但我不确定应用程序接下来会发生什么...它可能会被操作系统强制关闭,导致数据损坏等问题... - matori82
1
任何异常都对您的应用程序至关重要,如果它不被处理,应用程序将崩溃。如果这个特定的异常不应以特定方式处理,则应像处理其他任何异常一样处理。例如,如果您正在导入联系人,则应使用自定义异常“DataImportException”包装它,并将其抛出到调用者。从调用者的角度来看,它的数据导入操作失败了,无论原因是什么 - 数据都没有被导入。 - Sergey Berezovskiy

4
这是我通常关于异常处理所说的:
  • 对于异常,首先要做的是......什么都不用做。会有一个高级别的catch所有处理程序(例如黄色ASP.NET错误页面)来帮助您,并且您将拥有完整的堆栈帧(请注意,在其他非.NET环境中情况并非如此)。如果您有相应的PDB文件,还将具有源代码行号。
  • 现在,如果您想在某些异常中添加一些信息,当然可以在仔细选择的位置(也许是真正发生实际异常的地方,您想为将来的错误改进代码),但请确保您确实为原始内容添加了价值(并确保您也作为内部异常添加了原始内容,就像您在示例中所做的那样)。

因此,我会说您的示例代码可能没问题。这确实取决于“自定义错误消息”(以及可能的异常自定义属性 - 确保它们是可序列化的)。它必须添加价值或含义以帮助诊断问题。例如,对我来说,这看起来相当不错(可能需要改进):

string filePath = ... ;
try
{
    CreateTheFile(filePath);
    DoThisToTheFile(filePath);
    DoThatToTheFile(filePath);
    ...
}
catch (Exception e)
{
    throw new FileProcessException("I wasn't able to complete operation XYZ with the file at '" + filePath + "'.", e);
}

这个不行:

string filePath = ... ;
try
{
    CreateTheFile(filePath);
    DoThisToTheFile(filePath);
    DoThatToTheFile(filePath);
}
catch (Exception e)
{
    throw new Exception("I wasn't able to do what I needed to do.", e);
}

如何使用 catch (System.IO.IOException e)throw new MyFileIOException("...") 以及 catch (Exception e)throw new MyFileException("Something not related to file IO went wrong.") 的方式?换句话说,在一个通用的自定义异常之外,你可以添加一种或多种特定的自定义异常。或者,也可以在一个通用的自定义异常中结合 Exception.Data 使用。 - DavidRR
@DavidRR - 嗯,第二个“catch all” catch(Exception) 似乎没什么用,因为你最终会从最高级别的异常处理程序中获取到那些信息。 - Simon Mourier
嗯,不太确定我理解你的观点...我的建议是可以使用多个catch块,并从每个块中抛出不同的自定义异常。最后一个catch块将捕获Exception并抛出最通用的自定义异常(该异常将包装Exception)。关于您的示例,可能是FileProcessException。如果特别需要报告IO相关的异常,则使用FileProcessIOExceptionFileProcessIOException将派生自FileProcessException - DavidRR
@DavidRR - 对于不同的异常使用不同的catch是可以的,但在我看来最后一个“万能捕获”似乎没什么用,除非你真的向其中添加了一些内容。 - Simon Mourier
我认为我的“catch all”术语的使用很令人困惑。Exception可以捕获任何东西;我并不建议复制它。我真正建议的是可能性,即创建一个自定义异常类层次结构,以传达特定于业务的上下文。基类FileProcessException将表示与处理某个文件相关的所有异常。而派生类FileProcessIOException将表示更具体的错误情况。客户端可以选择捕获其中任何一个。 - DavidRR

3

前言: 我认为此模式仅在特定条件下并且完全理解其优缺点时才可使用。


说明 1: 当成员未能完成其名称所指示的任务时,就会出现异常。 (Jeffry Richter,《CLR via C# Fourth Edition》)

说明 2: 有时我们希望从库成员中获得结果或告诉我们不可能,并且我们不关心所有细节,只需将详细信息发送给库开发人员。

从说明 1 和说明 2 得出的结论: 当实现一个库方法时,我们可以包装一个一般的异常并抛出一个自定义异常,其中包括源异常作为其InnerException。在这种情况下,使用此成员的开发人员将需要捕获一个异常,而我们仍将获得调试信息。


案例 1: 您正在实现一个库方法,并且不想暴露其内部或者您打算/假设在将来更改其内部。

public string GetConfig()
{
    try
    {
        var assembly = Assembly.GetExecutingAssembly();
        var resourceName = "MyCompany.MyProduct.MyFile.cfg";

        // ArgumentNullException
        // ArgumentException
        // FileLoadException
        // FileNotFoundException
        // BadImageFormatException
        // NotImplementedException
        using (Stream stream = assembly.GetManifestResourceStream(resourceName))
        // ArgumentException
        // ArgumentNullException
        using (StreamReader reader = new StreamReader(stream))
        {
            // OutOfMemoryException
            // IOException
            string result = reader.ReadToEnd();
        }
        return result;

        // TODO: Read config parameter from DB 'Configuration'
    }
    catch (Exception ex)
    {
        throw new ConfigException("Unable to get configuration", ex);
    }
}

这是一个很棒的代码,你确定它不会抛出任何异常,因为它本来就不应该抛出异常。你确定吗?或者你会用try-catch包装一下以防万一?或者你会让开发者做这个工作吗?如果他不在意这个方法是否成功,或许他有备份计划?也许他会把对这个方法的调用包装在try-catch(Exception e)中,而不是你?我觉得不会这样做。
优点:
1.隐藏实现细节,可以在未来自由更改;
2.如果调用方关心,调用方无需捕获大量不同的异常,可以查看InnerException。
缺点:
1.您可能会丢失堆栈跟踪(对于第三方库可能并不那么重要);
Case 2:您想向异常添加信息。虽然不是直接与您在问题中编写的代码相关,但我认为这是异常处理工具被低估的重要部分,仍然会捕获通用的异常。
catch (Exception ex)
{
    ex.Data.Add(paramName);
    throw;
}

补充: 我会按以下方式编辑您的模式:

try
{
    // code that can cause various exceptions...
}
catch (Exception e)  
{
    if (e is ThreadAbortException || e is StackOverflowException || e is OutOfMemoryException)
    {                                       
        throw;
    }

    throw new MyCustomException("Custom error message", e);
}


摘要: 当开发一个库的公共方法以隐藏实现细节并简化开发人员的使用时,可以使用此模式。


2
情况1缺点:1. 你会失去堆栈跟踪(对于第三方库来说可能并不那么重要); 你确定吗?正如你所指出的,当你在自定义异常中包装一个异常时,通过 CustomException.InnerException 可以访问包装的异常。换句话说,InnerException 是否提供了堆栈跟踪呢? - DavidRR

1
主要取决于你捕获异常的位置。通常情况下,库应该更加保守地捕获异常,而在程序的顶层(例如在主方法或控制器的操作方法的顶部等),你可以更加自由地捕获异常。
原因是,例如在库中不希望捕获所有异常,因为这可能掩盖了与库无关的问题,例如“OutOfMemoryException”,你真的希望它能够上升,以便用户可以得到通知等。另一方面,如果你谈论的是在main()方法内捕获异常,显示它,然后退出...好吧,在这里捕获几乎任何异常可能是安全的。
关于捕获所有异常最重要的规则是,你永远不应该默默地吞噬所有异常...例如在Java中这样做:
try { 
something(); 
} 

catch (Exception ex) {}

或者用Python实现:
try:
something()
except:
pass

因为这些问题往往是最难追踪的。
一个好的经验法则是,只应该捕获你自己能够适当处理的异常。如果你无法完全处理异常,那么就让它上升到可以处理它的人手中。

1
通常情况下,对于异常的处理原则是捕获并在本地处理(如果适用),否则允许其冒泡到顶层容器。
这完全取决于应用程序运行的上下文环境。例如,如果应用程序是ASP.Net Web应用程序,则某些异常可以由IIS中的ASP应用程序服务器处理,但预期的特定于应用程序的错误(在您自己的接口中定义的错误)应该被捕获并在适当时呈现给最终用户。
如果您正在处理I/O,则有很多因素超出了您的控制范围(网络可用性、磁盘硬件等)。因此,如果出现故障,则最好立即处理它,通过捕获异常并向用户显示错误消息。
最重要的是不要默默失败,因此不要将异常包装在自己的异常中,然后忽略它们。最好允许它们冒泡并在Web服务器日志中找到它们。

1
阅读问题、答案和评论后,我认为Iain Galloway的全面回答可以更广泛地解释这个问题。
我的简短而甜美的观点是,
1.应该捕获异常,并且只有在您想要隐藏技术细节并且只想让用户得到一些适当的消息以指示某些内容失败时才抛出自定义异常,但另一方面始终将相同的异常记录到日志文件中,以便我们可以提供一些技术支持和帮助给用户,如果相同的情况经常发生并且从日志文件中获取所需的信息(技术上)。
2.显式捕获某些异常类型,并知道方法抛出异常的确切场景,然后处理它并具有一些预定义的代码,可以在调用该函数的其他方法中解释错误并采取一些操作。
这可能会有所帮助,如果您调用许多函数,并且它们都抛出一些预定义的异常,其中包含可以帮助对它们进行分类并相应地采取一些操作的异常代码。
在您提到的另一个评论中,如果您没有显式添加[HandleProcessCorruptedStateExceptions]部分到函数中,那么像OutOfMemoryException这样的系统关键异常将无法捕获。要了解何时应该处理它,请阅读此SO帖子以获取详细答案。

0

我认为这个问题非常具有投机性,通常取决于特定的情况,但我做了一些研究并将分享它。首先,我想表达我对Lain Galloway代码的看法:

try {
  GetDataFromNetwork("htt[://www.foo.com"); // FormatException?

  GetDataFromNetwork(uriArray[0]); // ArrayIndexOutOfBounds?

  GetDataFromNetwork(null); // ArgumentNull?
}
catch(Exception e)
{
  throw new WeClearlyKnowBetterException(
    "Hey, there's something wrong with your network!", e);
}

如果GetDataFromNetwork可能会抛出FormatException异常,那么这个外部调用应该有自己的方法,在其中处理该异常,并将其转换为自定义异常,就像这里一样:
try {
  GetDataFromNetwork();
} catch (FormatException ex) {
  // here you should wrap exception and add custom message, which will specify occuring problem
}

当我为特定应用程序创建自定义异常时,我会从Exception扩展MyGeneralException,并且每个更具体的异常都将扩展MyGeneralException。因此,在您包装成自定义异常时,您应该在方法中添加throws MyGeneralException。

我使用了一条规则,这条规则是我从比我更有经验的开发人员那里学来的,即在第一次可能抛出某些外部异常的地方,应该将其包装成自定义异常,因为您不希望依赖于其他模块的异常。

然后,如果您在任何地方使用该方法,您只需要在方法签名throws中放置MyGeneralException,它将通过应用程序的层级向上冒泡。它应该被捕获并在最高级别进行处理,大多数情况下,异常消息用于由某些处理程序创建响应,或者可以手动处理。

主要在设计异常处理期间,应该考虑到,如果您的库将使用第三方开发人员,则他们不会对处理许多异常感兴趣。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接