封装是面向对象编程的三大特征之一。封装就是将通过抽象得到的属性和方法相结合,形成一个有机的整体——“类”。封装的目的是增强数据安全性和简化编程,使用者不必了解具体的实现细节,所有对数据的访问和操作都必须通过特定的方法,否则便无法使用,从而达到数据隐藏的目的。
封装是面向对象编程语言对客观世界的模拟,客观世界的属性都是被隐藏在对象内部的,外界不能直接进行操作或者修改。譬如:常见的空调电视机等对象,这些对象都是封装好的,普通人只可以通过对小小的按钮操作来控制这些家电;不可以随意打开设备进行修改对象内容的配置。但是专业人员可以修改这些家电,而我们就是要做这些“专家”;如下图所示。
图1.1.1 封装对象
1.1.1为什么需要封装
通过第一阶段的学习,我们知道类由属性和方法组成,在类的外部通过本类的实例化对象可以自由访问和设置类中的属性信息,这样不利于属性信息的安全,示例1.1就是如此。
示例1.1
public class Person {
public String name;
public int age;
public void sayHello(){
System.out.print("你好!");
}
}
public class Test {
public static void main(String[] args) {
Person p=new Person();
p.name="皇帝";
p.age=1000;//属性信息可以直接设置
p.sayHello();
}
}
上述代码在第一阶段Java的课程中经常见到,大致一看没什么问题,但是仔细分析过之后会发现:把年龄设置成1000合理吗?
由于Person类的属性都是公有的(public),那也就意味着在Person类的外部,通过Person类的实例化对象可以对这些公有属性任意修改,这就使得我们无法对类的属性进行有效的保护和控制。这属于设计上的缺陷,那能不能避免这种情况呢?这就需要用到下面的封装了。
1.1.2现实生活中的封装
现实生活中封装的例子随处可见,例如药店里出售的胶囊类药品,我们只需要知道这个胶囊有什么疗效,怎么服用就行了,根本不用关心也不可能去操作胶囊的药物成分和生产工艺。再例如家家户户都用的电视机,我们只需要知道电视机能收看电视节目,知道怎么使用就行了,不用关心也不可能去搞清楚电视机内部都有哪些硬件以及是如何组装的。这些都是现实生活中封装的例子。
在刚才的两个例子中,我们可以认为药物成分是胶囊的属性,但是用户不需要也不可能去操作它。我们也可以认为内部硬件是电视机的属性,但是用户也不需要去操作它。这就是现实生活中封装的特征,程序中的封装与此类似。
1.1.3程序中的封装
封装就是:将对象的状态信息隐藏在对象内部,不允许外部程序直接访问对象内部的信息,而是通过该类所提供的方法来实现对内部信息的操作和访问。简而言之,封装就是将类的属性私有化,并提供公有方法访问私有属性的机制,我们看示例1.2。
示例1.2
public class Person{
//将属性使用private修饰,从而隐藏起来
private String name;
private int age;
public void sayHello()
{
System.out.print("你好!");
}
}
public class Test {
public static void main(String[] args) {
Person p=new Person();
p.name="杰克"; //编译报错
p.age=1000; //编译报错
p.sayHello();
}
}
大致一看这段代码跟前面的代码没什么两样,其实只是做了一点改动,Person类中属性的访问修饰符由public改成了private,即属性私有化了。这样一改就造成了main方法中的两行代码出现了编译错误,因为私有属性只能在其所在类的内部使用,在类的外部就无法访问了。
1.1.4如何实现封装
(1)使用private修饰符将类的属性私有化,如示例1.3所示。使用private修饰的属性只能在类的内部调用,访问权限最小。
示例1.3
public class Person{
private String name;
private int age;
}
(2)属性被私有化之后在类的外部就不能访问了,我们需要给每个属性提供共有的
Getter/Setter方法,如示例1.4所示。
示例1.4
public class Person
{
private String name;
private int age;
public String getName() {//获得name属性的getter方法
return name;
}
public void setName(String name) { //设置name属性的setter方法
this.name = name;
}
public int getAge() {//获得age属性的getter方法
return age;
}
public void setAge(int age) {//设置age属性的setter方法
this.age = age;
}
}
(3)在Getter/Setter方法中加入存取控制语句,如示例1.5所示。
示例1.5
public void setAge(int age) {
if(age>0 && age<100)//存取
this.age = age;
else
System.out.println("年龄不规范!");
}
上述代码中多次出现了this关键字,this表示当前类对象本身。this.属性表示调用当前对象的属性,this.方法表示调用当前对象的方法。在上面的Setter方法中,因为方法的参数和类的属性同名,所以需要使用this进行区别。
从以上示例可以看出,封装实现以下目的:
(1)隐藏类的实现细节。
(2)用户只能通过事先提供的共有的方法来访问属性,从而在该方法中加入控制逻辑,以对属性不合理的访问。
(3)可以进行对数据检查,有利于对象信息的完整性。
(4)便于后期的代码修改,有利于提高代码的安全性和可维护性。
1.1.5使用Eclipse生成Getter/Setter方法
Java类里的属性的Getter/Setter方法有非常重要的意义,例如某个类里包含了一个名为name的属性,则其对应的Getter/Setter方法名应为setName和getName(即将原属性名的首字母大写,并在前面分别加上get和set动词,这就变成Getter/Setter方法名)。如果一个Java类的每个属性都被使用private修饰,并为每个属性提供了public修饰Getter/Setter方法,这个类就是一个符合Sun公司制定的JavaBean规范类。因此,JavaBean总是一个封装良好的类。
编写类是程序人员日常工作中经常要做的事情,但是如果每一次都要手工编写Getter/Setter方法无疑会影响开发效率。Eclipse充分考虑到了这一点并提供了快速生成Getter/Setter方法的功能。在类中编写好私有属性后,在空白地方单击右键会弹出如图1.1.2所示的菜单。
图1.1.2 生成Getter/Setter方法
依次单击【Source】|【Generate Getters and Setters】菜单就弹出了如图1.1.3所示的对话框。
图1.1.3 生成Getter/Setter方法
单击【Select All】按钮将所有的属性选中,然后单击【OK】按钮就可以看到Getter/Setter方法已经自动生成了。
1.2构造方法
1.2.1为什么需要构造方法
为什么需要构造方法,先看示例1.6
public class Test {
public static void main(String[] args) {
Person p=new Person();
System.out.print("姓名是:"+p.getName()+"\年龄是:"+p.getAge());
}
}
上述代码是对经过封装的Person类进行测试,代码非常简单。下面我们看一下这段代码的运行结果,如图1.1.4所示。
图1.1.4 运行结果
上述代码的运行结果充分说明:当我们使用new关键字创建对象时,属性都有默认值,例如String类型属性的默认值是null,int类型属性的默认值是0。常见数据类型的默认值如下表所示。
表1-1-1 常见数据类型的默认值
| 类型 | 缺省值 | 类型 | 缺省值 |
| byte | (byte)0 | char | '\ ' |
| short | (short)0 | float | 0.0F |
| int | 0 | double | 0.0D |
| long | 0L | 对象引用 | null |
| boolean | false |
示例1.7
public class Person
{
private String name;
private int age;
public Person() {
this.name="杰克";
this.age=28;
}
//省略Getter/Setter方法
}
我们在示例1.7的Person类中加入了一个特殊的方法Person(),然后再运行上面那个测试类,运行结果就变成了如图1.1.5所示的情况。
图1.1.5 运行效果
上述代码的运行效果说明,那个特殊的Person()方法实现了对属性的初始化操作,而且这个方法无需我们显式调用。Person()就是构造方法。
1.2.2什么是构造方法
构造方法所起的主要作用就是在使用new关键字创建对象时完成对属性的初始化操作。构造方法有三个特征:
没有返回类型。
方法名和类名相同。
无需显式调用。
另外需要注意的是:如果一个类没有显式声明构造方法,那么这个类会有一个默认的构造方法,这个构造方法没有参数,方法体也为空。但是如果显式地声明了构造方法,那么这个默认的无参构造方法就不存在了。
1.2.3有参的构造方法
通过无参构造方法可以很方便的实现对属性的初始化操作,但也存在一定的弊端:就是通过无参构造方法所创建的对象的属性值都是一样的。那能不能在创建对象时可以灵活得给属性赋不同的值呢?这就需要有参的构造方法,我们看一下示例1.8。
示例1.8
public class Person
{
private String name;
private int age;
public Person(String name,int age) { //有参的构造方法
this.name= name;
this.age= age;
}
}
public class Test {
public static void main(String[] args) {
Person p1=new Person("杰克",28); //给参数赋值
Person p2=new Person("玛丽",24); //给参数赋值
}
}
构造方法和普通方法一样是可以带有参数的,通过带参的构造方法就可以灵活的创建对象,每个对象的属性值都不一样。
因为构造方法主要用于被其他方法来调用创建对象,并返回该类的实例,所以通常把构造方法设置成public访问权限,从而允许系统中任何位置的类都可以创建该类的对象。除非在一些极端的情况下,我们需要创建该类的对象,可以把构造方法设置成其他访问权限:例如设置为protected,主要用于被其子类调用(下一小节讲解),把其设置为private阻止其他类创建该类的实例。
1.3方法重载
在第一阶段的学习中我们已经在使用方法重载了,如示例1.9所示。
示例1.9
public class Test {
public static void main(String[] args) {
System.out.println(100); //int类型
System.out.println(true); //boolean类型
System.out.println("我爱Java"); //String类型
}
}
同样一个println方法却可以接收不同类型的参数,这是因为在一个类中存在多个println方法,但是这些println方法的参数是不同的,这其实就是方法重载。
当我们写的方法满足以下条件时就构成了方法重载:
在同一个类中。
方法名必须相同。
方法参数不同,包括参数个数不同或参数类型不同或参数顺序不同。
与方法的返回类型和访问修饰符没有任何关系。
有了方法重载,程序开发人员就不用显式的根据参数去判断该调用哪个方法了,而是由Java系统在运行时自动进行判断和调用。下面以主人训练宠物的案例来说明方法重载的好处;请看示例1.10。
示例1.10
public class Dog {//Dog类
private String color;//描述狗的颜色
private String name; //描述狗的昵称
private float price; //描述狗的价格
public Dog(String color, String name) {
this.color = color;
this.name = name;
}
...省略Getter/Setter方法
public class Monkey {//Monkey类
private String color;//描述猴子的颜色
private String name; //描述猴子的昵称
private float price; //描述猴子的价格
public Monkey(String color, String name) {
this.color = color;
this.name = name;
}
...省略Getter/Setter方法
public class Master {//主人类
private String name;
public void train(Dog dog)//训练Dog
{
System.out.println(dog.getColor() + "的" + dog.getName()
+ "向主人摇尾巴。");
System.out.println(dog.getColor() + "的" + dog.getName()
+ "接飞盘。");
}
public void train(Monkey monkey)//训练Monkey
{
System.out.println(monkey.getColor() + "的" + monkey.getName()
+ "向游人伸手要食物。");
System.out.println(monkey.getColor() + "的" + monkey.getName()
+ "在爬小树。");
}
...省略Getter/Setter方法
public class Test {//测试类
public static void main(String[] args) {
//创建Dog对象
Dog dog = new Dog("黑色小黑");
//创建Monkey对象
Monkey monkey = new Monkey("黄色阿黄");
//创建主人对象
Master master = new Master();
//调用训练Dog的方法
master.train(dog);
//调用训练Monkey的方法
master.train(monkey);
}
}
执行效果如图1.1.6所示。
图1.1.6 运行效果
从上例执行的效果可以看出,在调用重载的方法时;只需把参数dog或者monkey传给train方法,Java系统在运行时会根据方法的类型执行对应的方法。
构造方法和普通方法一样也可以构成方法重载,示例1.11就是如此。
示例1.11
public class Person
{
private String name;
private int age;
public Person() { //无参构造方法
}
public Person(String name,int age) { //有参构造方法
this.name= name;
this.age= age;
}
}
上述代码中有一个无参数的构造方法和有参的构造方法,完全符合方法重载的特征,所以这两个构造方法形成了方法重载。构造方法重载的好处是完成多种初始化行为,因为有些时候在创建对象的时候,如果仅仅知道姓名属性的值可以调用Person(String name)构造方法;如果两个属性的值都知道则可以调用Person(String name,int age)构造方法。构造方法重载提供了创建对象的灵活性;另外要注意的是通常建议保留类的无参数构造方法。
1.4继承
1.4.1为什么需要继承
我们现在封装了两个类:Student和Teacher,示例1.12给出了这两个类的参考代码。
示例1.12
public class Teacher{ //教员类
private String name;
private int age;
public Teacher() {
this.name="张老师";
this.age=28;
}
//此处省略getter/setter方法
public void sayHello(){
System.out.print("你好!");
}
}
public class Student{ //学生类
private String name;
private int age;
public Student() {
this.name="张无忌";
this.age=24;
}
//此处省略getter/setter方法
public void sayHello(){
System.out.print("你好!");
}
public void study(){
System.out.print("学习!");
}
}
上述代码存在的主要问题是:代码重复,还有上面的Dog类和Monkey类。不管是教员类还是学生类,或者是工人类都需要姓名、年龄等属性,这就会出现很多重复代码。如何有效的解决这个问题呢?
我们可以把Teacher类和Student类中相同的属性和方法提取出来放到另外一个单独的Person类中,然后让Teacher类和Student类继承Person类,同时保留自己独有的属性和方法,这就是Java中的继承,如示例1.13所示。
示例1.13
public class Person {//人类
private String name;
private int age;
//此处省略getter/setter方法
public void sayHello() {
System.out.print("你好!");
}
}
publicclass Teacher extends Person{//教员类
}
publicclass Student extends Person { //学生类
publicvoid study() {//学生类独有的方法
System.out.print("学习!");
}
}
在上述代码中,Teacher类没有定义任何属性和方法,Student类只定义了一个study方法,这样一来重复的代码的确没有了,但是会不会影响使用呢?我们看示例1.14的测试代码。
示例1.14
public class Test {
public static void main(String[] args) {
Teacher t=new Teacher(); //创建一个教员
t.setName("张三丰");
t.setAge(28);
t.sayHello();
Student s=new Student(); //创建一个学生
t.setName("张无忌");
t.setAge(24);
t.sayHello();
}
}
上述代码经过测试是可以正常运行的,这充分说明继承不但解决了代码重复的问题,也保证了我们自定义的类是可以正常使用的。
1.4.2什么是继承
图1.1.7 现实中的继承
图1.1.7给出了一个现实生活中继承的例子,通过这个例子我们可以总结出继承具有如下特征。
为什么说兔子和棉羊是食草动物,而不把兔子和狮子放在一起?因为兔子和棉羊具
有相同的特征和行为。这些相同的特征和行为可以抽象出一个父类――食草动物。
继承具有树形结构,越顶层的越模糊、越通用,越底层的越具体、越个性。
子类只能直接继承一个父类,一个父类可以有多个子类。就像一个父亲可以有多个
儿子,但是一个儿子只能有一个父亲一样。Java只支持单重继承。
树形结构的顺序:兔子是食草动物是正确的,但食草动物是兔子是不正确的。
这就是is-a关系。
这就是继承的特征,Java中的继承与之相同。那么Java中的子类究竟可以从父类中继承哪些“财产”呢?
子类可以继承父类中使用public和protected修饰的属性和方法,不论子类和父类是否在同一包中。
子类可以继承父类中使用默认修饰符修饰的属性和方法,但子类和父类必须在同一个包中。
子类无法继承父类中使用private修饰的属性和方法。
子类无法继承父类的构造方法。
1.4.3如何实现继承
在Java中实现继承很简单,使用extends关键字即可,语法如下所示。
publicclassSubClassextends SuperClass{ //继承
| } |
1.4.4类的祖先Object
在Java中,Object类是所有类的祖先,它处于继承关系的最顶层。在定义一个类时,即使没有使用关键字extends继承Object类,系统也会默认去继承Object类。其实我们在以前使用类时没有注意这一点,如图1.1.8所示。
图1.1.8Object类
在Teacher类中我们只定义了getter/setter方法和一个sayHello方法,但是在使用时却发现多出来很多其他的方法,例如:toString()、wait()等,这些方法都是从Object类中自动继承下来的。
1.5继承中的构造方法
在上一章我们学习了一个特殊的方法——构造方法,它的主要作用是在创建对象时完成初始化操作。构造方法在继承关系中的调用比较特殊,我们看一下示例1.15。
示例1.15
public class Person {
private String name;
private int age;
public Person() //构造方法
{
System.out.println("创建一个Person对象");
}
//此处省略getter和setter方法
}
public class Student extends Person { //学生类继承Person类
public void study() { //学生类独有的方法
System.out.print("学习!");
}
}
public class Test {
public static void main(String[] args) {
Student s=new Student(); //创建一个学生
s.setName("张无忌");
s.setAge(28);
s.study();
}
}
在上述代码中,Student类继承了Person类。在main方法我们中对Student类进行了测试,运行结果如图1.1.9所示。
图1.1.9 运行结果
这样的运行结果是不是有点出乎意料呢?下面给出继承关系中构造方法的调用规则。
如果子类没有显式调用父类的有参构造方法,则在创建子类对象时会默认先调用
父类无参的构造方法。
如果子类显式调用了父类的有参构造方法,则在创建子类对象时会先调用父类相
应的有参构造方法,而不会调用无参的构造方法。
在继承中我们可以使用super调用父类的构造方法,super代表对父类对象的默认引用,并且super语句必须出现在子类构造方法中的第一行,我们看一下示例1.16。
示例1.16
public class Person {
private String name;
private int age;
public Person(){
System.out.println("通过无参构造方法创建一个Person对象");
}
public Person(String name,int age){
this.name=name;
this.age=age;
System.out.println("通过有参构造方法创建一个Person对象");
}
}
public class Student extends Person { //Student类继承Person类
public Student(String name,int age)
{
super(name,age); //使用super显式调用父类构造方法
System.out.println("创建一个Studnet对象");
}
}
public class Test {
public static void main(String[] args) {
Student s=new Student("张无忌",24); //创建一个学生
}
}
在Student类的构造方法中,我们使用super语句显式的调用了父类的构造方法,运行结果如图1.1.10所示。要注意的是在子类的构造方法中调用父类的构造方法super语句要写在第一行,因为要先构造一个父类然后才能构造子类对象;就像先有父亲在有儿子一样。在普通方法里没有此,super语句可以出现任何位置。
图1.1.10 运行结果
1.6方法重写
通过前面的学习我们知道子类可以继承父类中的公有方法,但是如果这个方法不能满足子类的需求该怎么办呢?这时我们可以通过在子类中重写父类的方法来解决这个问题。我们看下面示例1.17的代码。
示例1.17
public class Person {
private String name;
private int age;
//此处省略getter/setter方法
public void sayHello() {
System.out.print("你好!");
}
}
public class Teacher extends Person{ //教员类继承Person类
public void sayHello() {
System.out.print("教员说:你好!");
}
}
public class Student extends Person { //学生类继承Person类
public void sayHello() {
System.out.print("学生说:你好!");
}
}
父类Person中定义了一个sayHello()方法,它的两个子类并没有简单的继承这个方法,而是在自身又定义了一个sayHello()方法。那这样会不会编译错误呢?我们看下面示例1.18的测试代码。
示例1.18
public class Test {
public static void main(String[] args) {
Teacher t=new Teacher(); //创建一个教员
t.sayHello();
Student s=new Student(); //创建一个学生
s.sayHello();
}
}
上述代码的运行结果如图1.1.11所示。
图1.1.11 运行结果
图1.1.11的运行结果充分说明了上面的代码能够正常运行。在子类中根据需要对从父类中继承下来的方法进行重写编写,称之为方法重写或方法覆盖(overriding)。方法重写必须满足以下条件:
子类的方法必须和父类中被重写的方法的名称相同。
子类的方法必须和父类中被重写的方法的参数相同,包括参数的个数、数据类型以及顺序。
子类方法的返回类型必须和父类中被重写的方法的返回类型相同或是其子类。
子类方法的访问修饰符权限不能小于父类中被重写的方法的访问修饰符权限。
第2章多态和接口
学习内容
多态
接口
抽象类和抽象方法
能力目标
理解并会使用多态
掌握抽象类和抽象方法的使用
理解并会使用面向接口编程
本章简介
上一章我们学习了面向对象三大特征中的封装和继承,本章将继续学习面向对象另外两个重点内容:多态和接口。
多态是面向对象编程的核心特征。在实际应用中,通过多态可以提高程序的可扩展性和可维护性。
Java不支持多重继承,即一个类同时继承多个类。为了解决这个问题,Java引入了接口。接口表示一种约定或标准。使用接口可以把“做什么”和“怎么做”分离开,使程序具备较好的可扩展性和可维护性。
本章将系统的讲解多态和接口的实现方式以及抽象类、抽象方法的基本应用。
核心技能部分
2.1多态
2.1.1为什么需要多态
多态是OOP中最核心的一个特征。下面我们先通过一个现实生活中的例子来认识一下多态,这个例子模拟的是主人喂养宠物。代码如示例2.1所示。
示例2.1
public class Pet { // 宠物类
private String name = "无名氏"; // 宠物昵称
private int health = 100; // 宠物健康值
public void eat(){
}
}
public class Dog extends Pet { //狗类继承宠物类
public void eat() { //重写eat方法
System.out.println("狗狗在吃饭...");
}
}
public class Cat extends Pet { //小猫类继承宠物类
public void eat() { //重写eat方法
System.out.println("小猫在吃饭...");
}
}
public class Master { //主人类
private String name = ""; // 主人名字
public void feed(Dog dog) { //主人给狗喂食
dog.eat();
}
public void feed(Cat pgn) { //主人给猫喂食
pgn.eat();
}
}
在上述代码中,Pet类是父类,Dog类和Cat类是子类,并且重写了父类的eat方法。Master类分别为Dog和Cat定义了喂食的方法,这两个方法构成了方法重载。这样一来就实现了主人喂养宠物的功能。但是,假如主人以后会喂养更多的不同种类的宠物时该怎么办呢?比如说现在主人要喂养鹦鹉,我们除了需要先定义一个鹦鹉类之外,还必须在Master类中重载一个给鹦鹉喂食的方法。宠物越多,重载的方法(feed)就越多。那有没有更好的解决办法呢?这就需要使用下面的多态。
2.1.2什么是多态
现实生活中存在很多多态的例子。例如:H2O在100摄氏度的高温下是气体,在常温下是液体,在0摄氏度以下是固体。这里的多态是指一个对象具有多种形态,OOP中的多态与之类似。换句话说多态就是:同一个引用类型,使用不同的实例可以执行不同的操作,即父类引用子类对象。我们看一下示例2.2。
示例2.2
Pet p1=new Dog();
p1.eat();
Pet p2=new Cat();
p2.eat();
在上述代码中,我们将子类对象赋值给了一个父类变量,这就是所谓的父类引用子类对象,或者说一个父类的引用指向了一个子类对象。代码在执行时调用的都是子类中重写过的eat方法,而不是父类中的eat方法,这就是多态。
2.1.3如何实现多态
我们明白了什么是多态之后,下面就通过多态来优化前面主人喂养宠物的代码,并介绍实现多态的步骤。
(1)实现继承,继承通常是多态存在的前提,如示例2.3所示。
示例2.3
public class Pet { // 宠物类
private String name = "无名氏"; // 宠物昵称
private int health = 100; // 宠物健康值
public void eat(){
}
}
public class Dog extends Pet { //狗类继承宠物类
}
public class Cat extends Pet { //小猫类继承宠物类
}
(2)子类重写父类的方法,如示例2.4所示。
示例2.4
public class Dog extends Pet {
public void eat() { //重写eat方法
System.out.println("狗狗在吃饭...");
}
}
public class Cat extends Pet {
public void eat() { //重写eat方法
System.out.println("小猫在吃饭...");
}
}
(3)父类引用子类对象,如示例2.5所示。
示例2.5
public class Master { //主人类
private String name = ""; // 主人名字
public void feed(Pet p) { //通过传参实现父类引用子类对象
p.eat();
}
}
(4)示例2.6是测试代码。
示例2.6
public static void main(String[] a)
{
Dog d = new Dog();
Cat c = new Cat();
Master m = new Master();
m.feed(d);
m.feed(c);
}
通过以上几步就实现了使用多态来解决主人喂养宠物的问题。这时不管主人将来喂养多少种宠物,我们只需要定义相应的类继承Pet并重写eat方法就行了,而Master类中始终只需要一个feed方法。使用多态可以提高代码的可扩展性和可维护性,使用父类作为方法形参是实现多态的常用方式。这里要注意的是在进行方法调用时必须调用子类重写过的父类的方法,子类中独有的方法是不能通过父类引用调用到的。
2.1.4向上转型和向下转型
示例2.7
Pet p1=new Dog();
p1.eat();
Pet p2=new Cat();
p2.eat();
上述代码是多态的一个例子,我们称之为父类引用子类对象。实际上父类和子类之间存在一种类型转换问题,称之为“向上转型”。“向上转型”也就是把子类转换成父类,这符合继承中的is-a关系(狗是宠物),所以这时进行了自动类型转换。
在使用“向上转型”时我们无法调用子类独有的方法。例如:Dog类有一个独有的方法catchFly(接飞盘),如果像示例2.8这样子写就错了。
示例2.8
Pet p1=new Dog(); //向上转型
p1.eat(); //调用子类中的eat方法
p1.catchFly(); //不能调用子类独有的方法,编译错误
与“向上转型”相对的就是“向下转型”,即将一个指向子类对象的父类引用赋值给一个子类引用,称之为“向下转型”,也就是把父类转换成子类,此时必须进行强制类型转换。我们看示例2.9的代码。
示例2.9
Pet p1=new Dog(); //向上转型
p1.eat(); //调用子类中的eat方法
Dog d=(Dog)p1; //向下转型
d.catchFly(); //可以调用子类独有的方法
p1经过向下转型被强制转换成了Dog类,这时就可以访问子类独有的方法了。但是在进行向下转型时需要注意一点:p1只能被强转成Dog类,因为p1指向的是Dog对象。例如示例2.10的代码就不正确。
示例2.10
Pet p1=new Dog(); //向上转型,p1指向一个Dog对象
Cat d=(Cat)p1; //编译错误
2.2抽象类和抽象方法
2.2.1抽象类
示例2.11
Pet p=new Pet();
p.eat();
示例2.11的代码有问题吗?从语法上来说没有任何问题,也可以正确运行。但是我们仔细想一下,现实生活中有小狗、小猫等具体存在的对象,但是有一种叫宠物的具体对象吗?宠物是我们抽象出来的一个概念,因此上述代码虽然没有问题,但是不符合逻辑。这时我们可以使用abstract关键字来修饰Pet类,即把Pet类改成抽象类,因为在Java中抽象类不能被实例化。
示例2.12
public abstractclass Pet { // 宠物类
private String name = "无名氏"; // 宠物昵称
private int health = 100; // 宠物健康值
public void eat(){
}
}
在示例2.12中,我们只需要在class前面加上abstract关键字就把Pet类改成了抽象类,这时如果运行下面示例2.13的代码就会出错。
示例2.13
public class Test {
public static void main(String[] a){
Pet p=new Pet(); //抽象类不能被实例化
p.eat();
}
}
2.2.2抽象方法
在继承关系中,子类可以直接继承父类中的公有方法,也可以重写父类中的公有方法。如果在某种情况下必须强制子类重写父类的方法该怎么办呢?这时可以通过Java中的抽象方法来解决,即用abstract关键字来修饰父类方法。我们看下面示例2.14的代码。
示例2.14
public abstract class Pet { // 宠物类
private String name = "无名氏";
private int health = 100;
public abstract void eat(); //抽象方法
}
在返回类型前加上abstract关键字就把eat()方法改成了抽象方法,这时在子类中就必须重写eat()方法,否则报错。一定要注意的是:抽象方法只有方法声明,没有方法体。
下面深入说明抽象类为什么不能被实例化,这可以从语义和语法两个方面来解释。
(1)在语义上,抽象类表示从一些具体类中抽象出来的类型。从具体类到抽象类,这是一种更高层次的抽象。例如苹果类、香蕉类和桔子类是具体类,而水果类则是抽象类,在自然界并不存在水果类本身的实例,而只存在它的具体子类的实例。
Fruit fruit = new Apple();//创建一个苹果对象,把它看做水果对象
在继承关系树上,总可以把子类的对象看做父类的对象,例如苹果对象是水果对象,香蕉对象也是水果对象。当父类是具体类时,父类的对象包括父类本身的对象及所有具体子类的对象;当父类是抽象类时,父类的对象包括所有具体子类的对象。因此,所谓的抽象类不能被实例化,是指不能创建抽象类本身的实例。尽管如此,可以创建一个苹果对象,并把它看做水果对象。
(2)从语法上,抽象类中可以包含抽象方法。例如Pet类的eat()方法,假如允许创建抽象类本身的实例:
Pet pet = new Pet(); //假定编译器未报错
pet.eat(); //运行时Java虚拟机无法执行这个方法
那么,在运行以上程序时,Java虚拟机无法执行eat()方法,因为eat()方法时抽象方法,根本没有方法体。由此可见,Java编译器不允许创建抽象类的实例是必要的。
使用abstract修饰符需要遵守一下语法规则。
(1)抽象类中可以没有抽象方法,但包含了抽象方法的类必须被定义为抽象类。如果子类没有实现父类中所有的抽象方法,那么子类也必须被定义为抽象类,否则编译出错。例如,在以下代码中Dog类继承了Pet类,但Dog类仅实现了Pet类中的eat抽象方法,而没有实现run抽象方法,因此Dog类必须声明为抽象类,否则编译出错。
public abstract class Pet{
public abstract void eat();
public abstract void run();
}
public class Dog extends Pet{//编译出错,Dog类必须声明为抽象类
public void eat()
{
System.out.println(“小狗在吃骨头”);
}
}
(2)没有抽象构造方法,也没有抽象静态方法。如下代码编译出错:
public abstract class Pet{
public abstract Pet(){} //编译出错,构造方法不能使抽象的
public static abstract void eat();//出错,static和abstract修饰符不能连用
public abstract void run();
}
(3)抽象类中可以有非抽象的构造方法,创建子类的实例时可能调用这些构造方法。抽象类不能被实例化。然而可以创建一个引用变量,其类型是一个抽象类,并让它引用非抽象的子类的一个实例。例如:
public abstract class Pet{
public Pet(){}
public abstract void eat();
}
public class Dog extends Pet{
public void eat()
{
System.out.println(“小狗在吃骨头”);
}
public static void main(String [] args)
{
Pet pet = new Pet();//编译出错,不能创建抽象类Pet的实例
Pet dog = new Dog();//合法,可以创建具体类Sub的实例
}
}
2.3接口
2.3.1生活中的接口
图2.1.1 主板PCI插槽
图2.1.1给出了一个我们比较熟悉的现实生活中的例子,电脑主板的PCI插槽上通常会插入一些硬件设备(声卡、网卡、显卡),这些设备相对于主板是的,所以通常称之为显卡、声卡和网卡。但是也有另外一种设计,就是把声卡、网卡、显卡这些设备集成到主板上,这些设备和主板是一体的,我们称之为集成显卡、集成声卡和集成网卡。
现在从可维护性和可扩展性这两方面来考虑一下这两种设计哪个更好一些呢?以显卡为例,如果我们使用的是集成显卡,将来显卡坏了,我们面临的选择可能就要更换主板了。但是如果我们使用的是显卡,一旦坏了我们只需要更换显卡就行了。
在主板和硬件设备之间需要约定一个标准(PCI),包括PCI的大小、技术指标等,然后生产遵循这个标准的硬件设备。因为主板和这些硬件设备都遵循PCI标准,所以这些硬件设备直接就可以插在主板上使用,并且兼顾了可维护性和可扩展性。Java中的接口与此类似。
2.3.2Java中的接口
在Java中,接口就是一个约定、规范或标准。现在我们用接口来实现图2.1.1中的生活案例,以此来认识一下Java中的接口。
publicinterface接口名称{
| } |
示例2.15
publicinterface PCI { //PCI接口
publicvoid work(); //没有方法体
}
使用interface关键字可以创建一个接口,接口中的方法没有方法体。
(2)下面创建遵循PCI接口的显卡类和声卡类,代码如示例2.16所示。
示例2.16
public class DisCard implements PCI{ //实现接口
public void work() {//实现接口中的word方法
System.out.println("显卡正在工作...");
}
}
public class SoundCard implements PCI{ //实现接口
public void work() {//实现接口中的word方法
System.out.println("声卡正在工作...");
}
}
要想让显卡和声卡遵循PCI规范,就必须使用implements关键字来实现PCI接口,同时在类中必须实现接口中的work()方法。
publicclass类名称implements接口1,接口2…{
| } |
(3)下面创建主板类来使用显卡和声卡,代码如示例2.17所示。
示例2.17
publicclass MainBoard {
publicvoid useCard(PCI p)//参数类型是PCI接口
{
p.work();
}
}
主板间接的通过PCI接口来使用显卡和声卡,而不是像之前那样直接使用。通过这样的设计就使我们的程序具备了良好的可维护性和可扩展性。示例2.18是测试类。
示例2.18
publicstaticvoid main(String[] a){
PCI p1=new DisCard();
PCI p2=new SoundCard();
MainBoard mb=new MainBoard();
mb.useCard(p1);
mb.useCard(p2);
}
}
2.3.3接口的特征
接口作为一种约定、规范或标准具有以下显著特征:
接口不可以被实例化,不能有构造方法。
接口中的所有成员都是public static final。
接口中的方法都是抽象方法,接口中的方法会自动使用public abstract修饰。
一个类可以同时实现多个接口。
实现类必须实现接口中的所有方法。
接口可以继承接口。
2.3.4深入理解接口的必要性
有了抽象类之后为什么还要使用接口呢?下面还以生活中的案例来深入理解接口的必要性。
假如轿车、卡车、拖拉机、摩托车、客车都是机动车的子类,其中机动车是一个抽象类(如图2.1.2所示)。如果机动车中有一个抽象方法“收取费用”,那么所有的子类都要实现这个方法,即给出方法体,产生各自的收费行为。这显然不符合人们的思维方法,因为拖拉机可能不需要有“收取费用”的功能,而其他的一些类,比如飞机,轮船等可能也需要具体实现“收取费用”的功能;但是飞机和轮船显然不能作为机动车的子类(如图2.1.3所示)。那么怎么实现呢?这些类没有了继承关系。这就接口产生的背景,接口可以增加很多类都需要实现的功能,不同的类可以使用相同的接口,同一个类也可以实现多个接口。
接口只关心功能,并不关心功能的具体实现,比如“客车类”实现一个接口,该接口中有一个“收取费用”的方法,那么这个“客车类”必须具体给出怎样收取费用的操作,即给出方法的方法体,不同的车类都可以实现“收取费用”,包括“轮船类”。但“收取费用”的手段可能不相同。接口的思想在于它可以增加很多类都需要实现的功能,使用相同的接口类不一定有继承关系,就像各式各样的商品,它们可能隶属于不同的公司,工商部门要求都必须具有显示标签的功能“实现同一个接口”;但商标的具作由各个公司自己去实现。
再比如,你是一个项目主管,你需要管理许多部门,这些部门要开发一些软件所需要的类。你可能要求某个类实现一个接口,也就是说你对一些类是否具有这个功能非常关系,但不关心功能的具体实现。比如,这个功能是speakLove,但你不关心是用汉语实现功能speakLove或用英语实现speakLove。在某些时候,你也许打个电话就可以了,告诉远方的一个开发部门实现你所规定的接口,并建议他们用汉语来实现speakLove。如果没有这个接口,你可能要花很多的口舌来让你的部门找到那个表达爱的方法,也许他们给表达爱的那个方法的名字完全不同的名字。
图2.1.2 继承关系图
图2.1.3 继承关系图
具体代码请看示例2.19
示例2.19
package com.javaoop.chapter2;
//定义Charge(收费)接口
interface Charge{
void getCharge();
}
//定义Car类实现Charge接口
class Car implements Charge{
public void getCharge() {
System.out.println("北京到上海收取费用380元。");
}
}
//定义Bus类实现Charge接口
class Bus implements Charge{
public void getCharge() {
System.out.println("公交车收费1元/人。");
}
}
//定义Plane类实现Charge接口
class Plane implements Charge{
public void getCharge() {
System.out.println("北京到首尔收费1988元/人");
}
}
public class InterfaceTest {
public static void main(String[] args) {
Charge car = new Car();//创建小汽车类对象
Charge bus = new Bus();//创建公共汽车类对象
Charge plane = new Plane();//创建飞机对象
car.getCharge();//调用收取费用方法
bus.getCharge();
plane.getCharge();
}
}
程序最终的运行结果如图2.1.4所示。
图2.1.4 运行结果
从执行效果可以看出,接口的优势在于不同类之间定义相同的行为;这些类之间不必有继承关系。接口的更大优势在于系统的设计,也就是面向接口编程。
2.4面向接口编程
在传统的项目开发过程中,由于客户的需求经常变化,如果不采用面向接口编程,那么我们必须不停改写现有的业务代码。改写代码可能产生新的BUG,而且改写代码还会影响到调用该业务的类,可能全都需要修改,影响系统本身的稳定性。而且为了将改写代码带来的影响最小,我们不得不屈服当前的系统状况来完成设计,代码质量和稳定性更低。当这种情况积累到一定程度时,系统就会出现不可预计的错误,代码凌乱,不易读懂,后接手的人无法读懂代码,系统的维护工作越来越重,最终可能导致项目失败。
而接口可以使我们的软件具备良好的可维护性和可扩展性,所以在开发大型的软件项目时会经常使用。面向接口编程就是指:软件系统的整体架构由接口构成,而不是具体的类。我们可以先设计好这些接口,然后再编写具体的实现类来实现相应的功能。
下面通过一个案例来介绍如何进行面向接口编程。案例描述如下:市场上常见的墨盒有彩色和黑白两种,常见的纸张有A4和B5两种。墨盒和纸张都不是打印机厂家生产的,但是打印机厂家所生产的打印机必须兼容市场上的各种墨盒和纸张。
实现这个案例的有效途径就是约定墨盒、纸张的规范或标准,然后打印机厂家按照这些规范或标准生产打印机。这样生产出来的打印机就能兼容市场上各种类型的墨盒和纸张。
(1)首先创建接口,代码如示例2.20所示。
示例2.20
public interface Paper { //纸张接口
public String getSize();
}
public interface InkBox { //墨盒接口
public String getColor();
}
(2)编写Paper接口的实现类,代码如示例2.21所示。
示例2.21
public class A4Paper implements Paper { //A4纸实现Paper接口
public String getSize() {
return "A4";
}
}
public class B5Paper implements Paper { //B5纸实现Paper接口
public String getSize() {
return "B5";
}
}
(3)编写InkBox接口的实现类,代码如示例2.22所示。
示例2.22
publicclass GrayInkBox implements InkBox { //黑白墨盒实现InkBox接口
public String getColor() {
return "黑白";
}
}
publicclass ColorInkBox implements InkBox { //彩色墨盒实现InkBok接口
public String getColor() {
return "彩色";
}
}
(4)编写打印机类实现打印功能,代码如示例2.23所示。
示例2.23
publicclass Printer { //打印机类
publicvoid print(InkBox inkBox,Paper paper){//参数类型都是接口
System.out.println("使用"+inkBox.getColor()+
"墨盒在"+paper.getSize()+"纸张上打印。");
}
}
(5)编写测试类进行测试,代码如示例2.24所示。
示例2.24
publicclass TestPrinter {
publicstaticvoid main(String[] args) {
Printer printer=new Printer(); //创建打印机
InkBox inkBox=new GrayInkBox(); //创建黑白墨盒
Paper paper=new A4Paper(); //创建A4纸
printer.print(inkBox, paper); //打印
inkBox=new ColorInkBox(); //创建彩色墨盒
paper=new B5Paper(); //创建B5纸
printer.print(inkBox, paper); //打印
}
}
程序最终的运行结果如图2.1.5所示。
图2.1.5 运行结果
从执行的效果可以看出面向接口编程的优点:
(1)接口和实现分离了,适于团队的协作开发。
(2)增强了程序的可扩展性可维护性。
缺点:
设计难了,在你没有写实现的时候,就得想好接口,接口一变,全部实现了该接口的类都要变化,这就是所谓的设计比实现难。
在软件公司设计接口的人工资都较高啊!做接口设计的人员一般称为系统架构师。
2.5比较抽象类和接口
抽象类和接口都位于继承树的上层,它们具有以下相同点。
1.代表系统的抽象层。
2.都不能被实例化。
3.都能包含抽象方法。
抽象类和接口主要有两大区别。
1.在抽象类中可以为部分方法提供默认的实现,从而避免在子类中重复实现它们,提高代码的可重用性,这是抽象类的优势所在;而接口中只能包含抽象方法。
2.一个类只能继承一个直接的父类,这个父类有可能是抽象类;但一个类可以实现多个接口,这是接口的优势所在。下载本文