1 心得体会
1.1 重构是一件大事,也是一件小事。为了应对软件腐化,重构是一件重要的大事。但更是一件小事,要做在平时,“Baby Step”。重构不是积累而成的负担,而是软件工程师的日常行为习惯。
1.2 我们以往对代码坏味道的识别太欠缺,一是从产品层面缺乏对代码坏味道的识别和跟踪,二是工程师识别坏味道的能力不足。
对一个软件产品来说,测试通过、DI清零、没有网上问题就够了吗?研发团队即使达到了上面的要求,依然痛苦不堪,因为每次新需求的开发都要如履薄冰,每次“过点”都要重新煎熬一遍。随着规模增长,代码越来越腐化,修改成本越来越高,开发效率其实越来越低。曾经有很多质量指标,但缺少的恰恰是对代码本身质量的度量、对代码坏味道的识别。当前,不论是人工代码检视,还是工具自动检查,都应该利用工具将代码坏味道管理起来(说的是代码坏味道,而不是指标)。圈复杂度、行数等指标依然不够,这些指标只说明代码可能存在坏味道,但没有把坏味道列出来,跟踪改进。培训中提到了好的经验,就是识别坏味道(人工或者工具),并跟踪坏味道代码改进,就像跟踪问题单一样跟踪一段坏味道代码。
另一方面,很多工程师没有意识到自己识别坏味道的能力不足,常常是看着代码但发现不了问题。这种现象是很突出的,在培训中,面对看上去很简单的案例代码,很多学员发现不了问题,认为那样写理所应当。而实际上,那段代码已经违反了一些基本的原则或规范。
public String printReport(){
StringBuffer reportinfo=new StringBuffer();
reportinfo.append("Report card for " + studentName +"\n");
reportinfo.append("-------------------------------"+"\n");
reportinfo.append("Course Title Grade"+"\n");
Iterator grades = clines.iterator();
CourseGrade grade;
double avg = 0.0;
while(grades.hasNext()) {
grade = (CourseGrade)grades.next();
reportinfo.append(grade.title + " " + grade.grade+"\n");
if(!(grade.grade=='F')) {
avg = avg + grade.grade-64;
}
}
avg = avg / clines.size();
reportinfo.append("-------------------------------"+"\n");
reportinfo.append("Grade Point Average = " + avg+"\n");
return reportinfo.toString();
}
1.3 对重构工具的使用太少,应该利用工具提高重构效率。重构课程上,现场调查,知道eclipse的重构菜单的人很少(不到30%),大多数人在重构时没有使用过这类自动化重构工具,都是自己手动修改代码。重构代码过程中,往往涉及大量的代码替换、改名、搬移等操作,如果手工操作,很容易遗漏、出错,而如果使用工具自动完成,效率提升极大。这次培训中使用了eclipse中的提取方法(Extract Method)、提取本地变量(Extract Local Variable)、提取常量(Extract Constant)、内联(Inline)几个重构菜单,一键智能化完成所有相关修改点的修改,效率提升十倍。

eclipse的重构菜单
1.4 针对遗留系统,首先做到“不伤害”老系统,即新旧隔离。方法有很,对于我们大部分产品都是基于已有系统增量开发,这一部分价值很大。新旧隔离的思想,工程师应该熟悉和深入理解、在工作中应用,让新代码不破坏旧系统,并保证新代码满足CleanCode。
1.5 重构工作能够真正落实到工程师日常工作中,需要得到管理者的认可和支持,关键是内部质量数据可视。工程师进行代码重构,会占用交付的时间。重构工作的产出,在以往的价值评价中是看不到的。按时交付特性,解决了几个问题单,这些都是度量可见的,以往管理者更(甚至只)关注这些。要解决日常重构价值不可见的问题,关键是将内部代码质量数据可视,通过看板等呈现出代码质量的变化,让工程师看到,让管理者看到。我们曾经经常听到正面的案例是,某某没有软件背景的(或者刚来不久的新人)在很短时间完成了某紧急需求开发,几天交付云云,这就是英雄。但代码的内在质量如何呢?实际上可能很烂。堆代码看上去很“快”交付,但导致的腐化其实是巨额债务,欠债总是要还的。以往,这些债务对于管理者是难以直接看到和感知到的。殊不知,很多在研的、维护的、网上的疑难问题动辄拉上几个团队联合攻关;很多需求合入版本后质量不能快速稳定;每当过点问题DI起起伏伏…… 都是前期技术债务的体现。后来的人,多少通宵达旦其实就是在还债。目前,代码内部质量的重要性在公司内已经达成共识,让内部质量显性化,让改进可视、可感知尤为重要。
1.6 CleanCode、单元测试、重构,应该是软件工程师的必备技能。一般新人进来后就重点培训这三项能力,具备这些能力后才能开展编码等相关工作。
除了在公司内的赋能、实践和牵引外,工程师自身也应该加强自我提升。《重构——改善既有代码的设计》(下称《重构》)是软件重构领域最经典的书,从1999年英文版出版(2003年中文版出版)到现在,二十年来一直是重构类必读的书,没有替代者。在内部调查中,读过该书的并不多,不到30%。另外,在核心开发骨干的调查中,Committer、骨干,了解GoogleTest工具的人,占比也是很少,1/4左右,工程师对业界经典的单元测试/LLT工具及方法了解很少,实际工作中更难以有效运用单元测试/LLT。在繁忙的交付过程中,工程师也应尽量自己学习和提升必备技能。
1.7 好代码是“打磨”出来的,不可能一蹴而就,“打磨”是即时进行的。“打磨”,也即重构,是在日常工作中完成,是工程师写完一个函数、一段代码后立即进行的,而不是等待其他“时机”。“我这次先合上去,下来一定再优化一下!”,经验告诉我们,让这种信誓旦旦的保证变为现实的可能性几乎为零。
2 理解重构
2.1 为什么要做重构?因为代码腐化
代码腐化的外在影响主要有:
- 开发效率降低。代码可读性差,难以理解;代码牵一发动全身,不敢修改;越来越想大泥球,无法针对修改进行精准测试…… 都会导致开发效率降低。
- 质量下降,故障率上升,难以定位问题。不清晰的代码结构,使修改更容易引入问题,并且难以快速定位清楚问题原因,导致系统故障率逐步上升,新开发功能质量难以快速稳定。
- 无法有效支撑新需求。系统越来越耦合,新功能越来越难以“插入”这种耦合系统,不但功能难以高效扩展,性能也可能无法达成要求。
代码腐化的具体表现有:
- 结构糟糕。例如函数圈复杂度过高、函数行数过大等。
- 安全隐患。例如内存泄漏/越界/非法访问、异常处理不合理、内部数据/接口暴露等。
- 扩展性差。随着功能的增加,如果没有很好控制耦合,会使后来的功能扩展越来越困难,典型的比如霰弹修改。
- 难以协同。如果系统存在越来越多的耦合,就难以进行多人、多团队独立开发,不同的开发者可能越来越多的面临冲突,并增大修改引入bug的可能性。
- 模块调试难。随意的修改和调用导致模块间的关联、依赖越来越多,模块间接口也开始模糊不清,会大大降低调试效率。
- 难以复用。相同的代码子不同模块或函数内重复出现,或者重复开发功能类似的子模块,都会使代码重复或者冗余,同时又可能跟所处上下文存在特殊关系,导致重复的内容也难以复用。而重复的内容又会带来修改遗漏引入bug等风险。
- 算法不当。主要指使用了不恰当的数据结构和算法,导致功能或者性能受、扩展性等受到影响。
- 性能问题。代码的不合理,也可能导致性能得不到满足,包括对突发规模(容量、访问量等)的应对问题。
代码重构,就是要解决和预防代码腐化问题。
2.2 为什么代码会腐化
导致腐化的因素 | 权重(%) |
---|
需求变更多 | 30% |
交付压力大时间紧 | 30% |
架构不好 | 15% |
保持与旧代码一致,但旧代码已经很烂 | 10% |
管理问题 | 10% |
工程师能力差 | 5% |
通过调查,导致代码腐化的主要原有有哪些?以下将输出综合到一起的大概结论:
其中,“需求变更多”、“交付压力大”几乎是排在TOP3的因素。也就是说,现在我们的工程师普遍认为,代码腐化主要的原因是活太多了,没时间把代码写好。这个理由乍听上去很合理,但首席的一句话,还是让人很有触动,他说:“工程师们普遍在找别人的原因、找外部的原因,好像都是别人的错。”
不可否认,外部需求的变更、版本交付的压力,的确是导致代码腐化的重要原因,然而,从工程师角度来说,如果只看到这些原因,那就有点“甩锅”了,特别是,大部分人都没有把编码能力提出来作为代码腐化的重要原因,值得深思。是不是我们长期只关注交付的氛围导致工程师自己都不知不觉中忽视了编码能力提升的重要性?
现实情况是,读过《重构》那本经典书的人占比不到30%(这还是乐观估计),了解业界流行的单元测试工具(例如GoogleTest)的人也很少,听过SOLID设计原则的也不多,很多人面对一段简单代码中的坏味道识别不出来(认为代码那样写理所当然)…… 上面这些例子,不是臆想,而是笔者近几年组织和参加多次软件训练营、重构培训时在现场调查中发现的普遍情况。值得注意的是,能参加那些专题赋能培训的人基本都是committer、MDE、开发骨干等。上述情况说明,我们的工程师在编码能力上还有不少提升空间。代码是工程师一行一行敲出来的,代码的腐化,特别是具体到函数、代码段中的坏味道,是直接出自工程师之手。代码腐化的原因有很多,除了外在压力外,工程师的编码能力也很关键。
2.2.1 破窗效应(Broken windows theory)和惯性
破窗效应:没有修复的破窗,导致更多的窗户被打破。
惯性定律:好代码会促生好的代码;糟糕的代码也会促生糟糕的代码。
破窗效应和惯性定律从社会心理学角度解释为什么代码会越来越腐化。
对工程师的伤害:如果工程师每天都与糟糕的代码相伴,会逐渐失去对代码坏味道的识别能力,并养成不好的编码习惯,久而久之,工程师的基础能力也会下降。
适应现象:生理学上的适应现象发生在感官的末端神经、感受中心的神经和大脑的中枢神经上,适应的结果是感官对刺激感受的灵敏度急剧下降。嗅觉器官若长时间嗅闻某种气体,就会使嗅感受体对这种气体产生适应,敏感性逐步下降,随着刺激时间的延长甚至达到忽略这种气味存在的程度。
反过来看,应该怎么做才能防止代码腐化?
- 发现破窗,立即修复。发现坏味道,立即重构,最好在刚写完一个函数就立即审视有没有坏味道,是否有必要优化。
- 学习好代码、写好代码,让好代码促生好代码,正向反馈。
2.2.2 技术债务(Technical Debt)
技术债务:开发人员为了加速软件开发,在应该采用最佳方案时进行了妥协,改用了短期内能加速软件开发的方案,从而在未来给自己带来的额外开发负担。这种技术上的选择,就像一笔债务一样,虽然眼前看起来可以得到好处,但必须在未来偿还。(1992年,Ward Cunningham首次将技术的复杂比作为负债)
欠债总是要还的,不是自己主动还(还好过一些),就是被逼债(更痛苦一些)。
实际上Ward Cunningham的原话更激进一些,他认为第一次提交代码就已经开始欠债了,要通过不断重写(重构)来偿还债务,小额债务可以加速开发。但久未偿还债务会引发危险。
Shipping first time code is like going into debt. A little debt speeds development so long as it is paid back promptly with a rewrite. Objects make the cost of this transaction tolerable. The danger occurs when the debt is not repaid. Every minute spent on not-quite-right code counts as interest on that debt. Entire engineering organizations can be brought to a stand-still under the debt load of an unconsolidated implementation, object-oriented or otherwise.
2.3 防止代码腐化,重构应该怎么做
重构:使用一系列的重构手法,对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。(Martin Fowler)
2.3.1 重构的技术挑战
- 如何发现重构点(识别坏味道) *
- 知道重构的目标
- 如何去重构——重构技法 *
- 如何保证重构的正确性——单元测试 *
当前,识别坏味道、熟练掌握重构技法、有效的开发自测(LLT),是从工程师能力角度出发,保证重构成功的关键,也是目前的短板。
2.3.2 重构的步骤
- 通过自动化验证所有测试例通过(Verify that all automated tests(microtests) pass)
- 确定要重构的代码(Decide what code to change)
- 仔细地完成一处或多处修改(Implement one or more refactoring carefully)
- 随时运行微测试,确认修改没有改变系统的行为(Run the microtests whenever you wish to confirm that changes have not altered system behavior)
- 重复上面的步骤,直到到完成重构或回退到之前的状态(Repeat until the refactoring is complete or revert to an earlier state)
上面的步骤概括解读一下:
- 首先要有测试例保证。
- 修改一点就自动化测试一点,每次修改都确保系统功能可用。
注意,这里说的是“每次修改”都不影响系统功能,而不是整个重构结束后确保系统功能不变!也就是说,在重构的过程中如果被打断了,代码也是可用的。因为每次修改都经过快速的自动化验证,验证通过了进行一次“commit”,这样就保证了仓库中的代码是持续可用的,使整个重构可以“随时中断”、“断点续传”。 - “Baby Steps”小步快反馈,不断重复“修改”+“验证”。典型的重构修改和验证应该是秒级或分钟级的。
来看看大师Robert C. Martin是怎么说的:
“我写函数时,一开始都冗长而复杂,有太多缩进和嵌套循环,有过长的参数列表,名称是随意取的,也会有重复的代码。
“但是,我会配上一套单元测试,覆盖每行丑陋的代码。
“然后我打磨这些代码,分解函数,修改名称,消除重复,缩短和重新安置方法,有时我还拆散类。同时保持测试通过。
“最后,遵从这些规则,我组装好这些函数。”
2.3.3 重构的最佳时机
重构是持续进行的,不要先编写烂代码再重构。重构的最佳时机,不是在项目结束时、版本某个时间点、专门安排某个重构迭代,甚至不是每天下班时才重构。重构应该是伴随工程师编码过程中的,每隔一个小时或者半个小时,甚至每写一段代码或者一个函数后就要去做的事情。重构即时进行,代码持续干净。
2.4 关于重构的“编程价值观”
关于代码是否需要重构,我们应该纠正一种观点,那就是多一事不如少一事。修改可能会引入问题,所以一段代码功能实现了,即使写的很难看,但没有出过问题,也不应该修改,应该保持现状,万一改坏了,不是没事找事吗?其实,正是这种思想,使代码持续腐化。
Martin Fowler 的观点是,如果一段凌乱的代码不但不需要修改,甚至都不需要理解它,那可以不重构(如果重写比重构还容易,那当然选择重写,这里讨论的是重构)。言外之意,一段代码即使不需要修改,但只是需要你去理解它做了什么,如果它很凌乱,你难以理解,那也是应该进行重构的。再简单说,只要你看到了坏味道,就应该重构它,除非你根本不需要看这块代码(这里好像矛盾,你不需要看它,为什么你还要点开看它呢,好奇心? :-> ),例如被封装在API里的永远不会被翻出来看和理解的稳定代码可以暂不重构。
软件工程师应该具备一点“代码洁癖”,像一个匠人,随时“打磨”自己的作品。软件功能追求准确无误,代码开发过程就像艺术品一样反复打磨,整洁、易读、易扩展、易维护……。
“整洁的代码简单直接。整洁的代码如同优美的散文。整洁的代码从不隐藏设计者的意图,充满了干净利落的抽象和直截了当的控制语句。”(Grady Booch,UML创始人之一、《面向对象分析与设计》作者)
2.4.1 构建好代码文化从TOP10准则普及开始
编码原则如何普及成为工程师的共识?编码的原则有很多,建议将最重要的TOP10原则作为普及的重点。让工程师每天都能看到,从“脸熟”开始,笔者分享了以下做法可供参考。
- 某米公司,将工程师TOP10编码原则做成电脑屏保
- 某公司,将TOP10原则做成卡片放到每个人工位
- 某公司,将TOP编程原则做成摆件,送给工程师
- ……
3 代码坏味道
代码坏味道不一定是bug,是代码不合理的地方,是让人难以理解或易于误解的代码,或者是不合理的代码结构,或者是暗含风险的代码逻辑……
经典的代码坏味道举例:
- 重复代码
- 过长的方法
- 过长的参数列表
- 注释过多
- 临时变量过多
- Switch语句
- 霰弹式修改
- 基本类型偏执
- 复杂表达式
- 数据泥团
- 冗余类
- ……
详见:22种常见代码坏味道
业界大部分坏味道的总结,都是从《重构》书中来的。
识别代码坏味道,是重构的最重要一步。
3.1 识别坏味道
前面已经说过,识别代码的坏味道,是目前我们工程师的一个短板。要么代码坏味道近在眼前却熟视无睹;要么可以意识到眼前的代码很烂,却说不出烂在哪里、如何重构,可以说相当“词穷”。所以,我们要培养对坏味道的敏感程度。
在对资深工程师的统计调查中,一口气能够写出10种代码坏味道的人并不多。建议是,每个软件工程师都应该掌握30+种代码坏味道,看到一段代码,能马上嗅出存在哪种坏味道,知道应该怎么重构改进。
3.2 跟踪代码坏味道
人工检视或者工具扫描识别的代码坏味道,应该得到跟踪改进。这里说的是坏味道代码本身,不只是行数、圈复杂度等统计度量数据,而是那段“烂代码”。笔者介绍了他所在公司对代码坏味道跟踪的内容,可供参考,除了这段代码的位置外,还包括:
- 代码坏味道(Code Smell)——坏味道名称
- 症状(Symptoms)——有助于找出问题的线索
- 原因(Causes)——对问题如何发生的说明
- 采取的措施(WhatToDo)——可能的重构方法
- 收益(PayOff)——在哪些方面有所改善
- 不适应情况——在哪些情况下不适用这种重构
就像问题单电子流一样,跟踪坏味道代码,并且给出重构的建议。识别了坏味道代码,并有对该坏味道的重构方法建议,可以使工程师很方便高效地进行重构。
4 重构名录
4.1 重构方法列表
经典的重构方法目录,即《重构》一书作者总结的几十种方法,详见:https://www.refactoring.com/catalog/
本地摘要: 重构方法摘要
4.2 重构与模式
重构的方法有很多,大致分为两类:一般的重构方法(微重构)和设计重构(基于模式的重构)。针对一种代码坏味道,往往可以使用一般的微重构方法,如果微重构不满足要求,也可以使用模式重构。
例如:
代码坏味道 | 一般重构方法 | 使用模式重构 |
---|
重复代码 | 提炼方法、提取类、方法上移、替换算法、链构造方法 | 构造Template Method、以Composite取代一/多之分、引入Null Object、用Adapter统一接口、用Factory Method引入多态创建 |
更多的重构与模式,详见这里
5 常见重构案例
5.1 函数
只要打开代码,几乎无时无刻不跟函数打交道。如何把代码写简洁、做好封装和抽象、避免重复等,围绕函数有很多方法可以使用。
5.1.1 单一抽象层次原则(Single Level of Abstraction Principle,SLAP)
“让一个方法中的所有操作处于相同的抽象层。” (Robert C. Martin,《Clean Code》)
遵循了单一抽象层次原则,函数的主流程将更加清晰。
举例:下面代码段1和代码段2中的 add 函数,哪个更易读呢?注意代码段1中6~11行与代码段2的比较。
public void add(Object element) {
if (readOnly){
return;
}
if (size + 1 > elements.length) {
Object[] newElements = new Object[elements.length + 10];
for (int i = 0; i < size; i++)
newElements[i] = elements[i];
elements = newElements;
}
addElement(element);
}
private void addElement(Object element) {
elements[size++] = element;
}
public void add(Object element) {
if (readOnly){
return;
}
if (shouldGrow()) {
grow();
}
addElement(element);
}
private boolean shouldGrow() {
return size + 1 > elements.length;
}
private void grow() {
Object[] newElements = new Object[elements.length + 10];
for (int i = 0; i < size; i++)
newElements[i] = elements[i];
elements = newElements;
}
private void addElement(Object element) {
elements[size++] = element;
}
5.1.2 意图与实现分离
“如果你需要花时间浏览一段代码才能弄清它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所做的事为其命名。”(Martin Fowler,《重构》)
我们经常听到两个人的对话中,一位说我要干这个、做那个…… 说着说着被对方打断:“你到底想干什么?”看代码也是,当我们看到一长段代码细节,不由得皱起眉头,这代码到底要干啥?意图与实现分离,将实现细节隐藏在子函数中,可以让代码可读性大大增强,就像看一本书,只要看看目录就知道书大概写了什么内容,如果没有目录、子目录,整整一本正文,要想快速知道书的内容就太难了。
把“意图与实现分离”与“单一抽象层次原则结合起来”,不断向下层迭代,你会发现,代码被分层,并且每个函数都变得很短小。而短小的函数,也是我们所推崇的。
“在我看来,一个函数一旦超过6行,就开始散发臭味。我甚至经常会写一些只有1行代码的函数。”(Martin Fowler,《重构》)
在上一小节“单一抽象层次原则”的示例中,同样体现了“意图与实现分离”的原则。
5.1.3 常用方法:提取子程序/函数
提取子程序/函数可以带来很多好处:
- 降低复杂度
度量函数复杂度的方法有多种,例如:
o 代码行数
o 参数个数
o 调用其它函数/对象/包的数量
o 每行运算符的数量
o 跳转语句的个数(go/break/continue/throw ……)
o 控制结构中的嵌套层数
o 变量个数(局部变量、全局变量)
o 同一变量的先后引用之间的代码行数(跨度)
o 变量生存的代码行数
o 递归
o 预编译的数量(c语言)
o 函数出口(return)数量
o 注释比例
o 分支语句比例
o ……
我们目前最常使用的度量指标是圈复杂度,即一个方法/函数中执行路径的数量。函数的圈复杂度最好不超过10
。
正常程序员看上去很难处理好5~9
个以上的智力实体,并且提高的可能性不大,因此你只有减低你程序的复杂度。(Miller,1995,计算机科学家,ACM Paris Kanellakis 奖获得者)
创建子程序/函数可以有效隐藏一些信息,使原有代码复杂度降低。
- 封装变化
将易变的内容封装到函数中,可以提升可维护性。当修改子函数的内容或者用另一个子函数做替换时,不会影响其他代码。 - 引入中间的,易懂的抽象
- 避免代码重复
当多处代码类似时,用一些技巧提取公共函数,之后再做修改时,可以只针对公共函数内部修改,从而避免修改遗漏,降低修改难度。 - 简化复杂的布尔判断
- 支持子类化(subclassing)
- 装换/适配
- 提高可移植性
- 改善性能
- 占位(TODO)
- 方便测试
- 隐藏全局数据
- 隐藏顺序
把带有执行顺序的一块代码放到函数中,比让它们在系统中到处散布要好得多,减少修改出错。 - 隐藏指针操作
- 递归
- ……
5.1.4 函数10个一
笔者分享的其所在公司倡导的函数“10个一”:
- 每个变量只用于单一用途
- 每一行代码只表达一件事
- 一个循环只做一件事
- 单一抽象层次原则
- 代码组织得一次只做一件事
- 一种变化仅仅修改一处
- 函数应该遵守单一职责
- 函数圈复杂度应该小于一十
概括为一句话就是:说话简单点、直接点!
构造软件设计有两种方法:一种是简单,明显地没有缺陷;另一种方法是使其复杂,却没有明显的缺陷。(Tony Hoare,图灵奖得主,快速排序算法、霍尔逻辑等的设计者)
- 函数第一原则是必须要短小
- 编写函数时必须一心一意,专注,怀有谦卑的心态
案例1:
下面函数中存在哪些坏味道?
void printValues() {
double averageAge = 0;
int totalGrade = 0;
for (int i = 0; i < students.length; i++) {
averageAge += students[i].getAge();
totalGrade += students[i].getGrade();
}
averageAge = averageAge / students.length;
System.out.println("averageAge = "+averageAge);
System.out.println("totalGrade = "+totalGrade);
}
坏味道:
- 违反单一职责原则,一个函数中既计算年龄、又计算成绩,最终目的是打印
- 一个循环中做了两件事
- averageAge变量具有两个不同含义,总和、平均值
- 在意图与实现分离方面可以进一步优化,使主流程更清晰
重构后代码:
void printValues() {
System.out.println("averageAge = "+averageAge());
System.out.println("totalGrade = "+totalGrade());
}
private double averageAge() {
double result = 0;
for (int i = 0; i < students.length; i++) {
result += students[i].getAge();
}
return result / students.length;
}
private int totalGrade() {
int result = 0;
for (int i = 0; i < students.length; i++) {
result += students[i].getGrade();
}
return result;
}
案例2:
class Counter {
private static int count = 0;
public int getCount() {
return ++count;
}
}
上面代码存在哪些隐患呢?
- 存在bug的可能,每次查询都会使计数改变
- 查询与操作没有分离,违反单一职责原则,函数做了不只一件事
5.1.5 展开讨论:每一行代码只表达一件事
Linux内核编码规范:
不要将多个语句放在同一行,除非你要掩饰什么!
Don’t put multiple statements on a single line unless you have something to hide!
也许有人会认为一行代码包含多个语句有如下好处:
- 一屏可以多看几行代码
- 缩短了函数的行数,看上去更简洁
- 为编译器提供了优化线索,提高性能
实际上,上述三点都存在问题。首先,如果一行包含多条语句,虽然看上去一屏可以多看几行代码,但实际上看代码的效率是降低了,因为你要花更多精力去分析一行中的多条语句。其次,通过一行代码多条语句缩短函数行数的做法有点自欺欺人,因为复杂度没有变化,反而隐藏了复杂性的直观性。最后,通过一行多条语句的方式,不会给编译器带来任何优化线索,格式编排优化不了编译性能。
相对于追求最小化代码行数,一个更好的度量方法是最小化人们理解它所需要的时间。
一行代码包含多个语句带来的问题:
- 增加阅读难度
- 编译错误难以定位
编译报错时,这一行有多条语句,到底是哪条出错了呢? - 调试时按行运行无法执行单个语句
- 扩大了代码修改的影响范围,易出错
如果一行代码包含多个语句,但你只希望修改其中一个语句,修改时更容易误操作改动同一行的其他语句。另外,你想为其中一个语句增加注释也很困难。 - 隐藏了程序的复杂性
感受一下一行代码多条语句的威力(让你的头变大 :> ):
while(i<n) {int id = getId(i); printStudent(getStudentInfo(name[i++], id));}
return a[++i] + (i>(n++))?*p:0;
一行代码只表达一件事带来的好处:
- 便于阅读理解
阅读代码只需要从上而下,不需要从左往右寻找语句,一目了然。甚至你只需要阅读前几个关键字就知道这行代码的含义。 - 更直观的代码复杂性展示
- 更方便地按行号找到编译错误
- 更方便的按行单步调试
- 便于修改,需要改哪行就改哪行,避免对其他代码语句的影响
5.1.6 展开讨论:函数第一原则是必须要短小
一个函数的最大长度和该函数的复杂度、缩进级数成反比。
Martin Fowler认为,一个函数超过6行就开始散发臭味,他甚至经常会写只有1行代码的函数。
- 函数调用过多会影响性能吗?
有人会担心短函数造成大量函数调用,影响性能,其实这种担心大部分情况是没有必要的。由于函数调用而影响性能的情况已经非常罕见了。
“短函数常常能让编译器的优化功能运转更良好,因为短函数可以更容易地被缓存。”(Martin Fowler,《重构》)
另外,现代编译器的智能优化,会把一些短函数在编译时进行内联(inline),把短函数再展开,避免过多层调用。
当然,如果程序对性能的要求达到了苛刻的程度,达到了要考虑函数调用带来的开销,那当然要考虑函数调用次数这个指标。
- 只有一行代码的函数有意义吗?
如果一行代码已经完成了一件事,那么应该封装为函数。
如下两段代码对计数的增加,哪一段更合适呢?
代码段1:
packageCounter[packageType]->errorCounter[ERROR_TIME_OUT]++;
代码段2:
addTimeOutCount(packageType);
代码段2封装为函数的好处:
- 更易读,函数名就已经表达了意图
- 更易于修改、替换
-1 如果代码中有多处count处理,那么只需要修改函数内部一处即可。
-2 如果count的处理完全变了,例如数据结构修改、字段修改,甚至计数方法修改,那么只需要替换函数内容即可。
5.1.7 SOFA原则
- Short:保持简短,以便能迅速获取主要目的
- One:只做一件事情,以便测试能集中于彻底检查这件事
- Few:少量的输入、参数、变量、逻辑……,以便非常重要的值组合都能被测试到
- Abstraction:抽象层次一致,以便它不会在做什么和怎么做之间来回跳转
5.2 复杂表达式
复杂表达式的典型情况:
- 复杂的条件逻辑
条件表达式中堆积计算过程 - 复杂的布尔表达式
大量的and
、or
、not
5.2.1 引入解释性变量(Introduce Explaining)
将复杂表达式的结果放入临时变量,以临时变量的名字来解释表达式的含义
if ((platform.toUpperCase().indexOf("MAC") > -1)
&& (browser.toUpperCase().indexOf("IE") > -1)
&& resize > 0) {
}
重构后:
final boolean isMacOs = platform.toUpperCase().indexOf("MAC") > -1;
final boolean isIEBrowser = browser.toUpperCase().indexOf("IE") > -1;
final boolean wasResized = resize > 0;
if (isMacOs && isIEBrowser && wasResized) {
}
5.2.2 将复杂的表达式做成布尔函数
Day today = time.dayOfWeek;
Day d = event.day;
if ((d == EVERYTDAY) || (d == today)
|| (d == WEEKEND && (today == SATURDAY || today == SUNDAY))
|| (d == WEEKDAY && (today >= MONDAY && today <= FRIDAY))) {
lightController(event.id, TURN_ON);
}
重构后:
Day today = time.dayOfWeek;
Day d = event.day;
if (isWakeUpDay(d, today)) {
lightController(event.id, TURN_ON);
}
bool isWakeUpDay(Day d, Day today) {
if (d == EVERYTDAY)
return TRUE;
if (d == today)
return TRUE;
if ((d == WEEKEND && (today == SATURDAY || today == SUNDAY))
return TRUE;
if ((d == WEEKDAY && (today >= MONDAY && today <= FRIDAY)))
return TRUE;
return FALSE;
}
5.2.3 分解条件式(Decompose Condition)
面对复杂的条件表达式,对条件判断和每个条件分支分别提取为函数。
if (!data.isBefore(SUMMER_START) && !data.isAfter(SUMMER_END))
charge = quantity * _summerRate;
else
charge = quantity * _winterRate + _winterServiceCharge;
重构后:
if (summer())
charge = summerCharge();
else
charge = winterCharge();
bool summer() {
return (!data.isBefore(SUMMER_START) && !data.isAfter(SUMMER_END));
}
Charge summerCharge() {
return quantity * _summerRate;
}
Charge winterCharge() {
return quantity * _winterRate + _winterServiceCharge;
}
5.2.4 以卫语句取代嵌套表达式(Replace Nested Conditional with Guard Clauses)
条件表达式通常有两种风格,一种是:两个条件分支都属于正常行为,if 和 else 分支同等重要;另一种是:一个分支是正常行为,另一个分支是异常情况。对于第二种情况,推荐使用卫语句单独检查异常情况并立刻返回。
或者这样理解:如果你关注的东西是多层嵌套后才执行的代码,那么为什么不先把不关注的情况排除掉呢?以减少嵌套层次。
public void add(Object element) {
if (!readOnly) {
if (shouldGrow()) {
grow();
}
addElement(element);
}
}
重构后:
对 readOnly
的判断使用卫语句处理。
public void add(Object element) {
if (readOnly){
return;
}
if (shouldGrow()) {
grow();
}
addElement(element);
}
5.2.5 表驱动法
表驱动法是一种编程模式,从表中查找信息而不是使用逻辑语句(if/else、switch/case)。如果针对同一类型的不同值判断条件很多,使用表驱动法可以有助于降低复杂度,提高可扩展性;否则,表驱动法会增加复杂度。
判断条件转换为表的索引,使用索引直接查表得到数据或者函数指针等。
const int monthDays[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
int getMonthDays(int month) {
return monthDays[month-1];
}
如上代码,如果用if/else或者switch/case来判断月份返回天数的话,代码会很长,复杂度也会提升。
从表中查询函数指针的例子:
typedef void* (*FuncCreatePackageBody)(void* extendData, int* returnSize);
typedef enum PackageType {
PACKAGE_ICMP = 0,
PACKAGE_TCP,
PACKAGE_UDP,
PACKAGE_TYPE_MAX
}PackageType;
FuncGetPackage createPackageBody[] = {
icmp_createPackage,
tcp_createPackage,
unknown_createPackage,
};
代码中的查表用法:
FuncCreatePackageBody funCreatePackage = createPackageBody[packageType];
void* package = funCreatePackage(extendData, returnSize);
5.3 其他注意事项
- 函数、变量名等尽量要用肯定语句形式
- 尽量使用肯定形式的布尔表达式
表达式中过多的否定会导致理解困难,感受一下这句话:“我并非不是一个不傻的人”
- 尽量少用/慎用
break
、continue
语句
break
和 continue
在一些复杂逻辑中容易使程序员犯错。
while (x) {
switch(y) {
case A:
if(z)
continue;
break;
case B:
break;
}
}
6 基于复杂遗留系统的开发和重构
在遗留系统上进行新增功能时,要防止软件退化。修改方案综合评估,包括不限于考虑:
- 修改风险(与原有代码的修改范围、修改量等)
- 可测试性
- 可读性
- 复用
- 可扩展性
- 开发效率
6.1 常见的问题:缠绕(Tangling)
代码不是一下就腐化的,最开始的结构往往是清晰的。但后来接到需求后直接往现有的的模块、对象、函数、方法里面塞代码,加了几个需求后,代码结构逐渐面目全非。
主要的原因有:
- 工程师不关注“隔离”
- 工程师不知道用好的手段进行隔离
- 软件本身就是个多维度的复杂体,即使有很聪明的开发者,可能也很难完全避免缠绕
6.2 关键思路:分离关注点,新旧分离
First, Do No Harm. 首先,做到不伤害。(Hippocrates,古希腊医学之父)
常见解决方案:
- 新生方法
在新的方法、函数中增加新功能 - 新生类
可以是具体类、接口(策略模式)、接口集合(观察者模式) - 外覆方法
o 将旧接口封装在新接口中,并在新接口中增加功能
o 老函数改名;新函数用老函数的名字,老代码的调用处不感知变化 - 外覆类
o 新增类,调用已有类,并增加新功能
o 新类继承老类,增加新功能
o 委托(代理模式)
o AOP(Aspect Oriented Programming,面向切面编程)
o 装饰者模式
针对历史遗留代码如何修改、新增功能,推荐书籍:《修改代码的艺术》
7 重构管理——管理者的视角
重构这项工作,往往在我们的工程师眼里属于出力不讨好的,重构好了,没人知道;修改出问题来了,拿你开刀。主要原因是,管理者视角难以看到重构工作的价值。因为理论上,重构不改变软件的外在表现,软件不会因为经过了重构就增加了功能、完成了新的需求,或者立即缩短了交付周期;相反,重构会增加工程师在现阶段的工作量。
重构改进的是内部质量,其价值面向的是未来,是为了遏制和预防当前以及未来的代码腐化,是为了使开发效率不至于越来越低。重构属于前人栽树后人乘凉型的工作。
重构带来的价值:
- 提升代码内部质量,有利于减少bug,进而提升外部质量
- 提升开发效率,使代码具有更好的可读性、易修改,提升软件的可扩展性,进而更快交付新需求
从管理这的视角来看,当前需要补足的是对重构价值的可视化,也即代码内部质量的可视化,形成对重构价值的反馈。
- 宏观上的代码内部质量
圈复杂度、行数等指标 - 微观上的代码内部质量
各种代码坏味道
建议:通过看板可视化、量化代码内部质量,使工程师和管理者都可见。代码内部质量的改进可度量。
举例:
- 某移动应用开发商,代码质量监控看板,呈现各种代码内部质量指标,“十大烂人榜”……
业界代码内部质量看板开源工具:Sonar(github开源项目)
推荐书籍:《程序开发心理学》