當我們買一台車時,會在意其「品質」,我們希望車子是:
如果有人問說是什麼讓汽車品質更好,最有可能得到的答案之一就是它的製造精良,在行駛之前已經過安全性和可靠性測試,並在組裝時正確良好。製作軟體也大同小異,要寫出高品質的軟體(程式碼),我們需要確定它的建構是良好的。
要把程式碼定義成高品質或低品質本質上是一種很主觀且決斷的事情,作者提供了高品質程式碼應達成的四個目標:
在定義「運作(working)」的含義時,需要捕捉所有需求,例如要解決的問題如果對效能特別敏感,那確保程式碼的執行效能就會是「程式碼要能運作」的目標之一,同樣也適用於其它如隱私和安全等等的延伸問題。
因以下幾個點原因,今天能運作的程式碼不保證明天一定能運作,因此要確保程式能持續運作,比如寫自動測試。
軟體在持續開發過程中需求可能發生變化(商業現況發生變化、消費者偏好發生變化、不斷添加新功能),我們無法精準預測出某段程式碼或軟體會如何隨著時間演變,這邊作者提到兩個極端的案例:
我們需要在這兩種極端之間找出一個平衡,而後續這本書會提到普遍適用的技術來確保程式碼具備適應性。
當我們在解決某個問題,並把它拆分成數個子問題時,若有些子問題已經有人解決過了,或是可以用程式語言中內建的功能來完成,請使用現成的解決方案而不是重新再發明一次,一來節省時間成本,二來這些程式碼應都已經過徹底測試且被廣泛使用,出錯機率低。
為了達成上述四個目標,作者給出更具體的六個策略:
可讀性差的程式碼讓我們難以理解以下內容
在團隊協作上,可讀性差的程式碼高機率會在 code review 時無法發現錯誤,他人對程式碼的掌握度低,在修改程式碼時也可能引入新的錯誤。
引用 Robert C. Martin 在 Clean Code 裡提到的內容:
Indeed, the ratio of time spent reading versus writing is well over 10 to 1. We are constantly reading old code as part of the effort to write new code.
我們平均花費在讀 code 跟寫 code 的時間比例約等於 10:1, 投資在讓程式碼易讀所帶來的效益絕對划算。
我想起以前曾經讀過的一些內容說明程式碼寫的越 boring 越好,為什麼呢?有時為了讓程式碼簡潔我們可能會寫出很 fancy 的 code 來解決問題,但如果考量到可讀性,其實越 boring 的程式碼通常可讀性越高,為什麼「無聊」?因為你可以馬上讀懂,不需要花更多時間去了解
以下這段 code 很簡潔,懂的人就懂,但對於不懂 fold
的人需花費更多時間暸解這段 code 在幹嘛
然而這段雖然多行,但多數人可能只需花費較少的時間即能讀懂這段程式
一段用來解決特定問題的程式碼,它只須解決「預期」的問題並得到「期望」的結果就好,不要做任何在「問題」範圍外的多餘操作,這只會帶來不預期的結果,導致讓使用這段 code 的人驚訝,然後再花費不必要的時間來修正,後續章節會詳述這個問題。
程式碼被呼叫是有緣由的,呼叫錯誤的程式碼可能引起不預期的行為。因此要讓程式碼難以或不可能被誤用來盡量提高運作執行和持續能運作的機會,第三章程式碼契約及第七章會有更具體的介紹。
模組化意味著某個物件或是系統是由多個獨立交換或可替代的小元件組成,透過明確定義每個元件的介面,元件與元件間相互交流的點越少越好,意味著當需求改變時,你只要改動與之相關的元件,並能很輕鬆地把改動部分替換上去(因為介面的交流點少),作者用下圖描述模組化的好處,當今天有一個新需求要將玩具的手掌加入手指,哪個會比較好做、且不會破壞到玩具呢?
code 寫得越多,錯誤的機率就越多,若寫一組程式能解決不同場景中的相同問題,不要重複寫,能重用就重用。
可泛化的意思是程式碼具有通用性,比如:如果要寫一段函式,找出給定 list<int>
中某個特定值的第一個 index, 可以這樣寫
但如果套上泛型 (generic type) 的概念,這段程式碼就不限於只能找含有 int type list 的 index 了。
為了確保錯誤不會在發佈出去後才被發現,必須先做測試,以人為測試來說,人都會犯錯,且軟體系統通常很龐大,不是每個人都能了解每個細節,因此「自動測試」就顯得很重要了。
如果有寫自動測試的習慣,當程式碼隨著需求逐漸壯大的情況下,每次的需求更動你都能更有信心的去改 code, 如果不小心破壞到了原本的邏輯,錯誤通常能在自動測試時被抓到。
為此作者提倡在寫程式時就因同時思考怎麼寫才能讓程式是「可測試」的,後面章節會有更詳細介紹。
:bulb:程式開發的目的是解決問題,通常會將一個問題分解為多個子問題,在思考每個子問題的解決方案時就會逐步建構出一系列的「層」。
在軟體領域,一個問題可以被視為高層次或低層次問題,並且是相對的。
高層次問題:涉及了解整體和程式的目標,通常不關心實作細節,而只關心結果,例如:「建立一個可讓使用者分享照片的系統」,因為較少涉及技術細節,它對非技術開發者更容易理解。
低層次問題:對於問題和解決方案更具體、更詳盡的表示,著重於實作細節,例如「將兩個數字相加」。
以「建立一個可讓使用者分享照片的系統」為例,可以分解為「讓使用者上傳照片」、「顯示照片給使用者」、「讓使用者評論」等低層次問題。每個子問題都可以獨立視為一個高層次問題,再分解對應的子問題,形成如以下的樹狀圖:
以「傳訊息到伺服器」為例,將這個問題分解成子問題後,我們寫了這段程式:
從高層次來看這似乎是個很簡單的問題,程式只有三行,解決辦法看起來也很簡單。
但實際情況並非如此,要從客戶端把字串「Hello server」傳送到伺服器所牽涉的問題範圍很廣。為了解決「傳訊息到伺服器」這點,需要解決很多子問題,對我們來說幸運的是已經有其他工程師解決了所有這些子問題。他們不僅解決了問題,還以一種我們不需要意識到的方式來幫我們處理了這些工作。
在我們不知道 HTTP 連線是如何運行的前提下,只寫了三行 code 就能解決問題,那「HTTP 連線」對於我們來說就是一個「抽象概念」;同樣地,實作 HTTP 協定的工程師可能也不需要去知道資料是如何調變成無線電訊號。這就是所謂的「抽象層」,下圖顯示傳訊息到伺服器所牽涉的一些抽象層概念。
:bulb:建立抽象層是為了分離問題的關注,關注於子問題的行為目的,而不必關注背後的實作細節
:bulb:乾淨的抽象層也意味著只需了解幾個概念就可以解決我們關心的高層次問題
另外建立乾淨的抽象層可以滿足第一章提到的四個提升程式碼品質的策略:
程式碼可讀:通過將子問題解決方案的程式碼組織成高層次的抽象概念,工程師可以更容易理解和使用它
模組化:通過抽象層清晰地劃分子問題,替換實作方法更為容易,不會影響其他層。在上述 HTTP 連線範例中,處理實體資料傳輸的部分可能已經模組化,當使用者在 WiFi 連線下使用模組 A,在行動網路下則使用模組 B,在更高層次的程式碼不需做任何事情來適應這種使用情境的變化。
可重用和可泛化:以 HTTP 連線範例為例,系統內處理 TCP/IP 和網路連線抽象層可以泛化通用到其他連線類型,如 WebSocket 所需處理的子問題。
可測試性:測試一個解決方案的前提在於確保每個子問題的行為都符合預期,通過把子問題的解決方案劃分成乾淨的抽象層,使測試更為容易。
:bulb: 把程式寫成迷你 API 供其他程式使用,以 API 的角度來思考程式碼有助於建立清晰的抽象層
舉例來說,如果你經常呼叫 HTTP GET request,你可能會想了解它的背後邏輯。但是,當你深入查看這個方法時,你可能會發現以下的程式碼(以 Dart 為例):
這就是一個抽象層的範例,它隱藏了實作細節,只需要讓呼叫方知道必要的概念,易於呼叫方使用。
在設計應用程式介面 (API) 時,往往要考量兩個面向:
這樣做就能有條理地提供服務呼叫方所需知道的相關概念,而所有的實作細節都隱藏在 API 後面。
:bulb: 我們可以使用函式、類別和介面 (及其他程式語言類似單元),把程式碼分解為抽象層
:x: 低內聚高耦合
:heavy_check_mark: 高內聚低耦合
:x: 超高內聚低耦合
:bulb: 建立抽象層(介面)有很多好處,但也不應過度使用
不是所有程式都需要放在介面後,比如當「層」很薄、解決方案不太會有一個以上的實作類別、解決方案不太會被重用時,可以考慮不要介面化,因為過度的介面化會導致: