跟著 Ramda 學 FP - 條件判斷(1)
ramda ifElse when unless cond -如何使用 Ramda 進行條件判斷。ifElse, when, unless 和 cond 的用法。
Why
Ramda 是一個比 lodash 更 functional programming (函式程式設計) 風格的函式庫。
如果還不熟習 FP ,借著使用 Ramda 可以很好的練習如何寫一個 FP 風格的程式。
What
- functional programming
 - 
函式語言程式設計,函式程式設計、泛函編程,是一種將程式視為函式運算,並避免使用全域變數或可變物件的程式寫作風格。
 - pure function
 - 
純函式是指對於相同的參數,有相同返回值且不會造成應用程序的副作用的函式。 沒有副作用也就是說程式的局部靜態變數、全域變數(非局部)、可變引用參數或輸入/輸出流不會因為純函式的使用而有變化。
 
How
ifElse
一般程式語言一定會提供條件判斷的語法,在 js 中,就是 if … else。
大概像是,
let n = 3
if ( n === 3) {
  // 如果判斷結果是 true
  console.log(n, 'equals 3')
} else {
  // 如果判斷結果不是 true 就執行這邊
  console.log(n, 'not equal to 3')
}
對應的 Ramda code 大概像是,
const eqThree = (n) => n === 3
const right = (n) => {
  console.log(n, 'equals 3')
}
const wrong = (n) => {
  console.log(n, 'not equal to 3')
}
ifElse(eqThree, right, wrong)(n)
可以看得出來,兩者最大的不同,就是 FP 風格的程式碼,全是透過函式呼叫來達成的。
FP 風格的寫作對函式的簽名是非常重視。
ifElse 的函式簽名如下,
ifElse :: (*… -> Boolean) -> (*… -> *) -> (*… -> *) -> (*… -> *)
上面的函式簽名是一種叫 Hindley-Milner type system 的簽名方式。常用於 FP 風格的語言。
因為 FP 的對 function 要求必須是一個 pure function。
純函數基本的要求便是要求相同的輸入要有相同的輸出,所以函式簽名特別重要。
這一點對 js 來說是剛好相反的,因為 js 沒有函式簽名這個特性。
這也是他方便的地方,同時也是容易產生問題的地方。
舉例來說,
// length :: String -> Number
const length = s => s.length
在 :: 之前是函式名稱。
函式的輸入型別是表示在 -> 前面。
函式的返回型別是 -> 之後或在最後面。
而對一個只有輸入和一個輸出的函式, -> 前面就是輸入參數的類型,後面就是返回值的型別。
上面的簽名就可以翻譯為 length 這個函式在傳入一個字串 (String) 後會返回一個數字 (Number)。
因為 js 不允許返回多值,所以,大部分的情況下,可以將最後一個型別當作返回型別。
接下來看一下,多參數的例子。
// join :: (String, [String]) -> String // (1)
const join = (sep, arr) => arr.join(sep)
// or
// join :: String -> [String] -> String // (2)
const join = curry((sep, arr) => arr.join(sep))
- 
一般情況
 - 
在有
curry特性時。 
上面的簽名可以翻譯為 join 接受兩個輸入參數,分別為字串和字串陣列,最後返回一個字串。
但考慮到 curry 時,第二個簽名更加合適,他可以翻譯為 join 接受一個字串參數並返回一個接受字串陣列函式,最後返回一個字串。
// 直接傳入兩個參數
join(',', ['a','b','c'])
// 先傳入一個參數,在對返回的函式傳入另一個參數
join(',')(['a','b','c'])
// 先傳入一個參數製造一個新函式,再使用新函式
joinComma = join(',')
joinComma(['a','b','c'])
以上三個方式結果都是一樣的。
如果函式對輸入參數的型別沒有特定,則可以用不定型別的 a 表示。
例如,
// length :: [a] -> Number
const length = arr => arr.length
可翻譯為 length 接受一個包含不定型別元素的陣列,返回一個數字。
如果函式對輸入參數的型別沒有特定要求,則可以用不定型別的 a 表示。
如果是相同的輸入型別得到相同的輸出型別,表示為 a → a。
但如果是輸入型別不一定得到相同的輸出型別,則表示為 a → b。
不定型別還是一種型別,他可以代表 String 也可以代表 Number 或代表其他的型別。
現在有個問題, * 是代表甚麼?
合理的推斷它代表任意型別,或未知型別。這也就是因為 js 採用動態型別,允許一個變數可以是 String 也可以是其他型別,所留下來的問題。
看下面的函式簽名,
// length :: String -> Number
const length = s => s.length
// length :: [a] -> Number
const length = arr => arr.length
length 這個函式可以接受一個 String 變數,也可以接受一個 Array 作為變數。
length 被限制接受的型別,並不是只能是字串,也不是只能接受陣列。
所以,可以表達為 length :: * -> Number。但這樣的表達實在是很模糊。
或許可以這樣表達 length :: String | [a] -> Number。
這邊還有個問題, js 並不限定在陣列中放置的元素必須是相同的型別。
所以,他可以是 [String, Number, Bool, null] 這樣的陣列。
這樣來說,更好的表達應該是 length :: String | [*] -> Number。
但一般情況下使用 a 還是使用 * 其實並不太影響理解函式簽名。
最後,再看一個任意數量參數的例子。
// apply :: *… -> String
const concatComma = unapply(join(','))
concatComma 接受任意個數的參數,由左到右用 , 將所有參數連接在一起,最後再傳回連接的字串。
現在應該足夠解釋 ifElse 的函式簽名。
ifElse :: (*… -> Boolean) -> (*… -> *) -> (*… -> *) -> (*… -> *)
初步看來 ifElse 接受三個函式當作參數,傳回一個函式。
第一個函式參數的函式簽名是 (*… -> Boolean) ,接受任意個任意參數,回傳 Boolean 值。
第二個函式參數的函式簽名是 (*… -> *) ,接受任意個任意參數,回傳一個任意型別的值。
第三個函式參數的函式簽名是 (*… -> *) ,同第二個。
傳回函式的函式簽名是 (*… -> *),接受任意個任意參數,回傳一個任意型別的值。
因為輸出的是函式,所以在使用上,下面兩個寫法是等價的。
// method 1
ifElse(fn1, fn2, fn3)(data)
// method 2
const fn = ifElse(fn1, fn2, fn3)
fn(data)
從函式簽名中可以看出 data 會被當成 fn1, fn2, fn3 的傳入參數。
這裡的 data 可以是一個參數也可以是多個參數。
回到最前面的例子,
ifElse(eqThree, right, wrong)(n)
n 被傳給 eqThree 並得到一個 Boolean 值回傳。
ifElse 會依照 eqThree 回傳的結果決定將 data 當作參數來執行 right 函式或 wrong 函式。
若 ifElse(eqThree, right, wrong) 當作一個函式,他的簽名應該是 (*… -> *)。
when
有了前面的基礎,我們看下一個函式 when 的簽名,
when :: (a -> Boolean) -> (a -> b) -> a -> a | b
使用範例,
const eqThree = (n) => n === 3
const right = (n) => {
  return n*n
}
when(eqThree, right)(n)
when 接受三個參數,傳回一個值。
第一個參數是函式其簽名是 (a -> Boolean) ,接受一個任意參數,回傳 Boolean 值。
第二個參數也是函式其簽名是 (a -> b) ,接受一個任意參數,回傳一個任意型別的值。
第三個參數是一個任意參數 a 。
最後傳回的是 a | b,一個與輸入相同或不同型別的值。
雖然在使用上很像 ifElse,但簽名卻太不一樣。
但這只是錯覺,改寫一下簽名,如下
when :: (a -> Boolean) -> (a -> b) -> (a -> a | b)
這樣對簽名的解釋就變成,
when 接受二個函式參數,傳回函式。
第一個參數是函式其簽名是 (a -> Boolean) ,接受一個任意參數,回傳 Boolean 值。
第二個參數也是函式其簽名是 (a -> b) ,接受一個任意參數,回傳一個任意型別的值。
傳回函式的簽名是 (a -> a | b)。
簡化一下 ifElse 的簽名,假設他的函式參數只接受一個參數。這樣就能將 *… 換成 a。
ifElse :: (*… -> Boolean) -> (*… -> *) -> (*… -> *) -> (*… -> *) // => `+*…+` 換成 `a` ifElse :: (a -> Boolean) -> (a -> *) -> (a -> *) -> (a -> *) // => 較嚴格的規定函式的回傳 ifElse :: (a -> Boolean) -> (a -> b) -> (a -> c) -> (a -> b | c)
現在比較一下,兩個新的簽名,
when :: (a -> Boolean) -> (a -> b) -> (a -> a | b) ifElse :: (a -> Boolean) -> (a -> b) -> (a -> c) -> (a -> b | c)
這樣看起來兩個函式是不是這長的很像,藉由
(a -> a | b) => (a -> *) 和
(a -> b | c) => (a -> *) 最後的回傳函式是可以視為一樣,
再看一次,
when :: (a -> Boolean) -> (a -> b) -> (a -> *) ifElse :: (a -> Boolean) -> (a -> b) -> (a -> c) -> (a -> *)
這樣 ifElse 就只比 when 多一個函式參數。
沒錯 when 在某種意義上就是 ifElse 的縮減版。
比較一般的寫法,
ifElse = (fn1, fn2, fn3, x) => if (fn1(x)) { return fn2(x) } else { return fn3(x) }
// or ifElse = (fn1, fn2, fn3, x) => fn1(x)? fn2(x): fn3(x)
when = (fn1, fn2, x) => if (fn1(x)) { return fn2(x) } else { return x }
// or when = (fn1, fn2, x) => fn1(x)? fn2(x): x
所以,兩者的差別就是當判斷的函式回傳 false 時 when 會直接回傳輸入參數,而 ifElse 會調用第三個函數參數後將得到評估值回傳。
所以實作上也能寫成
when = (fn1, fn2) => ifElse(fn1, fn2, I) // curry version when = curryN(2, (fn1, fn2) => ifElse(fn1, fn2, I))
上面的 when 其實跟原本的函式還是不太一樣,
因為原本的簽名是
when :: (a -> Boolean) -> (a -> b) -> a -> a | b
現在的簽名是
when :: (a -> Boolean) -> (a -> b) -> (a -> a | b)
差別在,
原本接受三個參數最後傳回與第三的參數型別相同或不同的值。
後者是接受兩個參數回傳 function。
雖然可以得到相同的結果,但在使用 curry 特性上卻有所差異。
為表現差異,可以重新命名之後來進行比較
const originalWhen = when const improveWhen = curryN(2, (fn1, fn2) => ifElse(fn1, fn2, I)) // 傳入 2 個參數後再以回傳的函式來執行 originalWhen(equals(3), inc)(3) // 4 improveWhen(equals(3), inc)(3) // 4 // 直接傳入 3 個參數時 originalWhen(equals(3), inc, 3) // 4 improveWhen(equals(3), inc, 3) // return function, 第 3 個參數被忽略
由上可知函式簽名有其重要性。
unless
簽名與 when 相同但功能相反。如果不滿足判斷函式則執行第二個函式參數,否則傳回原值。
cond
在大部分的程式語言中,可能會提供 switch 之類的語法。作為簡化或優化連續的 if ... else 來使用。
在一般的語法中比較 if 跟 switch ,
// if ... else
if ( n === 1 ) {
  console.log(n, 'equals 1')
} else if (n === 2) {
  console.log(n, 'equals 2')
} else if (n === 3 ) {
  console.log(n, 'equals 3')
} else {
  console.log(n, 'equals n')
}
// switch
switch (n) {
  case 1:
    console.log(n, 'equals 1')
  break
  case 2:
    console.log(n, 'equals 2')
  break
  case 3:
    console.log(n, 'equals 3')
  break
  default
    console.log(n, 'equals n')
  break
}
同樣在 Ramda 提供一個更 FP 風格的函式 cond。
cond :: [[(*… -> Boolean), (*… -> *)]] -> (*… -> *)
與 ifElse 簽名比較,
ifElse :: (*… -> Boolean) -> (*… -> *) -> (*… -> *) -> (*… -> *)
可以看出來將 ifElse 前兩參數 (*… -> Boolean) -> (*… -> *) 改為成對陣列的陣列,省略 else 的第三參數即得到 cond 的簽名。
將上面 swtch 改為 cond 寫法,
cond([
  [equals(1), (n) => console.log(n, 'equals 1')],
  [equals(2), (n) => console.log(n, 'equals 2')],
  [equals(3), (n) => console.log(n, 'equals 3')],
  [T, (n) => console.log(n, 'equals n')]
])
cond 接受一個陣列回傳一個函式,陣列中是成對的判斷函式與欲執行函式。
當判斷函式回傳 true 則執行與他成對函式。
cond 的判斷是依陣列順序,從這來看他更像 if … else 而不是 switch。
在寫程式時值得注意是,cond 的回傳是一個函式 (*… -> *),而呼叫該函式後會回傳一個值。
也就是若該函式的輸入參數在陣列中找不到吻合的判斷時,他將會回傳 undefined。
而 FP 的寫作風格通常會將函式的結果再給另一個函式處理,這在另一個函式沒預期接收一個 undefined 時,程式就會出錯。