Delegated properties trong Kotlin: lazy và observable

1. Tổng quan

Kotlin cho phép bạn giao việc đọc và ghi một property cho một đối tượng khác thông qua từ khóa by. Cơ chế này gọi là delegated properties. Thư viện chuẩn có sẵn vài delegate dùng rất thường xuyên. Phổ biến nhất là lazy để khởi tạo trễ, observable để nghe thay đổi và vetoable để chặn thay đổi không hợp lệ.

Đọc xong bài này, bạn sẽ biết cách dùng từng delegate và chọn đúng chế độ thread-safety cho lazy. Bạn cũng sẽ biết cách viết test cho chúng và những tình huống nên tránh.

2. Thiết lập

Các delegate trong bài nằm sẵn trong kotlin-stdlib, nên bạn không cần thêm thư viện. Để viết test, hãy thêm JUnit 5 và AssertJ:

<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>

Nếu bạn dùng Spring Boot 3.x, spring-boot-starter-test đã có sẵn cả hai thư viện này.

3. Delegated property hoạt động thế nào

Khi bạn viết val x: T by someDelegate, compiler sinh ra một field ẩn để giữ delegate. Getter của x sẽ gọi someDelegate.getValue(...). Nếu x là var, setter sẽ gọi setValue(...).

Bạn có thể tự viết một delegate đơn giản để thấy rõ cơ chế:

import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty

class TrimmedString(private var value: String = "") : ReadWriteProperty<Any?, String> {
    override fun getValue(thisRef: Any?, property: KProperty<*>): String = value

    override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        this.value = value.trim()
    }
}

class Customer {
    var name: String by TrimmedString()
}

fun main() {
    val customer = Customer()
    customer.name = "   Nguyễn Văn An  "
    println("[${customer.name}]")
}

Giải thích từng phần:

  • TrimmedString implement ReadWriteProperty, gồm hai hàm getValue và setValue.
  • Mỗi lần gán customer.name, Kotlin gọi setValue, và chuỗi được trim() trước khi lưu.
  • Tham số property cho bạn biết tên property đang được truy cập. observable dùng chính tham số này để báo cho bạn biết property nào vừa thay đổi.
[Nguyễn Văn An]

Bạn hiếm khi phải tự viết delegate, vì thư viện chuẩn đã có những cái hay dùng nhất.

4. Khởi tạo trễ với by lazy

4.1. Cách dùng cơ bản

lazy nhận một lambda. Lambda chỉ chạy ở lần đầu tiên bạn đọc property. Kết quả được cache lại cho mọi lần đọc sau.

class ReportService {
    val exchangeRates: Map<String, Double> by lazy {
        println("Đang tải tỷ giá...")
        mapOf("USD" to 25_400.0, "EUR" to 27_300.0)
    }
}

fun main() {
    val service = ReportService()
    println("Service đã tạo")
    println(service.exchangeRates["USD"])
    println(service.exchangeRates["EUR"])
}

Kết quả:

Service đã tạo
Đang tải tỷ giá...
25400.0
27300.0

Có ba điểm cần để ý:

  • Tạo ReportService không kích hoạt việc tải tỷ giá.
  • Dòng “Đang tải tỷ giá…” chỉ in một lần, dù property được đọc hai lần.
  • lazy chỉ dùng được với val. Viết var x by lazy { ... } sẽ gây lỗi biên dịch, vì Lazy không có setValue.

lazy hợp với những giá trị tốn công tạo nhưng không phải lúc nào cũng cần dùng. Ví dụ: một regex phức tạp, một cấu hình đọc từ file, hay một client gọi dịch vụ ngoài.

4.2. Chế độ thread-safety

Mặc định, lazy dùng LazyThreadSafetyMode.SYNCHRONIZED. Chế độ này đảm bảo lambda chỉ chạy đúng một lần, kể cả khi nhiều thread cùng truy cập. Bạn có thể đổi chế độ khi cần:

class PriceCalculator {
    // Chỉ một thread được chạy initializer, các thread khác chờ
    val taxTable: Map<String, Double> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
        mapOf("VN" to 0.1, "SG" to 0.09)
    }

    // Nhiều thread có thể cùng chạy initializer, giá trị đầu tiên được giữ lại
    val discountRules: List<String> by lazy(LazyThreadSafetyMode.PUBLICATION) {
        listOf("BLACK_FRIDAY", "TET_SALE")
    }

    // Không có đồng bộ, chỉ dùng khi chắc chắn property được truy cập từ một thread
    val formatter: java.text.DecimalFormat by lazy(LazyThreadSafetyMode.NONE) {
        java.text.DecimalFormat("#,###")
    }
}

fun main() {
    val calculator = PriceCalculator()
    println(calculator.taxTable["VN"])
    println(calculator.discountRules.first())
    println(calculator.formatter.format(1_250_000))
}
0.1
BLACK_FRIDAY
1,250,000

Bạn có thể chọn chế độ theo bảng sau:

Chế độ Initializer chạy Khi nào dùng
SYNCHRONIZED Đúng 1 lần Mặc định, an toàn cho đa số trường hợp
PUBLICATION Có thể nhiều lần, giữ kết quả đầu Initializer rẻ, không có side effect, muốn tránh lock
NONE Không đảm bảo Code chỉ chạy trên một thread (UI thread, vòng lặp nội bộ)

Lưu ý: NONE nhanh hơn một chút vì không có lock. Nếu bạn dùng nó trong một Spring bean singleton được nhiều request truy cập song song, initializer có thể chạy nhiều lần. Khi đó các thread có thể thấy những instance khác nhau. Đừng đổi chế độ chỉ để “tối ưu” khi chưa đo đạc.

4.3. Khi initializer ném exception

Nếu lambda ném exception, giá trị không được cache. Lần đọc tiếp theo sẽ chạy lại lambda:

class ConfigLoader {
    var attempts = 0

    val apiKey: String by lazy {
        attempts++
        if (attempts < 2) error("Chưa đọc được file cấu hình")
        "secret-key-123"
    }
}

fun main() {
    val loader = ConfigLoader()
    runCatching { loader.apiKey }.onFailure { println("Lỗi: ${it.message}") }
    println(loader.apiKey)
    println("Số lần thử: ${loader.attempts}")
}
Lỗi: Chưa đọc được file cấu hình
secret-key-123
Số lần thử: 2

Hành vi này có lợi khi lỗi là tạm thời. Nhưng nếu initializer rất tốn kém và lỗi lặp lại, mỗi lần truy cập sẽ tốn chi phí đó một lần nữa.

4.4. Kiểm tra đã khởi tạo hay chưa

Nếu bạn cần biết giá trị đã được tạo hay chưa, hãy giữ đối tượng Lazy trong một biến riêng rồi delegate tới nó:

class CacheHolder {
    private val productCacheDelegate = lazy { mutableMapOf<String, Int>() }
    val productCache: MutableMap<String, Int> by productCacheDelegate

    fun isCacheReady(): Boolean = productCacheDelegate.isInitialized()
}

fun main() {
    val holder = CacheHolder()
    println(holder.isCacheReady())
    holder.productCache["SKU-1"] = 10
    println(holder.isCacheReady())
}
false
true

Cách này hữu ích khi bạn muốn giải phóng tài nguyên lúc shutdown, nhưng chỉ khi tài nguyên đó thực sự đã được tạo.

4.5. lazy hay lateinit?

Hai cách này hay bị nhầm với nhau:

  • lazy: dùng với val. Giá trị do chính property tự tính, ở lần đọc đầu.
  • lateinit: dùng với var và kiểu non-null, không áp dụng cho primitive như Int. Giá trị do bên ngoài gán vào sau, ví dụ qua dependency injection hoặc hàm @BeforeEach trong test.

Nếu bạn biết cách tính giá trị ngay trong class, hãy dùng lazy. Nếu giá trị được “đưa vào” từ bên ngoài, hãy dùng lateinit.

5. Theo dõi thay đổi với Delegates.observable

Delegates.observable nhận một giá trị khởi đầu và một callback. Callback chạy sau khi giá trị mới đã được gán.

import kotlin.properties.Delegates

enum class OrderStatus { NEW, PAID, SHIPPED, DELIVERED }

class Order(val id: String) {
    val history = mutableListOf<String>()

    var status: OrderStatus by Delegates.observable(OrderStatus.NEW) { property, old, new ->
        history += "${property.name}: $old -> $new"
    }
}

fun main() {
    val order = Order("ORD-001")
    order.status = OrderStatus.PAID
    order.status = OrderStatus.SHIPPED
    order.status = OrderStatus.SHIPPED
    order.history.forEach(::println)
}
status: NEW -> PAID
status: PAID -> SHIPPED
status: SHIPPED -> SHIPPED

Giải thích:

  • OrderStatus.NEW là giá trị ban đầu. Việc gán giá trị này không kích hoạt callback.
  • Callback nhận ba tham số: property (metadata), old (giá trị cũ) và new (giá trị mới).
  • Dòng thứ ba cho thấy callback vẫn chạy khi bạn gán lại cùng một giá trị.

Nếu bạn chỉ muốn phản ứng khi giá trị thật sự đổi, hãy tự lọc trong callback:

var status: OrderStatus by Delegates.observable(OrderStatus.NEW) { _, old, new ->
    if (old != new) history += "$old -> $new"
}

observable thường được dùng để ghi log audit, đánh dấu đối tượng là “dirty” cần lưu lại, hoặc cập nhật một giá trị dẫn xuất.

6. Chặn giá trị không hợp lệ với Delegates.vetoable

vetoable giống observable, nhưng callback chạy trước khi gán và trả về Boolean. Nếu callback trả về false, giá trị cũ được giữ nguyên.

import kotlin.properties.Delegates

class CartItem(val sku: String) {
    var quantity: Int by Delegates.vetoable(1) { _, _, new ->
        new in 1..99
    }
}

fun main() {
    val item = CartItem("SKU-42")
    item.quantity = 5
    println(item.quantity)
    item.quantity = 0
    println(item.quantity)
    item.quantity = 150
    println(item.quantity)
}
5
5
5

Phép gán 0 và 150 bị từ chối một cách im lặng, và quantity vẫn là 5.

Lưu ý: Vì vetoable không ném exception, code gọi sẽ không biết phép gán đã thất bại. Với dữ liệu nghiệp vụ quan trọng, ví dụ số tiền thanh toán, bạn nên dùng setter tùy chỉnh với require(...) để lỗi lộ ra rõ ràng. vetoable hợp với những trường hợp mà việc bỏ qua giá trị sai là hành vi mong muốn, như giới hạn giá trị từ thanh trượt trên UI.

7. Viết test cho các delegate

Phần test dưới đây kiểm tra ba hành vi chính: lazy chỉ khởi tạo một lần, observable ghi lại lịch sử và vetoable từ chối giá trị sai.

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger

class DelegatedPropertiesTest {

    class ExpensiveHolder(private val counter: AtomicInteger) {
        val value: String by lazy {
            counter.incrementAndGet()
            Thread.sleep(50)
            "ready"
        }
    }

    @Test
    fun `lazy chỉ chạy initializer một lần dù nhiều thread cùng truy cập`() {
        val counter = AtomicInteger()
        val holder = ExpensiveHolder(counter)
        val executor = Executors.newFixedThreadPool(8)

        repeat(8) { executor.submit { holder.value } }
        executor.shutdown()
        executor.awaitTermination(5, TimeUnit.SECONDS)

        assertThat(holder.value).isEqualTo("ready")
        assertThat(counter.get()).isEqualTo(1)
    }

    @Test
    fun `observable ghi lại mọi lần đổi trạng thái`() {
        val order = Order("ORD-002")

        order.status = OrderStatus.PAID
        order.status = OrderStatus.DELIVERED

        assertThat(order.history).containsExactly(
            "status: NEW -> PAID",
            "status: PAID -> DELIVERED"
        )
    }

    @Test
    fun `vetoable giữ nguyên giá trị cũ khi bị từ chối`() {
        val item = CartItem("SKU-7")

        item.quantity = 10
        item.quantity = -3

        assertThat(item.quantity).isEqualTo(10)
    }
}

Test đầu tiên dùng 8 thread cùng đọc value. Với chế độ mặc định SYNCHRONIZED, counter luôn bằng 1. Nếu bạn đổi sang LazyThreadSafetyMode.NONE và chạy lại vài lần, test sẽ có lúc fail. Đó là cách nhanh để bạn tự kiểm chứng sự khác biệt giữa các chế độ.

8. Lỗi hay gặp và khi nào không nên dùng

Dùng delegate trong entity JPA. Field thật mà compiler sinh ra là status$delegate, có kiểu ObservableProperty, không phải OrderStatus. Hibernate không map được field kiểu này một cách tự nhiên. Với entity, hãy dùng field thông thường kèm setter tùy chỉnh hoặc entity listener.

Lambda của lazy giữ tham chiếu. Lambda bị giữ lại cho tới khi chạy xong. Nếu nó capture một đối tượng lớn mà property không bao giờ được đọc, đối tượng đó vẫn bị giữ trong bộ nhớ.

Đọc lazy trong init block quá sớm. Nếu lambda dùng tới một property khai báo bên dưới, mà bạn lại đọc property lazy ngay trong init, bạn có thể nhận về giá trị chưa khởi tạo, như null hoặc 0.

Thay thế cơ chế của framework. Trong Spring, đừng dùng by lazy để tự tạo bean hay tự quản lý vòng đời của connection pool. Hãy dùng @Lazy hoặc ObjectProvider, để container còn quản lý được việc đóng tài nguyên.

Side effect nặng trong observable. Callback chạy đồng bộ ngay trong setter. Gọi HTTP hay ghi database trong callback sẽ khiến một phép gán đơn giản trở nên chậm và dễ lỗi. Hãy phát một event thay vì làm việc nặng ngay tại chỗ.

Nhầm observable là thread-safe. observable và vetoable không có cơ chế đồng bộ nào. Nếu nhiều thread cùng gán, callback có thể nhận old không chính xác.

9. Kết luận

Delegated properties giúp bạn tách logic truy cập property ra khỏi class, nhờ vậy code gọn và dễ đọc hơn. Bạn dùng by lazy cho giá trị tốn công tạo, và giữ chế độ SYNCHRONIZED mặc định trừ khi có lý do rõ ràng để đổi. Bạn dùng Delegates.observable khi cần phản ứng sau mỗi lần thay đổi, và nhớ rằng callback vẫn chạy khi giá trị không đổi. Bạn dùng Delegates.vetoable khi muốn âm thầm bỏ qua giá trị sai, còn khi cần báo lỗi rõ ràng thì chuyển sang require trong setter.

Khi đã nắm được getValue và setValue, bạn hoàn toàn có thể tự viết delegate cho nhu cầu riêng như chuẩn hóa chuỗi, đọc giá trị từ Map hay ghi log truy cập. Bạn chỉ cần tránh dùng chúng ở những nơi framework cần thấy field thật, như entity JPA.

kotlindelegated propertieslazyobservabledelegates