Null Safety trong Kotlin: ?, !!, let và Elvis Operator
1. Tổng quan
NullPointerException (NPE) là nỗi ám ảnh của mọi dev Java. Kotlin giải quyết vấn đề này ngay ở compile-time bằng hệ thống null safety tích hợp vào type system. Bài này giúp bạn hiểu rõ bốn công cụ cốt lõi: dấu ? để khai báo nullable type, safe call ?., elvis operator ?:, và hàm let. Đọc xong, bạn sẽ biết khi nào dùng công cụ nào, và tránh được cái bẫy kinh điển: dùng !! tràn lan rồi vẫn gặp NPE.
2. Nullable Type và dấu ?
Trong Kotlin, mọi type mặc định là non-null. Muốn một biến có thể chứa null, bạn phải khai báo rõ bằng dấu ? sau type.
class Customer(val name: String, val email: String?)
fun main() {
val customer = Customer("An", null)
println(customer.email) // null, không lỗi vì email là String?
}
name: String— không bao giờ được lànull, compiler đảm bảo điều này.email: String?— có thể lànull, compiler buộc bạn xử lý mọi chỗ dùng đến nó.
Nếu bạn thử gọi method trực tiếp trên email như customer.email.length, compiler sẽ báo lỗi ngay:
error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?
Đây chính là lợi thế lớn nhất: lỗi được bắt lúc compile-time, không phải khi app đang chạy ở production.
3. Safe Call ?.
?. gọi method hoặc truy cập property chỉ khi receiver khác null. Nếu receiver là null, cả biểu thức trả về null luôn, không throw exception.
class Order(val id: Long, val customer: Customer?)
fun printCustomerName(order: Order) {
val length = order.customer?.name?.length
println("Độ dài tên: $length")
}
order.customer?.name— nếucustomerlànull, toàn bộ expression ngắn mạch (short-circuit) và trảnull, không cần chạy tiếp.length.- Kết quả
lengthcó type làInt?, vì mọi bước trong chuỗi?.đều có thể cho ranull.
Độ dài tên: null // khi customer == null
Độ dài tên: 2 // khi customer.name = "An"
Lưu ý: Chuỗi
?.dài quá 3-4 bước thường là dấu hiệu thiết kế domain model chưa tốt. Cân nhắc tách hàm hoặc dùng sealed class thay vì nullable lan tràn.
3.1. Safe Call kết hợp gọi hàm
?. cũng hoạt động với hàm, không chỉ property:
fun notifyCustomer(order: Order) {
order.customer?.let { sendEmail(it) }
}
fun sendEmail(customer: Customer) {
println("Gửi email tới ${customer.email}")
}
Dòng order.customer?.let { ... } chỉ thực thi block let khi customer khác null. Phần này liên quan trực tiếp tới mục let ở phần 5.
4. Elvis Operator ?:
Elvis operator cung cấp giá trị mặc định khi biểu thức bên trái là null. Tên gọi xuất phát từ hình dạng ?: giống tóc Elvis Presley nhìn ngang.
fun getDisplayEmail(customer: Customer): String {
return customer.email ?: "chưa cung cấp email"
}
- Nếu
customer.email != null, trả về chính giá trị đó. - Nếu là
null, trả về chuỗi fallback"chưa cung cấp email".
Elvis operator còn mạnh hơn khi kết hợp với return hoặc throw, giúp code early-return gọn gàng:
fun validateOrder(order: Order): Customer {
val customer = order.customer ?: throw IllegalStateException("Order ${order.id} thiếu customer")
return customer
}
Dòng ?: throw IllegalStateException(...) khiến customer sau đó được compiler suy ra type non-null Customer, không còn là Customer?. Đây là kỹ thuật rất phổ biến để “unwrap” nullable sớm trong hàm.
4.1. Elvis với hàm an toàn mặc định
fun calculateDiscount(order: Order): Double {
val tier = order.customer?.name?.length ?: 0
return if (tier > 5) 0.1 else 0.0
}
Ở đây, cả chuỗi safe call và elvis kết hợp: nếu bất kỳ bước nào trả null, kết quả cuối cùng fallback về 0.
5. Hàm let để xử lý giá trị không null
let là một scope function: nhận receiver, chạy block, trả về kết quả của block. Kết hợp với ?., nó trở thành pattern kinh điển để chỉ thực thi code khi giá trị khác null.
fun processPayment(amount: Double?) {
amount?.let {
println("Đang xử lý thanh toán: $it")
chargeCard(it)
}
}
fun chargeCard(value: Double) {
println("Đã trừ $value từ thẻ")
}
amount?.let { ... }— block bên trong chỉ chạy nếuamount != null.- Bên trong block,
itcó typeDouble(non-null), không phảiDouble?, nên bạn gọichargeCard(it)thoải mái, không cần!!hay check thêm.
So với viết if (amount != null) { ... }, cách dùng let giúp giới hạn scope của biến tạm, tránh rò rỉ biến ra ngoài block và code đọc theo kiểu “pipeline” hơn.
5.1. Kết hợp let với Elvis để trả về giá trị
fun formatAmount(amount: Double?): String {
return amount?.let { "Số tiền: %.2f".format(it) } ?: "Chưa có số tiền"
}
Dòng này đọc tự nhiên: “nếu có amount thì format, không thì trả fallback”. Đây là idiom rất hay gặp trong code Kotlin thực tế, đặc biệt khi map dữ liệu từ DTO sang domain model.
6. Non-null Assertion !! — Dùng khi nào?
!! ép compiler coi một nullable type là non-null. Nếu giá trị thực tế là null, nó throw NullPointerException ngay lập tức.
fun getCustomerNameUnsafe(order: Order): String {
return order.customer!!.name
}
order.customer!!— nếucustomerlànull, dòng này throw NPE với message khá rõ:
Exception in thread "main" java.lang.NullPointerException
at MainKt.getCustomerNameUnsafe(Main.kt:2)
Lưu ý:
!!về bản chất là bạn tự tay mang NPE từ Java quay lại Kotlin. Chỉ dùng khi bạn chắc chắn 100% giá trị không null tại thời điểm đó (ví dụ sau khi đã validate ở bước trước), và tốt nhất nên có comment giải thích lý do.
Khi nào chấp nhận dùng !!:
- Interop với code Java cũ, API trả
@Nullablenhưng bạn biết chắc business logic đảm bảo không null. - Test code, khi bạn muốn fail nhanh nếu setup sai.
Khi nào không nên dùng !!:
- Xử lý input từ user, request HTTP, database — luôn có thể null bất ngờ.
- Thay thế cho validate logic. Hãy dùng
requireNotNull()hoặc elvis + exception rõ nghĩa hơn.
fun getCustomerName(order: Order): String {
val customer = requireNotNull(order.customer) { "Order ${order.id} phải có customer" }
return customer.name
}
requireNotNull vừa unwrap nullable, vừa cho message lỗi rõ ràng hơn !! rất nhiều — đây là lựa chọn nên ưu tiên hơn !! trần trụi.
7. So sánh nhanh bốn công cụ
| Công cụ | Khi nào dùng | Hành vi khi gặp null |
|---|---|---|
? |
Khai báo type có thể null | N/A, chỉ là annotation type |
?. |
Truy cập an toàn, không chắc có null | Trả null, không throw |
?: |
Cần giá trị fallback hoặc early-return | Chạy biểu thức bên phải |
let |
Chỉ thực thi logic khi có giá trị | Không chạy block |
!! |
Chắc chắn 100% không null | Throw NullPointerException |
8. Viết test cho hàm xử lý null
Để đảm bảo logic null-safety hoạt động đúng, viết test cho cả hai nhánh: có giá trị và null.
import org.junit.jupiter.api.Test
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
class OrderServiceTest {
@Test
fun `getDisplayEmail tra ve fallback khi email null`() {
val customer = Customer(name = "An", email = null)
val result = getDisplayEmail(customer)
assertThat(result).isEqualTo("chưa cung cấp email")
}
@Test
fun `getDisplayEmail tra ve email khi co gia tri`() {
val customer = Customer(name = "An", email = "an@example.com")
val result = getDisplayEmail(customer)
assertThat(result).isEqualTo("an@example.com")
}
@Test
fun `validateOrder throw khi customer null`() {
val order = Order(id = 1L, customer = null)
assertThatThrownBy { validateOrder(order) }
.isInstanceOf(IllegalStateException::class.java)
.hasMessageContaining("thiếu customer")
}
}
- Test đầu kiểm tra elvis trả fallback đúng khi
email == null. - Test thứ hai kiểm tra nhánh ngược lại — không bị mất giá trị thực.
- Test cuối kiểm tra
?: throwcó throw đúng exception và message mong đợi, dùngassertThatThrownBycủa AssertJ.
Dependency cần cho test (nếu dùng Gradle Kotlin DSL):
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
9. Lỗi thường gặp
Lạm dụng !! để “làm compiler im miệng”. Khi bạn thấy compiler báo lỗi nullable và phản xạ gõ !! ngay, dừng lại — đó là dấu hiệu bạn chưa hiểu rõ luồng dữ liệu. Hãy hỏi: giá trị này thật sự có thể null không? Nếu có, xử lý bằng ?: hoặc let.
Dùng ?.let thay cho if-else một cách máy móc. Với logic phức tạp có nhiều nhánh, if (x != null) { } else { } đôi khi rõ ràng hơn let lồng nhau. Đừng ép mọi thứ vào elvis/let nếu code khó đọc hơn.
Quên rằng platform type từ Java không có null safety. Khi gọi method Java trả về object không có annotation @Nullable/@NonNull, Kotlin coi đó là platform type (String!), compiler không ép bạn check null. NPE vẫn có thể xảy ra ở runtime trong trường hợp này — hãy tự thêm ?. hoặc check thủ công khi làm việc với Java interop.
// Java: public String getLegacyName() { return null; } // không annotation
val name = legacyService.getLegacyName() // type suy ra là String!, Kotlin không cảnh báo
println(name.length) // có thể NPE runtime nếu name null
10. Kết luận
Null safety của Kotlin không phải “ma thuật” loại bỏ hoàn toàn NPE, mà là công cụ bắt lỗi sớm hơn — ở compile-time thay vì runtime. Dấu ? khai báo khả năng null, ?. truy cập an toàn, ?: cung cấp fallback hoặc early-return, let giới hạn scope logic khi có giá trị. Còn !! là lối thoát khẩn cấp, chỉ nên dùng khi bạn chắc chắn và sẵn sàng chấp nhận throw NPE nếu sai. Nắm vững bốn công cụ này, bạn sẽ viết code Kotlin an toàn hơn nhiều so với thói quen null-check kiểu Java cũ.