使用 Groovy™ 探索 Gatherers4J

作者: Paul King

发布时间:2025-05-06 08:30PM


让我们探索如何将 Groovy 与 Gatherers4J 库和 JDK 24 gatherers API 结合使用。我们还将为 JDK 8/11+ 用户提供迭代器变体!

最近 JDK 版本的一个有趣特性是 Gatherers。JDK 24 包含了一系列内置的 gatherers,但 gatherers API 的主要目标是允许开发自定义中间操作,而不是提供大量内置 gatherers。

我们在早期的博客文章中探讨了如何为 chop、collate 和其他内置 Groovy 功能编写自己的 gatherer 等价物。

其他人也一直在研究有用的 gatherers,并且库也开始出现。Gatherers4J 就是其中一个库。我们将使用 0.11.0、Groovy 5 快照(预发布)和 JDK 24。

现在,让我们看看 Gatherers4J 库中的许多其他 gatherers(以及它们在 Groovy 中的迭代器等价物)。

使用 Gatherers4J 重新审视 Collate

collate a list - produced by Dall-E 3

早前的博客文章中,我们展示了如何为 Groovy 在集合和迭代器上的 collate 扩展方法提供流等价物。它的一些功能受到内置的 windowFixedwindowSliding gatherers 的支持,我们展示了如何编写一些自定义 gatherers,即 windowSlidingByStepwindowFixedTruncating,以处理其余功能。

让我们看看转而使用 Gatherers4J 中的 window gatherer

assert (1..5).stream().gather(Gatherers4j.window(3, 1, true)).toList() ==
    [[1, 2, 3], [2, 3, 4], [3, 4, 5], [4, 5], [5]]
assert (1..8).stream().gather(Gatherers4j.window(3, 2, true)).toList() ==
    [[1, 2, 3], [3, 4, 5], [5, 6, 7], [7, 8]]
assert (1..8).stream().gather(Gatherers4j.window(3, 2, false)).toList() ==
    [[1, 2, 3], [3, 4, 5], [5, 6, 7]]
assert (1..8).stream().gather(Gatherers4j.window(3, 4, false)).toList() ==
    [[1, 2, 3], [5, 6, 7]]
assert (1..8).stream().gather(Gatherers4j.window(3, 3, true)).toList() ==
    [[1, 2, 3], [4, 5, 6], [7, 8]]

为了比较,以下是使用 Groovy 在集合上的 collate 扩展方法显示的输出

assert (1..5).collate(3, 1) == [[1, 2, 3], [2, 3, 4], [3, 4, 5], [4, 5], [5]]
assert (1..8).collate(3, 2) == [[1, 2, 3], [3, 4, 5], [5, 6, 7], [7, 8]]
assert (1..8).collate(3, 2, false) == [[1, 2, 3], [3, 4, 5], [5, 6, 7]]
assert (1..8).collate(3, 4, false) == [[1, 2, 3], [5, 6, 7]]
assert (1..8).collate(3, 3) == [[1, 2, 3], [4, 5, 6], [7, 8]]

它们并非完全等价。Groovy 版本对集合进行急切操作,而 gatherer 版本是基于流的。我们可以通过 Groovy 的 collate 方法的迭代器变体来展示更“苹果与苹果”的比较。让我们看一些无限流示例。

gatherer 版本

assert Stream.iterate(0, n -> n + 1)
    .gather(Gatherers4j.window(3, 3, true))
    .limit(3)
    .toList() == [[0, 1, 2], [3, 4, 5], [6, 7, 8]]
注意
因为我们不担心余数或不同的步长,所以这里可以直接使用 JDK 24 内置的 Gatherers.windowFixed(3) gatherer。

现在,让我们看看 Groovy 的迭代器等价物

assert Iterators.iterate(0, n -> n + 1)
    .collate(3)
    .take(3)
    .toList() == [[0, 1, 2], [3, 4, 5], [6, 7, 8]]

当我们说等价时,我们并不是暗示迭代器提供与流相同的能力或灵活性,但在许多简单场景中,它们将达到相同的结果并且可能更高效。

探索 Gatherers4J 的其他功能

现在让我们探索 Gatherers4J 中的一些其他 gatherers。为了进行“某种程度上”的“苹果对苹果”比较,我们将与等效的 Groovy 迭代器功能进行比较,除非另有说明。在某些情况下,Groovy 不提供基于迭代器的等效功能,因此在这些情况下我们将查看基于集合的解决方案。

请记住,根据您的场景,使用这些示例的集合变体可能更简单且同样适用。流式解决方案在处理大型流时确实表现出色,但我们需要在此类博客文章中保持示例简单。

对于 Gatherers4J 示例,我们将使用 浅绿色 背景;对于标准 Groovy 迭代器功能,我们将使用 浅蓝色 背景。对于某些示例,我们还将介绍使用 Groovy-stream 库的情况,在这种情况下,我们将使用 浅橙色 背景。

Gatherers4J 拥有 50 多个 gatherers,分为五种不同的类别

  • 序列操作 - 重新排序、组合或操纵元素的序列。

  • 过滤和选择 - 根据某些标准选择或删除元素。

  • 分组和窗口化 - 将元素收集到组或窗口中。

  • 数学运算 - 对流执行计算。

  • 验证和约束 - 对流强制执行条件。

我们将探索其中大约一半,并主观地选择可能更常见的场景,但如果您正在使用流,整个库都值得一试。值得记住的是,JDK 具有 Gatherers4J 不试图复制的内置功能。从这个意义上说,Gatherers4J 确实有一些不常用到的 gatherers,但许多仍然非常有用。

我们进行这些比较的目的不是为了将 gatherer 解决方案或迭代器解决方案视为竞争对手。流和迭代器各有其优点。如果使用其中任何一个,最好了解另一个可能是什么样子以及它可能提供什么。

在开始之前,让我们创建一些将在后续示例中使用的变量

var abc = 'A'..'C'
var abcde = 'A'..'E'
var nums = 1..3

crossWith, combine

让我们看看如何创建两个源之间的所有组合对,即 笛卡尔积

Gatherers4J 提供了 crossWith gatherer

assert abc.stream() .gather(Gatherers4j.crossWith(nums.stream())) .map(pair -> pair.first + pair.second) .toList() == ['A1', 'A2', 'A3', 'B1', 'B2', 'B3', 'C1', 'C2', 'C3']

对于集合,Groovy 提供了 combinationseachCombination,但对于迭代器,您可以执行以下操作

assert Iterators.combine(letter: abc.iterator(), number: nums.iterator()) .collecting(map -> map.letter + map.number) .toList() == ['A1', 'A2', 'A3', 'B1', 'B2', 'B3', 'C1', 'C2', 'C3']

Groovy 的 collect 方法家族对应于 Java 流中的 map 方法。Groovy 在命名该方法时遵循了 Smalltalk 的命名约定。Groovy 提供了急切的 collect 和惰性的 collecting 变体,因此我们可以在这里使用 collect 而无需 toList()

foldIndexed, inject+withIndex

折叠(又称 注入归约)操作将项目流折叠成单个值。让我们探索一个折叠的变体,它还提供项目的索引。

Gatherers4J 提供了 foldIndexed gatherer

assert abc.stream() .gather(Gatherers4j.foldIndexed( () -> '', // initialValue (index, carry, next) -> carry + next + index )) .findFirst().get() == 'A0B1C2'

Groovy 使用 inject 作为 fold 的名称,但没有提供带索引值的特殊变体。相反,您可以将普通的 inject 与 withIndexindexed 结合使用

assert abc.iterator().withIndex().inject('') { carry, next -> carry + next.first + next.last } == 'A0B1C2'

interleaveWith, interleave

interleaveWith gatherer 交错两个流中的元素

assert abc.stream() .gather(Gatherers4j.interleaveWith(nums.stream())) .toList() == ['A', 1, 'B', 2, 'C', 3]

Groovy 支持 interleave

assert abc.iterator() .interleave(nums.iterator()) .toList() == ['A', 1, 'B', 2, 'C', 3]

mapIndexed, collect+withIndex, mapWithIndex

映射(也称为 转换收集)操作将流中的元素转换为新值的流。

Gatherers4J 中的 mapIndexed gatherer 提供了对每个元素及其索引的访问

assert abc.stream() .gather(Gatherers4j.mapIndexed ( (i, s) -> s + i )) .toList() == ['A0', 'B1', 'C2']

在 Groovy 中,您通常会使用 withIndexcollect 来实现此功能

assert abc.iterator() .withIndex() .collect { s, i -> s + i } == ['A0', 'B1', 'C2']

或者我们可以使用这里所示的 collecting 变体

assert abc.iterator() .withIndex() .collecting { s, i -> s + i } .toList() == ['A0', 'B1', 'C2']

Groovy-stream 为此场景提供了一个 mapWithIndex 方法

assert Stream.from(abc) .mapWithIndex { s, i -> s + i } .toList() == ['A0', 'B1', 'C2']

orderByFrequency, countBy

Gatherers4J 中的 orderByFrequency gatherer 计算流中的元素,然后在流完成后,返回流中的唯一值(及其频率计数),并按频率计数顺序(升序或降序)排列。鉴于此行为,它可以作为收集器实现。我们将介绍使用内置收集器,然后为了完整性,展示 Gatherers4J 中的升序和降序变体

var letters = ['A', 'A', 'A', 'B', 'B', 'B', 'B', 'C', 'C'] assert letters.stream() .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())) .toString() == '[A:3, B:4, C:2]' assert letters.stream() .gather(Gatherers4j.orderByFrequency(Frequency.Ascending)) .map(withCount -> [withCount.value, withCount.count]) .toList() .collectEntries() .toString() == '[C:2, A:3, B:4]' assert letters.stream() .gather(Gatherers4j.orderByFrequency(Frequency.Descending)) .map(withCount -> [withCount.value, withCount.count]) .toList() .collectEntries() .toString() == '[B:4, A:3, C:2]'

Groovy 在集合上的 countBy 方法执行类似的操作,但默认情况下,它按首次出现的顺序返回唯一项。由于它有点像终端操作符,我们这里只使用 Groovy 集合方法。升序和降序行为可以通过排序实现

assert letters.countBy().toString() == '[A:3, B:4, C:2]' assert letters.countBy() .sort{ e -> e.value } .toString() == '[C:2, A:3, B:4]' assert letters .countBy() .sort{ e -> -e.value } .toString() == '[B:4, A:3, C:2]'

peekIndexed, tapEvery+withIndex, tapWithIndex

Gatherers4J 中的 peekIndexed gatherer 类似于 JDK Streams 的 peek 中间操作,但也提供对索引值的访问

assert abc.stream() .gather(Gatherers4j.peekIndexed( (index, element) -> println "Element $element at index $index" )) .toList() == abc

Groovy 的 eachWithIndex 提供了类似的功能,但会耗尽迭代器。相反,您可以将 withIndextapEvery 结合使用

assert abc.iterator().withIndex().tapEvery { tuple -> println "Element $tuple.first at index $tuple.last" }*.first == abc

Groovy-stream 提供了一个 tapWithIndex 方法

assert Stream.from(abc) .tapWithIndex { s, i -> println "Element $s at index $i" } .toList() == abc

以上所有操作都会产生以下输出

Element A at index 0
Element B at index 1
Element C at index 2

repeat

Gatherers4J 有一个 repeat gatherer,允许将元素源重复给定次数

assert abc.stream() .gather(Gatherers4j.repeat(3)) .toList() == ['A', 'B', 'C', 'A', 'B', 'C', 'A', 'B', 'C']

Groovy 为集合提供了 multiply 运算符,但对于迭代器也有一个 repeat 方法

assert abc * 3 == ['A', 'B', 'C', 'A', 'B', 'C', 'A', 'B', 'C'] assert abc.iterator() .repeat(3) .toList() == ['A', 'B', 'C', 'A', 'B', 'C', 'A', 'B', 'C']

Groovy-streams 也有一个 repeat 方法

assert Stream.from(abc) .repeat(3) .toList() == ['A', 'B', 'C', 'A', 'B', 'C', 'A', 'B', 'C']

repeatInfinitely

Gatherers4J 有一个 repeatInfinitely gatherer,允许元素源无限循环重复

assert abc.stream() .gather(Gatherers4j.repeatInfinitely()) .limit(5) .toList() == ['A', 'B', 'C', 'A', 'B']

Groovy 为此场景提供了一个 repeat 方法

assert abc.iterator() .repeat() .take(5) .toList() == ['A', 'B', 'C', 'A', 'B']

Groovy-stream 为此提供了一个 repeat 方法

assert Stream.from(abc) .repeat() .take(5) .toList() == ['A', 'B', 'C', 'A', 'B']

reverse

Gatherers4J 中的 reverse gatherer 在接收到所有元素后,以相反的顺序返回流中的元素

assert abc.stream() .gather(Gatherers4j.reverse()) .toList() == 'C'..'A'

由于在输出元素之前检查了整个流,因此这里使用流没有显著优势,并且 reverse 不适合用于处理无限流。

这同样适用于 Groovy 的迭代器实现,但 Groovy,像 Gatherers4J 一样,无论如何都提供了一个 reverse 扩展方法

assert abc.iterator() .reverse() .toList() == 'C'..'A'

rotate

Gatherers4J 中的 rotate gatherer 在接收到所有元素后,以旋转的位置返回流中的元素。同样,由于处理了整个流,与使用集合相比,这里没有显著优势。我们可以向任一方向旋转

var abcde = ['A', 'B', 'C', 'D', 'E'] var shift = 2 assert abcde.stream() .gather(Gatherers4j.rotate(Rotate.Left, shift)) .toList() == ['C', 'D', 'E', 'A', 'B'] assert abcde.stream() .gather(Gatherers4j.rotate(Rotate.Right, shift)) .toList() == ['D', 'E', 'A', 'B', 'C']

Groovy 不提供任何基于迭代器的等效方法。对于集合,如果可变方法可以接受,用户可以利用 JDK 库中的 Collections.rotate

var temp = abcde.clone() // unless mutating original is okay Collections.rotate(temp, -shift) // -ve for left assert temp == ['C', 'D', 'E', 'A', 'B'] temp = abcde.clone() Collections.rotate(temp, shift) // +ve for right assert temp == ['D', 'E', 'A', 'B', 'C']

或者,Groovy 的索引允许对集合进行非常灵活的切片,因此可以通过这种索引操作来实现旋转

assert abcde[shift..-1] + abcde[0..<shift] == ['C', 'D', 'E', 'A', 'B'] // left assert abcde[shift<..-1] + abcde[0..shift] == ['D', 'E', 'A', 'B', 'C'] // right

基于迭代器的 rotate 扩展方法可能是 Groovy 未来可能的一个特性。对于左移,似乎可以不存储整个列表,就像当前的 Gatherers4J 实现那样,而只存储“位移”距离的元素数量。对于右移,您需要流大小减去“位移”距离,并且您无法提前知道大小。

scanIndexed, injectAll+withIndex

Gatherers4J 提供了 scanIndexed gatherer。它类似于 JDK 内置的 scan gatherer,但也提供了对索引的访问

assert abc.stream() .gather( Gatherers4j.scanIndexed( () -> '', (index, carry, next) -> carry + next + index ) ) .toList() == ['A0', 'A0B1', 'A0B1C2']

对于 Groovy,可以使用 injectAll 方法与 withIndex 结合使用

assert abc.iterator() .withIndex() .injectAll('') { carry, next -> carry + next.first + next.last } .toList() == ['A0', 'A0B1', 'A0B1C2']

shuffle

Gatherers4J 提供了 shuffle gatherer

int seed = 42 assert Stream.of(*'A'..'G') .gather(Gatherers4j.shuffle(new Random(seed))) .toList() == [ 'B', 'D', 'F', 'A', 'E', 'G', 'C' ]

这是另一个会消耗整个流并将其保存在内存中,然后才产生值的 gatherer。与使用集合相比,没有显著优势。Groovy 只提供基于集合的此功能,使用 shuffled 扩展方法

assert ('A'..'G').shuffled(new Random(seed)) == ['C', 'G', 'E', 'A', 'F', 'D', 'B']

withIndex

Gatherers4J 和 Groovy 都提供了 withIndex。这是 gatherer 版本

assert abc.stream() .gather(Gatherers4j.withIndex()) .map(withIndex -> "$withIndex.value$withIndex.index") .toList() == ['A0', 'B1', 'C2']

这是迭代器版本

assert abc.iterator().withIndex().collect { s, i -> s + i } == ['A0', 'B1', 'C2']

zipWith, zip

Gatherers4J 提供了一个 zipWith gatherer

assert abc.stream() .gather(Gatherers4j.zipWith(nums.stream())) .map(pair -> "$pair.first$pair.second") .toList() == ['A1', 'B2', 'C3']

Groovy 提供 zip

assert abc.iterator() .zip(nums.iterator()) .collect { s, n -> s + n } == ['A1', 'B2', 'C3']

Groovy-stream 提供了相同的功能

assert Stream.from(abc) .zip(nums) { s, i -> s + i } .toList() == ['A1', 'B2', 'C3']

distinctBy, toUnique

Gatherers4J 有一个 distinctBy gatherer,它使用谓词来确定相等性,从而查找唯一元素

assert Stream.of('A', 'BB', 'CC', 'D') .gather(Gatherers4j.distinctBy(String::size)) .toList() == ['A', 'BB']

Groovy 为此提供了 toUnique

assert ['A', 'BB', 'CC', 'D'].iterator() .toUnique(String::size) .toList() == ['A', 'BB']

dropEveryNth/takeEveryNth

Gatherers4J 有特殊的 gatherers,用于获取或丢弃每第 N 个元素

// drop every 3rd assert ('A'..'G').stream() .gather(Gatherers4j.dropEveryNth(3)) .toList() == ['B', 'C', 'E', 'F'] // take every 3rd assert ('A'..'G').stream() .gather(Gatherers4j.takeEveryNth(3)) .toList() == ['A', 'D', 'G']

Groovy 没有用于获取/丢弃第 n 个元素的特定方法,但您可以使用 findAllLazywithIndex,或者 tapEvery

// drop every 3rd assert ('A'..'G').iterator().withIndex() .findAll { next, i -> i % 3 } *.first == ['B', 'C', 'E', 'F'] // take every 3rd assert ('A'..'G').iterator().withIndex() .findAll { next, i -> i % 3 == 0 } *.first == ['A', 'D', 'G'] // also take every 3rd var result = [] ('A'..'G').iterator().tapEvery(3) { result << it }.toList() assert result == ['A', 'D', 'G']

Groovy-stream 也没有特定的方法,但您可以使用 filterWithIndextapEvery 实现类似的功能

// drop every 3rd assert Stream.from('A'..'G') .filterWithIndex { next, idx -> idx % 3 } .toList() == ['B', 'C', 'E', 'F'] // take every 3rd assert Stream.from('A'..'G') .filterWithIndex { next, idx -> idx % 3 == 0 } .toList() == ['A', 'D', 'G'] // also take every 3rd (starting from 3rd) var result = [] Stream.from('A'..'G').tapEvery(3) { result << it }.toList() assert result == ['C', 'F'] result = []

dropLast, dropRight

dropLast gatherer 从流中删除最后 n 个元素

assert abcde.stream() .gather(Gatherers4j.dropLast(2)) .toList() == abc

Groovy 为此提供了 dropRight

assert abcde.iterator().dropRight(2).toList() == abc

filterIndexed

filterWithIndex gatherer 允许过滤元素并访问索引

assert abcde.stream() .gather(Gatherers4j.filterIndexed{ n, s -> n % 2 == 0 }) .toList() == ['A', 'C', 'E'] assert abcde.stream() .gather(Gatherers4j.filterIndexed{ n, s -> n < 2 || s == 'E' }) .toList() == ['A', 'B', 'E']

Groovy 没有一体化的等效功能,但您可以使用 withIndex 加上 findAllLazy

assert abcde.iterator().withIndex() .findAll { s, n -> n % 2 == 0 }*.first == ['A', 'C', 'E'] assert abcde.iterator().withIndex() .findAll { s, n -> n < 2 || s == 'E' }*.first == ['A', 'B', 'E']

Groovy-stream 提供了一个 filterWithIndex 方法

assert Stream.from(abcde) .filterWithIndex{ s, n -> n % 2 == 0 } .toList() == ['A', 'C', 'E'] assert Stream.from(abcde) .filterWithIndex{ s, n -> n < 2 || s == 'E' } .toList() == ['A', 'B', 'E']

filterInstanceOf

filterInstanceOf gatherer 将 filterinstanceof 结合起来

var mixed = [(byte)1, (short)2, 3, (long)4, 5.0, 6.0d, '7', '42'] assert mixed.stream() .gather(Gatherers4j.filterInstanceOf(Integer)) .toList() == [3] assert mixed.stream() .gather(Gatherers4j.filterInstanceOf(Number)) .toList() == [1, 2, 3, 4, 5.0, 6.0] assert mixed.stream() .gather(Gatherers4j.filterInstanceOf(Integer, Short)) .toList() == [2, 3]

Groovy 没有完全等效的功能,但它有一个急切的 grep,可以让你做类似的事情,以及其他功能,如匹配正则表达式模式。你也可以根据需要结合使用 findAllLazygetClass()instanceof

var mixed = [(byte)1, (short)2, 3, (long)4, 5.0, 6.0d, '7', '42'] assert mixed.iterator().grep(Integer) == [3] assert mixed.iterator().grep(Number) == [1, 2, 3, 4, 5.0, 6.0] assert mixed.iterator().grep(~/\d/).toString() == '[1, 2, 3, 4, 7]' assert mixed.iterator() .findAllLazy{ it.getClass() in [Integer, Short] } .toList() == [2, 3]

takeLast/takeRight

takeLast gatherer 返回流中的最后 n 个元素

assert abcde.stream() .gather(Gatherers4j.takeLast(3)) .toList() == ['C', 'D', 'E']

它在发出元素之前读取整个流。

Groovy 没有迭代器等价物,但有一个 takeRight 集合方法

assert abcde.takeRight(3) == 'C'..'E'

takeUntil, takeWhile, until

takeUntil gatherer 提取元素直到某个条件满足,包括满足条件的元素

assert abcde.stream() .gather(Gatherers4j.takeUntil{ it == 'C' }) .toList() == abc

takeWhile 扩展方法在满足某个条件时提取元素(因此需要相反的条件)

assert abcde.iterator() .takeWhile { it != 'D' } .toList() == abc

Groovy-stream 的 until 方法的条件与 gatherers4j 类似,但不包括触发条件的元素

assert Stream.from(abcde) .until { it == 'D' } .toList() == abc

uniquelyOccurring

uniquelyOccurring gatherer 在返回仅出现一次的元素之前读取整个流

assert Stream.of('A', 'B', 'C', 'A') .gather(Gatherers4j.uniquelyOccurring()) .toList() == ['B', 'C']

Groovy 没有等效的方法,但可以使用 countBy 实现类似的功能。Groovy 的 countBy 方法是急切的(可以看作是终端)操作符

assert ['A', 'B', 'C', 'A'].iterator() .countBy() .findAll{ it.value == 1 }*.key == ['B', 'C']

结论

我们已经研究了 Gatherers4j 库的一些功能,以及如何使用迭代器实现类似的功能。Gatherers4j 与 Groovy 配合良好,提供了 JDK 中没有的额外流功能。如果您经常使用流,请探索 Gatherers4j,它提供了可能满足您需求的预构建 gatherers。

更新历史

2025年5月6日:初始版本