Try   HackMD

Good Code, Bad Code

1. 程式碼品質 (Code quality)

當我們買一台車時,會在意其「品質」,我們希望車子是:

  • 安全的
  • 真的可以行駛
  • 不會分解,並且
  • 行為可預測:當我們踩煞車時,汽車應該減速

如果有人問說是什麼讓汽車品質更好,最有可能得到的答案之一就是它的製造精良,在行駛之前已經過安全性和可靠性測試,並在組裝時正確良好。製作軟體也大同小異,要寫出高品質的軟體(程式碼),我們需要確定它的建構是良好的。

要把程式碼定義成高品質或低品質本質上是一種很主觀且決斷的事情,作者提供了高品質程式碼應達成的四個目標:

1) 程式碼要能運作

在定義「運作(working)」的含義時,需要捕捉所有需求,例如要解決的問題如果對效能特別敏感,那確保程式碼的執行效能就會是「程式碼要能運作」的目標之一,同樣也適用於其它如隱私和安全等等的延伸問題。

2) 程式碼要能持續運作

因以下幾個點原因,今天能運作的程式碼不保證明天一定能運作,因此要確保程式能持續運作,比如寫自動測試。

  • 程式碼可能會依賴於其它程式碼,這些程式碼可能會被修改、更新和變更
  • 任何新功能的需求都有可能要對程式碼進行修改
  • 試圖解決的問題可能會隨時間推移而變化

3) 程式碼要能適應不斷變化的需求

軟體在持續開發過程中需求可能發生變化(商業現況發生變化、消費者偏好發生變化、不斷添加新功能),我們無法精準預測出某段程式碼或軟體會如何隨著時間演變,這邊作者提到兩個極端的案例:

  • 為了讓軟體能適應未來各種需求而耗費時間成本過度設計,時間上可能輸給競品,還可能預測錯誤,到頭來是一場空
  • 沒有考慮到適應性,速成一個只滿足當下需求的程式碼,把多個子問題的解決方案都捆綁在一起,當遇到適應性需求變化時,多半只能打掉重練,惡性循環下也損失了很多時間成本

我們需要在這兩種極端之間找出一個平衡,而後續這本書會提到普遍適用的技術來確保程式碼具備適應性。

4) 不用重新發明輪子

當我們在解決某個問題,並把它拆分成數個子問題時,若有些子問題已經有人解決過了,或是可以用程式語言中內建的功能來完成,請使用現成的解決方案而不是重新再發明一次,一來節省時間成本,二來這些程式碼應都已經過徹底測試且被廣泛使用,出錯機率低。

程式碼品質的支柱

為了達成上述四個目標,作者給出更具體的六個策略:

1) 讓程式碼可讀

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

可讀性差的程式碼讓我們難以理解以下內容

  • 做了什麼
  • 如何做到的
  • 需要什麼成分 (輸入或狀態)
  • 執行後能得到什麼

在團隊協作上,可讀性差的程式碼高機率會在 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 在幹嘛

int calculateSum(List<int> list) => list.fold(0, (a, b) => a + b);

然而這段雖然多行,但多數人可能只需花費較少的時間即能讀懂這段程式

int calculateSum(List<int> list) {
  int sum = 0;
  for (int i = 0; i < list.length; i++) {
    sum += list[i];
  }
  return sum;
}

2) 避免意外的驚訝

一段用來解決特定問題的程式碼,它只須解決「預期」的問題並得到「期望」的結果就好,不要做任何在「問題」範圍外的多餘操作,這只會帶來不預期的結果,導致讓使用這段 code 的人驚訝,然後再花費不必要的時間來修正,後續章節會詳述這個問題。

3) 讓程式碼不會被誤用

程式碼被呼叫是有緣由的,呼叫錯誤的程式碼可能引起不預期的行為。因此要讓程式碼難以或不可能被誤用來盡量提高運作執行和持續能運作的機會,第三章程式碼契約及第七章會有更具體的介紹。

4) 讓程式碼模組化

模組化意味著某個物件或是系統是由多個獨立交換或可替代的小元件組成,透過明確定義每個元件的介面,元件與元件間相互交流的點越少越好,意味著當需求改變時,你只要改動與之相關的元件,並能很輕鬆地把改動部分替換上去(因為介面的交流點少),作者用下圖描述模組化的好處,當今天有一個新需求要將玩具的手掌加入手指,哪個會比較好做、且不會破壞到玩具呢?

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

5) 讓程式碼可重用和可泛化

code 寫得越多,錯誤的機率就越多,若寫一組程式能解決不同場景中的相同問題,不要重複寫,能重用就重用。

可泛化的意思是程式碼具有通用性,比如:如果要寫一段函式,找出給定 list<int> 中某個特定值的第一個 index, 可以這樣寫

int indexOf(List<int> list, int value) {
  for (int i = 0; i < list.length; i++) {
    if (list[i] == value) {
      return i;
    }
  }
  return -1;
}

但如果套上泛型 (generic type) 的概念,這段程式碼就不限於只能找含有 int type list 的 index 了。

int indexOf<T>(List<T> list, T value) {
  for (int i = 0; i < list.length; i++) {
    if (list[i] == value) {
      return i;
    }
  }
  return -1;
}

6) 讓程式碼可測試且能正確測試

為了確保錯誤不會在發佈出去後才被發現,必須先做測試,以人為測試來說,人都會犯錯,且軟體系統通常很龐大,不是每個人都能了解每個細節,因此「自動測試」就顯得很重要了。

如果有寫自動測試的習慣,當程式碼隨著需求逐漸壯大的情況下,每次的需求更動你都能更有信心的去改 code, 如果不小心破壞到了原本的邏輯,錯誤通常能在自動測試時被抓到。

為此作者提倡在寫程式時就因同時思考怎麼寫才能讓程式是「可測試」的,後面章節會有更詳細介紹。

第一章小結

  • 要建立好的軟體,需要編寫高品質的程式碼
  • 程式碼在成為外面執行的軟題之前,通常必須通過幾個階段的檢查和測試
  • 這些檢查有助於防止錯誤和損壞的功能送到使用者或關鍵業務系統中
  • 在寫程式時的每個階段都要考量測試是很好的習慣,不應把測試視為將來才要處理的工作
  • 編寫高品質程式碼最初可能會減慢開發速度,但中長期來看通常能加快開發時間

2. 抽象層 (Layers of abstraction)

什麼是層

:bulb:程式開發的目的是解決問題,通常會將一個問題分解為多個子問題,在思考每個子問題的解決方案時就會逐步建構出一系列的「層」。

在軟體領域,一個問題可以被視為高層次或低層次問題,並且是相對的。

  • 高層次問題:涉及了解整體和程式的目標,通常不關心實作細節,而只關心結果,例如:「建立一個可讓使用者分享照片的系統」,因為較少涉及技術細節,它對非技術開發者更容易理解。

  • 低層次問題:對於問題和解決方案更具體、更詳盡的表示,著重於實作細節,例如「將兩個數字相加」。

以「建立一個可讓使用者分享照片的系統」為例,可以分解為「讓使用者上傳照片」、「顯示照片給使用者」、「讓使用者評論」等低層次問題。每個子問題都可以獨立視為一個高層次問題,再分解對應的子問題,形成如以下的樹狀圖:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

什麼是抽象層

以「傳訊息到伺服器」為例,將這個問題分解成子問題後,我們寫了這段程式:

HttpConnection connection = HttpConnection.connect("https://example.com/server");
connection.send("Hello server");
connection.close();

從高層次來看這似乎是個很簡單的問題,程式只有三行,解決辦法看起來也很簡單。

但實際情況並非如此,要從客戶端把字串「Hello server」傳送到伺服器所牽涉的問題範圍很廣。為了解決「傳訊息到伺服器」這點,需要解決很多子問題,對我們來說幸運的是已經有其他工程師解決了所有這些子問題。他們不僅解決了問題,還以一種我們不需要意識到的方式來幫我們處理了這些工作。

在我們不知道 HTTP 連線是如何運行的前提下,只寫了三行 code 就能解決問題,那「HTTP 連線」對於我們來說就是一個「抽象概念」;同樣地,實作 HTTP 協定的工程師可能也不需要去知道資料是如何調變成無線電訊號。這就是所謂的「抽象層」,下圖顯示傳訊息到伺服器所牽涉的一些抽象層概念。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

為何要建立抽象層

:bulb:建立抽象層是為了分離問題的關注,關注於子問題的行為目的,而不必關注背後的實作細節

:bulb:乾淨的抽象層也意味著只需了解幾個概念就可以解決我們關心的高層次問題

另外建立乾淨的抽象層可以滿足第一章提到的四個提升程式碼品質的策略:

  • 程式碼可讀:通過將子問題解決方案的程式碼組織成高層次的抽象概念,工程師可以更容易理解和使用它

  • 模組化:通過抽象層清晰地劃分子問題,替換實作方法更為容易,不會影響其他層。在上述 HTTP 連線範例中,處理實體資料傳輸的部分可能已經模組化,當使用者在 WiFi 連線下使用模組 A,在行動網路下則使用模組 B,在更高層次的程式碼不需做任何事情來適應這種使用情境的變化。

  • 可重用和可泛化:以 HTTP 連線範例為例,系統內處理 TCP/IP 和網路連線抽象層可以泛化通用到其他連線類型,如 WebSocket 所需處理的子問題。

  • 可測試性:測試一個解決方案的前提在於確保每個子問題的行為都符合預期,通過把子問題的解決方案劃分成乾淨的抽象層,使測試更為容易。

如何建立抽象層

:bulb: 把程式寫成迷你 API 供其他程式使用,以 API 的角度來思考程式碼有助於建立清晰的抽象層

舉例來說,如果你經常呼叫 HTTP GET request,你可能會想了解它的背後邏輯。但是,當你深入查看這個方法時,你可能會發現以下的程式碼(以 Dart 為例):

/// The interface for HTTP clients that take care of maintaining persistent
/// connections across multiple requests to the same server.
abstract class Client {

  /// Sends an HTTP GET request with the given headers to the given URL.
  Future<Response> get(Uri url, {Map<String, String>? headers});
  
  ...
}

這就是一個抽象層的範例,它隱藏了實作細節,只需要讓呼叫方知道必要的概念,易於呼叫方使用。

API 和實作細節

在設計應用程式介面 (API) 時,往往要考量兩個面向:

  • 呼叫方會看到的內容
    • 我們公開了哪些類別、介面和函式
    • 名稱、輸入參數和返回型別中暴露了哪些概念
    • 呼叫方需要知道以正確使用程式碼的任何額外資訊 (例如呼叫順序)
  • 呼叫方不會看到的東西
    • 實作細節

這樣做就能有條理地提供服務呼叫方所需知道的相關概念,而所有的實作細節都隱藏在 API 後面。

:bulb: 我們可以使用函式、類別和介面 (及其他程式語言類似單元),把程式碼分解為抽象層

函式(Functions)

  • 執行單一任務
  • 透過呼叫其他命名良好的函式組合處理更複雜的行為

類別(Classes)

  • 內聚:類別中所有成員(包括方法、屬性等)都有清晰的目的來達成了共同的目標,意圖明確,理想情況下只關注一件事,以此降低類別的複雜度。
  • 關注點分離:一個類別只負責處理一個問題(或關注點),類別與類別之間應做好良好的關注點分離,降低彼此的依賴性(耦合)。

:x: 低內聚高耦合

:heavy_check_mark: 高內聚低耦合

:x: 超高內聚低耦合

介面(Interfaces)

  • 在「層」中定義介面來提供公開的屬性和方法的抽象概念,不提供任何實作細節
  • 在「層」中定義實際類別來實作介面
  • 在「層」之上的程式碼只依賴介面,不依賴實際類別

:bulb: 建立抽象層(介面)有很多好處,但也不應過度使用

不是所有程式都需要放在介面後,比如當「層」很薄、解決方案不太會有一個以上的實作類別、解決方案不太會被重用時,可以考慮不要介面化,因為過度的介面化會導致:

  • 寫更多程式碼,且每次新增方法時要在兩個地方(介面與實作)裡同時新增
  • 增加不必要的複雜度,每當工程師要理解程式碼時,不能直接進到層下的實作類別,必須先到介面,然後再找出實作該介面的具體類別