Scope functions trong Kotlin: let, run, apply, also, with

1. Tổng quan

Kotlin có năm hàm nhìn rất giống nhau: let, run, with, apply và also. Chúng đều chạy một khối code trên một object, nhưng khác nhau ở cách bạn truy cập object đó và ở giá trị mà hàm trả về. Nhiều người chọn bừa một hàm, và code vẫn chạy được, nhưng sẽ khó đọc hơn.

Bài này giải thích hai tiêu chí phân biệt các scope function và minh họa từng hàm bằng ví dụ cụ thể. Sau đó bạn sẽ viết test để kiểm chứng hành vi của chúng và xem các lỗi hay gặp. Đọc xong, bạn sẽ chọn đúng hàm cho từng tình huống.

2. Hai tiêu chí phân biệt

Mọi scope function đều nhận một lambda và gọi lambda đó trên một context object. Bạn chỉ cần trả lời hai câu hỏi:

  1. Trong lambda, bạn truy cập object qua this (receiver) hay qua it (tham số)?
  2. Hàm trả về kết quả của lambda hay trả về chính object?
Hàm Context object Giá trị trả về Là extension?
let it Kết quả lambda Có
run this Kết quả lambda Có (và có bản không extension)
with this Kết quả lambda Không, object là tham số
apply this Chính object Có
also it Chính object Có

Bạn có thể nhớ nhanh theo cặp:

  • apply và also trả về object, nên chúng phù hợp để cấu hình object hoặc chèn side effect vào giữa một chuỗi gọi.
  • let, run và with trả về kết quả, nên chúng phù hợp để biến đổi object thành một giá trị khác.

Các ví dụ dưới đây dùng chung vài lớp đơn giản:

data class Customer(val name: String, val email: String?)

class Order(val id: String) {
    val items = mutableListOf<Double>()
    var discountPercent = 0
}

class Invoice {
    var number: String = ""
    var amount: Double = 0.0
    var paid: Boolean = false
}

3. let: biến đổi và xử lý giá trị nullable

let truyền object vào lambda dưới tên it và trả về kết quả của lambda. Cách dùng phổ biến nhất là kết hợp với safe call ?. để chỉ chạy code khi giá trị khác null:

fun sendWelcome(customer: Customer) {
    customer.email?.let { email ->
        println("Gửi email chào mừng tới $email")
    }
}

fun main() {
    sendWelcome(Customer("An", "an@example.com"))
    sendWelcome(Customer("Bình", null))
}
Gửi email chào mừng tới an@example.com

Với khách hàng Bình, email là null. Biểu thức ?. dừng lại ở đó và lambda không chạy. Trong lambda, email có kiểu String chứ không phải String?, nên bạn dùng nó mà không cần kiểm tra thêm. Ở đây ta đặt tên tham số là email thay cho it để code dễ đọc hơn.

let cũng hữu ích khi bạn muốn biến đổi một giá trị mà không cần tạo biến trung gian:

val domain = Customer("An", "an@example.com").email
    ?.let { it.substringAfter("@") }
    ?: "không rõ"

println(domain) // example.com

4. run: tính toán dựa trên object

run giống let, chỉ khác là object được truy cập qua this. Vì this có thể bỏ qua, bạn gọi thẳng property và method của object:

val order = Order("ORD-42").apply {
    items += listOf(100_000.0, 50_000.0)
    discountPercent = 10
}

val total = order.run {
    val subtotal = items.sum()
    subtotal - subtotal * discountPercent / 100
}

println(total) // 135000.0

Trong khối run, items và discountPercent là property của order. Giá trị trả về là biểu thức cuối cùng, tức số tiền sau khi giảm giá.

run còn có một phiên bản không phải extension. Bạn dùng nó để gom vài câu lệnh tạo ra một giá trị, mà không làm rò biến tạm ra scope bên ngoài:

val connectionUrl = run {
    val host = System.getenv("DB_HOST") ?: "localhost"
    val port = System.getenv("DB_PORT") ?: "5432"
    "jdbc:postgresql://$host:$port/shop"
}

Sau khối này, host và port không còn tồn tại. Chỉ connectionUrl được giữ lại.

5. with: gom nhiều lệnh gọi trên cùng một object

with hoạt động giống run, nhưng nó không phải extension. Object được truyền vào như tham số đầu tiên:

val summary = with(order) {
    "Đơn $id: ${items.size} sản phẩm, giảm $discountPercent%"
}

println(summary)
Đơn ORD-42: 2 sản phẩm, giảm 10%

Về ngữ nghĩa, with(order) { ... } đọc như “với order này, hãy làm…”. Hàm này phù hợp khi object chắc chắn khác null và bạn cần gọi nhiều thành viên của nó. Nếu object có thể là null, bạn nên dùng order?.run { ... }, vì with không kết hợp được với ?..

6. apply: cấu hình object

apply truy cập object qua this và trả về chính object. Đây là cách viết thành ngữ để khởi tạo và cấu hình một object trong cùng một biểu thức:

val invoice = Invoice().apply {
    number = "INV-001"
    amount = 250_000.0
    paid = false
}

println("${invoice.number} - ${invoice.amount}") // INV-001 - 250000.0

Kết quả của lambda bị bỏ qua và apply luôn trả về Invoice vừa tạo. Bạn sẽ hay gặp apply khi làm việc với các lớp Java dùng setter, chẳng hạn khi cấu hình một bean trong Spring:

@Bean
fun restTemplate(): RestTemplate = RestTemplate().apply {
    requestFactory = SimpleClientHttpRequestFactory().apply {
        setConnectTimeout(2_000)
        setReadTimeout(5_000)
    }
}

7. also: thêm side effect mà không phá chuỗi gọi

also truy cập object qua it và cũng trả về chính object. Hàm này phù hợp với những việc “tiện thể làm thêm” như ghi log, validate hay gọi metric, khi các việc đó không thay đổi luồng chính:

fun createOrder(id: String): Order =
    Order(id).also { println("Đã tạo đơn ${it.id}") }

fun main() {
    val order = createOrder("ORD-7")
        .also { it.items += 99_000.0 }
        .also { println("Số sản phẩm: ${it.items.size}") }
}
Đã tạo đơn ORD-7
Số sản phẩm: 1

Vì object được truy cập qua it chứ không qua this, bạn vẫn dùng được this của lớp bên ngoài mà không bị nhầm lẫn. Đây cũng là lý do also dễ đọc hơn apply khi lambda chủ yếu dùng object thay vì cấu hình nó.

Có một mẹo nhỏ là dùng also để hoán đổi hai biến:

var a = 1
var b = 2
a = b.also { b = a }
println("$a $b") // 2 1

Biểu thức b.also { ... } trả về giá trị cũ của b (là 2), còn lambda gán b = a (là 1) trước khi phép gán cho a xảy ra.

8. Chọn hàm nào?

Bạn có thể dùng các quy tắc sau:

  • Cần chạy code khi giá trị khác null, hoặc cần biến đổi giá trị: dùng ?.let.
  • Cần tính một giá trị từ nhiều thành viên của object: dùng run, hoặc with nếu object chắc chắn khác null.
  • Cần gom vài câu lệnh thành một biểu thức: dùng run { } không có receiver.
  • Cần khởi tạo hoặc cấu hình object: dùng apply.
  • Cần log, validate hoặc thêm side effect mà vẫn giữ object: dùng also.

9. Kiểm thử hành vi

Để chắc chắn bạn hiểu đúng giá trị trả về, hãy viết vài test. Đầu tiên, thêm dependency vào pom.xml:

<dependencies>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib</artifactId>
        <version>2.0.21</version>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.26.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Nếu dự án là Spring Boot 3.x, spring-boot-starter-test đã có sẵn JUnit 5 và AssertJ, nên bạn không cần khai báo riêng hai thư viện này.

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

class ScopeFunctionsTest {

    @Test
    fun `apply va also tra ve chinh object`() {
        val invoice = Invoice()

        val fromApply = invoice.apply { amount = 100.0 }
        val fromAlso = invoice.also { it.paid = true }

        assertThat(fromApply).isSameAs(invoice)
        assertThat(fromAlso).isSameAs(invoice)
        assertThat(invoice.amount).isEqualTo(100.0)
        assertThat(invoice.paid).isTrue()
    }

    @Test
    fun `let run with tra ve ket qua cua lambda`() {
        val order = Order("ORD-1").apply { items += listOf(10.0, 20.0) }

        val count: Int = order.let { it.items.size }
        val sum: Double = order.run { items.sum() }
        val label: String = with(order) { "Order $id" }

        assertThat(count).isEqualTo(2)
        assertThat(sum).isEqualTo(30.0)
        assertThat(label).isEqualTo("Order ORD-1")
    }

    @Test
    fun `let khong chay khi gia tri la null`() {
        var called = false
        Customer("Bình", null).email?.let { called = true }

        assertThat(called).isFalse()
    }
}

Test đầu tiên dùng isSameAs để kiểm tra rằng apply và also trả về đúng tham chiếu ban đầu, chứ không phải một bản sao. Test thứ hai khai báo kiểu tường minh (Int, Double, String) để thấy rõ mỗi hàm trả về kết quả của lambda. Test thứ ba xác nhận ?.let bỏ qua giá trị null.

10. Lỗi hay gặp

10.1. this bị che khuất trong apply

Bên trong apply, receiver gần nhất được ưu tiên. Vì vậy một property cùng tên ở lớp bên ngoài sẽ bị che mất:

class InvoiceFactory(private val amount: Double) {
    fun create() = Invoice().apply {
        amount = amount // gán Invoice.amount cho chính nó, kết quả vẫn là 0.0
    }
}

Bạn có hai cách sửa. Cách thứ nhất là ghi rõ receiver bằng this@InvoiceFactory.amount. Cách thứ hai là chuyển sang also:

fun create() = Invoice().also { it.amount = amount }

10.2. Bẫy ?.let { } ?: ...

Nhiều người viết x?.let { ... } ?: fallback với ý nghĩa giống if/else. Cách viết này không tương đương. Nếu lambda trả về null, nhánh ?: cũng chạy:

val cache = mapOf<String, String>()
val email: String? = "an@example.com"

email?.let { cache[it] } ?: println("Không có email")
Không có email

Ở đây email khác null, nhưng cache[it] trả về null, nên câu thông báo vẫn được in ra dù nó không đúng. Khi bạn cần rẽ nhánh rõ ràng, hãy dùng if (email != null) ... else ....

10.3. Lồng quá nhiều tầng

Khi bạn lồng các scope function vào nhau, it và this sẽ chỉ tới nhiều object khác nhau và người đọc phải dò lại xem mỗi cái là gì:

// Khó đọc
order.let { o -> o.items.firstOrNull()?.let { price -> price.run { this * 2 } } }

Lưu ý: Bạn không nên lồng quá một tầng. Nếu buộc phải lồng, hãy đặt tên tham số rõ ràng thay cho it. Tốt hơn nữa là tách thành biến cục bộ hoặc hàm riêng.

10.4. Dùng scope function chỉ vì có thể dùng

Đoạn val name = customer.let { it.name } không mang lại gì so với val name = customer.name. Scope function có ích khi nó giúp bạn bỏ biến tạm, xử lý null hoặc gom code cấu hình. Nếu nó không làm được những việc đó, đừng dùng.

11. Kết luận

Năm scope function của Kotlin chỉ khác nhau ở hai điểm: object được truy cập qua this hay it, và hàm trả về object hay kết quả của lambda. Từ đó bạn có cách dùng tương ứng: apply để cấu hình, also để thêm side effect, let để xử lý giá trị nullable và biến đổi, còn run và with để tính toán dựa trên object. Bạn cũng cần cẩn thận với việc this bị che khuất, với bẫy ?.let ?:, và với việc lồng quá nhiều tầng. Cuối cùng, hãy nhớ rằng mục tiêu của scope function là làm code dễ đọc hơn, chứ không phải ngắn hơn bằng mọi giá.

kotlinscope-functionsletapplyclean-code