Spliterator für die Arbeit

“In every job that must be done, there is an element of fun.”

Mary Poppins

Die Holidays Bibliothek aus dem Beitrag Kalenderspielereien mit Java hat schon seit geraumer Zeit keine Aktualisierung erfahren. Daher sollen einige Methoden hinzuzugefügt werden um den Umgang mit Werktagen zu vereinfachen.

Die bisherige Bibliothek prüft ein gegebenes Datum darauf, ob sich um einen Feiertag handelt. Zusätzlich liefert die Bibliothek die Feiertages eines Jahres als Map.

Die Unterstützung für Arbeitstage soll nicht nur die triviale Prüfung sein, sondern häufig benötigte Berechnungen umfassen. Dazu gehört die Berechnung des nächsten oder des n-ten Werktages nach einem vorgegebenen Datum. Nicht ganz so häufig genutzt aber auch sehr nützlich, der letzte Werktag vor einem bestimmten Datum oder die Anzahl Werktage in einem bestimmten Zeitraum.

Zum Einsatz kommen soll bei diesen Methoden die Java Stream API, damit die verschiedenen Berechnungen kurz und elegant formuliert werden können.

LocalDate begin = LocalDate.of(2021, Month.JANURAY, 1);
LocalDate end= LocalDate.of(2022, Month.JANURAY, 1);
Holidays holidays = Holidays.in(Locale.GERMANY);
int count = holidays.workdaysBetween(begin, end).count(); 
LocalDate firstWorkdayAfter = holidays.workdaysAfter(begin).findFirst().orElse(null);
LocalDate forthWorkdayAfter = holidays.workdaysAfter(begin).skip(3).findFirst().orElse(null);

In diesem Beispiel wird in Zeile 4 ein Stream<LocalDate> für die Werktage zwischen dem 1.1.2021 und dem 1.1.2022 erzeugt und die Anzahl Elemente gezählt. In Zeile 5 und 6 werden der erste und der vierte Werktag nach dem 1.1.2021 bestimmt, indem das erste bzw. das vierte Element aus dem Stream verwendet wird.

Um die Methoden workdaysBetween und workdaysAfter bereitstellen zu können, bedarf es zunächst einen Stream von LocalDate Instanzen, aus denen Feiertage und Wochenenden herausgefiltert wurden.

private Stream<LocalDate> workdaysBetween(Holidays holidays, LocalDate start, LocalDate end) {
  return start.datesUntil(end).filter(isWorkday()).filter(not(isHoliday(holidays)));
}

private Predicate<LocalDate> isWorkday() {
  return date -> date.getDayOfWeek() != DayOfWeek.SATURDAY && date.getDayOfWeek() != DayOfWeek.SUNDAY;
}

private Predicate<LocalDate> isHoliday(Holidays holidays) {
  Map<Integer, Map<LocalDate, String>> holidaysMap = new HashMap<>();
  return date -> holidaysMap.computeIfAbsent(date.getYear(), holidays::getHolidays).containsKey(date);
}

Eine erste Version der workdaysBetween Methode ist hier dargestellt. in der Zeile 2 wird mit Hilfe der datesUntil Methode der Klasse LocalDate ein Stream erzeugt und mit Hilfe der Prädikatsmethoden isWorkday und isHoliday gefiltert.

Die Methode isWorkday liefert ein Predicate, dass false für die Wochentage Samstag und Sonntag liefert und ansonsten true. Die Methode isHoliday ist etwas komplizierter. Ihr Predicate prüft das aktuelle Datum gegen Feiertage in einer Map. Da die Maps der Klasse Holidays nur jeweils ein Jahr enthalten, muss bei einem Jahreswechsel im Stream über computeIfAbsent ggf. eine neue Map berechnet werden.

Eine weitere Möglichkeit um dies zu implementieren ist ein eigenen Spliterator. Hauptaufgaben des Spliterator ist das Iterieren über eine Menge von Elementen und das Splitten dieser Menge für eine parallele Verarbeitung.

private static class WorkdaySpliterator implements Spliterator<LocalDate> {
  private final Holidays holidays;
  private LocalDate startDate;
  private LocalDate endDate;
  private int currentYear;
  private Map<LocalDate, String> currentHolidays;

  private WorkdaySpliterator(Holidays holidays, LocalDate startDate, LocalDate endDate) {
    this.holidays = holidays;
    this.startDate = startDate;
    this.endDate = endDate;
    this.increment = increment;
    currentYear = startDate.getYear();
    currentHolidays = holidays.getHolidays(currentYear);
  }

  @Override
  public boolean tryAdvance(Consumer<? super LocalDate> consumer) {
    if (startDate.equals(endDate)) {
      return false;
    }
    do {
      if (notHoliday(startDate)) {
        consumer.accept(startDate);
        startDate = startDate.plusDays(1);
        return true;
      }
      startDate = startDate.plusDays(1);
    } while (!startDate.equals(endDate));
    return false;
  }

  private boolean notHoliday(LocalDate startDate) {
    if (startDate.getYear() != currentYear) {
      currentYear = startDate.getYear();
      currentHolidays = holidays.getHolidays(currentYear);
    }
    return !currentHolidays.containsKey(startDate);
  }

  @Override
  public long estimateSize() {
    return startDate.until(endDate, ChronoUnit.DAYS);
  }

  @Override
  public int characteristics() {
    return Spliterator.DISTINCT | Spliterator.ORDERED | Spliterator.SORTED;
  }

  @Override
  public Comparator<? super LocalDate> getComparator() {
    return Comparator.naturalOrder();
  }
}

Das Iterieren übernimmt die tryAdvance Methode des WorkdaySpliterator. Das Iterieren endet, wenn das startDate das enddate erreicht hat, ansonsten wird das nächste Datum berechnet, das kein Feiertag ist und der consumer mit diesem Datum versorgt. Die Prüfung auf das Wochenende findet hier nicht statt. Dieses ist ausgelagert um den gleichen Spliterator für Berechnungen inklusive und exklusive Samstag zu verwenden.

Die Methode characteristics wurde implementiert um, da dieser Spliterator eine feste Reihenfolge der Elemente (ORDERED, SORTED) und kein Element doppelt (DISTINCT) vorkommen kann. Wegen der Sortierung muss zusätzlich ein Comparator angegeben werden. In diesem Fall einfach Comparator.naturalOrder().

Da nur bei einem Jahreswechsel eine neue Holiday Map benötigt wird, prüft dies die notHoliday Methode und wechselt ggf. die Map.

Verwendet wird der WorkdaySpliterator von den Methoden workdaysBetween und workdaysAfter.

public static Stream<LocalDate> workdaysBetween(Holidays holidays, LocalDate begin, LocalDate end, boolean parallel) {
  if (begin.equals(end) || begin.isAfter(end)) {
    throw new IllegalArgumentException();
  }
  return StreamSupport.stream(new WorkdaySpliterator(holidays, begin, end), parallel)
      .filter(day -> day.getDayOfWeek() != DayOfWeek.SUNDAY);
}

public static Stream<LocalDate> workdaysAfter(Holidays holidays, LocalDate start) {
  return workdaysBetween(holidays, start, LocalDate.MAX, false);
}

Die workdaysBetween Methode erzeugt den Stream für ein Anfangs- und ein Enddatum. Der Stream erhält zusätzliche einen Filter um Sonntage herauszufiltern. Eine Variante dieser Methode workdaysBetweenWithoutSaturday filtert auch die Samstage heraus. Der vierte Parameter parallel ermöglicht es zwischen paralleler und serieller Verarbeitung zu wechseln.

Die Methode workdaysAfter ist eine Spezialisierung von workdaysBetween mit dem größtmöglichen LocalDate als Enddatum. Auch hier gibt es die Variante workdaysAfterWithoutSaturday.

Eine Methode workdaysBefore kann mit einem invertierten Spliterator implementiert werden. Statt die Datumwerte hochzuzählen, werden sie heruntergezählt.

Neben dem Iterieren kümmert sich ein Spliterator um das Splitten der Menge von Elementen. Die Methode trySplit liefert einen neuen Spliterator. Dieser Spliterator kümmert sich um die Hälfte der Menge, während sich der bisherige Spliterator um die andere Hälfte kümmert.

@Override
public Spliterator<LocalDate> trySplit() {
  if (endDate.equals(LocalDate.MAX)) {
    return null;
  }
  long count = startDate.until(endDate, ChronoUnit.DAYS) / 2;
  if (count < 10) {
    return null;
  }
  LocalDate newEndDate = endDate;
  endDate = startDate.plusDays(count);
  return new WorkdaySpliterator(holidays, endDate, newEndDate, currentYear, currentHolidays);
}

Für den Fall, dass sich das Enddatum weit in der Zukunft liegt oder die Anzahl der Tage klein ist, wird kein neuer Spliterator erzeugt und null zurückgegeben. Im anderen Fall wird das Enddatum des ursprünglichen Spliterators und das Startdatum des neuen Spliterator auf das bisherige mittlere Datum gesetzt.

Die parallele Verarbeitung von Werktagen ist nicht in jedem Fall sinnvoll, aber u.a. für das Zählen von Werktagen nützlich.

LocalDate begin = LocalDate.of(2021, Month.JANURAY, 1);
LocalDate end= LocalDate.of(2022, Month.JANURAY, 1);
int count1 = Holidays.in(Locale.Germany).workdaysBetween(begin, end, false).count();
int count2 = Holidays.in(Locale.Germany).workdaysBetween(begin, end, true).count();

In diesem Beispiel werden die Werktage im Jahr 2021 gezählt. In Zeile 3 seriell und in der Zeile 4 parallel. Die parallele Version arbeitet etwa 25% schneller.

Damit ist der Beitrag über den WorkdaySpliterator in der Datumsberechnung auch schon am Ende. Zum Abschluss aber noch ein kurzer Ausblick auf den HolidaySpliterator, der Streams über Feiertage ermöglicht und seine wohl wichtigste Funktion.

Holidays.in(GermanFederalState.NW).holidaysBetween(begin, end)
  .filter(day -> -> day.getDayOfWeek() == DayOfWeek.THUERDAY || day.getDayOfWeek() == DayOfWeek.THURSDAY)
  .forEach(System.out::println);