匹夫细说C#:庖丁解牛聊委托,那些编译器藏的和U3D的那些事
作者:慕容小匹夫
原文: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分别引用了PrintNum和DoubleNum方法,并调用了它们。
方法组转换机制
在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个方法:构造器、Invoke、BeginInvoke和EndInvoke。所有的委托类型都派生自System.MulticastDelegate,而System.MulticastDelegate又派生自System.Delegate,System.Delegate继承自System.Object类。虽然我们创建的委托类型继承自MulticastDelegate类,但仍会用到Delegate类的一些方法,如Combine和Remove:
public static Delegate Combine(
Delegate a,
Delegate b
)
public static Delegate Remove(
Delegate source,
Delegate value
)
委托类的定义和访问修饰符
委托类可以在全局范围或嵌套在一个类型中定义,也有访问修饰符,如private、internal、public等,用于限定访问权限。
委托实例的内部结构
所有委托类型都继承了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中调用了三个回调方法PrintNum、PrintDoubleNum和PrintTripleNum。
委托链的构建过程
首先构造了三个MyDelegate委托类的实例myDelegate1、myDelegate2和myDelegate3,myDelegates初始化为null。然后使用Delegate.Combine方法将委托实例加入委托链:
myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate1);
myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate2);
myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate3);
- 第一次调用
Delegate.Combine时,由于myDelegates为null,方法直接返回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.Combine和Delegate.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;
}
}
至此,我们已经了解了委托如何调用多个方法。为了实现观察者模式甚至是自己的消息系统,还有与委托关系密切的事件需要介绍,下一篇博客将深入探讨委托和事件的世界。