异常过滤器的好坏(CLR)
为什么有些语言支持它们而另一些不支持呢?把它们加到我的新语言里是个好主意吗?我应该什么时候使用过滤器和catch/rethrow?就像很多事情一样,异常过滤器有好的一面也有坏的一面…
什么是异常过滤器?
CLR提供了许多高级语言可以构建的异常处理原语。有些是相当明显的,并且很容易映射到我们大多数人都知道和喜欢的语言结构:例如try/catch和try/finally。我敢猜测每个人都知道他们在做什么,但为了以防万一,让我们考虑一下C#:
try { try { Console.Write(“1”); if (P) throw new ArgumentException(); } finally { Console.Write(“2”); }
} catch (ArgumentException e) { Console.Write(“3”); } Console.Write(“4”);
如果P是真的,那么当然会打印出“1234”。如果P为假,则它将打印“124”。太棒了。
但是CLR还提供了另外两个EH原语:fault和filter。fault子句很像finally子句;它在异常逃逸其关联的try块时运行。区别在于finally子句也在控件正常离开try块时运行,而fault子句只在控件由于异常而离开try块时运行。在上面的例子中,如果我们将“finally”替换为“fault”(这里没有C#语法,但是暂时不要相信),那么如果P为真,它将打印“1234”,如果P为假,则“14”为假。看到区别了吗?大多数语言都不会将其公开为一级语言构造,但我们确实有一些语言在特定场景的封面下使用了fault子句。
这样我们就有了过滤器。我认为过滤器最简单的定义是它是一个允许构建条件catch子句的构造。实际上,这正是VB使用过滤器的目的。让我们考虑一个在VB中更复杂的例子:
Function Foo() As Boolean Console.Write(“3”) Return True End Function Sub Main() Dim P As Boolean = True Try Try Console.Write(“1”) If (P) Then Throw New ArgumentNullException() End If Console.Write(“P was False!”) Finally Console.Write(“2”) End Try Catch ex As ArgumentNullException When Foo() Console.Write(“4”) Catch ex As ArgumentException Console.Write(“5”) End Try Console.Write(“6”) End Sub
这里您将注意到“Catch ex As ArgumentNullException When Foo()”行是一个条件Catch语句。catch处理程序只在异常为ArgumentNullException且Foo()返回true时执行并打印“4”。如果Foo()返回false,则catch子句不会执行,系统将继续搜索可以处理异常的catch子句。在这种情况下,下一个子句将处理异常,并打印“5”。
那么,你认为这个程序会打印什么?不要试图编译和运行它来作弊!使用您所知道的异常处理和查看程序结构和语法,您认为这个程序会打印什么?我想大多数人都会猜到“12346”。我甚至给了你一个编号的线索。
我想我们中的大多数人会看到上面的例子,并得出结论,结果应该是“12346”,因为当我们看上面的语法时,我们很正确地看到词汇范围的语言结构。我们期望当内部finally子句中的代码开始执行时,关联try块中的任何地方都不会再执行任何代码。例如,在上面的例子中,如果P为true,那么当我们输入Finally时,我们知道在try块中将不会执行更多的代码,并且我们永远不会打印“P为False!”!”. 同样,当我们计算catch子句之一时,我们希望关联try块中的所有代码都已完成执行。
坏消息来了…
结果发现程序实际上打印了“13246”。我的编号线索是一个邪恶的诡计。在抛出之后,首先执行Foo()作为计算第一个catch子句的一部分,然后执行关联try块中的finally。这只是奇怪的…我们的词汇范围的语言结构发生了什么?!
对大多数人来说,这是一个令人惊讶的结果。它打破了我们基于语言范围的异常处理构造提供的关于语言的直觉推理。在这里,当我们计算条件catch子句时,关联try块中的所有代码实际上还没有完成执行。
为什么会这样?
我们在“2”之前看到“3”的原因是很微妙的,并且建立在CLR的异常处理实现中。CLR的异常处理模型实际上是一个“双过程”模型。当抛出异常时,运行库在调用堆栈中搜索异常的处理程序。第一次传递的目标是简单地确定堆栈上是否存在异常的处理程序。如果它看到finally(或fault)子句,它会暂时忽略它们。
处理程序可以采用类型化catch子句的形式,即“catch ex as ArgumentException”。当运行时看到类型化catch子句时,它可以通过执行一个简单的检查来确定异常对象是否属于继承自(或是)子句中类型的类型,从而确定此子句是否将处理异常。
但是,当运行时看到筛选器时,它必须执行筛选器,以确定关联的处理程序是否将处理异常。如果筛选器的计算结果为true,则已找到处理程序。如果计算结果为false,则运行时将继续搜索处理程序。
找到处理程序后,第一次传递结束,第二次传递开始。在第二次传递中,运行库再次从抛出点运行调用堆栈,但这次它执行它在到达第一次传递中标识的处理程序的路上找到的所有finally(或fault)子句。当到达处理程序时,将执行该处理程序,并最终处理异常。
为什么这么糟?
“好吧”,你说,“我明白了。我知道过滤器在第一次通过时运行。我能应付…有什么大不了的?“好吧,让我们先考虑一下finally子句的用途。我们通常使用最后子句来确保即使在面对异常时退出函数时,我们的程序状态仍然是一致的。我们把暂时破碎的不变量放回去。考虑使用try/finally构建C#“using”语句,然后考虑可以使用它做的所有事情。
但是当过滤器运行时,这些finally子句都没有执行。如果调用了关联的try块中的库,则在执行筛选器时可能尚未实际完成调用。在那种情况下你能回同一个图书馆吗?我不知道。可能有用。或者它可能会产生断言,或者异常,或者,嗯,你的猜测和我的一样好。关键是你说不出来。
明智地使用过滤器
但是条件catch子句的概念确实非常吸引人,而且有一些方法可以使用它们,而不会被过滤器何时实际执行的问题所困扰。关键是只从异常对象本身或不可变的全局状态读取信息,而不更改任何全局状态。如果将筛选器中的操作限制为仅这些操作,则筛选器何时运行并不重要,也不会有人知道筛选器运行不正常。
例如,如果有一个相当一般的异常,比如COMException,则通常只希望在它表示某个HRESULT时捕获它。例如,当它表示E_FAIL时,您想让它不受影响,但当它表示E_ACCESSDEINED时,您想捕捉它,因为对于这种情况,您有另一种选择。
这里,这是一个非常合理的条件catch子句:
Catch ex As System.Runtime.InteropServices.COMException When ex.ErrorCode() = &H80070005
另一种方法是将条件放在catch块中,如果不符合条件,则重新引发异常。
例如:
Catch ex As System.Runtime.InteropServices.COMException
If (ex.ErrorCode != &H80070005) Then Throw
从逻辑上讲,这个“catch/rethrow”模式与过滤器做的事情相同,但有一个微妙而重要的区别。如果异常未被处理,那么程序状态在两者之间是完全不同的。在catch/rethrow情况下,未处理的异常似乎来自catch块中的Throw语句。除此之外,将没有调用堆栈,直到catch子句的任何finally块都将被执行。两者都使调试更加困难。在filter情况下,异常从原始抛出点开始未经处理,finally子句未更改任何程序状态。
问题是我们依赖程序员的纪律来正确地使用过滤器,但是很容易错误地使用它们,并最终导致不经常执行的代码(毕竟,异常是在特殊情况下发生的),这些代码由于不一致的程序状态而具有微妙的、难以诊断的错误,这些错误本应在最后清除从句在后面。
为什么CLR使用两次传递的异常处理模型?
CLR实现了两遍异常处理系统,以便更好地与非托管异常处理系统进行互操作,如Win32结构化异常处理(SEH)或C++异常处理。我们必须在第二次传递上运行finally(和fault)子句,以便它们与非托管等价物一起按顺序运行。同样,我们不能稍后执行筛选器(例如,在第二次传递时),因为其中一个非托管系统可能已经记住它应该负责处理异常。如果我们在第二次传递的后期运行筛选器,并决定托管子句确实应该在先前没有在第一次传递时声明该异常之后捕获该异常,那么我们将违反与那些结果不可预测的非托管机制的约定。
所以,简而言之,它是针对互操作的,就像许多涉及互操作的事情一样,我们有一个不容忽视的兼容性负担。
一次传递的模式会更好吗?
多年来,许多人一直在想,也许两次模式一般来说是不是不好,一次模式是否更好。就像世界上的许多事情一样,这并不那么清楚。一次传递模型将简化异常处理实现,在上面所示的情况下,它将更有意义。然而,双次模型也有不可忽视的优点。也许最重要的一点是,如果在第一次传递时搜索处理程序失败,则异常将无法处理,并且通常不会更改任何程序状态,即使筛选器是运行的,因为筛选器往往不会更改任何内容。调用堆栈仍然是完整的,导致异常的所有值仍然存在于堆栈和堆中(假设没有竞争条件)。这在调试未处理的异常时通常是必需的。在一次传递模型中,所有finally子句都会在异常未处理之前运行。