Scala Tutorial(4)實戰 Higher-Order Function by brianhsu | CodeData
top

Scala Tutorial(4)實戰 Higher-Order Function

分享:

Scala Tutorial(3)變數與函式 << 前情

話說上次我們看到了 Scala 是如何以物件導向的方式來實作出 first-class function,但並未提及要如何在 Scala 中活用 first-class function,以及 first-class function 在 Scala 中佔有何種角色,而這一篇我們就要探討這個問題。

雖然在 Scala 中 first-class function 可以被應用在許多地方,但最顯而易見的就是針對 Collection 的操作,因此我們將會先從 Java 如何操作 Collection 開始,並且借由對比,來看在 Scala 中如何使用 first-class function 來操作 Collection。

在 Java 中 Collection 的操作

話說 Java 標準函式庫中提供了相當多的 Collection 供我們使用,例如 ArrayList、HashMap 或 HashSet 等等,這些 Collection 依照不同的資料結構來儲存資料,雖然有不一樣的特性,但大多數的 Collection 都提供了最基本的功能--Iteration,也就是走訪 Collection 中每一個元素的方式,而多數的時候我們會使用迴圈進行這樣的操作。

舉例而言,今天如果我們有一個 ArrayList,而我們希望將每一個元素都印出來時,我們可以這麼做:

List<Integer> score = new ArrayList<Integer>();

// 將值放入 elements 中

for (int n: score) {
    System.out.println("score:" + n);
}

而如果我們在程式碼的其他地方,需要再針對 score 中的每一個元素進行處理的話,那麼只需要再寫一個迴圈就行了,並沒有什麼不自然的地方。

不過通常現實世界裡的程式並不會那麼簡單,我們經常會需要針對 Collection 裡的元素做一些處理,並且也需要記錄處理的結果,這個時候我們可能很直覺地會將這個處理結果放到另放一個 Collection 中。

例如我們需要針對上述的 score 列表裡的分數進行判斷,並且記錄每一個分數是否及格,這個時候我們可能會使用下列的方式:

List<Integer> score = new ArrayList<Integer>();
List<Boolean> isPassed = new ArrayList<Integer>();

for (int n: score) {
  isPassed.add(n >= 60)
}

如果仔細觀查的話,會發現我們在處理 Java 中的 Collection 時,經常都會用到迴圈,而且其組成和邏輯都是相同的:把 Collection 中的元素都走訪一遍。

但問題是,如果這些走訪邏輯這麼常被用到,而且長得都一模一樣,那為什麼我們每次都需要重覆撰寫這些迴圈,而不能把這個回圈的邏輯用函式包起來呢?

在 Scala 對 Collection 的操作

沒錯,在 Scala 中正是這樣做的。由於這些迴圈的邏輯太過常用了,所以 Scala 的 Collection 直接提供了將這些迴圈的邏輯封裝起來的函式。

舉例而言,當我們要針對 Collection 中的每個元素做某件事情時,我們只要使用 foreach 這個函式,並且告知 Scala 那個「某件事」是哪件事就行了,對於走訪每個元素的迴圈,則是由 foreach 函式自己提供。

val scores = List(93, 45, 95, 63, 58, 60, 73)

// 分別在每一行印出 93 45 95, 63, 58, 60, 73 等值
scores.foreach((x: Int) => println(x))

在這邊,我們傳給 foreach 這個函式的是一個 first-class function,這個 function 接受了一個 Int 類型的參數,而其函式的內容則是執行 println(x) 這個 statement。

由於 foreach 會將這個函式套用在 List 中的每一個元素中,所以這隻程式執行之後,會將 scores 的每一個元素都印出來。

若您仍覺得難以理解,不如把上述的程式想像成以下的 Java 程式碼,唯一不同的是 Function 這個 Interface 和 foreach 這個函式,Scala 都已經先幫我們定義好了。

private List<Integer> scores = new ArrayList<Integer>();

interface Function {
    public void apply(int n);
}

void foreach(Function t) {
    for (int n: scores) {
        t.apply(n);
    }
}

foreach(new Function() {
    public void apply(int n) {
        System.out.println(n);
    }
})

在這裡我們可以看到,foreach 只是走訪了 scores 裡的每一個元素,並且呼叫 Function 這個介面裡的 apply 方法而已,雖然說細節上略有不同,但這正是上述 Scala 程式碼中 scores.foreach 這個函式所做的事情。

回到 Scala 的程式碼上,我們曾經提到過 Scala 有型別推論的功能,所以有許多地方型別的標註都可以省略,這裡也不例外。

由於在呼叫 foreach 的時候,Scala 已經知道 scores 裡的元素全都是整數,所以我們的 (x: Int) => println(x) 這個函式中的 x 型別一定是整數,既然 Scala 編譯器知道了,我們就沒必要再重覆這一件事,所以上述的程式碼也可以把型別標註拿掉,將其簡化成:

// 分別在每一行印出 93 45 95, 63, 58, 60, 73 等值
scores.foreach((x) => println(x))

而在這個函式當中,其參數列表只有 x 一個,所以我們可以進一步把多餘的括號去掉,變成下面這樣:

// 分別在每一行印出 93 45 95, 63, 58, 60, 73 等值
scores.foreach(x => println(x))

但如果你還是覺得寫成這樣子太麻煩,Scala 也提供了佔位符號的表示方法--反正 foreach 要的是一個函式,而這個函式的輸入值一定是 scores 裡的其中一個元素,為什麼還要那麼麻煩地特地取名叫做 x 呢?直接用個符號代表它就好啦:

// 分別在每一行印出 93 45 95, 63, 58, 60, 73 等值
scores.foreach(println(_))

當然,至於要使用哪一種方式,則是單純看個人對於編程風格的偏好,上述三種表達方式事實上編譯出來的程式都是相同的。

filter 與 map

除了走訪 Collection 中每一個元素用的 foreach 之外,其他兩個相當常用的函式是 filter 與 map,filter 正如其名,將會依照所傳入的 first-class function 來過濾 Collection 內的元素。

這個 function 接受一個參數,其參數的型別與 Collection 內元素的型別相同,而當將 Collection 的元素傳入的函式後,若返回的是 true 的話該元素會被留下,否則的話會被捨棄。

舉例而言,若我們要過濾 score 這個 List 中的分數,只留下大於 60 分的分數,則傳給 filter 這個函式一個接受 Int 參數,並返回 Boolean 值的 first-class function。

val scores = List(93, 45, 95, 63, 58, 60, 73)

// 以下會印出三行 List(93, 95, 63, 60, 73)
println(scores.filter((x: Int) => x >= 60))  // 傳入完整的 first-class function
println(scores.filter(x => x >= 60))         // 省略參數型別
println(scores.filter(_ >= 60))              // 進一步省略參數名稱

// 會印出 List(93, 45, 95, 63, 58, 60, 73)
println(scores)

在這邊要注意的,是最後一行 println(scores) 印出的內容和我們在第一行所建立的 List 相同,這是因為在 Scala 當中,List 和 Java 中的 String 一樣,都是被建立之後就不可以改變狀態的 immutable 物件,而當我們使用 filter 這個函式時,其實建立的是新的 List 物件,不會改變原來的 List 物件,就如同我們呼叫 substring 時不會影響到原本的字串一樣。

同樣的,關於 filter 這個函式,我們也可以用 Java 物件導向的方式去理解:

interface Function {
    public boolean filter(int n);
}

private List<Integer> scores = new ArrayList<Integer>();

public List<Integer> filter(Function function) {
    List<Integer> newList = new ArrayList<Integer>();
    for (n: scores) {
        if (function.apply(n)) {
            newList.add(n);
        }
    }
}

在這邊我們依然可以看到,在 Java 的版本中我們是告訴 Java 「如何做」(走訪每個元素,執行檢查,符合資格的加入新的 List 中,不符合的不做事),但是在 Scala 的版本中我們比較偏向描述「意圖」,也就是「我們想要 x >= 60 的元素」,至於實際的執行方式則是交由 filter 函式處理。

至於 map 這個函式則和 foreach 類似,都是走訪 Collection 中的每個元素,並將傳入的 first-class function 套用到每個元素上,但不同的是 map 會將用函式的回傳值來建立新的 Collection。

舉例來說,若我們要將一個字串列表中的字串全部轉成大寫,在 Java 中我們會這麼做:

List<String> orignStrings = new ArrayList<String>();
List<String> upperCaseStrings = new ArrayList<String>();

for (String str: originStrings) {
    upperCaseStrings.add(str.toUpperCase());
}

但是因為我們已經知道建立新的 List 和迴圈的部份,Scala 已經幫我們代勞了,所以我們只需要告知 Scala 編譯器「我們要什麼」的核心邏輯就可以了,而這裡的核心邏輯則是「將元素轉成大寫」:

val originStrings = List("Hello World", "This is a test", "Element 3")

val upperCaseStrings = originStrings.map(_.toUpperCase)

// 也可以寫成下面這樣
// val upperCaseStrings = originStrings.map(str => str.toUpperCase)
// val upperCaseStrings = originStrings.map((str: String) => str.toUpperCase)

// 印出 List(HELLO WORLD, THIS IS A TEST, ELEMENT 3)
println(upperCaseStrings)

當然,傳入的函式的回傳值型別不一定要和原本的 Collection 中的元素相同,我們也可以透過 map 函式建立型別不同的 Collection,例如另一個 List,其中的元素是原本的 Collection 中的字串的長度:

val originStrings = List("Hello World", "This is a test", "Element 3")

val counter = originStrings.map(_.length)

// 也可以寫成下面這樣
// val counter = originStrings.map(str => str.length)
// val counter = originStrings.map((str: String) => str.length)

// 印出 List(11, 14, 9)
println(counter)

當他們合在一起

另外,由於在 Scala 中 filter 和 map 這兩個函式都是定義在 Collection 上,而其回傳的又是另一個 Collection,所以我們可以使用 Fluent Interface 的風格來組合這些函式。

例如若我們可以挑選 List 中長度大於等於 4 的單字,並且將其轉換成大寫,然後再將每一個元素印出來:

val words = List("a", "apple", "banana", "fruit", "car", "card", "dog", "bird")
words.filter(_.length >= 4).map(_.toUpperCase).foreach(println _)

不知道讀者有沒有發現,不知不覺之間我們的程式碼好像和我們問題的描述可以進行一對一的對應關係耶:

words.filter(_.length >= 4). // 挑選長度大於等於 4 的字串
      map(_.toUpperCase).    // 並且將其轉為大寫
      foreach(println _)     // 然後印出每一個元素

這正是 Higher-order function 應用在 Collection 上的好處,因為「如何走訪 Collection 中的元素」這件事已經從我們的程式碼中消失,所以程式碼除了變得更為簡潔外,意途的傳達也變得更明確了。

小結

在這一篇中我們看到了 Scala 如何將 Functional Programming 中常出現的 first-class function,做為定義在 List 類別上的方法的參數,讓使用者可以不用自行處理常用的迴圈邏輯,而使得 Collection 的操作更加的簡潔,而程式碼的意圖也更為清楚。

不過到目前為止,我們都仍然只大致上介紹到「函式」的層次,但既然 Scala 是一個融合了 Functional Programming 與物件導向的程式語言,再加上我們也提過 Scala 很多關於 Functional Programming 的概念是實作在物件導向上,所以我們自然也不能忽略了物件導向的部份。

在下個星期,我們將會進入到 Scala 中物件導向的部份,並且看看 Scala 到底提供了哪些便利的功能,讓我們在寫物件導向的程式碼時能夠更輕鬆。

後續 >> Scala Tutorial(5)Scala 物件導向基礎之一

分享:
按讚!加入 CodeData Facebook 粉絲群

留言

留言請先。還沒帳號註冊也可以使用FacebookGoogle+登錄留言

Aames Jiang09/02

將會依照所傳入的 first-class function 來過率 Collection 內的元素
=>過濾

Aames Jiang09/02

for (String str: originStrings) {
upperCaseStrings.add(originString.toUpperCase());
}
=>
upperCaseStrings.add(str.toUpperCase());

熱門論壇文章

熱門技術文章