Skip to content

抽象类与接口 ​

S1-009必学入门约 90 分钟JDK 21编程语言基础 · 面向对象

1. 本节定位 ​

《多态》 里父类的 feeInCents() 返回 0、name() 返回"通用渠道"——那些默认实现其实没有意义,只是为了占位。

本节解决这个问题:当父类的方法无法给出有意义的实现时,就该把它声明为抽象方法。同时讲清另一个复用机制——接口,以及最常见的一个选型问题:抽象类和接口到底该用哪个。

2. 学习目标 ​

完成本节后,你应该能够:

  • 写出抽象类与抽象方法,说出为什么抽象类不能被实例化;
  • 用"模板方法"模式:父类定流程、子类填细节;
  • 写接口并让类实现它,说清接口与抽象类的三个区别;
  • 正确使用接口的默认方法与静态方法;
  • 按"是什么 / 能做什么 / 需要几个"三条标准做选型。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

问题一:父类的方法写不出实现。

java
public class Document {
    public String render() {
        return "文档内容";       // 占位实现,对发票和报告都不对
    }
}

Document 本身是概念,不是具体文档。给它一个"能跑但没意义"的实现,会隐藏错误:忘了重写的子类不会报错,只是输出错内容。

问题二:一个类只能继承一个父类,但可能需要多种能力。

Invoice 既是"文档"(有标题、能渲染),也是"可打印的"(能预览)。单继承装不下第二种身份——接口解决这个。

5. 核心概念 ​

5.1 抽象类 ​

java
public abstract class Document {
    private final String title;

    protected Document(String title) {
        this.title = title;
    }

    public abstract String render();       // 抽象方法:没有方法体

    protected abstract String type();

    public String summary() {              // 具体方法:可直接复用
        return "[" + type() + "] " + title + " → " + render();
    }
}

三条规则:

  1. 有抽象方法的类必须是抽象类;
  2. 抽象类不能被 new(new Document("x") 编译错误)——它只作为父类存在;
  3. 子类必须实现所有抽象方法,否则子类也要声明为 abstract。

抽象类可以同时拥有:抽象方法(强制子类实现)、具体方法(共享给子类)、字段(共享状态)、构造方法(供子类调用)。

5.2 模板方法:父类定流程,子类填细节 ​

summary() 就是模板方法的雏形:

text
父类固定:拼接顺序 = "[" + 类型 + "] " + 标题 + " → " + 渲染结果
子类提供:type() 与 render()

好处:流程只有一份,改流程只改父类;细节各不相同,各子类自己实现。

《继承与重写》 综合任务里的 payrollLine() 也是同一个模式:格式固定,算法可变。

5.3 接口 ​

java
public interface Printable {
    String preview();                       // 抽象方法(默认就是 public abstract)

    default String printHint() {            // 默认方法:可继承、可重写
        return "可直接打印";
    }

    static Printable empty() {              // 静态方法:工具方法
        return () -> "(空文档)";
    }
}

接口的三个关键点:

  1. 实现类必须实现所有抽象方法(用 implements);
  2. 一个类可以实现多个接口(这是它相对继承的最大优势);
  3. 接口的字段默认是 public static final,方法默认是 public abstract——所以常把常量放进接口(但更好的做法是放在专门的常量类里)。

5.4 默认方法:为演进而生 ​

java
public interface Printable {
    String preview();

    default String printHint() {            // 已有实现类不需要改动
        return "可直接打印";
    }
}

Java 8 引入默认方法的真实动机:让 JDK 能给已有接口(如 Collection)加方法(如 stream())而不破坏所有实现类。

对使用者的意义:设计接口时可以先只定义核心方法,后续再加带默认实现的方法——接口可以演进了。

5.5 抽象类 vs 接口:三条标准 ​

标准抽象类接口
是什么 / 能做什么"是一个"(发票是一个文档)"能做什么"(发票能被打印)
能否持有状态能(可以有字段与构造方法)不能(字段都是常量)
能继承/实现几个只能一个可以多个

选型建议:

  • 需要共享状态或实现 → 抽象类;
  • 需要表达能力、且可能要多种能力 → 接口;
  • 拿不准时 → 优先接口(更灵活,不占用唯一的继承名额)。

一个常见的组合用法

抽象类实现接口,提供通用的默认实现:

java
public abstract class AbstractDocument implements Printable {
    @Override
    public String preview() {
        return "默认预览:" + render();
    }

    protected abstract String render();
}

这样具体子类只需要实现 render(),preview() 已经免费获得。JDK 里 AbstractList、AbstractMap 都是这个套路。

5.6 接口的其他能力(Java 8+) ​

特性说明出现版本
默认方法default 修饰,有实现Java 8
静态方法接口内定义工具方法Java 8
私有方法供接口内部复用Java 9
函数式接口只有一个抽象方法,可用 lambdaJava 8

Printable 只有一个抽象方法(preview()),所以它是函数式接口,可以用 lambda 实现:

java
Printable empty = () -> "(空文档)";

这就是第 7 节代码里 Printable.empty() 能那样子写的原因。

6. 递进示例 ​

6.1 最小示例:抽象方法强制子类实现 ​

java
public abstract class Document {
    public abstract String render();
}

class Invoice extends Document {
    @Override
    public String render() { return "发票内容"; }
}
// 忘记实现 render() 的子类会编译失败 —— 这正是抽象方法的价值

6.2 业务示例:既是文档,又能打印 ​

java
public class Invoice extends Document implements Printable {
    @Override
    public String render() { ... }        // 来自抽象类

    @Override
    protected String type() { return "发票"; }

    @Override
    public String preview() { ... }       // 来自接口
}

Invoice 同时拥有两种身份:是一个文档(继承来的)与能被打印(实现来的)。

6.3 反例:用抽象类代替接口 ​

java
// 不好:想表达"能打印",却用抽象类占掉了唯一的继承名额
public abstract class Printable {
    public abstract String preview();
}

public class Invoice extends Printable { }     // 那还能继承 Document 吗?不能了

修法:把"能做什么"的能力用接口表达,把"是一个"的层级关系用抽象类表达。

7. 完整可运行代码 ​

文件:labs/oop-basics/src/main/java/cn/kb/learn/oop/ 下的 Document.java、Printable.java、Invoice.java、Report.java、InterfaceLesson.java

java
package cn.kb.learn.oop;

/** 抽象类:定义"文档是什么",并持有子类共享的状态 */
public abstract class Document {
    private final String title;

    protected Document(String title) {
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("标题不能为空");
        }
        this.title = title;
    }

    public final String title() {
        return title;
    }

    public abstract String render();

    protected abstract String type();

    /** 模板方法:父类定流程,子类只需实现细节 */
    public String summary() {
        return "[" + type() + "] " + title + " → " + render();
    }
}
java
package cn.kb.learn.oop;

/** 接口:定义"能做什么",不关心对方是什么类型 */
public interface Printable {
    String preview();

    default String printHint() {
        return "可直接打印";
    }

    static Printable empty() {
        return () -> "(空文档)";
    }
}
java
package cn.kb.learn.oop;

public class Invoice extends Document implements Printable {
    private final long amountInCents;

    public Invoice(String title, long amountInCents) {
        super(title);
        this.amountInCents = amountInCents;
    }

    @Override
    public String render() {
        return String.format("发票金额 %.2f 元", amountInCents / 100.0);
    }

    @Override
    protected String type() {
        return "发票";
    }

    @Override
    public String preview() {
        return "发票预览:" + title() + " / " + render();
    }

    @Override
    public String printHint() {
        return "需加盖发票专用章后打印";
    }
}

运行命令:

powershell
cd labs
./mvnw -pl oop-basics compile
java -cp oop-basics/target/classes cn.kb.learn.oop.InterfaceLesson

预期输出:

text
[发票] 2026-09 服务费 → 发票金额 1288.00 元
[报告] 2026 年第三季度总结 → 报告共 24 页
发票预览:2026-09 服务费 / 发票金额 1288.00 元 | 需加盖发票专用章后打印
报告预览:2026 年第三季度总结 / 报告共 24 页 | 可直接打印
空文档: (空文档) | 可直接打印
默认提示: 可直接打印

第 4、5 行值得对比:Report 没有重写 printHint(),于是用了接口的默认实现;Invoice 重写了它。这就是默认方法的意义——可以不写,也可以覆盖。

8. Debug 观察点 ​

  1. 在 document.summary() 处断点,按 F7 进入:summary() 在抽象类中执行,但里面调用的 type() 与 render() 会跳到具体子类——一个方法里两段代码分别落在父类和子类;
  2. 在 printable.printHint() 处断点,对 Invoice 与 Report 分别 F7:一个进入 Invoice 的重写版本,一个进入接口的默认实现;
  3. 展开 Variables 里的 document,观察实际类型是 Invoice 而声明类型是 Document;
  4. 给 Document 加一个新的抽象方法(如 int pageCount()),不修改子类,观察编译错误信息——抽象方法就是通过这种方式"逼"子类跟上需求变化。

9. 常见错误 ​

错误 1:抽象类里写了抽象方法却忘了 abstract 类声明 ​

java
public class Document {
    public abstract String render();       // 编译错误:抽象方法必须在抽象类中
}

修复:类上加 abstract。

错误 2:试图实例化抽象类 ​

java
Document document = new Document("标题");   // 编译错误

修复:抽象类只能作为类型使用(Document document = new Invoice(...);)。

错误 3:接口里写字段以为是实例变量 ​

java
public interface Printable {
    String name = "打印";      // 实际是 public static final,所有实现共享
}

修复:接口不适合存状态,需要状态就用抽象类。

错误 4:默认方法冲突不知道怎么办 ​

java
interface A { default String hello() { return "A"; } }
interface B { default String hello() { return "B"; } }

class C implements A, B { }        // 编译错误:冲突

修复:在类中显式重写并指定用哪个:

java
class C implements A, B {
    @Override
    public String hello() {
        return A.super.hello();     // 明确选择 A 的实现
    }
}

10. 动手实验 ​

目标:设计一个"抽象类 + 接口"组合的体系。

步骤:

  1. 运行第 7 节代码,核对输出;
  2. 新增一个 Note extends Document implements Printable:render() 返回"便签内容",type() 返回"便签";
  3. 在 InterfaceLesson 的列表里加上它,验证输出(循环体不用改);
  4. 给 Printable 加一个新的默认方法 boolean isArchived() 返回 false,观察所有实现类不需要改动就能编译通过;
  5. 把 Printable.isArchived() 改成抽象方法(去掉 default),观察编译错误——体会默认方法在接口演进中的价值。

验收方式:能解释第 4 步与第 5 步的差别。

扩展任务:设计一个 Comparable 风格的接口 Rankable,包含 int score() 与默认方法 boolean isBetterThan(Rankable other),用两个类实现并排序输出。

11. 练习题 ​

基础题

  1. 判断:抽象类可以没有抽象方法。(对 / 错)
  2. 填空:一个类最多继承 ______ 个父类,可以实现 ______ 个接口。
  3. 简答:什么时候该用抽象类,什么时候该用接口?

代码阅读题

java
abstract class Base {
    public String describe() {
        return "类型=" + type() + ",值=" + value();
    }

    protected abstract String type();
    protected abstract String value();
}

class Impl extends Base {
    @Override protected String type() { return "实现类"; }
    @Override protected String value() { return "42"; }
}

System.out.println(new Impl().describe());
  1. 输出是什么?describe() 在哪个类中执行?

编程题

  1. 设计一个支付体系:
    • 抽象类 PaymentChannel:字段 name,抽象方法 feeInCents(long),具体方法 actualReceive(long)(= 金额 - 手续费);
    • 接口 Refundable:方法 refund(long) 与默认方法 boolean supportsRefund() 返回 true;
    • AlipayChannel 实现两者;BankCardChannel 只继承抽象类;
    • 遍历所有渠道打印实收金额,并统计支持退款的渠道数。

综合任务

  1. 设计一个"报表导出"体系:
    • 抽象类 Exporter:final void export(List<String> rows) 作为模板方法,内部依次调用 open()、writeRow(row)、close();writeRow 为抽象方法;
    • 两个实现:CsvExporter(逗号分隔)与 MarkdownExporter(| 分隔);
    • 接口 Compressible:String compress() 与默认方法 boolean useGzip() 返回 false;
    • 让 CsvExporter 实现 Compressible;
    • 说明:为什么 export() 要是 final?
分级提示

提示 1(方向):第 4 题的 describe() 是抽象类里的具体方法,但里面的抽象方法调用会落到子类。

提示 2(关键点):第 6 题的模板方法把"打开 → 逐行写 → 关闭"的流程固化,子类只提供"怎么写一行"。

提示 3(接近答案):第 6 题的 export() 设为 final 是为了防止子类改变流程(比如忘记 close() 导致资源泄漏)。

12. 参考答案 ​

基础题
  1. 对。抽象类可以只有具体方法,它的作用通常是"不允许被实例化,只作为基类"。但反过来:有抽象方法的类必须是抽象类。
  2. 一、多。
  3. 需要共享状态或共享实现时用抽象类;只需要表达能力、或一个类要具备多种能力时用接口。拿不准时优先接口——它不占用唯一的继承名额。
代码阅读题(第 4 题)

输出 类型=实现类,值=42。

describe() 定义在抽象类 Base 中(是具体方法,不需要子类重写),但它内部调用的 type() 与 value() 都是抽象方法,运行时按实际类型(Impl)分发到子类实现。

这就是"模板方法 + 多态"的组合:流程在父类,细节在子类。

编程题(第 5 题)
java
package cn.kb.learn.oop;

public abstract class PaymentChannel {
    private final String name;

    protected PaymentChannel(String name) {
        this.name = name;
    }

    public final String name() {
        return name;
    }

    public abstract long feeInCents(long amountInCents);

    public long actualReceive(long amountInCents) {
        return amountInCents - feeInCents(amountInCents);
    }
}

interface Refundable {
    long refund(long amountInCents);

    default boolean supportsRefund() {
        return true;
    }
}

class AlipayChannel extends PaymentChannel implements Refundable {
    AlipayChannel() {
        super("支付宝");
    }

    @Override
    public long feeInCents(long amountInCents) {
        return Math.round(amountInCents * 0.006);
    }

    @Override
    public long refund(long amountInCents) {
        return amountInCents;      // 全额退款
    }
}

class BankCardChannel extends PaymentChannel {
    BankCardChannel() {
        super("银行卡");
    }

    @Override
    public long feeInCents(long amountInCents) {
        return Math.min(Math.round(amountInCents * 0.005), 2_500L);
    }
}

class PaymentRunner {
    public static void main(String[] args) {
        PaymentChannel[] channels = {new AlipayChannel(), new BankCardChannel()};
        long amount = 10_000L;
        int refundable = 0;

        for (PaymentChannel channel : channels) {
            System.out.printf("%s 实收 %d 分%n", channel.name(), channel.actualReceive(amount));
            if (channel instanceof Refundable refundable1) {
                System.out.println("  支持退款:" + refundable1.supportsRefund());
                refundable++;
            }
        }
        System.out.println("支持退款的渠道数:" + refundable);
    }
}

预期输出:

text
支付宝 实收 9940 分
  支持退款:true
银行卡 实收 9950 分
支持退款的渠道数:1
综合任务(第 6 题)
java
package cn.kb.learn.oop;

import java.util.List;

public abstract class Exporter {
    /** 模板方法:流程固定,子类只实现"怎么写一行" */
    public final void export(List<String> rows) {
        open();
        for (String row : rows) {
            writeRow(row);
        }
        close();
    }

    protected void open() {
        System.out.println("-- 开始导出 --");
    }

    protected abstract void writeRow(String row);

    protected void close() {
        System.out.println("-- 导出结束 --");
    }
}

interface Compressible {
    String compress();

    default boolean useGzip() {
        return false;
    }
}

class CsvExporter extends Exporter implements Compressible {
    @Override
    protected void writeRow(String row) {
        System.out.println(String.join(",", row.split("")));
    }

    @Override
    public String compress() {
        return "zip";
    }
}

class MarkdownExporter extends Exporter {
    @Override
    protected void writeRow(String row) {
        System.out.println("| " + row + " |");
    }
}

class ExportRunner {
    public static void main(String[] args) {
        List<String> rows = List.of("张三 90", "李四 85");

        new CsvExporter().export(rows);
        new MarkdownExporter().export(rows);

        Exporter exporter = new CsvExporter();
        if (exporter instanceof Compressible compressible) {
            System.out.println("压缩方式:" + compressible.compress() + ",默认 gzip:" + compressible.useGzip());
        }
    }
}

为什么 export() 要 final:它表达了"导出流程不允许被改变"这条设计约束。流程里包含资源管理(open / close 配对),一旦子类重写,就可能忘记 close() 造成资源泄漏。用 final 把"不能改的"钉死,把"可以改的"(writeRow)开放——这就是模板方法的核心权衡。

13. 掌握标准 ​

  • [ ] 能写出抽象类与抽象方法,并解释为什么不能实例化;
  • [ ] 会用模板方法固定流程、开放细节;
  • [ ] 能写出接口并让类实现多个接口;
  • [ ] 会用默认方法在不破坏实现类的前提下扩展接口;
  • [ ] 能按"是什么 / 能做什么 / 需要几个"做出选型。

14. 扩展资源 ​

15. 下一步学习 ​

《枚举》:一个被低估的语法特性——用类型安全的方式表达"有限个取值"。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引