C#泛型简介
很多时候,我们想把类的行为提取出来或者重构,使其不仅能应用于当前编码的类型上,还能应用于其它类型上。
在C#里面,实现跨类型的代码复用,有两种方式:继承 和 泛型。
- 继承 -> 继承的复用性来自基类
- 泛型 -> 泛型的复用性是通过带有“(类型)占位符”的“模板”实现的
泛型类型(Generic Types)
泛型允许我们声明类型参数化(Type Parameterized)的代码 - 泛型的消费者需要提供类型参数(argument)来把占位符类型填充上。
我们可以把泛型理解为原有的需要的具体类型的一个抽象,泛型类型不是类型,而是类型的“模板”。具体的数据类型不再硬编码,而是用一个占位符代替,按照默认约定一般用T占位,也可给一个有意义的名称,例如:TKey,表示一个泛型键。
首先我们来看一个存放类型T实例的泛型堆栈类型Stack<T>的例子:
public class Stack<T> { int position; T[] data = new T[100]; public void Push(T obj) => data[position++] = obj; public T Pop() => data[--position]; }
下面是一个调用泛型类的例子:
var stack = new Stack<int>(); stack.Push(5); stack.Push(10); Console.WriteLine(stack.Pop()); // 10 Console.WriteLine(stack.Pop()); // 5
Stack<int>用类型参数int填充T,这会在运行时隐式创建一个类型:Stack<int>。若试图将一个字符串加入Stack<int>中则会产生一个编译时错误。
Stack<int>相当于如下的定义:
public class ### { int position; int[] data = new int[100]; public void Push(int obj) => data[position++] = obj; public int Pop() => data[--position]; }
我们把Stack<T> 叫做开放类型(Open Type),Stack<int> 叫做封闭类型(Closed Type)。在运行时,所有的泛型类型实例都是封闭的(占位符类型已经被填充了)。
例如,var stack = new Stack<T>(); 这种就是不合法的,只有在泛型类或者泛型方法的内部是可以这么用的,在使用泛型类时就必须明确地填充占位符类型。
泛型为什么会出现?
为了实现代码复用,泛型是为了代码能够跨类型复用而设计的,还是上面的例子,假如我们需要一个整数栈,但是没有泛型的支持,我们有两种方式,一种是针对特定的类型都写一个类或方法,针对int,我们写一套代码,针对string我们也写一套代码。显然,这样会导致大量的重复代码。另一种就是使用object对象,但是对象在使用时会有装箱和向下类型转换的问题,而这在编译时无法进行检查。
看个例子:
public class ObjectStack { int position; object[] data = new object[100]; public void Push(object obj) => data[position++] = obj; public object Pop() => data[--position]; }
var stack = new ObjectStack(); //假如我们需要一个int stack.Push("str"); // 这里给了一个错误的类型,但是不会报错,因为它们都是object子类 int myInt = (int)stack.Pop(); //我实际要的是一个int,这里就会Downcast 发生一个运行时错误
我们需要的栈既需要支持各种不同类型的元素,又要有一种方法容易地将栈的元素类型限定为特定类型,以提高类型安全性,减少类型转换和装箱。泛型就是通过参数化元素类型提供了这些功能。
泛型方法(Generic Methods)
使用泛型方法很多基本的一些算法和逻辑就可以变得更加通用,共用性就更强了。
泛型方法在方法的签名中声明类型参数
下面是一个交换两个任意类型T的变量值的泛型方法:
static void Swap<T>(ref T a, ref T b) { T temp = a; a = b; b = temp; }
那么调用泛型方法可以这么调用:
int x = 5, y = 10; Swap(ref x, ref y);
通常情况下调用泛型方法不需要提供类型参数,编译器可以隐式的推断出这个T的具体类型,如果编译器不能推断出类型或者发生歧义,也可以这么写:
Swap<int>(ref x, ref y);
在泛型类型里面的方法,除非也引入了类型参数(type parameters),否则这个方法是不会归为泛型方法的。
比如上面的Stack<T>泛型类里面的Pop方法,它用到了T,但是这个T是在类型上声明的,而不是这个Pop方法引入的(Pop<T>),所以说这个Pop方法不是泛型方法,它只是这个泛型类里的一个普通方法。
只有类型和方法才可以引入类型参数,而属性、事件、索引器、字段、构造器、运算符等等都不可以声明类型参数。但是他们可以使用他们所在的泛型类型的类型参数,而不引人新的类型参数。
声明类型参数
可以在申明类、结构体、接口、委托和方法时引入类型参数。其他的结构,如属性,虽不能引入类型参数,但是可以使用类型参数。例如,属性Value使用T:
public struct Nullable<T> { public T Value { get; } }
泛型类或者方法可以有多个参数,例如:
class Dictionary<TKey,TValue>{…}
可以用以下方式实例化:
var myDic = new Dictionary<int,string>();
只要类型参数的数量不同,泛型类型名和泛型方法的名称就可以进行重载。
class A{}
class A<T>{}
class A<T1,T2>{}
这三个类型名称不会冲突。
typeof 和未绑定的泛型类型
泛型类型在运行时都是封闭的,不存在开放的泛型类型:开放泛型类型将编译为程序的一部分而封闭,但运行时可能存在未绑定(unbound)的泛型类型,只作为Type对象存在。
但是如果作为Type对象,C#中唯一指定未绑定泛型类型的方式是使用typeof运算符来实现。
class A<T> {} class A<T1,T2> {} ... Type a1 = typeof (A<>); // 未绑定类型 注意没有类型参数 Type a2 = typeof (A<,>); // 使用逗号表示多个类型参数
开放泛型类型通常用与反射API结合使用,也可以使用typeof运算符指定封闭类型:
Type a3 = typeof (A<int,int>);
或者开放类型(在运行时封闭):
class B<T>
{
void X()
{
Type t = typeof (T);
}
}
泛型的默认值
default关键字可用于获取泛型类型参数的默认值。
- 引用类型的默认值为null
- 值类型的默认值是将值类型的所有字段按位设置为0的值。
static void Zap<T>(T[] array) { for (int i = 0; i < array.Length; i++) { array[i] = default(T); } }
泛型的约束
默认情况下,类型参数可以由任何类型来替换。
如果只允许使用特定的类型参数,就可以在类型参数上应用约束,可以将类型参数定义为指定的类型参数。
where T : base-class // 基类约束,必须是某个父类的子类 where T : interface // 接口约束,必须继承某个接口 where T : class // 引用类型约束,必须是一个引用类型 where T : struct // 值类型约束 (不包括可空类型),必须是一个值类型 where T : new() // 无参数构造函数约束,必须有一个无参构造函数 where U : T // 裸类型约束,这个U必须继承自这个T
下面看一个例子:
class SomeClass {} interface Interface1 {} class GenericClass<T,U> where T : SomeClass, Interface1 where U : new() {...}
这个泛型类中的T,必须是继承自SomeClass这个类并且实现了Interface1接口,同时U必须是有一个无参的构造函数。
泛型约束不仅仅可以作用于类型,也可作用于方法的定义,下面我们看几个例子:
实现接口约束的泛型方法:
假设我们要编写一个通用的Max方法,它比较两个数返回比较大的那个值。我们可以利用通用接口在名为IComparable<T>的框架中定义:
public interface IComparable<T> // 接口的简化版本 { int CompareTo (T other); }
如果此值大于其他值,则CompareTo将返回一个正数。使用这个接口作为约束,我们可以编写如下Max方法(以避免分散注意力,省略空检查):
static T Max <T> (T a, T b) where T : IComparable<T> { return a.CompareTo (b) > 0 ? a : b; }
Max泛型方法可以接受任何实现了IComparable<T>接口的类型参数(包括大多数内置类型,如int和
字符串),看下调用的例子:
int z = Max (5, 10); // 10 string last = Max ("ant", "zoo"); // zoo
值类型约束的泛型方法:想要定义一个可空类型的泛型结构体,但是添加了值类型的约束,值类型约束是不可为空的,下面是一个错误的例子:
struct Nullable<T> where T : struct {...}
无参构造函数的泛型约束:
static void Initialize<T> (T[] array) where T : new() { for (int i = 0; i < array.Length; i++) array[i] = new T(); }
在Initialize泛型方法中添加了无参构造函数的约束,所以可以在方法体内部new一个T类型的对象。
泛型类型的子类
泛型类型和非泛型类型一样都可以派生子类,在子类里,仍可以让父类中的类型参数保持开放,例子:
class Stack<T> {...} class SpecialStack<T> : Stack<T> {...}
在子类里,也可以使用具体的类型来封闭父类的类型参数
class IntStack : Stack<int> {...}
子类型也可以引入新的类型参数
class List<T> {...} class KeyedList<T,TKey> : List<T> {...}
从技术上讲,子类型上的所有类型参数都是新的:你可以认为子类是先把父类的类型参数关闭然后重新打开父类的类型参数。
这意味着子类可以重新打开类型参数的新名称(可能更有意义):
class List<T> {...} class KeyedList<TElement,TKey> : List<TElement> {...}
自引用的泛型声明
一个类型可以使用自身类型作为具体类型来封闭类型参数,什么意思呢,看个例子:
public interface IEquatable<T>
{
bool Equals (T obj);
} public class Balloon : IEquatable<Balloon> { public string Color { get; set; } public int CC { get; set; } public bool Equals (Balloon b) { if (b == null) return false; return b.Color == Color && b.CC == CC; } }
这个只要记住允许这么用就行了,包括下面的两个例子
class Foo<T> where T : IComparable<T> { ... } class Bar<T> where T : Bar<T> { ... }
静态数据
静态数据对于每一个封闭的类型来说都是唯一的,看个例子:
class Bob<T> { public static int Count; } class Test { static void Main() { Console.WriteLine (++Bob<int>.Count); // 1 Console.WriteLine (++Bob<int>.Count); // 2 Console.WriteLine (++Bob<string>.Count); // 1 Console.WriteLine (++Bob<object>.Count); // 1 } }
这个例子说明,只要是Bob<int>都是同一个类型,Bob<string>又是一个新的类型。
类型参数的转换
C#的类型转换运算符可以进行多种的类型转换,包括:
- 数值转换
- 引用转换
- 装箱/拆箱转换
- 自定义转换(运算符重载)
根据已知操作数的类型,在编译时就已经决定了类型转换的方式。但在编译时操作数的类型还并未确定,使得上述规则在泛型类型参数上会出现特殊的情形。如果导致了二义性,那么就会产生一个编译时错误。
StringBuilder Foo<T> (T arg) { if (arg is StringBuilder) return (StringBuilder) arg; // 将编译不通过 ... }
由于编译器不知道T的具体类型,以为你要做一个自定义的类型转换,那么这个问题如何解决呢,就是使用as操作符:
StringBuilder Foo<T> (T arg) { StringBuilder sb = arg as StringBuilder; if (sb != null) return sb; ... }
更常见的一种做法是,先把它转换成object类型,这样就不是自定义转换了,return (StringBuilder) (object) arg;
int Foo<T> (T x) => (int) x; // 编译时错误
有可能是数值类型的转换也有可能是个拆箱操作,也有可能是自定义转换,定不下来,所以可能会发生歧义就报错,具体的解决办法也是先转换成object类型,这样肯定就是一个拆箱操作了,就没有歧义了
int Foo<T> (T x) => (int) (object) x;
协变(Covariance)
IEnumerable<string> strings = new List<string> { "a", "b", "c" }; IEnumerable<object> objects = strings;
上面这段代码在C#4.0之前会报错,在C#4.0之后可以就没有问题了。
IList<string> strings = new List<string> { "a", "b", "c" }; IList<object> objects = strings; //会报错
这段代码和上面的代码有什么区别呢,把IList<string>赋给IList<object>就会报错,为什么呢,因为这么做不安全。
具体可以看下IEnumerable和IList的源码定义就知道了。
Action<object> objectAction = obj => Console.WriteLine(obj); Action<string> stringAction = objectAction; stringAction("Print Message");
因为string是继承于object的,string可以向上转换为object。
前面这三个例子,就引出三个概念:
- Covariance协变,当值作为返回值/out输出
- Contravariance逆变,当值作为输入input
- Invariance不变,当值既是输入又是输出
对应的三个例子:
public interface IEnumerable<out T> //协变 public delegate void Action<in T> //逆变 public interface IList<T> //不变
前面这三种呢,我们都称之为Variance,即可变性。variance只能出现在接口和委托里。
Variance转换
涉及到Variance的转换就是variance转换
Variance转换时引用转换的一个例子。引用转换你无法改变其底层的值,只能改变编译时类型。
identity conversion(本体转换),对CLR来说,从一个类型转化到相同的类型。
我们看几个例子:
如果从A到B的转换时本体转换或者隐式引用转换,那么从IEnumerable<A>到IEnumerable<B>的转换就是合理的。
IEnumerable<string> to IEnumerable<object> // string到object隐式类型转换 IEnumerable<string> to IEnumerable<IConvertible> // string实现了这个接口,也是隐式类型转换 IEnumerable<IDisposable> to IEnumerable<object> // 也是隐式类型转换
上面这些都是合理的转换,下面看一些不合理的转换
IEnumerable<object> to IEnumerable<string> //object 到 string 得是显示类型转换才行 IEnumerable<string> to IEnumerable<Stream> //string和Stream没有任何关系,所以也是不合理的 IEnumerable<int> to IEnumerable<IConvertible> //这确实是一个隐式转换,但它是一个拆箱操作,而不是引用转换 IEnumerable<int> to IEnumerable<long> //这是一个隐式转换,但不是引用转换