1.什么是抽象类和接口?区别在哪里?

不同的编程语言对接口和抽象类的定义方式可能有些差别,但差别不会很大。Java这种编程语言,既支持抽象类,也支持接口,所以,为了让你对这两个语法概念有比较直观的认识,我们拿Java这种编程语言来举例讲解。

首先,我们看一下,在Java这种编程语言中,我们是如何定义抽象类的。

下面这段代码是一个比较典型的抽象类的使用场景(模板设计模式)。Logger是一个记录日志的类,FileLogger和MessageQueueLogger继承Logger,分别实现两种不同的日志记录方式:记录日志到文件中和记录日志到消息队列中。FIleLogger和MessageQueueLogger两个子类复用了父类Logger中的name,enable、minPermittedLevel属性和log()方法,但是因为这两个子类写日志的方式不同,它们又各自重写了父类的doLog()方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package alibaba;


import java.util.logging.Level;

//抽象类
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);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package alibaba;

import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;
import java.util.logging.Level;

public class FileLogger extends Logger{
    private Writer fileWriter;

    public FileLogger(String name, boolean enabled, Level minPermittedLevel, String filePath) throws IOException {
        super(name, enabled, minPermittedLevel);
        this.fileWriter = new FileWriter(filePath);
    }
    @Override
    public void doLog(Level level, String message) {
        //格式化level和message,输出到日志文件
    }
}

通过上面的这个例子,我们来看一下,抽象类具有哪些特性,总结为以下三点。

  • 抽象类不允许被实例化,只能被继承。也就是说,你不能new一个抽象类的对象出来(Logger logger = new Logger();会报编译错误)。
  • 抽象类可以包含属性和方法。方法既可以包含代码实现(比如Logger中的log方法),也可以不包含代码实现,比如Logger中的doLogger方法,不包含代码实现的方法叫做抽象方法
  • 子类继承抽象类,必须实现抽象类中的所有抽象方法。对应到例子代码终究是,所有继承logger抽象类的子类,都必须重写doLog()方法
  • 刚刚我们讲了如何定义抽象类,现在我们来看一下,在Java这种编程语言中,我们如何定义接口。

    1
    2
    3
    4
    
    //接口
    public interface Filter {
        void doFilter(RpcRequest req) throws Exception;
    }
    
    1
    2
    3
    4
    5
    6
    7
    
    //接口实现:鉴权过滤器
    public class AuthencationFilter implements Filter {
        @Override
        public void doFilter(RpcRequest req) throws RpcException {
            //...鉴权逻辑..
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    
    //接口实现:限流过滤器
    public class RateLimitFilter implements Filter {
        @Override
        public void doFilter(RpcRequest req) throws RpcException {
            //...限流处理...
        }
    }
    

    上面这段代码是一个比较典型的接口的使用场景。我们通过Java中的Interface关键字定义来了一个Filter接口。AuthencationFilter和RateLimitFilter是接口的两个实现类,分别实现了对RPC请求鉴权和限流的过滤功能。代码非常简洁。结合代码,我们再来看一下,接口都有哪些特性,总结了三点:

  • 接口不能包含属性(也就是成员变量)
  • 接口只能声明方法,方法不能包含代码实现。
  • 类实现接口的时候,必须实现接口中声明的所有方法
  • 从语法特性上对比,这两者有比较大的区别,比如抽象类中可以定义属性、方法的实现,而接口中不能定义属性,方法也不能包含代码实现等等。除了语法特性,从设计角度,两者也有比较大的区别。

    抽象类实际上就是类,只不过是一种特殊的类,这种类不能被实例化为对象,只能被子类继承。我们知道,继承关系是一种is-a的关系,那抽象类既然属于类,也表示一种is-a的关系。相当于抽象类的is-a关系来说,接口表示一种has-a关系,表示具有某些功能。对于接口,有一个更加形象的叫法,那就是协议(contract)

    2.接口和抽象类能解决什么编程问题?

    抽象类不能实例化,只能被继承。继承能解决代码复用的问题。所以,抽象类也是为代码复用而生的。多个子类可以继承抽象类中定义的属性和方法,避免在子类中,重复便携相同的代码。

    不过,既然继承本身就能达到代码复用的目的,而继承也并不要求父类一定是抽象类,那我们不使用抽象类,照样也可以实现继承和复用,从这个角度上来讲,我们貌似并不需要抽象类这种语法啊。那抽象类除了解决代码复用的问题,还有什么其他存在的意义吗?

    我们还是拿之前那个打印日志的例子来说明。我们先对上面的代码做了下改造。在改造之后的代码中,Logger不再是抽象类,只是一个普通的类,删除了Logger中的og()、doLog()方法,新增了isLoggable()方法。FileLogger和MessageQueueLogger还是继承Logger父类,以达到代码复用的目的,具体代码如下:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    
    //父类:非抽象类,就是普通的类,删除了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(){
                boolean loggable = enabled && (minPermittedLevel.intValue <= level.intValue());
                return loggable;
            }
        }
    
        //子类:输出日志到文件
        public class FileLogger extends Logger {
            private Write 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(...);
            }
        }
    }
    

    这个设计思路虽然达到了代码复用的目的,但是无法使用多态特性看。像下面这样编写代码,就会出现编译错误,in为Logger中没有定义log()方法。

    1
    2
    
    Logger logger = new FileLogger("access-log", true, Level.WARN, "/users/wangzheng/access.log");
    logger.log(level.ERROR, "This is a test log message.");
    

    你可能会说,这个问题解决起来简单啊。我们在Logger父类中,定义一个空的log()方法,让子类重写父类的log()方法,实现自己记录日志的逻辑,不就可以了吗?

    这个设计思路能用,但是,它显然没有之前通过抽象类的实现思路优雅。我们为什么这么说呢?主要有以下几点原因。

  • 在Logger中定义一个空的方法,会影响代码的可读性。如果我们不熟悉Logger背后的设计思想,代码注释又不怎么给力,我们在阅读Logger代码的时候,就可能对为什么定义一个空的log()方法而感到疑惑,需要查Logger、FIleLogger、MessageQueueLogger之间的继承关系,才能弄明白其中设计意图。
  • 当创建一个新的子类继承Logger父类的时候,我们有可能会忘记重新实现log()方法。之前基于抽象类的设计思路,编译器会强制要求子类重写log()方法,否则会报编译错误。你可能会说,我既要定义一个新的Logger子类,怎么会忘记重新实现log()方法呢?我们举的例子比较简单,Logger中的方法不多,代码行数也很少。但是,如果Logger有几巴掌,有n多种方法,除非你对Logger的设计非常熟悉,否则忘记重新实现log()方法,也不是不可能的。
  • Logger可以被实例化,换句话说,我们可以New一个Logger出来,并且调用空的log()方法。这也增加了类被误用的风险。当然,这个问题可以通过设置私有的构造函数的方式来解决。不过,显然没有通过抽象类来的优雅。
  • 其次我们再来看一下,我们为什么需要接口?他能够解决什么问题?

    抽象类更多的是为了代码复用,而接口就更侧重于解耦。接口是对行为的一种抽象,相当于一组协议或者契约,你可以联想类比以下API接口。调用者只需要关注抽象的接口,不需要了解具体的实现。具体的实现代码对调用者透明。接口实现了约定和实现相分离,可以降低代码间的耦合性,提高代码的可扩展性。