[x^2 for x in lst]

Nyheter i Java 8 - del 4 - metoder i klassen Collectors

2014-08-31

Detta är den fjärde delen i miniserien om nyheterna i Java 8. Del ett handlade om ändringarna som gjorts på interface, del två mestadels om de den nya funktionaliteten med streams och om lambda expressions, och del tre vad man kan göra med metoderna i Stream.

Den här fjärde delen kommer att fortsätta diskutera vad som kan göras med den variant av collect() som tar en Collector som argument och hur man kan utnyttja metoderna i Collectors-klassen för att generera inparametrar till den.

Metoder i Collectors

Vi nosade lite på ytan på den funktionalitet som erbjuds av klassen java.util.stream.Collectors i del tre. Nu skall vi dyka lite djupare ner och undersöka fler metoder i Collectors och se hur vi kan använda dessa för att vända och vrida på datat i en ström.

Detta är inte på något sätt en komplett förteckning av alla metoder i Collectors och beskriver inte alls allt som kan göras med de metoder som behandlas, utan skall nog ses som en aptitretare för vidare utforskning i ämnet. API-dokumentationen för Collectors finns här.

En klass att testa med

I exemplen nedan används en klass som representerar en bok. Den har bara fyra attribut: titel, förlag, publiceringsår och pris, och den ser ut som följer:

public class Book {
  private String title;
  private String publisher;
  private int yearPublished;
  private int price;

  public Book(String title, String publisher, int yearPublished, int price) {
    this.title = title;
    this.publisher = publisher;
    this.yearPublished = yearPublished;
    this.price = price;
  }

  public String getTitle() {
    return title;
  }

  public void setTitle(String title) {
    this.title = title;
  }

  public String getPublisher() {
    return publisher;
  }

  public void set(String publisher) {
    this.publisher = publisher;
  }

  public int getYearPublished() {
    return yearPublished;
  }

  public void setYearPublished(int yearPublished) {
    this.yearPublished = yearPublished;
  }

  public int getPrice() {
    return price;
  }

  public void setPrice(int price) {
    this.price = price;
  }

  public String toString() {
    return title + ", " + publisher + ", " + yearPublished + ", " + price + "kr";
  }
}

I exemplen nedan används en lista med Books som är skapad som följer:

Book functionalProgrammingInJava = new Book("Functional Programming In Java", "The Pragmatic Programmers", 2014, 260);
Book javaPerformance = new Book("Java Performance - the definitive guide", "O'Reilly", 2014, 300);
Book programmingGroovy2 = new Book("Programmming Groovy2", "The Pragmatic Programmers", 2012, 210);
Book effectiveJava = new Book("MongoDB - the definitive guide, 2ed", "O'Reilly", 2014, 260);
Book algorithmsInPython = new Book("Python algorithms", "Apress", 2012, 360);
    
List<Book> books = Arrays.asList(functionalProgrammingInJava, javaPerformance, programmingGroovy2, effectiveJava, algorithmsInPython);

Alla metoder från Collectors som används i exemplen är statiskt importerade. Detta för att spara lite skärmyta genom att slippa skriva Collectors. framför anropen. Och för att jag är lat.

Notera att jag i exemplen nedan ofta sparar undan resultat i lokala variabler innan utskrift på skärmen sker, isället för att direkt chain:a på exempelvis .stream().forEach(String.out::println). Det är gjort på detta sätt för att jag vill poängtera vilken typ resultatet har. Vilket inte alltid är uppenbart...

Vad returnerar Stream.collect()?

Om man tittar på API-beskrivningen till Stream.collect() ser man dess signatur är <R,A> R collect(Collector<? super T,A,R> collector). Den har alltså returtypen R där R är en av typparametrarna till Collector:n som collect() tar som inargument. Metoden kan alltså returnera helt olika, och då menar jag verkligen helt olika, typer beroende på vilken Collector den får som argument. Använder man sig av Collector:erna i Collectors kan det vara en god idé att undersöka javadoc:en för metoderna i fråga för att lista ut vad man skall förvänta sig för returtyp från collect().

Innan jag greppat att det var den sista typparametern till Collector:n som användes som argument till collect() som angav returtypen tyckte jag att många av de exempel jag såg på nätet var luriga att förstå.

Detta var ett tips bara. Ni är sannolikt inte lika tröga som jag och hade säkert fattat exemplen ändå, men just in case ;)

Hitta största elementet - maxBy()

Metoden maxBy() har signaturen static <T> Collector<T,?,Optional<T>> maxBy(Comparator<? super T> comparator). Den letar alltså upp det största elementet, givet någon viss Comparator, i en ström och returnerar detta som en Collector.

Vad är det då för bra med detta? Det finns ju andra metoder, tex Stream.max(), som gör samma sak. Fördelen med maxBy är just att den returnerar en Collector, vilket gör att man kan kombinera den med andra metoder i Collectors för att till exempel leta upp det största elementet i olika grupper av element. Mer om detta nedan.

Som ett litet exempel på maxBy() så kan vi skriva en liten snutt som letar upp den dyraste boken och skriver ut detaljer om den på konsolen. Programmet ser ut som följer:

Optional<Book> mostExpensiveBook = books.stream().collect(maxBy((b1, b2) -> b1.getPrice() - b2.getPrice()));
mostExpensiveBook.ifPresent(System.out::println);
...
Python algorithms, Apress, 2012, 360kr

Notera att returtypen från collect() alltså blir en Optional eftersom det är denna typ som specas som den sista typparametern till maxBy().

Summera - summingInt()

Om man istället för att hitta den dyraste boken vill beräkna summan av hur mycket av ens surt ihoptjänade pengar man lagt på datalitteratur kan man göra detta med metoden summingInt(). Det går exempelvis till på följande sätt:

int totalPrice = books.stream().collect(summingInt(Book::getPrice));
System.out.println(totalPrice); 
...
1390
Om nu bara den verkliga summan hade varit så låg...

Det finns också metoder summingDouble() och summingLong() för att summera double:s och long:s.

mapping()

mapping() har en signatur som får mig att få huvudvärk, nämligen static <T,U,A,R> Collector<T,?,R> mapping(Function<? super T,? extends U> mapper, Collector<? super U,A,R> downstream).

Läser man beskrivningen på vad den gör klarnar det dock lite. Det den gör är att den tar elementen från en kollektor med elementtyp U, applicerar en funktion U -> T och stoppar in resultatet i en annan kollektor med elementtypen T. Åtminstone är det det jag tror den gör ;)

Vi får se om det hjälper med ett exempel. Anta att vi bara vill skriva ut böckernas titlar. Detta kan göras på följande sätt:

List<String> bookTitles = books.stream().collect(mapping(Book::getTitle, toList()));
bookTitles.stream().forEach(System.out::println);
...
Functional Programming In Java
Java Performance - the definitive guide
Programmming Groovy2
MongoDB - the definitive guide, 2ed
Python algorithms

Vi samlade alltså upp alla böckernas titlar i en lista (pga kollektorn som returneras från toList()) och skrev sedan ut dem. Ser ut som det funkar va?

reducing()

reducing() gör motsvarande som reduce() i Stream, dvs den returnerar en collector som givet en BinaryOperator jämför elementen två och två med varandra och vid varje jämförelse väljer ut ett av dem. Resultatet blir att endast ett element återstår till slut.

Vi kan använda reducing() för att extrahera fram boken med den längsta titeln.

Optional<Book> bookWithLongestTitle = books.stream().collect(reducing((b1, b2) -> b1.getTitle().length() - b2.getTitle().length() >= 0 ? b1 : b2));
System.out.println(bookWithLongestTitle.get().getTitle()); 
...
Java Performance - the definitive guide

Gruppering (enkel) - groupingBy()

Ovanstående metoder har väl hittils egentligen inte tillfört något nytt jämfört med de metoder som finns i Stream. Alla exempel ovan hade vi kunnat skriva med Stream-metoder, vilket antagligen hade varit bättre. Nu när vi börjar kolla på groupingBy() kommer vi dock se att vi kan göra fler saker med maxBy(), mapping() med flera än vad vi kan med metoderna i Stream. Så då börjar vi väl då...

Den första varianten av groupingBy() som vi skall kolla på har signaturen static <T,K> Collector<T,?,Map<K,List<T>>>groupingBy(Function<? super T,? extends K> classifier). Den tar alltså in en Function som argument och med hjälp av denna grupperar den ihop elementen i strömmen i grupper. Grupperna returneras i en Map vars nycklar är de unika värden som returnerats från Function:en och vars värden är listor av medlemmarna i varje grupp.

Om vi vill gruppera böckerna i vårt löpande exempel i grupper baserat på deras publiceringsår kan detta göras så här:

Map<Integer, List<Book>> booksByYearPublished = books.stream().collect(groupingBy(Book::getYearPublished));
for (Map.Entry<Integer, List<Book>> entry :  booksByYearPublished.entrySet()) {
  entry.getValue().stream().forEach(System.out::println);
  System.out.println(""); 
}
...
Programmming Groovy2, The Pragmatic Programmers, 2012, 210kr
Python algorithms, Apress, 2012, 360kr

Functional Programming In Java, The Pragmatic Programmers, 2014, 260kr
Java Performance - the definitive guide, O'Reilly, 2014, 300kr
MongoDB - the definitive guide, 2ed, O'Reilly, 2014, 260kr
Vi ser alltså att böckerna grupperats in i två grupper: en innehållande böcker som publicerats 2012 och en med böcker som publicerats 2014.

Jag har i exempelprogrammet ovan använt en gammal hederlig for-loop för att skriva ut böckerna i de olika grupperna. Vill man så kan man istället använda forEach() med ett lambdaexpression för att skriva ut grupperna.

booksByYearPublished.forEach((publishedYear, listOfBooks) -> {
    listOfBooks.stream().forEach(System.out::println); 
    System.out.println("");
});
Notera den extra radbrytningen i slutet av lambdauttrycket som är med för att separera grupperna. Inkluderingen av denna gjorde att vi var tvungna att använda måsvingar runt utskriftsuttrycken.

Gruppering (med subgrupper)

Den andra versionen av groupingBy() som vi skall titta på kan användas för att gruppera element i fler än en nivå. Metoden har signaturen static <T,K,A,D> Collector<T,?,Map<K,D>>groupingBy(Function<? super T,? extends K> classifier, Collector<? super T,A,D> downstream).

I och med att den andra parametern, downstream, har typen Collector är det inget som hindrar att vi skickar in returvärdet från ytterligare ett anrop till groupingBy(). På så sätt kan gruppering ske i obegränsat antal steg. Vårt exempel kommer dock bara att gruppera i två led: böcker grupperat per utgivningsår och förlag. Detta kan föras som följer:

Map<Integer, Map<String, List<Book>>> booksByYearAndPublisher = books.stream().collect(groupingBy(Book::getYearPublished, groupingBy(Book::getPublisher)));
booksByYearAndPublisher.forEach((publishedYear, mapOfBooksPerPublisher) -> {
     System.out.println("*** " + publishedYear); 
     mapOfBooksPerPublisher.forEach((publisher, listOfBooksForPublisher) -> {
         System.out.println("*" + publisher); 
         listOfBooksForPublisher.stream().forEach(System.out::println); 
         System.out.println(""); 
       });
});
...
*** 2012
*The Pragmatic Programmers
Programmming Groovy2, The Pragmatic Programmers, 2012, 210kr

*Apress
Python algorithms, Apress, 2012, 360kr

*** 2014
*The Pragmatic Programmers
Functional Programming In Java, The Pragmatic Programmers, 2014, 260kr

*O'Reilly
Java Performance - the definitive guide, O'Reilly, 2014, 300kr
MongoDB - the definitive guide, 2ed, O'Reilly, 2014, 260kr

Vi ser att grupperna returneras i nästlade Map:ar. Den första Map:en har året i fråga som nyckel och som värde nästa nivå av gruppering i form av en annan Map. Den andra Map:en har förlag som nyckel och en lista med böcker per förlag som värde. Resultatet är alltså en gruppering av böckerna per publiceringsår och förlag. Inte så svårt eller hur?

Grupperingen per år fås alltså genom att skicka in en metodreferens till Book::getYearPublished som första pararameter till groupingBy(). Som andra parameter skickas ytterligare ett groupingBy() anrop, denna gången med metodreferensen Book::getPublisher som parameter, vilket alltså leder till att böckerna subgrupperas per förlag.

Om vi inte hade velat se den lite knöliga returtypen från collect(), dvs Map<Integer, Map<String, List<Book>>> hade vi givetvis kunnat haka på forEach() direkt på collect. Jag gjorde inte så nu eftersom jag ville att det skulle vara tydligt hur grupperingen av böcker såg ut.

Den här varianten av groupingBy() som alltså tar en Collector som andra argument kan ju givetvis användas med alla möjliga Collector:er, inte bara med sådana Collector:er som returneras från ytterligare ett anrop till groupingBy(). Kombinationsmöjligheterna är om inte oändliga, så i alla fall mycket stora. Vi skall kolla på några av dessa kombinationer lite senare i avsnittet, men först skall vi titta lite på en annan metod för att dela upp strömmen i delar.

Dela upp i två delar - partitioningBy()

Metoden partitioningBy() kan användas för att likt groupingBy() dela upp strömmen i delar. Skillnaden är att partitioningBy() tar in ett Predicate istället för en Function. Eftersom den abstrakta metoden i ett Predicate returnerar en boolean innebär detta att partitioningBy() delar in elementen i exakt två delar, inte ett godtyckligt antal som groupingBy() kan göra.

partitioningBy() finns likt groupingBy() i två varianter. Den ena har signaturen public static <T> Collector<T,?,Map<Boolean,List<T>>> partitioningBy(Predicate<? super T> predicate). Den returnerar alltså en Collector med elementen uppdelade i två delar: en för vilket det inskickade predikatet returnerar true och en för vilket det returnerar false. De booleska värdena kommer att vara nycklar i den returnerade Map:en och elementen som hör till respektive grupp kommer att finnas samlade listor som utgör värdena i Map:en.

Med den andra varianten av partitioningBy() kan man rekursivt fortsätta att dela upp delarna i mindre delar (helt analogt med den andra varianten av groupingBy()). Den har signaturen public static <T,D,A> Collector<T,?,Map<Boolean,D>> partitioningBy(Predicate<? super T> predicate, Collector<? super T,A,D> downstream).

Som ett exempel på den första varianten av partitioningBy() skall vi skriva ett litet program som delar upp böckerna i två delar: billiga böcker (som kostar mindre än 300 kronor) och dyra böcker (som kostar 300 kronor eller mer). Programmet kan se ut så här:

Map<Boolean, List<Book>> cheapAndExpensiveBooksMap = books.stream().collect(partitioningBy(book -> book.getPrice() < 300));
cheapAndExpensiveBooksMap.forEach((cheap,  cheapOrExpensiveBooks) -> {
    System.out.println(cheap ? "*** Billiga böcker: " : "*** Dyra böcker: "); 
    cheapOrExpensiveBooks.stream().forEach(System.out::println);
});
...
*** Dyra böcker: 
Java Performance - the definitive guide, O'Reilly, 2014, 300kr
Python algorithms, Apress, 2012, 360kr
*** Billiga böcker: 
Functional Programming In Java, The Pragmatic Programmers, 2014, 260kr
Programmming Groovy2, The Pragmatic Programmers, 2012, 210kr
MongoDB - the definitive guide, 2ed, O'Reilly, 2014, 260kr

Vi skickar in ett litet lambdauttryck book -> book.getPrice() < 300 vilket delar in böckerna i två delar: dyra och billiga. Vi ser vidare att Map:en som returneras innehåller exakt två nycklar (vilket ju är det vi förväntar oss): true och false. Samma resultat hade kunnat uppnås genom att använda groupingBy() med precis samma lambdauttryck. Skillnaden är att när partitioningBy() används så kommuniceras intentionen att uppdelningen skall ske i exakt två delar bättre. Med groupingBy() kan ju grupperna bli godtyckligt många.

Nu har vi gått igenom alla metoder som jag tänkt gå igenom i Collectors. Låt oss nu se hur vi kan kombinera dessa.

Kombinationer - största element i varje grupp

Det första exemplet på kombinationer som vi skall se på är hur vi kan extrahera fram den dyraste boken för varje år. För att hitta den totalt sett dyraste boken använde vi ovan oss av maxBy() och för att gruppera per år använde vi oss alldeles nyss av groupingBy(). Låt oss kombinera dessa två i ett androp till collect().

Map<Integer, Optional<Book>> mostExpensiveByYearPublished = books.stream().collect(groupingBy(Book::getYearPublished, maxBy((b1, b2) -> b1.getPrice() - b2.getPrice())));
mostExpensiveByYearPublished.forEach((year, mostExpensiveBookThatYear) -> {
     System.out.println("Year: " + year + " book: " + mostExpensiveBookThatYear.get()); 
});
...
Year: 2012 book: Python algorithms, Apress, 2012, 360kr
Year: 2014 book: Java Performance - the definitive guide, O'Reilly, 2014, 300kr

Vi har här grupperat böckerna per år genom att ange Book::getYearPublished som värde på Function-argumentet till collect(). Istället för att göra undergrupper med hjälp av ett andra anrop till groupingBy(), så gör vi nu istället ett anrop till maxBy() med en Comparator som väljer ut den dyraste boken av två. Resultatet blir alltså att vi får en Map innehållande de dyraste böckerna per år. Lysande Sickan. Det är förresten tur att jag inte tagit med böckerna jag köpte år 2005 i exemplet. Då köpte jag en inbunden specialvariant av Stroustrups C++-bok för över 900 spänn. Får skylla på att jag var ung och dum. Eller i alla fall dum ;)

Kombinationer - gruppering och mappning

Som ett andra exempel på hur man kan kombinera ihop Collector:s tänkte jag att vi skulle kolla på hur vi kan kombinera groupingBy() och mapping().

Det vi vill åstadkomma är att vi vill gruppera böckerna per förlag och skriva ut dess titlar. Vi kan göra det som följer:

Map<String, List<String>> bookTitleByPublisher = books.stream().collect(groupingBy(Book::getPublisher, mapping(Book::getTitle, toList())));
bookTitleByPublisher.forEach((publisher, bookTitlesForPublisher) -> {
    System.out.println("
*** " + publisher); 
    bookTitlesForPublisher.stream().forEach(System.out::println);        
});
...
*** The Pragmatic Programmers
Functional Programming In Java
Programmming Groovy2

*** O'Reilly
Java Performance - the definitive guide
MongoDB - the definitive guide, 2ed

*** Apress
Python algorithms

Ni börjar känna igen mönstret va? Först en groupingBy() för att gruppera och sedan en Collector (i det här fallet resultatet från mapping()) för att bearbeta grupperna på något sätt.

Kombinationer - gruppering och reducering

Som ett sista exempel skall vi se hur vi hur kan kombinera groupingBy() och reducing() för att hitta den bok per förlag som har den längsta titeln. Så här kan det se ut:

Map<String, Optional<Book>> bookWithLongestTitlePerPublisher = books.stream().collect(groupingBy(Book::getPublisher, reducing((b1, b2) -> b1.getTitle().length() - b2.getTitle().length() >= 0 ? b1 : b2)));
 bookWithLongestTitlePerPublisher.forEach((publisher, bookWithLongestTitleForPublisher) -> {
     System.out.println(publisher + ": " + bookWithLongestTitleForPublisher.get()); 
 });
...
The Pragmatic Programmers: Functional Programming In Java, The Pragmatic Programmers, 2014, 260kr
O'Reilly: Java Performance - the definitive guide, O'Reilly, 2014, 300kr
Apress: Python algorithms, Apress, 2012, 360kr

Exemplet följer samma mönster som de närmast ovan. Vi använder först groupingBy() för att gruppera böckerna och applicerar sedan reducing() på varje grupp för att hitta boken med den längsta titeln per grupp.

Avslutning del 4

I den här delen har vi sett exempel på ytterligare några metoder som finns i Collectors och hur man kan använda dem för att manipulera en ström av element på olika sätt. Den mest intressanta metoden, enligt mitt tycke, är groupingBy() och vi har sett hur vi kan använda denna för att gruppera elementen i strömmen på olika sätt. Vi har även tittat på hur man kan applicera olika funktioner på de olika grupperna om man så vill. Collectors-klassen erbjuder enorma kombinationsmöjligheter där vi kan kombinera ihop groupingBy() och de andra metoderna i närmast oändliga kedjor. Jag kommer nog dock att hålla mig till max två, tre nivåer. Det är allt mitt huvud mäktar med ;)

Jag hade från början tänkt att den här delen också skulle inkludera ett avsnitt om hur man kan parallellisera exekveringen av operationer på strömmar, men inlägget blev såpass långt att jag beslöt att detta ämne får vänta tills nästa inlägg.

Tidigare delar i serien

Del 1: Interface
Del 2: Streams och lambda expressions
Del 3: Metoder i Stream, metod- och konstruktorreferenser

Referenser

Subramaniam V, Functional Programming in Java, första utgåvan, The Pragmatic Programmers, 2014.
Java 8 API - Collectors


Leave a reply

Your name as it will be displayed when the comment is posted on the page. Your email address will not be published.