CodeGym /課程 /Kotlin SELF /copy() 與模型不可變性 — 不用突變的更新與 shallow copy

copy() 與模型不可變性 — 不用突變的更新與 shallow copy

Kotlin SELF
等級 33, 課堂 2
開放

1. 模型不可變性與 copy()

如果你才剛開始學,不可變性可能聽起來像是「為了限制而限制」:為什麼不直接就地修改物件?問題在於程式會成長,而隨著成長,「同一個物件」被讀取與被修改的地方也會變多。到了某個時刻,你會遇到這種等級的 bug:「我在某處改了支出金額,另一邊的報表卻壞掉了,而且我根本沒去碰那段」。

不可變性是一種嘗試,讓資料更像是一張商店收據:你可以重寫(建立新的),但不能「用筆把數字塗掉」還能不被人發現。

我們先用一個非常簡單的例子,感受「修改物件」與「建立更新後版本」之間的差異。

假設我們有一個使用者模型:

data class User(val name: String, val age: Int)

fun main() {
    val u1 = User("Ann", 20)
    // 不能這樣:age 是 val

    val u2 = u1.copy(age = 21)
    println(u1) // User(name=Ann, age=20)
    println(u2) // User(name=Ann, age=21)
}

這裡沒有任何「魔法」:我們不改 u1,而是建立一個新物件 u2,它只在 age 不同。這種作法讓變更歷程很清楚:先是某個狀態,之後變成另一個狀態。

copy() 在 data class 中做了什麼

在 data class 中,編譯器會自動產生 copy()。概念很簡單:你複製物件,同時可以替換部分欄位。

重點是,copy() 通常會有預設值參數,其預設值等於物件目前的欄位值。這常被解釋為等同於「手寫」版本的 copy(name = this.name, age = this.age)。

也就是說,copy() 很像物件版的「另存新檔…」:你拿到目前資料版本,做出一個新版本,只改動指定欄位。

小測試:「到底發生了什麼」:

data class Config(val host: String, val port: Int)

fun main() {
    val base = Config(host = "localhost", port = 8080)
    val prod = base.copy(host = "prod.company")

    println(base) // Config(host=localhost, port=8080)
    println(prod) // Config(host=prod.company, port=8080)
}

注意這段程式碼有多「人類可讀」:我們很明確地看到只改了 host。欄位有 5–10 個時尤其舒服,因為你不想每次都「從零」建立物件。

2. 透過物件版本更新資料

當你開始使用 copy(),很值得換一張腦內圖片:資料模型不是「會一直被我們修修補補的活體」,而是「狀態快照」。快照可以保存、比較、記錄到 log、傳遞到程式的其他地方。

這會大幅簡化除錯:如果哪裡不對,你可以輸出「更新前」與「更新後」,而且它們真的會是不同物件。

在我們的實作主題(主控台支出記帳)中,我們已經走到「用物件存支出」的階段(從 Pair/Triple 過渡之後)。因此我們先固定一個簡單的 Expense 模型,練習透過 copy() 來做更新。

data class Expense(
    val id: Int,
    val title: String,
    val amountCents: Long,
    val category: String
)

fun main() {
    val e1 = Expense(id = 1, title = "Coffee", amountCents = 350, category = "Food")
    val e2 = e1.copy(amountCents = 450)

    println(e1) // Expense(id=1, title=Coffee, amountCents=350, category=Food)
    println(e2) // Expense(id=1, title=Coffee, amountCents=450, category=Food)
}

接著有個重要的實務問題:「好,我建立了新物件。那要怎麼更新支出清單?」這裡有兩種常見作法 — 以我們目前程度兩種都很 OK。

更新 MutableList 中的元素:整個物件替換

我們把支出存在 MutableList<Expense> 中,透過索引替換元素,但替換的是整個新物件(而不是去改物件內部欄位)。

data class Expense(val id: Int, val title: String, val amountCents: Long)

fun main() {
    val expenses = mutableListOf(
        Expense(1, "Coffee", 350),
        Expense(2, "Taxi", 1200)
    )

    expenses[0] = expenses[0].copy(amountCents = 450)
    println(expenses[0]) // Expense(id=1, title=Coffee, amountCents=450)
}

以函式風格更新清單:建立新的集合

我們用 map 做「新清單」,不碰舊的。當你希望更偏函式風格、盡量少突變時,這很有用。

data class Expense(val id: Int, val title: String, val amountCents: Long)

fun main() {
    val expenses = listOf(
        Expense(1, "Coffee", 350),
        Expense(2, "Taxi", 1200)
    )

    val updated = expenses.map { e ->
        if (e.id == 1) e.copy(amountCents = 450) else e
    }

    println(updated[0]) // Expense(id=1, title=Coffee, amountCents=450)
}

兩種方法背後都是同一個想法:「更新 = 建立物件的新版本」。差別只在於:你是就地更新容器(清單),還是建立一個新集合。

為了讓腦袋不要過載,可以記一個小表格:

改動目標 方法 會突變的是 保持不變的是
MutableList 中的單一元素
list[i] = list[i].copy(...)
清單(那個格子) 格子裡的物件(我們用新物件替換了它)
整個集合
val newList = oldList.map { ...copy... }
沒有(建立了新的) 舊清單與舊物件

為什麼 copy() 通常更適合搭配 val,而不是 var

初學者很常見的錯誤是:把 data class 做成一堆 var,因為「反正我會改欄位」。看起來都能跑…直到開始混亂:到底誰、在什麼地方改了欄位?為什麼報表出現奇怪的數字?

使用 val 時,模型更像「穩定的值」:只要你握著 Expense 的參考,你就能確定它不會在你眼皮底下變掉。這在以下情境特別重要:

  • 你把物件傳進函式時,
  • 你把物件放進集合時,
  • 你用 == 比較物件時,
  • 你把物件印到 log(而且希望 log 符合真實狀態)時。

copy() + val 就像「沒有覆寫原檔 Save 的編輯器」:你永遠都是存成新版本。沒錯,一開始會覺得多做了幾步;但實務上它會省下大量追查「到底誰把資料弄壞」的時間。

用短例子比較「可變」與「版本化」模型:

data class MutableUser(var name: String, var age: Int)
data class StableUser(val name: String, val age: Int)

fun main() {
    val a = MutableUser("Ann", 20)
    a.age = 21
    println(a) // MutableUser(name=Ann, age=21)

    val b1 = StableUser("Bob", 20)
    val b2 = b1.copy(age = 21)
    println(b1) // StableUser(name=Bob, age=20)
    println(b2) // StableUser(name=Bob, age=21)
}

第二種寫法把「前/後」表達得非常清楚,也不會讓你不小心改到 b1。這就是它最主要的實務價值。

3. Shallow copy:當 copy() 共享巢狀參考

這裡就是本講最重要(也最容易踩坑)的一點:copy() 做的是表層複製(shallow copy)。意思是:copy() 會複製欄位的值,但不會「深入到」巢狀物件裡。組件不會遞迴複製,對其他物件的參考可能會被共享。

用白話說:如果你的物件裡面放了可變的東西(例如 MutableList),那原本的物件與複製後的物件會指向同一個清單。

我們重現一個經典驚喜:

data class Team(val name: String, val members: MutableList<String>)

fun main() {
    val a = Team("A", mutableListOf("Ann"))
    val b = a.copy()

    b.members.add("Bob")

    println(a.members) // [Ann, Bob]
    println(b.members) // [Ann, Bob]
}

這不是 Kotlin 的 bug。這只是參考的數學很誠實:a.members 和 b.members 都指向同一個 MutableList。

為了避免覺得「被騙了」,我們畫個示意圖:

flowchart LR
    A["a: Team"] --> LA["members -> List#1"]
    B["b: Team (copy)"] --> LA
    LA --> M1["Ann"]

執行 b.members.add("Bob") 之後,元素被加到 List#1,因此兩個物件都「看得到」。

在課程範圍內如何與 shallow copy 共存

我們有個簡單的實務結論:如果你想把 copy() 當成主要更新風格,盡量把資料模型設計成裡面不要有「隱藏的突變」。

最簡單的方式就是靠紀律:在模型內部用 List(唯讀介面)取代 MutableList,而修改則透過建立新清單來完成。這也呼應我們之前學過的集合概念:集合複製通常也只是表層複製,元素仍是同一批物件。

例如,讓支出有標籤,但不把「活的」可變集合直接暴露出去:

data class Expense(
    val id: Int,
    val title: String,
    val tags: List<String>
)

fun main() {
    val e1 = Expense(1, "Coffee", tags = listOf("food"))
    val e2 = e1.copy(tags = e1.tags + "morning")

    println(e1.tags) // [food]
    println(e2.tags) // [food, morning]
}

對 List 使用 + 會建立一個包含新元素的新清單。也就是說,你再一次做的是「資料新版本」,而不是突變舊資料 — 這正是與 copy() 最契合的用法。

4. 實作:不突變地更新支出

接下來我們組一段小而完整的邏輯,用在主控台支出追蹤器。想法是:我們有一個支出清單,想要「更新金額」或「改標題」,但不直接動到物件本身 — 只透過 copy()。

這會看起來稍微更囉嗦,但換來的是可預測性:函式不修改傳入的物件,而是回傳更新後的新版本。

先從最小模型開始:

data class Expense(
    val id: Int,
    val title: String,
    val amountCents: Long,
    val category: String,
    val tags: List<String>
)

現在做一個「更新金額」函式(注意:輸入是 Expense,輸出是新的 Expense):

fun withNewAmount(expense: Expense, newAmountCents: Long): Expense {
    require(newAmountCents >= 0) { "Amount must be non-negative: $newAmountCents" }
    return expense.copy(amountCents = newAmountCents)
}

在 main 中驗證:

fun main() {
    val e1 = Expense(1, "Coffee", 350, "Food", tags = listOf("morning"))
    val e2 = withNewAmount(e1, 450)

    println(e1.amountCents) // 350
    println(e2.amountCents) // 450
}

接著是「按 id 更新清單中的支出金額」。清單是可變的,但我們替換的是整個物件:

fun updateAmountById(expenses: MutableList<Expense>, id: Int, newAmountCents: Long) {
    val index = expenses.indexOfFirst { it.id == id }
    if (index == -1) return

    expenses[index] = expenses[index].copy(amountCents = newAmountCents)
}

小驗證:

fun main() {
    val expenses = mutableListOf(
        Expense(1, "Coffee", 350, "Food", listOf()),
        Expense(2, "Taxi", 1200, "Transport", listOf())
    )

    updateAmountById(expenses, id = 1, newAmountCents = 450)
    println(expenses[0]) // Expense(id=1, title=Coffee, amountCents=450, category=Food, tags=[])
}

再加一個「新增標籤」(同樣不突變,因為 tags: List<String>):

fun addTag(expense: Expense, tag: String): Expense {
    val clean = tag.trim()
    if (clean.isEmpty()) return expense
    return expense.copy(tags = expense.tags + clean)
}

以及測試:

fun main() {
    val e1 = Expense(1, "Coffee", 350, "Food", tags = listOf("morning"))
    val e2 = addTag(e1, "cafe")

    println(e1.tags) // [morning]
    println(e2.tags) // [morning, cafe]
}

如果你此刻心想:「可是這樣會產生很多 copy!」— 沒錯,複製會變多。但你換來的是更容易閱讀的變更流程: 「拿到支出 → 做成有新 amount 的版本 → 放回清單」。

對於教學專案與多數一般應用而言,這是簡單性與可靠性之間很合理的取捨。

為了更直觀,可以把更新想成這樣:

flowchart TD
    A["Expense (舊)"] -->|"copy(amountCents=...)"| B["Expense (新版本)"]
    B -->|替換進清單| C["MutableList⟨Expense⟩ (格子已更新)"]

5. 使用 copy() 與不可變性時的常見錯誤

錯誤 №1:期待 copy() 會做「深拷貝」。
很容易在某次寫下 val b = a.copy() 時,下意識就期待「現在全部都獨立了」。但 data class 的 copy() 是 shallow copy:巢狀參考(例如指向 MutableList)會是共享的,所以一邊的修改會在另一邊出現。

錯誤 №2:在模型中保存 MutableList/MutableMap,同時又用 copy() 建構整體架構。
可變集合本身並非禁用。真正的問題出在你以為「我只透過 copy() 更新物件」,但內部其實放著可變結構,某人能直接改它。這會得到一種混合體:外層看起來不可變,內層卻有隱藏突變,而這類 bug 會非常難找。

錯誤 №3:在 copy() 使用位置參數,然後搞錯順序。
技術上 copy() 也能用位置參數呼叫,但欄位一旦超過兩個,出錯機率就會大增。更安全(也更可讀)的方式是使用具名參數:expense.copy(amountCents = 450)。這點很重要,因為 copy() 的設計本來就是用來做「精準、局部」的修改。

錯誤 №4:用不可變性與 copy() 「治百病」,甚至用在明明直接修改更簡單的地方。
有些時候物件確實很局部、生命週期很短、也不會被傳出去 — 例如你在建立模型之前組裝輸入資料。在這些地方用 var 完全可能是合理工具。錯誤在於把 copy() 當成宗教。它只是某種風格,適合用在資料會在程式不同部分之間共享的地方。

錯誤 №5:忘記不可變性是「約定」,不是魔法。
如果你用 val tags: List<String>,這會在編譯器層級防止你寫出 tags.add(...)。但如果你在某處把可變清單的參考塞進 List,而另有人仍握著那個可變清單的參考,那修改依然可能發生。標準函式做集合複製通常也是 shallow:元素仍然是同一批物件。因此在應用程式中,「不可變性」也包含一種習慣:小心你往外暴露了什麼、交付了什麼參考。

1
任務
Kotlin SELF, 等級 33, 課堂 2
上鎖
生日
生日
1
任務
Kotlin SELF, 等級 33, 課堂 2
上鎖
生產模式
生產模式
1
任務
Kotlin SELF, 等級 33, 課堂 2
上鎖
更新價格
更新價格
1
任務
Kotlin SELF, 等級 33, 課堂 2
上鎖
關閉任務
關閉任務
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION