天天看點

麻省理工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;

  • vowel2 = "uoie" + vowel1;

  • vowel3 = vowel2;

  • vowel2[0] = 'x';

  • vowel3[0] = 'x';

  • vowel4 = vowel5;

  • vowel5 = vowel4;

  • vowel4[0] = 'x';

  • vowel5[0] = 'z';

Afterwards

當上一個練習的合法語句全部執行完以後,各個變量的值分别是多少?

vowel0

y

vowel1

a

vowel2

uoiea

vowel3

aeiou

vowel4

zeiou

vowel5

第二道防禦:将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 r
    assert 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        
    // B
    return roots;
}
           

在A處應該寫上哪一條語句?

  • 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;

  • assert roots.size() <= 2;

  • 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;

  • 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;

  • 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"));
    }
}
           
麻省理工18年春軟體構造課程閱讀09“避免調試”

假設我們将代碼執行到

addRoommate()

裡面就停住。上圖畫出了此刻程式不完整的快照圖。試着填上每一個标簽内的内容。如果你你忘了每一個方框代表的含義,參考:“代碼評審_在快照圖中的各種變量”

在A标簽處應該有哪些變量?

  • [ ] address
  • [ ] roommates
  • [ ] bathrooms (instance variable)
  • [ ] MAXIMUM_OCCUPANCY_PER_BATHROOM
  • [ ] newAddress
  • [ ] bathrooms (local variable)
  • [x] newRoommate
  • [ ] args
  • [ ] apt
  • [x] this

this

作為隐式參數傳入方法

在B标簽處應該有哪些變量?

  • [ ] newRoommate
  • [x] args
  • [x] apt
  • [ ] this

在C标簽處應該有哪些變量?

  • [x] address
  • [x] roommates
  • [x] bathrooms (instance variable)

在D标簽處應該有哪些變量?

  • [x] MAXIMUM_OCCUPANCY_PER_BATHROOM

此刻快照圖中不存在哪些變量?

  • [x] newAddress
  • [x] bathrooms (local variable)

此刻哪些變量是在

addRoommate()

中不可通路的(但是存在)?

總結

在這篇閱讀中,我們介紹了幾種最小化調試代價的方法:

  • 避免調試
    • 使用靜态類型檢查、動态檢查、不可變類型和不可變索引讓bug無法産生。
  • 限制bug範圍
    • 通過斷言檢查、快速失敗讓bug的影響不擴散。
    • 通過增量式開發和單元測試讓bug盡量隻存在于剛剛修改的代碼中。
    • 最小化變量作用域使得搜尋範圍減小。

最後還是将這次閱讀的内容和我們的三個目标聯系起來:

  • 遠離bug. 本閱讀的内容就是如何避免和限制bug。
  • 易于了解. 靜态類型檢查、

    final

    以及斷言都是額外的“注釋”——它們展現了你對程式狀态的假設。而縮小作用域使得讀者可以更好的了解變量是如何使用的,因為他們需要浏覽的代碼範圍變小了。
  • 可改動. 斷言檢查和靜态檢查都是能夠自動檢查的“假設”,是以如果未來有一個程式員錯誤改動了代碼,那麼違背假設的錯誤就能馬上檢測到。