[譯] 值類型導向編程

在 2015 WWDC 大會上,在一個具備影響力的會議(面向協議的 Swift 編程)中,Dave Abrahams 解釋瞭如何用 Swift 的協議來解決類的一些缺點。他提出了這條規則:「不要從類開始,從協議開始」。前端

爲了說明這一點,Dave 經過面向協議的方法描述了一個基本繪圖應用。該示例使用了一些基本形狀:android

protocol Drawable {}

struct Polygon: Drawable {
  var corners: [CGPoint] = []
}

struct Circle: Drawable {
  var center: CGPoint
  var radius: CGFloat
}

struct Diagram: Drawable {
  var elements: [Drawable] = []
}
複製代碼

這些是值類型。它解決了面向對象方法中的許多問題:ios

  1. 實例不能隱式共享git

    對象的引用在對象傳遞時增長了複雜性。在一個地方改變對象的屬性可能會影響有權訪問該對象的其餘代碼。併發須要鎖定,這增長了大量的複雜性。程序員

  2. 無繼承問題github

    經過繼承來重用代碼的方式是脆弱的。繼承還將接口與實現耦合在一塊兒,這使得代碼重用變得更加困難。這是它的特性,但即便是使用面向對象的程序員也會告訴你他更喜歡「組合而不是繼承」。編程

  3. 明確的類型關係swift

    對於子類,很難精確識別其類型。好比 NSObject.isEqual(),你必須當心且只能與兼容類型比較。協議和泛型協同工做能夠精確識別類型。後端

爲了處理實際的繪圖操做,咱們能夠添加一個描述基本繪圖操做的 Renderer 協議:併發

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

而後每種類型均可以使用 Rendererdraw 方法進行繪製。

protocol Drawable {
  func draw(_ renderer: Renderer)
}

extension Polygon : Drawable {
  func draw(_ renderer: Renderer) {
    renderer.move(to: corners.last!)
    for p in corners {
      renderer.line(to: p)
    }
  }
}

extension Circle : Drawable {
  func draw(renderer: Renderer) {
    renderer.arc(at: center, radius: radius, startAngle: 0.0, endAngle: twoPi)
  }
}

extension Diagram : Drawable {
  func draw(renderer: Renderer) {
    for f in elements {
      f.draw(renderer)
    }
  }
}
複製代碼

這使得定義根據給定類型並能爲此輕鬆工做的各類渲染器變的可能。一個最主要的賣點是定義測試渲染器的能力,它容許你經過比較字符串來驗證繪製:

struct TestRenderer : Renderer {
  func move(to p: CGPoint) { print("moveTo(\(p.x), \(p.y))") }
  func line(to p: CGPoint) { print("lineTo(\(p.x), \(p.y))") }
  func arc(at center: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat) {
      print("arcAt(\(center), radius: \(radius),"
        + " startAngle: \(startAngle), endAngle: \(endAngle))")
  }
}
複製代碼

你也能夠輕鬆擴展平臺特定的類型,使其成爲渲染器:

extension CGContext : Renderer {
  // CGContext already has `move(to: CGPoint)`

  func line(to p: CGPoint) {
    addLine(to: p)
  }

  func arc(at center: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat) {
    addArc(
      center: center,
      radius: radius,
      startAngle: startAngle,
      endAngle: endAngle,
      clockwise: true
    )
  }
}
複製代碼

最後,Dave 代表你能夠經過擴展協議來提供方便:

extension Renderer {
  func circle(at center: CGPoint, radius: CGFloat) {
    arc(at: center, radius: radius, startAngle: 0, endAngle: twoPi)
  }
}
複製代碼

我認爲這種方法很是棒,它具備更好的可測試性。它還容許咱們經過提供不一樣的渲染器,從而使用不一樣的方式解釋數據。而且值類型巧妙地迴避了面對對象版本中可能遇到的許多問題。

雖然有所改進,但邏輯和反作用仍然在面向協議的版本中強度耦合。Polygon.draw 作了兩件事:它將多邊形轉換爲多條線,而後渲染這些線。所以,當須要測試這些邏輯時,咱們須要使用 TestRenderer — 儘管 WWDC 暗示它只是一個模擬。

extension Polygon : Drawable {
  func draw(_ renderer: Renderer) {
    renderer.move(to: corners.last!)
    for p in corners {
      renderer.line(to: p)
    }
  }
}
複製代碼

咱們能夠將邏輯和效果拆分紅不一樣的步驟來區分它們。使用 movelinearc 來替代 Renderer 協議,讓咱們聲明表明這些底層操做的值類型。

enum Path: Hashable {
  struct Arc: Hashable {
    var center: CGPoint
    var radius: CGFloat
    var startAngle: CGFloat
    var endAngle: CGFloat
  }

  struct Line: Hashable {
    var start: CGPoint
    var end: CGPoint
  }

  // Replacing `arc(at: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat)`
  case arc(Arc)
  // Replacing `move(to: CGPoint)` and `line(to: CGPoint)`
  case line(Line)
}
複製代碼

如今,Drawable 能夠經過返回一組用於繪製的 path 來替代方法調用:

protocol Drawable {
  var paths: Set<Path> { get }
}

extension Polygon : Drawable {
  var paths: Set<Path> {
    return Set(zip(corners, corners.dropFirst() + corners.prefix(1))
      .map(Path.Line.init)
      .map(Path.line))
  }
}

extension Circle : Drawable {
  var paths: Set<Path> {
    return [.arc(Path.Arc(center: center, radius: radius, startAngle: 0.0, endAngle: twoPi))]
  }
}

extension Diagram : Drawable {
  var paths: Set<Path> {
    return elements
      .map { $0.paths }
      .reduce(into: Set()) { $0.formUnion($1) }
  }
}
複製代碼

如今 CGContext 經過擴展來繪製這些路徑:

extension CGContext {
    func draw(_ arc: Path.Arc) {
        addArc(
            center: arc.center,
            radius: arc.radius,
            startAngle: arc.startAngle,
            endAngle: arc.endAngle,
            clockwise: true
        )
    }

    func draw(_ line: Path.Line) {
        move(to: line.start)
        addLine(to: line.end)
    }

    func draw(_ paths: Set<Path>) {
        for path in paths {
            switch path {
            case let .arc(arc):
                draw(arc)
            case let .line(line):
                draw(line)
            }
        }
    }
}
複製代碼

咱們能夠添加用來建立 circle 的便捷方法:

extension Path {
  static func circle(at center: CGPoint, radius: CGFloat) -> Path {
    return .arc(Path.Arc(center: center, radius: radius, startAngle: 0, endAngle: twoPi))
  }
}
複製代碼

這與以前的運行效果同樣,並須要大體相同數量的代碼。但咱們引入了一個邊界,讓咱們將系統的兩個部分分開。這個邊界讓咱們:

  1. 沒有模擬測試

    咱們再也不須要 TestRenderer 了,咱們能夠經過測試從 paths 屬性返回的值來驗證 Drawable 是否能夠正確繪製。Path可進行相等比較 的,因此這是一個簡單的測試。

let polygon = Polygon(corners: [(x: 0, y: 0), (x: 6, y: 0), (x: 3, y: 6)])
let paths: Set<Path> = [
  .line(Line(from: (x: 0, y: 0), to: (x: 6, y: 0))),
  .line(Line(from: (x: 6, y: 0), to: (x: 3, y: 6))),
  .line(Line(from: (x: 3, y: 6), to: (x: 0, y: 0))),
]
XCTAssertEqual(polygon.paths, paths)
複製代碼
  1. 插入更多步驟

    使用值類型導向方法,咱們可使用 Set<Path> 並直接對其進行轉換。假設你想要水平翻轉結果。你只要計算尺寸,而後返回一個新的 Set<Path> 翻轉座標便可。

    在面向協議的方法中,繪製轉換步驟會有些困難。若是想要水平翻轉,你須要知道最終寬度。因爲預先不知道這個寬度,你須要實現一個 Renderer,(1)它保存了全部的方法調用(movelinearc)。(2)而後將其傳遞給另外一個 Render 來渲染翻轉結果。

    (這個假設的渲染器建立了咱們經過值類型導向方法建立的渲染器相同的邊界。步驟 1 對應於 .paths 方法;步驟 2 對應於 draw(Set<Paths>)。)

  2. 在調試時輕鬆檢查數據

    假設你有一個沒有正確繪製的複雜 Diagram。你進入調試器並找到繪製 Diagram 的位置。你如何定位這個問題?

    若是你正在使用面向協議的方法,你須要建立一個 TestRenderer(若是它在測試以外可用),或者你須要使用真實的渲染器並實際渲染某一部分。數據檢查將變得很困難。

    但若是你使用值類型導向方法,你只須要調用 paths 來檢查這些信息。相對於渲染效果,調試器更容易顯示數據值。

邊界增長了另外一個語義,爲測試、轉換和檢查帶來了更多的可能性。

我已經在不少項目中使用了這種方法,並發現它很是有用。即便是像本文給出的簡單例子,值類型也具備不少好處。但在更大、更復雜的系統中,這些好處將變得更加明顯和有用。

若是你想看一個真實的例子,請查看 PersistDB。我一直在研究的 Swift 持久存儲庫。公共 API 提供 QueryPredicateExpression。它們是 SQL.QuerySQL.PredicateSQL.Expression 的簡化版。它們中的每個都會被轉換成一個 SQL(一個表明一些實際 SQL 的值)。

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索