[譯] ES6中的尾呼叫優化

江米小棗tonylua發表於2018-01-04

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

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)。

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

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引擎通過 儲存本地變數並返回棧上的地址 來管理方法呼叫。該引擎會如何執行上述程式碼呢?

Step 1. 最初,棧上只有全域性變數idf

棧會對當前作用域的狀態(包括本地變數、引數等)進行編碼,形成被稱為“呼叫幀”(frame)的一塊。

Step 2. 在程式碼中的C行,f()被呼叫:首先,將要return到的位置被記錄在棧中;然後f的引數a被分配並執行。

棧現在看起來是這樣的:共有兩個呼叫幀,一個是位於底部的全域性作用域,另一個是其上方 的f()

Step 3. id() 在B行中被呼叫。再次形成了一個呼叫幀,包含了id將要返回到的地址及其引數x被分配和呼叫的值。

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

棧現在是這副模樣的了:

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 關注我們哦 [譯] ES6中的尾呼叫優化



相關文章