[譯] 值型別導向程式設計

淚已無痕發表於2019-01-02

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

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

protocol Drawable {}

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

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

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

這些是值型別。它解決了物件導向方法中的許多問題:

  1. 例項不能隱式共享

    物件的引用在物件傳遞時增加了複雜性。在一個地方改變物件的屬性可能會影響有權訪問該物件的其他程式碼。併發需要鎖定,這增加了大量的複雜性。

  2. 無繼承問題

    通過繼承來重用程式碼的方式是脆弱的。繼承還將介面與實現耦合在一起,這使得程式碼重用變得更加困難。這是它的特性,但即使是使用物件導向的程式設計師也會告訴你他更喜歡“組合而不是繼承”。

  3. 明確的型別關係

    對於子類,很難精確識別其型別。比如 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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章