1. 程式人生 > >Java中的函數語言程式設計

Java中的函數語言程式設計

[TOC]

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() {
    publicvoidactionPerformed(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 表示式很複雜不容易理解的時候,不妨把它擴寫為“匿名類”形式來看。

2.2 閉包

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

final String name = getUserName();
button.addActionListener(new ActionListener() {
    publicvoidactionPerformed(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.     */
    publicvoidactionPerformed(ActionEvent e);

}

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

我們把這種介面就叫做函式介面。

JDK 8 中提供了一組常用的核心函式介面:

介面引數返回型別描述
Predicate<T>Tboolean用於判別一個物件。比如求一個人是否為男性
Consumer<T>Tvoid用於接收一個物件進行處理但沒有返回,比如接收一個人並列印他的名字
Function<T, R>TR轉換一個物件為不同型別的物件
Supplier<T>NoneT提供一個物件
UnaryOperator<T>TT接收物件並返回同類型的物件
BinaryOperator<T>(T, T)T接收兩個同類型的物件,並返回一個原型別物件

其中 Cosumer 與 Supplier 對應,一個是消費者,一個是提供者。

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 裡的值生成一個列表,是一個及早求值操作。可以理解為 Stream 向 Collection 的轉換。

注意這邊的 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 中的 max 和 min 操作足以解決這一問題。

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. max 和 min 方法返回的是一個 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 操作可以實現從一組值中生成一個值。在上述例子中用到的 countmin 和 max 方法,因為常用而被納入標準庫中。事實上,這些方法都是 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 文件。

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,並對資料進行處理。也可以指定轉換為具體型別,如 ArrayListLinkedList 或者 HashMap。甚至可以自定義 Collectors,編寫自己的收集器。

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

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 就會報錯。重構程式碼時,使用它能很容易發現問題。

參考