13.继承 -- 类、超类和子类【Java温故系列】

参考自–《Java核心技术卷1》

继承 -- 类、超类和子类

      • 1 定义子类、超类
      • 2 方法的覆盖
      • 3 子类构造器
      • 4 继承层次
      • 5 多态
      • 6 理解方法调用
      • 7 阻止继承:final 类和方法
      • 8 方法内联(热点方法)
      • 9 类对象的强制类型转换
      • 10 抽象类
      • 11 protected 受保护访问
      • 12 继承的设计技巧

通过继承,可以基于已存在的类构造一个新类。继承已存在的类就是复用(继承)这些类的方法和域。在此基础上,还可以在新类中添加一些新的方法和域,以满足新的需求。


1 定义子类、超类

公司中有雇员(Employee 类),雇员分为普通员工和经理,当然,普通员工和经理的待遇存在一些差异,不过他们之间也存在很多相同的地方,例如,他们都领取薪水,普通员工完成工作后仅领取薪水,而经理在完成预期的业绩还能得到奖金。这种情形就需要使用继承。

可以为经理定义一个 Manager 类,以便增加了一些新的功能;同时,它继承于 Employee 类,可以重用 Employee 类的部分代码,并将其中的所有域保留下来。

显然,Manager 和 Employee 之间存在明显的"is-a"(是)关系,每个经理也是雇员(继承的明显特征)

Manager 类的定义:

//假设 Employee 类已存在(包含 name,salary 域)
//Manager 类继承于 Employee,增加了一个用于存储奖金信息的域
public class Manager extends Employee{
	//添加方法和域
    private double bonus;  //奖金
    ...
    public void setBonus(double bonus){
        this.bonus = bonus;
    }
}
//Manager 类对象继承了 Employee 的域和方法

关键字 extends 表示继承。extends 表明正在构造的新类派生于一个已存在的类。已存在的类(Employee)称为超类(superclass)、基类或父类;新类(Manager)称为子类(subclass)、派生类。超类和子类是 Java 中最常用的两个术语。

尽管 Employee类 是一个超类,但并不是因为它优于子类或者拥有比子类更多的功能,恰恰相反,子类比超类拥有更多的数据,更多的功能。

在通过扩展超类定义子类时,仅需要指出子类和超类的不同之处。因此在设计类的时候,应该将通用的方法放在超类中,而将具有特殊用途的方法放在子类


2 方法的覆盖

然而,很多时候超类的方法对子类并不适用。比如,Manager 类中的 getSarary方法应该返回薪水和奖金之和(Employee 类中只返回薪水)。为此,需要提供一个新的方法来**覆盖(override)**超类的这个方法:

public class Manager extends Employee{
	//添加方法和域
    private double bonus;  //奖金
    ...
    public double getSalary(){
        return super.getSalary()+this.bonus;
    }
}

上述 getSarary方法的实现使用了关键字 super。通过 super 关键字调用父类 Employee 的 getSalary 方法(public)获取了 Manager 的私有域 salary (私有域无法直接被其他类访问)。

在覆盖一个方法时,子类方法不能低于超类方法的可见性。特别是,如果超类方法是 public,子类方法一定要声明为 public 。

:super 不是一个对象的引用(this 是),它只是一个指示编译器调用超类方法的特殊关键字。

在子类中可以增加域,增加方法或覆盖超类的方法,但绝不能删除继承的任何域和方法。


3 子类构造器

子类的构造器可根据其用途定义,若要继承构造父类的域,则:

//Manager 全参数构造器
public Manager(String name,double salary){
	super(name,salary);  //调用父类的构造方法
	bouns = 0;
}

同样的,由于 Manager 类的构造器不能直接访问 Employee 的私有域,所以必须利用 Employee 类的构造器(该构造器必须在父类存在)对这部分私有域进行初始化,通过 super 实现对超类构造器的调用。使用 super 调用构造器的语句必须是子类构造器的第一条语句

如果子类的构造器没有显式地调用超类的构造器,则将自动地调用超类默认(没有参数)的构造器。如果超类没有不带参数的构造器(有其他带参数的构造器),而子类构造器又没有显式地调用超类的其他构造器,则 Java 编译器将报错。

this 和 super:

关键字 this 的用途:1.引用隐式参数;2.调用该类的其他的构造器。

关键字 super 的用途:1.调用超类的方法;2.调用超类的构造器。

在调用构造器时,它们俩的使用方式很相似:调用构造器的语句只能作为另一个构造器的第一条语句出现;构造参数既可以传递给本类(this)的其他构造器,也可以传递给超类(super)的构造器。


4 继承层次

继承并不局限于一个层次。如 Manager 类再派生出 Executive 类(新类)。

由一个公共超类派生出来的所有类的集合被称为继承层次。在继承层次中,从某个特定的类到其祖先的路径被称为该类的继承链。

如:
13.继承 -- 类、超类和子类【Java温故系列】_第1张图片

Java 不支持类之间的多继承(接口之间可以),即一个子类只能有一个父类。


5 多态

Manager m = new Manager("zs",1200);  //创建一个经理类的对象,name=zs,salary=1200
m.setBonus(200);  //设置奖金200

Employee[] staff = new Employee[3];

staff[0] = m;  //Employee 类对象引用 Manager 类对象
staff[1] = new Employee("lisi",800);  //创建雇员类对象,name=lisi,salary=800
staff[2] = new Employee("wangwu",900);

//输出每个人的薪水
for(Employee e:staff){
    System.out.println(e.getName()+"  "+e.getSalary());
}

输出:
    zs  1400.0
    lisi  800
    wangwu  900

上例中的 staff[1],staff[2] 仅输出基本的薪水,因为它们对应的是 Employee 对象,而 staff[0] 对应的是 Manager 对象,它的 getSalary() 方法将奖金与基本薪水加到一起。

需要提到的是,

e.getSalary()

调用能够确定应该执行哪个 getSalary 方法。请注意,尽管这里将 e 声明为 Employee 类型 ,但实际上 e 既可以引用 Employee 类型的对象 , 也可以引用 Manager 类型的对象 。

当 e 引用 Employee 对象时,e.getSalary() 调用的是 Employee 类中的 getSalary 方法;当 e 引用 Manager 对象时,e.getSalary() 调用的是 Manager 类中的 getSalary 方法 。虚拟机知道 e 实际引用的对象类型,因此能够正确地调用相应的方法 。

对象变量是多态的。一个对象变量(例如 e)可以指示多种实际类的现象被称为多态。在运行时能够自动地选择调用哪个方法的现象称为动态绑定。

多态的应用

1.可以将一个子类的对象赋给超类变量

Employee e;  //声明一个 Employee 类变量(超类变量)
e = new Employee();  // 创建一个 Employee 类赋给变量 e
//或者
e = new Manager();  // 创建一个 Manager 类对象(子类对象)赋给变量 e

2.一个 Employee 变量既可以引用一个 Employee 类对象,也可以引用 Employee 类下的任何一个子类的对象。

Manager manager = new Manager(); 
Employee[] staff = new Employee[3];  
staff[0] = manager;

变量 staff[0] 与 manager 引用同一个对象。但编译器将 staff[0] 看成 Employee 对象。

这意味着,可以:manager.setBonus(200); setBonus() 是子类 Manager 的方法;

但不能:staff[0].setBonus(200);

因为 staff[0] 声明的类型是 Employee ,而 setBonus() 不是 Employee 类的方法(正如,不是所有的雇员都是经理)。

3.然而,不能将一个超类的引用赋给子类变量。

Manager m = staff[i]; //非法

:在 Java 中,子类数组的引用可以转换成超类数组的引用,而不需要采用强制类型转换。

Manager[] managers = new Manager[10];
Employee[] staff = managers;  //,此时staff和managers引用的是同一个Manager数组

但是,如果:

staff[0] = new Employee();  //引发 ArrayStoreException 异常(普通员工归类到经理??!!)

6 理解方法调用

弄清楚如何在对象上应用方法调用非常重要。下面假设要调用 x.f(String) ,x 声明为类 A 的一个对象:

1)编译器查看对象的声明类型和方法名。如x.f(String) ,x 为A类的一个对象,需要注意,可能存在多个名字为 f ,但参数类型不一样的方法。例如可能同时存在方法 f(int) 或方法 f(String) 。编译器会一一列举所有 A 类中名为 f 的方法和其(A 类的)超类中访问属性为 public 且名为 f 的方法(超类的私有方法不可访问)。至此,编译器以获取所有可能被调用的候选方法。

2)接下来,编译器将查看调用方法时提供的参数类型。如果在所有名为 f 的方法中存在一个与提供的参数类型(该参数类型允许类型转换(int 可以转换为 double 等))完全匹配,则选择该方法。这个过程称为重载解析。如果编译器没有找到与参数类型匹配的方法,或发现经过类型转换后有多个方法与之匹配,就会报错。如果一切顺利,至此,编译器就获取需要调用的方法名称和参数类型。

:方法的名字+参数列表=方法的签名。例如,f(int) 和 f(String) 是两个具有相同名字,不同签名的方法。如果子类中定义了一个与超类签名相同的方法,那么子类中的这个方法就覆盖了超类中这个相同签名的方法。但是,返回类型并不是签名的一部分,因此,在覆盖方法时,一定要保证返回类型的兼容性,允许子类将覆盖方法的返回类型定义为原返回类型的子类型。

3)如果 f 是 private 方法、static 方法、final 方法或者构造器,那么编译器将可以准确地知道应该调用哪个方法,这种调用方式称为静态绑定。与此对应的是,调用的方法依赖于隐式参数的实际类型,并且在运行时实现动态绑定。此例中(x.f(String) ),编译器采用动态绑定的方式生成一条调用 f(String) 的指令。

4)当程序运行,并且采用动态绑定调用方法时,虚拟机一定调用与 x 所引用对象的实际类型最合适的那个类的方法。假设 x 的实际类型是 B,B 是 A类的子类。若 B 类定义了方法 f(String) ,则直接调用该方法;否则,将在 B 的超类中寻找 f(String).

每次调用方法都要进行搜索,时间开销相当大。因此,虚拟机预先为每个类创建了一个方法表,其中列出了所有方法的签名和实际调用的方法。这样在调用方法时,虚拟机查找这个表即可。


7 阻止继承:final 类和方法

有时候,可能希望阻止人们利用某个类定义子类。不允许扩展的类被称为 final 类

在定义类时使用 final 修饰符就表明这个类是 final 类(final 类中的所有方法自动地成为 final 方法,但域不会)。如:

public final class Executive extends Manager{....}

类中的方法也可以被声明为 final 类(这样的话该类的子类就不能覆盖这个方法)。

域也可以被声明为 final,对于 final 域来说,创建对象之后就不允许改变它们的值了。

将方法或类声明为 final 的主要目的是:确保它们不会在子类中改变语义。如 String 就是一个 final 类,这意味着不允许定义 String 的子类。换言之,若有一个 String 的引用,它引用的必然是一个 String 对象,而不可能是其他类的对象。


8 方法内联(热点方法)

如果一个方法没有被覆盖并且很短,编译器就能对它进行优化处理,这个过程称为内联。例如:

private int add(int x,int y){
	return x+y;
}

private int twoadd(int x1,int x2,int x3,int x4){
	return add(x1,x2)+add(x3,x4);
}

经编译器处理后,对 add 方法进行内联处理(展开其函数体):

//处理后
private int twoadd(int x1,int x2,int x3,int x4){
	return x1+x2+x3+x4;
}

函数调用是一个压栈和出栈的过程,期间会产生一定的时间开销和空间开销。

若一个方法方法体不大,而又频繁被调用,它的时间和空间开销就会相对变得很大,同时还会降低程序的性能。通过方法内联,减少了函数调用的时间开销,但会增加目标程序的代码量(典型的空间换时间)。

若一个方法的方法体很大,那么展开该方法的时间开销就可能已经超过了该函数的调用时间开销,此时若还引入内联反而减低了性能。

在 Java 中,是否为内联函数,是由编译器决定的。如果方法很简短、被频繁调用且没有真正地被覆盖,那么编译器就会将这个方法进行内联处理。若虚拟机加载了一个子类,这个子类中包含了对内联方法的覆盖,优化器就会取消覆盖方法的内联。

一般方法都不会被编译器内联处理,只有声明了 final 后,编译器才会考虑是否对它进行内联处理(还要看方法是否简短、是否被频繁调用等)。


9 类对象的强制类型转换

正如将普通浮点型数值转换为整型数值(int)一样,有时可能需要将某个类的对象引用转换为另一个类的对象引用。对象引用的转换语法与数值表达式的类型转换类似,仅需要用一对圆括号将目标类名括起来,并放置在需要转换的对象引用之前就可以。如:

Employee[] staff = new Employee[3];   //此前的Employee数组
...
Manager manager = (Manager)staff[0];  //将Employee类对象引用转换为Manager类对象引用

进行类型转换的唯一原因是:在暂时忽视对象的实际类型之后,使用对象的全部功能。如上,staff 数组记录所有员工对象,而经理也是员工,但我们需要将数组中引用经理的元素复原为 Manager 类,以便访问新增加的所有方法和域。

在 Java 中,每个对象变量都属于一个类型。类型描述了这个变量所引用的以及能够引用的对象类型。

将一个值存入变量时,编译器将检查是否允许该操作。将一个子类的引用赋给一个超类变量,编译器是允许的;但将一个超类的引用赋给一个子类变量,必须进行类型转换,这样才能通过运行时的检查

**instanceof 操作符:**检查对象变量的类型

x instanceof A

返回 true (x 对象是 A 类的一个实例或 A 类的超类的实例)或 false。在进行类型转换之前,往往需要使用 instanceof 操作符判断能否进行转换(避免程序异常终止)。

:若 x 变量为 null,则返回 false。

综上:

  • 只能在继承层次内进行类型转换
  • 在将超类转换成子类之前,应该使用 instanceof 进行检查

实际上,通过类型转换调整对象的类型并不是一种好的做法,往往只有在需要使用子类中特有的方法时才需要进行类型转换。一般情况下,应该尽量少用类型转换和 instanceof 运算符。


10 抽象类

在类的继承层次结构中,位于上层的类更具有通用性(拥有众多子类),更加抽象(集成方法)。从使用范围来看,祖先类更加通用,人们将它作为派生其他类的基类,而不作为想使用的特定的实例类。

例如,对 Employee 类层次进行扩展,雇员是人,学生也是人,将 Student 类和 Person 类添加到 Employee 类的层次结构中,如图:

image-20200427162250894

下面对这个类结构进行设计:

1.每个人都有一些诸如姓名这样的属性,学生和雇员都有姓名属性,因此可以将 getName 方法放置在位于继承关系较高层次的通用超类 Person 类中。

2.再增加一个 getDescription 方法,获取对一个人的描述。学生和雇员的描述显然不同,getDescription 方法在它们各自的类中实现很容易,但在 Person 类中要实现这个方法呢?Person 类并不知道描述信息,不过我们可以使它返回一个空字符串,但还有一个更好的方法:使用 abstract 关键字。

public abstract String getDescription();

这样一来,完全不需要实现这个方法但依旧可以使用这个方法。

为了提高程序的清晰度,包含一个或多个抽象方法的类本身必须被声明为抽象的(类即使不含有抽象方法,也可以声明为抽象的)。

//抽象的 Person 类
public abstract class Person{
	private String name;
	public Person(String name){
		this.name = name;
	}
	
	public abstract String getDescription();
	
	public String getName(){
		return this.name;
	}
	
}

除了抽象方法外,抽象类还可以包含具体数据和具体方法。抽象类不能被实例化,即如果将一个类声明为 abstract ,就不能创建这个类的对象,如:new Person("zs"); 是错误的,但是可以定义一个抽象类的对象变量,该对象变量可以引用抽象类的具体子类的对象。

Person p = new Student("zs");

抽象方法充当着占位的角色,它们的具体实现在子类中。

//扩展抽象类 Person 的具体子类 Student
public class Student extends Person{
	private String major;
	
	public Student(String name,String major){
		super(name);
		this.major = major;
	}
	
    //抽象方法的具体实现
	public String getDescription(){
		return "这是一个学生";
	}
}
//扩展抽象类 Person 的具体子类 Employee
public class Employee extends Person{
	private double salary;
	
	public Employee(String name,double salary){
		super(name);
		this.salary = salary;
	}
	
    //抽象方法的具体实现
	public String getDescription(){
		return "这是一个雇员";
	}
}
//应用
public static void main(String[] args){
    Person[] p = new Person[2];
    
    //Person 数组引用其具体子类对象
    p[0] = new Employee("zs",5000.0);
    p[1] = new Student("ls","soft");
    
    //输出对象的姓名和描述
    for(Person i:p){
        System.out.println(p.getName()+","+p.getDescription());
    }
}

试想,若省略 Person 超类中的抽象方法 getDescription() ,而仅在 Student 和 Employee 子类中定义 getDescription() 方法,那还能通过变量 i (Person 数组 p 中的元素)访问 getDescription() 吗?

显然是不能的,编译器只允许调用类中声明的方法。


11 protected 受保护访问

在类的设计中,最好将类中的域标记为 private ,而方法标记为 public 。任何声明为 private 的内容对其他类都是不可见的,包括它的子类(即子类也不能访问超类的私有域)。

然而,在某些时候,人们希望超类的某些方法允许被子类访问,或允许子类的方法访问超类的某个域。可以将这些方法或域声明为 protected 。

如 B 类是 A 类的子类,将 A 类的 c 域声明为 protected,其他域均声明为 private,则 B 类中的方法可以直接访问 A 类的 c 域,但也只能访问 c 域,而不能访问其他域。这种限制有利于避免滥用受保护机制,使得子类只能获得访问受保护域的权利。

在实际应用中,要谨慎使用 protected 关键字。

Java 用于控制可见性的4个访问修饰符:

  • 仅对本类可见 — private
  • 对所有类可见 — public
  • 对本包和所有子类可见 — protected
  • 对本包可见 — 默认,无修饰符

12 继承的设计技巧

1)将公共操作和域放在超类

2)不要使用受保护的域

3)使用继承实现 is-a 关系

4)除非所有继承的方法都有意义,否则不要使用继承

5)在覆盖方法时,不要改变预期的行为

6)使用多态,而非类型信息判断

7)不要过多地使用反射

你可能感兴趣的:(【Java温故系列】)