Clean Code(無瑕的程式碼)心得

Chap01 動機

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

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

Chap02 Clean Code 的定義

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

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

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

Chap03 原則

任何原則在特殊情形都是可以違反的,不須過於拘泥

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

有意義的命名

  • 有辦法唸出來的名稱,愈具體愈好
  • 用 define, enum, const 代替 常數
    • enum color{black, white}
    • 86400 → SECOND_PER_DAY
    • 容易了解的數字就不用
      • circumference = radius * Math.PI * 2: 不用將2換成實際名稱
  • class:名詞或名詞片語
    • boardchessGameBoard
    • addressportAddress, EmailAddress
    • dayelapsedTimeInDays
  • method:動詞或動詞片語
    • flagisFlagged()
    • Complex c = Complex(23.0)Complex c = Complex.FromRealNumber(23.0)
      • 使用靜態工廠
  • 對特定功能使用一致的用詞
    • fetch, retreive, get ...
  • 避免 data, info, manager 等意義較廣的字
    • accountListaccountGroup (除非此變數真的是list型態)
    • add()insert(), append()
  • 使用專有名詞
    • jobqueue
    • timestamp
  • 使用範圍較大的變數用較長的名稱
    • 愈少用的函數名稱可以愈長
    • for迴圈範圍較小,變數可以用i, j
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++)
...

函式

  • 一個函式只做在同一層級上的一件事情
    • 以「無法再分割」為標準
  • 長度:小於二十行(或一個螢幕的長度)
  • 不用switch:switch容易違反
    • 單一職責原則
    • 開放閉合原則
    • 解法:抽象工廠
      • 使用switch和多型
  • 參數
    • 愈少愈好($\leq$ 三個)
      • 太多參數時需要記順序,像是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為member,改成傳參數:cls.A(); cls.B();C = cls.A(); cls.B(C);
  • 常數宣告:放在適當的層級
    • 將預設的常數放在呼叫的參數,而非被呼叫的函式內
      • getPageNameOrDefault(request, "FrontPage") //default is "FrongPage"
    • 放在愈高階就愈容易修改

註解

註解是輔助程式碼來表達意圖的工具

  • 有註解代表程式碼不夠易懂

  • 愈少愈好

  • 與其寫註解,不如把程式碼弄整潔

  • 註解通常缺少維護

    • 容易產生許多過時的註解
    • 錯誤的註解比沒有註解可怕
  • 必要的註解

    1. 版權宣告
    2. 舉例示範
    3. 解釋意圖
      • 對某個問題的解決方法
      • 使用的演算法
      • 解釋自己無法修改的程式碼(函式庫等)
    4. 警告
      • 不希望被修改的地方
    5. 暫時記錄:TODO, BUG...
  • 糟糕的註解

    • 浪費時間看,最後被忽略
      • 沒有提供更多資訊
        • printBoard() // print board
      • 過多的資訊
      • 被強迫寫的(通常就是不必要的)
    • 已被版本控制軟體取代的功能
      • 版本變動記錄
      • 註解掉的程式碼
    • 過度使用標誌
      • // comment //////////////////

排版

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

物件及資料結構

你知道的太多了...

變數保持私有的理由,不希望有人依賴此變數,保持一個自由的空間,讓我們能自由的更改變數的型態,或是在突如其來的奇想或衝動時,能自由的變更實現內容的程式碼。

那為什麼有這麼多的程式設計師,自動替他們的物件加上getter和setter,讓他們的私有度數如同公用變數呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//完全暴露實現
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.物件

  • private member,public method
  • 將實現的過程隱藏(封裝)
  • 用抽象詞彙表達資料
    • getGallonsOfGasoline()getPercentFuelRemaining()
  • 要讓每件事物都是一個物件是一個神話(Java表示:)
    2.資料結構: map, set, array ...
  • 暴露資料(public member)
    • 為資料結構設getter和setter是多此一舉

物件與資料結構的互補

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

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。

錯誤處理

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

邊界

註:從這章之後大多是一章一個作者,所以頗有矛盾和重複的地方

使用第三方API

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

單元測試

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

測試驅動開發(TDD)
The Three Rules of TDD

  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:先寫測試再實作

類別

  • 凝聚性:方法內使用愈多變數,代表這個方法更屬於這個類別
    • 為了保持凝聚性,會產生許多小類別

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

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

系統

如同建造城市,某些人負責整體規劃,其它人專注在細節執行。
進行抽象化和模組化,將所有關注的事分離開來

  • 工廠模式:將建造和使用分離

相依性注入(1)(2)

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

擴大

  • 誰有辦法預期小鎮的成長,而在鎮上先鋪好六線道高速公路?
    • 讓系統一開始就做對,是一個神話
    • 應該只實現今天的故事(即目前的需求),然後重構它,並且明天再進行系統的擴充
  • 剖面導向程式設計AOP(1)(2)
    • 剖面(aspect)
      • 系統中某個關注點的行為,需要用一致性的方式修改
        • 如日誌記錄(log)、資料庫、安全性、暫存快取
        • 紀錄檔功能往往橫跨系統中的每個業務模組,即「橫切」所有有紀錄檔需求的類及方法體
      • 為應用程式基礎架構
    • 保持適當的關注點分離

不需要「先作大型設計」,因為不希望浪費先前的努力,這個設計會阻止你改進程式架構。

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

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

剖面範例

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

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

簡單設計守則

  1. 執行完所有的測試
  2. 程式重構:重要性 1 > 2 > 3
    1. 消除重複
      1. 樣版方法(Template Method)
      2. 大部份設計模式都是在提供消除重複的策略
      3. 物件導向是用來組織模組和消除重複的策略
    2. 具表達力
    3. 最小化類別及方法數量

平行化

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

將「做什麼」和「何時做」分解開來,像是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
  • 哲學家用餐
    • 雙手都拿到刀叉時才能用餐
    • 大程式的資源爭奪問題

詳見平行程式設計

程式碼的異味

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

程式碼的異味(Code Smell):
程式碼中的任何可能導致深層問題的症狀

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

參考資料

  • 30天快速上手 TDD
  • The Principles of Good Programming