1. 程式人生 > >函數語言程式設計 - 函式快取Memoization

函數語言程式設計 - 函式快取Memoization

函數語言程式設計風格中有一個“純函式”的概念,純函式是一種無副作用的函式,除此之外純函式還有一個顯著的特點:對於同樣的輸入引數,總是返回同樣的結果。在平時的開發過程中,我們也應該儘量把無副作用的“純計算”提取出來實現成“純函式”,尤其是涉及到大量重複計算的過程,使用純函式+函式快取的方式能夠大幅提高程式的執行效率。本文的主題即是函式快取實現的及應用,必須強調的是Memoization起作用的物件只能是純函式
函式快取的概念很簡單,先來一個最簡單的實現來說明一下:

function memoize(func) {
  const cache = {};
  return function(...args) {
    const key = JSON.stringify(args)
    return cache[key] || (cache[key] = func.apply(this, args))
  }
}

memoize就是一個高階函式,接受一個純函式作為引數,並返回一個函式,結合閉包來快取原純函式執行的結果,可以簡單的測試一下:

function sum(n1, n2) {
  const sum = n1 + n2
  console.log(`${n1}+${n2}=${sum}`)
  return sum
}
const memoizedSum = memoize(sum)
memoizedSum(1, 2) // 會打印出:1+2=3
memoizedSum(1, 2) // 沒有輸出

memoizedSum在第一次執行時將執行結果快取在了閉包中的快取物件cache中,因此第二次執行時,由於輸入引數相同,直接返回了快取的結果。
上面memoize

的實現能夠滿足簡單場景下純函式結果的快取,但要使其適用於更廣的範圍,還需要重點考慮兩個問題:

  • 1.快取器cache物件的實現問題
  • 2.快取器物件使用的key值計算問題

下面著重完善這兩個問題。

1.cache物件問題

上述實現版本使用普通物件作為快取器,這是我們慣用的手法。問題不大,但仍要注意,例如最後返回值的語句,存在一個容易忽略的問題:如果cache[key]為“假值”,比如0、null、false,那會導致每次都會重新計算一次。

    return cache[key] || (cache[key] = func.apply(this, args))

因此為了嚴謹,還是要多做一些判斷,

function memoize(func) {
  const cache = {};
  return function(...args) {
    const key = JSON.stringify(args)
    if(!cache.hasOwnProperty(key)) {
      cache[key] = func.apply(this, args)
    }
    return cache[key]
  }
}

更好的選擇是使用ES6+支援的Map物件

function memoize(func) {
  const cache = new Map()
  return function(...args) {
    const key = JSON.stringify(args)
    if (cache.has(key)) {
      return cache.get(key)
    }
    const result = func.apply(this, args)
    cache.set(key, result)
    return result
  }
}

2.快取器物件使用的key值計算問題

ES6+的支援使得第一個問題很容易就完善了,畢竟這年頭什麼程式碼不是babel加持;而快取器物件key的確定卻是一個讓人腦殼疼的問題。key直接決定了函式計算結果快取的效果,理想情況下,函式引數與key滿足一對一關係,上述實現中我們通過const key = JSON.stringify(args)將引數陣列序列化計算key,在大多數情況下已經滿足了一對一的原則,用在平時的開發中大概也不會有問題。但是需要注意的是序列化將會丟失JSON中沒有等效型別的任何Javascript屬性,如函式或Infinity,任何值為undefined的屬性都將被JSON.stringify忽略,如果值作為陣列元素序列化結果又會有所不同,如下圖所示。

雖然我們很少將這些特殊型別作為函式引數,但也不能排除這種情況。比如下面的例子,函式calc接收兩個普通引數和一個運算元,運算元則執行具體的計算,如果使用上面的方法快取函式結果,可以發現第二次輸入的是減法函式,但仍然打印出結果3而不是-1,原因是兩個引數序列化結果都是[1,2,null],第二次列印的是第一次的快取結果。

function sum(n1, n2, ) {
  const sum = n1 + n2
  return sum
}
function sub(n1, n2, ) {
  const sub = n1 - n2
  return sub
}
function calc(n1, n2, operator){
  return operator(n1, n2)
}
const memoizedCalc = memoize(calc)
console.log(memoizedCalc(1, 2, sum))  // 3
console.log(memoizedCalc(1, 2, sub)) // 3

既然JSON.stringify不能產生一對一的key,那麼有什麼辦法可以實現真正的一對一關係呢,參考Lodash的原始碼,其使用了WeakMap物件作為快取器物件,其好處是WeakMap物件的key只能是物件,這樣如果能夠保持引數物件的引用相同,對應的key也就相同。

function memoize(func) {
  const cache = new WeakMap()
  return function(...args) {
    const key = args[0]
    if (cache.has(key)) {
      return cache.get(key)
    }
    const result = func.apply(this, args)
    cache.set(key, result)
    return result
  }
}

function sum(n1, n2) {
  const sum = n1 + n2
  console.log(`${n1}+${n2}:`, sum)
  return sum
}

function sub(n1, n2, ) {
  const sub = n1 - n2
  console.log(`${n1}-${n2}:`, sub)
  return sub
}

function calc(param){
  const {n1, n2, operator} = param
  return operator(n1, n2)
}
const memoizedCalc = memoize(calc)

const param1 = {n1: 1, n2: 2, operator: sum}
const param2 = {n1: 1, n2: 2, operator: sub}

console.log(memoizedCalc(param1))
console.log(memoizedCalc(param2))
console.log(memoizedCalc(param2))

執行列印的結果為

1+2: 3
3
1-2: -1 // 只在第一次做減法運算時列印
-1
-1 // 第二次執行減法直接打印出結果

使用WeakMap作為快取物件還是有很多侷限性,首選引數必須是物件,再比如我們把上例最後幾行程式碼改成下面的程式碼,會發現後面減法的輸出還是錯誤的,因為前後引數引用的物件都是param1,因此對應的key是相同的,而且在開發過程中我們不太可能一直儲存引數的引用,大對數重讀計算的場景下,我們都會構造新的引數物件,即使有些引數物件看起來長的一樣,但卻對應不同的引用,也就對應不同的key,這就失去了快取的效果。

console.log(memoizedCalc(param1))  // 3
param1.operator = sub
console.log(memoizedCalc(param1)) // 3
console.log(memoizedCalc(param1)) // 3

為了使開發具有最高的靈活性,在Memoization過程中,key的計算最好由開發者自己決定使用何種規則產生與函式結果一一對應的關係,實際上Lodash和Ramda都提供了類似的實現。

function memoize(func, resolver) {
  if (typeof func != 'function' || (resolver != null && typeof resolver != 'function')) {
    throw new TypeError('Expected a function')
  }
  const cache = new Map() //可以根據實際情況使用WeakMap或者{}
  return function(...args) {
    const key = resolver ? resolver.apply(this, args) : args[0]
    if (cache.has(key)) {
      return cache.get(key)
    }
    const result = func.apply(this, args)
    cache.set(key, result)
    return result
  }
}

上述程式碼memoize除了接收需要快取的函式,還接收一個resolver函式,方便使用者自行決定如果計算key

參考
LodashRamda原始碼