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: 异常处理

<<:  全端入门Day19_前端程序撰写之JavaScript

>>:  Python - 修正 python pandas 模组的 runtimeError: package fails to pass a sanity check 错误处理参考笔记

第7天

中秋连假结束~ 参考线完成开始放入标题~ 把textview跟参考线连在一起~ 使用TextView...

从零开始学游戏设计:游戏中的背景音乐

这是 Roblox 从零开始系列,游戏环境章节的第五个单元,今天你将学会如何在游戏内播放背景音乐 【...

Day.30 维运必备辅助 - 系统监控(Percona Monitoring and Management)

在昨天我们安装完PMM监控,今天来认识几个监控收集的资料帮助我们了解资料库各方面的执行详情与效能监...

自动化测试,让你上班拥有一杯咖啡的时间 | Day 30 - 学习cypress intercept 与後记

此系列文章会同步发文到个人部落格,有兴趣的读者可以前往观看喔。 终於来到铁人赛第30天!谢谢观看我...

密钥协商-Diffie-Hellman

密钥分发是将加密密钥从一方发送到另一方的过程。对称和非对称密码术都面临着密钥分发的挑战。这个问题询问...