灵感来源
感谢
Aras Prantskevichus最近发表的有关面向初级程序员的报告的文章,此帖子才得以发表。 它讨论了如何适应新的ECS体系结构。 Aras遵循通常的模式(
以下说明 ):显示了糟糕的OOP代码的示例,然后证明了关系模型(
但称其为“ ECS”而不是关系模型 )是一个很好的选择。 我绝不批评阿拉斯-我是他的工作的忠实拥护者,并赞扬他的出色表现! 我选择了他的演示文稿,而不是从互联网上其他有关ECS的其他数百篇文章中选择,因为他付出了更多的努力,并在演示文稿发布的同时发布了一个git存储库供研究。 它包含一个小的简单“游戏”,用作选择不同体系结构解决方案的示例。 这个小项目使我可以展示我对特定材料的评论,所以,谢谢!
Aras幻灯片可在此处找到:
http://aras-p.info/texts/files/2018Academy-ECS-DoD.pdf ,代码在github上:
https :
//github.com/aras-p/dod-playground 。
我不会(尚未?)从此报告中分析最终的ECS体系结构,而是从一开始就专注于“糟糕的OOP”代码(类似于填充技巧)。 我将展示如果正确纠正所有违反OOD原理(面向对象的设计,面向对象的设计)的情况,它的真实外观。
破坏者:消除所有违反OOD的行为都可以提高性能,类似于从Aras到ECS的转换,它还比ECS版本使用更少的RAM和更少的代码行!TL; DR:在得出OOP吸吮和ECS驱动器结论之前,请暂停并检查OOD(以了解如何正确使用OOP),并了解关系模型(以了解如何正确应用ECS)。我长期在论坛上参与过很多关于ECS的讨论,部分原因是我认为该模型不应该单独存在(
破坏者:这只是关系模型的临时版本 ),还因为几乎
所有宣传ECS模式的帖子,演示文稿或文章都遵循以下结构:
- 显示一个可怕的OOP代码示例,由于过度使用继承(其实现违反了许多OOD原理),其实现存在严重的缺陷。
- 证明组合比继承是更好的解决方案(更不用说OOD确实给了我们相同的教训)。
- 证明关系模型非常适合游戏(但称其为“ ECS”)。
这样的结构使我很生气,因为:
(A)这是一个“塞满”的把戏……它把软代码和温暖代码(不好的代码和好的代码)进行比较……这是不公平的,即使是无意间做的,也不需要证明新的体系结构是好的。 并且,更重要的是:
(B)它具有副作用-这种方法抑制了知识,并无意中使读者不熟悉半个世纪的研究。 他们在1960年代开始撰写有关关系模型的文章。 在整个70年代和80年代,该模型已得到了显着改善。 初学者经常会遇到诸如“
您想将这些数据放入哪个班级? ”这样的问题,而作为回应,他们经常被告知有些含糊不清的信息,例如“
您只需要积累经验,然后您就可以学会内在地理解 ”……但是在70年代,这个问题很活跃研究并在一般情况下得出正式答案; 这称为
数据库规范化 。 抛弃现有研究并将ECS称为全新的现代解决方案,您就可以对初学者隐藏这些知识。
面向对象编程的基础已经建立在很久以前,甚至更早(
这种风格在1950年代的工作中就开始探索了 !)! 但是,直到1990年代,面向对象才变得流行,流行,并很快变成了占主导地位的编程范例。 包括Java和(
标准版本 )C ++在内的许多新的OO语言的流行已经发生。 但是,由于这是由于大肆宣传,所以每个人都
需要了解这一备受瞩目的概念,以便在简历中写信,但真正参与其中的只有少数人。 这些新语言从OO的许多功能中创建了关键字
-class ,
virtual ,
extends和
Implements ,我认为这就是为什么OO当时被划分为两个独立的实体,过着自己的生活。
我将这些受OO启发的语言功能的使用称为“
OOP ”,并将对OO启发的设计/架构技术的使用称为“
OOD ”。 所有人很快都接受了OOP。 教育机构开设了面向对象的课程,这些课程使新的面向对象编程人员感到欣慰……但是,对面向对象的知识却落后了。
我认为使用OOP语言功能但不遵循OOD设计原则的
代码不是OO代码 。 对OOP的大多数批评都使用了内脏代码,例如,它并不是真正的OO代码。
OOP代码的声誉非常差,尤其是因为大多数OOP代码没有遵循OOD的原理,因此不是“真正的” OO代码。
背景知识
如上所述,1990年代成为“ OO时尚”的高峰,而那时“糟糕的OOP”可能是最糟糕的。 如果您当时学习过OOP,那么您很可能了解了“ OOP的四个支柱”:
我更喜欢称它们不是四个支柱,而是“四个OOP工具”。 这些是
可用于解决问题的工具。 但是,仅仅找出工具的工作原理还不够,您需要知道何时使用它。对于教师而言,教导人们一种新工具,而不是告诉他们每一种工具何时值得使用是不负责任的。 在2000年代初期,人们对积极使用这些工具产生了抵制,这是OOD思维的“第二波”。 结果就是
SOLID助记符的出现,它提供了一种评估架构优势的快速方法。 应当指出,这种智慧实际上是在90年代广泛传播的,但尚未得到一个很酷的首字母缩写词,这使它们被确定为五个基本原则。
- 唯一责任原则 ( S ingle责任原则)。 每个班级只有一个改变的理由。 如果“ A”类有两个职责,那么您需要创建“ B”和“ C”类以分别处理它们,然后从“ B”和“ C”中创建“ A”。
- 打开/关闭的原理( O笔/关闭的原理)。 软件会随着时间而变化( 即其支持很重要 )。 尝试将最可能更改的部分放在实现中( 即在特定类中 ),并根据那些不太可能更改的部分创建接口 ( 例如,抽象基类 )。
- Barbara Liskov的替代原理 ( L iskov替代原理)。 接口的每个实现都必须100%满足该接口的要求,即 与接口配合使用的任何算法都应与任何实现配合使用。
- 界面分离的原理(接口分离原理)。 使接口尽可能小,以便使代码的每个部分“知道”最少的代码库,例如,避免不必要的依赖性。 这个技巧对C ++也很有用,如果不遵循它的话,编译时间会变得很长。
- 依赖反转原理 ( D依赖反转原理)。 通常,可以通过将其通信接口规范化为第三类(用作它们之间的接口)来分离它们,而不是直接(彼此依赖)进行通信的两个特定实现。 它可以是定义它们之间使用的方法的调用的抽象基类,甚至可以是定义它们之间传递的数据的POD结构。
- 首字母缩略词SOLID中没有包含另一个原则,但是我相信它非常重要: “优先于继承而不是继承” (复合重用原则)。 默认情况下,合成是正确的选择 。 对于绝对必要的情况,应保留继承。
所以我们得到SOLID-C(++)

下面我将参考这些原则,将其称为首字母缩写-SRP,OCP,LSP,ISP,DIP,CRP ...
一些注意事项:
- 在OOD中, 接口和实现的概念不能与任何特定的OOP关键字联系在一起。 在C ++中,我们经常使用抽象基类和虚函数创建接口,然后实现从这些基类继承……但这只是实现接口原理的一种特定方法。 在C ++中,我们还可以使用PIMPL , 不透明指针 , 鸭子类型 ,typedef等。。。您可以创建一个OOD结构,然后在C中实现它,而根本没有OOP语言关键字! 因此,当我谈论接口时 ,我不一定指虚函数-而是在讨论隐藏实现的原理。 接口可以是多态的 ,但往往不是! 多态很少被正确使用,但是接口是所有软件的基本概念。
- 正如我在上面明确指出的,如果您创建一个POD结构,该结构仅存储一些数据以从一个类传输到另一个类,则此结构将用作接口 -这是对数据的正式描述 。
- 即使仅使用公共部分和私有部分创建一个单独的类, 公共部分中的所有内容也是一个接口 ,私有部分中的所有内容都是一个实现 。
- 继承实际上(至少)具有两种类型-接口继承和实现继承。
- 在C ++中,接口继承包括具有纯虚函数,PIMPL,条件typedef的抽象基类。 在Java中,接口继承是通过Implements关键字表示的。
- 在C ++中,每当基类包含除纯虚函数之外的其他东西,实现的继承就会发生。 在Java中,实现继承是使用extend关键字表示的。
- OOD对于继承接口有很多规则,但是实现的继承通常值得考虑为“一口代码” !
最后,我将展示一些可怕的OOP培训示例,以及它如何导致现实生活中的不良代码(以及OOP的不良声誉)。
- 在教授层次结构/继承时,您可能会得到类似的任务: 假设您有一个大学应用程序,其中包含学生和工作人员的目录。 您可以创建基类Person,然后创建从Person继承的Student类和Staff类。
不不不 在这里,我会阻止你。 LSP原理不言而喻的含义是类层次结构和处理它们的算法是共生的。 这是整个程序的两半。 OOP是过程编程的扩展,并且仍然主要与这些过程相关。 如果我们不知道学生和教职员工可以使用哪种类型的算法( 并且由于多态性而简化了哪些算法 ),那么开始创建类层次结构是完全不负责任的。 首先,您需要了解算法和数据。 - 当您学习层次结构/继承时,您可能会得到类似的任务: 假设您有一类形状。 我们也有正方形和矩形作为子类。 正方形应该是矩形,还是矩形是正方形?
这实际上是一个很好的示例,可以演示实现的继承与接口的继承之间的区别。
TL; DR-您的OOP类告诉您继承是什么样的。 您缺少的OOD类应该告诉您99%的时间不要使用它!
实体/组件概念
在处理了先决条件之后,让我们继续前进至Aras的起点-所谓的“典型OOP”起点。
但是对于初学者来说,还有一个补充-Aras将此代码称为“传统OOP”,我想对此表示反对。 该代码对于现实世界中的OOP可能是典型的,但是,像上面的示例一样,它违反了OO的所有基本原理,因此根本不应视为传统代码。
在他开始重新构建ECS结构之前,我将首先进行第一次提交:
“使它再次在Windows上运行” 3529f232510c95f53112bbfff87df6bbc6aa1fae
是的,很难立即计算出一百行代码,所以让我们开始吧……我们还需要先决条件的一个方面-在90年代的游戏中,使用继承来解决所有代码重用的问题很普遍。 您拥有实体,可扩展角色,可扩展播放器和怪物,等等……这是实现的继承,如我们先前所描述的(
“扼杀代码” ),看来从它开始是正确的,但是结果导致不灵活的代码库。 因为OOD具有上述“继承之上的组成”原则。 因此,在2000年代,“组成高于继承”的原则开始流行,游戏开发人员开始编写类似的代码。
该代码的作用是什么? 好不好

简而言之,
此代码重新实现了该语言的现有功能-组合为运行时库,而不是该语言的功能。 您可以想象一下,好像代码实际上是在C ++之上创建了一个新的元语言,并在虚拟机(VM)上执行该元语言。 在Aras演示游戏中,不需要此代码(
我们将很快将其完全删除! ),仅可将游戏性能降低约10倍。
但是他实际上在做什么? 这是“实体/组件系统”(
有时由于某种原因被称为“实体/组件系统” )的概念,但是与
“实体/组件系统”的概念完全不同组件系统(“” entity-component-system“)(
出于明显的原因,从来没有被称为 “实体组件系统”。)它规范了“ EC”的几个原则:
- 游戏将基于不具有“实体”(“实体”)( 在此示例中称为 GameObjects)的功能构建,该功能由“组件”(“ Component”)组成。
- GameObjects实现“服务定位器”模式 -将按类型查询其子组件。
- 组件知道它们属于哪个GameObject-通过查询父级GameObject可以找到与它们处于同一级别的组件。
- 合成只能深一层 ( 组件不能具有自己的子组件,GameObjects不能具有子GameObjects )。
- GameObject每种类型只能有一个组件( 在某些框架中这是强制性要求,在其他框架中则不是 )。
- 每个组件(可能)会以某种未指定的方式随时间变化,因此该接口包含“虚拟无效更新”。
- GameObject属于可以对所有GameObject(并因此对所有组件)执行查询的场景。
类似的概念在2000年代非常流行,尽管有其局限性,但事实证明它足够灵活,可以创建当时和今天的无数游戏。
但是,这不是必需的。 您的编程语言已经支持组合作为该语言的功能-不需要need肿的概念来访问它。那么,为什么存在这些概念? 好吧,老实说,它们允许您
在运行时执行
动态合成 。 您可以从数据文件中加载它们,而不必在代码中硬性定义GameObject类型。 这非常方便,因为它允许游戏/关卡设计人员创建自己的对象类型。但是,在大多数游戏项目中,设计人员很少,实际上是一整套程序员,因此我认为这是一个重要的机会。 更糟糕的是,这不是在运行时实现合成的唯一方法! 例如,Unity使用C#作为其“脚本语言”,许多其他游戏使用其替代语言,例如Lua,这是一种方便的工具,设计人员可以生成C#/ Lua代码来定义新的游戏对象,而无需这种such肿的概念! 我们将在下一篇文章中重新添加此“功能”,并使其不会导致性能降低十倍...
让我们根据OOD评估此代码:
- GameObject :: GetComponent使用dynamic_cast。 大多数人会告诉您,dynamic_cast是“令人窒息的代码”,这很明显地表明您在某处存在错误。 我要说这-这是您违反LSP的证据-您有某种可以与基本接口一起使用的算法,但是它需要知道不同的实现细节。 由于这个特殊原因,代码闻起来很糟。
- 原则上来说,GameObject不错,如果您想象它实现了“服务定位器”模板...但是,如果您从OOD的角度出发不仅仅批评,那么该模板会在项目的各个部分之间创建隐式连接,并且我认为( 没有可以支持的Wikipedia链接)我从计算机科学的知识中了解到 )隐式沟通渠道是一种反模式 ,他们应该更喜欢显式沟通渠道。 相同的论点适用于有时在游戏中使用的the肿的“事件概念” ...
- 我想声明一个组件违反了SRP,因为它的接口( virtual void Update(time) )太宽了。 在游戏开发中普遍使用“虚拟无效更新”,但我还要说这是反模式。 好的软件应该可以让您轻松地考虑控制流和数据流。 将游戏代码的每个元素放在“虚拟无效更新”调用之后,这将完全混淆控制流和数据流。 恕我直言,无形的副作用 ,也称为远程,是一些最常见的bug来源,“虚拟无效更新”可确保几乎所有内容都是无形的副作用。
- 尽管Component类的目标是启用合成,但它是通过继承来实现的,这违反了CRP 。
- 此示例的唯一好处是,为了遵守SRP和ISP的原则,游戏代码过大了-将游戏代码划分为许多简单的组件,几乎没有责任,这对于重用代码非常有用。
但是,他不太擅长维护DIP-许多组件之间具有彼此直接的知识。
因此,上面显示的所有代码实际上都可以删除。 这整个结构。 删除GameObject(在其他框架中也称为Entity),删除Component,删除FindOfType。 这是违反OOD原则的无用VM的一部分,极大地减慢了我们的游戏速度。
没有框架的组合(即使用编程语言本身的功能)
如果我们删除了合成框架而没有Component基类,那么我们的GameObjects将如何使用合成并由组件组成? 就像标题中所说的那样,不要编写这个肿的VM并在其之上以奇怪的语言创建GameObjects,而只需用C ++编写它们,因为我们是游戏程序员,这实际上是我们的工作。
这是删除了实体/组件框架的提交:
https :
//github.com/hodgman/dod-playground/commit/f42290d0217d700dea2ed002f2f3b1dc45e8c27c这是源代码的原始版本:
https :
//github.com/hodgman/dod-playground/blob/3529f232510c95f53112bbfff87df6bbc6aa1fae/source/game.cpp这是源代码的修改版:
https :
//github.com/hodgman/dod-playground/blob/f42290d0217d700dea2ed002f2f3b1dc45e8c27c/source/game.cpp简要介绍一下更改:
- 从每种组件类型中删除了“:public Component”。
- 为每种类型的组件添加了一个构造函数。
- OOD主要是关于封装类的状态的,但是由于这些类非常小/简单,因此实际上没有什么可隐藏的:接口是对数据的描述。 但是,封装是主要支柱的主要原因之一是,它使我们能够保证类不变式的恒定真理……或者如果不变式被破坏,那么您只需要检查封装的实现代码以发现错误。 在此代码示例中,值得添加构造函数以实现简单的不变式-必须初始化所有值。
- 我重命名了过于笼统的“ Update”方法,以使其名称能够反映它们的实际功能-MoveComponent的UpdatePosition以及避免组件的ResolveCollisions。
- 我删除了三个类似于模板/预制的硬编码代码块-创建包含特定类型Component的GameObject的代码,并将其替换为三个C ++类。
- 消除了反模式“虚拟无效更新”。
- 游戏无需在组件之间通过“服务定位器”模板互相寻找,而是在构建过程中将它们明确绑定在一起。
对象
因此,代替此“虚拟机”代码:
现在,我们有了常规的C ++代码:
struct RegularObject { PositionComponent pos; SpriteComponent sprite; MoveComponent move; AvoidComponent avoid; RegularObject(const WorldBoundsComponent& bounds) : move(0.5f, 0.7f)
演算法
对算法进行了另一项重大更改。 记住,在一开始我曾说过接口和算法可以共生,并且应该影响彼此的结构吗? 因此,反模式“
virtual void Update ”也已成为这里的敌人。 初始代码包含主循环算法,仅包括以下内容:
您可以说它是美丽而简单的,但是恕我直言,它是非常非常糟糕的。 这完全混淆了游戏中的
控制 流和
数据流 。 如果我们希望能够理解我们的软件,想要对其进行支持,想要向其添加新的东西,对其进行优化,在多个处理器内核上高效地执行它,那么我们既需要了解控制流,也需要了解数据流。 因此,“虚拟无效更新”必须着火。
相反,我们创建了一个更明确的主循环,该循环大大简化了对控制流的理解(
仍然混淆了其中的
数据流,但是我们将在以下提交中解决此问题 )。
这种风格的缺点是,对于添加到游戏中的
每种新类型的对象 ,我们都必须在主循环中添加几行。 我将在本系列的后续文章中再次谈到这一点。
性能表现
有很多严重的OOD违规行为,在选择结构时做出了一些错误的决定,并且有很多优化的机会,但是我将在本系列的下一篇文章中找到它们。 但是,在此阶段已经很明显,带有“固定OOD”的版本几乎从演示结束就完全匹配或赢得了最终的“ ECS”代码……而我们所做的只是采用错误的伪OOP代码并使其符合原则。 OOP(还删除了一百行代码)!
后续步骤
在这里,我想考虑范围更广的问题,包括解决剩余的OOD问题,不可变的对象(
以功能风格进行编程 )以及它们可以带来有关数据流,消息传递,将DOD逻辑应用于我们的OOD代码的讨论的优势,在OOD代码中运用相关的知识,删除最终得到的这些“实体”类,并仅使用纯组件,并使用不同的样式来连接组件(比较指针和指针)。 从现实世界中携带)的容器部件的责任,ECS-修订版本更好的优化,以及进一步优化,在报表中不阿拉斯
(如多线程/ SIMD)提及
。 顺序不一定是这个顺序,也许我不会考虑以上所有内容...
加法
这篇文章的链接已经超出了游戏开发人员的范围,因此,我将添加:“
ECS ”(
顺便说一下,此Wikipedia文章很糟糕,它结合了EC和ECS的概念,而且不尽相同... )-这是在社区中流传的虚假模板游戏开发商。 实际上,它是关系模型的一种版本,其中“实体”只是指定无形对象的ID,“组件”是引用ID的特定表中的行,而“系统”是可以修改组件的过程代码。 这个“模板”一直被定位为解决过度使用继承问题的解决方案,但是没有提到过度使用继承实际上违反了OOP的建议。 因此,我很愤慨。 这不是编写软件的“唯一真实方法”。 该帖子旨在确保人们实际了解现有的设计原则。