編寫高質量程式碼:改善Java程式的151個建議(第3章:類、物件及方法___建議31~35)

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

                  書讀的多而不思考,你會覺得自己知道的很多。

                  書讀的多而思考,你會覺得自己不懂的越來越多。

                                    ———伏爾泰

  在物件導向程式設計(Object-Oriented Programming, OOP)的世界裡,類和物件是真實世界的描述工具,方法是行為和動作的展示形式,封裝、繼承、多型則是其多姿多彩的主要實現方式,本章主要講述關於Java物件,方法的種種規則,限制和建議。

建議31:在介面中不要存在實現程式碼

   看到這樣的標題,大家是否感到鬱悶呢?介面中有實現程式碼嗎?這怎麼可能呢?確實,介面中可以宣告常量,宣告抽象方法,可以繼承父介面,但就是不能有具體實現,因為介面是一種契約(Contract),是一種框架性協議,這表明它的實現類都是同一種型別,或者具備相似特徵的一個集合體。對於一般程式,介面確實沒有任何實現,但是在那些特殊的程式中就例外了,閱讀如下程式碼: 

 1 public class Client31 {
 2     public static void main(String[] args) {
 3         //呼叫介面的實現
 4         B.s.doSomeThing();
 5     }
 6 }
 7 
 8 // 在介面中存在實現程式碼
 9 interface B {
10     public static final S s = new S() {
11         public void doSomeThing() {
12             System.out.println("我在介面中實現了");
13         }
14     };
15 }
16 
17 // 被實現的介面
18 interface S {
19     public void doSomeThing();
20 }

  仔細看main方法,注意那個B介面。它呼叫了介面常量,在沒有實現任何顯示實現類的情況下,它竟然列印出了結果,那B介面中的s常量(介面是S)是在什麼地方被實現的呢?答案在B介面中。

  在B介面中宣告瞭一個靜態常量s,其值是一個匿名內部類(Anonymous Inner Class)的例項物件,就是該匿名內部類(當然,也可以不用匿名,直接在介面中是實現內部類也是允許的)實現了S介面。你看,在介面中也存在著實現程式碼吧!

  這確實很好,很強大,但是在一般的專案中,此類程式碼是嚴禁出現的,原因很簡單:這是一種非常不好的編碼習慣,介面是用來幹什麼的?介面是一個契約,不僅僅約束著實現,同時也是一個保證,保證提供的服務(常量和方法)是穩定的、可靠的,如果把實現程式碼寫到介面中,那介面就繫結了可能變化的因素,這會導致實現不再穩定和可靠,是隨時都可能被拋棄、被更改、被重構的。所以,介面中雖然可以有實現,但應避免使用。

  注意:介面中不能出現實現程式碼。

建議32:靜態變數一定要先宣告後賦值

  這個標題是否像上一個建議的標題一樣讓人鬱悶呢?什麼叫做變數一定要先宣告後賦值?Java中的變數不都是先宣告後使用的嗎?難道還能先使用後宣告?能不能暫且不說,我們看一個例子,程式碼如下:

 1 public class Client32 {
 2     public static int i = 1;
 3 
 4     static {
 5         i = 100;
 6     }
 7     public static void main(String[] args) {
 8         System.out.println(i);
 9     }
10 }

  這段程式很簡單,輸出100嘛,對,確實是100,我們稍稍修改一下,程式碼如下:

 1 public class Client32 {
 2     static {
 3         i = 100;
 4     }
 5 
 6     public static int i = 1;
 7 
 8     public static void main(String[] args) {
 9         System.out.println(i);
10     }
11 }

  注意變數 i 的宣告和賦值調換了位置,現在的問題是:這段程式能否編譯?如過可以編譯,輸出是多少?還要注意,這個變數i可是先使用(也就是賦值)後宣告的。

  答案是:可以編譯,沒有任何問題,輸出結果為1。對,輸出是 1 不是100.僅僅調換了位置,輸出就變了,而且變數 i 還是先使用後宣告的,難道顛倒了?

  這要從靜態變數的誕生說起,靜態變數是類載入時被分配到資料區(Data Area)的,它在記憶體中只有一個拷貝,不會被分配多次,其後的所有賦值操作都是值改變,地址則保持不變。我們知道JVM初始化變數是先宣告空間,然後再賦值,也就是說:在JVM中是分開執行的,等價於:

  int  i ; //分配空間

  i = 100; //賦值

  靜態變數是在類初始化的時候首先被載入的,JVM會去查詢類中所有的靜態宣告,然後分配空間,注意這時候只是完成了地址空間的分配,還沒有賦值,之後JVM會根據類中靜態賦值(包括靜態類賦值和靜態塊賦值)的先後順序來執行。對於程式來說,就是先宣告瞭int型別的地址空間,並把地址傳遞給了i,然後按照類的先後順序執行賦值操作,首先執行靜態塊中i = 100,接著執行 i = 1,那最後的結果就是 i =1了。

  哦,如此而已,如果有多個靜態塊對 i 繼續賦值呢?i 當然還是等於1了,誰的位置最靠後誰有最終的決定權。

  有些程式設計師喜歡把變數定義放到類最底部,如果這是例項變數還好說,沒有任何問題,但如果是靜態變數,而且還在靜態塊中賦值了,那這結果就和期望的不一樣了,所以遵循Java通用的開發規範"變數先宣告後賦值使用",是一個良好的編碼風格。

  注意:再次重申變數要先宣告後使用,這不是一句廢話。

建議33:不要覆寫靜態方法

     我們知到在Java中可以通過覆寫(Override)來增強或減弱父類的方法和行為,但覆寫是針對非靜態方法(也叫做例項方法,只有生成例項才能呼叫的方法)的,不能針對靜態方法(static修飾的方法,也叫做類方法),為什麼呢?我們看一個例子,程式碼如下: 

 1 public class Client33 {
 2     public static void main(String[] args) {
 3         Base base = new Sub();
 4         //呼叫非靜態方法
 5         base.doAnything();
 6         //呼叫靜態方法
 7         base.doSomething();
 8     }
 9 }
10 
11 class Base {
12     // 我是父類靜態方法
13     public static void doSomething() {
14         System.out.println("我是父類靜態方法");
15     }
16 
17     // 父類非靜態方法
18     public void doAnything() {
19         System.out.println("我是父類非靜態方法");
20     }
21 }
22 
23 class Sub extends Base {
24     // 子類同名、同引數的靜態方法
25     public static void doSomething() {
26         System.out.println("我是子類靜態方法");
27     }
28 
29     // 覆寫父類非靜態方法
30     @Override
31     public void doAnything() {
32         System.out.println("我是子類非靜態方法");
33     }
34 }

 

  注意看程式,子類的doAnything方法覆寫了父類方法,真沒有問題,那麼doSomething方法呢?它與父類的方法名相同,輸入、輸出也相同,按道理來說應該是覆寫,不過到底是不是覆寫呢?我們看看輸出結果:   我是子類非靜態方法      我是父類靜態方法

  這個結果很讓人困惑,同樣是呼叫子類方法,一個執行了父類方法,兩者的差別僅僅是有無static修飾,卻得到不同的結果,原因何在呢?

  我們知道一個例項物件有兩個型別:表面型別(Apparent Type)和實際型別(Actual Type),表面型別是宣告的型別,實際型別是物件產生時的型別,比如我們例子,變數base的表面型別是Base,實際型別是Sub。對於非靜態方法,它是根據物件的實際型別來執行的,也就是執行了Sub類中的doAnything方法。而對於靜態方法來說就比較特殊了,首先靜態方法不依賴例項物件,它是通過類名來訪問的;其次,可以通過物件訪問靜態方法,如果是通過物件訪問靜態方法,JVM則會通過物件的表面型別查詢靜態方法的入口,繼而執行之。因此上面的程式列印出"我是父類非靜態方法",也就不足為奇了。

  在子類中構建與父類方法相同的方法名、輸入引數、輸出引數、訪問許可權(許可權可以擴大),並且父類,子類都是靜態方法,此種行為叫做隱藏(Hide),它與覆寫有兩點不同:

  (1)、表現形式不同:隱藏用於靜態方法,覆寫用於非靜態方法,在程式碼上的表現是@Override註解可用於覆寫,不可用於隱藏。

  (2)、職責不同:隱藏的目的是為了拋棄父類的靜態方法,重現子類方法,例如我們的例子,Sub.doSomething的出現是為了遮蓋父類的Base.doSomething方法,也就是i期望父類的靜態方法不要做破壞子類的業務行為,而覆寫是將父類的的行為增強或減弱,延續父類的職責。

  解釋了這麼多,我們回頭看看本建議的標題,靜態方法不能覆寫,可以再續上一句話,雖然不能覆寫,但可以隱藏。順便說一下,通過例項物件訪問靜態方法或靜態屬性不是好習慣,它給程式碼帶來了"壞味道",建議大家閱之戒之。

建議34:建構函式儘量簡化

   我們知道通過new關鍵字生成的物件必然會呼叫建構函式,建構函式的簡繁情況會直接影響例項物件的建立是否繁瑣,在專案開發中,我們一般都會制定建構函式儘量簡單,儘可能不拋異常,儘量不做複雜運算等規範,那如果一個建構函式確實複雜了會怎麼樣?我們開看一段程式碼:

 1 public class Client34 {
 2     public static void main(String[] args) {
 3         Server s= new SimpleServer(1000);
 4     }
 5 }
 6 
 7 abstract class Server {
 8     public final static int DEFAULT_PORT = 40000;
 9 
10     public Server() {
11         // 獲得子類提供的埠號
12         int port = getPort();
13         System.out.println("埠號:" + port);
14         /* 進行監聽動作 */
15     }
16 
17     // 由子類提供埠號,並作可用性檢查
18     protected abstract int getPort();
19 }
20 
21 class SimpleServer extends Server {
22     private int port = 100;
23 
24     // 初始化傳遞一個埠號
25     public SimpleServer(int _port) {
26         port = _port;
27     }
28 
29     // 檢查埠是否有效,無效則使用預設埠,這裡使用隨機數模擬
30     @Override
31     protected int getPort() {
32         return Math.random() > 0.5 ? port : DEFAULT_PORT;
33     }
34 
35 }

   該程式碼是一個服務類的簡單模擬程式,Server類實現了伺服器的建立邏輯,子類要在生成例項物件時傳遞一個埠號即可建立一個監聽埠的服務,該程式碼的意圖如下:

  1. 通過SimpleServer的建構函式接收埠引數;
  2. 子類的建構函式預設呼叫父類的建構函式;
  3. 父類建構函式呼叫子類的getPort方法獲得埠號;
  4. 父類的建構函式建立埠監聽機制;
  5. 物件建立完畢,服務監聽啟動,正常執行。

  貌似很合理,再仔細看看程式碼,確實與我們的意圖相吻合,那我們嘗試多次執行看看,輸出結果要麼是"埠號:40000",要麼是"埠號:0",永遠不會出現"埠號:100"或是"埠號:1000",這就奇怪了,40000還好說,那個0是怎麼冒出來的呢?怠慢什麼地方出現了問題呢?

  要解釋這個問題,我們首先要說說子類是如何例項化的。子類例項化時,會首先初始化父類(注意這裡是初始化,不是生成父類物件),也就是初始化父類的變數,呼叫父類的建構函式,然後才會初始化子類的變數,呼叫子類的建構函式,最後生成一個例項物件。瞭解了相關知識,我們再來看看上面的程式,其執行過程如下:

  1. 子類SimpleServer的建構函式接收int型別的引數1000;
  2. 父類初始化常量,也就是DEFAULT_PORT初始化,並設定值為40000;
  3. 執行父類無參建構函式,也就是子類有參建構函式預設包含了super()方法;
  4. 父類無參建構函式執行到“int port = getPort() ”方法,呼叫子類的getPort方法實現;
  5. 子類的getPort方法返回port值(注意,此時port變數還沒有賦值,是0)或DEFAULT_PORT(此時已經是40000)了;
  6. 父類初始化完畢,開始初始化子類的例項變數,port值賦值100;
  7. 執行子類建構函式,port值被重新賦值為1000;
  8. 子類SimpleServer例項化結束,物件建立完畢。

  終於清楚了,在類初始化時getPort方法返回值還沒有賦值,port只是獲得了預設初始值(int型別的例項變數預設初始值是0),因此Server永遠監聽的是40000埠(0埠是沒有意義的)。這個問題的產生從淺處說是類元素初始順序導致的,從深處說是因為建構函式太複雜引起的。建構函式用作初始化變數,宣告例項的上下文,這都是簡單實現的,沒有任何問題,但我們的例子卻實現了一個複雜的邏輯,而這放在建構函式裡就不合適了。

  問題知道了,修改也很簡單,把父類的無參建構函式中的所有實現都移動到一個叫做start的方法中,將SimpleServer類初始化完畢,再呼叫其start方法即可實現伺服器的啟動工作,簡潔而又直觀,這也是大部分JEE伺服器的實現方式。

  注意:建構函式簡化,再簡化,應該達到"一眼洞穿"的境界

建議35:避免在建構函式中初始化其它類

  建構函式是一個類初始化必須執行的程式碼,它決定著類初始化的效率,如果建構函式比較複雜,而且還關聯了其它類,則可能產生想不到的問題,我們來看如下程式碼:

 1 public class Client35 {
 2     public static void main(String[] args) {
 3         Son son = new Son();
 4         son.doSomething();
 5     }
 6 }
 7 
 8 // 父類
 9 class Father {
10     public Father() {
11         new Other();
12     }
13 }
14 
15 // 相關類
16 class Other {
17     public Other() {
18         new Son();
19     }
20 }
21 
22 // 子類
23 class Son extends Father {
24     public void doSomething() {
25         System.out.println("Hi, show me Something!");
26     }
27 }

 

   這段程式碼並不複雜,只是在建構函式中初始化了其它類,想想看這段程式碼的執行結果是什麼?會列印出"Hi ,show me Something!"嗎?

  答案是這段程式碼不能執行,報StatckOverflowError異常,棧(Stack)記憶體溢位,這是因為宣告變數son時,呼叫了Son的無參建構函式,JVM又預設呼叫了父類的建構函式,接著Father又初始化了Other類,而Other類又呼叫了Son類,於是一個死迴圈就誕生了,知道記憶體被消耗完停止。

  大家可能覺得這樣的場景不會出現在開發中,我們來思考這樣的場景,Father是由框架提供的,Son類是我們自己編寫的擴充套件程式碼,而Other類則是框架要求的攔截類(Interceptor類或者Handle類或者Hook方法),再來看看問題,這種場景不可能出現嗎?  

  可能大家會覺得這樣的場景不會出現,這種問題只要系統一執行就會發現,不可能對專案產生影響。

  那是因為我們這裡展示的程式碼比較簡單,很容易一眼洞穿,一個專案中的建構函式可不止一兩個,類之間的關係也不會這麼簡單,要想瞥一眼就能明白是否有缺陷這對所有人員來說都是不可能完成的任務,解決此類問題最好的辦法就是:不要在建構函式中宣告初始化其他類,養成良好習慣。

相關文章