Java Comparable und Comparator
Natürliche Sortierung mit Comparable und externe Sortierung mit Comparator in Java definieren und kombinieren.
Zwei Interfaces, eine Aufgabe: Java mitteilen, wann ein Objekt „kleiner" als ein anderes ist. An der Aufrufstelle sehen sie fast identisch aus, und ihre Methoden geben sogar denselben Wertetyp zurück — einen negativen int, null oder einen positiven int. Der Unterschied liegt darin, wo die Ordnung festgelegt wird:
Comparable<T>— der Typ selbst weiß, wie seine Instanzen sortiert werden. Die Methodeint compareTo(T other)ist die natürliche Ordnung des Typs.Comparator<T>— ein externes Objekt, das Instanzen sortiert. Die Methodeint compare(T a, T b)beschreibt eine von vielen möglichen Ordnungen.
Comparable implementiert man, wenn es ein offensichtliches „kleiner als" für einen Typ gibt — Integer, String, LocalDate. Einen Comparator schreibt man für jede andere Sortierung — nach Länge, nach Groß-/Kleinschreibung, nach absteigendem Preis, nach allem, was sich im Code ausdrücken lässt. Die meisten Typen haben ein Comparable (oder keines) und Dutzende nützliche Comparators.
Der Vertrag: −/0/+
Beide Methoden geben einen int zurück, dessen Vorzeichen die Antwort ist:
- negativ —
akommt vorb - null — für Sortierzwecke gleich
- positiv —
akommt nachb
Die genaue Größenordnung spielt keine Rolle. -1 und -1_000_000 bedeuten dasselbe. Niemals return a.size - b.size verwenden, wenn ein Überlauf möglich ist: Das Subtrahieren von Integer.MIN_VALUE von einer positiven Zahl führt zu einem Überlauf. Stattdessen Integer.compare(a.size(), b.size()) verwenden — das ist überlaufsicher und hat dieselbe Anzahl von Zeichen.
Comparable<T> — natürliche Ordnung
Ein Typ implementiert Comparable<Self> und stellt compareTo bereit:
public record Version(int major, int minor, int patch) implements Comparable<Version> {
@Override public int compareTo(Version other) {
int m = Integer.compare(this.major, other.major);
if (m != 0) return m;
int n = Integer.compare(this.minor, other.minor);
if (n != 0) return n;
return Integer.compare(this.patch, other.patch);
}
}Nun funktionieren Collections.sort(versions), versions.stream().sorted(), new TreeSet<Version>() und new TreeMap<Version, X>() alle ohne zusätzliche Argumente.
Der Vertrag hat drei Regeln, die jedes compareTo einhalten muss:
- Antisymmetrie —
a.compareTo(b)undb.compareTo(a)haben entgegengesetzte Vorzeichen. - Transitivität — wenn
a < bundb < c, danna < c. - Konsistenz mit
equals(dringend empfohlen) —a.compareTo(b) == 0genau dann, wenna.equals(b).
Die dritte Regel wird am häufigsten versehentlich gebrochen. BigDecimal ist das bekannte Beispiel: new BigDecimal("1.0").compareTo(new BigDecimal("1.00")) ist 0, aber .equals gibt false zurück. Dadurch werden ein TreeSet<BigDecimal> und ein HashSet<BigDecimal> unterschiedlich darüber urteilen, ob "1.0" und "1.00" Duplikate sind. Wenn möglich, sollte man sie konsistent halten.
Comparator<T> — externe Ordnung
Ein Comparator ist ein separates Objekt. Er kann beliebige zwei Ts vergleichen, auch Typen, die man selbst nicht geschrieben hat:
Comparator<String> byLength = (a, b) -> Integer.compare(a.length(), b.length());
list.sort(byLength);Da Comparator<T> ein funktionales Interface ist (eine abstrakte Methode, compare), ist jeder Comparator nur ein Lambda oder eine Methodenreferenz. Das ist die moderne Form von Comparator-Code — man schreibt fast nie mehr eine vollständige anonyme Klasse.
Die Builder-Methoden auf Comparator
Die Klasse verfügt über statische Factory-Methoden, die das Erstellen von Comparatoren kurz und lesbar machen:
Comparator<Person> byAge = Comparator.comparingInt(Person::age);
Comparator<Person> byName = Comparator.comparing(Person::name);
Comparator<Person> byNameCi = Comparator.comparing(Person::name, String.CASE_INSENSITIVE_ORDER);
Comparator<Person> oldestFirst = byAge.reversed();
Comparator<String> nullsFirst = Comparator.nullsFirst(Comparator.naturalOrder());Primitive Builder-Methoden sollte man verwenden — comparingInt, comparingLong, comparingDouble — wenn der Schlüssel ein primitiver Typ ist. Sie vermeiden Boxing bei jedem Vergleich, was sich bei einer langen Sortierung summiert.
Verkettete Comparatoren mit thenComparing
Ein weiterer Grund, die Builder-Methoden zu bevorzugen: Man kann mehrere Schlüssel verketten.
Comparator<Person> ordering =
Comparator.comparing(Person::lastName)
.thenComparing(Person::firstName)
.thenComparingInt(Person::age);Das liest sich von oben nach unten als „Primärschlüssel Nachname; Gleichstand durch Vorname auflösen; dann nach Alter." thenComparing wird auf dem vorherigen Comparator aufgerufen und gibt einen neuen zurück, der den zweiten Schlüssel nur dann heranzieht, wenn der erste einen Gleichstand ergab. Die Kette ist beliebig lang.
reversed(), nullsFirst, nullsLast
Drei Modifikatoren kommen häufig vor:
reversed()kehrt die Reihenfolge eines beliebigen Comparators um.byAge.reversed()bedeutet „Älteste zuerst."nullsFirst(cmp)kapselt einen Comparator so, dassnull-Werte als kleiner als jeder Nicht-null-Wert behandelt werden. Nützlich beim Sortieren von Collections, dienullenthalten können.nullsLast(cmp)ist das symmetrische Gegenstück.
reversed() darf nicht auf einem verketteten Comparator verwendet werden und erwartet, dass nur der letzte Schlüssel umgekehrt wird — reversed() kehrt die gesamte Ordnung um, jeden Schlüssel in der Kette.
Comparable vs. Comparator in JDK-APIs
Viele Methoden existieren in zwei Varianten — eine, die die natürliche Ordnung verwendet, und eine, die einen Comparator entgegennimmt:
| Operation | Überladung mit natürlicher Ordnung | Überladung mit Comparator |
|---|---|---|
| Liste sortieren | Collections.sort(list) | Collections.sort(list, cmp) |
| Liste sortieren (modern) | list.sort(null) | list.sort(cmp) |
| Stream sortieren | stream.sorted() | stream.sorted(cmp) |
| Baumbasiertes Set | new TreeSet<>() | new TreeSet<>(cmp) |
| Baumbasierte Map | new TreeMap<>() | new TreeMap<>(cmp) |
| Min/Max | Collections.min(list) | Collections.min(list, cmp) |
| Binäre Suche | Collections.binarySearch(list, key) | Collections.binarySearch(list, key, cmp) |
PriorityQueue | natürliche Ordnung des Elementtyps | Konstruktor nimmt einen Comparator entgegen |
Die Varianten mit natürlicher Ordnung erfordern, dass der Elementtyp Comparable implementiert. Wenn das nicht der Fall ist und man sie trotzdem aufruft, erhält man zur Laufzeit eine ClassCastException — keinen Compilerfehler — da der Cast innerhalb der Sortierungsimplementierung stattfindet.
Ein praktisches Beispiel: natürliche Ordnung, benutzerdefinierte Comparatoren, verkettete Schlüssel, Nullwerte
Das folgende Programm definiert einen Record mit einer natürlichen Ordnung (Comparable) sowie drei externe Ordnungen: nach einem einzelnen Schlüssel, nach verketteten Schlüsseln mit umgekehrtem Sekundärschlüssel und eine, die null-Einträge toleriert.
Was sich aus der Ausgabe ergibt:
- Die
Comparable-Implementierung hat nach Name sortiert und Namensgleichstände nach Alter aufgelöst. Kein expliziter Comparator war nötig — natürliche Ordnung ist der Standard fürCollections.sortund verwandte Methoden. Comparator.comparingDouble(Person::salary)ist kürzer und schneller als(a, b) -> Double.compare(a.salary(), b.salary()), da es Boxing vermeidet.- Der verkettete Comparator sortierte primär nach Alter und verwendete
reversed()nur für den Gehalt-Schlüssel — das ist das richtige Muster, wenn man für verschiedene Schlüssel unterschiedliche Richtungen möchte. Im Vergleich dazu würde.reversed()auf der gesamten Kette beide Schlüssel umkehren. nullsFirstermöglichte es dem Comparator, eine Liste mitnull-Einträgen zu verarbeiten, ohne eineNullPointerExceptionauszulösen. Ohne diesen Wrapper würde der erste Vergleich mit einemnullabstürzen.- Der „Subtraktionstrick" lieferte die falsche Antwort für
Integer.MAX_VALUE - (-1): Diese Berechnung läuft über und ergibt eine negative Zahl, sodassbadMAX_VALUEals kleiner als-1meldet.Integer.compareliefert jederzeit das richtige Vorzeichen. Immer bevorzugen.
Was kommt als Nächstes
Jetzt sind Iteration (Iterator / ListIterator) und Ordnung (Comparable / Comparator) abgedeckt. Das nächste Kapitel fasst beides in der Hilfsklasse java.util.Collections zusammen — dem statischen Werkzeugkasten mit sort, search, reverse, shuffle, min, max und „diese Collection als unveränderlich kapseln"-Methoden, die auf beliebigen List-, Set- oder Map-Instanzen arbeiten. Danach behandeln zwei kurze Kapitel speziell das Sortieren und Suchen.