17

I have a stream of strings:

Stream<String> stream = ...;

I want to construct a string which concatenates these items with , as a separator. I do this as following:

stream.collect(Collectors.joining(","));

Now I want add a prefix [ and a suffix ] to this output only if there were multiple items. For example:

  • a
  • [a,b]
  • [a,b,c]

Can this be done without first materializing the Stream<String> to a List<String> and then checking on List.size() == 1? In code:

public String format(Stream<String> stream) {
    List<String> list = stream.collect(Collectors.toList());

    if (list.size() == 1) {
        return list.get(0);
    }
    return "[" + list.stream().collect(Collectors.joining(",")) + "]";
}

It feels odd to first convert the stream to a list and then again to a stream to be able to apply the Collectors.joining(","). I think it's suboptimal to loop through the whole stream (which is done during a Collectors.toList()) only to discover if there is one or more item(s) present.

I could implement my own Collector<String, String> which counts the number of given items and use that count afterwards. But I am wondering if there is a directer way.

This question intentionally ignores there case when the stream is empty.

John Doe
  • 215
  • 3
  • 7
  • "construct a string" or rather "construct a stream" ? – LuCio Oct 05 '18 at 19:32
  • The Streams have the count() method that gives the number of elements in the list, that way you don't have to materialize the stream, rather use it instead of list.size() – Aman J Oct 05 '18 at 19:35
  • 5
    @AmanJ `count()` is a [terminal operation](https://docs.oracle.com/javase/10/docs/api/java/util/stream/package-summary.html). After that the `stream` cannot be used anymore, it's consumed. – LuCio Oct 05 '18 at 19:36
  • 4
    There is no more direct way than making your own collector. – Louis Wasserman Oct 05 '18 at 19:57
  • 3
    it's a bit out of scope of your question but you can refactor last line of your code: stream.collect(Collectors.joining("-", "[", "]")); =)) – star67 Oct 05 '18 at 20:00
  • Your code is wrong, you can't collect a stream twice. You should stream the list and then collect. – fps Oct 06 '18 at 02:51

3 Answers3

7

Yes, this is possible using a custom Collector instance that will use an anonymous object with a count of items in the stream and an overloaded toString() method:

public String format(Stream<String> stream) {
    return stream.collect(
            () -> new Object() {
                StringJoiner stringJoiner = new StringJoiner(",");
                int count;

                @Override
                public String toString() {
                    return count == 1 ? stringJoiner.toString() : "[" + stringJoiner + "]";
                }
            },
            (container, currentString) -> {
                container.stringJoiner.add(currentString);
                container.count++;
            },
            (accumulatingContainer, currentContainer) -> {
                accumulatingContainer.stringJoiner.merge(currentContainer.stringJoiner);
                accumulatingContainer.count += currentContainer.count;
            }
                         ).toString();
}

Explanation

Collector interface has the following methods:

public interface Collector<T,A,R> {
    Supplier<A> supplier();
    BiConsumer<A,T> accumulator();
    BinaryOperator<A> combiner();
    Function<A,R> finisher();
    Set<Characteristics> characteristics();
}

I will omit the last method as it is not relevant for this example.

There is a collect() method with the following signature:

<R> R collect(Supplier<R> supplier,
              BiConsumer<R, ? super T> accumulator,
              BiConsumer<R, R> combiner);

and in our case it would resolve to:

<Object> Object collect(Supplier<Object> supplier,
              BiConsumer<Object, ? super String> accumulator,
              BiConsumer<Object, Object> combiner);
  • In the supplier, we are using an instance of StringJoiner (basically the same thing that Collectors.joining() is using).
  • In the accumulator, we are using StringJoiner::add() but we increment the count as well
  • In the combiner, we are using StringJoiner::merge() and add the count to the accumulator
  • Before returning from format() function, we need to call toString() method to wrap our accumulated StringJoiner instance in [] (or leave it as is is, in case of a single-element stream

The case for an empty case could also be added, I left it out in order not to make this collector more complicated.

syntagma
  • 23,346
  • 16
  • 78
  • 134
  • The same can be achieved using `Stream.reduce(...)` where the `identity` is `new Object() { ... }` and the `accumulator ` does `return container;` and then `combiner` does `return accumulatingContainer;` But is's longer than your solution, so your solution is still preferable. – LuCio Oct 06 '18 at 19:32
  • @LuCio …and it would be broken by definition, as `reduce` does not work with mutable containers. That’s precisely what `collect` is for. – Holger May 21 '19 at 10:59
  • @Holger: You're right. The task from the question needs a [mutable reduction](https://docs.oracle.com/en/java/javase/12/docs/api/java.base/java/util/stream/package-summary.html#MutableReductionl). And the documentation says: _The mutable reduction operation is called `collect()`, as it collects together the desired results into a result container_. My approach misuses the ordinary [reduction operation](https://docs.oracle.com/en/java/javase/12/docs/api/java.base/java/util/stream/package-summary.html#Reduction) since the `accumulator` wraps a container. - Thank you for the clarification. – LuCio May 24 '19 at 20:12
2

There is already an accepted answer and I upvoted it too.

Still I would like to offer potentially another solution. Potentially because it has one requirement:
The stream.spliterator() of Stream<String> stream needs to be Spliterator.SIZED.

If that applies to your case, you could use also this solution:

  public String format(Stream<String> stream) {
    Spliterator<String> spliterator = stream.spliterator();
    StringJoiner sj = spliterator.getExactSizeIfKnown() == 1 ?
      new StringJoiner("") :
      new StringJoiner(",", "[", "]");
    spliterator.forEachRemaining(sj::add);

    return sj.toString();
  }

According to the JavaDoc Spliterator.getExactSizeIfKnown() "returns estimateSize() if this Spliterator is SIZED, else -1." If a Spliterator is SIZED then "estimateSize() prior to traversal or splitting represents a finite size that, in the absence of structural source modification, represents an exact count of the number of elements that would be encountered by a complete traversal."

Since "most Spliterators for Collections, that cover all elements of a Collection report this characteristic" (API Note in JavaDoc of SIZED) this could be the desired directer way.

EDIT:
If the Stream is empty we can return an empty String at once. If the Stream has only one String there is no need to create a StringJoiner and to copy the String to it. We return the single String directly.

  public String format(Stream<String> stream) {
    Spliterator<String> spliterator = stream.spliterator();

    if (spliterator.getExactSizeIfKnown() == 0) {
      return "";
    }

    if (spliterator.getExactSizeIfKnown() == 1) {
      AtomicReference<String> result = new AtomicReference<String>();
      spliterator.tryAdvance(result::set);
      return result.get();
    }

    StringJoiner result = new StringJoiner(",", "[", "]");
    spliterator.forEachRemaining(result::add);
    return result.toString();
  }
LuCio
  • 5,055
  • 2
  • 18
  • 34
  • 1
    If you know that the Stream has a single `String` element, you could return that single element directly, omitting the copying to a `StringJoiner` and to a new `String`. But you should also handle the case where a stream has no known size but ends up having one element. It’s not a weird corner case, i.e. `Stream.of("foo").filter(s -> true)` does already fit into that category. – Holger May 21 '19 at 11:03
0

I know that I'm late to the party, but I too came across this very same problem recently, and I thought it might be worthwhile documenting how I solved it.

While the accepted solution does work, my opinion is that it is more complicated than it needs to be, making it both hard to read and understand. As opposed to defining an Object implementation and then separate accumulator and combiner functions that access and modify the state of said object, why not just define your own collector? In the problem presented above, this can be achieved as follows:

Collector<CharSequence, StringJoiner, String> collector = new Collector<>() {

    int count = 0;

    @Override
    public Supplier<StringJoiner> supplier() {
        return () -> new StringJoiner(",");
    }

    @Override
    public BiConsumer<StringJoiner, CharSequence> accumulator() {
        return (joiner, sequence) -> {
            count++;
            joiner.add(sequence);
        };
    }

    @Override
    public BinaryOperator<StringJoiner> combiner() {
        return StringJoiner::merge;
    }

    @Override
    public Function<StringJoiner, String> finisher() {
        return (joiner) -> {
            String joined = joiner.toString();
            return (count > 1) ? "[" + joined + "]" : joined;
        };
    }

    @Override
    public Set<Characteristics> characteristics() {
        return Collections.emptySet();
    }
};

The principle is the same, you have a counter that is incremented whenever elements are added to the StringJoiner. Based on the total number of elements added, the finisher decides wheater the StringJoiner result should be wrapped in square brackets. It goes without saying, but you can even define a separate class specific to this implementation, allowing it to be used throughout your codebase. You can even go a step further by making it more generic to support a wider range of input types, adding custom prefix/suffix parameters, and so on. Speaking of use, just pass instances of the collector to the collect method of your Stream:

return list.stream().collect(collector);
Ozren Dabić
  • 23
  • 1
  • 6