重構-把程式碼寫的更漂亮

語言: 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