Java中的七種函式程式設計技術 - foojay

banq發表於2021-05-13

根據維基百科:函數語言程式設計是一種程式設計範例-一種構建計算機程式的結構和元素的樣式-會將計算視為對數學函式的評估,並避免更改狀態和可變資料。
因此,在函數語言程式設計中,有兩個非常重要的規則
  • 無資料突變:這意味著在建立資料物件後不應更改它。
  • 無隱式狀態:應避免隱藏/隱式狀態。在函數語言程式設計狀態下,不消除狀態,而是使其可見和顯式

這表示:
  • 無副作用:功能或操作不得在其功能範圍之外更改任何狀態。即,一個函式應僅將一個值返回給呼叫者,並且不應影響任何外部狀態。這意味著程式更易於理解。
  • 僅純函式:功能程式碼是冪等的。函式應僅基於傳遞的引數返回值,並且不應該影響(副作用)或依賴於全域性狀態。對於相同的引數,此類函式始終會產生相同的結果。

 

一等和高階函式
一等函式(作為一流公民的函式)意味著您可以將函式分配給變數,將函式作為引數傳遞給另一個函式,或者從另一個函式返回一個函式。不幸的是,Java不支援此功能,因此使得諸如閉包,柯里化和高階函式之類的概念不太容易編寫。
在Java中,最接近一等函式的是Lambda表示式。也有一些內建的功能介面,如Function,Consumer,Predicate,Supplier等java.util.function下可用於函式程式設計軟體包。
僅當一個函式將一個或多個函式作為引數或結果返回另一個函式時,才可以將其視為高階函式。我們在Java中獲得的最接近高階函式的方法是使用Lambda表示式和內建的Functional介面。
這不是執行高階函式的最好方法,但這就是Java中的樣子,而且還不錯。

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

       //我們將陣列和FnFactory的匿名內部類例項作為mapForEach方法的引數傳遞。
        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]
    }

    //該方法將陣列和FnFactory的例項作為引數
    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 //不會做任何事情,只是提供資訊而已。.
    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");
        //我們將陣列和一個lambda表示式作為引數傳遞給mapForEach方法。
        var out = mapForEach(list, it -> it.length());
       //這可以進一步簡化為“ mapForEach(list,String :: length);”,我正在編寫擴充套件版本以提高可讀性
        System.out.println(out); // [6, 5, 6, 5]
    }

   //該方法將一個陣列和一個Function例項作為引數(我們已將自定義介面替換為內建介面)
    static <T, S> ArrayList<S> mapForEach(List<T> arr, Function<T, S> fn) {
        var newArray = new ArrayList<S>();
        //我們正在執行Function例項中的方法
        arr.forEach(t -> newArray.add(fn.apply(t)));
        return newArray;
    }
}

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

public class ClosureSample {
     //這是一個返回函式介面例項的高階函式
    Function<Integer, Integer> add(final int x) {
       //這是一個閉包,即一個包含Function介面的匿名內部類例項的變數
        //使用外部作用域中的變數
        var 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;
            }
        };
        //這裡返回閉包函式例項
        return partial;
    }

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

        //我們正在使用add方法來建立更多變數
        var add10 = sample.add(10);
        var add20 = sample.add(20);
        var 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 {
   //這是一個返回函式介面例項的高階函式
    Function<Integer, Integer> add(final int x) {
        // lambda表示式在這裡作為閉包返回
       //變數x從此方法的外部範圍獲得,該方法宣告為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
        var add10 = sample.add(10);
        var add20 = sample.add(20);
        var 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:

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

//這可以簡化為“ Collections.sort(list,Comparator.naturalOrder());”,我正在編寫擴充套件版本以提高可讀性
Collections.sort(list, (String a, String b) -> {
    return a.compareTo(b);
});

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

 

純函式
正如我們已經看到的,純函式應該僅基於傳遞的引數返回值,而不應該影響或依賴於全域性狀態。在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或編寫遞迴函式來實現。讓我們看一個計算數字階乘的例子。
在傳統的遞迴方法中:

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是嚴格的立即賦值評估,但運算元 &&, || 和?: 是慵懶惰賦值。在編寫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;
    }
}


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

public class LazySample {
    public static void main(String[] args) {
        //這是一個lambda表示式,表現為閉包
        UnaryOperator<Integer> add = t -> {
            System.out.println("executing add");
            return t + t;
        };
      //這是一個lambda表示式,表現為閉包
        UnaryOperator<Integer> multiply = t -> {
            System.out.println("executing multiply");
            return t * t;
        };
        //傳遞Lambda閉包而不是普通函式
        System.out.println(addOrMultiply(true, add, multiply, 4));
        System.out.println(addOrMultiply(false, add, multiply, 4));
    }

    //這是一個高階函式
    static <T, R> R addOrMultiply(
            boolean add, Function<T, R> onAdd,
            Function<T, R> onMultiply, T t
    ) {
        // Java的?會懶惰計算表示式,因此僅執行所需的方法
        return (add ? onAdd.apply(t) : onMultiply.apply(t));
    }
}

 

型別系統
Java具有強大的型別系統,並且隨著var關鍵字的引入,它現在也具有相當不錯的型別推斷。與其他函式程式語言相比,唯一缺少的是case類。有針對將來的Java版本的值類和案例類的建議。希望他們能做到。
 

引用透明
從維基百科:函式程式沒有賦值語句,也就是說,函式程式中的變數值一旦定義就不會改變。這消除了任何副作用的可能性,因為任何變數都可以在執行的任何時候用其實際值替換。因此,函式程式是引用透明的。
不幸的是,沒有很多方法可以限制Java中的資料突變,但是透過使用純函式以及使用其他概念明確避免資料突變和重新分配,我們可以在前面看到這一點。對於變數,我們可以使用final關鍵字,它是不可訪問的修飾符,以避免因重新分配而引起的突變。
例如,以下將在編譯時產生錯誤:

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

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


但是,當變數持有對其他物件的引用時,這將無濟於事,例如,以下更改將與final關鍵字無關。

final var list = new ArrayList<>();

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

final關鍵字允許對引用變數的內部狀態進行更改,因此從功能程式設計的角度來看,final關鍵字僅對常量和捕獲重新分配有用。
 

資料結構
使用函式程式設計技術時,建議使用函式資料型別,例如堆疊,對映和佇列。因此,在函式程式設計中作為資料儲存,對映比陣列或雜湊集更好。
  

結論
對於那些試圖在Java中應用某些函數語言程式設計技術的人來說,這只是一個介紹。用Java可以做的事情還很多,而Java 8新增了很多API,使使用Java進行函式性程式設計變得容易,例如流API,Optional介面,功能性介面等。
 

相關文章