原文: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. 最初,棧上只有全域性變數id
和f
。
棧會對當前作用域的狀態(包括本地變數、引數等)進行編碼,形成被稱為“呼叫幀”(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;
}
複製程式碼
呼叫者可以依賴一個總是返回undefined
的foo()
;但如果對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複製程式碼
----------------------------------------