Functional-Light JavaScript - Closure

前言

此為 frontend master 的 Functional-Light JavaScript, v3 的課程筆記,這篇講的是 functional programming 中 closure 的應用,像是 memoization、partial application、curry,以及在 functional programming 中使用 closure 要特別注意的地方。

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

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

Closure

Closure 在 Functional Programming 是很重要的,因為在 FP 當中會很常使用到 Closure,必須對 Closure 有足夠的了解才能進行下去,Kyle 對 Closure 的定義只有這句話:

Closure is when a function “remembers” the variables around it even when that function is executed elsewhere

來看一段 Closure 的程式碼:

function makeCounter() {
  var counter = 0
  return function increment() {
    return ++counter
  }
}

var c = makeCounter()

c() // 1
c() // 2
c() // 3

相信有寫過 JavaScript 並且有聽過 Closure 的人應該都對這種例子頗為熟悉的,在 makeCounter 執行完後,理論上 counter 應該要被 GC 回收才對,但因為 Closure 的緣故 ,counter 的值仍然被記著,每當執行 c 的時候,就會增加 counter 的值。

這個時候有個問題:「c 是一個 pure function 嗎?

答案不是,因為c 每次執行的 output 都不一樣;但 closure 在 Functional Programming 是可以被接受的,只是要注意必須使用到 non-mutating value。

接著來看另一個例子:

截圖 2021-01-07 下午8.51.24

為什麼 one 裡面的 fn unary 執行完之後仍然還存在呢?原因也是因為 Closure。

再來看另一個例子:

截圖 2021-01-07 下午8.53.13

這也是因為 Closure,而導致 addAnother 在執行結束後仍然可以使用到 z

所以嚴格來說,我們這兩個例子的 function,也是用到外部的 variable 了對吧?的確是,看似是一個 NoNoNo 的舉動,但因為使用到了不會被改變的 variable,形成了安全、可以被信賴的 pure function。

Closure Exercise

接下來看看練習題,要求是寫出一個 strBuilder 的 function,通關條件是可以讓下面的 console.log 都回傳 true。

注意 strBuilder 這個 function 在 input 為 string 的時候,會累加字串;等到沒有 input 直接執行的時候,會回傳剛剛累加的結果。

function strBuilder(str) {
  
}

var hello = strBuilder('Hello, ')
var kyle = hello('Kyle')
var susan = hello('Susan')
var question = kyle('?')()
var greeting = susan('!')()

console.log(strBuilder('Hello, ')('')('Kyle')('.')('')() === 'Hello, Kyle.')
console.log(hello() === 'Hello, ')
console.log(kyle() === 'Hello, Kyle')
console.log(susan() === 'Hello, Susan')
console.log(question === 'Hello, Kyle?')
console.log(greeting === 'Hello, Susan!')

錯誤範例

function strBuilder(str) {
  return function next(v) {
    if (typeof v === 'string') {
      str += v
      return next
    }
    return str
  }
}

var hello = strBuilder('Hello, ')
var kyle = hello('Kyle')
var susan = hello('Susan')
var question = kyle('?')()
var greeting = susan('!')()

console.log(hello()) // Hello, KyleSusan?!

這個版本的錯誤是什麼?錯誤源自於我們 mutate 了 str,所以當 hello 被執行,又或者後續的 susankyle被執行的時候,都會把 str 的值繼續累加進去,等於是我們一直在 mutate strBuilder 這個 scope 的 str ,無論是在 hellokylequestion 或其他都是。

正確範例

那修改後的版本應該要長怎樣?

function strBuilder(str) {
  return function next(v) {
    if (typeof v === 'string') {
      return strBuilder(str + v) 
    }
    return str
  }
}

這個範例和上一個不同在哪?在於每次 return 的是 setBuilder 執行後的結果!而 setBuilder 被傳入的是什麼值呢?是上一次執行 setBuilderstr 和上一次 next 被傳入的 v

舉例來說:

function strBuilder(str) {
  return function next(v) {
    if (typeof v === 'string') {
      return strBuilder(str + v)
    }
    return str
  }
}

strBuilder('Hello, ')('test')() // 'Hello, test'

/*
  1. 第一次 setBuilder 被傳入 'Hello, ', 並回傳了 next function
  2. 第二次 next 被傳入 'test',next 記得 str 是 'Hello, ',
     'Hello, ' 和 'test' 相加後變成 'Hello, test' 
  3. 'Hello, test' 傳入 setBuilder,執行後回傳 next function
  4. 最後一次沒有傳入 string,next 直接回傳 str,也就是 'Hello, test'
*/

也就是說,因為每次 strBuilder 都形成了一個新的閉包,這次的 str 和上一次的 str 是不同的,我們根本沒有 mutate str,我們只是取得 str 的值,和 v 相加之後,當作 argument 傳進去而已,也因為這樣達到了 pure function 的結果。

和 recursion 其實有點像,但又不算是 recursion。

重點在哪?

講這麼多,這段 Closure 的重點就在於 Kyle Simpson 說的這段話:

The key takeaway here is that if you’re going to use closure in functional programming, you have to make sure you’re closing over non-changing, non mutating value.

當你使用到 Closure 的時候,就要特別去注意使用到的 value 是不是 non mutating value,如果在 Closure 的過程中有 mutate variable,那就不是很 ok,因為會失去 pure function 的優勢,進而更容易產生非預期的 bug。

Lazy、Eager、Memoization

Lazy

function repeater(count) {
  return function allTheAs() {
    return "".padStart(count, "A")
  }
}

var A = repeater(10)

A() // AAAAAAAAAA
A() // AAAAAAAAAA

這段 code 裡面,真正計算出 10 個 A 的地方,是在 line7 還是 line 9 呢?答案是 line9,而這個時機點影響到了一些事情。

lazy(defer) 很有趣的地方在於它延後了真正執行的時間,以就是真正製造出 10 個 A 的時機,比起在 line7 就得到結果,他把結果延後到了 line9。

可以注意的是,我們這時候 closure 的對象是 count

Pros Cons
如果我們不確定執行的時機點,假設只有真正 10% 會需要這個 function,那如果沒有 defer 的話,90% 的執行都是浪費的,所以 defer 可以讓我們更精準的在這 10% 才執行。 壞處就是每次 function call 的時候,我們都會重複計算一次,如果他是要被 call 很多次的 function,那顯然用 lazy 是很不划算的。

Eager

那有沒有辦法計算一次,之後都用計算過後的值就好呢?有,就是 Eager。

function repeater(count) {
  var str = "".padStart(count, "A")
  return function allTheAs() {
    return str
  }
}

const A = repeater(10)

A() // AAAAAAAAAA
A() // AAAAAAAAAA

和先前不同,我們在 line8 ,const A = repeater(10) 的時候就把 10 個 A 給製造出來了,這個做法叫做 eager,當 function call 的時候就製造 A,沒有推遲這個任務。

我們這時候 closure 的是哪個東西呢?是 str

Pros Cons
好處是我們做了 cache,整個 function 只需要計算一次而已,當之後 function 不斷被 call 的時候,拿的都只是計算完後的結果! 壞處是如果這個 function 從來沒被 call 呢?那我們就白做工了。

Memoization

可以發現上述的 LazyEager 發現到一件事,那就是兩者都有 trade off。

那有沒有辦法可以同時兼顧兩者的優點呢?在真正 call function 之前我不會進行計算,而如果之後想要取得相同的 output,這個 function 也只會計算一次,其他次都是拿到計算過的舊值,像是 cache 拿取值那樣,有辦法嗎?

有的,那就是 Memoization,看看下面這段 code:

function repeater(count) {
  var str;
  return allTheAs() {
    if(str === undefined) {
      str = "".padStart(count, "A")
    } 
    return str
  }
}

const A = repeater(10)

A() // AAAAAAAAAA - 第一次 function call 進行計算
A() // AAAAAAAAAA - 之後都使用 cache

這技巧就叫 memoization ,我們只有在 str 是 undefined 的時候,也就是第一次 call 的時候計算,之後 call 這個 function 就是從舊的值裡面取得,是個雙贏!讚拉!

那這段 code 有沒有什麼疑慮呢?有的,那就是 —— 這段 code 算不算 pure 呢?

我們改了 strstr 從 undefined 被 mutate 成了 10 個 A,所以算 impure 對嗎?

但回想過去 pure function 的一段話:

A pure function call is characterized by given the same input, it always produce the same output.

pure function 無論 call 幾次,給它相同的 input,就會得到相同的 output,A 這個 function 有達到這個要求,所以它是 pure function 囉?

Kyle 這邊給的說法類似於「技術上來說它算 pure,但 reader 會認為有 impure 的意味」(這是我的解讀)Kyle 在這邊給的解法如下:

function repeater(count) {
  return memoization(function allTheAs() { // memoization 來自 utility library
    return "".padStart(count, "A")
  })
}

const A = repeater(10)

A() // AAAAAAAAAA
A() // AAAAAAAAAA

透過大部分 functional utility library 都有的 memoization 來幫助自己的 function 達到 pure 的效果,修改過後的 function 相比剛剛的確是更 pure 了對吧~雖然我自己看完是有點「嗯?嗯…」的黑人問號。

說到這裡,既然 memoization 這麼好用,那我乾脆把全部的 function 都包上就好啦!計算的值就都可以被記下來了,好像很讚喔?

NoNoNo,memoization 背後的代價在於會佔用 memory,假設你每次 call 這個 function,都會給予不同的 input,然後還會 call 好幾次,這背後的代價是會佔用更多的 memory;所以 memoization 最適合的應用場景在於「會 call 好幾次同樣 function,而每次都會給予相同的 input

Memoization 實作

function memoize(fn) {
  let cache = {}
  return function memo(...args) {
    const key = JSON.stringify(args) // serialize
    if(!cache[key]) {
      cache[key] = fn(...args)
    }
    return cache[key]
  }
}

概念上很單純,先傳入一個 function,接著會回傳一個 memo function 接受參數。line4 做的事情是 serialize,當 cache 裡面沒有值,也就是沒有計算過的話,那就進行計算,並且賦予 cache[key] 計算過後的值;啊如果有計算過的話,那就直接 return cache 的 value。

關於 Ramda 的實作可以看 Ramda - memoizeWith,會發現實作概念上幾乎是一樣的。

另外要特別注意的是,memoize 的優點建立在於 function 是 pure 的,因為你能夠確保同樣的 input 會得到同樣的 output,才能夠透過傳入的 input 決定要不要進行計算。

Partial Application & Curry

假設今天有一個 ajax 的 function,可以 call API 取得資料,程式碼長得像這樣:

function ajax(url, data, cb) { /*....*/ }

ajax(CUSTOMER_API, { id: 1 }, renderCustomer)

看起來 ok,但還有改進的空間,比如 CUSTOMER_API 是 hard code,好像可以隱藏起來,另外直接 call ajax 也不是那麼語意化,所以可以改成這樣:

function ajax(url, data, cb) { /*....*/ }

ajax(CUSTOMER_API, { id: 1 }, renderCustomer)

function getCustomer(data, cb) {
  return ajax(CUSTOMER_API, data, cb)
}

getCustomer({ id: 1 }, renderCustomer)

實際上就是宣告一個 getCustomer function,讓它更語意化,再省略掉 CUSTOMER_API 這個比較多餘的細節,增加可讀性。

要再更細節、更語意化還能改成這樣:

function ajax(url, data, cb) { /*....*/ }

ajax(CUSTOMER_API, { id: 1 }, renderCustomer)

function getCustomer(data, cb) {
  return ajax(CUSTOMER_API, data, cb)
}

getCustomer({ id: 1 }, renderCustomer)

function getCurrentUser(cb) {
  // ajax(CUSTOMER_API, { id: 1 }, renderCustomer)
  return getCustomer({id: 1}, cb)
}

getCurrentUser(renderCustomer)

看起來更語意化了,但可以注意到 line12 有一行註解,這行執行的結果其實和 line13 一模一樣,但為什麼選擇用 line13 呢?原因是這樣 getCurrentUsergetCustomer 的連結上就更明顯了,如果單純是 line12 和 line13 相比,line13 看起來的確是更語意化的對吧。

但如果每次都要這麼麻煩好像也是很搞剛,因此之後的章節會更簡化這段流程,用上 partial application、curry 的技巧。

Partial Application

function ajax(url, data, cb) { /*....*/ }

var getCustomer = partial(ajax, CUSTOMER_API)
var getCurrentUser = partial(getCustomer, { id: 1 })

getCustomer({ id: 1}, renderCustomer)
getCurrentUser(renderCustomer)

這是 partial 的基礎用法,透過 partial 傳入要執行的 function,以及想要先使用到的哪幾個 inputs,剩下的 parameter 傳入的時機被延後了,等到需要呼叫的時候再傳入。

在「Functional-Light JS」的書裡面有關於 partial 的實作

function partial(fn,...presetArgs) {
  return function partiallyApplied(...laterArgs){
    return fn( ...presetArgs, ...laterArgs );
  };
}

Curry

再來就是 Curry 了,這應該就是大多數人都聽過的技巧,名字超有記憶點,來由也挺有趣的,原因是 Curry 的發明者叫做 Haskell Curry。

Curry 相較 Partial Application 更普遍,但其實他們挺像的,做的事情都是 specialized function,所以差不多就是表親關係啦。

如果要把 ajax 這個 function 寫成 curry 的模式,會變成這樣:

function ajax(url) {
  return function getData(data) {
    return function getCb(cb) { /*...*/ }
  }
}

ajax(CUSTOMER_API)({ id: 1 })(renderCustomer)

var getCustomer = ajax(CUSTOMER_API)
var getCurrentUser = getCustomer({ id: 1 })

但這樣其實有點麻煩,有沒有更方便的辦法呢?有,通常 functional utility library 也會提供叫做 curry 的 function,比如要是使用 Ramda ˇ的話,使用起來就會變成這樣:

var curriedAjax = R.curry(
  function ajax(url, data, cb) { /*...*/ }
)

var getCustomer = curriedAjax(CUSTOMER_API)
var getCurrentUser = getCustomer({ id: 1 })

關於 curry 的實作,Functional-Light JS 的書裡也有,實作程式碼大概會長這樣:

function curry(fn,arity = fn.length) {
  return (function nextCurried(prevArgs){
    return function curried(nextArg){
      var args = [ ...prevArgs, nextArg ];

      if (args.length >= arity) {
        return fn( ...args );
      }
      else {
        return nextCurried( args );
      }
    };
  })( [] );
}

strict currying & loose currying

另外要提到的另一件事情是,curry 其實有分 strict curryingloose currying,使用起來的差別可以看這段程式碼:

var curriedAjax = R.curry(
  function ajax(url, data, cb) { /*...*/ }
)

// strict currying
curriedAjax(CUSTOMER_API)({ id: 1 })(renderCustomer)
// loose currying
curriedAjax(CUSTOMER_API, { id: 1 })(renderCustomer)

看到當中的差別了嗎?strict currying 一次 function call 只能傳一個 input,如果你要傳更多 input,那就要多一次 function call;loose currying 則是能夠在 function call 中自由的傳多個 input,也就是說一次傳一個或多個 arguments 都可以。

在 Haskell 中每個 function 都是 curry 的,而且都遵照 strict curring 的規則走,不能一次傳多個 arguments;但以目前多數 JavaScript functional utility library 來說,都是 loose currying ,原因應該單純就是方便,畢竟如果每多一個 argument 就要多一次 function call 也是有點惱人的。

Changing Function Shape with Curry

接下來要提到 Curry 的一大妙用了,首先看看這段程式碼:

function add(x, y) {
  return x + y
}

[1, 2, 3, 4, 5].map(function addOne(v) {
  return add(1, v)
})

可以看到我們給 map 的 function 需要再宣告一個 addOne 的 function,那為什麼我們需要這樣做,而而不是直接把 add 給 map 就好了呢?比如像是這樣:

function add(x, y) {
  return x + y
}

[1, 2, 3, 4, 5].map(add(1)) // 目前沒辦法這樣做

原因是 function 的 shape 不一樣(這也是為什麼 function shape matters),map 期待的 function 是 unary 的,而我們的的 add 是 binary 的,所以才造成我們必須要宣告 addOne 的原因。

那有沒有什麼辦法可以簡化這段 code,解決這個問題呢?有!就是 curry

function add(x, y) {
  return x + y
}

[1, 2, 3, 4, 5].map(function addOne(v) {
  return add(1, v)
})

var add = curry(add)

[1, 2, 3, 4, 5].map(add(1))

看到妙用了嗎?我們利用 curry 改變了 function 的 shape,原本是 binary 的 add ,現在被改造成了 unary 了,也因此被改成 point free 的 style。

References