iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 11
1

昨天雖然在介紹各個集合時,也順便提到了每個集合的專用函式。但其實在寫 elixir 時最常用到的,是兩個更為泛用的集合處理模組:EnumStream。而這兩個模組裡,包含了許多函數式編程共通的高階函式。例如最著名的三個高階函式,map/2filter/2reduce/3

高階函式?

在之前提過 lambda 、匿名函式時,有講到函式是一級公民,可以當做其它函式呼叫的參數,也可以當做函式的回傳值。所以我們就把接收函式為參數的函式回傳函式的函式,叫做高階函式。不過在一般的情況下,當你聽到高階函式時,大多數指的是接收函式為參數的函式這種。

2017 年的現在,連 Java 都有 lambda ,JavaScript 陣列也內建 mapreduce 了,你一定也用過高階函式,只是也許你沒有意識到而己。不過我們還是來解釋一下高階函式有什麼好處。

程式的抽象的形狀

例如我們有兩段程式如下 :
(以 JavaScript 為例,單純因為 iT 邦幫忙不幫 elixir 上色)
(Update: 我發現不要跟它說是 elixir 的話,iT 邦幫忙會把程式碼當做 ruby 來上色 XD)

/* JavaScript */
//1
console.log("Get some lobster")
putInPot("lobster")
putInPot("water")

//2
console.log("Get some chicken")
boomBoom('chicken')
boomBoom('coconut')

很多 programmer 會在這裡決定這段程式碼已經 ok,無法再改善了。因為它們呼叫了不同的函式,參數也大不相同。但有些人能發現這兩段程式,在另一個層次上有很像的地方:流程的形狀。這兩段都是先呼叫 console.log,而且跟第一個函式呼叫的參數有關,緊接著有兩個相同的函式呼叫。但呼叫的函式不同就是那些人停住的原因。而高階函式的概念,讓我們知道這段程式可以再進一步抽象成這樣:

/* JavaScript */
function cook(meat, soup, f) {
  console.log("Get some " + meat)
  f(meat)
  f(soup)
}

//1
cook("lobster", "water", putInPot)
//2
cook("chicken", "coconut", boomBoom)

再用上匿名函式,我們甚至可以不需要預先定義好的 putInPotboomBoom

/* JavaScript */
cook("lobster", "water",   x => console.log("pot " + x))
cook("chicken", "coconut", x => console.log("boom " + x))

類似 OO 的 design pattern,在函數式編程中,有許多泛用的集合操作方式 (很多都是從數學理論那邊直接拿過來用的)。最重要的三個,就是 map/2, filter/2reduce/3

reduce/3

reduce 在其它的函數式編程語言裡有時叫做 fold 或是 foldl。它會將一個集合及一個起始值,用傳入的函式折疊成最終的結果。該最終結果的型別會與起始值相同。我們再一次試試著實作串列加總,改用 Enum.reduce/3

Enum.reduce([1, 2, 3, 4, 5], 0, fn (i, total) ->
  total + i
end)
#=> 15

你傳進去的函式 (不一定要匿名) 需要處理兩個參數,第一個是集合中目前的元素 (在我們的例子裡是 i),第二個是上一步回傳的結果 (在例子中就是 total)。如果是第一個元素時,會把起始值,也就是 0 當做 total 傳進去。

如果你用的是 foldfoldl,幾乎都規定要傳起始值。但若叫 reduce 時,如果不傳起始值,就會拿集合的第一個元素當起始值,然後從第二個元素開始遍歷。

之所以從 reduce/3 開始介紹,是由於其它的高階函式,包括 map/2filter/2,大多都可以用 reduce/3 實作出來。幾乎可以說是高階函式的 prima materia。(哈哈終於讓我用到鍊金術哏了)

map/2

map 會將一個集合轉換成另一個長度相同的集合。新集合的元素的型別,就是傳入函式的回傳型別。

Enum.reduce([1, 2, 3, 4, 5], fn i ->
  "C#{i * 3}"
end)
#=>["C3", "C6", "C9", "C12", "C15"]

自己動手做一個

map2 = fn (collection, f) ->
  Enum.reduce(collection, [], fn (i, total) -> 
     total ++ [f.(i)]
  end)
end

filter/2

filter 要傳入的函式必須回傳布林值,若該元素應用到函式上回傳 true 時,這個元素才會被留下來,否則就會過濾掉。所以這個函式會回傳長度等於或小於原集合,且元素型別與原集合元素型別相同的新集合。

Enum.filter([1, 2, 3, 4, 5], fn i -> 
  i % 2 == 0
end)
#=> [1, 3, 5]

自己動手做一個

filter2 = fn (collection, f) ->
  Enum.reduce(collection, [], fn (i, total) ->
     if f.(i), do: total ++ [i], else: total
  end)
end

順帶一提,Enum.reject/2 是這個函式的相反版本。拒絕掉讓函式回傳 true 的元素。

如何記住這三個高階函式

https://ithelp.ithome.com.tw/upload/images/20171231/20103390a4Tcoddv8o.png

資料轉換的旅程

之前唸 Dave Thomas 的 Programming Elixir 時,有一句話給了我很大的啟發,他說:

Functional programming is like a journey of data transforming
函數式編程,就像是一趟資料轉換的旅程

我們來編造一個範例,假設我們有這樣的資料:

students = [
  %{name: "John Doe", age: 18},
  %{name: "Mary Su", age: 21},
  %{name: "Chris Smith", age: 16},
  %{name: "Bob Doe", age: 20},
]

我們想要拿到所有 18 歲以上的人,不重覆的姓。若在 OO 語言裡用 for 來處理,會長得像是這樣:

/* JavaScript */
let result = [] //宣告一個額外的結果陣列
for(let s of students) { //遍歷
  if (s.age >= 18) { //判別年齡
    let [first_name, last_name] = s.name.split() //切開姓名
    if(result.indexOf(last_name) == -1) { //判別重覆
      result.push(last_name) //把需要的值放進結果陣列裡
    }
  }
}
result

如果我們剛剛的條件要減少、增加或修改,整個結構會愈來愈複雜,以至於愈來愈難維護調整。也很難讓這段程式放到其它地方使用。

我們試著用資料轉換的旅程的想法來試試:

students
|> Enum.filter(&(&1.age >= 18)) # 判別年齡
|> Enum.map(fn %{name: name} -> String.split(name, " ") end) # 切開姓名
|> Enum.map(fn [_, last_name] -> last_name end) # 只拿姓
|> Enum.uniq # 去掉重覆

行數少了一半,讀起來反而更清楚。而且之後要新增條件,只要找到適當的地方 pipe 進新的函式就可以了。值得注意的是,我故意把切開姓名取姓分成兩步處理。如果你讀過歐萊禮的「深入淺出設計模式)」(順帶一提,這是本好書),第一章就告訴你用合成代替繼承,因為繼承三不五時會帶著一堆副作用來踩你的腳。

而在函數式編程裡,我們不太相信 reuse 這件事,而是專注在做出一堆很短,處理一件小事的簡單函式,再透過 function composition,依需求組合這些通用的小函式,把資料從原始的結構,一步步逐漸轉成我們想要的最終結果。這樣程式會更彈性,也更少錯誤。

Programming Elixir 裡有這麼一張圖,可以讓你更理解這個概念:
https://ithelp.ithome.com.tw/upload/images/20171231/20103390pvGBZ6YhZ8.png

更多好用的 Enum 函式

例如 Enum.all?/2Enum.any?/2chunk 系列、map 系列,還有 Enum.scan/3Enum.zip/3 等等。總之如果你想用特別的方式對集合進行操作,第一步一定是先來看 Enum 模組是不是有符合你需求的函式。

如果你之後去使用 Ramda 或是 lodash/fp ,還有 Rx.js ,甚至改學 Haskell,在過程中,你會不斷的發現這些函式庫及語言裡面的函式,概念上幾乎都是重疊的,有時只是名稱不同。你就會開始體會,這些全部都來自於他們背後的那套數學的形式系統。

然後也許你就會理解這句話:

學了 LISP 之後,有可能你這輩子再也不會寫 LISP。但是之後不管用什麼語言,你所寫出來的程式都跟 LISP 沒什麼兩樣。

重點回顧

  • 函數式編程,就像是一趟資料轉換的旅程
  • 高階函式可以讓你把程式碼再往上抽象一層,關注在流程這件事上
  • Enum 是泛用於各種集合的模組
  • mapfilterreduce,還有其它

明天接續這個主題,來聊聊 Stream 這個模組。
Happy hacking! 明天見。


上一篇
集合: List, Tuple, Keyword List 及 Map
下一篇
Immutability 及 Lazy evaluation
系列文
函數式編程: 從 Elixir & Phoenix 入門。31

1 則留言

1
taiansu
iT邦新手 5 級 ‧ 2017-12-30 23:34:46

女兒今晚哭哭兩小時,申請遲交相應的時間 XD。

辛苦了

taiansu iT邦新手 5 級‧ 2017-12-31 03:12:03 檢舉

/images/emoticon/emoticon41.gif

我要留言

立即登入留言