你了解《重构》吗?

大家好,我是阿滨。

前言

相信很多同学都听过《重构》这本书的大名,但是并没有真正把它捧在手里读过,或者是打开电脑阅读它的电子版。甚至绝大数同学会思考重构代码到底是否有必要,他们认为与其花功夫在优化代码上,不如抓紧时间完成一个需求好向老板交差。当然也有许多同学意识到自己的代码,大多时候是认为别人的代码丑陋不堪,终于下定决心开始尝试重构它,但是又不知如何入手。今天,我就跟着《重构》这本书,来和同学们一起探讨一下为何重构,如何发现代码中的坏味道,以及我们该如何去重构它们。

第一个重构

作者在第一章的时候就给出了一个示例,向我们好好地演示了一波如何去重构一段代码,但是我并不打算马上在开头给出来,这对于一些还不太熟悉重构的同学会很措手不及,我在这里先给出它的题目和需要重构的代码,同学们可以先尝试思考为何它需要重构,到底又如何去重构它。

设想有一个戏剧演出团,演员们经常要去各种场合表演戏剧。通常客户(customer)会指定几出剧目,而剧团则根据观众(audience)人数及剧目类型来向客户收费。该团目前出演两种戏剧:悲剧(tragedy)和喜剧(comedy)。给客户发出账单时,剧团还会根据到场观众的数量给出"观众量积分"(volumecredit)优惠,下次客户再请剧团表演时可以使用积分获得折扣------你可以把它看作一种提升客户忠诚度的方式。

该剧团将剧目的数据存储在一个简单的JSON文件中。

json 复制代码
{
  "hamlet":{"name":"Hamlet","type":"tragedy"},
  "as-like":{"name":"AsYouLikeIt","type":"comedy"},
  "othello":{"name":"Othello","type":"tragedy"}
}

他们开出的账单也存储在一个JSON文件里。

css 复制代码
[  {    {    "customer":"BigCo",    "performances":[      {        "playID":"hamlet",        "audience":55      },      {        "playID":"as-like",        "audience":35      },      {        "playID":"othello",        "audience":40      }    ]
  }
]

下面这个简单的函数用于打印账单详情。

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;
}

用上面的数据文件(invoices.json和plays.json)作为测试输入,运行这段代码,会得到如下输出:

swift 复制代码
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

在这个例子中,如果我们的用户希望对系统做几个修改,比如他们希望以HTML格式输出详单,又或者演员们尝试在表演类型上做出更多的突破,程序该如何做出修改。同学们可以带着这个疑惑自己动手尝试一下,如果觉得无从下手也没关系,相信你读完这篇文章,也会有能力在这段代码上大显身手。

重构的原则

何谓重构

作者给出了两个定义:

重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
重构(动词):使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。

重构的关键在于运用大量微小且保持软件行为的步骤,一步步达成大规模的修改。 每个单独的重构要么很小,要么由若干小步骤组合而成。因此,在重构的过程中,我的代码很少进入不可工作的状态,即便重构没有完成,我也可以在任何时刻停下来。

两顶帽子

KentBeck提出了"两顶帽子"的比喻。使用重构技术开发软件时,我把自己的时间分配给两种截然不同的行为:添加新功能和重构。添加新功能时,我不应该修改既有代码,只管添加新功能。

为何重构

  • 重构改进软件的设计。 如果没有重构,程序的内部设计(或者叫架构)会逐渐腐败变质。
  • 重构使软件更容易理解。 在重构上花一点点时间,就可以让代码更好地表达自己的意图。
  • 重构帮助找到bug。 重构一段代码,意味着我可以深入理解代码的所作所为,这时想不把bug揪出来都难。
  • 重构提高编程速度。 在需要添加新功能或者修复bug时,内部质量良好的软件让我可以很容易找到在哪里修改、如何修改。

何时重构

  • 预备性重构。 让添加新功能更容易。
  • 帮助理解的重构。 使代码更易懂。
  • 捡垃圾式重构。 像捡垃圾一样马上重构它。
  • 有计划的重构和见机行事的重构。 如果过去忽视了重构,那就专门花一些时间来优化代码库。
  • 长期重构。 长期地去解决一个复杂的问题。
  • 复审代码时重构。
  • 怎么对经理说。 如果这位经理懂技术,能理解"设计耐久性假说",那么向他说明重构的意义应该不会很困难。否则,不要告诉经理
  • 何时不应该重构。 如果我看见一块凌乱的代码,但并不需要修改它,那么我就不需要重构它。另一种情况是,如果重写比重构还容易,就别重构了。

重构的挑战

  • 延缓新功能开发。 我可能会每天花费很多时间来进行重构,不知情的人会认为这是在浪费时间。但是作为一个程序员,我清楚的知道重构是为了让我添加功能更快,修复bug更快。
  • 代码所有权。 对于那些不属于我的代码,大部分情况下我没有权限写入他们的代码库,这会妨碍我重构这些代码。
  • 分支
    • 每个人都在一条分支上工作,进行相当大量的开发之后,才合并回主线分支。在隔离的分支上工作得越久,将完成的工作集成(integrate)回主线就会越困难。
    • 为了减轻集成的痛苦,大多数人的办法是频繁地从主线合并(merge)或者变基(rebase)到分支。但如果有几个人同时在各自的特性分支上工作,这个办法并不能真正解决问题,因为我不仅要把主线的修改拉(pull)到我的分支上,而且要把我这里修改的结果推(push)回到主线上,两边都会发生修改。
    • 持续集成(CI)。在使用CI时,每个团队成员每天至少向主线集成一次。
  • 测试。 为了快速发现错误,我的代码应该有一套完备的测试套件,并且运行速度要快,否则没有人愿意频繁运行它。
  • 遗留代码。遗留代码往往很复杂,测试又不足,而且最关键的是,是别人写的(瑟瑟发抖)。

重构、架构和YAGNI

有了重构技术,即便是已经在生产环境中运行了多年的软件,我们也有能力大幅度修改其架构。重构对架构最大的影响在于,通过重构,我们能得到一个设计良好的代码库,使其能够优雅地应对不断变化的需求。有了重构技术,我就可以采取不同的策略。与其猜测未来需要哪些灵活性、需要什么机制来提供灵活性,我更愿意只根据当前的需求来构造软件,同时把软件的设计质量做得很高。随着对用户需求的理解加深,我会对架构进行重构,使其能够应对新的需要。这种设计方法有很多名字:简单设计增量式设计 或者 YAGNI [mf-yagni]------"你不会需要它"(you arenʼt going to need it)的缩写。采用YAGNI并不表示完全不用预先考虑架构。总有一些时候,如果缺少预先的思考,重构会难以开展。

重构与软件开发过程

重构的第一块基石是自测试代码 。自测试的代码也是持续集成的关键环节,所以这三大实践------自测试代码持续集成重构 ------彼此之间有着很强的协同效应。有这三大实践在手,我们就能运用前一节介绍的 YAGNI 设计方法。重构和 YAGNI 交相呼应、彼此增效,重构(及其前置实践)是 YAGNI 的基础,YAGNI 又让重构更易于开展有这三大核心实践打下的基础,才谈得上运用敏捷思想的其他部分。 持续交付确保软件始终处于可发布的状态,很多互联网团队能做到一天多次发布,靠的正是持续交付的威力。即便我们不需要如此频繁的发布,持续集成也能帮我们降低风险,并使我们做到根据业务需要随时安排发布,而不受技术的局限。

重构与性能

虽然重构可能使软件运行更慢,但它也使软件的性能优化更容易。 除了对性能有严格要求的实时系统,其他任何情况下"编写快速软件"的秘密就是:先写出可调优的软件,然后调优它以求获得足够的速度。

重构起源何处

你肯定找不出重构起源何处,因为优秀程序员肯定至少会花一些时间来清理自己的代码。

代码的坏味道

神秘命名

一些糟糕的程序员会给一个变量起一个糟糕的名字,比如一个变量的含义是总和,他却将它命名为a,这实在是不堪入目。更糟糕的是,很多人经常不愿意给程序元素改名,觉得不值得费这个劲,但好的名字能节省未来用在猜谜上的大把时间。改名不仅仅是修改名字而已。如果你想不出一个好名字,说明背后很可能潜藏着更深的设计问题。为一个恼人的名字所付出的纠结,常常能推动我们对代码进行精简。

重复代码

一旦有重复代码存在,阅读这些重复的代码时你就必须加倍仔细,留意其间细微的差异。如果要修改重复代码,你必须找出所有的副本来修改。

过长函数

过长的函数往往承担了很多不属于它的职责,不仅会让你的代码混乱不堪,也会让阅读它的人头晕眼花。

过长参数列表

过长的参数列表会导致调用方和函数紧紧地耦合在一起,他的本身也让人疑惑。

全局数据

全局数据的问题在于,从代码库的任何一个角落都可以修改它,而且没有任何机制可以探测出到底哪段代码做出了修改。全局数据最显而易见的形式就是全局变量,但类变量和单例也有这样的问题。

可变数据

对数据的修改经常导致出乎意料的结果和难以发现的 bug。我在一处更新数据,却没有意识到软件中的另一处期望着完全不同的数据,于是一个功能失效了。

发散式变化

如果某个模块经常因为不同的原因在不同的方向上发生变化,发散式变化就出现了。每当要对某个上下文做修改时,我们希望只需要理解这个上下文,而不必操心另一个。

霰弹式修改

霰弹式修改类似于发散式变化,但又恰恰相反。如果每遇到某种变化,你都必须在许多不同的类内做出许多小修改,如果需要修改的代码散布四处,你不但很难找到它们,也很容易错过某个重要的修改。

依恋情结

有时你会发现,一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于在自己所处模块内部的交流,这就是依恋情结的典型情况。

数据泥团

你常常可以在很多地方看到相同的三四项数据:两个类中相同的字段、许多函数签名中相同的参数。这些总是绑在一起出现的数据真应该拥有属于它们自己的对象。

基本类型偏执

很多程序员不愿意创建对自己的问题域有用的基本类型,如钱、坐标、范围等。于是,我们看到了把钱当作普通数字来计算的情况。

重复的switch

重复的switch的问题在于:每当你想增加一个选择分支时,必须找到所有的switch,并逐一更新。

循环语句

可以使用以管道取代循环来让这些老古董退休。我们发现,管道操作(如 filter和map)可以帮助我们更快地看清被处理的元素以及处理它们的动作。

冗赘的元素

可能有这样一个函数,它的名字就跟实现代码看起来一模一样;也可能有这样一个类,根本就是一个简单的函数。这可能是因为,起初在编写这个函数时,程序员也许期望它将来有一天会变大、变复杂,但那一天从未到来;也可能是因为,这个类原本是有用的,但随着重构的进行越变越小,最后只剩了一个函数。不论上述哪一种原因,请让这样的程序元素庄严赴义吧。

夸夸其谈通用性

当你企图以各式各样的钩子和特殊情况来处理一些非必要的事情时,大可不必,这么做的结果往往造成系统更难理解和维护。

临时字段

有时你会看到这样的类:其内部某个字段仅为某种特定情况而设。这样的代码让人不易理解,因为你通常认为对象在所有时候都需要它的所有字段。在字段未被使用的情况下猜测当初设置它的目的,会让你发疯。

过长的消息链

这意味客户端代码将与查找过程中的导航结构紧密耦合。一旦对象间的关系发生任何变化,客户端就不得不做出相应修改。

中间人

你也许会看到某个类的接口有一半的函数都委托给其他类,这样就是过度运用委托。

内幕交易

软件开发者喜欢在模块之间建起高墙,极其反感在模块之间大量交换数据,因为这会增加模块间的耦合。在实际情况里,一定的数据交换不可避免,但我们必须尽量减少这种情况,并把这种交换都放到明面上来。

过大的类

如果想利用单个类做太多事情,其内往往就会出现太多字段。一旦如此,重复代码也就接踵而至了。

异曲同工的类

使用类的好处之一就在于可以替换:今天用这个类,未来可以换成用另一个类。但只有当两个类的接口一致时,才能做这种替换。

纯数据类

所谓纯数据类是指:它们拥有一些字段,以及用于访问(读写)这些字段的函数,除此之外一无长物。

被拒绝的遗赠

子类应该继承超类的函数和数据。但如果它们不想或不需要继承,又该怎么办呢?

注释

常常会有这样的情况:你看到一段代码有着长长的注释,然后发现,这些注释之所以存在乃是因为代码很糟糕。

构筑测试体系

大多数程序员编写代码的时间仅占所有时间中很少的一部分。有些时间用来决定下一步干什么,有些时间花在设计上,但是,花费在调试上的时间是最多的。频繁地运行自测试可以让我们拥有强大的bug侦测能力。KentBeck将这种先写测试的习惯提炼成一门技艺,叫测试驱动开发(Test-Driven Development,TDD)[mf- tdd]。测试驱动开发的编程方式依赖于下面这个短循环:先 编写一个(失败的)测试,编写代码使测试通过,然后进行重构以保证代码整洁。这个"测试、编码、重构"的循环应该在每个小时内都完成很多次。这种良好的节奏感可使编程工作以更加高效、有条不紊的方式开展。我就不在这里再做更深入的介绍,但我自己确实经常使用,也非常建议你试一试。

尾声

看到这里,相信同学们对于重构是什么,什么需要重构有了一个清晰的认识,在下篇文章中我会结合《重构》来探讨一下重构的手法。希望大家有所收获。

相关推荐
PW2 天前
JavaScript基础实践:电话号码格式化的多种实现方式
javascript·代码规范
Kisorge2 天前
【C语言】C语言代码的编写规范、注释规范
java·c语言·代码规范
pe7er2 天前
使用 `echo` 命令美化 Shell 输出
前端·后端·代码规范
Cyrus丶5 天前
面向对象在前端的应用最佳实践(实战)
前端·代码规范
Pomelo_刘金6 天前
Rust:选择宏还是函数?
面试·rust·代码规范
SAP学习成长之路7 天前
如何在SM30生成的维护表中增加选择框 CheckBox
开发语言·数据库·sap·健康医疗·abap·代码规范
Cyrus丶10 天前
前端组件化开发指南(二)
前端·代码规范
FrozenMoments11 天前
编写可读代码的艺术
代码规范
陈陈陈建蕾14 天前
回顾前端 - 为多个小程序设计一个MonoRepo架构
前端·架构·代码规范
记忆深处的声音14 天前
vue2 + Element-ui 二次封装 Table 组件,打造通用业务表格
前端·vue.js·代码规范