歡迎光臨
每天分享高質量文章

谷歌大牛的程式設計建議和技巧

(點選上方公眾號,可快速關註)

編譯:伯樂線上/PJing

英文: Rob Pike

好文投稿, 請點選 → 這裡瞭解詳情

【伯樂線上導讀】:Rob Pike 是谷歌公司最著名的軟體工程師之一,曾是貝爾實驗室 Unix 開發團隊成員,Plan 9 作業系統開發的主要領導人,Inferno 作業系統開發的主要領導人。他是締造 Go 語言和 Limbo 語言的核心人物。和 Brian Kernighan 合著過兩本書:《The Unix Programming Environment》 和 《程式設計實踐(The Practice of Programming)》。

本文介紹的建議和技巧,是 Rob Pike 寫於 1989 年 2 月,伯樂線上編譯如下。

介紹

Kernighan 和 Plauger 編寫的《The Elements of Programming Style》,是一本很重要而且公認有很大影響力的書。但有時候我覺得對於書中的簡潔規則,可以看做是一種好的烹飪方法,而不是想簡潔的表達一種哲學思維。倘若這本書聲稱應該有意義地選擇變數名稱,那麼難道他們文章中對變數的命名更好?難道 MaximumValueUntilOverflow 比 maxval 更好嗎? 我不這麼認為。

下麵是一篇簡短的文章,總體上鼓勵在程式設計時應有清晰的哲學思維,而不是給予硬性規則。我並不希望你們能認可所有的東西,因為它們只是觀點,觀點會隨著時間的變化而變化。可是,如果不是直到現在把它們寫在紙上,長久以來這些基於許多經驗的觀點一直積累在我的頭腦中。因此希望這些觀點能幫助你們,瞭解如何規劃一個程式的細節。(我還沒有看到過一篇講關於如何規劃整個事情的好文章,不過這部分可以是課程的一部分)要是能發現它們的特質,那很好;要是不認同的話,那也很好。但如果能啟發你們思考為什麼不認同,那樣就更好了。在任何情況下,都不應該照搬我所說的方式進行程式設計;要用你認為最好的程式設計方式來嘗試完成程式。請一以貫之而且毫不留情的這麼做。

歡迎您的評論。

排版問題

程式是一種出版物。意味著程式員們會先閱讀(也許是幾天、幾周或幾年後的你自己閱讀),最後才輪到機器。機器的快樂就是程式能編譯,機器才不在乎程式寫的有多麼漂亮,可是人們應該保持程式的美觀。有時人們會過度關心:用漂亮的印表機獃板地打印出漂亮的輸出,而這些輸出只是將所有介詞用英文文字以粗體字型凸顯出來,都是些與程式無關的細節。雖然有很多人認為程式就應該像 Algol­68 所描述的一樣(有些系統甚至要求照搬該風格編寫程式),可清晰的程式不會因為這樣的呈現而變得更清晰,只會使糟糕的程式變得更可笑。

對於清晰的程式來說,排版規範一向都是至關重要的。當然,眾所周知最有用的是縮排,但是當墨水遮蓋了意圖時,就會控制住排版。因此即便堅持使用簡單的舊打字機輸出,也該意識到愚蠢的排版。避免過度修飾,比如保持註釋的簡潔和靈活。透過程式整齊一致地說出想表達的。接著往下看。

變數命名

對於變數名稱,長度並不是名稱的價值所在,清晰的表達才是。不常用的全域性變數可能會有一個很長的名稱,像 maxphysaddr。在迴圈中每一行所使用的陣列索引,並不需要取一個比 i 更詳盡的名字。取 index 或者 elementnumber 會輸入更多的字母(或呼叫文字編輯器),並且會遮蓋住計算的細節。當變數名稱很長時,很難明白髮生了什麼。在一定程度上,這是排版問題,看看下麵

        for0 to 100)

                array[i0;

vs.

       for(elementnumber 0 to 100)

                array[elementnumber0;


現實體子中的問題會變得更糟。所以僅需把索引當成符號來對待。

指標也需要合理的符號。np 僅僅只是作為指標 nodepointer 的助記符。如果一貫都遵從命名規範,那麼很容易就能推斷出 np 表示“節點指標”。在下一篇文章中會提到更多。

同時在程式設計可讀性的其它方面,一致性也是極其重要的。假使變數名為 maxphysaddr,則不要給同級關係的變數取名 lowestaddress。

最後,我傾向於「最小長度」但「最大資訊量」的命名,並讓背景關係補齊其餘部分。例如:全域性變數在使用時很少有背景關係幫助理解,那麼它們的命名相對而言更需要令人易懂。因此我稱 maxphyaddr (不是 MaximumPhysicalAddress)作為一個全域性變數名,對於在本地定義和使用的指標來說 np 並不一定是 NodePoint。這是品味的問題,但品味又與清晰度相關。

我避免在命名時嵌入大寫字母;在我經驗豐富的雙眼中,它們的閱讀舒適性太彆扭了,像糟糕的排版一樣令人心煩。

指標的使用

C 語言不同尋常,因為它允許指標指向任何事物。指標是鋒利的工具,像任何這樣的工具一樣,使用得當可以產生令人愉悅的生產力,但使用不當也可以造成極大的破壞(在寫這篇文章的前幾天,我把木工鑿插到拇指裡了)。指標在學術界的名聲不太好,因為它太危險了,莫名其妙地就變得糟糕的不行。但我認為它是強大的符號,它可以幫助我們清楚地自我表達。

思考:當有指標指向物件時,對於那個物件,確切地說它只是名稱,其它什麼也不是。聽起來很瑣碎,但看看下麵的兩個運算式:

        np

        node[i]


第一個指向一個 node(節點),第二個計算為(可以說)同一個 node。但第二種形式是不太容易理解的運算式。這裡解釋一下,因為我們必須要知道 node 是什麼,i 是什麼,還要知道 i 和 node 與周圍程式之間相關(可能不是很詳細)的規則是什麼。孤立的運算式並不能說明 i 是 node 的有效索引,更不用提是我們想要元素的索引。如果 ij 和 k 都是 node 陣列中的索引將很容易出差錯,而且連編譯器都不能幫助找出錯誤。當給子程式傳引數時,尤其容易出錯:指標只是一個單獨的引數;但在接收的子程式中必須認為陣列和索引是一體的。

計算為物件運算式本身,比該物件的地址更不易察覺,而且容易出錯。正確使用指標可以簡化程式碼:

        parent->link[i].type

vs.

lp->type.


如果想取下一個元素的 type 可以是

       parent->link[++i].type

        (++lp)->type.


i 前移,但其餘的運算式必須保持不變;用指標的話,只需要做一件事,就是指標前移。

把排版因素也考慮進來。對於處理連續的結構體來說,使用指標比用運算式可讀性更好:只需要較少的筆墨,而且編譯器和計算機的效能消耗也很小。與此相關的問題是,指標型別會影響指標正確使用,這也就允許在編譯階段使用一些有用的錯誤檢測,來檢查陣列序列不能分開。而且如果是結構體,那麼它們的標簽欄位就是其型別的提示。因此

             np->left

是足以讓人明白的。如果是索引陣列,陣列將取一些精心挑選的名字,而且運算式也會變得更長:

             node[i].left.

此外,由於例子變得越來越大,額外的字元更加讓人惱火。

一般來說,如果發現程式碼中包含許多相似並複雜的運算式,而且運算式計算為資料結構中的元素,那麼明智地使用指標可以消除這些問題。考慮一下

        if(goleft)

             p->left=p->right->left;

        else

             p->right=p->left->right;


看起來像利用複合運算式表示 p。有時這值得用一個臨時變數(這裡的 p)或者把運算提取成一個宏。

過程名稱

過程名稱應該表明它們是做什麼的,函式名稱應該表明它們傳回什麼。函式通常在像 if 這樣的運算式使用,因此可讀性要好。

        if(checksize(x))

是沒有太大幫助的,因為不能推斷出 checksize 錯誤時傳回 true,還是非錯誤時傳回。相反

        if(validsize(x))

使這點能清晰表達,並且在常規使用中將來也不大可能出錯。

註釋

這一個微妙的問題,需要自己體會和判斷。由於一些原因,我傾向於寧可清除註釋。第一,假如程式碼清晰,並且使用了規範的型別名稱和變數名稱,應該從程式碼本身就可以理解。第二,編譯器不能檢查註釋,因此不能保證準確,特別是程式碼修改過以後。誤導性的註釋會非常令人困惑。第三,排版問題:註釋會使程式碼變得雜亂。

但有時我會寫註釋,像下文一樣僅僅只是把它們用於介紹。例如:解釋全域性變數的使用和型別(我總是在龐大的程式中寫註釋);作為一個不尋常或者關鍵過程的介紹;或標記出大規模計算的一節。

糟糕註釋風格,有一個典型的例子:

        i=i+1;           /* Add one to i */

還有更爛的做法:

        /**********************************

         *                                *

         *          Add one to i          *

         *                                *

         **********************************/

 

                       i=i+1;


先不要嘲笑,等到在現實中看到再去吧。

或許除了諸如重要資料結構的宣告(對資料的註釋通常比對演演算法的更有幫助),這樣至關重要部分之外,需要避免對註釋的“可愛”排版和大段的註釋;基本上最好就不要寫註釋。如果程式碼需要靠註釋來說明,那最好的方法是重寫程式碼,以便能更容易地理解。這就把我們帶到了複雜度。

複雜度

許多程式過於複雜,比需要有效解決的問題更加複雜。這是為什麼呢?大部分是由於設計不好,但我會跳過這個問題,因為這個問題太大了。然而程式往往在微觀層面就很複雜,有關這些可以在這裡解決。

規則 1:不要斷定程式會在什麼地方耗費執行時間。

瓶頸總是出現在令人意想不到的地方,直到證實瓶頸在哪,不要試圖再次猜測並加快執行速度。

規則 2:估量(measure)

在沒有對程式碼做出估量之前不要最佳化速度,除非發現最耗時的那部分程式碼,要不也不要去做。

規則 3:當 n 很小時(通常也很小),花哨的演演算法執行很慢。

花哨演演算法有很大的常數級別複雜度。在你確定 n 總是很大之前, 不要使用花哨演演算法。(即使假如 n 變大,也優先使用規則 2).例如,對於常見問題,二叉樹總比伸展樹高效。

規則 4:花哨的演演算法比簡單的演演算法更容易有 bug,而且實現起來也更困難

儘量使用簡單的演演算法與簡單的資料結構。

以下幾乎是所有實際程式中用到的資料結構:

  • 陣列

  • 連結串列

  • 雜湊表

  • 二叉樹

當然也必須要有把這些資料結構靈活結合的準備,比如用雜湊表實現的符號表,其中雜湊表是由字元型陣列組成的連結串列。

規則 5:以資料為核心

如果選擇了適當的資料結構並把一切都組織得很有條理性,演演算法總是不言而喻的。程式設計的核心是資料結構,而不是演演算法。(參考 Brooks p. 102)

規則 6:就是沒有規則 6


資料程式設計

不像許多 if 陳述句,演演算法或演演算法的細節通常以緊湊、高效和明確的資料進行編碼。眼前的工作可以編碼,歸根到底是由於其複雜性都是由不相干的細節組合而成。分析表是典型例子,它透過一種解析固定、簡單程式碼段的形式,對程式語言的語法進行編碼。有限狀態機特別適合這種處理形式,但是幾乎任何涉及到對構建資料驅動演演算法有益的程式,都是將某些抽象資料型別的輸入“解析”成序列,序列會由一些獨立“動作”構成。

也許這種設計最有趣的地方是表結構有時可以由另一個程式生成(經典案例是解析生成器)。有個更接地氣的例子,假如作業系統是由一組表驅動,這組表包含連線 I/O 請求到相應裝置驅動的操作,那麼可以透過程式“配置“系統,該程式可以讀取到某些特殊裝置與可疑機器連線的描述,並列印相應的表。

資料驅動程式在初學者中不常見的原因之一是由於 Pascal 的專制。 Pascal 像它的創始人一樣,堅信程式碼要和資料分開。因而(至少在原始形式上)無法建立初始化的資料。與圖靈和馮諾依曼的理論背道而馳,這些理論可都是定義儲存計算機的基本原理。程式碼和資料是一樣的,或至少可以算是。還能怎樣解釋編譯器的工作原理呢?(函式式語言對 I/O 也有類似的問題)

函式指標

Pascal 專制的另一個結果是初學者不使用函式指標。(在 Pascal 中沒有把函式作為變數) 用函式指標來處理編碼複雜度會有一些令人感興趣的地方。

指標指向的程式有一定的複雜度。這些程式必須遵守一些標準協議,像要求一組都是相同呼叫的程式就是其中之一。除此之外,所要實現的只是完成業務,複雜度是分散的。

有個協議的主張是既然所有使用的功能相似,那麼它們的行為也必須相似。這對簡單的檔案、測試、程式擴充套件和甚至使程式透過網路分佈都有幫助——遠端過程呼叫可以透過該協議進行編碼。

我認為面相物件程式設計的核心是清晰使用函式指標。規定好要對資料執行的一系列操作,以及對這些操作響應的整套資料型別。將程式合攏到一起最簡單的方法是為每種型別使用一組函式指標。簡而言之,就是定義類和方法。當然,面向物件語言提供了更多更漂亮的語法、派生型別等等,但在概念上幾乎沒有提出額外的東西。

資料驅動程式與函式指標的結合,變成了一種表現令人驚訝的工作方法。根據我的經驗,這種方法經常會產生驚喜的結果。即使沒有面向物件語言,無需額外的工作也可以獲得 90% 的好處,並且能更好地管理結果。我無法再推薦出更高標準的實現方式。我所有的程式都是由這種方式組織管理,而且經過多次開發後都相安無事——遠遠優於缺少約束的方法。也許正如所說:從長遠來看,約束會帶來豐厚的回報。

包含檔案

簡單規則:包含(include)檔案時應該永遠不要巢狀包含。

如果宣告(在註釋或隱式宣告裡)需要的檔案沒有優先包含進來,那麼使用者(程式員)要決定包含哪些檔案,但要以簡單的方式處理,並採用避免多重包含的結構。多重包含是系統程式設計的禍根。將檔案包含五次或更多次來編譯一個單獨的 C 源檔案的事情屢見不鮮。Unix 系統中 /usr/include/sys 就用了這麼可怕的方式。

說到 #ifdef,有一個小插曲,雖然它能防止讀取兩次檔案,但實際上經常用錯。#ifdef 是定義在檔案本身中,而不是檔案包含它。結果是常常導致讓成千上萬不必要的程式碼透過詞彙分析器,這是(優秀編譯器中)耗費最大的階段。

只需遵從以上簡單規則。


看完本文有收穫?請轉發分享給更多人

關註「程式員的那些事」,提升程式設計技能

贊(0)

分享創造快樂