我应该返回一个集合还是一个流?

186

假设我有一个方法可以返回成员列表的只读视图:

class Team {
    private List<Player> players = new ArrayList<>();

    // ...

    public List<Player> getPlayers() {
        return Collections.unmodifiableList(players);
    }
}

进一步假设客户端只是立即一次性地迭代列表。可能是为了将玩家放入JList或类似的组件中。客户端不会存储对列表的引用以供以后查看!

考虑到这种常见情况,我应该返回一个流吗?

public Stream<Player> getPlayers() {
    return players.stream();
}

在Java中,返回流是否不符合惯用方式?流是被设计为总是在创建它们的同一表达式中“终止”的吗?


13
这个成语没有什么问题。毕竟,players.stream() 就是一个返回流给调用者的方法。真正的问题是,你是否想限制调用者只能遍历一次,并且阻止他通过 Collection API 访问你的集合?也许调用者只想将其 addAll 到另一个集合中? - Marko Topolnik
3
这完全取决于情况。你可以使用 collection.stream() 或者 Stream.collect() 函数。所以,这取决于你和调用该函数的人。 - Raja Anbazhagan
9个回答

258

答案总是“取决于”。这取决于返回的集合有多大,取决于结果是否随时间变化,以及返回结果的一致性有多重要。而且,这非常取决于用户可能如何使用答案。

首先,请注意您可以始终从Stream获取Collection,反之亦然:

// If API returns Collection, convert with stream()
getFoo().stream()...

// If API returns Stream, use collect()
Collection<T> c = getFooStream().collect(toList());
所以问题是,对于您的呼叫者来说哪个更有用。如果结果可能是无穷大,只有一个选择:Stream。如果结果可能非常大,您可能更喜欢Stream,因为一次性将其全部具体化可能没有任何价值,并且这样做可能会创建重大的堆压力。如果呼叫方要执行的操作只是迭代它(搜索、过滤、聚合),则应优先选择Stream,因为Stream已经内置了这些内容,而且没有必要具体化集合(特别是如果用户可能不处理整个结果)。即使您知道用户会多次迭代它或以其他方式保留它,仍然可能希望返回流,因为您选择放入其中的任何集合(例如ArrayList)可能不是他们想要的形式,然后调用者必须复制它。如果返回Stream,则他们可以使用collect(toCollection(factory))以恰好所需的形式获取它。上面“prefer Stream”情况大多源于Stream更加灵活的事实;您可以延迟绑定到如何使用它,而不会产生将其具体化为Collection的成本和约束。唯一必须返回Collection的情况是当存在强一致性要求时,您必须生成移动目标的一致快照。然后,您将希望将元素放入不会更改的集合中。因此,我会说大多数情况下Stream是正确的答案——它更加灵活,不会强加通常不必要的具体化成本,并且可以轻松地转换为您选择的Collection(如果需要)。但有时,您可能必须返回Collection(例如,由于强一致性要求),或者您可能希望返回Collection,因为您知道用户将如何使用它并且知道这对他们来说最方便的事情。如果您已经有一个适当的“待处理”Collection,并且似乎您的用户更愿意将其视为Collection与之交互,则只返回您拥有的内容是一个合理的选择(尽管不是唯一的选择,而且更脆弱)。

7
就像我之前所说的那样,有一些情况下不适用,比如当你想要返回一个移动目标的时间快照时,特别是在你有强一致性要求的情况下。但大多数情况下,流似乎是更通用的选择,除非你对它的使用方式有特定的了解。 - Brian Goetz
9
即使你把问题限制得如此狭窄,我仍然不同意你的结论。也许你认为创建一个流比用不可变包装器包装集合要昂贵得多?(即使你没有这样想,你从包装器中得到的流视图也不如原始视图好;因为UnmodifiableList没有覆盖spliterator()方法,你将失去所有并行性)。底线是:要注意熟悉偏差;你已经了解集合很多年了,这可能会让你不信任这个新手。 - Brian Goetz
6
@MarkoTopolnik 确定。我的目标是回答普遍的API设计问题,这已经成为FAQ了。关于成本,请注意,如果您没有一个可以返回或包装的具体化集合(OP有,但通常没有),在getter方法中具体化集合并不比返回流并让调用者具体化集合更便宜(当然,如果调用者不需要它或如果您返回ArrayList但调用者想要TreeSet,早期具体化可能会更昂贵)。但是Stream是新的,人们经常认为它比实际更贵。 - Brian Goetz
5
@MarkoTopolnik,虽然内存中运行是一个非常重要的应用场景,但还有其他一些情况也有很好的并行支持,例如非有序生成流(例如Stream.generate)。然而,在数据以随机延迟到达的反应式使用情况下,Streams并不适合。针对这种情况,我建议使用RxJava。 - Brian Goetz
6
我认为我们的观点并没有太大分歧,除非您希望我们将精力集中在略有不同的方面。 (我们已经习惯了这种情况;无法让所有人满意。)Streams 的设计中心集中在内存数据结构上;RxJava 的设计中心则专注于外部生成的事件。两者都是很好的库;但是当您试图将它们应用于远离其设计中心的情况时,它们都表现不佳。但仅仅因为锤子不适合绣花针工作,并不意味着锤子有任何问题。 - Brian Goetz
显示剩余20条评论

75

我有几个要补充的点,关于Brian Goetz优秀回答。 getter风格的方法调用返回Stream相当普遍。在Java 8 javadoc的Stream使用页面中查找“返回Stream的方法”(除了java.util.Stream),这些方法通常位于表示或包含多个值或聚合内容的类上。在这种情况下,API通常已经返回了集合或它们的数组。出于Brian在回答中提到的所有原因,添加返回Stream的方法非常灵活。许多这些类已经具有返回集合或数组的方法,因为这些类早于Streams API。如果你正在设计一个新的API,并且提供返回Stream的方法是有意义的,那么可能不需要再添加返回集合的方法。

Brian提到“材料化”值的成本。为了强调这一点,这里实际上有两个成本:将值存储在集合中的成本(内存分配和复制),以及首次创建值的成本。通过利用流的惰性行为,后者的成本通常可以降低或避免。一个很好的例子是java.nio.file.Files中的API:

static Stream<String>  lines(path)
static List<String>    readAllLines(path)

readAllLines不仅需要将整个文件内容保存在内存中以便将其存储到结果列表中,它还必须读取文件直到末尾才能返回列表。相反,lines方法可以在执行一些设置后立即返回,稍后再进行文件读取和行分割,或者根本不进行操作。例如,如果调用者只对前十行感兴趣,这将是一个巨大的优势:

try (Stream<String> lines = Files.lines(path)) {
    List<String> firstTen = lines.limit(10).collect(toList());
}

如果调用者过滤流以仅返回与模式匹配的行,就可以节省相当多的内存空间。

一种看起来正在兴起的习惯用法是将返回流的方法命名为它所表示或包含的事物名称的复数形式,而不使用get前缀。此外,当存在多个可能返回的值集时,例如某些同时包含属性和元素的对象,stream()是一个合理的流返回方法名称,您可能会提供两个返回流的API:

Stream<Attribute>  attributes();
Stream<Element>    elements();

3
好的。你能说得更多一些关于你看到的这种命名惯例是如何出现的,以及它正在获得多少关注(推广)吗?我喜欢采用这种命名规范,可以明显区分获取流和集合 —— 尽管我也经常期望通过 IDE 自动完成来告诉我可以获取什么。 - Joshua Goldberg
1
我也对那种命名惯例非常感兴趣。 - elect
6
JDK似乎已经采用了这种命名惯例,尽管并非完全如此。例如:Java 8中存在CharSequence.chars()和.codePoints()、BufferedReader.lines()以及Files.lines()。在Java 9中,新增了以下方法:Process.children()、NetworkInterface.addresses()、Scanner.tokens()、Matcher.results()和java.xml.catalog.Catalog.catalogs()。还添加了一些不使用这种惯例的返回流的方法,比如Scanner.findAll(),但是复数名词的惯例似乎已经在JDK中得到广泛使用。 - Stuart Marks

4

虽然一些知名回答者提供了很好的通用建议,但我惊讶地发现还没有人明确说明:

如果你已经手头有一个“实体化”的Collection(即在调用之前已经创建 - 如在给定示例中,它是一个成员字段),那么将其转换为Stream就没有意义。调用者可以轻松地自行完成这个过程。而如果调用者想要以原始形式消耗数据,则将其转换为Stream会强制他们进行冗余工作,重新实体化原始结构的副本。


2
这个答案几乎所有的内容都暴露了可疑的假设。返回集合,除非它已经是只读的或者你用只读视图包装它,否则调用者可以在你的控制之外改变集合,而流是只读视图。你似乎认为“转换”成流很昂贵;实际上并不是;它的代价不比包装成只读视图更高。你还似乎假设调用者总是需要重新生成它;这种情况很少见。(而且当他们这样做时,你不能保证他们想要的形式和你拥有的形式相同。) - Brian Goetz
感谢您的评论。您完全正确,我通常假设我们会包装成不可修改的形式,但我没有说明。我认为获取流并不昂贵;我只是认为放弃原始集合的功能而选择流可能不是最好的默认选择。返回流(当已经有一个实体化的集合时)保留更多的实现灵活性,代价是需要调用者进行冗余工作和空间,如果他们想要原始集合。我假设这种情况并不罕见,但这可能是我的错误。读者,你们的情况可能不同。 - Daniel Avery

2

流是否被设计为始终在创建它们的同一表达式中“终止”?

这是它们在大多数示例中使用的方式。

注意:返回流与返回迭代器并没有太大区别(尽管具有更多的表现力)。

在我看来,最好的解决方案是封装你所做的操作,并不返回集合。

例如:

public int playerCount();
public Player player(int n);

或者如果你想要对它们进行计数

public int countPlayersWho(Predicate<? super Player> test);

2
这个答案存在问题,它要求作者预测客户端想要执行的每一个操作,这将大大增加类中方法的数量。 - dkatzel
@dkatzel 这取决于最终用户是作者还是他们合作的人。如果最终用户不可知,则需要更一般的解决方案。您可能仍然希望限制对底层集合的访问。 - Peter Lawrey

2
如果流是有限的,并且返回的对象存在正常操作会抛出已检查异常,我总是返回一个集合。因为如果您要对每个可能抛出检查异常的对象执行某些操作,您将不喜欢这个流。流的一个真正缺陷在于它们无法优雅地处理受检异常。
现在,也许这是您不需要受检异常的迹象,这是公平的,但有时它们是不可避免的。

1
与集合不同,流具有附加特性。任何方法返回的流可能是:
  • 有限的或无限的
  • 并行或顺序的(具有默认全局共享线程池,可能影响应用程序的任何其他部分)
  • 有序或无序的
  • 保持引用以关闭或不关闭

这些差异在集合中也存在,但它们是显而易见的契约的一部分:

  • 所有集合都有大小,迭代器/可迭代对象可以是无限的。
  • 集合明确有序或无序
  • 并行性幸运地不是集合关心的问题,超出了线程安全的范畴
  • 集合通常也不可关闭,因此也不需要担心使用try-with-resources作为保护。
作为流的消费者(无论是从方法返回还是作为方法参数),这是一种危险且令人困惑的情况。为确保他们的算法行为正确,流的消费者需要确保算法对流特征没有错误的假设。而这是非常困难的事情。在单元测试中,这意味着您必须将所有测试重复执行,以使用相同的流内容,但使用以下流:
  • (有限,有序,顺序,要求关闭)
  • (有限,有序,并行,要求关闭)
  • (有限,无序,顺序,要求关闭)...
编写流的方法保护,如果输入流具有破坏算法的特性,则会抛出IllegalArgumentException,但这很困难,因为这些属性是隐藏的。
文档可以缓解问题,但它存在缺陷,经常被忽视,并且在流提供程序被修改时无法帮助。例如,请参见Java8文件的这些javadoc:
 /**
  * [...] The returned stream encapsulates a Reader. If timely disposal of
  * file system resources is required, the try-with-resources 
  * construct should be used to ensure that the stream's close 
  * method is invoked after the stream operations are completed.
  */
 public static Stream<String> lines(Path path, Charset cs)
 /**
  * [...] no mention of closing even if this wraps the previous method
  */
public static Stream<String> lines(Path path)

当上述问题都不重要时,通常是在流的生产者和消费者在同一代码库中,并且所有消费者都已知的情况下,才能将Stream作为方法签名中的有效选择。

在方法签名中使用其他数据类型更安全,并且具有显式契约(并且没有涉及隐式线程池处理),这使得无法意外地使用错误的假设来处理有序性、大小或并行性(以及线程池使用)的数据。


2
你对无限流的担忧是没有根据的;问题在于“我应该返回一个集合还是一个流”。如果集合是一种可能性,那么结果就是_根据定义_有限的。因此,担心调用者会冒着无限迭代的风险,_考虑到你本可以返回一个集合_,是没有根据的。这个答案中的其余建议只是错误的。听起来像是你遇到了一个过度使用Stream的人,而你正在另一个方向上过度旋转。可以理解,但是这是错误的建议。 - Brian Goetz

0

我认为这取决于你的情况。如果你让你的Team实现Iterable<Player>,那可能就足够了。

for (Player player : team) {
    System.out.println(player);
}

或者以函数式风格:

team.forEach(System.out::println);

但如果你想要一个更完整和流畅的API,那么流式处理可能是一个不错的解决方案。


1
请注意,在 OP 发布的代码中,玩家计数几乎没有用处,除了作为一个估计值(“现在有1034个玩家正在游戏中,点击此处开始!”)。这是因为你返回的是可变集合的不可变视图,所以你现在得到的计数可能与三微秒后的计数不相等。因此,虽然返回一个集合可以让你“轻松”地获得计数(实际上,stream.count() 也很容易),但那个数字对于除了调试或估计之外的任何事情都不是非常有意义的。 - Brian Goetz

-2
也许一个流工厂会是更好的选择。仅通过Stream公开集合的最大优势在于它更好地封装了您的领域模型数据结构。通过公开一个Stream,任何对您的领域类的使用都不可能影响List或Set的内部工作。
它还鼓励您的领域类的用户以更现代化的Java 8风格编写代码。通过保留现有的getter方法并添加新的返回Stream的getter方法,可以逐步将代码重构为这种风格。随着时间的推移,您可以重写遗留代码,直到最终删除所有返回List或Set的getter方法。一旦清除了所有遗留代码,这种重构就会感觉非常好!

7
为什么这段话要完全引用?是否有来源? - xeruf

-5
我可能会有2个方法,一个方法返回Collection,另一个方法返回Stream形式的集合。
class Team
{
    private List<Player> players = new ArrayList<>();

// ...

    public List<Player> getPlayers()
    {
        return Collections.unmodifiableList(players);
    }

    public Stream<Player> getPlayerStream()
    {
        return players.stream();
    }

}

这是两全其美的选择。客户端可以自行选择他们想要的 List 或 Stream,无需进行额外的对象创建来复制列表以获取 Stream。

这样也只会在 API 中添加一种方法,因此您不必过多地拥有太多方法。


1
因为他想要在两个选项之间做出选择,并询问了每个选项的利弊。此外,这也能够使每个人更好地理解这些概念。 - Libert Piou Piou
请不要这样做。想想那些API! - François Gautier

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