Quirks of Kotlin - Synthetic Constructors
Apr 14, 2023 1:50:00 PM • Author: Tommaso Brandirali
Today I want to go over a pretty odd bug our team came across recently, and the insights it gave us into the inner workings of the Kotlin language. Here at Ximedes we think Kotlin is awesome, and we do use it on most of our projects. However, like any other language, it does have its quirks, and we think understanding them is part of developing the kind of deep competence in the technologies we use that we pride ourselves on. This specific quirk (spoiler alert!) has to do with the way Kotlin compiles into JVM bytecode, and to find it we had to open up the hood and take a look at the engine.
We came across this bug while working on a project with a Spring Boot stack on Kotlin, with MyBatis as a persistence framework (using the MyBatis Spring Boot Starter package). Our troubles started when we realized that MyBatis was auto-mapping database columns to object class properties by order instead of by name. This is indeed the default behavior, but documentation on it is scarce. It took a decent bit of StackOverflow surfing to learn this, and to find the configuration properties enable-arg-name-based-constructor-automapping
and map-underscore-to-camel-case
, which should have finally convinced MyBatis to cooperate. Instead, things got weirder...
After setting those properties, MyBatis started throwing an exception looking like this:
argNameBasedConstructorAutoMapping is enabled and the class 'com.example.MyModelClass' has multiple constructors, so @AutomapConstructor must be added to one of the constructors.
...huh? Multiple constructors? Our model dataclass only had the standard constructor, where was MyBatis finding another one? We debugged the library until we got the list of constructors MyBatis found for our dataclass and, sure enough, there were two of them. Where was the second one coming from?
The first round of research only brought up discussions of the explicit secondary constructors in Kotlin classes, which we very much did not have. After a badly needed coffee break, refreshed but still clueless, we gave a try at simply pasting the signature of our mystery constructor into Google. Surprisingly, this gave us a lead: constructor default values. Our class constructor did indeed have a default value for one of the arguments.
I will skip the intermediary steps and just get to the interesting stuff; this is what we eventually learned: Kotlin compiles default values to new "synthetic" constructors. Synthetics are a feature of the JVM bytecode used to mark constructs(such as fields, functions, and classes) that were generated during compilation, and are not present in the source code. Kotlin's synthetic constructors have slightly different signatures, with a couple of extra arguments. This is what they look like:
data class User(
val name: String,
val role: String = "user"
)
fun main() {
for (constructor in User::class.java.constructors) {
println(constructor)
}
}
public com.example.User(java.lang.String,java.lang.String)
public com.example.User(java.lang.String,java.lang.String,int,kotlin.jvm.internal.DefaultConstructorMarker)
The synthetic constructor is the second one. Notice the two extra arguments at the end: the Int
one is a bitmask specifying which parameters to pass default values to, while the DefaultConstructorMarker
is a placeholder used to avoid signature conflicts in case there is another constructor with an integer parameter. This is just how Kotlin implements constructor default arguments, when compiling for the JVM. It's a bit of a hack, necessary to make Kotlin's features work within the limited instruction set of the JVM bytecode, by leveraging synthetic constructs. You can check out this StackOverflow response for a more in-depth look at this behavior.
The solution we found for our MyBatis problem was to substitute the Kotlin default value syntax with an explicit secondary constructor, calling the primary one with our default value. This secondary constructor could now be annotated with @AutomapConstructor
to make MyBatis happy. In our previous example, it would have looked like this:
data class User(
val name: String,
val role: String
) {
@AutomapConstructor
constructor(name: String) : this(name, "user")
}
With this fix, our app was running smoothly again. All is well that ends well...
The moral of this story is that building Kotlin on top of the JVM was a choice with tradeoffs. Being able to natively use any library in the vast landscape of Java software is an amazing feature, allowing developers to enjoy the modern features of Kotlin without sacrificing Java's large choice of dependencies and frameworks. However, this comes at a cost: Kotlin had to be built to compile to an instruction set that wasn't made to support all of its features. To make it work, Kotlin's developers had to find ways to fit a square peg into a round hole, resulting in design choices such as the above one. Credit to the language's developers at JetBrains, these workarounds are robust enough that you hardly ever notice them. Occasionally, problems do come up, though, especially with code that makes use of reflection. Figuring out the cause of the problem in such cases can be difficult, because compilers are inherently obscure: they work well if you don't have to worry about how they work.
I hope this writeup might help you avoid some headaches, and give you a better appreciation of just how hard a job the design of programming languages and compilers really is.