Skip to main content

Mutable objects or properties?

About 4 minKotlinArticle(s)blogkt.academykotlinkt

Mutable objects or properties? 관련

Java > Article(s)

Article(s)

Mutable objects or properties?
Time to answer the question: var/readonly vs val/mutable. Which one should you use?

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.

1
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.

2
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.

3
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.

4
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 collectionRead-only collection
Reading is cheapReading is cheap
Modifications are cheapModifications require making a copy
Cannot be observedCan be obserced
Modifications require synchroniztaionModifications require synchroniztaion
Exposing require making a defensive copyExposing is cheap
Exposing must be synchronizedExposing require no synchronization

이찬희 (MarkiiimarK)
Never Stop Learning.