C# 中的 Delegate(委托)详解
C# 中的 Delegate(委托)详解
Everett Rain前言
在上一篇介绍 Event 事件系统的文章中,提到了 Event 实际上是基于 Delegate 委托来实现的。但由于篇幅限制,上篇文章并未对委托进行全面且系统的讲解,同时介绍的顺序也可能显得有些晦涩难懂。
因此,本文将从头开始介绍委托,并讲解基于委托的三种匿名方法:Lambda 表达式、Action 和 Func 方法。同时,将会介绍委托可以如何简化复杂的交互代码,并给出一个简单的实现案例。
本教程测试环境
硬件与系统:MacOS Sequoia 15.4.1 MacBook Pro 2024
软件:Jetbrains Rider 2024.3.4、Unity 2022.3.48f1c1
基本的 Delegate 使用
首先我们需要创建一个基本的测试平台,在 Unity 中新建一个场景,创建第一个脚本 TestingDelegates.cs
,并将该脚本绑定在场景中。
本文的测试方法需要使用到 Unity 的基本操作,因此需要继承自 MonoBehaviour
类,书写基本代码:
1 | public class TestingDelegates : MonoBehaviour |
首先,声明一个名为 TestDelegate 的委托类型,该委托类型不返回任何值,也不需要引入任何参数。并实例化一个该委托类型,名为 testDelegate。
你可以将委托理解为一个方法的“标准”(不保证这个词语是最准确的),这个标准包含方法的返回值、需要传入的形参,也就是在创建委托类型时所必须指定的。当你创建了这个委托类型后,任何符合这个标准的方法(具有相同的返回值类型、相同的传入形参类型)都可以被添加到这个委托的实例中。
所以,我们在此处创建一个和委托的“标准”相同的方法 TestDelegateMethod()
,没有返回值,也没有形参。然后把这个方法直接赋值给委托的实例。
现在,这个委托实例就等同于我们所赋的方法,当委托被执行时,实际上就是 TestDelegateMethod
被执行。进入场景,就会发现该方法的 Debug 信息已经被打印出来。
同样,一个委托可以包含多个符合相同“标准”的方法,比如再创建一个方法,并通过 +=
的方式添加到委托实例:
1 | public class TestingDelegates : MonoBehaviour |
再次进入场景,就会发现:虽然 testDelegate 委托只被执行了一次,但两个方法的 Debug 信息都被打印,即添加到委托实例的所有方法都会被执行。
既然可以使用 +=
来添加方法,也就可以使用 -=
来从委托中删除方法:
1 | private void Start() |
这里执行了两次委托,进入场景可以发现,TestDelegateMethod
的 Debug 信息被打印了两次,而 TestDelegateMethod2
的信息只打印了一次。正是因为在执行一次后,我们从委托实例中删除了这个方法,它也就不再会被执行。
和变量的使用方式相同,你也可以直接用 TestDelegateMethod2
覆盖委托内原本的方法:
1 | private void Start() |
这样一来,第一次执行委托时,实际执行的是 TestDelegateMethod
方法,而第二次执行时,则为 TestDelegateMethod2
方法。
同时,既然有 void
类型的委托,那自然有其他类型的委托,现在我们实现一个返回 bool 值的委托,用来测试两个输入的整型变量的大小:
1 | private delegate bool TestBoolDelegate(int i, int j); |
通过将符合相同标准的方法(输入两个整形变量作为形参,输出一个 bool 值)添加到委托中,这里执行了两次委托,分别对两组数进行了比较。当输入的变量符合 a > b
时,委托返回 True,否则为 False。
不难看出,我们在创建委托类型时使用的 i
和 j
完全不会在下面的使用过程中出现,所以我们可以省略形参名称的设定:
1 | private delegate bool TestBoolDelegate(int, int); |
这样声明的委托类型,和上面所声明的类型完全一致。
匿名方法
在 C# 中,匿名方法(Anonymous Method)是一种没有名字的方法,可以在方法代码中直接定义和使用。
我们已经提到过,委托是用于引用与其具有相同标准的方法。换句话说,您可以使用委托对象调用可由委托引用的方法。其提供了一种传递代码块作为委托参数的技术,不需要指定返回类型,它是从方法主体内的 return 语句推断的。
delegate 匿名方法
我们还是先从委托开始,简化一个最基本的委托实现:
1 | public class TestingDelegates : MonoBehaviour |
上面的代码使用传统的“创建委托 - 创建方法 - 赋值方法”的方式来执行委托,匿名方法可以简化这一过程:
1 | public class TestingDelegates : MonoBehaviour |
这样,就不需要先创建一个完整的方法,而是直接将方法中的简单语句接在委托的赋值语句之后,省略“创建方法”的步骤。
基于委托的匿名函数需要三个部分:
1 | testDelegate = delegate (int a) { Debug.Log("Anonymous Method Body") }; |
首先使用 delegate
关键字表明该方法为 delegate 匿名方法,后跟形参部分 ( )
和方法体部分 { }
。具体的实现方法如上面的代码所示,如果没有传入参数,可以去除形参部分,只使用方法体部分声明匿名方法。
当然,这样的声明方式依然可以进行简化,也就是大名鼎鼎的 Lambda 表达式。
Lambda 表达式
Lambda 表达式是一个匿名方法,用它可以高效简化代码,常用作委托,回调。由于所有的 Lambda 表达式都使用运算符 =>
,所以当你见到这个符号,基本上就是一个 Lambda 表达式。
现在我们来使用 Lambda 表达式简化 delegate 匿名方法:
1 | public class TestingDelegates : MonoBehaviour |
基于上面的代码,我们直接将一个 Lambda 表达式赋值给 testDelegate
委托。可以看出,Lambda 表达式不需要使用任何关键字,直接使用运算符 =>
连接形参部分和方法体部分。
同时,在方法体只有一行代码的情况下,可以直接去除方法体的括号,直接写出语句,主打一个简洁:
1 | testDelegate = () => Debug.Log("Lambda Expression"); |
具有返回值的委托也可以使用 Lambda 表达式,在方法体只有一行代码的情况下,也可以直接去除方法体的括号,直接写出语句:
1 | public class TestingDelegates : MonoBehaviour |
在已经知道 testBoolDelegate
委托需要返回一个 bool 类型的变量的前提下,Lambda 表达式 (int i, int j) => i > j
可以直接替代 (int i, int j) => { return i > j; };
实现这个匿名方法。并通过执行委托来执行这个 Lambda 表达式,对输入的两个变量进行大小判断。
为什么叫匿名方法?
前文中提到,匿名方法(Anonymous Method)是一种没有名字的方法,可以在方法代码中直接定义和使用。这意味着,与传统的先给出方法名称再实现形参和方法体的定义方式不同,匿名方法完全没有特定的名称来表示自己。这也就意味着,当你创建两个匿名方法时,你就无法操作其中特定的方法,除非使用委托。
假设你有这样一段代码:
1 | public class TestingDelegates : MonoBehaviour |
代码里有三个不同的 Lambda 表达式,被同时添加到同一个委托中。
当你进行了很多操作、想要在巨大项目的某个角落里将委托的第三个 Lambda 表达式从委托中去除,但又要保持另外两个方法不变,此时,问题就出现了。
从委托中去除指定方法时,必须使用方法的名称,但匿名方法没有名称——这就意味着你可以看到这几个方法,但你完全不能用代码来指定它——这就是匿名方法最大的缺点。
如果此时你一定要去除最后一个方法,就只能重置整个委托,并且重新添加需要的方法。但这在一个巨大的项目中显然是非常恐怖的——添加到委托的匿名方法可能存在于代码的任何一个角落,访问这个委托的代码也可能存在于任何地方。如果其他开发者添加了一个你并不知道的方法,而你碰巧删除了它,那带来的结果可能是灾难性的。
当然,基于委托实现的 Event 事件系统也是相同的道理——使用匿名方法会导致该事件无法被解绑,其原理和上面提到的相同。
因此,在开发工作中,为确保代码整体的可维护性,还是应该尽量减少匿名方法的使用。
Action 与 Func
在介绍 Event 事件系统的文章中,已经对 Action 进行了简单的介绍。在上一篇文章中,是这么介绍 Action 的:
在 C# 中,由于匿名函数缺少显式调用方式、同时委托又需要加以简化,所以 .NET 提供了一种带有名称的委托简化形式。如果需要简化的委托不包含任何返回值,那就可以使用 Action;如果需要简化的委托包含返回值,那就可以使用 Func。
现在我们来举个例子,重新介绍一下 Action:
1 | private Action testAction; |
首先,声明两个 Action 委托:一个不需要传入任何形参,另一个传入一个整型变量和一个浮点变量作为形参。然后在 Start
函数中给两个委托进行赋值、执行。
可以看出,Action 具有委托所包含的全部要素,也简化了委托的定义。在需要临时使用委托(在这类情况下,通常是需要创建回调函数)时,还需要预先定义委托类型显得不太方便,同时也太不灵活。Action 则提供了一种方法,可以直接使用内置的预定义委托类型,直接实例化一个委托供使用。
因此,这类编写创建委托的方法在应用回调函数时使用广泛,上一篇文章也主要讲解了其在回调函数领域的使用。当然,如果创建的委托需要返回值,那 Action 就显得不太够用了,这时就需要用到 Func。
Func<T>
作为另一种内置的预定义委托类型,允许开发者在使用时定义委托的返回值。Func 在声明时必须指定至少一个类型作为返回值,如果指定多个类型,则默认最后一个类型为返回值。我们举个例子:
1 | private Func<bool> testFunc; |
相同的,先声明两个 Func 委托,一个不传入形参且返回一个 bool 类型变量,另一个传入两个整型变量作为形参、且返回一个 bool 类型变量。
在看完了上面的文章后,想必我也不再需要介绍代码的内容和作用了。你可以自行输入代码,运行尝试,在此不再过多赘述。
如何使用委托
什么时候你才算真正掌握了委托?当你知道如何使用委托、而不是如何创建委托的时候。下面我们举两个例子,让你知道什么时候使用委托,可以简化你的代码逻辑。
TIMER 计时器
在不使用委托的情况下,我们可以这样来实现一个计时器:
1 | public class ActionOnTimer : MonoBehaviour |
在这个类中,只实现了计时器的基础倒计时、设置计时时长、检测计时结束,如果需要在其他脚本中调用这个计时器,则需要书写很长一段代码:
1 | class Program : MonoBehaviour |
首先,需要获取到计时器脚本,并设置计时器的计时时间;其次,需要在 Update 方法中处理计时逻辑;为了防止计时结束后持续执行需要执行的方法,还需要为其添加一个标志位,确保方法只会在计时结束时执行一次。
为了一个功能,书写一串如此长的代码,对程序员而言的确是非常不美观的行为。更要命的是,不论计时器是否被激活,这个脚本都会一直判断计时器的相关逻辑——每秒执行 60 次(或更多)的 if
检测,哪怕根本没有计时器在工作,这无疑是相当耗费性能的。
但是我们能不能去除这些冗长且开销巨大的代码?在引入委托之前,显然是不可能去除的:如果需要使用计时器本身的 Update 方法来执行相关功能,就意味着只能执行计时器内部的方法,这是非常不现实的。但如果想在执行特定类功能的同时使用计时器,就必须在类内再实现一次计时器,也就是上面的复杂代码。
说了这么多,解决方案已经很清晰了:如果可以在计时器内部使用外部传入的方法,在计时结束时,执行这个传入的方法,不就可以去除这些冗长的东西了吗? 因此,委托应运而生:
1 | public class ActionOnTimer : MonoBehaviour |
现在,在设置计时时间的同时传入一个 Action 方法,计时结束时,方法会被自动执行。计时的详细内容被完全封装在 ActionOnTimer
类中,其他使用该类的开发者完全不需要知道实现细节,只需要使用 SetTimer
函数即可实现计时功能。
这样一来,在其他脚本中调用就很简单了:
1 | [private ActionOnTimer _actionOnTimer; ] |
通过 Lambda 表达式向计时器中传入一个匿名方法,计时结束后,匿名方法自动执行。原本需要完整实现的计时器功能,现在只需要一行代码即可调用。这就是委托。
基于委托的玩家攻击
假设现在你正在开发游戏角色的攻击系统,角色可以空手击打攻击,也可以使用剑进行挥舞攻击。现在,如何设置不同武器的不同攻击方式之间切换的逻辑?
在委托引入之前,你首先想到的可能是使用枚举。使用枚举定义“空手”和“剑”的武器类型,然后使用 switch
语句根据不同武器执行不同攻击函数。
1 | public class PlayerAttack : MonoBehaviour |
不难发现,HandleAttack
本身就可以用来执行多种攻击操作,而不需要在攻击时进行判断。如果使用这种方式,每次玩家执行攻击,系统都要根据玩家使用的武器进行判断,也是相当规模的开销了。
但与玩家的攻击次数相比,玩家切换武器的次数显得寥寥可数,也许可以在玩家切换武器时进行判断(好像本来就该这样),减少性能开销的同时精简代码。这个时候,委托又出场了:
1 | public class PlayerAttack : MonoBehaviour |
这样一来,玩家每次进行攻击时,只会执行攻击的委托方法,而委托所实际使用的方法会在玩家更换武器时修改,避免了在攻击时大量判断的性能损耗。
总结
在接触到 C# 的委托之前,作者其实花了相当一段时间学习 JavaScripts 这一语言,惊叹于其中的 callback 回调函数设计,并希望能在 Unity C# 开发中有所利用。了解到 C# 的委托后,我非常惊喜地发现:这不就是回调函数吗?
在很长一段时间内,我对于委托的应用也局限于回调的开发,却忽略了这一功能在面向对象编程中其他强大的能力,这也是上一篇文章在介绍委托时,基本只讲解了其在回调方法上的使用,现在看来局限性还是很大。
希望看到这里的读者(如果是通过博客看到这篇文章,大概率对 JS 语言都有所了解)能不仅仅局限于委托的单一功能,而是探索其更多的可能性,实现更多的功能。