iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 17
0

委任 (Delegation)

委任是一種設計模式,使類或結構可以將其某些職責移交給(或委託)其他型別的實例。通過定義封裝委任職責的協定來實現此設計模式,從而確保符合型別(稱為委任)提供已委任的功能。委任可用於響應特定操作,或從外部源檢索數據而無需了解該源的基礎型別。

下面的範例定義了兩種用於基於骰子的棋盤遊戲的協定:

protocol DiceGame {
    var dice: Dice { get }
    func play()
}
protocol DiceGameDelegate: AnyObject {
    func gameDidStart(_ game: DiceGame)
    func game(_ game: DiceGame, didStartNewTurnWithDiceRoll diceRoll: Int)
    func gameDidEnd(_ game: DiceGame)
}

DiceGame 協定是可以被涉及骰子的任何遊戲採用的協定。可以採用 DiceGameDelegate 協定來追蹤 DiceGame 的進度。為防止強引用循環,將委任宣告為弱引用。將協定標記為 class-only,可使本章稍後的類 SnakesAndLadders 宣告其委任必須使用弱引用。如純類協定中所述,純類協定由其從 AnyObject 的繼承來標記。

這是最初在 Control Flow 中引入的 Snakes and Ladders 遊戲的一個版本。此版本適用於將 Dice 實例用於其骰子;採用 DiceGame 協定;並將其進度通知 DiceGameDelegate

class SnakesAndLadders: DiceGame {
    let finalSquare = 25
    let dice = Dice(sides: 6, generator: LinearCongruentialGenerator())
    var square = 0
    var board: [Int]
    init() {
        board = Array(repeating: 0, count: finalSquare + 1)
        board[03] = +08; board[06] = +11; board[09] = +09; board[10] = +02
        board[14] = -10; board[19] = -11; board[22] = -02; board[24] = -08
    }
    weak var delegate: DiceGameDelegate?
    func play() {
        square = 0
        delegate?.gameDidStart(self)
        gameLoop: while square != finalSquare {
            let diceRoll = dice.roll()
            delegate?.game(self, didStartNewTurnWithDiceRoll: diceRoll)
            switch square + diceRoll {
            case finalSquare:
                break gameLoop
            case let newSquare where newSquare > finalSquare:
                continue gameLoop
            default:
                square += diceRoll
                square += board[square]
            }
        }
        delegate?.gameDidEnd(self)
    }
}

該遊戲的版本包裝為名為 SnakesAndLadders 的類,該類採用 DiceGame 協定。它提供了一個可獲取的骰子屬性和一個 play() 方法以符合該協定。 (骰子屬性被宣告為常數屬性,因為初始化後不需要更改它,並且協定僅要求它必須是可獲取的。) Snakes and Ladders 遊戲板的設置在該類的 init() 初始化程序中進行。所有遊戲邏輯都移到了協定的 play 方法中,該方法使用協定的必需骰子屬性來提供骰子擲骰值。請注意,委任屬性被定義為可選的 DiceGameDelegate,因為玩遊戲不需要委任。因為它是可選型別,所以委任屬性會自動設置為初始值 nil。此後,遊戲實例化程序可以選擇將屬性設置為合適的委任人。因為 DiceGameDelegate 協定是 class-only 的,所以可以宣告委任是 weak 的,以防止引用循環。

DiceGameDelegate 提供了三種追蹤遊戲進度的方法。這三種方法已合併到上述 play() 方法內的遊戲邏輯中,並在新遊戲開始,新回合開始或遊戲結束時被調用。由於委任屬性是可選的 DiceGameDelegate ,因此 play() 方法每次在委任上調用方法時都使用可選鏈。如果委任屬性為 nil,則這些委託調用將優雅地失敗並且沒有錯誤。如果委任屬性為非 nil,則調用委託方法,並將其作為參數傳遞給 SnakesAndLadders 實例。 下一個示例顯示一個名為 DiceGameTracker 的類,該類採用 DiceGameDelegate 協定:

class DiceGameTracker: DiceGameDelegate {
    var numberOfTurns = 0
    func gameDidStart(_ game: DiceGame) {
        numberOfTurns = 0
        if game is SnakesAndLadders {
            print("Started a new game of Snakes and Ladders")
        }
        print("The game is using a \(game.dice.sides)-sided dice")
    }
    func game(_ game: DiceGame, didStartNewTurnWithDiceRoll diceRoll: Int) {
        numberOfTurns += 1
        print("Rolled a \(diceRoll)")
    }
    func gameDidEnd(_ game: DiceGame) {
        print("The game lasted for \(numberOfTurns) turns")
    }
}

DiceGameTracker 實現 DiceGameDelegate 所需的所有三種方法。它使用這些方法來追蹤遊戲進行的回合數。在遊戲開始時,它將屬性 numberOfTurns 重置為零,在每次新的回合開始時將其遞增,並在遊戲結束後打印出總回合數。上面顯示的 gameDidStart(_:) 的實現使用 game 參數來打有關將要玩的遊戲的一些介紹性訊息。遊戲參數的型別為 DiceGame,而不是 SnakesAndLadders,因此 gameDidStart(_:) 只能訪問和使用作為 DiceGame 協定一部分實現的方法和屬性。但是,該方法仍然可以使用類型轉換來查詢基礎實例的類型。在此示例中,它將檢查遊戲是否實際上是幕後的 SnakesAndLadders 實例,如果是,則印出適當的消息。 gameDidStart(_:) 方法還訪問傳遞的遊戲參數的屬性 dice。由於已知遊戲符合 DiceGame 協定,因此保證具有骰子屬性,因此 gameDidStart(_:) 方法可以訪問和印出骰子的屬性 sides ,而不管正在玩哪種遊戲。

DiceGameTracker 的運作方式如下:

let tracker = DiceGameTracker()
let game = SnakesAndLadders()
game.delegate = tracker
game.play()
// Started a new game of Snakes and Ladders
// The game is using a 6-sided dice
// Rolled a 3
// Rolled a 5
// Rolled a 4
// Rolled a 5
// The game lasted for 4 turns

在擴展添加協議一致性

即使無權存取現有型別的源代碼,也可以擴展現有型別以採用並遵循新協定。擴展可以向現有型別添加新的屬性、方法和下標,因此可以添加協定可能要求的任何要求。

當擴展中的一致性添加到實例型別時,該型別的現有實例會自動採用並遵守協定。

例如,此協定稱為 TextRepresentable,用任何一種可以表示為文本的型別來實現。這可能是對自身的描述,也可能是其當前狀態的文本版本:

protocol TextRepresentable {
    var textualDescription: String { get }
}

上面的類 Dice 可以擴展為採用和符合 TextRepresentable

extension Dice: TextRepresentable {
    var textualDescription: String {
        return "A \(sides)-sided dice"
    }
}

此擴展採用新協定的方式與 Dice 在其原始實現中提供新協定的方式完全相同。在型別名稱之後提供協定名稱,並用冒號分隔,並在擴展程序的大括號內提供協定所有要求的實現。

現在,任何 Dice 實例都可以視為 TextRepresentable

let d12 = Dice(sides: 12, generator: LinearCongruentialGenerator())
print(d12.textualDescription)
// Prints "A 12-sided dice"

相同地,可以擴展 SnakesAndLadders 遊戲類以採用並遵循 TextRepresentable 協定:

extension SnakesAndLadders: TextRepresentable {
    var textualDescription: String {
        return "A game of Snakes and Ladders with \(finalSquare) squares"
    }
}
print(game.textualDescription)
// Prints "A game of Snakes and Ladders with 25 squares"

協定繼承

協定可以繼承一個或多個其他協定,並且可以在繼承的要求之上添加其他要求。協定繼承的語法類似於類繼承的語法,但是可以選擇列出多個繼承的協定,並用逗號分隔:

protocol InheritingProtocol: SomeProtocol, AnotherProtocol {
    // protocol definition goes here
}

這是從上方繼承 TextRepresentable 協定的協定範例:

protocol PrettyTextRepresentable: TextRepresentable {
    var prettyTextualDescription: String { get }
}

本範例定義了一個新協定 PrettyTextRepresentable,該協定繼承自 TextRepresentable任何採用PrettyTextRepresentable 的東西都必須滿足 TextRepresentable 強制執行的所有要求,再加上 PrettyTextRepresentable 強制執行的其他要求。在此範例中,PrettyTextRepresentable 添加了一個單一要求,以提供一個稱為 prettyTextualDescription的gettable 屬性,該屬性返回一個 String。

SnakesAndLadders 可擴展為採用並符合 PrettyTextRepresentable

extension SnakesAndLadders: PrettyTextRepresentable {
    var prettyTextualDescription: String {
        var output = textualDescription + ":\n"
        for index in 1...finalSquare {
            switch board[index] {
            case let ladder where ladder > 0:
                output += "▲ "
            case let snake where snake < 0:
                output += "▼ "
            default:
                output += "○ "
            }
        }
        return output
    }
}

該擴展聲明它採用了 PrettyTextRepresentable 協定,並為 SnakesAndLadders 型別提供了 prettyTextualDescription 屬性的實現。 PrettyTextRepresentable 的任何內容也必須是 TextRepresentable,因此 prettyTextualDescription 的實現始於從 TextRepresentable 協定訪問屬性 textualDescription 以開始輸出字串。它附加一個冒號和一個換行符號,並將其作為漂亮文本表示的開始。然後,它遍歷木板正方形的數組,並附加一個幾何形狀來表示每個正方形的內容:

  • 如果平方的值大於 0,則它是階梯的底數,並用 ▲ 表示。
  • 如果平方的值小於 0,則表示蛇的頭部,並用 ▼ 表示。
  • 否則,該正方形的值為 0,並且是一個由 ○ 表示的「自由」正方形。

現在,屬性 prettyTextualDescription 可用於印出任何 SnakesAndLadders 實例的漂亮文本描述:

print(game.prettyTextualDescription)
// A game of Snakes and Ladders with 25 squares:
// ○ ○ ▲ ○ ○ ▲ ○ ○ ▲ ▲ ○ ○ ○ ▼ ○ ○ ○ ○ ▼ ○ ○ ▼ ○ ▼ ○

純類協定 (Class-Only Protocols)

通過將 AnyObject 協定添加到協定的繼承列表中,可以將協定採用限制為類型別(而不是結構或列舉)。

protocol SomeClassOnlyProtocol: AnyObject, SomeInheritedProtocol {
    // class-only protocol definition goes here
}

在上面的範例中,SomeClassOnlyProtocol 僅可用於類型別。編寫試圖採用 SomeClassOnlyProtocol 的結構或列舉定義會發生編譯時錯誤。

當該協定的要求定義的行為假設或要求符合型別具有引用語義而非值語義時,請使用僅類協定。


協定組成 (Protocol Composition)

要求一種型別同時符合多種協定可能很有用。可以將多個協定組合成具有協定組成的單個需求。協定組合的行為就像定義了一個臨時本地協定,該協定具有組合中所有協定的組合要求。協定組成沒有定義任何新的協定型別。 協定組成具有 SomeProtocolAnotherProtocol 的形式。可以根據需要列出任意數量的協定,並用 & 分隔。除了協定列表之外,協定組成還可以包含一個類型別,可以使用該類型別來指定所需的父類。 這是一個範例,該示例將名為 NamedAged 的兩個協定組合為一個功能參數的單個協定組成要求:

protocol Named {
    var name: String { get }
}
protocol Aged {
    var age: Int { get }
}
struct Person: Named, Aged {
    var name: String
    var age: Int
}
func wishHappyBirthday(to celebrator: Named & Aged) {
    print("Happy birthday, \(celebrator.name), you're \(celebrator.age)!")
}
let birthdayPerson = Person(name: "Malcolm", age: 21)
wishHappyBirthday(to: birthdayPerson)
// Prints "Happy birthday, Malcolm, you're 21!"

在此範例中,協定 Named 對名為 name 的可獲取 String 屬性具有單一要求。協定 Aged 對可獲取的 Int 屬性有一個單一要求,稱為 age。兩種協定都被稱為 Person 的結構採用。 該範例還定義了 wishHappyBirthday(to:) 函數。celebrator 參數的型別為 Named & Aged,表示「既符合 Named 協定又符合 Aged 協定的任何型別。」將哪種特定型別傳遞給函數並不重要,只要它既符合必需的協定。 然後,該範例創建一個名為 BirthdayPerson 的新 Person 實例,並將該新實例傳遞給函數 wishHappyBirthday(to:) 。因為 Person 符合這兩個協定,所以此調用有效,並且函數 wishHappyBirthday(to:) 可以印出其生日問候。

這是一個結合了上一範例中的協定 Named 和類 Location 的範例:

class Location {
    var latitude: Double
    var longitude: Double
    init(latitude: Double, longitude: Double) {
        self.latitude = latitude
        self.longitude = longitude
    }
}
class City: Location, Named {
    var name: String
    init(name: String, latitude: Double, longitude: Double) {
        self.name = name
        super.init(latitude: latitude, longitude: longitude)
    }
}
func beginConcert(in location: Location & Named) {
    print("Hello, \(location.name)!")
}

let seattle = City(name: "Seattle", latitude: 47.6, longitude: -122.3)
beginConcert(in: seattle)
// Prints "Hello, Seattle!"

beginConcert(in:) 函數採用的參數型別為 Location & Named,即「任何屬於 Location 的子類且符合 Named 協定的型別。」在這種情況下,City 滿足了這兩個要求。 將BirthdayPerson 傳遞給 beginConcert(in:) 函數無效,因為 Person 不是 Location 的子類。同樣,如果創建的 Location 的子類不符合 Named 協定,則使用該型別的實例調用 beginConcert(in:) 也是無效的。


檢查協定一致性 (Checking for Protocol Conformance)

可以使用型別轉換中描述的 isas 運算符來檢查協定一致性,並轉換為特定協定。檢查和轉換為協定遵循與檢查和轉換為型別完全相同的語法:

  • 如果實例符合協定,則 is 運算符返回 true否則返回false
  • as? 版本的向下運算符的版本會返回協定型別的可選值,如果實例不符合該協定,則該值為 nil
  • as! 版本的向下轉換運算符會強制向下轉換為協定型別,如果向下轉換失敗,則會觸發運行時錯誤。

此範例定義了一個稱為 HasArea 的協定,具有一個名為 area 的 gettable Double 屬性的單個屬性要求:

protocol HasArea {
    var area: Double { get }
}

這是 CircleCountry 這兩個類,它們都符合 HasArea 協定:

class Circle: HasArea {
    let pi = 3.1415927
    var radius: Double
    var area: Double { return pi * radius * radius }
    init(radius: Double) { self.radius = radius }
}
class Country: HasArea {
    var area: Double
    init(area: Double) { self.area = area }
}

Circle 基於儲存的屬性 radius,將屬性 area 要求實現為計算屬性。類 Country 直接將面積要求實現為儲存屬性。這兩個類均正確符合 HasArea 協定。

這是一個名為 Animal 的類,它不符合 HasArea 協定:

class Animal {
    var legs: Int
    init(legs: Int) { self.legs = legs }
}

objects 數組初始化為含一個半徑為 2 個單位的 Circle 實例;初始化為英國表面積(以平方公里為單位)的 Country 實例;還有一個有四隻腳的 Animal 實例。

現在可以迭代 objects 數組,並且可以檢查數組中的每個物件以查看其是否符合 HasArea 協定:

for object in objects {
    if let objectWithArea = object as? HasArea {
        print("Area is \(objectWithArea.area)")
    } else {
        print("Something that doesn't have an area")
    }
}
// Area is 12.5663708
// Area is 243610.0
// Something that doesn't have an area

每當數組中的對象符合 HasArea 協定時,透過運算符 as? 返回解包綁定到名為 objectWithArea 的常數中的可選值。已知 objectWithArea 常數的型別為 HasArea,因此可以使用型別安全的方式訪問和印出其屬性 area

請注意,轉換過程不會更改基礎物件。他們仍然是一個 Circle,一個 Country 和一個 Animal。但是,由於它們儲存在 objectWithArea 常數中,因此只知道它們是 HasArea 型別,因此只能存取它們的屬性 area


上一篇
Day 16: 協定 (Protocol) -1
下一篇
Day 18: 使用 UIKit 開發 App
系列文
iOS 新手開發的大小事30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言