超详细解读Java接口:模块通信协议以及默认方法和静态方法
有不少学习Java的同学一直有个疑问,不仅在初学者中很普遍,连许多经验丰富的老手也很难表述清楚,那就是:Java接口到底是什么?
来看看孙鑫老师的讲解,本文干货含量拉满,这可能是距离你深入理解Java接口最近的一次。
本文选自《Java无难事:详解Java编程核心思想与技术》,文末了解本书详情。
深入接口:通信双方的协议
接口有一个重要的作用,就是作为模块与模块之间通信的协议。
在软件领域,一直以来都希望能够实现像硬件生产一样,不同的零部件由不同的厂商生产,然后按照标准的接口进行组装,得到成品。
以计算机为例,要组装一台计算机,我们需要主板、CPU、显卡、内存等配件,虽然这些配件是由不同厂家生产的,但这并不影响我们组装成一台计算机,我们只需要将这些配件插在主板的对应插槽中就可以了,因为主板生产商和其他配件生产商都会针对某个插槽定义的规范进行生产,而配件在主板上的这些插槽就类似于Java中的接口。
在大型软件系统中,通常都是多人协作开发,将整个系统进行拆解,划分出子系统和模块,然后分工协作,不同的开发人员负责不同的模块开发。
而模块之间如何调用,则可以通过接口来约定,也就是说,接口可以作为模块与模块之间通信的协议。定义接口,相当于制定了模块之间通信的协议,两个模块要想通过接口进行通信,那么必然是一个模块实现了接口,提供了接口中声明的方法实现,而另一个模块则通过接口来调用其实现。
上面的内容比较抽象,下面我们通过一个计算机组装的例子来看看接口是如何作为通信双方的协议的。
首先,我们定义两个接口CPU和GraphicsCard,代表主板上的CPU和显卡接口。
代码6.17
1package computer;
2
3public interface CPU {
4 void calculate();
5}
代码6.18
1package computer;
2
3public interface GraphicsCard{
4 void display();
5}
前面说了,通过接口通信的双方,必然有一方要实现接口,另一方通过接口来调用其实现。
上面两个接口定义了CPU和显卡需要实现的方法,于是CPU厂商和显卡厂商根据各自的接口定义开始生产相应的产品。
代码6.19
1package computer;
2
3public class IntelCPU implements CPU {
4 public void calculate() {
5 System.out.println("Intel CPU calculate.");
6 }
7}
代码6.20
1package computer;
2
3public class NVIDIACard implements GraphicsCard {
4 public void display() {
5 System.out.println("Display something");
6 }
7}
IntelCPU类和NVIDIACard类分别给出了CPU接口和GraphicsCard接口的实现。
接下来该轮到主板登场了,主板上应该有CPU和显卡的插槽,从软件的角度来说,就是主板类应该持有CUP和显卡接口的引用。
代码6.21
1package computer;
2
3public class Mainboard {
4 private CPU cpu;
5 private GraphicsCard gCard;
6
7 public void setCpu(CPU cpu) {
8 this.cpu = cpu;
9 }
10
11 public void setGraphicsCard(GraphicsCard gCard) {
12 this.gCard = gCard;
13 }
14
15 public void run(){
16 System.out.println("Starting computer...");
17 cpu.calculate();
18 gCard.display();
19 }
20}
在Mainboard类中,包含了两个私有的实例变量:cpu和gCard,它们的类型分别是CPU和GraphicsCard接口类型。
我们知道,接口是不能直接实例化对象的,真实的CPU和GraphicsCard对象是通过setCpu和setGraphicsCard方法传递进来的,至于真实的CPU和显卡对象是什么,我们需要知道吗?不需要,我们只需要知道传进来的对象已经实现了对应的接口就可以了。
这就像我们买主板时,不需要关心主板上的相应插槽最终插的是哪个厂商的配件一样,因为我们知道所选购的配件都是符合插槽规范的,可以和主板一起工作。
在Mainboard类的run方法中,调用CPU接口和GraphicsCard接口的方法,完成计算机的启动与显示工作。
Mainborad类只是与CPU和GraphicsCard接口打交道,并没有依赖于具体的实现类,所以在组装计算机时,可以任意创建实现了CPU和GraphicsCard接口的类的对象,然后“安装”到主板上。
最后,我们要组装计算机了,也就是编写一个Computer类。
代码6.22
1package computer;
2
3public class Computer {
4 public static void main(String[] args) {
5 Mainboard mb = new Mainboard();
6 mb.setCpu(new IntelCPU());
7 mb.setGraphicsCard(new NVIDIACard());
8 mb.run();
9 }
10}
从这个例子中可以看到,Mainboard类并不关心“插”在它上面的CPU和显卡的具体类型,它只需要按照CPU和GraphicsCard接口中声明的方法使用显卡和CPU即可。
而Computer类的main方法创建了CPU和GraphicsCard的实现对象,并通过setXxx方法把CPU和显卡“插”到主板上,接着调用Mainboard类的run方法启动计算机运行。
对于上面的例子来说,Mainboard类可以由一个人来开发,IntelCPU类可以由一个人来开发,NVIDIACard类可以由一个人来开发,而这三个人只需要按照CPU和GraphicsCard接口中声明的方法来进行编码就可以了,最终的程序通过Computer类来组装。
接口的默认方法和静态方法
接口的默认方法和静态方法是Java 8新增的特性。
默认方法
前面已经介绍过,接口中的方法都是抽象的,某个类实现了接口,就要实现接口中的所有方法,如果没有完全实现接口中的方法,那么这个类就必须声明为抽象类。在接口和实现类都编写完毕后,如果需要在接口中新增一个方法,那么该接口的实现类也必须重新编码,以实现这个新增的方法。如果该接口的实现类还比较多,那么修改起来就比较痛苦了,为此,Java 8新增了接口的默认方法这一特性,允许你在接口中定义带有默认实现的方法,默认方法需要用default关键字来声明。为原有的接口添加新的默认方法,不会影响到现有的实现类。
我们可以给Animal接口添加两个默认方法,如下。
代码6.23
1public interface Animal {
2 void bark();
3 void move();
4 default void desc(){
5 System.out.println("动物");
6 }
7 default String getName(){
8 return "unknown";
9 }
10}
与接口中的普通方法一样,默认方法默认就是public访问权限,不同的是,默认方法有方法体。与我们通常所理解的“默认”代表一个有所区别,接口中的默认方法可以有多个。
在Animal接口添加默认方法后,并不会影响到现有的实现了Animal接口的类,前述的程序依然可以照常运行。
接口中的默认方法本身是有实现的,因此接口的实现类并不需要去实现这个默认方法,可以自动继承默认方法。
代码6.24
1public class Trainer {
2 private Animal an;
3
4 public Trainer(Animal an){
5 this.an = an;
6 }
7
8 public void train(){
9 an.desc();
10 an.bark();
11 an.move();
12 }
13}
直接执行代码6.16中的Zoo类,程序输出结果是:
动物
Dog bark
Dog run
动物
Cat miaow
Cat walk
动物
Bird singing
Bird jump
当然实现类也是可以重写接口的默认方法的。我们在Dog类和Cat类中重写Animal接口的默认方法desc。
代码6.25
1public interface Animal {
2 void bark();
3 void move();
4 default void desc(){
5 System.out.println("动物");
6 }
7 default String getName(){
8 return "unknown";
9 }
10}
11class Dog implements Animal{
12 public void desc(){
13 System.out.println("狗");
14 }
15 ...
16}
17
18class Cat implements Animal {
19 public void desc(){
20 System.out.println("猫");
21 }
22 ...
23}
编译Animal.java,执行Zoo类,程序的输出结果是:
狗
Dog bark
Dog run
猫
Cat miaow
Cat walk
动物
Bird singing
Bird jump
我们知道,在子类中可以通过super关键字来调用父类被覆盖的方法,那么接口中被重写的默认方法能不能被调用呢?又要如何调用呢?答案是可以调用,不过需要采用特殊的语法格式:“接口名字.super.方法名”。
修改代码6.25中的Dog类,在重写的desc方法中调用Animal接口的默认方法desc,如代码6.26所示。
代码6.26
1public interface Animal {
2 ...
3}
4class Dog implements Animal{
5 public void desc(){
6 Animal.super.desc();
7 System.out.println("狗");
8 }
9 ...
10}
11
12class Cat implements Animal {
13 ...
14}
编译Animal.java,执行Zoo类,程序的输出结果是:
动物
狗
Dog bark
Dog run
猫
Cat miaow
Cat walk
动物
Bird singing
Bird jump
接下来,我们给Flyable接口也添加一个默认方法desc。
代码6.27
1public interface Flyable{
2 default void desc(){
3 System.out.println("会飞的");
4 }
5 void fly();
6}
编译Flyable.java,再编译代码6.12的Bird.java,你会看到如下图所示的错误。
这是因为Animal接口有默认方法desc,而Flyable接口也有一个同名的默认方法desc,Bird类同时实现了这两个接口,如果Bird类的对象调用desc方法,那么应该调用哪个接口中的desc方法呢?无法确定,所以编译器在编译的时候就报出了错误。
这就是新增了接口默认方法特性后所带来的一个问题。在Java 8之前的接口方法都是抽象的,没有方法实现,方法实现是在实现类中给出的,因此不管类实现了几个接口,也不管这些接口中的方法是否同名,在程序中该方法的代码都只存在一份,在调用时根本不会存在二义性的问题。但默认方法是有方法实现的,不同的接口都有各自的实现,因此类在实现多个接口时,如果存在相同的默认方法,就无从选择了。
要解决这个问题,只能是在实现类中重写接口的默认方法,给出自己的实现,或者通过“接口名字.super.方法名”来调用指定接口的默认方法。修改代码6.12的Bird类,重写desc方法,分别给出两种解决方案的实现。
代码6.28
1public class Bird implements Animal, Flyable {
2 public void desc(){
3 System.out.println("鸟");
4 }
5 ...
6}
代码6.29
1public class Bird implements Animal, Flyable {
2 public void desc(){
3 Animal.super.desc();
4 }
5 ...
6}
由于引入了接口的默认方法,因而在接口继承和实现时,情况就会变得复杂。
代码6.30
1interface A{
2 default void print(){
3 System.out.println("A");
4 }
5}
6
7interface B extends A{
8 default void print(){
9 System.out.println("B");
10 }
11}
12
13public class InterfaceDefualtMethod implements A, B{
14 public static void main(String[] args){
15 InterfaceDefualtMethod idm = new InterfaceDefualtMethod();
16 idm.print();
17 }
18}
接口A有一个默认方法print,接口B扩展了接口A,同时也给出了一个同名的默认方法print,类InterfaceDefualtMethod同时实现了接口A和接口B,在main方法中调用idm.print()会输出什么结果呢?
执行该程序,可以看到结果是“B”。为什么是“B”不是“A”呢?这里要记住一个规则,如果一个接口继承了另外一个接口,两个接口中包含了相同的默认方法,那么继承接口(子接口)的版本具有更高的优先级。比如这里,B继承了A接口,那么优先使用B接口中的默认方法print。当然,如果实现类覆盖了默认方法,则优先使用实现类中的方法。
我们再看另外一种情况,在接口A中有一个默认方法,接口B和C都继承了A接口,然后一个类同时实现了接口B和C。
代码6.31
1interface A{
2 default void print(){
3 System.out.println("A");
4 }
5}
6
7interface B extends A{}
8interface C extends A{}
9
10public class InterfaceDefualtMethod implements B, C{
11 public static void main(String[] args){
12 InterfaceDefualtMethod idm = new InterfaceDefualtMethod();
13 idm.print();
14 }
15}
上述程序可以正常编译和执行,输出结果是“A”。可以发现,出现问题的情况,都是实现类继承了多个同名的默认方法,如果实现类中只存在一份默认方法的代码,那么情况就会变得简单,程序就不会出错,也不会有歧义。
静态方法
Java 8还为接口增加了静态方法特性,也就是说,现在可以在接口中定义静态方法。
代码6.32
1interface Math{
2 static int add(int a, int b){
3 return a + b;
4 }
5}
6
7public class InterfaceStaticMethod {
8 public static void main(String[] args){
9 System.out.println("5 + 3 = " + Math.add(5, 3));
10 }
11}
与接口中的默认方法一样,静态方法默认也是public访问权限,而且也必须有方法体。
这里我们可能会有些疑问,Java 8新增的接口默认方法,可以解决给接口添加新方法而导致的已有实现类出现的问题,但新增的接口静态方法貌似和在类中直接定义静态方法没什么区别。实际上并非如此,在接口中定义的静态方法,只能通过该接口名来调用,通过子接口名或者实现类名来调用都是不允许的。修改代码6.32,让InterfaceStaticMethod类实现Math接口,同时在main方法中通过实现类的类名来调用add方法。
代码6.33
1interface Math{
2 static int add(int a, int b){
3 return a + b;
4 }
5}
6
7public class InterfaceStaticMethod implements Math{
8 public static void main(String[] args){
9 System.out.println("5 + 3 = " + InterfaceStaticMethod.add(5, 3));
10 }
11}
编译InterfaceStaticMethod.java,会提示如下图所示的错误。
说明这和类中定义的静态方法调用还是有区别的,类中定义的静态方法是可以通过子类名来调用的。也就是说,实现接口的类或者子接口不会继承接口中的静态方法。
还要注意的是,默认方法不能同时是静态方法,即static关键字和default关键字不能同时使用。
图书推荐
《Java无难事:详解Java编程核心思想与技术》
孙鑫 著
本书系统地讲解Java开发人员需要掌握的核心知识,按照中国人的思维习惯,由浅入深、循序渐进、引导式地带领你快速掌握Java知识。
本书讲解了依赖注入(IoC/DI)容器、面向切面编程(AOP)、对象关系映射(ORM)框架的实现原理,同时还给出了并发编程领域中经常用到的线程池的实现。涵盖了从Java 5到Java 11的所有重要新特性,不仅适合初学Java编程的读者,也适合有一定经验的读者,甚至对于正在从事Java开发工作的读者也适用。
本书同时在每个关键知识点旁都配备了相应的视频二维码,可以随看随学。
还有来自作者孙鑫老师的倾情讲解。
和孙鑫老师一起学Java,让Java无难事!
抽奖赠书
截止时间:2021年2月28日 17:00
如何抽奖:点击下方卡片,关注并回复关键词 :20210223
下次你更希望我们送哪本书呢?
留言告诉我们!