浅谈 Swift 中 “面向协议编程” 思想

最近在某个 iOS 开发交流群里看到了一个非常基本、却又很典型的例子:

protocol Eatable {
    func eat()
}

extension Eatable {
    func eat() {
        print("吃东西")
    }
}

struct Person: Eatable {
    func eat() {
        print("吃米饭")
    }
}

let p1: Person = Person()
let p2: Eatable = Person()
p1.eat()
p2.eat()

熟悉面向对象程序设计语言(如 C++)的朋友应该立马反应出这是面向对象概念中 “多态” 的一个例子,但好像和 C++ 中的多态又有所不同。没关系,我们改写成类 C++ 的写法:

class Animal {
    func eat() {
        print("吃东西")
    }
}

class Person: Animal {
    override func eat() {
        print("吃米饭")
    }
}

let p1: Person = Person()
let p2: Animal = Person()
p1.eat()
p2.eat()

上面两段代码在功能上是完全等价的,即实现了 Eatable/Animal 的 “多态”。不同的是 Swift 推荐实现 “多态” 的方式是借助协议 (Protocol),这和传统的面向对象编程语言不同(当然 Swift 也可用传统的方式实现多态,例如上面改写的类 C++ 代码)。根据 Swift 官方文档的描述,类型一旦遵守了某个协议,就必须实现该协议所列出的属性或方法,就为实现多态提供了方便。

以上面的第一段代码为例,我们首先定义了一个 Eatable 协议,在其中定义了一个吃东西的方法,表明 “凡是遵循该协议的类型均要能吃东西”,后面紧跟了一个扩展为协议的 eat 方法指定了一个默认动作;随后定义了一个 Person 类,遵守了 Eatable 协议,表明 Person 是能吃东西的一个类型,并实现了 eat 方法 “吃米饭”;最后分别定义并初始化 p1 p2、调用 eat 方法 —— 不同的是 p1 的类型为 Person,p2 直接使用协议作为其类型。由于 p2 初始化为 Person(),从而实现了多态,使用的是 Person 实现的 eat 方法,最终两者都输出 “吃米饭”。

看到这里大家可能要问了:“你说的我好像有点明白了,那为啥 Swift 要用所谓的 ‘协议’ 来实现多态呢?” 这就牵涉到了不得不聊的一个问题 —— 面向对象程序设计 (Object Oriented Programming,以下简称 OOP) 的缺陷了。

首先,OOP 的继承机制要求开发者在开发之前就能设计好整个程序的框架、结构、事物间的连接关系。这要求开发者必须有很好的分类设计能力,将不同的属性和方法分配的合适的层次里面去,设计清晰明了的继承体系总是很难的。

其次,OOP 建立的结构天生对改动有抵抗特性。这也是为什么 OOP 中所有程序员都对重构讳莫如深,有些框架到最后代码量急剧膨胀变得难以维护从而失控。例如我们要改变继承结构中的一个点,我们同样要关心它的父类和子类(如果有),改变这个地方之后父类和子类是否也要做相应的修改,这样就导致了代码的维护非常复杂。

再次,继承机制带来了另一个问题:很多语言(如 Java)都不提供多继承,我们不得不在父类塞入更多的内容,子类中会存在无用的父类属性和方法,而这些冗余代码会给子类带来一定的风险(如不经意的调用),而且对于层级很深的代码结构来说 Bug 修复将会成为难题。

最后,对象的状态不是我们的编码的好友,相反是我们的敌人(对象是引用类型,状态不易追踪)。对象固有的状态在分享和传递过程中是很难追踪调试的,对象可能会传递给很多方法使用,调用链条会非常深,其状态可能在传递的任何一个节点中发生变化,不易追踪问题,尤其在并行程序编码中问题就更加明显(例如在多线程中某个线程改变、读取该对象的先后顺序均会不同程度影响对象的状态)。OOP 所带来的可变、不确定、复杂等特征完全与并行编程中倡导的小型化、核心化、高效化完全背离。

针对如上问题,苹果在 2015 年推出 Swift 编程语言的同时也介绍了面向协议程序设计 (Protocol Oriented Programming,以下简称 POP) 的思想。所谓面向协议,就是每个协议定义了一个方法或属性的 “蓝图”,可以被所有的类、结构体和枚举所遵循。例如我们最开始举的那个例子,Person 类并不是通过继承基类 Animal 来获得 eat 方法,而是通过遵守 Eatable 协议来实现。Person 类自然不止局限于 eat 这个动作,我们可以去遵守一些其他的协议(如 Walkable、Drinkable 等)来构建 Person 这个类型,也就是说 Person 的每一小块功能都可以由对应的协议(蓝图)去对应它。

面向协议编程仍然有 “类” 的概念,但是它把 “协议” 放在了一个更为重要的位置。

OOP 主要关心的是对象 “是什么”,这个对象是个数组、按钮还是输入框;而 POP 主要关心对象能 “做什么”,具体能 “做什么” 是由各种协议来定义的。例如人是可以吃饭、喝水、走路的,一个实数是可以做加减乘除四则运算的。因此在 POP 中,类型每一部分都是由 “协议” 这个蓝图去刻画的。

下面我们通过一个具体例子来体会一下 OOP 在程序设计时可能遇到的情况:

class Human {
    var name: String
    var age: Int
    
    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }
    
    func sayHi() {
        print("Hi")
    }
}

class Athlete: Human {
    override func sayHi() {
        print("Hi, I'm \(name)")
    }
}

class Runner: Athlete {
    func run() {
        print("run")
    }
}

class Swimmer: Athlete {
    func swim() {
        print("swim")
    }
}

首先我们定义了一个 Human 类,我们知道每个人都有名字和年龄,因此 Human 类有两个非常基本的属性 —— 名字和年龄。同样每个人还会和别人打招呼,Human 类就有了一个 sayHi 的动作;另外我们定义了一个运动员 Athlete 类,继承了 Human 类的行为,同时重写了 sayHi 方法,把自己的名字也介绍了;我们知道运动员有很多种,例如我们有跑步项目的运动员,也有游泳项目的运动员,他们都有不同的行为 —— run 和 swim。此时我们的问题来了 —— 万一有个运动员特别全能,他既是跑步运动员,又是游泳运动员,那我们怎么办呢?要知道很多 OOP 的语言是不支持多继承的,若我们要设计这种面向对象的关系是比较困难的。

有了运动员,自然也有裁判,我们下面给出裁判类的定义:

class Referee: Human {
    func judge() {
        print("judge")
    }
}

同样地,如果一个运动员同时取得了裁判资格,也能当裁判,那对我们来说如何设计类也是一个难题。

POP 是如何解决上述问题的呢?我们就不通过继承来设计这些关系了,而是来定义一些协议:

protocol Human {
    var name: String { get set }
    var age: Int { get set }
    
    func sayHi()
}

protocol Runnable {
    func run()
}

protocol Swimmable {
    func swim()
}

protocol Justiciable {
    func judge()
}

struct Runner: Human, Runnable {
    var name: String = "xiaoming"
    var age: Int = 20
    
    func sayHi() {
        print("Hi, I'm \(name)")
    }
    
    func run() {
        print("run")
    }
    
}

struct Swimmer: Human, Swimmable {
    var name: String = "xiaohua"
    var age: Int = 20
    
    func sayHi() {
        print("Hi, I'm \(name)")
    }
    
    func swim() {
        print("swim")
    }
    
}

Human 不再是一个类,而是变成了一个协议,来约束一个人有名字、年龄两个可读写属性,同时约束一个行为(公共蓝图)—— 打招呼,此外我们再定义 Runnable、Swimmable 两个协议。当我们去定义跑步运动员和游泳运动员时,我们就不去继承父类了,而是他们去遵循对应的协议,例如跑步运动员只遵循 Human、Runnable 协议,游泳运动员只遵循 Human、Swimmable 协议。对于 OOP 中不使用多继承就难以解决的,既会跑步、也会游泳、还能当裁判的 “六边形战士”,难题就迎刃而解了:

struct AllAroundAthleteAndReferee: Human, Runnable, Swimmable, Justiciable {
    var name: String = "zhangsan"
    var age: Int = 20
    
    func sayHi() {
        print("Hi, I'm \(name)")
    }
    
    func run() {
        print("run")
    }
    
    func swim() {
        print("swim")
    }
    
    func judge() {
        print("judge")
    }
}

因为 Human、Runnable、Swimmable、Justiciable 这四个都是协议,所以我们让这个六边形战士遵循这四个协议,得到 AllAroundAthleteAndReferee 类型。

结语

面向协议编程是贯穿 Swift 编程的基本思想,它致力于解决 OOP 中的一系列缺陷,主要关心对象能 “做什么”,用协议来定义某个类型的方法或属性的 “蓝图”。Swift 的标准库很多都采用了 POP 的思想,因此掌握 POP 思想在实际 Swift 开发中是非常基础且重要的。

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注