java8函數語言程式設計筆記-延遲性

dust1發表於2018-09-29

延遲性

    眾所周知,java8的新特性之一便是Stream,通過Stream,我們可以生成無限流(也就是沒有結束的流)。這要依託Stream的特點:流中的資料並不是一開始就載入入記憶體中生成的,它是用到的時候才生成。依據這個才能創造出無限流:

IntStream stream = IntStream.iterate(2, i -> i + 1);
複製程式碼

如果我們要對無限的流進行遞迴會怎麼樣呢? 例子:

我們要獲取所有的質數,方法步驟如下:

  • 1:獲取一個從2開始的無限流
  • 2:獲取流的第一個元素
  • 3:從下一個元素開始遍歷,如果能整除第一個元素則從流中過濾掉
  • 4:重複2-3步驟
static IntStream primes (IntStream stream) {
    int head = stream.findFirst().getAsInt();
    return IntStream.concat(
        IntStream.of(head),
        primes(stream.skip(1).filter(i -> i % head != 0))
    );
}
複製程式碼

    很簡單的結果便是會發生java.lang.IllegalStateException異常,因為java的流和迭代器類似只能遍歷一遍,遍歷完之後我們便說流被消耗掉了。這個原因就和流的資料結構有關,流的一大特點就是隨用隨生成,我們可以把流看成是一組分佈在時間上的一組資料。。而和它類似的集合是在空間概念上的一組資料,從記憶體中看也一樣,集合在記憶體中劃出空間儲存資料,因此可以複用。

IntStream.concat(
        IntStream.of(head),
        primes(stream.skip(1).filter(i -> i % head != 0))
    );
複製程式碼

這行程式碼的concat第二個引數是直接遞迴呼叫,最終會導致出現無限遞迴的狀況。直接遞迴的方法導致方法執行的時候傳遞的所有引數都要在第一時間計算出來。因此會一直申請記憶體空間導致java.lang.StackOverflowError。

對大多數的Java應用而言,Java 8在Stream上的這一限制,即“不允許遞迴 定義”是完全沒有影響的,使用Stream後,資料庫的查詢更加直觀了,程式還具備了併發的能力。 所以,Java 8的設計者們進行了很好的平衡,選擇了這一皆大歡喜的方案。不過,Scala和Haskell 這樣的函式式語言中Stream所具備的通用特性和模型仍然是你程式設計武器庫中非常有益的補充。

因此我們需要一種方法能和Stream一樣隨用隨時生成。如果用更加技術性的程式設計術語來描述的話這個稱之為:延遲計算非限制式計算或者名呼叫。我們只需要處理質數的那個時刻對Stream進行計算。 Scala提供了對這種演算法的支援,在Scala中我們可以用現年的方式重寫前面的程式碼,操作符#::實現了延遲連線的功能(只有在我們需要的時候才進行計算):

def numbers(n: Int): Stream[Int] = n #:: numbers(n+1)
    def primes(numbers: Stream[Int]): Stream[Int] = {
        numbers.head #:: primes(numbers.tail filter (n -> n % numbers.head != 0))
    }
複製程式碼

建立我們自己的延遲列表

    java的Stream以延遲性著稱。他們被刻意設計成這樣,即延遲操作,有其獨特的原因:Stream就像一個黑盒,它接收請求生成結果。當我們向一個Stream發起一系列的操作請求時,這些請求只是被一一儲存起來。只有當我們向Stream發起一個終端操作是,才會實際地進行計算。我們拿最常用的IntStream.filter(IntPredicate predicate)來看,在IntPipeline.java中有該方法的具體實現:

@Override
    public final IntStream filter(IntPredicate predicate) {
        Objects.requireNonNull(predicate);
        return new StatelessOp<Integer>(this, StreamShape.INT_VALUE,
                                        StreamOpFlag.NOT_SIZED) {
            @Override
            Sink<Integer> opWrapSink(int flags, Sink<Integer> sink) {
                return new Sink.ChainedInt<Integer>(sink) {
                    @Override
                    public void begin(long size) {
                        downstream.begin(-1);
                    }

                    @Override
                    public void accept(int t) {
                        if (predicate.test(t))
                            downstream.accept(t);
                    }
                };
            }
        };
    }
複製程式碼

它返回一個

abstract static class StatelessOp<E_IN> extends IntPipeline<E_IN> {
       
        StatelessOp(AbstractPipeline<?, E_IN, ?> upstream,
                    StreamShape inputShape,
                    int opFlags) {
            super(upstream, opFlags);
            assert upstream.getOutputShape() == inputShape;
        }

        @Override
        final boolean opIsStateful() {
            return false;
        }
    }
複製程式碼

類。直接看作者註釋:

Base class for a stateless intermediate stage of an IntStream
IntStream的無狀態中間階段的基類
Construct a new IntStream by appending a stateless intermediate
通過將無狀態中間操作附加到現有流來構造新的IntStream。
<E_IN>
上游源中的元素型別

  • operation to an existing stream.
  • @param upstream The upstream pipeline stage 上游管道階段
  • @param inputShape The stream shape for the upstream pipeline stage 上游管道階段的流形狀
  • @param opFlags Operation flags for the new stage 新階段的操作標誌

光看這些可能還無法理解他有什麼作用,因此我們直接檢視他的父類構造器:

IntPipeline(AbstractPipeline<?, E_IN, ?> upstream, int opFlags) {
        super(upstream, opFlags);
    }
複製程式碼

檢視註釋可以知道

Constructor for appending an intermediate operation onto an existing
用於將中間操作附加到現有操作的建構函式

現在就明白了,這是一個將中間操作附加到現有操作上的函式。讓我們繼續檢視它的super:

AbstractPipeline(AbstractPipeline<?, E_IN, ?> previousStage, int opFlags) {
        if (previousStage.linkedOrConsumed)
            throw new IllegalStateException(MSG_STREAM_LINKED);
        previousStage.linkedOrConsumed = true;
        previousStage.nextStage = this;

        this.previousStage = previousStage;
        this.sourceOrOpFlags = opFlags & StreamOpFlag.OP_MASK;
        this.combinedFlags = StreamOpFlag.combineOpFlags(opFlags, previousStage.combinedFlags);
        this.sourceStage = previousStage.sourceStage;
        if (opIsStateful())
            sourceStage.sourceAnyStateful = true;
        this.depth = previousStage.depth + 1;
    }
複製程式碼

先不看程式碼,直接看註釋:

Constructor for appending an intermediate operation stage onto an existing pipeline.
用於將中間操作階段附加到現有管道的建構函式。

按理說我們都是在Stream上進行的操作,但是程式碼的註釋出現的是pipeline。顯然,Stream應該是一種特殊的pipeline。那麼上面關於流無法複用的真正原因也就知道了:pipeline是單向的。Stream作為特殊的pipeline當然就無法複用了。

    言歸正傳,我們接下來要說的延遲列表,它是一種更加通用的Stream形式(延遲列表構建了一個跟Stream非常類似的概念),它還同時提供了一種極好的方式去理解高階函式:我們可以將一個函式作為值放置到某個資料結構中,大多數時候他就靜靜地待在那裡,一旦對其進行呼叫(即根據需要),用它能夠建立更多的資料結構:

java8函數語言程式設計筆記-延遲性
LinkedList的元素存在於(並不斷延展)記憶體中。而LazyList的 元素由函式在需要使用時動態建立,你可以將它們看成實時延展的
讓我們開始建立一個簡單的連結-列表-式的類:

interface MyList<T> {
    T head ();
    
    MyList<T> tail ();
    
    default boolean isEmpty () {
        return true;
    }
}

class MyLinkedList<T> implements MyList<T> {
    
    private final T head;
    
    private final MyList<T> tail;
    
    public MyLinkedList (T head, MyList<T> tail) {
        this.head = head;
        this.tail = tail;
    }
    
    public T head () {
        return head;
    }
    
    public MyList<T> tail () {
        return tail;
    }
    
    public boolean isEmpty () {
        return false;
    }
    
}

class Empty<T> implements MyList<T> {
    
    public T head () {
        throw new UnsupportedOperationException();
    }
    
    public MyList<T> tail () {
        throw new UnsupportedOperationException();
    }
    
    public boolean isEmpty () {
        return false;
    }
    
}
複製程式碼

現在我們可以構造一個MyLinkedList值:

    MyList<Integer> list = new MyLinkedList<> (5, new MyLinkedList<> (10, new Empty<>()));
複製程式碼

對這個類進行改造使得它符合延遲列表的思想,根據上面所描述的我們需要將函式當成元素讓它“靜靜地待在那裡”。而我們最開始之所以會棧溢位是因為程式一開始想要計算所有的遞迴函式。因此我們將遞迴函式部分改造成函式。當我們需要的時候返回對應的MyList<T>物件,這裡我們需要的函式描述符為T -> ();因此使用Supplier<T>方法。

class MyLazyList<T> implements MyList<T> {
    
    private final T head;
    
    private final Supplier<MyList<T>> tail;
    
    public MyLazyList (T head, Supplier<MyList<T>> tail) {
        this.head = head;
        this.tail = tail;
    }
    
    public T head () {
        return head;
    }
    
    public MyList<T> tail () {
        //這裡使用get()方法提供延遲性
        return tail.get();
    }
    
    public boolean isEmpty () {
        return false;
    }
    
}
複製程式碼

呼叫tail方法會觸發get()進行節點建立返回節點,就像工廠一樣建立新物件。現在我們可以傳遞一個Supplier作為MyLazyList的構造器的tail引數,建立由數字構成的無限延遲列表了:

public static MyLazyList<Integer> form (int n) {
    return new MyLazyList<> (n, () -> form(n + 1));
}
複製程式碼

對它的使用如下:

    //從數字2開始生成
    LazyList<Integer> numbers = from(2);
    int two = numbers.head();
    int three = numbers.tail().head();
    int four = numbers.tail().tail().head();
    System.out.println(two + " " + three + " " + four);
    
複製程式碼

回到生成質數

讓我們將原來的Stream程式碼改造成使用延遲列表,這裡我們還需要在原來的程式碼中加上filter(Predicate<T> p) 方法來過濾資料.

public MyList<T> filter (Predicate<T> p) {
    return isEmpty() ? 
        this : //返回的將會是空物件
        p.test(head()) ? 
            new MyLazyList<T>(head(), () -> tail().filter(p)) : //滿足條件返回物件
            tail().filter(p); //否則跳到下一個
}
複製程式碼

這時候我們改造後的primes方法如下:

public static MyList<Integer> primes (MyList<Integer> list) {
    return new MyLazyList<> (
        list.head(),
        () -> primes (list.tail().filter(n -> n % list.head() != 0))
    );
}
複製程式碼

現在我們可以使用該方法了,讓我們看看計算頭三個質數:

MyLazyList<Integer> list = form (2);
int two = primes(list).head();
int three = primes(list).tail().head();
int four = primes(list).tail().tail().head();

System.out.println(two + " " + three + " " + four);
//out:2 3 5
複製程式碼

現在我們可以隨意生成質數了,甚至可以讓他一直生成下去

static <T> void printAll(MyList<T> list){
    while (!list.isEmpty()){
        System.out.println(list.head());
        list = list.tail();
    }
}

printAll(primes(from(2)));
複製程式碼

當然,為了可讀性和對應改文章主題等多方面著想,我們最好將它修改成更加簡潔的遞迴方式:

static <T> printAll (MyList<T> list) {
    if (list.isEmpty()) {
        return;
    }
    System.out.println(list.head());
    printAll(list.tail());
}
複製程式碼

最終程式會因為棧溢位而失敗,因為Java不支援尾部呼叫消除。

通常而言,執行一次遞迴式方法呼叫的開銷要比迭代執行單一機器級的分支指令大不少。

為什麼呢?每次執行遞迴方法呼叫都會在呼叫棧上建立一個新的棧幀,用於儲存每個方法呼叫的狀態(即它需要進行的乘法運算),這個操作會一直指導程式執行直到結束。這意味著你的遞迴迭代方法會依據它接收的 輸入成比例地消耗記憶體。

而這種問題函式式語言就提供並支援一個解決辦法:尾調優化。

尾調優化(屬於擴充套件,與本文無關)

例:

使用迭代計算階乘

static int factorialIterative (int n) {
    int r = 1;
    for (int i = 1; i <= n; i++) {
        r *= i;
    }
    return r;
}

/**
 * 遞迴的方法計算
 */
static long factorialRecursive (long n) {
    return n == 1 ? 1 : n * factorialRecursive(n - 1);
}

/**
 * 基於Stream的計算
 */
static long factorialStreams (long n) {
    return LongStream.rangeClosed(1, n)
                        .reduce(1, (a, b) -> a * b);
}
複製程式碼

尾調優化對於上述問題的解決:

編寫階乘的一個迭代定義,不過迭代呼叫發生在函式的最後(所以我們說呼叫發生在尾部).這種新的迭代呼叫經過優化後執行的速度快很多。
複製程式碼

以下是java基於尾-遞思想的定義:

static long factorialTailRecursive (long n) {
    return factorialHelper(1, n);
}

static long factorialHelper (long acc, long n) {
    return n == 1 ? acc : factorialHelper(acc * n, n - 1);
}
複製程式碼

方法factorialHelper屬於尾-遞型別的函式,原因是遞迴呼叫發生在方法最後,對比上面的factorialRecursive方法的定義,這個犯法的最後一個操作是乘以n,從而得到遞迴呼叫的結果.

這種形式的遞迴是非常有意義的,現在我們不需要在不同的棧幀上儲存每次遞迴計算的中間 值,編譯器能夠自行決定複用某個棧幀進行計算。實際上,在factorialHelper的定義中,立 即數(階乘計算的中間結果)直接作為引數傳遞給了該方法。再也不用為每個遞迴呼叫分配單獨 的棧幀用於跟蹤每次遞迴呼叫的中間值——通過方法的引數能夠直接訪問這些值。

java8函數語言程式設計筆記-延遲性

壞訊息是java當前不支援(笑)。很多現在的JVM語言:Scala和Groovy都已經支援這種形式的遞迴優化,他們的執行速度和迭代不相上下,在保留函數語言程式設計風格的同時兼顧了執行速度。

相關文章