1. 程式人生 > >尾呼叫及遞迴優化

尾呼叫及遞迴優化

<

ES6 規範中添加了對尾呼叫優化的支援,雖然目前來看支援情況還不是很好,但瞭解其原理還是很有必要的,有助於我們編寫高效的程式碼,一旦哪天引擎支援該優化了,將會收穫效能上的提升。

討論尾呼叫前,先看函式正常呼叫時其形成的堆疊(stack frame)情況。

函式的呼叫及呼叫堆疊

先看一個概念:呼叫堆疊(call stack),或叫呼叫幀(stack frame)。

我理解的呼叫堆疊:因為函式呼叫後,流程會進入到被呼叫函式體,此時需要傳入一些入參,這些入參需要存放到一個位置,另外當函式呼叫結束後,執行流程需要返回到被呼叫的地方,這個返回的地方也需要被記錄下來。所以會為這次函式呼叫分配一個儲存區域,存放呼叫時傳入的入參,呼叫結束後返回的地址。這個儲存區域儲存的都是跟本次函式呼叫相關的資料,所以,它是該函式的呼叫堆疊。

考察下面的示例程式碼:

function g(x) {
  return x; // (C)
}
function f(a) {
  let b = a + 1;
  return g(b); // (B)
}
console.log(f(2)); // (A)

下面模擬 JaavScript 引擎在執行上面程式碼時的流程,

  • 首先對於全域性作用哉來說,存在兩個字面量(不算上全域性 window) f,g,所以一開始執行時,已經預設有一個全域性的堆疊資訊了,看起來大概是這樣子:
+-------------------------------------+
|                                     |
|         f=function(a){...}          |
|         g=function(x){...}          |
|                                     |
+-------------------------------------+
  • 呼叫 f(2) 並生成呼叫堆疊,程式碼執行流程進入 f 的函式體中。形成了第一個 stack frame,裡面有 f(2) 入參 a,函式體中宣告的變數 b 以及被呼叫的位置 A。此時的堆疊是下面的樣子:
+-------------------------------------+
|                                     |
|         a=2                         |
|         b=2+1                       |
|         Line A                      |
|                                     |
+-------------------------------------+
|                                     |
|         f=function(a){...}          |
|         g=function(x){...}          |
|                                     |
+-------------------------------------+
  • 在函式 f 的函式體中,呼叫 g 程式碼流程進入 g 的函式體,同樣,為其生成相應的呼叫堆疊,儲存入參 x,呼叫位置 B
+-------------------------------------+
|                                     |
|         x=3                         |
|         Line B                      |
|                                     |
+-------------------------------------+
|                                     |
|         a=2                         |
|         b=2+1                       |
|         Line A                      |
|                                     |
+-------------------------------------+
|                                     |
|         f=function(a){...}          |
|         g=function(x){...}          |
|                                     |
+-------------------------------------+
  • 函式 greturn 進行返回後,因為 g 已經完成使命,其相關的呼叫堆疊被銷燬。程式碼執行流程回到 g 被呼叫的地方 A,相當於程式碼執行流程回到了 f 函式體中。此時的堆疊是這樣的:
+-------------------------------------+
|                                     |
|         a=2                         |
|         b=2+1                       |
|         Line A                      |
|                                     |
+-------------------------------------+
|                                     |
|         f=function(a){...}          |
|         g=function(x){...}          |
|                                     |
+-------------------------------------+
  • f 函式體中拿到 g 的返回後,什麼也沒幹,直接 return 返回,此時結束了 f 的執行,流程回到 f 被呼叫的地方 A,即流程回到全域性作用域,銷燬 f 的呼叫堆疊。此時的堆疊為:
+-------------------------------------+
|                                     |
|         f=function(a){...}          |
|         g=function(x){...}          |
|                                     |
+-------------------------------------+
  • 全域性作用域中,得到返回值後將其列印輸出結束了整段程式碼。

尾呼叫

如果一個函式最後一步是呼叫另一個函式,則這個呼叫為尾呼叫。比如:

function g(x) {
  return x;
}
function f(y) {
  var a = y + 1;
  return g(a);
}

但下面這個就不算尾呼叫了:

function g(x) {
  return x;
}
function f(y) {
  var a = y + 1;
  return g(a) + 1;
}

因為這裡 f 中最後一步操作並不是對 g 的呼叫,在拿到 g 函式的返回後還進行了另外的操作 g(a)+1。其程式碼相當於如下的形式:

function g(x) {
  return x;
}
function f(y) {
  var a = y + 1;
  var tmp = g(a);
  var result = tmp + 1;
  return result;
}

經過改造後就能很明顯看出,其中對 g 的呼叫不是尾呼叫了。

尾呼叫的判定

函式的呼叫形式

首先,JavaScript 中呼叫函式是多樣的,只要這是另一函式中最後一步操作,都可稱作尾呼叫。比如以下的函式呼叫形式:

  • 正常函式呼叫:fn(...)
  • 訪問物件屬性並呼叫:obj.method(...)
  • 通過 Function.prototype.all 呼叫:fn.call(...)
  • 通過 apply 呼叫:fn.apply(...)

表示式中的尾呼叫

因為剪頭函式的函式體可以是表示式,所以表示式最終的步驟是什麼決定了該剪頭函式中是否包含尾呼叫。

能夠形成尾呼叫的表示式有如下這些,

  • 三元表示式:?:
const a = x => x ? f() : g();

其中 f()g() 都是尾呼叫,表示式最終結果要麼是對 f() 的呼叫,要麼是對 g() 的呼叫。

  • 邏輯或表示式:||
const a = () => f() || g();

上面示例中, f() 不是尾呼叫,而對 g() 的呼叫是尾呼叫。這點可通過下面轉換後的等效程式碼看出來:

const a = () => {
    let fResult = f(); // not a tail call
    if (fResult) {
        return fResult;
    } else {
        return g(); // tail call
    }
};

a||b 的意思是如果 a 真則返回 a 的值,否則繼續判定 b,此時無論 b 真假與否,整個表示式的返回都是 b

所以從上面的轉換中看出,對於 f() 的呼叫,需要進一步對其返回值進行處理,而不是直接將 f() 進行返回,所以 f() 並不是函式體中最後一步操作,但 g() 是,因為對於 g() 的返回值沒有其他操作,而是直接返回。

  • 邏輯與表示式:&&
const a = () => f() && g();

與邏輯或表示式雷同,這裡需要對 f() 的返回值進一步處理,進行判定後決定整個表示式的執行,所以 f() 不是尾呼叫,而 g() 是。 其等效的程式碼如下:

const a = () => {
    let fResult = f(); // not a tail call
    if (!fResult) {
        return fResult;
    } else {
        return g(); // tail call
    }
};
  • 逗號表示式:expression,expression
const a = () => (f() , g());

這個就比較好理解了,逗號表示式是依次執行的,整個表示式返回結果為最後一個表示式。所以這裡 f() 不是尾呼叫,g() 是。

需要注意的是,單獨的函式呼叫並不是尾呼叫,比如下面這樣:

function foo() {
    bar(); // this is not a tail call in JS
}

這裡 bar() 並不是尾呼叫,因為函式體中,最後一步操作並不是返回 bar(),而是隱式地返回 undefined。其等效的程式碼為:

function foo() {
    bar();
    return undefined;
}

像這種情況其實並不能簡單地通過加一個 return 將其變成尾呼叫。因為這樣就改變了 foo 的返回值,其功能有可能就變了,呼叫方就不能再依賴於 foo() 返回的是 undefined

尾呼叫優化

回到最開始的那個示例:

function g(x) {
  return x; // (C)
}
function f(a) {
  let b = a + 1;
  return g(b); // (B)
}
console.log(f(2)); // (A)

這裡 f(a) 中就包含一個尾呼叫 g(b)。並且通過前面的呼叫堆疊的分析,可以知道,每一次函式呼叫都需要生成相應的呼叫堆疊,會有儲存的開銷。

如果仔細看前面呼叫過程的分析,會發現,在 f(a) 函式體中,當我們呼叫 g(b) 時,就可以將 f(a) 的呼叫堆疊直接銷燬了,其中 f(a) 相關的內容不會再被用到,除了其中關於 f(a) 被呼叫位置的記錄。這個位置需要在呼叫 g(b) 後返回。

所以,完全可以省掉 f(a) 作為中間商這一步,將 f(a) 反返回地址告訴 g(x),這樣 g(x) 在執行完成後直接返回到程式碼中 A 標記處。

上面優化後的執行場景下,其呼叫堆疊的分配則變成了:

  • 呼叫 f(2),為其分配堆疊
  • 呼叫 g(b),為其分配堆疊。同時發現 g(b)f(a) 的末尾直接被返回, f(a) 中儲存的變數 b 這些在後續執行中不會再被用到,將 f(a) 的呼叫規模銷燬。
  • 執行 g(b) 並返回到 A 標記處。

最最開始不同之處在於,在建立 g(b) 的呼叫堆疊時,同時銷燬了 f(a) 的呼叫堆疊。這當然是 JavaScript 引擎去實現的。

其好處顯而易見,在函式連續呼叫過程中,堆疊數沒有增加。假如不止一次尾呼叫, g(x) 中還存在對另外函式的尾呼叫,這樣的優化可以持續下去,也就是說,堆疊數並沒有隨著函式呼叫的增多而增加。

利用這個特性,我們可以將一些不是尾呼叫的函式想辦法改成尾呼叫,達到優化呼叫堆疊的目的,這便是尾呼叫優化。

尾遞迴呼叫

如果函式的尾呼叫是對自己的呼叫,便形成了遞迴呼叫,同時還是歸呼叫,所以合稱 尾遞迴呼叫。相比於傳統的遞迴,呼叫堆疊極速增加的不同,尾遞迴呼叫的呼叫堆疊是恆定的,這由前面的分析可知。

所以尾呼叫優化特別適合用於遞迴的情況,收益會很大。

計算階乘就是典型的遞迴場景:

function factorial(x) {
    if (x <= 0) {
        return 1;
    } else {
        return x * factorial(x-1); // (A)
    }
}

但上面這樣的實現並不是尾呼叫。不過可以通過新增一個額外的方法來達到優化成尾呼叫的目的。

function factorial(n) {
    return facRec(n, 1);
}
function facRec(x, acc) {
    if (x <= 1) {
        return acc;
    } else {
        return facRec(x-1, x*acc); // (A)
    }
}

此時 facRec 便是一個尾遞迴呼叫。

其他示例

利用尾遞迴呼叫實現迴圈語句。

function forEach(arr, callback, start = 0) {
    if (0 <= start && start < arr.length) {
        callback(arr[start], start, arr);
        return forEach(arr, callback, start+1); // tail call
    }
}
forEach(['a', 'b'], (elem, i) => console.log(`${i}. ${elem}`));

// Output:
// 0. a
// 1. b
function findIndex(arr, predicate, start = 0) {
    if (0 <= start && start < arr.length) {
        if (predicate(arr[start])) {
            return start;
        }
        return findIndex(arr, predicate, start+1); // tail call
    }
}
findIndex(['a', 'b'], x => x === 'b'); // 1

相關資源

  • 2ality - Tail call optimization in ECMAScript 6
  • ES6 尾呼叫優化在各引擎中的實現支援情況
  • What Is Tail Call Optimization?