Stream 和函数式接口的基础讲解

前言

关于 Stream 和函数式接口的基础概念的文章已经有很多,相信很多小伙伴也在实际工作中进行使用体验了。但是关于函数式接口的使用,部分小伙伴可能接触的比较少,其实我们经常使用的 map、filter 等方法内部就使用了函数式接口的知识:

// 以下两个接口截取自 Java8 源码中的定义, 这里的 Function<? super T, ? extends R>
// 和 Predicate<? super T> 就属于函数式接口方面的知识

<R> Stream<R> map(Function<? super T, ? extends R> mapper);

Stream<T> filter(Predicate<? super T> predicate);
复制代码

通过使用函数式接口,你可以写出更加简(hui)洁(se)优(nan)雅(dong)的代码。

通过本文,你将了解到下知识:

  1. Lambda 和方法引用的基础概念。

  2. Stream 的基本使用。

  3. 如何通过函数式接口和泛型的接口,写出更加简洁的代码。

然后你还可以通过浏览以下文章加深这些知识的了解:

  1. Stream 基本概念及创建方法

  2. 几个通过Stream让代码更优雅的技巧

  3. 以若依为例讲解函数式接口的应用

  4. 归约、分组与分区,深入讲解Java Stream终结操作

  5. Java 8 Stream 的终极技巧——Collectors 操作

基础概念

在这一部分会简单介绍函数式接口、Lambda 表达式及方法引用引用的基础概念及联系,为后续的实际使用打好基础,回顾一下基础知识。

函数式接口

先来看一个实际例子(节选自 Java8 源码中 Function 接口的内容):

@FunctionalInterface
public interface Function<T, R> {

    R apply(T t);
    
}
复制代码

函数式接口除了具有接口所有的特点以外,还有如下的特点:

  1. 有且只有一个抽象方法的接口(必须)。
  2. 含有 @FunctionalInterface 注解(非必须,建议添加可以让编译器校验 1 的要求)。

在 Java 中,底层已经给我们定义好了一些常用的接口:

函数接口及方法名 特征 示例
java.lang.Runnable#run 无参, 无返回值 image-20220107133251549
java.util.function.Supplier#get 无参,一个返回值 image-20220107133359709
java.util.function.Consumer#accept 一个参数, 无返回值 image-20220107133455386
java.util.function.Function#apply 一个参数, 一个返回值 image-20220107133543667

除此之外还有PredicateBiFunction等等之类的扩展类型,这里只需要知道这些接口只是为了对应不同方法的特征,用于描述 m 个参数和 0-1 个返回值,Java 中已定义的完整函数式接口可以自行搜索查找,当我们的方法特征在 Java 中已定义的接口中不存在或者我们需要更加明确的接口名,我们就需要编写自己的函数式接口了。

Lambda 表达式

在 Java 中可以把 Lambda 表达式简单地理解为可传递的匿名函数的一种形式, Lambada 表达式主要有以下两种形式:

  1. (params) -> { statements; }
  2. (params) -> expression

对于第二种形式如果只有一个参数时,还可以简化为param -> expression,以下代码分别对应这三种形式:

(username) -> {
	String msg = "Hello, " + username + ".";
	System.out.println(msg);
}

(a, b) -> a + b

msg -> System.out.println(msg)
复制代码

方法引用

方法引用的格式为类名/对象名::方法名,例如Integer::sumstr::lengthString::length,方法引用可以看作是对 Lambda 表达式的简洁定义,通过方法引用,在大多数情况下我们可以写出更加简单易懂的代码,下面展示一些 Lambda 表达式和方法引用等效的一些例子:

// 第一行为方法引用的方式, 第二行为 Lambda 表达式的方法
// 不过使用方法引用则需要保证已经包含对应的方法, 比如这里的 sum 方法
Integer:: sum;
(int a, int b) -> a + b;

// Java8 源码中 Integer 类中 sum 方法的定义
public static int sum(int a, int b) {
    return a + b;
}

// 以下第二行和第三行也是方法引用和 Lambda 表达式等效的例子
String str = "Hello, world!";
str::length;
() -> str.length();

// Java8 源码中 String 类中 length 方法的定义
public int length() {
    return value.length;
}
复制代码

其实在实际使用中,方法引用主要有三种形式,上述的代码实例中包含了其中两种,下面分别介绍这三种形式:

  1. 类名::静态方法名

    这种形式对应上述代码示例中的第一个例子,在这种情况下静态方法的参数和返回值的格式和 Lambda 表达的格式一一对应。

  2. 对象::成员方法名

    这种形式对应上述代码示例中的第二个例子,在这种情况下方法引用等同于使用传递的对象调用该类的所有成员方法,而对应到 Lambda 表达式的时候,由于已经有了 str 这个对象,所有成员方法的参数和返回值的格式和 Lambda 表达的格式一一对应。

  3. 类名::成员方法名 这种形式在上述的代码示例中没有展示,不过这种形式和 2 很相似,不过由于这里使用的类名和成员方法名,我们都知道想要调用类的成员方法需要有某个类的实例,也可以说是通过类 new 出来一个对象才可以调用该类的成员方法名,所以这种情况下其实可以看作在原本的成员方法名中增加了一个该类的对象参数,对于上面的 length 函数,如果使用 String::length这种格式,等效的 Lambda 表达式是str -> str.length()

Lambda 表达式和方法引用的使用

不管是 Lambda 表达式还是方法引用,都是没办法直接使用的,必须将其映射到指定的函数式接口类型(例如使用 Stream 的 map 接口时,我们传递的 Lambda 表达式或者是方法引用都隐含的被映射到了Function<? super T, ? extends R> mapper这个函数式接口类型),如果自己没有写过接收函数式接口类型参数的方法,到这里可能会比较陌生,不过暂时可以先忽略,只需要知道不管是 Lambda 表达式还是方法引用在实际中都需要将其对应到相应的函数式接口,比如Integer::sum(对应的 Lambda 表达式是(int a, int b) -> a + b)包含两个参数和一个返回值,那么就可以使用BiFunction<Integer, Integer, Integer> 进行接收,具体使用如下:

public static void main(String[] args) {
    BiFunction<Integer, Integer, Integer> sumFunction = Integer::sum;
    System.out.println(sumFunction.apply(1, 1));
}
复制代码

Stream 的使用

所谓 Stream(流),其实看作是为了方便我们对一组数据进行操作所产生的,类似于 Linux 中的管道命令操作,比如ps -ef | grep java | cut -c 1-4 | sort -n | uniq,类似地,假如我们对一个字符串列表进行去重排序并使用逗号进行拼接,只需要编写下述的代码即可:

public static void main(String[] args) {
    List<String> list = new ArrayList<>(Arrays.asList("a", "c", "a", "b"));
    String str = list.stream()
            .distinct()
            .sorted()
            .collect(Collectors.joining(","));
    System.out.println(str);
}
复制代码

可以发现,通过使用 Stream,我们可以像使用 SQL 语句使用条件查询数据一样,不需要自己去实现具体的算法细节,只需要声明式的告诉 Stream 我们需要进行哪些操作即可。

关于 Stream 的介绍就到这里,更多的方法使用可以通过浏览前言中提到的文档或者其它的总结博客,都讲的十分详细了,这里不再过多介绍,主要还是需要多使用,多练习。

函数式接口的使用

其实在前言中提到的以若依为例讲解函数式接口的应用这篇文档已经比较详细地介绍了如何实际利用函数式接口来简化自己的代码,不过其中关于函数式接口的实际应用场景并没有介绍,这里用一个例子来进行讲解:

首先假设我们有一个用户类:

import lombok.Data;

/**
 * 用户类
 *
 * @author 庄周de蝴蝶
 * @date 2022-02-19
 */
@Data
public class User {

    /**
     * 用户 id
     */
    private Long id;

    /**
     * 用户名
     */
    private String username;

    /**
     * 年龄
     */
    private Integer age;

    /**
     * 性别: 0 男 1 女
     */
    private Integer gender;

}
复制代码

假设我们现在需要编写两个方法,一个用于得到列表中所有成年用户,一个需要得到所有的男性用户,一般情况下我们会编写如下的代码(先不考虑 Stream 的方式,同时假设 age 和 gender 都有默认值):

import java.util.ArrayList;
import java.util.List;

/**
 * 用户服务类
 *
 * @author 庄周de蝴蝶
 * @date 2022-02-19
 */
public class UserService {

    /**
     * 筛选用户列表获取所有的成年用户
     *
	 * @param userList 用户列表
     * @return 成年用户列表
     */
    public List<User> getAdult(List<User> userList) {
        List<User> adultList = new ArrayList<>();
        for (User user : userList) {
            if (user.getAge() >= 18) {
                adultList.add(user);
            }
        }
        return adultList;
    }

    /**
     * 筛选用户列表获取所有的男性用户
     *
     * @param userList 用户列表
     * @return 男性用户列表
     */
    public List<User> getMale(List<User> userList) {
        List<User> maleList = new ArrayList<>();
        for (User user : userList) {
            if (user.getGender() == 0) {
                maleList.add(user);
            }
        }
        return maleList;
    }

}
复制代码

可以发现这两个方法都是如下的三个步骤:

  1. 初始化筛选结果列表。
  2. 遍历用户列表,将符合条件的用户添加到结果列表中。
  3. 返回筛选结果列表。

如果我们还需要其它的维度对用户列表进行筛选,我们就需要编写很多的重复代码,而只修改其中的筛选条件。而如果我们将筛选条件(类的某个字段满足某个条件,参数为对象,返回值为布尔值)当成普通参数一样传递给方法,我们就可以编写出如下的代码:

import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;

/**
 * 用户服务类
 *
 * @author 庄周de蝴蝶
 * @date 2022-02-19
 */
public class UserService {

    /**
     * 筛选用户列表获取所有的成年用户
     *
	 * @param userList 用户列表
     * @return 成年用户列表
     */
    public List<User> getAdult(List<User> userList) {
        return getUserByCondition(userList, user -> user.getAge() >= 18);
    }

    /**
     * 筛选用户列表获取所有的男性用户
     *
     * @param userList 用户列表
     * @return 男性用户列表
     */
    public List<User> getMale(List<User> userList) {
        return getUserByCondition(userList, user -> user.getGender() == 0);
    }

    /**
     * 根据条件对用户列表进行筛选
     *
	 * @param userList 用户列表
	 * @param condition 条件
     * @return 筛选结果列表
     */
    private List<User> getUserByCondition(List<User> userList, Predicate<User> condition) {
        List<User> resultList = new ArrayList<>();
        for (User user : userList) {
            if (condition.test(user)) {
                resultList.add(user);
            }
        }
        return resultList;
    }

}
复制代码

可以发现通过使用Predicate<User>这个函数式接口,我们再编写另外两个方法时,只需要将查询条件传递给getUserByCondition方法接口即可,这也是函数式接口的方便之处。如果我们发现代码中存在大量的重复逻辑,而只有部分执行语句不同时,就可以考虑这部分执行语句是否可以抽离出来通过 Lambada 表达式或者方法引用进行传递,这样就可以避免大量的重复代码。

利用上面的思路,我们就可以写出来一个可以根据条件对所有列表进行筛选的简单 demo 方法:

/**
 * 根据条件对列表进行筛选
 *
 * @param list 列表
 * @param condition 条件
 * @return 结果列表
 */
public static <E> List<E> filter(List<E> list, Predicate<E> condition) {
    List<E> resultList = new ArrayList<>();
    for (E e : list) {
        if (condition.test(e)) {
            resultList.add(e);
        }
    }
    return resultList;
}

public static void main(String[] args) {
    List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6));
    filter(list, item -> item > 3).forEach(System.out::println);
}
复制代码

而在实际开发中,通过泛型和函数式接口的结合,我们能够精简很多的代码,这里可以参考以若依为例讲解函数式接口的应用,希望上面这个简单的例子能让你对函数式接口的应用有一个大概的认识,后面只需要多加练习,善于发现代码中的优化点,就能够体会到函数式接口的方便之处。

总结

其实在写这篇文章前,是想要写一篇能够包含 Stream、函数式接口所有基础知识和实战使用的文档,但是关于这些知识点的相关优秀文章已经有很多了,后来就写的比较简便,导致原本准备大篇幅介绍的实战内容并没有写多少,不过还是希望本文能够给你一些编码的新思路,如果有错误之处,也欢迎一起交流。

参考资料

《Java8 实战》

Guess you like

Origin juejin.im/post/7066406224446619655