03继承与反射

5371 字
27 分钟
03继承与反射

第 5 章 继承#

5.1 类、超类和子类#

4.3 写的 Employee 已经是个完整的类。现在假设公司里还有一类人——经理。经理也是雇员:同样有姓名、薪水、入职日期,只是多一项奖金。

最笨的办法是把 Employee 的字段和方法在 Manager 里抄一遍。继承解决的正是这件事——公共部分留在父类,子类只写差异:

public class Manager extends Employee {
private double bonus; // Manager 独有的字段
public void setBonus(double b) { bonus = b; }
}

extends 后面只能跟一个类——Java 是单继承。

定义子类#

子类自动拥有父类的全部字段和方法,一个都不用重写。这里的「拥有」是数据层面——Manager 对象里确实含着 name、salary、hireDay 这三份。

但能不能访问是另一回事,取决于成员用的修饰符。4.3 里我们把 Employee 的字段设成了 private,于是在 Manager 的代码里读写 name 都编译不过——不只是不能写,读也不行,javac 报的是 cannot find symbol(没有这个名字)。

四个访问修饰符的完整规则:

修饰符本类代码同包其他类不同包的子类不同包非子类
private✅❌❌❌
默认(不写)✅✅❌❌
protected✅✅✅❌
public✅✅✅✅

private 那一行就是 Employee 字段所处的位置——连子类(第三列)都不行。

注意这张表管的是能不能碰,它不区分「只读」和「可写」:碰得到就既能读也能写。想只读要另想办法——字段加 final(构造后定死),或者干脆不给 setter、只给 getter(4.3.8 讲过)。

还有一条比表更根本的:访问权限跟「代码写在哪个类里」走,不跟对象走。 name 在 Employee 的代码里能碰,在 Manager 的代码里碰不到,哪怕说的是同一个对象。

覆盖方法#

子类写一个同名同参数的方法,就把父类那版替换掉:

@Override
public double getSalary() {
return super.getSalary() + bonus; // 先拿父类算的那份,再加奖金
}

super.getSalary() 调的是父类那一版的实现。this 是「这个对象」,super 是「父类的实现」,别混。

@Override 是注解(annotation),不是注释——注释给人看、编译器完全忽略;注解是给编译器看的,编译器会据此检查。它不是必需的,但强烈建议写:不写的话,你以为覆盖了,其实是新加了一个方法,要跑起来才发现。

这就是 4.3.6 讲的 fail fast,只是挪到了编译期——让错误在最早、最便宜的环节暴露。附带的好处是重构保险:父类一旦改了方法名或参数,所有写了 @Override 的子类会立刻全部报错,自动把该跟着改的地方列出来。

vs C:C 没有覆盖机制,也没有虚函数表,「同一个调用走不同实现」只能靠函数指针手工搭。

子类构造器#

子类构造器的第一行必须调用父类构造器:

public Manager(String name, double salary, int year, int month, int day) {
super(name, salary, year, month, day); // 第一行
bonus = 0;
}

写 super(...) 就调父类对应的那个;一行都不写,编译器自动插一句无参的 super()。所以父类只有有参构造器时,子类不手写 super(...) 就编译不过。

为什么必须这样:每个 Manager 对象里都含着一份「Employee 的部分」,这部分只能由 Employee 自己的构造器初始化——字段是 private,子类碰不到;就算碰得到,父类的初始化逻辑也是封在它自己构造器里的。

继承层次结构#

一个父类可以有多个子类,层层往下构成一棵树。Employee 是 Manager 的超类(superclass),Manager 是子类(subclass),同一条链往上的都叫祖先。

多态#

父类类型的变量可以指向子类对象:

Employee e = new Manager("张三", 50000, 2000, 1, 1);
e.getSalary(); // 跑的是 Manager 那一版

跑哪个实现由对象的真实类型决定,不由变量的类型决定——这就是多态。

两种声明摆一起,能看清各自管什么:

Manager m = new Manager("张三", 50000, 2000, 1, 1);
Employee e = new Manager("张三", 50000, 2000, 1, 1);

两者指向同一个对象,行为只差一处:

  • getSalary()——Manager 和 Employee 里都有:m 和 e 完全一样,都走 Manager 那一版。
  • setBonus()——只有 Manager 里有:m 能调,e 编译不过。

所以规则准确说是:父类变量看不到「子类新增」的成员。关键在「子类新增」,不在 setBonus 这个名字——如果哪天 Employee 里也声明了 setBonus,e.setBonus() 立刻就能编过,而且走的仍是 Manager 那一版。

一句话:对象的类型决定「调的是哪一版」,变量的类型决定「你能调什么」。 所以两种声明唯一的差别就是:Employee e 能调的方法集更小,少了子类新增的那部分。底层对象没变,要用得先转型回来。

好处是一个只认 Employee 的方法能自动有限的处理所有子类,将来新增子类也不用改它:

public static void printSalary(Employee e) { ... }

反过来不行。子类变量不能指向父类对象——父类对象里没有子类特有的成员(这里就是 bonus),拿到了也使不上:

Manager m = new Employee(...); // ❌ 编译错误

理解方法调用#

多态靠两步分开走实现:

阶段看什么决定什么
编译期变量的类型这方法能不能调
运行期对象的真实类型具体跑哪一个实现
Employee e = new Manager(...);
e.getSalary();
// 编译期:Employee 里有 getSalary,放行
// 运行期:e 实际是 Manager → 跑 Manager.getSalary()

这叫动态绑定。private / static / final 方法不走这套——它们要么不能被覆盖、要么和对象无关,编译器直接静态绑定,省掉运行时查找。

阻止继承#

final 加在类上,谁都不能继承它;加在方法上,子类不能覆盖它。

强制类型转换#

把父类引用转回子类,要写 (Manager) e。

编译期一律放行——编译器只知道 e 的变量类型是 Employee,看不见里面装的到底是哪个子类的对象。所以这次转型成不成,编译器不表态,留到运行期用对象的真实类型来判:

Employee e1 = new Manager(...);
Employee e2 = new Employee(...);
(Manager) e1; // ✅ 运行期通过,它确实是 Manager
(Manager) e2; // ❌ ClassCastException

e2 那种情况就是 5.1.5 说的「父类对象里没有子类特有的成员」——转型相当于向编译器承诺「里面有 bonus」,兑现不了就当场翻车。

所以向下转型不能盲转,用之前得先确认对象的真实类型。这正是下一小节 instanceof 要解决的问题。

vs C:C 的指针强转只是把这块内存重新解释一遍——转错了不报错也不崩,只是悄悄读出垃圾;Java 的引用转型会实地检查,最多抛异常,不会让你读坏内存。

instanceof 模式匹配#

一个典型用法是这样:

public static void giveBonus(Employee e, double amount) {
if (e instanceof Manager m) { // 判断 + 转型,一步完成
m.setBonus(amount); // m 已经是 Manager 类型,不用再强转
}
// 不是 Manager 就什么都不做
}

instanceof 判断通过的同时,把 e 转成 Manager 并赋给 m(Java 16+)。老写法要两行:

if (e instanceof Manager) {
Manager m = (Manager) e;
m.setBonus(amount);
}

除了少写一行,模式匹配还把 m 的作用域限制在 if 块内——出了块这个名字就没了,所以你不可能拿到一个没验证过的引用。

受保护访问#

protected 介于 private 和 public 之间——就是 5.1.1 那张表里的第三行:子类(哪怕在不同包)和同包都能访问。

它是专门为继承准备的。想让子类直接读写某个字段或调用某个方法,又不想让包外的无关类碰,就用 protected。

实际写代码时能用 private 就用 private,protected 只留给明确需要被子类改的成员。

5.2 Object:所有类的超类#

前提一句:所有类都隐式继承 Object——没写 extends 的类,也自动是 Object 的子类。

Object 类型的变量#

Object 类型的引用能指向任何对象(它是一切类的祖先),但拿到手只能当 Object 用,要用回原类型得转型。

equals 方法#

用来比内容:a.equals(b)。这条补上 4.1 留下的口子——== 比的是身份,比内容要用 equals。

Object 默认的 equals 比的就是身份(和 == 一样),所以想比内容必须自己重写。

相等测试与继承#

Object 给的 equals 只比身份,想比内容就得自己重写——这一小节就是教你怎么写对。

用在哪里:想让两个「内容相同」的对象被判为相等的时候。典型是放进 HashSet 去重、或当 HashMap 的键(Ch9 讲集合时会正面碰到),以及业务上判断「这两条是不是同一份数据」。

不是每个类都要写——不写就沿用 Object 那版(比身份),只有需要比内容的类才重写。下面这段写在 Employee 类里,是个普通方法,代码只留了方法本身:

@Override
public boolean equals(Object other) {
if (this == other) return true; // 同一个对象,直接真
if (!(other instanceof Employee)) return false; // 类型不对(含 null),直接假
Employee o = (Employee) other; // 类型确认过了,转型安全
return name.equals(o.name) // 字段逐个比
&& salary == o.salary;
}

逐个交代:

  • 形参必须写 Object,不能写 Employee。写成 Employee 就变成重载而不是覆盖了,@Override 会立刻报错——正是 5.1.2 说的那个保险在起作用。
  • this == other 是快速通道:同一个对象,内容必然相同,后面全都不用查了。
  • !(other instanceof Employee) 一句挡掉两种坏情况:类型不对,以及 null(null instanceof X 恒为假)。
  • 最后一行 name 用 equals、salary 用 ==——因为 name 是 String(对象,比内容得用 equals),salary 是 double(基本类型,== 就是比数值)。

最后是一个进阶边界,不了解也不影响正常使用——如果子类也重写了 equals,而且把条件收得更严,就会不对称:

// Employee.equals: other instanceof Employee → 比 name、salary
// Manager.equals: other instanceof Manager → 再多比一个 bonus
e.equals(m); // 真:Manager 也是 Employee
m.equals(e); // 假:e 不是 Manager

同两个对象,两条 equals 给出相反答案。

修法是父类里改用 getClass() 判断:

if (getClass() != other.getClass()) return false;

getClass() 返回的是一个 Class 对象——它代表这个对象运行时所属的那个类(既不是 Object,也不是 Employee 实例)。同一个类在整个 JVM 里只有一个这样的对象,所以两个 Class 用 != 比,就等于在问「这两个对象是同一个类吗」。Class 是反射的入口,5.9 会正面讲,这里只要知道它「代表所属的类」就够了。

这样两边都要求「类完全相同」,子类不算,不对称就不会出现——代价是父类和子类不再互相相等,但这本来就是你想要的。

hashCode 方法#

和 equals 必须配对重写:两个 equals 相等的对象,必须有相同的 hashCode。只改一个,塞进 HashMap / HashSet 就会出错(Ch9 讲集合时展开)。

@Override
public int hashCode() {
return Objects.hash(name, salary);
}

toString 方法#

返回对象的字符串表示。println(对象)、字符串拼接都会自动调用它,调试时最常用:

@Override
public String toString() {
return "Employee[name=" + name + ", salary=" + salary + "]";
}

为什么这里必须重写这三个成员? 因为调它们的不是你,是 JDK——println(e) 内部调 toString(),set.add(e) 内部调 hashCode() 和 equals()。这些通用代码只知道「拿到的是个 Object」,唯一敢调的就是 Object 上定义的方法。所以只有覆盖它们,库才能按你的语义工作;自己另起名(printMe())只有你自己会调。

不覆盖也不报错,只是库会用错默认语义:打印成 Employee@1b6d3586、内容相同的对象被判为不等(去重失效)、存进 HashMap 找不回来。

vs C:C 的对象没有公共祖先,比较、打印都得各自实现;Java 靠 Object 把这三件事统一到了所有类型上。

5.3 泛型数组列表#

ArrayList 就是会自动扩容的数组。

声明数组列表#

ArrayList<Employee> staff = new ArrayList<>(); // 菱形语法:右边省略类型,编译器从左边推断

尖括号里写「这个列表装什么类型」。(泛型的正式内容在 Ch8,这里先认得写法。)不指定初始容量也能用,满了会自动扩。

访问数组列表元素#

staff.add(e); // 追加到末尾
staff.get(i); // 取第 i 个
staff.set(i, e); // 替换第 i 个
staff.remove(i); // 删掉第 i 个
staff.size(); // 元素个数

注意取长度的写法三种类型都不同:数组是 arr.length(字段,没有括号),String 是 s.length(),ArrayList 是 list.size()——最后一个的名字是 size,不是 length。

vs C:C 的数组长度写死,要动态数组得自己 malloc / realloc / free;ArrayList 把这套活儿包了。

类型化与原始数组列表的兼容性#

老代码里的裸 ArrayList(不带尖括号)和类型化的混用会出警告。了解即可,自己写别用裸的。

5.4 对象包装器与自动装箱#

每个基本类型都有一个对应的包装器类:int → Integer、double → Double、char → Character,等等。作用是把基本类型包成对象,好塞进集合这类「只能装对象」的地方:

ArrayList<Integer> list = new ArrayList<>();
//ArrayList<int> 编译不过
list.add(3); // 自动装箱:int 3 → Integer
int n = list.get(0); // 自动拆箱:Integer → int

这层「包起来」和「拆开」由编译器自动补上,叫自动装箱 / 拆箱。

一个坑:包装器是对象,所以两个包装器用 == 比的是身份(4.1 那条),比内容必须用 equals。

vs C:C 里基本类型就是基本类型,没有「包成对象」这回事。

5.5 参数个数可变的方法#

printf(String fmt, Object... args) 里的 ... 是可变参数——调用时可以传任意多个实参:

public static double max(double... values) {
double largest = Double.NEGATIVE_INFINITY;
for (double v : values) if (v > largest) largest = v;
return largest;
}
max(3.1, 4.5); // 传几个都行
max(3.1, 4.5, 2.0);

编译器把它当数组处理——double... values 就是 double[] values,方法体里按数组用(上面那个 for-each 就是)。两者唯一的差别在调用端:写成 double... 才能 max(3.1, 4.5) 散着传;写成 double[] 就只能 max(new double[]{3.1, 4.5})。

一个方法最多一个可变参数,而且必须放在参数列表最后。

别和 main 搞混:main(String[] args) 声明的是数组,命令行上 java MyApp foo bar 看着像散着传,其实是 JVM 替你打包的,跟 ... 无关。

5.6 抽象类#

抽象方法只写声明、不写方法体:

public abstract class Person {
private String name;
public Person(String name) { this.name = name; }
public String getName() { return name; }
public abstract double getSalary(); // 只有声明,没有实现
}
  • 类里只要有抽象方法,这个类就必须声明成 abstract
  • 抽象类不能 new
  • 除此之外它和普通类一样:可以有字段、构造器、普通方法

它的关键不在「分开」,而在强制:抽象方法等于父类声明「这件事必须有,但我不规定怎么做」,编译器替你盯着——任何具体子类都必须实现它,否则编译不过。普通继承靠子类自觉(忘了覆盖也能编过),抽象方法把这种「忘了」变成编译错误——还是 4.3.6 那个 fail fast。

vs C:C 没有抽象类,想表达「这个函数必须由使用者自己实现」只能靠文档约定,编译器帮不上忙。

5.7 枚举类#

它解决的是一组「固定取值」没有类型约束的问题。 比如尺寸只有小/中/大,若用 public static final int 常量来表示,size = 999 编译器一声不吭——谁都能造出非法值。enum 把范围锁死:

public enum Size { SMALL, MEDIUM, LARGE }

取值只能是列出来的这几个,别的编译不过:

Size s = Size.MEDIUM; // ✅
Size t = "MEDIUM"; // ❌ 编译错误,它不是字符串
Size u = 999; // ❌ 编译错误

它是真正的类(编译后就是一个 class),所以能有字段、方法、构造器——不只是「一组带名字的常量」。给枚举加成员怎么写,属于进阶用法,这里不展开。

vs C 的 enum:C 的枚举本质就是 int 常量,随便拿个整数赋值都能过编译;Java 的枚举值不能乱造,类型检查在编译期就兜住了。

5.8 密封类#

sealed 限定哪些类可以继承我,名单外的编译不过:

public sealed class Shape permits Circle, Square { }

硬规矩:sealed 类的直接子类必须三选一表态,少一个就编译不过:

子类怎么写含义
final到此为止,谁都不能再继承我
sealed我也限制,名单写在我的 permits 里
non-sealed解封——从我往后恢复成普通类,随便继承
什么都不写❌ 编译错误
public final class Circle extends Shape { } // 到此为止
public non-sealed class Square extends Shape { } // 想继承随你

为什么要表态:sealed 是「继承被我用名单管住」,可管到哪一层为止,得由每一环自己交代——不然编译器不知道你是想继续封着,还是放开。

Java 17 引入。它和 5.1 的 final 是两个极端:final 谁都不许继承,sealed 只许名单内的继承。

5.9 反射#

反射 = 让程序在运行时「看自己」:拿到一个类,能查出它有哪些字段、方法、构造器,还能当场调用。

先说定位:自己动手写反射,日常极少。它是框架的地基——Spring 的依赖注入、Jackson 转 JSON、JUnit 找 @Test,背后全靠它。所以你通常是使用者而不是作者。但不懂它就读不懂框架报错(NoSuchMethodException 这类),也解释不了「为什么很多框架要求无参构造器」这种日常坑。这一节的目标是认得它、读得懂报错,不是会手写。

Class 类#

每个类都有一个 Class 对象,它是反射的入口:

Class<?> c1 = e.getClass(); // 从对象拿
Class<?> c2 = Manager.class; // 直接写
Class<?> c3 = Class.forName("com.example.Manager"); // 从类名拿

尖括号里的 ? 是个通配符,读作「任意类型」——Class<?> 的意思就是「某个类的 Class 对象,具体是哪个我不点名」。

它严格说不是必须的:写成光秃秃的 Class 也能编译,只是那叫「原始类型」,编译器 / IDE 会提示不规范:

Class<?> c1 = e.getClass(); // ✅ 规范
Class c2 = e.getClass(); // 能编译,但「原始类型」,不推荐

之所以用 ? 而不填一个具体类型(比如 Class<Manager>):e.getClass() 返回的到底是哪个类,写代码的时候不知道,只有运行时才知道——「是某个类、但说不定是哪个」,正是 ? 的用场。

声明受查异常 / 资源#

⚠️ 这节其实是异常的内容(Ch7 讲),书里放在这里只是因为反射 API 会抛受查异常。这里只记一句:受查异常必须在方法上声明,或用 try 包住。

利用反射分析类的能力#

拿到 Class 对象后,能查出这个类的成员:

Class<?> c = Manager.class; // 有了 Class 对象就能开始查
c.getName(); // 类名
c.getDeclaredFields(); // 所有字段
c.getDeclaredMethods(); // 所有方法
c.getDeclaredConstructors(); // 所有构造器

使用反射在运行时分析对象#

拿到字段之后,能读出某个对象当前的值——包括 private 的:

Employee e = new Employee("张三", 50000, 2000, 1, 1);
Class<?> c = e.getClass(); // ① 拿到 Employee 的 Class
Field f = c.getDeclaredField("salary"); // ② 找到名为 salary 的字段
f.setAccessible(true); // ③ salary 是 private,先开门
double s = f.getDouble(e); // ④ 读出这个对象的该字段值

setAccessible(true) 是关键一步——它把 private 那道门打开了,所以反射能读到普通代码碰不到的字段。5.1.1 说连子类都碰不到父类的 private,反射却可以——这正是它危险的地方。

使用反射编写泛型数组代码#

Array.newInstance(...) 这类,实用性低,扫一眼即可。

调用任意方法和构造器#

Manager boss = new Manager("张三", 50000, 2000, 1, 1);
Class<?> c = boss.getClass();// ① 拿到 Class
Method setBonus = c.getDeclaredMethod("setBonus", double.class);
// ② 找方法:名字 + 参数类型
setBonus.invoke(boss, 1000.0);
// ③ 在 boss 上调用,实参 1000.0

调用构造器是同一套,只是把「找方法」换成 getDeclaredConstructor(...)、「调用」换成 newInstance(...)。

代价:绕过了编译期检查,写错要到运行期才炸,而且慢。日常业务代码很少直接用,用得最多的是框架。

vs C:C 编译后类型信息基本被抹掉,运行时拿不到「这个结构体有哪些成员」;Java 的 class 文件里保留了完整类型信息,所以能反射。

5.10 继承的设计技巧#

  • 公共的字段和方法往父类提,子类只留差异。
  • 不要用继承表达「has-a」。Order 有 Customer,那是聚合(4.1 讲过),写成 Order extends Customer 就错了。判断法:B 是不是「一种」A,是才用继承。
  • 覆盖方法时不要改变约定的行为。父类的 getSalary 是「返回薪水」,子类不能把它改成「发邮件」。
  • 多用多态、少用类型判断。instanceof 和强制转换堆多了,通常说明设计出了问题——该把差异做成方法,让子类自己去实现。
  • 优先组合。能靠「装一个对象当字段」解决的,就不要用继承。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
03继承与反射
https://hecloud.top/posts/tech/java-se/03继承与反射/
作者
贺小云
发布于
2026-10-14
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
贺小云
热爱技术与折腾的博客 Dancing with uncertainty. 与不确定性共舞
公告
出于成本考虑,贺云已全面转型Serverless架构
分类
标签
碎碎念
站点统计
文章
91
分类
5
标签
44
总字数
103,192
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Cloudflare Pages
博客版本
Firefly v6.16.5
文章许可
CC BY-NC-SA 4.0