1. 概述

Java流API提供了多种方法来操作和处理元素序列。然而,如果我们只想处理部分流,例如每隔N个元素,这可能会很有用,比如当我们处理代表CSV文件或数据库表的原始数据流,并且只想处理特定列时。

我们将探讨两种类型的流:有限流和无限流。对于有限流,我们可以将其转换为列表,从而允许索引访问。而对于无限流,我们需要不同的方法。在这篇教程中,我们将学习如何使用各种技术解决这个挑战。

2. 测试设置

我们将使用参数化测试来检查解决方案的正确性。将有一些与相应的N值和预期结果相关的案例:

Arguments.of(
  Stream.of("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"),
  List.of("Wednesday", "Saturday"), 3),
Arguments.of(
  Stream.of("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"),
  List.of("Friday"), 5),
Arguments.of(
  Stream.of("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"),
  List.of("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"), 1)

现在,我们可以深入研究从流中处理第N个元素的不同方法。

3. 使用filter()

在第一种方法中,我们可以创建一个只包含我们想要处理的元素索引的单独流。我们可以使用一个*过滤器(断言函数)*来创建这样的数组:

void givenListSkipNthElementInListWithFilterTestShouldFilterNthElement(Stream<String> input, List<String> expected, int n) {
    final List<String> sourceList = input.collect(Collectors.toList());
    final List<String> actual = IntStream.range(0, sourceList.size())
      .filter(s -> (s + 1) % n == 0)
      .mapToObj(sourceList::get)
      .collect(Collectors.toList());
    assertEquals(expected, actual);
}

如果我们的数据结构允许索引访问,如列表,这种方法将奏效。所需的元素可以收集到一个新的List中,或者使用*forEach(消费器)*进行处理。

4. 使用iterate()

这种方法类似于前一种,但需要允许索引访问的数据结构。然而,我们不会像过滤掉不需要的索引那样,而是仅生成我们一开始想要使用的索引:

void givenListSkipNthElementInListWithIterateTestShouldFilterNthElement(Stream<String> input, List<String> expected, int n) {
    final List<String> sourceList = input.collect(Collectors.toList());
    int limit = sourceList.size() / n;
    final List<String> actual = IntStream.iterate(n - 1, i -> (i + n))
      .limit(limit)
      .mapToObj(sourceList::get)
      .collect(Collectors.toList());
    assertEquals(expected, actual);
}

在这个例子中,我们使用了*IntStream.iterate(int, IntUnaryOperator),它允许我们创建步长为n*的整数序列。

5. 使用subList()

这个方法利用了Stream.iterate,类似于前一个方法,但它创建的是以nk索引开始的列表流:

void givenListSkipNthElementInListWithSublistTestShouldFilterNthElement(Stream<String> input, List<String> expected, int n) {
    final List<String> sourceList = input.collect(Collectors.toList());
    int limit = sourceList.size() / n;
    final List<String> actual = Stream.iterate(sourceList, s -> s.subList(n, s.size()))
      .limit(limit)
      .map(s -> s.get(n - 1))
      .collect(Collectors.toList());
    assertEquals(expected, actual);
}

我们应该取这些列表的第一个元素以获取所需的结果。

6. 使用自定义Collector

作为更高级且透明的解决方案,我们可以实现一个自定义的收集器,它只收集所需的元素:

class SkippingCollector {
    private static final BinaryOperator<SkippingCollector> IGNORE_COMBINE = (a, b) -> a;
    private final int skip;
    private final List<String> list = new ArrayList<>();
    private int currentIndex = 0;
    private SkippingCollector(int skip) {
        this.skip = skip;
    }

    private void accept(String item) {
        int index = ++currentIndex % skip;
        if (index == 0) {
            list.add(item);
        }
    }
    private List<String> getResult() {
        return list;
    }

    public static Collector<String, SkippingCollector, List<String>> collector(int skip) {
        return Collector.of(() -> new SkippingCollector(skip),
          SkippingCollector::accept, 
          IGNORE_COMBINE, 
          SkippingCollector::getResult);
    }
}

这种方法更复杂,需要一些编码。同时,这种解决方案不支持并行化,并且在技术上甚至可能在顺序流上失败,因为组合是实现细节,可能会在未来版本中更改:

public static List<String> skipNthElementInStreamWithCollector(Stream<String> sourceStream, int n) {
    return sourceStream.collect(SkippingCollector.collector(n));
}

然而,可以使用* Spliterators*使这种方法适用于并行流,但这应该有充分的理由。

7. 简单循环

所有之前的解决方案都能工作,但总的来说,它们过于复杂,而且往往误导人。解决问题的最好方式往往是使用尽可能简单的实现。这就是我们如何使用一个*for*循环来达到相同的效果:

void givenListSkipNthElementInListWithForTestShouldFilterNthElement(Stream<String> input, List<String> expected, int n) {
    final List<String> sourceList = input.collect(Collectors.toList());
    List<String> result = new ArrayList<>();
    for (int i = n - 1; i < sourceList.size(); i += n) {
        result.add(sourceList.get(i));
    }
    final List<String> actual = result;
    assertEquals(expected, actual);
}

然而,有时我们需要直接与流一起工作,这将不允许我们直接通过索引访问元素。在这种情况下,我们可以使用带有while循环的*Iterator*:

void givenListSkipNthElementInStreamWithIteratorTestShouldFilterNthElement(Stream<String> input, List<String> expected, int n) {
    List<String> result = new ArrayList<>();
    final Iterator<String> iterator = input.iterator();
    int count = 0;
    while (iterator.hasNext()) {
        if (count % n == n - 1) {
            result.add(iterator.next());
        } else {
            iterator.next();
        }
        ++count;
    }
    final List<String> actual = result;
    assertEquals(expected, actual);
}

这些解决方案更清晰,更容易理解,同时解决了相同的问题。

8. 总结

Java流API是一个强大的工具,有助于使代码更具声明性和可读性。此外,流可以通过利用参数化来实现更好的性能。然而,渴望在任何地方都使用流可能不是使用此API的最佳方式。

尽管在不适合的地方应用流操作的思维锻炼可能会很有趣,但也可能导致“聪明代码”。通常,最简单的结构,如循环,可以用较少且更易理解的代码实现相同的结果。

如往常一样,本文中使用的所有代码均可在GitHub上找到。