Java8系列 - 从lambda看函数式编程

函数式编程

管道哲学

Unix管道

管道(Pipelines)是现代软件工程中一个非常有用计算架构模型,最早使用在Unix系统中,有句话是这么说的:如果说Unix是计算机文明中最伟大的发明,那么,Unix下的Pipe管道就是跟随Unix所带来的另一个伟大的发明。管道的出现,解决的就是让不同功能的程序可以互相连通通讯,从而可以让程序开发更加的高内聚,低耦合,它以一种链式模型来串接不同的程序或者不同的组件,让它们组成一条直线的工作流。这样给定一个输入,经过各个组件的先后协同处理,得到唯一的最终输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
那么,我们来看看下面这个例子:
cat /usr/share/dict/words | # Read in the system's dictionary.
grep purple | # Find words containing 'purple'
awk '{print length($1), $1}' | # Count the letters in each word
sort -n | # Sort lines ("${length} ${word}")
tail -n 1 | # Take the last line of the input
cut -d " " -f 2 | # Take the second part of each line
cowsay -f tux # Put the resulting word into Tux's mouth

用bash运行上面的命令,最终会返回一只可爱的Linux小企鹅,并告诉你字典中包含purple最长的一个单词,看起来是下面这个样子:
_____________
< unimpurpled >
-------------
\
\
.--.
|o_o |
|:_/ |
// \ \
(| | )
/'\_ _/`\
\___)=(___/

为了更直观的展示这个过程,我们把上述步骤画在一张图上:

管道1

管道的精髓不在于某个应用,而是它的哲学思想,Do one thing, Do it well。程序应该只关注一个目标,并尽可能把它做好,让程序能够互相协同工作。

程序设计

在程序开发中也经常会用到管道思想,将复杂的任务分解,抽象出一个个标准规范的、高内聚、低耦合单元组件,数据通过封装成消息事件方式在组件间传递,单元组件除接收输入外,包含两种输出方式:标准输出错误输出,单元组件再处理数据过程中,可能会与外界进行交互,比如数据库交互、读取配置、网络IO等。

Filter示意图

通过一种链式模型(chain)把不同功能的单元组件串联起来,通过单元组件互相协作,最终实现复杂业务处理功能。高内聚、低耦合单元组件降低代码复杂性、提高代码复用性,同时增强了程序的灵活性

1553568153032

pipeline作为一种架构模式,可以让我们的程序设计更加规范,业务逻辑更加清晰,扩展性更强,方便系统日后的维护和交流。

不论是管道还是pipeline架构模式,它们的核心思想:都是把数据传递给一个任务队列(任务链),由任务队列按次序依次对数据进行加工处理。不过,差异的地方是,shell管道中任务队列中的单元是一个又一个的进程;而pipeline架构模式是运行在一个进程中的一个又一个的程序块、或者说逻辑片

利用“分而治之”的思想,将复杂的业务逻辑标准组件化,然后将标准化组件积木式的拼装构建成处理链,从而实现通过让小组件间相互协作完成复杂业务,同时提高的系统的扩展性、灵活性。

将复杂业务分解成多个独立的子任务,好处有:

  • 降低了开发复杂性,复杂性降低,开发出现差错的机会就会减少,同时便于测试用例覆盖各种场景,提升代码质量
  • 被分解的子任务可以被不同的业务逻辑,避免代码冗余,冗余也是考量代码质量的一种重要指标
  • 扩展性、灵活性增强,通过添加、移除和替换子任务比较轻松的实现业务扩展和变更,符合开闭原则的思想:业务扩展尽量不要对已有的代码进行修改,即使修改,也要将修改造成的影响抑制到最小范围,避免修改带来的不确定因素影响整个系统。

并行模型

并发模型

1553569536655

1553569570725

核心实现

1553569747567

进一步抽象

1553569873509

Stream的内迭代和外迭代

Lambda

Java 8是最具有革命性改变的一个版本,核心变化主要体现在三个方面:Lambda(函数式编程)Stream(流)并发/并行编程简易化。其中lambda表达式Java具有函数式编程特性是最具革命性,为Stream并行/并发编程等新特性的支撑基石

为什么需要Lambda表达式

lambda表达式语法可以让代码量大大减少,程序逻辑也清晰明了,可以编写出简单、干净、易读的代码,这只是对lambda表达式直观印象和浅显的认识。

本质上,lambda表达式让Java具有了函数式编程特性,函数式编程完善了面向对象编程的不足,让行为参数化,轻松实现传递,提升代码的抽象能力,实现更高级、更灵活的接口。函数式编程现在还是比较盛行的,在很多语言,像ScalaGroovy等早已开始支持lambda表达式语法了。

四则预算案例 + 模板

1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void test(){
System.out.println(arithmetic(10, 20, (x, y) -> x + y));
System.out.println(arithmetic(10, 20, (x, y) -> x - y));
System.out.println(arithmetic(10, 20, (x, y) -> x * y));
System.out.println(arithmetic(10, 20, (x, y) -> x / y));
System.out.println(arithmetic(10, 20, (x, y) -> (x + y) * (x -y)));
}

public <T> T arithmetic(T t1, T t2, BinaryOperator<T> operator){
return operator.apply(t1, t2);
}

行为参数化就是可以帮助你处理频繁变更的需求的一种软件开发模式。在lambda表达式之前,Java专家们使用接口中的一个方法来封装对象行为进行参数传递,常见情况如下:

1
2
3
4
5
6
7
8
9
//传统写法
button.addActionListener(new ActionListener) {
public void actionPerformed(ActionEvent e) {
ui.dazzle(e.getModifiers());
}
}

//lambda写法
button.addActionListener(e -> ui.dazzle(e.getModifiers()));

使用接口方法定义行为,然后通过匿名类方式实现行为传递,但通常非常臃肿,既难于编写,也不易于维护。这种方案并不令人满意:冗余的语法会影响程序员在实践中使用行为参数化的积极性。像上面例子一样,真正有用的代码就只有一句:ui.dazzle(e.getModifiers()),而大量模板式的噪点代码充斥其中使代码复杂化,结构不够清晰。

1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void test3(){
List<Integer> list = Arrays.asList(1, 10, 3, 5, 9, 12, 18, 30);
//1、找出流中大于2的元素,然后将每个元素乘以2,然后忽略掉流中的前两个元素,然后再取流中的前两个元素,最后求出流中元素的总和

int ret = list.stream()
.filter(x -> x > 2)
.mapToInt(x -> x * 2)
.skip(2)
.limit(2)
.sum();
}

进一步扩展:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
public void test4(){
List<Integer> list = Arrays.asList(1, 10, 3, 5, 9, 12, 18, 30);
//1、找出流中大于2的元素,然后将每个元素乘以2,然后忽略掉流中的前两个元素,然后再取流中的前两个元素,最后求出流中元素的总和

//获取统计信息
IntSummaryStatistics statistics = list.stream()
.filter(x -> x > 2)
.mapToInt(x -> x * 2)
.skip(2)
.limit(2)
.summaryStatistics();

/** 计算出最大,最小,平均等等,是不是很好用,赶紧get起来 **/
System.out.println("the max:" + statistics.getMax());
System.out.println("the min:" + statistics.getMin());
System.out.println("the average:" + statistics.getAverage());
System.out.println("the sum:" + statistics.getSum());
System.out.println("the count:" + statistics.getCount());
}

Java中重要的函数接口

java.util.function包中定义了许多非常通用的函数式借口,其中四大核心函数式接口:

接口 参数 返回类型 描述
Predicate T boolean 断言型接口
Consumer T void 消费型接口
Function T R 函数式接口
Supplier None T 供给型接口

基本概念

Java中,我们无法将函数作为参数传递给一个方法,也无法声明返回一个函数的方法:

  • lambda表达式为Java添加了缺失的函数式编程特性,使我们能将函数当做⼀等公⺠看待
  • 在将函数作为⼀等公⺠的语⾔中,lambda表达式的类型是函数。 但在Java中,lambda表达式是对象,它们必须依附于⼀类特别的对象类型——函数式接(functional interface)

函数式接口(@FunctionalInterface):

  • 一个接口只有一个抽象方法。从JDK1.8开始,接口中可以有实现方法,该方法叫默认方法。
  • 如果我们在某个接口上声明了@FunctionalInterface注解,那么编译器就会按照函数式接口的定义要求该接口。
  • 如果某个接口只有一个抽象方法,但我们并没有给该接口声明@FunctionalInterface注解,那么编译器依然会将该接口看着是函数式接口。

注意:一个接口如果声明的抽象方法是重写(overriding)Object对象的,则不算抽象方法,因为一个接口的实现肯定是Object子类,肯定有该方法的实现。如下也可以看成是函数式接口:

1
2
3
4
5
@FunctionalInterface
interface MyFunction{
void execute(String str);
String toString();//来自Object类的重写
}

Java中的lambda表达式基本语法:(argument) -> (body),通过箭头符号将左右分割开,左边是参数,右边表示的是方法体。比如:(arg1, arg2...) -> {body},或(type1 arg1, type2 arg2...) -> {body}

默认方法

默认方法它可以为接口添加新的方法,而不会破坏已有的接口的实现。这在lambda表达式作为java 8语言的重要特性而出现之际,为升级旧接口且保持向后兼容(backward compatibility)提供了途径。

Java 8中对API最大的改变在于集合类,涉及到修改像集合类这样的核心类库之后,如果没有默认方法出现,很难保证向后兼容。比如:Java 8中为Collection接口增加stream方法,这意味着所有实现了Collection接口的类都必须增加这个新方法。对核心类库里的类来说,实现这个新方法(比如为ArrayList 增加新的stream 方法)就能就能使问题迎刃而解。但是,在JDK之外实现Collection接口的类,例如MyCustomList,也仍然需要实现新增的stream方法,否则这个MyCustomListJava 8中无法通过编译。这是所有使用第三方集合类库的梦魇,要避免这个糟糕情况,则需要在Java 8中添加新的语言特性:默认方法

Iterable接口中也新增了一个默认方法:forEach,该方法功能和for 循环类似,但是允许用户使用一个Lambda 表达式作为循环体。

1
2
3
4
5
default void forEach(Consumer<? super T> action) {
for (T t : this) {
action.accept(t);
}
}

默认方法给予我们修改接口而不破坏原来的实现类的结构提供了便利,目前java 8的集合框架已经大量使用了默认方法,当我们使用lambdas表达式时,提供一种平滑的过渡体验。

方法引用

lambda体中的内容有方法已经实现了,我们可以使用方法引用,可以理解为:方法引用是lambda表达式的另外一种表现形式,对一类特殊的lambda表达式进一步进行简化,共分为四种情况:

  • 类名::静态方法名:(x, y) -> 类名.静态方法名(x, y)
  • 对象名::实例方法名:(x, y) -> 对象名.实例方法名(x, y),如:System.out::println
  • 类名::实例方法名:(x) -> x.实例方法名()
  • 构造方法引用:类名::new(x, y) -> new 构造方法(x, y),如:Supplier<List<Object>> ret = ArrayList::new;
1
2
3
List<String> list = Arrays.asList("a", "b", "c");
list.stream().map(x -> x.toUpperCase()).forEach(x -> System.out.println(x));
list.stream().map(String::toUpperCase).forEach(System.out::println);

x -> x.toUpperCase()简写成String::toUpperCase

x -> System.out.println(x)简写成System.out::println

总结:x -> x.无参方法调用x -> 对象.方法(x作为参数传入)都可以进行简写对象引用,其中x -> 对象.方法(x作为参数传入),如果方法是static的,则可直接简写成:x -> 类.static方法(x作为参数传入),即类引用。

1
2
3
4
5
6
7
8
@Test
public void test1(){
List<SampleFunctionImpl> list =
Arrays.asList(new SampleFunctionImpl(), new SampleFunctionImpl());

//list.stream().forEach(x -> x.execute());
list.stream().map(SampleFunctionImpl::execute).forEach(System.out::println);
}

SampleFunctionImpl::execute简写可能有两种实现方式,方式一:x -> x.无参方法调用

1
2
3
4
5
class SampleFunctionImpl {
public String execute() {
return "execute()";
}
}

也可以通过方式二实现:x -> 类.static方法(x作为参数传入)

1
2
3
4
5
class SampleFunctionImpl {
public static String execute(SampleFunctionImpl impl) {
return "static execute(SampleFunctionImpl impl)";
}
}

如果SampleFunctionImpl类中这两个定义的方法都存在,则SampleFunctionImpl::execute编译器会识别存在多种实现而无法具体确定使用哪个方法而提示错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Demo11 {

@Test
public void test1(){
List<SampleFunctionImpl> list =
Arrays.asList(new SampleFunctionImpl(), new SampleFunctionImpl());

//list.stream().forEach(x -> x.execute());
list.stream().map(SampleFunctionImpl::execute).forEach(System.out::println);
list.stream().map(new SampleFunctionImpl()::execute2).forEach(System.out::println);//对象引用
}

}

class SampleFunctionImpl {
public String execute() {
return "execute()";
}

public static String execute(SampleFunctionImpl impl) {
return "static execute(SampleFunctionImpl impl)";
}

public String execute2(SampleFunctionImpl impl) {
return "static execute(SampleFunctionImpl impl)";
}
}

组合行为

函数式编程的一个核心操作是组合:将两个函数组合起来构成第3个函数,其效果等价于连续应用两个组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
public void test1(){
Function<Integer, Integer> function = x -> x*2;
Function<String, Integer> before = x -> Integer.parseInt(x) + 2;
/**
* Function<V, R> compose(Function<? super V, ? extends T> before):先执行参数传入行为,后执行当前行为
* Function<T, V> andThen(Function<? super R, ? extends V> after):先执行当前行为,后执行参数传入行为
*/
Function<String, Integer> function2 = function.compose(before);

System.out.println(execute(function2, "10"));
}

private <T, R> R execute(Function<T, R> function, T value){
return function.apply(value);
}

线性处理链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Album:专辑,由若干曲目组成
Track:曲目,专辑中的一支曲目
现在要实现这样需求:现在要找出长度大于1分钟的曲目,并将曲目名字返回成一个Set集合
传统方式:
public Set<String> findLongTracks(List<Album> albums) {
Set<String> trackNames = new HashSet<>();
for(Album album : albums) {
for (Track track : album.getTrackList()) {
if (track.getLength() > 60) {
String name = track.getName();
trackNames.add(name);
}
}
}
return trackNames;
}

使用Stream方式:
public Set<String> findLongTracks(List<Album> albums) {
return albums.stream()
.flatMap(album -> album.getTracks())
.filter(track -> track.getLength() > 60)
.map(track -> track.getName())
.collect(toSet());
}

非线性处理链

1553570946567

一个lambda就类似于之前的一个标准组件,借助于lambda表达式简洁语法,实现了即使只有一条语句也可以抽象成一个组件,同时lambda表达式提供了大量的组合行为方法,更加方便构建任务链

函数式编程思维

面向对象编程和函数式编程在思维上有很大的反差,熟悉面向对象编程初次接触函数式编程觉得很怪异,无法适应情况等。然后,函数式编程在很多语言中流行, 它可以实现更抽象、更灵活的接口(高级接口)。

面向对象编程是对物体归纳提取抽象成类,而函数式编程则是对行为进行归纳提取抽象,具有更高层次的抽象能力,可以实现更高级、更灵活的接口,函数式编程可以完善面向对象编程的不足。根据行为函数的输入参数、输出信息等特征,进行归纳抽象出这些行为函数的共性。比如:只有一个入参没有输出类型抽象成消费型接口Consumer,没有入参只有返回类型的抽象成供给型接口Supplier,有一个输入参数同时带返回类型的抽象成函数式接口Function等。

框架设计中大量使用模板设计模式,把系统的整个架子和可实现部分完成,对于那些无法实现的通过回调接口方式暴露出去,由具体的业务系统自己实现。这些回调接口可以看成框架和业务系统间的契约接口,框架只会关心接口输入参数个数、参数类型以及返回类型等特征,而对于接口中的具体实现,框架时不回关心的,这就是函数式编程的意义。

模式编程:

1553765668928

坚持原创技术分享,您的支持将鼓励我继续创作!