[譯] ES6中的尾調用優化

原文:http://exploringjs.com/es6/ch_tail-calls.htmlhtml

ECMAScript 6 提供了尾調用優化(tail call optimization)功能,以使得對某些函數的調用不會形成調用棧(call stack)的增加。本文解釋了這項功能,以及其帶來的好處。

  • 1. 什麼是尾調用優化
    • 1.1. 正常的執行
    • 1.2. 尾調用優化
  • 2. 檢查函數調用是否在尾部發生
    • 2.1. 表達式中的尾調用
    • 2.2. 聲明語句中的尾調用
    • 2.3. 尾調用優化只在嚴格模式下有效
    • 2.4. 單獨的函數調用不算在尾部
  • 3. 尾遞歸函數
    • 3.1. 尾遞歸循環

1. 什麼是尾調用優化?

粗略的來講,若是當一個函數所作的最後一件事是調用了另外一個函數,然後者不須要返回調用者函數中再去作任何動做時;以及由此可知,在這種狀況下沒有調用者的額外信息須要被儲存在調用棧(call stack)上,函數間的調用更像一種goto跳轉的時候 -- 這種調用就被成爲尾調用(tail call),此時使得內存棧再也不增加的行爲就稱爲尾調用優化(TCO - tail call optimization)。es6

舉個例子來更好的理解下TCO。首先說明一下是否用TCO的區別:bash

function id(x) {
    return x; // (A)
}
function f(a) {
    const b = a + 1;
    return id(b); // (B)
}
console.log(f(2)); // (C)
複製代碼

1.1 正常的執行

假設有一個JS引擎經過 存儲本地變量並返回棧上的地址 來管理方法調用。該引擎會如何執行上述代碼呢?
app

Step 1. 最初,棧上只有全局變量idf函數

棧會對當前做用域的狀態(包括本地變量、參數等)進行編碼,造成被稱爲「調用幀」(frame)的一塊。
優化

Step 2. 在代碼中的C行,f()被調用:首先,將要return到的位置被記錄在棧中;而後f的參數a被分配並執行。ui

棧如今看起來是這樣的:共有兩個調用幀,一個是位於底部的全局做用域,另外一個是其上方 的f()
this

Step 3. id() 在B行中被調用。再次造成了一個調用幀,包含了id將要返回到的地址及其參數x被分配和調用的值。編碼

Step 4. 在行A,結果x被返回。id的調用棧被移除,執行過程跳轉到其調用幀中存儲的要return的位置,也就是行B。(處理返回值有多種途徑,最多見的兩種是將結果留在棧中和在寄存器中處理之,此處按下不表)spa

棧如今是這副模樣的了:

Step 5. 在行B中,從id中返回的值將繼續返回給f的調用者。照舊,最上面的調用幀被移除,執行過程跳轉到要return的位置 -- 行C。

Step 6. 行C接收到返回值3並完成打印工做。

1.2 尾調用優化

function id(x) {
    return x; // (A)
}
function f(a) {
    const b = a + 1;
    return id(b); // (B)
}
console.log(f(2)); // (C)
複製代碼

回顧上個章節的過程,其實 step 5 是多餘的。行B中發生的所有事情其實只不過是把id()中返回的值傳遞給行C罷了。理想狀況是,id()能夠自行完成這一步,而跳過二傳手 step 5。

能夠經過對行B的函數調用採起不同的實現方式來達成以上目的。棧在調用發生前是這樣的:

檢查此次調用就會發現,它是f()的最後一個行爲。一旦id()完成,f()剩餘執行的惟一行爲就是把前者的結果返回給自身的調用者。所以,f中的變量就不須要了,其調用幀也就能夠在此次調用以前被移除了。賦給id()的將要return地址直接能夠是f的return地址,也就是行C了。在id()執行期間,棧看起來就是這樣的:

id()返回了數值3,或者能夠說它爲f()返回了這個值;由於經過行C,該值被傳遞給了f的調用者。

不難發現,行B的函數調用就是一個尾調用。這樣的調用能夠在棧0增加的狀況下完成。要判斷函數調用是不是尾調用,必須檢查其是否處於尾部(好比最後一個行爲)。下一章節將講述如何作到。

2. 檢查函數調用是否在尾部發生

咱們已經瞭解到尾調用能夠被更有效率的執行,那麼如何認定一個尾調用呢?

首先,調用函數的方式是無所謂的。下列調用若是出如今尾部,就均可以被優化:

  • 函數調用: func(···)
  • 方法調用: obj.method(···)
  • 經過 call(): func.call(···)
  • 經過 apply(): func.apply(···)

2.1 表達式中的尾調用

箭頭函數能夠用表達式做爲方法體。對於尾調用優化,所以必須找出表達式中函數調用的尾部。只有下列表達式會包含尾調用:

  • 條件操做符 (? :)
  • 邏輯或 (||)
  • 邏輯與 (&&)
  • 逗號 (,)

分別來舉例看一下:

2.1.1 條件操做符 (? :)
const a = x => x ? f() : g();
複製代碼

f() 和 g() 都在尾部。

2.1.2 邏輯或 (||)
const a = () => f() || g();
複製代碼

f() 不在尾部,g() 在尾部。至於爲何,看看下面的等價代碼就知道了:

const a = () => {
    const fResult = f(); // not a tail call
    if (fResult) {
        return fResult;
    } else {
        return g(); // tail call
    }
};
複製代碼

邏輯或操做符的結果依賴於f()的結果,因此是g(),而非f()的方法調用(調用者在其返回後又作了些什麼)處於尾部。

2.1.3 邏輯與 (&&)
const a = () => f() && g();
複製代碼

一樣,f() 不在尾部,g() 在尾部:

const a = () => {
    const fResult = f(); // not a tail call
    if (!fResult) {
        return fResult;
    } else {
        return g(); // tail call
    }
};
複製代碼

理由和邏輯或相同。

2.1.4 逗號 (,)
const a = () => (f() , g());
複製代碼

依然是,f() 不在尾部,g() 在尾部:

const a = () => {
    f();
    return g();
}
複製代碼

2.2 聲明語句中的尾調用

對於聲明語句,下列規則適用,只有這些混合聲明語句會包含尾調用:

  • 塊 (用 {}界定,有時會有一個label)
  • if: 包括邏輯上的 「then」 和 「else」 子句
  • do-while, while, for: 在其循環體中
  • switch: 在其判斷體中
  • try-catch: 只在 catch 子句中,try 子句將 catch 子句做爲上下文,致使沒法被優化
  • try-finally, try-catch-finally: 只在 finally 子句中,它會成爲其餘子句的上下文

對於全部原子(非混合)聲明語句,只有return會包含尾調用。其餘此類聲明語句都有沒法被優化的上下文。以下所示,當expr部分包含尾調用時,下列聲明語句就包含尾調用。

return «expr»;
複製代碼

2.3 尾調用優化只在嚴格模式下有效

在非嚴格模式下,大多數引擎會包含下面兩個屬性,以便開發者檢查調用棧:

  • func.arguments: 表示對 func最近一次調用所包含的參數
  • func.caller: 引用對 func最近一次調用的那個函數

在尾調用優化中,這些屬性再也不有用,由於相關的信息可能以及被移除了。所以,嚴格模式(strict mode)禁止這些屬性,而且尾調用優化只在嚴格模式下有效。

2.4 單獨的函數調用不算在尾部

下面的代碼中,對bar() 的函數調用不算在尾部:

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

緣由在於foo()的最後一個動做不是對bar() 的函數調用,而是隱式的返回了undefined。換句話說,foo()的行爲以下:

function foo() {
    bar();
    return undefined;
}
複製代碼

調用者能夠依賴一個老是返回undefinedfoo();但若是對bar()作了尾調用優化,那麼其返回值就有可能改變了foo的行爲。

所以,若是想要bar()成爲一個尾調用,就得改爲這樣:

function foo() {
    return bar(); // tail call
}
複製代碼

3. 尾遞歸函數

若是一個函數的主遞歸調用發生在尾部,那這個函數就是尾遞歸。

譬如,下面的階乘函數不是尾遞歸,由於行A中的主遞歸調用不在尾部:

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

能夠用一個輔助方法facRec()來使factorial()成爲尾遞歸。行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)
    }
}
複製代碼

這樣,一些非尾遞歸的函數就能夠轉化成尾遞歸了。

3.1 尾遞歸循環

尾調用優化使得在遞歸循環中不增加調用棧成爲可能。下面舉兩個例子。

3.1.1 forEach()
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
複製代碼
3.1.2 findIndex()
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複製代碼



(end)

----------------------------------------

長按二維碼或搜索 fewelife 關注咱們哦
相關文章
相關標籤/搜索