extends or interface
2025-01-22 08:19:30    1.5k 字   
This post is also available in English and alternative languages.

初学时继承是经常被强调的东西:有共有特征就可以来个继承关系。现在需要明白:继承要慎用


1. extends 的目的是什么

  1. 复用
    子类可以重用父类中的属性和方法,减少代码冗余和重复,提高复用性和可维护性。此外,子类还可以在父类的基础上进行扩展和修改,实现代码的灵活性和可扩展性。

  2. 多态
    父类对象可以引用子类对象,并直接使用子类的方法。这样更加灵活、可扩展性高,可以根据不同的需求和场景选择不同的子类对象。


2. extedns 的缺点

  1. 破坏封装性:子类可以访问其父类的所有非私有成员,包括属性和方法,这可能导致对父类中的数据和行为的意外修改,从而破坏了封装性。
  2. 强耦合性:子类与父类之间形成强耦合关系,当父类发生变化时,子类也必须相应地进行修改,这可能导致代码维护的困难。
  3. 限制子类灵活性:子类只能继承父类的所有属性和方法,这可能限制了子类的灵活性。如果子类需要从另一个类继承不同的属性和方法,则必须创建一个新的子类。
  4. 多层继承的复杂性:如果多个类之间进行了复杂的继承层次结构,则可能会导致代码的可读性和可维护性变差。
  5. 可能导致脆弱性:由于继承关系的复杂性,如果不小心修改了父类的实现,可能会影响到所有依赖于该父类的子类,这可能导致代码的脆弱性。

2.1. 破坏封装性示例

自定义一个 CustomizeHashSet 类,继承了 java.util.HashSet,并重写其中的 addaddAll 两个方法。与父类唯一的区别是加入了一个计数器,用于统计添加过多少元素;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class CustomizeHashSetTest {
private static final CustomizeHashSet<Integer> CUSTOMIZE_HASH_SET = new CustomizeHashSet<>();
public static void main(String[] args) {
CUSTOMIZE_HASH_SET.addAll(Arrays.asList(1, 2, 3));
System.out.println(CUSTOMIZE_HASH_SET.getCount());
}
}

public class CustomizeHashSet<E> extends HashSet<E> {
private int count = 0;
public int getCount() { return count; }
@Override
public boolean add(E e) {
System.out.println(">>>>> add");
count++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
System.out.println(">>>>> addAll");
count += c.size();
return super.addAll(c);
}
}

按照设想添加了3各元素,那输出的结果应该是3,但运行的结果有点出乎意料:

1
2
3
4
5
>>>>> addAll
>>>>> add
>>>>> add
>>>>> add
6

debug 进入到父类 java.util.AbstractCollection#addAll 方法就会发现出错的原因:java.util.AbstractCollection#addAll 方法内部调用的是 add 方法。所以最终会进入子类 CustomizeHashSet#add 方法。

因此在这个测试示例中,调用 CustomizeHashSet#addAll 方法时,计数器加3,接着调用父类 java.util.AbstractCollection#addAll 方法,然后调用子类 CustomizeHashSet#add 方法三次,这样计数器又加3。


分析上面的情况,出错的原因是因为父类的可覆盖方法存在自用性的问题,即:父类中的可覆盖方法调用了别的可覆盖方法。这时候如果子类覆盖了其中的一些方法,导致出乎意料的错误

一旦发生重写,子类重写的方法可能从表面上看起来没有问题,但结果却是错误的,这就迫使开发者需要去了解父类中的实现细节,从而打破了面向对象的封装性,因为封装性是要求隐藏实现细节。更危险的是,错误不一定能轻易地被测出来,如果开发者不了解超类的实现细节就进行重写,那么可能就埋下了隐患。


3. 设计可继承的类

设计可以用来继承的类时,应该注意:

  • 保证开放封闭原则,尽量不进行复写。
  • 对于存在自用性的可覆盖方法,设计者应该考虑到被复写的情况,要提供文档精确描述调用细节。
  • 尽可能少的暴露受保护成员,否则会暴露太多实现细节。
  • 构造器不应该调用任何可覆盖的方法。

4. interface

interface 是一种 声明协议,它用来表明一个类实现了一些功能,让使用者准确的知道这个类它可以调用哪些方法。而接口继承接口是单纯的协议的扩展,表示拥有更复杂的功能。

当面向接口编程时,只需要看这个类是否实现了相应的接口便可以调用对应方法,而不用考虑其内部是如何实现的。

当然使用抽象类也强行可以达到一定的效果,毕竟 interfce 是一种特殊的抽象类,但需要注意,抽象类的设计目的是代码重用,表明 is-a 的关系,相比较下 interface 是 like-a,使用 interface 更清晰易用。

因此合理的设计思路应该是:先在接口层面上去设计,好的接口设计可以在大的、模块的层次上大幅简化项目,而继承则可在小的、细碎的层次上简化实现

落实到 Netty、Spring 等优秀框架上的设计就是:

好的接口设计 ->> 抽象类实现部分功能(编写一些可重用代码) ->> 具体类继承抽象类的代码进行泛化,并可选择实现额外接口满足额外功能。


5. Reference