Java 8中,最重要的一個改變讓代碼更快、更簡潔,并向FP(函數式編程)打開了方便之門。下面我們來看看,它是如何做到的。
變量作用域
你經常會想,如果可以在Lambda表達式里訪問外部方法或類中變量就好了。看下面的例子:
-
public static void repeatMessage(String text, int count) {
-
Runnable r = () -> {
-
for (int i = 0; i < count; i++) {
-
System.out.println(text);
-
Thread.yield();
-
}
-
};
-
new Thread(r).start();
-
}
-
-
// Prints Hello 1,000 times in a separate thread
-
repeatMessage("Hello", 1000);
現在我們來看看Lambda中的變量:count和text。它們不是在Lambda中定義的,而是repeatMessage方法的參數。
如果你仔細瞧瞧,這里發生的事情并不是那么容易能看出來。Lambda表達式中的代碼,可能會在repeatMessage方法返回之后很久才會被調用,這時候,參數變量已經不存在了。那么text和count是如何保留下來的呢?
為了理解這段代碼,我們需要進一步的了解Lambda表達式。Lambda表達式有三個組成部分:
- 代碼塊
- 參數
- 自由變量值;自由變量不是參數,也不是在語句體中定義的變量
在我們的例子中,Lambda表達式有兩個自由變量:text和count。表示Lambda表達式的數據結構,必須保存自由變量的值,在這里,就是“Hello”和“1000”。我們說這些值被Lambda表達式捕獲了。(怎么做到的,那是實現的細節問題。例如,我們可以把Lambda表達式轉化成擁有單個方法的對象,這樣的話,自由變量的值就可以拷貝到對象的實例變量中去。)
擁有自由變量值的代碼塊的專業術語叫閉包。如果有人很得意的告訴你,他們的語言擁有閉包,那么本文其余的部分會向你保證,Java同樣也有。在Java中,Lambda表達式就是閉包。事實上,內部類一直就是閉包啊!而Java 8也給了我們擁有簡潔語法的閉包。
就像已經看到的,Lambda表達式可以捕獲外部作用域的變量值。在Java中,為了保證被捕獲的變量值是定義良好的,它有一個很重要的約束。在Lambda表達式里,只能引用值不變的變量。比如,下面的用法就不對:
-
public static void repeatMessage(String text, int count) {
-
Runnable r = () -> {
-
while (count > 0) {
-
count--; // Error: Can't mutate captured variable
-
System.out.println(text);
-
Thread.yield();
-
}
-
};
-
new Thread(r).start();
-
}
這樣做,是有原因的。因為,在Lambda表達式中改變自由變量的值,不是線程安全的。比如,考慮一系列的并發任務,每一個都更新共享的計數器matches:
-
int matches = 0;
-
for (Path p : files) {
-
// Illegal to mutate matches
-
new Thread(() -> { if (p has some property) matches++; }).start();
-
}
如果上面的代碼是合法的,那就非常非常糟糕了!“matches++”不是一個原子操作,當多個線程并發執行它的時候,我們不可能知道,到底會發生什么樣的事情。
內部類同樣也可以捕獲外部作用域的自由變量值。Java 8之前,內部類只能訪問被final修飾的本地變量。現在,這個規則被放寬到跟Lambda表達式一樣,內部類可以訪問事實上的final變量,也就是那些值不會改變的變量。
不要指望編譯器去捕獲所有的并發訪問錯誤。禁止改變的規則是使用于本地變量。如果matches是外部類的實例變量,或者靜態變量,就算你得到的結果是不確定的,編譯器也不會告訴你任何錯誤。
同樣的,盡管不正確,并發改變共享變量的值是相當合法的。下面的例子就是合法但不正確的:
-
List< Path > matches = new ArrayList<>();
-
for (Path p : files)
-
new Thread(() -> { if (p has some property) matches.add(p); }).start();
-
// Legal to mutate matches, but unsafe
注意,matches是事實上的final變量。(事實上的final變量是指,在它初始化以后,再也沒有改變它的值。)在這里,matches總是引用同一個ArrayList對象,并沒有改變。但是,matches引用的對象以線程不安全的方式,被改變了,因為如果多個線程同時調用add方法,結果就是不可預測的!
值的計數和搜集是存在線程安全的方式的。你可能想要用stream來收集特定屬性的值。在其他情形下,你可能會使用線程安全的計數器和集合。
跟內部類相似,有一個變通的方式,可以讓Lambda表達式更新外部本地作用域的計數器的值。比如,用一個長度為一的數組:
-
int[] counter = new int[1];
-
button.setOnAction(event -> counter[0]++);
當然,這樣的代碼不是線程安全的。也許,對一個按鈕的回調方法來說,是無所謂的。但通常,使用這種方式之前,你應該多考慮考慮。
Lambda表達式的語句體和嵌套代碼塊的作用域是一樣的。變量名沖突和隱藏規則同樣適用。在Lambda表達式里聲明的參數或本地變量跟外部本地變量同名,是非法的。
-
Path first = Paths.get("/usr/bin");
-
Comparator< String > comp =
-
(first, second) -> Integer.compare(first.length(), second.length());
-
// Error: Variable first already defined
在方法里,你不能有兩個同名的本地變量。Lambda表達式同樣如此。在Lambda表達式里,當你使用“this”時,你引用的是創建Lambda表達式方法的this參數。例如:
-
public class Application() {
-
public void doWork() {
-
Runnable runner = () -> {
-
...;
-
System.out.println(this.toString());
-
...
-
};
-
...
-
}
-
}
這里的this.toString調用的是Application對象的,不是Runnable實例的。在Lambda中使用this并沒有什么特別的。Lambda的作用域嵌套在doWork方法里,this的含義在方法中哪里都一樣。
默認方法
很多編程語言在它們的集合類庫中集成了函數表達式。這導致它們的代碼,比使用外循環更短,更易于理解。例如:
-
for (int i = 0; i < list.size(); i++)
-
System.out.println(list.get(i));
有一個更好的方法。類庫的設計者們可以提供一個forEach方法,它把函數應用到所包含的每一個元素上。然后我們就可以簡單的調用:
-
list.forEach(System.out::println);
這樣很好,如果類庫從一開始就是這樣設計的話。但是Java集合類庫是很多年前設計的,這就有一個問題。如果Collection接口多了一個新的方法,比如forEach,那么,所有實現了Collection的程序,都會編譯出錯,除非它們也實現多出來的那個方法。這在Java中肯定是不能接受的。
Java的設計者們決定一勞永逸的解決這個問題:他們允許接口中的方法擁有具體實現(稱為默認方法)!這些方法可以安全的加進現存接口中。下面我們來看看默認方法的細節。在Java 8里,forEach方法被加進了Collection的父接口Iterable接口中,現在我來說說這樣做的機制。
看如下的接口:
-
interface Person {
-
long getId();
-
default String getName() { return "John Q. Public"; }
-
}
接口中有兩個方法,抽象方法getId和默認方法getName。實現Person的具體類當然必須提供getId方法的實現,但可以選擇保留getName方法的實現,或者重載它。
默認方法的出現,終結了一個經典的模式:提供一個接口和實現了它的部分或全部方法的抽象類,比如Collection/AbstractCollection,或WindowListener/WindowAdapter。現在你可以直接在接口中實現方法了。
如果相同的方法在一個接口中被定義為默認方法,在超類或另一個接口中被定義為方法,會怎么樣呢?像Scala和C++都用復雜的規則來解決這種歧義性。幸好,在Java中,規則就簡單多了。它們是:
超類優先。如果超類提供了具體的方法,接口中的默認方法將被簡單的忽略。
接口沖突。如果父接口提供了默認方法,另一個接口有相同的方法(默認的或抽象的),那么你需要自己重載這個方法來解決沖突。
讓我們看看第二條規則。比如擁有getName方法的另一個接口:
-
interface Named {
-
default String getName() { return getClass().getName() + "_" + hashCode(); }
-
}
如果你寫一個實現接口Person和Named的類,會發生什么呢?
-
class Student implements Person, Named {
-
...
-
}
Student類繼承了兩個實現不一致的getName方法。Java編譯器會報錯,并把它留給開發者去解決沖突,而不是隨便選一個來使用。在Student類中,簡單的提供一個getName方法就可以了。至于方法里的實現,你可以在沖突的方法中任選一個。
-
class Student implements Person, Named {
-
public String getName() { returnPerson.super.getName(); }
-
...
-
}
現在假設接口Named沒有提供getName方法的默認實現:
-
interface Named {
-
String getName();
-
}
那么Student類會繼承Person的默認方法嗎?這也許是合理的,但Java的設計者們決定堅持一致性原則:接口之間怎么沖突不重要,只要至少有一個接口提供了默認方法,編譯器就報錯,開發人員必須自己去解決沖突。
如果接口都沒有提供相同方法的默認實現,那么這跟Java 8之前的時代是一樣的,沒有沖突。實現類有兩個選擇:實現這個方法,或者不實現它。后一種情形下,實現類本身就會是一個抽象類。
我剛剛討論了接口之間的方法沖突。現在看看一個類繼承了一個父類,并且實現了一個接口。它從兩者繼承了同一個方法。例如,Person是一個類,Student被定義成:
-
class Student extends Person implements Named { ... }
這種情況下,只有父類的方法會生效,接口中任何的默認方法都會被簡單的忽略。在我們的例子中,Student會繼承Person中的getName方法,Named接口提不提供默認getName的實現沒有任何區別。這就是“父類優先”的規則,它保證了與Java 7的兼容性。在默認方法出現之前的正常工作的代碼里,如果你給接口添加一個默認方法,它并沒有任何效果。但是小心:你絕不能寫一個默認方法,它重新定義Object類里的任何方法。比如,你不能定義toString或equals方法的默認實現,就算這樣做對有些接口(比如List)來說很有誘惑力,因為”父類優先“原則會導致這樣的方法不可能勝過Object.toString或Object.equals。
接口中的靜態方法
在Java 8里,你可以在接口中添加靜態方法。從來都沒有一個技術上的原因說這樣是非法的:它只是簡單的看起來與接口作為抽象規范的精神相違背。
到目前為止,把靜態方法放在伴生的類中是一個通常的做法。在標準類庫中,你會看到成對的接口和工具類,比如Collection/Collections,或者Path/Paths。
看看Paths類,它只有幾個工廠方法。你可以從一系列的字符串中,創建一個路徑,比如Paths.get("jdk1.8.0", "jre", "bin")。在Java 8中,你可以把這個方法加到Path接口中:
-
public interface Path {
-
public static Path get(String first, String... more) {
-
return FileSystems.getDefault().getPath(first, more);
-
}
-
...
-
}
這樣,Paths接口就不需要了。
當你在看Collections類的時候,你會看到兩類方法。一類這樣的方法:
-
public static void shuffle(List< ? > list)
將會作為List接口的默認方法工作的很好:
-
public default void shuffle()
你就可以在任何列表上簡單的調用list.shuffle()。
對工廠方法來說,那樣是不行的,因為你沒有調用方法的對象。這時候接口中的靜態方法就有用武之地了。例如:
-
public static < T > List< T > nCopies(int n, T o)
-
// Constructs a list of n instances of o
可以作為List的靜態方法。那么,你就可以調用List.nCopies(10, "Fred"),而不是Collections.nCopies(10, "Fred")。這樣,閱讀代碼的人就很清楚,結果一定是個List。
盡管如此,基本上,要Java集合類庫以上面這種方式去重構是不可能的。但是當你實現你自己的接口時,沒有理由去為工具方法提供單獨的伴生類了吧。
在Java 8中,很多接口都被添加了靜態方法。比如,Comparator接口有一個非常有用的靜態方法comparing,它接收一個”鍵抽取“函數,并產生一個比較抽取出來的鍵的比較器。要根據name比較Person對象,用Comparator.comparing(Person::name)就行了。
總結
本文中,我先用Lambda表達式
-
(first, second) -> Integer.compare(first.length(), second.length())
來比較字符串的長度。但我們可以做得更好,簡單的使用Comparator.compare(String::length)就行。這是一個很好的結束本文的方式,因為它展示了用函數開發的力量。compare方法把一個函數(鍵抽取器)變成了另一個更復雜的函數(基于鍵的比較器)。在我的書中,以及各種網上資料里面,就有關于”高階函數“更多細節的討論。
本文譯自:Lambda Expressions in Java 8
原創文章,轉載請注明: 轉載自LetsCoding.cn
本文鏈接地址: Java 8:Lambda表達式(三)