Java 8怎麼了之二:函式和原語
【編者按】本文作者為專注於自然語言處理多年的 Pierre-Yves Saumont,Pierre-Yves 著有30多本主講 Java 軟體開發的書籍,自2008開始供職於 Alcatel-Lucent 公司,擔任軟體研發工程師。
本文主要介紹了 Java 8 中的函式與原語,由國內 ITOM 管理平臺 OneAPM 編譯呈現。
Tony Hoare 把空引用的發明稱為“億萬美元的錯誤”。也許在 Java 中使用原語可以被稱為“百萬美元的錯誤”。創造原語的原因只有一個:效能。原語與物件語言毫無關係。引入自動裝箱和拆箱是件好事,不過還有很多有待發展。可能以後會實現(據說已經列入 Java 10的發展藍圖)。與此同時,我們需要對付原語,這可是個麻煩,尤其是在使用函式的時候。
Java 5/6/7的函式
在 Java 8之前,使用者可以建立下面這樣的函式:
public interface Function<T, U> {
U apply(T t);
}
Function<Integer, Integer> addTax = new Function<Integer, Integer>() {
@Override
public Integer apply(Integer x) {
return x / 100 * (100 + 10); }
};
System.out.println(addTax.apply(100));
這些程式碼會產生以下結果:
110
Java 8 帶來了 Function<T, U>
介面和 lambda 語法。我們不再需要界定自己的功能介面, 而且可以使用下面這樣的語法:
Function<Integer, Integer> addTax = x -> x / 100 * (100 + 10);
System.out.println(addTax.apply(100));
注意在第一個例子中,筆者用了一個匿名類檔案來建立一個命名函式。在第二個例子中,使用 lambda 語法對結果並沒有任何影響。依然存在匿名類檔案, 和一個命名函式。
一個有意思的問題是:“x
是什麼型別?”第一個例子中的型別很明顯。可以根據函式型別推斷出來。Java 知道函式引數型別是 Integer,因為函式型別明顯是 Function<Integer, Integer>
。第一個 Integer
是引數的型別,第二個 Integer
是返回型別。
裝箱被自動用於按照需要將 int
和 Integer
來回轉換。下文會詳談這一點。
可以使用匿名函式嗎?可以,不過型別就會有問題。這樣行不通:
System.out.println((x -> x / 100 * (100 + 10)).apply(100));
這意味著我們無法用識別符號的值來替代識別符號 addTax
本身( addTax
函式)。在本案例中,需要恢復現在缺失的型別資訊,因為 Java 8 無法推斷型別。
最明顯缺乏型別的就是識別符號 x
。可以做以下嘗試:
System.out.println((Integer x) -> x / 100 * 100 + 10).apply(100));
畢竟在第一個例子中,本可以這樣寫:
Function<Integer, Integer> addTax = (Integer x) -> x / 100 * 100 + 10;
這樣應該足夠讓 Java 推測型別,但是卻沒有成功。需要做的是明確函式的型別。明確函式引數的型別並不夠,即使已經明確了返回型別。這麼做還有一個很嚴肅的原因:Java 8對函式一無所知。可以說函式就是普通物件加上普通方法,僅此而已。因此需要像下面這樣明確型別:
System.out.println(((Function<Integer, Integer>) x -> x / 100 * 100 + 10).apply(100));
否則,就會被解讀為:
System.out.println(((Whatever<Integer, Integer>) x -> x / 100 * 100 + 10).whatever(100));
因此 lambda 只是在語法上起到簡化匿名類在 Function
(或 Whatever
)介面執行的作用。它實際上跟函式毫不相關。
假設 Java 只有 apply
方法的 Function
介面,這就不是個大問題。但是原語怎麼辦呢?如果 Java 只是物件語言,Function
介面就沒關係。可是它不是。它只是模糊地物件導向的使用(因此被稱為物件導向)。Java 中最重要的類別是原語,而原語與物件導向程式設計融合得並不好。
Java 5 中引入了自動裝箱,來協助解決這個問題,但是自動裝箱對效能產生了嚴重限制,這還關係到 Java 如何求值。Java 是一種嚴格的語言,遵循立即求值規則。結果就是每次有原語需要物件,都必須將原語裝箱。每次有物件需要原語,都必須將物件拆箱。如果依賴自動裝箱和拆箱,可能會產生多次裝箱和拆箱的大量開銷。
其他語言解決這個問題的方法有所不同,只允許物件,在後臺解決了轉化問題。他們可能會有“值類”,也就是受到原語支援的物件。在這種功能下,程式設計師只使用物件,編譯器只使用原語(描述過於簡化,不過反映了基本原則)。Java 允許程式設計師直接控制原語,這就增大了問題難度,帶來了更多安全隱患,因為程式設計師被鼓勵將原語用作業務型別,這在物件導向程式設計或函式式程式設計中都沒有意義。(筆者將在另一篇文章中再談這個問題。)
不客氣地說,我們不應該擔心裝箱和拆箱的開銷。如果帶有這種特性的 Java 程式執行過慢,這種程式語言就應該進行修復。我們不應該試圖用糟糕的程式設計技巧來解決語言本身的不足。使用原語會讓這種語言與我們作對,而不是為我們所用。如果問題不能通過修復語言來解決,那我們就應該換一種程式語言。不過也許不能這樣做,原因有很多,其中最重要的一條是隻有 Java 付錢讓我們程式設計,其他語言都沒有。結果就是我們不是在解決業務問題,而是在解決 Java 的問題。使用原語正是 Java 的問題,而且問題還不小。
現在不用物件,用原語來重寫例子。選取的函式採用型別 Integer
的引數,返回 Integer
。要取代這些,Java 有 IntUnaryOperator
型別。哇哦,這裡不對勁兒!你猜怎麼著,定義如下:
public interface IntUnaryOperator {
int applyAsInt(int operand);
...
}
這個問題太簡單,不值得調出方法 apply
。
因此,使用原語重寫例子如下:
IntUnaryOperator addTax = x -> x / 100 * (100 + 10);
System.out.println(addTax.applyAsInt(100));
或者採用匿名函式:
System.out.println(((IntUnaryOperator) x -> x / 100 * (100 + 10)).applyAsInt(100));
如果只是為了 int
返回 int
的函式,很容易實現。不過實際問題要更加複雜。Java 8 的 java.util.function
包中有43種(功能)介面。實際上,它們不全都代表功能,可以分類如下:
- 21個帶有一個引數的函式,其中2個為物件返回物件的函式,19個為各種型別的物件到原語或原語到物件函式。2個物件到物件函式中的1個用於引數和返回值屬於相同型別的特殊情況。
- 9個帶有2個引數的函式,其中2個為(物件,物件)到物件,7個為各種型別的(物件,物件)到原語或(原語,原語)到原語。
- 7個為效果,非函式,因為它們並不返回任何值,而且只被用於獲取副作用。(把這些稱為“功能介面”有些奇怪。)
- 5個為“供應商”,意思就是這些函式不帶引數,卻會返回值。這些可以是函式。在函式世界裡,有些特殊函式被稱為無參函式(表明它們的元數或函式總量為0)。作為函式,它們返回的值可能永遠不變,因此它們允許將常量當做函式。在 Java 8,它們的職責是根據可變語境來返回各種值。因此,它們不是函式。
真是太亂了!而且這些介面的方法有不同的名字。物件函式有個方法叫 apply
,返回數字化原語的方法被稱為 applyAsInt
、applyAsLong
,或 applyAsDouble
。返回 boolean
的函式有個方法被稱為 test
,供應商的方法叫做 get
或 getAsInt
、getAsLong
、 getAsDouble
,或 getAsBoolean
。(他們沒敢把帶有 test
方法、不帶函式的 BooleanSupplier
稱為“謂語”。筆者真的很好奇為什麼!)
值得注意的一點,是並沒有對應 byte
、 char
、 short
和 float
的函式,也沒有對應兩個以上元數的函式。
不用說,這樣真是太荒謬了,然而我們又不得不堅持下去。只要 Java 能推斷型別,我們就會覺得一切順利。然而,一旦試圖通過功能方式控制函式,你將會很快面對 Java 無法推斷型別的難題。最糟糕的是,有時候 Java 能夠推斷型別,卻會保持沉默,繼續使用另外一個型別,而不是我們想用的那一個。
如何發現正確型別
假設筆者想使用三個引數的函式。由於 Java 8沒有現成可用的功能介面,筆者只有一個選擇:建立自己的功能介面,或者如前文(Java 8 怎麼了之一)中所說,採取柯里化。建立三個物件引數、並返回物件的功能介面直截了當:
interface Function<T, U, V, R> {
R apply(T, t, U, u, V, v);
}
不過,可能出現兩種問題。第一種,可能需要處理原語。引數型別也幫不上忙。你可以建立函式的特殊形式,使用原語,而不是物件。最後,算上8類原語、3個引數和1個返回值,只不過得到6561中該函式的不同版本。你以為甲骨文公司為什麼沒有在 Java 8中包含 TriFunction
?(準確來說,他們只放了有限數量的 BiFunction
,引數為 Object
,返回型別為 int
、long
或double
,或者引數和返回型別同為 int、long 或 Object,產生729種可能性中的9種結果。)
更好的解決辦法是使用拆箱。只需要使用 Integer
、Long
、Boolean
等等,接下來就讓 Java 去處理。任何其他行動都會成為萬惡之源,例如過早優化(詳見 http://c2.com/cgi/wiki?PrematureOptimization)。
另外一個辦法(除了建立三個引數的功能介面之外)就是採取柯里化。如果引數不在同一時間求值,就會強制柯里化。而且它還允許只用一種引數的函式,將可能的函式數量限制在81之內。如果只使用 boolean
、int
、long
和double
,這個數字就會降到25(4個原語型別加上兩個位置的 Object
相當於5 x 5)。
問題在於在對返回原語,或將原語作為引數的函式來說,使用柯里化可能有些困難。以下是前文(Java 8怎麼了之一)中使用的同一例子,不過現在用了原語:
IntFunction<IntFunction<IntUnaryOperator>>
intToIntCalculation = x -> y -> z -> x + y * z;
private IntStream calculate(IntStream stream, int a) {
return stream.map(intToIntCalculation.apply(b).apply(a));
}
IntStream stream = IntStream.of(1, 2, 3, 4, 5);
IntStream newStream = calculate(stream, 3);
注意結果不是“包含值5、8、11、14和17的流”,一開始的流也不會包含值1、2、3、4和5。newStream
在這個階段並沒有求值,因此不包含值。(下篇文章將討論這個問題)。
為了檢視結果,就要對這個流求值,也許通過繫結一個終端操作來強制執行。可以通過呼叫 collect
方法。不過在這個操作之前,筆者要利用 boxed
方法將結果與一個非終端函式繫結在一起。boxed
方法將流與一個能夠把原語轉為對應物件的函式繫結在一起。這可以簡化求值過程:
System.out.println(newStream.boxed().collect(toList()));
這顯示為:
[5,8, 11, 14, 17]
也可以使用匿名函式。不過,Java 不能推斷型別,所以筆者必須提供協助:
private IntStream calculate(IntStream stream, int a) {
return stream.map(((IntFunction<IntFunction<IntUnaryOperator>>) x -> y -> z -> x + y * z).apply(b).apply(a));
}
IntStream stream = IntStream.of(1, 2, 3, 4, 5);
IntStream newStream = calculate(stream, 3);
柯里化本身很簡單,只要別忘了筆者在其他文章中提到過的一點:
(x, y, z) -> w
解讀為:
x -> y -> z -> w
尋找正確型別稍微複雜一些。要記住,每次使用一個引數,都會返回一個函式,因此你需要一個從引數型別到物件型別的函式(因為函式就是物件)。在本例中,每個引數型別都是 int
,因此需要使用經過返回函式型別引數化的 IntFunction。由於最終型別為 IntUnaryOperator
(這是 IntStream
類的 map
方法的要求),結果如下:
IntFunction<IntFunction<...<IntUnaryOperator>>>
筆者採用了三個引數中的兩種,所有引數型別都是 int
,因此型別如下:
IntFunction<IntFunction<IntUnaryOperator>>
可以與使用自動裝箱版本進行比較:
Function<Integer, Function<Integer, Function<Integer, Integer>>>
如果你無法決定正確型別,可以從使用自動裝箱開始,只要替換上你需要的最終型別(因為它就是 map
引數的型別):
Function<Integer, Function<Integer, IntUnaryOperator>>
注意,你可能正好在你的程式中使用了這種型別:
private IntStream calculate(IntStream stream, int a) {
return stream.map(((Function<Integer, Function<Integer, IntUnaryOperator>>) x -> y -> z -> x + y * z).apply(b).apply(a));
}
IntStream stream = IntStream.of(1, 2, 3, 4, 5);
IntStream newStream = calculate(stream, 3);
接下來可以用你使用的原語版本來替換每個 Function<Integer...
,如下所示:
private IntStream calculate(IntStream stream, int a) {
return stream.map(((Function<Integer, IntFunction<IntUnaryOperator>>) x -> y -> z -> x + y * z).apply(b).apply(a)); }
然後是:
private IntStream calculate(IntStream stream, int a) { return stream.map(((IntFunction<IntFunction<IntUnaryOperator>>) x -> y -> z -> x + y * z).apply(b).apply(a)); }
注意,三個版本都可編譯執行,唯一的區別在於是否使用了自動裝箱。
何時匿名 在以上例子中可見,lambdas 很擅長簡化匿名類的建立,但是不給建立的範例命名實在沒有理由。命名函式的用處包括:
- 函式複用
- 函式測試
- 函式替換
- 程式維護
- 程式文件管理
命名函式加上柯里化能夠讓函式完全獨立於環境(“引用透明性”),讓程式更安全、更模組化。不過這也存在難度。使用原語增加了辨別柯里化函式類別的難度。更糟糕的是,原語並不是可使用的正確業務型別,因此編譯器也幫不上忙。具體原因請看以下例子:
double tax = 10.24;
double limit = 500.0;
double delivery = 35.50;
DoubleStream stream3 = DoubleStream.of(234.23, 567.45, 344.12, 765.00);
DoubleStream stream4 = stream3.map(x -> {
double total = x / 100 * (100 + tax);
if ( total > limit) {
total = total + delivery;
}
return total;
});
要用命名的柯里化函式來替代匿名“捕捉”函式,確定正確型別並不難。有4個引數,返回 DoubleUnaryOperator
,那麼型別應該是 DoubleFunction<DoubleFunction<DoubleFunction<DoubleUnaryOperator>>>
。不過,很容易錯放引數位置:
DoubleFunction<DoubleFunction<DoubleFunction<DoubleUnaryOperator>>> computeTotal = x -> y -> z -> w -> {
double total = w / 100 * (100 + x);
if (total > y) {
total = total + z;
}
return total;
};
DoubleStream stream2 = stream.map(computeTotal.apply(tax).apply(limit).apply(delivery));
你怎麼確定 x
、y
、z
和 w
是什麼?實際上有個簡單的規則:通過直接使用方法求值的引數在第一位,按照使用方法的順序,例如,tax
、limit
、delivery
對應的就是 x
、y
和 z
。來自流的引數最後使用,因此它對應的是 w
。
不過還存在一個問題:如果函式通過測試,我們知道它是正確的,但是沒有辦法確保它被正確使用。舉個例子,如果我們使用引數的順序不對:
DoubleStream stream2 = stream.map(computeTotal.apply(limit).apply(tax).apply(delivery));
就會得到:
[1440.8799999999999, 3440.2000000000003, 2100.2200000000003, 4625.5]
而不是:
[258.215152, 661.05688, 379.357888, 878.836]
這就意味著不僅需要測試函式,還要測試它的每次使用。如果能夠確保使用順序不對的引數不會被編譯,豈不是很好?
這就是使用正確型別體系的所有內容。將原語用於業務型別並不好,從來就沒有好結果。但是現在有了函式,就更多了一條不要這麼做的理由。這個問題將在其他文章中詳細討論。
敬請期待
本文介紹了使用原語大概比使用物件更為複雜。在 Java 8中使用原語的函式一團糟,不過還有更糟糕的。在下一篇文章中,筆者將談論在流中使用原語。
OneAPM 能為您提供端到端的 Java 應用效能解決方案,我們支援所有常見的 Java 框架及應用伺服器,助您快速發現系統瓶頸,定位異常根本原因。分鐘級部署,即刻體驗,Java 監控從來沒有如此簡單。想閱讀更多技術文章,請訪問 OneAPM 官方技術部落格。
本文轉自 OneAPM 官方部落格
原文地址: https://dzone.com/articles/whats-wrong-java-8-part-ii
相關文章
- 8.掌握了Dart中的函式,你就掌握了原力!Dart函式
- Java 8 Function 函式介面JavaFunction函式
- java8特性-函式式介面Java函式
- Java 8:使用compose和andThen組合函式Java函式
- java基礎之二:取整函式(Math類)Java函式
- java8函數語言程式設計筆記-破壞式更新和函式式更新Java函數程式設計筆記函式
- 函式: 函式是怎麼使用的?函式
- 簡析JAVA8函式式介面Java函式
- Java 8怎麼了:區域性套用vs閉包Java
- 《Java 8函數語言程式設計》選讀:為什麼要給Java 8中加入函數語言程式設計?Java函數程式設計
- ORACLE單行函式與多行函式之二:字元函式示例Oracle函式字元
- Java8之Stream-函式式介面Java函式
- 「Java8系列」神奇的函式式介面Java函式
- Java8的新特性--函式式介面Java函式
- java8 新特性之函式式介面Java函式
- 第 8 節:函式-函式定義和引數函式
- java 8新特性學習之二:Java 8 lambda表示式初步第二回Java
- Java8新特性探索之函式式介面Java函式
- C語言中函式printf()和函式scanf()的用法C語言函式
- java自定義equals函式和hashCode函式Java函式
- 節流函式怎麼寫?函式
- 【Java8新特性】Lambda表示式基礎語法,都在這兒了!!Java
- awk之二次分隔函式函式
- JS 中的函式表示式和函式宣告你混淆了嗎?JS函式
- 記錄:java程式沒有main函式只有init函式怎麼執行,或者applet小程式怎麼執行JavaAI函式APP
- 最近寫了一個demo,想看看java和go語言是怎麼寫的JavaGo
- 函式式思維和函數語言程式設計函式函數程式設計
- 《Java 8函數語言程式設計》選讀:第一個Lambda表示式Java函數程式設計
- java8新特性之函式式介面、lambda表示式、介面的預設方法、方法和建構函式的引用Java函式
- Java 框架到底怎麼了Java框架
- ?Java8新特性之Lambda表示式,函式式介面,方法引用和default關鍵字Java函式
- JDK8之後,在java語言這條路怎麼走?JDKJava
- 第 8 節:函式-匿名函式、遞迴函式函式遞迴
- python怎麼封裝函式Python封裝函式
- postgresql中怎麼檢視函式SQL函式
- mysql中的if函式怎麼用MySql函式
- excel index match 函式怎麼用ExcelIndex函式
- OpenFaaS實戰之二:函式入門函式