化零爲整:Reduce 詳解

做者:Benedikt Terhechte,原文連接,原文日期:2015-11-30
譯者:pmst;校對:Cee;定稿:千葉知風git

即便早在 Swift 正式發佈以前,iOS / Cocoa 開發者均可以使用諸如 ObjectiveSugar 或者 ReactiveCocoa 第三方庫,實現相似 mapflatMapfilter 等函數式編程的構建。而在 Swift 中,這些傢伙(map 等幾個函數)已經入駐成爲「頭等公民」了。比起標準的 for 循環,使用函數式編程有不少優點。它們一般可以更好地表達你的意圖,減小代碼的行數,以及使用鏈式結構構建複雜的邏輯,更顯清爽。github

本文中,我將介紹附加於 Swift 中的一個很是酷的函數:「Reduce」。相對於 map / filter 函數,reduce 有時不失爲一個更好的解決方案。編程

一個簡單的問題

思考這麼一個問題:你從 JSON 中獲取到一個 persons 列表,意圖計算全部來自 California 的居民的平均年齡。須要解析的數據以下所示:swift

let persons: [[String: AnyObject]] = [["name": "Carl Saxon", "city": "New York, NY", "age": 44],
 ["name": "Travis Downing", "city": "El Segundo, CA", "age": 34],
 ["name": "Liz Parker", "city": "San Francisco, CA", "age": 32],
 ["name": "John Newden", "city": "New Jersey, NY", "age": 21],
 ["name": "Hector Simons", "city": "San Diego, CA", "age": 37],
 ["name": "Brian Neo", "age": 27]] //注意這傢伙沒有 city 鍵值

注意最後一個記錄,它遺漏了問題中 person 的居住地 city 。對於這些狀況,默默忽略便可...數組

本例中,咱們指望的結果是那三位來自 California 的居民。讓咱們嘗試在 Swift 中使用 flatMapfilter 來實現這個任務。使用 flatMap 函數替代 map 函數的緣由在於前者可以忽略可選值爲 nil 的狀況。例如 flatMap([0,nil,1,2,nil]) 的結果是 [0,1,2]。處理那些沒有 city 屬性的狀況這會很是有用。閉包

func infoFromState(state state: String, persons: [[String: AnyObject]]) 
     -> Int {
       // 先進行 flatMap 後進行 filter 篩選
     // $0["city"] 是一個可選值,對於那些沒有 city 屬性的項返回 nil
     // componentsSeparatedByString 處理鍵值,例如 "New York, NY" 
     // 最後返回的 ["New York","NY"],last 取到最後的 NY
    return persons.flatMap( { $0["city"]?.componentsSeparatedByString(", ").last })
       .filter({$0 == state})
       .count
}
infoFromState(state: "CA", persons: persons)
//#+RESULTS:
//: 3

這很是簡單。app

不過,如今來思考另一個難題:你想要獲悉居住在 California 的人口數,接着計算他們的平均年齡。若是咱們想要在上面函數的基礎上嘗試作修改,立馬會發現難度不小。解決方法卻是有幾種,不過大都看起來不適用函數式結構解決方案。卻是經過循環的方式能簡單的解決這個問題。ide

這時候咱們要琢磨爲啥不適用了,緣由很簡單:數據的形式(Shape)改變了。而 mapflatMapfilter 函數可以始終保持數據形式的類似性。數組傳入,數組返回。固然數組的元素個數和內容能夠改變,不過始終是數組形式(Array-shape)。可是,上面所描述的問題要求咱們最後轉換成的結果是個結構體(Struct),或者說是以元組(Tuple)的形式包含一個整型平均值(平均年齡)一個整型總和(人口數)函數式編程

對於這種類型的問題,咱們可使用 reduce 來救場。函數

Reduce

Reduce 是 mapflatMapfilter 的一種擴展的形式(譯者注:後三個函數能幹嗎,reduce 就能用另一種方式實現)。Reduce 的基礎思想是將一個序列轉換爲一個不一樣類型的數據,期間經過一個累加器(Accumulator)來持續記錄遞增狀態。爲了實現這個方法,咱們會向 reduce 方法中傳入一個用於處理序列中每一個元素的結合(Combinator)閉包 / 函數 / 方法。這聽起來有點複雜,不過經過幾個例子練手,你就會發現這至關簡單。

它是 SequenceType 中的一個方法,看起來是這樣的(簡化版本):

func reduce<T>(initial: T, combine: (T, Self.Generator.Element) -> T) -> T

此刻,咱們擁有一個初始值(Initial value)以及一個閉包(返回值類型和初始值類型一致)。函數最後的返回值一樣和初始值類型一致,爲 T

假設咱們如今要實現一個 reduce 操做 — 對一個整數列表值作累加運算,方案以下:

func combinator(accumulator: Int, current: Int) -> Int {
   return accumulator + current
}
[1, 2, 3].reduce(0, combine: combinator)
// 執行步驟以下
combinator(0, 1) { return 0 + 1 } = 1
combinator(1, 2) { return 1 + 2 } = 3
combinator(3, 3) { return 3 + 3 } = 6
= 6

[1, 2, 3] 中的每一個元素都將調用一次結合(Combinator)函數進行處理。同時咱們使用累加器(Accumulator)變量實時記錄遞增狀態(遞增並不是是指加法),這裏是一個整型值。

接下來,咱們從新實現那些函數式編程的「夥伴」(本身來寫 map、flatMap 和 filter 函數)。簡便起見,全部這些方法都是對 IntOptional<Int> 進行操做的;換言之,咱們此刻不考慮泛型。另外牢記下面的實現只是爲了展現 reduce 的實現過程。原生的 Swift 實現相比較下面 reduce 的版本,速度要快不少1。不過,Reduce 能在不一樣的問題中表現得很好,以後會進一步地詳述。

Map

// 從新定義一個 map 函數
func rmap(elements: [Int], transform: (Int) -> Int) -> [Int] {
    return elements.reduce([Int](), combine: { (var acc: [Int], obj: Int) -> [Int] in
       acc.append(transform(obj))
       return acc
    })
}
print(rmap([1, 2, 3, 4], transform: { $0 * 2}))
// [2, 4, 6, 8]

這個例子可以很好地幫助你理解 reduce 的基礎知識。

  • 首先,elements 序列調用 reduce 方法:elements.reduce...

  • 而後,咱們傳入初始值給累加器(Accumulator),即一個 Int 類型空數組([Int]())。

  • 接着,咱們傳入 combinator 閉包,它接收兩個參數:第一個參數爲 accumulator,即 acc: [Int];第二個參數爲從序列中取得的當前對象 obj: Int(譯者注:對序列進行遍歷,每次取到其中的一個對象 obj)。

  • combinator 閉包體中的實現代碼很是簡單。咱們對 obj 作變換處理,而後添加到累加器 accumulator 中。最後返回 accumulator 對象。

相比較調用 map 方法,這種實現代碼看起來有點冗餘。的確如此!可是,上面這個版本至關詳細地解釋了 reduce 方法是怎麼工做的。咱們能夠對此進行簡化。

func rmap(elements: [Int], transform: (Int) -> Int) -> [Int] {
    // $0 表示第一個傳入參數,$1 表示第二個傳入參數,依次類推...
    return elements.reduce([Int](), combine: {$0 + [transform($1)]})
}
print(rmap([1, 2, 3, 4], transform: { $0 * 2}))
// [2, 4, 6, 8]

依舊可以正常運行。這個版本都有哪些不一樣呢?實際上,咱們使用了 Swift 中的小技巧,+ 運算符可以對兩個序列進行加法操做。所以 [0, 1, 2] + [transform(4)] 表達式將左序列和右序列進行相加,其中右序列由轉換後的元素構成。

這裏有個地方須要引發注意:[0, 1, 2] + [4] 執行速度要慢於 [0, 1, 2].append(4)。假若你正在處理龐大的列表,應取代集合 + 集合的方式,轉而使用一個可變的 accumulator 變量進行遞增:

func rmap(elements: [Int], transform: (Int) -> Int) -> [Int] {
    return elements.reduce([Int](), combine: { (var ac: [Int], b: Int) -> [Int] in 
    // 做者提倡使用這種,由於執行速度更快
    ac.append(transform(b))
    return ac
    })
}

爲了進一步加深對 reduce 的理解,咱們將繼續從新實現 flatMapfilter 方法。

func rflatMap(elements: [Int], transform: (Int) -> Int?) -> [Int] {
    return elements.reduce([Int](), 
       combine: { guard let m = transform($1) else { return $0 } 
          return $0 + [m]})
}
print(rflatMap([1, 3, 4], transform: { guard $0 != 3 else { return nil }; return $0 * 2}))
// [2, 8]

這裏 rflatMap 和 rmap 主要差別在於,前者增長了一個 guard 表達式確保可選類型始終有值(換言之,摒棄那些 nil 的狀況)。

Filter

func rFilter(elements: [Int], filter: (Int) -> Bool) -> [Int] {
    return elements.reduce([Int](), 
       combine: { guard filter($1) else { return $0 } 
          return $0 + [$1]})
}
print(rFilter([1, 3, 4, 6], filter: { $0 % 2 == 0}))
// [4, 6]

依舊難度不大。咱們再次使用 guard 表達式確保知足篩選條件。

到目前爲止,reduce 方法看起來更像是 mapfilter 的複雜版本,除此以外然並卵。不過,所結合的內容不須要是一個數組,它能夠是其餘任何類型。這使得咱們依靠一種簡單的方式,就能夠輕鬆地實現各類 reduction 操做。

Reduce 範例

首先介紹我最喜歡的數組元素求和範例:

// 初始值 initial 爲 0,每次遍歷數組元素,執行 + 操做
[0, 1, 2, 3, 4].reduce(0, combine: +)
// 10

僅傳入 + 做爲一個 combinator 函數是有效的,它僅僅是對 lhs(Left-hand side,等式左側)rhs(Right-hand side,等式右側) 作加法處理,最後返回結果值,這徹底知足 reduce 函數的要求。

另一個範例:經過一組數字計算他們的乘積:

// 初始值 initial 爲 1,每次遍歷數組元素,執行 * 操做
[1, 2, 3, 4].reduce(1, combine: *)
// 24

甚至咱們能夠反轉數組:

// $0 指累加器(accumulator),$1 指遍歷數組獲得的一個元素
[1, 2, 3, 4, 5].reduce([Int](), combine: { [$1] + $0 })
// 5, 4, 3, 2, 1

最後,來點有難度的任務。咱們想要基於某個標準對列表作劃分(Partition)處理:

// 爲元組定義個別名,此外 Acc 也是閉包傳入的 accumulator 的類型
typealias Acc = (l: [Int], r: [Int])
func partition(lst: [Int], criteria: (Int) -> Bool) -> Acc {
   return lst.reduce((l: [Int](), r: [Int]()), combine: { (ac: Acc, o: Int) -> Acc in 
      if criteria(o) {
    return (l: ac.l + [o], r: ac.r)
      } else {
    return (r: ac.r + [o], l: ac.l)
      }
   })
}
partition([1, 2, 3, 4, 5, 6, 7, 8, 9], criteria: { $0 % 2 == 0 })
//: ([2, 4, 6, 8], [1, 3, 5, 7, 9])

上面實現中最有意思的莫過於咱們使用 tuple 做爲 accumulator。你會漸漸發現,一旦你嘗試將 reduce 進入到平常工做流中,tuple 是一個不錯的選擇,它可以將數據與 reduce 操做快速掛鉤起來。

執行效率對比:Reduce vs. 鏈式結構

reduce 除了較強的靈活性以外,還具備另外一個優點:一般狀況下,mapfilter 所組成的鏈式結構會引入性能上的問題,由於它們須要屢次遍歷你的集合才能最終獲得結果值,這種操做每每伴隨着性能損失,好比如下代碼:

[0, 1, 2, 3, 4].map({ $0 + 3}).filter({ $0 % 2 == 0}).reduce(0, combine: +)

除了毫無心義以外,它還浪費了 CPU 週期。初始序列(即 [0, 1, 2, 3, 4])被重複訪問了三次之多。首先是 map,接着 filter,最後對數組內容求和。其實,全部這一切操做咱們可以使用 reduce 徹底替換實現,極大提升執行效率:

// 這裏只須要遍歷 1 次序列足矣
[0, 1, 2, 3, 4].reduce(0, combine: { (ac: Int, r: Int) -> Int in 
   if (r + 3) % 2 == 0 {
     return ac + r + 3
   } else {
     return ac
   }
})

這裏給出一個快速的基準運行測試,使用以上兩個版本以及 for-loop 方式對一個容量爲 100000 的列表作處理操做:

// for-loop 版本
var ux = 0
for i in Array(0...100000) {
    if (i + 3) % 2 == 0 {
    ux += (i + 3)
    }
}

測試結果

正如你所看見的,reduce 版本的執行效率和 for-loop 操做很是相近,且是鏈式操做的一半時間。

不過,在某些狀況中,鏈式操做是優於 reduce 的。思考以下範例:

Array(0...100000).map({ $0 + 3}).reverse().prefix(3)
// 0.027 Seconds


Array(0...100000).reduce([], combine: { (var ac: [Int], r: Int) -> [Int] in
    ac.insert(r + 3, atIndex: 0)
    return ac
}).prefix(3)
// 2.927 Seconds

這裏,注意到使用鏈式操做花費 0.027s,這與 reduce 操做的 2.927s 造成了鮮明的反差,這到底是怎麼回事呢?2

Reddit 網站的搜索結果指出,從 reduce 的語義上來講,傳入閉包的參數(若是可變的話,即 mutated),會對底層序列的每一個元素都產生一份 copy 。在咱們的案例中,這意味着 accumulator 參數 ac 將爲 0…100000 範圍內的每一個元素都執行一次複製操做。有關對此更好、更詳細的解釋請看這篇 Airspeedvelocity 博客文章。

所以,當咱們試圖使用 reduce 來替換掉一組操做時,請時刻保持清醒,問問本身:reduction 在問題中的情形下是否確實是最合適的方式。

如今,能夠回到咱們的初始問題:計算人口總數和平均年齡。請試着用 reduce 來解決吧。

再一次嘗試來寫 infoFromState 函數

func infoFromState(state state: String, persons: [[String: AnyObject]]) 
      -> (count: Int, age: Float) {

      // 在函數內定義別名讓函數更加簡潔
      typealias Acc = (count: Int, age: Float)

      // reduce 結果暫存爲臨時的變量
      let u = persons.reduce((count: 0, age: 0.0)) {
      (ac: Acc, p) -> Acc in

      // 獲取地區和年齡
      guard let personState = (p["city"] as? String)?.componentsSeparatedByString(", ").last,
        personAge = p["age"] as? Int

        // 確保選出來的是來自正確的洲
        where personState == state

        // 若是缺失年齡或者地區,又或者上者比較結果不等,返回
        else { return ac }

      // 最終累加計算人數和年齡
      return (count: ac.count + 1, age: ac.age + Float(personAge))
      }

  // 咱們的結果就是上面的人數和除以人數後的平均年齡
  return (age: u.age / Float(u.count), count: u.count)
}
print(infoFromState(state: "CA", persons: persons))
// prints: (count: 3, age: 34.3333)

和早前的範例同樣,咱們再次使用了 tuple 做爲 accumulator 記錄狀態值。除此以外,代碼讀起來簡明易懂。

同時,咱們在函數體中定義了一個別名 Acctypealias Acc = (count: Int, age: Float),起到了簡化類型註釋的做用。

總結

本文是對 reduce 方法的一個簡短概述。假若你不想將過多函數式方法經過鏈式結構串聯起來調用,亦或是數據的輸出形式與傳入數據的形式不一致時,reduce 就至關有用了。最後,我將向你展現經過使用 reduce 的各類範例來結束本文,但願能爲你帶來些許靈感。

更多範例

如下範例展現了 reduce 的其餘使用案例。請記住例子只做爲展現教學使用,即它們更多地強調 reduce 的使用方式,而非爲你的代碼庫提供通用的解決方法。大多數範例均可以經過其餘更好、更快的方式來編寫(即經過 extension 或 generics)。而且這些實現方式已經在許多 Swift 庫中都有實現,諸如 SwiftSequence 以及 Dollar.swift

Minimum

返回列表中的最小項。顯然,[1, 5, 2, 9, 4].minElement() 方法更勝一籌。

// 初始值爲 Int.max,傳入閉包爲 min:求兩個數的最小值
// min 閉包傳入兩個參數:1. 初始值 2. 遍歷列表時的當前元素
// 假若當前元素小於初始值,初始值就會替換成當前元素
// 示意寫法: initial = min(initial, elem)
[1, 5, 2, 9, 4].reduce(Int.max, combine: min)

Unique

剔除列表中重複的元素。固然,最好的解決方式是使用集合(Set)

[1, 2, 5, 1, 7].reduce([], combine: { (a: [Int], b: Int) -> [Int] in
if a.contains(b) {
   return a
} else {
   return a + [b]
}
})
// prints: 1, 2, 5, 7

Group By

遍歷整個列表,經過一個鑑別函數對列表中元素進行分組,將分組後的列表做爲結果值返回。問題中的鑑別函數返回值類型須要遵循 Hashable 協議,這樣咱們才能擁有不一樣的鍵值。此外保留元素的排序,而組內元素排序則不必定被保留下來。

func groupby<T, H: Hashable>(items: [T], f: (T) -> H) -> [H: [T]] {
   return items.reduce([:], combine: { (var ac: [H: [T]], o: T) -> [H: [T]] in 
       // o 爲遍歷序列的當前元素
       let h = f(o) // 經過 f 函數獲得 o 對應的鍵值
       if var c = ac[h] { // 說明 o 對應的鍵值已經存在,只須要更新鍵值對應的數組元素便可
       c.append(o)
       ac.updateValue(c, forKey: h)
       } else { // 說明 o 對應的鍵值不存在,須要爲字典新增一個鍵值,對應值爲 [o]
       ac.updateValue([o], forKey: h)
       }
       return ac
   })
}
print(groupby([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], f: { $0 % 3 }))
// prints: [2: [2, 5, 8, 11], 0: [3, 6, 9, 12], 1: [1, 4, 7, 10]]
print(groupby(["Carl", "Cozy", "Bethlehem", "Belem", "Brand", "Zara"], f: { $0.characters.first! }))
// prints: ["C" : ["Carl" , "Cozy"] , "B" : ["Bethlehem" , "Belem" , "Brand"] , "Z" : ["Zara"]]

Interpose

函數給定一個 items 數組,每隔 count 個元素插入 element 元素,返回結果值。下面的實現確保了 element 僅在中間插入,而不會添加到數組尾部。

func interpose<T>(items: [T], element: T, count: Int = 1) -> [T] {
   // cur 爲當前遍歷元素的索引值 cnt 爲計數器,當值等於 count 時又從新置 1
   typealias Acc = (ac: [T], cur: Int, cnt: Int)
   return items.reduce((ac: [], cur: 0, cnt: 1), combine: { (a: Acc, o: T) -> Acc in 
       switch a {
      // 此時遍歷的當前元素爲序列中的最後一個元素
      case let (ac, cur, _) where (cur+1) == items.count: return (ac + [o], 0, 0)
      // 知足插入條件
      case let (ac, cur, c) where c == count:
         return (ac + [o, element], cur + 1, 1)
      // 執行下一步
      case let (ac, cur, c):
         return (ac + [o], cur + 1, c + 1)
       }
   }).ac
}
print(interpose([1, 2, 3, 4, 5], element: 9))
// : [1, 9, 2, 9, 3, 9, 4, 9, 5]
print(interpose([1, 2, 3, 4, 5], element: 9, count: 2))
// : [1, 2, 9, 3, 4, 9, 5]

Interdig

該函數容許你有選擇從兩個序列中挑選元素合併成爲一個新序列返回。

func interdig<T>(list1: [T], list2: [T]) -> [T] {
   // Zip2Sequence 返回 [(list1, list2)] 是一個數組,類型爲元組
   // 也就解釋了爲何 combinator 閉包的類型是 (ac: [T], o: (T, T)) -> [T]
   return Zip2Sequence(list1, list2).reduce([], combine: { (ac: [T], o: (T, T)) -> [T] in 
    return ac + [o.0, o.1]
   })
}
print(interdig([1, 3, 5], list2: [2, 4, 6]))
// : [1, 2, 3, 4, 5, 6]

Chunk

該函數返回原數組分解成長度爲 n 後的多個數組:

func chunk<T>(list: [T], length: Int) -> [[T]] {
   typealias Acc = (stack: [[T]], cur: [T], cnt: Int)
   let l = list.reduce((stack: [], cur: [], cnt: 0), combine: { (ac: Acc, o: T) -> Acc in
      if ac.cnt == length {
      return (stack: ac.stack + [ac.cur], cur: [o], cnt: 1)
      } else {
      return (stack: ac.stack, cur: ac.cur + [o], cnt: ac.cnt + 1)
      }
   })
   return l.stack + [l.cur]
}
print(chunk([1, 2, 3, 4, 5, 6, 7], length: 2))
// : [[1, 2], [3, 4], [5, 6], [7]]

函數中使用一個更爲複雜的 accumulator,包含了 stack、current list 以及 count 。

譯者注:有關 Reduce 底層實現,請看這篇文章

2015/12/01 改動:

  1. 修復 rFlatMap 類型簽名

  2. 爲代碼範例新增註解

  3. 修復了變量屬性爲 lazy 時執行效率不一致的問題


一、這麼作的緣由來看這篇博文
二、這篇文章的早期版本中,我錯誤地認爲 Swift 的懶惰特性是形成這種差別的罪魁禍首。感謝 Reddit 的這個討論指出了個人錯誤

本文由 SwiftGG 翻譯組翻譯,已經得到做者翻譯受權,最新文章請訪問 http://swift.gg

相關文章
相關標籤/搜索