泛型让你只需编写一次类型或方法,就能把它用于多种类型,同时不失去类型安全。你不必使用需要强制转换、又什么都能接收的 object 型 List,而是得到 List<int> 和 List<Order>,每一个都由编译器检查。尖括号中的占位符按惯例写作 T,称为类型参数;调用方提供的类型称为类型实参。
为什么需要泛型:它解决的问题
在有泛型之前(C# 1),集合存储的是 object。这样能编译通过,但错误只有在运行时才会暴露出来,而且每个 int 放进去时都要装箱:
var prices = new System.Collections.ArrayList();
prices.Add(9.99m);
prices.Add("12.50"); // compiles: anything is an object
decimal total = 0;
foreach (object p in prices)
total += (decimal)p; // InvalidCastException on the string
换成 List<decimal> 后,第二次 Add 会直接编译报错,循环不需要强制转换,decimal 值也以 decimal 的形式存储。
泛型类
在类名后面声明类型参数,然后在类的内部像使用其他类型一样使用它们:
输出:
Page 2/3: Chen, Dara, more: True
Page 1/1: 90, 72, 85, more: False
Page<string> 和 Page<int> 是由同一个定义生成的两个不同类型。一个类可以有多个类型参数(Dictionary<TKey, TValue>);只有一个时命名为 T,有多个时命名为 TSomething。
泛型方法与类型推断
无论所在的类是不是泛型类,方法都可以有自己的类型参数。调用方通常省略类型实参,因为编译器会根据实参推断出来:
输出:
2 1
back front
ho ho ho
4.50 EUR, 12.00 EUR, 0.99 EUR
推断只根据实参进行,从不根据返回类型。Repeat(0, 3) 不需要任何标注,但像 T Create<T>() 这样的方法没有可以推断的依据,所以调用方必须写成 Create<Customer>()。LINQ 的大部分内容都是依赖这种推断的泛型方法:prices.Where(p => p > 1) 就是 Where<decimal>,只是你不用写出来。
约束:T 可以是什么
在泛型方法内部,编译器只允许你做对所有可能的 T 都成立的操作。没有约束时,这比 object 支持的操作多不了多少。要调用 CompareTo、创建实例或读取某个属性,就用 where 告诉编译器 T 必须满足什么条件:
输出:
17
plum
19.99
42 Zoe
你会用到的约束:
| 约束 | 含义 | 允许你 |
|---|---|---|
where T : class | 引用类型 | 赋值 null,使用 as |
where T : struct | 不可为 null 的值类型 | 把 T? 当作 Nullable<T> 使用 |
where T : new() | 有公共无参构造函数 | 调用 new T()(必须写在最后) |
where T : Entity | 派生自 Entity | 使用 Entity 的成员 |
where T : IComparable<T> | 实现了该接口 | 调用 CompareTo |
where T : U | 派生自另一个类型参数 | 关联两个类型参数 |
where T : unmanaged(C# 7.3) | 内部不含引用的结构体 | 指针、stackalloc |
where T : Enum(C# 7.3) | 枚举类型 | 传给 Enum 的方法 |
where T : notnull(C# 8) | 不可为 null 的类型 | 可空引用类型注解 |
同一个参数上的多个约束用逗号分隔;多个参数则各自有自己的 where 子句:class Cache<TKey, TValue> where TKey : notnull where TValue : class。
缺少约束会在你使用成员的地方表现为编译错误,例如 CS1061('T' does not contain a definition for 'CompareTo'),或者在没有 new() 约束时调用 new T() 会报 CS0304。
default(T)
泛型代码有时需要一个未知类型的“空值”。default(T) 对数字给出 0,对 bool 给出 false,对引用类型和可空类型给出 null,其他情况则给出所有字段清零的结构体:
输出:
31
0
Porto
True
0001-01-01
第二行暴露了这种写法的弱点:对于值类型,“没找到”和“找到了 0”看起来一模一样。这就是框架方法改用 TryGet 形式的原因(bool TryGetValue(TKey key, out TValue value))。从 C# 7.1 开始,当类型可以从上下文看出时,你可以写成更简短的 return default;。
静态成员按构造类型区分
每个构造出来的类型(Counter<int>、Counter<string>)都有自己的一份类静态字段:
输出:
2
1
False
这有时很有用(例如按类型缓存的 TypeInfo<T>.Name,每个 T 只计算一次),有时则会让本以为只有一个共享计数器的你大吃一惊。把共享状态放在非泛型类中。
泛型集合
泛型集合能满足大多数需求:List<T>、Dictionary<TKey, TValue>、HashSet<T>、Queue<T>、Stack<T>,以及它们共有的接口 IEnumerable<T>、ICollection<T>、IList<T>、IReadOnlyList<T>。方法参数应接收它所需的最窄接口(如果只是循环遍历,就用 IEnumerable<T>),返回值则使用具体类型或只读类型。
协变与逆变
Dog 是一种 Animal,但 List<Dog> 不是 List<Animal>:
List<Dog> dogs = new List<Dog>();
List<Animal> animals = dogs;
// error CS0029: Cannot implicitly convert type 'List<Dog>' to 'List<Animal>'
如果允许这样做,animals.Add(new Cat()) 就会把一只猫放进狗的列表。只产出值的接口是安全的,它们用 out 声明为协变:
IEnumerable<Animal> readOnly = dogs; // fine: IEnumerable<out T>
IReadOnlyList<Animal> alsoFine = dogs; // fine: IReadOnlyList<out T>
与之对称的逆变用 in 标记,适用于只消费值的接口和委托:在需要 Action<Dog> 的地方可以使用 Action<Animal>,因为能处理任何动物的东西当然也能处理狗。IComparer<in T> 是逆变的,而 Func<in T, out TResult> 同时用到了这两个标记。变体只适用于接口和委托,而且只适用于引用类型的类型实参:IEnumerable<int> 不能转换为 IEnumerable<object>。
常见错误
- 在应该使用类型参数的地方使用
object。 这会放弃编译时检查,并且让值类型装箱。 - 用
==比较两个T值。 没有class约束时它无法编译(CS0019),有约束时它比较的是引用。请使用EqualityComparer<T>.Default.Equals(a, b),它适用于任何T,并且会遵循该类型自己的Equals。 - 把
default(T)当作“没找到”返回。 对于值类型,这会产生歧义。请使用bool TryX(..., out T value)。 - 约束太多。 每个约束都会缩小能使用该方法的范围。只约束方法体真正需要的东西。
- 指望
List<Derived>能转换为List<Base>。 请改为接收IEnumerable<Base>或IReadOnlyList<Base>。
常见问题
C# 中的泛型是什么?
泛型让你只需编写一次类、接口或方法,用一个占位类型 T 代替具体类型,由调用方填入:List<int>、List<Customer>。编译器会根据真实类型检查每一次使用,所以你无需强制转换就能获得类型安全,值类型的存储也不会装箱。System.Collections.Generic 中的集合就是最常见的例子。
C# 中如何编写泛型方法?
把类型参数放在方法名后面:static T Largest<T>(List<T> items) where T : IComparable<T> { ... }。调用方通常省略类型实参,因为编译器会根据实参推断出来:当 prices 是 List<decimal> 时,Largest(prices) 就能正常工作。如果没有可以推断的依据,就显式写出 Largest<decimal>(...)。
C# 中 where T : class 是什么意思?
它是一个约束:T 必须是引用类型,所以泛型代码可以把 null 赋给 T,也可以使用 as T。其他约束还有 where T : struct(不可为 null 的值类型)、where T : new()(有公共无参构造函数,所以代码可以调用 new T())、where T : SomeBaseClass 和 where T : ISomeInterface,后者让代码可以在 T 上调用该接口的成员。
C# 中的 default(T) 是什么?
无论 T 最终是什么类型,它都给出该类型的默认值:数字是 0,bool 是 false,引用类型和可空类型是 null,其他值类型则是所有字段清零的结构体。泛型代码在不知道具体类型却需要“没有值”时会用到它,例如作为查找失败时的返回值。C# 7.1 新增了更简短的 default 字面量。
为什么在 C# 中不能把 List<Dog> 赋给 List<Animal>?
因为 List<Animal> 可以接收任何动物:如果允许这样赋值,代码就可以把一只 Cat 加进实际上是 List<Dog> 的列表中。编译器会拒绝它(CS0029)。只读接口是协变的,所以 IEnumerable<Animal> animals = dogs; 和 IReadOnlyList<Animal> animals = dogs; 都能编译,因为无法通过它们添加任何元素。