Lambda Expressions(Lambda表达式)

时间:2021-08-04 18:50:07

有没有想过,如果匿名类非常简单,比方说一个接口仅有一个方法,实现这个接口的匿名类表达方式就显得很别扭。 而且,往往在这种情况下,你是把一个匿名类作为参数传给另外一个方法使用的。 因为匿名类经常是实现接口完成功能的, 也就是说你要把一个功能作为参数传给另外一个方法,感觉怪怪的,不是吗 (通常情况我们是传数据,不是传行为)? 就好比,点击一个按钮要完成特定的功能这种情况。 Lambda表达式可以简化这些,它可以把功能当作参数, 把代码调用当成数据在不同的地方传输。

在前一节,我们说过匿名类, 就是说如何实现一个基类而不用指出子类名字。相对每个子类都要起个名字,这已经是比较简洁的做法了,然后对于只有一个方法的父类来说,这样仍然显得有点多余。Lambda表达式就是用来优美的实例化只有一个方法的类。

让我们来看一个Labda表达式使用的理想案例。

Labda表达式使用的理想案例

假定你正在创建一个社交网站, 需要实现这样的功能:管理员能执行任何操作,其中一种操作就是能给符合条件的成员发送消息。下面的表格详细描述了该功能:

字段 描述
功能名字 对选定的成员执行相应的操作
主要执行角色 管理员
前置条件 管理员已经登陆进系统
后置条件 执行的操作只会应用到符合条件的成员上
主要的步骤 1.管理员指定要选出成员的条件
2.管理员对选定的成员发作操作指令
3.管理员选中提交按钮
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

实现方式1:创建方法来查找符合特定条件的成员

最最简单的方式就是创建它几个方法,每个方法查找符合特定条件的成员,例如根据性别或者年龄。下面的方法就可以输出年龄大于一定岁数的会员:

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

温馨提示:一个列表(List)就是一个有序的集合(Collection). 集合就是把多个元素归并到一个单位, 它就是用来存储、获取、操纵以及传输聚合性数据的。如果想要了解更多关于集合的信息,点击Collections

这种实现方式会使你的系统很脆弱, 也就是说一旦有小的更新或者改动 (例如引入新的数据类型),整个系统可能就不工作了。设想一下,如果你更新你的系统,把Person 的结构更改一下,增加新的成员变量或者计算年龄的算法变了。 那你就不得不重写你的很多方法来满足此次更新。而且这个方案限制性太强,设想一下如果你想要输出年龄小于一定岁数的会员,怎么办?

实现方式2:创建更通用的查找方法

下面的方法比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();
        }
    }
}

如果你要输出指定性别的成员,或者性别和年龄段组合起来的条件查询到的成员,该怎么办?如果你想改变Person 的结构, 增加一些其它的属性,例如关系状态和地理位置,要如何实现? 虽然这个方法是比printPersonsOlderThan 方法强了不少,但是它只适用于年龄这个属性上。 如果其它属性还需要创建新的方法。 对每种查询条件都创建一个方法还是会使系统显得不那么强壮。你可以把代码分离, 查询条件单独写到一个类中去。

实现方式3:用局部类实现查询条件

下面的方法就会输出满足你你指定条件的成员:

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

这个方法会检查列表参数roster 中的每一个Person 实例,看它是不是符合参数tester(CheckPerson类型) 所指定的条件。 通过tester.test来调用检查,如果返回true,就是满足,然后personprintPerson方法就会被调用 。

为了定义查询条件,你需要实现CheckPerson 接口:

interface CheckPerson {
    boolean test(Person p);
}

下面的类实现了接口CheckPerson, 实现了接口的test方法。 这个方法会查出在美国适合服兵役的成员。方法会返回true, 如果成员是男性并且在18到25岁之间。

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

要使用这个类,你需要实例化它,然后调用printPersons 方法

printPersons(
    roster, new CheckPersonEligibleForSelectiveService()); 

这种实现方式看起来好多了,如果要改变Person类的结构,你不需要重写方法了。然而你却额外增加了代码, 一个接口和一个局部类,而且每个查询都要新创建一个类。 因为CheckPersonEligibleForSelectiveService 实现了一个接口,可以考虑用匿名类来代替局部类, 这样就不用为每个查询都声明一个新类了。

实现方式4:用匿名类指定查询条件

下面的方法printPersons调用中,有一个参数我们使用的匿名类, 这个匿名类就是用来找出在美国适合符兵役的成员, 也就是年龄在18到25之间的男性。

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

这处方式减少了很多代码,因为你不再需要为每一种查询条件声明一个新类, 直接用匿名类调用就可以了。然后考虑到CheckPerson 只有一个方法,匿名类看起来还是有点臃肿,在这种情况下,你可以用Lambda表达式来代替匿名类,我们会在下一节中介绍。

实现方式5:用Lambda表达式指定查询条件

CheckPerson接口是一个函数式接口。任何只包含一个抽象方法(一个函数式接口可能用一个或多个默认方法静态方法)的接口都可以叫做函数式接口。因为函数式接口只包含了唯一一个抽象方法,当你在实现它的时候,你可以省略掉方法名字。 要到达到这样效果, 你可以用Lambda表达式来代替匿名类:

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

要想了解更多关于如何定义Lambda表达式,可以查看Lambda表达式语法

你还可以用标准的函数式接口来代替CheckPerson ,这样能减少更多的代码。

实现方式6: 接合标准的函数式接口和Lambda表达式

再看一下CheckPerson 接口

interface CheckPerson {
    boolean test(Person p);
}

这是一个非常简单的接口。它是函数式接口因为它只有一个抽象方法。这个方法有一个参数一个布尔类型的返回值。这个方法是如此的简单,也许根本不用在你的程序中重写。因为JDK已经定义了几个函数式接口,你可以在java.util.function 包中找到。

例如你可以用Predicate<T>接口来代替CheckPerson 这个接口有个方法boolean test(T t)

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

Predicate<T>接口也是泛型的一种应用 (如果想了解更多关于泛型的信息, 可以看这里)泛型(泛型接口),会把一个或者多个参数放在尖括号<>中。 这个接口只包含了一个类型参数T。当你用一个具体的类型参数声明或者实例化一个泛型后,你就拥有一个参数化类型。例如参数化的类型Predicate<Person> 就会像下面的样子:

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

这个参数化的类型所拥有的方法和CheckPerson.boolean test(Person p)的参数以及返回类型都一样,因此你可以用Predicate<T>来代替CheckPerson

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

同样的,下面的方法调用,和你在实现方式3里面的调用效果是一样,都是找出在美国适合符兵役的成员。

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

这也不是唯一可以用Lambda表达式的地方,我们可以在其它很多地方使用Lambda表达式。

实现方案7: 让Lambda表达式遍布你的应用

让我们再来看一看这个方法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,你可以给Person定义另外一种操作, 当满足tester指定的条件,就执行此操作。可以用Lambda表达式来定义这个新操作。 假定你想用Lambda表达式实现printPerson, 只有一个参数Person,并且返回空。别忘了,要用Lambda表达式,你需要实现一个函数接口。 因此我们先要定义一个函数接口, 它包含唯一的抽象方法,参数是Person,并且返回空。 Consumer<T> 接口包含了方法 void accept(T t) 刚好满足这种情况, 真TM凑巧呀。接下来,我们就用Consumer<T> 的一个实例调用方法void accept(T t)来替换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);
            }
        }
}

接下来的调用就和你在方案3里面调用printPersons 有一样的功效,输出适合服兵役的人。

processPersons(
     roster,
     p -> p.getGender() == Person.Sex.MALE
         && p.getAge() >= 18
         && p.getAge() <= 25,
     p -> p.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);
        }
    }
}

下面的方法调用就会获取适合服兵役的会员邮件地址并且输出出来 。

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

实现方案8: 更惨无人道的使用泛型

让我们再来看看下面的方法, 这是一个泛型的版本,它能接收一个数据集合为参数, 这个数据集合可以包含任何类型的数据元素

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);
        }
    }
}

如果要输出适合服兵役的会员,可以这样调用processElements 方法。

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

我们来分析一下这个方法调用过程:

  1. 从集合(Collection) source中获取一个调用时传进来的源对象。在这个例子中,方法会从传进来roster获取到一个Person对象。注意roster是一个集合类型的列表,也是Iterable的对象类型。
  2. 然后开始过滤对象, 要符合Predicate对象tester所指定的条件。在我们这个例子中,我们传的Predicate对象是一个Lambda表达式,它指定了需要服兵役的条件。
  3. 根据函数式对象Mapper, 把过滤的对象或者对象属性映射到另外一个具体的值或对象上。 在我们这个例子中,函数式对象是一个Lambda表达式,会返回一个成员的电子邮件地址。
  4. 对每个映射的对象做相应的操作, 这个操作是Consumer的对象block来定义的. 在我们的例子中,Consumer对象是一个Lambda表达式,它会输出一个字串,也就是函数式对象返回的电子邮件地址。

上面的每一个操作你都可以用聚合操作来代替。

实现方式9: 使用聚合操作,把Lambda当作参数

下面的例子使用了聚合操作, 它会把集合roster里面需要服兵役成员的邮件地址输出出来:

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

来,我们来看一下下面这个表格,列出了方法processElements 里面的每一个行为以及对应的聚合操作表达形式:

processElements行为 聚合操作
获取一个来源对象 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, map, 和 forEach都是聚合操作。聚合操作是操作一个流里的元素,而不是直接操作一个集合的元素(这也是为什么在例子中我们上来就调用stream方法的原因)。一个流就是一个元素序列。不像集合,它不是一个存储元素的数据结构。 相反的,流通过管道存储了具体的值,例如来源于collection的值。一个管道有一系列的流操作,在这个例子中就是filter- map-forEach。 而且聚合操作接受Lambda表达式作为参数,能够使你自定义操作行为。

如果想要了解更多的聚合操作, 参照这里聚合操作

Lambda表达式在GUI(图形化操作界面)程序中的使用

在图形化界面的程序中,如果要处理事件,例如键盘操作、鼠标操作或者滚动操作, 典型的做法就是创建相应的事件处理程序(句柄),通常就是实现特定的接口。这样接口经常是函数式接口,因为只有一个方法需要实现。

在JavaFX的例子 HelloWorld.java 中,你可以把下面的匿名类用Lambda表达式替代。

        btn.setOnAction(new EventHandler<ActionEvent>() {

            @Override
            public void handle(ActionEvent event) {
                System.out.println("Hello World!");
            }
        });

方法btn.setOnAction 就是用来指明当用户点击btn这个按钮时,要做什么样的操作或者反应。这个方法需要一个对象类型是:EventHandler<ActionEvent> . EventHandler<ActionEvent> 接口只包含一个方法:void handle(T event), 因此这个接口是一个函数式接口, 所以你可以用下面的Lambda表达式来代替:

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

Lambda表达式的语法

一个Lambda表达式包含下列部分:

  • 包含在花括号内逗号分隔的正常参数。CheckPerson.test 包含了一个参数 p, 代表Person类的一个实例。
    注意:在Lambda表达式中,你可以省略不写数据类型,而且只有一个参数的话,你可以把花括号也省略掉,例如下面的Lambda表达式就是有效的:

        p -> p.getGender() == Person.Sex.MALE 
        && p.getAge() >= 18
        && p.getAge() <= 25
  • 箭头符号 ->

  • 主体,包含简单的表达式或者语句块,在这个例子中,主体是下面的表达式:

     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表达式其实看上去更像一个方法的声明,你可以把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.println("40 + 2 = " +
            myApp.operateBinary(40, 2, addition));
        System.out.println("20 - 10 = " +
            myApp.operateBinary(20, 10, subtraction));    
    }
}

方法operateBinary 对两个整型操作数进行算术运算。运算方式是用接口IntegerMath声明的。这个例子用Lambda表达式定义了两种操作,additionsubtraction。 上面的代码会输入下面结果:

40 + 2 = 42
20 - 10 = 10

访问封闭范围内的局部变量

就如同局部类和匿名类一样, Lambda表达式能捕获携带变量, 它一样可以访问封闭范围内的局部变量。然而不像局部类和匿名类,Lambda表达式没有任何变量遮蔽的问题(想了解更多关于遮蔽, 看这里)。Lambda表达式是词法范围的东西,静态范围。也就是说他们不会从父类继承任何变量名字下来, 也不会改变变量的作用域。在Lambda表达式里面的任何变量声明就好像在它们自己封闭环境中一样。下面的例子 LambdaScopeTest,来详细说明这个特征:

import java.util.function.Consumer;

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 fl = st.new FirstLevel();
        fl.methodInFirstLevel(23);
    }
}

上面的代码会输出以下结果:

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

如果你Lambda表达式括号定义的y换成x, 编译就会报错。
错误会提示:“变量x已经在方法methodInFirstLevel(int)里定义了”,因为Lambda表达式不会引入新一级的变量作用域。相应的,你可以直接访问包装这个Lambda表达式代码块内的字段、方法以及局部变量。例如Lambda表达式就可以直接访问方法methodInFirstLevel 的参数x。 如果要访问包装它类内的变量,就用this。在这个例子中,this.x指的就是类成员变量FirstLevel.x

然而,就如同局部类和匿名类一样,Lambda表达式只能访问final或者事实上是final的局部变量或者参数。例如,如果你在方法methodInFirstLevel定义之后加上一句赋值语句:

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

因为这个赋值语句,变量FirstLevel.x不再是事实上的final, 那么编译就会报错:Lambda表达式引用的局部变量必须是final或者事实上的final。报错的地方就是这里:

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

目标决定类型

你怎么样判定一个Lambda表达式的类型?让我们回头看一下我们之前提到的Lambda表达式,用来选出年龄在18到25岁之间的男性成员。

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

这个Lambda表达式在下面两个方法里用到了:

当Java运行器调用方法printPersons 时,它期望的数据类型是CheckPerson, Lambda表达式就会是这种类型。然而当Java运行器调用printPersonsWithPredicate 时, 它期望的类型是Predicate<Person>, 而Lambda表达式就变成了这种类型。这些方法所期望的类型,我们叫做目标类型。为了判定一个Lambda表达式的类型,java编译器会在能找到Lambda表达式的上下文或者情形下使用的目标类型来决定。也就说,Lambda表达式只能使用在java编译器可以判定出目标类型的地方:
- 变量声明时
- 赋值时
- 返回语句(Return)
- 数组初始化时
- 方法或者构造器的参数
- Lambda表达式的内容里
- 条件表达式 ?:
- 类型转换表达式 (Cast)

目标类型和方法参数

关于方法参数,Java编译器在判定目标类型的时候用到了语言的另外两个特性:重载解析和类型参数推断。
我们来看一下下面的两个函数式接口:( java.lang.Runnablejava.util.concurrent.Callable)

public interface Runnable {
    void run();
}

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

方法Runnable.run不会返回任何值,但是Callable<V>.call 会返回。
假定你按下面的方式重载了方法invoke (如果想要了解更多的重载方法,可以查看定义方法)

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

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

那么下面的语句,哪一个invoke方法会被调用到?

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

invoke(Callable<T>) 方法会被调用到,因为这个方法是有返回值的方法。invoke(Runnable)方法不会被调用到。在这种情况下,Lambda表达式 () -> "done" 的数据类型就是Callable<T>

序列化

如果一个Lambda表达式的目标类型和携带参数都是可序列化的,你可以序列化这个Lambda表达式。然而,就像匿名类,强烈建议别瞎捣腾去序列化Lambda表达式。