博客 / 詳情

返回

從頭學Java17-Lambda表達式

Lambda表達式

這一系列教程,旨在介紹 lambda 的概念,同時逐步教授如何在實踐中使用它們。

回顧表達式、語句

表達式

表達式由變量、運算符和方法調用組成,其計算結果為單個值。您已經看到了表達式的示例,如下面的代碼所示:

int cadence = 0;
anArray[0] = 100;
System.out.println("Element 1 at index 0: " + anArray[0]);

int result = 1 + 2; // result is now 3
if (value1 == value2)
    System.out.println("value1 == value2");

表達式返回值的數據類型取決於其中使用的元素。cadence = 0返回 int,因為賦值運算符返回與其左側數據類型相同的值。從其他表達式中可以看出,也可以返回其他類型。

Java 編程語言允許您從各種較小的表達式構造複合表達式,只要表達式的一部分所需的數據類型與另一部分的數據類型匹配即可。下面是複合表達式的示例:

1 * 2 * 3

在此特定示例中,表達式的計算順序並不重要,因為乘法的結果與順序無關。但並非所有表達式都是如此。例如,以下表達式給出不同的結果,具體取決於您是先執行加法運算還是除法:

x + y / 100    // ambiguous

您可以使用括號,準確指定表達式的計算方式 。例如,要使前面的表達式明確,您可以編寫以下內容:

(x + y) / 100  // unambiguous, recommended

如果未顯式指示要執行的操作的順序,則順序由分配給表達式中使用的運算符的優先級確定。例如,除法的優先級高於加法。因此,以下兩個語句是等效的:

x + y / 100   // ambiguous

x + (y / 100) // unambiguous, recommended

編寫複合表達式時,要明確,並用括號指示應首先計算哪些運算符。這種做法使代碼更易於閲讀和維護。

語句

語句大致相當於自然語言中的句子,構成一個完整的執行單元。以下類型的表達式可以通過;來組成語句。

  • 賦值表達式
  • 任何++--
  • 方法調用
  • 對象創建

    此類語句稱為表達式語句。下面是一些示例。

// assignment statement
aValue = 8933.234;

// increment statement
aValue++;

// method invocation statement
System.out.println("Hello World!");

// object creation statement
Bicycle myBike = new Bicycle();

除了表達式語句,還有另外兩種類型的語句:聲明語句和控制流語句。聲明語句聲明一個變量。您已經看到許多聲明語句的示例:

// declaration statement
double aValue = 8933.234;

最後,控制流語句調節語句的執行順序。

何時使用嵌套類、本地類、匿名類和 Lambda

在嵌套類、本地類、匿名類和 Lambda之間進行選擇

嵌套類使您能夠對僅在一個位置使用的類進行邏輯分組,增加封裝的使用,並創建更可讀和可維護的代碼。本地類、匿名類和 Lambda也賦予了這些優勢;但是,它們旨在用於更具體的情況:

  1. 本地類:如果需要創建類的多個實例、訪問其構造函數或引入新的命名類型(例如,因為稍後需要調用其他方法),請使用它。
  2. 匿名類:如果需要聲明字段或其他方法,請使用它。
  3. lambda:

    • 如果要封裝要傳遞給其他代碼的單個行為單元,請使用它。例如,如果您希望對集合的每個元素、進程完成或進程遇到錯誤時執行特定操作,可以使用 Lambda。
    • 如果您需要函數接口的簡單實例,並且上述條件都不適用(例如,您不需要構造函數、命名類型、字段或其他方法),請使用它。
  4. 嵌套類:如果你的要求與本地類的要求類似,希望使類型更廣泛地可用,並且不需要訪問局部變量或方法參數,請使用它。
  5. 如果您需要訪問外層實例的非public字段和方法,請使用非靜態嵌套類(或內部類)。如果不需要,請使用靜態嵌套類。

開始編寫您的第一個 lambda

2014年,Java SE 8引入了lambda的概念。在之前,您可能還記得匿名類的概念。也許您聽説過 Lambda是編寫匿名類實例的另一種更簡單的方法,在某些實際場景下。

如果不記得,那麼你可能聽説過,見到過匿名類,並且可能害怕這種晦澀難懂的語法。

好消息是:您不需要通過匿名類來了解如何編寫 Lambda。此外,在許多情況下,由於在 Java 語言中添加了 lambda,您不再需要匿名類。

編寫 Lambda可以分解為三個步驟:

  • 確定要編寫的 Lambda的類型
  • 找到正確的實現方法
  • 實現它。

這真的是它的全部。讓我們詳細瞭解這三個步驟。

識別 Lambda的類型

在 Java 語言中,一切都有一個類型,這種類型在編譯時是已知的。因此,總是可以找到 Lambda的類型。它可以是變量、字段、方法參數或方法的返回類型。

Lambda的類型有一個限制:它必須是一個函數接口functional interface。因此,不實現函數接口的匿名類不能編寫為 Lambda。

函數接口的完整定義有點複雜。此時你需要知道的是,函數接口是一個只有一個抽象方法的接口。

您應該知道,從 Java SE 8 開始,接口中允許使用具體方法。可以是實例方法,稱為默認方法,也可以是靜態方法。這些方法都不算,因為它們不是抽象方法。

是否需要在接口上添加註解@FunctionalInterface才能使其正常運行?

不是的。此註解可確保接口是函數的。將此註解放在非函數接口類型上,編譯器將引發錯誤。

函數接口示例

讓我們看看從JDK API中獲取的一些示例。

@FunctionalInterface
public interface Runnable {
    public abstract void run();
}

Runnable 接口確實是函數式的,因為只有一個抽象方法。@FunctionalInterface只是輔助,但不是必需。

@FunctionalInterface
public interface Consumer<T> {

    void accept(T t);

    default Consumer<T> andThen(Consumer<? super T> after) {
        // the body of this method has been removed
    }
}

Consumer 接口也是函數式的:它有一個抽象方法和一個默認的、不計數的具體方法。

@FunctionalInterface
public interface Predicate<T> {

    boolean test(T t);

    default Predicate<T> and(Predicate<? super T> other) {
        // the body of this method has been removed
    }

    default Predicate<T> negate() {
        // the body of this method has been removed
    }

    default Predicate<T> or(Predicate<? super T> other) {
        // the body of this method has been removed
    }

    static <T> Predicate<T> isEqual(Object targetRef) {
        // the body of this method has been removed
    }

    static <T> Predicate<T> not(Predicate<? super T> target) {
        // the body of this method has been removed
    }
}

Predicate 接口稍微複雜一些,但它仍然是一個函數接口:

  • 它有一個抽象方法
  • 它有三個不計算的默認方法
  • 它有兩個靜態方法,兩者都不算。

找到正確的實現

此時,您已經確定了需要編寫的lambda的類型,好消息是:您已經完成了最困難的部分。

Lambda是函數接口中那個唯一抽象方法的實現。因此,只需要找到此方法。

您可以花一分鐘時間在上一段的三個示例中查找它。

對於Runanble,它是:

public abstract void run();

對於Predicate接口,它是:

boolean test(T t);

對於Consumer接口,它是:

void accept(T t);

使用 Lambda正確的實現

編寫實現Predicate

現在是最後一部分:編寫 lambda 本身。您需要了解的是,您正在編寫的 Lambda是您找到的抽象方法的實現。使用 Lambda語法,您可以在代碼中很好地內聯此過程。

此語法由三個元素組成:

  • 參數塊;
  • 一個箭頭 ->
  • 方法體。

讓我們看看這方面的例子。假設您需要一個Predicate實例,該實例返回true,表示字符串正好包含 3 個字符。

  1. Lambda的類型是Predicate
  2. 你需要實現的方法是boolean test(String s))

然後你寫參數塊,直接複製(String s)

然後添加一個微不足道的箭頭:->

和方法體。您的結果應如下所示:

Predicate<String> predicate =
    (String s) -> {
        return s.length() == 3;
    };

簡化語法

然後可以簡化此語法,這歸功於編譯器可以推斷出許多內容,您無需編寫它們。

首先,編譯器知道您正在實現Predicate接口的抽象方法,並且知道此方法將 String 作為參數。所以可以簡化為。在這種情況下,如果只有一個參數,您甚至可以通過刪除括號來更進一步。然後,(String s)變為s 。如果您有多個參數或沒有參數,則應保留括號。

其次,方法主體中只有一行代碼。在這種情況下,您不需要大括號或return

所以最終實際上如下:

Predicate<String> predicate = s -> s.length() == 3;

這是個好實踐:保持lambda簡短,這樣它們就只是一行簡單易讀的代碼。

實現Consumer

在某些時候,人們可能想走口訣。你可能聽開發人員説“Consumer拿一個對象,什麼也不返回”。或者“當字符串正好有三個字符時,Predicate為真”。大多數情況下,Lambda、它實現的抽象方法和保存此方法的函數接口之間存在一點混淆。

但是,由於函數接口的抽象方法和它的 Lambda實現緊密地聯繫在一起,因此這種説法實際上完全有意義。沒關係,不會導致任何歧義。

讓我們編寫一個 lambda,它使用String並在System.out打印 .語法可以是這樣的:

Consumer<String> print = s -> System.out.println(s);

這裏我們直接編寫了 Lambda的簡化版本。

實現Runnable

實現 Runnable 原來是編寫 void run()) 的實現。此參數塊為空,因此要用括號。請記住:有且只有一個參數時,才能省略括號。

因此,讓我們編寫一個Runnable對象,告訴我們它正在運行:

Runnable runnable = () -> System.out.println("I am running");

調用 Lambda

讓我們回到前面的Predicate示例,如何用它來測試給定的字符串是否確實長度為 3?

好吧,儘管你用語法正確編寫了lambda,但你需要記住,這個lambda是接口Predicate的一個實例。此接口定義了一個名為 test()) 的方法,接受String並返回boolean

讓我們這樣寫:

List<String> retainStringsOfLength3(List<String> strings) {

    Predicate<String> predicate = s -> s.length() == 3;
    List<String> stringsOfLength3 = new ArrayList<>();
    for (String s: strings) {
        if (predicate.test(s)) {
            stringsOfLength3.add(s);
        }
    }
    return stringsOfLength3;
}

請注意您如何定義Predicate,像前面一樣。由於Predicate接口定義了方法boolean test(String),)通過Predicate類型的變量調用Predicate的方法完全合法。

編寫此代碼有更好的方法,您將在本教程後面看到。

因此,每次編寫 lambda 時,都可以調用在此 lamdba 實現的接口上定義的任何方法。調用抽象方法將調用 lambda 本身的代碼,因為此 lambda 是該方法的實現。調用默認方法將調用接口中編寫的代碼。lambda 無法覆蓋默認方法。

捕獲局部變量

一旦習慣了,寫 lambda 就會變得非常自然。它們很好地集成到集合框架、Stream API 和 JDK 的許多其他地方。從Java SE 8開始,lambda無處不在,非常好。

使用 lambda 也存在約束,您可能遇到一些編譯時錯誤。

讓我們考慮以下代碼:

int calculateTotalPrice(List<Product> products) {

    int totalPrice = 0;
    Consumer<Product> consumer =
        product -> totalPrice += product.getPrice();
    for (Product product: products) {
        consumer.accept(product);
    }
}

即使這段代碼看起來還好,嘗試編譯它會在totalPrice +=那裏報錯:

Lambda中使用的變量應該是final的,或實際final的

原因如下:lambda 無法修改在其主體外部定義的變量。可以讀取,只要它們是final ,即不可變的。訪問變量的過程稱為捕獲capturing:lambda 無法捕獲變量,它們只能捕獲。final變量實際上是一個值。

您已經注意到報錯消息告訴我們變量應該是final的,這是 Java 語言中的經典概念。它還告訴我們,變量可以是實際final的。這個概念在Java SE 8中被引入:即使你沒有顯式聲明一個變量final,編譯器也可以為你做。如果它看到這個變量是從 lambda 中讀取的,並且你沒有修改它,會友好的為你添加聲明。當然,這是在編譯的代碼中完成的,編譯器不會修改您的源代碼。這些變量不稱為final變量;而是實際final變量。這是一個非常有用的功能。

序列化 Lambda

Lambda實際是可以被序列化的。

為什麼要序列化 Lambda?好吧,Lambda可以存儲在字段中,並且可以通過構造函數或 setter 方法訪問該字段。然後,您可能在運行時在對象狀態下有一個 Lambda,而沒有意識到它。

因此為了跟已存在的可序列化類保持兼容, Lambda也可以序列化。

在應用程序中使用 Lambda

Java SE 8 中 Lambda的引入也帶來了對 JDK API 的重大重寫。在引入 lamdba 之後,JDK 8 中更新的類比引入泛型的 JDK 5 中更新的更多。

由於函數接口的定義非常簡單,許多現有接口無需修改即可實現函數化。現有代碼也是如此:如果您的應用程序中有 Java SE 8 之前編寫的接口,那麼不用碰,即可用 lambda 實現它們。

探索java.util.function

JDK 8 還引入了一個新的包:java.util.function,帶有函數接口,供您在應用程序中使用。這些函數接口在JDK API中也被大量使用,特別是在集合框架和Stream API中。此包位於 java.base 模塊。

這個包有 40 多個接口,一開始可能看起來有點嚇人。事實上,它主要圍繞四個接口來組織。

Supplier創建或提供對象

實現Supplier接口

第一個接口是Supplier接口。簡單説,Supplier不接受任何參數並返回一個對象。

也可以説:實現Supplier接口的 lambda 不接受任何參數並返回一個對象。口訣使事情更容易記住,只要不令人困惑。

這個接口非常簡單:它沒有默認或靜態方法,只有一個普通的 get()) 方法。這是這個接口:

@FunctionalInterface
public interface Supplier<T> {

    T get();
}

以下 lambda 就是此接口的實現:

Supplier<String> supplier = () -> "Hello Duke!";`

此 Lambda僅返回字符串Hello Duke!。還可以編寫一個Supplier,每次調用時都返回新對象:

Random random = new Random(314L);
Supplier<Integer> newRandom = () -> random.nextInt(10);

for (int index = 0; index < 5; index++) {
    System.out.println(newRandom.get() + " ");
}

調用該Supplier的 get()) 方法將調用 random.nextInt(),)並將生成隨機整數。由於這個隨機生成器的種子是固定為314L,你應該看到生成的以下隨機數:

1
5
3
0
2

請注意,此 lambda 正在從外圍作用域中捕獲一個random變量,使該變量實際上為final

使用Supplier

請注意您如何使用上一示例中的newRandom Supplier生成隨機數:

for (int index = 0; index < 5; index++) {
    System.out.println(newRandom.get() + " ");
}

調用Supplier接口的 get()) 方法會調用您的 lambda。

使用專用Supplier

Lambda用於處理應用程序中的數據。因此,Lambda的執行速度在 JDK 中至關重要。任何CPU週期能保存的都必須保存,因為它可能代表着實際應用程序中的重大優化。

遵循這一原則,JDK API 還提供Supplier接口的專用優化版本。

您可能已經注意到,我們的第二個示例提供了 Integer 類型,其中 Random.nextInt()) 方法返回一個int .因此,在您編寫的代碼中,有兩件事正在幕後發生:

  • Random.nextInt()) 返回的int首先自動裝箱成一個Integer;
  • 然後,分配給nextRandom變量時,此 Integer 自動拆箱。

自動裝箱是一種機制,通過該機制可以將int值直接分配給 Integer 對象:

int i = 12;
Integer integer = i;

在後台,將為您創建一個對象,包裝該值。

自動拆箱的作用恰恰相反。

Integer integer = Integer.valueOf(12);
int i = integer;

這種裝箱/拆箱不是免費的,雖然成本很小。但某些情況下,可能是不可接受的,需要儘量避免。

好消息是:JDK為您提供了IntSupplier接口。

@FunctionalInterface
public interface IntSupplier {

    int getAsInt();
}

您可以使用完全相同的代碼來實現上面接口:

Random random = new Random(314L);
IntSupplier newRandom = () -> random.nextInt();

對代碼的唯一修改是需要調用 getAsInt()) 而不是 get():)

for (int i = 0; i < 5; i++) {
    int nextRandom = newRandom.getAsInt();
    System.out.println("next random = " + nextRandom);
}

運行的結果是相同的,但這次沒有裝箱/拆箱:此代碼比前一個性能更高。

JDK為您提供了四個這樣的專用Supplier,以避免應用程序中不必要的裝箱/拆箱:IntSupplierBooleanSupplierLongSupplierDoubleSupplier

您將看到更多專用版本的函數接口來處理原始類型。他們的抽象方法有一個簡單的命名約定:採用主抽象方法的名稱(在Supplier的情況下為 get(),)並將返回的類型添加到其中。因此,對於Supplier接口,我們有:getAsBoolean()),getAsInt)(),getAsLong())和getAsDouble()。)

Consumer消費對象

實現和使用Consumer

第二個接口是Consumer接口。Consumer與Supplier相反:它接受參數但不返回任何東西。

這個接口稍微複雜一些:其中有默認方法,本教程稍後將介紹這些方法。讓我們專注於它的抽象方法:

@FunctionalInterface
public interface Consumer<T> {

    void accept(T t);

    // default methods removed
}

您已經實現過Consumer:

Consumer<String> printer = s -> System.out.println(s);

可以使用Consumer更新前面的示例:

for (int i = 0; i < 5; i++) {
    int nextRandom = newRandom.getAsInt();
    printer.accept("next random = " + nextRandom);
}

使用專用Consumer

假設您需要打印整數。然後你可以編寫以下Consumer:

Consumer<Integer> printer = i -> System.out.println(i);`

然後,您可能會遇到與Supplier示例相同的自動裝箱問題。在性能方面,這種裝箱/拆箱在您的應用程序中是否可以接受?

如果不能,請不要擔心,JDK為您提供了三個專用Consumer:IntConsumerLongConsumerDoubleConsumer。這三個Consumer的抽象方法遵循與Supplier相同的約定,返回的類型始終是 void,它們都命名為 accept)。

用BiConsumer消費兩個元素

JDK添加了Consumer<T>接口的另一個變體,接受兩個參數,很自然地稱為BiConsumer<T,U>。

@FunctionalInterface
public interface BiConsumer<T, U> {

    void accept(T t, U u);

    // default methods removed
}

下面是一個BiConsumer的例子:

BiConsumer<Random, Integer> randomNumberPrinter =
        (random, number) -> {
            for (int i = 0; i < number; i++) {
                System.out.println("next random = " + random.nextInt());
            }
        };

您可以使用此 biconsumer 以不同的方式編寫前面的示例:

randomNumberPrinter.accept(new Random(314L), 5));

BiConsumer<T,U>接口有三個專用版本來處理原始類型:ObjIntConsumer<T>ObjLongConsumer<T>ObjDoubleConsumer<T>

將Consumer傳遞給可迭代對象

集合框架的接口中添加了幾個重要的方法。其中一個將 Consumer 作為參數,非常有用:Iterable.forEach()) 方法。這裏有一個簡單的例子:

List<String> strings = ...; // really any list of any kind of objects
Consumer<String> printer = s -> System.out.println(s);
strings.forEach(printer);

最後一行代碼將Consumer應用於列表的所有對象。在這裏,它將簡單地在控制枱上一一打印。您將在後面部分中看到編寫此Consumer的另一種方法。

這個 forEach()) 提供了一種訪問Iterable類型所有內部元素的方法,傳遞您需要對每個元素執行的操作。這是一種非常強大的方法,它還使您的代碼更具可讀性。

Predicate測試對象

實現和使用Predicate

第三個接口是Predicate。Predicate用於測試對象。它用於篩選Stream API 中的流,稍後你將看到這個主題。

它的抽象方法接受一個對象並返回一個布爾值。這個接口又比 Consumer 複雜一點:上面定義了默認方法和靜態方法,稍後您將看到。讓我們專注於它的抽象方法:

@FunctionalInterface
public interface Predicate<T> {

    boolean test(T t);

    // default and static methods removed
}

上一部分已經看到了Predicate<String>的示例:

Predicate<String> length3 = s -> s.length() == 3;

要測試給定的字符串,您需要做的就是調用Predicate接口的 test()) 方法:

String word = ...; // any word
boolean isOfLength3 = length3.test(word);
System.out.prinln("Is of length 3? " + isOfLength3);

使用專用Predicate

假設您需要測試整數值。您可以編寫以下Predicate:

Predicate<Integer> isGreaterThan10 = i -> i > 10;

Consumer、Supplier和這個Predicate也是如此。此Predicate作為參數的是對 Integer 類實例的引用,因此在將此值與 10 進行比較之前,此對象會自動拆箱。它非常方便,但帶有開銷。

JDK 提供的解決方案與Supplier和Consumer的解決方案相同:專用Predicate。與 Predicate是三個專用接口:IntPredicate、LongPredicateDoublePredicate。它們的抽象方法都遵循命名約定。都返回boolean ,僅被命名為 test()) 並採用與接口對應的參數。

因此,您可以按如下方式編寫前面的示例:

IntPredicate isGreaterThan10 = i -> i > 10;

您可以看到lambda本身的語法是相同的,唯一的區別是參數i現在是一個int類型而不是Integer

使用BiPredicate測試兩個元素

就像您在 Consumer<T> 中看到的,JDK 還添加了一個 BiPredicate<T,U> 接口,該接口測試兩個元素:

@FunctionalInterface
public interface BiPredicate<T, U> {

    boolean test(T t, U u);

    // default methods removed
}

下面是這種BiPredicate的示例:

Predicate<String, Integer> isOfLength = (word, length) -> word.length() == length;

您可以將此BiPredicate與以下模式一起使用:

String word = ...; // really any word will do!
int length = 3;
boolean isWordOfLength3 = isOfLength.test(word, length);

沒有專門的BiPredicate<T,U>處理原始類型。

將Predicate傳給集合

集合框架有個方法,接收一個Predicate作為參數:removeIf()) 。此方法使用此Predicate來測試集合的每個元素。如果測試結果為 true,則此元素將從集合中刪除。

您可以在以下示例中看到此模式的實際效果:

List<String> immutableStrings =
        List.of("one", "two", "three", "four", "five");
List<String> strings = new ArrayList<>(immutableStrings);
Predicate<String> isOddLength = s -> s.length() % 2 == 0;
strings.removeIf(isOddLength);
System.out.println("strings = " + strings);

運行此代碼將產生以下結果:

strings = [one, two, three]

在這個例子中,有幾件事值得指出:

  • 如您所見,調用 removeIf()) 會改變這個集合。
  • 因此,不應該在不可變集合上調用 removeIf(),)比如 List.of()) 工廠方法生成的集合。
  • Arrays.asList()) 生成一個行為類似於數組的集合。您可以改變其現有元素,但不允許添加或刪除。因此,在此列表中調用 removeIf()) 也不起作用。

Function將對象映射到其他對象

實現和使用函數

第四個接口是 Function<T,R> 接口。函數的抽象方法接受T類型的對象,並將該對象的轉換返回到U類型。此接口還具有默認和靜態方法。

@FunctionalInterface
public interface Function<T, R> {

    R apply(U u);

    // default and static methods removed
}

Stream API 中使用此函數將對象映射為其他對象,稍後將介紹該主題。Predicate可以看作是一種特殊類型的函數,它返回一個 boolean

使用專用函數

這是一個函數的示例,該函數接受字符串並返回該字符串的長度。

Function<String, Integer> toLength = s -> s.length();
String word = ...; // any kind of word will do
int length = toLength.apply(word);

在這裏,您可以再次發現裝箱和拆箱操作的實際效果。首先,length()) 方法返回一個int .由於該函數返回一個Integer,因此需要裝箱。但隨後結果被分配給一個int類型的變量,所以Integer被拆箱以存儲在這個變量中。

如果性能在您的應用程序中不是問題,那麼這種裝箱和拆箱真的沒什麼大不了的。如果是,您可能希望避免它。

JDK為您提供解決方案,具有Function<T,R>接口的專用版本。這組接口比我們看到的SupplierConsumerPredicate類別的接口更復雜,根據參數類型和返回類型定義了專用函數。

輸入參數和輸出都可以有四種不同的類型:

  • 參數化類型T;
  • 一個int;
  • 一個long;
  • 一個double

不止於此,有一個特殊的接口:UnaryOperator<T>它擴展了Function<T,T>。此函數用於接受給定類型並返回相同類型。

以下是您可以在java.util.function包中找到的16種特殊類型的函數。

參數類型 T int long double
T UnaryOperator IntFunction LongFunction DoubleFunction
int ToIntFunction IntUnaryOperator LongToIntFunction DoubleToIntFunction
long ToLongFunction IntToLongFunction LongUnaryOperator DoubleToLongFunction
double ToDoubleFunction IntToDoubleFunction LongToDoubleFunction DoubleUnaryOperator

這些接口的所有抽象方法都遵循相同的約定:它們以該函數的返回類型命名。以下是他們的名字:

  • apply()) 用於返回泛型類型T
  • applyAsInt()) 返回原始類型int
  • applyAsLong()) forlong
  • applyAsDouble()) fordouble

將UnaryOperator傳遞給列表

您可以使用 UnaryOperator 轉換列表的元素。人們可能想知道為什麼是 UnaryOperator而不是基本Function。答案其實很簡單:一旦聲明,就不能更改列表的類型。因此,您只需要更改列表元素,但無需更改其類型,也就不需要兩個。

採用此一元運算符的方法將其傳遞給 replaceAll()) 方法。下面是一個示例:

List<String> strings = Arrays.asList("one", "two", "three");
UnaryOperator<String> toUpperCase = word -> word.toUpperCase();
strings.replaceAll(toUpperCase);
System.out.println(strings);

運行此代碼將顯示以下內容:

[ONE, TWO, THREE]

請注意,這次我們使用了使用 Arrays.asList()) 模式創建的列表。實際上,您不需要在該列表中添加或刪除任何元素:此代碼只是逐個修改每個元素,這可以通過此特定列表來實現。

使用BiFunction映射兩個元素

類似Consumer和Predicate,Function還有一個接受兩個參數的版本:BiFunction<T,U,R>,其中TU是參數,R是返回類型:

@FunctionalInterface
public interface BiFunction<T, U, R> {

    R apply(T t, U u);

    // default methods removed
}

您可以使用 Lambda創建一個BiFunction:

BiFunction<String, String, Integer> findWordInSentence =
    (word, sentence) -> sentence.indexOf(word);

UnaryOperator<T> 接口還有一個帶有兩個參數的同級接口:BinaryOperator<T>,它擴展了 BiFunction<T,U,R>

所有可能的BiFunction專用版本的子集已添加到 JDK 中:

  • IntBinaryOperator、LongBinaryOperator 和 DoubleBinaryOperator ;
  • ToIntBiFunction<T>、ToLongBiFunction<T> 和 ToDoubleBiFunction<T>.

總結四類函數接口

java.util.function 包現在是 Java 的核心,因為您將在集合框架或 Stream API 中使用的所有 Lambda都實現了該包中的一個接口。

如您所見,此軟件包包含許多接口,找到自己的方式可能會很棘手。

首先,您需要記住的是有 4 類接口:

  • Supplier:不要參數,只返回
  • Consumer:要參數,不返回
  • Predicate:一個參數,返回一個boolean
  • Function:一個參數,返回一個類型

其次:某些接口的版本採用兩個參數而不是一個參數:

  • BiConsumer
  • BiPredicate
  • BiFunction

第三:一些接口有專門的版本,避免裝箱和拆箱。太多了,無法一一列舉。它們以它們採用的類型命名。例如:IntPredicate,或者它們返回的類型,如ToLongFunction。它們可能以兩者命名:IntToDoubleFunction

最後:有 Function<T、R> 和 BiFunction<T、U、R> 的擴展,用於所有類型都相同的情況:UnaryOperator<T> 和 BinaryOperator<T>,以及原始類型的專用版本。

將 Lambda編寫為方法引用

您看到 Lambda實際上是方法的實現:函數接口的唯一抽象方法。有時人們稱這些 Lambda為“匿名方法”,因為它就是這樣:一個沒有名稱的方法,您可以在應用程序中移動,存儲在字段或變量中,作為參數傳遞給方法或構造函數,並從方法返回。

有時,您將編寫 Lambda,這些表達式只是對某個特定方法的調用。事實上,您在編寫以下代碼時已經這樣做了:

Consumer<String> printer = s -> System.out.println(s);

這樣寫,這個lambda就是對System.out上定義的println())方法的引用。

這就是方法引用語法。

您的第一個方法引用

有時,Lambda只是對現有方法的引用。這種情況下,您可以將其編寫為方法引用。然後,前面的代碼將變為以下內容:

Consumer<String> printer = System.out::println;

方法引用有四類:

  • 靜態方法引用
  • 綁定方法引用
  • 非綁定方法引用
  • 構造方法引用

上面的printerConsumer屬於非綁定方法引用。

大多數情況下,IDE 將能夠告訴您是否可以將特定的 Lambda編寫為 Lambda。不要猶豫,問它!

靜態方法引用

假設您有以下代碼:

DoubleUnaryOperator sqrt = a -> Math.sqrt(a);

這個 Lambda實際上是對靜態方法 Math.sqrt()) 的引用。可以這樣寫:

DoubleUnaryOperator sqrt = Math::sqrt;

靜態方法引用的一般語法為 RefType::staticMethod

靜態方法引用可以有多個參數。請考慮以下代碼:

IntBinaryOperator max = (a, b) -> Integer.max(a, b);

您可以使用方法引用重寫它:

IntBinaryOperator max = Integer::max;

非綁定方法引用

不接受任何參數的方法

假設您有以下代碼:

Function<String, Integer> toLength = s -> s.length();

此函數可以編寫為 ToIntFunction。它只是對類 String 的方法 length()) 的引用。因此,您可以將其編寫為方法引用:

Function<String, Integer> toLength = String::length;//用第一個參數類型的方法

此語法起初可能會令人困惑,因為它實際上看起來像一個靜態調用。但實際上並非如此:length()) 方法是 String 類的實例方法

您可以使用這樣的方法引用從普通 Java Bean 調用任何 getter。假設您有一個定義了getName()User類。然後,您可以將以下函數:

Function<User, String> getName = user -> user.getName();

改為以下方法引用:

Function<User, String> toLength = User::getName;

不接受任何參數的方法

這是您已經看到的另一個示例:

BiFunction<String, String, Integer> indexOf = (sentence, word) -> sentence.indexOf(word);

這個 lambda 實際上是對 String 類的 indexOf()) 方法的引用,因此可以寫成以下方法引用:

BiFunction<String, String, Integer> indexOf = String::indexOf;//用第一個參數類型的方法,第二個作參數

此語法可能看起來更令人困惑。把經典代碼改成 lambda 的一個好方法是檢查此方法引用的類型。這從而此lambda的參數。

非綁定方法引用的一般語法如下:RefType:instanceMethod,其中RefType 是類型的名稱,instanceMethod是實例方法。

綁定方法引用

您看到的方法引用的第一個示例如下:

Consumer<String> printer = System.out::println;//第一個參數作為其他方法的參數

此方法引用稱為綁定方法引用。因為調用該方法的對象是在方法引用本身中定義的。因此,此調用綁定方法引用中給出的對象

如果考慮非綁定語法:Person::getName,則可以看到調用該方法的對象不是此語法的一部分:它是作為 Lambda的參數提供的。請考慮以下代碼:

Function<User, String> getName = User::getName;//屬於非綁定
User anna = new User("Anna");
String name = getName.apply(anna);

您可以看到該函數已應用於傳遞給該函數的 的特定實例User。然後,此函數在該實例上運行。

在前面的Consumer示例中不是這種情況:println()) 方法在 System.out 對象上調用,該對象是方法引用的一部分。

綁定方法引用的一般語法如下:expr:instanceMethod,其中 expr是調用的對象,instanceMethod是實例方法。

構造方法引用

您需要知道的最後一種是構造方法引用。假設您有以下Supplier<List<String>>

Supplier<List<String>> newListOfStrings = () -> new ArrayList<>();

您可以以與其餘方法相同的方式看到這一點:這歸結為 ArrayList 的空構造函數的引用。好吧,方法引用可以做到這一點。但由於構造函數不是方法,因此這是另一類方法引用。語法如下:

Supplier<List<String>> newListOfStrings = ArrayList::new;

您可以注意到此處不需要鑽石運算符。的確想要,則還需要提供類型:

Supplier<List<String>> newListOfStrings = ArrayList<String>::new;

您需要注意,如果您不知道方法引用的類型,那麼您就無法確切地知道它的作用。下面是一個示例:

Supplier<List<String>> newListOfStrings = () -> new ArrayList<>();
Function<Integer, List<String>> newListOfNStrings = size -> new ArrayList<>(size);//第一個參數作為new的參數

這兩個都可以用相同的語法ArrayList::new編寫,但它們不是相同的構造函數。你只需要小心這一點。

總結方法引用

下面是四種類型的方法引用。

名字 語法 Lambda
靜態 RefType::staticMethod (args) -> RefType.staticMethod(args)
綁定 expr::instanceMethod (args) -> expr.instanceMethod(args)
非綁定 RefType::instanceMethod (arg0, rest) -> arg0.instanceMethod(rest)
構造 ClassName::new (args) -> new ClassName(args)

組合 Lambda

您可能已經注意到 java.util.function 包的函數接口中存在默認方法。添加這些方法是為了允許 Lambda的組合和鏈接。

為什麼要做這樣的事情?只是為了幫助您編寫更簡單、更具可讀性的代碼。

使用默認方法鏈接Predicate

假設您需要處理字符串列表,僅保留非 null、非空且少於 5 個字符的字符串。您對給定字符串進行了三個測試:

  • 非null;
  • 非空;
  • 少於 5 個字符。

這些測試中的每一個都可以用一個非常簡單的單行Predicate輕鬆編寫。也可以將這三個測試組合成一個。它將看起來像下面的代碼:

Predicate<String> p = s -> (s != null) && !s.isEmpty() && s.length() < 5;

JDK允許你以另一種方式編寫這段代碼:

Predicate<String> nonNull = s -> s != null;
Predicate<String> nonEmpty = s -> s.isEmpty();
Predicate<String> shorterThan5 = s -> s.length() < 5;

Predicate<String> p = nonNull.and(nonEmpty).and(shorterThan5);

隱藏技術複雜性並表明代碼的意圖是組合 Lambda的意義所在。

API 級別是如何實現此代碼?不深入細節的話,您可以看到以下內容:

  • and()是一種方法
  • 它在Predicate的實例上調用:因此它是一個實例方法
  • 它需要另一個Predicate作為參數
  • 它返回一個Predicate

由於函數接口上只允許使用一個抽象方法,因此此and()方法必須是默認方法。因此,從 API 設計的角度來看,您擁有創建此方法所需的所有元素。好消息是:Predicate 接口已經有個 and()) 默認方法,所以你不必自己做。

順便説一下,還有一個 or()) 將另一個Predicate作為參數,還有一個不帶任何參數的 negate()) 。

使用這些,您可以按這種方式編寫前面的示例:

Predicate<String> isNull = Objects::isNull;
Predicate<String> isEmpty = String::isEmpty;
Predicate<String> isNullOrEmpty = isNull.or(isEmpty);
Predicate<String> isNotNullNorEmpty = isNullOrEmpty.negate();
Predicate<String> shorterThan5 = s -> s.length() < 5;

Predicate<String> p = isNotNullNorEmpty.and(shorterThan5);

當然此示例可能有點過,也可以利用方法引用和默認方法顯著提高代碼的表達能力。

使用工廠方法創建Predicate

通過使用函數接口中定義的工廠方法,可以進一步提高表現力。在Predicate接口上有兩個。

在下面的示例中,isEqualToDuke測試字符串。當測試的字符串等於“Duke”時,測試為真。此工廠方法可以為任何類型的對象創建Predicate。

Predicate<String> isEqualToDuke = Predicate.isEqual("Duke");

第二個工廠方法否定參數給出的Predicate。

Predicate<Collection<String>> isEmpty = Collection::isEmpty;
Predicate<Collection<String>> isNotEmpty = Predicate.not(isEmpty);//

使用默認方法鏈接Consumer

Consumer接口也具有鏈接Consumer的方法。您可以使用以下模式鏈接Consumer:

Logger logger = Logger.getLogger("MyApplicationLogger");
Consumer<String> log = message -> logger.info(message);
Consumer<String> print = message -> System.out.println(message);

Consumer<String> printAndLog = log.andThen(print);//

在此示例中,printAndLog是一個Consumer,它將首先將消息傳遞給log,然後將其傳遞給print

使用默認方法鏈接和組合Fuction

鏈接和組合之間的區別有點微妙。這兩個操作的結果實際上是相同的。不同的是你寫它的方式。

假設您有兩個函數 .您可以通過調用 f1.andThen(f2)) 來鏈接它們。將結果函數應用於對象,將首先將此對象傳遞給f1 ,並將結果傳遞給 f2

該接口具有第二個默認方法:f2.compose(f1)。)以這種方式編寫,也是,首先將對象傳遞給f1函數來處理對象,然後將結果傳遞給f2 .

你需要意識到的是,要獲得相同的結果函數,你需要調用 andThen()) onf1compose()) on f2

您可以鏈接或組合不同類型的函數。但是有明顯的限制:f1生成的結果的類型應與`f2 消費的類型兼容。

創建恆等函數

Function 接口還有一個工廠方法來創建恆等函數,稱為 identity()。)

Function<String, String> id = Function.identity();

此模式適用於任何有效類型。

編寫和組合Comparator

使用 Lambda實現Comparator

由於函數接口的定義,JDK 2 中引入的老式 Comparator 接口也變得函數化。因此,可以使用 Lambda實現Comparator。

以下是Comparator接口的唯一抽象方法:

@FunctionalInterface
public interface Comparator<T> {

    int compare(T o1, T o2);
}

Comparator的規則如下:

  • 如果o1 < o2然後compare(o1,o2))應該返回一個負數
  • 如果o1 > o2然後compare(o1,o2))應該返回一個正數
  • 在所有情況下, 和 compare(o2, o1) 應該有相反的符號。

如果 o1.equals(o2)true,並不嚴格要求compare(o1, o2)返回 0。

如何創建一個整數Comparator,以實現自然順序?好吧,您可以使用本教程開頭看到的方法:

Comparator<Integer> comparator = (i1, i2) -> Integer.compare(i1, i2);

您可能已經注意到,這個 Lambda也可以用一個非常好的綁定方法引用來編寫:

Comparator<Integer> comparator = Integer::compare;
避免使用(i1 - i2) 實現此Comparator,極端情況下不一定產生正確結果。

此模式可以擴展到您需要比較的任何內容,只要您遵循Comparator的規則即可。

ComparatorAPI更進一步,提供了一個非常有用的API,以更具可讀性的方式創建Comparator。

使用工廠方法創建Comparator

假設您需要創建一個Comparator以非自然的方式比較字符串:最短的字符串小於最長的字符串。

這樣的Comparator可以這樣寫:

Comparator<String> comparator =
        (s1, s2) -> Integer.compare(s1.length(), s2.length());

上一部分學習了可以鏈接和組合 Lambda。此代碼是此類組合的另一個示例。事實上,你可以用這種方式重寫它:

Function<String, Integer> toLength = String::length;//把操作提取出來,並可以作為參數傳入,定製比較規則
Comparator<String> comparator =
        (s1, s2) -> Integer.compare(
                toLength.apply(s1),
                toLength.apply(s2));

現在您可以看到,該Comparator僅取決於名為 的toLength的函數。因此,可以創建一個工廠方法,該方法將此函數作為參數並返回相應的 Comparator

函數toLength的返回類型仍然存在約束:它必須是可比較的。在這裏它運行良好,因為您始終可以將整數與其自然順序進行比較,但您需要牢記這一點。

JDK 中確實存在這樣的工廠方法:它已直接添加到Comparator接口中。因此,您可以通過這種方式編寫前面的代碼:

Comparator<String> comparator = Comparator.comparing(String::length);

這個 comparing()) 方法是Comparator接口的靜態方法。它接受一個Function作為參數,這個Function需要返回Comparable類型。

假設你有一個帶有 gettergetName()User類,你需要根據用户的名稱對用户列表進行排序。您需要編寫的代碼如下:

List<User> users = ...; // this is your list
Comparator<User> byName = Comparator.comparing(User::getName);
users.sort(byName);

鏈接Comparator

公司目前對您交付的Comparable<User>非常滿意。但是版本 V2 中有一個新要求:User類現在有個firstNamelastName ,您需要生成一個新的Comparator來處理此更改。

編寫每個Comparator遵循與前一個Comparator相同的模式:

Comparator<User> byFirstName = Comparator.comparing(User::getFirstName);
Comparator<User> byLastName = Comparator.comparing(User::getLastName);

現在,您需要的是一種鏈接它們的方法,就像鏈接PredicateConsumer的實例一樣。Comparator API 為您提供了一個解決方案來執行此操作:

Comparator<User> byFirstNameThenLastName =
        byFirstName.thenComparing(byLastName);

thenComparing()) 方法是 Comparator 接口的默認方法,它將另一個Comparator作為參數並返回一個新的Comparator。當應用於兩個用户時,Comparator首先使用byFirstName比較這些用户。如果結果為 0,則它將使用byLastName比較它們。簡而言之:它按預期工作。

Comparator API 更進一步:由於byLastName僅依賴於User::getLastName函數,因此 thenComparing()) 方法的重載已添加到 API 中,該方法將函數作為參數。因此,模式變為以下內容:

Comparator<User> byFirstNameThenLastName =
        Comparator.comparing(User::getFirstName)
                  .thenComparing(User::getLastName);

使用 Lambda、方法引用、鏈接和組合,創建Comparator從未如此簡單!

專用Comparator

Comparator也可能發生裝箱和拆箱或原始類型,從而導致與 java.util.function 包的函數接口相同的性能影響。為了解決這個問題,添加了 comparing()) 工廠方法和 thenComparing()) 默認方法的專用版本。

您還可以使用以下內容創建 Comparator 的實例:

  • comparingInt(ToIntFunction keyExtractor));
  • comparingLong(ToLongFunction keyExtractor));
  • comparingDouble(ToDoubleFunction keyExtractor)。)

如果需要使用原始類型的屬性比較對象,並且需要避免此原始類型的裝箱/拆箱,則可以使用這些方法。

還有相應的方法可以鏈接Comparator

  • thenComparingInt(ToIntFunction keyExtractor));
  • thenComparingLong(ToLongFunction keyExtractor));
  • thenComparingDouble(ToDoubleFunction keyExtractor)。)

思路是相同的。

使用自然順序比較可比較對象

本教程中有幾個工廠方法值得一提,它們將幫助您創建簡單的Comparator。

JDK 中的許多類,可能還有應用程序中的許多類都在實現 JDK 的一個特殊接口:Comparable 接口。此接口有一個方法:compareTo(T other),)返回一個int .此方法用於在 Comparator 接口的規則中,此T實例與 other進行比較。

JDK 的許多類已經實現此接口。原始類型的所有包裝類(IntegerLong等)、String類以及日期和時間 API 中的日期和時間類都是如此。

您可以使用這些類的自然順序(即使用此 compareTo()) 方法)比較這些類的實例。Comparator API 為您提供了一個 Comparator.naturalOrder()) 工廠類。它構建的Comparator正是這樣做的:它使用其 compareTo()) 方法比較任何Comparable對象。

當您需要鏈接Comparator時,擁有這樣的工廠方法非常有用。下面是一個示例,您希望將字符串與其長度進行比較,然後比較其自然順序(此示例使用 naturalOrder()) 方法的靜態導入以進一步提高可讀性):

Comparator<String> byLengthThenAlphabetically =
        Comparator.comparing(String::length)
                  .thenComparing(naturalOrder());
List<String> strings = Arrays.asList("one", "two", "three", "four", "five");
strings.sort(byLengthThenAlphabetically);
System.out.println(strings);

運行此代碼將產生以下結果:

[one, two, five, four, three]

反轉Comparator

Comparator的一個主要用途當然是對象列表的排序。JDK 8 在 List 接口上特別增加了一個方法:List.sort()。)此方法將Comparator作為參數。

如果你需要以相反的順序對前面的列表進行排序,你可以從 Comparator 接口調用 reversed()) 方法。

List<String> strings =
        Arrays.asList("one", "two", "three", "four", "five");
strings.sort(byLengthThenAlphabetically.reversed());
System.out.println(strings);

運行此代碼將產生以下結果:

[three, four, five, two, one]

處理null值

比較null對象可能會導致在運行代碼時出現令人討厭的 NullPointerException,這是您希望避免的。

假設您需要編寫一個 null 安全的整數Comparator來對整數列表進行排序。您決定遵循的規則是將所有 null 值推送到列表末尾,這意味着 null 值大於任何其他非 null 值。然後,您希望按自然順序對非空值進行排序。

下面是為實現此行為而編寫的代碼類型:

Comparator<Integer> comparator =
        (i1, i2) -> {
            if (i1 == null && i1 != null) {
                return 1;
            } else if (i1 != null && i2 == null) {
                return -1;
            } else {
                return Integer.compare(i1, i2);
            }
        };

您可以將此代碼與您在本部分開頭編寫的第一個Comparator進行比較,並發現可讀性受到了很大的影響。

幸運的是,有一種更簡單的方法可以編寫此Comparator,使用Comparator接口的另一種工廠方法。

Comparator<Integer> naturalOrder = Comparator.naturalOrder();

Comparator<Integer> naturalOrderNullsLast =
        Comparator.nullsLast(naturalOrder());

nullsLast()) 及其同級方法 nullsFirst()) 是 Comparator 接口的工廠方法。兩者都將Comparator作為參數並做到這一點:為您處理 null 值,將它們推到末尾,或者將它們放在排序列表中的第一個。

下面是一個示例:

List<String> strings =
        Arrays.asList("one", null, "two", "three", null, null, "four", "five");
Comparator<String> naturalNullsLast =
        Comparator.nullsLast(naturalOrder());
strings.sort(naturalNullsLast);
System.out.println(strings);

運行此代碼將產生以下結果:

[five, four, one, three, two, null, null, null]

使用Stream API 處理內存中的數據

Stream API 簡介

Stream API 可能是 Java SE 8 中僅次於lambda表達式的第二重要功能。簡而言之,Stream API 是關於向 JDK 提供眾所周知的 map-filter-reduce 算法的實現。

集合框架都是在 JVM 的內存中存儲和組織數據。可以將Stream API 視為集合框架的配套框架,以非常有效的方式處理此數據。實際上,您可以在集合上用流的方式處理它包含的數據。

不止於此:Stream API 可以為您做更多事情。JDK 為您提供了幾種模式,用於在其他源(包括 I/O 源)上創建流。此外,您可以毫不費力地創建自己的數據源以完全滿足您的需求。

當你掌握了Stream API 時,你能夠編寫非常富有表現力的代碼。這裏有一個小片段,你可以使用正確的靜態導入進行編譯:

List<String> strings = List.of("one","two","three","four","five");
var map = strings.stream()
                 .collect(groupingBy(String::length, counting()));
map.forEach((key, value) -> System.out.println(key + " :: " + value));

此代碼打印出以下內容。

  • 它通過 groupingBy(String::length)) 按長度對字符串進行分組
  • 它使用 counting()) 計算每個長度的字符串數量
  • 然後,它創建一個 Map<Integer,Long> 來存儲結果

運行此代碼將生成以下結果。

3 :: 2
4 :: 1
5 :: 1

即使你不熟悉 Stream API,閲讀使用它的代碼也能讓你一目瞭然地瞭解它在做什麼。

map-filter-reduce算法簡介

在深入瞭解Stream API 本身之前,讓我們看看你正在執行的map-filter-reduce算法的元素。

該算法是一種非常經典的數據處理算法。讓我們舉一個例子。假設您有一組具有三個屬性的Sale對象:日期、產品和金額。為了簡單起見,我們假設金額只是一個整數。這是你的Sale類。

public class Sale {
    private String product;
    private LocalDate date;
    private int amount;

    // constructors, getters, setters
    // equals, hashCode, toString
}

假設您需要計算 3 月份銷售額的總金額。您可能會編寫以下代碼。

List<Sale> sales = ...; // this is the list of all the sales
int amountSoldInMarch = 0;
for (Sale sale: sales) {
    if (sale.getDate().getMonth() == Month.MARCH) {
        amountSoldInMarch += sale.getAmount();
    }
}
System.out.println("Amount sold in March: " + amountSoldInMarch);

您可以看到三個步驟。

第一步包括僅考慮3月份發生的銷售。您正在根據給定的條件過濾元素。這正是filtering步驟。

第二步包括從對象中提取屬性。你對整個sale對象不感興趣;你需要的是它的amount屬性。您正在將sale對象映射為數量,即一個int值。這就是mapping步驟;它包括將您正在處理的對象轉換為其他對象或值。

最後一步包括將所有這些金額相加為一個金額。如果您熟悉 SQL 語言,您可以看到最後一步看起來像一個聚合。事實上,它也是這樣做的。此金額是將單個金額reduce為一個金額。

順便説一下,SQL語言在以可讀的方式表達這種處理方面做得很好。你需要的SQL代碼真的非常容易閲讀:

select sum(amount)
from Sales
where extract(month from date) = 3;

面向結果而不是面向算法編程

您可以看到,在SQL中,您正在編寫的是所需結果的描述:三月份所有銷售金額的總和。數據庫服務器有責任弄清楚如何有效地計算它。

剛才的 Java 代碼只是對過程的分步説明。它以命令式的方式精確描述。幾乎沒有空間給 Java 運行時作優化。

Stream API 的兩個目標是使您能夠創建更具可讀性和表現力的代碼,併為 Java 運行時提供一些迴旋餘地來優化您的計算。

將對象map到其他對象或值

map-filter-reduce 算法的第一步是mapping步驟。mapping包括轉換正在處理的對象或值。mapping是一對一的:如果map一個包含 10 個對象的列表,則將獲得包含 10 個轉換對象的列表。

在Stream API 中,mapping步驟又添加一個約束。假設您正在處理順序對象的集合。它可以是一個列表,也可以是順序對象的某個其他源。map該列表時,獲得的第一個對象應該是源中第一個對象的mapping。換句話説:mapping步驟遵循對象的順序;它不會打亂它們。

mapping會更改對象的類型;它不會改變他們的順序。

map由Function函數接口建模。實際上,函數可以接受任何類型的對象並返回其他類型。此外,專用函數可以將對象map到原始類型,反之亦然。

filter掉對象

另一方面,filtering不會改變您正在處理的對象。它只是選擇其中一些,並刪除其他。

filtering會更改對象的數量;它不會更改它們的類型。

filtering由Predicate功能接口建模。實際上,Predicate可以接受任何類型的對象或原始類型,並返回布爾值。

reduce對象以產生結果

reduce步驟比看起來更棘手。現在,我們將接受這個定義,即它與SQL聚合相同。想想計數求和最小值、最大值平均值。順便説一下Stream API 支持所有這些聚合。

只是為了給你一個提示,在這條道路上等待你的是什麼:reduce步驟允許你用你的數據構建複雜的結構,包括列表、集合、任何類型的map,甚至是自己構建的結構。看看這個頁面上的第一個例子:你可以看到對 collect() 方法的調用,該方法接收由 groupingBy()) 工廠方法構建的對象。此對象是collector。reduce可能包括使用collector收集數據。本教程稍後將詳細介紹collector。

優化map-filter-reduce算法

讓我們再舉一個例子。假設您有一個城市集合。每個城市都由一個City類建模,該類具有兩個屬性:名稱和人口,即居住在其中的人數。您需要計算居住在居民超過 100k 的城市中的總人口。

如果不使用Stream API,您可能會編寫以下代碼。

List<City> cities = ...;

int sum = 0;
for (City city: cities) {
    int population = city.getPopulation();
    if (population > 100_000) {
        sum += population;
    }
}

System.out.println("Sum = " + sum);

您可以看到另一個map-filter-reduce處理。

現在,讓我們做一個小的頭腦風暴:假設 Stream API 不存在,並且 Collection 接口上存在map()filter()方法,以及 sum()) 方法。

使用這些(虛構的)方法,以前的代碼可能會變成以下內容。

int sum = cities.map(city -> city.getPopulation())
                .filter(population -> population > 100_000)
                .sum();

從可讀性和表現力的角度來看,這段代碼非常容易理解。所以你可能想知道:為什麼這些map和filter方法不添加到Collection接口中?

讓我們更深入地挖掘:這些map()filter()方法的返回類型是什麼?好吧,由於我們處於集合框架中,因此返回集合似乎是很自然的。因此,您可以通過這種方式編寫此代碼。

Collection<Integer> populations         = cities.map(city -> city.getPopulation());
Collection<Integer> filteredPopulations = populations.filter(population -> population > 100_000);
int sum                                 = filteredPopulations.sum();

即使鏈接調用提高了可讀性,此代碼仍應正確。

現在讓我們分析這段代碼。

  • 第一步是mapping步驟。您看到,如果您必須處理 1,000 個城市,則此mapping步驟將生成 1,000 個整數並將它們放入集合中。
  • 第二步是filtering步驟。它遍歷所有元素並按照給定的標準刪除其中一些。這是另外 1,000 個要測試的元素和另一個要創建的集合,可能更小。

由於此代碼返回集合,因此它會map所有城市,然後filter生成的整數集合。這與你最初編寫的 for 循環非常不同。存儲此整數的中繼集合可能會導致大量開銷,尤其是在要處理大量城市的情況下。for 循環沒有這種開銷:它直接彙總結果中的整數,而不將它們存儲在中繼結構中。

這種開銷很糟糕,在某些情況下可能會更糟。假設您需要知道集合中是否有超過 100k 居民的城市。也許集合的第一個城市就是。這種情況下,您應該期望毫不費力地產生結果。如果先建立來自城市的所有人口的集合,然後filter它並檢查結果是否為空,將是荒謬的。

出於明顯的性能原因,創建在Collection 接口上返回Collectionmap()方法並不正確。您最終會創建不必要的中繼結構,在內存和 CPU 上都有很高的開銷。

相反,它們是在Stream接口上創建的。

正確的模式如下。

Stream<City> streamOfCities         = cities.stream();
Stream<Integer> populations         = streamOfCities.map(city -> city.getPopulation());
Stream<Integer> filteredPopulations = populations.filter(population -> population > 100_000);
int sum = filteredPopulations.sum(); // in fact this code does not compile; we'll fix it later

Stream 接口避免創建中繼結構來存儲map或filter的對象。在這裏,map()) 和 filter()) 方法仍在返回新的流。因此,為了使此代碼正常工作且高效,不應在這些流中存儲任何數據。在此代碼中創建的流,streamOfCities,populations,filteredPopulations必須全部為空對象。

它導致了流的一個非常重要的屬性:

流是一種對象,它不存儲任何數據。

Stream API 的設計方式是,只要您不在流模式中創建任何非流對象,就不會對數據進行計算。在前面的示例中,您的元素總和計算由流來處理。

sum操作才會觸發計算:cities列表的所有對象會逐個拉取。首先對它們進行map,然後進行filter,如果它們通過了filtering步驟,則進行彙總。

流處理數據的順序與編寫等效的 for 循環的順序相同。這樣就沒有內存開銷。此外,某些情況下,您無需遍歷集合的所有元素即可產生結果。

使用流就是創建操作管道。在某個時候,您的數據將通過此管道傳輸,並將被轉換、filter,然後參與結果的生成。

管道由流上的一系列方法調用組成。每個調用都會生成另一個流。然後在某個時候,最後一次調用會產生結果。返回另一個流的操作稱為中繼操作。同時,返回其他內容(包括 void)的操作稱為末端操作。

創建具有中繼操作的管道

中繼操作是返回另一個流的操作。調用此類操作會在現有操作管道上再添加一個操作,而不處理任何數據。它由返回流的方法建模。

使用末端操作計算結果

末端操作不返回流。調用此類操作會觸發流的源元素的使用。然後,這些元素由中繼管道處理,一次一個元素。

只要不返回流,末端操作可以由返回任何內容(包括 void)的方法建模。

不能在流上同時調用多箇中繼方法或末端方法。如果這樣做,您將收到一個 IllegalStateException,其中包含以下消息:“流已操作或關閉”。

使用專門的數字流避免裝箱

Stream API 提供了四個接口。

第一個是 Stream,可用於定義對任何對象的操作管道。

然後有三個專門的接口來處理數字流:IntStreamLongStreamDoubleStream。這三個流對數字使用原始類型而不是包裝器類型,以避免裝箱和拆箱。它們具有與 Stream 中幾乎相同的方法,但有一些例外。由於它們正在處理數字,因此它們具有一些 Stream 中不存在的末端操作

  • sum()):計算總和
  • min()), max()):計算流的最小或最大數量
  • average()):計算數字的平均值
  • summaryStatistics()):此調用生成一個特殊對象,該對象攜帶多個統計信息,所有這些統計信息都是在一次傳遞數據時計算的。這些統計信息是該流處理的元素數、最小值、最大值、總和和平均值。

遵循良好做法

如您所見,您只能在流上同時調用一個方法,即使此方法是中繼方法。因此,將流存儲在字段或局部變量中是無用的,有時甚至是危險的。編寫將流作為參數的方法也可能很危險,因為您無法確定收到的流尚未作。應當場創建和使用流。

流是連接到源的對象。它從此源中提取它處理的元素。此源不應由流本身修改。這樣做將導致未指定的結果。在某些情況下,此源是不可變的或只讀的,因此您將無法執行此操作。

Stream 接口中有很多可用的方法,您將在本教程中看到其中的大多數方法。編寫修改流本身之外的某些變量或字段的操作是一個壞主意,總是可以避免的。流不應有任何副作用

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.