在Unity3D中使用泛型

思考并回答以下问题:

  • 安全检查从运行时转移到了编译时怎么理解?
  • 类型也可以成为一种参数怎么理解?
  • 泛型方法如何表现类型参数?写在圆括号()可以吗?为了不和原参数冲突,使用<>表示怎么理解?
  • 泛型的本质是“类型是参数”怎么理解?
  • 方法使用的时候是不是要圆括号里面写参数,泛型既然用尖括号定义了形参 那调用的时候就还需要尖括号里传入实参怎么理解?
  • 泛型没出现之前有什么问题?
  • 为什么说object类型其本身其实是一个很“没有用”的存在?
  • 泛型类的虚方法更少如何理解?

本章涵盖:

  • 为什么需要泛型机制
  • Unity 3D中常见的泛型
  • 泛型机制的基础

由于泛型的引入,使得大量的安全检查从运行时(通过编译,运行出错)转移到了编译时(写错通不过编译)进行,C#的代码也因此获得了更加丰富的表现力。

其实从本质上来说,泛型实现了类型和方法的参数化,即类型也可以成为一种参数。就如同在一般的方法中,需要告诉该方法它所需要的参数是什么。同样在涉及到泛型类型的方法时,就像需要向方法提供参数一样,同样要告诉这个方法使用了什么类型。

那么既然是作为C#2所引入的最重要的一个功能,我们显然必须要先了解泛型这种机制出现的必然性。

为什么需要泛型机制

面向对象的开发模式之所以受人推崇,其中的一个好处便是代码的复用,也正是由于代码复用,因此使用面向对象编程时,开发效率很高。

举一个简单的例子,假设我们有一个基类已经定义了它的各种功能,如果需要拓展这个基类的功能,只需要在这个基类的基础上派生出一个新的类,让它继承基类的所有能力。而派生类所做的只是重写基类的虚方法,或者是添加一些新的方法,这样就可以实现拓展基类功能的目的。但这个和本节要说的泛型机制有什么关系呢?其实很简单,泛型机制的出现,最主要的目的也是实现另一种形式的代码复用,即“逻辑复用”。

在泛型机制出现之前的C#1的时代,由于没有泛型的概念,因此面对不同的类型,即便使用的是同一套逻辑,仍然需要对类型进行强制转换。任何方法只要将object作为参数类型或者返回类型使用,那么就会有可能在某个时候触发强制类型转换。这显然是低效率的,而且可能会带来很多Bug。而泛型的出现,则使得逻辑复用变成了可能,甚至是一件理所当然的事情。

简单地说,泛型出现之后,开发底层逻辑的人员只需要定义好功能逻辑,例如常见的排序、搜索、元素交换、元素比较等,或者是游戏中常见的寻敌、状态循环等游戏逻辑。但是定义这些逻辑的人员,并不需要指定特定的数据类型。例如List\这个数据结构,开发这个数据结构的开发人员无须事先假定List\中涉及到的排序、搜索、交换等操作的类型具体是什么。又比如一个游戏中的单位可能有英雄、士兵、怪物等,但是在开发寻找敌人的通用逻辑时,逻辑开发者不必知道在具体使用时,操作的到底是英雄、士兵、怪物。也就是说,该算法可以广泛地应用于不同类型的对象。而指定逻辑要具体操作的数据类型,则是使用该逻辑的开发人员需要考虑的。

在Unity 3D中,C#脚本语言只允许创建泛型引用类型和泛型值类型,但是要注意的是,创建泛型枚举类型是不被允许的。

作为开发中最常见的一种数据结构,也是最常见的一个泛型类,下面用List\作为例子,来讲解一下泛型的表现形式和具体操作方法,代码如下。

1
2


在上面这段代码中,由于List\定义在System.Collections.Generic命名空间中,因此首先要引入这个命名空间。之后可以看到泛型List类的写法是类名(List)+\的格式,表示List类操作的是一个没有指定的数据类型(T) 。在定义泛型类型或泛型方法时,为类型指定的任何变量都被称为类型参数,即本章刚开始的时候所提到的类型参数化,参数变量名为T。在上面的这段代码中,T是string,意思是这个List类的实例操作的是string型的对象。另一个例子是索引器方法,即C#游戏脚本语言中的this。索引器有一个get访问器方法,它返回T类型的值以及一个set访问器方法,它接受T类型的参数。

在设计List\的开发人员定义好List\中的各个逻辑方法后,就可以在使用它所提供的方法时选择具体的操作类型了。例如在上面的这段代码中,指定了一个string类型的实参来使用List的各种方法,当依次使用Add方法,为列表添加新的string型对象时,都能很顺利地执行,当执行到使用Add方法为列表添加一个int型对象时,编译器会提示错误。

通过上面的讲解和例子,各位读者都应该已经明白了C#语言引入泛型机制的原因和必要性了。下面做一个简单的总结,引入泛型所提供的好处有以下3点。

(1)类型安全:使用泛型类型或泛型方法来操作一个具体的数据类型时,编译器能够理解开发人员的意图,并且保证只有与制定数据类型兼容的对象才能用于该泛型类型或泛型方法。当使用不兼容类型的对象时,则会造成编译错误,甚至是在运行时抛出异常。例如在上面的代码中,对一个声明了操作类型为string的列表添加int型数据时,编译器会报错。

(2)更加清晰的代码:正如本章开始所说的,在没有引入泛型机制的C#1的时代,源代码中不得不进行的强制类型转换次数是很多的,因此代码相对不易维护和拓展。在引入了泛型机制后,源代码中不必进行很多强制类型转换,因此代码变得更加容易维护。例如在上面的代码中,将List\中索引为0的元素取出来,并且赋值给一个sting型的变量s的过程中并没有强制类型转换。

(3)更加优秀的性能:同样在本章开始就提到过的,如果没有泛型机制的话,为了使用同一套常规化的逻辑方法,则必须使用object作为参数或返回值的类型。但一个不得不承认的事实是,object类型其本身其实是一个很“没有用”的存在,这是由于如果要使用object做一些真正具体有意义的事情,则几乎不得不进行强制类型转换,转换成目标类型。同时,由于object是引用类型,当实际操作类型是值类型时,则又面临另一个十分影响性能的操作——装箱操作。Mono运行时将不得不在调用该逻辑方法之前对值类型实例进行装箱。但是,引入泛型机制后,由于能够通过该机制创建一个泛型类型或泛型方法来操作值类型,因此值类型的实例就无须执行装箱操作,反而可以直接通过传值的方式来传递了。与此同时,由于无须进行强制类型转换,因此在Mono运行时无须去验证这种转型是否类型安全。泛型机制使得大量的安全检查从运行时转移到了编译时进行,因此提高了代码的运行速度。

Unity 3D中常见的泛型

由于C#2的语言规范提供了丰富的实现细节,基本涵盖了泛型在所有可以预见的情况下可能的行为。不过,这并不意味着我们必须了解泛型所有的实现细节,才能写出高效简洁的代码。因此,为了让各位读者尽可能快的了解和掌握泛型在Unity 3D开发中的使用方法,本节将主要介绍在日常开发过程中使用泛型所需要的大多数的知识点。

首先要说明的一点是,使用泛型机制最明显的是一些集合类。例如在System.Collections.Generic和System.Collections.ObjectModel命名空间中提供了很多泛型集合类,例如之前用到的泛型类List\。而基于前一节中的结论,即使用泛型集合类是类型安全的,而且代码更加清晰、易于维护,同时还拥有更加出色的运行性能。除此之外,泛型类比非泛型类拥有更好的对象模型,一个直观的事实就是泛型类的虚方法更少,性能更好。因此建议各位读者使用泛型集合类,而不要使用非泛型类。

当然, C#游戏脚本语言还提供了许多泛型接口。而插入集合中的元素则可以通过实现接口来执行例如排序、查找等操作。常常提到的List\类就实现了IList\泛型接口,而常用的泛型接口往往也定义在System.Collection.Generic命名空间中。

除此之外,一个看上去和泛型好像没有什么关系的类——System.Array类,同样值得注意。作为所有数组类型的基类,System.Array类提供了很多静态泛型方法,例如AsReadOnly、BinarySearch、ConvertAll、Exists、Find、FindAll、FindIndex、FindLast、FindLastIndex、ForEach、IndexOf、LastIndexOf、Resize、Sort、TrueForAll等。

Array中定义的这些静态泛型方法的使用,代码如下。

1
2


另一个使用泛型的例子,是随着泛型机制的引入而引入的泛型字典——Dictionary\类。那么接下来会使用Dictionary\类来说明泛型类型是如何使用的。此时无须了解泛型是如何使用的,也无须熟悉泛型的语法,通过下面对Dictionary\类的操作,很快就能了解泛型的使用方法,并且摸索到如何写出可以运行的代码的方法。而泛型更加方便的一点,就在于泛型的出现将很多运行时才需要的检查转移到了编译时。因此,如果能够顺利通过编译时,那么这段代码就有很大的几率能够正常运行,这对初次接触泛型的开发者来说是一件十分方便的事情。

假设要开发一款战略游戏,其中存在军队(Army类)、英雄(Hero类)、士兵(Soldier类)的概念,军队是英雄和士兵的集合,而英雄和士兵又同时从同一个基类——游戏单位(BaseUnit类)派生而来,那么下面通过一个使用Dictionary\类来统计某个军队中某个种类的英雄出现次数的例子,来学习泛型的使用方法,代码如下。

1
2


首先要说的是,在CountHeros这个方法中,实现了将英雄名字(string类型)和该英雄的出现次数(int类型)一一映射的功能。对于当前军队中的每一个英雄的名字,都要先检查一下它是否已经存在了这种映射关系,也就是说在字典中是否有以该英雄名字作为Key的那组映射关系。如果存在,则计数加一,也就是对应的Value的值加一。如果不存在,则要为这个英雄的名字和它出现的次数创建一种映射关系,也就是说要在字典中增加一个以英雄名字为Key, 1为Value的元素。这里要注意的是,对Value值的修改,并不需要执行int型的强制类型转换,因为使计数递增的步骤是先对映射的索引器执行一次取值操作,取得当前的值之后再执行加一,最后在对索引器执行赋值操作。

其实“tempCounts[bu.name] += 1;”这行代码等效于下面3行代码。

1
2
3
int temp = tempCounts[bu.name];
temp += 1;
tempCounts[bu.name] = temp;

而最后需要指出的一点是如何遍历一个字典。在代码中,使用了KeyValuePair\这个泛型类来实现对字典的访问,其中这个类型拥有两个属性,即Key和Value,而Key和Value的返回类型通过传递类型实参便可以确定。

但是在没有引入泛型机制之前,和Dictionary\类功能类似的一个类,也是在第4章提到过的Hashtable类中,也有类似的类来实现遍历和访问的功能,那就是DictionaryEntry类,其中也有Key和Value这两个属性。但是由于没有泛型机制,因此为了实现普遍兼容各个类型的目的,Key和Value这两个属性返回的都是object类型。因此,如果不引入泛型机制,则在遍历字典时对heroName和count这两个变量赋值前需要进行从object到string和int的强制类型转换。同时,由于要调用Debug.Log方法,count还需要执行装箱操作。从这个小小的细节也能看出引入泛型机制的重要性。

接下来让我们继续通过Dictionary\这个例子来探索一下泛型的真正含义。什么是T、TKey和Tvalue?为什么它们要被“<>”括起来?

泛型机制的基础

上一节演示了一个使用泛型的实际情景。但是泛型机制从无到有,这背后到底发生了一些什么事情呢?

为了使泛型能够正常工作,C#语言的开发人员需要完成哪些工作呢?下面就简单列举一些为了实现泛型,而必须完成的一些工作。

  • 需要创建新的CIL指令,在CIL的层面实现识别类型实参的功能。
  • 修改引入泛型之前的元数据表的格式,使具有泛型参数的类型和方法能够正确表示。
  • 修改具体的编程语言,例如C#,使之能够支持新的语法。
  • 修改编译器(编程语言编译为CIL的编译器),使编程语言(C#)能够正确地被编译为对应的CIL代码。
  • 修改JIT编译器(CIL代码编译为原生代码),使新创建的处理类型实参的CIL代码能够被正确地编译为对应平台的原生代码。
  • 创建新的反射成员,使开发人员能够查询类型和成员,同时判断它们是否具有泛型参数。同时使开发人员能够在运行时创建泛型类型和泛型方法。

通过完成以上的工作,C#语言终于具备了泛型的机制。在Unity 3D的开发过程中,C#游戏脚本语言提供的泛型机制主要可以分为以下两种形式。

(1)泛型类型:包括类、接口、委托以及结构(值类型),但是需要注意的是并不包括泛型枚举。

(2)泛型方法。

泛型类型和类型参数

泛型类型又可以细分成很多不同的种类,例如Dictionary\是泛型类,Dictionary\则是泛型接口。当然还有泛型委托和泛型结构。但泛型类型,同样还是一个表示一个API的基本形式,用一个类型参数来代表在使用时期望的类型。也就是说类型参数是真实类型(类型实参)的占位符。可以说类型参数实现了接受信息的功能,类型实参则提供实际的类型信息,这种关系和方法参数与方法实参的关系十分类似。只不过类型实参必须是类型,而不能是别的值。

可以看到在声明一个新的Dictionary\时,例如声明一个新的变量“Dictionary counts = new Dictionary();”也就是说要将类型参数放到一对尖括号(\<>)内,并且在必要的时候使用逗号来间隔。而Dictionary\类的类型参数是Tkey,代表字典中键的类型;TValue代表字典中值的类型。当声明一个具体Dictionary\类的实例时,需要传入真实的类型,就像上面那行代码中的TKey是string型,TValue是int型。

但是需要注意的是,Mono运行时会为各种类型创建类型对象(即类型实例)的内部数据结构。拥有泛型类型参数的泛型类型同样是类型,因此Mono运行时同样会为它创建内部的类型对象。但是如果没有指定泛型类型中类型参数的具体类型,在这种情况下Mono运行时是禁止创建它的实例的,而这种情况下具有泛型类型参数的类型就被称为开放类型。例如要创建一个没有指定TKey和TValue具体类型的类Dictionarys\<,>的实例是不会成功的,这里Dictionarys<,>便是一个开放类型。

与开放类型相反的是封闭类型,我们称为所有类型参数都传递了实际的数据类型的类型为封闭类型,例如Dictionary\便可以被称为封闭类型。如果不是所有的类型参数都被指定,而是有部分类型参数未被指定,那么也不是封闭类型,也不能创建该类型的实例。开放类型和封闭类型的各自特点,代码如下。

1
2


编译上面这段代码,并且运行生成的程序,可以看到在调试窗口输出的内容如下所示。

1
2


可以看到在Actiator类调用CreateInstance方法来试图创建新的实例时,分别抛出了两个ArgumentException异常。分别是“System.Collections.Generic.Dictionary’2[TKey,TValue] is an open generic type”以及“DictionaryOnePara’1[TValue] is an open generic type”,异常消息的内容翻译过来就是无法创建该类型实例,因为该类型是开放类型。而最后由于是创建封闭类型的实例,因此很正常地创建了出来。因此,了解泛型类型在使用过程中的开放类型和封闭类型的特点,也是十分重要的。

不过既然说泛型类型也是类型,那么泛型类型是否能够派生和继承呢?下面就介绍一下泛型类型和继承。

泛型类型和继承

泛型类型仍然是类型,因此它能继承别的类型,也能派生出别的类型。当指定一个泛型类型的类型参数后,Mono运行时实际上会在幕后定义一个新的类型对象,而这个新的类型对象也从泛型类型继承的那个类型派生。

举一个例子,由于Dictionary派生自Object类,因此Dictionary从Dictionary类派生, 因此DictionaryOnePara-int>也是从Dictionary派生的。简单地说,指定类型实参后, Mono运行时虽然创建了一个新的类型,但是并不影响继承关系。

那么泛型类型的类型参数被指定后,到底是如何被编译成一个新的类型的呢?具体而言,对那些使用了泛型类型参数的代码进行JIT编译时, Mono运行时首先会获取其对应的CIL代码,并且用你指定的类型实参进行替换,最后将类型替换后的CIL代码编译为原生代码,因此指定了实参的泛型类型,实际上已经是一个新的类型了。但各位读者可能都能看到这种方式存在一个很大的隐患,那就是Mono运行时可能要为所有的“方法/实参类型”的组合生成一套原生代码,这样导致的后果就是代码编译后变得“臃肿不堪”。但是Mono运行时内部采用了一套优化机制来避免这种情况的发生,也就是使某个编译后的“方法实参类型”组合能够复用。假如某个特定的类型是某个泛型方法的类型实参,该“方法/实参类型”组合只需要被编译一次,之后再调用该方法就不需要再次编译了。例如一个Dictionary-string, int>被编译一次,之后代码中再出现Dictionary和List的代码编译之后都是相同的,因为它们的类型实参String和Stream都是引用类型。但是与此相反的是,如果类型实参是值类型,则必须单独编译成原生代码。

泛型接口和泛型委托

提到泛型类型,人们大多想到的是类(引用类型)或者结构(值类型) ,但是还有一些也是属于泛型类型范畴的,例如泛型接口和泛型委托。

0%