现在的位置: 首页 > 综合 > 正文

《Visual C# 最佳实践》第五章 泛型和集合类 (一):泛型

2013年04月25日 ⁄ 综合 ⁄ 共 9392字 ⁄ 字号 评论关闭
文章目录

第五章 泛型和集合类

  微软在2005年底正式发布了C#2.0版本,与C#1.0版本相比,新版本增加了许多新功能,其中最重要的是对泛型的支持。通过泛型,我们可以定义类型更安全的数据结构,而无需使用实际的数据类型,或者不可靠的装箱和拆箱操作。这样显著提高代码的性能,同时也提高了代码的质量。泛型,其实不是什么新鲜的东西,它在功能上有点类似于C++的模板,但是又和C++的模板有原理上的差别。
  本章讨论使用泛型的常见问题,比如说为什么要使用泛型、怎样编写泛型方法、泛型约束的使用、泛型方法的重载问题等,通过这些使我们可以大致了解泛型并掌握泛型的常规应用,编写出更简单、通用、高效的代码。同时,我们还讨论了几种常用集合类的应用。
  本章的学习重点:
  ◆    泛型用途
  ◆    泛型概念
  ◆    泛型定义
  ◆    泛型约束
  ◆    泛型方法
  ◆    泛型实现

5.1泛型

  “一次编码,多次使用”,这就是引入泛型的根源。目前,很多高级语言都提供了泛型这个强有力的术语。当我们使用C语言编写代码的时候就有了宏来实现简单替换,后来C++语言继续发扬了这一伟大风格,发明了模板的概念。大量的应用证明,它存在的价值。来到C#时代,泛型继承了模板的优良传统,并将继续发扬光大。
  在本文中,作者将向你展示定义和使用泛型是多么容易的事情。请注意,长期以来泛型一直被认为是最高级和最困难的术语。

5.1.1泛型用途

  在C#的一些早期版本中,我们可以证明没有泛型也可以工作,因为每种类型都是派生于一个公共基类型object。这意味着开发人员可以基于object类型定义一个类并且把一切东西放到该类上,因为一切都派生于object。然而,一个object类可能意味着,一个Customer对象,一个Integer对象以及只要你能想到的对象都能被放置到同一个类的实例上。结果是,开发人员要不断的装箱然后拆箱,还怕装错东西,相当麻烦。所有,我们一直建议使用强类型来装载这些东西,把有用的东西与垃圾分开。如果装错了,在编译环节就能够被发现并得到修改,基于object定义一切被认为是弱类型定义。
  业界的高手们在数十年前就确信强类型优于弱类型,所以.NET最终支持强类型,这看上去是很自然的事情。强类型算法当然建议类型化参数,这正是我们在泛型中所用的东西。那什么是类型化参数,我们将在泛型的概念中向大家介绍。
  我们在编写代码的时候,经常遇到两个模块的功能非常相似,只是一个是处理int类型,另一个是处理string类型,或者是其它自定义的数据类型。这样子的话,只好一个方法处理一个数据类型,因为方法的参数类型不同。有没有一个办法,在方法中把数据的类型也当作参数来传递,这样就可以合并代码了。泛型的出现正是为解决这个问题的。
  为了解决这个问题,我们先来看下面的代码,代码中省略了部分内容,功能是实现一个堆栈,这个堆栈只能处理int数据类型:
public class Stack
{
   private int[] items;
   public int pop() {}
   public void push(int item) {}
   public Stack(int i)
   {
      this.items = new int[i];
   }
}
  上述代码没有任何问题,但是,如果我们突然又需要一个堆栈来保存string类型的数据,我想,我们会把上面的代码copy一份,把int类型改成string类型就可以了。当然,这样做,肯定是没问题的,但是如果以后需要long类型,Node类型的堆栈那又该怎么办?继续复制?这里有个折衷的办法,就是使用一个通用的数据类型object来实现这个堆栈:
public class Stack
{
   private object[] items;
   public object pop() {}
   public void push() {}
   public Stack(int i)
   {
      this.items = new object [i]
   }
}
  这样,这个堆栈就很灵活,可以接收任何的数据类型。但是,我们仔细的想想,也不是一点缺陷也没有,主要是在:当Stack处理值类型时,会出现装箱,拆箱操作。这将在托管堆上分配和回收大量的变量,若数据量大,则性能损失就非常严重。在处理引用类型时,虽然没有装箱和拆箱操作,但将用到数据类型的强制转换操作,增加了处理器的负担。
  在数据类型的强制转换上还有一个更严重的问题,如下:
  Node1 x = new Node1();
  stack.push(x);
  Node2 y = (Node2)stack.pop();
  上面的代码在编译时是没有任何的问题,但是由于push了一个Node1类型的数据,但在pop时却要求转换为Node2类型,这将出现程序运行时的类型转换异常,但却逃离了编译器的检查。
  针对object类型堆栈的问题,我们引入泛型,他可以很优雅地解决这些问题。泛型用一个通过的数据类型T来代替object,在类实例化时指定T的类型,运行时(Runtime)自动编译为本地代码,运行效率和代码质量都有很大的提高,并且保证数据类型安全。具体示例,我们在讲完泛型概念后,向大家演示。
  下面我们列举一下使用object实现存在问题:
  1、性能开销问题,对象的装箱与拆箱会造成系统性能开销很大的问题,如果这个对象是一个集合,那么消耗就相当不乐观的。还有就是引用类型强制转换也会消耗性能,能不类型转换的尽量不转换。
  2、类型安全问题,object无法保证运行时类型转换是安全,object可以容纳Customer对象,同时也可以容纳Integer对象,类型转换根本没有保障。
  那么,使用泛型我们又给我们带来什么好处呢?
  1、使用泛型可以最大限度地重用代码、保护类型的安全以及提高性能。
  2、泛型最常见的用途是创建集合类,在集合类中,泛型被应用的淋漓尽致。
  3、可以创建自己的泛型接口、泛型类、泛型方法、泛型事件和泛型委托。
  4、可以对泛型类进行约束以限制使用这个类的具体版本。
  5、泛型中使用的参数类型可以在运行时通过反射获取相关信息。
  .NET Framework类库在System.Collections.Generic命名空间中包含几个新的泛型集合类。应尽可能地使用这些类来代替普通的类,如System.Collections命名空间中的ArrayList,这个问题我们将在介绍集合类的时候会向大家详细的介绍。

5.1.2泛型概念

  在介绍泛型用途的时候,我就向大家引入“类型化参数”的概念,那么什么才是“类型化参数”呢?下面我就向大家详细介绍这个概念。“类型化参数”可以说就是泛型的灵魂。十几年来,我们一直在使用字母T(type的缩写)作为类型化参数的名字。这样,在任何泛型类使用的地方,你都能够找到T。使用泛型的关键仅仅是提供这个T。定义泛型的关键在于实现一个方法或类,并且用特定数据类型来替换掉T。当然,我们这里所说的T很多时候,可以使用其他字母来替换,这个不是强制要求的,是大家潜移默化的书写规范。
  我们知道在一个方法中,一个变量的值可以作为参数,但其实这个变量的类型本身也可以作为参数。所以,泛型,就是通过“类型化参数”来实现在同一份代码上操作多种数据类型的机制。泛型是一种编程范式,它利用“类型化参数”将类型抽象化,从而实现更为灵活的复用。
  泛型赋予了代码更强的类型安全,更好的复用,更高的效率,更清晰的约束。
  那么,在.NET中,C#是如何实现泛型机制的?
  1、C#的泛型机制由CLR在运行时支持,区别于C++的编译时模板机制,和java的编译时的“搽拭法”。这样使得C#的泛型可以在各种支持CLR的语言之间进行无缝的互操作。
  2、C#的泛型被编译为IL和元数据时,采用特殊的占位符来表示泛型类型,并用专有的IL指令支持泛型操作。而真正的泛型实例化工作发生在JIT编译时。
  3、第一轮编译时,编译器只为Class<T>类型产生“泛型版”的IL代码和元数据,并不进行泛型的实例化,T在中间只充当占位符。JIT编译时,当JIT编译器第一次遇到Class<int>时,将用int类型替换“泛型版”IL代码与元数据中的T,进行泛型的实例化。
  4、如果类型参数是“引用类型”,CLR只产生同一份代码,但如果类型参数为“值类型”,对每一个不同的“值类型”,CLR将为其产生一份独立的代码。
5、如果实例化泛型的参数相同,那么JIT编译器会重复使用该类型,因此C#的动态泛型能力避免了C++静态模板可能导致的代码膨胀的问题。
  6、C#泛型携带有丰富的元数据,因此C#的泛型可以应用于强大的反射技术。
  7、C#的泛型采用“基类、接口、构造器、值类型/引用类型”的约束方式来实现对类型参数的“显示约束”,提高了类型安全的同时,也丧失了C++模板基于“签名”的隐式约束所具有的高灵活性。

5.1.3泛型定义

  本文通过定义泛型类来向大家介绍一下泛型,用到泛型的时候你就会发现,泛型其实就像一个口袋,你可以很方便地往里面装东西,只是在第一次使用这个口袋的时候要注意声明它只能装什么样类型的东西,以后就不能装错了。格式如下:
  public class Stack<T>
  {
      ……
  }
  Stack<string> myStack = new Stack<string>();
  下面我们使用泛型来重写上面讲述的堆栈,用一个通用的数据类型T来作为一个占位符,在实例化时用一个实际的类型来替换。如下:
public class Stack<T>
{
   private T[] items;
   public T pop() {}
   public void push(T item) {}
   public Stack(int i)
   {
    this.items = new T[i];
   }
}
  我们来看看这个类的书写格式其实没有什么变化,只是引入了通用的数据类型T,这样我们就说这个类就是泛型类,就可以适用于任何数据类型,并且类型安全的。这个泛型类的调用方法:
Stack<int> a = new Stack<int>(100);
a.push(10);
a.push("10");        //这行代码编译无法通过,因为泛型类a只接收int类型的数据
int x = a.pop();
Stack<string> b = new Stack<string>(100);
b.push(10);        //这行代码编译无法通过,因为泛型类b只接收string类型的数据
b.push("10");
string y = b.pop();
  可以看出,泛型类与Object实现的类有很大的区别:
  1.、泛型类比object实现的类更安全。实例化了int类型的堆栈,就不能处理string类型的数据,而且处理了别的类型的数据,在编译的时候就通不过,很安全。
  2、Object实现的类需要进行装箱和拆箱。泛型类可以在实例化时,按照传入的数据类型生成本地代码,这时,本地代码数据类型已确定,这样就无需装箱和拆箱。
  3、泛型不需要进行数据类型转换,节省了CPU的计算时间。

5.1.4泛型约束

  我们在编写泛型类的时候,总是会对通用的数据类型T进行有意或无意地有设想。也就是说这个T如果我只想代表值类型,不想代表引用类型,该怎么办?还有,这个T如果想限制为其中几种类型,又该怎样限制调用者传入的数据类型。这就需要对传入的数据类型进行约束。
  数据类型的约束方式一共有6种,现在我们先拿其中一种比较简单的约束方式来向大家演示。这里我们先介绍“派生约束”。就是指定T的祖先,即继承的接口或者类。因为C#是单继承的,所以约束可以有多个接口,但最多只能有一个类,并且类必须在接口之前。例如:
  public class<T> MyClass where T : Stack, IComparable
  {...}
  上面的泛型类的约束表明,T必须是从Stack和IComparable继承的,否则将无法通过编译器的类型检查,编译失败。
  这个就是约束,在定义泛型类时,可以对客户端代码能够在实例化类时用于类型参数的类型种类施加限制。约束是使用where关键字指定的。下表列出了六种类型的约束:
  T:struct,类型参数必须是值类型。可以指定除Nullable以外的任何值类型。

  public class<T> MyClass where T : struct
  {...}
  T:class,类型参数必须是引用类型,包括任何类、接口、委托或数组类型。

  public class<T> MyClass where T : class
  {...}
  T:new()    类型参数必须具有无参数的公共构造函数。当与其他约束一起使用时,new()约束必须最后指定。

  public   class<T> MyClass where T : new()
  {...}
  T:<基类名>,类型参数必须是指定的基类或派生自指定的基类。

  public class<T> MyClass where T : Stack
  {...}
  T:<接口名称> ,类型参数必须是指定的接口或实现指定的接口。可以指定多个接口约束。约束接口也可以是泛型的。

  public class<T> MyClass where T : IComparable
  {...}
  T:U,为T提供的类型参数必须是为U提供的参数或派生自为U提供的参数。这称为裸类型约束。

  public class<T> MyClass where T : U
  {...}

5.1.5泛型方法

  泛型方法和一般方法类似,很多概念都是通用的,只有一处不同,就是一般方法只带有一种参数,我们称为变量,里面用来保存数据,所有有时候我们也称这种参数为数据参数。而泛型方法不仅可以带保存数据的数据参数(也就是变量),还可以带另外一种参数,我们成为类型参数,里面保存的是类型,也是一个变量,但是这个变量代表的是一个类型。泛型方法的使用范围非常广,不仅可以在类上使用泛型,在方法,接口,结构,委托上都可以使用泛型。下面向大家重点介绍泛型方法的相关知识以及几种常用的应用。
  泛型方法的定义格式如下:
  public class MyClass
  {
     public void MyMethod<T>(T t)        //T是类型参数,t是数据参数(变量)
     {...}
  }
  上述代码中,不但定义数据参数t,还定义了类型参数T。这个功能非常方便,因为它可以每次用不同的类型调用该方法,而这对于辅助工具类非常方便。即使拥有泛型方法的类不使用泛型,我们也可以定义泛型方法。调用格式如下:
  MyClass myClass = new MyClass();
  myClass.MyMethod<int>(param);
  在C# 1.0中,我们知道类的静态成员变量在不同的类实例之间是共享的,并且它是通过类名访问的。C#3.0中由于引入了泛型,导致静态成员变量的机制出现了一些变化,静态成员变量在相同泛型版本间共享,不同的泛型版本间不共享。例如:
  Stack<int> a = new Stack<int>();
  Stack<int> b = new Stack<int>();
  Stack<long> c = new Stack<long>();
  类实例a和b使用同一种泛型版本,它们之间共享静态成员变量,但是类实例c和a,b使用的泛型版本完全不同,所以,不能和a,b共享静态成员变量。
  泛型类中的方法重载在.net framework中被大量应用,它要求重载具有不同的签名。在泛型类中,由于通用的类型T在编写类时并不确定,所以在重载时有些事项需要注意。我们举例说明一下:
public class Node<T, V>
  {
  public T add(T a, V b)
  {
     return a;            //返回T类型的数据
  }
  public T add(T a, V b)
  {
     return b;            //返回V类型的数据
  }
  public int add(int a, int b)
  {
     return a+b;
  }
}
  假设T和V都传入int类型的话,三个add方法将具有相同的签名,但这个类仍然能通过编译,是否会引起调用混淆呢?结果是编译器不混淆,但我们很混淆。下面我们来分析一下:
  Node<int, int> node = new Node<int, int>();
  int x = node.add(1, 10);
  这个node实例有三个相同的签名add方法,但却能调用成功。因为它优先匹配了第三个add,也就是非泛型方法。但是如果删除了第三个add方法,上面的调用代码则无法编译通过,提示方法产生的混淆,因为运行时无法在第一个add方法和第二个add方法之间选择。
  Node<string, int> node = new Node<string, int>();
  int x = node.add(2, 10);
  这两行调用代码可以正确编译,因为传入的string和int,使三个add方法具有不同的签名,这样就能找到唯一匹配的add方法。
  由此可知,C#泛型是在实例的方法被调用时检查重载是否产生混淆,而不是在泛型本身编译时检查,同时还有一个重要原则:当一般方法与泛型方法具有相同的签名时,会覆盖泛型方法。
  至于,泛型方法的重写与一般方法的重写是一样的,这里就不复述了。

5.1.6泛型实现

  泛型类和泛型方法同时具备可重用性、类型安全和效率高的特点,这是非泛型类和非泛型方法无法具备的。泛型通常用在集合或者集合上运行的方法中。
  当然,也可以创建自定义泛型类型和方法,以提供自己的通用解决方案。在编写程序的时候,我们经常遇到两个功能相似的模块,只是一个处理int数据,一个处理string数据,或者其他自定义类型。要是以前,我们就比较麻烦了,因为方法参数类型不同,只好写几个处理不同类型的方法。万一,逻辑改变,我们还得为几个方法进行修改代码。泛型的出现,就解决了这个问题。例如:
using System;
public class GenericClass
{
    public void printInt(int[] intParams)                        //定义一般方法处理Int类型
    {
        foreach (int intParam in intParams)
        {
            Console.WriteLine(intParam);                    //输出数据
        }
    }
    public void printString(string[] stringParams)                //定义一般方法处理String类型
    {
        foreach (string stringParam in stringParams)
        {
            Console.WriteLine(stringParam);                //输出数据
        }
    }
    public void print<T>(T[] ts)                            //定义泛型方法处理各种类型
    {
        foreach (T t in ts)
        {
            Console.WriteLine(t);                        //输出数据
        }
    }
}
public class Program
{
    public static void Main(string[] args)
    {
        int[] intParams = { 1, 3, 5, 7, 9 };                    //定义一个整型数组
        string[] stringParams = { "Abelard", "Bronson", "Liberty", "Ella" };    //定义一个字符串数组
        GenericClass myGenericClass = new GenericClass();    //创建一个GenericClass对象
        Console.WriteLine("用一般方法处理Int类型:");
        myGenericClass.printInt(intParams);                //用一般方法处理Int类型
        Console.WriteLine("用一般方法处理String类型:");
        myGenericClass.printString(stringParams);            //用一般方法处理String类型
        Console.WriteLine("用泛型方法处理Int类型:");
        myGenericClass.print<int>(intParams);                //用泛型方法处理Int类型
        Console.WriteLine("用泛型方法处理String类型:");
        myGenericClass.print<string>(stringParams);        //用泛型方法处理String类型
    }
}
  上述代码中,实现在同一个方法上操作多种数据类型。第2行我们定义了一个普通类,这个类没有使用泛型的特性。第18行,我们在这个普通类中,我们定义了一个泛型方法,使用类型参数T来代替具体参数,属于晚期绑定。泛型方法的好处就是不管是什么类型的,都可以很安全的执行操作,这个泛型方法比处理Oject类型的数据性能要好很多,不用拆箱和装箱操作。第4行和第11行,我们也分别定义了一般方法来操作数据,但是,如我们所见,一种类型就得需要一个方法,很不友好。
  最后的输出结果是:
  用一般方法处理Int类型:
  1
  3
  5
  7
  9
  用一般方法处理String类型:
  Abelard
  Bronson
  Liberty
  Ella
  用泛型方法处理Int类型:
  1
  3
  5
  7
  9
  用泛型方法处理String类型:
  Abelard
  Bronson
  Liberty
  Ella
  在泛型类和泛型方法中产生的一个问题是,在预先未知以下情况时,T有可能是引用类型也有可能是值类型,如何将默认值分配给参数化类型T呢?解决方案是使用default关键字,此关键字对于引用类型会返回空,对于数值类型会返回零。对于结构,此关键字将返回初始化为零或空的每个结构成员,具体取决于这些结构是值类型还是引用类型。
  可以肯定,C#通过提供编译时对泛型的支持,利用了两个阶段编译、元数据以及诸如约束等创新性概念把我们的代码质量提高到了一个新水平。同时,C#的将来版本将继续发展泛型,以便添加新的功能,并且将泛型扩展到诸如数据访问或本地化之类的其他.NET Framework领域。

抱歉!评论已关闭.