代碼之美——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(....);?
    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. //?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.pswp.cn/news/449695.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/449695.shtml
英文地址,請注明出處:http://en.pswp.cn/news/449695.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

UDP:用戶數據報協議 是什么

用戶數據報協議&#xff08;英語&#xff1a;User Datagram Protocol&#xff0c;縮寫為UDP&#xff09;&#xff0c;又稱用戶數據報文協議&#xff0c;是一個簡單的面向數據報的傳輸層協議&#xff0c;正式規范為RFC 768。在TCP/IP模型中&#xff0c;UDP為網絡層以上和應用層以…

隨想錄(程序員和收入)

距離上一次寫博客已經很長時間了&#xff0c;大約過了三個星期。這三個星期發生了很多事情&#xff0c;這中間也有我自己的思考積累&#xff0c;也有工作上的變故。總之&#xff0c;自己想了很多&#xff0c;也得到了很多。每到這個時候&#xff0c;畢業生朋友們都在尋找工作&a…

iOS進階之正則表達式

最近一直在弄正則表達式&#xff0c;于是在這里整理一下&#xff0c;便于日后查閱。 1、常用符號 ^&#xff1a;字符串的開始$&#xff1a;字符串的結束*&#xff1a;表示零個或若干個?&#xff1a;表示零個或一個&#xff1a;表示一個或若干個| &#xff1a;表示 或 操作. &a…

akshare分析漲停板股票數據

導入包&#xff0c;獲取日期數據 import pandas as pd import numpy as np import akshare as ak #畫圖 import matplotlib.pyplot as plt #正確顯示中文和負號 plt.rcParams[font.sans-serif][SimHei] plt.rcParams[axes.unicode_minus]False #處理時間 from dateutil.parser…

DNS(域名系統) 是什么

域名系統&#xff08;英文&#xff1a;Domain Name System&#xff0c;縮寫&#xff1a;DNS&#xff09;是互聯網的一項服務。 它作為將域名和IP地址相互映射的一個分布式數據庫&#xff0c;能夠使人更方便地訪問互聯網。 DNS使用TCP和UDP端口53。當前&#xff0c;對于每一級域…

《The Art of Readable Code》學習筆記(一)

放寒假回家有些頹廢&#xff0c;就是不想看書。但是已經大三了&#xff0c;春節過后就要找實習了。哎&#xff0c;快樂的大學生活終于要過去了。 先從簡單的書看起吧&#xff01;在圖書館借了本《The Art of Readable Code》&#xff0c;就是教你咋寫好優雅的代碼的&#xff0c…

文件基本處理

1 打開文件&#xff0c;將文件句柄賦值給一個變量 2 拿句柄對文件進行操作 3 關閉文件 將一個文件第一行寫道另外一個文件 f open("test","r",encoding"utf-8") # open找的是系統的編碼 x f.readlines() f.close() f1 open("test1"…

C++ ofstream和ifstream詳細用法

ofstream是從內存到硬盤&#xff0c;ifstream是從硬盤到內存&#xff0c;其實所謂的流緩沖就是內存空間; 在C中&#xff0c;有一個stream這個類&#xff0c;所有的I/O都以這個“流”類為基礎的&#xff0c;包括我們要認識的文件I/O&#xff0c;stream這個類有兩個重要的運算符&…

如何將JAR包發布到Maven中央倉庫?

將jar包發布到Maven中央倉庫(Maven Central Repository)&#xff0c;這樣所有的Java開發者都可以使用Maven直接導入依賴&#xff0c;例如fundebug-java&#xff1a; <!-- https://mvnrepository.com/artifact/com.fundebug/fundebug-java --> <dependency><grou…

SSH、SSL與HTTPS

前些天發現了一個巨牛的人工智能學習網站&#xff0c;通俗易懂&#xff0c;風趣幽默&#xff0c;忍不住分享一下給大家。點擊跳轉到教程。 關于加密 在解釋SSH、SSL與HTTPS協議之前我先介紹一下非對稱加密協議。在1976年以前&#xff0c;所有的加密都采用對稱加密&#xff0c…

北向資金運作akshare

import pandas as pd import numpy as np import matplotlib.pyplot as plt %matplotlib inline from pylab import mpl mpl.rcParams[font.sans-serif][SimHei] mpl.rcParams[axes.unicode_minus]False#獲取交易日歷 import datetime def get_cal_date(start,end):dates ak.to…

網絡性能測試工具iperf詳細使用圖文教程【轉載】

原文&#xff1a;https://www.cnblogs.com/yingsong/p/5682080.html 轉載于:https://www.cnblogs.com/luo30zhao/p/10512042.html

代碼審查:程序員內煉之道

摘要&#xff1a;“關注并弄清楚橋梁修建細節&#xff0c;否則你建起來的橋梁有可能坍塌。”代碼審查更重要的是一種技術分享或者代碼共享。程序員如何提升自我修煉之道&#xff0c;歡迎來支招。 代碼審查更重要的是一種技術分享或者代碼共享。在審查過程中&#xff0c;通過被…

扎實的基礎是成功的法寶

轉載鏈接&#xff1a;https://baijiahao.baidu.com/s?id1610187127874738836&wfrspider&forpc好基礎是好成績的根本,無論做任何事情,基本功的訓練是成功的前提:“還沒有學會走,就想學跑,那不行,肯定會摔跟頭。”這是成功人士的經驗之談。要建成高樓大廈,地基必須打好。…

發送qq郵件

import smtplib from email.mime.text import MIMEText from email.mime.image import MIMEImage from email.mime.multipart import MIMEMultipart from email.mime.application import MIMEApplication# 寫成了一個通用的函數接口&#xff0c;想直接用的話&#xff0c;把參數…

排序代碼(python,c++) 及 基本算法復雜度

0.導語 本節為手撕代碼系列之第一彈&#xff0c;主要來手撕排序算法&#xff0c;主要包括以下幾大排序算法&#xff1a; 直接插入排序 冒泡排序 選擇排序 快速排序 希爾排序 堆排序 歸并排序 1.直接插入排序 【算法思想】 每一步將一個待排序的記錄&#xff0c;插入到前面…

TCP/IP四層模型與OSI參考模型

前些天發現了一個巨牛的人工智能學習網站&#xff0c;通俗易懂&#xff0c;風趣幽默&#xff0c;忍不住分享一下給大家。點擊跳轉到教程。 TCP/IP四層模型&#xff1a; 1.鏈路層&#xff08;數據鏈路層/網絡接口層&#xff09;&#xff1a;包括操作系統中的設備驅動程序、計算…

Metal日記:使用步驟指南

本文參考資料&#xff1a; juejin.im/post/5b1e8f… xiaozhuanlan.com/topic/04598… developer.apple.com/videos/play… github.com/quinn0809/G… cloud.tencent.com/developer/a… devstreaming-cdn.apple.com/videos/wwdc… Metal處理邏輯 無論是CoreImage、GPUImage框架&…

還駕馭不了4核? 別人已模擬出百萬核心上的并行

摘要&#xff1a;不管是臺式機還是筆記本&#xff0c;四核雙核都已經不是新鮮的事了。計算機領域的你可能已經認識到了給電腦選配4核的處理器完全是一種浪費&#xff0c;因為大多數的程序都不支持多核心的并行處理。然而斯坦福的計算機科學家最近公布&#xff0c;他們已經模擬出…

docker安裝并運行ubuntu

拉取鏡像 docker pull dorowu/ubuntu-desktop-lxde-vnc 運行容器&#xff1a; docker run -p 6080:80 dorowu/ubuntu-desktop-lxde-vnc 之后就可以http://localhost:6080/