《Java8函数式编程》-读书笔记
Peng's Blog 只记录和技术相关的东西

《Java8函数式编程》-读书笔记

2017-07-26

简介

1、面向对象编程是对数据进行抽象,而函数式编程是对行为的抽象。

​ 现实世界中,数据和行为并存,程序也是如此,所以很有必要学习。

2、使得代码变得简单易读,易读就代表着业务逻辑清晰,也就代表着出错率会低。

3、函数式编程的核心在于:在思考问题时,使用不可变值和函数,函数对一个值进行处理,映射成另一个值。


Lambda表达式

关键词;函数式编程

两个例子

1、foreach

Arrays.asList( "a", "b", "d" ).forEach( e -> {
    System.out.print( e );
    System.out.print( e );
} );

lambda表达式可以引用类成员和局部变量,会将这些变量隐式的转变成final

String separator = “,”;

Arrays.asList( “a”, “b”, “d” ).forEach(

​ ( String e ) -> System.out.print( e + separator ) );

2、comparator

Collections.sort(names, (first, second) -> first.length() - second.length());

将过滤器建换成了一个lambda函数,上述代码能做到按names列表内元素的长度进行排序

优点,不需要指定参数的类型

在Java之前的传递行为:唯一的方法就是匿名内部类

比如:

sendEmail(new Runnable() {
  @Override
  public void run() {
    System.out.println("Sending email...");
  }
});

使用lambda表达式之后:sendEmail(() -> System.out.println(“Sending email…”));

()代表无参函数

lambda表达式在Java8中的运行机制

你可能已经发现lambda表达式的类型是一些类似上例中的comparator的接口。但并不是所有接口都可以使用lambda表达式。

只有那些仅仅只包含一个非实例化抽象方法的接口才能使用lambda表达式。这样的接口被称为函数式接口。

这时候可能会有个疑问,Java8的lambda表达式是否只是一个匿名内部类的语法糖或者函数式接口是如何被转换成字节码的?

其实并不是。

不用匿名内部类的原因

Java8不采用匿名内部类有两个原因:

1、性能影响。如果lambda表达式采用匿名内部类实现,那么每一个lambda表达式都会在磁盘上生成一个class文件。当jvm启动时,这些class文件会被加载进来,因为所有的class文件都需要在启动时加载并在使用前确认。这会导致jvm变慢。

2、向后的扩展性,如果使用匿名内部类的方式,那么这将限制lambda表达式的未来发展范围。

Java8的设计者采用的是Java7中新增的动态启用来延迟在运行时的加载策略。

原理:当Javac编译代码时,它会捕获代码中的lambda表达式并且生成一个动态启用的调用地址(称为lambda工厂)。当动态启用被调用时,就会向lambda表达式发生转换的地方返回一个函数式接口的实例。

匿名内部类 vs lambda

(区别)

1、在匿名类中,this 指代的是匿名类本身,但是在lambda中,this指代的是lambda表达式所在的这个类。

2、lambda表达式的类型是由上下文决定的,而匿名类中必须在创建实例的时候明确指定。


目的

流使程序员得以站在更高的抽象层次上对集合进行操作

细节

1、迭代器的使用,一般算是外部迭代。这样的缺点是 很难抽象出其它操作,此外,从本质上讲是一种串行化操作。

​ 使用内部迭代:

​ long count = allArtists.stream().filter(artist -> artist.isFrom(“London”)).count();

2、stream 是用函数式编程方式在集合类上进行复杂操作的工具

3、惰性求值,只要返回的结果是stream那么就是惰性求值;

​ 及早求值,如果返回值是另一个值或者为空,那么就是及早求值

​ 使用这些操作的理想方式就是形成一个惰性求值的链,最后用一个及早求值返回想要的结果,这正是它的合理之处。

​ allArtists.stream() .filter(artist -> artist.isFrom(“London”));

​ 像filter这样只描述stream,最终不产生新集合的方法叫做 惰性求值 方法。

​ 像count这种,最终会从stream产生值的方法叫做 及早求值 方法

​ 整个过程类似于 创建者模式。创建者模式使用一系列操作设置属性和配置,最后调用一个build方法,这时对象才被真正创建。

​ 为什么要区分惰性求值和及早求值? 为了只迭代一次

4、collet(toList( )) 方法

5、map : 将一个流中的值转换成另一个新的流

6、filter: 过滤

​ 注意,这个过滤不是指的过滤走满足条件的返回剩下的。这里返回的是过滤出来的,满足条件里面的。

7、flatMap:flatMap可用stream替换值,然后将多个stream连接成一个stream

​ 例子:假设有一个包含多个列表的流,希望能得到所有的序列:

​ Stream.of(asList(1, 2), asList(3, 4)).flatMap(numbers -> numbers.stream() ).collect(toList);

8、min和max

​ Track shortestTrack = tracks.stream().min(Comparator.comparing(track -> track.getLength())).get();

9、reduce 操作

​ reduce的意思是减少、归纳为

​ reduce可以实现从一组值中生成一个值。 count、min、nax都属于reduce操作

​ 使用reduce求和:

​ int count = Stream.of(1, 2, 4).reduce(0, (acc, element) -> acc + element);

10、整合操作 重点

​ 如何将问题分解成 stream 操作。

​ 将思路往上面这些关键字靠

鼓励用户使用 Lambda 表达式获取值而不是变量。获取值 使用户更容易写出没有副作用的代码

在 Lambda 表达式中使用局部变量, 可以不使用 final 关键字,但局部变量在既成事实上必须是 final 的。

无论何时,将 Lambda 表达式传给 Stream 上的高阶函数,都应该尽量避免副作用。唯一的 例外是 forEach 方法,它是一个终结方法。

11、不建议将流操作分开,建议一步完成。

​ 原因:分成多个步骤的话,可读性差、效率差,每一步都要对流及早求值,生成了新的集合。而且一大堆垃圾变量(中间变量),浪费空间。难以自动并行化处理。

12、高阶函数

​ 高阶函数是指接收另外一个函数作为参数,或返回一个函数的函数。

​ 很容易辩论什么是高阶函数,一般只要看函数签名就可以了。如果函数的参数列表里包含函数接口,或该函数返回一个函数接口,那么该函数就是高阶函数。

​ Stream接口中的几乎所有函数都是高阶函数。

13、正确使用lambda表达式

​ 作用,1)使代码更清晰更简洁、2)使用户更容易写出没有副作用的代码,具体是指,使用Lambda表达式获取值而不是变量。

总结:

内部迭代将更多控制权交给了集合类。

和Iterator类似,Stream是一种内部迭代方式。

将Lambda表达式和Stream上的方法结合起来,可以完成很多常见的集合操作。

练习

1、常用流操作

  • numbers.collet(Collectors.summingInt(a->a));
  • numbers.colect(a->a.getxxx,a.getxxx);


类库

前面说了如何编写lambda表达式,现在说如何使用lambda表达式。

默认方法和接口的静态方法。

在代码中使用Lambda表达式

​ 不同的函数接口有不同的方法。如果使用Predicate,就应该调用test方法,如果使用Function,就应该调用apply方法

基本类型

​ 拆箱装箱,int基本类型,Integer包装类。

​ 包装类又叫装箱类型,是对象。因此在内存红存在额外开销。比如:整型在内存中占用4字节,整型对象占用16字节。

​ 数组更严重,整型数组中的每个元素只占用基本类型的内存,而整型对象数组中,每个元素都是内存中的一个指针,指向Java堆中的某个对象。最坏的情况,同样大小的数组,Integer[] 要比int[] 多占6倍的内存。

​ 将基本类型转换成装箱类型,称为装箱。将装箱类型转换成基本类型,称为拆箱。两者都需要额外的内存开销。

​ 为了减少开销,所以Stream类的某些方法对基本类型和装箱类型做了区分。处理之后,如果方法返回类型为基本类型,则在基本类型前加To,如ToLongFunction。如果参数是基本类型,则不加前缀只需类型名即可,如LongFunction。 如果高阶函数使用基本类型,则在操作后加后缀To再加基本类型,如 mapToLong。

重载解析

​ 总而言之,Lambda 表达式作为参数时,其类型由它的目标类型推导得出,推导过程遵循 如下规则:

如果只有一个可能的目标类型,由相应函数接口里的参数类型推导得出;

如果有多个可能的目标类型,由最具体的类型推导得出;

如果有多个可能的目标类型且最具体的类型不明确,则需人为指定类型。

​ 注意:不能重写默认方法里面的equals和hashCode方法,因为它们是Object类定义的。

@FunctionlInterface

​ 事实上,每个用作函数接口的接口都用过加上这个注释。

​ 该注释会强制javac检查一个接口是否符合函数接口的标准。如果该注释添加给一个枚举类型、类或另一个注释,或接口包含不止一个抽象方法,javac就会报错。重构代码时,使用它能很容易发现问题。

二进制接口的兼容性

​ 使用Java1到Java7编译的类库或应用,可以直接在Java8上运行。

​ 新增方法,意味着会打破平衡。比如Java8的Collection接口增加了stream方法,这意味着所有实现了Collection接口的类都必须增加这个新方法。当然也不是加上就没事了,加上可能会无法通过编译。

​ 要避免这个糟糕情况,则需要爱Java 8中添加新的语言特性:默认方法。

默认方法 default关键字

​ Collection 接口中增加了新的 stream 方法,如何能让 MyCustomList 类在不知道该方法的情况下通过编译?Java 8通过如下方法解决该问题:Collection接口告诉它所有的子类: “如果你没有实现 stream 方法,就使用我的吧。”接口中这样的方法叫作默认方法,在任何接口中,无论函数接口还是非函数接口,都可以使用该方法。

默认方法和子类:

​ 增加默认方法主要是为了在接口上向后兼容,让类中重写方法的优先级高于默认方法能简化很多继承问题。

多重继承

​ 主要是指接口的多重继承。

​ 万一多个接口里面遇到名字相同的方法,鬼知道你调用的是哪一个。要指定的话,需要在类中实现。比如:

public class MusicalCarriage
implements Cariage, Jukebox {
  @Override
  public String rock() {
      return Carriage.super.rock();
  }
}

增强了super的语法。可以指定具体的接口里面的方法了。

使用默认方法的三定律:

1、类胜于接口。如果在继承链中有方法体或者抽象的方法声明,那么可以忽略接口中定义的方法。

2、子类胜于父类。如果一个接口继承自另一个接口,且两个接口都定义了一个默认方法,那么子类中定义的方法胜出。

3、没有规则3,如果上面两条不适用,子类要么实现该方法,要么将该方法声明成抽象方法。

第一条是为了然代码向后兼容。

权衡

​ 用接口还是抽象类呢,这个要根据具体问题具体情况进行权衡。

接口的静态方法

Optional

​ reduce方法返回optional对象。

​ optional是为核心类库新设计的一个数据类型,用来替换null值。

​ 使用optional的目的:1、Optional对象鼓励程序员适时检查变量是否为空,以避免代码缺陷。2)它将一个类的API中可能为空的值文档化,这比阅读实现代码要简单很多。

​ 当试图避免控制相关的缺陷,如未捕获的异常时,可以考虑一下是否可以使用 Optional对象。

要点回顾

​ 1、使用为基本类型定制的Lambda表达式和Stream,如IntStream可以明显提升系统性能。

​ 2、默认方法是指接口中定义的包含方法体的方法,方法名有default关键字做前缀。

​ 3、在一个值可能为空的建模情况下,使用Optional对象能代替使用null值


高级集合类和收集器

方法引用

​ 就比如 Artist::getName。这种写法就叫方法引用。 标准语法是ClassName::methodName。(构造方法也可以这样写。xxx::new、 还可以用来创建数组:String[]::new )

​ 需要注意的是不需要加括号。而且凡是使用lambda表达式的地方就可以使用方法引用。

元素顺序

​ 流中的元素是以何种顺序排序。

​ 直观上看,流是有序的,因为流中的元素都是按顺序处理的。这种顺序称为出现顺序。出 现顺序的定义依赖于数据源和对流的操作。

​ 如果在一个有序集合中创建一个流时,流中的元素就按出现顺序排序。如果集合本身就是无序的,那么生成的流也是无序的。比如Set。

​ foreach:

​ 使用并行流时,forEach方法不能保证元素是按照顺序处理的,如果需要按照顺序处理,应该使用forEachOrdered方法。

使用收集器

1、转换成其它集合

​ stream.collect(toCollection(TreeSet::new)); 这个目前已经熟练掌握了

2、转换成值

​ maxBy、minBy 找出最大最小的值。

​ 然后求和、求平均值啥的都有,基本上也都用过。

3、数据分块

​ 将一个集合分成两个集合。partitionBy(xxx)

比如:使用方法引用将艺术家组成的 Stream 分成乐队和独唱歌手两部分

public Map<Boolean, List<Artist>> bandsAndSoloRef(Stream<Artist> artists) { 
  return artists.collect(partitioningBy(Artist::isSolo));
}

4、数据分组

​ 和分块不同,可以使用任意值对数据分组。groupingBy(xxx) 这个也很熟练了。

​ 类似于SQL的 group by

5、转换成字符串

​ map提取内容,collet:: joining合并。

例如:使用流和收集器格式化艺术家姓名

     String result =
         artists.stream()
                   .map(Artist::getName)
                   .collect(Collectors.joining(", ", "[", "]"));

6、组合收集器

​ 就是将这些收集器方法组合运用,可以做到非常强大的效果。

7、重构和定制收集器

​ 可以自己写收集器。

8、对收集器的归一化处理

​ 虽然自己写一个收集器不难,但是还是不太建议。可以构建若干个集合对象,作为参数传递给领域内类的构造函数。

一些细节

  • compute方法。。这个太熟悉了
  • map的foreach方法,用Stream实现会很方便。不用再写
  • for(Map.Entry<Artist, List<Album» entry : albumsByArtist.entrySet()) {xxx} 这样子的代码了

要点回顾

  • 方法引用是一种引用方法的轻量级语法,形如ClassName::methodName
  • 收集器可以用来计算流的最终值,是reduce方法的模拟
  • Java8提供了手机多种容器类型的方式,同时允许用户自定义收集器


数据并行化

并发是指两个任务共享时间段,并行则是两个任务在同一时间发生

并发与并行

并发(concurrence)与并行(parallel)的区别:

并行与并发

举个例子(来自知乎龚昱阳 Dozer):

你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。 你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。 你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。并发的关键是你有处理多个任务的能力,不一定要同时。 并行的关键是你有同时处理多个任务的能力。所以我认为它们最关键的点就是:是否是『同时』。

并行化是指为缩短任务执行时间,将一个任务分解成几部分,然后并行执行。这和顺序执行的任务量是一样的,区别就像用更多的马来拉车,花费的时间自然就减少了。

串行化与并行化

串行:食堂只开一个窗口,后面一堆排队的。 并行:食堂开了好几个窗口,可以排成好几队

数据并行化

所谓的数据并行化,是指将数据分成块,为每块数据分配单独的处理单元。

打个比方,就是像从车里取出一些货物,放到另一辆车上,两辆马车都沿着同样的路径到达目的地。

任务并行化和数据并行化的区别:任务并行化中,线程不同工作各异。

并行化流操作

其实很简单,只需要将.stream()换成.parallelStream()就可以了。

但是,什么时候都试用吗?当然不是,只有数量特别大的时候并行化.parallelStream(的速度才会远远高于串行化.stream()。

当然,数据流的大小并不是决定并行化是否会带来速度提升的唯一因素,性能还会受到编写代码的方式核的数量的影响。

模拟系统

并行化流操作的主要应用场景就是使用简单操作处理大量数据,比如模拟系统。

限制

避免持有锁,因为关于同步操作,流框架会在需要的时候自己处理。

性能

影响并行流性能的主要因素有5个。

1、数据大小

只有数据足够大,每个数据处理管道花费的时间足够多时,并行化处理才有意义。

2、源数据结构

通常是集合,将不同的数据源分割相对容易,这里的开销影响了在管道中并行处理数据时到底能带来多少性能的提升。

3、装箱

处理基本类型比处理装箱类型要快。

4、核的数量

要是只有一个核??那完全没有必要并行化。运行时你的机器能使用多少核,这个值越高,性能就越高。

5、单元处理开销

比如数据大小,这是一场并行执行花费时间和分解合并操作开销之间的战争。花在流中每个元素身上的时间越长,并行操作带来的性能提升越明显。

并行求和demo:

private int addIntegers(List<Integer> values) { return values.parallelStream()
                      .mapToInt(i -> i)
                      .sum();
}

并行化数组操作

使用并行化数组操作初始化数组

public static double[] parallelInitialize(int size) { 
	double[] values = new double[size]; 
	Arrays.parallelSetAll(values, i -> i);
	return values;
}

要点回顾

  • 数据并行化是把工作拆分,同时在多核CPU上执行的方式。
  • 如果使用流编写代码,可通过调用 parallel 或者 parallelStream 方法来实现数据并行化操作。
  • 影响性能的五要素:数据大小,源数据结构,值是否装箱,可用CPU核数量,以及处理每个元素所花的时间。


测试、调试和重构

主要讲了如何把以前老的代码用lambda表达式进行重构,还说明了什么情况下不应该(直接使用Lambda表达式)。还讲了如何使用Lambda表达式提高非集合类代码的质量。

重构候选项

1、举了个例子,说明Lambda表达式更好地符合了面向对象编程。因为符合里面的一大特性 - -封装局部状态。

比如日志类:Logger 类的 isDebugEnable()方法就暴露了局部状态,这不合理,用Lambda表达式就可以把这个方法隐藏掉。

2、孤独的覆盖,基本上有 @Override 的地方,都要考虑一下是不是可以用Lambda表达式

3、同样的东西写两遍

Write Everything Twice,WET。

并不是所有的WET都适合Lambda化,那什么时候合适呢? 如果有一个整体上大概相似的牧师,只是行为上有所不同,就可以试着加入一个Lambda表达式。

Lambda表达式的单元测试

单元测试是测试一段代码的行为是否符合预期的方式

因为Lambda表达式没有名字,无法直接在测试代码中调用,所以Lambda表达式给单元测试带来了很大的麻烦。

你可以在测试的代码中复制Lambda表达式来测试,但这种方式的副作用是测试的不是真正的实现。

解决方式:

1、将Lambda表达式放入一个方法 测试,这种方式要测那个方法,而不是Lambda表达式本身。

2、别用Lambda表达式,请用方法引用。任何Lambda表达式都能被改写成普通方法,然后使用方法引用直接引用。

在测试替身时使用Lambda表达式

测试替身也被称作 模拟。

惰性求值和调试

调试的时候我们一般会设置断点,但是在使用流的时候,调试可能会变得十分复杂,因为迭代已经交由类库控制,而且很多流操作是惰性求值的。对于这一点,深有体会!!!太恶毒了

惰性求值,其实意思是说当需要的时候才求值。

日志和打印消息

还是和调试有关,说了流操作对日志和调试很麻烦,最好还是改写成普通的写法进行日志的打印和程序的调试。

解决方法:peek

跟队列的只看队头但不弹出队头的方法是同一个意思。

流有一个方法让你能查看每个值,同时能继续操作流,这就是peek方法。

例 7-18 使用 peek 方法记录中间值

Set<String> nationalities
	 = album.getMusicians()
	        .filter(artist -> artist.getName().startsWith("The"))
	        .map(artist -> artist.getNationality())
	        .peek(nation -> System.out.println("Found nationality: " + nation))
	        .collect(Collectors.<String>toSet());

使peek还能以同样的方法,将输入定向到现有的日志系统中,比如log4j、slf4j等

在流中间设置断点

peek好强大,直接在peek方法里面加一个空的方法体,里面就能设置断点了。当然方法不空也一样能设置断点。

要点回顾

  • 重构遗留代码时考虑使用Lambda表达式,有一些通用的模式;
  • 如果想对复杂一点的Lambda表达式编写单元测试,将其抽取成一个常规的方法;
  • peek方法negative记录中间值,在调试的时候非常有用


设计和架构的原则

主要讲了Lambda对传统设计模式的影响,还有就是Lambda的SOLD原则。

Lambda表达式改变了设计模式

设计模式:http://blog.tanpeng.net/2017/07/19/designPatterns/

使用Lambda表达式能让设计模式变得更好更简单。

命令者模式

将“请求”封装成对象,将动作请求者和动作执行者进行解耦。

代码在我的GitHub上面:https://github.com/enterprising/designPatterns

策略模式

可以理解成Java的多态。

定义一系列的算法,将每个算法封装起来,让他们可以互相替换。

例如:Collections.sort(),这个里面传入不同的比较器,就有不同的效果,这就是一种策略模式。

代码地址同上

观察者模式

在观察者模式中,被观察者持有一个观察者列表,当被观察者的状态发生改变,会通知观察者。

观察者模式被大量应用于基于MVC的GUI工具中,以此让模型状态发生变化时,自动刷新视图模块,达到两者之间的解耦。

代码地址同上。

使用Lambda表达式的领域专用语言

领域专用语言(DSL),是针对软件系统中某特定部分的编程语言。

人们通常将DSL分为两类:内部DSL和外部DSL。

外部DSL指的是脱离程序源码编写,然后单独解析和实现,比如 CSS 和正则表达式。

内部DSL嵌入到它们的编程语言当中,比如SQL等。

使用Lambda表达式的话,会简化很多。

使用Lambda表达式的SOLID原则

SOLID???看到这么奇葩的东西,一定是简称。没错,这就是设计面向对象程序时的一些基本原则。每个字母都是一个原则的首字母。

Single responsibility、Open/closed、Liskov substitution、Interface 和 Dependency inversion。

详细的讲了三个原则:单一功能原则,开闭原则,依赖反转原则。看名字就应该知道意思了。

要点回顾

  • Lambda 表达式能让很多现有设计模式更简单、可读性更强,尤其是命令者模式。
  • 在Java8中,创建领域专用语言有更多的灵活性。
  • 在Java8中,有应用SOLID原则的新机会。


使用Lambda表达式编写并发程序

为什么要用非阻塞式IO

NIO。。详细内容请看我之前的一篇博客:http://blog.tanpeng.net/2017/07/30/Java-BIO-NIO-AIO/

非阻塞式IO,又叫异步IO,可以处理大量并发网络编程,而且一个线程可以为多个连接服务。

回调

为了展示NIO的原则,所以.. 写一个聊天应用。

代码在:https://github.com/RichardWarburton/java-8-lambdas-exercises

消息传递架构

其实,这部分关于那个聊天软件的介绍用到了 Vert.x 这个框架。

在这一章,作者主要是想表达,使用Lambda表达式表示行为,构建API来管理并发。

响应式编程

响应式编程其实是一种声明式编程方法,它让程序员以自动流动的变化和数据流来编程。

何时何地使用新技术

本章主要讲了如何使用NIO和基于事件驱动的系统,但是这不意味着现在就马上需要抛弃Java EE还有一Spring那些。

现在CompletableFuture和RxJava相对比较新,它们用起来比导出显式使用Future和回调要简单很多,但是对很多问题来说,传统的阻塞式Web应用技术就已经足够了,不必画蛇添足。

但是,这是目前的一个潮流。事件驱动和响应式应用正在变得越来越流行,而且进场会是为你的问题建模的最好方式之一。

有两种情况特别适合使用响应式或者事件驱动的方式来思考:

1、业务逻辑本身就使用事件来描述。比如Twitter,它是一种订阅文字流信息的服务。图形化展示股票价格也是一种例子,每一次价格的变动都可以认为是一个事件。

2、需要同时处理大量IO操作。阻塞IO需要同时使用大量线程,这会导致大量锁之间的竞争和太多的上下文切换。如果你想处理成千上万的连接,非阻塞式IO会是更好的选择。

要点回顾

  • 使用基于Lambda表达式的回调,很容易实现事件驱动架构。
  • CompletableFuture代表了IOU,使用Lambda表达式能方便地组合、合并。
  • Obervable继承了CompletabnleFuture的概念,用来处理数据流。

完。 2017年8月12日。


下一篇 Java 任务调度

Comments

评论功能暂停使用,如需跟作者讨论请联系底部的GitHub