讀古今文學網 > Android程序設計:第2版 > Java類型系統 >

Java類型系統

Java語言基礎數據類型有兩種:對像和基本類型(primitives)。Java通過強制使用靜態類型來確保類型安全,要求每個變量在使用之前必須先聲明。舉個例子,變量i的類型聲明是int(原始32位整數),代碼如下:


int i;
  

這種機制和非靜態類型的語言有很大差別,非靜態語言不要求對變量進行聲明。雖然顯式類型聲明看起來較煩瑣,但其有助於編譯器對很多編程錯誤的預防,例如,由於變量名拼寫錯誤導致創建了沒有用的變量,調用了不存在的方法等,顯式聲明可以徹底防止這些錯誤被生成到運行代碼中。關於Java類型系統的詳細說明可以在Java語言規範(Java Language Specification)中找到。

基本類型

Java的基本類型不是對象,它們不支持本章稍後將會描述的對象相關的操作。基本數據類型只能通過一些預定義的操作符來修改它們,例如,「+」、「-」、「&」、「|」及「=」等。Java中的基本類型如下所示:

boolean(布爾型)

值為真或假

byte(字節)

8位二進制整數

short(短整型)

16位二進制整數

int(整型)

32位二進制整數

long(長整型)

64位二進制整數

char(字符型)

16位無符號整數,表示一個UTF-16編碼單元

float(浮點型)

32位IEEE 754標準的浮點數

double(雙精度浮點型)

64位的IEEE 754標準的浮點數

對像和類

Java是一種面向對象的語言,其重點不是基礎數據類型,而是對像(數據的組合及對這些數據的操作)。類(class)定義了成員變量(數據)和方法(程序),它們一起組成一個對象。在Java中,該定義(構建對像所用的模板)本身就是一種特定類型的對象,即類。在Java中,類是類型系統的基礎,開發人員可以用它來描述任意複雜的對象,包括複雜的、專門的對象和行為。

與絕大多數面向對象的語言一樣,在Java語言中,某些類型可以從其他類型繼承而來。如果一個類是從另一個類中繼承來的,那麼可以說這個類是其父類的子類(subtype或subclass),而其父類被稱為超類(supertype或superclass)。有多個子類的類可以稱為這些子類的基類(base type)。

在一個類中,方法和成員變量的作用域都可以是全局的,在對像外可以通過對這個類的實例的引用來訪問它們。

以下給出了一個非常簡單的類的例子,它只有一個成員變量ctr和一個方法incr:


public class Trivial {
    /** a field: its scope is the entire class */
    private long ctr;
    /** Modify the field. */
    public void incr { ctr++; }
}
  

對象的創建

使用關鍵字new創建一個新的對象,即某個類的實例,如:


Trivial trivial = new Trivial;
  

在賦值運算符「=」的左邊定義了一個變量,名為Trivial。該變量的類型是Trivial,因此只能賦給它類型為Trivial的對象。賦值符右邊為新創建的Trivial類的實例分配內存,並對該實例進行實體化。賦值操作符為新創建的對象變量分配引用。

在Trivial這個類中,變量ctr的定義是絕對安全的,雖然沒有對它進行顯式初始化,這可能會讓你很吃驚。Java會保證給ctr的初始值賦為0。Java會確保所有的字段在對像創建時自動進行初始化。布爾值初始化為false,基本數值類型初始化為0,所有的對象類型(包括String)初始化為null。

警告:上述的初始化賦值只適用於對象的成員變量。局部變量在被引用前必須進行初始化!

可以在定義類時,通過構造函數更好地控制對象的初始化。構造函數的定義看起來很像一個方法,區別在於構造函數沒有返回類型且名字必須和類的完全相同:


public class LessTrivial {
    /** a field: its scope is the entire class */
    private long ctr;
    /** Constructor: initialize the fields */
    public LessTrivial(long initCtr) { ctr = initCtr; }
    /** Modify the field. */
    public void incr { ctr++; }
}
  

事實上,Java中的每個類都會有一個構造函數。如果沒有顯式定義的構造函數,Java編譯器會自動創建一個不帶參數的構造函數。此外,如果子類的構造函數沒有顯式調用超類的構造函數,那麼Java編譯器會自動隱式調用超類的無參數的構造函數。前面給出了Trivial的定義(它沒有顯式地指定構造函數),實際上Java編譯器會自動為它創建一個構造函數:


public Trivial { super; }
  

如上所示,由於LessTrivial類顯式定義了一個構造函數,因此Java不會再給它隱式地定義一個默認的構造函數。這意味著如果創建一個沒有參數的LessTrivial對象,會出現錯誤:


LessTrivial fail = new LessTrivial; // ERROR!!
LessTrivial ok = new LessTrivial(18); // ... works
  

有兩個不同的概念,需要對它們進行區分:「無參數的構造函數」和「默認的構造函數」。「默認的構造函數」是沒有給一個類定義任何構造函數時,Java隱式地創建的構造函數,這個默認的構造函數剛好也是無參數的構造函數。而無參數的構造函數僅僅是沒有參數的構造函數。Java不要求一個類包含沒有參數的構造函數,也不需要定義無參數的構造函數,除非存在某些特定的需求。

警告:有一種特殊情況,需要無參數的構造函數,需要特別注意。有些庫需要能夠創建通用的新的對象。例如,JUnit框架,不管要測試什麼,都需要能夠創建新的測試用例。對持久性存儲或網絡連接進行編碼(marshal)和解碼(unmarshal)的庫也需要能夠創建新的對象。因為這些庫在運行時難以確定具體對像所需要的調用函數,它們通常要求顯式指定沒有參數的構造函數。

如果一個類有多個構造函數,則最好採用級聯(cascade)的方法創建它們,從而確保只會有一份代碼對實例進行初始化,所有其他構造函數都調用它。為了便於說明,我們用一個例子來演示一下。為了更好地模擬常見情況,我們給LessTrivial類增加一個無參數的構造函數:


public class LessTrivial {
    /** a field: its scope is the entire class */
    private long ctr;
    /** Constructor: init counter to 0 */
    public LessTrivial { this(0); }
    /** Constructor: initialize the fields */
    public LessTrivial(long initCtr) { ctr = initCtr; }
    /** Modify the field. */
    public void incr { ctr++; }
}
  

級聯方法(cascading method)是Java中標準的用來為一些參數賦默認值的方法。一個對象的初始化代碼應該統一放在一個單一、完整的方法或構造函數中,所有其他方法或構造函數只是簡單地調用它。在級聯方法中,在類的構造函數中必須顯式調用其超類的構造函數。

構造函數應該是簡單的,而且只應該包含為對象的成員變量指定一致性的初始狀態的操作。舉個例子,設計一個對像用來表示數據庫或網絡連接,可能會在構造函數中執行連接的創建、初始化和可用性的驗證操作。雖然這看起來很合理,但實際上這種方式會導致代碼模塊化程度不夠,從而難以調試和修改。更好的設計是構造函數只是簡單地把連接狀態初始化為closed,並另外創建一個方法來顯式地設置網絡連接。

對像類及其方法

Java類Object(java.lang.Object)是所有類的根類,每個Java對象都是一個Object。如果一個類在定義時沒有顯式指定其超類,它就是Object類的直接子類。Object類中定義了一組方法,這些方法是所有對象都需要的一些關鍵行為的默認實現。除非子類重寫了(override)這些方法,否則都會直接繼承自Object類。

Object類中的wait、notify和notifyAll方法是Java並發支持的一部分。P72「使用了wait和notify方法的線程控制」一節將對這些方法進行探討。

toString方法是對像用來創建一個自我描述的字符串的方法。toString方法的一個有趣的使用方式是用於字符串連接,任何一個對象都可以和一個字符串進行連接。以下這個例子給出了輸出相同消息的兩種方式,它們的運行結果完全相同。在這兩個方法中,都為Foo類創建了新的實例並調用其toString方法,隨後把結果和文本字符串連接起來,最後輸出結果:


System.out.println(
    "This is a new foo: " + new Foo);
System.out.println(
    "This is a new foo: ".concat((new Foo).toString));
  

在Object類中,toString方法的實現基於對像在堆中的位置,其返回一個沒什麼用的字符串。在代碼中對toString方法進行重寫是方便後期調試良好的開端。

clone方法和finalize方法屬於歷史遺留,只有在子類中重寫了finalize方法時,Java才會在運行時調用該方法。但是,當類顯式地定義了finalize方法時,對該類的對象執行垃圾回收時會調用該方法。Java不但無法保證什麼時候會調用finalize方法,實際上,它甚至無法確保一定會調用這個方法。此外,調用finalize方法可能會重新激活一個對象!其中的道理很複雜。當一個對像不存在可用的引用時,Java就會自動對它執行垃圾回收。但是,finalize方法的實現會為這個對象「創建」一個新的可用的引用,例如把實現了finalize的對象加到某個列表中!由於這個原因,finalize方法的實現阻礙了對所定義的類的很多優化。使用finalize方法,不會帶來什麼好處,卻帶來了一堆的壞處。

通過clone方法,可以不調用構造函數而直接創建對象。雖然在Object類中定義了clone方法,但在一個對像中調用clone方法會導致異常,除非該對像實現了Cloneable接口。當創建一個對象的代價很高時,clone方法可以成為一種有用的優化方式。雖然在某些特定情況下,使用clone方法可能是必須的,但是通過複製構造函數(以已有的實例作為其唯一參數)顯得更簡單,而且在很多情況下,其代價是可以忽略的。

Object類的最後兩個方法是hashCode和equals,通過這兩個方法,調用者可以知道一個對象是否和另一個對像相同。

在API文檔中,Object類的equals方法的定義規定了equals的實現準則。equals方法的實現應確保具有以下4個特性,而且相關的聲明必須始終為真:

自反性


x.equals(x)
  

對稱性


x.equals(y) == y.equals(x)
  

傳遞性


(x.equals(y) && y.equals(z)) == x.equals(z)
  

一致性

如果x.equals(y)在程序生命週期的任意點都為真,只要x和y值不變,則x.equals(y)就始終為真。

要滿足這4大特性,實際上需要很細緻工作,而且其困難程度可能超出預期。常見的錯誤之一是定義一個新的類(違反了自反性),它在某些情況下等價於已有的類。假設程序使用了已有的定義了類EnglishWeekdays的庫,假設又定義了類FrenchWeekdays。顯然,我們很可能會為FrenchWeekdays類定義equals方法,該方法和EnglishWeekdays相應的French等值進行比較並返回真。但是千萬不要這麼做!已有的EnglishWeekdays類看不到新定義的FrenchWeekdays類,因而它也永遠都無法確定你所定義的類的實例是否是等值的。因此,這種方式違反了自反性!

hashCode方法和equals方法應該是成對出現的,只要重寫了其中一個方法,另外一個也應該重寫。很多庫程序把hashCode方法作為判斷兩個對象是否等價的一種優化方式。這些庫首先比較兩個對象的散列碼,如果這兩個對象的散列碼不同,那麼就沒有必要執行代價更高的比較操作,因為這兩個對像一定是不同的。散列碼算法的特點在於計算非常快速,這方面可以很好地取代equals方法。一方面,訪問大型數組的每個元素來計算其散列碼,很可能還比不上執行真正的比較操作,而另一方面,通過散列碼計算可以非常快速地返回0值,只是可能不是非常有用。

對像、繼承和多態

Java支持多態(polymorphism),多態是面向對像編程的一個關鍵概念。對於某種語言,如果單一類型的對象具備不同的行為,則認為該語言具備多態性。如果某個類的子類可以被賦給其基礎類型的變量,那麼就認為這個類是多態的,下面通過例子說明會更清晰。

在Java中,聲明子類的關鍵字是extends。Java繼承的例子如下:


public class Car {
    public void drive {
        System.out.println("Going down the road!");
    }
}
public class Ragtop extends Car {
    // override the parent's definition.
    public void drive {
        System.out.println("Top down!");
            // optionally use a superclass method        
            super.drive; 
            System.out.println("Got the radio on!");    
      }
}
  

Ragtop是Car的子類。從前面的介紹中,可以知道Car是Object的子類。Ragtop重新定義(即重寫)了Car的drive方法。Car和Ragtop都是Car類型(但它們並不都是Ragtop類型),它們的drive方法有著不同的行為。

現在,我們來演示一個多態的例子:


Car auto = new Car;
auto.drive;
auto = new Ragtop;
auto.drive;
  

儘管把Ragtop類型賦值給了Car類型的變量,但這段代碼可以編譯通過(雖然把Ragtop類型賦值給Car類型的變量)。它還可以正確運行,並輸出如下結果:


Going down the road!
Top down!
Going down the road!
Got the radio on!
  

auto這個變量在生命的不同時期,分別指向了兩個不同的Car類型的對象引用。其中一個對象,不但是Car類型,也是其子類Ragtop類型。auto.drive語句的確切行為取決於該變量當前是指向基類對象的引用還是子類對象的引用,這就是所謂的多態行為。

類似很多其他的面向對像編程語言,Java支持類型轉換,允許聲明的變量類型為多態形式下的任意一種變量類型。


Ragtop funCar;
Car auto = new Car;
funCar = (Ragtop) auto; //ERROR! auto is a Car, not a Ragtop!
auto.drive;
auto = new Ragtop;
Ragtop funCar = (Ragtop) auto; //Works! auto is a Ragtop
auto.drive;
  

雖然類型轉換(casting)在某些情況下是必要的,但過度使用類型轉換會使得代碼很雜亂。顯然,根據多態規則,所有的變量都可以聲明為Object類型,然後進行必要的轉換,但是這種方式違背了靜態類型(static typing)準則。

Java限制方法的參數(即真正參數)是多態的,表示形參所指向的對象類型。同樣,方法的返回值也是多態的,返回聲明的對象類型。舉個例子,繼續以之前的汽車為例,以下代碼片段可以正常編譯和運行:


public class JoyRide {
    private Car myCar;
    public void park(Car auto) {
        myCar = auto;
    }
    public Car whatsInTheGarage {
        return myCar;
    }
    public void letsGo {
        park(new Ragtop);
        whatsInTheGarage.drive;
    }
}
  

在方法park的聲明中,Car類型的對象是其唯一參數。但是在方法letsGo中,在調用它時傳遞的參數類型是Ragtop,即Car類型的子類。同樣,變量myCar賦值的類型為Ragtop,方法whatsInTheGarage返回變量myCar的值。如果一個對象是Ragtop類型,當調用drive方法時,它會輸出「Top down!」和「Got the radio on!」信息;另一方面,因為它又是Car類型,它還可以用於任何Car類型可用的方法調用中。這種子類型可取代父類型是多態的一個關鍵特徵,也是其可以保證類型安全的重要因素。在編譯階段,一個對象是否和其用途兼容也已經非常清晰。類型安全使得編譯器能夠及早發現錯誤,這些錯誤如果只是在運行時才出現,那麼發現這些錯誤的成本就會高很多。

Final聲明和Static聲明

Java有11個關鍵字可以用作聲明的修飾符,這些修飾符會改變被聲明對象的行為,有時是很重要的改變。例如,在前面的例子中使用了多次的關鍵字:public和private。這兩個修飾符的作用是控制對象的作用域和可見性。在後面的章節中還會更詳細地介紹它們。在本節中,我們將探討的是另外兩個修飾符,這兩個修飾符是全面理解Java類型系統的基礎:final和static。

如果一個對象的聲明前面包含了final修飾符,則意味著這個對象的內容不能再被改變。類、方法、成員變量、參數和局部變量都可以是final類型。

當用final修飾類時,意味著任何為其定義子類的操作都會引發錯誤。舉個例子,String類是final類型,因為作為其內容的字符串必須是不可改變的(也就是說,創建了一個字符串後,就不能夠改變它)。如果你仔細思考一下,就會發現,確保其內容不被改變的唯一方式就是確保不能以String類型為基類來創建子類。如果能夠創建子類,例如DeadlyString,就可以把DeadlyString類的實例作為參數,並在驗證完其內容後,馬上在代碼中把該實例的值從「fred」改成「';DROP TABLE contacts;」(把惡意SQL注入到你的系統中,對你的數據庫進行惡意修改)!

當用final修飾方法時,它表示子類不能重寫(override)這個方法。開發人員使用final方法來設計繼承性(inheritance),子類的行為必須和實現高度相關,而且不允許改變其實現。舉個例子,一個實現了通用的緩存機制的框架可能會定義一個基類CacheableObject,編程人員使用該框架的子類型來創建每個新的可緩存的對象類型。然而,為了維護框架的完整性,CacheableObject可能需要計算一個緩存鍵(cache key),該緩存鍵對於各對像類型都是一致的。在這種情況下,該緩存框架就可以把其方法computeCacheKey聲明為final類型。

當用final修飾變量——成員變量、參數和局部變量時,它表示一旦對該變量進行了賦值,就不能再改變。這種限制是由編譯器負責保障的:不但變量的值「不會」發生變化,而且編譯器必須能夠證明它「不能」發生改變。用final修飾成員變量時,表示該成員變量的值必須在變量的聲明或者構造函數中指定。如果沒有在變量的聲明或構造函數中對final類型的成員變量進行初始化,或者試圖在任何其他地方對它進行賦值,都會出現錯誤。

當用final修飾參數時,表示在這個方法內,該參數的值一直都是在調用時傳遞進來的那個值。如果對final類型的參數進行賦值,就會出現錯誤。當然,由於參數值很可能是某種對象的引用,對像內部的內容是有可能會發生變化的。用關鍵字final修飾參數時僅僅表示該參數不能被賦值。

注意:在Java中,參數都是按值傳遞:函數的參數就是調用時所傳遞值的一個副本。另外,在Java中,在大部分情況下,變量是對象的引用,Java只是複製引用,而不是整個對象!引用就是所傳遞的值!

final類型的變量只能對其賦值一次。由於使用一個沒有初始化的變量在Java中會出現錯誤,因此final類型的變量只能夠被賦值一次。該賦值操作可以在函數結束之前任何時候進行,當然要在使用該參數之前。

靜態(static)聲明可以用於類,但不能用於類的實例。和static相對應的是dynamic(動態)。任何沒有聲明為static的實體,都是默認的dynamic類型。下述例子是對這一特點的說明:


public class QuietStatic {
    public static int classMember;
    public int instanceMember;
}
public class StaticClient {
    public void test {
        QuietStatic.classMember++;
        QuietStatic.instanceMember++; // ERROR!!
        QuietStatic ex = new QuietStatic;
        ex.classMember++; // WARNING!
        ex.instanceMember++;
    }
}
  

在這個例子中,QuietStatic是一個類,ex是該類的一個實例的引用。靜態成員變量classMember是QuietStatic的成員變量;可以通過類名引用它(QuietStatic.classMember)。反之,instanceMember是QuietStatic類的實例的成員變量,通過類名引用它(QuietStatic.instanceMember)就會出現錯誤。這種處理機制是有道理的,因為可以存在很多個名字為instanceMember的不同的變量,每個變量屬於QuietStatic類的一個實例。如果沒有顯式指定是哪個instanceMember,那麼Java也不可能知道是哪個instanceMember。

正如下一組語句所示,Java確實允許通過實例引用來引用類的(靜態)變量。這容易讓人產生誤解,被認為是不好的編程習慣。如果這麼做,大多數編譯器和IDE就會生成警告。

靜態聲明和動態聲明的含義之間的區別很微妙。最容易理解的是靜態成員變量和動態成員變量之間的區別。再次說明,靜態定義在一個類中只有一份副本,而動態定義對於每個實例都有一份副本。靜態成員變量保存的是一個類的所有成員所共有的信息。


public class LoudStatic {
    private static int classMember;
    private int instanceMember;
    public void incr {
        classMember++;
        instanceMember++;
    }
    @Override public String toString {
        return "classMember: " + classMember
            + ", instanceMember: " + instanceMember;
    }
    public static void main(String args) {
        LoudStatic ex1 = new LoudStatic;
        LoudStatic ex2 = new LoudStatic;
        ex1.incr;
        ex2.incr;
        System.out.println(ex1);
        System.out.println(ex2);
    }
}
  

該程序的輸出是:


classMember: 2, instanceMember: 1
classMember: 2, instanceMember: 1
  

在前面這個例子中,變量classMember的初始值被設置為0。在兩個不同的實例ex1和ex2中,分別調用incr方法對它執行遞加操作,兩個實例輸出的classMember值都是2。變量instanceMember在每個實例中,其初始值也都是被設置為0。但是,每個實例只對自己的instanceMember執行遞加操作,因此輸出的instanceMember值都為1。

在上面兩個實例中,靜態類定義和靜態方法定義的共同點在於靜態對像在其命名空間內都是可見的,而動態對像只能通過每個實例的引用才可見。此外,相比之下,靜態對像和動態對像之間的區別則更為微妙。

靜態方法和動態方法之間的一個顯著區別在於靜態方法在子類中不能重寫。舉個例子,下面的代碼在編譯時會出錯:


public class Star {
    public static void twinkle { }
}
public class Arcturus extends Star {
    public void twinkle { } // ERROR!!
}
public class Rigel {
    // this one works
    public void twinkle { Star.twinkle; }
}
  

在Java中,幾乎沒有理由要使用靜態方法。在Java的早期實現中,動態方法調用明顯慢於靜態方法。開發人員常常傾向於使用靜態方法來「優化」其代碼。在Android的即時編譯Dalvik環境中,不再需要這種優化。過度使用靜態方法通常意味著架構設計不良。

靜態類和動態類之間的區別是最微妙的。應用中的絕大部分類都是靜態的。類通常是在最高層聲明和定義的——在任何代碼塊之外。默認情況下,所有這些聲明都是靜態的;相反,很多其他聲明,在某些類之外的代碼塊,默認情況下是動態的。雖然成員變量默認是動態的,其需要顯式地使用靜態修飾符才會是靜態的,但類默認是靜態的。

注意:代碼塊(block)是指兩個大括號之間的代碼,即{和}之間的代碼。在代碼塊內所定義的一切,即變量、類型和方法等,在代碼塊內及其內嵌的代碼塊內都是可見的。而在一個代碼塊內,不止是在其中定義的類,所定義的一切在代碼塊外都是不可見的。

實際上,這完全符合一致性要求。根據對「靜態」的定義(屬於類但不屬於類的實例),高層聲明應該是靜態的,因為它們不屬於任何一個類。但是,如果是在代碼塊內定義的(例如在高層類內定義),那麼類的定義默認也是動態的。因此,為了動態地聲明一個類,只需要在另一個類內定義它。

這一點也說明了靜態類和動態類之間的區別。動態類能夠訪問代碼塊內的類(因為它屬於實例)的實例成員變量,而靜態類卻無法訪問。以下代碼是對這個特點的示例說明:


public class Outer {
    public int x;
    public class InnerOne {
        public int fn { return x; }
    }
    public static class InnerTube {
        public int fn {
            return x; // ERROR!!!
         }
     }
}
public class OuterTest {
    public void test {
        new Outer.InnerOne; // ERROR!!!
        new Outer.InnerTube;
    }
}
  

稍加思考,這段代碼就可理解。成員變量x是類Class的實例的成員變量,也就是說,可以有很多名字為x的變量,每個變量都是Outer的運行時實例的成員變量。類InnerTube是類Outer的一部分,但不屬於任何一個Outer實例。因此,在InnerTube類中無法訪問Outer的實例成員變量x。相反,由於類InnerOne是動態的,它屬於類Outer的一個實例。因此可以把類InnerOne理解成隸屬於類Outer的每個實例的獨立的類(雖然不是這個含義,但實際上就是這麼實現的)。因此,InnerOne能夠訪問其所屬的Outer類的實例的成員變量x。

類OuterTest說明了對於成員變量,我們可以使用類名.內部靜態類來定義,並可以使用該靜態類型的類的內部定義Outer.InnerTube(在這個例子中,是創建該類的一個實例),而動態類型的類的定義只有在類的實例中才可用。

抽像類

在Java的聲明中,如果將類及其一個或者多個方法聲明為抽像類型,則允許這個類的定義中可以不包含這些方法的實現:


public abstract class TemplatedService {
        public final void service {
        // subclasses prepare in their own ways
        prepareService;
        // ... but they all run the same service
        runService;
    }
    public abstract void prepareService;
    private final void runService {
        // implementation of the service ...
    }
}
public class ConcreteService extends TemplatedService {
    void prepareService {
        // set up for the service
    }
}
  

不能對抽像類進行實例化。抽像類的子類必須提供其父類的所有抽像方法的定義,或者該子類本身也定義成抽像類。

正如前面的例子所示,抽像類可以用於實現常見的模板模式,它提供可重用的代碼塊,支持在執行時自定義特定點。可重用代碼塊是作為抽像類實現的。子類通過實現抽像方法對模板自定義。關於抽像類的更多信息,請查看Java相關教程:http://download.oracle.com/javase/tutorial/java/IandI/abstract.html。

接口

其他編程語言(例如C++、Python和Perl)支持多繼承,即一個對象可以有多個父類。多繼承有時非常複雜,程序執行和預期的不同(如從不同的父類中繼承兩個相同名字的成員變量)。為了簡單起見,Java不支持多繼承性。和C++、Python和Perl等不同,在Java中,一個類只能有一個父類。

和多繼承性不同,Java支持一個類通過接口(interface)實現對多種類型的繼承。接口支持只對類型進行定義但不實現。可以把接口想像成一個抽像類,其所有的方法也都是抽像方法。Java對一個類可以實現的接口的數量沒有限制。

下面這個例子是關於Java接口和實現該接口的類的示例:


public interface Growable {
    // declare the signature but not the implementation
    void grow(Fertilizer food, Water water);
}
public interface Eatable {
    // another signature with no implementation
    void munch;
}
/**
 * An implementing class must implement all interface methods
 */
public class Beans implements Growable, Eatable {
    @Override
    public void grow(Fertilizer food, Water water) {
        // ...
    }
    @Override
    public void munch {
        // ...
    }
}
  

再次說明,接口只是方法的聲明,而沒有方法的實現。這種分工在日常生活中也是很常見的。假如你和同事正在準備雞尾酒會,你可能會分派任務,讓同事去買薄荷。當你攪拌杯子裡的東西時,你的同事是開車去商店還是步行去後院的果汁店買薄荷,這和你沒有關係,重要的是你拿到了薄荷。

關於接口,再舉個例子。假設程序需要根據郵件地址排序,顯示一個聯繫人列表。我們肯定會期望Android的運行時庫包含一些通用的排序程序。但是,由於這些程序是通用的,它們無法知道某個特定類的實例期望用什麼方式來進行排序。為了使用庫中的排序程序,在類中需要定義自己的排序方法。在Java中,是通過接口Comparable定義排序方法。

Comparable類的對象實現方法compareTo。一個對像接受另一個相同類型的對象作為參數,如果作為參數的對象大於、等於或小於原目標對象,就分別返回不同的整數值。程序庫可以對任何Comparable類型的對象進行排序。要實現對聯繫人列表的排序,只需要把聯繫方式Contact定義成Comparable類型,實現compareTo方法,就可以做到對這些聯繫方式進行排序:


public class Contact implements Comparable<Contact> {
    // ... other fields
    private String email;
    public Contact(
        // other params...
        String emailAddress)
    {
        // ... init other fields from corresponding params
        email = emailAddress;
    }
    public int compareTo(Contact c) {
        return email.compareTo(c.email);
    }
}
public class ContactView {
    // ...
    private List<Contact> getContactsSortedByEmail(
        List<Contact> contacts)
    {
        // getting the sorted list of contacts
        // is completely trivial
        return Collections.sort(contacts);
    }
    // ...
}
  

在類內部,Collections.sort程序只知道contacts包含一組類型為Comparable的列表。它調用類的compareTo方法來決定如何對這些列表進行排序。

正如這個例子所說明的,接口使得開發人員可以復用通用的程序,這些程序能夠對任何實現了Comparable接口的列表進行排序。除了這個簡單的示例,Java接口庫中還提供了一組複雜的編程模式的實現。這裡強烈推薦一本優秀的書籍《Effective Java》,Joshua Bloch著(Prentice Hall出版社出版)。

異常

Java語言使用異常(exceptions)作為處理異常情況的簡便方式。通常情況下,這些情況是錯誤的。

舉個例子,要解析Web頁面的代碼,如果不能通過網絡讀取頁面,就無法繼續執行。當然,可以先檢查網絡讀取頁面是否成功,確認成功後再繼續其他操作,如下例所示:


public void getPage(URL url) {
    String smallPage = readPageFromNet(url);
    if (null != smallPage) {
        Document dom = parsePage(smallPage);
        if (null != dom) {
            NodeList actions = getActions(dom);
            if (null != action) {
                // process the action here...
            }
        }
    }
}
  

使用異常,程序可以更完善和健壯:


public void getPage(URL url)
    throws NetworkException, ParseException, ActionNotFoundException
{
    String smallPage = readPageFromNet(url);
    Document dom = parsePage(smallPage);
    NodeList actions = getActions(dom);
    // process the action here...
}
public String readPageFromNet(URL url) throws NetworkException {
// ...
public Document parsePage(String xml) throws ParseException {
// ...
public NodeList getActions(Document doc) throws ActionNotFoundException {
// ...
  

在這個版本的代碼中,在getPage中調用的每個方法出現錯誤時,會使用異常來中斷所有的後續操作,這些方法會拋出異常。例如,getActions方法可能會如下所示:


public NodeList getActions(Document dom)
    throws ActionNotFoundException
{
    Object actions = xPathFactory.newXPath.compile("//node/@action")
        .evaluate(dom, XPathConstants.NODESET);
    if (null == actions) {
        throw new ActionNotFoundException("Action not found");
    }
    return (NodeList) actions;
}
  

當執行throw語句時,原有的操作會被立即中止並轉去執行最近的catch代碼塊。下面這個代碼塊就是一個try-catch的例子:


for (int i = 0; i < MAX_RETRIES; i++) {
    try {
        getPage(theUrl);
        break;
    }
    catch (NetworkException e) {
        Log.d("ActionDecoder", "network error: " + e);
    }
}
  

這段代碼的功能是在網絡失敗時進行重試。注意,拋出NetworkException異常的點是在另一個方法readPageFromNet中。這裡提到的程序從「最近的」try-catch代碼塊恢復執行這種機制,是Java中的異常處理方式。

如果在方法內的throw語句沒有和try-catch代碼塊一起使用,那麼拋出異常類似於馬上執行return(返回)語句。不需要執行進一步的操作,返回為空。例如,在之前的例子中,網絡獲取頁面之後的代碼,都不需要關注其前提條件(讀取到頁面)是否有得到滿足。當出現異常時,方法會立即終止,程序返回到getActions方法。由於getActions方法也沒有包含try-catch代碼塊,它也會立即終止並返回到它的調用函數處。

在這個例子中,當拋出NetworkException異常時,程序會跳轉到catch代碼塊的第一條語句,即調用日誌,記錄網絡錯誤。異常會在第一個catch語句中被捕獲,其參數是拋出的異常的類型或者是其父類。處理會從catch代碼塊的第一條語句處恢復,並依次執行後面的操作。

在這個例子中,從網絡讀取頁面時如果出現網絡錯誤將會導致ReadPageFromNet方法和getPage方法都被終止。在catch代碼塊中記錄過失敗信息後,在for循環中會重新嘗試獲取頁面,最多嘗試執行MAX_RETRIES次。

對Java異常類樹結構有清晰的理解是很有幫助的,如圖2-1所示。

圖2-1:異常的基類

所有異常都是Throwable類的子類。在代碼中,基本不需要引用Throwable類。可以把Throwable類當作一個抽像類型的基類,其包含兩個子類:Error和Exception。Error類及其子類是保留類,只用於Dalvik運行時環境本身的錯誤。雖然可以寫代碼來捕獲Error(或Throwable),但實際上,無法捕捉到這些錯誤。這種情況的一個例子是OOME,即OutOfMemoryException錯誤。當Dalvik系統出現內存溢出時,再簡單的代碼都無法繼續運行。實現一些複雜的代碼來捕捉OOME,並釋放一些預分配的內存也許是可行的,也許不可行。嘗試捕捉Throwable或Error的代碼絕對是徒勞。

Java要求在方法的聲明中包含其將要拋出的異常。在前面這個例子中,getPage聲明其拋出三個異常,因為它調用了三個方法,每個方法捕捉一個錯誤。調用getPage的方法的定義中必須指明getPage拋出的三個異常及它調用的其他方法拋出的異常。

不難想像,在這種機制中,調用樹的最高層方法會顯得多麼臃腫。最高層方法可能需要指明數10種不同類型的異常,僅僅因為它調用的方法拋出了這些異常。這個問題可以通過創建一棵和應用樹一致的異常樹來緩解,一個方法只需要聲明其拋出的所有異常的超類。如果創建一個名為MyApplicationException的基類,然後創建其子類MyNetworkException和MyUIException,分別用於網絡和UI子系統中,則最高層代碼只需要處理MyApplicationException異常。

這實際上只是緩解了部分問題。例如,假設這段網絡連接的代碼沒有成功地建立起網絡連接。隨著異常在重試和其他條件選擇代碼中不斷上傳給上層代碼的過程中,有時會出現能夠說明真實問題的異常信息被丟失的情況。例如,一個具體的數據庫異常對於嘗試預安裝電話號碼的代碼是沒有任何意義的。把該數據庫異常加到方法簽名中是毫無用處的,還不如簡單地讓所有方法聲明拋出Exception異常類。

RuntimeException類是Exception類的特殊子類。RuntimeException的子類被稱為「未檢查的(unchecked)」異常,不需要聲明。例如,以下代碼可以編譯通過:


public void ThrowsRuntimeException {
    throw new RuntimeException;
}
  

在Java社區中,關於何時使用及何時不使用未檢查的異常有很多爭論。顯然,可以在應用中使用未檢查的異常,而從不聲明任何在你的方法簽名中的異常。一些Java編程學派甚至推薦這種方式。然而,使用未檢查的異常,使得能夠利用編譯器來檢查代碼錯誤,這很符合「靜態類型」(static typing)的思想,以經驗和嘗試為指南。

Java Collections框架

Java Collections框架是Java最強大和便捷的工具之一,它提供了可以用來表示對象的集合(collections)的對象:list、set和map。Java Collections框架庫的所有接口和實現都可以在java.util包中獲取。

在java.util包中,幾乎沒有什麼歷史遺留類,基本都是Java Collections框架的一部分,最好記住這些類,並避免定義具有相同名字的類。這些類是Vector、Hashtable、Enumeration和Dictionary。

Collection接口類型

Java Collections庫中的5種主要對像類型都是使用接口定義的,如下所示。

Collection

這是Collections庫中所有對象的根類型。Collection表示一組對象,這些對像不一定是有序的,也不一定是可訪問的,還可能包含重複對象。在Collection中,可以增加和刪除對象,獲取其大小並對它執行遍歷(iterate)操作(後面將對iteration作更多說明)。

List

List是一種有序的集合。List中的對象和整數從0到length-1一一映射。在List中,可能存在重複元素。List支持Collection的所有操作。此外,在List中,可以通過get方法獲取索引對應的對象,反之,也可以通過indexOf方法獲取某個對象的索引。還可以用add(index,e)方法改變某個特定索引所對應的元素。List的iterator(迭代器)按序依次返回各個元素。

Set

Set是一個無序集合,它不包含重複元素。Set也支持Collection的所有操作。但是,如果在Set中添加的是一個已經存在的元素,則Set的大小並不會改變。

Map

Map和List類似,其區別在於List把一組整數映射到一組對像中,而Map把一組key對像映射到一組value對象。與其他集合類一樣,在Map中,可以增加和刪除key-value對(鍵值對),獲取其大小並對它執行遍歷操作。Map的具體例子包括:把單詞和單詞定義的映射,日期和事件的映射,或URL和緩存內容的映射等。

Iterator

Iterator(迭代器)返回集合中的元素,其通過next方法,每次返回一個元素。Iterator是對集合中所有元素進行操作的一種較好的方式。一般不建議使用下面這種方式遍歷:


      for (int i = 0; i < list.size; i++) {
          String s = list.get(i)
          // ...
      }
  

建議採用以下這種方式:


      for (Iterator<String> i = list.iterator; i.hasNext;) {
          String s = i.next;
          // ...
      }
  

實際上,後者還可以簡化,如下:


      for (String s: list) {
          // ... 
      }
  

Collection實現方式

這些接口類型有多種實現方式,每個都有其適用的場景。最常見的實現方式包括以下幾種。

ArrayList

ArrayList(數組列表)是一個支持數組特徵的List。它在執行索引查找操作時很快,但是涉及改變其大小的操作的速度很慢。

LinkedList

LinkedList(鏈表)可以快速改變大小,但是查找速度很慢。

HashSet

HashSet是一個以hash方式實現的set。在HashSet中,增、刪元素,判斷是否包含某個元素及獲取HashSet的大小這些操作都可以在常數級時間內完成。HashSet可以為空。

HashMap

HashMap是使用hash表作為索引,其實現了Map接口。在HashMap中,增、刪元素,判斷是否包含某個元素及獲取HashMap的大小這些操作都可以在常數級時間內完成。它最多只可以包含一個空的key值,但是可以有任意個value值為空的元素。

TreeMap

TreeMap是一個有序的Map。如果實現了Comparable接口,則TreeMap中的對象是按自然序排序;如果沒有實現Comparable接口,則是根據傳遞給TreeMap構造函數的Comparator類來排序。

經常使用Java的用戶只要可能,往往傾向於使用接口類型的聲明,而不是實現類型的聲明。這是一個普遍的規則,但在Java Collections框架下最易於理解其中的原因。

假設有一個會返回一個新的字符串列表的方法,其主要內容是傳遞給它的第二個參數的字符串列表,但是在返回的新的字符串列表中,每個字符串的前綴是第一個參數。該方法如下所示:


public ArrayList<String> prefixList(
    String prefix,
    ArrayList<String> strs)
{
    ArrayList<String> ret
        = new ArrayList<String>(strs.size);
    for (String s: strs) { ret.add(prefix + s); }
    return ret;
}
  

然而,這種實現方式存在一個問題:它無法在所有類型的List上都正常工作!它只能在ArrayList上正常工作。如果調用這個方法的代碼需要從ArrayList改成LinkedList,就不能再使用這個方法,因此沒有理由要使用這樣的實現方式。

更好的實現方式如下所示:


public List<String> prefix(
    String prefix,
    List<String> strs)
{
    List<String> ret = new ArrayList<String>(strs.size);
    for (String s: strs) { ret.add(prefix + s); }
    return ret;
}
  

這個版本的靈活性更強,因為它沒有把方法綁定到特定的實現。該方法只依賴於參數實現了某個接口,並不關心是如何實現的。使用接口類型作為參數,它確切地知道自己要做什麼。

事實上,還可以進一步對該版本進行改進,把參數和返回類型設置成Collection類型。

Java泛型

在Java中,泛型是相當大且複雜的一個話題。有些書整本都在探討這個主題。本節介紹Java泛型(Java generics)中最常用的設置,即Collections Library(集合庫),但是不會詳細探討它們。

在引入Java泛型之前,是無法對容器類的內容作靜態類型化的(statically type)。我們經常看到這樣的代碼:


public List makeList {
    // ...
}
public void useList(List l) {
    Thing t = (Thing) l.get(0);
    // ...
}
// ...
useList(makeList);
  

以上程序的問題非常明顯,useList方法不能保證makeList方法創建了一個Thing類型的對象列表。Java編譯器不能驗證useList中的轉換可以工作,代碼可能會在運行時崩潰。

Java泛型解決了這個問題,但其代價是使得實現變得較為複雜。以下是對上面代碼的改寫,在其中加入了泛型:


public List<Thing> makeList {
    // ...
}
public void useList(List<Thing> l) {
    Thing t = l.get(0);
    // ...
}
// ...
useList(makeList);
  

容器中的對象類型是在尖括號(<>)中指定,它是容器類型的一部分。注意,在useList中不再需要類型轉換,因為編譯器知道第一個參數是Thing類型的list。

泛型描述可能會變得非常煩瑣冗長,下面這樣的聲明是很常見的:


Map<UUID, Map<String, Thing>> cache
    = new HashMap<UUID, Map<String, Thing>>;
  

垃圾收集

Java是一種支持垃圾收集的語言,這意味著代碼不需要對內存進行管理。相反,我們的代碼可以創建新的對象,可以分配內存,當不再需要這些對像時,只是停止使用這些對像而已。Dalvik運行時會自動刪除這些對象,並適當地執行內存壓縮。

在不遠的過去,開發人員不得不為垃圾收集器擔心,因為垃圾收集器可能會暫停下所有的應用處理以恢復內存,導致應用長時間、不可預測地、週期性地沒有響應。很多開發人員,早期那些使用Java以及後來使用J2ME的開發人員,都還記得那些技巧、應對方式及不成文的規則來避免由早期垃圾收集器造成的長時間停頓和內存碎片。垃圾收集機制在這些年有了很大改進。Dalvik明顯不存在這些問題。創建新的對象基本上沒有開銷,只有那些對UI響應要求非常高的應用程序(例如遊戲)需要考慮垃圾收集造成的程序暫停。