庖丁解牛聊委托,那些编译器藏的和U3D给的(上)

chenjd

0x01 从观察者模式说起

在设计模式中,有一种我们常常会用到的设计模式——观察者模式。那么这种设计模式和我们的主题“如何在Unity3D中使用委托”有什么关系呢?别急,先让我们来聊一聊什么是观察者模式。

首先让我们来看看报纸和杂志的订阅是怎么一回事:

  1. 报社的任务便是出版报纸。

  2. 向某家报社订阅他们的报纸,只要他们有新的报纸出版便会向你发放。也就是说,只要你是他们的订阅客户,便可以一直收到新的报纸。

  3. 如果不再需要这份报纸,则可以取消订阅。取消之后,报社便不会再送新的报纸过来。

  4. 报社和订阅者是两个不同的主体,只要报社还一直存在着,不同的订阅者便可以来订阅或取消订阅。

如果各位读者能看明白我上面所说的报纸和杂志是如何订阅的,那么各位也就了解了观察者模式到底是怎么一回事。除了名称不大一样,在观察者模式中,报社或者说出版者被称为“主题”(Subject),而订阅者则被称为“观察者”(Observer)。将上面的报社和订阅者的关系移植到观察者模式中,就变成了如下这样:主题(Subject)对象管理某些数据,当主题内的数据改变时,便会通知已经订阅(注册)的观察者,而已经注册主题的观察者此时便会收到主题数据改变的通知并更新,而没有注册的对象则不会被通知。

当我们试图去勾勒观察者模式时,可以使用报纸订阅服务,或者出版者和订阅者来比拟。而在实际的开发中,观察者模式被定义为了如下这样:

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

那么介绍了这么多观察者模式的内容,是不是也该说一说委托了呢?是的,C#语言通过委托来实现回调函数的机制,而回调函数是一种很有用的编程机制,可以被广泛的用在观察者模式中。

那么Unity3D本身是否有提供这种机制呢?答案也是肯定的,那么和委托又有什么区别呢?下面就让我们来聊一聊这个话题。

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

当然,不可否认Unity3D游戏引擎的出现是游戏开发者的一大福音。但不得不说的是,Unity3D的游戏脚本的架构中是存在一些缺陷的。一个很好的例子就是本节要说的围绕SendMessage和BroadcastMessage而构建的消息系统。之所以说Unity3D的这套消息系统存在缺陷,主要是由于SendMessage和BroadcastMessage过于依赖反射机制(reflection)来查找消息对应的回调函数。频繁的使用反射自然会影响性能,但是性能的损耗还并非最为严重的问题,更加严重的问题是使用这种机制之后代码的维护成本。为什么说这样做是一个很糟糕的事情呢?因为使用字符串来标识一个方法可能会导致很多隐患的出现。举一个例子:假如开发团队中某个开发者决定要重构某些代码,很不巧,这部分代码便是那些可能要被这些消息调用的方法定义的代码,那么如果方法被重新命名甚至被删除,是否会导致很严重的隐患呢?答案是yes。这种隐患的可怕之处并不在于可能引发的编译时错误,恰恰相反,这种隐患的可怕之处在于编译器可能都不会报错来提醒开发者某些方法已经被改名甚至是不存在了,面对一个能够正常的运行程序而没有警觉是最可怕的,而什么时候这个隐患会爆发呢?就是触发了特定的消息而找不到对应的方法的时候 ,但这时候发现问题所在往往已经太迟了。

另一个潜在的问题是由于使用了反射机制因而Unity3D的这套消息系统也能够调用声明为私有的方法的。但是如果一个私有方法在声明的类的内部没有被使用,那么正常的想法肯定都认为这是一段废代码,因为在这个类的外部不可能有人会调用它。那么对待废代码的态度是什么呢?我想很多开发者都会选择消灭这段废代码,那么同样的隐患又会出现,可能在编译时并没有问题,甚至程序也能正常运行一段时间,但是只要触发了特定的消息而没有对应的方法,那便是这种隐患爆发的时候。因而,是时候向Unity3D中的SendMessage和BroadcastMessage说拜拜了,让我们选择C#的委托来实现自己的消息机制吧。

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

在非托管代码C/C++中也存在类似的回调机制,但是这些非成员函数的地址仅仅是一个内存地址。而这个地址并不携带任何额外的信息,例如函数的参数个数、参数类型、函数的返回值类型,因而我们说非托管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);
    }
}

下面我们来看看这段代码做的事情。在最开始,我们可以看到internal委托类型MyDelegate的声明。委托要确定一个回调方法签名,包括参数以及返回类型等等,在本例中MyDelegate委托制定的回调方法的参数类型是int型,同时返回类型为void。

DelegateScript类还定义了两个私有方法PrintNum和DoubleNum,它们的分别实现了打印传入的参数和打印传入的参数的两倍的功能。在Start方法中,MyDelegate类的实例myDelegate分别引用了这两个方法,并且分别调用了这两个方法。

看到这里,不知道各位读者是否会产生一些疑问,为什么一个方法能够像这样myDelegate = PrintNum; “赋值”给一个委托呢?这便不得不提C#2为委托提供的方法组转换。回溯C#1的委托机制,也就是十分原始的委托机制中,如果要创建一个委托实例就必须要同时指定委托类型和要调用的方法(执行的操作),因而刚刚的那行代码就要被改为:

new MyDelegate(PrintNum);

即便回到C#1的时代,这行创建新的委托实例的代码看上去似乎并没有让开发者产生什么不好的印象,但是如果是作为较长的一个表达式的一部分时,就会让人感觉很冗繁了。一个明显的例子是在启动一个新的线程时候的表达式:

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

这样看起来,C#1中的方式似乎并不简洁。因而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;

        myDelegate(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);

    }
}

这段代码中有两个方法名相同的方法:

void PrintNum(int num)

void PrintNum(int num1, int num2)

那么根据方法组转换机制,在向一个MyDelegate或一个MyDelegate2赋值时,都可以使用PrintNum作为方法组(此时有2个PrintNum,因而是“组”),编译器会选择合适的重载版本。

当然,涉及到委托的还有它的另外一个特点——委托参数的逆变性和委托返回类型的协变性。这个特性在很多文章中也有过介绍,但是这里为了使读者更加加深印象,因而要具体的介绍一下委托的这种特性。

在为委托实例引用方法时,C#允许引用类型的协变性和逆变性。协变性是指方法的返回类型可以是从委托的返回类型派生的一个派生类,也就是说协变性描述的是委托返回类型。逆变性则是指方法获取的参数的类型可以是委托的参数的类型的基类,换言之逆变性描述的是委托的参数类型。

例如,我们的项目中存在的基础单位类(BaseUnitClass)、士兵类(SoldierClass)以及英雄类(HeroClass),其中基础单位类BaseUnitClass作为基类派生出了士兵类SoldierClass和英雄类HeroClass,那么我们可以定义一个委托,就像下面这样:

delegate Object TellMeYourName(SoldierClass soldier);

那么我们完全可以通过构造一个该委托类型的实例来引用具有以下原型的方法:

string TellMeYourNameMethod(BaseUnitClass base);

在这个例子中,TellMeYourNameMethod方法的参数类型是BaseUnitClass,它是TellMeYourName委托的参数类型SoldierClass的基类,这种参数的逆变性是允许的;而TellMeYourNameMethod方法的返回值类型为string,是派生自TellMeYourName委托的返回值类型Object的,因而这种返回类型的协变性也是允许的。但是有一点需要指出的是,协变性和逆变性仅仅支持引用类型,所以如果是值类型或void则不支持。下面我们接着举一个例子,如果将TellMeYourNameMethod方法的返回类型改为值类型int,如下:

int TellMeYourNameMethod(BaseUnitClass base);

这个方法除了返回类型从string(引用类型)变成了int(值类型)之外,什么都没有被改变,但是如果要将这个方法绑定到刚刚的委托实例上,编译器会报错。虽然int型和string型一样,都派生自Object类,但是int型是值类型,因而是不支持协变性的。这一点,各位读者在实际的开发中一定要注意。

好了,到此我们应该对委托有了一个初步的直观印象。在本节中我带领大家直观的认识了委托如何在代码中使用,以及通过C#2引入的方法组转换机制为委托实例引用合适的方法以及委托的协变性和逆变性。那么本节就到此结束,接下来让我们更进一步的探索委托。

阅读 2.7k

Runtime
编程首先是爱好,其次才是职业。专注前沿技术,热爱开源。深信代码改变世界。没有值得吹嘘的项目,只有...

Microsoft Visual Studio and Development Technologies MVP

556 声望
61 粉丝
0 条评论

Microsoft Visual Studio and Development Technologies MVP

556 声望
61 粉丝
文章目录
宣传栏