【Java设计模式 面向对象设计思想】三 再谈抽象类和接口

抽象类和接口经常在面试中被拿来对比,面试官通常要求你讲出二者的区别,由此我们往往会将注意力都集中在区别之中,而对这两个概念为什么出现,面向对象为什么会设计这两个概念认知的深度其实不够,我之前的一篇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 关系,并且是为了解决抽象而非代码复用问题,那我们就用接口。总的来看,抽象类更像是介于普通类和接口直接,既可以代码复用,又可以优雅的实现多态。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

存在morning

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值