深入理解 Java 继承
5.14 Java封装继承多态
“三妹,今天我们继续来学习 Java 的三大特征之一:继承,之前学习抽象类的时候我们已经接触过这个概念,不过,我们今天我们来认真地聊一聊。”慢吞吞说完这句开篇词后,我对三妹做了一个打开 XMind 的手势提醒(其实就是瞎比划)。
“懂了,哥,我这就打开。”三妹一边说,一边打开了 XMind,开始正襟危坐了起来。
在谈 Java 面向对象的时候,不得不提到面向对象的三大特征:封装、继承、多态。三大特征紧密联系而又有区别,合理使用继承能大大减少重复代码,提高代码复用性。
01、什么是继承
继承(英语:inheritance)是面向对象软件技术中的一个概念。它使得复用以前的代码非常容易。
Java 语言是非常典型的面向对象的语言,在 Java 语言中继承就是子类继承父类的属性和方法,使得子类对象(实例)具有父类的属性和方法,或子类从父类继承方法,使得子类具有父类相同的方法。
我们来举个例子:动物有很多种,是一个比较大的概念。在动物的种类中,我们熟悉的有猫(Cat)、狗(Dog)等动物,它们都有动物的一般特征(比如能够吃东西,能够发出声音),不过又在细节上有区别(不同动物的吃的不同,叫声不一样)。
在 Java 语言中实现 Cat 和 Dog 等类的时候,就需要继承 Animal 这个类。继承之后 Cat、Dog 等具体动物类就是子类,Animal 类就是父类。
02、为什么需要继承
三妹,你可能会问为什么需要继承?
如果仅仅只有两三个类,每个类的属性和方法很有限的情况下确实没必要实现继承,但事情并非如此,事实上一个系统中往往有很多个类并且有着很多相似之处,比如猫和狗同属动物,或者学生和老师同属人。各个类可能又有很多个相同的属性和方法,这样的话如果每个类都重新写不仅代码显得很乱,代码工作量也很大。
这时继承的优势就出来了:可以直接使用父类的属性和方法,自己也可以有自己新的属性和方法满足拓展,父类的方法如果自己有需求更改也可以重写。这样使用继承不仅大大的减少了代码量,也使得代码结构更加清晰可见。
所以这样从代码的层面上来看我们设计这个完整的 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 类的属性和方法而不需要重复编写,各个类如果有自己的方法也可很容易地拓展。
03、继承的分类
继承分为单继承和多继承,Java 语言只支持类的单继承,但可以通过实现接口的方式达到多继承的目的。这个我们之前在讲接口的时候就提到过,这里我们再聊一下。
继承 | 定义 | 优缺点 |
---|---|---|
单继承 | 一个子类只拥有一个父类 | 优点:在类层次结构上比较清晰 缺点:结构的丰富度有时不能满足使用需求 |
多继承(Java 不支持,但可以用其它方式满足多继承使用需求) | 一个子类拥有多个直接的父类 | 优点:子类的丰富度很高 缺点:容易造成混乱 |
单继承
单继承,一个子类只有一个父类,如我们上面讲过的 Animal 类和它的子类。单继承在类层次结构上比较清晰,但缺点是结构的丰富度有时不能满足使用需求。
多继承
多继承,一个子类有多个直接的父类。这样做的好处是子类拥有所有父类的特征,子类的丰富度很高,但是缺点就是容易造成混乱。下图为一个混乱的例子。
Java 虽然不支持多继承,但是 Java 有三种实现多继承效果的方式,分别是内部类、多层继承和实现接口。
内部类可以继承一个与外部类无关的类,保证了内部类的独立性,正是基于这一点,可以达到多继承的效果。
多层继承:子类继承父类,父类如果还继承其他的类,那么这就叫多层继承。这样子类就会拥有所有被继承类的属性和方法。
实现接口无疑是满足多继承使用需求的最好方式,一个类可以实现多个接口满足自己在丰富性和复杂环境的使用需求。
类和接口相比,类就是一个实体,有属性和方法,而接口更倾向于一组方法。举个例子,就拿斗罗大陆的唐三来看,他存在的继承关系可能是这样的:
04、如何实现继承
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 拥有多继承的特性,使用范围为类实现接口的情况,一个类可以实现多个接口(接口与接口之间用逗号分开)。
我们来看一个案例,创建一个 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 接口的时候,需要实现其声明的方法,点击运行结果如下,这就是一个类实现接口的简单案例:
05、继承的特点
继承的主要内容就是子类继承父类,并重写父类的方法。使用子类的属性或方法时候,首先要创建一个对象,而对象通过构造方法去创建,在构造方法中我们可能会调用子父类的一些属性和方法,所以就需要提前掌握 this 和 super关键字。
创建完这个对象之后,再调用重写父类后的方法,注意重写和重载的区别。
this 和 super 关键字
后面会详细讲,这里先来简单了解一下。
this 和 super 关键字是继承中非常重要的知识点,分别表示当前对象的引用和父类对象的引用,两者有很大相似又有一些区别。
this 表示当前对象,是指向自己的引用。
this.属性 // 调用成员变量,要区别成员变量和局部变量
this.() // 调用本类的某个方法
this() // 表示调用本类构造方法
super 表示父类对象,是指向父类的引用。
super.属性 // 表示父类对象中的成员变量
super.方法() // 表示父类对象中定义的方法
super() // 表示调用父类构造方法
构造方法
构造方法是一种特殊的方法,它是一个与类同名的方法。在继承中构造方法是一种比较特殊的方法(比如不能继承),所以要了解和学习在继承中构造方法的规则和要求。
继承中的构造方法有以下几点需要注意:
父类的构造方法不能被继承:
因为构造方法语法是与类同名,而继承则不更改方法名,如果子类继承父类的构造方法,那明显与构造方法的语法冲突了。比如 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()调用,程序依然没问题,其实这样是为了节省代码,系统执行时会自动添加父类的无参构造方式,如果不信的话我们对上面的类稍作修改执行:
方法重写(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;
}
}
06、继承与修饰符
Java 修饰符的作用就是对类或类成员进行修饰或限制,每个修饰符都有自己的作用,而在继承中可能有些特殊修饰符使得被修饰的属性或方法不能被继承,或者继承需要一些其他的条件。
Java 语言提供了很多修饰符,修饰符用来定义类、方法或者变量,通常放在语句的最前端。主要分为以下两类:
- 访问权限修饰符,也就是 public、private、protected 等
- 非访问修饰符,也就是 static、final、abstract 等
访问修饰符
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 修饰变量或者方法的话,它们可以直接通过类访问,不需要创建一个类的对象来访问成员。
我们在设计类的时候可能会使用静态方法,有很多工具类比如Math
,Arrays
等类里面就写了很多静态方法。
可以看以下的案例证明上述规则:
源代码为:
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 修饰符通常和 static 修饰符一起使用来创建类常量。
final 方法:
- 父类中的 final 方法可以被子类继承,但是不能被子类重写。声明 final 方法的主要目的是防止该方法的内容被修改。
final 类:
- final 类不能被继承,没有类能够继承 final 类的任何特性。
所以无论是变量、方法还是类被 final 修饰之后,都有代表最终、最后的意思。内容无法被修改。
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");
}
}
07、Object 类和转型
提到 Java 继承,不得不提及所有类的根类:Object(java.lang.Object)类,如果一个类没有显式声明它的父类(即没有写 extends xx),那么默认这个类的父类就是 Object 类,任何类都可以使用 Object 类的方法,创建的类也可和 Object 进行向上、向下转型,所以 Object 类是掌握和理解继承所必须的知识点。
Java 向上和向下转型在 Java 中运用很多,也是建立在继承的基础上,所以 Java 转型也是掌握和理解继承所必须的知识点。
Object 类概述
- Object 是类层次结构的根类,所有的类都隐式的继承自 Object 类。
- Java 中,所有的对象都拥有 Object 的默认方法。
- 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;//错误的向下转型,虽然编译器不会报错但是运行会报错
08、子父类初始化顺序
在 Java 继承中,父子类初始化先后顺序为:
- 父类中静态成员变量和静态代码块
- 子类中静态成员变量和静态代码块
- 父类中普通成员变量和代码块,父类的构造方法
- 子类中普通成员变量和代码块,子类的构造方法
总的来说,就是静态>非静态,父类>子类,非构造方法>构造方法。同一类别(例如普通变量和普通代码块)成员变量和代码块执行从前到后,需要注意逻辑。
这个也不难理解,静态变量也称类变量,可以看成一个全局变量,静态成员变量和静态代码块在类加载的时候就初始化,而非静态变量和代码块在对象创建的时候初始化。所以静态快于非静态初始化。
而在创建子类对象的时候需要先创建父类对象,所以父类优先于子类。
而在调用构造方法的时候,是对成员变量进行一些初始化操作,所以普通成员变量和代码块优于构造方法执行。
至于更深层次为什么这个顺序,就要更深入了解 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();
}
}
执行结果:
09、结语
好啦,三妹,本次继承就介绍到这里啦,Java 面向对象三大特征之一继承——优秀的你已经掌握。
封装:是对类的封装,封装是对类的属性和方法进行封装,只对外暴露方法而不暴露具体使用细节,所以我们一般设计类成员变量时候大多设为私有而通过一些 get、set 方法去读写。
继承:子类继承父类,即“子承父业”,子类拥有父类除私有的所有属性和方法,自己还能在此基础上拓展自己新的属性和方法。主要目的是复用代码。
多态:多态是同一个行为具有多个不同表现形式或形态的能力。即一个父类可能有若干子类,各子类实现父类方法有多种多样,调用父类方法时,父类引用变量指向不同子类实例而执行不同方法,这就是所谓父类方法是多态的。
最后送你一张图捋一捋其中的关系吧。
“好的,二哥,我来消化一下,今天内容真不少。你先去休息一下。”三妹回应到。
参考链接:https://bbs.huaweicloud.com/blogs/271358,作者:bigsai,整理:沉默王二
GitHub 上标星 7600+ 的开源知识库《二哥的 Java 进阶之路》第一版 PDF 终于来了!包括Java基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM等等,共计 32 万余字,可以说是通俗易懂、风趣幽默……详情戳:太赞了,GitHub 上标星 7600+ 的 Java 教程
微信搜 沉默王二 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 222 即可免费领取。