Thinking in Java(6-3) final 關鍵字詳解及應用

Thinking in Java(6-3) final 關鍵字詳解及應用
Photo by orbtal media / Unsplash

final 關鍵字詳解及應用

Java 中的 final 關鍵字在不同的情境下具有細微的差異,但核心概念是不可改變。這種不可變性可能出於兩個主要原因:

  • 設計目的:為了保持程式的完整性和安全性,防止某些值或方法被修改。
  • 效率考量:在某些情況下,可以透過使用 final 來提高程式的執行效率。

本篇文章將深入探討 final 關鍵字在數據、方法、類別以及初始化等方面的詳細應用。

final 數據(Final Data)

許多程式語言都有方式讓編譯器知道某些數據是不可變的。在 Java 中,這可以透過使用 final 關鍵字來實現。

常數與基本型別

  • 編譯時常數:必須是基本型別的變數,並在聲明時以 final 關鍵字修飾,同時必須給予初始值。
  • static final:當一個變數同時被聲明為 staticfinal,它就成為一個不可改變的靜態常數。

範例如下:

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 都是帶有編譯時數值的基本型別,並且是不可改變的常數。
  • VALUE_THREE 被定義為 public,因此可以在類別外部使用。

final 物件引用

final 用於物件引用而非基本型別時,final 使該引用恆定不變。也就是說,一旦引用被初始化指向某個物件,就不能再指向其他物件。然而,物件本身的內容是可以被修改的。

重要注意事項:

  • 不可改變引用final 僅限制引用本身不可改變,但不限制引用的物件內容。
  • 無法創建真正的常量物件:Java 並未提供直接的方法來創建完全不可變的物件(除非自行設計不可變的類別)。

這種限制也適用於陣列,因為陣列在 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
*/

在這個範例中:

  • valueOneVALUE_TWO 是基本型別的 final 變數,並在定義時給予初始值,因此可以作為編譯時常數。
  • i4INT_5 也是 final,但它們在執行時期透過隨機數初始化,因此不是編譯時常數。
  • v2 是一個 final 物件引用,無法再指向其他物件,但可以修改其內部狀態。

空白 final(Blank Final)

Java 允許宣告空白 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 參數(Final Arguments)

Java 允許在方法的參數列表中將參數指定為 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);
  }
}

在這個範例中,with() 方法的參數 g 被聲明為 final,因此無法在方法內將 g 指向新的物件。

final 方法

使用 final 修飾方法有兩個主要原因:

  1. 防止方法被覆寫:將方法鎖定,防止繼承的類別修改其行為。
  2. 效率考量:在早期的 Java 版本中,宣告為 final 的方法允許編譯器將其轉換為內聯(inline)調用,以提高效率。

然而,現代的 JVM 已經能夠自動優化內聯調用,因此在 Java SE5/6 之後,應該將效率問題交給編譯器和 JVM,只在確實需要防止方法被覆寫時,才將其宣告為 final

private 方法隱式為 final

在類別中,所有的 private 方法都被隱式地視為 final。由於子類別無法訪問 private 方法,因此也無法覆寫它們。

以下是一個範例:

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()
*/

注意:只有當方法是父類別介面的一部分時,覆寫才會發生。當方法是 private 時,它不屬於子類別可見的介面,因此子類別中具有相同名稱的方法並不會覆寫父類別的 private 方法。

final 類別

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

final 類別中:

  • 類別內的欄位可以選擇是否宣告為 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++;
  }
}

在這個範例中,Dinosaur 被宣告為 final,因此無法被繼承。試圖繼承 Dinosaur 會導致編譯錯誤。

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

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

Java 中的類別載入機制

  • 類別載入時機:Java 的類別會在首次使用時載入,包括首次訪問 static 成員時。
  • 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 的實例變數初始化和構造函數。

初始化順序的重要性

  • static 成員的初始化順序:先父類別,後子類別,且按照定義的順序執行。
  • 實例成員的初始化順序:同樣按照父類別到子類別的順序進行,確保父類別的初始化在子類別之前完成。

這種初始化順序確保了在子類別中使用父類別的成員時,它們已經被正確初始化,避免了潛在的錯誤。

總結

final 關鍵字在 Java 中扮演著重要的角色,提供了控制不可變性的機制。透過 final,我們可以:

  • 宣告不可改變的變數:包括基本型別和物件引用。
  • 防止方法被覆寫:提高程式的安全性和穩定性。
  • 禁止類別被繼承:確保類別的設計不被修改。

同時,我們也討論了類別的初始化與載入機制,了解了繼承關係中初始化的順序和重要性。

關鍵要點

  • final 變數:一旦初始化後,無法再賦予新值(對於物件引用,無法指向新的物件,但可以修改物件內容)。
  • final 方法:防止方法被子類別覆寫。
  • final 類別:禁止類別被繼承。
  • 初始化順序static 成員在類別首次載入時初始化,實例成員在物件建立時按照父類別到子類別的順序初始化。

透過對 final 關鍵字的深入理解,我們可以編寫出更可靠、更安全的 Java 程式碼,充分利用語言提供的特性來構建穩健的應用程式。