接口说明一个类型能做什么,继承把父类的一切交给子类,组合则把另一个对象包装起来。通过运行小程序,学会默认方法、super、抽象类,并看清脆弱基类陷阱。
Java 有三种方式把类型联系起来。接口是一份合同,任何类都能签。继承用 extends,让一个类拥有父类的一切。组合把另一个对象放进字段里,然后调用它。选错了,子类就可能被它从没见过的代码弄坏。
本文讲接口,接口里的默认方法、静态方法和私有方法,extends 和 super,@Override,protected 和 final,抽象类,脆弱基类问题,用转发和装饰器实现组合,以及强制类型转换。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行,就把代码存成 Main.java,再执行 java Main.java。
接口是合同
接口列出一个类型承诺具备的方法,但不说明这些方法怎么实现。任何类或 record 都能实现它,只认识这个接口的代码可以使用所有这些实现。下面是三种图形,除此之外它们毫无共同点:
interface Shape {
double area();
String name();
}
record Circle(double radius) implements Shape {
@Override
public double area() {
return Math.PI * radius * radius;
}
@Override
public String name() {
return "circle";
}
}
record Rectangle(double width, double height) implements Shape {
@Override
public double area() {
return width * height;
}
@Override
public String name() {
return "rectangle";
}
}
class Triangle implements Shape {
private final double base;
private final double height;
Triangle(double base, double height) {
this.base = base;
this.height = height;
}
@Override
public double area() {
return base * height / 2;
}
@Override
public String name() {
return "triangle";
}
}
double totalArea(List<Shape> shapes) {
double total = 0;
for (Shape s : shapes) {
total += s.area();
}
return total;
}
void main() {
List<Shape> shapes = List.of(new Circle(1), new Rectangle(2, 3), new Triangle(4, 5));
for (Shape s : shapes) {
IO.println(String.format("%-9s %6.2f", s.name(), s.area()));
}
IO.println(String.format("total %6.2f", totalArea(shapes)));
}
输出:
circle 3.14
rectangle 6.00
triangle 10.00
total 19.14
totalArea 从没提到圆或三角形。它向每个 Shape 要面积,每个对象用自己的代码回答。这就是多态:同一个调用 s.area(),由对象决定运行哪个方法。明天加一个 Hexagon,totalArea 不用改就能用。
接口里的方法不管写没写,都是 public。所以每个实现都写成 public double area()。去掉它,javac 会拒绝编译,报 area() in Circle cannot implement area() in Shape。
默认方法和静态方法让接口可以演进
默认方法是带方法体的接口方法。实现这个接口的每个类都能直接得到它,也都可以重写它。一个接口一旦有很多类实现,再加一个抽象方法就会让它们全部出错。加默认方法不会。
interface Shape {
double area();
default String describe() {
return String.format("a shape with area %.2f", area());
}
static Shape square(double side) {
return new Rectangle(side, side);
}
}
record Circle(double radius) implements Shape {
@Override
public double area() {
return Math.PI * radius * radius;
}
}
record Rectangle(double width, double height) implements Shape {
@Override
public double area() {
return width * height;
}
@Override
public String describe() {
return "a " + width + " by " + height + " rectangle";
}
}
void main() {
List<Shape> shapes = List.of(new Circle(1), new Rectangle(2, 3), Shape.square(4));
for (Shape s : shapes) {
IO.println(s.describe());
}
}
输出:
a shape with area 3.14
a 2.0 by 3.0 rectangle
a 4.0 by 4.0 rectangle
Circle 没写 describe,所以用的是默认实现。默认实现调用 area(),这个调用落到圆自己的 area 上。Rectangle 用更好的写法重写了 describe。JDK 正是靠这个办法给 Collection 加上了 stream(),而没有弄坏任何一个集合类。
square 是接口的静态方法。你要通过接口调用它,写成 Shape.square(4),它也不会被继承:Rectangle.square(4) 无法编译。属于这份合同的工厂方法很适合放在这里,比如 List.of。
两个同名的默认方法无法编译
一个类可以实现两个接口,有时两个接口都有签名相同的默认方法。Java 不会替你挑一个:
interface Camera {
default String describe() {
return "takes photos";
}
}
interface Phone {
default String describe() {
return "makes calls";
}
}
class SmartPhone implements Camera, Phone {
}
void main() {
IO.println(new SmartPhone().describe());
}
构建失败,报错:
Main.java:13: error: types Camera and Phone are incompatible;
class SmartPhone implements Camera, Phone {
^
javac 输出的下一行说明了原因:class Main.SmartPhone inherits unrelated defaults for describe() from types Camera and Phone。之所以有 Main.,是因为紧凑源文件会把你的类嵌套在一个隐藏的 Main 类里,讲类的那一篇解释过。
这叫菱形问题,Java 要你亲手解决。重写这个方法,再用 Interface.super.method() 调用你想要的默认实现:
interface Camera {
default String describe() {
return "takes photos";
}
}
interface Phone {
default String describe() {
return "makes calls";
}
}
class SmartPhone implements Camera, Phone {
@Override
public String describe() {
return Phone.super.describe() + " and " + Camera.super.describe();
}
}
void main() {
IO.println(new SmartPhone().describe());
}
输出:
makes calls and takes photos
Phone.super.describe() 的意思是“在这个对象上运行 Phone 提供的默认实现”。你可以调用其中一个、两个都调用,或者都不调用,但必须做出选择。
私有方法让默认方法共享代码
两个默认方法需要同一个辅助方法时,可以把它写成 private 接口方法。它不属于合同,所以实现类看不到,也调用不了:
interface Greeter {
String name();
default String hello() {
return wrap("Hello, " + name());
}
default String goodbye() {
return wrap("Goodbye, " + name());
}
private String wrap(String text) {
return "[" + text + "]";
}
}
record English(String name) implements Greeter {}
void main() {
var g = new English("Ana");
IO.println(g.hello());
IO.println(g.goodbye());
}
输出:
[Hello, Ana]
[Goodbye, Ana]
私有接口方法是 Java 9 引入的。我们验证过:用 javac --release 8 编译同一个接口,会失败并报 private interface methods are not supported in -source 8。English 不需要方法体:record 的访问器 name() 就满足了接口方法。
只有一个方法的接口可以写成 lambda
恰好只有一个抽象方法的接口叫函数式接口,Java 允许你用 lambda 表达式而不是类来实现它。Runnable、Comparator 和 Function 都是这种接口,默认方法不算在“一个”之内。@FunctionalInterface 注解让编译器检查接口始终保持这个样子。讲 lambda 表达式和流的那一篇会介绍怎样编写和使用它们。
用 extends 继承
一个类 extends 另一个类,就继承了它的字段和方法,还可以重写这些方法来改变它们的行为。被继承的类叫超类,也叫父类。一个类只能继承一个类。下面是一个三层的动物小家族:
class Animal {
protected final String name;
Animal(String name) {
this.name = name;
}
String sound() {
return "...";
}
String describe() {
return name + " says " + sound();
}
}
class Dog extends Animal {
Dog(String name) {
super(name);
}
@Override
String sound() {
return "Woof";
}
}
class Puppy extends Dog {
Puppy(String name) {
super(name);
}
@Override
String sound() {
return super.sound().toLowerCase() + "?";
}
@Override
String describe() {
return super.describe() + " (a puppy called " + name + ")";
}
}
void main() {
List<Animal> animals = List.of(new Animal("Rock"), new Dog("Rex"), new Puppy("Bit"));
for (Animal a : animals) {
IO.println(a.describe());
}
}
输出:
Rock says ...
Rex says Woof
Bit says woof? (a puppy called Bit)
这个短短的程序里发生了不少事:
super(name)调用父类的构造器。Animal没有无参构造器,所以Dog必须调用这一个。每个构造器都会先运行父类的构造器,然后才轮到自己的字段就位。super.sound()调用父类版本的方法,即使这个类重写了它。Puppy拿到狗的"Woof",再加以改动。name上的protected让子类可以读取它,Puppy.describe就是这样用到name的。它还会把这个字段开放给同一个包里的所有类,而一个文件里的类都在同一个包中,所以单文件程序没法演示protected拒绝访问。- 动态分派是最重要的一条。
describe()只在Animal里写了一次,它调用sound()。当describe()在一个Dog上运行时,这个sound()调用会去到Dog.sound()。Java 在运行时按对象的真实类来选方法,而不是按变量的类型,也不是按写下这个调用的那个类。
这条规则让继承很强大,也正是它让继承变得危险。
@Override 能抓住拼写错误
@Override 告诉编译器,这个方法是要替换父类里的某个方法。它是可选的,而不写它,拼写错误就会溜过去。这里 Dog 把 sound 拼错了:
class Animal {
String sound() {
return "...";
}
}
class Dog extends Animal {
String sonud() {
return "Woof";
}
}
void main() {
Animal rex = new Dog();
IO.println(rex.sound());
}
输出:
...
这段代码能编译,即使用 javac -Xlint:all -Werror 也一样。sonud 是一个全新的方法,没有任何地方调用它,于是狗悄悄发出了父类的叫声。给它加上 @Override:
class Animal {
String sound() {
return "...";
}
}
class Dog extends Animal {
@Override
String sonud() {
return "Woof";
}
}
void main() {
Animal rex = new Dog();
IO.println(rex.sound());
}
构建失败,报错:
Main.java:8: error: method does not override or implement a method from a supertype
@Override
^
参数类型写错时它也能发现。参数类型不对,得到的是重载,而不是重写。
final 禁止重写
final 方法不能被重写,final 类不能被继承。如果父类的规则必须对每个子类都成立,就给方法加上 final:
class Account {
private int balance;
final void deposit(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("deposit must be positive");
}
balance += amount;
}
}
class SneakyAccount extends Account {
@Override
void deposit(int amount) {
IO.println("no checks here");
}
}
void main() {
new SneakyAccount().deposit(-50);
}
构建失败,报错:
Main.java:14: error: deposit(int) in Main.SneakyAccount cannot override deposit(int) in Main.Account
void deposit(int amount) {
^
javac 在下一行补充 overridden method is final。用在整个类上,final 会把门彻底关上。String 是 final 的,所以 class LoudString extends String 会失败,报 cannot inherit from final String。record 也是 final 的。讲密封类型的那一篇会介绍折中的做法:由类明确列出它允许哪些子类。
调用可重写方法的构造器
父类构造器在子类构造器主体之前运行,而动态分派在父类构造器里照样生效。两件事放在一起,父类就可能在子类设置好字段之前调用子类的方法:
class Widget {
Widget() {
IO.println("Widget constructor calls render()");
render();
}
void render() {
IO.println("plain widget");
}
}
class Label extends Widget {
private final String text;
Label(String text) {
this.text = text;
}
@Override
void render() {
IO.println("label: " + text.toUpperCase());
}
}
void main() {
new Label("hello");
}
输出后停止:
Widget constructor calls render()
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.toUpperCase()" because "this.text" is null
text 是 final,但此时它仍是 null。Label 构造器的第一步是一个看不见的 super(),它运行了 Widget(),Widget() 调用 render(),结果到了 Label.render()。这时 this.text = text 还没执行。final 字段只赋值一次,但在赋值之前你就能读到它。
Java 25 提供了解决办法。它的灵活的构造器主体允许构造器在调用 super() 之前给自己的字段赋值,讲类的那一篇演示过。在 Label 的构造器里先写 this.text = text;,再写 super();,同一个程序就会输出 label: HELLO。更老的规则则更简单:不要在构造器里调用可重写的方法。
抽象类与接口
抽象类是不能直接创建实例的类。它可以有字段、构造器和写好的方法,还可以有每个子类都必须实现的 abstract 方法。对下面这个类执行 new Account() 会失败,报 Main.Account is abstract; cannot be instantiated。
abstract class Account {
private int balance;
private final List<String> history = new ArrayList<>();
abstract int fee(int amount);
final void withdraw(int amount) {
int total = amount + fee(amount);
balance -= total;
history.add("-" + total);
}
final void deposit(int amount) {
balance += amount;
history.add("+" + amount);
}
String statement() {
return getClass().getSimpleName() + " " + history + " balance " + balance;
}
}
class Checking extends Account {
@Override
int fee(int amount) {
return 1;
}
}
class Savings extends Account {
@Override
int fee(int amount) {
return amount / 10;
}
}
void main() {
List<Account> accounts = List.of(new Checking(), new Savings());
for (Account a : accounts) {
a.deposit(100);
a.withdraw(50);
IO.println(a.statement());
}
}
输出:
Checking [+100, -51] balance 49
Savings [+100, -55] balance 45
Account 掌管余额、流水,以及修改它们的规则。它的 final 方法固定了步骤,唯一的 abstract 方法 fee 是每个子类要填的唯一空缺。这种结构叫模板方法模式。
接口做不到这一点,区别归结为三件事:
- 状态。 抽象类可以有实例字段和构造器,接口不行。接口只能有常量,它的默认方法只能通过其他方法来完成工作。
- 数量。 一个类只能继承一个类,却能实现任意多个接口。record 和枚举根本不能继承类,但可以实现接口。
- 耦合。 继承一个类,会把你和它的字段、它的实现绑在一起。实现一个接口,只把你和方法签名绑在一起。
先从接口开始,因为谁都能签它。当各个实现确实共享状态和步骤时,再加一个抽象类,同时让接口继续作为大家使用的类型。JDK 的 List 和 AbstractList 就是这样做的。
脆弱基类问题
子类依赖的不只是父类承诺了什么,还有父类内部是怎么写的。这些细节一旦改变,或者从一开始就和你设想的不一样,子类就会出错。下面是经典例子,出自 Joshua Bloch 的 Effective Java:一个统计总共添加过多少个元素的 HashSet。
class InstrumentedHashSet<E> extends HashSet<E> {
private static final long serialVersionUID = 1L;
private int addCount = 0;
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
int addCount() {
return addCount;
}
}
void main() throws Exception {
var set = new InstrumentedHashSet<String>();
set.addAll(List.of("Snap", "Crackle", "Pop"));
IO.println("size: " + set.size());
IO.println("addCount: " + set.addCount());
var addAll = HashSet.class.getMethod("addAll", Collection.class);
IO.println("addAll is written in " + addAll.getDeclaringClass().getName());
}
输出:
size: 3
addCount: 6
addAll is written in java.util.AbstractCollection
放进去三个元素,计数却是六。我们在 Java 25 上运行,想看看 JDK 是否仍然这样,结果确实如此。最后一行说明了原因:HashSet 没有自己写 addAll。它从 AbstractCollection 继承了一个,而那个方法会遍历集合,对每个元素调用 add。getMethod 可能抛出受检异常,所以 main 写了 throws Exception。
调用链是这样的:
一次 addAll 调用先把三个元素计入,再交给继承来的 AbstractCollection.addAll。那个方法对每个元素调用 add,动态分派把每次调用送回子类的 add,于是又计了一遍。
两个重写单独看都没错。bug 来自一个不在 HashSet 合同里的事实:它在内部调用了自己的哪些方法。这叫自用(self-use),从外面是看不到的。
显而易见的修法是删掉 addAll 的重写,现在这样确实能用。但这时计数正确,只是因为 AbstractCollection.addAll 碰巧调用了 add。如果将来某个 JDK 给 HashSet 一个更快、直接存入元素的 addAll,批量添加的计数就会悄悄变成零。无论哪种写法,你的类都依赖着你不拥有、也看不到的代码。
用十岁孩子能懂的话说
继承就像拿到父母的整栋房子。房间、家具、花园都归你,第一天就能搬进去,什么都不用建。可你也拿到了你不知道会漏水的屋顶,还有走廊里那个开关:它开灯的同时,还偷偷打开了花园的水管。你可以重新粉刷任何房间,但墙里的电线还是他们布的。
组合就像需要时请一位水管工。你住在自己的房子里。哪根水管要修,你就打电话给水管工,告诉他是哪一根。他可以换干活的方式、买新工具,甚至退休,你换一位水管工就行。他做什么都淹不到你没让他进的房间。
准确的说法
子类继承的是父类的实现,而不只是它的接口。子类重写的每个方法,都可能被父类自己的代码调用,因为 Java 按对象的运行时类来分派。所以子类是否正确,取决于父类的自用:哪些方法调用了哪些方法,按什么顺序。这个细节通常没有写进文档,父类的作者在下一个版本里完全可以改掉它。
组合没有这个问题。包装类把另一个对象放在私有字段里,调用它的公有方法。被包装对象的内部调用只会去到它自己的方法,永远不会回到包装类里,因为包装类不是子类。包装类只依赖合同。
这个比喻的局限:房子没法为下一任主人而建,类却可以。专为继承设计的父类会在文档里写明自用情况,就像 AbstractList 那样,并把其余部分标成 final。继承这样的类是安全的。
组合:用转发代替继承
用组合时,你的类拥有一个集合,而不是本身就是集合。它把一个 Set 放在字段里,把每个调用转发给它。下面用这种方式重写计数器:
class InstrumentedSet<E> {
private final Set<E> inner;
private int addCount = 0;
InstrumentedSet(Set<E> inner) {
this.inner = inner;
}
boolean add(E e) {
addCount++;
return inner.add(e);
}
boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return inner.addAll(c);
}
int size() {
return inner.size();
}
int addCount() {
return addCount;
}
@Override
public String toString() {
return inner.toString();
}
}
void main() {
var fromHash = new InstrumentedSet<String>(new HashSet<>());
fromHash.addAll(List.of("Snap", "Crackle", "Pop"));
IO.println("HashSet: size " + fromHash.size() + ", addCount " + fromHash.addCount());
var fromTree = new InstrumentedSet<String>(new TreeSet<>());
fromTree.addAll(List.of("Snap", "Crackle", "Pop"));
fromTree.add("Snap");
IO.println("TreeSet: size " + fromTree.size() + ", addCount " + fromTree.addCount());
IO.println(fromTree);
}
输出:
HashSet: size 3, addCount 3
TreeSet: size 3, addCount 4
[Crackle, Pop, Snap]
计数对了。inner.addAll(c) 内部仍然调用 add,但那是 HashSet 自己的 add,我们的包装类根本不知道。第二次添加 "Snap" 算作一次添加尝试,这正是这个类承诺的行为,而大小仍是 3。
还有额外的好处。包装类接受任何 Set,所以同一个类也能统计向 TreeSet 添加的次数,而 TreeSet 会让元素保持有序。子类版本永远只能是 HashSet。
代价是要写转发代码。想把这个包装类传给需要 Set 的地方,它就得实现 Set<E>,并转发每一个方法。Effective Java 把这些代码写在一个可复用的 ForwardingSet 里,只写一次。
“是一个”还是“有一个”
继承表示 Dog 是一个 Animal:凡是需要动物的地方,狗都必须放得进去。组合表示一个类有一个它要用的东西。写 extends 之前,先问问父类的每个方法放在子类上是否都说得通。JDK 自己就犯过一次这个错。Stack 继承了 Vector,于是栈就成了列表,还继承了破坏栈概念的列表方法:
void main() {
var stack = new Stack<String>();
stack.push("first");
stack.push("second");
stack.push("third");
stack.add(0, "sneaked in at the bottom");
stack.remove(2);
IO.println(stack);
IO.println("pop: " + stack.pop());
}
输出:
[sneaked in at the bottom, first, third]
pop: third
栈应该只允许在顶部压入和弹出。这个栈却让我们在底部插入、从中间删除,因为 Stack 没法去掉 Vector 的方法。栈内部有一个列表,它本身不是列表。所以 Stack 自己的文档都建议你改用 Deque,比如 ArrayDeque。
装饰器把包装层层叠起
装饰器是一种包装类,它和被包装的对象实现同一个接口。既然包装类也是那个类型,你就可以包装一个包装类,把行为一层层叠起来。用 record 写,每一层都只是一个短小的类型:
interface Formatter {
String format(String text);
}
record Plain() implements Formatter {
@Override
public String format(String text) {
return text;
}
}
record Shout(Formatter inner) implements Formatter {
@Override
public String format(String text) {
return inner.format(text).toUpperCase();
}
}
record Exclaim(Formatter inner) implements Formatter {
@Override
public String format(String text) {
return inner.format(text) + "!";
}
}
record Timestamp(Formatter inner, String time) implements Formatter {
@Override
public String format(String text) {
return "[" + time + "] " + inner.format(text);
}
}
void main() {
Formatter f = new Timestamp(new Exclaim(new Shout(new Plain())), "09:30");
IO.println(f.format("server started"));
Formatter g = new Shout(new Timestamp(new Plain(), "9am"));
IO.println(g.format("server started"));
}
输出:
[09:30] SERVER STARTED!
[9AM] SERVER STARTED
每一层先调用里面那一层,再加上自己的改动。在 g 里,Shout 在最外层,所以连时间戳也转成了大写,9am 变成了 9AM。顺序很重要,而顺序由你在构建对象时决定。
用继承的话,每种组合都得写一个子类。这里四个小类型就覆盖了所有组合。JDK 的 I/O 类就是这样工作的:BufferedReader 包装 InputStreamReader,InputStreamReader 又包装 InputStream。
instanceof 与强制类型转换
instanceof 检查一个对象是否属于某个类型,强制类型转换则告诉编译器把它当作那个类型。编译器相信强制类型转换,所以检查发生在运行时。对象不是那个类型时,转换就会抛出异常:
void main() {
List<Object> values = List.of("8080", 8080);
for (Object v : values) {
if (v instanceof String) {
String s = (String) v;
IO.println("a string of length " + s.length());
} else {
IO.println("not a string: " + v);
}
}
String port = (String) values.get(1);
IO.println("port is " + port);
}
输出后停止:
a string of length 4
not a string: 8080
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
循环是安全的,因为其中的转换有 instanceof 检查在前面把关。最后一次转换没有检查,而那个值是 Integer。实际代码从 Map<String, Object> 里取值时,就会碰到这种情况。
在紧凑源文件里对你自己的类做强制类型转换时,错误信息会写出类加载器的名字,结尾是一个 @ 加一串十六进制数,每次运行都可能不同。所以这个例子用的是 JDK 类型。
一长串 instanceof 检查,通常说明这个方法应该放到接口上,就像 area() 那样。当类型检查确实是合适的工具时,讲密封类型的那一篇会介绍 v instanceof String s,一步完成检查和转换。
要点
- 接口是合同。任何类或 record 都能实现多个接口,针对接口编写的代码适用于所有实现。
- 默认方法让接口能增加行为,而不弄坏实现类。两个冲突的默认方法无法编译;重写这个方法,再用
Camera.super.describe()做出选择。 extends继承一个父类的字段和方法。Java 按对象的真实类分派,所以父类的代码可以调用子类的重写方法,在构造器里也一样。- 每个重写方法都加上
@Override,拼错时构建就会失败。用final禁止重写,用super调用父类。 - 各个实现确实共享状态和步骤时,用抽象类。其他情况优先用接口。
- 继承一个你控制不了的类,会让你受制于它隐藏的自用。在 Java 25 上,
InstrumentedHashSet对 3 个元素计出 6,因为继承来的addAll调用了add。 - 组合把对象包装起来并转发给它,所以被包装对象的内部无法回调你的代码。装饰器把这些包装层层叠起。
只有当子类真的是父类,而且父类本来就是为继承而设计的,才去继承它。否则,就包装它。