LOGI

Lambda Expressions

匿名类的一大问题是,如果实现很简单,例如单方法接口,那么它的语法显得笨拙且模糊。这种情况下,你通常试图将函数作为参数传递给其它方法,比如用户点击按钮要采取的动作。Lambda [ˈlæmdə] 表达式允许你把函数当成方法参数,或把代码看成数据。

前章 Anonymous Classes 展示了如何不具名实现基类。尽管它比具名类简洁,但对于单方法类,也有点多余和累赘。Lambda 表达式允许你更紧凑地表达单方法类型实例。

本章涵盖如下话题:

Ideal Use Case for Lambda Expressions

假设你在编写社交网络应用。你想编写一个通用函数方便管理员执行操作,比如发送消息给满足特定规则的成员。用例的详细描述见下表:

FieldDescription
成员名称在该成员上执行操作
主要角色管理员
前置条件管理员已登录系统
后置条件只在满足特定条件的成员上执行动作
主要场景1. 管理员指定执行操作的成员条件
2. 管理员在选定成员上执行指定动作
3. 管理员按下 Submit 按钮
4. 系统寻找所有满足条件的成员
5. 系统在匹配成员上执行动作
扩展功能管理员在执行动作或点击提交前可以预览匹配条件的成员
使用频次每天多次使用

假定社交网络应用的成员使用如下 Person 类表示:

public class Person {
    public enum Sex {
        MALE, FEMALE
    }

    String name;
    LocalDate birthday;
    Sex gender;
    String emailAddress;

    public int getAge() {
        // ...        
    }

    public void printPerson() {
        // ...
    }
}

成员存储在 List<Person> 实例中。我们首先使用原生方法完成用例。随后通过局部匿名类改进,最后使用高效简洁的 lambda 表达式。你可以在 RosterTest 找到完整代码。

Approach 1: Create Methods That Search for Members That Match One Characteristic

最简单的方式是创建几个方法,每个方法搜寻匹配一个特征的成员,比如性别或年龄。如下方法打印年龄大于指定条件的成员:

public static void printPersonOlderThan(List<Person> roster, int age) {
    for (Person p : roster) {
        if (p.getAge() >= age) {
            p.printPerson();
        }
    }
}

[tip type="info" title="备注"]
List 是有序 CollectionCollection 对象是由多个元素组成的单元,用于存储、检索、操作和传递聚合数据。更多信息见 Collections 教程。
[/tip]

这种方式可能让你的应用很 brittle,它表示由于更新(例如新数据类型)导致应用无法工作的可能性。假设你升级应用改变了 Person 类让它包含不同成员变量,也许以不同的数据类型或算法记录和测量年龄,你必须重写许多 API 适应这种改变。此外,这种方式的限制性太强,比如,如果你想打印小于特定年龄的成员呢?

Approach 2: Create More Generalized Search Methods

下面的方法比 printPersonsOlderThan 通用,它打印特定年龄范围的成员:

public static void printPersonsWithinAgeRange(
        List<Person> roster, int low, int high) {
    for (Person p : roster) {
        if (low <= p.getAge() && p.getAge() < high) {
            p.printPerson();
        }
    }
}

如果你想打印指定性别,或者结合指定性别和年龄范围的成员该怎么办呢?如果决定增加其它属性,例如关系状态或地理位置又该怎么办呢?尽管此类比 printPersonsOlderThan 通用,但试图为每种可能搜索创建单独方法还是会导致代码脆弱。你可以分离搜索条件的代码到不同类中。

Approach 3: Specify Search Criteria Code in a Local Class

以下方法打印匹配特定搜索条件的成员:

public static void printPersons(
        List<Person> roster, CheckPerson tester) {
    for (Person p : roster) {
        if (tester.test(p)) {
            p.printPerson();
        }
    }
}

该方法检查 roster 中的每个 Person,看它是否满足指定的搜索条件 tester,通过调用 tester.test 方法。如果它返回 true,就调用 Person 实例的 printPerson

要指定搜索条件,你必须实现 CheckPerson 接口:

interface CheckPerson {
    boolean test(Person person);
}

下面是一个实现类,它的 test 方法用于过滤适合服兵役的成员:如果 Person 参数是男性并且年龄在 18 到 25 之间,它返回 true

class CheckPersonEligibleForSelectiveService implements CheckPerson {
    @Override
    public boolean test(Person person) {
        return person.getGender() == Person.Sex.MALE
                && person.getAge() >= 18
                && person.getAge() <= 25;
    }
}

要使用它,创建一个实例,并调用 printPersons 方法:

printPersons(
    roster, new CheckPersonEligibleForSelectiveService());

虽然这个方法不那么脆弱 —— 更新 Person 结构后,你无需重写方法 —— 但仍需额外代码:每种搜索都需新的接口和局部类。因为 CheckPersonEligibleForSelectiveService 实现了接口,你可以使用匿名类代替局部类,从而省略为每个搜索声明新类。

Approach 4: Specify Search Criteria Code in an Anonymous Class

下面使用匿名类调用 printPersons,功能同上述局部类:

printPersons(roster,
        new CheckPerson() {
            @Override
            public boolean test(Person person) {
                return person.getGender() == Person.Sex.MALE
                        && person.getAge() >= 18
                        && person.getAge() <= 25;
            }
        });

这种方式减少了代码量,因为你无需为每个搜索单独创建新类。但是,考虑到 CheckPerson 接口仅含一个方法,匿名类语法还是很笨重。这种情况下,你可以使用 lambda 表达式取代匿名类,就像下面那样。

Approach 5: Specify Search Criteria Code with a Lambda Expression

CheckPerson 是一个 functional interface。函数接口表示仅含一个 abstract method 的接口。(函数接口可以包含一到多个 default methodsstatic methods)由于函数接口仅含一个抽象方法,实现它时你可以省略方法名称。要这样做,使用 lambda expresstion 代替匿名类,就像以下方法调用的高亮部分:

printPersons(roster,
        (Person p) -> p.getGender() == Person.Sex.MALE
                && p.getAge() >= 18
                && p.getAge() <= 25);

阅读下方的 Syntax of Lambda Expressions,了解如何定义 lambda 表达式。

你可以使用标准函数接口取代 CheckPerson,进一步减少代码。

Approach 6: Use Standard Functional Interfaces with Lambda Expressions

重新考虑 CheckPerson 接口:

interface CheckPerson {
    boolean test(Person p);
}

它非常简单,是个仅含一个抽象方法的函数接口。该方法接收一个参数,返回一个 boolean 值。它是如此简单以至于不值得单独定义。因此,JDK 定义了几个标准函数接口,你可以在 java.util.function 下发现它们。

例如,你可以使用 Predicate<T> 接口代替 CheckPerson。该接口包含 boolean test(T t) 方法:

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

因此,你可以像下面这样使用它:

public static void printPersonsWithPredicate(
        List<Person> roster, Predicate<Person> tester) {
    for (Person p : roster) {
        if (tester.test(p)) {
            p.printPerson();
        }
    }
}

最终,你可以像下面这样查询指定成员:

printPersonsWithPredicate(
        roster,
        p -> p.getGender() == Person.Sex.MALE
                && p.getAge() >= 18
                && p.getAge() <= 25
);

此处不是唯一可使用 lambda 表达式的地方。以下示例探讨了其它方式。

Approach 7: Use Lambda Expressions Throughout Your Application

重新考虑 printPersonsWithPredicate,想象还有什么地方可以使用 lambda 表达式:

public static void printPersonsWithPredicate(
    List<Person> roster, Predicate<Person> tester) {
    for (Person p : roster) {
        if (tester.test(p)) {
            p.printPerson();
        }
    }
}

该方法检查 roster 中的每个 Person,看它是否满足 tester 指定的条件。如果满足,Person 实例的 printPerson 将被调用。

除了调用 printPerson,你可以使用 lambda 表达式指定不同的动作。假定你想编写一个类似于 printPerson 的 lambda 表达式,它接收一个 Person 类型参数,返回 void。记住,使用 lambda,你需要实现一个函数接口。此处的函数接口需要接收 Person 类型参数,返回 void。Consumer<T> 接口包含 void accept(T t) 方法,它符合这些特征。下例使用该接口调用 accept 取代 p.printPerson()

public static void processPersons(
        List<Person> roster,
        Predicate<Person> tester,
        Consumer<Person> block) {
    for (Person p : roster) {
        if (tester.test(p)) {
            block.accept(p);
        }
    }
}

processPersons(
     roster,
     p -> p.getGender() == Person.Sex.MALE
         && p.getAge() >= 18
         && p.getAge() <= 25,
     p -> p.printPerson() /* Person::printPerson */
);

如果你想对成员做更多操作不仅仅是打印它们,那该怎么办呢?假定你想验证成员的资料,或者检索它们的联系人。此时,你需要一个函数接口,它包含一个方法,该方法有返回值。Function<T,R> 接口包含 R apply(T t) 方法。下面的方法获取参数 mapper 指定的数据,随后执行参数 block 指定的动作:

public static void processPersonsWithFunction(
        List<Person> roster,
        Predicate<Person> tester,
        Function<Person, String> mapper,
        Consumer<String> block) {
    for (Person p : roster) {
        if (tester.test(p)) {
            String data = mapper.apply(p);
            block.accept(data);
        }
    }
}

下面的调用从 roster 中过滤每个符合兵役条件的成员,获取它们的邮箱并打印:

processPersonsWithFunction(
        roster,
        p -> p.getGender() == Person.Sex.MALE
                && p.getAge() >= 18
                && p.getAge() <= 25,
        Person::getEmailAddress,
        System.out::println);

Approach 8: Use Generics More Extensively

重新考虑 processPersonsWithFunction 方法。下面是它的泛型版本,以包含任何数据类型的容器为参数:

public static <X, Y> void processElements(
        Iterable<X> source,
        Predicate<X> tester,
        Function<X, Y> mapper,
        Consumer<Y> block) {
    for (X p : source) {
        if (tester.test(p)) {
            Y data = mapper.apply(p);
            block.accept(data);
        }
    }
}

它的调用参数和 Approach 7 相同:

processElements(
        roster,
        p -> p.getGender() == Person.Sex.MALE
                && p.getAge() >= 18
                && p.getAge() <= 25,
        Person::getEmailAddress,
        System.out::println);

该方法调用完成了如下动作:

  1. source 容器获得对象源。本例中,它从 roster 容器获取 Person。注意 roster 容器的类型是 List,它也是一个 Iterable 对象。
  2. 过滤匹配 Predicate tester 的对象。本例中,Predicate 是一个 lambda 表达式,指定了成员必须符合兵役规则。
  3. 将每个过滤对象映射为 Function mapper 指定的值。本例中,Function 是一个 lambda 表达式,它返回成员的邮箱地址。
  4. 在每个映射对象上执行 Consumer block 指定的动作。本例中,Consumer 是一个 lambda 表达式,它打印一个字符串,该字符串是 Function 对象的返回值。

你可以使用聚集操作替代所有这些动作。

Approach 9: Use Aggregate Operations That Accept Lambda Expressions as Parameters

下面的示例使用聚集操作打印 roster 中那些满足兵役条件成员的邮箱地址:

roster.stream()
        .filter(p -> p.getGender() == Person.Sex.MALE
                && p.getAge() >= 18
                && p.getAge() <= 25)
        .map(Person::getEmailAddress)
        .forEach(System.out::println);

下表是 processElements 与聚集操作之间的映射:

processElements ActionAggregate Operation
获得对象源Stream<E> stream()
过滤匹配 Predicate 的对象Stream<T> filter(Predicate<? super T> predicate)
映射对象为 Function 指定的值<R> Stream<R> map(Function<? super T,? extends R> mapper)
执行 Consumer 指定的动作void forEach(Consumer<? super T> action)

filter, mapforEach 都是 aggregate operations。聚集操作处理流中的元素,而非直接从容器获取(这就是为什么第一个调用的方法是 stream)。stream 是一个元素序列。不像容器,它不是一个储存元素的数据结构。相反,流通过管道,从源,例如容器,搬运数据。pipeline 是流的操作序列,即本例中的 filter-map-forEach。此外,聚集操作通常接收 lambda 表达式,让你自定义它们的行为。

更全面的聚集操作讨论,见 Aggregate Operations 课程。

Lambda Expressions in GUI Applications

为了处理 graphical user interface (GUI) 应用的事件,如键盘,鼠标点击和滚动动作,你通常需要创建事件处理器,这大多涉及实现特定接口。事件处理器经常是函数接口,它们倾向于只包含一个方法。

在 JavaFX 示例 HelloWorld.java 中(前面的 Anonymous Classes 部分讨论过),你可以将以下匿名类替换成 lambda 表达式:

btn.setOnAction(new EventHandler<ActionEvent>) {
    @Override
    public void handle(ActionEvent event) {
        System.out.println("Hello World!");
    }
}

该方法需要一个 EventHandler<ActionEvent> 对象,它包含一个方法 void handle(T event),所以是函数接口,你可以使用如下 lambda 表达式取代它:

btn.setOnAction(
    event -> System.out.println("Hello World!")
);

Syntax of Lambda Expressions

一个 lambda 表达式由以下部分组成:

[tip type="info" title="备注"]
你可以省略表达式中的参数类型。此外,如果只有一个参数,圆括号也可以省略。例如,下面的表达式也是合法的:

p -> p.getGender() == Person.Sex.MALE
    && p.getAge() >= 18
    && p.getAge() <= 25

[/tip]

p.getGender() == Person.Sex.MALE 
    && p.getAge() >= 18
    && p.getAge() <= 25

如果你指定单个表达式,Java 运行时会执行它并将结果返回。你也可以使用返回声明:

p -> {
    return p.getGender() == Person.Sex.MALE
        && p.getAge() >= 18
        && p.getAge() <= 25;
}

返回声明不是表达式,你必须使用花括号({})封闭它。但是,你不需要封闭无返回值方法调用。例如:

email -> System.out.println(email)

注意 lambda 表达式看上去像方法声明,你可以把它看成匿名方法 —— 没有名字的方法。

下面的示例 Calculator 使用了接收多个形式参数的 lambda 表达式:

public class Calculator {
    interface IntegerMath {
        int operation(int a, int b);
    }

    public int operateBinary(int a, int b, IntegerMath op) {
        return op.operation(a, b);
    }

    public static void main(String[] args) {
        Calculator myApp = new Calculator();
        IntegerMath addition = (a, b) -> a + b;
        IntegerMath subtraction = (a, b) -> a - b;
        System.out.printf(
                "40 + 2 = %d%n", myApp.operateBinary(40, 2, addition));
        System.out.printf(
                "40 - 2 = %d%n", myApp.operateBinary(40, 2, subtraction));

    }
}

Accessing Local Variables of the Enclosing Scope

和局部、匿名类一样,lambda 表达式也能 capture variables,它们对封闭域的局部变量有相同的访问权限。但是,与局部和匿名类不同,lambda 表达式没有任何 shadowing 问题(阅读 Shadowing 了解更多信息)。lambda 表达式有词法作用域,这意味着它不从父类型继承任何名称,或引入新作用域级别。lambda 表达式内的声明就像在封闭环境中被解释一样。下面的例子 LambdaScopeTest 阐释了这点:

public class LambdaScopeTest {
    public int x = 0;

    class FirstLevel {
        public int x = 1;

        void methodInFirstLevel(int x) {
            // The following statement causes the compiler to generate
            // the error "local variables referenced from a lambda expression
            // must be final or effectively final" in statement A:
            //
            // x = 99;

            Consumer<Integer> myConsumer = (y) ->
            {
                System.out.println("x = " + x); // Statement A
                System.out.println("y = " + y);
                System.out.println("this.x = " + this.x);
                System.out.println(
                    "LambdaScopeTest.this.x = " + LambdaScopeTest.this.x);
            };

            myConsumer.accept(x);
        }
    }

    public static void main(String[] args) {
        LambdaScopeTest st = new LambdaScopeTest();
        LambdaScopeTest.FirstLevel f1 = st.new FirstLevel();
        f1.methodInFirstLevel(23);
    }
}

示例输出如下:

x = 23
y = 23
this.x = 1
LambdaScopeTest.this.x = 0

如果你把 myConsumer 声明的 y 换成 x,那么编译器将产生错误:

Consumer<Integer> myConsumer = (x) -> {
    // ...
}

错误提示是:"variable x is already defined in method methodInFirstLevel(int)",因为 lambda 表达式不引入新作用域级别。所以,你可以直接访问封闭域的属性,方法和局部变量。例如,示例可以直接访问 methodInFirstLevel 的参数 x。要访问封闭类的变量,使用关键字 this。本例中,this.x 指向成员变量 FirstLevel.x

但是,和局部及匿名类一样,lambda 表达式只能访问封闭块中的 final等效 final 局部变量或参数。例如,假设你在 methodInFirstLevel 定义后立马加上以下赋值声明:

void methodInFirstLevel(int x) {
    x = 99;
    // ...
}

由于该赋值语句,FirstLevell.x 不再是等效 final 变量。最终,Java 编译器将在 lambda 表达式访问 FirstLevel.x 的地方生成类似 "local variables referenced from a lambda expression must be final or effectively final" 的错误信息:

System.out.println("x = " + x);

Target Typing

你如何确定 lambda 表达式的类型?回想选择男性且年龄在 18 到 25 之间成员的 lambda 表达式:

p -> p.getGender() == Person.Sex.MALE
    && p.getAge() >= 18
    && p.getAge() <= 25

该表达式用在了以下两个方法中:

当 Java 运行时调用 printPersons 方法时,它期待 CheckPerson 类型数据,所以 lambda 表达式就是该类型。而调用 printPersonsWithPredicate 方法时,它期待 Predicate<Person>,所以 lambda 表达式变为此类型。方法期待的数据类型叫 target type。Java 编译器使用表达式所在上下文的目标类型确定 lambda 表达式类型。只有在以下 Java 编译器可以确定目标类型的情形下,才可以使用 lambda 表达式:

Target Types and Method Arguments

对于方法参数,Java 编译器使用其它两个语言特性确定目标类型:重载解析和类型参数推导。

考虑下面两个函数接口(java.lang.Runnablejava.util.concurrent.Callable\<V\>):

public interface Runnable {
    void run();
}

public interfade Callable<V> {
    V call();
}

方法 Runnable.run 没有返回值,Callable.call 有。

假定你有如下重载方法 invoke(阅读 Defining Methods 详细了解重载方法):

void invoke(Runnable r) {
    r.run();
}

<T> T invoke(Callable<T> c) {
    return c.call();
}

下面的声明会调用那个方法呢?

String s = invoke(() -> "done");

invoke(Callable<T>) 会被调用,因为它有返回值。此时,lambda 表达式 () -> "done" 的类型是 Callable<T>

Serialization

你可以 serialize 一个 lambda 表达式,如果它的目标类型和捕获参数是可序列化的。但是,和 inner classes 一样,不推荐 lambda 表达式序列化。

Method References

Lambda Expressions 可以创建匿名方法。但是有时,lambda 除了调用已有方法什么也不做。此时,引用已有方法名更清晰。方法引用允许你这样做,它们是紧凑易读的具名方法 lambda 表达式。

重新考虑前面的 Person 类:

public class Person {
    public enum Sex {
        MALE, FEMALE
    }

    String name;
    LocalDate birthday;
    Sex gender;
    String emailAddress;

    public int getAge() {
        // ...
    }
    
    public Calendar getBirthday() {
        return birthday;
    }    

    public static int compareByAge(Person a, Person b) {
        return a.birthday.compareTo(b.birthday);
    }
}

假定社交网络应用的成员存储在数组中,你想让它们按年龄排序,可以使用如下代码(完整代码见 MethodReferencesTest):

Person[] rosterAsArray = roster.toArray(new Person[0]);

class PersonAgeComparator implements Comparator<Person> {
    @Override
    public int compare(Person a, Person b) {
        return a.getBirthday().compareTo(b.getBirthday());
    }
}

Arrays.sort(rosterAsArray, new PersonAgeComparator());

被调方法 sort 签名如下:

static <T> void sort(T[] a, Comparator<? super T> c)

Comparator 是一个函数接口。因此,你可以使用 lambda 表达式代替接口实现和实例创建:

Arrays.sort(rosterAsArray,
        (Person a, Person b) -> {
            return a.getBirthday().compareTo(b.getBirthday());
        }
);

但是,已经存在 Person.compareByAge,你可以直接调用它代替 lambda 表达式体:

Arrays.sort(rosterAsArray,
    (a, b) -> Person.compareByAge(a, b)
);

由于该 lambda 表达式调用已有方法,你可以使用方法引用代替表达式:

Arrays.sort(rosterAsArray, Person::compareByAge);

方法引用 Person::compareByAge 在语义上和 lambda 表达式 (a, b) -> Person.compareByAge(a, b) 等价。都有如下特征:

Kinds of Method References

存在四种方法引用:

KindExample
静态方法引用ContainingClass::staticMethodName
具名实例方法引用containingObject::instanceMethodName
任意类型不具名实例方法引用ContainingType::methodName
构造器引用ClassName::new

Reference to a Static Method

Person::compareByAge 便是静态方法引用。

Reference to an Instance Method of a Particular Object

下面是一个具名实例方法引用:

class ComparisonProvider {
    public int compareByName(Person a, Person b) {
        return a.getName().compareTo(b.getName());
    }

    public int compareByAge(Person a, Person b) {
        return a.getBirthday().compareTo(b.getBirthday());
    }
}
ComparisonProvider myComparisonProvider = new ComparisonProvider();
Arrays.sort(rosterAsArray, myComparisonProvider::compareByName);

方法引用 myComparisonProvider::compareByName 调用 myComparisonProvider 对象的 compareByName 方法。JRE 会推断方法参数类型,此处是 (Person, Person)

Reference to an Instance Method of an Arbitrary Object of a Particular Type

下例展示了任意特定类型对象的不具名实例方法引用:

String[] stringArray = {
        "Barbara", "James", "Mary", "John",
        "Patricia", "Robert", "Michael", "Linda"
};
Arrays.sort(stringArray, String::compareToIgnoreCase);

String::compareToIgnoreCase 等价 lambda 表达式的形式参数列表为 (String a, String b)ab 是为了方便描述随意起的名字。方法引用会调用 a.compareToIgnoreCase(b)

Reference to a Constructor

你可以像引用静态方法那样引用构造函数,区别是方法名为 new。以下方法将一个容器中的元素复制到另一个:

public static <T, SOURCE extends Collection<T>, DEST extends Collection<T>>
    DEST transferElements(SOURCE sourceCollection, 
                            Supplier<DEST> collectionFactory) {
    DEST result = collectionFactory.get();
    result.addAll(sourceCollection);
    return result;
}

函数接口 Supplier 包含一个方法 get,它没有参数,返回一个对象。因此,可以使用如下 lambda 表达式调用 transferElements

Set<Person> rosterSet = transferElements(roster, () -> {
    return new HashSet<>();
});

你可以使用构造器引用取代 lambda 表达式:

Set<Person> rosterSetLambda = transferElements(roster, HashSet::new);

Java 编译器能推断出你想创建一个包含 PersonHashSet。你可以显示指定元素类型:

Set<Person> rosterSet = transferElements(roster, HashSet<Person>::new);

When to Use Nested Classes, Local Classes, Anonymous Classes, and Lambda Expressions

就像 Nested Classes 提到的,嵌套类允许你将只在一处使用的类放到同一逻辑分组,从而创建易读可维护的高内聚代码。局部类,匿名类和 lambda 表达式也具有这些优点,但它们的使用场景更具体:

本文译自 Lambda Expressions,译者 LOGI。

当前页面是本站的「Google AMP」版。查看和发表评论请点击:完整版 »