专栏名称: 逸言
文学与软件,诗意地想念。
目录
相关文章推荐
手游那点事  ·  全球手游收入Top20:《王者荣耀》空降第一 ... ·  2 天前  
人生研究所  ·  汪小菲VS大S:「畸形」的爱,是一种病 ·  昨天  
手游那点事  ·  “上海圈vs广州圈vs北京圈,2025谁会更 ... ·  3 天前  
51好读  ›  专栏  ›  逸言

简单设计与重构实践

逸言  · 公众号  ·  · 2024-05-27 11:33

正文

为了保证代码质量,都会要求在编码之前做一些合理的设计。然而,设计毕竟不是真正能运行的代码,脱离编程实现的设计可能会出现以下问题:

  1. 设计不足:未能有效考虑到编码实现的复杂度,使得设计不足,需要不断完善
  2. 设计过度:过度考虑编程实现或需求任务的复杂度,使得设计过度,增加了设计开销
要在不足与过度之间取得平衡,做到设计的恰如其分,并不容易,毕竟需求总在发生变化,使得设计也需要随之演进。为了解决设计权衡的问题,Kent Beck提出了“简单设计”原则。据Kent Beck所述,遵循以下规则,设计就能变得简单:
  1. 通过所有测试(Passes its tests)
  2. 尽可能消除重复 (Minimizes duplication)
  3. 尽可能清晰表达 (Maximizes clarity)
  4. 更少代码元素 (Has fewer elements)
  5. 以上四个原则的重要程度依次降低。

第一条规则:通过所有测试。它并不是要求项目必须编写自动化测试,而是指必须满足客户的需求。这里所谓的“测试”,应理解为客户验收。通过所有测试,就意味着必须满足客户的所有验收条件。这条规则定义了代码的“ 正确性

第二条规则:尽可能消除重复。《代码整洁之道》P158讲到的“简单设计”原则将其称为“不可重复”,这是不合理的(我并不知道Robert Martin的原文到底是什么,至少中文版是这样)。“不可重复”意味着不能允许有任何重复,这其实很可怕,并非所有的重复都可以消除,消除重复的成本也不可轻视;而“尽可能消除重复”包括英文minimize都表达了适可而止的含义,更符合简单设计的思想。可以将本条规则概括为代码的“ 复用性 ”。
第三条规则:尽可能清晰表达。它关注一个设计方案容易理解的程度,就代码实现而言,可以认为是代码的“ 清晰性 ”,也可以直观理解为“可读性”。当然,每个人对代码清晰度的理解各有不同,除了一些基本的代码可读性指标外,终归带有一定的主观性。
第四条规则:更少代码元素。它符合奥卡姆剃刀原则,即“若无必要,勿增实体”,即不可额外增加类、接口、方法、变量、常量、字段等代码元素。只有满足了这条规则,才可以保证设计的简单。它可以概括为代码的“ 简单性 ”。

第五条规则看起来不是规则,言外之意却是对以上各条规则的重要性排列,依次为:

正确性 > 复用性 > 清晰性 > 简单性

这一排列顺序明确地告诉我们:当以上四条发生冲突时,应该如何取舍。
一言以蔽之,首先必须满足代码的正确性,然后寻找可能的重复,并尽可能消除它们;满足了复用性之后,再看代码是否清晰可读,如果不够清晰,就需要进一步改进,直到代码能够清晰地表达开发人员的意图,此时,就必须遵循简单性,不可额外增加多余的变量、方法、函数、类或接口等代码元素。
简单设计原则也可以用于指导重构。 简单设计的过程,也就是在保证代码正确性的基础上通过重构改进代码质量的过程。复用性与清晰性原则的违背,说明代码存在两个坏味道:重复和不清晰。一旦消除了这两个坏味道,就应该遵循简单性原则,重构到此为止,避免设计过度。
接下来,我选用了《代码整洁之道》给出的Fitness中的HtmlUtil类作为示范,演示简单设计原则如何与重构相结合。
在代码库(在github的Repository: https://github.com/agiledon/cleancode.git )中找到位于fitness下的HtmlUtil类,为演示方便,它只定义了一个testableHtml()方法:
实际上,作为一个工具类,除了该方法外,它还定义了更多的辅助方法。
我们按照简单设计的规则开展重构。重构的目标是改进testableHtml()的代码质量,这里先假定它的实现是满足需求的,即遵循了第一条规则的正确性,所以,接下来应该从第二条规则开始执行,即检查当前的实现有没有重复。
比较第9-16行(第一段)、第18-25行(第二段)、第29-37行(第三段)、第39-46行(第四段),是否有似曾相识的感觉?就好像有人根据第9-17行的样子刻了一个图章,然后再三印下图章的图案,并对图案做出微小的调整。我很怀疑当初写代码的家伙,暗地里祭出了Ctrl + C、Ctrl + V的复制粘贴大法。
这样的重复显而易见,我相信所有读者都能轻易看到。现在,可通过重构消除这些重复。最直观的做法就是针对这些相似代码提取方法,实现方法级的复用。
对比其他三个代码块,第29-37行的代码块要多一行代码,原因是第33和34行没有把换行符和其他字符串放在一起。为了保证相同性,可以考虑将其合并:
针对这四段代码块进行方法提取;然而,重构的结果却出乎我的意料之外。当我针对第一段代码块提取方法时,一切正常,我考虑将提取出来的新方法命名为includeInheritedPage()。通过Cmd + Opt + M提取方法后,弹出“Extract Parameters to Replace Duplicates”对话框,这是合理的,因为buffer追加的内容确实存在差异,通过提取一个参数来抹掉这个差异,乃是正解:
选择工具给出的默认项“Accept Signature Change”。这时,IDEA就帮我们找到了重复代码,并询问我们是否需要替换:
选择“All”,替换所有重复的代码。结果发现, 我们只是将重复的第一段与第四段替换为新提取出来的方法。
要说差异,第四段与第一段的差异,与第二段、第三段并没有什么不同。之所以IDEA无法侦测到, 原因是它们所处的位置 。对比提取方法前的四个代码块,发现重复的第一段与第四段位于相同嵌套层次,第二段和第三段也位于相同嵌套层次,但它们又比第一段和第四段要高一层。
如果此时针对第二段提取方法(为避免方法名称重复,暂命名为includeInheritedPage1),就会发现此操作与刚才的操作完全相同,但它会侦测到第三段出现了重复。于是,得到现在所示代码:
出现重复的代码块分别替换为includeInheritedPage()方法和includeInheritedPage1()方法。对比二者,除了内部的变量名称不同,并无实际差异:
此时可调整includeInheritedPage()方法的参数名与内部变量名。因为该方法能够满足四种场景,则其参数和内部变量的名称应尽可能通用:
因为includeInheritedPage()和includeInheritedPage1()方法的内部实现完全相同,所以,可手动将调用includeInheritedPage1()的代码修改为对includeInheritedPage()的调用,然后安全删除includeInheritedPage1()方法即可。
消除了这四段重复后的代码如下所示:
代码行也从原来的51行锐减到34行,消除重复的效果是立竿见影的。
依据简单设计原则,既然要“尽可能消除重复”,那就继续看看当下的代码还有没有重复?——有,但是不明显,重复度也不高,就是第7行与第14行代码都调用了pageData的hasAttribute("Test")方法,若将它们提取为一个私有方法,可以做到微小程度的复用。
我们在提取方法时,一定要仔细思考方法的名称。方法的名称不应暴露封装的细节,要从该方法做什么的角度去思考,即方法名称应体现what to do,而非how to do。
我举一个实际发生的例子。我当初参与研发一款BI产品,使用了百度开源的EChart绘制可视化图形。需求要求用户在点击图形时,当前选中图形应高亮显示。EChart并没有直接支持高亮的特性,但可以在实现上通过对选中图形设置透明度,达到类似的效果。具体做法就是将当前图形元素的itemStyle.normal.opacity设置为0.5。
既然是设置透明度,也就是让图形变得透明,因而,我将JS函数命名为makeTransparent。这就是典型从how to do的角度对方法/函数命名。倘若从what to do的角度命名,正确的JS函数名称应该为highlight。
从what to do的角度思考pageData.hasAttribute("Test")的目的,实则就是根据pageData判断当前page是否为测试页,故而将提取出来的方法命名为isTestPage():
提取方法后,IDEA自动侦测到这两处重复,它会进行自动替换。
与其说这个操作消除了重复,不如说它提升了代码的可读性。多数时候,导致代码不可读都是因为没有做好合理的封装。虽然该操作只是对pageData.hasAttribute("Test")一个语句进行了封装,道理却是相通的。对于testableHtml()方法的阅读者而言,关心的是当前pageData是否为测试页,并不需要了解该如何判断pageData是否测试页,也就是对hasAttribute()的调用属于实现细节。
同理,让我们阅读现在的代码,能一下子弄明白调用includeInheritedPage()方法的四个位置,究竟有何差异吗?此外,调用该方法时,为何需要传递如此多的参数值呢?这些都影响了代码的清晰性。
遵循简单设计原则,我们需要对现在的代码继续进行重构,使得代码可读,就是要用代码 自声明 它究竟做了什么,而不是该怎么做! 最好的自声明方式,就是用合理的方法命名与细节隐藏

这里需要交代一下理解该段代码的背景。testableHtml()方法是对测试页面的各个部分进行处理,以获得对用户友好的提示信息。一个完整的测试页面应该包含五个部分:

  1. suite setup
  2. setup
  3. test content
  4. teardown
  5. suite teardown

其中,第一部分和第五部分为可选,由传入的includeSuiteSetup参数值决定。除了第三部分,其余部分都调用了之前提取出来的新方法includeInheritedPage()。为了同时满足四个场景,该方法的定义与实现具有一定的通用性。可正是为了确保它的通用性,就不可避免要牺牲它的可读性与易用性。方法名称过于通用,无法清晰呈现不同的业务场景;定义了多个参数,允许调用者针对不同的业务场景,传递不同的inheritedPageName和pageName值。
可见,通用性的方法更加有利于去除代码的重复,但可读性和易用性往往不够好。要在复用性和清晰性之间寻得一个平衡,可以在提取通用性方法的基础之上,继续提取方法,定义更加具体的方法。就本例而言,就是针对四个调用点所处的不同业务场景,各自提取对应的方法。重构过程是将光标移到调用点,通过Cmd + Opt + M提取方法,在弹出的Expression快捷菜单中,选择覆盖整个调用语句的菜单项:
以下是提取出来的四个方法:

这四个方法的提取不仅没有消除重复代码,反而增加了多余的代码量(因为增加了四个私有方法的定义),但代码的可读性有了很大的提高:

  1. 方法名称直接体现其意图
  2. 封装了部分细节后,方法的参数数量明显减少

现在的testableHtml()方法如下所示:
四个新提取出来的方法放到各个调用点,其方法名明白无误地告知代码阅读者:它们各自包含了测试页面的哪一部分。
我对现在的代码还有一些不满意,因为多个if语句的嵌套,直接干扰了对程序主干的阅读和理解。
仔细分析当前代码的第13行和第20行,以及它前后的两个if语句,我发现之前的代码实现在分支语句的使用上完全多此一举。
当前实现是通过StringBuffer来“收集”要呈现的信息,最后,将其转换为字符串,调用pageData的setContent()方法来设置页面的内容。第13行代码的本意是说,无论是不是测试页面,都要将pageData的当前内容追加到StringBuffer中。可是,第20行又调用了pageData的setContent(),假设不是测试页面,代码只会执行第13、20行,它会先将pageData的content取出来放到StringBuffer中,然后又一字不改把它重新设置回pageData,这不是脱了裤子放屁吗?也就是说,对第13行和第20行代码而言,有无分支都是一样的,那就不如将它们合并到if条件分支下。代码调整为:
如此一来,整个方法的主干都放到了if分支下,再在合适的位置增加空行,即可看到清晰的结构:
在if分支中,第6、7行是必要的赋值与初始化,第9-17是对pageData的解析,第19行是对结果的处理,符合事前、事中、事后的三段式结构。
仔细观察第9-17行的代码,发现第13行代码稍显不伦不类。因为对应的includeXXX()方法隐藏了通过StringBuffer添加信息的细节,只有第13行暴露了这个细节,导致它的抽象层次与其他各行的抽象层次并不相同,违背了“单一抽象层次原则”。Kent Beck在《实现模式》一书中明确指出:
通过对其他方法的调用来组合出新的方法,被调用方法应大致属于相同的抽象层次。抽象层次的混杂预示着糟糕的组合。……流畅的代码更易于理解,而跳跃的抽象层次破坏了代码的流畅性。
testableHtml就是通过对includeXXX方法的调用形成的组合方法。如前所述,构成测试页面的五个部分,除了第三部分,在这个方法中都有体现。因而应该针对第13行再提取一个方法includeTestContent()。提取后,单单对比这几行代码,抽象层次是否就变得纯粹多了?代码是否也变得更流畅了呢?
虽然testableHtml()方法变得更加流畅,但增加的多个私有方法也会破坏代码的清晰度。一般说来,提取出来的被调用方法,其顺序最好和代码结构的调用顺序保持一致,因而我通过Cmd + 上下光标键依次调整了这5个方法的顺序:
现在,代码的清晰性就得到了最大的保障:合理的方法命名,一致的抽象层次,降低了阅读代码的难度,清晰地传递出开发者的意图。
我们再仔细检查一下还有没有影响可读性的内容。注意,要提升代码的可读性,除了代码的结构、合理的封装之外,不要忘记检查类、方法、字段、变量和参数的命名。如果不注意命名,要么就会传递出错误的信息,要么就会增加阅读的难度。看看现在的代码,发现StringBuffer变量名为buffer,这个名称没有很好地传递它的含义。此外,testableHtml()方法的第二个参数名includeSuiteSetup会让人产生误解,以为只需判断它是否包含SuiteSetup,却与SuiteTeardown无关,这是不对的。
将buffer命名为newPageContent,把includeSuiteSetup命名为isSuite,意义就更清楚了。通过Shift + F6对它们重命名:
可惜,提取出来的诸多includeXXX()方法都使用了StringBuffer,导致它们的参数名也不合理,需要重命名。这额外增加了一点点重构成本,如果我们在提取方法之前,就对变量名做对应的修改,就无需在后面再重命名了。这也说明在进行重构时,选择重构手法的次序会对重构工作量产生一定的影响。
当然,我们还可以检查诸如SuiteResponder.SUITE_SETUP_NAME这样的常量,是否还有别的使用者(示例代码只有这一处调用,真实代码未必如此)。如果只有一个调用点,也可以考虑做对它们做内联。
此外,在调用includeInheritedPage()方法时,第四个参数都额外传递了"\n"换行符。由于大家都用到了这样的转义符,意味着这是通用的,应该将它们放回到includeInheritedPage()方法中(如果在提取该方法之前,先把换行符考虑好,就不会出现这个问题)。除了"\n",对比它们对格式和值的要求,发现前面的"include "与后面的" ."都是如此,应该和"\n"一并处理。pageName前面的"-"符号本来也是如此,但若是将它认为是传入页面的参数前缀,就应该保留在调用点。如此一来,即可修改为:
注意: 在修改时,不要忘了在合适位置添加空格符,否则会影响最终生成的信息格式。
之所以留了这么一个尾巴,其实是我在前面检查重复代码时没有察觉到的。这些重复虽然非常细微,还是应尽可能地消除它们。如前面章节所述,写好代码后,可以停一停,退后几步看一看;重构时,也应该这样,不然就会漏掉一些“卫生死角”。






请到「今天看啥」查看全文