思考并回答以下问题:
- 安全检查从运行时转移到了编译时怎么理解?
- 类型也可以成为一种参数怎么理解?
- 泛型方法如何表现类型参数?写在圆括号()可以吗?为了不和原参数冲突,使用<>表示怎么理解?
- 泛型的本质是“类型是参数”怎么理解?
- 方法使用的时候是不是要圆括号里面写参数,泛型既然用尖括号定义了形参 那调用的时候就还需要尖括号里传入实参怎么理解?
- 泛型没出现之前有什么问题?
- 为什么说object类型其本身其实是一个很“没有用”的存在?
- 泛型类的虚方法更少如何理解?
本章涵盖:
- 为什么需要泛型机制
- Unity 3D中常见的泛型
- 泛型机制的基础
由于泛型的引入,使得大量的安全检查从运行时(通过编译,运行出错)转移到了编译时(写错通不过编译)进行,C#的代码也因此获得了更加丰富的表现力。
其实从本质上来说,泛型实现了类型和方法的参数化,即类型也可以成为一种参数。就如同在一般的方法中,需要告诉该方法它所需要的参数是什么。同样在涉及到泛型类型的方法时,就像需要向方法提供参数一样,同样要告诉这个方法使用了什么类型。
那么既然是作为C#2所引入的最重要的一个功能,我们显然必须要先了解泛型这种机制出现的必然性。
为什么需要泛型机制
面向对象的开发模式之所以受人推崇,其中的一个好处便是代码的复用,也正是由于代码复用,因此使用面向对象编程时,开发效率很高。
举一个简单的例子,假设我们有一个基类已经定义了它的各种功能,如果需要拓展这个基类的功能,只需要在这个基类的基础上派生出一个新的类,让它继承基类的所有能力。而派生类所做的只是重写基类的虚方法,或者是添加一些新的方法,这样就可以实现拓展基类功能的目的。但这个和本节要说的泛型机制有什么关系呢?其实很简单,泛型机制的出现,最主要的目的也是实现另一种形式的代码复用,即“逻辑复用”。
在泛型机制出现之前的C#1的时代,由于没有泛型的概念,因此面对不同的类型,即便使用的是同一套逻辑,仍然需要对类型进行强制转换。任何方法只要将object作为参数类型或者返回类型使用,那么就会有可能在某个时候触发强制类型转换。这显然是低效率的,而且可能会带来很多Bug。而泛型的出现,则使得逻辑复用变成了可能,甚至是一件理所当然的事情。
简单地说,泛型出现之后,开发底层逻辑的人员只需要定义好功能逻辑,例如常见的排序、搜索、元素交换、元素比较等,或者是游戏中常见的寻敌、状态循环等游戏逻辑。但是定义这些逻辑的人员,并不需要指定特定的数据类型。例如List\
在Unity 3D中,C#脚本语言只允许创建泛型引用类型和泛型值类型,但是要注意的是,创建泛型枚举类型是不被允许的。
作为开发中最常见的一种数据结构,也是最常见的一个泛型类,下面用List\
1 |
在上面这段代码中,由于List\
在设计List\
通过上面的讲解和例子,各位读者都应该已经明白了C#语言引入泛型机制的原因和必要性了。下面做一个简单的总结,引入泛型所提供的好处有以下3点。
(1)类型安全:使用泛型类型或泛型方法来操作一个具体的数据类型时,编译器能够理解开发人员的意图,并且保证只有与制定数据类型兼容的对象才能用于该泛型类型或泛型方法。当使用不兼容类型的对象时,则会造成编译错误,甚至是在运行时抛出异常。例如在上面的代码中,对一个声明了操作类型为string的列表添加int型数据时,编译器会报错。
(2)更加清晰的代码:正如本章开始所说的,在没有引入泛型机制的C#1的时代,源代码中不得不进行的强制类型转换次数是很多的,因此代码相对不易维护和拓展。在引入了泛型机制后,源代码中不必进行很多强制类型转换,因此代码变得更加容易维护。例如在上面的代码中,将List\
(3)更加优秀的性能:同样在本章开始就提到过的,如果没有泛型机制的话,为了使用同一套常规化的逻辑方法,则必须使用object作为参数或返回值的类型。但一个不得不承认的事实是,object类型其本身其实是一个很“没有用”的存在,这是由于如果要使用object做一些真正具体有意义的事情,则几乎不得不进行强制类型转换,转换成目标类型。同时,由于object是引用类型,当实际操作类型是值类型时,则又面临另一个十分影响性能的操作——装箱操作。Mono运行时将不得不在调用该逻辑方法之前对值类型实例进行装箱。但是,引入泛型机制后,由于能够通过该机制创建一个泛型类型或泛型方法来操作值类型,因此值类型的实例就无须执行装箱操作,反而可以直接通过传值的方式来传递了。与此同时,由于无须进行强制类型转换,因此在Mono运行时无须去验证这种转型是否类型安全。泛型机制使得大量的安全检查从运行时转移到了编译时进行,因此提高了代码的运行速度。
Unity 3D中常见的泛型
由于C#2的语言规范提供了丰富的实现细节,基本涵盖了泛型在所有可以预见的情况下可能的行为。不过,这并不意味着我们必须了解泛型所有的实现细节,才能写出高效简洁的代码。因此,为了让各位读者尽可能快的了解和掌握泛型在Unity 3D开发中的使用方法,本节将主要介绍在日常开发过程中使用泛型所需要的大多数的知识点。
首先要说明的一点是,使用泛型机制最明显的是一些集合类。例如在System.Collections.Generic和System.Collections.ObjectModel命名空间中提供了很多泛型集合类,例如之前用到的泛型类List\
当然, C#游戏脚本语言还提供了许多泛型接口。而插入集合中的元素则可以通过实现接口来执行例如排序、查找等操作。常常提到的List\
除此之外,一个看上去和泛型好像没有什么关系的类——System.Array类,同样值得注意。作为所有数组类型的基类,System.Array类提供了很多静态泛型方法,例如AsReadOnly、BinarySearch、ConvertAll、Exists、Find、FindAll、FindIndex、FindLast、FindLastIndex、ForEach、IndexOf、LastIndexOf、Resize、Sort、TrueForAll等。
Array中定义的这些静态泛型方法的使用,代码如下。1
2
另一个使用泛型的例子,是随着泛型机制的引入而引入的泛型字典——Dictionary\
假设要开发一款战略游戏,其中存在军队(Army类)、英雄(Hero类)、士兵(Soldier类)的概念,军队是英雄和士兵的集合,而英雄和士兵又同时从同一个基类——游戏单位(BaseUnit类)派生而来,那么下面通过一个使用Dictionary\
1 |
首先要说的是,在CountHeros这个方法中,实现了将英雄名字(string类型)和该英雄的出现次数(int类型)一一映射的功能。对于当前军队中的每一个英雄的名字,都要先检查一下它是否已经存在了这种映射关系,也就是说在字典中是否有以该英雄名字作为Key的那组映射关系。如果存在,则计数加一,也就是对应的Value的值加一。如果不存在,则要为这个英雄的名字和它出现的次数创建一种映射关系,也就是说要在字典中增加一个以英雄名字为Key, 1为Value的元素。这里要注意的是,对Value值的修改,并不需要执行int型的强制类型转换,因为使计数递增的步骤是先对映射的索引器执行一次取值操作,取得当前的值之后再执行加一,最后在对索引器执行赋值操作。
其实“tempCounts[bu.name] += 1;”这行代码等效于下面3行代码。1
2
3int temp = tempCounts[bu.name];
temp += 1;
tempCounts[bu.name] = temp;
而最后需要指出的一点是如何遍历一个字典。在代码中,使用了KeyValuePair\
但是在没有引入泛型机制之前,和Dictionary\
接下来让我们继续通过Dictionary\
泛型机制的基础
上一节演示了一个使用泛型的实际情景。但是泛型机制从无到有,这背后到底发生了一些什么事情呢?
为了使泛型能够正常工作,C#语言的开发人员需要完成哪些工作呢?下面就简单列举一些为了实现泛型,而必须完成的一些工作。
- 需要创建新的CIL指令,在CIL的层面实现识别类型实参的功能。
- 修改引入泛型之前的元数据表的格式,使具有泛型参数的类型和方法能够正确表示。
- 修改具体的编程语言,例如C#,使之能够支持新的语法。
- 修改编译器(编程语言编译为CIL的编译器),使编程语言(C#)能够正确地被编译为对应的CIL代码。
- 修改JIT编译器(CIL代码编译为原生代码),使新创建的处理类型实参的CIL代码能够被正确地编译为对应平台的原生代码。
- 创建新的反射成员,使开发人员能够查询类型和成员,同时判断它们是否具有泛型参数。同时使开发人员能够在运行时创建泛型类型和泛型方法。
通过完成以上的工作,C#语言终于具备了泛型的机制。在Unity 3D的开发过程中,C#游戏脚本语言提供的泛型机制主要可以分为以下两种形式。
(1)泛型类型:包括类、接口、委托以及结构(值类型),但是需要注意的是并不包括泛型枚举。
(2)泛型方法。
泛型类型和类型参数
泛型类型又可以细分成很多不同的种类,例如Dictionary\
可以看到在声明一个新的Dictionary\
但是需要注意的是,Mono运行时会为各种类型创建类型对象(即类型实例)的内部数据结构。拥有泛型类型参数的泛型类型同样是类型,因此Mono运行时同样会为它创建内部的类型对象。但是如果没有指定泛型类型中类型参数的具体类型,在这种情况下Mono运行时是禁止创建它的实例的,而这种情况下具有泛型类型参数的类型就被称为开放类型。例如要创建一个没有指定TKey和TValue具体类型的类Dictionarys\<,>的实例是不会成功的,这里Dictionarys<,>便是一个开放类型。
与开放类型相反的是封闭类型,我们称为所有类型参数都传递了实际的数据类型的类型为封闭类型,例如Dictionary\
1 |
编译上面这段代码,并且运行生成的程序,可以看到在调试窗口输出的内容如下所示。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
那么泛型类型的类型参数被指定后,到底是如何被编译成一个新的类型的呢?具体而言,对那些使用了泛型类型参数的代码进行JIT编译时, Mono运行时首先会获取其对应的CIL代码,并且用你指定的类型实参进行替换,最后将类型替换后的CIL代码编译为原生代码,因此指定了实参的泛型类型,实际上已经是一个新的类型了。但各位读者可能都能看到这种方式存在一个很大的隐患,那就是Mono运行时可能要为所有的“方法/实参类型”的组合生成一套原生代码,这样导致的后果就是代码编译后变得“臃肿不堪”。但是Mono运行时内部采用了一套优化机制来避免这种情况的发生,也就是使某个编译后的“方法实参类型”组合能够复用。假如某个特定的类型是某个泛型方法的类型实参,该“方法/实参类型”组合只需要被编译一次,之后再调用该方法就不需要再次编译了。例如一个Dictionary-string, int>被编译一次,之后代码中再出现Dictionary
泛型接口和泛型委托
提到泛型类型,人们大多想到的是类(引用类型)或者结构(值类型) ,但是还有一些也是属于泛型类型范畴的,例如泛型接口和泛型委托。