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

OneAPM官方技術部落格發表於2016-05-03

【編者按】本文作者為專注於自然語言處理多年的 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 是返回型別。

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

可以使用匿名函式嗎?可以,不過型別就會有問題。這樣行不通:

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);

注意結果不是“包含值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));

你怎麼確定 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

相關文章