将Java Stream过滤为1个且仅有1个元素

332

我正在尝试使用Java 8的Stream来查找LinkedList中的元素。然而,我想要保证过滤条件只有一个匹配项。

看一下这段代码:

public static void main(String[] args) {

    LinkedList<User> users = new LinkedList<>();
    users.add(new User(1, "User1"));
    users.add(new User(2, "User2"));
    users.add(new User(3, "User3"));

    User match = users.stream().filter((user) -> user.getId() == 1).findAny().get();
    System.out.println(match.toString());
}

static class User {

    @Override
    public String toString() {
        return id + " - " + username;
    }

    int id;
    String username;

    public User() {
    }

    public User(int id, String username) {
        this.id = id;
        this.username = username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public int getId() {
        return id;
    }
}

这段代码根据用户ID查找一个User。但是不能保证有多少个User与筛选条件匹配。

将筛选条件的行改为:

User match = users.stream().filter((user) -> user.getId() < 0).findAny().get();

会抛出一个NoSuchElementException(很好!)

不过如果有多个匹配项,我希望它抛出一个错误。有没有办法做到这一点?


count() 是一个终端操作,因此您不能这样做。在此之后无法再使用该流。 - Alexis C.
好的,谢谢@ZouZou。我不是完全确定那个方法做了什么。为什么没有Stream::size - ryvantage
10
因为流(stream)只能使用一次:计算它的大小意味着对它进行“迭代”,在此之后,您将无法再使用该流。 - assylias
4
哇,那条评论帮助我更好地理解了“流”(Stream)…… - ryvantage
4
当你意识到需要使用LinkedHashSet(假设你需要保留插入顺序)或者HashSet时,你会明白这一点。如果你的集合只用于查找单个用户ID,那么为什么要收集所有其他项?如果可能总是需要查找某个唯一的用户ID,为什么要使用列表而不是集合?你在逆向编程。使用正确的集合来完成任务,避免这种头痛。 - smac89
显示剩余2条评论
24个回答

296

Create a custom Collector

public static <T> Collector<T, ?, T> toSingleton() {
    return Collectors.collectingAndThen(
            Collectors.toList(),
            list -> {
                if (list.size() != 1) {
                    throw new IllegalStateException();
                }
                return list.get(0);
            }
    );
}

我们使用Collectors.collectingAndThen来构建我们想要的Collector,方法如下:

  1. 使用Collectors.toList()收集器将对象收集到一个List中。
  2. 在最后应用额外的完成器,返回单个元素或抛出IllegalStateException(如果 list.size != 1)。

用法示例:

User resultUser = users.stream()
        .filter(user -> user.getId() > 0)
        .collect(toSingleton());

然后您可以自定义这个Collector,例如在构造函数中传递异常,调整它以允许两个值等等。

另一种解决方案 - 可能不太优雅:

您可以使用“解决方法”,其中包括peek()AtomicInteger,但实际上您不应该使用它。

相反,您可以像这样将其收集到List中:

LinkedList<User> users = new LinkedList<>();
users.add(new User(1, "User1"));
users.add(new User(2, "User2"));
users.add(new User(3, "User3"));
List<User> resultUserList = users.stream()
        .filter(user -> user.getId() == 1)
        .collect(Collectors.toList());
if (resultUserList.size() != 1) {
    throw new IllegalStateException();
}
User resultUser = resultUserList.get(0);

43
如果你已经使用Google Guava,Guava的Iterables.getOnlyElement可以缩短这些解决方案并提供更好的错误消息。以上是给同样使用Google Guava的读者的提示。 - Tim Büthe
2
我将这个想法封装成了一个类 - https://gist.github.com/denov/a7eac36a3cda041f8afeabcef09d16fc - denov
2
自定义收集器仍会收集所有项目,时间复杂度为O(n),有没有捷径可以不这么做呢?获取单个项只需1步,检查另一个项是否存在也是1步,无论过滤流中还有多少个项目。 - TWiStErRob
2
@skiwi:答案中的代码绝对与您编写的代码相同。编辑器所做的只是清理您的帖子,仅删除了早期版本的singletonCollector()定义(该版本已过时),并将其重命名为toSingleton()。我的Java流专业知识有点生疏,但是我认为重命名很有帮助。审查此更改花费了我不到2分钟的时间。如果您没有时间审核编辑,请问您是否可以建议其他人在未来进行此操作,也许在Java聊天室中? - Martijn Pieters
12
我必须说,我真的不喜欢 toSingleton 这个名字,因为它会误导人。它返回的不是一个 Singleton,而是我认为在编程中是一个保留词。这是一个“单一元素”或“单一实例”。 - Javo
显示剩余16条评论

174

为了完整起见,这是与 @prunge 精彩回答对应的 "单行代码":

User user1 = users.stream()
        .filter(user -> user.getId() == 1)
        .reduce((a, b) -> {
            throw new IllegalStateException("Multiple elements: " + a + ", " + b);
        })
        .get();

从流中获取唯一匹配的元素,如果流为空,则抛出 NoSuchElementException;如果流包含多个匹配的元素,则抛出 IllegalStateException

这种方法的变体避免了早期抛出异常,而是将结果表示为一个 Optional,如果有零个或多个元素,则该值包含唯一的元素,否则为空。

Optional<User> user1 = users.stream()
        .filter(user -> user.getId() == 1)
        .collect(Collectors.reducing((a, b) -> null));

12
我喜欢这个答案的初始方法。为了定制目的,可以将最后的 get() 转换为 orElseThrow() - arin
7
我喜欢这个代码的简洁性,以及它避免在每次调用时创建不必要的List实例的特点。 - LordOfThePigs
1
如果您的用例允许流为空,则在链的末尾省略.get(),然后您将得到一个Optional,如果流为空,则该Optional将为空;如果流不为空,则Optional将包含单个元素。 - Matthew Wise
我认为这不是一个好的解决方案,因为在错误消息中,我们只会包含前两个无效的元素,而不会包括超过两个元素的值。 - Tomasz S
2
注意:Stream#reduce(...)的行为与Stream#collect(Collectors.reducing(...))不同:两者都使流返回一个Optional,但是当二元运算符返回null时,Stream#reduce将抛出NPE。因此,答案中的代码可以工作,但如果混淆了这两个代码片段,则无法正常工作。 - Qw3ry
显示剩余2条评论

97

其他涉及编写自定义Collector的答案可能更有效(例如Louis Wasserman's,+1),但如果您想要简洁,我建议使用以下方法:

List<User> result = users.stream()
    .filter(user -> user.getId() == 1)
    .limit(2)
    .collect(Collectors.toList());

然后验证结果列表的大小。

if (result.size() != 1) {
  throw new IllegalStateException("Expected exactly one user but got " + result);
}
User user = result.get(0);

7
在这个解决方案中,“limit(2)”有什么作用?如果结果列表的长度为2或100,会有什么区别? 如果长度大于1,会有什么影响? - ryvantage
25
如果发现第二个匹配项,它将立即停止。这就是所有高级收集器所做的,只不过使用了更多的代码。 :-) - Stuart Marks
11
如何添加 Collectors.collectingAndThen(toList(), l -> { if (l.size() == 1) return l.get(0); throw new RuntimeException(); }) - Lukas Eder
1
Javadoc 对 limit 的参数解释是:maxSize: the number of elements the stream should be limited to。因此,应该使用 .limit(1) 而不是 .limit(2),对吗? - alexbt
10
问题陈述是确保有且仅有一个(不多也不少)匹配元素。在我的代码中,可以测试 result.size() 确保它等于 1。如果是 2,则有多个匹配,因此这是一个错误。如果代码改为使用 limit(1),则多个匹配将导致单个元素,这无法区分是否恰好有一个匹配。这将忽略问题提出者担心的一种错误情况。 - Stuart Marks
@StuartMarks 哦,明白了,我不知道为什么会认为 limit(...) 在超出限制时会抛出异常。谢谢。 - alexbt

92

Guava提供了MoreCollectors.onlyElement()方法,可以正确地执行此操作。但如果您必须自己做,请为此编写自己的Collector

<E> Collector<E, ?, Optional<E>> getOnly() {
  return Collector.of(
    AtomicReference::new,
    (ref, e) -> {
      if (!ref.compareAndSet(null, e)) {
         throw new IllegalArgumentException("Multiple values");
      }
    },
    (ref1, ref2) -> {
      if (ref1.get() == null) {
        return ref2;
      } else if (ref2.get() != null) {
        throw new IllegalArgumentException("Multiple values");
      } else {
        return ref1;
      }
    },
    ref -> Optional.ofNullable(ref.get()),
    Collector.Characteristics.UNORDERED);
}

...或者使用您自己的Holder类型代替AtomicReference。 您可以随意重用该Collector


@skiwi的singletonCollector比这个更简洁易懂,所以我给了他赞。但是看到答案都认为自定义Collector是正确的选择也很好。 - ryvantage
1
好的,我主要是追求速度,而不是简洁。 - Louis Wasserman
1
是吗?为什么你的更快? - ryvantage
3
大多数情况下,分配一个完整的List比分配一个可变引用更昂贵。 - Louis Wasserman
2
@LouisWasserman,关于MoreCollectors.onlyElement()的最终更新句应该放在第一位(也许是唯一的 :) )。 - Piotr Findeisen
显示剩余4条评论

82

使用Guava的MoreCollectors.onlyElement() (源代码)。

它会执行您想要的操作,并在流包含两个或多个元素时抛出IllegalArgumentException,并在流为空时抛出NoSuchElementException

用法:

import static com.google.common.collect.MoreCollectors.onlyElement;

User match =
    users.stream().filter((user) -> user.getId() < 0).collect(onlyElement());

2
其他用户请注意:MoreCollectors 是尚未发布的版本 21 的一部分(截至2016年12月)。 - qerub
4
这个答案应该放在更上面。 - Emdadul Sawon

35

如果您需要进行流不支持的奇怪操作,可以通过请求一个Iterator来使用“逃生口”操作:

Iterator<T> it = users.stream().filter((user) -> user.getId() < 0).iterator();
if (!it.hasNext()) {
    throw new NoSuchElementException();
} else {
    result = it.next();
    if (it.hasNext()) {
        throw new TooManyElementsException();
    }
}

Guava有一个便捷方法,可以获取Iterator的唯一元素,并在没有或多个元素时抛出异常,这可以替换此处底部的 n-1 行。


6
Guava的方法:Iterators.getOnlyElement(Iterator<T> iterator)。 - anre

27

更新

@Holger在评论中提出了很好的建议:

Optional<User> match = users.stream()
              .filter((user) -> user.getId() > 1)
              .reduce((u, v) -> { throw new IllegalStateException("More than one ID found") });

原始回答

Optional#get抛出异常,但如果你有多个元素,这并没有什么帮助。你可以将用户收集到只接受一个项的集合中,例如:

User match = users.stream().filter((user) -> user.getId() > 1)
                  .collect(toCollection(() -> new ArrayBlockingQueue<User>(1)))
                  .poll();

可以使用一个带有可选项的缩减操作:

另一种方法是抛出java.lang.IllegalStateException: Queue full异常,但这感觉过于hacky。

User match = Optional.ofNullable(users.stream().filter((user) -> user.getId() > 1)
                .reduce(null, (u, v) -> {
                    if (u != null && v != null)
                        throw new IllegalStateException("More than one ID found");
                    else return u == null ? v : u;
                })).get();

这个缩减方法基本上返回:

  • 如果没有找到用户,则返回null
  • 如果只找到一个用户,则返回该用户
  • 如果找到多个用户,则抛出异常

结果然后被包装在optional中。

但最简单的解决方案可能是将其收集到一个集合中,检查其大小是否为1并获取唯一元素。


1
我会添加一个身份元素(null)以防止使用 get()。遗憾的是,您的 reduce 并不像您想象的那样工作,请考虑一下其中包含 null 元素的 Stream,也许您认为已经覆盖了它,但我可以是 [User#1, null, User#2, null, User#3],现在它不会抛出异常,除非我在这里弄错了。 - skiwi
2
@Skiwi 如果有空元素,过滤器会首先抛出 NPE。 - assylias
3
由于您知道流无法将 null 传递给缩减函数,删除身份值参数将使处理函数中的整个 null 处理变得过时:reduce((u,v) -> { throw new IllegalStateException("More than one ID found");}) 完成了工作,甚至更好,它已经返回了一个 Optional,省去了在结果上调用 Optional.ofNullable 的必要性。 - Holger

22

我认为这种方式更加简单:

User resultUser = users.stream()
    .filter(user -> user.getId() > 0)
    .findFirst().get();

14
当发现有多个结果时,需要抛出异常。目前只找到了第一个结果。请注意,在抛出异常时不要改变原来的意思。 - lczapski
这是不好的实践。如果有两个或更多对象,它会导致不确定性行为。整个JDK的findFirst都是一个坏主意。 - Radek Postołowicz
@RadekPostołowicz 1) 如果它是并行流,那么它不是确定性的吗?2) 如果有多个符合过滤条件的项目,您有多在意它是哪一个吗?(称其为“第一个”可能是个坏主意)3) 由于lczapski所说的原因,这篇文章都不适用。 - Jacob Zimmerman
它是非确定性的,因为它选择第一个元素,而可能有许多元素。如果没有明确排序,它只是随机选择第一个。 - Radek Postołowicz

19

使用 reduce

这是我发现的更简单和灵活的方法( 基于 @prunge 的回答)

Optional<User> user = users.stream()
        .filter(user -> user.getId() == 1)
        .reduce((a, b) -> {
            throw new IllegalStateException("Multiple elements: " + a + ", " + b);
        })

这样可以获得以下结果:

  • 如果存在,您将获得Optional对象;如果不存在,则为Optional.empty()
  • 如果有多个元素,则会抛出异常(并且可能包含您自定义的类型/消息)

4
这显然是这个页面上最优雅的解决方案。 - LordOfThePigs
@LordOfThePigs 谢谢,鉴于这个页面上还有 Brian Goetz 的答案,这真的意义非凡;-) - Fabio Bonfante

17

另一种选择是使用缩减(reduction):(此示例使用字符串,但同样适用于包括User在内的任何对象类型)

List<String> list = ImmutableList.of("one", "two", "three", "four", "five", "two");
String match = list.stream().filter("two"::equals).reduce(thereCanBeOnlyOne()).get();
//throws NoSuchElementException if there are no matching elements - "zero"
//throws RuntimeException if duplicates are found - "two"
//otherwise returns the match - "one"
...

//Reduction operator that throws RuntimeException if there are duplicates
private static <T> BinaryOperator<T> thereCanBeOnlyOne()
{
    return (a, b) -> {throw new RuntimeException("Duplicate elements found: " + a + " and " + b);};
}

那么对于 User 的情况,你会有:

User match = users.stream().filter((user) -> user.getId() < 0).reduce(thereCanBeOnlyOne()).get();

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