Java 学习笔记(7)——接口与多态

上一篇说了Java面向对象中的继承关系,在继承中说到:调用对象中的成员变量时,根据引用类型来决定调用谁,而调用成员方法时由于多态的存在,具体调用谁的方法需要根据new出来的对象决定,这篇主要描述的是Java中的多态以及利用多态形成的接口

多态

当时在学习C++时,要使用多态需要定义函数为virtual,也就是虚函数。类中存在虚函数时,对象会有一个虚函数表的头指针,虚函数表会存储虚函数的地址,在使用父类的指针或者引用来调用方法时会根据虚函数表中的函数地址来调用函数,会形成多态。

当时学习C++时对多态有一个非常精炼的定义:基类的指针指向不同的派生类,其行为不同。这里行为不同指的是调用同一个虚函数时,会调用不同的派生类函数。这里我们说形成多态的几个基本条件:1)指针或者引用类型是基类;2)需要指向派生类;3)调用的函数必须是基类重写的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Parent{
public void sayHelllo(){
System.out.println("Hello Parent");
}

public void sayHello(String name){
System.out.println("Hello" + name);
}
}

public class Child extends Parent{
public void sayHello(){
System.out.println("Hello Child");
}
}

根据上述的继承关系,我们来看下面几个实例代码,分析一下哪些是多态

1
2
Parent obj = new  Child();
obj.sayHello();

该实例构成了多态,它满足了多态的三个条件:Parent 类型的 obj 引用指向了 new出来的Child子类、并且调用了二者共有的方法。

1
2
Parent obj = new  Child();
obj.sayHello("Tom");

这个例子没有构成多态,虽然它满足基类的引用指向派生类,但是它调用了父类特有的方法。

1
2
Parent obj = new  Parent();
obj.sayHello();

这个例子也不满足多态,它使用父类的引用指向了父类,这里就是一个正常的类方法调用,它会调用父类的方法

1
2
Child obj = new Child();
obj.sayHello();

这个例子也不满足多态,它使用子类的引用指向了子类,这里就是一个正常的类方法调用,它会调用子类的方法

那么多态有什么好处呢?引入多态实质上也是为了避免重复的代码,而且程序更具有扩展性,我们通过println函数来说明这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
public void println(Object x) {
String s = String.valueOf(x);
synchronized (this) {
print(s);
newLine();
}
}

//Class String
public static String valueOf(Object obj) {
return (obj == null) ? "null" : obj.toString();
}

函数println实现了一个传入Object的重载,该函数调用了String类的静态方法 valueOf, 进一步跟到String类中发现,该方法只是调用了类的 toString 方法,传入的obj可以是任意继承Object的类(在Java中只要是对象就一定是继承自Object),只要类重写了 toString 方法就可以直接打印。这样一个函数就实现了重用,相比于需要后来的人额外重载println函数来说,要方便很多。

类类型转化

上面的println 函数,它需要传入的是Object类的引用,但是在调用该方法时,从来都没有进行过类型转化,都是直接传的,这里是需要进行类型转化的,在由子类转到父类的时候,Java进行了隐式类型转化。大转小一定是安全的(这里的大转小是对象的内存包含关系),子类一定可以包含父类的成员,所以即使转化为父类也不存在问题。而父类引用指向的内存不一定就是包含了子类成员,所以小转大不安全。

为什么要进行小转大呢?虽然多态给了我们很大的方便,但是多态最大的问题就是父类引用无法看到子类的成员,也就是无法使用子类中的成员。这个时候如果要使用子类的成员就必须进行小转大的操作。之前说过小转大不安全,由于父类可能有多个实现类,我们无法确定传进来的参数就是我们需要的子类的对象,所以java引入了一个关键字 instanceof 来判断是否可以进行安全的转化,只要传进来的对象引用是目标类的对象或者父类对象它就会返回true,比如下面的例子

1
2
3
4
5
Object obj = "hello"
System.out.println(obj instanceof String); //true
System.out.println(obj instanceof Object); //true
System.out.println(obj instanceof StringBuffer); //false
System.out.println(obj instanceof CharSequence); //true

抽象方法和抽象类

我们说有了多态可以使代码重用性更高。但是某些时候我们针对几个有共性的类,抽象出了更高层面的基类,但是发现基类虽然有一些共性的内容,但是有些共有的方法不知道如何实现,比如说教科书上经常举例的动物类,由于不知道具体的动物是什么,所以也无法判断该动物是食草还是食肉。所以一般将动物的 eat 定义为抽象方法,拥有抽象方法的类一定必须是抽象基类。

抽象方法是不需要写实现的方法,它只需提供一个函数的原型。而抽象类不能创建实例,必须有派生类重写抽象方法。为什么抽象类不能创建对象呢?对象调用方法本质上是根据函数表找到函数对应代码所在的内存地址,而抽象方法是未实现的方法,自然就无法给出方法的地址了,如果创建了对象,而我的对象又想调用这个抽象方法那不就冲突了吗。所以规定无法实例化抽象类。

抽象方法的定义使用关键字 abstract,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public abstract class Life{
public abstract void happy();
}

public class Cat{
public void happy(){
System.out.println("猫吃鱼");
}
}

public class Cat{
public void happy(){
System.out.println("狗吃肉");
}
}

public class Altman{
public void happy(){
System.out.println("奥特曼打小怪兽");
}
}

上面定义了一个抽象类Life 代表世间的生物,你要问生物的幸福是什么,可能没有人给你答案,不同的生物有不同的回答,但是具体到同一种生物,可能就有答案了,这里简单的给出了答案:幸福就是猫吃鱼狗吃肉奥特曼爱打小怪兽。

使用抽象类需要注意下面几点:

  • 不能直接创建抽象类的对象,必须使用实现类来创建对象
  • 实现类必须实现抽象类的所有抽象方法,否则该实现类也必须是抽象类
  • 抽象类可以有自己的构造方法,该方法仅供子类构造时使用
  • 抽象类可以没有抽象方法,但是有抽象方法的一定要是抽象类

接口

接口就是一套公共的规范标准,只要符合标准就能通用,比如说USB接口,只要一个设备使用了USB接口,那么我的电脑不管你的设备是什么,插上就应该能用。在代码中接口就是多个类的公共规范。

Java中接口也是一个引用类型。接口与抽象类非常相似,同样不能创建对象,必须创建实现类的方法。但是接口与抽象类还是有一些不同的。 抽象类也是一个类,它是从底层类中抽象出来的更高层级的类,但是接口一般用来联系多个类,是多个类需要实现的一个共同的标准。是从顶层一层层扩展出来的。

接口的一个常见的使用场景就是回调,比如说常见的窗口消息处理函数。这个场景C++中一般使用函数指针,而Java中主要使用接口。
接口使用关键字 interface 来定义, 比如

1
2
3
4
5
public interface USB{
public final String deviceType = "USB";
public abstract void open();
public abstract void close();
}

接口中常见的一个成员是抽象方法,抽象方法也是由实现类来实现,注意事项也与之前的抽象类相同。除了有抽象方法,接口中也可以有常量。

接口中的抽象方法是没有方法体的,它需要实现类来实现,所以实现类与接口中发生重写现象时会调用实现类,那么常量呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Mouse implements USB{
public final String deviceType = "鼠标";
public void open(){

}

public void close(){

}
}

public class Demo{
public static void main(String[] args){
USB usb = new Mouse();
System.out.println(usb.deviceType);
}
}

常量的调用遵循之前说的重载中的属性成员调用的方式。使用的是什么类型的引用,调用哪个类型中的成员。

与抽象类中另一个重要的不同是,接口运行多继承,那么在接口的多继承中是否会出现冲突的问题呢

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
public interface Storage{
public final String deviceType = "存储设备";
public abstract void write();
public abstract void read();
}

public class MobileHardDisk implements USB, Storage{
public void open(){

}

public void close(){

}

public void write(){

}

public void read(){

}
}

public class Demo{
public static void main(String[] args){
MobileHardDisk mhd = new MobileHardDisk();
System.out.println(mhd.deviceType);
}
}

编译上述代码时会发现报错了,提示 USB 中的变量 deviceType 和 Storage 中的变量 deviceType 都匹配 ,也就是说Java中仍然没有完全避免冲突问题。

接口中的默认方法

有的时候可能会出现这样的情景,当项目完成后,可能客户需求有变,导致接口中可能会添加一个方法,如果使用抽象方法,那么接口所有的实现类都得重复实现某个方法,比如说上述的代码中,USB接口需要添加一个方法通知PC设备我这是什么类型的USB设备,以便操作系统匹配对应的驱动。那么可能USB的实现类都需要添加一个,这样可能会引入大量重复代码,针对这个问题,从Java 8开始引入了默认方法。

默认方法为了解决接口升级的问题,接口中新增默认方法时,不用修改之前的实现类。

默认方法的使用如下:

1
2
3
4
5
6
7
8
public interface USB{
public final String deviceType = "USB";
public abstract void open();
public abstract void close();
public default String getType(){
return this.deviceType;
}
}

默认方法同样可以被所有的实现类覆盖重写。

接口中的静态方法

从Java 8中开始,允许在接口中定义静态方法,静态方法可以使用实现类的对象进行调用,也可以使用接口名直接调用

接口中的私有方法

从Java 9开始运行在接口中定义私有方法,私有方法可以解决在默认方法中存在大量重复代码的情况。

虽然Java为接口中新增了这么多属性和扩展,但是我认为不到万不得已,不要随便乱用这些东西,毕竟接口中应该定义一系列需要实现的标准,而不是自己去实现这些标准。

最后总结一下使用接口的一些注意事项:

  • 接口没有静态代码块或者构造方法
  • 一个类的父类只能是一个,但是类可以实现多个接口
  • 如果类实现的多个接口中有重名的默认方法,那么实现类必须重写这个实现方法,不然会出现冲突。
  • 如果接口的实现类中没有实现所有的抽象方法,那么这个类必须是抽象类
  • 父类与接口中有重名的方法时,优先使用父类的方法,在Java中继承关系优于接口实现关系
  • 接口与接口之间是多继承的,如果多个父接口中存在同名的默认方法,子接口中需要重写默认方法,不然会出现冲突

    final关键字

    之前提到过final关键字,用来表示常量,也就是无法在程序中改变的量。除了这种用法外,它还有其他的用法
  • 修饰类,表示类不能有子类。可以将继承关系理解为改变了这个类,既然final表示常量,不能修改,那么类自然也不能修改
  • 修饰方法:被final修饰的方法不能被重写
  • 修饰成员变量:表示成员变量是常量,不能被修改
  • 修饰局部变量:表示局部变量是常量,在对应作用域内不可被修改