本文是极客时间里王争专栏《设计模式之美》的学习笔记,你可以通过链接阅读原文获取更加详尽的描述,也可以通过该链接进行订阅和购买获取优惠。
接口隔离原则(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
来规范列表类业务开发的逻辑,比如翻页、UITableView
的DataSource
协议中的计算逻辑。
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
中的翻页逻辑字段定义默认实现,如下所示:
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
即可。
/// 使用`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
接口或函数
我们再换一种理解方式,把接口理解为单个接口或函数(以下简称为“函数”)。那接口隔离原则就可以理解为:函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现。接下来,我们还是通过一个例子来解释一下。
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
罗列的统计信息中一部分,比如,有的只需要用到 max
、min
、average
这三类统计信息,有的只需要用到 average
、sum
。而 count()
函数每次都会把所有的统计信息计算一遍,就会做很多无用功,势必影响代码的性能,特别是在需要统计的数据量很大的时候。所以,在这个应用场景下,count()
函数的设计就有点不合理了,我们应该按照接口隔离原则,把 count()
函数拆成几个更小粒度的函数,每个函数负责一个独立的统计功能。拆分之后的代码如下所示:
public Long max(Collection<Long> dataSet) { //... }
public Long min(Collection<Long> dataSet) { //... }
public Long average(Colletion<Long> dataSet) { //... }
// ...省略其他统计函数...
接口隔离原则跟单一职责原则有点类似,不过稍微还是有点区别。
- 单一职责原则针对的是模块、类、接口的设计。而接口隔离原则相对于单一职责原则,它更侧重于接口的设计;
- 接口隔离原则的思考的角度不同。它提供了一种判断接口是否职责单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
把“接口”理解为 OOP 中的接口概念
我们还可以把“接口”理解为 OOP 中的接口概念,比如 iOS 中的协议(Protocol
),这里不考虑利用协议实现委托的场景。举一个简单的例子。
假如项目中要做习题的功能,分为两种模式:练习模式和挑战模式。练习模式的习题是客户端随机生成,挑战模式下的习题是从数据库中获取。现定义有如下接口:
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
}
// ...其他逻辑代码...
}
}
现增加错题本,在练习模式下,错误习题记录到错题本,而在挑战模式下,无需记录。这种情况下,新增接口
func record(wrong: Equation?)
是应该放置在LearnService
中还是另新增协议RecordService
单独维护呢,如下:
protocol RecordService: AnyObject {
func record(wrong: Equation?)
}
根据接口隔离原则,应该使用新增RecordService
协议单独维护,这样可以避免在挑战模式下依赖不需要的接口。虽然,在iOS中可以将接口定义成可选类型(optional
),来避免实现不需要的接口,但是这样的话,违背了单一职责原则和接口隔离原则。
对于第三方库Reusable
中,开发者也是将NibLoadable
协议和Reusable
协议独立,如下:
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
满足接口隔离原则,避免实现者依赖不需要的接口。
重点回顾
- 如何理解“接口隔离原则”?
理解“接口隔离原则”的重点是理解其中的“接口”二字。这里有三种不同的理解。
如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。
如果把“接口”理解为单个 API 接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。
如果把“接口”理解为 OOP 中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。
- 接口隔离原则与单一职责原则的区别
单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。