代码之美——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. void f(const idSurface &s) { 
    2. s.Split(....); 

如果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. // splits the surface into a front and back surface, the surface itself stays unchanged 
  2. // frontOnPlaneEdges and backOnPlaneEdges optionally store the indexes to the edges that lay on the split plane 
  3. // returns a SIDE_? 

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

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


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/449695.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

文件基本处理

1 打开文件&#xff0c;将文件句柄赋值给一个变量 2 拿句柄对文件进行操作 3 关闭文件 将一个文件第一行写道另外一个文件 f open("test","r",encoding"utf-8") # open找的是系统的编码 x f.readlines() f.close() f1 open("test1"…

代码审查:程序员内炼之道

摘要&#xff1a;“关注并弄清楚桥梁修建细节&#xff0c;否则你建起来的桥梁有可能坍塌。”代码审查更重要的是一种技术分享或者代码共享。程序员如何提升自我修炼之道&#xff0c;欢迎来支招。 代码审查更重要的是一种技术分享或者代码共享。在审查过程中&#xff0c;通过被…

排序代码(python,c++) 及 基本算法复杂度

0.导语 本节为手撕代码系列之第一弹&#xff0c;主要来手撕排序算法&#xff0c;主要包括以下几大排序算法&#xff1a; 直接插入排序 冒泡排序 选择排序 快速排序 希尔排序 堆排序 归并排序 1.直接插入排序 【算法思想】 每一步将一个待排序的记录&#xff0c;插入到前面…

TCP/IP四层模型与OSI参考模型

前些天发现了一个巨牛的人工智能学习网站&#xff0c;通俗易懂&#xff0c;风趣幽默&#xff0c;忍不住分享一下给大家。点击跳转到教程。 TCP/IP四层模型&#xff1a; 1.链路层&#xff08;数据链路层/网络接口层&#xff09;&#xff1a;包括操作系统中的设备驱动程序、计算…

还驾驭不了4核? 别人已模拟出百万核心上的并行

摘要&#xff1a;不管是台式机还是笔记本&#xff0c;四核双核都已经不是新鲜的事了。计算机领域的你可能已经认识到了给电脑选配4核的处理器完全是一种浪费&#xff0c;因为大多数的程序都不支持多核心的并行处理。然而斯坦福的计算机科学家最近公布&#xff0c;他们已经模拟出…

Django内置权限扩展案例

当Django的内置权限无法满足需求的时候就自己扩展吧~ 背景介绍 overmind项目使用了Django内置的权限系统&#xff0c;Django内置权限系统基于model层做控制&#xff0c;新的model创建后会默认新建三个权限&#xff0c;分别为&#xff1a;add、change、delete&#xff0c;如果给…

Flutter Mac iOS 环境配置

官方文档&#xff1a;flutter.io/docs/get-st… 1.需要的命令行工具 bash curl git 2.x mkdir rm unzip which 2.SDK下载地址 flutter_macos_v1.0.0-stable.zip storage.googleapis.com/flutter_inf… 3.解压Flutter SDK cd ~/Flutter/SDK $ unzip ~/Downloads/flutter_macos_v…

IntelliJ IDEA中新建JAVA WEB项目、maven项目

前些天发现了一个巨牛的人工智能学习网站&#xff0c;通俗易懂&#xff0c;风趣幽默&#xff0c;忍不住分享一下给大家。点击跳转到教程。 在IntelliJ IDEA 中新建一个Web应用项目。 1、 在主界面顶部菜单栏依次“File”-"New"-"Project..." 2、在对话框中…

S/4HANA业务角色概览之订单到收款篇

2019独角兽企业重金招聘Python工程师标准>>> 大家好我叫Sean Zhang&#xff0c;中文名张正永。目前在S/4HANA产品研发部门任职产品经理&#xff0c;而这一阶段要从2017年算起&#xff0c;而在那之前接触更多还是技术类的&#xff0c;比如做过iOS、HANA、ABAP、UI5等…

ItelliJ IDEA开发工具使用—创建一个web项目

转自&#xff1a;https://blog.csdn.net/wangyang1354/article/details/50452806概念需要明确一下IDEA中的项目&#xff08;project&#xff09;与eclipse中的项目&#xff08;project&#xff09;是不同的概念&#xff0c;IDEA的project 相当于之前eclipse的workspace,IDEA的M…

极客无极限 一行HTML5代码引发的创意大爆炸

摘要&#xff1a;一行HTML5代码能做什么&#xff1f;国外开发者Jose Jesus Perez Aguinaga写了一行HTML5代码的文本编辑器。这件事在分享到Code Wall、Hacker News之后&#xff0c;引起了众多开发者的注意&#xff0c;纷纷发表了自己的创意。 这是最初的HTML5代码&#xff0c;它…

DOCKER windows 7 详细安装教程

前些天发现了一个巨牛的人工智能学习网站&#xff0c;通俗易懂&#xff0c;风趣幽默&#xff0c;忍不住分享一下给大家。点击跳转到教程。 DOCKER windows安装 DOCKER windows安装 1.下载程序包2. 设置环境变量3. 启动DOCKERT4. 分析start.sh5. 利用SSH工具管理6. 下载镜像 6.1…

芝麻信用免押金成趋势 报告称租赁经济有望突破10万亿元

中新网1月16日电 “很多物品都是租来的&#xff0c;但生活不是。”如今&#xff0c;越来越多的年轻人选择了“租”生活&#xff0c;从房子到车子&#xff0c;从服饰到电脑&#xff0c;甚至玩具、婴儿车&#xff0c;全都可以租用&#xff0c;租赁已成为当下年轻人追求品质生活的…

开发者成功学:扔掉你那些很sexy的想法

摘要&#xff1a;在开发者的世界里&#xff0c;开发iPhone应用并不像表面那么光鲜&#xff0c;收支不成正比是常有之事&#xff0c;劳心劳力开发的应用无人问津更是屡见不鲜。走出了开发的一小步却难以迈出销售推广上的一大步&#xff0c;究竟如何才能将应用卖出去并获取利润&a…

html-body相关标签

一 字体标签 字体标签包含&#xff1a;h1~h6、<font>、<u>、<b>、<strong><em>、<sup>、<sub> 标题 标题使用<h1>至<h6>标签进行定义。<h1>定义最大的标题&#xff0c;<h6>定义最小的标题。具有align属性&a…

CentOS上安装Docker (图解)

更简单的办法&#xff1a;三分钟装好 Docker ( 图解&#xff09; 前些天发现了一个巨牛的人工智能学习网站&#xff0c;通俗易懂&#xff0c;风趣幽默&#xff0c;忍不住分享一下给大家。点击跳转到教程。 // 用上面那个办法吧&#xff0c;简单多了&#xff0c;下面这个方法看看…

Uber提出有创造力的POET:自行开发更困难环境和解决方案

近日&#xff0c;Uber 发文介绍了一种开放式方法 POET&#xff08;Paired Open-Ended Trailblazer&#xff09;&#xff0c;可自行开发难度递增的环境及其解决方案&#xff0c;还可以实现不同环境中的智能体迁移&#xff0c;促进进化。Uber AI 实验室注重开放性&#xff08;ope…

C语言编译过程总结详解

源文&#xff1a;http://bbs.dzsc.com/space/viewspacepost.aspx?postid76976 C语言的编译链接过程要把我们编写的一个c程序&#xff08;源代码&#xff09;转换成可以在硬件上运行的程序&#xff08;可执行代码&#xff09;&#xff0c;需要进行编译和链接。编译就是把文本形…

promise之我见

在我们平时的方法中有很多方法是promise封装的&#xff0c; 有些函数后边跟的then和catch 就是promise的方法&#xff0c;先看一下pormise的特点 &#xff08;1&#xff09;对象的状态不受外界影响。Promise对象代表一个异步操作&#xff0c;有三种状态&#xff1a;pending&…

CPP虚析构函数

#include<iostream> using namespace std;class base {public:base(){};virtual ~base(){}; };// 在类声明中声明纯虚析构函数 //base::~base() {}class father: public base {public:~father(){cout << "father" << endl;} };int main() {base* a…