匹夫细说C#:庖丁解牛聊委托,那些编译器藏的和U3D的那些事

2015年08月12日 14:49 0 点赞 0 评论 更新于 2025-11-21 16:38

作者:慕容小匹夫

原文:http://www.taidous.com/forum.php?mod=viewthread&tid=28167

0x00 前言

由于工作繁忙,距离上一篇博客已经过去一个多月了。因此,我决心这个周末无论如何都要写点东西,既是总结也是分享。本文主要聚焦于委托的使用及其内部结构(当然也会涉及事件,但因篇幅限制,将分为两篇文章),并结合一部分Unity3D的设计思考。由于时间仓促,文中难免存在疏漏和不准确之处,欢迎各位指出,共同进步。

0x01 从观察者模式说起

在设计模式中,观察者模式是我们常用的一种。它与“如何在Unity3D中使用委托”有什么关系呢?下面先详细介绍观察者模式。

报纸和杂志订阅的例子

报社的主要任务是出版报纸。当你向某家报社订阅报纸后,只要有新报纸出版,报社就会给你发放。如果你不想再订阅,取消订阅后,报社便不会再送新报纸。报社和订阅者是两个不同的主体,只要报社存在,不同的订阅者都可以随时订阅或取消订阅。

观察者模式的概念

理解了报纸和杂志的订阅机制,也就基本掌握了观察者模式。在观察者模式中,报社或出版者被称为“主题”(Subject),订阅者被称为“观察者”(Observer)。主题对象管理某些数据,当主题内的数据发生改变时,会通知已经订阅(注册)的观察者,观察者会收到通知并更新,未注册的对象则不会收到通知。

观察者模式的准确定义是:定义了对象之间的一对多依赖关系,当一个对象改变状态时,它的所有依赖者都会收到通知并自动更新。

委托与观察者模式的联系

C#语言通过委托来实现回调函数的机制,回调函数在观察者模式中应用广泛。那么,Unity3D本身是否提供了类似机制?它与委托又有什么区别呢?接下来将详细探讨。

0x02 向Unity3D中的SendMessage和BroadcastMessage说拜拜

不可否认,Unity3D游戏引擎为游戏开发者带来了诸多便利,但它的游戏脚本架构存在一些缺陷,其中围绕SendMessage和BroadcastMessage构建的消息系统问题较为突出。

依赖反射机制带来的性能和维护问题

SendMessage和BroadcastMessage过于依赖反射机制(reflection)来查找消息对应的回调函数。频繁使用反射会影响性能,但更严重的是代码的维护成本。例如,开发团队重构代码时,如果涉及被这些消息调用的方法定义,方法被重命名或删除可能不会引发编译时错误,编译器不会提醒开发者某些方法已不存在。只有在触发特定消息却找不到对应方法时,问题才会暴露,但此时往往为时已晚。

调用私有方法带来的潜在问题

由于使用了反射机制,Unity3D的这套消息系统能够调用声明为私有的方法。如果一个私有方法在声明类的内部未被使用,开发者通常会认为这是废代码并选择删除。然而,删除后可能在编译和程序运行初期都正常,但触发特定消息时,若没有对应方法,隐患就会爆发。

因此,我们应该摒弃Unity3D中的SendMessage和BroadcastMessage,选择C#的委托来实现自己的消息机制。

0x03 认识回调函数机制——委托

在非托管代码C/C++中,也存在类似的回调机制,但非成员函数的地址只是一个内存地址,不携带函数的参数个数、参数类型和返回值类型等额外信息,因此不是类型安全的。而C#中的委托是一种类型安全的回调函数机制。

委托的直观示例

以下是一段展示委托使用的代码:

using UnityEngine;
using System.Collections;

public class DelegateScript : MonoBehaviour
{
// 声明一个委托类型,它的实例引用一个方法
internal delegate void MyDelegate(int num);
MyDelegate myDelegate;

void Start ()
{
// 委托类型MyDelegate的实例myDelegate引用的方法是PrintNum
myDelegate = PrintNum;
myDelegate(50);
// 委托类型MyDelegate的实例myDelegate引用的方法是DoubleNum
myDelegate = DoubleNum;
myDelegate(50);
}

void PrintNum(int num)
{
Debug.Log ("Print Num: " + num);
}

void DoubleNum(int num)
{
Debug.Log ("Double Num: " + num * 2);
}
}

在这段代码中,首先声明了一个委托类型MyDelegate,它的实例可以引用一个参数类型为int、返回类型为void的方法。在Start方法中,myDelegate分别引用了PrintNumDoubleNum方法,并调用了它们。

方法组转换机制

在C#2之前,创建委托实例需要同时指定委托类型和要调用的方法,代码会显得冗长。例如:

new MyDelegate(PrintNum);

在启动新线程时,代码会更复杂:

Thread th = new Thread(new ThreadStart(Method));

C#2引入了方法组转换机制,支持从方法到兼容的委托类型的隐式转换,使代码更简洁:

myDelegate = PrintNum;
Thread th = new Thread(Method);

由于重载的存在,可能有多个方法适用,编译器会根据委托类型选择合适的重载版本。例如:

using UnityEngine;
using System.Collections;

public class DelegateScript : MonoBehaviour
{
// 声明一个委托类型,它的实例引用一个方法
delegate void MyDelegate(int num);
// 声明一个委托类型,它的实例引用一个方法
delegate void MyDelegate2(int num, int num2);
MyDelegate myDelegate;
MyDelegate2 myDelegate2;

void Start ()
{
// 委托类型MyDelegate的实例myDelegate引用的方法是PrintNum
myDelegate = PrintNum;
myDelegate(50);
// 委托类型MyDelegate2的实例myDelegate2引用的方法是PrintNum的重载版本
myDelegate2 = PrintNum;
myDelegate2(50, 50);
}

void PrintNum(int num)
{
Debug.Log ("Print Num: " + num);
}

void PrintNum(int num1, int num2)
{
int result = num1 + num2;
Debug.Log ("result num is : " + result);
}
}

委托的协变性和逆变性

在为委托实例引用方法时,C#允许引用类型的协变性和逆变性。协变性指方法的返回类型可以是从委托的返回类型派生的派生类;逆变性指方法获取的参数类型可以是委托的参数类型的基类。

例如,项目中有基础单位类(BaseUnitClass)、士兵类(SoldierClass)和英雄类(HeroClass),其中BaseUnitClass是基类,派生出了SoldierClass和HeroClass。可以定义如下委托:

delegate Object TellMeYourName(SoldierClass soldier);

可以通过构造该委托类型的实例来引用具有以下原型的方法:

string TellMeYourNameMethod(BaseUnitClass base);

需要注意的是,协变性和逆变性仅支持引用类型,值类型或void不支持。例如,将TellMeYourNameMethod方法的返回类型改为int

int TellMeYourNameMethod(BaseUnitClass base);

此时将该方法绑定到委托实例上,编译器会报错。

0x04 委托是如何实现的

下面通过一段代码来分析委托的实现:

internal delegate void MyDelegate(int number);

MyDelegate myDelegate = new MyDelegate(myMethod1);
myDelegate = myMethod2;
myDelegate(10);

从表面看,委托的使用很简单,但编译器和Mono运行时在幕后做了大量工作。

编译器生成的委托类

使用Refactor反编译C#程序,可以看到编译器为我们定义了一个完整的类MyDelegate

internal class MyDelegate : System.MulticastDelegate
{
// 构造器
[MethodImpl(0, MethodCodeType = MethodCodeType.Runtime)]
public MyDelegate(object @object, IntPtr method);
// Invoke这个方法的原型和源代码指定的一样
[MethodImpl(0, MethodCodeType = MethodCodeType.Runtime)]
public virtual void Invoke(int number);

// 以下的两个方法实现对绑定的回调函数的一步回调
[MethodImpl(0, MethodCodeType = MethodCodeType.Runtime)]
public virtual IAsyncResult BeginInvoke(int number, AsyncCallback callback, object @object);
[MethodImpl(0, MethodCodeType = MethodCodeType.Runtime)]
public virtual void EndInvoke(IAsyncResult result);
}

编译器为MyDelegate类定义了4个方法:构造器、InvokeBeginInvokeEndInvoke。所有的委托类型都派生自System.MulticastDelegate,而System.MulticastDelegate又派生自System.DelegateSystem.Delegate继承自System.Object类。虽然我们创建的委托类型继承自MulticastDelegate类,但仍会用到Delegate类的一些方法,如CombineRemove

public static Delegate Combine(
Delegate a,
Delegate b
)

public static Delegate Remove(
Delegate source,
Delegate value
)

委托类的定义和访问修饰符

委托类可以在全局范围或嵌套在一个类型中定义,也有访问修饰符,如privateinternalpublic等,用于限定访问权限。

委托实例的内部结构

所有委托类型都继承了MulticastDelegate类的字段、属性和方法,以下是三个重要的非公有字段: | 字段 | 类型 | 作用 | | ---- | ---- | ---- | | _target | System.Object | 当委托的实例包装一个静态方法时,该字段为null;当委托的实例包装的是一个实例方法时,这个字段引用的是回调方法要操作的对象,即传递给实例方法的隐式参数this。 | | _methodPtr | System.IntPtr | 一个内部的整数值,运行时用该字段来标识要回调的方法。 | | _invocationList | System.Object | 该字段的值通常为null。当构造委托链时它引用一个委托数组。 |

委托实例的构造方法需要两个参数:对对象的引用和一个IntPtr类型的用来引用回调函数的句柄。例如:

public MyDelegate(object @object, IntPtr method);

但在创建委托实例时,代码可能是这样的:

MyDelegate myDelegate = new MyDelegate(myMethod1);

编译器会分析代码,确定引用的对象和方法,并将对象的引用传递给object参数,方法的引用传递给method参数。如果myMethod1是静态方法,object参数为null

委托实例调用回调方法的过程

以下是一段展示委托实例调用回调方法的代码:

using UnityEngine;
using System.Collections;

public class DelegateScript : MonoBehaviour
{
delegate void MyDelegate(int num);
MyDelegate myDelegate;

void Start ()
{
myDelegate = new MyDelegate(this.PrintNum);
this.Print(10, myDelegate);
myDelegate = new MyDelegate(this.PrintDoubleNum);
this.Print(10, myDelegate);
myDelegate = null;
this.Print(10, myDelegate);
}

void Print(int value, MyDelegate md)
{
if(md != null)
{
md(value);
}
else
{
Debug.Log("myDelegate is Null!!!");
}
}

void PrintNum(int num)
{
Debug.Log ("Print Num: " + num);
}

void PrintDoubleNum(int num)
{
int result = num + num;
Debug.Log ("result num is : " + result);
}
}

编译并运行后,输出结果如下:

Print Num:10
result num is : 20
myDelegate is Null!!!

Print方法中,首先检查传入的委托实例md是否为null,这是必要的检查。当调用md(value)时,编译器会在幕后生成代码调用该委托实例的Invoke方法,即md.Invoke(value)。可以将Print方法改写为:

void Print(int value, MyDelegate md)
{
if(md != null)
{
md.Invoke(value);
}
else
{
Debug.Log("myDelegate is Null!!!");
}
}

调用委托实例的Invoke方法时,之前在构造委托实例时被赋值的_target_methodPtr字段会为Invoke方法提供对象和方法信息,使得Invoke能够在指定的对象上调用包装好的回调方法。

0x05 委托是如何调用多个方法的?

为了方便,将用委托调用多个方法简称为委托链。委托链是委托对象的集合,可以利用委托链来调用集合中委托所代表的全部方法。

委托链的创建和调用示例

以下是一段演示委托链的代码:

using UnityEngine;
using System;
using System.Collections;

public class DelegateScript : MonoBehaviour
{
delegate void MyDelegate(int num);

void Start ()
{
// 创建3个MyDelegate委托类的实例
MyDelegate myDelegate1 = new MyDelegate(this.PrintNum);
MyDelegate myDelegate2 = new MyDelegate(this.PrintDoubleNum);
MyDelegate myDelegate3 = new MyDelegate(this.PrintTripleNum);
MyDelegate myDelegates = null;
// 使用Delegate类的静态方法Combine
myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate1);
myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate2);
myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate3);
// 将myDelegates传入Print方法
this.Print(10, myDelegates);
}

void Print(int value, MyDelegate md)
{
if(md != null)
{
md(value);
}
else
{
Debug.Log("myDelegate is Null!!!");
}
}

void PrintNum(int num)
{
Debug.Log ("1 result Num: " + num);
}

void PrintDoubleNum(int num)
{
int result = num + num;
Debug.Log ("2 result num is : " + result);
}

void PrintTripleNum(int num)
{
int result = num + num + num;
Debug.Log ("3 result num is : " + result);
}
}

编译并运行后,Unity3D的调试窗口会打印出:

1 result Num: 10
2 result Num: 20
3 result Num: 30

这表明一个委托实例myDelegates中调用了三个回调方法PrintNumPrintDoubleNumPrintTripleNum

委托链的构建过程

首先构造了三个MyDelegate委托类的实例myDelegate1myDelegate2myDelegate3myDelegates初始化为null。然后使用Delegate.Combine方法将委托实例加入委托链:

myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate1);
myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate2);
myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate3);
  • 第一次调用Delegate.Combine时,由于myDelegatesnull,方法直接返回myDelegate1的值,此时myDelegates引用myDelegate1所引用的委托实例。
  • 第二次调用时,myDelegates已引用一个委托实例,Delegate.Combine会构建一个新的委托实例,_invocationList字段被初始化为一个对委托实例数组的引用,数组的第一个元素是包装了PrintNum方法的委托实例,第二个元素是包装了PrintDoubleNum方法的委托实例。
  • 第三次调用时,同样创建一个新的委托实例,_invocationList字段引用的委托实例数组增加了一个包装了PrintTripleNum方法的委托实例。

委托实例的移除

Delegate类除了提供Combine方法,还提供了Remove方法用于移除委托实例。例如,移除包装了PrintDoubleNum方法的委托实例:

myDelegates = (MyDelegate)Delegate.Remove(myDelegates, new MyDelegate(PrintDoubleNum));

Delegate.Remove方法会从后向前扫描委托实例中的委托数组,对比_target_methodPtr字段的值,匹配后进行移除操作。需要注意的是,Remove方法每次仅移除一个匹配的委托实例。

操作符重载简化委托使用

为了方便开发者,C#编译器为委托类型的实例重载了+=-=操作符,分别对应Delegate.CombineDelegate.Remove。例如:

using UnityEngine;
using System.Collections;

public class MulticastScript : MonoBehaviour
{
delegate void MultiDelegate();
MultiDelegate myMultiDelegate;

void Start ()
{
myMultiDelegate += PowerUp;
myMultiDelegate += TurnRed;
if(myMultiDelegate != null)
{
myMultiDelegate();
}
}

void PowerUp()
{
print ("Orb is powering up!");
}

void TurnRed()
{
renderer.material.color = Color.red;
}
}

至此,我们已经了解了委托如何调用多个方法。为了实现观察者模式甚至是自己的消息系统,还有与委托关系密切的事件需要介绍,下一篇博客将深入探讨委托和事件的世界。

原文地址:http://www.cnblogs.com/murongxiaopifu/p/4684728.html

作者信息

洞悉

洞悉

共发布了 3994 篇文章