《Java 编程入门》8.2 接口、default 方法与抽象类

把 library 的存储从 LibraryService 里抽出来:用 LibraryRepository 定义契约,用 InMemoryLibraryRepository 提供内存实现;再对比 default 方法、抽象类与模板方法,讲清三者各自解决什么问题、什么时候不该用,并给出接口实现、默认方法冲突与可见性收窄的真实编译错误。

本节目标:能把「存哪里」从业务代码里抽成接口,写出一个可替换的内存实现;能区分接口、default 方法与抽象类,知道每种工具该在什么场景用。
适用版本:JDK 25 LTS(Temurin 25.0.5);default 方法与接口静态方法在 JDK 21 上同样可用。

问题:业务代码不该知道数据存在哪

8.1 的 LibraryService.borrow 里有一行 repository.findByIsbn(isbn)。如果 LibraryService 直接 new 一个 ArrayList 来存书,它会同时承担三件事:借阅规则、数据存储、集合操作。后果是测试时必须准备真实数据,换存储时(比如第 14 章要写 CSV)必须改业务类。

把「存储」抽出来的办法是定义一个接口:业务代码只依赖接口,不依赖实现。接口描述「能做什么」,实现决定「怎么做」。这样 LibraryService 可以在内存实现、文件实现、测试替身之间切换,而不改一行借阅逻辑。

这一节会建立两个类型:

  • LibraryRepository:接口,定义图书和借阅的存取契约。
  • InMemoryLibraryRepository:实现,用 ArrayList 把数据放在内存里。

第 10 章会把实现换成 Map,届时接口不变,只换实现。

接口定义契约

先写接口。方法名用业务语言,不用 insertRow、selectAll 这种数据库词:

package com.plume.library.repository;

import com.plume.library.domain.Book;
import com.plume.library.domain.Loan;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public interface LibraryRepository {

    void save(Book book);

    Optional<Book> findByIsbn(String isbn);

    List<Book> findAll();

    boolean deleteByIsbn(String isbn);

    default List<Book> findByAuthor(String author) {
        List<Book> matched = new ArrayList<>();
        for (Book book : findAll()) {
            if (book.author().equals(author)) {
                matched.add(book);
            }
        }
        return List.copyOf(matched);
    }

    void saveLoan(Loan loan);

    Optional<Loan> findActiveLoanByIsbn(String isbn);

    boolean removeLoan(String isbn);

    List<Loan> findLoansByMember(String memberId);
}

几个设计点:

  • 查询用 Optional 表达「可能没有」,而不是返回 null。调用方必须显式处理空值,忘记处理会编译失败,而不是在运行时抛 NullPointerException。
  • 列表返回 List.copyOf,不是内部 ArrayList。调用方拿到的是只读快照,改不动仓储内部状态;这条封装规则在 7.3 已经立过。
  • save 是 upsert 语义:ISBN 已存在就替换,不存在就插入。是否允许重复由 LibraryService.addBook 判断,仓储只负责存。
  • 接口方法默认是 public abstract,不需要写 public。实现类必须写 public,否则是可见性收窄,编译失败。

接口里只放行为,不放字段。需要共享状态时,用抽象类或组合,不要往接口里塞 public static 可变字段。

实现类:内存仓储

实现类的职责很窄:把对象放进集合,按条件取出来。

package com.plume.library.repository;

import com.plume.library.domain.Book;
import com.plume.library.domain.Loan;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public final class InMemoryLibraryRepository implements LibraryRepository {
    private final List<Book> books = new ArrayList<>();
    private final List<Loan> loans = new ArrayList<>();

    @Override
    public void save(Book book) {
        for (int i = 0; i < books.size(); i++) {
            if (books.get(i).isbn().equals(book.isbn())) {
                books.set(i, book);
                return;
            }
        }
        books.add(book);
    }

    @Override
    public Optional<Book> findByIsbn(String isbn) {
        for (Book book : books) {
            if (book.isbn().equals(isbn)) {
                return Optional.of(book);
            }
        }
        return Optional.empty();
    }

    @Override
    public List<Book> findAll() {
        return List.copyOf(books);
    }

    @Override
    public boolean deleteByIsbn(String isbn) {
        return books.removeIf(book -> book.isbn().equals(isbn));
    }

    @Override
    public void saveLoan(Loan loan) {
        loans.add(loan);
    }

    @Override
    public Optional<Loan> findActiveLoanByIsbn(String isbn) {
        for (Loan loan : loans) {
            if (loan.getIsbn().equals(isbn)) {
                return Optional.of(loan);
            }
        }
        return Optional.empty();
    }

    @Override
    public boolean removeLoan(String isbn) {
        return loans.removeIf(loan -> loan.getIsbn().equals(isbn));
    }

    @Override
    public List<Loan> findLoansByMember(String memberId) {
        List<Loan> matched = new ArrayList<>();
        for (Loan loan : loans) {
            if (loan.getMemberId().equals(memberId)) {
                matched.add(loan);
            }
        }
        return List.copyOf(matched);
    }
}

这份实现刻意用了线性扫描:findByIsbn 每次从头找,save 每次从头比对。它不追求性能,只追求语义正确,方便把测试和业务跑通。第 10 章会解释为什么 ArrayList 在数据量上来后不合适,并换成 Map;接口不变,LibraryService 也不用改。

findByAuthor 没有在实现类里出现,因为接口给了 default 实现。这是下一节要讲的机制。

default 方法:接口里的默认实现

findByAuthor 是一个很典型的「可以基于已有方法拼出来」的查询:遍历 findAll(),按作者过滤。每个实现类都写一遍很啰嗦,接口可以用 default 提供默认版本:

default List<Book> findByAuthor(String author) {
    List<Book> matched = new ArrayList<>();
    for (Book book : findAll()) {
        if (book.author().equals(author)) {
            matched.add(book);
        }
    }
    return List.copyOf(matched);
}

实现类如果觉得线性扫描够用,什么都不用写;如果第 10 章有了作者索引,可以覆写它,把复杂度降下来。这就是 default 方法的价值:在不破坏已有实现的前提下,给接口增加一个带默认行为的方法。

default 方法有两条边界:

  • 它只能调用接口里的其他方法,不能读实现类的字段。上面的代码只依赖 findAll(),所以任何实现都能用。
  • 多个接口提供同名 default 方法时,实现类必须显式解决冲突。

第二个边界很容易踩。下面两个接口都提供了 label():

interface Named {
    default String label() {
        return "Named";
    }
}

interface Versioned {
    default String label() {
        return "Versioned";
    }
}

class Combined implements Named, Versioned {
}

本机 javac 25.0.5 报:

错误: 类型 Named 和 Versioned 不兼容;
  类 Combined从类型 Named 和 Versioned 中继承了label() 的不相关默认值

解决办法是实现类覆写 label(),并用 接口名.super.方法() 选择要调用哪一个:

class Combined implements Named, Versioned {
    @Override
    public String label() {
        return Named.super.label() + "+" + Versioned.super.label();
    }
}

本机输出:

Named+Versioned

这里的 Named.super 不是父类,而是「接口的默认实现」。一个类只能有一个直接父类,但可以实现多个接口,所以需要这种限定语法来消除歧义。

default 方法不适合承载复杂业务。它没有状态、不能声明构造器、也不该调用实现类特有的方法。如果默认实现需要多个步骤、需要缓存、需要依赖注入,说明它更像一个抽象类或独立服务。

抽象类:共享状态与模板方法

接口适合定义「能做什么」,抽象类适合定义「部分已经怎么做,剩下交给子类」。library 里的罚金策略就是一个例子:不同会员的每日罚金和宽限天数不同,但「逾期天数怎么算、什么时候开始计费」是固定的。

package com.plume.library.service;

import com.plume.library.domain.Loan;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;

public abstract class FinePolicy {

    public final long fineCents(Loan loan, LocalDate returnedOn) {
        long overdueDays = Math.max(0, ChronoUnit.DAYS.between(loan.getDueOn(), returnedOn));
        long chargeableDays = Math.max(0, overdueDays - graceDays());
        return chargeableDays * dailyRateCents();
    }

    protected abstract long dailyRateCents();

    protected int graceDays() {
        return 0;
    }
}

fineCents 是 final 的模板方法:算法骨架固定,子类只能填两个空。普通会员没有宽限:

public final class StandardFinePolicy extends FinePolicy {
    @Override
    protected long dailyRateCents() {
        return 50;
    }
}

员工策略只需要再覆写 dailyRateCents() 和 graceDays(),把每日费率改成 25 分、宽限改成 3 天。protected abstract 的方法对子类可见、对调用方不可见,正好表达「这是扩展点,不是公开 API」;graceDays() 给了默认实现,子类不关心就可以不写。

把接口和抽象类放在一起对比:

维度接口抽象类
能放什么常量、抽象方法、default/static/private 方法字段、构造器、普通方法、抽象方法
继承数量一个类可实现多个接口一个类只能继承一个抽象类
状态不能有实例字段可以有实例字段和状态
适用场景定义契约、跨实现解耦共享部分实现、模板方法、受控扩展点
版本演进加 default 方法可兼容旧实现加抽象方法会破坏所有子类

选择顺序可以简单记成:先想接口,需要共享状态或固定算法骨架时再上抽象类。 不要因为「接口不能写字段」就退回抽象类;字段通常意味着实现细节,应该放进实现类,用组合注入。

在 library 里落地

LibraryService 的构造器接收 LibraryRepository,而不是自己创建实现:

public final class LibraryService {
    private final LibraryRepository repository;
    private final FinePolicy finePolicy;

    public LibraryService(LibraryRepository repository, FinePolicy finePolicy) {
        this.repository = Objects.requireNonNull(repository, "repository");
        this.finePolicy = Objects.requireNonNull(finePolicy, "finePolicy");
    }

    public void addBook(Book book) {
        if (repository.findByIsbn(book.isbn()).isPresent()) {
            throw new IllegalStateException("ISBN 已存在: " + book.isbn());
        }
        repository.save(book);
    }

    public List<Book> booksByAuthor(String author) {
        return repository.findByAuthor(author);
    }
}

组装发生在 main 或测试里:

LibraryRepository repository = new InMemoryLibraryRepository();
LibraryService service = new LibraryService(repository, new StandardFinePolicy());
service.addBook(new Book("9787111213826", "Java 编程思想", "Bruce Eckel", 2007));
service.addBook(new Book("9787020024759", "红楼梦", "曹雪芹", 1791));
service.addBook(new Book("9787536692930", "三体", "刘慈欣", 2008));

System.out.println("总数: " + repository.findAll().size());
System.out.println("曹雪芹: " + service.booksByAuthor("曹雪芹").getFirst().describe());

本机 JDK 25 输出:

总数: 3
曹雪芹: 红楼梦 / 曹雪芹 (1791)

这段代码没有出现 ArrayList,LibraryService 完全不知道数据存在内存里。第 14 章把实现换成文件仓储时,只需要在 main 里换一行;第 16 章写测试时,可以注入一个返回固定数据的假仓储,不用碰真实文件。

常见错误与边界

实现接口方法时漏写 public。 接口方法隐式公开,实现类不能收窄:

void save(Book book) {
}

本机 javac 25.0.5 报:

错误: Weak中的save(Book)无法实现LibraryRepository中的save(Book)
  正在尝试分配更低的访问权限; 以前为public

把可变集合直接返回。 return books; 会让调用方拿到内部 ArrayList,对方一个 clear() 就能清空仓储。统一用 List.copyOf,或者返回不可变视图。注意 List.copyOf 遇到 null 元素会抛 NullPointerException,仓储里不应该存 null。

抽象类构造器里调用抽象方法。 和 8.1 的坑一样:子类字段还没初始化。FinePolicy 的模板方法在对象构造完成后才被调用,所以安全;如果放在构造器里,就会读到默认值。

小结

  • 接口定义契约,业务代码依赖接口而不是具体实现,存储才能替换、测试才能替身。
  • 查询返回 Optional,列表返回 List.copyOf,是仓储接口的两条基本封装规则。
  • default 方法让接口在不破坏旧实现的前提下增加默认行为,但它不能读实现类字段。
  • 多个 default 方法冲突时,实现类必须覆写,并用 接口名.super 明确调用哪一个。
  • 抽象类适合共享状态和模板方法;一个类只能继承一个抽象类,接口可以 implements 多个。
  • library 用 LibraryRepository + InMemoryLibraryRepository 把存储隔离在业务之外,第 10 章只换实现、不换接口。

接口让「谁来做」可以在运行时决定。下一节把这件事拆开:当变量声明为接口、对象是具体实现时,JVM 到底怎么找到要执行的方法?

阅读导航:上一节:8.1 继承与 super:复用不是目的 · 下一节:8.3 多态与动态分派 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发