C# 中的 Delegate(委托)详解

前言

在上一篇介绍 Event 事件系统的文章中,提到了 Event 实际上是基于 Delegate 委托来实现的。但由于篇幅限制,上篇文章并未对委托进行全面且系统的讲解,同时介绍的顺序也可能显得有些晦涩难懂。

因此,本文将从头开始介绍委托,并讲解基于委托的三种匿名方法:Lambda 表达式、Action 和 Func 方法。同时,将会介绍委托可以如何简化复杂的交互代码,并给出一个简单的实现案例。

本教程测试环境

硬件与系统:MacOS Sequoia 15.4.1 MacBook Pro 2024

软件:Jetbrains Rider 2024.3.4、Unity 2022.3.48f1c1


基本的 Delegate 使用

虽然本文所讲的内容是上一篇文章 Event 系统的前提知识,但由于 Unity 的基本操作和 MonoBehaviour 类的介绍已经在前文中进行过详细叙述,因此不在本文中重新讲解。如果你完全不了解 Unity 的基本操作,建议先阅读上一篇介绍 Event 系统的文章。

首先我们需要创建一个基本的测试平台,在 Unity 中新建一个场景,创建第一个脚本 TestingDelegates.cs,并将该脚本绑定在场景中。

本文的测试方法需要使用到 Unity 的基本操作,因此需要继承自 MonoBehaviour 类,书写基本代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class TestingDelegates : MonoBehaviour
{
private delegate void TestDelegate();
private TestDelegate testDelegate;

private void Start()
{
testDelegate = TestDelegateMethod;
testDelegate();
}

private void TestDelegateMethod()
{
Debug.Log("TestDelegateMethod");
}
}

首先,声明一个名为 TestDelegate 的委托类型,该委托类型不返回任何值,也不需要引入任何参数。并实例化一个该委托类型,名为 testDelegate。

你可以将委托理解为一个方法的“标准”(不保证这个词语是最准确的),这个标准包含方法的返回值、需要传入的形参,也就是在创建委托类型时所必须指定的。当你创建了这个委托类型后,任何符合这个标准的方法(具有相同的返回值类型、相同的传入形参类型)都可以被添加到这个委托的实例中。

所以,我们在此处创建一个和委托的“标准”相同的方法 TestDelegateMethod(),没有返回值,也没有形参。然后把这个方法直接赋值给委托的实例。

现在,这个委托实例就等同于我们所赋的方法,当委托被执行时,实际上就是 TestDelegateMethod 被执行。进入场景,就会发现该方法的 Debug 信息已经被打印出来。

同样,一个委托可以包含多个符合相同“标准”的方法,比如再创建一个方法,并通过 += 的方式添加到委托实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class TestingDelegates : MonoBehaviour
{
private delegate void TestDelegate();
private TestDelegate testDelegate;

private void Start()
{
testDelegate = TestDelegateMethod;
testDelegate += TestDelegateMethod2;
testDelegate();
}

private void TestDelegateMethod()
{
Debug.Log("TestDelegateMethod");
}

private void TestDelegateMethod2()
{
Debug.Log("TestDelegateMethod2");
}
}

再次进入场景,就会发现:虽然 testDelegate 委托只被执行了一次,但两个方法的 Debug 信息都被打印,即添加到委托实例的所有方法都会被执行。

既然可以使用 += 来添加方法,也就可以使用 -= 来从委托中删除方法:

1
2
3
4
5
6
7
8
9
private void Start()
{
testDelegate = TestDelegateMethod;
testDelegate += TestDelegateMethod2;
testDelegate();

testDelegate -= TestDelegateMethod2;
testDelegate();
}

这里执行了两次委托,进入场景可以发现,TestDelegateMethod 的 Debug 信息被打印了两次,而 TestDelegateMethod2 的信息只打印了一次。正是因为在执行一次后,我们从委托实例中删除了这个方法,它也就不再会被执行。

和变量的使用方式相同,你也可以直接用 TestDelegateMethod2 覆盖委托内原本的方法:

1
2
3
4
5
6
7
8
private void Start()
{
testDelegate = TestDelegateMethod;
testDelegate();

testDelegate = TestDelegateMethod2;
testDelegate();
}

这样一来,第一次执行委托时,实际执行的是 TestDelegateMethod 方法,而第二次执行时,则为 TestDelegateMethod2 方法。

同时,既然有 void 类型的委托,那自然有其他类型的委托,现在我们实现一个返回 bool 值的委托,用来测试两个输入的整型变量的大小:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private delegate bool TestBoolDelegate(int i, int j);
private TestBoolDelegate testBoolDelegate;

private void Start()
{
testBoolDelegate = IsALargerThanB;
Debug.Log(testBoolDelegate(1, 4)); // 返回 False
Debug.Log(testBoolDelegate(8, 5)); // 返回 True
}

private bool IsALargerThanB(int a, int b)
{
return a > b;
}

通过将符合相同标准的方法(输入两个整形变量作为形参,输出一个 bool 值)添加到委托中,这里执行了两次委托,分别对两组数进行了比较。当输入的变量符合 a > b 时,委托返回 True,否则为 False。

不难看出,我们在创建委托类型时使用的 ij 完全不会在下面的使用过程中出现,所以我们可以省略形参名称的设定:

1
private delegate bool TestBoolDelegate(int, int);

这样声明的委托类型,和上面所声明的类型完全一致。

匿名方法

在 C# 中,匿名方法(Anonymous Method)是一种没有名字的方法,可以在方法代码中直接定义和使用。

我们已经提到过,委托是用于引用与其具有相同标准的方法。换句话说,您可以使用委托对象调用可由委托引用的方法。其提供了一种传递代码块作为委托参数的技术,不需要指定返回类型,它是从方法主体内的 return 语句推断的。

delegate 匿名方法

我们还是先从委托开始,简化一个最基本的委托实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class TestingDelegates : MonoBehaviour
{
private delegate void TestDelegate();
private TestDelegate testDelegate;

private void Start()
{
testDelegate = TestDelegateMethod;
testDelegate();
}

private void TestDelegateMethod()
{
Debug.Log("Just a normal Delegate Method");
}
}

上面的代码使用传统的“创建委托 - 创建方法 - 赋值方法”的方式来执行委托,匿名方法可以简化这一过程:

1
2
3
4
5
6
7
8
9
10
11
public class TestingDelegates : MonoBehaviour
{
private delegate void TestDelegate();
private TestDelegate testDelegate;

private void Start()
{
testDelegate = delegate () { Debug.Log("Anonymous Method"); };
testDelegate();
}
}

这样,就不需要先创建一个完整的方法,而是直接将方法中的简单语句接在委托的赋值语句之后,省略“创建方法”的步骤。

基于委托的匿名函数需要三个部分:

1
testDelegate = delegate (int a) { Debug.Log("Anonymous Method Body") };

首先使用 delegate 关键字表明该方法为 delegate 匿名方法,后跟形参部分 ( ) 和方法体部分 { }。具体的实现方法如上面的代码所示,如果没有传入参数,可以去除形参部分,只使用方法体部分声明匿名方法。

当然,这样的声明方式依然可以进行简化,也就是大名鼎鼎的 Lambda 表达式。

Lambda 表达式

Lambda 表达式是一个匿名方法,用它可以高效简化代码,常用作委托,回调。由于所有的 Lambda 表达式都使用运算符 =>,所以当你见到这个符号,基本上就是一个 Lambda 表达式。

现在我们来使用 Lambda 表达式简化 delegate 匿名方法:

1
2
3
4
5
6
7
8
9
10
11
12
public class TestingDelegates : MonoBehaviour
{
private delegate void TestDelegate();
private TestDelegate testDelegate;

private void Start()
{
testDelegate = delegate () { Debug.Log("Anonymous Method"); };
testDelegate = () => { Debug.Log("Lambda Expression"); };
testDelegate();
}
}

基于上面的代码,我们直接将一个 Lambda 表达式赋值给 testDelegate 委托。可以看出,Lambda 表达式不需要使用任何关键字,直接使用运算符 => 连接形参部分和方法体部分。

同时,在方法体只有一行代码的情况下,可以直接去除方法体的括号,直接写出语句,主打一个简洁:

1
testDelegate = () => Debug.Log("Lambda Expression");

具有返回值的委托也可以使用 Lambda 表达式,在方法体只有一行代码的情况下,也可以直接去除方法体的括号,直接写出语句:

1
2
3
4
5
6
7
8
9
10
11
public class TestingDelegates : MonoBehaviour
{
private delegate bool TestBoolDelegate(int i, int j);
private TestBoolDelegate testBoolDelegate;

private void Start()
{
testBoolDelegate = (int i, int j) => i > j;
Debug.Log(testBoolDelegate(5, 3));
}
}

在已经知道 testBoolDelegate 委托需要返回一个 bool 类型的变量的前提下,Lambda 表达式 (int i, int j) => i > j 可以直接替代 (int i, int j) => { return i > j; }; 实现这个匿名方法。并通过执行委托来执行这个 Lambda 表达式,对输入的两个变量进行大小判断。

为什么叫匿名方法?

前文中提到,匿名方法(Anonymous Method)是一种没有名字的方法,可以在方法代码中直接定义和使用。这意味着,与传统的先给出方法名称再实现形参和方法体的定义方式不同,匿名方法完全没有特定的名称来表示自己。这也就意味着,当你创建两个匿名方法时,你就无法操作其中特定的方法,除非使用委托。

假设你有这样一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class TestingDelegates : MonoBehaviour
{
private delegate void TestDelegate();
private TestDelegate testDelegate;

private void Start()
{
testDelegate += () => { Debug.Log("Lambda Expression"); };
testDelegate += () => { Debug.Log("Second Lambda Expression"); };
testDelegate += () => { Debug.Log("Third Lambda Expression"); };
testDelegate();
}
}

代码里有三个不同的 Lambda 表达式,被同时添加到同一个委托中。

当你进行了很多操作、想要在巨大项目的某个角落里将委托的第三个 Lambda 表达式从委托中去除,但又要保持另外两个方法不变,此时,问题就出现了。

从委托中去除指定方法时,必须使用方法的名称,但匿名方法没有名称——这就意味着你可以看到这几个方法,但你完全不能用代码来指定它——这就是匿名方法最大的缺点。

如果此时你一定要去除最后一个方法,就只能重置整个委托,并且重新添加需要的方法。但这在一个巨大的项目中显然是非常恐怖的——添加到委托的匿名方法可能存在于代码的任何一个角落,访问这个委托的代码也可能存在于任何地方。如果其他开发者添加了一个你并不知道的方法,而你碰巧删除了它,那带来的结果可能是灾难性的。

当然,基于委托实现的 Event 事件系统也是相同的道理——使用匿名方法会导致该事件无法被解绑,其原理和上面提到的相同。

因此,在开发工作中,为确保代码整体的可维护性,还是应该尽量减少匿名方法的使用。

Action 与 Func

在介绍 Event 事件系统的文章中,已经对 Action 进行了简单的介绍。在上一篇文章中,是这么介绍 Action 的:

Action<T> 是 .NET 框架提供的预定义委托类型之一,用于简化无需返回值的方法引用或匿名方法的使用。

在 C# 中,由于匿名函数缺少显式调用方式、同时委托又需要加以简化,所以 .NET 提供了一种带有名称的委托简化形式。如果需要简化的委托不包含任何返回值,那就可以使用 Action;如果需要简化的委托包含返回值,那就可以使用 Func

现在我们来举个例子,重新介绍一下 Action:

1
2
3
4
5
6
7
8
9
10
private Action testAction;
private Action<int, float> testIntFloatAction;

private void Start()
{
testAction = () => { Debug.Log("Hello Action"); };
testIntFloatAction = (int i, float f) => { Debug.Log($"Int: {i}, Float: {f}"); };
testAction();
testIntFloatAction(1, 5.6f);
}

首先,声明两个 Action 委托:一个不需要传入任何形参,另一个传入一个整型变量和一个浮点变量作为形参。然后在 Start 函数中给两个委托进行赋值、执行。

可以看出,Action 具有委托所包含的全部要素,也简化了委托的定义。在需要临时使用委托(在这类情况下,通常是需要创建回调函数)时,还需要预先定义委托类型显得不太方便,同时也太不灵活。Action 则提供了一种方法,可以直接使用内置的预定义委托类型,直接实例化一个委托供使用。

因此,这类编写创建委托的方法在应用回调函数时使用广泛,上一篇文章也主要讲解了其在回调函数领域的使用。当然,如果创建的委托需要返回值,那 Action 就显得不太够用了,这时就需要用到 Func

Func<T> 作为另一种内置的预定义委托类型,允许开发者在使用时定义委托的返回值。Func 在声明时必须指定至少一个类型作为返回值,如果指定多个类型,则默认最后一个类型为返回值。我们举个例子:

1
2
3
4
5
6
7
8
9
10
11
private Func<bool> testFunc;
private Func<int, int, bool> testIntBoolFunc;

private void Start()
{
testFunc = () => true;
Debug.Log(testFunc());

testIntBoolFunc = (int a, int b) => a > b;
Debug.Log(testIntBoolFunc(1, 4));
}

相同的,先声明两个 Func 委托,一个不传入形参且返回一个 bool 类型变量,另一个传入两个整型变量作为形参、且返回一个 bool 类型变量。

在看完了上面的文章后,想必我也不再需要介绍代码的内容和作用了。你可以自行输入代码,运行尝试,在此不再过多赘述。

如何使用委托

Finally someone who explains how to use delegates, not how to create the delegates. - @_Garm_ from YouTube

什么时候你才算真正掌握了委托?当你知道如何使用委托、而不是如何创建委托的时候。下面我们举两个例子,让你知道什么时候使用委托,可以简化你的代码逻辑。

TIMER 计时器

在不使用委托的情况下,我们可以这样来实现一个计时器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ActionOnTimer : MonoBehaviour
{
private float timer;

private void Update()
{
timer -= Time.deltaTime;
}

public void SetTimer(float time)
{
timer = time;
}

public bool IsTimerComplete()
{
return timer <= 0f;
}
}

在这个类中,只实现了计时器的基础倒计时、设置计时时长、检测计时结束,如果需要在其他脚本中调用这个计时器,则需要书写很长一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Program : MonoBehaviour
{
[SerializeField] private ActionOnTimer _actionOnTimer;
private bool hasTimerElapsed;

private void Start()
{
_actionOnTimer.SetTimer(1f);
}

private void Update()
{
if (!hasTimerElapsed && _actionOnTimer.IsTimerComplete())
{
Debug.Log("Timer Complete!");
hasTimerElapsed = true;
}
}
}

首先,需要获取到计时器脚本,并设置计时器的计时时间;其次,需要在 Update 方法中处理计时逻辑;为了防止计时结束后持续执行需要执行的方法,还需要为其添加一个标志位,确保方法只会在计时结束时执行一次。

为了一个功能,书写一串如此长的代码,对程序员而言的确是非常不美观的行为。更要命的是,不论计时器是否被激活,这个脚本都会一直判断计时器的相关逻辑——每秒执行 60 次(或更多)的 if 检测,哪怕根本没有计时器在工作,这无疑是相当耗费性能的。

但是我们能不能去除这些冗长且开销巨大的代码?在引入委托之前,显然是不可能去除的:如果需要使用计时器本身的 Update 方法来执行相关功能,就意味着只能执行计时器内部的方法,这是非常不现实的。但如果想在执行特定类功能的同时使用计时器,就必须在类内再实现一次计时器,也就是上面的复杂代码。

说了这么多,解决方案已经很清晰了:如果可以在计时器内部使用外部传入的方法,在计时结束时,执行这个传入的方法,不就可以去除这些冗长的东西了吗? 因此,委托应运而生:

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
public class ActionOnTimer : MonoBehaviour
{
private float timer;
private Action timerCallback;

public bool IsTimerComplete()
{
return timer <= 0f;
}

public void SetTimer(float time, Action callback)
{
timer = time;
timerCallback = callback;
}

private void Update()
{
if (timer > 0)
{
timer -= Time.deltaTime;

if (IsTimerComplete())
{
timerCallback();
}
}
}
}

现在,在设置计时时间的同时传入一个 Action 方法,计时结束时,方法会被自动执行。计时的详细内容被完全封装在 ActionOnTimer 类中,其他使用该类的开发者完全不需要知道实现细节,只需要使用 SetTimer 函数即可实现计时功能。

这样一来,在其他脚本中调用就很简单了:

1
2
3
4
5
6
[SerializeField] private ActionOnTimer _actionOnTimer;

private void Start()
{
_actionOnTimer.SetTimer(1f, () => { Debug.Log("Timer Complete!"); });
}

通过 Lambda 表达式向计时器中传入一个匿名方法,计时结束后,匿名方法自动执行。原本需要完整实现的计时器功能,现在只需要一行代码即可调用。这就是委托。

基于委托的玩家攻击

假设现在你正在开发游戏角色的攻击系统,角色可以空手击打攻击,也可以使用剑进行挥舞攻击。现在,如何设置不同武器的不同攻击方式之间切换的逻辑?

在委托引入之前,你首先想到的可能是使用枚举。使用枚举定义“空手”和“剑”的武器类型,然后使用 switch 语句根据不同武器执行不同攻击函数。

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
public class PlayerAttack : MonoBehaviour
{
private Weapon currentWeapon;

private enum Weapon
{
Hand,
Sword
}

private void Start()
{
currentWeapon = Weapon.Hand; // 设置一开始武器为空手
}

private void Update()
{
HandleAttack();

if (Input.GetKeyDown(KeyCode.M))
{
SetUseSword(); // 按下 M 键时切换武器为剑
}
}

private void SetUseSword()
{
currentWeapon = Weapon.Sword;
}

private void HandleAttack()
{
if (Input.GetMouseButtonDown(0))
{
switch (currentWeapon) // 根据武器判断攻击方式
{
case Weapon.Hand:
HandAttack(); // 伪代码:进行空手攻击
break;
case Weapon.Sword:
SwordAttack(); // 伪代码:进行挥舞攻击
break;
}
}
}
}

不难发现,HandleAttack 本身就可以用来执行多种攻击操作,而不需要在攻击时进行判断。如果使用这种方式,每次玩家执行攻击,系统都要根据玩家使用的武器进行判断,也是相当规模的开销了。

但与玩家的攻击次数相比,玩家切换武器的次数显得寥寥可数,也许可以在玩家切换武器时进行判断(好像本来就该这样),减少性能开销的同时精简代码。这个时候,委托又出场了:

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
public class PlayerAttack : MonoBehaviour
{
private Action attackMethod;

private void Start()
{
attackMethod = HandAttack; // 设置一开始的攻击 delegate 为空手攻击
}

private void Update()
{
HandleAttack();

if (Input.GetKeyDown(KeyCode.M))
{
SetUseSword(); // 将攻击 delegate 切换为挥舞攻击
}
}

private void HandleAttack()
{
if (Input.GetMouseButtonDown(0))
{
attackMethod(); // 执行攻击 delegate
}
}

private void SetUseSword()
{
attackMethod = SwordAttack;
}
}

这样一来,玩家每次进行攻击时,只会执行攻击的委托方法,而委托所实际使用的方法会在玩家更换武器时修改,避免了在攻击时大量判断的性能损耗。

总结

在接触到 C# 的委托之前,作者其实花了相当一段时间学习 JavaScripts 这一语言,惊叹于其中的 callback 回调函数设计,并希望能在 Unity C# 开发中有所利用。了解到 C# 的委托后,我非常惊喜地发现:这不就是回调函数吗?

在很长一段时间内,我对于委托的应用也局限于回调的开发,却忽略了这一功能在面向对象编程中其他强大的能力,这也是上一篇文章在介绍委托时,基本只讲解了其在回调方法上的使用,现在看来局限性还是很大。

希望看到这里的读者(如果是通过博客看到这篇文章,大概率对 JS 语言都有所了解)能不仅仅局限于委托的单一功能,而是探索其更多的可能性,实现更多的功能。