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 的代码里碰不到,哪怕说的是同一个对象。
覆盖方法
子类写一个同名同参数的方法,就把父类那版替换掉:
@Overridepublic 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; // ❌ ClassCastExceptione2 那种情况就是 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 类里,是个普通方法,代码只留了方法本身:
@Overridepublic 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 也是 Employeem.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 讲集合时展开)。
@Overridepublic int hashCode() { return Objects.hash(name, salary);}toString 方法
返回对象的字符串表示。println(对象)、字符串拼接都会自动调用它,调试时最常用:
@Overridepublic 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 → Integerint 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 的 ClassField 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();// ① 拿到 ClassMethod 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和强制转换堆多了,通常说明设计出了问题——该把差异做成方法,让子类自己去实现。 - 优先组合。能靠「装一个对象当字段」解决的,就不要用继承。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!





