JDK 8 函數式編程入門


1. 概述

1.1 函數式編程簡介

我們最常用的面向對象編程(Java)屬於命令式編程(Imperative Programming)這種編程范式。常見的編程范式還有邏輯式編程(Logic Programming),函數式編程(Functional Programming)。

函數式編程作為一種編程范式,在科學領域,是一種編寫計算機程序數據結構和元素的方式,它把計算過程當做是數學函數的求值,而避免更改狀態和可變數據。

函數式編程並非近幾年的新技術或新思維,距離它誕生已有大概50多年的時間了。它一直不是主流的編程思維,但在眾多的所謂頂級編程高手的科學工作者間,函數式編程是十分盛行的。

什么是函數式編程?簡單的回答:一切都是數學函數。函數式編程語言里也可以有對象,但通常這些對象都是恆定不變的 —— 要么是函數參數,要什么是函數返回值。函數式編程語言里沒有 for/next 循環,因為這些邏輯意味着有狀態的改變。相替代的是,這種循環邏輯在函數式編程語言里是通過遞歸、把函數當成參數傳遞的方式實現的。

舉個例子:

a = a + 1

這段代碼在普通成員看來並沒有什么問題,但在數學家看來確實不成立的,因為它意味着變量值得改變。

1.2 Lambda 表達式簡介

Java 8的最大變化是引入了Lambda(Lambda 是希臘字母 λ 的英文名稱)表達式——一種緊湊的、傳遞行為的方式。

先看個例子:

button.addActionListener(new ActionListener() {
	public void actionPerformed(ActionEvent event) {
		System.out.println("button clicked");
	}
});

這段代碼使用了匿名類。ActionListener 是一個接口,這里 new 了一個類實現了 ActionListener 接口,然后重寫了 actionPerformed 方法。actionPerformed 方法接收 ActionEvent 類型參數,返回空。

這段代碼我們其實只關心中間打印的語句,其他都是多余的。所以使用 Lambda 表達式,我們就可以簡寫為:

button.addActionListener(event -> System.out.println("button clicked"));

2. Lambda 表達式

2.1 Lambda 表達式的形式

Java 中 Lambda 表達式一共有五種基本形式,具體如下:

Runnable noArguments = () -> System.out.println("Hello World");

ActionListener oneArgument = event -> System.out.println("button clicked");

Runnable multiStatement = () -> {
	System.out.print("Hello");
	System.out.println(" World");
};

BinaryOperator<Long> add = (x, y) -> x + y;

BinaryOperator<Long> addExplicit = (Long x, Long y) -> x + y;

➊中所示的 Lambda 表達式不包含參數,使用空括號 () 表示沒有參數。該 Lambda 表達式 實現了 Runnable 接口,該接口也只有一個 run 方法,沒有參數,且返回類型為 void。➋中所示的 Lambda 表達式包含且只包含一個參數,可省略參數的括號,這和例 2-2 中的 形式一樣。Lambda 表達式的主體不僅可以是一個表達式,而且也可以是一段代碼塊,使用大括號 ({})將代碼塊括起來,如➌所示。該代碼塊和普通方法遵循的規則別無二致,可以用返 回或拋出異常來退出。只有一行代碼的 Lambda 表達式也可使用大括號,用以明確 Lambda表達式從何處開始、到哪里結束。Lambda 表達式也可以表示包含多個參數的方法,如➍所示。這時就有必要思考怎樣去閱 讀該 Lambda 表達式。這行代碼並不是將兩個數字相加,而是創建了一個函數,用來計算 兩個數字相加的結果。變量 add 的類型是 BinaryOperator ,它不是兩個數字的和, 而是將兩個數字相加的那行代碼。到目前為止,所有 Lambda 表達式中的參數類型都是由編譯器推斷得出的。這當然不錯, 但有時最好也可以顯式聲明參數類型,此時就需要使用小括號將參數括起來,多個參數的 情況也是如此。如➎所示。

記住一點很重要,Lambda 表達式都可以擴寫為原始的“匿名類”形式。所以當你覺得這個 Lambda 表達式很復雜不容易理解的時候,不妨把它擴寫為“匿名類”形式來看。

2.2 閉包

如果你以前使用過匿名內部類,也許遇到過這樣的問題。當你需要匿名內部類所在方法里的變量,必須把該變量聲明為 final。如下例子所示:

final String name = getUserName();
button.addActionListener(new ActionListener() {
	public void actionPerformed(ActionEvent event) {
		System.out.println("hi " + name);
	}
});

Java 8放松了這一限制,可以不必再把變量聲明為 final,但其實該變量實際上仍然是 final 的。雖然無需將變量聲明為 final,但在 Lambda 表達式中,也無法用作非終態變量。如果堅持用作非終態變量(即改變變量的值),編譯器就會報錯。

2.3 函數接口

上面例子里提到了 ActionListener 接口,我們看一下它的代碼:

public interface ActionListener extends EventListener {

    /**
     * Invoked when an action occurs.
     */
    public void actionPerformed(ActionEvent e);

}

ActionListener 只有一個抽象方法:actionPerformed,被用來表示行為:接受一個參數,返回空。記住,由於 actionPerformed 定義在一個接口里,因此 abstract 關鍵字不是必需的。該接口也繼承自一個不具有任何方法的父接口:EventListener

我們把這種接口就叫做函數接口。

JDK 8 中提供了一組常用的核心函數接口:

接口 參數 返回類型 描述
Predicate<T> T boolean 用於判別一個對象。比如求一個人是否為男性
Consumer<T> T void 用於接收一個對象進行處理但沒有返回,比如接收一個人並打印他的名字
Function<T, R> T R 轉換一個對象為不同類型的對象
Supplier<T> None T 提供一個對象
UnaryOperator<T> T T 接收對象並返回同類型的對象
BinaryOperator<T> (T, T) T 接收兩個同類型的對象,並返回一個原類型對象

其中 CosumerSupplier 對應,一個是消費者,一個是提供者。

Predicate 用於判斷對象是否符合某個條件,經常被用來過濾對象。

Function 是將一個對象轉換為另一個對象,比如說要裝箱或者拆箱某個對象。

UnaryOperator 接收和返回同類型對象,一般用於對對象修改屬性。BinaryOperator 則可以理解為合並對象。

如果以前接觸過一些其他 Java 框架,比如 Google Guava,可能已經使用過這些接口,對這些東西並不陌生。所以,其實 Java 8 的改進並不是閉門造車,而是集百家之長。

3. 集合處理

3.1 Stream 簡介

在程序編寫過程中,集合的處理應該是很普遍的。Java 8 對於 Collection 的處理花了很大的功夫,如果從 JDK 7 過渡到 JDK 8,這一塊也可能是我們感受最為明顯的。

Java 8 中,引入了流(Stream)的概念,這個流和以前我們使用的 IO 中的流並不太相同。

所有繼承自 Collection 的接口都可以轉換為 Stream。還是看一個例子。

假設我們有一個 List 包含一系列的 PersonPerson 有姓名 name 和年齡 age 連個字段。現要求這個列表中年齡大於 20 的人數。

通常按照以前我們可能會這么寫:

long count = 0;
for (Person p : persons) {
	if (p.getAge() > 20) {
		count ++;
	}
}

但如果使用 stream 的話,則會簡單很多:

long count = persons.stream()
					.filter(person -> person.getAge() > 20)
 					.count();

這只是 stream 的很簡單的一個用法。現在鏈式調用方法算是一個主流,這樣寫也更利於閱讀和理解編寫者的意圖,一步方法做一件事。

3.2 Stream 常用操作

Stream 的方法分為兩類。一類叫惰性求值,一類叫及早求值。

判斷一個操作是惰性求值還是及早求值很簡單:只需看它的返回值。如果返回值是 Stream,那么是惰性求值。其實可以這么理解,如果調用惰性求值方法,Stream 只是記錄下了這個惰性求值方法的過程,並沒有去計算,等到調用及早求值方法后,就連同前面的一系列惰性求值方法順序進行計算,返回結果。

通用形式為:

Stream.惰性求值.惰性求值. ... .惰性求值.及早求值

整個過程和建造者模式有共通之處。建造者模式使用一系列操作設置屬性和配置,最后調 用一個 build 方法,這時,對象才被真正創建。

3.2.1 collect(toList())

collect(toList()) 方法由 Stream 里的值生成一個列表,是一個及早求值操作。可以理解為 StreamCollection 的轉換。

注意這邊的 toList() 其實是 Collectors.toList(),因為采用了靜態倒入,看起來顯得簡潔。

List<String> collected = Stream.of("a", "b", "c")
							   .collect(Collectors.toList());
assertEquals(Arrays.asList("a", "b", "c"), collected);

3.2.2 map

如果有一個函數可以將一種類型的值轉換成另外一種類型,map 操作就可以使用該函數,將一個流中的值轉換成一個新的流。

List<String> collected = Stream.of("a", "b", "hello")
							   .map(string -> string.toUpperCase())
							   .collect(toList());
assertEquals(asList("A", "B", "HELLO"), collected);

map 方法就是接受的一個 Function 的匿名函數類,進行的轉換。

3.2.3 filter

遍歷數據並檢查其中的元素時,可嘗試使用 Stream 中提供的新方法 filter

List<String> beginningWithNumbers = 
		Stream.of("a", "1abc", "abc1")
			  .filter(value -> isDigit(value.charAt(0)))
			  .collect(toList());
assertEquals(asList("1abc"), beginningWithNumbers);

filter 方法就是接受的一個 Predicate 的匿名函數類,判斷對象是否符合條件,符合條件的才保留下來。

3.2.4 flatMap

flatMap 方法可用 Stream 替換值,然后將多個 Stream 連接成一個 Stream

List<Integer> together = Stream.of(asList(1, 2), asList(3, 4))
							   .flatMap(numbers -> numbers.stream())
							   .collect(toList());
assertEquals(asList(1, 2, 3, 4), together);

flatMap 最常用的操作就是合並多個 Collection

3.2.5 max和min

Stream 上常用的操作之一是求最大值和最小值。Stream API 中的 maxmin 操作足以解決這一問題。

List<Integer> list = Lists.newArrayList(3, 5, 2, 9, 1);
int maxInt = list.stream()
				 .max(Integer::compareTo)
				 .get();
int minInt = list.stream()
				 .min(Integer::compareTo)
				 .get();
assertEquals(maxInt, 9);
assertEquals(minInt, 1);

這里有 2 個要點需要注意:

  1. maxmin 方法返回的是一個 Optional 對象(對了,和 Google Guava 里的 Optional 對象是一樣的)。Optional 對象封裝的就是實際的值,可能為空,所以保險起見,可以先用 isPresent() 方法判斷一下。Optional 的引入就是為了解決方法返回 null 的問題。
  2. Integer::compareTo 也是屬於 Java 8 引入的新特性,叫做 方法引用(Method References)。在這邊,其實就是 (int1, int2) -> int1.compareTo(int2) 的簡寫,可以自己查閱了解,這里不再多做贅述。

3.2.6 reduce

reduce 操作可以實現從一組值中生成一個值。在上述例子中用到的 countminmax 方法,因為常用而被納入標准庫中。事實上,這些方法都是 reduce 操作。

上圖展示了 reduce 進行累加的一個過程。具體的代碼如下:

int result = Stream.of(1, 2, 3, 4)
				   .reduce(0, (acc, element) -> acc + element);
assertEquals(10, result);

注意 reduce 的第一個參數,這是一個初始值。0 + 1 + 2 + 3 + 4 = 10

如果是累乘,則為:

int result = Stream.of(1, 2, 3, 4)
				   .reduce(1, (acc, element) -> acc * element);
assertEquals(24, result);

因為任何數乘以 1 都為其自身嘛。1 * 1 * 2 * 3 * 4 = 24

Stream 的方法還有很多,這里列出的幾種都是比較常用的。Stream 還有很多通用方法,具體可以查閱 Java 8 的 API 文檔。

https://docs.oracle.com/javase/8/docs/api/

3.3 數據並行化操作

Stream 的並行化也是 Java 8 的一大亮點。數據並行化是指將數據分成塊,為每塊數據分配單獨的處理單元。這樣可以充分利用多核 CPU 的優勢。

並行化操作流只需改變一個方法調用。如果已經有一個 Stream 對象,調用它的 parallel() 方法就能讓其擁有並行操作的能力。如果想從一個集合類創建一個流,調用 parallelStream() 就能立即獲得一個擁有並行能力的流。

int sumSize = Stream.of("Apple", "Banana", "Orange", "Pear")
					.parallel()
					.map(s -> s.length())
					.reduce(Integer::sum)
					.get();
assertEquals(sumSize, 21);

這里求的是一個字符串列表中各個字符串長度總和。

如果你去計算這段代碼所花的時間,很可能比不加上 parallel() 方法花的時間更長。這是因為數據並行化會先對數據進行分塊,然后對每塊數據開辟線程進行運算,這些地方會花費額外的時間。並行化操作只有在 數據規模比較大 或者 數據的處理時間比較長 的時候才能體現出有事,所以並不是每個地方都需要讓數據並行化,應該具體問題具體分析。

3.4 其他

3.4.1 收集器

Stream 轉換為 List 是很常用的操作,其他 Collectors 還有很多方法,可以將 Stream 轉換為 Set, 或者將數據分組並轉換為 Map,並對數據進行處理。也可以指定轉換為具體類型,如 ArrayList, LinkedList 或者 HashMap。甚至可以自定義 Collectors,編寫自己的收集器。

Collectors (收集器)的內容太多,有興趣的可以自己研究。

http://my.oschina.net/joshuashaw/blog/487322
https://docs.oracle.com/javase/8/docs/api/java/util/stream/Collectors.html

3.4.2 元素順序

另外一個尚未提及的關於集合類的內容是流中的元素以何種順序排列。一些集合類型中的元素是按順序排列的,比如 List;而另一些則是無序的,比如 HashSet。增加了流操作后,順序問題變得更加復雜。

總之記住。如果集合本身就是無序的,由此生成的流也是無序的。一些中間操作會產生順序,比如對值做映射時,映射后的值是有序的,這種順序就會保留 下來。如果進來的流是無序的,出去的流也是無序的。

如果我們需要對流中的數據進行排序,可以調用 sorted 方法:

List<Integer> list = Lists.newArrayList(3, 5, 1, 10, 8);
List<Integer> sortedList = list.stream()
							   .sorted(Integer::compareTo)
							   .collect(Collectors.toList());
assertEquals(sortedList, Lists.newArrayList(1, 3, 5, 8, 10));

3.4.3 @FunctionalInterface

我們討論過函數接口定義的標准,但未提及 @FunctionalInterface 注釋。事實上,每個用作函數接口的接口都應該添加這個注釋。

但 Java 中有一些接口,雖然只含一個方法,但並不是為了使用 Lambda 表達式來實現的。比如,有些對象內部可能保存着某種狀態,使用帶有一個方法的接口可能純屬巧合。

該注釋會強制 javac 檢查一個接口是否符合函數接口的標准。如果該注釋添加給一個枚舉類型、類或另一個注釋,或者接口包含不止一個抽象方法,javac 就會報錯。重構代碼時,使用它能很容易發現問題。

參考

  1. 【Java 8 函數式編程】by Richard Warburton
  2. OpenJDK
  3. 函數式編程初探 by 阮一峰
  4. 函數式編程是一個倒退


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM