代码之美——Doom3源代码赏析
背景介紹:
Doom3是id Software于2004年開(kāi)發(fā)的第一人稱(chēng)射擊游戲,目前以GPL v3協(xié)議開(kāi)源。其采用游戲引擎的是id Tech 4,由id Software創(chuàng)始人、首席程序員John Carmack領(lǐng)導(dǎo)開(kāi)發(fā)。
再做個(gè)簡(jiǎn)單的對(duì)比:作者剛剛完成的Dyad有193k行純C++代碼,Doom3是601k(2004),Quake3是229k(1999),Quake2是136k(1997)。
以下是CSDN譯文,做了部分刪減:
關(guān)于代碼,什么才能被稱(chēng)為“好看”——或者說(shuō)“優(yōu)美”?在和幾個(gè)程序員朋友討論后,我得出了結(jié)論:
- 代碼應(yīng)該局部連貫而且功能單一:一個(gè)函數(shù)解決一個(gè)問(wèn)題。而且應(yīng)該很清晰。
- 局部代碼應(yīng)該能夠解釋,至少暗示整體的系統(tǒng)設(shè)計(jì)。
- 代碼應(yīng)該“自文檔”,盡可能地避免注釋。因?yàn)闊o(wú)論是在讀還是寫(xiě)代碼時(shí),注釋都是一項(xiàng)冗余工作。如果你需要添加注釋才能幫別人理解,那么那段代碼可能需要重寫(xiě)。
這里是idTech4引擎的編碼標(biāo)準(zhǔn),絕對(duì)值得一讀。
統(tǒng)一的語(yǔ)法與詞法分析
我在Doom源代碼中所見(jiàn)最聰明之處在于其詞法分析器和解釋器。所有的資源文件都是語(yǔ)法統(tǒng)一的ASCII文件:腳本、動(dòng)畫(huà)文件、配置文件,等等,所有東西都遵循相同的規(guī)則。因此一大塊代碼就可以閱讀并處理所有的文件。這個(gè)解析器非常健壯,支持一個(gè)C++的主要子集。通過(guò)一個(gè)統(tǒng)一的詞法分析、解釋器,引擎所有組件都不必?fù)?dān)心序列化數(shù)據(jù)的問(wèn)題,因?yàn)橐呀?jīng)準(zhǔn)備好了相應(yīng)的代碼,這保證其它地方的代碼更加整潔。
參數(shù)嚴(yán)格和const化
Doom的代碼非常嚴(yán)格,盡管在我看來(lái),const方面還不夠嚴(yán)格。可能很多程序員都沒(méi)注意到const的多種種作用。我的看法是“任何東西只要可以都應(yīng)該設(shè)定為const”,我希望C++中所有的變量都默認(rèn)是const。Doom參數(shù)幾乎完全遵守“no in-out”規(guī)則,這意味著所有函數(shù)都參數(shù)都不能既是輸入?yún)?shù)也是輸出參數(shù)。這樣,在當(dāng)你向函數(shù)傳入?yún)?shù)時(shí),更容易理解他身上發(fā)生了什么。比如:
從這幾個(gè)const中我就看出來(lái):
如果Split沒(méi)有被定義為 Split(...) const,這段代碼將無(wú)法編譯。無(wú)論被誰(shuí)所調(diào)用,f()都不會(huì)去修改外表,即使f()將surface傳遞給另一個(gè)函數(shù),或者調(diào)用一些Surface::method()。const能夠透露出很多關(guān)于函數(shù)甚至整個(gè)系統(tǒng)設(shè)計(jì)的信息,僅僅通過(guò)閱讀這里的函數(shù)聲明,我就明白了surface可以被plane動(dòng)態(tài)地split()。這個(gè)函數(shù)不會(huì)修改surface,而是返回新的surface、front、back數(shù)據(jù),可選地返回frontOnPlaneEdges和backOnPlaneEdges。
const規(guī)則,以及無(wú)input/output參數(shù)對(duì)我來(lái)說(shuō)也許是最重要的原則,也是區(qū)分好的代碼跟優(yōu)美代碼的關(guān)鍵,它能簡(jiǎn)化整個(gè)系統(tǒng)的理解、編輯和重構(gòu)。
最少注釋原則
這是一個(gè)“格式問(wèn)題”,但Doom基本不會(huì)過(guò)度注釋,這很漂亮!我經(jīng)常會(huì)看到這樣的代碼:
這太讓人惱火了,我通過(guò)名字就可以知道它的作用!如果這個(gè)函數(shù)名不能體現(xiàn)出其功能,毫無(wú)疑問(wèn)應(yīng)該重新命名;如果名字描述得過(guò)多,那么去簡(jiǎn)化它。除非實(shí)在不能通過(guò)重構(gòu)、重命名內(nèi)描述它唯一的功能,那么注釋才是合理的。我本以為程序員在學(xué)校已經(jīng)學(xué)會(huì)注釋的重要性,但實(shí)際上沒(méi)有。注釋很有必要,但它經(jīng)常沒(méi)必要。Doom在這方面做得非常合格,以idSurface::Split()為例,我們看看它是如何注釋的:
第一行有點(diǎn)多余,從函數(shù)定義中我們已經(jīng)能明白所有的信息了;但第二、第三行很有價(jià)值,雖然我們已經(jīng)可以推斷出第二行的屬性,但注釋消除了歧義。
Doom的代碼加上合理的注釋,閱讀非常方便。也許很多人把它歸為格式問(wèn)題,但我認(rèn)為,格式也有正確與否。如果有人修改了函數(shù),并且刪除了最后的const;這樣surface可以直接被函數(shù)修改,于是注釋與代碼不再同步;這樣注釋反過(guò)來(lái)會(huì)導(dǎo)致誤解,導(dǎo)致代碼更加難以閱讀。
縱向空間
Doom從不浪費(fèi)縱向空間。我們以t_stencilShadow::R_ChopWinding()為例:
整個(gè)算法只占了我1/4個(gè)屏幕,剩下的3/4可以用來(lái)觀看其周?chē)南嚓P(guān)代碼塊。實(shí)際上,我經(jīng)常看到這樣的代碼:
這可以歸為格式問(wèn)題,我有10年編程經(jīng)歷都是像后者那樣,大概在6年前才強(qiáng)行轉(zhuǎn)換為緊湊風(fēng)格的。
兩者的代碼行數(shù)比是11:18,同樣的代碼后者行數(shù)幾乎是前者的兩倍,所以可能導(dǎo)致看不到后面的代碼塊,就像這樣:
如果沒(méi)有前面的for循環(huán),僅僅上面這段代碼毫無(wú)意義,如果id沒(méi)有縱向緊湊的風(fēng)格,代碼可能更難閱讀、更難寫(xiě)、更難維護(hù)、也就遠(yuǎn)離了優(yōu)美代碼的定義。
另外一個(gè)我認(rèn)同的格式是:id永遠(yuǎn)盡可能地使用{},沒(méi)有括號(hào)會(huì)很糟糕,比如我看過(guò)這段代碼:
這非常丑陋,甚至比把{}放在同一行還要糟糕,我在id的代碼中從未發(fā)現(xiàn)省略{}的情況。省略{}會(huì)導(dǎo)致while代碼塊解析的時(shí)間大幅增加,而且編輯起來(lái)也非常痛苦:如果我希望往else if(c > d)分支中再插入一個(gè)if分支怎么辦?
最少模板
id“犯了不少C++的禁忌”,他們重寫(xiě)了所有需要的STD函數(shù)。我個(gè)人對(duì)STD愛(ài)恨交織。在Dyad,我調(diào)試構(gòu)建時(shí)常使用它來(lái)管理動(dòng)態(tài)資源;在發(fā)布時(shí)又會(huì)處理所有的資源,避免使用任何STL函數(shù),以求盡快地加載。STL很不錯(cuò),因?yàn)樗峁┝丝焖俚耐ㄓ脭?shù)據(jù)結(jié)構(gòu);它又很糟糕,因?yàn)槭褂盟?jīng)常導(dǎo)致代碼丑陋不堪,甚至容易出錯(cuò)。例如std::vector<T>類(lèi),如果我想迭代每一個(gè)元素:
在C++11中要簡(jiǎn)單些:
但我個(gè)人并不喜歡自動(dòng)化,雖然它簡(jiǎn)化了代碼編寫(xiě),卻導(dǎo)致代碼更難閱讀,最起碼我現(xiàn)在是這么認(rèn)為的。
STD有的函數(shù)、算法甚至非常荒謬,比如要從std::vector中刪除一個(gè)值:
你必須每次都能拼寫(xiě)正確!id除去了其中所以含糊不清的部分:他們使用自己的通用容器、字符串類(lèi)等等。他們編寫(xiě)的類(lèi)比起STL要更加專(zhuān)一,易于理解。id還盡可能地避免使用模板,而且使用自己定制的內(nèi)存分配器。STD代碼里則充斥著無(wú)意義的垃圾模板,而且不易于閱讀。
C++代碼很難寫(xiě)好,所以你需要不斷地努力,不相信的話(huà)可以去看看Microsoft和GCC的STD代碼,這是我見(jiàn)過(guò)的最難看的代碼!
id通過(guò)不濫用泛型就簡(jiǎn)單地解決了這個(gè)問(wèn)題。他們編寫(xiě)了HashTable<V>和HashIndex類(lèi),HashTable強(qiáng)制key類(lèi)型是const char *,而HashIndex是int->int對(duì)。這看起來(lái)像是很糟糕的C++實(shí)例。他們“本應(yīng)該”只有一個(gè)HashTable類(lèi),然后為編寫(xiě)局部特殊化:KeyType = const char *,然后專(zhuān)門(mén) <int, int>。
當(dāng)然,id的做法完全正確,也保證了代碼的優(yōu)美。
對(duì)比更鮮明的是,Hash生成“C++優(yōu)秀實(shí)踐”和id做法的比較:
為特定類(lèi)型專(zhuān)門(mén)化:
這樣你可以把ComputeHashForType當(dāng)作HashComputer傳給HashTable:
這和我的做法很相近,看起來(lái)很聰明,但實(shí)際上很難看!因?yàn)?#xff0c;如果可選的模板參數(shù)很多怎么辦?
這種情況下函數(shù)定義要更糟:
如果沒(méi)有代碼高亮,我甚至不能區(qū)分出方法名!
我也曾看到其它引擎試圖通過(guò)卸載模板參數(shù)規(guī)范到無(wú)數(shù)的typedef,這更糟糕!也許這利于理解,但卻導(dǎo)致了本地代碼和整個(gè)系統(tǒng)邏輯的斷層,所以缺乏美感。例如:
以及:
你這樣使用兩者:
你會(huì)產(chǎn)生疑惑:StringHashTable內(nèi)存分配器——StringAllocator會(huì)涉及全局內(nèi)存嗎?這里導(dǎo)致了混淆,于是你又需要返回之前的代碼檢查(循環(huán))……
Doom的做法和常規(guī)C++邏輯完全相反:它盡可能地避免泛型,除非有特別的意義。Doom的HashTable需要生成hash值時(shí)怎么辦?它只需要調(diào)用idStr::GetHash()。
C語(yǔ)言的余韻
雖然我不清楚id團(tuán)隊(duì)其他人的出身如何,但John Carmack基本上可以說(shuō)是開(kāi)發(fā)C應(yīng)用起家的,id在Quake III之前開(kāi)發(fā)游戲用的都是C語(yǔ)言。我見(jiàn)過(guò)很多沒(méi)有C開(kāi)發(fā)功底的C++程序員,編寫(xiě)代碼都有非常重的C++特色,上面過(guò)度使用模板的情況只是其中一例,其它還有:
- 過(guò)度使用set/get方法
- 使用字符串流
- 過(guò)度使用操作符重載
id在以上方面都做得非常完美。
通常很多人會(huì)這樣創(chuàng)建一個(gè)類(lèi):
這樣不僅浪費(fèi)行數(shù),還需要花費(fèi)更多的時(shí)間編來(lái)寫(xiě)和閱讀代碼。相比之下:
如果你經(jīng)常為var自增某個(gè)數(shù)字n呢?
相比于:
上面的例子明顯容易閱讀和編寫(xiě)。
id從不使用字符流,字符流通常包含糟糕的操作符重載:<<
例如:
雖然它有很多好處,但是很難看,而且語(yǔ)法也讓人討厭。
id選擇printf()來(lái)代替,這樣也易于閱讀理解。我同意這樣的決定。
另一方面,Doom還盡量避免操作符重載。雖然操作符重載是非常優(yōu)秀C++特性,但沒(méi)有操作符重載也就沒(méi)有歧義,更便于編寫(xiě)和閱讀。
橫向空間
這是我從Doom的代碼中最大的收獲,原來(lái)我是這樣編寫(xiě)代碼的:
根據(jù)Doom3的編碼標(biāo)準(zhǔn),始終使用相對(duì)于4個(gè)空格的tab,水平對(duì)齊其中所有類(lèi)的定義:
他們很少在類(lèi)的定義中嵌入內(nèi)聯(lián)函數(shù),我看到的唯一一次是代碼和函數(shù)聲明寫(xiě)在了同一行,這種做法有點(diǎn)不符合規(guī)范。這種類(lèi)定義的組織方式非常容易解析,不過(guò)需要更多的時(shí)間來(lái)編寫(xiě)。
我討厭多余的代碼編寫(xiě),但這種情況下,我只需要這次稍微多做一點(diǎn)工作,其他程序員在之后接手時(shí)就可以省下很多功夫。相信這里的Doom3編程規(guī)范能夠幫助你理解其代碼之美。(有網(wǎng)友稱(chēng)Google的C++編程規(guī)范與其也有很多相似之處。)
方法名
我認(rèn)為Doom在方法名方面缺乏規(guī)范,我個(gè)人會(huì)盡可能地以動(dòng)詞開(kāi)頭命名方法:
比這樣要好得多:
以下是John Carmack本人的回復(fù):
從某些角度來(lái)看,我認(rèn)為Quake3的代碼更加整潔,算是我C語(yǔ)言代碼的風(fēng)格的一次進(jìn)化,而非C++風(fēng)格的第一次迭代。當(dāng)然也可能因?yàn)榭偞a行數(shù)更少,或者是因?yàn)槲乙呀?jīng)10年沒(méi)看過(guò)它的代碼引起的錯(cuò)覺(jué)。我認(rèn)為“好的C++”在可讀性方面比“好的C語(yǔ)言”更好,其它方面大體相同。
我開(kāi)始掌握C++是在Doom3開(kāi)發(fā)的時(shí)候——在這之前,我有豐富的C語(yǔ)言編程經(jīng)驗(yàn),因?yàn)镹eXT Objective-C編程的原因也有OOP(面向?qū)ο缶幊?#xff09;背景,因此在使用C++的時(shí)候并沒(méi)有對(duì)其使用和習(xí)慣進(jìn)行適當(dāng)針對(duì)性的研究。現(xiàn)在回想起來(lái),真希望提前看過(guò)Effective C++這樣的教程。團(tuán)隊(duì)里其他程序員雖然之前有C++編程經(jīng)驗(yàn),但基本上也是按照我選擇和設(shè)置的風(fēng)格在編程。
很多年來(lái),我一直懷疑模板,一直在克制地使用它,不過(guò)最終確定自己更喜歡強(qiáng)類(lèi)型,而非充滿(mǎn)奇怪的代碼的頭文件。關(guān)于STL的爭(zhēng)論在id內(nèi)部一直沒(méi)有停息,顯得很有生氣。回想Doom3開(kāi)始開(kāi)發(fā)的時(shí)候,使用STL基本上算不得好主意,直到現(xiàn)在,即使是在游戲中我們也仍然在爭(zhēng)論這件事。
關(guān)于const,我直到現(xiàn)在基本上還是一個(gè)nazi,我會(huì)斥責(zé)任每一個(gè)不盡可能常量化變量和參數(shù)的程序員。
我現(xiàn)在的風(fēng)格主要是在向函數(shù)式編程靠近,這樣可以舍去很多舊習(xí),逐漸遠(yuǎn)離一些OOP的方向。
關(guān)于C++函數(shù)式編程John Carmack寫(xiě)過(guò)一篇《Functional Programming in C++》值得一讀!《程序員》對(duì)這篇文章做過(guò)編譯。
原文鏈接:KOTAKU
總結(jié)
以上是生活随笔為你收集整理的代码之美——Doom3源代码赏析的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: (转)LCD:LCD常用接口原理篇
- 下一篇: 开发中的“软”与“硬”:高画质移动游戏开