一文带你深入了解 Lambda 表达式和方法引用

时间:2023-03-09 16:26:58
一文带你深入了解 Lambda 表达式和方法引用

前言

尽管目前很多公司已经使用 Java8 作为项目开发语言,但是仍然有一部分开发者只是将其设置到 pom 文件中,并未真正开始使用。而项目中如果有8新特性的写法,例如λ表达式。也只是 Idea Alt+Enter 生成的。最近天气非常热,出门晒太阳不如和我一起系统的学习一下 Java8 的新特性。提高开发效率也可、享受同事羡慕的眼神也可,让我们开始吧

声明:本文首发于博客园,作者:后青春期的Keats;地址:https://www.cnblogs.com/keatsCoder/ 转载请注明,谢谢!

新特性

函数式编程:Lambda表达式、流式编程

其他特性:默认方法、新的Optional类、CompletableFutrue、LocalDate/LocalTime

这篇文章重点讨论 Lambda 及某些情况下更易读、更自然的方法引用。

Lambda表达式

行为参数化

行为参数化就是一个方法接受多个不同的行为作为参数,并在内部使用他们,完成不同行为的能力。其实说白了就是将一段代码作为另一个方法的形参,使该方法更加的灵活、可以应对多变的需求。

举个关于苹果的例子

例如老师安排张三这么一个任务("法外狂徒"张三改行做程序员了):篮子有很多苹果 List ,需要筛选出这些苹果中的绿色苹果

根据具象筛选苹果

这个需求很简单,张三两下就搞定了:

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

可是这个时候老师改主意了。说绿色的不好吃想吃红色的苹果,张三只好复制这个方法进行修改,将green改成red并修改方法名为 filterRedApples。然而如果老师又让他筛选多种其他颜色的苹果,例如:浅绿色、暗红色、黄色等。这种复制、修改的方法就显得有些难应付。一个良好的原则是尝试抽象其共性

对于筛选苹果的需求,可以尝试给方法添加一个参数 color。非常简单的就可以应对老师对不同颜色苹果的需求。

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

张三满意的提交了代码。但是这时老师又对张三说:我想要一些重一点的苹果,一般大于150g的苹果就是比较重的。作为程序员,张三早就想好老师可能会改重量。因此提前定义一个参数作为苹果的重量:

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

解决方案不错。可是张三复制了大量的方法用于遍历库存。并对每个苹果应用筛选条件。他打破了DRY(Dont repeat youselt 不要重复自己)的软件设计原则。试想一下,如果张三想换一种遍历的方式,那么每个方法都需要再改一次,工作量很大。那有没有一种方法能将颜色和质量组合成一个方法呢?可以尝试加一个 flag,然后根据 flag 的值来确定使用哪个判断条件。但这种方法十分差劲!试想如果以后有了更多的条件:苹果的大小、产地、品种等等。这个代码应该怎么维护?因此张三需要一种更加灵活的方式来实现筛选苹果的方法。

根据抽象条件筛选

不管使用什么条件筛选,他们都有共性:

  • 需要一个苹果
  • 执行一段代码
  • 返回一个 boolean 的值

其中执行一段代码这一步是不确定的,而参数和返回值是确定的,因此我们可以定义一个接口:

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

及不同条件筛选的实现:

public class AppleHeavyWeightPredicate implements ApplePredicate{
@Override
public boolean test(Apple apple) {
return apple.getWeight() > 150;
}
}

筛选的方法也可以改成这样:

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

这时无论应对怎样的需求,张三只需要重新实现 test 方法,然后通过 filterApples 方法传递 test 方法的行为。这表示 filterApples 方法的行为参数化了!

但是张三又觉得这样的实现太麻烦了,每次新来一个需求他都需要创建一个类实现 ApplePredicate 接口。有没有更好的办法呢?答案是肯定的。在 Java8 之前可以通过匿名类来实现:

public static void main(String[] args) {
List<Apple> appleList = new ArrayList<>();
appleList.add(new Apple("red", 150)); List<Apple> result = filterApples(appleList, new ApplePredicate() {
@Override
public boolean test(Apple apple) {
return "green".equals(apple.getColor()) && apple.getWeight() > 150;
}
});
}

匿名类虽然可以解决创建新类的问题,但是他太长了。那要如何简化呢? Java8 提供的 Lambda 就是专门用来简化它的。且看代码:

public static void main(String[] args) {
List<Apple> appleList = new ArrayList<>();
appleList.add(new Apple("red", 150)); List<Apple> result = filterApples(appleList, apple -> "green".equals(apple.getColor()) && apple.getWeight() > 150);
}

从苹果的例子可以看到,行为参数化是一种很有用的模式,它能够轻松应对多变的需求,它通过把一个行为(一段代码)封装起来,并通过传递和使用创建的行为将其参数化。这种做法类似于策略设计模式。而JavaAPI中已经在多出实践过这个模式了,例如 Comparator 排序、Runnable执行代码块等等

Lambda管中窥豹

Lambda是一种简洁的传递一个行为的匿名函数,它没有名称,却有参数列表、函数主体、返回值、甚至还可以抛出异常。基本语法像这样:

(parameters) -> {statements;}

(parameters) -> expression

在哪里及如何使用Lambda

函数式接口

函数式接口就是只定义一个抽象方法的接口(如果接口中定义了默认方法实现,无论有多少个。只要它只有一个抽象方法,它仍然是函数式接口)

前面我们在 ApplePredicate 接口中只定义了一个抽象方法 test,所以 ApplePredicate 接口就是函数式接口。类似的还有 Comparator 和 Runnable 等。 Lambda 可以代替匿名类来作为函数式接口的实例。

public static void main(String[] args) {
Runnable r1 = () -> System.out.println("Hello World 1");
Runnable r2 = new Runnable() {
@Override
public void run() {
System.out.println("Hello World 2");
}
};
process(r1);
process(r2);
process(() -> System.out.println("Hello World 3"));
} public static void process(Runnable r){
r.run();
}

@FunctionalInterface

该注解可以用来声明一个接口是函数式接口,如果接口上有声明,但程序员又为接口写了其他抽象方法,编译器会报错

环绕执行模式

资源处理(处理文件、数据库)常见的操作方法就是:打开一个资源、做一些处理、关闭/释放资源。这个打开和关闭阶段总是很相似,并且会围绕执行处理的哪些重要代码。这就是所谓的环绕执行模式。例如:

public static String readLine() throws IOException {
try(BufferedReader br = new BufferedReader(new FileReader("a.txt"))){
return br.readLine();
}
}

这个写法是有局限性的,因为你无法灵活的修改处理逻辑的代码。那就跟着我来将他改造成 lambda 可用的形式吧

  1. 行为参数化

    首先我们要做的行为定义为 processFile。以下是从文件中读取两行的参数化写法

    String result = processFile( BufferedReader br -> br.readLine() + br.readLine())

  2. 使用函数式接口来传递行为

    processFile 这个方法需要匹配的函数描述符长这样: BufferedReader -> String 。那我们可以照着它定义接口

    @FunctionalInterface
    public interface BufferedReaderProcesser {
    String profess(BufferedReader br);
    }
  3. 执行一个行为

    改造 processFile 方法,让 BufferedReaderProcesser 接口作为它所执行行为的载体

    public static String processFile(BufferedReaderProcesser brf) throws IOException {
    try(BufferedReader br = new BufferedReader(new FileReader("a.txt"))){
    return brf.professFile(br);
    }
    }
  4. 传递Lambda

    接下来就可以使用 Lambda 来传递不同的行为来以不同的方式处理文件了:

    public static void main(String[] args) throws IOException {
    // 读一行
    String str1 = processFile(br -> br.readLine());
    // 读两行
    String str2 = processFile(br -> br.readLine() + " " + br.readLine());
    // 找到第一个包含 lambda 的行
    String str3 = processFile(br ->
    {
    String s;
    while ((s = br.readLine()).length() > 0) {
    if (s.contains("lambda")) {
    return s;
    }
    }
    return null;
    }
    );
    System.out.println(str1);
    System.out.println(str2);
    System.out.println(str3);
    }

    且看控制台的输出:

    一文带你深入了解 Lambda 表达式和方法引用

Java提供的函数式接口

Java8 的设计师们在 java.util.function 包中引入了很多新的函数式接口,以下是几个常用的

Predicate

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

布尔类型接口:在需要将一个任意类型的对象处理成布尔表达式时,可能需要它。例如我们之前处理的苹果,当然 T 也可以是学生对象(筛选出身高大于多少的)、用户对象(筛选具有某特征的用户)等等

Consumer

@FunctionalInterface
public interface Consumer<T> {
void accept(T t);
}

消费类型接口:Consumer 是一个消费型方法,他接收一个泛型 然后处理掉。不返回任何东西。

Function

@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
}

如果你需要定义一个 Lambda 将输入的对象信息映射到输出,那 Function 是再合适不过的了。

function 包下还有需要类似的函数式接口,读者可以自行去关注一下接口中方法的参数和返回值。来决定使用哪个。

类型检查、类型推断及限制

通过上面的介绍,读者已经对 Lambda 表达式的写法有了一定的了解,那么 Java 编译器是如何识别 Lambda 的参数和返回值的呢?

类型检查

Java 通过上下文(比如,接受他传递的方法的参数或是接受他值的局部变量)来推断 Lambda 表达式需要的目标类型而这个目标类型一般是一个函数式接口,之后判断表达式的参数和返回值是否与接口中唯一抽象方法的声明相对应

类型推断

Java 编译器从上下文中推断出表达式的目标类型后,表达式的参数类型也就被编译器所知道。所以书写表达式时可以省略参数类型,例如:

String str1 = processFile(br -> br.readLine());

processFile 方法的参数(Lambda的目标类型)是:BufferedReaderProcesser brf。BufferedReaderProcesser 接口唯一的抽象方法:String profess(BufferedReader br);方法声明的参数类型是 BufferedReader 。Java 编译器可以推断到这里。因此直接写 br 是没问题的。对于两个参数的方法也可以省略参数类型。而一个参数的方法可以省略参数类型和参数两边的括号

方法引用

方法引用让你可以重复使用现有的方法定义,并像 Lambda 一样传递它们。即提前写好的,可复用的 Lambda 表达式。如果一个 Lambda 代表的只是“直接调用这个方法”,那最好还是用名称调用它。方法引用的写法如下:

目标引用::方法名 // 因为这里没有实际调用方法,故方法的 () 不用写

三类方法引用

  • 指向静态方法的方法引用

    (args) -> ClassName.staticMethod(args) 写成 ClassName::staticMethod

  • 指向任意类型实例方法的方法引用,例如 T 类的实例 arg0

    (arg0, rest) -> arg0.instanceMethod(rest) 写成 T::instanceMethod

  • 指向现有对象的实例方法的方法引用。

    (args) -> expr.instanceMethod(args) 写成 expr::instanceMethod

第二类和第三类乍看有些迷糊,仔细分辨可以发现:如果方法的调用者是 Lambda 的参数,则目标引用是调用者的类。如果调用者是已经存在的实例对象,则目标引用是该对象

构造函数方法引用

方法引用还可以被用在构造函数上,写法是这样:ClassName::new

比如获取对于获取类型Supplier的接口,我分别用三种写法写出创建一个苹果对象的方法:

// 方法引用写法
Supplier<Apple> s1 = Apple::new;
// Lambda 写法
Supplier<Apple> s2 = () -> new Apple();
// 普通写法
Supplier<Apple> s3 = new Supplier<Apple>() {
@Override
public Apple get() {
return new Apple();
}
};

复合 Lambda 表达式

上面我们所讨论的 Lambda 表达式都是单独使用的,而 function 包中很多接口中还定义了额外的默认方法,用来复合 Lambda 表达式。

比较器复合

倒序

假如我们有一个给苹果按指定重量排序的方法

List<Apple> appleList = new ArrayList<>();
// 构造一个按质量升序排序的比较器
Comparator<Apple> c = Comparator.comparing(Apple::getWeight);
appleList.sort(c);
// 按质量倒叙
appleList.sort(c.reversed());

其中,Comparator.comparing 方法是一个简化版的 compare 方法的实现形式,源码如下:

public static <T, U extends Comparable<? super U>> Comparator<T> comparing(
Function<? super T, ? extends U> keyExtractor)
{
Objects.requireNonNull(keyExtractor);
return (Comparator<T> & Serializable)
(c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2));
}

该方法接收一个 Function 接口的实现类作为参数,而我们的 Apple::getWeight 方法解析过来就是实现了 Function 接口,重写 apply 方法,apply 方法的声明解析为 int apply(Apple a) ,方法内通过调用 a.getWeight() 方法返回 int 类型的值。后来 return 语句中的 (c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2)); 其实就是 Comparator 的 Lambda 表达式实现的匿名类中的方法体。重写的是 int compare(T o1, T o2); 方法

比较器链

我们经常遇到这样的问题,比较苹果质量时,质量相同。那么接下来就需要第二选择条件了。Comparable 接口也提供了便于 Lambda 使用的比较器链方法 thenComparing。比如首先比较质量,当质量相同时按照价格降序

Comparator<Apple> c = Comparator.comparing(Apple::getWeight);
Comparator<Apple> compareByWeightThenPrice = c.thenComparing(Apple::getPrice).reversed(); appleList.sort(compareByWeightThenPrice);

谓词复合

Predicate 谓词接口中有三个可用的复合方法: and、or、negate 分别表示与或非。使用方法和比较器复合大同小异,读者可以自行体验

函数复合

Function 函数接口中有 andThen() 和 compose() 方法,参数都是 Function 的实现,区别如下

a.andThen(b) 是先执行 a 再执行 b

a.compose(b) 是先执行 b 再执行 a

总结

  1. Lambda 和方法引用本身并不难,理解行为参数化是使用 Lambda 和方法引用的前提
  2. 函数式接口是仅仅声明了一个抽象方法的接口,只有在接受函数式接口的地方才能使用 Lambda 表达式
  3. 方法引用可以让你复用现有的方法实现
  4. Comparator、Predicate、Function等函数式接口都提供了几个用来结合 Lambda 表达式的默认方法