iT邦幫忙

2021 iThome 鐵人賽

DAY 4
0
Software Development

成為乾淨的開發者吧! Clean Code, Clean Coder, Clean Architecture 導讀之旅系列 第 4

Day 04: 函式、錯誤處理

「關於函式的首要準則,就是要簡短。第二項準則,就是要比第一項的簡短函式還要更簡短。這是一個我無法證明的主張」

「我曾經寫過令人難受的 3000 行函式怪物,寫過數不清的 100 至 300 行大小的函式,也寫過只有 20 到 30 行的函式。這些經驗告訴我,函式應該要非常簡短

取自: Clean Code (p.40)

CH3: 函式 (Functions)

  • 先來個例子,請試著瀏覽下列 Code [1]並大致想像功能:

      public class HtmlUnit {
        public static String testableHtml(
           PageData pageData,
           boolean includeSuiteSetup
         ) throws Exception {
           WikiPage wikiPage = pageData.getWikiPage();
           StringBuffer buffer = new StringBuffer();
           if (pageData.hasAttribute("Test")) {
             if (includeSuiteSetup) {
               WikiPage suiteSetup =
                 PageCrawlerImpl.getInheritedPage(
                     SuiteResponder.SUITE_SETUP_NAME, wikiPage
                 );
               if (suiteSetup != null) {
                 WikiPagePath pagePath =
                   suiteSetup.getPageCrawler().getFullPath(suiteSetup);
                 String pagePathName = PathParser.render(pagePath);
                 buffer.append("!include -setup .")
                       .append(pagePathName)
                       .append("\n");
               }
             }
             WikiPage setup =
               PageCrawlerImpl.getInheritedPage("SetUp", wikiPage);
             if (setup != null) {
               WikiPagePath setupPath =
                 wikiPage.getPageCrawler().getFullPath(setup);
               String setupPathName = PathParser.render(setupPath);
               buffer.append("!include -setup .")
                     .append(setupPathName)
                     .append("\n");
             }
           }
           buffer.append(pageData.getContent());
           if (pageData.hasAttribute("Test")) {
             WikiPage teardown =
               PageCrawlerImpl.getInheritedPage("TearDown", wikiPage);
             if (teardown != null) {
               WikiPagePath tearDownPath =
                 wikiPage.getPageCrawler().getFullPath(teardown);
               String tearDownPathName = PathParser.render(tearDownPath);
               buffer.append("\n")
                     .append("!include -teardown .")
                     .append(tearDownPathName)
                     .append("\n");
             }
             if (includeSuiteSetup) {
               WikiPage suiteTeardown =
                 PageCrawlerImpl.getInheritedPage(
                         SuiteResponder.SUITE_TEARDOWN_NAME,
                         wikiPage
                 );
               if (suiteTeardown != null) {
                 WikiPagePath pagePath =
                   suiteTeardown.getPageCrawler().getFullPath (suiteTeardown);
                 String pagePathName = PathParser.render(pagePath);
                 buffer.append("!include -teardown .")
                       .append(pagePathName)
                       .append("\n");
               }
            }
          }
          pageData.setContent(buffer.toString());
          return pageData.getHtml();
         }
      }
    

    P.S. 筆者先自首,這是我第三次閱讀本書,事實上我沒有一次花超過 10 秒鐘在看這段 Code... 匆匆瀏覽的感想是這應該是一段跟 Html Render 有關的 Code,帶有 Mock (Test) 的功能切換、也許還做了一些不明的 Setup?

  • 上述的 Code 不僅符合前面所提到的命名、就連縮排風格筆者也用 Formatter 美化過了 (原書中更亂)

  • 究竟出了什麼問題,導致程式碼的可讀性下降?

    1. 重複的程式碼 (Duplicate Code)
    2. 詭異的字串[2] (Hard Coding)
    3. 隱晦的資料型態 (Implicit Type)
    4. 做了太多事
      太多不同抽象層次的概念混雜在一起 (建立 Buffer、取得頁面內容、搜尋被繼承的頁面、輸出路徑、最後回傳 HTML 網頁...等等)
    5. 巢狀 If 結構
      個人滿討厭這種寫法的,尤其偶爾又有函式呼叫、變數賦值及 Return,會導致程式的流程和進出入點很混亂
  • 接下來我們透過提取幾個函式來重構上面的 Code...

      public class HtmlUnit {
    
        public static String renderPageWithSetupsAndTeardowns(
          PageData pageData,
          boolean isSuite
        )
          throws Exception {
          boolean isTestPage = pageData.hasAttribute("Test");
          if (isTestPage) {
            WikiPage testPage = pageData.getWikiPage();
            StringBuffer newPageContent = new StringBuffer();
            includeSetupPages(testPage, newPageContent, isSuite);
            newPageContent.append(pageData.getContent());
            includeTeardownPages(testPage, newPageContent, isSuite);
            pageData.setContent(newPageContent.toString());
          }
    
          return pageData.getHtml();
        }
      }
    

    我想上述的 Code 已經 Clean 到不需要註解和文字介紹了,任何修過程式設計的學生應當都能猜出這段 Code 在做什麼了。順帶一提,上面的函式是可測試的 (Testable),我們會在後面的章節介紹「測試驅動設計 (TDD)」


簡短

  • 原作者在此提出了函式應至少要比前一個例子還要更簡短,像這樣:
    public static String renderPageWithSetupsAndTeardowns(
      PageData pageData, boolean isSuite) throws Exception {
      if (isTestPage(pageData)) 
        includeSetupAndTeardownPages(pageData, isSuite);
      return pageData.getHtml();
    }
    
    甚至只有 2, 3 或 4 行 (關於這點筆者是持保留看法的...)
  • 區塊 (Blocks) 和縮排 (Indenting)
    If、Else、While 內的敘述都應該只有一行 (通常是函式呼叫)
  • 函式不應該包含巢狀結構
    縮排控制在一或二層內

每個函式只有一層抽象概念

「函式應該只做一件事情」

思考:何謂「一件事」?

  • 上述例子其實做了三件事:

    1. 判斷此頁是否為測試頁
    2. 若是測試頁,設置和拆解頁面
    3. 將 pageData 轉換為 HTML 後回傳
  • 那麼該如何判斷呢?

    「函式只做函式名稱下 『同一層抽象概念』 的幾個步驟」

  • 因此,判斷函式是否做超過「一件事」的方法為

    「看你是否能從此函式中,提煉出另一個新函式」

    且,此新函式的提取會導致抽象概念的進一步簡化或改變

Switch

  • 更厲害的程式設計師會用 「多型」 來取代複雜的條件敘述 (Switch Case)
  • 例[3]:
    class Bird {
        double getSpeed() {
          switch (type) {
            case EUROPEAN:
              return getBaseSpeed();
            case AFRICAN:
              return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
            case NORWEGIAN_BLUE:
              return (isNailed) ? 0 : getBaseSpeed(voltage);
          }
          throw new RuntimeException("Should be unreachable");
        }
      }
    
    上述的 Switch 內包含了太多細節了。這導致此函式破壞了 「單一職責原則 (SRP)」「開放封閉原則 (OCP)」 (筆者會在 Clean Architecture 篇詳細介紹此類設計原則)
  • 接下來讓我們重構它:
      abstract class Bird {
        abstract double getSpeed();
      }
    
      class European extends Bird {
        double getSpeed() {
          return getBaseSpeed();
        }
      }
      class African extends Bird {
        double getSpeed() {
          return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
        }
      }
      class NorwegianBlue extends Bird {
        double getSpeed() {
          return (isNailed) ? 0 : getBaseSpeed(voltage);
        }
      }
    
      speed = bird.getSpeed();
    
    透過這樣子的更改,不僅封裝了底層細節、也提升了程式的可擴充性和維護性。詳細的說明讀者可參見 Reference

函式的參數 (Parameters)

  • 參數數量,最理想的是 0 個,至多用到 3 個
    無論如何都不該超過 3 個參數,除非有非常特殊的理由

    • includeSetupPage()includeSetupPage(newPageContent) 更容易理解。因為參數會強迫你去瞭解更多目前不重要的細節
    • 測試的角度看,測試案例需要考量到參數們的所有可能組合,愈多參數將導致測試愈困難
    • 技巧: 建立物件或類別,減少參數數量
      Circle makeCircle(double x, double y, double radius);
      
      // 將相似概念的參數放在一起
      Circle makeCircle(Point center, double radius);
      
  • 避免輸出型參數 (Output Parameter)

    • 閱讀函式時,我們習慣「參數 輸入 (Input) 到函式」的概念,而 輸出(Outpu) 則是透過 Return 來回傳。我們並不會預期回傳的資訊是透過參數來傳遞
    • StringBuffer transform(StringBuffer in) 會比 void transform(StringBuffer out) 更洽當
    • 關於何時可以使用輸出型參數,StackOverflow 上有許多的探討 [5],絕大多數情況我們都該避免 Output Parameter。目前筆者覺得最適用的情境在於資料庫 Stored Procedure [4] 的撰寫
  • 不要使用旗標參數 (Flag Parameter)

    「使用旗標參數是一種非常爛的做法」

    與其將 boolean 值傳遞給函式,不如直接 Return 處理完後的 boolean 值。或者直接拆成不同函式

    • 例子:
      render(true) 
      render(false) 
      
      // vs.
      
      renderForSuite()
      renderForSingleTest()
      

不要回傳或傳遞 Null

「回傳 null 是在給自己增加額外的工作量,也是在給呼叫者找麻煩」

「傳遞 null 到方法裡是更糟糕的行為,應該盡可能避免傳遞 null」

取自: Clean Code (pp.123-124)

要無副作用 (Side Effect)

  • 函式必須保證只做一件事,不能暗地裡偷做了其它事情。例如,驗證會員登入的函式內不能偷做 Session 的初始化,這會導致令人混淆的時空耦合 (Temporal Coupling) [8]

指令 (Command) 和 查詢(Query) 分離

「函式應該要能做某件事,能回答某個問題,但兩者不該同時發生」

  • 例子:

    // Confusing
    if (set("name", "bob")){
      ...
    }
    
    vs.
    
    // Concrete
    if (attributeExists("name")){
      setAttribute("name", "bob");
    }
    
  • [補充]: Command 和 Query 的混雜不僅在代碼層級會造成閱讀混淆,考慮到 Database 大量讀寫的情境,則可能導致一致性 (Consistency)權限控管不易的問題

  • 上升到架構層面後衍生出 「命令與查詢分離 (CQS)」「命令與查詢責任隔離 (CQRS)」 ...等模式
    可參見 Reference [10], [11]
    https://ithelp.ithome.com.tw/upload/images/20210921/20138643MGXCY8nJhF.png

使用例外事件 (Exceptions) 而非回傳錯誤碼 (Error Code)

  • 讓指令型函式回傳錯誤代碼,這有點違反指令查詢分離原則
  • 這會導致更深層的巢狀結構,當你回傳錯誤碼,呼叫者必須馬上處理這個錯誤

提取 Try / Catch 內的區塊

  • 程式若參雜錯誤處理,會混淆程式的結構
    可以把 Try / Catch 內的程式碼提取出來:
    public void delete(Page page) {
        try{
            deletePageAndAllReferences(page);
        }
        catch (Exception e){
            logError(e);
        }
    }
    
    private void deletePageAndAllReferences(Page page) throws Exception{
      // ...
    }
    

錯誤處理就是「一件事」

  • 一個處理錯誤的函式不該再做其他的事
  • Try 幾乎該出現在函式的開頭
  • Catch / Finally 區塊之後不該有其他程式碼

CH7: 錯誤處理 (Exceptions Handling)

「雖然 Clean Code 是易讀的,但它也必須是耐用的。當我們將錯誤處理看作是另一件重要的事,將之處理成獨立於主要邏輯的可讀程式,代表我們寫出了整潔又耐用的程式碼。在程式的維護性方面也向前邁進了一大步」

取自: Clean Code (p.126)

在開頭寫下 Try-Catch-Finally 敘述

  • 相當於在程式裡定義出一個視野 (Scope)
    try 區塊內執行的程式隨時都可能被中斷 (Interrupt)。中斷發生後會接續在 catch 區塊裡繼續執行,讓程式維持在一致的狀態
  • 請養成好習慣
    只要 Code 有可能 throw Exception,就要在開頭寫下 try / catch

Use Unchecked Exceptions (不檢查例外的類型定義)

  • C++, C#, Python 都不支援檢查型例外 (Checked Exceptions)。這一節內容主要針對 JAVA 程式碼
    故筆者略過此節。想瞭解更深入,推薦閱讀 Reference [12] 此文
  • 簡單總結
    對一般應用程式而言,不建議為每一個 Catch 到的 Exception 都定義非常仔細的錯誤情境。這其實會導致函式最底層至最高層的密封性被破壞
  • 檢查型例外適用的情境在於
    非常重要、容錯率極低的函式庫

從呼叫者的角度定義例外類別

  • 以四則運算為例子 [13],有時候我們只關心運算過程如何出錯,例如 Catch 到 "ArithmeticException",對於更詳細的 "DivideByZeroException" 則非呼叫者 (四則運算器) 所關注的細節

  • 可以透過 Wrapper 設計技巧讓程式只回傳共用的例外型態

    • 例如: 下列程式碼將 "ACMEPort" 類別包裹 (Wrap) 成 LocalPort
      LocalPort port = new LocalPort(0);
      try 
      {
          port.open();
      } 
      catch (PortDeviceFailure e) 
      {
          // error logging...
      } 
      finally 
      {
          // ...
      }
      
      public class LocalPort 
      {
          private ACMEPort innerPort;
      
          public LocalPort(int portNumber) 
          {
              innerPort = new ACMEPort(portNumber);
          }
      
          public void open() {
              try 
              {
                  innerPort.open();
              } 
              catch (DeviceResponseException e) 
              {
                  throw new PortDeviceFailure(e);
              } 
              catch (ATM1212UnlockedException e) 
              {
                  throw new PortDeviceFailure(e);
              } 
              catch (GMXError e) 
              {
                  throw new PortDeviceFailure(e);
              }
          }
      }
      

    上述包裹第三方函式庫的做法是非常好的技巧,可以減少對第三方 API 的依賴

提供發生例外的相關資訊

  • 哪個操作所引發的錯誤、錯誤型態、相關執行過程都建議用 logger 記錄起來

Reference

  1. ludwiggj/CleanCode
  2. 擁抱改變,遠離Hard Code
  3. Replace Conditional with Polymorphism
  4. Why Should I use Stored Procedure with out put parameter?
  5. When should I use out parameters?
  6. Good practice for Output parameter in C#
  7. Which is better, return value or out parameter?
  8. 筆記-什麼是時序耦合(Temporal Coupling)?
  9. 耦合性 (電腦科學)
  10. Command Query Separation (CQS) - A simple but powerful pattern
  11. CQRS 模式是什麼?
  12. Java筆記 — Exception 與 Error
  13. Catching and Wrapping Exceptions
  14. [Clean Code] Chapter 7: 異常處理

上一篇
Day 03: 有意義的命名、好的註解、垂直 & 水平編排
下一篇
Day 05: 物件及資料結構、邊界
系列文
成為乾淨的開發者吧! Clean Code, Clean Coder, Clean Architecture 導讀之旅31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言