Java中的七種函數(shù)式編程技巧
環(huán)境:Java21
1. 簡介
函數(shù)式編程是一種編程范式,以函數(shù)為核心,避免改變狀態(tài)與可變數(shù)據(jù),強調(diào)函數(shù)的第一公民地位。它通過使用高階函數(shù)和純函數(shù),實現(xiàn)代碼的模塊化和重用性,提升可讀性和可維護(hù)性,常用于并發(fā)編程和數(shù)學(xué)計算等領(lǐng)域。
在函數(shù)式編程中,有兩條非常重要的規(guī)則:
- 無數(shù)據(jù)變異
這意味著一旦數(shù)據(jù)對象被創(chuàng)建后就不應(yīng)該再被更改。任何對該對象的操作都應(yīng)該返回一個新的對象,而不是修改原始對象。 - 無隱式狀態(tài)
應(yīng)避免隱藏或隱式狀態(tài)。這樣理解:在傳統(tǒng)編程中,一個函數(shù)可能依賴于一些外部或隱藏的狀態(tài),比如全局變量、靜態(tài)變量或者類成員變量等,這些狀態(tài)不是通過參數(shù)傳遞給函數(shù)的。在函數(shù)式編程中,提倡避免這種隱式的依賴關(guān)系,而是將所有需要的狀態(tài)都作為參數(shù)顯式地傳遞給函數(shù)。這樣做的結(jié)果是提高了代碼的透明度和可測試性,因為你清楚地知道函數(shù)依賴哪些輸入來產(chǎn)生輸出,同時也減少了副作用的發(fā)生,即函數(shù)執(zhí)行時除了返回值外不改變其他任何東西。
除了上述內(nèi)容外,還有以下可以在Java中應(yīng)用的函數(shù)式編程概念:
- 高階函數(shù)(Higher-order functions)
- 閉包(Closures)
- 柯里化(Currying)
- 遞歸(Recursion)
- 惰性求值(Lazy evaluations)
- 引用透明性(Referential transparency)
使用函數(shù)式編程并不意味著必須全盤采用,你可以始終使用函數(shù)式編程概念來補充面向?qū)ο蟮母拍?,尤其是在Java中。無論你使用的范式或語言是什么,都可以盡可能地利用函數(shù)式編程的優(yōu)點。
接下來,我們將詳細(xì)介紹函數(shù)式編程在Java中的應(yīng)用
2. 實戰(zhàn)案例
2.1 一等函數(shù)和高階函數(shù)
在一等函數(shù)的上下文中,函數(shù)被視為頭等公民,意味著它們可以被賦值給變量、作為參數(shù)傳遞給其他函數(shù)、從函數(shù)中返回,以及包含在數(shù)據(jù)結(jié)構(gòu)中。遺憾的是,Java并不完全支持這一特性,因此像閉包、柯里化和高階函數(shù)這樣的概念在Java中實現(xiàn)起來不如在其他語言中那么方便。
在Java中最接近一等函數(shù)的概念是Lambda表達(dá)式。此外,在java.util.function包下還有一些內(nèi)置的函數(shù)式接口,如Function、Consumer、Predicate、Supplier等,可以用于函數(shù)式編程。
只有當(dāng)一個函數(shù)接受一個或多個函數(shù)作為參數(shù),或者返回另一個函數(shù)作為結(jié)果時,它才能被視為高階函數(shù)。在Java中,我們最接近高階函數(shù)的方式是使用Lambda表達(dá)式和內(nèi)置的函數(shù)式接口。
public class Test {
public static void main(String[] args) {
var list = Arrays.asList("Orange", "Apple", "Banana", "Grape", "XPack", "AKF");
var ret = calcLength(list, new FnFactory<String, Object>() {
public Object execute(final String it) {
return it.length();
}
});
System.err.printf("Length: %s%n", ret);
}
static <T, S> ArrayList<S> calcLength(List<T> arr, FnFactory<T, S> fn) {
var list = new ArrayList<S>();
arr.forEach(t -> list.add(fn.execute(t)));
return list;
}
@FunctionalInterface
public interface FnFactory<T, S> {
S execute(T it);
}
輸出結(jié)果:
Length: [6, 5, 6, 5, 5, 3]
接下來,我們使用內(nèi)置的Function接口和Lambda表達(dá)式語法來簡化上面的示例:
public class Test1 {
public static void main(String[] args) {
var list = Arrays.asList("Orange", "Apple", "Banana", "Grape", "XPack", "AKF") ;
var ret = calcLength(list, it -> it.length()) ;
System.err.printf("Length: %s%n", ret) ;
}
static <T, S> ArrayList<S> calcLength(List<T> arr, Function<T, S> fn) {
var list = new ArrayList<S>() ;
arr.forEach(t -> list.add(fn.apply(t))) ;
return list ;
}
}
使用這些概念加上Lambda表達(dá)式,我們可以像下面這樣編寫閉包和柯里化。
public class ClosureTest {
Function<Integer, Integer> add(final int x) {
Function<Integer, Integer> add(final int x) {
// 普通寫法
// var partial = new Function<Integer, Integer>() {
// public Integer apply(Integer y) {
// return x + y;
// }
// };
// 使用Lambda表達(dá)式語法;注意這里不能使用var
Function<Integer, Integer> partial = y -> x + y ;
return partial;
}
return partial;
}
public static void main(String[] args) {
ClosureTest closure = new ClosureTest();
var c1 = closure.add(100) ;
var c2 = closure.add(200) ;
System.out.println(c1.apply(66));
System.out.println(c2.apply(66));
}
}
運行結(jié)果
166
266
以上是關(guān)于閉包的應(yīng)用。
Java中也有許多內(nèi)置的高階函數(shù),如java.util.Collections#sort方法:
public static void main(String[] args) {
var list = Arrays.asList("Apple", "Orange", "Banana", "Grape");
Collections.sort(list, (String a, String b) -> {
return a.compareTo(b);
});
System.err.printf("%s%n", list) ;
}
Java Stream相關(guān)API中也提供了許多高階函數(shù),比如forEach、map等。
2.2 純函數(shù)
函數(shù)式編程傾向于使用遞歸而不是循環(huán)。在Java中,這可以通過使用流API或編寫遞歸函數(shù)來實現(xiàn)。讓我們來看一個計算數(shù)字階乘的例子。還使用JMH對這些方法進(jìn)行了基準(zhǔn)測試,并在下方列出了每操作的納秒數(shù)。
在傳統(tǒng)的迭代方法中:
@State(Scope.Thread)
public class FactorialTest {
// 我們要使用JMH進(jìn)行測試,所以通過@Param定義入?yún)? @Param({"20"})
private long num ;
@Benchmark
public long factorial() {
long result = 1;
for (; num > 0; num--) {
result *= num;
}
return result;
}
public static void main(String[] args) throws Exception {
Options options = new OptionsBuilder()
.include(FactorialTest.class.getSimpleName())
.forks(1)
.build() ;
new Runner(options).run() ;
}
}
測試結(jié)果
Benchmark (num) Mode Cnt Score Error Units
FactorialTest.factorial 20 avgt 5 0.475 ± 0.013 ns/op
同樣的功能也可以使用遞歸來實現(xiàn),如下所示,這在函數(shù)式編程中更為青睞。
@State(Scope.Thread)
public class FactorialTest2 {
@Param({ "20" })
private long num;
@Benchmark
public long factorialRec() {
return factorial(num);
}
private long factorial(long n) {
return n == 1 ? 1 : n * factorial(n - 1);
}
public static void main(String[] args) throws Exception {
Options options = new OptionsBuilder()
.include(FactorialTest2.class.getSimpleName())
.forks(1)
.build();
new Runner(options).run();
}
}
測試結(jié)果
Benchmark (num) Mode Cnt Score Error Units
FactorialTest2.factorialRec 20 avgt 5 17.316 ± 0.792 ns/op
遞歸方法的缺點是,它通常會比迭代方法更慢(我們追求的優(yōu)勢在于代碼的簡潔性和可讀性),并且由于每次函數(shù)調(diào)用都需要作為棧幀保存到堆棧中,可能會導(dǎo)致棧溢出錯誤。
我們還可以使用Stream進(jìn)行遞歸調(diào)用
@Param({ "20" })
private long num;
@Benchmark
public long factorialRec() {
return LongStream.rangeClosed(1, num)
.reduce(1, (n1, n2) -> n1 * n2);
}
運行結(jié)果
Benchmark (num) Mode Cnt Score Error Units
FactorialTest2.factorialRec 20 avgt 5 17.618 ± 1.414 ns/op
與遞歸算法差不多。
在編寫Java代碼時,考慮到可讀性和不可變性,可以考慮使用流API或遞歸;但如果性能至關(guān)重要,或者迭代次數(shù)將非常大,則應(yīng)使用標(biāo)準(zhǔn)循環(huán)。
2.3 惰性求值(Lazy evaluations)
惰性求值(Lazy evaluation)或非嚴(yán)格求值是指推遲表達(dá)式的計算,直到其結(jié)果真正被需要時才進(jìn)行計算。一般來說,Java執(zhí)行的是嚴(yán)格求值,但對于像&&、||和?:這樣的運算符,它會進(jìn)行惰性求值。我們可以利用這一點在編寫Java代碼時實現(xiàn)惰性求值。
考慮下面這個例子,在這個例子中Java會急切地(eagerly)計算所有內(nèi)容:
public static void main(String[] args) {
System.out.println(addOrMultiply(true, add(4), multiply(4))); // 8
System.out.println(addOrMultiply(false, add(4), multiply(4))); // 16
}
public static int add(int x) {
System.out.println("executing add");
return x + x;
}
public static int multiply(int x) {
System.out.println("executing multiply");
return x * x;
}
public static int addOrMultiply(boolean add, int onAdd, int onMultiply) {
return (add) ? onAdd : onMultiply;
}
執(zhí)行結(jié)果
executing add
executing multiply
8
executing add
executing multiply
16
函數(shù)一早就被執(zhí)行了。
我們可以使用Lambda表達(dá)式和高階函數(shù)將此重寫為惰性求值的版本:
public static void main(String[] args) {
UnaryOperator<Integer> add = t -> {
System.out.println("executing add");
return t + t;
};
UnaryOperator<Integer> multiply = t -> {
System.out.println("executing multiply");
return t * t;
};
System.out.println(addOrMultiply(true, add, multiply, 4));
System.out.println(addOrMultiply(false, add, multiply, 4));
}
public static <T, R> R addOrMultiply(
boolean add, Function<T, R> onAdd,
Function<T, R> onMultiply, T t) {
return (add ? onAdd.apply(t) : onMultiply.apply(t));
}
執(zhí)行結(jié)果
executing add
8
executing multiply
16
我們可以看到只執(zhí)行了所需的功能。
2.4 引用透明性(Referential transparency)
表示在程序中,一個函數(shù)調(diào)用可以用它的返回值來替換,而不改變程序的行為。換句話說,對于相同的輸入,函數(shù)總是產(chǎn)生相同的結(jié)果,沒有副作用。
遺憾的是,在Java中限制數(shù)據(jù)變異的方法并不多。然而,通過使用純函數(shù),并明確避免數(shù)據(jù)變異和重新賦值(使用我們之前討論過的其他概念),可以實現(xiàn)這一目標(biāo)。對于變量,我們可以使用final關(guān)鍵字,它是一個非訪問修飾符,用于防止通過重新賦值來改變變量的值。
例如,下面的代碼將在編譯時產(chǎn)生錯誤:
final var list = Arrays.asList("Apple", "Orange") ;
// 你不能重新賦值
list = Arrays.asList("Pack", "XXXOOO") ;
但是,當(dāng)變量持有對其他對象的引用時,這并不會起到作用。例如,即使使用了final
關(guān)鍵字,下面的對象變異仍然會發(fā)生:
final var list = new ArrayList<>() ;
// 我們還是可以添加數(shù)據(jù)
list.add("XXX") ;
list.add("OOO") ;
final 關(guān)鍵字允許引用變量的內(nèi)部狀態(tài)被修改,因此從函數(shù)式編程的角度來看,final 關(guān)鍵字僅對常量和捕獲重新賦值有用。