【編者按】本文做者爲專一於天然語言處理多年的 Pierre-Yves Saumont,Pierre-Yves 著有30多本主講 Java 軟件開發的書籍,自2008開始供職於 Alcatel-Lucent 公司,擔任軟件研發工程師。html
本文主要介紹了 Java 8 中的函數與原語,由國內 ITOM 管理平臺 OneAPM 編譯呈現。java
Tony Hoare 把空引用的發明稱爲「億萬美圓的錯誤」。也許在 Java 中使用原語能夠被稱爲「百萬美圓的錯誤」。創造原語的緣由只有一個:性能。原語與對象語言毫無關係。引入自動裝箱和拆箱是件好事,不過還有不少有待發展。可能之後會實現(聽說已經列入 Java 10的發展藍圖)。與此同時,咱們須要對付原語,這但是個麻煩,尤爲是在使用函數的時候。程序員
在 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 語法對結果並無任何影響。依然存在匿名類文件, 和一個命名函數。app
一個有意思的問題是:「x
是什麼類型?」第一個例子中的類型很明顯。能夠根據函數類型推斷出來。Java 知道函數參數類型是 Integer,由於函數類型明顯是 Function<Integer, Integer>
。第一個 Integer
是參數的類型,第二個 Integer
是返回類型。框架
裝箱被自動用於按照須要將 int
和 Integer
來回轉換。下文會詳談這一點。編程語言
可使用匿名函數嗎?能夠,不過類型就會有問題。這樣行不通:ide
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);
注意結果不是「包含值五、八、十一、14和17的流」,一開始的流也不會包含值一、二、三、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 官方博客