Lambda表示式之爭:Scala vs Java 8

ImportNew發表於2015-05-27

最近幾年Lambda表示式風靡於程式設計界。很多現代程式語言都把它作為函數語言程式設計的基本組成部分。基於JVM的程式語言如Scala、Groovy及Clojure把它作為關鍵部分整合在語言中。而如今,(最終)Java 8也加入了這個有趣的行列。

Lambda表示式最有意思的地方在於,在JVM的角度來看它是完全不可見的。在JVM中沒有匿名函式或Lambda表示式的概念。JVM唯一知道是位元組碼。位元組碼是一個嚴格的OO規範。由語言的創造者和編譯者通過這些限制來建立新的、高階的語言元素。

我們第一次遇到Lambda表示式是需要在Takipi中增加對Scala的支援,所以不得不深入瞭解Scala的編譯器。而這時Java 8也正處在關鍵時刻。我猜想Scala和Java編譯器對Lambda表示式的實現肯定會非常有趣。結果讓我極為驚訝。

為了演示這些內容,我寫了一個簡單的Lambda表示式,功能是將一個字串列表轉換為它們長度的列表。

Java:

List names = Arrays.asList("1", "2", "3");
Stream lengths = names.stream().map(name -> name.length());

Scala:

val names = List("1", "2", "3")
val lengths = names.map(name => name.length)

不要被它表面的簡單所迷惑,後面執行了相當複雜的過程。

我們從Scala開始

λ表示式之爭:Scala vs Java8

程式碼

我使用 javap 來檢視通過Scala編譯器生成的.class檔案的位元組碼的內容。讓我們看一下位元組碼的結果(這才是JVM真正執行的內容)。

//將變數名載入到棧中(JVM視為變數#2),先儲存在這,之後會在map函式中用到
aload_2

接下來的事情就變得更有趣了,一個由編譯器生成的synthetic的例項建立並初始化(譯者注:Synthetic類是指由JVM執行時生成的類)。非常有意思的是,Lambda作為整個方法的一部分來定義的,但它實際上完全存在於我們類的外部。

new myLambdas/Lambda1$$anonfun$1 //例項化Lambda物件
dup //把它加入棧中
//最後,呼叫建構函式.記住,這是源自JVM的一個簡單物件
invokespecial myLambdas/Lambda1$$anonfun$1/()V
//這個兩行載入immutable.List CanBuildFrom工廠,該工廠能生成新的list。工廠模式是Scala的集合架構的一部分。
getstatic scala/collection/immutable/List$/MODULE$
Lscala/collection/immutable/List$;
invokevirtual scala/collection/immutable/List$/canBuildFrom()
Lscala/collection/generic/CanBuildFrom;

//現在,棧上已經有了Lambda物件及工廠,下一階段就可以呼叫map函式。
//你應該還記得,我們在一開始的時候將名稱變數載入到了棧中。我們現在可以用它來實現map方法的呼叫了。
//map方法接受一個Lambda物件和一個工廠,生成一個長度的list。

invokevirtual scala/collection/immutable/List/map(Lscala/Function1;
Lscala/collection/generic/CanBuildFrom;)Ljava/lang/Object;

但是請稍等,Lambda物件內部做了什麼事情?

Lambda物件

Lambda類來繼承自scala.runtime.AbstractFunction1。通過這種方式,map() 函式可以多型呼叫重寫後的 apply() 方法,apply()程式碼如下:

//這段程式碼是載入this及目標物件,檢測它是不是一個字串,然後呼叫另一個過載後的、真正工作的apply方法,最後包裝返回結果
aload_0//載入this
aload_1//載入字串引數
checkcast java/lang/String//確保是一個字串 - 得到一個Object

// 呼叫synthetic類的apply()方法
invokevirtual myLambdas/Lambda1$$anonfun$1/apply(Ljava/lang/String;)I

//包裝結果
invokestatic scala/runtime/BoxesRunTime/boxToInteger(I)Ljava/lang/Integer
areturn

真正的執行.length() 操作的程式碼巢狀在另個一apply方法中,該方法正如我們期望的一樣,簡單的返回了字串的長度。

唷……,走了好長的一段路才到這。

aload_1
invokevirtual java/lang/String/length()I
ireturn

我們在上面只是寫了一行簡單的程式碼,但是卻產生了許多的位元組碼,包括一個額外的類和一堆方法。但是,這絕不是在勸阻我們不要用Lambda(我們是在Scala中寫程式碼,而不是C)。這僅僅是為了展示這種結構後面的複雜性。

我相當期待Java 8也是用這種方式實現的,但是令人驚訝的時,java採取了完全不同的方式。

Java 8:一種新的方式

Java 8產生的位元組碼比較短,但是還有更令人驚訝的東西。它剛開始簡單的載入了名稱變數,然後呼叫 stream() 方法,但是接下做了一些非常好的優化。它沒有建立一個新的物件來包裝Lambda函式,而是使用了新的 invokeDynamic 指令,該指令是Java 7時增加的,這個地方的用於呼叫真實的Lambda函式。

aload_1 // 載入名稱變數
//呼叫stream()方法
invokeinterface java/util/List.stream:()Ljava/util/stream/Stream;
//invokeDynamic指令魔法!
invokedynamic #0:apply:()Ljava/util/function/Function;
//呼叫map()方法
invokeinterface java/util/stream/Stream.map:
(Ljava/util/function/Function;)Ljava/util/stream/Stream;

InvokeDynamic魔法:這條JVM指令在Java 7中增加,用於減少JVM的限制,允許動態語言在執行時繫結符號。而在這之前,所有的連結都是靜態的,在程式碼編譯的時候就由JVM完成。

動態連結:如果你看過invokedynamic指令,你會發現沒有引用指向真正的Lambda函式(即lambda$0)。答案歸結於invokedynamic指令的設計,但是更簡短的答案是Lambda表示式的簽名,就我們的例子來說是

//一個名為lamda$0的函式,獲取一個字串,返回一個整數
lambdas/Lambda1.lambda$0:(Ljava/lang/String;)Ljava/lang/Integer;

儲存在.class的一個單獨的表中,該表作為#0引數傳遞給指令。這個新的表確實改變了位元組碼規範的結構,這是多年之後的第一次改變,這同樣需要採取Takipi的錯誤分析引擎。

Lambda程式碼

這段程式碼是真正的Lambda表示式。非常容易,簡單地載入字串引數,呼叫length()方法幷包裝成結果。請注意,它是編譯成了一個靜態函式,避免像之前看到的Scala一樣,傳入額外的this物件。

aload_0
invokevirtual java/lang/String.length:()
invokestatic java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
areturn

這是invokedynamic方式的另一個優點,它允許我們通過多型的方式來呼叫 map() 函式,且不需要包裝物件或呼叫虛擬的的重寫方法。非常酷!

總結

Java看起非常具有吸引力,最“嚴格”的現代語言現在開始使用動態連結來增加Lambda表示式的功能。該方式也是非常有效的一種方式,不需要載入和編譯額外的類,Lambda方法只是我們類中一個簡單的私有方法。

Java 8確實對Java 7引入的新的技術做了很多優化,使用了非常直接的方式實現了對Lambda表示式的支援。非常高興能看到像Java這樣“端莊”的女士能教我們一些戲法。

相關文章