Java开发教程24-反射与注解
本期是Java开发教程系列的第二十四期,本期介绍反射与注解.反射是"运行时解剖类"的能力,注解是"贴在代码上的标签".上一期 Gson 为什么能自动把你类的字段写进 JSON?这一期揭开谜底.若你已经熟悉 Class 对象、反射常用 API 和自定义注解,可以跳过本期内容.
天外音:想要的图在主贴最后了.
版权声明:图片部分来自于网络,如有侵权,可联系删除.
每期叠甲:
时间仓促,水平有限,文中难免存在缺点和错误,恳请广大开发者和从业者指正.
一、反射:运行时解剖一个类
平时我们写代码,类型都是编译期定死的:new Student() 就得是 Student,s.name 就得是 name.但有一类需求在编译期根本做不到:
- 一个工具要处理任何类.不知道类的名字,等运行了才知道
- 要遍历一个类的所有字段(Gson 就这么干的,第 23 期)
- 框架要在你的代码跑之前检查你写没写注解
这类需求的答案就是反射(Reflection):程序在运行期拿到类的结构信息(类名、字段、方法、构造器、注解),甚至能当场创建对象、调用方法:类对你不再神秘,像被解剖一样看得清清楚楚.
每个类都对应一个 Class 对象.这是反射的入口.拿到它的三种方式:
// 方式一:类名.class
Class c1 = Student.class;
// 方式二:对象的 getClass()
Student s = new Student("小明", 92);
Class c2 = s.getClass();
// 方式三:字符串形式(类名写错编译期也发现不了,运行期才抛 ClassNotFoundException)
Class c3 = Class.forName("Student");
三种方式拿到的是同一个对象.Student 类在 JVM 里只有一个 Class 实例,这点和后面要讲的单例模式(全局只有一个实例)是同一个思想.方式三的字符串形式是框架的最爱:配置文件里写一个类名,程序运行期按名字加载,所以 Spring才能做到"不改代码换实现".
二、解剖三件套:字段、方法、构造器
拿到 Class 后,可以查它的成员:
Class<?> c = Student.class;
// 所有字段(不含继承来的),getName 是字段名,getType 是字段类型
for (java.lang.reflect.Field f : c.getDeclaredFields()) {
System.out.println(f.getName() + " : " + f.getType().getSimpleName());
}
// name : String
// score : double
// 所有方法
for (java.lang.reflect.Method m : c.getDeclaredMethods()) {
System.out.println(m.getName());
}
// 所有构造器
for (java.lang.reflect.Constructor<?> con : c.getDeclaredConstructors()) {
System.out.println(con);
}
对应关系记一张表:
| 成员 | 查询 API | 说明 |
|---|---|---|
| 字段 | getDeclaredFields() | getDeclaredXxx 只管本类声明的,不含父类 |
| 方法 | getDeclaredMethods() | 方法对象能拿到名字、参数、返回类型 |
| 构造器 | getDeclaredConstructors() | 拿到后能 newInstance 创建对象 |
| 注解 | getDeclaredAnnotations() | 反射读取注解,下一节的主角 |
当场创建对象、调用方法:
// 用无参构造器创建对象
Student s = c.getDeclaredConstructor().newInstance();
// 拿方法对象,invoke 调用
java.lang.reflect.Method setName = c.getDeclaredMethod("setName", String.class);
setName.invoke(s, "小红"); // 等价于 s.setName("小红")
invoke 的妙处:方法名可以是字符串:方法名存进配置文件、由用户输入决定调哪个方法,这种"运行时才知道调谁"的场景,只有反射能办.
三、setAccessible:突破 private
第 4 期学的访问控制(public/private),反射面前有例外:字段/方法对象上调用 setAccessible(true),private 也能摸:
java.lang.reflect.Field nameField = c.getDeclaredField("name");
nameField.setAccessible(true); // 解锁 private
nameField.set(s, "小刚"); // 直接改私有字段
System.out.println(nameField.get(s)); // 直接读私有字段
这看起来和"封装"(第 5 期)矛盾?两点说明:
- 封装是约定,反射是例外.
private是给普通代码看的规矩;框架、调试工具需要突破它,所以要留后门(我让你留测试用的接口或者实现,不是让你乱上生产环境的后门!!! !!! !!!) - 能开不意味着该乱开.
setAccessible(true)有性能和安全隐患,业务代码里几乎不该出现;它是框架和工具的特权
顺带说一句,setAccessible 在 Java 17+ 对"非模块内反射"有严格限制(模块系统),跑框架内部用没问题;业务代码直接反射私有成员,在 JDK 17 下可能要加 --add-opens 启动参数.遇到报错先想到这句.
四、注解:贴在代码上的标签
注解(Annotation)你已经见了很多:@Override(第 6 期)、@FunctionalInterface(第 15 期)、@Test(下期 JUnit)、@SerializedName(第 23 期).它们是什么?
注解 = 贴在你的类/方法/字段上的标签.比如 @Override 的意思是"我这个方法覆盖了父类的方法".编译器读了这个标签,发现你根本没覆盖,就报错.
自定义一个注解:
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME) // 关键!表示这个注解在运行期还活着,反射才读得到
public @interface NotNull {
String message() default "字段不能为空"; // 注解的"参数",带默认值
}
语法要点:
@interface定义注解(读作"这是个注解类型")@Retention(RUNTIME)声明存活期:默认注解只活到编译期,反射读不到;标上RUNTIME才保留到运行期- 里面声明的"方法"就是注解的参数,用的时候
@NotNull(message = "名字不能空")传值,不传用默认值
注解本身什么都不做:它只是一张标签.让标签生效的是读者:编译器读@Override,校验器读@NotNull.而运行期注解的唯一读者就是反射:
NotNull a = field.getAnnotation(NotNull.class);
if (a != null) { // 这个字段贴了 @NotNull
Object value = field.get(obj);
if (value == null) {
throw new IllegalStateException(a.message());
}
}
看到没:反射拿到注解 → 读参数 → 决定行为."注解 + 反射"是 Java 框架的地基(JUnit、Spring、MyBatis 全靠这对组合),下一节亲手搭一个试试水.
五、小项目:迷你校验器
写一个注解 + 反射校验器:给任意对象的字段贴上 @NotNull 或 @Range,一个 validate 方法通吃所有类.
文件 NotNull.java:
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
public @interface NotNull {
String message() default "字段不能为空";
}
文件 Range.java:
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
public @interface Range {
int min() default 0;
int max() default 100;
String message() default "数值超出范围";
}
文件 Validator.java:
import java.lang.reflect.Field;
public class Validator {
// 校验任意对象:反射遍历所有字段,按注解检查
public static void validate(Object obj) throws IllegalAccessException {
Class<?> c = obj.getClass();
for (Field f : c.getDeclaredFields()) {
f.setAccessible(true); // 私有字段也查
Object value = f.get(obj);
// 检查 @NotNull
NotNull notNull = f.getAnnotation(NotNull.class);
if (notNull != null && value == null) {
throw new IllegalStateException(notNull.message() + ":" + f.getName());
}
// 检查 @Range(只对数字生效)
Range range = f.getAnnotation(Range.class);
// 语法小补充:if (value instanceof Number num) 表示"value 真的是 Number 时,
// 顺便把它转成 Number 并绑定到变量 num 上",是 JDK 16 起的"模式匹配 instanceof",
// 省掉了旧写法 (Number) value 的强转.后面还会见到它.
if (range != null && value instanceof Number num) {
double d = num.doubleValue();
if (d < range.min() || d > range.max()) {
throw new IllegalStateException(range.message() + ":" + f.getName());
}
}
}
}
}
文件 Main.java:
public class Main {
// 给玩家类贴标签:名字不能空,等级 1~99
static class Player {
@NotNull(message = "名字不能为空")
String name;
@Range(min = 1, max = 99, message = "等级要在 1~99 之间")
int level;
@Range(min = 0, max = 20, message = "血量要在 0~20 之间")
double hp;
}
public static void main(String[] args) throws Exception {
Player p = new Player();
p.name = null;
p.level = 100;
p.hp = 20;
try {
Validator.validate(p);
System.out.println("校验通过");
} catch (IllegalStateException e) {
System.out.println("校验失败: " + e.getMessage());
}
}
}
运行结果:
校验失败: 名字不能为空:name
把 p.name = "Steve" 加上再跑,失败信息变成等级超范围;再把 level 改成 10,全部通过.体会:校验逻辑只写一次,贴了标签的任意类都能用.新增校验规则只是加一个注解,不用改 Validator.这就是框架们(Spring的参数校验)的雏形.
朝花夕拾:第 23 期的"魔法",今天是"反射"
第 23 期我们问:Gson 怎么知道 Player 有哪些字段、每个字段什么类型?答案就在本期:gson.toJson(obj) 内部大概就是:
obj.getClass()拿 Class 对象getDeclaredFields()拿到所有字段(这就是为什么字段类型能自动写进 JSON)- 对每个字段
f.get(obj)取值,再按f.getType()写出对应类型
第 23 期要求"类要有无参构造器"的谜底也在这:gson.fromJson用getDeclaredConstructor().newInstance()创建对象,没有无参构造器就建不出来.
总结
本期学会了"运行时解剖"与"贴标签"两大能力:
- 反射入口:
Class对象三种拿法(类名.class/对象.getClass()/Class.forName) - 解剖三件套:
getDeclaredFields/getDeclaredMethods/getDeclaredConstructors;invoke动态调方法 - setAccessible:突破 private 的后门,框架特权,业务代码慎用
- 注解:
@interface定义、@Retention(RUNTIME)保活、参数带默认值;注解只是标签,反射读取才生效 - 迷你校验器小项目:
@NotNull/@Range+ 一个通吃所有类的校验器 - 反射绝对不可以滥用,会造成严重的bug和性能问题,某些AI在写我的世界模组就很喜欢滥用.
下一期我们学习单元测试 JUnit:今天学的注解,马上要被 JUnit 用来"发现你的测试方法".
哦天哪,这个图包我好不容易从143张找到几张能过审的