撰写了文章 发布于 2020-04-05 17:05:19
从C#开始的编程入门——作为对象的方法
假设你是负责某个小区的快递小哥。你需要在快递到达时进行处理。
class Person
{
public string Name{set;get;}
public string PhoneNumber{set;get;}
}
class Package
{
public string Id {set;get;}
public Person From {set;get;}
public Person To {set;get;}
}
class ExpressGuy
{
public void PackageArrived(Package package)
{
Send(package);
}
public void Send(Package package)
{
Console.WriteLine($"Sending {package.Id} to {package.To.Name}");
}
}
你可能很敬业,你决定挨家挨户送,现在如果小区只有一个人也没什么大不了的,Send过去就行了。但一般称得上小区的地方可能都得上百户人家。
现在某个住户没在家,需要你放在快递柜,好吧你只能在遇到这个人的快递时放到快递柜。
public void PackageArrived(Package package)
{
Send(package);
if (package.To.Name == "YangXiaoLong")
{
System.Console.WriteLine("我放柜子里了,记得拿");
}
}
随着住户增多,他们的需求也越来越不一样。直到有成百上千个用户,你终于疯了。现在快递一来,你要挨个跑断腿问他们要怎么办,然后你还得到处去放快递。难道你还要写一万个if来判断是不是哪个上帝要刁难你?
这个故事中暗含的问题是什么。快递员或者说快递点,主要的能力是接受快递并处理相关消息的发布。快递员并不知道用户在得知快递送达后想要怎么办,并且这不属于快递员的职责范围,即使需要快递员处理,也应该由客户告诉他,而不是挨个上门问。
回调
现在我们回到计算机世界中。我们现在调用方法,都是手上有一个已知的对象,然后也知道一个方法的具体名称,然后再通过成员访问运算符调用。但实际上,很多时候我们只知道调用的时机,或者只知道该做什么,但不知道什么时候做。比如说,窗口上有一个按钮,我们需要按钮被按下时弹出一个提示。假设我是按钮,我知道什么时候我被按下了,但我不知道其他什么东西对这个事情感兴趣,而其他东西在发生这个事情的时候想干什么。同样对于按钮以外的东西,它们也不知道什么时候按钮被按下。又如下载文件,文件下载的时间几乎总是不确定的,如果我们要在下载完成时通知对这个事情感兴趣的部分,我们也面临同样的问题。
调用一方如果需要在特定时间挨个询问每一个对象是否感兴趣,这会非常浪费时间和精力,同样的,对事件真正感兴趣的对象如果需要隔一段时间询问触发事件的一方,也很浪费。
因此,一个想法是,应当告诉知道调用方法时机的一方我们想做什么,而当时机成熟时,由触发事件的一方来调用方法,知道调用时机的一方负责调用,知道如何处理事件的一方自己处理,各司其职。但问题是,我们怎么告诉它怎么做,问题也就转化为:如何给它一个方法?
实际上这些交给其他对象在合适的时机调用的方法被称为回调(callback,亦称call-after function)。回调听起来有点莫名其妙,说成“调回”或者“后调”可能更容易理解,甚至还有点回拨电话那个感觉。回调就是一段可执行代码(在大多数语言中表现为函数之类的概念),但是它可以被当成参数传递给另一个函数或者对象,并且随后可以调用。C/C++中的函数指针就可以承担这样的工作,而其他很多语言中函数本身也是对象。它们的共同特点都是可以通过某种东西去引用一个函数,并在合适的时候间接调用(到目前为止我们都是通过成员访问运算符立即调用一个方法)。而在C#中,这种代表方法的对象,叫做委托(delegate)。
委托
委托实际上是一种特殊的类型,继承自Delegate类型的所有类型的统称。它代表对方法的引用。
定义委托类型
定义委托和定义方法非常像,定义委托时delegate关键字后面实际上就是一个方法签名。它代表这个委托类型可以引用的方法的签名,也就是说这个委托只能引用接受一个Package类型参数、无返回值的方法。
delegate void PackageArrivedEventHandler(Package package);
使用委托
现在我们定义了一个委托类型,委托类型也是类型,要使用一个类型,我们需要声明一个变量然后引用实例。对于委托类型来说,我们就是要通过一个委托对象来引用一个方法。
我们使用一个方法的名称来初始化一个委托。通过赋值运算符可以直接把方法绑定到这个委托上。比如代表顾客的类中定义了两个方法:
public void SignAndAccept(Package package)
{
System.Console.WriteLine($”{Name}已签收,慢走”);
}
public void CheckPackage(Package package)
{
if (package.To != this)
{
System.Console.WriteLine(“送错了!”);
}
// ......
}
那么对于不同的顾客有不同的要求,它们就需要把不同响应方法交给快递员。
现在我们把快递员的PackageArrived方法改成一个委托:
public PackageArrivedEventHandler PackageArrived;
现在创建一些要用的对象。然后让这个委托引用一个方法:
ExpressGuy expressGuy = new ExpressGuy();
Person aqua = new Person(){Name=“Minato Aqua"};
Person suisei = new Person(){Name=“Hoshimachi Suisei"};
Package package = new Package(){From=suisei, To=aqua};
expressGuy.PackageArrived = aqua.SignAndAccept;
回顾上面对SignAndAccept的定义,它接受一个Package类型的参数,不返回值。和快递员中定义的PackageArrivedEventHandler委托的签名是对得上的。要使得一个委托引用一个方法,使用赋值运算符把方法交给它即可。要注意使用一个方法初始化一个委托时,只需要方法名和对应所在的变量名称(对于实例方法)或类型名称(对于静态方法)即可。
一旦一个委托引用了某个方法,我们就可以通过这个委托调用这个方法,就像调用普通的方法一样,使用方法调用运算符,加上必要的参数。值得注意的是,此时我们并不知道我们在哪些对象上调用了哪些方法,我们唯一知道的就是这些方法有一致的签名。这就解决了一开始的问题。
expressGuy.PackageArrived(package);
在我们调用这个了这个委托之后就会输出了“Minato Aqua已签收,慢走”,因为被这个委托引用的aqua的SignAndAccept方法被调用了。
委托和函数指针:很多时候C#中的委托会被拿来和C/C++中的函数指针进行对比。事实上比起其他语言中的函数对象来说,委托确实和函数指针更接近。委托和函数指针一样,只是保存了对函数的引用,不包含和函数本身有关的额外信息。和C++不一样的是,委托引用方法时同时记录了方法所属对象,由于C#不存在类作用域外的函数,所以也不需要C++中那样有单独的“指向成员函数的函数指针”。另一方面由于对类型安全的强调,C#通过委托的形式来给函数指针取了名字。
委托是引用类型
委托也是引用类型。同时它也是可以为null的。一个委托一旦为null就意味着它并没有引用任何方法,因此调用这个委托也是不可能的。我们在调用委托时需要进行null检查。我们可以通过传统的方式使用if检查。
if (expressGuy.PackageArrived != null)
{
expressGuy.PackageArrived(package);
}
也可以使用null条件运算符使用委托类型的Invoke方法进行调用。这种方法是更受提倡的。和普通方法一样使用括号进行调用实际上也只是语法糖。
expressGuy.PackageArrived?.Invoke(package);
多播
委托可以引用一个以上的委托,我们可以称其为多播(multicast)的。 要往一个委托上增加新的方法引用,可以使用+=运算符。比如某位顾客并不想直接签收,她想检查一下再说:
expressGuy.PackageArrived += suisei.CheckPackage;
被引用的方法在委托被调用时会按添加顺序进行执行。 和+=对应的,委托还实现了-=。你可能已经猜到了,和增加方法引用相反,这是移除某个方法的引用。
如何理解委托
委托这个名词实际上确实有点故弄玄虚的感觉,因为它确实只是一种方法的包装,它甚至没有函数指针好理解。某些地方会说委托主要是用于逻辑分离,降低耦合,对于没怎么接触编程的朋友可能不太理解。对于“委托”的含义我们可以从两个角度来理解。
首先,委托把对象分成了两个部分,其一是调用委托的调用方,其二是方法被绑定到委托上的一方。
原本方法只能通过某个对象来调用,现在通过委托,实现方法的一方把调用的权利委托给了另一个定义了委托的一方。也就是说,你可以使用我这个方法,我委托你负责调用。我不能或者不想知道何时被调用。
另一方面对于调用的一方,我知道何时调用,但我不知道如何处理这个事情,并且我知道我能给你提供哪些信息。因此我可以把具体的处理方式委托给其他对象处理,我不需要知道怎么处理,也不想知道。
委托最终把方法(多个方法也称方法组,你会在某些地方看到这种说法)包装成了一个可以到处传递的对象。
实际上C#的委托天生适合实现观察者模式。简单来说,观察者模式中有一个被观察的对象(subject),以及观察对象的观察者(observer)。当被观察的对象的状态发生变化时,所有的观察者的状态也会随之产生相应的变化。这些需求C#的委托可以很好地去实现。
比如说你可以尝试模拟这样的场景:气象中心发布天气预报,气象中心就是被观察的对象。而各行各业都需要天气预报,他们就是观察者,他们可以让发布天气的委托绑定到各自的处理方法上。每当气象中心发布天气信息,各行各业关注天气的观察者也就会做出相应的响应。
事件
现在你负责的快递点不需要傻傻地挨个上门了。你们公司有一个专门的app可以提醒用户快递到了。很显然,这个东西你不会想让随便一个人就能操作,因为无关人员可能会有不法企图,或者误操作。客户可以通过各种手段订阅你的app发送的消息,但是只有你能操作发送这些消息。
如果我们的在一个类中定义了一个委托,那么为了外界能够知晓事件的发生,绑定它们各自的处理方法,我们必须要将委托暴露出来。但是一旦暴露整个委托,不仅是类内部可以调用,外界也可以调用。我们如何限制委托只能被定义它的类型调用呢?
答案是使用C#中的事件(event)。
一言以概之,事件就是一种特殊的委托,它只能在定义它的类型中被调用,外界只能订阅。
由于事件只是一种委托,因此声明事件时实际上首先是声明一个委托类型的对象,随后在前面添加event关键字。这样这个委托就成为了一个事件。
public event PackageArrivedEventHandler PackageArrived;
如果你跟着我的代码示例写了同样的内容,那么这个改动之后就会收到错误提示:The event 'ExpressGuy.PackageArrived' can only appear on the left hand side of += or -= (except when used from within the type 'ExpressGuy')。也就是说这个事件只能出现在+=或者-=左边,除了在ExpressGuy内部。这正是我们想要的效果,只有快递员能发布快递到了的信息,而不是随便一个人都能。
然后对于事件的调用,在声明事件的外部也不再允许直接触发事件(对于事件很多时候更习惯说触发,trigger),只允许在类型内部进行触发,这时我们可以定义一个方法来触发即可:
public void DeliverNewPackage(Package package)
{
PackageArrived(package);
}
通过事件,我们相当于把委托封装了起来,能够更好地隔离事件的发布者(publisher)和订阅者(subscriber),防止事件被错误地、恶意地触发。
C#对事件提供了语言基础层面上的支持方便了很多需要实现观察者模式和事件驱动式编程的地方。但是要知道并不是只有cs才能做到这些。Unreal Engine中实际上也实现了一套委托和事件机制,十分巧妙,并且如果你同时了解cs中的事件和委托和UE中的委托,你会发现其中有异曲同工之妙。Unity中提供了一个SendMessage方法提供名称调用方法,但实际上你完全可以自己通过委托和事件来实现更加高效的事件系统。游戏中经常需要处理各种事件,比如和某个对象发生了碰撞,收到子弹攻击计算伤害,又或是游戏开始游戏结束、解锁成就等等实际上都是触发事件和处理事件的场景。
