[Kotlin 小撇步 #10]不可變的資料類別及防禦性複製

Andy Lu
10 min readFeb 3, 2023
Image by Jan Simons from Pixabay

在 Kotlin 中,可以使用 val 、var 來決定變數能不能被修改,其中 val 表示該屬性是不可以變動的,而 var 則為可以變動的。

我們經常會使用 data class建立一個資料類別(Data Class)時,在建構式就必須要決定要使用 val或是 var。為了讓資料類別能夠將資料妥善的封裝在裡面,不會在意外的時間被修改,通常我們會使用 val 來限制其修改,所以當使用 val 宣告之後,是否就可以高枕無憂的認為這個資料類別裡的內容是固定的,不會被修改呢?

在大部分的情境之下,這個答案都是正確的,只要我們將屬性用 val 宣告,那麼我們就沒有辦法把其他的直指定到這個變數上。

下面有一個資料類別 BodyInfo裡面存放了兩個屬性(height: Int 以及 weight: Int),當我們嘗試要將新的內容塞給 weight 時,此時就會出現 compiled time error:Val cannot be reassigned ,如下:

data class BodyInfo(val height:Int, val weight: Int)
fun main() {
val bodyInfo = BodyInfo(180, 80)
bodyInfo.weight = 75
}
val cannot be reassigned

從上面的內容得知,我們可以使用 val 來保護資料類別內的資料不會被更改。那你可能會有疑問了,如果我們真的有需要修改資料類別裡的資料,該怎麼辦呢?
在 data class 中,我們可以使用 copy()將原先資料類別的內容修改後在複製一份出來,如下範例:


val newBodyInfo = bodyInfo.copy(weight = 75)

如此一來,原本所建立好的資料類別還是保持的原本的樣貌,而我們也取得修改後的 ”新” 資料類別。那麼這樣子又有什麼好處呢?當一個資料類別建立出來之後,裡面封裝了相關的資訊,對外部使用者來說,這個類別提供了固定的資訊,在每次的取用都應該得到相同的結果。但是如果我們是使用 var 宣告資料類別內的屬性時,這裡面的屬性就變得可以被修改,如果這個資料類別會在程式裡傳遞,那麼就有可能會收到預期外的內容。而使用 val 就能夠大部分的解決這個問題。

But

如果在資料類別內,存放的屬性是一個可變的集合(Collection)時,雖然不能重新賦值給這個可變列表(因為使用 val 宣告),但是卻可以更新這個列表。而且不太容易注意到的一點是,如果我們是在資料類別的外面建立了這個可變集合,並且持有這個實例,那麼在資料類別的外面也還保有著更新該集合的權力。如下面範例,一個 ClassBodyInfos 存放著所有學生的身高體重的資訊,而這邊使用 MutableMap 作為唯一的屬性,並且將這個屬性設定為 private val,表示為私有的且不能更改,在 main() 中,我們在外部建立了一個 MutableMap,並且將這個 Map 塞進至 ClassBodyInfos 這個資料類別中,從下方結果我們可以得知,ClassBodyInfo 內的資料就是我們在外面所建立的 MutableMap:

data class ClassBodyInfos(private val bodyInfos: MutableMap<String, BodyInfo>) {

override fun toString(): String {
val stringBuilder = StringBuilder()
stringBuilder.append("[\n")
bodyInfos.forEach { (name, bodyInfo) ->
stringBuilder.append("Name: $name, Height: ${bodyInfo.height}, Weight:${bodyInfo.weight}")
stringBuilder.append(",\n")
}
stringBuilder.append("]")
return stringBuilder.toString()
}
}

fun main() {
val maps = mutableMapOf(
"Alex" to BodyInfo(180, 80),
"Johnson" to BodyInfo(200, 90)
)

val classBodyInfos = ClassBodyInfos(maps)
println(classBodyInfos)
}
[
Name: Alex, Height: 180, Weight:80,
Name: Johnson, Height: 200, Weight:90,
]

如果,我們在外部更改了 MutableMap 呢?

我們將上面的範例稍微修改一下,將 ClassBodyInfo 的值印出來之後,我們在修改外部的 MutableMap,看看這樣會不會對內部的內容有影響,我們在第一次印出 classBodyInfos 之後,就對外部的 Mutable Map 新增了一個項目,並且再次列印其內容:


fun main() {
val maps = mutableMapOf(
"Alex" to BodyInfo(180, 80),
"Johnson" to BodyInfo(200, 90)
)

val classBodyInfos = ClassBodyInfos(maps)
println(classBodyInfos)


maps["Andy"] = BodyInfo(175, 70)
println(classBodyInfos)
}
[
Name: Alex, Height: 180, Weight:80,
Name: Johnson, Height: 200, Weight:90,
]
[
Name: Alex, Height: 180, Weight:80,
Name: Johnson, Height: 200, Weight:90,
Name: Andy, Height: 175, Weight:70,
]

從上方的結果得知,雖然我們沒有直接使用 ClassBodyInfos 內的 bodyInfos 來修改這個 Mutable Map,但是由於我們在外部持有這個 Mutable Map 的 Reference,所以只要有這個 Reference,就表示多了一個後門可以更新資料類別的內容,而這樣也就讓這個資料類別從「不可變」變成「可變」,而且還是可能在不知情的情形之下被更改內容。

那麼,我們要如何解決這個問題呢?

Kotlin 中,所有的 Collection 類別都有兩種版本,一種是不可變(Immutable)的、另一種則是可變(Mutable)的:

  • List Vs MutableList
  • Map Vs MutableMap
  • Set Vs MutableSet

而從定義的名稱我們可以知道, Kotlin 希望我們用的是哪一種(不可變的)。

若將 ClassBodyInfos內部的 MutableMap 修改成 Map ,且當我們在外部建立 Map 也是使用相同的型別(Map),如此一來,就算持有這個相同 Map 的 Reference 不能夠修改到資料類別內部的 Map:

data class ClassBodyInfos(private val bodyInfos: Map<String, BodyInfo>) {
...
}

fun main() {
val maps = mapOf(
"Alex" to BodyInfo(180, 80),
"Johnson" to BodyInfo(200, 90)
)

val classBodyInfos = ClassBodyInfos(maps)
println(classBodyInfos)


maps["Andy"] = BodyInfo(175, 70) // Compiled error
println(classBodyInfos)
}

但是,如果我們在外部是使用 MutableMap 而非 Map,雖然在資料類別內部是 Map,我們仍然可以在外部此 MutableMap,最後還是會影響到資料類別內儲存的資料。

fun main() {  
val maps = mutableMapOf(
"Alex" to BodyInfo(180, 80),
"Johnson" to BodyInfo(200, 90)
)

val classBodyInfos = ClassBodyInfos(maps)
println(classBodyInfos)


maps["Andy"] = BodyInfo(175, 70)
println(classBodyInfos)
}
[
Name: Alex, Height: 180, Weight:80,
Name: Johnson, Height: 200, Weight:90,
]
[
Name: Alex, Height: 180, Weight:80,
Name: Johnson, Height: 200, Weight:90,
Name: Andy, Height: 175, Weight:70,
]

因為 MutableMap 是 Map 的子型別,將 MutableMap 傳進 ClassBodyInfos 內時,會自動轉型成 Map,但是外部持有的還是 MutableMap,雖然型別不同,但是其 Reference 還是相同,所以還是會指向相同的記憶體位置。

防禦性複製

若要避免這種情況的發生,我們可以使用防禦性複製(defensive-copy),什麼是防禦性複製呢?我們將上面的範例修改一下,如下:

data class ClassBodyInfos3(private val infos: Map<String, BodyInfo>) {  
private val bodyInfos:Map<String, BodyInfo> = infos.toMap()

...
}

當我們從外部傳入一個 Map 時,我們可以在 data class 的內部直接使用 toMap() 將傳進來的 Map 直接複製一份存起來,如此資料類別內部的 Map 就與外部的那份毫無關聯,外部怎麼操作都與內部無關。修改後,重新執行一次:

[
Name: Alex, Height: 180, Weight:80,
Name: Johnson, Height: 200, Weight:90,
]
[
Name: Alex, Height: 180, Weight:80,
Name: Johnson, Height: 200, Weight:90,
]

從結果可以發現,我們得到的結果不會受到外部所影響,而這也就是防禦性複製保護了資料類別。

結論

把資料封裝在資料類別裡面,因為我們期待的是能夠在未來的任何時間點都能取得裡面的資料,也就是說,其內容應該是在初始化之後就確定了,之後該類別只負責提供資料的內容,所以 Kotlin 的 data class 可以只寫建構式即可。
也就是說當資料類別的實例建立之後,每次的取值應該都要是相同的,這個資料類別才能夠更安心的使用,所以資料類別的屬性應該使用 val 來宣告,而非 var
但是如果資料類別需要的是一個列表,就算是宣告成 val 也能夠被修改,原因就是因為外部的呼叫者持有了該列表的實例,也就表示持有了它的 Reference,所以也就能夠更改這個列表。
如果有被外部更改的可能性,我們可以採用防禦性複製的方式,如此一來,外部持有的實例就與資料類別內部的無關,也就不會影響到內部了。

參考

《Good Code, Bad Code — 寫出高品質的程式碼》(碁峰資訊:Tom Long,2022)7–2 考慮讓事物深度不可變

《Effective Java, Third Edition》(Pearson: Joshua Bloch,2018)Item 50 — Make defensive copies when needed

--

--

Andy Lu

Android/Flutter developer, Kotlin Expert, like to learn and share.