Java 小白成长记 · 第 6 篇「为什么说要慎用继承,优先使用组合」
0. 前言
在代码的编写过程中,避免冗余代码的出现是非常重要的,大段大段的重复代码必然不能够称之为优雅。所谓减少冗余代码,通俗来说就是实现一段代码多处使用,「在不污染源代码的前提下使用现存代码」,也就是代码「复用」,避免重复编写。然而,对于像 C 语言等面向过程的语言来说,复用通常指的仅仅只是「复制代码」,任何语言都可通过简单的复制来达到代码复用的目的,显然这样做的效果并不好。
Java 作为一种面向对象的语言,围绕「类」来解决冗余代码的问题。我们可以直接使用别人构建的代码,而非创建新类、重新开始或者无脑的复制代码。
Java 中实现代码复用的手段有两种,标题也写的很清楚:
-
第一种手段:组合 -
第二种手段:继承
本文会先分别讲解什么是继承,什么是组合,最后再揭开标题的谜底 — 「为什么说要慎用继承,优先使用组合」。
1. 什么是组合
所谓组合(Composition),就是「在新类中创建现有类的对象」。不管是继承和组合,都允许在新类中直接复用旧类的「公有」方法或字段。
举个例子,比如说所有的动物都拥有心跳 beat 和呼吸 breath,我们将心跳和呼吸抽象成一个类 Animal
,这个类就称为现有类,现在有一个动物:猫 Cat
,那么 Cat
这个类就称为新类,「将 Animal
类的对象嵌入 Cat
这个类中,Cat
就具有了心跳和呼吸」,这就使用了组合。
通俗来说 Cat
拥有 Animal
,即 「has-a」 的关系。以后再有其他动物的出现,比如狗 Dog
,也同样将 Animal
类嵌入其中使其具有心跳和呼吸即可,不必重复的写心跳和呼吸方法的代码。这便是组合的全部意义。UML 类图如下:
代码示例如下:
public class Animal {
public void beat(){
System.out.println("My heart is beating");
}
public void breath(){
System.out.println("I'm breathing");
}
}
Cat
拥有 Animal
,不仅拥有了呼吸和心跳功能,并且还可以添加自己的新属性,使其具有新的方法:
public class Cat {
// 组合
private Animal animal;
// 使用构造函数初始化成员变量
public Cat(Animal animal){
this.animal = animal;
}
// 通过调用成员变量的固有方法使新类具有相同的功能
public void breath(){
animal.breath();
}
// 通过调用成员变量的固有方法使新类具有相同的功能
public void beat(){
animal.beat();
}
// 为新类增加新的方法
public void run(){
System.out.println("I'm running");
}
}
这样,Cat
这个新类拥有了三种方法:breath / beat / run:
// 显式创建被组合的对象实例 animal
Animal animal = new Animal();
// 以 animal 为基础组合出新对象实例 cat
Cat cat = new Bird(animal);
// 新对象实例 cat 可以 breath()
cat.breath();
// 新对象实例 cat 可以 beat()
cat.beat();
// 新对象实例 cat 可以 run()
cat.run();
以上便是组合实现复用的方式,Cat
对象由 Animal
对象组合而成,如上面的示例代码,在创建 Cat
对象之前先创建 Animal
对象,并利用这个 Animal
对象来创建 Cat
对象。
实际上,组合表示出来的是一种明确的「整体-部分」的关系。而对于继承来说,是将某一个抽象的类,改造成能够适用于不同特定需求的类。
2. 什么是继承
还从上面的例子的入手,上面我们使用组合复用了 Animal
类,事实上,也可以使用继承实现 Animal
类的复用。
对于 Cat
和 Animal
,我们还可以这样理解,Cat
「是」一种 Animal
,即 「is-a」 的关系。这样,Cat
称为「子类(派生类)」,Animal
称为 Cat
的「父类(超类、基类)」。在组合中,新类 Cat
访问旧类 Animal
中的属性需要通过内嵌的旧类对象来调用,而对于继承来说,「新类(子类)可以直接调用旧类(父类)的公有属性」。UML 类图如下:
Java 中的继承关系使用关键字 extends
来标识,示例代码如下:
public class Cat extends Animal{
// 为新类增加新的方法
public void run(){
System.out.println("I'm running");
}
}
Cat
继承 Animal
后,自动拥有了父类 Animal
中的方法 beat
和 breath
,并可以直接调用,代码如下:
Cat cat = new Cat();
// 子类实例 cat 可以 breath()
cat.breath();
// 子类实例 cat 可以 beat()
cat.beat();
// 子类实例 cat 可以 run()
cat.run();
以上便是继承实现复用的方式,Cat
继承自抽象的类 Animal
,并将其改造成能够适用于某种特定需求的类。
3. 方法覆盖 / 重写
子类继承父类后,不仅可以直接调用父类的方法,还可以对父类的方法进行重写,使其拥有自己的特征。仍然以上面的 Cat
和 Animal
为例,假设 Cat
继承 Animal
后,对 Animal
原生的呼吸方法 breath
很不满意,但是你不能不呼吸对吧,所以这个时候就可以直接对 breath
方法的方法体进行重写。
「注意,重写和重载不同」,在Java 小白成长记第 4 篇中我们说过,重载指的是两个方法具有相同的名字,但是不同的参数,而「重写不仅方法名相同,参数列表和返回类型也相同」。示例代码如下:
public class Cat extends Animal{
......
// 重写 breath 方法
@Override
public void breath(){
System.out.println("I'm cat, " + super.breath());
}
}
@Override
注解即表示方法重写,不过这个也可以不写,JVM 能够自动的识别方法覆盖。
上面这个方法输出的将是 I'm cat, I'm breathing,也就是说,在子类中可以使用 super
关键字调用父类的方法。
另外,一定要注意的是:「在覆盖一个方法的时候,子类方法不能低于父类方法的可见性」。特别是, 如果超类方法是 public
, 子类方法一定要声明为 public
。常会发生这类错误:在声明子类方法的时候, 遗漏了 public
修饰符。此时,编译器将会把它解释为试图提供更严格的访问权限:
4. 子类的构造函数
现在,我们为父类 Animal
添加一个私有字段 age
,每个动物都有年龄嘛,当然,对于子类来说,这个私有字段它们是无法访问的。
public class Animal {
// 新增一个私有字段
private int age;
// 父类的构造函数
public Animal(int age) {
this.age = age;
}
......
}
同样的,我们规定在构造 Cat
的时候,需要为其指定年龄 age 和猫耳的类型 earKind,这就需要使用子类的构造函数了:
public class Cat extends Animal{
private String earKind;
public Cat(int age, String earKind) {
super(age);
this.earKind = earKind;
}
.........
}
可以看出,我们通过 super(age)
调用了父类的构造函数为这个猫指定了年龄,这个同 this
关键字一样,「使用 super
调用构造函数的语句必须是子类构造函数的第一条语句」。
❝「如果子类的构造器没有显式地调用父类的构造器, 则将自动地调用父类默认的构造函数(无参构造函数)」。如果超类没有无参构造函数, 并且在子类的构造器中又没有显式地调用超类的其他构造器,则 Java 编译器将报告错误。
❞
需要注意的是:「父类的构造函数总是先于子类的构造函数执行」。这点应该很好理解,你不能说先构造一个个猫出来,再给他添加呼吸和心跳对吧,你一定是先有呼吸和心跳,才有这个猫的。
5. 向上转型和向下转型
① 向上转型
继承最重要的方面不是为子类提供方法。它是子类与父类的一种关系。简而言之,上文我们也说过,这种关系可以表述为「子类是父类的一种类型」。这种描述并非是解释继承的一种花哨方式,这是直接由语言支持的。下面例子展示了编译器是如何支持这一概念的:
Animal cat = new Cat(...); // 向上转型 Cat->Animal
也就是说,「程序中出现父类对象的任何地方都可以用子类对象置换」,这便是「向上转型」。通过子类对象 (小范围) 实例化父类对象(大范围),这种属于自动转换。事实上,这是「多态」的一种体现。后续文章我们会详细讲解。
需要注意的是:「父类引用变量指向子类对象后,只能使用父类已声明的方法」,但方法如果被重写会执行子类的方法,如果方法未被重写那么将执行父类的方法。
② 向下转型
不仅存在向上转型,还存在向下转型。正像有时候需要将浮点型数值 float 转换成整型数值 int 一样,有时候也可能需要「将某个父类的对象引用转换成子类的对象引用,调用一些子类特有而父类没有的方法」。对象向下转型的语法与数值表达式的类型转换类似,仅需要用一对圆括号将目标类名括起来,并放置在需要转换的对象引用之前就可以了。例如:
Animal animal = new Cat(...); // 向上转型 Cat->Animal
Cat cat = (Cat) animal; // 向下转型 Animal->Cat,animal 的实质还是指向 Cat
6. 受保护访问 protected
大家都知道,最好将类中的域标记为 private
, 而方法标记为 public
。任何声明为 private
的内容对其他类都是不可见的。前面已经看到, 这对于子类来说也完全适用,即子类也不能访问父类的私有域。
然而,在有些时候,人们希望父类中的某些方法或字段允许被子类访问,为此, 需要将这些方法或域声明为 protected
。上篇文章说过,「这个访问修饰符提供包访问权限和子类访问权限」。例如,如果将父类 Animal
中的 age
声明为 proteced
,而不是私有的, Cat
中的方法就可以直接地访问它,「即使子类和父类不在一个包下」。这表明子类得到信任,可以正确地使用这个方法,而不和父类在同一个包下的其他类则不行。
7. Java 中的单继承
在深入学习 Java 之前,我学的其实是 C++,而 C++ 是支持多继承的,也就是说 A 可以同时继承 B 和 C 甚至更多。然而,「在 Java 中,子类只能继承一个父类」。也就是「单继承」。
为啥 Java 和 C++ 都是面向对象的,C++ 支持多继承和 Java 却不支持呢?C++ 语言是 1983 年由贝尔实验室的 Bjarne Stroustrup 在 C 语言的基础上推出的,Java 语言是 1995 年由 James Gosling 和同事共同正式推出的。在 C++ 被设计出来后,太多人掉进了多继承带来的坑,虽然它也提出了相应的解决办法,「但 Java 语言本着简单的原则舍弃了 C++ 中的多继承,这样也会使程序更具安全性」。
那么多继承到底带来什么坑?其实也不难理解:
如果一个子类拥有多个父类的话,那么当多个父类中有重复的属性或者方法时,子类的调用结果就会含糊不清,也就是存在「二义性」。因此 Java 使用了单继承。
那么问题来了,假设有一个人鱼种类,它既拥有动物 Animal
的特征,又拥有人 Person
的特征,既然不支持多继承,它如何同时具有这两个的特征呢?这时候就可以使用「多接口(多实现)」,通过实现多个接口拓展类的功能,即使实现的多个接口中有重复的方法也没关系,因为在实现类中必须重写接口中的方法,所以调用的时候调用的是实现类中重写的方法。接口部分是后话了,本文暂且不做讨论。
8. 为什么说要慎用继承,优先使用组合
终于来到了文章标题,为什么说要「慎用继承,优先使用组合」?
因为在 Java 中使用继承就无法避免以下这两个问题:
-
1)打破了封装性,违反了 OOP 原则。迫使开发者去了解父类的实现细节,子类和父类耦合 -
2)父类更新后可能会导致一些不可知的错误
这么说大家可能还无法直观的感受,这样,我们举个例子:自定义一个子类 MyHashSet
,它继承了 Java 的原生 API HashSet
,并重写了父类的两个方法 add
和 addAll
,它和父类唯一的区别是加入了一个计数器,用来统计添加过多少个元素。
public class MyHashSet<E> extends HashSet<E> {
private int addCount = 0;
// 获取 addCount
public int getAddCount() {
return addCount;
}
// 重写父类的 add 方法
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
// 重写父类的 add 方法
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
}
HashSet
是集合章节的内容,后续会详细讲解,这里大家只需要知道 add
用来向集合中添加一个元素,addAll
用来向集合中添加多个元素即可。
按照上面子类重写的逻辑,每向集合中添加一个元素,addCount
就会相应的增加一个。
MyHashSet<Integer> myHashSet = new MyHashSet<Integer>();
myHashSet.addAll(Arrays.asList(1,2,3));
System.out.println(myHashSet.getAddCount());
上面这段测试代码我们通过子类重写的 addAll
方法向集合中添加了 3 个元素,按理来说,addCount
应该是 3。然而,运行结果却是 6。这看起来确实很匪夷所思。
我们进入父类 HashSet
的源码看看,就能发现出错的原因:
addAll
方法内部调用的是 add()
方法。也就是说,按照上面子类重写的逻辑,子类在调用自己的 addAll()
方法时,首先 addCount 会加 3,然后调用父类的 addAll()
方法,父类的 addAll()
又会调用子类的 add()
方法三次,这样 addCount 又会再加 3。
出现这种情况的原因,就是「父类中可覆盖的方法调用了别的可覆盖的方法,这时候如果子类覆盖了其中的一些方法,就可能导致错误」。
结合上图理解,HashSet
类里有可覆盖的方法 addAll
和方法 add
,并且 addAll
调用了 add
。子类 MyHashSet
重写了方法 add
,这时候如果子类调用继承来的方法 addAll
,那么方法 addAll
调用的就不再是父类的 HashSet.add()
,而是子类中的方法 MyHashSet.add()
。
显然,这样的问题出现后,开发人员会一脸懵逼,子类的写法从表面上看来完全没有问题,这就迫使开发认域去了解父类的实现细节,从而打破了面向对象的封装性,因为封装性是要求隐藏实现细节的。更危险的是,错误不一定能轻易地被测出来,如果开发者不了解超类的实现细节就进行重写,那么可能就埋下了隐患。
第二个使用继承的缺点即父类更新后可能会导致一些不可知的错误,这点很好理解:
-
1)父类更改了方法的签名,会导致编译错误 -
2)父类新增了方法,并且正好和子类的某个方法同名但是返回类型不同,会导致编译错误 -
3)父类新增了方法,并且正好和子类的某个方法的签名完全相同,这时候编译器会认为子类进行了方法重写,会导致编译错误 -
4)......
说到这里,大家大概了解了为什么说要慎重使用继承了吧,「如果使用继承和组合都可以处理某种情况,那么优先使用组合」,组合完美的解决了上述继承的缺点。而如果必须要使用继承,那么应该精心设计父类,防止上述问题的发生,并提供详细的开发文档。