接口 vs 抽象类

Demon.Lee 2023年09月17日 882次浏览

在软件编程中,我们都知道要面向接口(抽象)编程,而不是基于实现(具体)编程。这样做的好处之一,是代码具有较大的灵活性,而灵活性是应对需求不断变化的利器。

在 Java 编程体系中,有两种方式可以实现这种抽象:接口抽象类。那它们之间有哪些异同,这篇文章,笔者就来数一数。



语法

Java 基于 C++ 起家的,但比 C++ 要容易学一些,原因在于其摒弃了很多内容,比如多继承和指针等。在此基础上,Java 又引入了一些新内容,比如接口。我们可以认为这其实是一种平衡,单继承简单,但能力不足,所以让接口支持多继承。

抽象类代码示例:

// 日志抽象类
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 {
  @Override 
  public void doLog(Level level, String mesage) { 
    // 格式化level和message,输出到日志文件 
    fileWriter.write(...); 
  }
}

接口代码示例:

// 过滤器接口
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 {
    //...限流逻辑...
  }
}

(1)抽象类

  • 抽象类用 abstract 关键字修饰,可以定义方法和成员属性;
  • 抽象类中的方法可以实现,也可以不实现,如果不实现,则需要使用 abstract 关键字修饰,表示一个抽象方法;
  • 抽象类不可以被实例化,否则会出现编译错误;
  • 继承抽象类的子类可以是抽象类,也可以不是;如果子类不是抽象类,那必须实现抽象父类中所有的抽象方法。

(2)接口

  • 接口不能包含成员属性,如果定义了属性,默认就是 public static final 修饰;
  • Java 8 之前,方法只能定义,不能实现;
  • Java 8 增加了默认方法 default 和静态方法 static ,默认也由 public 修饰,而从 Java 9 开始,支持定义静态私有方法;
  • 类实现接口时,必须实现所有方法(Java 8 及之后的默认/静态方法除外),否则会出现编译错误。
public interface Foo {

    public static final Logger log = LogManager.getLogger(Foo.class);
    public static final String name = "abc";

    public void filter();

    // since Java 9
    private static String getName() {
        return name;
    }

    // since Java 8
    public static String getName1() {
        return name;
    }

    // since Java 8
    public default String getName2() {
        return getName();
    }

    public static void main(String[] args) {
        log.info("name1:" + Foo.getName1());

        Foo foo = () -> System.out.println("this is a filter...");

        // compile error: Static method may be invoked on containing interface class only
        // System.out.println("name: " + foo.getName1());

        log.info("name2: " + foo.getName2());
    }
}

抽象类就是稍微特殊一点的类,本质上与一般的类没有太大的差别,而接口则出入较大。从上面的代码示例可以看到,接口相对于抽象类来说,要简洁一些,这其实反映了它们在不同方面的侧重。

目标

(1)抽象类:侧重于代码复用,is-a 的关系

  • 抽象类中,会提取大部分子类的通用字段或通用方法实现,然后把子类(实现类)特殊的字段或方法下沉。这些下沉的特殊方法,可以使用 abstract 关键字修饰。 abstract 的出现,其实是在告诉阅读者(或使用者):使用这个类,你必须要做一些实现工作。
  • 抽象类的局限性在于:实现类必须对其忠心不二(至少 Java 语言是这样)。如果想换一个业务视角来看待这个类,可能就束手无策了。
  • 继承这种机制,一般我们观察的视角是从子类往上看,也就是实现继承,面向的是子类。通过实现继承进行代码复用,并不是值得鼓励的做法,因为这很大程度上是一种受限思维。编程语言提供了继承这种代码复用的方式,所以我们就得用?那没有继承的编程语言就不能实现代码复用了?显然不是。
  • 还有一种观察视角,就是从父类向下看,此时面向的是父类,也被称为接口继承。从接口继承来看, abstract 关键字体现的含义是多态。没错,就是把业务对象的差异描绘出来。
  • 对于一些与业务无关的通用逻辑,抽象后可能会出现单继承的窘境,为此就诞生了一些由静态方法组成的工具类(Utils),比如 java.util.Collections

(2)接口:侧重于表达某种(或某类)行为,has-a 的关系

  • 如果只想实现一个功能(或完成某个行为),而又不想关注任何细节,用接口来表达则比较合适。接口对行为进行抽象,是抽象方法的集合,将 API 的定义与实现解耦。一个类实现了几个接口,就表示它具备了哪几种能力。

  • 上面提到的接口继承,主要用于多态,因为多态想表达的正是:一个接口,多种形态。一个发出呱呱叫(quack)的方法,真鸭子可以叫,玩具鸭也可以。

    public interface Quackable{
      void quack();
    }
    
    public class Duck implements Quackable {
      @Override
      public void quack(){
        ...
      }
    }
    
    public class ToyDuck implements Quackable {
      @Override
      public void quack(){
        ...
      }
    }
    
  • 接口又被看作是协议,契约,这里面隐含着什么意思?既然是契约,就不能随意变动,为此就得将业务中变与不变的部分隔离开。不变的,就是接口约定的内容;变化的呢,那就由各个子类自己去实现,因为对系统影响最大的就是变化。而协议的另一个层面体现的则是边界概念,不同的模块,不同的系统,各自职责必须泾渭分明,而接口表达的就是这种边界。一个接口中定义的方法,谁是使用者,谁是实现者?如果你发现各种接口的定义都非常随意,这往往是有问题的。

  • 相较于抽象类,接口这个 Java 语言中的创新,去除了多继承的复杂性和二义性,但也曾被挑刺——扩展性。如果想在当前接口中新增一个方法,是不被鼓励的,因为这意味着所有实现类都得改,成本太高。不过,我们现在知道,这个问题已经解决——Java 8 可以新增 default 方法,不用一个个去改实现类了。其实我个人并不觉得,这是一个特别好的方案。接口应该小,越小越好,变与不变要区分,变化频率不同的也要区分。如果不能随意加方法,反而会迫使开发人员去思考:让实现类实现多个接口,还是新增一个接口继承原来的接口,还是不得不在原有接口上加方法?现在,通过默认方法可以随意增加,就有了滥用的可能,出现没人思考的尴尬局面。

  • 当然,接口的使用,除了多态,也有一些其他实践,比如 Marker Interface,即没有任何方法的接口。它们的主要目的就是为了声明某些东西,比如 java.lang.Cloneablejava.lang.Serializable 等。这不就是 Annotation 吗?的确如此。用接口的好处是简单直接,但表达能力上仍不如可以带各种参数的注解,所以很多人优先选择 Annotation

其实,抽象类也好,接口也罢,它们的本质是一样:不要基于实现编程,而要基于抽象(接口)编程。那我们何时用接口,何时用抽象类?

选择

除了数学,这个世界上没有标准答案。对于选择接口还是抽象类,Kent Beck 在《实现模式 (豆瓣) 》一书中提到:

取舍最终归结为两点:接口会如何变化,实现类是否需要同时支持多个接口。抽象接口需要支持实现的变化以及接口本身的变化两种类型的变化。

但它们并不是互斥的,你可以提供一个接口说“你可以使用这些功能”,再提供一个超类说“这是一种实现方式”。

大佬的观点出自十几年前,可能有一点点落后,但仍值得细细品味。接口自身的变化,笔者在上面已经讨论过。而同时使用接口和抽象类,则是一个很好的实践,相当于将二者的优点集结到了一起。比如 Spring 框架中的 BeanFactory 接口,大部分方法在 AbstractBeanFactory 这个抽象类中都有实现,而 AbstractBeanFactory 又有具体实现的子类。

public interface BeanFactory {
  ...
   Object getBean(String name) throws BeansException;
  ...
}

public abstract class AbstractBeanFactory extends FactoryBeanRegistrySupport implements ConfigurableBeanFactory {
  ...
	@Override
	public Object getBean(String name) throws BeansException {
		return doGetBean(name, null, null, false);
	}
  ...
}

public abstract class AbstractAutowireCapableBeanFactory extends AbstractBeanFactory
		implements AutowireCapableBeanFactory {
  ...
}

public class DefaultListableBeanFactory extends AbstractAutowireCapableBeanFactory
		implements ConfigurableListableBeanFactory, BeanDefinitionRegistry, Serializable {
  ...
}

一般来说,表示 is-a 关系的,考虑使用抽象类,解决代码复用问题;而表示 has-a 关系的,则考虑使用接口。

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

小结

接口是声明,是在表达做什么,而不是怎么做。从这个角度来说,抽象类其实已经暴露了细节。另外,能用组合就不要用继承。

继承给了所有继承体系内的对象一个约束,让它们有了统一的行为,而多态则让整个体系更好地应对未来的变化。

软件开发的过程,其实就是在考验我们的封装能力,抽象能力和接口意识。如何把这些能力练出来?可能还得在平时下功夫,积累于日常工作中的点滴思考。