Swift 面向協議程式設計的那些事

胡蘿蔔卜發表於2019-03-11

一直想寫一些 Swift 的東西,卻不知道從何寫起。因為想寫的東西太多,然後所有的東西都混雜在一起,導致什麼都寫不出來。翻了翻以前在組內分享的一些東西,想想把這些內容整理下,寫進部落格吧。我對計劃要寫的東西做了個清單(最近做什麼都喜歡在前一天睡覺前做個清單,這樣多少改善了我的拖延症?):

  • 面向協議程式設計
  • 使用值型別代替引用型別
  • 函數語言程式設計
  • 單向資料流

面向協議程式設計是 Swift 不同於其他語言的一個特性之一,也是比 Objective-C 強大的一個語言特性(並不是Swift 獨有的,但是比 OC 的協議要強大很多),所以以面向協議程式設計作為 Swift 系列文章的開端是最合適不過的了。

文章的內容可能有點長,我就把要講的內容簡單地列了一下,同學們可以根據自己掌握的情況,跳到對應的小結進行閱讀。下面是主要內容:

  • 面向協議程式設計不是個新概念
  • Swift 中的協議
    • 從一個繪圖應用開始。通過實現一個繪圖應用,來講解在 Swift 中使用協議
    • 帶有 Self 和關聯型別的協議
      • 帶有 Self 的協議。通過實現一個二分查詢,來講解如何在協議中使用 Self
      • 帶有關聯型別的協議。通過實現一個帶載入動畫的資料載入器,來講解如何在協議中使用關聯型別
    • 協議與函式派發。通過一個使用多型的例子,來講解函式派發在協議中的表現
  • 使用協議改善既有的程式碼設計

面向協議程式設計不是個新概念

面向協議程式設計並不是一個新概念,它其實就是廣為所知的面向介面程式設計。面向協議程式設計 (Protocol Oriented Programming) 是 Apple 在 2015 年 WWDC 上提出的 Swift 的一種程式設計正規化。

很多程式設計師都能理解類、物件、繼承和介面這些物件導向的概念(不知道的自己面壁去啊)。可是類與介面的區別何在?有類了幹嘛要使用介面?相信很多人都有這樣的疑問。介面(協議是同一個東西)定義了型別,實現介面(子型別化)讓我們可以用一個物件來代替另一個物件。另一方面,類繼承是通過複用父類的功能或者只是簡單地共享程式碼和表述,來定義物件的實現和型別的一種機制。類繼承讓我們能夠從現成的類繼承所需要大部分功能,從而快速定義新的類。所以介面側重的是型別(是把某個型別當做另一種型別來用),而類側重的是複用。理解了這個區別你就知道在什麼時候使用介面,什麼時候使用類了。

GoF 在《設計模式》一書中提到了可複用物件導向軟體設計的原則:

針對介面程式設計,而不是針對實現程式設計

定義具有相同介面的類群很重要,因為多型是基於介面的。其他物件導向的程式語言,類如 Java,允許我們定義 "介面"型別,它確定了客戶端同所有其他具體類直接到一種 "合約"。Objective-C 和 Swift中與之對應的就是協議(protocol)了。協議也是物件之間的一種合約,但本身是不能夠例項化為物件的。實現協議或者從抽象類繼承,使得物件共享相同的介面。因此,子型別的所有物件,都可以針對協議或抽象類的介面做出應答。

Swift 中的協議

在 WWDC2015 上,Apple 釋出了Swift 2。新版本包含了很多新的語言特性。在眾多改動之中,最引人注意的就是 protocol extensions。在 Swift 第一版中,我們可以通過 extension 來為已有的 class,struct 或 enum 擴充功能。而在 Swift 2 中,我們也可以為 protocol 新增 extension。可能一開始看上去這個新特性並不起眼,實際上 protocol extensions 非常強大,以至於可以改變 Swift 之前的某些程式設計思想。後面我會給出一個 protocol extension 在實際專案中使用案例。

除了協議擴充,Swift 中的協議還有一些具有其他特性的協議,比如帶有關聯型別的協議、包含 Self 的協議。這兩種協議跟普通的協議還是有一些不同的,後面我也會給出具體的例子。

我們現在可以開始編寫程式碼,來掌握在實際開發中使用 Swift 協議的技巧。下面的繪圖應用和二分查詢的例子是來自 WWDC2015 中這個 Session。在寫本文前,筆者也想了很多例子,但是始終覺得沒有官方的例子好。所以我的建議是:這個 Session 至少要看一遍。看了一遍後,開始寫自己的實現。

從一個繪圖應用開始

現在我們可以先通過完成一個具體的需求,來學習如何在 Swift 中使用協議。

我們的需求是實現一個可以繪製複雜圖形的繪圖程式,我們可以先通過一個 Render 來定義一個簡單的繪製過程:

struct Renderer {
    func move(to p: CGPoint) { print("move to (\(p.x), \(p.y))") }
    
    func line(to p: CGPoint) { print("line to (\(p.x), \(p.y))")}
    
    func arc(at center: CGPoint, radius: CGFloat, starAngle: CGFloat, endAngle: CGFloat) {
        print("arc at center: \(center), radius: \(radius), startAngel: \(starAngle), endAngle: \(endAngle)")
    }
}
複製程式碼

然後可以定義一個 Drawable 協議來定義一個繪製操作:

protocol Drawable {
    func draw(with render: Renderer)
}
複製程式碼

Drawable 協議定義了一個繪製操作,它接受一個具體的繪製工具來進行繪圖。這裡將可繪製的內容和實際的繪製操作分開了,這麼做的目的是為了職責分離,在後面你會看到這種設計的好處。

如果我們想繪製一個圓,我們可以很簡單地利用上面實現好了的繪製工具來繪製一個圓形,就像下面這樣:

struct Circle: Drawable {
    let center: CGPoint
    let radius: CGFloat
    
    func draw(with render: Renderer) {
        render.arc(at: center, radius: radius, starAngle: 0, endAngle: CGFloat.pi * 2)
    }
}
複製程式碼

現在我們又想要繪製一個多邊形,那麼有了 Drawable 協議,實現起來也非常簡單:

struct Polygon: Drawable {
    let corners: [CGPoint]
    
    func draw(with render: Renderer) {
        if corners.isEmpty { return }
        render.move(to: corners.last!)
        for p in corners { render.line(to: p) }
    }
}
複製程式碼

簡單圖形的繪製已經完成了,現在可以完成我們這個繪圖程式了:

struct Diagram: Drawable {
    let elements: [Drawable]
    
    func draw(with render: Renderer) {
        for ele in elements { ele.draw(with: render) }
    }
}

let render = Renderer()

let circle = Circle(center: CGPoint(x: 100, y: 100), radius: 100)
let triangle = Polygon(corners: [
    CGPoint(x: 100, y: 0),
    CGPoint(x: 0, y: 150),
    CGPoint(x: 200, y: 150)])

let client = Diagram(elements: [triangle, circle])
client.draw(with: render)

// Result:
// move to (200.0, 150.0)
// line to (100.0, 0.0)
// line to (0.0, 150.0)
// line to (200.0, 150.0)
// arc at center: (100.0, 100.0), radius: 100.0, startAngel: 0.0, endAngle: 6.28318530717959
複製程式碼

通過上面的程式碼很容易就實現了一個簡單的繪圖程式了。不過,目前這個繪圖程式只能在控制檯中顯示繪製的過程,我們想把它繪製到螢幕上怎麼辦呢?要想把內容繪製到螢幕上其實也簡單的很,仍然是使用協議,我們可以把 Renderer 結構體改成 protocol:

protocol Renderer {
    func move(to p: CGPoint)
    
    func line(to p: CGPoint)
    
    func arc(at center: CGPoint, radius: CGFloat, starAngle: CGFloat, endAngle: CGFloat)
}
複製程式碼

完成了 Renderer 的改造,我們可以使用 CoreGraphics 來在螢幕上繪製圖形了:

extension CGContext: Renderer {
    func line(to p: CGPoint) {
        addLine(to: p)
    }
    
    func arc(at center: CGPoint, radius: CGFloat, starAngle: CGFloat, endAngle: CGFloat) {
        let path = CGMutablePath()
        path.addArc(center: center, radius: radius, startAngle: starAngle, endAngle: endAngle, clockwise: true)
        addPath(path)
    }
}
複製程式碼

通過擴充 CGContext,使其遵守 Renderer 協議,然後使用 CGContext 提供的介面非常簡單的實現了繪製工作。 下圖是這個繪圖程式最終的效果:

Swift 面向協議程式設計的那些事

完成上面繪圖程式的關鍵,是將圖形的定義和實際繪製操作拆開了,通過設計 DrawableRenderer 兩個協議,完成了一個高擴充的程式。想繪製其他形狀,只要實現一個新的 Drawable 就可以了。例如我想繪製下面這樣的圖形:

Swift 面向協議程式設計的那些事

我們可以將原來的 Diagram 進行縮放就可以了。程式碼如下:

let big = Diagram(elements: [triangle, circle])
diagram = Diagram(elements: [big, big.scaled(by: 0.2)])
複製程式碼

而通過實現 Renderer 協議,你既可以完成基於控制檯的繪圖程式也可以完成使用 CoreGraphics 的繪圖程式,甚至可以很簡單地就能實現一個使用 OpenGL 的繪圖程式。這種程式設計思想,在編寫跨平臺的程式是非常有用的。

帶有 Self 和關聯型別的協議

我在前面部分已經指出,帶有關聯型別的協議和普通的協議是有一些不同的。對於那些在協議中使用了 Self 關鍵字的協議來說也是如此。在 Swift 3 中,這樣的協議不能被當作獨立的型別來使用。這個限制可能會在今後實現了完整的泛型系統後被移除,但是在那之前,我們都必須要面對和處理這個限制。

帶有 Self 的協議

我們仍然從一個例子開始:

func binarySearch(_ keys: [Int], for key: Int) -> Int {
    var lo = 0, hi = keys.count - 1
    while lo <= hi {
        let mid = lo + (hi - lo) / 2
        if keys[mid] == key { return mid }
        else if keys[mid] < key { lo = mid + 1 }
        else { hi = mid - 1 }
    }
    return -1
}

let position = binarySearch([Int](1...10), for: 3)
// result: 2
複製程式碼

上面的程式碼實現了一個簡單的二分查詢,但是目前只支援查詢 Int 型別的資料。如果想支援其他型別的資料,我們必須對上面的程式碼進行改造,改造的方向就是使用 protocol,例如我可以新增下面的實現:

protocol Ordered {
    func precedes(other: Ordered) -> Bool
    
    func equal(to other: Ordered) -> Bool
}

func binarySearch(_ keys: [Ordered], for key: Ordered) -> Int {
    var lo = 0, hi = keys.count - 1
    while lo <= hi {
        let mid = lo + (hi - lo) / 2
        if keys[mid].equal(to: key) { return mid }
        else if keys[mid].precedes(other: key) { lo = mid + 1 }
        else { hi = mid - 1 }
    }
    return -1
}
複製程式碼

為了支援查詢 Int 型別資料,我們就必須讓 Int 實現 Oredered 協議:

Swift 面向協議程式設計的那些事

寫完上面的實現,發現程式碼根本就不能執行,報錯說的是 Int 型別和 Oredered 型別不能使用 < 進行比較,下面的 == 也是一樣。為了解決這個問題,我們可以在 protocol 中使用 Self:

protocol Ordered {
    func precedes(other: Self) -> Bool
    
    func equal(to other: Self) -> Bool
}

extension Int: Ordered {
    func precedes(other: Int) -> Bool { return self < other }
    
    func equal(to other: Int) -> Bool { return self == other }
}
複製程式碼

在 Oredered 中使用了 Self 後,編譯器會在實現中將 Self 替換成具體的型別,就像上面的程式碼中,將 Self 替換成了 Int。這樣我們就解決了上面的問題。但是又出現了新的問題:

Swift 面向協議程式設計的那些事

這就是上面所說的,帶有 Self 的協議不能被當作獨立的型別來使用。在這種情況下,我們可以使用泛型來解決這個問題:

func binarySearch<T: Ordered>(_ keys: [T], for key: T) -> Int {...}
複製程式碼

如果是 String 型別的資料,也可以使用這個版本的二分查詢了:

extension String: Ordered {
    func precedes(other: String) -> Bool { return self < other }
    
    func equal(to other: String) -> Bool { return self == other }
}

let position = binarySearch(["a", "b", "c", "d"], for: "d")
// result: 3
複製程式碼

當然,如果你熟悉 Swift 標準庫中的協議的話,你會發現上面的實現可以簡化為下面的幾行程式碼:

func binarySearch<T: Comparable>(_ keys: [T], for key: T) -> Int? {
    var lo = 0, hi = keys.count - 1
    while lo <= hi {
        let mid = lo + (hi - lo) / 2
        if keys[mid] == key { return mid }
        else if keys[mid] < key { lo = mid + 1 }
        else { hi = mid - 1 }
    }
    return nil
}
複製程式碼

這裡我們定義 Ordered 協議只是為了演示在協議中使用 Self 的過程。實際開發中,可以靈活地運用標準庫中提供的協議。其實在標準庫中 Comparable 協議中也是用到了 Self 的:

extension Comparable {
    public static func > (lhs: Self, rhs: Self) -> Bool
}
複製程式碼

上面通過實現一個二分查詢演算法,演示瞭如何使用帶有 Self 的協議。簡單來講,你可以把 Self 看做一個佔位符,在後面具體型別的實現中可以替換成實際的型別。

帶有關聯型別的協議

帶有關聯型別的協議也不能被當作獨立的型別來使用。在 Swift 中這樣的協議非常多,例如 Collection,Sequence,IteratorProtocol 等等。如果你仍然想使用這種協議作為型別,可以使用一種叫做型別擦除的技術。你可以從這裡瞭解如何實現它。

下面仍然通過一個例子來演示如何在專案中使用帶有關聯型別的協議。這次我們要通過協議實現一個帶有載入動畫的資料載入器,並且在出錯時展示相應的佔點陣圖。

這裡,我們定義了一個 Loading 協議,代表可以載入資料,不過要滿足 Loading 協議,必須要提供一個 loadingView,這裡的 loadingView 就是協議中關聯型別的例項。

protocol Loading: class {
    associatedtype LoadingView: UIView, LoadingViewType
    
    var loadingView: LoadingView { get }
}
複製程式碼

Loading 協議中的關聯型別有兩個要求,首先必須是 UIView 的子類,其次需要遵守 LoadingViewType 協議。LoadingViewType 可以簡單定義成下面這樣:

protocol LoadingViewType: class {
    var isAnimating: Bool { get set }
    var isError: Bool { get set }
    
    func startAnimating()
    func stopAnimating()
}
複製程式碼

我們可以在 Loading 協議的擴充中定義一些跟載入邏輯相關的方法:

extension Loading where Self: UIViewController {
    func startLoading() {
        if !view.subviews.contains(loadingView) {
            view.addSubview(loadingView)
            loadingView.frame = view.bounds
        }
        view.bringSubview(toFront: loadingView)
        loadingView.startAnimating()
    }
    
    func stopLoading() {
        loadingView.stopAnimating()
    }
}

複製程式碼

我們可以繼續給 Loading 新增一個帶網路資料載入的邏輯:

extension Loading where Self: UIViewController {
    func loadData(with re: Resource, completion: @escaping (Result) -> Void) {
        startLoading()
        NetworkTool.shared.request(re) { result in
            guard case .succeed = result else {
                self.loadingView.isError = true // 顯示出錯的檢視,這裡可以根據錯誤型別顯示對應的檢視,這裡簡單處理了
                self.stopLoading()
                return
            }
            completion(result)
            self.loadingView.isError = false
            self.stopLoading()
        }
    }
}
複製程式碼

以上就是整個 Loading 協議的實現。這裡跟上面的例子不同,這兒主要使用了協議的擴充來實現需求。這樣做的原因,是因為所有的載入邏輯幾乎都是一樣的,可能的區別就是載入的動畫不同。所以這裡把負責動畫的部分放到了 LoadingViewType 協議裡,Loading 的載入邏輯都放到協議的擴充裡進行定義。協議宣告裡定義的方法與在協議擴充裡定義的方法其實是有區別的,後面也會給出一個例子來說明它們都區別。

要想讓 ViewController 有載入資料的功能,只要讓控制器遵守 Loading 協議就行,然後在合適的地方呼叫 loadData 方法:

class ViewController: UIViewController, Loading {
    var loadingView = TestLoadingView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        loadData(with: Test.justEmpty) { print($0) }
    }
}
複製程式碼

下面是執行結果:

Swift 面向協議程式設計的那些事

我們只要讓控制器遵守 Loading 協議,就實現了從網路載入資料並帶有載入動畫,而且在出錯時顯示錯誤檢視的功能。這裡肯定有人會說,使用繼承也可以實現上述需求。當然,我們可以把協議中的載入邏輯都放到一個基類中,也可以實現該需求。如果後面又要新增重新整理和分頁功能,那麼這些程式碼也只能放到基類中,這樣就會隨著專案越來越大,基類也變得越來越臃腫,這就是所謂的上帝類。如果我們將資料載入、重新整理、分頁作為不同的協議,讓控制器需要什麼就遵守相應的協議,那麼控制器就不會包含那些它不需要的功能了。這就像搭積木一樣,可以靈活地給程式新增它需要的內容。

協議與函式派發

函式派發就是一個程式在呼叫一個方法時,如何選擇要執行的指令的過程。當我們每次呼叫一個方法時函式派發都會發生。

編譯型語言有三種基礎的函式派發方式:直接派發(Direct Dispatch),函式表(Table Dispatch) 和訊息(Message Dispatch)。大部分語言支援一到兩種。Java 預設使用函式表派發,你可以通過使用 final 關鍵字將其變為直接派發。C++ 預設使用直接派發,通過 virtual 關鍵字可以改為函式表派發。Objective-C 總是使用訊息派發,但允許開發者使用 C 直接派發來獲取效能的提高(比如直接呼叫 IMP)。Swift 在這方面走在了前面,她支援全部的3種派發方式。這樣的方式非常好,,不過也給很多Swift開發者帶來了困擾。

這裡只簡單說一下函式派發在 protocol 中的不同表現。看下面的例子:

protocol Flyable {
    func fly()
}
複製程式碼

上面定義了 Flyable 協議,表示了飛行的能力。遵守該協議就必須實現 fly() 方法。我們可以提供幾個實現:

struct Eagle: Flyable {
    func fly() { print("? is flying") }
}

struct Plane: Flyable {
    func fly() { print("✈️ is flying") }
}
複製程式碼

寫個客戶端程式測試一下:

let fls: [Flyable] = [Eagle(), Plane()]
for fl in fls {
    fl.fly()
}

// result:
? is flying
✈️ is flying
複製程式碼

上面測試程式的執行結果和我們的設想完全一樣。上面 fly() 方法是在協議的定義裡進行宣告的,現在我們把它放到協議擴充裡進行宣告,就像下面這樣:

extension Flyable {
    func fly() { print("Something is flying") }
}
複製程式碼

在執行前你可以先猜測一下執行的結果。

先暫停 3 秒鐘...

下面是執行結果:

Something is flying
Something is flying
複製程式碼

你看,我們只是簡單地把在協議定義裡的方法挪到了協議擴充裡,執行結果卻完全不同。出現像上面那樣的執行結果還跟這行程式碼有關:

let fls: [Flyable] = [Eagle(), Plane()]
複製程式碼

如果你直接使用具體型別進行呼叫,肯定是沒有問題的,就像下面這樣:

Eagle().fly() 	// ? is flying
Plane().fly() 	// ✈️ is flying
複製程式碼

出現上面兩種完全不同的結果,主要是因為函式派發根據方法宣告的位置的不同而採用了不同的策略,總結起來有這麼幾點:

  • 值型別(struct, enum)總是會使用直接派發
  • 而協議和類的 extension 都會使用直接派發
  • 協議和普通 Swift 類宣告作用域裡的方法都會使用函式表進行派發
  • 繼承 NSObject 的類宣告作用域裡的方法都會使用函式表派發
  • 繼承 NSObject 的類的 extension 、使用 dynamic 標記的方法會使用訊息派發

下面這張圖很清楚地總結了 Swift 中函式派發方式,不過少了 dynamic 的方式。

Swift 面向協議程式設計的那些事

在上面的例子中,雖然 EaglePlane 都實現了 fly() 方法,但在多型時,仍然會呼叫協議擴充裡的預設實現。因為,在協議擴充宣告的方法,在呼叫時,使用的是直接派發,直接派發總是要優於其他的派發方式的。

所以理解 Swift 中的函式派發,對於我們寫出結構清晰、沒有 bug 的程式碼是非常重要的。當然,如果你沒有使用到多型,直接使用具體的型別,是不會出現上面的問題的。既然你都開始 "針對介面程式設計,而不是針對實現程式設計",怎麼會用不到多型呢,是吧。

使用協議改善既有的程式碼設計

通過上面的例子可以看出,通過協議進行程式碼共享相比與通過繼承的共享,有這幾個優勢:

  • 我們不需要被強制使用某個父類。
  • 我們可以讓已經存在的型別滿足協議 (比如我們讓 CGContext 滿足了 Renderer)。子類就沒那麼靈活了,如果 CGContext 是一個類的話,我們無法以追溯的方式去變更它的父類。
  • 協議既可以用於類,也可以用於結構體、列舉,而繼承就無法和結構體、列舉一起使用了。
  • 協議可以模擬多繼承。
  • 最後,當處理協議時,我們無需擔心方法重寫或者在正確的時間呼叫 super 這樣的問題。

通過面向協議的程式設計,我們可以從傳統的繼承上解放出來,用一種更靈活的方式,像搭積木一樣對程式進行組裝。協議和類一樣,在設計時要遵守 "單一職責" 原則,讓每個協議專注於自己的功能。得益於協議擴充套件,我們可以減少繼承帶來的共享狀態的風險,讓程式碼更加清晰。

使用面向協議程式設計有助於我們寫出低耦合、易於擴充套件以及可測試的程式碼,而結合泛型來使用協議,更可以讓我們免於動態呼叫和型別轉換的苦惱,保證了程式碼的安全性。

相關文章