原则17:实现标准的 Dispose 模式

优质
小牛编辑
135浏览
2023-12-01

我们已经讨论过回收持有非托管资源对象的重要性。现在就介绍怎么实现管理类所包含不是内存的资源的代码。 .NET 框架回收非托管资源已经有了标准的模式。你的类的使用者会希望你遵循这个标准模式。标准的回收习惯是使用者调用你实现的 IDisposable 接口,如果使用者忘记了析构函数也会被动执行。它和垃圾回收器一起工作,保证你的对象在必要的时候只受到析构函数带来的性能损失。这就是正确处理非托管资源的方式,所以你需要完全弄明白它。

在继承层次的基类应该实现 IDisposable 接口以释放资源。这个类也应添加析构函数作为被动的调用。这两个方法都是通过虚函数实现资源释放,以至于子类可以根据它们自己的资源管理需求进行重载。子类只有当需要释放自己的资源的时候才需要重载虚函数,并且记得调用基类的实现版本。

开始时,如果你的类使用非托管资源,这个类就必须有析构函数。你不应该依赖使用者总是调用 Dispose() 方法。如果他们忘记了你就会出现内存泄露问题。没有调用 Dispose 是他们的过失,但是你也会受到他们的责备。唯一的保证非托管资源会被正确的释放的方式是创建析构函数。所以就创建一个。

当垃圾回收器运行时,它会立即把没有析构函数的对象从内存中移除。有析构函数的所有对象还会留在内存中。这些对象会添加到析构执行队列中,并且垃圾回收器起一个新线程执行这些对象析构函数。析构线程完成工作后,垃圾对象才会从内存中移除。需要析构的对象在内存中停留的时间会比没有析构函数的对象久点。但是你别无选择。如果你的类持有非托管资源,你就得被动实现析构函数。但是你不用太担心性能。下一步保证用户使用更简单,而且可以避免析构函数造成的性能损失。

实现 IDisposable 是标准的方式告诉用户和系统你的对象持有必须被及时释放的资源。 IDiposable 接口只包含一个方法:

public interface IDisposable 
{
    void Dispose(); 
}

实现 IDisposable.Dispose() 方法要负责四个任务:

1.释放所有非托管资源。

2.释放所有托管资源(包括卸载事件)。

3.设置表示对象依据被清理的标记位。在 public 方法你需要坚持这个状态值,并且如果已经被回收对象的调用要抛出 ObjectDisposed 异常。

4.阻止析构。你通过调用 GC.SuppressFinalize(this) 来完成这项工作。

实现 IDisposable ,你完成两件事情:第一个你提供了一种及时释放托管资源的机制,另一个是你提供了标准模式让用户释放非托管资源。这是非常重要的。你为类型实现 IDisposable 后,用户可以避免析构的开销。你的类在 .NET 环境中就理所当然有很好的表现。

但是这个机制还是会有些坑。怎么样做到子类清理自己的资源并且基类同时也进行清理。如果子类重载了析构函数或者添加自己的 IDisposable 实现,这两个方法必须调用基类的对应的实现。否则,基类就不会被正确的清理。并且,析构函数和 Dispose 有着相同的职责:几乎可以肯定析构函数和 Dispose 方法中会有重复的代码。后面你会在原则23中学到,重载接口函数不会像你期待那样工作。标准模式中的第三个方法,通过一个受保护的辅助性虚函数,提取出它们的常规任务并且让子类来释放自己的资源。基类包含接口的核心代码, 子类提供的 Dispose()虚函数或者析构函数来负责清理资源:

protected virtual void Dispose(bool isDisposing)

方法重载这个方法是非常必须的以同时支持析构函数和 Dispose,并且因为它虚函数,可以为所有子类提供入口。子类可以重载这个方法,提供恰当的实现去清理它们的资源,和调用基类的实现。当 isDisposing 为 true 时,你清理托管资源和非托管资源,如果为 false ,你只清理了非托管资源。这两种情况,都要调用基类的 Dispose(bool) 方法去清理基类的资源。

下面这个简单例子为你展示如何实现这个模式。 MyResourceHog 类演示实现了 IDisposable 和创建一个虚 Dispose 方法:

public class MyResourceHog : IDisposable 
{
    // Flag for already disposed 
    private bool alreadyDisposed = false;
    // Implementation of IDisposable. 
    // Call the virtual Dispose method. 
    // Suppress Finalization. 
    public void Dispose() 
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
    // Virtual Dispose method 
    protected virtual void Dispose(bool isDisposing) 
    {
        // Don't dispose more than once. 
        if (alreadyDisposed)
        return; 
        if (isDisposing) 
        {
        // elided: free managed resources here. 
        } 
        // elided: free unmanaged resources here. 
        // Set disposed flag: 
        alreadyDisposed = true;
    }
    public void ExampleMethod() 
    {
        if (alreadyDisposed) 
            throw new ObjectDisposedException("MyResourceHog","Called Example Method on Disposed object");
        // remainder elided. 
    }
}

如果你一个子类需要额外的清理,可以实现 protected 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 引用。然而任何你引用的对象都可能被回收了。或者已经被析构了。

你还会主要到 MyResourceHog 和 DerivedResourceHog 都没有包含析构函数。这个例子的代码没有直接包含任何非托管资源。所以,析构函数是不需要的。这意味着这里代码不需要调用 Dispose(false) 。这是就是正确的模式。除非你的类直接包含了非托管资源,否则你不需要析构函数。只有那些直接包含非托管资源的类才需要实现析构函数并且增加开销。即使没有被调用,析构函数的出现都会引入相当大的性能损失。除非你的类型需要析构函数,否则就不要添加它。然后,你仍要正确实现这个模式以保证只要子类添加非托管资源,就要添加析构函数,并且实现 Dispose(bool) 能正确处理非托管资源。

这带给我最重要的建议就是任何涉及回收或清理的方法:你都应该只是释放资源。不要在回收清理方法做其他操作。因为在 Dispose 和析构函数中处理其他操作会引入严重的问题。对象由构造函数而生,由垃圾回收器回收而灭亡。当你程序没有访问它们,你可以把它们当做休眠状态。如果你不能访问对象,就不能调用它的方法。对于这样的意图和目的,它就是死的。但是有析构函数的对象在它们被宣告死亡之前还能喘最后一口气。析构函数应该只是清理非托管资源。如果在析构函数访问这个对象,那么这个对象又会复活了。它还是活的但是不好,即使从休眠状态唤醒了。下面就是很明显的例子:

public class BadClass 
{
    // Store a reference to a global object: 
    private static readonly List<BadClass> finalizedList =
    new List<BadClass>(); 
    private string msg;
    public BadClass(string msg) 
    {
        // cache the reference: 
        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 拥有的成员还是停留在内存,它们看起来很像被回收或析构了。你没有任何方法能控制析构的顺序。你不能使这种结构的工作可靠。情不要尝试。

除了学院练习,我从来没有看过像这样复活对象的代码。但我见过析构函数试图做这件事调用函数保持引用带回到有生命状态。原则上析构函数上的任何代码都要非常小心,推而广之, Dispose 方法也一样。如果代码除了释放资源之外还做了其他任何事情,请再次确认。这次操作很可能在将来发生问题。移除这些操作,并且保证析构函数和 Dispose() 函数只是释放资源。

在托管的环境,你不需要为每个类都写一个析构函数;只有存储了非托管类型或者包含实现 IDisposable 的成员变量的类才需要。即使你只是需要 Disposable 接口,而不需要析构函数,但实现整个模式。否则,你限制你的子类以至于它们实现标准 Dispose 模式会很复杂。遵循我描述的标准 Dispose 模式习惯。那对于你,你类的使用者,继承你类的人都会是更简单的。

小结:

这篇介绍实现 Dispose 接口的准则,对于有引用非托管资源的就要实现 Dispose 接口并且写析构函数,具体的实现模式文中有详实的介绍。