學完本章你將能夠:
說明反射的用途,並用三種方式取得 |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 的共同核心:掃描、判斷、動態呼叫或 注入。
本章練習
觀念題:|getMethods()| 與 |getDeclaredMethods()| 有何差異? 各在什麼情況下使用? (提示:一個看繼承與公開,一個看本類別與私有。)
觀念題:為什麼想被反射讀取的標註一定要用 |@Retention(RUNTIME)|? 若用預設的 |CLASS| 會怎樣? (提示:想想標註在各階段是否還存在。)
實作題:寫一個方法,接收任意物件,用反射印出它所有欄位的名稱與目前值 (含私有欄位)。 (提示:|getDeclaredFields| 搭配 |setAccessible|。)
實作題:擴充迷你測試框架,加入 |@Before| 標註,讓每個測試方法 執行前先跑一次初始化方法。 (提示:先找出 |@Before| 方法,在每次 |invoke| 前呼叫它。)
實作題:定義 |@Range(min, max)| 標註加在整數欄位上,寫一個驗證器 用反射檢查欄位值是否在範圍內。 (提示:讀取標註的 |min|/|max| 再與欄位值比較。)
實作題:為迷你 DI 容器加入「單例快取」,讓相同型別的依賴只建立一次、 重複注入同一個實體。 (提示:用 |Map<Class<?>, Object>| 快取已建立的實體。)