簡述程式碼品質與優化方向
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。
延伸閱讀
▶ 註解
▶ Refactor