函数式编程学习
Java设计模式与实践 Kamalmeet Singh, Adrian lanculescu, Lucian-Paul Torrje 著
本文是对原书第5章-函数式编程做了略微梳理,大部分代码从Java9的JShell风格换回main函数,Java9特有的api会做出说明;最后给了一些个人的示例代码。
文章目录
1 简介
发展历史
- 20世纪30年代,数学家Alonzo Church发明lambda演算,成为函数式编程范式的开始,为函数式编程提供了理论基础。
- 1958年,John McCarthy设计了链式编程(LISP/List Programming)语言,LISP是一种函数式编程语言,它的方言(规范)如Common LISP至今仍在被使用。
在函数式编程中(通常缩写为FP/Functional Programming),函数是“一等公民”,这意味着软件由函数组合而成,有别于面向对象编程中那样由对象组合而成。
函数式编程通过以下机制实现Tell don’t ask(只告诉代码如何做,不关心他们的状态)
- 函数组合
- 变量不可变
- 无副作用
- 数据共享
函数式编程的好处如下:
- 代码更加简洁
- 代码更加灵活
- 易于业务人员阅读和维护
- 代码具有更高的信噪比,在函数式编程中,我们致力于编写更少的代码实现相同的效果。通过避免副作用和数据状态改变,仅依靠数据转换,让系统变得更加简单,易于调试和修复。
- 结果具有可预见性,我们知道同样的函数针对相同的入参会有相同的出参,因此可以用于并行计算,在任何其他函数前/后调用,返回值经过计算后可以马上进入缓存,从而提高性能。
函数式编程是一种声明式的编程方式,它更多的关注于应该实现什么。函数式编程范式包含以下观点和原则:
- lambda表达式
- 纯函数
- 引用透明性
- 初等函数
- 高阶函数
- 组合
- 柯里化
- 闭包
- 不可变性
- 函子
- 加强版函子
- 单子
1.1 lambda表达式
lambda表达式的名称来自于lambda演算,其中希腊字母lambda(λ)用于将lambda项绑定到某个函数。lambda项可以是变量、抽象函数、引用。通过以上各项的组合,可以实现表达式的规约或转换。
Java8中的体现就是Lambda允许把函数作为一个方法的参数(函数作为参数传递进方法中),值得一提的是,Java8以前是通过匿名类实现,但是匿名类需要加载太多的生成类,导致性能低下。而Java8中的lambda实现方式是基于Java7中引入的动态调用而非匿名类。
1.2 纯函数
纯函数就是一个没有副作用的函数,它针对相同的输入总产生相同的输出。
1.3 引用透明性
引用透明性是函数针对相同的输入得到相同返回值的一种属性(类似纯函数定义)。针对特定函数,这种属性有利于提高存储效率(将返回结果缓存)和并发性,测试具有此性质的功能函数也很容易。
1.4 初等函数
初等函数是指能够像面向对象编程中操作对象那样操作的函数(可类比JavaScript ES6之前的类定义),即可进行创建、存储、用作参数、返回值等。
1.5 高阶函数
高阶函数可以将其他函数作为参数,能够创建和返回函数,也能利用现有的和经过测试的小型函数进行代码重用,以下是一个将华氏度转换成摄氏温度平均值的实例(Java):
package chapter5.first;
import java.util.OptionalDouble;
import java.util.function.IntUnaryOperator;
import java.util.stream.IntStream;
public class Five {
public static void main(String[] args) {
OptionalDouble average = IntStream.of(70, 75, 80, 90).map(x -> (x - 32) * 5 / 9).average();
System.out.println(average); // OptionalDouble[25.5]
//在高阶映射函数中使用lambda表达式的方式,可以在多个位置使用相同的lambda表达式来转换温度
IntUnaryOperator convF2C = x -> (x - 32) * 5 / 9;
System.out.println(convF2C); // $$Lambda$6/122883338@27bc2616
average = IntStream.of(70, 75, 80, 90).map(convF2C).average();
System.out.println(average); // OptionalDouble[25.5]
int applyAsInt = convF2C.applyAsInt(80);
System.out.println(applyAsInt); // 26
}
}
1.6 组合
在数学中,常使用一个函数的输出作为下一个函数的输入,从而将它们组合或链接在一起,这样的规则同样适用于函数式编程,也就是高阶函数引用初等函数。上一个示例中 convF2C
的纯函数的使用方法就是例子。
为了使函数组合关系更加清晰,我们可以通过 andThen
方法重写上面的转换公式:
package chapter5.first;
import java.util.function.IntUnaryOperator;
public class Six {
public static void main(String[] args) {
IntUnaryOperator convF2C = ((IntUnaryOperator) (x -> x - 32))
.andThen(x -> x * 5)
.andThen(x -> x / 9);
System.out.println(convF2C); // java.util.function.IntUnaryOperator$$Lambda$3/2065951873@3feba861
int applyAsInt = convF2C.applyAsInt(80);
System.out.println(applyAsInt); // 26
}
}
1.7 柯里化
柯里化是将n元函数转化为一系列一元函数的过程,它以美国数学家Haskell Curry的名字命名。例如:
g
:
:
x
−
>
y
−
>
z
g::x->y->z
g::x−>y−>z
是下面的柯里化形式。
f
:
:
(
x
,
y
)
−
>
z
f::(x,y)->z
f::(x,y)−>z
下面代码显示了如何柯里化包含两个参数的函数,针对具有n个参数的情况,将有n个调用Function<X,Y>的apply函数(扁平化参数),函数功能实现了平方半径公式:
f
(
x
,
y
)
=
x
2
+
y
2
f(x,y)=x^2+y^2
f(x,y)=x2+y2
package chapter5.first;
import java.util.Arrays;
import java.util.HashMap;
import java.util.function.Function;
public class Seven {
public static void main(String[] args) {
Function<Integer,Function<Integer,Integer>> square_radius = x -> y -> x*x + y*y; // chapter5.first.Seven$$Lambda$1/764977973@27d6c5e0
Arrays.asList(
new HashMap<String, Integer>() {{
put("x", 1);
put("y", 5);
}},
new HashMap<String, Integer>() {{
put("x", 2);
put("y", 3);
}}
).stream()
.map(a -> square_radius
.apply(a.get("x"))
.apply(a.get("y")))
.forEach(System.out::println); // 26 13
}
}
1.8 闭包
闭包是一种实现词法作用域的技术。词法作用域允许我们在内部作用域中访问外部上下文变量。在上一个例子中,假设y
变量已经被分配了一个值,lambda表达式仍保持在一元表达式中将y
作为变量,这可能会导致一些难以发现的错误,如下面代码所示,在闭包获取对象的当前值时,我们期望add100
函数总是在输入上加100,但结果却不是这样:
package chapter5.first;
import java.util.function.Function;
public class Eight {
public static void main(String[] args) {
Integer a = 100;
Function<Integer, Integer> add100 = b -> b + a;
add100.apply(9); //109
a = 101;
add100.apply(9); //110
}
}
上面结果中,我们期望两次都是109,结果第二次时110,这就是a变量改变导致的。如果不是使用JShell运行以上代码,会发现编译器层面就给我们提示错误:
Error:(8, 54) java: 从lambda 表达式引用的本地变量必须是最终变量或实际上的最终变量
闭包使用需谨慎,根据经验,常使用final关键字来限制其修改。
1.9 不可变性
在《Effective Java》一书中,Joshua Bloch提了以下建议:“将对象视为不可变的。”在面向对象编程世界中要考虑这个建议其原因在于可变代码具有许多可替换部分以至于太复杂,不容易理解和修改。提升不可变性能够使代码更加简洁,有利于开发人员专注于流程而不是代码可能产生的副作用。最糟糕的副作用是一个地方的小变化会在另一个地方产生灾难性后果(蝴蝶效应)。可变代码有时难以实现并行操作,常常需要使用不同的锁。
1.10 函子
函子(functor)允许将函数应用于给定的容器。它们知道如何从包装对象中解包值,使用给定函数,并返回另一个包含经转换的包装对象的函子。它们抽象了很多习惯用语,包括集合、约定和Optional等。以下代码演示了如何在Java中使用Optional函子、Optional加强版函子(applicative):
package chapter5.first;
import java.util.Optional;
import java.util.function.Function;
public class Ten {
public static void main(String[] args) {
Optional<Integer> a = Optional.of(5); //Optional[5]
//操作函子,将函数应用于函子a,得到4.5
Optional<Float> b = a.map(x -> x * 0.9f); //Optional[4.5]
b.get(); //4.5
//静态的Optional返回空值
Optional<Object> empty = Optional.empty();
//加强版函子,可以将函数包装进函子Optional中,以下代码用于将定字符串大写化
Optional<String> strA = Optional.of("Hello Applicatives");
Optional<Function<String,String>> toUppercase = Optional.of(String::toUpperCase);
Optional<String> result = strA.map(x -> toUppercase.get().apply(x));
System.out.println(result); //Optional[HELLO APPLICATIVES]
//加强版函子,返回相同的输入输出
Optional<Function<String, String>> identity = Optional.of(Function.identity());//Function.identity()将返回与输入相同的输出
Optional<Function<String,String>> upper = Optional.empty();
Optional<String> sameResult = strA.map(x -> upper.orElse(identity.get()).apply(x));
System.out.println(sameResult); //Optional[Hello Applicatives]
}
}
1.11 单子
单子(monad)应用于接受包装值并返回包装值的函数。在Java中包含Stream、CompletableFuture和前面提到的Optional等使用用例。如以下代码所示:
package chapter5.first;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class Eleven {
public static void main(String[] args) {
//Java9中引入了Map的单子函数
//Map<Integer, String> mapping = Map.of(1, "北京", 2 , "上海" , 3 ,"杭州", 4, "温州");
//Java8中只能手动创建
Map<Integer, String> mapping = new HashMap<Integer, String>() {{
put(1, "北京");
put(2, "上海");
put(3, "杭州");
put(4, "温州");
}};
//创建key list的单子,同样也在Java9中才有
//List<Integer> key = List.of(3,4);
//Java8中可以通过Arrays工具方法,这同样是一个单子
List<Integer> keys = Arrays.asList(3, 4, 5);
//取出keys对应的城市列表,这里有兴趣的可以自行搜索:map和flatMap的区别
Stream<String> citysStream = keys.stream().flatMap(key -> Stream.of(mapping.get(key)));
System.out.println(citysStream.collect(Collectors.toList())); //[杭州, 温州, null]
//Java9中提供了Stream.ofNullable()来过滤掉null值
//keys.stream().flatMap(key -> Stream.ofNullable(mapping.get(key)));
}
}
附:map和flatMap的区别
Map:返回map内处理后的类型,内部不能返回stream
FlagMap:展开每个子项流,并拼接,内部返回的是stream
2 Java中的函数式编程
函数式编程基于Streams和lambda表达式,Java中这两者都最早在Java8引入。低版本的JVM运行时环境(Java5、Java6、Java7)中想要运行Java8代码,可以通过Retrolambda等库实现(通常用于Android开发)。
2.1 lambda表达式
lambda表达式是Java中java.util.functions包接口提供的”语法糖“,最重要的接口如下:
- BiConsumer<T, U>:接收2个入参但不返回结果,常用于map的forEach方法。可通过andThen链接多个BiConsumer。
- BiFunction<T, U, R>:接收两个入参并返回一个结果,通过apply方法应用其方法。
- BinaryOperation:作用与两个同类型操作符并返回操作符同类型结果,通过调用其集成的apply方法使用。静态的提供了minBy和maxBy方法,用于返回两个元素中较小/较大的元素。
- BiPredicate<T, U>:接收两个参数并返回布尔值,通过test方法调用
- Consumer:接收单个入参但不返回结果,与二级操作(BiConsumer)类似,支持andThen链接,通过accept调用。如:
//Consumer<T> 打印int值
Consumer<Object> printToConsole = System.out::println;
printToConsole.accept(9); //9
- Function<T, R>:接收一个入参并返回一个结果,可通过apply调用、通过compose组合,如:
//Function<T, R> 计算平凡->转换成字符串并打印
Function<Integer, Integer> square = x -> x*x;
Function<Integer, String> toString = x -> "平方:" + x.toString();
String res_1 = toString.compose(square).apply(4);
printToConsole.accept(res_1); //平方:16
square.andThen(toString).apply(4); //平方:16
- Predicate:接收一个参数并返回布尔值,通过test方法调用,如:
//Predicate<T> 判断小写
Predicate<String> isLower = x -> x.equals(x.toLowerCase());
isLower.test("hello lambda"); //true
isLower.test("Hello Lambda"); //false
- Supplier :无参数,返回一个结果,如:
//Supplier<T> 提供者
String lambda = "Hello lambda";
Supplier<String> closure = () -> lambda;
printToConsole.accept(closure.get());
- UnaryOperator:接收T类型的参数,并返回同类型的结果,等同于Function<T, T>。
上文代码:
package chapter5.second.one;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
public class LambdaDemo {
public static void main(String[] args) {
//Consumer<T> 打印int值
Consumer<Object> printToConsole = System.out::println;
printToConsole.accept(9); //9
//Function<T, R> 计算平凡->转换成字符串并打印
Function<Integer, Integer> square = x -> x*x;
Function<Integer, String> toString = x -> "平方:" + x.toString();
String res_1 = toString.compose(square).apply(4);
printToConsole.accept(res_1); //平方:16
square.andThen(toString).apply(4); //平方:16
//Predicate<T> 判断小写
Predicate<String> isLower = x -> x.equals(x.toLowerCase());
isLower.test("hello lambda"); //true
isLower.test("Hello Lambda"); //false
//Supplier<T> 提供者
String lambda = "Hello lambda";
Supplier<String> closure = () -> lambda;
printToConsole.accept(closure.get());
}
}
2.2 Stream流
流(Stream)是一系列函数的管道,它转换而不是修改数据。在流中包含开始、中间和结束操作,要从流中获取值,需要调用结束操作。流不是一种数据结构,也不能被重用,一且执行完毕将保持关闭。如果再次使用,将报出”java.lang,IllegalStateException:流正在被操作或已经关闭“的异常。
2.2.1 流开始操作
流可以顺序执行或并行执行。它们可以由Collection接口、JarFile、ZipFile或BitSet创建,自Java9开始,也可以由Optional类的stream()方法创建。
Collection类支持parallelStream()方法,该方法可以返回并行流或串行流。通过调用合适的Arrays.stream()方法,可以构造各种类型的流,比如基础数据类型(Integer、Long、Double)或其他类型,例如:
IntStream intStream = Arrays.stream(new int[]{1, 2, 3});
Stream<String> stringStream = Arrays.stream(new String[]{"a", "b", "c"});
List<Integer> integers = Arrays.asList(1, 2, 3, 4, 5);
Stream<Integer> stream = integers.stream(); //串行流,默认
Stream<Integer> streamPl = integers.stream().parallel(); //并行流
可以为基础数据类型构造了特定流类,比如IntStream、LongStream或DoubleStream,这些流类可以使用其静态方法构造流,例如:
- generate()
- of()
- empty()
- iterate()
- concat()
- range()
- rangeClosed()
- builder()
//基础数据类型的流创建
IntStream.of(1, 2, 3);
LongStream.of(1l, 2l, 3l);
DoubleStream.of(1.0, 2.0, 3.0);
从BufferedReader对象获取数据流可以通过调用lines()方法轻松实现,该方法在文件类中以静态形式存在,用于获取给定路径文件中的所有行。文件类也提供了其他流的创建方法,例如:
- list()
- walk()
- find()
//文件中读取的文本流的创建
Stream<String> lines = Files.lines(Paths.get("/文本.txt"));
除了之前提到的Optional,Java 9添加了许多其他返回流的类,例如Matcher类(results()方法)或Scanner类(findAll()和tokens()方法)。
上文代码:
package chapter5.second.two;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.List;
import java.util.stream.DoubleStream;
import java.util.stream.IntStream;
import java.util.stream.LongStream;
import java.util.stream.Stream;
public class StreamBegin {
public static void main(String[] args) throws IOException {
IntStream intStream = Arrays.stream(new int[]{1, 2, 3});
Stream<String> stringStream = Arrays.stream(new String[]{"a", "b", "c"});
List<Integer> integers = Arrays.asList(1, 2, 3, 4, 5);
Stream<Integer> stream = integers.stream(); //串行流
Stream<Integer> streamPl = integers.stream().parallel(); //并行流
基础数据类型的流创建
IntStream.of(1, 2, 3);
LongStream.of(1l, 2l, 3l);
DoubleStream.of(1.0, 2.0, 3.0);
//文件中读取的文本流的创建
Stream<String> lines = Files.lines(Paths.get("/文本.txt"));
}
}
2.2.2 流中间操作
中间流操作被延迟调用,这意味着实际的调用是在调用结束操作之后进行的。下面是一个查找第一个有效字符串的例子:
Stream<String> stream = Arrays.stream(new String[]{"晴天", "多云", "阴天", "小雨"});
Stream<String> printAndFilter = stream.map(x -> {
System.out.println("Map: " + x);
return x;
}).filter(x -> x.contains("天"));
//findFirst()是一个流结束操作,可以看到只打印出了 Map: 晴天
printAndFilter.findFirst();
流的中间操作包含以下操作:
/**
* 设置成串行流
*/
stream.sequential();
/**
* 设置成并行流
* 将上面示例改成并行流将会导致不可预估的错误
* 第二行代码会全部打印
*/
stream.parallel();
printAndFilter.parallel().findFirst(); //将会全部打印
/**
* 设置成无序的流。
* 这使得虚列流的输出顺序不确定,通过允许更有效的实现distinct或groupBy等聚合函数
* 使并行执行的性能得到提高
*/
stream.unordered();
/**
* 指定程序来关闭流使用的资源
* 下面以Files.lines()作为示例来关闭文件资源
*/
stream.onClose(() -> System.out.println("结束了"));
try(Stream<String> lines = Files.lines(Paths.get("d:/文本.txt"))) {
lines.forEach(System.out::println);
}
/**
* 过滤流
*/
stream.filter(x -> "".equals(x));
/**
* 通过自定义的函数转换流
*/
stream.map(x -> x.concat(" 温暖"));
/**
* 根据映射函数用流中的值来替换输入
*/
stream.flatMap(x -> Stream.of(x.toCharArray()));
/**
* 根据Object.equals()方法去重
*/
stream.distinct();
/**
* 根据缺省或给定的比较器对输入进行排序
*/
stream.sorted();
/**
* 在中间流中使用数据,不会改变流的数据
*/
stream.peek(x -> System.out.println(x));
/**
* 根据给定的数字截断流
*/
stream.limit(3);
/**
* 根据给定的数字丢弃前n个元素
*/
stream.skip(1);
下面示例显示了peek、limit、skip等方法的用法:
@Test
public void test() {
Arrays.stream(new String[]{"晴天", "多云", "阴天", "小雨"})
.skip(1)
.limit(2)
.flatMap(x -> Stream.of(x.toCharArray()))
.peek(x -> System.out.println("peek: " + x.length))
.forEach(System.out::println);
}
若是在Java9中,除了Stream<T>.ofNullable()
方法外,还引入了dropWhile和takeWhile方法,用于处理无限长的流,如下一个无限长的流处理示例,用于输出大于5和小于7的数组:
@Test
public void unlimitStream() {
IntStream.iterate(1, x -> x + 1)
.dropWhile(x -> x < 5) //小于5的全部丢弃
.takeWhile(x -> x < 7) //7是无限流的上限值
.forEach(System.out::println);
}
2.2.3 流结束操作
结束操作时值或副作用的操作,遍历整个中间操作过程并进行合适的调用。它们可以处理返回的值(forEach()、forEachOrdered())或可以返回以下任一:
- 迭代器:iterator()、spliterator()
- 集合:toArray()、collect()、Collectors的toList()、toSet()、toCollection()、groupingBy()、partitioningBy()或toMap()
- 特定元素:findFirst()、findAny()
- 聚合(归约),可以是以下的任何一种:
- 算术:IntStream、LongStream、DoubleStream三者专用的min()、max()、count()、sum()、average()、summaryStatistics()
- 布尔值:anyMatch()、allMatch()、noneMatch()
- 自定义:通过reduce()或collect()方法。常用的Collectors方法包括maxBy()、minBy()、reducing()、join()、counting()等
3 实战
3.1 函数接口作入参
package chapter5.practice;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
/**
* 功能:自定义生成字符串数组
* Function接口当作函数参数,调用方自定义转换逻辑
* Predicate接口用于限制数量
* Consumer用于自定义消费内容,这里打印生成的字符串
*/
public class FuncAsParam {
public static void main(String[] args) {
intMapToStrings(
index -> "当前数字:" + index,
index -> index < 3,
System.out::println);
}
private static List<String> intMapToStrings(Function<Integer, String> func, Predicate<Integer> predicate, Consumer<String> consumer) {
List<String> result = new ArrayList<>();
int i = 0;
while (predicate.test(i)) {
String str = func.apply(i++);
result.add(str);
if (null != consumer) {
consumer.accept(str);
}
}
return result;
}
}
结果:
3.2 字符串连接
package chapter5.practice;
import java.util.Arrays;
import java.util.stream.Collectors;
public class JoinString {
public static void main(String[] args) {
String favorite = Arrays.asList("\n","绘梨衣", "cp", "路明非").stream()
.peek(System.out::print)
.map(x -> "cp".equals(x) ? "喜欢" : x)
.collect(Collectors.joining("❤"));
System.out.println(favorite);
}
}
结果:
3.3 List转Map
package chapter5.practice;
import java.util.Arrays;
import java.util.stream.Collectors;
public class ListToMap {
public static void main(String[] args) {
Arrays.asList(new Book("龙族1","路明非、陈墨瞳"), new Book("龙族2", "楚子航、夏弥"), new Book("龙族3", "路明非、绘梨衣"))
.stream()
.collect(Collectors.toMap(Book::getName, Book::getPerson, (beforeKey, afterKey) -> beforeKey ))
.forEach((key,value) -> System.out.println("书名:" + key + " 主角:" + value));
}
public static class Book{
private String name;
private String person;
public Book(String name, String person) {
this.name = name;
this.person = person;
}
public String getName() {
return name;
}
public String getPerson() {
return person;
}
}
}
结果:
3.4 对象数组去重
package chapter5.practice;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.function.Predicate;
public class DistinctObject {
public static void main(String[] args) {
Arrays.asList(new Book("龙族1", "路明非、陈墨瞳"), new Book("龙族2", "楚子航、夏弥"),
new Book("龙族3", "路明非、绘梨衣"), new Book("龙族2", "楚子航、夏弥~"))
.stream()
.peek(book -> System.out.print("有重复(" + book.getName() + ") ")) //会打印出重复的书名
.filter(distinctByKey(Book::getName)) //根据书名去重
.forEach(book -> System.out.println("去重了(" + book + ")")); //流结束的打印结果被去重
}
public static <T> Predicate<T> distinctByKey(Function<? super T, Object> keyExtractor) {
Map<Object, Boolean> seen = new ConcurrentHashMap<>();
return object -> seen.putIfAbsent(keyExtractor.apply(object), Boolean.TRUE) == null;
}
private static class Book {
private String name;
private String person;
public Book(String name, String person) {
this.name = name;
this.person = person;
}
public String getName() {
return name;
}
@Override
public String toString() {
return "书名:" + name + " 主角:" + person;
}
}
}
结果:
4 附录
文中涉及的依赖:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>chapter5</groupId>
<artifactId>chapter5</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<maven.compiler.source>1.9</maven.compiler.source>
<maven.compiler.target>1.9</maven.compiler.target>
</properties>
<dependencies>
<!-- https://mvnrepository.com/artifact/com.aol.simplereact/cyclops-react -->
<dependency>
<groupId>com.aol.simplereact</groupId>
<artifactId>cyclops-react</artifactId>
<version>2.1.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.reactivestreams/reactive-streams -->
<dependency>
<groupId>org.reactivestreams</groupId>
<artifactId>reactive-streams</artifactId>
<version>1.0.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>23.0</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.8.0-M1</version>
</dependency>
</dependencies>
</project>