程式碼的印象派:寫點好程式碼吧

發表於2015-05-06

最近有一位獵頭顧問打電話詢問是否有換工作的意向,對推薦的公司和職位的描述為:”我們這裡有一家非常關注軟體質量的公司,在尋找一位不僅能完成有挑戰的軟體開發任務,並且還對程式碼質量有非常高追求的軟體工程師。”。

很難得獵頭顧問會以這樣的切入點來推薦職位,而不是諸如 “我們是網際網路公司”,”我們是著名網際網路公司”,”我們可以提供業內領先的薪資”,”我們創業的方向是行業的趨勢”,”我們提供創業公司期權”,”我們提供人體工程座椅”,”我們公司漂亮妹子多” 等等。

誰會為了把椅子或者每天能看公司漂亮的前臺妹子而跳槽呢?將這些描述可以概括成一點,就是 “我們給的錢多”。誠然,好的薪水是可以招募到傑出的軟體工程師的。然而,優秀的軟體工程師通常已經得到了較好的薪水,所以如果不是給出足夠的量,不一定會為五斗米而折腰。大多數的軟體工程師更看重的是技術興趣,所以諸如 “來我們這做 Openstack 吧”,”我們需要用 Go 實現 Docker 相關元件的人才” 看起來更有吸引力。

而軟體質量,程式碼風格,則是另一個吸引優秀工程師的方向。追求卓越軟體質量的工程師,通常有著自己的軟體設計與實現思路,並在不斷的實踐中錘鍊出自己的程式設計風格和程式碼品味。這一類工程師,其實無關使用什麼語言、做什麼產品,他們會始終保持自己的品味,追求軟體實現的卓越,進而產出高質量的軟體。更重要的是,優秀的工程師希望與更多優秀的工程師合作,並更願意工作在崇尚程式碼質量的氛圍中。

一般條件下,對軟體質量的要求通常與軟體生命週期的長短相關。按照軟體生命週期的長短,我們可以將軟體公司分為兩類:

  • 快公司:創業公司,網際網路公司。推崇快速開發,快速試錯。軟體生命週期較短,程式碼質量相對要求不高。
  • 慢公司:傳統行業軟體公司,產品公司。推崇穩定可靠的軟體設計。軟體生命週期較長,程式碼質量相對要求較高。

軟體生命週期的長短,通常也決定了實現軟體所使用的程式語言。比如,快公司通常會使用 PHP/Python/Ruby 等動態型別語言,而慢公司通常會選擇 C/C++/C#/Java 等靜態型別語言。

當獵頭顧問還沒有說出公司的名字之前,我們也可以大體猜測出該公司所屬的行業方向或軟體產品型別。比如,該公司可能來自傳統的金融、電信、醫療、石油、ERP 行業等,這些行業中有 IBM、Huawei、GE、Schlumberger、SAP 等等世界 500 強巨頭更重視軟體質量。當然,通常推崇軟體質量的公司中,大概率條件下碰到的會是外企,即使是中小外企,也會對軟體質量有相對較高的要求。對於產品型別,越是接近 Mission Critical 的產品形態則對軟體質量的要求越高。各種軟體中介軟體的軟體質量要求也相對較高,比如記憶體資料庫、Message Queue 等。或者如果區分 Framework 和 Application,則 Framework 的軟體質量顯然要求更高。

每一家較成熟的軟體公司,都會設計自己的軟體編碼規範,增強軟體工程師的協同效應,相互之間可以讀懂對方的程式碼。但實際操作中,又有多少人會執著遵守呢?編碼規範並不能保證產生出好程式碼,那程式碼編寫的好壞又如何具體衡量呢?筆者經歷過的公司中,多半都是以軟體釋出後的 Bug 數量來衡量軟體的質量的,這種統計形式簡單粗暴,優點就是可以量化,缺點就是很難評判出軟體程式碼編寫的優雅程度。我聽過一則笑話,說軟體質量也不能做的太好,軟體一定要有 Bug,這樣客戶才會買我們的服務,而我們就是靠後期賣服務賺錢的。好吧,情何以堪~~

好了,說了這麼多,好像文不切題,這篇文章不是叫《程式碼的印象派》嗎?和上面說的這些有什麼關係呀?

關係就在於,軟體質量與程式碼編寫的優雅程度息息相關。而是否能編寫出優雅的程式碼與軟體工程師的個人風格和品味息息相關。

在軟體工程領域,通常生命週期長的軟體會有著更高的軟體質量需求,描述軟體質量的內容可以參考下面兩篇文章。

  • Quality 是什麼?
  • 軟體質量模型

在各種軟體質量模型的描述中,都包含著軟體可維護性(Maintainability)這一屬性。而越是生命週期長的軟體,對其軟體可維護性的要求越高。而提高軟體可維護性的根本方式就是編寫可閱讀的程式碼,讓其他人理解程式碼的時間最小化。程式碼生來就是為人閱讀的,只是順便在機器上執行以完成功能。

在漫長的軟體生命週期中,我們有很多機會去修改軟體程式碼,比如發現了新的 Bug、增加新的功能、改進已有功能等。修改程式碼的第一步當然就是閱讀程式碼,以瞭解當前的設計和思路。如果程式碼都讀不懂的話,何談修改呢?還有,大概率條件下,修復自己實現模組的 Bug 的人通常就是你自己,如果時隔幾個月後自己都讀不懂自己編寫的程式碼,會是什麼感受呢?

所以,如何編寫出易讀的程式碼就成了問題的關鍵。而能否編寫出易讀程式碼,則直接取決於軟體工程師自己的的程式設計風格和程式碼品味。

在《孫子兵法》中有云:”上兵伐謀,其次伐交,其次伐兵,其下攻城。攻城之法,為不得已。” 對應到軟體領域,軟體架構師可以通過出色的系統分析來構建可演進的軟體架構,講究謀略;而軟體工程師則通過良好的設計和程式設計風格來完成攻城任務,講究方法。

Paul Graham 的《黑客與畫家》中描述了黑客與畫家的共同點,就是他們都是創作者,並且都在努力創作優秀的作品。畫家創作的作品就是畫,內嵌著自己的風格和品味。軟體工程師的作品就是軟體和程式碼,如果可以的話,你可以將程式碼列印成卷,出版成書,只是,閱讀的人會向你那樣幸福嗎?

畫家的作品都會保留下來,如果你把一個畫家的作品按照時間順序排列,就會發現每幅畫所用的技巧,都是建立在上一幅作品學到的東西之上。某幅作品如果特別出眾,你往往能在更早期的作品中找到類似的版本。軟體工程師也是通過實踐來學些程式設計,並且所進行的工作也是具有原創性的,通常不會有他人的完美的成果可以依靠,如果有的話我們為什麼還要再造輪子呢?

創作者的另一個學習途徑是通過範例。對於畫家而言,臨摹大師的作品一直是傳統美術教育的一部分,因為臨摹迫使你仔細觀察一幅畫是如何完成的。軟體工程師也可以通過學習優秀的程式原始碼來學會程式設計,不是看其執行結果,而是看原始碼實現思路和風格。優秀的軟體一定都是在軟體工程師對軟體美的不懈追求中實現的,現如今有眾多優秀的開源軟體存在,如果你檢視優秀軟體的內部,就會發現,即使在那些不被人知的部分,也同樣被優美的實現著。

所以說,程式碼是有畫面感的,看一段程式碼就可以瞭解一個軟體工程師的風格,進而塑造出該工程師在你心目中的印象。工作中,我們每天都在閱讀同事們的程式碼,進而對不同的同事產生不同的印象,對各種不同印象的感受也在不斷影響著自身風格的塑造。程式碼的印象派,說的就是,你想讓你的同事對你產生何種印象呢?

筆者不能自詡為我就是那類有著良好的程式設計風格,並且程式碼品味高雅的軟體工程師,只能說,我還在向這個目標努力著。風格和品味不是一朝一夕就能養成的,世間存在多少種風格我們也無法列舉,而說某種風格比另一種風格要好也會陷入無意的爭辯。況且,軟體工程師多少都會有點自戀情節,在沒有見到更好的程式碼之前,始終都會感覺自己寫出的程式碼就是好程式碼,並且有時不管你說什麼,我們就是這個味兒!

我個人總結了幾點關於優雅程式碼風格的描述:

  • 程式碼簡單:不隱藏設計者的意圖,抽象乾淨利落,控制語句直截了當。
  • 介面清晰:型別介面表現力直白,字面表達含義,API 相互呼應以增強可測試性。
  • 依賴項少:依賴關係越少越好,依賴少證明內聚程度高,低耦合利於自動測試,便於重構。
  • 沒有重複:重複程式碼意味著某些概念或想法沒有在程式碼中良好的體現,及時重構消除重複。
  • 戰術分層:程式碼分層清晰,隔離明確,減少間接依賴,劃清名空間,理清目錄。
  • 效能最優:區域性程式碼效能調至最優,減少後期因效能問題修改程式碼的機會。
  • 自動測試:測試與產品程式碼同等重要,自動測試覆蓋 80% 的程式碼,剩餘 20% 選擇性測試。

下面,我會列舉一些我在工作中遇到的不同的程式設計風格,用切身的體會來感悟程式碼的風格和品味。當然,吐槽為主,因為據說在 Code Review 時記錄說 “我擦” 的數量就可以衡量程式碼的好壞。

 

變數

關於變數,很遺憾,不得不提變數的命名。時至今日,在 Code Review 中仍然可以看到下面這樣的程式碼。

有各種奇葩的字首出現,有時同一個人的命名居然也不統一。雖然,眼睛和大腦在重複的觀察變數名後會自動學習以忽略字首,並不會太影響閱讀。實際上,使用字首的目的主要是為了在區域性程式碼中區分全域性變數和區域性變數。使用類似於 C# 這樣的高階語言,我們已經不再需要為變數新增字首了,可以利用 this 關鍵字來區分。如果非要新增的話,建議使用 “_” 單下劃線字首,促進大腦更快速的忽略。

將 Field 標記為 public 應該是沒有分清 Field 與 Property 的作用,進而推測對物件導向程式設計中的封裝概念理解也不會有多好。

使用 “p” 字首的顯然有 C/C++ 程式設計情節,想描述這個變數是一個指標,好吧,這種寫法在 C# 中只能稱為不倫不類。

使用縮寫,這裡的 “sch” 其實是想代表 “schedule”,但在沒有上下文的條件下誰能想的出來呢?我個人是絕對不推薦使用縮寫的,除非是普世的理解性存在,例如 “obj”, “ctx”, “pwd”, “ex”, “args” 這樣非常常見的縮寫。

使用拼音和有拼寫錯誤的單詞作為變數名會直接拉低工程師的檔次。使用合適單詞描述可以直接提高程式碼的質量,比如通常 “Begin”, “End” 會成對兒出現,上面的示例程式碼中涉及到了時間,”StartTime” 和 “BeginTime” 是同義詞,所以我們參考了 Outlook Calendar 中的預設術語,也就是 “StartTime” 和 “EndTime”,也就是找範例。

在區域性變數的使用中,我認為有一種使用方式是值得推薦的,那就是 “解釋性變數”。當今的程式設計風格中流行使用 Fluent API,這樣會產生類似於下面這樣的程式碼。

這一串 “.” 看著好帥氣,但我是理解不了這是要比較什麼。可以簡單重構為解釋性變數。

 

建構函式

很多工程師還沒有理解好建構函式的功效和使用方式,在選擇依賴注入方式時,更傾向於使用屬性依賴注入。個人認為,使用 “屬性依賴注入” 是懶惰的一種表現,其不僅打破了資訊隱藏的封裝,而且還可以暴露了本不需要暴露的部分。使用建構函式進行依賴注入是最正確的方式,我們應該竭盡全力將程式碼重構到這一點。

好的,你說的,我信了!並且,我也開始這麼做了!絕對純淨的建構函式注入!

好吧,你贏了!建構函式居然有 12 個引數,距離史上最長的建構函式不遠了。

一般寫成這樣的程式碼已經表示沒法看了,而且註定類的設計也不怎麼樣,這要是遺留下來的 Legacy Code,不知道維護者心情幾何?

還有一類建構函式問題就是引數順序,這直接體現了軟體工程師最終他人的基本素養。因為建構函式生來就是為使用者準備的,而為使用者設計合理的引數順序是類設計者的基本職責。

上面這種反人類思維的引數順序,怎麼描述呢?寫成下面這樣有多大難度?

 

屬性

蹩腳的屬性設計常常彰顯抽象物件型別的能力。以下面這個 Schedule 類為例,Schedule 業務上存在 Once 和 Recurring 兩種狀態。我們最初看到的類是這個樣子的。

看來這是想通過建構函式直接注入指定狀態,但 IsOnceSchedule 屬性的 set 又是 public 的允許修改,不僅暴露了封裝,還沒有起到隱藏的效果!

那麼,稍微改進下,試圖消滅 IsOnceSchedule 屬性,引入繼承機制。

實現上在 OnceSchedule 和 RecurringSchedule 中均封裝獨立的實現。如果非要通過父類抽象暴露 Recurring 狀態,可以在父類中通過屬性暴露只讀介面。

 

函式

我們或許都知道,函式命名要動詞開頭,如需要可與名詞結合。而函式設計的要求是儘量只做一件事,這件事有時簡單有時困難。

簡單的可以像下面這種一句話程式碼:

複雜的見到幾百行的函式也不新奇。拆解長函式的方法有很多,這麼不做贅述。這裡推薦一種使用 C# 語法糖衍生出的函式設計方法。

上面的小函式其實是非常過程化的程式碼,其是為類 DateTimeOffset 服務,我們可以使用 C# 中的擴充套件方法來優化這個小函式。

這樣,我們就可以像下面這樣使用了,感覺會不會好一些?

在設計函式時,我們時常猶豫的是,到底應該返回一個 null 值還是應該丟擲一個異常呢?

答案就是,如果你總是期待函式返回一個值時,而值不存在則應該丟擲異常;如果你期待函式可以返回一個不存在的值,則可以返回 null。總之,不要因為懶惰而使得應該設計丟擲異常的函式最終返回了 null,不幸的是,這種懶惰經常出現。

正常的程式碼是不需要 try..catch.. 的,異常就應該一拋到底直至應用程式崩潰,當然,這是開發階段。一拋到底有利於發現已有程式碼路徑中的錯誤,畢竟異常在正常邏輯中是不應該產生的。我們要做的是,合理期待某呼叫可能會產生某類異常,則直接 catch 該特定異常,如 catch (System.IO.FileNotFoundException ex)。

實際上,遇到這種抉擇場景,我們可以在函式命名上下些功夫,以變相解決問題。

 

單元測試

在開始寫程式碼的時候就開始考慮測試問題,有利於產生易於測試的程式碼。幸運的是,對測試友好的設計會很自然的產生良好的程式碼。

測試驅動開發(TDD)是一種程式設計風格,包含 TDD 三定律:

  1. 在編寫不能通過的單元測試前,不能編寫生產程式碼;
  2. 只編寫剛好無法通過的單元測試,不能編譯不算通過;
  3. 只編寫剛好通過當前失敗測試的生產程式碼;

我們顯然可以循規蹈矩的遵循上述 TDD 三定律風格程式設計,但 TDD 只是通過測試來保證程式碼質量,驅動良好設計的一種風格,我們沒有必要完全強迫自己遵循上述定律,找到適合自己的過程可能效率更高,所以重點在於,要寫單元測試,通過寫程式碼時思考測試這件事來幫助把程式碼寫的更好。

測試程式碼不是二等公民,它和生產程式碼一樣重要。他需要被思考、被設計、被維護,並且要像生產程式碼一樣保持優雅的風格。

單元測試測什麼?

在單元測試中,可通過兩種方式來驗證程式碼是否正確地工作。一種是基於結果狀態的測試,一種是基於互動行為的測試。這兩種方式在文章《單元測試的兩種方式》中有描述,這裡就不再贅述。

單元測試的可讀性

在測試程式碼中,可讀性仍然很重要。如果測試程式碼的可讀性良好,使其更易於後期的維護和修改,不至於是測試程式碼腐化以致被刪除。

下面是一些良好測試的關鍵點:

  • 測試越簡明越好,每個測試只關注一個點。
  • 如果測試執行失敗,則其應發出有幫助性的錯誤訊息或提示。
  • 使用簡單明確的測試輸入條件。
  • 給測試用例取一個可描述的名字。

那麼,具體什麼樣的單元測試用例名稱,算是好名稱呢?這裡推薦兩種:

  • 第一種:使用 Test_<ClassName>_<FunctionName>_<Situation> 風格;
  • 第二種:使用 Given_<State>_When_<Behavior>_Then_<SomethingHappen> 風格;

第二種實際上是 BDD 風格,其不僅可以應用於單元測試,在更高階的 Component Level 和 System Level 的測試中同樣有效。

實際上,單元測試用例程式碼的內部實現也是有風格可遵循,常見的就是 Arrange-Act-Assert (AAA) 模式。

第三方元件程式碼不便於測試

在文章《類依賴項的不透明性和透明性》中描述了依賴項對單元測試的影響,實踐中,我們碰到最多的是呼叫其他類庫的程式碼而導致的不可測試性。

上面的程式碼,如果寫一個 TestCase 的話,可能是下面這種情況。

這樣,呼叫了 instance.ExecuteJob() 導致了不知道如何驗證。同時,由於 Job 類使用了 sealed 關鍵字,並且沒有實現任何介面,所以也無法通過 mocking 庫來 mock。

解決辦法,增加中間層。

這樣,我們在測試 MyClass 類時,就可以通過 IJob 介面注入 Mock 物件。這裡選用的 Mocking Library 是 NSubstitute,參考《NSubstitute完全手冊索引》。

依賴時間的測試

還有一種較難測試的程式碼是依賴於時間的程式碼。比如,我們有一個依賴於時間的 Trigger 類,簡寫為這個樣子。

測試時,我可能會挑一些特定時間進行測試,特定時間有可能在很遠的未來。

好吧,這個 TestCase 應該是到 2016 年才能執行成功,顯然不是我們期待的。改進的辦法還是增加中間層,增加 IClock 介面用於提供時間。

這樣,我們就可以在 TestCase 程式碼中使用 Mocking 類庫來替換 IClock 的例項,進而指定時間。

 

相關文章