C#中的异常处理

前言

Jon Jagger先生是一个经历丰富的人,写的文章简单而发人深省。大家可以到他的网站open in new window上浏览一番,必然受益匪浅。这篇文章虽然比较简单,翻译当中难免出现错误,希望大家多多指教。

PS:这篇文章非常简单,如果你感觉到自己已经到达一定的水平,那么,请不要浪费时间了

一 、令人痛苦的程式化错误处理

异常还没出现前,处理错误最经典的方式就是使用错误代码检查语句了。例如

public sealed class Painful
{  
    ...  
    private static char[] ReadSource(string filename)
    {
        FileInfo file = new FileInfo(filename);
        if (errorCode == 2342) goto handler;
        int length = (int)file.Length;  
        char[] source = new char[length];
        if (errorCode == -734) goto handler;
        TextReader reader = file.OpenText();
        if (errorCode == 2664) goto handler;
        reader.Read(source, 0, length);
        if (errorCode == -5227) goto handler;
        reader.Close();
        Process(filename, source); 
        return source;
        handler:
        ...
    }
}

这种编码方式单调乏味,翻来复去,难以使用,看起来非常的复杂,而且还使得基本功能很不清晰。并且还很容易忽略错误(故意或者偶尔的遗忘)。现在好了,有很多来处理这种情况,但是其中必有一些处理方式要好过其他的。

二、关系分离

异常所能做到的最基本的事情就是允许你将错误和基本功能分离开来。换句话,我们能将上面的改写如下:

...
public sealed class PainLess
{
    public static int Main(string[] args)
    {
        try
        {
           string filename = args[0];
           char[] source = ReadSource(filename);
           Process(filename, source);
           return 0;
        }
        catch (SecurityException    caught) { ... }
        catch (IOException          caught) { ... }
        catch (OutOfMemoryException caught) { ... }
        ...
    }

    private static char[] ReadSource(string filename)
    {
        FileInfo file = new FileInfo(filename);
        int length = (int)file.Length;
        char[] source = new char[length];
        TextReader reader = file.OpenText();
        reader.Read(source, 0, length);
        reader.Close();
        return source;
    }
}

在转化过程中,需要注意以下几点:

  1. 以前使用数字作为错误代码来描述错误(很失败的一种做法,谁知道2342是什么意思呢?),现在使用命名的异常类来描述(例如:SecurityException)。
  2. 异常类彼此之间的关系并没有紧密的联系在一起。相反的,用来描述某一类错误的整数代码在整个错误描述代码中必须是唯一。
  3. ReadSource方法中没有没有抛出详细说明。在C#中抛出说明并不是必须的。

然而,最值得注意是:比较两段代码,ReadSource变得非常的清晰、简单、明了。它现在仅包含需要实现其基本功能的语句,没有表现出明显的错误处理。这是可以的,因为如果出现异常,调用堆栈就会自我展开。这个版本是我们想要的“理想”版本。

然而,异常允许我们接近这个ReadSource的理想版本,同时,又阻止我们到达它。ReadSource是一个编码例子,它请求资源(一个TextReader),使用了资源(Read),并且释放了资源(Close)。问题是如果在请求资源的过程中出现了异常,那么资源将不会被释放。这个问题的解决是本文的一部分。不过,“理想“中的ReadSource版本仍然是有用的。我们将使用它做为下面几个版本的ReadSource评价的参照物。


三、finally?

解决释放问题的方法依靠你现在使用的语言。在C++中,你可以使用构建于堆栈上的析构函数。Java中,你能构使用finally程序块。C#允许你创造自定义的结构类型但是不允许结构中的析构函数(只是因为一个C#析构函数其实是一个Finally方法,Finally被垃圾回收器调用。结构类,是一种值类型,并不归属于垃圾回收器回收的范围)。因而,只是在开始,C#必须追循Java的道路,使用finally程序块。首先,我们的finally程序块开起来如下:

private static char[] ReadSource(string filename)
{
    try
    {
        FileInfo file = new FileInfo(filename);
        int length = (int)file.Length;
        char[] source = new char[length];
        TextReader reader = file.OpenText();
        reader.Read(source, 0, length);
    }
    finally
    {
        reader.Close();
    }
    return source;
}

这个版本不得不引入一个try程序块(既然一个finally程序快必须跟随在一个try程序块后),这将是一个合理的解决方案,如果它奏效的话。但是,它没有做到。问题是try程序块构建成一个范围,所以在finally程序块中的reader并不在这个范围内并且返回语句中的source也不在这个范围。

finally?

为了解决这个问题,你不得不将reader和source的声明移到try程序块的外面,第二次尝试如下:

private static char[] ReadSource(string filename)
{
    TextReader reader;
    char[] source;
    try
    {
        FileInfo file = new FileInfo(filename);
        int length = (int)file.Length;
        source = new char[length];
        reader = file.OpenText();
        reader.Read(source, 0, length);
    }
    finally
    {
        reader.Close();
    }
    return source;
}

这个版本将reader和source的声明移到了try程序块的外面,接着指派给reader和source但没有初始化它们。这是和开始的“理想”版本不同的另外一个地方(出现两个多余行)。然而,你可能认为如果它工作,那将是一个合理的解决方案。但是它没有。问题是委派并不等同于初始化及让编译器知道它。如果在reader被分配之前出现一个异常,这是在finally程序块中对reader.close()的调用 将根据没有被分配的reader,C#,像Java一样,不允许那样。

finally?

很明显,你必须要初始化reader,第三次尝试如下:

private static char[] ReadSource(string filename)
{
    TextReader reader = null;
    char[] source;
    try
    {
        FileInfo file = new FileInfo(filename);
        int length = (int)file.Length;
        source = new char[length];
        reader = file.OpenText();
        reader.Read(source, 0, length);
    }
    finally
    {
        reader.Close();
    }
    return source;
}

这个版本引入了空值,这没有出现在最初的“理想版本”中。不过,如果你仍然认为如果它起作用这将是一个合理的解决方式,然而它不是(虽然它能通过编译)。问题是你在调用reader.close()的时候很容易抛出NullReferenceException异常。

finally?

一种解决方法是对eader.close()方法进行保护,下面做第四次尝试:

private static char[] ReadSource(string filename)
{
    TextReader reader = null;
    char[] source;
    try
    {
        FileInfo file = new FileInfo(filename);
        int length = (int)file.Length;
        source = new char[length];
        reader = file.OpenText();
        reader.Read(source, 0, length);
    }
    finally
    {
        if (reader != null)
        {
            reader.Close();
        }
    }
    return source;
}

当然,对reader.close()的保护并不是ReadSource的理想版本。但是,如果仅从它的效果上看,这将是一个合理的版本。最终,工作。它和最初的版本已经大不相同。稍微努力,你能复用下面这段代码:

private static char[] ReadSource(string filename)
{
    FileInfo file = new FileInfo(filename);
    int length = (int)file.Length;
    char[] source = new char[length];
    TextReader reader = file.OpenText();
    try
    {
        reader.Read(source, 0, length);
    }
    finally
    {
        if (reader != null)
        {
            reader.Close();
        }
    }
    return source;
}

在某些情况下,你可以在finally程序块中对可能出现空值进行判断(上面就是一个这样的例子),但是一般说来,你最好还是在finally程序块中判断一下(考虑到如果file.OpenText返回空值,或者如果reader被放进了try程序块中,或者reader作为ref/out参数被传递到try程序块中)。你不得不增加一个try程序块,一个finally程序块,和一个if防护。如果你正在使用Java,你不得不每一次都去做这些事情。在那里是个最大的问题。如果这个解决方案非常令人厌烦且完全偏离于最初的“完美”方案,这没有关系,你能够将这些异常提取到一块。在Java中,你不能这么做。遇到异常,Java将停止运行,而C#则将继续。

四、using语句

在C#中,最接近于“理想”版本的是使用using语句:

private static char[] ReadSource(string filename)
{
    FileInfo file = new FileInfo(filename);
    int length = (int)file.Length;
    char[] source = new char[length];
    using (TextReader reader = file.OpenText())
    {
        reader.Read(source, 0, length);
    }
    return source;
}

Reader将会被恰当的关闭。简单说来,using语句有大量的特征能够改善开始的“理想”版本。首先,我们看一下它内在的运行机制到底是怎样的。

using语句转换

C#ECMA标准描述using声明:

using (type variable = initialization)
    embeddedStatement

它等同于

{
    type variable = initialization;
    try
    {
        embeddedStatement
    }
    finally
    {
        if (variable != null)
        {
            ((IDisposable)variable).Dispose();
        }
    }
}

它依赖于System命名空间中的IDisposable接口:

namespace System
{
    public interface IDisposable
    {
        void Dispose();
    }
}

注意:finally程序块中的牵制转换意味着,这个变量必须是一个支持IDisposable接口的类(通过继承或转换操作)。如果它不是,你就会得到一个编译时错误。

using TextReader 转换

不出乎意料,TextReader支持IDisposable接口,并且实现了Dispose来调用关闭。这意味着:

using (TextReader reader = file.OpenText())
{
    reader.Read(source, 0, length);
}

相当于下面:

{
    TextReader reader = file.OpenText();
    try
    {
        reader.Read(source, 0, length);
    }
    finally
    {
        if (reader != null)
        {
            ((IDisposable)reader).Dispose();
        }
    }
}

除了对IDisposable的强制转换外,这和最通用的Java解决方式是相同的。这个强制转换是必须的因为这是一个通用解决方式。


五、以自定义方式处理

这是有益的当你去考虑如果TextReader没有实现IDisposable接口将会出现什么情况。这篇教程从此处开始将知道我们如何在我们自己的类里实现Dispose的处理。一种方式是使用对象适配器(Object Adapter)模式。例如:

public sealed class AutoTextReader : IDisposable
{
    public AutoTextReader(TextReader target)
    {
        // PreCondition(target != null);
        adaptee = target;
    }
 
    public TextReader TextReader
    {
        get { return adaptee; }
    }
 
    public void Dispose()
    { 
        adaptee.Close();
    }
 
    private readonly TextReader adaptee; 
}

你可以这样使用你自己的类:

using (AutoTextReader scoped = new AutoTextReader(file.OpenText()))
{
    scoped.TextReader.Read(source, 0, length);
}

你能够使用隐式转换操作符使问题变得更简单一些:

public sealed class AutoTextReader : IDisposable
{
    ...
    public static implicit operator AutoTextReader(TextReader target)
    {
        return new AutoTextReader(target);
    }
    ...
}

这将允许你这样使用:

using (AutoTextReader scoped = file.OpenText())
{
    scoped.TextReader.Read(source, 0, length);
}

struct :另一种选择

AutoTextReader有意使用为密封类,就想它的名字建议的,以用作本地变量。使用一个结构来代替类更加有意义:

public struct AutoTextReader : IDisposable
{
    // exactly as before
}

使用一个结构代替类也提供给你几种自由优化。既然一个结构是一个值类型,它能构一直都不是空值。这意味着编译器必须对生成的finally程序块做空值的检测。并且,既然你不能继承于一个结构,它的运行时将和编译时的类型一致。这意味着编译器一般在生成的finally程序块中做强制转换并因而避免了一次装箱操作(特别的,如果Dispose是一个公开的隐式接口实现而不是一个不公开的显示接口实现,这将避免强制转换)。

换句话,就是这样:

using (AutoTextReader scoped = file.OpenText())
{
    scoped.TextReader.Read(source, 0, length);
}

被转换为:

{
    AutoTextReader scoped = new file.OpenText();
    try
    {
        scoped.TextReader.Read(source, 0, length);
    }
    finally
    {
        scoped.Dispose();
    }
}

由此,我更喜欢使用using语句代替finally程序开来处理。事实上,using语句解决方式相较于开始的“理想“版本还有如下额外的几个优点,一个using语句:

  • 运行中,它能够一直释放资源
  • 是一个扩展机制。它允许你创建一个资源释放的集合。创建你自己的资源释放类例如AutoTextReader是容易的。
  • 允许你将资源获取和资源释放配对使用。释放资源最好的时刻就是你获得资源的一刻。就像如果你从图书馆借了一本书,你能在你借的时候被告知什么时候归还。
  • 根据句法构造,能够清楚的告诉你你正在使用一个资源。
  • 为拥有资源的变量创造一个范围。仔细观察对using语句的编译器转换,你将发现它聪明的使用了一对外部括号。
using (AutoTextReader scoped = file.OpenText())
{
    scoped.TextReader.Read(source, 0, length);
}
scoped.TextReader.Close(); // scoped is not in scope here

这是对C++中依据条件声明的追溯。允许你限制变量使用的范围,仅仅在这个范围内,变量是有用的,当变量能够使用,它只能存在于这个范围内。

Contributors: FHL