Java 大神的十個私藏避坑絕技

  • 2019 年 10 月 11 日
  • 筆記

1.奇數性

看下面程式碼時候是否能判斷參數 i 是奇數?

public static boolean isOdd(int i){    return i % 2 == 1;  }  

答案是: No! 看似正確的判斷奇數, 但是如果 i 是負數, 那麼它返回值都是false 造成這種現象的是 => 從思想上固化, 認為奇數只在正數範圍, 故判斷負數將報錯, 在C++中也是, 負數取余還是負. 在Java中取余操作定義產生的後果都滿足下面的恆等式:

int數值a, 與非零int數值b 都滿足下面的等式:  (a / b) * b + (a % b) == a  

從上面就可以看出, 當取余操作返回一個非零的結果時, 左右操作數具有相同的正負號, 所以當取余在處理負數的時候, 以及會考慮負號.

而上面的這個問題, 解決方法就是避免判斷符號:

public static boolean isOdd(int i){   return i % 2 != 0;  }  

讓結果與0比較, 很容易避免正負號判斷. 思考: 1.在使用取余操作的時候要考慮符號對結果的影響 2.在運算中, 嘗試使用0解決符號問題, 在一定程度上避免符號對結果的影響

2.浮點數產生的誤差

看下面程式碼會列印出什麼樣的結果?

public class Change{    public static void main(String args[]){      System.out.println(2.00 - 1.10);    }  }  

從主觀上看, 列印的結果必然是0.90, 然後這卻是一個主觀錯誤. 對於1.10這個數, 電腦只會使用近似的二進位浮點數表示, 產生精度影響. 從上面的例子中來看, 1,10在電腦中表示為1.099999, 這個1.10並沒有在電腦中得到精確的表示. 針對這個精度問題, 我們可能會選擇: System.out.printf("%.2f%n", 2.00 – 1.10);解決, 儘管列印出來的是正確答案, 但是依舊會暴露出一個問題: 如果精度控制在2.00 – 1.0010; 那麼精度誤差依舊會出現. 這裡也說明了: 使用printf, 電腦底層依舊是使用二進位的方式來計算, 只不過這種計算提供了更好的近似值而已. 那麼應該怎麼解決這個問題呢? 首先想到是使用int模擬小數每一位, 然後計算, 最後將結果又轉化為小數; 以此想到的就是使用BigDecimal類, 它主要用於精確小數運算.

import java.math.BigDecimal;  public class Change1{    public static void main(String args[]){      System.out.println(new BigDecimal("2.00").subtract(new BigDecimal("1.10")));    }  }  

通過上面的程式碼就能得到一個精確的值. 注: 使用BigDecimal的時候, 不要使用BigDecimal(double d)的構造方法, 在double與double之間傳值的時候依舊會引起精度損失. 這是一個嚴重的問題. BigDecimal底層採用的就是int[], 使用String的時候, 會將String不斷取每一位存入int[], 使用double的時候, 同理將數字的每一位存入int[], 但是double本身存在誤差, 導致存入的數據會出現誤差,例: 0.1存入double就表示為0.1000000099999999, 因此不使用double類型的構造函數 思考: 當然對於精確要求不高的地方, 完全可以使用float/double, 但是對於要求精度的計算, 比如貨幣 一定要使用int, long, BigDecimal.

3.長整數造成數據溢出

看下面的程式碼會列印什麼?

public class LongDivision{    public static void main(String args[]){      final long MICROS_PER_DAY = 24 * 60 * 60 * 1000 * 1000;      final long MILLIS_PER_DAY = 24 * 60 * 60 * 1000;      System.out.println(MICROS_PER_DAY/MILLIS_PER_DAY);    }  }  

整個過程, 除數與被除數都是long型, 很容易保存這兩個數, 結果一定是1000, 但是結果讓你失望了, 結果是5. 這又是怎麼回事呢? 首先這個表達式: 24606010001000總是在int類型的基礎上進行計算. 即表達式是按照int的規則計算. 很容易看出這個表達式計算的範圍早已超出int的取值範圍, 縱然使用long去存儲計算結果, 但是在計算的過程中就已經出現計算數據溢出, 這是一個隱藏錯誤. Java目標確定類型的特性 => 如上例子, 不同通過 long 去確定24606010001000按照long進行存儲. 必須指定數據類型, 才能按照指定的規則進行運算. 就用前面這個例子來看, 當指定24為24L就能防止數據計算溢出, 在進行乘法運算的時候就已經是在long的基礎上進行計算.

public class LongDivision{   public static void main(String args[ ]){       final long MICROS_PER_DAY = 24L * 60 * 60 * 1000 * 1000;       final long MILLIS_PER_DAY = 24L * 60 * 60 * 1000;       System.out.println(MICROS_PER_DAY/MILLIS_PER_DAY);   }  }  

思考: 這個問題給了我一個深刻的教訓, 當操作數很大的時候, 要預防操作數溢出, 當無法確定計算數會不會溢出, 所要做的就是用儲存範圍最大的類型: long 來進行計算。

4.long的 "L" 與 "l" 所引發的錯誤

從上面 "長整數運算造成數據溢出" 引發又一個問題, 看下面例子:

public class Elementary{   public static void main(String[] args){   System.out.println(12345+5432l);   }  }  

乍一看, 這很簡單, 計算結果時是 6666, 但是列印的結果是 17777, 我開始頭暈了, 這很不合理. 思考過後, 發現了一個問題: 我把 "l" 看作是 "1", "l" 只是用於標識5432是一個long類型, 這個視覺上的錯誤將會引發更嚴重的問題. 思考: 小寫字母 l 與 1 很容易造成混淆, 為了避免這種錯誤, 在表示long類型數據的, 要做的就是將 "l" 換做 "L", 掐斷產生混亂的源頭.

5.多重類型轉換引發的數值變化

看這樣的一個例子:

public class Multicast{   public static void main (String[] args){   System.out.println((int)(char)(byte) -1);   }  }  

看似結果是 -1, 但是運行之後, 結果變為 65535 分析一下:

byte下的-1 => 變為:      1111,1111,1111,1111,1111,1111,1111,1111  32位(4個位元組) 首位1表示負號.    byte到char => 變為:      0000,0000,1111,1111  16位(2個位元組),首位0, 就此負號變正號.    char到int => 變為:      0000,0000,0000,0000,0000,0000,1111,1111  32位(4個位元組)  

由此可見, 在byte到char的變化過程中出現符號轉換的問題. char首位總是0使得負號變正號. 類型轉換的過程存在這樣的簡單規則: 如果最初的數值類型是有符號的,那麼就執行符號擴展;如果它是 char,那麼不管它將要被轉換成什麼類型,都執行零擴展。因此這也就解釋了為什麼byte到char的過程存在負號變正號. 為了在轉換的過程中保留符號, 就使用位掩碼進行限制, 例如:

char c = (char)(b & 0xff);  

這樣就能保證符號具有保留 思考: 在對有符號與無符號之間的轉換, 一定要注意上面的轉換規則, 如果不能確定轉換符號是否正確, 那麼就避免出現有符號到無符號之間的轉換.

6.避免所謂聰明的編程技巧

對於交換兩個變數的內容, 在C/C++中存在一種這樣的編程技巧:

int x = 1111;  int y = 2;  x^=y^=x^=y;  cout<<x<<" "<<y;  

這樣一個簡單的連續異或就能解決變數的交換問題, 這種寫法在很久以前是為了減少臨時變數的使用, 所以這種做法在現在也得到了保留. 首先看這樣一個問題, 表達式x^=y, 在C/C++的編譯器中是先計算出y的值, 然後再獲取x的值, 最後再計算表達式. 但在Java中的做法是先獲得x的值, 再獲得y的值, 最後再計算. Java的語言規範描述: 操作符的操作數是從左往右求值. 這使得在計算 x^ =y^ =x^ =y表達式中的第二個x的時候是在計算x^ =y之前的值( x的值依舊是1111 ), 並不是x^=y後的值, 這就導致了計算上的錯誤. 所以在Java中準確的寫法是:

y = ( x^=( y^=x ) )^y  

思考: 上面的這種寫法極其容易引起錯誤, 程式的可讀性受到很大的影響, 所以在寫程式碼的時候要思考一個問題, 除非編譯器能確定操作數的運算順序, 不然不要讓編譯器去確定操作數的計算順序, 就比如這樣的表達式: x=a[i]++-a[j]++. 很容易導致錯誤.

7.避免使用混合運算

看如下程式碼:

public class DosEquis{      public static void main(String[] args){          char x = 'X';          int i = 0;          System.out.println(true ? x : 0);          System.out.println(false ? i : x);      }  }  

看似將列印: XX, 但是結果卻是X88. 這是一個出乎意料的結果. 思考之後, 將可能得出這樣的結論: 出現這樣問題的原因是操作數的類型自動提升, char=>int. 但是又有一個問題就是為什麼第一個運算不是88. 找其根本原因還是在於條件表達式的運算規則:

A ? B : C  B, C為相同類型, 那麼表達式的計算結果就是B, C的類型  B, C不是相同類型的時候, 那麼計算結果就按照B的類型(此時B必須是式子中最高類型).      此時C的類型就自動上升為式子中最高的類型, 例: false  ? x : i, 輸出是0, 而不是0對應的字元.  

上面的規則決定了, 將調用哪一個print的重載函數. 這種條件表達式返回值, 很容易受B, C類型影響. 當根據返回值作條件判斷的時候, 這種性質也將導致一個嚴重的問題. 思考: 上面的問題說明了, 在條件表達式中, 最後再後兩個操作數使用相同類型的操作數, 以此避免返回值類型不確定的問題, 並且在其他的表達式計算中, 一定要理清楚數值之間的類型轉換.

8.發現隱藏的類型轉換

在這樣的表達式: x += i; 按照平常的理解, 它一定是x = x + i; 可是這樣的運算表達式是建立在x與i具有相同的類型的基礎上的, 如果當x, i類型不相同的時候, 將會引發一個問題就是精度損失. 就比如:

short x = 0;  int i = 99999;  x += i;  

現在的x不是99999, 而是-31073. 當 x += i 的時候, 出現的問題就是i自動轉型為short, 此時x的值就不再是99999. 而當你將表達式寫為: x = x + i 的時候, 這是一種顯式的轉型, 自然需要強轉操作. 從而避免了隱藏的類型轉換. 思考: 複合運算會隱藏出現轉型操作, 這種轉型操作很有可能出現精度丟失. 所以在進行複合運算的時候, 避免兩邊的操作數是不同的類型, 防止編譯器出現危險的窄化類型, 或者不使用複合運算, 人為進行類型轉換.

9.字元串的"+"運算符

看如下程式碼:

public class LastLaugh{      public static void main(String[] args){           System.out.print("H"+"a");           System.out.print('H'+'a');       }  }  

由於長期受 "+" 運算符的影響, 上面的程式碼, 很容易把 'H'+'a' 也看作是字元串的連接, 這是一種慣性的思考方式. 在 'H'+'a' 表達式的運算中, 是將 'H', 'a', 上升為int, 進行數值運算. 如果想讓兩個字元連接在一起, 可以採用:

1.使用 StringBuffer/StringBuild 做 append 運算.      StringBuild s = "".append('H');  2.使用String s = "" + 'H' +'a'; 使用字元串連接.      String s1 = "" + 'H' + 'a';      String s2 = 'a' + 'H' + "";      System.out.println(s1);      System.out.println(s2);      注: 避免 s2 的寫法, 這樣寫 'a'+'H' 依舊做 int 的數值運算  

思考: 在使用 "+" 運算符一定要注意操作數的類型, 以防止慣性思維導致的運算錯誤. 在某些場合這種錯誤有可能是致命性的.

看完字元的 "+" 運算符, 現在再來字元數組的 "+"運算符 :

public class A{      public static void main(String[] args){           String letters = "ABC";           char[] numbers = {'1', '2', '3'};           System.out.println(letters + " easy as " + numbers);       }  }  

上面的程式碼, 最終的列印結果不是 ABC easy as 123, 而是ABC easy as [C@16f0472. 如果想到的列印結果是ABC easy as 123, 那麼犯的錯誤還是上面相同的錯誤. 在列印結果的時候, 首先會進行字元串連接, 當 "easy as" 這個字元串連接 char[] 的時候, 那麼調用的是char[] 的toString(), 而系統並沒有重寫toString(), 所以最後調用的就是Object的toString();

為了修正這樣的錯誤, 給出如下解決方式:

1.使用String.valueOf(number); 轉字元串後再進行連接操作.  2.使用System.out.println(number); 調用重載的println(char[] c);  

而在C/C++中, char numbers[4] = {'1', '2', '3', '' }; 代表的就是一個字元串. 思考: 牢記, 數組類型的toString都沒有重寫, 如果想獲得數組中的值, 避免調用數組類型的toString, 或者讓系統隱藏調用, 而是直接遍曆數組獲得其中的值.

10."=="運算符進行比較

  • 問題1: 這裡先說明第一個問題, 就是Java中的 "==" 運算符: 在比較基本類型的時候, 是比較基本類型值的關係; 在比較數組, 或者對象的時候是比較對象之間的引用值關係. 但是這裡要注意的是: 在比較Integer, Long(本人親測)這兩種的時候, 比較-128~127的時候是從快取池中拿取數據.

Integer中的equals方法:

public boolean equals(Object obj) {          if (obj instanceof Integer) {              return value == ((Integer)obj).intValue();          }          return false;      }  

這個過程中實現的是將Integer拆包,-128~127不需要拆包,可直接使用==比較.

Integer的快取池-128~127: 自動裝箱過程中使用valueOf創建對象,因此直接會使用快取池中對象. 思考: 這裡我想表達的意思就是, 如果要進行對象內容之間的比較, 務必重寫equals, 然後使用equals. 還有避免在基本類型與包裝類型混合狀態的基礎上使用 "==", 就比如 Integer. 這個很容易導致錯誤

  • 問題2: 當看到這樣的程式碼的時候:
public class AnimalFarm{       public static void main(String[] args){           final String pig = "length: 10";           final String dog = "length: " + pig.length();           System.out. println("Animals are equal: " + pig == dog);       }  }  

我想去比較pig與dog引用值關係, pig 與 dog 的引用值肯定是相同的, 但是最後的輸出結果卻是false. 在這裡忽略了一個問題, 那就是前面的 "+" 的運算級比 "==" 的運算級高, 看似是比較pig與dog的引用值, 最後卻是比較"Animals are equal: length: 10"與dog的引用值關係. 現在給出下面的修正方案:

1.System.out.println("Animals are equal: " + (pig == dog));  2.System.out.println("Animals are equal: " + pig.equals(dog));  

思考: 從這裡也看出, 比較對象內容的時候, 務必使用已經重載後equals, 除非刻意比較兩個對象的引用值, 否則千萬別使用"==".