Mối quan hệ giữa equals() và hashCode() trong Java

Trong bài viết này, chúng ta sẽ cùng nhau tìm hiểu về mối liên hệ giữa equals()hashCode() method, và làm thế nào để override chúng đúng cách.

Equals()

Trong java, Object class mặc định là cha của tất cả các class, bên trong nó định nghĩa sẵn 2 method equals() và  hashCode() – đều này có nghĩa rằng tất cả các class đều mặc định sẽ có 2 method này.

class Money {
    int amount;
    String currencyCode;

    public Money(int amount, String currencyCode) {
        this.amount = amount;
        this.currencyCode = currencyCode;
    }
}

class Main {

    public static void main(String[] agrs) {
        Money income = new Money(55, "USD");
        Money expenses = new Money(55, "USD");
        boolean balanced = income.equals(expenses); // FALSE

    }
}

Rõ ràng là amountcurrencyCode của income, expenses đều bằng nhau, thế nhưng kết quả so sánh từ equals() lại trả về FALSE

Mặc định, equals() được triển khai trong Object class sẽ so sánh địa chỉ object thay vì so sánh giá trị các thuộc tính trong object. Hai instance incomeexpenses là duy nhất và được cấp phát vùng nhớ riêng trong heap

Các kết phép so sánh dưới đây sẽ phù hợp với implement mặc định của equals() method.

Money tmp = income;
boolean b1 = income.equals(income); // true
boolean b2 = tmp.equals(income); // true

Overriding equals()

Nếu bạn muốn equals() so sánh giá trị các thuộc tính trong object thay vì so sánh địa chỉ thì có thể override equals().

class Money {
    int amount;
    String currencyCode;

    public Money(int amount, String currencyCode) {
        this.amount = amount;
        this.currencyCode = currencyCode;
    }

    @Override
    public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof Money))
            return false;
        Money other = (Money)o;
        boolean currencyCodeEquals = (this.currencyCode == null && other.currencyCode == null)
                || (this.currencyCode != null && this.currencyCode.equals(other.currencyCode));
        return this.amount == other.amount && currencyCodeEquals;
    }
}

class Main {

    public static void main(String[] agrs) {
        Money income = new Money(55, "USD");
        Money expenses = new Money(55, "USD");
        boolean balanced = income.equals(expenses); // TRUE 
    }
}

equals() Contract

Java SE đưa ra một số quy định chúng ta phải tuân thủ khi triển khai equals() method:

  • Phản xạ: Một object phải bằng chính nó
  • Đối xứng: x.equals(y) trả về kết quả giống với y.equals(x)
  • Bắc cầu: Nếu x.equals(y), y.equals(z) thì x.equals(z)
  • Nhất quán: Giá trị của equals() chỉ thay đổi khi 1 trong 2 object được so sánh bởi equals() thay đổi.

Vi phạm tính đối xứng của equals() với thừa kế

Khi chúng ta tạo 1 class thừa kế từ một class đã override equals() method. Hãy xem ví dụ sau:

Tạo class WrongVoucher thừa kế Money

class WrongVoucher extends Money {

    private String store;

    public WrongVoucher(int amount, String currencyCode, String store) {
        super(amount, currencyCode);
        this.store = store;
    }

    @Override
    public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof WrongVoucher))
            return false;
        WrongVoucher other = (WrongVoucher)o;
        boolean currencyCodeEquals = (this.currencyCode == null && other.currencyCode == null)
                || (this.currencyCode != null && this.currencyCode.equals(other.currencyCode));
        boolean storeEquals = (this.store == null && other.store == null)
                || (this.store != null && this.store.equals(other.store));
        return this.amount == other.amount && currencyCodeEquals && storeEquals;
    }
    
}

Xem kết quả dưới đây để thấy trường hợp vi phạm tính đối xứng của equals().

class Main {

    public static void main(String[] agrs) {
        Money cash = new Money(42, "USD");
        WrongVoucher voucher = new WrongVoucher(42, "USD", "Amazon");

        boolean b1 = voucher.equals(cash); // FALSE
        
        boolean b2 = cash.equals(voucher); // TRUE

    }
}

Giải thích:

  • WrongVoucher class thừa kế từ Money, nên 1 instance của WrongVoucher class cũng là instance của Money class, đồng thời nó chứa đầy đủ các giá trị của Money nên equals() của Money sẽ trả về true.
  • Thế nhưng, Money instance lại không phải là instance của WrongVoucher class nên kết quả là false

Như vậy đã vi phạm nguyên tắc đối xứng nếu x.equals(y) true thì y.equals(x) cũng phải true.

Tránh vi phạm equals() bất đối xứng

Để tránh vi phạm equals() bất đối xứng, chúng ta nên áp dụng mối quan hệ HAS-A thay cho thừa kế. 

Chúng ta sẽ sử dụng Money class như là 1 thuộc tính của Voucher thay vì thừa kế từ nó. 

class Voucher {
 
    private Money value;
    private String store;
 
    Voucher(int amount, String currencyCode, String store) {
        this.value = new Money(amount, currencyCode);
        this.store = store;
    }
 
    @Override
    public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof Voucher))
            return false;
        Voucher other = (Voucher) o;
        boolean valueEquals = (this.value == null && other.value == null)
          || (this.value != null && this.value.equals(other.value));
        boolean storeEquals = (this.store == null && other.store == null)
          || (this.store != null && this.store.equals(other.store));
        return valueEquals && storeEquals;
    }
 
    // other methods
}

hashCode()

hashCode() trả về một số nguyên đại diện cho 1 instance của class. Khi 2 object là bằng nhau thì hashCode() method của chúng cũng phải trả về giá trị bằng nhau. Đó là lý do nếu đã override equals() method thì cũng phải override hashCode() method.

hashCode() Contract

Java SE cũng đưa ra một số quy định cần phải tuân thủ khi override hashCode method.

  • Thống nhất trong nội bộ: Giá trị hashCode() sẽ không thay đổi trong các lần gọi trên cùng 1 object. Các object bằng nhau thì hashCode() cũng phải có giá trị bằng nhau.
  • Sự va chạm: Các đối tượng không bằng nhau có thể có giá trị hashCode() giống nhau.

Vi phạm tính nhất quán của equals() và hashCode()

Theo nguyên tắc mà hashCode() đưa ra thì 2 object bằng nhau thì phải có cùng mã hashCode(), vì vậy khi chúng ta override equals() method thì nhất định phải override hashCode() method.

Xét ví dụ dưới đây

class Team {

    String city;
    String department;

    public Team(String city, String department) {
        this.city = city;
        this.department = department;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Team team = (Team) o;
        return Objects.equals(city, team.city) &&
                Objects.equals(department, team.department);
    }
}

Team class chỉ override equals() method, nhưng lại không override hashCode() method nên nó vẫn sẽ sử dụng hashCode() của Object class. Như vậy các instance của Team class sẽ luôn có giá trị hashCode() khác nhau kể của khi chúng bằng nhau. Như vậy vi phạm tính thống nhất của equals() và hashCode().

class Main {
    public static void main(String[] agrs) {
        Team t1 = new Team("HCM", "development");
        Team t2 = new Team("HCM", "development");
        boolean b = t1.equals(t2);

        System.out.println("Equals: " + b);
        System.out.println(t1.hashCode());
        System.out.println(t2.hashCode());
    }
}

Output

Equals: true
460141958
1163157884

Mối liên hệ giữa HashMap Key với hashCode()

HashMap là cấu trúc dữ liệu dạng <key, value>, khi chúng ta truy xuất các phần tử theo object key thì HashMap sẽ kiểm tra xem có phần tử nào có key trùng giá trị với hashCode() của object key thì tiến hành trả về.

class Main {
    public static void main(String[] agrs) {
        Map<Team,String> leaders = new HashMap<>();
        leaders.put(new Team("New York", "development"), "Anne");
        leaders.put(new Team("Boston", "development"), "Brian");
        leaders.put(new Team("Boston", "marketing"), "Charlie");

        Team myTeam = new Team("New York", "development");
        String myTeamLeader = leaders.get(myTeam); // NULL
    }
}

Mặc dù chính ta đã định nghĩa rằng 2 Team object bằng nhau khi chúng có cùng giá trị city department, trong ví dụ trên chúng ta mong muốn kết quả trả về là “Anne” thế nhưng kết quả lại là null.

Để có được kết quả nhưng mong muốn chúng ta cần override hashCode() method, thêm hashCode() method vào Team class.

@Override
public int hashCode() {
    return Objects.hash(city, department);
}

Implementation Helpers

Nếu các bạn đang sử dụng các IDE thông dụng như IntelliJ hay Eclipse etc thì không cần phải viết tay các method này mà có thể sử dụng tính năng generate của chúng. Ví dụ trong IntellIJ thì chỉ cần vào Code | Generate | equals() và hashCode().

Ngoài ra chúng ta có thể sử dụng Apache Commons LangGoogle Guava với sự hỗ trợ của các Helper class. Hoặc project lombok cũng hỗ trợ sinh code tự động cho equals()hashCode() method với @EqualsAndHashCode annotation.

Chúng ta có thể kiểm tra xem 1 class trong dự án có đảm bảo tuân thủ các quy định mà Java SE đưa ra khi làm việc với equals() hashCode() method với thư viện EqualsVerifier.

Để sử dụng EqualsVerifier trong project Maven thêm dependency

<dependency>
    <groupId>nl.jqno.equalsverifier</groupId>
    <artifactId>equalsverifier</artifactId>
    <version>3.0.3</version>
    <scope>test</scope>
</dependency>

Kiểm tra 1 class thỏa quy định của Java SE đưa ra cho equals()hashCode()

@Test
public void equalsHashCodeContracts() {
    EqualsVerifier.forClass(Team.class).verify();
}

EqualsVerifier ngoài kiểm tra các chuẩn từ Java SE thì nó còn kiểm tra thêm một số quy định nghiêm ngặt như nó đảm bảo equals()hashCode() không được ném NullPointerException

Một điểm quan trọng là EqualsVerifier cấu hình mặc định chỉ cho phép các thuộc tính immutable theo chuẩn Domain-Driven Design. 

Nếu gặp các ràng buộc trong quá trình biên dịch project chúng ta có thể thêm suppress(Warning.SPECIFIC_WARNING) vào nơi sử dụng EqualsVerifier

Tóm lược

Override equals() hashCode() method khi chúng ta muốn so sánh 2 object theo giá trị thay vì so sánh theo địa chỉ, thông thường cách này thường áp dụng khi project của chúng ta có những yêu cầu này thật sự. Còn thông thường mặc định java xác định 2 object là bằng nhau khi chúng có cùng địa chỉ, đây là cơ chế được dùng phổ biến hơn.

Nguồn tham khảo

https://www.baeldung.com/java-equals-hashcode-contracts

5 2 votes
Article Rating
Subscribe
Notify of
guest
2 Comments
Newest
Oldest Most Voted
Inline Feedbacks
View all comments
2
0
Would love your thoughts, please comment.x
()
x