1. 程式人生 > >評:30 多年的編碼經驗濃縮成的 10 條最佳實踐

評:30 多年的編碼經驗濃縮成的 10 條最佳實踐

文章 30 多年的編碼經驗濃縮成的 10 條最佳實踐 原文出自 10 Tips for Writting Better Code。我認為這 10 條原則挺有幫助,所以本文想對這些原則做一些評價,說說我的看法,可以的話順便給一些例子。建議看這篇文章之前先閱讀原文。

事實上,我們可以將好的程式碼等同為 可重用 的程式碼

文章裡說“可重用”也是文中羅列的 10 條原則的“背後驅動”。那麼什麼樣的設計才是“可重用”的呢?其實早有大神提出了“高內聚,低耦合”的指標。“高內聚”說的是一個模組作為一個整體,功能要“專一”;“低耦合”說的是不同模組間的聯絡儘可能少。之後可以看到原文提到的 10 條原則很大程式上與之有關。

#遵循單一職責原則

函式是程式設計師的工具中最重要的抽象形式。它們能更多地被重複使用,你需要編寫的程式碼就越少,程式碼也因此變得更可靠。較小的函式遵循單一職責原則更有可能被重複使用。

這條原則幾乎就是“高內聚”的另一種說法,只不是“高內聚”談論的是模組,而這裡談論的是函式。

這裡舉一個 StackOverflow 關於高內聚的一個例子。假設你建立一個類用來將兩個數相加,與此同時,這個類還建立了一個視窗用來顯示相加的結果。這個類就是“低內聚”的。因為做加法和建立視窗這兩件事沒什麼相關性,建立視窗是“顯示”的部分,而加法是“邏輯”的部分。

按照“單一職責”的原則來說的話,這個類的職責是不單一的,因此我們很難去重用這個類,因為除非我們的需求正好是要做加法,同時要將結果在一個視窗顯示,否則這個類並不能被重用。

換句話說,如果一個函式有多個職責,那只有在使用者同時需要這幾個職責的時候,才能重用這個函式。因此保持函式/類的單一職責,有利於重用。

#儘量減少共享狀態

你應該儘量減少函式之間的隱式共享狀態,無論它是檔案作用域的變數還是物件的成員欄位,這有利於明確要求把值作為引數。當能明確地顯示函式需要什麼才可以產生所需的結果時,程式碼會變得更容易理解和重用。

對此的一個推論是,在一個物件中,相對於成員變數,你更應該優先選擇靜態的無狀態變數 (static stateless variables)。

首先講講什麼是“共享狀態”。這裡提了兩個:“檔案作用域變數”及“物件的成員欄位”。分別舉例如下:

g_config = read_configuration('config.ini'
)
def log(message): with open(g_config['log_file'], 'w') as fp: fp.write(message)def update_config(key, value): g_config[key] = value

這裡,g_config 變數存在於整個檔案裡,所以稱為“檔案作用域變數”。並且 log 函式與 update_config 函式之間共享了 g_config 這個狀態。上面這種寫法也可以寫成類的形式:

class Logger:    def __init__(self, config_file):        self.g_config = read_configuration(config_file)    def log(self, message):        with open(self.g_config['log_file'], 'w') as fp:            fp.write(message)    def update_config(self, key, value):        self.g_config[key] = value

這一次,由於 g_config 是類的“成員欄位”,而 logupdate_config 者依賴於這個變數,所以也稱他們共享了這個狀態。

為什麼要減少狀態共享?共享狀態增加了函式間的“耦合”,可能會引起:

  1. 程式碼不好閱讀,因為必須同時理解共享狀態的各個函式。
  2. 當修改了其中一個函式時,另外的函式的邏輯可能會發生改變,因此程式碼難以維護。
  3. 不利於多執行緒執行。容易造成競爭。

因此,推薦儘量把函式執行需要的狀態通過引數傳遞給函式,如:

def log(config, message):    with open(config['log_file'], 'w') as fp:        fp.write(message)def update_config(config, key, value):    config[key] = valueg_config = read_configuration('config.ini')log(g_config, "error here")

至於 static stateless variablesstatic 代表它不是“成員變數”, stateless 的含義應該等同於 final ,也就是說如果要共享狀態,最好就用類變數而非成員變數,同時,變數最好是“不可變的”。

#將“副作用”區域性化

理想的副作用(例如:列印到控制檯、日誌記錄、更改全域性狀態、檔案系統操作等)應該被放置到單獨的模組中,而不是散佈在整個程式碼裡面。函式中的一些“副作用”功能往往違反了單一職責原則。

“副作用(side effect)”是指在某個域(如函式域)裡修改了域之外的狀態。

  1. 副作用一般伴隨著狀態共享,這種程式碼非常難理解。
  2. 有副作用的程式碼一般都是“執行緒不安全”的。

這裡不深入這個話題,最好的方法就是儘量減少副作用的程式碼。感舉的話,可以考慮參考我之前的文章: 在面嚮物件語言中寫純函式!

#優先使用不變的物件

如果一個物件的狀態在其建構函式中僅被設定一次,並且從不再次更改,則除錯會變得更加容易,因為只要構造正確就能保持有效。這也是降低軟體專案複雜性的最簡單方法之一。

有這樣一句話:Shared Mutable State is the root of evil,共享的,可變的狀態是萬惡之源。為什麼,因為共享意味著它們之間是“耦合的”,沒法單獨分析/工作。可變的意味著這個共享是會傳染的,改變了共享的狀態,所有依賴該狀態的單元都可能發生變化。

有一些語言乾脆禁止使用可變變數(不準確),如 Haskell, Clojure 等。另一些語言則試圖阻止變數的“共享”,如 Rust。那麼在如 C/C++/Java 之類的語言中,雖然語言本身沒有過多的限制,但我們還是應該自己限制自己,減少不必要的麻煩。

#多用介面少用類

接收介面的函式(或 C++ 中的模板引數和概念)比在類上執行的函式更具可重用性。

我認為究其原因,主要是一般定義介面的時候不會指定成員變數,也就是說不會去限制這些介面(方法)的實現細節,而定義類的時候往往會這麼做,這就意味著介面具有更高的可擴充套件性,(API 的)使用者也更可能去實現某個介面而非繼承某個類,因此一個接收介面的函式更有可能被呼叫。

另外,java 是不允許多繼承的,但可以實現多個介面,如果函式接收的是類,那麼意味著使用者的類必須繼承我們指定的類,那使用者自己就無法構建類的繼承結構了。

#對模組應用良好的原則

尋找機會將軟體專案分解成更小的模組(例如庫和應用程式),以促進模組級別的重用。對於模組,應該遵循的一些關鍵原則是:

  1. 儘可能減少依賴
  2. 每個專案應該有一個明確的職責
  3. 不要重複自身

這裡的原則其實跟上面說的其它原則有一定重複:

  • “儘可能減少依賴”。其實就是減少該模組和其它模組的耦合。
  • “每個專案應該有一個明確的職責” 則對應著“高內聚”
  • “不要重複自身” (don’t repeat yourself) 翻譯有誤,應該指不要自己寫重複的程式碼,也就是說重複的程式碼要寫成函式。

#避免繼承

在面向物件程式設計中,繼承 —— 特別是和虛擬函式結合使用時,在可重用性方面往往是一條死衚衕。我很少有成功的使用或編寫過載類的庫的經歷。

這點可能有人會質疑,但我個人是深信不疑的。在 實踐中 我們很少能真正寫出一個能重用的類,這裡的重用指的是被繼承。

歸要結底,(我認為)這是面向物件這種方法的缺陷,世上的事物真的能用類繼承的方式良好地表達嗎?通常面向物件的教材會舉兩個例子,一個是“動物”,另一個是“圖形”。“圖形”的例子是說各種圖形都有“求面積”的方法,正方形可以繼續並實現自己的“面積”演算法,“圓形”也相似。因此可以通過繼承來表達,“面積”函式就是虛擬函式,實現多型。“動物”的例子也類似,例如狗會叫,但不同的狗叫聲不同,因此可以繼承“狗”類。

有人(找不到出處了)質疑,上面的例子都是良好定義的一些關係,但現實中遇到的問題真的能良好的表達嗎?不可否認面向物件有它適合的領域,如 GUI 的各個元件等。還有一些問題能用面向物件(繼承)但不一定是最佳的方案,例如報表,及不同細節的報表。另一些問題可能就不太能用面向物件來表達了。

這一點我建議閱讀一些其它的討論:

最後,要注意的是“繼承”繼承的是父類的“資料+方法”,更多的時候我們關心的只是“方法”的“繼承”。

#將測試作為設計和開發的一部分

我不是測試驅動開發的堅定分子,但開始編碼時先編寫測試程式碼會使得程式碼十分自然地遵循許多指導原則。這也有助於儘早發現錯誤。不過要注意避免編寫無用的測試,良好的編碼實踐意味著更高級別的測試(例如單元測試中的整合測試或特徵測試)在揭示缺陷方面更有效。

我的測試經驗不是特別豐富,同時我也不是測試驅動開發的堅定分子。

關於“編碼之前先寫測試”,我認為它最重要的作用是讓我們對該函式/類的功能有更清晰的認識,而不是一開始就把頭扎到實現細節中,這點非常用幫助。

“避免編寫無用的測試”這點也很重要。測試與開發的矛盾點在於,測試是要保證開發的功能是沒問題的,但開發(函式的內容/作用)是會隨著時間變動的。因此測試的粒度是一個十分重要的問題。目前我也在學習中。

#優先使用標準庫而不是手寫的

我經常看到更好版本的 std::vector 或 std::string,但這幾乎總是浪費時間和精力。一個明顯的事實是 —— 你正在為一個新的地方引入 bug,其他開發者也不太可能重用你的程式碼,因為沒有被廣泛理解、支援和測試。

+10086。去 hack 一個標準庫應該永遠是你 最後 想到的解決方法。你永遠無法想象一個標準庫需要經過多少測試,踩過多少坑才能穩定。並且,如果出現 bug,他們的支援也是十分富貴的,我們的時間永遠不夠用。

#避免編寫新的程式碼

這是每個程式設計師都應遵循的最重要的教誨:最好的程式碼就是還沒寫的程式碼。你寫的程式碼越多,你將遇到的問題就越多,查詢和修復錯誤就越困難。

在寫一行程式碼之前先問一問自己,有沒有一個工具、函式或者庫已經實現了你所需要的功能?你真的需要自己實現這個功能,而不是呼叫一個已經存在的功能嗎?

跟上一點有點重複,但我覺得這裡有兩個要點:

  1. 能不寫儘量不要自己寫。
  2. 如果非要寫,儘量寫得短。

同上一點一樣,要寫出 bug free 的程式碼很困難,並且後續維護需要很大的精力。最後即使是同一個功能,一般程式碼量小的更好,因為你需要處理(記憶/思考)的量小。

#總結

所謂的編碼原則,不是說非遵守不可,我們要去了解它背後的原理,原因然後因地制宜。理論上,如果你是一個天才,可以處理無窮的複雜事物,那麼原則毫無意義。但對於普通人而言,如果事件變得越來越複雜,我們的處理能力是下降的,我們狀態差的時候更是如此。

所以,平時遵守一些原則能提高我們在狀態差時候的處理能力。

最後,我認為“高內聚,低耦合”的內因實際上是減少我們同時需要處理/理解/記憶的程式碼量,以此來提高我們的效率。希望對你有所啟發。