「万字图文」史上最姨母级Java继承详解

459次阅读  |  发布于3年以前

课程导学

在Java课堂中,所有老师不得不提到面向对象(Object Oriented),而在谈到面向对象的时候,又不得不提到面向对象的三大特征:封装、继承、多态。三大特征紧密联系而又有区别,本课程就带你学习Java的继承

你可能不知道继承到底有什么用,但你大概率曾有过这样的经历:写Java项目/作业时候创建很多相似的类,类中也有很多相同的方法,做了很多重复的工作量,感觉很臃肿。而合理使用继承就能大大减少重复代码,提高代码复用性。

继承的初相识

学习继承,肯定是先从广的概念了解继承是什么以及其作用,然后才从细的方面学习继承的具体实现细节,本关就是带你先快速了解和理解继承的重要概念。

什么是继承

继承(英语:inheritance)是面向对象软件技术中的一个概念。它使得复用以前的代码非常容易,能够大大缩短开发周期,降低开发费用。

Java语言是非常典型的面向对象的语言,在Java语言中继承就是子类继承父类的属性和方法,使得子类对象(实例)具有父类的属性和方法,或子类从父类继承方法,使得子类具有父类相同的方法。父类有时也叫基类、超类;子类有时也被称为派生类。

我们来举个例子:我们知道动物有很多种,是一个比较大的概念。在动物的种类中,我们熟悉的有猫(Cat)、狗(Dog)等动物,它们都有动物的一般特征(比如能够吃东西,能够发出声音),不过又在细节上有区别(不同动物的吃的不同,叫声不一样)。在Java语言中实现Cat和Dog等类的时候,就需要继承Animal这个类。继承之后Cat、Dog等具体动物类就是子类,Animal类就是父类。

为什么需要继承

你可能会疑问为什么需要继承?在具体实现的时候,我们创建Dog,Cat等类的时候实现其具体的方法不就可以了嘛,实现这个继承似乎使得这个类的结构不那么清晰。

如果仅仅只有两三个类,每个类的属性和方法很有限的情况下确实没必要实现继承,但事情并非如此,事实上一个系统中往往有很多个类并且有着很多相似之处,比如猫和狗同属动物,或者学生和老师同属人。各个类可能又有很多个相同的属性和方法,这样的话如果每个类都重新写不仅代码显得很乱,代码工作量也很大。

这时继承的优势就出来了:可以直接使用父类的属性和方法,自己也可以有自己新的属性和方法满足拓展,父类的方法如果自己有需求更改也可以重写。这样使用继承不仅大大的减少了代码量,也使得代码结构更加清晰可见

所以这样从代码的层面上来看我们设计这个完整的Animal类是这样的:

class Animal
{
    public int id;
    public String name;
    public int age;
    public int weight;

    public Animal(int id, String name, int age, int weight) {
        this.id = id;
        this.name = name;
        this.age = age;
        this.weight = weight;
    }
    //这里省略get set方法
    public void sayHello()
    {
        System.out.println("hello");
    }
    public void eat()
    {
        System.out.println("I'm eating");
    }
    public void sing()
    {
        System.out.println("sing");
    }
}

而Dog,Cat,Chicken类可以这样设计:

class Dog extends Animal//继承animal
{
    public Dog(int id, String name, int age, int weight) {
        super(id, name, age, weight);//调用父类构造方法
    }
}
class Cat extends Animal{

    public Cat(int id, String name, int age, int weight) {
        super(id, name, age, weight);//调用父类构造方法
    }
}
class Chicken extends Animal{

    public Chicken(int id, String name, int age, int weight) {
        super(id, name, age, weight);//调用父类构造方法
    }
    //鸡下蛋
    public void layEggs()
    {
        System.out.println("我是老母鸡下蛋啦,咯哒咯!咯哒咯!");
    }
}

各自的类继承Animal后可以直接使用Animal类的属性和方法而不需要重复编写,各个类如果有自己的方法也可很容易地拓展。上述代码中你需要注意extends就是用来实现继承的。

继承的分类

继承分为单继承和多继承,Java语言只支持类的单继承,但可以通过实现接口的方式达到多继承的目的。我们先用一张表概述一下两者的区别,然后再展开讲解。

定义 优缺点
单继承 一个子类只拥有一个父类 优点:在类层次结构上比较清晰缺点:结构的丰富度有时不能满足使用需求
多继承(Java不支持,但可以用其它方式满足多继承使用需求) 一个子类拥有多个直接的父类 优点:子类的丰富度很高缺点:容易造成混乱

单继承

单继承,是一个子类只拥有一个父类,如我们上面讲过的Animal类和它的子类。单继承在类层次结构上比较清晰,但缺点是结构的丰富度有时不能满足使用需求

多继承(Java不支持,但可以实现)

多继承,是一个子类拥有多个直接的父类。这样做的好处是子类拥有所有父类的特征,子类的丰富度很高,但是缺点就是容易造成混乱。下图为一个混乱的例子。

Java虽然不支持多继承,但是Java有三种实现多继承效果的方式,分别是内部类、多层继承和实现接口。

内部类可以继承一个与外部类无关的类,保证了内部类的独立性,正是基于这一点,可以达到多继承的效果。

多层继承:子类继承父类,父类如果还继承其他的类,那么这就叫多层继承。这样子类就会拥有所有被继承类的属性和方法。

实现接口无疑是满足多继承使用需求的最好方式,一个类可以实现多个接口满足自己在丰富性和复杂环境的使用需求。类和接口相比,类就是一个实体,有属性和方法,而接口更倾向于一组方法。举个例子,就拿斗罗大陆的唐三来看,他存在的继承关系可能是这样的:

如何实现继承

实现继承除了上面用到的extends外,还可以用implements这个关键字实现。下面,让我给你逐一讲解一下。

extends关键字

在Java中,类的继承是单一继承,也就是说一个子类只能拥有一个父类,所以extends只能继承一个类。其使用语法为:

class 子类名 extends 父类名{}

例如Dog类继承Animal类,它是这样的:

class Animal{} //定义Animal类
class Dog extends Animal{} //Dog类继承Animal类

子类继承父类后,就拥有父类的非私有的属性和方法。如果不明白,请看这个案例,在IDEA下创建一个项目,创建一个test类做测试,分别创建Animal类和Dog类,Animal作为父类写一个sayHello()方法,Dog类继承Animal类之后就可以调用sayHello()方法。具体代码为:

class Animal {
    public void  sayHello()//父类的方法
    {
        System.out.println("hello,everybody");
    }
}
class Dog extends Animal//继承animal
{ }
public class test {
    public static void main(String[] args) {
       Dog dog=new Dog();
       dog.sayHello();
    }
}

点击运行的时候Dog子类可以直接使用Animal父类的方法。

implements 关键字

使用implements 关键字可以变相使Java拥有多继承的特性,使用范围为类实现接口的情况,一个类可以实现多个接口(接口与接口之间用逗号分开)。Java接口是一系列方法的声明,一个接口中没有方法的具体实现 。子类实现接口的时候必须重写接口中的方法。

我们来看一个案例,创建一个test2类做测试,分别创建doA接口和doB接口,doA接口声明sayHello()方法,doB接口声明eat()方法,创建Cat2类实现doA和doB接口,并且在类中需要重写sayHello()方法和eat()方法。具体代码为:

interface doA{
     void sayHello();
}
interface doB{
     void eat();
    //以下会报错 接口中的方法不能具体定义只能声明
    //public void eat(){System.out.println("eating");}
}
class Cat2 implements  doA,doB{
    @Override//必须重写接口内的方法
    public void sayHello() {
        System.out.println("hello!");
    }
    @Override
    public void eat() {
        System.out.println("I'm eating");
    }
}
public class test2 {
    public static void main(String[] args) {
        Cat2 cat=new Cat2();
        cat.sayHello();
        cat.eat();
    }
}

Cat类实现doA和doB接口的时候,需要实现其声明的方法,点击运行结果如下,这就是一个类实现接口的简单案例:

继承的特点

继承的主要内容就是子类继承父类,并重写父类的方法。使用子类的属性或方法时候,首先要创建一个对象,而对象通过构造方法去创建,在构造方法中我们可能会调用子父类的一些属性和方法,所以就需要提前掌握this和super关键字。创建完这个对象之后,在调用重写父类的方法,并区别重写和重载的区别。所以本节根据this、super关键字—>构造函数—>方法重写—>方法重载的顺序进行讲解。

this和super关键字

this和super关键字是继承中非常重要的知识点,分别表示当前对象的引用和父类对象的引用,两者有很大相似又有一些区别。

this表示当前对象,是指向自己的引用。

this.属性 // 调用成员变量,要区别成员变量和局部变量
this.() // 调用本类的某个方法
this() // 表示调用本类构造方法

super表示父类对象,是指向父类的引用。

super.属性 // 表示父类对象中的成员变量
super.方法() // 表示父类对象中定义的方法
super() // 表示调用父类构造方法

此外,this和super关键字只能出现在非static修饰的代码中。

this()和super()都只能在构造方法的第一行出现,如果使用this()表示调用当前类的其他构造方法,使用super()表示调用父类的某个构造方法,所以两者只能根据自己使用需求选择其一。

写一个小案例,创建D1类和子类D2如下:

class D1{
    public D1() {}//无参构造
    public void sayHello() {
        System.out.println("hello");
    }
}
class D2 extends D1{
    public String name;
    public D2(){
        super();//调用父类构造方法
        this.name="BigSai";//给当前类成员变量赋值
    }
    @Override
    public void sayHello() {
        System.out.println("hello,我是"+this.name);
    }
    public void test()
    {
        super.sayHello();//调用父类方法
        this.sayHello();//调用当前类其他方法
    }
}
public class test8 {
    public static void main(String[] args) {
        D2 d2=new D2();
        d2.test();
    }
}

执行的结果为:

构造方法

构造方法是一种特殊的方法,它是一个与类同名的方法。对象的创建就通过构造方法来完成,其主要的功能是完成对象的初始化。但在继承中构造方法是一种比较特殊的方法(比如不能继承),所以要了解和学习在继承中构造方法的规则和要求。

构造方法可分为有参构造和无参构造,这个可以根据自己的使用需求合理设置构造方法。但继承中的构造方法有以下几点需要注意:

父类的构造方法不能被继承:

因为构造方法语法是与类同名,而继承则不更改方法名,如果子类继承父类的构造方法,那明显与构造方法的语法冲突了。比如Father类的构造方法名为Father(),Son类如果继承Father类的构造方法Father(),那就和构造方法定义:构造方法与类同名冲突了,所以在子类中不能继承父类的构造方法,但子类会调用父类的构造方法。

子类的构造过程必须调用其父类的构造方法:

Java虚拟机构造子类对象前会先构造父类对象,父类对象构造完成之后再来构造子类特有的属性,这被称为内存叠加。而Java虚拟机构造父类对象会执行父类的构造方法,所以子类构造方法必须调用super()即父类的构造方法。就比如一个简单的继承案例应该这么写:

class A{
    public String name;
    public A() {//无参构造
    }
    public A (String name){//有参构造
    }
}
class B extends A{
    public B() {//无参构造
       super();
    }
    public B(String name) {//有参构造
      //super();
       super(name);
    }
}

如果子类的构造方法中没有显示地调用父类构造方法,则系统默认调用父类无参数的构造方法。

你可能有时候在写继承的时候子类并没有使用super()调用,程序依然没问题,其实这样是为了节省代码,系统执行时会自动添加父类的无参构造方式,如果不信的话我们对上面的类稍作修改执行:

image-20201026201029796

方法重写(Override)

方法重写也就是子类中出现和父类中一模一样的方法(包括返回值类型,方法名,参数列表),它建立在继承的基础上。你可以理解为方法的外壳不变,但是核心内容重写

在这里提供一个简单易懂的方法重写案例:

class E1{
    public void doA(int a){
        System.out.println("这是父类的方法");
    }
}
class E2 extends E1{
    @Override
    public void doA(int a) {
        System.out.println("我重写父类方法,这是子类的方法");
    }
}

其中@Override注解显示声明该方法为注解方法,可以帮你检查重写方法的语法正确性,当然如果不加也是可以的,但建议加上。

对于重写,你需要注意以下几点:

从重写的要求上看:

从访问权限上看:

从静态和非静态上看:

从抽象和非抽象来看:

当然,这些规则可能涉及一些修饰符,在第三关中会详细介绍。

方法重载(Overload)

如果有两个方法的方法名相同,但参数不一致,那么可以说一个方法是另一个方法的重载。方法重载规则如下:

重载可以通常理解为完成同一个事情的方法名相同,但是参数列表不同其他条件也可能不同。一个简单的方法重载的例子,类E3中的add()方法就是一个重载方法。

class E3{
    public int add(int a,int b){
        return a+b;
    }
    public double add(double a,double b) {
        return a+b;
    }
    public int add(int a,int b,int c) {
        return a+b+c;
    }
}

方法重写和方法重载的区别

方法重写和方法重载名称上容易混淆,但内容上有很大区别,下面用一个表格列出其中区别:

区别点 方法重写 方法重载
结构上 垂直结构,是一种父子类之间的关系 水平结构,是一种同类之间关系
参数列表 不可以修改 可以修改
访问修饰符 子类的访问修饰符范围必须大于等于父类访问修饰符范围 可以修改
抛出异常 子类方法异常必须是父类方法异常或父类方法异常子异常 可以修改

继承与修饰符

Java修饰符的作用就是对类或类成员进行修饰或限制,每个修饰符都有自己的作用,而在继承中可能有些特殊修饰符使得被修饰的属性或方法不能被继承,或者继承需要一些其他的条件,下面就详细介绍在继承中一些修饰符的作用和特性。

Java语言提供了很多修饰符,修饰符用来定义类、方法或者变量,通常放在语句的最前端。主要分为以下两类:

这里访问修饰符主要讲解public,protected,default,private四种访问控制修饰符。非访问修饰符这里就介绍static修饰符,final修饰符和abstract修饰符。

访问修饰符

public,protected,default(无修饰词),private修饰符是面向对象中非常重要的知识点,而在继承中也需要懂得各种修饰符使用规则。

首先我们都知道不同的关键字作用域不同,四种关键字的作用域如下:

同一个类 同一个包 不同包子类 不同包非子类
private
default
protect
public
  1. private:Java语言中对访问权限限制的最窄的修饰符,一般称之为“私有的”。被其修饰的属性以及方法只能被该类的对象访问,其子类不能访问,更不能允许跨包访问。
  2. default:(也有称friendly)即不加任何访问修饰符,通常称为“默认访问权限“或者“包访问权限”。该模式下,只允许在同一个包中进行访问。
  3. protected:介于public 和 private 之间的一种访问修饰符,一般称之为“保护访问权限”。被其修饰的属性以及方法只能被类本身的方法及子类访问,即使子类在不同的包中也可以访问。
  4. public:Java语言中访问限制最宽的修饰符,一般称之为“公共的”。被其修饰的类、属性以及方法不仅可以跨类访问,而且允许跨包访问。

Java 子类重写继承的方法时,不可以降低方法的访问权限子类继承父类的访问修饰符作用域不能比父类小,也就是更加开放,假如父类是protected修饰的,其子类只能是protected或者public,绝对不能是default(默认的访问范围)或者private。所以在继承中需要重写的方法不能使用private修饰词修饰。

如果还是不太清楚可以看几个小案例就很容易搞懂,写一个A1类中用四种修饰词实现四个方法,用子类A2继承A1,重写A1方法时候你就会发现父类私有方法不能重写,非私有方法重写使用的修饰符作用域不能变小(大于等于)。

正确的案例应该为:

class A1 {
    private void doA(){ }
    void doB(){}//default
    protected void doC(){}
    public void doD(){}
}
class A2 extends A1{

    @Override
    public void doB() { }//继承子类重写的方法访问修饰符权限可扩大

    @Override
    protected void doC() { }//继承子类重写的方法访问修饰符权限可和父类一致

    @Override
    public void doD() { }//不可用protected或者default修饰
}

还要注意的是,继承当中子类抛出的异常必须是父类抛出的异常或父类抛出异常的子异常。下面的一个案例四种方法测试可以发现子类方法的异常不可大于父类对应方法抛出异常的范围。

正确的案例应该为:

class B1{
    public void doA() throws Exception{}
    public void doB() throws Exception{}
    public void doC() throws IOException{}
    public void doD() throws IOException{}
}
class B2 extends B1{
    //异常范围和父类可以一致
    @Override
    public void doA() throws Exception { }
    //异常范围可以比父类更小
    @Override
    public void doB() throws IOException { }
    //异常范围 不可以比父类范围更大
    @Override
    public void doC() throws IOException { }//不可抛出Exception等比IOException更大的异常
    @Override
    public void doD() throws IOException { }
}

非访问修饰符

访问修饰符用来控制访问权限,而非访问修饰符每个都有各自的作用,下面针对static、final、abstract修饰符进行介绍。

static 修饰符

static 翻译为“静态的”,能够与变量,方法和类一起使用,称为静态变量,静态方法(也称为类变量、类方法)。如果在一个类中使用static修饰变量或者方法的话,它们可以直接通过类访问,不需要创建一个类的对象来访问成员。

我们在设计类的时候可能会使用静态方法,有很多工具类比如MathArrays等类里面就写了很多静态方法。static修饰符的规则很多,这里仅仅介绍和Java继承相关用法的规则:

可以看以下的案例证明上述规则:

源代码为:

class C1{
    public  int a;
    public C1(){}
   // public static C1(){}// 构造方法不允许被声明为static
    public static void doA() {}
    public static void doB() {}
}
class C2 extends C1{
    public static  void doC()//静态方法中不存在当前对象,因而不能使用this和super。
    {
        //System.out.println(super.a);
    }
    public static void doA(){}//静态方法能被静态方法重写
   // public void doB(){}//静态方法不能被非静态方法重写
}

final修饰符

final变量:

final 方法:

final类:

所以无论是变量、方法还是类被final修饰之后,都有代表最终、最后的意思。内容无法被修改。

abstract 修饰符

abstract 英文名为“抽象的”,主要用来修饰类和方法,称为抽象类和抽象方法。

抽象方法:有很多不同类的方法是相似的,但是具体内容又不太一样,所以我们只能抽取他的声明,没有具体的方法体,即抽象方法可以表达概念但无法具体实现。

抽象类有抽象方法的类必须是抽象类,抽象类可以表达概念但是无法构造实体的类。

抽象类和抽象方法内容和规则比较多。这里只提及一些和继承有关的用法和规则:

比如我们可以这样设计一个People抽象类以及一个抽象方法,在子类中具体完成:

abstract class People{
    public abstract void sayHello();//抽象方法
}
class Chinese extends People{
    @Override
    public void sayHello() {//实现抽象方法
        System.out.println("你好");
    }
}
class Japanese extends People{
    @Override
    public void sayHello() {//实现抽象方法
        System.out.println("口你七哇");
    }
}
class American extends People{
    @Override
    public void sayHello() {//实现抽象方法
        System.out.println("hello");
    }
}

Object类和转型

提到Java继承,不得不提及所有类的根类:Object(java.lang.Object)类,如果一个类没有显式声明它的父类(即没有写extends xx),那么默认这个类的父类就是Object类,任何类都可以使用Object类的方法,创建的类也可和Object进行向上、向下转型,所以Object类是掌握和理解继承所必须的知识点。而Java向上和向下转型在Java中运用很多,也是建立在继承的基础上,所以Java转型也是掌握和理解继承所必须的知识点。

Object类概述

  1. Object是类层次结构的根类,所有的类都隐式的继承自Object类。
  2. Java所有的对象都拥有Object默认方法
  3. Object类的构造方法有一个,并且是无参构造

Object是java所有类的父类,是整个类继承结构的顶端,也是最抽象的一个类。像toString()、equals()、hashCode()、wait()、notify()、getClass()等都是Object的方法。你以后可能会经常碰到,但其中遇到更多的就是toString()方法和equals()方法,我们经常需要重写这两种方法满足我们的使用需求。

**toString()**方法表示返回该对象的字符串,由于各个对象构造不同所以需要重写,如果不重写的话默认返回类名@hashCode格式。

如果重写toString()方法后直接调用toString()方法就可以返回我们自定义的该类转成字符串类型的内容输出,而不需要每次都手动的拼凑成字符串内容输出,大大简化输出操作。

**equals()方法主要比较两个对象是否相等,因为对象的相等不一定非要严格要求两个对象地址上的相同,有时内容上的相同我们就会认为它相等,比如String 类就重写了euqals()**方法,通过字符串的内容比较是否相等。

向上转型

向上转型 : 通过子类对象(小范围)实例化父类对象(大范围),这种属于自动转换。用一张图就能很好地表示向上转型的逻辑:

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

向下转型

向下转型 : 通过父类对象(大范围)实例化子类对象(小范围),在书写上父类对象需要加括号()强制转换为子类类型。但父类引用变量实际引用必须是子类对象才能成功转型,这里也用一张图就能很好表示向上转型的逻辑:

子类引用变量指向父类引用变量指向的对象后(一个Son()对象),就完成向下转型,就可以调用一些子类特有而父类没有的方法 。

在这里写一个向上转型和向下转型的案例:

Object object=new Integer(666);//向上转型

Integer i=(Integer)object;//向下转型Object->Integer,object的实质还是指向Integer

String str=(String)object;//错误的向下转型,虽然编译器不会报错但是运行会报错

子父类初始化顺序

在Java继承中,父子类初始化先后顺序为:

  1. 父类中静态成员变量和静态代码块
  2. 子类中静态成员变量和静态代码块
  3. 父类中普通成员变量和代码块,父类的构造函数
  4. 子类中普通成员变量和代码块,子类的构造函数

总的来说,就是静态>非静态,父类>子类,非构造函数>构造函数。同一类别(例如普通变量和普通代码块)成员变量和代码块执行从前到后,需要注意逻辑。

这个也不难理解,静态变量也称类变量,可以看成一个全局变量,静态成员变量和静态代码块在类加载的时候就初始化,而非静态变量和代码块在对象创建的时候初始化。所以静态快于非静态初始化。

而在创建子类对象的时候需要先创建父类对象,所以父类优先于子类。

而在调用构造函数的时候,是对成员变量进行一些初始化操作,所以普通成员变量和代码块优于构造函数执行。

至于更深层次为什么这个顺序,就要更深入了解JVM执行流程啦。下面一个测试代码为:

class Father{
    public Father() {
        System.out.println(++b1+"父类构造方法");
    }//父类构造方法 第四
    static int a1=0;//父类static 第一 注意顺序
    static {
        System.out.println(++a1+"父类static");
    }
    int b1=a1;//父类成员变量和代码块 第三
    {
        System.out.println(++b1+"父类代码块");
    }
}
class Son extends Father{
    public Son() {
        System.out.println(++b2+"子类构造方法");
    }//子类构造方法 第六
    static {//子类static第二步
        System.out.println(++a1+"子类static");
    }
    int b2=b1;//子类成员变量和代码块 第五
    {
        System.out.println(++b2 + "子类代码块");
    }
}
public class test9 {
    public static void main(String[] args) {
        Son son=new Son();
    }
}

执行结果:

结语

好啦,本次继承就介绍到这里啦,Java面向对象三大特征之一继承——优秀的你已经掌握。再看看Java面向对象三大特性:封装、继承、多态。最后问你能大致了解它们的特征嘛?

封装:是对类的封装,封装是对类的属性和方法进行封装,只对外暴露方法而不暴露具体使用细节,所以我们一般设计类成员变量时候大多设为私有而通过一些get、set方法去读写。

继承:子类继承父类,即“子承父业”,子类拥有父类除私有的所有属性和方法,自己还能在此基础上拓展自己新的属性和方法。主要目的是复用代码

多态:多态是同一个行为具有多个不同表现形式或形态的能力。即一个父类可能有若干子类,各子类实现父类方法有多种多样,调用父类方法时,父类引用变量指向不同子类实例而执行不同方法,这就是所谓父类方法是多态的。

最后送你一张图捋一捋其中的关系吧。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8