# RefactoringStudy **Repository Path**: giteehuan/refactoring-study ## Basic Information - **Project Name**: RefactoringStudy - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2021-02-18 - **Last Updated**: 2021-02-20 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 项目重构说明 ## 案例选择说明 ## 项目背景 设想有一个戏剧演出团,演员们经常要去各种场合表演戏剧。 通常客户( customer)会指定几出剧目, 而剧团则根据观众( audience)人数及剧目类型来向客户收费。 该团目前出演两种戏剧:悲剧( tragedy)和喜剧( comedy)。 给客户发出账单时,剧团还会根据到场观众的数量给出“观众量积分”( volume credit)优惠, 下次客户再请剧团表演时可以使用积分获得折扣一一你可以把它看作一种提升客户忠诚度的方式该剧团将剧目的数据存储在一个简单的JSON文件中。 ### 剧目数据 plays.json 该剧团将**剧目**的数据存储在一个简单的JSON文件中 ```json { "hamlet": {"name": "Hamlet", "type": "tragedy"}, // 剧目名字和类型 "as-like": {"name": "As You Like It", "type": "comedy"}, "othello": {"name": "Othello", "type": "tragedy"} } ``` ### 账单数据 invoices.json 他们开出的**账单**也存储在一个JSON文件里 ```json [ { "customer": "BigCo", // 客户名字 "performances": [ // 演出列表 { "playID": "hamlet", "audience": 55 }, { "playID": "as-like", "audience": 35 }, { "playID": "othello", "audience": 40 } ] } ] ``` ### 打印账单详情 的函数 statement ```javascript function statement (invoice, plays) { let totalAmount = 0; // 总费用 let volumeCredits = 0; // 观众量积分 let result = `Statement for ${invoice.customer}\n`; const format = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: 2 }).format; for (let perf of invoice.performances) { const play = plays[perf.playID]; let thisAmount = 0; switch (play.type) { case "tragedy": thisAmount = 40000; if (perf.audience > 30) { thisAmount += 1000 * (perf.audience - 30); } break; case "comedy": thisAmount = 30000; if (perf.audience > 20) { thisAmount += 10000 + 500 * (perf.audience - 20); } thisAmount += 300 * perf.audience; break; default: throw new Error(`unknown type: ${play.type}`); } // add volume credits volumeCredits += Math.max(perf.audience - 30, 0); // add extra credit for every ten comedy attendees if ("comedy" === play.type) volumeCredits += Math.floor(perf.audience / 5); // print line for this order result += ` ${play.name}: ${format(thisAmount/100)} (${perf.audience} seats)\n`; totalAmount += thisAmount; } result += `Amount owed is ${format(totalAmount/100)}\n`; result += `You earned ${volumeCredits} credits\n`; return result; } ``` #### 函数说明 结果形式 ``` Statement for BigCo Hamlet: $650.00 (55 seats) As You Like It: $580.00 (35 seats) Othello: $500.00 (40 seats) Amount owed is $1,730.00 You earned 47 credits ``` ## 项目需求 ### 需求 1 在这个例子里, 我们的用户希望对系统做几个修改。 首先,他们希望以HTML格式输出详单。 现在请你想一想,这个变化会带来什么影响。对于每处追加字符串到 result变量的地方我都得为它们添加分支逻辑。这会为函数引入更多复杂度。 遇到这种需求时,很多人会选择直接复制整个方法,在其中修改输出HTML的部分。 复制一遍代码似乎不算太难,但却给未来留下各种隐患:一旦**计费逻辑**发生变化,我就得同时修改两个地方,以保证它们逻辑相同。如果你编写的是一个永不需要修改的程序,这样剪剪贴贴就还好。但如果程序要保存很长时间,那么**重复的逻辑**就会造成潜在的威胁。 ## 需求 2 现在,第二个变化来了:演员们尝试在表演类型上做更多突破,无论是历史剧、田园剧、田园喜剧、田园史剧、历史悲剧还是历史田园悲喜剧,无论一成不变的正统戏,还是千变万幻的新派戏,他们都希望有所尝试,只是还没有决定试哪种以及何时试演。 这对戏剧场次的计费方式、积分的计算方式都有影响。 作为一个经验丰富的开发者,我可以肯定:不论最终提出什么方案,他们一定会在6个月之内再次修改它。毕竟,需求通常不来则已来便会接踵而至。 为了应对**分类规则**和**计费规则**的变化,程序必须对statement函数做出修改。但如果我把statement内的代码复制到用以打印 HTML 工详单的函数中 ## 重构前的说明 我再强调一次,是需求的变化使重构变得必要。 如果一段代码能正常工作,并且不会再被修改,那么完全可以不去重构它。 能改进之当然很好,但若没人需要去理解它,它就不会真正妨碍什么。 如果确实有人需要理解它的工作原理,并且觉得理解起来很费劲,那你就需要改进一下代码了。 # 重构过程 ## 准备工作 TODO 准备好测试工具集 ## 阶段 1 分解 statement 函数 检查一下,如果我将这块代码提炼到自己的一个函数里,有哪些变量会离开原本的作用域。 在此示例中,是perf、play和thisAmount这3个变量。 前两个变量会被提炼后的函数使用,但不会被修改,那么我就可以将它们以参数方式传递进来。 我更关心那些**会被修改的变量**。这里只有唯一一个—thisAmount,因此可以将它从函数中直接返回。 我还可以将其初始化放到提炼后的函数里。修改后的代码如下所示。 ![image-20210218165603072](pic/image-20210218165603072.png) ### 提取 switch _将这块代码抽取成一个独立的函数 amountFor ![image-20210218170030274](pic/image-20210218170030274.png) ### 测试——小步修改 做完这个改动后,我会马上编译并执行一遍测试,看看有无破坏了其他东西。 无论每次重构多么简单,养成重构后即运行测试的习惯非常重要。 犯错误是很容易的——至少我知道我是很容易犯错 的。 做完一次修改就运行测试,这样在我真的犯了错时,只需要考虑一个很小的改动范围,这使得查错与修复问题易如反掌。 这就是重构过程的精髓所在:小步修改,每次修改后就运行测试。 如果我改动了太多东西,犯错时就可能陷入麻烦的调试,并为此耗费大把时间。 小步修改,以及它带来的频繁反馈,正是防止混乱的关键。 ### 提交——为回滚备份 做完上面的修改,测试是通过的,因此下一步我要把代码提交到本地的版本控制系统。我会使用诸如git或mercurial这样的版本控制系统,因为它们可以支持本地提交。每次成功的重构后我都会提交代码,如果待会不小心搞砸了,我便能轻松回滚到上一个可工作的状态。把代码推送(push)到远端仓库前,我会把零碎的修改压缩成一个更有意义的提交(commit)。 ### 重命名——为提炼的函数amountFor 内部的变量改名 ![image-20210218171912497](pic/image-20210218171912497.png) 个人的编码风格:永远将函数的返回值命名为“result”,这样我一眼就能知道它的作用。 ### 再次编译、测试、提交代码。 ### 重命名——函数参数——amountFor——perf->aPerformance ![image-20210218181447674](pic/image-20210218181447674.png) 使用一门动态类型语言(如JavaScript)时,跟踪变量的类型很有意 义。因此,我为参数取名时都默认带上其类型名 a开头应该是数组的意思。performance 是 演出列表。 ![image-20210218181842908](pic/image-20210218181842908.png) ![image-20210218182230409](pic/image-20210218182230409.png) https://www.w3school.com.cn/js/pro_js_variables.asp ### 分析amountFor 函数的2个参数的来源 ![image-20210219001314585](pic/image-20210219001314585.png) 观察 amountFor 函数时,我会看看它的参数都从哪里来。 aPerformance是从循环变量中来,所以自然每次循环都会改变, 但 play 变量是由performance变量**计算得到**的,因此根本**没必要将它作为参数传入**,我可以在amountFor函数中重新计算得到它。 ![image-20210219001951812](pic/image-20210219001951812.png) 当我分解一个长函数时,我喜欢将play这样的变量移除掉,因为它们创建了很多具有局部作用域的临时变量,这会使提炼函数更加复杂。这里我要使用的重构手法是以查询取代临时变量(178)。 #### 干掉函数参数play ##### 在 statement 中 抽取中间计算表达式为函数 ![image-20210218183040551](pic/image-20210218183040551.png) ##### 在 statement 中 通过内敛变量将原来使用局部变量的地方替换成方法 ![image-20210218183445963](pic/image-20210218183445963.png) ##### 移除 amountFor 的 play 参数 ###### 第一步:在amountFor函数内部使用新提炼的函数。编译、测试、提交 ![image-20210218184701822](pic/image-20210218184701822.png) ###### 最后删除amountFor 的 play 参数 ![image-20210218184953662](pic/image-20210218184953662.png) ###### 然后再一次编译、测试、提交 ### 分析 处理完amountFor的参数后,回过头来一下它的调用点。 它被赋值给一个临时变量,之后就不再被修改,因此我又采用内联变量(123)手法内联它。 ![image-20210218185429159](pic/image-20210218185429159.png) 这会儿我们就看到了移除play变量的好处,移除了一个局部作用域的变量,提炼观众量积分的计算逻辑又更简单一些。 ### 分析 statement 函数的内部局部变量的使用 ![image-20210219003725904](pic/image-20210219003725904.png) perf同样可以轻易作为参数传入, 但volumeCredits变量则有些棘手。它是一个累加变量,循环的每次迭代都会更新它的值。 因此最简单的方式是,将整块逻辑提炼到新函数中,然后在新函数中直接返回volumeCredits。 #### 局部变量 volumeCredits 的处理 最简单的方式是, 将整块逻辑提炼到新函数中, 然后在新函数中直接返回volumeCredits。 ##### 抽取到新函数 #### ![image-20210219004447190](pic/image-20210219004447190.png) ##### 完善函数名字和局部变量名字 ![image-20210219004639939](pic/image-20210219004639939.png) ### 分析 再看一下statement这个主函数 正如我上面所指出的,临时变量往往会带来麻烦。 它们只在对其进行处理的代码块中有用,因此临时变量实质上是鼓励你写长而复杂的函数。 因此,下一步我要替换掉一些临时变量,而最简单的莫过于从format变量入手。 这是典型的“将函数赋值给临时变量”的场景,我更愿意将其替换为一个明确声明的函数。 #### 处理 statement 函数中的 format 变量 ##### 提取成函数 ![image-20210218223521253](pic/image-20210218223521253.png) ##### 对 format 函数重命名——usd 我对提炼得到的函数名称不很满意——format 未能清晰地描述其作用。formatAsUSD很表意,但又太长,特别它仅是小范围地被用在一个字符串模板中。我认为这里真正需要强调的是,它**格式化的是一个货币数字**,因此我选取了一个能体现此意图的命名,并应用了改变函数声明(124)手法。 ![image-20210218223744476](pic/image-20210218223744476.png) 好的命名十分重要,但往往并非唾手可得。只有恰如其分地命名,才能彰显出将大函数分解成小函数的价值。有了好的名称,我就不必通过阅读函数体来了解其行为。但要一次把名取好并不容易, 因此我会使用当下能想到最好的那个。如果稍后想到更好的,我就会毫不犹豫地换掉它。通常你需要花几秒钟通读更多代码,才能发现最好的名称是什么。 重命名的同时,我还将重复的除以100的行为也搬移到函数里。将钱以美分为单位作为正整数存储是一种常见的做法,可以避免使用浮点数来存储货币的小数部分,同时又不影响用数学运算符操作它。不过,对于这样一个以美分为单位的整数,我又需要以美元为单位进行展示,因此让格式化函数来处理整除的事宜再好不过。 ### 分析 下一个重构目标是volumeCredits。处理这个变量更加微妙,因为它是在循环的迭代过程中累加得到的。 #### 移除statement 函数中的观众量积分volumeCredits ##### step1 拆分为两个循环(227) ![image-20210218224652093](pic/image-20210218224652093.png) ##### step2 移动语句(223) ![image-20210218225201359](pic/image-20210218225201359.png) 把与更新volumeCredits变量相关的代码都集中到一起,有利于以查询取代临时变量(178)手法的施展。 ##### step3 以查询(即先抽取一个函数)取代临时变量(178) ![image-20210218225555191](pic/image-20210218225555191.png) 完成函数提炼后,我再应用内联变量(123) 手法内联totalVolumeCredits函数。 ##### step4 内联变量(123) 手法内联totalVolumeCredits函数 ![image-20210218230015127](pic/image-20210218230015127.png) #### 类似方法移除 totalAmount(多了一步要重命名的环节) ##### step 1 2 3 ![image-20210218230708925](pic/image-20210218230708925.png) ##### step 4 重命名函数 ![image-20210218234201644](pic/image-20210218234201644.png) ##### step 5 干掉变量 内敛变量 ![image-20210218234259143](pic/image-20210218234259143.png) ##### step 6 完善函数内部的细节——变量重命名 ![image-20210218234611653](pic/image-20210218234611653.png) ## 阶段2 开始新的需求(使用 HTML 打印详单)前——将计算数据和使用数据分离 2 个阶段分离 ### 审视已完成的工作 ![image-20210219011436476](pic/image-20210219011436476.png) 现在代码结构已经好多了。 顶层的statement 函数现在只剩7行代码,而且它处理的都是与打印详单相关的逻辑。 与计算相关的逻辑从主函数中被移走,改由一组函数来支持。 每个单独的计算过程和详单的整体结构,都因此变得更易理解了。 ### 新阶段的任务 与 问题 可以更多关注我要修改的功能部分了,也就是为这张详单提供一个HTML版本。 不管怎么说,现在改起来更加简单了。 因为计算代码已经被分离出来,我只需要为顶部的7行代码实现一个HTML的版本。 问题是,这些**分解出来的函数** **嵌套 ** 在**打印文本详单的函数**中。无论嵌套函数组织得多么良好,我总不想将它们全复制粘贴到另一个新函数中。我希望**同样的计算函数**可以被**文本版详单**和**HTML版详单** **共用** ### 解决复用的思路(很关键) 要实现复用有许多种方法,而我最喜欢的技术是**拆分阶段**(154)。这里我的目标是将逻辑分成两部分:一部分计算详单所需的数据,另一部分将数据渲染成文本或HTML。 第一阶段会创建一个**中转数据结构**,再把它传递给第二阶段。 ### 开工 ##### step1 对组成第二阶段的代码应用提炼函数(106) 注意:statement 中所有的东西都要搬过去 TODO ![image-20210219112438719](pic/image-20210219112438719.png) ##### step2 创建在两个阶段间传递的中转数据结构 接着创建一个对象,作为在两个阶段间传递的中转数据结构,然后将它作为第一个参数传递给renderPlainText(然后编译、测试、提交)。 ![image-20210219112659365](pic/image-20210219112659365.png) ##### step3 分析检查一下renderPlainText(第二阶段)用到的其他参数。 希望将它们挪到这个中转数据结构里, 这样所有计算代码都可以被挪到statement函数 中,让renderPlainText**只操作通过data参数传进来的数据**。 ###### step1 将顾客(customer)字段添加到中转对象 ![image-20210219121936538](pic/image-20210219121936538.png) ###### step2 将performances字段也搬移过去 ![image-20210219122740240](pic/image-20210219122740240.png) ###### step3 分析 ——将play数据封装到 aPerformance 中 ###### 1 将play 字段添加到 aPerformance 中(aPerformance 在 中转数据结构中)——使用副本 现在,我希望“剧目名称”信息也从中转数据中获得。为此,需要使用play中的数据填充aPerformance对象 ![image-20210219123521909](pic/image-20210219123521909.png) ###### 2 在副本中添加数据(将 play 数据填进去) 并且 移动函数 ![image-20210219163034166](pic/image-20210219163034166.png) ###### 3 替换renderPlainText中对playFor的所有引用点,让它们使用参数 aPerformance 获取数据 ![image-20210219163439349](pic/image-20210219163439349.png) ###### step4 分析——将mount 数据封装到 aPerformance 中 ![image-20210219164406805](pic/image-20210219164406805.png) ###### step5 分析——move the volume credits calculation ![image-20210219165855121](pic/image-20210219165855121.png) ###### step6 分析——move the two calculations of the totals 注意:这次移动的位置是 statement 函数 ![image-20210219174334185](pic/image-20210219174334185.png) ###### step7 分析——Replace Loop with Pipeline. ![image-20210219171923777](pic/image-20210219171923777.png) ## 阶段 3 将计算数据和使用数据的代码分两个文件保存 由于两个阶段已经彻底分离,我干脆把它搬移到另一个文件里去(并且修改了返回结果的变量名,与我一贯的编码风格保持一致)。 ### 第一步 提取第一阶段(计算)的部分 ![image-20210219190846618](pic/image-20210219190846618.png) ### 第二步 将两个阶段分离到不同的文件中 ![image-20210219190505819](pic/image-20210219190505819.png) ### 函数 API 说明 statement // renderPlainText // usd // 格式化函数——单位处理 totalAmount // 计算某个顾客观看的所有演出的费用 totalVolumeCredits // 计算某个顾客所有的观众量积分优惠 playFor // 根据顾客的演出列表 performances 的 playID 到 剧目数组中 查询 演出名字 volumeCreditsFor //计算顾客 观众量积分优惠 amountFor // 计算每场演出的费用 ![image-20210219143913188](pic/image-20210219143913188.png) ## 阶段 4 按类型重组计算过程 ### 分析已完成的工作 分离了详单的计算逻辑与样式。这种模块化使我更容易辨别代码的不同部分,了解它们的协作关系。虽说言以简为贵,但可演化的软件却以明确为贵。 通过增强代码的模块化,我可以轻易地添加HTML版本的代 码,而无须重复计算部分的逻辑。 ### 展望下一步工作 接下来我将注意力集中到下一个特性改动:支持**更多类型的戏剧**,以及**支持它们各自的价格计算和观众量积分计算**。 对于现在的结构,我只需要**在计算函数里添加分支逻辑**即可。amountFor函数清楚地体现了,戏剧类型在计算分支的选择上起着关键的作用——但这样的分支逻辑很容易随代码堆积而腐坏,除非编程语言提供了更基础的编程语言元素来防止代码堆积。 要为程序引入结构、显式地表达出“计算逻辑的差异是由类型代码确定”有许多途径,不过最自然的解决办法还是使用面向对象世界里的一个经典特性——**类型多态**。传统的面向对象特性在JavaScript世界一直备受争议,但新的ECMAScript 2015规范有意为类和多态引入了一个相当实用的语法糖。这说明,在合适的场景下使用面向对象是合理的——显然我们这个就是一个合适的使用场景。 我的设想是先建立一个继承体系,它有“喜 剧”(comedy)和“悲剧”(tragedy)两个子类,子类各自包含独立的计算逻辑。调用者通过调用一个多态的amount函数,让语言帮你分发到不同的子类的计算过程中。volumeCredits函数的处理也是如法炮制。为此我需要用到多种重构方法,其中最核心的一招是以多态取代条件表达式(272),将多个同样的类型码分支用多态取代。 但在施展以多态取代条件表达式(272)之前,我得先创建一个基本的继承结构。我需要先创建一个类,并将价格计算函数和观众量积分计算函数放进去。 #### step 1 检查计算代码 enrichPerformance函数是关键所在,因为正是它用每场演出的数据来填充中转数据结构。目前它直接调用了计算价格和观众量积分的函数,我需要创建一个类,通过这个类来调用这些函数。由于这个类存放了与每场演出相关数据的计算函数,于是我把它称为演出计算器(performance calculator) ![image-20210219211911491](pic/image-20210219211911491.png) #### step 2 创建一个类(预期通过这个类来调用计算价格和观众量积分的函数) ![image-20210220102441816](pic/image-20210220102441816.png) #### step 3 使用改变函数声明(124)手法,将performance的play字段传给计算器 ![image-20210220102622407](pic/image-20210220102622407.png) #### step 4 将amount函数搬移进计算器 要搬移的下一块逻辑,对计算一场演出的价格(amount)来说就尤为重要了。在调整嵌套函数的层级时,我经常将函数挪来挪去,但接下来需要改动到更深入的函数上下文,因此我将小心使用搬移函数(198)来重构它。 ##### step 1 将amountFor函数的逻辑复制一份到新的上下文中,也是PerformanceCalculator类中 ![image-20210220103059424](pic/image-20210220103059424.png) ##### step 2 微调一下代码 ![image-20210220103219305](pic/image-20210220103219305.png) ##### step 3 将原来的函数改造成一个委托函数,让它直接调用新函数。 ![image-20210220103359492](pic/image-20210220103359492.png) ##### step 4 应用内联函数(115),让引用点直接调用新函数 ![image-20210220103524442](pic/image-20210220103524442.png) #### step 5 类似搬移计算价格搬移观众量积分计算 ##### step 1 将volumeCreditsFor函数的逻辑复制一份到新的上下文中,也就是PerformanceCalculator类中 ![image-20210220103635255](pic/image-20210220103635255.png) ##### step 2 微调一下代码 如上图,使用 this ##### step 3 将原来的函数改造成一个委托函数,让它直接调用新函数。 ![image-20210220103838694](pic/image-20210220103838694.png) ##### step 4 应用内联函数(115),让引用点直接调用新函数 ![image-20210220104012508](pic/image-20210220104012508.png) ### 阶段 5 让计算器表现出多态性 #### 分析已完成的工作 已将全部计算逻辑搬移到一个类中,是时候将它多态化了。 #### step 1 应用以子类取代类型码(362)引入子类,弃用类型代码。 ##### step 1 使用以工厂函数取代构造函数(334)。 ![image-20210220104342086](pic/image-20210220104342086.png) ##### step 2 创建演出计算器的子类 ![image-20210220104415151](pic/image-20210220104415151.png) ##### step 3 由工厂函数决定返回哪一个子类的实例 ![image-20210220104601031](pic/image-20210220104601031.png) 准备好实现多态的类结构后,我就可以继续使用以多态取代条件表达式(272)手法了 ##### step 4 以多态取代条件表达式(272)——将原来条件表达式中的逻辑拆分到不同子类中 ###### step1_悲剧 将悲剧的价格计算 由计算器 下移到 计算子类中 ![image-20210220104850521](pic/image-20210220104850521.png) ###### step2_悲剧 父类计算器 PerformanceCalculator 不直接删掉处理悲剧的分支,而是抛出异常 ![image-20210220105036278](pic/image-20210220105036278.png) ###### step1_喜剧 将喜剧的价格计算 由计算器 下移到 计算子类中 ![image-20210220105202870](pic/image-20210220105202870.png) ###### step2_喜剧 此时,不删掉父类的 amount 方法,而是抛出异常 ![image-20210220105330339](pic/image-20210220105330339.png) ###### step1_观众量积分的计算 替换观众量积分的计算的条件表达式 分析: 回顾了一下前面关于未来戏剧类型的讨论, 发现大多数剧类在计算积分时都会检查观众数是否达到30,仅一小部分品类有所不同。 因此,将更为通用的逻辑放到超类作为默认条件,出现特殊场景时按需覆盖它. step 1 父类 volumeCredits 方法 step 2 子类 volumeCredits 方法 ![image-20210220105716739](pic/image-20210220105716739.png) ##### step 5 分析观摩代码 ### 阶段 6 总结 本章的重构有3个较为重要的节点,分别是: 1. 将原函数分解成一组嵌套的函数(decomposing the original function into a set of nested functions) 2. 应用拆分阶段(154)分离计算逻辑与输出格式化逻辑(using Split Phase to separate the calculation and printing code) 3. 为计算器引入多态性来处理计算逻辑。(introducing a polymorphic calculator for the calculation logic) 每一步都给代码添加了更多的结构,以便我能更好地表达代码的意图。 好代码的检验标准就是人们是否能**轻而易举地修改**它。好代码应该直截了当:有人需要修改代码时,他们应能轻易找到修改点,应该能快速做出更改,而不易引入其他错误。一个健康的代码库能够最大限度地提升我们的生产力,支持我们更快、更低成本地为用户添加新特性。为了保持代码库的健康,就需要时刻留意现状与理想之间的差距,然后通过重构不断接近这个理想。 开展高效有序的重构,关键的心得是:小的步子可以更快前进, 请保持代码永远处于**可工作**状态,小步修改累积起来也能大大改善系统的设计。