软件设计

面向协议编程(POP):突破OOP局限的方法

面向协议编程(POP)根据类型能做什么来设计类型,而不是看它继承了什么。本文通过Swift示例,总结继承的瓶颈、用协议组合解决问题的方法,以及应避免使用的场景。

3 分钟阅读
面向协议编程(POP):突破OOP局限的方法 封面图

通过类继承扩展功能时,父类逐渐变得臃肿的经历,大家应该都遇到过。为了加入公共功能,不断把方法塞进超类,最后就会面对一个让人不敢修改的巨大父类。

面向协议编程(POP)正是在这里突破OOP的局限。POP不是根据“继承了什么”,而是根据“能做什么”来设计类型。它不采用垂直的继承结构,而是按协议组合所需的能力。

本文将介绍POP源于OOP的哪些局限、实际Swift代码有何不同,以及何时适合或不适合使用。


OOP继承会在哪里受阻?

面向对象的继承确实很强大,但经常会在三个地方遇到瓶颈。

第一,单继承的局限。Swift的类只能有一个父类。要创建同时支持网络和缓存的类型,仅靠继承无法解决。

第二,臃肿基类的问题。把公共功能集中到父类后,子类连自己不用的方法也会全部继承。

第三,与值类型的适配性。Swift的struct和enum不能继承,而Swift标准库大多使用值类型。

也就是说,如果只围绕继承设计,就很难发挥Swift推崇的值类型优势。


什么是面向协议编程(POP)?

2015年,Apple在WWDC宣布“Swift是一门面向协议的语言”后,POP开始广为人知。

核心是可以为协议添加默认实现。使用协议扩展,不仅能定义接口,还能分发实际行为。

例如这样写。

protocol Greetable {
    var name: String { get }
}

extension Greetable {
    func greet() -> String {
        return "你好, \(name)我是"
    }
}

struct Person: Greetable {
    let name: String
}

print(Person(name: "智勋").greet())
// 输出:你好,我是智勋

只要采纳Greetable,就能免费获得greet()。无需继承,也能把功能分配给struct。

还可以同时采纳多个协议。分别定义网络和缓存能力后,就能为需要的类型选择性地添加所需能力。

Person类型采纳Greetable・Cacheable协议的类图
一个协议对应一项能力,只添加需要的功能

OOP继承与POP有什么不同?

两者的区别可以整理成下表。

项目 OOP继承 POP
代码复用方式 从父类继承 通过协议扩展组合
类型关系 垂直(is-a) 水平(can-do)
多重采纳 仅支持单继承 同时采纳多个协议
值类型支持 不支持struct/enum 支持struct/enum
耦合度 父子类紧密绑定 按能力松散分离

一句话概括:

继承说明“你是什么”,协议说明“你能做什么”。

显示OOP与POP对比表的Xcode画面,以及放着咖啡的桌子
遇到这种情况,我会先想“能不能用协议拆分”,而不是“先上继承”

什么时候用POP,什么时候应该避免?

POP并非万能,应根据场景选择。

场景 判断
想为值类型(struct/enum)拆分公共功能 POP是正确选择
需要组合不同能力 POP更有优势
已有清晰的层级结构(动物-哺乳动物-狗) 继承也足够好
引用共享是核心的对象(例如视图控制器) 类继承更自然
协议拆得过细,难以追踪 过度抽象,需要重新审视

选择标准很简单:需要值类型和能力组合时用POP,需要清晰层级和引用共享时用继承。两者不是对立关系,而是可以根据场景一起使用的工具。

面试时可以这样问

Q. POP解决了OOP的哪些局限?

它解决了单继承和臃肿基类的问题。因为可以组合多个协议,只添加所需能力,还能通过协议扩展为无法继承的struct/enum等值类型提供默认实现。

Q. 协议扩展和类继承有什么区别?

继承是垂直的is-a关系,只能有一个父类;协议是水平的can-do关系,可以同时采纳多个协议。此外,继承只适用于引用类型的类,而协议也能应用于值类型。


这不是继承不好、协议就好的二分法。不过使用Swift开发时,可以先想“这能不能用协议拆分”,而不是“先上继承”。这个习惯会带来更灵活的代码。

延伸阅读