委托是一种表示方法签名的类型。委托类型的变量持有对某个具有该签名的方法的引用,调用这个变量就是调用那个方法。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 自带了泛型委托:
| 内置类型 | 等价的自定义声明 |
|---|---|
Action | delegate 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。