軟體開發的秘密:讓你不被Bug困擾的5種技巧
開發軟體的過程中,常遇到Bug非常多、維護成本非常高的專案,不但要加班除錯,甚至專案進度也越來越差,今天分享5種實用技巧,希望能幫助大家降低專案裡的Bug。
5種技巧分別如下:
- 單元測試 (Unit Test)
- 快速失敗 (Fail Fast)
- 封裝 (Encapsulation)
- 表明意圖 (Intention Revealing)
- 抽象程度一致 (Same Level Abstraction)
單元測試 (Unit Test)
針對專案元件的”行為”撰寫單元測試,確保重要元件的”行為”皆有被單元測試涵蓋。
Why: 因為我們想要降低Regression Error的發生,簡單來說,我們不希望修改程式後,產生Bug,當專案很大時,為了增加新的功能,會需要修改既有程式碼,此時難以確保其他模組的功能能夠正常運作,因此透過單元測試,可以確保我們修改程式碼時不會產生Regression Error。
How: 那麼如何判斷單元並且撰寫測試呢?所謂的單元指的是Unit of Behavior,而不是Method,Unit of Behavior指的是客戶端預期達到的行為,用以下範例程式碼來闡述兩者之間的不同。
針對Method進行單元測試
@Test
public void test_get_student_name() {
Student s = new Student("Zi-Xuan");
assertEquals(s.getName(), "Zi-Xuan");
}
針對Unit of Behavior進行單元測試
@Test
public void change_the_name_of_a_student() {
Student s = new Student("Zi-Xuan");
s.changeName("New Name");
assertEquals(s.getName(), "New Name");
}
上述最大的差別在於,針對Method的測試沒有情境,也沒有複雜邏輯,而針對Unit of Behavior的測試擁有情境,是客戶端實際操作Student類別時會使用的API。
為什麼要針對Unit of Behavior呢?原因很簡單,針對Method的測試會導致Test與Production Code的耦合過度高(Over Specification),重構會導致測試失敗,即便沒有改變程式行為,而針對Unit of Behavior進行測試,能降低Test與Production Code之間的耦合。
想像一下一個10萬行的專案,修改後只需要執行單元測試,就能知道有無Bug產生,不需要Edit And Pray。
Recap: 單元測試只需針對Unit of Behavior進行測試,不是針對Class或是Method進行測試,單元測試能夠降低Regression Error。
快速失敗 (Fail Fast)
當程式的狀態發生錯誤時(Error State),越快讓程式丟出例外、終止越好。
Why: 當程式發生Error時,通常內部已有Bug,讓程式在發生Bug時馬上終止有利於Debug,若當程式發生Error State,卻使用Catch把Exception吃掉,雖然程式繼續運行,但實際上狀態已經錯誤,當Crash時,很難找出發生Bug的地點,因此Fail Fast讓程式碼Bug快速顯現,並且快速根除,來達軟體的強健性。
大家應該都有經驗,發生NullPointer Exception時,通常錯誤的地方都不是真正的問題點,實際上錯誤的程式碼離發生錯誤的程式碼很遠,造成除錯非常困難。
How: 當狀態發生Error時,馬上丟出Runtime Exception,客戶端不要亂Catch,讓Exception傳遞到最上層終止程式。
以下兩個範例
遵守Fail Fast
public static void main(String[] args) {
List<Integer> numbers = new ArrayList<>();
numbers.get(0);
}
沒有遵守Fail Fast
public static void main(String[] args) {
List<Integer> numbers = new ArrayList<>();
try {
numbers.get(0);
} catch(ArrayIndexOutOfBoundsException e) {
e.printStackTrace();
}
}
範例雖然小,但已經充分體現Fail Fast的精神,我們想要存取空的List,代表程式碼有Bug,沒有初始化完整的List,應該要解決沒有初始化List的根本問題,不是使用Catch吞掉錯誤訊息假裝沒看到。
大家試著想想看,假設今天有個10萬行的專案,在這專案中有100個地方違反Fail Fast,會有多難除錯,因此為了你也為了同事,不要在吞掉Exception了,Java的Checked Exception也是同樣道理,應該轉換成Runtime Exception,如下範例。
public static void main(String[] args) {
try {
File myObj = new File("filename.txt");
Scanner myReader = new Scanner(myObj);
while (myReader.hasNextLine()) {
String data = myReader.nextLine();
}
myReader.close();
} catch (FileNotFoundException e) {
throw new RuntimeException(e); // 當無法讀取檔案時,直接丟出Exception
e.printStackTrace(); // 強烈不建議,違反Fail Fast
}
}
Recap: 為了降低除錯難度,當程式發生狀態錯誤時,應該直接丟出例外,而不是使用Catch吞掉。
封裝 (Encapsulation)
透過封裝狀態與邏輯,讓客戶端只能操作物件的Public API,達到保護物件內部狀態的效果 (Protect Class Invariant)
Why:OO透過操作Public API來達到客戶端的需求,客戶端不會知道物件內的資料結構與實作細節,因此當物件實作修改時,只需修改此類別,不會影響其他模組,反之Procedure則是曝露資料結構,讓專案各處進行運算,因此當資料結構變動時,整個專案都會受到影響,難以評估修改影響的範圍,降低專案維護性。
當範圍很難評估時,我們必須建立複雜的Mental Model去評估影響的範圍,但人腦能處理的複雜度有限,因此容易遺漏某些地方導致新Bug產生,相比之下,OO讓我們關注一個類別,不需要建立複雜的Mental Model去評估影響的範圍,因為只有一個地方(Local Consequence Principle)。這是物件導向最大的優勢。
How: 功能的達成應透過傳遞Message給物件,請物件幫我們做事情,而不是直接存取物件內部資料進行運算。
以下為範例程式碼:
違反封裝
public double calculateArea(Shape shape) {
if (shape instance of Circle) {
Circle c = (Circle)shape;
double area = c.radius * c.radius * 3.13;
return area;
} else if (shape instance of Rectangle) {
Rectangle r = (Rectangle)shape;
double area = r.height * r.width;
return area;
}
}
遵守封裝
public void calculateArea(Shape shape) {
return shape.getArea();
}
從上述我們可以發現,破壞封裝的程式碼,透過if else判斷所有物件的型態,並且抓取內部的資料計算面積,而遵守封裝的程式碼,僅僅一行就能得到面積。
大家試著想想看,如果這個Shape的繼承架構有10個子類別,違反封裝的函數會有多複雜,如果又要計算周長,則又要撰寫一樣複雜的函數,如果又新增一個子類別,要修改的地方就會有計算面積的函數與周長的函數,違反DRP(Do Not Repeat Yourself),因為整個繼承架構在這兩個函數重複,反之,如果好好遵守封裝,只需要再寫一個簡單的calculatePerimeter即可,這就是封裝與多型的好處,達到擴充程式碼,不需要修改程式碼(OCP),當不需要修改程式碼時,產生Bug的可能性也會降低。
判斷物件導向程式寫得好不好的第一步,就是確認有無正確封裝行為到物件上,大家可以回去看看自己專案是否有違反封裝,透過把資料封裝在物件上來改善專案維護性。
Recap: 封裝狀態與邏輯到物件上,讓物件達到客戶端的行為,而不是客戶端存取物件內部所有資料。
表明意圖 (Intention Revealing)
程式碼應該透過宣告式的方式表明意圖
Why:可讀性,要修改程式碼,首先要能看得懂,如果看不懂就修改,容易產生Bug。
How: 透過Extract Method封裝瑣碎的步驟,表明程式的行為。
以下為範例程式碼:
違反Intention Revealing
public state void main(String[] args) {
soakInButterMilk();
coatTheChicken();
letItRest();
fry();
restThenServe();
}
遵守Intention Revealing
public state void main(String[] args) {
friedChicken();
}private static void friedChicken() {
soakInButtermilk();
coatTheChicken();
letItRest();
fry();
restThenServe();
}
上述比較應該非常清楚,違反Intention Revealing的程式碼,像政客講話,沒有任何重點,遵守Intention Revealing的程式碼,簡潔有力,不拖泥帶水。
Recap: 表明意圖,表明意圖,表明意圖,不要寫出像政客講話一樣的程式碼。
抽象化一致 Same Level Abstraction
組織專案的程式碼,讓High Level Logic旁邊都是High Level Logic,讓Low Level Logic旁邊都是Low Level Logic
Why:當物件抽象化程度不一致,High Level Logic混雜Low Level Logic時,此物件會越來越複雜,變成God Class,導致接手的工程師不知道如何修改,因此容易產生Bug。
以下引言,充分體現Same Level Abstraction的重要性
There are two ways of constructing a software design: One way is to make it so simple that there are obviously no deficiencies, and the other way is to make it so complicated that there are no obvious deficiencies. The first method is far more difficult.
How: 透過Interface定義High Level Logic,透過Abstract Class定義Low Level Logic,透過Extract Class封裝改變頻率一致Data與Logic (Same Rate of Change)。
以下為範例程式碼:
違反Same Level Abstraction
public interface WrongAbstraction {
public void calculate();
public void calculateFirst();
public void calculateSecond();
public void calculateThird();
}public class WrongAbstractionImp implements WrongAbstraction {
private int a;
private int b;
private int c;
private int d;
private double e;
private double f;
private String name;
public void calculate() {
calculateFirst();
calculateSecond();
calculateThird();
}
public void calculateFirst() {
// do something with data a, b, c
}
public void calculateSecond() {
// do something with data c, e, f
}
public void calculateThird() {
// do something with data name
}
}
遵守Same Level Abstraction
public interface GoodAbstraction {
public void calculate();
}
public class GoodAbstractionImp implements GoodAbstraction {
private final FirstCalculator firstCalculator;
private final SecondCalculator secondCalculator;
private final ThirdCalculator thirdCalculator;
public GoodAbstractionImp(FirstCalculator firstCalculator, SecondCalculator secondCalculator, ThirdCalculator thirdCalculator) {
this.firstCalculator = firstCalculator;
this.secondCalculator = secondCalculator;
this.thirdCalculator = thirdCalculator
}
public void calculate() {
this.firstCalculator.calculateFirst();
this.secondCalculator.calculateSecond();
this.thirdCalculator.calculateThird();
}
}
public class FirstCalculator {
private int a;
private int b;
private int c;
public void calculateFirst() {
// do something with data a, b, c
}
}
public class SecondCalculator {
private int d;
private double e;
private double f;
public void calculateSecond() {
// do something with data e, e, f
}
}
public class ThirdCalculator {
private String name;
public void calculateThird() {
// do something with data name
}
}
上述範例可以看到,我們使用Interface定義最High Level的介面,透過GoodAbstractionImp實作此介面,並且針對改變頻率一樣的Data與Logic抽出3個類別,並且注入這3個類別到GoodAbstractionImp裡面,此作法有以下有優點:
- High Level抽象化程度一致 (calculate)
- Low Level抽象化程度一致 (calculateFirst, calculateSecond, calculateThird)
- 相較於違反Same Level Abstraction的類別,我們需要建立複雜的Mental Model去思考如何修改,嚴格遵守Same Level Abstraction的類別讓我們能夠針對局部化進行修改,不需要消耗大量的腦力去建立複雜的Mental Model,達到Separation of Concern (關注點分離)的效果。
Recap: 透過Same Level Abstraction,讓讀者知道哪邊是High Level、哪邊是Low Level,讓修改更加容易。
結論:嚴格遵守上述五個技巧,不要忙碌的工作,卻不動腦,應該思考如何以有效的方式工作,達到最高效率化。