函數式編程(Functional Programming)是一種編程範式。它已經有近60年的歷史,因其更適合做並行計算,近年來開始受到大數據開發者的廣泛關注。Python、JavaScript等當紅語言對函數式編程支持都不錯,Scala更是以函數式編程的優勢在大數據領域攻城略地,即使是老牌的Java為了適應函數式編程,也加大對函數式編程的支持。未來的程序員或多或少都要了解一些函數式編程思想。本文拋開一些數學推理等各類複雜的概念,從使用的角度帶領讀者入門函數式編程。
函數式編程思想
在介紹函數式編程前,我們可以先回顧傳統的編程範式如何解決一個數學問題。假設我們想求解一個數學表達式:
<code>(x + y) * z/<code>
一般的編程思路是:
<code>addResult = x + yresult = addResult * 3/<code>
在這個例子中,我們要先求解中間結果,存儲到中間變量,再進一步求得最終結果。這僅僅是一個簡單的例子,在更多的編程實踐中,程序員必須告訴計算機每一步去執行什麼命令,需要聲明哪些中間變量等。因為計算機無法理解複雜的概念,只能聽從程序員的指揮。
中學時代,我們的數學課上曾花費大量時間講解函數,函數y = f(x)指對於自變量x的映射。函數式編程的思想正是基於數學中對函數的定義。其基本思想是,在使用計算機求解問題時,我們可以把整個計算過程定義為不同的函數。比如,將這個問題轉化為:
<code>result = multiply(add(x, y), z)/<code>
我們再做進一步的轉換:
<code>result = add(x, y).multiply(z)/<code>
傳統思路要創建中間變量,要分步執行,而函數式編程的形式與數學表達式形式更為相似。人們普遍認為,這種函數式的描述更接近人類自然語言。
如果要實現這樣一個函數式程序,主要需要兩步:
- 實現單個函數,將零到多個輸入轉換成零到多個輸出。比如add這種帶有映射關係的函數,它將兩個輸入轉化為一個輸出。
- 將多個函數連接起來,實現所需業務邏輯。比如,將add、multiply連接到一起。
接下來我們通過Java語言來展示如何實踐函數式編程思想。
Lambda表達式的構造
數理邏輯領域有一個名為λ演算的形式系統,主要研究如何使用函數來表達計算。一些編程語言將這個概念應用到自己的平臺上,期望能實現函數式編程,取名為Lambda表達式(λ的英文拼寫為Lambda)。
我們先看一下Java的Lambda表達式的語法規則:
<code>(parameters) -> { body }/<code>
Lambda表達式主要包括一個箭頭->符號,兩邊連接著輸入參數和函數體。我們再看看幾個Lambda表達式的例子:
<code>// 1. 無參數,返回值為5 () -> 5 // 2. 接收一個參數(int類型),將其乘以2,返回一個intx -> 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;}/<code>
可以看到,這幾個例子都有一個->,表示這是一個函數式的映射,相對比較靈活的是左側的輸入參數和右側的函數體。下圖為Java Lambda表達式的一個拆解示意圖,這很符合數學中對一個函數做映射的思維方式。
接下來我們來了解一下輸入參數和函數體的一些使用規範。
輸入參數
- Lambda表達式可以接收零到多個輸入參數。
- 程序員可以提供輸入類型,也可以不提供類型,讓編程語言根據上下文幫忙去推斷。
- 參數可以放在圓括號()中,多個參數通過英文逗號,隔開。如果只有一個參數,且類型可以被推斷,可以不使用圓括號()。空圓括號表示沒有輸入參數。
函數體
- 函數體可以有一到多行語句,是函數的核心處理邏輯。
- 當函數體只有一行內容,且該內容正是需要輸出的內容,可以不使用花括號{},直接輸出。
- 當函數體有多行內容,必須使用花括號{}。
- 輸出的類型與所需要的類型相匹配。
至此,我們可以大致看出,Lambda表達式能夠實現將零到多個輸入轉換為零到多個輸出的映射,即實現了函數式編程的第一步,定義單個的函數。
Functional Interface
通過前面的幾個例子,我們大概知道Lambda表達式的內部結構了,那麼Lambda表達式到底是什麼類型呢?在Java中,Lambda表達式是有類型的,它是一個interface。確切的說,Lambda表達式實現了一個函數式接口(Functional Interface),或者說,前面提到的一些Lambda表達式都是函數式接口的具體實現。
函數式接口是一種interface,並且它只有一個虛函數。因為這種interface只有一個虛函數,因此英文中被稱為Single Abstract Method(SAM)類型接口,這也意味著這個接口對外只提供這一個函數的功能。如果我們想自己設計一個函數式接口,我們應該給這個接口添加@FunctionalInterface註解。編譯器會根據這個註解確保該接口確實是函數式接口,當我們嘗試往該接口中添加超過一個虛函數方法時,編譯器會報錯。下面的例子中,我們自己設計一個加法的函數式接口AddInterface,然後實現這個接口。
關於interface、
<code>@FunctionalInterfaceinterface AddInterface{ 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); }}/<double>/<integer> /<code>
有了函數式接口的定義,我們知道在實現一個Lambda表達式時,Lambda表達式實際上是在實現這個函數式接口中的虛函數,Lambda表達式的輸入類型和返回類型要與虛函數定義的類型相匹配。
假如沒有Lambda表達式,我們仍然可以實現這個函數式接口,只不過代碼比較臃腫。首先,我們需要聲明一個類來實現這個接口,可以是下面這樣的一個類:
<code>public static class MyAdd implements AddInterface<double> { @Override public Double add(Double a, Double b) { return a + b; }}/<double>/<code>
在業務邏輯中這樣調用:doubleResult = new MyAdd().add(1.1d, 2.2d);。或者是使用匿名類,連MyAdd這個名字省去,直接實現AddInterface並調用:
<code>doubleResult = new AddInterface<double>(){ @Override public Double add(Double a, Double b) { return a + b; }}.add(1d, 2d);/<double>/<code>
聲明類並實現接口和使用匿名類這兩種方法是Lambda表達式出現之前Java開發者經常使用的兩種方法。實際上我們想實現的邏輯僅僅是一個a + b,其他行代碼其實都是冗餘的,都是為了給編譯器看的,並不是為了給程序員看的。有了比較我們會發現,Lambda表達式簡潔優雅的優勢就凸顯出來了。
為了方便大家使用,Java內置了一些的函數式接口,放在java.util.function包中,比如Predicate、Function、BinaryOperator等,開發者可以根據自己需求去實現這些接口。這裡簡單展示一下兩個接口。
Predicate對輸入進行判斷,符合給定邏輯則返回true,否則返回false。
<code>@FunctionalInterfacepublic interface Predicate{ // 判斷輸入的真假,返回boolean boolean test(T t);} /<code>
Function接收一個類型T的輸入,返回一個類型R的輸出。
<code>@FunctionalInterfacepublic interface Function{ // 接收一個類型T的輸入,返回一個類型R的輸出 R apply(T t);} /<code>
一些底層的框架性代碼提供了一些函數式接口供開發者調用,很多框架提供給開發者的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表達式來完成對應的邏輯。/<integer>
<code>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));/<integer>/<string>/<code>
這段代碼中,數據先經過stream方法被轉換為一個Stream類型,後經過filter、map、collect等處理邏輯,生成我們所需的輸出。各個操作之間使用英文點號.來連接,這種方式被稱作方法鏈(Method Chaining)或者鏈式調用。數據的鏈式調用可以被抽象成一個管道(Pipeline),如下圖所示。
我們深挖一下Java Stream的源碼,發現filter的參數正是前文所說的Predicate函數式接口,map的參數是前文提到的Function函數式接口。當處理具體的業務時,就是使用Lambda表達式來實現這些函數式接口。
<code>Streamfilter(Predicate super T> predicate); /<code>Stream map(Function super T, ? extends R> mapper);
上面兩行是Java Stream的源碼,其中?是泛型通配符,主要是為了對泛型做一些安全性上的限制,有興趣的讀者可以自行去了解泛型的的通配符。
Java Stream是應用Lambda表達式的最佳案例,Stream管道和鏈式調用解決了本文最初提到的函數式編程第二個問題:將多個函數連接起來,實現所需業務邏輯。
小結
函數式編程更符合數學上函數映射的思想。具體到編程語言層面,我們可以使用Lambda表達式來快速編寫函數映射,函數之間通過鏈式調用連接到一起,完成所需業務邏輯。Java的Lambda表達式是後來才引入的,而Scala天生就是為函數式編程所設計。由於函數式編程在並行處理方面的優勢,正在被大量應用在大數據計算領域。
閱讀更多 皮皮魯的AI星球 的文章