第 37 节 · 第四部分 进阶特性

泛型(Generic)

泛型就是先用 T 占住类型的位置,用的时候在尖括号里换成具体类型;类型不对编译时就报错,where 约束限制 T 能换成什么。

时长 8:48授课计划第 14 周

学完这一节,你能

讲义

跟着视频的顺序写。每行代码都有注释,代码和运行结果都是实际运行得到的。

1泛型是什么

泛型(Generic)就是写类或方法时先不把数据类型定死,用一个类型参数(习惯叫 T)占住位置,等到用的时候再在尖括号里写上具体类型。这样同一份代码就能和各种类型一起工作。

编译器只生成一份泛型定义,程序运行时用到 int、char,才做出各自的版本(后面用反射能看到)。视频里把泛型比成一台托盘机:卡座上插一张 T 空白卡,换成 int 卡,格子就只收 int。

2泛型类

类名后面加 <T>,里面的数组元素类型就写 T:

C# · 第 6-12 行
6public class MyGenericArray<T>// 类名后加 <T>:T 是类型参数,先占住位置
7{// MyGenericArray 类开始
8 private T[] array;// 数组的元素类型写 T,用的时候才定下来
9 public MyGenericArray(int size)// 构造函数,参数是数组大小
10 {// 构造函数开始
11 array = new T[size + 1];// 开 size + 1 格:传 5 实际开 6 格,最后一格没用到
12 }// 构造函数结束

用的时候在尖括号里换上具体类型,同一个类先用 int,再换成 char(charArray.setItem(c, (char)(c+97)) 放进 a 到 e):

C# · 第 28-32 行
28MyGenericArray<int> intArray = new MyGenericArray<int>(5);// 写 <int>:这一刻 T 就换成了 int
29// 设置值
30for (int c = 0; c < 5; c++)// c 从 0 到 4,循环 5 次
31{// for 开始
32 intArray.setItem(c, c*5);// 第 c 格放 c × 5:0、5、10、15、20
C# · 第 41 行
41MyGenericArray<char> charArray = new MyGenericArray<char>(5);// 同一个泛型类,换成 <char>
查看完整代码(56 行)
C#
1using System;// 引入 System 命名空间
2using System.Collections.Generic;// 引入泛型集合所在的命名空间
3
4namespace GenericApplication// 定义命名空间 GenericApplication
5{// 命名空间开始
6 public class MyGenericArray<T>// 泛型类 MyGenericArray,T 是类型参数
7 {// MyGenericArray 类开始
8 private T[] array;// 元素类型为 T 的数组
9 public MyGenericArray(int size)// 构造函数,参数是大小
10 {// 构造函数开始
11 array = new T[size + 1];// 开 size + 1 格
12 }// 构造函数结束
13 public T getItem(int index)// getItem:按下标取出一个 T
14 {// getItem 开始
15 return array[index];// 返回第 index 格的值
16 }// getItem 结束
17 public void setItem(int index, T value)// setItem:把一个 T 放进第 index 格
18 {// setItem 开始
19 array[index] = value;// 存进数组
20 }// setItem 结束
21 }// MyGenericArray 类结束
22
23 class Tester// 定义 Tester 类
24 {// Tester 类开始
25 static void Main(string[] args)// 程序入口 Main 方法
26 {// Main 开始
27 // 声明一个整型数组
28 MyGenericArray<int> intArray = new MyGenericArray<int>(5);// T 换成 int,传 5
29 // 设置值
30 for (int c = 0; c < 5; c++)// c 从 0 到 4
31 {// 第 1 个 for 开始
32 intArray.setItem(c, c*5);// 第 c 格放 c × 5
33 }// 第 1 个 for 结束
34 // 获取值
35 for (int c = 0; c < 5; c++)// 再循环 5 次
36 {// 第 2 个 for 开始
37 Console.Write(intArray.getItem(c) + " ");// 取出第 c 格,后面跟一个空格,不换行
38 }// 第 2 个 for 结束
39 Console.WriteLine();// 换行,第一行输出完
40 // 声明一个字符数组
41 MyGenericArray<char> charArray = new MyGenericArray<char>(5);// T 换成 char
42 // 设置值
43 for (int c = 0; c < 5; c++)// 循环 5 次
44 {// 第 3 个 for 开始
45 charArray.setItem(c, (char)(c+97));// 放进 c + 97 对应的字符
46 }// 第 3 个 for 结束
47 // 获取值
48 for (int c = 0; c < 5; c++)// 再循环 5 次
49 {// 第 4 个 for 开始
50 Console.Write(charArray.getItem(c) + " ");// 取出字符,后跟空格
51 }// 第 4 个 for 结束
52 Console.WriteLine();// 换行,第二行输出完
53 Console.ReadKey();// 等待按键(现在的 VS 不写也会停住)
54 }// Main 结束
55 }// Tester 类结束
56}// 命名空间结束
运行结果
0 5 10 15 20 
a b c d e

第二行是 a 到 e:(char) 把数字转成字符,97 正好是小写 a 的编码,加 1 就是 b。

3类型安全

往 MyGenericArray<int> 里放字符串 "abc",编译时就被拦下来:

C# · 第 40 行
40intArray.setItem(0, "abc");// intArray 的 T 是 int,却传进 string
编译报错
Program.cs(40,33): error CS1503: 参数 2: 无法从“string”转换为“int”

4泛型的特点

  1. 重用代码:一份 MyGenericArray,int、char 都能用。
  2. 类型安全:类型不对,编译时就报错,就是上面的例子。
  3. 提高性能:上一章的 ArrayList 什么都当 object 存,存 int 要先装箱(把值类型包成 object),取出来还要强转;泛型集合直接存 int,这两步都省了。
  4. 现成的泛型集合:在 System.Collections.Generic 命名空间里,ArrayList 换成 List,Hashtable 换成 Dictionary,Stack、Queue、SortedList 也都有泛型版。它们 2005 年的 .NET Framework 2.0 就有了(教程说的“新”已过时),现在是写新代码的首选。
  5. 自己写泛型:可以写泛型接口、泛型类、泛型方法和泛型委托,还能用约束限制 T 能换成哪些类型(后面讲)。

事件本身不能带类型参数(教程列的“泛型事件”这样写会报错):

C# · 第 5 行
5public event EventHandler<T>? Changed<T>;// 想给事件 Changed 加 <T>,这样写不行
编译报错
Program.cs(5,35): error CS7002: 意外使用了通用名称
Program.cs(5,45): error CS1514: 应为 {
Program.cs(5,45): error CS1055: 应为 add 访问器或 remove 访问器
Program.cs(13,2): error CS1513: 应输入 }

正确做法是用泛型委托类型来声明事件,比如 EventHandler<int>,触发时处理方法收到的就是一个 int。Invoke 就是调用委托,this 指当前这个 Thermo 对象:

C# · 第 5-9 行
5public event EventHandler<int>? Changed;// 事件类型是 EventHandler<int>;? 表示可以为空(还没人订阅)
6
7public void Set(int v)// Set 方法:传进一个 int
8{// Set 开始
9 Changed?.Invoke(this, v);// ?. 表示有人订阅才触发,把自己和 v 交出去
C# · 第 15-17 行
15static void Show(object? sender, int v)// sender 是触发事件的对象;v 直接是 int
16{// Show 开始
17 Console.WriteLine("changed to " + v);// 输出收到的数
运行结果
changed to 30

5运行时查泛型类型

泛型用的是什么类型,运行时能用反射(程序运行时查看类型信息的功能)查到:

C# · 第 8-12 行
8List<int> a = new List<int>();// 装 int 的 List
9List<char> b = new List<char>();// 装 char 的 List
10Console.WriteLine(a.GetType());// GetType() 取到对象的实际类型
11Console.WriteLine(b.GetType());// 再看 b 的类型
12Console.WriteLine(a.GetType().GetGenericArguments()[0]);// GetGenericArguments() 取出类型参数,[0] 是第一个
运行结果
System.Collections.Generic.List`1[System.Int32]
System.Collections.Generic.List`1[System.Char]
System.Int32

打出来是两个不同的类型:反引号后面的 1 表示有一个类型参数,方括号里一个是 Int32,一个是 Char。用到哪种类型,运行时就有了哪个版本。

6泛型方法

泛型也能用在方法上。方法名后加 <T>,两个参数都是 T;ref 表示直接改调用者传进来的变量:

C# · 第 8-14 行
8static void Swap<T>(ref T lhs, ref T rhs)// 泛型方法 Swap:交换两个 T 类型的变量
9{// Swap 开始
10 T temp;// 借一个临时变量 temp
11 temp = lhs;// 第 1 步:lhs 的值存进 temp
12 lhs = rhs;// 第 2 步:rhs 的值放进 lhs,lhs 原来的值被覆盖
13 rhs = temp;// 第 3 步:temp 里存着的旧值放进 rhs
14}// Swap 结束
C# · 第 31-32 行
31Swap<int>(ref a, ref b);// int 版:a = 10、b = 20 交换后 a = 20、b = 10
32Swap<char>(ref c, ref d);// char 版:c = 'I'、d = 'V' 交换后 c = 'V'、d = 'I'
查看完整代码(42 行)
C#
1using System;// 引入 System 命名空间
2using System.Collections.Generic;// 引入泛型集合所在的命名空间
3
4namespace GenericMethodAppl// 定义命名空间 GenericMethodAppl
5{// 命名空间开始
6 class Program// 定义 Program 类
7 {// Program 类开始
8 static void Swap<T>(ref T lhs, ref T rhs)// 泛型方法 Swap,参数用 ref 传
9 {// Swap 开始
10 T temp;// 临时变量 temp
11 temp = lhs;// lhs 存进 temp
12 lhs = rhs;// rhs 放进 lhs
13 rhs = temp;// temp 放进 rhs
14 }// Swap 结束
15 static void Main(string[] args)// 程序入口 Main 方法
16 {// Main 开始
17 int a, b;// 声明两个 int
18 char c, d;// 声明两个 char
19 a = 10;// a 是 10
20 b = 20;// b 是 20
21 c = 'I';// c 是大写 I
22 d = 'V';// d 是大写 V
23
24 // 在交换之前显示值
25 Console.WriteLine("Int values before calling swap:");// 提示:交换前的 int
26 Console.WriteLine("a = {0}, b = {1}", a, b);// 输出 a = 10, b = 20
27 Console.WriteLine("Char values before calling swap:");// 提示:交换前的 char
28 Console.WriteLine("c = {0}, d = {1}", c, d);// 输出 c = I, d = V
29
30 // 调用 swap
31 Swap<int>(ref a, ref b);// T 换成 int,交换 a、b
32 Swap<char>(ref c, ref d);// T 换成 char,交换 c、d
33
34 // 在交换之后显示值
35 Console.WriteLine("Int values after calling swap:");// 提示:交换后的 int
36 Console.WriteLine("a = {0}, b = {1}", a, b);// 输出 a = 20, b = 10
37 Console.WriteLine("Char values after calling swap:");// 提示:交换后的 char
38 Console.WriteLine("c = {0}, d = {1}", c, d);// 输出 c = V, d = I
39 Console.ReadKey();// 等待按键(现在的 VS 不写也会停住)
40 }// Main 结束
41 }// Program 类结束
42}// 命名空间结束
查看完整输出(8 行)
运行结果
Int values before calling swap:
a = 10, b = 20
Char values before calling swap:
c = I, d = V
Int values after calling swap:
a = 20, b = 10
Char values after calling swap:
c = V, d = I

Swap 后面的尖括号可以省掉,编译器看参数是 int,就知道 T 是 int(叫类型推断),运行结果一模一样:

C# · 第 31-32 行
31Swap(ref a, ref b);// 不写 <int>,编译器从 a、b 推出 T 是 int
32Swap(ref c, ref d);// 不写 <char>,推出 T 是 char

7约束 where

写一个 Max 方法返回两个值里大的那个,直接写 a > b 会报错,因为 T 可能是任何类型,编译器不知道它能不能比大小:

C# · 第 5-7 行
5static T Max<T>(T a, T b)// 泛型方法 Max,没有约束
6{// Max 开始
7 return a > b ? a : b;// ? : 是条件运算符:a 大就返回 a,否则 b
编译报错
Program.cs(7,16): error CS0019: 运算符“>”无法应用于“T”和“T”类型的操作数

加上 where T : IComparable<T>,意思是 T 必须能比较(IComparable<T> 本身就是泛型接口),就能调用 CompareTo 比大小:

C# · 第 5-13 行
5static T Max<T>(T a, T b) where T : IComparable<T>// where 加约束:T 必须实现 IComparable<T>
6{// Max 开始
7 return a.CompareTo(b) > 0 ? a : b;// CompareTo 大于 0 表示 a 比 b 大
8}// Max 结束
9
10static void Main()// 程序入口 Main 方法
11{// Main 开始
12 Console.WriteLine(Max(3, 7));// 3 和 7 比,返回 7
13 Console.WriteLine(Max("apple", "banana"));// 按字母顺序 banana 排在后面,返回 banana
运行结果
7
banana

传进两个 object 就报错,object 没有实现这个接口:

C# · 第 14 行
14Console.WriteLine(Max(new object(), new object()));// object 不满足 IComparable<T> 约束
编译报错
Program.cs(14,27): error CS0311: 类型“object”不能用作泛型类型或方法“Program.Max<T>(T, T)”中的类型参数“T”。没有从“object”到“System.IComparable<object>”的隐式引用转换。

常用的约束

  • where T : struct:T 必须是值类型,int 可以,string 报错。
  • where T : class:T 必须是引用类型,正好反过来。
  • where T : new():T 必须有无参数的构造函数,int 可以,string 报错。
  • 接口约束(如上面的 IComparable<T>):int 和 string 都满足;还可以写某个基类的名字,要求 T 派生自它。
C# · 第 5-13 行
5static void NeedStruct<T>() where T : struct { }// 要求 T 是值类型;{ } 是空方法体
6static void NeedClass<T>() where T : class { }// 要求 T 是引用类型
7static void NeedNew<T>() where T : new() { }// 要求 T 有无参数构造函数
8
9static void Main()// 程序入口 Main 方法
10{// Main 开始
11 NeedStruct<int>();// int 是值类型,通过
12 NeedClass<string>();// string 是引用类型,通过
13 NeedNew<int>();// int 有无参数构造,通过;最后输出 OK

换成反过来的类型,三行都报错:

C# · 第 11-13 行
11NeedStruct<string>();// string 不是值类型
12NeedClass<int>();// int 不是引用类型
13NeedNew<string>();// string 没有无参数构造函数
编译报错
Program.cs(11,9): error CS0453: 类型“string”必须是不可为 null 值的类型,才能用作泛型类型或方法“Program.NeedStruct<T>()”中的参数“T”
Program.cs(12,9): error CS0452: 类型“int”必须是引用类型才能用作泛型类型或方法“Program.NeedClass<T>()”中的参数“T”
Program.cs(13,9): error CS0310: “string”必须是具有公共的无参数构造函数的非抽象类型,才能用作泛型类型或方法“Program.NeedNew<T>()”中的参数“T”

8泛型委托

委托也能带类型参数,参数是 T,返回的也是 T。例子把它换成 int 来用:静态变量 num 初值 10,AddNum 给它加参数,MultNum 给它乘参数,getNum 读出它。

C# · 第 4 行
4delegate T NumberChanger<T>(T n);// 泛型委托:接收一个 T,返回一个 T
C# · 第 29-35 行
29NumberChanger<int> nc1 = new NumberChanger<int>(AddNum);// T 换成 int,nc1 指向 AddNum
30NumberChanger<int> nc2 = new NumberChanger<int>(MultNum);// nc2 指向 MultNum
31// 使用委托对象调用方法
32nc1(25);// 调用 AddNum(25):num = 10 + 25 = 35
33Console.WriteLine("Value of Num: {0}", getNum());// 输出 35
34nc2(5);// 调用 MultNum(5):num = 35 × 5 = 175
35Console.WriteLine("Value of Num: {0}", getNum());// 输出 175
运行结果
Value of Num: 35
Value of Num: 175

C# 还自带了现成的泛型委托,比如 Func 和 Action。

9小结

  • 泛型用类型参数 T 占位,用的时候在尖括号里写上具体类型。
  • 泛型类、泛型方法、泛型委托都是这样写;泛型方法的尖括号常常可以省。
  • 类型不对,编译时就报错,这就是类型安全。
  • 要限制 T 能换成哪些类型,就用 where 加约束。

下一节:让委托指向一段代码都得先写一个有名字的方法,能不能把代码直接写在用它的地方?

检查题

5 道,选完马上告诉你对不对。

  1. 1教程实例 1 第一行输出?

  2. 2MyGenericArray<int> 对象上写 setItem(0, "abc")?

  3. 3static T Max<T>(T a, T b) { return a > b ? a : b; }?

  4. 4加上 where T : IComparable<T> 后 Max(new object(), new object())?

  5. 5where T : class 约束下用 int?