Skip to content

Latest commit

 

History

History
247 lines (180 loc) · 10.7 KB

第 3 章 函式 f3add90e92f04182848659656c315f2e.md

File metadata and controls

247 lines (180 loc) · 10.7 KB

第 3 章 函式

簡短!

關於函式的首要準則,就是要簡短。第二項準則,就是要比第一項的簡短函式還要更簡短。

區塊 (Blocks) 和縮排 (Indenting)

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語句

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;
    }
}

此例有四種問題:

  1. 函式太長,增加新型別後,會更長
  2. 做了不止一件事,違反了單一權責原則
  3. 違反了開放封閉原則,新增新型別時,必須修改之
  4. 該結構可能到處重複出現,可能會有其他方法,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);

函式的參數

  • 最理想情況引數數量是零,其次是一,再次是二,應儘量避免三引數
  • 從測試角度,對於多引數,要編寫所有可能的引數組合正常的測試用例很困難
  • 輸入引數與輸出引數。習慣通過引數(即輸入引數)輸入函式,通過返回值從函式輸出。不太期望通過引數(輸出引數)輸出結果

一元函式

  • 單一參數函式作用:
    1. 詢問關於入參的問題,如Boolean fileExists("MyFile")
    2. 操作該引數
      • 轉換。InputStream fileopen("MyFile")
      • 事件(event),使用該引數修改系統狀態,有入參而無返回值(void)

旗標(Flag) 參數

  • 使用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方法

三元函式

  • 三元函式會使排序、琢磨、忽略(即忽略某個引數)等問題加倍體現,慎寫三元函式

數物件

  • 如果函式引數需要兩個、三個或三個以上的引數,說明其中一些引數需要封裝為類
  • 從引數建立物件,減少引數數量,同時一組引數被共同傳遞,則說明需要有自己名稱的某個概念。如下例子
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程式碼塊

  • 將錯誤處理與正常流程分離,把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 enum

  • 若回傳error,通常意味著在某個class或enum中定義了所有的Error code
public enum Error{
	OK,
	INVALID,
	WAITING_FOR_EVENT,
}
  • 這樣的enum就像依附性的磁鐵,其他許多class都必須引用他,所以當Error enum改變時,其他class就必須重新編譯和重新部屬,這會對Error enum產生負面的壓力,導致工程師不願意加入新的Error code
  • 若使用例外處理,新的例外可由例外class衍生出來,不必被迫重新編譯和重新部屬,就可以加入到現有的程式中

別重複自己

  • 重複是軟體中一切邪惡的根源

如何寫出好函式

  • 並不從一開始就完全按照規則寫函式。先想怎麼寫就怎麼寫,一開始都冗長而繁雜,有很多縮排、巢狀、過長的引數列表。名稱隨意取,重複的程式碼。不過注意配上一套單元測試,覆蓋程式碼,用於重構的迴歸測試
  • 再打磨。分解函式、修改名稱、消除重複、縮短和重新安置方法、拆散類等。同時保持測試通過