【C#进阶系列】20 异常和状态管理

C#

浏览数:144

2019-6-26

AD:资源代下载服务

博客园韩子卢【C#进阶系列】文章目录

异常就是指成员没有完成它的名称所宣示的行动。

    public class Girl {
        public string Name { get; set; }
    }
    public class Troy{
       Girl girl;
       public void Love() {
        Console.WriteLine("Troy爱上了" + girl.Name);
       }
    }

上面这段代码会有异常,因为Troy去执行Love这个函数,然而其中girl根本就没有赋值。本来Troy预期完成爱一个姑娘这个行动,结果发生了异常的事情,姑娘离开了Troy。

异常要解决的问题

很多行为(比如方法和属性)很多时候都没法返回错误代码(比如void方法,构造器,属性的获取设置),但他们仍然需要报告错误,于是异常就来解决这个问题。

也就是说异常处理机制实际上是为了返回可预知的错误代码,而不是为了去捕获未知的异常让程序不报错。(这一点非常重要)

不要去让程序吞异常,不把异常暴露出来让其继续运行,反而可能使程序做出更错误的举动。(有错就改,别藏着)

那么其实我在刚学习的时候一直有个疑问,我这个系统很多人在用啊,你如果不吞异常,那报黄页不是更6?

现在我认为这并不矛盾,如果有异常就在catch后进行异常处理还原操作,然后写日志或者用一个统一的页面去提示用户出错了,而不是把黄页去给用户看。(就像你告诉别人你得胃病了,用嘴和肢体语言表述都行,你剖开自己的肚子告诉别人你有病就是你的错了啊)

.Net的异常处理机制

.Net的异常处理机制是基于windows提供的结构化异常处理机制(Structured Exception Handing,简称SEH)构建的。

异常处理的代码就不演示了,说说三大块

  • try块
    • 一个try块中如果能抛出同一个异常类的操作,却要进行不同的异常恢复措施,那么应该分成两个try块。
    • try和finally到一起一般是执行资源清理操作(也可以用using哦)。
  • catch块
    • 一个try块可以关联0个或多个catch块。
    • catch后面跟着圆括号中的表达式称为捕捉类型,异常捕捉类型必须是System.Exception或者它的派生类。
    • CLR自上而下搜索异常,所以要将较具体的异常放在顶部。也就是说首先写派生程度最大的异常,然后才是其基类,然后才是System.Exception或者不指定任何捕捉类型的catch块。
    • 如果抛出的异常没有catch到,也就是说catch的类型没有一个与抛出的异常匹配,那么CLR就回去调用栈更高的一层搜索与异常匹配的捕捉类型。如果到了调用栈的顶部还是没有匹配到catch块,就会发生未处理的异常。而一旦找到匹配的catch块,就会执行内层所有finally块的代码,否则内层所有finally块的代码都不会执行。也就是说下面示例代码中会报异常:
             static void Main(string[] args)
              {
                  try
                  {
                      FuncA();
                  }
                  finally {
                      Console.WriteLine("主函数Finally");
                  }
      
                  Console.Read();
              }
      
              static void FuncA() {
                  try
                  {   
                      Object obj = new DateTime();
                      int a = (int)obj;//这里会报System.InvalidCastException异常
                  }
                  catch(InvalidDataException)//表示不匹配,然后到调用栈的上一级也就是main函数,然而main函数中的try根本就没有catch所以更谈不上什么匹配,也就是出现了一个未处理的异常
                  {
                      //这里完全不会执行
                  }
                  finally {//虽然有Finally说好的,不论是否异常都会执行,然而此时上面的异常没有catch到,
      //所以已经异常报错了,不会再执行到这里。此时CLR会终止进程,相较于让程序继续运行造成不可预知的结果这样更好 Console.WriteLine("函数A的Finally"); } }
    • catch块的末尾有以下三种处理方法:
      • 重新抛出相同的异常,向调用栈高一层的代码通知该异常的发生,也就是throw;
      • 抛出一个不同的异常,向调用栈高一层的代码提供更丰富的异常信息,也就是throw ex;//这里ex为新的异常对象
      • 让线程从catch块底部退出,不向更高层抛异常。
    • 代码可向AppDomain的FirstChanceException事件登记,这样只要AppDomain一发生异常就会收到通知,并且在CLR开始搜索任何catch块之前就会调用这些事件回调函数。
  • finally块
    • finally块为保证会执行的代码。
    • 如果在catch内部和finally内部又抛出了异常,那么在try中的异常不会被记录,其信息将丢失。

System.Exception类

微软规定所有CLS相容的编程语言都必须抛出和捕捉派生自该类型的异常。

一般来讲也就这个类中也就三个属性要注意:

  • Message指出抛出异常的原因
  • InnerException如果当前异常是在处理一个异常时抛出的,那么InnerException中就是上一个异常。用公共方法GetBaseException可以遍历内部异常链表,返回最初抛出的异常。
  • StackTrace包含异常抛出前调用过的所有方法的名称和签名。它返回一个从异常抛出位置到异常捕捉未知的所有方法。

抛出异常

抛异常需要考虑两个问题:

第一个是抛出什么Exception类型的异常。应该选择一个更有意义的类型。要考虑到调用栈中高处的代码,要知道那些代码如何判断一个方法失败从而执行得体的恢复代码。作者强烈建议异常的继承层次结构应该浅而宽,这样就可以尽量少的创建基类。而基类意味着把众多错误当做一个错误来处理。

第二个是向异常类型的构造器传递什么字符串消息。

自定义异常类

看起来自定义异常类很简单,只需要继承System.Exception类就OK了,然而实际上这是个很繁琐的事情。

因为从System.Exception类派生出来的所有类都应该是可序列化的,使它们能穿越AppDomain边界去写入日志或者数据库。而序列化就涉及到很多问题。

作者写了个泛型异常类去简化,我这里就不写了,实际上在格式上找个系统异常照着写就行了:

别忘了在自定义类上面加上[Serializable]特性。

作者的玩法更高端一点,自己建个泛型异常类继承Exception,然后将一些构造函数或者序列化函数写在这个类中。个性化的异常信息作为泛型变量T传给泛型异常类来使用,以此起到简化作用。

设计规范和最佳实践

  • 不要什么都捕捉
    • 就像前面说的捕捉异常表明已经预见到了此异常,理解它为什么发生,并知道如何处理它。如果catch了System.Exception就表明你确定预知到了一切异常,并且知道如何处理,仿若神明。
    • 所以应该有针对性地捕捉异常,而不是吞噬异常,没有捕捉到的异常请抛出。(有一种有趣的玩法就是用一个线程去吞噬异常然后给出结果,然后另一个线程去检测结果然后重新抛出该异常)
  • 发生不可恢复的异常时回滚部分完成的操作——维护状态
    • 捕捉到异常后看能否写代码简单回滚,不行的话也可以用事务来处理。
  • 隐藏实现细节来维系协定
    • 如果需要传递给上层更多的信息,可以直接在异常的Data属性中添加信息
    • 可以尝试着用对用户而言更形象的异常去包装实际发生的异常然后抛出,但是必须将实际发生的异常作为这个更形象的异常的InnerException。

未处理的异常

未处理的异常就是指那些未catch到的异常(调用栈向上查找也没catch到)。

应用程序应建立处理未处理异常的策略,而微软建议开发人员接收CLR的默认策略。也就是说,应用程序发生未处理异常时,程序终止,windows会向事件日志写一条记录。

可通过事件查看器查看该记录:

还可用可靠性监视程序查看应用程序的更多细节

图上显示我的dota2在3月25号又崩了,点后面可以查看详细信息。

我们可以将未处理的异常自己去写日志记录下来,或者发邮件什么的都行。而微软的每种应用程序模型都有自己的与未处理异常打交道的方式。

然而对于服务端程序而言,发生了未处理的异常,理想情况下是记录日志,然后向客户端发送通知,表明请求无法完成,最终终止服务器应用程序。(这个太扯了,作者也说太理想了)

对于服务器应用程序,与未处理异常有关的信息不应该返回客户端,首先用户这些信息用户并不能解决,其次服务器应该尽量少暴露自己的相关信息,防止被黑。(这个必须保证)

异常处理的性能问题

异常的处理实际上性能并不好,有的人用返回true和false,或者搞个类返回这个类的实例对象,此对象中既包含方法是否成功,又包含错误信息,以此来处理异常。(从需求上来看,这两种解决方法是等价的,也就是说异常机制和这种玩法的目的是一样的)。

然而作者并不推荐这种方法,因为要同时处理CLR和类库的抛出异常和自己的代码的返回错误代码。(这个理由其实有点牵强)

异常处理和返回错误码的方式相比,很难看出两者在性能上的差异。(虽然这么说,但是其实还是有影响的。有些人提倡直接返回布尔值和错误信息,然而实际上因为代码毕竟是人写的,往往很难得到彻底落实,也往往会导致一些人catch所有异常再返回true和false,不仅吞异常性能也没提高)

然而对于托管代码而言,因为托管对象在托管堆中分配,而托管堆受垃圾回收器的监视。如对象成功构造,而且抛出异常,垃圾回收器最终会释放掉对象的内存。编译器无需像非托管代码那样生成任何bookkeeping代码来跟踪成功构造的对象,也无需保证析构器的调用。因为垃圾处理器已经帮你自动处理了。在这一点上与非托管的C++相比,编译器会生成更少的代码,运行时执行的代码更少,性能也会更好。

事实上无论多么小的性能影响在过于频繁后都会产生不小的性能影响,比如如果一个int.Parse方法,用户经常输入无法解析的数据,那么如果去catch异常,那么因为过去频繁抛出和捕捉异常,必定会对应用程序的总体性能产生很大影响。

为了解决这类问题,微软为Int32类提供了TryParse方法。此方法转换字符串然后以out引用变量去输出结果,返回值为Boolean类型表示转换是否成功。

应确保一般情况下的方法不会失败,如果频繁抛异常导致性能不好才应该考虑添加一些tryXXX之类的方法。

约束执行区域(CER)

根据定义CER必须是对错误有适应力的代码块。由于AppDomain可能被卸载,造成状态被销毁,所以一般用CER处理多个AppDomain或进程共享的状态。如果要在抛出了非预期的异常时维护状态,CER就很有用。有时将这些异常称为异步异常。

class Program
    {
        static void Main(string[] args)
        {
            //执行以下代码,将代码体指定为受约束的执行区域即CER
            RuntimeHelpers.PrepareConstrainedRegions();////System.Runtime.CompilerServices命名空间
            try
            {
                Console.WriteLine("哈哈");
            }
            finally {
                //隐式调用Troy的构造方法,此时如果类型静态构造函数发生异常,会在执行RuntimeHelpers.PrepareConstrainedRegions()的时候就抛出
                //因为从名字就可以看出RuntimeHelpers.PrepareConstrainedRegions()就是对后面的异常机制中catch和finally的代码JIT进行提前编译。(前提是这些方法应用了ReliabilityContract)
                Troy.Show();
            }
        }

    }
    public class Troy {
        static Troy() {
            Console.WriteLine("类型构造函数被调用");
        }
        //应用在System.Runtime.ConstrainedExecution命名空间中定义的这个特性
        [ReliabilityContract(Consistency.WillNotCorruptState,Cer.Success)]
        public static void Show() { }
    }

RuntimeHelpers的另一个方法ExecuteCodeWithGuaranteedCleanup,它在资源保证得到清理的前提下才执行代码。

代码协定

代码协定提供了直接在代码中声明代码设计决策的一种方式。

采用以下形式:

  • 前条件
    • 一般用于对实参进行验证
  • 后条件
    • 方法因为一次普通的返回或者抛出异常而终止时,对状态进行验证
  • 对象不变性
    • 在整个对象生命期内,确保对象的字段的良好状态

代码协定有利于代码的使用、理解、进化、测试、文档和早期错误检测。

代码协定的核心类为静态类System.Diagnostics.Constracts.Constract.

作者:韩子卢