Challenge

The set that grew duplicates

20 minintermediate18 yrs

Edge cases. You have to reason, and two valid fixes differ.

What this teaches

  • Overriding equals() without hashCode() loses entries silently
  • The two methods must read the same fields — that agreement is the contract
  • A record fixes it structurally; a hand-written hashCode() fixes it locally
  • Nothing throws, so only a test catches this

Starter

Starter.java
import java.util.*;

/**
 * Challenge: this set is supposed to hold three distinct tags. It holds five.
 *
 * Nothing throws. size() is simply wrong, and contains() lies. Work out why
 * before you change anything, then fix it two different ways and decide which
 * one you would ship.
 */
public class Starter {

    static final class Tag {
        private final String name;
        private final String colour;

        Tag(String name, String colour) {
            this.name = name;
            this.colour = colour;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof Tag other)) return false;
            return name.equals(other.name) && colour.equals(other.colour);
        }

        // TODO: something is missing here.

        @Override
        public String toString() {
            return name + "/" + colour;
        }
    }

    public static void main(String[] args) {
        List<Tag> incoming = List.of(
            new Tag("urgent", "red"),
            new Tag("later", "grey"),
            new Tag("urgent", "red"),      // duplicate of the first
            new Tag("blocked", "amber"),
            new Tag("later", "grey"));     // duplicate of the second

        Set<Tag> distinct = new HashSet<>(incoming);

        System.out.println("incoming        = " + incoming.size());
        System.out.println("distinct tags   = " + distinct.size() + "   (should be 3)");
        System.out.println("contains urgent = "
            + distinct.contains(new Tag("urgent", "red")) + "   (should be true)");

        // Two objects equals() calls identical...
        Tag a = new Tag("urgent", "red");
        Tag b = new Tag("urgent", "red");
        System.out.println("a.equals(b)     = " + a.equals(b));
        // ...that the set treats as different. Why?
        System.out.println("same hash       = " + (a.hashCode() == b.hashCode()));

        // TODO 1: fix Tag so distinct.size() is 3 without touching this method.
        //
        // TODO 2: in a comment, say what would ALSO change if you converted Tag
        // into a record instead. There are at least three differences, and one
        // of them is about subclassing.
    }
}

Run it locally:

cd exercises/java/oop/hashcode-equals-contract/02-challenge
javac Starter.java -d /tmp/out && java -cp /tmp/out Starter

Hints

  1. Hint 1

    HashSet finds the bucket from the hash first. If the hashes differ, equals() is never called at all.

  2. Hint 2

    Object.hashCode() is identity-based, so two distinct instances almost always land in different buckets.

  3. Hint 3

    There are two valid fixes and they are not equivalent — one changes the class's shape, the other changes one method.

Done when

  • The set holds one entry per logical tag, not one per instance
  • equals() and hashCode() read exactly the same fields
  • A comment explains what converting Tag to a record would also change

← Back to What is the contract between hashCode() and equals()?