【问题标题】:Should TryFoo ever throw an exception?TryFoo 应该抛出异常吗?
【发布时间】:2012-08-29 22:39:05
【问题描述】:

.NET Framework 中的一个常见模式是 TryXXX 模式(我不知道他们是否真的这么称呼它),其中被调用的方法尝试做某事,如果成功则返回 True,或者False 如果操作失败。一个很好的例子是通用的Dictionary.TryGetValue 方法。

这些方法的文档说它们不会抛出异常:失败将在方法返回值中报告。到目前为止一切顺利。

我最近遇到了两种不同的情况,其中实现 TryXXX 模式的 .NET Framework 方法会引发异常。详情请见Bug in System.Random constructor?Uri.TryCreate throws UriFormatException?

在这两种情况下,TryXXX 方法调用了引发意外异常的其他方法,并且这些异常已转义。我的问题:这是否违反了不引发异常的隐含合同?

换一种说法,如果你写TryFoo,你能保证异常不会逃逸吗,这样写?

public bool TryFoo(string param, out FooThing foo)
{
    try
    {
        // do whatever
        return true;
    }
    catch
    {
        foo = null;
        return false;
    }
}

这很诱人,因为这样可以保证不会逃脱任何异常,从而遵守隐含的合同。但它是一个 bug 隐藏器。

根据我的经验,我的直觉告诉我这是一个非常糟糕的主意。但是如果TryFoo 让一些异常逃逸,那么它真正的意思是,“我不会抛出任何我知道如何处理的异常”,然后合同的整个想法“我不会抛出异常”是扔出窗外。

那么,你的意见是什么? TryFoo 应该处理所有异常,还是只处理预期发生的异常?你的理由是什么?

【问题讨论】:

    标签: .net exception


    【解决方案1】:

    这取决于。 TryParseFoo 应该捕获所有解析异常,例如 null 或格式错误的字符串。但也有一些不相关的异常(比如ThreadAbortException)不应该被自动静默。

    在 Python 中(请耐心等待),捕捉KeyboardInterrupt 异常可能会造成很多麻烦,这基本上意味着即使您退出了 Ctrl-C,您的程序也会继续运行。

    我的经验法则是,当您想使用 Foo 而不清理您的输入时,您应该使用 TryFoo。如果TryFoo 捕捉到与Foo 输入您的输入无关的错误,那就太激进了。

    【讨论】:

    • +1:仅仅因为它是一个 Try... 方法就捕获所有异常是没有意义的。它应该处理与解析机制相关的所有异常。许多人认为空的 catch 语句或捕获 Exception(基类)是不好的做法。我必须同意,这就是为什么它们被称为异常(不是因为你已经在期待它们了)。
    【解决方案2】:

    不保证不抛出异常。考虑一下 Dictionary 上的这个简单示例...

    var dic = new Dictionary<string, string>() { { "Foo", "Bar" } };
    
    string val = String.Empty;
    string key = null;
    
    dic.TryGetValue(key, out val); // oops
    

    我发送了一个空键,我得到一个 NullArgumentException。反射器因此显示代码...

    private int FindEntry(TKey key)
    {
      if (key == null)
      {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
       }
       // more stuff
    }
    

    在我看来,合同的含义是“如果你尝试做的事情失败了,我不会抛出异常”,但这远非“我不会抛出任何异常”。由于您可能没有编写框架,因此您可以妥协于这样的事情......

    catch( Exception ex )
    {
      Logger.Log( ex );
      Debug.Assert( false );
      foo = null;
      return false;
    }  
    

    ...并且不要使用该 catch 块处理 TryXXX 失败案例(因此您没有一堆琐碎的日志条目)。在这种情况下,您至少会揭示错误的性质并在开发过程中识别它。时间以及在运行时记下它。

    【讨论】:

      【解决方案3】:

      我不认为TryFoo 不能抛出异常。我认为TryFoo 的约定是将无效输入作为正常情况处理,而不是例外情况。也就是说,如果由于输入无效而导致任务失败,它不会抛出异常;如果由于任何其他原因失败,它应该抛出异常。 TryFoo 返回后我的期望是它处理了我的输入,或者 我的输入无效。如果这些都不是真的,我想要一个例外,所以我知道。

      【讨论】:

        【解决方案4】:

        您链接到的另外两个 StackOverflow 问题似乎是真正的错误。例如,Uri.TryCreate 抛出 UriFormatException 的那个绝对是合同的中断,因为该方法的 MSDN 文档明确指出:

        如果无法创建 Uri,则不抛出异常。

        另一个,TryDequeue,我找不到任何文档,但它似乎也是一个错误。如果这些方法被称为 TryXXX,但文档明确指出它们可以抛出异常,那么您可能有道理。

        这种方法通常有两个版本,一个抛出异常,一个不抛出异常。例如,Int32.ParseInt32.TryParseTryXXX 方法的重点不是隐藏 异常。它适用于失败实际上不是异常情况的情况。例如,如果您正在从控制台读取一个数字并想继续尝试直到输入正确的数字,您不想继续捕获异常。同样,如果您想从字典中获取一个值,但它可能不存在是完全正常的情况,您可以使用TryGetValue

        总结TryXXX 不应该抛出异常,也不是为了隐藏异常而设计的。它适用于失败是正常且非异常的情况,并且您希望轻松检测该情况而无需捕获异常的开销和努力。通常也会提供一个非 TryXXX 方法,它确实提供了异常,用于异常的情况。

        【讨论】:

        • 我需要跟进 Uri.TryCreate 异常。这是一个非常有趣的案例。
        【解决方案5】:

        您几乎不应该捕获所有异常。如果您必须在 XXX 周围使用 try/catch 对来实现 TryXXX(而不是首选,XXX 根据 TryXXX 实现并抛出错误结果),请仅捕获特定的预期异常.

        如果存在语义上不同的问题,例如程序员错误(可能在不期望的地方传递 null),TryXXX 可以抛出。

        【讨论】:

          【解决方案6】:

          这取决于方法试图做什么。

          例如,如果该方法从数据读取器读取字符串值并尝试将其解析为整数,则在解析失败时不应抛出异常,但如果数据读取器无法读取,则应抛出异常数据库,或者您正在读取的值根本不是字符串。

          该方法应遵循捕获异常的一般原则,即您应该只捕获您知道如何处理的内容。如果发生了一些完全不同的异常,你应该让它冒泡到知道如何处理它的其他代码。

          【讨论】:

            【解决方案7】:

            TryXXX 背后的想法是,如果您简单地调用相应的 XXX 调用,它不会抛出通常会发生的特定类型的异常 - 该方法的使用者知道他们忽略了通常会发生的任何异常发生。

            这很诱人,因为这样可以保证不会出现任何异常,从而遵守默示合同。

            这并不完全正确 - 有一些例外情况可以转义 try/catch(例如 OutOfMemoryExceptionStackOverflowException 等)。

            【讨论】:

            • 任何异常?如果传入的键上的 GetHashCode 调用引发异常时没有引发“TryGetValue”怎么办?
            • 不,不是任何例外。我会澄清的。
            【解决方案8】:

            但是如果 TryFoo 让一些异常逃逸,那么它真正的意思是,“我不会抛出任何我知道如何处理的异常”,然后是合同的整个理念“我不会抛出异常”被扔出窗外。

            我想你自己在这里说的:)

            TryXXX 应该只保证它不会抛出它知道如何处理的异常。如果它不知道如何处理给定的异常,为什么要捕获它?你也一样——如果你不知道该怎么做,你为什么要抓住它?例如,BadAllocExceptions,好吧,(通常)对这些没什么可做的,除了在你的主要 try/catch 块中捕获它们显示一些错误消息并尝试优雅地关闭应用程序..

            【讨论】:

              【解决方案9】:

              像您的示例中所做的那样捕获并吞下所有异常通常是一个坏主意。一些例外情况,尤其是 ThreadAbortExceptionOutOfMemoryException 不应被吞下。实际上,第一个不能被吞下,并且会在你的 catch 块结束时自动重新抛出,但仍然。

              Phil Haack 有一个关于这个确切主题的 blog entry

              【讨论】:

              • 哦,我不会写那样的代码。我完全理解这是一个坏主意。这只是一个例子。
              【解决方案10】:

              换一种说法,如果你在写 TryFoo,你会保证 异常无法逃脱,通过 这样写?

              不,我不会在我的 TryFoo 方法中吞下异常。 Foo 依赖于 TryFoo,而不是相反。既然您提到了具有 GetValue 和 TryGetValue 方法的通用字典,我将按如下方式编写字典方法:

              public bool TryGetValue(T key, out U value)
              {
                  IList<KeyValue> kvCollection = internalArray[key.GetHashCode() % internalArray.Length];
                  for(KeyValue kv in kvCollection)
                  {
                      if(kv.Key == key)
                      {
                          value = kv.Value;
                          return true;
                      }
                  }
                  value = default(U);
                  return false;
              }
              
              public U GetValue(T key)
              {
                  U value;
                  if (TryGetValue(key, out value))
                  {
                      return value;
                  }
                  throw new KeyNotFoundException(key);
              }
              

              因此,GetValue 依赖于 TryGetValue,如果 TryGetValue 返回 false,则会引发异常。这比从 TryGetValue 调用 GetValue 并吞下产生的异常要好得多。

              【讨论】:

              • 是的,我理解这种模式。但是如果 key.GetHashCode() 出于某种原因抛出异常怎么办?您的论点是 TryGetValue 应该让该异常逃脱吗?顺便说一句,您的 GetValue 方法应该返回 U 而不是 bool。
              • 是的,如果GetHashCode抛出异常,异常应该逃逸。这里的关键是范围和关注点分离:TryGetValue 安全地测试一个键是否存在于字典中,它不测试 GetHashCode 方法是否正确实现。
              【解决方案11】:

              我曾经参加过一个很棒的演讲,有人说他参与了 BCL 异常处理策略的制定。不幸的是,我忘记了他的名字,也找不到我的笔记。

              他这样描述策略:

              1. 方法名称必须是描述该方法所采取的操作的动词。
              2. 如果名称所描述的操作由于任何原因没有发生,则必须抛出异常。
              3. 应尽可能提供测试和避免即将发生的异常的方法。例如。如果文件不存在,调用 File.Open(filename) 将引发异常,但首先调用 File.Exists(filename) 可以避免这种情况(大多数情况下)。
              4. 如果有明显的原因(例如常见情况下的性能),可以添加一个额外的方法,调用 TryXXX,其中 XXX 是原始方法的名称。此方法应该能够处理单一常见故障模式,并且必须返回一个布尔值来指示成功或失败。

              这里有趣的一点是#4。我清楚地记得他说过指南中的单一故障模式部分。其他故障仍应引发异常。

              顺便说一句,他还说 CLR 团队告诉他 .Net 异常缓慢的原因是因为它们是在 SEH 之上实现的。他们还表示,没有特别需要以这种方式实施它们(除了权宜之计),如果他们曾经进入真实客户的前 10 名实际性能问题,他们会考虑重新实施它们以更快!

              【讨论】:

                【解决方案12】:

                首先要记住异常的含义:

                异常意味着您的方法无法按照其名称执行的操作。

                我们首先实现TryFoo 模式的原因是抛出异常的代价是昂贵的,另外,如果您要求调试器在第一次抛出异常时中断,它可能非常烦人的。因此,如果您的方法具有一种特别常见的故障模式,例如 Int32.Parse,它在输入无效输入时会抛出 ArgumentException,这将是有问题的。

                TryFoo 模式背后的想法是,您的方法应该能够指示特别常见的故障模式,而不会引发异常。因此,您应该从不以下列方式实现它:

                bool TryFoo() {
                    try {
                        Foo();
                        return true;
                    }
                    catch (SomeKindOfException) {
                        return false;
                    }
                }
                

                因为这首先颠覆了TryFoo 模式的全部目的。

                就抛出异常而言,答案是肯定的,有时您的TryFoo 方法可能需要抛出异常。具体来说:当方法因您期望的特别常见原因以外的原因而失败时。此处的示例可能是OutOfMemoryException、堆栈溢出、未部署的预期程序集或外部 Web 服务不可用。这些故障模式表明某些事情需要注意,不应忽视。

                【讨论】:

                  猜你喜欢
                  • 2013-05-20
                  • 1970-01-01
                  • 2011-10-13
                  • 2014-05-02
                  • 1970-01-01
                  • 1970-01-01
                  • 2019-01-27
                  • 1970-01-01
                  • 1970-01-01
                  相关资源
                  最近更新 更多