麻省理工18年春软件构造课程阅读09“避免调试”
本文內容來自MIT_6.031_sp18: Software Construction課程的Readings部分,采用CC BY-SA 4.0協議。
由于我們學校(哈工大)大二軟件構造課程的大部分素材取自此,也是推薦的閱讀材料之一,于是打算做一些翻譯工作,自己學習的同時也能幫到一些懶得看英文的朋友。另外,該課程的閱讀資料中有許多練習題,但是沒有標準答案,所給出的答案均為譯者所寫,有錯誤的地方還請指出。
譯者:李秋豪
審校:
V1.0 Sun Mar 25 13:32:29 CST 2018
本次課程的目標
- 如何避開調試(debugging)
- 當你不得不進行調試時,如何確保它不會太復雜
第一道防御:讓Bug無法產生
最好的防御策略就是在設計上讓Bug無法產生。
我們之前已經談到過靜態檢查 。靜態檢查能夠在編譯期發現很多bug。
我們也看到了一些動態檢查的例子。例如,Java會通過動態檢查讓數組越界訪問的bug不可能存在。如果你試著越界訪問一個數組或列表,Java就會在運行時報錯。在一些更老的語言中,例如C和C++,這樣的訪問是允許的——可能會導致bug和 安全漏洞.
不可變性也是另一種防止bug的設計策略。在創建時,一個不可變類型的對象的值就確定了,接下來可以保證不會發生改變。
字符串是一種不可變類型。你無法通過String內置的方法更改它內部存儲的字符。因此,字符串可以被安全地傳入/分享給程序的各個地方。
Java也提供了不變的索引:如果一個變量聲明時用final修飾,那么它的索引一旦確定就不能更改了。在實踐中,你應該盡可能對方法、參數、本地變量使用final。正如變量的類型一樣,final也是一種良好的文檔,它告訴了讀者這個變量索引的對象不會變為別的對象,而且這種檢查也是靜態的,由編譯器負責。
思考下面這個例子:
final char[] vowels = new char[] { 'a', 'e', 'i', 'o', 'u' };vowels 變量被聲明為final ,但是它指向的對象真的不會發生改變嗎?以下哪一個語句是不合法的(會被編譯器捕捉),哪一句又是合法的?
vowels = new char[] { 'x', 'y', 'z' }; vowels[0] = 'z';在下面的閱讀小練習中你會找打答案。一定要注意final的含義,它僅僅確保了索引的對象不會變為別的對象,而對象本身的值是可能發生更改的。
閱讀小練習
Final references, immutable objects
思考下面的代碼,它們按順序執行:
char vowel0 = 'a'; final char vowel1 = vowel0;String vowel2 = vowel1 + "eiou"; final String vowel3 = vowel2;char[] vowel4 = new char[] { vowel0, 'e', 'i', 'o', 'u' }; final char[] vowel5 = vowel4;在上面的語句執行完后,再按順序執行下面的語句,請選出合法的語句:
[x] vowel0 = 'y';
[ ] vowel1 = vowel0;
[x] vowel2 = "uoie" + vowel1;
[ ] vowel3 = vowel2;
[ ] vowel2[0] = 'x';
[ ] vowel3[0] = 'x';
[x] vowel4 = vowel5;
[ ] vowel5 = vowel4;
[x] vowel4[0] = 'x';
[x] vowel5[0] = 'z';
Afterwards
當上一個練習的合法語句全部執行完以后,各個變量的值分別是多少?
vowel0
y
vowel1
a
vowel2
uoiea
vowel3
aeiou
vowel4
zeiou
vowel5
zeiou
第二道防御:將Bug本地化
如果我們不能阻止bug產生,那么應該盡可能將它們的觸發地點集中在一小塊地方,這樣以后找bug的時候會方便許多。當bug被本地化在一個小方法或模塊的時候,我們可能只需要閱讀代碼就能發現bug。
我們之前已經討論過了快速失敗/報錯 :問題暴露的越早(或者離產生的地方越近),修復bug就會越容易。
現在看一個簡單的例子:
/*** @param x requires x >= 0* @return approximation to square root of x*/ public double sqrt(double x) { ... }假設有一個人用負數去調用了sqrt .sqrt最合理的行為應該是什么?既然調用者沒有滿足前置條件,講道理方法可以做任何事情:返回一個任意值、進入死循環、融化CPU等等。然而,我們應該盡早報告這個調用者的bug。例如,我們可以對這個前置條件做一個檢查,如果不滿足則拋出一個非檢查異常IllegalArgumentException :
/*** @param x requires x >= 0* @return approximation to square root of x*/ public double sqrt(double x) { if (! (x >= 0)) throw new IllegalArgumentException();... }檢查前置條件是防御性編程的一個例子 。程序往往都會有bug,而防御性編程減輕了bug的影響(即使你不知道bug在哪)。
斷言
在實踐中我們經常需要定義一套程式來進行這樣的防御性檢查,它們通常被稱為asser() (斷言)。
在Java中,assert是一種語句而非方法。最簡單的斷言語句會接受一個布爾表達式,如果這個表達式的值為假則拋出一個 AssertionError 。
assert x >= 0;斷言也是一種很好的文檔,它強制規定了特定時候程序應有的狀態,例如 assert x >= 0 就是在說“在這行代碼執行時,x不能是負數”。不過和注釋文檔不同,斷言是可執行的,它會在運行的時候進行檢查。
Java的段嚴重也可以包含一個描述語句,通常是字符串,也可以原始數據類型或者對象索引。在斷言失敗時,描述性的消息會打印出來,因此程序員可以根據描述語句進行跟蹤調試。描述語句跟在布爾表達式后面,用冒號隔開,例如:
assert x >= 0 : "x is " + x;如果x為-1,這個斷言就會失敗并打印:
x is -1
以及此時的棧幀情況(告訴你斷言的位置和函數調用情況)。這些信息通常以及足夠用來排除bug了。
一個嚴重的問題是,Java默認關閉斷言。。
如果你在Java默認的環境下運行程序,你所有的斷言都不會被檢查!Java的設計者這么做是因為斷言檢查會帶來性能上的損失。例如,我們寫了一個二分查找方法,而該方法的前置條件是數組已經排序。所以我們的斷言檢查應該是一個線性的復雜度,這樣就會改變整個方法的復雜度。但是,對于測試來說,這樣的檢查是必須的,因為斷言檢查會讓你的調試更加簡單。當程序發布時,這些測試斷言就會被去除掉。另外,對于大多數應用來說,斷言檢查的性能損失和后續的代碼比起來不算什么,所以它們還是值得的。
為了顯式的打開斷言,你需要在使用Java虛擬機的時候加上 -ea 參數。在Eclipse中,你需要進入 Run → Run Configurations → Arguments,然后在VM參數中添加 -ea 。如果想要將 -ea 設為默認參數,進入 Preferences → Java → Installed JREs → Edit → Default VM Arguments,然后加上 -ea 。這些在 Getting Started 中有詳細描述。
在用JUnit進行測試時也最好將斷言打開,你可以通過以下代碼測試斷言是否打開:
@Test(expected=AssertionError.class) public void testAssertionsEnabled() {assert false; }如果斷言打卡, assert false 語句就會拋出一個 AssertionError。而測試前的(expected=AssertionError.class) 表示這個測試應該拋出AssertionError,所以測試會通過。如果斷言關閉,那么就不會有AssertionError拋出,測試也不會通過。
注意到Java中的 asser語句并不等同于JUnit中的 assertTrue(), assertEquals()這些方法。雖然它們都是對代碼狀態進行預測,但是使用的上下文不一樣。 asser語句是在實現的代碼中使用的,以此來進行防御性編程。而Junit的 assert...() 方法是放在JUnit的測試文件中的。如果沒有使用-ea參數開啟斷言, assert 是不會檢查的,但是JUnit的斷言方法還是會運行。
什么時候需要斷言
檢查方法的參數要求,例如上面的 sqrt例子。
檢查方法的返回要求,這樣的檢查也稱為“自檢查(self check)” 。例如,sqrt可能會在返回前檢查結果是否在誤差范圍內:
public double sqrt(double x) {assert x >= 0;double r;... // compute result rassert Math.abs(r*r - x) < .0001;return r; }應該在什么時候寫上斷言?你應該在寫代碼的時候而非寫完之后添加斷言,因為在寫代碼的時候你的心里會有一些必須滿足的條件,這些必須滿足的條件就可以用斷言檢查,而寫完之后再添加就可能會忘掉這些必要條件。
譯者注:這個地方我有些疑惑,對于前置條件的檢查,到底應該拋出非檢查異常還是使用斷言呢?有幾點可以肯定:斷言是對于開發過程中的設計而言的,意在表示設計上不能達到的狀態,是面向開發者的,在后期可以取消。而非檢查異常似乎是對于使用者來說的,即強制要求前置條件得到滿足。這里引用一篇stackExchange上的回答:
Assertions are removed at runtime unless you explicitly specify to "enable assertions" when compiling your code. Java Assertions are not to be used on production code and should be restricted to private methods (see Exception vs Assertion), since private methods are expected to be known and used only by the developers. Also assert will throw AssertionError which extends Error not Exception, and which normally indicates you have a very abnormal error (like "OutOfMemoryError" which is hard to recover from, isn't it?) you are not expected to be able to treat.
Remove the "enable assertions" flag, and check with a debugger and you'll see that you will not step on the IllegalArgumentException throw call... since this code has not been compiled (again, when "ea" is removed)
It is better to use the second construction for public/protected methods, and if you want something that is done in one line of code, there is at least one way that I know of. I personally use the Spring Framework's Assert class that has a few methods for checking arguments and that throw "IllegalArgumentException" on failure. Basically, what you do is:
Assert.notNull(obj, "object was null");... Which will in fact execute exactly the same code you wrote in your second example. There are a few other useful methods such as hasText, hasLength in there.
什么時候不需要斷言
運行時的斷言檢查并不是能隨意使用的,如果用的不恰當,它們會像毫無意義的注釋一樣讓代碼變得繁瑣。例如:
// don't do this: x = y + 1; assert x == y+1;這個代碼并不能發現你代碼中的bug,事實上,它只能發現編譯器或者虛擬機的問題——而這幾乎是不可能出問題的。如果一個斷言檢查在上下文中是無意義的,刪除它。
永遠不要用斷言檢查程序之外的條件,例如文件是否存在、網絡是否可到達、或者用戶的輸入是否正確。斷言應該用來保證程序內部的合理性而非外部。當斷言失敗時,它意味著程序已經進入了一個設計上錯誤的狀態(bug),而外部的條件是你無法通過更改代碼能預測的,所以它們不是bug。通常來說,這些外部條件應該使用已檢查異常進行報告。
很多時候,斷言這種機制只用于程序的測試和調試階段,當程序發行時會全部取消。Java也是這樣。正因為斷言可能會被取消,你的代碼不能依賴于斷言檢查是否被執行,也就是說,斷言檢查不能有副作用(side-effects),例如
// don't do this: assert list.remove(x);如果斷言檢查被關閉,那么這個語句就不會被執行,而 x 也就不會從列表中刪除了。應該這樣寫:
boolean found = list.remove(x); assert found;相似的,在進行條件語句覆蓋檢查時,不要使用斷言,因為它們在未來可能會被關閉。對于非法的情況,應該拋出異常:
switch (vowel) {case 'a':case 'e':case 'i':case 'o':case 'u': return "A";default: throw new AssertionError("must be a vowel, but was: " + vowel);/* The exception in the default clause has the effect of asserting that vowel must be one of the five vowel letters.*/ }閱讀小練習
Assertions
思考下面這個函數:
/*** Solves quadratic equation ax^2 + bx + c = 0.* * @param a quadratic coefficient, requires a != 0* @param b linear coefficient* @param c constant term* @return a list of the real roots of the equation*/ public static List<Double> quadraticRoots(final int a, final int b, final int c) {List<Double> roots = new ArrayList<Double>();// A... // compute roots // Breturn roots; }在A處應該寫上哪一條語句?
[x] assert a != 0;
[ ] assert b != 0;
[ ] assert c != 0;
[ ] assert roots.size() >= 0;
[ ] assert roots.size() <= 2;
[ ] for (double x : roots) { assert Math.abs(a*x*x + b*x + c) < 0.0001; }
在B處寫上哪一條語句是合理的?
[ ] assert a != 0;
[ ] assert b != 0;
[ ] assert c != 0;
[ ] assert roots.size() >= 0;
[x] assert roots.size() <= 2;
[x] for (double x : roots) { assert Math.abs(a*x*x + b*x + c) < 0.0001; }
增量式開發
譯者注:Incremental development 也可譯為“漸增性開發”
增量式開發是一種將bug控制在小范圍內的好方法。在這種開發方法中,你每次只完成程序的一小部分,然后對這部分進行完全的測試,隨后再進行下一步的小范圍開發,并最終完成開發。通過這種方式,我們可以將大多數bug控制在我們剛剛修改/增加的代碼中,從而降低debug的困難。
在我們之前的閱讀中(譯者注:“測試”),談到了兩個可以在增量式開發中幫助我們的測試方法:
- 單元測試:每次只對一個獨立的模塊進行測試,這樣可以將bug的范圍控制在模塊中——或者在測試用例本身中。
- 回歸測試:當你在系統中添加新的功能或修改一個bug后,重新運行所有測試,防止代碼“回退”。
模塊化與封裝
你也可以通過好的設計將bug本地化。
模塊化.模塊化意味著將你的程序分成幾個模塊,每一個模塊都是單獨設計、實現、測試,并且可以在別的地方進行復用。模塊化的反面是使用一個“大塊”系統——其中的每一行的正確執行都依賴著前面的代碼。
例如,如果一個程序只有一個龐大的main函數,那他就是非模塊化的,這樣的代碼會很難懂,也很難將bug孤立出來。與此相對,如果一個程序被分為幾個小的函數和類,那它就是偏模塊化的。
封裝.封裝意味著你在模塊周圍建立起一道圍墻(或者說一個殼或膠囊),以此讓模塊只對自己內部的代碼行為負責,其他模塊的錯誤行為也不會影響到它的正確性。
一種封裝的方法就是使用 訪問控制,大多數時候就是使用 public 和 private 來控制變量和方法的可見/可訪問范圍。一個公共的方法和變量可以被任何地方的代碼訪問(假設它們所處的類也是公共的)。而一個私有的方法或變量只能被相同類的代碼訪問。盡可能使用private而非public ,特別是對于變量而言。通過控制訪問范圍,我們能縮小bug產生的范圍和debug時的搜索范圍。
另一種封裝的方法就是使用變量作用域。作用域是指程序源代碼中定義這個變量的區域。簡單的說,作用域就是變量與函數的可訪問/可見范圍。全局變量擁有全局作用域,函數參數作用于整個函數(不包括子函數),局部變量作用于聲明語句到下一個花括號為止。盡量使用和保持局部變量的作用范圍,我們就越容易定位bug,例如,下面是一個循環:
for (i = 0; i < 100; ++i) {...doSomeThings();... }但是你發現這個循環一直沒有停止——即i一直沒有到100.似乎某個人在某個地方更改了i的值,但是在哪呢?這有很多種可能性,例如你將i定義成了全局變量:
public static int i; ... for (i = 0; i < 100; ++i) {...doSomeThings();... }現在它的作用域是整個程序,它可以被任何地方的代碼改變!例如在doSomeThings()中,在doSomeThings()的子函數中,甚至在另一個并行的線程中。但是如果我們將i聲明成一個只在循環中存在的變量:
for (int i = 0; i < 100; ++i) {...doSomeThings();... }現在,i只能被for語句和...修改了。你不再需要考慮 doSomeThings()和程序其他位置是否會對i進行更改,因為其他位置的代碼都無法訪問這里的i 。
最小化作用域是一個將bug本地化的有力工具。對于Java來說,這里有一些好用的點子:
永遠在for語句內部聲明循環參量 所以羨慕這樣的寫法就是不對的,它讓for循環外部的剩余代碼也能更改i:
int i; for (i = 0; i < 100; ++i) {應該這樣寫:
for (int i = 0; i < 100; ++i) {這時i只能作用于for內部了。
盡量在需要使用變量的時候才聲明它,并且盡量將它放在最內部的花括號內. 在Java中,變量作用域是以花括號作為邊界的,所以你應該盡可能將變量聲明放在需要該變量的最內花括號內。不要在方法的一開始就聲明變量——這樣會使得它們的作用域變大。另外,在一些非靜態語言中,例如Python和JavaScript,變量的作用域通常是整個方法,所以你不能將作用域控制在某一個范圍。
避免使用全局變量. 這是一個很糟糕的注意,尤其是當程序變大的時候。通常來說,全局變量是為了方便向幾個方法傳入同樣的參數,但是這樣不如分別向各個方法傳入,因為全局變量很可能會被不經意的修改掉。
閱讀小練習
Variable scope
思考下面的代碼(沒有寫出一些變量的聲明):
1 class Apartment { 2 Apartment(String newAddress, int bathrooms) { 3 this.address = newAddress; 4 this.roommates = new HashSet<Person>(); 5 this.bathrooms = bathrooms; 6 } 7 8 String getAddress() { 9 return address; 10 } 11 12 void addRoommate(Person newRoommate) { 13 roommates.add(newRoommate); 14 if (roommates.size() > MAXIMUM_OCCUPANCY_PER_BATHROOM * bathrooms) { 15 roommates.remove(newRoommate); 16 throw new TooManyPeopleException(); 17 } 18 } 19 20 int getMaximumOccupancy() { 21 return MAXIMUM_OCCUPANCY_PER_BATHROOM * bathrooms; 22 } 23 }以下哪一行處于newRoommate 的作用域?
[ ] line 3
[ ] line 8
[x] line 13
[x] line 16
[ ] line 20
以下哪一行處于 address (沒有寫出聲明)的作用域?
[x] lines 2-22
[ ] lines 3-5
[ ] line 9
[ ] lines 13-17
以下哪一條 roommates 的聲明是最合理的?
[ ] List<Person> roommates;
[ ] Set<Person> roommates;
[x] final Set<Person> roommates;
[ ] HashSet<Person> roommates;
以下哪一條 MAXIMUM_OCCUPANCY_PER_BATHROOM 的聲明是最合理的?
[ ] int MAXIMUM_OCCUPANCY_PER_BATHROOM = 5;
[ ] final int MAXIMUM_OCCUPANCY_PER_BATHROOM = 5;
[ ] static int MAXIMUM_OCCUPANCY_PER_BATHROOM = 5;
[x] static final int MAXIMUM_OCCUPANCY_PER_BATHROOM = 5;
Snapshots of scope
下面是上一題的代碼,不過將代碼補全了:
class Apartment {final String address;final Set<Person> roommates;final int bathrooms;static final MAXIMUM_OCCUPANCY_PER_BATHROOM = 5;Apartment(String newAddress, int bathrooms) {this.address = newAddress;this.roommates = new HashSet<Person>();this.bathrooms = bathrooms;}String getAddress() {return address;}void addRoommate(Person newRoommate) {roommates.add(newRoommate);if (roommates.size() > MAXIMUM_OCCUPANCY_PER_BATHROOM * bathrooms) {roommates.remove(newRoommate);throw new TooManyPeopleException();}}int getMaximumOccupancy() {return MAXIMUM_OCCUPANCY_PER_BATHROOM * bathrooms;}public static void main(String[] args) {Apartment apt = new Apartment("221 Baker St", 1);apt.addRoommate(new Person("Sherlock Holmes"));} }假設我們將代碼執行到 addRoommate()里面就停住。上圖畫出了此刻程序不完整的快照圖。試著填上每一個標簽內的內容。如果你你忘了每一個方框代表的含義,參考:“代碼評審_在快照圖中的各種變量”
在A標簽處應該有哪些變量?
[ ] address
[ ] roommates
[ ] bathrooms (instance variable)
[ ] MAXIMUM_OCCUPANCY_PER_BATHROOM
[ ] newAddress
[ ] bathrooms (local variable)
[x] newRoommate
[ ] args
[ ] apt
[x] this
this作為隱式參數傳入方法
在B標簽處應該有哪些變量?
[ ] address
[ ] roommates
[ ] bathrooms (instance variable)
[ ] MAXIMUM_OCCUPANCY_PER_BATHROOM
[ ] newAddress
[ ] bathrooms (local variable)
[ ] newRoommate
[x] args
[x] apt
[ ] this
在C標簽處應該有哪些變量?
[x] address
[x] roommates
[x] bathrooms (instance variable)
[ ] MAXIMUM_OCCUPANCY_PER_BATHROOM
[ ] newAddress
[ ] bathrooms (local variable)
[ ] newRoommate
[ ] args
[ ] apt
[ ] this
在D標簽處應該有哪些變量?
[ ] address
[ ] roommates
[ ] bathrooms (instance variable)
[x] MAXIMUM_OCCUPANCY_PER_BATHROOM
[ ] newAddress
[ ] bathrooms (local variable)
[ ] newRoommate
[ ] args
[ ] apt
[ ] this
此刻快照圖中不存在哪些變量?
[ ] address
[ ] roommates
[ ] bathrooms (instance variable)
[ ] MAXIMUM_OCCUPANCY_PER_BATHROOM
[x] newAddress
[x] bathrooms (local variable)
[ ] newRoommate
[ ] args
[ ] apt
[ ] this
此刻哪些變量是在 addRoommate()中不可訪問的(但是存在)?
- [ ] address
- [ ] roommates
- [ ] bathrooms (instance variable)
- [ ] MAXIMUM_OCCUPANCY_PER_BATHROOM
- [ ] newAddress
- [ ] bathrooms (local variable)
- [ ] newRoommate
- [x] args
- [x] apt
- [ ] this
總結
在這篇閱讀中,我們介紹了幾種最小化調試代價的方法:
- 避免調試
- 使用靜態類型檢查、動態檢查、不可變類型和不可變索引讓bug無法產生。
- 限制bug范圍
- 通過斷言檢查、快速失敗讓bug的影響不擴散。
- 通過增量式開發和單元測試讓bug盡量只存在于剛剛修改的代碼中。
- 最小化變量作用域使得搜尋范圍減小。
最后還是將這次閱讀的內容和我們的三個目標聯系起來:
- 遠離bug. 本閱讀的內容就是如何避免和限制bug。
- 易于理解. 靜態類型檢查、final以及斷言都是額外的“注釋”——它們體現了你對程序狀態的假設。而縮小作用域使得讀者可以更好的理解變量是如何使用的,因為他們需要瀏覽的代碼范圍變小了。
- 可改動. 斷言檢查和靜態檢查都是能夠自動檢查的“假設”,所以如果未來有一個程序員錯誤改動了代碼,那么違背假設的錯誤就能馬上檢測到。
轉載于:https://www.cnblogs.com/liqiuhao/p/8644241.html
總結
以上是生活随笔為你收集整理的麻省理工18年春软件构造课程阅读09“避免调试”的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 1 week110的zookeeper的
- 下一篇: 提速XP操作系统