Java 大神的十個私藏避坑絕技
- 2019 年 10 月 11 日
- 筆記
看下面程式碼時候是否能判斷參數 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', '