在软件编程中,我们都知道要面向接口(抽象)编程,而不是基于实现(具体)编程。这样做的好处之一,是代码具有较大的灵活性,而灵活性是应对需求不断变化的利器。
在 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.Cloneable
,java.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
关系的,则考虑使用接口。
从类的继承层次上来看,抽象类是一种自下而上的设计思路,先有子类的代码重复,然后再抽象成上层的父类(也就是抽象类)。而接口正好相反,它是一种自上而下的设计思路。我们在编程的时候,一般都是先设计接口,再去考虑具体的实现。
小结
接口是声明,是在表达做什么,而不是怎么做。从这个角度来说,抽象类其实已经暴露了细节。另外,能用组合就不要用继承。
继承给了所有继承体系内的对象一个约束,让它们有了统一的行为,而多态则让整个体系更好地应对未来的变化。
软件开发的过程,其实就是在考验我们的封装能力,抽象能力和接口意识。如何把这些能力练出来?可能还得在平时下功夫,积累于日常工作中的点滴思考。