CategoryResourceRepost/极客时间专栏/设计模式之美/设计原则与思想:面向对象/08 | 理论五:接口vs抽象类的区别?如何用普通的类模拟抽象类和接口?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

312 lines
19 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="08 | 理论五接口vs抽象类的区别如何用普通的类模拟抽象类和接口" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f9/a7/f9e4a9f08f84083c4b03f5420a7a9ba7.mp3"></audio>
在面向对象编程中,抽象类和接口是两个经常被用到的语法概念,是面向对象四大特性,以及很多设计模式、设计思想、设计原则编程实现的基础。比如,我们可以使用接口来实现面向对象的抽象特性、多态特性和基于接口而非实现的设计原则,使用抽象类来实现面向对象的继承特性和模板设计模式等等。
不过并不是所有的面向对象编程语言都支持这两个语法概念比如C++这种编程语言只支持抽象类不支持接口而像Python这样的动态编程语言既不支持抽象类也不支持接口。尽管有些编程语言没有提供现成的语法来支持接口和抽象类我们仍然可以通过一些手段来模拟实现这两个语法概念。
这两个语法概念不仅在工作中经常会被用到,在面试中也经常被提及。比如,“接口和抽象类的区别是什么?什么时候用接口?什么时候用抽象类?抽象类和接口存在的意义是什么?能解决哪些编程问题?”等等。
你可以先试着回答一下,刚刚我提出的几个问题。如果你对某些问题还有些模糊不清,那也没关系,今天,我会带你把这几个问题彻底搞清楚。下面我们就一起来看!
## 什么是抽象类和接口?区别在哪里?
不同的编程语言对接口和抽象类的定义方式可能有些差别但差别并不会很大。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 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 &amp;&amp; (minPermittedLevel.intValue() &lt;= 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()方法。
**刚刚我们讲了如何定义抽象类现在我们再来看一下在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&lt;Filter&gt; filters = new ArrayList&lt;&gt;();
public void handleRpcRequest(RpcRequest req) {
try {
for (Filter filter : filters) {
filter.doFilter(req);
}
} catch(RpcException e) {
// ...处理过滤结果...
}
// ...省略其他处理逻辑...
}
}
```
上面这段代码是一个比较典型的接口的使用场景。我们通过Java中的interface关键字定义了一个Filter接口。AuthencationFilter和RateLimitFilter是接口的两个实现类分别实现了对RPC请求鉴权和限流的过滤功能。
代码非常简洁。结合代码,我们再来看一下,接口都有哪些特性。我也总结了三点。
- 接口不能包含属性(也就是成员变量)。
- 接口只能声明方法,方法不能包含代码实现。
- 类实现接口的时候,必须实现接口中声明的所有方法。
前面我们讲了抽象类和接口的定义,以及各自的语法特性。从语法特性上对比,这两者有比较大的区别,比如抽象类中可以定义属性、方法的实现,而接口中不能定义属性,方法也不能包含代码实现等等。除了语法特性,从设计的角度,两者也有比较大的区别。
抽象类实际上就是类只不过是一种特殊的类这种类不能被实例化为对象只能被子类继承。我们知道继承关系是一种is-a的关系那抽象类既然属于类也表示一种is-a的关系。相对于抽象类的is-a关系来说接口表示一种has-a关系表示具有某些功能。对于接口有一个更加形象的叫法那就是协议contract
## 抽象类和接口能解决什么编程问题?
刚刚我们学习了抽象类和接口的定义和区别,现在我们再来学习一下,抽象类和接口存在的意义,让你知其然知其所以然。
**首先,我们来看一下,我们为什么需要抽象类?它能够解决什么编程问题?**
刚刚我们讲到,抽象类不能实例化,只能被继承。而前面的章节中,我们还讲到,继承能解决代码复用的问题。所以,抽象类也是为代码复用而生的。多个子类可以继承抽象类中定义的属性和方法,避免在子类中,重复编写相同的代码。
不过,既然继承本身就能达到代码复用的目的,而继承也并不要求父类一定是抽象类,那我们不使用抽象类,照样也可以实现继承和复用。从这个角度上来讲,我们貌似并不需要抽象类这种语法呀。那抽象类除了解决代码复用的问题,还有什么其他存在的意义吗?
我们还是拿之前那个打印日志的例子来讲解。我们先对上面的代码做下改造。在改造之后的代码中Logger不再是抽象类只是一个普通的父类删除了Logger中log()、doLog()方法新增了isLoggable()方法。FileLogger和MessageQueueLogger还是继承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 &amp;&amp; (minPermittedLevel.intValue() &lt;= 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(&quot;access-log&quot;, true, Level.WARN, &quot;/users/wangzheng/access.log&quot;);
logger.log(Level.ERROR, &quot;This is a test log message.&quot;);
```
你可能会说这个问题解决起来很简单啊。我们在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()方法而感到疑惑需要查看Logger、FileLogger、MessageQueueLogger之间的继承关系才能弄明白其设计意图。
- 当创建一个新的子类继承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关键字这样所有的方法都不能有代码实现并且所有继承这个抽象类的子类都要实现这些方法。从语法特性上来看这个抽象类就相当于一个接口。
不过如果你熟悉的既不是Java也不是C++而是现在比较流行的动态编程语言比如Python、Ruby等你可能还会有疑问在这些动态语言中不仅没有接口的概念也没有类似abstract、virtual这样的关键字来定义抽象类那该如何实现上面的讲到的Filter、Logger的设计思路呢实际上除了用抽象类来模拟接口之外我们还可以用普通类来模拟接口。具体的Java代码实现如下所示。
```
public class MockInteface {
protected MockInteface() {}
public void funcA() {
throw new MethodUnSupportedException();
}
}
```
我们知道类中的方法必须包含实现这个不符合接口的定义。但是我们可以让类中的方法抛出MethodUnSupportedException异常来模拟不包含实现的接口并且能强迫子类在继承这个父类的时候都去主动实现父类的方法否则就会在运行时抛出异常。我们将构造函数设置成protected属性的这样就能避免非同包下的类去实例化MockInterface。不过这样还是无法避免同包中的类去实例化MockInterface。为了解决这个问题我们可以学习Google Guava中@VisibleForTesting注解的做法自定义一个注解人为表明不可实例化。
刚刚我们讲了如何用抽象类来模拟接口,以及如何用普通类来模拟接口,那如何用普通类来模拟抽象类呢?这个问题留给你自己思考,你可以留言说说你的实现方法。
实际上对于动态编程语言来说还有一种对接口支持的策略那就是duck-typing。我们在上一节课中讲到多态的时候也有讲过你可以再回忆一下。
## 如何决定该用抽象类还是接口?
刚刚的讲解可能有些偏理论,现在,我们就从真实项目开发的角度来看一下,在代码设计、编程开发的时候,什么时候该用抽象类?什么时候该用接口?
实际上判断的标准很简单。如果我们要表示一种is-a的关系并且是为了解决代码复用的问题我们就用抽象类如果我们要表示一种has-a关系并且是为了解决抽象而非代码复用的问题那我们就可以使用接口。
从类的继承层次上来看,抽象类是一种自下而上的设计思路,先有子类的代码重复,然后再抽象成上层的父类(也就是抽象类)。而接口正好相反,它是一种自上而下的设计思路。我们在编程的时候,一般都是先设计接口,再去考虑具体的实现。
## 重点回顾
好了,今天内容就讲完了,我们一块来总结回顾一下,你需要掌握的重点内容。
**1.抽象类和接口的语法特性**
抽象类不允许被实例化,只能被继承。它可以包含属性和方法。方法既可以包含代码实现,也可以不包含代码实现。不包含代码实现的方法叫作抽象方法。子类继承抽象类,必须实现抽象类中的所有抽象方法。接口不能包含属性,只能声明方法,方法不能包含代码实现。类实现接口的时候,必须实现接口中声明的所有方法。
**2.抽象类和接口存在的意义**
抽象类是对成员变量和方法的抽象是一种is-a关系是为了解决代码复用问题。接口仅仅是对方法的抽象是一种has-a关系表示具有某一组行为特性是为了解决解耦问题隔离接口和具体的实现提高代码的扩展性。
**3.抽象类和接口的应用场景区别**
什么时候该用抽象类什么时候该用接口实际上判断的标准很简单。如果要表示一种is-a的关系并且是为了解决代码复用问题我们就用抽象类如果要表示一种has-a关系并且是为了解决抽象而非代码复用问题那我们就用接口。
## 课堂讨论
1. 你熟悉的编程语言,是否有现成的语法支持接口和抽象类呢?具体是如何定义的呢?
1. 前面我们提到,接口和抽象类是两个经常在面试中被问到的概念。学习完今天的内容之后,你是否对抽象类和接口有一个新的认识呢?如果面试官再让你聊聊接口和抽象类,你会如何回答呢?
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。