重构,改善代码的既有设计——基本概念
基本概念
重构(n.)是对软件内部的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
重构(v.)是使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。
何为重构
-
重构改进软件的设计
如果没有重构,程序的内部设计会组件腐败变质,经常性的重构有助于代码维持自己该有的形态。
-
重构使软件更容易理解
编程的核心在于“准确的说出我想说什么”,然而你的源码还有其他读者(当然我觉得大部分时候都是未来的自己),他才是最重要的,所以很多时候代码要考虑给别人看。
-
重构帮助找到 bug
我没有盯着一大段代码就能找出 bug 的能力,此时如果对代码进行重构,就能深入代码的所作所为,重构能够帮我更有效地写出健壮的代码。
-
重构提高编程速度
前面的一切都归结到了一点,重构能够更快速地开发程序。我们需要记住一点,一旦开始编写代码,就算设计良好也会逐渐腐败,而不断的重构能够改善这种状况。
重构的挑战
-
该怎么和经理说重构的事
如果经理屁都不懂的话,就不管他,我才是软件的创造者,我认为能够实现新功能的方式是重构,我就去重构。
-
延缓新功能开发
重构的唯一目的就是让我们开发更快,用更少的工作量创造更大的价值,重构应该是由经济利益驱动。如果你是一个团队的技术领导,一定要向团队成员表明,你重视改善代码库健康的价值,何时是否应该重构这样的判断需要多年的累积。
-
代码所有权
很多重构手法不仅会影响一个模块内部,还会影响与其他部分的关系,有时调用方代码可能由令一支团队拥有。这样的函数属于已发布接口:接口的使用者与声明者彼此独立,声明者无权修改使用者的代码。可以把旧的接口标记为“不推荐使用”,但有些时候,旧的接口必须一直保留下去。
由于这些复杂性,推荐一支团队成员都可以修改这个团队的代码,每个人监控自己责任区内发生的修改。跨团队时可以在团队 A 的员工可以在一个分支上修改团队 B 的代码,同时把代码发给团队 B 审核。
-
分支
分支合并是一个复杂问题,随着特性分支存在的时间加长,合并到难度会指数上升。有种方法叫“持续集成(Continuous Integration, CI)”,也叫“基于主干开发(Trunk-Based Development)”。在使用 CI 时,每个团队成员每天至少向主线集成一次 。不过 CI 也有其代价,必须使用相关的实践以确保主线随时处于健康状态,必须学会将大功能拆分成小块,有时还需使用特性开关。
实际上,对于开源项目特性分支可能是合适的做法,但对全职的开发团队而言,用上 CI 的团队在软件的交付上更加高效。
-
测试
不会改变程序可观察的行为,这是重构的一个重要特征。人总有出错的时候,只要回滚到版本控制中最后一个版本就行了。这里的关键在于“快速发现错误”,绝大部分情况下,如果想要重构,得先有自测试的代码。自测试代码是极限编程的另一个重要组成部分,也是持续交付的重要环节。
-
遗留代码
遗留代码往往很复杂,测试又不足,最关键的是:是别人写的 :)。如果给缺乏测试的遗留系统加测试非常困难,因为它一开始就没有考虑到测试,此时可以尝试每次改善一点点。
重构相关
重构,架构和 YAGNI
有这样一种观点,在任何人写代码之前,必须先完成软件的设计和架构,一旦代码写出来,架构就固定了,只会因为程序员的草率对待而逐渐腐败。重构改变了这种观点,这种观点的最大问题在于,它假设了软件的需求可以预先充分理解,但经验显示,这个假设大多数时候是不切实际的,懂的都懂。
应对未来变化的办法之一,就是加入灵活性机制,比如预测给某个函数加入十多个参数,但有时这样会拖慢响应的速度。所以先评估“如果以后再重构会有多困难”,如果很困难时才会加入灵活性机制,这种设计方法又叫 YAGNI(you aren't going to need it) 这种工作方式必须有重构作为基础才可靠。
重构与软件开发过程
如果一个团队想要重构,那么每个团队成员都需要掌握重构技能,能够在需要时开展重构,而不会干扰其他人的工作,此时,自测试代码,持续集成,重构,彼此之间有很强的协同效应。有这三大核心时间打下的基础,才谈得上运用敏捷思想的其他部分,持续交付确保软件是总处于可以交付的状态,但这些东西说起来简单,做起来毫不容易。
重构与性能
大部分程序会把大半时间耗费在一小段代码上,所以我们先写出可调优的软件,然后调优它以获得足够的速度,之后分配一个性能调优阶段专门进行性能优化。
在性能优化阶段,我们应该用一个度量工具来监控程序的运行,之后找出性能热点并使用持续关注法来优化它们,和重构一样进行小幅度的修改,之后进行编译,测试,再次度量。知道达到客户满意的性能为止。
代码的坏味道
神秘命名
命名是编程最难的两件事之一,而改名可能是最常用的重构手法。
重复代码
重复代码需要使用提炼函数。
过长函数
函数越长,就越难理解。如果你的代码有注释,则可以把需要说明的东西写进一个独立函数中。分解成短函数之后,则需要进行良好的命名。
过长参数列表
实用类可以有效地缩短参数列表,同时也可以从现有的数据结构中抽出很多数据项。
全局数据
全局数据的问题在于,从代码库的任何一个角落都可以修改它,而没有任何机制可以探测出到底那段代码做出了修改。
可变数据
对数据的修改经常导致出乎意料的结果和难以发现的 bug,这种 bug 只在很罕见的情况下发现。函数式编程就是完全建立在数据永不变化的概念基础上,如果要更新一个数据结构,就返回一份新的数据副本,旧的数据仍保持不变。
发散式变化
如果某个模块经常因为不同的原因在不同的方向发生变化,发散式变化出现了,比如新加入一个数据库,我必须修改这三个函数;如果新出现一个金融工具,我就必须修改这四个函数,这就是发散式变化的征兆。
霰弹式修改
霰弹式修改类似于发散式变化,如果每遇到某种变化,你都必须在不同的类内做出修改,你所面临的坏味道就是霰弹式变化。
依恋情结
所谓模块化,就是力求将代码分出区域,最大化区域内部的交互、最小化跨区域的交互,但有时你会发现,一个函数和另一个模块中的函数或者数据交流格外频繁,远胜于自己模块内部的交流,这就是依恋情结的典型情况。
数据泥团
数据项就像小孩子,喜欢成群结队地待在一块,你尝尝可以在很多地方看到相同的三四项数据:两个类中相同的字段、许多函数签名中相同的参数,这些总是绑在一起出现的数据真应该拥有属于它们自己的对象。
基本类型偏执
大多数编程环境都大量使用基本类型。很多程序员不愿意创建对自己的问题域有用的基本类型,如钱、坐标、范围等。你可以以对象取代基本类型将原本单独存在的数据值替换为对象。
重复的 switch
如果你跟真正的面向对象布道者交谈,,任何 switch 语句都应该以多态取代条件表达式消除掉。
循环语句
循环已经有点儿过时,如今,函数作为一等公民已经得到了广泛的支持,因此我们可以使用以管道取代循环来让这些老古董退休。
亢赘的元素
程序元素能够给代码增加结构,但有时我们真的不需要这层额外的结构。
夸夸其谈通用性
当有人企图以各种各样的勾子和特殊情况来处理一些非标的事情,这种坏情况就出现了。这么做的结果往往造成系统更难理解和维护。
临时字段
有时你会看到这样的类:其内部某个字段仅为某种情况而设。这样的代码让人不易理解,因为你通常认为对象在所有时候都需要它的所有字段。
过长的消息链
如果你看到用户向一个对象请求另一个对象,然后再向后者请求另一个对象,这就是消息链。采取这种方式,意味着客户端代码将与查找过程中的导航结构紧密耦合。这时我们应该使用隐藏委托关系
中间人
对象的基本特征之一就是封装,对外部世界隐藏其内部细节。封装往往伴随着委托,比如,你问主管是否有时间参加加一个会议,他就把这个消息委托给他的记事本,然后他才能回答你。但是人们可能过度运用委托,你也许会看到某个类的接口有一般的函数都委托给其他类,这样就是过度运用,这是应该使用移除中间人,直接和真正负责的对象打交道。
内幕交易
软件喜欢在模块之间建起高墙,极其反感在模块之间大量交换数据,因为这会增加模块间的耦合。在实际情况中,一定的数据交换不可避免,但我们必须尽量减少这种情况,把这种交换都放到明面上来。
过大的类
如果想利用单个类做太多事情,其内往往就会出现太多字段,你可以运用提炼类讲几个变量一起提炼至新类内。
异曲同工的类
使用类的好处之一就在于可以替换,但只有当两个类的接口一致时才能做这种替换。
纯数据类
它们拥有一些字段,一级用于访问这些字段的函数,除此之外一无长物。这样的类只是一种不会说话的数据容器,它们几乎一定被其他类过分细琐的操控着。此时需要把处理数据的行为从客户端搬移到纯数据类来,就能使这种情况大大改观。但不必认为每次都得这样做,十有八九这种坏味道很淡,不值得理睬。
被拒绝的遗赠
子类应该继承超类的函数和数据,但如果它们不想或不需要继承,这就意味着继承体系设计错误,你需要为这个子类新建一个兄弟类,再把所有用不到的函数下推给这个兄弟,这样超类就只持有所有子类共享的东西。
注释
当你感觉需要撰写注释时,请先尝试重构,试着让所有注释都变得多余。
构筑测试体系
重构是很有价值的工具,但是只有重构还不行。要正确进行重构,前提是得有一套稳固的测试集合,以便发现难以避免的疏漏。
自测试代码的价值
很多程序员花费在调试代码的时间上是最多的,此时可以注意一句话:类应该包含它们自己的测试代码。我们可与确保所有测试都完全自动化,让它们检查自己的测试结果。一套测试就是强大的 bug 侦测器,能够大大缩减查找 bug 所需的时间。
编写测试程序,意味者要写很多额外的代码。除非你确定体会到这种方法是如何提升编程速度的,否则自测试似乎就没有什么意义。但是,如果测试可以自动运行,编写测试代码就会真的很有趣。事实上,编写测试代码的最好时机是在开始动手编码之前。编写测试代码还能帮我把注意力集中于接口而非实现(这永远是一件好事)。预先写好的测试代码也为我的工作安上一个明确的结束标志:一旦测试代码正常运行,工作就可以结束了。
测试驱动开发的编程方式依赖于下面这个短循环:先编写一个测试,编写代码使测试通过,然后重构以保证代码整洁。这个“测试,编码,重构”的循环应该在每个小时内都重复完成很多次,这种良好的节奏感会使编程工作以更加高效、有条不紊的方式开展。
待测试的示例代码
下面代码能从 JSON 文件中构造一个行省对象
class Province {
constructor(doc) {
this._name = doc.name;
this._products = [];
this._totalProduction = 0;
this._demand = doc.price;
this._price = doc.price;
doc.producers.forEach((d) => this.addProducer(new Producer(this, d)));
}
addProducer(arg) {
this._producers.push(arg);
this._totalProduction += arg.production;
}
}
下面的函数会创建可用的 JSON 数据
function sampleProvinceData() {
return {
name: "Asia",
producers: [
{
name: "Byzantium",
cost: 10,
production: 9,
},
{
name: "Attalia",
cost: 12,
production: 10,
},
{
name: "Sinope",
cost: 10,
production: 6,
},
],
};
}
代表生产商的 Producer 类基本只是一个存放数据的容器
class Province {
constructor(aProvince, data) {
this._province = aProvince
this._cost = data.cost
this._name = data.name
this._production = data.production || 0
}
get production() {
return this._production
}
set production() {
const amount = parseInt(amountStr)
const newProduction = Number.isNaN(amount) ? 0 : amount
this._province.totalProduction += newProduction - this._production
this._production = newProduction
}
// 缺额的计算
get shortfall() {
return this.demandValue - this.demandCost
}
// 计算利润
get profit() {
return this.demandValue - this.demandCost
}
get demandCost() {
let remainingDemand = this.demand
let result = 0
this.producers
.sort((a, b) => a.cost - b.cost)
.forEach(p => {
const contribution = Math.min(remainingDemand, p.production)
remainingDemand -= contribution
result += contribution * p.cost
})
return result
}
}
第一个测试
以下是为缺额计算过程编写的一个简单的测试
describe("province", function () {
it("shortfall", function () {
const asia = new Province(sampleProvinceData());
assert.equal(asia.shortfall, 5);
});
});
不同开发者在 describe 和 it 块中撰写的描述信息各有不同。我个人不喜欢多写,只要测试失败时足以识别出对应的测试就够了。
一个真实的系统可能拥有数千个测试。好的测试框架应该能帮我简单快速地运行这些测试,一旦出错,我马上能看到,尽管这种反馈非常简单,但对自测试代码来说却尤为重要。对于你正在处理地代码,与其对应的测试至少每隔几分钟就要运行一次,每天至少运行一次所有测试。
再添加一个测试
现在,我将继续添加更多测试。我遵循的风格是:观察被测试类应该做的所有事情,然后对这个类的每个行为进行测试,包括各种可能使它发生异常的边界条件。记住,测试应该是一种风险驱动的行为,我测试的目标是希望找出现在或未来可能出现的 bug。这一点很重要,测试的重点应该是我最担心出错的部分,这样就能从测试工作中得到最大利益。
我可以在一开始的测试夹具上,对总利润做一个基本的测试
describe("province", function () {
it("shortfall", function () {
const asia = new Province(sampleProvinceData());
expect(asia.shortfall).equal(5);
});
it("profit", function () {
const asia = new Province(sampleProvinceData());
expect(asia.profit).equal(230);
});
});
注意,虽然两个测试夹具产生了一些重复代码,但是最好不这样做,这会使测试间产生交互,是滋生 bug 的温床,最好用以下方法来进行测试。
describe("province", function () {
let asia;
beforeEach(function () {
asia = new Province(sampleProvinceData());
});
it("shortfall", function () {
expect(asia.shortfall).equal(5);
});
it("profit", function () {
expect(asia.profit).equal(230);
});
});
beforeEach 子句会在每个测试之前运行一遍,将 asia 变量清空,每次都给它赋一个新的值,这样我就能在每个测试开始前,为他们各自构建一套新的测试夹具,这保证了测试的独立性,避免了可能带来麻烦的不确定性。
修改测试夹具
producer 类中的产量字段,它的设值函数行为比较复杂,它可以用来测试
describe('province'...
it('change production', function() {
asia.producers[0].production = 20
expect(asia.shortfall).equal(-6)
expect(asia.profit).equal(292)
})
这是一个常见的测试模式。我拿到 beforeEach 配置好的初始标准夹具,然后对该夹具进行必要的检查,最后验证它是否表现我期望的行为。这是一套测试的常见术语,比如配置-检查-验证,或者准备-行为-断言等。其实还有第四个阶段,就是拆除阶段,此阶段可将测试夹具移除,以确保不同测试之间不会产生交互。这里因为是在 beforeEach 中配置好数据的,所以测试框架会默认在不同的测试间将我的测试夹具移除。
探测边界条件
到目前为止的测试都是聚焦于正常的行为上,这通常也被称为“正常路径”,它是指一切工作正常,用户使用方式也最符合规范的那种场景。同时,把测试推到这些条件的边界处也是不错的实践。比如看看集合为空的时候会发生什么
describe("no producers", function () {
let noProducers;
beforeEach(function () {
const data = {
name: "No producers",
producers: [],
demand: 30,
price: 20,
};
noProducers = new Province(data);
});
it("shortfall", function () {
expect(noProducers.shortfall).equal(30);
});
it("profit", function () {
expect(noProducers.profit).equal(0);
});
});
如果拿到的是数值类型,0 是不错的测试条件
describe('province'...
it('zero demand', function() {
asia.demand = 0
expect(asia.shortfall).equal(-25)
expect(asia.profit).equal(0)
})
负值同样值得一试
describe('province'...
it('negative demand', function() {
asia.demand = -1
expect(asia.shortfall).equal(-26)
expect(asia.profit).equal(-10)
})
可以看到,这里我是扮演’‘程序公敌“的角色,我积极思考如何破坏代码。
如果数据输入对象是可信的数据源提供的,比如同一个代码库的另一部分,就可以不用加入太多的检查,而如果输入对象是由另一个外部服务所提供,比如一个返回 JSON 数据的请求,那么校验和测试就显得必要了。
不要因为测试无法捕捉到所有的 bug 就不写测试,因为测试的确可以捕捉到大多数 bug
什么时候要停下测试呢?当测试数量到达一定程度之后,继续增加测试带来的边际效用会递减:如果试图编写太多测试,你也可能因为工作量太大而放弃,最后什么也完不成。
测试远不止如此
之前,测试更多被认为另一个独立的团队的责任,但它现在愈发成为任何一个软件开发者所必备的技能,如今一个架构的好坏,很大程度要取决于它的可测试性,这是一个好的行业趋势。与编程的很多方面类似,测试也是一种迭代式的活动,除非你技能非常纯熟,或者非常幸运。
一个值得养成的好习惯是,每当你遇见一个 bug,先写一个测试来复现它。仅当测试通过时,则视为把 bug 修完。另外,测试覆盖率的分析只能识别出那些未被测试覆盖到的代码,而不能用来衡量一个测试集的质量高低。一个测试集是否足够好,最好的衡量标准其实是主观的,请你试问自己,如果有人在代码里引入了一个缺陷,你有多大的自信它能被测试集就出来?这种信心难以被定量分析,但子测试代码的全部目标,就是要帮你获得这种信心。
测试同样可能过犹不及,测试写的太多的一个征兆是,相比要改的代码,我能感到测试就在拖慢我。