點燈坊

失くすものさえない今が強くなるチャンスよ

FP 之 Point-free

Sam Xiao's Avatar 2020-01-31

Curry Function 最主要的目的在於 Function Composition,所以儘管是 個 Argument,最後也可變成多個 單一 Argument 的 Function 方便組合;而 Point-free 正是最適合 Function Composition 寫法。

Version

macOS Mojave 10.15.2
VS Code 1.41.1
Quokka 1.0.276
F# 4.7
ECMAScript 2015
Ramda 0.26.1

FSharp

由於 F# 是純 FP 語言,function 可自動成為 curry function,常發現 F# 的 function 會如此設計。

List.map()
mapping : ('T -> 'U) -> list : 'T list -> 'U list

List.filter()
predicate : ('T -> bool) -> list : 'T list -> 'T list

List.reduce()
reduction : ('T -> 'T -> 'T) -> list : 'T list -> 'T

僅管 List.map()List.filter()List.reduce() 三個 function 功能都不同,input 與 return 值也不同,但最後一個 argument 一定都是 list : 'T list

Pipeline

let mapSquare = List.map (fun x -> x * x)
let filterOdd = List.filter (fun x -> x % 2 = 1)
let sum = List.reduce (fun a x -> a + x)

let calculate arr = 
  arr
  |> mapSquare
  |> filterOdd
  |> sum
    
calculate [1 .. 3] |> printf "%A"

第 1 行

let mapSquare = List.map (fun x -> x * x)
let filterOdd = List.filter (fun x -> x % 2 = 1)
let sum = List.reduce (fun a x -> a + x)

List.map()List.filter()List.reduce() 都僅提供 1 個 argument,所以 mapSquare()filterOdd()sum() 都是 function。

第 5 行

let calculate arr = 
  arr
  |> mapSquare
  |> filterOdd
  |> sum

mapSquare()filterOdd()sum() 透過 pipeline 處理 arr

因為 mapSquare()filterOdd()sum() 的最後一個 argument 都是 list : 'T list,在 pipeline 時,F# 允許省略之。

|> 為 F# 的 pipeline,由左至右

Function Composition

let mapSquare = List.map (fun x -> x * x)
let filterOdd = List.filter (fun x -> x % 2 = 1)
let sum = List.reduce (fun a x -> a + x)

let calculate = mapSquare >> filterOdd >> sum
    
calculate [1 .. 3] |> printf "%A"

第 1 行

let mapSquare = List.map (fun x -> x * x)
let filterOdd = List.filter (fun x -> x % 2 = 1)
let sum = List.reduce (fun a x -> a + x)

List.map()List.filter()List.reduce() 都僅提供 1 個 argument,所以 mapSquare()filterOdd()sum() 都是 function。

第 5 行

let calculate = mapSquare >> filterOdd >> sum

mapSquare()filterOdd()filterOdd() 組合成 calculate()

因為 mapSquare()filterOdd()sum() 的最後一個 argument 都是 list : 'T list,在 function composition 時,F# 允許省略之。

>> 為 F# 的 function composition,由左至右

我們可以發現 F# 在設計 function 時,會 故意 將要處理的 資料 放在最後一個 argument,將 條件 放在前面的 argument,如此所有 function 無論要做 pipeline 或 function composition 時,都可省略最後一個 argument,使程式碼更簡潔。

Pipeline 與 function composition 講的其實是同一件事情,只是 F# 文化較喜歡使用 pipeline,而 Haskell 較愛用 function composition

當初以為這只是 F# 的 syntax sugar,後來在歐陽繼超的 前端函數式攻城指南Haskell 趣學指南 這兩本書,才發現這是 FP 特有風格,稱為 point-free。

Definition

Point-free
Function 不特別將要處理的 data 放進參 argument,因此也不回傳處理過的 data,而是回傳 function,這有助於 function composition,也稱為 Tacit Programming

Q:為甚麼 point-free 能成立呢 ?

let fn arr = List.map (fun x -> x * x) arr

若原本 fn() 只帶一個 argument,相當於將 arr 傳入 List.map (fun x -> x * x),並回傳處理過的結果。

let fn = List.map (fun x -> x * x) 

由於 F# 的 function 天生是 curry function,fn() 沒有 argument,= 左右將 arr 同時消去,就相當於回傳 List.map (fun x -> x * x)

所以一個 function 將 data 放在最後一個 argument 時,提不提供 data 都成立:

  • 有提供 data 則回傳處理過的 data
  • 不提供 data 則回傳 function

由於回傳是 function,特別適合做 function composition。

Q:為什麼稱為 point-free ?

Point 所指的就是傳入 data 的 argument,point-free 就是指 function 將 data 放在最後一個 argument,要使用時故意將最後一個 argumet 丟棄 (free),則變成回傳 function,但若 data 不是最後一個 argument,則無法丟棄,因此也無法變成 function,也無法繼續再做 function composition。

Q:為什麼 point-free 適合做 function composition ?

Function composition 事實上來自於數學的 合成函數,也就是 fog(x) = f(g(x)),其中:

fog(x) = f(y)
y      = g(x)

也就是 g(x) 的 output 剛好為 f(y) 的 input,因此才能將 f(g(x)) 合併,變成 fog(x),其中的 y 剛好被消滅。

f()g() 每個 function 的 格式都一樣,都是最後一個 argument 為 data,則可將所有 function 加以組合成一個新 function,這就是 function composition。

ECMAScript

前面談的都是 F#,你可能看得似懂非懂,我們就來將相同程式碼以大家熟悉的 ECMAScript 改寫:

Pipeline

let map = fn => arr => arr.map(fn)
let filter = fn => arr => arr.filter(fn)
let reduce = fn => arr => arr.reduce(fn)

let mapSquare = map(x => x * x)
let filterOdd = filter(x => x % 2)
let sum = reduce((a, x) => a + x)

let pipe = (...fns) => init => fns.reduce((g, f) => f(g), init) 

let calculate = pipe(mapSquare, filterOdd, sum)

calculate([1, 2, 3]) // ?

第 1 行

let map = fn => arr => arr.map(fn)
let filter = fn => arr => arr.filter(fn)
let reduce = fn => arr => arr.reduce(fn)

ECMAScript 雖然都有提供 map()filter()reduce(),但這些都不是 curry function,因此無法使用 function composition,所以第一步就是將這些 function 改寫成 curry function。

第 5 行

let mapSquare = map(x => x * x)
let filterOdd = filter(x => x % 2)
let sum = reduce((a, x) => a + x)

改寫成 point-free function。

第 9 行

let pipe = (...fns) => init => fns.reduce((g, f) => f(g), init) 

由於 ECMAScript 沒有提供 pipe() 組合 function,我們自己土炮用 reduce() 寫一個 pipe(),負責將多個 point-free function 組合成單一 function。

11 行

let calculate = pipe(mapSquare, filterOdd, sum)

mapSquare()filterOdd()sum() 組合成 calculate()

這裡與 F# 一樣,而是 由左至右

pointfree000

Function Composition

let map = fn => arr => arr.map(fn)
let filter = fn => arr => arr.filter(fn)
let reduce = fn => arr => arr.reduce(fn)

let mapSquare = map(x => x * x)
let filterOdd = filter(x => x % 2)
let sum = reduce((a, x) => a + x)

let compose = (...fns) => init => fns.reduceRight((g, f) => f(g), init)

let calculate = compose(sum, filterOdd, mapSquare)

calculate([1, 2, 3]) // ?

第 9 行

let compose = (...fns) => init => fns.reduceRight((g, f) => f(g), init)

由於 ECMAScript 沒有提供 compose() 組合 function,我們自己土炮用 reduceRight() 寫一個 compose(),負責將多個 point-free function 組合成單一 function。

11 行

let calculate = compose(sum, filterOdd, mapSquare)

mapSquare()filterOdd()sum() 組合成 calculate()

這裡與 F# 不一樣,而是 由右至左

pointfree001

ECMAScript 雖然寫的出來,但由於沒有直接支援 curry function 、pipe()compose(),因此寫起來有些冗長。

Ramda

Pipeline

import { map, filter, reduce, pipe } from 'ramda'

let mapSquare = map(x => x * x)
let filterOdd = filter(x => x % 2)
let sum = reduce((a, x) => a + x, 0)

let calculate = pipe(mapSquare, filterOdd, sum)

calculate([1, 2, 3]) // ? 

第 1 行

import { map, filter, reduce, pipe } from 'ramda'

從 Ramda 引入 map()filter()reduce() ,這些都是已經是 curry function。

也從 Ramda 引入 pipe()

第 3 行

let mapSquare = map(x => x * x)
let filterOdd = filter(x => x % 2)
let sum = reduce((a, x) => a + x, 0)

使用 Ramda 的 function 改寫成 point-free function。

第 7 行

let calculate = pipe(mapSquare, filterOdd, sum)

使用 Ramda 的 pipe()mapSquare()filterOdd()sum() 組合成 calculate()

pointfree002

Function Composition

import { map, filter, reduce, compose } from 'ramda'

let mapSquare = map(x => x * x)
let filterOdd = filter(x => x % 2)
let sum = reduce((a, x) => a + x, 0)

let calculate = compose(sum, filterOdd, mapSquare)

calculate([1, 2, 3]) // ? 

第 1 行

import { map, filter, reduce, compose } from 'ramda'

從 Ramda 引入 map()filter()reduce() ,這些都是已經是 curry function。

也從 Ramda 引入 compose()

第 7 行

let calculate = compose(sum, filterOdd, mapSquare)

使用 Ramda 的 compose()mapSquare()filterOdd()sum() 組合成 calculate()

pointfree003

Ramda 版本已經非常精簡,我們只需實作 point-free function 再加以組合即可,整體風格已經與純 FP 的 F# 非常接近。

Conclusion

  • Point-free 是 FP 設計 argument 的基本精神,這也是為什麼歐陽繼超在前端函數式攻城指南一書中指出 Underscore 設計錯誤,因為 Underscore 是 _.map([1, 2, 3], x => x + 1),將 data 放在第 1 個 argument,這並不符合 point-free 原則,因此 Underscore 無法使用 function composition
  • FP 首重觀念,只要心裡有 function composition,無論是在 F# 或在 ECMAScript 都可實作
  • 純 ECMAScript 實作稍微冗長,若使用 Ramda,則整體精簡程度已經與 F# 非常接近

Reference

歐陽繼超,前端函數式攻城指南
Miran Lipovaca, Haskell 趣學指南
Wikipedia, Tacit Programming