继承
什么是继承以及为什么需要继承?
继承机制:是面向对象程序设计是代码实现复用中至关重要的一步,它允许程序在保持原有类的特性的基础上来进行扩充,增加新功能等。
总的来说,继承就是将不同类之间的共性进行抽取,抽取出来的这些共同的特性就可以单独写成一个父类,其他被抽取共性的类就可以继承这个类,这些便可成为子类。当然,继承之后,子类是可以复用父类中的成员,子类在实现是只需要关心自己新增加的成员即可。
继承最大的作用:抽取共性,实现代码的复用;实现多态(请继续往下看便可以知道多态)。
一段使用继承的简单代码
class Animal{
String name;
int age;
String sex;
public void sleep(){
System.out.println(this.name+"正在睡觉");
}
}
class Cat extends Animal{
public void eat(){
System.out.println(this.name+"正在吃猫粮");
}
}
class Dog extends Animal{
public void eat(){
System.out.println(this.name+"正在吃狗粮");
}
}
public class Main {
public static void main(String[] args) {
Cat cat=new Cat();
cat.name="小黄";
cat.eat();
Dog dog=new Dog();
dog.name="小黑";
dog.eat();
}
}
总结:
- 子类会将父类中的成员变量或者成员方法继承下来为自己所用
- 子类继承父类之后,建议要添加子类自己特有的成员,来体现出子类的不同之处,否则就没有继承的必要了
父类成员的访问
子类访问父类的成员变量
子类和父类不存在同名的成员变量
子类访问的这个成员变量是从父类中继承下来的,自己本身是没有的。
class Base{
public int a;
public int b;
}
class Derived extends Base{
public int c;
public void func(){
a=1;
b=2;
c=3;
}
}
public class Main {
public static void main(String[] args) {
Derived derived=new Derived();
derived.func();
System.out.println(derived.a);
}
}
子类和父类存在同名的成员变量
子类访问的这个成员变量自己本身是存在的,那么会优先调用自己的。
class Base{
public int a=10;
public int b;
}
class Derived extends Base{
public int a=3;
public char b;
public void func(){
a=100;
b=97;
}
}
public class Main {
public static void main(String[] args) {
Derived derived=new Derived();
System.out.println(derived.a); //a=3
derived.func();
System.out.println(derived.a); //a=100
System.out.println(derived.b); //b='a'
}
}
小总结
- 如果子类访问的成员变量在自己本身就已经拥有(不管父类中有或者没有),那么则会优先访问自己的成员变量
- 如果子类访问的成员变量在自己本身的类中没有,那么则会继承父类中的成员变量;如果父类中也没有该成员变量,那么编译器则会报错
子类访问父类的成员方法
子类和父类不存在同名的成员方法
class Base{
public void func1(){
System.out.println("在Base类中的成员方法");
}
}
class Derived extends Base {
public void func2(){
System.out.println("在Derived类中的成员方法");
}
public void func(){
func1(); //访问的是父类的成员方法
func2(); //访问的是子类的成员方法
}
}
public class Main {
public static void main(String[] args) {
Derived derived=new Derived();
derived.func();
}
}
子类和父类存在同名的成员方法
class Base{
public void func1(){
System.out.println("这是Base类的第一个成员方法");
}
public void func2(){
System.out.println("这是Base类的第二个成员方法");
}
}
class Derived extends Base {
public void func1(int a){
System.out.println("这是Derived类的第一个成员方法");
}
public void func2(){
System.out.println("这是Derived类的第二个成员方法");
}
public void func(){
func1();
func1(10);
func2();
}
}
public class Main {
public static void main(String[] args) {
Derived derived=new Derived();
derived.func();
}
}
运行结果:
小总结
从上面的两段代码以及运行结果中,得出结论:
- 通过子类对象来访问父类与子类不同名的方法时,与访问成员变量类似的,优先在子类中找,找到则会字节访问;如果子类中找不到,则会在父类中找,找到就会访问;如果找不到,则很可能会字节报错
- 通过子类对象来访问父类与子类同名的方法时,如果父类与子类方法中的参数列表不同(也就是构成重载),则会通过调用方法传递的参数来选择合适的方法来进行方法;如果父类与子类方法中的参数列表相同,原型一致(也就是构成重写),则会直接访问子类中的这个成员方法,这时候无法访问父类中的同名成员方法(除非使用super,接下来很快就介绍到)
super关键字
我们在上面说到了,子类和父类存在同名的成员方法(且原型一致)的时候,只能够直接地调用子类的成员方法,无法直接地访问到父类的成员方法。那么针对这种情况,Java提供了super关键字,用来在子类中访问父类的成员。
继续使用上一个例子,我们只需要将func2方法前加上super.
即可:
class Base{
public void func1(){
System.out.println("这是Base类的第一个成员方法");
}
public void func2(){
System.out.println("这是Base类的第二个成员方法");
}
}
class Derived extends Base {
public void func1(int a){
System.out.println("这是Derived类的第一个成员方法");
}
public void func2(){
System.out.println("这是Derived类的第二个成员方法");
}
public void func(){
func1();
func1(10);
super.func2();
}
}
public class Main {
public static void main(String[] args) {
Derived derived=new Derived();
derived.func();
}
}
注意:super关键字只能在非静态方法中使用。
但是这又是为什么呢?为什么super在静态方法中就不能够使用呢?
其实你只要仔细想什么是静态方法,那么这些问题也就可以迎刃而解了(这一点在前面在讲到的this时候的原因是一样的)。所谓的静态方法,其实就是不依赖于对象的方法,也就是说,它并不需要实例化对象就可以在类外直接进行调用,那么问题就来了,super如果是用在静态方法里面,然而这个静态方法很显然也不可能是构造方法,既然不是构造方法,那么也就不能够调用父类里面的内容,那又何来的super访问父类成员一说,显然就是无稽之谈。(以上子类中的构造方法在下文中会介绍到,这里当个了解,回头再看一遍即可)
子类的构造方法
class Base{
public Base(){
System.out.println("这是Base的构造方法");
}
}
class Derived extends Base{
public Derived(){
//super(); 这里原来是有这么一段代码的
System.out.println("这是Derived的构造方法");
}
}
public class Main {
public static void main(String[] args) {
Derived derived=new Derived();
}
}
在Java中,在对子类进行构造的时候,都需要先调用父类的构造方法,才能够来执行子类的构造方法。这样做的原因是:每一个子类对象中都是由两部分组成的,必须先将父类中的成员完整继承下来,在将子类自己新增加的成员初始化完整。
注意事项:
- 如果父类显示定义无参(就像上面的例子)或者是默认的构造方法,那么在子类构造方法的第一行会默认有隐藏的
super()
调用(也就是调用父类的构造方法,不需要自己手动添加) - 如果父类构造方法是带有参数的,那么这个时候编译器不会在给子类生成默认的构造方法,那么就需要在子类的构造方法中选择合适的父类构造方法调用,否则编译会失败。例子见下:
class Base{
public Base(int a,double z){
System.out.println("这是Base的构造方法");
}
}
class Derived extends Base{
public Derived(){
super(10,3.14);
System.out.println("这是Derived的构造方法");
}
}
public class Main {
public static void main(String[] args) {
Derived derived=new Derived();
}
}
- 在子类的构造方法中,
super(……)
调用父类构造时,必须是子类构造方法中的第一条语句 super(……)
只能在子类中出现一次,并且不能和 this 同时出现,因为无论是super还是this,在调用其他构造的时候都应该将本条语句放在本构造方法的第一条语句,那么这样就会引起不必要的错误。所以,super & this 二者只能够出现其一。
进一步了解super和this(总结)
相同点
- 都是Java中的关键字
- 都是只能在类的非静态方法中使用,用来访问非静态成员方法和字段
- 在构造方法中调用时,都必须是构造方法中的第一条语句,并且是不能够同使存在的
不同点
- this是当前对象的引用,当前对象也即是调用实例方法的对象;而super则相当于子类对象中从父类继承下来部分成员的引用
- 在非静态方法中,this是用来访问本类的方法和属性;而super则是用来访问父类继承下来的方法和属性
- this是非静态方法的一个隐藏参数,而super不是隐藏参数
- 成员方法中直接访问本类成员时,编译之后会将this还原,也就是本类的非静态成员都是通过this来进行访问的;而在子类中如果通过super访问父类成员,编译之后在字节码层面super实际是不存在的(也就是说在编译完成之后已经super了,super只选在于代码层面,提高代码可读性,来快速知道这里访问的是父类的成员,仅此而已)
- 在构造方法中一定会存在
super()
的调用,就算没有编写此代码也会默认添加;而this()
调用,如果不写则是没有,不会默认添加
代码块在继承关系上的执行顺序
简单回顾:在上篇文章讲“类和对象”中,我们就已经介绍了代码块以及其执行顺序。代码块主要可分为静态代码块和实例代码块,其中的顺序是:静态代码块优先执行,并且只执行一次,在类加载的阶段就开始执行;当有对象创建的时候,才会执行实例代码块;最后才是执行构造方法。
那么在继承关系上的执行顺序又会不会有什么变化呢?请继续往下看。
先上代码:
class Base{
public int a;
public String b;
public Base(int a, String b) {
this.a = a;
this.b = b;
System.out.println("Base类中的构造方法");
}
{
System.out.println("Base类中的实例代码块");
}
static {
System.out.println("Base类中的静态代码块");
}
}
class Dervied extends Base{
public Dervied(int a, String b) {
super(a,b);
System.out.println("Derived类中的构造方法");
}
{
System.out.println("Derived类中的实例代码块");
}
static {
System.out.println("Derived类中的静态代码块");
}
}
public class Main {
public static void main(String[] args) {
Dervied dervied1=new Dervied(10,"666");
System.out.println("=======================");
Dervied dervied2=new Dervied(20,"555");
}
}
运行结果:
从上面的代码以及运行结果可以得出结论:
- 父类的静态代码块优先于子类的静态代码块执行,且是最早执行的
- 父类的实例代码块和父类的构造方法再执行;子类的实例代码块和子类的构造方法再紧接着执行(注意:这里不是实例代码块都先执行再执行构造方法的)
- 因为静态代码块只会执行一次,所以在第二次实例化子类对象时,父类和子类的静态代码块都将不会再执行
总结Java中的继承方式
单继承
或者
实现代码:
public class A{
……
}
public class B extends A{
……
}
public class C extends A{
……
}
多层继承
public class A{
……
}
public class B extends A{
……
}
public class C extends B{
……
}
注意:
- Java是不支持多继承,而如果要实现像C++那样多继承的情况,那就要使用Java中的接口来实现(接下来的文章会讲到Java中的接口)
- 一般我们在使用多层继承的时候,是不会超过三层的继承关系,因为层数越多,会是代码变得越复杂,那么这时候就要考虑对代码进行重构了
继承中常使用的两个重要关键字
protected关键字
在之前文章“类和对象”一文中,我们介绍了private、public以及包访问权限,接下来就来详细介绍最后的一个访问限定符:protected。
在上篇文中,我们已经知道:
- private修饰的成员只能在同一个类中使用,在类外是不能访问的
- 包访问权限(也就是默认访问权限)修饰的成员只能在同一个包中使用,在这个包外就不能访问
- public修饰的成员不管是在哪里都能够访问
这时候,如果有人想要在一个包中的一个子类中继承另外一个包中父类,又不想让这个父类到处都可以访问(因为不安全),只能被子类继承仅此而已,那么这时候不管是使用包访问权限还是公开访问权限,都不合适。所以就引出了protected继承权限(可用于不同包中的继承),这样就可以很好地解决了这个问题。
final关键字
final关键字是可以用来修饰成员变量、成员方法和类。
- final修饰变量/字段(成员变量),表示常量(也就是不能对其进行修改)
final int a = 10;
a = 20; // 编译出错
- final修饰类,表示这个类将不能被继承
如果强制继承,则会报错。这也就是为什么我们用的String字符串类不能被继承的原因,就是因为其底层实现使用的是final修饰的。
- final修饰成员方法,表示这个方法不能被重写(后面会介绍到重写)
组合
与继承类似的,组合也是一种表达类之间关系的方式,也就是能够达到代码复用的效果。组合不像继承那样会用到一些类似extends之类的关键字,它仅仅只是将一个类的实例作为另外一个类的字段。
区别:
继承表示对象之间是is-a的关系:狗是动物,猫是动物。
组合表示对象之间是has-a的关系:汽车有轮胎,汽车有方向盘。
共同点:
组合和继承都可以实现代码复用,那么如何选择是使用继承还是使用组合呢?
- 总的来说,是需要根据代码应用的场景来进行选择的
- 但是一般来说,如果可以使用组合的尽量是使用组合
示例代码:
// 轮胎类
class Tire{
...
}
// 发动机类
class Engine{
...
}
// 车载系统类
class VehicleSystem{
...
}
class Car{
private Tire tire; // 可以复用轮胎中的属性和方法
private Engine engine; // 可以复用发动机中的属性和方法
private VehicleSystem vs; // 可以复用车载系统中的属性和方法
...
}
// 奔驰是汽车
class Benz extend Car{
// 将轮胎、发送机、车载系统等组合成的汽车全部继承下来
}
多态
什么是多态?
当某个对象来完成某个行为的时候,会产生出不同的状态(也就是说相同的一件事,发生在不同对象的身上,就会产生不同的结果),这就是多态。
重写
那么在了解多态之前,我们先来了解一下什么是方法的重写,以便后面进一步学习多态等相关内容。
其实我们在前面的文章中就已经频繁地说到和使用到了重写。
什么是重写?
重写,也可以理解为是覆盖。重写是子类对父类非静态、非private修饰、非构造方法等的实现过程进行重新编写,返回值和参数列表都不能改变,核心重写。重写的好处在于子类可以根据需要,定义特定于自己的行为(也就是说子类能够根据自己的需要实现父类的方法)。
方法重写的规则
- 子类在重写父类方法的时候,一般必须与父类原型保持一致,其中返回值类型、方法名、参数列表要完全一致
- 访问权限不能比父类中被重写的方法的访问权限更低,而应该是大于等于。就比如如果父类的方法被public修饰,那么子类中要重写的方法有且仅有使用public来进行修饰
- 父类中被private、static(静态)、final修饰的成员方法在子类中是不能够进行重写的
- 重写的方法,可以使用
**@Override**
注解来显示指定,这个注解能够帮助我们进行一些合法性校验,因为这样的话如果在子类重写的方法中不小心把重写父类的方法名写错了,编译器会帮忙提醒
重写和重载的区别
区别 | 重载 | 重写 |
---|---|---|
参数列表 | 必须修改 | 一定不能修改 |
返回值类型 | 可以修改 | 一定不能修改 |
访问限定符 | 可以修改 | 可以降低限制修改 |
总的来说:方法重载是一个类的多样性的表现;而方法重写是子类与父类的一种多样性的表现。
延伸知识点(拓展)
在实际开发的过程中,对于你们已经投入使用的类,尽量不要进行修改。最好的方式是重新定义一个新的类,来重复利用其中的共性的内容,并且添加或者修改一部分的内容。因为这样的设计能够让用户选择上下版本,不管是上一版本还是下一版本,都是兼容的,不会因为下一个版本的发布,导致上一版本无法使用的问题。
多态的实现条件
- 必须是要在继承的体系下
- 子类必须要对父类中的方法进行重写
- 通过父类的引用调用重写方法
示例代码:
class Aminal{
public String name;
public int age;
public Aminal(String name, int age) {
this.name = name;
this.age = age;
}
public void eat(){
System.out.println(this.name+"正在吃饭");
}
}
class Cat extends Aminal{
public Cat(String name, int age) {
super(name, age);
}
@Override
public void eat() {
System.out.println(this.name+"正在吃猫粮");
}
}
class Dog extends Aminal{
public Dog(String name, int age) {
super(name, age);
}
@Override
public void eat() {
System.out.println(this.name+"正在吃狗粮");
}
}
public class Main {
public static void main(String[] args) {
Cat cat=new Cat("小黄",3);
cat.eat();
Dog dog=new Dog("小黑",6);
dog.eat();
}
}
运行结果:
形如这样的,分别调用自己子类中的成员方法,对相同的一个方法实现各行其能,以达到不同的效果,这就是多态。
动态绑定(运行时绑定)
这里插入一个知识点——动态绑定和静态绑定,以便后面会使用到。
动态绑定:在发生多态的时候,查看编译后的代码会发现,编译时候的代码还是调用的是父类的方法,并没有调用子类中重写的方法,但是在运行时会变成子类自己的方法,类似这样的就称为运行时绑定,也叫动态绑定。
静态绑定:静态绑定就是在编译时期就确定的,例如方法的重载等。
向上转型和向下转型
向上转型
向上转型实际上就是创建一个子类,将其当成父类对象来进行使用。
发生向上转型只需要记住一个口诀:父类引用,引用子类对象。
示例代码:
class Aminal{
public String name;
public int age;
public Aminal(String name, int age) {
this.name = name;
this.age = age;
}
public void eat(){
System.out.println(this.name+"正在吃饭");
}
}
class Cat extends Aminal{
public Cat(String name, int age) {
super(name, age);
}
@Override
public void eat() {
System.out.println(this.name+"吃猫粮");
}
public void play(){
System.out.println(this.name+"正在眺");
}
}
class Dog extends Aminal{
public Dog(String name, int age) {
super(name, age);
}
@Override
public void eat() {
System.out.println(this.name+"吃狗粮");
}
public void play(){
System.out.println(this.name+"正在跑");
}
}
public class Main {
public static void main(String[] args) {
Aminal cat=new Cat("小黄",3);
cat.eat();
//cat.play(); //报错
Aminal dog=new Dog("小黑",6);
dog.eat();
//dog.play(); //报错
}
}
向上转型的优点是可以让代码的实现变得更加灵活,就比如在复杂的代码中我们是可以返回一个子类的实例化的对象,然后用一个父类引用来进行接收,这样就会变得更加简单灵活;当然,向上转型也有缺点,那就是不能够调用到子类特有的方法(上面代码中有体现出来)。
向下转型
有了向上转型,那也就出现了向下转型。因为向上转型是不能调用子类特有的方法的,那么这时候就出现了向下转型来弥补这一缺点。具体实现是将父类引用在还原为子类对象,那么这时候势必会涉及到强制类型转换,请继续往下看。
延续上面向上转型的代码:
class Aminal{
public String name;
public int age;
public Aminal(String name, int age) {
this.name = name;
this.age = age;
}
public void eat(){
System.out.println(this.name+"正在吃饭");
}
}
class Cat extends Aminal {
public Cat(String name, int age) {
super(name, age);
}
@Override
public void eat() {
System.out.println(this.name+"吃猫粮");
}
public void play(){
System.out.println(this.name+"正在眺");
}
}
class Dog extends Aminal {
public Dog(String name, int age) {
super(name, age);
}
@Override
public void eat() {
System.out.println(this.name+"吃狗粮");
}
public void play(){
System.out.println(this.name+"正在跑");
}
}
public class Main {
public static void main(String[] args) {
Aminal aminal1=new Cat("小黄",3); //向上转型
Cat cat=(Cat) aminal1; //向下转型
cat.play();
Aminal aminal2=new Dog("小黑",6); //向上转型
Dog dog=(Dog) aminal2; //向下转型
dog.play();
}
}
虽然向下转型可以解决向上转型后留下来的不能调用子类特有方法的问题,但是在实际的开发过程中,我们是比较少使用到向下转型的,因为不安全(1. 有可能没有进行前置类型转换而导致转换失败;2. 以上面的代码为例,有可能在进行向下转型的时候,将原本是猫类的转换成了狗类,从而导致错误),所以为了提高安全性,我们会尽量少地使用向下转型。
使用多态的优缺点
使用多态的优点:
- 能够降低代码的“圈复杂度”,避免大量使用 if-else(等的条件语句):如果没有多态,我们在调用不同类中的同一行为的不同实现的时候,就要一直使用条件语句来选择要调用哪个类中的方法。而多态就不一样了,可以写出父子类关系,然后将这个方法在子类中进行重写(还可使用向上转型相关的东西等),大大降低了代码的圈复杂度。
- 可扩展能力更强:如果写成 if-else(等的条件语句),在后续代码的修改成本会比较大(相当于直接把代码写死了)。而多态就不一样了,只要直接创建一个新类即可,改动的成本比较低。
**使用多态的缺点:**代码运行效率比较低。
避免在构造方法中调用重写的方法
比如下面这段代码:
class B {
public B() {
func();
}
public void func() {
System.out.println("B.func()");
}
}
class D extends B {
private int num = 1;
@Override
public void func() {
System.out.println("D.func() " + num);
}
}
public class Main {
D d=new D();
d.func(); //直接报错
}
这段代码在构造子类D对象的同时,会先调用父类B的构造方法,又因为B的构造方法中调用的func()方法,此时会触发动态绑定,会调用子类D中的func(),此时D对象自身还没有构造,num是并未进行初始化的,所以会报错。