`
xpp02
  • 浏览: 1012854 次
社区版块
存档分类
最新评论

代码之美——Doom3源代码赏析

 
阅读更多
摘要:Dyad作者、资深C++工程师Shawn McGrathz在空闲时翻看了Doom3的源代码,发出了这样的惊叹:“这是我见过的最整洁、最优美的代码!”“Doom 3的源代码让我对那些优秀的程序员刮目相看。”因此有了本文。

背景介绍:

Doom3是id Software于2004年开发的第一人称射击游戏,目前以GPL v3协议开源。其采用游戏引擎的是id Tech 4,由id Software创始人、首席程序员John Carmack领导开发。

再做个简单的对比:作者刚刚完成的Dyad有193k行纯C++代码,Doom3是601k(2004),Quake3是229k(1999),Quake2是136k(1997)。

以下是CSDN译文,做了部分删减:

关于代码,什么才能被称为“好看”——或者说“优美”?在和几个程序员朋友讨论后,我得出了结论:

  • 代码应该局部连贯而且功能单一:一个函数解决一个问题。而且应该很清晰。
  • 局部代码应该能够解释,至少暗示整体的系统设计。
  • 代码应该“自文档”,尽可能地避免注释。因为无论是在读还是写代码时,注释都是一项冗余工作。如果你需要添加注释才能帮别人理解,那么那段代码可能需要重写。

这里是idTech4引擎的编码标准,绝对值得一读。

统一的语法与词法分析


我在Doom源代码中所见最聪明之处在于其词法分析器和解释器。所有的资源文件都是语法统一的ASCII文件:脚本、动画文件、配置文件,等等,所有东西都遵循相同的规则。因此一大块代码就可以阅读并处理所有的文件。这个解析器非常健壮,支持一个C++的主要子集。通过一个统一的词法分析、解释器,引擎所有组件都不必担心序列化数据的问题,因为已经准备好了相应的代码,这保证其它地方的代码更加整洁。

参数严格和const化


Doom的代码非常严格,尽管在我看来,const方面还不够严格。可能很多程序员都没注意到const的多种种作用。我的看法是“任何东西只要可以都应该设定为const”,我希望C++中所有的变量都默认是const。Doom参数几乎完全遵守“no in-out”规则,这意味着所有函数都参数都不能既是输入参数也是输出参数。这样,在当你向函数传入参数时,更容易理解他身上发生了什么。比如:

从这几个const中我就看出来:

  1. 这个函数不会修改作为参数传入的idPlane。我无需坚持idPlane是否被修改就可以安全地使用它。
  2. 函数中的epsilon也不会被修改。
  3. front, back, frontOnPlaneEdges and backOnPlaceEdges是输出变量,是值的写入目标。
  4. 参数列表后面的const是我最赞赏的地方。它表明idSurface::Split()不会去修改surface。这是我最喜欢的C++独有功能,因为我可以这样使用:
    1. voidf(constidSurface&s){
    2. s.Split(....);
    3. }

如果Split没有被定义为 Split(...) const,这段代码将无法编译。无论被谁所调用,f()都不会去修改外表,即使f()将surface传递给另一个函数,或者调用一些Surface::method()。const能够透露出很多关于函数甚至整个系统设计的信息,仅仅通过阅读这里的函数声明,我就明白了surface可以被plane动态地split()。这个函数不会修改surface,而是返回新的surface、front、back数据,可选地返回frontOnPlaneEdges和backOnPlaneEdges。

const规则,以及无input/output参数对我来说也许是最重要的原则,也是区分好的代码跟优美代码的关键,它能简化整个系统的理解、编辑和重构。

最少注释原则


这是一个“格式问题”,但Doom基本不会过度注释,这很漂亮!我经常会看到这样的代码:

这太让人恼火了,我通过名字就可以知道它的作用!如果这个函数名不能体现出其功能,毫无疑问应该重新命名;如果名字描述得过多,那么去简化它。除非实在不能通过重构、重命名内描述它唯一的功能,那么注释才是合理的。我本以为程序员在学校已经学会注释的重要性,但实际上没有。注释很有必要,但它经常没必要。Doom在这方面做得非常合格,以idSurface::Split()为例,我们看看它是如何注释的:

  1. //splitsthesurfaceintoafrontandbacksurface,thesurfaceitselfstaysunchanged
  2. //frontOnPlaneEdgesandbackOnPlaneEdgesoptionallystoretheindexestotheedgesthatlayonthesplitplane
  3. //returnsaSIDE_?

第一行有点多余,从函数定义中我们已经能明白所有的信息了;但第二、第三行很有价值,虽然我们已经可以推断出第二行的属性,但注释消除了歧义。

Doom的代码加上合理的注释,阅读非常方便。也许很多人把它归为格式问题,但我认为,格式也有正确与否。如果有人修改了函数,并且删除了最后的const;这样surface可以直接被函数修改,于是注释与代码不再同步;这样注释反过来会导致误解,导致代码更加难以阅读。

纵向空间


Doom从不浪费纵向空间。我们以t_stencilShadow::R_ChopWinding()为例:

整个算法只占了我1/4个屏幕,剩下的3/4可以用来观看其周围的相关代码块。实际上,我经常看到这样的代码:

这可以归为格式问题,我有10年编程经历都是像后者那样,大概在6年前才强行转换为紧凑风格的。

两者的代码行数比是11:18,同样的代码后者行数几乎是前者的两倍,所以可能导致看不到后面的代码块,就像这样:

如果没有前面的for循环,仅仅上面这段代码毫无意义,如果id没有纵向紧凑的风格,代码可能更难阅读、更难写、更难维护、也就远离了优美代码的定义。

另外一个我认同的格式是:id永远尽可能地使用{},没有括号会很糟糕,比如我看过这段代码:

这非常丑陋,甚至比把{}放在同一行还要糟糕,我在id的代码中从未发现省略{}的情况。省略{}会导致while代码块解析的时间大幅增加,而且编辑起来也非常痛苦:如果我希望往else if(c > d)分支中再插入一个if分支怎么办?


最少模板


id“犯了不少C++的禁忌”,他们重写了所有需要的STD函数。我个人对STD爱恨交织。在Dyad,我调试构建时常使用它来管理动态资源;在发布时又会处理所有的资源,避免使用任何STL函数,以求尽快地加载。STL很不错,因为它提供了快速的通用数据结构;它又很糟糕,因为使用它经常导致代码丑陋不堪,甚至容易出错。例如std::vector<T>类,如果我想迭代每一个元素:

在C++11中要简单些:

但我个人并不喜欢自动化,虽然它简化了代码编写,却导致代码更难阅读,最起码我现在是这么认为的。

STD有的函数、算法甚至非常荒谬,比如要从std::vector中删除一个值:

你必须每次都能拼写正确!id除去了其中所以含糊不清的部分:他们使用自己的通用容器、字符串类等等。他们编写的类比起STL要更加专一,易于理解。id还尽可能地避免使用模板,而且使用自己定制的内存分配器。STD代码里则充斥着无意义的垃圾模板,而且不易于阅读。

C++代码很难写好,所以你需要不断地努力,不相信的话可以去看看Microsoft和GCC的STD代码,这是我见过的最难看的代码!

id通过不滥用泛型就简单地解决了这个问题。他们编写了HashTable<V>和HashIndex类,HashTable强制key类型是const char *,而HashIndex是int->int对。这看起来像是很糟糕的C++实例。他们“本应该”只有一个HashTable类,然后为编写局部特殊化:KeyType = const char *,然后专门 <int, int>。

当然,id的做法完全正确,也保证了代码的优美。

对比更鲜明的是,Hash生成“C++优秀实践”和id做法的比较:

为特定类型专门化:

这样你可以把ComputeHashForType当作HashComputer传给HashTable:

这和我的做法很相近,看起来很聪明,但实际上很难看!因为,如果可选的模板参数很多怎么办?

这种情况下函数定义要更糟:

如果没有代码高亮,我甚至不能区分出方法名!

我也曾看到其它引擎试图通过卸载模板参数规范到无数的typedef,这更糟糕!也许这利于理解,但却导致了本地代码和整个系统逻辑的断层,所以缺乏美感。例如:

以及:

你这样使用两者:

你会产生疑惑:StringHashTable内存分配器——StringAllocator会涉及全局内存吗?这里导致了混淆,于是你又需要返回之前的代码检查(循环)……

Doom的做法和常规C++逻辑完全相反:它尽可能地避免泛型,除非有特别的意义。Doom的HashTable需要生成hash值时怎么办?它只需要调用idStr::GetHash()。

C语言的余韵


虽然我不清楚id团队其他人的出身如何,但John Carmack基本上可以说是开发C应用起家的,id在Quake III之前开发游戏用的都是C语言。我见过很多没有C开发功底的C++程序员,编写代码都有非常重的C++特色,上面过度使用模板的情况只是其中一例,其它还有:

  • 过度使用set/get方法
  • 使用字符串流
  • 过度使用操作符重载

id在以上方面都做得非常完美。

通常很多人会这样创建一个类:

这样不仅浪费行数,还需要花费更多的时间编来写和阅读代码。相比之下:

如果你经常为var自增某个数字n呢?

相比于:

上面的例子明显容易阅读和编写。

id从不使用字符流,字符流通常包含糟糕的操作符重载:<<

例如:

虽然它有很多好处,但是很难看,而且语法也让人讨厌。

id选择printf()来代替,这样也易于阅读理解。我同意这样的决定。

另一方面,Doom还尽量避免操作符重载。虽然操作符重载是非常优秀C++特性,但没有操作符重载也就没有歧义,更便于编写和阅读。

横向空间


这是我从Doom的代码中最大的收获,原来我是这样编写代码的:

根据Doom3的编码标准,始终使用相对于4个空格的tab,水平对齐其中所有类的定义:

他们很少在类的定义中嵌入内联函数,我看到的唯一一次是代码和函数声明写在了同一行,这种做法有点不符合规范。这种类定义的组织方式非常容易解析,不过需要更多的时间来编写。

我讨厌多余的代码编写,但这种情况下,我只需要这次稍微多做一点工作,其他程序员在之后接手时就可以省下很多功夫。相信这里的Doom3编程规范能够帮助你理解其代码之美。(有网友称Google的C++编程规范与其也有很多相似之处。)

方法名


我认为Doom在方法名方面缺乏规范,我个人会尽可能地以动词开头命名方法:

比这样要好得多:

以下是John Carmack本人的回复:


从某些角度来看,我认为Quake3的代码更加整洁,算是我C语言代码的风格的一次进化,而非C++风格的第一次迭代。当然也可能因为总代码行数更少,或者是因为我已经10年没看过它的代码引起的错觉。我认为“好的C++”在可读性方面比“好的C语言”更好,其它方面大体相同。

我开始掌握C++是在Doom3开发的时候——在这之前,我有丰富的C语言编程经验,因为NeXT Objective-C编程的原因也有OOP(面向对象编程)背景,因此在使用C++的时候并没有对其使用和习惯进行适当针对性的研究。现在回想起来,真希望提前看过Effective C++这样的教程。团队里其他程序员虽然之前有C++编程经验,但基本上也是按照我选择和设置的风格在编程。

很多年来,我一直怀疑模板,一直在克制地使用它,不过最终确定自己更喜欢强类型,而非充满奇怪的代码的头文件。关于STL的争论在id内部一直没有停息,显得很有生气。回想Doom3开始开发的时候,使用STL基本上算不得好主意,直到现在,即使是在游戏中我们也仍然在争论这件事。

关于const,我直到现在基本上还是一个nazi,我会斥责任每一个不尽可能常量化变量和参数的程序员。

我现在的风格主要是在向函数式编程靠近,这样可以舍去很多旧习,逐渐远离一些OOP的方向。

关于C++函数式编程John Carmack写过一篇《Functional Programming in C++》值得一读!《程序员》对这篇文章做过编译

原文链接:KOTAKU


分享到:
评论

相关推荐

    DOOM源代码 看卡马克当年如何实现3D视觉

    DOOM源代码DOOM,源代码DOOM,源代码DOOM,源代码DOOM,源代码DOOM,源代码DOOM,源代码DOOM,源代码

    Doom3 源代码 毁灭战士

    Doom3 源代码 毁灭战士 《毁灭战士3》(Doom 3)由id Software开发,计算机版于2004年8月3日由Activision发行,是一款杂恐怖与科幻于一身的第一人称射击游戏。游戏故事除了英文名称外,并未有完全跟随《毁灭战士》...

    doom3源代码

    TTimo-doom3.gpl-7db8be7.zip The Doom 3 source-code -- based upon the id Tech 4 engine -- is now available as open-source software to the gaming community under the GNU GPL license. Pushed onto ...

    DOOM3 源代码

    DOOM3的游戏完整源代码,经过编译完全可以执行。 ID TECH 4引擎 卡马克所著!

    Doom3的源代码

    看源代码学习很不错的!doom3源代码你值得拥有!

    DOOM3 源代码(id tech 4 引擎)

    doom3源代码,包含id tech4引擎代码

    doom3-C++源代码

    doom3-完整C++源代码,一起学习,看到好多人都是3分或5分的分享,特提供分享1分资源分数,仅供快乐学习。

    Doom 3 SDK 源代码

    DOOM3 的源代码, 喜欢研究游戏的朋友可以参考参考. 里面用到的很多技巧到目前为止都是非常厉害的. 如果你想写一个自己的游戏用的快速常用算法数学库, 那里面的math.h 就是不可少的参考文件哦. 最经典的就是里面用到...

    doom3(含id tech 4引擎)源代码

    doom3源代码,包含id tech4引擎代码

    DOOM3部分源代码

    大神卡马克的 并不是完整版本 有兴趣的可以看看 可以学习下一些技巧

    DOOM-3源代码

    游戏DOOM-3源代码, C、C++学习者强烈建议读一下。

    Doom3:Doom3基于源代码的游戏。 只是学习-Source code learning

    dhewm 3是Doom 3 GPL源代码的修改。 dhewm 3的目标是借助SDL将DOOM 3带到所有合适的平台上。 原始DOOM 3中存在的错误将在不更改原始游戏玩法的情况下得到修复(如果确定)。 该项目托管在: : 在以下查询常见...

    DOOM游戏 Windows源代码(463kb)

    DOOM游戏 Windows源代码(463kb)

    doom3.rar_doom_doom3_发动机

    请注意,Doom 3和“毁灭战士3:邪恶复苏是从蒸汽存储在 http://store.steampowered.com/app/9050/ http://store.steampowered.com/app/9070/ 其他的平台,更新的源代码,安全问题: ------------------------------...

    Eclipse代码注释模板——code templates

    NULL 博文链接:https://java-doom.iteye.com/blog/1974412

    DOOM启示录

    本书忠实详尽地讲述了两个玩家是如何走上游戏之路,如何制作出迄今为止影响力最大的游戏作品——DOOM和Quake,以及他们为何在最辉煌的时候分道扬镳。 我的感觉: 本文被称为程序员圣经,虽有一定的夸大成分,但作为...

    DOOM启世录

    本书是第一部游戏领域的传记文学,它忠实详尽地讲述了两位玩家是如何走上游戏之路,如何制作出迄今为止影响力最大的游戏作品——Doom和Quake,以及他们为何在最辉煌的时候分道扬镳。与所有传记文学一样,不同的读者...

    doom:在此处添加了DOOM的源代码以进行实验和学习-Source code learning

    doom:在此处添加了DOOM的源代码以进行实验和学习

    doom3.gpl-master.zip

    doom3.gpl-master.zip

Global site tag (gtag.js) - Google Analytics