Skip to content
This repository has been archived by the owner on Aug 25, 2019. It is now read-only.

Latest commit

 

History

History
1407 lines (1103 loc) · 62.9 KB

第4章.md

File metadata and controls

1407 lines (1103 loc) · 62.9 KB

第4章 初始化和清除

“隨著計算機的進步,‘不安全’的程序設計已成為造成編程代價高昂的罪魁禍首之一。”

“初始化”和“清除”是這些安全問題的其中兩個。許多C程序的錯誤都是由於程序員忘記初始化一個變量造成的。對於現成的庫,若用戶不知道如何初始化庫的一個組件,就往往會出現這一類的錯誤。清除是另一個特殊的問題,因為用完一個元素後,由於不再關心,所以很容易把它忘記。這樣一來,那個元素佔用的資源會一直保留下去,極易產生資源(主要是內存)用盡的後果。

C++為我們引入了“構造器”的概念。這是一種特殊的方法,在一個對象創建之後自動調用。Java也沿用了這個概念,但新增了自己的“垃圾收集器”,能在資源不再需要的時候自動釋放它們。本章將討論初始化和清除的問題,以及Java如何提供它們的支持。

4.1 用構造器自動初始化

對於方法的創建,可將其想象成為自己寫的每個類都調用一次initialize()。這個名字提醒我們在使用對象之前,應首先進行這樣的調用。但不幸的是,這也意味著用戶必須記住調用方法。在Java中,由於提供了名為“構造器”的一種特殊方法,所以類的設計者可擔保每個對象都會得到正確的初始化。若某個類有一個構造器,那麼在創建對象時,Java會自動調用那個構造器——甚至在用戶毫不知覺的情況下。所以說這是可以擔保的!

接著的一個問題是如何命名這個方法。存在兩方面的問題。第一個是我們使用的任何名字都可能與打算為某個類成員使用的名字衝突。第二是由於編譯器的責任是調用構造器,所以它必須知道要調用是哪個方法。C++採取的方案看來是最簡單的,且更有邏輯性,所以也在Java裡得到了應用:構造器的名字與類名相同。這樣一來,可保證象這樣的一個方法會在初始化期間自動調用。

下面是帶有構造器的一個簡單的類(若執行這個程序有問題,請參考第3章的“賦值”小節)。

//: SimpleConstructor.java
// Demonstration of a simple constructor
package c04;

class Rock {
  Rock() { // This is the constructor
    System.out.println("Creating Rock");
  }
}

public class SimpleConstructor {
  public static void main(String[] args) {
    for(int i = 0; i < 10; i++)
      new Rock();
  }
} ///:~

現在,一旦創建一個對象:

new Rock();

就會分配相應的存儲空間,並調用構造器。這樣可保證在我們經手之前,對象得到正確的初始化。 請注意所有方法首字母小寫的編碼規則並不適用於構造器。這是由於構造器的名字必須與類名完全相同!

和其他任何方法一樣,構造器也能使用參數,以便我們指定對象的具體創建方式。可非常方便地改動上述例子,以便構造器使用自己的參數。如下所示:

class Rock {
  Rock(int i) {
    System.out.println(
      "Creating Rock number " + i);
  }
}

public class SimpleConstructor {
  public static void main(String[] args) {
    for(int i = 0; i < 10; i++)
      new Rock(i);
  }
}

利用構造器的參數,我們可為一個對象的初始化設定相應的參數。舉個例子來說,假設類Tree有一個構造器,它用一個整數參數標記樹的高度,那麼就可以象下面這樣創建一個Tree對象:

tree t = new Tree(12); // 12英尺高的樹

Tree(int)是我們唯一的構造器,那麼編譯器不會允許我們以其他任何方式創建一個Tree對象。

構造器有助於消除大量涉及類的問題,並使代碼更易閱讀。例如在前述的代碼段中,我們並未看到對initialize()方法的明確調用——那些方法在概念上獨立於定義內容。在Java中,定義和初始化屬於統一的概念——兩者缺一不可。

構造器屬於一種較特殊的方法類型,因為它沒有返回值。這與void返回值存在著明顯的區別。對於void返回值,儘管方法本身不會自動返回什麼,但仍然可以讓它返回另一些東西。構造器則不同,它不僅什麼也不會自動返回,而且根本不能有任何選擇。若存在一個返回值,而且假設我們可以自行選擇返回內容,那麼編譯器多少要知道如何對那個返回值作什麼樣的處理。

4.2 方法重載

在任何程序設計語言中,一項重要的特性就是名字的運用。我們創建一個對象時,會分配到一個保存區域的名字。方法名代表的是一種具體的行動。通過用名字描述自己的系統,可使自己的程序更易人們理解和修改。它非常象寫散文——目的是與讀者溝通。

我們用名字引用或描述所有對象與方法。若名字選得好,可使自己及其他人更易理解自己的代碼。

將人類語言中存在細緻差別的概念“映射”到一種程序設計語言中時,會出現一些特殊的問題。在日常生活中,我們用相同的詞表達多種不同的含義——即詞的“重載”。我們說“洗襯衫”、“洗車”以及“洗狗”。但若強制象下面這樣說,就顯得很愚蠢:“襯衫洗 襯衫”、“車洗 車”以及“狗洗 狗”。這是由於聽眾根本不需要對執行的行動作任何明確的區分。人類的大多數語言都具有很強的“冗餘”性,所以即使漏掉了幾個詞,仍然可以推斷出含義。我們不需要獨一無二的標識符——可從具體的語境中推論出含義。

大多數程序設計語言(特別是C)要求我們為每個函數都設定一個獨一無二的標識符。所以絕對不能用一個名為print()的函數來顯示整數,再用另一個print()顯示浮點數——每個函數都要求具備唯一的名字。

在Java裡,另一項因素強迫方法名出現重載情況:構造器。由於構造器的名字由類名決定,所以只能有一個構造器名稱。但假若我們想用多種方式創建一個對象呢?例如,假設我們想創建一個類,令其用標準方式進行初始化,另外從文件裡讀取信息來初始化。此時,我們需要兩個構造器,一個沒有參數(默認構造器),另一個將字符串作為參數——用於初始化對象的那個文件的名字。由於都是構造器,所以它們必須有相同的名字,亦即類名。所以為了讓相同的方法名伴隨不同的參數類型使用,“方法重載”是非常關鍵的一項措施。同時,儘管方法重載是構造器必需的,但它亦可應用於其他任何方法,且用法非常方便。

在下面這個例子裡,我們向大家同時展示了重載構造器和重載的原始方法:

//: Overloading.java
// Demonstration of both constructor
// and ordinary method overloading.
import java.util.*;

class Tree {
  int height;
  Tree() {
    prt("Planting a seedling");
    height = 0;
  }
  Tree(int i) {
    prt("Creating new Tree that is "
        + i + " feet tall");
    height = i;
  }
  void info() {
    prt("Tree is " + height
        + " feet tall");
  }
  void info(String s) {
    prt(s + ": Tree is "
        + height + " feet tall");
  }
  static void prt(String s) {
    System.out.println(s);
  }
}

public class Overloading {
  public static void main(String[] args) {
    for(int i = 0; i < 5; i++) {
      Tree t = new Tree(i);
      t.info();
      t.info("overloaded method");
    }
    // Overloaded constructor:
    new Tree();
  }
} ///:~

Tree既可創建成一顆種子,不含任何參數;亦可創建成生長在苗圃中的植物。為支持這種創建,共使用了兩個構造器,一個沒有參數(我們把沒有參數的構造器稱作“默認構造器”,註釋①),另一個採用現成的高度。

①:在Sun公司出版的一些Java資料中,用簡陋但很說明問題的詞語稱呼這類構造器——“無參數構造器”(no-arg constructors)。但“默認構造器”這個稱呼已使用了許多年,所以我選擇了它。

我們也有可能希望通過多種途徑調用info()方法。例如,假設我們有一條額外的消息想顯示出來,就使用String參數;而假設沒有其他話可說,就不使用。由於為顯然相同的概念賦予了兩個獨立的名字,所以看起來可能有些古怪。幸運的是,方法重載允許我們為兩者使用相同的名字。

4.2.1 區分重載方法

若方法有同樣的名字,Java怎樣知道我們指的哪一個方法呢?這裡有一個簡單的規則:每個重載的方法都必須採取獨一無二的參數類型列表。

若稍微思考幾秒鐘,就會想到這樣一個問題:除根據參數的類型,程序員如何區分兩個同名方法的差異呢?

即使參數的順序也足夠我們區分兩個方法(儘管我們通常不願意採用這種方法,因為它會產生難以維護的代碼):

//: OverloadingOrder.java
// Overloading based on the order of
// the arguments.

public class OverloadingOrder {
  static void print(String s, int i) {
    System.out.println(
      "String: " + s +
      ", int: " + i);
  }
  static void print(int i, String s) {
    System.out.println(
      "int: " + i +
      ", String: " + s);
  }
  public static void main(String[] args) {
    print("String first", 11);
    print(99, "Int first");
  }
} ///:~

兩個print()方法有完全一致的參數,但順序不同,可據此區分它們。

4.2.2 基本類型的重載

主(數據)類型能從一個“較小”的類型自動轉變成一個“較大”的類型。涉及重載問題時,這會稍微造成一些混亂。下面這個例子揭示了將基本類型傳遞給重載的方法時發生的情況:

//: PrimitiveOverloading.java
// Promotion of primitives and overloading

public class PrimitiveOverloading {
  // boolean can't be automatically converted
  static void prt(String s) {
    System.out.println(s);
  }

  void f1(char x) { prt("f1(char)"); }
  void f1(byte x) { prt("f1(byte)"); }
  void f1(short x) { prt("f1(short)"); }
  void f1(int x) { prt("f1(int)"); }
  void f1(long x) { prt("f1(long)"); }
  void f1(float x) { prt("f1(float)"); }
  void f1(double x) { prt("f1(double)"); }

  void f2(byte x) { prt("f2(byte)"); }
  void f2(short x) { prt("f2(short)"); }
  void f2(int x) { prt("f2(int)"); }
  void f2(long x) { prt("f2(long)"); }
  void f2(float x) { prt("f2(float)"); }
  void f2(double x) { prt("f2(double)"); }

  void f3(short x) { prt("f3(short)"); }
  void f3(int x) { prt("f3(int)"); }
  void f3(long x) { prt("f3(long)"); }
  void f3(float x) { prt("f3(float)"); }
  void f3(double x) { prt("f3(double)"); }

  void f4(int x) { prt("f4(int)"); }
  void f4(long x) { prt("f4(long)"); }
  void f4(float x) { prt("f4(float)"); }
  void f4(double x) { prt("f4(double)"); }

  void f5(long x) { prt("f5(long)"); }
  void f5(float x) { prt("f5(float)"); }
  void f5(double x) { prt("f5(double)"); }

  void f6(float x) { prt("f6(float)"); }
  void f6(double x) { prt("f6(double)"); }

  void f7(double x) { prt("f7(double)"); }

  void testConstVal() {
    prt("Testing with 5");
    f1(5);f2(5);f3(5);f4(5);f5(5);f6(5);f7(5);
  }
  void testChar() {
    char x = 'x';
    prt("char argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  void testByte() {
    byte x = 0;
    prt("byte argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  void testShort() {
    short x = 0;
    prt("short argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  void testInt() {
    int x = 0;
    prt("int argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  void testLong() {
    long x = 0;
    prt("long argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  void testFloat() {
    float x = 0;
    prt("float argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  void testDouble() {
    double x = 0;
    prt("double argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  public static void main(String[] args) {
    PrimitiveOverloading p =
      new PrimitiveOverloading();
    p.testConstVal();
    p.testChar();
    p.testByte();
    p.testShort();
    p.testInt();
    p.testLong();
    p.testFloat();
    p.testDouble();
  }
} ///:~

若觀察這個程序的輸出,就會發現常數值5被當作一個int值處理。所以假若可以使用一個重載的方法,就能獲取它使用的int值。在其他所有情況下,若我們的數據類型“小於”方法中使用的參數,就會對那種數據類型進行“轉型”處理。char獲得的效果稍有些不同,這是由於假期它沒有發現一個準確的char匹配,就會轉型為int

若我們的參數“大於”重載方法期望的參數,這時又會出現什麼情況呢?對前述程序的一個修改揭示出了答案:

//: Demotion.java
// Demotion of primitives and overloading

public class Demotion {
  static void prt(String s) {
    System.out.println(s);
  }

  void f1(char x) { prt("f1(char)"); }
  void f1(byte x) { prt("f1(byte)"); }
  void f1(short x) { prt("f1(short)"); }
  void f1(int x) { prt("f1(int)"); }
  void f1(long x) { prt("f1(long)"); }
  void f1(float x) { prt("f1(float)"); }
  void f1(double x) { prt("f1(double)"); }

  void f2(char x) { prt("f2(char)"); }
  void f2(byte x) { prt("f2(byte)"); }
  void f2(short x) { prt("f2(short)"); }
  void f2(int x) { prt("f2(int)"); }
  void f2(long x) { prt("f2(long)"); }
  void f2(float x) { prt("f2(float)"); }

  void f3(char x) { prt("f3(char)"); }
  void f3(byte x) { prt("f3(byte)"); }
  void f3(short x) { prt("f3(short)"); }
  void f3(int x) { prt("f3(int)"); }
  void f3(long x) { prt("f3(long)"); }

  void f4(char x) { prt("f4(char)"); }
  void f4(byte x) { prt("f4(byte)"); }
  void f4(short x) { prt("f4(short)"); }
  void f4(int x) { prt("f4(int)"); }

  void f5(char x) { prt("f5(char)"); }
  void f5(byte x) { prt("f5(byte)"); }
  void f5(short x) { prt("f5(short)"); }

  void f6(char x) { prt("f6(char)"); }
  void f6(byte x) { prt("f6(byte)"); }

  void f7(char x) { prt("f7(char)"); }

  void testDouble() {
    double x = 0;
    prt("double argument:");
    f1(x);f2((float)x);f3((long)x);f4((int)x);
    f5((short)x);f6((byte)x);f7((char)x);
  }
  public static void main(String[] args) {
    Demotion p = new Demotion();
    p.testDouble();
  }
} ///:~

在這裡,方法採用了容量更小、範圍更窄的基本類型值。若我們的參數範圍比它寬,就必須用括號中的類型名將其轉為適當的類型。如果不這樣做,編譯器會報告出錯。

大家可注意到這是一種“縮小轉換”。也就是說,在轉換或轉型過程中可能丟失一些信息。這正是編譯器強迫我們明確定義的原因——我們需明確表達想要轉型的願望。

4.2.3 返回值重載

我們很易對下面這些問題感到迷惑:為什麼只有類名和方法參數列出?為什麼不根據返回值對方法加以區分?比如對下面這兩個方法來說,雖然它們有同樣的名字和參數,但其實是很容易區分的:

void f() {}
int f() {}

若編譯器可根據上下文(語境)明確判斷出含義,比如在int x=f()中,那麼這樣做完全沒有問題。然而,我們也可能調用一個方法,同時忽略返回值;我們通常把這稱為“為它的副作用去調用一個方法”,因為我們關心的不是返回值,而是方法調用的其他效果。所以假如我們象下面這樣調用方法:

f();

Java怎樣判斷f()的具體調用方式呢?而且別人如何識別並理解代碼呢?由於存在這一類的問題,所以不能根據返回值類型來區分重載的方法。

4.2.4 默認構造器

正如早先指出的那樣,默認構造器是沒有參數的。它們的作用是創建一個“空對象”。若創建一個沒有構造器的類,則編譯程序會幫我們自動創建一個默認構造器。例如:

//: DefaultConstructor.java

class Bird {
  int i;
}

public class DefaultConstructor {
  public static void main(String[] args) {
    Bird nc = new Bird(); // default!
  }
} ///:~

對於下面這一行:

new Bird();

它的作用是新建一個對象,並調用默認構造器——即使尚未明確定義一個象這樣的構造器。若沒有它,就沒有方法可以調用,無法構建我們的對象。然而,如果已經定義了一個構造器(無論是否有參數),編譯程序都不會幫我們自動生成一個:

class Bush {
Bush(int i) {}
Bush(double d) {}
}

現在,假若使用下述代碼:

new Bush();

編譯程序就會報告自己找不到一個相符的構造器。就好象我們沒有設置任何構造器,編譯程序會說:“你看來似乎需要一個構造器,所以讓我們給你製造一個吧。”但假如我們寫了一個構造器,編譯程序就會說:“啊,你已寫了一個構造器,所以我知道你想幹什麼;如果你不放置一個默認的,是由於你打算省略它。”

4.2.5 this關鍵字

如果有兩個同類型的對象,分別叫作ab,那麼您也許不知道如何為這兩個對象同時調用一個f()方法:

class Banana { void f(int i) { /* ... */ } }
Banana a = new Banana(), b = new Banana();
a.f(1);
b.f(2);

若只有一個名叫f()的方法,它怎樣才能知道自己是為a還是為b調用的呢?

為了能用簡便的、面向對象的語法來書寫代碼——亦即“將消息發給對象”,編譯器為我們完成了一些幕後工作。其中的祕密就是第一個參數傳遞給方法f(),而且那個參數是準備操作的那個對象的引用。所以前述的兩個方法調用就變成了下面這樣的形式:

Banana.f(a,1);
Banana.f(b,2);

這是內部的表達形式,我們並不能這樣書寫表達式,並試圖讓編譯器接受它。但是,通過它可理解幕後到底發生了什麼事情。

假定我們在一個方法的內部,並希望獲得當前對象的引用。由於那個引用是由編譯器“祕密”傳遞的,所以沒有標識符可用。然而,針對這一目的有個專用的關鍵字:thisthis關鍵字(注意只能在方法內部使用)可為已調用了其方法的那個對象生成相應的引用。可象對待其他任何對象引用一樣對待這個引用。但要注意,假若準備從自己某個類的另一個方法內部調用一個類方法,就不必使用this。只需簡單地調用那個方法即可。當前的this引用會自動應用於其他方法。所以我們能使用下面這樣的代碼:

class Apricot {
void pick() { /* ... */ }
void pit() { pick(); /* ... */ }
}

pit()內部,我們可以說this.pick(),但事實上無此必要。編譯器能幫我們自動完成。this關鍵字只能用於那些特殊的類——需明確使用當前對象的引用。例如,假若您希望將引用返回給當前對象,那麼它經常在return語句中使用。

//: Leaf.java
// Simple use of the "this" keyword

public class Leaf {
  private int i = 0;
  Leaf increment() {
    i++;
    return this;
  }
  void print() {
    System.out.println("i = " + i);
  }
  public static void main(String[] args) {
    Leaf x = new Leaf();
    x.increment().increment().increment().print();
  }
} ///:~

由於increment()通過this關鍵字返回當前對象的引用,所以可以方便地對同一個對象執行多項操作。

(1) 在構造器裡調用構造器

若為一個類寫了多個構造器,那麼經常都需要在一個構造器裡調用另一個構造器,以避免寫重複的代碼。可用this關鍵字做到這一點。

通常,當我們說this的時候,都是指“這個對象”或者“當前對象”。而且它本身會產生當前對象的一個引用。在一個構造器中,若為其賦予一個參數列表,那麼this關鍵字會具有不同的含義:它會對與那個參數列表相符的構造器進行明確的調用。這樣一來,我們就可通過一條直接的途徑來調用其他構造器。如下所示:

//: Flower.java
// Calling constructors with "this"

public class Flower {
  private int petalCount = 0;
  private String s = new String("null");
  Flower(int petals) {
    petalCount = petals;
    System.out.println(
      "Constructor w/ int arg only, petalCount= "
      + petalCount);
  }
  Flower(String ss) {
    System.out.println(
      "Constructor w/ String arg only, s=" + ss);
    s = ss;
  }
  Flower(String s, int petals) {
    this(petals);
//!    this(s); // Can't call two!
    this.s = s; // Another use of "this"
    System.out.println("String & int args");
  }
  Flower() {
    this("hi", 47);
    System.out.println(
      "default constructor (no args)");
  }
  void print() {
//!    this(11); // Not inside non-constructor!
    System.out.println(
      "petalCount = " + petalCount + " s = "+ s);
  }
  public static void main(String[] args) {
    Flower x = new Flower();
    x.print();
  }
} ///:~

其中,構造器Flower(String s,int petals)向我們揭示出這樣一個問題:儘管可用this調用一個構造器,但不可調用兩個。除此以外,構造器調用必須是我們做的第一件事情,否則會收到編譯程序的報錯信息。

這個例子也向大家展示了this的另一項用途。由於參數s的名字以及成員數據s的名字是相同的,所以會出現混淆。為解決這個問題,可用this.s來引用成員數據。經常都會在Java代碼裡看到這種形式的應用,本書的大量地方也採用了這種做法。

print()中,我們發現編譯器不讓我們從除了一個構造器之外的其他任何方法內部調用一個構造器。

(2) static的含義

理解了this關鍵字後,我們可更完整地理解static(靜態)方法的含義。它意味著一個特定的方法沒有this。我們不可從一個static方法內部發出對非static方法的調用(註釋②),儘管反過來說是可以的。而且在沒有任何對象的前提下,我們可針對類本身發出對一個static方法的調用。事實上,那正是static方法最基本的意義。它就好象我們創建一個全局函數的等價物(在C語言中)。除了全局函數不允許在Java中使用以外,若將一個static方法置入一個類的內部,它就可以訪問其他static方法以及static字段。

②:有可能發出這類調用的一種情況是我們將一個對象引用傳到static方法內部。隨後,通過引用(此時實際是this),我們可調用非static方法,並訪問非static字段。但一般地,如果真的想要這樣做,只要製作一個普通的、非static方法即可。

有些人抱怨static方法並不是“面向對象”的,因為它們具有全局函數的某些特點;利用static方法,我們不必向對象發送一條消息,因為不存在this。這可能是一個清楚的參數,若您發現自己使用了大量靜態方法,就應重新思考自己的策略。然而,static的概念是非常實用的,許多時候都需要用到它。所以至於它們是否真的“面向對象”,應該留給理論家去討論。事實上,即使Smalltalk在自己的“類方法”裡也有類似於static的東西。

4.3 清除:收尾和垃圾收集

程序員都知道“初始化”的重要性,但通常忘記清除的重要性。畢竟,誰需要來清除一個int呢?但是對於庫來說,用完後簡單地“釋放”一個對象並非總是安全的。當然,Java可用垃圾收集器回收由不再使用的對象佔據的內存。現在考慮一種非常特殊且不多見的情況。假定我們的對象分配了一個“特殊”內存區域,沒有使用new。垃圾收集器只知道釋放那些由new分配的內存,所以不知道如何釋放對象的“特殊”內存。為解決這個問題,Java提供了一個名為finalize()的方法,可為我們的類定義它。在理想情況下,它的工作原理應該是這樣的:一旦垃圾收集器準備好釋放對象佔用的存儲空間,它首先調用finalize(),而且只有在下一次垃圾收集過程中,才會真正回收對象的內存。所以如果使用finalize(),就可以在垃圾收集期間進行一些重要的清除或清掃工作。

但也是一個潛在的編程陷阱,因為有些程序員(特別是在C++開發背景的)剛開始可能會錯誤認為它就是在C++中為“析構器”(Destructor)使用的finalize()——析構(清除)一個對象的時候,肯定會調用這個函數。但在這裡有必要區分一下C++和Java的區別,因為C++的對象肯定會被清除(排開編程錯誤的因素),而Java對象並非肯定能作為垃圾被“收集”去。或者換句話說:

垃圾收集並不等於“析構”!

若能時刻牢記這一點,踩到陷阱的可能性就會大大減少。它意味著在我們不再需要一個對象之前,有些行動是必須採取的,而且必須由自己來採取這些行動。Java並未提供“析構器”或者類似的概念,所以必須創建一個原始的方法,用它來進行這種清除。例如,假設在對象創建過程中,它會將自己描繪到屏幕上。如果不從屏幕明確刪除它的圖像,那麼它可能永遠都不會被清除。若在finalize()裡置入某種刪除機制,那麼假設對象被當作垃圾收掉了,圖像首先會將自身從屏幕上移去。但若未被收掉,圖像就會保留下來。所以要記住的第二個重點是:

我們的對象可能不會當作垃圾被收掉!

有時可能發現一個對象的存儲空間永遠都不會釋放,因為自己的程序永遠都接近於用光空間的臨界點。若程序執行結束,而且垃圾收集器一直都沒有釋放我們創建的任何對象的存儲空間,則隨著程序的退出,那些資源會返回給操作系統。這是一件好事情,因為垃圾收集本身也要消耗一些開銷。如永遠都不用它,那麼永遠也不用支出這部分開銷。

4.3.1 finalize()用途何在

此時,大家可能已相信了自己應該將finalize()作為一種常規用途的清除方法使用。它有什麼好處呢?

要記住的第三個重點是:

垃圾收集只跟內存有關!

也就是說,垃圾收集器存在的唯一原因是為了回收程序不再使用的內存。所以對於與垃圾收集有關的任何活動來說,其中最值得注意的是finalize()方法,它們也必須同內存以及它的回收有關。

但這是否意味著假如對象包含了其他對象,finalize()就應該明確釋放那些對象呢?答案是否定的——垃圾收集器會負責釋放所有對象佔據的內存,無論這些對象是如何創建的。它將對finalize()的需求限制到特殊的情況。在這種情況下,我們的對象可採用與創建對象時不同的方法分配一些存儲空間。但大家或許會注意到,Java中的所有東西都是對象,所以這到底是怎麼一回事呢?

之所以要使用finalize(),看起來似乎是由於有時需要採取與Java的普通方法不同的一種方法,通過分配內存來做一些具有C風格的事情。這主要可以通過“固有方法”來進行,它是從Java裡調用非Java方法的一種方式(固有方法的問題在附錄A討論)。C和C++是目前唯一獲得固有方法支持的語言。但由於它們能調用通過其他語言編寫的子程序,所以能夠有效地調用任何東西。在非Java代碼內部,也許能調用C的malloc()系列函數,用它分配存儲空間。而且除非調用了free(),否則存儲空間不會得到釋放,從而造成內存“漏洞”的出現。當然,free()是一個C和C++函數,所以我們需要在finalize()內部的一個固有方法中調用它。

讀完上述文字後,大家或許已弄清楚了自己不必過多地使用finalize()。這個思想是正確的;它並不是進行普通清除工作的理想場所。那麼,普通的清除工作應在何處進行呢?

4.3.2 必須執行清除

為清除一個對象,那個對象的用戶必須在希望進行清除的地點調用一個清除方法。這聽起來似乎很容易做到,但卻與C++“析構器”的概念稍有抵觸。在C++中,所有對象都會析構(清除)。或者換句話說,所有對象都“應該”析構。若將C++對象創建成一個本地對象,比如在棧中創建(在Java中是不可能的),那麼清除或析構工作就會在“結束花括號”所代表的、創建這個對象的作用域的末尾進行。若對象是用new創建的(類似於Java),那麼當程序員調用C++的delete命令時(Java沒有這個命令),就會調用相應的析構器。若程序員忘記了,那麼永遠不會調用析構器,我們最終得到的將是一個內存“漏洞”,另外還包括對象的其他部分永遠不會得到清除。

相反,Java不允許我們創建本地(局部)對象——無論如何都要使用new。但在Java中,沒有delete命令來釋放對象,因為垃圾收集器會幫助我們自動釋放存儲空間。所以如果站在比較簡化的立場,我們可以說正是由於存在垃圾收集機制,所以Java沒有析構器。然而,隨著以後學習的深入,就會知道垃圾收集器的存在並不能完全消除對析構器的需要,或者說不能消除對析構器代表的那種機制的需要(而且絕對不能直接調用finalize(),所以應儘量避免用它)。若希望執行除釋放存儲空間之外的其他某種形式的清除工作,仍然必須調用Java中的一個方法。它等價於C++的析構器,只是沒後者方便。

finalize()最有用處的地方之一是觀察垃圾收集的過程。下面這個例子向大家展示了垃圾收集所經歷的過程,並對前面的陳述進行了總結。

//: Garbage.java
// Demonstration of the garbage
// collector and finalization

class Chair {
  static boolean gcrun = false;
  static boolean f = false;
  static int created = 0;
  static int finalized = 0;
  int i;
  Chair() {
    i = ++created;
    if(created == 47)
      System.out.println("Created 47");
  }
  protected void finalize() {
    if(!gcrun) {
      gcrun = true;
      System.out.println(
        "Beginning to finalize after " +
        created + " Chairs have been created");
    }
    if(i == 47) {
      System.out.println(
        "Finalizing Chair #47, " +
        "Setting flag to stop Chair creation");
      f = true;
    }
    finalized++;
    if(finalized >= created)
      System.out.println(
        "All " + finalized + " finalized");
  }
}

public class Garbage {
  public static void main(String[] args) {
    if(args.length == 0) {
      System.err.println("Usage: \n" +
        "java Garbage before\n  or:\n" +
        "java Garbage after");
      return;
    }
    while(!Chair.f) {
      new Chair();
      new String("To take up space");
    }
    System.out.println(
      "After all Chairs have been created:\n" +
      "total created = " + Chair.created +
      ", total finalized = " + Chair.finalized);
    if(args[0].equals("before")) {
      System.out.println("gc():");
      System.gc();
      System.out.println("runFinalization():");
      System.runFinalization();
    }
    System.out.println("bye!");
    if(args[0].equals("after"))
      System.runFinalizersOnExit(true);
  }
} ///:~

上面這個程序創建了許多Chair對象,而且在垃圾收集器開始運行後的某些時候,程序會停止創建Chair。由於垃圾收集器可能在任何時間運行,所以我們不能準確知道它在何時啟動。因此,程序用一個名為gcrun的標記來指出垃圾收集器是否已經開始運行。利用第二個標記fChair可告訴main()它應停止對象的生成。這兩個標記都是在finalize()內部設置的,它調用於垃圾收集期間。

另兩個static變量——created以及finalized——分別用於跟蹤已創建的對象數量以及垃圾收集器已進行完收尾工作的對象數量。最後,每個Chair都有它自己的(非staticint i,所以能跟蹤瞭解它具體的編號是多少。編號為47的Chair進行完收尾工作後,標記會設為true,最終結束Chair對象的創建過程。

所有這些都在main()的內部進行——在下面這個循環裡:

while(!Chair.f) {
new Chair();
new String("To take up space");
}

大家可能會疑惑這個循環什麼時候會停下來,因為內部沒有任何改變Chair.f值的語句。然而,finalize()進程會改變這個值,直至最終對編號47的對象進行收尾處理。

每次循環過程中創建的String對象只是屬於額外的垃圾,用於吸引垃圾收集器——一旦垃圾收集器對可用內存的容量感到“緊張不安”,就會開始關注它。

運行這個程序的時候,提供了一個命令行參數before或者after。其中,before參數會調用System.gc()方法(強制執行垃圾收集器),同時還會調用System.runFinalization()方法,以便進行收尾工作。這些方法都可在Java 1.0中使用,但通過使用after參數而調用的runFinalizersOnExit()方法卻只有Java 1.1及後續版本提供了對它的支持(註釋③)。注意可在程序執行的任何時候調用這個方法,而且收尾程序的執行與垃圾收集器是否運行是無關的。

③:不幸的是,Java 1.0採用的垃圾收集器方案永遠不能正確地調用finalize()。因此,finalize()方法(特別是那些用於關閉文件的)事實上經常都不會得到調用。現在有些文章聲稱所有收尾模塊都會在程序退出的時候得到調用——即使到程序中止的時候,垃圾收集器仍未針對那些對象採取行動。這並不是真實的情況,所以我們根本不能指望finalize()能為所有對象而調用。特別地,finalize()在Java 1.0裡幾乎毫無用處。

前面的程序向我們揭示出:在Java 1.1中,收尾模塊肯定會運行這一許諾已成為現實——但前提是我們明確地強制它採取這一操作。若使用一個不是beforeafter的參數(如none),那麼兩個收尾工作都不會進行,而且我們會得到象下面這樣的輸出:

Created 47

Created 47
Beginning to finalize after 8694 Chairs have been created
Finalizing Chair #47, Setting flag to stop Chair creation
After all Chairs have been created:
total created = 9834, total finalized = 108
bye!

因此,到程序結束的時候,並非所有收尾模塊都會得到調用(註釋④)。為強制進行收尾工作,可先調用System.gc(),再調用System.runFinalization()。這樣可清除到目前為止沒有使用的所有對象。這樣做一個稍顯奇怪的地方是在調用runFinalization()之前調用gc(),這看起來似乎與Sun公司的文檔說明有些抵觸,它宣稱首先運行收尾模塊,再釋放存儲空間。然而,若在這裡首先調用runFinalization(),再調用gc(),收尾模塊根本不會執行。

④:到你讀到本書時,有些Java虛擬機(JVM)可能已開始表現出不同的行為。

針對所有對象,Java 1.1有時之所以會默認為跳過收尾工作,是由於它認為這樣做的開銷太大。不管用哪種方法強制進行垃圾收集,都可能注意到比沒有額外收尾工作時較長的時間延遲。

4.4 成員初始化

Java儘自己的全力保證所有變量都能在使用前得到正確的初始化。若被定義成相對於一個方法的“局部”變量,這一保證就通過編譯期的出錯提示表現出來。因此,如果使用下述代碼:

void f() {
int i;
i++;
}

就會收到一條出錯提示消息,告訴你i可能尚未初始化。當然,編譯器也可為i賦予一個默認值,但它看起來更象一個程序員的失誤,此時默認值反而會“幫倒忙”。若強迫程序員提供一個初始值,就往往能夠幫他/她糾出程序裡的“Bug”。

然而,若將基本類型設為一個類的數據成員,情況就會變得稍微有些不同。由於任何方法都可以初始化或使用那個數據,所以在正式使用數據前,若還是強迫程序員將其初始化成一個適當的值,就可能不是一種實際的做法。然而,若為其賦予一個垃圾值,同樣是非常不安全的。因此,一個類的所有基本類型數據成員都會保證獲得一個初始值。可用下面這段小程序看到這些值:

//: InitialValues.java
// Shows default initial values

class Measurement {
  boolean t;
  char c;
  byte b;
  short s;
  int i;
  long l;
  float f;
  double d;
  void print() {
    System.out.println(
      "Data type      Inital value\n" +
      "boolean        " + t + "\n" +
      "char           " + c + "\n" +
      "byte           " + b + "\n" +
      "short          " + s + "\n" +
      "int            " + i + "\n" +
      "long           " + l + "\n" +
      "float          " + f + "\n" +
      "double         " + d);
  }
}

public class InitialValues {
  public static void main(String[] args) {
    Measurement d = new Measurement();
    d.print();
    /* In this case you could also say:
    new Measurement().print();
    */
  }
} ///:~

輸入結果如下:

Data type      Inital value
boolean        false
char
byte           0
short          0
int            0
long           0
float          0.0
double         0.0

其中,Char值為空(NULL),沒有數據打印出來。

稍後大家就會看到:在一個類的內部定義一個對象引用時,如果不將其初始化成新對象,那個引用就會獲得一個空值。

4.4.1 規定初始化

如果想自己為變量賦予一個初始值,又會發生什麼情況呢?為達到這個目的,一個最直接的做法是在類內部定義變量的同時也為其賦值(注意在C++裡不能這樣做,儘管C++的新手們總“想”這樣做)。在下面,Measurement類內部的字段定義已發生了變化,提供了初始值:

class Measurement {
  boolean b = true;
  char c = 'x';
  byte B = 47;
  short s = 0xff;
  int i = 999;
  long l = 1;
  float f = 3.14f;
  double d = 3.14159;
  //. . .

亦可用相同的方法初始化非基本(主)類型的對象。若Depth是一個類,那麼可象下面這樣插入一個變量並進行初始化:

class Measurement {
Depth o = new Depth();
boolean b = true;
// . . .

若尚未為o指定一個初始值,同時不顧一切地提前試用它,就會得到一條運行期錯誤提示,告訴你產生了名為“異常”(Exception)的一個錯誤(在第9章詳述)。 甚至可通過調用一個方法來提供初始值:

class CInit {
int i = f();
//...
}

當然,這個方法亦可使用參數,但那些參數不可是尚未初始化的其他類成員。因此,下面這樣做是合法的:

class CInit {
int i = f();
int j = g(i);
//...
}

但下面這樣做是非法的:

class CInit {
int j = g(i);
int i = f();
//...
}

這正是編譯器對“向前引用”感到不適應的一個地方,因為它與初始化的順序有關,而不是與程序的編譯方式有關。

這種初始化方法非常簡單和直觀。它的一個限制是類型Measurement的每個對象都會獲得相同的初始化值。有時,這正是我們希望的結果,但有時卻需要盼望更大的靈活性。

4.4.2 構造器初始化

可考慮用構造器執行初始化進程。這樣便可在編程時獲得更大的靈活程度,因為我們可以在運行期調用方法和採取行動,從而“現場”決定初始化值。但要注意這樣一件事情:不可妨礙自動初始化的進行,它在構造器進入之前就會發生。因此,假如使用下述代碼:

class Counter {
int i;
Counter() { i = 7; }
// . . .

那麼i首先會初始化成零,然後變成7。對於所有基本類型以及對象引用,這種情況都是成立的,其中包括在定義時已進行了明確初始化的那些一些。考慮到這個原因,編譯器不會試著強迫我們在構造器任何特定的場所對元素進行初始化,或者在它們使用之前——初始化早已得到了保證(註釋⑤)。

⑤:相反,C++有自己的“構造器初始模塊列表”,能在進入構造器主體之前進行初始化,而且它對於對象來說是強制進行的。參見《Thinking in C++》。

(1) 初始化順序

在一個類裡,初始化的順序是由變量在類內的定義順序決定的。即使變量定義大量遍佈於方法定義的中間,那些變量仍會在調用任何方法之前得到初始化——甚至在構造器調用之前。例如:

//: OrderOfInitialization.java
// Demonstrates initialization order.

// When the constructor is called, to create a
// Tag object, you'll see a message:
class Tag {
  Tag(int marker) {
    System.out.println("Tag(" + marker + ")");
  }
}

class Card {
  Tag t1 = new Tag(1); // Before constructor
  Card() {
    // Indicate we're in the constructor:
    System.out.println("Card()");
    t3 = new Tag(33); // Re-initialize t3
  }
  Tag t2 = new Tag(2); // After constructor
  void f() {
    System.out.println("f()");
  }
  Tag t3 = new Tag(3); // At end
}

public class OrderOfInitialization {
  public static void main(String[] args) {
    Card t = new Card();
    t.f(); // Shows that construction is done
  }
} ///:~

Card中,Tag對象的定義故意到處散佈,以證明它們全都會在構造器進入或者發生其他任何事情之前得到初始化。除此之外,t3在構造器內部得到了重新初始化。它的輸入結果如下:

Tag(1)
Tag(2)
Tag(3)
Card()
Tag(33)
f()

因此,t3引用會被初始化兩次,一次在構造器調用前,一次在調用期間(第一個對象會被丟棄,所以它後來可被當作垃圾收掉)。從表面看,這樣做似乎效率低下,但它能保證正確的初始化——若定義了一個重載的構造器,它沒有初始化t3;同時在t3的定義裡並沒有規定“默認”的初始化方式,那麼會產生什麼後果呢?

(2) 靜態數據的初始化

若數據是靜態的(static),那麼同樣的事情就會發生;如果它屬於一個基本類型,而且未對其初始化,就會自動獲得自己的標準基本類型初始值;如果它是指向一個對象的引用,那麼除非新建一個對象,並將引用同它連接起來,否則就會得到一個空值(NULL)。

如果想在定義的同時進行初始化,採取的方法與非靜態值表面看起來是相同的。但由於static值只有一個存儲區域,所以無論創建多少個對象,都必然會遇到何時對那個存儲區域進行初始化的問題。下面這個例子可將這個問題說更清楚一些:

//: StaticInitialization.java
// Specifying initial values in a
// class definition.

class Bowl {
  Bowl(int marker) {
    System.out.println("Bowl(" + marker + ")");
  }
  void f(int marker) {
    System.out.println("f(" + marker + ")");
  }
}

class Table {
  static Bowl b1 = new Bowl(1);
  Table() {
    System.out.println("Table()");
    b2.f(1);
  }
  void f2(int marker) {
    System.out.println("f2(" + marker + ")");
  }
  static Bowl b2 = new Bowl(2);
}

class Cupboard {
  Bowl b3 = new Bowl(3);
  static Bowl b4 = new Bowl(4);
  Cupboard() {
    System.out.println("Cupboard()");
    b4.f(2);
  }
  void f3(int marker) {
    System.out.println("f3(" + marker + ")");
  }
  static Bowl b5 = new Bowl(5);
}

public class StaticInitialization {
  public static void main(String[] args) {
    System.out.println(
      "Creating new Cupboard() in main");
    new Cupboard();
    System.out.println(
      "Creating new Cupboard() in main");
    new Cupboard();
    t2.f2(1);
    t3.f3(1);
  }
  static Table t2 = new Table();
  static Cupboard t3 = new Cupboard();
} ///:~

Bowl允許我們檢查一個類的創建過程,而TableCupboard能創建散佈於類定義中的Bowlstatic成員。注意在static定義之前,Cupboard先創建了一個非staticBowl b3。它的輸出結果如下:

Bowl(1)
Bowl(2)
Table()
f(1)
Bowl(4)
Bowl(5)
Bowl(3)
Cupboard()
f(2)
Creating new Cupboard() in main
Bowl(3)
Cupboard()
f(2)
Creating new Cupboard() in main
Bowl(3)
Cupboard()
f(2)
f2(1)
f3(1)

static初始化只有在必要的時候才會進行。如果不創建一個Table對象,而且永遠都不引用Table.b1Table.b2,那麼static Bowl b1b2永遠都不會創建。然而,只有在創建了第一個Table對象之後(或者發生了第一次static訪問),它們才會創建。在那以後,static對象不會重新初始化。

初始化的順序是首先static(如果它們尚未由前一次對象創建過程初始化),接著是非static對象。大家可從輸出結果中找到相應的證據。

在這裡有必要總結一下對象的創建過程。請考慮一個名為Dog的類:

(1) 類型為Dog的一個對象首次創建時,或者Dog類的static方法/static字段首次訪問時,Java解釋器必須找到Dog.class(在事先設好的類路徑裡搜索)。

(2) 找到Dog.class後(它會創建一個Class對象,這將在後面學到),它的所有static初始化模塊都會運行。因此,static初始化僅發生一次——在Class對象首次載入的時候。

(3) 創建一個new Dog()時,Dog對象的構建進程首先會在內存堆(Heap)裡為一個Dog對象分配足夠多的存儲空間。

(4) 這種存儲空間會清為零,將Dog中的所有基本類型設為它們的默認值(零用於數字,以及booleanchar的等價設定)。

(5) 進行字段定義時發生的所有初始化都會執行。

(6) 執行構造器。正如第6章將要講到的那樣,這實際可能要求進行相當多的操作,特別是在涉及繼承的時候。

(3) 明確進行的靜態初始化

Java允許我們將其他static初始化工作劃分到類內一個特殊的“static構建從句”(有時也叫作“靜態塊”)裡。它看起來象下面這個樣子:

class Spoon {
  static int i;
  static {
    i = 47;
  }
  // . . .

儘管看起來象個方法,但它實際只是一個static關鍵字,後面跟隨一個方法主體。與其他static初始化一樣,這段代碼僅執行一次——首次生成那個類的一個對象時,或者首次訪問屬於那個類的一個static成員時(即便從未生成過那個類的對象)。例如:

//: ExplicitStatic.java
// Explicit static initialization
// with the "static" clause.

class Cup {
  Cup(int marker) {
    System.out.println("Cup(" + marker + ")");
  }
  void f(int marker) {
    System.out.println("f(" + marker + ")");
  }
}

class Cups {
  static Cup c1;
  static Cup c2;
  static {
    c1 = new Cup(1);
    c2 = new Cup(2);
  }
  Cups() {
    System.out.println("Cups()");
  }
}

public class ExplicitStatic {
  public static void main(String[] args) {
    System.out.println("Inside main()");
    Cups.c1.f(99);  // (1)
  }
  static Cups x = new Cups();  // (2)
  static Cups y = new Cups();  // (2)
} ///:~

在標記為(1)的行內訪問static對象c1的時候,或在行(1)標記為註釋,同時(2)行不標記成註釋的時候,用於Cupsstatic初始化模塊就會運行。若(1)和(2)都被標記成註釋,則用於Cupsstatic初始化進程永遠不會發生。

(4) 非靜態實例的初始化

針對每個對象的非靜態變量的初始化,Java 1.1提供了一種類似的語法格式。下面是一個例子:

//: Mugs.java
// Java 1.1 "Instance Initialization"

class Mug {
  Mug(int marker) {
    System.out.println("Mug(" + marker + ")");
  }
  void f(int marker) {
    System.out.println("f(" + marker + ")");
  }
}

public class Mugs {
  Mug c1;
  Mug c2;
  {
    c1 = new Mug(1);
    c2 = new Mug(2);
    System.out.println("c1 & c2 initialized");
  }
  Mugs() {
    System.out.println("Mugs()");
  }
  public static void main(String[] args) {
    System.out.println("Inside main()");
    Mugs x = new Mugs();
  }
} ///:~

大家可看到實例初始化從句:

  {
    c1 = new Mug(1);
    c2 = new Mug(2);
    System.out.println("c1 & c2 initialized");
  }

它看起來與靜態初始化從句極其相似,只是static關鍵字從裡面消失了。為支持對“匿名內部類”的初始化(參見第7章),必須採用這一語法格式。

4.5 數組初始化

在C中初始化數組極易出錯,而且相當麻煩。C++通過“集合初始化”使其更安全(註釋⑥)。Java則沒有象C++那樣的“集合”概念,因為Java中的所有東西都是對象。但它確實有自己的數組,通過數組初始化來提供支持。

數組代表一系列對象或者基本數據類型,所有相同的類型都封裝到一起——採用一個統一的標識符名稱。數組的定義和使用是通過方括號索引運算符進行的([])。為定義一個數組,只需在類型名後簡單地跟隨一對空方括號即可:

int[] al;

也可以將方括號置於標識符後面,獲得完全一致的結果:

int al[];

這種格式與C和C++程序員習慣的格式是一致的。然而,最“通順”的也許還是前一種語法,因為它指出類型是“一個int數組”。本書將沿用那種格式。

編譯器不允許我們告訴它一個數組有多大。這樣便使我們回到了“引用”的問題上。此時,我們擁有的一切就是指向數組的一個引用,而且尚未給數組分配任何空間。為了給數組創建相應的存儲空間,必須編寫一個初始化表達式。對於數組,初始化工作可在代碼的任何地方出現,但也可以使用一種特殊的初始化表達式,它必須在數組創建的地方出現。這種特殊的初始化是一系列由花括號封閉起來的值。存儲空間的分配(等價於使用new)將由編譯器在這種情況下進行。例如:

int[] a1 = { 1, 2, 3, 4, 5 };

那麼為什麼還要定義一個沒有數組的數組引用呢?

int[] a2;

事實上在Java中,可將一個數組分配給另一個,所以能使用下述語句:

a2 = a1;

我們真正準備做的是複製一個引用,就象下面演示的那樣:

//: Arrays.java
// Arrays of primitives.

public class Arrays {
  public static void main(String[] args) {
    int[] a1 = { 1, 2, 3, 4, 5 };
    int[] a2;
    a2 = a1;
    for(int i = 0; i < a2.length; i++)
      a2[i]++;
    for(int i = 0; i < a1.length; i++)
      prt("a1[" + i + "] = " + a1[i]);
  }
  static void prt(String s) {
    System.out.println(s);
  }
} ///:~

大家看到a1獲得了一個初始值,而a2沒有;a2將在以後賦值——這種情況下是賦給另一個數組。

這裡也出現了一些新東西:所有數組都有一個本質成員(無論它們是對象數組還是基本類型數組),可對其進行查詢——但不是改變,從而獲知數組內包含了多少個元素。這個成員就是length。與C和C++類似,由於Java數組從元素0開始計數,所以能索引的最大元素編號是length-1。如超出邊界,C和C++會“默默”地接受,並允許我們胡亂使用自己的內存,這正是許多程序錯誤的根源。然而,Java可保留我們這受這一問題的損害,方法是一旦超過邊界,就生成一個運行期錯誤(即一個“異常”,這是第9章的主題)。當然,由於需要檢查每個數組的訪問,所以會消耗一定的時間和多餘的代碼量,而且沒有辦法把它關閉。這意味著數組訪問可能成為程序效率低下的重要原因——如果它們在關鍵的場合進行。但考慮到因特網訪問的安全,以及程序員的編程效率,Java設計人員還是應該把它看作是值得的。

程序編寫期間,如果不知道在自己的數組裡需要多少元素,那麼又該怎麼辦呢?此時,只需簡單地用new在數組裡創建元素。在這裡,即使準備創建的是一個基本數據類型的數組,new也能正常地工作(new不會創建非數組的基本類型):

//: ArrayNew.java
// Creating arrays with new.
import java.util.*;

public class ArrayNew {
  static Random rand = new Random();
  static int pRand(int mod) {
    return Math.abs(rand.nextInt()) % mod + 1;
  }
  public static void main(String[] args) {
    int[] a;
    a = new int[pRand(20)];
    prt("length of a = " + a.length);
    for(int i = 0; i < a.length; i++)
      prt("a[" + i + "] = " + a[i]);
  }
  static void prt(String s) {
    System.out.println(s);
  }
} ///:~

由於數組的大小是隨機決定的(使用早先定義的pRand()方法),所以非常明顯,數組的創建實際是在運行期間進行的。除此以外,從這個程序的輸出中,大家可看到基本數據類型的數組元素會自動初始化成“空”值(對於數值,空值就是零;對於char,它是null;而對於boolean,它卻是false)。

當然,數組可能已在相同的語句中定義和初始化了,如下所示:

int[] a = new int[pRand(20)];

若操作的是一個非基本類型對象的數組,那麼無論如何都要使用new。在這裡,我們會再一次遇到引用問題,因為我們創建的是一個引用數組。請大家觀察包裝器類型Integer,它是一個類,而非基本數據類型:

//: ArrayClassObj.java
// Creating an array of non-primitive objects.
import java.util.*;

public class ArrayClassObj {
  static Random rand = new Random();
  static int pRand(int mod) {
    return Math.abs(rand.nextInt()) % mod + 1;
  }
  public static void main(String[] args) {
    Integer[] a = new Integer[pRand(20)];
    prt("length of a = " + a.length);
    for(int i = 0; i < a.length; i++) {
      a[i] = new Integer(pRand(500));
      prt("a[" + i + "] = " + a[i]);
    }
  }
  static void prt(String s) {
    System.out.println(s);
  }
} ///:~

在這兒,甚至在new調用後才開始創建數組:

Integer[] a = new Integer[pRand(20)];

它只是一個引用數組,而且除非通過創建一個新的Integer對象,從而初始化了對象引用,否則初始化進程不會結束:

a[i] = new Integer(pRand(500));

但若忘記創建對象,就會在運行期試圖讀取空數組位置時獲得一個“異常”錯誤。

下面讓我們看看打印語句中String對象的構成情況。大家可看到指向Integer對象的引用會自動轉換,從而產生一個String,它代表著位於對象內部的值。

亦可用花括號封閉列表來初始化對象數組。可採用兩種形式,第一種是Java 1.0允許的唯一形式。第二種(等價)形式自Java 1.1才開始提供支持:

//: ArrayInit.java
// Array initialization

public class ArrayInit {
  public static void main(String[] args) {
    Integer[] a = {
      new Integer(1),
      new Integer(2),
      new Integer(3),
    };

    // Java 1.1 only:
    Integer[] b = new Integer[] {
      new Integer(1),
      new Integer(2),
      new Integer(3),
    };
  }
} ///:~

這種做法大多數時候都很有用,但限制也是最大的,因為數組的大小是在編譯期間決定的。初始化列表的最後一個逗號是可選的(這一特性使長列表的維護變得更加容易)。

數組初始化的第二種形式(Java 1.1開始支持)提供了一種更簡便的語法,可創建和調用方法,獲得與C的“變量參數列表”(C通常把它簡稱為“變參表”)一致的效果。這些效果包括未知的參數數量以及未知的類型(如果這樣選擇的話)。由於所有類最終都是從通用的根類Object中繼承的,所以能創建一個方法,令其獲取一個Object數組,並象下面這樣調用它:

//: VarArgs.java
// Using the Java 1.1 array syntax to create
// variable argument lists

class A { int i; }

public class VarArgs {
  static void f(Object[] x) {
    for(int i = 0; i < x.length; i++)
      System.out.println(x[i]);
  }
  public static void main(String[] args) {
    f(new Object[] {
        new Integer(47), new VarArgs(),
        new Float(3.14), new Double(11.11) });
    f(new Object[] {"one", "two", "three" });
    f(new Object[] {new A(), new A(), new A()});
  }
} ///:~

此時,我們對這些未知的對象並不能採取太多的操作,而且這個程序利用自動String轉換對每個Object做一些有用的事情。在第11章(運行期類型識別或RTTI),大家還會學習如何調查這類對象的準確類型,使自己能對它們做一些有趣的事情。

4.5.1 多維數組

在Java裡可以方便地創建多維數組:

//: MultiDimArray.java
// Creating multidimensional arrays.
import java.util.*;

public class MultiDimArray {
  static Random rand = new Random();
  static int pRand(int mod) {
    return Math.abs(rand.nextInt()) % mod + 1;
  }
  public static void main(String[] args) {
    int[][] a1 = {
      { 1, 2, 3, },
      { 4, 5, 6, },
    };
    for(int i = 0; i < a1.length; i++)
      for(int j = 0; j < a1[i].length; j++)
        prt("a1[" + i + "][" + j +
            "] = " + a1[i][j]);
    // 3-D array with fixed length:
    int[][][] a2 = new int[2][2][4];
    for(int i = 0; i < a2.length; i++)
      for(int j = 0; j < a2[i].length; j++)
        for(int k = 0; k < a2[i][j].length;
            k++)
          prt("a2[" + i + "][" +
              j + "][" + k +
              "] = " + a2[i][j][k]);
    // 3-D array with varied-length vectors:
    int[][][] a3 = new int[pRand(7)][][];
    for(int i = 0; i < a3.length; i++) {
      a3[i] = new int[pRand(5)][];
      for(int j = 0; j < a3[i].length; j++)
        a3[i][j] = new int[pRand(5)];
    }
    for(int i = 0; i < a3.length; i++)
      for(int j = 0; j < a3[i].length; j++)
        for(int k = 0; k < a3[i][j].length;
            k++)
          prt("a3[" + i + "][" +
              j + "][" + k +
              "] = " + a3[i][j][k]);
    // Array of non-primitive objects:
    Integer[][] a4 = {
      { new Integer(1), new Integer(2)},
      { new Integer(3), new Integer(4)},
      { new Integer(5), new Integer(6)},
    };
    for(int i = 0; i < a4.length; i++)
      for(int j = 0; j < a4[i].length; j++)
        prt("a4[" + i + "][" + j +
            "] = " + a4[i][j]);
    Integer[][] a5;
    a5 = new Integer[3][];
    for(int i = 0; i < a5.length; i++) {
      a5[i] = new Integer[3];
      for(int j = 0; j < a5[i].length; j++)
        a5[i][j] = new Integer(i*j);
    }
    for(int i = 0; i < a5.length; i++)
      for(int j = 0; j < a5[i].length; j++)
        prt("a5[" + i + "][" + j +
            "] = " + a5[i][j]);
  }
  static void prt(String s) {
    System.out.println(s);
  }
} ///:~

用於打印的代碼裡使用了length,所以它不必依賴固定的數組大小。 第一個例子展示了基本數據類型的一個多維數組。我們可用花括號定出數組內每個向量的邊界:

int[][] a1 = {
{ 1, 2, 3, },
{ 4, 5, 6, },
};

每個方括號對都將我們移至數組的下一級。 第二個例子展示了用new分配的一個三維數組。在這裡,整個數組都是立即分配的:

int[][][] a2 = new int[2][2][4];

但第三個例子卻向大家揭示出構成矩陣的每個向量都可以有任意的長度:

    int[][][] a3 = new int[pRand(7)][][];
    for(int i = 0; i < a3.length; i++) {
      a3[i] = new int[pRand(5)][];
      for(int j = 0; j < a3[i].length; j++)
        a3[i][j] = new int[pRand(5)];
    }

對於第一個new創建的數組,它的第一個元素的長度是隨機的,其他元素的長度則沒有定義。for循環內的第二個new則會填寫元素,但保持第三個索引的未定狀態——直到碰到第三個new

根據輸出結果,大家可以看到:假若沒有明確指定初始化值,數組值就會自動初始化成零。 可用類似的表式處理非基本類型對象的數組。這從第四個例子可以看出,它向我們演示了用花括號收集多個new表達式的能力:

    Integer[][] a4 = {
      { new Integer(1), new Integer(2)},
      { new Integer(3), new Integer(4)},
      { new Integer(5), new Integer(6)},
    };

第五個例子展示瞭如何逐漸構建非基本類型的對象數組:

    Integer[][] a5;
    a5 = new Integer[3][];
    for(int i = 0; i < a5.length; i++) {
      a5[i] = new Integer[3];
      for(int j = 0; j < a5[i].length; j++)
        a5[i][j] = new Integer(i*j);
    }

i*j只是在Integer裡置了一個有趣的值。

4.6 總結

作為初始化的一種具體操作形式,構造器應使大家明確感受到在語言中進行初始化的重要性。與C++的程序設計一樣,判斷一個程序效率如何,關鍵是看是否由於變量的初始化不正確而造成了嚴重的編程錯誤(Bug)。這些形式的錯誤很難發現,而且類似的問題也適用於不正確的清除或收尾工作。由於構造器使我們能保證正確的初始化和清除(若沒有正確的構造器調用,編譯器不允許對象創建),所以能獲得完全的控制權和安全性。

在C++中,與“構建”相反的“析構”(Destruction)工作也是相當重要的,因為用new創建的對象必須明確地清除。在Java中,垃圾收集器會自動為所有對象釋放內存,所以Java中等價的清除方法並不是經常都需要用到的。如果不需要類似於構造器的行為,Java的垃圾收集器可以極大簡化編程工作,而且在內存的管理過程中增加更大的安全性。有些垃圾收集器甚至能清除其他資源,比如圖形和文件引用等。然而,垃圾收集器確實也增加了運行期的開銷。但這種開銷到底造成了多大的影響卻是很難看出的,因為到目前為止,Java解釋器的總體運行速度仍然是比較慢的。隨著這一情況的改觀,我們應該能判斷出垃圾收集器的開銷是否使Java不適合做一些特定的工作(其中一個問題是垃圾收集器不可預測的性質)。

由於所有對象都肯定能獲得正確的構建,所以同這兒講述的情況相比,構造器實際做的事情還要多得多。特別地,當我們通過“創作”或“繼承”生成新類的時候,對構建的保證仍然有效,而且需要一些附加的語法來提供對它的支持。大家將在以後的章節裡詳細瞭解創作、繼承以及它們對構造器造成的影響。

4.7 練習

(1) 用默認構造器創建一個類(沒有參數),用它打印一條消息。創建屬於這個類的一個對象。

(2) 在練習1的基礎上增加一個重載的構造器,令其採用一個String參數,並隨同自己的消息打印出來。

(3) 以練習2創建的類為基礎上,創建屬於它的對象引用的一個數組,但不要實際創建對象並分配到數組裡。運行程 序時,注意是否打印出來自構造器調用的初始化消息。

(4) 創建同引用數組聯繫起來的對象,最終完成練習3。

(5) 用參數beforeafternone運行程序,試驗Garbage.java。重複這個操作,觀察是否從輸出中看出了一些固定的模式。改變代碼,使System.runFinalization()System.gc()之前調用,再觀察結果。