DEV Community

Rajani K
Rajani K

Posted on

Chapter 3 - Methods Common to All Objects (Items 10-14)

Introduction

Welcome back to our "Effective Java" blog series! In this chapter, we're moving on to a critical part of object-oriented programming: understanding and correctly implementing the methods inherited from java.lang.Object. While these methods may seem straightforward, a flawed implementation can lead to subtle and hard-to-find bugs. As always, we'll see how Kotlin's modern design patterns can help us avoid these pitfalls from the start.

Chapter 3: Chapter 3 - Methods Common to All Objects

Item 10: Override equals() when you need a value-based equality

Summary

The equals() method is used to determine if two objects are logically equal. The default implementation in Object simply checks for reference equality (this == obj). You must override it when you want two distinct objects to be considered equal if their data is the same. However, a correct implementation must adhere to a strict contract.

The equals() Contract

  • Reflexive: x.equals(x) must be true.
  • Symmetric: If x.equals(y) is true, then y.equals(x) must be true.
  • Transitive: If x.equals(y) is true and y.equals(z) is true, then x.equals(z) must be true.
  • Consistent: Multiple calls to x.equals(y) return the same result, assuming the objects are not modified.
  • Non-null: x.equals(null) must be false.

Java

A manual implementation in Java is verbose and requires careful attention to detail.

import java.util.Objects;

public final class User {
    private final String name;
    private final String email;
    private final String phoneNumber;

    public User(String name, String email, String phoneNumber) {
        this.name = name;
        this.email = email;
        this.phoneNumber = phoneNumber;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(name, user.name) &&
               Objects.equals(email, user.email) &&
               Objects.equals(phoneNumber, user.phoneNumber);
    }
}
Enter fullscreen mode Exit fullscreen mode

Kotlin (Data Class)

This is where Kotlin's data class shines. A data class automatically generates equals(), hashCode(), and toString() methods based on its primary constructor properties. This is the idiomatic way to handle value-based equality in Kotlin.

data class User(val name: String, val email: String, val phoneNumber: String)
Enter fullscreen mode Exit fullscreen mode

This single line of code provides a robust and correct equals() implementation.

Kotlin (Non-Data Class)

For a regular class, you have to manually override equals(), just like in Java. It's a bit more verbose, but gives you more control.

class User(val name: String, val email: String, val phoneNumber: String) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is User) return false
        return name == other.name &&
               email == other.email &&
               phoneNumber == other.phoneNumber
    }
}
Enter fullscreen mode Exit fullscreen mode

Summary: Only override equals() when you need value equality. For simple cases in Kotlin, data class provides the perfect, foolproof solution, but it's important to know how to do it manually for more complex classes.

Item 11: Always Override hashCode() When You Override equals()

Summary

The hashCode() method must always be overridden if equals() is overridden. The Object contract specifies that two objects that are equal according to equals() must have the same hash code. Failure to do so will break hash-based collections like HashMap and HashSet.

Java

A good hashCode() implementation in Java is a combination of a prime number and the hash codes of the object's fields. The formula is: result = 31 * result + c. The Objects.hash utility is a convenient way to implement this in modern Java.

import java.util.Objects;

public final class User {
    // ...
    @Override
    public int hashCode() {
        return Objects.hash(name, email, phoneNumber);
    }
}
Enter fullscreen mode Exit fullscreen mode

Kotlin (Data Class)

Again, the data class simplifies this entirely. The hashCode() method is automatically generated and consistent with equals(), preventing a common source of bugs.

// The hashCode() is automatically generated and correct.
data class User(val name: String, val email: String, val phoneNumber: String)
Enter fullscreen mode Exit fullscreen mode

Kotlin (Non-Data Class)

For a regular class, you'll need to manually implement hashCode() as well. It's crucial that it's consistent with your equals() implementation.

class User(val name: String, val email: String, val phoneNumber: String) {
    override fun hashCode(): Int {
        var result = name.hashCode()
        result = 31 * result + email.hashCode()
        result = 31 * result + phoneNumber.hashCode()
        return result
    }
}
Enter fullscreen mode Exit fullscreen mode

Summary: The equals() and hashCode() methods are a pair. If you override one, you must override the other. In Kotlin, use a data class for this, or be diligent in your manual implementations.

Item 12: Always Override toString()

Summary

While not as critical as equals() and hashCode(), a good toString() implementation is vital for debugging and logging. The default implementation from Object is not useful.

Java

A manual toString() implementation should be clear, concise, and provide all relevant information about the object's state.

public final class User {
    // ...
    @Override
    public String toString() {
        return "User{" +
               "name='" + name + '\'' +
               ", email='" + email + '\'' +
               ", phoneNumber='" + phoneNumber + '\'' +
               '}';
    }
}
Enter fullscreen mode Exit fullscreen mode

Kotlin (Data Class)

Kotlin's data class also generates a useful toString() method for you, including all properties in the primary constructor.

// Automatically generates a readable toString() method.
data class User(val name: String, val email: String, val phoneNumber: String)
Enter fullscreen mode Exit fullscreen mode

Kotlin (Non-Data Class)

For a non-data class, you'll need to manually override toString() to get a meaningful representation.

class User(val name: String, val email: String, val phoneNumber: String) {
    override fun toString(): String {
        return "User(name=$name, email=$email, phoneNumber=$phoneNumber)"
    }
}
Enter fullscreen mode Exit fullscreen mode

Summary: A useful toString() method is essential. When you create a class, always consider how its string representation can help you understand its state.

Item 13: Override clone() Judiciously

Summary

The clone() method is part of a complex and flawed framework. The Cloneable interface is a marker interface, and Object.clone() is a protected method that performs a shallow copy. This approach is brittle and can lead to bugs. For instance, if an object contains a mutable field, a shallow copy will lead to two objects sharing the same mutable state, which is almost never what you want.

Java

To correctly implement clone(), you must handle both the Cloneable marker interface and potential CloneNotSupportedExceptions. Even then, it's often more trouble than it's worth.

// A flawed clone() implementation in Java.
public class MyObject implements Cloneable {
    private List<String> list;

    // ...

    @Override
    public MyObject clone() throws CloneNotSupportedException {
        // This is a shallow copy! The cloned object shares the same list.
        return (MyObject) super.clone();
    }
}
Enter fullscreen mode Exit fullscreen mode

A better solution is to use a copy constructor or a copy factory, which is a safer and more readable pattern.

Kotlin (Data Class)

Kotlin's data class provides a copy() method that is a perfect example of this idiomatic approach. The copy() method creates a new instance with the same properties as the original, allowing you to optionally change specific fields. It handles the deep vs. shallow copy behavior exactly as you would expect.

data class MyObject(val name: String, val list: MutableList<String>)

// Creating a deep copy using the copy() method.
val original = MyObject("A", mutableListOf("1", "2"))
val copy = original.copy(list = ArrayList(original.list))
Enter fullscreen mode Exit fullscreen mode

Kotlin (Non-Data Class)

Regular classes in Kotlin do not get an automatic copy() method. You'll need to create one yourself, or a copy constructor, to provide this functionality.

class MyObject(val name: String, val list: MutableList<String>) {
    fun copy(name: String = this.name, list: MutableList<String> = this.list): MyObject {
        return MyObject(name, list)
    }
}

// Creating a copy using our custom function.
val original = MyObject("A", mutableListOf("1", "2"))
val copy = original.copy(list = ArrayList(original.list))
Enter fullscreen mode Exit fullscreen mode

Summary: Avoid the clone() method. Use a copy constructor or, better yet, the copy() method in a Kotlin data class. For non-data classes, create your own copy() function.

Item 14: Consider Implementing Comparable

Summary

The Comparable interface is used for natural ordering. It contains a single method, compareTo(), which compares the this object to another object. Correctly implementing compareTo() allows objects to be sorted and to work with collections like TreeSet and TreeMap.

Java (Java 21)

Modern Java provides a more concise way to implement Comparable using Comparator.comparing and thenComparing, which is much cleaner and less error-prone than manual comparisons.

import java.util.Comparator;

public final class User implements Comparable<User> {
    private final String name;
    private final String email;
    private final String phoneNumber;

    public User(String name, String email, String phoneNumber) {
        this.name = name;
        this.email = email;
        this.phoneNumber = phoneNumber;
    }

    private static final Comparator<User> COMPARATOR =
        Comparator.comparing(User::getName)
                  .thenComparing(User::getEmail)
                  .thenComparing(User::getPhoneNumber);

    public String getName() { return name; }
    public String getEmail() { return email; }
    public String getPhoneNumber() { return phoneNumber; }

    @Override
    public int compareTo(User user) {
        return COMPARATOR.compare(this, user);
    }
}
Enter fullscreen mode Exit fullscreen mode

Kotlin

For a regular class, you implement Comparable and override compareTo() in the same way. The compareBy function provides a more idiomatic and readable alternative to manual comparison logic.

class User(val name: String, val email: String, val phoneNumber: String) : Comparable<User> {
    override fun compareTo(other: User): Int {
        return compareBy<User> { it.name }
            .thenBy { it.email }
            .thenBy { it.phoneNumber }
            .compare(this, other)
    }
}
Enter fullscreen mode Exit fullscreen mode

Summary: Use the Comparable interface to define a natural order for your objects, but be sure to adhere to its strict contract.

Wrap-up

The methods inherited from java.lang.Object are foundational to the Java platform. By correctly implementing equals(), hashCode(), toString(), and compareTo(), you ensure your objects behave predictably and work correctly with the rest of the Java ecosystem. The lessons we've learned here are a perfect example of why Kotlin's data class is such a powerful and effective tool, as it automates the implementation of these critical methods, helping you avoid common pitfalls.

Next Up

In our next post, we will begin exploring Chapter 4: Classes and Interfaces, and dive into how to design them for clarity and power. Stay tuned!

Top comments (0)