Logo
Published on

Kotlin 延遲初始化:lazy 與 lateinit 的抉擇

在寫 Kotlin 的時候,我們常常會遇到一個情境:某個屬性的初始化成本很高,或是在宣告的當下還沒辦法給值。這時候,Kotlin 提供了兩個好用的工具 — by lazylateinit

這篇文章會帶你搞懂這兩個機制的用法、差異,以及什麼時候該用哪一個。如果你是從 C# 轉過來的開發者,文章後半段也會對比 C# 的 Lazy<T>,幫助你快速對應觀念

為什麼需要延遲初始化?

先看一個問題。假設我們有一個很耗時的運算

class UserProfile {
    val report: String = generateReport() // 每次建立物件都會執行,即使根本沒用到

    private fun generateReport(): String {
        println("花了 3 秒產生報表...")
        return "這是一份很長的報表"
    }
}

如果 report 不一定會被用到,每次建立 UserProfile 都跑一次 generateReport() 就很浪費。延遲初始化的核心概念就是:用到的時候再初始化,沒用到就不浪費資源

by lazy:用到再算,算完就固定

by lazy 是 Kotlin 的委託屬性(Delegated Property),它接受一個 lambda,在第一次存取時才執行,之後都回傳快取的結果

基本用法

class UserProfile {
    val report: String by lazy {
        println("花了 3 秒產生報表...")
        "這是一份很長的報表"
    }
}

fun main() {
    val profile = UserProfile()
    println("物件建立完成,還沒存取 report")

    println(profile.report) // 這時才會印「花了 3 秒產生報表...」
    println(profile.report) // 直接回傳快取值,不會再執行 lambda
}

輸出

物件建立完成,還沒存取 report
花了 3 秒產生報表...
這是一份很長的報表
這是一份很長的報表

重要規則

by lazy 只能用在 val(唯讀屬性)。因為它的設計理念就是「算一次就固定」,不允許之後再改值。如果你試著用在 var 上,編譯器會直接報錯

// 編譯錯誤!lazy 沒有提供 setValue 運算子
var name: String by lazy { "Cash" }

執行緒安全模式

by lazy 提供三種執行緒安全模式,透過參數指定

// SYNCHRONIZED(預設):加鎖,確保只有一個執行緒能執行初始化
val a by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
    expensiveComputation()
}

// PUBLICATION:多個執行緒可能同時執行初始化,但最終只會採用一個結果
val b by lazy(LazyThreadSafetyMode.PUBLICATION) {
    expensiveComputation()
}

// NONE:不加鎖,效能最好,但只適合確定是單執行緒的情境
val c by lazy(LazyThreadSafetyMode.NONE) {
    expensiveComputation()
}

對初學者來說,大多數情況用預設的就好,不需要特別指定。如果你確定只在單執行緒使用(例如 Android 的 UI 執行緒),可以用 NONE 來省掉加鎖的開銷

適用情境

  • 計算成本高、但不一定會用到的屬性
  • 需要存取其他屬性來初始化(避免建構子裡的順序問題)
  • 設定檔解析、資料庫連線等只需要做一次的事

lateinit:我晚點再給值

lateinit 的用途完全不同。它告訴編譯器:「這個屬性我現在沒辦法給值,但我保證在使用之前一定會賦值」

基本用法

class MyActivity {
    lateinit var presenter: Presenter

    fun onCreate() {
        presenter = Presenter() // 在生命週期回呼中賦值
    }

    fun onButtonClick() {
        presenter.doSomething() // 使用時已經賦值了
    }
}

重要規則

lateinit 有幾個限制

// 1. 只能用在 var(因為之後要賦值)
lateinit var name: String  // OK
lateinit val name: String  // 編譯錯誤!

// 2. 不能用在原始型別
lateinit var count: Int     // 編譯錯誤!
lateinit var flag: Boolean  // 編譯錯誤!

// 3. 不能用在可為 null 的型別
lateinit var name: String?  // 編譯錯誤!

安全檢查

如果在賦值之前就存取 lateinit 屬性,會拋出 UninitializedPropertyAccessException。可以用 isInitialized 來檢查

class MyService {
    lateinit var config: Config

    fun printConfig() {
        if (::config.isInitialized) {
            println(config)
        } else {
            println("config 還沒初始化")
        }
    }
}

注意 ::config.isInitialized 這個語法,前面的 :: 是屬性參考(Property Reference),可以用來檢查 lateinit 屬性是否已經賦值。雖然語法上看起來像反射,但編譯器會直接處理,不需要引入額外的反射套件

適用情境

  • 依賴注入(DI)框架注入的屬性
  • Android/框架的生命週期回呼中才能初始化的物件
  • 測試中在 @BeforeEach 才建立的物件

lazy vs lateinit 比較

特性by lazylateinit
搭配的關鍵字valvar
初始化方式提供 lambda,自動執行手動賦值
初始化時機第一次存取時由開發者控制
能否重新賦值不行,值固定可以
原始型別支援支援(IntBoolean 等)不支援
Nullable 支援支援不支援
執行緒安全可設定(預設安全)需要自己處理
未初始化就存取不會發生(lambda 會自動執行)拋出例外

怎麼選?

決策其實很簡單,問自己一個問題:初始化的邏輯,你在宣告的時候就知道了嗎?

  • 知道 → 用 by lazy,把邏輯寫在 lambda 裡,讓 Kotlin 自動幫你處理
  • 不知道(要等外部注入、生命週期回呼等) → 用 lateinit,之後再手動賦值
class Example {
    // 我知道怎麼算,只是不想現在就算 → lazy
    val config: Config by lazy { loadConfig() }

    // 要等框架注入,我現在沒辦法給值 → lateinit
    lateinit var repository: Repository
}

當專案有 DI 框架,還需要 lazy 嗎?

在現代 Kotlin 後端專案中,DI 框架(如 Koin、Dagger)幾乎是標準配備。既然 DI 框架能管理物件的建立和生命週期,那還需要 lazy 嗎?

DI 能取代的場景

很多過去需要 lazy 的情境,有了 DI 之後其實可以交給框架處理

// 沒有 DI 的時代:用 lazy 延遲建立昂貴的依賴
class OrderService {
    val userRepository: UserRepository by lazy { UserRepository() }
    val emailService: EmailService by lazy { EmailService() }

    fun placeOrder(userId: Int) {
        val user = userRepository.findById(userId)
        emailService.send(user.email, "訂單成立")
    }
}

有了 DI 之後,這些依賴交給框架管理,透過建構子注入取得就好

// Koin DI 框架
val appModule = module {
    single { UserRepository() }
    single { EmailService() }
    single { OrderService(get(), get()) }
}

// 依賴由框架注入,不需要自己管理建立時機
class OrderService(
    private val userRepository: UserRepository,
    private val emailService: EmailService
) {
    fun placeOrder(userId: Int) {
        val user = userRepository.findById(userId)
        emailService.send(user.email, "訂單成立")
    }
}

而且 Koin 的 single 本身就有 lazy 的效果 — 物件在第一次被注入時才會建立,不是應用程式啟動就全部產生

DI 無法取代的場景

不過 DI 管的是物件之間的依賴關係,有些事情它管不到

  • 類別內部的衍生計算:從其他屬性推算出來的值,跟外部依賴無關
class Report(val data: List<Int>) {
    // 這是從 data 衍生出來的計算結果,不適合交給 DI
    val summary: String by lazy {
        "共 ${data.size} 筆,總和 ${data.sum()},平均 ${data.average()}"
    }
}
  • 根據執行時期資料決定的屬性:值取決於執行時期的狀態,宣告時還無法確定
class UserSession(val userId: Int) {
    // 要根據 userId 去查,DI 框架不知道 userId 是什麼
    val preferences: UserPreferences by lazy {
        loadPreferencesFromDb(userId)
    }
}
  • 沒有被 DI 管理的物件:像 data class、短生命週期的工具物件,本來就不在 DI 的管轄範圍

所以 DI 負責物件之間怎麼接線,lazy 負責單一屬性什麼時候算。兩個工具解決的問題不一樣,搭在一起用才對

從 C# 的 Lazy<T> 看 Kotlin 的 lazy

如果你有 C# 的背景,應該對 Lazy<T> 不陌生。在 C# 的專案裡,Lazy<T> 很常被用來做昂貴資源的延遲初始化,最經典的場景就是資料庫連線

C# 的典型用法:資料庫連線

public class DatabaseService
{
    private readonly Lazy<SqlConnection> _connection = new Lazy<SqlConnection>(() =>
    {
        var conn = new SqlConnection("Server=myServer;Database=myDB;...");
        conn.Open();
        Console.WriteLine("資料庫連線已建立");
        return conn;
    });

    public SqlConnection Connection => _connection.Value;

    public void Query()
    {
        // 第一次存取 Connection 時才會真正建立連線
        var cmd = Connection.CreateCommand();
        // ...
    }
}

這個模式的好處是:如果整個 Service 的生命週期中都沒有呼叫到 Query(),資料庫連線就根本不會建立,省下了不必要的資源開銷

Kotlin 的對應寫法

同樣的情境,在 Kotlin 用 by lazy 可以寫得更簡潔

class DatabaseService {
    val connection: Connection by lazy {
        println("資料庫連線已建立")
        DriverManager.getConnection("jdbc:mysql://localhost:3306/myDB", "user", "pass")
    }

    fun query() {
        // 第一次存取 connection 時才會建立連線
        val stmt = connection.createStatement()
        // ...
    }
}

對比兩邊最大的差異在於:C# 要透過 .Value 才能取到實際的值,而 Kotlin 直接存取屬性就好,呼叫端完全不知道背後是延遲初始化的

C# Lazy<T> vs Kotlin by lazy 語法差異

面向C# Lazy<T>Kotlin by lazy
宣告方式new Lazy<T>(() => ...)by lazy { ... }
取值方式透過 .Value 屬性直接存取,像一般屬性
執行緒安全預設安全(ExecutionAndPublication)安全(SYNCHRONIZED)
例外快取預設會快取例外,之後存取都拋同一個例外不會快取例外,下次存取會重新執行 lambda
語言整合度BCL 類別,與屬性系統無特殊整合委託屬性,與語言深度整合

例外處理的差異值得特別注意。在 C# 中,如果 Lazy<T> 的工廠方法拋出例外,這個例外會被快取起來,之後每次存取 .Value 都會得到同樣的例外,沒有重試的機會。Kotlin 的 by lazy 則不同,如果 lambda 拋出例外,下次存取會重新執行 lambda,等於給你一次重試的機會

但 Kotlin 的資料庫連線真的適合用 lazy 嗎?

答案是:看情境

by lazy 適合的是「建立一次、永久使用」的場景。但在現代的 Kotlin 後端開發中,資料庫連線通常不會只維護一條,而是透過連線池(Connection Pool)來管理。這時候更常見的做法是搭配 DI 框架

// 使用 Koin DI 框架的範例
val appModule = module {
    // singleton:整個應用程式只建立一個 DataSource
    single<DataSource> {
        HikariDataSource(HikariConfig().apply {
            jdbcUrl = "jdbc:mysql://localhost:3306/myDB"
            username = "user"
            password = "pass"
            maximumPoolSize = 10
        })
    }
}

class UserRepository(private val dataSource: DataSource) {
    fun findById(id: Int): User? {
        dataSource.connection.use { conn ->
            // 從連線池取連線,用完自動歸還
            // ...
        }
        return null
    }
}

在這種架構下,DataSource 的生命週期由 DI 框架管理,不需要自己用 lazy。但如果你是在寫小工具、腳本、或是沒有 DI 框架的簡單專案,by lazy 來管理資料庫連線依然是一個乾淨又好用的做法

用 lazy 實現快取

by lazy 本質上就是一種快取機制 — 計算一次,結果快取起來,之後重複使用。但實務上,我們常遇到更複雜的快取需求

最簡單的快取:by lazy

如果你的資料在整個生命週期中不會改變,by lazy 就是最自然的快取

class ConfigService {
    // 設定檔只讀一次,之後都用快取
    val appConfig: Map<String, String> by lazy {
        println("讀取設定檔...")
        loadConfigFromFile("/etc/app/config.yml")
    }
}

帶有 Key 的快取:用 Map 搭配 getOrPut

by lazy 只能快取單一值。如果你需要根據不同的 key 快取不同的結果,可以用 MutableMap 搭配 getOrPut

class UserService {
    private val cache = mutableMapOf<Int, User>()

    fun getUser(id: Int): User {
        return cache.getOrPut(id) {
            println("從資料庫查詢 User $id ...")
            queryUserFromDb(id)
        }
    }
}

fun main() {
    val service = UserService()
    service.getUser(1)  // 從資料庫查詢 User 1 ...
    service.getUser(1)  // 直接回傳快取,不會再查詢
    service.getUser(2)  // 從資料庫查詢 User 2 ...
}

getOrPut 的邏輯是:如果 Map 裡已經有這個 key,就直接回傳;沒有的話就執行 lambda、把結果存進去再回傳。這跟 C# 的 ConcurrentDictionary.GetOrAdd() 概念類似,但要注意上面用的是一般的 mutableMapOf,只適合單執行緒。如果需要多執行緒安全,可以改用 ConcurrentHashMap 搭配 computeIfAbsent

需要過期機制的快取

如果你的快取需要設定過期時間(TTL),就超出了 lazy 能處理的範圍。可以自己簡單實作(以下示範僅適用單執行緒,多執行緒需加上 synchronized 或其他同步機制)

class CachedValue<T : Any>(
    private val ttlMillis: Long,
    private val provider: () -> T
) {
    private var cachedValue: T? = null
    private var lastFetchTime: Long = 0

    fun get(): T {
        val now = System.currentTimeMillis()
        if (cachedValue == null || (now - lastFetchTime) > ttlMillis) {
            cachedValue = provider()
            lastFetchTime = now
        }
        return cachedValue!!
    }
}

class ExchangeRateService {
    // 匯率每 5 分鐘更新一次
    private val rateCached = CachedValue(ttlMillis = 5 * 60 * 1000L) {
        println("呼叫 API 取得最新匯率...")
        fetchExchangeRateFromApi()
    }

    fun getRate(): Double = rateCached.get()
}

快取策略選擇指引

需求推薦做法
單一值、永不過期by lazy
多個 key、永不過期MutableMap + getOrPut
需要 TTL / 過期機制自行實作或使用 Caffeine / Guava Cache
分散式快取Redis、Memcached 等外部方案

重點觀念是:by lazy 是最輕量的快取,適合「算一次就夠了」的場景。當需求變複雜,就應該往上選擇更適合的工具,而不是硬把 lazy 套在不適合的地方

總結

by lazylateinit 和快取各自解決不同層次的問題

  • by lazy 處理的是「成本問題」— 初始化太貴,用到再算,算完就固定
  • lateinit 處理的是「時機問題」— 現在還沒辦法給值,晚點再說
  • 快取處理的是「重複問題」— 同樣的運算不要做第二次。by lazy 本身就是最簡單的快取形式,更複雜的需求則需要搭配 Map、TTL 機制或專門的快取框架

有用 DI 框架的話,很多依賴的建立可以交給框架處理,不過類別內部的衍生計算、要等執行時期才能決定的值,還是得靠 lazy。如果你是從 C# 轉過來的開發者,Kotlin 的 by lazy 在概念上跟 Lazy<T> 幾乎一樣,只是語法更簡潔、跟語言整合得更深。搞清楚每個工具的定位,選擇就自然浮現了


圖片來源:AI 產生