Blog

Java 的接口、继承与组合

接口说明一个类型能做什么,继承把父类的一切交给子类,组合则把另一个对象包装起来。通过运行小程序,学会默认方法、super、抽象类,并看清脆弱基类陷阱。

Java 有三种方式把类型联系起来。接口是一份合同,任何类都能签。继承用 extends,让一个类拥有父类的一切。组合把另一个对象放进字段里,然后调用它。选错了,子类就可能被它从没见过的代码弄坏。

本文讲接口,接口里的默认方法、静态方法和私有方法,extendssuper@Overrideprotectedfinal,抽象类,脆弱基类问题,用转发和装饰器实现组合,以及强制类型转换。下面每个程序都在 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(),由对象决定运行哪个方法。明天加一个 HexagontotalArea 不用改就能用。

接口里的方法不管写没写,都是 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 8English 不需要方法体:record 的访问器 name() 就满足了接口方法。

只有一个方法的接口可以写成 lambda

恰好只有一个抽象方法的接口叫函数式接口,Java 允许你用 lambda 表达式而不是类来实现它。RunnableComparatorFunction 都是这种接口,默认方法不算在“一个”之内。@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 告诉编译器,这个方法是要替换父类里的某个方法。它是可选的,而不写它,拼写错误就会溜过去。这里 Dogsound 拼错了:

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

textfinal,但此时它仍是 nullLabel 构造器的第一步是一个看不见的 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 的 ListAbstractList 就是这样做的。

脆弱基类问题

子类依赖的不只是父类承诺了什么,还有父类内部是怎么写的。这些细节一旦改变,或者从一开始就和你设想的不一样,子类就会出错。下面是经典例子,出自 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 继承了一个,而那个方法会遍历集合,对每个元素调用 addgetMethod 可能抛出受检异常,所以 main 写了 throws Exception

调用链是这样的:

set.addAll(List.of(3 个元素)) InstrumentedHashSet.addAll: addCount += 3 super.addAll(c) AbstractCollection.addAll: 逐个 add(e) add(e) 找到重写版本 InstrumentedHashSet.add: addCount++ (x3) super.add(e) HashSet.add: 存入元素 addCount 3 3 6,重复计数 6,但 size 是 3

一次 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 包装 InputStreamReaderInputStreamReader 又包装 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
  • 组合把对象包装起来并转发给它,所以被包装对象的内部无法回调你的代码。装饰器把这些包装层层叠起。

只有当子类真的是父类,而且父类本来就是为继承而设计的,才去继承它。否则,就包装它。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。