編程的智慧

編程的智慧

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

反覆推敲代碼

既然「天才是百分之一的靈感,百分之九十九的汗水」,那我先來談談這汗水的部分吧。有人問我,提升編程水平最有效的辦法是什麼?我想了好久,終於發現最有效的辦法,實際上是反反覆覆地修改和推敲代碼。java

在IU的時候,因爲Dan Friedman的嚴格教導,咱們以寫出冗長複雜的代碼爲恥。若是你代碼多寫了幾行,這老頑童就會大笑,說:「當年我解決這個問題,只寫了5行代碼,你回 去再想一想吧……」 固然,有時候他只是誇張一下,故意刺激你的,其實沒有人能只用5行代碼完成。然而這種提煉代碼,減小冗餘的習慣,卻由此深刻了個人骨髓。linux

有些人喜歡炫耀本身寫了多少多少萬行的代碼,彷彿代碼的數量是衡量編程水平的標準。然而,若是你老是匆匆寫出代碼,卻歷來不回頭去推敲,修改和提 煉,實際上是不可能提升編程水平的。你會製造出愈來愈多平庸甚至糟糕的代碼。在這種意義上,不少人所謂的「工做經驗」,跟他代碼的質量,其實不必定成正比。 若是有幾十年的工做經驗,卻歷來不回頭去提煉和反思本身的代碼,那麼他也許還不如一個只有一兩年經驗,卻喜歡反覆推敲,仔細領悟的人。程序員

有位文豪說得好:「看一個做家的水平,不是看他發表了多少文字,而要看他的廢紙簍裏扔掉了多少。」 我以爲一樣的理論適用於編程。好的程序員,他們刪掉的代碼,比留下來的還要多不少。若是你看見一我的寫了不少代碼,卻沒有刪掉多少,那他的代碼必定有不少垃圾。編程

就像文學做品同樣,代碼是不可能一蹴而就的。靈感彷佛老是零零星星,陸陸續續到來的。任何人都不可能一筆呵成,就算再厲害的程序員,也須要通過一段 時間,才能發現最簡單優雅的寫法。有時候你反覆提煉一段代碼,以爲到了頂峯,無法再改進了,但是過了幾個月再回頭來看,又發現好多能夠改進和簡化的地方。 這跟寫文章如出一轍,回頭看幾個月或者幾年前寫的東西,你總能發現一些改進。api

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

寫優雅的代碼

人們都討厭「麪條代碼」(spaghetti code),由於它就像麪條同樣繞來繞去,無法理清頭緒。那麼優雅的代碼通常是什麼形狀的呢?通過多年的觀察,我發現優雅的代碼,在形狀上有一些明顯的特徵。數據結構

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

優雅的代碼的另外一個特徵是,它的邏輯大致上看起來,是枝丫分明的樹狀結構(tree)。這是由於程序所作的幾乎一切事情,都是信息的傳遞和分支。你 能夠把代碼當作是一個電路,電流通過導線,分流或者匯合。若是你是這樣思考的,你的代碼裏就會比較少出現只有一個分支的if語句,它看起來就會像這個樣 子:app

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就變成了findXprint之間的數據通道。因爲x屬於class A,這樣程序就失去了模塊化的結構。因爲這兩個函數依賴於成員x,它們再也不有明確的輸入和輸出,而是依賴全局的數據。findXfoo再也不可以離開class A而存在,並且因爲類成員還有可能被其餘代碼改變,代碼變得難以理解,難以確保正確性。

    若是你使用局部變量而不是類成員來傳遞信息,那麼這兩個函數就不須要依賴於某一個class,並且更加容易理解,不易出錯:

    String findX() {
        ...
        x = ...;
        return x;
     }
     void foo() {
       int 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 (...) {
      msg = "succeed";
      log.info(msg);
    } else {
      msg = "failed";
      log.info(msg);
    }

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

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

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

  5. 把複雜的邏輯提取出去,作成「幫助函數」。有些人寫的函數很長,以致於看不清楚裏面的語句在幹什麼,因此他們誤覺得須要寫註釋。若是你仔細 觀察這些代碼,就會發現不清晰的那片代碼,每每能夠被提取出去,作成一個函數,而後在原來的地方調用。因爲函數有一個名字,這樣你就能夠使用有意義的函數 名來代替註釋。舉一個例子:

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

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

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

    這樣原來的代碼就能夠改爲:

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

    更加清晰,並且註釋也不必了。

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

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

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

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

    這樣寫,不但有效地控制了單行代碼的長度,並且因爲引入的中間變量具備「意義」,步驟清晰,變得很容易理解。

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

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

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

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

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

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

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

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

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

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

爲了不IDE把這些手動調整好的換行弄亂,不少IDE(好比IntelliJ)的自動格式化設定裏都有「保留原來的換行符」的設定。若是你發現IDE的換行不符合邏輯,你能夠修改這些設定,而後在某些地方保留你本身的手動換行。

說到這裏,我必須警告你,這裏所說的「不需註釋,讓代碼本身解釋本身」,並非說要讓代碼看起來像某種天然語言。有個叫Chai的JavaScript測試工具,可讓你這樣寫代碼:

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

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

寫簡單的代碼

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

並非語言提供什麼,你就必定要把它用上的。實際上你只須要其中很小的一部分功能,就能寫出優秀的代碼。我一貫反對「充分利用」程序語言裏的全部特 性。實際上,我心目中有一套最好的構造。無論語言提供了多麼「神奇」的,「新」的特性,我基本都只用通過千錘百煉,我以爲值得信奈的那一套。

如今針對一些有問題的語言特性,我介紹一些我本身使用的代碼規範,而且講解一下爲何它們能讓代碼更簡單。

  • 避免使用自增減表達式(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,你每每只須要把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 (...) {
  if (...) {
    ...
    return false;
  } else {
    return true;
  }
} else if (...) {
  ...
  return false;
} else {
  return true;
}

使用這種方式,實際上是爲了無懈可擊的處理全部可能出現的狀況,避免漏掉corner case。每一個if語句都有兩個分支的理由是:若是if的條件成立,你作某件事情;可是若是if的條件不成立,你應該知道要作什麼另外的事情。無論你的 if有沒有else,你終究是逃不掉,必須得思考這個問題的。

不少人寫if語句喜歡省略else的分支,由於他們以爲有些else分支的代碼重複了。好比個人代碼裏,兩個else分支都是return true。爲了不重複,他們省略掉那兩個else分支,只在最後使用一個return true。這樣,缺了else分支的if語句,控制流自動「掉下去」,到達最後的return true。他們的代碼看起來像這個樣子:

if (...) {
  if (...) {
    ...
    return false;
  } 
} else if (...) {
  ...
  return false;
} 
return true;

這種寫法看似更加簡潔,避免了重複,然而卻很容易出現疏忽和漏洞。嵌套的if語句省略了一些else,依靠語句的「控制流」來處理else的狀況,是很難正確的分析和推理的。若是你的if條件裏使用了&&||之類的邏輯運算,就更難看出是否涵蓋了全部的狀況。

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

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

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

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

如今比較一下個人寫法:

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

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

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

固然,因爲這個狀況比較簡單,你還能夠把它寫成這樣:

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

對於更加複雜的狀況,我建議仍是寫成if語句爲好。

正確處理錯誤

使用有兩個分支的if語句,只是個人代碼能夠達到無懈可擊的其中一個緣由。這樣寫if語句的思路,其實包含了使代碼可靠的一種通用思想:窮舉全部的狀況,不漏掉任何一個。

程序的絕大部分功能,是進行信息處理。從一堆紛繁複雜,模棱兩可的信息中,排除掉絕大部分「干擾信息」,找到本身須要的那一個。正確地對全部的「可能性」進行推理,就是寫出無懈可擊代碼的核心思想。這一節我來說一講,如何把這種思想用在錯誤處理上。

錯誤處理是一個古老的問題,但是通過了幾十年,仍是不少人沒搞明白。Unix的系統API手冊,通常都會告訴你可能出現的返回值和錯誤信息。好比,Linux的read系統調用手冊裏面有以下內容:

RETURN VALUE 
On success, the number of bytes read is returned... 

On error, -1 is returned, and errno is set appropriately.

ERRORS EAGAIN, EBADF, EFAULT, EINTR, EINVAL, ...

不少初學者,都會忘記檢查read的返回值是否爲-1,以爲每次調用read都得檢查返回值 真繁瑣,不檢查貌似也相安無事。這種想法實際上是很危險的。若是函數的返回值告訴你,要麼返回一個正數,表示讀到的數據長度,要麼返回-1,那麼你就必需要 對這個-1做出相應的,有意義的處理。千萬不要覺得你能夠忽視這個特殊的返回值,由於它是一種「可能性」。代碼漏掉任何一種可能出現的狀況,均可能產生意 想不到的災難性結果。

對於Java來講,這相對方便一些。Java的函數若是出現問題,通常經過異常(exception)來表示。你能夠把異常加上函數原本的返回值,當作是一個「union類型」。好比:

String foo() throws MyException {
  ...
}

這裏MyException是一個錯誤返回。你能夠認爲這個函數返回一個union類型:{String, MyException}。任何調用foo的 代碼,必須對MyException做出合理的處理,纔有可能確保程序的正確運行。Union類型是一種至關先進的類型,目前只有極少數語言(好比 Typed Racket)具備這種類型,我在這裏提到它,只是爲了方便解釋概念。掌握了概念以後,你其實能夠在頭腦裏實現一個union類型系統,這樣使用普通的語 言也能寫出可靠的代碼。

因爲Java的類型系統強制要求函數在類型裏面聲明可能出現的異常,並且強制調用者處理可能出現的異常,因此基本上不可能出現因爲疏忽而漏掉的情 況。但有些Java程序員有一種惡習,使得這種安全機制幾乎徹底失效。每當編譯器報錯,說「你沒有catch這個foo函數可能出現的異常」時,有些人想 都不想,直接把代碼改爲這樣:

try {
  foo();
} catch (Exception e) {}

或者最多在裏面放個log,或者乾脆把本身的函數類型上加上throws Exception,這樣編譯器就再也不抱怨。這些作法貌似很省事,然而都是錯誤的,你終究會爲此付出代價。

若是你把異常catch了,忽略掉,那麼你就不知道foo其實失敗了。這就像開車時看到路口寫着「前方施工,道路關閉」,還繼續往前開。這固然早晚會出問題,由於你根本不知道本身在幹什麼。

catch異常的時候,你不該該使用Exception這麼寬泛的類型。你應該正好catch可能發生的那種異常A。使用寬泛的異常類型有很大的問 題,由於它會不經意的catch住另外的異常(好比B)。你的代碼邏輯是基於判斷A是否出現,可你卻catch全部的異常(Exception類),因此 當其它的異常B出現的時候,你的代碼就會出現莫名其妙的問題,由於你覺得A出現了,而其實它沒有。這種bug,有時候甚至使用debugger都難以發 現。

若是你在本身函數的類型加上throws Exception,那麼你就不可避免的須要在調用它的地方處理這個異常,若是調用它的函數也寫着throws Exception,這毛病就傳得更遠。個人經驗是,儘可能在異常出現的當時就做出處理。不然若是你把它返回給你的調用者,它也許根本不知道該怎麼辦了。

另外,try { ... } catch裏面,應該包含儘可能少的代碼。好比,若是foobar均可能產生異常A,你的代碼應該儘量寫成:

try {
  foo();
} catch (A e) {...}

try {
  bar();
} catch (A e) {...}

而不是

try {
  foo();
  bar();
} catch (A e) {...}

第一種寫法能明確的分辨是哪個函數出了問題,而第二種寫法全都混在一塊兒。明確的分辨是哪個函數出了問題,有不少的好處。好比,若是你的catch代碼裏面包含log,它能夠提供給你更加精確的錯誤信息,這樣會大大地加速你的調試過程。

正確處理null指針

窮舉的思想是如此的有用,依據這個原理,咱們能夠推出一些基本原則,它們可讓你無懈可擊的處理null指針。

首先你應該知道,許多語言(C,C++,Java,C#,……)的類型系統對於null的處理,實際上是徹底錯誤的。這個錯誤源自於Tony Hoare最先的設計,Hoare把這個錯誤稱爲本身的「billion dollar mistake」,由於因爲它所產生的財產和人力損失,遠遠超過十億美圓。

這些語言的類型系統容許null出如今任何對象(指針)類型能夠出現的地方,然而null其實根本不是一個合法的對象。它不是一個String,不 是一個Integer,也不是一個自定義的類。null的類型原本應該是NULL,也就是null本身。根據這個基本觀點,咱們推導出如下原則:

  • 儘可能不要產生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。

  • 不要把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的代碼,再考慮測試的問題。
相關文章
相關標籤/搜索