函數緩存

什麼是函數緩存

爲了講明白這個概念,假設你在開發一個天氣app。開始你不知道怎麼作,正好有一個npm包裏有一個getChanceOfRain的方法能夠調用:javascript

import { getChangeOfRain } from 'magic-weather-calculator';

function showWeatherReport() {
  let result = getChangeOfRain();    // 這裏調用
  console.log('The change of rain tomorrow is: ', result);
}
複製代碼

只是這樣會遇到一個問題。不管你作什麼,只要調用這個方法就會消耗100毫秒。因此,若是某個用戶瘋狂點擊「顯示天氣」按鈕,每次點擊app都會有一段時間沒有響應。java

showWeatherReport(); // 觸發計算
showWeatherReport(); // 觸發計算
showWeatherReport(); // 觸發計算
複製代碼

這很不理性。在實際開發中,若是你已經知道結果了,那麼你不會一次一次的計算結果。重用上次的結果纔是上佳選擇。這就是函數緩存。函數緩存也就是緩存函數的結算結果,這樣就不須要一次一次的調用函數git

在下面的例子裏,咱們會調用memoizedGetChangeOfRain()。在這個方法裏咱們會檢查一下是否已經有結果了,而不會每次都調用getChangeOfRain()方法:github

import { getChangeOfRain } from 'magic-weather-calculator';

let isCalculated = false;
let lastResult;

// 添加這個方法
function momoizedGetChangeOfRain() {
  if (isCalculated) {
    // 不須要在計算一次
    return lastResult;
  }
  
  // 第一次運行時計算
  let result = getChangeOfRain();
  
  lastResult = result;
  isCalculated = true;
  
  return result;
}

function showWeatherReport() {
  let result = momoizedGetChangeOfRain();
  console.log('The chance of rain tomottow is:', result);
}

複製代碼

屢次調用showWeatherReport()只會在第一次作計算,其餘都是返回第一次計算的結果。算法

showWeatherReport(); // (!) 計算
showWeatherReport(); // 直接返回結果
showWeatherReport(); // Uses the calculated result
showWeatherReport(); // Uses the calculated result
複製代碼

這就是函數緩存。當咱們說一個函數被緩存了,不是說在javascript語言上作了什麼。而是當咱們知道結果不變的狀況下避免沒必要要的調用。npm

函數緩存和參數

通常的函數緩存模式:瀏覽器

  1. 檢查是否存在一個結果
  2. 若是是,則返回這個結果
  3. 若是沒有,計算結果並保存在之後返回

然而,實際開發中須要考慮某些狀況。好比:getChangeOfRain()方法接收一個城市參數:緩存

function showWeatherReport(city) {
  let result = getChanceOfRain(city); // Pass the city
  console.log("The chance of rain tomorrow is:", result);
}
複製代碼

若是隻是簡單的像以前同樣緩存這個函數,就會產生一個bug:安全

showWeatherReport('Tokyo');  // (!) Triggers the calculation
showWeatherReport('London'); // Uses the calculated answer
複製代碼

發現了麼?東京和倫敦的天氣是很不同的,因此咱們不能直接使用以前的計算結果。也就是說咱們使用函數緩存的時候必需要考慮參數markdown

方法1:保存上一次的結果

最簡單的方法就是緩存結果和這個結果依賴的參數。也就是這樣:

import { getChanceOfRain } from 'magic-weather-calculator';

let lastCity;
let lastResult;

function memoizedGetChanceOfRain(city) {
  if (city === lastCity) { // 檢查城市!
    // 城市相同返回上次的結果
    return lastResult;
  }
  
  // 第一次計算,或者參數變了則執行計算
  let result = getChanceOfRain(city);
  
  // 保留參數和結果.
  lastCity = city;
  lastResult = result;
  return result;
}

function showWeatherReport(city) {
  // 參數傳遞給緩存的參數
  let result = memoizedGetChanceOfRain(city);
  console.log("The chance of rain tomorrow is:", result);
}
複製代碼

注意這個例子和第一個例子的些許不一樣。再也不是直接返回上次的計算結果,而是比較city === lastCity。若是中途城市發生了變化就要從新計算結果。

showWeatherReport('Tokyo');  // (!) 計算
showWeatherReport('Tokyo');  // 使用緩存結果
showWeatherReport('Tokyo');  // 使用緩存結果
showWeatherReport('London'); // (!) 從新計算
showWeatherReport('London'); // 使用緩存結果
複製代碼

這樣雖然修改了第一個例子的bug,可是也不老是最好的解決辦法。若是每次調用參數都不同,上面的解決方法就沒什麼用處了。

showWeatherReport('Tokyo');  // (!) 執行計算
showWeatherReport('London'); // (!) 執行計算
showWeatherReport('Tokyo');  // (!) 執行計算
showWeatherReport('London'); // (!) 執行計算
showWeatherReport('Tokyo');  // (!) 執行計算
複製代碼

不管什麼時候使用函數緩存都要檢查下是否是真的有幫助!

方法2:保留多個結果

另外一件咱們能夠作的就是保留多個結果。雖然咱們能夠爲每一個參數都定義一個變量,好比:lastTokyoResult, lastLondonResult等。使用Map看起來是一個更好的方法。

let resultsPerCity = new Map();

function memoizedGetChangeOfRain(city) {
  if (resultsPerCity.has(city)) {
    // 返回已經存在的結果
    return resultsPerCity.get(city);
  }
  
  // 第一次獲取城市數據
  let result = getChangeOfRain(city);
  
  // 保留整個城市的數據
  resultsPerCity.set(city, result);
  
  return result;
}

function showWeatherReport(city) {
  let result = memoizedGetChangeOfRain(city);
  console.log('The chance of rain tomorrow is:', result);
}
複製代碼

整個方法和適合咱們的用例。由於它只會在第一次獲取城市數據的時候計算。使用相同的城市獲取數據的時候都會返回已經保存在Map裏的數據。

showWeatherReport('Tokyo');  // (!) 執行計算
showWeatherReport('London'); // (!) 執行計算
showWeatherReport('Tokyo');  // 使用緩存結果
showWeatherReport('London'); // 使用緩存結果
showWeatherReport('Tokyo');  // 使用緩存結果
showWeatherReport('Paris');  // (!) 執行計算
複製代碼

然而這樣的方法也不是沒有缺點。尤爲在咱們城市參數不斷增長的狀況下,咱們保存在Map裏的數據會不斷增長。

因此,這個方法在得到性能提高的同時在無節制的消耗內存。在最壞的狀況下會形成瀏覽器tab的崩潰。

其餘方法

在「只保存上一個結果」和「保存所有結果」之間還有不少其餘的辦法。好比,保存最近使用的最後N個結果,也就是我麼熟知的LRU,或者「最近最少使用」緩存。這些都是在Map以外添加其餘邏輯的方法。你也能夠刪除某些時間以後刪掉過去的數據,就如同瀏覽器在緩存過時以後會把他們刪掉同樣。若是參數是一個對象(不是上例所示的字符串),咱們可使用WeakMap來代替Map。現代一點的瀏覽器都支持。使用WeakMap的好處是在做爲key的對象不存在的時候會把鍵值對都刪除。函數緩存是一個很是靈活的技術,你能夠根據具體狀況使用不一樣的策略。

函數緩存和函數純度

咱們知道函數緩存不老是安全的。

假設getChangeOfRain()方法不接受一個城市做爲參數,而是直接接收用戶輸入:

function getChangeOfRain() {
  // 顯示輸入框
  let city = prompt('Where do you live?');
  
  // 其餘代碼
}

// 咱們的代碼
function showWeatherReport() {
  let result = getChangeOfRain();
  console.log('The chance of rain tomorrow is:', result);
}
複製代碼

每次調用showWeatherReport()方法都會出現一個輸入框。咱們能夠輸入不一樣的城市,在console裏看到不一樣的結果。可是若是緩存了getChanceOfRain()方法,咱們只會看到一個輸入框!無法輸入一個不一樣的城市。

因此函數緩存只有在那個函數是純函數的狀況下才是安全的。也就是說:只讀取參數,不和外界交互。一個純函數,調用一次或者使用以前的緩存結果都是無所謂的。

這也是爲何在一個複雜的算法裏,把僅僅計算的代碼和作什麼的代碼分離的緣由。純計算的方法能夠安全的緩存來避免屢次調用。而那些什麼的方法無法作相同的處理。

// 若是這個方法值作計算的話,那麼能夠被稱爲純函數
// 對它使用函數緩存是安全的。
function getChanceOfRain(city) {
  // ...計算代碼...
}

// 這個方法要顯示輸入框給用戶,因此不是純函數
function showWeatherReport() {
  // 這裏顯示輸入框
  let city = prompt('Where do you live?');
  let result = getChanceOfRain(city);
  console.log("The chance of rain tomorrow is:", result);
}
複製代碼

如今能夠安全的對getChanceOfRain()作函數緩存。--由於它接受city做爲參數,而不是彈出一個輸入框。換句話說,它是純函數。

每次調用showWeatherReport()仍是會看到輸入框。可是獲得結果以後對應的計算是能夠避免的。

重用函數緩存

若是你要緩存不少個方法,爲每一個方法寫一次緩存有點重複勞動。這個是能夠自動化的,一個方法就能夠搞定。

咱們用第一個例子來演示:

let isCalculated = false;
let lastResult;

function memoizedGetChanceOfRain() {
  if (isCalculated) {
    return lastResult;
  }
  
  let result = getChanceOfRain();
  lastResult = result;
  isCalculated = true;
  
  return result;
}
複製代碼

以後咱們把這些步驟都放在一個叫作memoize的方法裏:

function memoize() {
  let isCalculated = false;
  let lastResult;
  
  function memoizedGetChanceOfRain() {
    if (isCalculated) {
      return lastResult;
    }
    
    let result = getChanceOfRain();
    lastResult = result;
    isCalculated = true;
    return result;
  }
}
複製代碼

咱們要讓這個方法更加有用,不只僅是計算下雨的機率。因此咱們要添加一個方法參數,就叫作fn

function memoize(fn) { // 聲明fn參數
  let isCalculated = false;
  let lastResult;
  
  function memoizedGetChanceOfRain() {
    if (isCalculated) {
      return lastResult;
    }
    let result = fn(); // 調用傳入的方法參數
    lastResult = result;
    isCalculated = true;
    return result;
  }
}
複製代碼

最後把memoizedGetChanceOfRain()重命名爲memoizedFn並返回:

function memoize(fn) {
  let isCalculated = false;
  let lastResult;
  
  return function memoizedFn() {
    if (isCalculated) {
      return lastResult;
    }
    
    let result = fn();
    lastResult = result;
    isCalculated = true;
    return result;
  }
}
複製代碼

咱們獲得了一個能夠重用的緩存函數。

如今咱們最開始的例子能夠改爲:

import { getChanceOfRain } from 'magic-weather-calculator';

let memoizedGetChanceOfRain = memoize(getChanceOfRain);

function showWeatherReport() {
  let result = memoizedGetChanceOfRain();
  console.log('The chance of rain tomorrow is:', result);
}
複製代碼

isCalculatedlastResult還在,可是在memoize方法內。也就是說他們是閉包的一部分了。咱們能夠在任何地方使用memoize方法了,每次都獨立緩存。

import { getChanceOfRain, getNextEarthquake, getCosmicRaysProbability } from 'magic-weather-calculator';

let momoizedGetChanceOfRain = memoize(getChanceOfRain);
let memoizedGetNextEarthquake = memoize(getNextEarthquake);
let memoizedGetCosmicRaysProbability = memoize(getCosmicRaysProbability);
複製代碼

這裏memoize的目的是生成方法的緩存版本。這樣咱們就不要每次都寫那麼多重複代碼了。

回顧

如今咱們能夠快速的回顧一下。函數緩存是一個讓你的程序運行加快的方法。若是有一段代碼只作計算(純函數)那麼這段代碼就能夠經過函數緩存避免爲同一個結果而執行沒有必要的重複計算。

咱們能夠緩存最後的N個結果,也能夠是所有的結果。這些須要你根據實際的狀況作取捨。

你本身實現memoize方法並不困難,同事也有一些包幫你作這件事情。這裏有Lodash的實現。

最核心的部分基本都是這樣的:

import { getChanceOfRain } from 'magic-weather-calculator';

function showWeatherReport() {
  let result = getChanceOfRain();
  console.log('The chance of rain tomorrow is:', result);
}
複製代碼

會變成:

import { getChanceOfRain } from 'magic-weather-calculator';

let isCalculated = false;
let lastResult;

function memoizedGetChanceOfRain() {
  if (isCalculated) {
    return lastResult;
  }
  let result = getChanceOfRain();
  lastResult = result;
  isCalculated = true;
  return result;
}

function showWeatherReport() {
  let result = memoizedGetChanceOfRain();
  console.log("The chance of rain tomorrow is:", result);
}
複製代碼

合理的使用函數緩存會帶來實際的性能提高。固然,要當心可能帶來的複雜度和潛在的bug。

備註

原文在這裏:whatthefork.is/memoization

相關文章
相關標籤/搜索