深刻理解抽象类和接口
深刻理解抽象类和接口
前言
在我初学Java时,和别人说道:“我要设计一个接口,然后……”,那人问我:“什么是接口?”,我说:“接口就是接口呗,顾名思义!”。其实那是我很难说清楚什么是接口。
现在写到了关于接口的文章,我也想办法用通俗的语言来描述“接口”这一概念,而不是用很标准的语言。我本来想自己讲一下,但是我再知乎上看到一段描述,我决定把它贴上来。因为我不会讲的比这位前辈还要生动形象。
接口就是个招牌。比如说你今年放假出去杭州旅游,玩了一上午,你也有点饿了,突然看到前面有个店子,上面挂着KFC,然后你就知道今天中饭有着落了。KFC就是接口,我们看到了这个接口,就知道这个店会卖炸鸡腿(实现接口)。那么为神马我们要去定义一个接口涅,这个店可以直接卖炸鸡腿啊(直接写实现方法),是的,这个店可以直接卖炸鸡腿,但没有挂KFC的招牌,我们就不能直接简单粗暴的冲进去叫服务员给两个炸鸡腿了。要么,我们就要进去问,你这里卖不卖炸鸡腿啊,卖不卖汉堡啊,卖不卖圣代啊(这就是反射)。很显然,这样一家家的问实在是非常麻烦(反射性能很差)。要么,我们就要记住,中山路108号卖炸鸡,黄山路45号卖炸鸡(硬编码),很显然这样我们要记住的很多很多东西(代码量剧增),而且,如果有新的店卖炸鸡腿,我们也不可能知道(不利于扩展)。
作者:Ivony
来源:知乎
著作权归作者所有。
简单的说,接口是规范,是模版,是统一标准。统一标准的目的,是大家都知道这个是做什么的,但是具体不用知道具体怎么做。
接口和内部类为我们提供了一种将接口与实现分离的更加结构化的方法。
本文内容:
- 前言
- 抽象类和抽象方法
- 接口
- 接口和抽象类的区别
- 完全解耦
- Java中的多继承
- 拓展接口
- 接口和模式设计
- 后记
抽象类和抽象方法
我们回想一下之前的几篇文章中“乐器”的例子,类Instrument中创建了一些方法,它有好多代表具体乐器的子类,而我们实际上需要的是对这些子类进行实例化。这样想一想,似乎类Instrument存在的意义就是为它的子类们创建一个模版(我是你们的父类,我长成这样,你们以我为模版,细节上各自发挥),或者说我们可以把它成为通用接口
。
那么建立这个通用接口的理由就是,不同的子类可以用不同的方式实现此接口。通用接口建立起了一种基本的规则,以用来表示所有子类的共同部分。处于这样的目的,我们干脆不把Instrument当成不同类了,我们用一种新的方法:抽象类。
在面向对象的概念中,我们知道所有的对象都是通过类来描绘的,但是反过来却不是这样。并不是所有的类都是用来描绘对象的,如果一个类中没有包含足够的信息来描绘一个具体的对象,这样的类就是抽象类。抽象类往往用来表征我们在对问题领域进行分析、设计中得出的抽象概念,是对一系列看上去不同,但是本质上相同的具体概念的抽象。
比如说在“乐器”类中,存在这具体的铜管、钢琴、小提琴等。它们各不相同,但是又同属于乐器这一种类。但是如果我们单独提到“乐器”这一概念,具体长成什么样子我们并不知道,它没有一个具体乐器的概念,所以它就是一个抽象类,需要一个具体的乐器,如手风琴、钢琴、萨克斯来对它进行特定的描述,我们才知道它长成啥样。
引用chenssy前辈的总结:
抽象类体现了数据抽象的思想,是实现多态的一种机制。它定义了一组抽象的方法,至于这组抽象方法的具体表现形式有派生类来实现。同时抽象类提供了继承的概念,它的出发点就是为了继承,否则它没有存在的任何意义。所以说定义的抽象类一定是用来继承的,同时在一个以抽象类为节点的继承关系等级链中,叶子节点一定是具体的实现类。
圈重点:抽象类不能实例化,存在的意义就是被继承,其中的抽象方法只能由子类来具体实现。
简单说一下什么是抽象方法:不完整的方法,仅有声明而没有方法体且必须使用关键字abstract做修饰。
abstract void f();
包含抽象方法的类叫做抽象类。如果一个类包含一个或多个抽象方法,该类必须被限定为抽象的。写到这让我想起了牛客网中的一道题:抽象类中可以没有抽象方法,但有抽象方法的一定是抽象类。 这让我又想起了有关抽象类的知识点,是关于继承和抽象类的问题:当一个子类要继承抽象类时,如果子类还想当抽象类就不一定要实现所有的抽象方法、如果子类不愿再抽象类了就必须实现父类所有的抽象方法。
我们把之前“乐器”的例子拿过来,
package ch8_2;
import ch8_1.Note;
//在这里有一些小改动,我们把它变成了抽象类,并添加了抽象方法
abstract class Instrument {
public int i;
public abstract void play(Note n);
public String what() {
return "乐器";
}
public abstract void adjust();
}
class Wind extends Instrument{
public void play(Note n){
System.out.println("来自管乐的演奏"+n);
}
public String what() {
return "管乐";
}
public void adjust() {
System.out.println("调整 管乐");
}
}
class Percussion extends Instrument{
public void play(Note n){
System.out.println("来自打击乐器的演奏"+n);
}
public String what() {
return "打击乐器";
}
public void adjust() {
System.out.println("调整 打击乐器");
}
}
class Stringed extends Instrument{
public void play(Note n){
System.out.println("来自弦乐的演奏"+n);
}
public String what() {
return "弦乐";
}
public void adjust() {
System.out.println("调整 弦乐");
}
}
class Brass extends Wind {
public void play(Note n){
System.out.println("来自铜管的演奏"+n);
}
public void adjust() {
System.out.println("调整 铜管");
}
}
class Woodwind extends Wind {
public void play(Note n){
System.out.println("来自木管的演奏"+n);
}
public String what() {
return "木管";
}
}
public class Music3 {
public static void tune(Instrument i){
i.play(Note.MIDDLE_C);
}
public static void tuneAll(Instrument[] e){
for(Instrument i : e)
tune(i);
}
public static void main(String args[]){
Instrument[] orchestra = {new Wind(),new Percussion(),new Stringed(),new Brass(),new Woodwind()};
tuneAll(orchestra);
}
}
输出的结果和之前一样,并且我们发现代码和之前,除了基类变成了抽象类之外,也没什么改变。那这么做有什么好处呢?抽象类和抽象方法可以使类的抽象性明确起来,并告诉用户和编译器他们打算怎样来使用它们。抽象类还是很有用的重构器,因为它们使我们可以很容易地将公共方法沿着继承层次结构向上移动。
那么现在我们总结一下使用抽象类时需要注意几点:
- 抽象类不能被实例化,实例化的工作应该交由它的子类来完成,它只需要有一个引用即可。
- 抽象方法必须由子类来进行重写。
- 只要包含一个抽象方法的抽象类,该方法必须要定义成抽象类,不管是否还包含有其他方法。
- 抽象类中可以包含具体的方法,当然也可以不包含抽象方法。
- 子类中的抽象方法不能与父类的抽象方法同名。
- abstract不能与final并列修饰同一个类。
- abstract 不能与private、static、final或native并列修饰同一个方法。
接口
我相信大家可能通过文章开头的例子对接口的概念有一定了了解,接口是规范,是模版,是统一标准。统一标准的目的,是大家都知道这个是做什么的,但是具体不用知道具体怎么做。
接口是比抽象类更进一步抽象的。abstract
关键字允许人们在类中创建没有任何定义的方法的抽象方法,以此来提供接口,但是没有提供任何相应的具体实现,这些实现是由此类的子类创建。而interface
这个关键字产生一个完全抽象的类(代替class
关键字),它根本就没有提供任何具体实现。它允许创建者确定方法名,参数列表和返回类型,但是没有任何方法体,接口只提供了形式,而未提供任何具体实现。
也就是说:接口表示了所有实现了该特定接口的类看起来都像这样。因此,任何使用某特定接口的代码都知道可以调用该接口的那些方法,而且仅需知道这些。因此,接口被用来建立类与类之间的协议。
而实现该接口的实现类必须要实现该接口的所有方法,通过使用implements
关键字,它表示该类在遵循某个或某组特定的接口,同时也表示着“interface只是它的外貌,但是现在需要声明它是如何工作的”。
接口长成下面这样:
interface 接口名称 {
全局常量;
抽象方法;
}
在使用接口过程中需要注意如下几个问题:
- 接口的所有方法的访问权限为public。如果声明为protected、private编译会出错。
- 接口中可以定义“成员变量”,或者说是不可变的常量,因为接口中的“成员变量”会自动变为为public static final,和全局常量差不多。可以通过类命名直接访问:
ImplementClass.name
。 - 接口中不存在实现的方法。
- 实现接口的非抽象类必须要实现该接口的所有方法。抽象类可以不用实现。
-
不能使用new操作符实例化一个接口,但可以声明一个接口变量,该变量必须引用一个实现该接口的类的对象。可以使用
instanceof
关键字 检查一个对象是否实现了某个特定的接口。例如:if(anObject instanceof Comparable){}。 - 在实现多接口的时候一定要避免方法名的重复。
如要将“乐器”类再改为接口,那么是下面这样的:
interface Instrument {
int VALUE = 5; //static&final
void play(Note n);
void adjust();
}
接口和抽象类的区别
我问从如下几个方面来说明接口与抽象类的不同。
1. 语法层次
首先我们从语法定义层面看抽象类和接口。在语法层面上,Java语言对于二者给出了不同的定义方式,下面以定义一个名为Demo的抽象类为例来说明这种不同。使用abstract class的方式定义Demo 抽象类的方式如下:
abstract class Demo {
abstract void method1();
abstract void method2();
…
}
使用interface的方式定义Demo抽象类的方式如下:
interface Demo {
void method1();
void method2();
…
}
我们可以总结出以下特点:
- 抽象类可以有自己的数据成员,也可以有非抽象的成员方法。
-
接口只能够有静态的、不能被修改的数据成员(也就是必须是
static final
的,不过在interface中一般不定义数据成员),所有的成员方法都是抽象的。 - 从某种意义上说,interface是一种特殊 形式的abstract class。
2. 抽象层次
抽象类是对类抽象,而接口是对行为的抽象。抽象类是对整个类整体进行抽象,包括属性、行为,但是接口却是对类局部(行为)进行抽象。
3. 设计理念
抽象类在Java语言中体现了一种继承关系,要想使得继承关系合理,父类和派生类之间必须存在"is a"关系,即父类和子类在概念本质上应该是相同的。对于interface
来说则不然,并不要求interface
的实现者和interface
定义在概念本质上是一致的,仅仅是实现了interface
定义的契约而已。
为了更直观的说明这一问题,下面将用一个简单的实例来说明(注:以下实例和分析参考自http://blog.csdn.net/ttgjz/article/details/2960451)。
考虑这样一个例子,假设在我们的问题领域中有一个关于Door的抽象概念,该Door具有执行两个动作open和close,此时我们可以通过abstract class或者interface来定义一 个表示该抽象概念的类型,定义方式分别如下所示:
使用abstract class方式定义Door:
abstract class Door {
abstract void open();
abstract void close();
}
使用interface方式定义Door:
interface Door {
void open();
void close();
}
其他具体的Door类型可以extends使用抽象类定义的Door或者implements使用接口定义的Door。看起来好像使用抽象类和接口没有大的区别。
如果现在要求Door还要具有报警的功能。我们该如何设计针对该例子的类结构呢?下面将罗列出可能的解决方案,并从设计理念层面对 这些不 同的方案进行分析。
解决方案一:
简单的在Door的定义中增加一个alarm方法,如下:
abstract class Door {
abstract void open();
abstract void close();
abstract void alarm();
}
interface Door {
void open();
void close();
void alarm();
}
那么具有报警功能的AlarmDoor的定义方式如下:
class AlarmDoor extends Door {
void open() { … }
void close() { … }
void alarm() { … }
}
或者
class AlarmDoor implements Door {
void open() { … }
void close() { … }
void alarm() { … }
}
这种方法违反了面向对象设计中的一个核心原则ISP(Interface Segregation Priciple ),在Door的定义中把Door概念本身固有的行为方法和另外一个概念"报警器"的行为方法混在了一起。这样引起的一个问题是那些仅仅依赖于Door这个概念的模块会因为"报警器"这个概念的改变(比如:修改alarm方法的参数)而改变,反之依然。
解决方案二:
既然open、close和alarm属于两个不同的概念,根据ISP原则应该把它们分别定义在代 表这两个概念的抽象类中。定义方式有:这两个概念都使用abstract class方式定义; 两个概念都使用interface方式定义;一个概念使用抽象类方式定义,另一个概念使用接口定义
显然,由于Java语言不支持多重继承,所以两个概念都使用abstract class方式定义是 不可行的。后面两种方式都是可行的,但是对于它们的选择却反映出对于问题领域中的 概念本质的理解、对于设计意图的反映是否正确、合理。我们一一来分析、说明。
如果两个概念都使用interface方式来定义,那么就反映出两个问题:
-
我们可能没有理解清楚问题领域,AlarmDoor在概念本质上到底是Door还是报警器?
-
如果我们对于问题领域的理解没有问题,比如:我们通过对于问题领域的分析发现AlarmDoor在概念本质上和Door是一致的,那么我们在实现时就没有能够正确的揭示我们 的设计意图,因为在这两个概念的定义上(均使用 interface方式定义)反映不出上述 含义。
如果我们对于问题领域的理解是:AlarmDoor在概念本质上是Door,同时它有具有报警 的功能。我们该如何来设计、实现来明确的反映出我们的意思呢?前面已经说过, abstract class在Java语言中表示一种继承关系,而继承关系在本质上是"is a"关系。
所以对于Door这个概念,我们应该使用abstarct class方式来定义。另外,AlarmDoor又 具有报警功能,说明它又能够完成报警概念中定义的行为,所以报警概念可以通过 interface方式定 义。如下所示:
abstract class Door {
abstract void open();
abstract void close();
}
interface Alarm {
void alarm();
}
class AlarmDoor extends Door implements Alarm {
void open() { … }
void close() { … }
void alarm() { … }
}
这种实现方式基本上能够明确的反映出我们对于问题领域的理解,正确的揭示我们的设计 意图。其实abstract class表示的是"is a"关系,interface表示的是"like a"关系,大家在选择时可以作为一个依据,当然这是建立在对问题领域的理解上的,比如:如
果我们认为AlarmDoor在概念本质上是报警器,同时又具有 Door的功能,那么上述的定义方式就要反过来了。
抽象方法是必须实现的方法。就象动物都要呼吸。但是鱼用鳃呼吸,猪用肺呼吸。动物类要有呼吸方法。怎么呼吸就是子类的事了。现在有很多讨论和建议提倡用interface代替abstract类,两者从理论上可以做一般性的混用,但是在实际应用中,他们还是有一定区别的。抽象类一般作为公共的父类为子类的扩展提供基础,这里的扩展包括了属性上和行为上的。而接口一般来说不考虑属性,只考虑方法,使得子类可以自由的填补或者扩展接口所定义的方法。
用一个简单的例子,比如说一个教师,我们把它作为一个抽象类,有自己的属性,比如说年龄,教育程度,教师编号等等,而教师也是分很多种类的,我们就可以继承教师类而扩展特有的种类属性,而普遍属性已经直接继承了下来。
而接口呢,还是拿教师做例子,教师的行为很多,除了和普通人相同的以外,还有职业相关的行为,比如改考卷,讲课等等,我们把这些行为定义成无body的方法,作为一个集合,它是一个interface。而教师张三李四的各自行为特点又有不同,那么他们就可以扩展自己的行为body。从这点意义上来说,interface偏重于行为。
总之,在许多情况下,接口确实可以代替抽象类,如果你不需要刻意表达属性上的继承的话。
以上的例子和分析通俗易懂,我相信理解了这些,我们就能彻底的弄清抽象类和接口的区别了。我们再来总结一下:
- 抽象类在java语言中所表示的是一种继承关系,一个子类只能存在一个父类,但是可以存在多个接口。
- 在抽象类中可以拥有自己的成员变量和非抽象类方法,但是接口中只能存在静态的不可变的成员数据(不过一般都不在接口中定义成员数据),而且它的所有方法都是抽象的。
- 抽象类和接口所反映的设计理念是不同的,抽象类所代表的是“is-a”的关系,而接口所代表的是“like-a”的关系。
完全解耦
完全解耦的意思就是尽最大程度的降低程序的耦合性可以是程序具备良好的扩展性,易于修改。
较为具体的说明就是有的时候程序需要修改,我只需要改正一部分,单是如果程序的耦合性很强的话就需要从头再写一遍很不划算,而正常的开发中都是改那部分,重写那部分,把配置文件一改就成了。接口从具体实现中解耦使得接口可以应用于多种不同的具体实现,因此可以很大程度上降低耦合性,使代码具有更好的复用性。
这部分内容设计了适配器模式等模式设计的内容,所以我想在关于《设计模式之禅》的读书笔记中学习它。
Java中的多继承
多重继承指的是一个类可以同时从多于一个的父类那里继承行为和特征,然而我们知道Java为了保证数据安全,它只允许单继承。
但是接口是根本没有任何具体实现的,也就是说,没有任何与接口相关的存储。你可以执行相同的行为,但是只有一个类可以有具体实现,因此,通过组合多个接口来实现多继承。
一个类,只能继承一个非接口的类(不论是否抽象),其余的“基元素”都是接口。需要将所有的接口名都置于implements关键字之后,用逗号将它们一一分割开。可以继承任意多个接口,并可以向上转型为每个接口。因为每一个接口都是一个独立的类型。下面还是要展示一下一个具体类是如何继承多个接口的:
interface CanFight{
void fight();
}
interface CanSwim{
void swim();
}
interface CanFly{
void fly();
}
class ActionCharacter{
public void fight(){}
}
class Hero extends ActionCharacter implements CanFight,CanSwim,CanFly{
@Override
public void fly() {}
@Override
public void swim() {}
}
public class Adventure {
public static void t(CanFight x){ x.fight(); }
public static void u(CanSwim x){ x.swim(); }
public static void v(CanFly x){ x.fly(); }
public static void w(ActionCharacter x){ x.fight(); }
public static void main(String[] args) {
Hero h = new Hero();
t(h);
u(h);
v(h);
w(h);
}
}
在上面的例子中,Hero组合了具体类ActionCharacter和接口CanFight、CanSwim和CanFly。当通过这种方式将一个具体类和多个接口组合到一起时,这个具体类必须放在前面,后面跟着的才是接口(否则编译器会报错)。
值得注意的一点是,CanFight接口与ActionCharacter类中的fight()方法的特征签名是一样的。我们知道实现接口的非抽象类必须要实现该接口的所有方法,但是在Hero中并没有提供fight()的定义。这是因为虽然Hero中没有提供显式的fight()定义,但是ActionCharacter中定义了fight()方法,Hero继承了下来。因此,所有接口的定义都存在了,可以创建Hero接口了。
Adventure类我一开始有点看不懂,我想要自己研究一下这个类。首先它有四个命名很垃圾的静态方法t,u,v,w。前三个方法传进来的参数是以接口为类型的。我们可要知道,接口是不能实例化的!但是我们可以将接口类型的参数作为方法参数(这是一种很常见的做法),这样做是为了将实现了接口的类,也就是Hero类传递给这些方法,实际调用的是实现类中的方法代码体,这样便根据传进入的参数的不同而实现不同的功能。
当Hero对象被创建时,它可以被传递给这些方法中的任何一个,这意味着它依次被向上转型为每一个接口。由于Java中这种设计接口的方式,使得这项工作不需要程序员付出任何特别的努力。
一定要记住,前面的例子所展示的就是使用接口的核心原因:为了能够向上转型为多个基类型(以及由此而带来的灵活性)。然而,使用接口的第二个原因却是与使用抽象基类相同:防止客户端程序员创建该类的对象,并确保这仅仅是建立一个接口。
这里还有一个小问题 ,是关于命名冲突的。
CanFight和ActionCharacter都有一个相同的void fight()方法。这不是问题所在,因为该方法在二者中是相同的。相同的方法不会有什么问题,但是如果它们的签名或返回类型不同,那就会出现错误。在打算组合的不同接口中使用相同的方法名通常会造成代码可读性的混乱,在实际开发中千万不要这样做,会挨骂的。
拓展接口
通过继承来拓展接口,也就是接口继承接口。这样很容易地在接口添加新的方法声明,也可以在新接口继承多个接口。
接口和模式设计
与模式设计有关的内容,我决定在《设计模式之禅》的笔记中详细学习。这里简单的说一下吧
适配器:
接口最吸引人的原因之一就是允许同一个接口具有多个不同的具体实现。在简单的情况中,它的体现形式通常是一个接受接口类型的方法,而该接口的实现和向该方法传递的对象则取决于方法的使用者。
因此,接口的一种常见的用法就是策略设计模式,此时编写一个执行某些操作的方法,而该方法将接受一个同样是指定的接口。主要就是要声明:“你可以用任何你想要的对象来调用我的方法,只要你的对象遵循我的接口。”这使得你的方法更加灵活、通用,并更具可复用性。
工厂:
接口是实现多重继承的途径,而生成遵循某个接口的对象的典型方式就是工厂方法设计模式。这与直接调用构造器不同,我们在工厂对象上调用的是创建方法,而该工厂对象将生成接口的某个实现的对象。理论上,通过这种方式,我们的代码将完全与接口的实现分离,这就使得我们可以透明地将某个实现替换为另一个实现。
后记
关于接口的很多应用都和模式设计有关,这里用了很长的篇幅,是想把抽象类、接口的概念、特点和用法详细的搞清楚。其中的很多知识点,不是一次两次就能够掌握的,我们需要时常复习,才能真正的俄领悟。