Menu

C# 委托(delegate):声明、调用、多播与回调

C# 中的委托是什么、怎么用:声明委托类型,用方法、lambda 或匿名方法创建委托,调用委托,把委托用作回调,用 += 和 -= 实现多播委托,用 ?.Invoke 做空值检查,以及委托与 Func、Action 和事件的关系。

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

委托是一种表示方法签名的类型。委托类型的变量持有对某个具有该签名的方法的引用,调用这个变量就是调用那个方法。C# 就是这样把函数当作值来对待的:你可以存储它们,把它们传给其他方法,以后再调用。

输出:

40
70
68.00

delegate decimal PriceRule(decimal price); 声明了一个新类型,和 class 一样。任何接收一个 decimal 并返回 decimal 的方法都符合它。变量 rule 先后指向了三个不同的方法,每次调用运行的都是它当时持有的那个。

声明、创建与调用

委托类型的声明看起来就像前面加了 delegate 关键字的方法签名。它通常放在命名空间级别,与类并列,或者嵌套在类里面。

创建委托实例有四种方式:

PriceRule a = HalfPrice;                             // method group (most common)
PriceRule b = new PriceRule(HalfPrice);              // explicit constructor, same thing
PriceRule c = p => p - 5m;                            // lambda expression
PriceRule d = delegate (decimal p) { return p; };    // anonymous method (C# 2 syntax)

调用它有两种方式,效果完全相同:rule(80m) 和 rule.Invoke(80m)。编译器在赋值时检查签名,所以签名不匹配的方法会导致编译错误,而不是在运行时给你一个意外。

委托也可以指向实例方法。这时它会同时记住方法和调用该方法的对象:

输出:

Ana got: Your order shipped
target: Mailbox, method: Receive

Target 是委托将在其上调用方法的对象(静态方法为 null),Method 描述了这个方法。持有目标对象也意味着委托会让该对象一直存活,这一点对事件很重要。

把委托用作回调

委托的主要作用,是让一个方法去调用由它的调用方选定的代码。方法决定什么时候发生某件事,调用方决定发生什么:

输出:

[##        ] 25%
[#####     ] 50%
[#######   ] 75%
[##########] 100%
import finished

ImportRows 对控制台和进度条一无所知。同一个方法可以驱动进度条、一行日志或一次 UI 更新,取决于调用方传入什么。List.Sort(Comparison<T>)、Array.Find(Predicate<T>)、Task.Run(Action) 以及每一个 LINQ 运算符都遵循这种模式。

多播委托:+= 与 -=

一个委托可以持有不止一个方法。+= 把方法追加到它的调用列表,-= 将其移除,调用委托时会按顺序调用每一个方法:

输出:

email sent for A-1001
stock updated for A-1001
warehouse notified for A-1001
3 handlers
email sent for A-1002
warehouse notified for A-1002

多播委托有三个在实践中很重要的细节:

  • 返回值:如果委托类型有返回值,调用多播委托只会返回最后一个方法的结果。要收集所有结果,就遍历 GetInvocationList(),自己逐个调用。
  • 异常:如果某个方法抛出异常,剩下的方法不会运行,异常会传到调用方。
  • 不可变性:委托是不可变的。+= 会创建一个列表更长的新委托,再赋值回变量,所以在副本上用 -= 移除方法不会影响原来的委托。

用 -= 移除 lambda,只有传入同一个委托实例才有效。把同样的 lambda 写两遍会创建两个不同的委托,所以 handlers -= id => Log(id); 什么也不会移除。打算取消订阅的 lambda,请先存到变量里。

null 委托与 ?.Invoke

没有赋值任何东西的委托变量是 null,用 -= 移除最后一个方法后也会变回 null。调用 null 会抛出 NullReferenceException:

输出:

no handler, no crash
saved draft.txt
NullReferenceException

?.Invoke 是调用可选回调的标准写法。?. 运算符不能直接放在括号前面(onSaved?("x") 不是合法语法),所以这种写法通过名字调用委托的 Invoke 方法,效果与简写的调用相同。

委托、Func、Action 与事件

为每一种签名都声明一个委托类型太繁琐了,所以 .NET 自带了泛型委托:

内置类型等价的自定义声明
Actiondelegate void Action();
Action<T>delegate void Action<T>(T arg);
Func<TResult>delegate TResult Func<TResult>();
Func<T, TResult>delegate TResult Func<T, TResult>(T arg);
Predicate<T>delegate bool Predicate<T>(T obj);

第一个例子中的 PriceRule 与 Func<decimal, decimal> 形状相同,但两者是不同的类型,不能相互转换。即使是签名完全相同的两个内置类型也不行:

Func<int, bool> isPositive = x => x > 0;
Predicate<int> p = isPositive;
// error CS0029: Cannot implicitly convert type 'System.Func<int, bool>' to 'System.Predicate<int>'

Predicate<int> ok = new Predicate<int>(isPositive);   // wrap it explicitly
Predicate<int> ok2 = x => isPositive(x);              // or with a lambda

lambda 可以转换为任何兼容的委托类型,而 API 通常接收 lambda,所以这个问题一般不会出现。Func 与 Action 那一页介绍了这一族泛型委托,以及什么时候自定义声明仍然值得。

事件直接建立在委托之上。event 字段是一个多播委托,外部代码只能对它 += 和 -=,不能调用或覆盖它。上面多播示例中的订阅者列表,正是事件替你管理的东西,只是多了这层保护。事件在单独的一页中介绍。

常见问题

C# 中的委托是什么?

委托是对方法的类型安全的引用。你用一个签名声明委托类型,例如 delegate decimal PriceRule(decimal price);,任何具有该签名的方法(静态方法、实例方法或 lambda)都可以存进这个类型的变量,之后再通过它调用。C# 正是通过委托把函数当作值来传递的。

C# 中委托和 Func 有什么区别?

Func<T, TResult> 和 Action<T> 是 .NET 已经替你声明好的委托类型,参数类型和返回类型都是泛型的。自定义的 delegate 声明会给类型起一个名字,并用参数名说明它的用途,还支持 ref、out 和 in 参数。大多数代码用 Func 和 Action 就够了。

C# 中的多播委托是什么?

多播委托是持有多个方法的委托。+= 把一个方法加入它的调用列表,-= 移除一个方法;调用委托时会按顺序调用所有方法。如果委托有返回值,调用方只能拿到最后一个方法的结果,而且一旦某个方法抛出异常,后面的方法就不会再运行。

如何安全地调用可能为 null 的委托?

使用空条件运算符:onProgress?.Invoke(50);。没有赋值任何方法的委托变量是 null,直接调用会抛出 NullReferenceException。?.Invoke 只读取一次变量,所以即使另一个线程在同一时刻移除了最后一个处理程序,它也是安全的。

什么时候应该自己声明委托类型,而不是用 Func 或 Action?

当签名需要 ref、out 或 in 参数时(Func 和 Action 无法表达它们),当一个有名字的类型能让公开 API 更清晰时(delegate bool Validator(string input)),或者当你希望参数名显示在 IntelliSense 中时。其他情况优先使用每个 .NET 开发者都认识的 Func 和 Action。

Coddy 编程语言插图

和 Coddy 一起学编程

开始