接著上一篇文章繼續談談Lambda表達式。
目錄
介紹
我們為什麼需要lambdas?
Lambdas的語法
功能接口
方法參考
構造函數參考
可變範圍
默認方法
結論
方法參考有時你已經有了一個適合你需求的方法,你想將它傳遞給其他一些方案。例如,假設您希望在單擊按鈕時只列印事件對象。你可以這樣寫
button.setOnAction(event -> System.out.println(event));
將 println方法傳遞給 setOnAction方法更直觀。以下示例顯示了它:
button.setOnAction(System.out::println);
System.out::println是一個方法引用,類似於lambda表達式。我們可以在這裡用方法引用替換lambda。
想像一下,你想要忽略一個字母大小寫的排序字符串。您可以編寫如下代碼:
Arrays.sort(strs, String::compareToIgnoreCase)
運算符 ::將方法名稱與對象或類的名稱分開。主要有三種情況:
在前兩種情況下,方法引用等效於帶有方法參數的lambda表達式。如上所示, System.out::println相當於 x->System.out.println(x)。同樣, Math::pow相當於 (x,y)->Math.pow(x,y)。在第三種情況下,第一個參數成為方法的目標。例如, String::compareToIgnoreCase與...相同 (x,y)->x.compareToIgnoreCase(y)。
眾所周知,類可以有多個具有相同名稱的重載方法。在這種情況下,編譯器將嘗試從上下文中找到要選擇的內容。例如,該 Math.max方法有兩個版本,一個用於int,一個用於double值。調用哪一個取決於 Math::max轉換的功能接口的方法籤名。方法引用不是單獨存在的。與幕後的lambdas類似,它們總是變成功能接口的實例。
您可能想到是否可以使用 this在方法參考中捕獲參數。是的你可以。例如, this::equals相當於 x->this.equals(x)。它也可以使用 super。當我們使用 super::instanceMethod它成為目標並調用給定方法的基類版本。這是一個非常真實的例子:
class Speaker {
public void speak() {
System.out.println("Hello, world!");
}
}
class ConcurrentSpeaker extends Speaker {
public void speak() {
Thread t = new Thread(super::speak);
t.start();
}
}
當線程啟動時, run調用方法並 super::speak執行,調用 speak其基類的方法。請注意,在內部類中,您可以 this將封閉類的引用,捕獲為 EnclosingClass.this::method或 EnclosingClass.super::method。
構造函數參考構造函數引用與方法引用相同,只是方法名稱為 new。例如, Button::new是類的構造函數引用 Button。將調用哪個構造函數取決於上下文。想像一下,您想要將字符串列錶轉換為按鈕數組。在這種情況下,您應該在每個字符串上調用構造函數。它可能是這樣的:
List<String> strs = ...;
Stream<Button> stream = strs.stream().map(Button::new);
List<Button> buttons = stream.collect(Collectors.toList());
有關 stream的更多信息,您可以查看文檔。在這種情況下,最重要的是該方法為每個列表元素調用構造函數。有多個構造函數,但編譯器選擇帶有參數的構造函數,因為從上下文中可以明顯看出應該調用帶有字符串的構造函數。map``collect``map``Button(String)``Button``String
還可以為數組類型創建構造函數引用。例如, int數組的構造函數引用是 int[]::new。它需要一個參數:數組的長度。它等同於lambda表達式 x->newint[x]。
數組的構造函數引用對於超越Java的限制很有用。創建泛型類型的數組是不可能的 T。表達式 newT[n]不正確,因為它將替換為 newObject[n]。對於圖書館作者來說這是一個問題。想像一下,我們想擁有一系列按鈕。有一種方法 toArray在類 Stream返回的數組 Object:
Object[] buttons = stream.toArray();
但那不是你想要的。用戶不想要 Objects,只有按鈕。該庫使用構造函數引用解決了這個問題。你應該傳遞 Button[]::new給方法 toArray:
Button[] buttons = stream.toArray(Button[]::new);
該 toArray方法調用此構造函數以獲取所需類型的數組,然後在填充後返回此數組。
可變範圍通常,您希望從lambda表達式中的封閉範圍訪問變量。考慮以下代碼:
public static void repeatText(String text, int count) {
Runnable r = () -> {
for (int i = 0; i < count; i++) {
System.out.println(text);
Thread.yield();
}
};
new Thread(r).start();
}
看下面這個調用:
repeatText("Hi!", 2000); // Prints Hi 2000 times in a separate thread
注意變量 count和 text沒有在lambda表達式中定義; 這些是封閉方法的參數。
如果仔細觀察這段代碼,你會發現幕後有某種魔力。該 repeatText方法可以在lambda表達式的代碼運行之前返回,而那時參數 count和 text變量將消失,但它們仍然可用於lambda。秘密是什麼?
要了解發生了什麼,我們需要提高對lambda表達式的理解。lambda表達式由三個部分組成:
代碼塊
參數
自由變量不是參數,也沒有在lambda中定義
在我們的案例中有兩個自由變量, text和 count。表示lambda的數據結構必須存儲它們的值,「嗨!」 他們說這些值是由lambda表達式捕獲的。(如何完成取決於實現。例如,實現可以使用一個方法將lambda表達式轉換為對象,並將自由變量的值複製到對象的實例變量中。)
有一個特殊的術語「封閉」; 它是一個代碼塊和自由變量的值。Lambda用一種方便的語法表示Java中的閉包。順便說一句,內部類總是封閉的。
因此,lambda表達式可以捕獲封閉範圍中變量的值,只是有一些限制。你無法更改這些捕獲變量的值。以下代碼不正確:
public static void repeatText(String text, int count) {
Runnable r = () -> {
while (count > 0) {
count--; // Error: Can't modify captured variable
System.out.println(text);
Thread.yield();
}
};
new Thread(r).start();
}
這種限制是合理的,因為lambda表達式中的變量變量不是線程安全的。想像一下,我們有一系列並發任務,每個任務都更新一個共享計數器。
int matchCount = 0;
for (Path p : files)
new Thread(() -> { if (p has some property) matchCount++; }).start();
// Illegal to change matchCount
如果這段代碼是正確的,那將是非常非常糟糕的。increment運算符 ++不是原子的,如果多個線程同時執行此代碼,則無法控制結果。
內部類也可以從外部類中捕獲值。在Java 8之前,內部類只能訪問 final局部變量。此規則已擴展為與lambda表達式匹配。現在,內部類可以處理其值不會發生變化的任何變量(實際上是 final變量)。
不要指望編譯器捕獲所有並發訪問錯誤。您應該知道,此修改規則僅適用於局部變量。如果我們使用外部類的實例或靜態變量,則不會出現錯誤,結果是未定義的。
您也可以修改共享對象,即使它不健全。例如,
List<Path> matchedObjs = new ArrayList<>();
for (Path p : files)
new Thread(() -> { if (p has some property) matchedObjs.add(p); }).start();
// Legal to change matchedObjs, but unsafe
如果你仔細觀察,你會發現變量 matchedObjs實際上是final的。(有效的final變量是一個在初始化後再無變化的變量。)在此代碼中, matchedObjs始終引用同一個對象。但是,該變量 matchedObjs已被修改,並且它不是線程安全的。在多線程環境中運行此代碼的結果是不可預測的。
這種多線程任務有安全的機制。例如,您可以使用線程安全計數器和集合,流來收集值。
對於內部類,有一種解決方法允許lambda表達式更新封閉的本地範圍中的計數器。想像一下,你使用長度為1的數組,如下所示:
int[] counts = new int[1];
button.setOnAction(event -> counts[0]++);
很明顯,這段代碼不是線程安全的。對於按鈕回調,這沒關係,但一般來說,在使用這個技巧之前你應該三思而後行。
lambda的主體與嵌套塊具有相同的範圍。名稱衝突和陰影的規則是相同的。您不能在lambda中聲明與封閉範圍中的變量同名的參數或局部變量。
Path first = Paths.get("/usr/local");
Comparator<String> comp =
(first, second) -> Integer.compare(first.length(), second.length());
// Error: Variable first already defined
在方法中不能有兩個具有相同名稱的局部變量。因此,您也不能在lambda表達式中引入這些變量。同樣的規則適用於lambda。在 thislambda中使用關鍵字時,請參考 this創建lambda的方法。我們考慮一下這段代碼
public class Application() {
public void doSomething() {
Runnable r = () -> { ...; System.out.println(this.toString()); ... };
...
}
}
在此示例中 <code>this.toString()調用對象的 toString方法 Application,而不是 Runnable實例。this在lambda表達式中使用沒有什麼特別之處。lambda表達式的範圍嵌套在 doSomething方法中,並且在該方法中的任何位置都具有相同的含義。
默認方法最後,讓我們談談與lambdas沒有直接關係的新功能,它也非常有趣 - 默認方法。
在許多程式語言中,函數表達式與其集合庫集成在一起。這通常會導致代碼比使用循環的等效代碼更簡單,更容易理解。我們考慮一下代碼:
for (int i = 0; i < strList.size(); i++)
System.out.println(strList.get(i));
我們可以改進這段代碼。該庫可以提供一種方便的方法 forEach,將函數應用於每個元素。所以你可以簡化代碼
strList.forEach(System.out::println);
如果庫是從頭開始設計的,那麼一切都還可以,但是如果它是很久以前在Java中創建的呢?如果 Collection接口添加了一個新方法,例如 forEach,那麼定義自己實現此接口的類的每個應用程式都將無法工作,直到它實現該方法。它在Java中並不好。
Java中的這個問題通過允許具有特定實現的接口方法(命名為默認方法)來解決。您可以將此類方法安全地添加到現有接口。現在我們將更密切地研究默認方法。在Java 8 中,使用您將在下面看到的技巧將該 forEach方法添加到 Iterable基本接口 Collection中。
考慮這個界面:
interface Person {
long getId();
default String getFirstName() { return "Jack"; }
}
接口中有兩種方法:getId抽象方法, getFirstName默認方法。當然,實現此接口的具體類必須提供實現 getId,但是它可以選擇使用 getFirstName或覆蓋它的默認實現。
這種技術使用提供接口的經典模式和實現其大部分或全部方法的抽象類來停止,例如 Collection/AbstractCollection或 WindowListener/WindowAdapter。現在,您可以在界面中實現所需的方法。
如果具有相同籤名的方法在一個接口中定義為默認方法,然後再在基類或另一個接口中定義,會發生什麼?其他語言(如C ++)具有解決此類歧義的複雜規則。幸運的是,Java中的規則要簡單得多。我們來看看它們:
基礎類獲勝。如果基類包含具體方法,則會忽略具有相同籤名的默認方法。
接口發生衝突。如果基接口具有默認方法,並且另一個接口包含具有相同籤名的方法(默認與否),則必須通過覆蓋該方法手動解決衝突。
讓我們仔細看看第二條規則。想像一下使用 getFirstName方法的另一個接口:
interface Naming {
default String getFirstName() { return getClass().getName() + "_" + hashCode(); }
}
如果您嘗試創建一個實現它們的類,會發生什麼?
class Student implements Person, Naming {
...
}
該類繼承兩種不同的方法 getFirstName通過接口提供的 Person和 Naming。Java編譯器報告錯誤而不是選擇其中一個錯誤,而是由程式設計師決定是否解決了歧義。只需 getFirstName在類中實現一個方法 Student。然後在該方法中,您可以選擇兩種衝突方法中的一種,如下所示:
class Student implements Person, Naming {
public String getFirstName() { returnPerson.super.getFirstName (); }
...
}
現在讓我們考慮一下 Naming接口不提供默認實現的情況 getFirstName:
interface Naming {
String getFirstName();
}
Student該類是否會從 Person接口繼承默認方法?Java設計者決定採用統一性,儘管這種方式可能是合理的。無論接口究竟如何衝突。如果至少一個接口包含實現,則編譯器報告衝突,並且程式設計師必須手動解決該問題。
如果沒有任何接口提供該方法的默認實現,則沒有問題。最後一個類可以實現該方法,也可以不實現該方法。在第二種情況下,類本身仍然是抽象的。
我剛剛描述了兩個接口之間的名稱衝突。現在讓我們考慮一個繼承另一個類並實現接口的類,從兩者繼承相同的方法。例如,假設它 Person是一個類, Naming是一個接口,類 Student定義為:
class Student extends Person implements Naming { ... }
在這種情況下,簡單地忽略界面中的任何默認方法。在我們的示例中, Student繼承了 getFirstName方法 Person, Naming接口是否提供默認實現 getFirstName並不重要。這是行動中的「階級勝利」規則。此規則可確保與Java 7的兼容性。如果將默認方法添加到接口,則它對舊代碼沒有影響。但請記住,您永遠不能創建覆蓋類中某個方法的默認方法 Object。舉例來說,你不能重新定義默認的方法 toString或者 equals,雖然它可能是如接口有用 List。作為規則的結果,這種方法永遠不會贏得反對 Object.toString或 Object.equals。
默認方法允許您向現有接口添加新功能,並確保與為這些接口的舊版本編寫的代碼的二進位兼容性。特別是,使用默認方法,您可以添加接受lambda表達式作為現有接口參數的方法。
結論我已經將lambda表達式描述為有史以來對編程模型的最大升級 - 甚至可能比泛型更大。我們可以使用lambdas編寫一些非常好的代碼。
我希望本文能夠一瞥Java 8為我們提供一些新變化。
推薦閱讀:
今日課題:
↑點擊圖片直接跳轉觀看免費直播課程↑
↑點擊圖片直接跳轉觀看免費直播課程↑