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

相关推荐
皮皮林5511 分钟前
Java 导出 Excel 利器:JXLS
后端
吃饱了得干活2 分钟前
从 LIKE 查询到毫秒级搜索:Elasticsearch 核心原理与 Java 实战
后端·elasticsearch
小坏讲微服务1 小时前
刚发布一个月的JDK 27,为什么我建议你立刻升级?
java·开发语言·spring boot·spring·架构·求职招聘·springboot4
kunicoding1 小时前
多线程编程核心概念详解:从线程基础到并发安全
java·jvm·安全
snow@li2 小时前
SpringBoot:核心注解全景梳理 / @xxx 叫“注解”(Annotation)
java·spring
devilnumber2 小时前
避坑_IDEA 提取方法(Extract Method)工具
java·ide·intellij-idea
鬼手点金2 小时前
Agent 的学习机制:后训练、上下文学习与外部化学习
java·人工智能·git·学习·自动化·nanogpt
小陈不好吃2 小时前
equals 与 hashCode 的契约关系:从 HashMap 到 Lombok 注解
java·hashmap·lombok
tianbin9112 小时前
大模型训练与推理:API中转站在企业AI落地中的隐藏价值
大数据·后端·ai
xiaogai_gai2 小时前
金蝶收料通知单查询策略实战教程:从源系统拉取到中间层落地的完整路径
java·前端·数据库