Java中的函數語言程式設計(三)lambda表示式

安員外發表於2021-10-22

寫在前面

lambda表示式是一個匿名函式。在Java 8中,它和函式式介面一起,共同構建了函數語言程式設計的框架。
 
lambda表示式乍看像是匿名內部類的一種語法糖,但實際上,它們是兩種本質不同的事物。匿名內部類本質是一個類,只是不需要程式設計師顯示指定類名,編譯器會自動為該類取名。而 lambda 表示式本質是一個函式,當然,編譯器也會為它取名。在JVM層面,匿名內部類對應的是一個 class 檔案,而 lambda 表示式對應的是它所在主類的一個私有方法。
 
lambda 表示式可以在函式體中引用外部的變數,從而實現了閉包。但 Java 對進入閉包的變數有 final 的限制,當然我們可以繞開這個限制。
 
本文的示例程式碼可從gitee上獲取:https://gitee.com/cnmemset/javafp
 

lambda表示式與匿名內部類

lambda表示式可以用來簡化某些匿名內部類(Anonymous Inner Classes)的寫法,但僅限於對函式式介面的簡寫。
 

無參的函式式介面

以最常用的Runnable介面為例:
在Java 7中,如果需要新建一個執行緒,使用匿名內部類的寫法是這樣:
public static void createThreadWithAnonymousClass() {
    // Runnable 是介面名。我們通過匿名內部類的方式,構造了一個 Runnable 的例項。
    Thread t = new Thread(new Runnable() {
        @Override
        public void run() {
            System.out.println("Thread is running");
        }
    });
 
    t.start();
}

使用匿名內部類的一個重要目的,就是為了減輕程式設計師的程式碼負擔,不需要額外再定義一個類,而且這個類是一個一次性的類,沒有太多的重用價值。但是,我們會發現,這個物件看起來也是多餘的,因為我們實際上並不是要傳入一個物件,而只是想傳入一個方法。

 

在Java 8中,因為 Runnable 介面是一個函式式介面(只有一個抽象方法的介面都屬於函式式介面),因此我們可以用lambda表示式來簡化匿名內部類的寫法:
public static void createThreadWithLambda() {
    // 在Java 8中,Runnable 是一個函式式介面,因此我們可以使用 lambda 表示式來實現它。
    Thread t = new Thread(() -> {
        System.out.println("Thread is running");
    });
 
    t.start();
}

 

帶參的函式式介面

Runnable是一個無參的函式式介面,我們再來看一個典型的帶引數的函式式介面 Comparator:
@FunctionalInterface
public interface Comparator<T> {
    int compare(T o1, T o2);
  
    ....
}

 

假設一個場景:給定一個省份的拼音列表,需要對該列表中的省份進行排序,排序規則是字母長度最小的省份排在前面,如果兩個省份字母長度一樣,則按字母順序排序。

使用匿名內部類的示例程式碼如下:
public static void sortProvincesWithAnonymousClass() {
    List<String> list = Arrays.asList("Guangdong", "Zhejiang", "Jiangsu", "Xizang", "Fujian", "Hunan", "Guangxi");

    list.sort(new Comparator<String>() {
        @Override
        public int compare(String first, String second) {
            int lenDiff = first.length() - second.length();
            return lenDiff == 0 ? first.compareTo(second) : lenDiff;
        }
    });

    list.forEach(s -> System.out.println(s));
}

 

上述程式碼輸出為:
Hunan
Fujian
Xizang
Guangxi
Jiangsu
Zhejiang
Guangdong
 
 
使用lambda表示式來簡化Comparator的實現,示例程式碼如下:
public static void sortProvincesWithLambda() {
    List<String> list = Arrays.asList("Guangdong", "Zhejiang", "Jiangsu", "Xizang", "Fujian", "Hunan", "Guangxi");
 
    // 下面的引數列表 first 和 second ,即方法 Comparator.compare 的引數列表
    list.sort((first, second) -> {
        int lenDiff = first.length() - second.length();
        return lenDiff == 0 ? first.compareTo(second) : lenDiff;
    });
 
    list.forEach(s -> System.out.println(s));
}

注意到,帶引數的lambda表示式,甚至不需要宣告型別,因為編譯器可以通過上下文來推斷出引數的型別。當然,我們也可以顯式指定引數型別,尤其是在引數型別推斷失敗的時候:

(String first, String second) -> {
    int lenDiff = first.length() - second.length();
    return lenDiff == 0 ? first.compareTo(second) : lenDiff;
}

 

this關鍵字的作用域

前面提到過,匿名內部類和lambda表示式本質是不同的:匿名內部類本質是一個類,而lambda表示式本質是一個函式。在JVM層面,匿名內部類對應的是一個class檔案,而lambda表示式對應的是它所在主類的一個私有方法。
 
這就導致了this關鍵字在匿名內部類和lambda表示式中是不一樣的。在匿名內部類中,this關鍵字指向匿名內部類的例項,而在lambda表示式中,this關鍵字指向的是主類的例項。
 
我們用程式碼驗證一下:
public class ThisScopeExample {
    public static void main(String[] args) {
        ThisScopeExample example = new ThisScopeExample();

        // 輸出 "I am Anonymous Class."
        example.runWithAnonymousClass();
        // 輸出 "I am ThisScopeExample Class."
        example.runWithLambda();
    }

    public void runWithAnonymousClass() {
        // 以匿名類的方式執行
        run(new Runnable() {
            @Override
            public void run() {
                // this 是實現了介面 Runnable 的匿名內部類的例項
                System.out.println(this);
            }

            @Override
            public String toString() {
                return "I am Anonymous Class.";
            }
        });
    }

    public void runWithLambda() {
        // 以lambda表示式的方式執行
        run(() -> {
            // this 是類 ThisScopeExample 的例項
            System.out.println(this);
        });
    }

    public void run(Runnable runnable) {
        runnable.run();
    }

    @Override
    public String toString() {
        return "I am ThisScopeExample Class.";
    }
}

 

上述程式碼輸出為:
I am Anonymous Class.
I am ThisScopeExample Class.
 
 

lambda表示式的語法

lambda表示式的語法是:引數,箭頭(->) 以及方法體。如果方法體無法用一個表示式來完成,就可以像寫普通的方法一樣,把程式碼放在大括號 { } 中。反之,如果方法體只有一個表示式,那麼就可以省略大括號 { }。
 
例如:
(String first, String second) -> {
    int lenDiff = first.length() - second.length();
    return lenDiff == 0 ? first.compareTo(second) : lenDiff;
}

上述是一個典型的而且完整的lambda表示式。

對無引數的lambda表示式,引數部分也不能省略,需要提供空括號,例如:
Supplier<Integer> supplier = () -> {
    return new Random().nextInt(100);
}

 

對於上面的lambda表示式,可以發現它的方法體只有一個表示式,所以,它可以省略大括號,甚至return關鍵字也省略了,因為編譯器可以根據上下文推斷是否需要返回值:如果需要,那麼就返回該唯一表示式的返回值,如果不需要,則在該唯一表示式後直接return。例如:

// Supplier 是需要返回值的,所以下面的lambda表示式等同於:
// () -> { return new Random().nextInt(100); }
Supplier<Integer> supplier = () -> new Random().nextInt(100);
 
// Runnable 是不需要返回值的,所以下面的lambda表示式等同於:
// () -> { new Random().nextInt(100); return; }
Runnable runnable = () -> new Random().nextInt(100);

 

如果編譯器可以推斷出lambda表示式的引數型別,則可以忽略其型別:

// 在這裡,編譯器可以推斷出 first 和 second 的型別是 String。
Comparator<String> comp = (first, second) -> {
    int lenDiff = first.length() - second.length();
    return lenDiff == 0 ? first.compareTo(second) : lenDiff;
};

 

如果lambda表示式只有一個引數,那麼引數列表中的小括號也可以省略掉:

// 這裡的 value ,等同於 (value)
Consumer<String> consumer = value -> System.out.println(value);

與普通的函式不一樣,lambda表示式不需要指定返回型別,它總是由編譯器自行推斷出返回型別。如果推斷失敗,則預設為Object型別。

lambda表示式與閉包

首先要理解lambda表示式和閉包(closure)是兩個不同的概念,但兩者有著緊密的聯絡。在不追求概念精確的場合,甚至可以說Java中的lambda表示式就是閉包。
 
閉包又稱為函式閉包(function closure),是一種延長變數生命週期的技術,從這個意義上說,閉包和麵向物件實現的功能是等價的。
 
閉包的定義是:在建立或定義一個函式的時候,除了記錄函式本身以外,同時還記錄了在建立函式時所能訪問到的自由變數(自由變數 free variable,是指在函式外部定義的變數,它既不是函式的引數,也不是函式內的區域性變數)。這樣一來,閉包的變數作用域除了包含函式執行時的區域性變數域外,還包含了函式定義時的外部變數域。
 
文字表達可能不夠直觀,我們來看一個程式碼示例:
public class ClosureExample {
    public static void main(String[] args) {
        // 平方
        IntUnaryOperator square = getPowOperator(2);
 
        // 立方
        IntUnaryOperator cube = getPowOperator(3);
 
        // 四次方
        IntUnaryOperator fourthPower = getPowOperator(4);
 
        // 5的平方
        System.out.println(square.applyAsInt(5));
        // 5的立方
        System.out.println(cube.applyAsInt(5));
        // 5的四次方
        System.out.println(fourthPower.applyAsInt(5));
    }
 
    public static IntUnaryOperator getPowOperator(int exp) {
        return base -> {
            // 變數 exp 是 getPowOperator 的引數,屬於lambda 表示式定義時的自由變數,
            // 它的生命週期會延長到和返回的 lambda 表示式一樣長。
            return (int) Math.pow(base, exp);
        };
    }
}

上述程式碼的輸出是:

25
125
625
 
可以看到,exp是方法 getPowOperator 的引數,但通過閉包技術,它“逃逸”出 getPowOperator 的作用域了。
 
很顯然,變數“逃逸”,在多執行緒環境下,容易導致執行緒安全問題,防不勝防。因此,Java規定了,在lambda表示式內部引用外部變數的話,必須是final的,即不可變物件,只能賦值一次,不可修改。(在這說句題外話,並不是所有的語言都這麼要求閉包的,譬如Python和JavaScript,閉包中引用的外部變數是可以任意修改的。)
 
為了書寫程式碼方便,Java 8不要求顯式將變數宣告為final,但如果你嘗試修改變數的值,編譯器將會報錯。例如:
public static IntUnaryOperator getPowOperator(int exp) {
    // 嘗試修改 exp 的值,但編譯器會在lambda表示式中報錯
    exp++;
    return base -> {
        // 如果嘗試修改 exp 的值,會在此處報錯:
        // Error: 從lambda 表示式引用的本地變數必須是final變數或實際上的final變數
        return (int) Math.pow(base, exp);
    };
}

 

但這種限制也是有限的,因為我們可以通過將變數宣告為一個陣列或一個類就可以修改其中的值。例如:

public static IntUnaryOperator getPowOperator(int[] exp) {
    // exp 是一個int陣列:exp = new int[1];
    exp[0]++;
    return base -> {
        // 此時不會報錯,可以正常執行
        return (int) Math.pow(base, exp[0]);
    };
}

 

結語

lambda表示式的出現,一方面為函數語言程式設計提供了支援,另一方面也提升了Java程式設計師的生產力。我們要熟悉常見的函式式介面,靈活使用lambda表示式和閉包。
 

為方便大家在移動端瀏覽,已註冊微信公眾號【員說】,歡迎關注。第一時間更新技術文章,也會不定時分享圈內熱門動態和一線大廠內幕。

感謝您閱讀本篇文章,如果覺得本文對您有幫助,歡迎點選推薦和關注,您的支援是我最大的寫作動力。

文章歡迎轉載,但需在文章頁面明顯位置,給出作者和原文連結,否則保留追究法律責任的權利!

注意!應各位朋友的邀請,建立了一個技術交流群,(聊技術/看內幕/找內推/讀書分享等,拒絕水群,保證品質),可新增微訊號【yuanshuo824】,備註:交流,即可入群。

相關文章