Mutable objects or properties?
Mutable objects or properties? 관련
One of the oldest discussions in Kotlin is if we should prefer to represent a mutable state with read-only var or a mutable val property. There is no single answer! Each of them should be preferred in different cases!
The key difference between those two options is how we can modify them. Modifications to var with read-only collection require making a copy of the entire collection. For large collections, that is a heavy and memory-consuming operation.
class UserRepository {
private var storedUsers: Map<Int, User> = mapOf()
fun getUser(id: Int): User? = storedUsers[id] // Cheap!
fun addUser(id: Int, name: String) {
storedUsers = storedUsers + (id to User(name))
// This makes a copy of the whole collection!
// What is expensive for large collections!
}
}
Mutable collections support a range of methods that allow their modification, that are as fast as possible. That is why many people prefer mutable collections by default, arguing it is for efficiency.
class UserRepository {
private val storedUsers: MutableMap<Int, User> = mutableMapOf()
fun getUser(id: Int): User? = storedUsers[id] // Cheap!
fun addUser(id: Int, name: String) {
storedUsers[id] = User(name) // Cheap!
}
}
However, var with read-only collections also have their advantages, and can sometimes be more efficient! First, their changes are observable, so we can use read-only collections with StateFlow, observable delegate, or observe them with setter.
val users = MutableStateFlow(mapOf<Int, User>())
var users by mutableStateOf(mapOf<Int, User>())
var users by Delegates.observable(mapOf<Int, User>()) { _, _, _ ->
// Do something when users change
}
We cannot so easily observe changes in mutable collections. Another problem is that when we store state in a mutable collection, it is easy to expose access to this collection! Consider the below code. Even though getAllUsers
returns a read-only type, it is still returning a reference to the mutable collection. That is why whenever this collection changes, the result of getAllUsers
changes as well. That is why when we print users
for the second time, it has changed, even though users
is a val with Map
type. That is a common source of bugs.
class UserRepository { private val storedUsers: MutableMap<Int, User> = mutableMapOf() fun getUser(id: Int): User? = storedUsers[id] fun getAllUsers(): Map<Int, User> = storedUsers fun addUser(id: Int, name: String) { storedUsers[id] = User(name) } } fun main() { val repo = UserRepository() val users = repo.getAllUsers() println(users) // {} repo.addUser(1, "ABC") println(users) // {1=ABC} } data class User(val name: String)
To prevent that, we must use a technique known as "defensive copy", so copy the collection before exposing it.
class UserRepository { private val storedUsers: MutableMap<Int, User> = mutableMapOf() fun getUser(id: Int): User? = storedUsers[id] fun getAllUsers(): Map<Int, User> = storedUsers.toMap() fun addUser(id: Int, name: String) { storedUsers[id] = User(name) } } fun main() { val repo = UserRepository() val users = repo.getAllUsers() println(users) // {} repo.addUser(1, "ABC") println(users) // {} } data class User(val name: String)
Making such a copy is expensive. We do not need to make a copy for read-only collection, so if we expect exposing the whole collection is more frequent than its modification, var with read-only collection should be preferable. However, it is rather a rare situation.
class UserRepository { private var storedUsers: Map<Int, User> = mapOf() fun getUser(id: Int): User? = storedUsers[id] fun getAllUsers() = storedUsers fun addUser(id: Int, name: String) { storedUsers = storedUsers + (id to User(name)) } } fun main() { val repo = UserRepository() val users = repo.getAllUsers() println(users) // {} repo.addUser(1, "ABC") println(users) // {} } data class User(val name: String)
Now consider thread safety. Default mutable collections require synchronizing both their reads, copies, and modifications, because those are all non-atomic operations.
class UserRepository {
private val storedUsers: MutableMap<Int, User> = mutableMapOf()
private val lock = Any()
fun getUser(id: Int): User? = synchronized(lock) {
storedUsers[id]
}
fun getAllUsers() = synchronized(lock) {
storedUsers.toMap()
}
fun addUser(id: Int, name: String) = synchronized(lock) {
storedUsers[id] = User(name)
}
}
If we don't do that, with every modification, we can end up with a corrupted collection. It might mean losing some data, or even worse, getting a ConcurrentModificationException
.
import kotlin.concurrent.thread class UserRepository { private val storedUsers: MutableMap<Int, User> = mutableMapOf() private val lock = Any() fun getUser(id: Int): User? = synchronized(lock) { storedUsers[id] } fun getAllUsers() = storedUsers.toMap() // Mistake! No synchronization! fun addUser(id: Int, name: String) = synchronized(lock) { storedUsers[id] = User(name) } } fun main() { val repo = UserRepository() repeat(1000) { thread { repo.addUser(it, "ABC") } thread { println(repo.getAllUsers()) // ERROR: ConcurrentModificationException } } } data class User(val name: String)
Of course, there are mutable collections like ConcurrentHashMap
that are thread-safe, but they are not always the best choice. They are slower than regular mutable collections.
class UserRepository {
private val storedUsers: MutableMap<Int, User> = ConcurrentHashMap()
fun getUser(id: Int): User? = storedUsers[id]
fun getAllUsers() = storedUsers.toMap()
fun addUser(id: Int, name: String) {
storedUsers[id] = User(name)
}
}
Property read and write is atomic, so we do not need to synchronize many operations on var with read-only collections. We typically need to synchronize only updates.
class UserRepository {
private var storedUsers: Map<Int, User> = mapOf()
private val lock = Any()
fun getUser(id: Int): User? = storedUsers[id]
fun getAllUsers() = storedUsers
fun addUser(id: Int, name: String) = synchronized(lock) {
storedUsers = storedUsers + (id to User(name)) // Copy!
}
}
To summarize, var with read-only allows observability, and it is easier to synchronize it. On the other hand, mutable collections offer us better update performance and offer thread-safe alternatives, but its exposure requires defensive copying.
Mutable collection | Read-only collection |
---|---|
Reading is cheap | Reading is cheap |
Modifications are cheap | Modifications require making a copy |
Cannot be observed | Can be obserced |
Modifications require synchroniztaion | Modifications require synchroniztaion |
Exposing require making a defensive copy | Exposing is cheap |
Exposing must be synchronized | Exposing require no synchronization |