抽象类与接口
上一章我们用 interface PaymentMethod 设计了支付系统,体验了多态的威力。但你可能有个疑问:接口和上一章学的抽象父类有什么区别?什么时候该用接口,什么时候该用抽象类?这一章,我们就来彻底厘清这两个”抽象工具”。
想象一位咖啡大师在编写《咖啡制作手册》。有些内容她写得很具体——“水温 92℃”、“萃取 25 秒”;有些内容她只写了目录,具体配方留给徒弟们去填——“第一章:浓缩咖啡的萃取(请自行完善)“。抽象类就像这本手册,可以既有”具体内容”也有”待填空白”;而接口更像一张”配方清单”,只列出”必须做哪些步骤”,不关心你怎么做。
本章我们将深入抽象类与接口的本质,学习 Java 8 引入的 default 方法、Java 9 引入的私有方法,以及函数式接口——这是 Java 函数式编程的基石。
一、抽象类(abstract class)
1.1 为什么需要抽象类
考虑一个 Shape 类,它有 area() 方法。但”形状”本身是抽象的——矩形、圆形、三角形都有面积,但”形状的面积”是什么?没意义。Shape 的 area() 方法体该写什么?
class Shape {
public double area() {
return 0; // 没意义的占位实现,纯粹是浪费
}
}
这种”父类没法给出有意义的实现,但子类必须实现”的方法,就是抽象方法。包含抽象方法的类必须声明为抽象类。
1.2 抽象类的定义
用 abstract 关键字修饰:
public abstract class Shape {
// 抽象方法:只有声明,没有方法体
public abstract double area();
public abstract double perimeter();
// 普通方法:可以有具体实现
public void describe() {
System.out.println("面积:" + area() + ",周长:" + perimeter());
}
}
1.3 抽象类的规则
- 不能实例化:
new Shape()编译错误。抽象类是”不完整的”,必须由子类补全才能用。 - 可以没有抽象方法:纯当”不可实例化的父类”用。
- 有抽象方法的类必须声明为 abstract。
- 子类必须实现所有抽象方法,否则子类也得声明为 abstract。
public class Circle extends Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
@Override
public double perimeter() {
return 2 * Math.PI * radius;
}
}
public class Rectangle extends Shape {
private double width, height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() { return width * height; }
@Override
public double perimeter() { return 2 * (width + height); }
}
现在可以多态地使用:
Shape s1 = new Circle(5);
Shape s2 = new Rectangle(3, 4);
s1.describe(); // 面积:78.54,周长:31.42
s2.describe(); // 面积:12.0,周长:14.0
1.4 抽象类的”模板方法”模式
抽象类有个经典用法——模板方法模式(Template Method):父类定义算法骨架,子类填充细节。
public abstract class Beverage {
// 模板方法:final 防止子类改写流程
public final void prepare() {
boilWater();
brew(); // 抽象方法,子类实现
pourInCup();
addCondiments(); // 抽象方法,子类实现
}
private void boilWater() { System.out.println("烧水"); }
private void pourInCup() { System.out.println("倒入杯中"); }
protected abstract void brew();
protected abstract void addCondiments();
}
public class Tea extends Beverage {
@Override
protected void brew() { System.out.println("泡茶叶"); }
@Override
protected void addCondiments() { System.out.println("加柠檬"); }
}
public class Coffee extends Beverage {
@Override
protected void brew() { System.out.println("冲咖啡粉"); }
@Override
protected void addCondiments() { System.out.println("加糖和奶"); }
}
prepare() 定义了”烧水→冲泡→倒杯→加料”的固定流程,子类只关心”泡什么""加什么”。这就是抽象类的价值——既提供共性实现,又留下扩展点。
二、接口(interface)
2.1 接口的定义
接口是比抽象类更”纯粹”的抽象——它只定义契约(方法签名),不关心实现。Java 8 之前,接口中所有方法都是抽象的;Java 8 之后可以有 default 和 static 方法;Java 9 之后可以有 private 方法。
public interface Flyable {
void fly(); // 默认 public abstract,无需写修饰符
}
public interface Swimmable {
void swim();
}
2.2 实现接口
用 implements 关键字实现接口,必须实现所有抽象方法:
public class Duck implements Flyable, Swimmable {
@Override
public void fly() { System.out.println("鸭子飞"); }
@Override
public void swim() { System.out.println("鸭子游"); }
}
注意:一个类可以实现多个接口(弥补了 Java 单继承的限制),但只能继承一个类。鸭子既能飞又能游,所以同时实现 Flyable 和 Swimmable。
2.3 接口的特性
- 接口中的方法默认是
public abstract(可省略)。 - 接口中的字段默认是
public static final(即常量),必须初始化。 - 接口不能有构造方法(不能
new,但可以有static工厂方法)。 - 接口可以继承多个父接口:
interface C extends A, B { ... }。
public interface Constants {
int MAX_SIZE = 100; // 等价于 public static final int MAX_SIZE = 100
}
⚠️ 不要在接口里放字段——这是过时的设计。现代 Java 接口应该只定义行为,常量请放到专门的类或枚举中。
三、接口的默认方法(default,Java 8+)
3.1 解决接口演进问题
想象你写了一个 CoffeeMaker 接口,被 100 个类实现。某天你想给它加一个新方法 clean()——如果加成抽象方法,这 100 个类全要改!这就是”接口演进”难题。
Java 8 引入默认方法(Default Method):用 default 关键字修饰,提供默认实现,子类可以选择重写或直接使用。
public interface CoffeeMaker {
void brew();
// 默认方法:有方法体
default void clean() {
System.out.println("执行标准清洁流程");
}
}
public class SimpleMaker implements CoffeeMaker {
@Override
public void brew() { System.out.println("冲咖啡"); }
// 不重写 clean(),使用默认实现
}
public class PremiumMaker implements CoffeeMaker {
@Override
public void brew() { System.out.println("冲精品咖啡"); }
@Override
public void clean() { System.out.println("深度清洁"); } // 重写
}
SimpleMaker 不重写 clean(),调用时执行默认实现;PremiumMaker 重写了,执行自己的版本。默认方法让接口能向后兼容地扩展——这就是 Java 8 能给 Collection 接口加 stream() 等方法而不破坏老代码的原因。
3.2 默认方法的多继承冲突
一个类实现两个接口,如果两个接口有同名默认方法,会怎样?
interface A {
default void hello() { System.out.println("A"); }
}
interface B {
default void hello() { System.out.println("B"); }
}
class C implements A, B {
// 编译错误!必须解决冲突
@Override
public void hello() {
A.super.hello(); // 显式选择 A 的版本
}
}
解决方式:子类必须重写冲突方法,可以用 接口名.super.方法名() 调用指定接口的版本。
四、接口的静态方法(Java 8+)
接口可以有 static 方法,作为该接口的工具方法:
public interface CoffeeMaker {
void brew();
// 静态方法:通过接口名调用
static CoffeeMaker getDefault() {
return new SimpleMaker();
}
static void printUsage() {
System.out.println("使用方法:先 brew(),再 clean()");
}
}
// 调用:
CoffeeMaker.printUsage();
CoffeeMaker maker = CoffeeMaker.getDefault();
静态方法属于接口本身,不会被实现类继承。这与类的静态方法类似。java.util.Comparator 接口就有 Comparator.naturalOrder()、Comparator.reverseOrder() 等静态工厂方法。
五、接口的私有方法(Java 9+)
Java 9 起,接口可以有 private 方法(包括私有静态方法),用于在接口内部复用代码,避免默认方法之间的重复:
public interface CoffeeMaker {
default void brewLatte() {
prepareEspresso();
System.out.println("加牛奶");
}
default void brewCappuccino() {
prepareEspresso();
System.out.println("加奶泡");
}
// 私有方法:抽取公共逻辑,不暴露给实现类
private void prepareEspresso() {
System.out.println("萃取浓缩咖啡");
}
// 私有静态方法:供 static 方法复用
private static void log(String msg) {
System.out.println("[LOG] " + msg);
}
}
私有方法只能在接口内部被默认方法或静态方法调用,实现类看不到也无法使用。
六、抽象类 vs 接口:何时用哪个
这是 OOP 设计的经典问题。核心区别在于”is-a”和”can-do”。
6.1 对比表
| 特性 | 抽象类 | 接口 |
|---|---|---|
| 关系 | is-a(是一种) | can-do(能做某事) |
| 继承 | 单继承(一个类只能继承一个抽象类) | 多实现(一个类可实现多个接口) |
| 字段 | 任意类型,可以是实例变量 | 只能是 public static final 常量 |
| 方法 | 抽象+具体方法 | 抽象+default+static+private |
| 构造方法 | 有 | 无 |
| 状态 | 有(可维护实例状态) | 无(纯契约) |
| 设计语义 | ”是什么”——定义本质 | ”能做什么”——定义能力 |
6.2 设计抉择
-
用抽象类当:一组类有强 is-a 关系,需要共享状态和代码。例如
AbstractList是ArrayList和LinkedList的父类,它们共享大量列表操作代码。 -
用接口当:想定义一种能力,跨越不同继承层级。例如
Comparable(可比较)、Iterable(可迭代)、Serializable(可序列化)——任何类都可能具备这些能力,与它们的类继承体系无关。
经典例子:ArrayList 继承 AbstractList(is-a,列表的本质),同时实现 List、Cloneable、Serializable 等接口(can-do,多种能力)。前者提供代码复用,后者提供能力契约。
6.3 一个常见的误区
“接口没有代码复用能力”——这是 Java 8 之前的认知。有了 default 方法,接口也能提供代码复用。例如 List 接口的 sort、replaceAll 等方法都是 default 实现,所有 List 实现类都能复用。但接口仍不能有状态(实例字段),这是与抽象类的根本区别。
七、函数式接口(@FunctionalInterface)
7.1 定义
函数式接口(Functional Interface)是只有一个抽象方法的接口(default、static 方法不算)。这种接口是 Java 8 Lambda 表达式的基础——Lambda 的类型就是函数式接口。
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2); // 唯一的抽象方法
// 还可以有多个 default 方法
}
@FunctionalInterface 是一个注解,告诉编译器”这是一个函数式接口”。如果意外添加了第二个抽象方法,编译器立即报错。建议显式加上这个注解,类似 @Override 的作用。
7.2 Java 内置的函数式接口
java.util.function 包提供了大量常用函数式接口,避免重复定义:
| 接口 | 抽象方法 | 含义 |
|---|---|---|
Supplier<T> | T get() | 提供者:无参,返回 T |
Consumer<T> | void accept(T) | 消费者:接收 T,无返回 |
Function<T,R> | R apply(T) | 函数:T → R |
Predicate<T> | boolean test(T) | 断言:T → boolean |
BiFunction<T,U,R> | R apply(T,U) | 双参函数 |
UnaryOperator<T> | T apply(T) | 一元运算:T → T |
7.3 与 Lambda 配合
函数式接口最大的价值是配合 Lambda 表达式使用:
// 传统写法:匿名内部类
Comparator<String> cmp1 = new Comparator<>() {
public int compare(String a, String b) {
return a.length() - b.length();
}
};
// Lambda 写法:简洁得多
Comparator<String> cmp2 = (a, b) -> a.length() - b.length();
Lambda 的详细语法会在后续章节讲解,这里只需理解:函数式接口是 Lambda 的”类型”,没有函数式接口就没有 Lambda。
八、综合实战
把本章知识串联起来,看一个完整的例子:
观察这段代码:Duck 既是 Animal(is-a),又能 Swimmable、Flyable(can-do)。它继承抽象类获得 introduce 模板方法,实现接口获得游泳和飞行能力。Greeter 是函数式接口,可以用 Lambda 简洁地实例化。这就是现代 Java 抽象工具的完整图景。
九、本章小结
| 概念 | 要点 |
|---|---|
| 抽象类 | abstract 修饰,不能实例化,可有抽象方法和具体方法 |
| 接口 | interface 定义,纯契约,支持多实现 |
default 方法 | Java 8+,接口的默认实现,解决接口演进问题 |
static 方法 | Java 8+,接口的工具方法 |
private 方法 | Java 9+,接口内部代码复用 |
| is-a vs can-do | 抽象类表”是什么”,接口表”能做什么” |
| 函数式接口 | 只有一个抽象方法的接口,是 Lambda 的类型 |
结语
抽象类与接口,是 Java 提供的两种抽象工具。它们不是”二选一”的对立关系,而是”协同作战”的伙伴——抽象类提供代码复用和模板骨架,接口定义能力契约和跨层级多态。一个设计良好的系统,往往是抽象类与接口并用:抽象类承载共性,接口表达能力。
下一章,我们将目光转向类的”内部”——内部类。一个类可以定义在另一个类里面,这种看似奇怪的设计,却承载着 Builder 模式、闭包、事件处理等重要用途。它是 Java 语言中最细腻的一笔,也是通往高级 OOP 的必经之路。