Java 8 之基礎篇
1. lambda表示式
從java8出現以來lambda,也可稱為閉包(closure),是最重要的特性之一,它可以讓我們用簡潔流暢的程式碼完成一個功能。 很長一段時間java被吐槽是冗餘和缺乏函數語言程式設計能力的語言,隨著函數語言程式設計的流行java8種也引入了這種程式設計風格。
1.1 什麼是Lambda?
Lambda表示式是一段可以傳遞的程式碼,它的核心思想是將面向物件中的傳遞資料變成傳遞行為。 我們回顧一下在使用java8之前要做的事,之前我們編寫一個執行緒時是這樣的:
Runnable r = new Runnable() { @Override public void run() { System.out.println("do something."); } }
這實際上是一個程式碼即資料的例子,在run方法中是執行緒要執行的一個任務,但上面的程式碼中任務內容已經被規定死了。當我們有多個不同的任務時,需要重複編寫如上程式碼。
設計匿名內部類的目的,就是為了方便 Java 程式設計師將程式碼作為資料傳遞。不過,匿名內部類還是不夠簡便。為了執行一個簡單的任務邏輯,不得不加上6 行冗繁的樣板程式碼。那如果是Lambda該怎麼做?
Runnable r = () -> System.out.println("do something.");
這是一個沒有名字的函式,也沒有任何引數,再簡單不過了。 使用->將引數和實現邏輯分離,當執行這個執行緒的時候執行的是->之後的程式碼片段,且編譯器幫助我們做了型別推導。
如上所示,Lambda表示式一個常見的用法是取代某些匿名內部類,但Lambda表示式的作用不限於此。
剛接觸Lambda表示式可能覺得它很神奇:不需要宣告類或者方法的名字,就可以直接定義函式。這看似是編譯器為匿名內部類簡寫提供的一個小把戲,但事實上並非如此,Lambda表示式實際上是通過invokedynamic
1.2 基礎語法
在Lambda中我們遵循如下的表示式來編寫:
expression = (variable) -> action
- variable: 這是一個變數,一個佔位符。像x,y,z,可以是多個變數;
- action:這是我們實現的程式碼邏輯部分,它可以是一行程式碼也可以是一個程式碼片段。
下面是Lambda表示式幾種可能的書寫形式。
Runnable run = () -> System.out.println("Hello World");// 1 ActionListener listener = event -> System.out.println("button clicked");// 2 Runnable multiLine = () -> {// 3 System.out.println("Hello "); System.out.println("World"); }; BinaryOperator<Long> add = (Long x, Long y) -> x + y;// 4 BinaryOperator<Long> addImplicit = (x, y) -> x + y;// 5
通過上例可以發現:
Lambda表示式是有型別的,賦值操作的左邊就是型別。Lambda表示式的型別實際上是對應介面的型別。
Lambda表示式可以包含多行程式碼,需要用大括號把程式碼塊括起來,就像寫函式體那樣。
大多數時候,Lambda表示式的引數表可以省略型別,就像程式碼2和5那樣。這得益於javac的型別推導機制,編譯器可以根據上下文推匯出型別資訊。
表面上看起來每個Lambda表示式都是原來匿名內部類的簡寫形式,該內部類實現了某個函式介面(Functional Interface),但事實比這稍微複雜一些,這裡不再展開。Java是強型別語言,無論有沒有顯式指明,每個變數和物件都必須有明確的型別,沒有顯式指定的時候編譯器會嘗試確定型別。
1.3 函式式介面
來看下jdk 8中的Runnable原始碼
@FunctionalInterface
public interface Runnable {
public abstract void run();
}
Runnable介面只有一個方法,大多數回撥介面都擁有這個特徵:比如Callable介面和Comparator介面。我們把這些只擁有一個方法的介面稱為函式式介面。
我們並不需要額外的工作來宣告一個介面是函式式介面:編譯器會根據介面的結構自行判斷(判斷過程並非簡單的對介面方法計數:一個介面可能冗餘的定義了一個Object已經提供的方法,比如toString(),或者定義了靜態方法或預設方法,這些都不屬於函式式介面方法的範疇)。不過API作者們可以通過 *@FunctionalInterface 註解來顯式指定一個介面是函式式介面(以避免無意聲明瞭一個符合函式式標準的介面),加上這個註解之後,編譯器就會驗證該介面是否滿足函式式介面的要求。
Lambda表示式必須對應一個函式式介面,方法體其實就是函式介面的實現。編譯器利用Lambda表示式所在上下文所期待的型別進行推導,這個被期待的型別被稱為目標型別。Lambda表示式只能出現在目標型別為函式式介面的上下文中。
2. 方法引用
我們通常使用Lambda表示式來建立匿名方法。然而,有時候我們僅僅是呼叫了一個已存在的方法。如下:
Arrays.sort(stringsArray,(s1,s2) -> s1.compareToIgnoreCase(s2));
實際上,compareToIgnoreCase就是String類中現成的一個方法,在Java8中,我們可以直接通過方法引用來簡寫Lambda表示式中已經存在的方法。
Arrays.sort(stringsArray, String::compareToIgnoreCase);
這種特性就叫做方法引用(Method Reference)。
方法引用其實是Lambda表示式的一個簡化寫法,所引用的方法其實是Lambda表示式的方法體實現,語法也很簡單,如下所示:
ObjectReference::methodName
方法引用是用來直接訪問類或者例項的已經存在的方法。計算時,方法引用會建立函式式介面的一個例項。
當Lambda表示式中只是執行一個方法呼叫時,不用Lambda表示式,直接通過方法引用的形式可讀性更高一些。方法引用是一種更簡潔易懂的Lambda表示式。
方法引用的型別可分為以下四種:
2.1 靜態方法引用
組成語法格式:ClassName::staticMethodName
我們前面舉的例子Person::compareByAge就是一個靜態方法引用。
靜態方法引用比較容易理解,和靜態方法呼叫相比,只是把【.】換為【::】。在目標型別相容的任何地方,都可以使用靜態方法引用。
例子:
String::valueOf
等價於Lambda表示式(s) -> String.valueOf(s)
Math::pow
等價於Lambda表示式 (x, y) -> Math.pow(x, y);
2.2 特定例項物件的方法引用
這種語法與用於靜態方法的語法類似,只不過這裡使用物件引用而不是類名。例項方法引用又分以下三種類型:
2.2.1 例項上的例項方法引用
組成語法格式:instanceReference::methodName
如下示例,引用的方法是myComparisonProvider 物件的compareByName方法。
class ComparisonProvider{
public int compareByName(Person a, Person b){
return a.getName().compareTo(b.getName());
}
public int compareByAge(Person a, Person b){
return a.getBirthday().compareTo(b.getBirthday());
}
}
ComparisonProvider myComparisonProvider = new ComparisonProvider();
Arrays.sort(rosterAsArray, myComparisonProvider::compareByName);
2.2.2 超類上的例項方法引用
組成語法格式:super::methodName
方法的名稱由methodName指定,通過使用super,可以引用方法的超類版本。
例子:
還可以捕獲this指標,this :: equals
等價於Lambda表示式 x -> this.equals(x)
2.2.3 型別上的例項方法引用
組成語法格式:ClassName::methodName
注意:若型別的例項方法是泛型的,就需要在::分隔符前提供型別引數,或者(多數情況下)利用目標型別推匯出其型別。
靜態方法引用和型別上的例項方法引用擁有一樣的語法。編譯器會根據實際情況做出決定。一般我們不需要指定方法引用中的引數型別,因為編譯器往往可以推匯出結果,但如果需要我們也可以顯式在::分隔符之前提供引數型別資訊。
例子:
String::toString
等價於Lambda表示式(s) -> s.toString()
這裡不太容易理解,例項方法要通過物件來呼叫,方法引用對應Lambda,Lambda的第一個引數會成為呼叫例項方法的物件。
任意物件(屬於同一個類)的例項方法引用
如下示例,這裡引用的是字串陣列中任意一個物件的compareToIgnoreCase方法。
String[] stringArray = { "Barbara", "James", "Mary", "John", "Patricia",
"Robert", "Michael", "Linda" };
Arrays.sort(stringArray, String::compareToIgnoreCase);
2.3 構造方法引用
構造方法引用又分構造方法引用和陣列構造方法引用。
2.3.1 構造方法引用(也可以稱作構造器引用)
組成語法格式:Class::new
建構函式本質上是靜態方法,只是方法名字比較特殊,使用的是new 關鍵字。
例子:
String::new
等價於Lambda表示式 () -> new String()
2.3.2 陣列構造方法引用
組成語法格式:TypeName[]::new
例子:
int[]::new
是一個含有一個引數的構造器引用,這個引數就是陣列的長度。等價於Lambda表示式 x -> new int[x]
。
假想存在一個接收int引數的陣列構造方法
IntFunction<int[]> arrayMaker = int[]::new;
int[] array = arrayMaker.apply(10) // 建立陣列 int[10]
3. 變數作用域
3.1 匿名內部類中的外部變數
在Java的經典著作《Effective Java》、《Java Concurrency in Practice》裡,大神們都提到:匿名函式裡的變數引用,也叫做變數引用洩露,會導致執行緒安全問題,因此在Java8之前,如果在匿名類內部引用函式區域性變數,必須將其宣告為final,即不可變物件。(Python和Javascript從一開始就是為單執行緒而生的語言,一般也不會考慮這樣的問題,所以它的外部變數是可以任意修改的)。
為什麼必須要為final呢?
首先我們知道在內部類編譯成功後,它會產生一個class檔案,該class檔案與外部類並不是同一class檔案,僅僅只保留對外部類的引用。當外部類傳入的引數需要被內部類呼叫時,從java程式的角度來看是直接被呼叫:
public class OuterClass {
public void display(final String name,String age){
class InnerClass{
void display(){
System.out.println(name);
}
}
}
}
從上面程式碼中看好像name引數應該是被內部類直接呼叫?其實不然,在java編譯之後實際的操作如下:
public class OuterClass$InnerClass {
public InnerClass(String name,String age){
this.InnerClass$name = name;
this.InnerClass$age = age;
}
public void display(){
System.out.println(this.InnerClass$name + "----" + this.InnerClass$age );
}
所以從上面程式碼來看,內部類並不是直接呼叫方法傳遞的引數,而是利用自身的構造器對傳入的引數進行備份,自己內部方法呼叫的實際上時自己的屬性而不是外部方法傳遞進來的引數。
在內部類中的屬性和外部方法的引數兩者從外表上看是同一個東西,但實際上卻不是,所以他們兩者是可以任意變化的,也就是說在內部類中我對屬性的改變並不會影響到外部的形參,而然這從程式設計師的角度來看這是不可行的,畢竟站在程式的角度來看這兩個根本就是同一個,如果內部類改變了,而外部方法的形參卻沒有改變,這是難以理解和不可接受的,所以為了保持引數的一致性,就規定使用final來避免形參的不改變。
簡單理解就是,拷貝引用,為了避免引用值發生改變,例如被外部類的方法修改等,而導致內部類得到的值不一致,於是用final來讓該引用不可改變。
3.2 Lambda中的外部變數
在Java8裡,有了一些改動,現在我們可以這樣寫Lambda或者匿名類了:
public static Supplier<Integer> testClosure() {
int i = 1;
return () -> {
return i;
};
}
這裡我們不用寫final了。但是,Java大神們說的引用洩露怎麼辦呢?其實本質沒有變,只是Java8這裡加了一個語法糖:在Lambda表示式以及匿名類內部,如果引用某區域性變數,則直接將其視為final。我們直接看一段程式碼吧:
public static Supplier<Integer> testClosure() {
int i = 1;
i++;
return () -> {
return i; //這裡會出現編譯錯誤
};
}
其實這裡我們僅僅是省去了變數的final定義,這裡i會強制被理解成final型別。很搞笑的是編譯錯誤出現在Lambda表示式內部引用i的地方,而不是改變變數值的地方。這也是Java的Lambda的一個被人詬病的地方。只能說,強制閉包裡變數必須為final,出於嚴謹性還可以接受,但是這個語法糖有點酸酸的感覺,還不如強制寫final……
4. 預設方法
在Java語言中,一個介面中定義的方法必須由實現類提供實現。但是當介面中加入新的API時,實現類按照約定也要修改實現,而Java8的API對現有介面也添加了很多方法,比如List介面中添加了sort方法。 如果按照之前的做法,那麼所有的實現類都要實現sort方法,JDK的編寫者們一定非常抓狂。
Java8種引入新的機制,支援在介面中宣告方法同時提供實現。有兩種方式完成
4.1 介面內定義預設方法。
我們來看看在JDK8中上述List介面新增方法的問題是如何解決的
default void sort(Comparator<? super E> c) {
Object[] a = this.toArray();
Arrays.sort(a, (Comparator) c);
ListIterator<E> i = this.listIterator();
for (Object e : a) {
i.next();
i.set((E) e);
}
}
翻閱List介面的原始碼,其中加入一個預設方法default void sort(Comparator<? super E> c)
。 在返回值之前加入default關鍵字,有了這個方法我們可以直接呼叫sort方法進行排序。
List<Integer> list = Arrays.asList(2, 7, 3, 1, 8, 6, 4);
list.sort(Comparator.naturalOrder());
System.out.println(list);
Comparator.naturalOrder()
是一個自然排序的實現,這裡可以自定義排序方案。你經常看到使用Java8操作集合的時候可以直接foreach的原因也是在Iterable介面中也新增了一個預設方法:forEach,該方法功能和for 迴圈類似,但是允許使用者使用一個Lambda表示式作為迴圈體。
和其它方法一樣,預設方法也可以被繼承。不過,當型別或者介面的超類擁有多個具有相同簽名的方法時,我們就需要一套規則來解決這個衝突:
- 類的方法宣告優先於介面預設方法。無論該方法是具體的還是抽象的。
- 被其它型別所覆蓋的方法會被忽略。這條規則適用於超型別共享一個公共祖先的情況。
為了演示第二條規則,我們假設Collection和List介面均提供了removeAll的預設實現,然後Queue繼承並覆蓋了Collection中的預設方法。在下面的implement從句中,List中的方法宣告會優先於Queue中的方法宣告:
class LinkedList<E> implements List<E>, Queue<E> { ... }
當兩個獨立的預設方法相沖突或是預設方法和抽象方法相沖突時會產生編譯錯誤。這時程式設計師需要顯式覆蓋超類方法。一般來說我們會定義一個預設方法,然後在其中顯式選擇超類方法:
interface Robot implements Artist, Gun {
default void draw() { Artist.super.draw(); }
}
最後,介面在inherits和extends從句中的宣告順序和它們被實現的順序無關。
4.2 介面內定義靜態方法
除了預設方法,Java SE 8還在允許在介面中定義靜態方法。這使得我們可以從介面直接呼叫和它相關的輔助方法,而不是從其它的類中呼叫(之前這樣的類往往以對應介面的複數命名,例如Collections)。比如,我們一般需要使用靜態輔助方法生成實現Comparator的比較器,在Java SE 8中我們可以直接把該靜態方法定義在Comparator介面中:
public static <T, U extends Comparable<? super U>>
Comparator<T> comparing(Function<T, U> keyExtractor) {
return (c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2));
}