Backwards compatibility of Kotlin's default arguments

kotlin

Kotlin’s default arguments can make APIs easier to understand as a user and more effortless to write as a developer. No more manually writing out function overloads with every possible useful combination of arguments. Furthermore, when reading these functions, it’s easy to see what arguments can be left out and what values they use if you do.

That all sounds great, right? Most uses of Kotlin’s default arguments will work perfectly, bar any lousy code you write, but that isn’t really Kotlin’s fault. However, in some use-cases, there is a flaw with how default arguments work. More precisely, evolving the API of a platform without requiring existing code to recompile.

This post will focus on how Kotlin’s default arguments work under the hood, why that affects backwards compatibility, and what you can do about it.

How default arguments work

Kotlin’s default arguments allow you to write a function like:

interface MyInterface {

  fun function(a: A = A(), b: B = B(), c: C = C(), d: D = D(), e: E = E())
}

Where the caller of the function decides which arguments it provides values for and relies on defaults for the rest. You can find a little bit more information on this in the Kotlin docs.

That’s what happens at a high level. For most people writing and using Kotlin code, that is all you’ll need. However, to understand how this can affect backwards compatibility, we’ll need to take a closer look. To do this, we’ll take a look at the generated bytecode.

Below is the Kotlin bytecode generated from the function shown before (large code snippet incoming!):

// ================dev/lankydan/api/MyInterface$DefaultImpls.class =================
// class version 50.0 (50)
// access flags 0x31
public final class dev/lankydan/api/MyInterface$DefaultImpls {


  // access flags 0x1009
  public static synthetic function$default(Ldev/lankydan/api/MyInterface;Ldev/lankydan/api/MyInterface$A;Ldev/lankydan/api/MyInterface$B;
  Ldev/lankydan/api/MyInterface$C;Ldev/lankydan/api/MyInterface$D;Ldev/lankydan/api/MyInterface$E;ILjava/lang/Object;)V
    ALOAD 7
    IFNULL L0
    NEW java/lang/UnsupportedOperationException
    DUP
    LDC "Super calls with default arguments not supported in this target, function: function"
    INVOKESPECIAL java/lang/UnsupportedOperationException.<init> (Ljava/lang/String;)V
    ATHROW
   L0
    ILOAD 6
    ICONST_1
    IAND
    IFEQ L1
   L2
    LINENUMBER 5 L2
    NEW dev/lankydan/api/MyInterface$A
    DUP
    INVOKESPECIAL dev/lankydan/api/MyInterface$A.<init> ()V
    ASTORE 1
   L1
    ILOAD 6
    ICONST_2
    IAND
    IFEQ L3
    NEW dev/lankydan/api/MyInterface$B
    DUP
    INVOKESPECIAL dev/lankydan/api/MyInterface$B.<init> ()V
    ASTORE 2
   L3
    ILOAD 6
    ICONST_4
    IAND
    IFEQ L4
    NEW dev/lankydan/api/MyInterface$C
    DUP
    INVOKESPECIAL dev/lankydan/api/MyInterface$C.<init> ()V
    ASTORE 3
   L4
    ILOAD 6
    BIPUSH 8
    IAND
    IFEQ L5
    NEW dev/lankydan/api/MyInterface$D
    DUP
    INVOKESPECIAL dev/lankydan/api/MyInterface$D.<init> ()V
    ASTORE 4
   L5
    ILOAD 6
    BIPUSH 16
    IAND
    IFEQ L6
    NEW dev/lankydan/api/MyInterface$E
    DUP
    INVOKESPECIAL dev/lankydan/api/MyInterface$E.<init> ()V
    ASTORE 5
   L6
    ALOAD 0
    ALOAD 1
    ALOAD 2
    ALOAD 3
    ALOAD 4
    ALOAD 5
    INVOKEINTERFACE dev/lankydan/api/MyInterface.function (Ldev/lankydan/api/MyInterface$A;Ldev/lankydan/api/MyInterface$B;Ldev/lankydan/api/MyInterface$C;Ldev/lankydan/api/MyInterface$D;Ldev/lankydan/api/MyInterface$E;)V (itf)
    RETURN
    MAXSTACK = 6
    MAXLOCALS = 8

  @Lkotlin/Metadata;(mv={1, 4, 1}, bv={1, 0, 3}, k=3)
  // access flags 0x19
  public final static INNERCLASS dev/lankydan/api/MyInterface$DefaultImpls dev/lankydan/api/MyInterface DefaultImpls
  // compiled from: MyInterface.kt
}

It’s quite likely that most of this doesn’t make sense. I’m not trying to be patronising, as I also struggle to read Kotlin/Java/JVM bytecode, although we can still extract some information from it.

Firstly, an alternative version of MyInterface was generated, dev/lankydan/api/MyInterface$DefaultImpls. I haven’t shown all the bytecode, but if I did, you would see dev/lankydan/api/MyInterface exists and is separate from the default version.

Secondly, a synthetic function was placed inside the previously mentioned MyInterface$DefaultImpls with arguments that don’t precisely match those of the original version in MyInterface. If you look more carefully, you’ll notice that the first argument is an instance of MyInterface and is followed by the same arguments defined in the Kotlin function. Although that is not entirely true, there are two extra arguments, an int and an Object (you can see them in ILjava/lang/Object; where the I represents the int and Ljava/lang/Object speaks for itself).

The next question is, why are these last two arguments there?

If you can understand the bytecode above, then maybe you’ve been able to figure out why they’re there. For the rest of us mortal beings though, I have added the decompiled version that shows it as Java code below:

public static final class DefaultImpls {
  // $FF: synthetic method
  public static void function$default(MyInterface var0, MyInterface.A var1, MyInterface.B var2, 
  MyInterface.C var3, MyInterface.D var4, MyInterface.E var5, int var6, Object var7) {
    if (var7 != null) {
      throw new UnsupportedOperationException("Super calls with default arguments not supported in this target, function: function");
    } else {
      if ((var6 & 1) != 0) {
        var1 = new MyInterface.A();
      }

      if ((var6 & 2) != 0) {
        var2 = new MyInterface.B();
      }

      if ((var6 & 4) != 0) {
        var3 = new MyInterface.C();
      }

      if ((var6 & 8) != 0) {
        var4 = new MyInterface.D();
      }

      if ((var6 & 16) != 0) {
        var5 = new MyInterface.E();
      }

      var0.function(var1, var2, var3, var4, var5);
    }
  }
}

From this version, we can now see how the int and Object are being used.

  • The Object - From what I could find out, the Object is not actually used yet. Supposedly, this Object will be used for adding super calls with default values some time in the future. Here are a few links that indicate this - StackOverflow, StackOverflow and Jetbrains’ YouTrack (currently 4 years old).
  • The int - Determines which arguments use their default values. This is done by executing a bitwise AND (&) against the int and a number that is increasing in powers of 2 (to represent binary numbers with a leading 1, e.g. 2 = 10 and 16 = 10000). The int itself is determined by the call-site depending on which arguments are passed into the function. For example:

    // Kotlin call-site
    myInterface.function(b = MyInterface.B(), c = MyInterface.C())
    // Decompiled code of the call-site
    DefaultImpls.function$default(myInterface, (A)null, new B(), new C(), (D)null, (E)null, 25, (Object)null);

    In this example, the compiler decides to pass in 25 (11001). Let’s quickly do the bitwise operations to see which default values are used. I’ve also put an example below and a link to Wikipedia in case you’re interested in how the bitwise AND works (I haven’t linked to Wikipedia for a while!):

    11001 AND
    00001
    -----
    00001 = 1

    Below are the operations matching the decompiled code:

    Operation
    Binary operation
    Output
    Default value used
    25 & 1
    11001 & 00001
    1
    25 & 2
    11001 & 00010
    0
    25 & 4
    11001 & 00100
    0
    25 & 8
    11001 & 01000
    1
    25 & 16
    11001 & 10000
    1

    These results match up with what we expect. The decompiled code shows that if the bitwise AND returns anything other than 0, then a default value will be supplied. For the call-site above, this means that A, D and E all receive default values, which correctly matches the Kotlin code.

Finally, as seen in the decompiled code, now that all variables are correctly set (var1 to var5), they are passed into the real function, reaching the end of the process.

Hopefully, that all made sense. If not, give it another read. If it still doesn’t, then you can blame me for failing to explain it well enough. You will need this knowledge in the next section to understand how Kotlin’s default arguments affect backwards compatibility.

Why this affects backwards compatibility

First, I’ll define what I mean as “backwards compatibility”. I quite liked how Wikipedia stated it, so I’ll put that below:

Backward compatibility (also known as backwards compatibility) is a property of a system, product, or technology that allows for interoperability with an older legacy system, or with input designed for such a system, especially in telecommunications and computing.

In terms of what I am writing about, it means that a library, framework or platform should evolve and release new versions without breaking existing code.

More precisely, for this post, a function with default arguments can evolve without breaking existing function usages.

We will use the code from before and explore what happens as we evolve the interface’s function declaration. I’ve added it again since it’s so far away now:

interface MyInterface {

  fun function(a: A = A(), b: B = B(), c: C = C(), d: D = D(), e: E = E())
}

While keeping the call-site the same as it represents “existing code”:

myInterface.function(b = MyInterface.B(), c = MyInterface.C())

Before I continue, I want to point out that removing an argument or adding a new one that does not have a default value from an existing function will never be backwards compatible.

// Removing an argumentfun function(a: A = A(), b: B = B(), c: C = C(), d: D = D())
// Adding an argument without a default valuefun function(a: A = A(), b: B = B(), c: C = C(), d: D = D(), e: E = E(), f: F)

You are guaranteed to break any existing code if you do that. The only way to do this without breaking anything is to add an overload with the change in arguments. As this post is focusing on default arguments, I won’t explain these two scenarios any further.

By excluding basic removing and adding, we are left with adding a new argument with a default value. This scenario is different from adding a new one without a default value because existing callers of the function should work. These callers will still be using the previous version of the function, and that’s ok due to their reliance on default values for missing arguments. Therefore the function’s new argument is not enough to break these applications.

// Adding an argument with a default valuefun function(a: A = A(), b: B = B(), c: C = C(), d: D = D(), e: E = E(), f: F = F())

But, and a big but 😏, the existing code will only continue to work if it is recompiled.

Now, this is ok in a lot of situations. Suppose you are using a library or framework and you update them. In that case, you will most likely recompile your project as your application is what starts everything and therefore cannot benefit from the updates without a rebuild.

However, if you are building something that resembles a platform that runs application code written by other developers, then these applications should continue to work without recompilation. A platform should evolve without breaking everything using it, assuming it’s not a major version update. This is where the inner workings of Kotlin’s default arguments unravel.

An analogy for this is, when your operating system updates, you don’t want to also update every application on your machine. Instead, you want them to continue working as they were with the possibility of updating to benefit from any specific improvements.

To understand why it breaks without recompilation, we need to look at the decompiled bytecode of the function and call-site again:

// Decompiled code of the original function with default arguments
public static void function$default(MyInterface var0, MyInterface.A var1, MyInterface.B var2, 
  MyInterface.C var3, MyInterface.D var4, MyInterface.E var5, int var6, Object var7)
// Decompiled code of the call-site
DefaultImpls.function$default(myInterface, (A)null, new B(), new C(), (D)null, (E)null, 25, (Object)null);

Now we’ll introduce the updated version with a new argument with a default value and take a look at the decompiled code:

// Decompiled code of the updated function with default arguments
public static void function$default(MyInterface var0, MyInterface.A var1, MyInterface.B var2,
  MyInterface.C var3, MyInterface.D var4, MyInterface.E var5, MyInterface.F var6, int var7, Object var8)

Note, that this is now the only version of the function in the bytecode.

Let’s match up the old call-site with the function’s new version:

Call-site
Extra argument
Matches
MyInterface
MyInterface
MyInterface.A
MyInterface.A
MyInterface.B
MyInterface.B
MyInterface.C
MyInterface.C
MyInterface.D
MyInterface.D
MyInterface.E
MyInterface.E
int
MyInterface.F
Object
int
-
Object

The call-site no longer passes in the correct inputs for the function. Trying to run the existing code against the updated function will cause the following error (nicely formatted to make it easier to read):

Caused by: java.lang.NoSuchMethodError: 
'void dev.lankydan.api.MyInterface$DefaultImpls.function$default(
  dev.lankydan.api.MyInterface, 
  dev.lankydan.api.MyInterface$A,
  dev.lankydan.api.MyInterface$B,
  dev.lankydan.api.MyInterface$C,
  dev.lankydan.api.MyInterface$D, 
  dev.lankydan.api.MyInterface$E,
  int,
  java.lang.Object
)'

Ok, so we’ve seen what goes wrong when a new argument with a default value is added to a function without recompiling the code using it. Does this mean there is no hope? Stop worrying, and read the next section!

What you can do about it

The previous section showed why platform writers need to be careful when adding new arguments with default values to existing functions. However, I didn’t detail any solutions which I’ll now rectify.

As mentioned previously, depending on the situation, recompiling the depending code might be the simplest solution. If that is a valid choice for you, then I would go with that. From now on, we’ll assume that it’s not a good idea and consider how to keep everything working without recompilation.

Evolving the function without breaking anything will require a new overload. You should treat it the same as removing or adding a non-default argument as it’s the only option you really have (I’ll touch on a completely different choice in a second).

// Original versionfun function(a: A = A(), b: B = B(), c: C = C(), d: D = D(), e: E = E())
// New overload with the extra argumentfun function(a: A = A(), b: B = B(), c: C = C(), d: D = D(), e: E = E(), f: F = F())

As shown earlier, you unfortunately cannot rely on the code to work without handling it yourself.

From a bytecode perspective, this works because there will be a synthetic function for each overload, keeping the original version around so existing applications can use it.

A different choice to circumvent this problem is not to use default arguments at all. I know that sounds odd, but one way to not fall into this trap is to stay on the well-trodden path. In Java, to evolve an API without breaking anything, you would always add a new overload. It worked for Java, so why wouldn’t it work for Kotlin.

The obvious downside is that you lose the benefit of default values altogether, but you’ll have to decide what works best for you and stick to it. Maybe a rule of “no default arguments” is more straightforward to follow than “always add an overload” even though the outcome is ultimately the same (for that singular change).

You might have to include a lot of overloads to keep the same functionality of default arguments.

I’m currently sitting on the side of adding a new overload while keeping default arguments around, but you should choose whatever makes sense for your situation.

Conclusion

Kotlin’s default arguments are beneficial for writing accessible code without explicitly overload a function to express every possible useful combination of arguments. However, they are not perfect. They are not backwards compatible unless the code using an evolved function is also recompiled. For most scenarios, that is perfectly fine, and I do not want to advocate that you remove all reliance on default arguments. Instead, I want to make sure that you have all the information you need to make the correct decision for your application, library, framework or platform.

After looking into this feature, my advice, is to keep freely using default arguments for your application, library or framework code, but if you are writing a platform that runs other developers’ applications, then you must carefully consider your use of this feature.

Written by Dan Newton
Twitter
LinkedIn
GitHub