編程是一種創(chuàng)造性的工作,是一門藝術(shù)。精通任何一門藝術(shù),都需要很多的練習(xí)和領(lǐng)悟,所以這里提出的“智慧”,并不是號稱一天瘦十斤的減肥藥,它并不能代替你自己的勤奮。然而由于軟件行業(yè)喜歡標(biāo)新立異,喜歡把簡單的事情搞復(fù)雜,我希望這些文字能給迷惑中的人們指出一些正確的方向,讓他們少走一些彎路,基本做到一分耕耘一分收獲。
反復(fù)推敲代碼
有些人喜歡炫耀自己寫了多少多少萬行的代碼,仿佛代碼的數(shù)量是衡量編程水平的標(biāo)準(zhǔn)。然而,如果你總是匆匆寫出代碼,卻從來不回頭去推敲,修改和提煉,其實是不可能提高編程水平的。你會制造出越來越多平庸甚至糟糕的代碼。在這種意義上,很多人所謂的“工作經(jīng)驗”,跟他代碼的質(zhì)量其實不一定成正比。如果有幾十年的工作經(jīng)驗,卻從來不回頭去提煉和反思自己的代碼,那么他也許還不如一個只有一兩年經(jīng)驗,卻喜歡反復(fù)推敲,仔細(xì)領(lǐng)悟的人。
有位文豪說得好:“看一個作家的水平,不是看他發(fā)表了多少文字,而要看他的廢紙簍里扔掉了多少?!?我覺得同樣的理論適用于編程。好的程序員,他們刪掉的代碼,比留下來的還要多很多。如果你看見一個人寫了很多代碼,卻沒有刪掉多少,那他的代碼一定有很多垃圾。
就像文學(xué)作品一樣,代碼是不可能一蹴而就的。靈感似乎總是零零星星,陸陸續(xù)續(xù)到來的。任何人都不可能一筆呵成,就算再厲害的程序員,也需要經(jīng)過一段時間,才能發(fā)現(xiàn)最簡單優(yōu)雅的寫法。有時候你反復(fù)提煉一段代碼,覺得到了頂峰,沒法再改進(jìn)了,可是過了幾個月再回頭來看,又發(fā)現(xiàn)好多可以改進(jìn)和簡化的地方。這跟寫文章一模一樣,回頭看幾個月或者幾年前寫的東西,你總能發(fā)現(xiàn)一些改進(jìn)。
所以如果反復(fù)提煉代碼已經(jīng)不再有進(jìn)展,那么你可以暫時把它放下。過幾個星期或者幾個月再回頭來看,也許就有煥然一新的靈感。這樣反反復(fù)復(fù)很多次之后,你就積累起了靈感和智慧,從而能夠在遇到新問題的時候直接朝正確,或者接近正確的方向前進(jìn)。
寫優(yōu)雅的代碼
人們都討厭“面條代碼”(spaghetti code),因為它就像面條一樣繞來繞去,沒法理清頭緒。那么優(yōu)雅的代碼一般是什么形狀的呢?經(jīng)過多年的觀察,我發(fā)現(xiàn)優(yōu)雅的代碼,在形狀上有一些明顯的特征。
如果我們忽略具體的內(nèi)容,從大體結(jié)構(gòu)上來看,優(yōu)雅的代碼看起來就像是一些整整齊齊,套在一起的盒子。如果跟整理房間做一個類比,就很容易理解。如果你把所有物品都丟在一個很大的抽屜里,那么它們就會全都混在一起。你就很難整理,很難迅速的找到需要的東西。但是如果你在抽屜里再放幾個小盒子,把物品分門別類放進(jìn)去,那么它們就不會到處亂跑,你就可以比較容易的找到和管理它們。
優(yōu)雅的代碼的另一個特征是,它的邏輯大體上看起來,是枝丫分明的樹狀結(jié)構(gòu)(tree)。這是因為程序所做的幾乎一切事情,都是信息的傳遞和分支。你可以把代碼看成是一個電路,電流經(jīng)過導(dǎo)線,分流或者匯合。如果你是這樣思考的,你的代碼里就會比較少出現(xiàn)只有一個分支的if語句,它看起來就會像這個樣子:
if(...){ if(...){ ... }else{ ... } }elseif(...){ ... }else{ ... }
注意到了嗎?在我的代碼里面,if語句幾乎總是有兩個分支。它們有可能嵌套,有多層的縮進(jìn),而且else分支里面有可能出現(xiàn)少量重復(fù)的代碼。然而這樣的結(jié)構(gòu),邏輯卻非常嚴(yán)密和清晰。在后面我會告訴你為什么if語句最好有兩個分支。
寫模塊化的代碼
有些人吵著鬧著要讓程序“模塊化”,結(jié)果他們的做法是把代碼分部到多個文件和目錄里面,然后把這些目錄或者文件叫做“module”。他們甚至把這些目錄分放在不同的VCS repo里面。結(jié)果這樣的作法并沒有帶來合作的流暢,而是帶來了許多的麻煩。這是因為他們其實并不理解什么叫做“模塊”,膚淺的把代碼切割開來,分放在不同的位置,其實非但不能達(dá)到模塊化的目的,而且制造了不必要的麻煩。
真正的模塊化,并不是文本意義上的,而是邏輯意義上的。一個模塊應(yīng)該像一個電路芯片,它有定義良好的輸入和輸出。實際上一種很好的模塊化方法早已經(jīng)存在,它的名字叫做“函數(shù)”。每一個函數(shù)都有明確的輸入(參數(shù))和輸出(返回值),同一個文件里可以包含多個函數(shù),所以你其實根本不需要把代碼分開在多個文件或者目錄里面,同樣可以完成代碼的模塊化。我可以把代碼全都寫在同一個文件里,卻仍然是非常模塊化的代碼。
想要達(dá)到很好的模塊化,你需要做到以下幾點:
避免寫太長的函數(shù)。如果發(fā)現(xiàn)函數(shù)太大了,就應(yīng)該把它拆分成幾個更小的。通常我寫的函數(shù)長度都不超過40行。對比一下,一般筆記本電腦屏幕所能容納的代碼行數(shù)是50行。我可以一目了然的看見一個40行的函數(shù),而不需要滾屏。只有40行而不是50行的原因是,我的眼球不轉(zhuǎn)的話,最大的視角只看得到40行代碼。
如果我看代碼不轉(zhuǎn)眼球的話,我就能把整片代碼完整的映射到我的視覺神經(jīng)里,這樣就算忽然閉上眼睛,我也能看得見這段代碼。我發(fā)現(xiàn)閉上眼睛的時候,大腦能夠更加有效地處理代碼,你能想象這段代碼可以變成什么其它的形狀。40行并不是一個很大的限制,因為函數(shù)里面比較復(fù)雜的部分,往往早就被我提取出去,做成了更小的函數(shù),然后從原來的函數(shù)里面調(diào)用。
制造小的工具函數(shù)。如果你仔細(xì)觀察代碼,就會發(fā)現(xiàn)其實里面有很多的重復(fù)。這些常用的代碼,不管它有多短,提取出去做成函數(shù),都可能是會有好處的。有些幫助函數(shù)也許就只有兩行,然而它們卻能大大簡化主要函數(shù)里面的邏輯。
有些人不喜歡使用小的函數(shù),因為他們想避免函數(shù)調(diào)用的開銷,結(jié)果他們寫出幾百行之大的函數(shù)。這是一種過時的觀念?,F(xiàn)代的編譯器都能自動的把小的函數(shù)內(nèi)聯(lián)(inline)到調(diào)用它的地方,所以根本不產(chǎn)生函數(shù)調(diào)用,也就不會產(chǎn)生任何多余的開銷。
同樣的一些人,也愛使用宏(macro)來代替小函數(shù),這也是一種過時的觀念。在早期的C語言編譯器里,只有宏是靜態(tài)“內(nèi)聯(lián)”的,所以他們使用宏,其實是為了達(dá)到內(nèi)聯(lián)的目的。然而能否內(nèi)聯(lián),其實并不是宏與函數(shù)的根本區(qū)別。宏與函數(shù)有著巨大的區(qū)別(這個我以后再講),應(yīng)該盡量避免使用宏。為了內(nèi)聯(lián)而使用宏,其實是濫用了宏,這會引起各種各樣的麻煩,比如使程序難以理解,難以調(diào)試,容易出錯等等。
每個函數(shù)只做一件簡單的事情。有些人喜歡制造一些“通用”的函數(shù),既可以做這個又可以做那個,它的內(nèi)部依據(jù)某些變量和條件,來“選擇”這個函數(shù)所要做的事情。比如,你也許寫出這樣的函數(shù):
voidfoo(){ if(getOS().equals("MacOS")){ a(); }else{ b(); } c(); if(getOS().equals("MacOS")){ d(); }else{ e(); } }
寫這個函數(shù)的人,根據(jù)系統(tǒng)是否為“MacOS”來做不同的事情。你可以看出這個函數(shù)里,其實只有c()是兩種系統(tǒng)共有的,而其它的a(),b(),d(),e()都屬于不同的分支。
這種“復(fù)用”其實是有害的。如果一個函數(shù)可能做兩種事情,它們之間共同點少于它們的不同點,那你最好就寫兩個不同的函數(shù),否則這個函數(shù)的邏輯就不會很清晰,容易出現(xiàn)錯誤。其實,上面這個函數(shù)可以改寫成兩個函數(shù):
voidfooMacOS(){ a(); c(); d(); }
和
voidfooOther(){ b(); c(); e(); }
如果你發(fā)現(xiàn)兩件事情大部分內(nèi)容相同,只有少數(shù)不同,多半時候你可以把相同的部分提取出去,做成一個輔助函數(shù)。比如,如果你有個函數(shù)是這樣:
voidfoo(){ a(); b() c(); if(getOS().equals("MacOS")){ d(); }else{ e(); } }
其中a(),b(),c()都是一樣的,只有d()和e()根據(jù)系統(tǒng)有所不同。那么你可以把a(),b(),c()提取出去:
voidpreFoo(){ a(); b() c();
然后制造兩個函數(shù):
voidfooMacOS(){ preFoo(); d(); }
和
voidfooOther(){ preFoo(); e(); }
這樣一來,我們既共享了代碼,又做到了每個函數(shù)只做一件簡單的事情。這樣的代碼,邏輯就更加清晰。
避免使用全局變量和類成員(class member)來傳遞信息,盡量使用局部變量和參數(shù)。有些人寫代碼,經(jīng)常用類成員來傳遞信息,就像這樣:
classA{ Stringx; voidfindX(){ ... x=...; } voidfoo(){ findX(); ... print(x); } }
首先,他使用findX(),把一個值寫入成員x。然后,使用x的值。這樣,x就變成了findX和print之間的數(shù)據(jù)通道。由于x屬于class A,這樣程序就失去了模塊化的結(jié)構(gòu)。由于這兩個函數(shù)依賴于成員x,它們不再有明確的輸入和輸出,而是依賴全局的數(shù)據(jù)。findX和foo不再能夠離開class A而存在,而且由于類成員還有可能被其他代碼改變,代碼變得難以理解,難以確保正確性。
如果你使用局部變量而不是類成員來傳遞信息,那么這兩個函數(shù)就不需要依賴于某一個class,而且更加容易理解,不易出錯:
StringfindX(){ ... x=...; returnx; } voidfoo(){ Stringx=findX(); print(x); }
寫可讀的代碼
有些人以為寫很多注釋就可以讓代碼更加可讀,然而卻發(fā)現(xiàn)事與愿違。注釋不但沒能讓代碼變得可讀,反而由于大量的注釋充斥在代碼中間,讓程序變得障眼難讀。而且代碼的邏輯一旦修改,就會有很多的注釋變得過時,需要更新。修改注釋是相當(dāng)大的負(fù)擔(dān),所以大量的注釋,反而成為了妨礙改進(jìn)代碼的絆腳石。
實際上,真正優(yōu)雅可讀的代碼,是幾乎不需要注釋的。如果你發(fā)現(xiàn)需要寫很多注釋,那么你的代碼肯定是含混晦澀,邏輯不清晰的。其實,程序語言相比自然語言,是更加強大而嚴(yán)謹(jǐn)?shù)?,它其實具有自然語言最主要的元素:主語,謂語,賓語,名詞,動詞,如果,那么,否則,是,不是,…… 所以如果你充分利用了程序語言的表達(dá)能力,你完全可以用程序本身來表達(dá)它到底在干什么,而不需要自然語言的輔助。
有少數(shù)的時候,你也許會為了繞過其他一些代碼的設(shè)計問題,采用一些違反直覺的作法。這時候你可以使用很短注釋,說明為什么要寫成那奇怪的樣子。這樣的情況應(yīng)該少出現(xiàn),否則這意味著整個代碼的設(shè)計都有問題。
如果沒能合理利用程序語言提供的優(yōu)勢,你會發(fā)現(xiàn)程序還是很難懂,以至于需要寫注釋。所以我現(xiàn)在告訴你一些要點,也許可以幫助你大大減少寫注釋的必要:
使用有意義的函數(shù)和變量名字。如果你的函數(shù)和變量的名字,能夠切實的描述它們的邏輯,那么你就不需要寫注釋來解釋它在干什么。比如:
//putelephant1intofridge2 put(elephant1,fridge2);
由于我的函數(shù)名put,加上兩個有意義的變量名elephant1和fridge2,已經(jīng)說明了這是在干什么(把大象放進(jìn)冰箱),所以上面那句注釋完全沒有必要。
局部變量應(yīng)該盡量接近使用它的地方。有些人喜歡在函數(shù)最開頭定義很多局部變量,然后在下面很遠(yuǎn)的地方使用它,就像這個樣子:
voidfoo(){ intindex=...; ... ... bar(index); ... }
由于這中間都沒有使用過index,也沒有改變過它所依賴的數(shù)據(jù),所以這個變量定義,其實可以挪到接近使用它的地方:
voidfoo(){ ... ... intindex=...; bar(index); ... }
這樣讀者看到bar(index),不需要向上看很遠(yuǎn)就能發(fā)現(xiàn)index是如何算出來的。而且這種短距離,可以加強讀者對于這里的“計算順序”的理解。否則如果index在頂上,讀者可能會懷疑,它其實保存了某種會變化的數(shù)據(jù),或者它后來又被修改過。如果index放在下面,讀者就清楚的知道,index并不是保存了什么可變的值,而且它算出來之后就沒變過。
如果你看透了局部變量的本質(zhì)——它們就是電路里的導(dǎo)線,那你就能更好的理解近距離的好處。變量定義離用的地方越近,導(dǎo)線的長度就越短。你不需要摸著一根導(dǎo)線,繞來繞去找很遠(yuǎn),就能發(fā)現(xiàn)接收它的端口,這樣的電路就更容易理解。
局部變量名字應(yīng)該簡短。這貌似跟第一點相沖突,簡短的變量名怎么可能有意義呢?注意我這里說的是局部變量,因為它們處于局部,再加上第2點已經(jīng)把它放到離使用位置盡量近的地方,所以根據(jù)上下文你就會容易知道它的意思:
比如,你有一個局部變量,表示一個操作是否成功:
booleansuccessInDeleteFile=deleteFile("foo.txt"); if(successInDeleteFile){ ... }else{ ... }
這個局部變量successInDeleteFile大可不必這么啰嗦。因為它只用過一次,而且用它的地方就在下面一行,所以讀者可以輕松發(fā)現(xiàn)它是deleteFile返回的結(jié)果。如果你把它改名為success,其實讀者根據(jù)一點上下文,也知道它表示”success in deleteFile”。所以你可以把它改成這樣:
booleansuccess=deleteFile("foo.txt"); if(success){ ... }else{ ... }
這樣的寫法不但沒漏掉任何有用的語義信息,而且更加易讀。successInDeleteFile這種“camelCase”,如果超過了三個單詞連在一起,其實是很礙眼的東西。所以如果你能用一個單詞表示同樣的意義,那當(dāng)然更好。
不要重用局部變量。很多人寫代碼不喜歡定義新的局部變量,而喜歡“重用”同一個局部變量,通過反復(fù)對它們進(jìn)行賦值,來表示完全不同意思。比如這樣寫:
Stringmsg; if(...){ msg="succeed"; log.info(msg); }else{ msg="failed"; log.info(msg); }
雖然這樣在邏輯上是沒有問題的,然而卻不易理解,容易混淆。變量msg兩次被賦值,表示完全不同的兩個值。它們立即被log.info使用,沒有傳遞到其它地方去。這種賦值的做法,把局部變量的作用域不必要的增大,讓人以為它可能在將來改變,也許會在其它地方被使用。更好的做法,其實是定義兩個變量:
if(...){ Stringmsg="succeed"; log.info(msg); }else{ Stringmsg="failed"; log.info(msg); }
由于這兩個msg變量的作用域僅限于它們所處的if語句分支,你可以很清楚的看到這兩個msg被使用的范圍,而且知道它們之間沒有任何關(guān)系。
把復(fù)雜的邏輯提取出去,做成“幫助函數(shù)”。有些人寫的函數(shù)很長,以至于看不清楚里面的語句在干什么,所以他們誤以為需要寫注釋。如果你仔細(xì)觀察這些代碼,就會發(fā)現(xiàn)不清晰的那片代碼,往往可以被提取出去,做成一個函數(shù),然后在原來的地方調(diào)用。由于函數(shù)有一個名字,這樣你就可以使用有意義的函數(shù)名來代替注釋。舉一個例子:
... //putelephant1intofridge2 openDoor(fridge2); if(elephant1.alive()){ ... }else{ ... } closeDoor(fridge2); ...
如果你把這片代碼提出去定義成一個函數(shù):
voidput(Elephantelephant,Fridgefridge){ openDoor(fridge); if(elephant.alive()){ ... }else{ ... } closeDoor(fridge); }
這樣原來的代碼就可以改成:
... put(elephant1,fridge2); ...
更加清晰,而且注釋也沒必要了。
把復(fù)雜的表達(dá)式提取出去,做成中間變量。有些人聽說“函數(shù)式編程”是個好東西,也不理解它的真正含義,就在代碼里大量使用嵌套的函數(shù)。像這樣:
Pizzapizza=makePizza(crust(salt(),butter()), topping(onion(),tomato(),sausage()));
這樣的代碼一行太長,而且嵌套太多,不容易看清楚。其實訓(xùn)練有素的函數(shù)式程序員,都知道中間變量的好處,不會盲目的使用嵌套的函數(shù)。他們會把這代碼變成這樣:
Crustcrust=crust(salt(),butter()); Toppingtopping=topping(onion(),tomato(),sausage()); Pizzapizza=makePizza(crust,topping);
這樣寫,不但有效地控制了單行代碼的長度,而且由于引入的中間變量具有“意義”,步驟清晰,變得很容易理解。
在合理的地方換行。對于絕大部分的程序語言,代碼的邏輯是和空白字符無關(guān)的,所以你可以在幾乎任何地方換行,你也可以不換行。這樣的語言設(shè)計是個好東西,因為它給了程序員自由控制自己代碼格式的能力。然而,它也引起了一些問題,因為很多人不知道如何合理的換行。
有些人喜歡利用IDE的自動換行機制,編輯之后用一個熱鍵把整個代碼重新格式化一遍,IDE就會把超過行寬限制的代碼自動折行??墒沁@種自動這行,往往沒有根據(jù)代碼的邏輯來進(jìn)行,不能幫助理解代碼。自動換行之后可能產(chǎn)生這樣的代碼:
if(someLongCondition1()&&someLongCondition2()&&someLongCondition3()&& someLongCondition4()){ ... }
由于someLongCondition4()超過了行寬限制,被編輯器自動換到了下面一行。雖然滿足了行寬限制,換行的位置卻是相當(dāng)任意的,它并不能幫助人理解這代碼的邏輯。這幾個boolean表達(dá)式,全都用&&連接,所以它們其實處于平等的地位。為了表達(dá)這一點,當(dāng)需要折行的時候,你應(yīng)該把每一個表達(dá)式都放到新的一行,就像這個樣子:
if(someLongCondition1()&& someLongCondition2()&& someLongCondition3()&& someLongCondition4()){ ... }
這樣每一個條件都對齊,里面的邏輯就很清楚了。再舉個例子:
log.info("failedtofindfile{}forcommand{},withexception{}",file,command, exception);
這行因為太長,被自動折行成這個樣子。file,command和exception本來是同一類東西,卻有兩個留在了第一行,最后一個被折到第二行。它就不如手動換行成這個樣子:
log.info("failedtofindfile{}forcommand{},withexception{}", file,command,exception);
把格式字符串單獨放在一行,而把它的參數(shù)一并放在另外一行,這樣邏輯就更加清晰。
為了避免IDE把這些手動調(diào)整好的換行弄亂,很多IDE(比如IntelliJ)的自動格式化設(shè)定里都有“保留原來的換行符”的設(shè)定。如果你發(fā)現(xiàn)IDE的換行不符合邏輯,你可以修改這些設(shè)定,然后在某些地方保留你自己的手動換行。
說到這里,我必須警告你,這里所說的“不需注釋,讓代碼自己解釋自己”,并不是說要讓代碼看起來像某種自然語言。有個叫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);
這種做法是極其錯誤的。程序語言本來就比自然語言簡單清晰,這種寫法讓它看起來像自然語言的樣子,反而變得復(fù)雜難懂了。
寫簡單的代碼
程序語言都喜歡標(biāo)新立異,提供這樣那樣的“特性”,然而有些特性其實并不是什么好東西。很多特性都經(jīng)不起時間的考驗,最后帶來的麻煩,比解決的問題還多。很多人盲目的追求“短小”和“精悍”,或者為了顯示自己頭腦聰明,學(xué)得快,所以喜歡利用語言里的一些特殊構(gòu)造,寫出過于“聰明”,難以理解的代碼。
并不是語言提供什么,你就一定要把它用上的。實際上你只需要其中很小的一部分功能,就能寫出優(yōu)秀的代碼。我一向反對“充分利用”程序語言里的所有特性。實際上,我心目中有一套最好的構(gòu)造。不管語言提供了多么“神奇”的,“新”的特性,我基本都只用經(jīng)過千錘百煉,我覺得值得信賴的那一套。
現(xiàn)在針對一些有問題的語言特性,我介紹一些我自己使用的代碼規(guī)范,并且講解一下為什么它們能讓代碼更簡單。
避免使用自增減表達(dá)式(i++,++i,i–,–i)。這種自增減操作表達(dá)式其實是歷史遺留的設(shè)計失誤。它們含義蹊蹺,非常容易弄錯。它們把讀和寫這兩種完全不同的操作,混淆纏繞在一起,把語義搞得烏七八糟。含有它們的表達(dá)式,結(jié)果可能取決于求值順序,所以它可能在某種編譯器下能正確運行,換一個編譯器就出現(xiàn)離奇的錯誤。
其實這兩個表達(dá)式完全可以分解成兩步,把讀和寫分開:一步更新i的值,另外一步使用i的值。比如,如果你想寫foo(i++),你完全可以把它拆成int t = i; i += 1; foo(t);。如果你想寫foo(++i),可以拆成i += 1; foo(i);拆開之后的代碼,含義完全一致,卻清晰很多。到底更新是在取值之前還是之后,一目了然。
有人也許以為i++或者++i的效率比拆開之后要高,這只是一種錯覺。這些代碼經(jīng)過基本的編譯器優(yōu)化之后,生成的機器代碼是完全沒有區(qū)別的。自增減表達(dá)式只有在兩種情況下才可以安全的使用。一種是在for循環(huán)的update部分,比如for(int i = 0; i < 5; i++)。另一種情況是寫成單獨的一行,比如i++;。這兩種情況是完全沒有歧義的。你需要避免其它的情況,比如用在復(fù)雜的表達(dá)式里面,比如foo(i++),foo(++i) + foo(i),…… 沒有人應(yīng)該知道,或者去追究這些是什么意思。
永遠(yuǎn)不要省略花括號。很多語言允許你在某種情況下省略掉花括號,比如C,Java都允許你在if語句里面只有一句話的時候省略掉花括號:
if(...) action1();
咋一看少打了兩個字,多好??墒沁@其實經(jīng)常引起奇怪的問題。比如,你后來想要加一句話action2()到這個if里面,于是你就把代碼改成:
if(...) action1(); action2();
為了美觀,你很小心的使用了action1()的縮進(jìn)。咋一看它們是在一起的,所以你下意識里以為它們只會在if的條件為真的時候執(zhí)行,然而action2()卻其實在if外面,它會被無條件的執(zhí)行。我把這種現(xiàn)象叫做“光學(xué)幻覺”(optical illusion),理論上每個程序員都應(yīng)該發(fā)現(xiàn)這個錯誤,然而實際上卻容易被忽視。
那么你問,誰會這么傻,我在加入action2()的時候加上花括號不就行了?可是從設(shè)計的角度來看,這樣其實并不是合理的作法。首先,也許你以后又想把action2()去掉,這樣你為了樣式一致,又得把花括號拿掉,煩不煩啊?其次,這使得代碼樣式不一致,有的if有花括號,有的又沒有。況且,你為什么需要記住這個規(guī)則?如果你不問三七二十一,只要是if-else語句,把花括號全都打上,就可以想都不用想了,就當(dāng)C和Java沒提供給你這個特殊寫法。這樣就可以保持完全的一致性,減少不必要的思考。
有人可能會說,全都打上花括號,只有一句話也打上,多礙眼???然而經(jīng)過實行這種編碼規(guī)范幾年之后,我并沒有發(fā)現(xiàn)這種寫法更加礙眼,反而由于花括號的存在,使得代碼界限明確,讓我的眼睛負(fù)擔(dān)更小了。
合理使用括號,不要盲目依賴操作符優(yōu)先級。利用操作符的優(yōu)先級來減少括號,對于1 + 2 * 3這樣常見的算數(shù)表達(dá)式,是沒問題的。然而有些人如此的仇恨括號,以至于他們會寫出2 << 7 - 2 * 3這樣的表達(dá)式,而完全不用括號。
這里的問題,在于移位操作<<的優(yōu)先級,是很多人不熟悉,而且是違反常理的。由于x << 1相當(dāng)于把x乘以2,很多人誤以為這個表達(dá)式相當(dāng)于(2 << 7) - (2 * 3),所以等于250。然而實際上<<的優(yōu)先級比加法+還要低,所以這表達(dá)式其實相當(dāng)于2 << (7 - 2 * 3),所以等于4!
解決這個問題的辦法,不是要每個人去把操作符優(yōu)先級表給硬背下來,而是合理的加入括號。比如上面的例子,最好直接加上括號寫成2 << (7 - 2 * 3)。雖然沒有括號也表示同樣的意思,但是加上括號就更加清晰,讀者不再需要死記<<的優(yōu)先級就能理解代碼。
避免使用continue和break。循環(huán)語句(for,while)里面出現(xiàn)return是沒問題的,然而如果你使用了continue或者break,就會讓循環(huán)的邏輯和終止條件變得復(fù)雜,難以確保正確。
出現(xiàn)continue或者break的原因,往往是對循環(huán)的邏輯沒有想清楚。如果你考慮周全了,應(yīng)該是幾乎不需要continue或者break的。如果你的循環(huán)里出現(xiàn)了continue或者break,你就應(yīng)該考慮改寫這個循環(huán)。改寫循環(huán)的辦法有多種:
如果出現(xiàn)了continue,你往往只需要把continue的條件反向,就可以消除continue。
如果出現(xiàn)了break,你往往可以把break的條件,合并到循環(huán)頭部的終止條件里,從而去掉break。
有時候你可以把break替換成return,從而去掉break。
如果以上都失敗了,你也許可以把循環(huán)里面復(fù)雜的部分提取出來,做成函數(shù)調(diào)用,之后continue或者break就可以去掉了。
下面我對這些情況舉一些例子。
情況1:下面這段代碼里面有一個continue:
ListgoodNames=newArrayList<>(); for(Stringname:names){ if(name.contains("bad")){ continue; } goodNames.add(name); ... }
它說:“如果name含有’bad’這個詞,跳過后面的循環(huán)代碼……” 注意,這是一種“負(fù)面”的描述,它不是在告訴你什么時候“做”一件事,而是在告訴你什么時候“不做”一件事。為了知道它到底在干什么,你必須搞清楚continue會導(dǎo)致哪些語句被跳過了,然后腦子里把邏輯反個向,你才能知道它到底想做什么。這就是為什么含有continue和break的循環(huán)不容易理解,它們依靠“控制流”來描述“不做什么”,“跳過什么”,結(jié)果到最后你也沒搞清楚它到底“要做什么”。
其實,我們只需要把continue的條件反向,這段代碼就可以很容易的被轉(zhuǎn)換成等價的,不含continue的代碼:
ListgoodNames=newArrayList<>(); for(Stringname:names){ if(!name.contains("bad")){ goodNames.add(name); ... } }
goodNames.add(name);和它之后的代碼全部被放到了if里面,多了一層縮進(jìn),然而continue卻沒有了。你再讀這段代碼,就會發(fā)現(xiàn)更加清晰。因為它是一種更加“正面”地描述。它說:“在name不含有’bad’這個詞的時候,把它加到goodNames的鏈表里面……”
情況2:for和while頭部都有一個循環(huán)的“終止條件”,那本來應(yīng)該是這個循環(huán)唯一的退出條件。如果你在循環(huán)中間有break,它其實給這個循環(huán)增加了一個退出條件。你往往只需要把這個條件合并到循環(huán)頭部,就可以去掉break。
比如下面這段代碼:
while(condition1){ ... if(condition2){ break; } }
當(dāng)condition成立的時候,break會退出循環(huán)。其實你只需要把condition2反轉(zhuǎn)之后,放到while頭部的終止條件,就可以去掉這種break語句。改寫后的代碼如下:
while(condition1&&!condition2){ ... }
這種情況表面上貌似只適用于break出現(xiàn)在循環(huán)開頭或者末尾的時候,然而其實大部分時候,break都可以通過某種方式,移動到循環(huán)的開頭或者末尾。具體的例子我暫時沒有,等出現(xiàn)的時候再加進(jìn)來。
情況3:很多break退出循環(huán)之后,其實接下來就是一個return。這種break往往可以直接換成return。比如下面這個例子:
publicbooleanhasBadName(Listnames){ booleanresult=false; for(Stringname:names){ if(name.contains("bad")){ result=true; break; } } returnresult; }
這個函數(shù)檢查names鏈表里是否存在一個名字,包含“bad”這個詞。它的循環(huán)里包含一個break語句。這個函數(shù)可以被改寫成:
publicbooleanhasBadName(Listnames){ for(Stringname:names){ if(name.contains("bad")){ returntrue; } } returnfalse; }
改進(jìn)后的代碼,在name里面含有“bad”的時候,直接用return true返回,而不是對result變量賦值,break出去,最后才返回。如果循環(huán)結(jié)束了還沒有return,那就返回false,表示沒有找到這樣的名字。使用return來代替break,這樣break語句和result這個變量,都一并被消除掉了。
我曾經(jīng)見過很多其他使用continue和break的例子,幾乎無一例外的可以被消除掉,變換后的代碼變得清晰很多。我的經(jīng)驗是,99%的break和continue,都可以通過替換成return語句,或者翻轉(zhuǎn)if條件的方式來消除掉。剩下的1%含有復(fù)雜的邏輯,但也可以通過提取一個幫助函數(shù)來消除掉。修改之后的代碼變得容易理解,容易確保正確。
寫直觀的代碼
我寫代碼有一條重要的原則:如果有更加直接,更加清晰的寫法,就選擇它,即使它看起來更長,更笨,也一樣選擇它。比如,Unix命令行有一種“巧妙”的寫法是這樣:
command1&&command2&&command3
由于 Shell 語言的邏輯操作a && b具有“短路”的特性,如果a等于false,那么b就沒必要執(zhí)行了。這就是為什么當(dāng) command1 成功,才會執(zhí)行 command2,當(dāng) command2 成功,才會執(zhí)行 command3。同樣,
command1||command2||command3
操作符||也有類似的特性。上面這個命令行,如果command1成功,那么command2和command3都不會被執(zhí)行。如果command1失敗,command2成功,那么command3就不會被執(zhí)行。
這比起用if語句來判斷失敗,似乎更加巧妙和簡潔,所以有人就借鑒了這種方式,在程序的代碼里也使用這種方式。比如他們可能會寫這樣的代碼:
if(action1()||action2()&&action3()){ ... }
你看得出來這代碼是想干什么嗎?action2和action3什么條件下執(zhí)行,什么條件下不執(zhí)行?也許稍微想一下,你知道它在干什么:“如果action1失敗了,執(zhí)行action2,如果action2成功了,執(zhí)行action3”。然而那種語義,并不是直接的“映射”在這代碼上面的。比如“失敗”這個詞,對應(yīng)了代碼里的哪一個字呢?你找不出來,因為它包含在了||的語義里面,你需要知道||的短路特性,以及邏輯或的語義才能知道這里面在說“如果action1失敗……”。每一次看到這行代碼,你都需要思考一下,這樣積累起來的負(fù)荷,就會讓人很累。
其實,這種寫法是濫用了邏輯操作&&和||的短路特性。這兩個操作符可能不執(zhí)行右邊的表達(dá)式,原因是為了機器的執(zhí)行效率,而不是為了給人提供這種“巧妙”的用法。這兩個操作符的本意,只是作為邏輯操作,它們并不是拿來給你代替if語句的。也就是說,它們只是碰巧可以達(dá)到某些if語句的效果,但你不應(yīng)該因此就用它來代替if語句。如果你這樣做了,就會讓代碼晦澀難懂。
上面的代碼寫成笨一點的辦法,就會清晰很多:
if(!action1()){ if(action2()){ action3(); } }
這里我很明顯的看出這代碼在說什么,想都不用想:如果action1()失敗了,那么執(zhí)行action2(),如果action2()成功了,執(zhí)行action3()。你發(fā)現(xiàn)這里面的一一對應(yīng)關(guān)系嗎?if=如果,!=失敗,…… 你不需要利用邏輯學(xué)知識,就知道它在說什么。
寫無懈可擊的代碼
在之前一節(jié)里,我提到了自己寫的代碼里面很少出現(xiàn)只有一個分支的if語句。我寫出的if語句,大部分都有兩個分支,所以我的代碼很多看起來是這個樣子:
if(...){ if(...){ ... returnfalse; }else{ returntrue; } }elseif(...){ ... returnfalse; }else{ returntrue; }
使用這種方式,其實是為了無懈可擊的處理所有可能出現(xiàn)的情況,避免漏掉corner case。每個if語句都有兩個分支的理由是:如果if的條件成立,你做某件事情;但是如果if的條件不成立,你應(yīng)該知道要做什么另外的事情。不管你的if有沒有else,你終究是逃不掉,必須得思考這個問題的。
很多人寫if語句喜歡省略else的分支,因為他們覺得有些else分支的代碼重復(fù)了。比如我的代碼里,兩個else分支都是return true。為了避免重復(fù),他們省略掉那兩個else分支,只在最后使用一個return true。這樣,缺了else分支的if語句,控制流自動“掉下去”,到達(dá)最后的return true。他們的代碼看起來像這個樣子:
if(...){ if(...){ ... returnfalse; } }elseif(...){ ... returnfalse; } returntrue;
這種寫法看似更加簡潔,避免了重復(fù),然而卻很容易出現(xiàn)疏忽和漏洞。嵌套的if語句省略了一些else,依靠語句的“控制流”來處理else的情況,是很難正確的分析和推理的。如果你的if條件里使用了&&和||之類的邏輯運算,就更難看出是否涵蓋了所有的情況。
由于疏忽而漏掉的分支,全都會自動“掉下去”,最后返回意想不到的結(jié)果。即使你看一遍之后確信是正確的,每次讀這段代碼,你都不能確信它照顧了所有的情況,又得重新推理一遍。這簡潔的寫法,帶來的是反復(fù)的,沉重的頭腦開銷。這就是所謂“面條代碼”,因為程序的邏輯分支,不是像一棵枝葉分明的樹,而是像面條一樣繞來繞去。
另外一種省略else分支的情況是這樣:
Strings=""; if(x5)?{ ??s?=?"ok"; }
寫這段代碼的人,腦子里喜歡使用一種“缺省值”的做法。s缺省為null,如果x<5,那么把它改變(mutate)成“ok”。這種寫法的缺點是,當(dāng)x<5不成立的時候,你需要往上面看,才能知道s的值是什么。這還是你運氣好的時候,因為s就在上面不遠(yuǎn)。很多人寫這種代碼的時候,s的初始值離判斷語句有一定的距離,中間還有可能插入一些其它的邏輯和賦值操作。這樣的代碼,把變量改來改去的,看得人眼花,就容易出錯。
現(xiàn)在比較一下我的寫法:
Strings; if(x5)?{ ??s?=?"ok"; }?else?{ ??s?=?""; }
這種寫法貌似多打了一兩個字,然而它卻更加清晰。這是因為我們明確的指出了x<5不成立的時候,s的值是什么。它就擺在那里,它是""(空字符串)。注意,雖然我也使用了賦值操作,然而我并沒有“改變”s的值。s一開始的時候沒有值,被賦值之后就再也沒有變過。我的這種寫法,通常被叫做更加“函數(shù)式”,因為我只賦值一次。
如果我漏寫了else分支,Java編譯器是不會放過我的。它會抱怨:“在某個分支,s沒有被初始化。”這就強迫我清清楚楚的設(shè)定各種條件下s的值,不漏掉任何一種情況。
當(dāng)然,由于這個情況比較簡單,你還可以把它寫成這樣:
Strings=x5???"ok"?:?"";
對于更加復(fù)雜的情況,我建議還是寫成if語句為好。
正確處理錯誤
使用有兩個分支的if語句,只是我的代碼可以達(dá)到無懈可擊的其中一個原因。這樣寫if語句的思路,其實包含了使代碼可靠的一種通用思想:窮舉所有的情況,不漏掉任何一個。
程序的絕大部分功能,是進(jìn)行信息處理。從一堆紛繁復(fù)雜,模棱兩可的信息中,排除掉絕大部分“干擾信息”,找到自己需要的那一個。正確地對所有的“可能性”進(jìn)行推理,就是寫出無懈可擊代碼的核心思想。這一節(jié)我來講一講,如何把這種思想用在錯誤處理上。
錯誤處理是一個古老的問題,可是經(jīng)過了幾十年,還是很多人沒搞明白。Unix的系統(tǒng)API手冊,一般都會告訴你可能出現(xiàn)的返回值和錯誤信息。比如,Linux的read系統(tǒng)調(diào)用手冊里面有如下內(nèi)容:
RETURNVALUE Onsuccess,thenumberofbytesreadisreturned... Onerror,-1isreturned,anderrnoissetappropriately. ERRORS EAGAIN,EBADF,EFAULT,EINTR,EINVAL,...
很多初學(xué)者,都會忘記檢查read的返回值是否為-1,覺得每次調(diào)用read都得檢查返回值真繁瑣,不檢查貌似也相安無事。這種想法其實是很危險的。如果函數(shù)的返回值告訴你,要么返回一個正數(shù),表示讀到的數(shù)據(jù)長度,要么返回-1,那么你就必須要對這個-1作出相應(yīng)的,有意義的處理。千萬不要以為你可以忽視這個特殊的返回值,因為它是一種“可能性”。代碼漏掉任何一種可能出現(xiàn)的情況,都可能產(chǎn)生意想不到的災(zāi)難性結(jié)果。
對于Java來說,這相對方便一些。Java的函數(shù)如果出現(xiàn)問題,一般通過異常(exception)來表示。你可以把異常加上函數(shù)本來的返回值,看成是一個“union類型”。比如:
Stringfoo()throwsMyException{ ... }
這里MyException是一個錯誤返回。你可以認(rèn)為這個函數(shù)返回一個union類型:{String, MyException}。任何調(diào)用foo的代碼,必須對MyException作出合理的處理,才有可能確保程序的正確運行。Union類型是一種相當(dāng)先進(jìn)的類型,目前只有極少數(shù)語言(比如Typed Racket)具有這種類型,我在這里提到它,只是為了方便解釋概念。掌握了概念之后,你其實可以在頭腦里實現(xiàn)一個union類型系統(tǒng),這樣使用普通的語言也能寫出可靠的代碼。
由于Java的類型系統(tǒng)強制要求函數(shù)在類型里面聲明可能出現(xiàn)的異常,而且強制調(diào)用者處理可能出現(xiàn)的異常,所以基本上不可能出現(xiàn)由于疏忽而漏掉的情況。但有些Java程序員有一種惡習(xí),使得這種安全機制幾乎完全失效。每當(dāng)編譯器報錯,說“你沒有catch這個foo函數(shù)可能出現(xiàn)的異常”時,有些人想都不想,直接把代碼改成這樣:
try{ foo(); }catch(Exceptione){}
或者最多在里面放個log,或者干脆把自己的函數(shù)類型上加上throws Exception,這樣編譯器就不再抱怨。這些做法貌似很省事,然而都是錯誤的,你終究會為此付出代價。
如果你把異常catch了,忽略掉,那么你就不知道foo其實失敗了。這就像開車時看到路口寫著“前方施工,道路關(guān)閉”,還繼續(xù)往前開。這當(dāng)然遲早會出問題,因為你根本不知道自己在干什么。
catch異常的時候,你不應(yīng)該使用Exception這么寬泛的類型。你應(yīng)該正好catch可能發(fā)生的那種異常A。使用寬泛的異常類型有很大的問題,因為它會不經(jīng)意的catch住另外的異常(比如B)。你的代碼邏輯是基于判斷A是否出現(xiàn),可你卻catch所有的異常(Exception類),所以當(dāng)其它的異常B出現(xiàn)的時候,你的代碼就會出現(xiàn)莫名其妙的問題,因為你以為A出現(xiàn)了,而其實它沒有。這種bug,有時候甚至使用debugger都難以發(fā)現(xiàn)。
如果你在自己函數(shù)的類型加上throws Exception,那么你就不可避免的需要在調(diào)用它的地方處理這個異常,如果調(diào)用它的函數(shù)也寫著throws Exception,這毛病就傳得更遠(yuǎn)。我的經(jīng)驗是,盡量在異常出現(xiàn)的當(dāng)時就作出處理。否則如果你把它返回給你的調(diào)用者,它也許根本不知道該怎么辦了。
另外,try { … } catch里面,應(yīng)該包含盡量少的代碼。比如,如果foo和bar都可能產(chǎn)生異常A,你的代碼應(yīng)該盡可能寫成:
try{ foo(); }catch(Ae){...} try{ bar(); }catch(Ae){...}
而不是
try{ foo(); bar(); }catch(Ae){...}
第一種寫法能明確的分辨是哪一個函數(shù)出了問題,而第二種寫法全都混在一起。明確的分辨是哪一個函數(shù)出了問題,有很多的好處。比如,如果你的catch代碼里面包含log,它可以提供給你更加精確的錯誤信息,這樣會大大地加速你的調(diào)試過程。
正確處理null指針
窮舉的思想是如此的有用,依據(jù)這個原理,我們可以推出一些基本原則,它們可以讓你無懈可擊的處理null指針。
首先你應(yīng)該知道,許多語言(C,C++,Java,C#,……)的類型系統(tǒng)對于null的處理,其實是完全錯誤的。這個錯誤源自于Tony Hoare最早的設(shè)計,Hoare把這個錯誤稱為自己的“billion dollar mistake”,因為由于它所產(chǎn)生的財產(chǎn)和人力損失,遠(yuǎn)遠(yuǎn)超過十億美元。
這些語言的類型系統(tǒng)允許null出現(xiàn)在任何對象(指針)類型可以出現(xiàn)的地方,然而null其實根本不是一個合法的對象。它不是一個String,不是一個Integer,也不是一個自定義的類。null的類型本來應(yīng)該是NULL,也就是null自己。根據(jù)這個基本觀點,我們推導(dǎo)出以下原則:
盡量不要產(chǎn)生null指針。盡量不要用null來初始化變量,函數(shù)盡量不要返回null。如果你的函數(shù)要返回“沒有”,“出錯了”之類的結(jié)果,盡量使用Java的異常機制。雖然寫法上有點別扭,然而Java的異常,和函數(shù)的返回值合并在一起,基本上可以當(dāng)成union類型來用。比如,如果你有一個函數(shù)find,可以幫你找到一個String,也有可能什么也找不到,你可以這樣寫:
publicStringfind()throwsNotFoundException{ if(...){ return...; }else{ thrownewNotFoundException(); } }
Java的類型系統(tǒng)會強制你catch這個NotFoundException,所以你不可能像漏掉檢查null一樣,漏掉這種情況。Java的異常也是一個比較容易濫用的東西,不過我已經(jīng)在上一節(jié)告訴你如何正確的使用異常。
Java的try…catch語法相當(dāng)?shù)姆爆嵑王磕_,所以如果你足夠小心的話,像find這類函數(shù),也可以返回null來表示“沒找到”。這樣稍微好看一些,因為你調(diào)用的時候不必用try…catch。很多人寫的函數(shù),返回null來表示“出錯了”,這其實是對null的誤用。“出錯了”和“沒有”,其實完全是兩碼事?!皼]有”是一種很常見,正常的情況,比如查哈希表沒找到,很正常?!俺鲥e了”則表示罕見的情況,本來正常情況下都應(yīng)該存在有意義的值,偶然出了問題。如果你的函數(shù)要表示“出錯了”,應(yīng)該使用異常,而不是null。
不要catch NullPointerException。有些人寫代碼很nice,他們喜歡“容錯”。首先他們寫一些函數(shù),這些函數(shù)里面不大小心,沒檢查null指針:
voidfoo(){ Stringfound=find(); intlen=found.length(); ... }
當(dāng)foo調(diào)用產(chǎn)生了異常,他們不管三七二十一,就把調(diào)用的地方改成這樣:
try{ foo(); }catch(Exceptione){ ... }
這樣當(dāng)found是null的時候,NullPointerException就會被捕獲并且得到處理。這其實是很錯誤的作法。首先,上一節(jié)已經(jīng)提到了,catch (Exception e)這種寫法是要絕對避免的,因為它捕獲所有的異常,包括NullPointerException。這會讓你意外地捕獲try語句里面出現(xiàn)的NullPointerException,從而把代碼的邏輯攪得一塌糊涂。
另外就算你寫成catch (NullPointerException e)也是不可以的。由于foo的內(nèi)部缺少了null檢查,才出現(xiàn)了NullPointerException?,F(xiàn)在你不對癥下藥,倒把每個調(diào)用它的地方加上catch,以后你的生活就會越來越苦。正確的做法應(yīng)該是改動foo,而不改調(diào)用它的代碼。foo應(yīng)該被改成這樣:
voidfoo(){ Stringfound=find(); if(found!=null){ intlen=found.length(); ... }else{ ... } }
在null可能出現(xiàn)的當(dāng)時就檢查它是否是null,然后進(jìn)行相應(yīng)的處理。
不要把null放進(jìn)“容器數(shù)據(jù)結(jié)構(gòu)”里面。所謂容器(collection),是指一些對象以某種方式集合在一起,所以null不應(yīng)該被放進(jìn)Array,List,Set等結(jié)構(gòu),不應(yīng)該出現(xiàn)在Map的key或者value里面。把null放進(jìn)容器里面,是一些莫名其妙錯誤的來源。因為對象在容器里的位置一般是動態(tài)決定的,所以一旦null從某個入口跑進(jìn)去了,你就很難再搞明白它去了哪里,你就得被迫在所有從這個容器里取值的位置檢查null。你也很難知道到底是誰把它放進(jìn)去的,代碼多了就導(dǎo)致調(diào)試極其困難。
解決方案是:如果你真要表示“沒有”,那你就干脆不要把它放進(jìn)去(Array,List,Set沒有元素,Map根本沒那個entry),或者你可以指定一個特殊的,真正合法的對象,用來表示“沒有”。
需要指出的是,類對象并不屬于容器。所以null在必要的時候,可以作為對象成員的值,表示它不存在。比如:
classA{ Stringname=null; ... }
之所以可以這樣,是因為null只可能在A對象的name成員里出現(xiàn),你不用懷疑其它的成員因此成為null。所以你每次訪問name成員時,檢查它是否是null就可以了,不需要對其他成員也做同樣的檢查。
函數(shù)調(diào)用者:明確理解null所表示的意義,盡早檢查和處理null返回值,減少它的傳播。null很討厭的一個地方,在于它在不同的地方可能表示不同的意義。有時候它表示“沒有”,“沒找到”。有時候它表示“出錯了”,“失敗了”。有時候它甚至可以表示“成功了”,…… 這其中有很多誤用之處,不過無論如何,你必須理解每一個null的意義,不能給混淆起來。
如果你調(diào)用的函數(shù)有可能返回null,那么你應(yīng)該在第一時間對null做出“有意義”的處理。比如,上述的函數(shù)find,返回null表示“沒找到”,那么調(diào)用find的代碼就應(yīng)該在它返回的第一時間,檢查返回值是否是null,并且對“沒找到”這種情況,作出有意義的處理。
“有意義”是什么意思呢?我的意思是,使用這函數(shù)的人,應(yīng)該明確的知道在拿到null的情況下該怎么做,承擔(dān)起責(zé)任來。他不應(yīng)該只是“向上級匯報”,把責(zé)任踢給自己的調(diào)用者。如果你違反了這一點,就有可能采用一種不負(fù)責(zé)任,危險的寫法:
publicStringfoo(){ Stringfound=find(); if(found==null){ returnnull; } }
當(dāng)看到find()返回了null,foo自己也返回null。這樣null就從一個地方,游走到了另一個地方,而且它表示另外一個意思。如果你不假思索就寫出這樣的代碼,最后的結(jié)果就是代碼里面隨時隨地都可能出現(xiàn)null。到后來為了保護(hù)自己,你的每個函數(shù)都會寫成這樣:
publicvoidfoo(Aa,Bb,Cc){ if(a==null){...} if(b==null){...} if(c==null){...} ... }
函數(shù)作者:明確聲明不接受null參數(shù),當(dāng)參數(shù)是null時立即崩潰。不要試圖對null進(jìn)行“容錯”,不要讓程序繼續(xù)往下執(zhí)行。如果調(diào)用者使用了null作為參數(shù),那么調(diào)用者(而不是函數(shù)作者)應(yīng)該對程序的崩潰負(fù)全責(zé)。
上面的例子之所以成為問題,就在于人們對于null的“容忍態(tài)度”。這種“保護(hù)式”的寫法,試圖“容錯”,試圖“優(yōu)雅的處理null”,其結(jié)果是讓調(diào)用者更加肆無忌憚的傳遞null給你的函數(shù)。到后來,你的代碼里出現(xiàn)一堆堆nonsense的情況,null可以在任何地方出現(xiàn),都不知道到底是哪里產(chǎn)生出來的。誰也不知道出現(xiàn)了null是什么意思,該做什么,所有人都把null踢給其他人。最后這null像瘟疫一樣蔓延開來,到處都是,成為一場噩夢。
正確的做法,其實是強硬的態(tài)度。你要告訴函數(shù)的使用者,我的參數(shù)全都不能是null,如果你給我null,程序崩潰了該你自己負(fù)責(zé)。至于調(diào)用者代碼里有null怎么辦,他自己該知道怎么處理(參考以上幾條),不應(yīng)該由函數(shù)作者來操心。
采用強硬態(tài)度一個很簡單的做法是使用Objects.requireNonNull()。它的定義很簡單:
publicstaticTrequireNonNull(Tobj){ if(obj==null){ thrownewNullPointerException(); }else{ returnobj; } }
你可以用這個函數(shù)來檢查不想接受null的每一個參數(shù),只要傳進(jìn)來的參數(shù)是null,就會立即觸發(fā)NullPointerException崩潰掉,這樣你就可以有效地防止null指針不知不覺傳遞到其它地方去。
使用@NotNull和@Nullable標(biāo)記。IntelliJ提供了@NotNull和@Nullable兩種標(biāo)記,加在類型前面,這樣可以比較簡潔可靠地防止null指針的出現(xiàn)。IntelliJ本身會對含有這種標(biāo)記的代碼進(jìn)行靜態(tài)分析,指出運行時可能出現(xiàn)NullPointerException的地方。在運行時,會在null指針不該出現(xiàn)的地方產(chǎn)生IllegalArgumentException,即使那個null指針你從來沒有deference。這樣你可以在盡量早期發(fā)現(xiàn)并且防止null指針的出現(xiàn)。
使用Optional類型。Java 8和Swift之類的語言,提供了一種叫Optional的類型。正確的使用這種類型,可以在很大程度上避免null的問題。null指針的問題之所以存在,是因為你可以在沒有“檢查”null的情況下,“訪問”對象的成員。
Optional類型的設(shè)計原理,就是把“檢查”和“訪問”這兩個操作合二為一,成為一個“原子操作”。這樣你沒法只訪問,而不進(jìn)行檢查。這種做法其實是ML,Haskell等語言里的模式匹配(pattern matching)的一個特例。模式匹配使得類型判斷和訪問成員這兩種操作合二為一,所以你沒法犯錯。
比如,在Swift里面,你可以這樣寫:
letfound=find() ifletcontent=found{ print("found:"+content) }
你從find()函數(shù)得到一個Optional類型的值found。假設(shè)它的類型是String?,那個問號表示它可能包含一個String,也可能是nil。然后你就可以用一種特殊的if語句,同時進(jìn)行null檢查和訪問其中的內(nèi)容。這個if語句跟普通的if語句不一樣,它的條件不是一個Bool,而是一個變量綁定let content = found。
我不是很喜歡這語法,不過這整個語句的含義是:如果found是nil,那么整個if語句被略過。如果它不是nil,那么變量content被綁定到found里面的值(unwrap操作),然后執(zhí)行print("found: " + content)。由于這種寫法把檢查和訪問合并在了一起,你沒法只進(jìn)行訪問而不檢查。
Java 8的做法比較蹩腳一些。如果你得到一個Optional
Optionalfound=find(); found.ifPresent(content->System.out.println("found:"+content));
這段Java代碼跟上面的Swift代碼等價,它包含一個“判斷”和一個“取值”操作。ifPresent先判斷found是否有值(相當(dāng)于判斷是不是null)。如果有,那么將其內(nèi)容“綁定”到lambda表達(dá)式的content參數(shù)(unwrap操作),然后執(zhí)行l(wèi)ambda里面的內(nèi)容,否則如果found沒有內(nèi)容,那么ifPresent里面的lambda不執(zhí)行。
Java的這種設(shè)計有個問題。判斷null之后分支里的內(nèi)容,全都得寫在lambda里面。在函數(shù)式編程里,這個lambda叫做“continuation”,Java把它叫做 “Consumer”,它表示“如果found不是null,拿到它的值,然后應(yīng)該做什么”。由于lambda是個函數(shù),你不能在里面寫return語句返回出外層的函數(shù)。比如,如果你要改寫下面這個函數(shù)(含有null):
publicstaticStringfoo(){ Stringfound=find(); if(found!=null){ returnfound; }else{ return""; } }
就會比較麻煩。因為如果你寫成這樣:
publicstaticStringfoo(){ Optionalfound=find(); found.ifPresent(content->{ returncontent;//can'treturnfromfoohere }); return""; }
里面的return a,并不能從函數(shù)foo返回出去。它只會從lambda返回,而且由于那個lambda(Consumer.accept)的返回類型必須是void,編譯器會報錯,說你返回了String。由于Java里closure的自由變量是只讀的,你沒法對lambda外面的變量進(jìn)行賦值,所以你也不能采用這種寫法:
publicstaticStringfoo(){ Optionalfound=find(); Stringresult=""; found.ifPresent(content->{ result=content;//can'tassigntoresult }); returnresult; }
所以,雖然你在lambda里面得到了found的內(nèi)容,如何使用這個值,如何返回一個值,卻讓人摸不著頭腦。你平時的那些Java編程手法,在這里幾乎完全廢掉了。實際上,判斷null之后,你必須使用Java 8提供的一系列古怪的函數(shù)式編程操作:map,flatMap,orElse之類,想法把它們組合起來,才能表達(dá)出原來代碼的意思。比如之前的代碼,只能改寫成這樣:
publicstaticStringfoo(){ Optionalfound=find(); returnfound.orElse(""); }
這簡單的情況還好。復(fù)雜一點的代碼,我還真不知道怎么表達(dá),我懷疑Java 8的Optional類型的方法,到底有沒有提供足夠的表達(dá)力。那里面少數(shù)幾個東西表達(dá)能力不咋的,論工作原理,卻可以扯到functor,continuation,甚至monad等高深的理論…… 仿佛用了Optional之后,這語言就不再是Java了一樣。
所以Java雖然提供了Optional,但我覺得可用性其實比較低,難以被人接受。相比之下,Swift的設(shè)計更加簡單直觀,接近普通的過程式編程。你只需要記住一個特殊的語法if let content = found {...},里面的代碼寫法,跟普通的過程式語言沒有任何差別。
總之你只要記住,使用Optional類型,要點在于“原子操作”,使得null檢查與取值合二為一。這要求你必須使用我剛才介紹的特殊寫法。如果你違反了這一原則,把檢查和取值分成兩步做,還是有可能犯錯誤。比如在Java 8里面,你可以使用found.get()這樣的方式直接訪問found里面的內(nèi)容。在Swift里你也可以使用found!來直接訪問而不進(jìn)行檢查。
你可以寫這樣的Java代碼來使用Optional類型:
Optionfound=find(); if(found.isPresent()){ System.out.println("found:"+found.get()); }
如果你使用這種方式,把檢查和取值分成兩步做,就可能會出現(xiàn)運行時錯誤。if (found.isPresent())本質(zhì)上跟普通的null檢查,其實沒什么兩樣。如果你忘記判斷found.isPresent(),直接進(jìn)行found.get(),就會出現(xiàn)NoSuchElementException。這跟NullPointerException本質(zhì)上是一回事。所以這種寫法,比起普通的null的用法,其實換湯不換藥。如果你要用Optional類型而得到它的益處,請務(wù)必遵循我之前介紹的“原子操作”寫法。
防止過度工程
人的腦子真是奇妙的東西。雖然大家都知道過度工程(over-engineering)不好,在實際的工程中卻經(jīng)常不由自主的出現(xiàn)過度工程。我自己也犯過好多次這種錯誤,所以覺得有必要分析一下,過度工程出現(xiàn)的信號和兆頭,這樣可以在初期的時候就及時發(fā)現(xiàn)并且避免。
過度工程即將出現(xiàn)的一個重要信號,就是當(dāng)你過度的思考“將來”,考慮一些還沒有發(fā)生的事情,還沒有出現(xiàn)的需求。比如,“如果我們將來有了上百萬行代碼,有了幾千號人,這樣的工具就支持不了了”,“將來我可能需要這個功能,所以我現(xiàn)在就把代碼寫來放在那里”,“將來很多人要擴(kuò)充這片代碼,所以現(xiàn)在我們就讓它變得可重用”……
這就是為什么很多軟件項目如此復(fù)雜。實際上沒做多少事情,卻為了所謂的“將來”,加入了很多不必要的復(fù)雜性。眼前的問題還沒解決呢,就被“將來”給拖垮了。人們都不喜歡目光短淺的人,然而在現(xiàn)實的工程中,有時候你就是得看近一點,把手頭的問題先搞定了,再談以后擴(kuò)展的問題。
另外一種過度工程的來源,是過度的關(guān)心“代碼重用”。很多人“可用”的代碼還沒寫出來呢,就在關(guān)心“重用”。為了讓代碼可以重用,最后被自己搞出來的各種框架捆住手腳,最后連可用的代碼就沒寫好。如果可用的代碼都寫不好,又何談重用呢?很多一開頭就考慮太多重用的工程,到后來被人完全拋棄,沒人用了,因為別人發(fā)現(xiàn)這些代碼太難懂了,自己從頭開始寫一個,反而省好多事。
過度地關(guān)心“測試”,也會引起過度工程。有些人為了測試,把本來很簡單的代碼改成“方便測試”的形式,結(jié)果引入很多復(fù)雜性,以至于本來一下就能寫對的代碼,最后復(fù)雜不堪,出現(xiàn)很多bug。
世界上有兩種“沒有bug”的代碼。一種是“沒有明顯的bug的代碼”,另一種是“明顯沒有bug的代碼”。第一種情況,由于代碼復(fù)雜不堪,加上很多測試,各種coverage,貌似測試都通過了,所以就認(rèn)為代碼是正確的。第二種情況,由于代碼簡單直接,就算沒寫很多測試,你一眼看去就知道它不可能有bug。你喜歡哪一種“沒有bug”的代碼呢?
根據(jù)這些,我總結(jié)出來的防止過度工程的原則如下:
先把眼前的問題解決掉,解決好,再考慮將來的擴(kuò)展問題。
先寫出可用的代碼,反復(fù)推敲,再考慮是否需要重用的問題。
先寫出可用,簡單,明顯沒有bug的代碼,再考慮測試的問題
審核編輯:湯梓紅
-
嵌入式
+關(guān)注
關(guān)注
5083文章
19131瀏覽量
305543 -
編程
+關(guān)注
關(guān)注
88文章
3616瀏覽量
93763 -
代碼
+關(guān)注
關(guān)注
30文章
4790瀏覽量
68654
原文標(biāo)題:提高嵌入式代碼質(zhì)量的一些方法
文章出處:【微信號:strongerHuang,微信公眾號:strongerHuang】歡迎添加關(guān)注!文章轉(zhuǎn)載請注明出處。
發(fā)布評論請先 登錄
相關(guān)推薦
評論