重构-改善既有代码的结构
重构
一.何谓重构?
重构:使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构,提高其可理解性,降低其修改成本。
二.为什么要重构?
- 重构改进软件设计
- 重构使软件更容易理解
- 重构帮助找到BUG
- 重构提高编程速度
三.何时重构?
- 三次法则:事不过三,三则重构
- 添加功能时重构
- 修复BUG时重构
- Review代码时重构
四.何时不该重构?
- 无法稳定运行直接重写不用重构
- 项目已经接近最后期限,不应该重构,虽然重构能够提高生产力,但是你没有足够的时间,这通常标示你其实早该进行重构了。
五.代码的坏味道
- Duplicated Code 重复代码
- Long Method 过长函数
- Large Class 过大的类
- Long Parameter List 过长参数列表
- Divergent Change 发散式变化
- Shotgun Surgery 散弹式修改
- Feature Envy 依恋情结
- Data Clumps 数据泥团
- Primitive Obsession 基本类型偏执
- Switch Statements switch惊悚现身 (使用多态性替换)
- Parallel Inheritance Hierarchies 平行继承体系
- Lazy Class 冗赘类
- Speculative Generality 夸夸其谈未来性
- Temporary Field 令人迷惑的暂时字段
- Message Chains 过渡耦合的消息链
- Middle Man 中间人
- Inappropriate Intimacy 狎昵关系
- Alternative Classes with Different Interfaces 异曲同工的类
- Incomplete Library Class 不完美的库类
- Data Class 纯稚的数据类
- Refused Bequest 被拒绝的遗赠
- Comments 过多的注释
五.常用的重构手法
5.1 Extract Method(提炼函数)
func printOwing(amount: Double) {
printBanner()
//print details
print("name:", self.name)
print("amount:", amount)
}
func printOwing(amount: Double) {
printBanner()
printDetails(amount: amount)
}
func printDetails(amount: Double) {
//print details
print("name:", self.name)
print("amount:", amount)
}
动机
当一个函数过长或者需要注释才能看懂的代码,可以将这段代码放入一个独立的函数中,并给他一个合适的命名。
简短而命名良好的函数的优点:函数粒度越小,它被复用的几率越大;其次,这会使高层函数读起来像一系列注释;再次,如果函数都是细粒度,子类重载父类函数也会容易得多。
做法
- 创建一个新函数,根据这个函数的意图来给他命名(以“做什么”来命名,而不要以“怎么做命名”)。
2.将要提炼的代码从源函数复制到新建的目标函数中。
3.将被提炼代码中需要读取的局部变量,作为参数传给目标函数。
4.处理完所有局部变量后,进行编译。
5.在源函数中,将被提炼的代码段替换为对目标函数的调用。
6.编译,测试。
5.2 Inline Method(内联函数)
func getRating() -> Int {
return valueMoreThanFive() ? 2 : 1
}
func valueMoreThanFive() -> Bool {
return self.value > 5
}
func getRating() -> Int {
return (self.value > 5) ? 2 : 1
}
动机
某些函数,其内部代码和它的名称一样清晰易读。
你手上有一群组织不合理的函数,你可以先把它们内联到一个大的函数里,在从中提炼出组织合理的小型函数。
使用了太多的中间层,使得系统中所有的函数只是对另外一个函数的简单调用,造成在这些调用中晕头转向。
做法
- 检查函数,确定它不具有多态性。
如果有子类重载它,不要将它内联,因为不能重载一个不存在的函数
2.找到这个函数所有被调用的点。
3.将这个函数所有被调用点都替换为函数本体。
4.编译,测试。
5.删除该函数的定义。
5.2 Move Method(搬移函数)
搬移函数动机
“搬移函数”是重构理论的支柱。如果有一个类有太多行为,或如果一个类与另一个类有太多合作而形成高度耦合,就应该使用搬移函数。
一个函数使用另一个对象的次数比使用自己所驻的对象还要多,考虑是否把这个函数搬移到另一个对象所属的类中。
做法
- 检查源类中被源函数使用的一切特性(包括字段和函数),考虑他们是否也该被搬移。
如果某个特性只被你打算搬移的函数用到,就应该将它一并搬移。如果有另外的函数使用了这个特性,你可以考虑将该特性的所有函数全都一并搬移。有时候,搬移一组函数比逐一搬移简单些。
- 检查源类的子类和父类,看看是否有该函数的其它声明。
- 在目标类中声明这个函数。
你可以为此函数选择一个新名称——对目标类更有意义的名称
- 将源函数的代码复制到目标函数中。调整目标函数,使其能在新家正常运行。
- 编译目标类。
- 决定如何从源函数正确引用目标对象。
- 修改源函数,使之成为一个纯委托函数。
- 编译,测试。
- 决定是否删除源函数,或者将它作为一个委托函数保留下来。
- 如果要移除源函数,将源类中对源函数的所有调用,替换为对目标函数的调用。
- 编译,测试。
5.2 Rename Method(函数改名)
Rename Method动机
函数的名称应该准确地表达它的用途。给函数命名有一个好方法:首先考虑应该给这个函数写上一句怎样的注释,然后想办法将注释变成函数名称。
如果你看到一个函数名称不能很好地表达它的用途,应该马上加以修改。好的函数名可以让其它人更容易读懂你的代码。要想成为一个编程高手,起名的水平是至关重要的。
做法
- 检查函数是否被父类或子类实现过。如果是,需要针对每个实现分别进行下列步骤。
- 声明一个新函数,将它命名为你想要的新名称。将旧函数的代码复制到新函数中,并进行适当调整。
- 编译。
- 修改旧函数,让它掉用新函数。
- 编译,测试。
- 找出旧函数所有被引用点,将它们全部修改为对新函数的引用。每次修改后,编译并测试。
- 删除旧函数。
如果旧函数是该类public接口的一部分,你可能无法安全地删除它。这种情况下,将它保留在原地,并将它标示为deprecated。
- 编译,测试。
5.2 Add Parameter(添加参数)
为此函数添加一个对象参数,让该对象带进函数所需信息。
动机
你必须修改一个函数,而修改后的函数需要一些过去没有的信息,因此你需要给该函数添加一个参数。
做法
- 检查函数是否被父类或子类实现过。如果是,需要针对每个实现分别进行下列步骤。
- 声明一个新函数,名称与原函数相同,只是加上新添参数。将旧函数的代码复制到新函数中。
- 编译。
- 修改旧函数,让它掉用新函数。
- 编译,测试。
- 找出旧函数所有被引用点,将它们全部修改为对新函数的引用。每次修改后,编译并测试。
- 删除旧函数。
如果旧函数是该类public接口的一部分,你可能无法安全地删除它。这种情况下,将它保留在原地,并将它标示为deprecated。
- 编译,测试。
5.2 Remove Parameter(移除参数)
函数本体不再需要某个参数,将该参数去除
动机
程序员可能经常添加参数,确往往不愿意去掉它们。因为多余的参数不会引起任何问题,而且以后还可能用上它。
但是对于多态函数,情况有所不同。可能多态函数的另一个实现会使用这个参数,此时你就不能去除它。
做法
- 检查函数是否被父类或子类实现过。如果是,需要针对每个实现分别进行下列步骤。
- 声明一个新函数,名称与原函数相同,只是去除不必要的参数。将旧函数的代码复制到新函数中。
- 编译。
- 修改旧函数,让它调用新函数。
- 编译,测试。
- 找出旧函数所有被引用点,将它们全部修改为对新函数的引用。每次修改后,编译并测试。
- 删除旧函数。
如果旧函数是该类public接口的一部分,你可能无法安全地删除它。这种情况下,将它保留在原地,并将它标示为deprecated。
- 编译,测试。
5.2 Parameterize Method(令函数携带参数)
若干函数做了类似的工作,但在函数名称中却包含了不同的值。建立单一函数,以参数表达那些不同的值
动机
你可能会发现两个函数,它们做着类似的工作,但因少数几个值致使行为略有不同。
做法
- 新建一个带参数的函数,使它可以替换先前所有的重复性函数。
- 编译。
- 将调用旧函数的代码改为调用新函数。
- 编译,测试。
- 对所有旧函数重复上述步骤,每次替换后,修改并测试。
- 删除旧函数。
如果旧函数是该类public接口的一部分,你可能无法安全地删除它。这种情况下,将它保留在原地,并将它标示为deprecated。
- 编译,测试。
5.2 Replace Parameter with Explicit Methods(以明确函数取代参数)
你有一个函数,其中完全根据参数值不同而采取不同的行为。针对该参数的每一个可能值,建立一个独立函数
重构前:
func setValue(name: String, value: CGFloat) {
if name == "height" {
self.height = value
} else if name == "width" {
self.width = value
}
}
重构后:
func setHeight(value: CGFloat) {
self.height = value
}
func setWidth(value: CGFloat) {
self.width = value
}
动机
如果某个参数有多种可能的值,而函数内又以条件表达式检查这些参数值,并根据不同参数值做出不同的行为,那么就应该使用本项重构。
接口更清晰,相比之下,Switch.setOn()比Switch.setState(true)要清楚得多。
做法
- 针对参数的每一种可能值,新建一个明确函数。
- 修改条件表达式的每个分支,使其调用合适的新函数。
- 修改每个分支后,编译并测试。
- 修改原函数的每一个被调用点,让它们调用合适的新函数。
- 编译,测试。
- 所有调用端都修改完毕后,删除原函数。
5.2 Preserve Whole Objcet(保持对象完整)
你把某个对象的若干值作为函数调用时的参数,改为传递整个对象
重构前:
let low = daysTempRange.low
let high = daysTempRange.high
withinPlan = plan.withinRange(low, high)
重构后:
withinPlan = plan.withinRange(daysTempRange)
动机
有时候,你会将同一对象的若干数据项作为参数,传递给某个函数。这样做的问题在于:万一将来被调用函数需要新的数据项,你就必须查找并修改对此函数的所有调用。
使参数列表变短,提高代码可读性。
弊端:如果你传的是数值,被调用的函数只依赖于这些数值。如果你传递的是整个对象,被调用的函数所在的对象就需要依赖参数对象。
5.2 Replace Parameter with Methods(以函数取代参数)
对象调用某个函数,并将所得结果作为参数,传递给另一个函数。而另一个函数本身也能调用前一个函数。让参数接受者去除该项参数,并直接调用前一个函数
重构前:
let basePrice = quantity * itemPrice
let discountLevel = self.getDiscountLevel()
let finalPrice = discountedPrice(basePrice, discountLevel)
重构后:
let basePrice = quantity * itemPrice
let finalPrice = discountedPrice(basePrice)
动机
如果函数可以通过其它途径获得参数值,那么它就不应该通过参数取得该值。
做法
- 如果有必要,使用Extract Method将参数的计算过程提炼到一个独立函数中。
- 将函数本地内引用该参数的地方改为调用新建的函数。
- 每次替换后,编译并测试。
- 全部替换完成后,使用Remove Parameter将该参数去掉。
5.2 Introduce Parameter Object(引入参数对象)
某些参数总是很自然地同时出现
动机
你常会看到特定的一组参数总是一起被传递,可以运用一个对象包装所有这些数据。
当你把这些参数组织到一起后,往往很快可以发现一些行为可被移至新建的类中。
做法
- 新建一个类,用以表现你想替换的一组参数。
- 编译。
- 针对使用该组参数的所有函数,使用Add Parameter,传入新建类的实例对象,并将此参数值设置为null。
- 对于这些参数中的每一项,从函数名中移除掉,并修改调用端和函数本体,让它们都通过新的参数对象取得该值。
- 每去除一个参数,编译并测试。
- 将原先的参数全部去掉之后,观察有无适当的函数可以运用Move Method搬移到参数对象之中。
被搬移的可以是整个函数,也可以是某个函数的部分代码。如果是后者,需要先用Extract Method将该部分代码提炼到一个新的函数中。
5.2 Hide Method(隐藏函数)
有一个函数,从来没有被其他任何类用到,将这个函数修改为private
动机
一个类,暴露给其他类的接口越多,越容易产生耦合。在一个函数确实需要被其他类访问之前,应该让它对其他类不可见。
做法
- 经常检查有没有可能降低某个函数的可见度。
- 尽可能降低所有函数的可见度。
- 每完成一组函数的隐藏之后,编译并测试。
5.2 Replace Constructor With Factory Method(以工厂函数取代构造函数)
你希望在创建对象时不仅仅是简单的构建动作,将构造函数替换为工厂函数
重构前:
Employee(type: Int) {
self.type = type
}
重构后:
static func create(type: Int) -> Employee {
return Employee(type: type)
}
动机
最明显的动机就是,在派生子类的过程中以工厂函数取代类型码。
做法
- 新建一个工厂函数,让它调用现有的构造函数。
- 将调用构造函数的代码改为调用工厂函数。
- 每次替换后,编译并测试。
- 将构造函数声明为private。
- 编译。
5.2 Pull Up Field(字段上移)
两个子类拥有相同的字段,将该字段移至父类
动机
如果子类是分别开发的,或者是在重构过程中组合起来的,你常会发现它们拥有重复性,特别是字段更容易重复。如果它们重复了,你就可以将它们归纳到父类中去。
做法
- 针对待提升的字段,检查它们的所有被使用点,确认它们以同样的方式被使用。
- 如果这些字段的名称不同,先将它们改名,使每一个名字都和你想为父类字段取的名字相同。
- 在父类中新建一个字段,并移除子类中的字段。
4.编译,测试。
5.2 Pull Up Method(函数上移)
有些函数,在各个子类中产生完全相同的结果。将该函数移至超类中。
动机
如果函数在各个子类中的函数体都相同,这就是最显而易见的适用场合。
Pull Up Mehod常常紧随其它重构而被使用。也许你能找出若干个身处不同子类中的函数,而它们又可以通过其它重构手法调整成为相同的函数。这时候,你就可以先调整函数,然后再将它们上移到父类中去。当然,如果你足够自信,也可以一次完成这两个步骤。
做法
- 检查待上移的函数,确定它们是完全一致的。
- 如果待上移的函数的名字不同,先使用Rename Method将它们都修改为你想要在父类中使用的名字。
- 在父类中新建一个函数,将某一个待上移函数的代码复制到其中,做适当的调整,然后编译。
- 移除一个待上移的子类函数, 编译,测试。
- 逐一移除待上移的子类函数,直到只剩下父类中的函数为止。每次移除之后都需要测试。
- 观察该函数的调用者,看看是否可以改为使用父类类型的对象。
5.2 Pull Up Constructor Body(构造函数本体上移)
在父类中新建一个构造函数,并在子类构造函数中调用它
重构前:
class Manager: Employee {
init(name: String, id: String, grade: Int) {
self.name = name
self.id = id
self.grade = grade
}
}
重构后:
class Employee {
init(name: String, id: String) {
self.name = name
self.id = id
}
...
}
class Manager: Employee {
init(name: String, id: String, grade: Int) {
self.grade = grade
super.init(name: name, id: id)
}
...
}
动机
如果你看到各个子类中的函数有共同行为,第一个念头就是将共同行为提炼到一个独立函数中,然后将这个函数上移到父类。对于构造函数而言,它们彼此的共同行为往往就是“对象的构建”。
做法
- 在父类中定义一个构造函数。
- 将子类构造函数中的共同代码搬移到父类构造函数中。
- 将子类构造函数中的共同代码删掉,改成调用新建的父类构造函数。
- 编译,测试。
5.2 Pull Down Method(函数下移)
父类中的某个函数只与部分子类有关,将这个函数移到相关子类去
动机
Pull Down Method与Pull Up Method刚好相反。当我们有必要把某些行为从父类移至子类时,就使用Pull Down Method。使用Extract SubClass之后你可能会需要它。
做法
- 在所有子类中声明该函数,将父类中的函数本体复制到每一个子类函数中。
- 删除父类中的函数。
- 编译,测试。
- 将该函数从所有不需要它的那些子类中删掉。
- 编译,测试。
5.2 Pull Down Field(字段下移)
父类中的某个字段只被部分子类用到,将这个字段移到需要它的那些子类中去
动机
与Pull Up Field刚好相反。如果只有部分子类需要父类中的一个字段,可以使用本项重构。
做法
- 在所有子类中声明该字段。
- 将该字段从父类中移除。
- 编译,测试。
- 将该字段从所有不需要它的那些子类中删掉。
- 编译,测试。
5.2 Extract Subclass(提炼子类)
类中的某些特性只被某些实例用到,新建一个子类,将那一部分特性移到子类中
动机
与Pull Up Field刚好相反。如果只有部分子类需要父类中的一个字段,可以使用本项重构。
做法
- 在所有子类中声明该字段。
- 将该字段从父类中移除。
- 编译,测试。
- 将该字段从所有不需要它的那些子类中删掉。
- 编译,测试。
5.2 Extract Superclass(提炼父类)
两个类有相似的特性。为两个类建立一个父类,将相同特性移至父类。
动机
重复代码是系统中最糟糕的东西之一。如果你在不同的地方做同一件事情,一旦需要修改那些动作,你就需要修改每一份代码。
如果两个类以相同的方式做类似的事情,你就需要使用继承机制来去除这些重复。
做法
- 为原本的类创建一个空白父类。
- 运用Pull Up Filed、Pull Up Method、 Pull Up Constructor Body逐一将子类中的共同元素上移到父类。
- 每次上移后,编译并测试。
- 检查留在子类中的函数,看它们是否还有共通成分。如果有,可以使用Extract Method将共同部分提炼出来,然后使用Pull Up Method将提炼出的函数上移到父类中。
- 将所有共通元素上移到父类后,检查子类的所有用户。如果它们只使用了共同接口,你就可以把调用它们的对象类型改为父类类型。
5.2 Replace Inheritance with Delegation(以委托代替继承)
在子类中新建一个字段持有父类的对象;调整子类函数,令它改而委托父类;然后去掉两者的继承关系
动机
设计模式中讲到,优先使用组合而不是继承。
但是你常常会遇到这样的情况:一开始继承了一个类,随后发现父类中的许多操作并不真正适用于子类。这是你可以使用组合来代替继承。
做法
- 在子类中新建一个字段,使其引用父类的一个对象,并将它初始化为self。
- 修改子类中的所有函数,让它们不再使用父类,转而使用上面新加的那个字段。
- 去除两个类之间的继承关系,新建一个受托类的对象赋给受托字段。
- 对于父类中的每一个函数,在子类中添加一个简单的委托函数。
- 编译,测试。
5.2 Extract Class(提炼类)
某个类做了应该由两个类做的事。建一个新类,将相关字段和和函数搬移到新类。
动机
单一职责原则说到,一个类应该只有一个职责。
如果一个类职责过于多,过于复杂,就应该把它们分离到一个单独的类中。
做法
- 决定如果分解类所负的责任。
- 建立一个新类,用以表现从旧类中分离出的职责。
- 建立从旧类访问新类的连接关系。(大部分情况是让旧类持有新类类型的变量)
- 对于每一个你想搬移的字段,使用Move Filed进行搬移。
- 每次搬移后编译,测试。
- 使用Move Method将必要函数搬移至新类。先搬较低层的函数,后搬较高层的函数。
- 每次搬移后编译,测试。
- 检查,精简每个类的接口。
- 决定是否公开新类。如果你确实需要公开它,就需要决定让它成为引用类型还是值类型。
六、重构示例(心动日常查看距离功能重构)
心动日常iOS的查看距离会根据用户所处的位置使用合适的地图,如果双方都在国内使用高德地图,如果有一方在国外使用苹果地图。
旧版本双方都有位置信息时才会展示地图,新版本接到新的需求:默认展示地图,只要一方有位置信息在地图上对应的位置展示人物头像,人物头像上增加用户位置权限标识。
接到需求后,我先熟悉代码。经过一段时间的熟悉,我发现了一些问题:
1.控制器类持有MAMapView和YLAppleMapView类的对象,他们的功能类似并且是互斥的,可以抽象出相同的接口。
2.一些实例变量和函数很明显应该封装到地图类内部,而不是放在ViewController类里。
3.需要调用对应地图类对象时,都要使用if语句来判断
因为这些问题,如果我要修改现有代码,我必须要修改对应的高德地图和苹果的图部分的代码。但是因为他们所在函数的函数名和具体的业务逻辑可能很不一致,找起来可能会很麻烦。
如果我要往地图上添加头像,需要判断当前是什么地图,并分别调用不同的方法。想想就很头大,于是我决定重构。
重构前后代码结构对比:
UML图-重构前UML图-重构后 ViewController成员-重构前
ViewController成员-重构后 地图相关的成员变量-重构前
地图相关的成员变量-重构后
具体重构过程通过git代码演示。