前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Java 小白成长记 · 第 6 篇「为什么说要慎用继承,优先使用组合」

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

作者头像
飞天小牛肉
发布2021-02-26 11:38:40
9300
发布2021-02-26 11:38:40
举报
文章被收录于专栏:飞天小牛肉

0. 前言

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

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

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

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

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

1. 什么是组合

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

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

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

代码示例如下:

代码语言:javascript
复制
public class Animal {
 public void beat(){
  System.out.println("My heart is beating");
 }
 public void breath(){
  System.out.println("I'm breathing");
 }
}

Cat 拥有 Animal,不仅拥有了呼吸和心跳功能,并且还可以添加自己的新属性,使其具有新的方法:

代码语言:javascript
复制
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:

代码语言:javascript
复制
// 显式创建被组合的对象实例 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 中的继承关系使用关键字 extends 来标识,示例代码如下:

代码语言:javascript
复制
public class Cat extends Animal{
    // 为新类增加新的方法
 public void run(){
  System.out.println("I'm running");  
 }
}

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

代码语言:javascript
复制
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 篇中我们说过,重载指的是两个方法具有相同的名字,但是不同的参数,而「重写不仅方法名相同,参数列表和返回类型也相同」。示例代码如下:

代码语言:javascript
复制
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,每个动物都有年龄嘛,当然,对于子类来说,这个私有字段它们是无法访问的。

代码语言:javascript
复制
public class Animal {
    // 新增一个私有字段
    private int age; 
    
    // 父类的构造函数
    public Animal(int age) { 
        this.age = age;
    }
 ......
}

同样的,我们规定在构造 Cat 的时候,需要为其指定年龄 age 和猫耳的类型 earKind,这就需要使用子类的构造函数了:

代码语言:javascript
复制
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. 向上转型和向下转型

① 向上转型

继承最重要的方面不是为子类提供方法。它是子类与父类的一种关系。简而言之,上文我们也说过,这种关系可以表述为「子类是父类的一种类型」。这种描述并非是解释继承的一种花哨方式,这是直接由语言支持的。下面例子展示了编译器是如何支持这一概念的:

代码语言:javascript
复制
Animal cat = new Cat(...); // 向上转型 Cat->Animal

也就是说,「程序中出现父类对象的任何地方都可以用子类对象置换」,这便是「向上转型」。通过子类对象 (小范围) 实例化父类对象(大范围),这种属于自动转换。事实上,这是「多态」的一种体现。后续文章我们会详细讲解。

需要注意的是:「父类引用变量指向子类对象后,只能使用父类已声明的方法」,但方法如果被重写会执行子类的方法,如果方法未被重写那么将执行父类的方法。

② 向下转型

不仅存在向上转型,还存在向下转型。正像有时候需要将浮点型数值 float 转换成整型数值 int 一样,有时候也可能需要「将某个父类的对象引用转换成子类的对象引用,调用一些子类特有而父类没有的方法」。对象向下转型的语法与数值表达式的类型转换类似,仅需要用一对圆括号将目标类名括起来,并放置在需要转换的对象引用之前就可以了。例如:

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

代码语言:javascript
复制
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 就会相应的增加一个。

代码语言:javascript
复制
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)......

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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-01-29,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 飞天小牛肉 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 0. 前言
  • 1. 什么是组合
  • 2. 什么是继承
  • 3. 方法覆盖 / 重写
  • 4. 子类的构造函数
  • 5. 向上转型和向下转型
    • ① 向上转型
      • ② 向下转型
      • 6. 受保护访问 protected
      • 7. Java 中的单继承
      • 8. 为什么说要慎用继承,优先使用组合
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档