重构-改善既有代码的结构

2021-11-30  本文已影响0人  癫癫的恋了

重构

一.何谓重构?

重构:使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构,提高其可理解性,降低其修改成本。

二.为什么要重构?

三.何时重构?

四.何时不该重构?

五.代码的坏味道

  1. Duplicated Code 重复代码
  2. Long Method 过长函数
  3. Large Class 过大的类
  4. Long Parameter List 过长参数列表
  5. Divergent Change 发散式变化
  6. Shotgun Surgery 散弹式修改
  7. Feature Envy 依恋情结
  8. Data Clumps 数据泥团
  9. Primitive Obsession 基本类型偏执
  10. Switch Statements switch惊悚现身 (使用多态性替换)
  11. Parallel Inheritance Hierarchies 平行继承体系
  12. Lazy Class 冗赘类
  13. Speculative Generality 夸夸其谈未来性
  14. Temporary Field 令人迷惑的暂时字段
  15. Message Chains 过渡耦合的消息链
  16. Middle Man 中间人
  17. Inappropriate Intimacy 狎昵关系
  18. Alternative Classes with Different Interfaces 异曲同工的类
  19. Incomplete Library Class 不完美的库类
  20. Data Class 纯稚的数据类
  21. Refused Bequest 被拒绝的遗赠
  22. 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)
    }

动机

当一个函数过长或者需要注释才能看懂的代码,可以将这段代码放入一个独立的函数中,并给他一个合适的命名。
简短而命名良好的函数的优点:函数粒度越小,它被复用的几率越大;其次,这会使高层函数读起来像一系列注释;再次,如果函数都是细粒度,子类重载父类函数也会容易得多。

做法

  1. 创建一个新函数,根据这个函数的意图来给他命名(以“做什么”来命名,而不要以“怎么做命名”)。
    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
    }

动机

某些函数,其内部代码和它的名称一样清晰易读。
你手上有一群组织不合理的函数,你可以先把它们内联到一个大的函数里,在从中提炼出组织合理的小型函数。
使用了太多的中间层,使得系统中所有的函数只是对另外一个函数的简单调用,造成在这些调用中晕头转向。

做法

  1. 检查函数,确定它不具有多态性。

如果有子类重载它,不要将它内联,因为不能重载一个不存在的函数

2.找到这个函数所有被调用的点。
3.将这个函数所有被调用点都替换为函数本体。
4.编译,测试。
5.删除该函数的定义。

5.2 Move Method(搬移函数)

搬移函数

动机

“搬移函数”是重构理论的支柱。如果有一个类有太多行为,或如果一个类与另一个类有太多合作而形成高度耦合,就应该使用搬移函数。
一个函数使用另一个对象的次数比使用自己所驻的对象还要多,考虑是否把这个函数搬移到另一个对象所属的类中。

做法

  1. 检查源类中被源函数使用的一切特性(包括字段和函数),考虑他们是否也该被搬移。

如果某个特性只被你打算搬移的函数用到,就应该将它一并搬移。如果有另外的函数使用了这个特性,你可以考虑将该特性的所有函数全都一并搬移。有时候,搬移一组函数比逐一搬移简单些。

  1. 检查源类的子类和父类,看看是否有该函数的其它声明。
  2. 在目标类中声明这个函数。

你可以为此函数选择一个新名称——对目标类更有意义的名称

  1. 将源函数的代码复制到目标函数中。调整目标函数,使其能在新家正常运行。
  2. 编译目标类。
  3. 决定如何从源函数正确引用目标对象。
  4. 修改源函数,使之成为一个纯委托函数。
  5. 编译,测试。
  6. 决定是否删除源函数,或者将它作为一个委托函数保留下来。
  7. 如果要移除源函数,将源类中对源函数的所有调用,替换为对目标函数的调用。
  8. 编译,测试。

5.2 Rename Method(函数改名)

Rename Method

动机

函数的名称应该准确地表达它的用途。给函数命名有一个好方法:首先考虑应该给这个函数写上一句怎样的注释,然后想办法将注释变成函数名称。
如果你看到一个函数名称不能很好地表达它的用途,应该马上加以修改。好的函数名可以让其它人更容易读懂你的代码。要想成为一个编程高手,起名的水平是至关重要的。

做法

  1. 检查函数是否被父类或子类实现过。如果是,需要针对每个实现分别进行下列步骤。
  2. 声明一个新函数,将它命名为你想要的新名称。将旧函数的代码复制到新函数中,并进行适当调整。
  3. 编译。
  4. 修改旧函数,让它掉用新函数。
  5. 编译,测试。
  6. 找出旧函数所有被引用点,将它们全部修改为对新函数的引用。每次修改后,编译并测试。
  7. 删除旧函数。

如果旧函数是该类public接口的一部分,你可能无法安全地删除它。这种情况下,将它保留在原地,并将它标示为deprecated。

  1. 编译,测试。

5.2 Add Parameter(添加参数)

为此函数添加一个对象参数,让该对象带进函数所需信息。

Add Parameter

动机

你必须修改一个函数,而修改后的函数需要一些过去没有的信息,因此你需要给该函数添加一个参数。

做法

  1. 检查函数是否被父类或子类实现过。如果是,需要针对每个实现分别进行下列步骤。
  2. 声明一个新函数,名称与原函数相同,只是加上新添参数。将旧函数的代码复制到新函数中。
  3. 编译。
  4. 修改旧函数,让它掉用新函数。
  5. 编译,测试。
  6. 找出旧函数所有被引用点,将它们全部修改为对新函数的引用。每次修改后,编译并测试。
  7. 删除旧函数。

如果旧函数是该类public接口的一部分,你可能无法安全地删除它。这种情况下,将它保留在原地,并将它标示为deprecated。

  1. 编译,测试。

5.2 Remove Parameter(移除参数)

函数本体不再需要某个参数,将该参数去除

Remove Parameter

动机

程序员可能经常添加参数,确往往不愿意去掉它们。因为多余的参数不会引起任何问题,而且以后还可能用上它。
但是对于多态函数,情况有所不同。可能多态函数的另一个实现会使用这个参数,此时你就不能去除它。

做法

  1. 检查函数是否被父类或子类实现过。如果是,需要针对每个实现分别进行下列步骤。
  2. 声明一个新函数,名称与原函数相同,只是去除不必要的参数。将旧函数的代码复制到新函数中。
  3. 编译。
  4. 修改旧函数,让它调用新函数。
  5. 编译,测试。
  6. 找出旧函数所有被引用点,将它们全部修改为对新函数的引用。每次修改后,编译并测试。
  7. 删除旧函数。

如果旧函数是该类public接口的一部分,你可能无法安全地删除它。这种情况下,将它保留在原地,并将它标示为deprecated。

  1. 编译,测试。

5.2 Parameterize Method(令函数携带参数)

若干函数做了类似的工作,但在函数名称中却包含了不同的值。建立单一函数,以参数表达那些不同的值

Parameterize Method

动机

你可能会发现两个函数,它们做着类似的工作,但因少数几个值致使行为略有不同。

做法

  1. 新建一个带参数的函数,使它可以替换先前所有的重复性函数。
  2. 编译。
  3. 将调用旧函数的代码改为调用新函数。
  4. 编译,测试。
  5. 对所有旧函数重复上述步骤,每次替换后,修改并测试。
  6. 删除旧函数。

如果旧函数是该类public接口的一部分,你可能无法安全地删除它。这种情况下,将它保留在原地,并将它标示为deprecated。

  1. 编译,测试。

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)要清楚得多。

做法

  1. 针对参数的每一种可能值,新建一个明确函数。
  2. 修改条件表达式的每个分支,使其调用合适的新函数。
  3. 修改每个分支后,编译并测试。
  4. 修改原函数的每一个被调用点,让它们调用合适的新函数。
  5. 编译,测试。
  6. 所有调用端都修改完毕后,删除原函数。

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)

动机

如果函数可以通过其它途径获得参数值,那么它就不应该通过参数取得该值。

做法

  1. 如果有必要,使用Extract Method将参数的计算过程提炼到一个独立函数中。
  2. 将函数本地内引用该参数的地方改为调用新建的函数。
  3. 每次替换后,编译并测试。
  4. 全部替换完成后,使用Remove Parameter将该参数去掉。

5.2 Introduce Parameter Object(引入参数对象)

某些参数总是很自然地同时出现

Introduce Parameter Object

动机

你常会看到特定的一组参数总是一起被传递,可以运用一个对象包装所有这些数据。
当你把这些参数组织到一起后,往往很快可以发现一些行为可被移至新建的类中。

做法

  1. 新建一个类,用以表现你想替换的一组参数。
  2. 编译。
  3. 针对使用该组参数的所有函数,使用Add Parameter,传入新建类的实例对象,并将此参数值设置为null。
  4. 对于这些参数中的每一项,从函数名中移除掉,并修改调用端和函数本体,让它们都通过新的参数对象取得该值。
  5. 每去除一个参数,编译并测试。
  6. 将原先的参数全部去掉之后,观察有无适当的函数可以运用Move Method搬移到参数对象之中。

被搬移的可以是整个函数,也可以是某个函数的部分代码。如果是后者,需要先用Extract Method将该部分代码提炼到一个新的函数中。

5.2 Hide Method(隐藏函数)

有一个函数,从来没有被其他任何类用到,将这个函数修改为private

image.png

动机

一个类,暴露给其他类的接口越多,越容易产生耦合。在一个函数确实需要被其他类访问之前,应该让它对其他类不可见。

做法

  1. 经常检查有没有可能降低某个函数的可见度。
  2. 尽可能降低所有函数的可见度。
  3. 每完成一组函数的隐藏之后,编译并测试。

5.2 Replace Constructor With Factory Method(以工厂函数取代构造函数)

你希望在创建对象时不仅仅是简单的构建动作,将构造函数替换为工厂函数
重构前:

    Employee(type: Int) {
        self.type = type
    }

重构后:

    static func create(type: Int) -> Employee {
        return Employee(type: type)
    }

动机

最明显的动机就是,在派生子类的过程中以工厂函数取代类型码。

做法

  1. 新建一个工厂函数,让它调用现有的构造函数。
  2. 将调用构造函数的代码改为调用工厂函数。
  3. 每次替换后,编译并测试。
  4. 将构造函数声明为private。
  5. 编译。

5.2 Pull Up Field(字段上移)

两个子类拥有相同的字段,将该字段移至父类

Pull Up Field

动机

如果子类是分别开发的,或者是在重构过程中组合起来的,你常会发现它们拥有重复性,特别是字段更容易重复。如果它们重复了,你就可以将它们归纳到父类中去。

做法

  1. 针对待提升的字段,检查它们的所有被使用点,确认它们以同样的方式被使用。
  2. 如果这些字段的名称不同,先将它们改名,使每一个名字都和你想为父类字段取的名字相同。
  3. 在父类中新建一个字段,并移除子类中的字段。
    4.编译,测试。

5.2 Pull Up Method(函数上移)

有些函数,在各个子类中产生完全相同的结果。将该函数移至超类中。

Pull Up Method

动机

如果函数在各个子类中的函数体都相同,这就是最显而易见的适用场合。
Pull Up Mehod常常紧随其它重构而被使用。也许你能找出若干个身处不同子类中的函数,而它们又可以通过其它重构手法调整成为相同的函数。这时候,你就可以先调整函数,然后再将它们上移到父类中去。当然,如果你足够自信,也可以一次完成这两个步骤。

做法

  1. 检查待上移的函数,确定它们是完全一致的。
  2. 如果待上移的函数的名字不同,先使用Rename Method将它们都修改为你想要在父类中使用的名字。
  3. 在父类中新建一个函数,将某一个待上移函数的代码复制到其中,做适当的调整,然后编译。
  4. 移除一个待上移的子类函数, 编译,测试。
  5. 逐一移除待上移的子类函数,直到只剩下父类中的函数为止。每次移除之后都需要测试。
  6. 观察该函数的调用者,看看是否可以改为使用父类类型的对象。

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)
    }
    ...
}

动机

如果你看到各个子类中的函数有共同行为,第一个念头就是将共同行为提炼到一个独立函数中,然后将这个函数上移到父类。对于构造函数而言,它们彼此的共同行为往往就是“对象的构建”。

做法

  1. 在父类中定义一个构造函数。
  2. 将子类构造函数中的共同代码搬移到父类构造函数中。
  3. 将子类构造函数中的共同代码删掉,改成调用新建的父类构造函数。
  4. 编译,测试。

5.2 Pull Down Method(函数下移)

父类中的某个函数只与部分子类有关,将这个函数移到相关子类去

Pull Down Method

动机

Pull Down Method与Pull Up Method刚好相反。当我们有必要把某些行为从父类移至子类时,就使用Pull Down Method。使用Extract SubClass之后你可能会需要它。

做法

  1. 在所有子类中声明该函数,将父类中的函数本体复制到每一个子类函数中。
  2. 删除父类中的函数。
  3. 编译,测试。
  4. 将该函数从所有不需要它的那些子类中删掉。
  5. 编译,测试。

5.2 Pull Down Field(字段下移)

父类中的某个字段只被部分子类用到,将这个字段移到需要它的那些子类中去

Pull Down Field

动机

与Pull Up Field刚好相反。如果只有部分子类需要父类中的一个字段,可以使用本项重构。

做法

  1. 在所有子类中声明该字段。
  2. 将该字段从父类中移除。
  3. 编译,测试。
  4. 将该字段从所有不需要它的那些子类中删掉。
  5. 编译,测试。

5.2 Extract Subclass(提炼子类)

类中的某些特性只被某些实例用到,新建一个子类,将那一部分特性移到子类中

Extract Subclass

动机

与Pull Up Field刚好相反。如果只有部分子类需要父类中的一个字段,可以使用本项重构。

做法

  1. 在所有子类中声明该字段。
  2. 将该字段从父类中移除。
  3. 编译,测试。
  4. 将该字段从所有不需要它的那些子类中删掉。
  5. 编译,测试。

5.2 Extract Superclass(提炼父类)

两个类有相似的特性。为两个类建立一个父类,将相同特性移至父类。

Extract Superclass

动机

重复代码是系统中最糟糕的东西之一。如果你在不同的地方做同一件事情,一旦需要修改那些动作,你就需要修改每一份代码。
如果两个类以相同的方式做类似的事情,你就需要使用继承机制来去除这些重复。

做法

  1. 为原本的类创建一个空白父类。
  2. 运用Pull Up Filed、Pull Up Method、 Pull Up Constructor Body逐一将子类中的共同元素上移到父类。
  3. 每次上移后,编译并测试。
  4. 检查留在子类中的函数,看它们是否还有共通成分。如果有,可以使用Extract Method将共同部分提炼出来,然后使用Pull Up Method将提炼出的函数上移到父类中。
  5. 将所有共通元素上移到父类后,检查子类的所有用户。如果它们只使用了共同接口,你就可以把调用它们的对象类型改为父类类型。

5.2 Replace Inheritance with Delegation(以委托代替继承)

在子类中新建一个字段持有父类的对象;调整子类函数,令它改而委托父类;然后去掉两者的继承关系

Replace Inheritance with Delegation

动机

设计模式中讲到,优先使用组合而不是继承。
但是你常常会遇到这样的情况:一开始继承了一个类,随后发现父类中的许多操作并不真正适用于子类。这是你可以使用组合来代替继承。

做法

  1. 在子类中新建一个字段,使其引用父类的一个对象,并将它初始化为self。
  2. 修改子类中的所有函数,让它们不再使用父类,转而使用上面新加的那个字段。
  3. 去除两个类之间的继承关系,新建一个受托类的对象赋给受托字段。
  4. 对于父类中的每一个函数,在子类中添加一个简单的委托函数。
  5. 编译,测试。

5.2 Extract Class(提炼类)

某个类做了应该由两个类做的事。建一个新类,将相关字段和和函数搬移到新类。

Extract Class

动机

单一职责原则说到,一个类应该只有一个职责。
如果一个类职责过于多,过于复杂,就应该把它们分离到一个单独的类中。

做法

  1. 决定如果分解类所负的责任。
  2. 建立一个新类,用以表现从旧类中分离出的职责。
  3. 建立从旧类访问新类的连接关系。(大部分情况是让旧类持有新类类型的变量)
  4. 对于每一个你想搬移的字段,使用Move Filed进行搬移。
  5. 每次搬移后编译,测试。
  6. 使用Move Method将必要函数搬移至新类。先搬较低层的函数,后搬较高层的函数。
  7. 每次搬移后编译,测试。
  8. 检查,精简每个类的接口。
  9. 决定是否公开新类。如果你确实需要公开它,就需要决定让它成为引用类型还是值类型。

六、重构示例(心动日常查看距离功能重构)

心动日常iOS的查看距离会根据用户所处的位置使用合适的地图,如果双方都在国内使用高德地图,如果有一方在国外使用苹果地图。
旧版本双方都有位置信息时才会展示地图,新版本接到新的需求:默认展示地图,只要一方有位置信息在地图上对应的位置展示人物头像,人物头像上增加用户位置权限标识。

接到需求后,我先熟悉代码。经过一段时间的熟悉,我发现了一些问题:
1.控制器类持有MAMapView和YLAppleMapView类的对象,他们的功能类似并且是互斥的,可以抽象出相同的接口。
2.一些实例变量和函数很明显应该封装到地图类内部,而不是放在ViewController类里。
3.需要调用对应地图类对象时,都要使用if语句来判断

因为这些问题,如果我要修改现有代码,我必须要修改对应的高德地图和苹果的图部分的代码。但是因为他们所在函数的函数名和具体的业务逻辑可能很不一致,找起来可能会很麻烦。
如果我要往地图上添加头像,需要判断当前是什么地图,并分别调用不同的方法。想想就很头大,于是我决定重构。

重构前后代码结构对比:

UML图-重构前
UML图-重构后 ViewController成员-重构前
ViewController成员-重构后 地图相关的成员变量-重构前
地图相关的成员变量-重构后

具体重构过程通过git代码演示。

上一篇下一篇

猜你喜欢

热点阅读