函數式程式設計(Functional Programming)是一種程式設計範式。它已經有近60年的曆史,因其更适合做并行計算,近年來開始受到大資料開發者的廣泛關注。Python、JavaScript等當紅語言對函數式程式設計支援都不錯,Scala更是以函數式程式設計的優勢在大資料領域攻城略地,即使是老牌的Java為了适應函數式程式設計,也加大對函數式程式設計的支援。未來的程式員或多或少都要了解一些函數式程式設計思想。本文抛開一些數學推理等各類複雜的概念,從使用的角度帶領讀者入門函數式程式設計。
函數式程式設計思想
在介紹函數式程式設計前,我們可以先回顧傳統的程式設計範式如何解決一個數學問題。假設我們想求解一個數學表達式:
(x + y) * z
一般的程式設計思路是:
addResult = x + y
result = addResult * 3
在這個例子中,我們要先求解中間結果,存儲到中間變量,再進一步求得最終結果。這僅僅是一個簡單的例子,在更多的程式設計實踐中,程式員必須告訴計算機每一步去執行什麼指令,需要聲明哪些中間變量等。因為計算機無法了解複雜的概念,隻能聽從程式員的指揮。
中學時代,我們的數學課上曾花費大量時間講解函數,函數y = f(x)指對于自變量x的映射。函數式程式設計的思想正是基于數學中對函數的定義。其基本思想是,在使用計算機求解問題時,我們可以把整個計算過程定義為不同的函數。比如,将這個問題轉化為:
result = multiply(add(x, y), z)
我們再做進一步的轉換:
result = add(x, y).multiply(z)
傳統思路要建立中間變量,要分步執行,而函數式程式設計的形式與數學表達式形式更為相似。人們普遍認為,這種函數式的描述更接近人類自然語言。
如果要實作這樣一個函數式程式,主要需要兩步:
- 實作單個函數,将零到多個輸入轉換成零到多個輸出。比如
這種帶有映射關系的函數,它将兩個輸入轉化為一個輸出。add
- 将多個函數連接配接起來,實作所需業務邏輯。比如,将
、add
連接配接到一起。multiply
接下來我們通過Java語言來展示如何實踐函數式程式設計思想。
Lambda表達式的構造
數理邏輯領域有一個名為λ演算的形式系統,主要研究如何使用函數來表達計算。一些程式設計語言将這個概念應用到自己的平台上,期望能實作函數式程式設計,取名為Lambda表達式(λ的英文拼寫為Lambda)。
我們先看一下Java的Lambda表達式的文法規則:
(parameters) -> {
body
}
Lambda表達式主要包括一個箭頭
->
符号,兩邊連接配接着輸入參數和函數體。我們再看看幾個Lambda表達式的例子:
// 1. 無參數,傳回值為5
() -> 5
// 2. 接收一個參數(int類型),将其乘以2,傳回一個int
x -> 2 * x
// 3. 接受2個參數(int類型),傳回他們的差
(x, y) -> x – y
// 4. 接收2個int型整數,傳回他們的和
(int x, int y) -> x + y
// 5. 接受一個String對象,在控制台列印,不傳回任何值
(String s) -> { System.out.print(s); }
// 6. 參數為圓半徑,傳回圓面積,傳回值為double類型
(double r) -> {
double pi = 3.1415;
return r * r * pi;
}
可以看到,這幾個例子都有一個
->
,表示這是一個函數式的映射,相對比較靈活的是左側的輸入參數和右側的函數體。下圖為Java Lambda表達式的一個拆解示意圖,這很符合數學中對一個函數做映射的思維方式。
接下來我們來了解一下輸入參數和函數體的一些使用規範。
輸入參數
- Lambda表達式可以接收零到多個輸入參數。
- 程式員可以提供輸入類型,也可以不提供類型,讓程式設計語言根據上下文幫忙去推斷。
- 參數可以放在圓括号
中,多個參數通過英文逗号()
隔開。如果隻有一個參數,且類型可以被推斷,可以不使用圓括号,
。空圓括号表示沒有輸入參數。()
函數體
- 函數體可以有一到多行語句,是函數的核心處理邏輯。
- 當函數體隻有一行内容,且該内容正是需要輸出的内容,可以不使用花括号
,直接輸出。{}
- 當函數體有多行内容,必須使用花括号
。{}
- 輸出的類型與所需要的類型相比對。
至此,我們可以大緻看出,Lambda表達式能夠實作将零到多個輸入轉換為零到多個輸出的映射,即實作了函數式程式設計的第一步,定義單個的函數。
Functional Interface
通過前面的幾個例子,我們大概知道Lambda表達式的内部結構了,那麼Lambda表達式到底是什麼類型呢?在Java中,Lambda表達式是有類型的,它是一個
interface
。确切的說,Lambda表達式實作了一個函數式接口(Functional Interface),或者說,前面提到的一些Lambda表達式都是函數式接口的具體實作。
函數式接口是一種
interface
,并且它隻有一個虛函數。因為這種
interface
隻有一個虛函數,是以英文中被稱為Single Abstract Method(SAM)類型接口,這也意味着這個接口對外隻提供這一個函數的功能。如果我們想自己設計一個函數式接口,我們應該給這個接口添加
@FunctionalInterface
注解。編譯器會根據這個注解確定該接口确實是函數式接口,當我們嘗試往該接口中添加超過一個虛函數方法時,編譯器會報錯。下面的例子中,我們自己設計一個加法的函數式接口
AddInterface
,然後實作這個接口。
關于
interface
、
<T>
泛型等知識,可以參考我前兩篇文章:繼承和泛型。
@FunctionalInterface
interface AddInterface<T> {
T add(T a, T b);
}
public class FunctionalInterfaceExample {
public static void main( String[] args ) {
AddInterface<Integer> addInt = (Integer a, Integer b) -> a + b;
AddInterface<Double> addDouble = (Double a, Double b) -> a + b;
int intResult;
double doubleResult;
intResult = addInt.add(1, 2);
doubleResult = addDouble.add(1.1d, 2.2d);
}
}
有了函數式接口的定義,我們知道在實作一個Lambda表達式時,Lambda表達式實際上是在實作這個函數式接口中的虛函數,Lambda表達式的輸入類型和傳回類型要與虛函數定義的類型相比對。
假如沒有Lambda表達式,我們仍然可以實作這個函數式接口,隻不過代碼比較臃腫。首先,我們需要聲明一個類來實作這個接口,可以是下面這樣的一個類:
public static class MyAdd implements AddInterface<Double> {
@Override
public Double add(Double a, Double b) {
return a + b;
}
}
在業務邏輯中這樣調用:
doubleResult = new MyAdd().add(1.1d, 2.2d);
。或者是使用匿名類,連
MyAdd
這個名字省去,直接實作
AddInterface
并調用:
doubleResult = new AddInterface<Double>(){
@Override
public Double add(Double a, Double b) {
return a + b;
}
}.add(1d, 2d);
聲明類并實作接口和使用匿名類這兩種方法是Lambda表達式出現之前Java開發者經常使用的兩種方法。實際上我們想實作的邏輯僅僅是一個
a + b
,其他行代碼其實都是備援的,都是為了給編譯器看的,并不是為了給程式員看的。有了比較我們會發現,Lambda表達式簡潔優雅的優勢就凸顯出來了。
為了友善大家使用,Java内置了一些的函數式接口,放在
java.util.function
包中,比如
Predicate
、
Function
、
BinaryOperator
等,開發者可以根據自己需求去實作這些接口。這裡簡單展示一下兩個接口。
Predicate
對輸入進行判斷,符合給定邏輯則傳回
true
,否則傳回
false
。
@FunctionalInterface
public interface Predicate<T> {
// 判斷輸入的真假,傳回boolean
boolean test(T t);
}
Function
接收一個類型T的輸入,傳回一個類型R的輸出。
@FunctionalInterface
public interface Function<T, R> {
// 接收一個類型T的輸入,傳回一個類型R的輸出
R apply(T t);
}
一些底層的架構性代碼提供了一些函數式接口供開發者調用,很多架構提供給開發者的API其實就是類似上面的函數式接口,開發者通過實作接口來完成自己的業務邏輯。Spark和Flink對外提供的Java API其實就是這種函數式接口。
Java Stream API
流(Stream)是Java 8 的另外一大亮點,它與
java.io
包裡的
InputStream
和
OutputStream
是完全不同的概念,也不是Flink、Kafka等大資料實時進行中的資料流。它專注于對集合(Collection)對象的操作,是借助Lambda表達式的一種應用。通過Java Stream,我們可以體驗到Lambda表達式帶來的程式設計效率的提升。
我們看一個簡單的例子,這個例子首先過濾出非空字元串,然後求得每個字元串的長度,最終傳回為一個
List<Integer>
。代碼使用了Lambda表達式來完成對應的邏輯。
List<String> strings = Arrays.asList(
"abc", "", "bc", "12345",
"efg", "abcd","", "jkl");
List<Integer> lengths = strings
.stream()
.filter(string -> !string.isEmpty())
.map(s -> s.length())
.collect(Collectors.toList());
lengths.forEach((s) -> System.out.println(s));
這段代碼中,資料先經過
stream
方法被轉換為一個
Stream
類型,後經過
filter
、
map
、
collect
等處理邏輯,生成我們所需的輸出。各個操作之間使用英文點号
.
來連接配接,這種方式被稱作方法鍊(Method Chaining)或者鍊式調用。資料的鍊式調用可以被抽象成一個管道(Pipeline),如下圖所示。
我們深挖一下Java Stream的源碼,發現
filter
的參數正是前文所說的
Predicate
函數式接口,
map
的參數是前文提到的
Function
函數式接口。當處理具體的業務時,就是使用Lambda表達式來實作這些函數式接口。
Stream<T> filter(Predicate<? super T> predicate);
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
上面兩行是Java Stream的源碼,其中
?
是泛型通配符,主要是為了對泛型做一些安全性上的限制,有興趣的讀者可以自行去了解泛型的的通配符。
Java Stream是應用Lambda表達式的最佳案例,Stream管道和鍊式調用解決了本文最初提到的函數式程式設計第二個問題:将多個函數連接配接起來,實作所需業務邏輯。
小結
函數式程式設計更符合數學上函數映射的思想。具體到程式設計語言層面,我們可以使用Lambda表達式來快速編寫函數映射,函數之間通過鍊式調用連接配接到一起,完成所需業務邏輯。Java的Lambda表達式是後來才引入的,而Scala天生就是為函數式程式設計所設計。由于函數式程式設計在并行處理方面的優勢,正在被大量應用在大資料計算領域。