Data Class và Copy trong Kotlin

1. Tổng quan

data class là một trong những tính năng giúp Kotlin viết code ngắn gọn hơn Java rất nhiều khi làm việc với các lớp chỉ chứa dữ liệu (DTO, model, value object). Bài này giúp bạn hiểu rõ Kotlin sinh những gì cho một data class, cách dùng copy() để tạo bản sao có sửa đổi một vài field, và những cạm bẫy phổ biến như so sánh sai, dùng var trong data class, hay kế thừa data class.

2. Data Class Là Gì?

Khi bạn khai báo một lớp với từ khóa data, trình biên dịch Kotlin tự động sinh ra một số hàm dựa trên các property khai báo trong constructor chính (primary constructor).

data class Customer(
    val id: Long,
    val name: String,
    val email: String
)

Chỉ với 4 dòng này, Kotlin sinh sẵn:

  • equals() / hashCode() dựa trên id, name, email.
  • toString() dạng Customer(id=1, name=..., email=...).
  • copy() để tạo bản sao có thể thay đổi một số field.
  • componentN() để hỗ trợ destructuring declaration.

So với Java, bạn sẽ phải viết một class với constructor, getter, equals, hashCode, toString — khoảng 40-50 dòng cho cùng một chức năng.

2.1. toString() Mặc Định

fun main() {
    val customer = Customer(1, "An Nguyen", "an@nonladev.com")
    println(customer)
}
Customer(id=1, name=An Nguyen, email=an@nonladev.com)

toString() sinh tự động rất tiện khi log hoặc debug, bạn không cần tự viết.

3. equals() và hashCode() Theo Giá Trị

Điểm khác biệt lớn nhất giữa data class và class thường là equals() so sánh theo giá trị (structural equality) chứ không phải theo tham chiếu.

fun main() {
    val c1 = Customer(1, "An Nguyen", "an@nonladev.com")
    val c2 = Customer(1, "An Nguyen", "an@nonladev.com")

    println(c1 == c2)   // so sánh giá trị
    println(c1 === c2)  // so sánh tham chiếu
}
true
false
  • == gọi equals(), hai object có cùng field thì bằng nhau dù khác tham chiếu.
  • === kiểm tra hai biến có trỏ cùng một object trong bộ nhớ hay không.

Lưu ý: Với class thường (không phải data class), == mặc định so sánh tham chiếu giống ===, trừ khi bạn tự override equals().

Vì hashCode() cũng được sinh dựa trên các property, bạn có thể dùng data class làm key trong HashMap hoặc phần tử trong HashSet một cách an toàn.

fun main() {
    val set = hashSetOf(
        Customer(1, "An Nguyen", "an@nonladev.com"),
        Customer(1, "An Nguyen", "an@nonladev.com")
    )
    println(set.size)
}
1

Hai object giống hệt giá trị được coi là trùng, HashSet chỉ giữ lại 1 phần tử.

4. Hàm copy() — Tạo Bản Sao Bất Biến

Nếu bạn dùng val cho property, Customer là immutable — không thể sửa trực tiếp field sau khi tạo. Để “thay đổi” một vài giá trị, Kotlin sinh hàm copy() cho phép tạo object mới, giữ nguyên các field không truyền vào.

fun main() {
    val original = Customer(1, "An Nguyen", "an@nonladev.com")
    val updated = original.copy(email = "an.new@nonladev.com")

    println(original)
    println(updated)
}
Customer(id=1, name=An Nguyen, email=an@nonladev.com)
Customer(id=1, name=An Nguyen, email=an.new@nonladev.com)
  • original không hề bị thay đổi — đây là bản chất của immutability.
  • updated là object mới, chỉ khác email, các field khác được copy nguyên.

Đây là pattern rất phổ biến khi làm việc với state trong UI (Compose, MVI) hoặc khi cập nhật entity trong business logic mà không muốn mutate trực tiếp.

4.1. copy() Với Named Arguments

Bạn có thể truyền nhiều tham số cùng lúc, theo tên (named arguments) để tránh nhầm thứ tự:

val promoted = original.copy(name = "An Nguyen (VIP)", email = "vip@nonladev.com")

Nếu không dùng named arguments mà theo thứ tự vị trí, code dễ gây nhầm lẫn khi class có nhiều field cùng kiểu String.

5. Shallow Copy — Cạm Bẫy Khi Có Mutable Object Bên Trong

copy() chỉ tạo shallow copy: nó copy tham chiếu của các field, không copy sâu (deep copy) các object phức tạp bên trong.

data class Order(
    val id: Long,
    val items: MutableList<String>
)

fun main() {
    val order1 = Order(1, mutableListOf("Book"))
    val order2 = order1.copy()

    order2.items.add("Pen")

    println(order1.items) // bị ảnh hưởng!
    println(order2.items)
}
[Book, Pen]
[Book, Pen]
  • order2 = order1.copy() chỉ tạo object Order mới, nhưng field items vẫn trỏ đến cùng một MutableList.
  • Sửa order2.items làm order1.items thay đổi theo — đây là lỗi rất dễ gặp khi dùng collection mutable trong data class.

Cách khắc phục: dùng List bất biến (toList()) hoặc tự copy collection trong hàm khởi tạo.

data class Order(
    val id: Long,
    val items: List<String>
)

fun main() {
    val order1 = Order(1, listOf("Book"))
    val order2 = order1.copy(items = order1.items + "Pen")

    println(order1.items)
    println(order2.items)
}
[Book]
[Book, Pen]

Dùng List<String> bất biến và toán tử + để tạo list mới thay vì mutate, giữ đúng tinh thần immutability của data class.

Lưu ý: Nếu bạn cần deep copy thật sự (nested data class chứa data class khác), bạn phải tự viết logic copy đệ quy, Kotlin không sinh sẵn deep copy.

6. Destructuring Declaration

Nhờ componentN() tự sinh, bạn có thể “destructure” một data class thành nhiều biến riêng lẻ:

fun main() {
    val customer = Customer(1, "An Nguyen", "an@nonladev.com")
    val (id, name, email) = customer

    println("$id - $name - $email")
}
1 - An Nguyen - an@nonladev.com

Thứ tự biến tương ứng với thứ tự property trong constructor chính, không phải theo tên. Đổi thứ tự khai báo property sẽ làm destructuring sai lệch — cần cẩn thận khi refactor.

Lưu ý: Chỉ nên destructure với data class có ít field (2-3). Với class nhiều field, dùng tên property trực tiếp để code dễ đọc hơn.

7. Giới Hạn Của Data Class

7.1. Không Thể Kế Thừa

data class không được phép kế thừa một data class khác (không thể open kèm data theo cách thừa kế field tự động):

// Lỗi biên dịch nếu cố làm thế này:
// data class VipCustomer(val tier: String) : Customer(...)

Kotlin chỉ cho phép data class implement interface, hoặc kế thừa một class thường (non-data) không có property trong constructor chính. Lý do: equals()/copy() sinh tự động sẽ rất khó định nghĩa đúng khi có kế thừa đa cấp field.

Giải pháp thường dùng: composition thay vì inheritance.

data class VipCustomer(
    val customer: Customer,
    val tier: String
)

7.2. Primary Constructor Phải Có Ít Nhất 1 Property

// Lỗi: data class phải có ít nhất 1 tham số trong primary constructor
// data class Empty

7.3. Chỉ Property Trong Constructor Chính Được Tính

data class Product(val name: String) {
    var discount: Int = 0
}

fun main() {
    val p1 = Product("Laptop").apply { discount = 10 }
    val p2 = Product("Laptop").apply { discount = 20 }

    println(p1 == p2) // true, discount không tham gia equals()
}
true

discount khai báo trong body, không nằm trong constructor chính, nên không được tính trong equals(), hashCode(), toString(), copy(). Đây là lỗi dễ gây nhầm lẫn — nếu field quan trọng cho so sánh, phải đưa vào constructor chính.

8. Kiểm Thử Với JUnit 5 và AssertJ

Dependency cần thiết (Gradle Kotlin DSL):

testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
testImplementation("org.assertj:assertj-core:3.26.0")

Viết test kiểm tra hành vi copy() và equals():

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class CustomerTest {

    @Test
    fun `copy should keep unchanged fields and update specified ones`() {
        val original = Customer(1, "An Nguyen", "an@nonladev.com")

        val updated = original.copy(email = "an.new@nonladev.com")

        assertThat(updated.id).isEqualTo(original.id)
        assertThat(updated.name).isEqualTo(original.name)
        assertThat(updated.email).isEqualTo("an.new@nonladev.com")
        assertThat(original.email).isEqualTo("an@nonladev.com")
    }

    @Test
    fun `two customers with same fields should be equal`() {
        val c1 = Customer(1, "An Nguyen", "an@nonladev.com")
        val c2 = Customer(1, "An Nguyen", "an@nonladev.com")

        assertThat(c1).isEqualTo(c2)
        assertThat(c1).isNotSameAs(c2)
    }
}
  • isEqualTo kiểm tra bằng giá trị, đúng với bản chất equals() của data class.
  • isNotSameAs xác nhận c1 và c2 là hai object khác nhau trong bộ nhớ, dù bằng nhau về giá trị.
  • Test đầu tiên xác nhận copy() hoạt động đúng: field không chỉ định giữ nguyên, field chỉ định được cập nhật, object gốc không đổi.

9. Khi Nào Không Nên Dùng Data Class

  • Entity JPA/Hibernate: equals()/hashCode() theo tất cả field (bao gồm cả field generate như id trước khi persist) dễ gây lỗi khi dùng trong HashSet hoặc so sánh entity đã/chưa lưu DB. Nên viết equals()/hashCode() thủ công theo id cho entity.
  • Class có logic nghiệp vụ phức tạp: data class dành cho data holder, không phù hợp khi class cần nhiều hành vi, kế thừa đa cấp.
  • Class cần tối ưu bộ nhớ với nhiều trường mutable lồng nhau: cân nhắc dùng builder pattern hoặc class thường nếu shallow copy gây rủi ro khó kiểm soát.

10. Kết luận

data class giúp bạn loại bỏ boilerplate code khi làm việc với DTO/model trong Kotlin: tự sinh equals(), hashCode(), toString(), copy() và hỗ trợ destructuring. Hàm copy() là công cụ mạnh để giữ tính bất biến, nhưng bạn cần nhớ nó chỉ shallow copy — cẩn thận với các field chứa collection hay object mutable bên trong. Tránh dùng data class cho JPA entity hay class có logic nghiệp vụ phức tạp, và luôn đưa field quan trọng vào constructor chính để equals()/copy() hoạt động đúng như mong đợi.

kotlindata-classcopyimmutabilitykotlin-basics