Java函數式編程
- java
- functional
1.簡介
在本教程中,我們將了解函數式編程範式的核心原理以及如何在Java編程語言中實踐它們。我們還將介紹一些高級功能編程技術。
這也將使我們能夠評估從函數式編程(尤其是Java)中獲得的收益。
2. 什麼是函數式編程
基本上,函數式編程是一種編寫計算機程序的樣式,該程序將計算視為評估數學函數。那麼,數學中的函數是什麼?
函數是將輸入集與輸出集相關聯的表達式。
重要的是,函數的輸出僅取決於其輸入。更有趣的是,我們可以將兩個或多個函數組合在一起以獲得一個新函數。
2.1 Lambda微積分
要了解為什麼這些數學函數的定義和屬性在編程中很重要,我們必須倒退一些時間。在1930年代,數學家Alonzo Chruch開發了一個正式的系統來表達基於函數抽象的計算。這種通用的計算模型被稱為Lambda微積分。
Lambda演算對開發編程語言(特別是函數式編程語言)的理論產生了巨大影響。通常,函數式編程語言實現lambda演算。
由於lambda演算著重於功能組合,因此功能性編程語言提供了表達功能的方法, 來構成功能組合中的軟件。
2.2 函數式編程分類
當然,函數式編程並不是實踐中唯一的編程風格。廣義上講,編程風格可以分為命令式和聲明式編程範式:
命令式方法將程序定義為一系列語句,這些語句會更改程序的狀態,直到達到最終狀態為止。過程式編程是一種命令式編程,其中我們使用過程或子例程來構造程序。流行的編程範例之一就是面向對象編程(OOP),它擴展了過程編程的概念。
相反,聲明性方法表達了計算的邏輯,而沒有按照語句序列描述其控制流程。簡而言之,聲明式方法的重點是定義程序必須達到的目標,而不是應該如何實現。函數式編程是聲明性編程語言的子集。
這些類別還有其他子類別,並且分類法變得相當複雜,但是在本教程中我們將不再贅述。
2.3 編程語言的分類
今天,對編程語言進行正式分類的任何嘗試本身就是一項學術工作!但是,我們將嘗試根據對函數式編程的支持來了解如何對編程語言進行劃分。
像Haskell這樣的純函數式語言僅允許純函數式程序。
但是,其他語言既允許功能程序也允許程序程序,因此被認為是不純功能語言。許多語言都屬於這一類,包括Scala,Kotlin和Java。
重要的是要理解,當今大多數流行的編程語言都是通用語言,因此它們傾向於支持多種編程範例。
3. 基本原理和概念
本節將介紹一些函數式編程的基本原理以及如何在Java中採用它們。請注意,我們將要使用的許多功能並不總是Java的一部分,建議使用Java 8或更高版本來有效地執行函數式編程。
3.1 一等和高階函數
如果編程語言將函數視為一等公民,則稱該語言具有一等函數。基本上,這意味著允許功能支持其他實體通常可用的所有操作。這些包括將函數分配給變量,將它們作為參數傳遞給其他函數,以及將它們作為其他函數的值返回。
該屬性使得可以在函數式編程中定義高階函數。高階函數能夠將函數作為參數接收並作為結果返回函數。這進一步啟用了功能編程中的幾種技術,例如函數組合和函數柯里化(Currying)。
傳統上,只能使用功能接口或匿名內部類之類的構造在Java中傳遞函數。功能接口只有一種抽象方法,也稱為單一抽象方法(SAM)接口。
Collections.sort
方法提供一個自定義比較器:
Collections.sort(numbers, new Comparator<Integer>() {
@Override
public int compare(Integer n1, Integer n2) {
return n1.compareTo(n2);
}
});
正如我們所看到的,這是一種繁瑣而冗長的技術-肯定不是鼓勵開發人員採用函數式編程的技術。幸運的是,Java 8帶來了許多新功能來簡化該過程,例如lambda表達式,方法引用和預定義的功能接口。
讓我們看看lambda表達式如何幫助我們完成同一任務:
Collections.sort(numbers, (n1, n2) -> n1.compareTo(n2));
無疑,這更加簡潔和可理解。但是,請注意,儘管這可能給我們留下在Java中使用函數作為一等公民的印象,但事實並非如此。
在lambda表達式的語法糖的背後,Java仍然將它們包裝到功能接口中。因此, Java將lambda表達式視為Object
,實際上,它是Java中真正的一等公民。
3.2 純函數
純函數的定義強調純函數應僅基於參數返回值,並且沒有副作用。現在,這聽起來很違反Java中的所有最佳實踐。
Java是一種面向對象的語言,建議將封裝作為一種核心編程實踐。它鼓勵隱藏對象的內部狀態,並僅公開訪問和修改對象的必要方法。因此,這些方法並不是嚴格意義上的純函數。
當然,封裝和其他面向對象的原則僅是建議,在Java中不是綁定。實際上,開發人員最近已經開始意識到定義不可變狀態和方法而沒有副作用的價值。
假設我們要查找剛剛排序的所有數字的總和:
Integer sum(List<Integer> numbers) {
return numbers.stream().collect(Collectors.summingInt(Integer::intValue));
}
現在,此方法僅取決於其接收到的參數,因此,它是確定性的。而且,它不會產生任何副作用。
副作用可以是除方法預期行為以外的任何東西。例如,副作用可以很簡單,例如在返回值之前更新本地或全局狀態或保存到數據庫。純粹主義者也將伐木視為副作用,但是我們都有自己的界限要設置!
但是,對於我們如何處理合法的副作用,我們可能會有所理由。例如,出於真正的原因,我們可能需要將結果保存在數據庫中。嗯,函數式編程中有一些技術可以在保留純函數的同時處理副作用。
我們將在後面的部分中討論其中的一些。
3.3 不變性
不變性是函數式編程的核心原則之一,它是指實體在實例化後無法修改的屬性。現在,在功能性編程語言中,語言級別的設計對此提供了支持。但是,在Java中,我們必須自行決定創建不可變的數據結構。
請注意, Java本身提供了幾種內置的不可變類型,例如String
。這主要是出於安全原因,因為我們String
並將其作為基於哈希的數據結構中的鍵。還有其他一些內置的不可變類型,例如原始包裝器和數學類型。
但是我們用Java創建的數據結構又如何呢?當然,默認情況下它們不是不可變的,我們必須進行一些更改以實現不可變性。使用final
關鍵字是其中之一,但並不僅限於此:
public class ImmutableData {
private final String someData;
private final AnotherImmutableData anotherImmutableData;
public ImmutableData(final String someData, final AnotherImmutableData anotherImmutableData) {
this.someData = someData;
this.anotherImmutableData = anotherImmutableData;
}
public String getSomeData() {
return someData;
}
public AnotherImmutableData getAnotherImmutableData() {
return anotherImmutableData;
}
}
public class AnotherImmutableData {
private final Integer someOtherData;
public AnotherImmutableData(final Integer someData) {
this.someOtherData = someData;
}
public Integer getSomeOtherData() {
return someOtherData;
}
}
請注意,我們必須認真遵守一些規則:
- 不變數據結構的所有字段都必須是不變的
- 這也必須適用於所有嵌套的類型和集合(包括它們所包含的內容)
- 根據需要應該有一個或多個構造函數用於初始化
- 應該只有訪問器方法,可能沒有副作用
每次都很難完全正確地做到這一點,尤其是當數據結構開始變得複雜時。但是,幾個外部庫可以使在Java中處理不可變數據更加容易。例如,Immutables和Project Lombok提供了現成的框架,用於在Java中定義不可變數據結構。
3.4 引用透明性
引用透明性可能是函數式編程更難理解的原理之一。但是,這個概念非常簡單。如果將表達式替換為其對應的值對程序的行為沒有影響,則我們將其稱為參照透明的。
這使函數編程中可以使用一些強大的技術,例如高階函數和惰性求值。為了更好地理解這一點,讓我們舉個例子:
public class SimpleData {
private Logger logger = Logger.getGlobal();
private String data;
public String getData() {
logger.log(Level.INFO, "Get data called for SimpleData");
return data;
}
public SimpleData setData(String data) {
logger.log(Level.INFO, "Set data called for SimpleData");
this.data = data;
return this;
}
}
這是Java中典型的POJO類,但我們有興趣了解它是否提供參照透明性。讓我們觀察以下語句:
String data = new SimpleData().setData("Baeldung").getData();
logger.log(Level.INFO, new SimpleData().setData("Baeldung").getData());
logger.log(Level.INFO, data);
logger.log(Level.INFO, "Baeldung");
logger
的三個調用在語義上是等效的,但在引用上不是透明的。第一次調用不是參照透明的,因為它會產生副作用。如果我們用第三個調用中的值替換該調用,則會丟失日誌。
SimpleData
是可變的,因此第二個調用也不是參照透明的。在程序中任何地方調用data.setData
都將使其很難被其值替換。
因此,基本上,對於引用透明性,我們需要我們的函數是純淨的且不可變的。這是我們前面已經討論過的兩個先決條件。作為引用透明性的有趣結果,我們生成了無上下文代碼。換句話說,我們可以按任何順序和上下文執行它們,從而導致不同的優化可能性。
4. 函數式編程技術
前面討論的函數式編程原理使我們能夠使用多種技術來受益於函數式編程。在本節中,我們將介紹其中一些流行的技術,並了解如何在Java中實現它們。
4.1 功能組成
函數組成是指通過組合簡單函數來組合複雜函數。這主要是在Java中使用功能接口實現的,實際上,這些功能接口是lambda表達式和方法引用的目標類型。
通常,具有單個抽象方法的任何接口都可以用作功能接口。因此,我們可以很容易地定義一個功能接口。 java.util.function
包下為我們提供了許多針對不同用例的功能接口。
這些功能接口中的許多功能都以default
方法和static
讓我們選擇“ Function
界面以更好地理解這一點。 Function
是一個簡單且通用的函數接口,它接受一個參數並產生結果。
它還提供了兩個默認方法compose
和andThen
,這將有助於我們進行函數組合:
Function<Double, Double> log = (value) -> Math.log(value);
Function<Double, Double> sqrt = (value) -> Math.sqrt(value);
Function<Double, Double> logThenSqrt = sqrt.compose(log);
logger.log(Level.INFO, String.valueOf(logThenSqrt.apply(3.14)));
// Output: 1.06
Function<Double, Double> sqrtThenLog = sqrt.andThen(log);
logger.log(Level.INFO, String.valueOf(sqrtThenLog.apply(3.14)));
// Output: 0.57
這兩種方法都允許我們將多個功能組合為一個功能,但提供不同的語義。雖然compose
應用在參數中傳遞的函數,然後再應用對其調用的函數,然後andThen
執行相同的操作。
幾個其他功能接口具有令人感興趣的方法在功能上組合物中使用,如在默認的方法and, or
,和negate
在Predicate
接口。儘管這些功能接口接受單個參數,但是有兩個領域的特殊化,例如BiFunction
和BiPredicate
。
4.2 Monads
許多函數式程序設計概念都源於範疇論,範疇論是數學中一般的函數理論。它提出了一些類別的概念,例如函子和自然變換。對於我們來說,唯一重要的是要知道這是在函數式編程中使用monad的基礎。
從形式上講,monad是一種抽象,它允許按一般方式構造程序。因此,從根本上講,monad允許我們包裝一個值,應用一組轉換並在應用了所有轉換的情況下取回值。當然,任何monad都需要遵循以下三個定律-左身份,右身份和關聯性-但我們不贅述。
在Java中,我們經常使用一些monad,例如Optional
和Stream
:
Optional.of(2).flatMap(f -> Optional.of(3).flatMap(s -> Optional.of(f + s)))
現在,為什麼我們將Optional
稱為monad?在這裡, Optional
允許我們使用該方法來包裝一個值of
並應用一系列的變換。 flatMap
方法添加另一個包裝值的轉換。
如果需要,我們可以證明Optional
遵循單子的三個定律。但是,批評家會很快指出, Optional
議定書》的確違反了單子法。但是,對於大多數實際情況,這對我們來說應該足夠了。
如果了解monad的基礎知識,我們很快就會意識到Java中還有許多其他示例,例如Stream
和CompletableFuture
。它們可以幫助我們實現不同的目標,但是它們都具有處理上下文操縱或轉換的標準組合。
當然,我們可以在Java中定義自己的monad類型,以實現不同的目標,例如log monad,report monad或audit monad。還記得我們在函數式編程中討論過如何處理副作用嗎?好吧,看起來,monad是實現該功能的一種功能編程技術。
4.3 函數Currying
函數Currying是一種數學技術,可將帶有多個參數的函數轉換成帶有單個參數的函數序列。但是,為什麼在函數式編程中需要它們?它為我們提供了一種強大的組合技術,無需調用所有參數的函數。
而且,curried函數在接收所有參數之前不會實現其效果。
在純函數式編程語言(例如Haskell)中,很好地支持currying。實際上,默認情況下所有函數都是咖哩的。但是,在Java中並不是那麼簡單:
Function<Double, Function<Double, Double>> weight = mass -> gravity -> mass * gravity;
Function<Double, Double> weightOnEarth = weight.apply(9.81);
logger.log(Level.INFO, "My weight on Earth: " + weightOnEarth.apply(60.0));
Function<Double, Double> weightOnMars = weight.apply(3.75);
logger.log(Level.INFO, "My weight on Mars: " + weightOnMars.apply(60.0));
在這裡,我們定義了一個函數來計算我們在行星上的重量。雖然我們的質量保持不變,但重力因我們所處的星球而異。我們可以通過僅傳遞重力來為特定行星定義函數來部分應用該函數。而且,我們可以將此部分應用的函數作為參數或返回值傳遞給任意組合。
咖哩依賴於語言來提供兩個基本特徵:lambda表達式和閉包。 Lambda表達式是匿名函數,可幫助我們將代碼視為數據。前面我們已經看到瞭如何使用功能接口來實現它們。
現在,lambda表達式可能會在其詞法範圍(我們將其定義為閉包)上關閉。讓我們來看一個例子:
private static Function<Double, Double> weightOnEarth() {
final double gravity = 9.81;
return mass -> mass * gravity;
}
請注意,我們在上述方法中返回的lambda表達式如何取決於封閉變量(我們稱為閉包)。與其他功能編程語言不同, Java的局限性在於封閉範圍必須是final或有效的final 。
作為一個有趣的結果,currying還允許我們在Java中創建任意接口的功能接口。
4.4 遞歸
遞歸是函數式編程中的另一項強大技術,它使我們可以將問題分解為更小的部分。遞歸的主要好處是它可以幫助我們消除副作用,這是任何命令式樣式循環所特有的。
讓我們看看如何使用遞歸來計算數字的階乘:
Integer factorial(Integer number) {
return (number == 1) ? 1 : number * factorial(number - 1);
}
在這裡,我們遞歸調用相同的函數,直到達到基本情況,然後開始計算結果。注意,我們在進行計算之前要進行遞歸調用,即在每一步或以單詞開頭計算結果。因此,這種遞歸樣式也稱為head遞歸。
這種遞歸的缺點是,每個步驟都必須保持所有先前步驟的狀態,直到我們到達基本情況為止。對於小數而言,這並不是真正的問題,但是對於大數保持狀態可能是低效的。
解決方案是稱為尾遞歸的遞歸的實現稍有不同。在這裡,我們確保遞歸調用是函數進行的最後一次調用。讓我們看看如何重寫上面的函數以使用尾部遞歸:
Integer factorial(Integer number, Integer result) {
return (number == 1) ? result : factorial(number - 1, result * number);
}
請注意,在函數中使用了累加器,從而無需在遞歸的每個步驟都保持狀態。這種樣式的真正好處是利用編譯器優化,編譯器可以決定放棄當前函數的堆棧框架,這是一種稱為尾調用消除的技術。
儘管許多語言(例如Scala)都支持尾部消除,但是Java仍然不支持這種方式。這是Java積壓工作的一部分,並且可能會作為Project Loom下提出的較大更改的一部分而出現。
5.為什麼函數式編程很重要?
在學習完本教程之後,我們必須想知道為什麼我們還要付出這麼大的努力。對於那些來自Java背景的人來說,函數式編程所要求的轉變並非微不足道。因此,在Java中採用函數式編程應該有一些真正有希望的優勢。
採用任何語言(包括Java)進行函數式編程的最大優勢是純函數和不可變狀態。如果我們回想起來,大多數編程挑戰都源於副作用和易變狀態。僅僅擺脫它們就可以使我們的程序更易於閱讀,推理,測試和維護。
這樣,聲明性編程會導致非常簡潔易讀的程序。函數式編程是聲明式編程的子集,它提供了多種構造,例如高階函數,函數組成和函數鏈。考慮一下Stream API為處理數據操作而帶入Java 8的好處。
但是,除非完全準備好,否則不要試圖切換。請注意,函數式編程不是我們可以立即使用並從中受益的簡單設計模式。函數式編程更多地改變了我們對問題及其解決方案的推理方式以及如何構造算法。
因此,在開始使用函數式編程之前,我們必須訓練自己以函數的方式考慮我們的程序。
6. Java是否合適?
儘管很難否認函數式編程的好處,但我們不得不自問Java是否適合它。從歷史上看, Java演變為一種通用編程語言,更適合於面向對象的編程。甚至想到在Java 8之前使用函數式編程都是很乏味的!但是Java 8之後,情況肯定發生了變化。
Java中沒有真正的函數類型這一事實違背了函數編程的基本原理。偽裝為lambda表達式的功能接口在很大程度上(至少在語法上)彌補了這一不足。然後, Java中的類型本質上是可變的,而我們不得不寫很多樣板來創建不可變的類型這一事實無濟於事。
我們希望功能編程語言提供Java缺少或難以實現的其他功能。例如, Java中參數的默認評估策略是eager 。但是,在函數式編程中,惰性評估是一種更有效和推薦的方法。
我們仍然可以使用運算符短路和功能接口在Java中實現惰性評估,但是它涉及更多。
該列表肯定是不完整的,可能包括帶有類型擦除的泛型支持,缺少對尾部調用優化的支持等。但是,我們有一個廣泛的想法。 Java絕對不適合在函數式編程中從頭開始編寫程序。
但是,如果我們已經有一個用Java編寫的現有程序,可能是面向對象的程序該怎麼辦?沒有什麼能阻止我們獲得函數式編程的某些好處,尤其是在Java 8中。
對於Java開發人員來說,函數式編程的大部分好處就在於此。將面向對象的程序設計與功能性程序設計的好處相結合可以走很長的路要走。
7.結論
在本教程中,我們介紹了函數式編程的基礎知識。我們介紹了基本原理以及如何在Java中採用它們。此外,我們使用Java中的示例討論了函數式編程中的一些流行技術。
最後,我們介紹了採用函數式編程的一些好處,並回答了Java是否適合使用函數式編程。