《重构》学习常用的重构手法 上

Posted RikkaTheWorld

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了《重构》学习常用的重构手法 上相关的知识,希望对你有一定的参考价值。

系列文章目录

1. 《重构》学习(1)拆分 statement 函数
2. 《重构》学习(2)拆分逻辑与多态使用
3. 《重构》学习(3)概述
4. 《重构》学习(4)常用的重构手法 上


我这边学习直接跳过了《重构》第3、4、5章,原因是:

  • 第三章,代码坏味道
    比较公式化的原则,仅是记录,在开发中规避,养成习惯就好了
  • 第四章, 构筑测试体系
    我所在的公司对测试体系的要求比较高,入职以来做过不少于两次的单元测试培训,这方面的思维已经比较完善了,并且日常开发中是比较看重单元测试的,粗略翻了一遍,都是已经学过的,所以我跳过了这一章
  • 第五章, 介绍重构名录
    这个结合现实,没有什么必要性,所以我也跳过了

3. 第一组重构

3.1 提炼函数

提炼函数 (Extract Method), 和 内联函数 是反义词
图片示例:

来看下面代码:

fun printOwing(invoices: Invoices) 
  printBanner()
  val outstanding = calculateOutStanding()

  print("name:$invoices.customer \\n amount:$outstanding")

提炼成:

fun printOwing() 
  printBanner()
  val outstanding = calculateOutStanding()
  printDetails(outstanding)


fun printDetails(outstanding) 
  print("name:$invoices.customer \\n amount:$outstanding")

何时将代码放到独立的函数中呢?

最合理的解答应该是:“将意图和实现分开”,如果你需要花时间浏览一段代码才能弄清楚它是干什么,
就应该把它提炼到一个函数中,并根据它所做的事为其命名

一段代码超过了6行,便逐渐开始散发臭味,所以要养成提炼函数的习惯,函数的名字长度并不重要。

命名时,根据函数的意图来对其进行命名, 用 “做什么”来命名,而不是 “怎么做”

如果编程语言支持嵌套函数,则把新函数嵌套在源函数中,可以减少中间变量。

对于变量:

  • 如果提炼后的代码访问到了源函数的变量,则把源函数的变量作为入参传进去。
  • 如果这个变量只在提炼后的函数中用到,源函数用不到,那么把这个变量的创建(声明)也放到提炼函数中去

有时候,提炼部分被赋值的局部变量太多,这时最好先放弃这种提炼,这种情况下,优先使用别的重构手法,例如:

  • 拆分变量
  • 查询取代临时变量

以简化变量使用情况,然后再考虑提炼函数

查看其他代码是否有与被提炼的代码段相同或相似之处。如果有,考虑使用以函数调用取代内联代码令其调用提炼出的新函数。

3.1.1 抽出无关局部变量的代码

来看看代码:

fun  printOwing(invoice: ExtractInvoices) 
    var outstanding = 0;
    print("***********************")
    print("**** Customer Owes ****")
    print("***********************")
    // calculate outstanding
    invoice.orders.forEach o ->
        outstanding += o.amount
    
    val today = Date();
    invoice.dueDate = Date(today.year, today.month, today.date + 30)
    print("name: $invoice.customer")
    print("amount: $outstanding")
    print("due: $invoice.dueDate.time")

这是一个用于打印单据的函数,如果想要提炼其中无关局部变量的代码,简直易如反掌:

    fun printOwing(invoice: ExtractInvoices) 
        var outstanding = 0
        printBanner()

        // calculate outstanding
        invoice.orders.forEach  o ->
            outstanding += o.amount
        
        val today = Date()
        invoice.dueDate = Date(today.year, today.month, today.date + 30)
        printDetails(invoice, outstanding)
    

    private fun printBanner() 
        print("***********************")
        print("**** Customer Owes ****")
        print("***********************")
    

    // 这里虽然和局部变量有关,但仅仅是入参,读取之,而不修改之
    private fun printDetails(invoice: ExtractInvoices, outstanding: Int) 
        print("name: $invoice.customer")
        print("amount: $outstanding")
        print("due: $invoice.dueDate.time")
    

3.1.2 有局部变量的提炼

就如上面的 printDetails() 最简单的提炼就是:我的方法只需读取它,并不用修改它们。

如果局部变量是一个数据结构,而源函数又修改了这个结构中的数据,也要如法炮制,将 “设置日期” 这一逻辑同样提炼出来,如下所示:

    fun printOwing(invoice: ExtractInvoices) 
        var outstanding = 0
        printBanner()

        // calculate outstanding
        invoice.orders.forEach  o ->
            outstanding += o.amount
        
        recordDueData(invoice)
        printDetails(invoice, outstanding)
    
     ...
    private fun recordDueData(invoices: ExtractInvoices) 
        val today = Date()
        invoices.dueDate = Date(today.year, today.month, today.date + 30)
    

3.1.3 对局部变量再赋值

如果上面代码中, 如果 printOwing() 对局部变量再进行赋值,问题就变得复杂了。(也就是可变变量)

被赋值的临时变量也分两种情况:

  • 比较简单的情况:这个变量只在被提炼代码中使用,如果是这样,可以将这个临时变量的声明移动到被提炼的代码段中,然后一起提炼出去
  • 比较复杂的情况:提炼函数之外的代码也是用了这个变量,这样我们就不能把这个变量放到提炼函数中去了。此时我们需要返回修改后的值

如上面代码中的 outstanding 变量,它被声明后,又通过 forEach 循环,进行了累加,变化了值,所以我们不能把其声明放到某个提炼函数中去,所以这个时候需要提炼其修改的函数:

    fun printOwing(invoice: ExtractInvoices) 
        printBanner()
        
        val outstanding = calculateOutstanding(invoice)
        
        recordDueData(invoice)
        printDetails(invoice, outstanding)
    

    private fun calculateOutstanding(invoice: ExtractInvoices): Int 
        return invoice.orders.fold(0)  acc, extractOrder ->
            acc + extractOrder.amount
        
    

这里提炼了 calculateOutstanding 函数,将计算放在这个函数中,并且把源函数的 outstanding 改成了 val 不可变变量,这样让函数更加的可控。

如果这个函数计算的不止一个值,而是好几个,如何返回多个值呢? 这里的做法有:

  • 像 Kotlin,可以使用像 PairTriple 这样的数据结构
  • 可以使用小对象
  • 比起上面这一步更好的办法,是回头重新处理局部变量,使用 查询取代临时变量 或 拆分变量

3.2 内联函数(Inline Function)

和 提炼函数 是反义词

    fun getRating(driver: InlineDriver): Int 
        return if (moreThanFiveLateDeliveries(driver)) 2 else 1
    

    private fun moreThanFiveLateDeliveries(driver: InlineDriver): Boolean 
        return driver.numberOfLateDeliveries > 5
    

这个函数应该内联成:

    fun getRating(driver: InlineDriver): Int 
        return if (driver.numberOfLateDeliveries > 5) 2 else 1
    

3.2.1 使用内联函数的场景

重构经常以简短的函数表现动作意图,这样会使代码更清晰已读,但有时候会遇到某些函数,其内部代码和函数名同样清晰易读。 如果是这样,就应该去掉这个函数,直接使用其中的代码,间接性可能带来帮助。

另外有一个使用的场景是: 我手上有一群组织及其不合理的函数,可以将他们全部内联到一个大的函数中,再用自己喜欢的方式重新提炼小函数。

原因:如果代码有太多中间层,使得系统中的所有函数都似乎只是对另外一个函数的简单委托,造成我在这些委托动作之间晕头转向,那我们通常会使用内联函数。 当然,中间层有其价值,但不是所有的中间层都有价值,通过内联手法,可以找到有用的中间层,同时将无用的中间层消除。

3.2.2 使用内联函数的步骤

  1. 检查函数,确定它不具备多态性
    如果这个 “简单委托函数” 已经属于一个类,并且有子类继承了这个函数,那么就无法内联
  2. 找出这个函数所有的调用点
  3. 将这个函数的所有调用点都替换为函数本题
  4. 每次替换之后,进行测试
    这里不必一次性完成所有代码的内联操作,如果某些调用点比较难以内联,可以等到时机成熟再来处理
  5. 删除该函数的定义

如果在使用内联的时,遇上了别的问题,那么就不该使用内联的重构手法

3.2.3 范例

来看下面代码:

    fun reportLines(customer: InlineCustomer): Map<String, String> 
        val map = mutableMapOf<String, String>()
        gatherCustomerData(map, customer)
        return map
    

    private fun gatherCustomerData(map: MutableMap<String, String>, customer: InlineCustomer) 
        map["name"] = customer.name
        map["location"] = customer.location
    

可以看到 reportLines() 这个函数的作用就是构造了一个 map并返回,其中还调用了 gatherCustomerData 来填充数据,这其实是多余的,gatherCustomerData() 给其增加了副作用,在平时开发时,我们应该多思考自己有没有写这种兜圈子的函数,如果出现了, 将其内联回去:

    fun reportLines(customer: InlineCustomer): Map<String, String> 
        return mapOf("name" to  customer.name, "location" to customer.location)
    

3.3 提炼变量

提炼变量 (Extract Variable)也叫 引入解释性变量(Introduce Explaining Variable), 反义重构是 内联变量


如下面代码

fun cal(order: ExtractVarOrder): Double 
    return order.quantity * order.itemPrice -
            (0.0).coerceAtLeast(order.quantity - 500) * order.itemPrice * 0.05 +
            (order.quantity * order.itemPrice * 0.1).coerceAtMost(100.0)

需要提炼变量 ,变成:

fun cal(order: ExtractVarOrder): Double 
    val basePrice = order.quantity * order.itemPrice
    val quantityDiscount = (0.0).coerceAtLeast(order.quantity - 500) * order.itemPrice * 0.05
    val shipping = (order.quantity * order.itemPrice * 0.1).coerceAtMost(100.0)
    return basePrice - quantityDiscount + shipping

3.3.1 使用场景

表达式有可能非常难以阅读,在这种情况下,局部变量可以帮助我们将表达式分解为比较容易管理的形式。在面对这块复杂的逻辑时,局部变量可以让我给其命名,这样命名可以更好的理解这部分逻辑想要干什么。(也就是说,提炼变量一般用于简化表达式)

使用这样的变量也便于调试,方便打印。

如果使用提炼变量,就意味着要给代码中的一个表达式命名,一旦决定要这样做,就得考虑这个名字所处的上下文。如果这个名字只在当前的函数中有意义,那么提炼变量很舒服。 如果这个变量名会和同文件或上下文中其他变量名冲突(或者有意义),那么就要考虑将其暴露出来,通常以函数的形式。

3.3.2 步骤

  1. 确定提炼的表达式没有副作用
  2. 声明一个不可修改的变量(val变量),把你想要提炼的表达式复制一份,以该表达式的结果值给这个比爱你量复制
  3. 用这个新变量取代原来表达式
  4. 测试
  5. 如果表达式出现了多次,请将新变量逐一替换

3.3.4 示例

data class ExtractRecord(val quantity: Double, val itemPrice: Double)
private class Order(private val data: ExtractRecord) 
    fun getQuantity(): Double = data.quantity
    fun getItemPrice(): Double = data.itemPrice

    fun getPrice(): Double 
        return getQuantity() * getItemPrice() -
                (0.0).coerceAtLeast(getQuantity() - 500) * getItemPrice() * 0.05 +
                (getQuantity() * getItemPrice() * 0.1).coerceAtMost(100.0)
    

我要提炼的还是同样的变量,但我注意到,这些变量名所代表的概念,适用于整个 Order 类,比如由 getQuantity() * getItemPrice()计算出来的 basePrice ,这个玩意它的含义是整个 Order 类其它地方或许也可以用到的,同理后面的表达式也是。
所以这个时候,更愿意将其提炼成方法,而不仅仅是变量了:

  ..
    fun getBasePrice(): Double = getQuantity() * getItemPrice()
    fun getQuantityDiscount(): Double = (0.0).coerceAtLeast(getQuantity() - 500) * getItemPrice() * 0.05
    fun getShipping(): Double = (getQuantity() * getItemPrice() * 0.1).coerceAtMost(100.0)

    fun getPrice(): Double 
        return getBasePrice() - getQuantityDiscount() + getShipping()
    
   ..

这是对象带来的一大好处,它们提供了合适的上下文,方便分享相关的逻辑和数据。 在如此简单的情况下,这方面的好处不太明显,但在一个更大的类当中,如果能找出可以共用的行为,应当及时赋予他独立的抽象概念,并且一个好的名字,对于使用对象的人会很有帮助。

3.4 内联变量

内联临时变量(Inline Temp),反义词是 提炼变量

例如下面代码:

    fun basePriceMoreThan1000(order: InlineVarOrder): Boolean 
        val basePrice = order.basePrice
        return basePrice > 1000
    

可以优化成:

    fun basePriceMoreThan1000(order: InlineVarOrder): Boolean 
        return order.basePrice > 1000
    

原则上,这种简单易懂的表达式,就无须再提炼变量出来了, 因为它可能妨碍了阅读。这个时候需要将其内联,消除变量。
而且现在的 IDE 也会帮我们消除这种问题

3.5 改变函数声明

也叫函数改名(Rename Function),指的是修改函数的名称增加/减少入参

3.5.1 为什么要改变

  1. 好的命名可以不让别人去查看实现代码就知道函数在做什么
  2. 参数也是同样的道理, 函数的参数列表阐述了函数如何与外部世界共处

对命名这里我就不做示例了,它比较考验程序员对函数的理解和文学的功底。

3.5.2 示例:添加参数

假如我们现在有一个 图书馆系统, Book 类代表图书,它可以接收顾客(custom)的预定(reservation),代码如下:

class Book() 
    private val reservations = mutableListOf<InlineVarCustomer>()

    fun addReservation(customer: InlineVarCustomer) 
        reservations.add(customer)
    

现在系统需要支持 “高优先级预定”,即需要给 addReservation 函数新增一个参数,表示这次预定是进入普通队列还是优先队列。 如果我们不可以直接修改这个函数的入参,我们可以采取下面的方式来解决。

步骤一:用提炼函数,把 addReservation 函数提炼出来,放进一个新函数,这个函数也叫 addReservation,因为 Java、Kotlin 支持重载,

    fun addReservation(customer: InlineVarCustomer) 
        addReservation(customer, false)
    

    fun addReservation(customer: InlineVarCustomer, isPriority: Boolean) 
        reservations.add(customer)
        ...
    

这样,我们就可以对源函数使用内联函数,将其调用者转向使用新函数,这样以后可以每次只修改一个调用者了。

3.5.3 示例:把参数改成属性

下面来处理一个更复杂的情况, 我们有一个函数,用于判断顾客(custom)是否在新英格兰地区:

    fun inNewEngland(customer: InlineVarCustomer): Boolean 
        val england = listOf("MA", "CT", "ME", "VT", "NH", "RI")
        return england.contains(customer.address.state)
    

调用函数如下:

    fun filterEnglanders(customers: List<InlineVarCustomer>) 
        val newEnglanders = customers.filter 
            inNewEngland(it)
        
    

inNewEngland 函数只用到了顾客所在的州(state)这项信息,基于这个信息来判断顾客是否在新英格兰地区。现在希望重构这个函数,使其接收州代码(state code)作为参数,这样就能去除对 custom 这个概念的依赖,使得这个函数在更多的上下文中使用。

在使用改变函数声明时,我们通常会先运用提炼函数变量,来提取我们想要的新参数:

    fun inNewEngland(customer: InlineVarCustomer): Boolean 
        val stateCode = customer.address.state
        return listOf("MA", "CT", "ME", "VT", "NH", "RI").contains(stateCode)
    

然后使用提炼函数来创建新函数:

    fun inNewEngland(customer: InlineVarCustomer): Boolean 
        val stateCode = customer.address.state
        return inNewEngland(stateCode)
    
    fun inNewEngland(stateCode: String): Boolean 
        return listOf("MA", "CT", "ME", "VT", "NH", "RI").contains(stateCode)
    

3.6 封装变量

又称 自封装字段(Self-Encapsulate Field)

例如将:

val defaultOwner = EncapsulateVarOwner(firstName = "Rikka", lastName = "xie")

调整成:

    private var defaultOwnerData =  EncapsulateVarOwner(firstName = "Rikka", lastName = "xie")
    fun getDefaultOwner(): EncapsulateVarOwner  = defaultOwnerData
    fun setDefaultOwner(owner: EncapsulateVarOwner) 
        defaultOwnerData = owner
    

3.6.1 为什么要封装变量

函数比较容易调整,因为函数只有一种用法,就是调用,在改名或者搬移函数时,我们总是可以比较容易的保留旧函数,将其作为转发函数。
而数据就比较麻烦,因为没办法设计这样的转发机制,如果把数据搬走,就必须同时修改所有引用该数据的代码,否则程序就不能运行。
如果数据的可访问范围很小,比如一个小函数内部的临时变量,那还好,但是如果可访问范围大,那么重构的难度会随之增大,这也就是说全局数据是大麻烦的原因。

所以,如果想要搬移一处被广泛使用的数据,最好的办法往往是先以函数形式封装所有对该数据的访问。这样就能把“重新组织数据”的困难转化为“重新组织函数”这个相对简单的任务。

同时,封装变量的好处还有:

  • 提供一个清晰的观测点,由此监控数据的变化和使用, 数据的作用域越大,封装就越重要

这里我的理解是:

  1. 一般看到 public 字段时,就会考虑使用封装变量,因为面向对象里强调数据应该保持数据为私有 private
  2. 封装数据很重要,但是不可变数据更重要,如果数据不能修改,就根本不需要数据更新前的验证或其他逻辑钩子。我们就可以放心的复制数据,而不用搬移原来的数据。

以上是关于《重构》学习常用的重构手法 上的主要内容,如果未能解决你的问题,请参考以下文章

重构手法之重新组织数据

入门实战资料《Android进阶解密》+《Android进阶之光》+《重构改善既有的代码第2版》电子资料学习

重构·改善既有代码的设计.04之重构手法(下)完结

重构 重构手法

重构·改善既有代码的设计.03之重构手法(上)

重构改善既有代码设计--重构手法16:Introduce Foreign Method (引入外加函数)&& 重构手法17:Introduce Local Extension (引入本