精讀《函數緩存》

1 引言

函數緩存是重要概念,本質上就是用空間(緩存存儲)換時間(跳過計算過程)。前端

對於無反作用的純函數,在合適的場景使用函數緩存是很是必要的,讓咱們跟着 https://whatthefork.is/memoiz... 這篇文章深刻理解一下函數緩存吧!git

2 概述

假設又一個獲取天氣的函數 getChanceOfRain,每次調用都要花 100ms 計算:github

import { getChanceOfRain } from "magic-weather-calculator";
function showWeatherReport() {
  let result = getChanceOfRain(); // Let the magic happen
  console.log("The chance of rain tomorrow is:", result);
}

showWeatherReport(); // (!) Triggers the calculation
showWeatherReport(); // (!) Triggers the calculation
showWeatherReport(); // (!) Triggers the calculation

很顯然這樣太浪費計算資源了,當已經計算過一次天氣後,就沒有必要再算一次了,咱們指望的是後續調用能夠直接拿上一次結果的緩存,這樣能夠節省大量計算。所以咱們能夠作一個 memoizedGetChanceOfRain 函數緩存計算結果:npm

import { getChanceOfRain } from "magic-weather-calculator";
let isCalculated = false;
let lastResult;
// We added this function!
function memoizedGetChanceOfRain() {
  if (isCalculated) {
    // No need to calculate it again.
    return lastResult;
  }
  // Gotta calculate it for the first time.
  let result = getChanceOfRain();
  // Remember it for the next time.
  lastResult = result;
  isCalculated = true;
  return result;
}
function showWeatherReport() {
  // Use the memoized function instead of the original function.
  let result = memoizedGetChanceOfRain();
  console.log("The chance of rain tomorrow is:", result);
}

在每次調用時判斷優先用緩存,若是沒有緩存則調用原始函數並記錄緩存。這樣當咱們屢次調用時,除了第一次以外都會當即從緩存中返回結果:瀏覽器

showWeatherReport(); // (!) Triggers the calculation
showWeatherReport(); // Uses the calculated result
showWeatherReport(); // Uses the calculated result
showWeatherReport(); // Uses the calculated result

然而對於有參數的場景就不適用了,由於緩存並無考慮參數:緩存

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

showWeatherReport("Tokyo"); // (!) Triggers the calculation
showWeatherReport("London"); // Uses the calculated answer

因爲參數可能性不少,因此有三種解決方案:微信

1. 僅緩存最後一次結果

僅緩存最後一次結果是最節省存儲空間的,並且不會有計算錯誤,但帶來的問題就是當參數變化時緩存會當即失效:閉包

import { getChanceOfRain } from "magic-weather-calculator";
let lastCity;
let lastResult;
function memoizedGetChanceOfRain(city) {
  if (city === lastCity) {
    // Notice this check!
    // Same parameters, so we can reuse the last result.
    return lastResult;
  }
  // Either we're called for the first time,
  // or we're called with different parameters.
  // We have to perform the calculation.
  let result = getChanceOfRain(city);
  // Remember both the parameters and the result.
  lastCity = city;
  lastResult = result;
  return result;
}
function showWeatherReport(city) {
  // Pass the parameters to the memoized function.
  let result = memoizedGetChanceOfRain(city);
  console.log("The chance of rain tomorrow is:", result);
}

showWeatherReport("Tokyo"); // (!) Triggers the calculation
showWeatherReport("Tokyo"); // Uses the calculated result
showWeatherReport("Tokyo"); // Uses the calculated result
showWeatherReport("London"); // (!) Triggers the calculation
showWeatherReport("London"); // Uses the calculated result

在極端狀況下等同於沒有緩存:app

showWeatherReport("Tokyo"); // (!) Triggers the calculation
showWeatherReport("London"); // (!) Triggers the calculation
showWeatherReport("Tokyo"); // (!) Triggers the calculation
showWeatherReport("London"); // (!) Triggers the calculation
showWeatherReport("Tokyo"); // (!) Triggers the calculation

2. 緩存全部結果

第二種方案是緩存全部結果,使用 Map 存儲緩存便可:ide

// Remember the last result *for every city*.
let resultsPerCity = new Map();
function memoizedGetChanceOfRain(city) {
  if (resultsPerCity.has(city)) {
    // We already have a result for this city.
    return resultsPerCity.get(city);
  }
  // We're called for the first time for this city.
  let result = getChanceOfRain(city);
  // Remember the result for this city.
  resultsPerCity.set(city, result);
  return result;
}
function showWeatherReport(city) {
  // Pass the parameters to the memoized function.
  let result = memoizedGetChanceOfRain(city);
  console.log("The chance of rain tomorrow is:", result);
}

showWeatherReport("Tokyo"); // (!) Triggers the calculation
showWeatherReport("London"); // (!) Triggers the calculation
showWeatherReport("Tokyo"); // Uses the calculated result
showWeatherReport("London"); // Uses the calculated result
showWeatherReport("Tokyo"); // Uses the calculated result
showWeatherReport("Paris"); // (!) Triggers the calculation

這麼作帶來的弊端就是內存溢出,當可能參數過多時會致使內存無限制的上漲,最壞的狀況就是觸發瀏覽器限制或者頁面崩潰。

3. 其餘緩存策略

介於只緩存最後一項與緩存全部項之間還有這其餘選擇,好比 LRU(least recently used)只保留最小化最近使用的緩存,或者爲了方便瀏覽器回收,使用 WeakMap 替代 Map。

最後提到了函數緩存的一個坑,必須是純函數。好比下面的 CASE:

// Inside the magical npm package
function getChanceOfRain() {
  // Show the input box!
  let city = prompt("Where do you live?");
  // ... calculation ...
}
// Our code
function showWeatherReport() {
  let result = getChanceOfRain();
  console.log("The chance of rain tomorrow is:", result);
}

getChanceOfRain 每次會由用戶輸入一些數據返回結果,致使緩存錯誤,緣由是 「函數入參一部分由用戶輸入」 就是反作用,咱們不能對有反作用的函數進行緩存。

這有時候也是拆分函數的意義,將一個有反作用函數的無反作用部分分解出來,這樣就能局部作函數緩存了:

// If this function only calculates things,
// we would call it "pure".
// It is safe to memoize this function.
function getChanceOfRain(city) {
  // ... calculation ...
}
// This function is "impure" because
// it shows a prompt to the user.
function showWeatherReport() {
  // The prompt is now here
  let city = prompt("Where do you live?");
  let result = getChanceOfRain(city);
  console.log("The chance of rain tomorrow is:", result);
}

最後,咱們能夠將緩存函數抽象爲高階函數:

function memoize(fn) {
  let isCalculated = false;
  let lastResult;
  return function memoizedFn() {
    // Return the generated function!
    if (isCalculated) {
      return lastResult;
    }
    let result = fn();
    lastResult = result;
    isCalculated = true;
    return result;
  };
}

這樣生成新的緩存函數就方便啦:

let memoizedGetChanceOfRain = memoize(getChanceOfRain);
let memoizedGetNextEarthquake = memoize(getNextEarthquake);
let memoizedGetCosmicRaysProbability = memoize(getCosmicRaysProbability);

isCalculatedlastResult 都存儲在 memoize 函數生成的閉包內,外部沒法訪問。

3 精讀

通用高階函數實現函數緩存

原文的例子仍是比較簡單,沒有考慮函數多個參數如何處理,下面咱們分析一下 Lodash memoize 函數源碼:

function memoize(func, resolver) {
  if (
    typeof func != "function" ||
    (resolver != null && typeof resolver != "function")
  ) {
    throw new TypeError(FUNC_ERROR_TEXT);
  }
  var memoized = function () {
    var args = arguments,
      key = resolver ? resolver.apply(this, args) : args[0],
      cache = memoized.cache;

    if (cache.has(key)) {
      return cache.get(key);
    }
    var result = func.apply(this, args);
    memoized.cache = cache.set(key, result) || cache;
    return result;
  };
  memoized.cache = new (memoize.Cache || MapCache)();
  return memoized;
}

原文有提到緩存策略多種多樣,而 Lodash 將緩存策略簡化爲 key 交給用戶本身管理,看這段代碼:

key = resolver ? resolver.apply(this, args) : args[0];

也就是緩存的 key 默認是執行函數時第一個參數,也能夠經過 resolver 拿到參數處理成新的緩存 key。

在執行函數時也傳入了參數 func.apply(this, args)

最後 cache 也再也不使用默認的 Map,而是容許用戶自定義 lodash.memoize.Cache 自行設置,好比設置爲 WeakMap:

_.memoize.Cache = WeakMap;

何時不適合用緩存

如下兩種狀況不適合用緩存:

  1. 不常常執行的函數。
  2. 自己執行速度較快的函數。

對於不常常執行的函數,自己就不須要利用緩存提高執行效率,而緩存反而會長期佔用內存。對於自己執行速度較快的函數,其實大部分簡單計算速度都很快,使用緩存後對速度沒有明顯的提高,同時若是計算結果比較大,反而會佔用存儲資源。

對於引用的變化尤爲重要,好比以下例子:

function addName(obj, name){
  return {
    ...obj,
    name:
  }
}

obj 添加一個 key,自己執行速度是很是快的,但添加緩存後會帶來兩個壞處:

  1. 若是 obj 很是大,會在閉包存儲完整 obj 結構,內存佔用加倍。
  2. 若是 obj 經過 mutable 方式修改了,則普通緩存函數還會返回原先結果(由於對象引用沒有變),形成錯誤。

若是要強行進行對象深對比,雖然會避免出現邊界問題,但性能反而會大幅降低。

4 總結

函數緩存很是有用,但並非全部場景都適用,所以千萬不要極端的將全部函數都添加緩存,僅限於計算耗時、可能重複利用屢次,且是純函數的。

討論地址是: 精讀《函數緩存》· Issue #261 · dt-fe/weekly

若是你想參與討論,請 點擊這裏,每週都有新的主題,週末或週一發佈。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公衆號

版權聲明:自由轉載-非商用-非衍生-保持署名( 創意共享 3.0 許可證

本文使用 mdnice 排版

相關文章
相關標籤/搜索