设计模式之美学习(六):接口vs抽象类的区别?如何用普通的类模拟抽象类和接口?

在面向对象编程中,抽象类和接口是两个常常被用到的语法概念,是面向对象四大特性,以及不少设计模式、设计思想、设计原则编程实现的基础。好比,咱们可使用接口来实现面向对象的抽象特性、多态特性和基于接口而非实现的设计原则,使用抽象类来实现面向对象的继承特性和模板设计模式等等。java

不过,并非全部的面向对象编程语言都支持这两个语法概念,好比,C++ 这种编程语言只支持抽象类,不支持接口;而像 Python 这样的动态编程语言,既不支持抽象类,也不支持接口。尽管有些编程语言没有提供现成的语法来支持接口和抽象类,咱们仍然能够经过一些手段来模拟实现这两个语法概念。c++

这两个语法概念不只在工做中常常会被用到,在面试中也常常被说起。好比,“接口和抽象类的区别是什么?何时用接口?何时用抽象类?抽象类和接口存在的意义是什么?能解决哪些编程问题?”等等。面试

什么是抽象类和接口?区别在哪里?

不一样的编程语言对接口和抽象类的定义方式可能有些差异,但差异并不会很大。编程

首先来看一下,在 Java 这种编程语言中,咱们是如何定义抽象类的。设计模式

下面这段代码是一个比较典型的抽象类的使用场景(模板设计模式)。Logger 是一个记录日志的抽象类,FileLoggerMessageQueueLogger 继承 Logger,分别实现两种不一样的日志记录方式:记录日志到文件中和记录日志到消息队列中。FileLoggerMessageQueueLogger 两个子类复用了父类 Logger 中的 nameenabledminPermittedLevel 属性和 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.writer = 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(...);
  }
}

经过上面的这个例子来看一下,抽象类具备哪些特性。ide

  • 抽象类不容许被实例化,只能被继承。也就是说,你不能 new 一个抽象类的对象出来(Logger logger = new Logger(…); 会报编译错误)。函数

  • 抽象类能够包含属性和方法。方法既能够包含代码实现(好比 Logger 中的 log() 方法),也能够不包含代码实现(好比 Logger 中的 doLog() 方法)。不包含代码实现的方法叫做抽象方法。this

  • 子类继承抽象类,必须实现抽象类中的全部抽象方法。对应到例子代码中就是,全部继承 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 {
    //...限流逻辑...
  }
}
// 过滤器使用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 : fitlers) {
        filter.doFilter(req);
      }
    } catch(RpcException e) {
      // ...处理过滤结果...
    }
    // ...省略其余处理逻辑...
  }
}

上面这段代码是一个比较典型的接口的使用场景。经过 Java 中的 interface 关键字定义了一个 Filter 接口。AuthencationFilterRateLimitFilter 是接口的两个实现类,分别实现了对 RPC 请求鉴权和限流的过滤功能。

代码很是简洁。结合代码再来看一下,接口都有哪些特性。

  • 接口不能包含属性(也就是成员变量)。

  • 接口只能声明方法,方法不能包含代码实现。

  • 类实现接口的时候,必须实现接口中声明的全部方法。

从语法特性上对比,这二者有比较大的区别,好比抽象类中能够定义属性、方法的实现,而接口中不能定义属性,方法也不能包含代码实现等等。除了语法特性,从设计的角度,二者也有比较大的区别。

抽象类实际上就是类,只不过是一种特殊的类,这种类不能被实例化为对象,只能被子类继承。咱们知道,继承关系是一种 is-a 的关系,那抽象类既然属于类,也表示一种 is-a 的关系。相对于抽象类的 is-a 关系来讲,接口表示一种 has-a 关系,表示具备某些功能。对于接口,有一个更加形象的叫法,那就是协议(contract)。

抽象类和接口能解决什么编程问题?

为何须要抽象类?它可以解决什么编程问题?

抽象类也是为代码复用而生的。多个子类能够继承抽象类中定义的属性和方法,避免在子类中,重复编写相同的代码。

不过,既然继承自己就能达到代码复用的目的,而继承也并不要求父类必定是抽象类,那咱们不使用抽象类,照样也能够实现继承和复用。从这个角度上来说,咱们貌似并不须要抽象类这种语法呀。那抽象类除了解决代码复用的问题,还有什么其余存在的意义吗?

仍是拿以前那个打印日志的例子。咱们先对上面的代码作下改造。在改造以后的代码中,Logger 再也不是抽象类,只是一个普通的父类,删除了 Loggerlog()doLog() 方法,新增了 isLoggable() 方法。FileLoggerMessageQueueLogger 仍是继承 Logger 父类,以达到代码复用的目的。具体的代码以下:

// 父类:非抽象类,就是普通的类. 删除了log(),doLog(),新增了isLoggable().
public class Logger {
  private String name;
  private boolean enabled;
  private Level minPermittedLevel;

  public Logger(String name, boolean enabled, Level minPermittedLevel) {
    //...构造函数不变,代码省略...
  }

  protected boolean isLoggable() {
    boolean loggable = enabled && (minPermittedLevel.intValue() <= level.intValue());
    return loggable;
  }
}
// 子类:输出日志到文件
public class FileLogger extends Logger {
  private Writer fileWriter;

  public FileLogger(String name, boolean enabled,
    Level minPermittedLevel, String filepath) {
    //...构造函数不变,代码省略...
  }
  
  public void log(Level level, String mesage) {
    if (!isLoggable()) return;
    // 格式化level和message,输出到日志文件
    fileWriter.write(...);
  }
}
// 子类: 输出日志到消息中间件(好比kafka)
public class MessageQueueLogger extends Logger {
  private MessageQueueClient msgQueueClient;
  
  public MessageQueueLogger(String name, boolean enabled,
    Level minPermittedLevel, MessageQueueClient msgQueueClient) {
    //...构造函数不变,代码省略...
  }
  
  public void log(Level level, String mesage) {
    if (!isLoggable()) return;
    // 格式化level和message,输出到消息中间件
    msgQueueClient.send(...);
  }
}

这个设计思路虽然达到了代码复用的目的,可是没法使用多态特性了。像下面这样编写代码,就会出现编译错误,由于 Logger 中并无定义 log() 方法。

Logger logger = new FileLogger("access-log", true, Level.WARN, "/users/muchen/access.log");
logger.log(Level.ERROR, "This is a test log message.");

你可能会说,这个问题解决起来很简单啊。在 Logger 父类中,定义一个空的 log() 方法,让子类重写父类的 log() 方法,实现本身的记录日志的逻辑,不就能够了吗?

public class Logger {
  // ...省略部分代码...
  public void log(Level level, String mesage) { // do nothing... }
}
public class FileLogger extends Logger {
  // ...省略部分代码...
  @Override
  public void log(Level level, String mesage) {
    if (!isLoggable()) return;
    // 格式化level和message,输出到日志文件
    fileWriter.write(...);
  }
}
public class MessageQueueLogger extends Logger {
  // ...省略部分代码...
  @Override
  public void log(Level level, String mesage) {
    if (!isLoggable()) return;
    // 格式化level和message,输出到消息中间件
    msgQueueClient.send(...);
  }
}

这个设计思路能用,可是,它显然没有以前经过抽象类的实现思路优雅。为何这么说呢?主要有如下几点缘由。

  • Logger 中定义一个空的方法,会影响代码的可读性。若是咱们不熟悉 Logger 背后的设计思想,代码注释又不怎么给力,咱们在阅读 Logger 代码的时候,就可能对为何定义一个空的 log() 方法而感到疑惑,须要查看 LoggerFileLoggerMessageQueueLogger 之间的继承关系,才能弄明白其设计意图。

  • 当建立一个新的子类继承 Logger 父类的时候,咱们有可能会忘记从新实现 log() 方法。以前基于抽象类的设计思路,编译器会强制要求子类重写 log() 方法,不然会报编译错误。你可能会说,我既然要定义一个新的 Logger 子类,怎么会忘记从新实现 log() 方法呢?咱们举的例子比较简单,Logger 中的方法很少,代码行数也不多。可是,若是 Logger 有几百行,有 n 多方法,除非你对 Logger 的设计很是熟悉,不然忘记从新实现 log() 方法,也不是不可能的。

  • Logger 能够被实例化,换句话说,咱们能够 new 一个 Logger 出来,而且调用空的 log() 方法。这也增长了类被误用的风险。固然,这个问题能够经过设置私有的构造函数的方式来解决。不过,显然没有经过抽象类来的优雅。

为何须要接口?它可以解决什么编程问题?

抽象类更多的是为了代码复用,而接口就更侧重于解耦。接口是对行为的一种抽象,至关于一组协议或者契约,你能够联想类比一下 API 接口。调用者只须要关注抽象的接口,不须要了解具体的实现,具体的实现代码对调用者透明。接口实现了约定和实现相分离,能够下降代码间的耦合性,提升代码的可扩展性。

实际上,接口是一个比抽象类应用更加普遍、更加剧要的知识点。好比,常常提到的“基于接口而非实现编程”,就是一条几乎每天会用到,而且能极大地提升代码的灵活性、扩展性的设计思想。

如何模拟抽象类和接口两个语法概念?

在前面举的例子中,使用 Java 的接口语法实现了一个 Filter 过滤器。不过,若是你熟悉的是 C++ 这种编程语言,你可能会说,C++ 只有抽象类,并无接口,那从代码实现的角度上来讲,是否是就没法实现 Filter 的设计思路了呢?

先来回忆一下接口的定义:接口中没有成员变量,只有方法声明,没有方法实现,实现接口的类必须实现接口中的全部方法。只要知足这样几点,从设计的角度上来讲,咱们就能够把它叫做接口。实际上,要知足接口的这些语法特性并不难。在下面这段 C++ 代码中,就用抽象类模拟了一个接口(下面这段代码其实是策略模式中的一段代码)。

class Strategy { // 用抽象类模拟接口
  public:
    ~Strategy();
    virtual void algorithm()=0;
  protected:
    Strategy();
};

抽象类 Strategy 没有定义任何属性,而且全部的方法都声明为 virtual 类型(等同于 Java 中的 abstract 关键字),这样,全部的方法都不能有代码实现,而且全部继承这个抽象类的子类,都要实现这些方法。从语法特性上来看,这个抽象类就至关于一个接口。

PythonRuby 这些动态语言中,不只没有接口的概念,也没有相似 abstractvirtual 这样的关键字来定义抽象类,那该如何实现上面的讲到的 FilterLogger 的设计思路呢?实际上,除了用抽象类来模拟接口以外,还能够用普通类来模拟接口。具体的 Java 代码实现以下所示。

public class MockInteface {
  protected MockInteface() {}
  public void funcA() {
    throw new MethodUnSupportedException();
  }
}

类中的方法必须包含实现,这个不符合接口的定义。可是,咱们可让类中的方法抛出 MethodUnSupportedException 异常,来模拟不包含实现的接口,而且能强迫子类在继承这个父类的时候,都去主动实现父类的方法,不然就会在运行时抛出异常。那又如何避免这个类被实例化呢?实际上很简单,咱们只须要将这个类的构造函数声明为 protected 访问权限就能够了。

如何决定该用抽象类仍是接口?

实际上,判断的标准很简单。若是咱们要表示一种 is-a 的关系,而且是为了解决代码复用的问题,咱们就用抽象类;若是咱们要表示一种 has-a 关系,而且是为了解决抽象而非代码复用的问题,那咱们就可使用接口。

从类的继承层次上来看,抽象类是一种自下而上的设计思路,先有子类的代码重复,而后再抽象成上层的父类(也就是抽象类)。而接口正好相反,它是一种自上而下的设计思路。咱们在编程的时候,通常都是先设计接口,再去考虑具体的实现。

重点回顾

1. 抽象类和接口的语法特性

抽象类不容许被实例化,只能被继承。它能够包含属性和方法。方法既能够包含代码实现,也能够不包含代码实现。不包含代码实现的方法叫做抽象方法。子类继承抽象类,必须实现抽象类中的全部抽象方法。接口不能包含属性,只能声明方法,方法不能包含代码实现。类实现接口的时候,必须实现接口中声明的全部方法。

2. 抽象类和接口存在的意义

抽象类是对成员变量和方法的抽象,是一种 is-a 关系,是为了解决代码复用问题。接口仅仅是对方法的抽象,是一种 has-a 关系,表示具备某一组行为特性,是为了解决解耦问题,隔离接口和具体的实现,提升代码的扩展性。

3. 抽象类和接口的应用场景区别

何时该用抽象类?何时该用接口?实际上,判断的标准很简单。若是要表示一种 is-a 的关系,而且是为了解决代码复用问题,咱们就用抽象类;若是要表示一种 has-a 关系,而且是为了解决抽象而非代码复用问题,那咱们就用接口。

思考

  • 接口和抽象类是两个常常在面试中被问到的概念。若是面试官再让你聊聊接口和抽象类,你会如何回答呢?

参考:接口vs抽象类的区别?如何用普通的类模拟抽象类和接口?

本文由博客一文多发平台 OpenWrite 发布! 更多内容请点击个人博客沐晨

相关文章
相关标签/搜索