編寫易於理解的程式碼
提示 1:經常註釋
請經常為程式碼新增註釋。假設您編寫了一個過程,但沒有為它做註釋,幾個月後,您再回過頭來想對它進行一些修整(您絕對會這麼做),將需要花費很多時間去研讀這些程式碼,原因就是因為您之前沒有做註釋。而時間是您最為寶貴的資源。丟失的時間是永遠也找不回來的。
但註釋和其他事情一樣也是需要技巧的。只要多練習,在這方面的技能就會不斷提高。註釋有好有壞。
最好不要將註釋寫得過長。假設為一個函式做了註釋,而這個註釋在將來可以節省您理解程式碼所需的時間,比如說 10 分鐘。這很好。現在假設所編寫的註釋過長,您花了 5 分鐘編寫這個註釋,之後還要再花 5 分鐘讀懂這個註釋。這樣一來,實際上沒有節省任何時間。這不是一種很好的做法。
當然,也不要將註釋寫得過短。如果在一兩頁之長的程式碼中找不到任何註釋,那麼這段程式碼最好清晰得 “晶瑩剔透”,否則將來研讀所需的時間將會很長。
再有,註釋的方式不能太死板。當剛剛開始編寫註釋時,人們往往會頭腦一熱,寫下這樣的註釋:
1 2 | // Now we increase Number_Aliens_on_screen by one. Number_Aliens_on_screen = Number_Aliens_on_screen + 1; |
這麼明顯的東西顯然不需要註釋。如果程式碼非常混亂以致於需要逐行註釋,那麼更有利的方式是首先簡化程式碼。在這種情況下,註釋並不能節省時間,反倒會消耗時間。因為註釋需要時間去研讀,而且它們分佈於螢幕上的實際程式碼中的不同位置,所以在顯示器上一次只能看少許的註釋。
此外,千萬不要這麼寫註釋:
1 2 3 4 5 6 7 8 | Short get_current_score() { [insert a whole bunch of code here.] return [some value]; // Now we're done. } |
“We’re done” 這樣的註釋有何用處呢?真是感謝您讓我知曉。註釋下面的這個大括號以及其後跟隨的大片空白難道還不足以讓我明白這是一段程式碼的結束麼?同樣,在返回語句之前也不需要使用類似 “Now we return a value” 這樣的註釋。
那麼,如果您正在編寫程式碼,而又沒有上司或公司的規定可以做指導,這時,又該如何註釋呢?我的做法是:對於由我自己維護的程式碼,我會寫一個簡介。這樣一來,當我返回一個我很久以前編寫的過程時,我就可以檢視對它的解釋。一旦我瞭解了其工作原理之後,我就可以很容易地理解實際的編碼了。這通常會涉及:
1. 過程/函式之前寫幾句話,說明其功能。
2. 對傳遞給它的數值的一個描述。
3. 如果是函式,對其返回結果的一個描述。
4. 在過程/函式內部,能將程式碼分解為更短小的任務的註釋。
5. 對於看起來有些難懂的大塊程式碼,對其成因給與簡短的解釋。
總之,我們需要在開始時給出一個描述,然後再在整個程式碼內部的幾個位置加以註釋。這種做法需時不多,但卻可在將來節省大量的時間。
如下所示是另一個取自假想的 Kill Bad Alients 遊戲的例子。考慮代表玩家子彈的那個物件。需要頻繁地呼叫函式來將其向上移動以便檢查該子彈是否會擊中任何目標。我可能會按如下所示編寫實現這個功能的程式碼:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | // This procedure moves the bullet upwards. It's called //NUM_BULLET_MOVES_PER_SECOND times per second. It returns TRUE if the //bullet is to be erased (because it hit a target or the top of the screen) and FALSE //otherwise. Boolean player_bullet::move_it() { Boolean is_destroyed = FALSE; // Calculate the bullet's new position. [Small chunk of code.] // See if an enemy is in the new position. If so, call enemy destruction call and // set is_destroyed to TRUE [small chunk of code] // See if bullet hits top of screen. If so, set is_destroyed to TRUE [Small chunk of code.] // Change bullet's position. [Small chunk of code.] Return is_destroyed; } |
如果程式碼足夠清晰,如上所示的註釋應該就已經足夠。對像我這樣需要不時地返回這個函式來修復錯誤的人來說,這將能夠節省大量時間。
提示 2:大量使用 #define。沒錯,是要大量使用。
假設,在我們這個假想的遊戲中,希望玩家在射中一個外星人時即可獲得 10 分。有兩種方法可以實現這個目的。如下所示的是其中一個比較糟糕的做法:
1 2 3 4 5 6 | // We shot an alien. Give_player_some_points(10); This is the good way: In some global file, do this : #define POINT_VALUE_FOR_ALIEN 10 |
之後,當我們需要給出一些分數時,我們很自然地會這麼寫:
1 2 | // We shot an alien. Give_player_some_points(POINT_VALUE_FOR_ALIEN); |
在某種程度上,大多數程式設計師都知道該這麼做,但是需要遵守一定之規,才能將其做好。比如,每次在定義常數時,都需要考慮在某個中心位置對其進行定義。假設,要將玩遊戲的區域設定成 800 * 600 畫素,請務必這麼做:
1 2 | #define PIXEL_WIDTH_OF_PLAY_AREA 800 #define PIXEL_HEIGHT_OF_PLAY_AREA 600 |
如果,在某個日期,又想更改遊戲視窗的大小了(您很可能需要這麼做),若在此處就能更改數值將會節省您雙倍的時間。這是因為:第一,無需在全部程式碼中查詢所有提到遊戲視窗是 800 畫素寬的地方(800!我當時是怎麼想的?)第二,無需總要修復那些由於漏掉了引用而引起的無法避免的 bug。
當我製作 Kill Bad Aliens 遊戲時,我要決定需要殺掉多少外星人一個 wave 才算結束、螢幕上一次能有多少外星人、這些外星人又以多快的速度出現。例如,如果我想讓每個 wave 中的外星人的人數相同,並且他們都以相同的速度出現,我可能會編寫如下所示的程式碼:
1 2 3 | #define NUM_Aliens_TO_KILL_TO_END_WAVE 20 #define MAX_Aliens_ON_SCREEN_AT_ONCE 5 #define SECONDS_BETWEEN_NEW_Aliens_APPEARING 3 |
這段程式碼很清晰。此後,若我覺得這個 wave 太短或外星人相繼出現的時間間隔過短,我就可以立即調整相應的值並立即讓遊戲重新生效。
如此設定遊戲值的一個妙處是能快速地做出更改,這種立竿見影的施控感覺實在是很好。比如,如果將上述程式碼改寫成如下所示:
1 2 3 | #define NUM_Aliens_TO_KILL_TO_END_WAVE 20 #define MAX_Aliens_ON_SCREEN_AT_ONCE 100 #define SECONDS_BETWEEN_NEW_Aliens_APPEARING 1 |
那麼,您就無法享受上述的快感和興奮了。
提示 3:不要使用弄巧成拙的變數名。
總的目標很簡單:編寫程式碼以便讓那些不知道其用意的人能讀懂,讓知道其用意的人能儘快地理解。
實現這一目標最好的策略是為變數、過程等賦以含義鮮明的名字。當他人看到這個變數名時,就會立刻清楚其意義,您也不必搜尋整個程式來尋找 incremeter_side_blerfm 的用意何在,這大約會節省五分鐘左右的時間。
這裡需要進行一些均衡。所給出的命名應該儘量長且足夠清晰以便您能理解其含義,但也不能過長或太過怪異,如果這樣,程式碼的可讀性就會受到影響。
例如,在實際中,我可能不會像上一節所示的那樣給常量命名。我之前之所以這麼做是為了讓讀者在沒有任何上下文的情況下也能充分理解這些常量的含義。在程式本身的上下文中,與如下所示的相比:
1 | #define MAX_Aliens_ON_SCREEN_AT_ONCE 5 |
我會毫不猶豫地這樣編碼:
1 | #define MAX_NUM_Aliens 5 |
這個簡短的名字所引起的疑惑很快就會迎刃而解,而簡短的命名還會增加程式碼的可讀性。
現在來看看在本文中我經常要呼叫的那個用來將外星人在螢幕上到處移動的程式碼片段,我會毫不猶豫地這樣編碼:
1 2 3 4 | // move all the Aliens for ( short i = 0; I < MAX_NUM_Aliens; i++) if (Aliens[i].exists()) // this alien currently exist? Aliens[i].move_it(); |
請注意,包含所有外星人的這個陣列的名稱很簡單,叫做 Aliens。這很棒。它恰好就是我想要的那種描述性名稱,這個名稱又很簡短,即使鍵入千遍之多,我也不會感到煩悶。此陣列將會經常用到。如果將其命名為類似 all_Aliens_currently_on_screen 這樣的名稱,那麼所編寫的最終程式碼將會長出很多,而且程式碼還會因此變得不怎麼清晰。
同樣,我還將迴圈變數直接命名為 i,無任何額外的說明。若是初次接觸描述性變數名這個概念,您很可能會忍不住將此迴圈變數命名為 “counter” 之類的名字。實際上,沒有必要這麼做。命名變數的意義在於讓讀者能夠立即理解該變數的用意。人人都知道 “i”、”j” 這類名稱常常用於迴圈變數,所以將迴圈變數如此命名是完全可以的,無需多加解釋和說明。
當然,有關變數命名還是需要多加註意。比如,有一種稱為 Hungarian Notation 的東西。其種類很多,但基本的理念是在變數名的開始新增一個標記以表示其型別(例如,所有無符號長型變數都以 ul 開頭)。這比我希望的要多少麻煩一些,但這個概念必須要了解。為了弄清楚事情可能需要花費太多時間,但還是值得的。
提示 4:進行錯誤檢查。
一個正常大小的程式往往都會有大量的函式和過程。而且更為麻煩的是,其中的每一個都需要進行稍許錯誤檢查。
當建立過程/函式時,應該總要考慮這樣的一個問題:“假如一些懷有惡意的人故意向函式或過程傳遞進各種怪異的值,這段剛剛建立的程式碼如何能自保並且讓計算機也能免受破壞呢?”然後,編寫程式碼來檢查這些惡意資料以保護自身免受這些資料的破壞。
舉個例子。我們的這個太空遊戲的主要目標是殺掉外星人並積分,所以我們需要一個過程來更改分數。而且,當加分時,我們需要呼叫一個例程來實現分數上星光閃爍的效果。如下所示的是第一個過程:
1 2 3 4 5 6 | Void change_score( short num_points) { score += num_points; make_sparkles_on_score(); } |
到目前為止還不錯。現在請思考一下:這裡可能出現的錯誤是什麼呢?
首先,一個很明顯的問題是:如果 num_points 是負值該如何呢?我們能讓玩家的分數降低麼?就算我們能降低分數,但在我之前給出的關於該遊戲的描述中,沒有提到過失分。而且,遊戲應該有趣,但失分無論如何不能算是一個有趣的事情。所以,我們將分數負值視為一個錯誤並必須要捕獲。
上述錯誤相對容易,但這裡有一個很微妙的問題(也是我在遊戲中經常要處理的)。如果 num_points 為零又會怎麼樣呢?
這是一個很似是而非的情景。還記得麼,我們會在每個 wave 結束時根據玩家完成速度的快慢給一個獎勵分數。如果玩家速度極慢,我們是否應該給他一個值為零的獎勵分數呢?在凌晨三點,呼叫 change_score 並傳遞值 0,這完全可行。
現在的問題是我們可能不想讓計分板在顯示的數值沒有變化時仍舊五顏六色地閃個不停。所以我們要先捕獲這個問題。讓我們嘗試如下程式碼:
01 02 03 04 05 06 07 08 09 10 11 12 13 | Void change_score( short num_points) { if (num_points < 0) { // maybe some error message return ; } score += num_points; if (num_points > 0) make_sparkles_on_score(); } |
好了,情況好多了。
請注意這是很簡單的一個函式。裡面並沒有用到任何極受新手推崇的新奇指標。如果要傳遞陣列或指標,那麼最好小心錯誤和壞資料的出現。
這樣做的好處並不僅僅限於讓程式免遭破壞。好的錯誤檢查還能讓除錯更為迅速。假設,您知道寫入的資料超出了陣列的範圍,為了發現可能出現的錯誤,您需要詳細檢查程式碼。若所檢視的這個過程中的錯誤檢查均已就緒,那麼就無需花很多時間去專門通查它來尋找錯誤。
這種做法將節省大量時間,而且還能重複。還是那句話,時間是我們所擁有的寶貴資源。
提示 5:“不成熟的最佳化是麻煩的根源” —— Donald Knuth
上述格言非我個人所造,它可以在 Wikipedia 中找到,所以必定是十分睿智的。
除非是想找別人麻煩,否則編寫程式碼的首要目標就是簡明性。簡單的程式碼更易於編寫、易於日後理解,也更易於除錯。
最佳化與簡明性是相悖的。但有時,卻必須要進行最佳化,在遊戲中尤其如此。這個問題至關重要,您可能直到用解析器實際對工作程式碼進行測試時才會意識到需要進行最佳化。(解析器 是一種程式,用來監視其他程式並找出該程式使用不同的呼叫所花費的時間。這些都是很棒的程式。您可以找一個來試試。)
每次當我最佳化遊戲時,常常都禁不住會大出所料。我十分擔心的那些程式碼總是問題不大,相反,我覺得萬無一失的程式碼反倒會執行得十分緩慢。由於我對執行速度的快慢並沒有什麼概念,在獲得實際資料之前我所進行的最佳化根本就是浪費時間。比浪費時間更糟糕的是它還讓程式碼變得有些混亂。
這個規則看來很難遵守。但,如果規則很容易,它也就稱不上規則了。好的程式設計師大都更痛恨將原本可以執行迅速的程式碼弄得臃腫笨拙。
但好訊息是,在我不斷 “該這樣不該那樣的” 佈道式的介紹中, 這是惟一的一個您可以稍微懈怠一些的地方!
請讓自己編寫的程式碼儘量整潔和有效一些吧。在後面的最佳化階段,可能需要將其變得面目全非。所以如非必要,請慎重。
說到傷害,接下來,就來看看最後的這條建議。
提示 6:不要一知半解、自作聰明。
您可能聽說過 IOCCC 吧,即 International Obfuscated C Code Contest。大家都知道,C 和 C++,不管其優勢如何卓越,都會最終導致編寫的程式碼噩夢般地複雜。這個比賽就是要透過評選出最離譜的程式碼來展示簡明程式碼的價值,真是別具匠心。
讓我們來看看在您自認為具有了程式設計的全部知識並甘願冒險的情況下,您能製造什麼樣的麻煩。足夠的知識讓您信心百倍地將十行程式碼壓縮排一行程式碼內。付出的代價就是您絕對無法快速修復其中可能存在的 bug。
這裡所需吸取的教訓就是如果您所編寫的程式碼要求您必須具有有關複雜優先規則的詳細知識或讓您不得不翻看某些書的後面章節才能弄清來龍去脈,那麼您在編寫這段程式碼時就犯了一知半解、自作聰明的毛病了。
每個人對程式碼的複雜性都有自己的容忍程度。就我個人而言,我編寫的程式往往呈比較典型的保守風格。我個人認為,如果一段 C 程式碼需要您必須知道 i++ 和 ++i 之間的差別,那麼這段程式碼就過於複雜了。
您儘可以把我想象成一個循規蹈矩的人。沒錯,我的確如此。但循規蹈矩卻可以讓我花很少的時間就可以讀懂我的程式碼。
結束語
至此,您可能會想:“哇哦,真是浪費時間。您介紹的所有這些東西都是顯而易見,盡人皆知的。為何還多此一舉,寫這樣的文章呢?” 實際上,我很希望您會這麼想,因為這意味著您已經進步了,變得明智了。這很好。
但不要錯認為所有這些內容對每個人都是不言自明的。事實並非如此。糟糕的程式碼隨處可見,但實際上這些程式碼本不應如此。
如果您正在努力編寫大量程式碼並想讓自己不受其所累。那麼就請讓程式碼儘量簡單明瞭一些,這樣,您就可以節省大量時間和免受很多挫折