[打造可維護軟體] Part I
筆者曾說過:「如果軟體工程師只能讀一本書,那你一定要看Uncle Bob的Clean Code。」然而,Clean Code一本那麼厚,要看到何時才看得完?
此時,Joost Visser的「打造可維護軟體」是一本不錯的選擇。
我不是說此書是替代品,相對地,我其實特別喜歡它的輕巧簡單。書中清楚地舉出10項法則,並且解釋動機與實現技巧,讓程式設計師follow。
最特別的是,作者還在每個法則後面,列出「當別人這麼質疑這個做法時,你可以怎樣反駁」,這是我最喜歡的部分!
以下我將分成數篇來分享本書,並附上我自己寫的簡易範例。這些範例當然不比書上詳盡與完整,畢竟是自己寫的嘛...XD,但可以供讀者簡單參考,而對該法則本身有初步的了解。
撰寫簡短的程式碼單元
你一定有這種經驗:打開一段程式,總長度有300多行,花了很多時間,總算搞清楚他的脈絡,找到自己應該要加邏輯的地方,加上一兩行判斷式或行為,送出,關起來。
是否有既視感了?不誇張,筆者曾經見過最厲害的程式,是用c寫的,總長度大約有1500行,而且是該公司最重要最核心的算帳功能,讓你光是打開就備感壓力!
重點當然不在c,重點在這個這麼重要的程式邏輯,默默長大成1500行以後,變成了一個沒有人敢改,也沒有人想改的巨大怪獸,光是看懂他的脈絡就要花上好一陣子,就甭談重構它了。
這樣子的程式,就算命名再清楚,結構再完整,都無法逃過『難以維護』的命運。
為甚麼?很簡單啊!你在780行看到了一個pointer++,忘記這個變數在做啥的,回頭一找發現他是在第250行宣告的,而你回頭讀完那附近邏輯,再回來要找剛剛讀到哪裡,就又忘記了!
因為,人的記憶是有極限的。他也許大小因人而異,但肯定是有極限的。
怎麼辦?就是要避免寫太長的程式單位。什麼叫太長?這個問題有很多答案,但一般來說,大概就是一個螢幕能塞得下的量吧!原則就是要讓讀的人『一眼看得完』。
一眼能看完的程式片段,當有了邏輯或是設計上的瑕疵,也才比較容易發現,並抓出來修改,否則,就像上文說的,設計得再漂亮也是枉然。那豈不功虧一簣,可惜了你的絕妙設計?
至於要怎麼做,一般來說有兩種手法可以選擇:『提取方法』與『方法物件』。
『提取方法』比較常見,我們來看看什麼叫做『方法物件』吧!
public void distribute(){
int numWaitings = waitingQueue.size();
while (numWaitings > 0){
List<Integer> group = new ArrayList<>();
while (numWaitings > 0 && group.size() <= MAX_GROUP_SIZE) {
int customer = waitingQueue.poll();
group.add(customer);
numWaitings--;
}
establishedGroups.add(group);
System.out.println("Group established: size = " + group.size());
}
System.out.println("Established groups: " + establishedGroups.size());
}
上例是一個簡單的分組程式片段,從一個排隊人龍中,每6人一組,依序分組至分完為止。
邏輯不算複雜,不過還是得稍微停下來想一下才知道在做啥。另外,方法長度還是有點長,有沒有解決的方法?
有的,改成這樣如何:
public void distribute(){
int numWaitings = waitingQueue.size();
while (numWaitings > 0){
GroupEstablisher groupEstablisher = new GroupEstablisher(numWaitings, waitingQueue);
List<Integer> group = groupEstablisher.getGroup();
numWaitings = groupEstablisher.getNumWaitings();
establishedGroups.add(group);
System.out.println("Group established: size = " + group.size());
}
System.out.println("Established groups: " + establishedGroups.size());
}
把內層迴圈替換成「物件操作」,新物件內再實作原邏輯:
private class GroupEstablisher {
// Skip lines...
public GroupEstablisher(int numWaitings, Queue<Integer> waitingQueue) {
// Skip lines...
invoke();
}
public void invoke() {
while (numWaitings > 0 && group.size() <= MAX_GROUP_SIZE) {
int customer = waitingQueue.poll();
group.add(customer);
numWaitings--;
}
} // Skip lines...
}
我們把分組再拆成兩層,其中第二層邏輯抽出去給另一個class去做,留在原類別的邏輯就簡單多了。
當提取方法不方便使用時,不妨試試方法物件!
撰寫簡單的程式碼單元
有了『簡短』的指導原則以後,下一個法則就是讓他變『簡單』。
這裡的簡單,指的是『邏輯簡單直覺』。為甚麼我們要寫簡單直覺的程式碼?因為
- 難懂的程式碼容易被改錯,一旦發生錯誤,也比較難找出問題。
- 邏輯分岐點多,測試案例也要準備得多,提高了測試的難度。
一般拿來判斷簡單程度的基準,就是『邏輯分岐點』。再說明白點,就是『有多少if-else,以及多少switch-case』。
關於這一點,Uncle Bob也是讚同的,甚至要求更嚴格地在他的Clean Code一書中直接表明:『我認為任何的switch-case,都是影響閱讀的。』
而在一般軟體工程中,光是if-else也被直接定義為壞味道,是應該要盡可能避免的。
解決此問題的方法倒是依狀況而不同,這裡我示範的是我自己最常見的狀況,以及解決方法。
例如,我有一個小工具,能回傳各國國旗有幾種顏色:
public List<Color> getFlagColors(String nationality) {
List<Color> result;
switch (nationality) {
case "Dutch":
result = Arrays.asList(RED, WHITE, BLUE);
break;
case "German":
result = Arrays.asList(BLACK, RED, YELLOW);
break;
case "Belgian":
result = Arrays.asList(BLACK, YELLOW, RED);
break;
case "French":
result = Arrays.asList(BLUE, WHITE, RED);
break;
case "Italian":
result = Arrays.asList(GREEN, WHITE, RED);
break;
case "Taiwanese":
result = Arrays.asList(RED, WHITE, BLUE);
break;
case "Unclassified":
default:
result = Arrays.asList(GRAY);
break;
}
return result;
}
看似簡單,但一旦國家超過20個,你能想像這個class會多可怕嗎?以下我用一個簡單的小手段,把這個switch-case取代掉:
static {
flags.put("Dutch", new DutchFlag());
flags.put("German", new GermanFlag());
flags.put("Belgian", new BelgianFlag());
flags.put("French", new FrenchFlag());
flags.put("Italian", new ItalianFlag());
flags.put("Taiwanese", new TaiwaneseFlag());
flags.put("Unclassified", new DefaultFlag());
}
public List<Color> getFlagColors(String nationality) {
Flag flag = flags.get(nationality);
return (flag == null ? new DefaultFlag() : flag).getColors();
}
Flag是一個Interface,他有DutchFlag, GermanFlag等數種實作。每當我要新支援一種國旗,我只要implement Flag一次即可。
這無法讓你少打字,的確,但「可讀性」與「不易犯錯」帶來的好處,是我們可以權衡的。
不撰寫重複的程式碼
很多時後,『複製貼上』真的很迷人,因為他可以快速解決問題。然而,重複的程式碼一多,你馬上面臨兩大問題:
- 萬一裡面有bug…
- 萬一需求變更,裡面有「一行」需要修改…
私以為,『複製貼上』就像大麻,吸的時候很爽,效果也很快,但:
不要不信邪。現在是21世紀,你的需求一定會變更的,除非你的產品沒人要。
一樣,「提取方法」是不錯的解法。然而,他有時會造成許多不相干的class,引用同一來源,一不小心反而增加耦和度。要注意影響。此外,在適當時機,也可考慮「提取父類方法」。
public class AnnualSalaryEmployee {
// Slip lines...
public void calculateAndSend(){
double salary = annualSalary / 12 * months;
EmailSender sender = new EmailSender();
System.out.println("sending email...");
sender.send(salary, address);
System.out.println("Done.");
}
}public class MonthlySalaryEmployee {
// Skip lines...
public void calculateAndSend(){
double salary = monthlySalary * months;
EmailSender sender = new EmailSender();
System.out.println("sending email...");
sender.send(salary, address);
System.out.println("Done.");
}
}
很明顯的重複,對不?
此時,我們可以建立一個父類Employee,再把重複程式碼提取出來放進去。此時,原來的兩種顧員都繼承父類的方法,就不用再寫一次了。
public abstract class Employee {
protected void sendEmail(double salary, String address) {
EmailSender sender = new EmailSender();
System.out.println("sending email...");
sender.send(salary, address);
System.out.println("Done.");
}
}public class AnnualSalaryEmployee extends Employee{
// Skip lines...
public void calculateAndSend(){
sendEmail(annualSalary / 12 * months, address);
}
}public class MonthlySalaryEmployee extends Employee{
// Skip lines...
public void calculateAndSend(){
sendEmail(monthlySalary * months, address);
}
}
人生苦短,一樣的code不要寫兩次。
(本文程式碼完整版,請見:https://github.com/bearhsu2/building_maintainable_software.git)