前言

本篇博客是为了给下一篇博客:UE 中的委托机制,铺路的。起因是在学习 ue 的过程中发现 ue 使用委托这个概念的频率与深度都比 unity 中要高得多,因此在这里总结一下学习 unity 期间对于委托的理解。

C# 中的委托与事件

C# 委托 ( delegate )

C# 中的委托(Delegate)类似于 C 或 C++ 中函数的指针。委托(Delegate) 是存有对某个方法的引用的一种引用类型变量。引用可在运行时被改变。

委托(Delegate)特别用于实现事件和回调方法。所有的委托(Delegate)都派生自 System.Delegate 类。

  1. 声明委托

委托声明决定了可由该委托引用的方法。委托可指向一个与其具有相同标签的方法。

例如,假设有一个委托:

1
public delegate int MyDelegate (string s);

上面的委托可被用于引用任何一个带有一个单一的 string 参数的方法,并返回一个 int 类型变量。

声明委托的语法如下:

1
delegate <return type> <delegate-name> <parameter list>
  1. 实例化委托

一旦声明了委托类型,委托对象必须使用 new 关键字来创建,且与一个特定的方法有关。当创建委托时,传递到 new 语句的参数就像方法调用一样书写,但是不带有参数。例如:

1
2
3
4
public delegate void printString(string s);
...
printString ps1 = new printString(WriteToScreen);
printString ps2 = new printString(WriteToFile);
  1. 委托的多播

委托对象可使用 “+” 运算符进行合并。一个合并委托调用它所合并的两个委托。只有相同类型的委托可被合并。“-” 运算符可用于从合并的委托中移除组件委托。

使用委托的这个有用的特点,您可以创建一个委托被调用时要调用的方法的调用列表。这被称为委托的 多播(multicasting),也叫组播。

为什么 C# 会有委托?

解决观察者模式的缺陷。这里又引申出一个问题,什么是观察者模式,观察者模式的缺陷又是什么?在回答这个问题前,我们其实已经知道一个答案,那就是先有设计模式中的观察者模式,再有委托事件这一技术。它有一个前后顺序,如果你要彻底学会委托,那么就要先学会观察者模式,还要了解观察者模式的缺陷。

观察者模式的缺陷

观察者模式是对主题对象和观察者对象进行解耦,使双方都依赖与抽象,而不是依赖于对方的具体对象,使双方的变化都不会影响到对方的具体对象。当多个对象需要根据一个对象的状态发生相应的改变或操作时,可使用观察者模式。

这里我写一个小 demo,非常简洁。还是猫和老鼠,猫是主题对象,人和老鼠是观察者。猫叫的时候,人和老鼠要执行相应操作。

使用观察者模式

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ObserverCatDemo : MonoBehaviour
{
void Start()
{
CatSubject cat = new CatSubject();
Mouse mouse = new Mouse();
Person person = new Person();
cat.Add(mouse);
cat.Add(person);
cat.Call();
}
}

// 抽象类,代表被观察者
public abstract class Subjectss
{
// 添加观察者
public abstract void Add(Observerss observer);
// 移除观察者
public abstract void Remove(Observerss observer);
// 通知观察者
public abstract void Call();
}

// 抽象类,代表观察者
public abstract class Observerss
{
// 被通知后状态更新
public abstract void Update();
}

// 猫,这里作为被观察者
public class CatSubject : Subjectss
{
public IList<Observerss> Observers = new List<Observerss>();
public override void Add(Observerss observer)
{
Observers.Add(observer);
}
public override void Remove(Observerss observer)
{
Observers.Remove(observer);
}
// 重写 call 方法,遍历并通知所有的观察者
public override void Call()
{
foreach (Observerss observer in Observers)
{
observer.Update();
}
}
}

// 老鼠,观察者
public class Mouse : Observerss
{
public override void Update()
{
Debug.Log("猫叫了,我得跑了");
}
}

// 人,观察者
public class Person : Observerss
{
public override void Update()
{
Debug.Log("猫叫了,应该是饿了");
}
}

使用委托

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
42
43
44
45
using System;
using System.Collections.Generic;
using UnityEngine;

public class DelegateCat:MonoBehaviour
{
private void Start()
{
DeCat deCat = new DeCat();
DePerson person = new DePerson();
DeMouse mouse = new DeMouse();

// 将方法注册进委托中
deCat.call += person.Notify;
deCat.call += mouse.Run;

// 委托发布,自动遍历注册的所有方法
deCat.call();
}
}

// 声明委托类型:参数与返回值均为空的类型
public delegate void CallEvent();

public class DeCat
{
// 声明了一个 CallEvent 类型的委托
public CallEvent call;
}

public class DePerson
{
public void Notify()
{
Debug.Log("猫叫了,应该是饿了");
}
}

public class DeMouse
{
public void Run()
{
Debug.Log("猫叫了,我得跑了!");
}
}

对比

做一下对比可以发现:

1.实现同样的功能,使用委托非常的简洁,并且主题猫的类里没有任何观察者类成员,两者是完全独立的。而观察者模式双方并没有完全的独立,抽象主题通知时依然依赖于抽象观察者。

2.观察者不需要继承观察者接口,它的通知方法名可以不同,比如说人的执行方法是 Notify(),猫的执行方法是 Run(),现实中本就是这样的,方法名本就不一定相同。

也就是说观察者模式的缺陷为:

1.抽象主题依旧依赖于抽象观察者。

2.具体的观察者,通知方法被固定了。

观察者模式存在的意义就是解耦,它使观察者和被观察者的逻辑不再搅在一起。而是彼此独立,互不依赖。而使用委托则能使得观察者和主题完全解耦,甚至不需要知道对方的存在。

事件 ( event )

一言以蔽之,事件就是被 event 关键字修饰过的委托类型。对委托的例子做一个小小的变动:

1
2
3
4
public class DeCat
{
public event CallEvent call;
}

没错我们给声明前面加个 event 就可以了,但是你突然发现,编译不过去,有报错。这也是事件与委托核心的区别。

事件是类或对象向其他类或对象通知发生的一种特殊签名的委托。它的本意是对委托的封装,它在外部只能被订阅或取消订阅,但是不能发布。一句话总结:事件只能在外部被订阅,但是不能在外部被触发。只能通过内部的公开方法,在方法内部触发事件,这样可以使得程序更加的安全。

这里我通过的例子来说明:当使用委托变量时,客户端可以直接通过委托变量触发事件,也就是直接调用 deCat.call();这将会影响到所有注册了该委托的变量,而事件的本意应该为在事件发布者在其本身的某个行为中触发。通过添加 event 关键字来发布事件,事件发布者的封装性会更好,事件仅仅是供其他类型订阅,而客户端不能直接触发事件(语句 deCat.call() 无法通过编译),事件只能通过事件发布者 DeCat 类的内部触发(比如在方法 deCat.CallMethod() 中)。换言之,就是 call() 语句只能在 deCat 内部被调用。这样才是事件的本意,事件发布者的封装才会更好。

Action & Func & Predicate

写完 STL 现在看到 predicate 就 PTSD 了有木有。

C# 内置了几种泛型的委托,使用起来非常方便,分别是 ActionFuncPredicate

Action

Action 是无返回值的泛型委托,通过函数重载提供了 0 ~ 16 个参数不同类型的 Action

  • Action 的定义
1
2
3
4
public delegate void Action();                               // 无参数
public delegate void Action<in T>(T obj); // 1个参数
public delegate void Action<in T1, in T2>(T1 arg1, T2 arg2); // 2个参数
// ...... 最多16个参数
  • Action 的使用举例
1
2
3
4
5
6
// 使用举例
Action action0 = () => { };
Action<int> action1 = (a) => { };
Action<int, float> action2 = (a, b) => { };
Action<int, int, double> action2 = (a, b, c) => { };
// ......

Func

Func 是有返回值的泛型委托,通过函数重载提供了 0 ~ 16 个参数不同类型的 Func

  • Func 的定义
1
2
3
4
public delegate TResult Func<out TResult>(); // 无参数
public delegate TResult Func<in T, out TResult>(T arg); // 1个参数
public delegate TResult Func<in T1, in T2, out TResult>(T1 arg1, T2 arg2); // 2个参数
// ...... 最多16个参数(不包括TResult)
  • Func 的使用举例
1
2
3
4
5
// 使用举例
Func<int> func0 = () => { return 0; };
Func<int, int> func1 = (a) => { return a; };
Func<int, int, int> func2 = (a, b) => { return a + b; };
// ......

Predicate

Func 是返回值类型为 bool 型的泛型委托,只能传递一个参数,因此没有其他的重载。

1
2
3
4
5
// 只有一个泛型委托,没有多参数重载
public delegate bool Predicate<in T>(T obj);

// 使用举例
Predicate<int> p = (a) => { return a > 0; };

参考资料