王垠:編程的智慧
最近偶然重讀到王垠在 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 裏面。結果這樣的作法並沒有帶來合作的流暢,而是帶來了許多的麻煩。這是因爲他們其實並不理解什麼叫做 “模塊”,膚淺的把代碼切割開來,分放在不同的位置,其實非但不能達到模塊化的目的,而且製造了不必要的麻煩。
真正的模塊化,並不是文本意義上的,而是邏輯意義上的。一個模塊應該像一個電路芯片,它有定義良好的輸入和輸出。實際上一種很好的模塊化方法早已經存在,它的名字叫做 “函數”。每一個函數都有明確的輸入(參數)和輸出(返回值),同一個文件裏可以包含多個函數,所以你其實根本不需要把代碼分開在多個文件或者目錄裏面,同樣可以完成代碼的模塊化。我可以把代碼全都寫在同一個文件裏,卻仍然是非常模塊化的代碼。
想要達到很好的模塊化,你需要做到以下幾點:
-
避免寫太長的函數。如果發現函數太大了,就應該把它拆分成幾個更小的。通常我寫的函數長度都不超過 40 行。對比一下,一般筆記本電腦屏幕所能容納的代碼行數是 50 行。我可以一目瞭然的看見一個 40 行的函數,而不需要滾屏。只有 40 行而不是 50 行的原因是,我的眼球不轉的話,最大的視角只看得到 40 行代碼。
如果我看代碼不轉眼球的話,我就能把整片代碼完整的映射到我的視覺神經裏,這樣就算忽然閉上眼睛,我也能看得見這段代碼。我發現閉上眼睛的時候,大腦能夠更加有效地處理代碼,你能想象這段代碼可以變成什麼其它的形狀。40 行並不是一個很大的限制,因爲函數里面比較複雜的部分,往往早就被我提取出去,做成了更小的函數,然後從原來的函數里面調用。
-
製造小的工具函數。如果你仔細觀察代碼,就會發現其實裏面有很多的重複。這些常用的代碼,不管它有多短,提取出去做成函數,都可能是會有好處的。有些幫助函數也許就只有兩行,然而它們卻能大大簡化主要函數里面的邏輯。
有些人不喜歡使用小的函數,因爲他們想避免函數調用的開銷,結果他們寫出幾百行之大的函數。這是一種過時的觀念。現代的編譯器都能自動的把小的函數內聯(inline)到調用它的地方,所以根本不產生函數調用,也就不會產生任何多餘的開銷。
同樣的一些人,也愛使用宏(macro)來代替小函數,這也是一種過時的觀念。在早期的 C 語言編譯器裏,只有宏是靜態 “內聯” 的,所以他們使用宏,其實是爲了達到內聯的目的。然而能否內聯,其實並不是宏與函數的根本區別。宏與函數有着巨大的區別(這個我以後再講),應該儘量避免使用宏。爲了內聯而使用宏,其實是濫用了宏,這會引起各種各樣的麻煩,比如使程序難以理解,難以調試,容易出錯等等。
-
每個函數只做一件簡單的事情。有些人喜歡製造一些 “通用” 的函數,既可以做這個又可以做那個,它的內部依據某些變量和條件,來 “選擇” 這個函數所要做的事情。比如,你也許寫出這樣的函數:
void foo() { if (getOS().equals("MacOS")) { a(); } else { b(); } c(); if (getOS().equals("MacOS")) { d(); } else { e(); } }
寫這個函數的人,根據系統是否爲 “MacOS” 來做不同的事情。你可以看出這個函數里,其實只有
c()
是兩種系統共有的,而其它的a()
,b()
,d()
,e()
都屬於不同的分支。這種 “複用” 其實是有害的。如果一個函數可能做兩種事情,它們之間共同點少於它們的不同點,那你最好就寫兩個不同的函數,否則這個函數的邏輯就不會很清晰,容易出現錯誤。其實,上面這個函數可以改寫成兩個函數:
void fooMacOS() { a(); c(); d(); }
和
void fooOther() { b(); c(); e(); }
如果你發現兩件事情大部分內容相同,只有少數不同,多半時候你可以把相同的部分提取出去,做成一個輔助函數。比如,如果你有個函數是這樣:
void foo() { a(); b() c(); if (getOS().equals("MacOS")) { d(); } else { e(); } }
其中
a()
,b()
,c()
都是一樣的,只有d()
和e()
根據系統有所不同。那麼你可以把a()
,b()
,c()
提取出去:void preFoo() { a(); b() c();
然後製造兩個函數:
void fooMacOS() { preFoo(); d(); }
和
void fooOther() { preFoo(); e(); }
這樣一來,我們既共享了代碼,又做到了每個函數只做一件簡單的事情。這樣的代碼,邏輯就更加清晰。
-
避免使用全局變量和類成員(class member)來傳遞信息,儘量使用局部變量和參數。有些人寫代碼,經常用類成員來傳遞信息,就像這樣:
class A { String x; void findX() { ... x = ...; } void foo() { findX(); ... print(x); } }
首先,他使用
findX()
,把一個值寫入成員x
。然後,使用x
的值。這樣,x
就變成了findX
和print
之間的數據通道。由於x
屬於class A
,這樣程序就失去了模塊化的結構。由於這兩個函數依賴於成員 x,它們不再有明確的輸入和輸出,而是依賴全局的數據。findX
和foo
不再能夠離開class A
而存在,而且由於類成員還有可能被其他代碼改變,代碼變得難以理解,難以確保正確性。如果你使用局部變量而不是類成員來傳遞信息,那麼這兩個函數就不需要依賴於某一個 class,而且更加容易理解,不易出錯:
String findX() { ... x = ...; return x; } void foo() { String x = findX(); print(x); }
寫可讀的代碼
有些人以爲寫很多註釋就可以讓代碼更加可讀,然而卻發現事與願違。註釋不但沒能讓代碼變得可讀,反而由於大量的註釋充斥在代碼中間,讓程序變得障眼難讀。而且代碼的邏輯一旦修改,就會有很多的註釋變得過時,需要更新。修改註釋是相當大的負擔,所以大量的註釋,反而成爲了妨礙改進代碼的絆腳石。
實際上,真正優雅可讀的代碼,是幾乎不需要註釋的。如果你發現需要寫很多註釋,那麼你的代碼肯定是含混晦澀,邏輯不清晰的。其實,程序語言相比自然語言,是更加強大而嚴謹的,它其實具有自然語言最主要的元素:主語,謂語,賓語,名詞,動詞,如果,那麼,否則,是,不是,…… 所以如果你充分利用了程序語言的表達能力,你完全可以用程序本身來表達它到底在幹什麼,而不需要自然語言的輔助。
有少數的時候,你也許會爲了繞過其他一些代碼的設計問題,採用一些違反直覺的作法。這時候你可以使用很短註釋,說明爲什麼要寫成那奇怪的樣子。這樣的情況應該少出現,否則這意味着整個代碼的設計都有問題。
如果沒能合理利用程序語言提供的優勢,你會發現程序還是很難懂,以至於需要寫註釋。所以我現在告訴你一些要點,也許可以幫助你大大減少寫註釋的必要:
-
使用有意義的函數和變量名字。如果你的函數和變量的名字,能夠切實的描述它們的邏輯,那麼你就不需要寫註釋來解釋它在幹什麼。比如:
// put elephant1 into fridge2 put(elephant1, fridge2);
由於我的函數名
put
,加上兩個有意義的變量名elephant1
和fridge2
,已經說明了這是在幹什麼(把大象放進冰箱),所以上面那句註釋完全沒有必要。 -
局部變量應該儘量接近使用它的地方。有些人喜歡在函數最開頭定義很多局部變量,然後在下面很遠的地方使用它,就像這個樣子:
void foo() { int index = ...; ... ... bar(index); ... }
由於這中間都沒有使用過
index
,也沒有改變過它所依賴的數據,所以這個變量定義,其實可以挪到接近使用它的地方:void foo() { ... ... int index = ...; bar(index); ... }
這樣讀者看到
bar(index)
,不需要向上看很遠就能發現index
是如何算出來的。而且這種短距離,可以加強讀者對於這裏的 “計算順序” 的理解。否則如果 index 在頂上,讀者可能會懷疑,它其實保存了某種會變化的數據,或者它後來又被修改過。如果 index 放在下面,讀者就清楚的知道,index 並不是保存了什麼可變的值,而且它算出來之後就沒變過。如果你看透了局部變量的本質——它們就是電路里的導線,那你就能更好的理解近距離的好處。變量定義離用的地方越近,導線的長度就越短。你不需要摸着一根導線,繞來繞去找很遠,就能發現接收它的端口,這樣的電路就更容易理解。
-
局部變量名字應該簡短。這貌似跟第一點相沖突,簡短的變量名怎麼可能有意義呢?注意我這裏說的是局部變量,因爲它們處於局部,再加上第 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”,如果超過了三個單詞連在一起,其實是很礙眼的東西。所以如果你能用一個單詞表示同樣的意義,那當然更好。 -
不要重用局部變量。很多人寫代碼不喜歡定義新的局部變量,而喜歡 “重用” 同一個局部變量,通過反覆對它們進行賦值,來表示完全不同意思。比如這樣寫:
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
被使用的範圍,而且知道它們之間沒有任何關係。 -
把複雜的邏輯提取出去,做成 “幫助函數”。有些人寫的函數很長,以至於看不清楚裏面的語句在幹什麼,所以他們誤以爲需要寫註釋。如果你仔細觀察這些代碼,就會發現不清晰的那片代碼,往往可以被提取出去,做成一個函數,然後在原來的地方調用。由於函數有一個名字,這樣你就可以使用有意義的函數名來代替註釋。舉一個例子:
... // 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); ...
更加清晰,而且註釋也沒必要了。
-
把複雜的表達式提取出去,做成中間變量。有些人聽說 “函數式編程” 是個好東西,也不理解它的真正含義,就在代碼裏大量使用嵌套的函數。像這樣:
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);
這樣寫,不但有效地控制了單行代碼的長度,而且由於引入的中間變量具有 “意義”,步驟清晰,變得很容易理解。
-
在合理的地方換行。對於絕大部分的程序語言,代碼的邏輯是和空白字符無關的,所以你可以在幾乎任何地方換行,你也可以不換行。這樣的語言設計是個好東西,因爲它給了程序員自由控制自己代碼格式的能力。然而,它也引起了一些問題,因爲很多人不知道如何合理的換行。
有些人喜歡利用 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);
這行因爲太長,被自動折行成這個樣子。file
,command
和exception
本來是同一類東西,卻有兩個留在了第一行,最後一個被折到第二行。它就不如手動換行成這個樣子:
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);
這種做法是極其錯誤的。程序語言本來就比自然語言簡單清晰,這種寫法讓它看起來像自然語言的樣子,反而變得複雜難懂了。
寫簡單的代碼
程序語言都喜歡標新立異,提供這樣那樣的 “特性”,然而有些特性其實並不是什麼好東西。很多特性都經不起時間的考驗,最後帶來的麻煩,比解決的問題還多。很多人盲目的追求“短小” 和“精悍”,或者爲了顯示自己頭腦聰明,學得快,所以喜歡利用語言裏的一些特殊構造,寫出過於“聰明”,難以理解的代碼。
並不是語言提供什麼,你就一定要把它用上的。實際上你只需要其中很小的一部分功能,就能寫出優秀的代碼。我一向反對 “充分利用” 程序語言裏的所有特性。實際上,我心目中有一套最好的構造。不管語言提供了多麼 “神奇” 的,“新”的特性,我基本都只用經過千錘百煉,我覺得值得信賴的那一套。
現在針對一些有問題的語言特性,我介紹一些我自己使用的代碼規範,並且講解一下爲什麼它們能讓代碼更簡單。
-
避免使用自增減表達式(i++,++i,i–,–i)。這種自增減操作表達式其實是歷史遺留的設計失誤。它們含義蹊蹺,非常容易弄錯。它們把讀和寫這兩種完全不同的操作,混淆纏繞在一起,把語義搞得烏七八糟。含有它們的表達式,結果可能取決於求值順序,所以它可能在某種編譯器下能正確運行,換一個編譯器就出現離奇的錯誤。
其實這兩個表達式完全可以分解成兩步,把讀和寫分開:一步更新 i 的值,另外一步使用 i 的值。比如,如果你想寫
foo(i++)
,你完全可以把它拆成int t = i; i += 1; foo(t);
。如果你想寫foo(++i)
,可以拆成i += 1; foo(i);
拆開之後的代碼,含義完全一致,卻清晰很多。到底更新是在取值之前還是之後,一目瞭然。有人也許以爲 i++ 或者 ++i 的效率比拆開之後要高,這只是一種錯覺。這些代碼經過基本的編譯器優化之後,生成的機器代碼是完全沒有區別的。自增減表達式只有在兩種情況下才可以安全的使用。一種是在 for 循環的 update 部分,比如
for(int i = 0; i < 5; 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,你就應該考慮改寫這個循環。改寫循環的辦法有多種:
下面我對這些情況舉一些例子。
情況 1:下面這段代碼裏面有一個 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 的鏈表裏面……”情況 2:for 和 while 頭部都有一個循環的 “終止條件”,那本來應該是這個循環唯一的退出條件。如果你在循環中間有 break,它其實給這個循環增加了一個退出條件。你往往只需要把這個條件合併到循環頭部,就可以去掉 break。
比如下面這段代碼:
while (condition1) { ... if (condition2) { break; } }
當 condition 成立的時候,break 會退出循環。其實你只需要把 condition2 反轉之後,放到 while 頭部的終止條件,就可以去掉這種 break 語句。改寫後的代碼如下:
while (condition1 && !condition2) { ... }
這種情況表面上貌似只適用於 break 出現在循環開頭或者末尾的時候,然而其實大部分時候,break 都可以通過某種方式,移動到循環的開頭或者末尾。具體的例子我暫時沒有,等出現的時候再加進來。
情況 3:很多 break 退出循環之後,其實接下來就是一個 return。這種 break 往往可以直接換成 return。比如下面這個例子:
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 變量賦值,break 出去,最後才返回。如果循環結束了還沒有 return,那就返回 false,表示沒有找到這樣的名字。使用 return 來代替 break,這樣 break 語句和 result 這個變量,都一併被消除掉了。我曾經見過很多其他使用 continue 和 break 的例子,幾乎無一例外的可以被消除掉,變換後的代碼變得清晰很多。我的經驗是,99% 的 break 和 continue,都可以通過替換成 return 語句,或者翻轉 if 條件的方式來消除掉。剩下的 1% 含有複雜的邏輯,但也可以通過提取一個幫助函數來消除掉。修改之後的代碼變得容易理解,容易確保正確。
-
如果出現了 continue,你往往只需要把 continue 的條件反向,就可以消除 continue。
-
如果出現了 break,你往往可以把 break 的條件,合併到循環頭部的終止條件裏,從而去掉 break。
-
有時候你可以把 break 替換成 return,從而去掉 break。
-
如果以上都失敗了,你也許可以把循環裏面複雜的部分提取出來,做成函數調用,之後 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 裏面,應該包含儘量少的代碼。比如,如果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 ...; } else { throw new NotFoundException(); } }
Java 的類型系統會強制你 catch 這個 NotFoundException,所以你不可能像漏掉檢查 null 一樣,漏掉這種情況。Java 的異常也是一個比較容易濫用的東西,不過我已經在上一節告訴你如何正確的使用異常。
Java 的 try…catch 語法相當的繁瑣和蹩腳,所以如果你足夠小心的話,像
find
這類函數,也可以返回 null 來表示 “沒找到”。這樣稍微好看一些,因爲你調用的時候不必用 try…catch。很多人寫的函數,返回 null 來表示“出錯了”,這其實是對 null 的誤用。“出錯了” 和“沒有”,其實完全是兩碼事。“沒有”是一種很常見,正常的情況,比如查哈希表沒找到,很正常。“出錯了”則表示罕見的情況,本來正常情況下都應該存在有意義的值,偶然出了問題。如果你的函數要表示“出錯了”,應該使用異常,而不是 null。 -
不要 catch NullPointerException。有些人寫代碼很 nice,他們喜歡 “容錯”。首先他們寫一些函數,這些函數里面不大小心,沒檢查 null 指針:
void foo() { String found = find(); int len = found.length(); ... }
當 foo 調用產生了異常,他們不管三七二十一,就把調用的地方改成這樣:
try { foo(); } catch (Exception e) { ... }
這樣當 found 是 null 的時候,NullPointerException 就會被捕獲並且得到處理。這其實是很錯誤的作法。首先,上一節已經提到了,
catch (Exception e)
這種寫法是要絕對避免的,因爲它捕獲所有的異常,包括 NullPointerException。這會讓你意外地捕獲 try 語句裏面出現的 NullPointerException,從而把代碼的邏輯攪得一塌糊塗。另外就算你寫成
catch (NullPointerException e)
也是不可以的。由於 foo 的內部缺少了 null 檢查,纔出現了 NullPointerException。現在你不對症下藥,倒把每個調用它的地方加上 catch,以後你的生活就會越來越苦。正確的做法應該是改動 foo,而不改調用它的代碼。foo 應該被改成這樣:void foo() { String found = find(); if (found != null) { int len = found.length(); ... } else { ... } }
在 null 可能出現的當時就檢查它是否是 null,然後進行相應的處理。
-
不要把 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 怎麼辦,他自己該知道怎麼處理(參考以上幾條),不應該由函數作者來操心。
採用強硬態度一個很簡單的做法是使用
Objects.requireNonNull()
。它的定義很簡單:public static <T> T requireNonNull(T obj) { if (obj == null) { throw new NullPointerException(); } else { return obj; } }
你可以用這個函數來檢查不想接受 null 的每一個參數,只要傳進來的參數是 null,就會立即觸發
NullPointerException
崩潰掉,這樣你就可以有效地防止 null 指針不知不覺傳遞到其它地方去。 -
使用 @NotNull 和 @Nullable 標記。IntelliJ 提供了 @NotNull 和 @Nullable 兩種標記,加在類型前面,這樣可以比較簡潔可靠地防止 null 指針的出現。IntelliJ 本身會對含有這種標記的代碼進行靜態分析,指出運行時可能出現
NullPointerException
的地方。在運行時,會在 null 指針不該出現的地方產生IllegalArgumentException
,即使那個 null 指針你從來沒有 deference。這樣你可以在儘量早期發現並且防止 null 指針的出現。 -
使用 Optional 類型。Java 8 和 Swift 之類的語言,提供了一種叫 Optional 的類型。正確的使用這種類型,可以在很大程度上避免 null 的問題。null 指針的問題之所以存在,是因爲你可以在沒有 “檢查”null 的情況下,“訪問” 對象的成員。
Optional 類型的設計原理,就是把 “檢查” 和“訪問”這兩個操作合二爲一,成爲一個“原子操作”。這樣你沒法只訪問,而不進行檢查。這種做法其實是 ML,Haskell 等語言裏的模式匹配(pattern matching)的一個特例。模式匹配使得類型判斷和訪問成員這兩種操作合二爲一,所以你沒法犯錯。
比如,在 Swift 裏面,你可以這樣寫:
let found = find() if let content = found { print("found: " + content) }
你從
find()
函數得到一個 Optional 類型的值found
。假設它的類型是String?
,那個問號表示它可能包含一個 String,也可能是 nil。然後你就可以用一種特殊的 if 語句,同時進行 null 檢查和訪問其中的內容。這個 if 語句跟普通的 if 語句不一樣,它的條件不是一個 Bool,而是一個變量綁定let content = found
。我不是很喜歡這語法,不過這整個語句的含義是:如果 found 是 nil,那麼整個 if 語句被略過。如果它不是 nil,那麼變量 content 被綁定到 found 裏面的值(unwrap 操作),然後執行
print("found: " + content)
。由於這種寫法把檢查和訪問合併在了一起,你沒法只進行訪問而不檢查。Java 8 的做法比較蹩腳一些。如果你得到一個
Optional<String>
類型的值 found,你必須使用 “函數式編程” 的方式,來寫這之後的代碼:Optional<String> found = find(); found.ifPresent(content -> System.out.println("found: " + content));
這段 Java 代碼跟上面的 Swift 代碼等價,它包含一個 “判斷” 和一個 “取值” 操作。ifPresent 先判斷 found 是否有值(相當於判斷是不是 null)。如果有,那麼將其內容 “綁定” 到 lambda 表達式的 content 參數(unwrap 操作),然後執行 lambda 裏面的內容,否則如果 found 沒有內容,那麼 ifPresent 裏面的 lambda 不執行。
Java 的這種設計有個問題。判斷 null 之後分支裏的內容,全都得寫在 lambda 裏面。在函數式編程裏,這個 lambda 叫做 “continuation”,Java 把它叫做 “Consumer”,它表示 “如果 found 不是 null,拿到它的值,然後應該做什麼”。由於 lambda 是個函數,你不能在裏面寫
return
語句返回出外層的函數。比如,如果你要改寫下面這個函數(含有 null):public static String foo() { String found = find(); if (found != null) { return found; } else { return ""; } }
就會比較麻煩。因爲如果你寫成這樣:
public static String foo() { Optional<String> found = find(); found.ifPresent(content -> { return content; // can't return from foo here }); return ""; }
裏面的
return a
,並不能從函數foo
返回出去。它只會從 lambda 返回,而且由於那個 lambda(Consumer.accept)的返回類型必須是void
,編譯器會報錯,說你返回了 String。由於 Java 裏 closure 的自由變量是隻讀的,你沒法對 lambda 外面的變量進行賦值,所以你也不能採用這種寫法:public static String foo() { Optional<String> found = find(); String result = ""; found.ifPresent(content -> { result = content; // can't assign to result }); return result; }
所以,雖然你在 lambda 裏面得到了 found 的內容,如何使用這個值,如何返回一個值,卻讓人摸不着頭腦。你平時的那些 Java 編程手法,在這裏幾乎完全廢掉了。實際上,判斷 null 之後,你必須使用 Java 8 提供的一系列古怪的函數式編程操作:
map
,flatMap
,orElse
之類,想法把它們組合起來,才能表達出原來代碼的意思。比如之前的代碼,只能改寫成這樣:public static String foo() { Optional<String> found = find(); return found.orElse(""); }
這簡單的情況還好。複雜一點的代碼,我還真不知道怎麼表達,我懷疑 Java 8 的 Optional 類型的方法,到底有沒有提供足夠的表達力。那裏面少數幾個東西表達能力不咋的,論工作原理,卻可以扯到 functor,continuation,甚至 monad 等高深的理論…… 彷彿用了 Optional 之後,這語言就不再是 Java 了一樣。
所以 Java 雖然提供了 Optional,但我覺得可用性其實比較低,難以被人接受。相比之下,Swift 的設計更加簡單直觀,接近普通的過程式編程。你只需要記住一個特殊的語法
if let content = found {...}
,裏面的代碼寫法,跟普通的過程式語言沒有任何差別。總之你只要記住,使用 Optional 類型,要點在於 “原子操作”,使得 null 檢查與取值合二爲一。這要求你必須使用我剛纔介紹的特殊寫法。如果你違反了這一原則,把檢查和取值分成兩步做,還是有可能犯錯誤。比如在 Java 8 裏面,你可以使用
found.get()
這樣的方式直接訪問 found 裏面的內容。在 Swift 裏你也可以使用found!
來直接訪問而不進行檢查。你可以寫這樣的 Java 代碼來使用 Optional 類型:
Option<String> found = find(); if (found.isPresent()) { System.out.println("found: " + found.get()); }
如果你使用這種方式,把檢查和取值分成兩步做,就可能會出現運行時錯誤。
if (found.isPresent())
本質上跟普通的 null 檢查,其實沒什麼兩樣。如果你忘記判斷found.isPresent()
,直接進行found.get()
,就會出現NoSuchElementException
。這跟NullPointerException
本質上是一回事。所以這種寫法,比起普通的 null 的用法,其實換湯不換藥。如果你要用 Optional 類型而得到它的益處,請務必遵循我之前介紹的 “原子操作” 寫法。
防止過度工程
人的腦子真是奇妙的東西。雖然大家都知道過度工程(over-engineering)不好,在實際的工程中卻經常不由自主的出現過度工程。我自己也犯過好多次這種錯誤,所以覺得有必要分析一下,過度工程出現的信號和兆頭,這樣可以在初期的時候就及時發現並且避免。
過度工程即將出現的一個重要信號,就是當你過度的思考 “將來”,考慮一些還沒有發生的事情,還沒有出現的需求。比如,“如果我們將來有了上百萬行代碼,有了幾千號人,這樣的工具就支持不了了”,“將來我可能需要這個功能,所以我現在就把代碼寫來放在那裏”,“將來很多人要擴充這片代碼,所以現在我們就讓它變得可重用”……
這就是爲什麼很多軟件項目如此複雜。實際上沒做多少事情,卻爲了所謂的 “將來”,加入了很多不必要的複雜性。眼前的問題還沒解決呢,就被“將來” 給拖垮了。人們都不喜歡目光短淺的人,然而在現實的工程中,有時候你就是得看近一點,把手頭的問題先搞定了,再談以後擴展的問題。
另外一種過度工程的來源,是過度的關心 “代碼重用”。很多人“可用” 的代碼還沒寫出來呢,就在關心“重用”。爲了讓代碼可以重用,最後被自己搞出來的各種框架捆住手腳,最後連可用的代碼就沒寫好。如果可用的代碼都寫不好,又何談重用呢?很多一開頭就考慮太多重用的工程,到後來被人完全拋棄,沒人用了,因爲別人發現這些代碼太難懂了,自己從頭開始寫一個,反而省好多事。
過度地關心 “測試”,也會引起過度工程。有些人爲了測試,把本來很簡單的代碼改成“方便測試” 的形式,結果引入很多複雜性,以至於本來一下就能寫對的代碼,最後複雜不堪,出現很多 bug。
世界上有兩種 “沒有 bug” 的代碼。一種是 “沒有明顯的 bug 的代碼”,另一種是“明顯沒有 bug 的代碼”。第一種情況,由於代碼複雜不堪,加上很多測試,各種 coverage,貌似測試都通過了,所以就認爲代碼是正確的。第二種情況,由於代碼簡單直接,就算沒寫很多測試,你一眼看去就知道它不可能有 bug。你喜歡哪一種“沒有 bug” 的代碼呢?
根據這些,我總結出來的防止過度工程的原則如下:
-
先把眼前的問題解決掉,解決好,再考慮將來的擴展問題。
-
先寫出可用的代碼,反覆推敲,再考慮是否需要重用的問題。
-
先寫出可用,簡單,明顯沒有 bug 的代碼,再考慮測試的問題。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/gY-4Ip76lCOvypQGNVtN2A