我们在编写程序时,经常遇到两个模块的功能非常相似,只是一个是处理int数据,另一个是处理string数据,或者其他自定义的数据类型,但我们没有办法,只能分别写多个方法处理每个数据类型,因为方法的参数类型不同。有没有一种办法,在方法中传入通用的数据类型,这样不就可以合并代码了吗?泛型的出现就是专门解决这个问题的。
泛型在实例化对象的时候,使用这个对象需要的类型
读完本篇文章,你会对泛型有更深的了解。
我们先简单的看一下下面的需求及其解释,后面我们会详细讲解里面的具体特性。
我们现在要求实现一个栈,这个栈只能处理int数据类型:
public class Stack{
private int[] a;
public int pop(){}
public void push(int aa){}
public Stack(int i){
this.a=new int[i];
}
}
上面代码运行的很好,但是,当我们需要一个栈来保存string类型时,该怎么办呢?很多人都会想到把上面的代码复制一份,把int改成string不就行了。当然,这样做本身是没有任何问题的,但一个优秀的程序是不会这样做的,因为他想到若以后再需要long、Node类型的栈该怎样做呢?还要再复制吗?优秀的程序员会想到用一个通用的数据类型object来实现这个栈:
public class Stack{
private int[] a;
public int pop(){}
public void push(object aa){}
public Stack(int i){
this.a=new int[i];
}
}
这个栈写的不错,他非常灵活,可以接收任何数据类型,可以说是一劳永逸。但全面地讲,也不是没有缺陷的,主要表现在:
当Stack处理值类型时,会出现装箱、折箱操作,这将在托管堆上分配和回收大量的变量,若数据量大,则性能损失非常严重。
在处理引用类型时,虽然没有装箱和折箱操作,但将用到数据类型的强制转换操作,增加处理器的负担。
所以泛型出现了,真正解决了我们的问题。
下面是用泛型来重写上面的栈,用一个通用的数据类型T来作为一个占位符,等待在实例化时用一个实际的类型来代替。让我们来看看泛型的威力:
public class Stack<T>{
private T[] a;
public T pop(){}
public void push(T aa){}
public Stack(int i){
this.a=new int[i];
}
}
类的写法不变,只是引入了通用数据类型T就可以适用于任何数据类型,并且类型安全的。这个类的调用方法:
//实例化只能保存int类型的类
Stack<int> b=new Stack<int>(100);
b.Push(10);
int x=async.pop();
//实例化只能保存string类型的类
Stack<sting> c=new Stack<string>(100);
c.Push(10);//这一行无法编译通过因为c的类型只接受string
c.push("8888");
string y=c.pop();
这个类和object实现的类有截然不同的区别:
public static void Main(string[] args){
List<int> intList=new List<int>;
intList.Add(3);
List<string> stringList=new List<string>;
string.Add("hello");
}
我们如果想实现两个数之间的比较,具体的代码如下。如果不引入泛型的话我们下面的一段比较代码就必须这么实现:
public class Compare{
public static int CompareInt(int int1,int int2){
if(int1.CompareTo(int2)>0){
return int1;
} else
return int2;
}
public static string CompareInt(string int1,string int2){
if(int1.CompareTo(int2)>0){
return int1;
} else
return int2;
}
}
代码虽然完成了需求,但是如果我们想再次增加浮点类型,则我们需要再次修改代码,甚是麻烦。所以,提供了泛型这个特性,使得类型可以被参数化,使用它的时候,如下所示:
public class Compare<T> where T:ICompareble{
public static T CompareGeneric(T t1,T t2){
if(t1.CompareTo(t2)>0){
return t1;
} else
{
return t2;
}
}
}
我们上面实现了一个自定义的泛型类,其中T是泛型的类型参数,compareGeneric是实现的泛型方法。代码where是参数类型的约束,它用来使得类型参数可以适用于CompareTo方法。这样,我们在主函数调用的时候,如下:
System.Console.WriteLine(Compare<int>.compareGeneric(3,4));
System.Console.WriteLine(Compare<string>.compareGeneric("abc","a"));
泛型不仅仅可以实现代码的重用,减少了装箱和拆箱的过程。泛型是避免损失的有效方法。下面我们通过代码测试了使用泛型和不适用泛型的执行时间。向泛型数组中加入元素的效率远高于非泛型数组,因为非泛型的Add操作中,参数为object类型,当int传入的时候,会发生装箱操作,从而导致性能的损失,时间变长。
where T:base-class-name
其中,T是类型形参的名称,base-class-name是基类的名称。只能指定一个基类。
接口约束是指定某个类型实参必须实现的接口。它的两个主要功能与基类约束一样,允许开发人员在泛型类中使用接口的成员;确保只能使用实现了特定接口的类型实参。也就是说对任何给定的接口约束,类型实参必须是接口本身或者实现了该接口的类。接口约束使用的where子句具有以下形式:
where T:interface-name
其中,T是类型形参的名称,interface-name是接口的名称。可
以使用逗号分隔开指定的多个接口。若某个约束同时包含基类和接口,则需先指定基类列表,再指定接口列表。
where T:new()
使用new()约束时应当注意3点:
1.new()约束可以与其他约束一起使用,但必须位于约束列表的末端
2.new()约束仅允许开发人员使用无参数的构造函数构造一个对象,
即使同时存在其他的构造函数也是如此。即不允许给类型形参的构造
函数传递实参。
3.不可以同时使用new()约束和值类型约束。因为值类型都隐式的提
供了一个无参公共构造函数。就如同定义接口时指定访问类型为
public一样,编译器会报错,因为接口一定是public的。
引用类型约束将一个类型形参限定为引用类型。引用类型一般是用户定义的类型,包含类、接口、委托、字符串和数组类型。引用类型约束使用class关键字,它的通用形式为
where T:class
在这个where子句中,class关键字是指定T必须是引用类型。因此,尝试对T使用值类型,将会导致编译错误。
值类型约束将一个类型形参限定为值类型。值类型派生于System.ValueType类型。基元和结构都是值类型。值类型约束使用struct关键字,它的通用形式为:
where T:struct
在该形式中,struct关键字指定T必须是值类型。因此,尝试对T使用引用类型,将导致编译错误。
同一个类型形参可以使用多个约束。这种情况下,需要使用一个由逗号分隔的约束列表。在该列表中,第一个约束必须是引用类型约束或者值类型约束,或者是基类约束。指定引用类型约束或值类型约束的同时也指定基类约束是非法的。接下来必须是所有的接口约束,最后是new()约束。下面是一个合法的声明:
class Test<T>where T:MyClass,linterface,new(){}
在上述声明中,用于替换T的类型实参必须继承MyClass类,实现Iinterface接口,并且拥有一个无参数的构造函数。
在使用两个或者更多的类型形参时,可以使用多条where子句分别为它们指定约束。
与方法一样,委托也可以是泛型的。因为泛型内在的类型安全性,无法将不兼容的方法赋给委托。声明泛型委托的形式为:
delegate ret-type delegate-name<type-parameter-list>(arg-list);
类型形参的声明紧跟在委托的名称之后。泛型委托的优点在于,它允许开发人员以类型安全的方式定义一种通用形式,该形式可用于匹配任意兼容的方法。
除了定义泛型类和泛型方法外,还可以定义泛型接口。泛型接口的定义与泛型类基本相同。泛型接口的声明形式为:
interface linterface<T>{}
使用泛型时,要注意一下限制:
1、extern修饰符不能用于泛型方法。
2、属性、运算符、索引器和事件不能泛型化。这些项仍可以用作泛型中,并且可以使用类的泛型类型形参。
3、指针类型不能用作类型实参。
4、如果泛型类包含一个static字段,那么每一个构造类型都会有该字段的独立副本。这意味着同一个构造函数类型的所有实例都会共享同一个static字段。然而,不同的构造类型使用不同的字符副本。因此,static字段并不是由所有的构造类型共享。
方法的重载在.Net Framework中被大量应用在泛型类中,由于通用类型T在类编写时并不确定,所以在重载时有些注意事项,这些事项我们通过以下的例子说明:
public class Node<T,V>{
public T add(T a,V b)
{
return a;
}
public T add(V a,T b)
{
return b;
}
public int add(int a,int b){
return a+b;
}
}
上面的类很明显,如果T和V都传入int的话,三个add方法将具有同样的签名,但这个类仍然能通过编译,是否会引起调用混淆将在这个类实例化和调用add方法时判断。请看下面调用代码:
Node<int,int>node=new Node<int, int>();
object x=node.add(2,11);
这个Node的实例化引起了三个add具有同样的签名,但却能调用成功,因为他优先匹配了第三个add。但如果删除了第三个add,上面的调用代码则无法编译通过,提示方法产生的混淆,因为运行时无法在第一个add和第二个add之间选择。
Node<string,int>node=new Node<string, int>();
object x=node.add(2,"11");
这两行调用代码可正确编译,因为传入的string和int,使三个add具有不同的签名,当然能找到唯一匹配的add方法。