So sánh các đối tượng trong java

Người dịch: Trần Ngọc Quân - Học viên lớp Java08
Email liên hệ: quan31794@gmail.com
Bài viết gốc: https://www.baeldung.com/java-comparing-objects

1. Giới thiệu

So sánh các đối tượng (objects) là một tính năng quan trọng của các ngôn ngữ lập trình hướng đối tượng.
Trong bài viết này, chúng ta sẽ tìm hiểu một số tính năng của ngôn ngữ lập trình Java giúp người dùng so sánh các đối tượng. Bên cạnh đó, chúng ta cũng sẽ tham khảo thêm một số tính năng hữu ích từ các thư viện bên ngoài.

2. Toán tử == và !=

2.1. Kiểu dữ liệu nguyên thủy

Đối với kiểu dữ liệu nguyên thủy, các dữ liệu được coi là giống nhau khi giá trị của chúng bằng nhau:

assertThat(1 == 1).isTrue();

Nhờ vào cơ chế auto-boxing của Java, cách so sánh này cũng có thể áp dụng so sánh giữa một giá trị kiểu nguyên thủy với một biến kiểu tham chiếu của chính nó.

Integer a = new Integer(1);
assertThat(1 == a).isTrue();

Nếu hai số nguyên có giá trị khác nhau, toán tử == sẽ trả về false, trong khi toán tử != sẽ trả về true.

2.2. Kiểu dữ liệu đối tượng

Cùng xét trường hợp ta muốn so sánh hai đối tượng của lớp wrapper Integer có cùng giá trị khởi tạo:

Integer a = new Integer(1);
Integer b = new Integer(1);

assertThat(a == b).isFalse();

Hai đối tượng trên có dữ liệu giống nhau tuy nhiên lại là hai đối tượng khác nhau. Nói cách khác, chúng được lưu tại các vị trí khác nhau trong bộ nhớ stack, do mỗi đối tượng được khởi tạo thông qua toán tử “new”. Nếu ta gán a cho b, t sẽ có được kết quả khác:

Integer a = new Integer(1);
Integer b = a;

assertThat(a == b).isTrue();

Ví dụ khi sử dụng hàm Integer.valueOf():

Integer a = Integer.valueOf(1);
Integer b = Integer.valueOf(1);

assertThat(a == b).isTrue();

Trong trường hợp này, hai biến a và b được coi là bằng nhau. Nguyên nhân do hàm valueOf() sẽ lưu đối tượng Integer trong bộ nhớ cache, khi hàm valueOf() được gọi lại với cùng giá trị, nó sẽ trả về đối tượng Integer đã có trong bộ nhớ đệm mà không tạo thêm một đối tượng mới.
Java cũng áp dụng cơ chế tương tự cho String

assertThat("Hello!" == "Hello!").isTrue();

Tuy nhiên, nếu chúng được khởi tạo bằng toán tử new, kết quả nhận được sẽ là không bằng nhau.
Cuối cùng, hai giá trị null được coi là bằng nhau, trong khi tất cả các đối tượng không tham chiếu đến null đều được coi là khác null:

assertThat(null == null).isTrue();

assertThat("Hello!" == null).isFalse();

Hiển nhiên, hoạt động của các toán tử so sánh bằng có giới hạn nhất định. Giả sử, khi muốn các đối tượng được lưu ở những vị trí khác nhau trong bộ nhớ được coi là bằng nhau nếu chúng có cùng các thuộc tính, chúng ta cần xử lý như nào? Hãy cùng tìm hiểu trong phần tiếp theo của bài viết.

3. Phương thức Object#equals

Phương thức này được định nghĩa trong lớp Object để tất cả các đối tượng Java đều kế thừa nó. Mặc định, phương thức này sẽ so sánh địa chỉ lưu trữ đối tượng trong bộ nhớ, do vậy nó hoạt động giống với toán tử ==. Tuy nhiên, chúng ta có thể ghi đè phương thức này để tùy biến điều kiện được coi là bằng nhau cho các đối tượng theo mong muốn của mình.
Đầu tiên, thử kiểm tra điều gì sẽ xảy ra với các đối tượng đã định nghĩa sẵn trong Java như Integer:

Integer a = new Integer(1);
Integer b = new Integer(1);

assertThat(a.equals(b)).isTrue();

Phương thức equals() sẽ vẫn trả về true khi cả hai đối tượng có cùng giá trị.
Lưu ý, ta chỉ có thể gọi null với vai trò là một đối số của hàm equals() nhưng không thể gọi hàm equals() cho null ( sẽ tạo ra ngoại lệ NullPointerException).
Ta cũng có thể sử dụng hàm equals() với một đối tượng do người dùng tự định nghĩa. Lấy ví dụ với lớp Person:

public class Person {
    private String firstName;
    private String lastName;

    public Person(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
}

Người dùng có thể ghi đè phương thức equals() của lớp Person để có thể so sánh hai đối tượng Person dựa theo các thuộc tính của chúng.

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Person that = (Person) o;
    return firstName.equals(that.firstName) &&
      lastName.equals(that.lastName);
}

4. Phương thức tĩnh Objects#equals

Như đã đề cập bên trên, chúng ta không thể gọi hàm Object#equals() cho null, việc này sẽ dẫn đến ngoại lệ NullPointerException.
Phương thức equals() của lớp helper Objects sẽ giúp người dùng giải quyết vấn đề trên. Nó sẽ nhận hai đối số truyền vào và so sánh chúng đồng thời xử lý các giá trị null.

@Override
Person joe = new Person("Joe", "Portman");
Person joeAgain = new Person("Joe", "Portman");
Person natalie = new Person("Natalie", "Portman");

assertThat(Objects.equals(joe, joeAgain)).isTrue();
assertThat(Objects.equals(joe, natalie)).isFalse();

Như đã giải thích, phương thức equals() xử lý cả các giá trị null. Vậy nên nếu cả hai đối số truyền vào là null, hàm sẽ trả về true, nếu chỉ một đối số là null, hàm sẽ trả về false
Tính năng này trở nên hữu ích với người dùng trong một số trường hợp. Giả sử, ta muốn thêm một thuộc tính không bắt buộc ( nullable) cho lớp Person:

public Person(String firstName, String lastName, LocalDate birthDate) {
    this(firstName, lastName);
    this.birthDate = birthDate;
}

Sau đó, phương thức equals() cần được ghi đè để thêm trường hợp xử lý giá trị null:

birthDate == null ? that.birthDate == null : birthDate.equals(that.birthDate);

Tuy nhiên, việc thêm quá nhiều thuộc tính không bắt buộc ( nullable) vào một lớp, sẽ dẫn đến sự phức tạp cho công tác xử lý. Bởi vậy sử dụng hàm Objects#equals() sẽ giúp code tối ưu và dễ đọc hơn:

Objects.equals(birthDate, that.birthDate);

5. Lớp giao tiếp Comparable (Interface)

Việc so sánh có thể được sử dụng để sắp xếp các đối tượng theo một thứ tự nhất định. Comparable interface sẽ giúp người dùng định nghĩa thứ tự sắp xếp giữa các đối tượng bằng cách xác định một đối tượng lớn hơn, bằng hay nhỏ hơn một đối tượng khác.
Comparable interface là geneneric (nhận tham số là một kiểu dữ liệu) chỉ có một hàm duy nhất là compareTo(), nhận đối số có cùng kiểu dữ liệu và trả về giá trị int. Giá trị trả về là 0 nếu đối tượng (this) bằng đối số truyền vào, giá trị âm nếu this nhỏ hơn đối số và ngược lại.
Xét ví dụ với lớp Person, ta muốn so sánh các đối tượng Person thông qua thuộc tính lastName:

public class Person implements Comparable<Person> {
    //...

    @Override
    public int compareTo(Person o) {
        return this.lastName.compareTo(o.lastName);
    }
}

Hàm compareTo() sẽ trả về giá trị âm int nếu đối số Person truyền vào nhỏ hơn this, 0 nếu có cùng lastName, và dương khi ngược lại.

6. Lớp giao tiếp Comparator (Interface)

Comparator interface là geneneric (nhận tham số là một kiểu dữ liệu) có hàm compare nhận hai đối số theo kiểu dữ liệu khai báo và trả về một giá trị interger. Chúng ta đã thấy khái niệm tương tự trước đó của Comparable interface.
Comparator về cơ bản là tương tự, tuy nhiên, nó tách biệt với việc khai báo class. Do vậy, chúng ta có thể định nghĩa đồng thời nhiều Comparators cho một class thay vì chỉ có thể định nghĩa 1 Comparable khi khai báo class.
Giả sử ta có một trang web hiển thị thông tin nhiều người trong một bảng dữ liệu và cần cung cấp các tính năng sắp xếp thứ tự theo lastName hoặc theo firstName. Trường hợp này ta buộc phải sử dụng thêm Comparators thay vì chỉ có Comparable như ban đầu.
Đầu tiên, ta cần tạo một Person Comparator để so sánh dựa theo thuộc tính firstName:

Comparator<Person> compareByFirstNames = Comparator.comparing(Person::getFirstName);

Tiếp theo, ta có thể sắp xếp 1 danh sách people sử dụng Comparator vừa tạo:

Person joe = new Person("Joe", "Portman");
Person allan = new Person("Allan", "Dale");

List<Person> people = new ArrayList<>();
people.add(joe);
people.add(allan);

people.sort(compareByFirstNames);

assertThat(people).containsExactly(allan, joe);

Ngoài ra, ta có thể sử dụng những phương thức khác trong Comparator interface để triển khai hàm compareTo():

@Override
public int compareTo(Person o) {
    return Comparator.comparing(Person::getLastName)
      .thenComparing(Person::getFirstName)
      .thenComparing(Person::getBirthDate, Comparator.nullsLast(Comparator.naturalOrder()))
      .compare(this, o);
}

Trong trường hợp này, đầu tiên ta so sánh lastName rồi đến firstName. Sau đó, ta so sánh birthDate, nhưng vì nó là thuộc tính nullable, để xử lý, ta truyền thêm đối số thứ 2 để thông báo khi gặp giá trị null sẽ tiến hành so sánh theo mặc định (hàm nullsLast sẽ coi 1 giá trị null lớn hơn giá trị non-null).

7. Thư viện Apache Commons

Trong phần này, bài viết sẽ đề cập đến so sánh sử dụng thư viện bên ngoài mà ở đây là thư viện Apache Commons library. Đầu tiên, ta cần thêm Maven dependency :

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.12.0</version>
</dependency>

7.1. Phương thức ObjectUtils#notEqual

Hàm ObjectUtils#notEqual nhận 2 đối tượng là đối số để kiểm tra xem chúng có bằng nhau không dựa theo hàm equals() (hàm xử lý cả giá trị null).

String a = new String("Hello!");
String b = new String("Hello World!");

assertThat(ObjectUtils.notEqual(a, b)).isTrue();

Lưu ý rằng ObjectUtils cũng có phương thức equals(). Tuy nhiên từ Java 7, hàm này không còn được khuyến khích sử dụng, thay vào đó là Objects#equals().

7.2. Phương thức ObjectUtils#compare

Tiếp theo, cùng so sánh các đối tượng bằng phương thức ObjectUtils#compare. Đây là phương thức generic nhận hai đối số của kiểu dữ liệu generic và trả về một giá trị Integer.

String first = new String("Hello!");
String second = new String("How are you?");

assertThat(ObjectUtils.compare(first, second)).isNegative();

Mặc định, phương thức sẽ coi các giá trị null lớn hơn các giá trị non-null. Bên cạnh đó, ta có thể ghi chồng phương thức để đảo ngược hành vi và coi giá trị null nhỏ hơn giá trị non-null bằng cách thêm đối số boolean.

8.Thư viện Guava

Kế tiếp, cùng tìm hiểu về thư viện Guava với bước đầu tiên là thêm dependency:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>31.0.1-jre</version>
</dependency>

8.1. Phương thức Objects#equal

Tương tự như thư viện Apache Commons, Google cung cấp một phương thức để so sánh hai đối tượng, Objects#equal. Mặc dù có cách triển khai khác nhau nhưng chúng đều trả về kết quả giống nhau.

String a = new String("Hello!");
String b = new String("Hello!");

assertThat(Objects.equal(a, b)).isTrue();

Lưu ý, theo JavaDoc, hàm này không còn được khuyến khích sử dụng và thay bằng hàm Object#equals từ Java 7 trở đi.

8.2. Các phương thức so sánh

Thư viện Guava không cung cấp một hàm để so sánh hai đối tượng, nhưng nó cung cấp các hàm để so sánh các giá trị nguyên thủy. Hãy lấy một ví dụ cho lớp helper Ints với hàm compare():

assertThat(Ints.compare(1, 2)).isNegative();

Như thường lệ, hàm Ints.compare trả về một integer có giá trị âm, 0 hoặc dương nếu đối số thứ nhất nhỏ hơn, bằng, hoặc lớn hơn đối số thứ hai truyền vào.

8.3. ComparisonChain Class

Cuối cùng, thư viện Guava cung cấp lớp ComparisonChain cho phép người dùng so sánh hai đối tượng thông qua 1 chuỗi các so sánh. Ta có thể dễ dàng so sánh hai đối tượng Person theo firstNamelastName:

Person natalie = new Person("Natalie", "Portman");
Person joe = new Person("Joe", "Portman");

int comparisonResult = ComparisonChain.start()
  .compare(natalie.getLastName(), joe.getLastName())
  .compare(natalie.getFirstName(), joe.getFirstName())
  .result();

assertThat(comparisonResult).isPositive();

Phương thức ComparisonChain được thực hiện bằng cách sử dụng phương thức compareTo(), do vậy các đối số truyền vào phải ở dạng nguyên thủy hoặc Comparables.

9. Tổng kết

Trong bài viết này, chúng ta đã tìm hiểu các cách khác nhau để so sánh các đối tượng trong Java. Chúng ta đã nghiên cứu sự khác nhau giữa các khái niệm giống nhau, bằng nhau, và thứ tự sắp xếp cùng với đó là những tính năng tương tự có thể áp dụng từ thư viện bên ngoài như Apache CommonsGuava.