面向对象编程中 35 年的错误:软件架构与历史背景的深度剖析
Better Software Conference
总结:
本视频深入探讨了面向对象编程(OOP)中一个重要的“35 年的错误”,该错误源于普遍采用的编译时层次结构,而该层次结构又严格地反映了领域模型。
- 从 Looking Glass 在 1998 年用于《神偷》等游戏的实体组件系统(ECS)开始,它按系统而非对象层次结构组织数据,证明了其效率。
- 这个被定义为“匹配领域模型的封装的编译时层次结构”的“错误”,偏离了早期更灵活的模式。
- 演讲者追溯了这种方法,从 C++(Stroustrup)和 Simula(Nygaard 和 Dahl)开始,他们主要关注代码重用和类型安全,但没有完全意识到对复杂系统的架构影响。
- C.A.R. Hoare 提出了具有类型安全区分联合体的互斥子类,这是一个关键的分歧,但这个概念在主流 OOP 中基本丢失。
- 真正的起源揭示了更早、更强大的面向数据的设计:Douglas Ross 在 20 世纪 50 年代的“plex”系统(类似于现代带有函数指针的结构体),以及 Ivan Sutherland 在 1963 年的 Sketchpad,它使用“全知”系统来直接操作统一数据存储中的组件。
- 演讲者分享了 1997 年的个人轶事,当时他为一个关卡编辑器复杂的 OOP 层次结构而苦苦挣扎,无意中试图实现一个类似 ECS 的系统。
- 这种疏忽导致了灵活性较差且通常更复杂的代码,这种模式仍在现代 OOP 教程中教授,突出了对架构洞察力的一个失落时期。核心问题是,看似有效的隐喻可能会抑制更强大的潜在洞察力。
演讲者介绍了软件架构中一个重要的“35 年的错误”,特别是在面向对象编程(OOP)中。
“错误”的起源:C++ 和 Simula [00:16:32]
探讨了这个有问题的 OOP 范例的历史根源,从 C++ 及其前身 Simula 开始。
- Bjarne Stroustrup 和带类的 C [00:16:51]
- C++ 的创建者 Bjarne Stroustrup 于 1978 年在剑桥大学开始了他在分布式系统仿真方面的工作,以获得博士学位。
- 他学习了 Simula,一种仿真语言,并发现它的类概念对于将应用程序概念(例如,不同的计算机类型)直接映射到语言结构中非常有效。
- Simula 提供了强大的类型检查,与当时较弱的类型系统(如 Pascal)相比,这是一个显着的优势。
- 然而,Simula 的实现面临严重的性能问题,特别是糟糕的链接时间和垃圾收集器消耗了超过 80% 的运行时间,即使没有产生任何垃圾。
- 这导致 Stroustrup 放弃了 Simula 作为他的主要工作,并用 BCPL 重写了他的模拟器,BCPL 是一种类型检查最少的语言。
- 搬到贝尔实验室后,Stroustrup 试图将 C 的效率与 Simula 的有益特性结合起来,从而产生了“带类的 C”(后来的 C++)。他的目标是模块化和并发分析,利用类来实现受硬件启发的保护机制(例如,虚拟内存保护导致
private 访问说明符,操作系统读/写权限导致 const)。
- 最初,带类的 C 没有虚函数,但后来从 Simula 添加了它们,优先考虑效率。
- Stroustrup 最初还使用继承来实现代码重用(例如,链表的
link 类),但后来提倡使用模板来实现此目的,承认继承对于通用容器类来说并不理想。
追溯到 Simula 及其起源 [00:31:47]
为了理解核心思想,将血统进一步追溯到 Simula 的发展。
- 挪威计算中心 [1962]:Kristen Nygaard 和 Ole-Johan Dahl [00:32:14]
- Kristen Nygaard 和 Ole-Johan Dahl 开发了 Simula。
- 初始版本 Simula I [1963] 被设计为“系统仿真的脚本语言”,用于运营研究以模拟排队系统(例如,机场起飞)。
- 该语言允许直接描述系统组件及其流程,从而生成用于优化的统计数据。
- Simula I 取得了成功,从而开发了 Simula 67 以解决缺点并实现更大的可重用性。
- Simula 67 的类概念的主要动机是代码重用,例如“桥上的收费站”场景(汽车、卡车、公共汽车)。他们想要定义一个通用的
car 类,truck 和 bus 可以从中继承,以及一个 link 类来启用链表功能。
- 这表明领域建模和代码重用(通过继承)从 Simula 类概念的一开始就交织在一起。
- Simula 还引入了动态分派机制,类似于虚函数,以处理基于对象类型的操作。
更深层次的根源:C.A.R. Hoare 和区分联合体 [00:41:59]
“子类”思想的起源归功于 C.A.R. Hoare,揭示了一个关键的分歧。
- C.A.R. Hoare [1966] 和“记录处理” [00:42:32]
- Nygaard 和 Dahl 明确地将“子类思想”归功于 C.A.R. Hoare,特别是来自他对“记录类结构”的研究。
- Hoare 的论文探讨了用互斥子类(例如,作为常量、变量、二元表达式的代数表达式)建模现实世界对象。
- 至关重要的是,Hoare 的提案包括区分联合体(或标记联合体),其中记录具有类型字段和一个
consider when ... then 结构,用于类型安全的条件分支和访问特定的子类型字段。
- 这种机制提供了编译时类型安全,并且是动态多态性的强大工具,允许代码安全地区分和操作不同的子类型。
- 然而,这种处理多态数据的直接、类型安全的方法被 Stroustrup 有意地从 C++ 中省略(Stroustrup 认为 Simula 的
INSPECT 语句破坏了模块化),因此在主流 OOP 中基本上丢失了。
真正的起源:Douglas Ross 和 Ivan Sutherland [00:50:50]
影响链被进一步追溯,揭示了这些基本思想的最终起源。
Casey Muratori 分享了他在 1997 年遇到的“错误”的个人经历。
- 开发“Negaman”[1997] [01:20:29]
- 作为一名年轻、缺乏经验的程序员,Casey 的任务是为一个游戏创建一个关卡编辑器,其中涉及“球体上的 CSG”(后来的“负元球”)。
- 他为此任务创建了一个名为“Negaman”的建模器。
- 他试图使用当时流行的编译时 OOP 层次结构(如 C++ 中的那些)来实现基于选择的 UI。
- 这导致了一个极其复杂和混乱的代码库(例如,具有回调的“属性宇宙”的并行层次结构,以获取/设置数据),试图处理跨选定对象的多态交互。
- 他事后意识到,他实际上是在尝试实现一个实体组件系统,而不知道它,与他所学的僵化的 OOP 范例作斗争。
- 他将自己效率低下的实现与 Sketchpad 和 Looking Glass 的 ECS 中找到的优雅解决方案进行了对比,感叹知识的流失。
继续循环 [2025] [01:47:04]
- Sketchpad 和 ECS 之间的 35 年差距代表着架构演变的一个错失的机会。
- 现代 OOP 教程仍然主要教授直接映射领域模型的有问题的编译时层次结构(例如,
IronMan 扩展 IronMan,Vehicle 扩展 Car,Bicycle 扩展 MountainBike)。
- 这使得架构方法往往过于工程化,并且对于许多常见问题效率较低。
危险的隐喻 [01:48:50]
- 核心问题是“最危险的隐喻是那些看起来在一段时间内有效,因为它们可以阻止更强大的洞察力涌现”。
- 直观但僵化的编译时 OOP 层次结构,虽然对于简单的情况看起来可行,但抑制了对更灵活的、面向数据的架构的更广泛的理解和采用,而这些架构实际上是在几十年前发现的。
- 基于现实世界对象的严格封装(受到分子生物学和分布式系统背景的影响)阻止了对更广泛地访问和操作数据的“全知”系统的接受。
演讲者回答问题,提供更多背景信息和个人反思。
- Looking Glass 向 ECS 的演变 [02:03:33]
- Ultima Underworld 实体是“胖结构体覆盖层”(联合覆盖层),用于提高内存效率,从而导致了错误(例如,由于燃料覆盖 HP,空的灯笼消失了)。
- Marc LeBlanc,一位关键的实施者,直接遇到了这些错误。
- 此类覆盖层需要手动检查以防止出现问题。
- 对于 System Shock,他们转向了一种“外链接”结构,其中实体具有一个共享的基础和一个指向扩展数据块的指针。这减少了内存碎片并提供了一定的类型安全,但限制了组合的可能性。
- Flight Unlimited 由于大量使用 C++
new/delete 而面临严重的内存碎片问题,导致强制重新启动游戏(“燃料耗尽”消息)。
- 臭名昭著的“燃料耗尽”消息实际上是内存碎片的一个指示,提示重新启动游戏。
- 这些现实世界的性能和内存问题促使 Looking Glass 团队在 Thief 中创新并朝着实体组件系统发展。
- Tom Leonard 被认为是 ECS 的关键见解:从
entity.getHitPoints() 转移到 hitPointsSystem.getEntityHitPoints(entityID),允许系统有效地迭代特定组件并管理任意大小的属性,同时仍然允许设计人员在运行时定义类型。
- 他们的动机包括避免碎片、支持任意大小的属性、启用运行时类型以及确保尽可能快的性能。