Effective C# 根据需要选用恰当的集合

如果要问 “哪种集合是最好的?”我的回答是:“视需要而定。”不同的集合有不同的功能特性,并且针对其行为的不同进行了优化。.Net Framework支持许多相似的集合:列表、数组、队列、栈等等。另外,C#支持多维数组,其性能特点不同于其它的一维数组或者交错数组。.Net Framework中还包含了很多专门化的集合,你可以回顾一下以前创建的程序中用到的那些集合。由于所有的集合都实现了ICollection接口,你可以非常快速的找到它们。在描述ICollection接口的文档中列出了所有实现这个接口的类。这二十多个类都是可供我们使用的集合。

      在创建集合时,你应当考虑最经常对这个集合执行哪些操作,这有助于选出适合你需要的正确的集合。另外,为了使程序更具弹性,你应当依赖于集合类所实现的接口编程,这样即使发现当初设想中使用的集合是不正确的,你仍然可以用其它的集合来代替它。

      在.Net Framework中有三种不同类型的集合:数组、类数组集合和基于哈希原理的集合容器。其中数组是最简单,一般来说也是速度最快的,那就让我们先从这里说起吧。数组是我们最常用的集合类型。

      一般来说当需要使用集合时,System.Array类,或者更恰当的说,一个指定类型的数组类应当是你的第一选择。选用数组最重要的原因就是数组是类型安全的。除C# 2.0中的泛型(参见本书第49项)外,其它集合储存的都是System.Object类型的引用。当我们声明数组时,编译器会为我们指定的类型创建一个特殊的System.Array的派生。例如下例中的声明将创建一个整型数组:

private int [] _numbers = new int[100];

      数组中储存的将是整数,而不是System.Object。这样的意义在于当我们添加、获取或者移除数组中的值类型的时候,可以避免装箱和拆箱操作所带来的效率上的损失(参见本书第17项)。上例中的初始化过程创建了一个可以储存100个整数的一维数组。数组所占用的内存单元都被置以0。值类型数组的初始值都是0,而引用类型数组的初始值都是null。我们可以通过索引来访问数组中的每一项。

int j = _numbers[9];

  除此之外,我们还可以使用foreach或者枚举器来遍历数组

  foreach (int i in _numbers)
  
{
      Console.WriteLine(i.ToString());
  }

            
//或者


        IEnumerator it 
= _numbers.GetEnumerator();
        
while (it.MoveNext())
          
{
                
int i = (int)it.Current;
                Console.WriteLine(i.ToString());
            }

 

 

如果你需要储存单一序列的对象,你应当选择数组来储存他们。但是一般来说,我们的数据构成都是比较复杂的集合。这很容易让我们马上倒退回C语言风格转而使用交错数组――一种包含数组的数组。有时这正是我们需要的。在交错数组中外层集合的每个元素都是一个数组。

 

 

    public class MyClass
    
{
        
private int[][] _jagged;
        
public MyClass()
        
{
            _jagged 
= new int[5][];
            _jagged[
0= new int[10];
            _jagged[
1= new int[12];
            _jagged[
2= new int[7];
            _jagged[
3= new int[23];
            _jagged[
4= new int[5];
        }

    }

 

外层数组内部存储的每个一维数组可以是不同大小的。当需要创建不同大小的数组的数组时,你可以使用交错数组。交错数组的缺点在于列方向遍历的效率低。例如现在要检查交错数组中每一行第三列的值,每检查一行,都需要对数组进行2次查找。在交错数组中,第0行第3列的元素和第1行第3列的元素之间并没有关联关系。只有多维数组才能高效的完成列方向上的遍历。以前C和C++的程序员使用一维数组来完成对将二维(或多维)数组的映射。对于以前的C和C++程序员来说,这样的代码是很清晰的:

double num = MyArray[ i * rowLength + j ];


      而其它人更喜欢这样写:

double num = MyArray[ i, j ];

      但是C和C++不支持多维数组,而C#支持。使用多维数组可以创建一个真实的多维结构,不论对于你还是编译器来说都会更加清晰。你可以使用类似于一维数组声明的标记来创建一个多维数组。

private int[,] _multi = new int[10, 10];


      上面的声明创建了一个二维数组,10×10的阵列共100个元素。在多维数组中每一个维度的长度都是恒定值。利用这个特性,编译器可以生成高效的初始化代码。而初始化一个交错数则组需要多次初始化声明。在早些的简单例子中可以看到,对于例子中的交错数组,你需要声明五次。交错数组越大、维数越多所需要的初始化代码也越庞大,你必须手工来完成这一切。然而对于多维数组来说,所需要的仅仅是在初始化声明时指定其维度。此外,多维数组还可以高效的初始化数组元素。对于值类型的数组来说,有效范围内的每个索引所对应的元素,都被初始化为一个值的容器。这些值的内容都是0。引用类型的数组的每个索引对应的都是null。对于数组的数组,其存储单元内部也是null。

      一般来说,多维数组中的遍历要比交错数组快的多,特别是列方向或斜线方向的遍历。编译器可以使用指针算法来处理数组中的任意一个维度。而对于交错数组来说,这需要在每个一维数组中搜索正确的值。

      多维数组可以充当任意的集合,在很多场合都能发挥作用。假设你要创建一个在棋盘上进行的游戏。你需要安排一个有64块区域的表格来做为棋盘:

private Square[,] _theBoard = new Square[8, 8];

      这样的初始化方式创建了储存这些Square类型的数组。假设Square是引用类型,由于这些Square类型本身还没有被创建,因此每个数组中存储的元素都是null。为了初始化这些元素,我们必须考虑到数组中的每一个维度。

 

for (int i = 0; i < _theBoard.GetLength(0); i++)
  
{
        
for (int j = 0; j < _theBoard.GetLength(1); j++)
        
{
            _theBoard[i, j] 
= new Square();
        }

    }

 

但是在多维数组中,你拥有更加灵活的遍历元素方式。我们可以通过数组的索引来获取其中合法的元素:

Square sq = _theBoard[4, 4];


      如果你需要遍历整个集合,你可以使用迭代器

 

foreach(Square sq in _theBoard)
{
    sq.PaintSquare();
}



与之对比的是如果我们使用交错数组:

foreach(Square[] row in _theBoard)
{
    foreach(Square sq in row)
    {
        sq.PaintSquare();
    }
}



交错数组中增加每一个新的维度代表着需要声明一个新的foreach来完成遍历。而在多维数组中,一个foreach声明就可以生成检查每个维度是否越界和获取数组中元素的所有代码。foreach声明会生成特殊的代码来对数组的每个维度进行遍历。foreach循环所生成的代码等同于如下代码:

        for (int i = _theBoard.GetLowerBound(0); i < _theBoard.GetUpperBound(0); i++)
            {
                for (int j = _theBoard.GetLowerBound(1); j < _theBoard.GetUpperBound(1); j++)
                {
                    _theBoard[i, j].PaintSquare();
                }
            }



这些代码看起来效率并不高,因为在循环内部调用了GetLowerBound和GetUpperBound方法,但是实际上这是最高效的结构。JIT编译器可以将数组的边界缓存起来,并且取消内部对数组越界判断的操作。

      数组类有两个主要的缺点,正是这两个缺点使得.Net Framework中其它的集合类型有了用武之地。第一个缺点影响数组的大小调整:数组不能动态的调整大小。如果你需要调整数组某一维度的大小,你就必须重新创建一个数组并从原数组中将所有已存在的元素拷贝至新数组。调整大小非常耗时:一个新的数组必须被分配空间,已有数组中的全部元素必须被拷贝到新数组中。尽管这种在托管堆上的拷贝和移动的代价已经不像C或者C++时代那样昂贵,但是依然会耗费时间。而更重要的是这种操作可能导致陈旧数据被应用。考虑下面的代码片断:

 

   private string[] _cities = new string[100];

        
public void SetDataSource()
        
{
            myListBox.DataSource 
= _cities;
        }


        
public void AddCity(string cityName)
        
{
            
string[] temp = new string[_cities.Length + 1];
            _cities.CopyTo(temp, 
0);
            temp[_cities.Length] 
= cityName;
            _cities 
= temp;
        }

 

即便是AddCity方法被调用之后,列表框所使用的数据源仍然是_cities数组的老版本拷贝。新添加的城市永远不会显示在列表框之中。

      ArrayList类是构建在数组上的一种高层次抽象。ArrayList集合混合了一维数组和链表的特征。你可以在ArrayList中进行插入操作,也可以调整它的大小。ArrayList将其大部分职责都委托给其内部包含的数组,这意味着ArrayList类在功能特性上和Array类是非常相似的。当我们可以使用ArrayList来轻松的应对未知大小的集合,这也是ArrayList较Array而言的主要优点。ArrayList可以随时增长或缩减。虽然我们仍然需要付出拷贝和移动数组元素的代价,但是这些算法的代码是已经写好并经过测试的。由于ArrayList对象内部储存数据的数组是封装好的,也不会出现陈旧数据的问题:客户程序将指向ArrayList对象而不是内部数组。ArrayList集合是C++标准类库中的vector类在.Net Framework中的版本。

      队列和栈类在System.Array基础上提供了专门的接口。通过这些类的特定的接口实现了先进先出的队列和后进先出的栈。我们要始终牢记这些集合是使用其内部的一维数组来储存数据的。当我们改变它们的大小时同样会受到性能上的损失。

      .Net中不包含链表结构的集合。由于有高效的垃圾收集机制,表结构出场亮相的次数也减少了。如果你的确需要实现链表行为时,你有两种选择。如果你引起经常要添加或移除项目而使用列表时,你可以使用字典类简单的储存键,对于值则赋以null。当需要实现一个键/值的单链表时,你可以使用ListDictionary类。或者你可以使用HyBridDictionary类。当集合较小时,HyBridDictionary类会使用ListDictionary来应对,而对于较大的集合则选用HashTable。这几个集合和其它许多集合一起位于System.Collections.Specialized命名空间下。尽管如此,如果你为了实现某些用户指令的目的而使用链表结构的话,那么你完全可以使用ArrayList集合来代替它。尽管ArrayList内部是使用数组来进行存储的,但是它也可以完成在任意位置插入元素的功能。

 

 

另外两种支持基于字典的集合是SortedList和Hashtable。它们都包含键/值对。SortedList会对键进行排序而Hashtable不会。Hashtable提供了对给定键的快速搜索,而SortedList提供了按键的顺序遍历元素的功能。Hashtable通过做为键的对象的哈希值来进行搜索,如果哈希键是足够高效的话,那么其每次搜索操作所耗费的时间是一个常数,即时间复杂度为0(1)。SortedList使用二分法来进行搜索,这种算法操作的时间复杂度为0(ln n)。

      最后我们来介绍一下BitArray类。顾名思义,这个类是用来存储二进制数据的。BitArray类使用一个整型的数组来储存数据。整型数组中的每个存储单元储存32个二进制值。这样做可以达到压缩的目的,但是同样也会降低性能。每次对BitArray进行get或者set操作都会引发对储存着目标数据和其它31个二进制数据的整数的操作。BitArray包含了一些方法来对其内部的值进行布尔型操作,例如:OR,XOR,AND和NOT。这些方法使用BitArray做为参数,可以被用来快速过滤BitArray中的多位二进制数。BitArray针对位操作做了专门的优化,应当使用它来存储那些经常进行做为掩码的二进制标记集合,而不应当使用一般的布尔型的数组来代替。

      除了Array类之外,在.Net Framework 1.x版本的C#中再也没有其它集合类是强类型的。它们储存的都是Object的引用。C#泛型中包含了一种新版本的拓扑结构,它能够以一种更加普遍化的方式被创建。泛型是创建类型安全集合的最好方法。你也可以通过现在的System.Collection命名空间中包含的抽象基类在非类型安全的集合上构建你自己的类型安全接口:CollectionBase和ReadOnlyCollectionBase提供了存储键/值集合的基类。DictionaryBase类使用的是哈希表的实现方法,他的功能特点和哈希表非常相似。

      当你的类包含集合时,你会希望为将它暴露给你的类用户。你有两种方法来达到这个目的:使用索引器或者实现IEnumerable接口。在本节开始的部分,我向你展示了数组如何通过[]标记来获取其中的项目,你也可以使用foreach来遍历数组中的项目。

      你可以为你的类创建多维索引器。这很类似于C++中重载操作符[]一样。就像C#中的数组一样,你可以创建多维的索引器:

 
    public int this[int x, int y]
      {
            get
            {
                return ComputeValue(x, y);
            }
        }


添加索引功能通常意味着你的类型内部包含一个集合。而这也意味这你的类型应当支持IEnumerable接口。IEnumerable接口提供了一种标准的迭代遍历集合中所有元素的机制。

 
public interface IEnumerable
{
        IEnumerator GetEnumerator();
}

      //GetEnumerator方法返回一个实现了IEnumerator接口的对象。IEnumerator接口支持对集合的遍历:

public interface IEnumerator
{
        object Current { get;}
        bool MoveNext();
        void Reset();
    }


除了IEnumerable接口外,如果你的类型要模拟一个数组,那么你还应当考虑IList和ICollection接口。如果你的类型要模拟一个字典,那么你应当考虑实现IDictionary接口。当然,你可以自己来实现这些庞大的接口,如果要解释实现方法的话,我恐怕需要多花上许多篇幅。其实有一个更简单的解决办法:当我们要创建特殊目的的集合时,我们可以从CollectionBase或者DictionaryBase来派生出我们的类。

      让我们来回顾一下本节所覆盖的内容。一个最好的集合取决于它要执行的操作和应用程序对空间和时间的要求。在大多数情况下,Array类提供了最高效的集合容器。C#中多维数组的出现意味着我们可以非常简单的模拟多维结构而不必担心牺牲性能。当你的程序需要更加灵活的添加和删除项时,你可以哪些使用更加灵活的集合类型。最后,当你要创建一个模拟集合的类时,应当为其实现索引器和IEnumerable接口。
posted @ 2008-11-11 21:15  瞪着你的小狗  阅读(487)  评论(0编辑  收藏  举报