Java 8怎麼了之二:函數和原語

【編者按】本文做者爲專一於天然語言處理多年的 Pierre-Yves Saumont,Pierre-Yves 著有30多本主講 Java 軟件開發的書籍,自2008開始供職於 Alcatel-Lucent 公司,擔任軟件研發工程師。html

本文主要介紹了 Java 8 中的函數與原語,由國內 ITOM 管理平臺 OneAPM 編譯呈現。java

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 語法對結果並無任何影響。依然存在匿名類文件, 和一個命名函數。app

一個有意思的問題是:「x 是什麼類型?」第一個例子中的類型很明顯。能夠根據函數類型推斷出來。Java 知道函數參數類型是 Integer,由於函數類型明顯是 Function<Integer, Integer>。第一個 Integer 是參數的類型,第二個 Integer 是返回類型。框架

裝箱被自動用於按照須要將 intInteger 來回轉換。下文會詳談這一點。編程語言

可使用匿名函數嗎?能夠,不過類型就會有問題。這樣行不通: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,返回數字化原語的方法被稱爲 applyAsIntapplyAsLong,或 applyAsDouble。返回 boolean 的函數有個方法被稱爲 test,供應商的方法叫作 getgetAsIntgetAsLonggetAsDouble,或 getAsBoolean。(他們沒敢把帶有 test 方法、不帶函數的 BooleanSupplier 稱爲「謂語」。筆者真的很好奇爲何!)

值得注意的一點,是並無對應 bytecharshortfloat 的函數,也沒有對應兩個以上元數的函數。

不用說,這樣真是太荒謬了,然而咱們又不得不堅持下去。只要 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,返回類型爲 intlongdouble,或者參數和返回類型同爲 int、long 或 Object,產生729種可能性中的9種結果。)

更好的解決辦法是使用拆箱。只須要使用 IntegerLongBoolean 等等,接下來就讓 Java 去處理。任何其餘行動都會成爲萬惡之源,例如過早優化(詳見 http://c2.com/cgi/wiki?PrematureOptimization)。

另一個辦法(除了建立三個參數的功能接口以外)就是採起柯里化。若是參數不在同一時間求值,就會強制柯里化。並且它還容許只用一種參數的函數,將可能的函數數量限制在81以內。若是隻使用 booleanintlongdouble,這個數字就會降到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));

你怎麼肯定 xyzw 是什麼?實際上有個簡單的規則:經過直接使用方法求值的參數在第一位,按照使用方法的順序,例如,taxlimitdelivery 對應的就是 xyz。來自流的參數最後使用,所以它對應的是 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

相關文章
相關標籤/搜索