继承
为什么需要继承
Java中使用类对现实世界中实体来进行描述,类经过实例化之后的产物对象,则可以用来表示现实中的实体,但是现实世界错综复杂,事物之间可能会存在一些关联
比如:狗和猫 它们都是一个动物
代码举例如下:
那能否将这些共性抽取呢?面向对象思想中 提出了继承的概念 专门用来进行共性抽取 实现代码复用(意义)
继承概念
继承(inheritance)机制:是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加新功能,这样产生新的类,称派生类/子类。
继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。继承主要解决的问题是:共性的抽取,实现代码复用。
例如:狗和猫都是动物,那么我们就可以将共性的内容进行抽取,然后采用继承的思想来达到共用
Dog和Cat都继承了Animal类
其中:Animal类称为父类/基类/超类
Dog和Cat可以称为Animal的子类/派生类 继承之后 子类可以复用父类中成员 子类在实现时 只需关心自己新增加的成员即可
从继承概念中可以看出继承最大的作用就是:实现代码复用,还有就是来实现多态(之后讲)。
继承的语法
在Java中如果要表示类之间的继承关系,需要借助extends关键字(如Dog和Cat继承Animal)
修饰符 class 子类 extends 父类{
//.....
}
现在我们详细讲解之前所写的代码
运行结果:
我们未给成员变量赋值 这里输出的都是默认值
注意:
子类会将父类中的成员变量或者成员方法继承到子类中
子类继承父类之后 必须要新添加自己的 特有的成员 体现出与基类(父类)的不同 否则就没必要继承了
父类成员访问
在继承体系中 子类将父类中的方法和字段继承下来了 那在子类中能否直接访问父类中继承下来的成员呢?
子类中访问父类的成员变量
1. 子类和父类不存在同名成员变量
2.子类和父类成员变量同名
在子类方法中 或者 通过子类对象访问成员时:
1.如果访问的成员变量子类中有,优先访问自己的成员变量
2.如果访问的成员变量子类中无,则访问父类继承下来的,如果父类也没有定义,则编译报错。
3.如果访问的成员变量与父类中成员变量同名,则优先访问自己的
子类中访问父类的成员方法
1.成员方法名字不同
总结:成员方法没有同名时,在子类方法中或者通过子类对象访问方法时,则优先访问自己的,自己没有时再到父类中找,如果父类中也没有则报错。
2.成员方法名字相同
说明
1.通过子类对象访问父类与子类中不同名方法时,优先在子类中找,找到则访问,否则在父类中找,找到则访问,否则编译报错。
2.通过子类对象访问父类与子类同名方法时,如果父类和子类同名方法的参数列表不同(重载),根据调用方法适传递的参数选择合适的方法访问,如果没有则报错;
问题:如果子类中存在与父类中相同的成员时,那如何在子类中访问父类相同名称的成员呢?
这时候就需要用到super关键字了
super关键字
由于设计不好,或者因场景需要,子类和父类中可能会存在相同名称的成员,如果要在子类方法中访问父类同名成员时,该如何操作?直接访问是无法做到的,Java提供了super关键字,该关键字主要作用:在子类方法中访问父类的成员。
注意事项:
1.只能在非静态方法中使用
2.在子类方法中 访问父类的成员变量和方法
super的其他用法在后文中介绍
子类构造方法
子类对象构造时 需要先调用父类构造方法(帮父类的成员进行初始化) 然后执行子类的构造方法
在子类构造方法中,并没有写任何关于基类构造的代码,但是在构造子类对象时,先执行基类的构造方法,然后执行子类的构造方法,因为:子类对象中成员是有两部分组成的,基类(父类)继承下来的以及子类新增加的部分 。父子父子肯定是先有父再有子,所以在构造子类对象时候 ,先要调用基类的构造方法,将从基类继承下来的成员构造完整,然后再调用子类自己的构造方法,将子类自己新增加的成员初始化完整
注意:
1. 若父类显式定义无参或者默认的构造方法,在子类构造方法第一行默认有隐含的super()调用,即调用基类构造方法
2. 如果父类构造方法是带有参数的,此时需要用户为子类显式定义构造方法,并在子类构造方法中选择合适的父类构造方法调用,否则编译失败
3. 在子类构造方法中,super(...)调用父类构造时,必须是子类构造函数中第一条语句。
4. super(...)只能在子类构造方法中出现一次,并且不能和this同时出现
5.super只能 指代 当前类的父类 不能指代 父类的父类 甚至继续往上
super和this
super和this都可以在成员方法中用来访问:成员变量和调用其他的成员函数,都可以作为构造方法的第一条语句,那他们之间有什么区别呢
相同点
1. 都是Java中的关键字
2. 只能在类的非静态方法中使用,用来访问非静态成员方法和字段
3. 在构造方法中调用时,必须是构造方法中的第一条语句,并且不能同时存在
不同点
1. this是当前对象的引用,当前对象即调用实例方法的对象,super相当于是子类对象中从父类 继承下来的 部分成员的引用
2. 在非静态成员方法中,this用来访问本类的方法和属性,super用来访问父类继承下来的方法和属性
3. 在构造方法中:this(...)用于调用本类构造方法,super(...)用于调用父类构造方法,两种调用不能同时在构造方法中出现
4. 构造方法中一定会存在super(...)的调用,用户没有写编译器也会增加,但是this(...)用户不写则没有
再谈初始化
我们还记得之前讲过的代码块吗?我们简单回顾一下几个重要的代码块:实例代码块和静态代码块。我们之前已经学习了 在没有继承关系时的执行顺序
在这里 我们学习下 继承关系上的执行顺序
继承关系上的执行顺序
运行结果:
通过分析运行结果 得出以下结论:
- 父类静态代码块优先于子类静态代码块执行 且是最早执行
- 父类实例代码块和父类构造方法紧接着执行
- 子类实例代码块和子类构造方法紧接着再执行
- 第二次初始化子类对象时 父类和子类的静态代码块都将不会再执行
protected关键字
在类和对象章节中,为了实现封装特性,Java中引入了访问限定符,主要限定:类或者类中成员能否在类外或者其他包中被访问
简单记忆:
private:只能在当前类使用
default:同一个包底下的类可以访问
protected:同一个包底下都是可以使用的 但是 不同包下的只有子类才能使用
public:哪里都能使用
那父类中 不同访问权限的成员 在子类中的可见性又是什么样子的呢?
注意:父类中private成员变量虽然在子类中不能直接访问,但是也继承到子类中了
什么时候下用哪一种呢?
我们希望类要尽量做到 "封装", 即隐藏内部实现细节, 只暴露出 必要 的信息给类的调用者
因此我们在使用的时候应该尽可能的使用 比较严格 的访问权限. 例如如果一个方法能用 private, 就尽量不要用 public.
另外, 还有一种 简单粗暴 的做法: 将所有的字段设为 private, 将所有的方法设为 public. 不过这种方式属于是对访问权限的滥用, 还是更希望大家能写代码的时候认真思考, 该类提供的字段方法到底给 "谁" 使用(是类内部自己用, 还是类的调用者使用, 还是子类使用)
继承方式
在现实生活中,事物之间的关系是非常复杂,灵活多样
但在Java中只支持以下几种继承方式:
注意:Java中不支持多继承。
虽然java不支持 多继承 但是我们可以通过接口的形式 支持多继承 后续会学习接口
时刻牢记, 我们写的类是现实事物的抽象. 而我们真正在公司中所遇到的项目往往业务比较复杂, 可能会涉及到一系列复杂的概念, 都需要我们使用代码来表示, 所以我们真实项目中所写的类也会有很多. 类之间的关系也会更加复杂.
但是即使如此, 我们并不希望类之间的继承层次太复杂. 一般我们不希望出现超过三层的继承关系. 如果继承层次太多, 就需要考虑对代码进行重构了.
如果想从语法上进行限制继承, 就可以使用 final 关键字
final关键字
final关键可以用来修饰变量、成员方法以及类
1. 修饰变量或字段,表示常量(即不能修改)
2.修饰类:表示此类不能被继承(密封类)
如果一个类 不想被其他类继承 此时 可以使用关键字final来修饰当前这个类
此时 这个类叫做 密封类
我们平时是用的 String 字符串类, 就是用 final 修饰的, 不能被继承
3.修饰方法:表示该方法不能被重写--密封方法(后序介绍)
组合
和继承类似, 组合也是一种表达类之间关系的方式, 也是能够达到代码重用的效果。组合并没有涉及到特殊的语法(诸如 extends 这样的关键字), 仅仅是将一个类的实例作为另外一个类的字段
继承表示对象之间是is-a的关系,比如:狗是动物,猫是动物
组合表示对象之间是has-a的关系,比如:汽车
汽车和其轮胎、发动机、方向盘、车载系统等的关系就应该是组合,因为汽车是有这些部件组成的
代码演示:
组合和继承都可以实现代码复用,应该使用继承还是组合,需要根据应用场景来选择,一般建议:能用组合尽量用组合。
向上转型
在Java中,向上转型(Upcasting)是一种自动的、隐式的类型转换,它发生在将子类的引用赋值给其父类类型的变量时。由于子类继承了父类的所有特性(属性和方法),所以向上转型是安全的,编译器会自动处理这种转换
向上转型:实际就是创建一个子类对象,将其当成父类对象来使用
语法格式:
父类类型 对象名 = new 子类类型()
如:
animal是父类类型,但可以引用一个子类对象,因为是从小范围向大范围的转换
父类引用 引用了子类对象
提出问题:只有这一种方式实现 向上转型 吗?
答案:当然不是 共有3种方式可以实现向上转型
1.直接赋值(子类对象赋值给父类对象)
2.方法的传参
3.返回值
例如:
在这个例子中,Dog类继承了Animal类。当我们创建一个Dog对象并将其引用赋值给Animal类型的变量myAnimal时,就发生了向上转型。通过myAnimal引用,我们只能访问Animal类中定义的方法(或被子类重写的方法),而不能访问Dog类中特有的方法,如bark()
需要注意的是,虽然向上转型是安全的,但向下转型(Downcasting)则可能引发ClassCastException异常,因为将一个父类引用强制转换为子类类型时,必须确保该引用实际指向的是一个子类对象。
在进行向下转型时,通常需要使用instanceof关键字来检查引用类型,以确保转换的安全性
向上转型的优点:让代码实现更简单灵活
向上转型的缺陷:不能调用到子类特有的方法
向下转型
将一个子类对象经过向上转型之后当成父类方法使用,再无法调用子类的方法,但有时候可能需要调用子类特有的方法,此时:将父类引用再还原为子类对象即可,即向下转换
在Java中,向下转型(Downcasting)是一种显式的类型转换,它发生在将父类类型的引用赋值给子类类型的变量时。由于父类引用可能指向的是任何继承自该父类的子类对象(或者就是父类对象本身),因此,在向下转型之前,必须确保父类引用实际上指向的是一个子类对象,否则会发生ClassCastException
在之前的例子中,我们已经定义了Animal类和Dog类,其中Dog类继承自Animal类。现在我们将使用这个例子来解释向下转型:
在这个例子中,我们首先创建了一个Dog对象,并将其引用赋值给了一个Animal类型的变量myAnimal,这是向上转型。然后,我们想要通过myAnimal来调用Dog类特有的bark()方法。但是,由于myAnimal是Animal类型,它不能直接调用Dog类的方法。
为了解决这个问题,我们进行了向下转型。在进行向下转型之前,我们使用instanceof关键字来检查myAnimal是否确实指向了一个Dog对象。如果是,那么我们可以安全地将myAnimal转换为Dog类型,并将其引用赋值给Dog类型的变量myDogAgain。然后,我们就可以通过myDogAgain来调用Dog类特有的bark()方法了。
如果myAnimal没有指向一个Dog对象(例如,它指向了一个Animal对象或其他子类的对象),那么instanceof检查将失败,我们就不会尝试进行不安全的向下转型,从而避免了ClassCastException异常的发生
重写
在Java中,方法的重写(Overriding)是面向对象编程的一个重要概念。它允许子类提供一个与父类方法签名相同但实现不同的方法。当子类对象调用这个方法时,会执行子类中的实现,而不是父类中的实现。这是Java中实现多态性的一种方式
下面是一些关于Java中方法重写的重要规则和要点:
- 方法签名相同:子类中的重写方法必须与父类中被重写的方法具有相同的方法名、参数列表和返回类型
- 访问权限不能更低:子类中的重写方法的访问权限(public、protected、默认或private)不能低于父类中被重写的方法的访问权限。也就是说,如果父类中的方法是public,那么子类中的重写方法也必须是public或更高的访问权限(但实际上在Java中public就是最高的访问权限了)
- 返回类型可以相同或更具体:在Java 5及更高版本中,子类方法的返回类型可以是父类方法返回类型的子类(协变返回类型)---返回值具有父子关系
- 静态方法不能被重写:在Java中,静态方法是与类关联的,而不是与对象关联的。因此,静态方法不能被重写,只能被隐藏(即子类可以定义一个与父类静态方法同名的新静态方法,但这两个方法是独立的)
- final方法不能被重写:如果一个方法被声明为final,那么它就不能被子类重写
- private方法不能被重写:由于private方法只能在声明它的类中被访问,因此子类无法访问和重写它。子类可以定义一个与父类private方法同名的方法,但这将被视为一个新方法,而不是重写
- 构造方法不能被重写:构造方法是用于创建对象并初始化其状态的特殊方法。由于构造方法不是类的成员方法,因此它们不能被重写。子类可以通过调用super()来调用父类的构造方法
注意:重写的方法, 可以使用 @Override 注解来显式指定. 有了这个注解能帮我们进行一些合法性校验. 例如不小心将方法名字拼写错了 (比如写成 aet), 那么此时编译器就会发现父类中没有 aet 方法, 就会编译报错, 提示无法构成重写
即:方法重载是一个类的多态性表现,而方法重写是子类与父类的一种多态性表现
重写的设计原则
1.通过重写,子类可以在不修改父类代码的情况下添加新的功能或修改现有功能。
这意味着当需求变化时,我们应该优先通过扩展(如添加新的子类)来满足变化,而不是修改已有的代码
2.在重写方法中,子类必须确保其行为与父类方法的行为一致或更为具体。这意味着子类方法不能引入与父类方法不一致的行为或副作用
3.当子类对象被赋值给父类引用时,通过重写,我们可以确保在调用方法时执行的是子类中的实现,而不是父类中的实现。这种灵活性使得代码更加易于理解和维护
4.通过重写,我们可以将父类中的通用功能与子类中的特定功能分离开来,从而确保每个类和方法都专注于自己的职责
静态绑定:也称为前期绑定(早绑定),即在编译时,根据用户所传递实参类型就确定了具体调用那个方法。典型代表函数重载
动态绑定:也称为后期绑定(晚绑定),即在编译时,不能确定方法的行为,需要等到程序运行时,才能够确定具体调用那个类的方法
如:
程序在编译的时候 确实调用的是 父类的eat方法
当代码运行的时候 通过父类引用 调用了父类和子类重写的那个方法 结果实际调用了子类的方法 此时我们把这种情况 叫做 动态绑定
现在我们来看一个题目:
这里说明一下箭头上的3:
如果在父类的构造方法当中 调用父类和子类重写的方法 此时会发生动态绑定(实际执行子类的方法)
多态
多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态
总的来说:同一件事情,发生在不同对象身上,就会产生不同的结果
在学习多态前 我们还得先熟悉 向上转型 方法的重写
在学习完向上转型 方法的重写后 我们便可以更好的去学习多态
多态实现条件
在java中要实现多态,必须要满足如下几个条件,缺一不可:
1. 必须在继承体系下
2. 子类必须要对父类中方法进行重写
3. 通过父类的引用调用重写的方法
多态体现:在代码运行时,当传递不同类对象时,会调用对应类中的方法
编译器在编译代码时 并不知道要调用Dog还是Cat中的eat方法
等程序运行起来后 形参a引用的具体对象确定后 才知道调用哪个方法
注意:此处的形参类型必须是父类类型才可以
Test类以外的代码是类的实现者编写的
Test类的代码则是类的调用者编写的
当类的调用者在编写 eat 这个方法的时候, 参数类型为 Animal (父类), 此时在该方法内部并不知道, 也不关注当前的 a 引用指向的是哪个类型(哪个子类)的实例. 此时 a这个引用调用 eat方法可能会有多种不同的表现(和 a 引用的实例相关), 这种行为就称为 多态.
多态的优缺点
假设有如下代码:
使用多态的好处
1. 能够降低代码的 "圈复杂度", 避免使用大量的 if - else
什么叫 "圈复杂度" ?
圈复杂度是一种描述一段代码复杂程度的方式. 一段代码如果平铺直叙, 那么就比较简单容易理解. 而如果有很多的条件分支或者循环语句, 就认为理解起来更复杂.
因此我们可以简单粗暴的计算一段代码中条件语句和循环语句出现的个数, 这个个数就称为 "圈复杂度". 如果一个方法的圈复杂度太高, 就需要考虑重构.
不同公司对于代码的圈复杂度的规范不一样. 一般不会超过 10
例如我们现在需要打印的不是一个形状了, 而是多个形状. 如果不基于多态, 实现代码如下:
equals( ):用于比较两个对象的内容是否相等 相等返回true 否则返回false
如果使用多态, 则不必写这么多的 if - else 分支语句, 代码更简单
2. 可扩展能力更强
如果要新增一种新的形状, 使用多态的方式代码改动成本也比较低
只要创建一个新类的实例就可以了, 改动成本很低
而对于不用多态的情况, 就要把 if - else 进行一定的修改, 改动成本更高
多态缺陷:代码的运行效率降低
1. 属性没有多态性
当父类和子类都有同名属性的时候,通过父类引用,只能引用父类自己的成员属性
2.构造方法没有多态性
见如下代码~
这段代码其实我们之前当做题目给大家讲了 现在再来看一遍
1.构造 D 对象的同时, 会调用 B 的构造方法.
2.B 的构造方法中调用了 func 方法, 此时会触发动态绑定, 会调用到 D 中的 func
3.此时 D 对象自身还没有构造, 此时 num 处在未初始化的状态, 值为 0. 如果具备多态性,num的值应该是1.
4.所以在构造函数内,尽量避免使用实例方法,除了final和private方法
结论: "用尽量简单的方式使对象进入可工作状态", 尽量不要在构造器中调用方法(如果这个方法被子类重写, 就会触发动态绑定, 但是此时子类对象还没构造完成), 可能会出现一些隐藏的但是又极难发现的问题