【函式式 Swift】函式式思想

養樂多發表於2016-12-01

所謂函數語言程式設計方法,是藉助函式式思想對真實問題進行分析和簡化,繼而構建一系列簡單、實用的函式,再“裝配”成最終的程式,以解決問題的方法。

本章關鍵詞

請帶著以下關鍵詞閱讀本文:

  • 一等值(一等函式)
  • 模組化
  • 型別驅動

案例:Battleship

本章案例是一個關於戰艦攻擊範圍計算的問題,描述如下:

  • 戰艦能夠攻擊到射程範圍內的敵船
  • 攻擊時不能距離自身太近
  • 攻擊時不能距離友船太近

問題:計算某敵船是否在安全射程範圍內。

對於這個問題,我們換一種描述:

  • 輸入:目標(Ship)
  • 處理:計算戰艦到敵船的距離、敵船到友船的距離,判斷敵船距離是否在射程內,且敵船到友船距離足夠大
  • 輸出:是否(Bool)

看上去問題並不複雜,我們可以產出以下程式碼:

typealias Distance = Double

struct Position {
    var x: Double
    var y: Double
}

struct Ship {
    var position: Position
    var firingRange: Distance
    var unsafeRange: Distance
}

extension Ship {
    func canSafelyEngageShip(target: Ship, friendly: Ship) -> Bool {
        let dx = target.position.x - position.x
        let dy = target.position.y - position.y
        let targetDistance = sqrt(dx * dx + dy * dy)
        let friendlyDx = friendly.position.x - target.position.x
        let friendlyDy = friendly.position.y - target.position.y
        let friendlyDistance = sqrt(friendlyDx * friendlyDx + friendlyDy * friendlyDy)
        return targetDistance <= firingRange
            && targetDistance > unsafeRange 
            && friendlyDistance > unsafeRange
    }
}複製程式碼

可以看出,canSafelyEngageShip 方法分別計算了我們需要的兩個距離:targetDistancefriendlyDistance,隨後與戰艦的射程 firingRange 和安全距離 unsafeRange 進行比較。

功能看上去沒有什麼問題了,如果覺得 canSafelyEngageShip 方法過於繁瑣,還可以新增一些輔助函式:

extension Position {
    func minus(p: Position) -> Position {
        return Position(x: x - p.x, y: y - p.y)
    }
    var length: Double {
        return sqrt(x * x + y * y)
    }
}

extension Ship {
    func canSafelyEngageShip(target: Ship, friendly: Ship) -> Bool {
        let targetDistance = target.position.minus(p: position).length
        let friendlyDistance = friendly.position.minus(p: target.position).length
        return targetDistance <= firingRange
            && targetDistance > unsafeRange
            && (friendlyDistance > unsafeRange)
    }
}複製程式碼

到此,我們編寫了一段比較直觀且容易理解的程式碼,但由於我們使用了非常“過程式”的思維方式,所以擴充套件起來就不太容易了。比如,再新增一個友船,我們就需要再計算一個 friendlyDistance_2,這樣下去,程式碼會變得很複雜、難理解。

為了更好解決這個問題,我們先介紹一個概念:一等值(First-class Value),或者稱為 一等函式(First-class Function)

我們來看看維基上的解釋:

In computer science, a programming language is said to have first-class functions if it treats functions as first-class citizens. Specifically, this means the language supports passing functions as arguments to other functions, returning them as the values from other functions, and assigning them to variables or storing them in data structures.

簡單來說,就是函式與普通變數相比沒有什麼特殊之處,可以作為引數進行傳遞,也可以作為函式的返回值。在 Swift 中,函式是一等值。帶著這個思維,我們嘗試使用更加宣告式的方式來思考這個問題。

歸根結底,就是定義一個函式來判斷一個點是否在範圍內,所以我們要的就是一個輸入 Position,輸出 Bool 的函式:

func pointInRange(point: Position) -> Bool {
    ...
}複製程式碼

然後,我們就可以用這個能夠判斷一個點是否在區域內的函式來表示一個區域,為了更容易理解,我們給這個函式起個名字(因為函式是一等值,所以我們可以像變數一樣為其設定別名):

typealias Region = (Position) -> Bool複製程式碼

我們將攻擊範圍理解為可見區域,超出攻擊範圍或處於不安全範圍均視為不可見區域,那麼可知:

有效區域 = 可見區域 - 不可見區域

如此,問題從距離運算演變成了區域運算。明確問題後,我們可以定義以下區域:

// 圓心為原點,半徑為 radius 的圓形區域
func circle(radius: Distance) -> Region {
    return { point in point.length <= radius }
}

// 圓心為 center,半徑為 radius 的圓形區域
func circle2(radius: Distance, center: Position) -> Region {
    return { point in point.minus(p: center).length <= radius }
}

// 區域變換函式
func shift(region: @escaping Region, offset: Position) -> Region {
    return { point in region(point.minus(p: offset)) }
}複製程式碼

前兩個函式很容易理解,但第三個區域有些特別,它將一個輸入的 region 通過 offset 變化後返回一個新的 region。為什麼要有這樣一個特殊“區域”呢?其實,這是函數語言程式設計的一個核心概念,為了避免產生 circle2 這樣會不斷擴充套件然後變複雜的函式,通過一個函式來改變另一個函式的方式更加合理。例如,一個圓心為 (5,5) 半徑為 10 的圓就可以用下面的方式來表示了:

shift(region: circle(radius: 10), offset: Position(x: 5, y: 5))複製程式碼

掌握了 shift 式的區域定義方法,我們可以繼續定義以下“區域”:

// 將原區域取反得到新區域
func invert(region: @escaping Region) -> Region {
    return { point in !region(point) }
}

// 取兩個區域的交集作為新區域
func intersection(region1: @escaping Region, _ region2: @escaping Region) -> Region {
    return { point in region1(point) && region2(point) }
}

// 取兩個區域的並集作為新區域
func union(region1: @escaping Region, _ region2: @escaping Region) -> Region {
    return { point in region1(point) || region2(point) }
}

// 取在一個區域,且不在另一個區域,得到新區域
func difference(region: @escaping Region, minus: @escaping Region) -> Region {
    return intersection(region1: region, invert(region: minus))
}複製程式碼

很輕鬆有木有!

基於這個小型工具庫,我們來改寫案例中的程式碼,並與之前的程式碼進行對比:

// After
func canSafelyEngageShip(target: Ship, friendly: Ship) -> Bool {
    let rangeRegion = difference(region: circle(radius: firingRange),
     minus: circle(radius: unsafeRange))
    let firingRegion = shift(region: rangeRegion, offset: position)
    let friendlyRegion = shift(region: circle(radius: unsafeRange),
     offset: friendly.position)
    let resultRegion = difference(region: firingRegion, minus: friendlyRegion)
    return resultRegion(target.position)
}

// Before
func canSafelyEngageShip(target: Ship, friendly: Ship) -> Bool {
    let targetDistance = target.position.minus(p: position).length
    let friendlyDistance = friendly.position.minus(p: target.position).length
    return targetDistance <= firingRange
      && targetDistance > unsafeRange
      && (friendlyDistance > unsafeRange)
}複製程式碼

藉助以上函式式的思維方式,我們避開了具體問題中一系列的複雜數值計算,得到了易讀、易維護、易遷移的程式碼。


思考

一等值(一等函式)

一等值這個名詞我們可能較少聽到,但其概念卻是滲透在我們日常開發過程中的。將函式與普通變數對齊,是很重要的一項語言特性,不僅是編碼過程,在簡化問題上也能為我們帶來巨大的收益。

例如本文案例,我們使用函式描述區域,然後使用區域運算代替距離運算,在區域運算中,又使用了諸如 shift 的函式式思想,進而將問題進行簡化並最終解決。

模組化

在《函式式 Swift》的前言部分,有一段對模組化的描述:

相比於把程式認為是一系列賦值和方法呼叫,函式式開發者更傾向於強調每個程式都能被反覆分解為越來越小的模組單元,而所有這些模組可以通過函式裝配起來,以定義一個完整的程式。

模組化是一個聽上去很酷,遇到真實問題後有時又會變得難以下手,本文案例中,原始問題看上去目標簡單並且明確,只需要一定的數值計算就可以得到最終結果,但當我們藉助函式式思維,將問題的解決轉變為區域運算後,關注點就轉變為區域的定義上,然後進一步分解為區域變換、交集、並集、差集等模組,最後,將這些模組“裝配”起來,問題的解決也就順理成章了。

型別驅動

這裡的型別,對應著前文中我們定義的 Region,因為我們選用了 Region 這個函式式定義來描述案例中的基本問題單元,即判斷一個點是否在區域內,從而使我們的問題轉變為了區域運算。

可見,我們是一種“型別驅動”的問題解決方式,或者說是編碼方式,型別的選擇決定了我們解決問題的方向,假如我們堅持使用 PositionDistance,那麼解決問題的方向必然陷入此類數值運算中,顯然,函式式的型別定義幫助我們簡化並且更加優雅的解決了問題。


參考資料

  1. Github: objcio/functional-swift
  2. First-class function

本文屬於《函式式 Swift》讀書筆記系列,同步更新於 huizhao.win,歡迎關注!

相關文章