-
Notifications
You must be signed in to change notification settings - Fork 618
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support polymorphic subclass serializers with inferred type arguments by refactoring Serializer
s to bind to KType
s instead of / in addition to KClass
s
#2555
Comments
This is a valid request, but it is connected with several complications. Let's take a look at the standard example:
Currently, you have to write
Unlike contextual, where we know type arguments in compile time, for open polymorphism we know only a base class. (e.g. kotlinx.serialization/core/commonMain/src/kotlinx/serialization/modules/SerializersModule.kt Line 153 in cd9f8b0
Here, we have only
It is roughly the same as 2, but in opposite direction. Usually, in json we'll get something like To sum up, we do not have enough information to support this in compile time or runtime. I believe an easier way to solve this is to provide some kind of more sensible replacement to ??? than |
When you use a custom serializer for In many cases you also want to limit the algebraic data types to be present at the Kotlin side, not in the data representation side. This translation requires a custom serializer anyway (which can also handle polymorphic values where appropriate). |
@sandwwraith Thank you for your detailed explanations. I went through the code again and rethought some of the designs. For the complications you mentioned, I still have some questions and I can think of some possible solutions.
This is where I don't quite understand. I don't know a general case where such a kind of polymorphism is used where it's only possible to get runtime class information. Usually, if I call As how I understand kotlinx.serialization works, the compiler plugin first generates companion So I think it is possible to refactor the constructors of
For example, this new signature can be added or replace the one you mentioned: Possible approaches to determine subtype type argumentsThe key point lies in how to refactor a parameterized polymorphic root class's companion Approach 1: invoke type inference in the root class's companion
|
This is a bit trickier than you seem to see. For example for
The problem is that for deserialization it isn't enough. And for some formats not enough at all. For example the XML format requires the ability to enumerate all subtypes (as it has annotations to map tag names to type names) - this might work if passing As a starting point you can probably get away with a custom special polymorphic serializer (that uses your logic instead of the existing logic). As to approach 2. It can not work as it is not possible (generically) at runtime to distinguish between As to the sealed bit, with annotations:
This doesn't work. What As to trying things out, you can do that if you realise that the main thing the compiler plugin does is to generate serializers. You can write your own custom serializers to behave whatever way you want. Even nested type parameters work (for each type parameter a nested serializer is passed (which can have type parameters of its own)). The challenge is when the type parameter is polymorphic as polymorphic serialization erases the subtypes (there is one polymorphic serializer independent of type parameters), if your serializer has special logic you can make the child |
This is where I think I can provide a solution. When the user calls a deserialization function such as
Yes, you are right. This is another reason Approach 2 doesn't work which I missed.
In this approach the subtype serializers have to be specified in the constructor, so the compiler plugin can infer and pass
Thanks for pointing this out. I actually doubted this but I didn't check since Approach 3 is problematic anyway. |
This issue is important for something I am trying to do and I'm glad it is being discussed. To be honest, I do not fully understand everything that has been said. It's a lot of different ideas and issues, but I am pretty sure I get the main point. To sum it up: polymorphic serializers are not able transmit their type argument serializers to their subclasses. This issue really matters for me so I wanted to contribute in some way, so I hope this is helpful. First, here is some tests that demonstrate exactly what problem I had, and also contains a custom workaround I developed. I will discuss the custom workaround, its caveats, and its implications below. Click here to see tests and usage of custom serializer
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Polymorphic
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import matt.json.asolution.solution2.workingGenericPolymorphicSerializer
import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(ExperimentalSerializationApi::class)
class PolymorphicGenericSerializers {
private val instance = SubClass(1)
@Test
fun test1() {
assertFullCircleEquality(serializer<SubClass<Int>>(), instance)
}
@Test
fun test2() {
assertFullCircleEquality(serializer<SuperInterface<Int>>(), instance)
}
@Test
fun test3() {
assertFullCircleEquality(SubClass.serializer(serializer<Int>()), instance)
}
@Test
fun test4() {
assertFullCircleEquality(SuperInterface.serializer(serializer<Int>()), instance)
}
@Test
fun test5() {
assertFullCircleEquality(serializer(SubClass::class, listOf(serializer<Int>()), false), instance)
}
@Test
fun test6() {
testSolution(SubClass(1))
}
private inline fun <reified T> testSolution(instance: SubClass<T>) {
assertFullCircleEquality(workingGenericPolymorphicSerializer(SuperInterface::class, serializer<T>()), instance)
}
private inline fun <reified A, reified B> testSolution2(instance: SuperInterface2<A, B>) {
assertFullCircleEquality(
workingGenericPolymorphicSerializer(
SuperInterface2::class, serializer<A>(),
serializer<B>()
),
instance
)
}
@Test
fun test7() {
testSolution(SubClass(1f))
testSolution(SubClass("abc"))
testSolution(SubClass('a'))
testSolution(SubClass(1.0))
testSolution(SubClass(1L))
testSolution(SubClass(listOf(1, 2, 3)))
testSolution(SubClass(SubClass(1)))
testSolution(SubClass(mapOf("a" to 1)))
testSolution2(SubClass2(1, 'a'))
testSolution2(SubClass2(SubClass(SubClass(1)), SubClass(SubClass2(1.0, 1f))))
}
private fun <T> assertFullCircleEquality(
serializer: KSerializer<T>,
instance: T,
json: Json = Json
) {
val serialized = json.encodeToString(serializer, instance)
val deserialized = json.decodeFromString(serializer, serialized)
assertEquals(instance, deserialized)
}
}
@Polymorphic
@Serializable
data class SubClass<T>(override val value: T) : SuperInterface<T>
@Serializable
sealed interface SuperInterface<T> {
val value: T
}
@Serializable
sealed interface SuperInterface2<A, B>
@Serializable
data class SubClass2<A, B>(
val a: A,
val b: B
) : SuperInterface2<A, B>
@Serializable
data class SubClass3<A, B>(
val a: A,
val b: B
) : SuperInterface2<A, B>
In my tests above, which run on Kotlin/JVM, "test2" and "test4" fail. These failures demonstrate the high level issue and how it practically affects me. The context in which I am trying to use sealed interfaces with type arguments is in a custom web socket protocol in which the server and client are exchanging serialized messages. You can also see in the tests above that I used my custom solution "test6" and "test7". To sum up what this does, basically it gives you a polymorphic serializer that transmits the type arguments to subtypes as you would expect in certain contexts. Click here to see the implementation of the custom serializer
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SealedClassSerializer
import kotlinx.serialization.serializer
import kotlin.reflect.KClass
import kotlin.reflect.typeOf
@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class)
inline fun <reified T : Any> workingGenericPolymorphicSerializer(
baseClass: KClass<T>,
vararg typeArgSerializers: KSerializer<*>
): KSerializer<T> {
val typeArgCount = typeOf<T>().arguments.size
check(typeArgCount == typeArgSerializers.size) {
"base class has $typeArgCount type params, so exactly $typeArgCount typeArgSerializers should be provided (not ${typeArgSerializers.size})"
}
return when (typeArgCount) {
0 -> serializer<T>()
else -> {
val defaultSerializer = serializer(baseClass, typeArgSerializers.toList(), isNullable = false)
defaultSerializer as SealedClassSerializer
val subclasses = baseClass.sealedSubclasses.toTypedArray()
val subclassSerializers = Array<KSerializer<out T>?>(subclasses.size) { null }
subclasses.forEachIndexed { i, it ->
val subclassTypeArgCount = it.typeParameters.size
when {
subclassTypeArgCount > typeArgCount ->
error(
"this will never work, some type args are undefined here"
)
subclassTypeArgCount < typeArgCount ->
TODO(
"gotta sort out which type args are serialized"
)
else -> {
val r = serializer(it, typeArgSerializers.toList(), isNullable = false)
@Suppress("UNCHECKED_CAST")
subclassSerializers[i] = r as KSerializer<out T>
}
}
}
return SealedClassSerializer(
serialName = defaultSerializer.descriptor.serialName,
baseClass = baseClass,
subclasses = subclasses,
subclassSerializers = subclassSerializers.filterNotNull().toTypedArray()
)
}
}
} Basically, I just create a The nice things about this solution are:
The caveats are:
Moving forward, I see the potential for some potentially small-ish changes to this library that could make a significant dent in this overall issue:
|
Having had a look at #2729 it should be possible for this to be implemented (by the plugin) for the sealed base class case where the child type parameter matches the parent type parameter. In such case you would effectively want a |
What is your use-case and why do you need this feature?
The Kotlin compiler can infer some type arguments of a subtype from those of the parent abstract/sealed types. Currently, polymorphic serializers can not make use of such inferred type arguments, either automatically for a sealed type or by registering a polymorphic serializer manually for a concrete parent type and passing the serializer for the concrete subtype with the inferred type arguments. Type parameter information is erased at runtime and one has to register a polymorphic serializer for the inferrable type's upper bound as instructed in the guide. Having such types is a common feature called GADT (generic algebraic data type) in functional programming and is becoming more popular with type parameter support in various languages, so it makes sense to support this with serialization. After searching in issues I see that there are many issues related to polymorphic serialization can be resolved by supporting this feature, such as #944, #1252, and #1784.
Describe the solution you'd like
By reading and trying to refactor part of the implementation code I realized that a major obstacle is that in the current implementation
Serializer
s bind toKClass
s and the generated code by the compiler plugin also passesKClass
arguments. To support such a feature, theSerializer
s, or at least the polymorphicSerializer
s need to also bind to concreteKType
s, and a serializer for a concrete type with specific type arguments should take precedence over a serializer for a class / a type with star-projections.I tried implementing this myself and I realized that there is a huge amount of code to refactor, not only in this repo but also in the Kotlin compiler repo. Such a refactor to the core APIs may change the behavior of existing functionality and break existing tests if not implemented carefully and maintaining API compatibility might be a challenge. If this proposal is OK and my direction is right I can also try to complete the remaining necessary changes and submit a PR.
The text was updated successfully, but these errors were encountered: