主頁 >  其他 > 王垠-編程的智慧

王垠-編程的智慧

2020-09-20 06:53:06 其他

編程的智慧

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

?

反復推敲代碼

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

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

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

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

?

寫優雅的代碼

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

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

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

if (xxx) {
  if (xxx) {
    ...
  } else {
    ...
  }
} else if (xxx) {
  ...
} 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就變成了findXprint之間的資料通道,由于x屬于class A,這樣程式就失去了模塊化的結構,由于這兩個函式依賴于成員x,它們不再有明確的輸入和輸出,而是依賴全域的資料,findXfoo不再能夠離開class A而存在,而且由于類成員還有可能被其他代碼改變,代碼變得難以理解,難以確保正確性,

    如果你使用區域變數而不是類成員來傳遞資訊,那么這兩個函式就不需要依賴于某一個class,而且更加容易理解,不易出錯:

     String findX() {
        ...
        x = ...;
        return x;
     }
     void foo() {
       String x = findX();
       print(x);
     }
    

    ?

寫可讀的代碼

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

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

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

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

  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 (xxx) {
      msg = "succeed";
      log.info(msg);
    } else {
      msg = "failed";
      log.info(msg);
    }
    

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

    if (xxx) {
      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);

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

?

寫簡單的代碼

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

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

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

  • 避免使用自增減運算式(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 (xxx)
      action1();
    

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

    if (xxx)
      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,你往往只需要把continue的條件反向,就可以消除continue,
    2. 如果出現了break,你往往可以把break的條件,合并到回圈頭部的終止條件里,從而去掉break,
    3. 有時候你可以把break替換成return,從而去掉break,
    4. 如果以上都失敗了,你也許可以把回圈里面復雜的部分提取出來,做成函式呼叫,之后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%含有復雜的邏輯,但也可以通過提取一個幫助函式來消除掉,修改之后的代碼變得容易理解,容易確保正確,

    ?

寫直觀的代碼

我寫代碼有一條重要的原則:如果有更加直接,更加清晰的寫法,就選擇它,即使它看起來更長,更笨,也一樣選擇它,比如,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 (xxx) {
  if (xxx) {
    ...
    return false;
  } else {
    return true;
  }
} else if (xxx) {
  ...
  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 (xxx) {
  if (xxx) {
    ...
    return false;
  }
} else if (xxx) {
  ...
  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自己,根據這個基本觀點,我們推匯出以下原則:

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

    public String find() throws NotFoundException {
      if (xxx) {
        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型別的值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”的代碼呢?

?

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

  1. 先把眼前的問題解決掉,解決好,再考慮將來的擴展問題,
  2. 先寫出可用的代碼,反復推敲,再考慮是否需要重用的問題,
  3. 先寫出可用,簡單,明顯沒有bug的代碼,再考慮測驗的問題,

本文轉載自: http://www.yinwang.org/blog-cn/2015/11/21/programming-philosophy

轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/86817.html

標籤:其他

上一篇:利用視頻決議網站免費觀看各大平臺VIP電影

下一篇:計算機網路概述

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • 網閘典型架構簡述

    網閘架構一般分為兩種:三主機的三系統架構網閘和雙主機的2+1架構網閘。 三主機架構分別為內端機、外端機和仲裁機。三機無論從軟體和硬體上均各自獨立。首先從硬體上來看,三機都用各自獨立的主板、記憶體及存盤設備。從軟體上來看,三機有各自獨立的作業系統。這樣能達到完全的三機獨立。對于“2+1”系統,“2”分為 ......

    uj5u.com 2020-09-10 02:00:44 more
  • 如何從xshell上傳檔案到centos linux虛擬機里

    如何從xshell上傳檔案到centos linux虛擬機里及:虛擬機CentOs下執行 yum -y install lrzsz命令,出現錯誤:鏡像無法找到軟體包 前言 一、安裝lrzsz步驟 二、上傳檔案 三、遇到的問題及解決方案 總結 前言 提示:其實很簡單,往虛擬機上安裝一個上傳檔案的工具 ......

    uj5u.com 2020-09-10 02:00:47 more
  • 一、SQLMAP入門

    一、SQLMAP入門 1、判斷是否存在注入 sqlmap.py -u 網址/id=1 id=1不可缺少。當注入點后面的引數大于兩個時。需要加雙引號, sqlmap.py -u "網址/id=1&uid=1" 2、判斷文本中的請求是否存在注入 從文本中加載http請求,SQLMAP可以從一個文本檔案中 ......

    uj5u.com 2020-09-10 02:00:50 more
  • Metasploit 簡單使用教程

    metasploit 簡單使用教程 浩先生, 2020-08-28 16:18:25 分類專欄: kail 網路安全 linux 文章標簽: linux資訊安全 編輯 著作權 metasploit 使用教程 前言 一、Metasploit是什么? 二、準備作業 三、具體步驟 前言 Msfconsole ......

    uj5u.com 2020-09-10 02:00:53 more
  • 游戲逆向之驅動層與用戶層通訊

    驅動層代碼: #pragma once #include <ntifs.h> #define add_code CTL_CODE(FILE_DEVICE_UNKNOWN,0x800,METHOD_BUFFERED,FILE_ANY_ACCESS) /* 更多游戲逆向視頻www.yxfzedu.com ......

    uj5u.com 2020-09-10 02:00:56 more
  • 北斗電力時鐘(北斗授時服務器)讓網路資料更精準

    北斗電力時鐘(北斗授時服務器)讓網路資料更精準 北斗電力時鐘(北斗授時服務器)讓網路資料更精準 京準電子科技官微——ahjzsz 近幾年,資訊技術的得了快速發展,互聯網在逐漸普及,其在人們生活和生產中都得到了廣泛應用,并且取得了不錯的應用效果。計算機網路資訊在電力系統中的應用,一方面使電力系統的運行 ......

    uj5u.com 2020-09-10 02:01:03 more
  • 【CTF】CTFHub 技能樹 彩蛋 writeup

    ?碎碎念 CTFHub:https://www.ctfhub.com/ 筆者入門CTF時時剛開始刷的是bugku的舊平臺,后來才有了CTFHub。 感覺不論是網頁UI設計,還是題目質量,賽事跟蹤,工具軟體都做得很不錯。 而且因為獨到的金幣制度的確讓人有一種想去刷題賺金幣的感覺。 個人還是非常喜歡這個 ......

    uj5u.com 2020-09-10 02:04:05 more
  • 02windows基礎操作

    我學到了一下幾點 Windows系統目錄結構與滲透的作用 常見Windows的服務詳解 Windows埠詳解 常用的Windows注冊表詳解 hacker DOS命令詳解(net user / type /md /rd/ dir /cd /net use copy、批處理 等) 利用dos命令制作 ......

    uj5u.com 2020-09-10 02:04:18 more
  • 03.Linux基礎操作

    我學到了以下幾點 01Linux系統介紹02系統安裝,密碼啊破解03Linux常用命令04LAMP 01LINUX windows: win03 8 12 16 19 配置不繁瑣 Linux:redhat,centos(紅帽社區版),Ubuntu server,suse unix:金融機構,證券,銀 ......

    uj5u.com 2020-09-10 02:04:30 more
  • 05HTML

    01HTML介紹 02頭部標簽講解03基礎標簽講解04表單標簽講解 HTML前段語言 js1.了解代碼2.根據代碼 懂得挖掘漏洞 (POST注入/XSS漏洞上傳)3.黑帽seo 白帽seo 客戶網站被黑帽植入劫持代碼如何處理4.熟悉html表單 <html><head><title>TDK標題,描述 ......

    uj5u.com 2020-09-10 02:04:36 more
最新发布
  • 2023年最新微信小程式抓包教程

    01 開門見山 隔一個月發一篇文章,不過分。 首先回顧一下《微信系結手機號資料庫被脫庫事件》,我也是第一時間得知了這個訊息,然后跟蹤了整件事情的經過。下面是這起事件的相關截圖以及近日流出的一萬條資料樣本: 個人認為這件事也沒什么,還不如關注一下之前45億快遞資料查詢渠道疑似在近日復活的訊息。 訊息是 ......

    uj5u.com 2023-04-20 08:48:24 more
  • web3 產品介紹:metamask 錢包 使用最多的瀏覽器插件錢包

    Metamask錢包是一種基于區塊鏈技術的數字貨幣錢包,它允許用戶在安全、便捷的環境下管理自己的加密資產。Metamask錢包是以太坊生態系統中最流行的錢包之一,它具有易于使用、安全性高和功能強大等優點。 本文將詳細介紹Metamask錢包的功能和使用方法。 一、 Metamask錢包的功能 數字資 ......

    uj5u.com 2023-04-20 08:47:46 more
  • vulnhub_Earth

    前言 靶機地址->>>vulnhub_Earth 攻擊機ip:192.168.20.121 靶機ip:192.168.20.122 參考文章 https://www.cnblogs.com/Jing-X/archive/2022/04/03/16097695.html https://www.cnb ......

    uj5u.com 2023-04-20 07:46:20 more
  • 從4k到42k,軟體測驗工程師的漲薪史,給我看哭了

    清明節一過,盲猜大家已經無心上班,在數著日子準備過五一,但一想到銀行卡里的余額……瞬間心情就不美麗了。最近,2023年高校畢業生就業調查顯示,本科畢業月平均起薪為5825元。調查一出,便有很多同學表示自己又被平均了。看著這一資料,不免讓人想到前不久中國青年報的一項調查:近六成大學生認為畢業10年內會 ......

    uj5u.com 2023-04-20 07:44:00 more
  • 最新版本 Stable Diffusion 開源 AI 繪畫工具之中文自動提詞篇

    🎈 標簽生成器 由于輸入正向提示詞 prompt 和反向提示詞 negative prompt 都是使用英文,所以對學習母語的我們非常不友好 使用網址:https://tinygeeker.github.io/p/ai-prompt-generator 這個網址是為了讓大家在使用 AI 繪畫的時候 ......

    uj5u.com 2023-04-20 07:43:36 more
  • 漫談前端自動化測驗演進之路及測驗工具分析

    隨著前端技術的不斷發展和應用程式的日益復雜,前端自動化測驗也在不斷演進。隨著 Web 應用程式變得越來越復雜,自動化測驗的需求也越來越高。如今,自動化測驗已經成為 Web 應用程式開發程序中不可或缺的一部分,它們可以幫助開發人員更快地發現和修復錯誤,提高應用程式的性能和可靠性。 ......

    uj5u.com 2023-04-20 07:43:16 more
  • CANN開發實踐:4個DVPP記憶體問題的典型案例解讀

    摘要:由于DVPP媒體資料處理功能對存放輸入、輸出資料的記憶體有更高的要求(例如,記憶體首地址128位元組對齊),因此需呼叫專用的記憶體申請介面,那么本期就分享幾個關于DVPP記憶體問題的典型案例,并給出原因分析及解決方法。 本文分享自華為云社區《FAQ_DVPP記憶體問題案例》,作者:昇騰CANN。 DVPP ......

    uj5u.com 2023-04-20 07:43:03 more
  • msf學習

    msf學習 以kali自帶的msf為例 一、msf核心模塊與功能 msf模塊都放在/usr/share/metasploit-framework/modules目錄下 1、auxiliary 輔助模塊,輔助滲透(埠掃描、登錄密碼爆破、漏洞驗證等) 2、encoders 編碼器模塊,主要包含各種編碼 ......

    uj5u.com 2023-04-20 07:42:59 more
  • Halcon軟體安裝與界面簡介

    1. 下載Halcon17版本到到本地 2. 雙擊安裝包后 3. 步驟如下 1.2 Halcon軟體安裝 界面分為四大塊 1. Halcon的五個助手 1) 影像采集助手:與相機連接,設定相機引數,采集影像 2) 標定助手:九點標定或是其它的標定,生成標定檔案及內參外參,可以將像素單位轉換為長度單位 ......

    uj5u.com 2023-04-20 07:42:17 more
  • 在MacOS下使用Unity3D開發游戲

    第一次發博客,先發一下我的游戲開發環境吧。 去年2月份買了一臺MacBookPro2021 M1pro(以下簡稱mbp),這一年來一直在用mbp開發游戲。我大致分享一下我的開發工具以及使用體驗。 1、Unity 官網鏈接: https://unity.cn/releases 我一般使用的Apple ......

    uj5u.com 2023-04-20 07:40:19 more