10k

设计模式之美-课程笔记3-接口与抽象类

理论五:接口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)

解决的问题

  1. 继承本身也可以实现代码的复用,为什么还需要抽象类呢?

  2. 举例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(...);
  }
}
  1. 代码复用达到了,但是没有多态了,大家都一样的方法和变量。那么我们在父类写一个空方法doLog()然后在子类重写他呢?

  2. 可以,不如之前的抽象类优雅。

    1. 空方法影响代码的可读性,如果没有注释,或者不了解子类Logger和父类的关系,就不太好知道这个空方法是在做什么;
    2. 可能会忘记实现空方法,导致后续调用的时候产生不预期的结果。
    3. Logger可以被实例化,空方法也可以被调用,同样空方法被调用后也会产生不预期结果。(这个点可以通过设置私有构造函数解决)。
  3. 接口侧重于解耦。调用者只需关注接口,不需了解实现。降低代码的耦合性。

如何模拟抽象类和接口?

  1. 只要不定义成员变量,没有方法实现,使用接口的类必须实现所有方法,这样就可以称为接口。

    1. 在C++中,定义虚拟方法,不定义变量,也可实现接口。

    2. 在普通类中,比如主动在方法中抛出异常,迫使实现(子类)类中重写。

      1. 包外部可能会实例化这个类-> 使用protected关键字修饰

      2. 包内部还是会实例化这个类-> 参照Guava中@VisibleForTesting注解的做法,自定义一个注解,人为表明不可实例化。

        @VisibleForTesting会将方法的private修饰符改成protected;

如何决定该使用哪个?

就看你要实现的关系是is-a还是has-a。

接口是自下向上,先设计接口(行为)再去考虑具体实现;抽象类是自顶向下,先设计父类的“形象”,再去继承实现。

理论六:为什么基于接口而非实现编程?有必要为每个类都定义接口吗?

如何解读原则中的“接口”

  1. 不要局限于某个编程语言(例如不是专指Java中的interface
  2. 本质上是一组协议或者约定(服务端和客户端的接口,或通信协议中的接口,编程中的接口)
  3. 区别于其它接口,编程中的接口可以理解为接口或者抽象类
  4. 接口与实现分离, 封装实现后,只暴露稳定接口。上游系统面向接口编程,不依赖不稳定的细节。当实现发生变化,依赖的代码几乎不需要改动,降低了耦合,提升了扩展性。
  5. 另一个表述方式是“基于抽象编程”。
    1. 软件开发中,需求不断变化或者迭代。
    2. 越抽象,越顶层,能脱离某一具体实现,越可以提升灵活性,应对变化。
    3. 好的代码,要适当考虑未来的变化以及应对。

如何应用到实战中

假设我们的系统中有很多涉及图片处理和存储的业务逻辑。图片经过处理之后被上传到阿里云上。为了代码复用,我们封装了图片存储相关的代码逻辑,提供了一个统一的 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);
  }
  
}
  1. 整个上传流程包含三个步骤:创建 bucket(你可以简单理解为存储目录)、生成 access token 访问凭证、携带 access token 上传图片到指定的 bucket 中。代码实现非常简单,类中的几个方法定义得都很干净,用起来也很清晰,乍看起来没有太大问题,完全能满足我们将图片存储在阿里云的业务需求。

  2. 时过境迁,我们自建私有云,并将服务迁移到自己的云上。那我们的代码该如何修改?

    1. Option1: 我们新建一个PrivateImageStore类,全局替换AliyunImageStore类对象,然后还需要做一些其他的改动。例如我们要实现AliyunImageStore中所有的公有方法。
      1. 问题在于:1. 有的方法暴露了实现细节,比如uploadToAliyun() 和 downloadFromAliyun()。再照搬到新的类中显然已经不太合适。但是如果要修改方法名称,全局中所有使用到这两个方法的地方都要改。2. 存储流程可能私有云和公有云并不一致。例如私有因不需要access token,那其实不需要照抄generateAccessToken这个方法。那改到私有云,整个流程中用到这个方法的地方也都要调整。
    2. 如何解决这个问题?根本解决方法就是基于接口编程。
      1. 函数命名不要暴露实现细节。
      2. 封装具体的实现细节。比如跟阿里云相关的特殊上传下载流程不应该暴露给调用者。对外只提供包含所有上传或者下载细节的方法以供调用者使用。
      3. 为实现类提供统一抽象的接口。不依赖具体实现类。
  3. 重构之后:

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);
  }
}
  1. 要避免一种思维:希望通过实现类来反推接口。这会导致接口不够抽象,依赖具体实现,这样的话基于接口的编程没有意义了。要辩证的看这个思路,不要一概照搬实现类中的方法。

  2. 接口只定义应该做什么,不是怎么做。

是否要为每个类定义接口

  1. 尺度,不要过度设计,徒增复杂度和负担。

  2. 从设计初衷来看:

    将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性。

    如果一个功能只有一种实现方式,且为了也不会被替换,那就没必要设计接口。

Thoughts? Leave a comment