查看原文
其他

面向对象编程已死,OOP 永存!

Brooke 程序人生 2019-10-30


【程序人生 编者按】ECS(ECS,Entity–component–system,实体组件系统,是一种主要用于游戏开发的架构模式),是在游戏开发社区广为流传的伪模式,它基本上是关系模型的翻版,其中“实体”是ID,表示无形的对象,“组件”是特定表中的行,该行引用一个ID,而“系统”是更改组件的过程性的代码。

这种“模式”经常会导致继承的过度使用,而不会提及过度使用继承,其实违反了OOP(OOP,Object Oriented Programming,面向对象编程,是一种计算机编程架构)原则。那么如何避免这种情况呢?本文作者,会给大家介绍下真正的设计指南。

作者 | Brooke Hodgman

译者 | 弯月,责编 | 胡巍巍

出品 | CSDN(ID:CSDNnews)


灵感


这篇文章的灵感,来自最近Unity的知名工程师Aras Pranckevičius一次面向初级开发者的公开演讲,演讲的目的是让他们熟悉新的“ECS”架构的一些术语。

Aras使用了非常典型的模式,他展示了一些非常糟糕的OOP代码,然后表示关系模型是个更好的方案(不过这里的关系模型称为“ECS”)。我并不是要批评Aras,实际上我很喜欢他的作品,也非常赞赏他的演讲!

我选择他的演讲而不是网上几百篇关于ECS的其他帖子的原因是,他的演讲给出了代码,里面有个非常简单的小“游戏”用来演示各种不同的架构。这个小项目节省了我很多精力,可以方便我阐述自己的观点,所以,谢谢Aras!

Aras幻灯片的链接:

  • http://aras-p.info/texts/files/2018Academy%20-%20ECS-DoD.pdf

代码链接:

  • https://github.com/aras-p/dod-playground

我不想分析他的演讲最后提出的ECS架构,只想就他批判的“坏的OOP”代码来说说我的看法。我想论述的是,如果我们能改正所有违反OOD(面向对象设计)原则的地方,会变成什么样子。

剧透警告:改正违反OOD的代码,能得到与Aras的ECS版本相似的性能改进,而且还能比ECS版本占用更少的内存,代码量也更少!

概括为一句话:如果你认为OOP是垃圾、而ECS才是王道,那么先去了解一下OOD(即怎样正确使用OOP),再学学关系模型(了解怎样正确使用ECS)

我一直很反感论坛上的许多关于ECS的帖子,部分原因是我觉得ECS够不上单独弄个术语的程度(剧透:它只不过是关系模型的专用版本),另一部分原因是所有宣扬ECS模式的帖子、幻灯片或文章都有着同样的结构:

  • 展示一些很糟糕的OOP代码,其设计很垃圾,通常是过度使用继承(这一条就违反了许多OOD原则)

  • 证明组合要比继承更好(其实OOD早就这么说过)

  • 证明关系模型很适合游戏开发(只不过改名叫ECS)

这种结构的文章很让我恼火,因为:

  • 偷换概念。它对比的对象风马牛不相及,这一点很难让人信服,虽然可能是出于无意,却也并不能证明它提出的新架构更好。

  • 它会产生副作用,贬低知识,并且无意间打击读者去学习该领域长达五十多年的研究结果。关系模型第一次是在上世纪六十年代提出的。七八十年代深入研究了该模型的各个方面。新手经常提出的问题是“这个数据应该放到哪个类里?”而该问题的答案通常很模糊,“等你有了更多经验以后自然而然就知道了”。但在七十年代,这个问题深入地研究,并用通用的、正式的方式解决了,即数据库的正规化(https://en.wikipedia.org/wiki/Database_normalization#Normal_forms)。忽略已有的研究成果把ECS当作全新的方案来展示,就等于把这些知识藏起来不告诉新手程序员。

面向对象编程的历史也同样悠久(实际上比关系模型还要久,它的概念从上世纪五十年代就出现了)!但是,直到九十年代,OO才得到人们的关注,成了主流的编程范式。各种各样的OO语言雨后春笋般地出现,其中就包括Java和(标准版本的)C++。

但由于它是被炒作起来的,所以每个人只是把这个词写到自己的简历上,真正懂得它的人少之又少。这些新语言引入了许多关键字来实现OO的功能,如CLASS、Virtual、extends、implements,我认为自此OO分成了两派。

后面我把拥有OO思想的编程语言称为“OOP”,使用OO思想的设计和架构技术称为“OOD”。每个人学习OOP都很快,学校里也说OO类非常高效,很适合新手程序员……但是,OOD的知识却被抛在了后面。

我认为,使用OOP的语言特性却不遵循OOD设计规则的代码,不是OO代码。大多数反对OO的文章所攻击的代码都不是真正的OO代码。

OOP代码的名声很差,其中部分原因就是大多数OOP代码没有遵循OOD原则,所以其实不是真正的OO代码。


背景


前面说过,上世纪九十年代是OO的大爆炸时代,那个时期的“坏OOP代码”可能是最糟糕的。如果你在那个时期学习了OOP,那么你很可能学过下面的“OOP四大支柱”:

  • 抽象

  • 封装

  • 多态

  • 继承

我更倾向于称他们为“OOP的四大工具”而不是四大支柱。这些工具可以用来解决问题。但是,只学习工具的用法是不够的,你必须知道什么时候应该使用它们。

教育者只传授工具的用法而不传授工具的使用场景,是不负责任的表现。在二十一世纪初,第二波OOD思潮出现,工具的滥用得到了一定的抑制。

当时提出了SOLID(https://en.wikipedia.org/wiki/SOLID)思想体系来快速评价设计的质量。注意其中的许多建议其实在上世纪九十年代就广为流传了,但当时并没有像“SOLID”这种简单好记的词语将其提炼成五条核心原则……

  • 单一职责原则(Single Responsibility Principle)。每个类应该只有一个目的。如果类A有两个目的,那么分别创建类B和类C来处理每个目的,再从B和C中提炼出A。

  • 开放/封闭原则(Open / Closed Principle)。软件随时都在变化(即维护很重要)。把可能会变化的部分放到实现(即具体的类)中,给不太可能会变化的东西建立接口(比如抽象基类)

  • 里氏替换原则(Liskov Substitution Principle)。每个接口的实现都应该100%遵循接口的要求,即任何能在接口上运行的算法都应该能在具体的实现上运行。

  • 接口隔离原则(Interface Segregation Principle )。接口应当尽量小,保证每一部分代码都“只需了解”最小量的代码,也就是说避免不必要的依赖。这一条建议对C++也很好用,因为不遵循这条原则会让编译时间大幅增长。

  • 依赖倒置原则(Dependency Inversion Principle)。两个具体的实现直接通信并且互相依赖的模式,可以通过将两者之间的通信接口正规化成第三个类,将这个类作为两者之间的接口的方式解耦合。这第三个类可以是个抽象积累,定义两者之间需要的调用,甚至可以只是个定义两者间传递数据的简单数据结构。

  • 这一条不在SOLID中,但我认为这一条同样重要:组合重用原则(Composite Reuse Principle)。默认情况下应当使用组合,只有在必须时才使用继承。

    这才是我们的SOLID C++。

    接下来我用三字母的简称来代表这些原则:SRP、OCP、LSP、ISP、DIP、CRP。

    一点其他看法:

    • 在OOD中,接口和实现并不对应任何具体的OOP关键字。在C++中,接口通常用抽象类和虚函数建立,然后实现从基类继承……但那只是实现接口的概念的一种方式而已。C++中能使用PIMPL(https://en.cppreference.com/w/cpp/language/pimpl)、不透明指针(https://en.wikipedia.org/wiki/Opaque_pointer)、鸭子类型(https://en.wikipedia.org/wiki/Duck_typing)、typedef等……你甚至可以创建OOD的设计,然后用完全不支持OOP关键字的C语言实现!所以我这里说的接口指的并不一定是虚函数,而是隐藏实现的思想(https://en.wikipedia.org/wiki/Information_hiding)。接口可以是多态的(https://en.wikipedia.org/wiki/Polymorphism_(computer_science)),但大多数情况下并不是!好的多态非常罕见,但任何软件都会用到接口。

    • 上面说过,如果建立一个简单的数据结构负责从一个类传递数据到另一个类,那么该结构就起到了接口的作用——用正式的语言来说,这叫数据定义(https://en.wikipedia.org/wiki/Data_definition_language)

    • 即使只是将一个类分成了公有和私有两部分,那么所有公有部分中的东西都是接口,而私有部分的都是实现。

    • 继承实际上(至少)有两种类型:接口继承,实现继承。

    • 在C++中,接口继承包括:利用纯虚函数实现的抽象基类、PIMPL、条件typedef。在Java中,接口继承用implements关键字表示。

    • 在C++中,实现继承发生在一切基类包含纯虚函数以外的内容的情况。在Java中,实现继承用Extends关键字表示。

    • OOD定义了许多关于接口继承的规则,但实现继承通常是不祥的预兆(https://en.wikipedia.org/wiki/Code_smell)

        最后,我也许应该给出一些糟糕的OOP教育的例子,以及这种教育导致的糟糕代码(以及OOP的坏名声)

        在学习层次结构和继承时,你很可能学习过以下类似的例子:

        • 假设我们有个学校的应用,其中包括学生和教职工的名录。于是我们可以用Person作为基类,然后从Person继承出Student和Staff两个类。

        • 这完全错了。先等一下。LSP(里氏替换原则)指出,类的层次结构和操作它们的算法是共生(symbiotic)的。它们是一个完整程序的两个部分。OOP是过程式编程的扩展,它的主要结构依然是过程。所以,如果不知道Student和Staff上的算法(以及哪些算法可以用多态来简化),那么设计类层次结构是不负责任的。必须首先有算法和数据才能继续。

        在学习层次结构和继承时,你很可能学习过以下类似的例子:

        假设你有个形状的类。它的子类可以有正方形和矩形。那么,应该是正方形is-a矩形,还是矩形is-a正方形?

        这个例子其实很好地演示了实现继承和接口继承之间的区别。

        如果你考虑的是实现继承,那么你完全没有考虑LSP,只不过是把继承当做复用代码的工具而已。从这个观点来看,下面的定义是完全合理的:struct Square { int width; }; struct Rectangle: Square { int height; }; 正方形只有宽度,而矩形在宽度之外还有高度,所以用高度扩展正方形,就能得到矩形!

        你一定猜到了,OOD认为这种设计(很可能)错了。我说可能的原因是你还可以争论其中暗含的接口……不过这无关紧要。

        正方形的宽度和高度永远相同,所以从正方形的接口的角度来看,我们完全可以认为它的面积是“宽度×宽度”。

        如果矩形从正方形继承,那么根据LSP,矩形必须遵守正方形接口的规则。所有能在正方形上正确工作的算法必须能在矩形上正确工作。

        • 比如下面的算法:std::vector<Square*> shapes; int area = 0; for (auto s: shapes) area += s->width * s-> width; 这个算法能在正方形上正确工作(产生所有面积之和),但对于矩形则不能正确工作。因此,矩形违反了LSP原则。

        • 如果用接口继承的方式来思考,那么无论是正方形还是矩形,都不应该从对方继承。正方形和矩形的接口实际上是不同的,谁都不是谁的超集。

        • 所以,OOD实际上并不鼓励实现继承。前面说过,如果你要复用代码,OOD认为应该使用组合!

        • 所以,上面实现继承的层次结构代码的正确版本,用C++来写应该是这样:

        struct Shape { virtual int area() const 0; };

        struct Square : public virtual Shape { virtual int area() const return width * width; }; int width; };

        struct Rectangle : private Square, public virtual Shape { virtual int area() const return width * height; }; int height; };

        • public virtual相当于Java中的implements,在实现一个接口时使用。

        • private可以让你从基类继承,而无需继承它的接口。在本例中,Rectangle is-not-a Square,虽然它继承了Square。

        • 我不推荐这样写代码,但如果你真想使用实现继承,那么这才是正确的写法!

        总之一句话,OOP课程教给你什么是继承,而你没有学习的OOD课程本应教给你在99%的情况下不要使用继承!


        实体 / 组件框架


        有了这些背景之后,我们来看看Aras开头提出的那些所谓的“常见的OOP”。

        实际上我还要说一句,Aras称这些代码为“传统的OOP”,而我并不这样认为。这些代码也许是人们常用的OOP,但如上所述,这些代码破坏了所有核心的OO规则,所以它们完全不是传统的OOP。

        我们从最早的提交开始——当时他还没有把设计修改成ECS:"Make it work on Windows again"(https://github.com/aras-p/dod-playground/blob/3529f232510c95f53112bbfff87df6bbc6aa1fae/source/game.cpp)

        class GameObject;class Component;typedef std::vector<Component*> ComponentVector;typedef std::vector<GameObject*> GameObjectVector;class Component{public:
            Component() : m_GameObject(nullptr) {}
            virtual ~Component() {}

            virtual void Start() {}
            virtual void Update(double time, float deltaTime) {}

            const GameObject& GetGameObject() const return *m_GameObject; }
            GameObject& GetGameObject() return *m_GameObject; }
            void SetGameObject(GameObject& go) { m_GameObject = &go; }
            bool HasGameObject() const return m_GameObject != nullptr; }private:
            GameObject* m_GameObject;};class GameObject{public:
            GameObject(const std::string&& name) : m_Name(name) { }
            ~GameObject()
            {

                for (auto c : m_Components) delete c;
            }


            template<typename T>
            T* GetComponent()
            
        {
                for (auto i : m_Components)
                {
                    T* c = dynamic_cast<T*>(i);
                    if (c != nullptr)
                        return c;
                }
                return nullptr;
            }


            void AddComponent(Component* c)
            
        {
                assert(!c->HasGameObject());
                c->SetGameObject(*this);
                m_Components.emplace_back(c);
            }

            void Start() for (auto c : m_Components) c->Start(); }
            void Update(double time, float deltaTime) for (auto c : m_Components) c->Update(time, deltaTime); }
            private:
            std::string m_Name;
            ComponentVector m_Components;};static GameObjectVector s_Objects;template<typename T>static ComponentVector FindAllComponentsOfType(){
            ComponentVector res;
            for (auto go : s_Objects)
            {
                T* c = go->GetComponent<T>();
                if (c != nullptr)
                    res.emplace_back(c);
            }
            return res;}template<typename T>static T* FindOfType(){
            for (auto go : s_Objects)
            {
                T* c = go->GetComponent<T>();
                if (c != nullptr)
                    return c;
            }
            return nullptr;}

        OK,代码很难一下子看懂,所以我们来分析一下……不过还需要另一个背景:在上世纪九十年代,使用继承解决所有代码重用问题,这在游戏界是通用的做法。首先有个Entity,然后扩展成Character,再扩展成Player和Monster等等……

        如前所述,这是实现继承,尽管一开始看起来不错,但最后会导致极其不灵活的代码。因此,OOD才有“使用组合而不是继承”的规则。因此,在本世纪初“使用组合而不是继承”的规则变得流行后,游戏开发才开始写这种代码。

        这段代码实现了什么?总的来说都不好,呵呵。

        简单来说,这段代码通过运行时函数库重新实现了组合的功能,而不是利用语言特性来实现。

        你可以认为,这段代码在C++之上构建了一种新的语言,以及运行这种语言的编译器。Aras的示例游戏并没有用到这段代码(我们一会儿就会把它都删掉了!),它唯一的用途是将游戏的性能降低10倍。

        它实际上做了什么?这是个“实体/组件”(Entity/Component)框架(有时候会被误称为“实体/组件系统”),但它跟“实体组件系统”(Entity Component System)框架完全没关系(后者很显然不会被称为“实体组件系统”)

        • 游戏从一个无功能的“实体”开始(本例中称为GameObject),这些实体自身由“组件”(Component)构成。

        • GameObject实现了服务定位器模式(Service Locator Pattern,https://en.wikipedia.org/wiki/Service_locator_pattern),这种模式可以通过类型查询子组件。

        • Component知道自己属于哪个GameObject,它们可以通过查询父GameObject来定位兄弟组件。

        • 组合仅限于单层(Component不能拥有子组件,GameObject也不能拥有子GameObject)

        • GameObject只能有各种类型的组件各一个(有些框架要求这一点,有些不要求)

        • 所有组件(可能)都会以未知的方式改变,因此接口定义为“virtual void Update”。

        • GameObject属于场景,场景可以查询所有GameObject(因此可以继续查询所有Component)

        这种框架在本世纪初非常流行,尽管它很严格,但提供了足够的灵活性来支持无数的游戏,直到今天依然如此。

        但是,这种框架并不是必须的。编程语言的特性中已经提供了组合,不需要再用框架实现一遍……那为什么还需要这些框架?那是因为框架可以实现动态的、运行时的组合。

        GameObject无须硬编码,可以从数据文件中加载。这样游戏设计师和关卡设计师就可以创建自己的对象……但是,在大多数游戏项目中,项目的设计师都很少,而程序员很多,所以我认为这并不是关键的功能。何况,还有许多其他方式来实现运行时组合!

        例如,Unity使用C#作为其“脚本语言”,许多其他游戏使用Lua等替代品,所以面向设计师的工具可以生成C#/Lua代码来定义新的游戏对象,而不需要这些框架!

        我们会在以后的文章里重新加入运行时组合的“功能”,但要同时避免10倍的性能开销……

        如果我们用OOD的观点评价这段代码:

        • GameObject:GetComponent使用了dynamic_cast。大多数人都会告诉你,dynamic_cast是一种代码异味——它强烈地暗示着代码什么地方有问题。我认为,它预示着你的代码违反了LSP——某个算法在操作基类的解耦,但它要求了解不同实现的细节。这正是代码异味的原因。

        • GameObject还算可以,如果认为它实现了服务定位器模式的话……但是从OOD的观点来看,这种模式在项目的不同部分之间建立了隐含的联系,而且我认为(我找不到能用计算机科学的知识支持我的维基链接)这种隐含的通信通道是一种反面模式(https://en.wikipedia.org/wiki/Anti-pattern),应当使用明示的通信通道。这种观点同样适用于一些游戏中使用的“事件框架”……

        • 我认为,Component违反了SRP(单一责任原则),因为它的接口( virtual void Update(time))太宽泛了。“virtual void Update”在游戏开发中非常普遍,但我还是要说这是个反面模式。好的软件应该可以很容易地论证其控制流和数据流。将一切游戏代码放在“virtual void Update”调用后面完全混淆了控制流和数据流。在我看来,不可见的副作用(https://en.wikipedia.org/wiki/Side_effect_(computer_science))——也称为“远隔作用”(https://en.wikipedia.org/wiki/Action_at_a_distance_(computer_programming)——是最常见的Bug来源,而“virtual void Update”使得一切都拥有不可见的副作用。

        • 尽管Component类的目的是实现组合,但它是通过继承实现的,这违反了CRP(组合重用原则)

        这段代码好的一方面在于,它满足了SRP和ISP(接口隔离原则),分割出了大量的简单组件,每个组件的责任非常小,这一点非常适合代码重用。

        但是,它在DIP(依赖反转原则)方面做得不好,许多组件都互相了解对方。

        所以,我上面贴出的所有代码实际上都可以删掉了。整个框架都可以删掉。删掉GameObject(即其他框架中的Entity),删掉Component,删掉Find Of Type。这些都是无用的VM中的一部分,破坏了OOD的规则,使得游戏变得非常慢。


        无框架组合(即使用编程语言的功能实现组合)


        如果删掉整个组合框架,并且没有Component基类,我们怎样才能使用组合来管理GameObject呢?

        我们不需要写VM再在我们自己的奇怪的语言之上实现GameObject,我们可以使用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的主旨是封装类的状态,但这些类非常小、非常简单,所以没有太多东西需要隐藏,它的接口只是数据描述而已。但是,封装成为面向对象支柱的主要原因之一是,它可以让类不变量(class invariant,https://en.wikipedia.org/wiki/Class_invariant)永远为真……或者说,在违反某个不变量时,你只需要检查封装的实现代码就能找到Bug。在这段示例代码中,我们值得添加一个构造函数来确保一个简单的不变量,即所有值必须被初始化。

        • 我将过于通用的“Update”方法改名,使之能够反映出实际功能,比如MoveComponent的叫做Update Position,Avoid Component的叫做Resolve Collisions。

        • 我删掉了三段有关模板和预制组件(Prefab)硬编码的代码,即创建包含特定Component类型的GameObject代码,并用三个C++类来代替。

        • 修正了“virtual void Update”反面模式。

        • 不再让组件通过服务定位器模式互相查找,而是让GameObject在构造过程中直接链接组件。


        对象


        这样,我们不再使用下面的“VM”代码:

         for (auto i = 0; i < kObjectCount; ++i)
            {
                GameObject* go = new GameObject("object");


                PositionComponent* pos = new PositionComponent();
                pos->x = RandomFloat(bounds->xMin, bounds->xMax);
                pos->y = RandomFloat(bounds->yMin, bounds->yMax);
                go->AddComponent(pos);


                SpriteComponent* sprite = new SpriteComponent();
                sprite->colorR = 1.0f;
                sprite->colorG = 1.0f;
                sprite->colorB = 1.0f;
                sprite->spriteIndex = rand() % 5;
                sprite->scale = 1.0f;
                go->AddComponent(sprite);


                MoveComponent* move = new MoveComponent(0.5f, 0.7f);
                go->AddComponent(move);


                AvoidComponent* avoid = new AvoidComponent();
                go->AddComponent(avoid);

                s_Objects.emplace_back(go);
            }

        而是使用正常的C++实现:

        struct RegularObject{    PositionComponent pos;  SpriteComponent sprite; MoveComponent move; AvoidComponent avoid;

            RegularObject(const WorldBoundsComponent& bounds)
                : move(0.5f0.7f)      
                , pos(RandomFloat(bounds.xMin, bounds.xMax),
                      RandomFloat(bounds.yMin, bounds.yMax))        
                , sprite(1.0f,
                         1.0f,
                         1.0f,
                         rand() % 5,
                         1.0f)
            {
            }};...
          regularObject.reserve(kObjectCount);for (auto i = 0; i < kObjectCount; ++i)
            regularObject.emplace_back(bounds);


        算法


        现在另一个难题是算法。还记得开始时我说过,接口和算法是共生(Symbotic)的,两者应该互相影响对方的设计吗?“virtual void Update”反面模式也不适合这种情况。原始的代码有个主循环算法,它的结构如下:

        for (auto go : s_Objects)
            {

                go->Update(time, deltaTime);

        你可能会认为这段代码很简洁,但我认为这段代码很糟糕。它完全混淆了游戏中的控制流和数据流。

        如果我们想理解软件,维护软件,给软件添加新功能,优化软件,甚至想让它能在多个CPU核心上运行得更快,那么我们必须理解控制流和数据流。所以,“virtual void Update”不应该出现。

        相反,我们应该使用更明确的主循环,才能让论证控制流更容易(这里数据流依然被混淆了,我们会在稍后的提交中解决)

        for (auto& go : s_game->regularObject)
            {       UpdatePosition(deltaTime, go, s_game->bounds.wb);
            }   for (auto& go : s_game->avoidThis)
            {       UpdatePosition(deltaTime, go, s_game->bounds.wb);
            }   

            for (auto& go : s_game->regularObject)
            {       ResolveCollisions(deltaTime, go, s_game->avoidThis);
            }

        这种风格的缺点是,每加入一个新类型的对象,就要在主循环中添加几行。我会在以后的文章中解决这个问题。


        性能


        现在代码中仍然有违反OOD的地方,有一些不好的设计抉择,还有许多可以优化的地方,但这些问题我会在以后的文章中解决。

        至少在目前来看,这个“改正后的OOD”版本的性能不弱于Aras演讲中最后的ECS版本,甚至可能超过它……

        而我们所做的只是将伪OOP代码删除,并使用真正遵守OOP规则的代码而已(并且删除了100多行代码!)


        下一步


        我还想谈更多的问题,包括解决残余的OOD问题、不可更改的对象(函数式风格编程,https://en.wikipedia.org/wiki/Functional_programming),以及对数据流、消息传递的论证能带来的好处。

        并给我们的OOD代码添加一些DOD论证,给OOD代码添加一些关系型技巧,删掉那些“实体”类并得到纯粹由组件组成的、以不同风格互相链接的组件(指针 VS 事件处理),真实世界的组件容器,加入更多优化以跟上ECS版本,以及更多Aras的演讲中都没有提到的优化(如线程和SIMD)

        原文:https://www.gamedev.net/blogs/entry/2265481-oop-is-dead-long-live-oop/

        5G进入元年,物联网发展愈加火爆!

        你是否身怀绝技、却无人知晓;别让你的IoT项目再默默无闻了!

        继第一届AI优秀案例评选活动之后,2019年案例评选活动再度升级,CSDN将评选出TOP 30优秀IoT案例,赶快扫码参与评选吧!重磅福利,等你来领!

         热 文 推 荐 

        ☞《乐队的夏天》很酷?程序员式的摇滚才燃爆了!

        ☞ 腾讯又涨工资!员工平均月薪达 7.27 万!

        ☞ 为什么华为 200 万招聘 AI 博士,马斯克却推出脑机接口对抗 AI?

        ☞鸿蒙 OS 背后神秘人物曝光!
        ☞只需要支付0.5元就可以撤回交易?这下可坑苦DApp了……
        ☞从原理到代码,轻松深入逻辑回归模型!
        ☞@程序员,“10倍工程师”都在追这四大AI风向
        ☞常见的Hadoop十大应用误解
        ☞行!这下 CSDN 玩大了!粉丝:太良心
        你点的每个“在看”,我都认真当成了喜

          您可能也对以下帖子感兴趣

          文章有问题?点此查看未经处理的缓存