Subsections

问题出在哪?

面向对象设计有什么问题?它有哪些危害?

多年来,游戏开发者已经陷入一种 C++ 风格,它如此不讨好硬件,以至于对比托管型语言也没有快多少。游戏开发中,C++ 的使用模式与PlayStation 3 和 Xbox 360 这代硬件的适配性惊人地差,也难怪解释型语言在正常使用下只慢了 50%,在其专业领域有时还更快 [*]。为什么这种奇怪的语言风格,会嵌入到 C++ 游戏开发者的头脑中?流行的编写游戏的方式,为什么会变成最差的利用目标机器的方式?本质上,游戏开发风格中面向对象的 C++,危害在哪里?

其中一些,来自对面向对象的最初解释。很多游戏开发者认为,面向对象表示必须将在意的所有实例,都变成对象的实例映射到代码中。这种开发形式可以解释为面向实例开发,它把单一的独特实体放在整个程序之先。这样更易发现一些签注的问题。个例的性能很难说是差;对象方法难以准确计时,甚至可以说近乎不可能。当开发实践鼓励个体元素高于程序整体,就会消耗心智,因为开发者必须从角色角度,而非价值语义的角度,用它们的隐藏状态,考虑所有操作。

另一个问题:语言设计者并没有忽视性能,但可能是在孤立的情况下测试的。可能由于 C++ 的实际用途与库提供者的期望有大不同,也可能库的提供者按照内部指标工作,而不是确保理解客户。作者认为,在开发库或用于 C++ 的模板时,代码的性能不应只是一个可调整的选项,它应该是默认的。如果可以调整性能,就会用功能换取理解和性能。对于游戏开发者,这笔交易并不划算,但已经被广泛接受了;毕竟通用语言的好处非常诱人。

危害

主张:虚函数开销并不高,但若频繁调用,开销会累积。

或 —— 千里之堤,溃于蚁穴 [*]

简单的测试中,虚拟调用的开销可以忽略不计。对比虚拟调用,额外的开销近乎微不足道,除了解引用的开销和虚表指针占的额外空间外,很可能不再有其他明显的副作用。在获得某个特定实例中要调用的函数指针之前,额外的解引用也只增加了一点点负担,但让我们仔细看看到底发生了什么。

派生自基类,有虚方法的类都具备一定的结构。在类中添加虚方法,会立即在可执行文件中添加一个虚表。虚表指针会隐式地成为该类的第一个数据成员。这点没办法。语言规范中,允许编译器决定类的数据布局。为了实现虚方法,它们可以添加隐藏成员,在幕后生成新的函数指针数组。虽然也可以通过其他方式实现,但似乎大多数编译器都是通过虚表。重点是,虚拟调用不是操作系统级别的概念,就 CPU 而言,它们不存在,只是 C++ 的一个实现细节。

调用类的虚方法时,必须得知道会运行什么代码,通常得知道要访问虚表的哪一条。因此,要读取第一个数据成员,以便访问并调用正确的虚表。先从类的地址加载到寄存器,再加上偏移。每次“没什么开销”的虚方法调用,都是一次查表,所以在编译后的代码中,所有虚拟调用实际上都在解引用函数指针数组。偏移量用在这里,它是函数指针数组的偏移量。得到真正的函数指针地址后,才能开始解码指令。有些方法可以不调用虚表,特别是 C++11 中,final 关键字有了些进步,能帮那些无法重写的类知道,如果它们调用自己,就可以直接调用函数。但它对多态调用、不知道具体类型的接口访问都没有帮助(见代码 [*])。不过偶尔在一些习惯性做法中也能发挥作用,如私有实现(pImpl),以及奇怪的常见的模板模式。


\begin{lstlisting}[caption={简单的派生类}, label={lst:14.1}, captionpos=b]...
...
// prints ''Derived'' via direct call
pd ->LocalCall ();
}
\end{lstlisting}

多重继承会稍复杂些,但基本仍然是虚表,只不过每个函数会定义要引用哪个类的虚表。

现在,我们来计算一下这个调用涉及的实际操作:先是 load,然后 add,再一次 load,最后是 branch。几乎所有的程序员看来,为了运行时多态,这都算不上什么沉痛代价。每次调用有四个操作,所以可以把所有实体扔进一个数组,然后循环更新、渲染、收集碰撞状态、生成声音效果。乍一看似乎是不错的权衡,但仅限这些特定指令开销很低的时候。

四条指令中,有两条是加载,开销似乎不应该太大。但除非命中附近的缓存,否则加载就需要很长时间,解码指令也需要时间。加法开销很低 [*],它会修改寄存器的值,寻址正确的函数指针。但是分支开销就不一定了,在第二次加载完成前,不知道下一步会去哪。可能会导致指令缓存未命中。总而言之,由于虚拟调用而浪费了大把时间,在任何显而易见的大规模游戏中都很常见。在这一大块时间里,光是浮点单元就能完成大量点积、方根运算。最好的情况,虚表指针已经在内存中,对象类型与上次相同,所以函数指针地址也相同,因此函数指针也在缓存中。这样的话,分支很可能不会停顿,因为指令也可能还在缓存中。但对于各种类型的数据,并不是每次都能遇到“最佳情况”。

考虑另一种情况:函数末尾,需要要返回值,然后调用另一个函数。指令顺序相当清楚,在 CPU 看来近乎于一条直线。获得指令方面没有任何偏差,只是沿着每个函数,依次跟随程序计数器。于是可能提前很长时间猜到将要调用的新函数地址,因为它们都不依赖于数据。即使有大量函数调用,在编译时也可推断,于是很容易被预取(prefetch)、预翻译(precompile)。

C++ 的实现不喜欢我们迭代对象的方式。迭代一组异质对象的标准方法,就是字面意思上:拿到迭代器,依次调用每个对象的虚函数。常规的游戏代码中,会涉及到为每个对象加载虚表指针。在加载缓存行的时候会导致等待,而且难以轻易避免。加载到虚表指针后,就可以用常数偏移量(虚方法的索引)找到目标函数指针。然而,鉴于游戏中常见的虚函数的大小,该表不会出现在缓存中。自然,另一次等待加载少不了。等这次加载完成,我们只能寄希望于,该对象实际上与前一个元素的类型相同;否则,又不得不再等待一些加载指令。

即使没有加载,数据加载前也不知道哪个函数会被调用。意味着需要依赖缓存行的信息,才能确定正在解码的是正确的指令。

游戏中的虚函数之所以大,是因为开发者已经被灌输了这样的观念:只要不在紧密循环中使用虚函数就好了。但无一例外,导致它们被用于更多其他架构考量中去:如对象类型的层次结构,或树状问题解决系统(如寻路,或行为树)中的 helper 类。

我们再重温一遍:许多开发者现在认为,使用虚拟的最好方法是把繁重的工作负载放到虚方法的主体中,这样可以降低虚拟调用机制的开销[*]。然而,这样做,几乎一定会导致,指令和数据缓存的很大一部分会被每次 update() 调用驱逐;并且大多数分支预测器槽也可能变脏,无法为下一次 update() 带来任何好处。假设虚拟调用不会累积,因为是在高级代码上调用的。很好,直到它变成通用编程风格,开发人员不再考虑应用程序如何被影响着,最终导致每秒数百万次调用。所有这些低效调用都会累加并影响硬件,但它们几乎从未出现在任何性能分析(profile)中。问题不在于它存在与否,而在于它在机器的整个处理过程中稀疏地分散在各处。总是会在代码调用的某个地方出现。

Carlos Bueno 的 Mature Optimization Handbook[#!CBueno!#] 一书中提到,盲目追随低垂的果实,很容易错过拖慢速度的真正原因。这就证明先提出一个假设是有用的,当确定没有得到预期回报时,就能更快回溯、重整。例如 Facebook,他们追踪了导致驱逐的原因,并优化了这些功能,不是为了速度,而是为了尽可能消除缓存中驱逐其他数据的几率。

C++ 中,虚表中的函数指针按类存储。还有一种方法是,为每个函数设置一个虚表,并根据调用类切换函数指针。实践中运行良好,也的确省了一些开销,在一组对象的一次迭代中,虚表对所有的调用都是一样的。然而,C++ 设计允许运行时链接到其他库,库中的新类可能继承自现有代码库。该设计必须允许能为运行时链接的类添加新的虚方法,并确保可以从原始运行代码中调用它们。如果 C++ 采用面向函数的虚表,那么每当链接一个新库,不管是静态编译的链接时,还是在动态链接库的运行时,语言都必须在运行时修补虚表。于是,为每个类使用一个虚表提供同样的功能,但避免了在链接时、运行时修改虚表;这些表是由类导向的,根据语言设计,它们在链接时不可变。

结合虚表的组织,游戏倾向的方法调用顺序,即使以高度可预测的方式执行列表,缓存未命中问题也很常见。不仅是因为类的实现,任何时候数据都是指令运行的决定性因素。游戏通常会实现脚本语言,它们通常被解释并运行于虚拟机上。不管虚拟机(或 JIT 编译器)如何实现,总有某种数据控制着指令调用。这就会导致分支预测错误。这也是为什么,通常解释型语言比较慢:在字节码解释器中,它们要么基于加载的数据执行代码,要么即时编译代码,虽然能生成更快的代码,但也带来了自身的其他问题。

开发者在不使用 C++ 中内置的虚函数、虚表、this 指针的情况下,实现面向对象的框架,除非按函数使用虚表,否则也没法提高缓存命中率。但即便特别小心,用开发者的访问模式进行面向对象编程,即在异质对象的数组上调用单一虚函数,仍会发生解码相同指令、缓存未命中的情况。也就是说,他们最希望的是每次虚拟调用减少一个基于数据的 CPU 状态变化。但却留下了两个预测错误的机会。

那么,面对所有这些明显的低效,为什么游戏开发者仍坚持使用面向对象编码实践呢?游戏开发者们常常作为计算机软件开发的前沿被提及,为什么他们不选择全盘脱离这个问题,完全停止使用面向对象开发实践呢?

映射问题

主张:对象为将现实世界的问题描述为最终代码层面的解决方案,能更好地映射。

游戏编程中,面向对象设计,始于从实体的角度考虑。设计中,为每个实体都赋予一个类,如 boatplayerbulletscore。每个对象都保持自己的状态,通过方法(method)与其他对象通信,并封装。因此,当某个实体的实现有变化,使用它或为它提供效用的其他对象则不需要改变。开发者们喜欢抽象,回看历史,他们不单单要为一个目标平台编写游戏,一般有两个以上。而过去还只是在主机制造商之间。现在,开发者需要兼顾 $Windows^{TM}$、主机,以及移动端。过去的抽象主要针对硬件访问,少量玩法抽象。但随着游戏开发行业日趋成熟,物理、人工智能、玩家控制等领域也都有了常见的抽象形式。这些常见的抽象衍生出第三方库,其中许多也使用面向对象的设计。对于库,通常用代理与游戏交互。这些代理对象包含他们自己的,或隐藏,或公开的状态数据,实现了一些功能。通过这些功能,可以其所属系统的约束条件下操作它们。

游戏设计的灵感对象(如 boatplayerbullet)维护着代理,并通过它们获悉世界中发生了什么。玩家通过面向对象 API,与物理、输入、动画以及其他实体交互,隐藏了很多完成任务的实际的细节。

面向对象设计中的实体,是数据的容器,用于存放操作这些数据的函数。不要把这些实体与实体系统中的“实体”混为一谈,因为面向对象设计的实体,在其生命周期内是不可变的类。C++ 中没有原地重新构造类这一特性,所以面向对象的实体在其生命周期中不会改变类。不出所料,没有合适的工具的话,优秀的工程师就会尝试权宜之计。开发者不会在运行时改变对象的类型,但如果游戏实体需要,他们会创建新的并销毁旧的。但通常,由于语言中没有这个功能,即便有意义,也没法充分利用。

例如,在 FPS (第一人称射击游戏) 中,声明一个对象,表示玩家动画模型,玩家死亡时,用一份克隆表示尸体的布娃娃(rag-doll)。可以隐藏动画对象,并移至下一个重生点,而尸体对象有不同的功能和数据,会留在死亡地点,好让玩家看到。于是,一旦玩家死亡,尸体对象就替代动画模型,因而需要定义复制构造函数。玩家重生时,动画模型再次可见,如果愿意,玩家还可以去看看尸体。这样效果不错,但如果不生成不同类型的克隆,而是将玩家类转换成死去的布娃娃,反倒没什么必要。而且还有其他潜在问题:克隆过程可能出错,导致其他问题;如果玩家死亡,但又能被复活,那么就得能将布娃娃变换回动画模型。这就更复杂了。

再举一个 AI 的例子。大多数游戏中运行的 AI 的有限状态机和行为树,都会为所有潜在状态维护必要数据。假设例中的 AI 有三种状态,Idle; Making-a-stand; Fleeing-in-terror,它就有所有状态所需的数据。假设 Making-a-stand 状态有个恐惧值累积,AI 会战斗,直到害怕到无法继续,而 Fleeing-in-terror 状态有个定时器,AI 会逃跑,但只有一段时间,那么 Idle 状态也会有这两个非必须的属性。这个小例子中,AI 类有三个数据项,state; how-scared; flee-time,但只有一个用于所有状态。如果 AI 在状态转换时可以改变类型,那甚至不需要状态成员,虚表指针能承担这个功能。AI 只在适当状态下,才为每个状态的成员分配空间。C++ 中,能做到的极限就是手动改变虚表指针来伪造它,虽然很危险,但总归是能做到;或为每种转换实现复制构造函数。

除了类型不可变,面向对象开发还有个哲学问题。人是如何在现实生活中感知物体的?每一次观察总会有个背景。一张简陋的桌子,你看着它,可能会看到四条桌腿,木制,适度抛光。以上,还能看到它是棕色的,有些反光。能看到质地,颜色,并且觉得它是某种确定的颜色。然而,如果学习过艺术,可能就知道,我们看到的不是实际存在的东西。没什么纯色。盯着桌子,也没法看到它准确的形状,只能推断。如果是通过进入视网膜的平均光色,推断它是棕色,那如果关了灯,还是不是?如果光线太强,只看到抛光表面强烈的反射,又如何?如果闭上一只眼睛,从其中一条长边看着长方形桌面,看到的也不是直角,而是梯形。我们看到物体时,会自动调整、分类。会应用我们的成见,锁定,帮助做出推断。这就是为什么面向对象的开发有如此吸引力。然而,人容易理解的东西,对计算机可能并没有那么友好。将对象当作游戏实体,我们觉得是一个整体。但计算机没有对象的概念,只把对象看作是组织得很差的数据和随机调用的功能。

再以桌子为例,假设桌腿高约 90cm,即标准桌高。如果只有 30cm,那可能是张茶几。很矮,但仍可以扔杂志、放杯子。但要是桌腿只有 3cm,那就不是张桌子,只是块粘了几根棍子的大木头。不同尺寸的同一物品,轻易地就划分为三个不同类别。桌子、茶几、木头。但什么时候这块木头可以称作茶几?高度 15cm  30cm 的时候吗?跟沙子的问题一样,从沙粒到一堆沙子?多少粒是一堆,多少又算一个沙丘?答案必然是:无解。这样有助于理解计算机的思维方式。它不知道我们人类分类的具体区别,毕竟某些程度上,我们也不知道。

对象的类,难以用它是什么来定义,最好用它做什么来定义。这就是为什么鸭子类型化的方法很强大。我们也意识到,如果根据"做什么"能更好定义类型,那从根本了解多态类型时,就会发现,它只是在"做什么"方面有多态性。C++ 中,我们知道有虚函数的类,能当作运行时的多态实例调用;但如果它没有这些,就不清楚了,不过也没必要第一时间搞清楚。这就是多重继承发挥作用的点。多重继承,只是意味对象能响应某些信号。它在声明中了签下了一份协议,能够执行一些多态函数。如果多态只是对象履行协议的能力,那就不需要每次都用虚拟调用处理。还有其他方法能让代码能根据不同对象,表现出不同效果。

大多数游戏引擎中,面向对象的方法会导致大量对象处于非常深的层次结构中。常见的实体的继承链可能是这样: PlayerEntity → CharacterEntity → MovingEntity → PhysicalEntity → Entity → Serialisable → ReferenceCounted → Base.

这些深层结构,必然导致使用虚方法时多次间接调用。涉及到交叉代码也产生诸多痛苦,即,与不相干的代码、层级相互影响。假设有个游戏,人物在场景移动。这个场景中,会有人物、世界,还有些粒子、灯光,有静态,有动态。所有这些,要么直接被渲染,要么渲染要用到。传统的方法,或是用多重继承, 或是确保每个实体的继承链上有 Renderable 基类。但会发出声响的实体呢?是否也要添加一个发声类?可序列化的实体,或可显式管理的实体呢?常见到需要一个独立内存管理器(如,粒子)的,或只需选择性渲染的(如,垃圾、花、远处的草),又该如何处理?这一点已经解决了无数次:将所有最常见功能,放到游戏的核心基类中。当然也有例外:如关卡有动画时;玩家角色处于开场或死亡画面时;又或是 Boss(特殊到值得多写点代码)。只有不使用多重继承时,这些 hack 才必要,不过一旦用上了多重继承,一张网就悄然编织,最终以虚拟继承及其带来的复杂状态告终。妥协的结果,往往总是成为某种形式的宇宙基类的相反模式。

面向对象开发擅长在源代码中,面向人类表述问题,但不擅长表述面向机器的解决方案。它难以提供创建最佳解决方案的框架,所以问题仍然存在:为什么游戏开发者仍然使用面向对象技术开发游戏?可能不是为了更好的设计,而是为了更容易修改代码。众所周知,开发者会随着设计更迭,不断修改代码,直到上线。面向对象开发是否能让修改和维护更简单、更安全?

内部化状态

主张:封装让代码更易复用。不影响使用的前提下,更易修改实现。维护和重构变得简单、快速、安全。

封装本质上是为用户提供一份协议,而不是提供一份原始实现。理论上,封装地好的面向对象代码,能避免因改变对象操作和数据的方式带来损害。如果所有使用该对象的代码都遵守协议,且不通过访问函数直接使用任何数据成员,那无论对该类的协议内部实现怎样改变,都不会引入新 bug。理论上,只要不修改协议,只是扩展,就能任意改变实现。这就是开闭原则(Open/Closed Principle)。类应该对扩展是开放的,但对修改是封闭的。

协议是为了保证复杂系统能工作。但在实践中,只有单元测试才能保证这点。

有时,程序员会不知不觉地依赖对象实现的隐藏特性。比如依赖的对象有个 bug,但正好符合使用情况。如果该 bug 被修复,那使用该对象的代码就不像预期般工作了。虽然被保留了协议使用,但并没能帮助另一份代码在不同的版本中正常工作。相反,还带来误导,一份错误地希望,即返回值不会改变。有时候不一定得是 bug。对象内部的时间耦合(或意外、未记录的特性)在后来的版本中消失,也会在未能报错的情况下带来损害。

如一个按顺序维护内部列表的实现,有个用例恰好依赖它(用户用例中不可预见的 bug,不是有意依赖 )。维护者推送了一个优化性能更新,用户只得到一堆新 bug,他们很可能不会觉得是自己的问题,而是性能更新导致的。

再举一例:有个项目管理器,维护一份按名称排序的项目列表。有个函数返回所有符合过滤器的项目类型,调用者可以迭代返回列表,直到找到想要的项目。为了加速,可以在发现一个名字比要找的项目靠后时,提前退出,或也可以对返回的列表做二分查找。两种情况下,如果内部表示法改变,不再按名称排序,那应用代码就不工作了。如果内部表示变成了按哈希值排序,那提前退出、二分查找的实现都被破坏了。

在许多链表实现中,可以决定是否存储列表长度。选择存储一个计数成员会导致多线程访问变慢;但如果不存储,则会让查询链表长度变成 $O(n)$ 的操作。只想知道列表是否为空的话,如果对象协议只有一个 get_count() 函数,就没法确定是检查计数是否大于 0,还是检查 begin()end() 是否相同,哪个更高效了。这也是个表明协议提供的信息太少了的例子。

封装看起来只是一种隐藏 bug 的方法,并且会导致程序员做假设。有句老话,除非能接触到源码,否则封装会阻止得到是或否的答案。如果有源码,且需要查看它以找出问题所在,那么封装做的只是在工作上又加了一层,并没什么额外的用处。

面向实例开发

主张:把每个对象都变成实例,能更容易地思考对象的责任、生命周期、及其在世界中的归属。

实例思维的第一个问题:一个条目只做一件事,这种思路必然导致性能不佳。

第二个,也更普遍的问题:让人抽象思考实例,以完整对象为思考的单元,只会让算法变低效。甚至对程序员用户,也隐藏项目内部表示,就会导致在不同对象的思维模式间来回跳转。比如有个条目,需要修改另一个对象,但发现当前环境下无法做到,所以不得不向其所在容器发送消息,以回答另一个实体相关的问题。然而,程序常常在这些线路上忽略了数据要求,在查询、响应中发送额外信息,不仅会放出不必要的权限,还会因为相关的系统状态,带来不必要的限制。

这里举个反例,比如有个城市建设类游戏,其中有人口幸福指数。若每个市民的幸福指数都是独立的,就需要一些计算。首先假设市民数量尚未严重超标,比如最多 $1,000$ 座建筑,且每座最多住 $10$ 人。若只在必要时计算幸福感,就能更快完成任务。至少在这个游戏里,这些数字相似,正确的做法就是用惰性求值。如果从市民个体角度,而非城市角度来计算,反而会比较棘手。比如一个市民的幸福感可能来自:工作离家近,周边生活设施齐全,远离工业园区,交通便利等。那幸福感计算可能会基于某种寻路算法。如果能缓存寻路结果,至少同一建筑物内的市民可以共享它;但每个建筑到其他建筑的距离总会有微小差异。在这么多实例上运行寻路的开销会很大。

相反,如果从城市角度计算,就可以为每种会影响幸福指数的建筑,通过Flood Fill 生成距离图,使用 Floyd-Warshall 算法创建整个城市的通用距离图,帮市民确定到办公地点的距离。用 $O(n^3)$算法代替 $O(n^2)$ 算法可能有点傻,但寻路是为每个市民做的,所以变成了$O(n^2m)$,虽然在算法层面也没什么优势。但在真实环境下,寻路本身还有其他开销,计算幸福指数前,执行 Floyd-Warshall 算法生成表格,后续的计算工作就能更简单(指数据存储方面),且进入功能代码之前的分支也更少。Floyd-Warshall 算法还能根据现有地图来确定需要更新的条目,实现部分更新。若从实例角度运行,知道拓扑结构变化,或是附近的建筑类型,就需要以某种形式,逐实例做距离检查。

总之,抽象是解决困难问题的基础。但在游戏中,我们通常不在玩法层面上解决困难的算法问题。相反,我们往往倾向过早抽象。通过面向对象设计,通常也能以一种简单、可识别的方式抽象。但直到很久以后,对其过度依赖,因而无法在不影响其他代码的情况下清除它时,才逐渐看清其代价。

层设计与变革

主张:可以通过扩展继承来复用代码。添加新功能很简单。

通常认为,游戏程序员在 C++ 中使用类,主要是为了继承。最直观的好处,是能继承多个接口,获取系统对象(如物理、动画、渲染)的属性和代理权。早期使用 C++ 时,层次通常很浅,基本不会超过三层。但后来,诸如玩家、载具、AI 玩家这些主要类中,超过九层的继承链已经很常见了。例如,在《虚幻竞技场》(Unreal Tournament)中,迷你机枪(minigun)的弹药对象是这样:

Miniammo → TournamentAmmo → Ammo → Pickup → Inventory → Actor → Object

游戏开发者借助继承,能稳定实现多态。因而能大量更新、渲染、查询游戏实体,无需手动编码做类型检查。从一个类继承的同时,也增加了功能,同时能减少复制粘贴,所以很受开发者欢迎。早期的混合继承还能减少代码里的 bug,很多时候,bug 之所以存在,只是因为程序员没能修复所有地方。渐渐地,多重继承淡化为只继承接口,只继承自一个真正的类,而任何其他的类都必须是按照 Java 中定义的纯虚接口类。

虽然看起来通过继承来扩展类的功能很安全,但很多情况下,重载方法时,类不一定会像预期那样。要扩展一个类,往往需要阅读源码,不光是该类本身,还有它继承的类。如果基类创建了一个纯虚方法,会强迫子类实现它。如果理由合理,就应强制执行,但又没法强制要求每个继承类都实现这个方法,只能是第一个继承它的可实例化的类。这样可能会导致一些不明显的错误,即一个新类有时会像它的父类那样行事,又或被当作父类。

C++ 中缺失非虚这一概念。没法声明一个函数不是虚函数。即,可以定义一个函数是 override,但没法定义它 non-override。如果用常用词汇组合,引入新的虚方法,就可能产生问题。如果它与有相同签名的现存函数重叠,或许就是个新 bug。

C++ 继承的另一个陷阱,是运行时与编译时的链接。一个典型的例子:方法调用的默认参,以及难以理解的覆盖规则。你觉得代码 [*] 中的程序会输出什么?


\begin{lstlisting}[caption={运行时?编译期?还是链接期?}, label={...
..., char *argv [] ) {
A *a = new B;
a->foo();
return 0;
}
\end{lstlisting}

如果发现输出是 10,会惊讶吗?有些代码依赖于编译状态,有些依赖于运行时。通过扩展类增加新功能,很快就变成玩火。两层以下的类就可能带来耦合的副作用,抛出异常(或更坏,不抛异常悄悄失败);绕过改动;又或者因为命名空间已经占用、不兼容等问题,导致功能无法实现,比如要某种对齐,或是需要在某个 RAM Bank 中。

继承确实能干净地实现运行时多态,但正如前文所述,它并非唯一解。通过继承增加新特性,需要重新审视基类,提供默认实现(或是纯虚),然后在所有需要处理新特性的类中实现。因而需要修改基类,如果用纯虚的方式,可能会触及所有子类。因此,尽管编译器能帮我们定位到所有需要修改的代码,但也没能让修改来得更容易。

使用类型成员替代虚表指针,同样能利用到运行时链接,并且对缓存命中率更友好,也更容易添加新功能,更易推断。实现这些新功能时,它的包袱更少,相较于继承,混合与兼容变得更简单,多态代码也能放到一起。例如,假设有个虚函数 go-forwardCar 类会踩下油门。Person 类,需要设置方向向量。UFO 类中,也要个方向向量。看起来 switch 中的 default: 就能胜任。又比如虚函数 re-fuelCarUFO 会启动 re-fuel 计时器,并在播放加油动画时保持静止;而 Person 可以直接减耐力药剂库存,立即补充体能。同样,带 defaultswitch 语句能实现所有运行时多态,但不需要再为在每个类的每个功能尺度的细化实现动用多重继承。继承并不擅长在类中选择每个方法做什么,它仍有可取之处,也的确可以绕开继承实现多态。

使用继承的初衷,是不需要重新审视基类,也不会为了扩展而改变现有代码,但实际上极有可能必须查看基类实现。随着游戏规格变化,在基类层面上做改动也变得很常见。继承也会抑制某些类型分析,将人们的思维锁定在:对象与游戏中的其他对象类型有是什么的关系。对象是功能的组合,程序员被锁在这一概念之外,灵活性随之一同束缚。把多重继承限制到接口层面,虽然有助于减少代码复杂度,但将类视作复合对象这一好办法也被掩藏。尽管它还会滥用缓存,本身也不是好方案。而 switch 类型也能提供类似于虚表的功能,并且没有相关的包袱。那为什么还要把东西放进类里呢?

分工

主张:模块化架构能减少耦合,更易测试。

人们认为,面向对象的范式也能保证代码质量。严格遵守开闭原则,始终通过访问器、方法、继承来使用和扩展对象,程序员实现的模块化代码明显多于纯过程化编码。每个对象的代码分离成单元。这些单元是所有数据,以及作用于数据的方法的集合。这些已经经过实践验证,测试也更简单,可以对每个对象孤立测试。

然而,我们知道这不是真的,数据因目的产生联系;目的,又是因数据连结在一起的,一系列偶然的关系。

面向对象设计在通信层面存在错误。对象并非系统,系统需要测试,系统不仅包括对象,还包括它们内在的通信。对象间的通信难以测试。实践中,很难隔离类之间的相互作用。面向对象的开发,导致从面向对象的角度去观察系统,因而像数据变换、通信、时间耦合这类非对象,难以独立出来。

模块化结构能限制变化带来的潜在损害,因此是好的。但就像前文中的封装一样,对任何模块的协议都必须足够明确,才能减少外部去依赖实现中非预期副作用。

面向对象的模块由对象的边界定义,而非基于更高层次的概念,因此效果并不好。而好的例子有:stdioFILECRTmalloc/freeNvTriStrip 库的 GenerateStrips。每一个都是通过稳定、精炼、文档完备的函数集,访问那些的繁杂功能。

面向对象开发中的模块化,能避免不理解代码的程序员破坏代码。但为什么不理解代码的程序员,使用简化的接口也很安全?刚接触代码的人眼中,对象的方法就是它的说明书,所以把所有重要的操作方法写一块,就能为用户提供线索。模块化在这里很重要,游戏中的对象经常很大,不同方面的大量功能汇聚在一起。游戏对象没有尝试找方法解决跨领域的问题,反而想满足所有需求,避免将自己限制在最初的设计中。这种臃肿,模块化的方法,即,在源代码中按关注度整合方法,对于刚开始接触对象的程序员很有帮助。要解决这个问题,初看应该使用能在基础层面支持跨领域的范式,但 C++ 中的面向对象的代码,似乎并不能胜任它。

如果面向对象开发不能这样提高模块化程度,比不过显式的模块化代码,那它能做什么?

可复用的通用代码

主张:复用通用代码可以加快开发。

复用旧代码,持续减少开发的成本,开发者们一直将其奉做圣杯。人们觉得,可以用现有代码组装应用程序,最多实现一些额外的小功能,就能避免浪费时间和精力。但事实上,任何有趣的新功能,都可能不兼容旧代码和旧数据。于是需要重写旧代码,好加入新功能,新数据布局。若软件项目能基于现有解决方案,从为旧项目的功能创建的对象中建立,那它可能不是很复杂。任何具有显著复杂度的项目都包括成百上千,甚至是成千上万的特例对象,满足项目所有的特殊需求。例如,绝大多数游戏都会有玩家类,但几乎没有游戏共享一套核心属性。扑克游戏中是否有世界坐标成员变量?赛车游戏的玩家有没有命中率成员变量?纯离线游戏中,需不需要有“玩家账户”(gamer-tag)?可以重复使用的通用类并不能更轻松地创建游戏,它只是将特化的内容转移到其他地方。一些游戏引擎通过用脚本扩展基本类来实现这一点。有些引擎则将玩法限制在某一类型,并通过数据驱动的方式扩展。目前为止,还没有人创建一套游戏 API,如果这样,它必须非常通用,因而没法提供比开发语言本身更多的内容。

复用,被制作人追捧,被没有游戏制作经验的人推崇。对于许多游戏开发者,复用本身已经变成一种目的。泛型的隐患在于,只注重保持一个类的泛型,使其能够最大限度复用,但从未考虑为什么复用,如何复用。首先,为什么?它是主要的绊脚石,开发者需要尽快知道。为了通用而生产,并非一个有效目标。一开始就做通用的东西,只会增加开发的时长,还不带来价值。有些开发者觉得这是短视行为,然而,同样的话也可以用来反驳。如果一个类只用在一处,怎么泛化它?类的实现只在有东西可测时才能保证测试有效,如果它只用在一处,就只能测试它在一种情况下的工作情况。在能复用类之前,本质上就无法测试类的复用性。通常的经验法则,除非至少有三处用它,否则就不是可复用的。如果泛化该类,但除了初始情况外,没有其他的测试案例,就只能测试在泛化这个类时,有没有造成破坏。所以,如果不能保证这个类在其他类型、情况下都能工作,那么泛化这个类所做的,就只是增加代码量,藏一些 bug。这些 bug 现在隐藏在可以工作的代码中,甚至可能在隔离状态下通过测试,等同于,泛化过程为引入的任何 bug 都盖章,批准,认证。

测试驱动开发,隐式否认了通用编码,除非真的有用。只有把代码转移到更通用的状态,才算是好选择,即,通过复用通用功能减少冗余。

通用代码如果要适用于多数情况,就必须满足更多基本功能。如果写一个模板化的数组容器,方括号运算符访问数组就属于基本功能,还得实现迭代器,可能还要添加插入操作以避免在内存中整理数组。如果有重写这些函数的需求,各种微小的 bug 就悄悄冒出来,而链表在权宜的[*]情况下的实现,可谓是声名狼藉。为了适用所有用户,任何通用容器都应提供一整套操作的方法,STL 就是典型。在成为 STL 专家前,要理解数百种不同的函数。也必须成为 STL 专家,才能确保用 STL 写出高效的代码。而且 STL 的各种实现,有大量文档可用。大多数 STL 的实现即便功能不同,也会非常相似。即便如此,相当于还需要学习另一种语言,因此成为有价值的 STL 程序员可能需要花些时间。 STL 是一种新的语言,它有自己的语法体系。为了限制这种情况,许多游戏公司会大幅缩减并重新解释 STL 的功能集,或提供更好的内存管理(因为硬件很笨),扩展可选的容器数目(除了map,还可以直接选 hash-map, tree, b-tree 等),或显式实现更简单的容器,如堆栈、单向链表还有它们的 instrusive 版本。这些库通常范围较小,因此比 STL 的变体更易学习、破解,但仍需要花时间。过去,这种行为算是很好的妥协,但现在 STL 有大量在线文档,很少有理由不使用 STL,除非内存开销非常大,如嵌入式领域,主内存是以千字节计算的,或者编译时间非常重要 [*]

然而,我们能了解到的是,开发者仍需学习通用代码,以提高编码效率,不带来意外的性能瓶颈。如果采用 STL,至少手头上有大量文档。如果游戏公司实现了一套及其复杂的模板库,不要指望任何程序员在有足够时间学习它之前会去用。也即,如果写通用代码,反倒希望人们不要用它,除非是意外遇到,或被明确告知这是通用代码。并且可能也没法迅速获得信任。换句话说,一开始就写通用代码,是个可以快速产出大量代码,且不带来任何价值的好办法。