Effective C# 原则18:实现标准的处理(Dispose)模式

JerryXia 发表于 , 阅读 (5,989)

我们已经讨论过,处理一个占用了非托管资源对象是很重要的。现在是时候来讨论如何写代码来管理这些类占用的非内存资源了。一个标准的模式就是利用.Net框架提供的方法处理非内存资源。你的用户也希望你遵守这个标准的模式。也就是通过实现IDisposable接口来释放非托管的资源,当然是在用户记得调用它的时候,但如果用户忘记了,析构函数也会被动的执行。它是和垃圾回收器一起工作的,确保在一些必要时候,你的对象只会受到因析构函数而造成的性能损失。这正是管理非托管资源的好方法,因此有必要彻底的弄明白它。

处在类继承关系中顶层的基类应该实现IDisposable接口来释放资源。这个类型也应该添加一个析构函数,做为最后的被动机制。这两个方法都应该是用虚方法来释放资源,这样可以让它的派生类重载这个函数来释放它们自己的资源。派生类只有在它自己须要释放资源时才重载这个函数,并且一定要记得调用基类的方法。

开始时,如果你的类使用了非内存资源,则一定得有一个析构函数。你不能指望你的用户总是记得调用Dispose方法,否则当他们忘记时,你会丢失一些资源。这或许是因为他们没有调用Dispose的错误,但你也有责任。唯一可以确保非内存资源可以恰当释放的方法就是创建一个析构函数。所以,添加一个析构函数吧!

当垃圾回收器运行时,它会直接从内存中移除不用析构的垃圾对象。而其它有析构函数的对象还保留在内存中。这些对象被添加到一个析构队列中,垃圾回收器会起动一个线程专门来析构这些对象。当析构线程完成它的工作后,这些垃圾对象就可以从内存中移除了。就是说,须要析构的对象比不须要析构的对象在内存中待的时间要长。但你没得选择。如果你是采用的这种被动模式,当你的类型占用非托管资源时,你就必须写一个析构函数。但目前你还不用担心性能问题,下一步就保证你的用户使用更加简单,而且可以避免因为析构函数而造成的性能损失。

实现IDisposable接口是一个标准的模式来告诉用户和进行时系统:你的对象占有资源而且必须及时的释放。IDisposable接口只有一个方法:

public interface IDisposable
{
    void Dispose();
}

实现IDisposable.Dispose()方法有责任完成下面的任务:
1、感知所有的非托管资源。 2、感知所有的托管资源(包括卸载一些事件)。
3、设置一个安全的标记来标识对象已经被处理。如果在已经处理过的对象上调用任何方法时,你可以检验这个标记并且抛出一个ObjectDisposed的异常。
4、阻止析构。你要调用GC.SuppressFinalize(this)来完成最后的工作。

通过实现IDisposable接口,你写成了两件事:第一就是提供了一个机制来及时的释放所有占用的托管资源(译注:这里就是指托管资源,当实现了这个接口后,可以通过调用Dispose来立即释放托管资源),另一个就是你提供了一个标准的模式让用户来释放非托管资源。这是十分重要的,当你在你的类型上实现了IDisposable接口以后,用户就可以避免析构时的损失。你的类就成了.Net社区中表现相当良好的成员。

但在你创建的机制中还是存在一些漏洞。如何让一个派生类清理自己的资源,同时还可以让基类很好的再做资源清理呢?(译注:因为调用Dispose方法时,必须调用基类的Dispose,当然是在基类有这个方法时。但前面说过,我们只有一个标记来标识对象是否处理过,不管先调用那个,总得有一个方法不能处理这个标记,而这就存在隐患)
如果基类重载了析构函数,或者自己添加实现了IDisposable接口,而这些方法又都是必须调用基类的方法的;否则,基类无法恰当的释放资源。同样,析构和处理共享了一些相同的职责:几乎可以肯定你是复制了析构方法和处理方法之间的代码。正如你会在原则26中学到的,重载接口的方法根本没有如你所期望的那样工作。Dispose标准模式中的第三个方法,通过一个受保护的辅助性虚函数,制造出它们的常规任务并且挂接到派生类来释放资源。基类包含接口的核心代码,
派生类提供的Dispose()虚函数或者析构函数来负责清理资源:

protected virtual void Dispose( bool isDisposing );

重载的方法同时完成析构和处理必须提供的任务,又因为它是虚函数,它为所有的派生类提供函数入口点。派生类可以重载这个函数,提供恰当的实现来释放它自己的资源,并且调用基类的函数。当isDisposing为true时你可能同时清理托管资源和非托管资源,当isDisposing为false时你只能清理非托管资源。两种情况下,都可以调用基类的Dispose(bool)方法让它去清理它自己的资源。

当你实现这样的模式时,这里有一个简单的例子。MyResourceHog类展示了IDisposable的实现,一个析构函数,并且创建了一个虚的Dispose方法:

public class MyResourceHog : IDisposable
{
  // Flag for already disposed
  private bool _alreadyDisposed = false;

  // finalizer:
  // Call the virtual Dispose method.
  ~MyResourceHog()
  {
    Dispose( false );
  }

  // Implementation of IDisposable.
  // Call the virtual Dispose method.
  // Suppress Finalization.
  public void Dispose()
  {
    Dispose( true );
    GC.SuppressFinalize( true );
  }

  // Virtual Dispose method
  protected virtual void Dispose( bool isDisposing )
  {
    // Don't dispose more than once.
    if ( _alreadyDisposed )
      return;
    if ( isDisposing )
    {
      // TODO: free managed resources here.
    }
    // TODO: free unmanaged resources here.
    // Set disposed flag:
    _alreadyDisposed = true;
  }
}

如果派生类有另外的清理任务,就让它实现Dispose方法:

public class DerivedResourceHog : MyResourceHog
{
  // Have its own disposed flag.
  private bool _disposed = false;

  protected override void Dispose( bool isDisposing )
  {
    // Don't dispose more than once.
    if ( _disposed )
      return;
    if ( isDisposing )
    {
      // TODO: free managed resources here.
    }
    // TODO: free unmanaged resources here.

    // Let the base class free its resources.
    // Base class is responsible for calling
    // GC.SuppressFinalize( )
    base.Dispose( isDisposing );

    // Set derived class disposed flag:
    _disposed = true;
  }
}

注和意,派生类和基类都有一个处理状态的标记,这完全是被动的。重制的标记掩盖了在处理时任何可能发生的错误,而且是单一的类型处理,而不是处理构成这个对象的所有类型。(译注:就是基类与子类各自标记一个,互不影响。)

你应该被动的写处理方法和析构函数,处理对象可能以任何顺序发生,你可能会遇到这种情况:你的类中某个成员在你调用Dispose方法以前已经被处理过了。你没有看到这种情况是因为Dispose()方法是可以多次调用的。如果在一个已经被处理过的对象上调用该方法,就什么也不发生。析构函数也有同样的规则。任何对象的引用存在于内存中时,你不用检测null引用。然而,你引用的对象可能已经处理掉了,或者它已经析构了。

这就引入用了一个非常重要的忠告:对于任何与处理和资源清理相关的方法,你必须只释放资源!
不要在处理过程中添加其它任何的任务。你在处理和清理中添加其它任务时,可能会在对象的生存期中遇到一些严重而繁杂的问题。对象在你创建它时出生,在垃圾回收器认领它时死亡。你可以认为当你的程序不能再访问它们时,它们是睡眠的。你无法访问对象,无法调用对象的方法。种种迹象表明,它们就像是死的。但对象在宣布死亡前,析构函数还有最后一气。析构函数什么也不应该做,就是清理非托管资源。如果析构函数通过某些方法让对象又变得可访问,那么它就复活了。(译注:析构函数不是用户调用的,也不由.Net系统调用,而是在由GC产生的额外线程上运行的)
它又活了,但这并不好。即使是它是从睡眼中唤醒的。这里有一个明显的例子:

public class BadClass
{
  // Store a reference to a global object:
  private readonly ArrayList _finalizedList;
  private string _msg;

  public BadClass( ArrayList badList, string msg )
  {
    // cache the reference:
    _finalizedList = badList;
    _msg = (string)msg.Clone();
  }

  ~BadClass()
  {
    // Add this object to the list.
    // This object is reachable, no
    // longer garbage. It's Back!
    _finalizedList.Add( this );
  }
}

当一个BadClass对象的析构函数执行时,它把自己的一个引用添加到了全局的链表中。这使得它自己又是可达的,它就又活了。前面向你介绍的这个方法会遇到一些让人畏缩的难题。对象已经被析构了,所以垃圾回收器从此相信再也不用调用它的析构函数了。如果你实际要析构一个可达对象,这将不会成功。其次,你的一些资源可能不再有用。GC不再从内存上移除那些只被析构队列引用的对象,但它们可能已经析构了。如果是这样,它们很可能已经不能使用了。(译注:也就是说利用上面的那个方法让对象复活后,很有可能对象是不可用的。)尽管BadClass所拥有的成员还在内存里,它们像是可以被析构或者处理,但C#语言没有一个方法可以让你控制析构的次序,你不能让这样的结构可靠的运行。不要尝试。

我还没有看到这样的代码:用这样明显的方式来复活一个对象,除非是学术上的练习。但我看过这样的代码,析构函数试图完成一些实质的工作,最后还通过析构函数的调用把引用放到对象中,从而把自己复活。析构函数里面的代码看上去是精心设计的,另外还有处理函数里的。再检查一遍,这些代码是做了其它事情,而不是释放资源!这些行为会为你的应用程序在后期的运行中产生很多BUG。删除这些方法,确保析构函数和Dispose()方法除了清理资源外,什么也不做。

在托管环境里,你不用为每一个创建的类写析构函数;只有须要释放一些使用的非托管资源时才添加,或者你的类所包含的成员有实现了IDisposable接口的时候也要添加。即使如此,你也只用实现IDisposable接口完成所有的功能就行了,不用析构函数。否则,你会限制你的派生类实现实现标准的Dispose习惯。遵守这个我所讲叙的标准的Dispose习惯。这会让你的程序生活变得轻松,也为你的用户,也为那些从你的类创建派生类的人。

添加新评论