当前位置 博文首页 > CHQIUU的专栏:重构改善现有代码的设计-知识点整理
名词形式:对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提供其可理解性,降低其修改成本;
动词形式:使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构;
添加新功能时,不应该修改既有代码,只管添加新功能,并通过测试;
重构时不能再添加新功能,只管改进程序的架构,并通过已有的测试。
为什么程序如此难以修改? | 设计与重构的目标 |
---|---|
难以阅读的程序,难以修改 | 容易阅读 |
逻辑重复的程序,难以修改 | 所有逻辑都只在唯一地点指定 |
添加新行为时需要修改已有代码的程序,难以修改 | 新的改动不会危及现有行为 |
带复杂条件逻辑的程序,难以修改 | 尽可能简单表达条件逻辑 |
允许逻辑共享:比如说一个子函数在两个不同的地点被调用,或超类中的某个函数被所有子类共享;
分开解释意图和实现:方法越短小越容易起名字解释意图,职责越单一;
隔离变化:很可能我在修改两个不同地点使用的同一对象,其中一个地点我想改变对象行为,但如果修改了它,我就要冒同时影响两处的风险。为此我做出一个子类,并在需要修改处引用这个子类。现在,我可以修改这个子类而不必承担无意中影响另一处的风险
封装条件逻辑:对象有一种奇妙的机制–多态消息,可以灵活而清楚的表达条件逻辑,将条件逻辑转化为消息形式,往往能降低代码的重复、增加清晰度并提高弹性
“计算机科学是这样一门科学:它相信所有问题都可以通过增加一个间接层来解决。”
----Dennis DeBruler
使代码难以阅读,因此要权衡加入间接层的利弊
决定何时重构、何时停止和知道重构机制如何运转一样重要
代码有很多种坏味道,重复是最坏的一种
是Shortgun Surgery的一种特殊情况,每当为某个类增加一个子类,必须也为另外一个类相应增加一个子类。
例如:
蜡笔有大中小三种型号,12种颜色,总共必须有36种蜡笔。
每增加一种颜色,都必须增加大中小三种型号。颜色和型号紧紧耦合在一起。
再来看毛笔,不同的毛笔型号抽象成毛笔,不同颜色抽象成颜料,
毛笔和颜料两个基类形成关联,避免了Shortgun Surgery,这就是Bridge模式。
如果两个函数做同一件事,却有着不同的签名,可运用Rename Method根据它们的用途重新命名。但这往往不够,请反复运用Move Method将某些行为移入类,直接两者的协议一致为止。如果你必须重复而的移入代码才能完成这些,或许可运用Extract Supercalss为自己赎点罪
数据类不应该把全部字段单纯的通过getter/setter暴露出来(我们在多层结构系统开发时经常这么做),而应该暴露抽象接口,封装内部结构。《Clean Code》第六章开始也有讲过同样的问题。
在面向对象的设计过程中,“决定把责任放在哪儿”是最重要的事之一。
最常见的烦恼是:你不可能一开始就保证把事情做对。
在这种情况下,就可以大胆使用重构,改变自己原先的设计。
类的行为做到单一职责,不要越俎代庖。
如果一个类有太多行为,或一个类与另一个类有太多合作而形成高度耦合,就需要搬移函数。
观察调用它的那一端、它调用的那一端,已经继承体系中它的任何一个重定义函数。
根据“这个函数不哪个对象的交流比较多”,决定其移动路径。
如果一个类的字段在另一个类中使用更频繁,就考虑搬移它。
一个类应该是一个清楚地抽象,处理一些明确的责任。
Inline Class (将类内联化)正好于Extract Class (提炼类)相反。如果一个类丌再承担足够责仸、丌再有单独存在的理由。将这个类的所有特性搬移到另一个类中,然后移除原类。
在服务类上建立客户所需的所有函数,用以隐藏委托关系
封装委托对象也是要付出代价的:每当客户要使用受托类的新特性时,就必须在服务端添加一个委托函数。
随着委托类的特性(功能)越来越多,服务类完全变成了“中间人”,此时就应该让客户直接调用受托类。
很难说什么程度的隐藏才是合适的,随着系统不断变化,使用Hide Delegate和Remove Middle Man不断调整。
你需要为提供服务的类增加一个函数,但你无法修改这个类。
在客户类中建立一个函数,并以第一参数形式传入一个服务类实例。
客户类使用Date类的接口,但Date类没有提供nextDay()的接口,也不能改Date的源码:
Date newStart = new Date(previousEnd.getYear(), previousEnd.getMonth(), previousEnd.getDate()+1);
应改成
Date newStart = nextDay(previousEnd);
Date nextDay(Date date) {
return new Date(date.getYear(), date.getMonth(), date.getDate()+1);
}
你需要为服务类提供一些额外函数,但你无法修改这个类。
建立一个新类,使它包含这些额外函数。让这个扩展品成为源类的子类戒包装类。
为这个字段建立getter/setter函数,并且只以这些函数访问字段。
直接访问一个字段,导致出现强耦合关系;
直接访问的好处是易阅读,间接访问的好处是好管理,子类好覆写。
一个数据项,需要与其他数据和行为一起使用才有意义。将数据项改成对象。
随着设计深入,数据之间的关系逐渐显现出来,就需要将相关数据及其操作封装成对象。
如果希望修改某个值对象的数据,并且影响到所有引用此对象的地方。
将这个值对象变成引用对象。
组合改为关联
引用对象很小且不可变,将它改成一个值对象。
数组中的元素各自代表不同的东西。
以对象替换数组,对于数组中的每个元素,以一个字段来表示。
两个类都需要使用对方特性,但其间只有一条单向连接。
添加一个反向指针,并使修改函数能够同时更新2条连接。
两个类之间有双向关联,但其中一个类如今不再需要另一个类的特性。去除不必要的关联。
你有一个字面数值,带有特别含义。
创建一个常量,根据其意义为它命名,并将上述的字面数值替换为这个常量。
你的类中存在一个public字段。将它声明为private,并提供相应的访问函数。
如果一个函数返回一个集合,应改为返回该集合的一个只读副本,并在这个类中提供添加/移除集合元素的函数。
类中有一个数值类型码,但它并不影响类的行为。以一个新的类替换该数值类型码。
如果类型码不会影响宿主类的行为,可以使用Replace Type Code with Class (以类取代类型码)来处理。
但如果类型码会影响宿主类的行为,最好的办法是多态来处理变化行为。
你有一个类型码,它会影响类的行为,但你无法提供继承手法消除它。以状态对象替代类型码。
你的各个子类的唯一差别只在“返回常量数据”的函数身上。
修改这些函数,使它们返回超类中的某个字段(新增),然后销毁子类。
在面向对象的设计过程中,表达式的使用通常较少,
因为很多条件行为都被多态机制处理掉了,所以条件的扩展更为容易。
有时候条件逻辑可能十分复杂,可以使用本章提供的一些重构手法来简化它们。
程序中,复杂的条件逻辑是最常导致复杂度上升的地点之一。
可以将它分解为多个独立函数,根据每个小块代码的用途,为分解的新函数命名,从而更清楚的表达意图。
if (date.before(SUMMER_START) || date.after(SUMMER_END)){
charge = quantity * winterRate;
} else{
charge = quantity * summerRate;
}
改为
if (notSummer(date)){
charge = winterCharge(quantity);
} else{
charge = summerCharge(quantity);
}
boolean notSummer(Date date) {
return date.before(SUMMER_START) || date.after(SUMMER_END);
}
double winterCharge(double quantity) {
return quantity * winterRate;
}
一系列条件测试,都得到相同结果。
将这些测试合并为一个条件表达式,并将这个条件表达式提炼为一个独立函数。
在条件表达式的每个分支上有着相同的一段代码。将这段重复代码移到条件表达式之外。
以break或return语句取代控制标记。
double getPayAmount (){
double result;
if (_isDead)
result = deadAmount();
else{
if (_isSep)
result = SepAmount();
else{
if (isRetired)
result = retireAmount();
else
result = normalAmount();
}
}
return result;
}
改为
double getPayAmount () {
if (_isDead)
return deadAmount();
if (_isSep)
return SepAmount();
if (isRetired)
return retireAmount();
return normalAmount();
}
一个条件表达式,它根据对象类型的丌同而选择丌同的行为。
将这个条件表达式的每个分支放进一个子类的覆写函数中,然后将原始函数声明为抽象函数。
某一段代码需要对程序状态做出某种假设。以断言明确表现这种假设。
使用断言明确标明对输入条件的严格要求和限制;
断言可以辅助交流和调试。
double getExpenseLimit () {
//should have either expense limit or a primary project
return (_expLimit != NULL_EXPENSE) ? _expLimit : _primaryPro.getExpenseLimit();
}
改为
double getExpenseLimit () {
Assert.isTrue((_expLimit != NULL_EXPENSE) || _primaryPro != NULL );
return (_expLimit != NULL_EXPENSE) ? _expLimit : _primaryPro.getExpenseLimit();
}
在面向对象的设计技术中,最重要的概念莫过于“接口”(interface)。
容易被理解和被使用的接口,是开发良好面向对象软件的关键。
某个函数需要从调用端得到更多信息。为此函数添加一个对象参数,让该对象带进函数所需信息。
函数不再需要某个参数时,将其移除
若干函数做了类似的工作,但在函数本体中却包含了不同的值。
建立一个单一函数,以参数表达那些不同的值。
某个函数完全取决于参数值而采取不同行为,为了获得一个清晰的接口,
针对该参数的每一个可能值,建立一个独立函数。
void setValue (String name, int value) {
if (name.euqals("height") {
_height = value;
return ;
}
if (name.euqals("width") {
_width = value;
return ;
}
Assert.shouldNeverReachHere();
}
改为
void setHeight ( int arg) {
_height = arg;
}
void setWidth ( int arg) {
_width = arg;
}
如果从对象中取出若干值,将它们作为某一次函数调用时的参数。改为传递整个对象。
除了可以使参数列更稳固外,还能简化参数列表,提高代码的可读性。
此外,使用完整对象,被调用函数可以利用完整对象中的函数来计算某些中间值。
不过事情总有2面:如果你传的是数值,被调用函数就叧依赖于这些数值。但如果你传递的是整个对象,被调用函数所在的对象就需要依赖参数对象。如果这会使你的依赖结构恶化,那么就不该使用。
有的观点认为:如果被调用函数只需要参数对象的其中一项数值,那么只传递那个数值会更好。这个观点不能被认同:因为传递一项数值和传递一个对象,至少在代码清晰度上是一致的。更重要的考量应该放在对象之间的依赖关系上。