抽象类和接口经常在面试中被拿来对比,面试官通常要求你讲出二者的区别,由此我们往往会将注意力都集中在区别之中,而对这两个概念为什么出现,面向对象为什么会设计这两个概念认知的深度其实不够,我之前的一篇Blog:【Java SE基础 四】封装、继承、多态,里面从粗浅的角度提到了一些区别和联系,今天再次深入的厘清接口和抽象类为什么会有,分别应该在什么场景下使用。
接口和抽象类定义
面向对象编程中,抽象类和接口是两个经常被用到的语法概念,是面向对象四大特性,以及很多设计模式、设计思想、设计原则编程实现的基础。比如,我们可以使用接口来实现面向对象的抽象特性、多态特性和基于接口而非实现的设计原则,使用抽象类来实现面向对象的继承特性、抽象特性和模板设计模式
什么是抽象类
下面这段代码是一个比较典型的抽象类的使用场景(模板设计模式)。Logger 是一个记录日志的抽象类,FileLogger 和 MessageQueueLogger 继承 Logger,分别实现两种不同的日志记录方式:记录日志到文件中和记录日志到消息队列中。FileLogger 和 MessageQueueLogger 两个子类复用了父类 Logger 中的 name、enabled、minPermittedLevel 属性和 log() 方法,但因为这两个子类写日志的方式不同,它们又各自重写了父类中的 doLog() 方法
// 抽象类 public abstract class Logger { private String name; private boolean enabled; private Level 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.intValue()); 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, Level minPermittedLevel, String filepath) { super(name, enabled, minPermittedLevel); this.fileWriter = new FileWriter(filepath); } @Override public void doLog(Level level, String mesage) { // 格式化level和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) { // 格式化level和message,输出到消息中间件 msgQueueClient.send(...); } }
通过上面的这个例子,我们来看一下,抽象类具有哪些特性。
- 抽象类不允许被实例化,只能被继承。也就是说,你不能 new 一个抽象类的对象出来(
Logger logger = new Logger(...);
会报编译错误) - 抽象类可以包含属性和方法。方法既可以包含代码实现(比如 Logger 中的 log() 方法),也可以不包含代码实现(比如 Logger 中的 doLog() 方法)。不包含代码实现的方法叫作抽象方法。
- 非抽象子类继承抽象类,必须实现抽象类中的所有抽象方法。对应到例子代码中就是,所有继承 Logger 抽象类的子类,都必须重写 doLog() 方法。
以上是抽象类的核心特性,具体的一些修饰符设置可以参照之前的这篇blog:【Java SE基础 四】封装、继承、多态
什么是接口
再举一个例子,我们通过 Java 中的 interface 关键字定义一个 Filter 接口。AuthencationFilter 和 RateLimitFilter 是接口的两个实现类,分别实现了对 RPC 请求鉴权和限流的过滤功能
// 接口 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 { //...限流逻辑... } } // 过滤器使用Demo public class Application { // filters.add(new AuthencationFilter()); // filters.add(new RateLimitFilter()); private List<Filter> filters = new ArrayList<>(); public void handleRpcRequest(RpcRequest req) { try { for (Filter filter : filters) { filter.doFilter(req); } } catch(RpcException e) { // ...处理过滤结果... } // ...省略其他处理逻辑... } }
这里也总结一下接口的三个核心特性:
- 接口不能包含属性(也就是成员变量),只能有类变量public static final,但一般也不使用
- 接口只能声明方法,方法不能包含代码实现。即使1.8之后有默认方法default,但也并非接口设计的本意,只是为了兼容考虑
- 类实现接口的时候,必须实现接口中声明的所有方法。
那么接口和抽象类的区别是什么呢?
抽象类和接口的区别
接口和抽象类的区别语法层面上可以参照:【Java SE基础 四】封装、继承、多态,这里想说的重点是从它们产生的设计角度来看:抽象类实际上就是类,只不过是一种特殊的类,这种类不能被实例化为对象,只能被子类继承。我们知道,继承关系是一种 is-a 的关系,那抽象类既然属于类,也表示一种 is-a 的关系。相对于抽象类的 is-a 关系来说,接口表示一种 has-a 关系,表示具有某些功能。对于接口,有一个更加形象的叫法,那就是协议(contract),这个层面的区分才是接口和抽象类的本质区别
抽象类和接口存在的意义
我们追更溯源,从它们存在的意义来探究。
为什么需要抽象类
我们先来看看如果没有抽象类用普通类实现Logger会有什么问题,我们不再把doLog方法定义为抽象方法,而是一个空实现的成员方法。这样依然可以实现代码复用和多态特性
public class Logger { // ...省略部分代码... public void doLog(Level level, String mesage) { // do nothing... } } public class FileLogger extends Logger { // ...省略部分代码... @Override public void doLog(Level level, String mesage) { // 格式化level和message,输出到日志文件 fileWriter.write(...); } } public class MessageQueueLogger extends Logger { // ...省略部分代码... @Override protected void doLog(Level level, String mesage) { // 格式化level和message,输出到消息中间件 msgQueueClient.send(...); } }
但是存在以下问题:
- 在 Logger 中定义一个空的方法,会影响代码的可读性。如果我们不熟悉 Logger 背后的设计思想,代码注释又不怎么给力,我们在阅读 Logger 代码的时候,就可能对为什么定义一个空的 doLog() 方法而感到疑惑,需要查看 Logger、FileLogger、MessageQueueLogger 之间的继承关系,才能弄明白其设计意图。
- 当创建一个新的子类继承 Logger 父类的时候,我们有可能会忘记重新实现 doLog() 方法。之前基于抽象类的设计思路,编译器会强制要求子类重写 doLog() 方法,否则会报编译错误。你可能会说,我既然要定义一个新的 Logger 子类,怎么会忘记重新实现 log() 方法呢?我们举的例子比较简单,Logger 中的方法不多,代码行数也很少。但是,如果 Logger 有几百行,有 n 多方法,除非你对 Logger 的设计非常熟悉,否则忘记重新实现 log() 方法,也不是不可能的。
- Logger 可以被实例化,换句话说,我们可以 new 一个 Logger 出来,并且调用空的 doLog() 方法。这也增加了类被误用的风险。当然,这个问题可以通过设置私有的构造函数的方式来解决。不过,显然没有通过抽象类来的优雅。
基于以上原因,抽象类其实是有区别与普通类它独特的作用的,去掉无意义的方法实现、优雅的实现多态,以及保留继承的代码复用能力。总的来说抽象类是一种特殊的类,所以类具备的特性它都具备,这个就不详细展开,我们为什么要设计抽象类呢?结合抽象类的特性,它必须被子类继承才能实例化,所以要想使用抽象类一定会用到继承的特性,也就是解决代码复用的问题;子类要想实例化必须实现抽象方法,这样其实就起到一个强制子类都实现抽象方法的作用,如果某个类想管理强制所有子类都实现某个方法,那么这个类定义成抽象类,方法定义为抽象方法就能很方便的起到这个强制实现的作用,防止编写子类代码时忘记实现这个方法,而且还顺便实现了多态的特性。
为什么需要接口
抽象类更多的是为了代码复用,而接口就更侧重于解耦。接口是对行为的一种抽象,相当于一组协议或者契约,你可以联想类比一下 API 接口。调用者只需要关注抽象的接口,不需要了解具体的实现,具体的实现代码对调用者透明。接口实现了约定和实现相分离,可以降低代码间的耦合性,提高代码的可扩展性
抽象类和接口的应用场景
在要优雅实现多态的前提下,如果要表示一种 is-a 的关系,并且是为了解决代码复用问题,我们就用抽象类;如果要表示一种 has-a 关系,并且是为了解决抽象而非代码复用问题,那我们就用接口
总结一下
抽象类是对成员变量和方法的抽象,是一种 is-a 关系,是为了解决代码复用问题。接口仅仅是对方法的抽象,是一种 has-a 关系,表示具有某一组行为特性,是为了解决解耦问题,隔离接口和具体的实现,提高代码的扩展性。如果要表示一种 is-a 的关系,并且是为了解决代码复用问题,我们就用抽象类;如果要表示一种 has-a 关系,并且是为了解决抽象而非代码复用问题,那我们就用接口。总的来看,抽象类更像是介于普通类和接口直接,既可以代码复用,又可以优雅的实现多态。