Java: Coding-Style und die Implementierung zusammengesetzter Comparator-Klassen

von Hubert Schmid vom 2012-05-06

Ich bin kürzlich auf einen Blog-Artikel gestoßen, in dem es um die Implementierung mehrstufiger Vergleichsfunktionen ging. Dabei werden beim Vergleich zweier Objekte mehrere Attribute berücksichtigt, da die einzelnen Attribute nicht eindeutig sind. Das typische Beispiel ist der Vergleich zweier Personen anhand der Attribute Nachname, Vorname, Geburtsort und Geburtsdatum. In dem erwähnten Artikel wird im Wesentlichen die folgende Struktur empfohlen, wobei ich vom konkreten Beispiel ein wenig abstrahiere, um die eigentliche Aussage hervorzuheben:

class PersonComparator implements Comparator<Person> { @Override public int compare(Person lhs, Person rhs) { if (lhs.getLastName().compareTo(rhs.getLastName()) == 0) { if (lhs.getFirstName().compareTo(rhs.getFirstName()) == 0) { if (lhs.getPlaceOfBirth().compareTo(rhs.getPlaceOfBirth()) == 0) { return lhs.getDateOfBirth().compareTo(rhs.getDateOfBirth()); } else { return lhs.getPlaceOfBirth().compareTo(rhs.getPlaceOfBirth()); } } else { return lhs.getFirstName().compareTo(rhs.getFirstName()); } } else { return lhs.getLastName().compareTo(rhs.getLastName()); } } }

Mich irritiert diese Empfehlung, denn die Vorteile erschließen sich mir nicht. Trotzdem kann man festhalten, dass die Implementierung funktional korrekt und die Struktur (Palindrom) erkennbar und nachvollziehbar ist. Abgesehen davon habe ich schon schlimmere Implementierungen gesehen.

Ich rate von einer derartigen Struktur allerdings ab, da sie nicht-funktionale Eigenschaften wie Wartbarkeit und Effizienz nur unzureichend adressiert. Insbesondere verletzt sie eines der wichtigsten Prinzipien des Software-Engineering: Bringe zusammen, was zusammen gehört – und trenne, was getrennt gehört. Ein Palindrom ist das genaue Gegenteil davon. Und die folgende Implementierung zeigt, dass bereits die Negation der Bedingungen zu einer deutlich verbesserten Lesbarkeit führt:

class PersonComparator implements Comparator<Person> { @Override public int compare(Person lhs, Person rhs) { if (lhs.getLastName().compareTo(rhs.getLastName()) != 0) { return lhs.getLastName().compareTo(rhs.getLastName()); } else if (lhs.getFirstName().compareTo(rhs.getFirstName()) != 0) { return lhs.getFirstName().compareTo(rhs.getFirstName()); } else if (lhs.getPlaceOfBirth().compareTo(rhs.getPlaceOfBirth()) != 0) { return lhs.getPlaceOfBirth().compareTo(rhs.getPlaceOfBirth()); } else { return lhs.getDateOfBirth().compareTo(rhs.getDateOfBirth()); } } }

Die gerade durchgeführte Änderung hat die Situation zwar ein wenig verbessert, aber absolut betrachtet ist die Implementierung immer noch erschreckend schlecht. Ausstehend ist insbesondere die enorme Redundanz, die geradezu nach Fehlern während Weiterentwicklung schreit.

Der nächste Schritt könnte darin bestehen, die Zwischenergebnisse in lokalen Variablen zu speichern, so dass jeder Vergleich höchstens einmal durchgeführt wird. Das klingt zunächst sinnvoll. Schaut man sich jedoch das Ergebnis an, so wird deutlich, dass der Unterschied viel geringer als erwartet ist.

class PersonComparator implements Comparator<Person> { @Override public int compare(Person lhs, Person rhs) { int resultLastName = lhs.getLastName().compareTo(rhs.getLastName()); if (resultLastName != 0) { return resultLastName; } int resultFirstName = lhs.getFirstName().compareTo(rhs.getFirstName()); if (resultFirstName != 0) { return resultFirstName; } int resultPlaceOfBirth = lhs.getPlaceOfBirth().compareTo(rhs.getPlaceOfBirth()); if (resultPlaceOfBirth != 0) { return resultPlaceOfBirth; } return lhs.getDateOfBirth().compareTo(rhs.getDateOfBirth()); } }

Anstatt noch weiter in dieser Richtung rumzumurksen, schlage ich einen anderen Weg ein. Die folgende Implementierung setzt auf eine übergreifend einsetzbare Hilfsfunktion namens compoundCompare:

class PersonComparator implements Comparator<Person> { @Override public int compare(Person lhs, Person rhs) { return compoundCompare( lhs.getLastName().compareTo(rhs.getLastName()), lhs.getFirstName().compareTo(rhs.getFirstName()), lhs.getPlaceOfBirth().compareTo(rhs.getPlaceOfBirth()), lhs.getDateOfBirth().compareTo(rhs.getDateOfBirth())); } }

Die Hilfsfunktion macht dabei nichts anderes, als von den übergebenen Werten den Ersten zurückzugeben, der nicht 0 ist – und 0 falls kein solcher Wert existiert.

// utility method used with static import int compoundCompare(int... results) { for (int result : results) { if (result != 0) { return result; } } return 0; }

Diese Variante ist gut verständlich und einfach erweiterbar. Außerdem wird wie beabsichtigt jedes Attribut nur einmal verglichen. Der größte Nachteil liegt allerdings darin, dass bei jedem Aufruf alle Attribute verglichen werden, auch wenn nur die Ersten für die Unterscheidung notwendig sind. Das kann sich signifikant auf das Laufzeitverhalten auswirken.

Um dieses Problem zu umgehen, könnte man den Vergleich lazy ausführen – beispielsweise mit den für Java 8 geplanten Lambda-Ausdrücken. Das würde dann ungefähr wie folgt aussehen:

interface Compare { int compare(); } int compoundCompare(Compare... compares) { for (Compare compare : compares) { int result = compare.compare(); if (result != 0) { return result; } } return 0; } class PersonComparator implements Comparator<Person> { @Override public int compare(Person lhs, Person rhs) { return compoundCompare( () -> lhs.getLastName().compareTo(rhs.getLastName()), () -> lhs.getFirstName().compareTo(rhs.getFirstName()), () -> lhs.getPlaceOfBirth().compareTo(rhs.getPlaceOfBirth()), () -> lhs.getDateOfBirth().compareTo(rhs.getDateOfBirth())); } }

Dazu muss man allerdings sagen, dass Lambda-Funktionen selbst auch Laufzeitkosten verursachen. Im schlimmsten Fall werden sie durch anonyme, innere Klassen realisiert, so dass bei jeder Ausführung der compare-Methode vier Objektinstanzen (und zusätzlich die Array-Instanz für die variable Anzahl Argumente) erzeugt werden. Das wäre für eine Vergleichsoperation relativ viel, aber es bleibt abzuwarten, ob sich die Entwickler der JVM dazu etwas Gutes überlegt haben.

Meine Hoffnungen in dieser Richtung halten sich allerdings in Grenzen. Unabhängig davon habe ich noch eine weitere Variante – ausgehend vom Ziel: Wie würde man sich denn die Implementierung des Comparator wünschen? Wäre es nicht schön, etwas in der folgenden Art schreiben zu können?

Comparator<Person> comparator = new KeyMethodComparator<>( Person::getLastName, Person::getFirstName, Person::getPlaceOfBirth, Person::getDateOfBirth);

Aller Voraussicht nach wird das mit Java 8 möglich sein. Die Implementierung der Klasse KeyMethodComparator könnte dafür beispielsweise wie folgt aussehen:

interface KeyMethod<T> { @SuppressWarnings("rawtypes") Comparable get(T object); } class KeyMethodComparator<T> implements Comparator<T> { private final KeyMethod<T>[] keyMethods; @SafeVarargs public KeyMethodComparator(KeyMethod<T>... keyMethods) { this.keyMethods = keyMethods; } @Override public int compare(T lhs, T rhs) { for (KeyMethod<T> keyMethod : keyMethods) { @SuppressWarnings("unchecked") int result = keyMethod.get(lhs).compareTo(keyMethod.get(rhs)); if (result != 0) { return result; } } return 0; } }

Abschließend sei gesagt, dass es zu diesem Thema offensichtlich einiges zu sagen gibt, und dass die Aufgabe der Implementierung einer Vergleichsfunktion in Java nicht trivial ist. Bemerkenswert ist auch, dass eine elegante Lösung erst fast 20 Jahre nach der ersten Veröffentlichung von Java möglich sein wird.