4、接口 VS 抽象类

接口和抽象类的区别是什么?什么时候用接口?什么时候用抽象类?抽象类和接口存在的意义是什么?能解决哪些编程问题?

1、抽象类和接口

1.1、抽象类

下面这段代码是一个比较典型的抽象类的使用场景(模板设计模式)

  • Logger 是一个记录日志的抽象类
  • FileLogger 和 MessageQueueLogger 继承 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 message) {
        // 格式化 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 message) {
        // 格式化 level 和 message, 输出到消息中间件
        msgQueueClient.send(...);
    }
}

通过上面的这个例子,我们来看一下抽象类具有哪些特性

  • 抽象类不允许被实例化,只能被继承,也就是说:你不能 new 一个抽象类的对象出来(Logger logger = new Logger(...); 会报编译错误)
  • 抽象类可以包含属性和方法,方法既可以包含代码实现(比如 Logger 中的 log() 方法),也可以不包含代码实现(比如 Logger 中的 doLog() 方法)
    不包含代码实现的方法叫作抽象方法
  • 子类继承抽象类,必须实现抽象类中的所有抽象方法,对应到代码中就是:所有继承 Logger 抽象类的子类,都必须重写 doLog() 方法

1.2、接口

// 接口
public interface Filter {
    void doFilter(RpcRequest req) throws RpcException;
}
// 接口实现类: 限流过滤器
public class RateLimitFilter implements Filter {
    @Override
    public void doFilter(RpcRequest req) throws RpcException {
        // ... 限流逻辑 ...
    }
}

// 接口实现类: 鉴权过滤器
public class AuthenticationFilter implements Filter {
    @Override
    public void doFilter(RpcRequest req) throws RpcException {
        // ... 鉴权逻辑 ...
    }
}
// 过滤器使用 demo
public class Application {

    // filters.add(new AuthenticationFilter());
    // filters.add(new RateLimitFilter());
    private List<Filter> filters = new ArrayList<>();

    public void handleRpcRequest(RpcRequest req) {
        try {
            for (Filter filter : fitlers) filter.doFilter(req);
        } catch (RpcException e) {
            // ... 处理过滤结果 ...
        }

        // ... 省略其他处理逻辑 ...
    }
}

上面这段代码是一个比较典型的接口的使用场景

  • 我们通过 Java 中的 interface 关键字定义了一个 Filter 接口
  • AuthenticationFilter 和 RateLimitFilter 是接口的两个实现类,分别实现了对 RPC 请求鉴权和限流的过滤功能

代码非常简洁,结合代码,我们再来看一下接口都有哪些特性

  • 接口不能包含属性(也就是成员变量)
  • 接口只能声明方法,方法不能包含代码实现
  • 类实现接口的时候,必须实现接口中声明的所有方法

1.3、区别

前面我们讲了抽象类和接口的定义,以及各自的语法特性

  • 从语法特性上对比,这两者有比较大的区别
    比如抽象类中可以定义属性、方法的实现,而接口中不能定义属性,方法也不能包含代码实现等等
  • 除了语法特性,从设计的角度,两者也有比较大的区别
    抽象类实际上就是类,只不过是一种特殊的类,这种类不能被实例化为对象,只能被子类继承
    我们知道,继承关系是一种 is - a 的关系,那抽象类既然属于类,也表示一种 is - a 的关系
    相对于抽象类的 is - a 关系来说,接口表示一种 has - a 关系,表示具有某些功能,对于接口有一个更加形象的叫法,那就是协议(contract)

2、抽象类解决什么问题

刚刚我们讲到,抽象类不能实例化,只能被继承,而前面的章节中我们还讲到,继承能解决代码复用的问题
所以抽象类也是为代码复用而生的,多个子类可以继承抽象类中定义的属性和方法,避免在子类中,重复编写相同的代码

既然继承本身就能达到代码复用的目的,而继承也并不要求父类一定是抽象类,那我们不使用抽象类,照样也可以实现继承和复用
从这个角度上来讲,我们貌似并不需要抽象类这种语法呀

那抽象类除了解决代码复用的问题,还有什么其他存在的意义吗
我们还是拿之前那个打印日志的例子来讲解,我们先对上面的代码做下改造,在改造之后的代码中

  • Logger 不再是抽象类,只是一个普通的父类,删除了 Logger 中 log()、doLog() 方法,新增了 isLoggable() 方法
  • FileLogger 和 MessageQueueLogger 还是继承 Logger 父类,以达到代码复用的目的

2.1、示例

// 父类: 非抽象类, 删除了 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 message) {
        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 message) {
        if (!isLoggable()) return;
        // 格式化 level 和 message, 输出到消息中间件
        msgQueueClient.send(...);
    }
}

2.2、问题

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

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() 方法,实现自己的记录日志的逻辑,不就可以了吗?

public class Logger {

    // ... 省略部分代码 ...

    public void log(Level level, String mesage) {
        // do nothing ...
    }
}

// 子类: 输出日志到文件
public class FileLogger extends Logger {

    // ... 省略部分代码 ...

    @Override
    public void log(Level level, String message) {
        if (!isLoggable()) return;
        // 格式化 level 和 message, 输出到日志文件
        fileWriter.write(...);
    }
}

// 子类: 输出日志到消息中间件, 比如 kafka
public class MessageQueueLogger extends Logger {

    // ... 省略部分代码...

    @Override
    public void log(Level level, String message) {
        if (!isLoggable()) return;
        // 格式化 level 和 message, 输出到消息中间件
        msgQueueClient.send(...);
    }
}

这个设计思路能用,但是它显然没有之前通过抽象类的实现思路优雅

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

3、接口解决什么问题

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

实际上,接口是一个比抽象类应用更加广泛、更加重要的知识点
比如:我们经常提到的 "基于接口而非实现编程",就是一条几乎天天会用到,并且能极大地提高代码的灵活性、扩展性的设计思想
关于接口这个知识点,我会单独再用一节课的时间,更加详细全面的讲解,这里就不展开了

4、该用抽象类还是接口

如果我们要表示一种 is - a 的关系,并且是为了解决代码复用的问题,我们就用抽象类
如果我们要表示一种 has - a 关系,并且是为了解决抽象而非代码复用的问题,那我们就可以使用接口

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

posted @ 2023-06-24 17:03  lidongdongdong~  阅读(6)  评论(0编辑  收藏  举报