Functional-Light JavaScript - Point Free

前言

此為 frontend master 的 Functional-Light JavaScript, v3 的課程筆記,這篇講的是 point free,point free 是我在剛接觸 FP 時完全不懂的概念,經過一點時間的接觸後,我想應該有比較了解 point free 的概念了,所以這篇就來稍稍講解什麼是 point free、point free 的好處是什麼。

內容不完全和課程編排相同,單純是個人消化過後的編排。

因為剛開始學 functional programming 的緣故,內容、觀念可能會有諸多錯誤,如有發現錯誤麻煩不吝指教,感謝!

什麼是 Point Free?

根據維基百科所說:

Tacit programming (point-free programming) is a programming paradigm in which a function definition does not include information regarding its arguments, using combinators and function composition […] instead of variables.

Kyle Simpson 則是講的更簡略一點:

point free definition is defining a function without needing to defining its point, aka, its inputs.

point 指的是 function 的 input,point free 的字面意思就是你可以不用去定義 function 的 input,那為什麼不用定義呢?因為你在別的地方已經定義了其他 function,透過組合其他 function 就能夠得到新的 function。

所以,point free 就是你不用寫出 function 的參數,簡單的說就是:「point free 沒有在跟你說資料長怎樣的。」

聽起來還是很抽象,所以來舉幾個例子:

function print(text) {
  console.log(text)
}

// 其實可以簡化成
const print = console.log

為什麼我們能夠這樣簡化呢?因為我們在其他地方定義了 log 這個 function 該做些什麼事情了。

再看看另一個例子:

const getServerStuff = callback => ajaxCall(json => callback(json));

// 以 point free 的方式可以簡化成
const getServerStuff = ajaxCall;

// 詳細拆解
// this line
ajaxCall(json => callback(json));

// is the same as this line
ajaxCall(callback);

// so refactor getServerStuff
const getServerStuff = callback => ajaxCall(callback);

// ...which is equivalent to this
const getServerStuff = ajaxCall; // <-- look mum, no ()'s

概念上和前一個例子是一樣的,我們在其他地方定義好了 function、預計要接受幾個 arguments,因此透過原先定義好的 function 組合出新 function 時,就能夠省略 input。

point free 的好處就是可以減少不必要的資訊,因為我們根本不在乎傳入的參數叫什麼名字,怎麼被傳遞,其實我們只在乎會得到什麼結果而已,因此用上 point free 就可以閱讀程式碼時的腦袋負荷。

但 point free 同時也是一把雙刃劍,過度的使用它也可能會讓閱讀者不知道這段程式碼到底在幹嘛,反而造成更多理解上的負擔。(比如上面的程式碼,我剛看的時候就不懂為什麼可以這樣)

Point Free Refactor

接著再重構一小段程式碼,試著更熟悉 point free 的模式吧。

function isOdd(v) {
  return v % 2 === 1
}

function isEven(v) {
  return !isOdd(v)
}

isEven(4) // true

這段是很簡單判斷數字是否為奇數或偶數的程式碼,我們怎麼把這邊改成 point-free style 呢?在思考如何改成 point free 以前,有兩個問題要回答:

  1. 為什麼要另外宣告 isEven?這樣有什麼好處嗎?明明 isOdd 就可以足夠判斷一個數字是奇數或偶數了吧?
  2. 為什麼要改成 point free 呢?這樣有什麼好處?現在不就很 ok 了嗎?

為什麼要另外宣告 isEven?

先回答第一個問題,的確是不用另外宣告就可以達成原本目的,畢竟結果不是偶數就是基數嘛。

但可以想想宣告了得到什麼好處,那就是讓 isEvenisOdd 產生了聯繫、關係,試想如果單純用 isOdd 來判斷奇偶,那就沒有這層關聯了;但宣告 isEven 時,你可以從程式碼看出它是 isOdd 的相反,並且當需要用到判斷偶數的時候,「某數字是偶數」相比「某數字是奇數的相反」還更好理解對吧!

為什麼要用 point free?

再來是第二個問題,我們直接來看改完後的 code:

function complement(fn) {
  return function negated(...args) {
    return !fn(...args)
  }
}

function isOdd(v) {
  return v % 2 === 1
}

var isEven = complement(isOdd)

isEven(4) // true

我們另外宣告了一個叫做 complement 的 function,他可以把原本 function 的執行結果變得相反,比如 isOdd 執行完會是 true,complement 回傳的 function 執行結果會是 false。

改完後的 code 很明顯就可以感受到 point free 的好處了,第一是我們省略了不必要的資訊,在上一段 code 中 isEven 有個參數 v ,但這根本是不必要的資訊,我們根本不在乎 v 是三小對吧,因為我們在乎的只有「傳進來的數字是不是偶數」,改成 point free 後 v 就不見了;第二就是 isEvenisOdd 的關聯性變得很 obvious,從上面那段程式碼來說,如果你知道 complement 的含義和用途,你很快就可以知道 isEvenisOdd 的相反。

小補充,Ramda 有提供兩個類似功能的 utils:

  1. 一個叫做 complement,它預計接收一個 function(我們先稱為 f),並回傳一個 function(我們先稱為 g),當 f return falsy 的值,g 會 return true;當 f return truthy. 的值,g 會 return false。
  2. 另一個叫做 not,預計接收的會是一個 Boolean,單純就是把 true 變 false,false 變 true。

Kyle Simpson 在這堂課程把上面程式碼的 utils 命名為 not ,我認為會讓用過 Ramda 的人有點混淆,因此將命名改為 complement

Point Free Exercise

function when(fn) {
  return function (predicate) {
    return function (...args) {
      if (predicate(...args)) {
        return fn(...args)
      }
    }
  }
}

function output(txt) {
  console.log(txt);
}

function printIf(shouldPrintIt) {
  return function(msg) {
    if (shouldPrintIt(msg)) {
      output(msg);
    }
  };
}

function isShortEnough(str) {
  return str.length <= 5;
}


function isLongEnough(str) {
  return !isShortEnough(str);
}

var msg1 = "Hello";
var msg2 = msg1 + " World";

printIf(isShortEnough)(msg1);		// Hello
printIf(isShortEnough)(msg2);
printIf(isLongEnough)(msg1);
printIf(isLongEnough)(msg2);		// Hello World

這段練習目的是要把 outputprintIfisLongEnough 三段程式碼重構為 point-free style,並且讓原本 line35 - line38 印出字串的行為不變,也就是 line35 依然會印出 Hello,line38 依然會印出 Hello World。

也就是說經過重構成 point free 後, outputprintIfisLongEnough 三個 function 的宣告都不會看到參數。

開始前先解釋

開始前先來搞清楚狀況,知道每個 function 在做什麼,然後原本行為是什麼吧!

  1. output的行為很單純,就是把傳進來的 argument 用 console.log 印出。

  2. printIf比較複雜,printIf 會接受一個 shouldPrintIt 的 function 後回傳另一個 function;下一個 function 會接受一個叫 msg的 argument,指的是要印出的訊息,當這個 function 執行之後,會執行 shouldPrintIt ,如果 shouldPrintIt 是 true,那就會使用 output 印出 ·msg。

  3. isLongEnough 很單純,就是 isShortEnough 執行結果的相反。

  4. when 則是作者提供給我們的 utils,待會重構會用到。

以 line35 來說,printIf 傳入了 isShortEnough ,再傳入了 msg1,因此執行的時候,會透過 isShortEnough 檢查傳入的字串 length 是否 <= 5,為 true 的話就印出字串。

一步一步重構

我們先來看 output

function output(txt) {
  console.log(txt);
}

和我們前面的範例很像,直接重構成這樣就好:

// function output(txt) {
//   console.log(txt);
// }

const output = console.log

接著我們先跳過 printIf ,來看 isLongEnough

function isShortEnough(str) {
  return str.length <= 5;
}


function isLongEnough(str) {
  return !isShortEnough(str);
}

有沒有發現和原本的 isOddisEven 很像,所以其實可以如法炮製,透過 complement 重構:

function complement(fn) {
  return function negated(...args) {
    return !fn(..args)
  }
}

function isShortEnough(str) {
  return str.length <= 5;
}

// function isLongEnough(str) {
//   return !isShortEnough(str);
// }

const isLongEnough = complement(isShortEnough)

接著往下看到 printIf

function when(fn) {
  return function (predicate) {
    return function (...args) {
      if (predicate(...args)) {
        return fn(...args)
      }
    }
  }
}

const output = console.log

function printIf(shouldPrintIt) {
  return function(msg) {
    if (shouldPrintIt(msg)) {
      output(msg);
    }
  };
}

printIf(isShortEnough)(msg1);	// Hello

如果細看 when 這個 function,可以發現到 line2 - line8 的邏輯和 printIf 基本上是一模一樣的,所以舉例來說,如果我們要用 when 達到和 line19 一樣的結果,可以寫成這樣:

when(output)(isShortEnough)(msg1) 
// 等同於
printIf(isShortEnough)(msg1) // Hello

也就是說 when(output)printIf 是一樣的,因此我們可以重構成這樣:

function when(fn) {
  return function (predicate) {
    return function (...args) {
      if (predicate(...args)) {
        return fn(...args)
      }
    }
  }
}

const output = console.log

// function printIf(shouldPrintIt) {
//   return function(msg) {
//     if (shouldPrintIt(msg)) {
//       output(msg);
//     }
//   };
// }

const printIf = when(output)

printIf(isShortEnough)(msg1);	// Hello

所以重構後的結果會長成這樣:

function when(fn) {
  return function (predicate) {
    return function (...args) {
      if (predicate(...args)) {
        return fn(...args)
      }
    }
  }
}

function not(fn) {
  return function negated(...args) {
    return !fn(..args)
  }
}

const output = console.log

const printIf = when(output)

function isShortEnough(str) {
  return str.length <= 5;
}

const isLongEnough = not(isShortEnough)

var msg1 = "Hello";
var msg2 = msg1 + " World";

printIf(isShortEnough)(msg1);		// Hello
printIf(isShortEnough)(msg2);
printIf(isLongEnough)(msg1);
printIf(isLongEnough)(msg2);

重構完成!可以發現到原本的 outputprintIfisLongEnough 三個 function 重構成 point-free style 以後,都看不到它的參數惹,那為什麼能夠辦到這件事呢?因為我們把現有的 function 拼拼裝裝組合成新的 function。

Advanced Point Free

這段落會講進階的 point free 技巧,以及會應用到的另一個 FP 技巧,composition。

我們要把先前的 isOdd 改成 point free style,但在此之前,先看幾段程式碼。

function mod(y) {
  return forX(x) {
    return x % y
  }
}

function eq(y) {
  return forX(x) {
    return x === y
  }
}

從這段程式碼我們可以看到, mod 是負責回傳 x % y 的餘數; eq 負責判斷 x === y 。於此同時,你可能會注意到,為什麼這邊是從 y 先傳進來,再傳 x 呢?好像很反直覺欸?

Functional Programming 很注重 function 的 shape,除此之外,也很注重參數的順序,順序決定這個 function 有沒有那麼好使用,而這邊先傳 y 進來的好處是什麼呢?讓我們繼續看下去

function mod(y) {
  return forX(x) {
    return x % y
  }
}

function eq(y) {
  return forX(x) {
    return x === y
  }
}

var mod2 = mod(2)
var eq1 = eq(1)

應該能夠感受到先從 y 傳的好處了對吧?先傳 y 的話,我們就可以先決定 modeq 部分的邏輯,以 mod 來說,我們能夠先傳 2 進去,並且宣告一個 mod2 的 function,代表傳入 mod2 的數字都回傳除以 2 的餘數;eq 也是如此,eq1代表了會回傳是否與 1 相等的結果,也就是說,如果先寫 y 的話,我們能夠先決定條件,再藉此宣告出更特定、客製化的 function。

接著就可以進入正題了,來動手用 mod2eq1 來改造之前的 isOdd 吧!

//... mod 和 eq 的宣告就先省略
var mod2 = mod(2)
var eq1 = eq(1)

function isOdd(x) {
  return eq1(mod2(x))
}

發現到一件很酷的事情了嗎?在 isOdd裡面, mod2(x) 執行後的 output,又會馬上傳入 eq1 裡面當作它的 input!

這種執行後的結果,馬上又被傳入另一個 function 當作 input 的作法,就被稱為 Composition

Take one function’s output, and make the input to another function

可以發現目前還是沒有到達 point free 的最終目標,那我們要怎麼使用 composition 達到 point free 呢?

var mod2 = mod(2)
var eq1 = eq(1)

function isOdd(x) {
  return eq1(mod2(x))
}

function compose(fn2, fn1) {
  return function composed(x) [
    return fn2(fn1(x))
  ]
}

var isOdd = compose(eq1, mod2)

酷吧!compose 做了幾件事:

  1. 接收 fn2、fn1 兩個參數,執行後回傳一個 composed function ,接受一個 x 參數。
  2. composed function 傳入 x 後,會先執行 fn1fn1 的 output 會傳入 fn2 當作 input
  3. 執行完後 return 結果

所以 compose 傳入 function 的執行順序是由 「右 -> 左」,以 isOdd 為例子的話,mod2 就是 fn1eq1 就是 fn2

思路怎麼運作的?

看看原本的程式碼:

function isOdd(x) {
  return eq1(mod2(x))
}

function compose(fn2, fn1) {
  return function composed(x) [
    return fn2(fn1(x))
  ]
}

composedisOdd 的 shape 是一樣的,eq1(mod2(x))fn2(fn1(x)) 也是一樣的!

而在最後的最後,我們又能夠發現,eq1eq(1)mod2mod(2) 又是一樣的,所以其實也不用另外宣告,最終的結果會長這樣:

function compose(fn2, fn1) {
  return function composed(x) [
    return fn2(fn1(x))
  ]
}

// var isOdd = compose(eq1, mod2)
var isOdd = compose(eq(1), mod(2))

Kyle Simpson 重構完後如此說:

Those piece are interchangable because they have the same shape. That’s equational reasoning, and equational reasoning is the heart of being able to do point freestyle.

對於 equational reasoning 有疑惑的人可以參考 第 3 章:Pure Function-單純的幸福 ,因為我沒把握把它說明的很清楚哈哈。

Function Parameter Order

Function Parameter Order Matters Greatly.

Function 的 parameter 順序很重要,paramter 的順序決定了你這個 function 好用與否。

截圖 2021-01-09 上午1.08.33

一般來說,設計 function parameter 的順序會按照「General -> Specific」的規則來設計,也就是越通用的會放在越前面的順序,越特定的會放在越後面的順序。

Ramda map 就可以這個設計 function 的方式,R.map把 function 放前面,array 放後面。

以 Ramda 的 map 設計來說,function 其實是更為 general 的 parameter,比如你可以宣告一個叫 add1 的 function 後放到各處使用到 R.map 的地方;array 則是更 specific、更有變化的內容,因此放在右邊更合理、方便使用。

所以 function parameter order 設計的重點在於,function paramter 由左而右的順序應該要是從最通用的,再到最特定的 paramter。

References