When is a Kotlin object not a Kotlin object? When it’s a Serializable Kotlin object.
This blog post is the story of a strange issue that I encountered recently where a Kotlin object stopped behaving as it was supposed to, how I initially got it working with a bad fix, and how I eventually fixed it properly thanks to a colleague who pointed me in the right direction.
Objects are a useful part of the Kotlin language and there is actually a real gotcha when it comes to using them with the Java Serializer. Let’s begin with a quick recap of what an object is, and the expected behaviour.
A Kotlin object is essentially a Singleton which can extend another class and provide custom behaviours to that class. The specific use-case where I ran in to problems was with a sealed class, but the same issue could be encountered with any Kotlin object. My sealed class looked something like this:
sealed class SealedClass { object Object : SealedClass() object Object2 : SealedClass() data class DataClass(val data: String) : SealedClass() }
A common pattern for using such sealed classes is to use a when statement based on the concrete implementation of the sealed class:
when (sealedClass) { SealedClass.Object -> // Do something SealedClass.Object2 -> // Do something else is SealedClass.DataClass -> // Do something else again }
We need to use the is
qualifier for the DataClass
instance because we want to match the class type rather than a specific instance, whereas for the two objects we can omit this because each is a singleton, and we can check for an identical object. This is an important concept to be aware of for what follows, and if we convert our Kotlin source for SealedClass.Object
to bytecode and then decompile back to Java code we can see the singleton represented as the Instance
member:
public static final class Object extends SealedClass { public static final SealedClass.Object INSTANCE; private Object() { super((DefaultConstructorMarker)null); } static { SealedClass.Object var0 = new SealedClass.Object(); INSTANCE = var0; } }
If we want to access a Kotlin object from Java, we need to call SealedClass.Object.INSTANCE
in order to get the correct behaviour. Note that the constructor is private
to prevent accidental misuse to the constructor instead of calling INSTANCE
.
Hopefully there’s nothing unexpected so far.
The issue arrises if we need to serialise things. Java serialisation is usually achieved pretty simply:
sealed class SealedClass : Serializable { object Object : SealedClass() object Object2 : SealedClass() data class DataClass(val data: String) : SealedClass() }
Implementing the Serializable
interface on SealedClass
is all we need to do in order to serialise this class hierarchy. While this appears to work it actually breaks the singleton implementation of Object
and Object2
and using objects that have been serialised. The reason for this is that the Java serialiser has absolutely no knowledge of a Kotlin object or even that this is actually a Java class that has a specific singleton contract, and when it deserialises one of these classes it invokes the constructor (even though it is private) to create a new instance of this class. This new instance is different to the singleton stored in INSTANCE
and if we now pass this through the when statement that we looked at earlier it will no longer match either of the objects because the statements matching these are checking for identical instances, which is no longer the case.
This can be quite a nasty one because if we run one of these objects through our when
statement everything will work perfectly if the object has not been serialised then deserialised, but after serialisation, they will no longer be correctly matched. It’s also something that can be missed by unit tests because most tests would not be written to serialise then deserialise the objects before testing them – they would assume that the objects would have the same behaviours after serialisation.
At this point, I should mention that this is not a bug in the Java serialiser because it’s just doing its thing and cannot be expected to infer design patterns, such as singleton, that we have used within our code as these are not part of the language itself. It is not a failing with Kotlin either because it is implementing a pretty standard singleton contract. We’ll see later on how any singleton implementation written in Java can fall foul of this problem.
My quick fix for this was to update the when
block to match types for the Kotlin objects instead of the instance checks we had before:
when (sealedClass) { // Don't do this - it's a bad idea - see the article text for the reasons is SealedClass.Object -> // Do something is SealedClass.Object2 -> // Do something else is SealedClass.DataClass -> // Do something else again }
This actually fixes the problem. We now get consistent matching irrespective of whether Object
or Object2
have been serialised, but it feels kind of dirty.
After giving further thought to this I think is a really bad idea. We have actually broken the singleton contract of our Kotlin object. While this may not feel like a big deal, it really affects the maintainability of our code. In the future other developers (or even ourselves) might try and use this class hierarchy without understanding that the object contract is no longer intact, and there are potential bugs just waiting to be written.
I actually opened a PR with this and am deeply indebted to my colleague at Babylon Health Matt Dolan who recognised the issue I was facing and pointed me to an article that he had written in a series of posts where he looked at items in Joshua Bloch’s Effective Java to see how they applied to Kotlin. The post in question was about enforcing a singleton pattern with the Java serialiser and I highly recommend giving it a read along with the other insightful articles that Matt has published.
The more correct solution for this issue is to follow the pattern offered by Joshua in Effective Java and implement readResolve()
to correctly apply the singleton contract when the object is deserialised:
sealed class SealedClass { object Object : SealedClass() { private fun readResolve(): Any = Object } object Object2 : SealedClass() { private fun readResolve(): Any = Object2 } data class DataClass(val data: String) : SealedClass() }
The readResolve()
method is part of the contract between the class itself and the Java serialiser. Just as the serialiser can invoke a private constructor, or will also detect and invoke (if present) the readResolve()
method during deserialisation.
In this case we implement readResolve()
to return the singleton. Looking at the decompiled bytecode we see this:
public static final class Object extends SealedClass { public static final SealedClass.Object INSTANCE; private final java.lang.Object readResolve() { return INSTANCE; } private Object() { super((DefaultConstructorMarker)null); } static { SealedClass.Object var0 = new SealedClass.Object(); INSTANCE = var0; } }
So it is returning the INSTANCE
when readResolve()
is called, and this preserves the singleton behaviour of our Kotlin objects across serialisation.
Since encountering this issue I also spoke with Eugenio Marletti who pointed me to a talk he gave at Droidcon Italy in 2019 where he covered this amongst other things and is well worth watching.
The source code for this article is available here. It takes a slightly different form to my usual projects to accompany blog posts – it is a pure Kotlin project (not an android one) and contains only the sealed class hierarchy that we’ve been looking at. But there is also a unit test suite which verifies the correct operation for objects which both have and haven’t been serialised. To see the problem in action, simply comment out the readResolve()
implementations from either Object
or Object2
and you’ll see some of the tests fail.
© 2019, Mark Allison. All rights reserved.
Copyright © 2019 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.
Hey Mark, thanks for a very insightful post. How did you encounter this issue? Did you get any stacktrace or logs which could at least give you a hint where the problem was?
I found it because I wrote some code which fell foul of the issue. It was only when I saw what I thought was an exhaustive `when` block actually matched nothing because the singleton contract wasn’t being maintained across serialisation. It was only when I stepped over it using a debugger that I realised that nothing was being matched.