計算機程式的思維邏輯 (91) - Lambda表示式

weixin_34377065發表於2017-07-05

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (91) - Lambda表示式

在之前的章節中,我們的討論基本都是基於Java 7的,從本節開始,我們探討Java 8的一些特性,主要內容包括:

  • 傳遞行為程式碼 - Lambda表示式
  • 函式式資料處理 - 流
  • 組合式非同步程式設計 - CompletableFuture
  • 新的日期和時間API

本節,我們先討論Lambda表示式,它是什麼?有什麼用呢?

Lambda表示式是Java 8新引入的一種語法, 是一種緊湊的傳遞程式碼的方式,它的名字來源於學術界的λ演算,具體我們就不探討了。

理解Lambda表示式,我們先回顧一下介面、匿名內部類和程式碼傳遞。

通過介面傳遞程式碼

我們在19節介紹過介面以及面向介面的程式設計,針對介面而非具體型別進行程式設計,可以降低程式的耦合性、提高靈活性、提高複用性。介面常被用於傳遞程式碼,比如,在59節,我們介紹過File的如下方法:

public String[] list(FilenameFilter filter)
public File[] listFiles(FilenameFilter filter)
複製程式碼

list和listFiles需要的其實不是FilenameFilter物件,而是它包含的如下方法:

boolean accept(File dir, String name);
複製程式碼

或者說,list和listFiles希望接受一段方法程式碼作為引數,但沒有辦法直接傳遞這個方法程式碼本身,只能傳遞一個介面。

再比如,我們在53節介紹過Collections的一些演算法,很多方法都接受一個引數Comparator,比如:

public static <T> int binarySearch(List<? extends T> list, T key, Comparator<? super T> c)
public static <T> T max(Collection<? extends T> coll, Comparator<? super T> comp)
public static <T> void sort(List<T> list, Comparator<? super T> c)
複製程式碼

它們需要的也不是Comparator物件,而是它包含的如下方法:

int compare(T o1, T o2);
複製程式碼

但是,沒有辦法直接傳遞方法,只能傳遞一個介面。

我們在77節介紹過非同步任務執行服務ExecutorService,提交任務的方法有:

<T> Future<T> submit(Callable<T> task);
<T> Future<T> submit(Runnable task, T result);
Future<?> submit(Runnable task);
複製程式碼

Callable和Runnable介面也用於傳遞任務程式碼。

通過介面傳遞行為程式碼,就要傳遞一個實現了該介面的例項物件,在之前的章節中,最簡潔的方式是使用匿名內部類,比如:

//列出當前目錄下的所有字尾為.txt的檔案
File f = new File(".");
File[] files = f.listFiles(new FilenameFilter(){
    @Override
    public boolean accept(File dir, String name) {
        if(name.endsWith(".txt")){
            return true;
        }
        return false;
    }
});
複製程式碼

將files按照檔名排序,程式碼為:

Arrays.sort(files, new Comparator<File>() {

    @Override
    public int compare(File f1, File f2) {
        return f1.getName().compareTo(f2.getName());
    }
});
複製程式碼

提交一個最簡單的任務,程式碼為:

ExecutorService executor = Executors.newFixedThreadPool(100);
executor.submit(new Runnable() {
    @Override
    public void run() {
        System.out.println("hello world");
    }
});
複製程式碼

Lambda表示式

語法

Java 8提供了一種新的緊湊的傳遞程式碼的語法 - Lambda表示式。對於前面列出檔案的例子,程式碼可以改為:

File f = new File(".");
File[] files = f.listFiles((File dir, String name) -> {
    if (name.endsWith(".txt")) {
        return true;
    }
    return false;
});
複製程式碼

可以看出,相比匿名內部類,傳遞程式碼變得更為直觀,不再有實現介面的模板程式碼,不再宣告方法,也名字也沒有,而是直接給出了方法的實現程式碼。Lambda表示式由->分隔為兩部分,前面是方法的引數,後面{}內是方法的程式碼。

上面程式碼可以簡化為:

File[] files = f.listFiles((File dir, String name) -> {
    return name.endsWith(".txt");
});
複製程式碼

當主體程式碼只有一條語句的時候,括號和return語句也可以省略,上面程式碼可以變為:

File[] files = f.listFiles((File dir, String name) -> name.endsWith(".txt"));
複製程式碼

注意,沒有括號的時候,主體程式碼是一個表示式,這個表示式的值就是函式的返回值,結尾不能加分號;,也不能加return語句。

方法的引數型別宣告也可以省略,上面程式碼還可以繼續簡化為:

File[] files = f.listFiles((dir, name) -> name.endsWith(".txt"));
複製程式碼

之所以可以省略方法的引數型別,是因為Java可以自動推斷出來,它知道listFiles接受的引數型別是FilenameFilter,這個介面只有一個方法accept,這個方法的兩個引數型別分別是File和String。

這樣簡化下來,程式碼是不是簡潔清楚多了?

排序的程式碼用Lambda表示式可以寫為:

Arrays.sort(files, (f1, f2) -> f1.getName().compareTo(f2.getName())); 
複製程式碼

提交任務的程式碼用Lambda表示式可以寫為:

executor.submit(()->System.out.println("hello"));
複製程式碼

引數部分為空,寫為()。

當引數只有一個的時候,引數部分的括號可以省略,比如,File還有如下方法:

public File[] listFiles(FileFilter filter)
複製程式碼

FileFilter的定義為:

public interface FileFilter {
    boolean accept(File pathname);
}
複製程式碼

使用FileFilter重寫上面的列舉檔案的例子,程式碼可以為:

File[] files = f.listFiles(path -> path.getName().endsWith(".txt"));
複製程式碼

變數引用

與匿名內部類類似,Lambda表示式也可以訪問定義在主體程式碼外部的變數,但對於區域性變數,它也只能訪問final型別的變數,與匿名內部類的區別是,它不要求變數宣告為final,但變數事實上不能被重新賦值。比如:

String msg = "hello world";
executor.submit(()->System.out.println(msg));
複製程式碼

可以訪問區域性變數msg,但msg不能被重新賦值,如果這樣寫:

String msg = "hello world";
msg = "good morning";
executor.submit(()->System.out.println(msg));
複製程式碼

Java編譯器會提示錯誤。

這個原因與匿名內部類是一樣的,Java會將msg的值作為引數傳遞給Lambda表示式,為Lambda表示式建立一個副本,它的程式碼訪問的是這個副本,而不是外部宣告的msg變數。如果允許msg被修改,則程式設計師可能會誤以為Lambda表示式會讀到修改後的值,引起更多的混淆。

為什麼非要建副本,直接訪問外部的msg變數不行嗎?不行,因為msg定義在棧中,當Lambda表示式被執行的時候,msg可能早已被釋放了。如果希望能夠修改值,可以將變數定義為例項變數,或者,將變數定義為陣列,比如:

String[] msg = new String[]{"hello world"};
msg[0] = "good morning";
executor.submit(()->System.out.println(msg[0]));
複製程式碼

與匿名內部類比較

從以上內容可以看出,Lambda表示式與匿名內部類很像,主要就是簡化了語法,那它是不是語法糖,內部實現其實就是內部類呢?答案是否定的, Java會為每個匿名內部類生成一個類,但Lambda表示式不會,Lambda表示式通常比較短,為每個表示式生成一個類會生成大量的類,效能會受到影響。

Java利用了Java 7引入的為支援動態型別語言引入的invokedynamic指令、方法控制程式碼(method handle)等,具體實現比較複雜,我們就不探討了,感興趣可以參看cr.openjdk.java.net/~briangoetz…,我們需要知道的是,Java的實現是非常高效的,不用擔心生成太多類的問題。

Lambda表示式不是匿名內部類,那它的型別到底是什麼呢?是 函式式介面

函式式介面

Java 8引入了函式式介面的概念, 函式式介面也是介面,但只能有一個抽象方法,前面提及的介面都只有一個抽象方法,都是函式式介面。之所以強調是"抽象"方法,是因為Java 8中還允許定義其他方法,我們待會會談到。Lambda表示式可以賦值給函式式介面,比如:

FileFilter filter = path -> path.getName().endsWith(".txt");
FilenameFilter fileNameFilter = (dir, name) -> name.endsWith(".txt");
Comparator<File> comparator = (f1, f2) -> f1.getName().compareTo(f2.getName());
Runnable task = () -> System.out.println("hello world");
複製程式碼

如果看這些介面的定義,會發現它們都有一個註解@FunctionalInterface,比如:

@FunctionalInterface
public interface Runnable {
    public abstract void run();
}
複製程式碼

@FunctionalInterface用於清晰地告知使用者,這是一個函式式介面,不過,這個註解不是必需的,不加,只要只有一個抽象方法,也是函式式介面。但如果加了,而又定義了超過一個抽象方法,Java編譯器會報錯,這類似於我們在85節介紹的Override註解。

預定義的函式式介面

介面列表

Java 8定義了大量的預定義函式式介面,用於常見型別的程式碼傳遞,這些函式定義在包java.util.function下,主要的有:

計算機程式的思維邏輯 (91) - Lambda表示式

對於基本型別boolean, int, long和double,為避免裝箱/拆箱,Java 8提供了一些專門的函式,比如,int相關的主要函式有:

計算機程式的思維邏輯 (91) - Lambda表示式

這些函式有什麼用呢?它們被大量使用於Java 8的函式式資料處理Stream相關的類中,關於Stream,我們下節介紹。

即使不使用Stream,也可以在自己的程式碼中直接使用這些預定義的函式,我們看一些簡單的示例。

Predicate示例

為便於舉例,我們先定義一個簡單的學生類Student,有name和score兩個屬性,如下所示,我們省略了getter/setter方法。

static class Student {
    String name;
    double score;
    
    public Student(String name, double score) {
        this.name = name;
        this.score = score;
    }
}
複製程式碼

有一個學生列表:

List<Student> students = Arrays.asList(new Student[] {
        new Student("zhangsan", 89d),
        new Student("lisi", 89d),
        new Student("wangwu", 98d) });
複製程式碼

在日常開發中,列表處理的一個常見需求是過濾,列表的型別經常不一樣,過濾的條件也經常變化,但主體邏輯都是類似的,可以藉助Predicate寫一個通用的方法,如下所示:

public static <E> List<E> filter(List<E> list, Predicate<E> pred) {
    List<E> retList = new ArrayList<>();
    for (E e : list) {
        if (pred.test(e)) {
            retList.add(e);
        }
    }
    return retList;
}
複製程式碼

這個方法可以這麼用:

// 過濾90分以上的
students = filter(students, t -> t.getScore() > 90);
複製程式碼

Function示例

列表處理的另一個常見需求是轉換,比如,給定一個學生列表,需要返回名稱列表,或者將名稱轉換為大寫返回,可以藉助Function寫一個通用的方法,如下所示:

public static <T, R> List<R> map(List<T> list, Function<T, R> mapper) {
    List<R> retList = new ArrayList<>(list.size());
    for (T e : list) {
        retList.add(mapper.apply(e));
    }
    return retList;
}
複製程式碼

根據學生列表返回名稱列表的程式碼可以為:

List<String> names = map(students, t -> t.getName());
複製程式碼

將學生名稱轉換為大寫的程式碼可以為:

students = map(students, t -> new Student(t.getName().toUpperCase(), t.getScore()));
複製程式碼

Consumer示例

在上面轉換學生名稱為大寫的例子中,我們為每個學生建立了一個新的物件,另一種常見的情況是直接修改原物件,具體怎麼修改通過程式碼傳遞,這時,可以用Consumer寫一個通用的方法,比如:

public static <E> void foreach(List<E> list, Consumer<E> consumer) {
    for (E e : list) {
        consumer.accept(e);
    }
}
複製程式碼

上面轉換為大寫的例子可以改為:

foreach(students, t -> t.setName(t.getName().toUpperCase()));
複製程式碼

以上這些示例主要用於演示函式式介面的基本概念,實際中應該使用下節介紹的流API。

方法引用

基本用法

Lambda表示式經常就是呼叫物件的某個方法,比如:

List<String> names = map(students, t -> t.getName());
複製程式碼

這時,它可以進一步簡化,如下所示:

List<String> names = map(students, Student::getName);
複製程式碼

Student::getName這種寫法,是Java 8引入的一種新語法,稱之為 方法引用,它是Lambda表示式的一種簡寫方法,由::分隔為兩部分,前面是類名或變數名,後面是方法名。方法可以是例項方法,也可以是靜態方法,但含義不同。

我們看一些例子,還是以Student為例,先增加一個靜態方法:

public static String getCollegeName(){
    return "Laoma School";
}
複製程式碼

靜態方法

對於靜態方法,如下語句:

Supplier<String> s = Student::getCollegeName;
複製程式碼

等價於:

Supplier<String> s = () -> Student.getCollegeName();
複製程式碼

它們的引數都是空,返回型別為String。

例項方法

而對於例項方法,它第一個引數就是該型別的例項,比如,如下語句:

Function<Student, String> f = Student::getName;
複製程式碼

等價於:

Function<Student, String> f = (Student t) -> t.getName();
複製程式碼

對於Student::setName,它是一個BiConsumer,即:

BiConsumer<Student, String> c = Student::setName;
複製程式碼

等價於:

BiConsumer<Student, String> c = (t, name) -> t.setName(name);
複製程式碼

通過變數引用方法

如果方法引用的第一部分是變數名,則相當於呼叫那個物件的方法,比如:

Student t = new Student("張三", 89d);
Supplier<String> s = t::getName;
複製程式碼

等價於:

Supplier<String> s = () -> t.getName(); 
複製程式碼

而:

Consumer<String> consumer = t::setName;
複製程式碼

等價於:

Consumer<String> consumer = (name) -> t.setName(name);
複製程式碼

構造方法

對於構造方法,方法引用的語法是<類名>::new,如Student::new,如下語句:

BiFunction<String, Double, Student> s = (name, score) -> new Student(name, score);
複製程式碼

等價於:

BiFunction<String, Double, Student> s = Student::new;
複製程式碼

函式的複合

在前面的例子中,函式式介面都用作方法的引數,其他部分通過Lambda表示式傳遞具體程式碼給它,函式式介面和Lambda表示式還可用作方法的返回值,傳遞程式碼回撥用者,將這兩種用法結合起來,可以構造複合的函式,使程式簡潔易讀

下面我們會看一些例子,在介紹例子之前,我們先需要介紹Java 8對介面的增強。

介面的靜態方法和預設方法

在Java 8之前,介面中的方法都是抽象方法,都沒有實現體,Java 8允許在介面中定義兩類新方法:靜態方法和預設方法,它們有實現體,比如:

public interface IDemo {
    void hello();

    public static void test() {
        System.out.println("hello");
    }

    default void hi() {
        System.out.println("hi");
    }
}
複製程式碼

test()就是一個靜態方法,可以通過IDemo.test()呼叫。在介面不能定義靜態方法之前,相關的靜態方法往往定義在單獨的類中,比如,Collection介面有一個對應的單獨的類Collections,在Java 8中,就可以直接寫在介面中了,比如Comparator介面就定義了多個靜態方法。

hi()是一個預設方法,由關鍵字default標識,預設方法與抽象方法都是介面的方法,不同在於,它有預設的實現,實現類可以改變它的實現,也可以不改變。引入預設方法主要是函式式資料處理的需求,是為了便於給介面增加功能

在沒有預設方法之前,Java是很難給介面增加功能的,比如List介面,因為有太多非Java JDK控制的程式碼實現了該介面,如果給介面增加一個方法,則那些介面的實現就無法在新版Java 上執行,必須改寫程式碼,實現新的方法,這顯然是無法接受的。函式式資料處理需要給一些介面增加一些新的方法,所以就有了預設方法的概念,介面增加了新方法,而介面現有的實現類也不需要必須實現它。

看一些例子,List介面增加了sort方法,其定義為:

default void sort(Comparator<? super E> c) {
    Object[] a = this.toArray();
    Arrays.sort(a, (Comparator) c);
    ListIterator<E> i = this.listIterator();
    for (Object e : a) {
        i.next();
        i.set((E) e);
    }
}
複製程式碼

Collection介面增加了stream方法,其定義為:

default Stream<E> stream() {
    return StreamSupport.stream(spliterator(), false);
}
複製程式碼

需要說明的是,即使能定義方法體了,介面與抽象類還是不一樣的,介面中不能定義例項變數,而抽象類可以。

瞭解了靜態方法和預設方法,我們看一些利用它們實現複合函式的例子。

Comparator中的複合方法

Comparator介面定義瞭如下靜態方法:

public static <T, U extends Comparable<? super U>> Comparator<T> comparing(
        Function<? super T, ? extends U> keyExtractor)
{
    Objects.requireNonNull(keyExtractor);
    return (Comparator<T> & Serializable)
        (c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2));
}
複製程式碼

這個方法是什麼意思呢?它用於構建一個Comparator,比如,在前面的例子中,對檔案按照檔名排序的程式碼為:

Arrays.sort(files, (f1, f2) -> f1.getName().compareTo(f2.getName()));
複製程式碼

使用comparing方法,程式碼可以簡化為:

Arrays.sort(files, Comparator.comparing(File::getName));
複製程式碼

這樣,程式碼的可讀性是不是大大增強了?comparing方法為什麼能達到這個效果呢?它構建並返回了一個符合Comparator介面的Lambda表示式,這個Comparator接受的引數型別是File,它使用了傳遞過來的函式程式碼keyExtractor將File轉換為String進行比較。像comparing這樣使用複合方式構建並傳遞程式碼並不容易閱讀和理解,但呼叫者很方便,也很容易理解。

Comparator還有很多預設方法,我們看兩個:

default Comparator<T> reversed() {
    return Collections.reverseOrder(this);
}


default Comparator<T> thenComparing(Comparator<? super T> other) {
    Objects.requireNonNull(other);
    return (Comparator<T> & Serializable) (c1, c2) -> {
        int res = compare(c1, c2);
        return (res != 0) ? res : other.compare(c1, c2);
    };
}
複製程式碼

reversed返回一個新的Comparator,按原排序逆序排。thenComparing也是一個返回一個新的Comparator,在原排序認為兩個元素排序相同的時候,使用提供的other Comparator進行比較。

看一個使用的例子,將學生列表按照分數倒序排(高分在前),分數一樣的,按照名字進行排序,程式碼如下所示:

students.sort(Comparator.comparing(Student::getScore)
                        .reversed()
                        .thenComparing(Student::getName));
複製程式碼

這樣,程式碼是不是很容易讀?

java.util.function中的複合方法

在java.util.function包中的很多函式式介面裡,都定義了一些複合方法,我們看一些例子。

Function介面有如下定義:

default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
    Objects.requireNonNull(after);
    return (T t) -> after.apply(apply(t));
}
複製程式碼

先將T型別的引數轉化為型別R,再呼叫after將R轉換為V,最後返回型別V。

還有如下定義:

default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
    Objects.requireNonNull(before);
    return (V v) -> apply(before.apply(v));
}
複製程式碼

對V型別的引數,先呼叫before將V轉換為T型別,再呼叫當前的apply方法轉換為R型別返回。

Consumer, Predicate等都有一些複合方法,它們大量被用於下節介紹的函式式資料處理API中,具體我們就不探討了。

小結

本節介紹了Java 8中的一些新概念,包括Lambda表示式、函式式介面、方法引用、介面的靜態方法和預設方法等。

最重要的變化是,傳遞程式碼變的簡單了,函式變為了程式碼世界的一等公民,可以方便的被作為引數傳遞,被作為返回值,被複合利用以構建新的函式,看上去,這些只是語法上的一些小變化,但利用這些小變化,卻能使得程式碼更為通用、更為靈活、更為簡潔易讀,這,大概就是函數語言程式設計的奇妙之處吧。

下一節,我們來探討Java 8引入的函式式資料處理API,它們大大簡化了常見的集合資料操作。

(與其他章節一樣,本節所有程式碼位於 github.com/swiftma/pro…,位於包shuo.laoma.java8.c91下)


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),從入門到高階,深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (91) - Lambda表示式

相關文章