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

在寫 Kotlin 的時候,我們常常會遇到一個情境:某個屬性的初始化成本很高,或是在宣告的當下還沒辦法給值。這時候,Kotlin 提供了兩個好用的工具 — by lazy 和 lateinit
這篇文章會帶你搞懂這兩個機制的用法、差異,以及什麼時候該用哪一個。如果你是從 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 lazy | lateinit |
|---|---|---|
| 搭配的關鍵字 | val | var |
| 初始化方式 | 提供 lambda,自動執行 | 手動賦值 |
| 初始化時機 | 第一次存取時 | 由開發者控制 |
| 能否重新賦值 | 不行,值固定 | 可以 |
| 原始型別支援 | 支援(Int、Boolean 等) | 不支援 |
| 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 lazy、lateinit 和快取各自解決不同層次的問題
by lazy處理的是「成本問題」— 初始化太貴,用到再算,算完就固定lateinit處理的是「時機問題」— 現在還沒辦法給值,晚點再說- 快取處理的是「重複問題」— 同樣的運算不要做第二次。
by lazy本身就是最簡單的快取形式,更複雜的需求則需要搭配 Map、TTL 機制或專門的快取框架
有用 DI 框架的話,很多依賴的建立可以交給框架處理,不過類別內部的衍生計算、要等執行時期才能決定的值,還是得靠 lazy。如果你是從 C# 轉過來的開發者,Kotlin 的 by lazy 在概念上跟 Lazy<T> 幾乎一樣,只是語法更簡潔、跟語言整合得更深。搞清楚每個工具的定位,選擇就自然浮現了
圖片來源:AI 產生