Java中的函數語言程式設計
前言
JDK8引入的Lambda表示式和Stream為Java平臺提供了函數語言程式設計的支援,極大地提高了開發效率.本文結合網路資源和自身使用經驗,介紹下Java中的函數語言程式設計
Java中的函數語言程式設計
出現的原因
語言面臨著要麼改變,要麼衰亡的壓力. Java是傳統的指令式程式設計,而函數語言程式設計 .是一種更"高階"的程式設計正規化,Java為了支援它,推出了Lambda表示式和Stream.
函數語言程式設計VS 指令式程式設計
一言以蔽之:
函數語言程式設計是:
"我現在想要這樣東西(怎麼辦到我不管,你來處理)"
指令式程式設計是:
"你要先...,再...,最後...,就能拿到這樣東西了"
事實上,函數語言程式設計的底層實現還是指令式程式設計,就像面嚮物件語言核心部分(如JVM)是由面向過程語言(如C)實現的.畢竟髒活累活總是要有人去做的.
舉個栗子
以一個比較蘋果重量的Comparator為例,類Apple定義如下
public class Apple{ private int weight; private int type; public int getWeight(){ return this.weight; } public int getType(){ return this.type; } }
如果按照匿名類實現,程式碼會是這樣,總體來說比較繁瑣.
Comparator<Apple> byWeight=new Comparator<>(){ @Override public int compareTo(Apple a1,Apple a2) { return a1.getWeight().compareTo(a2.getWeight()); } }
如果使用Lambda表示式,最繁瑣的形式會是這樣
Comparator<Apple> byWeight= (Apple a1,Apple a2) -> {return a1.getWeight().compareTo(a2.getWeight());}
要搞清楚Lambda表示式的工作原理,首先要了解它的語法以及函式式介面
Lambda表示式 VS 方法
Lambda的語法結構如下
// 引數列表 箭頭 方法體 ( ParameterType1 param1,ParameterType2 param2... ) -> { ... }
方法的語法結構如下(暫不考慮throws)
訪問許可權 ReturnType methodName(ParameterType1 param1,ParameterType2 param2...){ ... }
可以看出,Lambda表示式可以看做方法的簡化形式: 沒有訪問許可權,返回型別以及方法名.並且它還可以進一步簡化.
函式式介面
有且只有一個抽象方法的介面
首先澄清一下這裡抽象方法的定義(java doc )
- 介面中的default方法不是抽象方法,因為它有預設實現
- 如果介面中的方法覆蓋了java.lang.Object中的方法,也不做計數
下面以Comparator為例(適當精簡)
@FunctionalInterface public interface Comparator<T> { int compare(T o1, T o2);//1 boolean equals(Object obj);//2 default Comparator<T> reversed() {//3 return Collections.reverseOrder(this); } }
@FunctionalInterface用來標識一個介面是函式式介面,它和@Override註解類似,只是編譯時起檢查作用,如果這個介面定義不符合的話,編譯時就會報錯,如果一個介面符合函式式介面的定義,即使沒有這個註解依然是有效的
再來看下Comparator中有幾個抽象方法
- 是抽象方法
- 覆蓋了Object.equal()方法,所以不是
- 是default方法,也不是抽象方法
只有一個抽象方法,因此Comparator介面是一個函式式介面.
說了這麼多,函式式介面到底有什麼作用呢?
Lambda表示式允許你直接以內聯的形式為函式式介面的抽象方法提供實現,並把整個表示式作為函式式介面的例項
當我們把一個Lambda表示式賦給一個函式式介面時,這個表示式對應的必定是介面中唯一的抽象方法,因此就不需要以匿名類那麼繁瑣的形式去實現這個介面.可以說在語法簡化上,Lambda表示式完成了方法層面的簡化,函式式介面完成了類層面的簡化.
Lambda表示式的進一步簡化
在Lambda中,除了引數列表的大括號()
和箭頭→
不能省略,其他部分如果編譯器可以自動推斷,都能省略.
簡化規則1: 如果編譯器可以推斷出引數型別,引數列表中就可以省略引數型別
Comparator<Apple> byWeight= (a1,a2) -> {return a1.getWeight().compareTo(a2.getWeight());}
簡化規則2: 如果方法體只有一條語句,花括號{} 和return (如果有的話)都可以省略
Comparator<Apple> byWeight= (a1,a2) ->a1.getWeight().compareTo(a2.getWeight())
簡化規則3: 可以通過方法引用
來呼叫方法
首先要介紹一個新概念:方法引用,它的基本思想是:如果一個Lambda代表的只是直接呼叫這個方法,那最好還是用名稱來呼叫它,而不是去描述如何呼叫它.這樣可讀性更好.
方法引用的一般形式如下
//可以表示對靜態/例項方法的呼叫 類名::方法名 //只能表示例項方法 this::方法名
針對上面的例子,首先利用JDK提供的工具做一些簡化
Comparator<Apple> byWeight= Comparator.comparingInt((a)->a.getWeight())
然後利用方法引用可以簡化為如下形式,是不是簡單明瞭?
Comparator<Apple> byWeight= Comparator.comparingInt(Apple::getWeight)
然而Lambda並不是萬金油,它也有自己的限制.
Lambda的區域性變數限制
Lambda引用區域性變數時,要求區域性變數時final或effective final(即僅被賦值一次,之後不被修改).例項變數則可以隨意使用.這個限制有如下幾個原因
-
堆和棧的差異
區域性變數是儲存在棧上的,即區域性變數是執行緒私有的,而Lambda表示式不是執行緒私有的,它可能在其他執行緒上執行,而其他執行緒上是沒有對應的區域性變數的(例項變數是在堆上分配的,任何執行緒都能訪問到),為了解決這個問題,Java會將區域性變數的拷貝一份儲存到在Lambda表示式中.因此Java在訪問區域性變數時,實際是在訪問它的副本,而不是訪問原始變數. 如果區域性變數不是effective final的(比如在Lambda表示式之後對原始變數進行了修改),拷貝就可能和原始變數不一致,會引發很多語義上的問題(匿名內部類中區域性變數也是相同原因)
-
避免函數語言程式設計的不正確使用
區域性變數必須是effective final恰好符合函數語言程式設計的特徵之一—immutable data資料不可變 .資料不可變便沒有了資料競爭問題,這樣最有利於並行
假設非effective final區域性變數是被允許的,那麼下面這句程式碼實際上是序列執行的,因為每個任務都在競爭sum這個變數
int sum=0; //parallelStream()會以多執行緒形式執行任務 ints.parallelStream().forEach(i->sum+=i);
以函數語言程式設計的思想來寫,應該是這樣,沒有資料競爭問題,能夠充分利用並行.
int sum=ints.parallelStream().reduce(0, (e1, e2) -> e1 + e2);
-
併發問題
引用JLS的說明 ,什麼情況下會導致併發問題筆者還沒搞清楚.
The restriction to effectively final variables prohibits access to dynamically-changing local variables, whose capture would likely introduce concurrency problems.
Lambda表示式的匹配
鴨子型別: “當看到一隻鳥走起來像鴨子、游泳起來像鴨子、叫起來也像鴨子,那麼這隻鳥就可以被稱為鴨子。”
還是以Comparator為例,首先看下Comparator.compare方法簽名
int compare(T o1, T o2);
而上面我們提供的Lambda表示式正好符合這個形式: 兩個同類型引數,返回int值
(Apple a1,Apple a2) -> {return a1.getWeight().compareTo(a2.getWeight());}
在這裡 Lambda表示式就代表"鴨子"這個型別,而Comparator的行為完全符合鴨子的特徵("走起來像鴨子,游泳起來像鴨子,叫起來也想鴨子"),就可以認為它"是"一隻"鴨子"
假設現在我們有如下介面
public interface SomeClass<T>{ int someMethod(T a1,T a2); }
那麼上面這個Lambda同樣適用於這個方法,因為它也符合鴨子的特徵
SomeClass<Apple> someMethod= (Apple a1,Apple a2) -> {return ...;}
Lambda表示式的匹配規則相當的寬鬆簡單,這也讓它的使用更加方便.那麼如何有效的利用它呢?
Stream
Java中的Stream是對函數語言程式設計中pipeline
的實現,日常業務開發中用的特別特別多
,很值得學習.
又一個例子
需求: 有一堆蘋果List<Apple> apples,以重量從小到大,獲取他們的品種.
以指令式程式設計來做會是:
Comparator<Apple> byWeight=new Comparator<>(){ @Override public int compareTo(Apple a1,Apple a2) { return a1.getWeight().compareTo(a2.getWeight()); } } apples.sort(byWeight); List<Integer> types=new ArrayList(); for(Apple apple:apples){ types.add(apple.getType()); }
有了Stream,會是這樣,語義清晰了很多,個人非常喜歡這種鏈式呼叫(鏈式呼叫一時爽,一直鏈式一直爽)再次展示出指令式程式設計和函數語言程式設計的不同
List<Integer> types=apples.stream() .sorted(Comparator.comparingInt(Apple::getWeight)) .map(Apple::getType) .collect(Collectors.toList());
在日常開發中,將一個列表進行排序過濾轉化最後收集這個套路十分常見,這個過程中變化的只是我們傳遞過去的Lambda表示式
,這也被稱為行為引數化
-
行為引數化
一個方法接受多個不同的行為作為引數,並在內部使用它們,完成不同行為.
-
何為行為
實際一點來說,獲取蘋果的型別(這個方法)就是一個行為,在程式碼中就是就是map(Apple::getType)中的Apple::getType ,假設Apple增加了一個屬性尺寸Size ,獲取蘋果的尺寸這個新的行為就是Apple::getSize.
-
引數化
Apple::getType這個行為 是作為一個引數傳遞給map()的,這就是引數化
-
parallelStream—並行化任務的最簡單方式
假設現在有一個包含100w元素的List,要對它進行一系列操作,元素很多,會消耗很多時間.
elements.stream().filter(...).map(...).collect(...);
很明顯,多執行緒執行能夠加速執行,只需要一點點修改就能使它以多執行緒模式執行,wonderful!
elements.parallelStream().filter(...).map(...).collect(...);
parallelStream的底層是fork/join框架.可以把它理解一個智慧的執行緒池,它能將任務拆分並分發給不同的執行緒執行,最終彙總.然而 parallelStream並不是銀彈,以下幾點需要注意
- parallelStream()的後續操作中進行排序(呼叫sorted())得出的結果是無效的.原因很簡單,它是多執行緒執行的.這個問題的解決辦法就是在parallelStream結束後再進行排序.
- 執行的任務不能依賴於執行緒私有資料(比如ThreadLocal),由於是多執行緒執行,其他執行緒並沒有當前執行緒棧上的資料,一個最常見的例子就是在spring中執行資料庫操作,session是繫結線上程上的,這時候以parallelStream執行就會報錯 can't obtain session
- 任務數量必須足夠多/單個任務耗時很長(io/網路操作)才有必要使用parallelStream,不然執行反而會更慢. 因為要fork/join也是要付出很大代價的: 劃分子任務,分配任務給執行緒.具體的計算規則可以參考這篇文章
更好的使用Lambda
預定義的函式式介面
java.util.function下有很多JDK預定義的函式式介面.以常用的為例
使用JDK中新增的Lambda相關方法
JDK8的底層機制也添加了很多和函數語言程式設計相關的改進,作為開發者,如何更好的享受這免費的午餐呢?
又又又是一個例子:對Map<String,Integer> map中的所有值進行+1操作 ,在JDK8中,最好的做法如下
map.replaceAll((key,oldVal)->oldVal+1);
首先看replaceAll方法的簽名,replaceAll接收一個BiFunction作為引數,很明顯 這個BiFunction就是我們要傳遞的行為
/** * Replaces each entry's value with the result of invoking the given * function on that entry until all entries have been processed or the * function throws an exception.Exceptions thrown by the function are * relayed to the caller. * **/ default void replaceAll(BiFunction<? super K, ? super V, ? extends V> function){ ... }
再來看BiFunction的定義,它是一個函式式介面,apply方法接收兩個引數,有返回值
@FunctionalInterface public interface BiFunction<T, U, R> { /** * Applies this function to the given arguments. * * @param t the first function argument * @param u the second function argument * @return the function result */ R apply(T t, U u); }
再來看我們提供的Lambda表示式,符合Map.replaceAll中對BiFunction.apply的方法簽名要求.完美!!!
(key,oldVal)->oldVal+1
使用JDK8新增的Lambda相關方法,可以大概遵循下面這個步驟
- 查詢符合需求的api,如replaceAll
- 檢視該方法要求的行為(引數)的定義,如BiFucntion.apply()
- 編寫符合方法簽名的Lambda表示式
用Lambda改造設計模式
又是一個例子,使用Lambda來實現模板方法模式,需求如下
根據不同行為對Apple進行不同的處理
public void templateMethod(Supplier<Apple> supplier,Consumer<Apple> consumer){ ... Apple apple=supplier.get(); ... consumer.accept(apple); ... } //eg. Supplier normalSupplier=()->new Apple(10,1); Consumer<Apple> weightConsumer=(a)->System.out.println(a.getWeight()); templateMethod(normalSupplier,weightConsumer); //eg. Supplier bigSupplier=()->new Apple(100,2); Consumer<Apple> typeConsumer=(a)->System.out.println(a.getType()); templateMethod(bigSupplier,typeConsumer);
上面這個例子就是行為引數化
的直觀體現,相比傳統的模板方法設計模式,免去了抽象出類的麻煩,更加易用.
總結
Lambda表示式和Stream使Java用起來不再那麼繁瑣,即使不探究其底層原理,也能用的很舒服.但是隻有真正的理解函數語言程式設計的思想,才能真正發揮Lambda表示式的威力.