游戏架构理论
04类型系统
本文,主要总结我在写游戏代码过程中对于类型系统的理解和使用。
一般讲起类型系统,都会直接想到编程语言中的类型概念,即Int,Long,String等类型标识符所指代的概念。这些也确实属于类型系统的一部分,但是本文想讨论更广泛的类型概念。
在底层硬件和机器代码级别,程序逻辑和数据都是使用位来标识的,即由01构成的串。如果单纯的将这些串放在机器中,实际上是没有任何含义的其也无法运行,即需要一个方式来确实这些数据的语义才能讲起运行。而Int,Long,String等类型标识实际就可以认为是对01串语义的解释操作,即该段数据的类型。
在参考书《编程与类型系统》中将类型定义如下:
类型:
类型是对数据做的一种分类,定义了能够对数据执行的操作、数据的意义,以及允许数据接受的值的集合。编译器和运行时会检查类型,以确保数据的完整性,实施访问限制,以及按照开发人员的意图来解释数据。
简单来说,我们可以把类型看作对数据的一个解释操作,说明了这个数据段的结构和运行方式。对于Int类型和我们通过代码声明Struct建立的类型本质有着相同特点,这也是本文想讨论的重点。
从更高层次的角度来讲,其可以是对不动点过程中的概念的抽象。
类型分派
在代码架构中最常用的类型系统,就是类型分派结构。该结构一般被用在表驱动结构中,或者策略设计模式中。先从这种最简单的表驱动结构思考起。
其结构图如下:

该结构特征跟Dispatcher类似,存在一个中心节点,该节点就是一个简单的Dict(Map)结构。
其运行过程时,对于每一个传入或调用的实例对象会根据其指定的Tag字段,在Dict结构中查找处理用的TagHandler来处理数据。
这个过程可以有以下的理解方式
- 按类型分派:类型字段Tag解释了数据块的使用方式,而Dict则是一个固定的解释分派点,解释了每个Tag下的Object数据的使用方式。
而这种方式会带来如下的代码结构特点:
- 调用接口统一化:
- 对于静态编译语言来说,查找调用过程统一,所以对于调用的接口,其接口环境必须是一致的。所谓接口环境即在调用分派过程中,调用参数以及返回参数必须一致化。
- 对于动态语言来说,则具体取决于对Tag所指定Handler的参数传递方式。即,在调用该Tag指定的Handler前,实际已经约定了该Handler所应该接受的参数格式。
- Handler可以包含提前注入的数据,辅助解析传入的实例数据结构。
实际上该结构也是<01容器化理论>中提及的结构。如果可以动态修改Dict的内容,并附加一定结构,其就会变成Dispatcher。而每个Tag实际上是架构中的不动点,这个类型数据结构使用目标指定的数据解释。
但是实际也可以通过一定技术,使得静态编译语言一样有着不同的接口输入,其本质思路是一样的,都是Tag是数据结构的共用字段,一定存在,其起到数据块的解释作用,指明了数据块的说明方式。例如如下结构:
传入参数是一个Object类型,传入Handler之后,对数据进行强转来识别。这样一致接口参数就是Object类型,但是每个Tag可以用自己不一样的参数格式。实际是将参数格式包装到了这个Object类型之下。
抽象类型概念
在上面的结构中,我们可以明显看到一些对于封装类型概念所持有的特征。例如结算,结算可能包含伤害,治疗,添加buff等等操作。每个操作都可以定义为一种类型,而这些类型都属于结算这一个类型概念的实例。所以我们其实要封装一个结算类型这样一件事情。
而结算类型这个概念实际则是被两部分封装。一部分是Object中统一的数据结构。一部分是,根据这个统一固定的数据结构进行的处理操作。例如上面,对于所有结算操作,都会通过Dict根据Tag字段来查找对应Handler进行处理操作。而对伤害,治疗等操作,其传入的数据可能是完全不一样的。但这些数据中则一定有共同的数据结构,即拥有Tag字段,Tag字段的实际数值则指明了使用的数据结构格式,其对应的Handler则对传入的数据进行对应的处理操作。
这种类型概念,更接近一种约定好的协议,即对于指定数据结构之上,使用指定的方式来操作数据。
具体来说可以这么认为:
所谓对类型概念封装可以认为分成两部分:
- 类型概念被封装为一个固定的数据结构。即这类数据结构有一部分固定的组成结构。
- 根据固定的组成结构,对整个数据区块进行解释和计算。
我称这种将架构中概念封装的过程,称之为抽线类型概念的封装。
其实可以看到这种思路,跟所谓的面向对象以及继承等等都有一定类似行。但是实际完全不同,这个理解,更加底层,更加简单,同时又能很好的理解所有的情况。而类的概念,则相对更加重化。
而如何抽象出这种类型不动点语义,我觉得则是完全取决于游戏的设计,开发者对游戏结构敏锐的抽取。即什么地方改封装成这样一个类型指派,该有这样一个数据结构。这一方面取决于游戏设计业务逻辑,一方面可以通过业务中一些端倪发现。
类,面向对象与类型系统
当使用面向对象语言编程时,其一般具有封装,继承,多态等特性,代码会通过写大量类(Class)来完成整个功能,所有类型都会时Object的子类。
而实际上,其代码所描述的Class对象就是对类型的一种抽象概念封装,实际建立了一套指定的类型描述系统来支持上面抽象的类型概念。对于一个类来说,这种构建方式可以描述为如下的方式:
- 当申明一个类的时候,需要写其成员变量,以及成员函数。
- 当实例一个类,生成其对象的时候,会在内存上按照其成员变量结构,初始化内存区块。
- 当调用对象上成员函数时,实际会根据其所属类型找到对应的函数,并且将自己作为参数传递进去,进行处理。
所以
- 当书写一个类的成员变量时,实际相当于申明该类型有什么样的数据结构。
- 当书写一个类的成员函数时,实际相当于写了在该类型数据结构上,调用该名称时查找那一个处理函数。
- 所谓继承,多态等特性,都是为了覆盖对统一数据结构上的统一名称的处理函数而定义的一套规则。
这个结构如下图:

可以看到这套面向对象实际提供了一种对抽象类型概念的实践方式。这样一个类型可以认为做了两件事情:
- 类申明的成员变量,决定这个数据块的数据结构。
- 类申明的成员方法,决定了这个数据块的一些解释方式。
通常情况下我们会注意到,这个数据结构,以及如何使用数据结构其实是两件事情。但面向对象的类型潜藏了其中过程,即这个类型固定绑定了这个函数,当从实例出发,直接调用函数式,实际会索引到其关联类型上,查找目标函数并调用。
但实际情况是,使用面向对象定义抽象类型,往往会在数据结构和绑定函数之间产生定义的混淆。例如一个函数,是不是应该跟这个类型绑定。例如一把枪,开枪这个操作的对应函数是不是应该就跟其绑定,成为其固定的一个方法?还是说开枪也可以封装为一个操作函数而已,只是目标对象拥有了该操作?而继承的结构更是提高了对这种抽象类型抽取的理解成本。是否应该标记为虚函数,每把不同的枪实现不同的开枪?将数据结构,和指定的操作函数绑在一起不是很利于代码的扩展,往往写到一般就发现原来的结构无法使用,需要重新写。
后面又提出了接口的概念,对于实现了目标接口的都可以被指定接口调用处理,看似进一步的分离了函数以及数据实例对象。但其实可以这样去理解,相当于该接口名称确定了封装的函数集合明而已。对于不同的数据结构,通过该名称来作为桥梁,找到目标处理函数然后处理对应结构。而作为接口,其只有函数集合的要求(相当于有这个字段)但是还是没有对相应数据结构的匹配实现功能(即实现接口内容不能有效复用)
这里我们可以换一种思路,完全解耦一个数据结构与绑定函数之间的关系。即完全自主的去装配数据结构与针对数据结构上的函数功能结构。简单来说:
对于上面结构也可以通过如下图结构来实现:

这个结构遵循上面的思路,将每个处理函数与记录实体数据结构分开。这相当于把面向对象中,类的方法索引功能单独抽出成一个模块。这样一个结构我们可以看到如下:
- 对于确定抽象类型可以控制的更加细致,有更强的扩展性。
- 添加一个新的操作时,可以明确针对数个标签的数据结构进行扩展而不干扰老的数据结构。对于其中每一个细节都提供了切入点和控制层级。
- 同时可以显然的看到什么地方需要数据结构的变化。什么地方需要功能的变化。
同时也有一些问题:
- 数据和功能函数太过分离,知道数据结构不能快速知道相关函数实现。
但是这里面依然有个切分结构。即对于数据结构的共用组成部分怎么切分?对于中间这一层功能映射层对应的是什么?怎么去找到这种类型划分的方式呢?
我觉得,这方面可能任然需要一定量的经验,对于需求描述,可以洞察之中需要架构的层次。实际上随着大部分程序员开发经验的增长,时间的流逝,最后都会发现一个下对于某些特定问题的架构方式。这些架构模式最后可能倾向于一种固化的模式,而这就是设计模式诞生的来源。但是如果仔细刨分这些设计模式的背后,都会看到是一种对于数据结构,以及如何索引方法的建构。
另一方面是好在面向对象也是抽象类型实现方式的一种。其也可以实现相同的结构,功能。实际上后面也可以看到,作为一种类的方式,面向对象也可以实现相同的功能。对于所有架构,其实也可以有效的转化为一个面向对象继承的架构形式。只是其过程可能比较隐蔽。
但是如果换一种更加简单基础的指引思路,可能会更有帮助去理解这些架构的拆分方式。
继承,混入,组合
现在来考察面向对象的核心概念,继承,以及几个常用的方式,混入与组合。我们只是单纯的从数据结构以及关联函数的组合方面来看这几个差异,以及如何驱动整个架构的执行。
一般来说:
- 继承:是面向对象语言必定的组成部分。
- 混入:由JavaScript提出的一种数据结构组合方式。可以把一个类中的成员方法以及变量等,注入到另一个类对象中去。
- 组合:人们经常说到的一种数据组合方式。
为方便描述,我们都先从HighLevel的数据结构与关联函数组成来看这些方式。然后再从语言来看这些东西。
下图中的表格都表示一个类中,定义的成员变量以及成员方法组成。是一个面向对象的方式,但是其关联函数部分可以根据上面所说再进一步拆分。
继承

继承跟绝大多数面向对象中的继承一致。这里我们来这样简略定义继承。继承发生在两个对象定义之间,例如Child与Base。当Child继承Base时。Child会拥有Base的数据结构,以及关联函数。同时可以覆盖部分函数来进行重载。
混入

混入(MixIn)是在JavaScript中提到的一种结构组织方式,这种技术也是建立在两个对象定义之间。例如上面的Member和Base。通过提供的接口,可以将Member的成员变量声明和成员方法注入到Base中。使其拥有相同的数据结构字段。
组合

组合则是建立在一种实例层之间的组合方式。其有一个容器,即这个map。这个map是一个容器结构,之前已经讨论过。这种属于实际运行时候建立的一种组合方式。其相当于动态的拥有了这些对象实例,并且放在自己身上。
现在我们可以显而易见的发现,对于继承,与混入来说。其实都是一种代码定义层级的方式,即这两个方式操作的是数据结构以及关联函数定义的方式。但是组合则是一个实例层级的组合方式。
很多书籍都会如下去讲述继承与组合之间的判定,继承表示is a即一个对象是一个什么,组合表示has a即一个对象。然而这是显然的,因为一个是数据结构定义层级,一个则是实例的组合方式。于是这个判定并没有告诉怎么去使用两者之间的结构。实际上两个就不是一个层级的故事。如果从数据结构定义,与实例之间,我们可以很清楚的看到之间的差异,以及运用场景。
现在我们来看之间的异同。我们这里的思路都是抛弃特定语言方式,而是用通用化的语言来操作,可以认为一个对象定义就是一个Map里面写了那些成员。
首先就可以看到的是
- 继承是混入的一种特例:
对于继承来说,实际上就相当把Base当作混入的Member放入到当Child中去。而对于函数重载则可以通过单拉一个实现了目标函数的类,分别混入到Child中去实现复用结构。于是继承所实现的功能可以都通过混入清晰的理解并实现。
但是反过来则不一定,绝大部分面向对象都是单继承结构,对于这种复合复用的结构则不一定能很好的实现融合结构。
实际上继承也好,混入也好。都可以简单理解为一种对象数据结构定义的组合方式。即如何将两个对象的,数据结构,关联函数合并在一起。也就是说,如果有其他的合理方式,其实都是可以的。
- 组合相当于运行时做数据结构组合:
对于组合来说,其放入的Component都可以动态认为是Owner的一部分,也就是以这种方式把Component上的数据和函数放入到Owner上去调用。Owner的容器结构,实际上相当于一种约定好的类型结构了,然后通过Map获取Component对象。进而可以获得
但两者又有一定的区别。对于对象定义层面的组合,显然其实例对象可以直接获取Member上的数据结构。但是Component方式则要通过中间Map来获取目标实例对象然后操作。但可以通过一定方法,让Component也拥有类似的功能效果。实际上可以认为类本身就是一个Map,只不过面向对象的语言清晰划分了类定义这件事情。
这样来看,组合结构实际上就是在类上面加一个Map结构来动态实现混入相同的功能。即动态的把数据结构定义组合起来。而这也是一个方式,使得单继承的方式也可以有效的实现混入(多重继承)的方式。
- 继承与混入相同对象,就相当于拥有相同数据结构定义:
对于组合来说,我们可以注意到其都有一个字段,跟前面Tag所一致。这个字段就是用来表示其具体类型的动态字段。因为对于同样的数据结构,无论是面向对象的语言,还是C这种。其实都不知道当前数据结构的组成方式,而说明这种数据结构组成方式的便是通过这样一个字段来说明。
而值得注意的是,实际上面向对象语言中,类型的这个概念跟这里这个字段结构的功能是一致的。可以认为在类中,包含了这么一个描述结构,描述了当前对象的数据结构定义,而所有实例都能索引到这么一个类,而后这个描述结构解析了整个实例数据块。这个结构,最后就体现在了,类型转换以及反射上面。
结合前面看到的:
所以可以明确的看到,这三个实际上时在干一件事情,但是方式不一样。其最关键的部分我觉得就是:
抽象类型概念的封装。而对于这种封装的方式,无外乎最前面所提到的方式。
- 类型概念被封装为一个固定的数据结构。即这类数据结构有一部分固定的组成结构。
- 根据固定的组成结构,对整个数据区块进行解释和计算。
泛型编程
现在来考虑