在Java Streams中,peek方法是否只用于调试?

218

我正在学习Java Streams并随着了解的深入而发现新东西。我找到的其中一件新事物是peek()函数。几乎所有我读到的有关peek()的内容都说它应该用于调试你的Streams。

如果我有一个Stream,其中每个账户都有用户名、密码字段以及login()和loggedIn()方法。

我也有

Consumer<Account> login = account -> account.login();

Predicate<Account> loggedIn = account -> account.loggedIn();

为什么这会是件糟糕的事情呢?

List<Account> accounts; //assume it's been setup
List<Account> loggedInAccount = 
accounts.stream()
    .peek(login)
    .filter(loggedIn)
    .collect(Collectors.toList());

据我所知,这段代码完全实现了它的预期功能。它:

  • 获取账户列表
  • 尝试登录每个账户
  • 过滤掉未登录的任何账户
  • 将已登录的账户收集到一个新列表中

这样做有什么缺点吗?有没有任何不应该继续进行的原因?最后,如果不使用这种解决方案,则使用什么其他解决方案?

原始版本使用.filter()方法如下:

.filter(account -> {
        account.login();
        return account.loggedIn();
    })

71
每当我发现需要多行lambda时,我就将这些行移至私有方法,并传递该方法的引用,而不是使用lambda。 - VGR
1
你的意图是什么 - 你想记录所有账户并根据它们是否已登录进行过滤(这可能是微不足道的真实)?还是,你想先将它们登录,然后根据它们是否已登录进行过滤?我按照这个顺序问这个问题,因为 forEach 可能是你想要的操作,而不是 peek。仅仅因为它在 API 中存在并不意味着它不能被滥用(比如 Optional.of)。 - Makoto
13
请注意,您的代码可以只是 .peek(Account::login).filter(Account::loggedIn);没有理由编写一个只调用另一个方法的 Consumer 和 Predicate。 - Joshua Taylor
2
还要注意,流API在行为参数中明确反对副作用 - Didier L
8
有用的消费者总是有副作用,当然不会被打击。实际上,在同一部分中提到了这一点:“少数流操作(例如forEach()peek())只能通过副作用进行操作;这些应该小心使用。”我的评论更多是为了提醒不要用map()filter()等其他操作来替换设计用于调试目的的peek操作。 - Didier L
显示剩余6条评论
10个回答

164
重要的是要理解的是,流是由终端操作驱动的。终端操作确定是否必须处理所有元素或仅任意一个。因此,collect是处理每个项的操作,而findAny可能在遇到匹配元素后停止处理项。

count()在可以确定流大小而无需处理项时可能不会处理任何元素。由于这是Java 8中未进行的优化,但在Java 9中将进行优化,因此当您切换到Java 9并且有代码依赖于count()处理所有项时,可能会出现意外情况。这也与其他实现相关的细节有关,例如,在Java 9中,即使是引用实现,也无法预测与limit组合时无限流源的大小,而没有基本限制防止这种预测。

由于peek允许“在从结果流中消耗元素时对每个元素执行提供的操作”,因此它不强制处理元素,但将根据终端操作需要执行该操作。这意味着,如果需要特定的处理(例如要对所有元素应用操作),则必须非常小心地使用它。如果终端操作保证处理所有项,则它可以工作,但即使如此,您也必须确保下一个开发人员不会更改终端操作(或者您忘记了这个微妙的方面)。

此外,尽管流保证对于操作的某些组合,甚至针对并行流,都保持相遇顺序,但这些保证不适用于peek。当收集到列表中时,结果列表将对有序的并行流具有正确的顺序,但是peek操作可能会以任意顺序和并发方式调用。

因此,您可以使用peek最有用的事情就是找出是否已处理流元素,这正是API文档所说的:

该方法主要用于支持调试,其中您希望在管道中的某个特定点看到元素流过的情况。


1
OP的使用情况,现在或将来,会有任何问题吗?他的代码总是按照他的意愿执行吗? - ZhongYu
11
据我所见,这个确切形式没有问题。但是,正如我试图解释的那样,以这种方式使用它意味着您必须记住这个方面,即使您在一两年后回到代码中将“功能请求9876”纳入代码中... - Holger
1
"peek操作可能会以任意顺序和并发方式被调用。这个说法是否违反了它们关于peek工作规则的规定,例如“随着元素被消耗”?" - Jose Martinez
6
“@Jose Martinez:它说的是‘在结果流中元素被消耗时’,这不是终端动作而是处理过程,即使最终动作以非顺序方式消耗元素,只要最终结果始终如一就可以。但我认为API注释中的短语‘看作元素通过管道中的某个点流过’更能描述它。” - Holger

112

以下是要点:

不要以意外的方式使用API,即使它达到了您的直接目标。这种方法可能会在将来变得无效,而且对于未来的维护者也不清楚。


将其分解为多个操作并没有什么问题,因为它们是独立的操作。但是,以一种不明确和非预期的方式使用API是有害的,如果这种特殊行为在Java的未来版本中被修改,可能会产生后果。

在此操作中使用 forEach 将使维护者清楚地了解,在 accounts 的每个元素上都存在 预期的 副作用,并且您正在执行某些可能会改变它的操作。

从传统意义上讲,这种做法更加常规,因为 peek 是一个中间操作,只有在终端操作运行时才对整个集合进行操作,而 forEach 确实是一个终端操作。 这样,您就可以围绕代码的行为和流程提出强有力的论据,而不是询问在此上下文中,peek 是否会像 forEach 一样运行。

accounts.forEach(a -> a.login());
List<Account> loggedInAccounts = accounts.stream()
                                         .filter(Account::loggedIn)
                                         .collect(Collectors.toList());

6
如果您在预处理步骤中执行登录,则根本不需要流。您可以直接在源集合上执行 forEachaccounts.forEach(a -> a.login()); - Holger
1
@Holger:非常好的观点。我已经将其纳入答案中。 - Makoto
2
@Adam.J:没错,我的回答更关注你标题中包含的一般问题,即这种方法是否真的只用于调试,通过解释该方法的各个方面来说明。而这个答案更侧重于你实际的使用情况以及如何代替它。因此,你可以说,它们共同提供了完整的图片。首先,原因是这不是预期的用途,其次是结论,不要坚持不合适的用途,而要代替做什么。后者对你来说将有更多的实际用途。 - Holger
2
当然,如果“login()”方法返回一个表示成功状态的布尔值,则会简单得多... - Holger
3
这就是我想要的。如果login()返回一个布尔值,你可以将其用作谓词,这是最清晰的解决方案。它仍然具有副作用,但只要它是非干扰性的,即一个Accountlogin过程对另一个Account的登录过程没有影响,那么这就可以接受。 - Holger
显示剩余11条评论

33

或许一个经验法则应该是,如果你在“调试”场景之外使用 peek,那么只有当你确定终止和中间过滤条件时才能这样做。例如:

return list.stream().map(foo->foo.getBar())
                    .peek(bar->bar.publish("HELLO"))
                    .collect(Collectors.toList());

看起来有一个有效的情况,您希望在一次操作中将所有的Foos转换为Bars并向它们打招呼。

似乎比像这样的东西更高效和优雅:

List<Bar> bars = list.stream().map(foo->foo.getBar()).collect(Collectors.toList());
bars.forEach(bar->bar.publish("HELLO"));
return bars;

而且你不会两次迭代集合。


5
迭代两次的时间复杂度为O(2n) =~ O(n)。由于此操作可能导致性能问题的几率很小,因此您不必担心。但是,如果不使用peek函数,您可以提高代码的清晰度。 - GauravJ
2
事实上,两个迭代版本可能比单个流操作更好,因为它们有两个不同的目的。在具有运行时优化器的环境中预测性能是非常困难的。 - undefined

11
很多答案提出了很好的观点,特别是Makoto的(被接受的)答案详细描述了可能存在的问题。但没有人真正展示它如何可能出错:
[1]-> IntStream.range(1, 10).peek(System.out::println).count();
|  $6 ==> 9

无输出。

[2]-> IntStream.range(1, 10).filter(i -> i%2==0).peek(System.out::println).count();
|  $9 ==> 4

输出数字2、4、6、8。

[3]-> IntStream.range(1, 10).filter(i -> i > 0).peek(System.out::println).count();
|  $12 ==> 9

输出1到9的数字。

[4]-> IntStream.range(1, 10).map(i -> i * 2).peek(System.out::println).count();
|  $16 ==> 9

没有输出。

[5]-> Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9).peek(System.out::println).count();
|  $23 ==> 9

无输出。

[6]-> Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9).stream().peek(System.out::println).count();
|  $25 ==> 9

无输出。
[7]-> IntStream.range(1, 10).filter(i -> true).peek(System.out::println).count();
|  $30 ==> 9

输出数字1到9。

[1]-> List<Integer> list = new ArrayList<>();
|  list ==> []
[2]-> Stream.of(1, 5, 2, 7, 3, 9, 8, 4, 6).sorted().peek(list::add).count();
|  $7 ==> 9
[3]-> list
|  list ==> []

你明白这个想法。

这些示例在 jshell(Java 15.0.2)中运行,并模拟将数据转换的用例(例如,将 System.out::println 替换为 list :: add ,如某些答案中所做的那样),并返回添加了多少数据。 目前的观察结果是,任何可以过滤元素的操作(例如filter或skip)似乎都需要处理所有剩余的元素,但不必保持这种状态。


我不确定你的结果是可靠的。因为 .count 终止操作也会产生输出,JShell 可能正在用它来覆盖 .peek 操作的输出。如果您将 .count 替换为另一个不产生输出的终止操作,则它可以正常工作,例如: jshell> IntStream.range(1,10).peek(System.out::println).forEach(i->{}) - ThomasH
2
在这里作为终端操作的count正是我想要展示的问题。count并不关心你的实际元素,这就是为什么它们有时不会被处理而只计算了数量的原因。 - René
啊,好的,现在我明白了。 - ThomasH
只是想知道count()方法如何在不实际计算流中元素数量的情况下工作,我强烈认为这是因为IntStreamStream.of设置了标记StreamOpFlag.SIZED。更糟糕的是,在JVM版本之间,Stream.of的行为有所不同:在1.8中它曾经是一个普通的流,但在一些较新的版本中它变成了SIZED,如果我没记错的话。 - Xobotun
2
@Xobotun 如其他(更早的)答案中所述,count() 在 Java 9 中发生了变化,而不是 Stream.of() 的行为。 - undefined

8

虽然我同意上面大部分答案,但是有一个情况使用peek似乎是最干净的方法。

类似于您的用例,假设您只想过滤活跃帐户,然后对这些帐户执行登录操作。

accounts.stream()
    .filter(Account::isActive)
    .peek(login)
    .collect(Collectors.toList());

使用 Peek 方法可以避免在不必要的情况下重复调用集合,而无需两次迭代:

accounts.stream()
    .filter(Account::isActive)
    .map(account -> {
        account.login();
        return account;
    })
    .collect(Collectors.toList());

5
你所需做的就是正确实现登录方法。我真的不明白为什么peek是最清晰的方式。如果阅读你的代码的人不知道你实际上正在误用API,该怎么办?良好、干净的代码不应该让读者对代码作出任何假设。 - kaba713
我认为你需要在.peek操作中限定方法引用,例如Account::login,这样它才能正常工作。 - ThomasH
1
我同意使用.peek而不是.map替代方案更加简洁、表达力更强且易于理解。在.map中,lambda仅用于返回传入的对象,而.peek则可以自行完成此操作。当我读取操作名称时,我知道这一点,而不必检查lambda以找出它。 - ThomasH

8
我认为peek提供了分散代码的能力,这些代码可以改变流对象或者基于它们修改全局状态,而不是将所有东西都塞进传递给终端方法的简单或组合函数中。
现在的问题可能是:在函数式java编程中,我们应该从函数内部改变流对象或者改变全局状态吗?
如果以上两个问题的答案是肯定的(或者在某些情况下是肯定的),那么peek()绝对不仅仅用于调试目的,这与forEach()的情况一样。
当我在forEach()peek()之间进行选择时,我要考虑以下几点:我想要改变流对象的代码片段(或者改变全局状态)附加到可组合的对象上,还是直接附加到流上?
我认为peek()与java9的方法更配对。例如,takeWhile()可能需要根据已经变异的对象来决定何时停止迭代,因此与forEach()配对将没有相同的效果。
P.S. 我没有在任何地方提到map(),因为在我们想要改变对象(或全局状态)而不是生成新对象的情况下,它的作用与peek()完全相同。

1
根本不是真的。根据其JavaDocs,中间Stream操作java.util.Stream.peek()“主要存在于支持调试”目的。 - Hannes Schneidermayer

8
尽管 .peek 的文档注释说该方法主要用于调试,但我认为它具有普适性。因为文档中也提到 "主要",所以留下了其他用例的余地。它也没有被弃用多年,而且我认为对其删除的猜测是徒劳的。
我认为,在我们仍然需要处理副作用方法的世界中,.peek 有其重要的地位和用途。在流中有许多有效的操作使用了副作用。许多已经在其他答案中提到,我只想补充一下,例如在一组对象上设置标志或将它们注册到一个注册表中,这些对象随后在流中进一步处理。更不用说在流处理期间创建日志消息了。
我支持在不同的流操作中有单独的行为,所以我避免将所有内容都推入最终的 .forEach 中。我更喜欢使用 .peek 而不是一个等效的带有 lambda 的 .map,该 lambda 的唯一目的是调用副作用方法并返回传入的参数。 .peek 告诉我,当我遇到此操作时,输入的内容也会立即输出,我不需要阅读 lambda 才能找出。从这个意义上说,它简洁、表达力强,提高了代码的可读性。
话虽如此,我同意在使用 .peek 时需要考虑到所有的因素,例如要注意流的终止操作对其影响。

这是一个非常好的回答!非常感谢您详细阐述了您的想法。 - chaotic3quilibrium

2

函数式的解决方案是使账户对象不可变。因此,account.login()必须返回一个新的账户对象。这意味着可以使用map操作来进行登录,而不是peek。


1
为了消除警告,我使用函数对象tee,以Unix' tee命名:
public static <T> Function<T,T> tee(Consumer<T> after) {
    return arg -> {
        f.accept(arg);
        return arg;
    };
}

您可以替换:
  .peek(f)

使用

  .map(tee(f))

0

看起来需要一个帮助类:

public static class OneBranchOnly<T> {
    public Function<T, T> apply(Predicate<? super T> test,
                                Consumer<? super T> t) {
        return o -> {
            if (test.test(o)) t.accept(o);
            return o;
        };
    }
}

然后用 map 替换 peek
.map(new OneBranchOnly< Account >().apply(                   
                    account -> account.isTestAccount(),
                    account -> account.setName("Test Account"))
)

结果:只有测试账户被重命名的账户集合(没有保留任何引用)


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