龙空技术网

Java核心技术,继承:类+继承层次+强制类型转换+多态+抽象类

程序员高级码农II 387

前言:

眼前各位老铁们对“java语言中允许一个类继承多个类”大致比较看重,姐妹们都想要分析一些“java语言中允许一个类继承多个类”的相关资讯。那么小编同时在网上网罗了一些关于“java语言中允许一个类继承多个类””的相关内容,希望朋友们能喜欢,大家快快来了解一下吧!

前言

主要阐述了类和对象的概念。本章将学习面向对象程序设计的另外一个基本概念:继承(inheritance)。利用继承,人们可以基于已存在的类构造一个新类。继承已存在的类就是复用(继承)这些类的方法和域。在此基础上,还可以在新类中添加一些新的方法和域,以满足新的需求。这是Java程序设计中的一项核心技术。

与前一章相同,对于只使用过C、Visual Basic或COBOL这类面向过程的程序设计语言的读者来说,一定要仔细地阅读本章的内容。对于使用过C++或Smalltalk这类面向对象的程序设计语言的读者来说,对本章介绍的绝大部分内容可能已经比较熟悉,不过Java与C++或其他面向对象的程序设计语言相比较,在实现继承上存在着较大的差异。

另外,在本章中,还阐述了反射(reflection)的概念。反射是指在程序运行期间发现更多的类及其属性的能力。这是一个功能强大的特性,使用起来也比较复杂。由于主要是开发软件工具的人员,而不是编写应用程序的人员对这项功能感兴趣,因此对于这部分内容,可以先浏览一下,待日后再返回来学习。

类、超类和子类

现在让我们重新回忆一下在前一章中讨论的Employee类。假设你在某个公司工作,该公司中经理的待遇与普通雇员的待遇存在着差异。不过,他们之间也存在着很多相同的地方,例如,他们都领取薪水。只是普通雇员在完成本职任务之后仅领取薪水,而经理在完成了预期的业绩之后还能得到奖金。这种情形就需要使用继承。这是因为需要为经理定义一个新类Manager,以便增加一些新功能。但可以重用Employee类中已经编写的部分代码,并将其中的所有域保留下来。从理论上讲,在Manager与Employee之间存在着明显的“is-a”(是)关系,每个经理都是一名雇员:

“is-a”关系是继承的一个明显特征。

下面是由继承Employee类定义Manager类的格式,关键字extends表示继承。

class Manager extends Employee{添加方法和域}

在Manager类中,增加了一个用于存储奖金信息的域,以及一个用于设置这个域的方法:

这里定义的方法和域并没有什么特别之处。如果有一个Manager对象,就可以使用setBounds方法。

Manager boss = . . . ;

boss.setBonus(5000);

当然,由于setBonus方法不是在Employee类中定义的,所以属于Employee类的对象不能使用它。

然而,尽管在Manager类中没有显式地定义getName和getHireDay等方法,但属于Manager类的对象却可以使用它们,这是因为Manager类自动地继承了超类Employee中的这些方法。

同样,从超类中还继承了name、salary和hireDay这3个域。这样一来,每个Manager类对象就包含了4个域:name、salary、hireDay和bonus。

在通过扩展超类定义子类的时候,仅需要指出子类与超类的不同之处。因此在设计类的时候,应该将通用的方法放置在超类中,而将具有特殊用途的方法放置在子类中,这种将通用的功能放到超类的做法,在面向对象程序设计中十分普遍。

然而,超类中的有些方法对子类Manager并不一定适用。例如,在Manager类中的getSalary方法应该返回薪水和奖金的总和。为此,需要提供一个新的方法来覆盖(override)超类中的这个方法:

应该如何实现这个方法呢?乍看起来似乎很简单,只要返回salary和bonus域的总和就可以了。

然而,这个方法并不能运行。这是因为Manager类的方法getSalary不能够直接地访问超类的私有域。也就是说,尽管每个Manager对象都拥有一个名为salary的域,但在Manager类的getSalary方法中并不能够直接地访问salary域。只有Employee类的方法才能够访问私有部分。如果Manager类的方法一定要访问私有域,就必须借助于公有的接口,Employee类中的公有方法getSalary正是这样一个接口。

现在,再试一下。将对salary域的访问替换成调用getSalary方法。

上面这段代码仍然不能运行。问题出现在调用getSalary的语句上,这是因为Manager类也有一个getSalary方法(就是正在实现的这个方法),所以这条语句将会导致无限次地调用自己,直到整个程序崩溃为止。

这里需要指出:我们希望调用超类Employee中的getSalary方法,而不是当前类的这个方法。为此,可以使用特定的关键字super解决这个问题。

上述语句调用的是Employee类中的getSalary方法。下面是Manager类中getSalary方法的正确书写格式:

正像前面所看到的那样,在子类中可以增加域、增加方法或覆盖超类的方法,然而绝对不能够删除继承的任何域和方法。

最后,看一下super在构造器中的应用。

这里的关键字super具有不同的含义。语句

是“调用超类Employee中含有n、s、year、month和day参数的构造器”的简写形式。

由于Manager类的构造器不能访问Employee类的私有域,所以必须利用Employee类的构造器对这部分私有域进行初始化,我们可以通过super实现对超类构造器的调用。使用super调用构造器的语句必须是子类构造器的第一条语句。

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

重新定义Manager对象的getSalary方法之后,奖金就会自动地添加到经理的薪水中。

下面给出一个例子,其功能为创建一个新经理,并设置他的奖金:

Manager boss = new Manager("Carl Cracker", 80000, 1987,12,15);

boss.setBonus(5000);

下面定义一个包含3个雇员的数组:

Employee[ ] staff = new Employee[3];

将经理和雇员都放置到数组中:

staff[0] = boss;

staff[1] = new Employee("Harry Hacker", 50000, 1989, 10, 1);

staff[2] = new Employee("Tony Tester", 40000, 1990, 3, 15);

输出每个人的薪水:

for (Employee e:staff)

System.out.println(e.getName( )+" "+e.getSalary( ));

运行这条循环语句将会输出下列结果:

Carl Cracker 85000.0

Harry Hacker 50000.0

Tommy Tester 40000.0

这里的staff[1]和staff[2]仅输出了基本薪水,这是因为它们对应的是Employee对象,而staff[0]对应的是Manager对象,它的getSalary方法将奖金与基本薪水加在了一起。

需要提到的是,e.getSalary( ) 能够确定应该执行哪个getSalary方法。请注意,尽管这里将e声明为Employee类型,但实际上e既可以引用Employee(当i为1或2)类型的对象,也可以引用Manager(当i为0)类型的对象。

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

一个对象变量(例如,变量e)可以引用多种实际类型的现象被称为多态(polymorphism)。

在运行时能够自动地选择调用的适当方法的现象称为动态绑定(dynamic binding)。在本章中将详细地讨论这两个概念。

例5-1的程序展示了Employee对象与Manager对象在薪水计算上的区别。

例5-1 ManagerTest.java

继承层次

继承并不仅限于类的一个层次。例如,可以由Manager类扩展Executive类。由一个公共超类派生出来的所有类的集合被称为继承层次(inheritance hierarchy),如图5-1所示。在继承层次中,从某个特定的类到其祖先的路径被称为该类的继承链(inheritance chain)。

通常,一个祖先类可以拥有多个子孙继承链。

例如,可以由Employee类派生出子类Programmer或Secretary,它们与Manager类没有任何关系(有可能它们彼此之间也没有任何关系)。必要的话,可以将这个过程一直延续下去。

多态

有一个简单的规则,可以用来判断是否应该将数据设计为继承关系。这就是“is-a”规则,它表明子类的每个对象也是超类的对象。例如,每个经理都是雇员,因此,将Manager类设计为Employee类的子类是显而易见的。反之不然,因为并不是每一名雇员都是经理。

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

例如,可以将一个子类的对象赋给超类变量。

Employee e;

e = new Employee(. . .); //Employee object expected

e = new Manager(. . .); //Ok, Manager can be used as well

在Java程序设计语言中,对象变量是多态的。一个Employee类型变量既可以引用一个Employee类型对象,也可以引用一个Employee类的任何一个子类的对象(例如,Manager、Executive、Secretary等)。

从例5-1中,已经看到了置换法则的优点:

Manager boss = new Manager(. . .);

Employee[ ] staff = new Employee[3];

staff[0] = boss;

在这个例子中,变量staff[0]与boss引用同一个对象。但编译器将staff[0]看待成Employee对象。

这意味着,可以这样调用:

boss.setBonus(5000); //Ok

但不能这样调用:

staff[0].setBonus(5000); //ERROR

这是因为staff[0]声明的类型是Employee,而setBonus不是Employee类的方法。

然而,不能将一个超类的引用赋给子类变量。例如,下面的赋值是非法的:

Manager m = staff[i]; //ERROR

原因很清楚,不是所有的雇员都是经理。如果赋值成功,m有可能引用了一个不是经理的Employee对象,当在后面调用m.setBonus(. . .) 时就有可能发生运行时错误。

动态绑定

弄清调用对象方法的执行过程十分重要。下面是调用过程的详细描述:

1)编译器查看对象的声明类型和方法名。假设调用x.f(param),且x被声明为C类的对象。需要注意的是:有可能存在多个名字为f,但参数类型不一样的方法。例如,可能存在方法f(int) 和方法f(String)。编译器将会一一列举所有C类中名为f的所有方法和其超类中访问属性为public且名为f的方法。

至此,编译器已获得所有可能被调用的候选方法。

2)接下来,编译器将查看调用方法时提供的参数类型。如果在所有名为f的方法中存在一个其参数类型与提供的参数完全匹配,那么就调用这个方法。这个过程被称为重载解析(overloadingresolution)。例如,对于调用x.f("Hello") 来说,编译器将会挑选f(String),而不是f(int)。由于允许类型转换(int可以转换成double,Manager可以转换成Employee等等),所以这个过程可能很复杂。如果编译器没有找到与参数类型匹配的方法,或者发现经过类型转换后有多个方法与之匹配,就会报告一个错误。

至此,编译器已获得需要调用的方法的名字和参数类型。

3)如果方法是private、static、final(有关final修饰符的含义将在下一节讲述)或者构造器,那么编译器将可以准确地知道应该调用哪个方法。我们将这种调用方式称为静态绑定(staticbinding)。与之对应,调用哪个方法将依赖于隐式参数的实际类型,并且在运行时动态绑定。在上面列举的例子中,编译器就是采用动态绑定的方式生成一条调用f(String) 的指令。

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

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

现在让我们查看一下例5-1中调用e.getSalary( )的详细过程。e声明为Employee类型。Employee类只有一个名叫getSalary的方法,这个方法没有参数。因此,在这里我们不必担心重载解析的问题。

由于getSalary不是private、static和final方法,所以将采用动态绑定。虚拟机为Employee和Manager两个类生成方法表。在Employee的方法表中,列出了这个类定义的所有方法:

实际上,上面列出的方法并不完整,稍后会看到Employee类有一个超类Object,Employee类从这个超类中还继承了许多方法。在此,我们略去了这些方法。

Manager方法表稍微有些不同。其中有三个方法是继承而来的,一个方法是重新定义的,还有一个方法是新增加的。

在运行的时候,调用e.getSalary( )的解析过程为:

1)首先,虚拟机提取e的实际类型的方法表。既可能是Employee、Manager的方法表,也可能是Employee类的其他子类的方法表。

2)接下来,虚拟机搜索定义getSalary签名的类。此时,虚拟机已经知道应该调用哪个方法。

3)最后,虚拟机调用方法。

动态绑定有一个非常重要的特性:无需对现存的代码进行修改,就可以对程序进行扩展。

假设增加一个新类Executive,并且变量e有可能引用这个类的对象。我们不需要对包含调用e.getSalary( ) 的代码进行重新编译。如果e恰好引用一个Executive类型的对象,就会自动地调用Executive.getSalary( )方法。

阻止继承:final类和final方法

有时候,可能希望阻止人们利用某个类定义子类。不允许扩展的类被称为final类。如果在定义类的时候使用了final修饰符就表明这个类是final类。例如,假设希望阻止人们定义Executive类的子类,那么就可以在定义这个类的时候,使用final修饰符声明。声明格式如下所示:

类中的方法也可以被声明为final。如果这样做,子类就不能覆盖这个方法。(final类中的所有方法自动地成为final方法。)例如,

将方法或类声明为final主要鉴于以下原因:确保它们不会在子类中改变语义。例如,Calendar类中的getTime和setTime方法都是final的。这表明Calendar类的设计者负责实现Date类与日历状态之间的转换,而不允许子类处理这些问题。同样地,String类也是final的,这意味着不允许任何人定义String的子类。

换言之,如果有一个String的引用,那么它引用的一定是一个String对象,而不可能是其他类的对象。

有些程序员认为:应该将所有的方法都声明为final,除非有足够的理由使用多态性。事实上,在C++和C#中,如果没有特别地说明,所有的方法都不具有多态性。这两种做法可能都有些偏激。我们提倡在设计类层次时,仔细地思考应该将哪些方法和类声明为final的。

在早期的Java中,有些程序员为了避免动态绑定带来的系统开销而使用final关键字。如果一个方法没有被覆盖并且很短,编译器就能够对它进行优化处理,这个过程为称为内联(inlining)。

例如,内联调用e.getName( ) 将被替换为访问e.name域。这是一项很有意义的改进,这是由于CPU在处理调用方法的指令时,使用的分支转移会扰乱预取指令的策略,所以,被视为不受欢迎的。

然而,如果getName在另外一个类中被覆盖,那么编译器就无法知道覆盖的代码将会做什么操作,因此也就不能对它进行内联处理了。

幸运的是,虚拟机中的即时编译器比传统编译器的处理能力强得多。这种编译器可以准确地知道类之间的继承关系,并能够检测出类中是否真正地存在覆盖给定方法。如果方法很简短、被频繁调用且没有真正地被覆盖,那么即时编译器就会将这个方法进行内联处理。如果虚拟机加载了另外一个子类,而在这个子类中包含了对内联方法的覆盖,那么将会发生什么情况呢?

优化器将取消对覆盖方法的内联。这个过程很慢,但却很少发生。

强制类型转换

在第3章中曾经讲过,将一个类型强制转换成另外一个类型的过程被称为类型转换。Java程序设计语言提供了一种专门用于进行类型转换的表示法。例如:

double x = 3.405;

int nx = (int) x;

将表达式x的值转换成整数类型,舍弃了小数部分。

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

Manager boss = (Manager) staff[0];

进行类型转换的唯一原因是:在暂时忽视对象的实际类型之后,使用对象的全部功能。例如,在managerTest类中,由于某些项是普通雇员,所以staff数组必须是Employee对象的数组。我们需要将数组中引用经理的元素复原成Manager类,以便能够访问新增加的所有变量。(需要注意,在前面的示例代码中,为了避免类型转换,我们做了一些特别的处理,即将boss变量存入数组之前,先用Manager对象对它进行初始化。而为了设置经理的奖金,必须使用Manager类型。)

大家知道,在Java中,每个对象变量都属于一个类型。类型描述了这个变量所引用的以及能够引用的对象类型。例如,staff[i]引用一个Employee对象(因此它还可以引用Manager对象)。

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

如果试图在继承链上进行向下的类型转换,并且“谎报”有关对象包含的内容,会发生什么情况呢?

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

运行这个程序时,Java运行时系统将报告这个错误,并产生一个ClassCastException异常。如果没有捕获这个异常,程序就会终止。因此,应该养成这样一个良好的程序设计习惯:在进行类型转换之前,先查看一下是否能够成功地转换。这个过程使用instanceof运算符就可以实现。例如:

最后,如果这个类型转换不可能成功,编译器就不会进行这个转换。例如,下面这个类型转换:

Date c = (Date) staff[1];

将会产生编译错误,这是因为Date不是Employee的子类。综上所述:

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

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

实际上,通过类型转换调整对象的类型并不总是一种好的做法。在我们列举的例子中,大多数情况下并不需要将Employee对象转换成Manager对象,两个类的对象都能够正确地调用getSalary方法,这是因为实现多态性的动态绑定机制能够自动地找到相应的方法。

只有在使用Manager中特有的方法时才需要进行类型转换,例如,setBonus方法。如果鉴于某种原因,发现需要通过Employee对象调用setBonus方法,就应该检查一下超类的设计是否合理。重新设计一下超类,并将setBonus方法移至超类中才是正确的选择。请记住,只要没有捕获ClassCastException异常,程序就会终止执行。在一般情况下,应该尽量少用类型转换和instanceof运算符。

抽象类

如果自下而上仰视类的继承层次结构,那么位于上层的类更具有通用性,甚至可能更加抽象。从某种角度看,祖先类更加通用,人们只将它作为派生其他类的基类,而不作为想使用的特定的实例类。例如,考虑一下对Employee类层次的扩展。一名雇员是一个人,一名学生也是一个人。下面将类Person和类Student添加到类的层次结构中。图5-2是这三个类之间的关系层次图。

为什么要花费精力进行这样高层次的抽象呢?每个人都有一些诸如姓名这样的属性。学生与雇员都有姓名属性,因此可以将getName方法放置在位于继承关系较高层次的通用超类中。

现在,再增加一个getDescription方法,它可以返回对一个人的简短描述。例如:

an employee with a salary of $50,000.00

a student majoring in computer science

在Employee类和Student类中实现这个方法很容易。但是在Person类中应该提供什么内容呢?除了姓名之外,Person类一无所知。当然,可以让Person.getDescription( )返回一个空字符串。然而,还有一个更好的方法,就是使用abstract关键字,这样就完全不需要实现这个方法了。

为了提高程序的清晰度,包含一个或多个抽象方法的类本身必须被声明为抽象的。

除了抽象方法之外,抽象类还可以包含具体数据和具体方法。例如,Person类还保存着姓名和一个返回姓名的具体方法。

抽象方法充当着占位的角色,它们的具体实现在子类中。扩展抽象类可以有两种选择。一种是在子类中定义部分抽象方法或抽象方法也不定义,这样就必须将子类也标记为抽象类;另一种是定义全部的抽象方法,这样一来,子类就不是抽象的了。

例如,通过扩展抽象Person类,并实现getDescription方法来定义Student类。由于在Student类中不再含有抽象方法,所以不必将这个类声明为抽象的。

即使不含抽象方法,也可以将类声明为抽象类。

抽象类不能被实例化。也就是说,如果将一个类声明为abstract,就不能创建这个类的对象。

例如,表达式

new Person("Vince Vu")

是错误的,但可以创建一个具体子类的对象。

需要注意,可以定义一个抽象类的对象变量,但是它只能引用非抽象子类的对象。例如,

Person p = new Student ("Vince Vu", "Economics");

这里的p是一个抽象类person的变量,它引用了一个非抽象子类Student的实例。

下面通过抽象类Person类扩展一个具体子类Student:

在Student类中定义了getDescription方法。因此,在Student类中的全部方法都是非抽象的,这个类不再是抽象类。

在例5-2的程序中定义了抽象超类Person和两个具体子类Employee和Student。下面将雇员和学生对象赋给Person引用的数组。

Person[ ] people = new Person[2];

people[0] = new Employee(. . .);

people[1] = new Student(. . .);

然后,输出这些对象的姓名和信息描述:

for (Person p : people)

System.out.println(p.getName( ) + ", "+ p.getDescription( ));

有些人可能对下面这个调用感到困惑:

p.getDescription( )

这不是调用了一个没有定义的方法吗?请牢记,由于不能构造抽象类Person的对象,所以变量p永远不会引用Person对象,而是引用诸如Employee或Student这样的具体子类对象,而在这些对象中都定义了getDescription方法。

那么,是否可以省略Person超类中的抽象方法,而仅在Employee和Student子类中定义getDescription方法呢?如果这样的话,就不能通过变量p调用getDescription方法了。编译器只允许调用在类中声明的方法。

在Java程序设计语言中,抽象方法是一个重要的概念。在接口(interface)中将会看到更多的抽象方法。有关接口的详细介绍请参阅第6章。

例5-2 PersonTest.java

受保护的访问

大家都知道,最好将类中的域标记为private,将方法标记为public。任何声明为private的特性对于其他类都是不可见的。前面已经看到,这对于子类来说也完全适用,即子类也不能访问超类的私有域。

然而,在有些时候,人们希望超类中的某些方法允许被子类访问,而不允许被其他类访问。

为此,需要将这些类特性声明为protected。例如,如果将超类Employee中的hireDay域声明为protected,而不是私有的,那么Manager中的方法就可以直接地访问它。

不过,Manager类中的方法只能够访问Manager对象中的hireDay域,而不能访问其他Employee对象中的这个域。这种限制有助于避免滥用受保护机制,使得子类只能获得访问受保护域的权利。

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

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

这种方法的一个很好的例子就是Object类中的clone方法,有关它的详细内容请参阅第6章。

下面归纳一下Java用于控制可见性的4个访问修饰符:

1)仅对本类可见—private。

2)对所有类都可见—public。

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

4)对本包可见—默认,所谓默认是指没有标明任何修饰符的情况,这是一种不太受欢迎的形式。

觉得文章不错的话,可以转发关注一下小编~~~~后续持续更新!!!!!

标签: #java语言中允许一个类继承多个类 #c语言浮点型强制转换为整型 #java核心技术2