接口还是抽象类?这是一个问题

“接口”与“抽象类”的区别,这是初级程序员在面试的时候常常会被问到的一个问题😱。这两种类型确实有太多的相似之处,比如:它们都有抽象方法;都不能实例化;都支持动态绑定等等;但是也有很多不同:“接口”支持多实现而“抽象类”只能单继承;“接口”不能有构造而“抽象类”可以;“接口”只有公共静态常量属性而“抽象类”确可以拥有变量属性等等。我相信,对于Java语法熟悉的同学肯定都能说出一大堆这些相同与不同。但是真正到使用的时候,我们却往往不能分清楚到底该定义“接口”呢?还是“抽象类”呢?有没有一个准确的判定标准,或者说有没有有一个面向对象思想层面的区分呢?

“抽象类”与“接口”的语法区别

对于语法已经很熟悉的同学可以跳过这一节,但我们建议大家还是都看看😏😏。

定义的语法

我们先来看看一段定义“抽象类“和”接口“的代码:

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
//抽象类:AbstractClassA
public abstract class AbstractClassA{
private int fieldA;
public static final int FIELDA = 100;
public AbstractClassA(){
}
public void setFieldA(int fieldA){
this.fieldA = fieldA;
}
public int getFieldA(){
return this.fieldA;
}
public abstract void methodA();
}
//接口:InterfaceB
public interface InterfaceB{
public static final int FIELDB = 100;
public void methodB();
public default void methodBB(){
System.out.println("JDK1.8的新语法,接口也可以定义实现方法。");
System.out.println("方法声明必须有default关键字。");
}
}

我们来点评一下,它们“定义语法”的特点:

  1. “抽象类”使用的是关键字abstract class;
    “接口”用的是interface;
    说明:“抽象类”仍然是一种“类”类型,只是特殊在它能拥有未实现的抽象方法;而“接口”却是一种独立于“类”类型之外的全新的数据类型;可见当初在设计语法的时候,设计人员就默认这是两种本质上完全不同的类型!
  2. “抽象类”是有构造方法的,不写也有默认的公共无参构造;
    “接口”则是完全没有构造方法这一概念的,当然也就不能书写构造;
    说明:“抽象类”会关心和配合对象的产生,但是“接口”对最终的对象到底是什么完全不在意,那”接口”关注的是什么呢?🤔️
  3. “抽象类”可以有各种属性(变量、常量、静态的、非静态的,各种访问修饰符);
    “接口”只能拥有公共静态常量属性,就算不写public static final这三个关键字,你定义在“接口”中的属性也自动认为是公共静态常量;
    说明:相对于“抽象类”,“接口”几乎完全不关心属性的变化性。那么,还是那个问题,“接口”到底关注的是什么呢;🤔🤔
  4. “抽象类”可以有实现的方法,也可以有抽象方法;
    “接口”在Java长达20多年的时间中,都只能拥有抽象方法,直到JDK1.8才能拥有实现的方法(还必须用default关键字修饰);
    说明:这里很明显体现出,语法的设计人员认为:“接口”应该关注的是行为,而且只关注这个行为的声明(即有什么行为),而不关注这个行为的实现。

使用的语法

“接口”和“抽象类”由于都具有抽象方法,也就是其内部都包含不确定的内容,所以它们都不能够直接实例化对象(对象是具体的、实际的存在)。它们都是作为上层类型设计出来,让下层类型(子类,实现类)按照上层的设计意图去完成具体的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//抽象类和接口的使用
public class ClassA extends AbstractClassA
implements InterfaceB, InterfaceC{
@Override
public void methodA(){
System.out.println("重写抽象父类的抽象方法。");
}
@Override
public void methodB(){
System.out.println("重写接口的抽象方法。");
}
@Override
public void methodC(){
System.out.println("重写另一个接口的抽象方法。");
}
}

我们再来点评一下这里使用语法的特点吧😁。

  1. ClassA使用extends关键字绑定的“抽象类”AbstractClassA,这在语法上叫做“继承”。而我们都知道“继承”的含义是:子类是以父类为基础,去完成自己新的扩展。这种扩展包括:添加自己的新属性、新方法以及重写来自于父类的方法。当父类为“抽象类”的时候,这种扩展带有一定程度的强制性,即子类必须实现父类中的抽象方法,否则子类自己也只能是抽象类。
    说明:“抽象类”和它的“子类”是一种“is-a”的继承关系,所以“抽象类”在设计的意图上更多表现出了一种一脉相承的强绑定关系。简单点说:“抽象类”就是用来定义“子类”到底是什么的,它决定了“子类”的本质。
  2. ClassA使用implements关键字绑定“接口”InterfaceB和InterfaceC,这在语法上叫做“实现”。通过实现“接口”,实现类ClassA被要求必须重写来自于”接口“的抽象方法,否则这个实现类也只能是抽象类。
    说明:“接口”与“实现类”的关系相对于“抽象类”与“子类”而言要弱上一些。简单点说:“接口”就是给“实现类”添加额外方法的,它并不关注这个“实现类”本身是什么,只关注它必须拥要有“接口”提供的附加行为。
  3. 在Java语法中,“继承”是单继承,“实现”是多实现。即一个类(ClassA)只能有一个父类(AbstractClassA),却同时可以实现多个接口(InterfaceB和InterfaceC)。
    说明:“单继承”和“多实现”绝对不是数量的多少这么简单。继承就是父子关系,子类再如何设计丰富度但是其本质只能有一个,这是它万变不离其宗的“宗”。“抽象类”设计的意图就是要体现这个根源性,而“万变”的丰富性就交给了“接口”。让各种各样的“接口”来增加子类的表现力,从而能够适应各种场景的变化。

动态绑定的语法

无论是“抽象类”还是“接口”,都可以达到动态绑定的效果。这是他们的相似性。

1
2
3
4
5
6
7
8
9
//抽象类的引用 指向 子类的对象
AbstractClassA aca = new ClassA();
aca.methodA();//只能调用到来自于抽象类的方法或属性
//接口的引用 指向 实现类的对象
InterfaceB ib = new ClassA();
ib.methodB();//只能调用到来自于接口B的方法
InterfaceC ic = new ClassA();
ic.methodC();

说明:前者体现的是只要对象的类型本质是一样的(同一父类),那么都可以进行统一的操作;后者体现的是只要对象具有某些相同的行为(同一接口),那么也可以进行统一的操作。细细品味,它们要表达和对应的问题域还是有细微的不同的。

从一扇“门”,到千扇“门”

在探讨了语法之后,我们最后还是要落脚到实际的使用当中来。假如在某一场景问题域中,我们找到了一些抽象方法。那么,我们到底是把它们设计到抽象类当中呢?还是接口当中呢?关于这个问题,在网络上有一个流传相当广泛也非常经典的例子,那就是:“门”🚪。
众所周知,门有各种各样的门,门也有很多的功能。比如:有开门关门的行为、开锁的行为、按门铃的行为等等。而不同的门对这些行为的实现又是各有各的方式。普通门、推拉门、卷帘门、旋转门的开关实现各不相同;有用钥匙开锁的、有密码锁的、有指纹锁的,开锁实现也多种多样;按门铃也是如此,电子门铃、拉绳敲钟的、古时候还有用门环叩门的方式呢。那么在上层设计中,这些方法都只能设计为抽象方法了,具体实现交给各个下层类去确定。

1
2
3
4
5
public abstract void openDoor();//开门
public abstract void closeDoor();//关门
public abstract void lockDoor();//上锁
public abstract void unlockDoor();//解锁
public abstract void ringBell();//按门铃

那么问题就来了,这些抽象方法我们设计到哪里呢?是设计到抽象类中?还是设计到接口中?或是一些方法设计到抽象类,另一些设计到接口中?

设计抽象类

我们首先确定一下问题域。在这个问题域中是要表现各种各样的门(包括我们已知的,或是未来可能出现的)。那么我们可以很明显的发现,在这5个抽象方法中,开关门的两个方法是与另外三个不同的。因为这两个方法是所有的门都必须具备的,也就是我们常说的“一脉相承、与生俱来”的行为,没有这两个行为就根本上否认了门的本质(做得再像,那也是墙而不是门)。这样的方法,我们就应该设计到父类当中,让所有的门子类都具备!

1
2
3
4
public abstract class Door{
public abstract void openDoor();//开门
public abstract void closeDoor();//关门
}

另外三个方法上锁、解锁和按门铃,不是所有门都具备的,但却可以灵活添加到各种子类门身上,从而满足各种变化的丰富度。这些可以“附属添加”的行为,就要定义在接口当中。
那么是定义一个大接口拥有这三个方法呢?还是定义多个接口,各自设计一些方法呢?

设计接口

在设计接口的时候,其实是有一个叫做“接口隔离原则”的,也被称之为“最小接口原则”。其含义是在定义接口的时候,尽量不要设计一个庞大的接口拥有一堆抽象方法。因为这样做的话,会导致实现这个接口的实现类,必须具备所有的这一大堆方法。比如:如果我们把上锁、解锁和按门铃这三个方法设计到一个接口中,会导致所有有锁的门都必须有按门铃,所有有按门铃的门都有锁。但是,很明显锁和门铃是可以拆分开的两个功能。场景中是允许出现只有锁功能或只有门功能的门类。分开设计,可以让我们更灵活的想实现谁就实现,不想实现谁就不实现,反正接口的语法可以多实现嘛~~~😊
但我们也不要简单的理解为一个接口就设计一个方法,因为还有可能有些方法是天生成对出现的。比如本场景当中的上锁和解锁两个功能,它们是在门上同时出现或同时不出现的。那么对于这种情况,我们就可以设计到一个接口当中了。因此,本例接口最后的设计是:

1
2
3
4
5
6
7
8
public interface Lockable{
public abstract void lockDoor();//上锁
public abstract void unlockDoor();//解锁
}
public interface RingBellable{
public abstract void ringBell();//按门铃
}

多样性的体现

你看,这样我们就能表示各种门了✌️

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//普通门请直接继承这个Door类
public abstract class Door{
public abstract void openDoor();//开门
public abstract void closeDoor();//关门
}
//带锁的门请直接继承这个LockDoor类
public abstract class LockDoor extends Door implements Lockable{
}
//带门铃的门请直接继承这个BellDoor类
public abstract class BellDoor extends Door implements RingBellable{
}
//既有锁又有门铃的门请直接继承这个LockBellDoor类
public abstract class LockBellDoor extends Door
implements Lockable,RingBellable{
}

综述一下下

抽象方法设计到抽象类还是接口,是要由这个抽象方法的地位所决定的。

  1. 如果要设计的方法是场景中某个类型“与生俱来、一脉相承”的(无论以后场景如何变化,该方法都肯定会存在于类型当中),那么这样的抽象方法就应该设计到“抽象类”当中。
  2. 如果要设计的方法并不是场景中某个类型的根本,而是为了满足场景变化的丰富度选择性添加或不添加的,那么这样的抽象方法就应该设计到“接口”当中。
  3. 设计“接口”的时候,请注意⚠️要满足“接口隔离原则”。不要把可能会分离的方法放到一个大接口当中了,这样的话会造成让实现类拥有了不该有的方法(我们把这种情况叫做:接口污染)。

语法的不同,体现设计意图的不同

通过上面对于“接口”和“抽象类”语法和经典场景的分析,我们最后总结如下:

  1. “接口”与“抽象类”虽然都可以定义抽象方法,但是两者的关注点是完全不同的。“抽象类”关注的是继承体系树上一脉相承的根源性,是某一类型家族所有类的本质;“接口”关注的是具有相同行为的一组类,它并不对这些类的本质有任何影响或关注,只要某类型有相同行为即可通过实现接口成为具有某种共性的一个分组。
  2. 不能因为“单继承”,就说抽象类不好;也不应该因为可以“多实现”就到处都设计为接口。任何一个事物的本质只能有一个(亲爹只能有一个嘛~~~),但是也应该可以后续扩展出更多的变化度(你可以多认几个干爹呀😈)。所以“单继承”和“多实现”本来就是父类和接口的关注度不同所导致的。它不是“抽象类”的原罪,也不是“接口”的强悍!这是这两种类型设计意图的不同,所带来的必然结果!
  3. 正确的分析出,哪些抽象方法设计到“抽象类”中,哪些抽象方法设计到“接口”中,对于适应场景的变化是非常重要的。只有符合场景的语法,才能跟随场景的变化而变化,这样设计出来的程序才具有业务成长性。👍👍👍