深入解析 Java 建構子與多型:從初始化到清理的完整指南【Thinking in Java筆記(7-2)】

深入解析 Java 建構子與多型:從初始化到清理的完整指南【Thinking in Java筆記(7-2)】
Photo by Elena Mozhvilo / Unsplash

為什麼每次 new 一個物件,都會跑一串看似神祕的程式? 多型 又是什麼?為什麼它在「平常呼叫方法」時那麼厲害,卻在建構子裡露出馬腳?

如果你只是想 寫個程式跑得出結果,可能會覺得這些玄學可以跳過。但當專案變大、同事要求修改時,忽略初始化的細節就會 狠狠咬你一口。下面就讓我們從零開始,一步步拆解 Java 的建構子與多型秘密

建構子調用順序:像在組裝三明治

之前已在更前面章節中做了較為詳細的對建構子調用順序介紹,讓我們再快速回顧

為什麼每次 new SubClass(),都會一路往上呼叫好幾個建構子?

想像你正在蓋一棟房子

  1. 地基 先打好(最頂層父類)
  2. 梁柱 逐層搭建(中間父類)
  3. 屋頂 最後蓋上(最底層子類)

在 Java 裡,這個過程保證了所有基礎部件都先就位,才能往下一層構建。具體規則:

  • 父類建構子:自最上層逐層向下執行
  • 無參數預設:若子類建構子未顯式呼叫 super(...),編譯器就會自動加上無參數 super()若父類沒提供無參數建構子,就會編譯失敗,因為找不到地基
class Meal {
  Meal() { print("Meal()"); }
}

class Bread {
  Bread() { print("Bread()"); }
}

class Cheese {
  Cheese() { print("Cheese()"); }
}

class Lettuce {
  Lettuce() { print("Lettuce()"); }
}

class Lunch extends Meal {
  Lunch() { print("Lunch()"); }
}

class PortableLunch extends Lunch {
  PortableLunch() { print("PortableLunch()");}
}

public class Sandwich extends PortableLunch {
  private Bread b = new Bread();
  private Cheese c = new Cheese();
  private Lettuce l = new Lettuce();
  public Sandwich() { print("Sandwich()"); }
  public static void main(String[] args) {
    new Sandwich();
  }
} /* Output:
Meal()
Lunch()
PortableLunch()
Bread()
Cheese()
Lettuce()
Sandwich()
*/

從這段程式中,你可以看到:

  • 父類建構子Meal(), Lunch(), PortableLunch())先跑完
  • 成員欄位new Bread(), new Cheese(), new Lettuce())依宣告順序初始化
  • 子類建構子本體Sandwich())最後執行

就像先把房子的基礎、牆柱都搭好,最後才裝潢門窗,才能穩穩住人。這樣的呼叫順序,讓你的物件不會拆了半截就出錯

初始化 vs. 清理:裝好後的拆解順序

為什麼資源要「拆裝有序」? 完成物件初始化後,如果不講究步驟,可能會造成「檔案還沒關就釋放了底層物件」等奇怪錯誤。這就像你離開辦公室

  1. 先關門鎖門(最外層)
  2. 再拔電源、關燈(中層)
  3. 最後把個人物品帶走(最內層)

在 Java 裡,垃圾回收雖然能處理大部分記憶體,但遇到檔案、網路連線資料庫連接等資源,就必須手動清理

清理的基本守則

  • 子類先手動清理:在子類中覆寫 dispose()close(),先釋放自己開啟的資源
  • 務必呼叫 super.dispose():最後再交給父類清理,確保基礎資源不會剩下漏網之魚

以下是一個示例,展示了物件的清理順序:

class Characteristic {
  private String s;
  Characteristic(String s) {
    this.s = s;
    print("Creating Characteristic " + s);
  }
  protected void dispose() {
    print("disposing Characteristic " + s);
  }
}

class Description {
  private String s;
  Description(String s) {
    this.s = s;
    print("Creating Description " + s);
  }
  protected void dispose() {
    print("disposing Description " + s);
  }
}

class LivingCreature {
  private Characteristic p =
    new Characteristic("is alive");
  private Description t =
    new Description("Basic Living Creature");
  LivingCreature() {
    print("LivingCreature()");
  }
  protected void dispose() {
    print("LivingCreature dispose");
    t.dispose();
    p.dispose();
  }
}

class Animal extends LivingCreature {
  private Characteristic p =
    new Characteristic("has heart");
  private Description t =
    new Description("Animal not Vegetable");
  Animal() { print("Animal()"); }
  protected void dispose() {
    print("Animal dispose");
    t.dispose();
    p.dispose();
    super.dispose();
  }
}

class Amphibian extends Animal {
  private Characteristic p =
    new Characteristic("can live in water");
  private Description t =
    new Description("Both water and land");
  Amphibian() {
    print("Amphibian()");
  }
  protected void dispose() {
    print("Amphibian dispose");
    t.dispose();
    p.dispose();
    super.dispose();
  }
}

public class Frog extends Amphibian {
  private Characteristic p = new Characteristic("Croaks");
  private Description t = new Description("Eats Bugs");
  public Frog() { print("Frog()"); }
  protected void dispose() {
    print("Frog dispose");
    t.dispose();
    p.dispose();
    super.dispose();
  }
  public static void main(String[] args) {
    Frog frog = new Frog();
    print("Bye!");
    frog.dispose();
  }
} /* Output:
Creating Characteristic is alive
Creating Description Basic Living Creature
LivingCreature()
Creating Characteristic has heart
Creating Description Animal not Vegetable
Animal()
Creating Characteristic can live in water
Creating Description Both water and land
Amphibian()
Creating Characteristic Croaks
Creating Description Eats Bugs
Frog()
Bye!
Frog dispose
disposing Description Eats Bugs
disposing Characteristic Croaks
Amphibian dispose
disposing Description Both water and land
disposing Characteristic can live in water
Animal dispose
disposing Description Animal not Vegetable
disposing Characteristic has heart
LivingCreature dispose
disposing Description Basic Living Creature
disposing Characteristic is alive
*/

想像你在拆組一個雙層蛋糕盒:你不會先把最裡層小蛋糕拿走,再拆最外層的盒子;正好相反,你會先打開盒蓋,然後一層層往下,最後才拿出裡面的蛋糕

  • 初始化順序:從最上層父類一直到最底層子類,就像先一層層疊起盒子
  • 清理順序:和初始化相反,先拆最底層子類的成員,再一層層往上,最後拆最上層父類

這樣的順序可以確保,當你拆下一層之前,上層的結構都還完好,不會出現「盒子已拆完,裡面的蛋糕還沒出來」這種尷尬狀況

引用計數:借書也要有人負責還

多個物件同時共用一個資源(例如同一個檔案、同一條網路連線),卻不確定 最後誰該關閉?如果沒有人負責,資源就會一直掛在那裡,造成記憶體或連線洩漏

引用計數(Reference Counting)就像圖書館借書制度:

class Shared {
  private int refcount = 0;
  private static long counter = 0;
  private final long id = counter++;
  public Shared() {
    print("Creating " + this);
  }
  public void addRef() { refcount++; }
  protected void dispose() {
    if(--refcount == 0)
      print("Disposing " + this);
  }
  public String toString() { return "Shared " + id; }
}

class Composing {
  private Shared shared;
  private static long counter = 0;
  private final long id = counter++;
  public Composing(Shared shared) {
    print("Creating " + this);
    this.shared = shared;
    this.shared.addRef();
  }
  protected void dispose() {
    print("disposing " + this);
    shared.dispose();
  }
  public String toString() { return "Composing " + id; }
}

public class ReferenceCounting {
  public static void main(String[] args) {
    Shared shared = new Shared();
    Composing[] composing = { new Composing(shared),
      new Composing(shared), new Composing(shared),
      new Composing(shared), new Composing(shared) };
    for(Composing c : composing)
      c.dispose();
  }
} /* Output:
Creating Shared 0
Creating Composing 0
Creating Composing 1
Creating Composing 2
Creating Composing 3
Creating Composing 4
disposing Composing 0
disposing Composing 1
disposing Composing 2
disposing Composing 3
disposing Composing 4
Disposing Shared 0
*/
  • 借書(addRef):每當新物件需要共用 Shared,refcount 加一
  • 還書(dispose):每當物件清理,refcount 減一;只有當 refcount 變成 0,才真正呼叫 Shared.dispose()

想像圖書館裡那本熱門書,只有最後一位讀者歸還後,館方才把書放回書架並允許維護;否則即使有人已經離開,書仍得留在借閱狀態。這樣確保 沒有人「落跑不還」,也不會過早關閉資源或過度佔用

在建構子裡呼叫多型方法:小心踩雷

通常在一般方法中,Java 會在執行時選擇要呼叫哪個版本(動態綁定),讓多型發揮威力。但如果在建構子裡面就呼叫這類方法,就像在房子還沒蓋好時就安裝瓦片——衍生類別的欄位還沒初始化,就被迫執行,結果自然會出乎意料

class Glyph {
  void draw() { print("Glyph.draw()"); }
  Glyph() {
    print("Glyph() before draw()");
    draw();
    print("Glyph() after draw()");
  }
}	

class RoundGlyph extends Glyph {
  private int radius = 1;
  RoundGlyph(int r) {
    radius = r;
    print("RoundGlyph.RoundGlyph(), radius = " + radius);
  }
  void draw() {
    print("RoundGlyph.draw(), radius = " + radius);
  }
}	

public class PolyConstructors {
  public static void main(String[] args) {
    new RoundGlyph(5);
  }
} /* Output:
Glyph() before draw()
RoundGlyph.draw(), radius = 0
Glyph() after draw()
RoundGlyph.RoundGlyph(), radius = 5
*/

發生了什麼?

  1. 分配記憶體:所有欄位先被設為預設值(數字型 0,物件型 null)
  2. 執行父類建構子:呼叫 draw() 竟然執行到子類的版本,卻只能看到預設值
  3. 初始化子類欄位:才開始設定 radius = r,並執行子類建構子主體

就好像你先在未畫好的畫布上塗色,看不到正確畫面

解決方案

  • 別在建構子裡呼叫可被覆寫的方法:它們可能還沒「就緒」,容易踩雷
  • 若真的需要呼叫,把方法改成 final 或改成靜態輔助函式,避免動態綁定
  • 或者將邏輯搬到工廠方法(Factory),或在初始化結束後再呼叫,確保物件完全就緒後再使用

最佳實踐小提醒

  • 簡化建構子:只做最基本的欄位設定,複雜邏輯放到 factory 方法或靜態函式裡
  • 避免多型呼叫:建構子裡不要呼叫能被覆寫的方法
  • 資源清理:若有額外資源(檔案、網路),務必在 dispose()(或 close())中 super.dispose()
  • 參考順序:父類先建構、子類後建構;子類先清理、父類後清理

總結

這一篇我們一起探索了 建構子初始化成員欄位初始化資源清理,還有 多型方法在建構子中的風險。重點回顧:

  1. 建構子呼叫順序:父類→子類,確保每一層的「地基」都先打好,避免中途崩盤
  2. 成員與清理順序:初始化是父類→子類,清理剛好相反,子類→父類,就像拆裝蛋糕盒
  3. 引用計數機制:多個物件共用資源時,要像圖書館借書一樣,最後一人歸還才真正釋放
  4. 多型方法呼叫警示:建構子中不要呼叫可被覆寫的方法,否則會在「未就緒」時觸發錯誤

持續練習這些規則,不僅能讓程式跑得穩,更能減少調試時的挫折。期待你用這些基礎,打造更可靠的 Java 應用