iT邦幫忙

2024 iThome 鐵人賽

DAY 29
0
Software Development

程式淨化計畫:痛苦是重構的起源!系列 第 29

Clean Code - Chapter 3 Functions - Part 1

  • 分享至 

  • xImage
  •  

以下是讀完 Clean code 第三章節(part 1)的筆記:

  • 先以FitNesse的一段3-1程式碼講解, 問我們能看懂多少?
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();
}
  • 想必要花很多時間理解
    • 太多nest, if, 多種變數等
  • 經過重構後的3-2程式碼
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();
}

Small!!

  • 函數就該小!!
  • 建議20行就為上限
  • 比如上述3-2程式碼的renderPageWithSetupsAndTeardowns程式碼, 更精簡後的3-3程式碼
public static String renderPageWithSetupsAndTeardowns(
  PageData pageData, boolean isSuite) throws Exception {
  if (isTestPage(pageData))
    includeSetupAndTeardownPages(pageData, isSuite);
  return pageData.getHtml();
}
  • 一般函數4行即可…???

Blocks and Indenting

  • 用縮排來看, 函數最多只能兩層, 來達到small

Do One Thing

  • 函數就應該只做一件事, 就做好這件事.
  • 3-1程式碼明顯做了很多功能
    • 建立緩衝區
    • 取得頁面
    • 搜索繼承的頁面
    • 渲染路徑
    • 新增神秘字串
    • 生成HTML
  • 在3-3程式碼只做簡單的一件事
    • 實際是三件事
      • 判斷是否為測試頁面
      • 如果是, 則進入設置與拆分流程
      • 渲染成HTML
  • 能以To起頭, 描述一個函數要做的事情
    • 也就是定義抽象的層級
    • TO RenderPageWithSetupsAndTeardowns, we check to see whether the page is a test page and if so, we include the setups and teardowns. In either case we render the page in HTML
  • 而另一種判斷函數是否不只做一件事, 可以嘗試能否再拆出一個函數

Sections within Functions

  • 程式碼4-7的generatePrimes被切分多個區段
    • 也就是函數做太多事情

One Level of Abstraction per Function

  • 函數是否只做一件事, 看內容的每個Statement是否都在同一個層級
    • 程式碼3-1來看
      • getHtml()是較高的層級
      • String pagePathName = PathParser.render(pagePath);是中間的抽象層
      • .append("\n") 是低階的層級
  • 當細節 與 基礎概念混和, 該函數就會被細節糾結

Reading Code from Top to Bottom: The Stepdown Rule

  • 保持著每一層函數都是To開頭, 接著引述(呼叫)下一個To開頭的函數

Switch Statements

  • 寫出短小的switch / ifelse很難
  • switch本身要做很多事情
  • switch可埋藏在較小的抽象層, 並不重複
    • 透過多型來達成
  • 以下是3-4程式碼, 依賴於Employee的函數
public Money calculatePay(Employee e)
throws InvalidEmployeeType {
  switch (e.type) {
  case COMMISSIONED:
    return calculateCommissionedPay(e);
  case HOURLY:
    return calculateHourlyPay(e);
  case SALARIED:
    return calculateSalariedPay(e);
  default:
    throw new InvalidEmployeeType(e.type);
  }
}
  • 3-4問題有
    • 程式碼太長, 如果有更多type, 將會更長
    • 做很多事情
    • 違反單一職責原則
      • 因為會有很多修改的理由
    • 違反開放封閉原則
      • 因為新增type就得修改
  • 經過重構的3-5程式碼
    • 將switch埋在抽象工廠
    • 透過Employee的多型介面來處理isPayday, calculatePay, deliverPay等功能
public abstract class Employee {
  public abstract boolean isPayday();
  public abstract Money calculatePay();
  public abstract void deliverPay(Money pay);
}
-- -- -- -- -- -- -- -- -
public interface EmployeeFactory {
  public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType;
}
-- -- -- -- -- -- -- -- -
public class EmployeeFactoryImpl implements EmployeeFactory {
  public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType {
    switch (r.type) {
    case COMMISSIONED:
      return new CommissionedEmployee(r);
    case HOURLY:
      return new HourlyEmployee(r);
    case SALARIED:
      return new SalariedEmploye(r);
    default:
      throw new InvalidEmployeeType(r.type);
    }
  }
}
  • 對於switch, 如果只出現一次並用多型物件來解決, 是OK的

Use Descriptive Names

  • 3-7程式碼將函數名稱testableHtml改成SetupTeardownIncluder.render
    • 恰當描述函數要做的事情
  • 有些私有方法也是具有描述性
    • isTestable
    • includeSetupAndTeardownPages
  • 原則: You know you are working on clean code when each routine turns out to be pretty much what you expected
  • 長名字比短而令人難解的名字好多了
    • 也比描述性的長註解好多
  • 命名方式要保持一致, 與該模組名字一脈相承的短句、名詞和動詞做函數命名
    • 比如includeXXXXXPages

Function Arguments

  • 最理想是無參數
    • 其次是一個參數
    • 再其次是兩個參數
    • 特殊理由才會三個以上參數
      • 最好都不要這樣!
  • 以前面案例, 如果把StringBuffery作為參數傳遞, 變成很多函數得知道這細節
  • 以測試角度, 多個參數要測試的案例太多了
    • 所以才說不要超過2個參數
  • 輸出參數(reference type或C# out 變數等)比輸入參數還難理解

Common Monadic Forms(一元函數的普遍格式)

  • 一般有兩種理由
    • 一個是問關於該參數的事情
      • 比如bool fileExists(”MyFilePath”)
    • 另一個是操作該參數, 將其轉換成其他東西
      • 比如InputStream fileOpen(”MyFilePath”)
  • 而其他特殊的像是事件
    • 有輸入、沒輸出
    • 比如void passwordAttemptFailedNtimes(int attemps)
    • 這種函數要特別小心使用, 須讓讀者知道這是事件
  • 盡量避免寫不遵循此格式的一元函數
    • 比如void includeSetupPageInfo(StringBuffer pageText)
  • 對於轉換(transform), 使用輸出參數而不是回傳值會讓人困惑
    • 轉換結果要體現在回傳值!
    • 比如StringBuffer transform(StringBuffer in)比void transform(StringBuffer out)好很多
    • 即使它只是要回傳StringBuffer輸入參數而已, 建議仍這麼做

Flag Arguments

  • 是一種醜陋參數, 因為明顯宣告這函數不只做一件事
  • 但3-7程式碼解釋, 已經呼叫者render(bool isSuite)帶這參數了, 如果要重構的話
    • renderForSuite()與renderForSingleTest()

Dyadic Functions(二元函數)

  • 以Point(x, y)為例, x 和 y是有順序的參數, 因此需兩個參數很合理
  • 但如果writeField(outputStream, name), 明顯這不是自然的組合, 也不是排序
  • AssertEquals也有這問題, 到底Actual與expected的位置在哪?
  • 盡量將二元函數重構成1元函數
    • 以writeField為例
      • 可以重構
        • outputStream的擴展函數, 能用writeField(name)
        • 或將outputStream作為一個類別的成員

Triads

  • 就是比二元函數更難讀懂的格式
  • assertEquals也是有三元的
    • 如果他有一種assertEquals(message, actual, expected), 很容易帶錯參數

Argument Objects

  • 超過兩個的參數,考慮封裝成類別
Circle makeCircle(double x, double y, double radius);
Circle makeCircle(Point center, double radius);
  • 這種做法是一種概念化,將類似物件放一起

Argument Lists

  • 像是string.format, 是可以帶多個參數最第一個字串的參數
    • param類型
  • 這種也會形成3元以上的設計

Verbs and Keywords

  • 以一元函數來看, 函數名稱是動詞、參數是名詞
    • write(name)
    • writeField(name)
      • 這會解釋field = name的概念
  • 另一種是把參數名稱作為函數名稱
    • assertExpectedEqualsActual(expected, actual)
      • 這樣會記得順序

上一篇
Clean Code - Chapter 2 Meaningful Names - Part 2
下一篇
Clean Code - Chapter 3 Functions - Part 2
系列文
程式淨化計畫:痛苦是重構的起源!31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言