函数应该短小精悍

  1. public static String testableHtml(
  2. PageData pageData,
  3. boolean includeSuiteSetup
  4. ) throws Exception {
  5. WikiPage wikiPage = pageData.getWikiPage();
  6. StringBuffer buffer = new StringBuffer();
  7. if (pageData.hasAttribute("Test")) {
  8. if (includeSuiteSetup) {
  9. WikiPage suiteSetup =
  10. PageCrawlerImpl.getInheritedPage(
  11. SuiteResponder.SUITE_SETUP_NAME, wikiPage
  12. );
  13. if (suiteSetup != null) {
  14. WikiPagePath pagePath =
  15. suiteSetup.getPageCrawler().getFullPath(suiteSetup);
  16. String pagePathName = PathParser.render(pagePath);
  17. buffer.append("!include -setup .")
  18. .append(pagePathName)
  19. .append("\n");
  20. }
  21. }
  22. WikiPage setup =
  23. PageCrawlerImpl.getInheritedPage("SetUp", wikiPage);
  24. if (setup != null) {
  25. WikiPagePath setupPath =
  26. wikiPage.getPageCrawler().getFullPath(setup);
  27. String setupPathName = PathParser.render(setupPath);
  28. buffer.append("!include -setup .")
  29. .append(setupPathName)
  30. .append("\n");
  31. }
  32. }
  33. buffer.append(pageData.getContent());
  34. if (pageData.hasAttribute("Test")) {
  35. WikiPage teardown =
  36. PageCrawlerImpl.getInheritedPage("TearDown", wikiPage);
  37. if (teardown != null) {
  38. WikiPagePath tearDownPath =
  39. wikiPage.getPageCrawler().getFullPath(teardown);
  40. String tearDownPathName = PathParser.render(tearDownPath);
  41. buffer.append("\n")
  42. .append("!include -teardown .")
  43. .append(tearDownPathName)
  44. .append("\n");
  45. }
  46. if (includeSuiteSetup) {
  47. WikiPage suiteTeardown =
  48. PageCrawlerImpl.getInheritedPage(
  49. SuiteResponder.SUITE_TEARDOWN_NAME,
  50. wikiPage
  51. );
  52. if (suiteTeardown != null) {
  53. WikiPagePath pagePath =
  54. suiteTeardown.getPageCrawler().getFullPath(suiteTeardown);
  55. String pagePathName = PathParser.render(pagePath);
  56. buffer.append("!include -teardown .")
  57. .append(pagePathName)
  58. .append("\n");
  59. }
  60. }
  61. }
  62. pageData.setContent(buffer.toString());
  63. return pageData.getHtml();
  64. }

搞懂这个函数了吗?大概没有。有太多事发生,有太多不同层级的抽象。奇怪的字符串和函数调用,混以双重嵌套、用标识来控制的if语句等,不一而足。
不过,只要做几个简单的方法抽离和重命名操作,加上一点点重构,就能在9行代码之内搞掂(如代码清单3-2所示)。用3分钟阅读以下代码,看你能理解吗?

  1. public static String renderPageWithSetupsAndTeardowns(
  2. PageData pageData, boolean isSuite
  3. ) throws Exception {
  4. boolean isTestPage = pageData.hasAttribute("Test");
  5. if (isTestPage) {
  6. WikiPage testPage = pageData.getWikiPage();
  7. StringBuffer newPageContent = new StringBuffer();
  8. includeSetupPages(testPage, newPageContent, isSuite);
  9. newPageContent.append(pageData.getContent());
  10. includeTeardownPages(testPage, newPageContent, isSuite);
  11. pageData.setContent(newPageContent.toString());
  12. }
  13. return pageData.getHtml();
  14. }

只做一件事

函数应该做一件事。做好这件事。只做这一件事。
问题在于很难知道那件该做的事是什么。代码清单3-3只做了一件事,对吧?其实也很容易看作是三件事:
(1)判断是否为测试页面;
(2)如果是,则容纳进设置和分拆步骤;
(3)渲染成HTML。

如果函数只是做了该函数名下同一抽象层上的步骤,则函数还是只做了一件事。

要判断函数是否不止做了一件事,有一个判断方法,就是看是否能再拆出一个函数,该函数不仅只是单纯地重新诠释其实现。

每个函数一个抽象层级

函数:
任务A
子任务A1
任务B
任务C
子任务C1
子任务C2

以上假设一个函数有两个层级

函数中混杂不同抽象层级,往往让人迷惑。读者可能无法判断某个表达式是基础概念还是细节。更恶劣的是,就像破损的窗户,一旦细节与基础概念混杂,更多的细节就会在函数中纠结起来。

确保每个switch都埋藏在较低的抽象层级

代码清单3-4 Payroll.java

  1. public Money calculatePay(Employee e) throws InvalidEmployeeType {
  2. switch (e.type) {
  3. case COMMISSIONED:
  4. return calculateCommissionedPay(e);
  5. case HOURLY:
  6. return calculateHourlyPay(e);
  7. case SALARIED:
  8. return calculateSalariedPay(e);
  9. default:
  10. throw new InvalidEmployeeType(e.type);
  11. }
  12. }

该函数有好几个问题。首先,它太长,当出现新的雇员类型时,还会变得更长。其次,它明显做了不止一件事。第三,它违反了单一权责原则。
第四,它违反了开放闭合原则(Open Closed Principle[8], OCP),因为每当添加新类型时,就必须修改之。不过,该函数最麻烦的可能是到处皆有类似结构的函数。例如,可能会有

isPayday(Employee e, Date date),

deliverPay(Employee e, Money pay),
如此等等。它们的结构都有同样的问题。

该问题的解决方案,是将switch语句埋到抽象工厂底下,不让任何人看到。该工厂使用switch语句为Employee的派生物创建适当的实体,而不同的函数,如calculatePay、isPayday和deliverPay等,则藉由Employee接口多态地接受派遣。

函数参数

最理想的参数数量是零(零参数函数),其次是一(单参数函数),再次是二(双参数函数),应尽量避免三(三参数函数)。有足够特殊的理由才能用三个以上参数(多参数函数)——所以无论如何也不要这么做。

不要使用布尔参数

向函数传入布尔值简直就是骇人听闻的做法。这样做,方法签名立刻变得复杂起来,大声宣布本函数不止做一件事。如果标识为true将会这样做,标识为false则会那样做!

如果函数看来需要两个、三个或三个以上参数,就说明其中一些参数应该封装为类了。例如,下面两个声明的差别:
Circle makeCircle(double x, double y, double radius);
Circle makeCircle(Point center, double radius);

给函数取个好名字

给函数取个好名字,能较好地解释函数的意图,以及参数的顺序和意图。对于一元函数,函数和参数应当形成一种非常良好的动词/名词对形式。例如,write(name)就相当令人认同。不管这个“name”是什么,都要被“write”。更好的名称大概是writeField(name),它告诉我们,“name”是一个“field”。

分隔指令与询问

函数要么做什么事,要么回答什么事,但二者不可得兼。函数应该修改某对象的状态,或是返回该对象的有关信息。两样都干常会导致混乱。看看下面的例子:

public boolean set(String attribute, String value);

if (set(“username”, “unclebob”))…

从读者的角度考虑一下吧。这是什么意思呢?它是在问username属性值是否之前已设置为unclebob吗?或者它是在问username属性值是否成功设置为unclebob呢?从这行调用很难判断其含义,因为set是动词还是形容词并不清楚。

真正的解决方案是把指令与询问分隔开来,防止混淆的发生:

if (attributeExists(“username”)) {
setAttribute(“username”, “unclebob”);

}

使用异常替代返回错误码

返回错误码通常暗示某处有个类或是枚举,定义了所有错误码。

  1. public enum Error {
  2. OK,
  3. INVALID,
  4. NO_SUCH,
  5. LOCKED,
  6. OUT_OF_RESOURCES,
  7. WAITING_FOR_EVENT;
  8. }

其他许多类都得导入和使用它。当Error枚举修改或删除时,所有这些其他的类都需要重新编译和部署。[11]这对Error类造成了负面压力。程序员不愿增加新的错误代码,因为这样他们就得重新构建和部署所有东西。于是他们就复用旧的错误码,而不添加新的。
使用异常替代错误码,新异常就可以从异常类派生出来,无需重新编译或重新部署[12]。

  1. if (deletePage(page) == E_OK) {
  2. if (registry.deleteReference(page.name) == E_OK) {
  3. if (configKeys.deleteKey(page.name.makeKey()) == E_OK) {
  4. logger.log("page deleted");
  5. } else {
  6. logger.log("configKey not deleted");
  7. }
  8. } else {
  9. logger.log("deleteReference from registry failed");
  10. }
  11. } else {
  12. logger.log("delete failed");
  13. return E_ERROR;
  14. }

从指令式函数返回错误码轻微违反了指令与询问分隔的规则。它鼓励了在if语句判断中把指令当作表达式使用。

另一方面,如果使用异常替代返回错误码,错误处理代码就能从主路径代码中分离出来,得到简化:

  1. try {
  2. deletePage(page);
  3. registry.deleteReference(page.name);
  4. configKeys.deleteKey(page.name.makeKey());
  5. } catch (Exception e) {
  6. logger.log(e.getMessage());
  7. }

抽离Try/Catch代码块

Try/catch代码块丑陋不堪。它们搞乱了代码结构,把错误处理与正常流程混为一谈。最好把try和catch代码块的主体部分抽离出来,另外形成函数。

  1. public void delete (Page page){
  2. try {
  3. deletePageAndAllReferences(page);
  4. } catch (Exception e) {
  5. logger.log(e.getMessage());
  6. }
  7. }
  8. private void deletePageAndAllReferences (Page page) throws Exception {
  9. deletePage(page);
  10. registry.deleteReference(page.name);
  11. configKeys.deleteKey(page.name.makeKey());
  12. }

在上例中,delete函数只与错误处理有关。很容易理解然后就忽略掉。deletePageAndAllReference函数只与完全删除一个page有关。错误处理可以忽略掉。有了这样美妙的区隔,代码就更易于理解和修改了。

函数应该只做一件事。错误处理就是一件事。

结构化编程

有些程序员遵循Edsger Dijkstra的结构化编程规则[15]。Dijkstra认为,每个函数、函数中的每个代码块都应该有一个入口、一个出口。遵循这些规则,意味着在每个函数中只该有一个return语句,循环中不能有break或continue语句,而且永永远远不能有任何goto语句。

我们赞成结构化编程的目标和规范,但对于小函数,这些规则助益不大。只有在大函数中,这些规则才会有明显的好处。

如何写出这样的函数

写代码和写别的东西很像。在写论文或文章时,你先想什么就写什么,然后再打磨它。初稿也许粗陋无序,你就斟酌推敲,直至达到你心目中的样子。

我写函数时,一开始都冗长而复杂。有太多缩进和嵌套循环。有过长的参数列表。名称是随意取的,也会有重复的代码。不过我会配上一套单元测试,覆盖每行丑陋的代码。

然后我打磨这些代码,分解函数、修改名称、消除重复。我缩短和重新安置方法。有时我还拆散类。同时保持测试通过。

我并不从一开始就按照规则写函数。我想没人做得到。