Java8函數之旅 (六) -- 使用lambda實現Java的尾遞歸

前言

本篇介紹的不是什麼新知識,而是對前面講解的一些知識的綜合運用。衆所周知,遞歸是解決複雜問題的一個頗有效的方式,也是函數式語言的核心,在一些函數式語言中,是沒有迭代與while這種概念的,由於此類的循環統統能夠用遞歸來實現,這類語言的編譯器都對遞歸的尾遞歸形式進行了優化,而Java的編譯器並無這樣的優化,本篇就要完成這樣一個對於尾遞歸的優化。html

什麼是尾遞歸

本篇將使用遞歸中最簡單的階乘計算來做爲例子java

遞歸實現

/**
     * 階乘計算 -- 遞歸解決
     *
     * @param number 當前階乘須要計算的數值
     * @return number!
     */
    public static int factorialRecursion(final int number) {
        if (number == 1) return number;
        else return number * factorialRecursion(number - 1);
    }

這種方法計算階乘比較大的數很容易就棧溢出了,緣由是每次調用下一輪遞歸的時候在棧中都須要保存以前的變量,因此整個棧結構相似是這樣的app

5
  4
    3
      2
        1
-------------------> 
      棧的深度

在沒有遞歸到底以前,那些中間變量會一直保存着,所以每一次遞歸都須要開闢一個新的棧空間ide

尾遞歸實現

任何遞歸的尾遞歸版本都十分簡單,分析上面棧溢出的緣由就是在每次return的時候都會附帶一個變量,所以只須要在return的時候不附帶這個變量便可。提及來簡單,該怎麼作呢?其實也很容易,咱們使用一個參數來保存上一輪遞歸的結果,這樣就能夠了,所以尾遞歸的階乘實現應該是這樣的代碼。函數

/**
     * 階乘計算 -- 尾遞歸解決
     *
     * @param factorial 上一輪遞歸保存的值
     * @param number    當前階乘須要計算的數值
     * @return number!
     */
    public static int factorialTailRecursion(final int factorial, final int number) {
        if (number == 1) return factorial;
        else return factorialTailRecursion(factorial * number, number - 1);
    }

使用一個factorial變量保存上一輪階乘計算出的數值,這樣return的時候就無需保存變量,整個的計算過程是
(5*4)20 -> (20*3) 60 -> (60*2) 120 -> return 120工具

這樣子經過每輪遞歸結束後刷新當前的棧空間,複用了棧,就克服了遞歸的棧溢出問題,像這樣的return後面不附帶任何變量的遞歸寫法,也就是遞歸發生在函數最尾部,咱們稱之爲'尾遞歸'。測試

使用lambda實現編譯器的優化

很顯然,若是事情這麼簡單的話,這篇文章也就結束了,和lambda也沒啥關係 :) 然而當你調用上文的尾遞歸寫法以後,發現並無什麼做用,該棧溢出的仍是會棧溢出,其實緣由我在開頭就已經說了,尾遞歸這樣的寫法自己並不會有什麼用,依賴的是編譯器對尾遞歸寫法的優化,在不少語言中編譯器都對尾遞歸有優化,然而這些語言中並不包括java,所以在這裏咱們使用lambda的懶加載(惰性求值)機制來延遲遞歸的調用,從而實現棧幀的複用。優化

設計尾遞歸的接口

所以咱們須要設計一個這樣的函數接口來代替遞歸中的棧幀,經過apply這個函數方法(取名叫apply是由於該方法的參數是一個棧幀,返回值也是一個棧幀,類比function接口的apply)完成每一個棧幀之間的鏈接,除此以外,咱們棧幀還須要定義幾個方法來豐富這個尾遞歸的接口。this

  • apply(鏈接棧幀,惰性求值)
  • 判斷遞歸是否結束
  • 獲得遞歸最後的結果
  • 執行遞歸(及早求值)

根據上面的幾條定義,設計出以下的尾遞歸接口設計

/**
 * 尾遞歸函數接口
 * @author : martrix
 */
@FunctionalInterface
public interface TailRecursion<T> {
    /**
     * 用於遞歸棧幀之間的鏈接,惰性求值
     * @return 下一個遞歸棧幀
     */
    TailRecursion<T> apply();

    /**
     * 判斷當前遞歸是否結束
     * @return 默認爲false,由於正常的遞歸過程當中都還未結束
     */
    default boolean isFinished(){
        return false;
    }

    /**
     * 得到遞歸結果,只有在遞歸結束才能調用,這裏默認給出異常,經過工具類的重寫來得到值
     * @return 遞歸最終結果
     */
    default T getResult()  {
        throw new Error("遞歸尚未結束,調用得到結果異常!");
    }

    /**
     * 及早求值,執行者一系列的遞歸,由於棧幀只有一個,因此使用findFirst得到最終的棧幀,接着調用getResult方法得到最終遞歸值
     * @return 及早求值,得到最終遞歸結果
     */
    default T invoke() {
        return Stream.iterate(this, TailRecursion::apply)
                .filter(TailRecursion::isFinished)
                .findFirst()
                .get()
                .getResult();
    }
}

設計對外統一的尾遞歸包裝類

爲了達到能夠複用的效果,這裏設計一個尾遞歸的包裝類,目的是用於對外統一方法,使得須要尾遞歸的調用一樣的方法便可完成尾遞歸,不須要考慮內部實現細節,由於全部的遞歸方法無非只有2類類型的元素組成,一個是怎樣調用下次遞歸,另一個是遞歸的終止條件,所以包裝方法設計爲如下兩個

  • 調用下次遞歸
  • 結束本輪遞歸
    代碼以下
/**
 * 使用尾遞歸的類,目的是對外統一方法
 *
 * @author : Matrix
 */
public class TailInvoke {
    /**
     * 統一結構的方法,得到當前遞歸的下一個遞歸
     *
     * @param nextFrame 下一個遞歸
     * @param <T>       T
     * @return 下一個遞歸
     */
    public static <T> TailRecursion<T> call(final TailRecursion<T> nextFrame) {
        return nextFrame;
    }

    /**
     * 結束當前遞歸,重寫對應的默認方法的值,完成狀態改成true,設置最終返回結果,設置非法遞歸調用
     *
     * @param value 最終遞歸值
     * @param <T>   T
     * @return 一個isFinished狀態true的尾遞歸, 外部經過調用接口的invoke方法及早求值, 啓動遞歸求值。
     */
    public static <T> TailRecursion<T> done(T value) {
        return new TailRecursion<T>() {
            @Override
            public TailRecursion<T> apply() {
                throw new Error("遞歸已經結束,非法調用apply方法");
            }

            @Override
            public boolean isFinished() {
                return true;
            }

            @Override
            public T getResult() {
                return value;
            }
        };
    }
}

完成階乘的尾遞歸函數

經過使用上面的尾遞歸接口與包裝類,只須要調用包裝了call與done就能夠很輕易的寫出尾遞歸函數,代碼以下

/**
     * 階乘計算 -- 使用尾遞歸接口完成
     * @param factorial 當前遞歸棧的結果值
     * @param number 下一個遞歸須要計算的值
     * @return 尾遞歸接口,調用invoke啓動及早求值得到結果
     */
    public static TailRecursion<Integer> factorialTailRecursion(final int factorial, final int number) {
        if (number == 1)
            return TailInvoke.done(factorial);
        else
            return TailInvoke.call(() -> factorialTailRecursion(factorial + number, number - 1));
    }

經過觀察發現,和原先預想的尾遞歸方法幾乎如出一轍,只是使用包裝類的call與done方法來表示遞歸的調用與結束
預想的尾遞歸

/**
     * 階乘計算 -- 尾遞歸解決
     *
     * @param factorial 上一輪遞歸保存的值
     * @param number    當前階乘須要計算的數值
     * @return number!
     */
    public static int factorialTailRecursion(final int factorial, final int number) {
        if (number == 1) return factorial;
        else return factorialTailRecursion(factorial * number, number - 1);
    }

測試尾遞歸函數

這裏做一個說明,由於階乘的計算若是要計算到棧溢出通常狀況下Java的數據類型須要使用BigInteger來包裝,爲了簡化代碼,這裏的測試僅僅是是測試棧會不會溢出的問題,所以咱們將操做符的*改爲+這樣修改的結果僅僅是結果變小了,可是棧的深度卻沒有改變。測試代碼以下
首先測試 深度爲10W的普通遞歸

測試代碼

@Test
    public void testRec() {
        System.out.println(factorialRecursion(100_000));
    }

理所固然的棧溢出了

java.lang.StackOverflowError
    at test.Factorial.factorialRecursion(Factorial.java:20)
    at test.Factorial.factorialRecursion(Factorial.java:20)
    at test.Factorial.factorialRecursion(Factorial.java:20)
    at test.Factorial.factorialRecursion(Factorial.java:20)
    at test.Factorial.factorialRecursion(Factorial.java:20)
    
Process finished with exit code -1

這裏咱們測試1000W棧幀的尾遞歸
尾遞歸代碼

public static TailRecursion<Long> factorialTailRecursion(final long factorial, final long number) {
        if (number == 1)
            return TailInvoke.done(factorial);
        else
            return TailInvoke.call(() -> factorialTailRecursion(factorial + number, number - 1));
    }

測試代碼

@Test
    public void testTailRec() {
        System.out.println(factorialTailRecursion(1,10_000_000).invoke());
    }

發現結果運轉良好

50000005000000

Process finished with exit code 0

因爲階乘的計算通常初始值都爲1,因此再進一步包裝一下,將初始值設置爲1

public static long factorial(final long number) {
        return factorialTailRecursion(1, number).invoke();
    }

最終調用代碼以下,徹底屏蔽了尾遞歸的實現細節

@Test
    public void testTailRec() {
        System.out.println(factorial(10)); //結果爲 3628800
    }

總結

本文講解了利用lambda懶加載的特性完成了遞歸中棧幀的複用,實現了函數式語言編譯器的'尾遞歸'優化,雖然上面的例子很簡單,可是設計的接口和包裝類都是通用的,能夠說任何須要使用尾遞歸的均可以使用上面的代碼來實現尾遞歸的優化,這也算是爲編譯器幫了點忙吧。

上一篇:開始Java8之旅(五) -- Java8中的排序
下一篇:開始Java8之旅(七) -- 函數式備忘錄模式優化遞歸

相關文章
相關標籤/搜索