重構-把代碼寫的更漂亮

語言: CN / TW / HK

政採雲技術團隊.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 等眾多優秀開源社區的貢獻者。如果你想改變一直被事折騰,希望開始折騰事;如果你想改變一直被告誡需要多些想法,卻無從破局;如果你想改變你有能力去做成那個結果,卻不需要你;如果你想改變你想做成的事需要一個團隊去支撐,但沒你帶人的位置;如果你想改變本來悟性不錯,但總是有那一層窗户紙的模糊……如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望參與到隨着業務騰飛的過程,親手推動一個有着深入的業務理解、完善的技術體系、技術創造價值、影響力外溢的技術團隊的成長過程,我覺得我們該聊聊。任何時間,等着你寫點什麼,發給 [email protected]

微信公眾號

文章同步發佈,政採雲技術團隊公眾號,歡迎關注

政採雲技術團隊.png