編寫高質量程式碼:改善Java程式的151個建議(第1章:JAVA開發中通用的方法和準則___建議6~10)

阿赫瓦里發表於2016-09-09

建議6:覆寫變長方法也循規蹈矩  

  在JAVA中,子類覆寫父類的中的方法很常見,這樣做既可以修正bug,也可以提供擴充套件的業務功能支援,同時還符合開閉原則(Open-Closed Principle)。

符合開閉原則(Open-Closed Principle)的主要特徵:

  1.對於擴充套件是開放的(Open for extension)。這意味著模組的行為是可以擴充套件的。當應用的需求改變時,我們可以對模組進行擴充套件,使其具有滿足那些改變的新行為。也就是說,我們可以改變模組的功能。

  2.對於修改是關閉的(Closed for modification)。對模組行為進行擴充套件時,不必改動模組的原始碼或者二進位制程式碼。模組的二進位制可執行版本,無論是可連結的庫、DLL或者.EXE檔案,都無需改動。

下面我們看一下覆寫必須滿足的條件:

  1. 覆寫方法不能縮小訪問許可權;
  2. 引數列表必須與被覆寫方法相同;
  3. 返回型別必須與被重寫方法的相同;
  4. 重寫方法不能丟擲新的異常,或者超出父類範圍的異常,但是可以丟擲更少,更有限的異常,或者不丟擲異常。

看下面這段程式碼:

 1 public class Client6 {
 2     public static void main(String[] args) {
 3         // 向上轉型
 4         Base base = new Sub();
 5         base.fun(100, 50);
 6         // 不轉型
 7         Sub sub = new Sub();
 8         sub.fun(100, 50);
 9     }
10 }
11 
12 // 基類
13 class Base {
14     void fun(int price, int... discounts) {
15         System.out.println("Base......fun");
16     }
17 }
18 
19 // 子類,覆寫父類方法
20 class Sub extends Base {
21     @Override
22     void fun(int price, int[] discounts) {
23         System.out.println("Sub......fun");
24     }
25 }

  該程式中sub.fun(100, 50)報錯,提示找不到fun(int,int)方法。這太奇怪了:子類繼承了父類的所有屬性和方法,甭管是私有的還是公開的訪問許可權,同樣的引數,同樣的方法名,通過父類呼叫沒有任何問題,通過子類呼叫,卻編譯不過,為啥?難到是沒繼承下來?或者子類縮小了父類方法的前置條件?如果是這樣,就不應該覆寫,@Override就應該報錯呀。

  事實上,base物件是把子類物件做了向上轉型,形參列表由父類決定,由於是變長引數,在編譯時,base.fun(100, 50);中的50這個實參會被編譯器"猜測"而編譯成"{50}"陣列,再由子類Sub執行。我們再來看看直接呼叫子類的情況,這時編譯器並不會把"50"座型別轉換因為陣列本身也是一個物件,編譯器還沒有聰明到要在兩個沒有繼承關係的類之間轉換,要知道JAVA是要求嚴格的型別匹配的,型別不匹配編譯器自然就會拒絕執行,並給予錯誤提示。

  這是個特例,覆寫的方法引數列表竟然與父類不相同,這違背了覆寫的定義,並且會引發莫名其妙的錯誤。所以讀者在對變長引數進行覆寫時,如果要使用次類似的方法,請仔細想想是不是要一定如此。

  注意:覆寫的方法引數與父類相同,不僅僅是型別、數量,還包括顯示形式.

建議7:警惕自增的陷阱

   記得大學剛開始學C語言時,老師就說:自增有兩種形式,分別是i++和++i,i++表示的先賦值後加1,++i是先加1後賦值,這樣理解了很多年也木有問題,直到遇到如下程式碼,我才懷疑我的理解是不是錯了:    

1 public class Client7 {
2     public static void main(String[] args) {
3         int count=0;
4         for(int i=0; i<10;i++){
5             count=count++;
6         }
7         System.out.println("count = "+count);
8     }
9 }

這個程式輸出的count等於幾?是count自加10次嗎?答案等於10?可以肯定的說,這個執行結果是count=0。為什麼呢?

  count++是一個表示式,是由返回值的,它的返回值就是count自加前的值,Java對自加是這樣處理的:首先把count的值(注意是值,不是引用)拷貝到一個臨時變數區,然後對count變數+1,最後返回臨時變數區的值。程式第一次迴圈處理步驟如下:

  1. JVM把count的值(其值是0)拷貝到臨時變數區;
  2. count的值+1,這時候count的值是1;
  3. 返回臨時變數區的值,注意這個值是0,沒修改過;
  4. 返回值賦給count,此時count的值被重置為0.

"count=count++"這條語句可以按照如下程式碼理解: 

1 public static int mockAdd(int count) {
2         // 先儲存初始值
3         int temp = count;
4         // 做自增操作
5         count = count + 1;
6         // 返回原始值
7         return temp;
8     }

  於是第一次迴圈後count的值為0,其它9次迴圈也是一樣的,最終你會發現count的值始終沒有改變,仍然保持著最初的狀態.

  此例中程式碼作者的本意是希望count自增,所以想當然的賦值給自身就可以了,不曾想到調到Java自增的陷阱中了,解決辦法很簡單,把"count=count++"改為"count++"即可。該問題在不同的語言環境中有著不同的實現:C++中"count=count++"與"count++"是等效的,而在PHP中保持著與JAVA相同的處理方式。每種語言對自增的實現方式各不相同。

建議8:不要讓舊語法困擾你  

 1 public class Client8 {
 2     public static void main(String[] args) {
 3         // 資料定義初始化
 4         int fee = 200;
 5         // 其它業務處理
 6         saveDefault: save(fee);
 7     }
 8 
 9     static void saveDefault() {
10     System.out.println("saveDefault....");
11     }
12 
13     static void save(int fee) {
14     System.out.println("save....");
15     }
16 }

這段程式碼分析一下,輸出結果,以及語法含義:

  1. 首先這段程式碼中有標號(:)操作符,C語言的同學一看便知,類似JAVA中的保留關鍵字 go to 語句,但Java中拋棄了goto語法,只是不進行語義處理,與此類似的還有const關鍵字。
  2. Java中雖然沒有了goto語法,但擴充套件了break和continue關鍵字,他們的後面都可以加上標號做跳轉,完全實現了goto功能,同時也把goto的詬病帶進來了。
  3. 執行之後程式碼輸入為"save....",執行時沒錯,但這樣的程式碼,給大家閱讀上造成了很大的問題,所以就語法就讓他隨風遠去吧!

建議9:少用靜態匯入  

  從Java5開始引入了靜態匯入語法(import static),其目的是為了減少字元的輸入量,提高程式碼的可閱讀性,以便更好地理解程式。我們先倆看一個不用靜態匯入的例子,也就是一般匯入:  

 1 public class Client9 {
 2     // 計算圓面積
 3     public static double claCircleArea(double r) {
 4         return Math.PI * r * r;
 5     }
 6 
 7     // 計算球面積
 8     public static double claBallArea(double r) {
 9         return 4 * Math.PI * r * r;
10     }
11 }

  這是很簡單的兩個方法,我們再這兩個計算面積的方法中都引入了java.lang.Math類(該類是預設匯入的)中的PI(圓周率)常量,而Math這個類寫在這裡有點多餘,特別是如果Client9類中的方法比較多時。如果每次輸入都需要敲入Math這個類,繁瑣且多餘,靜態匯入可以解決此問題,使用靜態匯入後的程式如下: 

 1 import static java.lang.Math.PI;
 2 
 3 public class Client9 {
 4     // 計算圓面積
 5     public static double claCircleArea(double r) {
 6         return PI * r * r;
 7     }
 8 
 9     // 計算球面積
10     public static double claBallArea(double r) {
11         return 4 * PI * r * r;
12     }
13 }

靜態匯入的作用是把Math類中的Pi常量引入到本類中,這會是程式更簡單,更容易閱讀,只要看到PI就知道這是圓周率,不用每次都把類名寫全了。但是,濫用靜態匯入會使程式更難閱讀,更難維護,靜態匯入後,程式碼中就不需要再寫類名了,但我們知道類是"一類事物的描述",缺少了類名的修飾,靜態屬性和靜態方法的表象意義可以被無限放大,這會讓閱讀者很難弄清楚其屬性或者方法代表何意,繩子哪一類的屬性(方法)都要思考一番(當然IDE的友好提示功能另說),把一個類的靜態匯入元素都引入進來了,那簡直就是噩夢。我們來看下面的例子:

 1 import static java.lang.Math.*;
 2 import static java.lang.Double.*;
 3 import static java.lang.Integer.*;
 4 import static java.text.NumberFormat.*;
 5 
 6 import java.text.NumberFormat;
 7 
 8 public class Client9 {
 9 
10     public static void formatMessage(String s) {
11         System.out.println("圓面積是: " + s);
12     }
13 
14     public static void main(String[] args) {
15         double s = PI * parseDouble(args[0]);
16         NumberFormat nf = getInstance();
17         nf.setMaximumFractionDigits(parseInt(args[1]));
18         formatMessage(nf.format(s));
19 
20     }
21 }

就這麼一段程式,看著就讓人惱火,常量PI,這知道是圓周率;parseDouble方法可能是Double類的一個轉換方法,這看名稱可以猜的到。那緊接著getInstance()方法是哪個類的?是Client9本地類?不對呀,本地沒有這個方法,哦,原來是NumberFormat類的方法,這個和formatMessage本地方法沒有任何區別了---這程式碼太難閱讀了,肯定有人罵娘。

  所以,對於靜態匯入,一定要追尋兩個原則:

  1. 不使用*(星號)萬用字元,除非是匯入靜態常量類(只包含常量的類或介面);
  2. 方法名是具有明確、清晰表象意義的工具類。

何為具有明確、清晰表象意義的工具類,我們看看Junit中使用靜態匯入的例子:

1 import static org.junit.Assert.*;
2 class DaoTest{
3     @Test
4     public void testInsert(){
5         //斷言
6         assertEquals("foo","foo");
7         assertFalse(Boolean.FALSE);
8     }
9 }

  我們從程式中很容易判斷出assertEquals方法是用來斷言兩個值是否相等的,assertFalse方法則是斷言表示式為假,如此確實減少了程式碼量,而且程式碼的可讀性也提高了,這也是靜態匯入用到正確的地方帶來的好處。

建議10:不要在本類中覆蓋靜態匯入的變數和方法

如果在一個類中的方法及屬性與靜態匯入的方法及屬性相同會出現什麼問題呢?看下面的程式碼

 1 import static java.lang.Math.PI;
 2 import static java.lang.Math.abs;
 3 
 4 public class Client10 {
 5     // 常量名於靜態匯入的PI相同
 6     public final static String PI = "祖沖之";
 7     //方法名於靜態匯入的方法相同
 8     public static int abs(int abs) {
 9         return 0;
10     }
11 
12     public static void main(String[] args) {
13         System.out.println("PI = "+PI);
14         System.out.println("abs(-100) = "+abs(-100));
15     }
16 }

  以上程式碼中定義了一個String型別的常量PI,又定義了一個abs方法,與靜態匯入的相同。首先說好訊息,程式碼沒有報錯,接下來是壞訊息:我們不知道那個屬性和方法別呼叫了,因為常量名和方法名相同,到底呼叫了那一個方法呢?執行之後結果為:

  PI  = "祖沖之",abs(-100) = 0;
  很明顯是本地的方法被呼叫了,為何不呼叫Math類中的屬性和方法呢?那是因為編譯器有一個"最短路徑"原則:如果能夠在本類中查詢到相關的變數、常量、方法、就不會去其它包或父類、介面中查詢,以確保本類中的屬性、方法優先。

  因此,如果要變更一個被靜態匯入的方法,最好的辦法是在原始類中重構,而不是在本類中覆蓋.

 

相關文章