Java8新特性第1章(Lambda表示式)

張磊BARON發表於2016-09-16

轉載請註明出處:www.jianshu.com/p/6e400da4a…
歡迎大家關注我的知乎專欄:zhuanlan.zhihu.com/baron


在介紹Lambda表示式之前,我們先來看只有單個方法的Interface(通常我們稱之為回撥介面):

public interface OnClickListener {
    void onClick(View v);
}複製程式碼

我們是這樣使用它的:

button.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        v.setText("lalala");
       }
});複製程式碼

這種回撥模式在各種框架中非常流行,但是像上面這樣的匿名內部類並不是一個好的選擇,因為:

  • 語法冗餘;
  • 匿名內部類中的this指標和變數容易產生誤解;
  • 無法捕獲非final區域性變數;
  • 非靜態內部類預設持有外部類的引用,部分情況下會導致外部類無法被GC回收,導致記憶體洩露。

令人高興的是Java8為我們帶來了Lambda,下面我們看看利用Lambda如何實現上面的功能:

button.setOnClickListener(v -> v.setText("lalala"));

怎麼樣?!五行程式碼用一行就搞定了!!!

在這裡補充個概念函式式介面;前面提到的OnClickListener介面只有一個方法,Java中大多數回撥介面都有這個特徵:比如Runnable和Comparator;我們把這些只擁有一個方法的介面稱之為函式式介面

一、Lambda表示式

匿名內部類最大的問題在於其冗餘的語法,比如前面的OnClickListener中五行程式碼僅有一行是在執行任務。Lambda表示式是匿名方法,前面我們也看到了它用極其輕量的語法解決了這一問題。

下面給大家看幾個Lambda表示式的例子:

(int x, int y) -> x + y                      //接收x和y兩個整形引數並返回他們的和
() -> 66                                     //不接收任何引數直接返回66
(String name) -> {System.out.println(name);} //接收一個字串然後列印出來
(View view) -> {view.setText("lalala");}     //接收一個View物件並呼叫setText方法複製程式碼

Lambda表示式語法由引數列表->函式體組成。函式體既可以是一個表示式也可以是一個程式碼塊。

  • 表示式:表示式會被執行然後返回結果。它簡化掉了return關鍵字。
  • 程式碼塊:顧名思義就是一坨程式碼,和普通方法中的語句一樣。

二、目標型別

通過前面的例子我們可以看到,lambda表示式沒有名字,那我們怎麼知道它的型別呢?答案是通過上下文推導而來的。例如,下面的表示式的型別是OnClickListener

OnClickListener listener = (View v) -> {v.setText("lalala");};複製程式碼

這就意味著同樣的lambda表示式在不同的上下文裡有不同的型別

Runnable runnable = () -> doSomething();  //這個表示式是Runnable型別的
Callback callback = () -> doSomething();  //這個表示式是Callback型別的複製程式碼

編譯器利用lambda表示式所在的上下文所期待的型別來推導表示式的型別,這個被期待的型別被稱為目標型別。lambda表示式只能出現在目標型別函式式介面的上下文中。

Lambda表示式的型別和目標型別的方法簽名必須一致,編譯器會對此做檢查,一個lambda表示式要想賦值給目標型別T則必須滿足下面所有的條件:

  • T是一個函式式介面
  • lambda表示式的引數必須和T的方法引數在數量、型別和順序上一致(一一對應)
  • lambda表示式的返回值必須和T的方法的返回值一致或者是它的子類
  • lambda表示式丟擲的異常和T的方法的異常一致或者是它的子類

由於目標型別是知道lambda表示式的引數型別,所以我們沒必要把已知的型別重複一遍。也就是說lambda表示式的引數型別可以從目標型別獲取:

//編譯器可以推匯出s1和s2是String型別
Comparator<String> c = (s1, s2) -> s1.compareTo(s2);
//當表示式的引數只有一個時括號也是可以省略的
button.setOnClickListener(v -> v.setText("lalala"));複製程式碼

ps: Java7中的泛型方法和<>構造器也是通過目標型別來進行型別推導的,如:

List<Integer> intList = Collections.emptyList>();
List<String> strList = new ArrayList<>();複製程式碼

三、作用域

在內部類中使用變數名和this非常容易出錯。內部類通過繼承得到的成員變數(包括來說object的)可能會把外部類的成員變數覆蓋掉,未做限制的this引用會指向內部類自己而非外部類。

而lambda表示式的語義就十分簡單:它不會從父類中繼承任何變數,也不用引入新的作用域。lambda表示式的引數及函式體裡面的變數和它外部環境的變數具有相同的語義(this關鍵字也是一樣)。

下面我們舉個例子吧!

public class HelloLambda {

    Runnable r1 = () -> System.out.println(this);
    Runnable r2 = () -> System.out.println(toString());

    @Override
    public String toString() {
        return "Hello, lambda!";
    }

    public static void main(String[] args) {
        new HelloLambda().r1.run();  
        new HelloLambda().r2.run();
    }
}複製程式碼

上面的程式碼最終會列印兩個Hello, lambda!,與之相類似的內部類則會列印出類似HelloLambda$1@32a890HelloLambda$1@6b32098這種出乎意料的字串。

總結:基於詞法作用域的理念,lambda表示式不可以掩蓋任何其所在上下文的區域性變數。

四、變數捕獲

在Java7中,編譯器對內部類中引用的外部變數(即捕獲的變數)要求非常嚴格:如果捕獲的變數沒有被宣告為final就會產生一個編譯錯誤。但是在Java8中放寬了這一限制--對於lambda表示式和內部類,允許在其中捕獲那些符合有效只讀的區域性變數(如果一個區域性變數在初始化後從未被修改過,那麼它就是有效只讀)。

Runnable getRunnable(String name){
    String hello = "hello";
    return () -> System.out.println(hello+","+name);
}複製程式碼

對於this的引用以及通過this對未限定欄位的引用和未限定方法的呼叫本質上都屬於使用final區域性變數。包含此類引用的lambda表示式相當於捕獲了this例項。在其他情況下,lambda物件不會保留任何對this的應用。

這個特性對記憶體管理是極好的:要知道在java中一個非靜態內部類會預設持有外部類例項的強引用,這往往會造成記憶體洩露。而在lambda表示式中如果沒有捕獲外部類成員則不會保留對外部類例項的引用。

不過儘管Java8放寬了對捕獲變數的語法限制,但試圖修改捕獲變數的行為是被禁止的,比如下面這個例子就是非法的:

int sum  = 0;
list.forEach(i -> {sum += i;});複製程式碼

為什麼要禁止這種行為呢?因為這樣的lambda表示式很容易引起race condition

lambda表示式不支援修改捕獲變數的另外一個原因是我們可以使用更好的方式來實現同樣的效果:使用規約(condition)。java.util.stream包提供了各種規約操作,關於Java8中的Stream API我們放到下一章介紹。

五、方法引用

lambda表示式允許我們定義一個匿名方法,並以函式式介面的方式使用它。Java8能夠在已有的方法上實現同樣的特性。

方法引用和lambda表示式擁有相同的特性(他們都需要一個目標型別,並且需要被轉化為函式式介面的例項),不過我們不需要為方法引用提供方法體,我們可以直接通過方法名引用已有方法。

以下面的程式碼為例,假設我們要按照userName排序

class User{

    private String userName;

    public String getUserName() {
        return userName;
    }
    ...
}

List<User> users = new ArrayList<>();
Comparator<User> comparator = Comparator.comparing(u -> u.getUserName());
Collections.sort(users, comparator);複製程式碼

我們可以用方法引用替換上面的lambda表示式

Comparator<User> comparator = Comparator.comparing(User::getUserName);複製程式碼

這裡的User::getUserName被看做是lambda表示式的簡寫形式。儘管方法引用不一定會把程式碼變得更緊湊,但它擁有更明確的語義--如果我們想要呼叫的方法擁有一個名字,那麼我們就可以通過方法名呼叫它。

方法引用有很多種,它們的語法如下:

  • 靜態方法引用:ClassName::methodName
  • 例項上的例項方法引用:instanceReference::methodName
  • 超類上的例項方法引用:super::methodName
  • 型別上的例項方法引用:ClassName::methodName
  • 構造方法引用:Class::new
  • 陣列構造方法引用:TypeName[]::new

如果大家喜歡我的文章,歡迎關注我的知乎專欄、GitHub、簡書部落格。

相關文章