跟著 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
時,程式就會出錯。