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 timeGenerics 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 neededSo 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 ArrayListvoid f(List<String> xs) { }
void f(List<Integer> xs) { } // compile error: same erasureclass 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<?>) { } // allowedThe 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 IntegerWhy ? 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 timeJava'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 studentsThe 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:
- Reflexive —
x.equals(x)is true. - Symmetric — if
x.equals(y)theny.equals(x). - Transitive — if
x.equals(y)andy.equals(z)thenx.equals(z). - Consistent — repeated calls give the same answer while nothing changes.
- Null-safe —
x.equals(null)is false, never aNullPointerException.
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 buckethashCode 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, unreachableThis 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 compareToComparator 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); // correcta - 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 onList<String>versusList<Integer>, noinstanceof List<String>. - PECS: Producer Extends, Consumer Super. Generics are invariant; arrays are covariant and unsafe.
- Override
equals(Object)— notequals(Student)— and always@Override. - Equal objects must have equal hash codes. Override both, from the same immutable fields.
equalswithouthashCodebreaksHashMaplookups; a mutable field inhashCodeloses the object.Comparableis one natural order inside the class;Comparatoris any number of orders outside.- Compare with
Integer.compare, never by subtracting.