泛型
如果一个类成员允许使用多种数据类型(例如设计一个通用的数据结构),一种实现方法是将类成员指定为object类型,但这种写法代码可读性较差。C# 2.0引入了泛型,泛型可以实现将类成员的类型作为一个参数抽离出来,在某些场景下使用泛型定义类,能够极大提高代码的可读性和可复用性。
泛型定义和使用
C#中的类、方法、接口、委托都支持泛型。
泛型类
泛型类的参数使用一对尖括号<>指定,泛型参数允许存在多个,使用逗号分隔。下面例子我们定义了一个泛型类,两个属性DataA和DataB的数据类型是通过泛型参数T和U指定的,这里类实例化时会传入具体的类型名。
class Container<T, U>
{
public T DataA { get; set; }
public U DataB { get; set; }
}
实例化泛型类时需要传入对应的类型参数,写法如下:
Container<int, string> container = new Container<int, string>();
container.DataA = 1;
container.DataB = "hello";
泛型方法
泛型方法的声明和泛型类差不多,也是增加一组泛型参数。下面例子中,我们在一个普通的类里声明了泛型方法。
class A
{
public void Foo<T, U>(T t, U u)
{
Console.WriteLine(u);
Console.WriteLine(t);
}
}
调用泛型方法时,也可以使用尖括号传入泛型参数。
A a = new A();
a.Foo<int, string>(1, "hello");
不过实际上,这里我们声明的Foo方法的两个类型参数T和U显然可以从其参数t和u类型中推断出来,因此调用该方法时我们可以不必传泛型参数,写作a.Foo(1, "hello");也是可以的,但不能自动推断的时候就必须显式的写出泛型参数了。
约束泛型类型
我们定义泛型时,可以使用where关键字对其进行限制,例如:必须继承某个类或接口,必须有无参构造方法,必须是引用类型或是值类型等,这些约束还可以组合使用。类、方法、接口、委托都支持泛型约束。
| 约束 | 说明 |
|---|---|
| where T : class | T必须是引用类型 |
| where T : struct | T必须是值类型 |
| where T : new() | T必须有无参构造函数,如果和其他约束组合使用,new()必须放在最后 |
| where T : 类名或接口名 | T必须派生自某类或接口 |
下面代码对类的泛型进行了约束,T类型必须继承Animal类。
public class Zoo<T> where T : Animal { }
多个约束可以组合使用,使用逗号分隔。
public class Zoo<T> where T : Animal, new() { }
下面代码对方法的泛型进行了约束,T类型必须有无参构造函数。
public static T GetInstance<T>() where T : new() { }
C#和Java在泛型上的区别
C#的泛型和Java的泛型虽然写法相似,但有本质上的不同。
Java中的泛型是采用类型擦除实现的,这种实现方式经常被诟病为“假泛型”,所谓的假泛型是一种编译期的语法糖,且只适用于引用类型,Java字节码中使用的还是Object类型,只不过编译器帮我们添加了类型转换的代码。而C#泛型是运行时级别支持的,且适用于引用类型和值类型,这导致两者在用法上有很多区别,C#支持很多Java程序员想都不敢想的写法!
下面例子代码我们编写了一个实例化泛型类型的代码,代码中直接使用new关键字实例化了一个泛型类型,这在Java中是无法实现的,Java实现类似功能只能传入一个类型对象,然后使用反射来实例化,写法十分扭曲。
public class ServiceFactory
{
public static T GetInstance<T>() where T : new()
{
T t = new T();
return t;
}
}
代码中我们使用了where T : new()约束保证T有无参构造函数。
调用该泛型方法:
StudentService s = ServiceFactory.GetInstance<StudentService>();
泛型的协变和逆变
前面委托和事件章节介绍过委托的协变和逆变,泛型接口和泛型委托上同样存在变体的概念,而且实际开发中更为常见。
协变
先看一个直觉上应该成立、但实际上不成立的例子。
class Animal { }
class Dog : Animal { }
List<Dog> dogs = new List<Dog>();
// List<Animal> animals = dogs; // 编译报错
虽然Dog继承自Animal,但List<Dog>和List<Animal>之间没有任何继承关系,这个限制是合理的:如果允许这种赋值,我们就可以通过animals变量向列表中插入一个Cat,破坏原列表的类型安全。
但另一方面,IEnumerable<Dog>却可以赋给IEnumerable<Animal>,这是合法的。
IEnumerable<Animal> animals = dogs; // 合法!
原因在于IEnumerable<out T>的类型参数T上标注了out关键字,这被称为协变。out标注表示T只会出现在返回值(输出)的位置,既然元素只能被读出而不能被写入,那么把Dog当作Animal读出来就是安全的,编译器也因此允许IEnumerable<Dog>向IEnumerable<Animal>的隐式转换。
逆变
与协变相反,用in关键字标注的类型参数称为逆变,它表示T只会出现在参数(输入)的位置,允许从更宽泛的类型向更具体的类型转换。典型的例子是比较器接口IComparer<in T>。
// IComparer<in T>是逆变的
IComparer<Animal> animalComparer = GetAnimalComparer();
// 能比较Animal的东西,自然也能比较Dog
IComparer<Dog> dogComparer = animalComparer; // 合法!
变体的规则和记忆法
- 只有泛型接口和泛型委托支持变体标注,泛型类不支持,这就是上面
List<Dog>不能赋给List<Animal>的原因之一。 out表示协变,类型参数只能用于返回值等输出位置;in表示逆变,类型参数只能用于参数等输入位置。可以按字面记忆:协变out是"往外拿",逆变in是"往里塞"。- 标准库中常见的变体接口有
IEnumerable<out T>、IReadOnlyList<out T>(协变)和IComparer<in T>、IEqualityComparer<in T>、Action<in T>(逆变)等。
数组协变的历史遗留
最后提一个容易踩坑的历史遗留:引用类型的数组是支持协变的,string[]可以直接赋给object[],但这个设计并不安全。
string[] strs = new string[3];
object[] objs = strs; // 合法,数组协变
objs[0] = 1; // 编译通过,运行时抛出ArrayTypeMismatchException!
写入不兼容类型的元素时编译器无法发现问题,错误被推迟到了运行期。数组协变是.NET早期为了兼容而做出的妥协,实际开发中应避免依赖它,使用IEnumerable<out T>等接口才是安全的协变方式。