The Issue of the Day Before

跟著 Ramda 學 FP - 條件判斷(1)

ramda -

如何使用 Ramda 進行條件判斷。ifElse, when, unlesscond 的用法。

Why

Ramda 是一個比 lodashfunctional 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))
  1. 一般情況

  2. 在有 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

所以,兩者的差別就是當判斷的函式回傳 falsewhen 會直接回傳輸入參數,而 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 來使用。

在一般的語法中比較 ifswitch

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

閱讀在雲端