This article was originally published at my personal blog on March 9, 2023.
Have you ever felt like this kid when programming? I sure did! When I got into Kotlin development I felt like this kid many many times. And even now I have some experience, every once in a while, when conducting a Kotlin training or when on a Kotlin assignment, I encounter situations where I simply cannot explain why the code doesn’t work the way I intended. Or maybe even worse: I can’t explain why the code DOES work. In this post I’ll show you 7 code snippets containing common mistakes (I made) when programming in Kotlin.
Hello World!
Let’s start off with a rather simple “hello world” example. Consider the code below. What do you expect the outcome of this program to be?
fun main(args: Array<String>) {
fun hello() = print("Hello")
fun world() = {
print("World")
}
hello()
world()
}
It might surprise you, but the output of this program will just be Hello
.
This one is especially confusing and a common mistake for Scala developers moving to Kotlin. I have been doing Scala for years and with Scala both functions would be equivalent. In Kotlin, however, using the combination of =
and {}
means your function is returning a lambda and NOT executing it.
To make this example program print Hello World
you would have to either remove the =
or the {}
in the function world
, e.g. like this:
fun main(args: Array<String>) {
fun hello() = print("Hello")
fun world() {
print("World")
}
hello()
world()
}
Good luck, Scala developers!
Fallback
The next one is about a nifty little (extension) function, withDefault
, that is available on Kotlin Map
instances. This function allows you to define a default value to be returned when a missing key is retrieved. In the example below I create an empty Map
and try to make sure it returns the phrase "default"
for missing keys. Will this succeed?
fun main(args: Array<String>) {
val map = mapOf<String, Int>().withDefault{ "default" }
println(map["1"])
}
Unfortunately, the answer is no. This program will print null
, which is probably not what you would expect here. The default value is ONLY considered when using the getValue
function to access the map. If you use the indexing operator, the square brackets, under the hood the get
function is used and this always returns null
when the key is missing.
Let’s rewrite this using getValue
.
fun main(args: Array<String>) {
val map = mapOf<String, Int>().withDefault{ "default" }
println(map.getValue("1"))
}
The program above will in fact print default
. So, the solution is to always retrieve values from a Map
with getValue
when using withDefault
? Not quite. Consider the following snippet:
fun main(args: Array<String>) {
val map = mapOf<String, Int>().withDefault{ "default" }
println(map.filterKeys { it != "2" }.getValue("1"))
}
This code will actually throw a java.util.NoSuchElementException
!
Exception in thread "main" java.util.NoSuchElementException: Key 1 is missing in the map.
at kotlin.collections.MapsKt__MapWithDefaultKt.getOrImplicitDefaultNullable(MapWithDefault.kt:24)
at kotlin.collections.MapsKt__MapsKt.getValue(Maps.kt:348)
at DefaultKt.main(Default.kt:4)
Why is that? Using any transforming operations on Kotlin read-only collections (more on that in the next section) will in fact return a new instance of that collection type. When we use filterKeys
on our Map
instance, we get a new instance of Map
. An instance without the default defined. Hence, getValue
still won’t have a default value to fallback to and throws an exception.
To summarise, my advice is to avoid using withDefault
all together. It’s not reliable. Instead, always specify the fallback value explicitly when retrieving from a Map
by using the getOrDefault
function. For example:
fun main(args: Array<String>) {
val map = mapOf<String, Int>()
println(map.filterKeys { it != "2" }.getOrDefault("1", "default"))
}
This program will actually output default
as we intended (because the key "2"
is missing). Don’t use withDefault
, use getOrDefault
when you need to rely on a fallback value.
Immutability
As already mentioned above, Kotlin has read-only collections. If you use any of the <collection type>Of()
functions (e.g. listOf()
or mapOf()
) you’ll get a read-only instance of that collection type. Using any of the mutable<collection type>Of()
functions will create a mutable collection of that type.
It is a common mistake in Kotlin to confuse read-only collections with immutable collections. Read-only collections are similar to immutable collections in that they both provide collections that cannot be modified once they are created. However, there are some differences between the two concepts. Read-only collections are collections that can still be modified by their underlying data source, but cannot be modified directly through the reference to the collection. For example, if a read-only list is backed by an ArrayList
, the contents of the ArrayList
can still be modified, but the reference to the read-only list cannot be used to add, remove, or modify elements.
Immutable collections (e.g. in Scala) are truly immutable and cannot be modified by any means. Once an immutable collection is created, its contents cannot be changed, and any attempt to do so will result in a new immutable collection being created. This means that an immutable collection can provide stronger guarantees of safety and immutability than a read-only collection in Kotlin.
I’ll demonstrate this using the code below
import java.util.*
fun main(args: Array<String>) {
val readonly = listOf(1, 2, 3)
if(readonly is MutableList) {
readonly.add(4)
}
println(readonly)
}
You might expect this code would not even compile, since the add
operation is not available on read-only collections and we just learned that listOf
would create a read-only list. The List
interface does not define an add
function. Sadly, this code actually compiles and running it will throw an UnsupportedOperationException
.
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.AbstractList.add(AbstractList.java:153)
at java.base/java.util.AbstractList.add(AbstractList.java:111)
at ImmutabilityKt.main(Immutability.kt:6)
Why does this code even compile? Kotlin’s smart casting mechanism is in play here. The compiler tracks the is
-checks and inserts (safe) casts automatically. As a result it changes the type of the readonly
variable to MutableList
. Unlike Kotlin’s List
interface, theMutableList
interface has the add
operation available.
But why would the is MutableList
check even pass? This is where the true difference between read-only collections and immutable collections becomes apparent. While the listOf
function returns a read-only list of type List
, the concrete type is the plain oldArrayList
(java.util.Arrays$ArrayList
). This collection is mutable by design. So, our read-only list is actually backed by a mutable implementation! Read-only collections only protect you on the interface level. Under the hood standard mutable Java collections are used, so you can still mess things up if you are not careful.
Knowing this, can we break things even more? Sure we can! Consider the following code
fun main(args: Array<String>) {
val readonly = listOf(1, 2, 3)
Collections.sort(readonly, reverseOrder())
println(readonly)
}
It might surprise you (but shouldn’t anymore after the discovery we made above) that this program actually outputs [3, 2, 1]
. We managed to modify our read-only list (!) by passing it to a sort function.
I shot myself in the foot by using mutable data structures way too many times. Be extra careful when using read-only collections in Kotlin. They only protect you to a certain extent.
Equality
The next example involves Kotlin data classes, marked with the data
keyword. These are classes whose main purpose is to hold data and of which standard functionality can be derived from the data by the compiler. Among those automatically derived functions are the equals()
and hashCode()
functions. Those functions are automatically generated by the compiler based on the properties of the class. They are used to determine equality and collision, for instance when adding elements to a set.
Let’s have a look at the code below. We are creating two instances of data class A
, a
and b
, with the same value for the num
property. We change the value of the name
property of b
to "b"
. Then we add a
and b
to a Set
in order to keep only unique instances of data class A
.
fun main(args: Array<String>) {
data class A(val num: Int) {
var name: String = "a"
}
val a = A(1)
val b = A(1)
b.name = "b"
val unique = setOf(a, b)
println(unique)
}
You might think that a
and b
shouldn’t be considered equal, since they have a different value for the name
property, and thus should be both added to the unique
Set
. However, the compiler only takes properties into account that are part of the constructor of a data class. So in this case it only takes num into account.
I never encountered this one myself, but it is implicit and obscure enough to be considered as a pitfall in Kotlin, if not a common mistake.
Type Safety
The next pitfall I discovered during a basic Kotlin training that I was conducting. My goal was to demonstrate strong typing and generics to my students. In a REPL, I showed them the following example code.
fun main(args: Array<String>) {
class Container<T>(val value: T)
val noValue: Any? = null
val container = Container(noValue)
println(container.value is Int?)
}
It was quite a shock to find out this program actually outputs true
. But why? If I put in Any?
, this becomes a Container<Any?>
. The property value
will also be of type Any?
. So, the type check container.value is Int?
should return false
.
The reason of this behaviour becomes clear after decompiling the byte code as shown in the video below.
As you can see in the video, the resulting Java code from the decompiled byte code provides the answer to this mystery. Apparently the type check always returns true if the value is null. D’oh!
Zip it!
In the program below we are trying to zip two lists, a list of keys and a list of values, and create a map of the resulting list of tuples. The zip
function returns a list of tuples with the type List<Pair<Int, String>>
. The mapOf
function accepts tuples as a vararg argument: vararg pairs: Pair<K, V>
. We use the spread operator *
on zipped
to be able to pass it as a vararg argument.
fun main(args: Array<String>) {
val keys = listOf(1, 2, 3)
val values = listOf("a", "b", "c")
val zipped = keys.zip(values)
// * is the spread operator, that unpacks the array into a list of values
println(mapOf(*zipped))
}
Given the type of zipped
and the signature of mapOf
we might expect this program to print {1=a, 2=b, 3=c}
. Unfortunately, it results in a rather cryptic compilation error.
None of the following functions can be called with the arguments supplied.
mapOf(vararg Pair<TypeVariable(K), TypeVariable(V)>)
where K = TypeVariable(K), V = TypeVariable(V) for
fun <K, V> mapOf(vararg pairs: Pair<K, V>): Map<K, V> defined in kotlin.collections
mapOf(Pair<TypeVariable(K), TypeVariable(V)>)
where K = TypeVariable(K), V = TypeVariable(V) for
fun <K, V> mapOf(pair: Pair<K, V>): Map<K, V> defined in kotlin.collections
This can be explained by type erasure. Due to type erasure some of the type information is only available at compile time and not at runtime. It’s a common mistake in Java or Kotlin to forget about type erasure and this is something to get easily bitten by when dealing with parameterised types.
In the example code above the type of zipped is List<Pair<Int, String>>
. However the type arguments Int
and String
are only known at compile time. At runtime all we know is that this is a Pair
. Using the spread operator *
we pass Pair
values as vararg arguments. We no longer have the type information available of the inner types, hence the compilation error.
We can fix this program by applying a little tweak. To preserve type information when passing arguments to a varargs function, you’ll need to use the toTypedArray
function.
fun main(args: Array<String>) {
val keys = listOf(1, 2, 3)
val values = listOf("a", "b", "c")
val zipped: List<Pair<Int, String>> = keys.zip(values)
// * is the spread operator, that unpacks the array into a list of values
println(mapOf(*zipped.toTypedArray()))
}
Our modified program will output{1=a, 2=b, 3=c}
as intended.
Extensions
The last Kotlin gotcha involves extension functions and type hierarchy. Consider the following program. We have two classes, A
and B
, where B
inherits from A
. We define an extension function extensionFun
for each of them that outputs "A"
respectively "B"
. Then we define a third function, extensionFun
, that takes one argument, myVar
, of type A
. This function calls the corresponding extension function of myVar
and prints the result to the console. What is the output?
fun main(args: Array<String>) {
open class A
class B : A()
fun A.extensionFun() = "A"
fun B.extensionFun() = "B"
fun extensionFun(myVar: A) {
println(myVar.extensionFun())
}
extensionFun(B())
}
The output of this program is "A"
. At compile time it is determined which extension function should be called. It doesn’t matter what the runtime type is. In this case the compiler sees that myVar
is of type A
, so version A of extensionFun
is called.
Extension functions are a really powerful tool. But you should be really careful when “overloading” extension functions in a type hierarchy.
Conclusion
That’s all for now! I’ve shown 7 common mistakes in Kotlin of which most of them I ran into myself. To summarise the “features” to be aware of:
- Using
=
and{}
in your function definition means your function is returning a lambda and NOT executing it. I’m looking at you, Scala developers 😉 - Avoid using
withDefault
. Instead, always specify the fallback value explicitly when retrieving from aMap
by using thegetOrDefault
function. - Read-only != immutable. Be aware of this when working with read-only collections and especially be careful when passing read-only collections to Java API’s. Here’s another great post highlighting the differences between read-only and immutable, in this case with regards to the
val
keyword. - Auto generated equality checks only take properties into account that are part of the constructor of a data class
- Type comparisons on nullable types can be tricky! For null values the types are considered equal.
- Type erasure can get in your way when using the spread operator (
*
). UsetoTypedArray
to preserve type information. - Runtime polymorphism doesn’t mix with extension functions. Which extension function to call is determined at compile time.