Skip to content

Latest commit

 

History

History
1040 lines (772 loc) · 34.6 KB

polymorphism.md

File metadata and controls

1040 lines (772 loc) · 34.6 KB

Polymorphism

This is the fourth chapter of the Kotlin Serialization Guide. In this chapter we'll see how Kotlin Serialization deals with polymorphic class hierarchies.

Table of contents

Closed polymorphism

Let us start with basic introduction to polymorphism.

Static types

Kotlin Serialization is fully static with respect to types by default. The structure of encoded objects is determined by compile-time types of objects. Let's examine this aspect in more detail and learn how to serialize polymorphic data structures, where the type of data is determined at runtime.

To show the static nature of Kotlin Serialization let us make the following setup. An open class Project has just the name property, while its derived class OwnedProject adds an owner property. In the below example, we serialize data variable with a static type of Project that is initialized with an instance of OwnedProject at runtime.

@Serializable
open class Project(val name: String)

class OwnedProject(name: String, val owner: String) : Project(name)

fun main() {
    val data: Project = OwnedProject("kotlinx.coroutines", "kotlin")
    println(Json.encodeToString(data))
}  

You can get the full code here.

Despite the runtime type of OwnedProject, only the Project class properties are getting serialized.

{"name":"kotlinx.coroutines"}

Let's change the compile-time type of data to OwnedProject.

@Serializable
open class Project(val name: String)

class OwnedProject(name: String, val owner: String) : Project(name)

fun main() {
    val data = OwnedProject("kotlinx.coroutines", "kotlin")
    println(Json.encodeToString(data))
}  

You can get the full code here.

We get an error, because the OwnedProject class is not serializable.

Exception in thread "main" kotlinx.serialization.SerializationException: Serializer for class 'OwnedProject' is not found.
Please ensure that class is marked as '@Serializable' and that the serialization compiler plugin is applied.

Designing serializable hierarchy

We cannot simply mark OwnedProject from the previous example as @Serializable. It does not compile, running into the constructor properties requirement. To make hierarchy of classes serializable, the properties in the parent class have to be marked abstract, making the Project class abstract, too.

@Serializable
abstract class Project {
    abstract val name: String
}

class OwnedProject(override val name: String, val owner: String) : Project()

fun main() {
    val data: Project = OwnedProject("kotlinx.coroutines", "kotlin")
    println(Json.encodeToString(data))
}  

You can get the full code here.

This is close to the best design for a serializable hierarchy of classes, but running it produces the following error:

Exception in thread "main" kotlinx.serialization.SerializationException: Serializer for subclass 'OwnedProject' is not found in the polymorphic scope of 'Project'.
Check if class with serial name 'OwnedProject' exists and serializer is registered in a corresponding SerializersModule.
To be registered automatically, class 'OwnedProject' has to be '@Serializable', and the base class 'Project' has to be sealed and '@Serializable'.

Sealed classes

The most straightforward way to use serialization with a polymorphic hierarchy is to mark the base class sealed. All subclasses of a sealed class must be explicitly marked as @Serializable.

@Serializable
sealed class Project {
    abstract val name: String
}
            
@Serializable
class OwnedProject(override val name: String, val owner: String) : Project()

fun main() {
    val data: Project = OwnedProject("kotlinx.coroutines", "kotlin")
    println(Json.encodeToString(data)) // Serializing data of compile-time type Project
}  

You can get the full code here.

Now we can see a default way to represent polymorphism in JSON. A type key is added to the resulting JSON object as a discriminator.

{"type":"example.examplePoly04.OwnedProject","name":"kotlinx.coroutines","owner":"kotlin"}

Pay attention to the small, but very important detail in the above example that is related to Static types: the val data property has a compile-time type of Project, even though its run-time type is OwnedProject. When serializing polymorphic class hierarchies you must ensure that the compile-time type of the serialized object is a polymorphic one, not a concrete one.

Let us see what happens if the example is slightly changed, so that the compile-time of the object that is being serialized is OwnedProject (the same as its run-time type).

@Serializable
sealed class Project {
    abstract val name: String
}
            
@Serializable
class OwnedProject(override val name: String, val owner: String) : Project()

fun main() {
    val data = OwnedProject("kotlinx.coroutines", "kotlin") // data: OwnedProject here
    println(Json.encodeToString(data)) // Serializing data of compile-time type OwnedProject
}  

You can get the full code here.

The type of OwnedProject is concrete and is not polymorphic, thus the type discriminator property is not emitted into the resulting JSON.

{"name":"kotlinx.coroutines","owner":"kotlin"}

In general, Kotlin Serialization is designed to work correctly only when the compile-time type used during serialization is the same one as the compile-time type used during deserialization. You can always specify the type explicitly when calling serialization functions. The previous example can be corrected to use Project type for serialization by calling Json.encodeToString<Project>(data).

Custom subclass serial name

A value of the type key is a fully qualified class name by default. We can put SerialName annotation onto the corresponding class to change it.

@Serializable
sealed class Project {
    abstract val name: String
}
            
@Serializable         
@SerialName("owned")
class OwnedProject(override val name: String, val owner: String) : Project()

fun main() {
    val data: Project = OwnedProject("kotlinx.coroutines", "kotlin")
    println(Json.encodeToString(data))
}  

You can get the full code here.

This way we can have a stable serial name that is not affected by the class's name in the source code.

{"type":"owned","name":"kotlinx.coroutines","owner":"kotlin"}

In addition to that, JSON can be configured to use a different key name for the class discriminator. You can find an example in the Class discriminator for polymorphism section.

Concrete properties in a base class

A base class in a sealed hierarchy can have properties with backing fields.

@Serializable
sealed class Project {
    abstract val name: String   
    var status = "open"
}
            
@Serializable   
@SerialName("owned")
class OwnedProject(override val name: String, val owner: String) : Project()

fun main() {
    val json = Json { encodeDefaults = true } // "status" will be skipped otherwise
    val data: Project = OwnedProject("kotlinx.coroutines", "kotlin")
    println(json.encodeToString(data))
}  

You can get the full code here.

The properties of the superclass are serialized before the properties of the subclass.

{"type":"owned","status":"open","name":"kotlinx.coroutines","owner":"kotlin"}

Objects

Sealed hierarchies can have objects as their subclasses and they also need to be marked as @Serializable. Let's take a different example with a hierarchy of Response classes.

@Serializable
sealed class Response
                      
@Serializable
object EmptyResponse : Response()

@Serializable   
class TextResponse(val text: String) : Response()   

Let us serialize a list of different responses.

fun main() {
    val list = listOf(EmptyResponse, TextResponse("OK"))
    println(Json.encodeToString(list))
}  

You can get the full code here.

An object serializes as an empty class, also using its fully qualified class name as type by default:

[{"type":"example.examplePoly08.EmptyResponse"},{"type":"example.examplePoly08.TextResponse","text":"OK"}]

Even if object has properties, they are not serialized.

Open polymorphism

Serialization can work with arbitrary open classes or abstract classes. However, since this kind of polymorphism is open, there is a possibility that subclasses are defined anywhere in the source code, even in other modules, the list of subclasses that are serialized cannot be determined at compile-time and must be explicitly registered at runtime.

Registered subclasses

Let us start with the code from the Designing serializable hierarchy section. To make it work with serialization without making it sealed, we have to define a SerializersModule using the SerializersModule {} builder function. In the module the base class is specified in the polymorphic builder and each subclass is registered with the subclass function. Now, a custom JSON configuration can be instantiated with this module and used for serialization.

Details on custom JSON configurations can be found in the JSON configuration section.

val module = SerializersModule {
    polymorphic(Project::class) {
        subclass(OwnedProject::class)
    }
}

val format = Json { serializersModule = module }

@Serializable
abstract class Project {
    abstract val name: String
}
            
@Serializable
@SerialName("owned")
class OwnedProject(override val name: String, val owner: String) : Project()

fun main() {
    val data: Project = OwnedProject("kotlinx.coroutines", "kotlin")
    println(format.encodeToString(data))
}    

You can get the full code here.

This additional configuration makes our code work just as it worked with a sealed class in the Sealed classes section, but here subclasses can be spread arbitrarily throughout the code.

{"type":"owned","name":"kotlinx.coroutines","owner":"kotlin"}

Please note that this example works only on JVM because of serializer function restrictions. For JS and Native, explicit serializer should be used: format.encodeToString(PolymorphicSerializer(Project::class), data) You can keep track of this issue here.

Serializing interfaces

We can update the previous example and turn Project superclass into an interface. However, we cannot mark an interface itself as @Serializable. No problem. Interfaces cannot have instances by themselves. Interfaces can only be represented by instances of their derived classes. Interfaces are used in the Kotlin language to enable polymorphism, so all interfaces are considered to be implicitly serializable with the PolymorphicSerializer strategy. We just need to mark their implementing classes as @Serializable and register them.

interface Project {
    val name: String
}

@Serializable
@SerialName("owned")
class OwnedProject(override val name: String, val owner: String) : Project

Now if we declare data with the type of Project we can simply call format.encodeToString as before.

fun main() {
    val data: Project = OwnedProject("kotlinx.coroutines", "kotlin")
    println(format.encodeToString(data))
}    

You can get the full code here.

{"type":"owned","name":"kotlinx.coroutines","owner":"kotlin"}

Note: On Kotlin/Native, you should use format.encodeToString(PolymorphicSerializer(Project::class), data)) instead due to limited reflection capabilities.

Property of an interface type

Continuing the previous example, let us see what happens if we use Project interface as a property in some other serializable class. Interfaces are implicitly polymorphic, so we can just declare a property of an interface type.

@Serializable
class Data(val project: Project) // Project is an interface

fun main() {
    val data = Data(OwnedProject("kotlinx.coroutines", "kotlin"))
    println(format.encodeToString(data))
}        

You can get the full code here.

As long as we've registered the actual subtype of the interface that is being serialized in the SerializersModule of our format, we get it working at runtime.

{"project":{"type":"owned","name":"kotlinx.coroutines","owner":"kotlin"}}

Static parent type lookup for polymorphism

During serialization of a polymorphic class the root type of the polymorphic hierarchy (Project in our example) is determined statically. Let us take the example with the serializable abstract class Project, but change the main function to declare data as having a type of Any:

fun main() {
    val data: Any = OwnedProject("kotlinx.coroutines", "kotlin")
    println(format.encodeToString(data))
}    

You can get the full code here.

We get the exception.

Exception in thread "main" kotlinx.serialization.SerializationException: Serializer for class 'Any' is not found.
Please ensure that class is marked as '@Serializable' and that the serialization compiler plugin is applied.

We have to register classes for polymorphic serialization with respect for the corresponding static type we use in the source code. First of all, we change our module to register a subclass of Any:

val module = SerializersModule {
    polymorphic(Any::class) {
        subclass(OwnedProject::class)
    }
}

Then we can try to serialize the variable of type Any:

fun main() {
    val data: Any = OwnedProject("kotlinx.coroutines", "kotlin")
    println(format.encodeToString(data))
}    

You can get the full code here.

However, Any is a class and it is not serializable:

Exception in thread "main" kotlinx.serialization.SerializationException: Serializer for class 'Any' is not found.
Please ensure that class is marked as '@Serializable' and that the serialization compiler plugin is applied.

We must to explicitly pass an instance of PolymorphicSerializer for the base class Any as the first parameter to the encodeToString function.

fun main() {
    val data: Any = OwnedProject("kotlinx.coroutines", "kotlin")
    println(format.encodeToString(PolymorphicSerializer(Any::class), data))
}    

You can get the full code here.

With the explicit serializer it works as before.

{"type":"owned","name":"kotlinx.coroutines","owner":"kotlin"}

Explicitly marking polymorphic class properties

The property of an interface type is implicitly considered polymorphic, since interfaces are all about runtime polymorphism. However, Kotlin Serialization does not compile a serializable class with a property of a non-serializable class type. If we have a property of Any class or other non-serializable class, then we must explicitly provide its serialization strategy via the @Serializable annotation as we saw in the Specifying serializer on a property section. To specify a polymorphic serialization strategy of a property, the special-purpose @Polymorphic annotation is used.

@Serializable
class Data(
    @Polymorphic // the code does not compile without it 
    val project: Any 
)

fun main() {
    val data = Data(OwnedProject("kotlinx.coroutines", "kotlin"))
    println(format.encodeToString(data))
}

You can get the full code here.

Registering multiple superclasses

When the same class gets serialized as a value of properties with different compile-time type from the list of its superclasses, we must register it in the SerializersModule for each of its superclasses separately. It is convenient to extract registration of all the subclasses into a separate function and use it for each superclass. You can use the following template to write it.

val module = SerializersModule {
    fun PolymorphicModuleBuilder<Project>.registerProjectSubclasses() {
        subclass(OwnedProject::class)
    }
    polymorphic(Any::class) { registerProjectSubclasses() }
    polymorphic(Project::class) { registerProjectSubclasses() }
}        

You can get the full code here.

Polymorphism and generic classes

Generic subtypes for a serializable class require a special handling. Consider the following hierarchy.

@Serializable
abstract class Response<out T>
            
@Serializable
@SerialName("OkResponse")
data class OkResponse<out T>(val data: T) : Response<T>()

Kotlin Serialization does not have a builtin strategy to represent the actually provided argument type for the type parameter T when serializing a property of the polymorphic type OkResponse<T>. We have to provide this strategy explicitly when defining the serializers module for Response. In the below example we use OkResponse.serializer(...) to retrieve the Plugin-generated generic serializer of the OkResponse class and instantiate it with the PolymorphicSerializer instance with Any class as its base. This way, we can serialize an instance of OkResponse with any data property that was polymorphically registered as a subtype of Any.

val responseModule = SerializersModule {
    polymorphic(Response::class) {
        subclass(OkResponse.serializer(PolymorphicSerializer(Any::class)))
    }
}

Merging library serializers modules

When the application grows in size and splits into source code modules, it may become inconvenient to store all class hierarchies in one serializers module. Let us add a library with the Project hierarchy to the code from the previous section.

val projectModule = SerializersModule {
    fun PolymorphicModuleBuilder<Project>.registerProjectSubclasses() {
        subclass(OwnedProject::class)
    }
    polymorphic(Any::class) { registerProjectSubclasses() }
    polymorphic(Project::class) { registerProjectSubclasses() }
}

We can compose those two modules together using the plus operator to merge them, so that we can use them both in the same Json format instance.

You can also use the include function in the SerializersModule {} DSL.

val format = Json { serializersModule = projectModule + responseModule }

Now classes from both hierarchies can be serialized together and deserialized together.

fun main() {
    // both Response and Project are abstract and their concrete subtypes are being serialized
    val data: Response<Project> =  OkResponse(OwnedProject("kotlinx.serialization", "kotlin"))
    val string = format.encodeToString(data)
    println(string)
    println(format.decodeFromString<Response<Project>>(string))
}

You can get the full code here.

The JSON that is being produced is deeply polymorphic.

{"type":"OkResponse","data":{"type":"OwnedProject","name":"kotlinx.serialization","owner":"kotlin"}}
OkResponse(data=OwnedProject(name=kotlinx.serialization, owner=kotlin))

If you're writing a library or shared module with an abstract class and some implementations of it, you can expose your own serializers module for your clients to use so that a client can combine your module with their modules.

Default polymorphic type handler for deserialization

What happens when we deserialize a subclass that was not registered?

fun main() {
    println(format.decodeFromString<Project>("""
        {"type":"unknown","name":"example"}
    """))
}

You can get the full code here.

We get the following exception.

Exception in thread "main" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 0: Serializer for subclass 'unknown' is not found in the polymorphic scope of 'Project' at path: $
Check if class with serial name 'unknown' exists and serializer is registered in a corresponding SerializersModule.

When reading a flexible input we might want to provide some default behavior in this case. For example, we can have a BasicProject subtype to represent all kinds of unknown Project subtypes.

@Serializable
abstract class Project {
    abstract val name: String
}

@Serializable
data class BasicProject(override val name: String, val type: String): Project()

@Serializable
@SerialName("OwnedProject")
data class OwnedProject(override val name: String, val owner: String) : Project()

We register a default deserializer handler using the defaultDeserializer function in the polymorphic { ... } DSL that defines a strategy which maps the type string from the input to the deserialization strategy. In the below example we don't use the type, but always return the Plugin-generated serializer of the BasicProject class.

val module = SerializersModule {
    polymorphic(Project::class) {
        subclass(OwnedProject::class)
        defaultDeserializer { BasicProject.serializer() }
    }
}

Using this module we can now deserialize both instances of the registered OwnedProject and any unregistered one.

val format = Json { serializersModule = module }

fun main() {
    println(format.decodeFromString<List<Project>>("""
        [
            {"type":"unknown","name":"example"},
            {"type":"OwnedProject","name":"kotlinx.serialization","owner":"kotlin"} 
        ]
    """))
}

You can get the full code here.

Notice, how BasicProject had also captured the specified type key in its type property.

[BasicProject(name=example, type=unknown), OwnedProject(name=kotlinx.serialization, owner=kotlin)]

We used a plugin-generated serializer as a default serializer, implying that the structure of the "unknown" data is known in advance. In a real-world API it's rarely the case. For that purpose a custom, less-structured serializer is needed. You will see the example of such serializer in the future section on Maintaining custom JSON attributes.

Default polymorphic type handler for serialization

Sometimes you need to dynamically choose which serializer to use for a polymorphic type based on the instance, for example if you don't have access to the full type hierarchy, or if it changes a lot. For this situation, you can register a default serializer.

interface Animal {
}

interface Cat : Animal {
    val catType: String
}

interface Dog : Animal {
    val dogType: String
}

private class CatImpl : Cat {
    override val catType: String = "Tabby"
}

private class DogImpl : Dog {
    override val dogType: String = "Husky"
}

object AnimalProvider {
    fun createCat(): Cat = CatImpl()
    fun createDog(): Dog = DogImpl()
}

We register a default serializer handler using the polymorphicDefaultSerializer function in the SerializersModule { ... } DSL that defines a strategy which takes an instance of the base class and provides a serialization strategy. In the below example we use a when block to check the type of the instance, without ever having to refer to the private implementation classes.

val module = SerializersModule {
    polymorphicDefaultSerializer(Animal::class) { instance ->
        @Suppress("UNCHECKED_CAST")
        when (instance) {
            is Cat -> CatSerializer as SerializationStrategy<Animal>
            is Dog -> DogSerializer as SerializationStrategy<Animal>
            else -> null
        }
    }
}

object CatSerializer : SerializationStrategy<Cat> {
    override val descriptor = buildClassSerialDescriptor("Cat") {
        element<String>("catType")
    }
  
    override fun serialize(encoder: Encoder, value: Cat) {
        encoder.encodeStructure(descriptor) {
          encodeStringElement(descriptor, 0, value.catType)
        }
    }
}

object DogSerializer : SerializationStrategy<Dog> {
  override val descriptor = buildClassSerialDescriptor("Dog") {
    element<String>("dogType")
  }

  override fun serialize(encoder: Encoder, value: Dog) {
    encoder.encodeStructure(descriptor) {
      encodeStringElement(descriptor, 0, value.dogType)
    }
  }
}

Using this module we can now serialize instances of Cat and Dog.

val format = Json { serializersModule = module }

fun main() {
    println(format.encodeToString<Animal>(AnimalProvider.createCat()))
}

You can get the full code here

{"type":"Cat","catType":"Tabby"}

The next chapter covers JSON features.