《Java 8函數語言程式設計》選讀:第一個Lambda表示式

群峰發表於2014-10-23

Java 8的最大變化是引入了Lambda表示式——一種緊湊的、傳遞行為的方式。它也是本書後續章節所述內容賴以依存的基礎,因此,接下來就瞭解一下什麼是Lambda表示式。

2.1 第一個Lambda表示式

Swing是一個與平臺無關的Java類庫,用來編寫圖形使用者介面(GUI)。該類庫有一個常見用法:為了響應使用者操作,需要註冊一個事件監聽器。一旦使用者輸入,監聽器會執行一些操作。(見例2-1)。

例2-1 使用匿名內部類將行為和按鈕單擊進行關聯

button.addActionListener(new ActionListener() {
    public void actionPerformed(ActionEvent event) {
        System.out.println("button clicked");
    }
});

在這個例子中,我們建立了一個新物件,該物件實現了ActionListener介面。這個介面只有一個方法:actionPerformed,當使用者點選螢幕上的按鈕時,該方法就會被button呼叫。匿名內部類實現了該方法。在例2-1中該方法所執行的只是列印出一條資訊,表明按鈕已被點選。

這實際上是一個程式碼即資料的例子——我們給按鈕傳遞了一個代表某種行為的物件。

設計匿名內部類的目的,就是為了方便Java程式設計師將程式碼作為資料傳遞。不過,匿名內部類還是不夠簡便。為了呼叫一行重要的邏輯程式碼,不得不加上四行冗繁的樣板程式碼。若把樣板程式碼用其他顏色區分開來,就可一目瞭然:

button.addActionListener(new ActionListener() {
    public void actionPerformed(ActionEvent event) {
        System.out.println("button clicked");
    }
});

儘管如此,冗餘的樣板程式碼並不是唯一的問題:這些程式碼還相當難讀,因為它沒有清楚地表達程式設計師的意圖。我們不想傳入物件,只想傳入行為。在Java 8中,上述程式碼可以寫成一個Lambda表示式,如例2-2所示。

例2-2 使用Lambda表示式將行為和按鈕單擊進行關聯

button.addActionListener(event -> System.out.println("button clicked"));

和傳入一個實現某介面的物件不同,我們傳入了一段程式碼塊——一個沒有名字的函式。event是引數名,和上面匿名內部類示例中的是同一個引數。->將引數和Lambda表示式的主體分開,而主體是使用者點選按鈕時會執行的一些程式碼。

和使用匿名內部類的另一處不同在於宣告event引數的方式。使用匿名內部類時需要顯式地宣告引數型別ActionEvent event,而在Lambda表示式中無需指定型別,程式依然可以編譯。這是因為javac根據程式的上下文(addActionListener方法的簽名)在後臺推斷出了引數event的型別。這意味著如果引數型別不言而明,則無需顯式指定。稍後會介紹型別推斷的更多細節,現在先來看看編寫Lambda表示式的各種方式。

儘管與之前相比,Lambda表示式中的引數需要很少的樣板程式碼,但是Java 8仍然是一種靜態型別語言。為了增加可讀性並遷就我們的習慣,宣告引數時也可以包括型別資訊,而且有時編譯器不一定能根據上下文推斷出引數的型別!

2.2 如何辨別Lambda表示式

Lambda表示式除了基本的形式之外,還有幾種變體,如例2-3所示。

例2-3 編寫Lambda表示式的不同形式

Runnable noArguments = () -> System.out.println("Hello World");➊

ActionListener oneArgument = event -> System.out.println("button clicked");➋

Runnable multiStatement = () -> {➌
    System.out.print("Hello");
    System.out.println(" World");
};

BinaryOperator<Long> add = (x, y) -> x + y;➍ 

BinaryOperator<Long> addExplicit = (Long x, Long y) -> x + y;➎

➊中所示的Lambda表示式不包含引數,使用空括號()表示沒有引數。該Lambda表示式實現了Runnable介面,該介面也只有一個run方法,沒有引數,且返回型別為void

➋中所示的Lambda表示式包含且只包含一個引數,可省略引數的括號,這和例2-2中的形式一樣。

Lambda表示式的主體不僅可以是一個表示式,也可以是一段程式碼塊,使用大括號({})將程式碼塊括起來,如➌所示。該程式碼塊和普通方法遵循的規則別無二致,可以用返回或丟擲異常來退出。只有一行程式碼的Lambda表示式也可使用大括號,用以明確Lambda表示式從何處開始、到哪裡結束。

Lambda表示式也可以表示包含多個引數的方法,如➍所示。這時就有必要思考怎樣去閱讀該Lambda表示式。這行程式碼並不是將兩個數字相加,而是建立了一個函式,用來計算兩個數字相加的結果。變數add的型別是BinaryOperator<Long>,它不是兩個數字的和,而是將兩個數字相加的那行程式碼。

到目前為止,所有Lambda表示式中的引數型別都是由編譯器推斷得出的。這當然不錯,但有時最好也可以顯式宣告引數型別,此時就需要使用小括號將引數括起來,多個引數的情況也是如此。如➎所示。

目標型別是指Lambda表示式所在上下文環境的型別。比如,將Lambda表示式賦值給一個區域性變數,或傳遞給一個方法作為引數,區域性變數或方法引數的型別就是Lambda表示式的目標型別。

上述例子還隱含了另外一層意思:Lambda表示式的型別依賴於上下文環境,是由編譯器推斷出來的。目標型別也不是一個全新的概念。如例2-4所示,Java中初始化陣列時,陣列的型別就是根據上下文推斷出來的。另一個常見的例子是null,只有將null賦值給一個變數,才能知道它的型別。

例2-4 等號右邊的程式碼並沒有宣告型別,系統根據上下文推斷出型別資訊

final String[] array = { "hello", "world" };

2.3 引用值,而不是變數

如果你曾使用過匿名內部類,也許遇到過需要引用它所在方法裡的變數的情況。這個時候,需要將變數宣告為final,如例2-5所示。將變數宣告為final,意味著不能為其重複賦值。同時也意味著在使用final變數時,實際上是在使用賦給該變數的一個特定的值。

例2-5 匿名內部類中使用final區域性變數

final String name = getUserName();
button.addActionListener(new ActionListener() {
    public void actionPerformed(ActionEvent event) {
        System.out.println("hi " + name);
    }
});

Java 8雖然放鬆了這一限制,可以引用非final變數,但是該變數在既成事實上必須是final。雖然無需將變數宣告為final,但在Lambda表示式中,也無法用作非終態變數。如果堅持用作非終態變數,編譯器就會報錯。

既成事實上的final是指只能給該變數賦值一次。換句話說,Lambda表示式引用的是,而不是變數。在例2-6中,name就是一個既成事實上的final變數。

例2-6 Lambda表示式中引用既成事實上的final變數

String name = getUserName();
button.addActionListener(event -> System.out.println("hi " + name));

final就像程式碼中的線路噪聲,省去之後程式碼更易讀。當然,有些情況下,顯式地使用final程式碼更易懂。是否使用這種既成事實上的final變數,完全取決於個人喜好。

如果你試圖給該變數多次賦值,然後在Lambda表示式中引用它,編譯器就會報錯。比如,例2-7無法通過編譯,並顯示出錯資訊:local variables referenced from a Lambda expression must be final or effectively final{![Lambda表示式中引用的區域性變數必須是final或既成事實上的final變數。——譯者注]}。

例2-7 未使用既成事實上的final變數,導致無法通過編譯

String name = getUserName();
name = formatUserName(name);
button.addActionListener(event -> System.out.println("hi " + name));

這種行為也解釋了為什麼Lambda表示式也被稱為閉包。未賦值的變數被周邊環境封閉起來,進而被繫結到一個特定的值。在眾說紛紜的計算機程式語言圈子裡,Java是否擁有真正的閉包一直備受爭議,因為在Java中只能引用既成事實上的final變數。名字雖異,功能相同,就好比把菠蘿叫做鳳梨,其實都是同一種水果。為了避免無意義的爭論,全書將使用“Lambda表示式”一詞。無論名字如何,如前文所述,Lambda表示式都是靜態型別的。因此,接下來就分析一下Lambda表示式本身的型別:函式介面

2.4 函式介面

函式介面是隻有一個抽象方法的介面,用作Lambda表示式的型別。

在Java裡,所有方法引數都有固定的型別。假設將數字3作為引數傳給一個方法,則引數的型別是int。那麼,Lambda表示式的型別又是什麼呢?

使用單一方法的介面來表示某特定方法並反覆使用,是很早就有的習慣。使用Swing編寫過使用者介面的人對這種方式都不陌生,例2-2中的用法也是如此。這裡無需再標新立異,Lambda表示式也使用同樣的技巧,並將這種介面稱為函式介面。例2-8展示了前面例子中所用的函式介面。

例2-8 ActionListener介面:接受ActionEvent型別的引數,返回空

public interface ActionListener extends EventListener {
    public void actionPerformed(ActionEvent event);
}

ActionListener只有一個抽象方法:actionPerformed,被用來表示行為:接受一個引數,返回空。記住,由於actionPerformed定義在一個介面裡,因此abstract關鍵字不是必需的。該介面也繼承自一個不具有任何方法的父介面:EventListener

這就是函式介面,介面中單一方法的命名並不重要,只要方法簽名和Lambda表示式的型別匹配即可。可在函式介面中為引數起一個有意義的名字,增加程式碼易讀性,便於更透徹地理解引數的用途。

這裡的函式介面接受一個ActionEvent型別的引數,返回空(void),但函式介面還可有其他形式。例如,函式介面可以接受兩個引數,並返回一個值, 還可以使用泛型,這完全取決於你要幹什麼。

使用Java程式設計,總會遇到很多函式介面,但Java開發工具包(JDK)提供的一組核心函式介面會頻繁出現。 前面已講過函式介面接收的型別,也講過javac可以根據上下文自動推斷出引數的型別,且使用者也可以手動宣告引數型別,但何時需要手動宣告呢?下面將對型別推斷作出詳盡說明。

2.5 型別推斷

某些情況下,使用者需要手動指明型別,建議大家根據自己或專案組的習慣,採用讓程式碼最便於閱讀的方法。有時候省略型別資訊可以減少干擾,更易弄清狀況;而有時卻需要型別資訊幫助理解程式碼。經驗證發現,一開始型別資訊是有用的,但隨後可以只在真正需要時才加上型別資訊。下面將介紹一些簡單的規則,來幫助確認是否需要手動宣告引數型別。

Lambda表示式中的型別推斷,實際上是Java 7就引入的目標型別推斷的擴充套件。讀者可能已經知道Java 7中的菱形操作符,它可使javac推斷出泛型引數的型別。參見例2-9。

例2-9 使用菱形操作符,根據變數型別做推斷

Map<String, Integer> oldWordCounts = new HashMap<String, Integer>();➊
Map<String, Integer> diamondWordCounts = new HashMap<>();➋

我們為變數oldWordCounts➊明確指定了泛型的型別,而變數diamondWordCounts➋則使用了菱形操作符。不用明確宣告泛型型別,編譯器就可以自己推斷出來,這就是它的神奇之處!

當然,這並不是什麼魔法,根據變數diamondWordCounts➋的型別可以推斷出HashMap的泛型型別,但使用者仍需要宣告變數的泛型型別。

如果將建構函式直接傳遞給一個方法,也可根據方法簽名來推斷型別。在例2-10中,我們傳入了HashMap,根據方法簽名已經可以推斷出泛型的型別。

例2-10 使用菱形操作符,根據方法簽名做推斷

useHashmap(new HashMap<>());
...
private void useHashmap(Map<String, String> values);

Java 7中程式設計師可省略建構函式的泛型型別,Java 8更進一步,程式設計師可省略Lambda表示式中的所有引數型別。再強調一次,這並不神奇,javac根據Lambda表示式上下文資訊就能推斷出引數的正確型別。程式依然要經過型別檢查來保證執行的安全性,但不用再明確宣告型別罷了。這就是所謂的型別推斷

Java 8中對型別推斷系統的改善值得一提。上面的例子將new HashMap<>()傳給useHashmap方法,即使編譯器擁有足夠的資訊,也無法在Java 7中通過編譯。

接下來將通過舉例來詳細分析型別推斷。

例2-11和例2-12都將變數賦給一個函式介面,這樣便於理解。第一個例子(例2-11)使用Lambda表示式檢測一個Integer是否大於5。這實際上是一個Predicate——用來判斷真假的函式介面。

例2-11 型別推斷

Predicate<Integer> atLeast5 = x -> x > 5;

Predicate也是一個Lambda表示式,和前文中ActionListener不同的是,它還返回一個值。在例2-11中,表示式x > 5是Lambda表示式的主體。這樣的情況下,返回值就是Lambda表示式主體的值。

例2-12 Predicate介面的原始碼,接受一個物件,返回一個布林值

public interface Predicate<T> {
    boolean test(T t);
}

從例2-12中可以看出,Predicate只有一個泛型型別的引數,Integer用於其中。Lambda表示式實現了Predicate介面,因此它的單一引數被推斷為Integer型別。javac還可檢查Lambda表示式的返回值是不是boolean,這正是Predicate方法的返回型別。

例2-13是一個略顯複雜的函式介面:BinaryOperator。該介面接受兩個引數,返回一個值,引數和值的型別均相同。例項中所用的型別是Long

例2-13 略顯複雜的的型別推斷

BinaryOperator<Long> addLongs = (x, y) -> x + y;

型別推斷系統相當智慧,但若資訊不夠,型別推斷系統也無能為力。型別系統不漫無邊際地瞎猜,而會中止操作並報告編譯錯誤,尋求幫助。比如,刪掉例2-13中的某些型別資訊,則如例2-14所示。

例2-14 沒有泛型,程式碼則通不過編譯

BinaryOperator add = (x, y) -> x + y;

編譯器給出的報錯資訊如下:

Operator '& #x002B;' cannot be applied to java.lang.Object, java.lang.Object.

報錯資訊讓人一頭霧水,到底怎麼回事?BinaryOperator畢竟是一個具有泛型引數的函式介面,該型別既是引數xy的型別,也是返回值的型別。上面的例子中並沒有給出變數add的任何泛型資訊,給出的正是原始型別的定義。因此,編譯器認為引數和返回值都是java.lang.Object例項。

後文中討論“過載解析”時還會講到型別推斷,但就目前來說,掌握以上型別推斷的知識就已經足夠了。

2.6 要點回顧

  • Lambda表示式是一個匿名方法,將行為像資料一樣進行傳遞。
  • Lambda表示式的常見結構:BinaryOperator<Integer> add = (x, y) → x + y
  • 函式介面指僅具有單一抽象方法的介面,用來表示Lambda表示式的型別。

相關文章