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

委托(Delegate)

委托是一种类型,签名规定它能装哪些方法;委托对象里存着方法的引用,调用委托就是按调用列表依次调用这些方法。

时长 9:17授课计划第 13 周

学完这一节,你能

讲义

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

1委托是什么

委托是一种引用类型,委托对象里存的是对某个方法的引用。它像 C/C++ 的函数指针,但会检查方法的“样子”对不对得上。事件、回调、LINQ 背后都是委托。

C# · 第 9-10 行
9Console.WriteLine(typeof(NumberChanger).BaseType);// typeof 取到委托类型,.BaseType 是它的父类
10Console.WriteLine(typeof(MulticastDelegate).BaseType);// 再往上一层的父类
运行结果
System.MulticastDelegate
System.Delegate

教程说委托都派生自 System.Delegate;实测是先继承 MulticastDelegate,再往上一层才是 System.Delegate。

2声明委托

C# · 第 3-4 行
3public delegate int MathOperation(int x, int y);// 能装“接收两个 int、返回 int”的方法
4public delegate int MyDelegate(string s);// 能装“接收一个 string、返回 int”的方法

delegate 后面依次是返回类型、委托名、参数列表。参数和返回值合起来叫签名(教程说的“相同标签”就是签名),签名对得上的方法才能装进去。视频里把它比成插卡导轨:插座的形状是委托类型,插上卡是委托对象,按按钮就是调用。

3创建委托并调用

例子里 num 初值 10,AddNum 给它加参数,MultNum 给它乘参数,getNum 读出它。

C# · 第 28-34 行
28NumberChanger nc1 = new NumberChanger(AddNum);// 创建委托 nc1,装着 AddNum(方法名不带括号)
29NumberChanger nc2 = new NumberChanger(MultNum);// 创建委托 nc2,装着 MultNum
30// 使用委托对象调用方法
31nc1(25);// 调用委托 = 调用 AddNum(25):num = 10 + 25 = 35
32Console.WriteLine("Value of Num: {0}", getNum());// 输出 35
33nc2(5);// 调用 MultNum(5):num 已是 35,35 × 5 = 175
34Console.WriteLine("Value of Num: {0}", getNum());// 输出 175
查看完整代码(38 行)
C#
1using System;// 引入 System 命名空间
2
3delegate int NumberChanger(int n);// 声明委托类型:接收 int,返回 int
4namespace DelegateAppl// 定义命名空间 DelegateAppl
5{// 命名空间开始
6 class TestDelegate// 定义 TestDelegate 类
7 {// 类开始
8 static int num = 10;// 静态变量 num,初值 10
9 public static int AddNum(int p)// AddNum:签名是“接收 int,返回 int”,和委托对得上
10 {// AddNum 开始
11 num += p;// num 加上参数 p
12 return num;// 返回新的 num
13 }// AddNum 结束
14
15 public static int MultNum(int q)// MultNum:签名同样对得上
16 {// MultNum 开始
17 num *= q;// num 乘上参数 q
18 return num;// 返回新的 num
19 }// MultNum 结束
20 public static int getNum()// getNum:不接收参数,只返回 num
21 {// getNum 开始
22 return num;// 返回当前的 num
23 }// getNum 结束
24
25 static void Main(string[] args)// 程序入口 Main 方法
26 {// Main 开始
27 // 创建委托实例
28 NumberChanger nc1 = new NumberChanger(AddNum);// 创建委托 nc1,里面装着 AddNum
29 NumberChanger nc2 = new NumberChanger(MultNum);// 创建委托 nc2,里面装着 MultNum
30 // 使用委托对象调用方法
31 nc1(25);// 调用 nc1,等于调用 AddNum(25):num = 10 + 25 = 35
32 Console.WriteLine("Value of Num: {0}", getNum());// 输出 num:35
33 nc2(5);// 调用 nc2,等于调用 MultNum(5):num = 35 × 5 = 175
34 Console.WriteLine("Value of Num: {0}", getNum());// 输出 num:175
35 Console.ReadKey();// 等待按键,防止控制台窗口一闪而过
36 }// Main 结束
37 }// 类结束
38}// 命名空间结束
运行结果
Value of Num: 35
Value of Num: 175

从 C# 2.0 起可以不写 new,直接写方法名,输出不变:

C# · 第 28-29 行
28NumberChanger nc1 = AddNum;// 编译器自动创建委托
29NumberChanger nc2 = MultNum;// 同样直接写方法名,nc2 装着 MultNum

签名对不上就装不进去,这叫类型安全:

C# · 第 28 行
28NumberChanger nc1 = new NumberChanger(getNum);// getNum 不接收参数,和委托签名对不上
编译报错
Program.cs(28,30): error CS0123: “getNum”没有与委托“NumberChanger”匹配的重载

4多播

一个委托可以装好几个方法:+= 加进调用列表,-= 拿掉,调用时按顺序执行,只有同一种委托类型才能合并。

C# · 第 31-34 行
31nc = nc1;// 调用列表:[AddNum]
32nc += nc2;// 调用列表:[AddNum, MultNum]
33// 调用多播
34nc(5);// 先 AddNum:10 + 5 = 15,再 MultNum:15 × 5 = 75
运行结果
Value of Num: 75

合并委托返回的是最后一个方法的返回值。+= 会生成一个新委托赋给左边,nc1、nc2 本身不变。

5委托当参数:回调

C# · 第 29-38 行
29public static void sendString(printString ps)// 参数 ps 是一个委托
30{// sendString 开始
31 ps("Hello World");// 只管调用,具体做什么看传进来的方法
32}// sendString 结束
33static void Main(string[] args)// 程序入口
34{// Main 开始
35 printString ps1 = new printString(WriteToScreen);// ps1 装着“打到屏幕”
36 printString ps2 = new printString(WriteToFile);// ps2 装着“写进文件”
37 sendString(ps1);// 控制台输出一行
38 sendString(ps2);// 写进 message.txt,控制台看不到
查看完整代码(42 行)
C#
1using System;// 引入 System 命名空间
2using System.IO;// 引入 System.IO,文件读写用的类在这里
3
4namespace DelegateAppl// 定义命名空间
5{// 命名空间开始
6 class PrintString// 定义 PrintString 类
7 {// 类开始
8 static FileStream fs;// 文件流,写文件时用
9 static StreamWriter sw;// 写入器,往文件流里写文字
10 // 委托声明
11 public delegate void printString(string s);// 委托 printString:接收 string,不返回值
12
13 // 该方法打印到控制台
14 public static void WriteToScreen(string str)// WriteToScreen:签名和委托对得上
15 {// WriteToScreen 开始
16 Console.WriteLine("The String is: {0}", str);// 把字符串打到控制台
17 }// WriteToScreen 结束
18 // 该方法打印到文件
19 public static void WriteToFile(string s)// WriteToFile:签名也对得上
20 {// WriteToFile 开始
21 fs = new FileStream("c:\\message.txt", FileMode.Append, FileAccess.Write);// 以追加方式打开文件 c:\message.txt(字符串里 \\ 代表一个 \)
22 sw = new StreamWriter(fs);// 在文件流上创建写入器
23 sw.WriteLine(s);// 写入一行字符串
24 sw.Flush();// 把缓冲区里的内容真正写进文件
25 sw.Close();// 关闭写入器
26 fs.Close();// 关闭文件流
27 }// WriteToFile 结束
28 // 该方法把委托作为参数,并使用它调用方法
29 public static void sendString(printString ps)// sendString 的参数 ps 是一个委托
30 {// sendString 开始
31 ps("Hello World");// 用 "Hello World" 调用委托,具体做什么看传进来的方法
32 }// sendString 结束
33 static void Main(string[] args)// 程序入口 Main 方法
34 {// Main 开始
35 printString ps1 = new printString(WriteToScreen);// ps1 装着 WriteToScreen
36 printString ps2 = new printString(WriteToFile);// ps2 装着 WriteToFile
37 sendString(ps1);// 传 ps1:在控制台打出一行
38 sendString(ps2);// 传 ps2:写进文件,控制台上看不到
39 Console.ReadKey();// 等待按键
40 }// Main 结束
41 }// 类结束
42}// 命名空间结束
运行结果
The String is: Hello World

控制台只有一行,第二次的 Hello World 写进了文件。教程路径是 C 盘根目录,我们在 macOS 上实测,文件写在了运行目录里。

6用 -= 移除

C# · 第 17-24 行
17PrintMessage print = PrintUpperCase;// 调用列表:[大写]
18print += PrintLowerCase;// 调用列表:[大写, 小写]
19
20// 移除 PrintLowerCase 方法
21print -= PrintLowerCase;// 拿掉小写,回到 [大写]
22
23// 调用委托(只会调用 PrintUpperCase)
24print("Hello, C#"); // 输出: HELLO, C#// 只执行 PrintUpperCase
查看完整代码(26 行)
C#
1public class Program// 定义 Program 类
2{// 类开始
3 public delegate void PrintMessage(string message);// 委托 PrintMessage:接收 string,不返回值
4
5 public static void PrintUpperCase(string message)// 方法:输出大写
6 {// PrintUpperCase 开始
7 Console.WriteLine(message.ToUpper());// 把字符串转成大写后输出
8 }// PrintUpperCase 结束
9
10 public static void PrintLowerCase(string message)// 方法:输出小写
11 {// PrintLowerCase 开始
12 Console.WriteLine(message.ToLower());// 把字符串转成小写后输出
13 }// PrintLowerCase 结束
14
15 public static void Main()// 程序入口 Main 方法
16 {// Main 开始
17 PrintMessage print = PrintUpperCase;// print 先装 PrintUpperCase:调用列表 [大写]
18 print += PrintLowerCase;// 再加上 PrintLowerCase:调用列表 [大写, 小写]
19
20 // 移除 PrintLowerCase 方法
21 print -= PrintLowerCase;// 拿掉 PrintLowerCase:调用列表回到 [大写]
22
23 // 调用委托(只会调用 PrintUpperCase)
24 print("Hello, C#"); // 输出: HELLO, C#// 只执行 PrintUpperCase
25 }// Main 结束
26}// 类结束
运行结果
HELLO, C#

不写 -= 那一行,会输出 HELLO, C# 和 hello, c# 两行。VS 会在这两行画绿色波浪线(警告 CS8601、CS8602):减完后委托可能变成 null,调用前最好先判断,写法见下面的 if (Click != null)。

7委托和事件

C# · 第 4 行
4public event EventHandler Click;// 用 event 声明事件,EventHandler 是一种委托
C# · 第 23 行
23button.Click += Button_Click;// 订阅:把 Button_Click 挂到 Click 上
查看完整代码(33 行)
C#
1public class Button// 定义 Button 类(发布者)
2{// 类开始
3 // 定义一个事件
4 public event EventHandler Click;// 用 event 声明事件 Click,类型 EventHandler 是一种委托
5
6 // 引发事件的方法
7 public void OnClick()// OnClick:引发事件
8 {// OnClick 开始
9 if (Click != null)// 先确认有人订阅(不是空)
10 {// if 开始
11 Click(this, EventArgs.Empty); // 调用事件// 调用事件:挂在上面的方法被依次执行
12 }// if 结束
13 }// OnClick 结束
14}// 类结束
15
16public class Program// 定义 Program 类(订阅者)
17{// 类开始
18 public static void Main()// 程序入口 Main 方法
19 {// Main 开始
20 Button button = new Button();// 创建一个按钮对象
21
22 // 订阅事件
23 button.Click += Button_Click;// 用 += 把 Button_Click 挂到 Click 上
24
25 // 引发事件
26 button.OnClick(); // 输出 "Button clicked!"// 引发事件,Button_Click 被调用
27 }// Main 结束
28
29 private static void Button_Click(object sender, EventArgs e)// 事件处理方法,签名和 EventHandler 对得上
30 {// Button_Click 开始
31 Console.WriteLine("Button clicked!");// 输出一行字
32 }// Button_Click 结束
33}// 类结束
运行结果
Button clicked!

事件是包着委托的成员,就像属性包着字段。在 Button 类外面只能 +=、-=,直接调用会报错:

C# · 第 26 行
26button.Click(null, EventArgs.Empty);// 在类外面直接调用事件
编译报错
Program.cs(26,16): error CS0070: 事件“Button.Click”只能出现在 += 或 -= 的左边(从类型“Button”中使用时除外)

8现成的委托

.NET 准备好了三种委托,不用自己声明(下面是顶级语句写法,不写类和 Main):

C#
1Action<string> printMessage = Console.WriteLine;// Action:不返回值;这里接收一个 string
2printMessage("Hello");// 输出 Hello
C#
1Func<int, int, int> add = (x, y) => x + y;// Func:最后一个类型是返回值,前面都是输入;=> 是 lambda 写法
2Console.WriteLine(add(3, 4)); // 输出 7// 3 + 4,输出 7
C#
1Predicate<int> isEven = x => x % 2 == 0;// Predicate:接收一个参数,固定返回 bool
2Console.WriteLine(isEven(4)); // 输出 True// 4 是偶数,输出 True

9注意事项

  1. 匿名方法和 lambda:可以当场写方法交给委托,两种写法结果都是 8。
C#
1Func<int, int, int> add = delegate (int x, int y) { return x + y; };// 匿名方法:delegate 关键字 + 参数 + 方法体
2Console.WriteLine(add(5, 3)); // 输出 8// 5 + 3,输出 8
  1. 异步调用:教程里的 BeginInvoke 只能在老的 .NET Framework 上用,现在会抛异常,异步改用 async 和 await。
C# · 第 4 行
4add.BeginInvoke(5, 3, null, null);// 老写法的异步调用
运行结果
PlatformNotSupportedException
Operation is not supported on this platform.

10小结

  • 委托是一种类型,签名决定它能装哪些方法。
  • 用 new 或直接写方法名创建;调用委托就是调用里面的方法。
  • +=、-= 增删调用列表,按顺序执行,返回最后一个的值。
  • 委托能当参数传,做回调;Action、Func、Predicate 是现成的委托。

下一节:包上 event 以后,为什么外面只能订阅、不能直接调用?

检查题

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

  1. 1视频里的 NumberChanger 例子(num 开始是 10,先调用装着 AddNum 的委托传 25,再调用装着 MultNum 的委托传 5),两行输出?

  2. 2nc = nc1; nc += nc2; nc(5);(nc1 装 AddNum,nc2 装 MultNum,num 开始是 10)输出?

  3. 3把不接收参数的 getNum 装进 NumberChanger(接收一个 int)?

  4. 4print = PrintUpperCase; print += PrintLowerCase; print -= PrintLowerCase; print("Hello, C#"); 输出?

  5. 5Func<int, int, int> 的意思?