書讀的多而不思考,你會覺得自己知道的很多。
書讀的多而思考,你會覺得自己不懂的越來越多。
———伏爾泰
在面向對象編程(Object-Oriented Programming, OOP)的世界裡,類和對象是真實世界的描述工具,方法是行為和動作的展示形式,封裝、繼承、多態則是其多姿多彩的主要實現方式,本章主要講述關於Java對象,對象,方法的種種規則,限制和建議。
看到這樣的標題,大家是否感到郁悶呢?接口中有實現代碼嗎?這怎麼可能呢?確實,接口中可以聲明常量,聲明抽象方法,可以繼承父接口,但就是不能有具體實現,因為接口是一種契約(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接口。你看,在接口中也存在著實現代碼吧!
這確實很好,很強大,但是在一般的項目中,此類代碼是嚴禁出現的,原因很簡單:這是一種非常不好的編碼習慣,接口是用來干什麼的?接口是一個契約,不僅僅約束著實現,同時也是一個保證,保證提供的服務(常量和方法)是穩定的、可靠的,如果把實現代碼寫到接口中,那接口將就綁定了可能變化的因素,這會導致實現不再穩定和可靠,是隨時都可能被拋棄、被更改、被重構的。所以,接口中雖然可以有實現,但應避免使用。
注意:接口中不能出現實現代碼。
這個標題是否像上一個建議的標題一樣讓人郁悶呢?什麼叫做變量一定要先聲明後賦值?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通用的開發規范"變量先聲明後賦值使用",是一個良好的編碼風格。
注意:再次重申變量要先聲明後使用,這不是一句廢話。
我們知到在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期望父類的靜態方法不要做破壞子類的業務行為,而覆寫是將父類的的行為增強或減弱,延續父類的職責。
解釋了這麼多,我們回頭看看本建議的標題,靜態方法不能覆寫,可以再續上一句話,雖然不能覆寫,但可以隱藏。順便說一下,通過實例對象訪問靜態方法或靜態屬性不是好習慣,它給代碼帶來了"壞味道",建議大家閱之戒之。
我們知道通過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類實現了服務器的創建邏輯,子類要在生成實例對象時傳遞一個端口號即可創建一個監聽端口的服務,該代碼的意圖如下:
貌似很合理,再仔細看看代碼,確實與我們的意圖相吻合,那我們嘗試多次運行看看,輸出結果要麼是"端口號:40000",要麼是"端口號:0",永遠不會出現"端口號:100"或是"端口號:1000",這就奇怪了,40000還好說,那個0是怎麼冒出來的呢?怠慢什麼地方出現了問題呢?
要解釋這個問題,我們首先要說說子類是如何實例化的。子類實例化時,會首先初始化父類(注意這裡是初始化,不是生成父類對象),也就是初始化父類的變量,調用父類的構造函數,然後才會初始化子類的變量,調用子類的構造函數,最後生成一個實例對象。了解了相關知識,我們再來看看上面的程序,其執行過程如下:
終於清楚了,在類初始化時getPort方法返回值還沒有賦值,port只是獲得了默認初始值(int類型的實例變量默認初始值是0),因此Server永遠監聽的是40000端口(0端口是沒有意義的)。這個問題的產生從淺處說是類元素初始順序導致的,從深處說是因為構造函數太復雜引起的。構造函數用作初始化變量,聲明實例的上下文,這都是簡單實現的,沒有任何問題,但我們的例子卻實現了一個復雜的邏輯,而這放在構造函數裡就不合適了。
問題知道了,修改也很簡單,把父類的無參構造函數中的所有實現都移動到一個叫做start的方法中,將SimpleServer類初始化完畢,再調用其start方法即可實現服務器的啟動工作,簡潔而又直觀,這也是大部分JEE服務器的實現方式。
注意:構造函數簡化,再簡化,應該達到"一眼洞穿"的境界。
構造函數是一個類初始化必須執行的代碼,它決定著類初始化的效率,如果構造函數比較復雜,而且還關聯了其它類,則可能產生想不到的問題,我們來看如下代碼:
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方法),再來看看問題,這種場景不可能出現嗎?
可能大家會覺得這樣的場景不會出現,這種問題只要系統一運行就會發現,不可能對項目產生影響。
那是因為我們這裡展示的代碼比較簡單,很容易一眼洞穿,一個項目中的構造函數可不止一兩個,類之間的關系也不會這麼簡單,要想瞥一眼就能明白是否有缺陷這對所有人員來說都是不可能完成的任務,解決此類問題最好的辦法就是:不要在構造函數中聲明初始化其他類,養成良好習慣。