Java 面向对象:继承、多态、接口与 equals/hashCode 契约
引言
面向对象(OOP)是 Java 的组织代码的基本方式。三大特性------封装、继承、多态------人人会背,但真正容易出错的细节在别处:父类子类的初始化顺序、接口和抽象类怎么选、equals 和 hashCode 为什么必须一起重写、什么时候该用组合代替继承。
这篇文章把这些"会背但容易用错"的知识点一次讲清楚,最后介绍 Java 16 正式化的 record------它正在取代大量手写的数据类。
一、三大特性速览
| 特性 | 机制 | 解决的问题 |
|---|---|---|
| 封装 | private 字段 + public 方法 | 隐藏内部实现,控制访问 |
| 继承 | extends | 代码复用,建立 is-a 关系 |
| 多态 | 重写 + 向上转型 | 同一接口,不同实现;解耦调用方和实现方 |
多态是 OOP 里最有价值的部分。依赖抽象而不是具体实现,是 Spring 依赖注入、策略模式等一切设计的基础:
java
interface PaymentService {
void pay(long amountFen);
}
class AlipayService implements PaymentService {
public void pay(long amountFen) { System.out.println("支付宝支付 " + amountFen); }
}
class WechatPayService implements PaymentService {
public void pay(long amountFen) { System.out.println("微信支付 " + amountFen); }
}
// 调用方只依赖接口,换实现不改调用代码
class CheckoutController {
private final PaymentService paymentService;
CheckoutController(PaymentService paymentService) {
this.paymentService = paymentService;
}
void checkout() { paymentService.pay(9900); }
}
二、初始化顺序
父类子类的初始化顺序是固定规则,面试常考,debug 字段莫名是 null 时也靠它:
markdown
1. 父类静态成员/静态块(按书写顺序)
2. 子类静态成员/静态块
3. 父类实例成员/实例块
4. 父类构造器
5. 子类实例成员/实例块
6. 子类构造器
java
class Parent {
static { System.out.println("1 父类静态块"); }
{ System.out.println("3 父类实例块"); }
Parent() { System.out.println("4 父类构造器"); }
}
class Child extends Parent {
static { System.out.println("2 子类静态块"); }
{ System.out.println("5 子类实例块"); }
Child() { System.out.println("6 子类构造器"); }
}
// new Child() 输出:1 2 3 4 5 6
// 再 new Child() 输出:3 4 5 6(静态块只执行一次)
一个高频坑:构造器里调用可被子类重写的方法。此时子类字段还没初始化,重写方法读到的是默认值:
java
abstract class Base {
Base() { init(); } // 危险:此时子类字段尚未初始化
abstract void init();
}
class Sub extends Base {
private final String config = loadConfig();
@Override
void init() {
System.out.println(config); // null!config 还没赋值
}
}
规则:构造器里只调用 private/final 方法,或直接传参初始化。
三、重载 vs 重写
| 对比项 | 重载 Overload | 重写 Override |
|---|---|---|
| 发生位置 | 同一个类中 | 父子类之间 |
| 方法签名 | 方法名相同,参数列表不同 | 方法名、参数列表相同 |
| 返回值 | 无要求 | 相同或是子类(协变返回) |
| 异常 | 无要求 | 不能抛出更大的受检异常 |
| 访问权限 | 无要求 | 不能比父类更严格 |
| 绑定时机 | 编译期(静态分派) | 运行期(动态分派) |
java
class Animal {
void speak() { System.out.println("..."); }
}
class Dog extends Animal {
@Override
void speak() { System.out.println("汪汪"); }
}
Animal a = new Dog();
a.speak(); // 汪汪 ------ 编译看左边(Animal),运行看右边(Dog)
@Override 注解强烈建议永远写上:它能在你方法名拼错时直接编译报错,否则会变成"无意重载"的隐蔽 bug。
四、抽象类 vs 接口
| 对比项 | 抽象类 | 接口 |
|---|---|---|
| 关键字 | abstract class | interface |
| 继承/实现 | 单继承(extends 一个) | 多实现(implements 多个) |
| 构造器 | 有 | 无 |
| 成员变量 | 任意 | 只能 public static final |
| 方法实现 | 可以有具体方法 | Java 8 起可有 default/static 方法 |
| 设计语义 | is-a,"是什么" | can-do,"能做什么" |
接口的演进:
java
public interface OrderRepository {
Order findById(Long id); // 抽象方法
// Java 8:default 方法,实现类可以不重写
default boolean exists(Long id) {
return findById(id) != null;
}
// Java 8:静态方法
static OrderRepository empty() {
return id -> null;
}
}
选择原则:
- 需要共享状态(字段)或构造逻辑 → 抽象类
- 定义能力/契约,或需要多实现 → 接口
- 现代 Java 的默认选择是接口,配合 default 方法提供通用实现
多接口 default 方法冲突时,实现类必须显式重写:
java
interface A { default void hi() { System.out.println("A"); } }
interface B { default void hi() { System.out.println("B"); } }
class C implements A, B {
@Override
public void hi() {
A.super.hi(); // 显式选择调用 A 的版本
}
}
五、equals 与 hashCode 契约
这是 OOP 里最重要的契约,违反它 HashMap/HashSet 就会"丢数据":
equals 相等的两个对象,hashCode 必须相等;hashCode 相等,equals 未必相等。
java
class User {
private final Long id;
private final String name;
User(Long id, String name) {
this.id = id;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User user)) return false; // Java 16 模式匹配
return Objects.equals(id, user.id) && Objects.equals(name, user.name);
}
@Override
public int hashCode() {
return Objects.hash(id, name);
}
}
只重写 equals 不重写 hashCode 的后果:
java
Set<User> set = new HashSet<>();
set.add(new User(1L, "tom"));
System.out.println(set.contains(new User(1L, "tom"))); // false!
// 两个对象 equals 相等,但默认 hashCode 不同,落在了不同的桶里
实践规则:
- 重写 equals 必须同时重写 hashCode,用
Objects.equals+Objects.hash一把梭 - 作为 Map key 的对象应当不可变(字段 final),否则改 key 的字段后永远 get 不到
- IDE(IntelliJ:Alt+Insert → equals/hashCode)生成的实现可以直接用
六、record:数据类终结者(Java 16+)
只为承载数据的类(DTO、VO、配置对象),手写 getter/equals/hashCode/toString 纯属体力活。record 一行搞定:
java
public record Order(Long id, String sku, int quantity, long amountFen) {
// 紧凑构造器:做参数校验
public Order {
if (quantity <= 0) throw new IllegalArgumentException("quantity");
if (amountFen < 0) throw new IllegalArgumentException("amountFen");
}
}
Order o = new Order(1L, "SKU-001", 2, 9900);
System.out.println(o.sku()); // SKU-001(注意是 sku() 不是 getSku())
System.out.println(o); // Order[id=1, sku=SKU-001, quantity=2, amountFen=9900]
// equals/hashCode 自动按所有字段生成
record 的特性:
- 隐式 final 类,字段隐式 private final ------ 天然不可变
- 自动实现 equals/hashCode/toString
- 可以实现接口,但不能继承类(它隐式继承 Record)
- 可以添加静态方法、实例方法和紧凑构造器
适用场景:API 响应体、数据库查询投影、方法多返回值、Map 的复合 key。Spring Boot 3 / Jackson / MyBatis-Plus 新版本都已完整支持 record。
七、组合优于继承
继承是强耦合:父类改实现,所有子类跟着变;子类能访问父类 protected 细节,破坏封装。《Effective Java》第 18 条的建议:优先用组合(委托)。
java
// ❌ 继承第三方类,依赖其内部实现细节
class CountingHashSet<E> extends HashSet<E> {
private int addCount = 0;
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
// HashSet.addAll 内部如果改为调用 add,计数就翻倍了------这是实现细节,随时可能变
}
// ✅ 组合 + 委托,只依赖公开 API
class CountingSet<E> implements Set<E> {
private final Set<E> delegate = new HashSet<>();
private int addCount = 0;
@Override
public boolean add(E e) {
addCount++;
return delegate.add(e);
}
// 其余方法委托给 delegate(实际项目可用 Guava ForwardingSet)
}
什么时候用继承:明确的 is-a 关系 + 你控制父类代码(同一模块内)+ 父类专门为继承设计(文档说明了扩展点)。
八、常见陷阱
8.1 重写 equals 忘了 hashCode
后果见第五节:HashSet 重复添加、HashMap get 返回 null。永远成对重写。
8.2 可变对象做 Map 的 key
java
List<String> key = new ArrayList<>(List.of("a"));
Map<List<String>, String> map = new HashMap<>();
map.put(key, "v");
key.add("b"); // 修改 key,hashCode 变了
map.get(key); // null!
key 用 String、Integer 或 record 这类不可变对象。
8.3 equals 里直接强转
java
public boolean equals(Object o) {
User other = (User) o; // 传入其他类型直接 ClassCastException
}
正确姿势:先 instanceof 判断(Java 16 起 o instanceof User user 一步到位)。
8.4 构造器调用可重写方法
见第二节,子类字段未初始化导致 NPE 或读到默认值。
8.5 误以为 private 方法不能被"覆盖"
private 方法对子类不可见,子类同名方法只是恰好重名,不是重写,也没有多态。父子类出现同名 private 方法时行为容易让人困惑------直接改名。
8.6 用继承实现代码复用
"我需要复用那三个方法"不是继承的理由。用组合、工具类或 default 方法。继承只为 is-a 语义服务。
九、总结
| 知识点 | 核心结论 |
|---|---|
| 初始化顺序 | 静态→实例→构造,父先于子 |
| 重载 vs 重写 | 重载编译期,重写运行期 |
| 接口 vs 抽象类 | 默认选接口,共享状态才用抽象类 |
| equals/hashCode | 成对重写,Objects.equals + Objects.hash |
| record | 数据类首选,天然不可变 |
| 组合 vs 继承 | 组合优先,继承需要 is-a 语义 |
OOP 的细节决定了代码的可维护性。把 equals 契约和初始化顺序刻进肌肉记忆,能避开一大类隐蔽 bug。
参考资料
- Effective Java 3rd Edition - Joshua Bloch
- Java Tutorial: Interfaces and Inheritance
- JEP 395: Records
作者 :eralong
个人网站 :eralong.com