C#杂记之壹:委托与事件

此前虽然接触过C#中的一些较为高级的操作,但基本是只知其有,不知在哪些场景下可以应用。这几天写着代码就顿觉冗杂不堪,虽然能跑但看起来十分难受,希望寻求一些函数式编程之类的魔法来简化流程。

那首先要理解的还是委托和事件。虽然学完了也不一定会用,但看懂一点总是好的。

委托

定义

如之前所说,委托类似于函数指针。或者说,委托/是/一种/存储/函数引用/的/类型(注意断句)。由于函数的参数须是变量、常量、表达式,只要把函数变成一种引用类型的变量,就可以让一个函数作为其它函数的参数了。同样,也可以通过直接调用委托变量来调用委托变量所引用的函数。

为什么要把类型加粗?因为声明一个委托类型并不是声明一个委托变量,而是一类委托的模板,说明了这类委托应具有的的返回值类型与参数列表。委托类型与具体的委托变量之关系,类似于“类”和“对象”的关系。

委托类型声明和函数类似,拥有返回值类型和参数列表,但没有函数体,并且在前面用delegate关键字声明这是一个委托类型。然后我们再声明这个委托类型的变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//声明委托类型和委托变量
delegate double ProcessDelegate(double param1, double param2);
ProcessDelegate process;

//...

//将函数Multiply注册到ProcessDelegate类型的委托变量process
process = new ProcessDelegate(Multiply);

//也可以简写
process = Multiply;

//多播委托(虽然这里没什么用)
process += Divide;
process += Add;

//调用委托
System.Console.WriteLine($"Result:{process(12.1, 22.4)}");

实际上,委托确实是一种“类”。上面所展示的委托变量声明,其形式就和类的实例化完全一致。在委托变量后面打个点,你甚至能看到一堆委托类型包含的方法和属性:

委托也是类

何时用委托?

委托的一个有趣且有用的属性是,它不知道也不关心所引用的方法属于哪个类;只关心引用的方法是否具有与委托相同的参数和返回类型。

这就像是当你饿了的时候,并不是非得去哪家特定的饭店吃饭,而是只要你给钱(参数)能得到食物(返回类型)就行了。这样,就无需先通过继承建筑类来构建一个店铺类,再实现一个厨房接口之类的东西,让其成为饭店——而是就算是大街上一个路人你能从TA那买到吃的,也算是能满足你的需求。

因此,委托的大用处在于解耦。一个典型的例子是用在LINQ中。下面的示例选取numbers中小于10的数:

1
var smallNumbers = numbers.Where(n => n < 10);

LINQ的详细介绍留至以后,这可以说是C#中最为变态的武器之一,这里只说说Where方法。

Where方法是接口IEnumerableIEnumerable<T>中的方法,也只有实现了这两个接口之一或它们的派生接口(如IQueryable<T>)的类可以使用LINQ,这种类被称为可查询类型。Where方法的原型:

1
public static IEnumerable<TSource> Where<TSource> (this IEnumerable<TSource> source, Func<TSource, bool> predicate);

分析一下。

首先,这个方法是一个static方法,并且参数列表的第一个参数被this修饰,这是扩展方法的特征。这是因为Where方法之类的LINQ操作并不是直接定义在IEnumerable<T>接口里,而是在System.Linq命名空间中定义的扩展方法。扩展方法的第一个,被this所修饰的参数指明了这个扩展方法扩展的是什么类型。

Where是扩展方法.jpg

可以看到,如果注释掉using System.Linq,虽然List<int>实现了IEnumerable<T>接口,但其中是无法查找到Where方法的。因此,方法原型中的this IEnumerable<TSource> source,指的就是“Where方法所扩展的是IEnumerable<TSource>接口”。

而后面的Func<TSource, bool> predicate就是一个委托,并且是泛型委托。其中关系到“协变和逆变”之类的高深操作留待下次再说

这个委托用于对每个元素进行测试,看它们是否满足条件。自然,如果满足条件,这个委托将返回bool型的true。泛型委托的泛型类型参数中,最后一个即为委托的返回类型,故而是bool型。而前面的TSource指示了所封装的方法的参数类型。

因此,这个Func<TSource, bool> predicate可以看作是:

1
2
3
4
5
6
7
8
delegate bool FooDelegate(TSource foo);     //这里的TSource是某个类型

bool FooFunc(TSource foo){ //接收TSource,返回bool的函数
//...
return true;
}

FooDelegate predicate = new FooDelegate(FooFunc);

泛型委托

除了用delegate关键字声明传统委托之外,还有两种泛型委托:即上面提到过的Func<>和另一种Action<>泛型委托。

使用泛型委托,可以避免传统委托在使用前必须定义委托类型才能用委托变量引用函数的麻烦。

Func<>泛型委托

Func<>泛型委托适用于引用那些返回类型非void的函数。其泛型类型参数的个数可从1-17个不等,其中最后的泛型类型参数代表返回类型,前面的0-16个泛型类型参数代表委托的参数列表。

以下示例用两个泛型委托分别输出List中大于5和小于10的数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
using System;
using System.Collections.Generic;
using System.Linq;
using static System.Console;

namespace PlayGround
{
class Program
{
static void Main(string[] args)
{
//定义两个函数
bool Foo1(int num)
{
return num > 5 ? true : false;
}

bool Foo2(int num)
{
return num < 10 ? true : false;
}

//定义两个泛型委托
Func<int, bool> foo1 = Foo1;
Func<int, bool> foo2 = Foo2;

int[] input = { 1, 2, 4, 5, 6, 10, 37, 256 };
List<int> numsList = new List<int>(input);

foreach (int num in numsList.Where(foo1).ToList()) //用委托调用函数Foo1
Write($"{num} "); //Print: 6 10 37 256

WriteLine();

foreach (int num in numsList.Where(foo2).ToList()) //用委托调用函数Foo2
Write($"{num} "); //Print: 1 2 4 5 6

ReadKey();
}
}
}

使用泛型委托之后,声明委托类型、声明委托变量、引用函数的步骤被结合到了一步:

1
Func<int, bool> foo1 = Foo1;

当然,上述这个例子用Lambda表达式作为委托还会简单得多:

1
2
3
4
foreach (int num in numsList.Where(n => n > 5).ToList())    //用Lambda表达式表示大于5
Write($"{num} "); //Print: 6 10 37 256
foreach (int num in numsList.Where(n => n < 10).ToList()) //用Lambda表达式表示小于10
Write($"{num} "); //Print: 6 10 37 256

实际上,Lambda表达式它完全就是一种委托,属于匿名委托的更简写法。

Action<>泛型委托

Func<>的区别在于,Action<>的返回类型是void。因此,泛型类型参数的个数从0~16个不等,全部代表委托的参数列表。这也非常“Action”:传统意义上的“函数”感觉就是得返回点什么(函数值),而什么也不返回的更像是一个“动作”。这个就不再举例了。

事件

定义

事件是在委托的基础上实现的,同样也是解耦利器。

在委托变量的声明前加event关键字就是声明了一个事件。需要注意,用于事件的委托其返回值一般是void

1
2
3
4
5
6
//一个委托类型,接收string,返回void
public delegate void BoilerLogHandler(string status);

//基于上面的委托类型定义BoilerLogHandler类型的事件BoilerEventLog
//这要求事件处理函数接受string返回void
public event BoilerLogHandler BoilerEventLog;

包含了事件声明定义的类被称为发布器(publisher)类;接收事件,并提供事件处理函数的类被称为订阅器(subscriber)类。所谓订阅事件的过程,就是将订阅器类的处理函数注册到发布器类那里,当发布器发现事件被引发时,就通知订阅器类去执行相应的处理函数。发布器类有可能同时也是订阅器类之一,而引发事件的类可能既不是发布器类也不是订阅器类。

上面的那段就是发布器类的一部分,定义了事件BoilerEventLog。接下来展示一个相符的订阅器类内容:

1
2
//满足BoilerEventLog事件对参数和返回类型要求的函数
public void DisplayMessage(string message) => Console.WriteLine($"Message arrived: {message}");

订阅事件和引发事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//实例化发布器类和订阅器类
Logger myLogger = new Logger();
Display myDisplay = new Display();

//订阅事件
myLogger.BoilerEventLog += new BoilerLogHandler(myDisplay.DispalyMessage);

//或者和委托一样,简化写法:
myLogger.BoilerEventLog += myDisplay.DisplayMessage;

//引发事件:

//用?. 运算符可以轻松确保在事件没有订阅器时不引发事件
//当?. 左侧为null时返回null,非空才访问右侧。没有被订阅的事件就是空的。
myLogger.BoilerEventLog?.Invoke("This is a message.");

接下来,myLogger对象对BoilerEventLog事件中是否有被注册的事件处理函数进行检查,发现有来自myDisplay的处理函数DisplayMessage(),于是传入的参数"This is a message."被送给处理函数进行处理。

与多播委托的区别?

目前看来,区别就是没什么太大区别,以至于C#文档里都有这么一篇:区别委托和事件

委托/定义所言,委托其实也是类,定义的每一种委托都继承自Delegate类。而Delegate类拥有一个静态的Delegate.Combine()方法,用于将同类型多个委托变量的调用列表进行连接合并。这个方法可以由C#编译器对运算符+转译而来(并不是运算符重载)。这和String类的字符串连接是一样的。

多播委托的执行顺序是固定的,按照GetInvocationList()方法所得数组的逆序执行;事件则是不可预测顺序的(据称)。

不过,接下来就有区别了,这个才是重点。

EventHandler

C#预定义了委托EventHandler和泛型委托EventHandler<TEventArgs>用来写发布器。事实上,考虑到事件引发时通常需要传入不少各种各样的参数,相比前面与多播委托基本没区别的语法,使用这种预定义的事件要更多。

原型:

1
2
3
public delegate void EventHandler(object sender, EventArgs e);

public delegate void EventHandler<TEventArgs>(object sender, TEventArgs e);

这两个参数,第一个是事件源,在引发事件时填写this即可;第二个参数的类型派生自System.EventArgs,包含任意个事件参数。派生自System.EventArgs的类有很多很多,其中都是C#预先定义的事件形参。

而泛型委托EventHandler<TEventArgs>的区别在于,第二项参数可以不派生自System.EventArgs,而是根据泛型类型参数TEventArgs决定。这意味着,如果希望在引发事件时传入10种参数,你需要根据TEventArgs写一个类,在里面定义10项属性。引发事件时,实例化这个类,把构造出的对象传到第二项参数里。

这个过程相当于是引发事件的类填了一张表格寄出去。表格的格式是预先设计好的,无论是派生自System.EventArgs的类,还是泛型委托里根据泛型类型参数自己写的那个类,其属性是在填写表格之前就确定了的。寄出去的除了填写完整的表格e之外,信封上还有你的联系方式sender。这样,收件人(发布器类)才能知道信是谁寄来的,也能够确定信里表格的格式是符合要求的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//一个定义的例子
public void event EventHandler<MessageArrivedEventArgs> MessageArrived;

//事件参数类的定义
public class MessageArrivedEventArgs : EventArgs
{
private string message;

public string Message
{
get{return message;} //让message通过Message只读
}
//显式定义无参构造函数
public MessageArrivedEventArgs()=>
message = "No message sent."
//有参构造函数
public MessageArrivedEventArgs(string newMessage)=>
message = newMessage;
}

//引发事件,第一个参数是this,第二个实例化事件参数类
MessageArrived(this, new MessageArrivedEventArgs("Hello World!"));

最后要注意的是,如果事件根本不需要传入参数,依然可以用EventHandler委托来定义,只要向第二项参数里传入EventArgs.Empty就行了。

暂且到此为止,明天应当学习协变、逆变,以及LINQ。