程式設計的智慧

jianshu發表於2015-11-24

  程式設計是一件創造性的工作,是一門藝術。精通任何一門藝術,都需要很多的練習和領悟,所以這裡提出的“智慧”,並不是號稱三天瘦二十斤的減肥藥,它並不能代替你自己的勤奮。然而我希望它能給迷惑中的人們指出一些正確的方向,讓他們少走一些彎路,基本做到一分耕耘一分收穫。

  反覆推敲程式碼

  既然“天才是百分之一的靈感,百分之九十九的汗水”,那我先來談談這汗水的部分吧。有人問我,提高程式設計水平最有效的辦法是什麼?我想了很久,終於發現最有效的辦法,其實是反反覆覆地修改和推敲程式碼。

  在IU的時候,由於Dan Friedman的嚴格教導,我們以寫出冗長複雜的程式碼為恥。如果你程式碼多寫了幾行,這老頑童就會大笑,說:“當年我解決這個問題,只寫了5行程式碼,你再回去想想吧……” 當然,有時候他只是誇張一下,故意刺激你的,其實沒有人能只用5行程式碼完成。然而這種提煉程式碼,減少冗餘的習慣,卻由此深入了我的骨髓。

  有些人喜歡炫耀自己寫了多少多少萬行的程式碼,彷彿程式碼的數量是衡量程式設計水平的標準。然而,如果你總是匆匆寫出程式碼,卻從來不回頭去推敲,修改和提煉,其實是不可能提高程式設計水平的。你會製造出越來越多平庸甚至糟糕的程式碼。在這種意義上,很多人所謂的“工作經驗”,跟他程式碼的質量,其實不一定成正比。如果有幾十年的工作經驗,卻從來不回頭去提煉和反思自己的程式碼,那麼他也許還不如一個只有一兩年經驗,卻喜歡反覆推敲,仔細領悟的人。

  有位文豪說得好:“看一個作家的水平,不是看他發表了多少文字,而要看他的廢紙簍裡扔掉了多少。” 我覺得同樣的理論適用於程式設計。好的程式設計師,他們刪掉的程式碼,比留下來的還要多很多。如果你看見一個人寫了很多程式碼,卻沒有刪掉多少,那他的程式碼一定有很多垃圾。

  就像文學作品一樣,程式碼是不可能一蹴而就的。靈感似乎總是零零星星,陸陸續續到來的。任何人都不可能一筆呵成,就算再厲害的程式設計師,也需要經過一段時間,才能發現最簡單優雅的寫法。有時候你反覆提煉一段程式碼,覺得到了頂峰,沒法再改進了,可是過了幾個月再回頭來看,又發現好多可以改進和簡化的地方。這跟寫文章一模一樣,回頭看幾個月或者幾年前寫的東西,你總能發現一些改進。

  所以如果反覆提煉程式碼已經不再有進展,那麼你可以暫時把它放下。過幾個星期或者幾個月再回頭來看,也許就有煥然一新的靈感。這樣反反覆覆很多次之後,你就積累起了靈感和智慧,從而能夠在遇到新問題的時候直接朝正確,或者接近正確的方向前進。

  寫優雅的程式碼

  人們都討厭“麵條程式碼”(spaghetti code),因為它就像麵條一樣繞來繞去,沒法理清頭緒。那麼優雅的程式碼一般是什麼形狀的呢?經過多年的觀察,我發現優雅的程式碼,在形狀上有一些明顯的特徵。

  如果我們忽略具體的內容,從大體結構上來看,優雅的程式碼看起來就像是一些整整齊齊,套在一起的盒子。如果跟整理房間做一個類比,就很容易理解。如果你把所有物品都丟在一個很大的抽屜裡,那麼它們就會全都混在一起。你就很難整理,很難迅速的找到需要的東西。但是如果你在抽屜裡再放幾個小盒子,把物品分門別類放進去,那麼它們就不會到處亂跑,你就可以比較容易的找到和管理它們。

  優雅的程式碼的另一個特徵是,它的邏輯大體上看起來,是枝丫分明的樹狀結構(tree)。這是因為程式所做的幾乎一切事情,都是資訊的傳遞和分支。你可以把程式碼看成是一個電路,電流經過導線,分流或者匯合。如果你是這樣思考的,你的程式碼裡就會比較少出現只有一個分支的if語句,它看起來就會像這個樣子:

if (...) {
  if (...) {
    ...
  } else {
    ...
  }
} else if (...) {
  ...
} else {
  ...
}

  注意到了嗎?在我的程式碼裡面,if語句幾乎總是有兩個分支。它們有可能巢狀,有多層的縮排,而且else分支裡面有可能出現少量重複的程式碼。然而這樣的結構,邏輯卻非常嚴密和清晰。在後面我會告訴你為什麼if語句最好有兩個分支。

  寫模組化的程式碼

  有些人吵著鬧著要讓程式“模組化”,結果他們的做法是把程式碼分部到多個檔案和目錄裡面,然後把這些目錄或者檔案叫做“module”。他們甚至把這些目錄分放在不同的VCS repo裡面。結果這樣的作法並沒有帶來合作的流暢,而是帶來了許多的麻煩。這是因為他們其實並不理解什麼叫做“模組”,膚淺的把程式碼切割開來,分放在不同的位置,其實非但不能達到模組化的目的,而且製造了不必要的麻煩。

  真正的模組化,並不是文字意義上的,而是邏輯意義上的。一個模組應該像一個電路晶片,它有定義良好的輸入和輸出。實際上一種很好的模組化方法早已經存在,它的名字叫做“函式”。每一個函式都有明確的輸入(引數)和輸出(返回值),同一個檔案裡可以包含多個函式,所以你其實根本不需要把程式碼分開在多個檔案或者目錄裡面,同樣可以完成程式碼的模組化。我可以把程式碼全都寫在同一個檔案裡,卻仍然是非常模組化的程式碼。

  想要達到很好的模組化,你需要做到以下幾點:

  • 避免寫太長的函式。如果發現函式太大了,就應該把它拆分成幾個更小的。通常我寫的函式長度都不超過50行,那正好是我的膝上型電腦螢幕所能容納的程式碼的行數。這樣我可以一目瞭然的看見一個函式,而不需要滾屏。50行並不是一個很大的限制,因為函式裡面比較複雜的部分,往往早就被我提取出去,做成了更小的函式,然後從原來的函式裡面呼叫。所以我寫的函式大小一般遠遠不足50行。

    有些人不喜歡使用小的函式,因為他們想避免函式呼叫的開銷,結果他們寫出幾百行之大的函式。這是一種歷史遺留的錯覺。現代的編譯器都能自動的把小的函式內聯(inline)到呼叫它的地方,所以根本不產生函式呼叫,也就不會產生任何多餘的開銷。

    同樣的一些人,也愛使用巨集(macro)來代替小函式,這也是一種歷史遺留的錯覺。在早期的C語言編譯器裡,只有macro是靜態“內聯”的,所以他們使用巨集,其實是為了達到內聯的目的。然而能否內聯,其實並不是巨集與函式的根本區別。巨集與函式有著巨大的區別(這個我以後再講),應該儘量避免使用巨集。為了內聯而使用巨集,其實是濫用了巨集,這會引起各種各樣的麻煩,比如使程式難以理解,難以除錯,容易出錯等等。

  • 每個函式只做一件簡單的事情。有些人喜歡製造一些“通用”的函式,既可以做這個又可以做那個,然後他們傳遞一個引數來“選擇”這個函式所要做的事情。這種“複用”其實是有害的。如果一個函式可能做兩種不一樣的事情,最好就寫成兩個不同的函式,否則這個函式的邏輯就不會很清晰,容易出現錯誤。

  寫可讀的程式碼

  有些人以為寫很多註釋就可以讓程式碼更加可讀,然而卻發現事與願違。註釋不但沒能讓程式碼變得可讀,反而由於大量的註釋充斥在程式碼中間,讓程式變得障眼難讀。而且程式碼的邏輯一旦修改,就會有很多的註釋變得過時,需要更新。修改註釋是相當大的負擔,所以大量的註釋,反而成為了妨礙改進程式碼的絆腳石。

  實際上,真正優雅可讀的程式碼,是幾乎不需要註釋的。如果你發現需要寫很多註釋,那麼你的程式碼肯定是含混晦澀,邏輯不清晰的。其實,程式語言的邏輯表達能力,是遠遠高於自然語言的。使用大量的自然語言去解釋程式的細節,是本末倒置的。

  有人受到了Donald Knuth提出的所謂“文學程式設計”(Literate Programming)的誤導,認為程式裡面註釋應該是主要的部分,而程式碼其次,其實並不是這樣的。很多人(包括Knuth自己)使用文學程式設計,其實並沒有寫出容易理解的程式碼。Knuth認為人與人之間交流,必須使用自然語言,而其實如果使用得當,程式語言能夠更加清晰精確地在人類之間傳遞資訊。

  之所以說“如果使用得當”,是因為如果沒能合理利用程式語言提供的優勢,你會發現程式還是很難懂,以至於需要寫註釋。所以我現在告訴你一些要點,也許可以幫助你大大減少寫註釋的必要:

  1. 使用有意義的函式和變數名字。如果你的函式和變數的名字,能夠切實的描述它們的邏輯,那麼你就不需要寫註釋來解釋它在幹什麼。比如:

    // put elephant elephant1 into fridge fridge2
    putElephantIntoFridge(elephant1, fridge2);

    由於我的函式名putElephantIntoFridge已經說明了它要幹什麼(把大象放進冰箱),所以上面那句註釋完全沒有必要。

  2. 把複雜的邏輯提取出去,做成“幫助函式”。有些人寫的函式很長,以至於看不清楚裡面的語句在幹什麼,所以他們誤以為需要寫註釋。如果你仔細觀察這些程式碼,就會發現不清晰的那片程式碼,往往可以被提取出去,做成一個函式,然後在原來的地方呼叫。由於函式有一個名字,這樣你就可以使用有意義的函式名來代替註釋。舉一個例子:

    ... ...
    ... ...
    
    // put elephant elephant1 into fridge fridge2
    openDoor(fridge2);
    if (driveElephantIntoFridge(elephan1, fridge2)) {
       feedElephant(new Treat(), elephant1);
    } else {
       putBananaIntoFridge(new Banana(), fridge2);
       waitForElephantEnter(elephant1, fridge2);
    }
    closeDoor(fridge2);
    
    ... ...
    ... ...

    如果你把這片程式碼提出去定義成一個函式:

    function putElephantIntoFridge(elephant, fridge) {
      openDoor(fridge2);
      if (driveElephantIntoFridge(elephan1, fridge2)) {
        feedElephant(new Treat(), elephant1);
      } else {
        putBananaIntoFridge(new Banana(), fridge2);
        waitForElephantEnter(elephant1, fridge2);
      }
      closeDoor(fridge2);
    }

    然後原來的程式碼就可以改成:

    ... ...
    ... ...
    
    putElephantIntoFridge(elephant1, fridge2);
    
    ... ...
    ... ...

    註釋就沒必要了。

  程式語言相比自然語言,是非常強大而嚴謹的,它其實已經具有自然語言的主要元素:主語,謂語,賓語,名詞,動詞,如果,因為,所以,否則,是,不是,…… 所以如果你充分利用了程式語言的表達能力,你完全可以用程式本身來表達它到底在幹什麼,而不需要自然語言的輔助。

  有少數的時候,你也許會為了繞過其他一些程式碼的設計問題,採用一種違反直覺的作法。這時候你就可以使用很短的一條註釋,說明為什麼要寫成那奇怪的樣子。這樣的情況應該很少出現,否則這意味著整個程式碼的設計都有問題。

  寫簡單的程式碼

  現在我提出一些我自己正在使用的程式碼規範,稍微解釋一下為什麼它們能讓程式碼更加簡單,從而提高程式碼的質量。

  • 避免使用i++和++i。這種自增減操作表示式含義很蹊蹺,非常容易搞混淆。而且含有它們的表示式的結果,有可能取決於引數的求值順序。其實這兩個表示式完全可以分解成兩步做,把讀寫操作分開:一步更新i的值,另外一步使用i的值。比如,如果你想寫foo(i++),你完全可以把它拆成int t = i; i += 1; foo(t);。如果你想寫foo(++i),可以拆成i += 1; foo(i); 拆開之後的程式碼,含義完全一致,卻清晰很多。到底更新是在取值之前還是之後,非常的明顯。

    有人也許以為i++或者++i的效率比拆開之後要高,這只是一種誤解。這些程式碼經過最基礎的編譯器優化之後,生成的機器程式碼是完全沒有區別的。i++和++i,只有在兩種情況下可以安全的使用。一種是用在for迴圈語句的update部分,比如for(int i = 0; i < 5; i++),另一種情況是寫在單獨的一行,比如i++;。這兩種情況是完全沒有歧義的。但是一定要避免把i++和++i用在複雜的表示式裡面,比如foo(i++),foo(++i) + foo(i),…… 沒有人應該知道,或者去追究這些是什麼意思。

  • 永遠不要省略花括號。很多語言允許你在某種情況下省略掉花括號,比如C,Java都允許你在if語句裡面只有一句話的時候省略掉花括號:

    if (...) 
      action1();

    咋一看少打了兩個字,多好。可是這其實經常引起奇怪的問題。比如,你後來想要加一句話action2()到這個if裡面,於是你就把程式碼改成:

    if (...) 
      action1();
      action2();

    為了美觀,你很小心的使用了action1()的縮排。咋一看它們是在一起的,所以你下意識裡以為它們只會在if的條件為真的時候執行,然而action2()卻其實在if外面,它會被無條件的執行。我把這種現象叫做“光學幻覺”(optical illusion),理論上每個程式設計師都應該發現這個錯誤,然而實際上卻容易被忽視。

    那麼你問,誰會這麼傻,我在加入action2()的時候加上花括號不就行了?可是從設計的角度來看,這樣其實並不是合理的作法。首先,也許你以後又想把action2()去掉,這樣你為了樣式一致,又得把花括號拿掉,煩不煩啊?其次,這使得程式碼樣式不一致,有的if有花括號,有的又沒有。況且,你為什麼需要記住這個規則?如果你不問三七二十一,只要是if-else語句,把花括號全都打上,就可以想都不用想了,就當C和Java沒提供給你這個特殊寫法。這樣就可以保持完全的一致性,減少不必要的思考。

    有人可能會說,全都打上花括號,只有一句話也打上,多礙眼啊?然而經過實行這種編碼規範幾年之後,我並沒有發現這種寫法更加礙眼,反而由於花括號的存在,使得程式碼界限明確,讓我的眼睛負擔更小了。

  • 合理使用括號,不要盲目依賴操作符優先順序。利用操作符的優先順序來減少括號,對於1+2*3這樣常見的算數表示式,是沒問題的。然而有些人如此的仇恨括號,以至於他們會寫出2 << 7 - 2 * 3這樣的表示式,而完全不用括號。

    這裡的問題,在於移位操作<<的優先順序,是很多人不熟悉,而且是違反常理的。由於x << 1相當於把x乘以2,很多人誤以為這個表示式相當於(2 << 7) - (2 * 3),所以等於250。然而實際上<<的優先順序比加法+還要低,所以這表示式其實相當於2 << (7 - 2 * 3),所以等於4!

    解決這個問題的辦法,不是要每個人去把操作符優先順序表給硬背下來,而是合理的加入括號。比如上面的例子,最好直接加上括號寫成2 << (7 - 2 * 3)。雖然沒有括號也表示同樣的意思,但是加上括號就更加清晰,讀者不再需要死記<<的優先順序就能理解程式碼。

  • 避免使用continue和break。迴圈語句(for,while)裡面出現return是沒問題的,但是如果使用了continue或者break,就會讓迴圈的邏輯和終止條件變得複雜,難以確保正確。如果只有一個continue或者break也許還好,但是如果你的迴圈語句裡面出現了多個continue或者break,你就該考慮改寫整個迴圈了。

    出現continue或者break的原因,往往是對迴圈要執行的邏輯沒有想得很清楚。因為如果你考慮周全了,應該是幾乎不需要continue或者break的。改寫迴圈的辦法有多種,你也許可以把複雜的部分提取出來,做成函式呼叫,或者把它變成一個沒有continue或者break的迴圈結構。

    舉一個例子。下面這段程式碼裡面有一個continue:

    List<String> goodNames = new ArrayList<>();
    for (String name: names) {
      if (name.contains("bad")) {
        continue;
      }
      goodNames.add(name);
      ...
    }

    它說:“如果name含有'bad'這個詞,跳過後面的迴圈程式碼……” 注意,這是一種“負面”的描述,它不是在告訴你什麼時候“做”一件事,而是在告訴你什麼時候“不做”一件事。為了知道它到底在幹什麼,你必須搞清楚continue會導致哪些語句被跳過了,然後腦子裡把邏輯反個向,你才能知道它到底想做什麼。這就是為什麼含有continue和break的迴圈不容易理解,它們依靠“控制流”來描述“不做什麼”,“跳過什麼”,結果到最後你也沒搞清楚它到底“要做什麼”。

    其實,我們只需要把continue的條件反向,這段程式碼就可以很容易的被轉換成等價的,不含continue的程式碼:

    List<String> goodNames = new ArrayList<>();
    for (String name: names) {
      if (!name.contains("bad")) {
        goodNames.add(name);
        ...
      }
    }

    goodNames.add(name);和它之後的程式碼全部被放到了if裡面,多了一層縮排,然而continue卻沒有了。你再讀這段程式碼,就會發現更加清晰。因為它是一種更加“正面”地描述。它說:“在name不含有'bad'這個詞的時候,把它加到goodNames的連結串列裡面……”

    再舉一個例子:

    public boolean hasBadName(List<String> names) {
      boolean result = false;
    
      for (String name: names) {
          if (name.contains("bad")) {
              result = true;
              break;
          }
      }
      return result;
    }

    這個函式檢查names連結串列裡是否存在一個名字,包含“bad”這個詞。它的迴圈裡包含一個break語句。這個函式可以被改寫成:

    public boolean hasBadName(List<String> names) {
      for (String name: names) {
          if (name.contains("bad")) {
              return true;
          }
      }
      return false;
    }

    改進後的程式碼,在name裡面含有“bad”的時候,直接用return true返回,而不是對result變數賦值。如果迴圈結束了還沒有return,那就返回false,表示沒有找到這樣的名字。使用return來代替break,這樣break語句和result這個變數,都一併被消除掉了。

    我的經驗是,99%的break和continue,都可以用return語句或者翻轉if條件的方式來消除掉。修改之後的程式碼變得容易理解,容易確保正確。

  寫直觀的程式碼

  我寫程式碼有一條重要的原則:如果有更加直接,更加清晰的寫法,就選擇它,即使它看起來更長,更笨,也一樣選擇它。比如,Unix命令列有一種“巧妙”的寫法是這樣:

command1 && command2 && command3

  由於Shell語言的邏輯操作a && b具有“短路”的特性,如果a等於false,那麼b就沒必要執行了。這就是為什麼當command1成功,才會執行command2,當command2成功,才會執行command3。同樣,

command1 || command2 || command3

  操作符||也有類似的特性。上面這個命令列,如果command1成功,那麼command2和command3都不會被執行。如果command1失敗,command2成功,那麼command3就不會被執行。

  這比起用if語句來判斷失敗,似乎更加巧妙和簡潔,所以有人就借鑑了這種方式,在程式的程式碼裡也使用這種方式。比如他們可能會寫這樣的程式碼:

if (action1() || action2() && action3()) {
  ...
}

  你看得出來這程式碼是想幹什麼嗎?action2和action3什麼條件下執行,什麼條件下不執行?也許稍微想一下,你知道它在幹什麼:“如果action1失敗了,執行action2,如果action2成功了,執行action3”。然而那種語義,並不是直接的“對映”在這程式碼上面的。比如“失敗”這個詞,對應了程式碼裡的哪一個字呢?你找不出來,因為它包含在了||的語義裡面,你需要知道||的短路特性,以及邏輯或的語義才能知道這裡面在說“如果action1失敗……”。每一次看到這行程式碼,你都需要思考一下,這樣積累起來的負荷,就會讓人很累。

  其實,這種寫法是濫用了邏輯操作&&和||的短路特性。這兩個操作符可能不執行右邊的表示式,原因是為了機器的執行效率,而不是為了給人提供這種“巧妙”的用法。這兩個操作符的本意,只是作為邏輯操作,它們並不是拿來給你代替if語句的。也就是說,它們只是碰巧可以達到某些if語句的效果,但你不應該因此就用它來代替if語句。如果你這樣做了,就會讓程式碼晦澀難懂。

  上面的程式碼寫成笨一點的辦法,就會清晰很多:

if (!action1()) {
  if (action2()) {
    action3();
  }
}

  這裡我很明顯的看出這程式碼在說什麼,想都不用想:如果action1()失敗了,那麼執行action2(),如果action2()成功了,執行action3()。你發現這裡面的一一對應關係嗎?if=如果,!=失敗,…… 你不需要利用邏輯學知識,就知道它在說什麼。

  寫無懈可擊的程式碼

  在之前一節裡,我提到了自己寫的程式碼裡面很少出現只有一個分支的if語句。我寫出的if語句,大部分都有兩個分支,所以我的程式碼很多看起來是這個樣子:

if (...) {
  if (...) {
    ...
    return false;
  } else {
    return true;
  }
} else if (...) {
  ...
  return false;
} else {
  return true;
}

  使用這種方式,其實是為了無懈可擊的處理所有可能出現的情況,避免漏掉corner case。每個if語句都有兩個分支的理由是:如果if的條件成立,你做某件事情;但是如果if的條件不成立,你應該知道要做什麼另外的事情。不管你的if有沒有else,你終究是逃不掉,必須得思考這個問題的。

  很多人寫if語句喜歡省略else的分支,因為他們覺得有些else分支的程式碼重複了。比如我的程式碼裡,兩個else分支都是return true。為了避免重複,他們省略掉那兩個else分支,只在最後使用一個return true。這樣,缺了else分支的if語句,控制流自動“掉下去”,到達最後的return true。他們的程式碼看起來像這個樣子:

if (...) {
  if (...) {
    ...
    return false;
  } 
} else if (...) {
  ...
  return false;
} 
return true;

  這種寫法看似更加簡潔,避免了重複,然而卻很容易出現疏忽和漏洞。巢狀的if語句省略了一些else,依靠語句的“控制流”來處理else的情況,是很難正確的分析和推理的。如果你的if條件裡使用了&&和||之類的邏輯運算,就更難看出是否涵蓋了所有的情況。

  由於疏忽而漏掉的分支,全都會自動“掉下去”,最後返回意想不到的結果。即使你看一遍之後確信是正確的,每次讀這段程式碼,你都不能確信它照顧了所有的情況,又得重新推理一遍。這簡潔的寫法,帶來的是反覆的,沉重的頭腦開銷。這就是所謂“麵條程式碼”,因為程式的邏輯分支,不是像一棵枝葉分明的樹,而是像麵條一樣繞來繞去。

  正確處理錯誤

  使用有兩個分支的if語句,只是我的程式碼可以達到無懈可擊的其中一個原因。這樣寫if語句的思路,其實包含了使程式碼可靠的一種通用思想:窮舉所有的情況,不漏掉任何一個。

  程式的絕大部分功能,是進行資訊處理。從一堆紛繁複雜,模稜兩可的資訊中,排除掉絕大部分“干擾資訊”,找到自己需要的那一個。正確地對所有的“可能性”進行推理,就是寫出無懈可擊程式碼的核心思想。這一節我來講一講,如何把這種思想用在錯誤處理上。

  錯誤處理是一個古老的問題,可是經過了幾十年,還是很多人沒搞明白。Unix的系統API手冊,一般都會告訴你可能出現的返回值和錯誤資訊。比如,Linux的read系統呼叫手冊裡面有如下內容:

RETURN VALUE
On success, the number of bytes read is returned...
On error, -1 is returned, and errno is set appropriately.

  ERRORS EAGAIN, EBADF, EFAULT, EINTR, EINVAL, ...

  很多初學者,都會忘記檢查read的返回值是否為-1,覺得每次呼叫read都得檢查返回值真繁瑣,不檢查貌似也相安無事。這種想法其實是很危險的。如果函式的返回值告訴你,要麼返回一個正數,表示讀到的資料長度,要麼返回-1,那麼你就必須要對這個-1作出相應的,有意義的處理。千萬不要以為你可以忽視這個特殊的返回值,因為它是一種“可能性”。程式碼漏掉任何一種可能出現的情況,都可能產生意想不到的災難性結果。

  對於Java來說,這相對方便一些。Java的函式如果出現問題,一般通過異常(exception)來表示。你可以把異常加上函式本來的返回值,看成是一個“union型別”。比如:

String foo() throws MyException {
  ...
}

  這裡MyException是一個錯誤返回。你可以認為這個函式返回一個union型別:{String, MyException}。任何呼叫foo的程式碼,必須對MyException作出合理的處理,才有可能確保程式的正確執行。Union型別是一種相當先進的型別,目前只有極少數語言(比如Typed Racket)具有這種型別,我在這裡提到它,只是為了方便解釋概念。掌握了概念之後,你其實可以在頭腦裡實現一個union型別系統,這樣使用普通的語言也能寫出可靠的程式碼。

  由於Java的型別系統強制要求函式在型別裡面宣告可能出現的異常,而且強制呼叫者處理可能出現的異常,所以基本上不可能出現由於疏忽而漏掉的情況。但有些Java程式設計師有一種惡習,使得這種安全機制幾乎完全失效。每當編譯器報錯,說“你沒有catch這個foo函式可能出現的異常”時,有些人想都不想,直接把程式碼改成這樣:

try {
  foo();
} catch (Exception e) {}

  或者最多在裡面放個log,或者乾脆把自己的函式型別上加上throws Exception,這樣編譯器就不再抱怨。這些做法貌似很省事,然而都是錯誤的,你終究會為此付出代價。

  如果你把異常catch了,忽略掉,那麼你就不知道foo其實失敗了。這就像開車時看到路口寫著“前方施工,道路關閉”,還繼續往前開。這當然遲早會出問題,因為你根本不知道自己在幹什麼。

  catch異常的時候,你不應該使用Exception這麼寬泛的型別。你應該正好catch可能發生的那種異常A。使用寬泛的異常型別有很大的問題,因為它會不經意的catch住另外的異常(比如B)。你的程式碼邏輯是基於判斷A是否出現,可你卻catch所有的異常(Exception類),所以當其它的異常B出現的時候,你的程式碼就會出現莫名其妙的問題,因為你以為A出現了,而其實它沒有。這種bug,有時候甚至使用debugger都難以發現。

  如果你在自己函式的型別加上throws Exception,那麼你就不可避免的需要在呼叫它的地方處理這個異常,如果呼叫它的函式也寫著throws Exception,這毛病就傳得更遠。我的經驗是,儘量在異常出現的當時就作出處理。否則如果你把它返回給你的呼叫者,它也許根本不知道該怎麼辦了。

  另外,try { ... } catch裡面,應該包含儘量少的程式碼。比如,如果foo和bar都可能產生異常A,你的程式碼應該儘可能寫成:

try {
  foo();
} catch (A e) {...}

try {
  bar();
} catch (A e) {...}

  而不是

try {
  foo();
  bar();
} catch (A e) {...}

  第一種寫法能明確的分辨是哪一個函式出了問題,而第二種寫法全都混在一起。明確的分辨是哪一個函式出了問題,有很多的好處。比如,如果你的catch程式碼裡面包含log,它可以提供給你更加精確的錯誤資訊,這樣會大大地加速你的除錯過程。

  正確處理null指標

  窮舉的思想是如此的有用,依據這個原理,我們可以推出一些基本原則,它們可以讓你無懈可擊的處理null指標。

  首先你應該知道,許多語言(C,C++,Java,C#,……)的型別系統對於null的處理,其實是完全錯誤的。這個錯誤源自於Tony Hoare最早的設計,Hoare把這個錯誤稱為自己的“billion dollar mistake”,因為由於它所產生的財產和人力損失,遠遠超過十億美元!

  這些語言的型別系統允許null出現在任何物件(指標)型別可以出現的地方,然而null其實根本不是一個合法的物件。它不是一個String,不是一個Integer,也不是一個自定義的類。null的型別本來應該是NULL,也就是null自己。根據這個基本觀點,我們推匯出以下原則:

  • 儘量不要產生null指標。儘量不要用null來初始化變數,函式儘量不要返回null。如果你的函式要返回“沒有”,“出錯了”之類的結果,儘量使用Java的異常機制。雖然寫法上有點彆扭,然而Java的異常,和函式的返回值合併在一起,基本上可以當成union型別來用。比如,如果你有一個函式find,可以幫你找到一個String,也有可能什麼也找不到,你可以這樣寫:

    public String find() throws NotFoundException {
      if (...) {
        return "found";
      } else {
        throw new NotFoundException();
      }
    }

    Java的型別系統會強制你catch這個NotFoundException,所以你不可能像漏掉檢查null一樣,漏掉這種情況。Java的異常也是一個比較容易濫用的東西,不過我已經在上一節告訴你如何正確的使用異常。

  • 不要把null放進“容器資料結構”裡面。所謂容器(collection),是指一些物件以某種方式集合在一起,所以null不應該被放進Array,List,Set等結構,不應該出現在Map的key或者value裡面。把null放進容器裡面,是一些莫名其妙錯誤的來源。因為物件在容器裡的位置一般是動態決定的,所以一旦null從某個入口跑進去了,你就很難再搞明白它去了哪裡,你就得被迫在所有從這個容器裡取值的位置檢查null。你也很難知道到底是誰把它放進去的,程式碼多了就導致除錯極其困難。

    解決方案是:如果你真要表示“沒有”,那你就乾脆不要把它放進去(Array,List,Set沒有元素,Map根本沒那個entry),或者你可以指定一個特殊的,真正合法的物件,用來表示“沒有”。

    需要指出的是,類物件並不屬於容器。所以null在必要的時候,可以作為物件成員的值,表示它不存在。比如:

    class A {
      String name = null;
      ...
    }

    之所以可以這樣,是因為null只可能在A物件的name成員裡出現,你不用懷疑其它的成員因此成為null。所以你每次訪問name成員時,檢查它是否是null就可以了,不需要對其他成員也做同樣的檢查。

  • 函式呼叫者:明確理解null所表示的意義,儘早檢查和處理null返回值,減少它的傳播。null很討厭的一個地方,在於它在不同的地方可能表示不同的意義。有時候它表示“沒有”,“沒找到”,有時候它表示“出錯了”,“失敗了”…… 你必須理解每一個null的意義,不能給混淆起來。

    如果你呼叫的函式有可能返回null,那麼你應該在第一時間對null做出“有意義”的處理。比如,上述的函式find,返回null表示“沒找到”,那麼呼叫find的程式碼就應該在它返回的第一時間,檢查返回值是否是null,並且對“沒找到”這種情況,作出有意義的處理。

    “有意義”是什麼意思呢?我的意思是,使用這函式的人,應該明確的知道在拿到null的情況下該怎麼做,承擔起責任來。他不應該只是“向上級彙報”,把責任踢給自己的呼叫者。如果你違反了這一點,就有可能採用一種不負責任,危險的寫法:

    public String foo() {
      String found = find();
      if (found == null) {
        return null;
      }
    }

    當看到find()返回了null,foo自己也返回null。這樣null就從一個地方,遊走到了另一個地方。如果你不假思索就寫出這樣的程式碼,最後的結果就是程式碼裡面隨時隨地都可能出現null。到後來為了保護自己,你的每個函式都會寫成這樣:

    public void foo(A a, B b, C c) {
      if (a == null) { ... }
      if (b == null) { ... }
      if (c == null) { ... }
      ...
    }
  • 函式作者:明確宣告不接受null引數,當引數是null時立即崩潰。不要試圖對null進行“容錯”,不要讓程式繼續往下執行。如果呼叫者使用了null作為引數,那麼呼叫者(而不是函式作者)應該對程式的崩潰負全責。上面的例子之所以成為問題,就在於人們對於null的“容忍態度”。

    上面這種“保護式”的寫法,試圖“容錯”,試圖“優雅的處理null”,其結果是讓呼叫者更加肆無忌憚的傳遞null給你的函式。到後來,你的程式碼裡出現一堆堆nonsense的情況,null可以在任何地方出現,都不知道到底是哪裡產生出來的。誰也不知道出現了null是什麼意思,該做什麼,所有人都把null踢給其他人。最後這null像瘟疫一樣蔓延開來,到處都是,成為一場噩夢。

    正確的做法,其實是強硬的態度。你要告訴函式的使用者,我的引數全都不能是null,如果你給我null,程式崩潰了該你自己負責!至於呼叫者程式碼裡有null怎麼辦,他自己該知道怎麼處理(參考以上幾條),不應該由函式作者來操心。

  • 使用@NotNull和@Nullable標記。IntelliJ提供了@NotNull和@Nullable兩種標記,加在型別前面,這樣可以比較可靠地防止null指標的出現。IntelliJ本身會對含有這種標記的程式碼進行靜態分析,指出執行時可能出現NullPointerException的地方。在執行時,會在null指標不該出現的地方產生IllegalArgumentException,即使那個null指標你從來沒有deference。這樣你可以在儘量早期發現並且防止null指標的出現。

  擴充套件話題:關於Optional型別和Union型別

  有些語言,比如Java 8和Swift,提供了一種叫“Optional型別”的東西。比如在Java 8裡面,你可以使用Optional<String>來表示“可能是String,可能沒有”。很多人以為有了Optional型別,就可以完美的解決null指標的問題,然而它並不是想象的那樣完美。

  因為你看到的型別是Optional<String>,而不是String,所以型別系統不允許你直接把它當String來用。這多出來的一層關卡,可以防止你不問三七二一就取它的值,你總要想一下。然而這並不能從根本上解決問題。Optional並不能完全阻止你產生跟NullPointerException等價的執行時錯誤。因為你仍然可以寫這樣的程式碼:

Optional<String> x = Optional.empty();
String y = x.get();

  沒有檢查x.isPresent()就使用x.get(),結果出現NoSuchElementException。這其實等價於沒有檢查null就在dereference它。只不過現在出現的不是NullPointerException,而是NoSuchElementException。兩個都是執行時錯誤,換湯不換藥,程式照樣崩潰。所以你看到了,Optional只是一種善意的“提示”,它使你不會在完全不知情的情況下犯錯誤。可是如果你忽略這種提示,照樣可以犯一樣的錯誤。Optional並沒有任何強制性的力量。

  Swift的Optional型別跟Java的是一樣的問題,Swift的手冊裡指出:“Using the ! operator to unwrap an optional that has a value of nil results in a runtime error.” 所以,Swift並不能靜態地阻止你對一個值為nil的Optional進行!操作。如果你做了,就會產生“執行時錯誤”。

  另外,Optional型別會導致程式變得複雜。Optional和null指標,在結構上有一個很大的差別。Optional比null指標多了一層資料結構。Optional把需要的值放在了另外一個物件裡面。你必須用x.get()來得到裡面這個值,這跟使用null的時候很不一樣。當你判斷了一個String不可能是null,你不需要再做一次get把內容給取出來。比如:

String found = find();
if (found != null) {
  total += found.length();
}

  判斷found不是null之後,我們可以直接用found.length()得到它的長度,而不需要先使用found.get()。這個例子貌似小事,然而如果Optional型別被放進另外的結構或者容器裡面,或者包含了另外型別,你就知道它的繁瑣和痛苦了。Optional的這個問題,跟Haskell的Maybe型別的問題一樣,經常導致型別巢狀層數太多,太煩。

  相比之下,union型別系統可以完全靜態地防止NullPointerException,而不導致型別的過度巢狀。Union型別可以完全的涵蓋Optional型別的功能,非常的簡單,而且有很多其它的好處。這種型別系統已經存在於Typed Racket語言(一個Scheme的後代),還沒有面世的Yin語言也實現了union型別。PySonar的型別推導系統裡面也具有union型別。Union型別系統非常強大,它不但可以完全靜態地消滅NullPointerException,而且可以取代Java等語言的exception機制。它讓錯誤處理變得非常嚴密,卻又非常方便。

  不過需要注意的是,就算你有了union型別系統,完全靜態地防止了NullPointerException,上面提到的幾條對待null的原則仍然是有用的。在有union型別的語言裡面,一個容易犯的錯誤是不假思索的擴充套件union型別,把什麼可能性都加進去,結果最後得到很大的union型別。這導致很多變數和引數具有union型別,每個變數都有可能是好多種東西,以至於你需要做好幾個判斷才能通過型別檢查。這種現象跟null指標的泛濫的問題並沒有本質的區別,因為你沒能有效地控制住“可能性”。這個“可能性爆炸”的問題,程式語言也許不能給你很好的幫助。只有靠自己,遵循上面的原則,儘早排除union型別或者減少其中的可能性,你才能避免這種混亂。

  防止過度工程

  人的腦子真是奇妙的東西。雖然大家都知道過度工程(over-engineering)不好,在實際的工程中卻經常不由自主的出現過度工程。所以我覺得必須分析一下過度工程出現的訊號和兆頭,在初期的時候就避免它。

  過度工程即將出現的一個重要訊號,就是當你過度的思考“將來”,考慮一些還沒有發生的事情,還沒有出現的需求。比如,“如果我們將來有了上百萬行程式碼,有了幾千號人,這樣的工具就支援不了了”,“將來我可能需要這個功能,所以我現在就把程式碼寫來放在那裡”,“將來很多人要擴充這片程式碼,所以現在我們就讓它變得可重用”……

  這就是為什麼很多軟體專案如此複雜。實際上沒做多少事情,卻為了所謂的“將來”,加入了很多不必要的複雜性。眼前的問題還沒解決呢,就被“將來”給拖垮了。人們都不喜歡目光短淺的人,然而在現實的工程中,有時候你就是得看近一點,把手頭的問題先搞定了,再談以後擴充套件的問題。

  另外一種過度工程的來源,是過度的關心“程式碼重用”。很多人“可用”的程式碼還沒寫出來呢,就在關心“重用”。為了讓程式碼可以重用,最後被自己搞出來的各種框架捆住手腳,最後連可用的程式碼就沒寫好。如果可用的程式碼都寫不好,又何談重用呢?很多一開頭就考慮太多重用的工程,到後來被人完全拋棄,沒人用了,因為別人發現這些程式碼太難懂了,自己從頭開始寫一個,反而省好多事。

  過度地關心“測試”,也會引起過度工程。有些人為了測試,把本來很簡單的程式碼改成“方便測試”的形式,結果引入很多複雜性,以至於本來一下就能寫對的程式碼,最後複雜不堪,出現很多bug。

  世界上有兩種“沒有bug”的程式碼。一種是“沒有明顯的bug的程式碼”,另一種是“明顯沒有bug的程式碼”。第一種情況,由於程式碼複雜不堪,加上很多測試,各種coverage,貌似測試都通過了,所以就認為程式碼是正確的。第二種情況,由於程式碼簡單直接,就算沒寫很多測試,你一眼看去就知道它不可能有bug。你喜歡哪一種“沒有bug”的程式碼呢?

  根據這些,我總結出來的防止過度工程的原則如下:

  1. 先把眼前的問題解決掉,解決好,再考慮將來的擴充套件問題。
  2. 先寫出可用的程式碼,反覆推敲,再考慮是否需要重用的問題。
  3. 先寫出可用,簡單,明顯沒有bug的程式碼,再考慮測試的問題。

相關文章