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

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 不同,落在了不同的桶里

实践规则

  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 一行搞定:

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。


参考资料


作者 :eralong

个人网站eralong.com

相关推荐
哦虎!1 小时前
【数据库】事务
java·数据库·mysql
CDN3601 小时前
流媒体加速实践:出海东南亚短视频点播频繁缓冲,HLS 分片与 Nginx 配置调优
java·网络·nginx·流媒体加速
苏三说技术2 小时前
DeepSeek Harness必装的10个插件
后端
程序员黑豆2 小时前
Java中的null与NullPointerException完全指南:安全处理、实战排查与面试题
java·前端·ai编程
阿弱2 小时前
graph-core 策略合并机制的设计与用法
后端·agent
fatcoder2 小时前
玩转Docker 08 — 实战:容器化真实后端并编排
后端·docker·容器
神奇小汤圆2 小时前
从原始的CRUD 到高并发架构:关于秒杀系统的问题拆解与推演
后端
魔兽大山哥2 小时前
NL2SQL 最怕越权:我在执行层叠了好几层 SQL 闸门
后端
晴天162 小时前
Electron面试题-Day19
java·javascript·electron