clean code(無瑕的程式碼)心得

僅節錄我認為有助益的。(書中有蠻多java的重構範例)

Chap01 動機

  1. 神就藏在細節裡: 一致性的縮排是程式低錯誤率的最顯著指標
  2. Later equals NEVER: 不及時清理 → 累積愈多難看的程式碼 → 愈難清理,所以更不想清理 → 直到修改的成本太高,只好重寫
  3. 不夠好的程式碼使維護成本太高(你看得懂自己寫的code嗎)

讓開發速度變快的方法:隨時保持clean code

Chap02 Clean Code 的定義

認為自己的code應該要有的樣子

CleanCode學派(作者)對此的定義

  • 每個函式、類別、模組都能表達單一意圖,降低程式相依性
  • 易讀:不該使人猜測程式的意思
    • 因為在寫新的程式碼前,要先花時間了解舊程式碼
    • 每個看到的程式,執行結果都與你想得差不多
  • 抽象化:程式碼不重複

Chap03 原則

任何原則在特殊情形都是可以違反的

  • 童子軍規則:離開的code比剛來時更乾淨
  • 寫軟體如同寫作,先把想法寫下來,然後開始啄磨,直到讀起來很通順。第一份初稿通常是粗糙而雜亂無章的,修改之後才會改善到想要的樣子。程式設計大師在寫程式時,並不認為自己是在寫程式,而是在說故事。
  • 寫程式時,只能專注在 「讓程式運作」或「讓程式整潔」其中之一,要先程式能動再清理;或是讓程式架構明確易懂再實作都可以。

(1)有意義的命名

  • 有辦法唸出來的名稱,愈具體愈好
  • 用 define, enum, const 代替 常數
    • enum color{black, white}
    • 86400 → SECOND_PER_DAY
    • 容易了解的數字就不用
      • circumference = radius * Math.PI * 2: 不用將2換成實際名稱
  • class:名詞或名詞片語
    • board → chessGameBoard
    • address → portAddress, EmailAddress
    • d → elapsedTimeInDays
  • method:動詞或動詞片語
    • flag → isFlagged()
    • Complex c = Complex(23.0)Complex c = Complex.FromRealNumber(23.0)
    • 使用靜態工廠
  • 對特定功能使用一致的用詞
    • fetch, retreive, get …
  • 避免 data, info, manager 等意義較廣的字
    • accountList → accountGroup (除非此變數真的是list型態)
    • add() → insert(), append()
  • 使用專有名詞
    • jobqueue
    • timestamp
    • 發明一個專用的命名標準(普及語言)
  • 使用範圍較大的變數用較長的名稱
    • 愈少用的函數名稱可以愈長
    • for迴圈內的變數可以用i
      1
      2
      3
      4
      5
      6
      7
      //which is better?
      for(int i = 0; i < RowNumber; i++)
      for(int j = 0; j < ColumnNumber; j++)
      ...
      for(int r = 0; r < RowNumber; r++)
      for(int c = 0; c < ColumnNumber; c++)
      ...

(2)函式

  • 一個函式只做在同一層級上的一件事情
    • 以「無法再分割」為標準
  • 長度:小於二十行(或一個螢幕的長度)
  • 不用switch:switch容易違反
    • 單一職責原則
    • 開放閉合原則
    • 解法:抽象工廠
      • 使用switch和多型
  • 參數
    • 愈少愈好($\leq$ 三個)
      • 太多參數時需要記順序
      • e.g., strcpy()
      • 例外:四維象限中的座標(只算是一個參數),printf()(只算是兩個參數)
    • 避免用參數當回傳值
      • void saveResult(FILE* f, int* returnStatus),使用returnStatus來作為回傳
      • 解法一:呼叫擁有變數的class,用其method來修改
      • 解法二:用return
      • 用例外處理取代回傳錯誤碼
    • 通常只使用一個參數
      • 用途一:取得參數的性質
        • boolean isFileExists("MyFile")
      • 用途二:使用參數,然後回傳操作後的結果
        • InputStream fileOpen("MyFile")
    • 避免flag
      • 代表函式不只做一件事
        • 有flag和沒有flag做的事不同
      • render(bool isSuite)renderForSuite() + renderForSingleTest()
    • 減少參數的方法
      • 將多個參數合成一個class
      • 拆成多個函式
  • 命名
    • 以「不用重複查看函式定義」為原則
      • write(name) → writeNameField(name)
      • assertEquals(expected, actual) → assertExpectedEqualsActual(expected, actual)
    • 描述可能的副作用
      • getOOS()createOrReturnOOS(): 呼叫時若無OOS的
      • ,可能會create
  • 指令(command)與查詢(query)分離
    • 此函式同時有查詢和進行動作的功能: if(setAttribute("username", "unclebob")) ...
    • 改進: if(attributeExists("username")) setAttribute("username", "unclebob");
  • 適當的靜態(static)宣告
    • 靜態方法用到的資料都從參數而來,而不是從任何擁有這個方法的物件得來
  • 結構化程式設計準則
    • 每個函式的區塊都應該只有一個進入點和一個離開點(沒有break, continue, goto,只有一個return)
    • 在函式夠短的情況下沒有必要
  • 暴露時序耦合
    • 必須先A()再B()的函式
    • C = A(); B(C);
    • 透露出真實的復雜度

(3)註解

  • 註解是輔助程式碼來表達意圖的工具
    • 有註解代表程式碼不夠易懂
    • 愈少愈好
    • 與其寫註解,不如把程式碼弄整潔
  • 註解通常缺少維護
    • 容易產生許多過時的註解
    • 錯誤的註解比沒有註解可怕
  • 必要的註解
    • 版權宣告
    • 舉例示範
    • 解釋意圖
      • 對某個問題的解決方法
      • 使用的演算法
    • 解釋自己無法修改的程式碼(函式庫等)
    • 警告
      • 不希望被修改的地方
    • TODO
  • 糟糕的註解
    • 浪費時間看,最後被忽略
      • 沒有提供更多資訊
        • printBoard() // print board
      • 過多的資訊
      • 被強迫寫的
    • 已被版本控制軟體取代的功能
      • 版本變動記錄
      • 被註解的程式碼
    • 過度使用標誌:(示範:// comment //////////////////)

(4)排版

  • 偏好小檔案(200-500行)
  • 報紙型編排:先出現標題(高階概念、演算法),再來是內容(低階函式)
    • 最重要的概念先出現,用最少的資訊來表達,再來才是實作細節
    • C, C++需要先宣告函式才可使用
  • 垂直距離:類似的概念應盡可能靠近
    • 空白行用來分隔思緒,概念(類似文章分段)
      • 做相似工作的函式愈近愈好
    • 變數宣告的位置:靠近變數被使用的地方
      • 若函式夠短,可在函式最上方宣告
    • 迴圈的控制變數:在迴圈內宣告
      • for(int i = 0, ...)
    • 實體變數(instance variable)
      • C++ 通常放在類別的最下方(scissors rule)
      • java 通常放在類別的最上方
    • 降層準則
      • 函式後面為其呼叫的函式,易於閱讀
  • 將常數宣告放在一個大家比較容易找到的地方。
  • 寬度:不要超過螢幕
    • 強調運算子的優先權
      • b*b - 4*a*c

(5)物件及資料結構

你知道的太多了…

變數保持私有的理由,不希望有人依賴此變數,保持一個自由的空間,讓我們能自由的更改變數的型態,或是在突如其來的奇想或衝動時,能自由的變更實現內容的程式碼。
那為什麼有這麼多的程式設計師,自動替他們的物件加上getter和setter,讓他們的私有度數如同公用變數呢。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Point {
public double x;
public double y;
}

public interface Point {
double getX();
double getY();
void setCartesian(double x, double y);
double getR();
double getTheta();
void setPolar(double r, double theta);
}

你無法分辨這個實現過程是使用直角座標系,還是極座標系,或者兩者皆非!
利用抽象化的詞彙來表達資料。這並不是只透過介面及讀取、設定函式就能完成。想辨法找到最能詮釋「資料抽象概念」的方式。
而最差的作法,則是天真地加上讀取函式及設定函式而已。

1.物件

  • 變數為私有,函式為公用
  • 將實現的過程隱藏(封裝)
  • 用抽象詞彙表達資料
    • getGallonsOfGasoline()getPercentFuelRemaining()
  • 常數宣告:放在適當的層級
    • 將預設的常數放在呼叫的參數,而非被呼叫的函式內
      • getPageNameOrDefault(request, "FrontPage") //default is “FrongPage”
    • 放在愈高階就愈容易進行修改
  • 要讓每件事物都是一個物件是一個神話(Java表示:)

2.資料結構

  • 曝露資料(public),沒有顯著的函式
  • 為資料結構設getter和setter是多此一舉
  • 如 map, set, array …

互補性

  • 物件:新資料型態的彈性 ↔ 資料結構:新行為的彈性
    • 資料結構容易添加函式,而不用更改現有的資料結構
    • 物件容易添加新的類別(繼承),而不用更改現有的函式
  • 混合體只會得到兩者的缺點

The law of Demeter

  • 模組不該知道關於它所操縱物件的內部運作
  • 違反原則: 火車連結
    • String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath()
    • 改進(仍然知道底層操作):Options opts = ctxt.getOptions();, File scratchDir = opts.getScratchDir();, String outputDir = scratchDir.getAbsolutePath();
    • 若 ctxt, options 是資料結構,則以下可行
      • outputDir = ctxt.options.scratchDir.absolutePath
    • 若是物件,則我們應該要告訴 ctxt 去做 某件事情
      • 和其他程式碼合併 : BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName)

可調整的資料應放在高層次

  • 底層不應存放參數
  • 放在高階層比較好找,也比較好修改

One Switch原則:

  • 對於給定的變數,不應該有超過一個以上的switch敘述。在那個唯一的Switch敘述中的多個case,必須建立多型物件以取代其他case。

(6)錯誤處理

  • 定義正常的流程
    • 使用特殊情況物件(special case pattern)替代if()檢查
      • 將特殊情況包在特殊情況物件
      • 特殊情況物件處理例外
    • 包裹第三方程式庫
      • 減少依頼,容易更換
  • 使用 例外處理(try-catch-finally) 取代回傳error code
    • 例外處理是「一件事」
      • error code 必須在呼叫結束之後立即檢查錯誤
      • 提取try和catch的內容,成為新函式
      • 用class包裹例外,此class只處理例外
  • 不要傳遞NULL
    • 要檢查值是不是NULL,很麻煩
      • 所有函式都不return NULL → 都不用檢查
    • 解決
      • 回傳特殊情況物件
        • 例:找不到時,回傳空list
      • 使用例外處理
  • Java:使用不檢查型例外:, (檢查和不檢查型例外), 因為其破壞開放閉合原則,修改低階函式時也需要修改高階函式

將邊界條件的處理放置於同一個地方,不要散佈在程式的各個角落。
nextLevel 取代 level+1

(7)邊界

避免在公用API使用介面(如Map)

使用第三方API時

  • 學習性測試
    • 寫一些測試程式來了解第三方軟體
    • 第三方軟體改版後也能用來確定行為是否改變
  • 包裹第三方API
    • public void open() { try{innerPort.open();} ... }
    • 好處
      • 掌握控制程式的權利
      • 在API未知的情況可以繼承此interface,創造一個Fake API來測試

(8)單元測試

有了測試程式(和版本控制系統)就不會害怕修改程式!

測試驅動開發(TDD)
(2)

  1. 先寫測試程式,再寫對應的實作程式
  2. 只寫剛好無法通過的單元測試
    1. 測試無法通過時,應該要修復實作程式
  3. 只寫剛好能通過測試的程式
    1. 測試無法通過時,只能寫和測試有關的實作程式,不能寫其他實作程式

測試程式和產品程式一樣重要

範圍

  • 使用Coverage Tool(涵蓋率工具)
    • 檢查是否每個函式都有被執行過
  • 測試邊界條件

原則

  • 測試的程式碼也需要整潔
    • 最重要的是可讀性,效率是其次
  • 建造-操作-檢查(BUILD-OPERATE-CHECK)
    • 產生數據,操作之,再檢查正確性
    • GIVEN-WHEN-THEN
      • Given: 前提、環境
      • When: 發生事情
      • Then: 預期結果
  • 一個測試只用一個assert
  • 在程式錯誤附近進行詳盡的測試
    • 因為Bug往往聚集

測試程式的 F.I.R.S.T. 法則

  • Fast:快速(足夠快即可)
  • Independent:可個別獨立執行
  • Repeatable:可在任何環境重複執行
  • Self-Validating:輸出bool值,而不是記錄檔
    • 不用額外檢查
  • Timely:先寫測試再實作

(9)類別

計算職責的數量:單一職責原則

  • 每個class只能有一個修改的理由
  • 凝聚性:方法內使用愈多變數代表這個方法更屬於這個類別
  • 保持凝聚性就會產生許多小類別

把工具放在有許多標記的小型工具箱裡,比少量的大抽屜,然後將所有的東西都丟進去好。

開放閉合原則:類別是可擴充,但不可修改的
相依性反轉原則 (DIP, Dependency Inversion Principle)

  • 類別應該相依於抽象概念而非具體細節
    • 當細節變動時,類別如果相依於具體類別的細節,就會產生風險
    • 用介面和抽象類別來隔離
  • 細節應該依賴抽象,抽象不該依賴細節

一種解法:
將public interface method 重構到 extend class

不應該對相依的模組有預先的假設(即邏輯上的相依),應該清楚的詢問所需的有關訊息(即實體上的相依)。

(10)系統

如同建造城市,某些人負責整體規劃,其它人專注在細節執行。
進行抽象化和模組化

將所有關注的事分離開來
e.g. 延遲初始(Lazy Initialization):物件的建立會延後到第一次使用為止
但是每個物件都要做的話,產生很多重複

  • 將建造和使用分離
    • main()建造物件,並將物件傳送至應用程式。應用程式只需要使用
    • 有時候應用程式要決定何時產生物件
      • 抽象工廠
        • main()開工廠,集中生產,並由應用程式呼叫

相依性注入(1)(2)

  • 控管反轉
    • 將某件事的第二個職責移到其它專注於該職責的物件裡

擴大

  • 誰有辦法預期小鎮的成長,而在鎮上先鋪好六線道高速公路?
    • 讓系統一開始就做對,是一個神話
    • 應該只實現今天的故事(即目前的需求),然後重構它,並且明天再進行系統的擴充
  • AOP(1)(2)
    • 剖面(aspect)
      • 系統中哪些點的行為,需要用一致性的方式修改,以支援某個關注點
      • 如日誌記錄(log)、使用資料庫、安全性、暫存快取
      • 為應用程式基礎架構
    • 保持適當的關注點分確
    • Java可用AspectJ

不需要「先作大型設計」,因為這個設計阻止你去改進,可能是因為不希望浪費先前的努力。

有時候拖延至最後一刻是最好的作法,這讓我們得以運用可能是最好的資訊進行選擇。

剖面範例

  1. Java代理機制
    1. 代理可以呼叫被代理物件的函式,也可以進行剖面的行為
  2. Java AOP框架
    1. Spring AOP
    2. JBoss AOP
  3. AspectJ

一個理想的架構,包含模組化的關注點,每個關注點都用一個普通物件實作。 不同領域之間利用最小侵入性的剖面工具整合。 這樣的架構就可以是測試驅動的(test-driven),如同程式碼一樣。

系統需要特定領域的語言(Domain-Specific Language), 讓領域專家可以把程式寫得像是散文的結構,而且領域概念和實作程式碼相似,減少轉換錯誤。

(11)簡單設計守則

  1. 執行完所有的測試
  2. 程式重構
    1. 消除重複
      1. 樣版方法(Template Method)
      2. 過去十五年出現的大部份設計模式,都是在提供消除重複的策略
      3. Codd Normal Forms是用來消除資料庫綱要重複性的策略
      4. 物件導向是用來組織模組和消除重複的策略
    2. 具表達力
      1. 持續嘗試
        1. 用心照顧你的程式
    3. 最小化類別及方法數量
    4. 重要性 1 > 2 > 3

(12)平行化

物件是處理過程的抽象化,執行緒是排程的抽象化

將「做什麼」和「何時做」分解開來,像是Web應用的Servlet模型
當獲得一個網頁請求時,Servlet會以非同步方式執行,不需要管理所有的請求,每一個servlet都在其自我的小小世界裡執行。

缺點

  • 需要修改程式碼/架構
  • 速度不一定變快(有overhead)
  • 很難寫正確
    • 很難重現bug

建議

  • 將平行化的程式碼和其他程式碼分開
  • 保護資料
    • 限制共享資料的存取次數
      • 減少critical section的使用次數
    • 限制資料的視野
      • mutex lock(c), synchronized(java)
      • 避免在一個共享物件上使用多個方法
    • 使用資料的複本
      • 由一個執行緒負責合併結果
      • 每個執行緒盡可能獨立運行
  • 測試
    • 偽造/產生失敗以測試
    • 讓平行化的程式可以開關平行化的功能
    • 在不同的平台測試
    • 執行比處理器數量還多的執行緒
      • 強迫進行task swapping
    • 調整參數(執行緒數量、重複執行次數)
    • 正確的關閉程式
      • 防止deadlock
    • 用IBM contest找BUG
      • 不一定會照順序執行

實作

  • ReentrantLock
    • 一般的鎖
  • Semaphore號誌
    • 有計數功能的鎖
  • CountDownLatch
    • 先等待指定數量的事件,使得所有的執行緒都有公平的機會,在同時間啟動

模型

  • 生產-消費
    • 有限資源
    • 生產者放工作入queue
    • 消費者取出工作
  • 讀取-寫入
    • 平衡reader和writer的需求,避免某一方飢餓(starvation)
    • 簡單方法:writer等到沒有reader使用時再write
      • writer容易starve
  • 哲學家用餐
    • 雙手都拿到刀叉時才能用餐
    • 大程式的資源爭奪問題

(13) 程式碼的異味

聖人見微以知萌,見端以知末,故見象箸而怖,知天下不足也。
韓非子.說林上

Code Smell:

程式碼中的任何可能導致深層問題的症狀(之前重複的就不列了)

  1. 一份檔案有多種程式語言
  2. 明顯該有的行為未實現
  3. 在邊界條件的不正確行為
    1. 封裝邊界條件: 將邊界條件的集中處理
      1. int nextLevel = level+1; //boundary condition
  4. 無視編譯器警告
  5. 否定的判斷式
    1. 改善 if(!buffer.isFull())if(buffer.isNotFull())
  6. 沒有封裝條件判斷
    1. 改善 if(timer.hasExpired && !timer.isRecurrent())if(shouldBeDeleted(timer))
  7. 過多的資訊(↔ 資料隱藏)
    1. 類別擁有的變數、方法數量愈少愈好、方法擁有的變數愈少愈好
  8. 不一致性
    1. 命名的一致性(例:對於同一個物件,在不同函式中的實體使用相同的名稱)
    2. 行為的一致性(例:用同樣的方式來完成所有類似的事)
  9. 特色留戀(Feature Envy)
    1. 使用別的物件的getter或setter來操作那個物件的資料
    2. 有時候是必要之惡:放入別的物件會違反其他OOP的原則(此例:單一職責、開放閉合原則等)
  10. 隱藏執行順序的耦合(temporal coupling)

用萬用字元來避免冗長的引入列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//feature envy
public clas HourlyEmployeeReport{
private HourlyEmployee employee;

String reportHours(){
return String.Format(
"Name:%s, Hours:%d.%ld\n",
employee.getName(),
employee.getTenthsWorked()/10,
employee.getTenthsWorked()%10);
}
}

//temporal coupling
public void buyFoodFromMarket(){
//you may forget one of this function when you create new method, like buyFoodFromShop()
prepareCar();
gotoMarket();
buyFood();
goHome();
}

public void NEW_buyFoodFromMarket(){
car = prepareCar();//expose the temporal coupling
market = gotoMarket(car);
food = buyFood(market);
goHome(food);
}

進階閱讀

  • 30天快速上手 TDD
  • cleancode筆記
  • 敏捷軟體開發:原則、樣式及實務
  • The Principles of Good Programming