[打造可維護軟體] Part I

Yu-Song Syu
Kuma君的閱讀雜記

--

筆者曾說過:「如果軟體工程師只能讀一本書,那你一定要看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去做,留在原類別的邏輯就簡單多了。

當提取方法不方便使用時,不妨試試方法物件!

撰寫簡單的程式碼單元

有了『簡短』的指導原則以後,下一個法則就是讓他變『簡單』。

這裡的簡單,指的是『邏輯簡單直覺』。為甚麼我們要寫簡單直覺的程式碼?因為

  1. 難懂的程式碼容易被改錯,一旦發生錯誤,也比較難找出問題。
  2. 邏輯分岐點多,測試案例也要準備得多,提高了測試的難度

一般拿來判斷簡單程度的基準,就是『邏輯分岐點』。再說明白點,就是『有多少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一次即可。

這無法讓你少打字,的確,但「可讀性」與「不易犯錯」帶來的好處,是我們可以權衡的。

不撰寫重複的程式碼

很多時後,『複製貼上』真的很迷人,因為他可以快速解決問題。然而,重複的程式碼一多,你馬上面臨兩大問題:

  1. 萬一裡面有bug…
  2. 萬一需求變更,裡面有「一行」需要修改…

私以為,『複製貼上』就像大麻,吸的時候很爽,效果也很快,但:

不要不信邪。現在是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)

--

--