我们的类型选择

第二章 我们的类型选择

在大多数面向对象的编程语言中我们创建的类是引用类型,它作为我们对象的蓝图。不像其它面向对象的编程语言,在 Swift 中,结构体拥有类的大部分相同功能;然而结构体是值类型。苹果已经说了相比于引用类型我们应该更倾向于使用值类型,例如结构体,但是实际的优点是什么? Swift 实际上有若干我们能使用的类型选择,在这一章中,我们会查看这些类型中的每一个来看看每个类型的优点和缺点。知道怎样使用和何时使用每个类型对于在你的工程中合理地实现面向协议编程很重要。

在这一章中,你会学到:

  • 什么是类,怎么使用它?
  • 什么是结构体,怎么使用它?
  • 什么是枚举,怎么使用它?
  • 什么是元组,怎么使用它?
  • 值类型和引用类型的区别是什么?

Swift 要么把类型归为具名类型要么把类型归为组合类型。具名类型是在定义时能给定一个名字的类型。这些具名类型包括类、结构体、枚举和协议。除了用户定义的具名类型以外,Swift 还在 Swift 标准库中定义了很多常用的具名类型,包括数组、集合跟字典。

其它语言中的很多原始类型在 Swift 中实际上是具名类型并且在 Swift 标准库中使用结构体来实现。这些包括代表着数字、字符串、字符和布尔值的类型。因为这些类型被实现为具名类型,我们可以像我们使用任何其它具名类型一样使用扩展来扩展它们的默认行为。我们会在这一章和之后的章节中看到,扩展具名类型的能力,包括那些传统上被当做原始类型和协议的类型,是 Swift 语言的一种非常强大的特性也是面向协议编程的顶梁柱之一。

混合类型是在定义时没有给定名字的类型。 在 Swift 中,我们有两个混合类型:函数类型和元组类型。函数类型代表闭包,函数和方法,而元组类型是包围在圆括号中的用逗号分隔的列表。

我们可以使用 typealias 声明给我们的混合类型起别名。这允许我们在代码中使用别名而非类型自身。

还有两个类别的类型:引用类型和值类型。当我们传递引用类型的实例时,我们传递的是对原实例的引用,这意味着那两个引用共享着同一个实例。类是引用类型。当我们传递值类型的实例时,我们传递的是实例的一份新的拷贝,这意味着每个实例获得一个唯一的拷贝。值类型包括结构体、枚举和元组。

在 Swift 中类型要么是具名类型要么是混合类型,并且除了协议这种情况之外它们要么是引用类型要么是值类型。因为我们不能创建一个协议的实例,它既不是引用类型又不是值类型。听起来有点困惑?真得没有。当我们看到所有的类型选择还有怎么去使用它们时,我们会看到这有多么容易理解。

现在我们开始查看 Swift 中拥有的类型选择。我们从面向对象编程的支柱,类, 开始:

在面向对象编程语言中,我们不能在没有蓝图的情况下创建一个对象来告诉应用程序期望从对象中获取什么属性和方法。在大部分面向对象语言中,这个蓝图就是类。类是一种允许我们把对象的属性、方法和构造函数封装到单个类型中的数据结构。类也可以包括其它条目,例如脚本(subscripts);然而我们将着力于不光在 Swift 中也在其它语言中的组成类的基本条目。

我们来看怎么在 Swift 中使用类。下面的代码展示了在第一章,面向对象编程和面向协议编程中我们是怎么定义 Drink 类的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 = "Drink base class"
self.drinkSize = drinkSize
}

func drinking(amount: Double) {
volume -= amount
}
func temperatureChange(change: Double) {
temperature += change
}
}

类的实例通常叫做对象;然而在 Swift 中,结构体和类拥有大部分相同的功能,因此当我们引用任一类型的实例时我们会使用术语实例。我们会创建如下的 Drink 类的一个实例:

1
2
var myDrink = Drink(volume: 23.5, caffeine: 280,
temperature: 38.2, drinkSize: DrinkSize.Can24)

当我们创建类的实例的时候,它是具名的因此类是具名类型。类也是引用类型。

结构体

在 Swift 的标准库中大部分的类型是使用结构体来实现的。苹果能使用结构体来实现大部分的 Swift 标准库的原因是,在 Swift 中结构体拥有很多和类同样地功能。

在 Swift 中,结构体是一种允许我们把属性、方法和构造函数封装到单个类型中的类型。它也可以包括其它条目,例如脚本(subscripts);然而我们将着力于组成结构体的基本条目。

我们来创建一个和 Drink 类拥有同样功能的结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Drink {
var volume: Double
var caffeine: Double
var temperature: Double
var drinkSize: DrinkSize
var description: String

mutating func drinking(amount: Double) {
volume -= amount
}
mutating func temperatureChange(change: Double) {
temperature += change
}
}

Drink 结构体中,我们没有被要求定义一个构造函数,因为如果我们没有提供构造函数的话结构体会为我们创建一个默认的构造函数。当我们创建结构体的实例时这个构造函数会要求我们为该结构体所有的属性提供初始值。

结构体和类的另一个不同之处是结构体中用在函数上的 mutating 关键字。结构体是值类型;因此,默认地,不能从实例方法的内部更改结构体的属性。通过使用 mutating 关键字,我们选择了更改那个特定方法的行为。 如果结构体中得方法想要更改结构体属性的值,那么我们必须要使用 mutating 关键字。

我们来创建一个 Drink 结构体实例:

1
2
3
var myDrink = Drink(volume: 23.5, caffeine: 280,
temperature: 38.2, drinkSize: DrinkSize.Can24,
description: "Drink Structure")

结构体是具名类型,因为当我们创建类型的实例时,实例被命名了。 结构体也是值类型。

枚举

在 Swift 中枚举的功能跟类和结构体相近。我们来定义一个标准的叫做 Devices 的枚举:

1
2
3
4
5
enum Devices {
case IPod
case IPhone
case IPad
}

和其它语言不同的是 Swift 可以使用原始值(raw values)预填充枚举。我们使用 String 类型的值来预填充 Devices 枚举:

1
2
3
4
5
6
7
8
9
10
11
enum Devices: String {
case IPod = "IPod"
case IPhone = "IPhone"
case IPad = "IPad"
}
```

我们可以使用 *rawValue* 属性来检索任何一个枚举元素的原始值:

```Swift
Devices.IPod.rawValue

在 Swift 中我们还可以随着 case 值存储关联值。这些关联值可以是任意类型并且每次我们使用 case 的时候可以变化。这允许我们能够使用 case 类型存储额外的自定义信息。我们使用关联值来重新定义 Devices 枚举:

1
2
3
4
5
eunm Devices {
case IPod(model: Int, year: Int, memory: Int)
case IPhone(model: String, memory: Int)
case IPad(model:String, memory: Int)
}

关联值的使用:

1
2
var myPhone = Devices.IPhone(model: "6", memory: 64)
var myTablet = Devices.IPad(model: "Pro", memory: 128)

在这个例子中,我们把 myPhone 设备定义为 64GB 内存的 IPhone 6, 把 myTablet 设备定义为 128GB 的 iPad Pro。我们来检索枚举中的关联值:

1
2
3
4
5
6
7
8
switch myPhone {
case .IPod(let model, let year, let memory):
print("IPod: \(model) \(memory)")
case .IPhone(let model, let memory):
print("IPhone: \(model) \(memory)")
case .IPad(let model, let memory):
print("IPad: \(model) \(memory)")
}

在上面的例子中,我们仅仅打印出了 myPhone 设备中的关联值。能使用 .IPod 这种简写的以点号开头的形式是因为,根据 myPhone 变量可以推断出 myPhone 是一个枚举,所以我们不用显式的说 Devices.IPod 就知道 .IPod 代表的意思。

枚举中还可以包含计算属性,构造函数和方法,就像类和结构体那样。我们来看下怎么在枚举中使用方法和计算属性:

1
2
3
4
5
6
7
8
9
10
11
12
enum Reindeer: String {
case Dasher, Dancer, Prancer, Vixen, Comet, Cupid, Donner, Blitzen, Rudolph

static var allCases: [Reindeer] {
return [Dasher, Dancer, Prancer, Vixen, Comet, Cupid, Donner, Blitzen, Rudolph]
}

static func randomCase() -> Reindeer {
let randomValue = Int(arc4random_uniform(Uint32(allCases.count)))
return allCases[randomValue]
}
}

把关联值、方法和属性组合起来使用会让枚举超级强大,我们来定义一个 BookFormat 枚举,每种格式在关联值中存储着页数和价格:

1
2
3
4
5
6
7
enum BookFormat {
case PaperBack (pageCount: Int, price: Double)
case HardCover (pageCount: Int, price: Double)
case PDF (pageCount: Int, price: Double)
case EPub (pageCount: Int, price: Double)
case Kindle (pageCount: Int, price: Double)
}

但是这种枚举在检索关联值时有缺点,例如,我们创建如下实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var paperBack = BookFormat.paperBack(pageCount: 220, price: 39.99)

# 从枚举中检索关联值
switch paperBack {
case .PaperBack(let pageCount, let price):
print("\(pageCount) - \(price)")
case .HardCover(let pageCount, let price):
print("\(pageCount) - \(price)")
case .PDF(let pageCount, let price):
print("\(pageCount) - \(price)")
case .EPub(let pageCount, let price):
print("\(pageCount) - \(price)")
case .Kindle(let pageCount, let price):
print("\(pageCount) - \(price)")
}

检索关联值用的代码太多了。我们可以给枚举添加一个计算属性来为我们检索 pageCountprice 值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
enum BookFormat {
case PaperBack (pageCount: Int, price: Double)
case HardCover (pageCount: Int, price: Double)
case PDF (pageCount: Int, price: Double)
case EPub (pageCount: Int, price: Double)
case Kindle (pageCount: Int, price: Double)
}

var pageCount: Int {
switch self {
case .PaperBack(let pageCount, _):
return pageCount // 返回值而不打印值
case .HardCover(let pageCount, _):
return pageCount
case .PDF(let pageCount, _):
return pageCount
case .EPub(let pageCount, _):
return pageCount
case .Kindle(let pageCount, _):
return pageCount
}

var price: Double {
switch self { // self 是创建的枚举实例,在创建枚举实例后才有 self
case .PaperBack(_, let price):
return price
case .HardCover(_, let price):
return price
case .PDF(_, let price):
return price
case .EPub(_, let price):
return price
case .Kindle(_, let price):
return price
}
}
}

使用这两个计算属性,我们检索关联值就很容易了:

1
2
var paperBack = BookFormat.PaperBack(pageCount: 220, price: 39.99)
print("\(paperBack.pageCount) - \(paperBack.price)") // self 指的是 paperBack

这些计算属性隐藏了 switch 语句的复杂性并且给了我们一个更加清晰的点语法让我们使用。我们还能给这个枚举添加方法,例如如果一个人一次买多本不同格式的书的话,我们就给他 20% 的折扣:

1
2
3
4
5
6
7
8
func purchaseTogether(otherFormat: BookFormat) -> Double {
return (self.price + otherFormat.price) * 0.80
}

// 打0.8折
var paperBack = BookFormat.PaperBack(pageCount: 220, price: 39.99)
var pdf = BookFormat.PDF(pageCount: 180, price: 14.99)
var total = paperBack.purchaseTogether(pdf)

当我们创建枚举实例的时候,它被命名了因此它是具名类型。同时它也是值类型。

元组

1
2
3
let mathGrade1    = ("Jon", 100) // 把字符串和整数组合到单个元组中
let (name, score) = mathGrade1 // 使用模式匹配分解元组
print("\(name) - \(score)")

创建具名元组:

1
2
let mathGrade2  = (name: "Jon", grade: 100)  // 给元组的每个值都起了名字
print("\(mathGrade2.name) - (mathGrade2.grade)") // 使用这些名字访问元组中的信息, 避免了解构这一步。

元组可以用来在函数中返回多个值:

1
2
3
4
5
6
7
8
9
func calculateTip(billAmount: Double,tipPercent: Double) ->
(tipAmount: Double, totalAmount: Double) {
let tip = billAmount * (tipPercent/100)
let total = billAmount + tip
return (tipAmount: tip, totalAmount: total)
}

var tip = calculateTip(31.98, tipPercent: 20)
print("\(tip.tipAmount) - \(tip.totalAmount)")

在 Swift 中元组是值类型,也是混合类型; 然而我们可以使用 typealias 关键字给元组起个别名。下面的这个例子展示了我们怎么把别名赋值给元组:

1
typealias myTuple = (tipAmount: Double, totalAmount: Double)

在 Swift 中协议也被认为是一种类型。

协议

把协议当做类型可能会让某些人感到吃惊,因为实际上我们不能创建协议的实例;然而我们可以把协议用作类型。我们说这句话的意思是,当我们为变量、常量、元组或集合定义类型的时候,我们可以使用协议作为那个类型。

协议既不是值类型也不是引用类型因为我们不能创建协议的一个实例。

值类型 Vs. 引用类型

我们来创建两个类型解释一下值类型和引用类型的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
struct MyValueType {
var name: String
var assignment: String
var grade: Int
}

class MyReferenceType {
var name: String
var assignment: String
var grade: Int

// 结构体中不需要定义构造函数的原因是它会为我们提供一个默认的构造函数如果我们没有提供一个默认构造函数的话
init(name: String, assignment: String, grade: Int) {
self.name = name
self.assignment = assignment
self.grade = grade
}
}

// 定义两个函数
func extraCreditReferenceType(ref: MyReferenceType, extraCredit: Int) {
ref.grade += extraCredit
}

func extraCreditValueType(var val: MyValueType, extraCredit: Int) {
val.grade += extraCredit
}

var ref = MyReferenceType(name: "Jon", assignment: "Math Test 1", grade:90)
extraCreditReferenceType(ref, extraCredit:5)
print("Reference: \(ref.name) - \(ref.grade)") // Reference: Jon - 95


var val = MyValueType(name: "Jon", assignment: "Math Test 1", grade: 90)
extraCreditValueType(val, extraCredit: 5) // 传递的是对原实例的一份拷贝
print("Value: \(val.name) - \(val.grade)") // Value: Jon - 90

为什么 MyValueType 那个例子中的 grade 没有增加 5 ? 因为我们传递值类型的实例给函数的时候,我们实际传递的是对原实例的一份拷贝。这意味着,当我们在 extraCredit 函数中为 grade 添加额外的额度时,我们把额度添加到原实例的拷贝身上了,这意味着更改不会反射回原实例中。

我们创建一个用于检索 grade 的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func getGradeForAssignment(assignment: MyReferenceType) {
let num = Int(arc4random_uniform(20) + 80)
assignment.grade = num
print("Grade for \(assignment.name) is \(num)")
}

var mathGrades = [MyReferenceType]()
var students = ["Jon", "Kim", "Kailey", "Kara"]
var mathAssignment = MyReferenceType(name: "", assignment: "Math Assignment", grade: 0)

for student in students {
mathAssignment.name = student
getGradeForAssignment(mathAssignment)
mathGrades.append(mathAssignment)
}

这段代码打印出

1
2
3
4
Grade for Jon is 90
Grade for Kim is 84
Grade for Kailey is 99
Grade for Kara is 89

这似乎是我们想要的。然而这里面有一个大 bug,当我们遍历 mathGrades 数组来查看数组自身中有什么 grades 的时候:

1
2
3
for assignment in mathGrades {
print("\(assignment.name): grade \(assignment.grade)")
}

却打印出:

1
2
3
4
Kara: grade 89
Kara: grade 89
Kara: grade 89
Kara: grade 89

这不是我们想要的。原因是我们创建了一个 MyReferenceType 类型的实例然后不断地更新这个单个实例。这意味着我们不断地覆盖之前的 namegrade。 因为 MyReferenceType 是引用类型, mathGrades 数组中的所有引用都指向同一个 MyReferenceType 实例,结果就是 Kara 的分数。

大部分有面向对象开发经验的开发者都会尽力避免这个问题,但也会偶尔发生问题。使用值类型可以避免这个问题但是我们有时想要拥有这种行为。苹果使用 inout 参数来允许我们更改参数的值并在函数调用结束时继续持有那个更改过的值。

我们通过把 inout 关键字放在参数定义的开头来定义一个 inout 参数。inout 参数把值传递给函数,这个值然后被函数更改并传出函数以替换原值。

我们使用带有 inout 关键字的值类型来重写我们之前的那个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func getGradeForAssignment(inout assignment: MyValueType) {
let num = Int(arc4random_uniform(20) + 80) // 80到100之间的随机分
assignment.grade = num
print("Grade for \(assignment.name) is \(num)")
}

var mathGrades = [MyValueType]()
var students = ["Jon", "Kim", "Kailey", "Kara"]
var mathAssignment = MyValueType(name: "", assignment: "Math Assignment",grade: 0)

for student in students {
mathAssignment.name = student
getGradeForAssignment(&mathAssignment) // & 告诉我们我们正传递引用给值类型,所以函数中所作的任何更改都被反射回原来的实例中
mathGrades.append(mathAssignment)
}

for assignment in mathGrades {
print("\(assignment.name): grade \(assignment.grade)")
}

输出看起来是这样:

1
2
3
4
5
6
7
8
9
Grade for Jon is 97
Grade for Kim is 83
Grade for Kailey is 87
Grade for Kara is 85

Jon: grade 97
Kim: grade 83
Kailey: grade 87
Kara: grade 85

这样的输出是我们所期望的。mathGrades 数组中的每个实例都代表着一个不同的分数。当我们把 mathAssignment 实例添加到 mathGrades 数组中时,我们向数组中添加的是对 mathAssignment 实例的拷贝。然而,当我们把 mathAssignment 实例传递给 getGradeForAssignment 函数时,我们传递的是一个引用。

有些事情我们不能使用值类型来做但是可以使用引用类型(类)来做。例如递归数据类型。

递归数据类型(仅限于引用类型)

img

1
2
3
4
5
6
7
class LinkedListReferenceType {
var value: String
var next: LinkedListReferenceType?
init(value: String) {
self.value = value
}
}

next 属性指向链表中的下一个 item, 如果 next 属性为 nil,则这个实例会是列表中的最后一个节点。如果用值类型来实现这个链表,那么代码可能看起来像这样:

1
2
3
4
struct LinkedListValueType {
var value: String
var next: LinkedListValueType?
}

如果在 Playground 中,我们会收到如下错误:

1
Recursive value type 'LinkedListValueType' is not allowed

这告诉我们 Swift 不允许递归的值类型。我们来看看为什么递归的值类型是不允许的,这也有助于你理解值类型和引用类型的区别,还有为什么我们有时候需要使用引用类型。

假设我们可以创建 LinkedListValueType 结构体并且不报错。现在我们创建 3 个节点:

1
2
3
var one = LinkedListValueType(value: "one", next: nil)
var two = LinkedListValueType(value: "Two", next: nil)
var three = LinkedListValueType(value: "Three", next: nil)

使用下面的代码把这些节点连接到一块儿:

1
2
one.next = two
two.next = three

想想值类型是如何传递的你就会发现问题。在第一行中,one.next = two 我们并没有把 next 属性设置为 two 实例自身;我们把它设置为了 two 实例的一份拷贝。这意味着在 two.next = three 中我们把 two 实例自身的 next 属性设置为了 three 实例。然而,这个变更并没有反射回 one 实例的 next 属性指向的拷贝。听起来有点困惑?我们来看看运行这两行代码之后的示意图:

img

如我们所见, one 实例的 next 属性指向的是 two 实例的一份拷贝,该拷贝的 next 属性仍旧是 nil。 原 two 实例的 next 属性指向了 three 实例,我们到不了 three 实例因为 two 实例的拷贝的 next 属性仍旧是nil

使用引用类型还可以继承。

继承(仅限于引用类型)

创建类的层级:

1
2
3
4
5
6
7
8
9
10
11
12
class Animal {
var numberOfLegs = 0
func sleeps() {
print("zzzzz")
}
func walking() {
print("Walking on \(numberOfLegs) legs")
}
func speaking() {
print("No sound")
}
}

创建两个子类 Biped(两条腿的动物) 和 Quadruped(四条腿的动物):

1
2
3
4
5
6
7
8
9
10
11
12
13
class Biped: Animal {
override init() {
super.init()
numberOfLegs = 2
}
}

class Quadruped: Animal {
override init() {
super.init()
numberOfLegs = 4
}
}

因为这两个类从 Animal 类中继承了所有的属性和方法,我们需要做的所有事情就是创建一个构造函数来把 numberOfLegs 属性的值设置为正确的数量。现在我们添加另外一层继承:

1
2
3
4
5
class Dog: Quadruped {
override func speaking() {
print("Barking")
}
}

Dog 类中因为我们继承自 Quadruped, 其派生于 Animal* 类,所以我们的 Dog 类会从 AnimalQuadruped 类中拥有所有的属性、方法和特性。如果 Quadruped 类重写了 Animal 类中的任何东西, 那么 Dog 类就会从 Quadruped** 继承重写过的版本。

按照这样的方式我们可以创建出很复杂的层级。

img

类层级的最大缺点是复杂性。就像上面的示意图那样,我们不知道做出更改会对所有的子类造成什么样的影响。举个例子,我们来看 dog 类和 cat 类,我们可能想给 Quadruped 类添加一个 furColor 属性, 以使我们能设置动物软毛的颜色,然而 horses 没有软毛; 它们有硬毛。所以当我们在类层级中做出改动之前,我们需要知道它是怎么影响层级中的所有类的。

Swift 中得内置数据类型和数据结构

Swift 标准库定义了几种诸如 IntDoubleString 类型的标准数据类型。在大部分语言中,这些类型是作为原始类型实现的,这意味着它们不可以被扩展或子类化。然而在 Swift 标准库中这些类型是作为结构体来实现的,这意味着我们可以扩展这些类型,就像我们能扩展其它结构体类型那样。

举个例子,我们来扩展 Int 类型,为该类型添加一个阶乘计算函数:

1
2
3
4
5
6
7
8
9
extension Int {
func factorial() -> Int {
var answer = 1
for x in self.stride(to: 1, by: -1) {
answer *= x
}
return answer
}
}

现在我们能计算任何整数的阶乘了:

1
2
var f = 10
print(f.factorial())

有时候扩展一个协议比扩展单个类型更好,因为当我们扩展协议的时候,所有遵守该协议的类型都会接受到那个功能而不仅仅是单个类型接收到那个功能。

总结

在大部分面向对象编程语言中,我们的类型选择很有限。然而在 Swift 中,我们有很多选择。这允许我们在合适的情况下使用正确的类型。