Java 8: Lambdas, Part 1
了解Java8 中的lambda表達式
對開發人員來說沒有什麼比自己選擇的語言或平台發布新版本更令人激動了。Java開發者也不例外。實際上,我們更期待新版本的發布,有一部分原因是因為在不久前我們還在考慮Java的前途,因為Java的創造者——Sun在衰落。一次與死亡的擦肩而過會使人更加珍惜生命。但在這種情況下,我們的熱情來源不像以前發布版本時那樣,這次是來源於事實。Java 8最終會獲得一些我們期待了幾十年的“現代”語言特性。
當然,Java 8主要的改變集中在lambdas(或者叫閉包),這也是這兩篇文章主要討論的內容。但是一個語言特性,就其本身而言它的出現除非其背後有一定的支持,如果它不實用或有趣。Java 7的幾個特點符合這種描述:例如,增強數值文本不能讓大多數人注意。
然而,這次不僅僅是作為Java 8函數式語言改變的一個核心部分,而且它們的引入帶來了一些能讓它們更易使用的附加語言特性,同樣一些包的改進也使那些特性能直接使用。這將能讓我們更容易的做一個Java開發者。
Java Magazine在之前發表過lambdas的文章,但寫這篇文章時可能比較之前的語法有修改了,並且並不是所有的人都有時間去上麵閱讀。我將假設讀者從沒有讀過那篇文章。
注意:這篇文章是以即將發布的Java SE 8為基礎的,同樣的,可能與最終發布版本時有一點區別。因為在最終發布之前語法與語義總會改變。
對於那些想更深入的了解這方麵內容的人,我推薦Brian Goetz’論文,比如“State of the Lambda: Libraries Edition” 和在Lambda主頁上的其它文章,這些文章都有很好的參考價值。
背景:功能函數
Java一直需要功能性對象(也可以稱為功能函數),雖然我們在社區中為淡化其的影響而一直掙紮。在Java的早些年,當我們建立GUI時,我們需要像窗口打開、關閉、按鈕按下和滾動條移動這樣的響應用戶事件的代碼塊。
在Java 1.0中,抽象窗口工具包(AWT)應用被期待像它的C++前輩一樣去擴展窗口類和覆蓋選擇的事件方法;這被認為是笨拙的和不可行的。所以在Java 1.1 Sun給我們一係列監聽接口,每一個接口對應一個或多個GUI事件方法。
CODE = OBJECT
代碼=對象
隨著Java的成長和成熟,我們發現在很多地方我們把代碼塊當做對象(數據)不僅很有用並且很必要。
但是為了更簡單的去寫這些類,必須實現這些接口和接口中相關連的方法。Sun給了我們內部類,其中匿名內部類可以在已存在的類的內部不需要特別命名而去實現一個類。(順便說一句,監聽事件並不是在Java曆史中唯一的例子。我們稍後會看到更“核心的”接口,例如:Runnable和Comparator。)
內部類對它們來說不管在語法還是語義上都有一些陌生。例如,決定內部類是靜態內部類或實例內部類,並不是由指定的關鍵字決定的(當然靜態內部類,可以用static關鍵字聲明),而是由實例被創建的語境決定的。實際情況中,Java開發者經常在麵試中被問到Listing 1中所展示的錯誤。
Listing 1
class InstanceOuter { public InstanceOuter(int xx) { x = xx; } private int x; class InstanceInner { public void printSomething() { System.out.println("The value of x in my outer is " + x); } } } class StaticOuter { private static int x = 24; static class StaticInner { public void printSomething() { System.out.println("The value of x in my outer is " + x); } } } public class InnerClassExamples { public static void main(String... args) { InstanceOuter io = new InstanceOuter(12); // Is this a compile error? InstanceOuter.InstanceInner ii = io.new InstanceInner(); // What does this print? ii.printSomething(); // prints 12 // What about this? StaticOuter.StaticInner si = new StaticOuter.StaticInner(); si.printSomething(); // prints 24 } }
像內部類這樣的“特點”一直讓Java開發者認為是適合麵試而不是其它用途的Java角落裏的知識——除非我們用到它。即便如此,大多數時候它們隻被用在事件處理上。
Above and Beyond超出本文範圍的內容
然而,隨著語法和語義的越來截止臃腫,係統仍在運行。隨著Java的成長和成熟,我們發現很多地方把代碼塊當作對象(數據)並不僅僅是有用而且是必要的。在Java SE1.2修訂後的安全係統發現傳入一個代碼塊在不同的安全上下文中執行非常有用。Java8 修改後的Collection類發現傳入一段代碼塊順便去了解如何在排序的集合中排序是非常有用的。Swing發現傳一段代碼塊順便去決定用戶打開文件時展示哪些文件很有用,等等。它起作用——雖然它的語法讓人很不喜歡。
但是當函數式編程要進入主流編程時,所有人都放棄了。雖然可行(參考這個非常完整的例子),無論如何,函數式編程在Java中都是棘手的。Java需要成長和加入主流的編程語言,並為定義、傳遞、存儲後執行代碼塊提供一流的語言支持。
Java8: Lambdas,目標類型和詞法作用域(Lexical Scoping)
Java 8 介紹了幾種新的語言特性目的是讓寫這樣的代碼更加容易——其中最主要的是lambda表達式,通俗稱為閉包(原因我們以後更說)或者匿名方法。接下來讓我們一條一條解釋。
Lambda 表達式。從根本上說,lambda表達式隻是簡單的實現稍後執行的方法。因此,當我們在Listing 2中定義一個Runnable,這個Runnable是用匿名內部類直接實現(這意味著需要寫很多行代碼)。但是,Java 8中的lambda允許我們像Listing 3中那樣實現。
Listing 2
public class Lambdas { public static void main(String... args) { Runnable r = new Runnable() { public void run() { System.out.println("Howdy, world!"); } }; r.run(); } }
Listing 3
public static void main(String... args) { Runnable r2 = () -> System.out.println("Howdy, world!"); r2.run(); }
這兩種方法可以得到相同的結果:一個實現Runnable的對象,其中的run()方法被調用,並輸出結果。然而,在底層Java 8並不是僅僅實現了一個Runnable接口的匿名類——其中一些需要Java 7 中介紹的調用動態字節碼。我們將不會去深入討論這方麵的內容,但是你要知道這不是“僅僅”實現了一個匿名類接口。
函數式接口。Runnable接口、Callable<T>接口、Comparator<T>接口,和Java中定義的其它大量接口——在Java 8中我們稱為函數式接口:它們是隻需要實現一個方法去滿足需求的接口。這就是為什麼它實現起來很簡潔的原因,因為這樣你可以很確切的知道需要實現哪個方法。
Java 8 的設計者給了我們一個注釋,@FunctionalInterface,它被當作接口使用lambdas的一個文檔提示,但是編譯器不需要這個——它決定了”功能性接口”是從接口的結構而來,而不是從注釋。
這一整篇文章,我們將會用Runnale和Comparator<T>接口作為例子,這不是因為它們有什麼特別之處,除了它們是單方法接口外。任何開發者任何時間可以定義一個新的接口,像下麵的例子那樣,它都可以使用lambda實現。
interface Something { public String doit(Integer i); }
Something接口是像Runnable和Comparator<T>那樣完全合法的功能性接口;我們將在看一些lambda例子後再分析這個。
語法。Java中的lambda本質上有三部分組成:一些參數加上括號,一個箭頭和實體,它可以是一個單獨的表達式或一塊代碼。像Listing 2中的例子那樣,run不需要參數並且返回void,所以那個不需要參數和返回值。但是Listing 4中展示的Comparator<T>例子,符合上麵的三個條件。Comparator需要兩個string並且需要返回integer類型的負值(小於)、正值(大於)和0(相等)。
Listing 4
public static void main(String... args) { Comparator<String> c = (String lhs, String rhs) -> lhs.compareTo(rhs); int result = c.compare("Hello", "World"); }
如果lambda本身需要多個表達式,則表達式可以被當做返回值調用,像其它的Java代碼塊那樣(參考Listing 5)。
Listing 5
public static void main(String... args) { Comparator<String> c = (String lhs, String rhs) -> { System.out.println("I am comparing" + lhs + " to " + rhs); return lhs.compareTo(rhs); }; int result = c.compare("Hello", "World"); }
(像Listing 5列出的花括號中的代碼將會在未來幾年主導Java留言板和博客。)lambda寫代碼有幾個限製,其中大部分都很直觀——不能使用”break”或”continue”跳出lambda,並且如果lambda返回一個值,每一個代碼路徑都要返回一個值或拋出異常,等等。普通的方法也有類似的規則,所以不要大驚小怪。
推理類型。另一個被其它語言使用的概念是推理類型:編譯器應該足夠聰明去辨認出這個參數應該是什麼類型,而不是強製開發者是重新輸入參數。
就像Listing 5中的Comparator的例子。如果目標類型是Comparator<String>,傳入lambda中的類型就必須是string;否則代碼將不能編譯。
在這種情況下在lhs和rhs前麵再聲明String是完全多餘的,多謝Java 8增強了類型推斷機製,如Listing 6他們是完全可選的。
Listing 6
public static void main(String... args) { Comparator<String> c = (lhs, rhs) -> { System.out.println("I am comparing" + lhs + " to " + rhs); return lhs.compareTo(rhs); }; int result = c.compare("Hello", "World"); }
語言規範中有準確的規則時,需要明確聲明lambda正式類型,但在大多數情況下它被當做默認的,而不是需要特別注明的,所以參數類型的聲明可能會被完全排除。
Java的lambda語法在Java史中一個有趣的影響是,我們發現不需要分配一個指定類型的引用對象(參考Listing 7)——至少不是沒有幫助。
Listing 7
public static void main4(String... args) { Object o = () -> System.out.println("Howdy, world!"); // will not compile }
編譯器可能會抱怨Object不是一個功能性接口,盡管真正的原因是編譯器並不能理解這個lambda需要實現哪個功能性接口:Runnable或者是其它的?我們可以用一個例子來幫助編譯器,如Listing 8。
Listing 8
public static void main4(String... args) { Object o = (Runnable) () -> System.out.println("Howdy, world!"); // now we're all good }
從前lambda語法適用於任何接口,所以一個lambda可以很容易實現一個定製接口,像Listing 9。順便說一句,原始類型與它們的包裝類型在Lambda類型簽名中一樣。
Listing 9
Something s = (Integer i) -> { return i.toString(); }; System.out.println(s.doit(4));
再一次,這是真正新的東西;Java 8隻是應用了Java的長期原則、模式和語法。如果還是明白,就花幾分鍾時間去探索下代碼中的類型推理。
詞法作用域(Lexical scoping)。這是新的,對於編譯器在lambda和內部類中處理名稱的方式。參考在Listing 10內部類的例子。
Listing 10
class Hello { public Runnable r = new Runnable() { public void run() { System.out.println(this); System.out.println(toString()); } }; public String toString() { return "Hello's custom toString()"; } } public class InnerClassExamples { public static void main(String... args) { Hello h = new Hello(); h.r.run(); } }
當我運行Listing 10中的代碼,在我們機器上會直接輸出“Hello$1@f7ce53”。原因很簡單:在匿名Runnable的實現中包括的this和toString是綁定在匿名內部類實現的,因為這是滿足要求的最內層範圍。
如果我們需要打印出Hello版本的toString,我們不得不明確使用Java規範中內部類的”outerthis”語法,如Listing 11。
Listing 11
class Hello { public Runnable r = new Runnable() { public void run() { System.out.println(Hello.this); System.out.println(Hello.this.toString()); } }; public String toString() { return "Hello's custom toString()"; } }
坦白的講,這是其中一點比起內部類解決的問題,它給我們製造了更多的困惑。當然,直到解釋this關鍵字出現在這不直觀的語法中的原因時發現它是有意義的,但是它的意義在於讓狡辯者找到借口。
然而,Lambdas是語法作用域,意義是lambda辨認出它定義周圍的直接環境作為它的下一層作用域。所以Listing 12中的lambda例子會產生Listing 11中第二個例子的效果,但這種形式語法上更直觀。
Listing 12
class Hello { public Runnable r = () -> { System.out.println(this); System.out.println(toString()); }; public String toString() { return "Hello's custom toString()"; } }
順便說一句,這意味著this不是引用的lambda,這可能在某些情況下很有用——但是這種情況非常少。而且,如果這種情況出現了(例如,也許一個lambda需要返回一個lambda,並且要返回它自身),這裏有一個相對簡單的方法,我們稍後會講。
變量捕捉(Variable capture)。lambda被稱為是閉包的一部分原因是,一個函數文本(function literal)(比如我們之前寫過的)能夠“覆蓋(Close over)”在作用域內函數文本體之外的引用變量(對於Java,這通常是lambda的方法被定義)。內部類也能這樣做,但是所有令Java開發都失望的部分大都關於內部類,實際上,隻能從作用域引用在它本身定義的頂部的”final”變量。
Lambda放寬了限製,但是隻是放寬了一點:隻要引用變量還是”有效的final“,這就是意味著它還是final,這樣lambda可以引用它(例如Listing 13)。因為message在main內不會被修改,包括lambda被定義。這就是有效的final,並且,有資格被Runnable lambda存儲在r中。(譯者注:這裏的意思是message不會被修改,而不是不能被修改)
Listing 13
public static void main(String... args) { String message = "Howdy, world!"; Runnable r = () -> System.out.println(message); r.run(); }
從表麵上看好像沒有什麼,但記住lambda語法規則並不改變Java的性質。總的來說,引用在lambda的定義之後,是可以被訪問和修改的,例如Listing 14。
Listing 14
public static void main(String... args) { StringBuilder message = new StringBuilder(); Runnable r = () -> System.out.println(message); message.append("Howdy, "); message.append("world!"); r.run(); }
熟悉老版本內部類語法的精明開發者,應該記得這也是被內部類引用的真正的“final”引用——final隻被應用於引用上,而不是引用另一邊的對象(譯者注:如果要在老版本的Java內部類中使用message,這個message就必須是final)。這個在Java社區中仍被視為一個bug或者特性,但目前就是這樣,並且,為了避免出錯,開發者應該理解Lambda是怎麼捕獲變量的。(實事上,這種行為並不是新的——這隻是重做Java在減少輸入這樣已有的功能,和從編程器處得到更多的支持。)
方法引用。到目前為止,我們所有的lambda的例子都是匿名的——本質上,lambda需要在它使用的地方定義。這種對單一場景使用非常有幫助,但是對多場景使用用處不大。例如,下麵的Person類(這時請忽略封裝)。
class Person { public String firstName; public String lastName; public int age; });
如果把一個Person放到SortedSet中,或者它需要以某種形式排序,我們將需要不同的機製來決定Person怎麼排序——例如,有時是以firstName,有時會以lastName排序。這就是Comparator<T>的作用:允許我們通過傳入Comparator<T>一個實例來決定怎麼排序。
SCOPE IT OUT注意
Lambdas是作用域,意思是lambda會辨認出它定義周圍的直接環境作為它的下一層作用域。
Lambda能寫出比較簡單的排序代碼,如Listing 15。但是,使用firstName排序Person對象,可能會在之後用到很多次,這現在這樣的代碼無疑違反了不重複自己(Dont’t Repeat Yourself)原則。
Listing 15
public static void main(String... args) { Person[] people = new Person[] { new Person("Ted", "Neward", 41), new Person("Charlotte", "Neward", 41), new Person("Michael", "Neward", 19), new Person("Matthew", "Neward", 13) }; // Sort by first name Arrays.sort(people, (lhs, rhs) -> lhs.firstName.compareTo(rhs.firstName)); for (Person p : people) System.out.println(p); }
Comparator可以被當作Person,像Listing 16。然後,Comparator<T>也像其它靜態字段一樣被引用,像Listing 17。我確信函數式編程的狂熱愛好者非常喜歡這種方式,因為它允許以多種方式組合功能。
Listing 16
class Person { public String firstName; public String lastName; public int age; public final static Comparator<Person> compareFirstName = (lhs, rhs) -> lhs.firstName.compareTo(rhs.firstName); public final static Comparator<Person> compareLastName = (lhs, rhs) -> lhs.lastName.compareTo(rhs.lastName); public Person(String f, String l, int a) { firstName = f; lastName = l; age = a; } public String toString() { return "[Person: firstName:" + firstName + " " + "lastName:" + lastName + " " + "age:" + age + "]"; } }
Listing 17
public static void main(String... args) { Person[] people = . . .; // Sort by first name Arrays.sort(people, Person.compareFirstName); for (Person p : people) System.out.println(p); }
但是,傳統的Java開發者會感覺很奇怪,與簡單的創建一個符合Comparator<T>的方法然後直接使用相反——這正是一個方法引用所允許的(如Listing 18)。注意用::形式,這告訴編譯器定義在Person裏的compareFirstNames在這裏應該被用到,而不是簡單的字麵方法(method literal)。
Listing 18
class Person { public String firstName; public String lastName; public int age; public static int compareFirstNames(Person lhs, Person rhs) { return lhs.firstName.compareTo(rhs.firstName); } // ... } public static void main(String... args) { Person[] people = . . .; // Sort by first name Arrays.sort(people, Person::compareFirstNames); for (Person p : people) System.out.println(p); }
對那些好奇的人來說,這是另一種使用的方法,我們可以使用compareFirstNames方法去創建一個Comparator<Person>實例,像下麵這樣:
Comparator cf = Person::compareFirstNames;
當然,還能然再簡潔,我們還可以通過使用一些新的包特性來完全避免一些語法開銷,利用高階的函數(意思是,更粗略,一個函數傳另一些函數)從根本上避免之前的一行一行的代碼。
Arrays.sort(people, comparing(
Person::getFirstName));
這就是函數式編程技術為什麼那麼強大的一部分原因。
虛擬擴展方法。然而,關於接口被提及的一個缺點是,它們沒有默認實現,既然當實現是非常明顯的時候。例如,假如有一個Relational接口,它定義了一係列假想的關係方法(大於,小於,大於或等於,等等)。隻要其中的一個方法被定義,你就會發現其它的方法可以依據這個方法實現。實際上,如果提前知道Comparable<T>中的compare方法,所有的這些方法都可以通過compare方法實現。但是,接口不能有默認行為,並且抽象類也是一個類,Java隻能實現單繼承。
然而,在Java 8中這樣的函數變的很普遍,它變的更加重要的原因是能夠指定默認行為沒失去接口的“接口性”。因此,Java 8現在介紹虛擬擴展方法(在之前的版本中被稱為保守方法),如果沒有派生的實現,本質上允許一個接口提供一個默認方法。
回想一下Iterator接口。現在它有三個方法(hasNext,next和remove),每一個都必須定義。但是,在iteration流中“跳躍”到下一個元素可能很有用。並且,因為Iterator的這個方法很容易利用其它三個方法實現,我們在Listing 19中提供了實現。
Listing 19
interface Iterator<T> { boolean hasNext(); T next(); void remove(); void skip(int i) default { for (; i > 0 && hasNext(); i--) next(); } }
有一些可能會在Java社區中引起爭議,聲明這些是弱化接口的作用,並運用這種形式實現多繼承。在某種程度上就是這樣,特別是在默認實現的優先級方麵(如果一個類繼承了多個接口,並且相同的方法有不同的實現的情況)的規則需要大量的研究。
了解更多 |
但是,正如它的名字暗示的一樣,虛擬擴展方法提供了一個強大的擴展己存在接口的機製,並且不需要在它的實現類中再去實現該方法。運用這樣的機製,Oracle可以為現有的包提供附加的、更強大的實現,而不需要開發者去逐一實現其下的類。沒有SkippingIterator類,現在開發都需要去尋找集合去提供支持。實際上,代碼不需要修改任何地方,所有的Iterator<T>,不管什麼時候寫的,它將自動擁有這個行為。
通過虛擬擴展方法,在Collection類中將會發生很多的改變。好的消息是你的Collection類將會得到很多新的方法,更好的消息是你的代碼在此期間並不需要做任何改變。不好的消息是我們將在這個係列的另一篇文章中繼續討論。
總結
Lambdas能給Java帶來很多改變,包括怎麼樣寫和設計Java代碼。其中的一些改變是函數式編程帶來的,這將會改變Java程序員寫代碼的方式——這是個機會也是個挑戰。
我們將在另一篇文章中更深入的討論這些改變給Java庫帶來的影響,並且我們將會花一些時間去討論這些新的API、接口和類去設計一些以前由於內部類的原因而不能去實現的方法。
Java 8是一個非常有趣的版本。係好安全帶,這將是一次火箭式旅行。
最後更新:2017-05-23 16:33:57