更新于2019/7/10
本章结构:
面向数据设计
- 一切都是关于数据
- 数据不是问题域
- 数据和统计
- 数据可以改变
- 数据是如何形成的
- 框架
- 结论和需要记住的点
面向数据设计
面向数据设计这个名字是由Noel Llopis在他2009年九月的文章中(Noel DOD)正式提出的,但在此之前,面向数据设计已经以各种形式存在数十年之久了。面向数据设计是否是一个编程范式,这一直存在着争议。许多人认为面向数据设计可以与其他编程范式共同使用,如面向对象、过程式、函数式编程。某种程度上说,他们是正确的,面向数据设计可以与其他编程范式协同工作,但这不排除它能从整体上影响编程。其他的编程范式在某种程度上也能够协同工作。总所周知,Lisp中函数式编程和面向对象编程共存,C则是过程式编程和面向对象编程。我们应该抛弃这些争议,认可面向数据设计是一个重要的工具,并且它能与其他设计方法共存。
回到2009年,硬件已经成熟,正在等待软件开发方式的改变。具有高速潜能的计算机受阻于无视底层硬件的编程方式。那时候游戏开发者们的编码让许多的引擎开发者们哭泣。但是时代改变了。现在许多移动和台式机平台上的解决方案似乎减少了对面向数据设计的需求,其原因并不是机器能够更高效地执行这些低效的代码,而是游戏设计变得更不严格,复杂度也降低了。随着移动平台的浪潮似乎开始转向AAA发展,将会使我们重新谋求复杂度的管理,榨取机器的性能。
现实中,我们使用的计算机都是多核的,包括口袋里的手机也是一样。因此,学会如何以一种更不串行的方式编程是很重要的。远离对象消息传递,立即获得响应是面向数据给程序员们带来的好处。坚定依赖于对数据流认知的编程,可以使你迈向GPGPU(图形处理器通用计算)和其他计算方法。为使游戏变得栩栩如生,需要这些方法来处理复杂工作。对面向数据设计的需求只会与日俱增。因为抽象和串行思维将会是你竞争对手的瓶颈,而拥抱面向数据方法的人会获得成功。
一切都是关于数据
数据是我们拥有的一切。我们通过转变数据来创造用户体验。我们打开文档时加载数据。数据是屏幕上显示的图像,是你按下游戏手柄上按钮的脉冲,是扬声器产生声波的原因,是决定你如何升级的方法,是坏蛋如何知晓你的位置并向你射击的原因。数据是炸药爆炸需要的时间,是你摔到地刺上丢失金币的数量。数据是游戏结束时显示的美丽场景中,每一个粒子当前的位置和速度,这些都是从硬盘中加载出来的,数据的变换由源代码编译的汇编指令解码的机器指令驱动。
任何应用都离不开数据。Adobe Photoshop没有图像则一无是处。笔刷、图层、压感都是必不可少的。Microsoft Word不可以没有文字、字体、分页。FL Studio 没有事件则毫无价值。Visual Studio如果不能写代码则没有任何意义。所有应用的目的都是基于一些输入数据来产生输出数据。数据的形式可以非常复杂,也可以简单到不需要文档说明,但是所有的应用程序都生产、需要数据。如果它们不需要可识别的数据,那么它们顶多是玩具或技术原型。
机器指令同样是数据。指令占据存储,消耗带宽,可以被改变、加载、保存和构建。开发者很自然地会忽略指令是数据的事实,但在过去老旧的机器上,指令与我们现在观念中的数据几乎没有区别。即使现在的硬件会为可执行指令留出特定空间以保证这些指令不会被修改和损坏,但这个相对新的发明几乎不算是发明。在修改的Harvard架构中,指令和数据是被同等看待的。因此,指令也是数据,也是我们转换的对象。我们使用指令并将其转化为行为。指令的数量、大小、频率都是需要考虑的。我们可以控制解决问题所使用的指令,于是有了优化。应用我们对数据的了解,我们可以决定如何处理数据。了解指令运行的结果,为我们提供了数据来确定哪些指令是必要的,哪些是繁忙的,哪些是可以用消耗更低的指令等效替换的。
这构成了发展面向数据方法的论证基础,但遗漏了一个主要因素:所有的数据以及对数据的转换,如字符串、图像、指令,都是运行在某个东西之上的。有时候,这个东西相当抽象,如运行在未知硬件上的虚拟机。有时候,这个东西是非常具体的,比如明确了的特定CPU和GPU,已知容量和带宽的存储。在所有情况下,数据不仅仅是数据,它脱离不开硬件。数据存储在硬件上,并经由同一个硬件转换。从本质上讲,面向数据设计是通过开发良构数据的转换来设计软件的实践,其中良构的标准由目标硬件以及需要在其上运行的变换的模式和类型引导。有时候,数据没有被很好的定义,硬件同样被回避,但在大多情况下,对硬件的了解有助于几乎所有的软件项目。
如果应用程序的终极结果是数据,并且所有的输入可以由数据表示,结合数据的转换不是发生在虚空中的这一认知,那么软件开发方法可以建立在这些原则之上。这些原则是关于理解数据,在已知机器如何处理特定数量、频率和统计质量的数据条件下,如何改变数据。有了这个基础,我们可以对“什么使得一个方法学是面向数据的”这个问题建立一套基础论述。
数据不是问题域
第一个原则:数据不是问题域。
对于一些人来说,面向数据设计似乎与大多数其他编程范式对立。因为面向数据设计是一种不容易让问题域以源代码形式进入软件的技术。它不会以任何方式将对象的概念提升为到用户上下文的一种映射,因为数据本身故意且始终是没有意义的。高度抽象的范式试图假装计算机和数据是不存在的,使人们不考虑字节、CPU管线和其他硬件特征,并直接将问题的模型带入程序。它们经常将视图模型引入代码,或者将世界模型作为问题的上下文。也就是说,它们要么围绕预期解决方案的属性构造代码,要么围绕问题域的描述构造代码。
意义可以被赋予数据来创造信息。意义不是数据固有的。当你说4时,几乎没有意义,但是说4公里或是4个鸡蛋,就有意义了。当你有3个数时,作为三元数,它几乎没有意义,但你将它命名为x,y,z,则被看做是位置信息。当你在游戏中有一个位置列表时,如果没有上下文,这些位置也基本没什么意义。面向对象设计通常将位置作为对象的一部分,借助类名和邻近数据(同样已被命名),你可以了解该数据的具体含义。如果没有上下文相关的已命名数据,“位置”可以已各种不同的方式解读。虽然从某种意义上说,将数字放在上下文中是好的,但这阻止了将位置看做纯粹三个数字组合的这种思维,而这种思考方式对于程序员思考真正问题的解决方案来说是很重要的。
举一个将数据放入对象内部深处以致忘记其影响时可能发生的情况示例。比如,一个已发行的大型游戏,在开发阶段时,本应该采用2D或3D的网格系统作为数据布局,但是由于未知的原因,开发者没采用该方法而是为世界中所有的实体采用对象范式。这不是特例,真实的发行游戏中可以看到不少这种以对象为中心的方法——将几百个对象放置在世界空间中的网格坐标,而不是实际上由网格驱动,造成大量的硬件消耗。当程序员看着网格,会意识到需要大量的元素来满足需求,很可能会犹豫是否申请一片大的连续内存来存储。考虑到256x256的网格地图需要65536个网格。一个面向对象程序员可能会觉得这65000个物体开销相当大。对于他们来说,按需动态分配是更合理的选择。即使是最终在编辑器中,手动放置了65000个网格,由于是人为手工操作的,这些网格的必要性被确定了,成为了必须处理的事,而不是潜在担忧的事。
这种普遍存在的对底层知识的缺失不仅会形成低效率的处理渲染和单物体放置的方法,在解释元素的局部性时,会导致更高的复杂度。要获得没有网格表示的元素的访问通常需要沿着相邻元素的链接(需要不断更新)跳转,遍历所有链在一起的元素(消耗高),或者引用辅助增强的网格对象或与物体关联的空间映射系统,但碍于游戏设计,这种方法不会成为可能。这种由无网格设计引入的假自由形式暴露在理解数据上存在问题,并且已成为某些游戏中一些重大性能损失的原因。 因此也造成了程序员精神资源的巨大浪费。
除了没有合理的网格之外,许多现代游戏似乎也为游戏中的每个物体提供实例对象。每个物体一个实例而不是用一个变量存储数目。对于某些游戏来说,这是一种优化,因为对象的创建和销毁时开销高昂的行为,但这种趋势令人担忧,因为这种存储世界信息的方式,使得对世界信息的简单获取变得难以实现。
一些游戏倾向于将与玩家相关的一切数据都塞进玩家类中。如果玩家在游戏中死亡,他们必须被标记为死亡对象继续存在于游戏中,否则,成就相关的数据会丢失。将数据的内容,数据所在的位置和数据共享的生命周期进行链接会导致单一的大类,很难解开关系,这经常是bug产生的根源。这种错误经常发生在使用现成的面向对象引擎的开发者身上。
面向数据的设计方法不会将现实问题构建到代码中。在有经验的面向对象程序员眼中,这可能会被认为是面向数据编程的失败之处,因为面向对象的成功之处正是将人类的观念带入机器中,在这个中间地带,问题的解答可以被人和计算机都理解。面向数据方法将问题域留在设计文档中,将元素的约束和期望纳入数据转换,这放弃一些人类的可读性,但解放了机器,使其可以不用按人类的观念来处理数据。
让我们来看看问题域是如何成为那些采用推动不必要抽象编程范例编写的软件中的一部分的。考虑“对象”,我们通过将它们与包含的类及其相关函数相关联来将含义与数据联系起来。在高级抽象中,我们按高级概念来分离操作和数据,这些概念可能不适用于低级别,从而降低了有效实现功能的可能性。
当一个类拥有一些数据时,它会为该数据提供一个上下文,这有时会限制重用数据的能力或理解操作对其的影响。将函数添加到上下文可能引入更多数据,这很快会导致类中包含许多不相关的数据,但这些数据又不得不在同一个类中,因为操作需要上下文,而上下文由于其他相关操作需要更多的数据。这听起来非常熟悉,引用乔阿姆斯特朗的话:“我认为缺乏可重用性来自面向对象的语言,而不是函数式语言。 因为面向对象语言的问题是他们已经拥有了所有这些隐含的环境。 你想要一个香蕉,但你得到的是一只拿着香蕉和整个丛林的大猩猩。”这似乎与上下文引用的问题产生共鸣,这种问题似乎困扰着面向对象的语言。
对于相信可以通过使用接口或依赖注入来删除上下文之间的联系这一点,您是可以被宽恕的,但是实际上联系远比这更深。一个对象的上下文通常连接不同种类的数据,因为同一个对象在不同情景下会有不同的数据。比如香蕉这个词就有不同的表达目的:水果、颜色、以B开头的单词等等。我们必须考虑将香蕉作为一个实例,同时也是一类实体这种想法所带来的问题。如果我们需要从进口商品法律或其营养价值的角度获取有关香蕉的信息,那么它与我们目前有多少库存信息是不同的。我们很幸运从香蕉开始。如果我们谈论大猩猩,那么我们有关于个体大猩猩,动物园或丛林中的大猩猩以及大猩猩类的信息。我们可能给三个不同的抽象层同一个名字。至少对于香蕉,每个个体都没有太多的重要数据。我们在现实世界中一直看到这种上下文联系,在对话中,我们很好地管理复杂性。但是一旦我们开始强硬地将这些上下文放在一起,它们之间生硬的连接将使它们变得脆弱不堪。
所有这些混合的抽象层变得难以解开,因为在每个上下文中操作的函数需要来自类中随机一块的数据,这意味着许多数据项无法被删除,因为它们会变得无法访问。这足以阻止大多数程序员尝试大规模开发软件项目。但是还有一个问题,隐藏内部数据的操作会产生不必要的额外复杂度。当你看到链表和树,数组和图,表格和行时,你可以弄清它们以及它们间的交互和转换。如果你试图对家庭和办公室,道路和通勤者,咖啡店和公园做同样的事情,你经常会陷入思考问题领域概念的困境,而不能发现线索来得到更好的数据表示或不同的算法。
大多计算机科学里的算法都能在原始数据类型上重复使用,但是当您引入具有自己内部数据布局的新类时,如果没有明确遵循现有数据结构的模式,那么您就不能够充分利用这些算法了,甚至可能不知道如何应用。将数据结构放在对象设计中来阐述它们是什么可能是有意义的,但从数据操作的角度来看,它们通常没什么意义。
当我们从面向数据的设计角度考虑数据时,数据仅仅是可以以任何必要的方式解释的事实,以获得所需格式的输出数据。我们只关心我们做什么变换,以及数据最终的结果。实际上,当您从数据中删除含义时,您还可以减少将真实与其上下文纠缠在一起的可能性,因此您还可以降低仅为了一两个操作而混合不相关数据的可能性。
数据和统计
第二个原则:数据是类型,频率,数量,形状和概率。
第二个陈述是数据不仅仅是结构。关于面向数据的设计的一个常见误解是它只是用来解决缓存未命中的。当然,即使这一切都是为了确保你永远不会错过缓存,帮助你构建类,以便将冷热数据分开。对你的编程工具来说,这也是一个非常有用的补充。但面向数据的设计涉及数据的各个方面。要写一本关于如何避免缓存未命中的书,您需要的不仅仅是关于如何组织结构的一些提示,您需要了解计算机运行程序时实际发生的事情。在一本书中教学也是不可能的,因为它只适用于一代硬件和一代编程语言,但是,面向数据的设计并不仅仅只适用于一种语言和一些不寻常的硬件,即使最能从中受益的语言是C ++,并且最有利于该方法的硬件是任何具有不平衡瓶颈的东西。数据的表示很重要,但数值和数据转换方式同样重要,甚至可能更重要。只用一些猎豹的照片来确定它能跑多快是不够的。 你需要在野外看到它,并了解跑得慢需要付出的代价。
面向数据的设计模型以数据为中心。它以实时数据,真实数据和同样是信息的数据为中心。面向对象的设计以问题定义为中心。 对象不是真实的东西,而是待解决问题的上下文的抽象表示。对象操纵表示它们所需的数据,而不考虑硬件或现实世界的数据模式或数量。这就是为什么面向对象的设计允许您快速构建应用程序的第一个版本,允许您将设计文档或问题定义的第一个版本直接放入代码中,并快速尝试解决方案。
面向数据的设计采用不同的方法解决问题,而不是假设我们对硬件一无所知,它假设我们对问题的真实性质知之甚少,并使数据模型成为二等公民。任何编写过相当大的软件的人都可能会认识到项目的技术结构和设计经常发生变化,以至于初稿中几乎没有任何部分在最终实施中保持不变。面向数据的设计避免浪费资源,因为从不假设设计需要存在于文档之外的任何地方。它通过一些高级代码控制事件序列提供解决当前问题的方法,并指定模型,从而为数据提供临时意义。
面向数据的设计从所看到或预期的数据中获取线索。不是计划所有可能性,或尝试使设计具备高适应性,而是倾向于使用最合理的输入来指导算法的选择。它不是提前规划可扩展性,而是专注于简单和可替换,并完成工作。可扩展性可以在以后添加,使用单元测试的安全网来确保它在保持简单复杂度的同时继续工作。幸运的是,通过利用多年前开发的用于处理数据库的技术,有一种方法可以使您的数据布局可扩展而无需过多考虑。
引入关系模型后,数据库技术得到积极的转变。在Out of the Tar Pit一文中,功能关系编程在引用使用关系模型数据结构和功能转换的想法时更进了一步。这些都是明确定义的,并且有很多关于如何调整其形状以满足您的要求的文献。
数据可以改变
面向数据编程是关于现在的。它不是对于历史问题或解决方案的更新,也不是一个通用方案以应对未来的所有问题。被过去禁锢会妨碍灵活性,而展望未来通常是徒劳无功的,因为程序员并不是预言家。作者认为,能应对未来考验的系统是很少的。当设计在现实世界中发生变化时,面向对象设计开始显示出它的弱点。
众所周知,面向对象编程可以很好的处理底层实现细节的改变。这是因为这些改变是预期的,明显的,也是常常在介绍面向对象编程时提及的。然而,现实中的变化,如用户需求的改变,输入格式、数量、频率的改变,以及信息传播路径的改变等等,并没能得到很好的处理。在On the Criteria To Be Used in Decomposing Systems into Modules中介绍了一种模块化方法,当时许多人采用的这种方法与生产线很相似。在生产线中,各种实现方法作为基本元素被纳入到解决方案的各个阶段中。这些阶段本身可以用当前对问题的解释来确定。在最初的文档中,解决方案是引入一种隐藏数据的方法来进行模块化。虽然这是一种改进,但在后来的Software Pioneers: Contributions to Software Engineering书中,D.L.Parnas重新审视了这个问题并提醒我们,即使根据业务事实做出结构化的决策可以加快初始的软件开发,但同时它会给软件的维护和后续开发带来负担。面向对象编程的设计方法受害于这种固有的惯性——将问题域与实现方案相耦合。如前所述,将问题域引入到实现中,可以帮助我们快速做出决策。因为,我们在问题原有的形式上工作,可以很容易看到我们的实现方案是否靠近问题的最终解。面向对象设计的问题在于更高层次的变化是不可避免的。
设计会因多种原因发生变化,有时甚至没有原因地改变。对设计的不正确理解,或者是对设计的错误解释都会导致实现方案上的改变,正如字面要求设计需要改变一样。面向数据的代码设计方法通过理解数据含义的变化来考虑设计的变化。与面向对象方法的封装内部状态操作不同,面向数据的设计方法还允许在数据源更改时更改代码。一般来说,面向数据的设计可以更好地处理变化,因为数据块和数据转换的耦合和解耦比对象的转变和重用更简单。
之所以如此,是因为将意图或表象与数据联系起来。将数据和函数与对象的概念集中在一起时,你会发现对象是数据的模式。数据的表象与该对象相链接,这意味着很难从另一个角度来考虑数据。数据的用例,以及现实世界或设计,现在都被链接到一个由对象定义隐含的单一视角的数据布局中。如果将数据布局链接到预期操作所需数据的集合,那么你的数据操作将会和数据表象关联,使得你很难在之后将数据和表象分开。当不同的表象需要不同的数据子集,并且它们重叠时,就会出现困难。当它们重叠时,它们会成为一组越来越大的数值集合,这些值需要作为一个单元在各个系统中传递。遇到这种情况,我们通常会将一个类重构为两个或多个类,或者将数据的所有权赋予另一个类。而这就是我们所说的将数据绑定到表象上。它与数据具有目的的透镜相关联,但是对于静态类型的对象,目的是预先定义的,是多个目的的联合,有时带有失效的关系。一些目的可能是设计不再需要的。不幸的是,当一段关系需要存在时,要比不需要存在时更容易看到,随着时间的推移,这会导致更多的联系,而不是更少的联系。
如果你将方法与相关的数据关联在一起,比如你将方法放在类中(操作的数据为成员变量),当数据改变或拆分时,你将很难将方法和数据的关联取消,相对的,你的数据也很难拆分,如果你的方法需要那些数据。如果将数据保存在一个地方,将操作保存在另一个地方,并将数据的各个表象和角色与数据操作和转换方法隔离,那么你会发现,在面向对象的代码中那么大而复杂的重构将会变得微不足道,甚至不存在。 当然,事物都两面性,这种好处的代价是,你必须维护许多表格来记录各个方法都需要哪些数据,并应对数据不同步带来的潜在危险。这种考虑可能使我们将一些冷代码保持为面向对象的风格,对象将负责保持内部一致性,而不是效率和可变性。面向对象设计远优于面向数据的地方的例子可以是系统或硬件的驱动层。尽管Vulkan和OpenGL是面向对象的,但对象的粒度很大,且与空间中稳定的概念相关联。与之相像的是,文件系统中基于面向对象方法设计的FILE类型或是句柄,它们稳定的概念有:打开、关闭、读取、写入等操作。
对于接触面向数据设计范式的新人来说,通常有一个巨大的误解,这个误解来源于基于抽象的开发过程,我们可以为本书中提供的所有面向数据解决方案设计一个静态库或一套模板来提供一个通用解决方案。与领域驱动的设计一样,面向数据的设计是特定于产品和工作流程的。你将学习如何进行面向数据设计,而不是如何将其添加到你的项目中。一个基本的事实是,尽管数据在类型上是通用的,但如何使用却大有不同。这些数值都是不同的,并且通常包含我们可以利用的模式。数据可以是通用的这一观点正是面向数据设计试图纠正的错误观点。应用于数据的转换在某种程度上是通用的,但操作的顺序和选择实际上是解决问题的方法。源代码是将数据从一种形式转换为另一种形式的烹饪法。不可能有一个模板库来理解和利用数据中的模式,这正是驱动一个成功的面向数据设计的原因。的确,我们可以构建算法来查找数据中的模式,否则,数据压缩是不可能的,但是当涉及到面向数据的设计时,我们考虑的模式是更高级别的、特定领域的,而不是简单的频率映射。
软件运行效率的提升可能会使得代码变得较难阅读,但我们并不鼓励不采用面向对象,或进行太多地硬编码。(后面大致就是说,要合理在使用已有的函数库和自己编码中做权衡)
数据是如何形成的
游戏中有许多各式各样的数据。不同平台有各自的纹理格式,还有动画、声音、灯光、脚本,网格等等。我们很难将所有游戏中要使用到的数据限定在一个范围内。这就是我们花费大量工作在编辑器和工具链上的原因。设计师和艺术家需要借用这些工具将自由形式的作品添加到游戏引擎中。面向对象的方法可以使我们很好的处理这些格式各异的数据。它帮助我们分清数据的类型(属于什么领域),并根据这些数据的用途进行分类。我们可以很方便的添加和使用这些数据,但为所有数据格式添加封装需要耗费大量的工作。为这些对象添加新的功能,有时需要大量的重构。因为这些功能可能与这个类的定义发生冲突。比如,在旧引擎中,纹理中的纹素必须是1,2或4个字节。随着浮点数纹理的出现,所有相关的代码都要重构。过去,我们不能从像素着色器中读取纹理。因此,当基于纹理的蒙皮技术出现后,工程师们不得不修改渲染管线的代码。(此处省略其他一堆例子,说明数据的格式或组成形式在不断的变化,而面向对象编程不能很好的处理这一情况)唯一不变的是变化(Change is the only constant)。
之前提到的数据格式在游戏开发过程中都是很常见的,许多游戏引擎为这些数据格式提供了抽象支持。当某种数据格式被广泛采用后,游戏引擎会将其作为核心类型加入到引擎中。当新的数据类型出现后,尽管它更好的性能,我们通常仍会将其它作为特殊格式,直到这种格式被广泛使用。(用户在切换数据类型过程中要花费大量精力,这其实也限制了工具的更新换代)。
游戏中的数据非常复杂。想要构建通用的抽象就变得非常困难。
What can provide a computational framework for such complex data?
未翻译
结论和需要记住的点
我们已经讨论了面向数据设计是一种思考数据布局和构建架构的方法。我们提出了面向数据设计的两个基本原则。
思考你的数据是如何被它的名称影响的,并与其他相近的数据做横向比较。对于第一原则:
数据不是问题域
你应该思考以下问题:
- 是什么把你的数据联系在一起,概念还是潜在的意义?
- 你的数据布局是仅仅由一种视角下的一种诠释决定的吗?
- 思考数据可以如何被重新诠释?
- 是什么使得数据如此重要?
网友评论