重构-把代码写的更漂亮

政采云技术团队.png

帅帅.png

为什么要重构

代码是写给人看的,不是写给机器看的

  • 做为程序员,我们一定有这样的体验,当你需要去维护祖师爷留的代码时,打开 IDEA,密密麻麻的代码扑面而来,一个方法上百行代码,捏着鼻子,耐住兴致尝试阅读代码,一会儿往东,一会儿往北,读了半天整个人晕头转向,不知所以。半天已经过去了别说找到 bug,整个代码到底什么逻辑都没有了解清楚。打开 gitlog 发现这段代码张三写过,李四写过,甚至自己也写过几行,顿时心理一万头草泥马狂奔而过,含着眼泪打开 debug 继续啃代码。
  • 上面的场景大家一定不会陌生,这就是我们平时工作的日常,这时也许我们会更加深刻的认识到《计算机程序的构造和解释》这本书提到提到的观点"代码是写给人看的,不是写给机器看的,只是顺便计算机可以执行而已。如果代码是写给机器看的,那完全可以使用汇编语言或者机器语言(二进制),直接让机器执行"

好代码的参考标准

  • 既然我们都知道了代码是写给人看的,那么我们在写代码的时候就得认真考虑怎么写出给人看的代码,写出好的代码。 那如何衡量代码是否是好的代码呢?都有那些标准呢?下面我们简单介绍一下。常见的考察代码质量问题的维度:可读、可扩展、可维护、简洁、可复用、可测试等。具体来说比如:

    • 目录设置是否合理、模块划分是否清晰、代码结构是否满足"高内聚、松耦合"。
    • 是否遵循经典的设计原则和设计思想。
    • 代码是否容易扩展,如果要添加新功能,是否容易实现。
    • 代码是否可以复用,是否可以复用已有的项目代码或类库。
    • 代码是否容易测试,单元测试是否全面覆盖了各种正常和异常的情况。
    • 代码是否易读,是否符合编码规范。

代码是如何腐烂的

  • 太好了,我知道了代码是写给人看到,所以在写代码之前我会先进行设计而后再进行编码,从这个良好的设计开始,我相信我的代码一直是好的代码,但是不要忘了上面的例子。随着时间的流逝,张三、李四还有其他很多人都会不断修改代码,于是根据原先设计所得的系统,整体结构逐渐衰弱。代码质量慢慢沉沦,编码工作从严谨的工程堕落为胡砍乱劈的随性行为。

既然代码一定会腐烂,除了摆烂,我们还能怎么做

  • 程序员圈子里有一个小笑话。小白问 leader "代码写的太烂怎么办?","能跑吗?","什么能跑?","你和代码,有一个能跑就行"。看了这么多,代码的腐烂似乎已成必然,我们除了摆烂还能做什么呢?说到这就不能不提今天的主角"重构"。

何为重构

  • 《重构:改善既有代码的设计》这本书对重构下过如下的定义,重构:对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
  • 作者认为,使用重构技术开发软件时,可以把自己的时间分配给两种截然不同的行为:添加新功能,以及重构。添加新功能时,你不应该修改既有代码,只管添加新功能。重构时你就不能再添加功能,只管改进程序结构。

重构可以解决的问题

  • 重构改进软件设计。如果没有重构,程序的设计会逐渐腐败变质。重构很像是在整理代码,你所做的就是让所有东西回到应对的位置上,经常性的重构可以帮助代码维护自己该有的形态。
  • 重构使软件更容易理解。所谓程序设计,很大程度上就是与计算机交谈:你编写代码告诉计算机做什么事,它的响应则是精确按照你的指示行动。你得及时填补"想要它做什么"和"告诉它做什么"之间的缝隙。在重构上花一点点时间,就可以让代码更好地表达自己的用途。这种编程模式的核心就是"准确说出我所要的"。
  • 重构帮助找到 bug 。通过重构可以深入理解代码的行为,并恰到好处地把新的理解反馈回去。搞清楚程序结构的同时,我也清楚了自己所做的一些假设,于是想不把 bug 揪出来都难。
  • 重构提高编程速度。良好的设计是快速开发的根本,重构可以阻止系统腐败变质,让我们得以维护程序最开始的良好设计。

重构的一个小例子

  • 讲了这么多,你是不是有点迫不及待的想要更进一步了解重构了,纸上得来终觉浅 绝知此事要躬行,下面跟随一个小例子,让我们一起进入重构的世界一探究竟吧。

  • 正式开始之前先介绍一下我们等下要一起重构的系统。

  • 业务说明 :系统根据客户租的电影,租期,计算每个顾客的消费金额和打印详单。

    • 入参:顾客, 租的影片,租期
    • 系统根据租凭时间和影片类型计算费用,影片分为 3 类,普通片,儿童片,新片。除了计算费用,还要为常客计算积分,积分根据租片是否为新片而不同。
  • V0 版本的系统:省略其他代码,重点关注 Customer 的 statement 方法

package chapter1.v0;
​
public class Customer {
private List<Rental> rentals = new ArrayList<Rental>();
​
    public String statement() {
        double totalAmount = 0;
        int frequentRenterPoints = 0;
​
        String result = "Rental Record for " + getName() + "\n";
        for (Rental each :rentals) {
            double thisAmount = 0;
            switch (each.getMovie().getPriceCode()) {
                case Movie.REGULAR:
                    thisAmount += 2;
                    if (each.getDaysRented() > 2) {
                        thisAmount += (each.getDaysRented() - 2) * 1.5;
                    }
                    break;
                case Movie.CHILDREN:
                    thisAmount += each.getDaysRented() * 3;
                    break;
                case Movie.NEW_RELEASE:
                    thisAmount += 1.5;
                    if (each.getDaysRented() > 3) {
                        thisAmount += (each.getDaysRented() - 3) * 1.5;
                    }
                    break;
                default:
                    break;
            }
​
            // add grequent renter points
            frequentRenterPoints++;
            // add bonus for a two day new release rental
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) && each.getDaysRented() > 1) {
                frequentRenterPoints++;
            }
​
            // show fingures for this rental
            result += "\t" + each.getMovie().getTitle() + "\t" + String.valueOf(thisAmount) + "\n";
            totalAmount += thisAmount;
        }
​
        // add footer lines
        result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
        result += "You earned " + String.valueOf(frequentRenterPoints) + " frequent renter points";
        return result;
    }
}
​
  • 此时系统的类图结构如下 class.png

  • 用上面提到的好代码的参考标准,我们可以分析一下 v0 版本代码的问题

    违反了职责单一,customer 的 statement,打印和计算都做了。价格和积分计算都由一个方法承担

    不容易扩展(开闭原则):新的打印方式加不进去。新的计算价格和积分的方式加不进去

  • 唯一不变的是变化,第一个变化来了,用户希望用 html 格式输出详单。很简单,使用 CV 大法把 statement 拷贝,粘贴一下,修改一下就完工了。

  • 恭喜第一个变化 OK 了,很快第二个变化来了,用户希望改变影片分类规则。这些改变会影响客户消费和常客积分点的计算。为了应对改变,程序必须修改 statement 和 htmlStatement。 随着这些小的改动越来越多,终于这个系统没有人可以维护了。这时候重构技术就该粉墨登场了。

    如果你发现自己需要为程序添加一个特性,而代码结构使你无法很方便地达成目的,那就先重构那个程序, 使特性的添加比较容易进行,然后再添加特性。

重构一: 找出系统的逻辑泥团

  • 本例就是 swith 语句,把他提炼到独立函数中(Extract Method)似乎比较好。
  • 观察这段代码,我们可以找到函数内的局部变量和参数,有两个 each,thisAmount。 其中 each 没有被修改,可以直接作为参数传入新的函数。 thisAmount 是个临时变量,每次循环开始之前设置为 0,switch 之前不会变,所以可以作为新函数的返回值。 所以我们可以把这个计算 thisAmount 的代码抽取成一个独立的函数。完成之后的代码如下
private double amountFor(Rental each) {
    double thisAmount = 0;
    switch (each.getMovie().getPriceCode()) {
        case Movie.REGULAR:
            thisAmount += 2;
            if (each.getDaysRented() > 2) {
                thisAmount += (each.getDaysRented() - 2) * 1.5;
            }
            break;
        case Movie.CHILDRENS:
            thisAmount += each.getDaysRented() * 3;
            break;
        case Movie.NEW_RELEASE:
            thisAmount += 1.5;
            if (each.getDaysRented() > 3) {
                thisAmount += (each.getDaysRented() - 3) * 1.5;
            }
            break;
        default:
            break;
    }
    return thisAmount;
}

重构二: 把代码放到合适的位置

  • 再看修改好的代码,使用了来自 Rental 的信息,但是没有使用来自 Customer 的信息。这是一个信号,"它是否放错了位置"。

    绝大多数情况下,函数应该在它使用的数据的所属对象内。

  • 使用 Move Method(搬移方法)将代码放到合适的类中,本例是 Rental 类。我们先把代码复制到 Rental 类中,调整代码使之适应新家。然后修改原函数,让它调用新的函数。

  • 回到 Customer 的 statement 方法,我们下一步要对"积分计算"部分做同样的处理。

    • 首先需要运用 Extract Method 重构手法,同样我们看一下局部变量。这里再一次用到了 each,而它可以被当作参数传入新函数中。另一个变量 frequentRenterPoints , 在使用前已经先有了初始值,但提炼出来的函数并没有读取该值,所以不需要把 它作为参数传递进去。

重构三: 消除临时变量

  • 再次回到 Customer 的 statement 方法,这次我们要开始处理临时变量。运用 Replace Temp with Query(利于查询函数来取代临时变量) 。

    • thisAmount: 使用 each.getCharge(); 直接替换。
    • totalAmount:使用 getTotalAmount()代替 totalAmount,由于 totalAmount 在循环内赋值,需要把整个循环复制到查询函数中。
    • frequentRenterPoints: 和 totalAmount 一样的处理。
  • 经过我们的调整,目前 Customer 的 statement 的方法现在是这样的。

    public String statement() {
        String result = "Rental Record for " + getName() + "\n";
        for (Rental each :rentals) {
            result += "\t" + each.getMovie().getTitle() + "\t" + String.valueOf(each.getCharge()) + "\n";
        }
        // add footer lines
        result += "Amount owed is " + String.valueOf(getTotalAmount()) + "\n";
        result += "You earned " + String.valueOf(getTotalFrequentRenterPoints()) + " frequent renter points";
        return result;
    }
​
    private int getTotalFrequentRenterPoints(){
        int totalFrequentRenterPoints = 0;
        for (Rental each :rentals) {
            totalFrequentRenterPoints +=  each.getFrequentRenterPoints();
        }
        return totalFrequentRenterPoints;
    }
    
    private double  getTotalAmount()
    {
        double totalAmount = 0;
        for (Rental each :rentals) {
            totalAmount +=  each.getCharge();
        }
        return totalAmount;
    }
  • Rental 类中的代码如下
public double getCharge() {
    double thisAmount = 0;
    switch (getMovie().getPriceCode()) {
        case Movie.REGULAR:
            thisAmount += 2;
            if (getDaysRented() > 2) {
                thisAmount += (getDaysRented() - 2) * 1.5;
            }
            break;
        case Movie.CHILDRENS:
            thisAmount += getDaysRented() * 3;
            break;
        case Movie.NEW_RELEASE:
            thisAmount += 1.5;
            if (getDaysRented() > 3) {
                thisAmount += (getDaysRented() - 3) * 1.5;
            }
            break;
        default:
            break;
    }
    return thisAmount;
}
​
public int getFrequentRenterPoints() {
      if ((getMovie().getPriceCode() == Movie.NEW_RELEASE) && getDaysRented() > 1) {
          return 2;
      }
      return 1;
}
  • 至此,我们第一版的重构已经完成,这时候我们再看看客户提出的"html 格式输出详单",其中 statement 里面的计算逻辑可以全部复用。
  • 我们可以止步当前的情况,除非需求又发生了让我们不好添加特性的时候。

再出发

  • 还记得上面的第二个变化吗?客户希望改变影片分类规则,这样会影响到消费和常客积分点的计算,面对这样的需求,我们的重构又该如何入手呢?

  • 还记得被我们搬移到 Rental 类的 switch 代码吗?它在 customer 时我们发现它的位置不对,于是我们把它搬移到了 Rental 中,再回头看一下它,再结合我们的需求变化你觉得它放在哪里更合适呢?

  • 看看 Movie,我们有数种影片类型,它们以不同的方式回答相同的问题。这听起来很像子类或策略的工作。我们可以建立 Movie 的三个子类,每个都有自己的计费法。可以使用多态来取代 switch。这确定合适吗?

    不推荐子类,一部影片可以在生命周期内修改自己的分类,一个对象却不能在生命周期内修改自己所属的类

  • 怎么样,是不是有思路了,打开 IDEA 尝试动手做一下吧。

结语

  • 通过以上的内容,希望你能了解到高质量代码的重要性,也能认识到技术人员工具箱中另外一个宝贵的工具。重构
  • 通过重构上面这个简单的例子,希望你对于"重构怎么做"有一点感觉。
  • 最最希望的是能引起你的好奇心,跟着文章最后的问题,实际上手体验一把重构,看着代码经你之手变得简单易读、职责单一、可扩展、可维护。相信那种快乐是无与伦比的。

推荐阅读

浅析大数据OLAP引擎-Presto

指标体系的设计和思考

redis 性能分享

基于gitlab ci_cd实现代码质量管理

sharding-jdbc 分享

招贤纳士

政采云技术团队(Zero),一个富有激情、创造力和执行力的团队,Base 在风景如画的杭州。团队现有 500 多名研发小伙伴,既有来自阿里、华为、网易的“老”兵,也有来自浙大、中科大、杭电等校的新人。团队在日常业务开发之外,还分别在云原生、区块链、人工智能、低代码平台、中间件、大数据、物料体系、工程平台、性能体验、可视化等领域进行技术探索和实践,推动并落地了一系列的内部技术产品,持续探索技术的新边界。此外,团队还纷纷投身社区建设,目前已经是 google flutter、scikit-learn、Apache Dubbo、Apache Rocketmq、Apache Pulsar、CNCF Dapr、Apache DolphinScheduler、alibaba Seata 等众多优秀开源社区的贡献者。如果你想改变一直被事折腾,希望开始折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊……如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的技术团队的成长过程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 zcy-tc@cai-inc.com

微信公众号

文章同步发布,政采云技术团队公众号,欢迎关注

政采云技术团队.png