Subsections

维护与复用

最初推广面向对象设计时,据说它比传统的过程式方法更易修改、扩展。虽然实践证明并非如此,但看到其他编程范式时,面向对象的开发者常常引用这一点。不管他们的专业水平如何,但凡涉及到大型项目,面向对象的程序员很可能会将可扩展性、封装性作为其优势。

经验丰富但较为客观的开发者已经承认,甚至写到,面向对象的 C++ 并非高度适用于有大量依赖关系的大项目,但只要严格遵循诸如 Large-scale C++ 一书中的准则,就可以 [#!jlakos!#]。如果无法立即看到面向数据开发范式,在维护、发展方面优势,本章中会阐明,为什么它比用对象工作更容易。

宇宙层级

不管怎么称呼:宇宙基类、万恶之源、Gotcha #97,还是 CObject。大型 C++ 项目中,所有东西都衍生自一个基类,近乎是个普遍的失败点。C++ 原生不支持内省或鸭子类型,所以很难有效利用CObjects。如果用数据库驱动,那就能在一开始就巧妙地引入宇宙基类,作为所有其他组件描述出的实体,从而保证所有东西都是实体。尽管基于组件的引擎经常会用一个 EntityID 作为所有者,但并非所有的引擎都有必要。也不是所有引擎都只有一个所有者。标准化数据库时,就会发现有不同的实体类型的集合。在前面关卡文件的例子中,可以看到一开始的对象,如何一步步变成MeshIDTextureIDRoomIDPickupID。甚至出现了 DoorID,当然也有必要。如果把所有这些 ID 集中到 EntityID 中,系统或许仍能正常工作,但却没什么必要。很多实体系统的确这么做了。但跟大多数运动一样,第一时间尝试找回平衡,动作幅度往往太大。可以在数据库行业提供的数据标准化的实例中找到平衡点。

调试

导致错误的主要原因:是变换带来的意外的副作用,或是一个条件没有返回正确值的意外边界。面向对象编程中,可以表现在许多方面:从解引用空值引起的异常,到忽略玩家的交互(游戏逻辑并不知道自己需要互动)。

记着系统状态,玩电脑以搞清楚状况,即:程序员绝对要在这个领域里,才能完成真正的工作。现实可能远没那么惊心动魄。实际情况更类似于对于这种情况的恐惧,只有代码复杂到了近乎致命时,程序员才需要进到这个领域。

生命周期

最常见的导致解引用 null 的原因之一,是控制生命周期与使用它的对象是分离的。如,假设有个敌人会死的游戏,每当敌人被删除时,那就必须小心翼翼地更新所有使用他们的对象;否则,最终就可能解引用到无效内存,进而可能解引用空指针。该类已经被破坏了。面向数据开发则倾向于避免这类情况。因为实体存在于数组中,就表示需要处理,如果表中只留下实体的一部分,没有完全删除它。那这又另一个不同的 bug,但不是崩溃,而且更容易发现、解决。因为它只要确保在实体被销毁时,所有包含它的表也会对应销毁其中的元素。

远离指针

在寻找面向数据编程问题的解决方案时,我们常发现指针不是必需的,而且常常导致解决方案更难扩展。在可能出现空值的地方使用指针,意味着每个指针不仅表示被指向的对象的值,也意味着它隐含着布尔属性:确定实例是否存在。去除这个不必要的额外功能可以消除错误,节省时间,并降低复杂度。

不良状态

bug 与处于错误状态有很大关系。因此,调试就变成了找出游戏是如何进入到当前错误状态的。

要给一个变量赋值时,就会破坏历史。以代码 [*] 为例。一个函数中只有一个返回语句,这种理想状态会导致这种错误,并且比预想的次数要多。有一个以上的返回点也有自己的问题。重点是,一旦走到了函数末尾,就很难搞清楚导致它验证失败的原因。甚至没法对问题点下断点。递归的例子就更危险,因为有一整棵树的对象,不管值是多少,都会在返回前对所有对象执行递归,并且也没法下断点。


\begin{lstlisting}[caption={修改状态可能会隐藏历史}, label={lst:13.1}...
...or( int i = 0; i < count - 4; ++i ) {
a[i] += a[i+4];
}
}
\end{lstlisting}

封装状态会隐藏内部变化。很快就会引入大量调试日志。相较于隐藏,面向数据则倾向以简单形式保留数据。有可能维护数据超过预期时间,然而会高度简化对变换的检查。假设有个将将可以工作的变换,在某些奇怪的情况下会出错,那通过添加断言,或者不删除输入数据,这类简单做法能一定程度减少辛劳和猜测,进而更好地理解问题并重现。如果大部分变换保持单向,即,从一个来源获取,并生成、更新另一个来源,那即使多次运行代码,仍然会产生与第一次相同的结果。变换是幂等的。这个属性可以确保找到错误症状,然后倒回去,追溯原因,而不必尝试重建初始状态。

要保持代码的可操作性,一种办法是用赋值实现变换。如果用多个变换操作,但都指向预定的连接点,节奏就由自己掌握,也可以避免回溯,回顾导致最终状态的原因。如果条件是一个条件表,只要在检查完成前保留输入,就能进入任何实时系统,并检查如何进入当前状态。仅此一点,就可以将 debug 时间缩减到最低限度。

复用性

面向对象开发人员常提到:面向数据开发中看起来缺乏复用性。这种想法在于,不能再次使用已经写好的代码库,因为设计部分体现在了实现中。当然,一旦开始针对某个软件项目的特殊功能优化代码,确实会出现无法复用的情况。开发面向数据的项目时,会假定无法复用源代码的情况会很严重,但实际并非如此。仔细想想复用性的真正含义,就能发现真相。

复用性从根本上说,并非指复用源文件或库。复用性是维持信息投资的能力,如创造更多表达,用它们传达意图,例如 STL,或其他的结构代码库。作为复用操作序列的案例,它们是有开发知识产权的实体的知识财富,非常接近专利的根基。后者中,表达往往是偶然发现,而非真正发明的。

版权法让人很难看出哪些源码有复用价值。它讨论源码,而非源码代表的知识产权。仅仅一个想法是无法版权化的。借由保持这种立场,版权方就能通过这种微弱联系维持保留信息的权利。复用性源自对它所存储的媒介中所含信息的认识。在我们的例子里,它通常以源码形式存储,但信息不是源码。通过面向对象开发,源码可以修改(适配器模式)到任何希望投产的项目。但是,源码也不是信息。现在以及将来能在数据上执行的任务的顺序及其存在本身,才是信息。可以理解为,一个编程技术所产生的任何复用性,都归结于其输入和输出的可变性。将一组时间上耦合的任务调整到新的使用框架中的难易程度,就是评判复用性的标准。

面向对象开发中,可以通过修改执行该任务的类,来应用代码中固有的信息 (也可以包装(wrapper),或使用代理(agent))。面向数据开发中,则直接复制函数和架构,并在应用面向数据的变换中包含的信息时,围绕输入、输出数据结构进行变换。

尽管从外表看,面向数据的代码复用性不高。但实际上,它以更简单的形式维护同样数量的信息,所以更具复用性。因为,它不像面向对象编程那样,携带相关数据、相关函数的包袱;也不像过程式编程那样,出于标准化倾向,会产生复杂的变换来生成输入,提取输出。

由于数据间的接口有一套更严格的规则,在面向对象编程中通常不能使用鸭子类型。但可以用模板实现,而且效果很好。只要保持统一命名风格,就可以把或许不明显的可复用代码,变成简单的策略,或一连串可以应用于任何类型数据或结构的变换。

面向对象的 C++ 的复用是信息和架构的混合。从围绕面向数据变换的角度开发,架构似乎只是很多花哨的代码。唯一值得保存的好架构是数据流和变换的实现。少有的情况下,可以复用面向对象的模块,毕竟面向对象项目之间的接口天生就会带来难度。

最可复用的面向对象代码是作为代理接口,出现在更复杂的系统中。最好的例子是 stdio.h 中的 FILE 类型,这种方法让一切都更易处理,高度可复用,并且完全封装。它用于代理进入任何平台和操作系统都需要打开、访问、写入、读出系统上的文件。

可复用的函数

除了在将所有数据保持在简单的线性排列时,可以自由扩展的优势,还有种隐含:意外发现可重复使用的解决方案。由于数据更加严格地格式化了,因此在适合的时候,几乎可以作为某种鸭子类型。如果数据适合一个变换,那么该变换就理应能应用于它。有人会说,仅仅因为类型匹配,并不表示函数就会产生预期结果。但这一点可以通过通过避免使用不理解的代码来规避。并且某些情况下,只要知道签名,就能理解变换。一个极端的例子,我们可以纯粹根据参数来理解相当数量的 Haskell 函数。最后,由于代码变得更易理解,所以决定复用一个变换之前,了解其功能花费的时间更少。

数据每次都以相同方式建立,再变换,并且总是保存在相同类型的容器中。所以很可能存在多种与设计无关的优化,能应用于代码中的许多位置。通用的排序、计数、查找、空间感知系统,可以在不调用 OOP 适配器,不实现接口的情况下,附加到新数据上。策略(Strategies) 得以执行。这也是为什么在数据库中,可以有通用查询优化。以这种形式开发,就可以在更多的项目中引入优化。

单元测试

单元测试在开发游戏时作用重大。但面向对象范式,会使程序员把代码看作是对象的表示,而非数据的变换,所以很难看到能测试什么。将不相关的概念链接到同一个对象中,并且在测试前需要设置复杂的状态,导致单元测试在游戏开发中有着先天劣势。因而简单的测试在面向对象编程中也难以编写。同时,由于在游戏世界中的对象,以某种不显著的方式变换为实体,测试变得更加复杂。除非开发者已经相当熟悉,否则很难写好单元测试。但单元测试的作用之一,是让尚未完全摸透系统的人可以修改,且不至搞得一团糟。

重构中常会用到单元测试,把游戏或引擎从一种代码、数据布局变成另一种,应对新的变化。重构,通常是因为数据的格式不对。如果此时再去标准化数据,本身就更难,反而更可能让数据处于未配置的形式。有时即便数据已经标准化了也不够。例如,当游戏设计发生变化,足以使原来的数据分析不再正确,或至少变得低效甚至无效。

在面向数据技术中,单元测试相对简单。因为精力已经集中在变换上了。如果尚未处于游戏开发过程中,那生成测试数据的表就是开发的一部分。保留一些表,用作单元测试也很容易。用单元测试帮助指导代码,相当于部分遵循了测试驱动开发技术,而它已经被证明是一种产生高效和清晰代码的好方法。

记住,在做面向数据开发时,游戏完全是由有状态的数据无状态的变换驱动的。为变换实现单元测试非常简单。甚至不需要框架,只要有输入/输出表,然后再用一个比较函数,检查变换的结果是否正确即可。

重构

重构过程中,知道是否因为代码改变带来破坏,始终都很重要。引入简单的单元测试就成功了一半。面向数据开发的另一个优点,它每次都能剥离出不必要的元素。或许我们会发现,重构更多是改变变换顺序,而非改变数据的表现形式。重构通常会涉及一些新的数据表示,但只要在构建结构时考虑到标准化,需求就不会那么多。真正需要时,从一种模式变换到另一种模式的工具可以一次实现,多次使用。

使用标准化的数据时,开发者可能就会意识到:一开始就重构了这么多,是因为意义嵌入到了代码中,数据放进了有名字的对象中,方法是针对对象执行,而非为了变换数据。