我刚刚在GitHub上发布了LazySeq
库,这是我最近进行的Java 8实验的结果。 我希望你会喜欢它。 即使您觉得它不是很有用,它仍然是Java 8(以及一般而言)中的函数式编程的重要课程。 而且它可能是第一个针对Java 8的社区库!
介绍
惰性序列是仅在实际需要其元素时才计算的数据结构。 对延迟序列的所有操作map()
例如map()
和filter()
也都是延迟的,从而将调用延迟到真正必要的时候。 总是从一开始就使用非常便宜的first / rest遍历惰性序列
分解( head()
和tail()
)。 惰性序列的一个重要属性是它们可以表示无限的数据流,例如,随时间推移的所有自然数或温度测量值。
惰性序列会记住已计算的值,因此,如果您访问第N个元素,则也会计算并缓存从1
到N-1
所有元素。 尽管如此, LazySeq
(处于许多功能语言和算法的核心)是不可变的且线程安全的。
基本原理
该库在很大程度上受到scala.collection.immutable.Stream
启发,旨在提供不可变,线程安全且易于使用的惰性序列实现(可能是无限的)。 有关某些用例,请参见Scala和Clojure中的惰性序列 。
Stream
类名称是用Java 8已被使用,因此LazySeq
被选择,类似于lazy-seq
Clojure中 。 说到Stream
,一开始它看起来像是一个开箱即用的惰性序列实现。 但是,引用Javadoc:
流不是数据结构
和:
一旦在流上执行了某个操作,该操作将被视为已消耗并且不再可用于其他操作。
换句话说, java.util.stream.Stream
只是现有集合的一个瘦包装,适合一次使用。 更类似于Iterator
,而不是Stream
于斯卡拉。 该库试图填补这一空白。
当然,在Java 8之前可以实现惰性序列数据结构,但是缺少lambda使得使用这种数据结构变得乏味且过于冗长。
入门
在10分钟内构建并处理惰性序列。
所有自然数的无限序列
为了创建一个惰性序列,您可以使用LazySeq.cons()
工厂方法,该方法接受第一个元素( head )和一个以后可能用于计算rest( tail )的函数。 例如,为了产生具有给定起始元素的自然数的惰性序列,您只需说:
private LazySeq<Integer> naturals(int from) {return LazySeq.cons(from, () -> naturals(from + 1));
}
这里真的没有递归。 如果存在,则调用naturals()
会很快导致StackOverflowError
因为它会在没有停止条件的情况下自行调用。 但是() -> naturals(from + 1)
表达式定义了一个函数,该函数返回此数据结构将调用的LazySeq<Integer>
(准确地说是Supplier
),但仅在需要时才返回。 查看下面的代码,您认为几次调用了naturals()
函数(第一行除外)?
final LazySeq<Integer> ints = naturals(2);final LazySeq<String> strings = ints.map(n -> n + 10).filter(n -> n % 2 == 0).take(10).flatMap(n -> Arrays.asList(0x10000 + n, n)).distinct().map(Integer::toHexString);
的第一次调用naturals(2)
返回来自起始懒惰序列2
但休息( 3
, 4
, 5
,...)还没有被计算。 稍后,我们将map()
到该序列上,对其进行filter()
, take()
前10个元素,删除重复项,等等。所有这些操作都不会评估序列,并且尽可能懒惰。 例如take(10)
不会急切地求出前10个元素以返回它们。 而是返回新的惰性序列,该序列记住它应该在第10个元素处截断原始序列。
同样适用于distinct()
。 它不会评估提取所有唯一值的整个序列(否则,上面的代码将Swift爆炸,遍历无限量的自然数)。 而是返回仅包含第一个元素的新序列。 如果您要第二个唯一元素,它将懒惰地评估尾巴,但只会尽可能多。 查看
toString()
输出:
System.out.println(strings);
//[1000c, ?]
问号( ?
)说: “该集合中可能还有其他内容,但我还不知道” 。 您了解1000c
来自何处吗? 仔细地看:
- 从
2
开始的无限自然数流开始 - 向每个元素添加
10
(因此第一个元素变为12
或十六进制的C
) -
filter()
输出奇数(12
为偶数,因此保持不变) -
take()
到目前为止序列中的前10
元素 - 每个元素都被两个元素替换:该元素加0x1000和元素本身(
flatMap()
)。 这不会产生成对的序列,但是会产生两倍长的整数序列 - 我们确保只返回
distinct()
元素 - 最后,我们将整数转换为十六进制字符串。
如您所见,这些操作都不是真正需要评估整个流的。 唯一的头正在转变,这就是我们最终看到的。 那么,何时实际评估此数据结构? 在绝对必要时(例如在副作用遍历期间):
strings.force();//or
strings.forEach(System.out::println);//or
final List<String> list = strings.toList();//or
for (String s : strings) {System.out.println(s);
}
仅上述所有语句将强制评估整个延迟序列。 如果我们的序列是无限的,不是很聪明,但是
strings
仅限于前10个元素,因此不会无限运行。 如果只想强制执行序列的一部分,只需调用strings.take(5).force()
。 顺便说一句,您是否注意到我们可以使用标准Java 5 for-each语法遍历LazySeq strings
? 这是因为LazySeq
实现了List
接口,因此可以与Java Collections Framework生态系统很好地配合使用:
import java.util.AbstractList;public abstract class LazySeq<E> extends AbstractList<E>
请记住,一旦对惰性序列进行了评估(计算),它将对它们进行缓存( 记住 )以备后用。 这使得惰性序列非常适合表示无限或非常长的数据流,这些数据计算起来很昂贵。
迭代()
建立无限的惰性序列通常可以归结为提供一个初始元素和一个功能,该功能可以根据前一个元素生成下一个元素。 换句话说,第二元素是第一个的函数,第三元素是第二个的函数,依此类推。 为这种情况提供了便利的LazySeq.iterate()
函数。 现在, ints
定义可以如下所示:
final LazySeq<Integer> ints = LazySeq.iterate(2, n -> n + 1);
我们从2
开始,每个后续元素表示为前一个元素+ 1。
更多示例:斐波那契数列和Collatz猜想
没有斐波那契数字,就不会留下关于懒惰数据结构的文章,例如:
private static LazySeq<Integer> lastTwoFib(int first, int second) {return LazySeq.cons(first,() -> lastTwoFib(second, first + second));
}
斐波那契数列也是无限的,但我们可以通过多种方式自由变换它:
System.out.println(fib.drop(5).take(10).toList()
);
//[5, 8, 13, 21, 34, 55, 89, 144, 233, 377]final int firstAbove1000 = fib.filter(n -> (n > 1000)).head();fib.get(45);
看到无限的数字流是多么容易和自然吗? drop(5).take(10)
跳过前5个元素,并显示下一个10。在这一点上,已经计算了前15个数字,以后再也不会计算了。
查找高于1000(可能是1597
)的第一个斐波那契数非常简单。 head()
始终由filter()
预先计算,因此不需要进一步评估。 最后但并非最不重要的一点是,我们只需索取第45个斐波那契数 (从0开始)并获得
1134903170
。 如果您尝试访问该编号之前的任何斐波那契数,它们将被预先计算并可以快速检索。
有限序列(Collatz猜想)
Collatz猜想也是一个很有趣的问题。 对于每个正整数n
我们使用以下算法计算下一个整数:
- 如果
n
为偶数则为n
n/2
- 如果
n
为奇数,则为3n + 1
例如,从10
序列开始的外观如下:10、5、16、8、4、2、1。该序列在达到1时结束。数学家认为,从任何整数开始,我们最终都将达到1,但尚未证明。
让我们创建一个惰性序列,该序列为给定的n
生成Collatz级数,但仅根据需要生成。 如上所述,这次我们的序列将是有限的:
private LazySeq<Long> collatz(long from) {if (from > 1) {final long next = from % 2 == 0 ? from / 2 : from * 3 + 1;return LazySeq.cons(from, () -> collatz(next));} else {return LazySeq.of(1L);}
}
该实现由定义直接驱动。 对于每个大于1
数字,返回该数字+惰性计算的流的其余部分( () -> collatz(next)
)。 如您所见,如果给定1
,我们将使用特殊的of()
工厂方法返回单元素惰性序列。 让我们用上述10
测试它:
final LazySeq<Long> collatz = collatz(10);collatz.filter(n -> (n > 10)).head();
collatz.size();
filter()
允许我们找到大于10
的序列中的第一个数字。 请记住,惰性序列将必须遍历内容(进行自我评估),但只能遍历找到第一个匹配元素的位置。 然后停止,确保其计算量尽可能少。
但是,为了计算元素总数, size()
必须遍历整个序列。 当然,这仅适用于有限的惰性序列,在无限序列上调用size()
最终效果不佳。
如果您对此序列稍作练习,您将很快意识到,不同数字的序列共享相同的后缀 (总是以相同的数字序列结尾)。 这请求进行一些缓存/结构共享。 有关详细信息,请参见CollatzConjectureTest
。
现实生活?
无限的数字序列很棒,但在现实生活中并不十分实用。 也许还有更多脚踏实地的例子? 假设您有一个收藏,您需要从该收藏中随机挑选一些物品。 代替集合,我将使用一个返回随机拉丁字符的函数:
private char randomChar() {return (char) ('A' + (int) (Math.random() * ('Z' - 'A' + 1)));
}
但是有一个转折。 您需要N个(N <26,拉丁字符个数)唯一值。 仅仅几次调用randomChar()
并不能保证唯一性。 解决这个问题的方法很少,使用LazySeq
非常简单:
LazySeq<Character> charStream = LazySeq.<Character>continually(this::randomChar);
LazySeq<Character> uniqueCharStream = charStream.distinct();
当需要时, continually()
只需为每个元素调用给定函数。 因此, charStream
将是无限的随机字符流。 当然,它们不能唯一。 但是uniqueCharStream
保证其输出是唯一的。 它通过检查底层charStream
下一个元素并拒绝已出现的项目来实现。 现在我们可以说uniqueCharStream.take(4)
并确保不会出现重复项。
再次注意, continually(this::randomChar).distinct().take(4)
实际上只调用randomChar()
一次! 只要您不消耗此序列,它就会保持延迟并尽可能延迟评估。
另一个示例涉及从数据库加载数据的批次(页面)。 使用ResultSet
或Iterator
很麻烦,但是将整个数据集加载到内存中通常不可行。 另一种选择是急于加载第一批数据,然后提供加载下一批数据的功能。 仅当确实需要数据时才加载数据,而我们不会遇到性能或可伸缩性问题。
首先,让我们定义抽象的API,以从数据库中加载批量数据:
public List<Record> loadPage(int offset, int max) {//load records from offset to offset + max
}
我完全从技术中抽象出来,但是您明白了。 假设我们现在定义LazySeq<Record>
,它从第0行开始,仅在需要时才加载下一页:
public static final int PAGE_SIZE = 5;private LazySeq<Record> records(int from) {return LazySeq.concat(loadPage(from, PAGE_SIZE),() -> records(from + PAGE_SIZE));
}
通过调用records(0)
创建新的LazySeq<Record>
实例时,将加载5个元素的第一页。 这意味着已经计算出前5个序列元素。 如果您尝试访问6th或更高版本,sequence将自动加载所有丢失的记录并对其进行缓存。 换句话说,您永远不会两次计算相同的元素。
使用序列时,更有用的工具是grouped()
和sliding()
方法。 首先将输入序列分成大小相等的组。 以这个为例,还证明这些方法总是很懒:
final LazySeq<Character> chars = LazySeq.of('A', 'B', 'C', 'D', 'E', 'F', 'G');chars.grouped(3);
//[[A, B, C], ?]chars.grouped(3).force(); //force evaluation
//[[A, B, C], [D, E, F], [G]]
同样适用于sliding()
:
chars.sliding(3);
//[[A, B, C], ?]chars.sliding(3).force(); //force evaluation
//[[A, B, C], [B, C, D], [C, D, E], [D, E, F], [E, F, G]]
这两种方法非常有用。 您可以通过滑动窗口查看数据(例如,计算移动平均值 )或将其划分为等长的存储桶。
您可能会发现有用的最后一个有用的实用程序方法是scan()
,它会(当然是延迟地scan()
迭代输入流,并通过在输入的前一个和当前元素上应用函数来构造输出的每个元素。 代码段值一千个字:
LazySeq<Integer> list = LazySeq.numbers(1).scan(0, (a, x) -> a + x);list.take(10).force(); //[0, 1, 3, 6, 10, 15, 21, 28, 36, 45]
LazySeq.numbers(1)
是自然数(1、2、3…)的序列。 scan()
创建一个新的序列,从
0
并为输入的每个元素(自然数)将其添加到自身的最后一个元素。 因此我们得到:[ 0
0+1
0+1+2
0+1+2+3
0+1+2+3+4
0+1+2+3+4+5
…]。 如果您想要一系列增长的字符串,只需替换几种类型:
LazySeq.continually("*").scan("", (s, c) -> s + c).map(s -> "|" + s + "\\").take(10).forEach(System.out::println);
并享受这个美丽的三角形:
|\
|*\
|**\
|***\
|****\
|*****\
|******\
|*******\
|********\
|*********\
或者(相同的输出):
LazySeq.iterate("", s -> s + "*").map(s -> "|" + s + "\\").take(10).forEach(System.out::println);
Java Collections框架的互操作性
LazySeq
实现java.util.List
接口,因此可以在许多地方使用。 此外,它还对集合(即流和集合)实现了Java 8增强:
lazySeq.stream().map(n -> n + 1).flatMap(n -> asList(0, n - 1).stream()).filter(n -> n != 0).substream(4, 18).limit(10).sorted().distinct().collect(Collectors.toList());
但是,Java 8中的流是为了解决作为LazySeq
(延迟评估)基础的功能而创建的。 上面的示例将所有中间步骤推迟到调用collect()
为止。 使用LazySeq
您可以安全地跳过.stream()
并直接处理序列:
lazySeq.map(n -> n + 1).flatMap(n -> asList(0, n - 1)).filter(n -> n != 0).slice(4, 18).limit(10).sorted().distinct();
此外, LazySeq
提供了特殊用途的收集器(请参阅: LazySeq.toLazySeq()
),即使与collect()
一起使用也可以避免评估,这通常会强制进行完整的收集计算。
实施细节
每个懒惰序列都是围绕急切计算的头和懒洋洋地表示为函数的尾巴的思想构建的。 这与经典的单链列表递归定义非常相似:
class List<T> {private final T head;private final List<T> tail;//...
}
但是,在延迟序列的情况下, 尾部是函数而不是值。 该功能的调用会尽可能推迟:
class Cons<E> extends LazySeq<E> {private final E head;private LazySeq<E> tailOrNull;private final Supplier<LazySeq<E>> tailFun;@Overridepublic LazySeq<E> tail() {if (tailOrNull == null) {tailOrNull = tailFun.get();}return tailOrNull;}
有关完整的实现,请参见在创建时知道tail
时使用的Cons.java
和FixedCons.java
(例如LazySeq.of(1, 2)
与LazySeq.cons(1, () -> someTailFun()
相对LazySeq.of(1, 2)
,例如LazySeq.of(1, 2)
)。
陷阱和常见危险
下面介绍常见问题和误解。
评估太多
使用无限序列的最大危险之一就是试图对其进行完全评估,这显然会导致无限计算。 无限序列背后的思想不是整体评估它,而是在不引入人为限制和意外复杂性的情况下,尽可能多地获取它(请参见数据库加载示例)。
但是,评估整个序列太容易遗漏了。 例如,调用LazySeq.size()
必须评估整个序列,并且将无限运行,最终填满堆栈或堆(实现细节)。 还有其他方法需要完全遍历才能正常运行。 例如allMatch()
确保所有元素都匹配给定谓词。 有些方法甚至更危险,因为它们是否完成取决于序列中的数据。 例如,如果head匹配谓词–或从不, anyMatch()
可能会立即返回。
有时,我们可以使用更具确定性的方法来轻松避免昂贵的操作。 例如:
seq.size() <= 10 //BAD
如果seq
是无限的,则可能无法正常工作或非常慢。 但是,我们可以通过(更多)可预测的方式实现相同的目标:
seq.drop(10).isEmpty()
请记住,惰性序列是不可变的(因此,我们实际上并不对seq
突变), drop(n)
通常为O(n)
而isEmpty()
为O(1)
。
如有疑问,请查阅源代码或JavaDoc以确保您的操作不会过于急切地评估您的序列。 使用LazySeq
,也要非常小心,需要使用java.util.Collection
或java.util.List 。
持有不必要的头参考
惰性序列应定义为记住已计算的元素。 您必须意识到这一点,否则您的序列(尤其是无限序列)将Swift填满可用内存。 但是,由于LazySeq
只是一个奇特的链接列表,因此,如果您不再保留对head的引用(而仅保留中间的某个元素),则可以进行垃圾回收。 例如:
//LazySeq<Char> first = seq.take(10);
seq = seq.drop(10);
前十个元素被删除,我们假设没有任何东西引用过seq
以前的内容。 这使前十个元素有资格进行垃圾回收。 但是,如果我们取消注释第一行,并保持参照老head
在first
,JVM不会释放任何内存。 让我们对此进行透视。 下面的代码最终将抛出OutOfMemoryError
因为infinite
引用将保持序列的开头,因此,到目前为止创建的所有元素:
LazySeq<Big> infinite = LazySeq.<Big>continually(Big::new);
for (Big arr : infinite) {//
}
但是,通过内联对continually()
调用或将其提取到方法中,此代码可以完美运行(嗯,仍然可以永远运行,但几乎不使用内存):
private LazySeq<Big> getContinually() {return LazySeq.<Big>continually(Big::new);
}for (Big arr : getContinually()) {//
}
有什么不同? 每个循环都在下面使用迭代器。 LazySeqIterator
下面的LazySeqIterator
不会保留对旧head()
的引用,因此,如果没有其他引用该head的对象,则可以进行垃圾回收,当使用for-each时,请参见true javac
输出:
for (Iterator<Big> cur = getContinually().iterator(); cur.hasNext(); ) {final Big arr = cur.next();//...
}
TL; DR
您的序列在遍历时会增加。 如果在另一端成长时保持一端,则最终会炸毁。 就像您在Hibernate中的一级缓存一样,如果您在一个事务中加载过多。 仅根据需要使用。
转换为纯Java集合
转换很简单,但是很危险。 这是以上几点的结果。 您可以通过调用toList()
将惰性序列转换为java.util.List
:
LazySeq<Integer> even = LazySeq.numbers(0, 2);
even.take(5).toList(); //[0, 2, 4, 6, 8]
或使用具有更丰富API的Java 8中的Collector
:
even.stream().limit(5).collect(Collectors.toSet()) //[4, 6, 0, 2, 8]
但是请记住,Java集合在定义上是有限的,因此请避免将惰性序列明确转换为集合。 注意
LazySeq
已经是List
,因此是Iterable
和Collection
。 它还具有高效的LazySeq.iterator()
。 如果可以,只需直接传递LazySeq
实例即可。
性能,时间和空间复杂度
每个序列(空除外head()
的head()
总是很容易计算,因此对其进行快速O(1)
访问。 计算tail()
可能占用从O(1)
(如果已经计算过)到无限时间的所有内容。 以这个有效的流为例:
import static com.blogspot.nurkiewicz.lazyseq.LazySeq.cons;
import static com.blogspot.nurkiewicz.lazyseq.LazySeq.continually;LazySeq<Integer> oneAndZeros = cons(1,() -> continually(0)
).
filter(x -> (x > 0));
它代表1
后跟无穷多个0
s。 通过过滤所有正数( x > 0
),我们得到了一个具有相同头部的序列,但是尾部的过滤被延迟了(延迟)。 但是,如果现在我们不小心调用oneAndZeros.tail()
, LazySeq
将继续计算该无限序列的越来越多,但是由于在初始1
之后没有正元素,因此该操作将永远运行,最终抛出StackOverflowError
或OutOfMemoryError
(这是一个实施细节)。
但是,如果您达到此状态,则可能是编程错误或滥用该库。 通常tail()
将接近O(1)
。 另一方面,如果您已经“堆叠”了很多操作,则调用tail()
会Swift一次又一次触发它们,因此tail()
运行时间在很大程度上取决于您的数据结构。
LazySeq
上的大多数操作都是O(1)
因为它们是惰性的。 一些操作,例如get(n)
或drop(n)
都是O(n)
( n
表示参数,而不是序列长度)。 一般而言,运行时间将类似于正常的链表。
因为LazySeq
记住单个链接列表中所有已经计算出的值,所以内存消耗始终为O(n)
,其中n
n是已经计算出的元素数。
故障排除
错误invalid target release: 1.8
在Maven构建期间invalid target release: 1.8
如果在maven生成过程中看到此错误消息:
[INFO] BUILD FAILURE
...
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.1:compile (default-compile) on project lazyseq:
Fatal error compiling: invalid target release: 1.8 -> [Help 1]
这意味着您不使用Java 8进行编译。 下载具有lambda支持的JDK 8,并让maven使用它:
$ export JAVA_HOME=/path/to/jdk8
我收到StackOverflowError
或程序无限期挂起
使用LazySeq
,有时会出现StackOverflowError
或OutOfMemoryError
:
java.lang.StackOverflowErrorat sun.misc.Unsafe.allocateInstance(Native Method)at java.lang.invoke.DirectMethodHandle.allocateInstance(DirectMethodHandle.java:426)at com.blogspot.nurkiewicz.lazyseq.LazySeq.iterate(LazySeq.java:118)at com.blogspot.nurkiewicz.lazyseq.LazySeq.lambda$0(LazySeq.java:118)at com.blogspot.nurkiewicz.lazyseq.LazySeq$$Lambda$2.get(Unknown Source)at com.blogspot.nurkiewicz.lazyseq.Cons.tail(Cons.java:32)at com.blogspot.nurkiewicz.lazyseq.LazySeq.size(LazySeq.java:325)at com.blogspot.nurkiewicz.lazyseq.LazySeq.size(LazySeq.java:325)at com.blogspot.nurkiewicz.lazyseq.LazySeq.size(LazySeq.java:325)at com.blogspot.nurkiewicz.lazyseq.LazySeq.size(LazySeq.java:325)at com.blogspot.nurkiewicz.lazyseq.LazySeq.size(LazySeq.java:325)at com.blogspot.nurkiewicz.lazyseq.LazySeq.size(LazySeq.java:325)at com.blogspot.nurkiewicz.lazyseq.LazySeq.size(LazySeq.java:325)
当使用可能无限的数据结构时,必须小心。 避免调用必须 ( size()
, allMatch()
, minBy()
, forEach()
, reduce()
,…)或可以 ( filter()
, distinct()
,…)遍历整个序列以便给出正确值的操作结果。 有关更多示例和避免方法,请参阅陷阱 。
到期
质量
该项目是作为练习开始的,尚未经过战斗验证。 但是,健康的300多个单元测试套件 (测试代码/生产代码比率为3:1)可以保护质量和功能正确性。 我还通过LazySeq
尾部函数并验证它们被尽可能少地调用来确保LazySeq
尽可能地懒惰。
贡献和错误报告
如果发现错误或缺少功能,请随时打开新票证或开始拉取请求 。 我也很LazySeq
在野外看到LazySeq
更多有趣用法。
可能的改进
- 就像在
FixedCons
知道尾巴的情况下使用FixedCons
一样,请考虑将IterableCons
包装在一个节点中而不是建立FixedCons
层次结构的Iterable
。 这可以用于所有concat
方法。 - 并行处理支持(实现分离器?)
执照
该项目是在Apache许可证的 2.0版下发布的 。
翻译自: https://www.javacodegeeks.com/2013/05/lazy-sequences-implementation-for-java-8.html