Add prefix and suffix to Collectors.joining() only

2020-07-06 08:30发布

问题:

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.

回答1:

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.



回答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();
  }