Java 中的 final 關鍵字,為什麼要學?怎麼用?【Thinking in Java筆記(6-4)】

Java 中的 final 關鍵字,為什麼要學?怎麼用?【Thinking in Java筆記(6-4)】
Photo by Claudio Schwarz / Unsplash

你是否曾經在程式裡,因為某個值被意外改掉,而找不到問題在哪裡?或者想要讓某些數據一旦設定後就永遠維持不變?這時候,final 就登場了。透過下面的步驟,我們一起用輕鬆對話的方式,拆解 final 的各種玩法,讓程式小白也能立刻上手

final 變數:讓資料一旦設定,永不反悔

  • 編譯時常數
    如果在宣告變數的時候,就用 final 修飾,並且立刻給它一個值,這個變數就變成「編譯時常數」。意思是編譯程式的時候,Java 就把它當成永遠不會變的數字,直接把數值「塞」進去,後續根本不會再去看那個變數
public class Constants {
  public static final int VALUE_ONE = 9;
  private static final int VALUE_TWO = 99;
  public static final int VALUE_THREE = 39;
}
  • VALUE_ONEVALUE_TWOVALUE_THREE 這三個都是一開始就定好的數字,無法再改
  • 再加上 static,就代表這個常數和整個類別綁在一起,連物件都不用製作,就能直接拿來用
就像你訂了一張火車票,一旦劃位成功,座位號碼就不會改變;但你還是可以在車廂裡走動,只是不會換到別人的座位

final 物件參考:保護「指向」,但不保護內容

當你把 final 用在物件參考上,Java 會保證這條「指向」不改掉,但並不會凍結物件本身

class Value {
  int i; // Package access
  public Value(int i) { this.i = i; }
}

public class FinalData {
  private static Random rand = new Random(47);
  private String id;
  public FinalData(String id) { this.id = id; }
  // Can be compile-time constants:
  private final int valueOne = 9;
  private static final int VALUE_TWO = 99;
  // Typical public constant:
  public static final int VALUE_THREE = 39;
  // Cannot be compile-time constants:
  private final int i4 = rand.nextInt(20);
  static final int INT_5 = rand.nextInt(20);
  private Value v1 = new Value(11);
  private final Value v2 = new Value(22);
  private static final Value VAL_3 = new Value(33);
  // Arrays:
  private final int[] a = { 1, 2, 3, 4, 5, 6 };
  public String toString() {
    return id + ": " + "i4 = " + i4 + ", INT_5 = " + INT_5;
  }
  public static void main(String[] args) {
    FinalData fd1 = new FinalData("fd1");
    //! fd1.valueOne++; // Error: can't change value
    fd1.v2.i++; // Object isn't constant!
    fd1.v1 = new Value(9); // OK -- not final
    for(int i = 0; i < fd1.a.length; i++)
      fd1.a[i]++; // Object isn't constant!
    //! fd1.v2 = new Value(0); // Error: Can't
    //! fd1.VAL_3 = new Value(1); // change reference
    //! fd1.a = new int[3];
    print(fd1);
    print("Creating new FinalData");
    FinalData fd2 = new FinalData("fd2");
    print(fd1);
    print(fd2);
  }
} /* Output:
fd1: i4 = 15, INT_5 = 18
Creating new FinalData
fd1: i4 = 15, INT_5 = 18
fd2: i4 = 13, INT_5 = 18
*/

當你在程式中看到 private final Value v2 = new Value(22); 這行聲明時,Java 會把「指向那個 Value 物件的路徑」鎖住,無法再指向其他物件,就像把鉤子牢牢固定在牆上,之後不能換到其他鉤子
不過,物件內容(鉤子上掛的畫)仍然可以被修改,例如 v2.i++ 就是改變 Value 裡的 i 值,而不動那條「指針」
相比之下,如果沒有加上 final(像 private Value v1 = new Value(11);),就像使用一條可以自由掛移的繩子,你可以隨時把繩子掛到新的鉤子上(fd1.v1 = new Value(9);
這樣的設計讓你能鎖定參考,卻不會限制對象內部的靈活度,兼具安全與彈性

重要注意事項

  • 不可改變引用:final 僅限制引用本身不可改變,但不限制引用的物件內容
  • 無法創建真正的常量物件:Java 並未提供直接的方法來創建完全不可變的物件(除非自行設計不可變的類別)
  • 這種限制也適用於陣列,因為陣列在 Java 中也是物件

空白 final(Blank Final)

有時候你想要的 final 變數,不是馬上就知道初始值,這時就用 空白 final。它就像你買了一個上鎖的行李箱——一旦上鎖,就不能再換密碼,但你可以選擇在不同的機場(也就是建構子)設定開鎖碼。一旦設定完成,就不能再動

問題引導:那為什麼不直接在宣告時給預設值?想像如果你要根據不同條件(像是使用者輸入或外部設定)為變數賦值,就需要留到建構物件時再決定,這就叫空白 final

class Poppet {
  private int i;
  Poppet(int ii) { i = ii; }
}

public class BlankFinal {
  private final int i = 0; // Initialized final
  private final int j; // Blank final
  private final Poppet p; // Blank final reference
  // Blank finals MUST be initialized in the constructor:
  public BlankFinal() {
    j = 1; // Initialize blank final
    p = new Poppet(1); // Initialize blank final reference
  }
  public BlankFinal(int x) {
    j = x; // Initialize blank final
    p = new Poppet(x); // Initialize blank final reference
  }
  public static void main(String[] args) {
    new BlankFinal();
    new BlankFinal(47);
  }
}

想像你帶著兩個上鎖的行李箱——jp,你必須在每個「機場辦理託運」(建構子)時設定上鎖密碼(初始化),完成後這兩個行李箱就無法再打開換鎖碼,否則連安檢(編譯)都過不了。透過這個機制,你能獲得「一旦設定就不可變」的安全性,同時又能根據不同情境(建構子參數、外部設定)靈活決定初值(彈性)

final 參數(Final Arguments):封條與地址

Java 允許在方法的參數列表中將參數指定為 final

為什麼要把方法參數也宣告為 final ? 想像一封信(參數)寄到某個地址(物件)。如果你貼上封條(final),就不能再拆掉封條更改收件地址(重新指向),但你可以在信封內對內容(物件狀態)做標註或修改

class Gizmo {
  public void spin() {}
}

public class FinalArguments {
  void with(final Gizmo g) {
    //! g = new Gizmo(); // Illegal -- g is final
  }
  void without(Gizmo g) {
    g = new Gizmo(); // OK -- g not final
    g.spin();
  }
  // void f(final int i) { i++; } // Can't change
  // You can only read from a final primitive:
  int g(final int i) { return i + 1; }
  public static void main(String[] args) {
    FinalArguments bf = new FinalArguments();
    bf.without(null);
    bf.with(null);
  }
}
  • 封條(final)作用:禁止在方法內重新賦值給參數,但不影響對物件內容的呼叫與修改
  • 物件參數 vs 基本型別:無論是 Gizmo gint i,宣告為 final 都不能再改變變數本身,但 非引用本身內容g.spin()return i+1)仍合法
  • 為何要這樣做? 將參數設為 final,可以避免意外在方法裡改動參考或基本值,提升程式可讀性和穩定性

final 方法:鎖定台詞,確保行為一致

為什麼要把方法也「封印」起來? 就像在電影中有些經典台詞你不希望被人隨意改編,就能貼上「禁止改寫」的印章

  1. 防止覆寫(保護核心邏輯):當你在父類別中寫下重要行為,不希望子類別偷偷修改,就用 final 封印,保持演出內容永遠如你設計
  2. 效能小加速(早期優化):過去編譯器看到 final 方法,就能把方法體直接貼到呼叫處(inline),省去跳轉成本。但現在 JVM 也夠聰明,只要想確保不可覆寫,就夠了
private 方法天生即為 final:因為其他類別看不到它們,自然無法改寫,等同自動加上「禁止改寫」章戳
class WithFinals {
  // Identical to "private" alone:
  private final void f() { print("WithFinals.f()"); }
  // Also automatically "final":
  private void g() { print("WithFinals.g()"); }
}

class OverridingPrivate extends WithFinals {
  private final void f() {
    print("OverridingPrivate.f()");
  }
  private void g() {
    print("OverridingPrivate.g()");
  }
}

class OverridingPrivate2 extends OverridingPrivate {
  public final void f() {
    print("OverridingPrivate2.f()");
  }
  public void g() {
    print("OverridingPrivate2.g()");
  }
}

public class FinalOverridingIllusion {
  public static void main(String[] args) {
    OverridingPrivate2 op2 = new OverridingPrivate2();
    op2.f();
    op2.g();
    // You can upcast:
    OverridingPrivate op = op2;
    // But you can't call the methods:
    //! op.f();
    //! op.g();
    // Same here:
    WithFinals wf = op2;
    //! wf.f();
    //! wf.g();
  }
} /* Output:
OverridingPrivate2.f()
OverridingPrivate2.g()
*/

只有那些對外開放的(publicprotected)方法,才算是父類別的「API 接口」,子類別才能夠覆寫它們。private 方法像是鎖在保險箱裡的私密文件,子類別根本看不到,也就無法改寫。如果在子類別中寫了同樣名字的方法,那其實是另創一條新路,並不會取代或影響父類別保險箱裡的原本實作

final 類別

有時候,不只是一個方法或屬性,你可能想要確保整個類別永遠不被繼承或修改。這時就能把類別「貼上封條」,告訴大家「禁止繼承我」,就像把獨門秘方放進無法複製的保險箱

當將整個類別聲明為 final 時,表示該類別不打算被繼承,也不允許其他類別繼承它。這可能出於設計或安全性的考量,確保類別的行為不被修改。

class SmallBrain {}

final class Dinosaur {
  int i = 7;
  int j = 1;
  SmallBrain x = new SmallBrain();
  void f() {}
}

//! class Further extends Dinosaur {}
// error: Cannot extend final class 'Dinosaur'

public class Jurassic {
  public static void main(String[] args) {
    Dinosaur n = new Dinosaur();
    n.f();
    n.i = 40;
    n.j++;
  }
}
  • final class Dinosaur:這行宣告一出,編譯器就知道「不准建立任何子類別」
  • 嘗試 class Further extends Dinosaur 會被阻擋,好比保險箱的鎖被牢牢鎖上
即便不能繼承,你還是可以使用這個類別的所有公開功能,也能修改實例欄位的值

應用場景

  • 安全核心:像是系統安全檢查、金鑰管理等,不希望被人動歪
  • API 設計:公開給外部使用的工具類別,確保行為一致
  • Helper 類:純靜態工具類別,不需要也不應該被繼承

final 類別中:

  • 禁止繼承:用 final 封印整個類別,確保設計意圖不被改動
  • 方法隱式 final:在 final 類別中,所有方法自動成為 final,自然無法覆寫
  • 保持彈性:即使類別禁繼承,類別內的欄位和方法仍能正常使用和修改內容,以維持功能彈性

繼承類別的初始化與類別載入

當你創建一台智慧手機,會分階段完成組裝、測試、自檢,才能交付使用;Java 類別的「載入」與「初始化」也有類似流程,確保每個零件都先安裝、再執行,最後交由開發者使用

在傳統的程式語言中,初始化的順序必須小心控制。例如,在 C++ 中,如果一個 static 變數在初始化之前被使用,可能會導致問題

Java 中的類別載入機制:誰先登場?

想像演出一齣舞台劇:演員要先上台、燈光音效要先準備,才能正式演出;Java 類別也需要「載入」與「預備」步驟,確保後面執行順利

  • 何時載入(Class Loading):當程式第一次碰到某個類別,例如呼叫它的 static 成員、使用 new 建立實例或透過反射操作時,Java 才會把這個類別「拉上舞台」,載入到記憶體中
  • 靜態初始化(Static Initialization):載入後,Java 會依宣告順序執行所有 static 變數的初始化static 區塊程式碼,就像舞台上的場景、道具要按照指定步驟一一佈置好,才能開始演出

繼承與初始化順序

以下是一個示範繼承與初始化順序的範例:

class Insect {
  private int i = 9;
  protected int j;
  Insect() {
    print("i = " + i + ", j = " + j);
    j = 39;
  }
  private static int x1 =
    printInit("static Insect.x1 initialized");
  static int printInit(String s) {
    print(s);
    return 47;
  }
}

public class Beetle extends Insect {
  private int k = printInit("Beetle.k initialized");
  public Beetle() {
    print("k = " + k);
    print("j = " + j);
  }
  private static int x2 =
    printInit("static Beetle.x2 initialized");
  public static void main(String[] args) {
    print("Beetle constructor");
    Beetle b = new Beetle();
  }
} /* Output:
static Insect.x1 initialized
static Beetle.x2 initialized
Beetle constructor
i = 9, j = 0
Beetle.k initialized
k = 47
j = 39
*/

執行步驟解析

  1. 載入 Beetle 類別:執行 Beetle.main(),載入器開始載入 Beetle 的編譯碼
  2. 載入父類別 Insect:發現 Beetle 繼承自 Insect,因此先載入 Insect
  3. 執行父類別的 static 初始化:執行 Insect 中的 static 初始化塊和變數
  4. 執行子類別的 static 初始化:執行 Beetle 中的 static 初始化塊和變數
  5. 建立物件,執行構造函數
    • 初始化基本型別:將物件中的基本型別設為預設值,物件引用設為 null
    • 執行父類別的構造函數:呼叫 Insect 的構造函數
    • 執行子類別的實例初始化:執行 Beetle 的實例變數初始化和構造函數

為什麼要在意順序?

  • 確保父類的核心行為完成前,子類不會意外使用還未設定的欄位
  • 避免靜態變數因先後錯亂而造成配置異常

將類別載入與初始化想成手機生產線,每個步驟都有先後順序,才能保證組裝出來的「產品」(物件) 正常運作

總結

在 Java 裡,final 就像幫你的程式加上保護鎖安全網,讓關鍵資料、方法和類別都不會被意外篡改。回顧一下我們學到的:

  1. final 變數:一旦設定就永遠不變,無論是基本型別還是物件參考,都能避免被重新指向
  2. final 物件參考:鎖定指向路徑,但允許物件內部狀態靈活變動,兼顧安全彈性
  3. 空白 final:先預留空間,然後在每個建構子裡根據需求設定,確保初始化完整又有彈性
  4. final 參數:在方法裡貼上封條,防止參數被重新賦值,讓方法更具可讀性和穩定
  5. final 方法:鎖定核心行為,阻止子類覆寫,確保演出內容永遠如你所願
  6. final 類別:封印整個類別,杜絕不必要的繼承,維持 API 設計一致性
  7. 載入與初始化順序:把類別載入想像成生產線,每個步驟有先後順序,保證程式執行時環境準備完善

掌握這些技巧後,你就能打造更可靠、更可維護的程式碼。下次寫測試、設計 API、或和同事一起合作時,都別忘了給關鍵部分加上一把final鎖,讓專案更穩健