Menu

C# 泛型:泛型类、泛型方法与约束

C# 中泛型如何工作:用类型参数编写泛型类和泛型方法,类型推断,用 where 添加约束(class、struct、new()、基类、接口),default(T),按类型区分的静态成员,以及 IEnumerable<out T> 的协变。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

泛型让你只需编写一次类型或方法,就能把它用于多种类型,同时不失去类型安全。你不必使用需要强制转换、又什么都能接收的 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; 都能编译,因为无法通过它们添加任何元素。

Coddy 编程语言插图

和 Coddy 一起学编程

开始