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