假设我们声明了一个叫做MyIntStack
的类,它实现了把一个int
类型的值压入栈以及把它们弹出。
class MyIntStack
{
int StackPointer = 0;
int[] StackArray;
public void Push(int x)
{
//...
}
public int Pop()
{
//...
}
}
然后我们希望相同的功能应用于float
类型的值,我们对MyIntStack
类进行复制粘贴,并修改了相关数据类型。
class MyFloatStack
{
int StackPointer = 0;
float[] StackArray;
public void Push(float x)
{
//...
}
public float Pop()
{
//...
}
}
如果我们需要将这个功能应用于string
,double
或者是long
类型的值,继续沿用上面的方法有如下缺点:
泛型generic
特性提供了一种更优雅的方式,可以让多个类型共享一组代码。泛型允许我们声明类型参数化的代码。也就是说,我们可以用 “类型占位符” 来写代码,然后在创建类的实例时指明真实的类型。
我们知道,类型是实例的模板。对于泛型类型,它是类型的模板。如图1所示。
C#提供了5种泛型:类,结构,接口,委托和方法。前面4个是类型,而方法是成员。
在上面栈的示例中,MyIntStack
和 MyFloatStack
两个类的主体声明都差不多,只不过在保存类型时有些不同。我们从MyIntStack
通过以下步骤创建一个泛型类:
- 在
MyIntStack
类定义中,使用类型占位符T
而不是float
来替换int
。- 修改类的名称为
MyStack
。- 在类名后放置
。
class MyStack <T>
{
int StackPointer = 0;
T[] StackArray;
public void Push(T x)
{
//...
}
public T Pop()
{
//...
}
}
在以上泛型类的声明中,有尖括号和T
构成的字符串表明T
是类型占位符(也不一定是T
,他可以是任何标识符。)在类声明的主体中,每一个T
都会被编译器替换为实际类型。
泛型类的使用有以下几个步骤:
- 在某些类型上使用占位符来声明一个泛型类。
- 为占位符提供真实类型,创建真实类的定义,该类型为构造类型。
- 创建构造类型的实例。
//创建泛型类(where及其子句为类型参数的约束,暂时不必关心)
class SomeClas <T1,T2>
where T1 : new()
where T2 : new()
{
public T1 SomeVar = new T1();
public T2 OtherVar = new T2();
}
class Program
{
static void Main(string[] args)
{
//创建构造类型及其实例
SomeClass <short, int> MyClass1 = new SomeClass<short, int>();
var MyClass2 = new SomeClass<short, int>();
}
}
注意以下几个名词:
类型参数:类名后尖括号中的"类型占位符",以","间隔,如
。
构造类型:将泛型类中的"类型占位符"替换为真实的类型后,所创建的真实的类。
类型实参:用于替换"类型占位符"的真实的类型。如:。
观察以下泛型类,类中声明了名为LessThan
的方法,接受了两个泛型类型的变量。LessThan
尝试用小于运算符返回结果。但是由于不是所有的类都实现了小于运算符,也就不能用任何类来代替T
,所以编译器会产生一个错误消息。
class Simple<T>
{
static public bool LessThan(T t1,T t2)
{
return t1 < t2; //错误
}
}
以下展示了一个类型参数被约束了的泛型类:
class MyClass<T1, T2, T3>
where T2 : Customer
where T3 : IComparable
{
//...
}
关于类型参数的约束格式,需要注意以下几点:
- 约束格式:
where 类型参数 :约束1,约束2,...
- 约束位置:在类型参数列表的尖括号之后。
- 对多个类型参数进行约束时,
where
子句之间没有任何符号分隔。- 对多个类型参数进行约束时,
where
子句可以以任何次序列出。- 没有被约束的类型参数又被称为未绑定的类型参数。
关于类型参数的约束类型,有以下几种:
类名
:只有这个类型的类或者从它继承的类才能用作类型实参。
class
:任何引用类型,包括类,数组,委托和接口都可以用作类型实参。
struct
:任何值类型都可以用作类型实参。
接口名
:只有这个接口或实现这个接口的类型才能用作类型实参。
new()
:任何带有无参公共构造函数的类型都可以用作类型实参。这叫做构造函数约束。
虽然where
子句可以以任何次序列出,但是where
子句的约束必须有特定的次序。关于类型参数的约束次序,需要注意以下几点:
- 最多只能有一个主约束,如果有必须放在第一位。主约束包括:
类名
,class
,struct
。- 可以有任意多的接口约束。
- 如果存在构造函数约束,必须放在最后。
以下展示了一个泛型方法的声明和调用:
class MyClass
{
//声明泛型方法
public void MyMethod1<S, T>() where S : Pesron
{
//...
}
public void MyMethod2<T>(T t)
{
//...
}
}
class Program
{
static void Main(string[] args)
{
MyClass myClass = new MyClass();
//普通调用泛型方法
myClass.MyMethod1<Pesron, string>();
//在调用时通过传入参数的类型推断类型参数,从而省略类型参数列表
int myInt = 5;
myClass.MyMethod2(myInt);
}
}
关于泛型方法的声明和调用,要注意以下几点:
- 类型参数列表放置在方法名后,方法参数列表之前。
- 约束放置在方法参数列表之后。
- 可省略类型参数列表,因为编译器可从传入参数推断类型参数。
泛型结构的使用与泛型类相似,以下展示了一个泛型结构示例:
struct MyStruct<T>
{
//...
}
class Program
{
static void Main(string[] args)
{
MyStruct<int> myIntStruct = new MyStruct<int>();
MyStruct<string> myStringStruct = new MyStruct<string>();
}
}
以下展示了泛型委托的示例:
//定义泛型委托
delegate R MyDelegate<T, R>(T value);
class Program
{
static void Main(string[] args)
{
//构造委托类型,创建委托变量,同时初始化委托变量。
MyDelegate<bool, bool> myDelegate1 = new MyDelegate<bool, bool>(PrintBool);
MyDelegate<int, int> myDelegate2 = new MyDelegate<int, int>(PrintInt);
myDelegate1(true);
myDelegate2(5);
}
static bool PrintBool(bool myBool)
{
Console.WriteLine(myBool);
return myBool;
}
static int PrintInt(int myInt)
{
Console.WriteLine(myInt);
return myInt;
}
}
关于泛型委托,需要注意以下几点:
- 在泛型委托的定义中,类型参数列表放置在委托名之后,形参列表之前。
- 类型参数列表包括返回值类型和形参列表类型。
以下展示了泛型接口的使用示例:
//声明泛型接口
interface IMyIfc<T>
{
T ReturnIt(T inValue);
}
//在泛型类中实现泛型接口
class Simple1<S> : IMyIfc<S>
{
public S ReturnIt(S inValue)
{
return inValue;
}
}
//在非泛型类中实现泛型接口,需要构建构造接口类型
class Simple2 : IMyIfc<int>,IMyIfc<String>
{
public int ReturnIt(int inValue)
{
return inValue;
}
public string ReturnIt(string inValue)
{
return inValue;
}
}
class Program
{
static void Main(string[] args)
{
//调用泛型类方法
Simple1<int> myIntSimple1 = new Simple1<int>();
myIntSimple1.ReturnIt(5);
Simple1<string> myStringSimple1 = new Simple1<string>();
myStringSimple1.ReturnIt("Hello");
//调用非泛型类方法
Simple2 mySimple2 = new Simple2();
mySimple2.ReturnIt(5);
mySimple2.ReturnIt("Hello");
}
关于泛型接口的使用,有以下几点需要注意:
- 类型参数列表放置在接口名之后。
- 实现不同类型参数的泛型接口是不同的接口。
- 既可在泛型类中实现泛型接口,也可在非泛型类中实现泛型接口。
- 在泛型类中实现泛型接口时,可选择性构建构造接口类型,但必须保证不能产生重复的接口。
- 在非泛型类中实现泛型接口时,需要构建构造接口类型,也就是说"类型占位符"需要替换为具体的类型。
- 泛型接口的名字不会和非泛型接口的名字冲突,因此,可声明两个同名接口,一个为非泛型,另外一个为泛型。
注意
:在泛型类中实现泛型接口时,源自同一泛型接口的不同类型参数的接口,必须保证不能产生两个重复的接口。例如:
//错误,IMyIfc已经包含IMyIfc
class Simple1<S> : IMyIfc<S>,IMyIfc<int>
{
public S ReturnIt(S inValue)
{
return inValue;
}
public int ReturnIt(int inValue)
{
return inValue;
}
}
在了解可变性之前,先了解以下名词:
赋值兼容性:将派生类对象的引用赋值给基类变量。
关于逆变和协变的官方解释:
在C#中,协变和逆变能够实现数组类型,委托类型,泛型类型的参数的隐式引用转换。协变保留了赋值兼容性,逆变反转了赋值兼容性。
需要注意的是:
- 赋值兼容性存在于C#任何类型(预定义类型和用户自定义类型)中。
- 可变性存在于数组类型,委托类型,泛型类型中。
个人理解(都说了是个人理解,内含有个人大胆猜测和比喻,有错误欢迎指正,请勿乱喷,求生欲Max!):
注:这里引用了高数函数概念。官方并没有"
A
兼容B
"这样的说法。
设有类型A
和B
,B
继承A
。如果B
的实例可以赋值给A
类型的变量,那么说明A
兼容B
。
f(A)
或f(B)
中的f
指的是将A
类型变成A
类型的数组/委托/泛型f(A)
,将B
类型变成B
类型的数组/委托/泛型f(B)
。
如果f(B)
的实例可以赋值给f(A)
类型的变量,那么称f(A)
兼容f(B)
,这与运算前相同,因此称这个过程保留了赋值兼容性,为协变。
如果f(A)
的实例可以赋值给f(B)
类型的变量,那么称f(B)
兼容f(A)
,这与运算前相反,因此称这个过程反转了赋值兼容性,为逆变。
以下示例体现了赋值兼容性:
//体现赋值兼容性
string myString = "Hello";
object myObject = myString;
以下示例体现了协变:
//协变:保留了赋值兼容性
string[] myStringArray = new string[10];
object[] myObjectArray = myStringArray;
以下示例体现了逆变:
//逆变:反转了赋值兼容性
Action<object> myObjectAction = new Action<object>(MyMethod);
Action<string> myStringAction = myObjectAction;
void MyMethod(object obj)
{
//...
}
泛型类型的可变性与其他类型的可变性又有所区别,主要有以下两点:
- 泛型中的协变和逆变需要在类型参数前加
out
和in
关键字,用于显式的标识对哪一个类型参数使用协变或者逆变。- 泛型中的协变用于输出参数,因此
out
添加在与输出参数对应的类型参数前,逆变用于传入参数,因此in
添加在与输入参数对应的类型参数前。
以下示例展示了泛型中的协变:
class Animal
{
public int Legs = 4;
}
class Cat : Animal
{
//...
}
//泛型用于输出参数,并用out关键字标记为协变
delegate T Factory<out T>();
class Program
{
static void Main(string[] args)
{
Factory<Cat> catMaker = MakeCat;
Factory<Animal> animalMaker = catMaker;
Console.WriteLine(animalMaker().Legs.ToString());
}
static Cat MakeCat()
{
return new Cat();
}
}
如果将派生类委托赋值给基类委托变量,却没有在泛型委托的类型参数列表中使用 out
和in
进行标记,上面示例的下列代码行会报错,提示无法进行隐式类型转换。
Factory<Animal> animalMaker = catMaker;
报错的原因:
并不是因为赋值兼容性不成立,而是因为不适用。因为派生类委托并不继承基类委托。
以下示例展示了泛型中的逆变:
class Animal
{
public int Legs = 4;
}
class Cat : Animal { }
//泛型用于输入参数,并用in关键字标记为逆变
delegate void Factory<in T>(T t);
class Program
{
static void Main(string[] args)
{
Factory<Animal> animalMaker = MakeAnimal;
Factory<Cat> catMaker = animalMaker;
}
static void MakeAnimal(Animal a)
{
Console.WriteLine(a.Legs);
}
}
到这里会出现一个疑问,为什么在非泛型类型中协变和逆变时不用 out
和in
标记,因为:
C#能自动进行协变和逆变的类型转换,但是为了防止编程人员在不知情的情况下进行了一些导致错误编程,因此需要显式的进行标记,像是约定了一种规则。
总结,关于可变性,需要注意以下几点:
可变性只适用于引用类型。
out
和in
关键字只适用于委托接口,不适用于类,结构和方法。
不包括out
和in
关键字的委托和接口类型参数叫做不变。这些参数不能用于协变或逆变。
书籍:《C#图解教程 第4版》
博客:10分钟了解C#中的协变和逆变,协变和逆变(官方文档)