Java核心技术1-继承

  |  

摘要: 本文是《Java核心技术 10th》中继承的要点总结

【对算法,数学,计算机感兴趣的同学,欢迎关注我哈,阅读更多原创文章】
我的网站:潮汐朝夕的生活实验室
我的公众号:算法题刷刷
我的知乎:潮汐朝夕
我的github:FennelDumplings
我的leetcode:FennelDumplings


本文是《Java核心技术1》第10版 【Chap5 继承】 的要点总结。由于此前在 C++ 中已经接触过面向对象编程,这里主要关注 Java 与 C++ 有区别的地方。


类、超类、子类

定义子类

假设已经有了一个 Employee 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Employee {
// instance fields 实例域
private String name;
private double salary;
private LocalDate hireDay;

// constructor 构造器
public Employee(String n, double s, int year, int month, int day) {
name = n;
salary = s;
hireDay = LocalDate.of(year, month, day);
}

// 方法
public String getName() {
return name;
}

public double getSalary() {
return salary;
}

public LocalDate getHireDay() {
return hireDay;
}

public void raiseSalary(double byPercent) {
double raise = salary * byPercent / 100;
salary += raise;
}
}

现在从 Employee 派生出 Manager 类,新增 Bonus 和 setBonus 方法。

1
2
3
4
5
6
7
public class Manager extends Employee {
private double bonus;

public void setBonus(double bonus) {
this.bonus = bonus;
}
}

C++ 和 Java 中继承的区别

Java 中所有的继承都是公有继承,而 C++ 中有私有继承和保护继承。

覆盖方法

Manager 类的 getSalary 应返回薪金和奖金的总和,因此需要新的方法覆盖(override)超类的方法。

1
2
3
4
5
public class Manager extends Employee {
public double getSalary() {
return bonus + super.getSalary();
}
}

其中 super.getSalary() 最关键,有两个要点需要注意

  • 不能直接写 salary,因为子类不能直接访问超类的私有域,必须借助公有接口。
  • 不能直接写 getSalary(),因为子类也有一个 getSalary() 方法,因此需要用 super 指出。

super 不是一个对象的引用,不能将 super 赋值给另一个对象变量,它只是一个指示编译器调用超类方法的关键字。

C++ 和 Java 中使用超类方法的区别

Java 中用 super 调用超类的方法;C++ 中用 ::,例如 Employee::getSalary()

子类构造器

1
2
3
4
5
6
public class Manager extends Employee {
public Manager(String n, double s, int year, int month, int day) {
super(n, s, year, month, day);
bonus = 0;
}
}

super(n, s, year, month, day) 的意思是调用超类中含有 n, s, year, month 和 day 参数的构造器。

由于 Manager 类的构造器不能访问 Employee 类的私有域,所以必须利用 Employee 类的构造器对这部分私有域进行初始化。

如果子类的构造器没有显式地调用超类的构造器,则将自动地调用超类默认的构造器。

如果超类没有不带参数的构造器,并且在子类的构造器中又没有显式地调用超类的其他构造器,则Java编译器将报告错误。

C++ 和 Java 中调用超类构造函数的区别

在 C++ 的构造函数中,使用初始化列表语法调用超类的构造函数。

1
2
3
4
5
Manager::Manager(String name, double s, int year, int month, int day)
:Employee(n, s, year, month, day)
{
bonus = 0;
}

多态和动态绑定的初步例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ManagerTest {
public static void main(String[] args) {
// construct a Manaber object
Manager boss = new Manager("Carl Cracker", 80000, 1987, 12, 15);
boss.setBonus(5000);

Employee[] staff = new Employee[3];

// fill the staff array with Manager and Employee objects

staff[0] = boss;
staff[1] = new Employee("Harry Hacker", 50000, 1989, 10, 1);
staff[2] = new Employee("Tommy Tester", 40000, 1990, 3, 15);

// print out information about all Employee objects
for(Employee e: staff) {
System.out.println("name=" + e.getName() + ",Salary=" + e.getSalary());
}
}
}

(1) Employee 类型的对象变量 e,既可以引用 Employee 类型的对象,也可以引用 Manager 类型的对象。

(2) 虚拟机知道 e 实际引用的对象类型,因此能够正确地调用相应的方法。

  • 当 e 引用 Employee 对象时,e.getSalary() 调用的是 Employee 类中的 getSalary 方法;
  • 当 e 引用 Manager 对象时,e.getSalary() 调用的是 Manager 类中的 getSalary 方法。

一个对象变量可以指示多种实际类型的现象被称为多态(polymorphism),例如前面的 (1)。

在运行时能够自动地选择调用哪个方法的现象称为动态绑定(dynamic binding),例如前面的 (2)。

C++ 和 Java 关于多态和动态绑定的区别

在 Java 中,不需要将方法声明为虚拟方法。动态绑定是默认的处理方式。

如果不希望让一个方法具有虚拟特征,可以将它标记为 final。

继承层次

Manager 类还可以继续派生 Executive 类,由一个公共超类派生出的所有类的集合称为继承层次

从某特定类到祖先的路径称为该类的继承链

C++ 和 Java 关于继承层次的区别

Java 不支持多继承。有关 Java 中多继承功能的实现方式,需要接口的概念。

多态

判断是否应该设计为继承关系的规则:”is-a” 规则。

“is-a” 规则表明程序中出现超类对象的任何地方都可以用子类对象置换。

1
2
3
Manager boss = new Manager(...);
Employee[] staff = new Employee[3];
staff[0] = boss;

上面的代码中,staff 中的元素是 Employee,但可以引用 Manager,例如代码中 staff[0] 和 boss 引用同一个对象。

但是注意:编译器将 staff[0] 视为 Employee 对象,因此 staff[0].setBonus(5000) 是不行的。

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

1
2
Manager[] managers = new Manager[10];
Employee[] staff = managers;

注意此时 staff 和 managers 是应用的同一个数组,staff[0] 和 managers[0] 引用的是同一个对象。如果 staff[0] = new Employee(...),则 managers[0] 也会指向这个新生成的 Employee,这被编译器接受。但是风险会很大,因为此后调用 managers[0].setBonus(1000) 时会调用一个不存在的实例域,搅乱相邻存储空间的内容。

对象方法调用过程详细描述

假设调用 x.f(String),隐式参数 x 声明为类 C 的一个对象。

  • step1: 编译器查看对象的声明类型和方法名。也就是 x.f,其中 x 是 C 的对象。有可能存在多个名称为 f 参数类型不一样的方法,编译器会一一例举所有 C 类中名为 f 的方法和其超类中访问属性为 public 且名为 f 的方法(超类的私有方法不可访问)

  • step2: 编译器将查看调用方法时提供的参数类型。如果有完全匹配的,就选择这个方法。这个过程是重载解析如果没找到,或者发现经过类型转换后有多个方法匹配,报错。

返回类型不是函数签名的一部分,因此覆盖方法时应该保证返回类型的兼容性:允许子类将覆盖方法的返回类型定义为原类型的子类型。

  • step3: 如果是 private 方法、static 方法、final 方法或者构造器,那么编译器将准确知道该调用哪个方法,这种调用方式称为静态绑定;与此相对,调用的方法依赖于隐式参数的实际类型,且在运行时实现动态绑定

  • step4: 当程序运行,且采用动态绑定调用方法时,虚拟机一定调用与 x 所引用对象的实际类型最合适的那个类的方法。

例如,假设 x 的实际类型是 D,它是 C 类的子类。如果 D 类定义了方法 f(String),就直接调用它;否则,将在 D 类的超类 C 中寻找 f(String),以此类推。

每次调用方法都要进行搜索,时间开销相当大。因此,虚拟机预先为每个类创建了一个方法表(method table),其中列出了所有方法的签名和实际调用的方法。这样一来,在真正调用方法的时候,虚拟机仅查找这个表就行了。在前面的例子中,虚拟机搜索 D 类的方法表,以便寻找与调用 f(Sting) 相匹配的方法。这个方法既有可能是 D.f(String),也有可能是 X.f(String),这里的 X 是 D 的超类。这里需要提醒一点,如果调用super.f(param),编译器将对隐式参数超类的方法表进行搜索。

动态绑定有一个非常重要的特性:无需对现存的代码进行修改,就可以对程序进行扩展。假设增加一个新类Executive,并且变量e 有可能引用这个类的对象,我们不需要对包含调用 .getSalary() 的代码进行重新编译。如果 e 恰好引用一个 Executive 类的对象,就会自动地调用 Executive.getSalary() 方法。

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

阻止继承: final 类和方法

有时希望阻止用某个类定义子类,这个类称为 final 类,例如:

1
2
3
public final class Executive extends Manager {
...
}

类中特定方法也可以被声明为 final,这样子类就不能覆盖这个方法,final 类中所有方法会自动成为 final 方法。例如

1
2
3
4
5
public class Employee {
public final String getName() {
return name;
}
}

域也可以声明 fianl,但是含义是构造对象后不允许改变值。final 类的方法会自动成为 final,而域不会。

将方法或类声明为final主要目的是:确保它们不会在子类中改变语义。例如 String 是 final 类,则任何人不许定义 String 的子类,此时如果有一个 String 的引用,它引用的一定是 String 对象,而不可能是其它类的对象。

除非有足够的理由使用多态性,应该将所有的方法都声明为 final。在 C++ 中也类似,如果没有特别地说明,所有的方法都不具有多态性。

早期 Java 编译器中,使用 final 还有避免动态绑定的系统开销问题的考虑。如果一个方法没被覆盖且很短,编译器就能进行内联优化:例如 e.getName() 将被替换为 e.name。这个改进是有意义的:由于 CPU 在处理调用方法的指令时,使用的分支转移会扰乱预取指令的策略。但是如果 getName 在另外一个类中被覆盖,则编译器就无法内联处理了。

现在虚拟机中的即时编译器已经可以准确知道类之间的继承关系,并能够检测出类中是否真正存在覆盖给定的方法,而无需 final 的说明了。

强制类型转换

类似于有时候需要将浮点型数值转换成整型数值一样,有时候也需要将某个类的对象引用转换成另外一个类的对象引用。例如:

1
Manager boss = (Manager)staff[0];

进行类型转换的唯一原因是:在暂时忽视对象的实际类型之后,使用对象的全部功能。例如 staff 是 Employee 数组,而 staff[0] 是 Manager,如果想用 staff[0] 中的 Manager 特有的方法,就要转换为 Manager。

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

但是类型转换时不能谎报对象包含的内容,例如

1
Manager boss = (Manager)staff[1];

staff[1] 指向的不是 Manager 对象,做这个转换相当于谎报了 staff[1] 的内容,Java 运行时系统将报错,并产生 ClassCastException 异常,如果没有捕获这个异常,程序将终止。

因此类型转换前,最好先检查是否能够转换,这个过程用 instanceof 实现,null instanceof C 会返回 false。

1
2
3
if(staff[1] instanceof Manager) {
boss = (Manager) staff[1];
}

如果编译时就知道类型转换不可能成功,那么编译时就会报错,比如 String c = (String) staff[1];,因为 String 不是 Employee 的子类。

强制类型转换总结

  • 只能在继承层次内进行类型转换。

  • 在将超类转换成子类之前,应该使用 instanceof 进行检查。

一些经验:只有在使用 Manager 中特有的方法时才需要进行类型转换,例如,setBonus 方法。如果鉴于某种原因,发现需要通过 Employee 对象调用 setBonus 方法,那么就应该检查一下超类的设计是否合理。重新设计一下超类,并添加 setBonus 方法才是正确的选择。

C++ 和 Java 关于强制类型转换的区别

Java 使用的类型转换语法来源于 C 语言,处理过程有点像 C++ 的 dynamic_cast 仓做。例如

1
Manager boss = (Manager) staff[1];

相当于

1
Manager *boss = dynamic_cast<Manager*>(staff[1]);

它们之间只有一点重要的区别:当类型转换失败时,Java不会生成一个null对象,而是抛出一个异常。从这个意义上讲,有点像 C++ 中的引用(reference)转换。

Java 中,需要将 instanceof (类型测试)和类型转换组合使用

1
2
3
if (staff[1] instanceof Manager) {
Manager boss = (Manager) staff[1];
}

C++ 中,可以在一个操作中完成类型测试和类型转换。

1
2
3
4
5
Manager *boss = dynamic_cast<Manager*>(staff[1]);
if(boss != nullptr)
{
...
}

抽象类

类的继承层次网上走,类更具有通用性,也更加抽象。例如

上面的继承层次中,getDescription 方法也是 Employee 和 Student 都有的,但是 Person 并不知道如何实现,这是可以用 abstract 声明为抽象方法。包含一个或多个抽象方法的类本身必须被声明为抽象的。

Employee 和 Student 都有姓名属性,因此 getName 可以实现在 Person 类中,它们是抽象类中的具体数据和具体方法。

1
2
3
4
5
6
7
8
9
10
11
12
public abstract class Person {
private String name;
public Person(String name) {
this.name = name;
}

public abstract String getDescription();

public String getName() {
return name;
}
}

抽象方法充当着占位的角色,它们的具体实现在子类中。抽象类不能实例化,抽象类的子类可以有两种选择

  1. 一种是在抽象类中定义部分抽象类方法或不定义抽象类方法,这样就必须将子类也标记为抽象类
  2. 另一种是定义全部的抽象方法,这样一来,子类就不是抽象的了。

注意:抽象类不能实例化,但是可以定义一个抽象类的对象变量,只是它只能引用费抽象子类的对象。

1
2
Person p = new Student(...);
p.getDescription();

由于 Person 不可实例化,p 就永远不会引用 Person 对象,而是类似于 Employee 或 Student 的子类,因此 getDescription 是可以像上面这样调用的。如果去掉 Person 超类的抽象方法,那么就不能像 p.getDescription()这样调用了。

C++ 和 Java 中关于抽象类的区别

C++ 中,在尾部用 =0 标记抽象方法,称为纯虚函数

1
2
3
4
5
class Person
{
public:
virtual string getDescription() =0;
};

在C++中,只要有一个纯虚函数,这个类就是抽象类。没有提供用于表示抽象类的特殊关键字。

受保护访问

有时希望超类中的某些域或方法允许被子类访问,此时应该声明 protected。

要谨慎使用 protected 属性。假设需要将设计的类提供给其他程序员使用,而在这个类中设置了一些受保护域,由于其他程序员可以由这个类再派生出新类,并访问其中的受保护域。在这种情况下,如果需要对这个类的实现进行修改,就必须通知所有使用这个类的程序员。这违背了 OOP 提倡的数据封装原则。

受保护的方法更具有实际意义。如果需要限制某个方法的使用,就可以将它声明为protected。这表明子类(可能很熟悉祖先类)得到信任,可以正确地使用这个方法,而其他类则不行。最好的例子是 Object 类的 clone 方法

C++ 和 Java 中关于保护机制的区别

Java 的受保护部分,对所有子类,以及同一个包中的所有其它类都可见。安全型比 C++ 的 protected 差。

总结 Java 的 4 种可见性

1)仅对本类可见:private。

2)对所有类可见:public。

3)对本包和所有子类可见:protected。

4)对本包可见:默认。


继承的设计技巧

  • 将公共操作和域放在超类。
  • 避免使用受保护的域。
    protected 机制并不能够带来更好的保护,其原因主要有两点。
  1. 子类集合是无限制的,任何一个人都能够由某个类派生一个子类,并编写代码以直接访问 protected 的实例域,从而破坏了封装性。
  2. 在Java程序设计语言中,在同一个包中的所有类都可以访问proteced域,而不管它是否为这个类的子类。
    protected 比较有用的场景:protected 方法对于指示那些不提供一般用途而应在子类中重新定义的方法很有用。
  • 使用继承实现 “is-a” 关系,如果不是 “is-a” 关系,不要用继承。
  • 除非所有继承的方法都有意义,否则不要使用继承。
  • 在覆盖方法时,不要改变预期的行为。(置换原则不仅应用于语法,而且也可以应用于行为)
  • 使用多态,而非类型信息。使用多态方法或接口编写的代码比使用对多种类型进行检测的代码更加易于维护和扩展,例如:
    1
    2
    3
    4
    if(obj isstanceof Class1)
    action1(obj);
    else if(obj isstanceof Class2)
    action2(obj);
    如果 action1 和 action2 是相同的概念,这应该为这个概念定义一个方法,放置在两个类的超类,或接口中,然后调用 obj.action()这样就可以用多态性提供的动态分派机制执行相应的动作。
  • 不要过多使用反射
    反射机制使得人们可以通过在运行时查看域和方法,这种功能对于编写系统程序来说极其实用,但是通常不适于编写应用程序。坏处是编译器很难帮助人们发现程序中的错误,因此只有在运行时才发现错误并导致异常。

Share