Tomasz Mularczyk
Tomasz Mularczyk

Reputation: 36179

Keep track of iteration with parametrized Stream

This code:

Kid[] kids = Kid.getSimpleArray();
String names = Stream.of(kids)
                .filter(Kid::hasToy)
                .map(Kid::getSurname)
                .collect(Collectors.joining(", "));
out.println(names);

gives me this result:

Stępień, Inglot, Czubówna, Lepiej, Łagowska

And I'm thinking of a solution to make it like this:

1.Stępień, 2.Inglot, 3.Czubówna, 4.Lepiej, 5.Łagowska

P.S I couldn't think of a better question to get answer I'm looking for.

Upvotes: 3

Views: 158

Answers (4)

Tagir Valeev
Tagir Valeev

Reputation: 100199

Using third-party free library StreamEx (written by me) which enhances standard Stream API you can zip the stream with numbers:

String result = StreamEx.of("abc", "def", "aaa", "foo", "a12", "a34", "bar", "aaa")
    .filter(s -> s.startsWith("a"))
    .zipWith(IntStreamEx.ints().boxed(), (str, num) -> (num+1)+". "+str)
    .joining(", ");
// 1. abc, 2. aaa, 3. a12, 4. a34, 5. aaa

Upvotes: 3

Hank D
Hank D

Reputation: 6471

In your example, after all the filtering is done, Strings are collected and joined. Since you want to end up with String values, we can create a version of toString() (forgetting for the moment that your Stream already contains Strings) that prepends the count, like this:

public static<T> Function<T, String> getCountedToStringFunc(){
    AtomicInteger i = new AtomicInteger(0);
    return t -> i.incrementAndGet() + ". " + t.toString();
}

You can use it like this:

myStream
.filter(...)
.map(getCountedToStringFunc())
.collect(Collectors.joining(",")));

Or, noting that having to remember to apply the counter as late as possible in the pipeline creates a maintainability risk if you should want to modify the pipeline in the future, you could take it out of the pipeline altogether and make it part of the terminating function:

myStream
.filter(...)
.collect(Collectors.mapping(getCountedToStringFunc(),joining(","))));

The nice thing about the latter approach is you could use it to create separate counters for partitioned or grouped data.

Upvotes: 1

Sam
Sam

Reputation: 9944

Streams only operate on one item at once. Each computation knows only about the element it is operating on. So trying to add in some knowledge of the item's position in the ordering is always going to be a hack.

Collecting into a List instead of a String would allow you to do a final computation that operates on the list of all the items, rather than on single items in isolation. Here's a method that takes the List and formats it as a comma-separated String of numbered items:

static String enumerateAndJoin(List<String> l, String separator) {
    StringBuffer sb = new StringBuffer();
    ListIterator<String> it = l.listIterator();
    while(it.hasNext()) {
        sb.append(it.nextIndex() + 1).append(".").append(it.next());
        if (it.hasNext()) {
            sb.append(separator);
        }
    }
    return sb.toString();
}

You could of course then do:

 List<String> surnames = Stream.of(kids)
            .filter(Kid::hasToy)
            .map(Kid::getSurname)
            .collect(toList());

 String output = enumerateAndJoin(surnames, ", ");

But if you're using this in several places, it might be convenient to define a new collector.

Collector<String, ?, String> myCollector = collectingAndThen(toList(), list -> enumerateAndJoin(list, ", "));

String output = Stream.of(kids)
            .filter(Kid::hasToy)
            .map(Kid::getSurname)
            .collect(myCollector);

However, all this really serves to do is obscure the code and waste cycles. Ultimately we're now doing two passes over the items. And the 'hack' solutions that just do one pass work by treating the stream as if it is an iterator, counting how many items have been encountered. That approach will only work for very straightforward ordered streams, and isn't at all what streams are intended for.

The stream is certainly appealing for its fluent filter and map methods. But if you can bear a bit more verbosity, why not just iterate over kids using a real iterator in the first place?

int i = 0;
Iterator<Kid> it = new ArrayIterator<>(kids);
StringBuffer sb = new StringBuffer();
while (it.hasNext()) {
    Kid kid = it.next();
    if (!kid.hasToy()) continue;
    sb.append(++i).append(".").append(kid.getSurname());
    if (it.hasNext()) {
        sb.append(", ");
    }
}

For a hybrid approach that saves a line or two and is arguably clearer, I like Guava's FluentIterable:

int i = 0;
Iterator<Kid> kidsWithToys = FluentIterable.of(kids).filter(Kid::hasToy).iterator();
StringBuffer sb = new StringBuffer();
while (kidsWithToys.hasNext()) {
    sb.append(++i).append(".").append(kidsWithToys.next().getSurname());
    if (kidsWithToys.hasNext()) {
        sb.append(", ");
    }
}

For some problems, streams are not the answer, and I think this is one of them.

Upvotes: 1

Yassin Hajaj
Yassin Hajaj

Reputation: 21975

Note that this is really ugly and not an advice I should give here but here it is.

You may use a temporary collection that will serve us as a counter. The advantage is that we can block the reference as final but still add elements to it.

We add whatever to it each time a Kid passes the filter and get the size for the integer in your String.

final List tmp = new ArrayList();
Kid[] kids = Kid.getSimpleArray();
String names = Stream.of(kids)
                .filter(Kid::hasToy)
                .peek(x -> tmp.add(1))
                .map(x -> tmp.size() + "." + x.getSurname())
                .collect(Collectors.joining(", "));
out.println(names);

EDIT

Here are two other solutions provided by @Sam and @Tunaki


@Sam

final AtomicInteger ai = new AtomicInteger();
Kid[] kids = Kid.getSimpleArray();
String names = Stream.of(kids)
                .filter(Kid::hasToy)
                .map(x -> ai.incrementAndGet() + "." + x.getSurname())
                .collect(Collectors.joining(", "));
out.println(names);

@Tunaki

Kid[] kids = Kid.getSimpleArray();
Kid[] filteredKids = Arrays.stream(kids).filter(Kid::hasToy).toArray(Kid[]::new);
String names = IntStream.range(0, filteredKids.length)
                        .mapToObj(i -> i + "." + filteredKids[i].getSurname())
                        .collect(Collectors.joining(", ");
out.println(names);

Upvotes: 4

Related Questions