设计原则与思想:面向对象 II

290 阅读7分钟

一、接口 VS 抽象类

首先声明,并不是所有的面向对象编程语言都支持这两个语法概念,拿 Golang 来说,只支持接口,并不支持抽象类。这里的抽象类以 Java 为例展开说明。

1. 什么是抽象类和接口

  • 抽象类的特性

    • 抽象类不能被实例化,只能被继承。
    • 抽象类可以包含属性和方法,方法可以是具体的实现方法,也可以是抽象方法。
    • 子类继承抽象类,必须实现抽象类中的所有抽象方法。
  • 接口的特性

    • 接口不能包含属性(即成员变量)。
    • 接口只能声明方法,方法不能包含代码实现。
    • 类实现接口的时候,必须实现接口中声明的所有方法。
  • 对比 抽象类实际上就是类,只是不能被初始化的一种特殊类,只能被子类继承。抽象类是 is-a 关系,接口表示 has-a 关系,表示具有某些功能。对于接口,有一个更加形象的说法,那就是协议(contract)

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

  • 抽象类 抽象类为代码复用而生,多态的优雅解决方案。

继承不一定就能实现多态,继承 + 重写才能实现多态。多态不一定需要抽象,但是抽象类能够提升代码可维护性和可读性。

  • 接口 抽象类更多的是为了代码复用,而接口就更侧重于解耦。接口实现了约定和实现分离,降低代码之间的耦合性,提高代码的可拓展性。

3. 该用抽象类还是接口

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

从类的继承层次来看,抽象类是一种自下而上的设计思路,先有子类的代码重复,然后再抽象成上层的父类(即抽象类)。而接口恰好相反,它是一种自上而下的设计思路。在编程过程中,通常是先设计接口,再去考虑具体的实现。

二、基于接口而非实现编程

1. 如何理解原则中的“接口”二字?

“基于接口而非实现编程”,这里的接口是约定、协议,不要和编程语言提供的“接口”语法机制挂钩。

这条原则能非常有效地提高代码质量,接口与实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化时,上游系统的代码基本不需要改动,一次降低耦合性,提高拓展性。

“基于接口而非实现编程”这条原则的另一个表述方式,是“基于抽象而非实现编程”。越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。

Program to an interface, not an implementation.

2. 如何将这条原则应用到实战中?

需要做到如下三点:

  • 函数的命名不能暴露任何实现细节,要足够通用。
  • 封装具体的实现细节。
  • 为实现类定义抽象的接口(与特定实现相关的方法不要定义在接口中),具体的实现类都依赖统一的接口定义。使用者依赖接口,而不是具体的实现类来编程。

在实际的开发场景中,很多人都是通过实现类来反推接口的定义。先把实现类写好,然后看实现类中有哪些方法,能抄到接口定义中。按照这种思考方式,就有可能导致接口定义不够抽象,依赖具体的实现。这样的接口设计就没有意义了。这种思考方式更加顺畅,但要选择性地搬迁实现类的方法到接口定义中,不要将跟具体实现相关的方法搬移到接口中。

3. 是否需要为每个类定义接口?

过度使用这条原则,给每个类都定义接口,会导致不必要的开发负担。衡量是否需要为某个类定义接口的依据,还是要回归到设计原则诞生的初衷上来。只要搞清楚了这条原则是为了解决什么样的问题而产生,对于之前模棱两可的问题,才能变得豁然开朗。

如果在业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就好了。

越是不稳定的系统,越是要在代码的拓展性、维护性上下功夫。相反,如果某个系统特别稳定,开发完成之后,基本上不需要做维护,就没有必要为其拓展性,投入不必要的开发时间。

唯一不变的就是变化。

"基于接口而非实现编程"这一原则,不仅仅可以指导非常细节的编程开发,还能指导更加上层的架构设计、系统设计等。比如,服务端与客户端之间的“接口”设计、类库的“接口”设计。

三、多用组合少用继承

经典的设计原则:组合优于继承,多用组合少用继承。

1. 为什么不推荐使用继承?

继承层次过深、过复杂,会影响到代码的可维护性

类的继承层次会越来越深、继承关系会越来越复杂。导致代码的可读性变差

  • 当要搞清楚某个类有什么方法、属性时,必须阅读父类的代码、父类的父类的代码……一直追溯到最顶层父类的代码。
  • 破坏了类的封装特性,将父类的实现细节暴露给了子类(类的命名暴露了类的实现细节)。
  • 子类的实现依赖父类的实现,两者高度耦合,一旦父类代码修改,就有影响所有子类的逻辑。

2. 组合相比继承有哪些优势?

使用组合(composition)、接口(interface)、委托(delegattion)三个技术手段,可以解决继承存在的问题。

接口用来表示拥有什么功能,委托表现在依赖注入,组合实现多个接口。分离了不同的功能,还减少了耦合,避免代码重复。

继承的三个作用:表示 is-a 关系,支持多态特性,代码复用,这三个作用可以通过其他技术手段来达成。

  • 通过组合和接口的 has-a 关系代替 is-a 关系。
  • 利用接口实现多态特性。
  • 使用组合和委托实现代码复用。

3. 如何判断使用组合还是继承?

鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。继承改写组合意味着要做更细粒度的类拆分,要定义更多的类和接口,增加了代码的复杂度和维护成本。

如果类之间的继承结构稳定,继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,那就大胆使用继承。反之,尽量使用组合来替代继承。

一些设计模式会固定使用继承或者组合。比如,装饰器模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系。

一些特殊场景,比如外部提供的类或者历史遗留的一些类,我们无法修改这些类,为了支持多态,只能采用继承来实现。