ISP(接口隔离原则)——SOLID的第四个原则,声明:客户端不应依赖它们不使用的方法。该原则由Robert Martin在面向对象系统的接口设计背景下提出。正如《Clean Architecture(2017)》中所述,接口隔离原则要求创建高度专门化的接口而不是一个通用接口,这减少了耦合并简化了更改的引入。
要点
ISP(接口隔离原则)——禁止创建包含并非所有客户端都使用的方法的“胖”接口。与其设计一个包含十种方法的接口,不如设计多个小接口,每个接口服务于其自己的客户端群组。
该原则由Robert Martin引入,作为解决“接口污染”问题的方案:当一个类被迫实现它不需要的方法,仅仅因为这些方法在一个公共接口中声明时。在静态类型语言中,这会导致空实现或抛出异常——这是违反ISP的直接标志。
ISP与SRP相互补充:SRP关乎类的职责,ISP关乎接口的契约。SRP说“一个类——一个更改理由”,ISP说“一个接口——一个客户端场景”。它们共同形成模块化架构,系统中每个元素都有清晰的边界。
胖接口(Fat Interface)——包含比特定客户端所需更多方法的接口。例如,具有work、eat、sleep方法的Worker接口。机器人工作者不应该实现eat和sleep,但被迫这样做。解决方案——拆分为Workable、Eatable、Sleepable。每个客户端都得到它确切需要的东西。
在移动开发中,胖接口出现在委托和DataSource协议中。一个协议可能包含两个不同场景(编辑+显示)的方法,尽管特定屏幕只使用其中一个。
ISP的实现从分析每个接口的客户端开始。如果两个客户端使用同一个接口的不同方法集——该接口应该被拆分。每个新接口对在一个场景中一起被调用的方法进行分组。
拆分机制:原始接口被拆分为多个狭小接口,每个接口继承公共部分(如果存在)。客户端切换到依赖所需的狭小接口而非通用接口。实现原始接口的类现在只实现它们真正需要的那些狭小接口。
重要说明:拆分程度由客户端的数量及其场景决定。ISP不要求最大程度的拆分(每个方法一个微接口)。这会导致过度复杂。目标是消除客户端对不必要方法的依赖,而不是最小化每个接口的大小。
违反ISP的主要迹象包括:类用空方法实现接口(虚假实现),在实现中抛出UnsupportedOperationException异常,大量参数或返回类型未被部分客户端使用,以及频繁的接口更改仅影响部分客户端。
在Android开发中,违反ISP的典型例子是OnItemClickListener接口,它包括点击、长按和滑动的方法。如果特定屏幕只使用点击——其余方法保持为空。解决方案——拆分为OnItemClickListener、OnItemLongClickListener、OnItemSwipeListener。
在iOS开发中,违反ISP体现在UIKit委托中:一个协议包含组件不同状态的方法。UITableViewDelegate包括显示、选择、编辑和滑动操作的方法。开发人员经常用十个空方法实现整个协议。根据职责组拆分为多个协议解决了这个问题。
问题不仅在于代码美学。当接口发生变化(添加新方法)时,所有实现类都必须更新——甚至那些不需要新方法的类。在拥有数十个屏幕的移动开发中,这会导致级联更改。ISP将每个客户端与不相关的更改隔离开来。
隐性违反ISP通过配置参数发生。如果一个方法接收带有大量字段的对象,而客户端只使用其中的2-3个——这是拆分的信号。替代方案:多个具有最少参数集的专门化方法。
在Android开发中,当使用一个SharedPreferencesManager来读取和写入所有应用设置时,ISP被违反。只需要读取主题的Fragment获得了对具有数十种不同数据类型方法的全局管理器的依赖。拆分为ThemePreferenceProvider、AuthPreferenceProvider、FeatureFlagProvider——在配置服务级别应用ISP。每个提供者包含其客户端确切需要的方法。
让我们看看Android示例中用于数据处理的接口。违反ISP——一个接口用于所有CRUD操作,尽管并非所有客户端都需要所有操作。
// 违反ISP:胖接口
interface UserRepository {
fun getAll(): List<User>
fun getById(id: Int): User
fun save(user: User)
fun delete(id: Int)
}
// 应用ISP后:狭小接口
interface UserReader {
fun getAll(): List<User>
fun getById(id: Int): User
}
interface UserWriter {
fun save(user: User)
fun delete(id: Int)
}
// ReadOnlyViewModel不依赖于写入方法
class ReadOnlyViewModel(
private val reader: UserReader
)
iOS示例中拆分协议用于媒体处理:
// 违反ISP:一个协议用于所有媒体处理
protocol MediaService {
func play(url: URL)
func pause()
func stop()
func upload(data: Data) async -> URL
func download(url: URL) async -> Data
}
// ISP后:按责任拆分为协议
protocol MediaPlayer {
func play(url: URL)
func pause()
func stop()
}
protocol MediaTransfer {
func upload(data: Data) async -> URL
func download(url: URL) async -> Data
}
// PlayerViewModel不依赖于加载方法
class PlayerViewModel {
private let player: MediaPlayer
}
实际结论:ISP保护客户端免受接口中不相关部分更改的影响。将UserRepository拆分为UserReader和UserWriter意味着save中的更改不影响ReadOnlyViewModel,反之亦然。每个客户端都与不使用的功能隔离,在系统其他部分改进时不需要更改。
ISP与SRP——天然一对。SRP规定一个类应该有一个更改理由。ISP将同样的逻辑应用于接口:一个接口应该服务于一个客户端场景。一个类可以实现多个狭小接口(每个对应一个职责),这比一个具有多个职责的胖接口更清晰。
ISP与OCP也相关:狭小接口更容易扩展。向狭小接口添加新方法只影响其客户端。向胖接口添加方法影响所有客户端——如果客户端被迫更改其实现,则可能违反OCP。
ISP与DIP一起工作:DIP要求依赖抽象。ISP使这些抽象变得狭小且集中。依赖宽接口仍然是依赖抽象,但从ISP的角度来看是“糟糕的”抽象。四个原则(SRP、OCP、ISP、DIP)形成“模块化金字塔”:SRP和ISP确定边界,OCP和DIP确定扩展和连接的方式。
组件架构在移动项目(模块、功能、层)中在公共API级别受益于ISP。每个模块为其消费者导出狭小接口,而不是一个公共外观。这允许更改模块的内部实现而不影响只使用其部分功能的消费者。
在使用Clean Architecture的Android项目中,ISP应用于UseCase:每个UseCase是一个单独的接口,具有一个invoke或execute方法。客户端(ViewModel)只依赖于它需要的UseCase,而不是整个存储库。这使依赖关系透明且可测试。
常见问题
是的,过度拆分是可能的。ISP不要求每个方法一个接口。标准:是否存在只需要接口部分方法的客户端?如果所有客户端都使用所有方法——则不需要拆分接口。最佳拆分级别由实际使用场景决定。
参数级别的ISP意味着:如果函数只使用对象的一部分字段,则不应接收带有大量字段的对象。相反,应该只传递必要的数据或使用专门化的接口(例如,使用Renderable接口而不是完整的User)。
LSP涉及正确的继承和子类型的行为兼容性。ISP涉及接口设计:客户端不应依赖它们不使用的方法。LSP回答“子类能否代替基类使用?”的问题,ISP回答“客户端是否需要整个接口?”的问题。
狭小接口简化了mock对象的创建:测试创建一个具有一两个方法的mock,而不是十个。接口中的方法越少,模拟其行为就越容易。这减轻了测试开发人员的认知负担,并减少了mock逻辑中错误的可能性。
如果接口稳定且所有客户端使用所有方法——拆分是多余的。典型例子:Apple设计的UIKit协议。拆分它们是危险的,因为UIKit期望委托的完整实现。在这种情况下,违反ISP因API的稳定性而得到合理化的解释。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。