Java 8怎麼了:區域性套用vs閉包

OneAPM官方技術部落格發表於2016-04-28

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

本文主要介紹了 Java 8 中的閉包與區域性套用功能,由國內 ITOM 管理平臺 OneAPM 編譯呈現。

關於Java 8,存在著許多錯誤觀念。譬如,認為Java 8給Java帶來了閉包特性就是其中之一。這個想法是錯的,因為閉包特性從Java誕生之初就已經存在了。然而閉包是有缺陷的。儘管Java 8似乎傾向於函數語言程式設計,我們仍應盡力避免使用Java閉包。但是,Java 8並沒有在此方面提供過多幫助。

我們知道,引數求值時間是使用方法和使用函式時的一個重大區別。在Java中,我們可以寫一個帶引數且有返回值的方法。但是,這可以被稱作函式嗎?當然不能。方法只可以通過呼叫進行操縱,這表示它的引數會在該方法執行前取值。這是Java中引數按值傳遞的結果。

函式則與之不同。操作函式時我們可以不計算引數,且對引數何時取值有絕對的控制權。而且,如果一個函式有多個引數,它們可以不同時取值。這一點通過區域性套用就可以做到。但是首先,我們將考慮如何利用閉包進行實現。

閉包舉例

對函式而言,閉包能夠在封裝的上下文中獲取內容。在函數語言程式設計中,一個函式的結果應當僅由其引數決定。很顯然,閉包打破了這一準則。

請看Java 5/6/7中的示例:

private Integer b = 2; 
    List list = Arrays.asList(1, 2, 3, 4, 5); 
    System.out.println(calculate(list.stream(), 3).collect(toList())); 
    private Stream calculate(Stream stream, Integer a) { 
      return stream.map(new Function() { 
        @Override 
        public Integer apply(Integer t) { 
          return t * a + b; 
        } 
      }); 
    } 
    public interface Function<T, U> { 
      U apply(T t); 
    }

以上程式碼將產生如下結果:

[5, 8, 11, 14, 17]

所得結果是函式 f(x) = x * 3 + 2 對於列 [1, 2, 3, 4, 5]的對映。到這一步都沒什麼問題。但是3和2可以用其他值替換嗎?換句話說,它難道不是函式f(x, a, b) = x * a + b 對於該列的對映嗎?

是,也不是。不是的原因在於a和b都被隱性定義了final關鍵詞,因此它們在函式取值時作為常數參與計算。但是當然,它們的值也會有變動。它們的final屬性(在Java 8中隱性定義,在之前版本中則顯性定義)只是編譯器優化編譯過程的一種方式。編譯器並不在乎任何潛在的變動值。它只在乎引用有沒有發生變動,也就是說,它想要確保Integer整數物件ab的引用不發生變化,但並不在意它們的取值。這個特性在以下程式碼中可以看出:

 private Integer b = 2; 
    private Integer getB() { 
      return this.b; 
    } 
    List list = Arrays.asList(1, 2, 3, 4, 5); 
    System.out.println(calculator.calculate(list.stream(), new Int(3)).collect(toList())); 
    private Streamcalculate00(Streamstream, final Int a) { 
      return stream.map(new Function() { 
        @Override 
        public Integer apply(Integer t) { 
          return t * a.value + getB(); 
        } 
      }); 

    } 
    - 
    static private class Int { 
      public int value; 
      public Int(int value) { 
        this.value = value; 
      } 
     }

在這裡,我們使用了可變物件a(屬於Int類,而不是不可變的Integer類),以及一個方法來獲取b。現在,我們來模擬一個有三個變數的函式,但是仍舊使用僅有一個變數的函式,同時使用閉包來代替其他兩個變數。很顯然,這是非函式性的,因為它打破了僅依賴於函式引數的準則。

結果之一是,儘管有需要,我們也不能在別的地方重用這個函式,因為它依賴於上下文而不僅僅依賴於引數。我們要複製這些程式碼才能實現重用。另一個結果是,由於它需要上下文才能執行,我們也不能單獨進行函式測試。

那麼,我們應該使用帶有三個引數的函式嗎?我們可能會認為,這不可能實現。因為具體的實現過程與三個引數何時取值相關。它們都在不同的地方取值。如果我們剛才使用的是帶有三個引數的函式,它們就必須同時取值。而對映方法只會對映帶一個引數的函式到流,不可能對映帶有三個引數的函式。因此,其餘兩個引數在函式繫結時(也即傳遞給對映時)必須已經取值。解決方法是先對其餘兩個引數取值。

我們也可以用閉包來實現這一功能,但是所得程式碼是不可測試的,且可能存在重疊。

使用Java 8 的句法(lambdas)也無法改變這一狀況:

private Integer b = 2; 
    private Stream<Integer> calculate(Stream<Integer> stream, Integer a) { 
      return stream.map(t -> t * a + b); 

    }

我們需要的是一種在不同時間獲取三個引數的方法——Currying(區域性套用,也稱柯里化函式,儘管它其實是Moses Shönfinkel發明的)。

使用區域性閉包

區域性閉包就是逐一對函式引數取值,每一步都生成少一個引數的新函式。舉例來看,如果我們有如下函式:

f(x, y, z) = x * y + z

我們可以同時取引數值為2,4,5,得到以下方程:

f(3, 4, 5) = 3 * 4 + 5 = 17

我們也可以只取一個引數為3,得到以下方程:

f(3, y, z) = g(y, z) = 3 * y + z

現在,我們得到了只有兩個引數的新函式g。再對該函式進行區域性套用,將4賦值給y:

g(4, z) = h(z) = 3 * 4 + z

給引數賦值的順序對計算結果並無影響。此處,我們並不是在區域性相加,(如果是區域性相加,我們還得考慮運算子優先順序。)而是在進行對函式的區域性應用。

那麼,我們如何在Java中實現這種方法呢?以下是在Java5/6/7中的應用:

private static List<Integer> calculate(List<Integer> list, Integer a) { 
      return list.map(new Function<Integer, Function<Integer, Function<Integer, Integer>>>() { 
        @Override 
        public Function<Integer, Function<Integer, Integer>> apply(final Integer x) { 
          return new Function<Integer, Function<Integer, Integer>>() { 
            @Override 
            public Function<Integer, Integer> apply(final Integer y) { 
              return new Function<Integer, Integer>() { 
                @Override 
                public Integer apply(Integer t) { 
                  return x + y * t; 
                } 
              }; 
            } 
          }; 
        } 
      }.apply(b).apply(a)); 
    }

以上程式碼完全可以實現所需功能,但是要想說服開發者,讓他們用這種方式編寫程式碼,恐怕非常困難!還好,Java 8的lambda句法提供了以下實現方式:

private Stream<Integer> calculate(Stream<Integer> stream, Integer a) {

      return stream.map(((Function<Integer, Function<Integer, Function<Integer, Integer>>>) 
                           x -> y -> t -> x + y * t).apply(b).apply(a)); 
    }

怎麼樣?或者,是不是可以寫得更簡單一點:

private Stream<Integer> calculate(Stream<Integer> stream, Integer a) { 
      return stream.map((x -> y -> t -> x + y * t).apply(b).apply(a)); 
    }

完全可以,但是Java 8不能自行判斷引數型別,因此我們必須使用manifest型別來幫助確認(manifest在Java規範中的意思是explicit)。為了讓程式碼看起來更整潔,我們可以使用一些小技巧:

interface F3 extends Function<Integer, Function<Integer, Function<Integer, Integer>>> {} 
    private Stream<Integer> calculate(Stream<Integer> stream, Integer a) { 
      return stream.map(((F3) x -> y -> z -> x + y * z).apply(b).apply(a)); 
    }

現在,我們來為函式命名,並在必要時重用它:

private Stream<Integer> calculate(Stream<Integer> stream, Integer a) { 
      F3 calculation = x -> y -> z -> x + y * z; 
      return stream.map(calculation.apply(b).apply(a)); 
    }

我們還可以宣告計算函式為一個輔助類的靜態成員,使用靜態匯入來進一步簡化程式碼:

public class Functions { 
      static Function<Integer, Function<Integer, Function<Integer, Integer>>> calculation = 
           x -> y -> z -> x + y * z; 
        } 
        ... 

        import static Functions.calculation; 
        private Stream<Integer> calculate(Stream<Integer> stream, Integer a) { 
          return stream.map(calculation.apply(b).apply(a)); 
        }

可惜,Java 8 鼓勵的是使用閉包。不然,我會介紹更多能讓區域性套用的使用更為簡便的功能性語法糖。比如,在Scala中,以上例子就可以這樣改寫:

stream.map(calculation(b)(a))

雖然在Java中我們沒法這樣寫。可是,通過下面的靜態方法,我們可以達到相似的效果:

static Function<Integer, Function<Integer, Function<Integer, Integer>>> calculation 
        = x -> y -> z -> x + y * z; 
    static Function<Integer, Integer> calculation(Integer x, Integer y) { 
      return calculation.apply(x).apply(y); 
    }

現在,我們可以寫:

 private Stream<Integer> calculate(Stream<Integer> stream, Integer a) { 
      return stream.map(calculation(b, a)); 
    }

請注意,calculation(b, a)不是帶有兩個引數的函式。它只是一個方法,在將兩個引數逐一地區域性呼叫至一個帶有三個引數的函式之後,它會返回一個帶有一個引數的函式,該函式便可傳遞給對映函式。

現在,calculation方法便可以單獨測試了。

自動區域性呼叫

在之前的例子中,我們已經親手實踐過區域性呼叫了。然而,我們大可以編寫程式來自動化呼叫過程。我們可以編寫這樣一個方法:它會接收帶有兩個引數的函式,並返回該函式的區域性呼叫版本。寫起來非常簡單:

public <A, B, C> Function<A, Function<B, C>> curry(final BiFunction<A, B, C> f) { 
      return (A a) -> (B b) -> f.apply(a, b); 
    }

有必要的話,我們還可以寫一個方法來顛倒這一過程。這個過程可以接受A的Function函式作為引數,返回一個可返回C的B的Function函式,最終返回一個返回C的A,B的BiFunction函式。

public <A, B, C> BiFunction<A, B, C> uncurry(Function<A, Function<B, C>> f) { 
      return (A a, B b) -> f.apply(a).apply(b); 
    }

區域性呼叫的其他應用

區域性呼叫的應用方式還有很多。最重要的應用是模擬多引數函式。在Java 8提供了單引數函式(java.util.functions.Function)以及雙引數函式(java.util.functions.BiFunction)。但並未提供存在於其他語言中的三引數、四引數、五引數甚至更多引數的函式。其實,有沒有這些函式並不重要。它們只是在特定情況下,需要同時對所有引數取值時應用的語法糖。實際上,這也是BiFunctin在Java 8中存在的原因:函式的常見使用方法就是模擬二元運算子,(請注意:在Java 8中有BinaryOperator介面,但它只用於兩個引數以及返回值都屬於同一型別的特殊情況。我們將在下一篇文章中討論這一點。)

區域性呼叫在函式的各個引數需要在不同地方取值時是非常好用的。通過區域性呼叫,我們可以在某一元件中對一個引數取值,然後將計算結果傳遞到另一元件對其他引數取值,如此反覆,直到所有引數值都被取到。

小結

Java 8並不是一種函式式語言(可能永遠也不會是)。但是,我們仍可以在Java(甚至是Java 8之前的版本)中使用函式式正規化。這樣做的確會略有代價。但這種代價在Java 8中已經大幅減少了。儘管如此,想要寫函式型程式碼的開發者還是得動動腦筋才能掌握這種正規化。使用區域性呼叫就是智力成果之一。

請記住:

(A, B, C) -> D

總是可以由如下方式替代:

A -> B -> C -> D

即便Java 8無法判斷該表達方式的型別,你只要自行指定其型別就可以了。這就是區域性呼叫,它總是比閉包更為穩妥。

OneAPM 能為您提供端到端的 Java 應用效能解決方案,我們支援所有常見的 Java 框架及應用伺服器,助您快速發現系統瓶頸,定位異常根本原因。分鐘級部署,即刻體驗,Java 監控從來沒有如此簡單。想閱讀更多技術文章,請訪問 OneAPM 官方技術部落格本文轉自 OneAPM 官方部落格 編譯自:https://dzone.com/articles/whats-wrong-java-8-currying-vs

相關文章