Tips
《Effective Java, Third Edition》一書英文版已經出版,這本書的第二版想必很多人都讀過,號稱Java四大名著之一,不過第二版2009年出版,到現在已經將近8年的時間,但隨着Java 6,7,8,甚至9的發布,Java語言發生了深刻的變化。
在這里第一時間翻譯成中文版。供大家學習分享之用。
44. 優先使用標准的函數式接口
現在Java已經有lambda表達式,編寫API的最佳實踐已經發生了很大的變化。 例如,模板方法模式[Gamma95],其中一個子類重寫原始方法以專門化其父類的行為,變得沒有那么吸引人。 現代替代的選擇是提供一個靜態工廠或構造方法來接受函數對象以達到相同的效果。 通常地說,可以編寫更多以函數對象為參數的構造方法和方法。 選擇正確的函數式參數類型需要注意。
考慮LinkedHashMap
。 可以通過重寫其受保護的removeEldestEntry
方法將此類用作緩存,每次將新的key值加入到map時都會調用該方法。 當此方法返回true時,map將刪除傳遞給該方法的最久條目。 以下代碼重寫允許map增長到一百個條目,然后在每次添加新key值時刪除最老的條目,並保留最近的一百個條目:
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return size() > 100;
}
這種技術很有效,但是你可以用lambdas做得更好。如果LinkedHashMap
是現在編寫的,那么它將有一個靜態的工廠或構造方法來獲取函數對象。查看removeEldestEntry
方法的聲明,你可能會認為函數對象應該接受一個Map.Entry <K,V>
並返回一個布爾值,但是這並不完全是這樣:removeEldestEntry
方法調用size()方法來獲取條目的數量,因為removeEldestEntr
y是map上的一個實例方法。傳遞給構造方法的函數對象不是map上的實例方法,無法捕獲,因為在調用其工廠或構造方法時map還不存在。因此,map必須將自己傳遞給函數對象,函數對象把map以及最就的條目作為輸入參數。如果要聲明這樣一個功能接口,應該是這樣的:
// Unnecessary functional interface; use a standard one instead.
@FunctionalInterface interface EldestEntryRemovalFunction<K,V>{
boolean remove(Map<K,V> map, Map.Entry<K,V> eldest);
}
這個接口可以正常工作,但是你不應該使用它,因為你不需要為此目的聲明一個新的接口。 java.util.function
包提供了大量標准函數式接口供你使用。 如果其中一個標准函數式接口完成這項工作,則通常應該優先使用它,而不是專門構建的函數式接口。 這將使你的API更容易學習,通過減少其不必要概念,並將提供重要的互操作性好處,因為許多標准函數式接口提供了有用的默認方法。 例如,Predicate
接口提供了組合判斷的方法。 在我們的LinkedHashMap
示例中,標准的BiPredicate<Map<K,V>, Map.Entry<K,V>>
接口應優先於自定義的EldestEntryRemovalFunction
接口的使用。
在java.util.Function中有43個接口。不能指望全部記住它們,但是如果記住了六個基本接口,就可以在需要它們時派生出其余的接口。基本接口操作於對象引用類型。Operator
接口表示方法的結果和參數類型相同。Predicate
接口表示其方法接受一個參數並返回一個布爾值。Function
接口表示方法其參數和返回類型不同。Supplier
接口表示一個不接受參數和返回值(或“供應”)的方法。最后,Consumer
表示該方法接受一個參數而不返回任何東西,本質上就是使用它的參數。六種基本函數式接口概述如下:
接口 | 方法 | 示例 |
---|---|---|
UnaryOperator
|
T apply(T t) | String::toLowerCase |
BinaryOperator
|
T apply(T t1, T t2) | BigInteger::add |
Predicate
|
boolean test(T t) | Collection::isEmpty |
Function<T,R> | R apply(T t) | Arrays::asList |
Supplier
|
T get() | Instant::now |
Consumer
|
void accept(T t) | System.out::println |
在處理基本類型int,long和double的操作上,六個基本接口中還有三個變體。 它們的名字是通過在基本接口前加一個基本類型而得到的。 因此,例如,一個接受int的Predicate
是一個IntPredicate
,而一個接受兩個long值並返回一個long的二元運算符是一個LongBinaryOperator
。 除Function
接口變體通過返回類型進行了參數化,其他變體類型都沒有參數化。 例如,LongFunction<int[]>
使用long類型作為參數並返回了int []
類型。
Function
接口還有九個額外的變體,當結果類型為基本類型時使用。 源和結果類型總是不同,因為從類型到它自身的函數是UnaryOperator
。 如果源類型和結果類型都是基本類型,則使用帶有SrcToResult
的前綴Functio
n,例如LongToIntFunction
(六個變體)。如果源是一個基本類型,返回結果是一個對象引用,那么帶有<Src>ToObj
的前綴Function
,例如DoubleToObjFunction
(三種變體)。
有三個包含兩個參數版本的基本功能接口,使它們有意義:BiPredicate <T,U>
,BiFunction <T,U,R>
和BiConsumer <T,U>
。 也有返回三種相關基本類型的BiFunction
變體:ToIntBiFunction <T,U>
,ToLongBiFunction <T,U>
和ToDoubleBiFunction <T,U>
。 Consumer
有兩個變量,它們帶有一個對象引用和一個基本類型:ObjDoubleConsumer <T>
,ObjIntConsumer <T>
和ObjLongConsumer <T>
。 總共有九個兩個參數版本的基本接口。
最后,還有一個BooleanSupplier
接口,它是Supplier
的一個變體,它返回布爾值。 這是任何標准函數式接口名稱中唯一明確提及的布爾類型,但布爾返回值通過Predicate
及其四種變體形式支持。 前面段落中介紹的BooleanSupplier
接口和42個接口占所有四十三個標准功能接口。 無可否認,這是非常難以接受的,並且不是非常正交的。 另一方面,你所需要的大部分功能接口都是為你寫的,而且它們的名字是經常性的,所以在你需要的時候不應該有太多的麻煩。
大多數標准函數式接口僅用於提供對基本類型的支持。 不要試圖使用基本的函數式接口來裝箱基本類型的包裝類而不是基本類型的函數式接口。 雖然它起作用,但它違反了第61條中的建議:“優先使用基本類型而不是基本類型的包裝類”。使用裝箱基本類型的包裝類進行批量操作的性能后果可能是致命的。
現在你知道你應該通常使用標准的函數式接口來優先編寫自己的接口。 但是,你應該什么時候寫自己的接口? 當然,如果沒有一個標准模塊能夠滿足您的需求,例如,如果需要一個帶有三個參數的Predicate
,或者一個拋出檢查異常的Predicate
,那么需要編寫自己的代碼。 但有時候你應該編寫自己的函數式接口,即使與其中一個標准的函數式接口的結構相同。
考慮我們的老朋友Comparator <T>
,它的結構與ToIntBiFunction <T, T>
接口相同。 即使將前者添加到類庫時后者的接口已經存在,使用它也是錯誤的。 Comparator
值得擁有自己的接口有以下幾個原因。 首先,它的名稱每次在API中使用時都會提供優秀的文檔,並且使用了很多。 其次,Comparator
接口對構成有效實例的構成有強大的要求,這些要求構成了它的普遍契約。 通過實現接口,就要承諾遵守契約。 第三,接口配備很多了有用的默認方法來轉換和組合多個比較器。
如果需要一個函數式接口與Comparator
共享以下一個或多個特性,應該認真考慮編寫一個專用函數式接口,而不是使用標准函數式接口:
- 它將被廣泛使用,並且可以從描述性名稱中受益。
- 它擁有強大的契約。
- 它會受益於自定義的默認方法。
如果選擇編寫你自己的函數式接口,請記住它是一個接口,因此應非常小心地設計(條目 21)。
請注意,EldestEntryRemovalFunctio
n接口(第199頁)標有@FunctionalInterface
注解。 這種注解在類型類似於@Override
。 這是一個程序員意圖的陳述,它有三個目的:它告訴讀者該類和它的文檔,該接口是為了實現lambda表達式而設計的;它使你保持可靠,因為除非只有一個抽象方法,否則接口不會編譯; 它可以防止維護人員在接口發生變化時不小心地將抽象方法添加到接口中。 始終使用@FunctionalInterface
注解標注你的函數式接口。
最后一點應該是關於在api中使用函數接口的問題。不要提供具有多個重載的方法,這些重載在相同的參數位置上使用不同的函數式接口,如果這樣做可能會在客戶端中產生歧義。這不僅僅是一個理論問題。ExecutorService
的submit
方法可以采用Callable<T>
或Runnable
接口,並且可以編寫需要強制類型轉換以指示正確的重載的客戶端程序(條目 52)。避免此問題的最簡單方法是不要編寫在相同的參數位置中使用不同函數式接口的重載。這是條目52中建議的一個特例,“明智地使用重載”。
總之,現在Java已經有了lambda表達式,因此必須考慮lambda表達式來設計你的API。 在輸入上接受函數式接口類型並在輸出中返回它們。 一般來說,最好使用java.util.function.Function
中提供的標准接口,但請注意,在相對罕見的情況下,最好編寫自己的函數式接口。