09. 接口 vs 抽象类比较
09.接口 vs 抽象类比较
目录介绍
01.面向对象设计特性
1.1 抽象和接口特性
1.2 一些问题思考
1.3 抽象的设计思想
1.4 抽象思想案例
02.抽象类和接口
2.1 什么是抽象类
2.2 为何有抽象类
2.3 什么是接口
2.4 为何设计接口
2.5 抽象类和接口区别
03.解决什么编程问题
3.1 抽象类的由来
3.2 接口的由来
04.模拟抽象类和接口
4.1 模拟接口设计
4.2 模拟抽象类
05.如何选择场景
5.1 判断标准很简单
5.2 场景上的区别
5.3 一些具体的例子
5.4 开发总结一下
01.面向对象设计特性
1.1 抽象和接口特性
在面向对象编程中,抽象类和接口是两个经常被用到的语法概念,是面向对象四大特性,以及很多设计模式、设计思想、设计原则编程实现的基础。
比如,我们可以使用接口来实现面向对象的抽象特性、多态特性和基于接口而非实现的设计原则,使用抽象类来实现面向对象的继承特性和模板设计模式等等。
并不是所有的面向对象编程语言都支持这两个语法概念,比如,C++ 这种编程语言只支持抽象类,不支持接口;而像 Python 这样的动态编程语言,既不支持抽象类,也不支持接口。
尽管有些编程语言没有提供现成的语法来支持接口和抽象类,我们仍然可以通过一些手段来模拟实现这两个语法概念。
1.2 一些问题思考
这两个语法概念不仅在工作中经常会被用到,在面试中也经常被提及。比如,“接口和抽象类的区别是什么?什么时候用接口?什么时候用抽象类?抽象类和接口存在的意义是什么?能解决哪些编程问题?”等等。
1.3 抽象的设计思想
抽象思想是指将事物或概念从具体的、特定的细节中抽离出来,关注其普遍性、共性和本质特征的思维方式。它是一种对事物进行概括、归纳和提炼的思考方式,通过忽略细节和个别差异,抓住事物的本质和共同点,以更高层次的概念和模型来理解和描述事物。
1.4 抽象思想案例
02.抽象类和接口
2.1 什么是抽象类
不同的编程语言对接口和抽象类的定义方式可能有些差别,但差别并不会很大。首先来看一下,在 Java 这种编程语言中,我们是如何定义抽象类的。
下面这段代码是一个比较典型的抽象类的使用场景(模板设计模式)。
Logger 是一个记录日志的抽象类,FileLogger 和 MessageQueueLogger 继承 Logger,分别实现两种不同的日志记录方式:记录日志到文件中和记录日志到消息队列中。
FileLogger 和 MessageQueueLogger 两个子类复用了父类方法,但因为这两个子类写日志的方式不同,它们又各自重写了父类中的 doLog() 方法。
抽象类是一种在面向对象编程中的概念,它是不能被实例化的类,只能被继承。抽象类用于定义一组抽象方法和可能的具体方法,以提供一种通用的接口和行为,供其子类实现和继承。
通过上面的这个例子来看一下,抽象类具有哪些特性。
抽象类不允许被实例化,只能被继承。也就是说,你不能 new 一个抽象类的对象出来(Logger logger = new Logger(…); 会报编译错误)。
抽象类可以包含属性和方法。方法既可以包含代码实现(比如 Logger 中的 log() 方法),也可以不包含代码实现(比如 Logger 中的 doLog() 方法)。不包含代码实现的方法叫作抽象方法。
子类继承抽象类,必须实现抽象类中的所有抽象方法。对应到例子代码中就是,所有继承 Logger 抽象类的子类,都必须重写 doLog() 方法。
抽象类提供了一种抽象的概念和接口,用于定义一组相关的类的共同行为和属性。它可以作为一种模板或基类,为子类提供一致的接口和行为,实现代码的复用和多态性。
2.2 为何有抽象类
2.3 什么是接口
再来看一下,在 Java 这种编程语言中,我们如何定义接口。
上面这段代码是一个比较典型的接口的使用场景。通过 Java 中的 interface 关键字定义了一个 Filter 接口。AuthenticationFilter 和 RateLimitFilter 是接口的两个实现类,分别实现了对 RPC 请求鉴权和限流的过滤功能。
代码非常简洁。结合代码再来看一下,接口都有哪些特性。
接口不能包含属性(也就是成员变量)。
接口只能声明方法,方法不能包含代码实现。
类实现接口的时候,必须实现接口中声明的所有方法。
接口是一种抽象的概念,用于定义一组方法的契约,而不涉及具体的实现。接口定义了类应该具有的方法和行为,以提供一种通用的接口,供类来实现。
2.4 为何设计接口
2.5 抽象类和接口区别
从语法特性上对比,这两者有比较大的区别:
语法特性的区别:比如抽象类中可以定义属性、方法的实现,而接口中不能定义属性,方法也不能包含代码实现等等。
设计角度的区别:抽象类实际上就是类,只不过是一种特殊的类,这种类不能被实例化为对象,只能被子类继承。我们知道,继承关系是一种 is-a 的关系,那抽象类既然属于类,也表示一种 is-a 的关系。相对于抽象类的 is-a 关系来说,接口表示一种 has-a 关系,表示具有某些功能。对于接口,有一个更加形象的叫法,那就是协议(contract)。
03.解决什么编程问题
3.1 抽象类的由来
抽象类也是为代码复用而生的。多个子类可以继承抽象类中定义的属性和方法,避免在子类中,重复编写相同的代码。
不过,既然继承本身就能达到代码复用的目的,而继承也并不要求父类一定是抽象类,那我们不使用抽象类,照样也可以实现继承和复用。从这个角度上来讲,我们貌似并不需要抽象类这种语法呀。
那抽象类除了解决代码复用的问题,还有什么其他存在的意义吗?
还是拿之前那个打印日志的例子。我们先对上面的代码做下改造。在改造之后的代码中,Logger 不再是抽象类,只是一个普通的父类,删除了 Logger 中 log()、doLog() 方法,新增了 isLoggable() 方法。FileLogger 和 MessageQueueLogger 还是继承 Logger 父类,以达到代码复用的目的。具体的代码如下:
这个设计思路虽然达到了代码复用的目的,但是无法使用多态特性了。像下面这样编写代码,就会出现编译错误,因为 Logger 中并没有定义 log() 方法。
你可能会说,这个问题解决起来很简单啊。在 Logger 父类中,定义一个空的 log() 方法,让子类重写父类的 log() 方法,实现自己的记录日志的逻辑,不就可以了吗?
这个设计思路能用,但是,它显然没有之前通过抽象类的实现思路优雅。为什么这么说呢?主要有以下几点原因。
在 Logger 中定义一个空的方法,会影响代码的可读性。如果我们不熟悉 Logger 背后的设计思想,代码注释又不怎么给力,我们在阅读 Logger 代码的时候,就可能对为什么定义一个空的 log() 方法而感到疑惑,需要查看 Logger、FileLogger、MessageQueueLogger 之间的继承关系,才能弄明白其设计意图。
当创建一个新的子类继承 Logger 父类的时候,我们有可能会忘记重新实现 log() 方法。之前基于抽象类的设计思路,编译器会强制要求子类重写 log() 方法,否则会报编译错误。我们举的例子比较简单,Logger 中的方法不多,代码行数也很少。但是,如果 Logger 有几百行,有 n 多方法,除非你对 Logger 的设计非常熟悉,否则忘记重新实现 log() 方法,也不是不可能的。
Logger 可以被实例化,换句话说,我们可以 new 一个 Logger 出来,并且调用空的 log() 方法。这也增加了类被误用的风险。当然,这个问题可以通过设置私有的构造函数的方式来解决。不过,显然没有通过抽象类来的优雅。
3.2 接口的由来
为什么需要接口?它能够解决什么编程问题?
抽象类更多的是为了代码复用,而接口就更侧重于解耦。接口是对行为的一种抽象,相当于一组协议或者契约,调用者只需要关注抽象的接口,不需要了解具体的实现,具体的实现代码对调用者透明。接口实现了约定和实现相分离,可以降低代码间的耦合性,提高代码的可扩展性。
实际上,接口是一个比抽象类应用更加广泛、更加重要的知识点。比如,经常提到的“基于接口而非实现编程”,就是一条几乎天天会用到,并且能极大地提高代码的灵活性、扩展性的设计思想。
04.模拟抽象类和接口
4.1 模拟接口设计
如果你熟悉的是 C++ 这种编程语言,你可能会说,C++ 只有抽象类,并没有接口,那从代码实现的角度上来说,是不是就无法实现 接口 的设计思路了呢?
先来回忆一下接口的定义:接口中没有成员变量,只有方法声明,没有方法实现,实现接口的类必须实现接口中的所有方法。
只要满足这样几点,从设计的角度上来说,我们就可以把它叫作接口。实际上,要满足接口的这些语法特性并不难。
在下面这段 C++ 代码中,就用抽象类模拟了一个接口(下面这段代码实际上是策略模式中的一段代码)。
抽象类 Strategy 没有定义任何属性,并且所有的方法都声明为 virtual 类型(等同于 Java 中的 abstract 关键字)。
这样,所有的方法都不能有代码实现,并且所有继承这个抽象类的子类,都要实现这些方法。从语法特性上来看,这个抽象类就相当于一个接口。
4.2 模拟抽象类
在 Python、Ruby 这些动态语言中,不仅没有接口的概念,也没有类似 abstract、virtual 这样的关键字来定义抽象类,那该如何实现上面的讲到的 抽象类 的设计思路呢?
实际上,除了用抽象类来模拟接口之外,还可以用普通类来模拟接口。具体的 Java 代码实现如下所示。
类中的方法必须包含实现,这个不符合接口的定义。但是,我们可以让类中的方法抛出 MethodUnSupportedException 异常,来模拟不包含实现的接口,并且能强迫子类在继承这个父类的时候,都去主动实现父类的方法,否则就会在运行时抛出异常。
那又如何避免这个类被实例化呢?实际上很简单,我们只需要将这个类的构造函数声明为 protected 访问权限就可以了。
05.如何选择场景
5.1 判断标准很简单
实际上,判断的标准很简单。
如果我们要表示一种 is-a 的关系,并且是为了解决代码复用的问题,就用抽象类;
如果我们要表示一种 has-a 关系,并且是为了解决抽象而非代码复用的问题,那就可以使用接口。
从类的继承层次上来看,抽象类是一种自下而上的设计思路,先有子类的代码重复,然后再抽象成上层的父类(也就是抽象类)。而接口正好相反,它是一种自上而下的设计思路。我们在编程的时候,一般都是先设计接口,再去考虑具体的实现。
5.2 场景上的区别
抽象类和接口在设计上有一些区别,尽管它们都是面向对象编程中的抽象概念。以下是它们之间的一些主要区别:
实现方式:抽象类通过继承的方式被子类实现,而接口通过实现的方式被类实现。一个类只能继承一个抽象类,但可以实现多个接口。
方法实现:抽象类可以包含具体方法的实现,而接口只能包含方法的声明,没有具体的实现。类继承抽象类时,可以直接继承具体方法的实现,而实现接口时,必须提供方法的具体实现。
关注点:抽象类更适合用于描述一种 "是什么" 的关系,即类与类之间的继承关系。接口更适合用于描述一种 "能做什么" 的关系,即类具有哪些方法和行为。
灵活性:抽象类可以包含实例变量,而接口只能包含常量。抽象类可以提供一些默认的实现,而接口只能定义方法的契约,没有默认实现。
使用场景:抽象类通常用于描述一组相关的类,提供一种通用的基类,而接口通常用于定义一组方法的契约,用于实现多态性和解耦合。
5.3 一些具体的例子
那么在实际开发中应该如何选择抽象类和接口的案例:
如果需要提供一组相关类的通用行为和属性,可以使用抽象类。比如,Android 中要抽象出公共的 BaseActivity,可以做到子类复用!
如果需要定义一组方法的契约,以实现多态性和解耦合,可以使用接口。比如,Android 中要定义 MVP 的 View 和 Presenter 的交互接口,则要用接口!
在某些情况下,抽象类和接口可以结合使用,以满足更复杂的设计需求。
5.4 开发总结一下
抽象类和接口的语法特性
抽象类不允许被实例化,只能被继承。它可以包含属性和方法。方法既可以包含代码实现,也可以不包含代码实现。不包含代码实现的方法叫作抽象方法。子类继承抽象类,必须实现抽象类中的所有抽象方法。接口不能包含属性,只能声明方法,方法不能包含代码实现。类实现接口的时候,必须实现接口中声明的所有方法。
抽象类和接口存在的意义
抽象类是对成员变量和方法的抽象,是一种 is-a 关系,是为了解决代码复用问题。接口仅仅是对方法的抽象,是一种 has-a 关系,表示具有某一组行为特性,是为了解决解耦问题,隔离接口和具体的实现,提高代码的扩展性。
抽象类和接口的应用场景区别
什么时候该用抽象类?什么时候该用接口?实际上,判断的标准很简单。如果要表示一种 is-a 的关系,并且是为了解决代码复用问题,我们就用抽象类;如果要表示一种 has-a 关系,并且是为了解决抽象而非代码复用问题,那我们就用接口。
评论