理论五:接口vs抽象类的区别?如何用普通的类模拟抽象类和接口?
什么是接口和抽象类?区别是什么?
抽象类
In Java, 看一个例子。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(...); } }
抽象类的特点
- 抽象类不允许被实例化,只能被继承。
- 抽象类可以包含属性和方法,方法既可以包含实现,也可以不包含(抽象方法)
- 子类继承抽象类,必须重写抽象类中的抽象方法。例子中就是Logger类的doLog()方法。
接口
一个经典使用场景:Filter
// 接口 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) { // ...处理过滤结果... } // ...省略其他处理逻辑... } }
这是一个比较典型的接口使用场景。我们通过Java中的interface定义了一个Filter接口,AuthenticaionFilter和RateLimitFilter是接口的两个实现类,分别实现了对RPC的请求及安全和限流的过滤功能。
- 接口不能包含属性(成员变量);
- 接口只能声明方法,方法不能包含实现;
- 类实现接口的时候,必须实现接口声明的所有方法。
区别
从语法特性上来说,他们有一定的区别:抽象类中可以定义属性和方法的实现,而接口却不可。从设计上,抽象类与其子类是is-a的关系;而接口表示一种has-a的关系,表示具有某些功能。(也有人建议接口的关系是behave like)
解决的问题
-
继承本身也可以实现代码的复用,为什么还需要抽象类呢?
-
举例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(...); } }
-
代码复用达到了,但是没有多态了,大家都一样的方法和变量。那么我们在父类写一个空方法doLog()然后在子类重写他呢?
-
可以,不如之前的抽象类优雅。
- 空方法影响代码的可读性,如果没有注释,或者不了解子类Logger和父类的关系,就不太好知道这个空方法是在做什么;
- 可能会忘记实现空方法,导致后续调用的时候产生不预期的结果。
- Logger可以被实例化,空方法也可以被调用,同样空方法被调用后也会产生不预期结果。(这个点可以通过设置私有构造函数解决)。
-
接口侧重于解耦。调用者只需关注接口,不需了解实现。降低代码的耦合性。
如何模拟抽象类和接口?
-
只要不定义成员变量,没有方法实现,使用接口的类必须实现所有方法,这样就可以称为接口。
-
在C++中,定义虚拟方法,不定义变量,也可实现接口。
-
在普通类中,比如主动在方法中抛出异常,迫使实现(子类)类中重写。
-
包外部可能会实例化这个类-> 使用protected关键字修饰
-
包内部还是会实例化这个类-> 参照Guava中@VisibleForTesting注解的做法,自定义一个注解,人为表明不可实例化。
@VisibleForTesting
会将方法的private修饰符改成protected;
-
-
如何决定该使用哪个?
就看你要实现的关系是is-a还是has-a。
接口是自下向上,先设计接口(行为)再去考虑具体实现;抽象类是自顶向下,先设计父类的“形象”,再去继承实现。
理论六:为什么基于接口而非实现编程?有必要为每个类都定义接口吗?
如何解读原则中的“接口”
- 不要局限于某个编程语言(例如不是专指Java中的
interface
) - 本质上是一组协议或者约定(服务端和客户端的接口,或通信协议中的接口,编程中的接口)
- 区别于其它接口,编程中的接口可以理解为接口或者抽象类。
- 接口与实现分离, 封装实现后,只暴露稳定接口。上游系统面向接口编程,不依赖不稳定的细节。当实现发生变化,依赖的代码几乎不需要改动,降低了耦合,提升了扩展性。
- 另一个表述方式是“基于抽象编程”。
- 软件开发中,需求不断变化或者迭代。
- 越抽象,越顶层,能脱离某一具体实现,越可以提升灵活性,应对变化。
- 好的代码,要适当考虑未来的变化以及应对。
如何应用到实战中
假设我们的系统中有很多涉及图片处理和存储的业务逻辑。图片经过处理之后被上传到阿里云上。为了代码复用,我们封装了图片存储相关的代码逻辑,提供了一个统一的 AliyunImageStore 类,供整个系统来使用。具体的代码实现如下所示:
public class AliyunImageStore { //...省略属性、构造函数等... public void createBucketIfNotExisting(String bucketName) { // ...创建bucket代码逻辑... // ...失败会抛出异常.. } public String generateAccessToken() { // ...根据accesskey/secrectkey等生成access token } public String uploadToAliyun(Image image, String bucketName, String accessToken) { //...上传图片到阿里云... //...返回图片存储在阿里云上的地址(url)... } public Image downloadFromAliyun(String url, String accessToken) { //...从阿里云下载图片... } } // AliyunImageStore类的使用举例 public class ImageProcessingJob { private static final String BUCKET_NAME = "ai_images_bucket"; //...省略其他无关代码... public void process() { Image image = ...; //处理图片,并封装为Image对象 AliyunImageStore imageStore = new AliyunImageStore(/*省略参数*/); imageStore.createBucketIfNotExisting(BUCKET_NAME); String accessToken = imageStore.generateAccessToken(); imagestore.uploadToAliyun(image, BUCKET_NAME, accessToken); } }
-
整个上传流程包含三个步骤:创建 bucket(你可以简单理解为存储目录)、生成 access token 访问凭证、携带 access token 上传图片到指定的 bucket 中。代码实现非常简单,类中的几个方法定义得都很干净,用起来也很清晰,乍看起来没有太大问题,完全能满足我们将图片存储在阿里云的业务需求。
-
时过境迁,我们自建私有云,并将服务迁移到自己的云上。那我们的代码该如何修改?
- Option1: 我们新建一个
PrivateImageStore
类,全局替换AliyunImageStore
类对象,然后还需要做一些其他的改动。例如我们要实现AliyunImageStore中所有的公有方法。- 问题在于:1. 有的方法暴露了实现细节,比如uploadToAliyun() 和 downloadFromAliyun()。再照搬到新的类中显然已经不太合适。但是如果要修改方法名称,全局中所有使用到这两个方法的地方都要改。2. 存储流程可能私有云和公有云并不一致。例如私有因不需要access token,那其实不需要照抄generateAccessToken这个方法。那改到私有云,整个流程中用到这个方法的地方也都要调整。
- 如何解决这个问题?根本解决方法就是基于接口编程。
- 函数命名不要暴露实现细节。
- 封装具体的实现细节。比如跟阿里云相关的特殊上传下载流程不应该暴露给调用者。对外只提供包含所有上传或者下载细节的方法以供调用者使用。
- 为实现类提供统一抽象的接口。不依赖具体实现类。
- Option1: 我们新建一个
-
重构之后:
public interface ImageStore { String upload(Image image, String bucketName); Image download(String url); } public class AliyunImageStore implements ImageStore { //...省略属性、构造函数等... public String upload(Image image, String bucketName) { createBucketIfNotExisting(bucketName); String accessToken = generateAccessToken(); //...上传图片到阿里云... //...返回图片在阿里云上的地址(url)... } public Image download(String url) { String accessToken = generateAccessToken(); //...从阿里云下载图片... } private void createBucketIfNotExisting(String bucketName) { // ...创建bucket... // ...失败会抛出异常.. } private String generateAccessToken() { // ...根据accesskey/secrectkey等生成access token } } // 上传下载流程改变:私有云不需要支持access token public class PrivateImageStore implements ImageStore { public String upload(Image image, String bucketName) { createBucketIfNotExisting(bucketName); //...上传图片到私有云... //...返回图片的url... } public Image download(String url) { //...从私有云下载图片... } private void createBucketIfNotExisting(String bucketName) { // ...创建bucket... // ...失败会抛出异常.. } } // ImageStore的使用举例 public class ImageProcessingJob { private static final String BUCKET_NAME = "ai_images_bucket"; //...省略其他无关代码... public void process() { Image image = ...;//处理图片,并封装为Image对象 ImageStore imageStore = new PrivateImageStore(...); imagestore.upload(image, BUCKET_NAME); } }
-
要避免一种思维:希望通过实现类来反推接口。这会导致接口不够抽象,依赖具体实现,这样的话基于接口的编程没有意义了。要辩证的看这个思路,不要一概照搬实现类中的方法。
-
接口只定义应该做什么,不是怎么做。
是否要为每个类定义接口
-
尺度,不要过度设计,徒增复杂度和负担。
-
从设计初衷来看:
将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性。
如果一个功能只有一种实现方式,且为了也不会被替换,那就没必要设计接口。