目录
1.引言
在面向对象编程中,抽象类和接口是两个经常被提及的语法概念,也是面向对象编程的四大特性,以及很多设计模式和设计原则编程实现的基础。例如,我们可以使用接口实现面向对象的抽象特性、多态特性和基于接口而非实现的设计原则,使用抽象类实现面向对象的继承特性和模板设计模式,等等。
不过,并不是所有的面向对象编程语言都支持这两个语法概念,如C++这种编程语言只支持抽象类,不支持接口;而像 Python 这样的动态编程语言,既不支持抽象类,又不支持接口。尽管有些编程语言没有提供现成的语法来支持接口和抽象类,但是我们仍然可以通过一些手段模拟实现这两个语法概念。
这两个语法概念不但在工作中经常会被用到,而且在面试中经常被提及。接口和抽象类的区别是什么?什么时候使用接口?什么时候使用抽象类?抽象类和接口存在的意义是什么?通过阅读本节内容,相信读者可以从中找到答案。
2.抽象类和接口的定义与区别
不同的编程语言对接口和抽象类的定义方式可能有差别,但差别并不会很大。因为Java既支持抽象类,又支持接口,所以我们使用Java进行举例讲解,以便读者对这两个有直观的认识。
首先,我们看一下如何在 Java 中定义抽象类。
下面这段代码是一个典型的抽象类使用场景(模板设计模式)。Logger 是一个记录日志抽象类,FileLogger 类和 MessageQueueLogger 类继承 Logger 类,分别实现不同的日志记式:将日志输出到文件中和将日志输出到消息队列中。FileLogger和MessageQueueLogger 两个子类复用了父类 Logger 中的 name、enabled、minPermittedLevel 属性,以及log()方法,因为这两个子类输出日志的方式不同,所以它们又各自重写了父类中的 doLog()方法。
public abstract class Logger{
private String name;
private boolean enabled;
private evel minPermittedLevel;
public Logger(String name, boolean enabled, Level minPermittedLevel){
this.name = name;
this.enabled = enabled;
this.minPermittedLevel = minPermittedLevel;
}
public void log(Level level,String message){
boolean loggable = enabled && (minPermittedLevel.intValue()<=level.intValve());
if(!loggable)
return;
doLog(level,message);
}
protected abstract void doLog (Level level,String message);
}
//抽象类的子类:输出日志到文件
public class FileLogger extends Logger{
private Writer fileWriter;
public FileLogger(String name,boolean enabled.Ievel minPermittedLevel,String filepath)
{
super(name,enabled,minPermittedLevel);
this.fileWriter = new FileWriter(filepath);
}
@0verride
public void doLog(Level level,string mesage){
//格式化leve1和message,并输出到日志文件
fileWriter.write(...);
}
}
//抽象类的子类:输出日志到消息中间件(如Kafka)
public class MessageQueueLogger extends Logger{
private MessageQueueClient msgQueueclient;
public MessageQueueLogger(String name, boolean enabled,Level minPermittedLevel,MessageQueueClient msgQueueClient){
super(name,enabled,minPermittedLevel);
this.msgQueueClient = msgQueueClient;
}
@Override
protected void doLog(Level level,String mesage){
//格式化1evel和message,并输出到消息中间件
msgQueueClient.send(...);
}
}
结合上述示例,我们总结了下列抽象类的特点:
1)抽象类不允许被实例化,只能被继承。也就是说,我们不能通过关键字 new定义一个
抽象类的对象(编写“Logger logger=new Logger(..);”语句会报编译错误)。
2)抽象类可以包含属性和方法。方法可以包含代码实现(如Logger类中的log()方法)也可以不包含代码实现(如Logger 类中的 doLog()方法)。不包含代码实现的方法称为抽象方法。
3)子类继承抽象类时,必须实现抽象类中的所有抽象方法。对应到示例代码中,所有继承Logger 抽象类的子类都必须重写 doLog()方法。
上面是对抽象类的定义。接下来,我们看一下如何在Java 中定义接口。我们还是先看一段示例代码。
public interface Filter {
void doFilter(RpcRequest req)throws RpcException;
}
//接口实现类:鉴权过滤器
public class AuthencationFilter implements Filter {
@Override
public void doFilter(RpcRequest req)throws RpcException {
//省略鉴权逻辑...
}
}
//接口实现类:限流过滤器
public class RatelimitFilter implements Filter {
@Override
public void doFilter(RpcRequest req) throws RpcException {
//...省略限流逻辑
}
}
//过滤器使用示例
public class Application {
private list<Filter> filters = new ArrayList<>();
public Application(){
filters.add(new AuthencationFilter());
filters.add(new RatelimitFilter());
public void handleRpcRequest(RpcRequest req){
try{
for(Filter filter : filters ){
filter.doFilter(req);
}
}catch(RpcException e){
//..省略处理过滤结果.
}
}
//..省略其他处理逻辑.
}
}
上述代码是一个典型的接口使用场景。通过Java 中的 interface 关键字,我们定义了一个Filter 接口。AuthencationFilter 和RateLimitFiliter是接口的两个实现类,分别实现了对RPC求鉴权和限流。结合上述代码,我们总结了下列接口的特点:
1)接口不能包含属性(也就是成员变量)。
2)接口只能声明方法,方法不能包含代码实现。
3)类实现接口时,必须实现接口中声明的所有方法。
有些读者可能说,在Java 1.8版本之后,接口中的方法可以包含代码实现,并且接口可以包含静态成员变量。注意,这只不过是Java语言对接口定义的妥协,目的是方便使用。抛开Java 这一具体的编程语言,接口仍然具有上述3个特点。
在上文中,我们介绍了抽象类和接口的定义,以及各自的语法特性。从语法特性方面对比,抽象类和接口有较大的区别,如抽象类中可以定义属性、方法的实现,而接口中不能定义属性,方法也不能包含代码实现,等等。除语法特性以外,从设计的角度对比,二者也有较大的区别。
抽象类也属于类,只不过是一种特殊的类,这种类不能被实例化为对象,只能被子类继承。我们知道,继承关系是一种is-a关系,那么,抽象类既然属于类,也表示一种is-a关系。相比抽象类的is-a关系,接口表示一种has-a关系(或can-do关系、behave like 关系),表示具有某些功能。因此,接口有一个形象的叫法:协议(contract)。
3.抽象类和接口存在的意义
在上面我们介绍了抽象类和接口的定义与区别,现在我们探讨一下抽象类和接口存在的意义,以便读者知其然,知其所以然。
为什么需要抽象类?它能够在编程中解决什么问题?在上面我们讲到,抽象类不能被实例化,只能被继承。之前,我们还讲过,继承能够解决代码复用问题。因此,抽象类是为代码复用而生的。多个子类可以继承抽象类中定义的属性和方法,这样可以避免在子类中重复编写相同的代码。
既然继承就能达到代码复用的目的,而维承并不要求父类必须是抽象类,那么,不使用抽象类照样可以实现继承和复用。从这个角度来看,抽象类语法似乎是多余的。那么,除解决代码复用问题以外,抽象类还有其他存在的意义吗?
我们还是结合之前打印日志的示例代码进行讲解。不过,我们需要先对之前的代码进行改造。在改造之后,Logger不再是独象类。万法一个普通类。另外,我们删除了Logger类中的log()、doLog()方法,新增了isLoggable()方法,FileLogger类和 MessageQueueLogger 类仍级继承 Logger 类。具体代码如下:
//父类:Logger, 非抽象类就是普通类,删除了log()和doLog()方法,新增了 isLoggeable()方法
public abstract class Logger{
private String name;
private boolean enabled;
private evel minPermittedLevel;
public Logger(String name, boolean enabled, Level minPermittedLevel){
this.name = name;
this.enabled = enabled;
this.minPermittedLevel = minPermittedLevel;
}
protected boolean isLoggable(){
boolean loggable = enabled && (minPermittedLevel.intValue()<=level.intValve());
return loggable;
}
}
//抽象类的子类:输出日志到文件
public class FileLogger extends Logger{
private Writer fileWriter;
public FileLogger(String name,boolean enabled.Ievel minPermittedLevel,String filepath)
{
super(name,enabled,minPermittedLevel);
this.fileWriter = new FileWriter(filepath);
}
@0verride
public void log(Level level,string mesage){
if (!isLoggable())
return;
fileWriter.write(...);
}
}
//抽象类的子类:输出日志到消息中间件(如Kafka)
public class MessageQueueLogger extends Logger{
private MessageQueueClient msgQueueclient;
public MessageQueueLogger(String name, boolean enabled,Level minPermittedLevel,MessageQueueClient msgQueueClient){
super(name,enabled,minPermittedLevel);
this.msgQueueClient = msgQueueClient;
}
@Override
public void log(Level level,string mesage){
if (!isLoggable())
return;
msgQueueClient.send(...);
}
}
虽然上面这段代码的设计思路达到了代码复用的目的,但是无法使用多态特性。如果我们像下面这样编写代码,就会出现编译错误,因为Logger类中并没有定义log()方法。
Logger logger = new FileLogger("access-log",true, Level.WARN, "/users/wangzheng/access.log") ;
logger.log(Level,ERROR, "This is a test log message .");
读者可能会说,这个问题的解决很简单,在Logger类中,定义一个空的log()方法,让子类重写 Logger类的log()方法,并实现自己的日志输出逻辑,不就可以了吗?代码如下所示。
Public class Logger{
//...省略部分代码...
Public void log(Level level,string mesage){ //方法体为空}
}
public class FileLogger extends Logger{
//..省略部分代码..
@Override
public void log(Level level,String mesage){
if(!isLoggable())
return;
//格式化1evel和message,并输出到日志文件
filewriter.write(...);
}
}
public class MessageQueuelogger extends Logger{
//..省略部分代码..
@Override
public void log(Level level, string mesage){
if(!isLoggable())
return;
//格式化1evel和message,并输出到消息中间件
msgQueueClient.send(...);
}
}
虽然上面这段代码的设计思路可用,能够解决问题,但是,它显然没有之前基于抽象*。设计思路优雅,理由如下。
1)在Logger类中,定义一个空的方法,会影响代码的可读性。如果我们不熟悉Logger类背后的设计思想,加之代码的注释不详细,那么,在阅读Logger类的代码时,有可解生为什么定义一个空的log()方法的疑问。或许,我们需要通过查看Logger、FileLogger和MessageQueueLogger 之间的继承关系,才能明白其背后的设计意图。
2)当创建一个新的子类并继承Logger类时,我们很有可能忘记重新实现log()方法。前基于抽象类的设计思路,编译器会强制要求子类重写log()方法,否则会报编译错误。读者可能会问,既然要定义一个新的Logger 类的子类,那么怎么会忘记重新实现 log()方法呢?其实,我们举的例子比较简单,Logger类中的方法不多,代码行数也很少。我们可以想象一下如果Logger类中有几百行代码,包含很多方法,除非我们对Logger类的设计非常熟悉,否则极有可能忘记重新实现log()方法。
3)Logger类可以被实例化,换句话说,我们可以通过关键字new定义一个Logger 类的对象,并且调用它的空的log()方法。这增加了类被误用的风险。当然,这个问题可以通过设置私有的构造函数的方式来解决。不过,这显然没有基于抽象类的实现思路优雅。为什么需要接口?它能够在编程中解决什么问题?抽象类侧重代码复用,而接口侧重解耦。接口是对行为的一种抽象,相当于一组协议或契约,读者可以类比API。调用者只需要关注抽象的接口,不需要了解具体的实现,具体的实对调用者透明。接口实现了约定和实现分离,可以降低代码的耦合度,提高代码的可扩展性。
4.模拟实现抽象类和接口
有些编程语言只有抽象类,并没有接口,如C++。我们可以通过抽象类模拟接口,只要它满足接口的特性(接口中没有成员变量,只有方法声明,没有实现方法,实现接口的类必须实现接口中的所有方法)即可。在下面这段C++代码中,我们使用抽象类模拟了一个接口。
class Strategy{
//用抽象类模拟接口
public:
virtual ~Strategy();
virtual void algorithm() = 0,
protected:
Strategy();
}
抽象类 Strategy 没有定义任何属性,并且所有的方法都声明为virual(等同于 Java中的abstract 关键字)类型,这样,所有的方法都不能有代码实现,并且所有继承这个抽象类的子类都要实现这些方法。从语法特性上来看,这个抽象类就相当于一个接口。
不过,现在流行的动态编程语言,如Python、Ruby等,它们不但没有接口的概念,而且没有抽象类。在这种情况下,我们可以使用普通类模拟接口。具体的Java代码实现如下。
public class MockInterface{
protected MockInterface();
public void funcA(){
throw new MethodUnSupportedException();
}
}
我们知道,类中的方法必须包含实现,但这不符合接口的定义。其实,我们可以让类中的方法抛出 MethodUnSupportedException 异常来模拟不包含实现的接口,并且,在子类继承父类时,强迫子类主动实现父类的方法,否则会在运行时抛出异常。那么,如何避免这个类被实例化呢?我们只需要将构造函数设置成protected属性,这样就能避免非同一包(package)下的类去实例化 MockInterface。不过,这样做还是无法避免同一包下的类去实例化 MockInterface.为了解决这个问题,我们可以学习Google Guava中 @VisibleForTesting注解的做法,自定义个注解,人为地表明其不可实例化。
上面讲了如何用抽象类来模拟接口,以及如何用普通类来模拟接口,那么,如何用普通类来模拟抽象类呢?我们可以类比 MockInterface 类的处理方式,让本该为abstract的方法内部抛出MethodUnSupportedException异常,并且将构造函数设置为protected 属性,避免实例化。
5.抽象类和接口的应用场景
在真实的项目开发中,什么时候该用抽象类?什么时候该用接口?实际上,判断的标准很简单。如果我们要表示一种is-a关系,并且是为了解决代码复用的问题,那么使用抽象类;如果我们要表示一种 has-a关系,并且是为了解决抽象而非代码复用的问题,那么使用接口。
从类的继承层次上来看,抽象类是一种自下而上的设计思路,先有子类的代码重复,再抽象出上层的父类(也就是抽象类)。而接口正好相反,它是一种自上而下的设计思路。在编程开发时,一般先设计接口,再考虑具体的实现。