Java 函数式编程(一):Lambda 表达式

1. 怎样理解 Lambda 表达式?

答:

  • Lambda 这个名字来源于学术界的演算
  • 不同于接口和匿名内部类的传递代码的方式,Lambda 表达式是一种紧凑的传递代码的方式。利用 Lambda 表达式可以实现简洁灵活的函数式编程。
  • 基于 Lambda 表达式,针对常见的集合数据处理,Java 8 引入了一套新的类库,位于包 java.util.stream 下,称为 Stream API。不同于容器类 APIStream API 是函数式的,非常简洁、灵活、易读
  • Stream API 是对容器类的增强,它可以将对集合数据的多个操作以流水线的方式组合在一起。
  • 利用 Lambda 表达式,Java 8 还增强了日期和时间 API

2. Lambda 表达式的语法?

答:Lambda 表达式由 -> 分隔为两部分,前面是方法的参数,后面 {} 内是方法的代码

3. 【笔试题】使用 Lambda 表达式重写下面代码?

// 列出当前目录下的所有扩展名为 .txt 的文件
File f = new File(".");
File[] files = f.listFiles(new FilenameFilter() {
    @Override
    public boolean accept(File dir, String name) {
        if(name.endsWith(".txt")) {
            return true;
        }
        return false;
    }
});

答:

  • 标准写法:

    File f = new File(".");
    File[] files = f.listFiles((File dir, String name) -> { // 不再有实现接口的模板代码,不再声明方法,也没有名字,而是直接给出了方法的实现代码
        if(name.endsWith(".txt")) {
            return true;
        }
        return false;
    });
    
  • 简化版本一:

    File f = new File(".");
    File[] files = f.listFiles((File dir, String name) -> {
        return name.endsWith(".txt"); // 对 if 判断语句的传统优化
    });
    
  • 简化版本二:

    1
    2
    File f = new File(".");
    File[] files = f.listFiles((File dir, String name) -> name.endsWith(".txt")); // 当主体代码只有一条语句的时候,大括号 {} 和 return 关键字也可以省略
  • 简化版本三:

    File f = new File(".");
    File[] files = f.listFiles((dir, name) -> name.endsWith(".txt")); // 方法的参数类型声明也可以省略
    
    // 之所以可以省略方法的参数类型,是因为 Java 可以自动推断出来,它知道 listFiles() 方法接受的参数类型是 FilenameFilter,这个接口只有一个方法 accept(),这个方法的两个参数类型分别是 File 和 String
    
  • 异化版本:

    // 当参数只有一个的时候,参数部分的括号可以省略
    // 比如,File 还有如下方法:public File[] listFiles(FileFilter filter)
    // FileFilter 的定义为:
        public interface FileFilter {
            boolean accept(File path);
        }
    // 使用 FileFilter 重写上面的列举文件的例子
    File f = new File(".");
    File[] files = f.listFiles(path -> path.getName().endsWith(".txt"));
    

4. 【笔试题】使用 Lambda 表达式重写下面代码?

File f = new File(".");
File[] files = f.listFiles((dir, name) -> name.endsWith(".txt"));

// 将 files 按照文件名排序
Arrays.sort(files, new Comparator<File> () {
    @Override
    public int compare(File f1, File f2) {
        return f1.getName().compareTo(f2.getName());
    }
});

答:

File f = new File(".");
File[] files = f.listFiles((dir, name) -> name.endsWith(".txt"));

Arrays.sort(files, (f1, f2) -> f1.getName().compareTo(f2.getName()));

5. 【笔试题】使用 Lambda 表达式重写下面代码?

// 提交一个最简单的任务
ExecutorService executor = Executors.newFixedThreadPool(100);
executor.submit(new Runnable() {
    @Override
    public void run() {
        System.out.println("hello world");
    }
});

答:

ExecutorService executor = Executors.newFixedThreadPool(100);
executor.submit(() -> System.out.println("hello world")); // 参数部分为空,写为 ()

6. Lambda 表达式和匿名内部类的区别是?

答:

  • 与匿名内部类类似,Lambda 表达式也可以访问定义在主体代码外部的变量,但对于局部变量,它也只能访问 final 类型的变量
  • 与匿名内部类的区别是,它不要求变量声明为 final,但变量事实上不能被重新赋值
  • 这个原因与匿名内部类是一样的,Java 会将 msg 的值作为参数传递给 Lambda 表达式,为 Lambda 表达式建立一个副本,它的代码访问的是这个副本,而不是外部声明的 msg 变量。如果允许 msg 被修改,则程序员可能会误以为 Lambda 表达式读到修改后的值,引起更多的混淆
  • 为什么非要建立副本,直接访问外部的 msg 变量不行吗?不行,因为 msg 定义在栈中,当 Lambda 表达式被执行的时候,msg 可能早已被释放了。如果希望能够修改值,可以将变量定义为实例变量,或者将变量定义为数组
  • Lambda 表达式与匿名内部类很像,主要就是简化了语法,但 Lambda 表达式的内部实现并不是匿名内部类。Java 会为每个匿名内部类生成一个类,但 Lambda 表达式不会。Lambda 表达式通常比较短,为每个表达式生成一个类会生成大量的类,性能会收到影响
  • 内部实现上,Java 利用了 Java 7 引入的为支持动态类型语言引入的 invokedynamic 指令、方法句柄 method handle 等,具体实现比较复杂,实现细节可参见 http://cr.openjdk.java.net/~briangoetz/lambda/lambda-translation.html。需要知道的是,Lambda 表达式的 Java 实现是非常高效的,不用担心生成太多类影响性能的问题。
  • Lambda 表达式的类型是函数式接口

7. 什么是函数式接口?

答:

  • Java 8 引入了函数式接口的概念。
  • 函数式接口也是接口,但只能有一个抽象方法
  • 之所以强调是抽象方法,是因为 Java 8 中还允许定义静态方法和默认方法
  • Lambda 表达式可以赋值给函数式接口

8. 怎样理解 @FunctionalInterface 注解?

答:

  • Java 8 中函数式接口都有一个注解 @FunctionalInterface。比如:

    @FunctionalInterface
    public interface Runnable {
        public abstract void run();
    }
    
  • 注解 @FunctionalInterface 用于清晰地告知使用者这是一个函数式接口。不过,这个注解不是必需的。不加,只要只有一个抽象方法,也是函数式接口。但如果加了,而又定义了超过一个抽象方法,Java 编译器会报错,类似于 @Override 注解

9. Java 8 中预定义的函数式接口有哪些?

答:Java 8 定义了大量的预定义函数式接口,用于常见类型的代码传递,这些函数定义在包 java.util.function 下:

  • 主要的预定义函数式接口
函数接口 方法定义 说明
Predicate boolean test(T t) 谓词,测试输入是否满足条件
Function R apply(T t) 函数转换,输入类型 T,输出类型 R
Consumer void accept(T t) 消费者,输入类型 T
Supplier T get() 工厂方法
UnaryOperator T apply(T t) 函数转换的特例,输入和输出类型一样
BiFunction R apply(T t, U u) 函数转换,接受两个参数,输出 R
BinaryOperator T apply(T t, T u) BiFunction 的特例,输入和输出类型一样
BiConsumer void accept(T t, U u) 消费者,接受两个参数
BiPredicate boolean test(T t, U u) 谓词,接受两个参数
  • int 类型的函数式接口:除 int 之外,对于基本类型 booleanlongdouble,为避免装箱/拆箱,Java 8 也提供了一些专门的函数:
函数接口 方法定义 说明
IntPredicate boolean test(int value) 谓词,测试输入是否满足条件
IntFunction R apply(int value) 函数转换,输入类型 int,输出类型 R
IntConsumer void accept(int value) 消费者,输入类型 int
IntSupplier int getAsInt() 工厂方法
  • 这些函数的作用是:它们被大量用于 Java 8 的函数式数据处理 Stream 相关的类中,即使不使用 Stream,也可以在自己的代码中直接使用这些预定义的函数

10. 【笔试题】使用预定义的 Predicate 过滤学生列表,筛选 90 分以上的学生?

答:

// 学生类
static class Student {
    String name;
    double score;
    // 省略了构造方法和 getter/setter 方法
}

// 学生列表
List<Student> students = Arrays.asList(new Student[] {
    new Student("zhangsan", 89d), new Student("lisi", 89d), new Student("wangwu", 98d) 
});

// 在日常开发中,列表处理的一个常见需求是过滤,列表的类型经常不一样,过滤的条件也经常变化,但主体逻辑都是类似的,可以借助 Predicate 写一个通用的方法
public static <E> List<E>  filter(List<E> list, Predicate<E> pred) {
    List<E> retList = new ArrayList<> ();
    for(E e : list) {
        if(pred.test(e)) {
            retList.add(e);
        }
    }
    return retList;
}

// 过滤 90 分以上的学生
students = filter(students, t -> t.getScore() > 90);

11. 【笔试题】使用预定义的 Function 转换学生列表,将学生名称转换为大写?

答:

// 借助 Function 写一个通用的方法
public static <T, R> List<R> map(List<T> list, Function<T, R> mapper) {
    List<R> retList = new ArrayList<> (list.size());
    for(T e : list) {
        retList.add(mapper.apply(e));
    }
    return retList;
}

// 根据学生列表返回名称列表
List<String> names = map(students, t -> t.getName());

// 将学生名称转换为大写
students = map(students, t -> new Student(t.getName().toUpperCase(), t.getScore()));

12. 【笔试题】使用预定义的 Consumer 转换学生列表,将学生名称转换为大写(直接修改原对象,而不是为每个学生创建新对象)?

答:

// 用 Consumer 写一个通用的方法
public static <E> void foreach(List<E> list, Consumer<E> consumer) {
    for(E e : list) {
        consumer.accept(e);
    }
}

// 将学生名称转换为大写
foreach(students, t -> t.setName(t.getName().toUpperCase()));

13. 怎样理解 Java 8 的方法引用语法?

答:

  • 下面两种写法是等价的:

    • List<String> names = map(students, t -> t.getName());
    • List<String> names = map(students, Studetn::getName);
  • Student::getName 这种写法是 Java 8 引入的一种新语法,称为:方法引用

  • 方法引用是 Lambda 表达式的一种简写方法,由 :: 分隔为两部分,前面是类名或变量名,后面是方法名(不带小括号 ()
  • 方法可以是实例方法,也可以是静态方法,但含义不同:举例,以 Student 为例,先增加一个静态方法:public static String getCollegeName() {return "Laoma School"}

    • 对于静态方法,下面两条语句是等价的:(参数都是空,返回类型为 String

      1. Supplier<String> s = Student::getCollegeName;
      2. Supplier<String> s = () -> Student.getCollegeName();
    • 对于实例方法,下面两条语句是等价的:(它的第一个参数就是该类型的实例)

      1. Function<Student, String> f = Student::getName;
      2. Function<Student, String> f = (Student t) -> t.getName();
    • 对于 Student::setName,它是一个 BiConsumer,即下面两条语句是等价的:

      1. BiConsumer<Student, String> c = Student::seName;
      2. BiConsumer<Student, String> c = (t, name) -> t.setName(name);
    • 如果方法引用的第一部分是变量名,则相当于调用那个对象的方法。比如,假定 t 是一个 Student 类型的变量,则下面两条语句是等价的:

      1. Supplier<String> s = t::getName;
      2. Supplier<String> s = () -> t.getName();
    • 下面两条语句也是等价的:

      1. Consumer<String> consumer = t::setName;
      2. Consumer<String> consumer = (name) -> t.setName(name);
    • 对于构造方法,方法引用的语法是 <类名>::new,如 Student::new,即下面两条语句是等价的:

      1. BiFunction<String, Double, Student> s = (name, score) -> new Student(name, score);
      2. BiFunction<String, Double, Student> s = Student::new;

14. Java 8 函数式编程中函数的复合是什么意思?

答:

  • 函数式接口和 Lambda 表达式除了用作方法的参数,还可以用作方法的返回值,传递代码回调用者。将这两种用法结合起来,可以构造复合的函数,使程序简洁易读
  • 复合函数经常会用到 Java 8 对接口的增强,即静态方法和默认方法
  • ComparatorConsumerPredicate 等都有一些复合方法,被大量用于函数式数据处理 API 中。
-------------本文结束感谢您的阅读-------------