Lesson 11 · 11 min read · Java for placements

Generics, equals and hashCode, and sorting

Why generics exist and what erasure means, wildcards and PECS, the equals and hashCode contract, and Comparable against Comparator.

Three topics in one lesson because they are the same topic in disguise: all three are about telling Java how your own type should behave inside the standard library.

Generics

Before Java 5, a collection held Object and you cast on the way out:

List names = new ArrayList();      // raw type
names.add("Raja");
names.add(42);                     // nobody stopped you
String s = (String) names.get(1);  // ClassCastException at RUN time

Generics move that failure to compile time:

List<String> names = new ArrayList<>();
names.add("Raja");
names.add(42);                     // compile error — caught before it ships
String s = names.get(0);           // no cast needed

So generics buy compile-time type safety and no casts. That is the answer to "why generics?".

Your own generic class and method

class Box<T> {
    private T value;

    void put(T value) { this.value = value; }
    T get() { return value; }
}

Box<String> b = new Box<>();
b.put("Raja");
String s = b.get();            // no cast
// a generic METHOD — the <T> goes before the return type
static <T> void printAll(List<T> items) {
    for (T item : items) System.out.println(item);
}

// bounded — T must be Comparable, so compareTo is available
static <T extends Comparable<T>> T max(List<T> items) {
    T best = items.get(0);
    for (T item : items) {
        if (item.compareTo(best) > 0) best = item;
    }
    return best;
}

<T extends Comparable<T>> is a bounded type parameter: it constrains what T may be, which is what lets you call compareTo inside. Convention is T for type, E for element, K/V for key and value, R for result.

Type erasure

Generics exist only at compile time. The compiler checks the types, then erases them so the bytecode contains raw types plus casts — which is how generic code stayed compatible with pre-Java-5 libraries.

Consequences you can be asked to explain:

List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass());   // true — both are just ArrayList
void f(List<String> xs) { }
void f(List<Integer> xs) { }     // compile error: same erasure
class Box<T> {
    T[] array = new T[10];       // compile error: cannot create an array of T
    T[] ok = (T[]) new Object[10];   // the usual workaround, with a warning
}
if (list instanceof List<String>) { }   // compile error — the type is gone at run time
if (list instanceof List<?>) { }        // allowed

The one-sentence answer: type erasure means generic type information is removed after compilation, so it is unavailable at run time.

Wildcards

List<?> anything;                       // some unknown type — read-only
List<? extends Number> numbers;         // Number or a subclass — you can READ a Number
List<? super Integer> ints;             // Integer or a supertype — you can WRITE an Integer

Why ? extends is read-only is worth understanding rather than memorising:

List<? extends Number> xs = new ArrayList<Integer>();
Number n = xs.get(0);        // safe — whatever it holds IS a Number
xs.add(1);                   // compile error — it might be a List<Double>

The mnemonic is PECS: Producer Extends, Consumer Super. If the parameter produces values for you to read, use extends; if it consumes values you write into it, use super.

And note that generics are not covariant, unlike arrays:

List<Object> objs = new ArrayList<String>();     // compile error
Object[] arr = new String[3];                    // allowed...
arr[0] = 42;                                     // ...and throws ArrayStoreException at run time

Java's designers made generics invariant precisely to move that failure to compile time. Arrays kept the old, unsafe behaviour for backward compatibility.

equals and hashCode

Every class inherits both from Object. The inherited versions compare identity — so two objects with identical contents are not equal:

class Student {
    String roll;
    Student(String roll) { this.roll = roll; }
}

Student a = new Student("05H4");
Student b = new Student("05H4");
System.out.println(a.equals(b));        // false — Object.equals is ==

Set<Student> set = new HashSet<>();
set.add(a);
set.add(b);
System.out.println(set.size());         // 2 — the set sees two different students

The contract

@Override
public boolean equals(Object o) {
    if (this == o) return true;                       // same object — fast path
    if (o == null || getClass() != o.getClass()) return false;
    Student other = (Student) o;
    return Objects.equals(roll, other.roll);          // null-safe
}

@Override
public int hashCode() {
    return Objects.hash(roll);                        // must use the SAME fields
}

equals must be:

  • Reflexivex.equals(x) is true.
  • Symmetric — if x.equals(y) then y.equals(x).
  • Transitive — if x.equals(y) and y.equals(z) then x.equals(z).
  • Consistent — repeated calls give the same answer while nothing changes.
  • Null-safex.equals(null) is false, never a NullPointerException.

And the rule that binds them together:

If two objects are equal, they must have the same hash code. The reverse is not required — unequal objects may share a hash code, which is simply a collision.

Note the signature: equals(Object o), not equals(Student o). Writing the latter overloads instead of overriding, and the collections keep calling Object.equals. This is why @Override is worth writing — it catches exactly this.

What breaks if you get it wrong

equals without hashCode: two equal objects can have different hash codes, so they land in different buckets and a HashMap never finds the one you stored.

// equals overridden, hashCode NOT
Map<Student, Integer> map = new HashMap<>();
map.put(new Student("05H4"), 87);
System.out.println(map.get(new Student("05H4")));   // null — wrong bucket

hashCode without equals: two objects hash to the same bucket, but the bucket's equals check fails, so you get duplicates in a HashSet.

A mutable field in hashCode: the object's hash changes after insertion and it becomes unfindable in the map it is sitting in. Use immutable fields — ideally an identifier.

Student s = new Student("05H4");
Set<Student> set = new HashSet<>();
set.add(s);
s.roll = "05H5";                      // hash changed
System.out.println(set.contains(s));  // false — still in the set, unreachable

This is the reason String is immutable, and the reason a good hashCode uses only fields that do not change.

A record generates both correctly from all its components, which is one of the best reasons to use one for a value type.

Comparable and Comparator

Comparable defines a type's natural order, inside the class, with one method:

class Student implements Comparable<Student> {
    String name;
    int marks;

    @Override
    public int compareTo(Student other) {
        return Integer.compare(this.marks, other.marks);    // ascending by marks
    }
}

List<Student> list = new ArrayList<>(...);
Collections.sort(list);          // uses compareTo

Comparator defines an order outside the class, so you can have several:

list.sort(Comparator.comparingInt(s -> s.marks));                    // by marks
list.sort(Comparator.comparing((Student s) -> s.name));              // by name
list.sort(Comparator.comparingInt((Student s) -> s.marks).reversed());  // descending

// marks descending, then name ascending — the ranking pattern
list.sort(Comparator.comparingInt((Student s) -> -s.marks)
                    .thenComparing(s -> s.name));

The contract for compareTo and compare: return a negative number if the first argument comes first, zero if they tie, positive if the second comes first. It must be consistent — if a.compareTo(b) is negative then b.compareTo(a) must be positive.

One trap: never implement it by subtracting:

return this.marks - other.marks;         // overflows for large values
return Integer.compare(this.marks, other.marks);   // correct

a - b with a = 2_000_000_000 and b = -2_000_000_000 overflows and returns a negative number — producing a comparator that violates its own contract and can make sort throw IllegalArgumentException: Comparison method violates its general contract.

Comparable Comparator
Where inside the class outside
Method compareTo(T) compare(T, T)
How many orders one — the natural one as many as you like
Modifies the class yes no
Used by Collections.sort(list), TreeSet list.sort(cmp), new TreeSet<>(cmp)

Rule of thumb: if there is one obvious order for the type, make it Comparable. For every other order, write a Comparator.

What to take into an interview

  • Generics give compile-time type safety and remove casts.
  • Type erasure removes generic types after compilation, so no new T[], no overload on List<String> versus List<Integer>, no instanceof List<String>.
  • PECS: Producer Extends, Consumer Super. Generics are invariant; arrays are covariant and unsafe.
  • Override equals(Object) — not equals(Student) — and always @Override.
  • Equal objects must have equal hash codes. Override both, from the same immutable fields.
  • equals without hashCode breaks HashMap lookups; a mutable field in hashCode loses the object.
  • Comparable is one natural order inside the class; Comparator is any number of orders outside.
  • Compare with Integer.compare, never by subtracting.
Lesson 11 of 12 · all 12 are free to read, no account. Course contents