接口和抽象类提供了一种将接口与实现分离的更加结构化的方法。
这种机制在编程语言中不常见,例如C++只对这种概念有间接的支持。而在Java中存在这些关键字,说明这些思想很重要,Java为它们提供了直接支持。
首先,我们将学习抽象类,一种介于普通类和接口之间的折衷手段。尽管你的第一想法是创建接口,但是对于构建具有属性和未实现方法的类来说,抽象类也是重要且必要的工具。你不可能总是使用纯粹的接口。
抽象类和方法
在上一章的乐器例子中,基类Instrument中的方法往往是“哑”方法。如果调用了这些方法,就会出现一些错误。这是因为接口的目的是为了它的派生类创建一个通用接口。
在那些例子中,创建这个通用接口的唯一理由是,不同的子类可以用不同的方式表示此接口。通用接口建立了一个基本形式,以此表达所有派生类的共同部分。另一种说法把Instrument称为抽象基类,或简称抽象类。
对于Instrument那样的抽象类来说,它的对象几乎总是没有意义的。创建一个抽象类是为了通过通用接口操纵一系列类。因此,Instrument只是表示接口,不是具体实现,所以创建一个Instrument的对象毫无意义,我们可能希望阻止用户这么做。通过让Instrument所有的方法产生错误,就可以达到这个目的,但是这么做会延迟到运行时才能得知错误信息,并且需要用户进行可靠、详尽的测试。最好能在编译时捕捉问题。
Java提供了一种叫做抽象方法的机制,这个方法是不完整的:它只有声明没有方法体。下面是抽象方法的声明语法:
1 | abstract void f(); |
包含抽象方法的类叫做抽象类。如果一个类包含一个或多个抽象方法,那么类本身也必须限定为抽象的,否则,编译器会报错。
1 | // interface/Basic.java |
如果一个抽象类不是完整的,当试图创建这个类的对象时,Java会怎么做呢?它不会创建抽象类的对象,所以我们只会得到编译器的错误信息。这样保证了抽象类的纯粹性,我们不用担心误用它。
1 | // interfaces/AttemptToUseBasic.java |
如果创建一个继承抽象类的新类并为之创建对象,那么就必须为基类的所有抽象方法提供方法定义。如果不这么做(可以选择不做),新类仍然是一个抽象类,编译器会强制我们为新类加上abstract关键字。
1 | // interfaces/Basic2.java |
可以将一个不包含任何抽象方法的类指明为abstract,在类中的抽象方法没什么意义但想阻止创建类的对象时,这么做就很有用。
1 | // interfaces/AbstractWithoutAbstracts.java |
为了创建可初始化的类,就要继承抽象类,并提供所有抽象方法的定义:
1 | // interfaces/Instantiable.java |
留意@Override的使用。没有这个注解的话,如果你没有定义相同的方法名或签名,抽象机制会认为你没有实现抽象方法从而产生编译错误。因此,你可能认为这里的@Override是多余的。但是,@Override还提示了这个方法被覆盖——我认为这是有用的,所以我会使用@Override,不仅仅是因为当没有这个注解时,编译器会告诉我出错。
记住,事实上的访问权限是“friendly”。你很快会看到接口自动将其方法指明为public。事实上,接口只允许public方法,如果不加访问修饰符的话,接口的方法不是friendly而是public。然而,抽象类允许每件事:
1 | // interfaces/AbstractAccess.java |
private abstract被禁止了是有意义的,因为你不可能在AbstractAccess的任何子类中合法地定义它。
上一章的Instrument类可以很轻易地转换为一个抽象类。只需要部分方法是abstract即可。将一个类指明为abstract并不强制类中的所有方法必须都是抽象方法。
下面为修改成使用抽象类和抽象方法的管弦乐器的例子:
1 | // interfaces/music4/Music4.java |
除了Instrument,基本没区别。
创建抽象类和抽象方法是有帮助的,因为它们使得类的抽象类很明确,并能告知用户和编译器使用意图。抽象类同时也是一种有用的重构工具,使用它们使得我们很容易地将沿着继承层级结构上移公共方法。
接口创建
使用interface关键字创建接口。interface和class一样随处可见,除非特指关键字interface,其他情况下都采用正常字体书写interface。
描述Java8之前的接口更加容易,因为它们只允许抽象方法。像下面这样:
1 | // interfaces/PureInterface.java |
我们甚至不用为方法加上abstract关键字,因为方法在接口中。Java知道这些方法不能有方法体(仍然可以为方法加上abstract关键字,但是看起来像是不明白接口,徒增难堪罢了)。
因此,在Java8之前我们可以这么说:interface关键字产生一个完全抽象的类,没有提供任何实现。我们只能描述类应该像什么,做什么,但不能描述怎么做,即只能决定方法名、参数列表和返回类型,但是无法确定方法体。接口只提供形式,通常来说没有实现,尽管在某些受限制的情况下可以有实现。
一个接口表示:所有实现了该接口的类看起来都像这样。因此,任何使用某特定接口的代码都知道可以调用该接口的哪些方法,而且仅需知道这些。所以,接口被用来建立类之间的协议。
Java8中接口稍微有些变化,因为Java8允许接口包含默认方法和静态方法——基于某些重要原因。接口的基本概念仍然没有变,介于类型之上、实现之下。接口与抽象类最明显的区别可能就是使用上的管用方式。接口的典型使用是代表一个类的类型或一个形容词,如Runnable或Serializable,而抽象类通常是类层次结构的一部分或一件事物的类型,如String或ActionHero。
使用关键字interface而不是class来创建接口。和类一样,需要在关键字interface前加上public关键字,否则接口只有包访问权限,只能在接口相同的包下才能使用它。
接口同样可以包含属性,这些属性被隐式指明为static和final。
使用implements关键字使一个类遵循某个特定接口,它表示:接口只是外形,现在我要说明它是如何工作的。除此之外,它看起来像继承。
1 | // interfaces/ImplementingAnInterface.java |
你可以选择显式地声明接口中的方法为public,但是即使你不这么做,它们也是public的。所以当实现一个接口,来自接口中的方法必须被定义为public。否则,它们只有包访问权限,这样在继承时,它们的可访问权限就被降低了,这是Java编译器所不允许的。
默认方法
Java8为关键字default增加了一个新的用途。当在接口中使用它时,任何实现接口却没有定义方法的时候可以使用default创建的方法体。默认方法比抽象类中的方法受到更多的限制,但是非常有用,我们将在“流式编程”一章中看到。现在让我们看下如何使用:
1 | // interfaces/AnInterface.java |
我们可以像这样实现接口:
1 | // interfaces/AnImplementation.java |
如果我们在AnInterface中增加了一个新方法newMethod(),而在Anlmplementation中没有实现它,编译器就会报错:
1 | AnImplementation.java:3:error: AnImplementation is not abstract and does not override abstract method newMethod() in AnInterface |
如果我们使用关键字default()为newMethod()方法提供默认的实现,那么所有与接口有关的代码能正常工作,不受影响,而且这些代码还可以调用新的方法newMethod():
1 | // interfaces/InterfaceWithDefault.java |
关键字default允许在接口中提供方法实现——在Java8之前被禁止。
1 | // interfaces/Implementation2.java |
尽管Implementation2中未定义newMethod(),但是可以使用newMethod()了。
增加默认方法的极具说服力的理由是它允许在不破坏已使用接口的代码的情况下,在接口中增加新的方法。默认方法有时也称为守卫方法或虚拟扩展方法。
多继承
多继承意味着一个类可能从多个父类中继承特征和特性。
Java在设计之初,C++的多继承机制饱受诟病。Java过去是一种严格要求单继承的语言:只能继承自一个类(或抽象类),但可以实现任意多个接口。在Java8之前,接口没有包袱——它只是方法外貌的描述。
多年后的现在,Java通过默认方法具有了某种多继承的特性。结合带有默认方法的接口意味着结合了多个基类中的行为。因为接口中仍然不允许存在属性(只有静态属性,不适用),所以属性仍然只会来自单个基类或抽象类,也就是说,不会存在状态的多继承:
1 | // interfaces/MultipleInheritance.java |
现在我们做些在Java8之前不可能完成的事:结合多个源的实现。只要基类方法中的方法名和参数列表不同,就能工作得很好,否则会得到编译器错误:
1 | // interface/MICollision.java |
Sam类中两个sam()方法有相同的方法名但是签名不同——方法签名包括方法名和参数类型,编译器也是用它来区分方法。但是从Max类可看出,返回类型不是方法签名的一部分,因此不能用来区分方法。为了解决这个问题,需要覆写冲突的方法:
1 | // interfaces/Jim.java |
当然,你可以重新定义jim()方法,但是也能像上例中那样使用super关键字选择基类实现中的一种。
接口中的静态方法
Java8允许在接口中添加静态方法。这么做能恰当地把工具功能置于接口中,从而操作接口,或者成为通用的工具:
1 | // onjava/Operations.java |
这是模板方法设计模式的一个版本,runOps()是一个模板方法。runOps()使用可变参数列表,因而我们可以传入任意多的Operations参数并按顺序运行它们:
1 | // interface/Machine.java |
这里展示了创建Operations的不同方式:一个外部类(Bing),一个匿名类,一个方法引用和lambda表达式——毫无疑问在这里是最好的解决方法。
这个特性是一项改善,因为它允许把静态方法放在更适合的地方。
Instrument作为接口
回顾下乐器的例子,使用接口的话:
类Woodwind和Brass说明了一旦实现了某个接口,那么其实现就变成了一个普通类,可以按常规方式扩展它。
接口的工作方式使得我们不需要显式声明其中的方法为public,它们自动就是public的。play()和adjust()使用default关键字定义实现。在Java8之前,这些定义要在每个实现中重复实现,显得多余且令人烦恼。
1 | // interfaces/music5/Music5.java |
这个版本的例子的另一个变化是:what()被修为toString()方法,因为toString()实现的正是what()方法要实现的逻辑。因为toString()是根基类Object的方法,所以它不需要出现在接口中。
注意到,无论是将其向上转型称作Instrument的普通类,或称作Instrument的抽象类,还是叫作Instrument的接口,其行为都是相同的。事实上,从tune()方法上看不出来Instrument到底是一个普通类、抽象类,还是一个接口。
抽象类和接口
尤其是在Java8引入default方法之后,选择用抽象类还是用接口变得 更加令人困惑。下表做了明确的区分:
特性 | 接口 | 抽象类 |
---|---|---|
组合 | 新类可以组合多个接口 | 只能继承单一抽象类 |
状态 | 不能包含属性(除了静态方法,不支持对象状态) | 可以包含属性,非抽象方法可能引用这些属性 |
默认方法和抽象方法 | 不需要在子类中实现默认方法。默认方法可以引用其他接口的方法 | 必须在子类中实现抽象方法 |
构造器 | 没有构造器 | 可以有构造器 |
可见性 | 隐式public | 可以是protected或“friendly” |
抽象类仍然是一个类,在创建新类时只能继承它一个。而创建类的过程中可以实现多个接口。
有一条实际经验:在合理的范围内尽可能地抽象。因此,更倾向使用接口而不是抽象类。只有当必要时才使用抽象类。除非必须使用,否则不要用接口和抽象类。大多数时候,普通类已经做得很好,如果不行的话,再移动到接口或抽象类中。
完全解耦
每当一个方法与一个类而不是接口一起工作时(当方法的参数是类而不是接口),你只能应用那个类或它的子类。如果你想把这方法应用到一个继承层次之外的类,是做不到的。接口在很大程度上放宽了这个限制,因而使用接口可以编写复用性更好的代码。
例如有一个类Processor有两个方法name()和process()。process()方法接收输入,修改并输出。把这个类作为基类用来创建各种不同类型的Processor。下例中,Processor的各个子类修改String对象:
1 | // interfaces/Applicator.java |
Applicator的apply()方法可以接受任何类型的Processor,并将其应用到一个Object对象上输出结果。像本例中这样,创建一个能根据传入的参数类型从而具备不同行为的方法称为策略设计模式。方法包含算法中不变的部分,策略包含变化的部分。策略就是传入的对象,它包含要执行的代码。在这里,Processor对象是策略,main()方法展示了三种不同的应用于String s上的策略。
Split() 是String类中的方法,它接收String类型的对象并以传入的参数作为分隔界限,返回一个数组String[]。在这里它是为了更快捷地创建String数组。
假设现在发现了一组电子滤波器,它们看起来好像能使用Applicator的apply()方法:
1 | // interfaces/filters/Waveform.java |
Filter类与Processor类具有相同的接口元素,但是因为它不是继承自Processor——因为Filter类的创建者根本不知道你想将它当作Processor使用——因此你不能将Applicator的apply()方法应用在Filter类上,即使这样做也能正常运行。主要是因为Applicator的apply()方法和Processor过于耦合,这阻止了Applicator的apply()方法被复用。另外要注意的一点是Filter类中process()方法的输入输出都是Waveform。
但如果Processor是一个接口,那么限制就会变得松动到足以复用Applicator的apply()方法,用来接受那个接受参数。下面是修改后的Processor和Applicator版本:
1 | // interfaces/interfaceprocessor/Processor.java |
复用代码的第一种方式是客户端程序员遵循接口编写类,像这样:
1 | // interfaces/interfaceprocessor/StringProcessor.java |
[1]该声明不是必要的,即使移除它,编译器也不会报错。但是注意这里的协变返回类型从Object变成了String。[2]S自动就是final和static的,因为它是在接口中定义的。[3]可以在接口中定义main()方法。
这种方式运作得很好,然而你经常遇到的情况是无法修改类。例如在电子滤波器的例子中,类库是被发现而不是创建的。在这些情况下,可以使用适配器设计模式。适配器允许代码接受已有的接口产生需要的接口,如下:
1 | // interfaces/interfaceprocessor/FilterProcessor.java |
在这种使用适配器的方式中,FilterAdapter的构造器接受已有的接口Filter,继而产生需要的Processor接口的对象。你可能还注意到FilterAdapter中使用了委托。
协变允许我们从process()方法中产生一个Waveform而非Object对象。
将接口与实现解耦使得接口可以应用于多种不同的实现,因而代码更具可复用性。
多接口结合
接口没有任何实现——也就是说,没有任何与接口相关的存储——因此无法阻止结合的多借口。这是有价值的,因为你有时需要表示“一个x是一个a和b以及一个c”。
派生类并不要求必须继承自抽象或“具体的”(没有任何抽象方法)的基类。如果继承一个非接口的类,那么只能继承一个类,其余的基元素必须都是接口。需要将所有的接口名称置于implements关键字之后且用逗号分隔。可以有任意多个接口,并可以向上转型为每个接口,因为每个接口都是独立的类型。下例展示了一个由多个接口组合而成的具体类产生的新类:
1 | // interfaces/Adventure.java |
类Hero结合了具体类ActionCharacter和接口CanFight、CanSwim和CanFly。当通过这种方式结合具体类和接口时,需要将具体类放在前面,后面跟着接口。
接口CanFight和类ActionCharacter中的fight()方法签名相同,而在类Hero中也没有提供fight()的定义。可以扩展一个接口,但是得到的是另一个接口。当想创建一个对象时,所有的定义必须首先都存在。类Hero中没有显示地提供fight()的定义,是由于该方法在类ActionCharacter中已经定义过,这样才使得创建Hero对象成为可能。
在类Adventure中可以看到四个方法,它们把不同的接口和具体类作为参数。当创建一个Hero对象时,它可以被传入这些方法中的任意一个,意味着它可以依次向上转型为每个接口。Java中这种接口的设计方式,使得程序员不需要付出特别的努力。
记住,前面例子展示了使用接口的核心原因之一:为了能够向上转型为多个基类型。然而,使用接口的第二个原因与使用抽象基类相同:防止客户端程序员创建这个类的对象,确保这仅仅只是一个接口。这带来了一个问题:应该使用接口还是抽象类呢?如果创建不带任何方法定义或成员变量的基类,就选择接口而不是抽象类。事实上,如果知道某事物是一个基类,可以考虑用接口实现它。
使用继承扩展接口
通过继承,可以很容易在接口中增加方法声明,还可以在新接口中结合多个接口。这两种情况都可以得到新接口:
1 | // interfaces/HorrorShow.java |
接口DangerousMonster是Monster简单扩展的一个新接口,类DragonZilla实现了这个接口。
Vampire中使用的语法仅适用于接口继承。通常来说,extends只能用于单一类,但是在构建接口时可以引用多个基类接口。注意到,接口名之间用逗号分隔。
结合接口时的命名冲突
当实现多个接口时可能会存在一个小陷阱。在前面的例子中,CanFight和ActionCharacter具有完全相同的fight()方法。完全相同的方法没有问题,但是如果它们的签名或返回类型不同会怎么样呢?
1 | // interfaces/InterfaceCollision.java |
覆写、实现和重载令人不快地搅和在一起带来了困难。同时,重载方法仅根据返回类型是区分不了的。当不注释最后两行时,报错信息如下:
1 | error: C5 is not abstract and does not override abstract |
当打算组合接口时,在不同的接口中使用相同的方法名通常会造成代码可读性的混乱、尽量避免这种情况。
接口适配
接口最吸引人的原因之一是相同的接口可以有多个实现。在简单情况下体现在一个方法接受接口作为参数,该接口的实现和传递对象则取决于方法的使用者。
因此,接口的一种常见用法是前面提到的策略设计模式。编写一个方法执行某些操作并接受一个指定的接口作为参数。可以说:“只要对象遵循接口,就可以调用方法”,这使得方法更加灵活、通用、并更具可复用性。
例如,类Scanner的构造器接受一个Readable接口。你会发现Readable没有用作Java标准库中其他任何方法的参数——它是单独为Scanner创建的,因此Scanner没有将其参数限制为某个特定类。通过这种方式,Scanner可以与更多的类协作。如果你创建了一个新类并想让Scanner作用于它,就让它实现Readable接口:
1 | // interfaces/RandomStrings.java |
Readable接口只需要实现read()方法,在read()方法里,将输入内容添加到CharBuffer参数中,或在没有输入时返回-1。
假设你有一个类没有实现Readable接口,怎么才能让Scanner作用于它呢?下面是一个产生随机浮点数的例子:
1 | // interfaces/RandomDoubles.java |
我们可以再次使用适配器模式,但这里适配类可以实现两个接口。因此,通过关键字interface提供的多继承,我们可以创建一个既是RandomDoubles,又是Readable的类:
1 | // interfaces/AdaptedRandomDoubles.java |
因为你可以以这种方式在已有类中增加新接口,所以这就意味着一个接受接口类型的方法提供了一种让任何类都可以与该方法进行适配的方式。这就是使用接口而不是类的强大之处。
接口字段
因为接口中的字段都是static和final的,所以接口就成为了创建一组常量的方便工具。在Java5之前,这是产生与C和C++中的enum(枚举类型)具有相同效果的唯一方式。所以你可能在Java5之前的代码中看到:
1 | // interfaces/Months.java |
注意Java中使用大写字母的风格定义具有初始化值的static final变量。接口中的字段自动是public的,所以没有显示指明这点。
自Java5开始,我们有了更强大和灵活的关键字enum,那么在接口中定义常量组就显得没什么意义了。然而当你阅读遗留的代码时,在很多场合你还会碰到这种旧的习惯用法。
初始化接口中的字段
接口中定义的字段不能是“空final”,但是可以用非常量表达式初始化:
1 | // interfaces/RandVals.java |
因为字段是static的,所以它们在类第一次被加载时初始化,这发生在任何字段首次被访问时。下面是个简单的测试:
1 | // interfaces/TestRandVals.java |
这些字段不是接口的一部分,它们的值被存储在接口的静态存储区域中。
接口嵌套
接口可以嵌套在类或其他接口中。下面揭示一些有趣的特性:
1 | // interfaces/nesting/NestingInterfaces.java |
在类中嵌套接口的语法是相当显而易见的。就像非嵌套接口一样,它们具有public或包访问权限的可见性。
作为一种新添加的方式,接口也可以是private的,例如A.D。那么private嵌套接口有什么好处呢?你可能猜测它只是被用来实现一个private内部类,就像DImp。然而A.DImp2展示了它可以被实现为public类,但是A.DImp2只能被自己使用,你无法说它实现了private接口D,所以实现private接口是一种可以强制该接口中的方法定义不会添加任何类型信息(即不可以向上转型)的方式。
getD()方法产生了一个与private接口有关的窘境。它是一个public方法却返回了对private接口的引用。能对这个返回值做些什么呢?main()方法里进行了一些使用返回值的尝试但都失败了。返回值必须交给有权使用它的对象,本例中另一个A通过receiveD()方法接受了它。
接口E说明了接口之间也能嵌套。然而,作用于接口的规则——尤其是,接口中的元素必须是public的——在此都会被严格执行,所以嵌套在另一个接口中的接口自动就是public的,不能指明为private。
类NestingInterfaces展示了嵌套接口的不同实现方式。尤其是当实现某个接口时,并不需要实现嵌套在其内部的接口。同时,private接口不能在定义它的类之外被实现。
添加这些特性的最初原因看起来像是出于对严格的语法一致性的考虑,但是我通常认为,一旦你了解某种特性,就总能找到其用武之地。
接口和工厂方法模式
接口是多实现的途径,而生成符合某个接口的对象的典型方式是工厂方法设计模式。不同于直接调用构造器,只需调用工厂对象中的创建方法就能生成对象的实现——理论上,通过这种方式可以将接口与实现的代码完全分离,使得可以透明地将某个实现替换为另一个实现。这里是一个展示工厂方法结构的例子:
1 | // interfaces/Factories.java |
如果没有工厂方法,代码就必须在某处指定将要创建的Service的确切类型,从而调用恰当的构造器。
为什么要添加额外的间接层呢?一个常见的原因是创建框架,假设你正在创建一个游戏系统;例如,在相同的棋盘下国际象棋和西洋跳棋:
1 | // interfaces/Games.java |
如果类Games表示一段很复杂的代码,那么这种方式意味着你可以在不同类型的游戏里复用这段代码。你可以再想象一些能够从这个模式中收益的更加精巧的游戏。