lambda -- Filter Java Stream to 1 and only 1 element

时间:2021-12-18 12:24:20

I am trying to use Java 8 Streams to find elements in a LinkedList. I want to guarantee, however, that there is 1 and only 1 match to the filter criteria.

Take this code:

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;
}
}

This code finds a User based on their ID. But there are no guarantees how many Users matched the filter.

Changing the filter line to:

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

Will throw a NoSuchElementException (good!)

I would like it to throw an error if there are multiple matches, though. Is there a way to do this?

asked Mar 27 at 17:25
ryvantage

2,58921335

   
count() is a terminal operation so you can't do that. The stream can't be used after. – ZouZou Mar 27 at 17:42 
   
Ok, thanks @ZouZou. I wasn't entirely certain what that method did. Why is there noStream::size ? –  ryvantage Mar 27 at 17:44
2  
@ryvantage Because a stream can only be used once: calculating its size means "iterating" over it and after that you can't use the stream any longer. –  assylias Mar 27 at 17:45
   
Wow. That one comment helped me understand Streams so much more than I did before... –  ryvantage Mar 27 at 17:50

6 Answers

up vote13down voteaccepted

Technically there's an ugly 'workaround' that involves peek() and an AtomicInteger, but really you shouldn't be using that.

What I do in these cases is just collecting it in a list, like this:

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);

I am not aware of a way to do this in the API, meanwhile I will work on another example involving a custom element.

Update, You should create your own Collector for this:

public static <T> Collector<T, List<T>, T> singletonCollector() {
return Collector.of(
ArrayList::new,
List::add,
(left, right) -> { left.addAll(right); return left; },
list -> {
if (list.size() != 1) {
throw new IllegalStateException();
}
return list.get(0);
}
);
}

What it does is:

  • It mimicks the Collectors.toList() collector.
  • It applies an extra finisher at the end, that throws an exception, or if no exception, returns the first element of the list.

Used as:

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

You can then customize this singletonCollector as much as you want, for example give the exception as argument in the constructor, tweak it to allow two values, and more.

New update, I revised my old answer once more for singletonCollector(), it can actually be obtained like this:

public static <T> Collector<T, ?, T> singletonCollector() {
return Collectors.collectingAndThen(
Collectors.toList(),
list -> {
if (list.size() != 1) {
throw new IllegalStateException();
}
return list.get(0);
}
);
}
answered Mar 27 at 17:33
skiwi

9,37332767

   
@ryvantage I updated my answer with how I would do it with writing the own custom Collector, which I believe is the correct method. –  skiwi Mar 27 at 18:03
   
what do you think of my reduction? –  assylias Mar 27 at 18:32
1  
@assylias I commented on your answer, I think this one i smore concise and more straight forward though and less error prone. –  skiwi Mar 27 at 18:38
   
The difference main between the two collectors is that the first will stop as soon as it finds a second matching item whereas the second will iterate through the whole list. – assylias Mar 27 at 22:57

Wow, such complexity! :-) The other answers that involve writing a custom Collector are probably more efficient (such as Louis Wasserman's, +1), but if you want brevity, I'd suggest the following:

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

Then verify the size of the result list.

answered Mar 28 at 15:39
Stuart Marks

12.5k11850

   
What's the point of limit(2) in this solution? What difference would it make whether the resulting list was 2 or 100? If it's greater than 1. –  ryvantage Mar 28 at 18:31
2  
It stops immediately if it finds a second match. This is what all the fancy collectors do, just using more code. :-) –  Stuart Marks Mar 29 at 3:24 

You could roll your own Collector for this:

<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);
}

...or using your own Holder type instead of AtomicReference. You can reuse that Collector as much as you like.

answered Mar 27 at 17:51
Louis Wasserman

71k894155

   
@skiwi's singletonCollector was smaller and easier to follow than this, that's why I gave him the check. But good to see consensus in the answer: a custom Collector was the way to go. –  ryvantage Mar 27 at 20:37
   
Fair enough. I was primarily aiming for speed, not conciseness. –  Louis WassermanMar 27 at 20:40
   
Yeah? Why is yours faster? –  ryvantage Mar 27 at 20:45
   
Mostly because allocating an all-up List is more expensive than a single mutable reference. –  Louis Wasserman Mar 27 at 20:52 
   
I was unable to get yours to compile –  ryvantage Mar 28 at 18:28

The "escape hatch" operation that lets you do weird things that are not otherwise supported by streams is to ask for an 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 has a convenience method to take an Iterator and get the only element, throwing if there are zero or multiple elements, which could replace the bottom n-1 lines here.

answered May 24 at 19:26
Brian Goetz

8,86341733

 

The exception is thrown by Optional#get, but if you have more than one element that won't help. You could collect the users in a collection that only accepts one item, for example:

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

which throws a java.lang.IllegalStateException: Queue full, but that feels too hacky.

Or you could use a reduction combined with an optional:

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();

The reduction essentially returns:

  • null if no user is found
  • the user if only one is found
  • throws an exception if more than one is found

The result is then wrapped in an optional.

But the simplest solution would probably be to just collect to a collection, check that its size is 1 and get the only element.

answered Mar 27 at 17:37
assylias

110k10153289

   
I would add an identity element (null) to prevent using get(). Sadly your reduce is not working as you think it does, consider a Stream that has null elements in it, maybe you think that you covered it, but I can be [User#1, null, User#2, null, User#3], now it will not throw an exception I think, unless I'm mistaken here. –  skiwiMar 27 at 18:36
   
@Skiwi if there are null elements the filter will throw a NPE first. –  assylias Mar 27 at 18:37

Have you tried this

    long c = users.stream().filter((user) -> user.getId() == 1).count();
if(c>1){
throw new IllegalStateException();
}
long count()
Returns the count of elements in this stream. This is a special case of a reduction and is equivalent to: return mapToLong(e -> 1L).sum(); This is a terminal operation.
answered Mar 28 at 7:03
pardeep131085

826134

   
It was said that count() is not good to use because it is a terminal operation. – ryvantage Mar 28 at 18:29