前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Java 8 Lambda函数编程【面试+工作】

Java 8 Lambda函数编程【面试+工作】

作者头像
Java帮帮
发布2018-07-27 15:00:21
1.1K0
发布2018-07-27 15:00:21
举报

Java 8 Lambda函数编程【面试+工作】

Java 8 函数式编程电子书下载

链接:https://pan.baidu.com/s/1q_S2URG8mWKI1nTvPVvDzg 密码:2als


什么是函数式编程

每个人对函数式编程的理解不尽相同。但其核心是:在思考问题时,使用不可变值和函 数,函数对一个值进行处理,映射成另一个值。

背景

Java是一门面向对象编程语言。面向对象编程语言和函数式编程语言中的基本元素(Basic Values)都可以动态封装程序行为:面向对象编程语言使用带有方法的对象封装行为,函数式编程语言使用函数封装行为。但这个相同点并不明显,因为Java的对象往往比较“重量级”:实例化一个类型往往会涉及不同的类,并需要初始化类里的字段和方法。

随着回调模式和函数式编程风格的日益流行,我们需要在Java中提供一种尽可能轻量级的将代码封装为数据(Model code as data)的方法。匿名内部类并不是一个好的选择,因为:

  • 语法过于冗余
  • 匿名类中的this和变量名容易使人产生误解
  • 类型载入和实例创建语义不够灵活
  • 无法捕获非final的局部变量
  • 无法对控制流进行抽象

Lambda表达式基本知识

辨别Lambda表达式

Lambda 表达式不包含参数,使用空括号()表示没有参数。 Runnable noArguments = () -> System.out.println("Hello World");

Lambda 表达式包含且只包含一个参数,可省略参数的括号。 ActionListener oneArgument = event -> System.out.println("button clicked");

Lambda 表达式的主体不仅可以是一个表达式,而且也可以是一段代码块,使用大括号 {}将代码块括起来。

Lambda 表达式也可以表示包含多个参数的方法。 BinaryOperator<Long> add = (x, y) -> x + y;

Lambda 变量类型如何识别

在Lambda表达式中无需指定类型,程序依然可以编译。这是因为 javac 根据程序的上下文(方法的签名)在后台推断出了参数的类型。这意味着如果参数类型不言而明,则无需显式指定。即Lambda 表达式的类型依赖于上下文环境,是由编译器推断出来的。

Lambda 引用值,而不是变量

如果你曾使用过匿名内部类,也许遇到过这样的情况:需要引用它所在方法里的变量。这时,需要将变量声明为final。将变量声明为final,意味着不能为其重复赋值。同时也意味着在使用final变量时,实际上是在使用赋给该变量的一个特定的值。

Java 8虽然放松了这一限制,可以引用非final变量,但是该变量在既成事实上必须是 final。虽然无需将变量声明为final,但在Lambda表达式中,也无法用作非终态变量。如果坚持用作非终态变量,编译器就会报错。(local variables referenced from a lambda expression must be final or effectively final.)

既成事实上的 final 是指只能给该变量赋值一次。换句话说,Lambda 表达式引用的是值, 而不是变量。

Lambda用在哪里

我们知道Lambda表达式的目标类型是函数性接口——每一个Lambda都能通过一个特定的函数式接口与一个给定的类型进行匹配。因此一个Lambda表达式能被应用在与其目标类型匹配的任何地方,lambda表达式必须和函数式接口的抽象函数描述一样的参数类型,它的返回类型也必须和抽象函数的返回类型兼容,并且他能抛出的异常也仅限于在函数的描述范围中。

函数接口

使用只有一个方法的接口来表示某特定方法并反复使用,这种接口称为函数接口。

Java中重要的函数接口:

接口

参数

返回类型

Predicate

T

boolean

Consumer

T

void

Function

T

R

Supplier

None

T

UnaryOperator

T

T

BinaryOperator

(T,T)

T

Java 8中新增的对核心类库的改进主要包括集合类的 API 和新引入的流 (Stream)。

从外部迭代到内部迭代

两者的差异如图:

实现机制

通常,在 Java 中调用一个方法,计算机会随即执行操作:比如System.out.println ("Hello World");会在终端上输出一条信息。Stream里的一些方法却略有不同,它们虽是 普通的 Java 方法,但返回的 Stream 对象却不是一个新集合,而是创建新集合的配方。

像 filter 这样只描述 Stream,最终不产生新集合的方法叫作惰性求值方法(lazy);而像 count 这样 最终会从 Stream 产生值的方法叫作及早求值方法(eager)。

判断一个操作是惰性求值还是及早求值很简单:只需看它的返回值。如果返回值是 Stream, 那么是惰性求值;如果返回值是另一个值或为空,那么就是及早求值。使用这些操作的理想方式就是形成一个惰性求值的链,最后用一个及早求值的操作返回想要的结果,这正是 它的合理之处。

常用的流操作

collect(toList())

collect(toList()) 方法由 Stream 里的值生成一个列表,是一个及早求值操作。

map

如果有一个函数可以将一种类型的值转换成另外一种类型,map 操作就可以 使用该函数,将一个流中的值转换成一个新的流。

filter

遍历数据并检查其中的元素时,可尝试使用 Stream 中提供的新方法 filter。

由于此方法和 if 条件语句的功能相同,因此其返回值肯定是 true 或者 false。经过过滤, Stream 中符合条件的,即 Lambda 表达式值为 true 的元素被保留下来。该 Lambda 表达式 的函数接口正是前面章节中介绍过的 Predicate

flatMap

flatMap 方法可用 Stream 替换值,然后将多个 Stream 连接成一个 Stream。

前面已介绍过 map 操作,它可用一个新的值代替 Stream 中的值。但有时,用户希望让 map 操作有点变化,生成一个新的 Stream 对象取而代之。用户通常不希望结果是一连串的流, 此时 flatMap 最能派上用场。

max和min

Stream 上常用的操作之一是求最大值和最小值。Stream API 中的 max 和 min 操作就是解决这一问题的。

查找 Stream 中的最大或最小元素,首先要考虑的是用什么作为排序的指标。

reduce

reduce 操作可以实现从一组值中生成一个值。

Lambda 表达式的返回值是最新的 acc,是上一轮 acc 的值和当前元素相加的结果。reducer 的类型是前面已介绍过的 BinaryOperator。

类库

Lambda 表达式,接下来将详细阐述另一个重要方面:如何使用 Lambda 表达式。即使不需要编写像 Stream 这样重度使用函数式编程风格的类库,学会如何使用 Lambda 表达式也是非常重要的。即使一个最简单的应用,也可能会因为代码即数据的函数式编程风格而受益。

在代码中使用Lambda表达式

从调用Lambda 表达式的代码的角度来看,它和调用一个普通接口方法没什么区别。例子:

传统的写法:

Lambda表达式的写法:

基本类型

在 Java 中,有一些相伴的类型,比如 int 和 Integer —— 前者是基本类型,后者是装箱类型。基本类型内建在语言和运行环境中,是基本的程序构建模块;而装箱类型属于普通的 Java 类,只不过是对基本类型的一种封装。

将基本类型转换为装箱类型,称为装箱,反之则称为拆箱,两者都需要额外的计算开销。 对于需要大量数值运算的算法来说,装箱和拆箱的计算开销,以及装箱类型占用的额外内存,会明显减缓程序的运行速度。

为了减小这些性能开销,Stream 类的某些方法对基本类型和装箱类型做了区分。下图所示的高阶函数LongFunction和其他类似函数即为该方面的一个尝试。在Java 8中,仅对整型、 长整型和双浮点型做了特殊处理,因为它们在数值计算中用得最多,特殊处理后的系统性能提升效果最明显。

对基本类型做特殊处理的方法在命名上有明确的规范。

  1. 如果方法返回类型为基本类型,则在基本类型前加To,如上图中的 ToLongFunction
  2. 如果参数是基本类型,则不加前缀只需类型名即可,如下图中的 LongFunction
  3. 如果高阶函数使用基本类型,则在操作后加后缀 To 再加基本类型,如 mapToLong

这些基本类型都有与之对应的 Stream,以基本类型名为前缀,如 LongStream。事实上, mapToLong 方法返回的不是一个一般的 Stream,而是一个特殊处理的 Stream。在这个特 殊的 Stream 中,map 方法的实现方式也不同,它接受一个 LongUnaryOperator 函数,将 一个长整型值映射成另一个长整型值,如下图所示。通过一些高阶函数装箱方法,如 mapToObj,也可以从一个基本类型的 Stream 得到一个装箱后的 Stream,如 Stream<Long>

重载解析

在 Java 中可以重载方法,造成多个方法有相同的方法名,但签名确不一样。这在推断参数 类型时会带来问题,因为系统可能会推断出多种类型。这时,javac 会挑出最具体的类型。

例:

两个重载方法可供选择

overloadedMethod("abc");方法调用在选择定义的重载方法时,输出 String,而不是 Object。

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

  • 如果只有一个可能的目标类型,由相应函数接口里的参数类型推导得出;
  • 如果有多个可能的目标类型,由最具体的类型推导得出;
  • 如果有多个可能的目标类型且最具体的类型不明确,则需人为指定类型。

@FunctionalInterface

每个用作函数接口的接口都应该添加这个注释。

这究竟是什么意思呢? Java 中有一些接口,虽然只含一个方法,但并不是为了使用 Lambda 表达式来实现的。比如,有些对象内部可能保存着某种状态,使用带有一个方法的接口可能纯属巧合。java.lang.Comparablejava.io.Closeable 就属于这样的情况。

如果一个类是可比较的,就意味着在该类的实例之间存在某种顺序,比如字符串中的字母顺序。人们通常不会认为函数是可比较的,如果一个东西既没有属性也没有状态,拿什么比较呢?

一个可关闭的对象必须持有某种打开的资源,比如一个需要关闭的文件句柄。同样,该接口也不能是一个纯函数,因为关闭资源是更改状态的另一种形式。

和 Closeable 和 Comparable 接口不同,为了提高 Stream 对象可操作性而引入的各种新接 口,都需要有 Lambda 表达式可以实现它。它们存在的意义在于将代码块作为数据打包起 来。因此,它们都添加了 @FunctionalInterface 注释。

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

二进制接口的兼容性

Java 8中对API最大的改变在于集合类。虽然Java在持续演进,但它一直在保持着向后二进制兼容。具体来说,使用Java 1到Java 7编译的类库或应用,可以直接在 Java 8 上运行。

事实上,修改了像集合类这样的核心类库之后,这一保证也很难实现。我们可以用具体的例子作为思考练习。Java 8中为Collection接口增加了stream方法,这意味着所有实现了 Collection 接口的类都必须增加这个新方法。对核心类库里的类来说,实现这个新方法 (比如为 ArrayList 增加新的 stream 方法)就能就能使问题迎刃而解。

缺憾在于,这个修改依然打破了二进制兼容性,在 JDK 之外实现 Collection 接口的类, 例如MyCustomList,也仍然需要实现新增的stream方法。这个MyCustomList在Java 8中 无法通过编译,即使已有一个编译好的版本,在 JVM 加载 MyCustomList 类时,类加载器 仍然会引发异常。 这是所有使用第三方集合类库的梦魇,要避免这个糟糕情况,则需要在Java 8中添加新的语言特性:默认方法

默认方法

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

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

完整的继承体系图:

简言之,类中重写的方法胜出。这样的设计主要是由增加默认方法的目的决定的,增加默认方法主要是为了在接口上向后兼容。让类中重写方法的优先级高于默认方法能简化很多继承问题。

多重继承

接口允许多重继承,因此有可能碰到两个接口包含签名相同的默认方法的情况。

三定律 如果对默认方法的工作原理,特别是在多重继承下的行为还没有把握,如下三条简单的定律可以帮助大家。

  • 类胜于接口。如果在继承链中有方法体或抽象的方法声明,那么就可以忽略接口中定义 的方法。
  • 子类胜于父类。如果一个接口继承了另一个接口,且两个接口都定义了一个默认方法, 那么子类中定义的方法胜出。
  • 没有规则三。如果上面两条规则不适用,子类要么需要实现该方法,要么将该方法声明 为抽象方法。

其中第一条规则是为了让代码向后兼容。

接口的静态方法

Stream 是个接口, Stream.of是接口的静态方法。这也是Java 8中添加的一个新的语言特性,旨在帮助编写类库的开发人员,但对于日常应用程序的开发人员也同样适用。

Optional

reduce 方法的一个重点尚未提及:reduce 方法有两种形式,一种如前面出现的需要有一个初始值,另一种变式则不需要有初始值。没有初始值的情况下,reduce 的第一步使用 Stream 中的前两个元素。有时,reduce 操作不存在有意义的初始值,这样做就是有意义 的,此时,reduce 方法返回一个 Optional 对象。

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

人们常常使用 null 值表示值不存在,Optional 对象能更好地表达这个概念。使用 null 代表值不存在的最大问题在于 NullPointerException。一旦引用一个存储 null 值的变量,程序会立即崩溃。使用 Optional 对象有两个目的:首先,Optional 对象鼓励程序员适时检查变量是否为空,以避免代码缺陷;其次,它将一个类的 API 中可能为空的值文档化,这比 阅读实现代码要简单很多。

使用工厂方法 of,可以从某个值创建出一个 Optional 对象。Optional 对象相当于值的容器,而该值可以 通过 get 方法提取。例如创建某个值的 Optional 对象:

Optional 对象也可能为空,因此还有一个对应的工厂方法 empty,另外一个工厂方法 ofNullable 则可将一个空值转换成 Optional 对象。第三个方法 isPresent 的用法(该方法表示一个 Optional 对象里是否有值)。例如创建一个空的 Optional 对象,并检查其是否有值:

使用 Optional 对象的方式之一是在调用 get() 方法前,先使用 isPresent 检查 Optional 对象是否有值。使用 orElse 方法则更简洁,当 Optional 对象为空时,该方法提供了一个 备选值。如果计算备选值在计算上太过繁琐,即可使用 orElseGet 方法。该方法接受一个 Supplier 对象,只有在 Optional 对象真正为空时才会调用。例如使用 orElse 和 orElseGet 方法:

高级集合类和收集器

Java 8对集合类的改进不止第一讲的那些。还有一些高级主题,包括新引入的 Collector 类、方法引用等。

方法引用

Lambda 表达式有一个常见的用法:Lambda 表达式经常调用参数。比如想得到艺术家的姓名,Lambda 的表达式如下:

artist -> artist.getName()

这种用法如此普遍,因此Java 8为其提供了一个简写语法,叫作方法引用,帮助程序员重用已有方法。用方法引用重写上面的 Lambda 表达式,代码如下:

Artist::getName

标准语法为 Classname::methodName。需要注意的是,虽然这是一个方法,但不需要在后面加括号,因为这里并不调用该方法。我们只是提供了和 Lambda 表达式等价的一种结构, 在需要时才会调用。凡是使用 Lambda 表达式的地方,就可以使用方法引用。

构造函数也有同样的缩写形式,如果你想使用 Lambda 表达式创建一个 Artist 对象,可能 会写出如下代码:

(name, nationality) -> new Artist(name, nationality)

使用方法引用,上述代码可写为:

Artist::new

这段代码不仅比原来的代码短,而且更易阅读。Artist::new 立刻告诉程序员这是在创建 一个 Artist 对象,程序员无需看完整行代码就能弄明白代码的意图。另一个要注意的地方 是方法引用自动支持多个参数,前提是选对了正确的函数接口。

还可以用这种方式创建数组,下面的代码创建了一个字符串型的数组:

String[]::new

元素顺序

流中的元素以何种顺序排列。一些集合类型中的元素是按顺序排列的,比如 List;而另一些则是无序的,比如 HashSet。 增加了流操作后,顺序问题变得更加复杂。

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

在一个有序集合中创建一个流时,流中的元素就按出现顺序排列,例如顺序测试永远通过:

如果集合本身就是无序的,由此生成的流也是无序的。例如顺序测试不能保证每次通过:

流的目的不仅是在集合类之间做转换,而且同时提供了一组处理数据的通用操作。有些集合本身是无序的,但这些操作有时会产生顺序。例如生成出现顺序:

一些中间操作会产生顺序,比如对值做映射时,映射后的值是有序的,这种顺序就会保留下来。如果进来的流是无序的,出去的流也是无序的。看一下例所示代码,我们只能断言 HashSet 中含有某元素,但对其顺序不能作出任何假设,因为 HashSet 是无序的,使用了映射操作后,得到的集合仍然是无序的。

一些操作在有序的流上开销更大,调用 unordered 方法消除这种顺序就能解决该问题。大 多数操作都是在有序流上效率更高,比如 filter、map 和 reduce 等。

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

使用收集器

一种通用的、从流生成复杂值的结构就是收集器。

转换成其他集合

有一些收集器可以生成其他集合。比如前面已经见过的 toList,生成了 java.util.List 类的实例。还有 toSet 和 toCollection,分别生成 Set 和 Collection 类的实例。

通常情况下,创建集合时需要调用适当的构造函数指明集合的具体类型:

但是调用 toList 或者 toSet 方法时,不需要指定具体的类型。Stream 类库在背后自动为你 挑选出了合适的类型。本书后面会讲述如何使用 Stream 类库并行处理数据,收集并行操作 的结果需要的 Set,和对线程安全没有要求的 Set 类是完全不同的。

可能还会有这样的情况,你希望使用一个特定的集合收集值,而且你可以稍后指定该集合 的类型。比如,你可能希望使用 TreeSet,而不是由框架在背后自动为你指定一种类型的 Set。此时就可以使用 toCollection,它接受一个函数作为参数,来创建集合。例如使用 toCollection,用定制的集合收集元素:

转换成值

还可以利用收集器让流生成一个值。maxBy 和 minBy 允许用户按某种特定的顺序生成一个 值。averagingInt 方法接受一个 Lambda 表达式作参数,将流中的元素转换成一个整数,然后再计算平均数。例如找出一组专辑上曲目的平均数:

数据分组

数据分组是一种更自然的分割数据操作,与将数据分成 ture 和 false 两部分不同,可以使 用任意值对数据分组。比如现在有一个由专辑组成的流,可以按专辑当中的主唱对专辑分组。例如使用主唱对专辑分组:

groupingBy 收集器(如下图所示)接受一个分类函数,用来对数据分组,就像 partitioningBy 一样,接受一个 Predicate 对象将数据分成 ture 和 false 两部分。

字符串

很多时候,收集流中的数据都是为了在最后生成一个字符串。

旧的方法:使用 for 循环格式化艺术家姓名

新的方法:使用流和收集器格式化艺术家姓名

上面的输出都为:"[George Harrison, John Lennon, Paul McCartney, Ringo Starr, The Beatles]"

组合收集器

虽然现在看到的各种收集器已经很强大了,但如果将它们组合起来,会变得更强大。例如使用收集器计算每个艺术家的专辑数:

上面用到的counting是第二个收集器,用以收集最终结果的一个子集。这些收集器叫作下游收集器。收集器是生成最终结果的一剂配方,下游收集器则是生成部分结果的配 方,主收集器中会用到下游收集器。这种组合使用收集器的方式,使得它们在 Stream 类库 中的作用更加强大。

重构和定制收集器 and 对收集器的归一化处理

例如reducing 是一种定制收集器的简便方式:

一些细节

Lambda 表达式的引入也推动了一些新方法被加入集合类。假设使用Map<String, Artist> artistCache定义缓存。

1.Java 8引入了一个新方法computeIfAbsent,该方法接受一个Lambda表达式,值不存在时 使用该 Lambda 表达式计算新值。例如使用 computeIfAbsent 缓存:

你可能还希望在值不存在时不计算,为 Map 接口新增的 compute 和 computeIfAbsent 就能处理这些情况。

2.在 Map 上迭代。例如使用内部迭代遍历 Map 里的值:

数据并行化

在Java 8中,编写并行化的程序很容易。这都多亏了前面介绍的 Lambda 表达式和流,我们完全不必理会串行或并行,只要告诉程序该做什么就行了。这听起来和长久以来使用 Java 编程的方式并无区别,但告诉计算机做什么和怎么做是完全不同的。因此本文主要内容并不在于如何更改代码,而是讲述为什么需要并行化和什么时候会带来性能的提升。

并行和并发

并发是两个任务共享时间段,并行则是两个任务在同一时间发生,比如运行在多核 CPU 上。如果一个程序要运行两个任务,并且只有一个 CPU 给它们分配了不同的时间片,那 么这就是并发,而不是并行。两者之间的区别如图所示。

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

本章会讨论一种特殊形式的并行化:数据并行化。数据并行化是指将数据分成块,为每块 数据分配单独的处理单元。还是拿马拉车那个例子打比方,就像从车里取出一些货物,放 到另一辆车上,两辆马车都沿着同样的路径到达目的地。 当需要在大量数据上执行同样的操作时,数据并行化很管用。它将问题分解为可在多块数 据上求解的形式,然后对每块数据执行运算,最后将各数据块上得到的结果汇总,从而获得最终答案。

并行化流操作

并行化操作流只需改变一个方法调用。如果已经有一个 Stream 对象,调用它的 parallel 方法就能让其拥有并行操作的能力。如果想从一个集合类创建一个流,调用 parallelStream 就能立即获得一个拥有并行能力的流。串行化代码的速度不一定比并行化代码的速度慢,后面在进行分析。

性能

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

1.数据大小

输入数据的大小会影响并行化处理对性能的提升。将问题分解之后并行化处理,再将结 果合并会带来额外的开销。因此只有数据足够大、每个数据处理管道花费的时间足够多 时,并行化处理才有意义。

2.源数据结构

每个管道的操作都基于一些初始数据源,通常是集合。将不同的数据源分割相对容易, 这里的开销影响了在管道中并行处理数据时到底能带来多少性能上的提升。

3.装箱

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

4.核的数量

极端情况下,只有一个核,因此完全没必要并行化。显然,拥有的核越多,获得潜在性 能提升的幅度就越大。在实践中,核的数量不单指你的机器上有多少核,更是指运行时 你的机器能使用多少核。这也就是说同时运行的其他进程,或者线程关联性(强制线程在某些核或 CPU 上运行)会影响性能。

5.单元处理开销

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

我们可以根据性能的好坏,将核心类库提供的通用数据结构分成以下 3 组。

1.性能好

ArrayList、数组或 IntStream.range,这些数据结构支持随机读取,也就是说它们能轻 而易举地被任意分解。

2.性能一般

HashSet、TreeSet,这些数据结构不易公平地被分解,但是大多数时候分解是可能的。

3.性能差

有些数据结构难于分解,比如,可能要花 O(N) 的时间复杂度来分解问题。其中包括 LinkedList,对半分解太难了。还有 Streams.iterate 和 BufferedReader.lines,它们长度未知,因此很难预测该在哪里分解。

初始的数据结构影响巨大。举一个极端的例子,比较对 10000 个整数并行求和,使用 ArrayList 要比使用 LinkedList 快 10 倍。这不是说业务逻辑的性能情况也会如此,只是说明了数据结构 对于性能的影响之大。使用形如 LinkedList 这样难于分解的数据结构并行运行可能更慢。

理想情况下,一旦流框架将问题分解成小块,就可以在每个线程里单独处理每一小块,线程之间不再需要进一步通信。无奈现实不总遂人愿!

在讨论流中单独操作每一块的种类时,可以分成两种不同的操作:无状态的和有状态的。 无状态操作整个过程中不必维护状态,有状态操作则有维护状态所需的开销和限制。

如果能避开有状态,选用无状态操作,就能获得更好的并行性能。无状态操作包括 map、 filter 和 flatMap,有状态操作包括 sorted、distinct 和 limit。

并行化数组操作

Java 8还引入了一些针对数组的并行操作,脱离流框架也可以使用Lambda表达式。像流框架上的操作一样,这些操作也都是针对数据的并行化操作。

这些操作都在工具类 Arrays 中,该类还包括 Java 以前版本中提供的和数组相关的有用方 法,下表总结了新增的并行化操作。

数组上的并行化操作:

方法名

操作

parallelPrefix

任意给定一个函数,计算数组的和

parallelSetAll

使用 Lambda 表达式更新数组元素

parallelSort

并行化对数组元素排序

测试、调试和重构

重构、测试驱动开发(TDD)和持续集成(CI)越来越流行,如果我们需要将 Lambda 表达式应用于日常编程工作中,就得学会如何为它编写单元测试。

重构候选项

使用 Lambda 表达式重构代码有个时髦的称呼:Lambda 化(读作 lambda-fi-cation,执行重构的程序员叫作lamb-di-fiers或者有责任心的程序员)。

  • 进进出出、摇摇晃晃(In, Out, In, Out, Shake It All About)
  • 孤独的覆盖(The Lonely Override)
  • 同样的东西写两遍(Behavioral Write Everything Twice)

Lambda表达式的单元测试

通常,在编写单元测试时,怎么在应用中调用该方法,就怎么在测试中调用。给定一些输入或测试替身,调用这些方法,然后验证结果是否和预期的行为一致。

Lambda 表达式给单元测试带来了一些麻烦,Lambda 表达式没有名字,无法直接在测试代码中调用。

你可以在测试代码中复制 Lambda 表达式来测试,但这种方式的副作用是测试的不是真正的实现。假设你修改了实现代码,测试仍然通过,而实现可能早已在做另一件事了。

解决该问题有两种方式。第一种是将 Lambda 表达式放入一个方法测试,这种方式要测那个方法,而不是 Lambda 表达式本身。第二种测试的重点放在方法的行为上。

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

编写单元测试的常用方式之一是使用测试替身描述系统中其他模块的期望行为。这种方式 很有用,因为单元测试可以脱离其他模块来测试你的类或方法,测试替身让你能用单元测试来实现这种隔离。【测试替身也常被称为模拟,事实上测试存根和模拟都属于测试替身。区别是模 拟可以验证代码的行为。】

惰性求值和调试

调试时通常会设置断点,单步跟踪程序的每一步。使用流时,调试可能会变得更加复杂, 因为迭代已交由类库控制,而且很多流操作是惰性求值的。

日志和打印消息

假设你要在集合上进行大量操作,你要调试代码,你希望看到每一步操作的结果是什么。 可以在每一步打印出集合中的值,这在流中很难做到,因为一些中间步骤是惰性求值的。

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

在流中间设置断点:为了像调试循环那样一步一步跟踪,可在 peek 方法 中加入断点,这样就能逐个调试流中的元素了。

测试、调试和重构

重构、测试驱动开发(TDD)和持续集成(CI)越来越流行,如果我们需要将 Lambda 表达式应用于日常编程工作中,就得学会如何为它编写单元测试。通过这一章,我们来看看如何使用 Lambda 表达式提高非集合类代码的质量。

重构候选项(Lambda Refactoring Candidates)

下面的一些要点,可以帮助你确定什么时候应该 Lambda 化(lambdafication)自己的应用或类库。其中的每一条都可看作一个局部的反模式或代码异味,它们可以借助于 Lambda 化进行修复。

进进出出、摇摇晃晃(In, Out, In, Out, Shake It All About)

在程序中记录日志,例:

这段代码先调用 isDebugEnabled 方法抽取布尔值,用来检查是否启用调试级别,如果启用,则调用 Logger 对象的相应方法记录日志。如果你发现自己的代码不断地查询和操作某对象,目的只为了在最后给该对象设个值,那么这段代码就本该属于你所操作的对象。

这种反模式通过传入代码即数据的方式很容易解决。与其查询并设置一个对象的值,不如传入一个 Lambda 表达式,该表达式按照计算得出的值执行相应的行为。当程序处于调试级别,并且检查是否使用 Lambda 表达式的逻辑被封装在 Logger 对象中时,才会调用 Lambda 表达式。例:

上述记录日志的例子也展示了如何使用 Lambda 表达式更好地面向对象编程(OOP),面向对象编程的核心之一是封装局部状态,比如日志的级别。通常这点做得不是很好, isDebugEnabled 方法暴露了内部状态。如果使用 Lambda 表达式,外面的代码根本不需要检查日志级别。

孤独的覆盖(The Lonely Override)

这个代码异味是使用继承,其目的只是为了覆盖一个方法。例如:

在Java8中,可以为工厂方法withInitial传入一个Supplier对象的实例来创建对象。例如:

第二个例子优于第一个的原因:

  1. 任何已有的 Supplier 实例不需要重新封装,就可以在此使用,这鼓励了重用和组合。
  2. 代码更加清晰。
  3. JVM 会少加载一个类。
同样的东西写两遍(Behavioral Write Everything Twice)

不要重复你劳动(Don’t Repeat Yourself,DRY)是一个众所周知的模式,它的反面是同样 的东西写两遍(Write Everything Twice,WET)。

不是所有 WET 的情况都适合 Lambda 化。有时,重复是唯一可以避免系统过紧耦合的方 式。什么时候该将 WET 的代码 Lambda 化?这里有一个信号可以参考。如果有一个整体上大概相似的模式,只是行为上有所不同,就可以试着加入一个 Lambda 表达式。

Lambda表达式的单元测试

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

Lambda 表达式给单元测试带来了一些麻烦,Lambda 表达式没有名字,无法直接在测试代码中调用。

你可以在测试代码中复制 Lambda 表达式来测试,但这种方式的副作用是测试的不是真正的实现。假设你修改了实现代码,测试仍然通过,而实现可能早已在做另一件事了。

解决该问题有两种方式。第一种是将 Lambda 表达式放入一个方法测试,这种方式要测那个方法,而不是 Lambda 表达式本身。第二种是用方法引用,任何 Lambda 表达式都能被改写为普通方法,然后使用方法引用直接引用。

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

编写单元测试的常用方式之一是使用测试替身描述系统中其他模块的期望行为。这种方式 很有用,因为单元测试可以脱离其他模块来测试你的类或方法,测试替身让你能用单元测试来实现这种隔离。

【测试替身也常被称为模拟,事实上测试存根和模拟都属于测试替身。区别是模 拟可以验证代码的行为。比如Java中使用的MokitoPowerMokito等】

惰性求值和调试

调试时通常会设置断点,单步跟踪程序的每一步。使用流时,调试可能会变得更加复杂, 因为迭代已交由类库控制,而且很多流操作是惰性求值的。

日志和打印消息

假设你要在集合上进行大量操作,你要调试代码,你希望看到每一步操作的结果是什么。 可以在每一步打印出集合中的值,这在流中很难做到,因为一些中间步骤是惰性求值的。

解决方案:peak

幸运的是流有一个方法让你能查看每个值,同时能继续操作流。这就是 peek 方法。peek可以输出流中的值,同时避免了重复的流操作。使用 peek 方法还能以同样的方式,将输出定向到现有的日志系统中,比如 log4jjava.util.logging 或者 slf4j

在流中间设置断点

记录日志这是 peek 方法的用途之一。为了像调试循环那样一步一步跟踪,可在 peek 方法中加入断点,这样就能逐个调试流中的元素了。

Java 8 函数式编程电子书下载

链接:https://pan.baidu.com/s/1q_S2URG8mWKI1nTvPVvDzg 密码:2als

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2018-07-11,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Java帮帮 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 什么是函数式编程
  • 背景
  • Lambda表达式基本知识
    • 辨别Lambda表达式
      • Lambda 变量类型如何识别
        • Lambda 引用值,而不是变量
          • Lambda用在哪里
            • 函数接口
              • 从外部迭代到内部迭代
                • 实现机制
                  • 常用的流操作
                    • collect(toList())
                    • map
                    • filter
                    • flatMap
                    • max和min
                    • reduce
                  • 类库
                    • 在代码中使用Lambda表达式
                    • 基本类型
                    • 重载解析
                    • @FunctionalInterface
                    • 二进制接口的兼容性
                    • 默认方法
                    • 多重继承
                    • 接口的静态方法
                    • Optional
                  • 高级集合类和收集器
                    • 方法引用
                    • 元素顺序
                    • 使用收集器
                    • 一些细节
                  • 数据并行化
                    • 并行和并发
                    • 并行化流操作
                    • 性能
                    • 并行化数组操作
                  • 测试、调试和重构
                    • 重构候选项
                    • Lambda表达式的单元测试
                    • 在测试替身时使用Lambda表达式
                    • 惰性求值和调试
                    • 日志和打印消息
                  • 测试、调试和重构
                    • 重构候选项(Lambda Refactoring Candidates)
                    • Lambda表达式的单元测试
                    • 在测试替身时使用Lambda表达式
                    • 惰性求值和调试
                    • 日志和打印消息
                    • 解决方案:peak
                    • 在流中间设置断点
                相关产品与服务
                持续集成
                CODING 持续集成(CODING Continuous Integration,CODING-CI)全面兼容 Jenkins 的持续集成服务,支持 Java、Python、NodeJS 等所有主流语言,并且支持 Docker 镜像的构建。图形化编排,高配集群多 Job 并行构建全面提速您的构建任务。支持主流的 Git 代码仓库,包括 CODING 代码托管、GitHub、GitLab 等。
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档