初学时继承是经常被强调的东西:有共有特征就可以来个继承关系。现在需要明白:继承要慎用。
1. extends 的目的是什么
复用
子类可以重用父类中的属性和方法,减少代码冗余和重复,提高复用性和可维护性。此外,子类还可以在父类的基础上进行扩展和修改,实现代码的灵活性和可扩展性。多态
父类对象可以引用子类对象,并直接使用子类的方法。这样更加灵活、可扩展性高,可以根据不同的需求和场景选择不同的子类对象。
2. extedns 的缺点
- 破坏封装性:子类可以访问其父类的所有非私有成员,包括属性和方法,这可能导致对父类中的数据和行为的意外修改,从而破坏了封装性。
- 强耦合性:子类与父类之间形成强耦合关系,当父类发生变化时,子类也必须相应地进行修改,这可能导致代码维护的困难。
- 限制子类灵活性:子类只能继承父类的所有属性和方法,这可能限制了子类的灵活性。如果子类需要从另一个类继承不同的属性和方法,则必须创建一个新的子类。
- 多层继承的复杂性:如果多个类之间进行了复杂的继承层次结构,则可能会导致代码的可读性和可维护性变差。
- 可能导致脆弱性:由于继承关系的复杂性,如果不小心修改了父类的实现,可能会影响到所有依赖于该父类的子类,这可能导致代码的脆弱性。
2.1. 破坏封装性示例
自定义一个 CustomizeHashSet 类,继承了 java.util.HashSet
,并重写其中的 add
、addAll
两个方法。与父类唯一的区别是加入了一个计数器,用于统计添加过多少元素;
1 | public class CustomizeHashSetTest { |
按照设想添加了3各元素,那输出的结果应该是3,但运行的结果有点出乎意料:
1 | >>>>> addAll |
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 等优秀框架上的设计就是:
好的接口设计 ->> 抽象类实现部分功能(编写一些可重用代码) ->> 具体类继承抽象类的代码进行泛化,并可选择实现额外接口满足额外功能。