提到面向对象语言中的继承,可能大家都觉得很简单,在平常工作中经常使用。这种编码实现的特点不仅是代码重用的有力手段,更重要的是,它是java封装特性的基石。但是在日常工作中有没有想过是否是必须使用继承,有没有可替代的做法,或者继承是否有滥用的情况呢?
其实谈到继承所实现的功能,组合复用也能打到相同的效果。比如现在有出库和入库两种单据,都要实现根据单号查找对应的详情的需求,可以使用新建两个继承自订单基类的出入库子类的做法,来实现这个功能。这样根据单号查找详情的逻辑就被抽象到了上层的父类中。当然如果使用组合复用的做法,也可以将根据单号查找详情作为一个单独的类,由出入库类调用这个类作为组件,来实现这个功能。
如果在日常工作中,遇到类似情况你毫不犹豫的选择了继承来打到你的目的,那就说明有问题了。实现一个目的不可能只有一个选择。在本文中,我只阐述我的观点,不要为了复用代码而去使用继承。因为在java语境中,你extend了一个父类后,你的子类就复用了该基类的实现,而不能再采用另外的实现。而组合复用的方式则不会受到此类限制。
抛开上面提到的,其实继承还有很多值得关注的点,一般我们在看一些成熟框架的源码的时候,会发现大部分继承都是在同一个包中完成的。在包的内部使用继承是非常安全的,因为这很大概率表明了该父类和继承的子类是处在同一时空中,处在同一个程序员的控制之下。这种继承非常安全。但是如果继承操作跨了包了,也就证明这个继承操作,打破了java的封装性,因为子类的实现逻辑牵扯到了父类的逻辑。这种情况下如果父类发生了演化那么子类也要跟着一起演化。就好像你在使用了某个框架的类,把它作为父类,进行了继承,但是在某个时间点你升级了该框架,那么你实现的子类中的操作就可能存在着隐患,因为他所继承的父类中可能发生了变动。
这里的变动可能是修改也可能是新增。修改操作比较好理解,那么新增操作为什么会导致子类存在隐患呢?拿java中的集合来举例说明,加入在父类和子类中存在着加入该集合需要满足某些条件的逻辑,子类覆盖了所有添加的集合元素的方法,这种情况下能够正常工作。但是现在父类增加了一个新的添加集合的方法,但是子类没有去实现,这样后果就是该集合中可以添加不满足子类限制条件,但是满足父类限制条件的元素。这种情况针对子类来说是危险的。
很容易想到一种方法来替代继承,那就是在子类中增加一个私有域。这个私有域引用一个现有类的实现。这个实例中提供给不同的子类相同的方法。这种设计就叫做组合复用。用上面的出入库订单来举例的话,出库和入库两个类不再继承同一个父类,而是引入这样一个类,它提供了公共的根据单号查询出/入库单据的方法。这种形式就相当于这两个出入库的子类包装了具有公共方法的那个类,所以这两个类也叫做包装类(wrapper class)。这也正是Decorator模式,因为这两个出入库类修饰了共有方法类,为他增加了新的特性。
那么什么时候才应该使用继承呢?只有当子类真生是父类的子类型(subtype)时,才适合用继承。换句话说,只有子类和父类存在“is a”关系的时候,子类才应该扩展父类。而不仅仅是子类共有的方法聚合成一个父类。如果子类和父类不是“is a”的关系,这种情况下这里所谓的子类(其实这里使用组合复用的模式就不存在子类与父类的关系了)应该包含父类的一个私有实例,并且这个私有实例只暴露一个较小的API。本质上这里的子类不是父类的实现,只是父类的部分实现细节而已。
总结一点,继承的功能很强大,但是要小心使用,因为它违背了封装原则。只有当子类和超类之间确实存在子类型关系时,使用继承才是恰当的。即便如此,如果子类和超类处在不同的包中,并且超类并不是为了继承而设计的,那么继承将会导致脆弱性。为了避免脆弱性,可以用组合复用来代替继承,尤其是当存在在适当的接口可以实现包装类的时候。包装类不仅比子类更加健壮,而且功能也更加强大。