【问题标题】:How heavy .NET exception handling is?.NET 异常处理有多重?
【发布时间】:2012-02-11 19:48:29
【问题描述】:

有时我会遇到这种情况,将整段代码包装在 try-catch 块中而不是进行大量检查会大大降低代码的可读性。 比如这个

var result = string.Empty;
if (rootObject != null)
{
    if (rootObject.FirstProperty != null)
    {
        if (rootObject.FirstProperty.SecondProperty != null)
        {
            if (!string.IsNullOrEmpty(rootObject.FirstProperty.SecondProperty.InterestingString))
            {
                result = rootObject.FirstProperty.SecondProperty.InterestingString;
            }
        }
    }
}

我真的更喜欢这样做

var result = string.Empty;
try
{
    result = rootObject.FirstProperty.SecondProperty.InterestingString;
}
catch { }

但是在代码审查之后,我经常听到我的导师说,当可以进行简单检查时,我应该避免使用 try-catch 块。真的如此关键,并且每个 try-catch 块都会(相对)消耗大量系统资源吗?是否仅在引发错误或每种情况(成功与否)同样“严重”时才使用此资源?

【问题讨论】:

  • 如果您为我工作并编写此代码:catch { } 您将不再为我工作。
  • 只需创建一个测试用例,在其中使用 TryParse 和 Parse+trycatch 解析一个 int。
  • @SteveWellens - 我假设catch {} 只是示例代码。
  • @VitaliiKorsakov - 关键是你不应该捕捉所有异常,只捕捉你知道你的代码会引发的异常。在这种情况下NullReferenceException.
  • @VitaliiKorsakov “但是 catch {} 和...之间的区别是什么?” Catch {} 就像断开您孩子卧室的烟雾探测器一样。 Catch {} 就像在汽车仪表板上的白痴灯上贴上黑色胶带。 Catch {} 可能会隐藏某些严重错误的事实。它可以使调试成为一场噩梦。

标签: c# .net error-handling try-catch


【解决方案1】:

在大多数情况下,您不应该测试引用是否不为 null,因为 null 甚至不应该在可能值的范围内。如果一个函数不能返回可能值范围内的东西,那么这个函数除了抛出异常别无选择。这应该在没有明确的空检查的情况下发生。它应该在为例如构造返回值时发生

但这意味着您需要不可为空的类型,这是该语言理想地支持但 C# 不支持的类型。

您可以使用 Jon Skeet 的 NonNullable<> 之类的内容。

【讨论】:

    【解决方案2】:

    .NET 框架中的异常相对较多 - 值得付出一些努力来避免它们。毕竟,它们被称为Exceptions——它们不应该很常见。

    也就是说,它们远没有某些人想象的那么贵。

    在 Visual Studio 下调试时,处理异常可能需要几秒钟,因为 IDE 会显示捕获异常的行。因此,有些人认为每个异常都需要几秒钟的时间来处理,因此必须不惜一切代价避免它们。

    我看到人们将糟糕的系统性能归咎于它一个小时会抛出几十个异常。

    这是一个神话。该系统完全能够每秒抛出和捕获数千个异常。

    也就是说,您可以使用如下几个简单的扩展函数来整理您的原始代码:

    var result
        = rootObject.With( r => FirstProperty)
              .With( r => r.SecondProperty)
              .Return( r => r.InterestingString, string.Empty);
    

    With 有这个定义:

        public static TResult With<TInput, TResult>(
            this TInput o, 
            Func<TInput, TResult> evaluator)
            where TResult : class
            where TInput : class
        {
            if (o == null)
            {
                return null;
            }
    
            return evaluator(o);
        }
    

    Return 有这个:

        public static TResult Return<TInput, TResult>(
            this TInput o, 
            Func<TInput, TResult> evaluator, 
            TResult defaultValue) 
            where TInput : class
        {
            if (o == null)
            {
                return defaultValue;
            }
    
            return evaluator(o);
        }
    

    【讨论】:

    • +1,试图搜索这个(扩展方法),但在任何地方都找不到。
    • 太棒了!几乎是我需要的!附言我认为使用 With 扩展名时不需要 null
    • 这是一个解决方案的好主意,但术语With 并没有对我说“如果为空则不评估”。与Return 结合使用时有意义,但最初单独看到时则没有意义(例如,当有人第一次通过智能感知“发现”它时)。你能给它一个更好的方法名称吗?对此解决方案的一些相关讨论 - stackoverflow.com/questions/4244225/…stackoverflow.com/questions/4244225/… - 另外,我建议在某处干扰 default(TInput) :)
    • 名称 一个挑战 - 我发现有一整类方法的名称与十年前的旧动词/名词约定不太相符前。我没有比 With() 更好的了 - 像 EvaluateIfNotNull() 这样的描述性名称会引入太多噪音,比我们试图解决的问题还要糟糕。
    • 糟糕,同一个链接发布了两次。这是我要发布的第二个:stackoverflow.com/questions/1917531/…
    【解决方案3】:

    异常处理

    我不会担心这样的代码中的异常“有多大”。稍后我会解释原因。

    例外是针对特殊情况。拥有异常处理程序意味着您希望这些属性永远不会为空。

    何时编写保护条件

    这些属性通常是null吗?

    如果是这样,这不是一个例外情况。您应该编写适当的空测试,并将您的异常处理程序代码更改为空条件代码。

    这些属性为null 是否不常见,也就是说,您是否可以合理地期望它们永远不会是null

    如果是这样,您可以简单地避免编写 null 检查,而让底层代码抛出。但是,您不会获得很多关于哪个属性引发异常的上下文。

    您也可以进行null 检查并抛出更具体的上下文异常。

    何时编写异常处理程序

    如果这些属性不常见为null,那么这是一种例外情况。但这并不意味着你必须有一个处理程序。

    您是否有一种简单的方法可以在异常情况发生之前对其进行测试?

    如果是这样,那么您应该在允许您使用的底层代码引发异常之前对此进行测试。由于您只需检查null,我会说这很容易。

    你有合理的逻辑来处理这个级别的这个案例吗?

    如果你有一个合理的方法来处理那个级别的异常情况,并且仍然保证你的方法正确执行,那么继续添加处理程序代码。如果您依赖于返回null 之类的机制,那么请确保从消费者的角度来看,他们不会总是得到结果是有道理的。例如。将方法命名为 FindInterestingString 而不是 GetInterestingString

    如果您没有合理的方法来处理这种情况,请不要将异常处理程序置于该级别。让您的异常冒泡并在代码的较高位置处理它。

    如果你根本没有合理的方法来处理异常,那就让程序崩溃吧。这总是比吞下异常并继续好。这隐藏了错误。

    这些规则的例外情况

    有时您无法在不引发异常的情况下轻松测试条件。

    文件系统等外部依赖项将在您的程序下发生变化。即使您进行了预测试,并且即使预测试通过了,一旦您尝试使用该对象,也可能会引发异常。在这种情况下,你无能为力,必须依赖异常处理。

    电子邮件地址和 URI 等复杂验证可能需要您使用引发异常的构造。再说一次,它可能不会。您应该始终寻找最合适的方法来进行与您的意图相匹配的错误处理。仅在必要时才使用异常处理。

    性能

    性能不太可能成为错误检测代码的问题。

    在高使用率的代码(当您编写框架时)、应用程序的瓶颈以及众所周知的 CPU/内存密集型算法中,性能很重要。您应该了解何时需要担心性能,但它应该始终是代码的可读性、可维护性和正确性的次要问题。

    您会发现不可能完美地预测整个应用程序中的性能问题。获得准确图片的唯一方法是在真实条件下使用真实场景运行代码并对其进行分析。在您开发应用程序达到这一点之前,您不应该担心性能,除非您知道这将是一个问题。

    使用异常的性能成本并不像许多人认为的那样高。在 .Net 中,它们被设计为在不引发异常时表现得非常好。这与例外是针对例外情况的事实相吻合。


    您的代码示例

    您提供的代码示例存在一些其他问题。希望我能在你被他们困住之前指出其中的一些。如果没有,希望您在遇到问题时可以回顾这些以获得指导。

    编写异常处理程序

    您为异常处理程序编写的代码根本不可接受。以下是编写更好的异常处理程序代码的一些指导:

    不好:

    try
    {
    }
    catch // Note: Doesn't catch `Exception e`
    {
        // ... eats the exeption
    }
    

    这是一种不好的形式,永远不应该使用。您绝对无法正确处理所有异常类型。最常用的例子是OutOfMemoryException

    可能接受:

    try
    {
    }
    catch(Exception e)
    {
        logger.Log(e.ToString());
        // ... eats the exeption
    }
    

    如果您捕获异常并将其记录或显示,则可以吃掉异常。只有在您积极监控/报告这些异常并且有办法保证这些异常会被诊断出来时,这才可以。

    好的:

    try
    {
    }
    catch(Exception e)
    {
        logger.Log(e.ToString()); // Make sure your logger never throws...
        throw; // Note: *not* `throw e;`
    }
    
    // Or:
    
    try
    {
    }
    catch
    {
        // Todo: Do something here, but be very careful...
        throw;
    }
    

    如果你非常小心不要创建新的异常,并且如果你重新抛出异常,你可以在异常处理程序中做任何你想做的事情。这将保证错误被注意到。如果重新抛出异常,请确保使用throw; 而不是throw e;,否则您的原始堆栈跟踪将被破坏。

    好:

    try
    {
    }
    catch(NullReferenceException e)
    {
        // ... Do whatever you want here ...
    }
    

    这是安全的,因为您只捕获已知由try 块中的代码抛出的某些异常类型。很容易理解代码的意图,也很容易进行代码审查。异常处理代码好不好,很容易理解。

    避免重复代码

    在可以避免的情况下,切勿重新访问属性。而不是像这样编写访问您的属性的代码:

    rootObject ...
    rootObject.FirstProperty ...
    rootObject.FirstProperty.SecondProperty ...
    rootObject.FirstProperty.SecondProperty.InterestingString ...
    

    ...只调用一次 getter:

    var firstProperty = rootObject.FirstProperty;
    var secondProperty = firstProperty.SecondProperty;
    var interestingString = secondProperty.InterestingString;
    

    您的代码示例看起来更像这样:

    if (rootObject != null)
    {
        var firstProperty = rootObject.FirstProperty;
    
        if (firstProperty != null)
        {
            var secondProperty = firstProperty.SecondProperty;
    
            if (secondProperty != null)
            {
                var interestingString = secondProperty.InterestingString;
    
                if (!string.IsNullOrEmpty(interestingString))
                {
                    result = interestingString;
                }
            }
        }
    }
    

    这样做的一个原因是 getter 可能具有复杂的逻辑,并且多次调用它可能会导致性能影响。

    另一个原因是你avoid repeating yourself。没有太多重复的代码总是更易读。

    当您重复自己时,可维护性也会受到影响。如果您更改其中一个属性的名称,您将不得不更改它所在的每一行代码,这将导致更难推断更改的影响。

    避免深入挖掘依赖层次结构

    您应该避免在同一方法中进行链式属性访问。即:

    rootObject.FirstProperty.SecondProperty.InterestingString
    

    即使您已将其拆分以避免重复自己(如我上面建议的那样),您仍然可能没有正确考虑您的代码。您的代码仍然与该数据结构的层次结构紧密耦合。每当您更改该层次结构时,都需要更改遍历该层次结构的任何代码。如果这就是你的所有代码,那么你的状态很糟糕。

    为避免这种情况,请将了解每个级别的代码与其下面的级别分开。

    处理根对象的代码应该只调用处理根直接下面的对象的代码。处理FirstProperty 的代码应该只知道SecondProperty 级别(在FirstProperty 下)的属性。唯一应该了解InterestingString 的代码是SecondProperty 返回的对象类型的处理程序代码。

    一个简单的方法是将遍历代码拆分,并将其移动到对象本身中。

    见:

    拆分逻辑的示例代码:

    public class SomeClassUsingRoot
    {
        public string FindInterestingString()
        {
            return root != null
                ? root.FindInterestingString()
                : null;
        }
    
        private RootSomething root;
    }
    
    public class RootSomething
    {
        public string FindInterestingString()
        {
            return FirstProperty != null
                ? FirstProperty.FindInterestingString()
                : null;
        }
    
        public SomethingTopLevel FirstProperty { get; set; }
    }
    
    public class SomethingTopLevel
    {
        public string FindInterestingString()
        {
            return SecondProperty != null
                ? SecondProperty.InterestingString
                : null;
        }
    
        public SomethingLowerLevel SecondProperty { get; set; }
    }
    
    public class SomethingLowerLevel
    {
        public string InterestingString { get; set; }
    }
    

    这不是解决问题的唯一方法。关键是将处理每个级别的逻辑拆分为单独的方法,或者(甚至更好)单独的对象。这样,当层次结构发生变化时,您的影响就会更小。

    【讨论】:

    • 谢谢,但使用通用 WithResult 方法的解决方案对我来说似乎更优雅
    • @VitaliiKorsakov:NP。该解决方案仍将受到“所有遍历代码在一个地方”问题的影响。但由您决定这对您是否重要。
    【解决方案4】:

    异常是重量级的还是轻量级的完全无关紧要。 本可以轻松阻止的抛出异常是错误。不要捕获异常:修复错误,这样您就不必这样做了。

    【讨论】:

    • 但这不是错误。这只是我尝试将检查替换为提供更多可读性的错误处理。唯一的问题是简单检查的可读性差。你能想象当程序有足够的系统资源每分钟引发超过 1000 个异常并且它根本不会影响 UI 的情况吗?
    • @VitaliiKorsakov:你错过了我的观点。我再说一遍:异常是否轻量级无关紧要。将所有可避免的异常视为错误。这是对待异常的正确和正确的态度:它们是exceptional并表明某些事情是错误的。您应该能够在调试器中打开“中断所有已处理的异常”,并且每次调试器中断,这是一个错误。
    • @EricLippert,太真实了!我正在开发一个产品,该产品有很多地方会引发异常,以至于您根本无法使用“异常中断”功能。
    • @EricLippert '您应该能够在调试器中打开“中断所有处理的异常”,并且每次调试器中断时,这是一个错误。 -- 这在使用第三方库时是不可能的;有些在异常方面设计得很差。另一个经典示例是 I/O,您可以在读取之前检查文件是否存在,但这可能会因另一个用户在检查和实际读取文件之间删除文件而失败。
    • @phoog:至于你的第一点:其他人的错误库的存在是不让问题变得更糟的一个很好的理由。开始向他们报告错误。至于您的第二点:您描述的外生异常是不可避免,但仍然是异常。调试时意外发生这种事情的几率很小。
    【解决方案5】:

    视情况而定。

    如果rootObject可能 为空,则以第一种方式对其进行编码会更好,因为它不是特殊情况。但是,它会使方法的执行稍微变慢。尽管有一些方法可以重新编码嵌套的 if 语句以避免深度嵌套并允许快速退出该方法。

    另一方面,如果 正常 执行速度是个问题,并且rootObject不太可能为空,那么第二种方式编码更好,因为它**是 和特殊情况。

    您需要分析您的系统,看看哪种方法对您的应用程序更好

    【讨论】:

    • "但是,它会使方法的执行稍微慢一些。"我明白这一点,但是如果将 try-catch 块放置在性能不那么重要的 UI 中呢?
    • @VitaliiKorsakov - 多少取决于各种因素,实际上可能无法衡量。另外,该语句指的是防御性编程版本,而不是异常处理版本。
    【解决方案6】:

    如果您可以从一开始就阻止您的代码引发异常,那么您应该这样做。

    Try/Catch 块在正确使用时非常有用(例如,当您访问代码无法控制的内容时,例如打开网络连接时)。

    如果您认为代码的某个部分存在非致命错误,您也可以使用它们继续运行您的代码。只需确保正确处理 catch 块中的错误即可。

    但是,您应该考虑的一件事是编译器如何执行您的谓词。

    它将采用谓词的最左边的子句rootObject != null,如果它是假的,并且它与您的其他子句进行“与”运算,则该谓词保证评估为假。然后编译器将忽略谓词的其余部分,因此您可以执行以下操作:

    if (rootObject != null && rootObject.FirstProperty != null && rootObject.FirstProperty.SecondProperty != null && !string.IsNullOrEmpty(rootObject.FirstProperty.SecondProperty.InterestingString))
    {
        result = rootObject.FirstProperty.SecondProperty.InterestingString;
    }
    

    【讨论】:

    • 这样的检查条件让我有点害怕
    【解决方案7】:

    一种选择是使用code contracts。它们是一种非常干净的方式来执行您正在执行的检查类型,并且如果您正确配置调试构建,编译器实际上可以找到会违反您的合同的代码。一个空的 catch 块真的不是一个好主意(并不是因为它会使用资源......它只是因为很多原因而不是好的编码)。

    【讨论】:

      【解决方案8】:

      提高可读性的一种方法是反转您的条件:

      var result = string.Empty;
      if (rootObject == null) return result;
      if (rootObject.FirstProperty == null) return result;
      if (rootObject.FirstProperty.SecondProperty == null) return result;
      if (!string.IsNullOrEmpty(rootObject.FirstProperty.SecondProperty.InterestingString))
      {
          result = rootObject.FirstProperty.SecondProperty.InterestingString;
      }
      

      下一步是使用编译器将为您执行的条件快捷方式:

      var result = string.Empty;
      if (rootObject == null || rootObject.FirstProperty == null ||
          rootObject.FirstProperty.SecondProperty == null) return result;
      if (!string.IsNullOrEmpty(rootObject.FirstProperty.SecondProperty.InterestingString))
      {
          result = rootObject.FirstProperty.SecondProperty.InterestingString;
      }
      

      【讨论】:

      • 哦,那个根本不干净
      【解决方案9】:

      例外是针对......好吧,例外情况。它们适用于发生无法以其他方式计划的事情时。异常有一定的开销,使用它们来捕获像这样的一般问题被认为是不好的做法,特别是如果您只是忽略异常的结果(使用空的 catch 块)。

      它们可能会让你的代码LOOK更干净,但它们不会让你的代码EXECUTE更干净。

      【讨论】:

      • 对不起,我没有得到最后一句话。你什么意思?
      • @VitaliiKorsakov - 异常不干净。有很多工作在幕后进行。最重要的是,如果您的一个块中发生不同的异常会发生什么,您的全部捕获将忽略它,并且您将遇到难以追踪的错误。为此使用异常是不好的做法,您会受到任何专业开发人员的批评。
      • 异常是 goto 的一种形式。虽然它们有自己的位置,但您不想随意使用它们。
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2015-09-11
      • 1970-01-01
      • 1970-01-01
      • 2013-07-17
      相关资源
      最近更新 更多