equals- und hashCode-Contract

Wer bei diesem Titel mit einem grossen Gähnen denkt “Ach, nicht schon wieder diese alten Kamellen” verpasst etwas! Ich gebe es ja offen zu: Obschon ich zu den grossen Verfechtern von Test-First, TDD und generell Unit-Testing gehöre – spätestens wenn es bei Java um das Testen von equals() und hashCode() ging, habe auch ich häufig die Flügel gestreckt. Nun gibt es aber eine beeindruckende Wunderwaffe, mit der auch das gelingt!

Für die ganz eiligen Leser: EqualsVerifier (http://www.jqno.nl/equalsverifier) ist eine Library welche das vollautomatische Testen dieser zwei Methoden (und des zugrunde liegenden Contract) in vielen Fällen in einem Einzeiler ermöglicht!

Natürlich habe ich das sofort ausprobiert und dafür eine altbekannte Klasse Balloon aus dem Modul PRG2 verwendet, dich ich ohnehin etwas auffrischen sollte. Zum Zwecke der Übersichtlichkeit habe ich im folgenden Code sämtliche JavaDoc entfernt:

public final class Balloon {

    private String text;
    private int size;

    public Balloon(final String text) {
        this.text = text;
        this.size = 0;
    }

    public void blowUp() {
        size = size + 5;
    }

    public void deflate() {
        size = 0;
    }

    @Override
    public boolean equals(final Object other) {
        if (this == other) { // 1. Test auf Identität
            return true;
        }
        if (other == null) { // 2. Test auf null
            return false;
        }
        if (other.getClass() != this.getClass()) { // 3. Test auf Vergleichbarkeit
            return false;
        }
        if (!text.equals(((Balloon) other).text)) { // 4. Vergleich relev. Felder
            return false;
        }
        return true;
    }

    @Override
    public int hashCode() {
        final int hashvalue = text.hashCode();
        return hashvalue * 13 + size;
    }
}

Wie man an den Inline-Kommentaren unschwer erkennen kann, wird anhand dieses Codebeispieles auch tatsächlich in die Thematik von equals() eingeführt. Das perfekte Beispiel also für einen Test! Und das geht folgendermassen: Man fügt einfach die Jar-Datei equalsverifier-1.6.jar in den (test-)Classpath ein und implementiert einen sehr kompakten Unit-Test:

    @Test
    public void testEqualsContract() {
        EqualsVerifier.forClass(Balloon.class).verify();
    }

Das war’s auch schon! Wobei, halt – der Test failed dann mit folgender Meldung:

junit.framework.AssertionFailedError:
Non-nullity: equals throws NullPointerException on field text.

Huch! Was für eine Überraschung. Stimmt, das Attribut text könnte ja tatsächlich null sein. Und gemäss equals-Contract soll eine equals()-Methode niemals eine NullPointerException (NPE) werfen. Bei der Korrektur dieses Fehlers fällt dann auf, dass wenn die Attribute beider Objekte null sind, Gleichheit vorhanden ist (null == null). Die dafür notwendigen if-Konstrukte kann man sich aber ersparen, denn seit Java 7 gibt es die nützliche Klasse Objects welche unter anderem eine Hilfsmethode equals() anbietet welche das sehr vereinfacht:

    @Override
    public boolean equals(final Object other) {
        ...
        // 4. Effektiver Vergleich der Attribute (inkl. null)
        return Objects.equals(this.text, ((Balloon) other).text);
    }

Und wir testen erneut:

junit.framework.AssertionFailedError: Non-nullity:
hashCode throws NullPointerException on field text.

Hätte mir ja auch gleich auffallen können. Für die Methode hashCode() gilt das Gleiche und auch dort wird (muss!) ja auf das Attribut text zugegriffen werden. Also korrigieren wir auch das:

    @Override
    public int hashCode() {
        final int hashvalue = (text != null ? text.hashCode() : 7);
        return hashvalue * 13 + size;
    }

Und wir testen aufs Neue:

junit.framework.AssertionFailedError:
Mutability: equals depends on mutable field text.

Ok, ich gebe es ja zu: Dass das Attribut text eigentlich Immutable implementiert ist (es wird nur im Konstruktor gesetzt) könnte man mit einem final noch unterstreichen, und gleiches hat mir mein Freund und Helfer PMD ja schon längst gesagt… 😉 Also ergänze ich die Deklaration des Attributes mit einem final und Teste zum dritten Mal:

junit.framework.AssertionFailedError:
Significant fields: hashCode relies on size, but equals does not.

Meine Güte aber auch! Der hashCode-Contract sagt dazu: “If two objects are equal according to the equals(Object) method, then calling the hashCode method on each of the two objects must produce the same integer result.” – und hier wird doch tatsächlich die Grösse (size) des Ballons auch für die Berechnung des Hashwertes beigezogen. Also verbessern wir auch diesen Fehler:

    @Override
    public int hashCode() {
        return (text != null ? text.hashCode() : 7);
    }

Was uns auch noch eine lokale Variable erspart. Und was ist die Moral der Geschichte? Selten habe ich mich so sehr über ein

    [junit] Testsuite: ch.hslu.prg2.oop04.BalloonTest
    [junit] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.286 sec

BUILD SUCCESSFUL
Total time: 4 seconds

gefreut!

Nachtrag: Hinweis auf Klasse Objects und die Methode equals(Object a, Object b) ergänzt.

Leave a Reply

Your email address will not be published. Required fields are marked *