Aufzählungen und andere String-Konkatenationen

Immer wieder müssen Strings in Java Applikationen zusammengefügt werden. Lange vorbei sind dabei die Zeiten, in denen der Entwickler selber die String Instanzen und einen Separator in einen StringBuilder stecken musste.

Mittlerweile kann der Entwickler bei der Verwendung von Streams auf die Collectors.joining Methoden oder in anderen Fällen, auf den dahinter verborgenen StringJoiner, zurückgreifen.

Aufzählungen

Um eine Auflistung der Bundesländer für einen speziellen Feiertag zu erhalten kann die Collectors.joining Methode mit Präfix und Suffix verwendet werden.

System.out.println(EnumSet.range(BB, NW).map(GermanFederalState::getName)
  .collect(Collectors.joining(", ", "Gesetzlicher Feiertag in ", "")));

Im obigen Beispiel werden die Namen der entsprechenden Bundesländer Komma-separiert aneinandergefügt und "Gesetzlicher Feiertag in " als Präfix verwendet. Das Ergebnis ist der folgende Text.

Gesetzlicher Feiertag in Brandenburg, Bremen, Hamburg, Hessen, Mecklenburg-Vorpommern, Niedersachsen, Nordrhein-Westfalen

Das sieht zwar schon sehr schön aus, aber statt eines Kommas würden wir lieber ein „und“ vor Nordrhein-Westfalen sehen. Für diesen Fall existiert leider keine Methode in der Collectors Klasse und wir müssen eine eigene Methode ersinnen.

System.out.println(EnumSet.range(BB, NW).map(GermanFederalState::getName)
  .collect(enumerated("Gesetzlicher Feiertag in ", ",", " und ")));

Die Methode enumerated erwartet einen Präfix und zwei Separatoren. Der zweite Separator dient als Separator für das letzte Element der Aufzählung.

Gesetzlicher Feiertag in Brandenburg, Bremen, Hamburg, Hessen, Mecklenburg-Vorpommern, Niedersachsen und Nordrhein-Westfalen

Damit das korrekte Ergebnis geliefert werden kann, benötigen wir einen eigenen Collector. Erzeugt wird dieser in der enumerated Methode. Als Eingabe erwartet der Collector Instanzen von CharSequence und liefert als Ergebnis einen String.

public class EnumeratedCollector {
  public static Collector<CharSequence, Accumulator, String> enumerated(String prefix, String lastDelimiter, String delimiter) {
    return Collector.of(() -> new Accumulator(delimiter, lastDelimiter, prefix), Accumulator::add, (a, b) -> {
      throw new UnsupportedOperationException("parallel not supported");
    }, Accumulator::get);
  }
}

Die Hauptteil der Arbeit findet in der Accumulator Klasse statt. Dabei wird hier, für das Einsammeln der Eingangswerte, ein StringJoiner verwendet. Der Unterschied zum Original ist, dass der jeweils letzte Eingangswert erst in den StringJoiner eingefügt wird, wenn ein neuer Eingangswert zum Accumulator hinzugefügt wird. Am Ende wird der letzte Eingangswert mit dem Separator für das letzte Element an das Ergebnis des StringJoiners angefügt.

private static class Accumulator {
  private final StringJoiner joiner;
  private String last;
  private final String lastDelimiter;

  public Accumulator(String delimiter, String lastDelimiter, String prefix) {
    joiner = new StringJoiner(delimiter, prefix, "");
    joiner.setEmptyValue("");
    this.lastDelimiter = lastDelimiter;
  }

  public void add(CharSequence b) {
    if (last != null) {
      joiner.add(last);
    }
    last = String.valueOf(b);
  }

  public String get() {
    if (last == null) {
      return "";
    }
    if (joiner.length() == 0) { 
      return joiner.add(last).toString();
    }
    return joiner + lastDelimiter + last;
  }
}

Die Accumulator.get Methode ist etwas länger, weil hier noch zwei Sonderfälle abgefangen werden müssen.

Der erste Sonderfall tritt auf, wenn kein einziges Element dem Accumulator hinzugefügt wurde und das Attribut last den Wert null enthält. In dem Fall wird der leere String zurückgegeben. Das Attribut last, kann nur vor dem ersten Hinzufügen null sein, da es mit der Zuweisung last = String.valueOf(b) immer einen Wert erhält, der nicht null sein kann.

Der zweite Sonderfall tritt auf, wenn nur in Element dem Accumulator hinzugefügt wurde. Dann besitzt der StringJoiner noch die Länge 0. In diesem Fall wird das letzte Element last dem StringJoiner hinzugefügt und das Ergebnis des StringJoiner zurückgegeben. Diese Lösung liefert auch bei einem Präfix das gewünschte Resultat. Normalerweise würde der Präfix und der Suffix mit eingerechnet werden, aber der Aufruf joiner.setEmptyValue("") sorgt dafür, dass die StringJoiner.length Methode für den leeren StringJoiner das Resultat 0 zurückliefert.

Auslassungspunkte …

Auslassungspunkte sind eine andere beliebte Variante, um eine Aufzählung zu beenden. Von Software Entwicklern werden diese drei Punkte aber nur verwendet, wenn der Platz für den Text nicht ausreicht.

System.out.println(Stream.of("1", "2", "3", "4", "5", "6", "7", "8").collect(ellipsis(16)));

Als notwendigen Parameter benötigt die Methode die Anzahl der Zeichen, die zur Verfügung stehen. Als Resutat erzeugt die Methode eine kommaseparierte Liste mit weniger Zeichen als angegeben. An diese Liste wird dann ggf. noch das Auslassungszeichen gesetzt.

1, 2, 3, 4, 5, …

Im Gegensatz zu vielen anderen Implementierungen, verwendet diese tatsächlich das Auslassungszeichen (U+2026). Für Ausgaben, die dieses Zeichen nicht darstellen können, existiert eine Variante um ein alternatives Auslassungszeichen zu verwenden.

public class EllipsisCollector {
  public static Collector<CharSequence, ?, String> ellipsis(int maxLength) {
    return ellipsis(", ", "…", maxLength);
  }

  public static Collector<CharSequence, ?, String> ellipsis(CharSequence delimiter, String ellipsis, int maxLength) {
    return Collector.of(() -> new Accumulator(delimiter, ellipsis, maxLength), Accumulator::add, (a, b) -> {
      throw new UnsupportedOperationException("parallel not supported");
    }, Accumulator::get);
  }
}

Der EllipsisCollector verwendet einen Accumulator, der die Länge des StringJoiner beim Einfügen prüft. Solange noch Platz für den Trenner und das Auslassungszeichen bleibt, wird der neue Eintrag angefügt. Bei der Ausgabe des Resultates wird geprüft, ob alle Eintrage angefügt werden konnten. In dem Fall wird kein Auslassungszeichen ergänzt.

private static class Accumulator {
  private StringJoiner joiner;
  private String ellipsis;
  private int maxLength;
  private CharSequence delimiter;
  private boolean full;

  public Accumulator(CharSequence delimiter, String ellipsis, int maxLength) {
    joiner = new StringJoiner(delimiter);
    this.delimiter = delimiter;
    this.ellipsis = ellipsis;
    this.maxLength = maxLength - ellipsis.length() - delimiter.length();
  }

  public void add(CharSequence b) {
    if (joiner.length() > maxLength) {
      full = true;
      return;
    }
    joiner.add(b);
  }

  public String get() {
    return full ? joiner + delimiter.toString() + ellipsis : joiner.toString();
  }
}

Der EllipsisCollector hat gegenüber der Erzeugung in einer Liste einen nicht unerheblichen Nachteil. Auch wenn nur ein Bruchteil der Elemente in den String eingefügt werden können, müssen alle Elemente des Streams verarbeitet werden. Eine Verarbeitung in einer Schleife, kann diese vorzeitig verlassen.

Mit den Stream Collector Klassen sind viele elegante Implementierungen eigener Kollektoren möglich. Hin und wieder muss aber beim Einsatz der Streams auf die Effizienz geachtet werden.