王垠:編程的智慧

最近偶然重讀到王垠在 2015 年的一篇舊文,與幾年前初讀時比,有着很不一樣的感受。文章比較長,有 2 萬字左右。主要舉例的是 Java 的語法,但思想和經驗對於其它語言,多數是相通的。相信這篇文章對大家也很有借鑑意義,特此分享。

轉自:Python 貓

作者:王垠

來源:http://www.yinwang.org/blog-cn/2015/11/21/programming-philosophy

編程是一種創造性的工作,是一門藝術。精通任何一門藝術,都需要很多的練習和領悟,所以這裏提出的 “智慧”,並不是號稱一天瘦十斤的減肥藥,它並不能代替你自己的勤奮。然而由於軟件行業喜歡標新立異,喜歡把簡單的事情搞複雜,我希望這些文字能給迷惑中的人們指出一些正確的方向,讓他們少走一些彎路,基本做到一分耕耘一分收穫。

反覆推敲代碼

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

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

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

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

寫優雅的代碼

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

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

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

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

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

寫模塊化的代碼

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

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

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

寫可讀的代碼

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

實際上,真正優雅可讀的代碼,是幾乎不需要註釋的。如果你發現需要寫很多註釋,那麼你的代碼肯定是含混晦澀,邏輯不清晰的。其實,程序語言相比自然語言,是更加強大而嚴謹的,它其實具有自然語言最主要的元素:主語,謂語,賓語,名詞,動詞,如果,那麼,否則,是,不是,…… 所以如果你充分利用了程序語言的表達能力,你完全可以用程序本身來表達它到底在幹什麼,而不需要自然語言的輔助。

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

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

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

    // put elephant1 into fridge2
    put(elephant1, fridge2);

    由於我的函數名put,加上兩個有意義的變量名elephant1fridge2,已經說明了這是在幹什麼(把大象放進冰箱),所以上面那句註釋完全沒有必要。

  2. 局部變量應該儘量接近使用它的地方。有些人喜歡在函數最開頭定義很多局部變量,然後在下面很遠的地方使用它,就像這個樣子:

    void foo() {
      int index = ...;
      ...
      ...
      bar(index);
      ...
    }

    由於這中間都沒有使用過index,也沒有改變過它所依賴的數據,所以這個變量定義,其實可以挪到接近使用它的地方:

    void foo() {
      ...
      ...
      int index = ...;
      bar(index);
      ...
    }

    這樣讀者看到bar(index),不需要向上看很遠就能發現index是如何算出來的。而且這種短距離,可以加強讀者對於這裏的 “計算順序” 的理解。否則如果 index 在頂上,讀者可能會懷疑,它其實保存了某種會變化的數據,或者它後來又被修改過。如果 index 放在下面,讀者就清楚的知道,index 並不是保存了什麼可變的值,而且它算出來之後就沒變過。

    如果你看透了局部變量的本質——它們就是電路里的導線,那你就能更好的理解近距離的好處。變量定義離用的地方越近,導線的長度就越短。你不需要摸着一根導線,繞來繞去找很遠,就能發現接收它的端口,這樣的電路就更容易理解。

  3. 局部變量名字應該簡短。這貌似跟第一點相沖突,簡短的變量名怎麼可能有意義呢?注意我這裏說的是局部變量,因爲它們處於局部,再加上第 2 點已經把它放到離使用位置儘量近的地方,所以根據上下文你就會容易知道它的意思:

    比如,你有一個局部變量,表示一個操作是否成功:

    boolean successInDeleteFile = deleteFile("foo.txt");
    if (successInDeleteFile) {
      ...
    } else {
      ...
    }

    這個局部變量successInDeleteFile大可不必這麼囉嗦。因爲它只用過一次,而且用它的地方就在下面一行,所以讀者可以輕鬆發現它是deleteFile返回的結果。如果你把它改名爲success,其實讀者根據一點上下文,也知道它表示”success in deleteFile”。所以你可以把它改成這樣:

    boolean success = deleteFile("foo.txt");
    if (success) {
      ...
    } else {
      ...
    }

    這樣的寫法不但沒漏掉任何有用的語義信息,而且更加易讀。successInDeleteFile這種 “camelCase”,如果超過了三個單詞連在一起,其實是很礙眼的東西。所以如果你能用一個單詞表示同樣的意義,那當然更好。

  4. 不要重用局部變量。很多人寫代碼不喜歡定義新的局部變量,而喜歡 “重用” 同一個局部變量,通過反覆對它們進行賦值,來表示完全不同意思。比如這樣寫:

    String msg;
    if (...) {
      msg = "succeed";
      log.info(msg);
    } else {
      msg = "failed";
      log.info(msg);
    }

    雖然這樣在邏輯上是沒有問題的,然而卻不易理解,容易混淆。變量msg兩次被賦值,表示完全不同的兩個值。它們立即被log.info使用,沒有傳遞到其它地方去。這種賦值的做法,把局部變量的作用域不必要的增大,讓人以爲它可能在將來改變,也許會在其它地方被使用。更好的做法,其實是定義兩個變量:

    if (...) {
      String msg = "succeed";
      log.info(msg);
    } else {
      String msg = "failed";
      log.info(msg);
    }

    由於這兩個msg變量的作用域僅限於它們所處的 if 語句分支,你可以很清楚的看到這兩個msg被使用的範圍,而且知道它們之間沒有任何關係。

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

    ...
    // put elephant1 into fridge2
    openDoor(fridge2);
    if (elephant1.alive()) {
      ...
    } else {
       ...
    }
    closeDoor(fridge2);
    ...

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

    void put(Elephant elephant, Fridge fridge) {
      openDoor(fridge);
      if (elephant.alive()) {
        ...
      } else {
         ...
      }
      closeDoor(fridge);
    }

    這樣原來的代碼就可以改成:

    ...
    put(elephant1, fridge2);
    ...

    更加清晰,而且註釋也沒必要了。

  6. 把複雜的表達式提取出去,做成中間變量。有些人聽說 “函數式編程” 是個好東西,也不理解它的真正含義,就在代碼裏大量使用嵌套的函數。像這樣:

    Pizza pizza = makePizza(crust(salt(), butter()),
       topping(onion(), tomato(), sausage()));

    這樣的代碼一行太長,而且嵌套太多,不容易看清楚。其實訓練有素的函數式程序員,都知道中間變量的好處,不會盲目的使用嵌套的函數。他們會把這代碼變成這樣:

    Crust crust = crust(salt(), butter());
    Topping topping = topping(onion(), tomato(), sausage());
    Pizza pizza = makePizza(crust, topping);

    這樣寫,不但有效地控制了單行代碼的長度,而且由於引入的中間變量具有 “意義”,步驟清晰,變得很容易理解。

  7. 在合理的地方換行。對於絕大部分的程序語言,代碼的邏輯是和空白字符無關的,所以你可以在幾乎任何地方換行,你也可以不換行。這樣的語言設計是個好東西,因爲它給了程序員自由控制自己代碼格式的能力。然而,它也引起了一些問題,因爲很多人不知道如何合理的換行。

有些人喜歡利用 IDE 的自動換行機制,編輯之後用一個熱鍵把整個代碼重新格式化一遍,IDE 就會把超過行寬限制的代碼自動折行。可是這種自動這行,往往沒有根據代碼的邏輯來進行,不能幫助理解代碼。自動換行之後可能產生這樣的代碼:

   if (someLongCondition1() && someLongCondition2() && someLongCondition3() &&
     someLongCondition4()) {
     ...
   }

由於someLongCondition4()超過了行寬限制,被編輯器自動換到了下面一行。雖然滿足了行寬限制,換行的位置卻是相當任意的,它並不能幫助人理解這代碼的邏輯。這幾個 boolean 表達式,全都用&&連接,所以它們其實處於平等的地位。爲了表達這一點,當需要折行的時候,你應該把每一個表達式都放到新的一行,就像這個樣子:

   if (someLongCondition1() &&
       someLongCondition2() &&
       someLongCondition3() &&
       someLongCondition4()) {
     ...
   }

這樣每一個條件都對齊,裏面的邏輯就很清楚了。再舉個例子:

   log.info("failed to find file {} for command {}, with exception {}", file, command,
     exception);

這行因爲太長,被自動折行成這個樣子。filecommandexception本來是同一類東西,卻有兩個留在了第一行,最後一個被折到第二行。它就不如手動換行成這個樣子:

   log.info("failed to find file {} for command {}, with exception {}",
     file, command, exception);

把格式字符串單獨放在一行,而把它的參數一併放在另外一行,這樣邏輯就更加清晰。

爲了避免 IDE 把這些手動調整好的換行弄亂,很多 IDE(比如 IntelliJ)的自動格式化設定裏都有 “保留原來的換行符” 的設定。如果你發現 IDE 的換行不符合邏輯,你可以修改這些設定,然後在某些地方保留你自己的手動換行。

說到這裏,我必須警告你,這裏所說的 “不需註釋,讓代碼自己解釋自己”,並不是說要讓代碼看起來像某種自然語言。有個叫 Chai 的 JavaScript 測試工具,可以讓你這樣寫代碼:

expect(foo).to.be.a('string');
expect(foo).to.equal('bar');
expect(foo).to.have.length(3);
expect(tea).to.have.property('flavors').with.length(3);

這種做法是極其錯誤的。程序語言本來就比自然語言簡單清晰,這種寫法讓它看起來像自然語言的樣子,反而變得複雜難懂了。

寫簡單的代碼

程序語言都喜歡標新立異,提供這樣那樣的 “特性”,然而有些特性其實並不是什麼好東西。很多特性都經不起時間的考驗,最後帶來的麻煩,比解決的問題還多。很多人盲目的追求“短小” 和“精悍”,或者爲了顯示自己頭腦聰明,學得快,所以喜歡利用語言裏的一些特殊構造,寫出過於“聰明”,難以理解的代碼。

並不是語言提供什麼,你就一定要把它用上的。實際上你只需要其中很小的一部分功能,就能寫出優秀的代碼。我一向反對 “充分利用” 程序語言裏的所有特性。實際上,我心目中有一套最好的構造。不管語言提供了多麼 “神奇” 的,“新”的特性,我基本都只用經過千錘百煉,我覺得值得信賴的那一套。

現在針對一些有問題的語言特性,我介紹一些我自己使用的代碼規範,並且講解一下爲什麼它們能讓代碼更簡單。

  1. 如果出現了 continue,你往往只需要把 continue 的條件反向,就可以消除 continue。

  2. 如果出現了 break,你往往可以把 break 的條件,合併到循環頭部的終止條件裏,從而去掉 break。

  3. 有時候你可以把 break 替換成 return,從而去掉 break。

  4. 如果以上都失敗了,你也許可以把循環裏面複雜的部分提取出來,做成函數調用,之後 continue 或者 break 就可以去掉了。

寫直觀的代碼

我寫代碼有一條重要的原則:如果有更加直接,更加清晰的寫法,就選擇它,即使它看起來更長,更笨,也一樣選擇它。比如,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 條件裏使用了&&||之類的邏輯運算,就更難看出是否涵蓋了所有的情況。

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

另外一種省略 else 分支的情況是這樣:

String s = "";
if (x < 5) {
  s = "ok";
}

寫這段代碼的人,腦子裏喜歡使用一種 “缺省值” 的做法。s缺省爲 null,如果 x<5,那麼把它改變(mutate)成 “ok”。這種寫法的缺點是,當x<5不成立的時候,你需要往上面看,才能知道 s 的值是什麼。這還是你運氣好的時候,因爲 s 就在上面不遠。很多人寫這種代碼的時候,s 的初始值離判斷語句有一定的距離,中間還有可能插入一些其它的邏輯和賦值操作。這樣的代碼,把變量改來改去的,看得人眼花,就容易出錯。

現在比較一下我的寫法:

String s;
if (x < 5) {
  s = "ok";
} else {
  s = "";
}

這種寫法貌似多打了一兩個字,然而它卻更加清晰。這是因爲我們明確的指出了x<5不成立的時候,s 的值是什麼。它就擺在那裏,它是""(空字符串)。注意,雖然我也使用了賦值操作,然而我並沒有 “改變”s 的值。s 一開始的時候沒有值,被賦值之後就再也沒有變過。我的這種寫法,通常被叫做更加 “函數式”,因爲我只賦值一次。

如果我漏寫了 else 分支,Java 編譯器是不會放過我的。它會抱怨:“在某個分支,s 沒有被初始化。” 這就強迫我清清楚楚的設定各種條件下 s 的值,不漏掉任何一種情況。

當然,由於這個情況比較簡單,你還可以把它寫成這樣:

String s = x < 5 ? "ok" : "";

對於更加複雜的情況,我建議還是寫成 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 裏面,應該包含儘量少的代碼。比如,如果foobar都可能產生異常 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 自己。根據這個基本觀點,我們推導出以下原則:

防止過度工程

人的腦子真是奇妙的東西。雖然大家都知道過度工程(over-engineering)不好,在實際的工程中卻經常不由自主的出現過度工程。我自己也犯過好多次這種錯誤,所以覺得有必要分析一下,過度工程出現的信號和兆頭,這樣可以在初期的時候就及時發現並且避免。

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

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

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

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

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

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

  1. 先把眼前的問題解決掉,解決好,再考慮將來的擴展問題。

  2. 先寫出可用的代碼,反覆推敲,再考慮是否需要重用的問題。

  3. 先寫出可用,簡單,明顯沒有 bug 的代碼,再考慮測試的問題。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/gY-4Ip76lCOvypQGNVtN2A