Java基础教程(23)--lambda表达式

一.初识lambda表达式

1.定义

  lambda表达式是一个可传递的代码块,或者更确切地说,可以把lambda表达式理解为简洁地表示可传递的匿名方法的一种方式。它没有名称,但它有参数列表、函数主体和返回类型。下面阐述了lambda表达式的几个特点:

  • 匿名——有别于普通方法,lambda表达式没有名称。
  • 方法——我们说它是方法,是因为尽管lambda表达式不像方法那样属于某个特定的类。但和方法一样,lambda有参数列表、方法主体和返回类型。
  • 传递——lambda表达式可以作为参数传递给方法或存储在变量中。
  • 简洁——无需像匿名类那样写很多模板代码。

2.语法

  下面是一个lambda表达式的例子:

(int a, int b) -> System.out.println("a + b = " + (a + b))

  一个lambda表达式由参数列表、箭头(->)和主体组成。下面将分别对参数列表和lambda表达式的主体进行详细介绍。

(1)参数列表

  和普通方法一样,lambda表达式也有参数列表。例如上面的例子中的(int a, int b)表示lambda主体中的代码需要使用a和b两个参数,这和一般的方法并没有什么区别。lambda表达式的参数列表使用一对小括号包围,里面依次是每个参数的类型和名称。
  实际上,lambda表达式的参数列表可以省略参数的类型,编译器会自动推断出每个参数的类型(具体有关这方面的内容会在稍后进行介绍)。也就是说,上面的lambda表达式可以改写为:

(a, b) -> System.out.println("a + b = " + (a + b))

  如果只有一个参数,就可以省略括号。例如:

a -> System.out.println("a = " + a)

  如果没有参数,那么仍然需要提供空括号:

() -> System.out.println("No parameter.")

  

(2)lambda表达式的主体

  lambda表达式的主体可以由一个表达式、一条语句或若干条语句组成。当lambda表达式的主体是多条语句时,需要将这些语句放在大括号{}中;当lambda表达式的主体只有一个表达式时,没有必要使用并且也不能使用大括号;当lambda表达式的主体只有一条语句时,可以省略大括号。
  下面的lambda表达式的主体只有一个表达式:

(a, b) -> a + b

  在执行主体内只有一个表达式的lambda表达式时,JVM会计算出表达式的结果并将其返回。
  下面的lambda表达式的主体只有一条语句:

a -> System.out.println("a = " + a)

  可以为这条语句加上大括号,注意最后要使用分号。不过这样做显然没什么意义,这里仅仅只是做个说明:

a -> {System.out.println("a = " + a);}

  当lambda表达式的主体是多条语句时,需要将这些语句放在大括号{}中。例如:

() -> {
    System.out.println("I'm statement 1.");
    System.out.println("I'm statement 2.");
}

二.使用lambda表达式的例子

  虽然上面已经介绍了lambda表达式的定义和语法,但我相信很多人读到这里对于lambda表达式还是一头雾水。应该在什么时候使用它?如何去使用它?下面将使用一个例子作为讲解,在这个例子中,我们将使用不同的语法来应对不断变化的需求,以展示一些让代码更灵活的最佳做法。

这个例子来自于《Java 8实战》一书,如果有需要pdf版本的可以在文章下面留下邮箱,我会及时发出。

1.筛选绿苹果

  假设有一个Apple类,它有一个getColor方法,还有一个变量inventory,它是一个保存着若干个Apple的列表。现在我们需要帮果农从这些苹果中筛选出绿色的苹果,那么我们的第一个解决方案可能是下面这样的:

public static List<Apple> filterGreenApples(List<Apple> inventory) {
    List<Apple> result = new ArrayList<>();
    for(Apple apple: inventory){
        if("green".equals(apple.getColor()) {
            result.add(apple);
        }
    }
    return result;
}

2.把颜色作为参数

  果农突然改变主意了,他还想要筛选红色的苹果,那应该怎么做呢?简单的解决办法就是复制这个方法,把名字改成filterRedApples,然后更改if条件来匹配红苹果。然而,如果果农需要筛选更多颜色:浅绿色、暗红色、黄色等,那么我们就需要为每一种颜色编写一个新的方法,这显然是不可取的。一个良好的原则是在编写类似的代码之后,尝试将其抽象化。
  一种做法是给方法加一个参数,把颜色变成参数,这样就能灵活地适应变化了:

public static List<Apple> filterApplesByColor(List<Apple> inventory, String color) {
    List<Apple> result = new ArrayList<Apple>();
    for(Apple apple: inventory){
        if(apple.getColor().equals(color)) {
            result.add(apple);
        }
    }
    return result;
}

  现在,只要像下面这样调用方法,果农朋友就会满意了:

List<Apple> greenApples = filterApplesByColor(inventory, "green");
List<Apple> redApples = filterApplesByColor(inventory, "red");

3.筛选其他属性

  现在这位果农又告诉我们:“要是能区分轻的苹果和重的苹果就太好了。重的苹果的重量大于150克。”
  这肯定难不倒我们,于是我们编写了另一个方法,用一个参数来应对不同的重量:

public static List<Apple> filterApplesByWeight(List<Apple> inventory, int weight) {
    List<Apple> result = new ArrayList<Apple>();
    for(Apple apple: inventory){
        if(apple.getWeight() > weight){
            result.add(apple);
        }
    }
    return result;
}

  这个解决方案看上去不错,但是请注意,我们复制了大部分的代码来实现遍历库存,并对每个苹果应用筛选条件。这有点儿令人失望,因为它打破了DRY(Don’t Repeat Yourself,不要重复自己)的软件工程原则。如果我们想要改变筛选遍历方式来提升性能呢?那就得修改所有方法的实现,而不是只改一个。从工作量的角度来看,这代价太大了。
  可以将颜色和重量结合为一个方法。不过就算这样,还是需要一种方式来区分想要筛选哪个属性。可以加上一个标志来区分对颜色和重量的查询,就像下面这样:

public static List<Apple> filterApples(List<Apple> inventory, String color, int weight, boolean flag) {
    List<Apple> result = new ArrayList<Apple>();
        for(Apple apple: inventory) {
            if( (flag && apple.getColor().equals(color)) ||
                (!flag && apple.getWeight() > weight) ){
            result.add(apple);
        }
    }
    return result;
}

  现在我们可以使用这个方法来筛选颜色和重量:

List<Apple> greenApples = filterApples(inventory, "green", 0, true);
List<Apple> heavyApples = filterApples(inventory, "", 150, false);

  这个方案再差不过了。首先,代码可读性很差。true和false是什么意思?为什么筛选一个属性的时候还要提供另外一个属性的默认值?此外,这个解决方案还是不能很好地应对变化的需求。如果这位果农要求我们对苹果的不同属性做筛选,比如大小、形状、产地等,又怎么办?而且,如果果农要求我们组合属性,做更复杂的查询,比如绿色的重苹果,又该怎么办?我们将会有好多个重复的filter方法,或一个巨大的非常复杂的方法。我们需要一种更好的方式,来把苹果的选择标准告诉filterApples方法。在下一节中,我们会介绍了如何利用行为参数化实现这种灵活性。

4.对筛选条件进行抽象

  我们需要一种比添加很多参数更好的方法来应对变化的需求。让我们后退一步来看看更高层次的抽象。一种可能的解决方案是对选择标准建模:根据Apple的某些属性(比如它是绿色的吗?重量超过150克吗?)来返回一个boolean值。我们把它称为谓词(即一个返回boolean值的函数)。让我们定义一个接口来对选择标准建模:

public interface ApplePredicate {
    boolean test(Apple apple);
}

  现在可以用ApplePredicate的多个实现代表不同的选择标准:

public class AppleHeavyWeightPredicate implements ApplePredicate {  // 选出重的苹果
    public boolean test(Apple apple) {
        return apple.getWeight() > 150;
    }
}
public class AppleGreenColorPredicate implements ApplePredicate {   // 选出绿苹果
    public boolean test(Apple apple) {
        return "green".equals(apple.getColor());
    }
}

  但是,该怎么利用ApplePredicate的不同实现呢?我们需要filterApples方法接受ApplePredicate对象,对Apple做条件测试。这就是行为参数化:让方法接受多种行为作为参数,并在内部使用,来完成不同的行为。
  利用ApplePredicate改造之后,filter方法是这样的:

public static List<Apple> filterApples(List<Apple> inventory, ApplePredicate p){
    List<Apple> result = new ArrayList<>();
    for(Apple apple: inventory){
        if(p.test(apple)){
            result.add(apple);
        }
    }
    return result;
}

  这段代码比我们第一次尝试的时候灵活多了,读起来、用起来也更容易!现在我们可以创建不同的ApplePredicate对象,并将它们传递给filterApples方法。比如,如果果农让我们找出所有重量超过150克的红苹果,我们只需要先创建一个类来实现ApplePredicate:

public class AppleRedAndHeavyPredicate implements ApplePredicate{
    public boolean test(Apple apple){
        return "red".equals(apple.getColor()) && apple.getWeight() > 150;
    }
}

  然后在代码中去使用这个筛选条件:

List<Apple> redAndHeavyApples = filterApples(inventory, new AppleRedAndHeavyPredicate());

5.对付啰嗦

  虽然我们的代码现在足够灵活,可以应对任何涉及苹果属性的需求变更。但是,每次把新的筛选条件传递给filterApples方法的时候,我们不得不声明一个实现ApplePredicate接口的类并将其实例化,尽管这个类可能只会用到一次。下面的程序总结了你目前看到的一切。这真的很啰嗦,很费时间!

public class AppleHeavyWeightPredicate implements ApplePredicate {
    public boolean test(Apple apple){
        return apple.getWeight() > 150;
    }
}
public class AppleGreenColorPredicate implements ApplePredicate {
    public boolean test(Apple apple){
        return "green".equals(apple.getColor());
    }
}
public class FilteringApples{
    public static void main(String...args){
        List<Apple> inventory = new ArrayList<>();
        inventory.add(new Apple(80,"green"));
        inventory.add(new Apple(155, "green"));
        inventory.add(new Apple(120, "red"));
        
        List<Apple> heavyApples = filterApples(inventory, new AppleHeavyWeightPredicate());
        List<Apple> greenApples = filterApples(inventory, new AppleGreenColorPredicate());
    }

    public static List<Apple> filterApples(List<Apple> inventory, ApplePredicate p) {
        List<Apple> result = new ArrayList<>();
        for (Apple apple : inventory){
            if (p.test(apple)){
                result.add(apple);
            }
        }
        return result;
    }
}

  费这么大劲儿真没必要,能不能做得更好呢?还记得之前的匿名类吗?它可以让你同时声明和实例化一个类,可以帮助你进一步改善代码,让代码变得更简洁。
  下面的代码展示了如何通过创建一个用匿名类实现ApplePredicate的对象,重写筛选的例子:

List<Apple> redApples = filterApples(inventory, new ApplePredicate() {
    public boolean test(Apple apple){
        return "red".equals(apple.getColor());
    }
});

  当需要新的筛选条件时,我们可以使用匿名类来实现并实例化ApplePredicate接口,这样就无需为每一个筛选条件创建一个新的类了。

6.使用lambda表达式

  匿名类还是不够好。它往往很笨重,因为它占用了很多空间。并且它用起来很让人费解。即使匿名类处理在某种程度上改善了为每一个筛选条件创建一个实现类的啰嗦问题,但它仍不能令人满意。
  现在回过头来观察我们的代码。我们的filterApples方法所需要的只是一个筛选固定特征的方法。但是,由于我们无法向filterApples方法传递一个方法,所以我们只能将这个方法放在类里,将其实例化之后再传递给filterApples方法。尽管匿名类解决了为每一个筛选条件创建一个实现类的问题,但我们传递的仍然是对象而不是方法。
  回想一下上面对lambda表达式的定义——lambda表达式是一个可传递的匿名方法。这意味着从Java 8之后,我们可以将方法当作参数一样传递了。现在以筛选红苹果的方法为例,使用lambda表达式对它进行改造:

public boolean test(Apple apple){
    return "red".equals(apple.getColor());
}

  这个方法接受一个Apple对象作为参数,判断它的颜色是否为红色并返回。使用lambda表达式可以将其重写为:

apple -> "red".equals(apple.getColor())

  我们可以将这个lambda当成参数一样传递给filterApples方法:

List<Apple> result = filterApples(inventory, apple -> "red".equals(apple.getColor()));

  不得不说,现在的代码比我们最开始写的简洁太多了。我们在灵活性和简洁性之间找到了最佳平衡点,这在Java 8之前是不可能做到的。

三.函数式接口

  在看完上面的例子之后,相信读者已经了解了如何将lambda表达式作为参数传递。但是,什么样的参数或者变量才可以接受lambda表达式呢?答案就是函数式接口。现在我们来详细解释什么是函数式接口。
  函数式接口就是只含有一个抽象方法的接口。一个接口可能会有超过一个的默认方法或静态方法,但只要这个接口只有一个抽象方法,它就是函数式接口。
  我们在上面定义的ApplePredicate接口就是一个函数式接口,因为它只含有一个抽象方法test。我们可以在需要传递ApplePredicate类型的变量的地方去传递一个lambda表达式,就像上面的例子一样:

List<Apple> result = filterApples(inventory, apple -> "red".equals(apple.getColor()));

  需要注意的是,不是任何lambda表达式都可以传递给函数式接口类型的变量。在传递lambda表达式时,lambda表达式的参数和返回值类型要与函数式接口中的抽象方法的参数和返回值类型相匹配。这里我们使用一个特殊表示法来描述Lambda和函数式接口的签名。()->void代表了参数列表为空,且没有返回值的函数。同理,表达式apple->"red".equals(apple.getColor())所对应的签名就是(Apple)->boolean,这意味着这个表达式接受一个Apple类型的参数并返回一个布尔值,这和ApplePredicate接口的test方法完全匹配,因此我们可以将这个表达式传递给filterApples方法。
  利用这个特性,我们在编写代码时,如果某个方法需要传递行为,或者更通俗地说需要传递代码块,我们就可以先定义一个函数式接口,然后将让该方法接受一个函数式接口类型的变量,这样将来在使用这个方法时就可以向它传递lambda表达式。
  实际上,Java 8已经为我们提供了一些常用的函数式接口,它们位于java.util.function包中,一共是43个。下面对这些函数式接口进行简单地介绍。

1.Perdicate

  Predicate是谓词的意思,它表示那些接受一个参数并返回一个布尔值的函数式接口。下面是java.util.function包中属于Predicate类型的函数式接口:

函数式接口 签名
Predicate T->boolean
IntPredicate int->boolean
LongPredicate long->boolean
DoublePredicate double->boolean
BiPredicate<T,U> (T,U)->boolean

2.Consumer

  Consumer是消费者的意思,它表示那些接受参数并且没有返回值的函数式接口。下面是java.util.function包中属于Consumer类型的函数式接口:

函数式接口 签名
Consumer T->void
IntConsumer int->void
LongConsumer long->void
DoubleConsumer double->void
BiConsumer<T,U> (T,U)->void
ObjIntConsumer (T,int)->void
ObjLongConsumer (T,long)->void
ObjDoubleConsumer (T,double)->void

3.Supplier

  Supplier是提供者的意思,它表示那些不接受参数但是有返回值的函数式接口。下面是java.util.function包中属于Supplier类型的函数式接口:

函数式接口 签名
Supplier ()->T
BooleanSupplier ()->boolean
IntSupplier ()->int
LongSupplier ()->long
DoubleSupplier ()->double

4.Function

  Function表示那些接受一种或两种参数,返回其他类型的返回值的函数式接口。下面是java.util.function包中属于Function类型的函数式接口:

函数式接口 签名
Function<T,R> T->R
IntFunction int->R
LongFunction long->R
DoubleFunction double->R
ToIntFunction R->int
ToLongFunction R->long
ToDoubleFunction R->double
IntToLongFunction int->long
IntToDoubleFunction int->double
LongToIntFunction long->int
LongToDoubleFunction long->double
DoubleToIntFunction double->int
DoubleToLongFunction double->long
BiFunction<T,U,R> (T,U)->R
ToIntBiFunction<T,U> (T,U)->int
ToLongBiFunction<T,U> (T,U)->long
ToDoubleFunction<T,U> (T,U)->double

5.Operator

  Operator表示那些参数和返回值类型一样的函数式接口。下面是java.util.function包中属于Function类型的函数式接口:

函数式接口 签名
UnaryOperator T->T
IntUnaryOperator int->int
LongUnaryOperator long->long
DoubleUnaryOperator double->double
BinaryOperator (T,T)->T
IntBinaryOperator (int,int)->int
LongBinaryOperator (long,long)->long
DoubleBinaryOperator (double,double)->double

  上面的这些函数式接口基本覆盖了我们可能遇到的大部分情况。当然,如果上面的这些函数式接口中没有能够满足我们的需求的,可以自己定义一个函数式接口,就像下面这样:

@FunctionalInterface
public interface TernaryConsumer<T> {
    void accept(T t1, T t2, T t3);
}

注:并不是说使用@FunctionalInterface注解的接口就是函数式接口,函数式接口只与抽象方法的个数有关。使用@FunctionalInterface注解可以让编译器帮我们检查所设计的接口是不是一个函数式接口。如果使用@FunctionalInterface注解的接口中有超过一个的抽象方法,编译器就会给出错误。这一点和@Override注解类似,它可以让编译器帮我们检查所注解的方法是否重写了父类或接口的方法。

四.方法引用

  方法引用让你可以重复使用现有的方法定义,并像lambda一样传递它们。在一些情况下,比起使用Lambda表达式,它们似乎更易读,感觉也更自然。方法引用可以被看作仅仅调用特定方法的lambda的一种快捷
写法。它的基本思想是,如果一个lambda代表的只是“直接调用这个方法”,那最好还是用名称来调用它,而不是去描述如何调用它。
  当需要使用方法引用时,将目标引用放在分隔符::前,方法的名称放在后面。例如,对于下面的lambda表达式:

(Apple a) -> a.getWeight()

  它仅仅是调用了Apple的getWeight方法,因此可以使用下面的方法引用替换它:

Apple::getWeight

  请记住,方法名称后面不需要括号,因为并没有实际调用这个方法。
  方法引用主要有三类:
(1)指向静态方法的方法引用(例如Integer的parseInt方法,写作Integer::parseInt)。
(2)指向任意类型实例方法的方法引用(例如String的length方法,写作String::length)。
(3)指向现有对象的实例方法的方法引用(假设有一个Apple类型的变量a,那么它的getWeight可以写作a::getWeight)。
  还可以创建对构造方法的引用。引用构造方法的语法为:

ClassName::new

  假设有一个构造函数没有参数。它适合Supplier的签名() -> Apple。你可以这样做:

Supplier<Apple> c1 = Apple::new;
Apple a1 = c1.get();

  这等价于:

Supplier<Apple> c1 = () -> new Apple();
Apple a1 = c1.get();

  如果你的构造函数的签名是Apple(Integer weight),那么它就适合Function接口的签名,于是你可以这样写:

Function<Integer, Apple> c2 = Apple::new;
Apple a2 = c2.apply(110);

  这等价于:

Function<Integer, Apple> c2 = (weight) -> new Apple(weight);
Apple a2 = c2.apply(110);

五.捕获变量

  与局部类和匿名类一样,lambda表达式也可以捕获变量,也就是说lambda表达式可以使用外层作用域中定义的变量。例如:

int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber);

  lambda可以没有限制地捕获实例变量和静态变量。但局部变量必须显式声明为final,或实际上是final。例如,下面的代码将会出现编译错误:

int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber);
portNumber = 31337;

  你可能会问,为什么局部变量有这些限制。实例变量和局部变量背后的实现有一个关键不同。实例变量都存储在堆中,而局部变量则保存在栈上。如果lambda是在另一个线程中使用的,则使用lambda的线程,可能会在分配该变量的线程将这个变量收回之后,去访问该变量。因此,lambda在访问局部变量时,实际上是在访问它的副本,而不是访问原始变量。既然是访问副本,那么对副本的修改自然不会应用到真正的那个局部变量。因此,为了避免造成可以在lambda中修改局部变量的假象,就有了这个限制。

posted @ 2019-04-13 10:57  maconn  阅读(632)  评论(0编辑  收藏  举报