Java 8 Streams

Jan Schoubo har tidligere skrevet en artikel om default metoder på interfaces i Java 8. Denne gang har han kastet sig over Stream API’et, som er en af de ny features i Java 8, der har fået mest omtale. Hans artikel tager udgangspunkt i en række eksempler på benyttelse af Streams i et af Lund&Bendsens interne projekter.

Java 8 – Brug af stream() i det virkelige liv

Jeg har nu brugt forskellige Java 8 features i et lille års tid, og begyndt at få lidt erfaringer med hvad der fungerer i praksis. Særlig stream() er spændende, da den ikke er helt banal at bruge, men til gengæld resulterer i mere elegant kode.

Herunder er vist nogle eksempler, der alle er taget direkte fra et internt kursus-planlægningsprogram. De kræver at man kender lidt til Stream APIet.

Bemærk at flere af eksemplerne også bruger LocalDate fra det nye Date and Time API.

Eksempel 1

Først et relativt enkelt eksempel – en metode der kan generere en CSV-fil med en instruktørs planlagte kurser. Filen kan importeres i Google Calendar – online integrationen kommer i v2.

public String generateGoogleCalendarCSV(final String instructor)
{
   String result =
       CourseEvent.getGoogleCalendarCSVHeader() + "n" +
 ❶     events.stream()
 ❷         .filter((e) -> e.getInstructor().equals(instructor))
 ❸          .map(CourseEvent::getGoogleCalendarCSV)
 ❹         .collect(Collectors.joining("n"));
   return result;
 }

❶ Få fat i en stream af CourseEvent’s ud fra en Collection af samme type
❷ Filtrér, så kun instruktørens kurser er med
❸ Kald funktionen getGoogleCalendarCSV på CourseEvent objektet. Det er en instans-metode, så den bliver kaldt uden parameter på hvert objekt i stream’en. Den resulterer i en ny stream af String’s.
❹ Saml objekterne i stream’en med en indbygget String-collector joining.

Og de to simple metoder i CourseEvent:

 public static String getGoogleCalendarCSVHeader()
 {
    return "Subject," + "Start Date," + "End Date," + "All Day Event," +
           "Description," + "Location," + "Private";
 }
 public String getGoogleCalendarCSV()
 {
    return quoteCSV("[?] " +
           getCourseNumber() + " " +
           getCourseTitle() + "/" +
           getLocation() + " (LBKP)") + "," +
           dateCSV(getStartDate()) + "," +
           dateCSV(getEndDate().plusDays(1)) + "," +
           "True" + "," +
           quoteCSV(getCustomer()) + "," +
           quoteCSV(getLocation()) + "," +
          "False";
 }

Eksempel 2

Byg en kalender med alle kursusafholdelser, og find samtidig ud af hvor langt kalenderen skal række for at have alle planlagte kurser med.

Først en lokal hjælpeklasse, der indeholder et datointerval. Startdatoen sættes ved oprettelsen, mens slutdatoen kan justeres senere:

 static class DateInterval
 {
   private final LocalDate first;
   private LocalDate end;
   public DateInterval(final LocalDate date)
   {
      super();
      this.first = date;
      this.end = date;
   }
❶ public static DateInterval create()
   {
      return new DateInterval(LocalDate.now());
   }
❷ public void adjust(final LocalDate date)
   {
      if (date.isAfter(end)) end = date;
   }
}

❶ Denne factory-metode bruges til at oprette et nyt interval startende i dag.
❷ Justerer slutdatoen til en senere dato, hvis nødvendigt.

Selve logikken (vedligeholdelsen af calendar er ikke vist):

    TreeMap<LocalDate, DateInfo> calendar = new TreeMap<>();
❸   Supplier<DateInterval> supplier = DateInterval::create;
❹   BiConsumer<DateInterval, CourseEvent> accumulator =
        (final DateInterval r, final CourseEvent e) ->
        {
           r.adjust(e.getEndDate());
           updateCalendar(calendar, e.getStartDate(), e.getEndDate(), e);
        };
❺  BiConsumer<DateInterval, DateInterval> combiner =
        (final DateInterval d1, final DateInterval d2) -> d1.adjust(d2.end);
❻  DateInterval interval = events.stream().collect(supplier, accumulator, combiner);

❸ Instantiering af et Functional Interface-objekt af (en anonym subklasse til) typen Supplier, med een metode, create. Fordi metoden i objektet ikke skal andet end at kalde create, kan man nøjes med en Method Reference (::) her.
❹ Instantiering af et Functional Interface-objekt af (en anonym subklasse til) typen BiConsumer, med een metode specificeret i {}. Metoden tager som parametre dels DateInterval (resultatet so far), dels CourseEvent (næste objekt i stream’en)
❺ Instantiering af et Functional Interface-objekt af (en anonym subklasse til) typen BiConsumer, der tager to parametre af typen DateInterval og kombinerer dem.

❻ Skab en stream ud fra en Collection, og behandl objekterne med samler-funktionen collect. Resultatet er eet objekt af typen DateInterval.
supplier har til opgave at oprette eet objekt til at begynde med.
accumulator tager dette start-objekt og kombinerer det med et objekt fra stream’en. I dette tilfælde sker det ved at kalde .adjust og justere slut-datoen.
combiner bruges til at kombinere resultater fra flere tråde, hvis Java vælger at udføre opgaven på flere tråde parallelt.

Eksempel 3

En operation jeg opdagede, jeg ofte udførte, var at tage en Collection som stream, og filtrere den efter om kurserne jeg ønskede er åbne kurser eller firmahold. Så hvorfor ikke lave metoder der kan returnere filtrerede streams direkte.

Her er nogle forskellige getter’e som illustrerer dette. isOpenCourse og isCompanyCourse er gettere til boolean instans-variable på CourseEvent-klassen:

private final List<CourseEvent> events = new LinkedList<CourseEvent>();
public Stream<CourseEvent> streamOpen()
{
   return events.stream().filter(CourseEvent::isOpenCourse);
}

public Stream<CourseEvent> streamCompany()
{
   return events.stream().filter(CourseEvent::isCompanyCourse);
}

public Stream<CourseEvent> stream()
{
   return events.stream();
}

public Stream<CourseEvent> stream(final boolean isOpenCourse)
{
   if (isOpenCourse)

      return streamOpen();

   else

      return streamCompany();

}

Eksempel 4

I programmet har jeg også brug for at vise en liste af kursus-afholdelser, som er planlagt indenfor en vis kommende periode, i en Java Swing TreeNode. Oplysningerne kommer fra en anden datastruktur, som afgør om et kursus skal med på listen eller ej – RuleStates. Det er også muligt at angive på skærmbilledet hvilke typer kurser der skal vises – det sker i panel.getSelectedStates().

Alt dette styres elegant fra een sætning:

 courses.stream()
❶       .flatMap((ce) -> ce.streamEventRules())
❷       .filter((rs) -> rs.isDue(panel.getSelectedStates()))
❸       .collect(Collectors.groupingBy(RuleState::getCourseEvent))
❹       .keySet()
❺       .stream()
❻       .sorted(Comparator.comparing(CourseEvent::getStartDate))
        .forEach((ce) -> {
           children.add(new CourseTreeNode(mainPanel, depth + 1, new DisplayInfoCourseEvent(ce)));
        });

❶ Kik på de afholdelse, der er knyttet til det enkelte kursus. Hvert kursus har en stream af afholdelser. streamEventRules er en hjælpemetode, der returnerer listen af afholdelser som en stream. flatMap betyder at alle elementerne fra de enkelte streams bliver kombineret i en samlet stream.
❷ Vælg kun de afholdelser, som er ”op over” og af den type, der er valgt på skærmbilledet.
❸ Saml afholdelserne i en Map, med CourseEvent som nøgle
❹ Tag disse nøgler (afholdelser) og…
❺ …lav en ny stream af dem
❻ Sorter i dato-rækkefølge og placer dem som children i en TreeNode.

Spørgsmål til den meget opmærksomme læser

Bemærk hvordan den oprindelige stream afsluttes med collect, som afleverer en Map-instans, og at der derefter laves en ny stream udfra keySet().

Kan det gøres enklere?

Helt præcist: Kan sekvensen

   :
   .collect(Collectors.groupingBy(RuleState::getCourseEvent))
   .keySet()
   .stream()
   .sorted(Comparator.comparing(CourseEvent::getStartDate))
   :

erstattes af sekvensen

   :
   .map(RuleState::getCourseEvent)
   .sorted(Comparator.comparing(CourseEvent::getStartDate))
   .distinct()
   :

?