我们刚刚看到 Swift
如何同时被作为面向对象编程语言和面向协议编程语言,以及两者之间的真正区别是什么。在本章给出的例子中,这两种设计有两个主要的区别。
第一个区别是面向协议编程中应该从协议开始,而不是超类。且可以通过协议扩展向遵循该协议的类型添加功能。面向对象编程是从超类开始。当我们重新设计示例时,我们将 Drink 超类转换为 Drink 协议,然后使用协议扩展drinking()
和 temperatureChange()
方法。
第二个真正的区别是我们的饮料类型使用了值类型 (structures) 而不是引用类型 (class)。苹果已经说过,在适当的情况下,我们应该优先使用值类型而不是参考类型。在我们的示例中,饮料类型使用值类型是合适的; 而冷却器类型仍然为引用类型。
混合和匹配值和引用类型对于代码长期的可维护性来说不是最佳方法。通过在示例中使用它来强调值和引用类型之间的差异。在第2章,类型选择中,我们将详细讨论这一点。
面向对象的设计和面向协议的设计都体现了多态性,让我们使用相同的接口与不同类型进行交互。在面向对象的设计中,我们使用超类提供的接口与所有子类进行交互。在面向协议的设计中,我们使用协议和协议扩展提供的接口与符合协议的类型进行交互。
既然我们已经总结了面向对象编程设计和面向协议编程设计之间的差异,让我们仔细看看这些差异。
我在本章开头提到,面向协议编程不仅仅是协议,不仅是编写应用程序的一种新方式,也是一种新编程思想。在本节中,我们将研究两种设计之间的差异,便于了解实际含义。
作为一名开发人员,我们的主要目标始终是开发一个运行正常的应用程序,但我们也应该专注于编写整洁和安全的代码。在本节中,我们将大量讨论整洁和安全的代码,首先让我们看看这些术语的含义。
整洁的代码是非常容易阅读和理解的代码。编写整洁的代码很重要,因为我们编写的任何代码都需要由某人维护,并且通常是由某人编写的。没有什么比回顾你写的代码而不能理解它的作用更糟糕的了。在整洁和易理解的代码中发现错误也容易得多。
安全代码是指难以破解的代码。作为开发人员,没有什么比对我们的代码中做一个小的改变,使得整个代码库中弹出错误或者应用程序中出现许多bug更令人沮丧的了。通过编写整洁的代码,我们的代码将本质上更安全,因为其他开发人员将能够查看代码并准确理解它的作用。
现在,让我们简要看看协议、协议扩展和超类之间的区别。在第4章,介绍所有关于协议的内容;第5章,实践扩展类型。
协议、协议扩展和超类
在面向对象的编程示例中,我们从所有饮料类导出了饮料的超类;在面向协议的编程示例中,我们使用协议和协议扩展获得相同的结果。然而,使用协议有几个优点。
为了加深我们对这两种解决方案的记忆,让我们看一下 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.drinkSize = drinkSize
self.description = "Drink base class"
}
func drinking(amount: Double) {
volume -= amount
}
func temperatureChange(change: Double) {
temperature -= change
}
}
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
}
}
这两个解决方案中的代码都相当安全且易于理解。作为个人偏好,我喜欢把实现和定义分开。因此,对我来说,协议/协议扩展的代码更好,但这确实是一个偏好的问题。然而,我们将在接下来的几页中看到,协议/协议扩展方案作为一个整体是比较干净和容易理解的。
与超类相比,协议/协议扩展还有三个优势。第一个优点是,类型可以符合多个协议;但是,它们只能有一个超类。这意味着我们可以创建许多协议来增加非常具体的功能,而不是创建一个单一的超类。例如,通过我们的 Drinks
协议,我们还可以创建 DietDrink
、SodaDrink
和 EnergyDrink
协议,这些协议包含对这些类型饮料的特定要求和功能。然后,DietCoke
和 CaffeineFreeDietCoke
类型将符合 Drink
、DietDrink
和 SodaDrink
协议,而 Jolt
结构将符合 Drink
和 EnergyDrink
协议。通过一个超类,我们需要将DietDrink
、SodaDrink
和 EnergyDrink
协议中定义的功能结合到单一的超类中。
协议/协议扩展的第二个优点是,我们可以使用协议扩展来增加功能,而不需要原始代码。这意味着我们可以扩展任何协议,甚至是作为 Swift
语言本身一部分的协议。为了给我们的超类添加功能,我们需要拥有原始代码。我们可以使用扩展来为超类添加功能,这意味着所有子类也将继承该功能。然而,一般来说,我们使用扩展来为一个特定的类添加功能,而不是为一个类的层次结构添加功能。
协议/协议扩展具有的第三个优势是,协议可以被类、结构体和枚举所采用,而类层次结构只限于类类型。协议/协议扩展让我们可以选择在适当的地方使用值类型。
饮料类型的实现
饮料类型(Jolt
和 CaffeineFreeDietCoke
类型)的实现在面向对象的例子和面向协议的例子中明显不同。我们将看看这两个例子之间的差异,但首先让我们再看一下代码,提醒我们如何实现饮料类型。我们先看一下在面向对象的例子中我们是如何实现饮料类型的:
class Jolt: Drink {
init(temperature: Double) {
super.init(volume: 23.5, caffeine: 280, temperature: temperature, drinkSize:DrinkSize.Can24)
self.description = "Jolt energy drink"
}
}
class CaffeineFreeDietCoke: Drink {
init(volume: Double, temperature: Double, drinkSize: DrinkSize) {
super.init(volume: volume, caffeine: 0, temperature: temperature, drinkSize: drinkSize)
self.description = "Caffeine Free Diet Coke"
}
}
这两个类都是 Drink
超类的子类,都实现了一个初始化器。虽然这些都是非常简单明了的实现,但我们确实需要充分理解超类所期望的东西才能正确地实现它们。例如,如果我们没有完全理解 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.drinkSize = DrinkSize.Can24
self.description = "Jolt Energy Jolt"
}
}
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.drinkSize = drinkSize
self.description = "Caffeine Free Diet Coke"
}
}
在面向协议的编程例子中实现这些类型需要的代码明显多于面向对象的编程例子;然而,面向协议的例子中的代码要安全得多,也更容易理解。我们说面向协议的例子更安全、更容易理解的原因是这两个例子中的属性和初始化器是如何实现的。
在面向对象编程的例子中,所有的属性都在超类中定义。我们需要查看代码或超类的文档,以了解哪些属性被定义了,以及它们是如何定义的。对于协议,我们也需要查看协议本身或协议的文档,看看要实现哪些属性,但实现是在类型本身中完成的。这使得我们可以看到这个类型的所有东西是如何实现的,而不需要回头看超类的代码,也不需要通过一个完整的类层次结构来查看东西是如何实现和初始化的。
子类中的初始化器也必须调用超类中的初始化器,以确保超类的所有属性被正确设置。虽然这确实保证了我们在子类之间有一个一致的初始化,但它也隐藏了类的初始化方式。在协议的例子中,所有的初始化都是在类型本身中完成的。因此,我们不必通过类的层次结构来查看所有的初始化方式。
Swift
中的超类提供了我们需求的实现。Swift
中的协议只是一种契约,它说任何符合给定协议的类型都必须满足协议所规定的要求。因此,有了协议,所有的属性、方法和初始化器都被定义在符合协议的类型本身。这让我们可以非常容易地看到一切是如何定义和初始化的。
值类型与引用类型
值类型与引用类型
引用类型和值类型之间有几个基本的区别,我们将在第2章 "我们的类型选择 "中更详细地讨论这些。现在,我们将专注于这两种类型之间的一个主要区别,那就是类型的传递方式。当我们传递一个引用类型(类)的实例时,我们是在传递一个对原始实例的引用。这意味着所做的任何改变都会反映到原始实例上。当我们传递一个值类型的实例时,我们传递的是原始实例的一个新副本。这意味着所做的任何改变都不会反映到原始实例上。
正如我们前面提到的,在我们的例子中,一个饮料类型的实例在同一时间应该只有一个所有者。我们的代码中不应该有多个部分与一个饮料类型的单一实例进行交互。举个例子,当我们创建一个饮料类型的实例时,我们会把它放在一个冷却器类型的实例中。然后,如果一个人走过来,从冷却器中取出该实例,这个人就会拥有该饮料实例。如果一个人把饮料给了另一个人,那么第二个人将拥有该饮料。
使用值类型可以确保我们总是得到一个唯一的实例,因为我们传递的是原始实例的副本而不是对原始实例的引用。因此,我们可以相信,我们代码的其他部分不会意外地改变我们的实例。这对多线程环境特别有帮助,因为不同的线程可能会改变数据并产生意外的行为。
我们需要确保我们适当地使用值类型和引用类型。在这个例子中,饮料类型说明了什么时候应该优先使用值类型,而冷却器类型说明了什么时候应该优先使用引用类型。
在大多数面向对象的语言中,我们没有选择将我们的自定义类型实现为值类型。在Swift中,类和结构的功能比其他语言更接近,我们可以选择将自定义类型创建为值类型。我们只需要确保我们在创建自定义类型时使用适当的类型。我们将在第二章《我们的类型选择》中更详细地讨论这些选项。
赢家是...
当我们在阅读本章时,看到面向协议的编程比面向对象的编程具有的所有优势,我们可能认为面向协议的编程明显优于面向对象的编程。然而,这个假设可能并不完全正确。
面向对象编程自20世纪70年代以来就一直存在,是一种久经考验的编程范式。面向协议的编程是一个新的孩子,是为了纠正面向对象编程的一些问题而设计的。
我个人已经在几个项目中使用了面向协议的编程范式,我对它的可能性感到非常兴奋。
面向对象编程和面向协议编程有类似的理念,比如创建自定义类型来模拟现实世界的对象,以及使用单一接口与多种类型进行交互的多态性。区别在于这些理念是如何实现的。
对我来说,与使用面向对象编程的项目相比,使用面向协议编程的项目的代码库更安全,更容易阅读。这并不意味着我将完全停止使用面向对象编程。我仍然可以看到对类的层次结构和继承的大量需要。
记住,当我们在设计我们的应用程序时,我们应该总是使用正确的工具来完成正确的工作。我们不会想用电锯来切割一块2×4的木材,但我们也不会想用 skilsaw 来砍伐一棵树。因此,赢家是程序员,我们可以选择使用不同的编程范式,而不是只限于一种范式。
摘要
在本章中,我们看到了Swift如何作为一种面向对象的编程语言,也看到了它如何作为一种面向协议的编程语言。虽然这两种编程范式有相似的理念,但它们实现这些理念的方式不同。
在面向对象编程中,当我们创建对象时,我们会使用类作为我们的蓝图。在面向协议的编程中,我们可以选择使用类、结构和枚举。我们甚至可以使用其他类型,正如我们将在第2章 "我们的类型选择 "中看到的。
通过面向对象编程,我们可以使用类的层次结构来实现多态性。通过面向协议的编程,使用协议和协议扩展的组合来实现多态性。我们将在第四章 "关于协议的一切 "中深入研究协议。
通过面向对象编程,我们能够在超类中实现被子类继承的功能。子类确实有能力覆盖超类所提供的功能。通过面向协议的编程,我们使用协议扩展来为符合我们协议的类型添加功能。如果这些类型选择的话,它们也可以影射这个功能。我们将在第5章《让我们扩展一些类型》中深入研究协议扩展。
虽然面向对象编程从20世纪70年代就开始了,它是一种久经考验的编程范式,但它也开始出现了一些磨损。在这一章中,我们看了面向协议编程所要解决的问题和设计问题。
现在我们已经看到了面向协议编程的概况,现在是时候更详细地看看构成面向协议编程的每个领域了。通过对不同领域的更好理解,我们将能够在我们的应用程序中更好地实现面向协议的编程。我们首先要看一下 Swift
编程语言的各种类型选择,以及我们应该如何使用它们。