學完本章你將能夠:

  • 說明反射的用途,並用三種方式取得 |Class| 物件。

  • 用反射檢視類別的欄位、方法與建構子,解析修飾詞。

  • 動態建立實體、呼叫方法、存取私有成員,並理解其代價。

  • 自訂標註,運用 |@Retention|、|@Target| 等元標註。

  • 在執行期讀取標註,打造迷你測試框架。

  • 用反射實作簡易依賴注入,理解 Spring 的核心原理。

到目前為止,我們寫的程式都在「編譯期」就決定了要用哪個類別、呼叫哪個方法。 反射(reflection)打破了這個限制:它讓程式在執行期才動態地檢視與操作 類別、方法與欄位。這聽起來很抽象,但你天天在用的框架幾乎都靠它運作——Spring 的依賴注入、Jackson 的 JSON 序列化、JUnit 的測試探索,底層都是反射。

反射與標註(Annotation)是一對好搭檔:標註為程式碼「貼上標籤」,反射則在執行期 「讀取標籤」並據以行動。本章最後會用這對組合,親手打造迷你版的 JUnit 與 Spring, 讓你徹底看穿框架的魔法。

反射是什麼

一般情況下,若要使用 |ArrayList|,我們會 |import| 它、|new| 它、呼叫它的方法——這些都在編譯期綁定。但框架的作者面對的是「使用者將來才會 寫、我現在還看不到」的類別。它們無法 |import| 一個還不存在的類別,只能 靠反射在執行期動態處理。反射的典型場景包括:框架(Spring、Hibernate)、 序列化工具(把物件轉成 JSON)、以及測試工具(自動找出並執行測試方法)。

Class 物件:反射的入口

反射的一切都從 |Class| 物件開始。JVM 為每個載入的類別建立唯一 一個 |Class| 物件,它記錄了該類別的全部結構資訊。取得它有三種方式:

/** * 三種取得 Class 物件的方式,並驗證其唯一性。 */ public class GetClassObject

public static void main(String[] args) throws ClassNotFoundException

// 方式一:類別字面值(編譯期已知型別,最安全) Class<String> c1 = String.class;

// 方式二:物件的 getClass(已有實體時) String s = "hello"; Class<?> c2 = s.getClass();

// 方式三:Class.forName(執行期用全名字串載入,最動態) Class<?> c3 = Class.forName("java.lang.String");

// 三者指向同一個 Class 物件(唯一性) System.out.println("c1 == c2?" + (c1 == c2)); System.out.println("c2 == c3?" + (c2 == c3)); System.out.println("類別名稱:" + c1.getName());

c1 == c2?true c2 == c3?true 類別名稱:java.lang.String

三種方式各有適用時機:|.class| 用於編譯期已知型別;|getClass| 用於 手上已有物件;|Class.forName| 最動態,適合「類別全名來自設定檔或字串」的 情境,例如 JDBC 載入驅動。

檢視類別結構

取得 |Class| 物件後,就能查詢它的欄位、方法與建構子。這裡要先釐清一組 容易混淆的方法:

方法家族 是否含私有成員 是否含繼承成員
|getFields()| / |getMethods()| 否(僅 public) 是
|getDeclaredFields()| 等 是(含 private) 否(僅本類別宣告)

換言之,|getDeclared*| 看的是「這個類別自己宣告的所有成員(含私有)」, 而 |get*| 看的是「這個類別對外公開的成員(含繼承來的)」。以下走訪一個 類別的完整結構:

import java.lang.reflect.Field; import java.lang.reflect.Method; import java.lang.reflect.Modifier;

/** * 用反射走訪類別的欄位與方法,並解析其修飾詞。 */ public class InspectClass

/** 被檢視的目標類別 */ static class Account

private double balance; // 私有欄位 public String owner; // 公開欄位

public void deposit(double amount) balance += amount; private void audit()

public static void main(String[] args)

Class<?> c = Account.class;

System.out.println("== 所有宣告的欄位 =="); for (Field f : c.getDeclaredFields()) // Modifier 把修飾詞的整數位元解析成可讀字串 String mod = Modifier.toString(f.getModifiers()); System.out.println(" " + mod + " " + f.getType().getSimpleName() + " " + f.getName());

System.out.println("== 所有宣告的方法 =="); for (Method m : c.getDeclaredMethods()) System.out.println(" " + m.getName() + ",參數個數:" + m.getParameterCount());

== 所有宣告的欄位 == private double balance public String owner == 所有宣告的方法 == deposit,參數個數:1 audit,參數個數:0

動態建立與呼叫

反射不只能「看」,還能「動」。我們可以動態建立實體、呼叫方法,甚至存取私有 成員。以下範例完整示範這條流水線:

import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Method;

/** * 動態建立實體、呼叫方法、存取私有欄位的完整示範。 */ public class DynamicInvoke

static class Greeter private String name; // 私有欄位 public Greeter(String name) this.name = name; public String greet(String lang) // 有參數、有回傳 return ("zh".equals(lang) ? "你好," : "Hello, ") + name;

public static void main(String[] args) throws Exception

Class<?> c = Greeter.class;

// 1. 動態呼叫建構子建立實體 Constructor<?> ctor = c.getDeclaredConstructor(String.class); Object obj = ctor.newInstance("Alice");

// 2. 動態呼叫方法(方法名 + 參數型別,再傳入實際引數) Method greet = c.getMethod("greet", String.class); Object result = greet.invoke(obj, "zh"); System.out.println("方法回傳:" + result);

// 3. 存取並修改私有欄位(需先開權限) Field nameField = c.getDeclaredField("name"); nameField.setAccessible(true); // 破除私有存取限制 nameField.set(obj, "Bob"); // 直接改寫私有欄位 System.out.println("改名後:" + greet.invoke(obj, "en"));

方法回傳:你好,Alice 改名後:Hello, Bob

|setAccessible(true)| 能繞過 |private| 限制,直接讀寫私有成員。 這是把雙面刃:框架靠它注入依賴、還原序列化物件,但濫用會徹底破壞封裝, 讓外部程式依賴實作細節,一旦內部欄位改名就全盤崩潰。此外,自 Java 9 模組系統 之後,若目標類別所在的模組沒有對外 |opens|,強行 |setAccessible| 會拋出 |InaccessibleObjectException|。請只在框架層級、且確有必要時使用。

反射的成本

反射威力強大,但絕非免費午餐。

反射有三重代價。效能:反射呼叫比直接呼叫慢(需動態解析、無法內聯), 雖然現代 JVM 已大幅最佳化,熱路徑上仍應避免。失去編譯期檢查:方法名 與欄位名變成字串,打錯字要到執行期才爆炸,編譯器幫不了你。重構不安全: IDE 的「重新命名」無法追蹤字串裡的方法名,改名後反射程式碼默默失效。 結論:反射是框架的工具,不是日常業務邏輯的工具。能用一般呼叫解決的, 就不要用反射。

標註(Annotation)

標註是加在程式元素上的中繼資料(metadata),它本身不改變程式邏輯, 而是「附加資訊」給編譯器或框架讀取。注意術語:|@Override| 這類叫「標註」 (Annotation),而 |//| 開頭那種給人看的文字叫「註解」(comment),兩者 截然不同。Java 內建的標註如下:

內建標註 用途
|@Override| 標示覆寫父類方法,寫錯簽章時編譯報錯
|@Deprecated| 標示已棄用,使用時編譯警告
|@SuppressWarnings| 抑制指定的編譯警告
|@FunctionalInterface| 標示函數式介面(見第 1 章)

自訂標註

用 |@interface| 定義自己的標註。標註可以有「元素」(看起來像方法), 並能指定預設值。若元素名為 |value| 且只有它一個,使用時可省略元素名。

import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target;

/** * 自訂標註 @Column:用來標示欄位對應的資料庫欄位名與是否可為空。 * RUNTIME 保留策略讓反射在執行期能讀到它。 */ @Retention(RetentionPolicy.RUNTIME) // 執行期仍保留 @Target(ElementType.FIELD) // 只能加在欄位上 @interface Column String name(); // 無預設值,使用時必填 boolean nullable() default true; // 有預設值,可省略

元標註

「元標註」(meta-annotation)是「用來標註標註」的標註。最重要的是 |@Retention|,它決定標註保留到哪個階段:

保留策略 意義
|SOURCE| 只在原始碼,編譯後丟棄(如 |@Override|)
|CLASS| 保留到 class 檔,但執行期不載入(預設值)
|RUNTIME| 保留到執行期,反射可讀取(框架最常用)

其他常用元標註:|@Target| 限定標註可用的位置(類別、方法、欄位等); |@Inherited| 讓標註可被子類別繼承;|@Documented| 讓標註出現在 Javadoc;|@Repeatable| 允許同一位置重複使用同一標註。

若想在執行期用反射讀取標註,其 |@Retention| 必須是 |RUNTIME|。這是最常見的陷阱:忘了加 |@Retention(RUNTIME)|,標註 在編譯後就消失,反射永遠讀不到,框架自然「沒反應」,還很難察覺問題出在哪。

執行期讀取標註

在執行期,用 |isAnnotationPresent| 判斷是否有某標註,用 |getAnnotation| 取得標註實體並讀取其元素值:

import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.lang.reflect.Field;

/** * 用反射讀取欄位上的 @Column 標註,模擬 ORM 的欄位對應。 */ public class ReadAnnotation

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) @interface Column String name(); boolean nullable() default true;

static class User

@Column(name = "user_id", nullable = false) private Long id;

@Column(name = "user_name") private String username;

public static void main(String[] args) for (Field f : User.class.getDeclaredFields()) // 先確認欄位上有 @Column 才讀取 if (f.isAnnotationPresent(Column.class)) Column col = f.getAnnotation(Column.class); System.out.println(f.getName() + " → 欄位 " + col.name() + "(可為空:" + col.nullable() + ")");

id → 欄位 user_id(可為空:false) username → 欄位 user_name(可為空:true)

綜合實戰一:迷你測試框架

現在把反射與標註合起來用。JUnit 的核心原理其實很單純:定義一個 |@Test| 標註,然後用反射掃描類別、找出所有標了 |@Test| 的方法、逐一執行並統計 通過與失敗。我們親手實作一個迷你版:

import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.reflect.Method;

/** * 迷你測試框架:掃描 @MiniTest 方法、執行、統計通過與失敗。 */ public class MiniTest

/** 標示「這是一個測試方法」 */ @Retention(RetentionPolicy.RUNTIME) @interface Case

/** 被測試的類別,混合了會通過與會失敗的案例 */ static class Calculator @Case public void addShouldWork() assertTrue(1 + 1 == 2, "1+1 應為 2"); @Case public void divideShouldFail() assertTrue(10 / 2 == 6, "故意寫錯的斷言");

/** 簡易斷言:不成立就丟例外 */ static void assertTrue(boolean cond, String msg) if (!cond) throw new AssertionError(msg);

public static void main(String[] args) throws Exception

Class<?> c = Calculator.class; Object instance = c.getDeclaredConstructor().newInstance(); int pass = 0, fail = 0;

// 掃描所有方法,只執行標了 @Case 的 for (Method m : c.getDeclaredMethods()) if (!m.isAnnotationPresent(Case.class)) continue; try m.invoke(instance); // 反射呼叫測試方法 System.out.println("[通過] " + m.getName()); pass++; catch (Exception e) // invoke 會把方法內的例外包成 InvocationTargetException System.out.println("[失敗] " + m.getName() + " → " + e.getCause().getMessage()); fail++; System.out.println("結果:通過 " + pass + ",失敗 " + fail);

addShouldWork [失敗] divideShouldFail → 故意寫錯的斷言 結果:通過 1,失敗 1 (註:方法掃描順序不保證,通過與失敗的行序可能對調)

看穿了嗎?JUnit 並不神祕——它就是「標註 + 反射掃描 + 動態呼叫」。理解這一點, 你對所有註解驅動的框架都會有全新的視野。

綜合實戰二:簡易依賴注入

Spring 最核心的功能「依賴注入」(Dependency Injection)同樣是反射的應用。原理 是:容器掃描物件的欄位,看到 |@Inject| 標註就自動建立對應的物件、用反射 塞進該欄位。使用者不必自己 |new| 依賴,達到「控制反轉」。

import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.reflect.Field;

/** * 迷你 DI 容器:掃描 @Inject 欄位並自動注入實體。 * 這正是 Spring 依賴注入的最小核心。 */ public class MiniInjector

@Retention(RetentionPolicy.RUNTIME) @interface Inject

/** 一個被依賴的服務 */ static class MessageService String send(String msg) return "已送出:" + msg;

/** 依賴 MessageService 的元件,用 @Inject 宣告需求 */ static class NotificationClient

@Inject MessageService service; // 由容器自動注入

void notifyUser() System.out.println(service.send("歡迎加入!"));

/** 極簡容器:為每個 @Inject 欄位建立並注入實體 */ static void inject(Object target) throws Exception for (Field f : target.getClass().getDeclaredFields()) if (f.isAnnotationPresent(Inject.class)) // 依欄位型別建立實體 Object dep = f.getType() .getDeclaredConstructor().newInstance(); f.setAccessible(true); f.set(target, dep); // 注入

public static void main(String[] args) throws Exception NotificationClient client = new NotificationClient(); inject(client); // 交給容器完成注入 client.notifyUser(); // service 已就緒

已送出:歡迎加入!

|NotificationClient| 從頭到尾沒有 |new MessageService()|,依賴卻 自動就位。這就是控制反轉的精髓:物件不再自己掌控依賴的建立,而是交給容器。 真正的 Spring 當然複雜得多(作用域、生命週期、循環依賴、AOP),但最核心的 魔法,你現在已經親手實作過了。

本章小結

  • 反射讓程式在執行期動態檢視與操作類別,是框架、序列化、測試工具的基石。

  • |Class| 物件是反射入口,可用 |.class|、|getClass|、 |Class.forName| 取得,同一類別的 |Class| 物件唯一。

  • |getDeclared*| 含私有但不含繼承;|get*| 含繼承但僅 public。

  • 反射可動態建立實體(|newInstance|)、呼叫方法(|invoke|)、 存取私有成員(|setAccessible|)。

  • 反射有效能、失去編譯期檢查、重構不安全三重代價,應限於框架層級。

  • 標註是中繼資料;欲在執行期以反射讀取,|@Retention| 必須為 |RUNTIME|。

  • 自訂標註用 |@interface|,可有元素與預設值;元標註有 |@Retention|、|@Target| 等。

  • 「標註 + 反射」是 JUnit 與 Spring 的共同核心:掃描、判斷、動態呼叫或 注入。

本章練習

  1. 觀念題:|getMethods()| 與 |getDeclaredMethods()| 有何差異? 各在什麼情況下使用? (提示:一個看繼承與公開,一個看本類別與私有。)

  2. 觀念題:為什麼想被反射讀取的標註一定要用 |@Retention(RUNTIME)|? 若用預設的 |CLASS| 會怎樣? (提示:想想標註在各階段是否還存在。)

  3. 實作題:寫一個方法,接收任意物件,用反射印出它所有欄位的名稱與目前值 (含私有欄位)。 (提示:|getDeclaredFields| 搭配 |setAccessible|。)

  4. 實作題:擴充迷你測試框架,加入 |@Before| 標註,讓每個測試方法 執行前先跑一次初始化方法。 (提示:先找出 |@Before| 方法,在每次 |invoke| 前呼叫它。)

  5. 實作題:定義 |@Range(min, max)| 標註加在整數欄位上,寫一個驗證器 用反射檢查欄位值是否在範圍內。 (提示:讀取標註的 |min|/|max| 再與欄位值比較。)

  6. 實作題:為迷你 DI 容器加入「單例快取」,讓相同型別的依賴只建立一次、 重複注入同一個實體。 (提示:用 |Map<Class<?>, Object>| 快取已建立的實體。)