簡述程式碼品質與優化方向

Last updated on

2018 iT 邦幫忙鐵人賽 🔗文章補完計劃,持續優化程式碼品質-總覽基礎篇

在開發時,為了快速或是避免麻煩,可能會用簡單的代號,例如 aa 這種名稱,來暫時做為變數的名稱。這無可厚非,但是…假若在完成該功能後,未能進行程式的整理。

也許在三個月後的某一天,程式有需求變動,不管是要修改自己開發的程式,還是要維護前人遺留下來的軟體。在一番苦戰,好不容易找到要修改變動的程式區塊。

看著各種無意義的命名、複雜且混亂的程式邏輯,只能苦苦的思考,為什麼當初會這樣寫?這個變數是什麼意義?動作背後的用意?邊改寫程式,邊幹譙當初寫下這段程式的人。

程式碼的易讀性

將以前處理過的研究生專題的原始碼為例。當需要對這段程式進行維護或修改,事前需要花費額外大量的時間,用於理解程式碼所代表的意義。爾後才能開始進行修改。

為了撰寫程式的效率,我們應該盡可能讓程式碼呈現更高的意圖性與可讀性,讓後續接手的人可以更快速的定位焦點所在。

public int[,] ga(int[,,] im_1, int[,,] im2_1)
{
    int h = im_1.GetLength(1);
    int w = im_1.GetLength(0);

    int[,] k = new int[2, ggg];
    int[] k1 = new int[ggg];
    int[,] k2 = new int[3, ggg];

    for (int ie = 0; ie < ggg; ie++){
        k1[ie] = 1000;
    }

    Random innerRnd = new Random(Guid.NewGuid().GetHashCode());
    Random innerRnd1 = new Random(Guid.NewGuid().GetHashCode());

    for (int j = 0; j < ggg; j++){
        k[0, j] = (innerRnd.Next(255)) - Val01;
        k[1, j] = (innerRnd1.Next(255)) - Val02;
    }

    progressBar1.Maximum = 26;

    int iu, ju, temp, temp1, temp2, score = 0;

    while (score <= 25){
        score = score + 1;
        progressBar1.Value = score;

        for (int score1 = 0; score1 <= 15; score1++){
            int[,] k111 = k;
            Random innerRnd8 = new Random(Guid.NewGuid().GetHashCode());
            int a1 = innerRnd8.Next(ggg);
            Random innerRnd9 = new Random(Guid.NewGuid().GetHashCode());
            int a2 = innerRnd9.Next(ggg);

            double rrr = _RRR(k[0, a1], k[1, a1], im_1, im2_1);
            double rrr_1 = _RRR(k[0, a2], k[1, a2], im_1, im2_1);

            if (rrr > rrr_1){
                k[0, a1] = k[0, a2];
                k[1, a1] = k[1, a2];
                k1[a2] = Convert.ToInt32(rrr_1);
                k1[a1] = Convert.ToInt32(rrr_1);
            }
            else if (rrr <= rrr_1){
                k[0, a2] = k[0, a1];
                k[1, a2] = k[1, a1];
                k1[a1] = Convert.ToInt32(rrr);
                k1[a2] = Convert.ToInt32(rrr);
            }

            /***********************************
            //  略過部份程式碼內容
            ***********************************/

            for (iu = 0; iu < ggg; iu++)
            {
                for (ju = 1; ju < ggg; ju++)
                {
                    if (k2[2, ju - 1] > k2[2, ju])
                    {
                        temp = k2[2, ju - 1];
                        temp1 = k2[0, ju - 1];
                        temp2 = k2[1, ju - 1];

                        k2[2, ju - 1] = k2[2, ju];
                        k2[0, ju - 1] = k2[0, ju];
                        k2[1, ju - 1] = k2[1, ju];

                        k2[2, ju] = temp;
                        k2[0, ju] = temp1;
                        k2[1, ju] = temp2;
                    }
                }
            }

            int ddd1 = 0;
            for (iu = 0; iu < gggzz; iu++){
                ddd1 = ddd1 + (k2[2, iu]);
            }

            int ddd_1 = Convert.ToInt16(ddd1 / gggzz);
            dddd_2 = 0;

            for (iu = 0; iu < gggzz; iu++)
            {
                dddd_2 = dddd_2 + Math.Abs(ddd_1 - k2[2, iu]);
            }
        }

        if (Math.Sqrt(dddd_2) <= 0.005 && k2[2, 0] <= 5){
            score = 26;
            progressBar1.Value = 26;
        }
    }

    int gg = 0;
    int gg1 = 0;
    int[,] good = new int[2, ggg];

    for (int j111 = 0; j111 < gggzz; j111++){
        gg = gg + k2[0, j111];
        gg1 = gg1 + k2[1, j111];
    }

    int x_new = Convert.ToInt16(gg / gggzz);
    int y_new = Convert.ToInt16(gg1 / gggzz);

    int[,] x_y = new int[2, 1];

    x_y[0, 0] = x_new;
    x_y[1, 0] = y_new;
    return x_y;
}

不易閱讀的因素,大致上包含

  • 大量無意義的變數名
  • 區域變數與全域變數無法識別
  • 程式的邏輯與介面高耦合性
  • 函數名稱與實際動作內容有差異

程式的壞味道(Code small)與改善

造成程式碼不易閱讀的因素有很多,但最常見的就是命名行為不確實

    for (int score1 = 0; score1 <= 15; score1++){
        int[,] k111 = k;
        Random innerRnd8 = new Random(Guid.NewGuid().GetHashCode());
        int a1 = innerRnd8.Next(ggg);
        Random innerRnd9 = new Random(Guid.NewGuid().GetHashCode());
        int a2 = innerRnd9.Next(ggg);

        double rrr = _RRR(k[0, a1], k[1, a1], im_1, im2_1);
        double rrr_1 = _RRR(k[0, a2], k[1, a2], im_1, im2_1);

首先發現的是不具代表性的變數名稱 k,看到的當下,很難知道它所代表意思。只能回頭查看 k 這個物件,它儲存了什麼資訊、它有什麼用途。

回頭找到 k 的定義,這才發現它是一組用於儲存樣本資料的二維陣列,長度為 ggg 代表的取樣的數量。

private int ggg = 40;
    
public int[,] ga(int[,,] im_1, int[,,] im2_1)
{
    int[,] k = new int[2, ggg];
    ...
}

花費在 查詢無意義命名的變數的用途與資訊 的時間,就是變向的浪費寶貴的開發時間。為了提升可讀性,並避免每次看到 變數 k 就要再次確認背後的用意,所以將其更名為代表性意義的名稱。

另外,ggg 為類別的成員(Field),但在程式碼中,無法直接辨識變數的屬於區域變數、或是類別成員。實務上,當類別成員與區域變數無法區分,可能會出現變數誤用的情況,導致非預期的 BUG。

針對兩個問題,進行調整。

private int _sampleAmount = 40;
    
public int[,] ga(int[,,] im_1, int[,,] im2_1)
{
    int[,] sampleGroup = new int[2, _sampleCount];
    ...
}

再來,來探討 int _RRR(int _X, int _Y, int[, ,] im_1, int[, ,] im2_1) 幾個不易閱讀或理解問題點。

  • 難以判讀的函數名稱: 無法從函數名稱直接得知回傳物件的意義。
  • 不具代表性的函數參數名稱: 無法直接經由參數的名稱,進一步瞭解到參數的意義,可能會造成需要額外的說明文件或註解。

進一步理解 _RRR(...) 的功能,得知該方法的目的為,找出兩張影像在特定座標位置的誤差量。在知道方法的功用後,將方法名稱與參數進行調整為 CalculateImageOverlapError(...) ,以便更快速了解函數的功能與目標。

最後,再來看一再重複出現,但功能相同的程式碼。

    Random innerRnd8 = new Random(Guid.NewGuid().GetHashCode());
    int a1 = innerRnd8.Next(ggg);
    Random innerRnd9 = new Random(Guid.NewGuid().GetHashCode());
    int a2 = innerRnd9.Next(ggg);

仔細觀察背後的含義,就只是為了取到特定範圍內的亂數種子,所以可以將重複的部份抽取為一個方法 GetRnadomSeed(int maxRange),以達到重複使用的結果。

上述這些將變數與方法進行更名,方法的抽取,都是重構的一種行為。重構 這件事,其實沒有那麼困難,甚至可以說,重構這個行為本身就融入在開發的過程中。

只要在不影響外部行為的前提下,修改內部的程式結構,讓其更加容易閱讀、維護與變更。所以只要符合上面提到的原則,都可以稱之為重構。

工欲善其事、必先利其器

但是重構的作業,有時步驟很瑣碎或煩瑣,但這些問題,許多的 IDE 已內建方便的功能,讓開發者可以快速的完成重構,或是快速定位要修改的位置。

就算這樣,也是很難想像差異。用情境故事或許更容易可以理解差異。

想像一下,一早帶著愉快的心情走進辦公室,才剛坐下來,椅子還沒有坐熱,就突然收到臨時告知,要增加一個新的功能項目,而且後天早上的就要 DEMO。

幹譙歸幹譙,還是認命的去趕工。

善用工具的開發者

小偉是一個很怕麻煩,而且厭倦高重覆性且沒效率的做事方式,所以他熟記開發環境中常用的快捷鍵好用的功能 ,並在使用的 IDE 中,安裝了許多便利的 輔助工具

小偉接受任務後,快速的評估了一下該項目的變動範圍,構想一下如何實作,開始撰寫程式碼後,除非是必需使用滑鼠的地方,否則小偉的手幾乎沒有離開鍵盤。

開發過程中,大量使用 IDE 的快捷鍵與輔助工具提供的功能。同時,針對程式內重要的區塊,撰寫對應的單元測試,當天下班前,就將功能雛形實作的差不多了。

隔天,小偉將匆忙完成的程式,進行最後的校調與測試。確定功能無誤後,將程式碼上版控。隨後,使用輔助工具,對程式碼進行快速的重構與整理,完成後,再上版控。

此時,午休鈴剛剛好響了起來,小偉伸了伸腰,心想又完成了一個任務。

初級開發者

小刀是一個很認真的人,但是從來沒有想過要去使用快捷鍵,只使用原生的 IDE 功能,未安裝任何的輔助工具。

同樣的情境,小刀接受任務後,快速的評估了一下該項目的變動範圍,構想一下如何實作,開始撰寫程式碼後,所有需要使用IDE的功能,都使用滑鼠去點擊。

相對的,開發的過程中,就看小刀的手不停的在鍵盤與滑鼠間移動,到了下班時間,發現進度只到一半,決定多留下來多寫一兩個小時。

隔天,小刀持續全心全力的趕工中,到了下班時間,發現還差一點,只好再留下來加班。到晚上八點,好不容易將項目完成,小刀拖著疲累的身體回家的路上,只想好好休息。


對使用的 IDE 有多熟、準備的工具有多少,完全可以反應在開發的效率與結果。

不要以為上面的情境不可能發生,上面的例子都是小弟親自遇過的案例。做 SOHO 的期間,在拜訪客戶時,還曾經看過用記事本來寫韌體的強者(小弟大概一輩子都做不到)。這個世界很大,什麼事都可能發生。

如果看倌有機會看到高手寫程式,就只會看到他們的螢幕畫面切來切去,利用輔助工具來自動補齊或產生特定的程式碼區塊,撰寫速度跟飛的一樣。

網路上,也有很多熱心的朋友,會推薦或開發好用的輔助工具,都可以去試用看看,找到屬於自己的秘寶。懂得使用工具,才會減輕工作量,才有機會持續精進自己。

隱藏在原始碼的小幫手~註解

開發者不可能永遠都是寫新的專案,一定會遇到舊程式的維護或調整。在接手前人的程式時,有可能會發生以下幾種情況,

  • 基本上不存在 IDE 自行產生的註解以外的註解。
  • 滿滿的註解起來的程式碼,但是不確定這些註解有沒有用途。
  • 完全不明白意思的註解。

軟體開發過程中,為了修改程式邏輯,經常會出現將原本的程式碼區段註解,避免之後還有派上用場旳時間。但是在完成開發後,整理程式碼時,應該清除將這些暫時註解掉的程式碼。

若程式碼己經具有高閱讀性,某方面而言,是可以替代一定程度的註解。但是註解可沒有那麼單純。註解中,有時候,會特別記錄重要的資訊,而這些資訊,正好是程式碼本身無法表示出來的。

像是特殊的業務邏輯、臨時止血用的短暫解法,因為特定因素所採用特定的做法,請務必特別註明,讓後面的人知道,避免在維護程式時,將功能改壞。為了未來的自己,或是後續接手的人,請養成註解說明的習慣。

Clean Code 一書中,關於註解的章節中,提到好的程式碼,應該程式碼本身就是最好的註解,雖然無法避免使用註解進行說明,但應該該竭盡所能,讓註解減少到最低。對於這個說法,很多人抱持的不同看法,算是滿有爭議的說法。但也很值得我們去思考的這個問題。

另外要注意的是註解干擾,例如無用的註解無法解讀的註解,這部份務必上版或釋出前,請刪除無意義或無用的註解。

public int StatisticCostAmount(...)
{
    ...
    // 做法一
    // .....
    // .....

    // 解法二
    foreach(var item in items)
    {
        ...
       
           
        // 2017/5/12 修改
        ...        
    }
    // Console.WriteLine($"total={total}")
}
  • 無用的註解

    開發的過程中,有時為了 DEBUG,可能會在程式碼中,插入除錯用的程式碼。

    但解決完問題後,被 comment out 的區塊卻保留下來,如上面範例中出現的 Console.WriteLine(...)`,基本上,在完成除錯後,應該要刪除這些有特定用途的程式碼。

  • 食之無味、棄之可惜的註解

    再者,常見發生在使用檔案壓縮做版控時,因為特定因素,改寫原本正常運行的的程式寫法。同時,為方便後續回頭來查詢原本寫法,所以將本來的寫法保留下來。

    例如範例中的作法一 的註解區塊。事實上,如果修改後的程式沒有發生任何問題,回來查看被註解掉的程式碼機會,基本上無限趨近於 0。

    其實,若有養成使用版控軟體的版控習慣,例如 Git。因為原本寫法已保留在之前的記錄中,所以建議直接移除,保持程式碼的清潔,以提升閱讀性。若是一定要保留在程式碼中,應該針對註解的部份,說明保留下來的原因。

  • 無法解讀的註解

    看到 // 2017/5/12 修改 這句註解,應該是滿頭霧水,註解的用意是?

    • 單純表示 2017/5/12 修改的?

      如果只是單純註明這段程式碼修改的日期,那這個註解本身是沒有任何意義的,應該盡可能避免。

    • 告知協同開發的伙伴說明程式碼己經修改?

      一般而言,如果需要協同開發程式,表示軟體有一定的規模。請愛用版本控制軟體,版控軟體可以更有效的比對出異動的程式碼。

    • 修改程式碼是因為需求變動?

      如果是因為臨時性的需求變動,特別標註修改日期,那麼應該連帶說明修改的原因。以確保其他人看到這段程式碼時,第一時間可以明白修改的原因。

回顧

持續優化碼的第一步,就是自我要求程式碼具有可有高閱讀性。這件事,一開始一定是困難的。因為這己經跟你原本的工作習慣有所差異。至少要花上三個月左右來磨合,工作上才會比較順手。

  • 撰寫新功能

    當我們現在撰寫新的功能時,不管是在宣告變數函數名稱類別名稱等等,都需要事先設想它的功能用途。然後,取一個符合該功能用途的名稱。

  • 維護現有程式

    當 Legacy Code 需要維護修改時,假若程式閱讀性不佳。只要針對要修改部份的程式區段,進行最基本的重構—改名,讓經手過的程式,變成具有閱讀性的好程式碼。不管是要持續負責維護的自己,還是未來接手的同事,都是好事。

  • 適時重構程式碼

    寫程式就像是打造一件藝術品,一定要經過不斷的雕琢後,才能散發出他內在的光芒。沒有人可以一開始就寫出完美而美麗的程式碼,必定隨著開發過程中,不斷的重構,才能慢慢的產生高閱讀性高維護性高修改性的程式碼。

  • 重構的保護傘—單元測試

    很多人之所以不重構,主要的原因之一,就是擔心在執行軟體功能的優化或重構時,會將功能改壞,造成多做多錯、不做不錯。為避免這情況,可使用像 單元測試 等方法,確保動作的正常。

  • 重覆使用程式碼

    很多工程師,為了快速達到開發的目標,可能會將相同的程式碼 Copy/Paste 到多個地方。但是,萬一這個部份的程式碼要修改,很容易發生…以為全部的程式碼都有改到,結果偏偏漏改一個地方。善用開發使用程式語言特性,盡可能程式區塊被重複使用。以達到高維護性高修改性

作法分享

  • 資料來源商業邏輯使用者介面切割。
  • 相同職責的函數,全部封裝於同一個類別之中。
  • 開發功能中,以實現功能為最優先事項。完成一個函數功能後,立即確認是否有要重構的部份。
  • 相近的功能,評估是否可以合併、重構為獨立的函數。盡可能的達到重覆使用程式碼的目標。
  • 針對高使用性高重要性的的函數功能,撰寫對應的單元測試
  • 完成程式開發後,再 Code review

延伸閱讀

▶ 註解

  1. 『用註解補足程式碼易讀性?』 — 論註解的是與非 🔗
  2. 技術翻譯 - 編寫程式註解的最佳實踐 🔗

▶ Refactor

  1. 大話重構 🔗

Series
持續優化程式碼品質