Introduction
When using Java's Map collection, behavior differences can emerge based on whether you use primitive types (or their wrapper classes) as keys versus using your own custom classes. Let’s explore this phenomenon with two code examples – one using a primitive type (int
and its wrapper class Integer
) and another using a custom class. We’ll then look at the output of each and discuss why these approaches yield different results. Finally, we'll delve into a solution in line with the principles from "Effective Java" by Joshua Bloch.
Experimenting with Maps
Using Primitive Type (int) and Its Wrapper (Integer) as Map Keys
Firstly, let’s consider a map using int
(and its wrapper class Integer
) as keys:
Map<Integer, String> mapWithIntegers = new HashMap<>();
mapWithIntegers.put(1, "Apple");
mapWithIntegers.put(2, "Banana");
System.out.println(mapWithIntegers.get(1)); // Output: Apple
System.out.println(mapWithIntegers.get(2)); // Output: Banana
Using a Custom Class as Map Keys
Now, let's use a custom class Fruit
as the key:
class Fruit {
private String name;
Fruit(String name) {
this.name = name;
}
// getters and setters
}
Map<Fruit, String> mapWithCustomObjects = new HashMap<>();
mapWithCustomObjects.put(new Fruit("Apple"), "Red");
mapWithCustomObjects.put(new Fruit("Banana"), "Yellow");
System.out.println(mapWithCustomObjects.get(new Fruit("Apple"))); // Output: null
System.out.println(mapWithCustomObjects.get(new Fruit("Banana"))); // Output: null
Observations
In the first scenario, using integers as keys, the map retrieves the values correctly. However, in the second case, despite using seemingly identical keys (custom Fruit
objects), the map returns null
. Let's understand why.
Behind the Scenes: Primitive Types and Wrapper Classes vs Custom Objects
Why Integer Works
In Java, primitive types such as int
are automatically wrapped into their corresponding wrapper classes (Integer
in this case) when used in collections. These wrapper classes have equals()
and hashCode()
methods properly implemented. They compare and hash based on value, not reference, allowing the map to accurately retrieve values.
Why the Custom Class Fails
By default, our custom class Fruit inherits equals()
and hashCode()
from Object
. These methods compare and hash based on object references. Hence, two different instances of Fruit with the same name are considered unequal, and their hash codes differ, leading to failed map lookups.
The Solution: Implementing equals() and hashCode()
Following the principles from "Effective Java", we need to override the equals()
and hashCode()
methods in our custom class.
Why Override equals() and hashCode()?
When you use a custom class as a key in a map, Java needs a way to determine if two keys are "equal" and to compute the storage location (hash) for each key. The default implementations of equals()
and hashCode()
in the Object class are often insufficient for custom objects. The default equals()
method simply checks if two references point to the same object, and hashCode()
returns a distinct integer for each object, even if they are "equal" in terms of their properties.
Crafting the equals() Method
According to "Effective Java", the equals()
method must be:
- Reflexive: An object must equal itself.
- Symmetric: If A equals B, then B must equal A.
- Transitive: If A equals B and B equals C, then A must equal C.
- Consistent: Repeated calls must consistently return the same value.
- Non-nullity: Any non-null object should not equal null.
The hashCode() Contract
The hashCode()
method must produce the same integer result for two objects that are equal according to equals()
. It's not required to produce a unique hash code for each distinct object
Overriding equals() and hashCode()
class Fruit {
private String name;
Fruit(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Fruit fruit = (Fruit) o;
return Objects.equals(name, fruit.name);
}
@Override
public int hashCode() {
int result = 17; // Initial non-zero value
result = 31 * result +
(name != null ? name.hashCode() : 0);
return result;
}
}
Explanation of hashCode()
Why start with a non-zero constant (like 17)?
- Starting with a non-zero constant reduces the risk of ending up with a hash code of 0 for distinct objects, which could lead to poor performance in hash tables.
Why multiply by 31?
- 31 is an odd prime number, chosen because it's an inexpensive operation to perform (shift and subtract) and historically has produced good distributions of hash codes.
- Multiplying by 31 creates a nice mix of the hash code bits,which tends to produce distinct hash codes for distinct objects, reducing collisions in hash tables.
Why add the result of name.hashCode()?
- The hash code of the
name
field is included to ensure that differentFruit
objects with different names produce different hash codes, adhering to the contract ofhashCode()
that equal objects must produce the same hash code, and unequal objects should ideally produce distinct hash codes.
Revisiting the Custom Class Example
With the overridden methods, let's use our Fruit class again:
Map<Fruit, String> mapWithCustomObjects = new HashMap<>();
mapWithCustomObjects.put(new Fruit("Apple"), "Red");
mapWithCustomObjects.put(new Fruit("Banana"), "Yellow");
System.out.println(mapWithCustomObjects.get(new Fruit("Apple"))); // Output: Red
System.out.println(mapWithCustomObjects.get(new Fruit("Banana"))); // Output: Yellow
Conclusion
The key takeaway is the importance of properly implementing equals() and hashCode() in custom classes for use as keys in maps. These methods ensure that map keys are compared and hashed based on their content rather than their memory references. By adhering to the guidelines from "Effective Java," we ensure our custom objects behave predictably in maps, leading to consistent and reliable applications.
REF: "Effective Java" by Joshua Bloch.
Top comments (0)