javascript尾調用與尾遞歸


1  什么是尾調用?

尾調用( Tail Call )是函數式編程的一個重要概念,本身非常簡單,一句話就能說清楚,就是指某個函數的最后一步是調用另一個函數。

function f(x){
    return g(x);
}

上面代碼中,函數 f 的最后一步是調用函數 g ,這就叫尾調用。

以下三種情況,都不屬於尾調用。

//  情況一
function f(x){
    let y = g(x);
    return y;
}
//  情況二
function f(x){
    return g(x) + 1;
}
//  情況三
function f(x){
    g(x);
}

上面代碼中,情況一是調用函數 g 之后,還有賦值操作,所以不屬於尾調用,即使語義完全一樣。情況二也屬於調用后還有操作,即使寫在一行內。情況三等同於下面的代碼。

function f(x){
    g(x);
    return undefined;
}

尾調用不一定出現在函數尾部,只要是最后一步操作即可。

function f(x) {
    if (x > 0) {
        return m(x)
    }
    return n(x);
}

上面代碼中,函數 m 和 n 都屬於尾調用,因為它們都是函數 f 的最后一步操作。 

2  尾調用優化

尾調用之所以與其他調用不同,就在於它的特殊的調用位置。

我們知道,函數調用會在內存形成一個 “ 調用記錄 ” ,又稱 “ 調用幀 ” ( call frame )(也叫執行上下文),保存調用位置和內部變量等信息。如果在函數 A 的內部調用函數 B ,那么在 A 的調用幀上方,還會形成一個 B 的調用幀。等到 B 運行結束,將結果返回到 A , B 的調用幀才會消失。如果函數 B 內部還調用函數 C ,那就還有一個 C 的調用幀,以此類推。所有的調用幀,就形成一個 “ 調用棧 ” ( call stack )(也叫執行上下文棧,執行棧)。

尾調用由於是函數的最后一步操作,所以不需要保留外層函數的調用幀,因為調用位置、內部變量等信息都不會再用到了,只要直接用內層函數的調用幀,取代外層函數的調用幀就可以了。

function f() {
    let m = 1;
    let n = 2;
    return g(m + n);
}
f();
//  等同於
function f() {
    return g(3);
}
f();
//  等同於
g(3);

上面代碼中,如果函數 g 不是尾調用,函數 f 就需要保存內部變量 m 和 n 的值、 g 的調用位置等信息。但由於調用 g 之后,函數 f 就結束了,所以執行到最后一步,完全可以刪除 f(x)  的調用幀,只保留 g(3)  的調用幀。 

這就叫做 “ 尾調用優化 ” ( Tail call optimization ),即只保留內層函數的調用幀。如果所有函數都是尾調用,那么完全可以做到每次執行時,調用幀只有一項,這將大大節省內存。這就是 “ 尾調用優化 ” 的意義。

注意,只有不再用到外層函數的內部變量,內層函數的調用幀才會取代外層函數的調用幀,否則就無法進行 “ 尾調用優化 ” 。

function addOne(a){
    var one = 1;
    function inner(b){
        return b + one;
    }
    return inner(a);
}

上面的函數不會進行尾調用優化,因為內層函數inner用到了外層函數addOne的內部變量one。

3  尾遞歸

函數調用自身,稱為遞歸。如果尾調用自身,就稱為尾遞歸。

遞歸非常耗費內存,因為需要同時保存成千上百個調用幀,很容易發生 “ 棧溢出 ” 錯誤( stack overflow )。但對於尾遞歸來說,由於只存在一個調用幀,所以永遠不會發生 “ 棧溢出 ” 錯誤。

function factorial(n) {
    if (n === 1) return 1;
    return n * factorial(n - 1);
}
factorial(5) // 120

上面代碼是一個階乘函數,計算 n 的階乘,最多需要保存 n 個調用記錄,復雜度 O(n)  。
如果改寫成尾遞歸,只保留一個調用記錄,復雜度 O(1)  。

function factorial(n, total) {
    if (n === 1) return total;
    return factorial(n - 1, n * total);
}
factorial(5, 1) // 120

還有一個比較著名的例子,就是計算 fibonacci  數列,也能充分說明尾遞歸優化的重要性
如果是非尾遞歸的 fibonacci  遞歸方法

function Fibonacci (n) {
    if ( n <= 1 ) {return 1};
    return Fibonacci(n - 1) + Fibonacci(n - 2);
}
Fibonacci(10); // 89
// Fibonacci(100)
// Fibonacci(500)
//  堆棧溢出了

如果我們使用尾遞歸優化過的 fibonacci  遞歸算法

function Fibonacci2 (n , ac1 = 1 , ac2 = 1) {
    if( n <= 1 ) {return ac2};
    return Fibonacci2 (n - 1, ac2, ac1 + ac2);
}
Fibonacci2(100) // 573147844013817200000
Fibonacci2(1000) // 7.0330367711422765e+208
Fibonacci2(10000) // Infinity

由此可見, “ 尾調用優化 ” 對遞歸操作意義重大,所以一些函數式編程語言將其寫入了語言規格。 ES6 也是如此,第一次明確規定,所有 ECMAScript 的實現,都必須部署 “ 尾調用優化 ” 。這就是說,在 ES6 中,只要使用尾遞歸,就不會發生棧溢出,相對節省內存。

4  遞歸函數的改寫 

尾遞歸的實現,往往需要改寫遞歸函數,確保最后一步只調用自身。做到這一點的方法,就是把所有用到的內部變量改寫成函數的參數。比如上面的例子,階乘函數 factorial  需要用到一個中間變量 total  ,那就把這個中間變量改寫成函數的參數。這樣做的缺點就是不太直觀,第一眼很難看出來,為什么計算 5 的階乘,需要傳入兩個參數 5 和 1 ?
兩個方法可以解決這個問題。

方法一是在尾遞歸函數之外,再提供一個正常形式的函數。

function tailFactorial(n, total) {
    if (n === 1) return total;
    return tailFactorial(n - 1, n * total);
}
function factorial(n) {
    return tailFactorial(n, 1);
}
factorial(5) // 120

上面代碼通過一個正常形式的階乘函數 factorial  ,調用尾遞歸函數 tailFactorial  ,看起來就正常多了。
函數式編程有一個概念,叫做柯里化( currying ),意思是將多參數的函數轉換成單參數的形式。這里也可以使用柯里化。

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

面代碼通過柯里化,將尾遞歸函數 tailFactorial  變為只接受 1 個參數的 factorial  。
第二種方法就簡單多了,就是采用 ES6 的函數默認值。

function factorial(n, total = 1) {
    if (n === 1) return total;
    return factorial(n - 1, n * total);
}
factorial(5) // 120

上面代碼中,參數 total  有默認值 1 ,所以調用時不用提供這個值。
總結一下,遞歸本質上是一種循環操作。純粹的函數式編程語言沒有循環操作命令,所有的循環都用遞歸實現,這就是為什么尾遞歸對這些語言極其重要。對於其他支持 “ 尾調用優化 ” 的語言(比如 Lua , ES6 ),只需要知道循環可以用遞歸代替,而一旦使用遞歸,就最好使用尾遞歸。

5  嚴格模式

ES6 的尾調用優化只在嚴格模式下開啟,正常模式是無效的。
這是因為在正常模式下,函數內部有兩個變量,可以跟蹤函數的調用棧。
func.arguments:返回調用時函數的參數。
func.caller:返回調用當前函數的那個函數。
尾調用優化發生時,函數的調用棧會改寫,因此上面兩個變量就會失真。嚴格模式禁用這兩個變量,所以尾調用模式僅在嚴格模式下生效。

function restricted() {
    "use strict";
    restricted.caller; //  報錯
    restricted.arguments; //  報錯
}
restricted();

6  尾遞歸優化的實現

尾遞歸優化只在嚴格模式下生效,那么正常模式下,或者那些不支持該功能的環境中,有沒有辦法也使用尾遞歸優化呢?回答是可以的,就是自己實現尾遞歸優化。
它的原理非常簡單。尾遞歸之所以需要優化,原因是調用棧太多,造成溢出,那么只要減少調用棧,就不會溢出。怎么做可以減少調用棧呢?就是采用 “ 循環 ” 換掉 “ 遞歸 ” 。
下面是一個正常的遞歸函數。

function sum(x, y) {
    if (y > 0) {
        return sum(x + 1, y - 1);
    } else {
        return x;
    }
}
sum(1, 100000)

上面代碼中,sum是一個遞歸函數,參數x是需要累加的值,參數y控制遞歸次數。一旦指定sum遞歸 100000 次,就會報錯,提示超出調用棧的最大次數。
蹦床函數( trampoline )可以將遞歸執行轉為循環執行。

function trampoline(f) {
    while (f && f instanceof Function) {
        f = f();
    }
    return f;
}

上面就是蹦床函數的一個實現,它接受一個函數f作為參數。只要f執行后返回一個函數,就繼續執行。注意,這里是返回一個函數,然后執行該函數,而不是函數里面調用函數,這樣就避免了遞歸執行,從而就消除了調用棧過大的問題。
然后,要做的就是將原來的遞歸函數,改寫為每一步返回另一個函數。

function sum(x, y) {
    if (y > 0) {
        return sum.bind(null, x + 1, y - 1);
    } else {
        return x;
    }
}

上面代碼中,sum函數的每次執行,都會返回自身的另一個版本。
現在,使用蹦床函數執行sum,就不會發生調用棧溢出。

trampoline(sum(1, 100000))
// 100001
//蹦床函數並不是真正的尾遞歸優化,下面的實現才是。
function tco(f) {
    var value;
    var active = false;
    var accumulated = [];
    return function accumulator() {
        accumulated.push(arguments);
        if (!active) {
            active = true;
            while (accumulated.length) {
                value = f.apply(this, accumulated.shift());
            }
            active = false;
            return value;
        }
    };
}
var sum = tco(function(x, y) {
    if (y > 0) {
        return sum(x + 1, y - 1)
    }else {
        return x
    }
});
sum(1, 100000)
// 100001

上面代碼中,tco函數是尾遞歸優化的實現,它的奧妙就在於狀態變量active。默認情況下,這個變量是不激活的。一旦進入尾遞歸優化的過程,這個變量就激活了。然后,每一輪遞歸sum返回的都是undefined,所以就避免了遞歸執行;而accumulated數組存放每一輪sum執行的參數,總是有值的,這就保證了accumulator函數內部的while循環總是會執行。這樣就很巧妙地將 “ 遞歸 ” 改成了 “ 循環 ” ,而后一輪的參數會取代前一輪的參數,保證了調用棧只有一層。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM