Java中的函式程式設計技術 - Deepu K Sasidharan

banq發表於2019-08-01

關於函數語言程式設計(FP)有很多炒作,很多很酷的孩子都在做這件事,但它並不是一顆銀彈。與其他程式設計正規化/風格一樣,函數語言程式設計也有其優點和缺點,人們可能更喜歡一種正規化而不是另一種正規化。如果您是Java開發人員並想進入函數語言程式設計,請不要擔心,因為Java,您不必學習Haskell或Clojure等函數語言程式設計語言(甚至是Scala或JavaScript,儘管它們不是純函數語言程式設計語言)

什麼是函數語言程式設計? 
根據維基百科,
函數語言程式設計是一種程式設計正規化 - 一種構建計算機程式結構和元素的方式 - 將計算視為數學函式的評估並避免改變狀態和可變資料。
因此,在函數語言程式設計中,有兩個非常重要的規則

  • 無資料突變:這意味著資料物件在建立後不應更改。
  • 沒有隱式狀態:應該避免隱藏/隱含狀態。在函數語言程式設計中,狀態不會被消除,相反,它是可見的和顯式的

這意味著:
  • 無副作用:功能或操作不應更改其功能範圍之外的任何狀態。即,函式應該只向呼叫者返回一個值,不應該影響任何外部狀態。這意味著程式更容易理解。
  • 僅限純函式:函式程式碼是冪等的。函式應僅基於傳遞的引數返回值,不應影響(副作用)或依賴於全域性狀態。這些函式總是為相同的引數產生相同的結果。

使用函數語言程式設計並不意味著全部或全部,您總是可以使用函數語言程式設計概念來補充物件導向的概念,尤其是在Java中。無論您使用何種範例或語言,都可以儘可能利用函數語言程式設計的優勢。

Java中的函式程式設計
那麼讓我們看看如何在Java中應用上面的一些函數語言程式設計概念。我們將使用Java 11,因為它目前是LTS版本。
作為一等公民的函式意味著您可以將函式賦值給變數,將函式作為引數傳遞給另一個函式或從另一個函式返回函式。遺憾的是,Java不支援這一點,因此使得閉包,currying和高階函式等概念的編寫不太方便。
與Java中最接近的第一類函式是Lambda表示式。還有像一些內建的功能介面Function,Consumer,Predicate,Supplier等下java.util.function可用於函式程式設計軟體包。
只有當函式將一個或多個函式作為引數或者作為結果返回另一個函式時,才能將函式視為高階函式。我們可以在Java中獲得的最接近高階函式的是使用Lambda表示式和內建的Functional介面。
這不是做高階函式最好看的方法,但這就是它在Java中的表現,而不是那麼糟糕的IMO。

public class HocSample {
    public static void main(String args) {
        var list = Arrays.asList("Orange", "Apple", "Banana", "Grape");

        // we are passing an array and an anonymous inner class instance of FnFactory as arguments to mapForEach method.
        var out = mapForEach(list, new FnFactory<String, Object>() {
            @Override
            public Object execute(final String it) {
                return it.length();
            }
        });
        System.out.println(out); // [6, 5, 6, 5]
    }

    // The method takes an array and an instance of FnFactory as arguments
    static <T, S> ArrayList<S> mapForEach(List<T> arr, FnFactory<T, S> fn) {
        var newArray = new ArrayList<S>();
        // We are executing the method from the FnFactory instance
        arr.forEach(t -> newArray.add(fn.execute(t)));
        return newArray;
    }

    @FunctionalInterface // this doesn't do anything it is just informative.
    public interface FnFactory<T, S> {
        // The interface defines the contract for the anonymous class
        S execute(T it);
    }
}

幸運的是,實際上可以使用內建Function介面和lambda表示式語法進一步簡化上面的示例。

public class HocSample {
    public static void main(String args) {
        var list = Arrays.asList("Orange", "Apple", "Banana", "Grape");
        // we are passing the array and a lambda expression as arguments to mapForEach method.
        var out = mapForEach(list, it -> it.length()); 
        // This can be further simplified to "mapForEach(list, String::length);", I'm writing the expanded version for readability
        System.out.println(out); // [6, 5, 6, 5]
    }

    // The method takes an array and an instance of Function as arguments (we have replaced the custom interface with the built-in one)
    static <T, S> ArrayList<S> mapForEach(List<T> arr, Function<T, S> fn) {
        var newArray = new ArrayList<S>();
        // We are executing the method from the Function instance
        arr.forEach(t -> newArray.add(fn.apply(t)));
        return newArray;
    }
}


使用這些概念和lambda表示式,我們可以編寫閉包和currying,如下所示:

public class ClosureSample {
    // this is a higher-order-function that returns an instance of Function interface
    Function<Integer, Integer> add(final int x) {
        // this is a closure, i.e, a variable holding an anonymous inner class instance of the Function interface
        // which uses variables from the outer scope
        Function<Integer, Integer> partial = new Function<Integer, Integer>() {
            @Override
            public Integer apply(Integer y) {
                // variable x is obtained from the outer scope of this method which is declared as final
                return x + y;
            }
        };
        // The closure function instance is returned here
        return partial;
    }

    public static void main(String args) {
        ClosureSample sample = new ClosureSample();

        // we are currying the add method to create more variations
        Function<Integer, Integer> add10 = sample.add(10);
        Function<Integer, Integer> add20 = sample.add(20);
        Function<Integer, Integer> add30 = sample.add(30);

        System.out.println(add10.apply(5)); // 15
        System.out.println(add20.apply(5)); // 25
        System.out.println(add30.apply(5)); // 35
    }
}

可以使用如下的lambda表示式進一步簡化這一過程:

public class ClosureSample {
    // this is a higher-order-function that returns an instance of Function interface
    Function<Integer, Integer> add(final int x) {
        // The lambda expression is returned here as closure
        // variable x is obtained from the outer scope of this method which is declared as final
        return y -> x + y;
    }

    public static void main(String args) {
        ClosureSample sample = new ClosureSample();

        // we are currying the add method to create more variations
        Function<Integer, Integer> add10 = sample.add(10);
        Function<Integer, Integer> add20 = sample.add(20);
        Function<Integer, Integer> add30 = sample.add(30);

        System.out.println(add10.apply(5));
        System.out.println(add20.apply(5));
        System.out.println(add30.apply(5));
    }
}


Java中還有許多內建的高階函式,例如這裡 java.util.Collections的sort方法

List<String> list = Arrays.asList("Apple", "Orange", "Banana", "Grape");

// This can be simplified as "Collections.sort(list, Comparator.naturalOrder());", I'm writing the expanded version for readability
Collections.sort(list, (String a, String b) -> {
    return a.compareTo(b);
});

System.out.println(list); // [Apple, Banana, Grape, Orange]

Java流API還提供了許多有趣的高階函式,如forEach,map等。

純粹的函式
正如我們所看到的,純函式應該僅基於傳遞的引數返回值,並且不應該影響或依賴於全域性狀態。除了某些涉及檢查過的異常的情況外,可以在Java中執行此操作。
這很簡單,下面這是一個純函式。它將始終為給定輸入返回相同的輸出,並且其行為具有高度可預測性。如果需要,我們可以安全地快取該方法結果。

public static int sum(int a, int b) {
    return a + b;
}

如果我們在此函式中新增額外的行,則行為變得不可預測,因為它現在具有影響外部狀態的副作用。

static Map map = new HashMap<String, Integer>();

public static int sum(int a, int b) {
    var c = a + b;
    map.put(a + "+" + b, c);
    return c;
}

因此,儘量保持您的函式簡單明瞭。

遞迴
函數語言程式設計有利於迴圈遞迴。在Java中,這可以透過使用流API或編寫遞迴函式來實現。讓我們看一個計算數字階乘的例子。
我還使用JMH對這些進行了基準測試,並提到了下面的納秒/操作
在傳統的遞迴方法中:

public class FactorialSample {
    // benchmark 9.645 ns/op
    static long factorial(long num) {
        long result = 1;
        for (; num > 0; num--) {
            result *= num;
        }
        return result;
    }

    public static void main(String args) {
        System.out.println(factorial(20)); // 2432902008176640000
    }
}

使用如下的遞迴可以完成相同的操作,這在函數語言程式設計中是有利的。

public class FactorialSample {
    // benchmark 19.567 ns/op
    static long factorialRec(long num) {
        return num == 1 ? 1 : num * factorialRec(num - 1);
    }

    public static void main(String args) {
        System.out.println(factorialRec(20)); // 2432902008176640000
    }
}

遞迴方法的缺點是,與大多數時候的迭代方法相比,它會更慢(我們的目標是程式碼簡單性和可讀性)並且可能導致堆疊溢位錯誤,因為每個函式呼叫都需要儲存為堆疊的框架。為了避免這種尾遞迴是首選,特別是當遞迴過多次時。在尾遞迴中,遞迴呼叫是函式執行的最後一件事,因此編譯器不儲存函式堆疊幀。大多數編譯器可以最佳化迭代程式碼最佳化尾遞迴程式碼,從而避免效能損失。遺憾的是,Java編譯器不執行此最佳化。
現在使用尾遞迴,相同的函式可以寫成如下,但Java並沒有對此進行最佳化,儘管有一些變通方法,但它仍然在基準測試中表現更好。

public class FactorialSample {
    // benchmark 16.701 ns/op
    static long factorialTailRec(long num) {
        return factorial(1, num);
    }

    static long factorial(long accumulator, long val) {
        return val == 1 ? accumulator : factorial(accumulator * val, val - 1);
    }

    public static void main(String args) {
        System.out.println(factorialTailRec(20)); // 2432902008176640000
    }
}

我們也可以使用Java流庫進行遞迴,但目前它的遞迴速度比正常慢。

public class FactorialSample {
    // benchmark 59.565 ns/op
    static long factorialStream(long num) {
        return LongStream.rangeClosed(1, num)
                .reduce(1, (n1, n2) -> n1 * n2);
    }

    public static void main(String args) {
        System.out.println(factorialStream(20)); // 2432902008176640000
    }
}

在編寫Java程式碼時考慮使用流API或遞迴以實現可讀性和不變性,但如果效能至關重要或迭代次數很多,則使用標準迴圈。

懶計算
惰計算或非嚴格計算是延遲表示式計算直到需要其結果。在一般情況下,Java沒有嚴格的這樣評估計算,但對於像運算元&&,||而?:它確實懶惰的延遲計算。在編寫java程式碼時,我們可以利用它來進行惰性求值。

Java為熱切評估計算的示例。

public class EagerSample {
    public static void main(String args) {
        System.out.println(addOrMultiply(true, add(4), multiply(4))); // 8
        System.out.println(addOrMultiply(false, add(4), multiply(4))); // 16
    }

    static int add(int x) {
        System.out.println("executing add"); // this is printed since the functions are evaluated first
        return x + x;
    }

    static int multiply(int x) {
        System.out.println("executing multiply"); // this is printed since the functions are evaluated first
        return x * x;
    }

    static int addOrMultiply(boolean add, int onAdd, int onMultiply) {
        return (add) ? onAdd : onMultiply;
    }
}

這將產生以下輸出,我們可以看到兩個函式始終執行

executing add
executing multiply
8
executing add
executing multiply
16

我們可以使用lambda表示式和高階函式將其重寫為一個延遲評估的版本:

public class LazySample {
    public static void main(String args) {
        // This is a lambda expression behaving as a closure
        Function<Integer, Integer> add = t -> {
            System.out.println("executing add");
            return t + t;
        };
        // This is a lambda expression behaving as a closure
        Function<Integer, Integer> multiply = t -> {
            System.out.println("executing multiply");
            return t * t;
        };
        // Lambda closures are passed instead of plain functions
        System.out.println(addOrMultiply(true, add, multiply, 4));
        System.out.println(addOrMultiply(false, add, multiply, 4));
    }

    // This is a higher-order-function
    static <T, R> R addOrMultiply(
            boolean add, Function<T, R> onAdd,
            Function<T, R> onMultiply, T t
    ) {
        // Java evaluates expressions on ?: lazily hence only the required method is executed
        return (add ? onAdd.apply(t) : onMultiply.apply(t));
    }
}

輸出如下,我們可以看到只執行了所需的函式:

executing add
8
executing multiply
16


型別系統
Java有一個強大的型別系統,並且隨著var關鍵字的引入,它現在也有相當不錯的型別推斷。與其他函數語言程式設計語言相比,唯一缺少的是case類。有關未來Java版本的值類和案例類的建議。讓我們希望他們成功。
 引用透明度:函式程式沒有賦值語句,也就是說,函式程式中的變數值一旦定義就不會改變。這消除了任何副作用的可能性,因為任何變數都可以在任何執行點用其實際值替換。因此,函式程式是引用透明的。
遺憾的是,限制Java中的資料變異的方法並不多,但是透過使用純函式並使用我們之前看到的其他概念明確地避免資料突變和重新分配,可以實現這一點。對於變數,我們可以使用final關鍵字作為非訪問修飾符來避免重新分配的突變。
例如,下面的程式碼會在編譯時產生錯誤:

final var list = Arrays.asList("Apple", "Orange", "Banana", "Grape");

list = Arrays.asList("Earth", "Saturn");


但是,當變數持有對其他物件的引用時,這將無濟於事,例如,無論最終關鍵字如何,以下變異都將起作用。

final var list = new ArrayList<>();

list.add("Test");
list.add("Test 2");


finalkeyword允許對引用變數的內部狀態進行變異,因此從函數語言程式設計透檢視中,final關鍵字僅對常量有用並且可以捕獲重新分配。

資料結構
使用函數語言程式設計技術時,鼓勵使用堆疊,對映和佇列等函式資料型別。因此,對映在函數語言程式設計中比陣列或雜湊集更好地作為資料儲存。

結論
Java 8新增了大量API,以便在Java中輕鬆進行函數語言程式設計,如流API,可選介面,功能介面等。正如我之前所說,函數語言程式設計不是一個靈丹妙藥,但它為更易理解,可維護和可測試的程式碼提供了許多有用的技術。它可以與命令式和麵向物件的程式設計風格完美地共存。

相關文章