在Java中对单个流的元素执行多个不相关操作

6

我如何在单个流的元素上执行多个不相关操作?

假设我有一个由文本组成的List<String>。列表中的每个字符串可能包含或不包含某个单词,该单词表示要执行的操作。假设:

  • 如果字符串包含“of”,则必须计算该字符串中的所有单词。
  • 如果字符串包含“for”,则必须返回第一次出现“for”之后的部分,从而生成包含所有子字符串的List<String>

当然,我可以像这样做:

List<String> strs = ...;

List<Integer> wordsInStr = strs.stream()
    .filter(t -> t.contains("of"))
    .map(t -> t.split(" ").length)
    .collect(Collectors.toList());

List<String> linePortionAfterFor = strs.stream()
    .filter(t -> t.contains("for"))
    .map(t -> t.substring(t.indexOf("for")))
    .collect(Collectors.toList());

但是,如果strs包含大量元素,则列表将被遍历两次,这可能会导致性能损失。

有没有办法在不遍历列表两次的情况下执行这两个操作?


1
在我看来,为了不存在的性能问题而使你的代码变得复杂完全是不必要的。在内存中迭代列表/集合非常快,即使它很大也很快。再次强调,在我的代码审查中,如果我是决策者,我会接受原始帖子中的代码并拒绝被采纳的答案中的代码。 - 123-xyz
1
@123-xyz已经抓住了关键点。有趣的是,当您使用contains("for")紧随其后立即使用indexOf("for")时,您并不需要在迭代两次时担心,尽管这些操作相关的,并且可以融合而不损失易读性。而且,如果您真的关心性能,请思考当您只想计算空格数时,t.split(" ")实际上执行了什么操作。当操作无关时迭代两次不是问题。 - Holger
也许我应该更准确地阐述问题。我试图在帖子中附上一个示例,因此我不得不编写一个简单的用例。那个用例根本没有意义,而且实现也很糟糕:事实上,仅应用t.split(" ")来获取实际单词数是错误的。但是,如果我没有元素列表,而是从网络连接或某个I/O通道获得的Stream,那么这将显着改变用例,不是吗? - MC Emperor
@MCEmperor 是的,它可能会改变事情 - 取决于收集到集合中的流的大小,如果您需要将其用于多个不相关的操作,则仍然值得付出内存成本,或者它可能完全不可能(例如,如果流是无限的 - 当然,在这种情况下,所有不相关的操作都必须是短路的,这可能会很复杂)。 - Hulk
@MCEmperor 如果元素来自网络/数据库调用,那将是完全不同的问题/场景。首先,即使大小很小或只有一个元素,我们也不应该重复网络/数据库调用。其次,您仍然可以将网络/数据库调用的结果保存到临时列表中,然后执行 OP 中所做的操作。第三,如果大小太大无法保存,或者出于某些原因您不想保存,您可以在映射函数中使用 Pair/TripleFunction<? super T, Pair<R1, R2>> mapper = ...,然后再考虑创建一些复杂的收集器。 - 123-xyz
@123-xyz 我认为 Pair 不适用,因为在大多数情况下,forof 的出现次数是不同的,将结果放在一个 Pair 中 a) 会导致一些 Pair 没有左值或右值,b) 也不能反映出 Pair 的语义。 - MC Emperor
4个回答

6
如果您想要一个单次遍历的Stream,则必须使用自定义的Collector(并行化可能)。
class Splitter {
  public List<String> words = new ArrayList<>();
  public List<Integer> counts = new ArrayList<>();

  public void accept(String s) {
    if(s.contains("of")) {
      counts.add(s.split(" ").length);
    } else if(s.contains("for")) {
      words.add(s.substring(s.indexOf("for")));
    }
  }

  public Splitter merge(Splitter other) {
    words.addAll(other.words);
    counts.addAll(other.counts);
    return this;
  }
}
Splitter collect = strs.stream().collect(
  Collector.of(Splitter::new, Splitter::accept, Splitter::merge)
);
System.out.println(collect.counts);
System.out.println(collect.words);

4

以下是以不同角度回答原帖的答案。首先,让我们来看一下如何快速/慢速迭代列表/集合。以下是在我的机器上执行的性能测试结果:

当: 字符串列表长度=100,线程数=1,循环次数=1000,单位=毫秒


原帖: 0.013

被接受的答案: 0.020

使用counter函数: 0.010


当: 字符串列表长度=1000_000,线程数=1,循环次数=100,单位=毫秒


原帖: 99.387

被接受的答案: 89.848

使用counter函数: 59.183


结论: 性能提升的百分比非常小,甚至更慢(如果字符串列表长度很小)。通常,通过更复杂的收集器来减少已加载到内存的列表/集合的迭代是错误的做法,你不会获得太多的性能提升。如果存在性能问题,我们应该寻找其他地方进行优化。

以下是我使用工具Profiler进行性能测试的代码:(我不打算在此讨论如何进行性能测试。如果您对测试结果有疑问,请使用您信任的任何工具重新进行测试)

@Test
public void test_46539786() {
    final int strsLength = 1000_000;
    final int threadNum = 1;
    final int loops = 100;
    final int rounds = 3;

    final List<String> strs = IntStream.range(0, strsLength).mapToObj(i -> i % 2 == 0 ? i + " of " + i : i + " for " + i).toList();

    Profiler.run(threadNum, loops, rounds, "OP", () -> {
        List<Integer> wordsInStr = strs.stream().filter(t -> t.contains("of")).map(t -> t.split(" ").length).collect(Collectors.toList());
        List<String> linePortionAfterFor = strs.stream().filter(t -> t.contains("for")).map(t -> t.substring(t.indexOf("for")))
                .collect(Collectors.toList());

        assertTrue(wordsInStr.size() == linePortionAfterFor.size());
    }).printResult();

    Profiler.run(threadNum, loops, rounds, "Accepted answer", () -> {
        Splitter collect = strs.stream().collect(Collector.of(Splitter::new, Splitter::accept, Splitter::merge));
        assertTrue(collect.counts.size() == collect.words.size());
    }).printResult();

    final Function<String, Integer> counter = s -> {
        int count = 0;
        for (int i = 0, len = s.length(); i < len; i++) {
            if (s.charAt(i) == ' ') {
                count++;
            }
        }
        return count;
    };

    Profiler.run(threadNum, loops, rounds, "By the counter function", () -> {
        List<Integer> wordsInStr = strs.stream().filter(t -> t.contains("of")).map(counter).collect(Collectors.toList());
        List<String> linePortionAfterFor = strs.stream().filter(t -> t.contains("for")).map(t -> t.substring(t.indexOf("for")))
                .collect(Collectors.toList());

        assertTrue(wordsInStr.size() == linePortionAfterFor.size());
    }).printResult();
}

2
我认为,将.map(t -> t.split(" ").length)替换为.map(t -> 1+(int)t.chars() .filter(c -> c==' ').count())在处理大数据集时会比节省迭代次数更有影响,因为每种情况背后发生的事情不同... - Holger

1
你可以使用自定义收集器,并仅迭代一次:
 private static <T, R> Collector<String, ?, Pair<List<String>, List<Long>>> multiple() {

    class Acc {

        List<String> strings = new ArrayList<>();

        List<Long> longs = new ArrayList<>();

        void add(String elem) {
            if (elem.contains("of")) {
                long howMany = Arrays.stream(elem.split(" ")).count();
                longs.add(howMany);
            }
            if (elem.contains("for")) {
                String result = elem.substring(elem.indexOf("for"));
                strings.add(result);
            }

        }

        Acc merge(Acc right) {
            longs.addAll(right.longs);
            strings.addAll(right.strings);
            return this;
        }

        public Pair<List<String>, List<Long>> finisher() {
            return Pair.of(strings, longs);
        }

    }
    return Collector.of(Acc::new, Acc::add, Acc::merge, Acc::finisher);
}

使用方法如下:

Pair<List<String>, List<Long>> pair = Stream.of("t of r m", "t of r m", "nice for nice nice again")
            .collect(multiple());

0
如果您想通过列表获取1个流,您需要一种管理2种不同状态的方法,您可以通过将Consumer实现到新类中来实现此目的。
    class WordsInStr implements Consumer<String> {

      ArrayList<Integer> list = new ArrayList<>();

      @Override
      public void accept(String s) {
        Stream.of(s).filter(t -> t.contains("of")) //probably would be faster without stream here
            .map(t -> t.split(" ").length)
            .forEach(list::add);
      }
    }

    class LinePortionAfterFor implements Consumer<String> {

      ArrayList<String> list = new ArrayList<>();

      @Override
      public void accept(String s) {
        Stream.of(s) //probably would be faster without stream here
            .filter(t -> t.contains("for"))
            .map(t -> t.substring(t.indexOf("for")))
            .forEach(list::add);
      }
    }

    WordsInStr w = new WordsInStr();
    LinePortionAfterFor l = new LinePortionAfterFor();

    strs.stream()//stream not needed here
        .forEach(w.andThen(l));
    System.out.println(w.list);
    System.out.println(l.list);

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接