Java8 新特性 —— 函數式編程

本文部分摘錄自 On Java 8

概述

通常,傳遞給方法的數據不同,結果也不同。同樣的,如果我們希望方法被調用時的行為不同,該怎麼做呢?結論是:只要能將代碼傳遞給方法,那麼就可以控制方法的行為。

說得再具體點,過去我們總是創建包含所需行為的對象,然後將對象傳遞給想要控制的方法,一般使用匿名內部類來實現。假設現在有這麼一個需求:有一個員工信息列表,根據年齡過濾出符合條件的員工信息

// 過濾出大於35歲的員工
public List<Employee> filterEmployee(List<Employee> list) {
    List<Employee> emps = new ArrayList<>();
    for(Employee emp : list) {
        if(emp.getAge() > 35) {
            emps.add(emp);
        }
    }
    return emps;
}

// 過濾出大於45歲的員工
public List<Employee> filterEmployee2(List<Employee> list) {
	...   
}

這樣寫當然能實現需求,但如果需求變了,要過濾 45 歲的,那豈不是又得寫一個 filterEmplyee2() 方法?如果還要過濾 50 歲的,60 歲的,那就沒完沒了了,而且代碼的實現邏輯幾乎沒有區別。於是我們藉助策略模式的思想來簡化代碼。

public interface MyPredicate<> {
    boolean predicate(T t);
}

// 如果有其他過濾需求,只需要實現 MyPredicate 接口即可
public class EmployeeFilter implements MyPredicate<Employee> {
    @Override
    public boolean predicate(Employee employee) {
        return t.getAge() >= 35;
    }
}

// 根據傳入的 MyPredicate 對象來實現不同的過濾邏輯
public List<Employee> filterEmployee(List<Employee> list, MyPredicate<Employee> mp) {
    List<Employee> emps = new ArrayList<>();
    for(Employee emp : list) {
        if(mp.predicate(emp)) {
            emps.add(emp);
        }
    }
    return emps;
}

public void test(List<Employee> list) {
    // 創建實現類對象,傳入過濾方法
    MyPredicate<Employee> predicate = new EmployeeFilter<>();
    List<Employee> res = filterEmployee(list, predicate);
    // 更簡單的方式是使用匿名內部類
    List<Employee> res2 = filterEmployee(list, new MyPredicate<Employee>() {
        @Override
        public boolean predicate(Employee employee) {
            return t.getAge() >= 100;
        }
    });
}

通過觀察我們發現,我們需要的只有 predicate() 方法的代碼,其他的我們一律不關心。如果 MyPredicate 接口還有其他抽象方法,我們又必須每一個做一次實現,但真正用上的只有 predicate() 方法,不僅顯得冗餘,而且可讀性也很低。為了解決這個問題,Java8 為我們提供了 Lambda 表達式和方法引用兩種更加簡潔的方式。

Lambda 表達式

Lambda 表達式是一個匿名函數,可以把 Lambda 表達式理解為是一段可以傳遞的代碼(將代碼像數據一樣傳遞)。雖然在 JVM 規範規定一切都是類,但其幕後執行的各種操作使得 Lambda 看起來像是函數。因此我們可以大膽假設 Lambda 表達式產生的就是一個函數,而不是類。

Lambda 的基本語法有是:(參數) -> {方法體}

  • 其中 -> 可以視為將參數傳遞給方法體使用的一個中間橋樑
  • 左側為表達式的參數列表。使用括號包裹參數,當只有一個參數時,可以不需要括號,如果沒有參數,則必須使用括號表示空參數列表。參數列表的數據類型可以省略不寫,因為 Java 的編譯器可以幫助我們根據上下文推斷數據類型
  • 右側為表達式中所需執行的功能。方法體如果只有單行,可以省略花括號,此時執行結果自動轉化為 Lambda 表達式的放回值,使用 return 關鍵字是非法的;如果方法體有多行,則必須放在花括號中,這時如果有返回值,就需要使用 return

Lambda 表達式能產生比匿名內部類更易讀的代碼,因此我們應該儘可能使用 Lambda 表達式。回到之前的例子,我們可以用 Lambda 表達式來替換匿名內部類。

public interface MyPredicate<> {
    boolean predicate(T t);
}


// 根據傳入的 MyPredicate 對象來實現不同的過濾邏輯
public List<Employee> filterEmployee(List<Employee> list, MyPredicate<Employee> mp) {
    List<Employee> emps = new ArrayList<>();
    for(Employee emp : list) {
        if(mp.predicate(emp)) {
            emps.add(emp);
        }
    }
    return emps;
}

public void test(List<Employee> list) {
    // 使用 Lambda 表達式
    List<Employee> res = filterEmployee(list, e -> e.getAge() <= 5000);
}

Lambad 表達式通常比匿名內部類產生更易讀的代碼,因此我們應該儘可能使用 Lambda 表達式。

如果我們想編寫遞歸的 Lambda 表達式,必須注意:

方法引用

Lambda 表達式可以幫助我們實現僅調用方法,而不做其他多餘動作(如創建對象)的目的,而有些情況下,已經存在能滿足需求的方法,我們可以不必再編寫 Lambda 表達式,而通過方法引用直接使用該方法。可以理解為方法引用是 Lambda 表達式的另一種表現形式。

方法引用的組成:類名或對象名,後面跟 ::,然後跟方法名稱,如果要分類的話,可以用如下組合:

  • 引用靜態方法 className::staticMethod

  • 引用某個對象的實例方法 instance::instanceMethod

  • 引用某個類型的任意對象的實例方法 className::instanceMethod

  • 引用構造方法 className::new

interface Callable {
	void call(String s);
}

class Describe {
    void show(String msg) {
        System.out.println(msg);
    }
}

public class MethodReferences {
    
    static void hello(String name) {
        System.out.println("Hello, " + name);
    }

    public static void main(String[] args) {
		// 對象名:: 方法名稱
        Describe d = new Describe();
        Callable c = d::show;
        c.call("call()");
		// 類名::方法名
        c = MethodReferences::hello;
        c.call("Bob");
    }
}

要注意的是,方法引用的簽名(參數類型和返回類型)必須符合 Callable 的 call() 的簽名。上述代碼我沒有演示 className::instanceMethodclassName::new 的情況,這兩個有點特殊,待會再介紹。

Runnable 接口

通過之前的學習,我們發現 Runnable 接口也符合特殊的單方法接口格式:它的 run() 方法不帶參數,也沒有返回值,因此我們可以使用 Lambda 表達式和方法引用作為 Runnable

class Go {
    static void go() {
        System.out.println("thread go");
    }
}

public class RunnableMethodReference {
    public static void main(String[] args) {
		// 匿名內部類方式
        new Thread(new Runnable() {
            public void run() {
                System.out.println("Anonymous");
            }
        }).start();
		// Lambda 表達式方式
        new Thread(
            () -> System.out.println("lambda")
        ).start();
		// 方法引用方式
        new Thread(Go::go).start();
    }
}

未綁定的方法引用

未綁定的方法引用是指沒有關聯對象的普通(非靜態方法),使用未綁定的引用,我們必須先提供對象

class X {
    String f() { return "X::f()"; }
}

interface MakeString {
    String make();
}

interface TransformX {
    String transform(X x);
}

public class UnboundMethodReference {
    public static void main(String[] args) {
        // MakeString ms = X::f; // 無法通過編譯
        TransformX sp = X::f;
        X x = new X();
        System.out.println(sp.transform(x));
        System.out.println(x.f());	// 同等效果
    }
}

我們看到在 MakeString ms = X::f; 中,即使 make()f() 有相同的方法簽名,卻無法通過編譯。這是因為實際上還有另一個隱藏參數 this 沒有考慮,你不能在沒有 X 對象的情況下調用 f(),因為它尚未綁定到對象。

要解決這個問題,我們需要一個 X 對象,所以我們的接口需要一個額外的參數如 TransformX,用來接收一個 X 對象。同樣的,在調用 transform(X x) 方法時,也必須傳遞一個 X 對象作為參數。如果你的方法有多個參數,就以第一個參數接受 this 的模式來處理。

構造函數引用

還可以捕獲構造函數的引用,然後通過引用去調用該構造函數。

class Dog {
    String name;
    int age;
    Dog() { name = "stray"; }
    Dog(String nm) { name = nm; }
    Dog(String nm, int yrs) { name = nm; age = yrs; }
}

interface MakeNoArgs {
    Dog make();
}

interface Make1Arg {
    Dog make(String name);
}

interface Make2Args {
    Dog make(String name, int age);
}

public class CtorReference {
    public static void main(String[] args) {
        MakeNoArgs mna = Dog::new;
        Make1Arg m1a = Dog::new;
        Make2Args m2a = Dog::new;

        Dog dn = mna.make();
        Dog d1 = m1a.make("Comet");
        Dog d2 = m2a.make("Ralph", 4);
    }
}

函數式接口

接口中只有一個抽象方法的接口,稱為函數式接口,可以使用註解 @FunctionalInterface 檢查一個接口是否符合函數式接口的規範。

Lambda 表達式和方法引用都要賦值給對應的函數式接口引用。Java8 提供了一組 java.util.function 包,它包含一組完整的函數式接口,一般情況下,我們可以直接使用,而不需要自己再定義。

Java 為我們提供了內置的四大核心函數式接口:

  • 消費型接口

    有參數,無返回值類型的接口

    @FunctionalInterface
    public interface Consumer<T> {
    
        void accept(T t);
    }
    
  • 供給型接口

    只有產出,沒有輸入,就是只有返回值,沒有入參

    @FunctionalInterface
    public interface Supplier<T> {
    
        T get();
    }
    
  • 函數型接口

    既有入參,也有返回值,T 表示函數的參數類型,R 表示函數的返回類型

    @FunctionalInterface
    public interface Function<T, R> {
    
        R apply(T t);
    }
    
  • 斷言型接口

    輸入一個參數,返回一個 boolean 類型的返回值

    @FunctionalInterface
    public interface Predicate<T> {
    
        boolean test(T t);
    }
    

除了上述的四個核心內置接口,Java 還為我們提供其他常用的函數式接口,如 BiFunction<T, U, R> 也是函數型接口,但可以接收兩個參數,我們可以根據需要去查閱 API 文檔。

函數組合

意為多個組合成新的函數,一些 java.util.function 接口包含支持函數組合的方法

  • andThen(Function<? super R,? extends V> after)

    返回一個組合函數,前一個函數的結果作為後一個函數的入參

  • compose(Function<? super V,? extends T> before)

    返回一個組合函數,後一個函數首先處理原始入參,再將結果交給前一個函數處理

  • and(Predicate<? super T> other)

    返回一個組合的謂詞,表示該謂詞與另一個謂詞的短路邏輯與

  • or(Predicate<? super T> other)

    返回一個組合的謂詞,表示該謂詞與另一個謂詞的短路邏輯或

  • negate()

    返回表示此謂詞的邏輯否定的謂詞

閉包

考慮一個函數,x 是 其中的一個入參,i 則是其中的一個局部變量,返回一個 Lambda 表達式

public class Closure {
    IntSupplier makeFun(int x) {
        int i = 0;
        return () -> x + i;
    }
}

我們知道,函數的入參的局部變量只在方法的生命周期內有效,正常情況下,當 makeFun(int x) 方法執行完後,x 和 i 就會消失,但它返回的 Lambda 表達式卻依然保存着 x 和 i 的值。相當於 makeFun(int x) 返回的 IntSupplier 關住了 x 和 i

另外要注意的一點是:被 Lambda 表達式引用的局部變量必須是 final 或是等同 final 效果的。所謂等同 final,意思是即使你沒有明確聲明變量是 final,但因變量值沒被改變過而實際上有了 final 同等的效果。Java8 默認 Lambda 中的局部變量具有等同 final 效果。

柯里化

柯里化意為:將一個多參數的函數,轉換為一系列單參數函數

public class CurryingAndPartials {
    // 未柯里化
    static String uncurried(String a, String b) {
        return a + b;
    }
    public static void main(String[] args) {
        // 柯里化的函數
        // a -> b -> a + b,意思是傳入參數 a,返回 b -> a + b 的函數
        // 由於 Lambda 表達式的閉包特性,b -> a + b 中的 a 是有保存值的
        Function<String, Function<String, String>> sum = a -> b -> a + b;

        System.out.println(uncurried("Hi ", "Ho"));
        
        Function<String, String> hi = sum.apply("Hi ");
        System.out.println(hi.apply("Ho"));

        Function<String, String> sumHi = sum.apply("Hup ");
        System.out.println(sumHi.apply("Ho"));
        System.out.println(sumHi.apply("Hey"));
    }
}

柯里化的目的是通過提供一個參數來創建一個新函數,根據上述的例子,我們可以通過添加級別來柯里化具有更多參數的函數

Tags: