读《Java 8 函数式编程》
前言
- 以下内容带有“【摘】”字样的段落,均来自”Java 8 Lambda, Richard Warburton著(O‘Reilly , 2015)”。
- 我自己写的代码均使用Junit @Test,实体均为内部类,这里的@Data是lombok插件哦。
- 本文内容章节随书的章节而定,并不是所有的章节都有做笔记,所以部分章节可能没有出现。
简介
每个人对函数式编程的理解不尽相同。但其核心是:在思考问题时,使用不可变值和函
数,函数对一个值进行处理,映射成另一个值。
不同的语言社区往往对各自语言中的特性孤芳自赏。现在谈 Java 程序员如何定义函数式编程还为时尚早,但是,这根本不重要!我们关心的是如何写出好代码,而不是符合函数式
编程风格的代码。
本书将重点放在函数式编程的实用性上,包括可以被大多数程序员理解和使用的技术,帮助他们写出易读、易维护的代码。【摘】
Lambda 表达式
Lambda 表达式的几种形式
- unnable noArguments = () -> System.out.println(“Hello World”);
- ActionListener oneArgument = event -> System.out.println(“button clicked”);
- Runnable multiStatement = () -> {- System.out.print(“Hello”);System.out.println(“ World”);};
- BinaryOperator
add = (x, y) -> x + y; - BinaryOperator
addExplicit = (Long x, Long y) -> x + y;
Lambda 表达式的类型依赖于上下文环境,是由编译器
推断出来的。目标类型也不是一个全新的概念。如final String[] array = { "hello", "world" };
Java 中初始化数组时,数组的类型就是根据上下文推断出来的。另一个常见的例子是 null ,只有将
null 赋值给一个变量,才能知道它的类型。
这里的示例为什么要用final
呢?我理解为让我们有一个良好的编程习惯,常量用final
修饰。
看到BinaryOperator
就涉及到我的知识盲区了,下面附带一个测试代码。
介绍:表示对同一类型的两个操作数的操作,产生与操作数相同类型的结果。 对于操作数和结果都是相同类型的情况,这是BiFunction
的专业化
1 | @Test |
2.3 引用值 , 而不是变量
Java 8 可以引用非 final 变量,但是该变量在既成事实上必须是
final 。虽然无需将变量声明为 final ,但在 Lambda 表达式中,也无法用作非终态变量。如果坚持用作非终态变量,编译器就会报错。
比如:
1 | String str = "final"; |
2.4 函数接口
函数接口是只有一个抽象方法的接口,用作 Lambda 表达式的类型。
Java中重要的函数接口
接口 | 参数 | 返回类型 | 示例 |
---|---|---|---|
Predicate |
T | boolean | 这张唱片已经发行了吗 |
Consumer |
T | void | 输出一个值 |
Function<T,R> | T | R | 获得 Artist 对象的名字 |
Supplier |
None | T | 工厂方法 |
UnaryOperator |
T | T | 逻辑非 (!) |
BinaryOperator |
(T, T) | T | 求两个数的乘积 (*) |
好吧,这都是知识盲区,既然见到了不得不学习一番。
- Predicate
即对t进行断言,返回true或者false。
1 | @Data |
- Consumer
表示接受单个输入参数并且不返回结果的操作。 与大多数其他功能界面不同, Consumer预计将通过副作用进行操作。本人不成熟的见解:当前类中的处理进行封装,更有利于调用,通过副作用进行实现。
1 | // JDK1.8实现 |
- Function<T,R> 表示接受一个参数并产生结果的函数。
1 | Function<Integer, Integer> name = e -> e * 2; |
- Supplier
是一个提供结果的函数接口,每次调用get()方法的时候才会创建对象。并且每次调用创建的对象都不一样;
1 | public class SupplierTest { |
- UnaryOperator
Operator其实就是Function,函数有时候也叫作算子。算子在Java8中接口描述更像是函数的补充,和上面的很多类型映射型函数类似。它包含UnaryOperator和BinaryOperator。分别对应单元算子和二元算子。
1 | Function<Integer, Integer> name = e -> e * 2; |
- BinaryOperator
上面也介绍到了,部分通用接口部分介绍中也体现了,这里就不讲了。
2.5 类型推断
某些情况下,用户需要手动指明类型,建议大家根据自己或项目组的习惯,采用让代码最便于阅读的方法。有时省略类型信息可以减少干扰,更易弄清状况;而有时却需要类型信息帮助理解代码。经验证发现,一开始类型信息是有用的,但随后可以只在真正需要时才加上类型信息。下面将介绍一些简单的规则,来帮助确认是否需要手动声明参数类型。【摘】
下面是一些例子:
1 | Map<String, Integer> oldWordCounts = new HashMap<String, Integer>(); |
1 | useHashmap(new HashMap<>()); |
Java 7 中程序员可省略构造函数的泛型类型,Java 8 更进一步,程序员可省略 Lambda 表达式中的所有参数类型。再强调一次,这并不是魔法, javac 根据 Lambda
表达式上下文信息就能推断出参数的正确类型。程序依然要经过类型检查来保证运行的安全性,但不用再显式声明类型罢了。这就是所谓的类型推断。【摘】
3 流——我最喜欢,不仅使处理易读,更提升了速度(并行)
流使程序员得以站在更高的抽象层次上对集合进行操作。【摘】
3.1 从外部迭代到内部迭代
传统的迭代方式都是为循环操作,每次迭代集合类时,都需要写很多样板代码。将
for 循环改造成并行方式运行也很麻烦,需要修改每个 for
循环才能实现。
外部迭代Iterator
:然而,外部迭代也有问题。首先,它很难抽象出本章稍后提及的不同操作;此外,它从本质上来讲是一种串行化操作。总体来看,使用 for 循环会将行为和方法混为一谈。
另一种方法就是内部迭代。 stream()
方法的调用,它和 iterator()
的作用一样。该方法不是返回一个控制迭代的 Iterator
对象,而是返回内部迭代中的相应接口: Stream
。
首先对比一下for、iterator、stream
对User
的操作:
1 | List<User> userList = new ArrayList<>(); |
Stream 是用函数式编程方式在集合类上进行复杂操作的工具。【摘】
3.2 实现机制
filter
、count
两种操作是否意味着需要两次循环?事实上,类库设计精妙,只需对艺术家列表迭代一次。count
这样最终会从 Stream 产生值的方法叫作及早求值方法。Stream
最终都会有终止操作;
3.3 常用的流操作
下面所有的userList
为: List<User> userList = Arrays.asList(new User("Jack"), new User("Jon"));
3.3.1 collect(toList())
collect(Collectors.toList())
方法由 Stream 里的值生成一个列表,是一个及早求值操作。
1 | List<String> collected = Stream.of("a", "b", "c").collect(Collectors.toList()); |
3.3.2 map,可以接受Function参数
如果有一个函数可以将一种类型的值转换成另外一种类型, map 操作就可以使用该函数,将一个流中的值转换成一个新的流。
1 | userList.stream().map(user -> user.getName().length()).collect(Collectors.toList());//[4, 3] |
3.3.3 filter,可以接受Predicate参数
1 | userList.stream().filter(user -> user.getName().equalsIgnoreCase("Jon")).collect(Collectors.toList());//[StreamTest.User(name=Jon)] |
3.3.4 flatMap
flatMap 方 法 可 用 Stream 替 换 值, 然 后 将 多 个 Stream 连 接 成 一 个 Stream
(如图 3-7 所示)。【摘】
1 | List<Integer> together = Stream.of(asList(1, 2), asList(3, 4)) |
【摘】
3.3.5 max 和 min
1 | userList.stream().min(Comparator.comparing(user -> user.getName().length())).get();//StreamTest.User(name=Jon) |
max``min
返回的是Optional
,也是一个新特性,可以取代三元运算符。
3.3.7 reduce
reduce
操作可以实现从一组值中生成一个值。
1 | Stream.of(1, 2, 3).reduce(0, (acc, element) -> acc + element);//6 |
3.3.8 整合操作
1 | Stream.of(1, 2, 3).map(item -> item * item).filter(item -> item > 4).collect(Collectors.toList());//[9] |
3.6 高阶函数
高阶函数是指接受另外一个函
数作为参数,或返回一个函数的函数。高阶函数不难辨认:看函数签名就够了。如果函数的参数列表里包含函数接口,或该函数返回一个函数接口,那么该函数就是高阶函数。【摘】
3.10 进阶练习
- 只用 reduce 和 Lambda 表达式写出实现 Stream 上的 map 操作的代码,如果不想返回
Stream ,可以返回一个 List 。
1 | // reduce实现map |
- 只用 reduce 和 Lambda 表达式写出实现 Stream 上的 filter 操作的代码,如果不想返回
Stream ,可以返回一个 List 。
1 | // reduce实现filter |
上面来自这里
4 类库
有点复杂,建议直接看书,总结不出来。
4.10 Optional
上面提到过,Optional
是为核心类库新设计的一个数据类型,用来替换 null 值。
5 高级集合类和收集器
5.1 方法引用
例如下面两种语法结果相同。
1 | user->user.getName() |
例如创建对象
1 | User::new |
更复杂的还是看书。
5.2 元素顺序
另外一个尚未提及的关于集合类的内容是流中的元素以何种顺序排列。读者可能知道,一些集合类型中的元素是按顺序排列的,比如 List ;而另一些则是无序的,比如 HashSet 。
增加了流操作后,顺序问题变得更加复杂。
直观上看,流是有序的,因为流中的元素都是按顺序处理的。这种顺序称为出现顺序。出现顺序的定义依赖于数据源和对流的操作。
在一个有序集合中创建一个流时,流中的元素就按出现顺序排列,因此,List集合代码总是可以通过。【摘】
如果集合本身就是无序的,由此生成的流也是无序的。 HashSet 就是一种无序的集合,因此不能保证程序每次都通过。【摘】
这 会 带 来 一 些 意 想 不 到 的 结 果, 比 如 使 用 并 行 流 时, forEach 方 法 不 能 保 证 元 素 是按顺序处理的(第 6 章会详细讨论这些内容)
。如果需要保证按顺序处理,应该使用forEachOrdered 方法,它是你的朋友。【摘】
5.3 使用收集器
前面我们使用过 collect(toList()) ,在流中生成列表。显然, List 是能想到的从流中生成的最自然的数据结构,但是有时人们还希望从流生成其他值,比如 Map 或 Set ,或者你希望定制一个类将你想要的东西抽象出来。
5.3.1 转换成其他集合
list转Map
1 | System.out.println(Stream.of(new User("jdck")).collect(Collectors.toMap(User::getName,Function.identity()))); |
5.3.3 数据分块
通过一个例子,很好理解
1 | System.out.println(Stream.of(new User("jdck")).collect(partitioningBy(t->t.getName().equalsIgnoreCase("jdck")))); |
5.3.4 数据分组
分组是分块的子集(可能想等),下面有个例子:
1 | System.out.println(Stream.of(new User("jdck")).collect(groupingBy(t->t.getName().equalsIgnoreCase("jdck")))); |
读者可能知道 SQL 中的 group by 操作,我们的方法是和这类似的一个概念,只不过在 Stream 类库中实现了而已。
5.3.5 字符串
字符串的joining方法如下:
1 | System.out.println(userList.stream().map(User::getName).collect(Collectors.joining(",","[","]")));//[Jack,Jon] |
这里使用 map 操作提取出艺术家的姓名,然后使用 Collectors.joining 收集流中的值,该方法可以方便地从一个流得到一个字符串,允许用户提供分隔符(用以分隔元素)、前缀和后缀。
下面这个结果是报错的:
1 | List<Integer> a=Arrays.asList(1,2,3); |
调试之后发现,Arrays.asList()
返回类型是Array$ArrayList@
而new ArrayList()<>
返回类型是ArrayList@
。
6 数据并行化——高潮来了
6.1 并行和并发
并发和并行不是一个概念!
并发是两个任务共享时间段,并行则是两个任务在同一时间发生,比如运行在多核 CPU上。
并行化是指为缩短任务执行时间,将一个任务分解成几部分,然后并行执行。这和顺序执行的任务量是一样的,区别就像用更多的马来拉车,花费的时间自然减少了。实际上,和顺序执行相比,并行化执行任务时,CPU 承载的工作量更大。【摘】(简直废话)
6.2 为什么并行化如此重要
硬件越来越给力。
6.3 并行化流操作
下面两个操作都可以实现并行:
1 | userList.stream().parallel().map(User::getName).collect(Collectors.toList()); |
并不是并行速度就快,要看运行时的环境。在一个四核电脑上,如果有 10 张专辑,串行化代码的速度是并行化代码速度的 8 倍;如果将专辑数量增至 100 张,串行化和并行化速度相当;如果将专辑数量增值 10 000
张,则并行化代码的速度是串行化代码速度的 2.5 倍。
6.4 模拟系统
pass:书上说的很详细,这里只是把我不了解的拿出来。
1 | // 使用蒙特卡洛模拟法并行化模拟掷骰子事件 |
上面的完全看不懂的样子,查一下API。IntStream.range(0, 100)
,是生成[0-100)的区间为1的stream
的流。
6.6 性能
在前面我简要提及了影响并行流是否比串行流快的一些因素,现在让我们仔细看看它们。理解哪些能工作、哪些不能工作,能帮助在如何使用、什么时候使用并行流这一问题上做出明智的决策。影响并行流性能的主要因素有 5 个,依次分析如下。【摘】
• 数据大小
输入数据的大小会影响并行化处理对性能的提升。将问题分解之后并行化处理,再将结果合并会带来额外的开销。因此只有数据足够大、每个数据处理管道花费的时间足够多时,并行化处理才有意义。6.3 节讨论过。
• 源数据结构
每个管道的操作都基于一些初始数据源,通常是集合。将不同的数据源分割相对容易,这里的开销影响了在管道中并行处理数据时到底能带来多少性能上的提升。
• 装箱
处理基本类型比处理装箱类型要快。
• 核的数量
极端情况下,只有一个核,因此完全没必要并行化。显然,拥有的核越多,获得潜在性能提升的幅度就越大。在实践中,核的数量不单指你的机器上有多少核,更是指运行时你的机器能使用多少核。这也就是说同时运行的其他进程,或者线程关联性(强制线程在某些核或
CPU 上运行)会影响性能。
• 单元处理开销
比如数据大小,这是一场并行执行花费时间和分解合并操作开销之间的战争。花在流中每个元素身上的时间越长,并行操作带来的性能提升越明显。【摘】
我们可以根据性能的好坏,将核心类库提供的通用数据结构分成以下 3 组。
• 性能好
ArrayList 、数组或 IntStream.range ,这些数据结构支持随机读取,也就是说它们能轻而易举地被任意分解。
• 性能一般
HashSet 、 TreeSet ,这些数据结构不易公平地被分解,但是大多数时候分解是可能的。
• 性能差
有些数据结构难于分解,比如,可能要花 O(N) 的时间复杂度来分解问题。其中包括LinkedList ,对半分解太难了。还有 Streams.iterate 和 BufferedReader.lines
,它们长度未知,因此很难预测该在哪里分解。【摘】
7 测试、调试和重构
7.1 重构候选项
使用 Lambda 表达式重构代码有个时髦的称呼: Lambda 化(读作 lambda-fi-cation ,执行重构的程序员叫作 lamb-di-fiers 或者有责任心的程序员)。Java 8
中的核心类库就曾经历过这样一场重构。在选择内部设计模型时,想想以何种形式向外展示 API 是大有裨益的。【摘】
7.1.1 进进出出 、 摇摇晃晃
使用 Lambda 表达式更好地面向对象编程(OOP),面向对象编程的核心之一是封装局部状态.
7.6 Lambda日志和打印消息,解决方案 : peak
使用 peek 方法
1 | Set<String> nationalities |
8 设计和架构的原则
8.1 Lambda 表达式改变了设计模式
以曾经风靡一时的单例模式为例,该模式确保只产生一个对象实例。在过去十年中,人们批评它让程序变得更脆弱,且难于测试。敏捷开发的流行,让测试显得更加重要,单例模式的这个问题把它变成了一个反模式:一种应该避免使用的模式。
8.1.2 策略模式
这里实现了压缩方式的选择gzip、zip(来源书中)
定义压缩数据的策略接口
1 | public interface CompressionStrategy { |
使用 gzip 算法压缩数据
1 | public class GzipCompressionStrategy implements CompressionStrategy { |
使用 zip 算法压缩数据
1 | public class ZipCompressionStrategy implements CompressionStrategy { |
在构造类时提供压缩策略
1 | public class Compressor { |
如果使用这种传统的策略模式实现方式,可以编写客户代码创建一个新的 Compressor
,并
且使用任何我们想要的策略(如例 8-13 所示)。
例 8-13 使用具体的策略类初始化 Compressor
1 | Compressor gzipCompressor = new Compressor(new GzipCompressionStrategy()); |
和前面讨论的命令者模式一样,使用 Lambda
表达式或者方法引用可以去掉样板代码。在
这里,我们可以去掉具体的策略实现,使用一个方法实现算法,这里的算法由构造函数
中对应的 OutputStream
实现。使用这种方式,可以完全舍弃 GzipCompressionStrategy
和ZipCompressionStrategy
类。例 8-14 展示了使用方法引用后的代码。
例 8-14 使用方法引用初始化 Compressor
1 | Compressor gzipCompressor = new Compressor(GZIPOutputStream::new); |
本文地址 读《Java 8 函数式编程》