Swift 面向協議編程的那些事

一直想寫一些 Swift 的東西,殊不知道從何寫起。由於想寫的東西太多,而後全部的東西都混雜在一塊兒,致使什麼都寫不出來。翻了翻之前在組內分享的一些東西,想一想把這些內容整理下,寫進博客吧。我對計劃要寫的東西作了個清單(最近作什麼都喜歡在前一天睡覺前作個清單,這樣多少改善了個人拖延症🤪):程序員

  • 面向協議編程
  • 使用值類型代替引用類型
  • 函數式編程
  • 單向數據流

面向協議編程是 Swift 不一樣於其餘語言的一個特性之一,也是比 Objective-C 強大的一個語言特性(並非Swift 獨有的,可是比 OC 的協議要強大不少),因此以面向協議編程做爲 Swift 系列文章的開端是最合適不過的了。算法

文章的內容可能有點長,我就把要講的內容簡單地列了一下,同窗們能夠根據本身掌握的狀況,跳到對應的小結進行閱讀。下面是主要內容:編程

  • 面向協議編程不是個新概念
  • Swift 中的協議
    • 從一個繪圖應用開始。經過實現一個繪圖應用,來說解在 Swift 中使用協議
    • 帶有 Self 和關聯類型的協議
      • 帶有 Self 的協議。經過實現一個二分查找,來說解如何在協議中使用 Self
      • 帶有關聯類型的協議。經過實現一個帶加載動畫的數據加載器,來說解如何在協議中使用關聯類型
    • 協議與函數派發。經過一個使用多態的例子,來說解函數派發在協議中的表現
  • 使用協議改善既有的代碼設計

面向協議編程不是個新概念

面向協議編程並非一個新概念,它其實就是廣爲所知的面向接口編程。面向協議編程 (Protocol Oriented Programming) 是 Apple 在 2015 年 WWDC 上提出的 Swift 的一種編程範式。swift

不少程序員都能理解類、對象、繼承和接口這些面向對象的概念(不知道的本身面壁去啊)。但是類與接口的區別何在?有類了幹嗎要使用接口?相信不少人都有這樣的疑問。接口(協議是同一個東西)定義了類型,實現接口(子類型化)讓咱們能夠用一個對象來代替另外一個對象。另外一方面,類繼承是經過複用父類的功能或者只是簡單地共享代碼和表述,來定義對象的實現和類型的一種機制。類繼承讓咱們可以從現成的類繼承所須要大部分功能,從而快速定義新的類。因此接口側重的是類型(是把某個類型當作另外一種類型來用),而類側重的是複用。理解了這個區別你就知道在何時使用接口,何時使用類了。設計模式

GoF 在《設計模式》一書中提到了可複用面向對象軟件設計的原則:api

針對接口編程,而不是針對實現編程安全

定義具備相同接口的類羣很重要,由於多態是基於接口的。其餘面向對象的編程語言,類如 Java,容許咱們定義 "接口"類型,它肯定了客戶端同全部其餘具體類直接到一種 "合約"。Objective-C 和 Swift中與之對應的就是協議(protocol)了。協議也是對象之間的一種合約,但自己是不可以實例化爲對象的。實現協議或者從抽象類繼承,使得對象共享相同的接口。所以,子類型的全部對象,均可以針對協議或抽象類的接口作出應答。bash

Swift 中的協議

在 WWDC2015 上,Apple 發佈了Swift 2。新版本包含了不少新的語言特性。在衆多改動之中,最引人注意的就是 protocol extensions。在 Swift 初版中,咱們能夠經過 extension 來爲已有的 class,struct 或 enum 拓展功能。而在 Swift 2 中,咱們也能夠爲 protocol 添加 extension。可能一開始看上去這個新特性並不起眼,實際上 protocol extensions 很是強大,以致於能夠改變 Swift 以前的某些編程思想。後面我會給出一個 protocol extension 在實際項目中使用案例。網絡

除了協議拓展,Swift 中的協議還有一些具備其餘特性的協議,好比帶有關聯類型的協議、包含 Self 的協議。這兩種協議跟普通的協議仍是有一些不一樣的,後面我也會給出具體的例子。app

咱們如今能夠開始編寫代碼,來掌握在實際開發中使用 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 提供的接口很是簡單的實現了繪製工做。 下圖是這個繪圖程序最終的效果:

完成上面繪圖程序的關鍵,是將圖形的定義和實際繪製操做拆開了,經過設計 DrawableRenderer 兩個協議,完成了一個高拓展的程序。想繪製其餘形狀,只要實現一個新的 Drawable 就能夠了。例如我想繪製下面這樣的圖形:

咱們能夠將原來的 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 協議:

寫完上面的實現,發現代碼根本就不能執行,報錯說的是 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。這樣咱們就解決了上面的問題。可是又出現了新的問題:

這就是上面所說的,帶有 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) }
    }
}
複製代碼

下面是運行結果:

咱們只要讓控制器遵照 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 的方式。

在上面的例子中,雖然 EaglePlane 都實現了 fly() 方法,但在多態時,仍然會調用協議拓展裏的默認實現。由於,在協議拓展聲明的方法,在調用時,使用的是直接派發,直接派發老是要優於其餘的派發方式的。

因此理解 Swift 中的函數派發,對於咱們寫出結構清晰、沒有 bug 的代碼是很是重要的。固然,若是你沒有使用到多態,直接使用具體的類型,是不會出現上面的問題的。既然你都開始 "針對接口編程,而不是針對實現編程",怎麼會用不到多態呢,是吧。

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

經過上面的例子能夠看出,經過協議進行代碼共享相比與經過繼承的共享,有這幾個優點:

  • 咱們不須要被強制使用某個父類。
  • 咱們可讓已經存在的類型知足協議 (好比咱們讓 CGContext 知足了 Renderer)。子類就沒那麼靈活了,若是 CGContext 是一個類的話,咱們沒法以追溯的方式去變動它的父類。
  • 協議既能夠用於類,也能夠用於結構體、枚舉,而繼承就沒法和結構體、枚舉一塊兒使用了。
  • 協議能夠模擬多繼承。
  • 最後,當處理協議時,咱們無需擔憂方法重寫或者在正確的時間調用 super 這樣的問題。

經過面向協議的編程,咱們能夠從傳統的繼承上解放出來,用一種更靈活的方式,像搭積木同樣對程序進行組裝。協議和類同樣,在設計時要遵照 "單一職責" 原則,讓每一個協議專一於本身的功能。得益於協議擴展,咱們能夠減小繼承帶來的共享狀態的風險,讓代碼更加清晰。

使用面向協議編程有助於咱們寫出低耦合、易於擴展以及可測試的代碼,而結合泛型來使用協議,更可讓咱們免於動態調用和類型轉換的苦惱,保證了代碼的安全性。

相關文章
相關標籤/搜索