本文是极客时间里王争专栏《设计模式之美》的学习笔记,你可以通过链接阅读原文获取更加详尽的描述,也可以通过该链接进行订阅和购买获取优惠。

接口隔离原则(ISP)

今天来看看SOLID中的I, 接口隔离原则。

如何理解“接口隔离原则”?

接口隔离原则(Interface Segregation Principle),缩写为ISP。其定义:

Clients should not be forced to depend upon interfaces that they do not use。

客户端不应该被强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。

"接口"这个名词,在软件开发中,我们既可以把它看做一组抽象的约定,也可以具体指系统与系统之间的API接口,还可以特指面向对象编程语言中的接口等。

理解接口隔离原则的关键,就是理解其中的“接口”二字。在这条原则中,我们可以把“接口”理解以下三种:

  • 一组API接口集合
  • 单个API接口或函数
  • OOP中的接口概念

接下来看看,按照这三种理解方式,在不同的场景下,这条原则具体是如何解读和应用的。

把“接口”理解成一组API接口集合

举个例子。客户端开发中,声明了一组API来规范列表类业务开发的逻辑,比如翻页、UITableViewDataSource协议中的计算逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
protocol TableViewModel {
var pageSize: Int { get set }
var pageNum: Int { get set }
var hasNextPage: Bool { get set }
func numberOfSections() -> Int
func numberOfRowsIn(section: Int) -> Int
// ...其他行为约定...
}

class XXViewModel: TableViewModel {

}

假如我们如上定义协议,有一个问题就是,业务是一个列表类型的展示,但是没有翻页的业务场景,但是我遵循了该协议就必须声明翻页逻辑相关的字段。或许可以通过给TableViewModel中的翻页逻辑字段定义默认实现,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
extension TableViewModel {
var pageSize: Int {
get { return 0 }
set {}
}

var pageNum: Int {
get { return 1 }
set {}
}

var hasNextPage: Bool {
get { return false }
set {}
}
}

但是,按照接口隔离原则,调用者不应该依赖它不需要的接口,没有翻页逻辑的业务,就不应该遵循上述翻页的接口。

将翻页的接口单独放到另外一个接口Pageable中,然后将TableViewModel & Pageable打包给具有翻页逻辑的列表使用,不具有翻页逻辑的列表只依赖TableViewModel即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/// 使用`TableView`实现的列表相关接口
protocol TableViewModel {
func numberOfSections() -> Int
func numberOfRowsIn(section: Int) -> Int
// ...其他行为约定...
}

/// 翻页相关接口
protocol Pageable {
var pageSize: Int { get set }
var pageNum: Int { get set }
var hasNextPage: Bool { get set }
}

/// 具有翻页的列表
typealias PageableTableViewModel = TableViewModel & Pageable

class XXViewModel: PageableTableViewModel {

}

另外,Pageable协议独立后,可以与项目中UICollectionView实现的列表打包结合使用。

在上面的例子中,我们把接口隔离原则中的接口,理解为一组接口集合,它可以是某个视图的接口,也可以是某个类库的接口等等。在设计视图或者类库接口的时候,如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。

把“接口”理解为单个API接口或函数

我们再换一种理解方式,把接口理解为单个接口或函数(以下简称为“函数”)。那接口隔离原则就可以理解为:函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现。接下来,我们还是通过一个例子来解释一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

public class Statistics {
private Long max;
private Long min;
private Long average;
private Long sum;
private Long percentile99;
private Long percentile999;
//...省略constructor/getter/setter等方法...
}

public Statistics count(Collection<Long> dataSet) {
Statistics statistics = new Statistics();
//...省略计算逻辑...
return statistics;
}

在上面的代码中,count()函数的功能包含很多不同的统计功能,比如,求最大值、最小值、平均值等等。

如果在项目中,对每个统计需求,Statistics定义的那几个统计信息都有涉及,那 count() 函数的设计就是合理的。相反,如果每个统计需求只涉及Statistics罗列的统计信息中一部分,比如,有的只需要用到 maxminaverage这三类统计信息,有的只需要用到 averagesum。而 count() 函数每次都会把所有的统计信息计算一遍,就会做很多无用功,势必影响代码的性能,特别是在需要统计的数据量很大的时候。所以,在这个应用场景下,count() 函数的设计就有点不合理了,我们应该按照接口隔离原则,把 count() 函数拆成几个更小粒度的函数,每个函数负责一个独立的统计功能。拆分之后的代码如下所示:

1
2
3
4
5

public Long max(Collection<Long> dataSet) { //... }
public Long min(Collection<Long> dataSet) { //... }
public Long average(Colletion<Long> dataSet) { //... }
// ...省略其他统计函数...

接口隔离原则跟单一职责原则有点类似,不过稍微还是有点区别。

  • 单一职责原则针对的是模块、类、接口的设计。而接口隔离原则相对于单一职责原则,它更侧重于接口的设计;
  • 接口隔离原则的思考的角度不同。它提供了一种判断接口是否职责单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。

把“接口”理解为 OOP 中的接口概念

我们还可以把“接口”理解为 OOP 中的接口概念,比如 iOS 中的协议(Protocol),这里不考虑利用协议实现委托的场景。举一个简单的例子。

假如项目中要做习题的功能,分为两种模式:练习模式和挑战模式。练习模式的习题是客户端随机生成,挑战模式下的习题是从数据库中获取。现定义有如下接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
protocol LearnService: AnyObject {
func fetchSectionItems(isInit: Bool) -> [Equation]
func currentItem() -> Equation?
func hasFinishSection() -> Bool
//...其他接口...
}

class ChallengeService: LearnService {
// ...忽略实现...
}

// LearnService的使用
class ExerciseViewController: UIViewController {
var service: LearnService!
// ...省略其他属性...

func fetchDataAndRefresh(isInit: Bool = false) {
let items = service.fetchSectionItems(isInit: isInit)
guard !items.isEmpty else {
return
}
// ...其他逻辑代码...
}
}

现增加错题本,在练习模式下,错误习题记录到错题本,而在挑战模式下,无需记录。这种情况下,新增接口

1
func record(wrong: Equation?)

是应该放置在LearnService中还是另新增协议RecordService单独维护呢,如下:

1
2
3
protocol RecordService: AnyObject {
func record(wrong: Equation?)
}

根据接口隔离原则,应该使用新增RecordService协议单独维护,这样可以避免在挑战模式下依赖不需要的接口。虽然,在iOS中可以将接口定义成可选类型(optional),来避免实现不需要的接口,但是这样的话,违背了单一职责原则和接口隔离原则。

对于第三方库Reusable中,开发者也是将NibLoadable协议和Reusable协议独立,如下:

1
2
3
4
5
6
7
8
9
10
11
public protocol Reusable: class {
/// The reuse identifier to use when registering and later dequeuing a reusable cell
static var reuseIdentifier: String { get }
}

public protocol NibLoadable: class {
/// The nib file to use to load a new instance of the View designed in a XIB
static var nib: UINib { get }
}

public typealias NibReusable = Reusable & NibLoadable

满足接口隔离原则,避免实现者依赖不需要的接口。

重点回顾

  1. 如何理解“接口隔离原则”?

理解“接口隔离原则”的重点是理解其中的“接口”二字。这里有三种不同的理解。

如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。

如果把“接口”理解为单个 API 接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。

如果把“接口”理解为 OOP 中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。

  1. 接口隔离原则与单一职责原则的区别

单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。