接口和抽象类:如何使用普通类模拟接口和抽象类

目录

1.引言

2.抽象类和接口的定义与区别

3.抽象类和接口存在的意义

4.模拟实现抽象类和接口

5.抽象类和接口的应用场景


1.引言

        在面向对象编程中,抽象类和接口是两个经常被提及的语法概念,也是面向对象编程的四大特性,以及很多设计模式和设计原则编程实现的基础。例如,我们可以使用接口实现面向对象的抽象特性、多态特性和基于接口而非实现的设计原则,使用抽象类实现面向对象的继承特性和模板设计模式,等等。

        不过,并不是所有的面向对象编程语言都支持这两个语法概念,如C++这种编程语言只支持抽象类,不支持接口;而像 Python 这样的动态编程语言,既不支持抽象类,又不支持接口。尽管有些编程语言没有提供现成的语法来支持接口和抽象类,但是我们仍然可以通过一些手段模拟实现这两个语法概念。

        这两个语法概念不但在工作中经常会被用到,而且在面试中经常被提及。接口和抽象类的区别是什么?什么时候使用接口?什么时候使用抽象类?抽象类和接口存在的意义是什么?通过阅读本节内容,相信读者可以从中找到答案。

2.抽象类和接口的定义与区别

        不同的编程语言对接口和抽象类的定义方式可能有差别,但差别并不会很大。因为Java既支持抽象类,又支持接口,所以我们使用Java进行举例讲解,以便读者对这两个有直观的认识。

        首先,我们看一下如何在 Java 中定义抽象类。

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

public abstract class Logger{
    private String name;
    private boolean enabled;
    private evel 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.intValve());
        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.Ievel minPermittedLevel,String filepath)
    {
       super(name,enabled,minPermittedLevel);
       this.fileWriter = new FileWriter(filepath);
    }

    @0verride
    public void doLog(Level level,string mesage){
            //格式化leve1和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){
        //格式化1evel和message,并输出到消息中间件
        msgQueueClient.send(...);
    }
}

        结合上述示例,我们总结了下列抽象类的特点:

        1)抽象类不允许被实例化,只能被继承。也就是说,我们不能通过关键字 new定义一个

抽象类的对象(编写“Logger logger=new Logger(..);”语句会报编译错误)。

        2)抽象类可以包含属性和方法。方法可以包含代码实现(如Logger类中的log()方法)也可以不包含代码实现(如Logger 类中的 doLog()方法)。不包含代码实现的方法称为抽象方法。

        3)子类继承抽象类时,必须实现抽象类中的所有抽象方法。对应到示例代码中,所有继承Logger 抽象类的子类都必须重写 doLog()方法。

        上面是对抽象类的定义。接下来,我们看一下如何在Java 中定义接口。我们还是先看一段示例代码。

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 {
        //...省略限流逻辑
    }
}

//过滤器使用示例
public class Application {
    private list<Filter> filters = new ArrayList<>();

    public Application(){
        filters.add(new AuthencationFilter());
        filters.add(new RatelimitFilter());

        public void handleRpcRequest(RpcRequest req){
            try{
                for(Filter filter : filters ){
                    filter.doFilter(req);
                }
            }catch(RpcException e){
                //..省略处理过滤结果.
            }
        }
        //..省略其他处理逻辑.
    }
}

        上述代码是一个典型的接口使用场景。通过Java 中的 interface 关键字,我们定义了一个Filter 接口。AuthencationFilter 和RateLimitFiliter是接口的两个实现类,分别实现了对RPC求鉴权和限流。结合上述代码,我们总结了下列接口的特点:

        1)接口不能包含属性(也就是成员变量)。

        2)接口只能声明方法,方法不能包含代码实现。

        3)类实现接口时,必须实现接口中声明的所有方法。

        有些读者可能说,在Java 1.8版本之后,接口中的方法可以包含代码实现,并且接口可以包含静态成员变量。注意,这只不过是Java语言对接口定义的妥协,目的是方便使用。抛开Java 这一具体的编程语言,接口仍然具有上述3个特点。

        在上文中,我们介绍了抽象类和接口的定义,以及各自的语法特性。从语法特性方面对比,抽象类和接口有较大的区别,如抽象类中可以定义属性、方法的实现,而接口中不能定义属性,方法也不能包含代码实现,等等。除语法特性以外,从设计的角度对比,二者也有较大的区别。

        抽象类也属于类,只不过是一种特殊的类,这种类不能被实例化为对象,只能被子类继承。我们知道,继承关系是一种is-a关系,那么,抽象类既然属于类,也表示一种is-a关系。相比抽象类的is-a关系,接口表示一种has-a关系(或can-do关系、behave like 关系),表示具有某些功能。因此,接口有一个形象的叫法:协议(contract)。

3.抽象类和接口存在的意义

        在上面我们介绍了抽象类和接口的定义与区别,现在我们探讨一下抽象类和接口存在的意义,以便读者知其然,知其所以然。

        为什么需要抽象类?它能够在编程中解决什么问题?在上面我们讲到,抽象类不能被实例化,只能被继承。之前,我们还讲过,继承能够解决代码复用问题。因此,抽象类是为代码复用而生的。多个子类可以继承抽象类中定义的属性和方法,这样可以避免在子类中重复编写相同的代码。

        既然继承就能达到代码复用的目的,而维承并不要求父类必须是抽象类,那么,不使用抽象类照样可以实现继承和复用。从这个角度来看,抽象类语法似乎是多余的。那么,除解决代码复用问题以外,抽象类还有其他存在的意义吗?

        我们还是结合之前打印日志的示例代码进行讲解。不过,我们需要先对之前的代码进行改造。在改造之后,Logger不再是独象类。万法一个普通类。另外,我们删除了Logger类中的log()、doLog()方法,新增了isLoggable()方法,FileLogger类和 MessageQueueLogger 类仍级继承 Logger 类。具体代码如下:

//父类:Logger, 非抽象类就是普通类,删除了log()和doLog()方法,新增了 isLoggeable()方法
public abstract class Logger{
    private String name;
    private boolean enabled;
    private evel minPermittedLevel;

    public Logger(String name, boolean enabled, Level minPermittedLevel){
        this.name = name;
        this.enabled = enabled;
        this.minPermittedLevel = minPermittedLevel;
    }

    protected boolean isLoggable(){
        boolean loggable = enabled && (minPermittedLevel.intValue()<=level.intValve());
        return loggable;
    }
}

//抽象类的子类:输出日志到文件
public class FileLogger extends Logger{
    private Writer fileWriter;

    public FileLogger(String name,boolean enabled.Ievel minPermittedLevel,String filepath)
    {
       super(name,enabled,minPermittedLevel);
       this.fileWriter = new FileWriter(filepath);
    }

    @0verride
    public void log(Level level,string mesage){
        if (!isLoggable())
            return;
        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
    public void log(Level level,string mesage){
        if (!isLoggable())
            return;
        msgQueueClient.send(...);
    }
}

        虽然上面这段代码的设计思路达到了代码复用的目的,但是无法使用多态特性。如果我们像下面这样编写代码,就会出现编译错误,因为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()方法,让子类重写 Logger类的log()方法,并实现自己的日志输出逻辑,不就可以了吗?代码如下所示。

Public class Logger{
    //...省略部分代码...
    Public void log(Level level,string mesage){ //方法体为空}
}

public class FileLogger extends Logger{
    //..省略部分代码..
    @Override
    public void log(Level level,String mesage){
        if(!isLoggable())
            return;
        //格式化1evel和message,并输出到日志文件
        filewriter.write(...);
    }
}

public class MessageQueuelogger extends Logger{
   //..省略部分代码..
   @Override
   public void log(Level level, string mesage){
       if(!isLoggable())
           return;
       //格式化1evel和message,并输出到消息中间件
       msgQueueClient.send(...);
    }
}

        虽然上面这段代码的设计思路可用,能够解决问题,但是,它显然没有之前基于抽象*。设计思路优雅,理由如下。

        1)在Logger类中,定义一个空的方法,会影响代码的可读性。如果我们不熟悉Logger类背后的设计思想,加之代码的注释不详细,那么,在阅读Logger类的代码时,有可解生为什么定义一个空的log()方法的疑问。或许,我们需要通过查看Logger、FileLogger和MessageQueueLogger 之间的继承关系,才能明白其背后的设计意图。

        2)当创建一个新的子类并继承Logger类时,我们很有可能忘记重新实现log()方法。前基于抽象类的设计思路,编译器会强制要求子类重写log()方法,否则会报编译错误。读者可能会问,既然要定义一个新的Logger 类的子类,那么怎么会忘记重新实现 log()方法呢?其实,我们举的例子比较简单,Logger类中的方法不多,代码行数也很少。我们可以想象一下如果Logger类中有几百行代码,包含很多方法,除非我们对Logger类的设计非常熟悉,否则极有可能忘记重新实现log()方法。

        3)Logger类可以被实例化,换句话说,我们可以通过关键字new定义一个Logger 类的对象,并且调用它的空的log()方法。这增加了类被误用的风险。当然,这个问题可以通过设置私有的构造函数的方式来解决。不过,这显然没有基于抽象类的实现思路优雅。为什么需要接口?它能够在编程中解决什么问题?抽象类侧重代码复用,而接口侧重解耦。接口是对行为的一种抽象,相当于一组协议或契约,读者可以类比API。调用者只需要关注抽象的接口,不需要了解具体的实现,具体的实对调用者透明。接口实现了约定和实现分离,可以降低代码的耦合度,提高代码的可扩展性。

4.模拟实现抽象类和接口

        有些编程语言只有抽象类,并没有接口,如C++。我们可以通过抽象类模拟接口,只要它满足接口的特性(接口中没有成员变量,只有方法声明,没有实现方法,实现接口的类必须实现接口中的所有方法)即可。在下面这段C++代码中,我们使用抽象类模拟了一个接口。

class Strategy{
    //用抽象类模拟接口
    public:
        virtual ~Strategy();
        virtual void algorithm() = 0,

    protected:
        Strategy();
}

        抽象类 Strategy 没有定义任何属性,并且所有的方法都声明为virual(等同于 Java中的abstract 关键字)类型,这样,所有的方法都不能有代码实现,并且所有继承这个抽象类的子类都要实现这些方法。从语法特性上来看,这个抽象类就相当于一个接口。

        不过,现在流行的动态编程语言,如Python、Ruby等,它们不但没有接口的概念,而且没有抽象类。在这种情况下,我们可以使用普通类模拟接口。具体的Java代码实现如下。

public class MockInterface{
    protected MockInterface();

    public void funcA(){
        throw new MethodUnSupportedException();
    }
}

        我们知道,类中的方法必须包含实现,但这不符合接口的定义。其实,我们可以让类中的方法抛出 MethodUnSupportedException 异常来模拟不包含实现的接口,并且,在子类继承父类时,强迫子类主动实现父类的方法,否则会在运行时抛出异常。那么,如何避免这个类被实例化呢?我们只需要将构造函数设置成protected属性,这样就能避免非同一包(package)下的类去实例化 MockInterface。不过,这样做还是无法避免同一包下的类去实例化 MockInterface.为了解决这个问题,我们可以学习Google Guava中 @VisibleForTesting注解的做法,自定义个注解,人为地表明其不可实例化。

        上面讲了如何用抽象类来模拟接口,以及如何用普通类来模拟接口,那么,如何用普通类来模拟抽象类呢?我们可以类比 MockInterface 类的处理方式,让本该为abstract的方法内部抛出MethodUnSupportedException异常,并且将构造函数设置为protected 属性,避免实例化。

5.抽象类和接口的应用场景

        在真实的项目开发中,什么时候该用抽象类?什么时候该用接口?实际上,判断的标准很简单。如果我们要表示一种is-a关系,并且是为了解决代码复用的问题,那么使用抽象类;如果我们要表示一种 has-a关系,并且是为了解决抽象而非代码复用的问题,那么使用接口。

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

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值