Java中的函數式編程(一)概念


寫在前面

從Java 8開始,Java語言添加了lambda表達式以及函數式接口等新特性。這意味着Java語言也開始逐步提供函數式編程的能力。

事實上,如果你熟悉Erlang、Scala、JavaScript或Python,那你或多或少對函數式編程相對熟悉。但如果你是一個通過常規路徑學習的Javaer,可能對函數式編程思想不甚了解,相對的,你可能對面向對象編程思想會更熟悉。

先熟悉一下幾個術語,有利於提升大家的逼格:
FP,Functional Programming,函數式編程;
OOP,Object Oriented Programming,面向對象編程;


雖然FP是在最近10年才流行起來的,但它的歷史和OOP幾乎等長。為什么FP突然流行起來了呢?

  • 最主要原因是摩爾定律正在逐漸失效,單核CPU的計算能力在短期內無法有大的突破,計算機領域正在向“多核CPU和分布式計算”的方向發展。而FP則天然具備適合並發編程的優點:它不修改變量,不存在多線程間的競爭問題,因此也不需要考慮“鎖”和“線程阻塞”,易於並發編程。在未來的大數據時代,函數式編程思想將會越來越重要。
  • 其次,在現實的編碼過程中,程序員們發現了即使在OOP的世界中,FP能更好的解決某些具體問題,是OOP的有益補充。因此,出現了許多混合式風格的代碼(混合式指 OOP + FP ),這也是為什么Java這個老牌的OOP編程語言要引入FP新特性的原因所在。

本系列文章的重點在於介紹Java中的函數式編程,Java作為一個經典的OOP編程語言,在實際應用中,大部分Java程序都是OOP+FP的混合式代碼。

因此,對於函數式編程中的一些高級特性和技巧,例如Currying、惰性求值、尾遞歸等,我們不做專門的闡述,感興趣的同學,可以搜索公眾號,員說,一起討論。

下面,我們先了解一下函數式編程的定義以及它的優點。

本文的示例代碼可從gitee上獲取:https://gitee.com/cnmemset/javafp

什么是函數式編程?

函數式編程是一種編程范式(programming paradigm),追求的目標是整個程序都由函數調用(function applying)以及函數組合(function composing)構成的。

函數調用大家容易理解,但在函數式編程中,函數調用有一個限制——它不會改變函數以外的其它狀態,換而言之,即函數調用不會改變在該函數之外定義的變量值。這種函數有個專門的術語——純函數(purely function)。純函數有個特點,當參數值不變的時候,多次運行純函數,得到的結果總是一樣的。這個特點特別有利於對純函數進行unit test和debugging。

函數組合指的是將一系列簡單函數組合起來形成一個復合函數。函數組合是一個相對復雜的概念,譬如在Python中:

from functools import reduce
def compose(*funcs) -> int:
    """將一組簡單函數 [f, g, h] 組合為一個復合函數 (f(g(h(...)))) """
    return reduce(lambda f, g: lambda x: f(g(x)), funcs)

# 例子
f = lambda x: x + 1
g = lambda x: x * 2
h = lambda x: x - 3
# 調用復合函數 f(g(h(x)):[(x-3) * 2] + 1
print(compose(f, g, h)(10))  // print 15
在Java中,java.util.Objects.Consumer 接口的默認方法 andThen 是一個簡單的函數組合函數:
default Consumer<T> andThen(Consumer<? super T> after) {
    Objects.requireNonNull(after);
    return (T t) -> { accept(t); after.accept(t); };
}

 

函數式編程的特性

1. 函數是“第一等公民”(first-class citizens)

函數是“第一等公民”,意味着函數和其它數據類型具備同等的地位——可以賦值給某個變量,可以作為另一個函數的參數,也可以作為另一個函數的返回值。

判斷某種開發語言對函數式編程支持程度高低,一個重要的標准就是該語言是否把函數作為“第一等公民”。

例如下面的Java代碼,print變量可以看做是一個匿名函數,它作為一個參數傳入了函數 ArrayList.forEach。更多的語言細節可以參考隨后的系列文章。

public static void simpleFunctinoProgramming() {
    List<String> l = Arrays.asList("a", "b", "c");
    Consumer<String> print = s -> System.out.println(s);
    l.forEach(print);
}

上述代碼會輸出:
a
b
c

2. 沒有“副作用(side effects)”

“副作用(side effects)”,指的是函數在執行的時候,除了得出計算結果之外,還會改變函數以外的狀態。“副作用”的典型場景就是修改了程序的全局變量(譬如Java中某個全局可見的類的屬性值、某個類的靜態變量等等);修改傳入的參數也屬於“副作用”之一;IO操作或調用其它有“副作用”的函數也屬於“副作用”。

函數式編程中要求函數都是“純函數(purely function)”。給定了參數后,多次運行純函數,總會得到相同的返回值,而且它不會修改函數以外的狀態或產生其它的“副作用”。

“副作用”的含義是如此苛刻,但有的時候我們需要在計算過程中保存狀態,然而我們又不能使用可變量,此時我們使用遞歸,利用保存在棧上的參數來記錄狀態。下面的代碼是一個經典的實例,它定義了一個將字符串反轉的函數reverse。可以看到,reverse在執行時的中間狀態,是通過它在遞歸時的參數來保存的。務必牢記,在函數式編程中,所有的參數和變量都是final的(只能賦值1次而且賦值后不可變):

public static String reverse(final String arg) {
    if (arg.length() == 0) {
        return arg;
    } else {
        return reverse(arg.substring(1)) + arg.substring(0, 1);
    }
}

我們可以使用遞歸來解決類似的問題,雖然它的性能實在不敢恭維,但它確實完全符合函數式編程風格。此時,不免有一個疑問:函數式編程限制如此的多,性能看起來也不太好,我們為什么還要提倡函數式編程呢?原因就在於函數式編程有着許多的優點,尤其是在大數據 && 多核計算的時代。

3. 引用透明(Referential transparency)

引用透明(Referential transparency),指的是函數的運行不依賴於外部狀態或外部變量,只依賴於輸入的參數。任何時候只要參數相同,運行函數所得到的返回值總是相同的。

在其他的編程范式中,函數的返回值往往與系統狀態有關。不同的狀態之下,返回值可能不一樣。這很不利於對程序進行測試和調試。

 

函數式編程的優點

1. 便於單元測試和調試(Debugging)

由於函數式編程的“沒有副作用”和“引用透明”的特點,每個函數都是一個獨立的邏輯單元,不依賴於外部的狀態或變量,也不修改外部的狀態或變量。給定了參數后,多次運行函數,總會得到相同的返回值。這對單元測試者以及調試者來說,簡直是最理想的情形。

2. 易於“並發編程”

同樣的,函數式編程不依賴於也不會修改外部的狀態或變量,因此我們不需要考慮多線程的並發競爭、死鎖等問題,因為我們根本不需要加“鎖”。這樣,並發編程的復雜度將大大降低,部署也非常方便。

在大數據和多核時代,這個優點被更大的放大了,這也是函數式編程思想煥發活力的主要原因。

3. 便於熱部署

這個優點也很顯然——因為函數式編程不依賴於也不會修改外部的狀態或變量,所以只要保持接口不變,我們可以隨時升級代碼,不需要重啟服務。

結語

需要謹記,函數式編程(FP)是一種編程范式或者說是一種編程思想,和它可以類比的是面向對象編程(OOP)。

OOP的精髓在於“封裝對象”,而FP的精髓在於“不涉及外部狀態”。

一門開發語言,可以既支持OOP,同時也支持FP,兩者並不是矛盾的。

一門開發語言,即使它號稱真正支持函數式編程,也不可能100%符合函數式編程風格,因為IO操作是有“副作用”的,但實際上,不做I/O是不可能的。因此,在實際應用中,函數式編程只要求把I/O的影響限制到最小,並更專注於保持計算過程的單純性。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM