为什么.NET异常不能针对接口而不是基类工作?

21

.Net框架的try-catch实现仅允许你捕获继承自基类“System.Exception”的类型。为什么不能使用一个名为“System.IException”的接口来实现呢?

应用场景

我们在每个API中都使用继承自System.Exception的自定义基类。只有在记录异常后才会抛出这个基类异常,因此我们可以通过如下方式轻松避免重新记录:

try
{
    // Do something.
}
catch (LoggedException)
{
    // Already logged so just rethrow.
    throw;
}
catch (Exception ex)
{
    // TODO: Log exception.
    throw new LoggedException("Failed doing something.", ex);
}

这很棒,但如果你想要一个继承自另一个系统异常类型(如System.FormatException)的自定义异常,那就有些麻烦了。

现在唯一的处理方法是拥有两个自定义基类型,并且需要复制每个catch语句。

重构

如果.NET框架只是简单地寻找System.IException之类的东西,那么你可以简单地拥有一个自定义异常接口,例如CompanyName.ILoggedException,它继承System.IException,并将其实现于你所有的自定义异常类型中。因此,你的新catch代码会像这样:

try
{
    // Do something.
}
catch (ILoggedException)
{
    // Already logged so just rethrow.
    throw;
}
catch (IException ex)
{
    // TODO: Log exception.
    throw new CustomException("Failed doing something.", ex);
}

这种框架实现方式有实际原因吗?还是应该在未来的.Net框架版本中请求此功能?


你知道在 catch (Exception ex) 中可以直接使用 is 运算符,对吧?if (ex is ILoggedException) - xanatos
1
@xanatos:但这只是一个hack/变通方法,而不是内置功能。 - sll
1
@xanatos 嗯,我发帖后也想到了这个问题,但我个人很希望在未来的版本中能看到这样的东西...也许我们可以实现它! - Daniel Bradley
@Lasse 关键是他们同样可以实现一个基础的IException接口,并将Exception类基于IException实现,从而使得throw仅在基于IException的类中“合法”。用这种方式实现也会有相同的困难。他们没有这么做只有两个逻辑原因:他们没有考虑过(Java有一个Throwable类作为Throwable异常的基类,我们知道C#/.Net部分基于Java),或者正如Achim所说的那样。 - xanatos
这很有趣。一方面,由于catch被锁定为仅限于从System.Exception派生的类型,我可以理解打开它以接口的方式会破坏该检查(我想这是有充分理由的),但另一方面,除非对象从Exception(据我所知)派生,否则不会将其提交给这些测试,因此限制似乎有点多余,我认为公开基于接口分支异常处理的方法没有什么害处... - Steven
显示剩余8条评论
8个回答

20

你可能知道,在基类情况下我们只能进行单一继承,但在接口情况下一个类可以实现多个接口,所以如果你有如下代码:

class MyException : IExceptionB, IExceptionA
{
}

try
{
 throw new MyException();
}
catch(IExceptionB b){}
catch(IExceptionA b){}

现在这样做会导致歧义,不清楚该调用哪个catch处理程序,因为异常实现了两个接口。就层次结构而言,两者处于同一级别,而基类中不会存在两个处于同一级别的类。

此代码是假设性的,展示了如果允许基于接口的catch会出现的问题。


11
如果你把两个异常都放在 catch 语句块中,如何解决 ArgumentNullException 和 ArgumentException 之间的区别呢?这是相同的问题(因为 ArgumentException 继承自 ArgumentNullException)。catch 语句块的顺序定义了它们的相对优先级。 - xanatos
2
然而,这种情况可能会出现在父异常和子异常类型之间。在这种情况下,框架将使用第一个catch语句,但如果第一个抛出的类型也与第二个类型匹配,则会执行第二个catch语句。 - Daniel Bradley
1
你无法捕获接口,IExceptionA/B也不能是类,否则就没有多重继承了。在一个catch块内部新的throw语句不会被同一级别下的下一个catch块处理,即使它匹配异常类型。这里的答案和其中一个评论都是错误的。每个try/catch只使用一个catch块,实际上抛出的第一个匹配异常类型的catch块将被使用。因此不存在歧义。 - Lasse V. Karlsen
1
它将选择第一个代码块。与普通异常类型的逻辑相同。如果您的第一个 catch 块捕获 Exception,则所有其他块都将被忽略。请参阅我的答案。我认为您的想法是正确的,但主要问题是性能。 - Achim
1
现在,由于实现了异常类型继承,第一个匹配的catch块将被使用。 - codymanix
显示剩余5条评论

10
C#6引入了异常过滤器,因此现在可以在C#中实现您要求的功能(在VB.Net中早已有此功能long been possible in VB.Net)。我们现在可以使用when关键字。
以下是使用新语法重构的代码:
try
{
      ...
}
catch (Exception ex) when (!(ex is ILoggedException))
{
    // TODO: Log exception.
    throw new Exception("Failed doing something.", ex);
}

请注意,我们不再需要第一个catch块,因为它实质上只是一个过滤器,它所做的就是抛出异常。
类和接口定义:
public interface ILoggedException { }

public class CustomLoggedException : Exception, ILoggedException { ... }

8

可能还没有提到的一种更简洁的解决方法是使用扩展方法。通过利用异常数据字段,您可以从单个catch块中整齐地发现当前异常是否已经被记录,并根据需要采取行动。这将允许您构建许多不同的公司特定异常(已经隐含地记录了)。

所需的扩展方法:

private const string ExceptionLoggedKey = "IsExceptionLogged";

public static bool IsLogged(this Exception exception)
{
    if (exception.Data.Contains(ExceptionLoggedKey))
    {
        return (bool)exception.Data[ExceptionLoggedKey];
    }
    return false;
}

public static void SetLogged(this Exception exception)
{
    exception.Data.Add(ExceptionLoggedKey, true);
}

公司的异常遵循以下格式,在构造函数中设置IsLogged标志:

public class CompanysLoggedException : InvalidOperationException  //Could be any Exception
{
    public CompanysLoggedException()
    {
        this.SetLogged();
    }
}

try/catch 用法:

try
{
    throw new ArgumentException("There aren't any arguments.");
}
catch (Exception ex)
{
    if (ex.IsLogged())
        //Nothing additional to do - simply throw the exception
        throw;
    else
        //TODO Log the exception
        throw new CompanysLoggedException();
}

我同意这绝对不如根据实现的接口匹配异常那样整洁,但我认为这种模式非常简练易懂。不过需要记住在定义每个新公司异常时都要添加调用SetLogged()是一个小缺陷。


非常有帮助,昨天我刚看了数据收集方面的内容,正在起草一个新问题来检查该方法的有效性。我喜欢添加扩展方法以便于访问。我想到的唯一另外一个补充是使用单例对象作为键,因为它是一个对象->对象集合而不是字符串,以避免与其他人使用相同的键可能发生的冲突。 - Daniel Bradley
重构答案以提取字符串键 - 此值应该封装在扩展方法静态类中,因为它应该在此上下文之外访问,因此是私有常量。 - Alex
用VB.NET写类似上面的代码,使用“Catch...When”语句,并将其编码为方法包装器DLL,以绕过C#缺乏异常筛选器的限制,这个想法怎么样? - supercat

5

我不太清楚原因,但我认为这与性能有关。在出现异常的情况下,每个catch块都必须检查它是否匹配异常。如果只允许类型,那在.Net中就非常简单,因为你只有单一继承。实现接口的继承树可能会变得更加复杂。


这是一个非常好的论点。我不确定这是否真的会成为一个问题。我认为一个对象的已实现类/接口列表保存在它的类型中,类似于一个字典,所以访问它只比检查父列表多了一点点开销,但仍然… - xanatos
看看我的帖子。同意,但在通用语言中,我认为实现这一点并不困难,而是将其引入到语言标准中很困难(请参见“仅接口”原因)。 - Alan Turing
有趣的想法,有人有关于父类和接口类型检查性能差异的参考资料吗? - Daniel Bradley
@info_dev 做了一些次。实现了一个接口的4个异常类。http://pastebin.com/6RAE8dCW 有时候(Release + Ctrl-F5运行,不带调试器)。唯一需要注意的是第一次运行会稍微慢一些(顺序并不重要)。可能是因为如果我没记错的话,类型层次结构只在请求时构建,然后缓存。 - xanatos

2
在VB中,可以使用以下代码捕获接口:
  ' 不需要外部函数,但如果需要实际使用Ex作为IMyExceptionInterface,则需要类型转换
  Catch Ex As Exception When TypeOf(Ex) Is IMyExceptionInterface
  ' 另一种形式:需要预先声明变量和一个函数:
  ' Function TryCastIntoSecondParam(Of TSource As Class, TDest As Class) _
  '                                (ByVal Thing As TSource,  ByRef Dest As TDest)
  '   Dim Result As TDest
  '   Result = TryCast(Thing, TDest)
  '   Dest = Result
  '   Return Dest IsNot Nothing
  ' End Function
  Catch Ex As Exception When TryCastIntoSecondParam(Ex, myIMyException)

如果VB或C#编译器实现者想要这样做,他们可以允许使用以下语法:

  Catch Ex As IMyExceptionInterface  ' vb
  catch IExceptionInterface ex       ' C#

并使用上述代码实现。即使没有编译器支持,VB用户也可以通过上述代码获得正确的语义。在C#中,需要捕获异常,测试它是否是所需类型,并在不是的情况下重新抛出;这与使用过滤器避免首先捕获异常的语义不同。请注意,为了实现上述结构,C#编译器必须使用过滤器块,但不必向程序员公开所有过滤器块的功能——这是C#实现者故意拒绝做的事情。

所有这些都说完了,我怀疑对于原始问题的答案可能是“设计者想不到任何好的用例”,或者可能是“接口需要更复杂的类型解析,从而可能导致仅决定是否捕获异常就会失败并引发自己的异常。”

实际上,我恰好不喜欢使用类类型作为决定捕获哪些异常的手段,因为是否捕获异常的问题通常与导致异常的具体原因的问题大相径庭。如果加载文档的尝试失败,我对某个参数是否超出范围或某个索引是否超出范围的问题并不那么感兴趣,而是对我能否通过假装我从未进行过尝试来安全地恢复尝试的问题感兴趣。真正需要的是一种异常“严重程度”度量标准,它可以随着异常沿调用链向上传递而增加或减少。这样的东西在vb.net中可能是有点实用的(它具有异常过滤器),但在C#中可能不太实用(它没有),但无论如何都会受到内置异常中缺乏任何支持的限制。

编辑/补充

如果使用VB编写的DLL实现try/filter/catch/finally包装器并调用一些委托,则可以在C#项目中使用异常过滤器。不幸的是,使用这样的DLL需要在运行时效率和代码可读性之间做出一些权衡。我没有考虑为捕获任意数量的接口而实现这样的DLL;我不知道将此功能包含在DLL中是否有任何优势,相对于传递lambda表达式或匿名方法来测试是否应该捕获异常。
顺便说一下,包装器可以提供另一个特性,在C#中缺少的能力,即报告双重错误条件(主线中发生异常,随后在“finally”块中发生另一个异常),而无需捕获初始异常。当在finally块中发生异常时,通常比在主线中发生异常更为严重,因此不应该被压制,但是允许“finally块”异常上升到调用堆栈会破坏任何原始异常的证据。虽然周围的代码可能更关心清理失败而不是原始异常,但记录两个异常很可能比抑制原始异常更有用。

+1 这是一个非常巧妙的解决方案。真遗憾 C# 不支持这个功能,以前从未见过这样做。对于其他人来说,这是一个有用的参考:http://blogs.msdn.com/b/jaredpar/archive/2008/10/09/vb-catch-when-why-so-special.aspx - Daniel Bradley
@info_dev:请参见上面的补录。 - supercat
我认为确定异常严重程度的问题并不是你所描述的那样可以概括的(假设我理解你的意思)。能否从错误中恢复很大程度上取决于实现。如果由于用户输入了不存在的路径而无法加载文件,则可以通过告知用户其错误来轻松恢复。但是,如果您尝试加载不存在的配置文件,则恢复要复杂得多。同样的错误/异常,但完全不同的严重程度。 - Christian Palmstierna
@CPX:试图加载文件的代码会将导致系统在尝试之前和之后处于相同状态的加载失败视为“干净失败”。试图加载配置文件并在此过程中遇到任何失败(即使是“干净”的失败),都应将“干净失败”升级为更“严重”的失败。理想情况下,异常机制应允许升级异常的严重性,而无需捕获和重新抛出,但是对于任何标准异常,.net中不存在这样的机制,并且使用筛选器块实现它会很笨拙。 - supercat

1

这几乎就是你所提到的,只是没有一些“代码糖”而已:

try
{
}
catch(LoggerException ex) 
{ 
    ex.WriteLog(); 
} 
catch(Exception ex)
{
    ILoggerException1 l1 = ex as ILoggerException1; 
    if (l1 != null)
    {
        l1.WriteLog1();
    }
    else
    {
        ILoggerException2 l2 = ex as ILoggerException2; 
        if (l2 != null)
        {
            l2.WriteLog2();
        }
        else
        {
            ILoggerException3 l3 = ex as ILoggerException3; 
            if (l3 != null)
            {
                l3.WriteLog3();
            }
            else
            {
                throw ex;
            }
        }
    }
}

有编译器的支持,它应该被写成:

try
{
}
catch(LoggerException ex)
{
    ex.WriteLog();
}
// no more classes is allowed by the compiler be here, only interfaces in a tail catch recursion
catch(ILoggerException1 ex1)
{
    ex1.WriteLog();
}
catch(ILoggerException2 ex2)
{
    ex2.WriteLog();
}
catch(ILoggerException3 ex3)
{
    ex3.WriteLog();
}

捕获并重新抛出异常与不捕获异常是不同的。重新抛出“ex”将丢失堆栈跟踪中的所有信息;未指定“ex”而抛出异常会稍微好一些;它将丢失当前方法调用失败的例程的行号,但保留其余堆栈跟踪。这仍然意味着在捕获和重新抛出异常之前将运行任何嵌套的清理代码,这通常从调试的角度来看很烦人,并且偶尔会在生产代码中引起问题。 - supercat

1

异常不仅仅是让你的代码知道出了什么问题的一种方式,它包含各种调试信息(堆栈跟踪、目标、内部异常、hresult、Watson 桶等),这些信息必须被收集到某个地方。最合乎逻辑和简单的解决方案是让基类来收集。


是的,拥有接口的默认实现对于重用非常有用,但是任何实现接口的人都必须实现所有相关函数,因此可以自行重新实现。 - Daniel Bradley
将其放在基类中可以确保由于开发人员的错误而导致的调试信息不会不正确。当然,您可以在自己的实现中调用“默认”实现,但您也可以选择不这样做。 如果我们进一步推论您的论点,那么为什么我们要使用类继承呢?我们只需实现接口并在适当时使用基本实现即可。 - Christian Palmstierna
@supercat:是的,但这并不能保证ExceptionInfo包含任何内容。我认为把异常信息留给实现者自行决定似乎不是一个好主意... - Christian Palmstierna
1
@CPX:任何未能提供有效ExceptionInfo的异常实现都将失效。今天,异常类可以以完全奇怪和不合理的方式覆盖现有属性,这将破坏大多数“catch”代码,但这似乎并不是一个特别的问题。实际上,正如我在答案中所指出的那样,我认为捕获接口比捕获类更加强大,但从根本上讲,我不喜欢使用类型作为决定要捕获什么的主要因素的概念。 - supercat
@CPX:子类可以重写 ToString、StackTrace、Source 和 Data 等内容。 - supercat
显示剩余2条评论

0
原来自2.0框架以来就有一个接口:
System.Runtime.InteropServices._Exception

如需有关此接口的更多信息,请单击此处

不过,我使用了一个变通方法,使用了一个抽象类并实现了该类。虽然这让我感到有些不舒服,但对于我所需的实现来说,它实际上是更容易的。


1
这个接口是存在的,但如果 System.Exception 没有继承它,那么就没有多态性到任何异常类。而事实上就是这种情况。 - Prometheus
它仅用于非托管代码访问异常数据,不应从托管代码中调用。 - jwdonahue

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