第6章 集合与泛型
集合与数组的基本功能大致相同,但它在处理数据时具有更强更灵活的功能。.NET Framework提供了用于数据存储和检索的专用集合类,包含在System.Collections和System.Collections.Generic(泛型)中。泛型是C# 2008的一个新增特性,通过泛型可以定义更安全的数据结构,得到更优质的代码。在本章中,将对集合与泛型进行详细介绍。
6.1 什么是集合
集合类是为保障数据的安全存储和访问设计的,常见的集合类如表6-1所示。
表6-1 常见的集合类
除了上述类外,还要重点说明一些接口。大多数集合类实现相同的接口,表6-2为一些集合类实现的接口。
表6-2 集合类的常见接口
以上知识点对于初学者而言可能比较难以理解,特别是像哈希表这样的类。但是没有关系,在下面的章节中,会对每个类都进行举例介绍。ArrayList类在第5章已经详细介绍过了,这里就不再赘述。下面就重点介绍其他类和相关接口。
6.2 SortedList可排序数组集合
SortedList集合的初始化方式有两种,包括泛型的和非泛型的,如下所示。
SortedList sl = new SortedList(); //创建非泛型SortedList集合 SortedList<Tkey, Tvalue> sl = new SortedList<Tkey, Tvalue> (); //创建泛型 //SortedList集合
本节只介绍非泛型SortedList集合的用法,有关泛型将会在以后的章节中介绍。
打开VS2008,在D:\C#\ch6目录下添加名为SortedListTest的控制台应用程序,步骤如下所示。
(1)包含命名空间System.Collections,在Program.cs中添加如下代码。
private static void KeyandValueOut(SortedList sl) { Console.WriteLine("\t-键-\t-值-"); for (int i = 0; i < sl.Count; i++) { Console.WriteLine("\t{0}:\t{1}", sl.GetKey(i), sl.GetByIndex(i)); //获取每个元素的键和值 } }
(2)定义一个能将SortedList里元素的键和值输出的方法,供Main()函数调用。在Main()函数中添加如下代码。
SortedList sl = new SortedList(); //创建SortedList可排序数组 sl.Add(1, "you"); //向SortedList添加键和值 sl.Add(2, "me"); sl.Add(3, "him"); Console.WriteLine("SortedList里的元素共{0}个", sl.Count); //输出SortedList的 //元素个数 Console.WriteLine("容量:"+sl.Capacity); KeyandValueOut(sl); //遍历输出SortedList所有的键及其对应的值 Console.ReadKey();
先实例化一个SortedList类,然后向里面添加三个元素,元素的键分别为“1”、“2”、“3”,元素的值分别为“you”、“me”、“him”,再输出SortedList的元素个数及每个元素对应的键和值。运行结果如图6-1所示。
图6-1 运行结果
6.3 Queue消息队列集合
同SortedList集合一样,Queue队列也有两种初始化方式,如下所示。
Queue q = new Queue(); //创建非泛型队列 Queue<T> q = new Queue<T>(); //泛型队列
下面还是以例子说明非泛型Queue的用法。
打开VS2008,在D:\C#\ch6目录下创建名为QueueTest的控制台应用程序,在命名空间中包含System.Collections,然后在Main()函数中添加如下代码。
Queue q = new Queue(); //创建队列 q.Enqueue("I"); //给队列添加元素 q.Enqueue("love"); q.Enqueue("peace"); q.Enqueue("! "); IEnumerator myEnumerator = q.GetEnumerator(); //实例化能循环访问队列枚举数 //IEnumerator接口 Console.WriteLine("该队列中的所有元素如下所示。"); while (myEnumerator.MoveNext()) //将枚举数推到队列的下一个元素 { Console.Write(myEnumerator.Current+""); //获取队列中的全部元素 } Console.WriteLine(); q.Peek(); //返回队列的开始处 q.Dequeue(); Console.WriteLine("将某元素踢出队列后剩余的元素如下所示。"); IEnumerator myEnumerator1 = q.GetEnumerator(); while (myEnumerator1.MoveNext()) { Console.Write(myEnumerator1.Current + " "); } Console.WriteLine(); if (q.Contains("love") == true) //查询队列中是否包含love元素 { Console.WriteLine("该队列包含love元素"); } else { Console.WriteLine("该队列不包含love元素"); } q.Clear(); //删除队列中的所有元素 Console.WriteLine("该队列所含元素总数为:"+q.Count); Console.ReadKey();
运行结果如图6-2所示。
图6-2 运行结果
本例中,用到了队列的一些常见操作。例如,向队列添加元素,从队列中删除某元素,查询某元素是否在队列中,返回队列的开始处,删除队列中的所有元素等。希望读者能认真体会,下一节将会介绍另一个集合类——Stack栈集合。
6.4 Stack栈集合
和前两种集合类一样,Stack栈也有两种初始化方式,非泛型和泛型。如下所示。
Stack st = new Stack(); //非泛型堆栈 Stack<T> st = new Stack<T>(); //泛型堆栈
同理,本节也举例对Stack的用法进行说明。
打开VS2008,在D:\C#\ch6目录下创建名为StackTest的控制台应用程序。在命名空间中包含System.Collections,然后在Main()函数中添加如下代码。
Stack st = new Stack(); //创建堆栈集合 st.Push(' a' ); //将a, b, c, d, e压入栈 st.Push(' b' ); st.Push(' c' ); st.Push(' d' ); st.Push(' e' ); Console.WriteLine("堆栈中的元素共有:{0}", st.Count); IEnumerator myEnumerator = st.GetEnumerator(); //实例化能遍历访问堆栈中所有元素 //的IEnumerator接口 Console.WriteLine("堆栈中的所有元素如下所示。"); while (myEnumerator.MoveNext()) { Console.Write( myEnumerator.Current+" "); //把堆栈中所有元素输出到控制台 } st.Pop(); //将第一个元素弹出堆栈 IEnumerator myEnumerator1 = st.GetEnumerator(); Console.WriteLine(); Console.WriteLine("某元素弹出后堆栈中的剩余元素如下所示。"); while (myEnumerator1.MoveNext()) { Console.Write(myEnumerator1.Current + " "); } Console.WriteLine(); Console.ReadKey();
运行结果如图6-3所示。
图6-3 运行结果
本例主要介绍了对象的是入栈与出栈操作。从程序运行结果可以看出,Stack是表示对象后进先出的简单结合。在程序中,依次压入了“a”、“b”、“c”、“d”、“e”5个char型字符,读者可以发现在堆栈中的排列是“e”、“d”、“c”、“b”、“a”,所以最后弹出堆栈中的第一个元素时,把“e”去掉了。下一节介绍最后一个集合HashTable,也就是常说的哈希表。
6.5 HashTable哈希表集合
与前面的三种集合不同的是,.NET Framework本身没有对HashTable提供泛型,所以它的初始化方式只有一种,如下所示。
HashTable ht = new HashTable(); //创建哈希表集合
HashTable是本章的一个难点,可能会比前三种集合都难以理解。同SortedList集合一样,HashTable也是键/值对的集合,但这些键/值是根据哈希代码进行组织。还是先举个例子对HashTable具体的用法进行说明。
打开VS2008,在D:\C#\ch6目录下建立名为HashTableTest的控制台应用程序。首先还是要包含System.Collections命名空间,在Program.cs中添加如下方法。
public static void PrintKeysAndValues(Hashtable myHT) { foreach (string s in myHT.Keys) { Console.WriteLine(s); Console.WriteLine(" -键- -值-"); } //HashTable中的每个元素都是键/值对,既不是键的类型,也不是值的类型,而是 //DictionaryEntry类型 foreach (DictionaryEntry de in myHT) { Console.WriteLine(" {0}: {1}", de.Key, de.Value); } Console.WriteLine(); }
上面的函数用作HashTable中所有元素的遍历输出,提供给Main()函数调用,接下来在Main()中添加如下代码。
Hashtable ht = new Hashtable(); //创建HashTable ht.Add("Ea", "a"); //向HashTable中添加键/值对 ht.Add("Ab", "b"); ht.Add("Cc", "c"); ht.Add("Bd", "d"); Console.WriteLine("Hashtable表中的所有元素的键如下:"); PrintKeysAndValues(ht); //遍历输出HashTable中的所有键/值对 ArrayList al = new ArrayList(ht.Keys); al.Sort(); //将HashTable中的元素按键的首字母排序 foreach (string key in al) { Console.Write("键:"+key); //输出键 Console.WriteLine("值:"+ht[key]); //输出键对应的值 } Console.ReadKey();
运行结果如图6-4所示。
图6-4 运行结果
本例中,主要介绍了向HashTable中添加键/值对,并遍历输出其中的所有键/值对,最后对HashTable中的元素按键的首字母排序输出。这里有如下两个问题需要说明。
(1)从图6-4中可以看出,HashTable元素的输出顺序并非是按添加的顺序输出。因为当某个元素被添加到HashTable中时,将根据键的哈希代码进行组织存储。哈希代码比较复杂,读者如果想深究,可参阅数据结构的相关书籍。
(2)当对HashTable中的元素进行遍历输出时,用到了结构体DictionaryEntry,这里有两个原因。第一,HashTable不像SortedList那样有访问自身键/值对的方法;第二,HashTable中的每个元素都是键/值对,既不是键的类型,也不是值的类型,而是DictionaryEntry类型。至此,就介绍完了集合类中的几个重要集合,下一节将介绍集合类的一些非常重要的概念。
6.6 集合中的一些重要概念
要很好地理解和运用集合,则必须理解集合中一些比较重要的概念,包括集合中的索引器、迭代器和深度复制等。
6.6.1 集合中的索引器
索引器在第3章曾经提到过,它在集合中应用比较广泛。在集合中,索引器有两个非常重要的特点。
(1)索引器有和数组相同的索引方式,但索引器可以不按照整数值进行索引,读者可以自定义查询机制,这是数组实现不了的。
(2)索引器可以被重载,也可以有多个参数。索引器在.NET Framework中没有专有的名称,它的定义形式如下。
T this[int pos]; //创建索引器
T表示返回类型,pos表示位置参数,因为它类似于属性,所以它也能支持get和set操作。下面举一个相关例子。
打开VS2008,在D:\C#\ch6目录下创建名为IndexerTest的控制台应用程序。首先,将Program.cs重命名为IntIndexer.cs,因为后面要用到该类的构造函数,这样重命名方便于读者理解。然后,在IntIndexer.cs中添加如下代码。
string[] Data; public IntIndexer(int size) //索引器类构造函数 { Data = new string[size]; //定义一个字符串数组 for (int i=0; i < size; i++) { Data[i] = "no value"; //对数组中的每个元素进行赋值 } } public string this[int pos] //定义返回类型为string的索引指示器 { //get、set操作与属性中的用法一致 get { return Data[pos]; } set { Data[pos] = value; } }
上面代码在构造函数中对字符串数组Data进行初始化,并为其创建一个索引器,用get、set操作能对其中的元素进行获取或设置。下面就是怎么调用的问题,在Main()函数中添加如下代码。
int size = 10; IntIndexer Ind = new IntIndexer(size); Ind[2] = "Some Value"; Ind[3] = "Another Value"; Ind[7] = "Any Value"; Console.WriteLine("\n索引器输出\n"); for (int i = 0; i < size; i++) { Console.WriteLine("Ind[{0}]: {1}", i, Ind[i]); } Console.ReadKey();
Main()函数的功能是初始化一个新的IntIndexer对象,并往里面添加一些新值,最后将全部元素输出到控制台。运行结果如图6-5所示。
图6-5 运行结果
6.6.2 集合中的迭代器
迭代器是C# 2008的一个重要功能,它可以是一种方法、get访问器或是一种运算符,它支持在类或结构中实现foreach迭代,而不用实现整个IEnumerable接口。迭代器能返回具有相同类型的值的有序序列。使用yield return语句能依次返回所有元素,yield break能终止迭代操作。迭代器的返回类型必须是Ienumerable、Ienumerator或Ienumerable<T>、Ienumerator<T>。当执行到yield return语句时,能够保存当前的位置。说了这么多理论,可能读者也不知所措了,还是看看下面的例子,实现步骤如下。
(1)打开VS2008,在D:\C#\ch6目录下建立名为IterateTest的控制台应用程序。
(2)打开编译器后,单击“视图”—“解决方案资源管理器”命令,在“解决方案资源管理器”窗口中右键单击“IterateTest”按钮,选择“添加”—“新建项”命令,在弹出的窗口中选择“类”选项,重命名为“MonthofYear.cs”,然后往里面添加如下代码。
string[] m_Months = { "Jan", "Feb", "Mar", "Api", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; //通过迭代器实现System.Collections.IEnumerable接口的GetEnumerator()方法 public System.Collections.IEnumerator GetEnumerator() { for (int i = 0; i < m_Months.Length; i++) { yield return m_Months[i]; //依次输出数组中的每个元素 } }
(3)通过GetEnumerator()方法返回一个枚举器,实现数组元素的遍历输出。然后返回到Program.cs,在Main()函数中添加如下代码。
MonthofYear my = new MonthofYear(); foreach (string Month in my) { System.Console.Write(Month + " "); } Console.ReadKey();
(4)程序运行结果如图6-6所示。
图6-6 程序运行结果
本例中,通过用迭代器实现System.Collections.IEnumerable接口的GetEnumerator()方法,完成了对字符串数组m_Months元素的遍历输出。重点说明的是迭代器本身不是成员,而是实现函数的一种手段。比如GetEnumerator()方法,如果它没有被迭代器实现就不能被调用。
6.6.3 深度复制
在日常的应用中,常常会用到将一个变量的内容复制到另一个变量。这里有两种方式,浅度复制和深度复制。下面先看看两者的区别。如果是复制一个类的成员,那么浅度复制的新对象包含的引用对象和源对象的指向一致,也就是说,它既复制了值,又复制了值的引用;而深度复制的新对象与它复制的源对象是相互独立的,它只复制了值,而没有复制值的引用。
下面以一个例子来说明两者的区别。
打开VS2008,在D:\C#\ch6目录下建立名为ShadowCopyTest的控制台应用程序。先添加一个新类Class1.cs(方法同上一节),并添加如下代码。
public string val;
这里定义了一个字符串,用作被复制的源对象,然后在Program.cs中添加如下代码。
Class1 cs = new Class1(); public Program(string nVal) { cs.val=nVal; } public object GetCopy() { return MemberwiseClone(); //创建源对象的浅复制副本 }
对Class1类进行实例化,并定义复制的方法。MemberwiseClone()派生于System.Object,能实现源对象的浅度复制。最后,在Main()函数中添加如下代码。
Program pg = new Program("a"); //源对象 Program pg1 = (Program)pg.GetCopy(); //新对象 Console.WriteLine("源对象:"+pg.cs.val); Console.WriteLine("新对象:"+pg1.cs.val); pg.cs.val = "b"; //改变源对象 Console.WriteLine("改变后的源对象:"+pg.cs.val); Console.WriteLine("改变源对象后的新对象:" + pg1.cs.val); Console.ReadKey();
这段代码主要是测试,改变源对象的同时复制了源对象的新对象是否发生改变。程序运行结果如图6-7所示。
图6-7 程序运行结果
从运行结果可以看出,新对象也跟着发生了变化。但有些时候,这不是需要的。通常需要新对象不随着源对象发生改变,这就需要用到深度复制。接下来,将以上代码中的GetCopy()方法修改为ICloneable里面的Clone()方法,该方法能实现深度复制,具体代码如下。
public object Clone() { Program dpg = new Program(cs.val); return dpg; }
然后再将调用该方法的代码修改如下。
Program pg1 = (Program)pg.Clone();
运行结果如图6-8所示。
图6-8 运行结果
从运行结果中可以看出,当源对象发生改变时,新对象没有发生变化。深度复制较之与浅度复制并非有什么优势,而是两者应用的场合不一样,这点读者应该明白。至此,集合的几个主要概念就介绍完毕,下一节将开始讲述怎样为集合使用泛型。
6.7 为集合使用泛型
泛型是C# 2008的重要特性,它的主要用途是定义类型安全的数据结构。“泛”,从字面意思讲,应该是范围广,众多的意思,“泛型”,应该解释为范围广的类型,或者叫多类型。从这点上就可以看出它的一个优势,就是能够避免烦琐的类型转化。
6.7.1 定义泛型类
泛型在集合中会常常用到,例如,创建好一个集合类1,定义好它的类型和一些方法。但现在还需要一个集合类2,它和集合类1的区别仅是类型不一样,但由此引发的一些方法也需要改变。如果不想再定义一个集合类2,就必须实现进行类型转化或重载,但这也是很麻烦的。使用泛型能很好地解决这个问题。泛型类是在类的实例化过程中建立的,可以很容易实现强类型化,而且代码很简单。
看下面的一个简单的例子,定义一个泛型队列。
Queue<T> q = new Queue<T>();
泛型的使用分为两种,一种是使用.NET Framework提供的泛型,另一种是使用自己创建的泛型。先介绍一些.NET Framework提供的泛型类和接口,它们包含在System.Collections. Generic命名空间中,如表6-3所示。
表6-3 常见的泛型类和接口
以上表格中的很多类或接口,似乎在前面已经见过,只是多了一个“<>”而已。对于非泛型集合和泛型集合的区别,想必读者已经有了一个感性认识,下面通过一个简单的例子进行说明。打开VS2008,在D:\C#\ch6目录下建立名为GenericTest的控制台应用程序,在Main()函数中添加如下代码。
ArrayList al = new ArrayList(); al.Add(1); al.Add(2); al.Add(3.0); int total = 0; foreach (int val in al) { total = total + val; } Console.WriteLine("元素的总和为: {0}", total); Console.ReadKey();
本例是对ArrayList集合添加三个元素,并遍历输出,单击F5键,程序能编译通过,在运行期间抛出异常,如图6-9所示。
图6-9 程序运行异常
这是因为,代码为ArrayList集合添加了三个元素,前两个为int型,第三个为double型,而最后的遍历输出时指定的元素类型为int型,所以此时会抛出异常。对于这样的小程序,出了异常可以立即进行改正。但对于一些数据结构十分复杂的大程序而言,程序员往往希望能在程序编译过程中,就能找出潜在的错误。现在,将上面的代码替换为如下所示代码。
List<int> l = new List<int>(); l.Add(1); l.Add(2); l.Add(3.0); int total = 0; foreach (int val in l) { total = total + val; } Console.WriteLine("元素的总和为: {0}", total); Console.ReadKey();
按F5键,程序在编译期间出现错误,如图6-10所示。
图6-10 编译错误
本例中定义了一个泛型集合,通过类型指定,在编译期间即能检测出错误。这很好地体现了泛型能定义类型安全的数据结构。除了能定义泛型类外,还能定义泛型接口、泛型委托、泛型方法等,在下面的章节中将作详细介绍。
6.7.2 定义泛型接口
为泛型集合类定义泛型接口是非常重要的,它可以避免进行值类型的装箱与拆箱操作,使代码更简单,运行速度更快。如果将接口指定成了某类型的约束时,就必须使用实现该接口的类型。.NET Framework中有一些比较重要的泛型接口,参见表6-3。泛型接口的定义语法和泛型类比较相似,看下面的代码。
interface myinterface<T> where T : Class1
上面代码定义了一个泛型接口myinterface<>, interface是接口的关键字。有关泛型接口的举例会在后面的章节讲到。
6.7.3 定义泛型方法
泛型方法的定义也很简单,如下面的代码。
public T Method<T>() { return default(T); }
上面代码定义了一个名为Method的泛型方法,读者需要注意的是该方法的返回类型和参数都必须使用泛型类型参数。使用default关键字是为了为类型T返回默认值。定义的方法自然应该被调用,下面接着看怎样调用该方法,代码如下。
string result = Method<T>(); //调用泛型方法
在调用该泛型方法时,指定了方法的返回类型为string型。泛型方法的举例也放在后面讲。
6.7.4 定义泛型委托
在前面章节中介绍了委托的定义,下面先看看定义一般委托与泛型委托的区别。
public delegate int mydelegate(int p1, int p2) //一般委托 public delegate T1 mydelegate<T1, T2>(T2 p1, T2 p2)where T1:T2 //泛型委托
一般委托能够调用方法,这在前面章节中已经讲过。泛型委托调用方法的方式和一般委托差不多,看下面的例子。
public delegate void mydelegate<T>(T item); public static void Method(int i) { } mydelegate<int> md = new mydelegate<int>(Method);
本例中,定义了一个带有int型参数的Method方法。使用泛型委托mydelegate进行调用,在实例化过程中为Method方法指定了参数类型。至此,泛型的基本类型就介绍完了。
6.8 小结
本章主要介绍了一些常见的集合类,如SortedList集合、Queue集合、Stack集合和HashTable集合等。同时分别举例说明了它们的用法,如怎样添加元素,怎样遍历访问等。然后,说明了集合中的几个重要概念,即索引器、迭代器和深度复制,并举例说明了它们的用途。在本章最后,引入C# 2008的一个重要特性——泛型。分别介绍了怎样定义泛型类、泛型接口、泛型方法、泛型委托等,最后以一个自定义的泛型链表说明了泛型的应用。