OOP 已死,DOD 长存

标题:OOP Is Dead, Long Live Data-oriented Design

日期:2018/10/26

作者:Stoyan Nikolov

链接:https://www.youtube.com/watch?v=yy8jQgmhbAU

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。

备注:标题参考了「国王已死,浩气长存」,不过这本来就是一个经典句式。


早上好。我叫 Toyan。首先,感谢大家这么早起床来参加这个演讲。今天我们将聊聊数据导向设计(data-oriented design)。我决定做这个演讲是因为,在查阅了所有关于数据导向设计的资源、演讲和博客后,我无法找到一个好的例子,展示人们如何用数据导向设计构建一个真实世界的生产系统,并将其与面向对象编程(object-oriented programming)进行比较,而且是在游戏领域之外的例子。我在视频游戏行业已经有大约十年了。我一直在做游戏。然而,我主要从事的是游戏技术。我在一家名为 Coherent Labs 的公司工作,我们为游戏创造技术。所以,如果你在玩游戏并且你是个游戏玩家,你的机器很可能已经执行过我们将要看到的一些代码。在过去的六年半里,我们一直在开发基于 Chromium、WebKit 的产品,以及现在我们自有的专有游戏 UI 和浏览器引擎。如果你不熟悉这些术语,Chromium 是开源的,是 Chrome 浏览器、Slack、Skype 以及许多其他很可能正在你机器上运行的应用程序的核心。WebKit 是 Chromium 核心最初分叉出来的项目,用于 Safari、iOS、Mac,所以如果你是苹果用户,你也在使用它。我们有过这些成功的项目,大约两年半前,当我致力于优化和改进基于这些技术的软件时,我始终感觉自己身处一个牢笼之中。那个牢笼就是 WebKit 的架构,它在很多方面也是 Chromium 的架构,是那些布局引擎的架构。因此,我和我的联合创始人们聚在一起,做了唯一明智的事情,当然,那就是从头开始。抛开所有那些,为我们自己构建一个内部使用的游戏浏览器引擎。

对我们来说,有一个非常大的问题。一个浏览器引擎能否通过数据导向设计取得成功?我们当时不知道。我们有用数据导向设计的成功项目。我们的图形引擎就是用这个构建的。我们知道很多游戏通过采用这种设计取得了成功。但我们不知道有任何达到我们目标规模的例子。而且我们不知道它是否能扩展到其核心非常面向对象的软件。如果你看看所有这些开源项目,它们都是面向对象的。这有很多原因。部分是历史遗留问题。部分是因为 HTML 渲染标准要求你以某种方式做事。那么,为了看看效果如何,我们做一个非常快速的演示。好的。让我们运行一个我用 Chromium 做的测试页面。这是我这里有的一个构建版本。好的。这是一个非常简单的例子。这里有 3,000 个矩形。我所做的只是让它们左右移动并改变颜色。在中心,有一个大矩形,它的唯一目的是让我们了解性能情况。所以,如果你是一个敏锐的游戏玩家或者经常关注性能的人,你已经看到帧率并不理想。确切的数字有点难看清。大约是每秒 13 到 15 帧左右。为了放大一点,我会使用一种非常老派的方法。我会把它粘贴到画图工具里然后放大。原因是 Windows 放大镜实际上会降低 Windows 演示者的性能,所以如果我们用放大镜测试,会扭曲我们的结果。所以我们得到 15.7 帧每秒。这离我们想要的流畅动画所需的 60 帧差得很远。好的,看看我们做了什么。如果你看右下角,我们得到了大约 50 帧。实际上,在我的酒店房间里是 70 帧,但谁知道是怎么回事。所以我们在性能上有了显著的提升。

而那个问题的答案碰巧是肯定的,我们成功了。我们将看到这是如何实现的。

所以今天的议程是:我们将看看面向对象编程的基本问题。我会简要地看一下我们认为可以做得更好的地方。我们将看看数据导向设计的基础知识。有很多资源。我最后有一张幻灯片列出了参考资料,你之后可以查阅。今天的主要想法是设计一个具体的系统,看看一个用面向对象编程设计出来的方案,然后用数据导向设计重做它,并比较这两者之间的差异。

面向对象编程到底有什么问题?为什么人们对它有意见?我相信一张图胜过千言万语。所以这是一个继承层次结构。有点难理解,对吧?你有菱形继承等等。面向对象编程的核心是将操作与它们所操作的数据捆绑在一起。这并非一段幸福的婚姻。因为最终你会得到那些庞大的对象,里面包含大量不相关的数据,你在软件的不同部分去访问它们。如果我们想象一个游戏,你有一个敌人,你可能有关于其物理的数据,一些关于其 AI 的数据,一些关于其渲染的数据等等。但每次你访问那个对象,例如为了绘制它时,你实际上是在污染你的缓存,并获取了大量你此刻并不使用的其他东西的数据。面向对象编程还有一个特点,就是在很多地方隐藏状态。我说的隐藏,是指你有很多布尔值,在你的方法中有所有这些隐藏的 if 语句,最终大大增加了你代码的复杂性。所有这些都对性能、可伸缩性、可修改性和可测试性产生了实际的影响。而这将是我们将要分析的四个质量属性。

数据导向设计采取了不同的方法。它把数据放在首位。你可以想象,这就在标题里了。所以,如果我们看幻灯片的下半部分,在面向对象编程中,你通常有这个逻辑实体及其所有字段,所有在逻辑上属于该对象的东西。在数据导向设计中,我们采取了一种有点不同的方法。我们根据数据的用途来分离数据。所以我们可能有数据 A 到 C,包含字段 A 到 C 的数据 A,被用在系统 alpha 中。我们从字段 D 到 F 获取数据 B。我们在 alpha 和 beta 系统中使用它。我们将该数据转换成一个新的集合,用于下游的流水线。所以数据导向设计的核心是将数据与逻辑分离。我们尝试在必要的地方使用数据,而不污染我们的缓存,并努力保持我们的逻辑更简单。所以我们拥抱数据。我们不试图隐藏它。我们避免隐藏的使用。我们会看到如何做到。这是一种有趣的技术。我们尽量避免虚函数调用,因为我们通常根本不需要它们。数据导向设计是那种核心简单但非常难以精通的东西之一。有点像围棋游戏。它促进领域知识。如果你不了解那些数据,不了解你正在解决的问题和你正在设计的系统,你就无法很好地分离你的数据并构建一个好的系统。正如我提到的,在演讲的最后有参考资料供获取更多信息。

那么我们来设计一个系统。我选择的系统是来自我之前展示的浏览器引擎中的一个动画系统。为了让你们了解用这样的系统能做什么。

这是来自我们某个示例的一个非常小的例子。但它让你明白,通过 CSS 动画,你可以做很多事情。我们之前展示的性能例子只是移动矩形和改变它们的颜色。但你可以做更复杂的事情,比如变换、移动文本、透明度等等。所以你可以修改不同的属性。系统的细节并不那么重要。对我们来说,更重要的是深入了解如何创建它。

动画的定义同样非常简单。我们有一个文本定义。我们有我们的关键帧。每个做动画的人都知道关键帧是什么。就是我想要动画从这里开始,到这里结束,这里具有这个属性,到这里结束。我们有动画的持续时间。在现实世界的系统中,显然还有其他属性。但这些是基础,其他的属性并不会对设计产生太大影响。

我们已经看到我们可以修改不同类型的属性。这将对我们的设计产生实际影响。这意味着当我们在 C++ 中实现它时,我们必须对不同类型的 C++ 结构体或对象或其他东西进行插值。我们可以有数字。我们可以有颜色。我们可以有变换等等。另一个重要的设计点是,这个定义不是静态的。我们希望允许用户在运行时修改动画。所以让我们尝试用面向对象编程来实现它。对于今天的例子,我选择了 Chromium。我不知道有多少人熟悉 Chromium 的代码库。但我真的、真的鼓励你们去研究和学习它。它是一个由世界上最好的工程师完成的不可思议的软件。有很多值得学习的地方。当然,它是一个庞大的代码库。数百万行代码。但当你掌握了它的精髓,它非常酷。Chromium 实际上有两个动画系统。我们只看其中一个。另一个在设计上类似。所以我说的所有内容也适用于它。它非常严格地遵循 HTML 标准。这大概就是为什么他们首先采用这种面向对象设计的原因。

由于它是面向对象的,并且我们正在创建动画,我们期望有什么?一个叫做 Animation 的类。对。这正是我在代码里查到的。有一个叫 Animation 的类。太好了。但是哇哦。它继承了六个其他非平凡(non-trivial)的类。好吧,至少它是 final 的,所以我们知道后面没有其他继承者了。你还记得我在开头展示的那张关于继承层次结构的图片吗?它实际上就是这个类的继承层次结构。所以往上你得到很多继承。这已经很难理解了,对吧?好的。为了更好地理解系统,我们将着眼于最常见的操作。也就是滴答或更新一个动画。即根据时间和定义来为这一帧生成我们正在改变的属性的新值。比如我们元素的新颜色或新位置。我们将深入研究 Chromium 的代码。它开始得很符合我们的预期。有一个方法叫做 ServiceAnimations。它接收时间。在第二行,它获取当前时间。然后它对每个动画指针调用 Update。也就是说,在我们的集合中。有一个需要更新的动画集合。到目前为止很清楚。然而,我们已经看到一些奇怪的事情发生了。他们不是在遍历 animations_needing_update 向量(vector)。他们是在复制所有的指针,然后遍历并迭代另一个临时向量。这样做的原因是,在这个调用栈中,也可能在其他调用栈中,他们正在从原始向量中删除元素。所以我们有一些不清晰的生命周期语义。我们现在很难知道这些动画是何时创建的。最重要的是,它们何时被删除。如果他们写了这样的代码,那是有原因的。

那么让我们看看 Update 方法。那里发生了什么。

相当简单。它接收原因。但第一行就是我刚才提到的隐藏状态。如果没有时间线,也就是某个指针之类的东西,它们就返回 false。所以在某些条件下,动画将什么都不做。但仅仅为了这个 if 判断,我们已经付出了很大的代价。因为动画位于堆上。所以我们仅仅是为了查看它是否被设置而访问了那个字段。我们正在为一次缓存未命中付出代价。而且我们也可能会有很多分支预测失败。因为如果我们有大量活跃和不活跃的动画混合在一起,分支预测器很可能会混淆。所以我们正在付出一些性能代价。而且我们正在指数级地增加系统的状态和复杂性。好的,那么如果我们有这个内容,这是另一个条件,我们做什么?我们实际上将动画委托给另一个类。这个类作为成员保存在我们动画的声明中。Chromium 在 C++ 中做垃圾回收。但你可以把这个成员类型看作一个共享指针。所以它们,我们再次在为一次可能的缓存未命中付出代价。而这个 AnimationEffectReadOnly 实际上是我们动画的定义。好的。我会跳过一些调用栈。我本来有更多的幻灯片,但由于时间限制不得不删掉了。我们将跳过调用栈,只看有趣的部分。

接下来,我们要更新我们的值。我们跳过了一些,看到一些在面向对象编程中经常发生的趣事。在某些情况下,动画必须调用一个事件。确实如此。所以如果有一个事件委托,我们将调用一个事件条件。这里我们在动画系统和事件系统之间存在广泛的耦合。此外,我们根本不知道 OnEventCondition 实际上会做什么。它可能调用 JavaScript,这会破坏我们缓存中的所有热数据。它将不得不加载所有 JavaScript 所需的冷数据,执行它要做的任何事情。然后你必须重新加载我们刚刚扔掉的所有数据。所以这是一个潜在的非常严重的性能打击。可能还会有一些指令缓存未命中,因为我们不知道在庞大的源代码中这个调用会去哪里。我们计算完了所有这些,然后到了我开头提到的有趣部分。

我们希望能够对不同的 C++ 类型进行插值。因为我们的动画可能作用于颜色,可能作用于数字等等。然而,我们仍然希望以某种方式将其保留在我们的系统中。那么我们在经典的 C++,经典的面向对象编程中如何做到这一点?我们想要一个根据我们想要插值的类型而不同的方法。而且它们大小不同。有谁知道?虚函数。是的。虚函数和抽象类。这正是他们所做的。所以有一个抽象的 Interpolation 类。它为每一种可以插值的类型继承。虚函数 Interpolate 完成了魔法。不幸的是,在 C++ 中这种动态类型擦除的成本非常高。你有分配所有这些插值对象的成本。在我们之前的例子中,那是六千个动画。所以,六千个这样的对象。而且当你调用虚函数时,你很可能遭遇缓存未命中。并且根据你正在动画化的属性类型,很可能遭遇指令缓存未命中。最后,我们计算出了新值。插值完成了它的魔法。我们必须将这个新值应用到属性上,应用到正在被动画化的对象上。

我们再次用一种非常直接的方式来做。我们有一个成员变量target,它是将接收新值的元素。我们再次引入了一些系统间的耦合。因为现在我们的动画系统,其唯一的工作是根据时间计算新值,却知道了文档对象模型,知道了样式系统,以便它可以调用元素的相应方法。在我们的例子中,这个方法叫做 SetNeedsAnimationStyleRecalc。如果我们看看它的实现,我们会看到一些非常非常可怕的东西。我相信这又是面向对象编程的一个症状,你调用一个方法,但因为它是一个黑盒,你根本不知道里面发生了什么。所以你最终可能为此付出非常高昂的代价。我们确实付出了高昂的代价,因为那里调用的 SetNeedsStyleRecalc 实际上会为每个元素向上遍历文档对象模型树并设置一个布尔值。在向上遍历这棵树的每一次调用中,都很可能遭遇一次缓存未命中。所以我们原本在做简单的动画,现在却在遍历 DOM 树。我们再次改变了程序的上下文。

所以快速回顾一下,为了完成我们的动画,我们使用了超过六个 DOM 树组。我们使用了视频类。对象包含指向其他系统的智能指针。它们与其他系统耦合在一起。对于插值,我们使用了抽象类。我们基本上从头到尾都在饮下面向对象编程的毒药。

好的。现在让我们尝试用数据导向设计来做。让我们清空思绪,忘记刚才看到的,回到绘图板。让我们围绕最常见的操作来设计我们的系统。这个操作就是滴答,更新动画。它占了大约 99% 的时间。显然我们还有其他操作,比如添加、移除、暂停动画等等。但最重要的,定义我们性能的操作,是滴答。对于这个滴答,我们有两个非常简单的数据作为输入。显然,我们有动画的定义。我们有时间。作为输出,如果你仔细想想,我们也有一些非常简单的数据。我们需要哪些属性发生了变化。我们需要新的属性值。我们需要一个映射,指向将接收这些属性的元素。再次,作为数据导向设计的基石,我们将在这个系统上工作,就好像有很多动画一样。而使用面向对象编程和我们之前看到的东西,它工作起来就好像只有一个动画。所以你调用动画上的方法,它做它该做的事。但这不是实际发生的情况。你不是有一个动画。你有几十个、几百个、甚至几千个。那么我们的滴答将如何工作?哦,它可以做得非常简单。让我们创建一个叫做 AnimationController 的类,或者任何我们觉得有趣的名字。它将包含一个我们动画状态的数组。它将时间作为输入。这些状态是由定义生成的。它将精确地输出我们需要的东西。一个数组,一个向量,包含已改变的属性。一个数组,包含已改变的元素及其指向新属性的链接。在这里,元素指针对我们的系统来说只是哑指针(dumb pointers)。它不知道它们是什么。它不直接调用文档对象模型。有了这些数据作为输出,我们可以留给流水线中的下一个系统来决定该做什么。所以事件系统可以遍历这些已改变的属性、这些已改变的元素,并在必要时决定调用事件。样式系统可以遍历这些元素,知道它们的新属性后决定该做什么。这样我们就实现了高度的解耦,高度的关注点分离。我们如何构建这些动画状态?嗯,这很容易。再次强调,要扁平化。忘掉方法。只需要一个普通旧数据风格(plain old data style)的结构体,包含我们需要的所有数据。细节并不重要。这是我们需要的运行时数据。比如开始时间,动画的暂停时间。这里有个小转折。我们也将所有动画定义复制了一份给每个动画状态。我们这样做有两个原因。第一个是,经验上我们发现这样性能更好。第二,它使某些操作变得非常容易。想象一下你想改变某个动画的持续时间。如果定义是共享指针,你将不得不实现某种写时复制或其他惯用法来实现。但现在它们是分开的,你只需更改该动画的属性。所有其他动画完全不受影响。所以想想这可能会如何帮助我们,例如在多线程中做这件事。好的,所以我们有了这些属性。然而,我们还没有解决必须对不同 C++ 类型进行插值的问题。记住,他们是用抽象类做的。但我们不想为此付出代价。为了避免类型擦除,C++ 有一个非常好的静态方法。我们可以使用模板。我们可以使用模板,因为我们确切地知道我们将要修改的所有属性类型。没有新的类型。所以我们现在有不同的类型,由动画关键帧模板化。我们想要遍历它们来运行动画。然而,显然它们在内存中具有不同的大小。所以我们不能把它们放在一个漂亮整洁的向量里然后遍历。但正如我所说,我们知道每一个类型。所以我们可以做的非常简单。让我们为每种类型准备一个向量。

现在它们大小相同了。所以我们有和所需类型数量一样多的向量。当我们要对它们进行插值时,这非常简单。我们遍历所有类型 A 的向量。然后我们转到第二个类型。然后我们转到第三个类型。所以我们现在经历的缓存未命中数量比面向对象的情况少了一个数量级。原因是,当我们改变正在迭代的向量类型时,我们会遭遇一次缓存未命中。然而,这又是一点领域知识,我们知道我们通常只会有少数几种属性真正被动画化。所以我们最终会得到一堆非常大的向量,我们可以线性迭代。CPU 的预取器会发挥它的魔力,我们不会为缓存未命中付出代价。而我们最终会有很多向量是空的,我们会直接跳过它们。因此,与面向对象情况中每个动画遭遇一次缓存未命中不同,我们实际上是为每种动画类型遭遇一次缓存未命中,这少了一个数量级。实际上在我之前展示的性能例子中,有 6,000 个动画,因为有 3,000 个矩形。但它们同时在改变颜色和位置。所以这些是不同的动画。所以在面向对象的情况下,我们每帧有 6,000 次缓存未命中。在这种情况下,我们只是遍历颜色和位置。所以我们可能只会遭遇 2 次缓存未命中。在实现层面,这非常容易。我们可以根据属性对滴答动画函数进行模板化,只需运行相同的代码。

我提到过面向对象编程倾向于隐藏大量状态。我们在动画的活跃性上看到了这一点。所以你可以有一些是启用的,一些是禁用的。在最简单的实现方式中,你可以有一个布尔值 is_active。或者在 Chromium 的例子中,是 if (there is a timeline),这基本上是同一回事。然后做 if (this) do that 等等。在我们的系统中,活跃与非活跃是最重要的状态,最重要的布尔值。所以我们可以采用一种叫做基于存在的谓词(existence-based predication)的技术。我们只需要两个数组。一个数组用于活跃动画。另一个用于非活跃动画。启用一个动画的操作仅仅意味着将数据从一个数组移动到另一个数组,或者反之亦然。这样,当我们迭代并需要对活跃动画应用操作时,我们知道没有隐藏状态。我们知道可以对它们全部应用完全相同的代码,完全相同的转换。这对于你拥有的每一个可能的状态来说有点难做到。所以你可以根据最重要的状态,或者波动性最大的状态来确定优先级,或者你可以尝试减少状态的数量。那么来看看最终的代码,细节并不那么重要。但如果你看它,它非常简单,并且在很多方面很优美,因为没有 if 语句,没有分支,你可以像读一本书一样阅读它。所以我们插值关键帧,我们插值数值,一切都如我们预期的那样工作。我高亮显示的这个 GetInterpolatedValue 函数只是一个模板,它会做正确的事情,并为我们正在改变的属性调用正确的函数。而且这段代码可以安全地放在我们的 C++ 文件中,它不会到处污染模板,也很可能将代码在最终的可执行文件中保持在一起。所以指令缓存未命中的可能性非常小。

好的,然而,我们的系统现在可以工作了。它的性能非常高,我们对此非常满意。但我们想给用户添加一个 API。我们希望它是一个方便的 API。而在这里,带有方法等的面向对象风格的类确实非常方便。你可以给用户一个动画对象,她可以调用 play()、pause() 等等。所以我们想把这个给用户。那么让我们创建我们的动画对象。现在是时候了。它拥有我们想要的所有方法,但只包含一个简单的数据。那就是一个动画句柄,仅仅是一个 ID。它将所有操作推迟到其他函数,即动画控制器。所以我们的 Animation 类非常“笨”,但它给用户提供了这个方便的 API,而我们仍然可以重构内部数据并使我们的系统尽可能高效。我们使用一个简单的句柄来简化这些对象的生命周期管理。因为现在我们知道生命周期完全由我们的控制器控制。这些数据没有共享所有权。

在控制器中实现 DOM API 非常简单。你只需要所有接受这个 ID 的方法。所以快速回顾一下我们在 OOP 和数据导向设计之间看到的概念差异。在 Chromium 的情况下,我们使用了六个类的继承。所有逻辑都塞在这个 Animation 对象及其链接中。在 DoD 中,我们做了一个非常简单的扁平动画状态结构体,模板化的。我们有其他方法操作这些数据。OOP 使用了动态分配的插值列表,即抽象类。但在 C++ 中你可以做得更好。你可以使用模板。如果你知道你拥有的所有类型,你可以为每种类型准备一个数组。OOP 使用布尔标志来表示活跃性。同样,标志不仅仅指布尔值,也可以是指针存在与否等等。

在数据导向设计中,我们为不同的状态使用了不同的数组。为了给用户一个 API,在面向对象编程中,我们直接给出类的 API。然而,我们的用户只是想播放一个动画,却立即得到了一百个他不知道该怎么用的方法。我们通过只提供所需内容的、非常清晰的 API 来解决这个问题。最后,在面向对象中,你直接接触程序中其他系统。在数据导向设计中,我们只输出包含新数据的表,让流水线中的其他家伙决定如何处理它们。所以数据导向设计的要点是:尝试保持数据扁平。尝试使用基于存在的谓词来摆脱无处不在的状态。尝试使用基于 ID 的句柄来减少指针并简化对象的生命周期。尝试使用基于表的输出。因为这种基于表的输出实际上将成为流水线中下一环节的输入。

让我们分析一下我们的成果。好的,我们将首先关注性能。我将关注这个系统的性能。它快了六倍。这对我们来说是一个隐藏的宝藏。我们没有改变算法。我们没有发明一些非常聪明的方法来做动画。我们执行并做了几乎相同的事情。仅仅通过重组数据的使用方式和代码的结构,我们在这个系统上获得了六倍的性能提升。想想看。你的代码库中很可能也有这样的系统。宝藏正等待着被发现。

接下来,让我们谈谈可伸缩性。我们如何在假设上扩展这些系统?在这里,我所说的扩展是一个非常简单的意思:让它们在多线程上工作。

嗯,在面向对象的情况下,这将非常困难。因为我们必须考虑所有这些依赖关系,以及系统中很可能发生的数据竞争。所以当我们告诉目标改变其样式时,我们不知道是否有其他线程也在做这件事。我们不知道我们调用的所有方法是否是线程安全的。我们必须确保它们是。隐藏状态的另一个问题是,如果我们假设性地解决了这些问题并运行,比如说,一半的动画在核心 A 上,另一半在核心 B 上,如果它们的运行时特性非常不同,比如核心 A 很幸运,得到了很多不活跃的动画,那么它在很多时间里将是空闲的。我们将无法均匀分配工作。

使用数据导向设计,事情看起来更简单。我们的动画状态彼此完全独立。我们复制了一些数据来实现这一点。所以我们可以做的就是把我们的向量切成两半。一半在这里,一半在另一个核心上。每一个都输出自己的数据。最后,以经典的 fork-and-join方式,我们可以合并这些数据并将其留给下一个系统。

那么可测试性呢?

嗯,同样,在面向对象的情况下,看起来,嗯,很困难。我可不想成为那个为所有这些写单元测试的人。因为例如,我将不得不模拟很多东西。事件、文档对象模型,仅仅为了测试我的动画系统。此外,我有很多隐藏状态。所以有很多 if 和 else 等等。所以如果我想覆盖所有情况,我将面临测试组合爆炸的问题。

在数据导向的情况下,它看起来又简单了一些。只是因为我们确实将动画系统与其他所有东西隔离开了。我们有非常清晰的输入,非常清晰的输出。所以只要这个契约没有被破坏,正确的输入给你正确的输出,你就没问题。系统就能工作。而下游的其他系统有责任以正确的方式使用该输出。对于可修改性。如此庞大而复杂的面向对象层次结构往往会固化。我的意思是,它们变得如此复杂,以至于程序员非常不愿意去修改它们。所以对我们来说,例如,尝试改变对象的布局或把一些数据从一个类移到另一个类会非常困难。仅仅因为一切都会崩溃,我们必须修复很多东西。所以这些事情通常留给了大规模重构,而这些重构最终从未发生。在数据导向的情况下,由于我们完全拥有我们的数据并且非常清晰,我们有更多的回旋余地。我们可以将动画状态拆分成数组。我们的动画可以继续同时使用这两者。但我们可以把部分数据发送到另一个系统。我们可以尝试重新排序一些函数。这更清晰一些。

然而,有一件事是面向对象编程本身擅长的。这就是我认为的,我称之为快速修改。想象一下我们添加一个新状态。比如,如果是星期二,动画运行速度加倍。用面向对象编程很容易做到。if (Tuesday) do whatever. else do the other thing。所以我们添加一个新状态,它就工作了。如果我们要保持纯粹的数据导向设计,这就困难得多。因为我们必须重新分析我们的数据。我们必须想办法把这个状态排除在运行动画的主核函数之外。这可能会花费我们更多时间。但最终,它很可能会带来回报。

显然,数据导向设计是一个工具。就像面向对象编程一样。就像我们在编程中所做的一切一样。和所有工具一样,它有其用武之地,但也有一些缺点。最大的缺点,我认为是,正确的数据分离实际上非常难做到

我之前提到过,但数据导向设计很容易解释。但非常难以精通,最终也很难真正实现你想要的目标。这需要一些对你有益的领域知识。如果你不太了解你正在解决的问题,你很难设计出最优化的数据方式。所以了解你的问题。如果你有很多状态,基于存在的谓词也有点难以实现。因为你开始拥有这些多维数组,包含这个、那个等等。我那里的建议是尝试减少状态。如果你做过 GPU 编程,那里有很好的例子。你可以尝试通过在多数据上执行相同操作或其他技术来减少状态。只是为了摆脱那些 if 和 else。快速修改可能会很困难,正如我已经提到的。而我们作为程序员可能不得不忘掉一两件事。所以实际上,我们一直以来都被这种面向对象类型的思维所束缚。所以尤其是在开始时,很难在这个新框架下开始并重新思考一切。不幸的是,C++ 语言在某些情况下有点难用。它并不总是你的朋友。它是你的朋友。

那么,我们应该完全抛弃面向对象编程吗?我不这么认为。它也是一个工具。它在我们的代码库中有一席之地。是的,这与我演讲标题的论点相反。但有时我们别无选择。你可能在使用使用面向对象编程的第三方库。所以你不走运。你可能需要满足 API 要求,迫使你使用面向对象编程。你可能已经看到了,但面向对象编程并不意味着,也不是类和方法等的同义词。在数据导向设计中,我仍然有类。我仍然有结构体。只是我把数据放在第一位。我把数据放在第一位,而不是操作,不是代码。多态性和接口必须加以控制。我不认为它们的作用应该在一个非常低层的系统中,比如在动画中插值几个数字。但它们在高层系统、面向客户端的 API中有一席之地。如果你做某种插件,并且你确实需要一些动态多态,它们是非常棒的。记住,C++ 拥有令人惊叹的静态多态设施。使用模板,或者当你知道你拥有的所有类型时,你实际上可以生成一切,你可以走得很远。它的效果出奇地好。最后,C++ 中是否会有一些变化,使我们更容易编写数据导向设计的代码?我有一个梦想,可能永远不会实现。但有些语言允许你非常容易地改变对象的内存布局。例如,可以试验结构体数组或数组结构体,而不必为此重写一半的代码。我看不到它到来,但我们总是可以希望。我没有展示这一点,但在游戏中,数据导向设计通常与实体组件系统齐头并进。即使你不需要实体组件系统的全部灵活性,如果能够让我们在不付出巨大代价的情况下重新排序并将我们的类拆分成组件,那将是非常棒的。这在 C++ 中实际上是可行的。我说的是在没有指针间接的情况下做到。因为如果你用指针间接来做,这很容易。但它需要大量定制代码,而且有点混乱。对我来说,范围提案(std::ranges)看起来非常令人兴奋。我相信它将是一个伟大的补充,并将极大地帮助构建一些结构。特别是使代码更清晰、更易读。

这是我的一点个人不满,但无序映射和无序集合并不是那么好。因为它们进行分配。我认为我们需要在标准中引入一个新的,比如说,另一个哈希表,它具有一些宽松的要求,允许我们使用另一种寻址方案并减少那些分配。所以,显然有很多开源解决方案。我们都使用哈希表,实际上在我们某个版本中,仅仅通过使用开放寻址方案替换无序映射和无序集合,就将分配次数减少了 10%。这再次让你回归到:了解你的机器,并为将要运行它的机器设计软件。不要为像标准中那样的假设抽象机器设计,因为它并不存在。

所以,总结一下,面向对象编程不是银弹。但是,数据导向设计也不是。关键在于你必须运用你最好的判断力。它们只是你工具箱里的工具,所以它们各有其位。找到它们的定位是你的工作。

谢谢大家。我们还有大约 10 分钟时间提问,请提问。

是的,你提倡,抱歉,你提倡编译时多态。我也喜欢这个。然后总是有人对我说,是啊,但那样编译器会生成大量代码。是的。根据你在了解机器方面的经验,你在什么时候会遇到这方面的问题?比如代码太多导致指令缓存未命中或者…?是的。你知道有什么工具可以让我看到这些问题是否发生吗?是的。这是一个值得关注的问题。所以,我们尝试做的是将模板保留在实现文件内部。在动画系统中,这是可行的。如果我们看到模板爆炸,这是我们尝试逐个案例去应对的问题。所以你在那里有两个问题。你可能会使构建时间爆炸,这显然对生产力非常不利。另一件事就是你提到的。我们在某些情况下看到编译器生成了大量二进制码,增加了我们的指令缓存未命中。

老实说,没有解决这个问题的万能公式。你要么必须减少模板的使用,要么可以依赖一些黑魔法。老实说,这就是我们在某些地方所做的。所以,如果你有幸在一个能非常容易地显示指令缓存未命中发生在哪里的平台上工作,我们有这样的平台。例如,在游戏主机上,这很容易看到。你可以去那里尝试重新排序你的函数。或者你可以尝试使用链接时优化。实际上,根据我们的经验,链接时优化在解决其中一些情况方面帮助很大。但有时你运气不好,版本一一切正常。然后你在代码某处改了一行,就发现慢了 5%,仅仅是因为 Clang 重新排序了一些东西。当你真正接近最大可能性能工作时,这就是生活的一部分。不幸的是,我没有一个能始终解决这个问题的公式。好的,非常感谢。谢谢。嗨。嗨。你描述了这个系统的架构,包括数据和对数据的操作。我对这个领域不太熟悉,但我相信你的选择是好的。但使用这些结构时,你可能希望在数据中维护某些不变量。例如,你可以用构造函数或方法来做到这一点。是的。你还描述了,对于系统的某些部分,比如面向用户的部分或面向客户端的部分,你可能需要那些抽象。是的。所以我想我最大的问题是,你究竟认为什么是面向对象编程?为什么你描述的不是那个?因为我认为你实际上只是在为系统的不同部分选择不同的抽象。除非你对什么是 OOP 有不同的看法,否则我并不认为这违背了 OOP 的理念。我明白了。是的,这是解决同一个问题的不同方式。所以是相同的数据。相同的不变量。这是真的。然而,我们有点颠覆了它的完成方式。因为在面向对象编程中,我相信它的意思,以及人们通常最终做的是,拥有我们看到的那个包含所有数据、所有不变量和针对一个动画的所有操作的 Animation 对象,对吧?这就是我们看到的。然而,这并不能很好地映射到硬件。它也不能很好地映射到我们系统的复杂性。因为我们最终得到了这个知道一切、能做一切的庞然大物。让我试着稍微精炼一下我的问题。当然。所以你演讲的标题是“OOP 已死”。是的。我知道你自己在最后也稍微弱化了这个立场。但我想问的是,究竟什么死了?你希望消亡的到底是什么样的 OOP?是指拥有这些庞大的逻辑类,包含不相关的数据,我相信它们必须被重构成不同的数据集合,供不同的系统使用。这回答了你的问题吗?这听起来对我来说更像是糟糕的工程与良好的工程的区别。我不知道你是否能称之为 OOP。嗯,不幸的是,很多代码库最终都变成了我展示的那样。因为当你有一个 Animation 类时,每个开发者通常都会去添加一个新的布尔值、新的数据或新的方法。你可以称之为糟糕的工程。你想怎么称呼它都可以。但这是生活中的事实。无论你多么努力。而且毫无疑问,Chromium 和 WebKit 是由一些最好的工程师完成的。但最终你还是会得到这种庞大的继承层次结构等等。

嗨。如果我理解正确的话,在你的初始演示和 Chromium 演示之间,每帧大约有 15 到 20 毫秒的差异。你对这 15 到 20 毫秒花在哪里有概念吗?比如我们谈了很多虚函数间接调用和缓存未命中。但我们并没有真正讨论这些每帧占用了多少毫秒。只是说它们发生了。例如,Chrome 的 GPU 进程是被严重沙箱化的。它甚至必须通过 IPC来读取帧缓冲区。所以这可能是导致周期消耗的主导因素,而显然你的程序没有这个。绝对是的。所以我的演示运行得比我预期的慢一点,老实说。但可能是因为电脑没插电源。但我有个想法。这肯定对性能有一些影响。这就是为什么在做最终比较时,我只比较这个系统(动画系统)。第一个(整体演示)只是一种吸引注意力的方式,让大家看到整个系统运行得更好。它运行得更好有很多原因。GPU 进程老实说可能不是最大的原因。最大的原因通常是更少的缓存未命中。更简单的调用栈。我不知道你是否熟悉 Chromium 等的代码库。但它们有非常长的调用栈。而且它们这样做有原因。我理解。但例如,动画系统本可以做得更好。它只是做了同样的事情,并且快得多。毫秒数就在这里。所以 6、8(毫秒),这是在这台电脑上,对比 1、1(毫秒)。是的,这引出了我的下一个问题。那就是,显然简单的演示是简单的,而 Chromium 并不简单。所以你认为数据导向设计能很好地扩展到实际需要的设计复杂性吗?例如,你可能有另外 20 个子系统需要挂接到动画系统中,修改它,或者在事情发生时被通知,或者运行 JavaScript 代码。而且,我的意思是,也许你的系统能做到。我不知道,但是。是的。是的,它做到了。是的。它做到了。关于性能的另一点。在 PC 上我们通常看到大约四到五倍的性能提升。这是一个人工测试,但我指的是真实的 UI 等等。但这台电脑有 8 兆字节的 L3 缓存。我不知道那是什么。好的。好的。好的。是的。如果你去一个缓存少得多的平台,比如手机或游戏主机,性能差异会大得多。是的。好的。嗨。嗨。嗨。所以,是的。继续上一位在麦克风前的人所说的思路,我认为你对面向对象编程不够完全公平,而且并非所有的 OOP 实现都具备所有这些特性。当然。而且,是的,这是一个案例,显然 OOP 的经典方法在这里不适用。实际上,我想问你一点关于这个的。6.12是在什么类型的 CPU 上测的?这个?这个,是的。在这个上,是的。老实说,我认为如果在 ARM CPU 上测试,差异会更大。哦,是的。它们对缓存未命中的容忍度要低得多。是的。所以也许下次演讲时使用 ARM CPU 的统计数据。是的。我提到过,但,问题是,我必须为 ARM 构建 Chromium,而我不想这么做。但可以肯定的是,如果你的处理器缓存更少,这个数字会上升。回答第一部分,是的,我相信世界上存在编写良好的面向对象系统,它们有自己的一席之地。这就是我最后说的。我在调试像这样的数据驱动系统时看到了一些挑战。我想知道你遇到过哪些挑战,以及你是如何解决的?是的,有一些挑战。最大的挑战是我们习惯于,嗯,我们看到一个错误,我们进入调试器,我们有一个对象,我们可以检查所有数据。但当你做数据导向设计时,你可能知道哪些数据属于哪个逻辑实体。我们通常做的是在调试中注释我们的数据,以便我们更容易将其与系统中的其他东西关联起来。这是我们通常的做法。我没有太多… 你认为是否可能有工具可以帮助调试,比如访问数据?因为数据是,如果你有一个实体,数据是分散在各处的。所以对人类来说理解这个,要困难得多。当然。我没有见过通用的工具。我见过很多临时工具,比如我们自己开发的只是为了解决这个问题的工具。所以我们注释数据。我们有一些调试器的扩展,让我们更容易找到它。我见过很多游戏公司,特别是当事情变得多线程时,要找到问题就更困难了。很多公司有某种图可以可视化,或者试图那样做。但我不知道,也想不出一个通用的工具能真正帮上忙。它是非常领域特定的。谢谢。你使用这种方法进行长期支持和维护的经验如何?我最大的担忧是,随着时间的推移,你向结构中添加更多数据。而且,嗯,是的,你保留了多个副本。所以数据,会有,就像数据膨胀。所以在几年后,你几乎必须重组所有层次,调整它们以适应所使用的 CPU 的架构,并几乎重做整个事情。是的。嗯,我们首先会注意我们做了什么。其次,我相信这有点像我之前提到的关于系统趋于固化的那张幻灯片。我相信,如果数据是以这种方式组织的,而不是在一个庞大的类层次结构中,重新设计并最终拆分数据会更容易。所以随着时间的推移,需求变化,系统也必须改变。到目前为止我们有非常好的经验。我们在 Chromium 基础上工作了多年,在这个东西上也工作了一些年。实际上,维护时间、解决错误和实现新功能的时间快了一个数量级。当然,这可能与 Chromium 有数百万行代码有关。虽然我们的系统代码量也很大,但没那么大。所以这取决于情况。我相信随着时间的推移,这个更容易维护。谢谢。谢谢。谢谢。你谈到在所有类之间存在只读的公共状态,你需要在所有状态中复制它。是的。能谈谈为什么不能简单地将其提取出来,将只读状态与可变状态分离的挑战吗?当然。因为它不是只读的。事实是用户可以随时修改那个定义。所以一种方法是写时复制,因为你可能有多个动画引用它。另一种方法是复制它。因为我们数据量小,这是一个经验性的发现,复制数据对我们来说更容易、更快。随着情况变化,这个事实也可能改变。你在 Chromium 代码库中可能注意到了,那个类叫做 AnimationEffectReadOnly。它并不是只读的。它们有 const 方法进行 const_cast,移除 const 并修改它们。所以那不是真的。所以在它们的实现中也是可变的。

不幸的是,我们时间到了。我会留在这里。所以欢迎所有有问题的人提问。再次感谢大家的时间。