第二章 我們的類型選擇
在大多數面向對象的編程語言中我們創建的類是引用類型,它作為我們對象的藍圖。不像其它面向對象的編程語言,在 Swift 中,結構體擁有類的大部分相同功能;然而結構體是值類型。蘋果已經說了相比于引用類型我們應該更傾向于使用值類型,例如結構體,但是實際的優點是什么? Swift 實際上有若干我們能使用的類型選擇,在這一章中,我們會查看這些類型中的每一個來看看每個類型的優點和缺點。知道怎樣使用和何時使用每個類型對于在你的工程中合理地實現面向協議編程很重要。
在這一章中,你會學到:
- 什么是類,怎么使用它?
- 什么是結構體,怎么使用它?
- 什么是枚舉,怎么使用它?
- 什么是元組,怎么使用它?
- 值類型和引用類型的區別是什么?
Swift 要么把類型歸為具名類型要么把類型歸為組合類型。具名類型是在定義時能給定一個名字的類型。這些具名類型包括類、結構體、枚舉和協議。除了用戶定義的具名類型以外,Swift 還在 Swift 標準庫中定義了很多常用的具名類型,包括數組、集合跟字典。
其它語言中的很多原始類型在 Swift 中實際上是具名類型并且在 Swift 標準庫中使用結構體來實現。這些包括代表著數字、字符串、字符和布爾值的類型。因為這些類型被實現為具名類型,我們可以像我們使用任何其它具名類型一樣使用擴展來擴展它們的默認行為。我們會在這一章和之后的章節中看到,擴展具名類型的能力,包括那些傳統上被當做原始類型和協議的類型,是 Swift 語言的一種非常強大的特性也是面向協議編程的頂梁柱之一。
混合類型是在定義時沒有給定名字的類型。 在 Swift 中,我們有兩個混合類型:函數類型和元組類型。函數類型代表閉包,函數和方法,而元組類型是包圍在圓括號中的用逗號分隔的列表。
我們可以使用 typealias 聲明給我們的混合類型起別名。這允許我們在代碼中使用別名而非類型自身。
還有兩個類別的類型:引用類型和值類型。當我們傳遞引用類型的實例時,我們傳遞的是對原實例的引用,這意味著那兩個引用共享著同一個實例。類是引用類型。當我們傳遞值類型的實例時,我們傳遞的是實例的一份新的拷貝,這意味著每個實例獲得一個唯一的拷貝。值類型包括結構體、枚舉和元組。
在 Swift 中類型要么是具名類型要么是混合類型,并且除了協議這種情況之外它們要么是引用類型要么是值類型。因為我們不能創建一個協議的實例,它既不是引用類型又不是值類型。聽起來有點困惑?真得沒有。當我們看到所有的類型選擇還有怎么去使用它們時,我們會看到這有多么容易理解。
現在我們開始查看 Swift 中擁有的類型選擇。我們從面向對象編程的支柱,類, 開始:
類
在面向對象編程語言中,我們不能在沒有藍圖的情況下創建一個對象來告訴應用程序期望從對象中獲取什么屬性和方法。在大部分面向對象語言中,這個藍圖就是類。類是一種允許我們把對象的屬性、方法和構造函數封裝到單個類型中的數據結構。類也可以包括其它條目,例如腳本(subscripts);然而我們將著力于不光在 Swift 中也在其它語言中的組成類的基本條目。
我們來看怎么在 Swift 中使用類。下面的代碼展示了在第一章,面向對象編程和面向協議編程中我們是怎么定義 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 = "Drink base class"
self.drinkSize = drinkSize
}
func drinking(amount: Double) {
volume -= amount
}
func temperatureChange(change: Double) {
temperature += change
}
}
類的實例通常叫做對象;然而在 Swift 中,結構體和類擁有大部分相同的功能,因此當我們引用任一類型的實例時我們會使用術語實例。我們會創建如下的 Drink 類的一個實例:
var myDrink = Drink(volume: 23.5, caffeine: 280,
temperature: 38.2, drinkSize: DrinkSize.Can24)
當我們創建類的實例的時候,它是具名的因此類是具名類型。類也是引用類型。
結構體
在 Swift 的標準庫中大部分的類型是使用結構體來實現的。蘋果能使用結構體來實現大部分的 Swift 標準庫的原因是,在 Swift 中結構體擁有很多和類同樣地功能。
在 Swift 中,結構體是一種允許我們把屬性、方法和構造函數封裝到單個類型中的類型。它也可以包括其它條目,例如腳本(subscripts);然而我們將著力于組成結構體的基本條目。
我們來創建一個和 Drink 類擁有同樣功能的結構體:
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 結構體實例:
var myDrink = Drink(volume: 23.5, caffeine: 280,
temperature: 38.2, drinkSize: DrinkSize.Can24,
description: "Drink Structure")
結構體是具名類型,因為當我們創建類型的實例時,實例被命名了。 結構體也是值類型。
枚舉
在 Swift 中枚舉的功能跟類和結構體相近。我們來定義一個標準的叫做 Devices 的枚舉:
enum Devices {
case IPod
case IPhone
case IPad
}
和其它語言不同的是 Swift 可以使用原始值(raw values)預填充枚舉。我們使用 String 類型的值來預填充 Devices 枚舉:
enum Devices: String {
case IPod = "IPod"
case IPhone = "IPhone"
case IPad = "IPad"
}
我們可以使用 rawValue 屬性來檢索任何一個枚舉元素的原始值:
Devices.IPod.rawValue
在 Swift 中我們還可以隨著 case 值存儲關聯值。這些關聯值可以是任意類型并且每次我們使用 case 的時候可以變化。這允許我們能夠使用 case 類型存儲額外的自定義信息。我們使用關聯值來重新定義 Devices 枚舉:
eunm Devices {
case IPod(model: Int, year: Int, memory: Int)
case IPhone(model: String, memory: Int)
case IPad(model:String, memory: Int)
}
關聯值的使用:
var myPhone = Devices.IPhone(model: "6", memory: 64)
var myTablet = Devices.IPad(model: "Pro", memory: 128)
在這個例子中,我們把 myPhone 設備定義為 64GB 內存的 IPhone 6, 把 myTablet 設備定義為 128GB 的 iPad Pro。我們來檢索枚舉中的關聯值:
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
代表的意思。
枚舉中還可以包含計算屬性,構造函數和方法,就像類和結構體那樣。我們來看下怎么在枚舉中使用方法和計算屬性:
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 枚舉,每種格式在關聯值中存儲著頁數和價格:
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 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)")
}
檢索關聯值用的代碼太多了。我們可以給枚舉添加一個計算屬性來為我們檢索 pageCount 和 price 值:
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
}
}
}
使用這兩個計算屬性,我們檢索關聯值就很容易了:
var paperBack = BookFormat.PaperBack(pageCount: 220, price: 39.99)
print("\(paperBack.pageCount) - \(paperBack.price)") // self 指的是 paperBack
這些計算屬性隱藏了 switch 語句的復雜性并且給了我們一個更加清晰的點語法讓我們使用。我們還能給這個枚舉添加方法,例如如果一個人一次買多本不同格式的書的話,我們就給他 20% 的折扣:
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)
當我們創建枚舉實例的時候,它被命名了因此它是具名類型。同時它也是值類型。
元組
let mathGrade1 = ("Jon", 100) // 把字符串和整數組合到單個元組中
let (name, score) = mathGrade1 // 使用模式匹配分解元組
print("\(name) - \(score)")
創建具名元組:
let mathGrade2 = (name: "Jon", grade: 100) // 給元組的每個值都起了名字
print("\(mathGrade2.name) - (mathGrade2.grade)") // 使用這些名字訪問元組中的信息, 避免了解構這一步。
元組可以用來在函數中返回多個值:
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
關鍵字給元組起個別名。下面的這個例子展示了我們怎么把別名賦值給元組:
typealias myTuple = (tipAmount: Double, totalAmount: Double)
在 Swift 中協議也被認為是一種類型。
協議
把協議當做類型可能會讓某些人感到吃驚,因為實際上我們不能創建協議的實例;然而我們可以把協議用作類型。我們說這句話的意思是,當我們為變量、常量、元組或集合定義類型的時候,我們可以使用協議作為那個類型。
協議既不是值類型也不是引用類型因為我們不能創建協議的一個實例。
值類型 Vs. 引用類型
我們來創建兩個類型解釋一下值類型和引用類型的區別:
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 的函數:
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)
}
這段代碼打印出
Grade for Jon is 90
Grade for Kim is 84
Grade for Kailey is 99
Grade for Kara is 89
這似乎是我們想要的。然而這里面有一個大 bug,當我們遍歷 mathGrades 數組來查看數組自身中有什么 grades 的時候:
for assignment in mathGrades {
print("\(assignment.name): grade \(assignment.grade)")
}
卻打印出:
Kara: grade 89
Kara: grade 89
Kara: grade 89
Kara: grade 89
這不是我們想要的。原因是我們創建了一個 MyReferenceType 類型的實例然后不斷地更新這個單個實例。這意味著我們不斷地覆蓋之前的 name 和 grade。 因為 MyReferenceType 是引用類型, mathGrades 數組中的所有引用都指向同一個 MyReferenceType 實例,結果就是 Kara 的分數。
大部分有面向對象開發經驗的開發者都會盡力避免這個問題,但也會偶爾發生問題。使用值類型可以避免這個問題但是我們有時想要擁有這種行為。蘋果使用 inout 參數來允許我們更改參數的值并在函數調用結束時繼續持有那個更改過的值。
我們通過把 inout 關鍵字放在參數定義的開頭來定義一個 inout 參數。inout 參數把值傳遞給函數,這個值然后被函數更改并傳出函數以替換原值。
我們使用帶有 inout 關鍵字的值類型來重寫我們之前的那個例子:
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)")
}
輸出看起來是這樣:
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 函數時,我們傳遞的是一個引用。
有些事情我們不能使用值類型來做但是可以使用引用類型(類)來做。例如遞歸數據類型。
遞歸數據類型(僅限于引用類型)
class LinkedListReferenceType {
var value: String
var next: LinkedListReferenceType?
init(value: String) {
self.value = value
}
}
next
屬性指向鏈表中的下一個 item, 如果 next 屬性為 nil,則這個實例會是列表中的最后一個節點。如果用值類型來實現這個鏈表,那么代碼可能看起來像這樣:
struct LinkedListValueType {
var value: String
var next: LinkedListValueType?
}
如果在 Playground 中,我們會收到如下錯誤:
Recursive value type 'LinkedListValueType' is not allowed
這告訴我們 Swift 不允許遞歸的值類型。我們來看看為什么遞歸的值類型是不允許的,這也有助于你理解值類型和引用類型的區別,還有為什么我們有時候需要使用引用類型。
假設我們可以創建 LinkedListValueType 結構體并且不報錯。現在我們創建 3 個節點:
var one = LinkedListValueType(value: "one", next: nil)
var two = LinkedListValueType(value: "Two", next: nil)
var three = LinkedListValueType(value: "Three", next: nil)
使用下面的代碼把這些節點連接到一塊兒:
one.next = two
two.next = three
想想值類型是如何傳遞的你就會發現問題。在第一行中,one.next = two
我們并沒有把 next
屬性設置為 two
實例自身;我們把它設置為了 two
實例的一份拷貝。這意味著在 two.next = three
中我們把 two 實例自身的 next 屬性設置為了 three 實例。然而,這個變更并沒有反射回 one 實例的 next 屬性指向的拷貝。聽起來有點困惑?我們來看看運行這兩行代碼之后的示意圖:
如我們所見, one 實例的 next 屬性指向的是 two 實例的一份拷貝,該拷貝的 next 屬性仍舊是 nil。 原 two 實例的 next 屬性指向了 three 實例,我們到不了 three 實例因為 two 實例的拷貝的 next 屬性仍舊是nil。
使用引用類型還可以繼承。
繼承(僅限于引用類型)
創建類的層級:
class Animal {
var numberOfLegs = 0
func sleeps() {
print("zzzzz")
}
func walking() {
print("Walking on \(numberOfLegs) legs")
}
func speaking() {
print("No sound")
}
}
創建兩個子類 Biped(兩條腿的動物) 和 Quadruped(四條腿的動物):
class Biped: Animal {
override init() {
super.init()
numberOfLegs = 2
}
}
class Quadruped: Animal {
override init() {
super.init()
numberOfLegs = 4
}
}
因為這兩個類從 Animal 類中繼承了所有的屬性和方法,我們需要做的所有事情就是創建一個構造函數來把 numberOfLegs 屬性的值設置為正確的數量。現在我們添加另外一層繼承:
class Dog: Quadruped {
override func speaking() {
print("Barking")
}
}
在 Dog 類中因為我們繼承自 Quadruped, 其派生于 *Animal 類,所以我們的 Dog 類會從 Animal 和 Quadruped 類中擁有所有的屬性、方法和特性。如果 Quadruped 類重寫了 Animal 類中的任何東西, 那么 Dog 類就會從 Quadruped 繼承重寫過的版本。
按照這樣的方式我們可以創建出很復雜的層級。
類層級的最大缺點是復雜性。就像上面的示意圖那樣,我們不知道做出更改會對所有的子類造成什么樣的影響。舉個例子,我們來看 dog 類和 cat 類,我們可能想給 Quadruped 類添加一個 furColor 屬性, 以使我們能設置動物軟毛的顏色,然而 horses 沒有軟毛; 它們有硬毛。所以當我們在類層級中做出改動之前,我們需要知道它是怎么影響層級中的所有類的。
Swift 中得內置數據類型和數據結構
Swift 標準庫定義了幾種諸如 Int、Double 和 String 類型的標準數據類型。在大部分語言中,這些類型是作為原始類型實現的,這意味著它們不可以被擴展或子類化。然而在 Swift 標準庫中這些類型是作為結構體來實現的,這意味著我們可以擴展這些類型,就像我們能擴展其它結構體類型那樣。
舉個例子,我們來擴展 Int 類型,為該類型添加一個階乘計算函數:
extension Int {
func factorial() -> Int {
var answer = 1
for x in self.stride(to: 1, by: -1) {
answer *= x
}
return answer
}
}
現在我們能計算任何整數的階乘了:
var f = 10
print(f.factorial())
有時候擴展一個協議比擴展單個類型更好,因為當我們擴展協議的時候,所有遵守該協議的類型都會接受到那個功能而不僅僅是單個類型接收到那個功能。
總結
在大部分面向對象編程語言中,我們的類型選擇很有限。然而在 Swift 中,我們有很多選擇。這允許我們在合適的情況下使用正確的類型。