關於函式的首要準則,就是要簡短。第二項準則,就是要比第一項的簡短函式還要更簡短。
If、else、while 及其他敘述都應該只有一行,這意味者,函式不應該大到包含巢狀結構。因此,除非有特殊的需求,否則函式裡的縮排程度應該只能包含 1 ~ 2 層。
「一件事情」其實很難定義,作者也提到很難用論文或是資料佐證,但是他還是根據自己的親身經驗提出了一個方法。
如果我們以文字的方式描述一個函式,看到其中包含了「不同層次」的抽象概念步驟,就可以判斷這個程式包含了不只一件事。
聽起來超級抽象的吧!我們用在第 2 章看到的程式碼舉例,你認為 get_flagged_cells()
做了幾件事?
def get_flagged_cells():
flagged_cells = []
for cell in gameBoard:
if cell.is_flagged():
flagged_cells.append(cell)
return flagged_cells
我們將程式碼的概念抽象化,也就是用「文章」描述這個函式。
建立儲存「插旗」地雷格子的 flagged_cells ,接著,走訪 game_board 上所有的地雷格子,並判斷是否插旗。如果已被插旗,則把該地雷格子放入 flagged_cells 中。最後,回傳被插旗的地雷格子。
我們很明顯地看見這個函式,沒有突然岔開做其他事情,所以我們可以判定 get_flagged_cells()
是一個只做一件事情的函式。
假設 get_flagged_cells()
做的事情除了取得被插旗的格子外,同時做了建立資料庫連線、存取資料庫的檔案,我們就可以知道 get_flagged_cells()
不只做一件事。
switch天生要做N件事,雖無法避開switch語句,但確保每個switch都埋藏較低的抽象層級,且永遠不重複,利用多型實現。
準則:如果switch只出現一次,而且是用來產生多型物件,並藏匿在某個繼承關係之下使其他系統看不到它們,那就可以容忍switch的存在。
// 可能依賴於僱員型別的一種操作
public Money calculatePay(Employee e) {
switch (e.type) {
case HOURLY:
return calculateHourlyPay(e);
case SALARIED:
return calculateSalariedPay(e);
default:
return null;
}
}
此例有四種問題:
- 函式太長,增加新型別後,會更長
- 做了不止一件事,違反了單一權責原則
- 違反了開放封閉原則,新增新型別時,必須修改之
- 該結構可能到處重複出現,可能會有其他方法,isPayday(Employee e)等等方法
正例
- 將switch語句埋藏到抽象工廠,該工廠使用switch語句為Employee的繼承派生物建立適當實體,不同的函式calculatePay、isPayday則由Employee介面多型的接受呼叫派遣
- switch語句儘量只出現一次,用於抽象工廠中建立多型物件,系統其它部分看不到。
// Employee與使用switch的抽象工廠
public abstract class Emloyee {
public abstract boolean isPayday();
public abstract Money calculatePay();
}
----------
public interface EmployFactory{
public Employee makeEmployee(EmployeeRecode r) throws InvalidEmployeeType;
}
----------
public class EmployeeFactoryImpl implements EmployeeFactory {
public Employee makeEmployee(EmployeeRecord r) {
switch(r.type) {
case HOURLY:
return new HourlyEmployee(r); // 返回Employee派生類物件
case SALARIED:
return new SalariedEmployee(r);
default:
return null;
}
}
}
- 函式越短小、功能越集中,就越便於取好名字
- 別害怕花時間取名字,嘗試不同的名稱,實測其閱讀效果
- 描述性的名稱能夠理清你關於模組的設計思路,並可改進之
- 命名方式保持一致,使用與模組名一脈相承的短語、名詞和動詞等措辭給函式命名
- 注意完整函式簽名中,考慮函式所在類名的namespace的描述作用和函式引數名的描述作用,在函式被呼叫使用時可組合起來描述函式作用(可以避免函式名過度冗長,如下例)
// 如EmployeeManager類中儲存Employee的操作
public class EmployeeManager {
// 寫法一
public void saveEmployee(EmployeeDO employeeDO);
// 寫法二:考慮實際使用時會通過類物件呼叫方法,如類物件employeeManager本身也有描述意義,即通過employeeManager.save(employeeDO)使用,函式名不用再用較為冗長的saveEmployee
public void save(EmployeeDO employeeDO);
- 最理想情況引數數量是零,其次是一,再次是二,應儘量避免三引數
- 從測試角度,對於多引數,要編寫所有可能的引數組合正常的測試用例很困難
- 輸入引數與輸出引數。習慣通過引數(即輸入引數)輸入函式,通過返回值從函式輸出。不太期望通過引數(輸出引數)輸出結果
- 單一參數函式作用:
- 詢問關於入參的問題,如
Boolean fileExists("MyFile")
- 操作該引數
- 轉換。
InputStream fileopen("MyFile")
- 事件(event),使用該引數修改系統狀態,有入參而無返回值(void)
- 轉換。
- 詢問關於入參的問題,如
- 使用flag是不好的作法,將bool傳遞給function是非常恐怖的習慣。
- 這表示當bool是true時,他做了一件事;當bool是false時,他做了另一件事。
- 可以將需要bool的function分裂成兩個function
- 如render(Boolen isSuite)改為renderForSuite()和renderForSingleTest()
- 有時兩個引數正好。如Point p = new Point(0,0),因為此為單個值的有序組成部分。對應自然組合的引數適合二元函式,如笛卡爾座標
- 儘量將二元函式轉換為一元函式。如對於
writeField(outputStream, name)
的改進方法:- 把writeField寫成outputStream的成員方法,呼叫方式改為:
outputStream.writeField(name)
- outputStream寫為當前類的成員變數,從而無需再傳遞它
- 分離出類似FieldWriter新類別,在建構子中引入outputStream,新類別包含一個write方法
- 把writeField寫成outputStream的成員方法,呼叫方式改為:
- 三元函式會使排序、琢磨、忽略(即忽略某個引數)等問題加倍體現,慎寫三元函式
- 如果函式引數需要兩個、三個或三個以上的引數,說明其中一些引數需要封裝為類
- 從引數建立物件,減少引數數量,同時一組引數被共同傳遞,則說明需要有自己名稱的某個概念。如下例子
Circle makeCircle(double x, double y, double radius);
// 修改為
Circle makeCircle(Point center, double radius);`
- 對於一元函式,函式和引數應形成良好的動/名詞對形式,如write(name),更好的是writeField(name)
- 通過在函式名中使用關鍵字,將引數名編碼到函式名,如assertEqual改成assertExpectedEqualsActual(expected, actual),減輕記憶引數順序負擔
- 副作用是一種謊言,函式有時會對自己類中的變數做出未能預期的改動,有時會把變數搞成向函式傳遞的引數或是系統全域性變數,都是具有破壞性的,會導致古怪的時序性耦合和順序依賴(即函式能否正常呼叫依賴於其他函式呼叫順序)。可通過重新命名函式宣告表達潛在的時序性問題,如將簡單的checkPassword()重新命名為checkPasswordAndInitializeSession()解決其中對Session.initialize()的呼叫依賴(此即為副作用)。
- 避免被引數是用作輸入還是輸出而迷惑,如appendFooter(s),該函式是否會向s後面新增東西?此時會付出檢查函式宣告的代價而中斷思路
- 避免輸出引數方式
- 使用report.appendFooter()呼叫,可避免輸出引數,即若函式需要修改某種狀態,修改所屬物件狀態
- 直接使用返回值
- 函式要麼做某事(指令),要麼回答某事(詢問),二者不可得兼,否則會導致混亂。要麼修改某物件狀態,要麼返回該物件的有關資訊
if (set("username","unclebob")) // 是在問unclebob是否設定過?還是是否成功設定unclebob?
// 將上述設定與返回分開
if (attributeExists("username")) {
setAttribute("username","unclebob")
}
- 錯誤碼會導致更深層次的巢狀結構(例如需要判斷:if (deletePage(page) == E_OK));使用例外處理能將錯誤處理程式碼從主路徑分離出來
- 將錯誤處理與正常流程分離,把try和catch程式碼塊中的主體部分抽離出,形成函式
public void delete(Page page) {
try {
deletePageAndAllReferences(page);
}
catch(Exception e) {
logError(e);
}
}
private void deletePageAndAllReferences(Page page) {
....
}
private void logError(Exception e) {
...
}
上例將delete函式和錯誤處理有關,很容易理解並忽略掉即可。如此美妙的區隔,程式碼更易理解和修改。
- 函式應該只做一件事,錯誤處理就是一件事
- 處理錯誤的函式不應該做其他事,意味著(如上例):
- 如果try存在於某個函式,它就是該函式的第一個單詞
- catch/finally程式碼塊後面不該有其他內容
- 若回傳error,通常意味著在某個class或enum中定義了所有的Error code
public enum Error{
OK,
INVALID,
WAITING_FOR_EVENT,
}
- 這樣的enum就像依附性的磁鐵,其他許多class都必須引用他,所以當Error enum改變時,其他class就必須重新編譯和重新部屬,這會對Error enum產生負面的壓力,導致工程師不願意加入新的Error code
- 若使用例外處理,新的例外可由例外class衍生出來,不必被迫重新編譯和重新部屬,就可以加入到現有的程式中
- 重複是軟體中一切邪惡的根源
- 並不從一開始就完全按照規則寫函式。先想怎麼寫就怎麼寫,一開始都冗長而繁雜,有很多縮排、巢狀、過長的引數列表。名稱隨意取,重複的程式碼。不過注意配上一套單元測試,覆蓋程式碼,用於重構的迴歸測試
- 再打磨。分解函式、修改名稱、消除重複、縮短和重新安置方法、拆散類等。同時保持測試通過