178

我正在阅读有关 Java 流的信息并在进行过程中发现新事物。我发现的新事物之一是peek()函数。我在 peek 上读到的几乎所有内容都表明它应该用于调试您的 Streams。

如果我有一个 Stream,其中每个 Account 都有一个用户名、密码字段和一个 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();
    })
4

8 回答 8

136

您必须了解的重要一点是流是由终端操作驱动的。终端操作确定是否必须处理所有元素或任何元素。collect处理每个项目的操作也是如此,而一旦findAny遇到匹配元素就可能停止处理项目。

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

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

此外,虽然流保证即使对于并行流也能保持某种操作组合的相遇顺序,但这些保证不适用于peek. 当收集到一个列表中时,结果列表将对有序并行流具有正确的顺序,但该peek操作可能会以任意顺序同时被调用。

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

此方法的存在主要是为了支持调试,您希望在元素流过管道中的某个点时查看它们

于 2015-11-10T17:50:30.683 回答
100

从中得出的关键结论:

不要以非预期的方式使用 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());
于 2015-11-10T17:55:22.227 回答
31

也许经验法则应该是,如果您确实在“调试”场景之外使用 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;

并且您最终不会迭代集合两次。

于 2016-11-11T12:52:01.600 回答
9

我想说的是,它peek提供了分散可以改变流对象或修改全局状态(基于它们)的代码的能力,而不是将所有内容都填充到传递给终端方法的简单或组合函数中。

现在的问题可能是:我们应该在函数式 Java 编程中改变流对象还是从函数内部更改全局状态

如果上述 2 个问题中的任何一个的答案是肯定的(或者:在某些情况下是肯定的),那么peek()肯定不仅仅是出于调试目的出于同样的原因,forEach()也不仅仅是出于调试目的

对我而言,在forEach()和之间进行选择时peek(),选择以下内容:我是否希望将变异流对象的代码附加到可组合对象,还是希望它们直接附加到流?

我认为peek()会更好地与 java9 方法配对。例如takeWhile(),可能需要根据已经变异的对象决定何时停止迭代,因此将其与它配对forEach()不会产生相同的效果。

PS我没有提到map()任何地方,因为如果我们想要改变对象(或全局状态),而不是生成新对象,它的工作方式与peek().

于 2018-06-21T14:14:55.693 回答
6

尽管我同意上面的大多数答案,但我有一种情况,使用 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());
于 2017-10-26T14:45:35.270 回答
4

很多答案都提出了很好的观点,尤其是 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::printlnlist::add某些答案中的示例)并返回添加了多少数据。目前的观察是,任何可以过滤元素的操作(例如过滤或跳过)似乎都会强制处理所有剩余的元素,但它不必保持这种状态。

于 2021-02-04T14:48:55.977 回答
3

尽管文档说明.peek“方法的存在主要是为了支持调试”,但我认为它具有普遍的相关性。一方面,文档说“主要”,因此为其他用例留出了空间。多年来它一直没有被弃用,关于它的移除的猜测在 IMO 是徒劳的。

我想说,在一个我们仍然必须处理副作用的方法的世界里,它有一个有效的位置和效用。流中有许多使用副作用的有效操作。在其他答案中已经提到了许多,我将在此处添加以在对象集合上设置标志,或将它们注册到注册表,然后在流中进一步处理的对象上。更不用说在流处理期间创建日志消息了。

我支持在单独的流操作中执行单独操作的想法,因此我避免将所有内容都推入 final .forEach. 我赞成使用 lambda.peek的等价物.map,除了调用副作用方法之外,它的唯一目的是返回传入的参数。.peek告诉我,一旦遇到这个操作,进去的东西也会出去,我不需要阅读 lambda 来找出。从这个意义上说,它简洁、富有表现力并提高了代码的可读性。

话虽如此,我同意使用 时的所有注意事项.peek,例如了解使用它的流的终端操作的影响。

于 2021-04-23T06:10:11.523 回答
2

功能解决方案是使帐户对象不可变。所以 account.login() 必须返回一个新的帐户对象。这意味着地图操作可以用于登录而不是 peek。

于 2019-02-28T20:11:36.870 回答