Java 面向对象:继承、多态、接口

9 阅读7分钟

Java 面向对象:继承、多态、接口与 equals/hashCode 契约

引言

面向对象(OOP)是 Java 的组织代码的基本方式。三大特性——封装、继承、多态——人人会背,但真正容易出错的细节在别处:父类子类的初始化顺序、接口和抽象类怎么选、equals 和 hashCode 为什么必须一起重写、什么时候该用组合代替继承。

这篇文章把这些"会背但容易用错"的知识点一次讲清楚,最后介绍 Java 16 正式化的 record——它正在取代大量手写的数据类。


一、三大特性速览

特性机制解决的问题
封装private 字段 + public 方法隐藏内部实现,控制访问
继承extends代码复用,建立 is-a 关系
多态重写 + 向上转型同一接口,不同实现;解耦调用方和实现方

多态是 OOP 里最有价值的部分。依赖抽象而不是具体实现,是 Spring 依赖注入、策略模式等一切设计的基础:

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 时也靠它:

1. 父类静态成员/静态块(按书写顺序)
2. 子类静态成员/静态块
3. 父类实例成员/实例块
4. 父类构造器
5. 子类实例成员/实例块
6. 子类构造器
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(静态块只执行一次)

一个高频坑:构造器里调用可被子类重写的方法。此时子类字段还没初始化,重写方法读到的是默认值:

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
发生位置同一个类中父子类之间
方法签名方法名相同,参数列表不同方法名、参数列表相同
返回值无要求相同或是子类(协变返回)
异常无要求不能抛出更大的受检异常
访问权限无要求不能比父类更严格
绑定时机编译期(静态分派)运行期(动态分派)
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 classinterface
继承/实现单继承(extends 一个)多实现(implements 多个)
构造器有无
成员变量任意只能 public static final
方法实现可以有具体方法Java 8 起可有 default/static 方法
设计语义is-a,"是什么"can-do,"能做什么"

接口的演进:

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 方法冲突时,实现类必须显式重写:

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 未必相等。

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 的后果:

Set<User> set = new HashSet<>();
set.add(new User(1L, "tom"));
System.out.println(set.contains(new User(1L, "tom"))); // false!
// 两个对象 equals 相等,但默认 hashCode 不同,落在了不同的桶里

实践规则:

  1. 重写 equals 必须同时重写 hashCode,用 Objects.equals + Objects.hash 一把梭
  2. 作为 Map key 的对象应当不可变(字段 final),否则改 key 的字段后永远 get 不到
  3. IDE(IntelliJ:Alt+Insert → equals/hashCode)生成的实现可以直接用

六、record:数据类终结者(Java 16+)

只为承载数据的类(DTO、VO、配置对象),手写 getter/equals/hashCode/toString 纯属体力活。record 一行搞定:

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 条的建议:优先用组合(委托)。

// ❌ 继承第三方类,依赖其内部实现细节
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

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 里直接强转

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。


参考资料


作者:eralong
个人网站:eralong.com