第一章 面向对象编程和面向协议编程
这本书是关于面向协议编程的。当苹果在 2015 年世界开发者大会上宣布 Swift 2 时,
他们也声明 Swift 是世界上第一个面向协议编程的语言。通过它的名字, 我们可能认为面向协议编程都是跟协议相关的; 然而, 这可能是一个错误的假定。面向协议编程不仅仅是关于协议; 实际上它不仅是编写程序的新方式, 也是我们思考编程的新方式。
在这一章, 你会学到:
- Swift 是怎么用作面向对象的编程的。
- Swift 是怎么用作面向协议的编程语言的。
- 面向对象编程和面向协议编程的区别。
- 面向协议编程相对于面向对象编程的优点。
虽然这本书是关于面向协议编程的,我们将通过讨论 Swift 是怎么用作面向对象的编程语言开始。理解好面向对象的编程会有助于理解面向协议的编程并一窥面向协议编程设计所解决的问题。
作为面向对象编程语言的 Swift
面向对象编程是一种设计哲学。使用面向对象的编程语言写程序和使用老旧的诸如 C 和 Pascal 等过程式编程语言编写程序从根本上是不同的。过程式语言通过依靠过程(或程序)使用一系列说明来告诉计算机每一步该怎么做。然而, 面向对象的编程全部是关于对象的。这似乎是一个非常明显的声明。但是本质上,当我们谈论面向对象编程的时候,我们需要考虑对象。
对象是包含属性和方法的数据结构。对象可以是一个东西,在英语中,它们通常被当做名词。这些对象可以是真实世界中的对象或者虚拟的对象。如果你四处看看,你会发现很多真实世界中的对象,并且,实际上,它们中的所有一切都能以一种带有属性和动作的面向对象的方式被模型化。
当我开始写这一章时,我看着外面,我看到了湖泊、很多树、草地、我的狗、还有我家后院中的篱笆。所有这些物品都可以被模型化为含有属性和动作的对象。
当我写这一章的时候,我也想起了我最喜欢的能量饮料。那种能量饮料叫做 Jolt(姑且叫它加多宝)。 不知道还有多少人记得 Jolt 碳酸水或 Jolt 能量饮料,但是我整个大学期间都在喝它。一罐加多宝(Jolt)可以被模型化为带有属性(容量、咖啡因量、温度和尺寸)和动作(喝和温度变化)的对象。
我们可以把加多宝保存在冷藏器中来使它们保持冷却。这个冷藏器也可以被模型化为对象因为它拥有属性(温度、加多宝罐数、最大存储罐数)和动作(添加和移除罐子)。
正是对象让面向对象编程那样强大。使用对象,我们可以模型化真实世界中的对象, 例如加多宝的罐子、或视频游戏中的诸如字符的虚拟对象。这些对象之后可以在我们的应用程序中交互以模型化真实世界中的行为或我们想要的虚拟世界中的行为。
在计算机程序中,我们不能在没有能告知程序期望什么样的属性和动作的蓝图的情况下创建对象。在大部分面向对象的编程语言中,这个蓝图以类的形式出现。类是一种允许我们把对象的属性和动作封装到单个类型中的结构。
我们在类中使用构造函数(initializers)来创建类的实例。我们通常使用这些构造函数来为对象设置属性的初始值或执行我们的类需要的其它初始化。一旦我们创建了类的实例,之后就能在代码中使用它了。
关于面向对象编程的所有解释都很好,但是没有什么比实际的代码更能解释这个概念了。我们来看看在 Swift 中是怎么使用类来模型化加多宝和冷藏器的。下面我们会从模型化一罐加多宝开始:
class Jolt {
var volume: Double
var caffeine: Double
var temperature: Double
var canSize: Double
var description: String
init(volume: Double, caffeine: Double, temperature: Double) {
self.volume = volume
self.caffeine = caffeine
self.temperature = temperature
self.description = "加多宝凉茶"
self.canSize = 24
}
func drinking(amount: Double) {
volume -= amount
}
func temperatureChange(change: Double) {
temperature += change
}
}
在这个 Jolt 类中,我们定义了 5 个属性。这些属性是 volume(罐子中 Jolt 的量),caffeine(罐子中有多少咖啡因),temperature(罐子的当前温度),description(产品描述),和 canSize(罐子自身的尺寸)。然后我们定义了一个用于初始化对象属性的构造函数。该构造函数会确保所有的属性在实例被创建后都被合适地初始化了。最后,我们为 can 定义了两个动作。这两个动作是 driking(有人喝罐子中的饮料时调用)和 temperatureChange(罐子的温度变化时调用)。
现在,我们看看怎么模型化一个冷藏器以使我们的加多宝罐子保持冷藏,因为没有人喜欢加热的加多宝罐子:
class Cooler {
var temperature: Double
var cansOfJolt = [Jolt]()
var maxCans: Int
init(temperature: Double, maxCans: Int) {
self.temperature = temperature
self.maxCans = maxCans
}
func addJolt(jolt: Jolt) -> Bool {
if cansOfJolt.count < maxCans {
cansOfJolt.append(jolt)
return true
} else {
return false
}
}
func removeJolt() -> Jolt? {
if cansOfJolt.count > 0 {
return cansOfJolt.removeFirst()
} else {
return nil
}
}
}
我们以和模型化加多宝罐类似的方法模型化了冷藏器。我们以定义 3 个冷藏器的属性开始。那三个属性是 temperature(冷藏器中的当前温度)、cansOfJolt(冷藏器中加多宝的罐数)和 maxCans(冷藏器能装下地最大罐数)。当我们创建 Cooler 类的实例时,我们使用一个构造函数来初始化属性。最后,我们为冷藏器定义了两个动作。它们是 addJolt(用于为冷藏器添加加多宝罐)或 removeJolt(用于从冷藏器中移除加多宝罐)。既然我们拥有了 Jolt 和 Cooler 类,让我们看看怎么把这两个类组合到一块使用:
var cooler = Cooler(temperature: 38.0, maxCans: 12)
for _ in 1...5 {
let can = Jolt(volume: 23.5, caffeine: 280, temperature: 45)
let _ = cooler.addJolt(can)
}
let jolt = cooler.removeJolt()
jolt?.drinking(5)
print("罐子中还剩 \(jolt?.volume) ml 加多宝")
在这个例子中,我们使用构造函数来设置默认属性创建了 Cooler 类的一个实例。当我们创建了 Jolt 类的 6 个实例并使用 for-in 循环把它们添加到冷藏器中。最后,我们从冷藏器中拿出一罐加多宝并喝了一点。还有比这更爽的吗?
这种设计对于我们这个简单地例子似乎工作的很好; 然而,它真的不是那么灵活。虽然我真的喜欢咖啡因,但是我妻子不喜欢;她更喜欢不含咖啡因的减肥可乐。如果使用我们当前的设计,当她要在冷藏器中添加一些她的减肥可乐时,我们不得不告诉她那是不可能的因为我们的冷藏器只接受 Jolt 实例。这不好,因为现实中冷藏器不是这么工作的,因为我不会告诉妻子她不能把减肥可乐放进冷藏器中(相信我没有人会愿意告诉他的妻子她不能把减肥可乐放进冷藏器中,不然等着看吧,她一整天都会唧唧歪歪的!) 所以,我们怎么使这个设计更加灵活呢?
答案是多态。多态来源于希腊单词 Poly(多的意思)和 Morph(形式)。在计算机科学中,当我们想在代码中使用单个接口来表示多个类型时我们使用多态。多态让我们拥有了以唯一的方式和多个类型进行交互的能力, 我们能在任何时候添加遵守那个接口的额外的对象类型。然后我们可以在代码中几乎不做更改地使用这些额外的类型。
使用面向对象编程语言,我们能使用子类化来达到多态和代码复用。子类化就是一个类继承自它的父类。例如, 假如我们有一个模型化普通人的 Person 类, 然后我们可以子类化 Person 类来创建 Student 类。则 Student 类会继承 Person 类的所有属性和方法。 Student 类可以覆盖任何它继承到的属性和方法并且/或者添加它自己的额外的属性和方法。之后我们能添加派生于 Person 超类的额外的类,并且我们能通过 Person 类呈现的接口来跟所有这些子类进行交互。
当一个类派生自另一个类,那么原来的类,即我们派生新类的那个类,就是人们熟知的超类或父类,派生出的新类就是人们所熟知的孩子或子类。在我们的 person-student 例子中, Person 类是超类或父类, 而 Student 是子类或孩子类。 在这本书中,我们会一直使用属于超类和子类。
多态是使用子类化达到的因为我们能通过超类呈现的接口和所有子类的实例进行交互。举个例子,如果我们有 3 个孩子类(Student,Programmer,和 Fireman)都是 Person 类的子类,那么我们能够通过 Person 类呈现的接口跟所有 3 个子类进行交互。如果 Person 类拥有一个名为 running()
的方法,那么我们可以假定 Person 类的所有子类都拥有一个名字 running()
的方法(这个方法要么继承自 Person 要么是子类中的方法覆盖了 Person 类的中的方法)。因此,我们可以使用 running()
方法跟所有子类进行交互。
让我们来看看多态是怎么帮助我们把 drinks 而非 Jolt 添加到我们的冷藏器中的。在我们原来的例子中,我们可以在 Jolt 类中硬编码罐子的尺寸因为 Jolt 能量饮料只有 24 盎司的罐子卖(苏打有不同的尺寸,但是能量饮料只卖 24 盎司的)。下面的枚举定义了冷藏器将接受的罐子尺寸:
enum DrinkSize {
case Can12
case Can16
case Can24
case Can32
}
这个 DrinkSize 枚举让我们在冷藏器中使用 12、16、24 和 32 盎司的饮料尺寸。
现在, 让我们看看我们的基类或超类,所有的 drink 类型都会派生自它。我们会把这个超类叫做 Drink:
class Drink {
var volume: Double
var caffeine: Double
var temperature: Double
var drinkSize: DrinkSize
var description: String
init(volume: Double, caffeine: Double, temperature: Double, drinkSize: DrinkSize) {
self.volume = volume
self.caffeine = caffeine
self.temperature = temperature
self.drinkSize = drinkSize
self.description = "饮料基类"
}
func drinking(amount: Double) {
volume -= amount
}
func temperatureChange(change: Double) {
self.temperature += change
}
}
这个 Drink 类很像原来的 Jolt 类。我们定义了原 Jolt 类中同样拥有的 5 个属性;然而,drinkSize 现在被定义为 DrinkSize 类型而非 Double 类型。我们为 Drink 类型定义了单个构造函数以初始化类中的所有 5 个属性。最后, 我们拥有了和原来的 Jolt 类相同的两个方法,它们是 drinking() 和 temperatureChange()。需要注意的一件事情是,在 Drink 类中,我们的 description 属性被设置到 Drink 基类中了。
现在,我们创建一个 Jolt 类成为 Drink 类的子类。这个类将从 Drink 类中继承所有的属性和方法:
class Jolt: Drink {
init(temperature: Double) {
super.init(volume: 23.5, caffeine: 280,
temperature: temperature, drinkSize: DrinkSize.Can24)
self.description = "加多宝能量饮料"
}
}
就像我们在 Jolt 类中看到的那样,我们不需要重新定义继承自超类 Drink 的属性和方法。我们给 Jolt 类添加了一个构造函数。这个构造函数要求提供 Jolt 罐子的温度。所有其他的值被设置为它们给 Jolt 罐提供的默认值。
现在, 我们看看怎么创建 Cooler 类来接受除了 Jolt 类型之外的其他饮料类型:
class Cooler {
var temperature: Double
var cansOfDrinks = [Drink]()
var maxCans: Int
init(temperature: Double, maxCans: Int) {
self.temperature = temperature
self.maxCans = maxCans
}
func addDrink(drink: Drink) -> Bool {
if cansOfDrinks.count < maxCans {
cansOfDrinks.append(drink)
return true
} else {
return false
}
}
func removeDrink() -> Drink? {
if cansOfDrinks.count > 0 {
return cansOfDrinks.removeFirst()
} else {
return nil
}
}
}
这个 Cooler 类很像原来的那个 Cooler 类,除了我们把所有对 Jolt 类的引用替换成了对 Drink 类的引用。因为 Jolt 类是 Drink 类的一个子类,我们可以把 Jolt 类用在需要 Drink 实例的任何地方。我们来看看这是怎么工作的。下面的代码会创建一个 Cooler 类。然后向冷藏器中添加 6 罐加多宝(Jolt),再从冷藏器中拿出一罐加多宝来喝:
var cooler = Cooler(temperature: 38.0, maxCans: 24)
for _ in 0...5 {
let can = Jolt(temperature: 45.1)
let _ = cooler.addDrink(can)
}
let jolt = cooler.removeDrink()
cooler.cansOfDrinks.count
jolt?.drinking(5)
print("这罐加多宝还剩 \(jolt?.volume) 盎司")
注意在这个例子中, 在需要 Drink 类实例的地方我们使用了 Jolt 类的实例。这就是多态。既然我们冷藏器中有了 Jolt,我们将要开始我们的旅途了。我妻子当然想带上她的不含咖啡因的减肥可乐,所以她问我能否在冷藏器中放上一些以保持冷藏。我知道我们阻止不了她喝减肥可乐,所以我们很快创建了一个可以使用的 CaffeineFreeDietCoke 类。这个类的代码如下:
class CaffeineFreeDietCoke: Drink {
init(volume: Double, temperature: Double, drinkSize: DrinkSize) {
super.init(volume: volume, caffeine: 0, temperature: temperature, drinkSize: drinkSize)
self.description = "不含咖啡因的减肥可乐"
}
}
CaffeineFreeDietCoke 类和 Jolt 类很像。 它们都是 Drink 类的子类,并且它们都定义了一个构造函数来初始化类。关键是它们都是 Drink 类的子类,这意味着在冷藏器中这两个类的实例我们都可以使用。因此, 当我的妻子带了 6 瓶不含咖啡因的减肥可乐时,我们可以把它们放进冷藏器中就像我们存放加多宝罐子一样。下面的代码解释了这个:
var cooler = Cooler(temperature: 38.0, maxCans: 24)
for _ in 0...5 {
let can = Jolt(temperature: 45.1)
let _ = cooler.addDrink(can);
}
for _ in 0...5 {
let can = CaffeineFreeDietCoke(volume: 15.5, temperature: 45, drinkSize: DrinkSize.Can16)
let _ = cooler.addDrink(can)
}
在这个例子中, 我们创建了一个冷藏器(cooler)实例;我们在冷藏器中放进了 6 罐加多宝(Jolt)和 6 罐不含咖啡因的减肥可乐。使用多态,就像这儿展示的,允许我们创建尽可能多的 Drink 子类, 并且所有这些子类都可以用在 Cooler 类中而不需更改 Cooler 类的代码。这让我们的代码更加灵活。
所以,当我们从冷藏器中拿走一个罐子会发生什么? 显然地,如果我妻子拿到一罐加多宝,她会把它放回去并拿走另外不同的一罐。但是她怎么知道她拿到的是什么呢?
为了检查某个实例是哪个特定的类型,我们使用类型检查操作符(is)。如果实例的类型是那个类型则类型检查操作符返回 true, 否则就返回 false。在下面的代码中,我们使用类型检查操作符来不断地从冷藏器中移除罐子直到我们找到不含咖啡因的减肥可乐为止:
var foundCan = false
var wifeDrink: Drink?
while !foundCan {
if let can = cooler.removeDrink() {
if can is CaffeineFreeDietCoke {
foundCan = true
wifeDrink = can
} else {
cooler.addDrink(can)
}
}
}
if let drink = wifeDrink {
print("拿到了 " + drink.description)
}
在这个代码中,我们的 while 循环持续循环直到 foundCan 布尔值被设置为 true。 在 while 循环中,我们从冷藏器中拿出一罐饮料然后使用类型检查操作符(is)来看我们拿出的罐子是否是 CaffeineFreeDietCoke 类的实例。如果它是 CaffeineFreeDietCoke 类的实例,那么我会把 foundCan 布尔值设置为 true 并把 wifeDrink 变量设置为刚从冷藏器中拿走的那罐饮料的实例。 如果那罐饮料不是 CaffeineFreeDietCoke 类的实例,那么我们会把罐子放回到冷藏器中并回到循环中以抓取另外一罐饮料。
在上面的例子中, 我们展示了 Swift 是怎么用作面向对象的编程语言的。我们还使用了多态来让我们的代码更加灵活并且更容易扩展;然而,这个设计有几个缺点。在我们进行面向协议编程之前,我们来看看这两个缺点。然后,我们会看到面向协议编程是怎么让这个设计更好的。
我们的设计中的第一个缺点是 drink类(Jolt, CaffeineFreeDietCoke 和 DietCoke) 的初始化。当我们初始化子类的时候,我们需要调用超类的构造函数。这是把双刃剑。虽然调用我们超类的构造函数让我们拥有了一致性的初始化,但是如果我们不小心的话,它也会带给我们不合适的初始化。例如,假设说我们使用如下代码创建了另外一个叫做 DietCoke 的 Drink 类:
class DietCoke: Drink {
init(volume: Double, temperature: Double, drinkSize: DrinkSize) {
super.init(volume: volume, caffeine: 45, temperature: temperature, drinkSize: drinkSize)
}
}
如果我们仔细看, 我们会看到在 DietCoke 类中的构造函数中, 我们根本没有设置 description 属性。因此,这个类的 description 会使用 Drink 基类中的 description, 这不是我们想要的。
当我们创建这样的子类的时候要小心以确保所有的属性被合理的设置了, 我们不能指望超类的构造函数会为我们合理地设置所有的属性。
我们的设计的第二个缺点是我们使用了引用类型。 虽然那些熟悉面向对象编程的人可能认为这不是一个缺点并且很多情况下都偏好使用引用类型,在我们的设计中,把 drink 的类型定义为值类型更有意义。如果你不熟悉引用类型和值类型是如何工作的,我们会在第二章,我们的类型选择里深入讨论它们。
当我们传递引用类型(即我们传递给函数或像数组那样的集合)时,我们传递的时对原实例的引用。当我们传递值类型的实例时,我们传递的是对原实例的一份拷贝。通过实验下面的代码,我们来看看如果我们不小心地使用了引用类型会导致什么问题:
var jolts = [Drink]()
var myJolt = Jolt(temperature: 48)
for _ in 0...5 {
jolts.append(myJolt)
}
jolts[0].drinking(10)
for (index, can) in jolts.enumerate() {
print("Can \(index) amount Left: \(can.volume)")
}
在这个例子中,我们创建了一个会包含 Drink 类或 Drink 类的子类的实例的数组。之后我们创建了一个 Jolt 类的实例并在数组里放了 6 罐加多宝。接着,我们从数组中拿出第一罐来喝并打印出数组中每罐加多宝的剩余容量。如果我们运行这段代码,我们会看到如下结果:
Can 0 amount Left: 13.5
Can 1 amount Left: 13.5
Can 2 amount Left: 13.5
Can 3 amount Left: 13.5
Can 4 amount Left: 13.5
Can 5 amount Left: 13.5
就像我们从结果中看到的,数组中的所有加多宝罐子都拥有同样的剩余容量。这是因为我们创建了 Jolt 类的单个实例,之后我们在 jolts 数组中添加了 6 个该单个实例的引用。因此,我们从数组中拿出第一罐饮料时来喝时,我们实际上把数组中的每一罐都拿出来喝了。
这种错误对于有经验的面向对象的程序员看起来不是什么问题;然而,它经常出现在不熟悉面向对象编程的初级程序员或开发者之中很令人吃惊。当类的构造函数很复杂时这种错误出现的更加频繁。我们可以通过使用第六章,在 Swift 中遵循设计模式中看到的 Builder 模式来避免这个问题,或者在我们的自定义类中实现一个 copy 方法以拷贝一份实例。
就像上面的例子中展示的那样,面向对象编程和子类化需要注意的另外一件事情是, 一个类只能拥有一个超类。例如,我们的 Jolt 类的超类是 Drink 类。这可能导致单个超类变得非常臃肿并且包含所有子类中所不需要或不想要的代码。这在游戏开发中是一个普遍的问题。
现在, 我们来看看怎么使用面向协议的编程来实现我们的 drinks 和 cooler 例子。
作为面向协议编程语言的 Swift
使用面向对象编程,我们在设计时通常从考虑对象和类的层级开始。面向协议编程有点不同。这儿, 我们在设计时从考虑协议开始。然而,就像我们在这一章的开头所说的,面向协编程不仅仅是关于协议的。
当我们浏览这一节时, 我们会就我们当前的例子简要讨论下组成面向协议编程的不同条目。然后我们会在接下来的几章里深入探讨这些条目以让你更好地理解在我们的应用程序中是怎么完整地使用面向协议编程的。
在之前的小节中,当我们把 Swift 用作面向对象的编程语言时, 我们使用类的层级来设计我们的方案, 就像下面的展示图一样:
为了用面向协议编程重新设计这个方案,我们需要重新思考该设计的几个方面。第一个方面是我们怎么重新考虑 Drink 类。面向协议编程声明我们应该从协议而不是超类开始。这意味着我们的 Drink 类会变成 Drink 协议。我们可以使用协议扩展为遵守该协议的 drink 类添加通用代码。我们将在第四章关于协议中复习协议,并且我们会在第五章让我们扩展某些类型中涵盖协议扩展。
我们要重新思考的第二个方面是引用(类)类型的使用。在 Swift 中,苹果已经声明了在尽可能合理的地方更偏好使用值类型胜过使用引用类型。是使用引用类型还是使用值类型有很多地方需要考虑,我们会在第二章中深入了解这个问题。在这个例子中,我们会在 drink类型(Jolt 和 CaffeineFreeDietCoke)中使用值(结构体)类型,在 Cooler 类型中使用引用(类)类型。
在这个例子中,为 drink 类型使用值类型和为 Cooler 类型使用引用类型的决定依赖于我们怎么使用这些类型的实例。我们的 drink 类型的实例只会有一个拥有者。例如,当饮料在冷藏器中时,冷藏器就拥有了它。但是之后,当有人把它从冷藏器中拿了出来,这个人就拥有了它。
Cooler 类型和 drink 类型有点不一样。虽然 drink 类型一次只会有一个拥有者和它交互,但是 Cooler 类型可能在代码中拥有几个部分来跟它进行交互。例如,我们可以让代码的一部分为冷藏器添加饮料而让几个人的实例从冷藏器中喝饮料。
总的来说, 我们使用值类型(结构体)来模型化我们的 drink 类型因为一次只有代码的一部分能跟 drink 类型的实例进行交互。然而,我们使用引用类型(类)来模型化 Cooler 因为我们的代码的多个部分将和 Cooler 类型的同一个实例进行交互。
我们会在该书中多次强调这点:引用类型和值类型的一个主要区别是我们怎么传递类型的实例。当我们传递引用类型的实例时, 我们传递的是对原实例的引用。这意味着变化被反射到这两个引用中。当我们传递值引用的时候,我们传递的是对原实例的一份拷贝。这意味着一个实例中的更改不会反射到其他实例中。
在我们你一步实验面向协议编程之前, 我们来看看怎么以面向协议编程的方式来重写我们的例子。我们将以创建 Drink 协议开始:
protocol Drink {
var volume: Double {get set}
var caffeine: Double {get set}
var temperature: Double {get set}
var drinkSize: DrinkSize {get set}
var description: String {get set}
}
在我们的 Drink 协议中, 我们定义了每个遵守该协议的类型必须要提供的 5 个属性。 DrinkSize 类型和我们这一章中面向对象小节中的 DrinkSize 一样。
在我们添加遵守 Drink 协议的任何类型之前,我们想扩展一下这个协议。协议扩展在 Swift 2 中被添加进来,它允许我们为遵守该协议的类型提供功能。这让我们为遵守该协议的所有类型定义行为,而不是把行为添加到每个遵守该协议的单独的类型中。在 Drink 协议的扩展中,我们会定义两个方法: drinking()
和 temperatureChange()
。 这个这一章中的面向对象编程中的 Drink 超类中的两个方法相同。下面是 Drink 扩展的代码:
extension Drink {
mutating func drinking(amount: Double) {
volume -= amount
}
mutating func temperatureChange(change: Double) {
temperature += change
}
}
现在,任何遵守 Drink 协议的类型都会自定地接收到 drinking() 方法和 temperatureChange 方法。协议扩展很适合为遵守协议的所有类型添加通用的代码。
这和为超类添加功能很像,其中所有的子类从超类中接收功能。单独遵守协议的类型也能遮蔽由协议扩展所提供的功能,这和重写超类中的功能类似。
现在我们来创建 Jolt 类型和 CaffeineFreeDietCoke 类型:
struct Jolt: Drink {
var volume: Double
var caffeine: Double
var temperature: Double
var drinkSize: DrinkSize
var description: String
init(temperature: Double) {
self.volume = 23.5
self.caffeine = 280
self.temperature = temperature
self.description = "加多宝能量饮料"
self.drinkSize = DrinkSize.Can24
}
}
struct CaffeineFreeDietCoke: Drink {
var volume: Double
var caffeine: Double
var temperature: Double
var drinkSize: DrinkSize
var description: String
init(volume: Double, temperature: Double, drinkSize: DrinkSize) {
self.volume = volume
self.caffeine = 0
self.temperature = temperature
self.description = "不含咖啡因的减肥可乐"
self.drinkSize = drinkSize
}
}
如我们所见, Jolt 和 CaffeineFreeDietCoke 类型都是结构体而非类。这意味着它们都是值类型而非引用类型,就像它们在面向对象设计中一样。这两个类型都实现 Drink 协议中定义的 5 个属性还有一个构造函数用于初始化类型的实例。和面向对象的 drink 类的例子相比,这些类型需要的代码更多。然而,在这里很容易理解这些 drink 类型中发生了什么因为所有东西是在类型自身中初始化的,而非在它们的超类中初始化。
最后, 我们看看 cooler 类型:
class Cooler {
var temperature: Double
var cansOfDrinks = [Drink]()
var maxCans: Int
init(temperature: Double, maxCans: Int) {
self.temperature = temperature
self.maxCans = maxCans
}
func addDrink(drink: Drink) -> Bool {
if cansOfDrinks.count < maxCans {
cansOfDrinks.append(drink)
return true
} else {
return false
}
}
func removeDrink() -> Drink? {
if cansOfDrinks.count > 0 {
return cansOfDrinks.removeFirst()
} else {
return nil
}
}
}
如我们所见, Cooler 类和我们在这一章的面向对象编程一节中所创建的类相同。对于把 Cooler 类型创建为结构体而非类会颇有微词, 但是它实际上取决于我们在代码中打算怎么用它。之前, 我们声明代码的多个部分会需要跟单个 cooler 实例进行交互。因此,最好把我们的 cooler 实现为引用类型而非值类型。
注意
苹果的建议是在尽可能合理地地方优先使用值类型而非引用类型。因此,疑惑的时候,推荐以值类型开始而非引用类型。
下面的示意图展示了我们的新设计看起来什么样:
既然我们完成了重新设计,让我们来总结一下面向协议编程和面向对象编程之间有什么不同吧。
总结面向协议编程和面向对象编程
我们刚刚看到了 Swift 是怎么既用作面向对象的编程语言又用作面向协议的编程语言的,还有两者真正的不同之处。在这一章展示的例子中, 这两种设计之间有两个主要的不同之处。
第一个不同之处是当我们谈论面向协议编程的时候应该从协议开始而非从超类开始。然后我们可以使用协议扩展来为遵守该协议的类型添加功能。而对于面向对象编程,我们从超类开始。在我们重新设计我们的例子的时候,我们把 Drink 超类转换为 Drink 协议,然后使用协议扩展以添加 drinking() 方法和 temperatureChange() 方法。
我们看到的第二个实际的区别是我们的 drink 类型使用了值类型(结构体)而非引用类型(类)。苹果已经说了我们应该在合适的地方尽可能地偏好使用值类型而非引用类型。在我们的例子中,当我们实现我们的 drink 类型时,使用值类型是合适的;然而,我们仍旧把 Cooler 类型实现为引用类型。
对于我们的代码的长期维护混合和匹配值类型和引用类型可能并不是最好的方法。我们在例子中使用它使为了强调值类型和引用类型之间的不同。在第二章我们的类型选择中,我们会详细讨论这个。
面向对象的设计和面向协议的设计都使用了多态让我们使用同样的接口来跟不同的类型进行交互。在面向对象的设计中,我们使用了超类提供的接口来跟所有的子类进行交互。在面向协议的设计中,我们使用了协议和协议扩展提供的接口来跟遵守该协议的类型进行交互。
既然我们已经总结了面向对象编程设计和面向协议编程设计之间的区别,让我们走得更近一点来看看两者之间的区别。
面向对象编程和面向协议编程
我在这一章的开头提到面向协议编程不仅仅是关于协议的,而且也是一种新的编写程序和编程思考方式。在这一节里,我们会验证两种设计模式的不同之处,并看看那个声明意味着声明。
作为一个开发者,我们主要的目标是开发好一个应用程序并且工作良好,但是我们也应该着眼于写干净并安全的代码。在这一节中,我们会谈论很多关于写干净和安全的代码的东西,所以让我们通过这些条目来查看我们的意思是什么。
干净的代码是非常易读和易理解的代码。写出干净的代码很重要,因为我们写得任何代码都需要由某人维护,而那个人通常是写出那些代码的人。没有什么比回头看你所写的代码但是又不理解它干什么更糟糕的了干净的代码更容易找出代码中得错误并且容易理解。
关于安全的代码我们的意思是代码很难被中断。作为一个开发者没有什么比在代码中做一处小的修改并导致代码库中到处是错误或让应用程序出现很多 bugs 更令人沮丧的了。通过书写干净的代码,我们的代码将会天然地更安全因为其他开发者能查看代码并准确地知道它做了什么。
现在,我们简要地看看协议/协议扩展和超类之间的区别。我们会在第四章-协议的全部和第五章-扩展某些类型中涵盖更多有关的东西。
协议和协议扩展 VS. 超类
在面向对象编程的例子中,我们创建了一个 Drink 超类,从这个超类派生出所有的 drink 类。在面向协议编程的例子中,我们结合使用了协议和协议扩展来达到同样地结果;然而,使用协议有几个优点。
刷新一下我们对两种法案的记忆,我们来看看 Drink 超类和 Drink 协议和协议扩展的代码。下面的代码展示了 Drink 超类:
class Drink {
var volume: Double
var caffeine: Double
var temperature: Double
var drinkSize: DrinkSize
var description: String
init(volume: Double, caffeine: Double, temperature: Double, drinkSize: DrinkSize) {
self.volume = volume
self.caffeine = caffeine
self.temperature = temperature
self.description = "饮料基类"
self.drinkSize = drinkSize
}
func drinking(amount: Double) {
volume -= amount
}
func temperatureChange(change: Double) {
temperature += change
}
}
Drink 类是一个我们能创建实例的完备类型。这是件好事也是件坏事。有的时候,就像这个例子中的,这个时候我们不应该创建超类的实例;我们应该只创建子类的实例。为此,我们仍旧可以在面向对象编程中使用协议;然而,我们仍然需要使用协议扩展来添加通用的功能,这会导致我们向下沿着面向协议编程的路径继续。
现在, 我们来看看怎么在 Drink 协议和 Drink 的协议扩展中使用面向协议编程:
protocol Drink {
var volume: Double {get set}
var caffeine: Double {get set}
var temperature: Double {get set}
var drinkSize: DrinkSize {get set}
var description: String {get set}
}
extension Drink {
mutating func drinking(amount: Double) {
volume -= amount
}
mutating func temperatureChange(change: Double) {
temperature += change
}
}
两种方式的代码都很好而且易懂。作为个人偏好, 我喜欢把实现和定义分开。因此,对于我来说,协议/协议扩展代码更好,但是这真得是个人偏好的问题。然而,在后面几页中我们会看到协议/协议扩展的方法整体更干净和易懂些。
协议/协议扩展相比超类有 3 个优点:第一个优点是类型可以遵守多个协议而只能拥有一个超类。这意味着我们可以创建很多个协议以添加每个特定的功能而不是把所有功能都写到一个巨大的类中。例如,使用我们的 Drinks 协议,我们还能创建包含特定需求和功能的 DietDrink、SodaDrink、和 EnergyDrink 协议。然后,DietCoke 和 CaffeineFreeDietCoke 类型会遵守 Drink、DietDrink 和 SodaDrink 协议,而 Jolt 结构体会遵守 Drink 和 EnergyDrink 协议。使用超类, 我们需要把定义在 DietDrink、SodaDrink 和 EnergyDrink 协议中得功能组合到单个巨大的超类中。
第二个优点是我们不需要源代码就可以使用协议扩展来添加功能。这意味着我们可以扩展任何协议,即使是 Swift 自己内建的协议也能扩展。而给超类添加功能我们需要知道源代码。我们可以使用扩展给超类添加功能,这意味着所有的子类会继承那个功能。然而,我们一般使用扩展来为特定的类添加功能而非为类的层级添加功能。
第三个优点是协议可以被类、结构体和枚举遵守,而类层级被约束为类类型。协议/协议扩展让我们可以在尽可能合理的地方使用值类型。
实现 drink 类型
首先看一下面向对象的实现:
class Jolt: Drink {
init(temperature: Double) {
super.init(volume: 23.5, caffeine: 280, temperature: temperature, drinkSize: DrinkSize.Can24)
self.description = "加多宝能量饮料"
}
}
class CaffeineFreeDietCoke: Drink {
init(volume: Double, temperature: Double, drinkSize: DrinkSize) {
super.init(volume: volume, caffeine: 0, temperature: temperature, drinkSize: drinkSize)
self.description = "不含咖啡因的减肥可乐"
}
}
这两个都是 Drink 超类的子类并且都实现了单个构造函数。我们需要完全理解超类期望它们合理地去实现什么。例如,如果我们没有完全理解超类,我们可能会忘记设置 description 属性。在我们的例子中,忘记设置超类可能没有什么大问题,但是在更复杂的类型中,忘记合理地设置某个属性可能会导致意想不到的行为。我们可以在超类的构造函数中设置所有的属性来阻止这些错误;然而,这在某些情况下可能不行。
现在,我们看看怎么在面向协议编程中实现 drink 类型:
struct Jolt: Drink {
var volume: Double
var caffeine: Double
var temperature: Double
var drinkSize: DrinkSize
var description: String
init(temperature: Double) {
self.volume = 23.5
self.caffeine = 280
self.temperature = temperature
self.description = "加多宝能量饮料"
self.drinkSize = DrinkSize.Can24
}
}
struct CaffeineFreeDietCoke: Drink {
var volume: Double
var caffeine: Double
var temperature: Double
var drinkSize: DrinkSize
var description: String
init(volume:Double, temperature: Double, drinkSize: DrinkSize) {
self.volume = volume
self.caffeine = 0
self.temperature = temperature
self.description = "不含咖啡因的减肥可乐"
self.drinkSize = drinkSize
}
}
面向协议编程的例子更易懂而且更安全。在面向对象编程的例子中,所有的属性都定义在超类中。我们需要查看超类的代码或文档来看看超类中定义了哪些属性,并且这些属性是怎么定义的。使用协议,我们也需要查看协议自身或协议的文档来查看要实现的属性,但是实现是在类型自身中做的。这允许我们在类型自身中查看所实现的所有东西而不需要来回查看超类的代码,或者穿梭于类的层级来查看东西是怎么实现和初始化的。
子类中的构造函数也必须调用超类的构造函数以确保超类的所有属性都被合理地设置了。虽然这的确保证了子类之间初始化的一致性,但是它也隐藏了类是如何初始化的。在面向协议的例子中,所有的初始化都是在类型自身中完成的。因此,我们不需要在类的层级之间来回穿梭以查看所有东西是如何初始化的。
Swift 中的超类提供了我们要求的实现。Swift 中的协议仅仅是一个约定,任何遵守给定协议的类型必须填充协议指定的需求。因此,使用协议,所有的属性、方法和构造函数都被定义在遵守协议的类型自身中。这让我们很容易地查看到所有东西是怎么被定义和初始化的。
值类型 VS. 引用类型
引用类型和值类型的一个主要的区别就是类型是如何传递的。当我们传递引用类型(class)的实例时,我们传递的对原实例的引用。这意味着所做的任何更改都会反射回原实例中。当我们传递值类型的实例时,我们传递的是对原实例的一份拷贝。这意味着所做的任何更改都不会反射回原实例中。
我们之前提到过,在我们的例子中, drink 类型的实例一次应该只有一个拥有者。没有必要在代码中得多个部分中和单个 drink 类型进行交互。举个例子,当我们创建了一个 drink 类型,我们会把它放进冷藏器类型实例中。然后,如果有人过来了然后从冷藏器中拿走了一罐饮料,那么这个人会拥有那个饮料实例。如果这个人把饮料给了另外一个人,那么第二个人会拥有那罐饮料。
使用值类型确保了我们总是得到一个唯一的实例因为我们传递了一份对原实例的拷贝而非对原实例的引用。因此,我们能相信没有代码中的其它部分会意外地修改我们的实例。这在多线程环境中尤其有用,其中不同的线程可以修改数据并创建意外地行为。
赢家是 ...
没有谁赢谁输,使用合适的工具做正确的事情。