Thinking in Java(4-4) 深入理解 Java 的成員初始化與建構子

Thinking in Java(4-4) 深入理解 Java 的成員初始化與建構子
Photo by orbtal media / Unsplash

在 Java 程式設計中,成員變數的初始化、建構子和靜態資料的初始化都是至關重要的概念。本篇文章將深入探討自動與顯式初始化的機制和順序,以及如何有效地使用它們來優化程式設計。

成員初始化

Java 盡可能地保證所有變數都能得到適當的初始化。對於方法的區域變數,Java 透過編譯錯誤來強制我們進行初始化:

void f() {
  int i;
  i++; // Error -- i not initialized
}
雖然編譯器可以為 i 賦予一個預設值,但未初始化的變數更可能是疏忽,因此強制提供初始值可以幫助我們找出程式中的缺陷。

預設初始化值

當變數作為類別的成員變數(field)時,情況有所不同。類別的每個基本型態(primitive)成員變數都保證會有一個預設初始值。

public class InitialValues {
  boolean t;
  char c;
  byte b;
  short s;
  int i;
  long l;
  float f;
  double d;
  InitialValues reference;
  void printInitialValues() {
    System.out.println("Data type      Initial value");
    System.out.println("boolean        " + t);
    System.out.println("char           [" + c + "]");
    System.out.println("byte           " + b);
    System.out.println("short          " + s);
    System.out.println("int            " + i);
    System.out.println("long           " + l);
    System.out.println("float          " + f);
    System.out.println("double         " + d);
    System.out.println("reference      " + reference);
  }
  public static void main(String[] args) {
    InitialValues iv = new InitialValues();
    iv.printInitialValues();
    /* You could also say:
    new InitialValues().printInitialValues();
    */
  }
} /* Output:
Data type      Initial value
boolean        false
char           [ ]
byte           0
short          0
int            0
long           0
float          0.0
double         0.0
reference      null
*/
當在類別中定義物件的參考(reference)時,如果沒有初始化,會得到特殊值 null

指定初始化

若要為某個變數賦予初始值,可以在定義成員變數時直接賦值:

public class InitialValues2 {
  boolean bool = true;
  char ch = 'x';
  byte b = 47;
  short s = 0xff;
  int i = 999;
  long lng = 1;
  float f = 3.14f;
  double d = 3.14159;
} 

也可以用同樣的方法初始化非基本型態的物件:

class Depth {}

public class Measurement {
  Depth d = new Depth();
  // ...
}
若沒有為 d 賦初值而試圖使用它,會得到 null。如果進行任何實際操作如 d.someMethod(),則會拋出 NullPointerException

使用方法進行初始化

可以透過呼叫方法來提供初始值。方法可以帶參數,但被引用的變數必須已經被初始化,正確性依賴於初始化順序而非編譯方式。編譯器會適當地給出向前引用的警告:

public class MethodInit {
  int i = f();
  int f() { return 11; }
}

public class MethodInit2 {
  int i = f();
  int j = g(i);
  int f() { return 11; }
  int g(int n) { return n * 10; }
}

public class MethodInit3 {
  //! int j = g(i); // Illegal forward reference
  int i = f();
  int f() { return 11; }
  int g(int n) { return n * 10; }
}

使用建構子進行初始化

在 Java 中,建構子是初始化類別實例的主要方式之一。透過建構子,開發者可以在物件創建時執行初始化邏輯。

即便不在建構子中明確進行初始化,Java 保證所有類別成員變數在使用前都會自動被初始化為預設值,例如整數型態的成員變數自動為 0,物件參考自動為 null

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

使用建構子進行初始化的優勢在於可以根據創建物件時的具體情況動態地設定成員變數的值。例如,可以根據傳入的參數或當前的環境設定來調整初始化值。

初始化順序

在 Java 中,類別的成員變數定義的先後順序決定了初始化的順序,這些初始化會在建構子被調用之前完成,即使這些變數的定義散布在不同的位置。

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

class House {
  Window w1 = new Window(1); // Before constructor
  House() {
    // Show that we're in the constructor:
    print("House()");
    w3 = new Window(33); // Reinitialize w3
  }
  Window w2 = new Window(2); // After constructor
  void f() { print("f()"); }
  Window w3 = new Window(3); // At end
}

public class OrderOfInitialization {
  public static void main(String[] args) {
    House h = new House();
    h.f(); // Shows that construction is done
  }
} /* Output:
Window(1)
Window(2)
Window(3)
House()
Window(33)
f()
*/

由輸出可以看出,w3 這個參考在建構子中被重新初始化了一次,這展示了類別成員變數初始化與建構子執行的順序互動。

靜態資料的初始化

靜態資料(使用 static 關鍵字定義的成員變數)無論建立多少個物件,都只佔用一份記憶體。

  • static 關鍵字不能用於區域變數,只能在類別的成員變數上使用。
  • 如果靜態成員變數未顯式初始化:
    • 基本型態的靜態成員變數將獲得預設的初始值(例如 int 為 0,booleanfalse)。
    • 物件參考型態的靜態成員變數將預設為 null
  • 在定義處初始化,與非靜態資料的方法相同。
class Bowl {
  Bowl(int marker) {
    print("Bowl(" + marker + ")");
  }
  void f1(int marker) {
    print("f1(" + marker + ")");
  }
}

class Table {
  static Bowl bowl1 = new Bowl(1);
  Table() {
    System.out.println("Table()");
    bowl2.f1(1);
  }
  void f2(int marker) {
    System.out.println("f2(" + marker + ")");
  }
  static Bowl bowl2 = new Bowl(2);
}

class Cupboard {
  Bowl bowl3 = new Bowl(3);
  static Bowl bowl4 = new Bowl(4);
  Cupboard() {
    System.out.println("Cupboard()");
    bowl4.f1(2);
  }
  void f3(int marker) {
    System.out.println("f3(" + marker + ")");
  }
  static Bowl bowl5 = 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();
    table.f2(1);
    cupboard.f3(1);
  }
  static Table table = new Table();
  static Cupboard cupboard = new Cupboard();
} /* Output:
Bowl(1)
Bowl(2)
Table()
f1(1)
Bowl(4)
Bowl(5)
Bowl(3)
Cupboard()
f1(2)
Creating new Cupboard() in main
Bowl(3)
Cupboard()
f1(2)
Creating new Cupboard() in main
Bowl(3)
Cupboard()
f1(2)
f2(1)
f3(1)
*/

在上述程式碼中,靜態成員變數 bowl1bowl2Table 類別首次被載入時就已經初始化,這證明了靜態初始化只在類別首次載入時執行一次。

如果不先建立 Table 物件,也不引用 Table.bowl1Table.bowl2,那麼靜態的 Bowl bowl1bowl2 永遠都不會被建立。

靜態資料的初始化順序先於任何物件創建或非靜態資料的初始化。

假設有個類別 Dog

  1. 即使沒有顯式地使用 static,建構子實際上也是靜態方法。因此,當首次建立類型為 Dog 的物件時,或是 Dog 的靜態方法或成員變數首次被訪問時,Java 直譯器必須查找類別路徑以定位 Dog.class 文件。
  2. 載入 Dog.class,有關靜態初始化的動作都會執行,因此靜態初始化只在 類別 首次載入時進行一次。
  3. 當用 new Dog() 創建物件的時候,首先會在堆積(heap)上為 Dog 分配足夠的記憶體。
  4. 這塊空間會被清零,這就自動將 Dog 中所有基本型態的成員變數都設成預設值。
  5. 執行所有成員變數定義的初始化動作。
  6. 執行建構子。

顯式的靜態初始化

Java 允許使用特殊的 static 區塊(也稱為 static block)來組織多個靜態初始化動作:

public class Spoon {
  static int i;
  static {
    i = 47;
  }
}

雖然看起來像方法,但實際上它只是一段緊隨 static 關鍵字後面的代碼。

與其他靜態初始化相同,這段代碼只在以下時刻執行一次:

  • 首次生成這個類別的物件時。
  • 首次訪問那個類別的靜態成員。
class Cup {
  Cup(int marker) {
    System.out.println("Cup(" + marker + ")");
  }
  void f(int marker) {
    System.out.println("f(" + marker + ")");
  }
}

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

public class ExplicitStatic {
  public static void main(String[] args) {
    System.out.println("Inside main()");
    Cups.cup1.f(99);  // (1)
  }
  // static Cups cups1 = new Cups();  // (2)
  // static Cups cups2 = new Cups();  // (2)
} /* Output:
Inside main()
Cup(1)
Cup(2)
f(99)
*/

在上述範例中,Cup(1)Cup(2) 的創建只發生在 Cups 類別的靜態區塊執行時,證明靜態區塊只在類別首次載入時被執行一次。

非靜態實例初始化

Java 也提供了一種類似於靜態初始化區塊的語法來初始化非靜態變數,這種初始化區塊在每個建構子調用前執行:

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

public class Mugs {
  Mug mug1;
  Mug mug2;
  {
    mug1 = new Mug(1);
    mug2 = new Mug(2);
    print("mug1 & mug2 initialized");
  }
  Mugs() {
    System.out.println("Mugs()");
  }
  Mugs(int i) {
    System.out.println("Mugs(int)");
  }
  public static void main(String[] args) {
    System.out.println("Inside main()");
    new Mugs();
    System.out.println("new Mugs() completed");
    new Mugs(1);
    System.out.println("new Mugs(1) completed");
  }
} /* Output:
Inside main()
Mug(1)
Mug(2)
mug1 & mug2 initialized
Mugs()
new Mugs() completed
Mug(1)
Mug(2)
mug1 & mug2 initialized
Mugs(int)
new Mugs(1) completed
*/

這種語法在匿名內部類別(anonymous inner classes)的初始化中特別重要,它保證了無論建構子的具體形式如何,某些初始化動作都會被執行。這對於需要在多個建構子中共用初始化邏輯的類別來說非常有用。

結論

理解 Java 中成員初始化、建構子以及靜態資料初始化的機制和順序,有助於我們寫出更高效、更穩健的程式。透過合理地使用這些機制,我們可以確保物件在正確的時間被正確地初始化,從而避免潛在的錯誤和性能問題。