代码改变世界

理解.NET中的异常(二)

2007-03-15 20:19 Anders Cui 阅读(...) 评论(...) 编辑 收藏


上一篇中讨论了异常的优点、异常的本质、异常的机制

这里将讨论FCL中预定义的异常类,自定义异常类,正确的使用异常(抛出、捕获、封装),最后给出性能方面的建议。

4、System. Exception及其它FCL中的异常类

4.1 System.Exception 类型

CLR允许我们将任何类型——Int32、String等——的一个实例作为异常抛出。但实际上,微软定义了System.Exception类型,并规定所有和CLS兼容的编程语言都必须能够抛出并捕获那些继承自System.Exception的异常类型。继承自System.Exception的异常类型被认为是与CLS兼容的。C#和其它许多语言都只允许代码抛出与CLR兼容的异常。

System.Exception类型是一个很简单的类型,下表列出了它所包含的一些属性。

属性

访问权限

类型

描述

Message

只读

String

包含一段辅助性的文本,描述异常发生的原因。在出现未处理异常时,这些信息通常会写入log。这些信息用户通常是看不见的,所以应尽量使用技术性的词汇以帮助其它开发人员修正代码。

Data

只读

IDictionary

一个指向key-value对集合的引用。通常应在抛出异常前,向该集合添加信息,而捕获异常的代码则使用这些信息进行异常恢复操作。

Source

读写

String

产生异常的程序集的名称

StackTrace

只读

String

包含了调用堆栈中抛出异常的方法的名称和签名。该属性对于调试极具价值。

TargetSite

只读

MethodBase

抛出异常的方法。

HelpLink

读写

String

获取或设置异常的关联帮助文件的链接。

InnerExceptoin

只读

Exception

如果当前异常是在处理另一个异常时产生的,那么该属性表示前一个属性。该属性通常为null。Exception类型还提供了一个公有方法GetBaseException,用以遍历所以内部异常组成的链表,返回最开始那个异常。

4.2 FCL中的异常类结构

.NET框架类库(FCL)中定义了很多异常类型(而它们最终又都继承自Exception类)。

微软的最初想法是这样的:System.Exception应是所有异常的基类型,另两个类型System.SystemException和System.ApplicationException则是仅有的直接继承自System.Exception的异常类型。此外,CLR抛出的异常都继承自SystemException,应用程序抛出的异常应当继承自ApplicationException。这样一来,开发人员就能够编写catch块来捕获所有CLR抛出的异常或所有应用程序抛出的异常。

但遗憾的是,FCL也没能很好地遵循这个原则。有些异常类直接继承自Exception(IsolatedStorageException),有些CLR抛出的异常却继承自ApplicationException(如TargetInvocationException),还有些应用程序抛出的异常则继承自SystemException(如FormatException)。这显得有些混乱,而其结果便是SystemException和ApplicationException类型的存在没有多大价值了。

5、自定义异常类

如果FCL没有为我们定义合适的异常类型,我们就要考虑定义创建自己的异常类了。

一般情况下,自定义异常类应继承自Exception类型或其它与Exception相近的基类型,如果我们定义的类型不打算作为其它类型的基类型,应该将其标识为sealed。

Exception基类型定义了4个构造函数:

  • 公有的无参(默认)构造函数,创建一个异常类型的实例,并将所有字段和属性的值设置为默认值。
  • 公有的带一个String参数的构造函数,创建一个异常类型的实例,异常的消息将设置为参数指定的文本。
  • 公有的带String和Exception参数的构造函数,创建一个异常类型的实例,并设置其消息文本和内部异常。在对异常进行封装时,该构造函数会显示出其重要性
  • 受保护的带SerializationInfo和StreamingContext参数的构造函数,反序列化Exception对象的实例。记住,如果异常类型是密封的(sealed),该方法应声明为private的,确保该构造函数能够调用基类型的相同的构造函数,这样基类的字段能够得到正确的序列化。

在定义自己的异常类型时,我们应当实现这四个构造函数,而且它们都要调用基类型中相应的构造函数。

当然,我们定义的异常类型会继承Exception类型的所有字段和属性。此外,我们还可以为其添加自己的字段和属性。例如,System.ArgumentException中添加了一个虚拟的(virtual)String属性ParamName(其它的一切则继承自Exception类型)。ArgumentException也定义了两个新的构造函数(除了上述四个),来初始化ParamName属性。这里我们也应了解,如果为异常类型添加字段,要确保添加必要的构造函数来初始化这些字段,同时也要定义相应的属性或其它成员(如方法)返回这些字段的值

所有的异常类型(继承自Exception类型)都应是可序列化的,只有这样异常对象才能在跨越应用程序域时得到封送处理(marshaled),同时还能持久化至日志或数据库。要使自定义的异常类型可序列化,首先为该类型应用Serializable特性(attribute);如果类型定义了新的字段,我们还必须让其实现ISerilizable接口的GetObjectData方法和上面提及的受保护的构造函数。

下面是的示例演示了如何正确地创建自定义异常类型:

要实现上面四个构造函数,建议使用VS 2005中的Code Snippet:



按两下Tab后,VS就会为你生成类的基本结构:),我们在这个基础上编写代码。

下面是类的完整代码:

    // 为类应用Serializable特性
    [Serializable]
    
public sealed class CustomException : Exception
    {
        
// 添加的自定义字段
        private string stringInfo;
        
private bool booleanInfo;

        
// 实现三个公有的构造函数,这里只是简单地调用基类的构造函数
        public CustomException() { }

        
public CustomException(string message) : base(message) { }

        
public CustomException(string message, Exception inner) 
            : 
base(message, inner) { }

        
// 实现ISerialization接口所需要的反序列化构造函数。
        
// 因为本类为sealed,该构造函数为private。
        private CustomException(SerializationInfo info, StreamingContext context)
            : 
base(info, context) 
        {
            stringInfo 
= info.GetString("StringInfo");
            booleanInfo 
= info.GetBoolean("BooleanInfo");
        }

        
// 添加构造函数以确保自定义字段能得到正确的初始化
        public CustomException(string message, string stringInfo, bool booleanInfo)
            : 
base(message)
        {
            
this.stringInfo = stringInfo;
            
this.booleanInfo = booleanInfo;
        }

        
public CustomException(string message, Exception inner, string stringInfo, bool booleanInfo)
            : 
base(message, inner)
        {
            
this.stringInfo = stringInfo;
            
this.booleanInfo = booleanInfo;
        }

        
// 通过属性或其它成员提供对自定义字段的访问
        public string StringInfo
        {
            
get { return stringInfo; }
        }

        
public bool BooleanInfo
        {
            
get { return booleanInfo; }
        }

        
// 重写GetObjectData方法。
        
// 如果添加了自定义字段,一定要重写基类GetObjectData方法的实现。
        public override void GetObjectData(SerializationInfo info, StreamingContext context)
        {
            
// 序列化自定义数据成员
            info.AddValue("StringInfo", stringInfo);
            info.AddValue(
"BooleanInfo", booleanInfo);

            
// 调用基类方法,序列化它的成员
            base.GetObjectData(info, context);
        }

        
public override string Message
        {
            
get
            {
                
string message = base.Message;

                
if (stringInfo != null)
                {
                    message 
+= Environment.NewLine +
                        stringInfo 
+ " = " + booleanInfo;
                }

                
return message;
            }
        }

    }

然后使用下面这样的代码测试异常的序列化:

    // 创建一个CustomException对象,将其序列化
    CustomException e = new CustomException("New Custom Exception""My String Info"true);
    FileStream fs 
= new FileStream(@"Test", FileMode.Create);
    IFormatter f 
= new SoapFormatter();
    f.Serialize(fs, e);
    fs.Close();

    
// 反序列化CustomException对象,查看它的字段
    fs = new FileStream(@"Test", FileMode.Open);
    e 
= (f.Deserialize(fs)) as CustomException;
    fs.Close();

    Console.WriteLine(e.Message);

此处需要引用System.Runtime.Serialization.Formatters.Soap.dll。

6、正确地使用异常

根据第2节的讨论,我们已经了解何种情况可以称之为异常。下面再给出一些异常使用相关的规范或建议。

借鉴《.NET设计规范》一书的做法,本节的这些规范和建议通常由要、考虑、避免、不要这些词进行组织,每一条都描述了一种好的或是不好的做法。对于好的做法,标以√,相应的,对那些不好的做法则标以×。而不同的措辞也能够揭示该条规范的重要性。

要……”描述的是总要遵循的规范(但特殊情况下,可能需要违反)。

考虑……”描述的是一般情况下应该遵循的规范,但如果完全理解规范背后的道理,并有很好的理由不遵循它时,也不要畏惧打破常规。

不要……”描述的是一些几乎绝对不该违反的规范。

避免……”则没有那么绝对,它描述的是那些通常并不好,但却存在一些已知的可以违反的情况。

6.1 抛出异常

在设计我们自己的方法时,应考虑如何正确地抛出异常。

× 不要返回错误码。

前面第1节已经讨论了异常的种种好处,所以还是把异常作为报告错误的主要方法。记住每个异常都有两种信息:其一是异常信息(Message属性),其二是异常的类型,异常处理程序根据它来决定应该执行什么操作。

通过抛出异常的方式来报告操作失败。

如果一个方法未能完成它应该完成的任务,那么应该认为这是方法层面的操作失败,并抛出异常。

考虑通过调用System.Environment.FailFastNew in .NET 2.0)来终止进程,而不要抛出异常,如果代码遇到了严重问题,已经无法继续安全地执行

× 不要在正常的控制流中使用异常,如果能够避免的话。

考虑抛出异常可能会对性能造成的影响,详见第7节。

为所有的异常撰写文档,异常本质上是对程序接口隐含假设的一种违反我们显然需要对这些假设作详细的文档,以减少用户代码引发异常的机会。

× 不要让公有成员根据某个选项来决定是否抛出异常。

例如:

// 不好的设计
public Type GetType(string path, bool throwOnError)

调用者要比方法设计者更难以决定是否抛出异常。

× 不要把异常用作公有成员的返回值或输出参数。

这样会丧失用异常来报告操作失败的诸多好处。

× 避免显式地从finally代码块中抛出异常。

考虑优先使用System命名空间中已有的异常,而不是自己创建新的异常。

使用自定义的异常类型,如果对错误的处理方式与其它已有异常类型有所不同。

关于创建自定义异常类的的细节见第5节。

× 不要仅仅为了拥有自己的异常而创建并使用新的异常。

使用最合理、最具针对性的异常。

抛出System.Exception总是错的,如果这么做了,那么就想一想自己是否真地了解抛出异常的原因。

在抛出异常时提供丰富而有意义的错误消息。

要注意的是这些信息是提供给谁的,可能是其它开发人员,也可能是最终用户,所以这些信息应根据面向的对象设计

确保异常消息的语法正确无误(指自然语言,如汉语、英语等)。

确保异常消息中的每个句子都有句号。

这个看起来似乎过于追究细节了,那么想想这种情况:使用FCL预定义异常的Message信息时,我们有没有加过句号。如果我们提供的信息没有句号,其它开发人员使用时到底加不加句号呢?

× 避免在异常消息中使用问号和感叹号。

或许我们习惯于使用感叹号来”警示”某些操作有问题,扪心自问,我们使用的代码返回一个感叹号,自己会有什么感觉。

× 不要在没有得到许可的情况下在异常消息中泄漏安全信息。

考虑把组件抛出的异常信息本地化,如果希望组件为使用不用(自然)语言的开发人员使用。

6.2 处理异常

根据6.1节的讨论,我们可以决定何时抛出异常,然后为之选择合适的类型,设计合理的信息,下一步就是如何处理异常了。

如果用catch语句块捕获了某个特定类型的异常,并完全理解在catch块之后继续执行对应用程序意味着什么,那么我们说这种情况是对异常进行了处理。

如果捕获的异常具体类型不确定(通常都是如此),并在不完全理解操作失败的原因或没有对操作失败作出反应的情况下让应用程序继续执行,那么我们说这种情况是把异常吞了

× 不要在框架(是指供开发人员使用的程序)的代码中,在捕获具体类型不确定的异常(如System.ExceptionSystem.SystemException)时,把异常吞了。

× 避免在应用程序的代码中,在捕获具体类型不确定的异常(如System.ExceptionSystem.SystemException)时,把错误吞了。

有时在应用程序中把异常吞了是可以接受的,但必须意识到其风险。发生异常通常会导致状态的不一致,如果贸然将异常吞掉,让程序继续执行下去,后果不堪设想。

× 不要在为了转移异常而编写的catch代码块中把任何特殊的异常排除在外。

考虑捕获特定类型的异常,如果理解异常产生的原因并能对错误做适当的反应

此时一定要能够确信,程序能够从异常中完全恢复

× 不要捕获不应该捕获的异常。通常应允许异常沿调用栈向上传递。

这一点极为重要。如果捕获了不该捕获的异常,会让bug更难以发现。在开发、测试阶段应当把所有bug暴露出来。

在进行清理工作时使用try-finally,避免使用try-catch

对于精心编写的代码来说,try-finally的使用频率要比try-catch要高的多。这一点可能有违于直觉,因为有时可能会觉得:try不就是为了catch吗?要知道一方面我们要考虑程序状态的一致,另一方面我们还需要考虑资源的清理工作。

在捕获并重新抛出异常时使用空的throw语句。这是保持调用栈的最好方法。

如果捕获异常后抛出新的异常,那么所报告的异常已不再是实际引发的异常,显然这会不利于程序的调试,因此应重新抛出原来的异常。

× 不要用无参数的catch块来处理不与CLS兼容的异常(不是继承自System.Exception的异常)。

有时候让底层代码抛出的异常传递到高层并没有什么意义,此时,可以考虑对底层的异常进行封装使之对高层的用户也有意义。还有一种情况,更重要的是要知道代码抛出了异常,而异常的类型则显得无关紧要,此时可以封装异常。

考虑对较低层次抛出的异常进行适当的封装,如果较低层次的异常在较高层次的运行环境中没有什么意义。

× 避免捕获并封装具体类型不确定的异常。

在对异常进行封装时为其指定内部异常(inner exception)。

这一点极为重要,对于代码的调试会很有帮助。

6.3 标准异常类型的使用

× 不要抛出ExceptionSystemException类型的异常。

× 不要在框架(供其它开发人员使用)代码中捕获ExceptionSystemException类型的异常,除非打算重新抛出。

× 避免捕获ExceptionSystemException类型的异常,除非是在顶层的异常处理器程序中。

× 不要抛出ApplicationException类型的异常或者从它派生新类(参看4.2描述)。

抛出InvalidOperationException类型的异常,如果对象处于不正确的状态。

一个例子是向只读的FileStream写入数据。

抛出ArgumentException或其子类,如果传入的是无效参数。要注意尽量使用位于继承层次末尾的类型。

在抛出ArgumentException或其子类时设置ParamName属性。

该属性表明了哪个参数引发了异常。

    public static FileAttributes GetAttributes(string path)
    {
        
if (path == null)
        {
            
throw new ArgumentNullException("path");
        }
    }

在属性的设置方法中,以value作为隐式值参数的名字。

    public FileAttributes Attributes
    {
        
set
        {
            
if (value == null)
            {
                
throw new ArgumentNullException("value");
            }
        }
    }

× 不要让公用的API抛出这些异常。

抛出这些异常会暴露实现细节,而细节可能会随时间变化。

另外,不要显式地抛出StackOverflowException、OutOfMemeryException、ComException、SEHException异常,应该只有CLR才能抛出这些异常。

7、性能方面的考虑

我们在使用异常时常常会产生性能方面的顾虑,在调试的时候感觉尤其明显。这样的顾虑合情合理。当成员抛出异常时,对性能的影响将是指数级的。当遵循前面的规范,我们仍有可能获得良好的性能。本节推荐两种模式。

7.1 Tester-Doer 模式

有时候,我们可以把抛出异常的成员分解为两个成员,这样就能提高该成员的性能。下面看看ICollection<T>接口的Add方法。

ICollection<int> numbers = …

numbers.Add(
1); 

如果集合是只读的,那么Add方法会抛出异常。在Add方法经常会失败的场景中,这可能会引起性能问题。缓解问题的方法之一是在调用Add方法前,检查集合是否可写。

ICollection<int> numbers = …

if(!numbers.IsReadOnly)
{
numbers.Add(
1);
}

用来对条件进行测试的成员成为tester,这里就是IsReadOnly属性;用来执行实际操作并可能抛出异常的成员成为doer,这里就是Add方法。

考虑在方法中使用Test-Doer模式来避免因异常而引发的性能问题,如果该方法在普通的场景中都可能会抛出异常(引发异常的频率较高)。

前提是”test”操作要远比”do”操作快。另外要注意,在多线程访问一个对象时会有危险性。

7.2 Try-Parse 模式

与Tester-Doer 模式相比,Try-Parse 模式甚至更快,应在那些对性能要求极高的API中使用。该模式对成员的名字进行调整,使成员的语义包含一个预先定义号的测试。例如,DateTime定义了一个Parse方法,如果解析字符串失败,那么它会抛出异常,同时还提供了一个与之对应的TryParse方法,在解析失败时会返回false,成功时则通过一个输出参数来返回结果。

使用这个模式时注意,如果因为try操作之外的原因导致(方法)操作失败,仍应抛出异常。

考虑在方法中使用Try-Parse模式来避免因异常而引发的性能问题,如果该方法在普通的场景中都可能会抛出异常。



参考:
 《.NET 框架程序设计》-Jeffery Richter
 《.NET 设计规范》-Krzysztof Cwalina, Brad Abrams
 《Visual C# 2005 Recipes》-Allen Jones, Matthew MacDonald