重构改善现有代码的设计

前文 对本书的的评论无外乎是“本程序员的案头书”,“如果能在职业生涯初期读到这本书能少写多少恶臭的代码”云云。言而总之,这本书应该相当适合初入职场/尚未形成个人代码风格/读到屎山不是选择含泪生吞而是想把始作俑者活剥/一个有公德心的代码贡献者的fresh coder。
在开始读书之前,不妨先去简单和作者打个照面~
Martin Fowler
个人官方网站的Nav Bar可以快速获得他作为技术人员的几个关键词,Refractoring, Agile, Architecture,Thoughtworks。而在网站综述中,高亮文本进一步囊括了MicroService,Testing,Continuous Delivery。搭配着profile photo中甑光瓦亮的脑门和不修边幅的胡茬,作者留给我的第一印象是一位对技术有偏执追求的,严谨而乐于奉献的业界前辈。More codeful, more hairless。
而在阅读完本书的第一章后,我更加发现他是一个出色的内容撰写者和知识分享者。很多过往阅读的技术类书籍,包括O'Reilly在内,作者输出了很多高屋建瓴的观点,但是选取的例子却往往显得片面/没有代表性/没有可实现性。就我个人而言,动手实战+过程反思往往是消化知识最有效的方式,没有自我参与的技术书籍阅读就像是夕阳下的奔跑,你看着作者的影子在夕阳下越拉越长,最后消失在地平线,徒留一阵惘然若失。而之所以敬佩本书作者的原因,正是在于从第一章的选例讲解中,你可以看到一个精细的高密度到低密度的知识渗透过程,而不是知识泄洪,作者尽可能地把每一小步重构的变动都转化为代码,并用不同的字体标注了有变动的地方,以保证读者能够跟得上进度。
第一章 重构,第一个示例 在这一章作者引入了一个例子用于说明

  • Why We Need Refactoring
  • How We Implement Refactoring

    重构改善现有代码的设计
    文章图片
    Code Refactoring - Performance Audience Credit.png
【重构改善现有代码的设计】ok,我本身对js并不是非常熟悉,就简单用WebStorm实现了一下js的逻辑。当然诚如作者所言,语言更多的只是载体,在本书第一版中java是主要的逻辑载体。下面的代码打印了客户的用户积分,作者给出这一版粗粝的代码想要展示如何通过重构技巧,提高代码的可拓展性和可读性。
let plays = { "hamlet": {"name": "Hamlet", "type": "tragedy"}, "as-like": {"name": "As You Like It", "type": "comedy"}, "othello": {"name": "Othello", "type": "tragedy"} } let invoices = [ { "customer": "BigCo", "performances": [ { "playID": "hamlet", "audience": 55 }, { "playID": "as-like", "audience": 35 }, { "playID": "othello", "audience": 40 } ] } ]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; }console.log(statement(invoices[0], plays));

输出
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

首先得承认这段代码是一颗顽石,仅就学习过的设计方法而言,这段代码显而易见地不满足开闭原则,这就要求对公共代码进行抽象,而基于单一职责原则,这个方法冗杂了太多的业务逻辑,包括费用的计算和积分的计算功能和打印记录可视化的功能。
那么在没有进一步学习作者的专业技巧之前,我们可以简单地尝试自行重构这段代码。
  • 对费用计算进行整合
function statement (invoice, plays) { let totalAmount = 0; let volumeCredits = 0; let result = `Statement for ${invoice.customer}\n`; for (let perf of invoice.performances) { let thisAmount = 0; thisAmount = getAmount(perf); // add volume credits volumeCredits += calcAudCredit(perf); // print line for this order result += `${getPlayFromPerf(perf).name}: ${usdFormat(thisAmount/100)} (${perf.audience} seats)\n`; totalAmount += thisAmount; } result += `Amount owed is ${usdFormat(totalAmount/100)}\n`; result += `You earned ${volumeCredits} credits\n`; return result; }function usdFormat(amount) { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: 2 }).format(amount); }function calcAudCredit(perf) { let volumeCredits = 0; volumeCredits = Math.max(perf.audience - 30, 0); // add extra credit for every ten comedy attendees if ("comedy" === getPlayFromPerf(perf).type) volumeCredits += Math.floor(perf.audience / 5); return volumeCredits; }function getPlayFromPerf(perf) { return plays[perf.playID]; }function getAmount(perf) { let amount = 0; let play = getPlayFromPerf(perf); switch (play.type) { case "tragedy": amount = 40000; if (perf.audience > 30) { amount += 1000 * (perf.audience - 30); } return amount; case "comedy": amount = 30000; if (perf.audience > 20) { amount += 10000 + 500 * (perf.audience - 20); } amount += 300 * perf.audience; return amount; default: throw new Error(`unknown type: ${play.type}`); } }

第一步我的想法是将业务功能不同的逻辑块尽可能抽象成方法,主逻辑中只通过调用方法表征业务逻辑。
下一步使方法能够面向新的需求,比如
  • 马戏团提供了更多种类的戏剧,其费用和用户积分的计算逻辑相应地改变
  • 输出格式有变更的需求,比如新的文本格式/新的货币单位
function statement (invoice, plays) { let activityData = https://www.it610.com/article/{}; initData(activityData, invoice); let result = `Statement for ${invoice.customer}/n`; for (let perf of invoice.performances) { enforceData(activityData, enforcePerformance(perf)); // print line for this order result += `${perf.play.name}: ${usdFormat(perf.amount / 100)} (${perf.audience} seats)/n`; } result += `Amount owed is ${usdFormat(activityData.totalAmount / 100)}/n`; result += `You earned ${activityData.volumeCredits} credits/n`; return result; function initData(activityData, invoice) { activityData.totalAmount = 0; activityData.volumeCredits = 0; activityData.invoice = JSON.parse(JSON.stringify(invoice)); }function usdFormat(amount) { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: 2 }).format(amount); }function enforceData(activityData, perf) { activityData.totalAmount += perf.amount; activityData.volumeCredits += perf.audCredit; return activityData; }function enforcePerformance(perf) { perf.calculator = buildCalculator(perf); perf.play = getPlayFromPerf(perf); perf.amount = perf.calculator.getAmount(); perf.audCredit = perf.calculator.calcAudCredit(); return perf; }function getPlayFromPerf(perf) { return plays[perf.playID]; }function buildCalculator(perf) { switch (getPlayFromPerf(perf).type) { case "tragedy": return new TragetyCalculator(perf); case "comedy": return new ComedyCalculator(perf); default: throw new Error(`unknown type: ${getPlayFromPerf(perf).type}`); } } }class OperaCalculator { constructor(perf) { this.perf = perf; }getAmount() { return 0; }calcAudCredit() { return Math.max(this.perf.audience - 30, 0); } }class ComedyCalculator extends OperaCalculator { getAmount() { let amount = 30000; if (this.perf.audience > 20) { amount += 10000 + 500 * (this.perf.audience - 20); } amount += 300 * this.perf.audience; return amount; }calcAudCredit() { return super.calcAudCredit() + Math.floor(this.perf.audience / 5); } }class TragetyCalculator extends OperaCalculator { getAmount() { let amount = 40000; if (this.perf.audience > 30) { amount += 1000 * (this.perf.audience - 30); } return amount; } }

在上面的代码中我把数值计算部分抽象到可继承的计算器类中,从而实现了可拓展。同时对有从属关系的变量进行模型化整合,这样一来进一步减少了主方法statement()中的逻辑表达,顶层方法面向抽象的pojo而不是具体参数。顺带一提,在实例传递时尽可能避免修改原实例中的值,当然这会造成一定的内存消耗,在代码中由于传递的类型比较简单我直接使用了JSON.parse(JSON.stringify(invoice))
的深复制方法,如果需要适配多种数据类型,最好去了解一下jQuery或其他框架是如何实现深复制的。
当重构来到这里我感觉遇到了瓶颈,打印部分的代码耦合在了循环体里,合并之前必须进行拆分,这样一来就不得不在代码中进行两次循环。这可能就是重构和性能的trade-off,看了作者的处理,也是将这部分逻辑从循环体中剥离从而组装成一个完整的打印方法,诚然对于这个功能而言增加一个循环体造成的时间损耗微乎其微,但毕竟性能优化并非重构的首要考量,我认为重构是面向业务扩展和面向可持续交付的。因此,在性能与重构交锋处留一个心眼,往往也不是什么坏事。
原书实现 statement.js
import createStatementData from './createStatementData.js'; function statement (invoice, plays) { return renderPlainText(createStatementData(invoice, plays)); } function renderPlainText(data, plays) { let result = `Statement for ${data.customer}\n`; for (let perf of data.performances) { result += `${perf.play.name}: ${usd(perf.amount)} (${perf.audience} seats)\n`; } result += `Amount owed is ${usd(data.totalAmount)}\n`; result += `You earned ${data.totalVolumeCredits} credits\n`; return result; } function htmlStatement (invoice, plays) { return renderHtml(createStatementData(invoice, plays)); } function renderHtml (data) { let result = `Statement for ${data.customer}\n`; result += "\n"; result += ""; for (let perf of data.performances) { result += ``; result += `\n`; } result += "
playseatscost
${perf.play.name}${perf.audience}${usd(perf.amount)}
\n"; result += `Amount owed is ${usd(data.totalAmount)}
\n`; result += `You earned ${data.totalVolumeCredits} credits
\n`; return result; } function usd(aNumber) { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: 2 }).format(aNumber/100); }

createStatementData.js
export default function createStatementData(invoice, plays) { const result = {}; result.customer = invoice.customer; result.performances = invoice.performances.map(enrichPerformance); result.totalAmount = totalAmount(result); result.totalVolumeCredits = totalVolumeCredits(result); return result; function enrichPerformance(aPerformance) { const calculator = createPerformanceCalculator(aPerformance, playFor(aPerformance)); const result = Object.assign({}, aPerformance); result.play = calculator.play; result.amount = calculator.amount; result.volumeCredits = calculator.volumeCredits; return result; } function playFor(aPerformance) { return plays[aPerformance.playID] } function totalAmount(data) { return data.performances .reduce((total, p) => total + p.amount, 0); } function totalVolumeCredits(data) { return data.performances .reduce((total, p) => total + p.volumeCredits, 0); } }function createPerformanceCalculator(aPerformance, aPlay) { switch(aPlay.type) { case "tragedy": return new TragedyCalculator(aPerformance, aPlay); case "comedy" : return new ComedyCalculator(aPerformance, aPlay); default: throw new Error(`unknown type: ${aPlay.type}`); } } class PerformanceCalculator { constructor(aPerformance, aPlay) { this.performance = aPerformance; this.play = aPlay; } get amount() { throw new Error('subclass responsibility'); } get volumeCredits() { return Math.max(this.performance.audience - 30, 0); } } class TragedyCalculator extends PerformanceCalculator { get amount() { let result = 40000; if (this.performance.audience > 30) { result += 1000 * (this.performance.audience - 30); } return result; } } class ComedyCalculator extends PerformanceCalculator { get amount() { let result = 30000; if (this.performance.audience > 20) { result += 10000 + 500 * (this.performance.audience - 20); } result += 300 * this.performance.audience; return result; } get volumeCredits() { return super.volumeCredits + Math.floor(this.performance.audience / 5); } }

第一章总结 本章中作者采用的重构手法有:
  • 提炼函数 Extract Function
  • 内联变量 Inline Variable
  • 搬移函数 Move Function
  • 以多态取代条件表达式 Replace Conditional with Polymorphism
    链接指向原书网页版对这些名词的图文解释
    对比作者的重构手法和个人的,不难发现,重构更多是coder基因里决定的东西。更准确地说,是人类大脑对更具组织化的对象的理解本能,驱动着我们去对代码进行重构。这种行为体现在简单的事物上你很难去区分其究竟是本能驱动还是依据了某种纲领,但在处理复杂的逻辑时,你总能发现有法可循的好处。
    那么总结一下作者采取的三个重构步骤
  1. 将原函数拆分成一组嵌套的函数
  2. 采用拆分阶段 Split Phase分离计算逻辑和格式化输出
  3. 为计算器引入多态性处理计算逻辑
第二章 重构的原则 这两天忙着维护github page了,结果再读第二章就已经是两天后的事情了。第二章作者说明了何为重构,为何重构和何时重构。
重构/名词,对软件内部的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
Refactoring (noun): a change made to the internal structure of software to make it easier to understand and cheaper to modify without changing its observable behavior.
重构/动词,使用一系列的重构手法,在不改变软件可观察行为的前提下,调整其结构。
Refactoring (verb): to restructure software by applying a series of refactorings without changing its observable behavior.
两顶帽子 由Kent Beck提出的两顶帽子理论,表示程序员的工作时间分配给两种截然不同的行为:添加新功能重构。外国友人是真喜欢用帽子做比喻...
为何重构
  • 重构改进软件的设计
  • 重构使软件更容易理解
  • 重构帮助发现bug
  • 重构提高编程速度
在此我把程序员理想中重构应该实现的效果图摘录一下:

重构改善现有代码的设计
文章图片
pseudograph.png 何时重构
The Rule of Three
Here’s a guideline Don Roberts gave me: The first time you do something, you just do it. The second time you do something similar, you wince at the duplication, but you do the duplicate thing anyway. The third time you do something similar, you refactor.
Or for those who like baseball: Three strikes, then you refactor.
三次法则
Don Roberts分享的准则中说到,凡遇事也,旦一且做,遇之再也,蹙眉做之,其三遇之,当思重构。
  • 预备性重构:让添加新功能更容易
  • 帮助理解的重构:使代码更易懂
  • 捡垃圾式重构
  • 有计划的重构和见机行事的重构
  • 长期重构
  • 复审代码时重构
读至此处我不由得开始反思工作以来遇到的许多项目,有些是重头开始设计,有些是半途接手,在自己管辖内大动干戈者有之,在别人壁炉上零敲碎打者有之。项目经验积累的越多,越感觉到架构的重要性。所谓架构,并不是arch的特权,通常情况是真正负责功能实现的人先草拟出系统的架构,再和架构师根据文档就细节进行讨论。而一个系统的好坏,往往可以从后续经手者的口口相传中获知一二,尽管“薄古厚今”的传统在技术人员中也广为流传。接手他人业务时,往往有尾大不掉之惑,一则在于项目的tech leader是否有丰富的项目接手经验,能够在交接过程中快速掌握业务功能的七寸并从原生团队中获得经验,另外接手者往往苦恼于,代码组织混乱,变量中间状态存储介质/变化法则不明,在缺乏文档(文档管理混乱/缺乏实效性)的情况下学习成本大大增加。
而另一位同事接手的System Refine工作中,需要将系统代码里经年累月累积的“特效药”代码进行分析评估并尽可能移除积弊。作为偶尔坐她旁边帮着分析过一段时间的经历者,我大概能明白为什么她的工作时间直线攀升。这类代码的特质往往是创造者/经手者大多已经离职或者远离coding一线,没有明确的方法注释,有很强的上下游业务依赖。因此分析起来往往需要经过提出猜测——本地debug验证猜测——找到上下游client确认是否仍旧需要的周期性反复工序。这些特效药代码,无非是业务需求下,笨拙的系统为了接纳新的功能产生的排异反应,开发者为了快速在两幢大楼间建立通道,无暇测量预留接口的大小是否合适,要么是把口子扯扯大,要么是用特殊质地的材料把接口塞塞紧,末了还在不起眼处打几颗至关重要的螺丝钉。工期结束楼盘交付后就不再想着怎么优化原有的不合理结构。
但其实作者也承认,实际情况下,当一块ugly code的存在不影响现有业务/接洽新的功能时,大部分情况下是可以容忍的。而一个系统的重构难度一旦过大,那迎来的就将是推翻重建。
大人 不是时代变了 朕的大清亡了 是因为其身上再没有一点能够流通的血液了
重构的挑战
  • 延缓新功能开发
  • 代码所有权
  • 分支/版本控制
  • 测试
  • 遗留代码
  • 数据库
    作者罗列了不少重构时会面临的阻碍,值得一提的事对于最后两点,作者介绍了两本有指导作用的书:
  • 《修改代码的艺术 Working Effectively with Legacy Code》[Feathers]
  • 《渐进式数据库设计》
  • 《数据库重构 Refactoring Databases》
    日后有时间应该会找来一并了解一下。
重构的三大基石
自测试代码,持续集成,重构
第二章总结 作者在第二章的观点在如今看来浅显易懂,是因为重构的理念如今已经为大多数技术人员接受。同样的,在几十年前重构所面临的诸多困难,也逐渐被持续交付/持续集成/自动化IDE一一解决。在我们小组的工作中,小规模的重构任务已经成为了我们日常工作量的一部分。我近期的一个比较ambitious的计划,就是给一个项目引入流式验证以取代以往验证逻辑积压在一个验证类中的做法。
——未完待续——
2020.05.05

    推荐阅读