1. 程式人生 > >Java 理論與實踐: 閉包之爭

Java 理論與實踐: 閉包之爭

  Java 語言是否應增加閉包以及如何新增?
提起向 Java™ 語言增加新的特性,每個人都有自己的一兩個想法。隨著 Java 平臺的原始碼日漸開放,而使用其他語言(例如 JavaScript 和 Ruby)作為伺服器端應用程式日趨流行,因此關於 Java 語言未來的爭論空前激烈。Java 語言是否應該包容像閉包這樣的主流新特性,然而引入過多特性會不會使得這種好端端的語言過於龐雜?在這個月的 “ Java 理論與實踐 ” 專題中,Brian Goetz 回顧了相關的概念,詳細介紹了兩種競爭的閉包方案。

跨越邊界 系列最近的一篇文章中,我的朋友兼同事 Bruce Tate 以 Ruby 為例描述了閉包的強大功能。最近在安特衛普召開的 JavaPolis 會議上,聽眾人數最多的演講是 Neal Gafter 的 “向 Java 語言增加閉包特性”。在 JavaPolis 的公告欄上,與會者可以寫下和 Java 技術有關(或者無關)的想法,其中將近一半和關於閉包的爭論有關。最近似乎 Java 社群的每個人都在討論閉包——雖然閉包這一業已成熟的概念早在 Java 語言出現的 20 年之前就已經存在了。

本文中,我的目標是介紹關於 Java 語言閉包特性的種種觀點。本文首先介紹閉包的概念及其應用,然後簡要說明目前提出來的相互競爭的一些方案。

閉包:基本概念

閉包是可以包含自由(未繫結)變數的程式碼塊;這些變數不是在這個程式碼塊或者任何全域性上下文中定義的,而是在定義程式碼塊的環境中定義。“閉包” 一詞來源於以下兩者的結合:要執行的程式碼塊(由於自由變數的存在,相關變數引用沒有釋放)和為自由變數提供繫結的計算環境(作用域)。在 Scheme、Common Lisp、Smalltalk、Groovy、JavaScript、Ruby 和 Python 等語言中都能找到對閉包不同程度的支援。

閉包的價值在於可以作為函式物件 或者匿名函式,對於型別系統而言這就意 味著不僅要表示資料還要表示程式碼。支援閉包的多數語言都將函式作為第一級物件,就是說這些函式可以儲存到變數中、作為引數傳遞給其他函式,最重要的是能夠 被函式動態地建立和返回。比如下面清單 1 所示的 Scheme 例子(摘自 SICP 3.3.3):


清單 1. Scheme 程式語言的函式示例,該函式接受另一個函式作為引數並返回快取後的函式
                
(define (memoize f)
(let ((table (make-table)))
(lambda (x)
(let ((previously-computed-result (lookup x table
)))
(if (not (null? previously-computed-result))
previously-computed-result
(let ((result (f x)))
(insert! x result table)
result))))))

上述程式碼定義了一個叫做 memoize 的函式,接受函式 f 作為其引數,返回和 f 計算結果相同的另一個函式,不過新函式將以前的計算結果儲存在表中,這樣讀取結果更快。返回的函式使用 lambda 結構建立,該結構動態建立新的函式物件。斜體顯示的識別符號在新定義函式中是自由的,它們的值在建立該函式的環境中繫結。比如,用於儲存快取資料的表變數在 呼叫 memoize 的時候建立,由於被新建的函式引用,因此直到垃圾回收器回收結果函式的時候才會被收回。如果呼叫結果函式時帶有引數 x ,它首先檢查是否已經計算過 f(x)。是的話返回已經得到的 f(x),否則計算 f(x) 並在返回之前儲存到表中以備後用。

閉包為建立和操縱引數化的計算提供了一種緊湊、自然的方式。可以認為支援閉包就是提供將 “程式碼塊” 作為第一級物件處理的能力:能夠傳遞、呼叫和動態建立新的程式碼塊。要完全支援閉包,這種語言必須支援在執行時操縱、呼叫和建立函式,還要支援函式可以捕獲 建立這些函式的環境。很多語言僅提供了這些特性的一個子集,具備閉包的部分但不是全部優勢。關於是否要在 Java 語言中增加閉包,關鍵問題在於提高表達能力所帶來的益處能否與更高的複雜性所帶來的代價相抵消。

匿名類和函式指標

C 語言提供了函式指標,允許將函式作為引數傳遞給其他函式。但是,C 中的函式不能有自由變數:所有變數在編譯時必須是已知的,這就降低了函式指標作為一種抽象機制的表達能力。

Java 語言提供了內部類,可以包含對封閉物件欄位的引用。該特性比函式指標更強大,因為它允許內部類例項保持對建立它的環境的引用。乍看起來,內部類似乎確實提 供了閉包的大部分作用,雖然這還不是全部作用。您可以很容易構造一個名為 UnaryFunction 的介面,並建立能夠快取任何 unary 函式的快取包裝程式。但是這種方法通常不易於實現,它要求與函式互動的所有程式碼在編寫時都必須知道這個函式的 “框架”。





回頁首


閉包作為一種模式模板

匿名類允許建立這樣的物件,該物件能夠捕獲定義它們的一部分環境,但是物件和程式碼塊不一樣。以一個常見的編碼模式為例,如執行帶有 Lock 的程式碼塊。如果需要遞增帶有 Lock 的計數器,程式碼如清單 2 所示——即使這麼簡單的操作也非常羅嗦:


清單 2. 執行加鎖程式碼塊的規範用法
                
lock.lock();
try {
++counter;
}
finally {
lock.unlock();
}

如果能夠提取出加鎖管理程式碼就好了,這樣會使程式碼看起來更緊湊,也不容易出錯。首先可以建立如清單 3 所示的 withLock() 方法:


清單 3. 提取了 “加鎖執行” 的概念,但是問題在於缺乏異常的透明性
                
public static void withLock(Lock lock, Runnable r) {
lock.lock();
try {
r.run();
}
finally {
lock.unlock();
}
}

不幸的是,這種方法只能達到您預期的部分目標。建立這種抽象程式碼的目標之一是使程式碼更緊湊;但是,匿名內部類的語法不是很緊湊,呼叫程式碼看起來如清單 4 所示:


清單 4. 清單 3 中 withLock() 方法的客戶端程式碼
                
withLock(lock,
new Runnable() {
public void run() {
++counter;
}
});

要遞增一個加鎖的計數器仍然需要編寫很多程式碼!另外,將受到鎖保護的程式碼塊轉化成方法呼叫所帶來的抽象問題大大增加了問題的複雜性——如果受保護的程式碼塊丟擲一個檢測異常怎麼辦?現在我們不能使用 Runnable 來表示執行的任務,而必須建立一種新的表示方法以允許在方法呼叫中丟擲異常。不幸的是,在這裡泛化也幫不上多少忙,雖然方法可以用泛型引數 E, 表示可能丟擲的檢測異常,但是這種方法不能很好地泛化丟擲多種檢測異常型別的方法(這就是為何 Callable 中的 call() 方法宣告為丟擲 Exception 而不是用型別引數指定一個型別的原因)。清單 3 中的方法最大的問題在於缺乏異常透明性,除此之外,還存在其他非透明性的問題,在 清單 4Runnable 上下文中,returnbreak 這類語句的含義,與 清單 2 中 try 語句塊中的一般意義不同。

理想情況下,受保護的遞增操作應該像清單 5 所示的那樣,並且塊中程式碼的含義和 清單 2 的擴充套件形式相同:


清單 5. 清單 3 客戶端程式碼的理想形式(但是是假設形式)
                
withLock(lock,
{ ++counter; });

在語言中新增閉包以後,就可以建立行為類似控制流結構的方法,比如 “加鎖執行這段程式碼”、“操作流並在完成後將其關閉” 或者 “為程式碼塊的執行計時” 等。這種策略有可能簡化某些型別的程式碼,這些程式碼反覆使用特定編碼模式或者慣用法,比如 清單 2 所示的加鎖用法。(在一定程度上提供類似表達能力的另一種技術是 C 前處理器,它可以將 withLock() 操作用預處理巨集表示,雖然和閉包相比巨集更難以組織,而且安全性也更差。)

泛化演算法的閉包

閉包能夠大大簡化程式碼的另一個地方是泛化演算法的使用。隨著多處理器計算機越來越便宜,利用小粒度並行機制的重要性日漸突出。使用泛化演算法定義計算為庫實現在問題空間中採用並行機制提供了一種自然的方式。

比方說,假設要計算一個大型數字集合的平方和。清單 6 給出了一種計算方法,但這種方法是按順序計算結果的,對於大規模多處理器系統可能不是效率最高的方法:


清單 6. 順序計算平方和
                
double sum;
for (Double d : myBigCollection)
sum += d*d;

每次迴圈迭代有兩個操作:取平方,累加到最終結果。平方操作是互相獨立的,可以並行執行;加法操作也不一定要執行 N 次,如果計算組織得當,只要 log(N) 次操作即可完成。

清單 6 中的操作是 map-reduce 演算法的一個示例,對大批資料元素中的每一個數據元素應用一個函式,然後將每次應用該函式計算出的結果通過某種累加函式累加起來。假設有一個 map-reduce 實現過程接受資料集作為輸入,用一元函式處理每個元素,用二元函式累加結果,則可用清單 7 所示的程式碼完成平方和運算:


清單 7. 使用 MapReduce 計算平方和,可以實現並行執行
                
Double sumOfSquares = mapReduce(myBigCollection,
new UnaryFunction<Double> {
public Double apply(Double x) {
return x * x;
}
},
new BinaryFunction<Double, Double> {
public Double apply(Double x, Double y) {
return x + y;
}
});

假設清單 7 中的 mapReduce() 實現知道哪些操作可以並行執行,因而可以將函式應用和累加過程並行執行,從而改進並行系統的吞吐量。但是清單 7 中的程式碼不簡潔,用了更多程式碼來表達和清單 6 中三行程式碼等價的泛化演算法。

通過閉包可以更好地管理清單 7 中的程式碼。比如,清單 8 中的閉包語法和目前提出的 Java 語言閉包方案都不一樣,目的僅在於說明閉包對泛化演算法的支援:


清單 8. 使用 MapReduce 和假設的閉包語法計算平方和
                
sumOfSquares = mapReduce(myBigCollection,
function(x) {x * x},
function(x, y) {x + y});

清單 8 中基於閉包的演算法具有兩方面的好處:程式碼容易閱讀和編寫,抽象層次比順序迴圈更高,能夠有效地通過庫實現並行。





回頁首


閉包方案

目 前至少提出了兩種向 Java 語言增加閉包的方案。其一,綽號為 “BGGA”(名字源於其作者 Gilad Bracha、Neal Gafter、James Gosling 和 Peter von der Ahe),它擴充套件了型別系統,引入了 function 型別。其二,綽號為 “CICE” (代表 Concise Inner Class Expressions,簡潔內部類表示),是由 Joshua Bloch、Doug Lea 和 “瘋狂的” Bob Lee 所支援的,其目標更謙虛:簡化匿名內部類例項的建立。 JSR 可能很快就會收到這方面的提議,考慮在未來的 Java 語言版本中支援閉包的形式和程度。

BGGA 方案

BGGA 方案提出了 function 型別的概念,即函式都帶有一個型別引數列表、返回型別和 throws 子句。在 BGGA 方案中,計算平方和的程式碼將如清單 9 所示:


清單 9. 使用 BGGA 閉包語法計算平方和
                
sumOfSquares = mapReduce(myBigCollection,
{ Double x => x * x },
{ Double x, Double y => x + y });

=> 字元到左側花括號之間的程式碼表示引數的名稱和型別,右側的程式碼表示定義的匿名函式的實現。這段程式碼可以引用塊中定義的區域性變數、閉包的引數以及建立閉包的作用域中的變數。

在 BGGA 方案中,可以宣告 function 型別的變數、方法引數和方法返回值。在需要一個抽象方法類(如 RunnableCallable)例項的任何上下文中都可以使用閉包,對於匿名型別的閉包,您可以使用帶有給定引數列表的 invoke() 方法來呼叫。

BGGA 方案的主要目標之一是允許程式設計師建立行為類似控制結構的方法。因此,BGGA 還在語法上提出了一些吸引人的花招,允許像新的關鍵字那樣呼叫接受閉包的方法,從而能夠建立像 withLock()forEach() 這樣的方法,然後向控制原語一樣呼叫它們。清單 10 說明了根據 BGGA 方案如何定義 withLock() 方法,清單 11清單 12 說明了如何呼叫該方法,包括標準形式和“控制結構”形式:


清單 10. 採用 BGGA 閉包方案編寫的 withLock() 方法
                
public static <T,throws E extends Exception>
T withLock(Lock lock, {=>T throws E} block) throws E {
lock.lock();
try {
return block.invoke();
} finally {
lock.unlock();
}
}

清單 10 中的 withLock() 方法接受鎖和閉包。閉包的返回型別和 throws 子句是泛化引數,編譯器中的型別推斷通常允許在未指定 TE 值的情況下呼叫,如清單 11 和 12 所示:


清單 11. 呼叫 withLock()
                
withLock(lock, {=>
System.out.println("hello");
});


清單 12. 使用控制結構的縮寫形式呼叫 withLock()
                
withLock(lock) {
System.out.println("hello");
}

和泛化一樣,BGGA 方案中閉包的複雜性在很大程度上是由庫的編寫者來分擔的,使用接受閉包的庫方法更簡單。

使用內部類例項是閉包所帶來的好處,但是這種方法缺少透明性,BGGA 方案在一定程度上還有助於解決這個問題。比如,returnbreakthis 在某一程式碼塊中的語義與其在 Runnable(或其他內部類例項)中同一程式碼塊中的語義是不同的。為了利用泛化演算法而對程式碼進行移值的時候,這些不透明因素可能會造成混亂。

CICE 方案

CICE 方案要簡單得多,它解決了例項化內部類例項不太靈活的問題。它沒有建立函式型別的概念,只不過為一個抽象方法(如 RunnableCallableComparator)內部類例項化提出了一種更緊湊的語法。

清單 13 說明了按照 CICE 如何計算平方和。它顯示使用了 mapReduce() 中的 UnaryFunctionBinaryFunction 型別。mapReduce() 的引數是從 UnaryFunctionBinaryFunction 派生的匿名類,這種語法大大了降低了建立匿名例項的冗餘。


清單 13. 採用 CICE 閉包方案計算平方和的程式碼
                
Double sumOfSquares = mapReduce(myBigCollection,
UnaryFunction<Double>(Double x) { return x*x; },
BinaryFunction<Double, Double>(Double x, Double y) { return x+y; });

由於為傳遞給 mapReduce() 的函式所建立的物件是普通的匿名類例項,其函式體可以引用封閉域中定義的變數,清單 13 中的方法和清單 7 相比,唯一的區別在於語法的繁簡程度。





回頁首


結束語

BGGA 方案為 Java 這種語言增加了功能強大的新武器,但是同時也為其語義和語法帶來了可以預見的複雜性。另一方面,CICE 方案更簡單:利用語言中已有的特性並使其更易於使用,但是沒有增加重要的新功能。閉包是一種強大的抽象機制,用過之後多數人不願意放棄。(問問那些熟悉 Scheme、Smalltalk 或 Ruby 程式設計的朋友對閉包的感想如何,他們可能會反問您對呼吸有什麼感想。)但語言是有機的整體,為語言增加最初設計時沒有預料到的新特性充滿了危險,而且會增加 語言的複雜性。爭論的焦點不在於閉包是否有用——因為答案顯然是肯定的——而在於為閉包重新改造 Java 語言的好處是否抵得上要付出的代價。