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ênid,name,email.toString()dạngCustomer(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ọiequals(), 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ự overrideequals().
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)
originalkhông hề bị thay đổi — đây là bản chất của immutability.updatedlà object mới, chỉ khácemail, 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 objectOrdermới, nhưng fielditemsvẫn trỏ đến cùng mộtMutableList.- Sửa
order2.itemslàmorder1.itemsthay đổ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)
}
}
isEqualTokiểm tra bằng giá trị, đúng với bản chấtequals()củadata class.isNotSameAsxác nhậnc1vàc2là 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ưidtrước khi persist) dễ gây lỗi khi dùng trongHashSethoặc so sánh entity đã/chưa lưu DB. Nên viếtequals()/hashCode()thủ công theoidcho entity. - Class có logic nghiệp vụ phức tạp:
data classdà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.