12. 協定

協定(Protocol)像是一個最高指導單位,用來決定結構、類別與列舉型態中必須要實作哪些屬性與方法。協定本身並不實作任何東西,他的目的只是要確保符合這個協定的資料型態中一定要具備某些屬性與方法而已。

12.1 語法

定義協定使用保留字protocol後面接一個協定名稱即可,如下:

protocol SomeProtocol {

}

要讓結構或類別符合協定的規範,只要在結構或名稱的後方使用冒號然後接協定名稱即可,如果要符合兩個協定的規範,協定與協定間用逗點隔開,各語法如下:

struct SomeStruct: SomeProtocol, AnotherProtocol {
    
}

class SomeClass: SomeProtocol, AnotherProtocol {
    
}

enum SomeEnum: SomeProtocol, AnotherProtocol {
    
}

如果類別繼承了另外一個類別且同時要符合協定規範,語法是先列出繼承的類別,然後再列出符合的協定。

class Subclass: Superclass, SomeProtocol, AnotherProtocol {
    
}

要求屬性

協定中的屬性定義必須使用var,並且不可以初始化,因為初始化要交給符合這個協定的資料型態去實作,然後要明確標示屬性為可讀可寫還是唯讀。標示可讀可寫的方式是在資料型態後加上 {set get} 或 {get set},唯讀則是加上 {get}。如果屬性屬於型態屬性type property,前面需加上static修飾子。如下所示:

protocol SomeProtocol {
    var readonly: Int {get}
    var variable: Int {get set}
    static var typeproperty: Int {get}
}

要求方法

定義方法時只要將方法名稱以及傳回值型態列出即可,如果沒有傳回值可以省略傳回值型態部分,方法中的內容部分不需要實作,其實就是將函數名稱列出來,但是不要加上 { } 程式碼區段。如果該方法屬於型態方法type method,前面加上static修飾子。如下所示:

protocol SomeProtocol {
    func instanceMethod()
    static func typeMethod()
}

要求初始化器

在協定中定義初始化器時,init前面不需要加上任何修飾子。當類別要實作這些初始化器時,不論該初始化器型態是否為designated初始化器或是convenience 初始化器,都算符合協定要求。但實作協定中的初始化器時,必須在初始化器前面加上required,目的是讓之後的子類別都確保一定會實作這個初始化器。除此之外,若初始化器已經在父類別中實作,而子類別因為要符合協定要求必須實作時,還要在required前面或後面加上override。

protocol SomeProtocol {
    init()
}

class Superclass {
    init() {

    }
}

class Subclass: Superclass, SomeProtocol {
    required override init() {
        super.init()
    }
}

若協定中定義了屬性,而子類別需符合這個協定規範但是父類別不需要符合這個協定規範時,如果此時父類別剛好實作了協定中要求的屬性,這時因為類別繼承的特性,子類別不需要實作該屬性就可以符合協定規範了,舉例如下,子類別不需要實作屬性name就符合協定規範,因為父類別已經實作了name屬性,即是父類別跟協定沒有關係。

protocol SomeProtocol {
    var name: String {get set}
}

class Superclass {
    var name: String
    init(_ name: String) {
        self.name = name
    }
}

class Subclass: Superclass, SomeProtocol {

}

要求變動

由於結構與列舉屬於value type,因此若結構與列舉中的方法要修改其自身屬性時,該方法前必須加上mutating保留字。換句話說,如果要透過協定來要求必須實作這個方法時,協定中的定義前面也要加上mutating。

protocol SerialNumber {
    var num: Int {get}
    mutating func next() -> Int
}

這是一個可用來產生序號的協定,每呼叫next()一次,會將屬性num的值加1候傳回。如果是結構要符合這個協定的規範時,next()函數前必須要加上mutating。

struct AutoIncrement: SerialNumber {
    var num: Int = 0
    mutating func next() -> Int {
        num += 1
        return num
    }
}

var number = AutoIncrement()
for _ in 1..<10 {
    print(number.next())
}

如果是類別來實作這個協定,由於類別屬於reference type,所以內部本來就可以修改自身的屬性值,方法前並不需要加上mutating。因此如果是類別,實作協定中要求的方法時,前面不需要加上mutating。

class AutoIncrement: SerialNumber {
    var num: Int = 0
    func next() -> Int {
        num += 1
        return num
    }
}

範例

假設我們需要定義許多不同的圖形的結構或類別,例如矩形、三角形、圓形…等,這些不同的圖形中都必須有面積area這個屬性,為了保證每個定義出來的圖形都有area,所以先定義一個協定。由於面積是計算出來的結果,因此屬性area設定為只能讀不能寫。

protocol Shape {
    var area: Double {get}
}

接下來定義矩形Rectangle結構,並且讓他符合Shape協定的規範,因此,日後只要使用Rectangle的人,都一定可以透過屬性area取得面積。矩形面積的計算是根據矩形的長與寬,因此在Rectangle中必須額外定義width與height屬性,area就根據這兩個屬性計算出面積。

struct Rectangle: Shape {
    var width, height: Double
    var area: Double {
        width * height
    }
}

let rect = Rectangle(width: 10, height: 20)
print(rect.area)
// Prints "200.0"

之所以在Shape協定中不定義width與height的原因在於不同圖形面積有不同計算公式,不是每個圖形都是用width與height來計算面積的。例如三角形面積為「底乘高除2」,因此三角形結構可以設計如下:

struct Triangle: Shape {
    var base, height: Double
    var area: Double {
        base * height / 2
    }
}

let tri = Triangle(base: 20, height: 6)
print(tri.area)
// Prints "60.0"

將協定當成資料型態

協定可以當成資料型態使用,例如想要寫個函數來比較兩個圖形面積是否相等,傳進函數中的參數型態不需要考慮圖形原本的資料型態,以協定來代替即可。由於不論哪一個圖形都必定符合相同協定Shape的規範,因此以Shape當成他們共通的資料型態,這也是物件導向中的多型特性。

func isEqualArea(_ shape1: Shape, _ shape2: Shape) -> Bool {
    shape1.area == shape2.area
}

let result = isEqualArea(rect, tri)
// result == "false"

有了協定可以當成資料型態使用的概念後,我們可以將面積是否相同的函數藉由Shape協定強制規定每一種圖形中都必須實作,例如:

protocol Shape {
    var area: Double {get}
    func isEqualArea(_ shape: Shape) -> Bool
}

struct Rectangle: Shape {
    var width, height: Double
    var area: Double {
        width * height
    }
    func isEqualArea(_ shape: Shape) -> Bool {
        self.area == shape.area
    }
}

如此一來,相關的函數就被封裝在結構裡面而不是散落在外,讓整體的物件導向架構更為清楚。

let rect1 = Rectangle(width: 10, height: 20)
let rect2 = Rectangle(width:50, height: 4)
print(rect1.isEqualArea(rect2))
// Print "true"

let tri = Triangle(base: 20, height: 20)
print(tri.isEqualArea(rect1))
// Print "true"

有了協定,只要確認相關的資料型態是否符合該協定的規範,我們就能確保這些資料型態必定具備了該有的功能,而不需要去個別檢查每個資料型態是否具備該有的功能。

協定組合

我們知道協定可以當資料型態使用,讓函數呼叫時,讓傳進去的值其資料型態必須符合特定協定才可以傳進去。但如果該資料型態符合兩個以上協定時,這時候就需要透過協定組合語法來限制傳進去的參數其型態必須同時符合這些協定才可以。要將兩個協定組合起來使用「&」符號。

以下面這個例子為例,有兩個協定:A與B,然後定義一個結構ABC符合協定A與協定B的規範,另外定義一個類別DEF也符合協定A與協定B的規範。定義一個函數f(_:)接受一個參數,參數型態指定了型態只要同時符合協定A與協定B的型態就可以,因此傳進函數f(_:)的參數型態可以是ABC也可以是DEF。

protocol A { }
protocol B { }

struct ABC: A, B {
    
}

class DEF: A, B {
    
}

func f(_ x: A & B) {
    
}

我們可以任意組合不限數量的協定當成資料型態,透過這個方式可以讓函數設計的很彈性,有時函數中需要的是協定中所定義的屬性或方法,而不在意是哪個結構或類別時,透過協定組合就很容易處理這個問題了。

繼承

協定可以繼承另外一個協定,繼承語法與類別相同。當協定B繼承協定A,且當某型態符合協定B規範時,該型態也同時必須符合協定A的規範。例如協定A中定義了屬性id。協定B繼承協定A,所以在協定B中包含了屬性id,因此協定B可以定義初始化id的初始化器。接下來當結構SomeStruct符合協定B的規範時,必須同時實作屬性id以及初始化器。

protocol A {
    var id: Int {get set}
}

protocol B: A {
    init(_ id: Int)
}

struct SomeStruct: B {
    var id: Int
    init(_ id: Int) {
        self.id = id
    }
}

類別限定

一般來說協定可以使用在類別、結構與列舉型態上,如果希望協定僅能使用於類別上,只要在該協定的繼承列表上出現AnyObject保留字即可。例如:

protocol ClassOnly: AnyObject {
    
}

上面這個協定如果使用於結構或列舉型態時會得到錯誤訊息。

12.2 委派

委派的用途在於將要做的事情交由別的類別所建立的實體來執行,並且透過協定的規範來確保受委託的類別符合某些要求。例如接收資料的類別收到資料後必須顯示在螢幕上,但是接受資料的類別其專長並不是用來顯示資料,因此他將資料交由專門顯示資料的類別去處理,而整個架構只要確保一件事情就是顯示資料的類別能夠處理接受資料的類別傳過去的資料格式即可。

舉個例子。我們需要一個倒數計時的計時器,當設定的時間到了以後會通知我們。首先要先設計一個協定,用來確保計時器計時結束後一定可以通知到我們,在委派架構中,通知就是一種函數呼叫,所以協定設計如下,加上AnyObject確保這個協定只能使用於類別,原因稍後說明。

protocol CountdownTimerDelegate: AnyObject {
    func complete()
}

有個這個協定後,類別We代表計時器結束後要通知的對象,因此We要符合CountdownTimerDelegate協定的規範,所以要實作complete()方法。

class We: CountdownTimerDelegate {
    func complete() {

    }
}

處理倒數計時的類別CountdownTimer的設計上,必須在倒數結束後呼叫We裡面的complete()方法,所以該類別中必須能夠儲存We的實體,透過這個實體呼叫complete()。

因為要使用倒數計時功能的類別不一定都是類別We也可能是別的類別,只要符合CountdownTimerDelegate規範即可,因此在CountdownTimer中儲存We實體的資料型態應該使用協定型態,也就是使用CountdownTimerDelegate當成We的資料型態。在方法start()中呼叫了Foundation框架中的Timer類別來完成倒數計時的功能。Timer.scheduledTimer函數的第一個參數代表什麼時間會執行第三個參數中的程式碼,因此第一個參數填需要倒數的秒數加上現在時間,第二個參數填false代表第一個參數是某個時間點,第三個參數為closure,填上要呼叫委派對象中的complete()方法。

import Foundation
class CountdownTimer {
    var delegate: CountdownTimerDelegate?
    var second = 0.0
    
    init(second: TimeInterval) {
        self.second = second
    }

    func start() {
        let at = Date().timeIntervalSinceNow + second
        Timer.scheduledTimer(withTimeInterval: at, repeats: false) {_ in
            self.delegate?.complete()
        }
    }
}

~補充說明~
不用擔心Foundation這個框架,只要使用Xcode建立的專案,不論是macOS的Command Line Tool或是Playground專案,Xcode都預先匯入了這個框架(Playground專案如果預先匯入的是UIKit框架時,UIKit內部會匯入Foundation框架)。

最後在class We的init(_:)初始化器中實體化CountdownTimer,並且將本身傳給CountdownTimer的delegate屬性,然後呼叫start()方法啟動倒數計時。最後一行程式碼We(3)的代表倒數計時3秒鐘,3秒鐘後We裡面的complete()方法會被呼叫。

class We: CountdownTimerDelegate {
    init(_ second: TimeInterval) {
        let timer = CountdownTimer(second: second)
        timer.delegate = self
        timer.start()
    }
    
    func complete() {
        print("倒數結束")
    }
}

We(3)

在結構中使用委派需要非常小心,原因在於結構屬於value type,所以在將本身self交給屬性delegate時,delegate中儲存的是self的複製品,兩者已經不是同一個實體,如果其中一方改了屬性內容後,另一方在取該屬性值的時候會發現還是舊的。所以委派通常只使用於類別,因為類別屬於reference type,委派時透過delegate取得的實體跟當初傳給delegate的實體是同一個,任何一方修改屬性值都不會造成不同步的現象。

另一個範例

我們再來看一個更複雜並且要處理memory leak(記憶體洩漏)的例子。這例子中透過參數設計,傳出更多的訊息,並且透過private等級來保護重要的屬性與方法。假設辦公室要寄信,郵差收到信後開始處理寄信相關的程序,並且紀錄哪個單位寄了多少封信,寄完信後將處理結果交還給委派對象。協定與郵差類別設計如下:

protocol PostmanDelegate: AnyObject {
    var id: String {get}
    func didSent(_ postman: Postman, _ count: Int)
}

class Postman {
    weak var delegate: PostmanDelegate?
    private var log: [String: Int] = [:]
    
    func send(_ message: String) {
        // Sending process here
        if let delegate = delegate {
            log[delegate.id, default: 0] += 1
            delegate.didSent(self, log[delegate.id]!)
        }
    }
}

上述程式碼中,協定加上了AnyObject,表示這個協定只有類別可以使用,此外,在類別Postman中的屬性delegate前加上了weak修飾子,這是確保記憶體在不使用時可以正常釋放,不會形成memory leak,這部分將在第17章ARC說明。接下來就可以根據PostmanDelegate協定的規範來設計Office類別。

class Office: PostmanDelegate {
    private var postman: Postman
    let id: String

    init(_ id: String) {
        self.id = id
        postman = Postman()
        postman.delegate = self
    }
    
    func send(_ message: String) {
        postman.send(message)
    }
    
    func didSent(_ postman: Postman, _ count: Int) {
        print("\(id) 送出第 \(count) 封訊息")
    }
}

let a32 = Office("A32")
let b16 = Office("B16")
a32.send("Hi, everyone")
b16.send("Good day")
a32.send("Bye-Bye")
// A32 送出第 1 封訊息
// B16 送出第 1 封訊息
// A32 送出第 2 封訊息

12.3 擴充

我們可以透過擴充語句,將一個新的協定加到現有的資料型態上,並且在擴充語句中讓資料型態符合新協定的規範。我們先回憶一下本章第一節最後的圖形範例,這裡先移除協定中的isEqualArea(_:)函數。

protocol Shape {
    var area: Double {get}
}

struct Rectangle: Shape {
    var width, height: Double
    var area: Double {
        width * height
    }
}

當想要比較兩個矩形的大小時,我們定義圖形大小是根據面積大小來決定的,例如下面這個例子r1的面積為50,r2的面積為30,因此r1大於r2。

var r1 = Rectangle(width: 10, height: 5)
var r2 = Rectangle(width: 10, height: 3)

Swift標準函數庫中Comparable協定專門用來比較大小,因此該協定中定義了四個用來比較大小的符號,分別是「<」、「<=」、「>」與「>=」。現在我們讓Rectangle符合Comparable協定規範然後實作「<」符號。我們只要實作「<」即可,另外三個編譯器會自動幫我們根據「<」中的程式碼來完成,但前提是另外三個的內容跟「<」大同小異,否則還是要自己實作一遍。

extension Rectangle: Comparable {
    static func < (lhs: Self, rhs: Self) -> Bool {
        lhs.area < rhs.area
    }
}

接下來我們就可以使用「<」「<=」「>」「>=」來判斷兩個矩形的大小了,結果以布林型態傳回。

var r1 = Rectangle(width: 10, height: 5)
var r2 = Rectangle(width: 10, height: 3)

print(r1 > r2)
// Prints "true"
r2.height = 10
print(r1 >= r2)
// Prints "false"

事實上,符合Comparable協定的同時也要符合Equatable協定,因為Comparable繼承Equatable。Equatable協定主要用來定義等於「==」與不等於「!=」。這兩個符號對於結構與類別有不同的作用,先來看結構。只要兩個結構的內容一樣,不論是不是產生了兩個實體,「==」都會傳回true,「!=」傳回false。例如:

var r1 = Rectangle(width: 10, height: 5)
var r2 = Rectangle(width: 10, height: 5)
print(r1 == r2)
// Prints "true"

一般狀況來說,「==」這樣的預設結果沒有什麼太大的問題,但是用在我們所定義的圖形大小比較上就不適合了。以下面這個例子而言,r1與r2只是長寬剛好對調,如果我們認為r1與r2大小一樣,此時我們就需要重新定義「==」了,以面積來判斷兩個圖形是否相等。如下:

extension Rectangle {
    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.area == rhs.area
    }
}

var r1 = Rectangle(width: 10, height: 5)
var r2 = Rectangle(width: 5, height: 10)
print(r1 == r2)
// Prints "true"

如果Rectangle是類別型態的話,「==」ㄧ定要實作,否則會得到不符合Equatable協定規範的錯誤訊息。不論是結構還是類別,只要實作「==」即可,「!=」編譯器會自動幫我們完成。

協定擴充

我們可以使用extension語句對協定進行擴充,擴充項目如果是方法,該方法必須同時在extension中實作,如果擴充項目是屬性,則該屬性必須完成初始化。這是確保目前已經符合該協定型態不會因為擴充後而產生語法錯誤。在上個單元,我們使用extension讓原本只符合Shape協定規範的Rectangle結構開始符合Comparable協定的規範,然後實作了符號「<」與「==」。但我們發現這樣處理是很麻煩的,因為還有其他的圖形也需要處理,例如三角形Triangle、圓形Circle…等。

現在我們用協定繼承與協定擴充兩項技術,直接修改源頭協定Shape,讓所有使用這個協定的資料型態都立刻擁有比較大小的能力。首先使用協定繼承讓Shape繼承Comparable協定。

protocol Shape: Comparable {
    var area: Double {get}
}

再來使用協定擴充讓Shape增加「<」與「==」方法,因為此時是協定擴充,所以需要在extension中立刻實作。

extension Shape {
    static func < (lhs: Self, rhs: Self) -> Bool {
        lhs.area < rhs.area
    }
    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.area == rhs.area
    }
}

現在所有符合Shape協定規範的圖形,不論是矩形、三角形、圓形…等,都自動具有比較大小的功能了。

12.4 可選擇項目

對Objective-C比較熟悉的讀者知道,在Objective-C中的協定,可以透過@required或@optional來決定該項目是否一定要實作或是可選擇實作。Swift的協定也支援這樣的功能,但是如果要在Swift的協定上加入optional,則該協定與要求項目必須同時加上@objc標記。例如下面協定中的方法thisIsOptional()為可選擇實作,因此開頭必須加上@objc optional,如果協定中有項目是@objc optional的話,協定也要加上@objc才行。

@objc protocol SomeProtocol {
    func thisIsRequired()
    @objc optional func thisIsOptional()
}

有標示@objc的協定只能作用於類別,無法給結構與列舉型態使用。符合該協定的類別只要實作所有非optional的項目即可滿足協定的規範,如下述的SomeClass類別一定要實作thisIsRequired()方法,但是thisIsOptional()方法並不一定要實作。

class SomeClass: SomeProtocol {
    func thisIsRequired() {
        
    }
}

範例

若有一個產品驗證為AAA,符合這個驗證的產品都必須要有:名稱name、來源地origin以及成分ingredients,其他訊息others則可有可無,因此標記@objc optional。AAA設計如下:

@objc protocol AAA {
    var name: String {get}
    var origin: String {get}
    func ingredients() -> [String]
    @objc optional func others() -> [String]
}

假設一家公司需要訂購產品,且採購部門被要求訂購的產品要符合AAA協定的規範,如此一來,公司就可以知道所購買的產品一定有標示名稱、來源地與成分可供查詢。在公司的類別中,屬性productSource存放訂購的商品,因此透過此屬性可以取得購買的商品資訊。我們在公司這個類別中設計一個用來查詢商品資訊的方法,在這個方法中主要要確定符合AAA協定規範的商品是否有提供others資訊,也就是是否有實作others()方法,如果沒有,就不可以呼叫該方法,否則會導致程式當掉。公司類別設計如下:

class Company {
    var productSource: AAA?
    func productInformation() {
        guard let product = productSource else {
            return
        }

        print("名稱:\(product.name)")
        print("來源:\(product.origin)")
        print("原料:\(product.ingredients())")
        if let others = product.others?() {
            print("其他資訊:\(others)")
        }
    }
}

現在有兩個產品符合AAA要求,其中Product1沒有實作others()而Prodect2有,如下。

class Product1: AAA {
    let name = "麵條"
    let origin = "關廟"
    func ingredients() -> [String] {
        ["麵粉", "食鹽", "水"]
    }
}

class Product2: AAA {
    let name = "果汁"
    let origin = "嘉義"
    func ingredients() -> [String] {
        ["鳳梨", "糖"]
    }
    func others() -> [String] {
        ["建議售價 50", "夏季限定"]
    }
}

當公司購買了Product1與Product2並且察看產品訊息的結果如下,可以看到Prodect2因為有實作others(),因此列出了比Product1更多的訊息。

let co = Company()
co.product = Product1()
co.productInformation()
// 名稱:麵條
// 來源:關廟
// 原料:["麵粉", "食鹽", "水"]

co.product = Product2()
co.productInformation()
// 名稱:果汁
// 來源:嘉義
// 原料:["鳳梨", "糖"]
// 其他資訊:["建議售價 50", "夏季限定"]

One thought on “12. 協定

  1. 想努力成為 iOS 工程師的人
    想努力成為 iOS 工程師的人 says:

    老師好,希望上課可以多著重在這些基礎,雖然枯燥,但其實根基穩日後才能獨當一面,短期就想馬上寫出一個 App 其實對真的想從事這行的有心人來說只是其次,感謝老師。

發表迴響