函數式編程 - 函數緩存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源碼


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM