vlambda博客
学习文章列表

Java 小白成长记 · 第 6 篇「为什么说要慎用继承,优先使用组合」


0. 前言

在代码的编写过程中,避免冗余代码的出现是非常重要的,大段大段的重复代码必然不能够称之为优雅。所谓减少冗余代码,通俗来说就是实现一段代码多处使用,「在不污染源代码的前提下使用现存代码」,也就是代码「复用」,避免重复编写。然而,对于像 C 语言等面向过程的语言来说,复用通常指的仅仅只是「复制代码」,任何语言都可通过简单的复制来达到代码复用的目的,显然这样做的效果并不好。

Java 作为一种面向对象的语言,围绕「类」来解决冗余代码的问题。我们可以直接使用别人构建的代码,而非创建新类、重新开始或者无脑的复制代码。

Java 中实现代码复用的手段有两种,标题也写的很清楚:

  • 第一种手段:组合
  • 第二种手段:继承

本文会先分别讲解什么是继承,什么是组合,最后再揭开标题的谜底 — 「为什么说要慎用继承,优先使用组合」。

1. 什么是组合

所谓组合(Composition),就是「在新类中创建现有类的对象」。不管是继承和组合,都允许在新类中直接复用旧类的「公有」方法或字段。

举个例子,比如说所有的动物都拥有心跳 beat 和呼吸 breath,我们将心跳和呼吸抽象成一个类 Animal,这个类就称为现有类,现在有一个动物:猫 Cat,那么 Cat 这个类就称为新类,「将 Animal 类的对象嵌入 Cat 这个类中,Cat 就具有了心跳和呼吸」,这就使用了组合。

通俗来说 Cat 拥有 Animal,即 「has-a」 的关系。以后再有其他动物的出现,比如狗 Dog,也同样将 Animal 类嵌入其中使其具有心跳和呼吸即可,不必重复的写心跳和呼吸方法的代码。这便是组合的全部意义。UML 类图如下:

Java 小白成长记 · 第 6 篇「为什么说要慎用继承,优先使用组合」

代码示例如下:

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 类的复用。

对于 CatAnimal,我们还可以这样理解,Cat 「是」一种 Animal,即 「is-a」 的关系。这样,Cat 称为「子类(派生类)」,Animal称为 Cat「父类(超类、基类)」。在组合中,新类 Cat 访问旧类 Animal 中的属性需要通过内嵌的旧类对象来调用,而对于继承来说,「新类(子类)可以直接调用旧类(父类)的公有属性」。UML 类图如下:

Java 小白成长记 · 第 6 篇「为什么说要慎用继承,优先使用组合」

Java 中的继承关系使用关键字 extends 来标识,示例代码如下:

public class Cat extends Animal{
    // 为新类增加新的方法
 public void run(){
  System.out.println("I'm running");  
 }
}

Cat 继承 Animal 后,自动拥有了父类 Animal 中的方法 beatbreath,并可以直接调用,代码如下:

Cat cat = new Cat();
// 子类实例 cat 可以 breath()
cat.breath();
// 子类实例 cat 可以 beat()
cat.beat();
// 子类实例 cat 可以 run()
cat.run();

以上便是继承实现复用的方式,Cat 继承自抽象的类 Animal,并将其改造成能够适用于某种特定需求的类。

3. 方法覆盖 / 重写

子类继承父类后,不仅可以直接调用父类的方法,还可以对父类的方法进行重写,使其拥有自己的特征。仍然以上面的 CatAnimal 为例,假设 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修饰符。此时,编译器将会把它解释为试图提供更严格的访问权限:

Java 小白成长记 · 第 6 篇「为什么说要慎用继承,优先使用组合」

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
Java 小白成长记 · 第 6 篇「为什么说要慎用继承,优先使用组合」

6. 受保护访问 protected

大家都知道,最好将类中的域标记为 private, 而方法标记为 public。任何声明为 private 的内容对其他类都是不可见的。前面已经看到, 这对于子类来说也完全适用,即子类也不能访问父类的私有域。

然而,在有些时候,人们希望父类中的某些方法或字段允许被子类访问,为此, 需要将这些方法或域声明为 protected。上篇文章说过,「这个访问修饰符提供包访问权限和子类访问权限」。例如,如果将父类 Animal中的 age声明为 proteced,而不是私有的, Cat中的方法就可以直接地访问它,「即使子类和父类不在一个包下」。这表明子类得到信任,可以正确地使用这个方法,而不和父类在同一个包下的其他类则不行。

Java 小白成长记 · 第 6 篇「为什么说要慎用继承,优先使用组合」

7. Java 中的单继承

在深入学习 Java 之前,我学的其实是 C++,而 C++ 是支持多继承的,也就是说 A 可以同时继承 B 和 C 甚至更多。然而,「在 Java 中,子类只能继承一个父类」。也就是「单继承」。

Java 小白成长记 · 第 6 篇「为什么说要慎用继承,优先使用组合」

为啥 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,并重写了父类的两个方法 addaddAll,它和父类唯一的区别是加入了一个计数器,用来统计添加过多少个元素。

public class MyHashSet<Eextends 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 的源码看看,就能发现出错的原因:

Java 小白成长记 · 第 6 篇「为什么说要慎用继承,优先使用组合」

addAll 方法内部调用的是 add() 方法。也就是说,按照上面子类重写的逻辑,子类在调用自己的 addAll() 方法时,首先 addCount 会加 3,然后调用父类的 addAll() 方法,父类的 addAll() 又会调用子类的 add() 方法三次,这样 addCount 又会再加 3。

Java 小白成长记 · 第 6 篇「为什么说要慎用继承,优先使用组合」

出现这种情况的原因,就是「父类中可覆盖的方法调用了别的可覆盖的方法,这时候如果子类覆盖了其中的一些方法,就可能导致错误」。

结合上图理解,HashSet 类里有可覆盖的方法 addAll 和方法 add,并且 addAll 调用了 add。子类 MyHashSet 重写了方法 add,这时候如果子类调用继承来的方法 addAll,那么方法 addAll 调用的就不再是父类的 HashSet.add(),而是子类中的方法 MyHashSet.add()

显然,这样的问题出现后,开发人员会一脸懵逼,子类的写法从表面上看来完全没有问题,这就迫使开发认域去了解父类的实现细节,从而打破了面向对象的封装性,因为封装性是要求隐藏实现细节的。更危险的是,错误不一定能轻易地被测出来,如果开发者不了解超类的实现细节就进行重写,那么可能就埋下了隐患。

第二个使用继承的缺点即父类更新后可能会导致一些不可知的错误,这点很好理解:

  • 1)父类更改了方法的签名,会导致编译错误
  • 2)父类新增了方法,并且正好和子类的某个方法同名但是返回类型不同,会导致编译错误
  • 3)父类新增了方法,并且正好和子类的某个方法的签名完全相同,这时候编译器会认为子类进行了方法重写,会导致编译错误
  • 4)......

说到这里,大家大概了解了为什么说要慎重使用继承了吧,「如果使用继承和组合都可以处理某种情况,那么优先使用组合」,组合完美的解决了上述继承的缺点。而如果必须要使用继承,那么应该精心设计父类,防止上述问题的发生,并提供详细的开发文档。



Java 小白成长记 · 第 6 篇「为什么说要慎用继承,优先使用组合」