Transform Java Stream and return reduced values bo

2019-07-18 01:46发布

问题:

Assuming I consume a Stream of entities from a source which I do not want to materialize, and I want to both transform the elements, and return some globally reduced value, what is the idiomatic way with java(8)?

This is essentially trying to perform both a reduce() and a collect().

Example:

class Person {
    public String firstname,
    public String lastname,
    public int age;
}

class TeamSummary {
    public List<String> fullnames, // firstname and lastname of all
    public Person oldest
}

public TeamSummary getSummary(Stream<Person> personStream) {
   final TeamSummary summary = new Summary();
   summary.fullnames = personStream
       .peek(p -> if (summary.getOldest() == null || summary.getOldest.age < p.age) {
           summary.oldest = p;
       })
       .map(p -> p.firstname + ' ' + p.lastname)
       .collect(toList());
   return summary;
}

It looks ugly to interact with a variable outside the stream inside the peek method, but what good alternatives are there, it seems I need to combine collect() and reduce().

It get's worse if I want to get a reduced value from the whole stream (like average age), and a filtered list (like Persons above 18 years). It also get's worse if TeamSummary is an immutable class, and additional mutable variables are required.

In such cases it is more idiomatic to use a while loop on stream.iterator() to avoid coupling of stream methods and variables? Or is it natural to use reduce to a tuple like (oldest, accumulated).

I am aware this question is a matter of opinion unless there is an obvious way (like a special collector) that solves this elegantly.

回答1:

So you want to reduce your collection to a single value? That's where Collectors.reducing comes into play (Alternative: You could use Stream.reduce but with other modifications). Furthermore, you want to aggregate your values in some way and also have the perfect accumulator: TeamSummary.

Now, in the below code I made the foollowing adjustments:

  • Team Summary has the merge/identity function required for reduce, as it serves as the accumulator
  • I use a Null Object instead of null for a non-existing person, which makes the code much more readable without null checks (NPE during converter being one of the problems). Have you thought about your output if the stream is empty?
  • I added a Person constructor for my own convenience. But consider using getters and final fields (even if you think getters and the whole fake encapsulation are boilerplate: You can use method references, e.g. to pass to a comparator, but not field references)

Here is the code:

static class Person {
    public String firstname;
    public String lastname;
    public int age;

    public Person(String firstname, String lastname, int age) {
        this.firstname = firstname;
        this.lastname = lastname;
        this.age = age;
    }

    public static Person getNullObjectYoung() {
        return new Person("", "", 0);
    }
}

static class TeamSummary {
    public List<String> fullnames;
    public Person oldest;

    public static TeamSummary merge(TeamSummary lhs, TeamSummary rhs) {
        TeamSummary result = new TeamSummary();
        result.fullnames = new ArrayList<>();
        result.fullnames.addAll(lhs.fullnames);
        result.fullnames.addAll(rhs.fullnames);
        result.oldest = Comparator.<Person, Integer>comparing(p -> p.age).reversed()
                .compare(lhs.oldest, rhs.oldest) < 0
                ? lhs.oldest
                : rhs.oldest;
        return result;
    }

    public static TeamSummary of(Person person) {
        TeamSummary result = new TeamSummary();
        result.fullnames = new ArrayList<>();
        result.fullnames.add(person.firstname + " " + person.lastname);
        result.oldest = person;
        return result;
    }

    public static TeamSummary identity() {
        TeamSummary result = new TeamSummary();
        result.fullnames = new ArrayList<>();
        result.oldest = Person.getNullObjectYoung();
        return result;
    }
}

public static void main(String[] args) {        
    Stream<Person> personStream = Arrays.asList(
            new Person("Tom", "T", 32),
            new Person("Bob", "B", 40))
            .stream();

    TeamSummary result = personStream.collect(
            Collectors.reducing(
                    TeamSummary.identity(),
                    TeamSummary::of,
                    TeamSummary::merge
            ));
    System.out.println(result.fullnames + " " + result.oldest.age);

}

Note: You asked for a java 8 version. Maybe in java 12, you could also use Collectors.teeing, since you basically want to do two different reductions at the same time (for which we can currently leverage the accumulator).


Edit: Also added a solution for Stream.reduce, which requires a BiFunction (summary, person) -> person:

static class TeamSummary {

    ...

    public TeamSummary include(final Person person) {
        final TeamSummary result = new TeamSummary();
        result.fullnames = new ArrayList<>(fullnames);
        result.fullnames.add(person.firstname + " " + person.lastname);
        result.oldest = Comparator.<Person, Integer> comparing(p -> p.age).reversed()
                .compare(oldest, person) < 0
                        ? oldest
                        : person;
        return result;
    }
}

public static void main(final String[] args) {
    ...

    final TeamSummary reduced = personStream.reduce(
            TeamSummary.identity(),
            TeamSummary::include,
            TeamSummary::merge);
}


回答2:

Based on the requirements such as - Stream as input and inferring the complete list of names in the output of teamSummary. You can perform the operation mapping the person and its name details to an entry and then reduce them further such as :

return personStream
        .map(p -> new AbstractMap.SimpleEntry<>(p, Collections.singletonList(p.getFirstname() + ' ' + p.getLastname())))
        .reduce((entry1, entry2) -> new AbstractMap.SimpleEntry<>(entry1.getKey().getAge() >= entry2.getKey().getAge() ?
                entry1.getKey() : entry2.getKey(), Stream.of(entry1.getValue(), entry2.getValue()).flatMap(List::stream).collect(Collectors.toList())))
        .map(entry -> new TeamSummary(entry.getKey(), entry.getValue()))
        .orElseThrow(IllegalArgumentException::new);

For a readable and simplified approach though I would rather suggest passing on the collection and working with multiple stream operations here to construct the TeamSummary as :

public TeamSummary getSummary(List<Person> people) {
    List<String> fullNames = people.stream()
            .map(p -> p.getFirstname() + ' ' + p.getLastname())
            .collect(Collectors.toList());
    Person oldestPerson = people.stream()
            .reduce(BinaryOperator.maxBy(Comparator.comparing(Person::getAge)))
            .orElseThrow(IllegalArgumentException::new);
    return new TeamSummary(oldestPerson, fullNames);
}


回答3:

I don't know why you'd use Collectors.reducing() when you can stream.reduce() directly?

BinaryOperator<Player> older = (p1, p2) -> 
    Comparator.comparing(Player::getAge) > 0 
        ? p1 : p2;
TeamSummary summary = stream.reduce(
    TeamSummary::new, // identity
    // accumulator
    (ts, player) -> {
        ts.addFullnames(String.format("%s %s", player.firstName, player.lastName));
        ts.setOldest(older.apply(ts.getOldest(), player));
    }
    // combiner
    (ts1, ts2) -> {
        // we can safely modify the given summaries, they were all created while reducing
        ts1.setOldest(Math.max(ts1.getOldest(), ts2.getOldest()));
        ts1.addFullnames(ts2.getFullnames().toArray());
        return ts1;
    });

TeamSummary would then look like this:

class TeamSummary {
    private int oldest; 
    public Player getOldest() { return oldest; }
    public void setOldest(Player newOldest) { oldest = newOldest; }

    private List<String> fullnames();
    public List<String> getFullnames() { return Collections.unmodifiableList(fullnames); }

    public void addFullnames(String... names) {
        fullnames.addAll(Arrays.asList(names));
    }
}

Alternative

You could also extend TeamSummary with something like addPlayer(Player p) and merge() to allow it to maintain its consistency:

class TeamSummary {

    @Getter
    private int oldest;
    @Getter
    private List<String> fullnames = new ArrayList<>();

    public void addPlayer(Player p) {
        fullnames.add(String.format("%s %s", p.getFirstname(), p.getLastname()));
        oldest = olderPlayer(oldest, p);
    }
    public TeamSummary merge(TeamSummary other) {
        older = olderPlayer(oldest, other.oldest)
        fullnames.addAll(other.fullnames);
        return this;
    }

    final static Comparator<Player> BY_AGE = Comparator.comparing(Player::getAge);
    private static Player olderPlayer(Player p1, Player p2) {
        return BY_AGE.compare(p1, p2) > 0 ? p1 : p2;
    }
}

which would make the reduction

stream.reduce(
    TeamSummary::new,
    TeamSummary::addPlayer,
    TeamSummary::merge
);