Swift 教學:認識 Higher Order Functions 高階函數(Reduce 和 FlatMap)


歡迎來到這篇有趣的新程式教學文章!Swift 是種富含多種特性的語言,當中有一個不能錯過的良好特性,就是高階函數 (higher order functions)。根據 Wikipedia 上面的解釋所描述:

在數學和計算機科學中,高階函數是至少滿足下列其中一個條件的函數:

- 接受一個或多個函數作為輸入
- 輸出一個函數

所有其他函數皆為一階函數

從現實世界程式設計的實際觀點來看,高階函數是百利而無一害的工具。我們不必實作 Swift 中已有的功能,而且可以透過更少程式碼完成同樣的功能。高階函數通常會應用在集合型別(陣列 (arrays)、字典 (dictionaries)、集合 (sets))上,而如你在下文可見,它們目的就是以不同方式用於所包含的元素中。雖然它們被稱為「函數」,你還是可以透過將要使用的集合物件 (collection object),使用點語法 (dot syntax) 將它們作為方法使用。

編者備註:如果你是 Swift 的新手,你可以參考我們所推出的 Swift 書籍

如果真要說一個高階函數的缺點,比起其它更「Swifty」的程式碼來說,高階函數的寫法可能會讓人有些不習慣。不過,即使你不適應它們,還是可以選擇這個讓生活更輕鬆的工具,因為這都只是需要時間習慣及訓練而已。如果高階函數的技巧並不在你的程式技能樹規劃裡面,那麼請開始嘗試使用它們,它們將會成為你程式設計日常中的最好工具。

高階函數建基於閉包 (Closure) 上,但是你不一定要精通閉包才能瞭解下一步。然而,如果你需要回顧一下或是瞭解多點關於閉包的資訊,你可以在讀下去之前先看一下 Apple 的官方文件。高階函數之所以令人驚豔,是因為它們可以用非常短的方式來編寫,所以我們可以將平常所寫的五、六行程式碼,以一行的方式來完成。實現這種簡短的編寫方式,就是靠像 $0$1 的這種速記參數 (Shorthand arguments)(這種用法在剛剛官方文件的連結中也有提到)。我們在接下來的部分就會討論到這一點,所以現在不需要更進一步地討論。

在本次的教學中,我將會介紹 Swift 中最著名、也最常用的高階函數:

  • map
  • compactMap
  • flatMap
  • filter
  • reduce
  • forEach
  • contains
  • removeAll
  • sorted
  • split

讀完這篇文章之後,你將會學到所有高階函數的必要概念與技巧,並能夠實際地將它們應用到你的專案之中。所以,讓我們立即開始吧!

在 Swift 中使用高階函數之前的準備工作

與我們平常的教學文章不同,這次我們沒有起始專案作練習之用。為了要順利嘗試本次教學文章的內容,你可以使用建立一個或多個 Xcode 的 playgrounds 來作為本次的開發環境。

在這裡補充一個實用的小技巧,如果你發現 playground 看似被凍結了,不執行你所輸入的程式碼,那麼你可以嘗試:

  1. 顯示檢閱器 (Inspector) 視窗。
  2. File 檢閱器中的 Playground Settings 部分,選擇 macOS 為平台。

此外,在開始添加客製化程式碼之前,你可以使用下列程式碼取代任何新 playground 之中的預設程式碼:

import Foundation

因為在本次練習中我們並不需要任何 UI,你不需要導入 UIKitCocoa 框架(視乎你選擇創建 iOS 或 macOS playground)。如果你想知道更多關於 playground 的技巧,只要在網站快速搜尋一下,你就能找到許多有用的資訊。這篇教學文章不會討論關於 playground 的細節,所以我會假設你對 playground 已經有基本的認識,可以在上面執行程式碼。

Map

讓我們開始第一個、又是最有名的高階函數 map 吧!這個函數「會對所有集合型別中的元素執行操作,並回傳含有對原本元素操作結果的新集合」。這聽起來很棒,但實際上它是如何運作的呢?

在 Xcode 創建一個 playground,並加入下列程式碼:

    let numbers = [2, 5, 3, 9, 15, 12, 8, 17, 20, 11]

這個陣列包含了十個整數,讓我們嘗試創建一個新的陣列,包含上列數字的雙倍數值。如果我們不使用任何高階函數,而使用傳統的 for-in 迴圈來實現的話,就會寫成下列程式碼:

var doubled = [Int]()

for number in numbers {
    doubled.append(number * 2)
}

print(doubled)
// Prints [4, 10, 6, 18, 30, 24, 16, 34, 40, 22]

首先,我們初始化一個新的陣列,並指定其元素型別。接著,使用迴圈來將每個數字從原本的陣列中變為兩倍,並附加到新創建的陣列之中,最後再把陣列印出來以便我們來確認結果。

現在讓我們來首度嘗試使用 map,並看看如何使用更精簡而聰明的程式碼,來達到同樣的結果。在 playground 中輸入下列程式碼:

doubled = numbers.map

Xcode 會自動跳出 map 函數的建議,按下 Return 鍵應該就會獲得這個:

higher order functions in Swift explained

接著再按一次 Return,讓 Xcode 展開佔位符 (placeholder),並提供預設的實作。

higher-order-functions-in-swift-demo

首先來看看閉包,我們在當中把想要執行的實作加到原始數據中。它帶有一個參數,而該參數就代表原始集合中的單一元素。在這個範例中,參數就代表著 numbers 陣列中的每個數字。高階函數會在集合上進行迭代,所以參數就依次代表著集合中的所有項目。

接著,還有一個泛型的回傳型別 T,它必須被明確地指定。這是閉包將要回傳的數據型別,在這邊我們將會回傳 Int 數值。

所以,讓我們將參數名稱設定到佔位符之中,並修正閉包的回傳型別:

doubled = numbers.map({ (number) -> Int in

})

不要忘記 map 函數會回傳一個新的集合,在這個範例之中,它會回傳一個整數陣列,包含了我們所指派的 doubled 變數。

請注意,保存結果的陣列不需要像之前一樣先初始化,我們可以直接將它刪除:

var doubled = [Int]()

並在變數前使用 let 或是 var 關鍵字來保存 map 的結果:

let doubled = numbers.map({ (number) -> Int in

})

現在,在閉包的本體之中,我們將會加入需要應用的客製化邏輯,也就是把 number 參數變成雙倍:

let doubled = numbers.map({ (number) -> Int in
    return number * 2
})

再次將 doubled 陣列印出,我們再次獲得原始數字加倍的結果。太棒了!

上文使用 map 函數的方式是完全正確的,不過實際上我們可以使它變得更短!那要怎麼做?就是不寫出含有參數與回傳型別的完整閉包定義,並善用速記參數來完成:

let doubled = numbers.map { $0 * 2 }

$0 是一個速記參數,它是一種通用方式,用來參照原本集合中的任何元素。在這個範例中,它代表的是 numbers 陣列中的任何數字,就像是剛剛範例中 mapnumber 參數。

除此之外,你也可以注意到方法中的左和右括號都被省略。為了提高可讀性,map 關鍵字和大括號之間只有一個可選的空格。

再次將 doubled 陣列印出後,我們可以在除錯區看到這次的結果依然與先前兩次相同。但是這次有一個不同的地方,就是我們只使用了一行程式碼,就將原本數值加倍,並將它們置於新的 doubled 陣列之中。高階函數真的令人驚奇,對吧?

讓我們再來看看另一個範例。看看下列包含角度數值的陣列:

let degrees = [20, 45, 160, 360, 84, 215, 178, 185]

我們想要將它們轉換為弧度,同樣可以使用 map 函數以一行程式碼來完成:

let rads = degrees.map { Double($0) * Double.pi / 180.0 }

你可以看到處理速記參數 $0 的方式就如同一般參數一樣。雖然金錢符號看起來可能有點怪,但這並不會影響你,這就只是表達模式而已。

如果不使用 Map 函數,我們就需要這樣來做轉換:

var rads = [Double]()
for deg in degrees {
    rads.append(Double(deg) * Double.pi / 180.0)
}

這個方法也可以,但是使用 map 我們可以使用更少的程式碼完成目的,以提高撰寫程式碼的效率。

Map 與 Dictionaries

除了陣列以外,map 函數還可以應用到其他集合型別,字典以及集合也是集合型別,雖然透過字典來使用的方法會有些不同,這取決於我們想要只獲取鍵 (keys)、或值 (values)、或兩者都獲取。

讓我們宣告一個字典,並填入下列數值:

var info = [String: String]()
info["name"] = "andrew"
info["city"] = "berlin"
info["job"] = "developer"
info["hobby"] = "computer games"

將上述字典印出,我們可以獲得:

map-result-1

map 函數會回傳一個含有數值的陣列,所以要單獨獲得鍵或值其實非常容易:

let keys = info.map { $0.key }
let values = info.map { $0.value }

info 就是字典,因此要引用鍵和值,就分別需要使用 keyvalue 訪問器 (accessor)。添加以下兩個語句:

print(keys)
print(values)

這將會印出接下來的兩行。一如我們預期,這會有兩個含有不同數值的陣列:

map-result-2

但是,如果我們這樣做的話,會發生什麼事呢?

let result = info.map { $0 }
print(result)

結果會像這樣:

map-result-3

result 陣列中的每個元素都變成了一個 tuple,當中第一個數值為鍵,而第二個數值為鍵所對應的值。

像上文這樣的陣列,可以以下列程式碼來初始化一個字典:

let newInfo = Dictionary(uniqueKeysWithValues: result)

當然,如果我們不修改原始數據就進行上述操作,是沒有任何意義的,這樣子 newInfo 字典會跟 info 完全相同。因此,讓我們先對鍵和值兩者進行操作吧!我們想要實現的,是將鍵轉換為大寫的字串,並將每個值的首字母轉換成大寫。接著,我們將使用經過調整的鍵和值,創建一個新的字典。

如上文所示,我們會在最後創建新字典。但在這之前,讓我們先定義 map 函數,在該函數中我們將回傳具有修改後的鍵和值的客製化元組值 (tuple value):

let updatedKeysAndValues = info.map { ($0.key.uppercased(), $0.value.capitalized) }

在兩個大括號之間,那個有兩個數值在內的括號就是元組,兩個數值分別是鍵在前和值在後。我們使用字串類別的 uppercased() 方法,將鍵的值轉換為大寫,再使用 capitalized 屬性(同樣屬於字串類別),來將值的首字母轉換為大寫。

關於上面使用的速記參數,這裡有一個重點。針對 $0.key$0.value,我們可以改寫成:

let updatedKeysAndValues = info.map { ($0.uppercased(), $1.capitalized) }

$0 參數參照到鍵,而 $1 參數參照到值。兩種方式都是正確的,你可以選擇你覺得合適的方式。

現在,讓我們來創建一個新的字典:

let capitalizedInfo = Dictionary(uniqueKeysWithValues: updatedKeysAndValues)
print(capitalizedInfo)

結果如下:

map-result-4

我們也可以將上面的動作寫成一行程式碼,就像是這樣:

let capitalizedInfo = Dictionary(uniqueKeysWithValues: info.map { ($0.uppercased(), $1.capitalized) } )

MapValues – 一種 Map 的變形

看完上述的內容後,讓我們來看看另一種相當簡潔的方式,讓我們獲取保持原始的鍵和修改了的值的字典,那就是不同的高階函數,是 map 的變形,稱為 mapValues

let updatedInfo = info.mapValues { $0.capitalized }
print(updatedInfo)

結果如下:

["job": "Developer", "city": "Berline", "name": "Andrew", "hobby": "Computer Games"]

updatedInfo 就是你所看到的字典,因此我們不需要手動創建。如果你不想修改字典的鍵,就可以使用 mapValues 高階函數。如果想同時修改鍵和值,你只需要使用 map

Map 與客製化型別

map 函數(一般來說是高階函數)可以用於其元素都是客製化型別實例的集合型別之中。讓我們來看看一個例子,以下類別包含了儲存關於產品測試員資訊的屬性:

class Tester {
    var name: String
    var age: Int

    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }
}

讓我們創建一個測試員的陣列:

let testers = [Tester(name: "John", age: 23), Tester(name: "Lucy", age: 25), Tester(name: "Tom", age: 32), Tester(name: "Mike", age: 29), Tester(name: "Hellen", age: 19), Tester(name: "Jim", age: 35)]

假設我們現在為了統計,需要從陣列之中去單獨讀取每個測試員的年齡資訊。我們可以透過 map 函數搭配速記參數的方式,很簡單地就完成讀取 age 屬性:

let ages = testers.map { $0.age }
print(ages)

結果如下:

[23, 25, 32, 29, 19, 35]

在這個範例之中,$0 參數就代表了 Tester 物件,所以我們可以將它視為該類別,來獲取 age 屬性。

CompactMap

compactMap 函數與 map 非常相似,但有個很大的不同,就是它的結果陣列並不會包含任何 nil 值。為了更清楚地說明這一點,讓我們看看下列範例,以下的陣列包含了整數與一些 nil 值:

let numbersWithNil = [5, 15, nil, 3, 9, 12, nil, nil, 17, nil]

這個陣列的型別明顯是 [Int?]。現在假設我們想要透過 map 函數,將非空值的元素加倍,並產生出一個新的陣列,做法會像是下面這樣:

let doubledNums = numbersWithNil.map { $0 * 2 }

Xcode 會顯示出下列的錯誤訊息:

Understanding Higher Order Functions in Swift 5

看到這個錯誤並不意外,因為 $0 參數如果是整數,我們就可以將它加倍,但如果是空值,就沒有數值可以被加倍了。為了解決這個情況,我們必須解開 $0 參數,並在只有非空值的情況之下執行加倍,map 函數的完整寫法如下:

let doubledNums = numbersWithNil.map { (number) -> Int? in
    if let number = number {
        return number * 2
    } else {
        return nil
    }
}

可以看到閉包的回傳型別為 Int?,代表閉包會回傳一個加倍的整數值、或是在 number 參數為 nil 時回傳 nil。根據閉包的回傳型別,可以推論出 doubledNums 陣列的型別也會是 [Int?]

而上面做法的簡化版本如下:

let doubledNums = numbersWithNil.map { $0 != nil ? $0! * 2 : nil }

在這裡,我們使用了三元運算子 (ternary operator) 讓表達更加簡潔。如果 $0 不是空值,我們將會回傳加倍後的整數;而如果 $0 是空值,我們就同樣回傳空值。而 doubledNums 也像剛剛一樣,被推論為同樣的型別。

加入下列 print 指令:

print(doubledNums)

我們將會獲得結果:

Understanding Higher Order Functions in Swift 6

然而,像上面的結果,擁有可選型態及空值的陣列並不理想。這問題的解決方案就是使用 compactMap 函數,在 numbersWithNil 陣列中使用它,結果的陣列將會只包含非空值的數值,而該陣列的型態會是 [Int]

這次,讓我們使用 compactMap 來完成剛剛所做的事情:

let notNilDoubled = numbersWithNil.compactMap { $0 != nil ? $0! * 2 : nil }

這次的結果如下:

[10, 30, 6, 18, 24, 34]

我們可以看到,如果原始陣列中含有空值,結果陣列中元素的數量與原本陣列很明顯是不同的。所以,如果元素數量在你的程式碼中很重要的話,你可能還是得使用 map 函數,以在空值的情形下給予預設值,這樣一來就能保留原本陣列的元素數量。我們可以調整一下剛剛上面的做法,以下的 map 函數同樣會將非空值加倍後回傳,但是在空值的情況之下它會回傳 -1:

let doubledNums = numbersWithNil.map { $0 != nil ? $0! * 2 : -1 }

結果就會變成這樣:

[10, 30, -1, 6, 18, 24, -1, -1, 34, -1]

在這範例中,-1 回傳值是隨機選的。你在程式碼中做類似操作的時候,請務必特別小心,以防所謂的預設值對程式的整體功能帶來負面、或預計之外的影響。

FlatMap

集合型別裡面包含另一種集合型別,而我們想要將它們合併成單一集合型別的時候,flatMap 就大派用場了。看看下面的陣列中,它的元素是包含著其他陣列的。假設裡面的陣列每個都包含了三個不同課程的學生成績:

let marks = [[3, 4, 5], [2, 5, 3], [1, 2, 2], [5, 5, 4], [3, 5, 3]]

使用傳統的 for-in 迴圈,我們可以獲取單一陣列之中的所有數值:

var allMarks = [Int]()
for marksArray in marks {
    allMarks += marksArray
}

然而,如果我們使用 flatMap 高階函數,就可以避免這些手動工作:

let allMarks = marks.flatMap { (array) -> [Int] in
    return array
}

閉包的參數代表在原始集合型別中,應用了高階函數的內層集合型別 (inner collection)。你需要指明結果陣列的型別,在這個範例中,型別就是 [Int]。如果不需要對 array 參數值的元素執行任何操作的話,那麼只需要回傳該陣列即可。

我們也可以進一步透過速記參數來精簡上述的程式碼:

let allMarks = marks.flatMap { $0 }

在這樣的情況下,如果我們把所有結果印出,除錯區就出現以下結果:

print(allMarks)
// Prints [3, 4, 5, 2, 5, 3, 1, 2, 2, 5, 5, 4, 3, 5, 3]

我們將所有數值放在同一個陣列之中,就可以進一步的執行所需要的操作或運算。

如果內部集合型別包含空值,那麼我們使用 flatMap 函數時,空值也會被帶入到結果陣列之中。你可以看看以下的範例:

let valuesWithNil = [[2, nil, 5], [4, 3, nil], [nil, nil, 1]]

let result = valuesWithNil.flatMap { $0 }

print(result)
// Prints [Optional(2), nil, Optional(5), Optional(4), Optional(3), nil, nil, nil, Optional(1)]

Filter

filter 也是其中一個很有用的高階函數。顧名思義,這個函數的目的,是根據某些條件,來過濾集合型別裡的元素,並生成一個僅包含符合條件元素的新集合型別

為了展示 filter 的使用方式,讓我們再次使用在 map 函數範例中看到的 numbers 陣列:

let numbers = [2, 5, 3, 9, 15, 12, 8, 17, 20, 11]

假設我們想要過濾上述陣列,只保留當中大於 10 的元素。如果不使用 filter 函數,我們可以這樣做:

var over10 = [Int]()

for number in numbers {
    if number > 10 {
        over10.append(number)
    }
}

接下來,看看我們如何透過 filter 函數來達到同樣的目的:

let over10 = numbers.filter { (number) -> Bool in
    if number > 10 {
        return true
    } else {
        return false
    }
}

我們能夠寫得再簡短一點:

let over10 = numbers.filter { (number) -> Bool in
    return number > 10
}

你可以注意到,我們不需要明確地指出函數的回傳型別,它總會是 Bool,而我們撰寫在閉包中的程式碼,必須是一個結果是 true/false 的條件

我們又可以用速記參數,將上面的程式碼變得更簡短:

let over10 = numbers.filter { $0 > 10 }

不論上面的哪一種形式,最後都會回傳同樣的結果:

[15, 12, 17, 20, 11]

filter 之中的條件,可以很簡單,也可以很複雜。而且,就如 map 函數一樣,客製化的物件可以被包含在原始的集合型別之中,並且使用點語法來存取它們的屬性。還記得在 map 函數部分那一個小型的 Tester 類別,和我們初始化了的測試員嗎:

class Tester {
    var name: String
    var age: Int

    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }
}

let testers = [Tester(name: "John", age: 23), Tester(name: "Lucy", age: 25), Tester(name: "Tom", age: 32), Tester(name: "Mike", age: 29), Tester(name: "Hellen", age: 19), Tester(name: "Jim", age: 35)]

我們只需要利用一個 filter 函數,就可以找到名字以 “J” 開頭、而年齡大於三十歲的測試員:

let results = testers.filter { $0.name.prefix(1) == "J" && $0.age >= 30 }

在這個範例中,我們知道有一個符合條件的測試員,所以可以放心地將結果印出:

print(results[0].name, results[0].age)

結果如下:

Jim 35

然而,在實際應用時,我們應該先這樣確認一下結果是否存在:

if results.count > 0 {
    // Do something...
}

通常在過濾出所需要的結果之後,我們必須取得集合型別中的第一個項目。舉例來說,要取得第一個名字以 “J” 開頭的測試員,比較常見但不太精簡的方式是:

let filtered = testers.filter { $0.name.prefix(1) == "J" }

if filtered.count > 0 {
    let firstTester = filtered[0]
    // Do something with firstTester...
}

如果不使用索引值的方法,另一個方法是可以使用 first 屬性來獲得 filtered 陣列中的第一個項目。但這裡要注意一點,因為回傳的值有可能是空值,所以必須要做拆解的動作。

let filtered = testers.filter { $0.name.prefix(1) == "J" }

if filtered.count > 0 {
    if let firstTester = filtered.first {
        // Do something with firstTester...
    }
}

雖然上面的程式碼是正確的,我們可以使用 guard 來簡化程式碼:

guard let firstTester = (testers.filter { $0.name.prefix(1) == "J" }).first else { return }
// Do something with firstTester...

我們也可以使用類似的方式,來計數集合型別中被過濾後的項目。在範例的 numbers 陣列中,我們可以計算有多少個大於十的數值:

let totalOver10 = numbers.filter { $0 > 10 }.count

print("Total items greater than 10: ", totalOver10)

結果如下:

Total items greater than 10: 5

關於 filter 函數,還有一個重點:它並沒有對集合型別中的元素做任何修改(像是 map 就有)。我們使用 filter 只是為了獲取原始數據中,滿足特定條件的子集合。假如你想要對過濾後的數據做進一步的操作,你可以使用 filter 函數所產生出結果,作為其他高階函數所使用的輸入數據。

Reduce

reduce 高階函數的目的,是為了從原本集合型別中的所有元素來產生一個值。為了解這個函數的用法,假設我們想要計算下列陣列中所有元素的乘積 (product):

let numbers = [5, 3, 8, 4, 2]

若不使用 reduce 函數,而是用 for-in 迴圈來實作的話,將每個數值乘起來所獲得的結果應該像是這樣:

var product = 1
for number in numbers {
    product *= number
}

然而,不同於上面的做法,使用 reduce 函數也能夠獲得同樣的結果。正如同你等一下看到的程式碼中,它會接收兩個參數,第一個是初始值(就像是剛剛的 product 變數),而第二個是閉包中所需要實作在集合型別裡元素的邏輯。

let product = numbers.reduce(1) { (partialResult, number) -> Int in
    return partialResult * number
}

這個閉包的參數也是非常有趣的,因為第一個參數並不像第二個參數一樣,代表著集合型別中的元素,第一個參數是代表先前用來與 number 數值相乘的結果。讓我再清楚一點來說明:

// 1st iteration: partialResult = 1, number = 5, Returns 5
// 2nd iteration: partialResult = 5, number = 3, Returns 15
// 3rd iteration: partialResult = 15, number = 8, Returns 120
// 4th iteration: partialResult = 120, number = 4, Returns 480
// 5th iteration: partialResult = 480, number = 2, Returns 960

reduce 也可以使用速記參數來簡化:

let product = numbers.reduce(1, { $0 * $1 })

別忘記第一個參數都是代表上一個運算結果,而不是集合型別中的元素。在這裡,$0 就代表上一個運算結果,而 $1 就代表著陣列中的元素。

像我們範例這麼簡單的情況中,reduce 還可以用再進一步簡化,甚至可以連速記參數的部分都省略掉。我們唯一所需要的,就是要執行的操作符號:

let product = numbers.reduce(1, *)

使用這語法時要小心,請確認你的初始值是正確的。

reduce 也可以搭配字典使用。看看下列字典,包含了五個朋友、以及他們各自擁有的零用錢:

let friendsAndMoney = ["Alex": 150.00, "Tim": 62.50, "Alice": 79.80, "Jane": 102.00, "Bob": 94.20]

他們想知道所擁有的金錢總額是否足夠,讓他們一起購買非常想要的新遊戲機。使用 reduce 函數,只要一行程式碼就能夠完成:

let allMoney = friendsAndMoney.reduce(0, { $0 + $1.value })

你可以看到上面這行程式碼有趣的地方:為了在閉包中獲取所有的金錢數值,我們必須使用 value 屬性,因為 $1 並不是一個單一數值,它其實是一個 (String, Double) 鍵值對 (key-value pair)(元組)。而另一方面,$0 就是用來代表先前運算結果的單一數值。

ForEach

對於開發者來說,大家都非常瞭解如何撰寫 for-in 迴圈來遍歷集合型別之中的所有元素。從以下的例子中,你可以看到我們如何遍歷 numbers 陣列中的所有元素,並且印出每個元素是奇數或是偶數。

let numbers = [2, 5, 3, 9, 15, 12, 8, 17, 20, 11]

for number in numbers {
    number.isMultiple(of: 2) ? print("\(number) is even") : print("\(number) is odd")
}

結果如下:

2 is even
5 is odd
3 is odd
9 is odd
15 is odd
12 is even
8 is even
17 is odd
20 is even
11 is odd

上述的迴圈可以使用 forEach 函數來取代。就如其他高階函數一樣,forEach也提供了閉包來實作所需要的自定義邏輯。讓我們使用 forEach 函數來再次實現上面所做的事情吧:

numbers.forEach { (number) in
    number.isMultiple(of: 2) ? print("\(number) is even") : print("\(number) is odd")
}

我們也可以搭配速記參數,只使用一行程式碼來完成:

numbers.forEach { $0.isMultiple(of: 2) ? print("\($0) is even") : print("\($0) is odd") }

我們經常會遇到的情況是,我們需要在迴圈內部有條件地解開可選型態來獲取數值,因此需要分別使用 breakcontinue 語句,來中斷或是繼續迴圈的執行。看看以下的範例:

let numbersWithNil = [5, 15, nil, 3, 9, 12, nil, nil, 17, nil]

for number in numbersWithNil {
    guard let number = number else {
        print("Found nil")
        continue
    }
    print("The double of \(number) is \(number * 2)")
}

在上述程式碼之中,continue 語句是用來辨別程式是否要進行到下一個迭代 (iteration),以防在 guard 拆解 number 內容時遇到失敗的情形(在 else 情況下執行所有操作後)。我們不可能在 forEach 函數之中做到一樣的事情,因為 continuebreak 語句並不能在這裡使用。反之,return 是唯一能夠使用的語法。使用它的時候,程式會離開當前的閉包,不過如果集合之中有更多的元素,通常會隨即使用 forEach 函數。讓我們直接看看程式碼的實作:

numbersWithNil.forEach { (number) in
    guard let number = number else {
        print("Found nil")
        return
    }
    print("The double of \(number) is \(number * 2)")
}

簡而言之,請記住 continuebreak 語法並不能在 forEach 函數之中使用;它們只能夠在迴圈當中使用,而 forEach 函數並不是迴圈。

Contains

contains 函數是用於集合型別中,以確認當中有沒有能滿足特定條件的元素,而它會回傳一個布林值 (boolean value)。假設我們現在想找找 numbers 陣列之中,是否有包含小於 5 的元素。如果我們利用迴圈來實作,方法應該是這樣:

var containsLessThan5 = false
for number in numbers {
    if number < 5 {
        containsLessThan5 = true
        break
    }
}

不過,像這樣不斷重複不同的條件、在不同的集合型別上的方式,其實非常沒有效率,也不是一種好的做法。

解決方法就是使用 contains 高階函數!讓我們使用 contains 再次檢查 numbers 陣列之中是否有小於 5 的元素:

let hasNumbersLessThan5 = numbers.contains { (number) -> Bool in
    return number < 5
}

print("Has small numbers? :", hasNumbersLessThan5)
// Prints true

這個函數不會回傳一個新的集合型別,而是回傳一個布林值,如果結果為 true 就代表條件有被滿足;反之如果結果為 false,就代表條件沒有被滿足。如果條件像上面範例中那樣簡單,我們同樣可以使用速記參數來簡化為一行程式碼:

let hasNumbersLessThan5 = numbers.contains { $0 < 5 }

就如同其他高階函數一樣,我們同樣可以在這個函數中使用客製化型別的物件,並且將條件建基於客製化屬性上。看看下面的類別,它包含了一些公司員工的資訊,以及在陣列中的一些實例:

class Staff {
    enum Gender {
        case male, female
    }

    var name: String
    var gender: Gender
    var age: Int

    init(name: String, gender: Gender, age: Int) {
        self.name = name
        self.gender = gender
        self.age = age
    }
}

let staff = [Staff(name: "Nick", gender: .male, age: 37), Staff(name: "Julia", gender: .female, age: 29), Staff(name: "Tom", gender: .male, age: 41), Staff(name: "Tony", gender: .male, age: 45), Staff(name: "Emily", gender: .female, age: 42), Staff(name: "Irene", gender: .female, age: 30)]

我們使用 contains(where:),就能像下面這樣簡單地進行搜尋:

let hasStaffOver40 = staff.contains { $0.age > 40 }
print("hasStaffOver40", hasStaffOver40)
// Prints true

let hasMalesOver40 = staff.contains { $0.gender == .male && $0.age > 40 }
print("hasMalesOver40", hasMalesOver40)
// Prints true

let hasMalesUnder30 = staff.contains { $0.gender == .male && $0.age < 30 }
print("hasMalesUnder30", hasMalesUnder30)
// Prints false

contains 同樣也可以在字典當中使用,以搜尋是否有符合特定條件的元素。看看下面的字典,包含了幾個城市的攝氏溫度:

let temperatures = ["London": 7, "Athens": 14, "New York": 15, "Cairo": 19, "Sydney": 28]

假設高於 25 度就屬於高溫,如果我們想要知道有沒有城市屬於高溫,可以這樣得到答案:

let hasHighTemperatures = temperatures.contains { $0.value > 25 }

print(hasHighTemperatures)
// Prints true

RemoveAll

removeAllcontains 函數相似,它可以幫你從原本的集合型別中根據條件刪除元素。在這種情況下,不論是利用 remove(at:) 方法來獲取元素在集合中的索引、或是任何其他的 remove 方法都沒有用。

我們再次使用 numbers 陣列作為範例,不過這次我們使用 var 關鍵字來宣告它,以便後續可以對它做修改:

var numbers = [2, 5, 3, 9, 15, 12, 8, 17, 20, 11]

在這邊,我們的目的是移除所有小於 10 的元素:

numbers.removeAll { (number) -> Bool in
    return number < 10
}

如你所見,沒有任何東西被回傳,不過原本的陣列已經被修改:

print(numbers)

結果如下:

[15, 12, 17, 20, 11]

上面的方式同樣可以修改為一行:

numbers.removeAll { $0 < 10 }

請注意,removeAll 函數只能在陣列中被使用,在字典或是集合中都無法使用。

Sorted

在 Swift 中使用 sorted() 方法,我們可以簡單地將集合中的元素以升序方式排序。看看以下的範例:

let toSort = [5, 3, 8, 2, 10]

let sorted = toSort.sorted()

print(sorted)
// Prints [2, 3, 5, 8, 10]

然而,當我們必須要指定排序順序(升序或是降序),或是排序必須基於某個特定條件時,sorted 高階函數可以大派用場了。它可以以三種不同的方式來撰寫,最冗長的形式是這樣:

let sorted = toSort.sorted { (num1, num2) -> Bool in
    return num1 > num2
}

這兩個參數代表了陣列在排序過程中相互比較的任意兩個元素,閉包會回傳一個布林值,代表定義的條件是否成立。最後,出現了一個新集合型別,當中包含原本集合型別中的元素,但是以特定排序條件所產生的排序結果。剛剛的程式碼會把 toSort 陣列以降序的方式來排序。

我們可以一行程式碼來呈現:

let sorted = toSort.sorted { $0 > $1 }

我們會獲得同樣以降序方式來排序的結果陣列,$0$1 參數代表了兩個在排序過程中互相比較的數字。

當然,還有更精簡的方式可以來完成排序:

let sorted = toSort.sorted(by: >)

簡單地寫出大於小於符號已經足夠表示排序順序,請注意,在這個情況中,by 參數名稱不能被省略。

sorted() 方法在字典中也可以使用,然而排序的結果並不是字典的型態,而是元組的陣列,其中元組的第一個元素代表著字典的鍵,而第二個元素代表字典的值。

還記得前文包含溫度資訊的字典嗎?以下我們將使用城市及溫度的資訊,把它們從高溫到低溫排序:

let sortedTemperatures = temperatures.sorted { $0.value > $1.value }

以下是將上述結果印在除錯區的樣子:

Understanding Higher Order Functions in Swift 7

只要取得陣列有效索引值中特定的元組,就可以獲取任何相應的鍵或是值。舉例來說,我們可以這樣得到第三個城市 (New York) 的溫度資訊:

print(sortedTemperatures[2].value)
// Prints 15

我們可以使用 sortedTemperatures 陣列來初始化一個實際的字典。不過鍵和值會再次變成隨機、沒有排序的情況:

let sortedTemperaturesDict = Dictionary(uniqueKeysWithValues: sortedTemperatures)

print(sortedTemperaturesDict)
// Prints ["Cairo": 19, "London": 7, "New York": 15, "Athens": 14, "Sydney": 28]
// By running again it prints ["Cairo": 19, "Athens": 14, "London": 7, "Sydney": 28, "New York": 15]

我們也可以用鍵的值來排序字典,以下我們要求城市按字母順序來排序的情況:

let sortedCities = temperatures.sorted { $0.key < $1.key }

結果如下:

Understanding Higher Order Functions in Swift 8

上面的狀況是合乎情理的,因為鍵本身是字串型別,而字串型別遵從 Comparable 協定。

Split

split 函數通常是配合字串來使用的,目的是將字串依照特定條件拆解為片段,最後的結果會是一個由子字串所組成的陣列。它有點像是 components(separatedBy:),都是透過特定分隔字元來將字串拆解。請注意,在 split 函數中,我們只可以使用字元來作為分隔符號。你可能會問:為甚麼這個函數是用在字串,而不是其他的集合型別上,事實上,split 是用於字串中的字元序列,遵循 Collection 協定。

首先,來看一下 split 是如何宣告的:

func split(maxSplits: Int = Int.max, omittingEmptySubsequences: Bool = true, whereSeparator isSeparator: (Character) throws -> Bool) rethrows -> [Substring]

如你所見,它需要三個參數,前面兩個是可選的,適用在有初始值的情況,我們等一下會討論到這一點。而最後一個參數是閉包,用來明確指定原始字串要如何拆解的條件。用一個簡單的範例來說明,讓我們把以下的字串以空格字元來拆解:

let message = "Hello World!"

let result = message.split { (char) -> Bool in
    return char == " "
}

print(result)
// Prints ["Hello", "World!"]

像是其他高階函數一樣,我們可以使用速記參數將 split 改寫成一行程式碼:

let result = message.split { $0 == " " }

現在,假如我們在呼叫 split 時加入了 maxSplits 參數,我們就可以指定字串要按分隔字符拆問多少次;換句話說,會有多少個子字串會從原本字串中創建出來,下列的範例可以更清楚地說明:

let anotherMessage = "This message is going to be broken in pieces!"

let splitOnce = anotherMessage.split(maxSplits: 1, whereSeparator: { $0 == " " })

print(splitOnce)
// Prints ["This", "message is going to be broken in pieces!"]

你會發現儘管字串中包含了超過一個空格,但最後還是只拆解了第一個空格的位置,因為我們透過 maxSplits 參數限定了最多只能有一次的拆解動作。

關於 omittingEmptySubsequences 參數,如果將它設為 false,則所有被拆解條件所拆解出來的空子字串,都會出現在結果陣列之中。讓我們修改一下剛剛的字串:

let anotherMessage = "This message is going to    be broken in pieces!"

現在將 omittingEmptySubsequences 參數的值設為 false,讓我們再次拆解它:

let emptySequences = anotherMessage.split(omittingEmptySubsequences: false, whereSeparator: { $0 == " " })

print(emptySequences)
// Prints ["This", "message", "is", "going", "to", "", "", "", "be", "broken", "in", "pieces!"]

最後,還有兩件事要注意的。首先,split 所產生出在陣列中的元素型別是 String.SubSequence,而不是 String。如果你想要把它們當作字串來使用,你必須先使用它們來創建字串物件。下列範例創建了一個新的陣列,而其中所有子字串都透過 map 函數轉型成為字串:

let message = "Hello World!"
let result = message.split { $0 == " " }
print(result)
// Prints ["Hello", "World!"]

// result type is [String.SubSequence]

// Create String values from substrings.
let allStrings = result.map { String($0) }

print(allStrings)
// Prints ["Hello", "World!"]

// allStrings type is [String]

第二個注意事項是,你為拆解字串所指定的條件,可以設定得比我們的範例更複雜。下面的範例中,我們以句號及驚嘆號將一段文章拆解為句子。你可以看看 split 函數中的條件:

let paragraph = "This paragraph will be separated in parts. Based on the periods and the exclamation mark! There will be three strings!"

let sentences = paragraph.split { $0 == "." || $0 == "!" }

print(sentences)
// Prints ["This paragraph will be separated in parts", " Based on the periods and the exclamation mark", " There will be three strings"]

總結

在這篇文章中,我們透過簡單的範例展示了如何在 Swift 中使用常見的高階函數。我相信大家都注意到,使用高階函數可以減少我們所需要的程式碼,同時,程式碼本身也變得更加清晰及有效率。如果你還沒有在程式碼中使用過高階函數,現在應該考慮開始使用了,因為它們確實帶來很大的幫助。無論如何,我們已經來到本次教學文章的尾聲,希望你今天學到了一些新知識,並為你的程式設計日常帶來莫大的效益!

譯者簡介:HengJay,iOS 初學者,閒暇之餘習慣透過線上 MOOC 資源學習新的技術,喜歡 Swift 平易近人的語法也喜歡狗狗,目前參與生醫領域相關應用的 App 開發,希望分享文章的同時也能持續精進自己的基礎。

LinkedIn: https://www.linkedin.com/in/hengjiewang/
Facebook: https://www.facebook.com/hengjie.wang

原文Understanding Higher Order Functions in Swift


資深軟體開發員,從事相關工作超過二十年,專門在不同的平台和各種程式語言去解決軟體開發問題。自2010年中,Gabriel專注在iOS程式的開發,利用教程與世界上每個角落的人分享知識。可以在Google+或推特關注 Gabriel。

blog comments powered by Disqus
Shares
Share This