系列二:资源管理(减少装箱与拆箱,实现标准dispose)

1.值类型是数据的容器,它们不具备多态性。另一方面就是说,.Net框架被设计成单一继承的引用类型,System.Object,在整个继承关系中作为根对象存在。设计这两种类型的目的是截然不同的,.Net框架使用了装箱与拆箱来链接两种不同类型的数据。

 

2.装箱是把一个值类型数据放置在一个无类型的引用对象上,从而使一个值类型在须要时可以当成引用类型来使用。拆箱则是额外的从“箱”上拷贝一份值类型数据。装箱和拆箱可以让你在须要使用System.Object对象的地方使用值类型数据。但装箱与拆箱操作却是性能的强盗,在些时候装箱与拆箱会产生一些临时对象,它会导致程序存在一些隐藏的BUG。应该尽可能的避免使用装箱与拆箱。

 

3.装箱可以把一个值类型数据转化也一个引用类型,一个新的引用对象在堆上创建,它就是这个“箱子”,值类型的数据就在这个引用类型中存储了一份拷贝。参见图2.3,演示了装箱的对象是如何访问和存储的。箱子中包含一份这个值类型对象的拷贝,并且复制实现了已经装箱对象的接口。当你想从这个箱子中取回任何内容时,一个值类型数据的拷贝会被创建并返回。这就是装箱与拆箱的关键性概念:对象的一个拷贝存放到箱子中,而不管何时你再访问这个箱子时,另一个拷贝又会被创建。

4.Console.WriteLine("A few numbers:{0}, {1}, {2}", 25, 32, 50);

 使用重载的Console.WriteLine函数须要一个System.Object类型的数组引用,整型是值类型,所以必须装箱后才能传给重载的WriteLine方法。唯一可以强制这三个整数成为System.Object对象的方法就是把它们装箱。另外,在WriteLine内部,通过调用箱子对象上的ToString()方法来到达箱子内部。某种意义上讲,你生成了这样的结构:

int i =25;
object o = i; // box
Console.WriteLine(o.ToString());

 在WriteLine内部,下面的执行了下面的代码:

object o;
int i = ( int )o; // unbox
string output = i.ToString( );

  你可能自己从来不会写这样的代码,但是,却让编译器自动从一个指定的类型转化为System.Object,这确实是你做的。

为了避免这么挑剔的惩罚,在使用它们来调用WriteLine之前,你自己应该把你的类型转化成字符串的实例。console.WriteLine("A few numbers:{0}, {1}, {2}",25.ToString(), 32.ToString(), 50.ToString());

  这段代码使用已知的整数类型,而且值类型再也不会隐式的转化为System.Object类型。这个常见的例子展示了避免装箱的第一个规则:注意隐式的转化为System.Object,如果可以避免,值类型不应该被System.Object代替。

 

5.

public struct Person
{
private string _Name;

public string Name
{
    get
    {      return _Name;    }
    set
    {      _Name = value;    }
}

public override string ToString( )
{    Return _Name;  }
}

ArrayList attendees = new ArrayList( );
Person p = new Person( "Old Name" );
attendees.Add( p );

Person p2 = (( Person )attendees[ 0 ] );
p2.Name = "New Name";

Console.WriteLine(attendees[ 0 ].ToString( ));

        Person是一个值类型数据,在存储到ArrayList之前它被装箱。这会产生一个拷贝。而在移出的Persone对象上通过访问属性做一些修改时,另一个拷贝被创建。而你所做的修改只是针对的拷贝,而实际上还有第三个拷贝通过ToString()方法来访问attendees[0]中的对象。

        正因为这以及其它一些原因,你应该创建一些恒定的值类型(参见条款7)。如果你非要在集合中使用可变的值类型,那就使用System.Array类,它是类型安全的。

        如果一个数组不是一个合理的集合,以C#1.x中你可以通过使用接口来修正这个错误。尽量选择一些接口而不是公共的方法,来访问箱子的内部去修改数据:

public interface IPersonName
{
string Name
{    get; set; }
}

struct Person : IPersonName
{
private string _Name;

public string Name
{
    get    {      return _Name;    }
    set    {      _Name = value;    }
}

public override string ToString( )
{
    return _Name;
}
}

ArrayList attendees = new ArrayList( );
Person p = new Person( "Old Name" );
attendees.Add( p ); // 装箱

(( IPersonName )attendees[ 0 ] ).Name = "New Name";//这里用的是接口的方法,接口是引用类型,不需要拆箱

Console.WriteLine(attendees[ 0 ].ToString( )); // 拆箱

 装箱后的引用类型会实现原数据类型上所有已经实现的接口。这就是说,不用做拷贝,你可以通过调用箱子上的IPersonaName.Name方法来直接访问请求到箱子内部的值类型数据。在值类型上创建的接口可以让你访问集合里的箱子的内部,从而直接修改它的值。在值类型上实现的接口并没有让值类型成为多态的,这又会引入装箱的惩罚

我们应该对任何将值类型转化成System.Object或者接口类型的构造保持密切的关注:把值类型放到集合里,调用定义参数为System.Object类型的方法,或者强制转化为System.Object。只要有可能,我们都应该避免这些构造!

 

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

 

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


 8. 开始时,如果你的类使用了非内存资源,则一定得有一个析构函数。你不能指望你的用户总是记得调用Dispose方法,否则当他们忘记时,你会丢失一些资源

 

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

public interface IDisposable
{
void Dispose( );
}

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

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

 

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

protected virtual void Dispose( bool isDisposing );

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

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

public class MyResourceHog : IDisposable
{
// 标识是否调用过dispose()

private bool _alreadyDisposed = false;

// 终结器,调用虚方法
~MyResourceHog()
{
    Dispose( false );
}

// dispose方法,调用虚方法,终止析构函数
public void Dispose()
{
    Dispose( true );
    GC.SuppressFinalize( true );
}

// Dispose虚方法
protected virtual void Dispose( bool isDisposing )
{
    // 不释放多次
    if ( _alreadyDisposed )
      return;
    if ( isDisposing )
    {
      // TODO: 释放托管资源.
    }
    // TODO: 释放非托管资源.
    // 设置是否已经执行dispose:
    _alreadyDisposed = true;
}
}

 

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

public class DerivedResourceHog : MyResourceHog
{
// 设置自己的标识符.
private bool _disposed = false;

protected override void Dispose( bool isDisposing )
{
    // 不能多次调用.
    if ( _disposed )
      return;
    if ( isDisposing )
    {
      // TODO: 释放自己的托管资源.
    }
    // TODO: 释放非托管资源.

    // 让基类释放自己的资源.
    // 基类会调用 GC.SuppressFinalize( )处理析构函数
    base.Dispose( isDisposing );

    // 设置调用标识:
    _disposed = true;
}
}

 

注意,派生类和基类都有一个处理状态的标记,这完全是防御性的。重制的标记掩盖了在处理时任何可能发生的错误,而且是单一的类型处理,而不是处理构成这个对象的所有类型。你应该防御性的写处理方法dispose和析构函数,处理对象可能以任何顺序发生,你可能会遇到这种情况:你的类中某个成员在你调用Dispose方法以前已经被释放过了。你没有看到这种情况是因为Dispose()方法是可以多次调用的。如果在一个已经被处理过的对象上调用该方法,就什么也不发生。析构函数也有同样的规则。任何对象的引用存在于内存中时,你不用检测null引用。然而,你引用的对象可能已经处理掉了,或者它已经析构了。

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

public class BadClass
{
// 存储全局对象引用:
private readonly ArrayList _finalizedList;
private string _msg;

public BadClass( ArrayList badList, string msg )
{
    // 对引用进行缓存

    _finalizedList = badList;
    _msg = (string)msg.Clone();
}

~BadClass()
{
    // 将对象加入 _finalizedList.
    // 对象又复活了,而不是垃圾
    _finalizedList.Add( this );
}
}

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

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

        在托管环境里,我们不需要为每一个类型都要创建一个终结器只有当类型包含非托管类型,或者类所包含的成员实现了IDisposable接口,我们才需要为它们创建终结器即使我们只需要IDisposable接口(而不需要终结器),我们也应该实现完整的模式。否则,派生类在实现标准Dispose模式时将变得非常复杂。我们应该遵循上述的Dispose模式,这会使得包括我们自己、我们类型的用户以及由我们的类型创建派生类的开发人员的生活变得更加轻松。

 

 

 

posted @ 2011-01-13 23:35  yu_liantao  阅读(401)  评论(0编辑  收藏  举报