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

相关推荐
白山编程大哥5 小时前
Java 迭代器接口详解:从 Iterator 到 ListIterator 的完整指南
java·开发语言·windows
热心网友俣先生5 小时前
2026国赛C题:五年命题规律与预测
java·c语言·前端·数学建模
见叶之秋5 小时前
【C++】String类的使用(含模拟实现)
java·开发语言·c++
竹叶青_七弦6 小时前
从复制粘贴到Harness:Java后端AI编程的工程化之路
java
阿拉斯攀登6 小时前
MQ消息积压问题排查:消费卡顿、堆积、消费速度优化
后端
wuminyu6 小时前
Linux内核线程调度与虚拟线程调度机制系统级深度剖析
java·linux·c语言·jvm·c++
阿拉斯攀登6 小时前
无人售货机库存异步更新方案:解决高并发下单库存卡顿问题
后端
阿拉斯攀登6 小时前
无人售货机MQ消息丢失、重复消费、超时异常全套兜底方案
后端
做系统的大强6 小时前
我用中文从零写了一个操作系统(下篇):从45个BUG到165个——假持久化、USB地狱与OS自举
后端
程序员老赵6 小时前
Docker 部署 Rocky Linux:轻松搭建 RHEL 兼容企业级基础镜像平台
linux·后端·docker