只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

27、函数式编程

内容来自王争 Java 编程之美
Stream API

在《设计模式之美》中,我们详细讲解了现在主流的三种编程范式:面向过程、面向对象和函数式编程
函数式编程并非一个很新的东西,早在 50 多年前就已经出现了,近几年,函数式编程越来越被人关注,出现了很多新的函数式编程语言,比如 Clojure、Scala、Erlang 等
一些非函数式编程语言也加入了很多特性、语法、类库来支持函数式编程,比如 Java、Python、Ruby、JavaScript 等
本节我们就来讲讲 Java 语言对函数式编程的支持

1、函数式编程

不过在讲解 Java 语言对函数式编程的支持之前,我们先要了解一下,到底什么是函数式编程?

严格上来讲,函数式编程中的 "函数",并不是指编程语言中的 "函数",而是指数学 "函数" 或者 "表达式"(比如,y = f(x))
不过在编程实现时,对于数学 "函数" 或 "表达式",我们一般习惯性地将它们设计成函数,因此如果不深究的话,函数式编程中的 "函数" 也可以理解为编程语言中的 "函数"

每个编程范式都有自己独特的地方,这就是它们会被抽象出来作为一种范式的原因

  • 面向对象编程最大的特点是:以类、对象作为组织代码的单元以及它的四大特性
  • 面向过程编程最大的特点是:以函数作为组织代码的单元,数据与方法相分离
  • 那么函数式编程最独特的地方又在哪里呢?

实际上,函数式编程最独特的地方在于它的编程思想
函数式编程认为,程序可以用一系列数学函数或表达式的组合来表示,函数式编程是程序面向数学的更底层的抽象,将计算过程描述为表达式
不过这样说你肯定会有疑问,真的可以把任何程序都表示成一组数学表达式吗?

理论上讲是可以的,但并不是所有的程序都适合这么做,函数式编程有它自己适合的应用场景,比如科学计算、数据处理、统计分析等
在这些领域,程序往往比较容易用数学表达式来表示,比起非函数式编程,实现同样的功能,函数式编程需要更少的代码
但是对于强业务相关的大型业务系统开发来说,费劲吧啦地将它抽象成数学表达式,硬要用函数式编程来实现,显然是自讨苦吃
相反,在这种应用场景下,面向对象编程更加合适,写出来的代码更加可读、可维护
因此我个人觉得,函数式编程并不能完全替代更加通用的面向对象编程范式,但是作为一种补充,它也有很大存在、发展和学习的意义

刚刚讲的是函数式编程的编程思想,如果我们再具体到编程实现,函数式编程跟面向过程编程一样,也是以函数作为组织代码的单元,不过它跟面向过程编程的区别在于,它的函数是无状态的,何为无状态?
简单点讲就是,函数内部涉及的变量都是局部变量,不会像面向对象编程那样,共享类成员变量,也不会像面向过程编程那样,共享全局变量,函数的执行结果只与入参有关,跟其他任何外部变量无关,同样的入参,不管怎么执行,得到的结果都是一样的,这实际上就是数学函数或数学表达式的基本要求,我举个例子来简单解释一下

// 有状态函数: 执行结果依赖 b 的值是多少
// 即便入参相同, 多次执行函数, 函数的返回值有可能不同, 因为 b 值有可能不同
int b;
int increase(int a) {
return a + b;
}
// 无状态函数: 执行结果不依赖任何外部变量值
// 只要入参相同, 不管执行多少次, 函数的返回值就相同
int increase(int a, int b) {
return a + b;
}

这里稍微总结一下,不同的编程范式之间并不是截然不同的,总是有一些相同的编程规则
不管是面向过程、面向对象还是函数式编程,它们都有变量、函数的概念,最顶层都要有 main 函数执行入口,来组装编程单元(类、函数等)
只不过面向对象的编程单元是类或对象,面向过程的编程单元是函数,函数式编程的编程单元是无状态函数

接下来我们就看下 Java 这种面向对象编程语言,对函数式编程的支持,我们先来看下面这样一段非常典型的 Java 函数式编程的代码
这段代码的作用是,先过滤除非空字符串,然后再查找以 "wz-" 为前缀的字符串,最后统计每个字符串的长度,并打印输出

public class FPDemo {
public static void main(String[] args) {
List<String> strList = Arrays.asList("wz-a.java", "wz-b.txt", "c.java");
strList.stream()
.filter(s -> !s.isEmpty())
.filter(s -> s.startsWith("wz-"))
.map(String::length)
.forEach(l -> System.out.println(l)); // 输出 9、8
}
}

如果你不了解 Java 函数式编程的语法,看了上面的代码或许会有些懵,主要的原因是,Java 为函数式编程引入了4 个新的语法
函数接口(Functional Interface)、Lambda 表达式、方法引用、Stream 流
函数接口的作用是让我们可以把函数包裹成接口(interface),来实现把函数当做参数一样来使用(Java 不像 C 一样支持函数指针,可以把函数直接当参数来使用)

  • 引入 Lambda 表达式的作用是简化函数接口的匿名实现类的代码编写
  • 方法引用的作用是进一步简化 Lambda 表达式
  • Stream 流用来支持通过 "." 级联多个函数操作的代码编写方式

接下来,我们就一一详细讲解一下这 4 个语法

2、函数接口

2.1、Runnable 和 Comparator

我们知道,C 语言支持函数指针,它可以把函数直接当变量来使用,但是 Java 没有函数指针这样的语法,所以它通过函数接口,将函数包裹在接口中,当作变量来使用
比如我们经常使用的 Runnable、Comparator 等都是函数接口,代码如下所示

@FunctionalInterface
public interface Runnable {
public abstract void run();
}
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2);
boolean equals(Object obj);
default Comparator<T> reversed() {
return Collections.reverseOrder(this);
}
default Comparator<T> thenComparing(Comparator<? super T> other) {
Objects.requireNonNull(other);
return (Comparator<T> & Serializable) (c1, c2) -> {
int res = compare(c1, c2);
return (res != 0) ? res : other.compare(c1, c2);
};
}
}

实际上函数接口就是接口,不过它也有自己特别的地方,那就是要求只包含一个未实现的方法
Java 提供了专门的注解 @FunctionalInterface 来标识某个接口是函数接口,这个注解的作用是检查接口中是否只有一个未实现的方法
如果标记了这个注解的接口有两个及两个以上的未实现的方法,那么代码在编译时就会报错
实际上,即便不使用 @FunctionalInterface 注解来标记接口,只要接口中只包含一个未实现的方法,那么在 Java 眼里就认为它是函数接口,跟标注了 @FunctionalInterface 注解的接口无差别对待

  • Runnable 接口中只有一个未实现的方法 run()
  • Comparator 接口中虽然有很多方法,但绝大部分都有默认实现,只有 compare() 和 equals() 没有实现
    Comparator 中有两个未实现方法,那么是不是就不是函数接口了呢?
    实际上,所有的类都会继承顶级父类 Object,而 Object 中有 equals() 方法的默认实现,我们在创建 Comparator 接口的实现类时,只需要强制实现 compare() 方法即可
    从这个角度来说,Comparator 中相当于只有 compare() 方法没有实现,因此符合函数接口只包含一个未实现方法的限制

2.2、Predicate

除了刚刚提到的 Runnable、Comparator 之外,Java 还预定义了大量的其他函数接口,比如 Predicate<T>、Function<T, R>、Comsumer<T>、Supplier<T> 等等
我们拿 Predicate<T> 举例,代码如下所示,对于其他预定义函数接口,你可以自行在 java.util.function 包下查看
在 Predicate<T> 内,除了未实现的 test() 方法之外,还定义了很多具有默认实现的方法,那么这些具有默认实现的方法又是做什么用的呢?关于这个问题,我们稍后讲解

@FunctionalInterface
public interface Predicate<T> {
boolean test(T t); // 只有这一个未实现的方法
default Predicate<T> and(Predicate<? super T> other) {
Objects.requireNonNull(other);
return (t) -> test(t) && other.test(t);
}
default Predicate<T> negate() {
return (t) -> !test(t);
}
default Predicate<T> or(Predicate<? super T> other) {
Objects.requireNonNull(other);
return (t) -> test(t) || other.test(t);
}
static <T> Predicate<T> isEqual(Object targetRef) {
return (null == targetRef) ? Objects::isNull : object -> targetRef.equals(object);
}
}

2.3、自定义函数接口

跟自定义异常、自定义注解一样,我们也可以自定义函数接口

@FunctionalInterface
public interface Filter<T> {
boolean accept(T name);
}

只有函数接口的定义是没有意义的,我们还要有代码用到它,函数接口的使用如下示例所示

  • 从作用上:函数接口有点类似《设计模式之美》中讲到的模板模式
  • 从实现上:函数接口有点类似《设计模式之美》中讲到的回调

将某个流程中可变的逻辑抽离出来,设计成函数接口,以此来支持灵活定制可变逻辑

public class Demo {
// filter 过滤策略为可变逻辑
public static List<String> filterFiles(List<String> files, Filter<String> filter) {
List<String> res = new ArrayList<>();
for (String file : files) {
if (filter.accept(file)) res.add(file);
}
return res;
}
public static void main(String[] args) {
List<String> files = Arrays.asList("wang.txt", "zheng.java", "xiao.txt", "ge.java");
List<String> javaFiles = filterFiles(files, new Filter<String>() {
@Override
public boolean accept(String name) {
return name.endsWith(".java");
}
});
}
}

看到这里你可能会说,Runnable、Comparator 这种只包含一个未实现方法的接口早就有了,为什么在 JDK 8 引入函数式编程时,将它们重新定义为函数接口呢?
它们在函数式编程中有什么特殊作用呢?函数接口为什么只能允许包含一个未实现的接口呢?要回答这些问题,我们就要先了解 Lambda 表达式

3、Lambda 表达式

我们前面讲到,Java 引入 Lambda 表达式的主要作用是简化代码编写,但凡用到函数接口的地方,我们都可以将函数接口的匿名实现类替换成 Lambda 表达式
如上示例代码所示,我们将 Filter<T> 的匿名实现类替换为 Lambda 表达式,如下所示

public static void main(String[] args) {
List<String> files = Arrays.asList("wang.txt", "zheng.java", "xiao.txt", "ge.java");
// 使用匿名实现类
List<String> javaFiles1 = filterFiles(files, new Filter<String>() {
@Override
public boolean accept(String name) {
return name.endsWith(".java");
}
});
// 使用 Lambda 表达式
List<String> javaFiles2 = filterFiles(files, (String name) -> {
return name.endsWith(".java");
});
}

Lambda 表达式包括三部分:输入、函数体、输出,如下所示

(类型 a, 类型 b) -> { 语句1; 语句2; ...; return 输出; } // a, b 是输入参数

实际上 Lambda 表达式的写法非常灵活,我们刚刚给出的是标准写法,还有很多简化写法

比如我们可以将输入参数的类型省略,由编译器自行推测,如果输入参数只有一个,还可以省略 (),直接写成 a->{...}
如果函数体只有一个语句,那么还可以将 {} 和 return 关键词省略掉

按照以上省略规则,我们将上述示例代码中的 Lamba 表达式简化,简化之后的代码如下所示,从这里我们也可以得知,Lambda 表达式只是为了简化代码比较简单的匿名实现类
当匿名实现类中只包含一条语句时,简化效果最好,如果匿名实现类中代码逻辑比较复杂,那么使用 Lambda 表达式的简化效果就不明显了

List<String> javaFiles = filterFiles(files, name -> name.endsWith(".java"));

如果我们把以下 Lambda 表达式,全部替换为函数接口的实现方式,如下所示,对比来看,代码是不是多了很多?

// 使用 Lambda 表达式, 非常简洁
Optional<Integer> result = Stream.of("f", "ba", "hello")
.map(s -> s.length())
.filter(l -> l <= 3)
.max((o1, o2) -> o1 - o2);
// 还原为函数接口的实现方式, 代码变长很多
Optional<Integer> result2 = Stream.of("fo", "bar", "hello")
.map(new Function<String, Integer>() {
@Override
public Integer apply(String s) {
return s.length();
}
})
.filter(new Predicate<Integer>() {
@Override
public boolean test(Integer l) {
return l <= 3;
}
})
.max(new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o1 - o2;
}
});

了解了 Lambda 表达式之后,我们就可以回答之前遗留的问题了,函数接口本质上就是接口,没有什么新颖的,在函数式编程出现之前就已经存在并且大量使用
抽象出函数接口这个概念,完全是新瓶装旧酒,目的是希望对能够简写为 Lambda 表达式的这一类接口有一个统一的称谓,方便沟通,那么函数接口为什么只能包含一个未实现的方法呢?这里主要有两个原因

  • 其一是:函数接口的目的是为了能把函数(也就是方法)当做参数来传递,在这里函数是主角,一个函数理应包裹为一个单独的函数接口,一个函数接口内不应该有两个主角
  • 其二是:因为只有包含一个未实现的方法,简化之后的 Lambda 表达式才能明确知道匹配的是哪个方法
    如果函数接口包含两个未实现的方法,并且方法入参、返回值都一样,那么 Java 在翻译 Lambda 表达式时,就无法知道表达式对应哪个方法了

4、方法引用

当 Lambda 中的逻辑已经有现成的方法实现时,我们可以直接使用方法引用
需要注意的是:方法引用要求所引用的方法的参数列表和返回值,跟函数接口中未实现方法的参数列表和返回值完全一致,方法引用的语法格式如下所示

对象::实例方法
类::静态方法
类::实例方法

比如下列示例中就用到了两个方法引用,String::isEmpty 和 String::length,当然我们也可以不使用方法引用,如注释中所示

public class FPDemo {
public static void main(String[] args) {
List<String> strList = Arrays.asList("wz-a.java", "wz-b.txt", "c.java");
strList.stream()
.filter(((Predicate<String>) String::isEmpty).negate()) //.filter(s -> !s.isEmpty())
.filter(s -> s.startsWith("wz-"))
.map(String::length) //.map(s -> s.length())
.forEach(l -> System.out.println(l)); // 输出 9、8
}
}

5、Stream 流

上面讲到的函数接口、Lambda 表达式,只是函数式编程中的辅助语法,真正体现函数式编程其 "函数" 本质的应该是 Stream 流,接下来,我们就来讲一讲 Stream 流

假设我们要计算这样一个表达式:(3 - 1) * 2 + 5,如果按照普通的函数调用的方式写出来,就是下面这个样子的

add(multiply(subtract(3, 1), 2), 5);

不过这样编写的代码可读性比较差,我们换个可读性更好的写法,如下所示

subtract(3, 1).multiply(2).add(5);

我们知道,在 Java 中,"." 表示调用某个对象上的方法,为了支持上面这种级联调用方式,我们让每个函数都返回一个通用的类型:Stream 对象
在 Stream 上的操作有两种:中间操作和终止操作,中间操作返回的仍然是 Stream 对象,而终止操作返回的是确定的值结果或者没有返回值

我们再来看之前的例子,我对代码做了注释解释,如下所示

  • map、filter 是中间操作,返回 Stream 对象,可以继续级联其他操作
  • forEach() 是终止操作,没有返回值,无法再继续往下级联处理了
public class FPDemo {
public static void main(String[] args) {
List<String> strList = Arrays.asList("wz-a.java", "wz-b.txt", "c.java");
strList.stream() // 返回 Stream<String> 对象
.filter(((Predicate<String>) String::isEmpty).negate()) // filter 返回 Stream<String> 对象
.filter(s -> s.startsWith("wz-")) // filter 返回 Stream<String> 对象
.map(String::length) // map 返回 Stream<Integer> 对象
.forEach(l -> System.out.println(l)); // forEach 终止操作
}
}

Stream 中的 filter()、map()、forEach() 等方法的参数为 Java 预定义的函数接口,如下所示,因此函数接口的匿名实现类可以通过 Lambda 表达式来简化
除了 filter()、map()、forEach() 之外,Stream 类中还定义了很多其他操作,你可以自行查看 java.util.stream.Stream 的源码

Stream<T> filter(Predicate<? super T> predicate);
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
void forEach(Consumer<? super T> action);

前面提到,在 Java 预定义的函数接口中,不仅仅包含未实现的方法,还包含一些具有默认实现的方法,比如 Predicate<T> 函数接口中的 and()、or()、negate() 等方法
那么这些具有默认实现的方法到底是做什么用的呢?实际上它们一般用来组合操作,我举个例子你就清楚了,示例代码如下所示
我们希望过滤得到既包含前缀 "wz-",又包含后缀 ".java" 的字符串,这里我们就可以使用 Predicate<T> 中的 and() 操作

List<String> strList = Arrays.asList("wz-a.java", "wz-b.txt", "c.java");
Predicate<String> p1 = s -> s.startsWith("wz-");
Predicate<String> p2 = s -> s.endsWith(".java");
List<String> res = strList.stream()
.filter(p1.and(p2))
.collect(Collectors.toList());

6、课后思考题

什么情况下适合使用函数式编程?什么时候不适合使用函数式编程?

函数接口本质上就是接口,没有什么新颖的,在函数式编程出现之前就已经存在并且大量使用
抽象出函数接口这个概念,完全是 "新瓶装旧酒",目的是希望对能够简写为 Lambda 表达式的这一类接口有一个统一的称谓,方便沟通
那么函数接口为什么只能包含一个未实现的方法呢?这里主要有两个原因
其一是:函数接口的目的是为了能把函数(也就是方法)当做参数来传递,在这里,函数是主角,一个函数接口内不应该有两个主角
其二是:因为只有包含一个未实现的方法,简化之后的 Lambda 表达式才能明确知道匹配的是哪个方法
如果函数接口包含两个未实现的方法,并且方法入参、返回值都一样,那么 Java 在翻译 Lambda 表达式时,就无法知道表达式对应哪个方法了
posted @   lidongdongdong~  阅读(46)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开