1. Overview
There are times when we want to keep our internal state inside mutable collections, but expose immutable variants, to prevent interface misuse. In this article, we’ll be taking a look at different ways in which we can change a mutable collection into an immutable one.
2. Built-in Immutable Collections
One of the approaches is to use Kotlin’s default mutable and immutable collections. As an example, let’s take a look at MutableList and List. First, we’ll create a mutable list and add an element to it:
val mutableList = mutableListOf<String>()
mutableList.add("Hello")
// Prints "Hello"
println(mutableList.joinToString())
We can create an immutable list from our mutable list:
val immutableList: List<String> = mutableList.toList()
But we’ll get an error if we try to modify it:
// Throws an error
immutableList[0] = "World"
We can even make a mutable copy of our immutable list and then modify the copy:
val backToMutableList = immutableList.toMutableList()
backToMutableList[0] = "World"
// Prints "World"
println(backToMutableList.joinToString())
// Prints "Hello"
println(mutableList.joinToString())
The default methods should be good enough for everyday use, but there are some cases when making a full copy is not acceptable — for example, when performance is key. In such a case, we can try casting to an immutable type. The issue here is that can allow a particularly stubborn user to modify underlying data.
Using the previously shown mutableList, instead of using toList, let’s simply cast it:
// Just casting
val immutableList: List<String> = mutableList
Again, we can’t modify it, because the type we cast to doesn’t expose required operations:
// Throws an error
immutableList[0] = "World"
But here’s where the stubborn user comes in. Since we only did a simple cast, the user can cast it back into MutableList:
// Unsafe casting
val backToMutableList = immutableList as MutableList<String>
And then modify the original collection:
backToMutableList[0] = "World"
// Prints "World"
println(backToMutableList.joinToString())
// Prints "World"
println(mutableList.joinToString())
As we can see, the “just casting” approach can lead to serious misuse. But if, for some reason, we don’t want to copy the collection with built-in methods, then what are our options? What can we do when built-in collection methods are not enough?
3. When Built-ins Are Not Enough: the Delegation Approach
One thing we can do is to wrap the required collection into another type. Thanks to Kotlin’s delegation feature, this is easy to implement. First, define the immutable version of a collection we want to use:
class ImmutableList<T>(private val protectedList: List<T>): List<T> by protectedList
Once we have a wrapper like this, we can use it to correct the issue we had in the previous example. Once again, let’s use mutableList:
// Prints "Hello"
println(mutableList.joinToString())
But instead of using toList or casting, let’s wrap it in our ImmutableList:
// Wrap - no copy!
val immutableList = ImmutableList(mutableList)
As before, we don’t have access to additional methods, and we can’t cast the immutableList to MutableList because of our wrapping:
// Error - Immutable List does not have addition methods
immutableList[0] = "World"
// Error - Cannot cast to Mutable list
val backToMutable = immutableList as MutableList<String>
And so, our issue is solved: We can use our wrapper to disable the possibility of interface users casting our collection into MutableList, without copying what Kotlin’s toList does. What’s even better is that we don’t have to implement a wrapper like this by ourselves at all. Someone already did that for us.
3.1. Klutter Implementation
Popular Kotlin utility library Klutter already provides the implementation for immutable collections. It uses the delegation approach described earlier, but in a more feature-complete way.
In order to use this solution, let’s add Klutter as a dependency into our project:
<dependency>
<groupId>uy.kohesive.klutter</groupId>
<artifactId>klutter-core</artifactId>
<version>3.0.0</version>
</dependency>
Once we’ve done that, we can use Klutter to get all kinds of immutable collections. Let’s say we have a map or a set:
val map = mutableMapOf<String, String>()
val set = mutableSetOf<String>()
We can use provided extension functions to, for example, copy and wrap our collection in the delegate:
map.toImmutable()
Or just wrap the collection in the delegate:
set.asReadOnly()
There is more to the way Klutter implements this approach, but that could be a whole other article. For now, we can take a look directly at Klutter’s source code.
4. Conclusion
In this article, we’ve learned multiple ways to achieve immutable collections in Kotlin. As always, the full code is available over on GitHub.