尾呼叫和尾遞迴

leocoder發表於2018-04-10

尾呼叫

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();

call stack

造成這種結果是因為每個函式在呼叫另一個函式的時候,並沒有 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)禁止這些屬性,並且尾呼叫最佳化只在嚴格模式下有效。

如果尾呼叫最佳化生效,流程圖就會變成這樣:

call stack

我們可以很清楚的看到,尾呼叫由於是函式的最後一步操作,所以不需要保留外層函式的呼叫記錄,只要直接用內層函式的呼叫記錄取代外層函式的呼叫記錄就可以了,呼叫棧中始終只保持了一條呼叫幀。

這就叫做尾呼叫最佳化,如果所有的函式都是尾呼叫的話,那麼在呼叫棧中的呼叫幀始終只有一條,這樣會節省很大一部分的記憶體,這也是尾呼叫最佳化的意義

尾遞迴

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...

相關文章