尾呼叫
1. 定義
尾呼叫是函數語言程式設計中一個很重要的概念,當一個函式執行時的最後一個步驟是返回另一個函式的呼叫,這就叫做尾呼叫。
注意這裡函式的呼叫方式是無所謂的,以下方式均可:
函式呼叫: func(···)
方法呼叫: obj.method(···)
call呼叫: func.call(···)
apply呼叫: func.apply(···)
並且只有下列表示式會包含尾呼叫:
條件運算子: ? :
邏輯或: ||
邏輯與: &&
逗號: ,
依次舉例:
const a = x => x ? f() : g();
// f() 和 g() 都在尾部。
const a = () => f() || g();
// g()有可能是尾呼叫,f()不是
// 因為上述寫法和下面的寫法等效:
const a = () => {
const fResult = f(); // not a tail call
if (fResult) {
return fResult;
} else {
return g(); // tail call
}
}
// 只有當f()的結果為falsey的時候,g()才是尾呼叫
const a = () => f() && g();
// g()有可能是尾呼叫,f()不是
// 因為上述寫法和下面的寫法等效:
const a = () => {
const fResult = f(); // not a tail call
if (fResult) {
return g(); // tail call
} else {
return fResult;
}
}
// 只有當f()的結果為truthy的時候,g()才是尾呼叫
const a = () => (f() , g());
// g()是尾呼叫
// 因為上述寫法和下面的寫法等效:
const a = () => {
f();
return g();
}
2. 尾呼叫最佳化
函式在呼叫的時候會在呼叫棧(call stack)中存有記錄,每一條記錄叫做一個呼叫幀(call frame),每呼叫一個函式,就向棧中push一條記錄,函式執行結束後依次向外彈出,直到清空呼叫棧,參考下圖:
function foo () { console.log(111); }
function bar () { foo(); }
function baz () { bar(); }
baz();
造成這種結果是因為每個函式在呼叫另一個函式的時候,並沒有 return 該呼叫,所以JS引擎會認為你還沒有執行完,會保留你的呼叫幀。
baz() 裡面呼叫了 bar() 函式,並沒有 return 該呼叫,所以在呼叫棧中保持自己的呼叫幀,同時 bar() 函式的呼叫幀在呼叫棧中生成,同理,bar() 函式又呼叫了 foo() 函式,最後執行到 foo() 函式的時候,沒有再呼叫其他函式,這裡沒有顯示宣告 return,所以這裡預設 return undefined。
foo() 執行完了,銷燬呼叫棧中自己的記錄,依次銷燬 bar() 和 baz() 的呼叫幀,最後完成整個流程。
如果對上面的例子做如下修改:
function foo () { console.log(111); }
function bar () { return foo(); }
function baz () { return bar(); }
baz();
這裡要注意:尾呼叫最佳化只在嚴格模式下有效。
在非嚴格模式下,大多數引擎會包含下面兩個屬性,以便開發者檢查呼叫棧:
- func.arguments: 表示對 func最近一次呼叫所包含的引數
- func.caller: 引用對 func最近一次呼叫的那個函式
在尾呼叫最佳化中,這些屬性不再有用,因為相關的資訊可能以及被移除了。因此,嚴格模式(strict mode)禁止這些屬性,並且尾呼叫最佳化只在嚴格模式下有效。
如果尾呼叫最佳化生效,流程圖就會變成這樣:
我們可以很清楚的看到,尾呼叫由於是函式的最後一步操作,所以不需要保留外層函式的呼叫記錄,只要直接用內層函式的呼叫記錄取代外層函式的呼叫記錄就可以了,呼叫棧中始終只保持了一條呼叫幀。
這就叫做尾呼叫最佳化,如果所有的函式都是尾呼叫的話,那麼在呼叫棧中的呼叫幀始終只有一條,這樣會節省很大一部分的記憶體,這也是尾呼叫最佳化的意義。
尾遞迴
1. 定義
先來看一下遞迴,當一個函式呼叫自身,就叫做遞迴。
function foo () {
foo();
}
上面這個操作就叫做遞迴,但是注意了,這裡沒有結束條件,是死遞迴,所以會報棧溢位錯誤的,寫程式碼時千萬注意給遞迴新增結束條件。
那麼什麼是尾遞迴?
前面我們知道了尾呼叫的概念,當一個函式尾呼叫自身,就叫做尾遞迴。
function foo () {
return foo();
}
2. 作用
那麼尾遞迴相比遞迴而言,有哪些不同呢?
我們透過下面這個求階乘的例子來看一下:
function factorial (num) {
if (num === 1) return 1;
return num * factorial(num - 1);
}
factorial(5); // 120
factorial(10); // 3628800
factorial(500000); // Uncaught RangeError: Maximum call stack size exceeded
上面是使用遞迴來計算階乘的例子,作業系統為JS引擎呼叫棧分配的記憶體是有大小限制的,如果計算的數字足夠大,超出了記憶體最大範圍,就會出現棧溢位錯誤。
這裡500000並不是臨界值,只是我用了一個足夠造成棧溢位的數。
如果用尾遞迴來計算階乘呢?
'use strict';
function factorial (num, total) {
if (num === 1) return total;
return factorial(num - 1, num * total);
}
factorial(5, 1); // 120
factorial(10, 1); // 3628800
factorial(500000, 1); // 分情況
// 注意,雖然說這裡啟用了嚴格模式,但是經測試,在Chrome和Firefox下,還是會報棧溢位錯誤,並沒有進行尾呼叫最佳化
// Safari瀏覽器進行了尾呼叫最佳化,factorial(500000, 1)結果為Infinity,因為結果超出了JS可表示的數字範圍
// 如果在node v6版本下執行,需要加--harmony_tailcalls引數,node --harmony_tailcalls test.js
// node最新版本已經移除了--harmony_tailcalls功能
透過尾遞迴,我們把複雜度從O(n)降低到了O(1),如果資料足夠大的話,會節省很多的計算時間。
由此可見,尾呼叫優化對遞迴操作意義重大,所以一些函數語言程式設計語言將其寫入了語言規格。
避免改寫遞迴函式
尾遞迴的實現,往往需要改寫遞迴函式,確保最後一步只呼叫自身。
要做到這一點,需要把函式內部所有用到的中間變數改寫為函式的引數,就像上面的factorial()函式改寫一樣。
這樣做的缺點就是語義不明顯,要計算階乘的函式,為什麼還要另外傳入一個引數叫total?
解決這個問題的辦法有兩個:
1. ES6引數預設值
'use strict';
function factorial (num, total = 1) {
if (num === 1) return total;
return factorial(num - 1, num * total);
}
factorial(5); // 120
factorial(10); // 3628800
2. 用一個符合語義的函式去呼叫改寫後的尾遞迴函式
function tailFactorial (num, total) {
if (num === 1) return total;
return tailFactorial(num - 1, num * total);
}
function factorial (num) {
return tailFactorial(num, 1);
}
factorial(5); // 120
factorial(10); // 3628800
上面這種寫法其實有點類似於做了一個函式柯里化,但不完全符合柯里化的概念。
函式柯里化是指把接受多個引數的函式轉換為接受一個單一引數(最初函式的第一個引數)的函式,並且返回接受餘下引數且返回結果的新函式。
概念看著很繞口,我們來個例子感受一下:
// 普通加法函式
function add (x, y, z) {
return x + y + z;
}
add(1, 2, 3); // 6
// 改寫為柯里化加法函式
function add (x) {
return function (y) {
return function (z) {
return x + y + z;
}
}
}
add(1)(2)(3); // 6
可以看到,柯里化函式透過閉包找到父作用域裡的變數,最後依次相加輸出結果。
透過這個例子,可能看不出為什麼要用柯里化,有什麼好處,這個我們以後再談,這裡先引出一個概念。
是把接受多個引數的函式變換成接受一個單一引數(最初函式的第一個引數)的函式,並且返回接受餘下的引數且返回結果的新函式的技術。
如果用柯里化改寫求階乘的例子:
// 柯里化函式
function curry (fn) {
var _fnArgLength = fn.length;
function wrap (...args) {
var _args = args;
var _argLength = _args.length;
// 如果傳的是所有引數,直接返回fn呼叫
if (_fnArgLength === _argLength) {
return fn.apply(null, args);
}
function act (...args) {
_args = _args.concat(args);
if (_args.length === _fnArgLength) {
return fn.apply(null, _args);
}
return act;
}
return act;
}
return wrap;
}
// 尾遞迴函式
function tailFactorial (num, total) {
if (num === 1) return total;
return tailFactorial(num - 1, num * total);
}
// 改寫
var factorial = curry(tailFactorial);
factorial(5)(1); // 120
factorial(10)(1); // 3628800
這是符合柯里化概念的寫法,在阮一峰老師的文章中是這樣寫的:
function currying(fn, n) {
return function (m) {
return fn.call(this, m, n);
};
}
function tailFactorial(n, total) {
if (n === 1) return total;
return tailFactorial(n - 1, n * total);
}
const factorial = currying(tailFactorial, 1);
factorial(5) // 120
我個人認為,這種寫法其實不是柯里化,因為並沒有將多引數的tailFacrotial改寫為接受單引數的形式,只是換了一種寫法,和下面這樣寫意義是一樣的:
function factorial (num) {
return tailFactorial(num, 1);
}
function tailFactorial (num, total) {
if (num === 1) return total;
return tailFactorial(num - 1, num * total);
}
factorial(5); // 120
factorial(10); // 3628800
結束
這篇文章我們主要討論了尾呼叫最佳化和柯里化。
要注意的是,經過測試,Chrome和Firefox並沒有對尾呼叫進行最佳化,Safari對尾呼叫進行了最佳化。
Node高版本也已經去除了透過--harmony_tailcalls引數啟用尾呼叫最佳化。
有任何問題,歡迎大家留言討論,另附我的部落格網站,快來呀~~
歡迎關注我的公眾號
參考連結
http://www.ruanyifeng.com/blo...
https://juejin.im/post/5a4d89...
https://github.com/lamdu/lamd...