程式設計是一種創造性的工作,是一門藝術。精通任何一門藝術,都需要很多的練習和領悟,所以這裡提出的“智慧”,並不是號稱一天瘦十斤的減肥藥,它並不能代替你自己的勤奮。然而由於軟體行業喜歡標新立異,喜歡把簡單的事情搞複雜,我希望這些文字能給迷惑中的人們指出一些正確的方向,讓他們少走一些彎路,基本做到一分耕耘一分收穫。
反覆推敲程式碼
既然“天才是百分之一的靈感,百分之九十九的汗水”,那我先來談談這汗水的部分吧。有人問我,提高程式設計水平最有效的辦法是什麼?我想了很久,終於發現最有效的辦法,其實是反反覆覆地修改和推敲程式碼。
在IU的時候,由於Dan Friedman的嚴格教導,我們以寫出冗長複雜的程式碼為恥。如果你程式碼多寫了幾行,這老頑童就會大笑,說:“當年我解決這個問題,只寫了5行程式碼,你回去再想想吧……” 當然,有時候他只是誇張一下,故意刺激你的,其實沒有人能只用5行程式碼完成。然而這種提煉程式碼,減少冗餘的習慣,卻由此深入了我的骨髓。
有些人喜歡炫耀自己寫了多少多少萬行的程式碼,彷彿程式碼的數量是衡量程式設計水平的標準。然而,如果你總是匆匆寫出程式碼,卻從來不回頭去推敲,修改和提煉,其實是不可能提高程式設計水平的。你會製造出越來越多平庸甚至糟糕的程式碼。在這種意義上,很多人所謂的“工作經驗”,跟他程式碼的質量,其實不一定成正比。如果有幾十年的工作經驗,卻從來不回頭去提煉和反思自己的程式碼,那麼他也許還不如一個只有一兩年經驗,卻喜歡反覆推敲,仔細領悟的人。
有位文豪說得好:“看一個作家的水平,不是看他發表了多少文字,而要看他的廢紙簍裡扔掉了多少。” 我覺得同樣的理論適用於程式設計。好的程式設計師,他們刪掉的程式碼,比留下來的還要多很多。如果你看見一個人寫了很多程式碼,卻沒有刪掉多少,那他的程式碼一定有很多垃圾。
就像文學作品一樣,程式碼是不可能一蹴而就的。靈感似乎總是零零星星,陸陸續續到來的。任何人都不可能一筆呵成,就算再厲害的程式設計師,也需要經過一段時間,才能發現最簡單優雅的寫法。有時候你反覆提煉一段程式碼,覺得到了頂峰,沒法再改進了,可是過了幾個月再回頭來看,又發現好多可以改進和簡化的地方。這跟寫文章一模一樣,回頭看幾個月或者幾年前寫的東西,你總能發現一些改進。
所以如果反覆提煉程式碼已經不再有進展,那麼你可以暫時把它放下。過幾個星期或者幾個月再回頭來看,也許就有煥然一新的靈感。這樣反反覆覆很多次之後,你就積累起了靈感和智慧,從而能夠在遇到新問題的時候直接朝正確,或者接近正確的方向前進。
寫優雅的程式碼
人們都討厭“麵條程式碼”(spaghetti code),因為它就像麵條一樣繞來繞去,沒法理清頭緒。那麼優雅的程式碼一般是什麼形狀的呢?經過多年的觀察,我發現優雅的程式碼,在形狀上有一些明顯的特徵。
如果我們忽略具體的內容,從大體結構上來看,優雅的程式碼看起來就像是一些整整齊齊,套在一起的盒子。如果跟整理房間做一個類比,就很容易理解。如果你把所有物品都丟在一個很大的抽屜裡,那麼它們就會全都混在一起。你就很難整理,很難迅速的找到需要的東西。但是如果你在抽屜裡再放幾個小盒子,把物品分門別類放進去,那麼它們就不會到處亂跑,你就可以比較容易的找到和管理它們。
優雅的程式碼的另一個特徵是,它的邏輯大體上看起來,是枝丫分明的樹狀結構(tree)。這是因為程式所做的幾乎一切事情,都是資訊的傳遞和分支。你可以把程式碼看成是一個電路,電流經過導線,分流或者匯合。如果你是這樣思考的,你的程式碼裡就會比較少出現只有一個分支的if語句,它看起來就會像這個樣子:
1 2 3 4 5 6 7 8 9 10 11 |
if (...) { if (...) { ... } else { ... } } else if (...) { ... } else { ... } |
注意到了嗎?在我的程式碼裡面,if語句幾乎總是有兩個分支。它們有可能巢狀,有多層的縮排,而且else分支裡面有可能出現少量重複的程式碼。然而這樣的結構,邏輯卻非常嚴密和清晰。在後面我會告訴你為什麼if語句最好有兩個分支。
寫模組化的程式碼
有些人吵著鬧著要讓程式“模組化”,結果他們的做法是把程式碼分部到多個檔案和目錄裡面,然後把這些目錄或者檔案叫做“module”。他們甚至把這些目錄分放在不同的VCS repo裡面。結果這樣的作法並沒有帶來合作的流暢,而是帶來了許多的麻煩。這是因為他們其實並不理解什麼叫做“模組”,膚淺的把程式碼切割開來,分放在不同的位置,其實非但不能達到模組化的目的,而且製造了不必要的麻煩。
真正的模組化,並不是文字意義上的,而是邏輯意義上的。一個模組應該像一個電路晶片,它有定義良好的輸入和輸出。實際上一種很好的模組化方法早已經存在,它的名字叫做“函式”。每一個函式都有明確的輸入(引數)和輸出(返回值),同一個檔案裡可以包含多個函式,所以你其實根本不需要把程式碼分開在多個檔案或者目錄裡面,同樣可以完成程式碼的模組化。我可以把程式碼全都寫在同一個檔案裡,卻仍然是非常模組化的程式碼。
想要達到很好的模組化,你需要做到以下幾點:
- 避免寫太長的函式。如果發現函式太大了,就應該把它拆分成幾個更小的。通常我寫的函式長度都不超過40行。對比一下,一般膝上型電腦螢幕所能容納的程式碼行數是50行。我可以一目瞭然的看見一個40行的函式,而不需要滾屏。只有40行而不是50行的原因是,我的眼球不轉的話,最大的視角只看得到40行程式碼。如果我看程式碼不轉眼球的話,我就能把整片程式碼完整的對映到我的視覺神經裡,這樣就算忽然閉上眼睛,我也能看得見這段程式碼。我發現閉上眼睛的時候,大腦能夠更加有效地處理程式碼,你能想象這段程式碼可以變成什麼其它的形狀。40行並不是一個很大的限制,因為函式裡面比較複雜的部分,往往早就被我提取出去,做成了更小的函式,然後從原來的函式裡面呼叫。
- 製造小的工具函式。如果你仔細觀察程式碼,就會發現其實裡面有很多的重複。這些常用的程式碼,不管它有多短,提取出去做成函式,都可能是會有好處的。有些幫助函式也許就只有兩行,然而它們卻能大大簡化主要函式裡面的邏輯。有些人不喜歡使用小的函式,因為他們想避免函式呼叫的開銷,結果他們寫出幾百行之大的函式。這是一種過時的觀念。現代的編譯器都能自動的把小的函式內聯(inline)到呼叫它的地方,所以根本不產生函式呼叫,也就不會產生任何多餘的開銷。同樣的一些人,也愛使用巨集(macro)來代替小函式,這也是一種過時的觀念。在早期的C語言編譯器裡,只有巨集是靜態“內聯”的,所以他們使用巨集,其實是為了達到內聯的目的。然而能否內聯,其實並不是巨集與函式的根本區別。巨集與函式有著巨大的區別(這個我以後再講),應該儘量避免使用巨集。為了內聯而使用巨集,其實是濫用了巨集,這會引起各種各樣的麻煩,比如使程式難以理解,難以除錯,容易出錯等等。
- 每個函式只做一件簡單的事情。有些人喜歡製造一些“通用”的函式,既可以做這個又可以做那個,它的內部依據某些變數和條件,來“選擇”這個函式所要做的事情。比如,你也許寫出這樣的函式:
12345678910111213void foo() {if (getOS().equals("MacOS")) {a();} else {b();}c();if (getOS().equals("MacOS")) {d();} else {e();}}
寫這個函式的人,根據系統是否為“MacOS”來做不同的事情。你可以看出這個函式裡,其實只有c()
是兩種系統共有的,而其它的a()
,b()
,d()
,e()
都屬於不同的分支。這種“複用”其實是有害的。如果一個函式可能做兩種事情,它們之間共同點少於它們的不同點,那你最好就寫兩個不同的函式,否則這個函式的邏輯就不會很清晰,容易出現錯誤。其實,上面這個函式可以改寫成兩個函式:
12345void fooMacOS() {a();c();d();}和
12345void fooOther() {b();c();e();}如果你發現兩件事情大部分內容相同,只有少數不同,多半時候你可以把相同的部分提取出去,做成一個輔助函式。比如,如果你有個函式是這樣:
12345678910void foo() {a();b()c();if (getOS().equals("MacOS")) {d();} else {e();}}其中
a()
,b()
,c()
都是一樣的,只有d()
和e()
根據系統有所不同。那麼你可以把a()
,b()
,c()
提取出去:1234void preFoo() {a();b()c();然後製造兩個函式:
1234void fooMacOS() {preFoo();d();}和
1234void fooOther() {preFoo();e();}這樣一來,我們既共享了程式碼,又做到了每個函式只做一件簡單的事情。這樣的程式碼,邏輯就更加清晰。
- 避免使用全域性變數和類成員(class member)來傳遞資訊,儘量使用區域性變數和引數。有些人寫程式碼,經常用類成員來傳遞資訊,就像這樣:
1234567891011121314class 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,而且更加容易理解,不易出錯:
123456789String findX() {...x = ...;return x;}void foo() {int x = findX();print(x);}
寫可讀的程式碼
有些人以為寫很多註釋就可以讓程式碼更加可讀,然而卻發現事與願違。註釋不但沒能讓程式碼變得可讀,反而由於大量的註釋充斥在程式碼中間,讓程式變得障眼難讀。而且程式碼的邏輯一旦修改,就會有很多的註釋變得過時,需要更新。修改註釋是相當大的負擔,所以大量的註釋,反而成為了妨礙改進程式碼的絆腳石。
實際上,真正優雅可讀的程式碼,是幾乎不需要註釋的。如果你發現需要寫很多註釋,那麼你的程式碼肯定是含混晦澀,邏輯不清晰的。其實,程式語言相比自然語言,是更加強大而嚴謹的,它其實具有自然語言最主要的元素:主語,謂語,賓語,名詞,動詞,如果,那麼,否則,是,不是,…… 所以如果你充分利用了程式語言的表達能力,你完全可以用程式本身來表達它到底在幹什麼,而不需要自然語言的輔助。
有少數的時候,你也許會為了繞過其他一些程式碼的設計問題,採用一些違反直覺的作法。這時候你可以使用很短註釋,說明為什麼要寫成那奇怪的樣子。這樣的情況應該少出現,否則這意味著整個程式碼的設計都有問題。
如果沒能合理利用程式語言提供的優勢,你會發現程式還是很難懂,以至於需要寫註釋。所以我現在告訴你一些要點,也許可以幫助你大大減少寫註釋的必要:
- 使用有意義的函式和變數名字。如果你的函式和變數的名字,能夠切實的描述它們的邏輯,那麼你就不需要寫註釋來解釋它在幹什麼。比如:
12// put elephant1 into fridge2put(elephant1, fridge2);
由於我的函式名put
,加上兩個有意義的變數名elephant1
和fridge2
,已經說明了這是在幹什麼(把大象放進冰箱),所以上面那句註釋完全沒有必要。 - 區域性變數應該儘量接近使用它的地方。有些人喜歡在函式最開頭定義很多區域性變數,然後在下面很遠的地方使用它,就像這個樣子:
1234567void foo() {int index = ...;......bar(index);...}
由於這中間都沒有使用過index
,也沒有改變過它所依賴的資料,所以這個變數定義,其實可以挪到接近使用它的地方:
1234567void foo() {......int index = ...;bar(index);...}
這樣讀者看到bar(index)
,不需要向上看很遠就能發現index
是如何算出來的。而且這種短距離,可以加強讀者對於這裡的“計算順序”的理解。否則如果index在頂上,讀者可能會懷疑,它其實儲存了某種會變化的資料,或者它後來又被修改過。如果index放在下面,讀者就清楚的知道,index並不是儲存了什麼可變的值,而且它算出來之後就沒變過。如果你看透了區域性變數的本質——它們就是電路里的導線,那你就能更好的理解近距離的好處。變數定義離用的地方越近,導線的長度就越短。你不需要摸著一根導線,繞來繞去找很遠,就能發現接收它的埠,這樣的電路就更容易理解。
- 區域性變數名字應該簡短。這貌似跟第一點相沖突,簡短的變數名怎麼可能有意義呢?注意我這裡說的是區域性變數,因為它們處於區域性,再加上第2點已經把它放到離使用位置儘量近的地方,所以根據上下文你就會容易知道它的意思:比如,你有一個區域性變數,表示一個操作是否成功:
123456boolean successInDeleteFile = deleteFile("foo.txt");if (successInDeleteFile) {...} else {...}
這個區域性變數
successInDeleteFile
大可不必這麼囉嗦。因為它只用過一次,而且用它的地方就在下面一行,所以讀者可以輕鬆發現它是deleteFile
返回的結果。如果你把它改名為success
,其實讀者根據一點上下文,也知道它表示”success in deleteFile”。所以你可以把它改成這樣:123456boolean success = deleteFile("foo.txt");if (success) {...} else {...}這樣的寫法不但沒漏掉任何有用的語義資訊,而且更加易讀。
successInDeleteFile
這種”camelCase“,如果超過了三個單詞連在一起,其實是很礙眼的東西,所以如果你能用一個單詞表示同樣的意義,那當然更好。 - 不要重用區域性變數。很多人寫程式碼不喜歡定義新的區域性變數,而喜歡“重用”同一個區域性變數,通過反覆對它們進行賦值,來表示完全不同意思。比如這樣寫:
12345678String msg;if (...) {msg = "succeed";log.info(msg);} else {msg = "failed";log.info(msg);}
雖然這樣在邏輯上是沒有問題的,然而卻不易理解,容易混淆。變數
msg
兩次被賦值,表示完全不同的兩個值。它們立即被log.info
使用,沒有傳遞到其它地方去。這種賦值的做法,把區域性變數的作用域不必要的增大,讓人以為它可能在將來改變,也許會在其它地方被使用。更好的做法,其實是定義兩個變數:1234567if (...) {String msg = "succeed";log.info(msg);} else {String msg = "failed";log.info(msg);}由於這兩個
msg
變數的作用域僅限於它們所處的if語句分支,你可以很清楚的看到這兩個msg
被使用的範圍,而且知道它們之間沒有任何關係。 - 把複雜的邏輯提取出去,做成“幫助函式”。有些人寫的函式很長,以至於看不清楚裡面的語句在幹什麼,所以他們誤以為需要寫註釋。如果你仔細觀察這些程式碼,就會發現不清晰的那片程式碼,往往可以被提取出去,做成一個函式,然後在原來的地方呼叫。由於函式有一個名字,這樣你就可以使用有意義的函式名來代替註釋。舉一個例子:
12345678910...// put elephant1 into fridge2openDoor(fridge2);if (elephant1.alive()) {...} else {...}closeDoor(fridge2);...
如果你把這片程式碼提出去定義成一個函式:
123456789void put(Elephant elephant, Fridge fridge) {openDoor(fridge);if (elephant.alive()) {...} else {...}closeDoor(fridge);}這樣原來的程式碼就可以改成:
123...put(elephant1, fridge2);...更加清晰,而且註釋也沒必要了。
- 把複雜的表示式提取出去,做成中間變數。有些人聽說“函數語言程式設計”是個好東西,也不理解它的真正含義,就在程式碼裡大量使用巢狀的函式。像這樣:
12Pizza pizza = makePizza(crust(salt(), butter()),topping(onion(), tomato(), sausage()));
這樣的程式碼一行太長,而且巢狀太多,不容易看清楚。其實訓練有素的函式式程式設計師,都知道中間變數的好處,不會盲目的使用巢狀的函式。他們會把這程式碼變成這樣:
123Crust crust = crust(salt(), butter());Topping topping = topping(onion(), tomato(), sausage());Pizza pizza = makePizza(crust, topping);這樣寫,不但有效地控制了單行程式碼的長度,而且由於引入的中間變數具有“意義”,步驟清晰,變得很容易理解。
- 在合理的地方換行。對於絕大部分的程式語言,程式碼的邏輯是和空白字元無關的,所以你可以在幾乎任何地方換行,你也可以不換行。這樣的語言設計是個好東西,因為它給了程式設計師自由控制自己程式碼格式的能力。然而,它也引起了一些問題,因為很多人不知道如何合理的換行。有些人喜歡利用IDE的自動換行機制,編輯之後用一個熱鍵把整個程式碼重新格式化一遍,IDE就會把超過行寬限制的程式碼自動折行。可是這種自動這行,往往沒有根據程式碼的邏輯來進行,不能幫助理解程式碼。自動換行之後可能產生這樣的程式碼:
1234if (someLongCondition1() && someLongCondition2() && someLongCondition3() &&someLongCondition4()) {...}
由於
someLongCondition4()
超過了行寬限制,被編輯器自動換到了下面一行。雖然滿足了行寬限制,換行的位置卻是相當任意的,它並不能幫助人理解這程式碼的邏輯。這幾個boolean表示式,全都用&&
連線,所以它們其實處於平等的地位。為了表達這一點,當需要折行的時候,你應該把每一個表示式都放到新的一行,就像這個樣子:123456if (someLongCondition1() &&someLongCondition2() &&someLongCondition3() &&someLongCondition4()) {...}這樣每一個條件都對齊,裡面的邏輯就很清楚了。再舉個例子:
12log.info("failed to find file {} for command {}, with exception {}", file, command,exception);這行因為太長,被自動折行成這個樣子。
file
,command
和exception
本來是同一類東西,卻有兩個留在了第一行,最後一個被折到第二行。它就不如手動換行成這個樣子:12log.info("failed to find file {} for command {}, with exception {}",file, command, exception);把格式字串單獨放在一行,而把它的引數一併放在另外一行,這樣邏輯就更加清晰。
為了避免IDE把這些手動調整好的換行弄亂,很多IDE(比如IntelliJ)的自動格式化設定裡都有“保留原來的換行符”的設定。如果你發現IDE的換行不符合邏輯,你可以修改這些設定,然後在某些地方保留你自己的手動換行。
說到這裡,我必須警告你,這裡所說的“不需註釋,讓程式碼自己解釋自己”,並不是說要讓程式碼看起來像某種自然語言。有個叫Chai的JavaScript測試工具,可以讓你這樣寫程式碼:
1 2 3 4 |
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 。另一種情況是寫成單獨的一行,比如
i++;
。這兩種情況是完全沒有歧義的。你需要避免其它的情況,比如用在複雜的表示式裡面,比如foo(i++)
,foo(++i) + foo(i)
,…… 沒有人應該知道,或者去追究這些是什麼意思。 - 永遠不要省略花括號。很多語言允許你在某種情況下省略掉花括號,比如C,Java都允許你在if語句裡面只有一句話的時候省略掉花括號:
12if (...)action1();
咋一看少打了兩個字,多好。可是這其實經常引起奇怪的問題。比如,你後來想要加一句話action2()
到這個if裡面,於是你就把程式碼改成:
123if (...)action1();action2();
為了美觀,你很小心的使用了action1()
的縮排。咋一看它們是在一起的,所以你下意識裡以為它們只會在if的條件為真的時候執行,然而action2()
卻其實在if外面,它會被無條件的執行。我把這種現象叫做“光學幻覺”(optical illusion),理論上每個程式設計師都應該發現這個錯誤,然而實際上卻容易被忽視。那麼你問,誰會這麼傻,我在加入
action2()
的時候加上花括號不就行了?可是從設計的角度來看,這樣其實並不是合理的作法。首先,也許你以後又想把action2()
去掉,這樣你為了樣式一致,又得把花括號拿掉,煩不煩啊?其次,這使得程式碼樣式不一致,有的if有花括號,有的又沒有。況且,你為什麼需要記住這個規則?如果你不問三七二十一,只要是if-else語句,把花括號全都打上,就可以想都不用想了,就當C和Java沒提供給你這個特殊寫法。這樣就可以保持完全的一致性,減少不必要的思考。有人可能會說,全都打上花括號,只有一句話也打上,多礙眼啊?然而經過實行這種編碼規範幾年之後,我並沒有發現這種寫法更加礙眼,反而由於花括號的存在,使得程式碼界限明確,讓我的眼睛負擔更小了。
- 合理使用括號,不要盲目依賴操作符優先順序。利用操作符的優先順序來減少括號,對於
1 + 2 * 3
這樣常見的算數表示式,是沒問題的。然而有些人如此的仇恨括號,以至於他們會寫出2 這樣的表示式,而完全不用括號。
這裡的問題,在於移位操作的優先順序,是很多人不熟悉,而且是違反常理的。由於
解決這個問題的辦法,不是要每個人去把操作符優先順序表給硬背下來,而是合理的加入括號。比如上面的例子,最好直接加上括號寫成x 相當於把
x
乘以2,很多人誤以為這個表示式相當於(2 ,所以等於250。然而實際上
的優先順序比加法
+
還要低,所以這表示式其實相當於2 ,所以等於4!
2 。雖然沒有括號也表示同樣的意思,但是加上括號就更加清晰,讀者不再需要死記
的優先順序就能理解程式碼。
- 避免使用continue和break。迴圈語句(for,while)裡面出現return是沒問題的,然而如果你使用了continue或者break,就會讓迴圈的邏輯和終止條件變得複雜,難以確保正確。出現continue或者break的原因,往往是對迴圈的邏輯沒有想清楚。如果你考慮周全了,應該是幾乎不需要continue或者break的。如果你的迴圈裡出現了continue或者break,你就應該考慮改寫這個迴圈。改寫迴圈的辦法有多種:
- 如果出現了continue,你往往只需要把continue的條件反向,就可以消除continue。
- 如果出現了break,你往往可以把break的條件,合併到迴圈頭部的終止條件裡,從而去掉break。
- 有時候你可以把break替換成return,從而去掉break。
- 如果以上都失敗了,你也許可以把迴圈裡面複雜的部分提取出來,做成函式呼叫,之後continue或者break就可以去掉了。
下面我對這些情況舉一些例子。
情況1:下面這段程式碼裡面有一個continue:
12345678List goodNames = new ArrayList();for (String name: names) {if (name.contains("bad")) {continue;}goodNames.add(name);...}它說:“如果name含有’bad’這個詞,跳過後面的迴圈程式碼……” 注意,這是一種“負面”的描述,它不是在告訴你什麼時候“做”一件事,而是在告訴你什麼時候“不做”一件事。為了知道它到底在幹什麼,你必須搞清楚continue會導致哪些語句被跳過了,然後腦子裡把邏輯反個向,你才能知道它到底想做什麼。這就是為什麼含有continue和break的迴圈不容易理解,它們依靠“控制流”來描述“不做什麼”,“跳過什麼”,結果到最後你也沒搞清楚它到底“要做什麼”。
其實,我們只需要把continue的條件反向,這段程式碼就可以很容易的被轉換成等價的,不含continue的程式碼:
1234567List 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。
比如下面這段程式碼:
123456while (condition1) {...if (condition2) {break;}}當condition成立的時候,break會退出迴圈。其實你只需要把condition2反轉之後,放到while頭部的終止條件,就可以去掉這種break語句。改寫後的程式碼如下:
123while (condition1 && !condition2) {...}這種情況表面上貌似只適用於break出現在迴圈開頭或者末尾的時候,然而其實大部分時候,break都可以通過某種方式,移動到迴圈的開頭或者末尾。具體的例子我暫時沒有,等出現的時候再加進來。
情況3:很多break退出迴圈之後,其實接下來就是一個return。這種break往往可以直接換成return。比如下面這個例子:
1234567891011public boolean hasBadName(List names) {boolean result = false;for (String name: names) {if (name.contains("bad")) {result = true;break;}}return result;}這個函式檢查names連結串列裡是否存在一個名字,包含“bad”這個詞。它的迴圈裡包含一個break語句。這個函式可以被改寫成:
12345678public boolean hasBadName(List 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%含有複雜的邏輯,但也可以通過提取一個幫助函式來消除掉。修改之後的程式碼變得容易理解,容易確保正確。
寫直觀的程式碼
我寫程式碼有一條重要的原則:如果有更加直接,更加清晰的寫法,就選擇它,即使它看起來更長,更笨,也一樣選擇它。比如,Unix命令列有一種“巧妙”的寫法是這樣:
1 |
command1 && command2 && command3 |
由於Shell語言的邏輯操作a && b
具有“短路”的特性,如果a
等於false,那麼b
就沒必要執行了。這就是為什麼當command1成功,才會執行command2,當command2成功,才會執行command3。同樣,
1 |
command1 || command2 || command3 |
操作符||
也有類似的特性。上面這個命令列,如果command1成功,那麼command2和command3都不會被執行。如果command1失敗,command2成功,那麼command3就不會被執行。
這比起用if語句來判斷失敗,似乎更加巧妙和簡潔,所以有人就借鑑了這種方式,在程式的程式碼裡也使用這種方式。比如他們可能會寫這樣的程式碼:
1 2 3 |
if (action1() || action2() && action3()) { ... } |
你看得出來這程式碼是想幹什麼嗎?action2和action3什麼條件下執行,什麼條件下不執行?也許稍微想一下,你知道它在幹什麼:“如果action1失敗了,執行action2,如果action2成功了,執行action3”。然而那種語義,並不是直接的“對映”在這程式碼上面的。比如“失敗”這個詞,對應了程式碼裡的哪一個字呢?你找不出來,因為它包含在了||
的語義裡面,你需要知道||
的短路特性,以及邏輯或的語義才能知道這裡面在說“如果action1失敗……”。每一次看到這行程式碼,你都需要思考一下,這樣積累起來的負荷,就會讓人很累。
其實,這種寫法是濫用了邏輯操作&&
和||
的短路特性。這兩個操作符可能不執行右邊的表示式,原因是為了機器的執行效率,而不是為了給人提供這種“巧妙”的用法。這兩個操作符的本意,只是作為邏輯操作,它們並不是拿來給你代替if語句的。也就是說,它們只是碰巧可以達到某些if語句的效果,但你不應該因此就用它來代替if語句。如果你這樣做了,就會讓程式碼晦澀難懂。
上面的程式碼寫成笨一點的辦法,就會清晰很多:
1 2 3 4 5 |
if (!action1()) { if (action2()) { action3(); } } |
這裡我很明顯的看出這程式碼在說什麼,想都不用想:如果action1()失敗了,那麼執行action2(),如果action2()成功了,執行action3()。你發現這裡面的一一對應關係嗎?if
=如果,!
=失敗,…… 你不需要利用邏輯學知識,就知道它在說什麼。
寫無懈可擊的程式碼
在之前一節裡,我提到了自己寫的程式碼裡面很少出現只有一個分支的if語句。我寫出的if語句,大部分都有兩個分支,所以我的程式碼很多看起來是這個樣子:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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
。他們的程式碼看起來像這個樣子:
1 2 3 4 5 6 7 8 9 10 |
if (...) { if (...) { ... return false; } } else if (...) { ... return false; } return true; |
這種寫法看似更加簡潔,避免了重複,然而卻很容易出現疏忽和漏洞。巢狀的if語句省略了一些else,依靠語句的“控制流”來處理else的情況,是很難正確的分析和推理的。如果你的if條件裡使用了&&
和||
之類的邏輯運算,就更難看出是否涵蓋了所有的情況。
由於疏忽而漏掉的分支,全都會自動“掉下去”,最後返回意想不到的結果。即使你看一遍之後確信是正確的,每次讀這段程式碼,你都不能確信它照顧了所有的情況,又得重新推理一遍。這簡潔的寫法,帶來的是反覆的,沉重的頭腦開銷。這就是所謂“麵條程式碼”,因為程式的邏輯分支,不是像一棵枝葉分明的樹,而是像麵條一樣繞來繞去。
另外一種省略else分支的情況是這樣:
1 2 |
String s = ""; if (x |
寫這段程式碼的人,腦子裡喜歡使用一種“預設值”的做法。s
預設為null,如果xx不成立的時候,你需要往上面看,才能知道s的值是什麼。這還是你運氣好的時候,因為s就在上面不遠。很多人寫這種程式碼的時候,s的初始值離判斷語句有一定的距離,中間還有可能插入一些其它的邏輯和賦值操作。這樣的程式碼,把變數改來改去的,看得人眼花,就容易出錯。
現在比較一下我的寫法:
1 2 |
String s; if (x |
這種寫法貌似多打了一兩個字,然而它卻更加清晰。這是因為我們明確的指出了x不成立的時候,s的值是什麼。它就擺在那裡,它是
""
(空字串)。注意,雖然我也使用了賦值操作,然而我並沒有“改變”s的值。s一開始的時候沒有值,被賦值之後就再也沒有變過。我的這種寫法,通常被叫做更加“函式式”,因為我只賦值一次。
如果我漏寫了else分支,Java編譯器是不會放過我的。它會抱怨:“在某個分支,s沒有被初始化。”這就強迫我清清楚楚的設定各種條件下s的值,不漏掉任何一種情況。
當然,由於這個情況比較簡單,你還可以把它寫成這樣:
1 |
String s = x |
對於更加複雜的情況,我建議還是寫成if語句為好。
正確處理錯誤
使用有兩個分支的if語句,只是我的程式碼可以達到無懈可擊的其中一個原因。這樣寫if語句的思路,其實包含了使程式碼可靠的一種通用思想:窮舉所有的情況,不漏掉任何一個。
程式的絕大部分功能,是進行資訊處理。從一堆紛繁複雜,模稜兩可的資訊中,排除掉絕大部分“干擾資訊”,找到自己需要的那一個。正確地對所有的“可能性”進行推理,就是寫出無懈可擊程式碼的核心思想。這一節我來講一講,如何把這種思想用在錯誤處理上。
錯誤處理是一個古老的問題,可是經過了幾十年,還是很多人沒搞明白。Unix的系統API手冊,一般都會告訴你可能出現的返回值和錯誤資訊。比如,Linux的read系統呼叫手冊裡面有如下內容:
1 |
RETURN VALUEOn success, the number of bytes read is returned...On error, -1 is returned, and errno is set appropriately.ERRORSEAGAIN, EBADF, EFAULT, EINTR, EINVAL, ... |
很多初學者,都會忘記檢查read
的返回值是否為-1,覺得每次呼叫read
都得檢查返回值真繁瑣,不檢查貌似也相安無事。這種想法其實是很危險的。如果函式的返回值告訴你,要麼返回一個正數,表示讀到的資料長度,要麼返回-1,那麼你就必須要對這個-1作出相應的,有意義的處理。千萬不要以為你可以忽視這個特殊的返回值,因為它是一種“可能性”。程式碼漏掉任何一種可能出現的情況,都可能產生意想不到的災難性結果。
對於Java來說,這相對方便一些。Java的函式如果出現問題,一般通過異常(exception)來表示。你可以把異常加上函式本來的返回值,看成是一個“union型別”。比如:
1 2 3 |
String foo() throws MyException { ... } |
這裡MyException是一個錯誤返回。你可以認為這個函式返回一個union型別:{String, MyException}
。任何呼叫foo
的程式碼,必須對MyException作出合理的處理,才有可能確保程式的正確執行。Union型別是一種相當先進的型別,目前只有極少數語言(比如Typed Racket)具有這種型別,我在這裡提到它,只是為了方便解釋概念。掌握了概念之後,你其實可以在頭腦裡實現一個union型別系統,這樣使用普通的語言也能寫出可靠的程式碼。
由於Java的型別系統強制要求函式在型別裡面宣告可能出現的異常,而且強制呼叫者處理可能出現的異常,所以基本上不可能出現由於疏忽而漏掉的情況。但有些Java程式設計師有一種惡習,使得這種安全機制幾乎完全失效。每當編譯器報錯,說“你沒有catch這個foo函式可能出現的異常”時,有些人想都不想,直接把程式碼改成這樣:
1 2 3 |
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,你的程式碼應該儘可能寫成:
1 2 3 4 5 6 7 |
try { foo(); } catch (A e) {...} try { bar(); } catch (A e) {...} |
而不是
1 2 3 4 |
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,也有可能什麼也找不到,你可以這樣寫:
1234567public 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。 - 不要把null放進“容器資料結構”裡面。所謂容器(collection),是指一些物件以某種方式集合在一起,所以null不應該被放進Array,List,Set等結構,不應該出現在Map的key或者value裡面。把null放進容器裡面,是一些莫名其妙錯誤的來源。因為物件在容器裡的位置一般是動態決定的,所以一旦null從某個入口跑進去了,你就很難再搞明白它去了哪裡,你就得被迫在所有從這個容器裡取值的位置檢查null。你也很難知道到底是誰把它放進去的,程式碼多了就導致除錯極其困難。解決方案是:如果你真要表示“沒有”,那你就乾脆不要把它放進去(Array,List,Set沒有元素,Map根本沒那個entry),或者你可以指定一個特殊的,真正合法的物件,用來表示“沒有”。需要指出的是,類物件並不屬於容器。所以null在必要的時候,可以作為物件成員的值,表示它不存在。比如:
1234class A {String name = null;...}
之所以可以這樣,是因為null只可能在A物件的name成員裡出現,你不用懷疑其它的成員因此成為null。所以你每次訪問name成員時,檢查它是否是null就可以了,不需要對其他成員也做同樣的檢查。
- 函式呼叫者:明確理解null所表示的意義,儘早檢查和處理null返回值,減少它的傳播。null很討厭的一個地方,在於它在不同的地方可能表示不同的意義。有時候它表示“沒有”,“沒找到”。有時候它表示“出錯了”,“失敗了”。有時候它甚至可以表示“成功了”,…… 這其中有很多誤用之處,不過無論如何,你必須理解每一個null的意義,不能給混淆起來。如果你呼叫的函式有可能返回null,那麼你應該在第一時間對null做出“有意義”的處理。比如,上述的函式
find
,返回null表示“沒找到”,那麼呼叫find
的程式碼就應該在它返回的第一時間,檢查返回值是否是null,並且對“沒找到”這種情況,作出有意義的處理。“有意義”是什麼意思呢?我的意思是,使用這函式的人,應該明確的知道在拿到null的情況下該怎麼做,承擔起責任來。他不應該只是“向上級彙報”,把責任踢給自己的呼叫者。如果你違反了這一點,就有可能採用一種不負責任,危險的寫法:123456public String foo() {String found = find();if (found == null) {return null;}}當看到find()返回了null,foo自己也返回null。這樣null就從一個地方,遊走到了另一個地方,而且它表示另外一個意思。如果你不假思索就寫出這樣的程式碼,最後的結果就是程式碼裡面隨時隨地都可能出現null。到後來為了保護自己,你的每個函式都會寫成這樣:
123456public 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()
。它的定義很簡單:1234567public static 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裡面,你可以這樣寫:
1234let 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型別的值found,你必須使用“函數語言程式設計”的方式,來寫這之後的程式碼:
12Optional 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):12345678public static String foo() {String found = find();if (found != null) {return found;} else {return "";}}就會比較麻煩。因為如果你寫成這樣:
1234567public static String foo() {Optional 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外面的變數進行賦值,所以你也不能採用這種寫法:12345678public static String foo() {Optional 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
之類,想法把它們組合起來,才能表達出原來程式碼的意思。比如之前的程式碼,只能改寫成這樣:1234public static String foo() {Optional 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型別:
1234Option 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的程式碼,再考慮測試的問題。