轉(zhuǎn)載自:《深入理解Java 8 Lambda(語言篇——lambda,方法引用,目標類型和默認方法)》——Lucida
注:本文是筆者在上述地址學習 Java SE 8 Lambda 表達式的筆記。筆者的學習習慣,是在學習過程中將內(nèi)容敲打一遍,記憶會更加深刻。本文只節(jié)選了原文一部分,更多內(nèi)容詳見原文。
一. 背景
不過有些 Java 對象只是對單個函數(shù)的封裝。例如下面這個典型用例:Java API 中定義了一個接口(一般被稱為回調(diào)接口),用戶通過提供這個接口的實例來傳入指定行為,例如:
public interface ActionListener {
void actionPerformed(ActionEvent e);
}
這里并不需要專門定義一個類來實現(xiàn) ActionListener 接口,因為它只會在調(diào)用處被使用一次。用戶一般會使用匿名類型把行為內(nèi)聯(lián)(inline):
button.addActionListener(new ActionListener) {
public void actionPerformed(ActionEvent e) {
ui.dazzle(e.getModifiers());
}
}
很多庫都依賴于上面的模式。對于并行 API 更是如此,因為我們需要把待執(zhí)行的代碼提供給并行API,并行編程是一個非常值得研究的領(lǐng)域,因為在這里摩爾定律得到了重生:盡管我們沒有更快的 CPU,但是我們有更多的 CPU。
隨著回調(diào)模式和函數(shù)式編程風格的日益流行,我們需要在 Java 中提供一種盡可能輕量級的將代碼封裝為數(shù)據(jù)的方法。但匿名內(nèi)部類并不是一個好的選擇,因為:
- 語法過于冗余;
- 匿名類中的this和變量名容易使人產(chǎn)生誤解;
- 類型載入和實例創(chuàng)建語義不夠靈活;
- 無法捕獲非final的局部變量;
- 無法對控制流進行抽象;
對于上述問題,在 Java 8 中大多都被解決:
- 提供更簡潔的語法和局部作用域規(guī)則 -> 解決了問題 1 和問題 2
- 提供更加靈活而且便于優(yōu)化的表達式語義 -> 繞開了問題 3
- 允許編譯器推斷變量的“常量性” -> 減輕了問題 4
二. 函數(shù)式接口
上面提到的 ActionListener 接口只有一個方法,大多數(shù)回調(diào)接口都擁有這個特征。比如 Runnable 接口和 Comparator 接口。我們把這些只擁有一個方法的接口稱為函數(shù)式接口。編譯器會根據(jù)接口的結(jié)構(gòu)自行判斷。
注:
- 判斷過程并非簡單的對接口方法計數(shù);
- API 作者們可以通過 @FunctionalInterface 注解來顯式指定一個接口是函數(shù)式接口,加上這個注解之后,編譯器就會驗證該接口是否滿足函數(shù)式接口的要求。
函數(shù)式類型的另一種方式,是引入一個全新的結(jié)構(gòu)化函數(shù)類型:“箭頭”類型。例如,一個接收 String 和 Object 并返回 int 的函數(shù)類型可以被表示為:
(String, Object) -> int
但 Sun 公司最終出于下面的原因?qū)⑵浞穸ǎ?/p>
- 它會為Java類型系統(tǒng)引入額外的復(fù)雜度,并帶來結(jié)構(gòu)類型和指名類型的混用。而 Java 幾乎全部使用指名類型;
- 它會導致類庫風格的分歧——一些類庫會繼續(xù)使用回調(diào)接口,而另一些類庫會使用結(jié)構(gòu)化函數(shù)類型;
- 它的語法會變得十分笨拙;
- 每個函數(shù)類型很難擁有其運行時表示,使開發(fā)者受到類型擦除 (erasure) 的困擾和局限。例如:我們無法對方法 m(T->U) 和 m(X->Y) 進行重載;
所以 Sun 公司最終選擇了“使用已知類型”這種方法。因為現(xiàn)有的類庫大量使用了函數(shù)式接口,通過沿用這種模式,我們使得現(xiàn)有類庫能夠直接使用 lambda 表達式。
Java SE 7 中已經(jīng)存在的函數(shù)式接口如下:
- java.lang.Runnable
- java.util.concurrent.Callable
- java.security.PrivilegedAction
- java.util.Comparator
- java.io.FileFilter
- java.beans.PropertyChangeListener
除此之外,Java SE 8 中增加了一個新的包:java.util.function。它里面包含了常用的函數(shù)式接口,例如:
- Predicate<T>: 接收 T 對象并返回 boolean;
- Consume<T>: 接收 T 對象,不返回值;
- Functio<T, R>: 接收 T 對象,返回 R 對象;
- Supplie<T>: 提供 T 對象(例如工廠),不接收值;
- UnaryOperato<T>: 接收 T 對象,返回 T 對象;
- BinaryOperator<T>: 接收兩個 T 對象,返回 T 對象;
除了上面的這些基本的函數(shù)式接口,還有一些針對原始類型的特化函數(shù)式接口,例如 IntSupplier 和 LongBinaryOperator。(只為 int, long, double 提供了特化函數(shù)式接口,如果需要使用其它原始類型則需要進行類型轉(zhuǎn)換)
同樣,還有一些針對多個參數(shù)的函數(shù)式接口,例如 BiFunction<T, U, R>,它接收 T 對象和 U 對象,返回 R 對象。
三. lambda 表達式
lambda 表達式是匿名方法,它提供了輕量級的語法,從而解決了匿名內(nèi)部類帶來的冗余語法問題(又被稱為“高度問題”)。下面是一些lambda表達式:
(int x, int y) -> x + y
() -> 42
(String s) -> { System.out.println(s); }
這幾個表達式的意義如下:
- 第一個:lambda 表達式接收 x 和 y 這兩個整形參數(shù)并返回它們的和;
- 第二個:lambda 表達式不接收參數(shù),返回整數(shù)'42';
- 第三個:lambda 表達式接收一個字符串并把它打印到控制臺,不返回值。
lambda 表達式的語法由參數(shù)列表、箭頭符號->和函數(shù)體組成。其中函數(shù)體既可以是一個表達式,也可以是一個語句塊:
- 表達式:表達式會被執(zhí)行然后返回執(zhí)行結(jié)果;
- 語句塊:語句塊中的語句會被依次執(zhí)行,就像方法中的語句一樣;
- return語句會把控制權(quán)交給匿名方法的調(diào)用者;
- break和continue只能在循環(huán)中使用;
- 如果函數(shù)體有返回值,那么函數(shù)體內(nèi)部的每一條路徑都必須返回值;
lambda 表達式也會經(jīng)常出現(xiàn)在嵌套環(huán)境中,比如說作為方法的參數(shù)。為了使 lambda 表達式在這些場景下盡可能簡潔,我們?nèi)コ瞬槐匾姆指舴?。不過在某些情況下我們也可以把它分為多行,然后用括號包起來,就像其它普通表達式一樣。
下面是一些出現(xiàn)在語句中的lambda表達式:
FileFilter java = (File f) -> f.getName().endsWith("*.java");
String user = doPrivileged(() -> System.getProperty("user.name"));
new Thread(() -> {
connectToService();
sendNotification();
}).start();
四. 目標類型
對于給定的 lambda 表達式,它的類型是由其上下文推導而來。例如,下面代碼中的 lambda 表達式類型是 ActionListener:
ActionListener l = (ActionEvent e) -> ui.dazzle(e.getModifiers());
這就意味著,同樣的 lambda 表達式在不同上下文里可以擁有不同的類型。例如第一個 lambda 表達式 () -> "done" 是 Callable 的實例,而第二個 lambda 表達式則是 PrivilegedAction 的實例。
Callable<String> c = () -> "done";
PrivilegedAction<String> a = () -> "done";
編譯器負責推導 lambda 表達式的類型。它利用 lambda 表達式所在上下文所期待的類型進行推導,這個被期待的類型被稱為目標類型。lambda 表達式只能出現(xiàn)在目標類型為函數(shù)式接口的上下文中。
當然,lambda 表達式對目標類型也是有要求的。編譯器會檢查 lambda 表達式的類型和目標類型的方法簽名是否一致。當且僅當下面所有條件均滿足時,lambda 表達式才可以被賦給目標類型 T:
- T 是一個函數(shù)式接口;
- lambda 表達式的參數(shù)和 T 的方法參數(shù)在數(shù)量和類型上一一對應(yīng)
- lambda 表達式的返回值和 T 的方法返回值相兼容;
- lambda 表達式內(nèi)所拋出的異常和 T 的方法 throws 類型相兼容;
由于函數(shù)式接口的目標類型已經(jīng)了解 lambda 表達式的形式參數(shù)類型,所以我們沒有必要把已知類型再重復(fù)一遍,即 lambda 表達式的參數(shù)類型可以從目標類型中得出。例如:
Comparator<String> c = (s1, s2) -> s1.compareToIgnoreCase(s2);
編譯器可以推導出 s1 和 s2 的類型是 String。此外,當 lambda 的參數(shù)只有一個而且它的類型可以被推導得知時,該參數(shù)列表外面的括號可以被省略。例如:
FileFilter java = f -> f.getName().endsWith(".java");
button.addActionListener(e -> ui.dazzle(e.getModifiers()));
這些改進展示了我們的設(shè)計目標:“不要把高度問題轉(zhuǎn)化成寬度問題?!闭Z法元素能夠盡可能的少,以便代碼的讀者能夠直達 lambda 表達式的核心部分。
五. 目標類型的上下文
前文提到,lambda 表達式只能出現(xiàn)在擁有目標類型的上下文中。這些帶有目標類型的上下文有:
- 變量聲明
- 賦值
- 返回語句
- 數(shù)組初始化器
- 方法和構(gòu)造方法的參數(shù)
- lambda 表達式函數(shù)體
- 條件表達式(? :)
- 轉(zhuǎn)型(Cast)表達式
在變量聲明、賦值、返回語句里,目標類型即是被賦值或被返回的類型:
Comparator<String> c;
c = (String s1, String s2) -> s1.compareToIgnoreCase(s2);
public Runnable toDoLater() {
return () -> {
System.out.println("later");
}
}
數(shù)組初始化器和賦值類似,只是這里的“變量”變成了數(shù)組元素,而類型是從數(shù)組類型中推導得知的:
filterFiles(new FileFilter[] {
f -> f.exists(), f -> f.canRead(), f -> f.getName().startsWith("q")
});
方法參數(shù)的類型推導要相對復(fù)雜,涉及到其它兩個語言特性重載解析和參數(shù)類型推導。
重載解析會為一個給定的方法調(diào)用尋找最合適的方法聲明。由于不同的聲明具有不同的簽名,當 lambda 表達式作為方法參數(shù)時,重載解析就會影響到 lambda 表達式的目標類型。編譯器會通過它所得之的信息來做出決定。如果 lambda 表達式具有顯式類型(參數(shù)類型被顯式指定),編譯器就可以直接使用 lambda 表達式的返回類型;如果 lambda 表達式具有隱式類型(參數(shù)類型被推導而知),重載解析則會忽略 lambda 表達式函數(shù)體而只依賴 lambda 表達式參數(shù)的數(shù)量。
如果在解析方法聲明時存在二義性,我們就需要利用轉(zhuǎn)型 (cast) 或顯式 lambda 表達式來提供更多的類型信息。如果 lambda 表達式的返回類型依賴于其參數(shù)的類型,那么 lambda 表達式函數(shù)體有可能可以給編譯器提供額外的信息,以便其推導參數(shù)類型。例如:
List<Person> ps = ...
Stream<String> names = ps.stream().map(p -> p.getName());
在上面的代碼中,ps 的類型是 List<Person>,所以 ps.stream() 的返回類型是 Stream<Person>。map() 方法接收一個類型為 Function<T, R> 的函數(shù)式接口,這里 T 的類型即是 Stream 元素的類型,也就是 Person,而 R 的類型未知。由于在重載解析之后 lambda 表達式的目標類型仍然未知,我們就需要推導 R 的類型:通過對 lambda 表達式函數(shù)體進行類型檢查,我們發(fā)現(xiàn)函數(shù)體返回 String,因此 R 的類型是 String,因而 map() 返回 Stream<String>。絕大多數(shù)情況下編譯器都能解析出正確的類型,但如果碰到無法解析的情況,我們則需要:
- 使用顯式 lambda 表達式(為參數(shù) p 提供顯式類型)以提供額外的類型信息;
- 把 lambda 表達式轉(zhuǎn)型為 Function<Person, String>;
- 為泛型參數(shù) R 提供一個實際類型。(Stream<String> names = ps.stream().<String>map(p -> p.getName()))
lambda 表達式本身也可以為它自己的函數(shù)體提供目標類型,也就是說 lambda 表達式可以通過外部目標類型推導出其內(nèi)部的返回類型,這意味著我們可以方便的編寫一個返回函數(shù)的函數(shù):
Supplier<Runnable> c = () -> () -> { System.out.println("hi"); };
類似的,條件表達式可以把目標類型“分發(fā)”給其子表達式:
Callable<Integer> c = flag ? (() -> 23) : (() -> 42);
轉(zhuǎn)型表達式 (Cast expression) 可以顯式提供 lambda 表達式的類型,這個特性在無法確認目標類型時非常有用:
// 非法代碼
// Object o = () -> { System.out.println("hi"); };
// 有效代碼
Object o = (Runnable) () -> { System.out.println("hi"); };
六. 方法引用
lambda 表達式允許我們定義一個匿名方法,并允許我們以函數(shù)式接口的方式使用它。我們也希望能夠在已有的方法上實現(xiàn)同樣的特性。方法引用和 lambda 表達式擁有相同的特性,例如,它們都需要一個目標類型,并需要被轉(zhuǎn)化為函數(shù)式接口的實例。不過我們并不需要為方法引用提供方法體,我們可以直接通過方法名稱引用已有方法。
以下面的代碼為例,假設(shè)我們要按照 name 或 age 為 Person 數(shù)組進行排序:
class Person {
private final String name;
private final int age;
public int getAge() { return age; }
public String getName() {return name; }
...
}
Person[] people = ...
Comparator<Person> byName = Comparator.comparing(p -> p.getName());
Arrays.sort(people, byName);
這段代碼可以用方法引用代替 lambda 表達式:
Comparator<Person> byName = Comparator.comparing(Person::getName);
這里的 Person::getName 可以被看作為 lambda 表達式的簡寫形式。盡管方法引用不一定會把語法變的更緊湊,但它擁有更明確的語義:如果我們想要調(diào)用的方法擁有一個名字,我們就可以通過它的名字直接調(diào)用它。
因為函數(shù)式接口的方法參數(shù)對應(yīng)于隱式方法調(diào)用時的參數(shù),所以被引用方法簽名可以通過放寬類型,裝箱以及組織到參數(shù)數(shù)組中的方式對其參數(shù)進行操作,就像在調(diào)用實際方法一樣:
// void exit(int status)
Consumer<Integer> b1 = System::exit;
// void sort(Object[] a)
Consumer<String[]> b2 = Arrays:sort;
// void main(String... args)
Consumer<String> b3 = MyProgram::main;
// void main(String... args)
Runnable r = Myprogram::mapToInt
七. 方法引用的種類
方法引用有很多種,它們的語法如下:
- 靜態(tài)方法引用:ClassName::methodName
- 實例上的實例方法引用:instanceReference::methodName
- 超類上的實例方法引用:super::methodName
- 類型上的實例方法引用:ClassName::methodName
- 構(gòu)造方法引用:Class::new
- 數(shù)組構(gòu)造方法引用:TypeName[]::new
對于靜態(tài)方法引用,我們需要在類名和方法名之間加入 "::" 分隔符,例如 Integer::sum。
對于具體對象上的實例方法引用,我們則需要在對象名和方法名之間加入分隔符:
Set<String> knownNames = ...
Predicate<String> isKnown = knownNames::contains;
這里的隱式 lambda 表達式會從 knownNames 中捕獲 String 對象,而它的方法體則會通過 Set.contains 使用該 String 對象。有了實例方法引用,在不同函數(shù)式接口之間進行類型轉(zhuǎn)換就變的很方便:
Callable<Path> c = ...
Privileged<Path> a = c::call;
引用任意對象的實例方法,都需要在實例方法名稱和其所屬類型名稱間加上分隔符:
Function<String, String> upperfier = String::toUpperCase;
如果類型的實例方法是泛型的,那么我們就需要在 "::" 分隔符前提供類型參數(shù),或者利用目標類型推導出其類型。
需要注意的是,靜態(tài)方法引用和類型上的實例方法引用擁有一樣的語法。編譯器會根據(jù)實際情況做出決定。一般我們不需要指定方法引用中的參數(shù)類型,因為編譯器往往可以推導出結(jié)果,但如果需要我們也可以顯式在 :: 分隔符之前提供參數(shù)類型信息。
和靜態(tài)方法引用類似,構(gòu)造方法也可以通過 new 關(guān)鍵字被直接引用:
SocketImplFactory factory = MySocketImpl::new;
如果類型擁有多個構(gòu)造方法,那么我們就會通過目標類型的方法參數(shù)來選擇最佳匹配,這里的選擇過程和調(diào)用構(gòu)造方法時的選擇過程是一樣的。
如果待實例化的類型是泛型的,那么我們可以在類型名稱之后提供類型參數(shù),否則編譯器則會依照"菱形"構(gòu)造方法調(diào)用時的方式進行推導。
數(shù)組的構(gòu)造方法引用的語法則比較特殊,為了便于理解,你可以假想存在一個接收int參數(shù)的數(shù)組構(gòu)造方法。參考下面的代碼:
IntFunction<int[]> arrayMaker = int[]::new;
int[] array = arrayMaker.apply(10) // 創(chuàng)建數(shù)組 int[10]