博客 阅读时间 6 分钟

Doom3 证明了"保持简单"行之有效

分享本文
Doom3 proves that “keep it simple” works.

如果您在网上搜索最佳 C++ 源代码的例子,Doom3 的源代码经常被提及,并附有像这样的赞誉。

"我花了一些时间通读 Doom3 的源代码。它可能是我见过的最整洁、最赏心悦目的代码。"

《毁灭战士 3》是由id Software开发、Activision发行的一款电子游戏。这款游戏为 id Software 带来了商业上的成功,销量超过 350 万份。

2011 年 11 月 23 日,id Software 延续了这一传统,公开了其上一代引擎的源代码。这份源代码被许多开发者研究过。例如,下面是 Fabien 的评价(原文):

《Doom 3 BFG》是用 C++ 编写的,这门语言如此庞大,既可以用来生成伟大的代码,也可以造出让你"辣眼睛"的怪物。幸运的是,id Software 选择了一个接近"带类的 C"(C with Classes) 的 C++ 子集,读起来毫不费劲:
  • 不使用异常。
  • 不使用引用(使用指针)。
  • 模板的使用极少。
  • 到处使用 const。
  • 类。
  • 多态。
  • 继承。

如今许多 C++ 专家不再推荐"带类的 C"这种做法。不过,Doom3 是在 2000 到 2004 年间开发的,这或许可以解释为什么没有使用现代 C++ 机制。

让我们使用CppDepend来探索它的源代码,看看它究竟有何特别之处。

Doom3 被组织为多个项目。下面是项目列表,以及关于其类型的一些统计:

doom5

下面是展示它们之间关系的依赖图:

doom2

Doom3 定义了许多全局函数。不过,大部分处理逻辑是在类中实现的。

数据模型使用结构体定义。为了具体了解源代码中结构体的使用情况,上面的度量视图将它们显示为蓝色矩形。

在度量视图中,代码库使用矩形树图(Treemap)表示。矩形树图是一种使用嵌套矩形展示树状结构数据的方法。树结构遵循常见的代码层级:

  • 项目包含命名空间。
  • 命名空间包含类型。
  • 类型包含方法和字段。
doom13

我们可以看到,代码中定义了许多结构体——例如,DoomDLL 中超过 40% 的类型是结构体。它们被系统地用于定义数据模型。许多项目都采用了这种做法,但在多线程应用程序的情况下,这种方法有一个很大的缺点:带有公有字段的结构体不是不可变的。

使用不可变对象有一个重要理由:它能极大简化并发编程。想一想——为什么编写正确的多线程代码是一项艰巨的任务?因为很难同步多个线程对资源(对象或其他操作系统资源)的访问。为什么难以同步这些访问?因为很难保证多个线程对多个对象进行的多次读写访问之间不会发生竞态条件。如果不再有写访问呢?换句话说,如果线程访问的对象的状态不再改变呢?那就不再需要同步了!

让我们搜索至少拥有一个基类的类:

doom6

将近 40% 的结构体和类拥有基类。在 OOP 中,继承的主要好处之一是多态。下面是源代码中定义的虚方法,以蓝色显示:

doom7

超过 30% 的方法是虚方法。其中纯虚方法很少,下面是定义的所有抽象类的列表:

doom9

仅定义了 52 个抽象类,其中 35 个是纯接口,即它们所有的虚方法都是纯虚的。

doom8

让我们搜索使用 RTTI 的方法。

doom17

很少有方法使用 RTTI。

总结一下:这里只使用了基本的 OOP 概念——没有高级设计模式,没有滥用接口和抽象类,RTTI 的使用有限,数据以结构体定义。

到目前为止,没有什么特别之处能把这份代码与许多其他使用"带类的 C"、并受到许多 C++ 开发者批评的代码区分开来。

下面是它的开发者做出的一些有趣选择,有助于解释这份代码的特别之处:

1. 提供一个带有实用服务的公共基类。

许多类都继承自 idClass:

doom10

idClass 提供以下服务:

  1. 实例创建。
  2. 类型信息管理。
  3. 事件管理。
doom11

2. 让字符串操作变得简单

一般来说,字符串是项目中最常用的类型之一。许多操作都在字符串上执行,所以我们需要函数来操作它们。

Doom3 定义了 idStr 类,它几乎包含了操作字符串所需的所有实用方法——无需像其他框架提供的字符串类那样,再去定义自己的方法。

3. 源代码与 GUI 框架(MFC)高度解耦

在许多使用 MFC 的项目中,代码与其类型高度耦合,您可以在代码的任何地方找到 MFC 类型。

在 Doom3 中,代码与 MFC 高度解耦;只有 GUI 类直接依赖它,如下面的 CQLinq 查询所示:

doom3

这一选择对生产力影响很大。只有 GUI 开发者需要操心 MFC 框架;其他开发者不必花时间处理 MFC。

4. 它提供了一个非常出色的工具库(idlib)

在几乎所有项目中,使用最多的类型都是工具类,如下面的查询结果所示:

doom4

我们可以看到,使用最多的是工具类。如果 C++ 开发者没有一个好用的工具框架,他们可能会把大量开发时间花在处理技术层上。

idlib 提供了实用的类,包含处理字符串、容器和内存所需的全部方法,这让开发者的工作更加轻松,让他们能够更专注于游戏逻辑。

5. 实现非常容易理解

Doom3 实现了一个硬编码的编译器,正如 C++ 开发者所知,开发解析器和编译器绝非易事。然而,Doom3 的实现非常容易理解,代码也非常整洁。

下面是编译器所用类的依赖图:

doom16

下面是编译器源代码中的一段代码片段:

doom15

我们已经研究过许多解析器和编译器的源代码。但这是我们第一次遇到源代码如此易于理解的编译器——整个 Doom3 代码库也是如此。这简直神奇。当我们浏览 Doom3 的源代码时,我们会情不自禁地说:哇,太漂亮了!

总结

尽管 Doom3 的设计选择相当基础,但它的设计者做出了许多决策,让开发者能够更专注于游戏逻辑,并简化技术层的工作,从而显著提高生产力。

不过,在使用"带类的 C"时,您必须清楚地知道自己在做什么。您必须像 Doom3 的开发者那样成为专家。初学者不应冒险无视现代 C++ 的建议。

分享本文