背景介紹:
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中我就看出來:
- 這個函數不會修改作為參數傳入的idPlane。我無需堅持idPlane是否被修改就可以安全地使用它。
- 函數中的epsilon也不會被修改。
- front, back, frontOnPlaneEdges and backOnPlaceEdges是輸出變量,是值的寫入目標。
- 參數列表后面的const是我最贊賞的地方。它表明idSurface::Split()不會去修改surface。這是我最喜歡的C++獨有功能,因為我可以這樣使用:
- void?f(const?idSurface?&s)?{?
- 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()為例,我們看看它是如何注釋的:
- //?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_??
第一行有點多余,從函數定義中我們已經能明白所有的信息了;但第二、第三行很有價值,雖然我們已經可以推斷出第二行的屬性,但注釋消除了歧義。
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