Bad Smells & Refactoring
以前做的一个培训,当时备课时还是花了一些工夫。ppt贴不上来,把备课稿贴在这,备份一个吧。
Bad Smells & Refactoring
1 题记
Any fool can write code that a computer can understand. Good programmers write code that humans can understand.——Martin Fowler
(任何一个傻瓜都能写出计算机可以理解的代码。唯有写出人类容易理解的代码,才是优秀的程序员。)
2 Bad Smells
2.1 Duplicated Code重复的代码
(重复代码,不需要定义,大家都知道是什么东东。)
1. “重复的代码”有什么不好?(比方说,从可维护性的角度看,很多地方的代码看似一样,又有可能有细微差别,对读代码的人容易产生困扰,于是可以定罪,可读性不好;同样的修改可能需要修改多次,而且容易遗漏,可修改性不好。)(但是,重复代码也有好处啊,代码行数多,千行代码故障率会少,对我们的考核比较有利……开玩笑的是么?)
2. 代码重复到什么程度,Smell才算Bad?(if (filename == null || filename.trim().equals(""))这句话可作为例子,我们认为这句话就算是重复代码了,比如哪天filename.trim().equals("")这个判断我觉得不好,想要换成filename.trim().length() == 0,那么岂不是霰弹式修改?使用绝技“Ctrl-C/ Ctrl-V”、同时还有用“Ctrl-F”,因为大家没有住在一起,要搜索一下才联系得到……)
3. 三种情况的重复代码:
a) 同一个class中的两个method含有相同表达式。(Extract Method)
b) 两个互为兄弟的subclass内含有相同表达式。(Extract Method、Pull up Method、Form Template Method)
c) 两个毫不相关的class内含有相同表达式。( Extract Method、 Extract Class)
2.2 Long Method过长函数
2.2.1 “过长函数”有什么不好?
1. 可读性:使用短函数,高层函数看起来像系列注释、低层函数不超过(比方说)10行,逻辑一目了然,可读性更优。
2. 可重用性:(长函数可能包含逻辑A/B/C,想单独重用A逻辑,不可能。一个类比的例子,发动机的重用机会肯定比车大。大家知道深圳BYD使用的是三菱发动机,标致307/206 1.6系列使用的发动机跟富康、爱丽舍16V系列是相同的。发动机上面的螺丝重用机会更大。很多不同种类发动机上使用的螺丝很可能是同一个厂家生产的相同螺丝。)
3. 可插入性:使用短函数,利用Override等操作,替换处理逻辑会更加容易。比如,更容易应用模板方法模式Template Method,参见Form Template Method章节。
2.2.2 “短函数”难道没有缺点?
1. 调用开销?(现代OO语言几乎已经完全免除了进程内的“函数调用动作的额外开销”。)
2. 看代码的时候,跳来跳去,很心烦?(高层函数看起来像系列注释,而且函数名字起得好,单纯看代码,比如接手模块的时候,低层的函数甚至可以不看。)
2.2.3 函数到了多长,Smell才算Bad?(写多长的函数才比较合适?)
1. 李维说:5行。(李维大家认识么?台湾IT界著名散文家,与侯捷齐名。不得不承认,此处我断章取义了,他说“5行”,是有一定语境的,他是想说“很短”的意思,建议大家不要追究他说的是5还是6。)
2. Martin Fowler说:长度不是问题,关键在于函数名称和函数本体之间的语义距离。(Martin Fowler大家认识么?《重构》《UML精粹》《分析模式》《企业应用架构模式》等经典著作的作者。后面,Martin还是,如果提炼动作可以强化代码的清晰度,那就去做,就算函数名称比提炼出来的代码还长也无所谓。什么叫做“函数名称与函数本体之间的语义距离”?函数名称要能概括函数体的动作,我引用的这句话没有能表达Martin的所有意思。)
3. 王振宇说:应该很短、可以较长,只要函数是在做一件从语义上无法再次拆解的事。(王振宇大家认识么?注意到没有,我们认为这句话是对Martin上一句话的补充,在函数体的组织方式上做了要求。简单说就是,一个函数做一件事儿。Extract Method中的那个例子。)
2.3 Large Class(过大类)
(一个例子)
1. “过大类”有什么不好?(难维护、容易出现重复代码。)
2. “过大类”的常见情况:
a) 本应该是针对不同类的操作放到一个类中。(比如,某些类本应拆出一些小的零件类,该类与零件类之间可能是关联、依赖关系,但没有这么做,而是所有代码放在一个类中。)
b) 大量静态方法放在“*Comm”类中。
i. 现象:大量静态方法放在“Comm”类中。很像C的函数库。(这个现象常见么?好不好?)
ii. 评述:这是一种使用面向对象语言编写面向过程代码的尝试。我个人觉得这类尝试是一种倒退,拒绝面向对象所带来的所有特性。没有很好地封装,程序的结构化不好;使用方与之的依赖是静态的;没有可插入性。
iii. 措施:解决这个问题的做法是,按照操作实施在哪个对象身上来把操作规划到对象所在的类里面;如果保持静态方法不变,也不要所有方法放到一个类中,最好按照语义来划分到合适的类。JDK类库提供了那么多方法,很少出现静态函数库的现象。当然,也不是说不存在,比如Math、ArrayList等类,不过至少他们在语义上分得很清晰。一个例子,比如,String的substring方法,很可能被一些程序员设计成public static,因为他们可能觉得无法把这个方法归属到哪个类中,于是放到“Comm”类中。(大家体会一下?是不是这么个事儿。)
iv. 疑问:那不是想调用某个方法的时候就要new一个实例(性能问题!)?首先,这又是面向过程的思维方式,想的是过程、调用,而不是对象、依赖、关联。其次,轻量级的对象,创建、回收成本很低。我曾经对一些不同算法、策略从性能角度做过相应的比较试验,通常,执行次数在10w-100w以上量级时,才有差别。我使用面向对象的做法,可以明显地获得更优的可维护性(可读、可扩展、可改、可插入、可重用),而且面向对象本身不会造成什么性能问题。当然,具体情况要具体分析,如有明显的性能隐患,最好能够做一个简单的试验,用数据来说话。做个类比,说某些政客在很多场合的潜台词中认为,民主、自由会破坏安定的大好局面,显然不能够让人信服;同样,说面向对象增加了对象的创建、销毁成本,会影响性能,影响软件系统的稳定局面,也是不能够让人信服的。于是在编程时尽可能地使用静态方法,这种做法,不可取。
3. 类长到多大才算“Large”?类,应该较小、可以较大,只要该类从语义上无法再次拆解。(“发动机”类,可以包含对“螺丝”类的引用(关联),但不要把“螺丝”类的操作也放到“发动机”类中来实现。)
2.4 Long Parameter List(过长参数列)
1. “过长参数列”有什么不好?(难读、难用、难记,还有一点,无法获得对象所带来的优势。比如,参数之间的约束关系没有得到很好地封装。例如,startTime/endTime,他俩作为参数来讲,可能不算长,这里仅做示例来说事儿。这对表示时间范围的参数可能多处使用,在没有包装成对象的时候,如果要保证“startTime < endTime”这个约束,就需要所有用到这对参数的地方都做判断;包装成对象,情况就好多了,比如叫做TimeRange,在类的构造函数中可以做这个判断。显然,使用对象,把变化封装得好一些。)
2. 想一下,JAVA类库的函数,比起C类库的函数,传递的参数是不是大都短很多?(应该是。这体现了面向对象的优势。)
3. “参数太多”这个Smell如何去除?
a) Introduce Parameter Object,无非是把多个参数封装成一个对象。
2.5 Divergent Change(发散式变化)
1. 什么是“发散式变化”?
a) “某一个类受到多种变化的影响”,A/B/C/D……多种功能变化的时候它都需要修改。
2. 为什么会造成“发散式变化”?哪儿没弄好?
a) 大致是由于这个类担负了多项任务,太操心了,不该他做的事儿也来做,越俎代庖。很可能需要再拆分几个类出来,把变化封装得更细。
3. 历史教训(反面教材)(以前我写配置MAF端代码的时候,写过一个P_Unit类,他处理所有BSC单元的逻辑,但各种单板的逻辑是不一样的,于是DTB改逻辑的时候要修改P_Unit、ABPM改的时候要修改P_Unit、IPCF、UPCF、GCM……所有具有特殊逻辑的单板修改功能的时候,他都要修改,甚至HDLC/UID等逻辑修改的时候P_Unit都要改。显然该类管得太多了。后来,我看了一本书,翻然悔悟,痛下把代码决心做了重构。其实早在03年,徐峰(据说徐峰要离开公司,这么牛的人离开了对我们整个OMC损失很大,我在这里提一下他的名字,简陋地送别一下。)做配置CAF的时候建议我针对每种有特殊逻辑的板子弄一个类,我完全不以为然。显然,当时没有理解“封装变化”这四个字。)
2.6 Shotgun Surgery(霰弹式修改)
1. 什么是“霰弹式修改”?
a) “一个变化引发多个类的修改”,完成某个需求的时候,A/B/C/D……多个类都需要修改。
2. 为什么会造成“霰弹式修改”?哪儿没弄好?
a) 大致是多个类之间的耦合太严重。很可能是类没有规划好,没有把变化封装得足够令人满意。
3. 一个插曲:记得此前讨论这个Bad Smell的时候,严钧认为,去掉这个Bad Smell不好强求,而且举出Abstract Factory模式作为例证。也有道理。我在这一点上是这么认为的:我们要清楚的认识到我们努力的方向,Abstract Factory模式同样不完美,它没有满足Open-Close原则。我们可以在某些条件(包括技术条件)受限的时候写出不完美的代码,但一定要知道它是不完美的。
a) Factory Method模式(工厂方法)代替Abstract Factory来说事儿。
每增加一种Produce的实现类,就要同时增加一个对应该类的Creator类。当时严钧可能说的是Abstract Factory模式,我用Factory Method模式来说事儿,因为他简单些,但同样可以说明问题。
b) Open-Close原则
软件实体应该对扩展开放,对修改关闭。Open-Close原则是一个愿景性质的原则,如果系统能够达到Open-Close原则描述的情形就比较理想了,对扩展开放、对修改关闭,即,不修改原有代码即可完成对系统的扩展。系统可以获得最大可能的稳定性,加功能的时候旧有代码不修改,当然不会带入BUG。
? 玉帝招安美猴王的故事
齐天大圣美猴王,想当初可是功夫了得,从东海龙王那儿拿了根棍儿,大闹天宫……叫嚣得不行(后来怎么不灵了,一根灯草、一根头绳、一条看大门的狮子狗都整不过),喊出来一些革命口号:“皇帝轮流做,明年到我家”,“只教他搬出去,将天宫让与我!”。有一些农民起义领袖的风范。
太白金星给玉皇大帝打了个报告出主意:“把他宣来上界……与他籍名在箓……一则不动众劳师,二则收仙有道也”。
玉皇大帝遵循Open-Close原则招安了美猴王。不动众劳师,不破坏天规,是关闭对已有系统的修改,不修改,是Closed。收仙有道,是对已有系统的扩展,可扩展,是Open。
同时应用了依赖倒换原则,合成/聚合复用原则,以后有机会给大家讲讲面向对象的设计原则。
4. 讲回霰弹式修改这个smell,很多程序在接手时,前辈一再嘱咐,改什么功能的时候,一定要注意,什么什么……一堆地方必须同时修改,要细心不要漏了……这很可能是设计水平的问题给维护造成的难度。其实如果程序设计得好,此后的工作将愉快很多。(插播广告)我记得,刚看到斯诺克比赛在电视上转播的时候(那时候还小,初中吧,还没见过真的斯诺克台球桌),很不屑,觉得他们大部分时候在打一些比较近距离的球,最多半张台子距离吧,我甚至盲目自信,感觉那些球我都能打进,电视上的人有什么了不起。其实大家都知道,母球走位是难度更大、更重要的工作(要经过全盘性思考的),走位走好了,下一杆就好打;就好比做软件,程序结构设计得好,维护就更容易。
2.7 Data Clumps(数据泥团)
1. 什么是“数据泥团”?
a) 某些数据项经常黏在一起行动,称之为“数据泥团”。时间长了就应该考虑是不是该把他们封装到一个对象中,来封装这个泥团所可能具有的逻辑。(比如一堆表示定位信息的字段,system、subsystem、unit、rack、shelf、slot……总是一起出现。)(这个Bad Smell在在很多时候与Long Parameter List,是一样的,但Data Clumps的涵盖范围比Long Parameter List要大一些,比如,某些类的Field,可能没有当作参数来传递,但是总是黏在一起,也可能出现数据之间的逻辑,于是也需要绑成一个对象,来做封装。)
2. 如何判断是否属于“数据泥团”?
a) 删掉这些数据项中的其中之一项,其他数据有没有因此失去意义?(比如startTime/ endTime,就是成对表示时间范围的,去掉其中一个,另一个失去意义。)
2.8 Switch Statements(Switch惊悚现身)
(惊悚现身,很像香港翻译好莱坞电影片名的风格是么?)
1. Switch语句有什么不好?
a) 容易形成“长函数”(比较容易理解)
b) 容易形成“霰弹式修改”
2. 如何替换掉Switch语句?
a) 多态(使用Pet的例子的第三个版本来说明)
3. 是不是使用多态可以去掉所有Switch语句?
a) 不是。比如,根据消息号,分发把消息分发到相应的处理函数(处理类)来处理。(原因是某些情况下,调用端无法动态创建确切(子类的)实例,于是依然需要分发过程。即,需要Switch语句分发、或者“配置文件+反射”的方式分发。)
4. 对Switch语句有什么要求?
a) Switch语句可以存在,但每个case的处理语句不应超过2-3行。
2.9 Comments(过多的注释)
(首先,注释本身没有错,很多时候注释是必须存在的。但,注释过多,就是坏味道了。)
1. Why?为什么?
a) 过多的注释,是降低代码可读性的帮凶。(如果,代码只有通过大量注释才能被理解,那么说明代码的可读性不好。事实上,很多文章也就此有些说法:代码要写得“自解释能力强”、自己解释自己;代码就是文档。这就要求,类、方法的编写要清爽,类名、方法名、变量名要起得好。)
2. How?如何写好注释?(Why?How?想起那个关于两个渔夫和一个美人鱼的荤笑话,由于有未成年人士在场,我不便当众详细讲。)
a) 写“why”。(注释应该写代码的编写思路,特别是某些地方没有按常理出牌,要写注释来说明。比如,对数组做for循环遍历,边界一般是数组的length,如果某一次出于某种特殊考虑,没这么做,就需要注释说明。)
b) 不写“what”。(注释不要写代码是干什么的,“what”这样的信息应该尽量包含在类名、方法名、变量名中。)
c) 不写充数注释。(不要为了写注释而写注释,不要往猪肉里注水,虽然没什么大碍,但终归是没品味的做法。比如,“String a = null;//创建一个String实例。”看到这样的注释,我胃口都不舒服。虽然部门有注释比例的要求,但像我们这样的高级程序员、高级工程师,还是不要充数用的注释。)
3 Refactoring
3.1 Extract Method
void printOwing() {
printBanner();
//print details
System.out.println ("name:" + _name);
System.out.println ("amount" + getOutstanding());
}
重构为:
void printOwing() {
printBanner();
printDetails(getOutstanding());
}
void printDetails (double outstanding) {
System.out.println ("name:" + _name);
System.out.println ("amount" + outstanding);
}
(前面说了,函数应该短。重复一下带来的好处:可读性好,高层函数像注释、低层函数行数少;可重用性好,比如上例中的printDetails,可能别处也能用,重构前是无法被重用的;可插入性好,子类可能写一个新的printDetails,使用不同格式打印。)
3.1.1 抽取函数时候,参数、临时变量如何处理
1. Replace Temp With Query 去掉临时局部变量,代之以查询类的方法,拆开的小函数需要此临时变量的时候,就调用这个查询方法。
2. Introduce Parameter Object 让长参数列变得简洁。
3. Replace Method with Method Object 去掉多个参数、局部变量。为待重构的大函数创建一个对象,这样,所有方法内的临时变量就变成对象的field,于是大函数拆开的所有小函数就共享这些field,不必再使用参数传递。
3.1.2 起名字很重要!名字应该:(此处的名字包括函数、类等)
1. 清晰、恰当。表达的信息涵盖函数的所作所为。
a) 当一个函数名字为了涵盖函数所为“必须”起成“do1stThingAndDo2ndThing”的时候,就有必要实施Extract Method来抽取函数了。
b) 一个OMC代码中的例子,某函数叫做checkParameter,但函数体中除了检查参数之外,还“顺便”为几个类属性赋值,虽然此函数很短、很超值,但我们认为他的命名是不恰当的,甚至他的函数设计也是不恰当的,一个函数要干单纯的一件事儿,函数内部从语义上无法再次分解。
2. 尽量简短、可以较长。但应该首先满足上一条要求。
a) compareToIgnoreCase(String类的方法)、getDisplayLanguage(Locale类的方法)、getTotalFrequentRenterPoints(《重构》书中的示例代码),这些函数名长不长?(重要的是把信息表述清楚,名字长一点没关系。)
3.2 Replace Temp with Query
double basePrice = _quantity * _itemPrice;
if (basePrice > 1000)
return basePrice * 0.95;
else
return basePrice * 0.98;
重构为:
if (basePrice() > 1000)
return basePrice() * 0.95;
else
return basePrice() * 0.98;
…
double basePrice() {
return _quantity * _itemPrice;
}
(例子很容易理解,basePrice是临时变量,临时变量的问题在于:它们是暂时的,而且只能在所属函数内使用。由于临时变量只有在所属函数内才可见,所以它们会驱使你写出更长的函数,因为只有这样你才能访问到想要访问的临时变量。如果把临时变量替换为一个查询式(query method),那么同一个class中的所有函数都将可以获得这份信息。为拆解大函数提供了方便。)
3.2.1 一个借助Replace Temp with Query来提炼函数的例子
double getPrice() {
int basePrice = _quantity * _itemPrice;
double discountFactor;
If (basePrice >1000) discountFactor = 0.95;
else basePrice = 0.98;
return basePrice * discountFactor;
}
重构为:
double getPrice() {
return basePrice() * discountFactor();
}
private int basePrice() {
return _quantity * _itemPrice;
}
Private double discountFactor() {
If (basePrice() >1000) return 0.95;
else return 0.98;
}
(重构前,在getPrice方法中,先计算基础价格、再计算折扣因子、再计算最终价格,做了语义上可以再次拆解的三件事儿,不符合“函数只做一件事儿”的要求,于是使用Extract Method方法来重构。借助Replace Temp with Query重构方法,将临时变量basePrice、discountFactor用相应的查询函数来替代。
需要指出的是,此次重构,查询函数basePrice被调用了两次,损失的一点点性能可以忽略,我们认为这样做是值得的。)
3.3 Split Temporary Variable
double temp = 2 * (_height + _width);
System.out.println (temp);
temp = _height * _width;
System.out.println (temp);
重构为:
final double perimeter = 2 * (_height + _width);
System.out.println (perimeter);
final double area = _height * _width;
System.out.println (area);
(如果临时变量被赋值超过一次就意味它们在函数中承担了一个以上的责任(循环变量等用途除外)。
例子中,临时变量temp开始被用来记录矩形周长,后来被用来记录矩形面积。应该拆解为多个临时变量,否则:
1. 影响代码可读性。(多用途临时变量通常无法获得合适的命名)
2. 增加代码出错机会。(程序某处,可能都记不清这个临时变量现在是记录什么数值的)
实际操作中,推荐使用final来限定临时变量被赋值次数。)
3.4 Remove Assignments to Parameters
int discount (int inputVal, int quantity, int yearToDate) {
if (inputVal > 50) inputVal -= 2;
重构为:
int discount (int inputVal, int quantity, int yearToDate) {
int result = inputVal;
if (inputVal > 50) result -= 2;
1. 不要对参数赋值
void nextDate(Date arg) {
arg.setDate(arg.getDate() + 1);
}
void nextDate(Date arg) {
arg = new Date(arg.getYear(), arg.getMonth(), arg.getDate() + 1);
}
上面两个函数的写法,哪个是对参数赋值了的?哪个会起到应有的作用?
2. Java是pass by value(传值)的
a) 传进函数体中的参数,是调用语句那个传入参数在内存中的一份拷贝
b) 函数体内对参数的再赋值不会影响调用方的参数原始值(比如,修改int等基本类型参数的数值、修改Object等对象引用的指向)
c) Java中,对参数的再次赋值是一种纯粹降低程序清晰程度的做法
3.5 Replace Method with Method Object
class Order...
double price() {
double primaryBasePrice;
double secondaryBasePrice;
double tertiaryBasePrice;
// long computation;
...
}
…
重构为:
(一个大的函数,提炼出一个专门实现这个函数功能的类。
比如getMoney方法可能就是提炼出一个MoneyGetter类。此处是price方法提炼出PriceCalculator类。
这样做的理由是,price可能是个很大的函数,我们为了获得短函数的优势(前面说过的,可重用、可读、可插入等),想利用Extract Method抽取出多个短函数,但每个短函数可能都需要primaryBasePrice、secondaryBasePrice、tertiaryBasePrice等几个临时变量,把它们都作为参数传递显然太笨了。而把这个大函数提炼成类,这些临时变量就变成了类的Field,在类中是共享的,这样,抽取出来的小函数之间就可以不需要传递参数,就很容易实现Extract Method这个重构过程。)
3.5.1 如此这般之后,类是不是太多了?
面向对象的套路,玩的就是类。函数不嫌多,为什么嫌类多?多个风马牛不相及的函数杂居在一个类中(能够容忍么?),为什么不多弄几个类把它们各自封装?类,对应了现实世界的有机无机物种,把物种分得足够细致,世界才得到了完美地描述。
3.6 Replace Array with Object
String[] person = new String[3];
person[0] = “Robert De Niro";
person[1] = “60";
person[2] = “Actor”;
重构为:
Person robert = new Person();
robert.setName(" Robert De Niro");
robert.setAge(“60");
robert.setProfession(“Actor”);
1. 数组应该容纳一组相似的对象,用户很难记住“数组第一个元素是人名、第二个元素是年龄”这样的约定。
a) 用注释来保证这种约定么?
b) 使用数组是出于效率考虑么?(抬杠?)
2. 同理,能够恰当地利用既有数据结构把接口约束得紧一些(更贴切、更严丝合缝),对大家都有好处。
a) 比如,能够确定是一组Person类实例,就要用Person[]来装,而不用Set、Map这样的“广口”容器。(拒绝“私下、口头约定,注释”等靠不住的协议方式所带来的弊端。)
3.7 Encapsulate Field
public String _name;
重构为:
private String _name;
public String getName() {
return _name;
}
public void setName(String arg) {
_name = arg;
}
强调封装:
一般来讲,任何时候都不要将类field声明为public(常量除外)。数据和使用数据的行为被集中在一起,一旦情况发生变化,代码的修改比较容易,因为需要修改的代码都集中在同一块地方,而不是星罗棋布地散落在整个程序中。
3.8 Replace Magic Number with Symbolic Constant
double potentialEnergy(double mass, double height) {
return mass * 9.81 * height;
}
重构为:
double potentialEnergy(double mass, double height) {
return mass * GRAVITATIONAL_CONSTANT * height;
}
static final double GRAVITATIONAL_CONSTANT = 9.81;
宏值定义代替散落在代码中的“魔术数”,没什么好说的。
3.9 Encapsulate Collection
重构为:
依然是强调封装:
1. 类内高内聚:数据和对数据的操作紧密结合在一起,对数据的实施操作比较容易。(比如,餐厅管理系统中的某个类,dishes(Vector类型)作为类的field,装载点菜时被点中的菜目,如果想统计一下哪些菜受欢迎,对于按Encapsulate Collection设计的类就比较容易操作,add方法中做一些手脚即可(分类累加)。)
2. 类之间松耦合:内部数据结构不要暴露的外界,外界也不需要关心。(这样,即便你把内部数据由array换成Vector,外部都不需要知道。)
3.10 Replace Type Code with Class
重构为:
3.10.1 Why?Type Code有什么不好?
1. Type Code会降低可读性。
a) 在定义的地方可能看不出来(定义时使用宏值,可读性挺好),但在使用的地方就会显现问题。上例中,比如有个方法getCharacter获得血型对应的性格描述,参数是血型,使用Type Code时,参数类型为int,重构后,参数类型为BloodGroup,显然后者的可读性好。
2. 使用Type Code失去了使用对象所拥有的独立扩展的机会。
a) 像这个例子,Type Code很容易有它自己的行为,比如根据血型得到性格描述、得到ABO溶血症可能性、判断血型之间的输血匹配可能……于是将其抽取成类是比较好的做法。(还是封装!)
3.10.2 插科打诨,Meilir Page-Jones讲的故事
(故事是讲面向对象的,面向对象的主要特征有哪些?封装、继承、多态)
Meilir Page-Jones在《UML面向对象设计基础》(个人认为此书堪称经典)一书中编了一个故事:
软件界在“面向对象”的定义上,一度很难达成一致。我开始步入面向对象领域时,决定澄清一下“面向对象”的定义。
我把数十位面向对象的老前辈关在一个没有食物和水的房间里。我告诉他们只有当他们的定义达成一致的意见,并且可以在软件世界发布时才允许他们出去。在一小时的喧哗过后,房内一片安静,老前辈们背靠背谁也不理谁了,陷入了僵局。此时,蹦出来一位组织者,让每个人都列出他们认为在面向对象世界中不可缺少的特性,大家同意。一通罗列,每个人都列出了三个五个、十个八个。
此时,刚才蹦出来那位组织者又蹦出来开始讲话,说,现在我们大致有两种做法:一种是建立一个长列表,该列表是每个人列表的并集;另一种是建立一个短列表,该列表是每个人列表的交集。大家选择了后者,产生了一个短列表,该列表中的特性在每个人列表中都有。这个列表确实很短,短到只有一个词,“封装”。
一堆废话告诉大家一个道理,封装,是面向对象最为重要的特性,封装好了,才能做到所谓的高内聚、松耦合。获得面向对象思想许诺的种种优势。
3.11 Replace Type Code with Subclass
(跟前面宠物店的例子是不是很像?)
重构为:
3.12 Replace Type Code with State/Strategy
重构为:
这个就厉害了!清晰地展示了“合成/聚合复用原则”。
上面例子,将Engineer和Salesman弄成并列的子类,是存在问题的。(什么问题?)
1. Salesman明确地从Employee继承,那么就无法再从Male、Newcomer等类继承来获得他们的特性。
2. Salesman的实例被new出来之后,他可能转岗做研发,想变成Engineer,无法实现。
3.12.1 什么是合成/聚合复用原则
1. 要尽量使用合成/聚合,尽量不要使用继承。
2. 从复用角度来说:“合成/聚合复用”比“继承”复用灵活。前者是动态复用(因而具有可插入性)、后者是静态复用(编译时就固定了复用关系),而且后者的复用有“不支持多重继承”的限制。
3.13 Decompose Conditional
if (date.before (SUMMER_START) || date.after(SUMMER_END))
charge = quantity * _winterRate + _winterServiceCharge;
else charge = quantity * _summerRate;
重构为:
if (notSummer(date))
charge = winterCharge(quantity);
else charge = summerCharge (quantity);
Extract Method在条件判断语句段中的应用。
3.14 Consolidate Conditional Expression
double disabilityAmount() {
if (_seniority < 2) return 0;
if (_monthsDisabled > 12) return 0;
if (_isPartTime) return 0;
// compute the disability amount
重构为:
double disabilityAmount() {
if (isNotEligableForDisability()) return 0;
// compute the disability amount
这条比较雕虫小技,可视具体情况参考实施。
3.15 Consolidate Duplicate Conditional Fragments
if (isSpecialDeal()) {
total = price * 0.95;
send();
}
else {
total = price * 0.98;
send();
}
重构为:
if (isSpecialDeal()) {
total = price * 0.95;
}
else {
total = price * 0.98;
}
send();
虽然这条也比较雕虫小技,但前面这样的代码确实也有人写得出来。
3.16 Remove Control Flag
set done to false
while not done
if (condition)
do something
set done to true
next step of loop
Control Flag为什么不好?
影响可读性,程序看起来比较绕。
3.16.1 一个例子
void checkSecurity(String[] people) {
String found = "";
for (int i = 0; i < people.length; i++) {
if (found.equals("")) {
if (people[i].equals ("Don")){
sendAlert();
found = "Don";
}
if (people[i].equals ("John")){
sendAlert();
found = "John";
}
}
}
someLaterCode(found);
}
无论找到Don还是John都退出循环做其他事儿。注意:这里使用了标志found。
重构为:
void checkSecurity(String[] people) {
String found = foundMiscreant(people);
someLaterCode(found);
}
String foundMiscreant(String[] people){
for (int i = 0; i < people.length; i++) {
if (people[i].equals ("Don")){
sendAlert();
return "Don";
}
if (people[i].equals ("John")){
sendAlert();
return "John";
}
}
return "";
}
重构之后,去掉了标志,增加了函数的出口,同时增加了程序的可读性。
3.17 Replace Nested Conditional with Guard Clauses
(卫语句:某些条件判断为真时,立即从函数返回。这样的判断就应该首先、单独进行,把这种单独检查称之为“卫语句”。Guard Clauses。是Kent Beck给起的名字,Kent Beck是TDD、XP的第一倡导者。)
double getPayAmount() {
double result;
if (_isDead) result = deadAmount();
else {
if (_isSeparated) result = separatedAmount();
else {
if (_isRetired) result = retiredAmount();
else result = normalPayAmount();
};
}
return result;
};
重构为:
double getPayAmount() {
if (_isDead) return deadAmount();
if (_isSeparated) return separatedAmount();
if (_isRetired) return retiredAmount();
return normalPayAmount();
};
(好处是明显的,可以减少if/else嵌套的数目,从而强烈地提高程序可读性。比较重要的是,需要习惯“函数有多个出口”这种做法。)
3.18 Replace Conditional with Polymorphism
double getSpeed() {
switch (_type) {
case EUROPEAN:
return getBaseSpeed();
case AFRICAN:
return getBaseSpeed() - getLoadFactor() *
_numberOfCoconuts;
case NORWEGIAN_BLUE:
return (_isNailed) ? 0 : getBaseSpeed(_voltage);
}
throw new RuntimeException ("Should be unreachable");
}
重构为:
(跟讲Switch那个Bad Smell时举过的例子,基本一样。)
3.19 Introduce Parameter Object
重构为:
3.20 Replace Error Code with Exception
int withdraw(int amount) {
if (amount > _balance) {
return -1;
else {
_balance -= amount;
return 0;
}
}
重构为:
void withdraw(int amount) throws BalanceException {
if (amount > _balance) throw new BalanceException();
_balance -= amount;
}
Why?
1. 提高代码可读性。
2. 方便调用方。调用方可以不再判断返回的Error Code,而只是把异常直接抛出去,待最终接受方处理。比如,类C/S的结构,服务端代码在所有环节都可以直接透传Exception,简化处理流程。Exception最终由客户端统一处理。
(注:有些地方无法完全取代Error Code,比如前台回来的消息处理,onMessage函数。)
3.21 Form Template Method
重构为:
(计算金额的步骤,各个子类都一样,即算得基础金额、再算得缴税几多,然后二者想加。于是把这个逻辑上升到父类。子类仅负责其中的子步骤。)
(下面再看一个例子。说明一下Template Method是怎么回事儿。
所有的工作人员,一天的活动,步骤都差不多,无外乎,吃早餐、赶到工作地点……,但不同子类对于各个步骤的实现是不同的。
比如,上午工作,公务员可能是:编写官样文章、聊天、按照规定合理地拒绝刁蛮市民的无理要求……;我司员工可能是:编码、调试、上网看新闻、到匿名论坛发牢骚……;农民工兄弟可能是:扛包、抽烟休息一会、扛包、喝水休息一会……。
比如,吃午餐,公务员是:免费、或者象征性收费的豪华自助餐;我司员工是:别无选择的XLX;农民工兄弟是:其他农民工在廉价出租棚子里制作的不干不净吃了可能得病的方便盒饭。)
以前做的一个培训,当时备课时还是花了一些工夫。ppt贴不上来,把备课稿贴在这,备份一个吧。
Bad Smells & Refactoring
1 题记
Any fool can write code that a computer can understand. Good programmers write code that humans can understand.——Martin Fowler
(任何一个傻瓜都能写出计算机可以理解的代码。唯有写出人类容易理解的代码,才是优秀的程序员。)
2 Bad Smells
2.1 Duplicated Code重复的代码
(重复代码,不需要定义,大家都知道是什么东东。)
1. “重复的代码”有什么不好?(比方说,从可维护性的角度看,很多地方的代码看似一样,又有可能有细微差别,对读代码的人容易产生困扰,于是可以定罪,可读性不好;同样的修改可能需要修改多次,而且容易遗漏,可修改性不好。)(但是,重复代码也有好处啊,代码行数多,千行代码故障率会少,对我们的考核比较有利……开玩笑的是么?)
2. 代码重复到什么程度,Smell才算Bad?(if (filename == null || filename.trim().equals(""))这句话可作为例子,我们认为这句话就算是重复代码了,比如哪天filename.trim().equals("")这个判断我觉得不好,想要换成filename.trim().length() == 0,那么岂不是霰弹式修改?使用绝技“Ctrl-C/ Ctrl-V”、同时还有用“Ctrl-F”,因为大家没有住在一起,要搜索一下才联系得到……)
3. 三种情况的重复代码:
a) 同一个class中的两个method含有相同表达式。(Extract Method)
b) 两个互为兄弟的subclass内含有相同表达式。(Extract Method、Pull up Method、Form Template Method)
c) 两个毫不相关的class内含有相同表达式。( Extract Method、 Extract Class)
2.2 Long Method过长函数
2.2.1 “过长函数”有什么不好?
1. 可读性:使用短函数,高层函数看起来像系列注释、低层函数不超过(比方说)10行,逻辑一目了然,可读性更优。
2. 可重用性:(长函数可能包含逻辑A/B/C,想单独重用A逻辑,不可能。一个类比的例子,发动机的重用机会肯定比车大。大家知道深圳BYD使用的是三菱发动机,标致307/206 1.6系列使用的发动机跟富康、爱丽舍16V系列是相同的。发动机上面的螺丝重用机会更大。很多不同种类发动机上使用的螺丝很可能是同一个厂家生产的相同螺丝。)
3. 可插入性:使用短函数,利用Override等操作,替换处理逻辑会更加容易。比如,更容易应用模板方法模式Template Method,参见Form Template Method章节。
2.2.2 “短函数”难道没有缺点?
1. 调用开销?(现代OO语言几乎已经完全免除了进程内的“函数调用动作的额外开销”。)
2. 看代码的时候,跳来跳去,很心烦?(高层函数看起来像系列注释,而且函数名字起得好,单纯看代码,比如接手模块的时候,低层的函数甚至可以不看。)
2.2.3 函数到了多长,Smell才算Bad?(写多长的函数才比较合适?)
1. 李维说:5行。(李维大家认识么?台湾IT界著名散文家,与侯捷齐名。不得不承认,此处我断章取义了,他说“5行”,是有一定语境的,他是想说“很短”的意思,建议大家不要追究他说的是5还是6。)
2. Martin Fowler说:长度不是问题,关键在于函数名称和函数本体之间的语义距离。(Martin Fowler大家认识么?《重构》《UML精粹》《分析模式》《企业应用架构模式》等经典著作的作者。后面,Martin还是,如果提炼动作可以强化代码的清晰度,那就去做,就算函数名称比提炼出来的代码还长也无所谓。什么叫做“函数名称与函数本体之间的语义距离”?函数名称要能概括函数体的动作,我引用的这句话没有能表达Martin的所有意思。)
3. 王振宇说:应该很短、可以较长,只要函数是在做一件从语义上无法再次拆解的事。(王振宇大家认识么?注意到没有,我们认为这句话是对Martin上一句话的补充,在函数体的组织方式上做了要求。简单说就是,一个函数做一件事儿。Extract Method中的那个例子。)
2.3 Large Class(过大类)
(一个例子)
1. “过大类”有什么不好?(难维护、容易出现重复代码。)
2. “过大类”的常见情况:
a) 本应该是针对不同类的操作放到一个类中。(比如,某些类本应拆出一些小的零件类,该类与零件类之间可能是关联、依赖关系,但没有这么做,而是所有代码放在一个类中。)
b) 大量静态方法放在“*Comm”类中。
i. 现象:大量静态方法放在“Comm”类中。很像C的函数库。(这个现象常见么?好不好?)
ii. 评述:这是一种使用面向对象语言编写面向过程代码的尝试。我个人觉得这类尝试是一种倒退,拒绝面向对象所带来的所有特性。没有很好地封装,程序的结构化不好;使用方与之的依赖是静态的;没有可插入性。
iii. 措施:解决这个问题的做法是,按照操作实施在哪个对象身上来把操作规划到对象所在的类里面;如果保持静态方法不变,也不要所有方法放到一个类中,最好按照语义来划分到合适的类。JDK类库提供了那么多方法,很少出现静态函数库的现象。当然,也不是说不存在,比如Math、ArrayList等类,不过至少他们在语义上分得很清晰。一个例子,比如,String的substring方法,很可能被一些程序员设计成public static,因为他们可能觉得无法把这个方法归属到哪个类中,于是放到“Comm”类中。(大家体会一下?是不是这么个事儿。)
iv. 疑问:那不是想调用某个方法的时候就要new一个实例(性能问题!)?首先,这又是面向过程的思维方式,想的是过程、调用,而不是对象、依赖、关联。其次,轻量级的对象,创建、回收成本很低。我曾经对一些不同算法、策略从性能角度做过相应的比较试验,通常,执行次数在10w-100w以上量级时,才有差别。我使用面向对象的做法,可以明显地获得更优的可维护性(可读、可扩展、可改、可插入、可重用),而且面向对象本身不会造成什么性能问题。当然,具体情况要具体分析,如有明显的性能隐患,最好能够做一个简单的试验,用数据来说话。做个类比,说某些政客在很多场合的潜台词中认为,民主、自由会破坏安定的大好局面,显然不能够让人信服;同样,说面向对象增加了对象的创建、销毁成本,会影响性能,影响软件系统的稳定局面,也是不能够让人信服的。于是在编程时尽可能地使用静态方法,这种做法,不可取。
3. 类长到多大才算“Large”?类,应该较小、可以较大,只要该类从语义上无法再次拆解。(“发动机”类,可以包含对“螺丝”类的引用(关联),但不要把“螺丝”类的操作也放到“发动机”类中来实现。)
2.4 Long Parameter List(过长参数列)
1. “过长参数列”有什么不好?(难读、难用、难记,还有一点,无法获得对象所带来的优势。比如,参数之间的约束关系没有得到很好地封装。例如,startTime/endTime,他俩作为参数来讲,可能不算长,这里仅做示例来说事儿。这对表示时间范围的参数可能多处使用,在没有包装成对象的时候,如果要保证“startTime < endTime”这个约束,就需要所有用到这对参数的地方都做判断;包装成对象,情况就好多了,比如叫做TimeRange,在类的构造函数中可以做这个判断。显然,使用对象,把变化封装得好一些。)
2. 想一下,JAVA类库的函数,比起C类库的函数,传递的参数是不是大都短很多?(应该是。这体现了面向对象的优势。)
3. “参数太多”这个Smell如何去除?
a) Introduce Parameter Object,无非是把多个参数封装成一个对象。
2.5 Divergent Change(发散式变化)
1. 什么是“发散式变化”?
a) “某一个类受到多种变化的影响”,A/B/C/D……多种功能变化的时候它都需要修改。
2. 为什么会造成“发散式变化”?哪儿没弄好?
a) 大致是由于这个类担负了多项任务,太操心了,不该他做的事儿也来做,越俎代庖。很可能需要再拆分几个类出来,把变化封装得更细。
3. 历史教训(反面教材)(以前我写配置MAF端代码的时候,写过一个P_Unit类,他处理所有BSC单元的逻辑,但各种单板的逻辑是不一样的,于是DTB改逻辑的时候要修改P_Unit、ABPM改的时候要修改P_Unit、IPCF、UPCF、GCM……所有具有特殊逻辑的单板修改功能的时候,他都要修改,甚至HDLC/UID等逻辑修改的时候P_Unit都要改。显然该类管得太多了。后来,我看了一本书,翻然悔悟,痛下把代码决心做了重构。其实早在03年,徐峰(据说徐峰要离开公司,这么牛的人离开了对我们整个OMC损失很大,我在这里提一下他的名字,简陋地送别一下。)做配置CAF的时候建议我针对每种有特殊逻辑的板子弄一个类,我完全不以为然。显然,当时没有理解“封装变化”这四个字。)
2.6 Shotgun Surgery(霰弹式修改)
1. 什么是“霰弹式修改”?
a) “一个变化引发多个类的修改”,完成某个需求的时候,A/B/C/D……多个类都需要修改。
2. 为什么会造成“霰弹式修改”?哪儿没弄好?
a) 大致是多个类之间的耦合太严重。很可能是类没有规划好,没有把变化封装得足够令人满意。
3. 一个插曲:记得此前讨论这个Bad Smell的时候,严钧认为,去掉这个Bad Smell不好强求,而且举出Abstract Factory模式作为例证。也有道理。我在这一点上是这么认为的:我们要清楚的认识到我们努力的方向,Abstract Factory模式同样不完美,它没有满足Open-Close原则。我们可以在某些条件(包括技术条件)受限的时候写出不完美的代码,但一定要知道它是不完美的。
a) Factory Method模式(工厂方法)代替Abstract Factory来说事儿。
每增加一种Produce的实现类,就要同时增加一个对应该类的Creator类。当时严钧可能说的是Abstract Factory模式,我用Factory Method模式来说事儿,因为他简单些,但同样可以说明问题。
b) Open-Close原则
软件实体应该对扩展开放,对修改关闭。Open-Close原则是一个愿景性质的原则,如果系统能够达到Open-Close原则描述的情形就比较理想了,对扩展开放、对修改关闭,即,不修改原有代码即可完成对系统的扩展。系统可以获得最大可能的稳定性,加功能的时候旧有代码不修改,当然不会带入BUG。
? 玉帝招安美猴王的故事
齐天大圣美猴王,想当初可是功夫了得,从东海龙王那儿拿了根棍儿,大闹天宫……叫嚣得不行(后来怎么不灵了,一根灯草、一根头绳、一条看大门的狮子狗都整不过),喊出来一些革命口号:“皇帝轮流做,明年到我家”,“只教他搬出去,将天宫让与我!”。有一些农民起义领袖的风范。
太白金星给玉皇大帝打了个报告出主意:“把他宣来上界……与他籍名在箓……一则不动众劳师,二则收仙有道也”。
玉皇大帝遵循Open-Close原则招安了美猴王。不动众劳师,不破坏天规,是关闭对已有系统的修改,不修改,是Closed。收仙有道,是对已有系统的扩展,可扩展,是Open。
同时应用了依赖倒换原则,合成/聚合复用原则,以后有机会给大家讲讲面向对象的设计原则。
4. 讲回霰弹式修改这个smell,很多程序在接手时,前辈一再嘱咐,改什么功能的时候,一定要注意,什么什么……一堆地方必须同时修改,要细心不要漏了……这很可能是设计水平的问题给维护造成的难度。其实如果程序设计得好,此后的工作将愉快很多。(插播广告)我记得,刚看到斯诺克比赛在电视上转播的时候(那时候还小,初中吧,还没见过真的斯诺克台球桌),很不屑,觉得他们大部分时候在打一些比较近距离的球,最多半张台子距离吧,我甚至盲目自信,感觉那些球我都能打进,电视上的人有什么了不起。其实大家都知道,母球走位是难度更大、更重要的工作(要经过全盘性思考的),走位走好了,下一杆就好打;就好比做软件,程序结构设计得好,维护就更容易。
2.7 Data Clumps(数据泥团)
1. 什么是“数据泥团”?
a) 某些数据项经常黏在一起行动,称之为“数据泥团”。时间长了就应该考虑是不是该把他们封装到一个对象中,来封装这个泥团所可能具有的逻辑。(比如一堆表示定位信息的字段,system、subsystem、unit、rack、shelf、slot……总是一起出现。)(这个Bad Smell在在很多时候与Long Parameter List,是一样的,但Data Clumps的涵盖范围比Long Parameter List要大一些,比如,某些类的Field,可能没有当作参数来传递,但是总是黏在一起,也可能出现数据之间的逻辑,于是也需要绑成一个对象,来做封装。)
2. 如何判断是否属于“数据泥团”?
a) 删掉这些数据项中的其中之一项,其他数据有没有因此失去意义?(比如startTime/ endTime,就是成对表示时间范围的,去掉其中一个,另一个失去意义。)
2.8 Switch Statements(Switch惊悚现身)
(惊悚现身,很像香港翻译好莱坞电影片名的风格是么?)
1. Switch语句有什么不好?
a) 容易形成“长函数”(比较容易理解)
b) 容易形成“霰弹式修改”
2. 如何替换掉Switch语句?
a) 多态(使用Pet的例子的第三个版本来说明)
3. 是不是使用多态可以去掉所有Switch语句?
a) 不是。比如,根据消息号,分发把消息分发到相应的处理函数(处理类)来处理。(原因是某些情况下,调用端无法动态创建确切(子类的)实例,于是依然需要分发过程。即,需要Switch语句分发、或者“配置文件+反射”的方式分发。)
4. 对Switch语句有什么要求?
a) Switch语句可以存在,但每个case的处理语句不应超过2-3行。
2.9 Comments(过多的注释)
(首先,注释本身没有错,很多时候注释是必须存在的。但,注释过多,就是坏味道了。)
1. Why?为什么?
a) 过多的注释,是降低代码可读性的帮凶。(如果,代码只有通过大量注释才能被理解,那么说明代码的可读性不好。事实上,很多文章也就此有些说法:代码要写得“自解释能力强”、自己解释自己;代码就是文档。这就要求,类、方法的编写要清爽,类名、方法名、变量名要起得好。)
2. How?如何写好注释?(Why?How?想起那个关于两个渔夫和一个美人鱼的荤笑话,由于有未成年人士在场,我不便当众详细讲。)
a) 写“why”。(注释应该写代码的编写思路,特别是某些地方没有按常理出牌,要写注释来说明。比如,对数组做for循环遍历,边界一般是数组的length,如果某一次出于某种特殊考虑,没这么做,就需要注释说明。)
b) 不写“what”。(注释不要写代码是干什么的,“what”这样的信息应该尽量包含在类名、方法名、变量名中。)
c) 不写充数注释。(不要为了写注释而写注释,不要往猪肉里注水,虽然没什么大碍,但终归是没品味的做法。比如,“String a = null;//创建一个String实例。”看到这样的注释,我胃口都不舒服。虽然部门有注释比例的要求,但像我们这样的高级程序员、高级工程师,还是不要充数用的注释。)
3 Refactoring
3.1 Extract Method
void printOwing() {
printBanner();
//print details
System.out.println ("name:" + _name);
System.out.println ("amount" + getOutstanding());
}
重构为:
void printOwing() {
printBanner();
printDetails(getOutstanding());
}
void printDetails (double outstanding) {
System.out.println ("name:" + _name);
System.out.println ("amount" + outstanding);
}
(前面说了,函数应该短。重复一下带来的好处:可读性好,高层函数像注释、低层函数行数少;可重用性好,比如上例中的printDetails,可能别处也能用,重构前是无法被重用的;可插入性好,子类可能写一个新的printDetails,使用不同格式打印。)
3.1.1 抽取函数时候,参数、临时变量如何处理
1. Replace Temp With Query 去掉临时局部变量,代之以查询类的方法,拆开的小函数需要此临时变量的时候,就调用这个查询方法。
2. Introduce Parameter Object 让长参数列变得简洁。
3. Replace Method with Method Object 去掉多个参数、局部变量。为待重构的大函数创建一个对象,这样,所有方法内的临时变量就变成对象的field,于是大函数拆开的所有小函数就共享这些field,不必再使用参数传递。
3.1.2 起名字很重要!名字应该:(此处的名字包括函数、类等)
1. 清晰、恰当。表达的信息涵盖函数的所作所为。
a) 当一个函数名字为了涵盖函数所为“必须”起成“do1stThingAndDo2ndThing”的时候,就有必要实施Extract Method来抽取函数了。
b) 一个OMC代码中的例子,某函数叫做checkParameter,但函数体中除了检查参数之外,还“顺便”为几个类属性赋值,虽然此函数很短、很超值,但我们认为他的命名是不恰当的,甚至他的函数设计也是不恰当的,一个函数要干单纯的一件事儿,函数内部从语义上无法再次分解。
2. 尽量简短、可以较长。但应该首先满足上一条要求。
a) compareToIgnoreCase(String类的方法)、getDisplayLanguage(Locale类的方法)、getTotalFrequentRenterPoints(《重构》书中的示例代码),这些函数名长不长?(重要的是把信息表述清楚,名字长一点没关系。)
3.2 Replace Temp with Query
double basePrice = _quantity * _itemPrice;
if (basePrice > 1000)
return basePrice * 0.95;
else
return basePrice * 0.98;
重构为:
if (basePrice() > 1000)
return basePrice() * 0.95;
else
return basePrice() * 0.98;
…
double basePrice() {
return _quantity * _itemPrice;
}
(例子很容易理解,basePrice是临时变量,临时变量的问题在于:它们是暂时的,而且只能在所属函数内使用。由于临时变量只有在所属函数内才可见,所以它们会驱使你写出更长的函数,因为只有这样你才能访问到想要访问的临时变量。如果把临时变量替换为一个查询式(query method),那么同一个class中的所有函数都将可以获得这份信息。为拆解大函数提供了方便。)
3.2.1 一个借助Replace Temp with Query来提炼函数的例子
double getPrice() {
int basePrice = _quantity * _itemPrice;
double discountFactor;
If (basePrice >1000) discountFactor = 0.95;
else basePrice = 0.98;
return basePrice * discountFactor;
}
重构为:
double getPrice() {
return basePrice() * discountFactor();
}
private int basePrice() {
return _quantity * _itemPrice;
}
Private double discountFactor() {
If (basePrice() >1000) return 0.95;
else return 0.98;
}
(重构前,在getPrice方法中,先计算基础价格、再计算折扣因子、再计算最终价格,做了语义上可以再次拆解的三件事儿,不符合“函数只做一件事儿”的要求,于是使用Extract Method方法来重构。借助Replace Temp with Query重构方法,将临时变量basePrice、discountFactor用相应的查询函数来替代。
需要指出的是,此次重构,查询函数basePrice被调用了两次,损失的一点点性能可以忽略,我们认为这样做是值得的。)
3.3 Split Temporary Variable
double temp = 2 * (_height + _width);
System.out.println (temp);
temp = _height * _width;
System.out.println (temp);
重构为:
final double perimeter = 2 * (_height + _width);
System.out.println (perimeter);
final double area = _height * _width;
System.out.println (area);
(如果临时变量被赋值超过一次就意味它们在函数中承担了一个以上的责任(循环变量等用途除外)。
例子中,临时变量temp开始被用来记录矩形周长,后来被用来记录矩形面积。应该拆解为多个临时变量,否则:
1. 影响代码可读性。(多用途临时变量通常无法获得合适的命名)
2. 增加代码出错机会。(程序某处,可能都记不清这个临时变量现在是记录什么数值的)
实际操作中,推荐使用final来限定临时变量被赋值次数。)
3.4 Remove Assignments to Parameters
int discount (int inputVal, int quantity, int yearToDate) {
if (inputVal > 50) inputVal -= 2;
重构为:
int discount (int inputVal, int quantity, int yearToDate) {
int result = inputVal;
if (inputVal > 50) result -= 2;
1. 不要对参数赋值
void nextDate(Date arg) {
arg.setDate(arg.getDate() + 1);
}
void nextDate(Date arg) {
arg = new Date(arg.getYear(), arg.getMonth(), arg.getDate() + 1);
}
上面两个函数的写法,哪个是对参数赋值了的?哪个会起到应有的作用?
2. Java是pass by value(传值)的
a) 传进函数体中的参数,是调用语句那个传入参数在内存中的一份拷贝
b) 函数体内对参数的再赋值不会影响调用方的参数原始值(比如,修改int等基本类型参数的数值、修改Object等对象引用的指向)
c) Java中,对参数的再次赋值是一种纯粹降低程序清晰程度的做法
3.5 Replace Method with Method Object
class Order...
double price() {
double primaryBasePrice;
double secondaryBasePrice;
double tertiaryBasePrice;
// long computation;
...
}
…
重构为:
(一个大的函数,提炼出一个专门实现这个函数功能的类。
比如getMoney方法可能就是提炼出一个MoneyGetter类。此处是price方法提炼出PriceCalculator类。
这样做的理由是,price可能是个很大的函数,我们为了获得短函数的优势(前面说过的,可重用、可读、可插入等),想利用Extract Method抽取出多个短函数,但每个短函数可能都需要primaryBasePrice、secondaryBasePrice、tertiaryBasePrice等几个临时变量,把它们都作为参数传递显然太笨了。而把这个大函数提炼成类,这些临时变量就变成了类的Field,在类中是共享的,这样,抽取出来的小函数之间就可以不需要传递参数,就很容易实现Extract Method这个重构过程。)
3.5.1 如此这般之后,类是不是太多了?
面向对象的套路,玩的就是类。函数不嫌多,为什么嫌类多?多个风马牛不相及的函数杂居在一个类中(能够容忍么?),为什么不多弄几个类把它们各自封装?类,对应了现实世界的有机无机物种,把物种分得足够细致,世界才得到了完美地描述。
3.6 Replace Array with Object
String[] person = new String[3];
person[0] = “Robert De Niro";
person[1] = “60";
person[2] = “Actor”;
重构为:
Person robert = new Person();
robert.setName(" Robert De Niro");
robert.setAge(“60");
robert.setProfession(“Actor”);
1. 数组应该容纳一组相似的对象,用户很难记住“数组第一个元素是人名、第二个元素是年龄”这样的约定。
a) 用注释来保证这种约定么?
b) 使用数组是出于效率考虑么?(抬杠?)
2. 同理,能够恰当地利用既有数据结构把接口约束得紧一些(更贴切、更严丝合缝),对大家都有好处。
a) 比如,能够确定是一组Person类实例,就要用Person[]来装,而不用Set、Map这样的“广口”容器。(拒绝“私下、口头约定,注释”等靠不住的协议方式所带来的弊端。)
3.7 Encapsulate Field
public String _name;
重构为:
private String _name;
public String getName() {
return _name;
}
public void setName(String arg) {
_name = arg;
}
强调封装:
一般来讲,任何时候都不要将类field声明为public(常量除外)。数据和使用数据的行为被集中在一起,一旦情况发生变化,代码的修改比较容易,因为需要修改的代码都集中在同一块地方,而不是星罗棋布地散落在整个程序中。
3.8 Replace Magic Number with Symbolic Constant
double potentialEnergy(double mass, double height) {
return mass * 9.81 * height;
}
重构为:
double potentialEnergy(double mass, double height) {
return mass * GRAVITATIONAL_CONSTANT * height;
}
static final double GRAVITATIONAL_CONSTANT = 9.81;
宏值定义代替散落在代码中的“魔术数”,没什么好说的。
3.9 Encapsulate Collection
重构为:
依然是强调封装:
1. 类内高内聚:数据和对数据的操作紧密结合在一起,对数据的实施操作比较容易。(比如,餐厅管理系统中的某个类,dishes(Vector类型)作为类的field,装载点菜时被点中的菜目,如果想统计一下哪些菜受欢迎,对于按Encapsulate Collection设计的类就比较容易操作,add方法中做一些手脚即可(分类累加)。)
2. 类之间松耦合:内部数据结构不要暴露的外界,外界也不需要关心。(这样,即便你把内部数据由array换成Vector,外部都不需要知道。)
3.10 Replace Type Code with Class
重构为:
3.10.1 Why?Type Code有什么不好?
1. Type Code会降低可读性。
a) 在定义的地方可能看不出来(定义时使用宏值,可读性挺好),但在使用的地方就会显现问题。上例中,比如有个方法getCharacter获得血型对应的性格描述,参数是血型,使用Type Code时,参数类型为int,重构后,参数类型为BloodGroup,显然后者的可读性好。
2. 使用Type Code失去了使用对象所拥有的独立扩展的机会。
a) 像这个例子,Type Code很容易有它自己的行为,比如根据血型得到性格描述、得到ABO溶血症可能性、判断血型之间的输血匹配可能……于是将其抽取成类是比较好的做法。(还是封装!)
3.10.2 插科打诨,Meilir Page-Jones讲的故事
(故事是讲面向对象的,面向对象的主要特征有哪些?封装、继承、多态)
Meilir Page-Jones在《UML面向对象设计基础》(个人认为此书堪称经典)一书中编了一个故事:
软件界在“面向对象”的定义上,一度很难达成一致。我开始步入面向对象领域时,决定澄清一下“面向对象”的定义。
我把数十位面向对象的老前辈关在一个没有食物和水的房间里。我告诉他们只有当他们的定义达成一致的意见,并且可以在软件世界发布时才允许他们出去。在一小时的喧哗过后,房内一片安静,老前辈们背靠背谁也不理谁了,陷入了僵局。此时,蹦出来一位组织者,让每个人都列出他们认为在面向对象世界中不可缺少的特性,大家同意。一通罗列,每个人都列出了三个五个、十个八个。
此时,刚才蹦出来那位组织者又蹦出来开始讲话,说,现在我们大致有两种做法:一种是建立一个长列表,该列表是每个人列表的并集;另一种是建立一个短列表,该列表是每个人列表的交集。大家选择了后者,产生了一个短列表,该列表中的特性在每个人列表中都有。这个列表确实很短,短到只有一个词,“封装”。
一堆废话告诉大家一个道理,封装,是面向对象最为重要的特性,封装好了,才能做到所谓的高内聚、松耦合。获得面向对象思想许诺的种种优势。
3.11 Replace Type Code with Subclass
(跟前面宠物店的例子是不是很像?)
重构为:
3.12 Replace Type Code with State/Strategy
重构为:
这个就厉害了!清晰地展示了“合成/聚合复用原则”。
上面例子,将Engineer和Salesman弄成并列的子类,是存在问题的。(什么问题?)
1. Salesman明确地从Employee继承,那么就无法再从Male、Newcomer等类继承来获得他们的特性。
2. Salesman的实例被new出来之后,他可能转岗做研发,想变成Engineer,无法实现。
3.12.1 什么是合成/聚合复用原则
1. 要尽量使用合成/聚合,尽量不要使用继承。
2. 从复用角度来说:“合成/聚合复用”比“继承”复用灵活。前者是动态复用(因而具有可插入性)、后者是静态复用(编译时就固定了复用关系),而且后者的复用有“不支持多重继承”的限制。
3.13 Decompose Conditional
if (date.before (SUMMER_START) || date.after(SUMMER_END))
charge = quantity * _winterRate + _winterServiceCharge;
else charge = quantity * _summerRate;
重构为:
if (notSummer(date))
charge = winterCharge(quantity);
else charge = summerCharge (quantity);
Extract Method在条件判断语句段中的应用。
3.14 Consolidate Conditional Expression
double disabilityAmount() {
if (_seniority < 2) return 0;
if (_monthsDisabled > 12) return 0;
if (_isPartTime) return 0;
// compute the disability amount
重构为:
double disabilityAmount() {
if (isNotEligableForDisability()) return 0;
// compute the disability amount
这条比较雕虫小技,可视具体情况参考实施。
3.15 Consolidate Duplicate Conditional Fragments
if (isSpecialDeal()) {
total = price * 0.95;
send();
}
else {
total = price * 0.98;
send();
}
重构为:
if (isSpecialDeal()) {
total = price * 0.95;
}
else {
total = price * 0.98;
}
send();
虽然这条也比较雕虫小技,但前面这样的代码确实也有人写得出来。
3.16 Remove Control Flag
set done to false
while not done
if (condition)
do something
set done to true
next step of loop
Control Flag为什么不好?
影响可读性,程序看起来比较绕。
3.16.1 一个例子
void checkSecurity(String[] people) {
String found = "";
for (int i = 0; i < people.length; i++) {
if (found.equals("")) {
if (people[i].equals ("Don")){
sendAlert();
found = "Don";
}
if (people[i].equals ("John")){
sendAlert();
found = "John";
}
}
}
someLaterCode(found);
}
无论找到Don还是John都退出循环做其他事儿。注意:这里使用了标志found。
重构为:
void checkSecurity(String[] people) {
String found = foundMiscreant(people);
someLaterCode(found);
}
String foundMiscreant(String[] people){
for (int i = 0; i < people.length; i++) {
if (people[i].equals ("Don")){
sendAlert();
return "Don";
}
if (people[i].equals ("John")){
sendAlert();
return "John";
}
}
return "";
}
重构之后,去掉了标志,增加了函数的出口,同时增加了程序的可读性。
3.17 Replace Nested Conditional with Guard Clauses
(卫语句:某些条件判断为真时,立即从函数返回。这样的判断就应该首先、单独进行,把这种单独检查称之为“卫语句”。Guard Clauses。是Kent Beck给起的名字,Kent Beck是TDD、XP的第一倡导者。)
double getPayAmount() {
double result;
if (_isDead) result = deadAmount();
else {
if (_isSeparated) result = separatedAmount();
else {
if (_isRetired) result = retiredAmount();
else result = normalPayAmount();
};
}
return result;
};
重构为:
double getPayAmount() {
if (_isDead) return deadAmount();
if (_isSeparated) return separatedAmount();
if (_isRetired) return retiredAmount();
return normalPayAmount();
};
(好处是明显的,可以减少if/else嵌套的数目,从而强烈地提高程序可读性。比较重要的是,需要习惯“函数有多个出口”这种做法。)
3.18 Replace Conditional with Polymorphism
double getSpeed() {
switch (_type) {
case EUROPEAN:
return getBaseSpeed();
case AFRICAN:
return getBaseSpeed() - getLoadFactor() *
_numberOfCoconuts;
case NORWEGIAN_BLUE:
return (_isNailed) ? 0 : getBaseSpeed(_voltage);
}
throw new RuntimeException ("Should be unreachable");
}
重构为:
(跟讲Switch那个Bad Smell时举过的例子,基本一样。)
3.19 Introduce Parameter Object
重构为:
3.20 Replace Error Code with Exception
int withdraw(int amount) {
if (amount > _balance) {
return -1;
else {
_balance -= amount;
return 0;
}
}
重构为:
void withdraw(int amount) throws BalanceException {
if (amount > _balance) throw new BalanceException();
_balance -= amount;
}
Why?
1. 提高代码可读性。
2. 方便调用方。调用方可以不再判断返回的Error Code,而只是把异常直接抛出去,待最终接受方处理。比如,类C/S的结构,服务端代码在所有环节都可以直接透传Exception,简化处理流程。Exception最终由客户端统一处理。
(注:有些地方无法完全取代Error Code,比如前台回来的消息处理,onMessage函数。)
3.21 Form Template Method
重构为:
(计算金额的步骤,各个子类都一样,即算得基础金额、再算得缴税几多,然后二者想加。于是把这个逻辑上升到父类。子类仅负责其中的子步骤。)
(下面再看一个例子。说明一下Template Method是怎么回事儿。
所有的工作人员,一天的活动,步骤都差不多,无外乎,吃早餐、赶到工作地点……,但不同子类对于各个步骤的实现是不同的。
比如,上午工作,公务员可能是:编写官样文章、聊天、按照规定合理地拒绝刁蛮市民的无理要求……;我司员工可能是:编码、调试、上网看新闻、到匿名论坛发牢骚……;农民工兄弟可能是:扛包、抽烟休息一会、扛包、喝水休息一会……。
比如,吃午餐,公务员是:免费、或者象征性收费的豪华自助餐;我司员工是:别无选择的XLX;农民工兄弟是:其他农民工在廉价出租棚子里制作的不干不净吃了可能得病的方便盒饭。)