Logo
Published on

Kotlin Class 的初始化機制:解密 init 與初始化順序

Kotlin 的 class 初始化機制裡有個在 Java/C# 中沒有直接對等語法的角色:init block。C# 沒有對應的概念,Java 雖然有 instance initializer block 但幾乎沒人用。Kotlin 不只把 init 提升為一等公民,還讓它跟 primary constructor、property initializer 之間形成了一套有趣的初始化順序

Primary Constructor 不是一般的 Constructor

大多數語言的 constructor 是一個有 body 的函式,在裡面做初始化。Kotlin 的 primary constructor 沒有 body,它負責宣告參數,加上 val/var 則同時宣告 property

class A(name: String)          // name 只是參數,不是 property
class B(val name: String)      // name 是 property
class User(val name: String, val email: String)

這一行同時做了三件事:宣告 class、宣告 primary constructor 的參數、宣告兩個 property。需要在建構時做邏輯處理?這就是 init 存在的理由

init Block — Primary Constructor 的 Body

init block 是 primary constructor 的延伸,能存取 primary constructor 的參數,在物件建立時執行

class User(val name: String, val email: String) {

    init {
        require(name.isNotBlank()) { "Name cannot be blank" }
        require(email.contains("@")) { "Invalid email format" }
        println("User created: $name")
    }
}

init 做了 primary constructor 自己做不到的事:驗證參數、跑額外邏輯

多個 init Block

Kotlin 允許在一個 class 裡宣告多個 init block,按照原始碼中的出現順序依次執行

class User(val name: String) {

    init {
        println("First init: validating")
        require(name.isNotBlank())
    }

    init {
        println("Second init: logging")
        println("User $name is being created")
    }
}

建立 User("Cash") 的輸出結果

First init: validating
Second init: logging
User Cash is being created

語法上允許多個 init,但實務上用一個就夠,除非有明確理由分段(職責區隔或可讀性)。多個 init block 散落在 class 各處會讓初始化邏輯難以追蹤

初始化順序 — 最關鍵的部分

Property initializer 和 init block 按照原始碼中的出現順序交錯執行,不是「所有 property 先跑完,再跑 init」

class Demo(name: String) {

    val upperName = name.uppercase().also {
        println("1. Property upperName initialized: $it")
    }

    init {
        println("2. First init block, upperName = $upperName")
    }

    val length = upperName.length.also {
        println("3. Property length initialized: $it")
    }

    init {
        println("4. Second init block, length = $length")
    }
}

建立 Demo("cash") 的輸出結果

1. Property upperName initialized: CASH
2. First init block, upperName = CASH
3. Property length initialized: 4
4. Second init block, length = 4

這個交錯執行的設計帶來一個重要限制:init block 只能安全地存取在它之前宣告的 property

順序錯誤的陷阱

class Broken(name: String) {

    init {
        // 危險!greeting 還沒初始化
        println(greeting.length)  // NullPointerException!
    }

    val greeting = "Hello, $name"
}

這段程式碼可以編譯,但執行時會拋出 NullPointerExceptiongreetinginit 執行時,backing field 在 JVM 層級還是預設值 null——儘管它宣告為 String 而非 String?。Kotlin 的型別系統保證的是建構完成後的非空契約,但在建構過程中用錯初始化順序,仍可能讀到 JVM 的預設值。編譯器不報錯,但 IDE 通常能提示你可能讀到未初始化的 backing field

完整的初始化順序

有繼承關係、primary constructor、secondary constructor 同時存在時

open class Parent(value: String) {

    open val parentProp = "Parent prop".also {
        println("1. Parent property initialized")
    }

    init {
        println("2. Parent init block")
    }

    constructor(value: String, extra: Int) : this(value) {
        println("3. Parent secondary constructor")
    }
}

class Child : Parent {

    val childProp = "Child prop".also {
        println("4. Child property initialized")
    }

    init {
        println("5. Child init block")
    }

    constructor(value: String) : super(value, 42) {
        println("6. Child secondary constructor")
    }
}

注意 Child 沒有 primary constructor,只有 secondary constructor——這是為了展示 secondary constructor 在初始化順序中的時機

建立 Child("test") 的輸出結果

1. Parent property initialized
2. Parent init block
3. Parent secondary constructor
4. Child property initialized
5. Child init block
6. Child secondary constructor

四條規則

  • Parent 的 property initializer 和 init block (按原始碼順序交錯執行)
  • Parent 的 secondary constructor body (如果有的話)
  • Child 的 property initializer 和 init block (按原始碼順序交錯執行)
  • Child 的 secondary constructor body (如果有的話)

每一層都是先完成「property + init」的交錯初始化,再執行 secondary constructor

容易踩到的坑:在初始化階段呼叫 open 成員

這是所有 OOP 語言都有的經典問題,Kotlin 的 init 讓它更容易被觸發

open class Parent {

    init {
        // 危險!此時 Child 還沒初始化
        setup()
    }

    open fun setup() {
        println("Parent setup")
    }
}

class Child : Parent() {

    val data = mutableListOf("initial")

    override fun setup() {
        // data 此時還是 null!
        println("Child setup, data = $data")
        data.add("from setup")  // NullPointerException!
    }
}

Parent 的 init 在 Child 的 property 初始化之前執行,所以 datasetup() 被呼叫時還是 null——即使它宣告為 MutableList<String> 而非 nullable 型別。這跟前面 greeting 的情況一樣,是 JVM 層級的初始化順序問題,Kotlin 的 null safety 無法在這裡保護你。編譯器會針對這種情況給出 “Calling non-final function in constructor” 的警告,不要忽略它。同樣的問題也適用於 open val/open var——父類初始化階段存取被子類 override 的 property,getter 會動態派發到子類,而子類的 backing field 此時尚未初始化

init vs Constructor Body vs Lazy

什麼邏輯該放在哪裡?

class UserService(dbUrl: String) {

    // Property initializer:簡單的賦值或轉換
    private val normalizedUrl = dbUrl.trim().lowercase()

    // init:驗證、前置條件檢查、初始化日誌
    init {
        require(normalizedUrl.startsWith("jdbc:")) {
            "Invalid database URL"
        }
        println("UserService initializing with $normalizedUrl")
    }

    // lazy:昂貴的初始化,延遲到真正需要時
    // DriverManager 是 Java 標準函式庫 (java.sql) 的資料庫連線 API
    val connection by lazy {
        println("Creating database connection...")
        DriverManager.getConnection(normalizedUrl)
    }
}
  • Property initializer:適合純粹的值轉換,盡量無副作用
  • init block:適合前置條件檢查、需要依賴多個 property 或跑額外邏輯的場景
  • lazy:適合初始化成本高、不一定會用到的資源
  • Secondary constructor body:適合需要提供多種建構方式時的額外邏輯

和其他語言的比較

Java 把所有初始化邏輯擠在 constructor body 裡,多個 constructor 之間共用邏輯要嘛抽成 private method 再各自呼叫,要嘛用 constructor chaining。Java 的 instance initializer block 語法上存在,但幾乎沒人用,行為也不直覺

C# 沒有 init block,初始化邏輯只能放在 constructor,或用 property initializer 做簡單賦值

Kotlin 把初始化拆成明確的階段,每個角色各司其職:primary constructor 接收參數、property initializer 轉換和賦值、init 做驗證和額外邏輯、secondary constructor 提供替代的建構方式

總結

init block 的存在是因為 primary constructor 沒有 body,總得有個地方放「建構時需要執行的邏輯」

理解初始化順序只需要記住一句話:property initializer 和 init block 按原始碼順序交錯執行。加上繼承時「由父到子」的原則,任何情況都能預測

三件事值得特別留意

  • init 裡只存取在它之前宣告的 property
  • 不要在初始化階段呼叫 open 的成員函式或存取 open 的 property
  • 多個 init block 雖然合法,大多數情況下一個就夠了

圖片來源:AI 產生